feat(ui): L-shell step 7 — tab-action dropdown (three-verb close + per-persona verbs)

PaneManager tabs gain a caret opening a generic, keyboard-navigable action
dropdown — recovering the affordances the pane-header removal (5e.2e) dropped.
The mechanism is generic; the item set is pane-type AND deployment derived.

- pane.js: the caret (a <span>, not a nested <button>) + _openTabMenu/_closeTabMenu
  — singleton, right-anchored under the caret with overflow flip + viewport clamp,
  Arrow/Home/End/Esc/Tab nav, ContextMenu/Shift+F10 + right-click open.
- shell.css: the .tab-menu chrome promoted to the SHARED sheet (both deployments),
  recovered from the retired .ws-tab-dropdown design but translated onto the DS
  token vocabulary (--panel-2/--hair-2/--ink-*/--err).
- shell.js: convTabMenu wires each type by capability/feature-detection —
    coordinator: Export · Close pane · Close workstream (its controller's
      closeSession — the Export + end removed from its header land here)
    standalone interactive: Refresh/Edit/Fork · Export · Close pane ·
      Close workstream · Delete (classic ui/static globals)
    console interactive: Export · Close pane (those globals are standalone-only)
    admin: Close pane
  Three-verb close is load-bearing: Close pane (drop tab) != Close workstream
  (stop session) != Delete (destroy + unsave).

Designer-reviewed both personas, dark+light: resting danger cue on Delete (never
colour-alone), elevated --panel-2 surface, accent-wash hover, light key-hint AA,
viewport y-clamp + max-height.

