fix(ui): rail/footer cleanup — drop admin btn, real username, logout menu, collapse Manage

Mock-review batch (4 items):
- Footer: drop the redundant Admin button — Manage already surfaces every
  admin tab, so only the theme toggle relocates from the retired header.
- Footer: the user chip now shows the real logged-in user.  whoami returns
  user_id but _storePermissions only persisted permissions, so the chip was
  stuck on the "account" placeholder; it is now stored as ts.username and the
  chip repaints once whoami lands (Tier-1 render hook).
- Footer: Log out moves into a click-menu on the user chip (reuses the
  .tab-menu popup chrome; the item clicks the hidden #logout-btn so auth.js
  stays the single owner of logout and its in-flight-refresh race guards).
- Manage: groups start collapsed instead of auto-expanding the first one —
  the rail is a discovery map, not a wall of open links.

Verified end-to-end in the real console shell (headless): chip is a button
showing the user, no admin button in the footer, the menu opens with Log out
which invokes logout, outside-click/Escape close it, no JS errors.
This commit is contained in:
Patrick Buckley
2026-06-07 18:03:12 -07:00
parent 3eb79cf725
commit b33ccb16c5
4 changed files with 133 additions and 16 deletions
+9
View File
@@ -713,6 +713,15 @@ function _storePermissions(data) {
} else {
sessionStorage.removeItem("turnstone_permissions");
}
// Surface the authenticated identity for the rail footer's user chip.
// whoami returns user_id (the username); the shell reads it via ts.username
// and repaints once it lands. Cleared in lockstep with permissions so the
// chip never shows a stale user after logout / revocation.
if (data && data.user_id) {
sessionStorage.setItem("ts.username", data.user_id);
} else {
sessionStorage.removeItem("ts.username");
}
}
function _setBusy(busy, label) {
+4 -4
View File
@@ -331,21 +331,21 @@ export function mountManage(root, paneManager) {
root.replaceChildren();
// If the Admin pane is already open (e.g. restored by PaneManager.rehydrate),
// seed the rail to its current tab + expand the owning group; otherwise the
// first group expands (mock) and nothing is marked until the user clicks.
// seed the rail to its current tab + expand the owning group; otherwise every
// group starts collapsed and nothing is marked until the user clicks.
const adminOpen =
paneManager && paneManager.hasPane && paneManager.hasPane("admin");
const activeTab = adminOpen && TS.getActiveTab ? TS.getActiveTab() : null;
const rowByTab = new Map(); // tab -> its row <button>, for active-state sync
ia.forEach((group, gi) => {
ia.forEach((group) => {
const tabs = group.tabs.filter((t) => allowed(t.tab));
if (!tabs.length) return; // every tab in the group is gated away, drop it
const expanded = activeTab
? group.tabs.some((t) => t.tab === activeTab) // group owning the active tab
: gi === 0; // default: first group open (mock)
: false; // default: every group collapsed — Manage is a discovery map, not a wall of open links
const grp = document.createElement("div");
grp.className = "grp";
+13 -1
View File
@@ -395,7 +395,8 @@
border-color: var(--accent-dim);
color: var(--ink);
}
/* logout starts hidden (app.js toggles it on auth) — keep that behaviour */
/* The user chip is a menu button (click → Log out); reset native button
chrome so it reads as the rail's identity line, not a form control. */
.user-chip {
margin-left: auto;
display: flex;
@@ -403,6 +404,17 @@
gap: 7px;
color: var(--ink-3);
font-size: 12px;
padding: 4px 6px;
margin-right: -6px;
border: none;
border-radius: var(--r-sm);
background: none;
font-family: inherit;
cursor: pointer;
}
.user-chip:hover {
background: var(--panel-2);
color: var(--ink);
}
.avatar {
width: 24px;
+107 -11
View File
@@ -237,18 +237,38 @@ async function mountShell() {
shell.connSlot.replaceWith(statusBarEl);
}
// Relocate the header controls into the rail footer (onclick + ids preserved),
// then retire the now-empty header. Order: theme · admin · logout · user.
for (const id of ["theme-toggle", "admin-btn", "logout-btn"]) {
const btn = document.getElementById(id);
if (btn) shell.foot.append(btn);
}
const userChip = make("span", "user-chip");
userChip.append(make("span", "avatar", initialsFor(caps)));
const nameEl = make("span", null, displayNameFor(caps));
userChip.append(nameEl);
// Relocate ONLY the theme toggle into the rail footer (id + onclick
// preserved), then retire the now-empty header. The Admin button is dropped
// — Manage already surfaces every admin tab, so a separate footer button is
// redundant. Logout moves into the user menu (the #logout-btn stays in the
// hidden header for its wired onclick + auth.js race-guards; the menu clicks it).
const themeBtn = document.getElementById("theme-toggle");
if (themeBtn) shell.foot.append(themeBtn);
// User chip = a menu button: click opens a small popup with Log out. Name +
// avatar come from the whoami identity; the chip is built before whoami lands,
// so it starts at the "account" placeholder and refreshUser() repaints it once
// the real username arrives (see the Tier-1 hook below).
const userChip = make("button", "user-chip");
userChip.type = "button";
userChip.setAttribute("aria-haspopup", "menu");
userChip.setAttribute("aria-expanded", "false");
const avatarEl = make("span", "avatar", initialsFor(caps));
const userNameEl = make("span", "user-name", displayNameFor(caps));
userChip.append(avatarEl, userNameEl);
userChip.addEventListener("click", (e) => {
e.stopPropagation();
toggleUserMenu(userChip);
});
shell.foot.append(userChip);
if (headerEl) headerEl.style.display = "none";
const refreshUser = () => {
const nm = displayNameFor(caps);
if (userNameEl.textContent !== nm) {
userNameEl.textContent = nm;
avatarEl.textContent = initialsFor(caps);
}
};
// ----- PaneManager: one new spine -----
const pm = new PaneManager({
@@ -497,7 +517,10 @@ async function mountShell() {
// rail agree and the glyph never sits stale at an open-time placeholder. One
// Tier-1 writer for the tab glyph; the pane's Tier-2 stream drives its body.
if (window.TS_APP && typeof window.TS_APP.onRender === "function") {
window.TS_APP.onRender(() => paintConvTabGlyphs(pm));
window.TS_APP.onRender(() => {
paintConvTabGlyphs(pm);
refreshUser();
});
}
// Hand off to the legacy boot (login + Tier-1 stream) now that the shell and
@@ -511,6 +534,79 @@ async function mountShell() {
}
}
// --- Footer user menu (Log out lives here) ---------------------------------
// A small popup anchored to the rail-footer user chip. Reuses the .tab-menu
// popup chrome; the one item clicks the hidden #logout-btn so auth.js stays the
// single owner of logout (incl. its in-flight-refresh race guards).
let _userMenuCleanup = null;
function closeUserMenu() {
if (_userMenuCleanup) _userMenuCleanup();
}
function toggleUserMenu(chip) {
if (_userMenuCleanup) {
closeUserMenu();
return;
}
const menu = document.createElement("div");
menu.className = "tab-menu user-menu";
menu.setAttribute("role", "menu");
menu.setAttribute("aria-label", "Account");
const item = document.createElement("button");
item.type = "button";
item.className = "tab-menu-item destructive";
item.setAttribute("role", "menuitem");
const label = document.createElement("span");
label.className = "tab-menu-label";
label.textContent = "Log out";
item.append(label);
item.addEventListener("click", () => {
closeUserMenu();
const lb = document.getElementById("logout-btn");
if (lb) lb.click();
});
menu.append(item);
document.body.append(menu);
// Fixed-positioned, popping UP from the chip (the footer sits at the viewport
// bottom); flip down only if there is no room above.
const ar = chip.getBoundingClientRect();
const mr = menu.getBoundingClientRect();
let x = ar.left;
if (x + mr.width > window.innerWidth) x = window.innerWidth - mr.width - 4;
if (x < 4) x = 4;
let y = ar.top - mr.height - 4;
if (y < 4) y = ar.bottom + 4;
menu.style.left = x + "px";
menu.style.top = y + "px";
chip.setAttribute("aria-expanded", "true");
const onDown = (e) => {
if (!menu.contains(e.target) && !chip.contains(e.target)) closeUserMenu();
};
const onKey = (e) => {
if (e.key === "Escape") {
closeUserMenu();
chip.focus();
}
};
_userMenuCleanup = () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
menu.remove();
chip.setAttribute("aria-expanded", "false");
_userMenuCleanup = null;
};
// Defer the listener attach so the click that opened the menu does not
// immediately close it.
setTimeout(() => {
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
}, 0);
item.focus();
}
function displayNameFor(caps) {
// sessionStorage is populated by auth.js after whoami; fall back gracefully.
try {