Verified: 19/19 mechanism harness + real-stack wiring harnesses (all three menus,
errs:[]) + 25 shell JS guards + CSS audit at baseline (zero new flips).
This commit is contained in:
Patrick Buckley
2026-06-07 13:36:58 -07:00
parent 9cb2b5d831
commit 7448792251
4 changed files with 430 additions and 1 deletions
+69
View File
@@ -23,6 +23,7 @@ _ROOT = Path(__file__).resolve().parent.parent
_SHARED = _ROOT / "turnstone/shared_static"
_SHELL_JS = _SHARED / "shell.js"
_PANE_JS = _SHARED / "pane.js"
_SHELL_CSS = _SHARED / "shell.css"
_CONSOLE_INDEX = _ROOT / "turnstone/console/static/index.html"
_CONSOLE_APP = _ROOT / "turnstone/console/static/app.js"
_CONSOLE_ADMIN = _ROOT / "turnstone/console/static/admin.js"
@@ -322,3 +323,71 @@ def test_step5d_rail_open_marker_tracks_active_pane() -> None:
# The Dashboard row is no longer unconditionally open; a session row gets it.
assert 'dash.className = "row open"' not in rail
assert "active && active.rawId === ws.id" in rail
def test_step7_tab_dropdown_mechanism() -> None:
"""Step 7: PaneManager owns the GENERIC tab-action dropdown — a caret on any
pane that exposes ``tabMenu()`` (the Dashboard home exposes none, so no
caret), opening a keyboard-navigable ``.tab-menu``. The caret is a <span>,
NOT a nested <button> inside the tab <button> (invalid markup); the menu is
reachable by keyboard via ContextMenu / Shift+F10, and a single menu is open
at a time (Escape / outside-click close it)."""
body = _PANE_JS.read_text(encoding="utf-8")
assert "_openTabMenu(" in body and "_closeTabMenu(" in body, (
"PaneManager must own the dropdown open/close mechanism"
)
assert 'typeof pane.tabMenu === "function"' in body, (
"the caret is gated on the pane exposing a tabMenu() descriptor"
)
assert '"tab-caret"' in body, "the tab must get a caret affordance"
for cls in ('"tab-menu"', '"tab-menu-item"', '"tab-menu-sep"'):
assert cls in body, f"the dropdown must build the namespaced {cls} chrome"
assert "ContextMenu" in body and "F10" in body, (
"the menu must be keyboard-openable (ContextMenu / Shift+F10)"
)
assert 'e.key === "Escape"' in body, "Escape must close the open menu"
def test_step7_tab_menu_wired_per_persona() -> None:
"""Step 7: the shell wires each pane type's tab menu via convTabMenu —
pane-type AND deployment derived. The load-bearing recovery: the coordinator
header's removed Export + end (5e.2e) return here as Export + Close workstream
(its controller's closeSession). The three-verb close (Close pane = pm.close
!= Close workstream != Delete) is the spine; the standalone interactive verbs
are feature-detected globals, so the console degrades to a reduced menu."""
shell = _SHELL_JS.read_text(encoding="utf-8")
assert "function convTabMenu(" in shell, "the shared tab-menu builder must exist"
assert shell.count("pane.tabMenu =") >= 3, (
"coordinator, interactive, and admin panes must each expose a tabMenu"
)
# The three-verb close — the BRIEFING's load-bearing distinction.
assert '"Close pane"' in shell and "pm.close(pane.id)" in shell
assert '"Close workstream"' in shell, "Close workstream must be distinct from Close pane"
assert '"Delete"' in shell
# Coordinator recovery: Close workstream routes to the controller's server-close.
assert "pane._ctl.closeSession()" in shell, (
"the coordinator's Close workstream must call the controller's closeSession "
"(the end button removed from its header in 5e.2e)"
)
assert "exportWorkstreamDownload" in shell, "Export conversation must wire the shared util"
# Deployment-aware: the standalone interactive verbs are feature-detected globals.
assert 'typeof window.closeWorkstream === "function"' in shell, (
"the interactive Close workstream is a standalone-only global (feature-detected)"
)
assert "refreshWorkstreamTitle" in shell and "confirmDeleteWorkstream" in shell, (
"the interactive title/delete verbs are feature-detected standalone globals"
)
def test_step7_tab_menu_css_promoted_shared() -> None:
"""Step 7: the dropdown chrome is promoted to the SHARED shell sheet (so both
deployments render it), recovered from the retired .ws-tab-dropdown design but
translated onto the DS token vocabulary (--panel/--hair/--ink-*/--err, not the
retired ui/static --bg-surface/--border/--fg-*)."""
css = _SHELL_CSS.read_text(encoding="utf-8")
for sel in (".tab-caret", ".tab-menu", ".tab-menu-item", ".tab-menu-sep", ".tab-menu-key"):
assert sel in css, f"shell.css must carry the {sel} chrome"
assert "@keyframes tab-menu-in" in css, "the dropdown entrance keyframe must be defined"
assert "color-mix(in oklab, var(--err)" in css, (
"the destructive item must use the DS --err token (the translation happened)"
)
+157 -1
View File
@@ -69,6 +69,7 @@ export class PaneManager {
this._order = []; // paneId[] — tab order
this._activeId = null;
this._activeSubs = []; // active-pane-change listeners (e.g. the rail marker)
this._openMenu = null; // the currently-open tab-action dropdown, if any
// The tab bar is a WAI-ARIA tablist; arrow keys rove focus across the open
// tabs (delegated, so it survives tab reconciliation).
if (this.tabbarEl) {
@@ -200,6 +201,7 @@ export class PaneManager {
close(paneId) {
const pane = this._panes.get(paneId);
if (!pane || pane.closable === false) return;
this._closeTabMenu(); // a dropdown anchored on the closing tab must not strand
try {
pane.onClose();
} catch (e) {
@@ -258,7 +260,31 @@ export class PaneManager {
tab.append(g);
}
tab.append(document.createTextNode(pane.title));
tab.addEventListener("click", () => this.activate(pane.id));
// Tab-action menu (step 7): a pane that exposes `tabMenu()` gets a caret to
// the right of its label that opens the action dropdown (the three-verb close
// + per-persona verbs). The Dashboard home tab exposes none, so it gets no
// caret. The caret is a <span>, NOT a nested <button> (invalid inside the
// tab <button>); a click is routed to the menu vs activation by its target.
if (typeof pane.tabMenu === "function") {
const caret = document.createElement("span");
caret.className = "tab-caret";
caret.setAttribute("aria-hidden", "true");
caret.textContent = "▾"; // down-caret menu affordance
tab.append(caret);
tab.setAttribute("aria-haspopup", "menu");
tab.setAttribute("aria-expanded", "false");
}
tab.addEventListener("click", (e) => {
if (typeof pane.tabMenu === "function" && e.target.closest(".tab-caret"))
this._openTabMenu(tab, pane);
else this.activate(pane.id);
});
// Right-click / long-press parity with the caret.
tab.addEventListener("contextmenu", (e) => {
if (typeof pane.tabMenu !== "function") return;
e.preventDefault();
this._openTabMenu(tab, pane);
});
pane.tabEl = tab;
return tab;
}
@@ -279,6 +305,16 @@ export class PaneManager {
const tabs = Array.from(this.tabbarEl.querySelectorAll('[role="tab"]'));
const i = tabs.indexOf(document.activeElement);
if (i < 0) return;
// ContextMenu key / Shift+F10 opens the focused tab's action menu — keyboard
// parity with the caret click (the caret itself is a decorative span).
if (e.key === "ContextMenu" || (e.shiftKey && e.key === "F10")) {
const pane = this._panes.get(tabs[i].dataset.paneId);
if (pane && typeof pane.tabMenu === "function") {
e.preventDefault();
this._openTabMenu(tabs[i], pane);
}
return;
}
let j = i;
if (e.key === "ArrowRight" || e.key === "ArrowDown")
j = (i + 1) % tabs.length;
@@ -291,6 +327,126 @@ export class PaneManager {
tabs[j].focus();
}
/** Open a pane's tab-action dropdown, anchored under its caret. Generic: the
* item set comes from `pane.tabMenu()` (wired per type in the shell); the
* PaneManager owns only the chrome + keyboard + positioning. Singleton menu —
* opening one closes any other. Items are
* `{label, key?, cls?, separator?, action}`. */
_openTabMenu(tab, pane) {
this._closeTabMenu();
let items;
try {
items = pane.tabMenu() || [];
} catch (e) {
console.error("PaneManager: tabMenu() failed", pane.id, e);
return;
}
if (!items.length) return;
const menu = document.createElement("div");
menu.className = "tab-menu";
menu.setAttribute("role", "menu");
menu.setAttribute("aria-label", (pane.title || "Pane") + " actions");
for (const item of items) {
if (item.separator) {
const sep = document.createElement("div");
sep.className = "tab-menu-sep";
sep.setAttribute("role", "separator");
menu.append(sep);
continue;
}
const btn = document.createElement("button");
btn.type = "button";
btn.className = "tab-menu-item" + (item.cls ? " " + item.cls : "");
btn.setAttribute("role", "menuitem");
btn.tabIndex = -1;
const label = document.createElement("span");
label.className = "tab-menu-label";
label.textContent = item.label;
btn.append(label);
if (item.key) {
const key = document.createElement("span");
key.className = "tab-menu-key";
key.setAttribute("aria-hidden", "true"); // a visual hint, not the action
key.textContent = item.key;
btn.append(key);
}
btn.addEventListener("click", () => {
this._closeTabMenu();
try {
item.action();
} catch (e) {
console.error("PaneManager: tab action failed", item.label, e);
}
});
menu.append(btn);
}
document.body.append(menu);
// Position fixed, right-aligned under the caret; flip up / clamp on overflow.
const anchor = tab.querySelector(".tab-caret") || tab;
const ar = anchor.getBoundingClientRect();
const mr = menu.getBoundingClientRect();
let x = ar.right - mr.width;
let y = ar.bottom + 2;
if (x < 4) x = 4;
if (x + mr.width > window.innerWidth) x = window.innerWidth - mr.width - 4;
if (y + mr.height > window.innerHeight) y = ar.top - mr.height - 2;
if (y < 4) y = 4; // never strand the menu above the viewport (short window)
menu.style.left = x + "px";
menu.style.top = y + "px";
tab.setAttribute("aria-expanded", "true");
const onKey = (e) => {
const btns = Array.from(menu.querySelectorAll(".tab-menu-item"));
if (e.key === "Escape" || e.key === "Tab") {
e.preventDefault();
this._closeTabMenu();
tab.focus();
} else if (
e.key === "ArrowDown" ||
e.key === "ArrowUp" ||
e.key === "Home" ||
e.key === "End"
) {
e.preventDefault();
if (!btns.length) return;
const idx = btns.indexOf(document.activeElement);
if (e.key === "ArrowDown") btns[(idx + 1) % btns.length].focus();
else if (e.key === "ArrowUp")
btns[(idx - 1 + btns.length) % btns.length].focus();
else if (e.key === "Home") btns[0].focus();
else btns[btns.length - 1].focus();
}
};
const onDown = (e) => {
if (!menu.contains(e.target) && !tab.contains(e.target))
this._closeTabMenu();
};
this._openMenu = { menu, tab, onKey, onDown };
document.addEventListener("keydown", onKey);
// Defer the outside-mousedown attach a tick so the opening click that
// bubbled to document doesn't immediately re-close the menu.
setTimeout(() => {
if (this._openMenu && this._openMenu.menu === menu)
document.addEventListener("mousedown", onDown);
}, 0);
const first = menu.querySelector(".tab-menu-item");
if (first) first.focus();
}
/** Tear down the open tab-action dropdown (if any) + its document listeners. */
_closeTabMenu() {
const m = this._openMenu;
if (!m) return;
this._openMenu = null;
document.removeEventListener("keydown", m.onKey);
document.removeEventListener("mousedown", m.onDown);
if (m.menu.parentNode) m.menu.parentNode.removeChild(m.menu);
if (m.tab && m.tab.isConnected)
m.tab.setAttribute("aria-expanded", "false");
}
_persist() {
try {
const order = this._order.map((paneId) => {
+130
View File
@@ -430,6 +430,33 @@
.tab.active .glyph {
color: var(--ink-2);
}
/* Tab-action caret — opens the pane's action dropdown (step 7). A decorative
span inside the tab <button> (a nested <button> is invalid markup); the menu
it triggers is keyboard-reachable via ContextMenu / Shift+F10 on the focused
tab. Dim by default, brightening on tab hover / active / open. */
.tab-caret {
margin-left: 6px;
margin-right: -3px;
padding: 2px 3px;
color: var(--ink-4);
font-size: 10px;
line-height: 1;
border-radius: var(--r-sm);
transition:
color 0.1s,
background 0.1s;
}
.tab:hover .tab-caret,
.tab.active .tab-caret {
color: var(--ink-2);
}
.tab-caret:hover,
.tab[aria-expanded="true"] .tab-caret {
color: var(--ink);
}
.tab-caret:hover {
background: var(--hair-2);
}
.tab-add {
width: 28px;
height: 28px;
@@ -455,6 +482,109 @@
color: var(--ink-4);
}
/* Tab-action dropdown — promoted to the shared shell sheet so the console and
the standalone server both render it. Recovers the retired `.ws-tab-dropdown`
design (git 92ad5bd4) translated onto the DS token vocabulary (the old sheet
used --bg-surface/--border/--fg-*; the shell speaks --panel/--hair/--ink-*). */
.tab-menu {
position: fixed;
z-index: 300;
min-width: 184px;
max-height: calc(
100vh - 16px
); /* never taller than the viewport — scroll instead */
overflow-y: auto;
padding: 4px 0;
/* --panel-2 (a step up from the page/tabbar --panel) reads as an elevated
popup; the shadow + --hair-2 border carry the rest. */
background: var(--panel-2);
border: 1px solid var(--hair-2);
border-radius: var(--r-md);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
animation: tab-menu-in 0.1s ease-out;
}
[data-theme="light"] .tab-menu {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
.tab-menu-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: 100%;
padding: 7px 14px;
border: none;
background: none;
color: var(--ink);
font-family: var(--font-ui);
font-size: 13px;
text-align: left;
white-space: nowrap;
cursor: pointer;
transition: background 0.1s;
}
.tab-menu-item:hover,
.tab-menu-item:focus-visible {
/* a visible delta from the --panel-2 surface on BOTH themes; reuses the rail
.row.open accent wash so the highlight reads as one vocabulary. */
background: color-mix(in srgb, var(--accent) 8%, var(--panel-2));
}
.tab-menu-item:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
/* Delete carries a RESTING danger cue (red-tinted label + key), not colour-on-
hover-only — the brief's "never colour alone" rule. ("Delete" is itself the
non-colour cue; the tint is the at-a-glance scan affordance.) ~7:1 at rest. */
.tab-menu-item.destructive {
color: color-mix(in srgb, var(--err) 80%, var(--ink-2));
}
.tab-menu-item.destructive .tab-menu-key {
color: color-mix(in srgb, var(--err) 55%, var(--ink-4));
}
.tab-menu-item.destructive:hover,
.tab-menu-item.destructive:focus-visible {
color: var(--err);
background: color-mix(in oklab, var(--err) 14%, transparent);
}
.tab-menu-item.destructive:focus-visible {
outline-color: var(--err);
}
.tab-menu-label {
flex: 1;
}
.tab-menu-key {
flex-shrink: 0;
font-family: var(--font-mono);
font-size: 11px;
color: var(--ink-4);
}
/* light --ink-4 on the menu surface is ~4:1 (sub-AA for this 11px text) — one
step up; dark --ink-4 already clears AA so it keeps the dimmer hint. */
[data-theme="light"] .tab-menu-key {
color: var(--ink-3);
}
.tab-menu-sep {
height: 1px;
margin: 6px 0;
background: var(--hair-2);
}
@keyframes tab-menu-in {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.tab-menu {
animation: none;
}
}
/* pane host — ONE pane visible per tab (no split; the mock's 2-up was a
display device only, so this is a single column, not `1fr 1fr`). */
.panes {
+74
View File
@@ -148,6 +148,62 @@ function nodeForWs(wsId) {
return null;
}
// Tab-action menu items for a conversational pane — the three-verb close plus
// the per-persona verbs. Pane-type-derived AND deployment-aware: a verb appears
// only when its handler exists here, so the SAME shell yields the full menu in
// the standalone (whose interactive verbs are classic globals in ui/static's
// app.js) and a reduced menu in the console — the capability-derived-affordances
// thesis applied to the tab menu. `opts`: titleVerbs (Refresh/Edit/Fork title),
// deleteVerb (the destructive Delete), closeSession (stop the workstream itself).
function convTabMenu(pane, pm, wsId, opts) {
opts = opts || {};
const G = window;
const items = [];
if (opts.titleVerbs) {
if (typeof G.refreshWorkstreamTitle === "function")
items.push({
label: "Refresh title",
action: () => G.refreshWorkstreamTitle(wsId),
});
if (typeof G.editWorkstreamTitle === "function")
items.push({
label: "Edit title",
key: "Ctrl+Shift+E",
action: () => G.editWorkstreamTitle(wsId),
});
if (typeof G.forkWorkstream === "function")
items.push({
label: "Fork",
key: "Ctrl+Shift+F",
action: () => G.forkWorkstream(wsId),
});
}
if (typeof G.exportWorkstreamDownload === "function")
items.push({
label: "Export conversation",
action: () => G.exportWorkstreamDownload(wsId),
});
items.push({ separator: true });
// Close pane — drop the tab, leave the session running (PaneManager-level).
items.push({
label: "Close pane",
key: "Ctrl+W",
action: () => pm.close(pane.id),
});
// Close workstream — stop the session itself (distinct from closing the tab).
if (opts.closeSession)
items.push({ label: "Close workstream", action: opts.closeSession });
// Delete — destroy + unsave (interactive standalone only; confirms itself).
if (opts.deleteVerb && typeof G.confirmDeleteWorkstream === "function")
items.push({
label: "Delete",
key: "Ctrl+Shift+X",
cls: "destructive",
action: () => G.confirmDeleteWorkstream(wsId),
});
return items;
}
async function mountShell() {
const caps = window.TURNSTONE_SHELL_CAPS || {};
@@ -214,6 +270,9 @@ async function mountShell() {
// dashboard pane already hosts #main, so #view-admin moves out of it here.
pm.registerType("admin", () => {
const pane = new ShellPane({ type: "admin", title: "Admin", glyph: "⚙" });
pane.tabMenu = () => [
{ label: "Close pane", key: "Ctrl+W", action: () => pm.close(pane.id) },
];
pane.onMount = function () {
if (viewAdminEl) {
viewAdminEl.style.display = ""; // clear the inline display:none guard
@@ -237,6 +296,15 @@ async function mountShell() {
title: wsTitle(id),
glyph: "○",
});
pane.tabMenu = () =>
convTabMenu(pane, pm, id, {
titleVerbs: true,
deleteVerb: true,
closeSession:
typeof window.closeWorkstream === "function"
? () => window.closeWorkstream(id)
: null,
});
pane.onMount = function () {
// Node-proxy transport only exists in a cluster deployment (the console).
// On a single-node standalone (caps.cluster=false) every session is LOCAL,
@@ -286,6 +354,12 @@ async function mountShell() {
title: wsTitle(id),
glyph: "◆",
});
pane.tabMenu = () =>
convTabMenu(pane, pm, id, {
closeSession: () => {
if (pane._ctl && pane._ctl.closeSession) pane._ctl.closeSession();
},
});
pane.onMount = function () {
this._ctl = createCoordinatorPane(this.bodyEl, id, {
onClose: () => pm.close(pane.id),