feat(ui): L-shell step 7 — live tab state-glyphs (Tier-1, shape+colour)

Conversational tabs now show a live shape+colour state glyph (● ◐ ⚠ ✗ ○) instead
of the static ◆/○ placeholders the header removal (5e.2e) left behind — driven by
the SAME Tier-1 source + builder the rail uses, so tab and rail always agree.

- rail.js: export the glyph() builder (one source of truth for the mapping).
- pane.js: ShellPane.stateful + PaneManager.setTabGlyph/statefulTabs — generic
  (PaneManager owns no glyph vocabulary; the shell passes the built element).
  A stateful pane builds no static glyph; the shell paints a live .ui-glyph.
- shell.js: stateForWs() reads the Tier-1 snapshot; paintConvTabGlyphs() repaints
  every stateful tab on each Tier-1 render (subscribed to TS_APP.onRender) + per
  pane on activate. Coordinator + interactive panes are now stateful.
- shell.css: .tab .tab-glyph spacing (static + live); live glyphs keep their own
  .ui-glyph-* state colour (no .tab .glyph override).

SINGLE WRITER: the tab glyph is written only by the Tier-1 path (the pane's Tier-2
stream drives its body, not the tab) — no two-tier race, no stale open-time
placeholder on reconnect (BRIEFING L144-147). A coordinator-telemetry-parity gap
(open Q#2) would stale tab + rail equally, consistently.

Verified: 27/27 mechanism harness (8 new glyph asserts) + real-stack wiring
harnesses (console coord ui-glyph-running / int ui-glyph-idle; standalone int
ui-glyph-running — matching the stubbed Tier-1 state, errs:[]) + 26 shell JS
guards + CSS audit at baseline.
This commit is contained in:
Patrick Buckley
2026-06-07 13:49:28 -07:00
parent 7448792251
commit e60f5a3108
5 changed files with 117 additions and 12 deletions
+30
View File
@@ -391,3 +391,33 @@ def test_step7_tab_menu_css_promoted_shared() -> None:
assert "color-mix(in oklab, var(--err)" in css, (
"the destructive item must use the DS --err token (the translation happened)"
)
def test_step7_live_tab_state_glyphs() -> None:
"""Step 7 #2: conversational tabs show LIVE state glyphs driven by Tier-1 —
the SAME source + builder the rail uses (one writer; the pane's Tier-2 stream
drives its body, not the tab glyph), so tab and rail agree and the glyph never
sits stale at an open-time placeholder. The coord/int static placeholders are
retired for a `stateful` flag."""
rail = _RAIL_JS.read_text(encoding="utf-8")
assert "export function glyph(" in rail, (
"rail must export its state-glyph builder so the tab reuses it (single source)"
)
pane = _PANE_JS.read_text(encoding="utf-8")
assert "setTabGlyph(paneId, el)" in pane and "statefulTabs()" in pane, (
"PaneManager must expose the generic glyph setter + the stateful-tab list"
)
assert "this.stateful" in pane, "ShellPane must carry the stateful flag"
shell = _SHELL_JS.read_text(encoding="utf-8")
assert 'import { mountRail, mountManage, glyph } from "./rail.js"' in shell, (
"the shell must import the rail's glyph builder (one source for tab + rail)"
)
assert "function stateForWs(" in shell and "function paintConvTabGlyphs(" in shell
assert "window.TS_APP.onRender" in shell and "paintConvTabGlyphs(pm)" in shell, (
"tab glyphs must repaint on every Tier-1 render (live, not stale)"
)
assert shell.count("stateful: true") == 2, (
"the coordinator + interactive panes are stateful (their static placeholders retired)"
)
css = _SHELL_CSS.read_text(encoding="utf-8")
assert ".tab .tab-glyph" in css, "the tab-glyph spacing rule must apply to static + live glyphs"
+30 -2
View File
@@ -31,7 +31,8 @@ export class ShellPane {
this.rawId = opts.id != null ? opts.id : null; // ws_id for keyed panes, null for singletons
this.id = this.rawId == null ? this.type : this.type + ":" + this.rawId;
this.title = opts.title || this.type || "";
this.glyph = opts.glyph || null; // a single char shown in the tab (e.g. "◇"); state glyphs use ui-base .ui-glyph-*
this.glyph = opts.glyph || null; // a single static char shown in the tab (e.g. "◇"); stateful panes use a live .ui-glyph-* instead
this.stateful = opts.stateful || false; // conversational panes: tab glyph tracks live Tier-1 state (set via setTabGlyph)
this.closable = opts.closable !== false; // dashboard is not closable
// DOM — created and owned by the PaneManager on mount:
this.el = null; // <section class="pane">
@@ -109,6 +110,29 @@ export class PaneManager {
this._activeSubs.push(cb);
}
/** Replace a tab's leading state glyph with `el` (a built glyph span). The
* shell drives this from Tier-1 so a conversational tab shows live shape+colour
* state — generic here (PaneManager owns no glyph vocabulary; the shell passes
* the element, reusing the rail's builder so tab and rail render identically). */
setTabGlyph(paneId, el) {
const pane = this._panes.get(paneId);
if (!pane || !pane.tabEl || !el) return;
el.classList.add("tab-glyph"); // tab spacing (margin); colour stays the el's own
if (pane._glyphEl && pane._glyphEl.parentNode === pane.tabEl)
pane._glyphEl.replaceWith(el);
else pane.tabEl.insertBefore(el, pane.tabEl.firstChild);
pane._glyphEl = el;
}
/** Open panes whose tab shows live Tier-1 state — `{id, rawId}[]` (the shell
* repaints these on every Tier-1 render). */
statefulTabs() {
const out = [];
for (const p of this._panes.values())
if (p.stateful && p.rawId != null) out.push({ id: p.id, rawId: p.rawId });
return out;
}
/** Create the pane if absent, then focus it. Auth/cap gating is the caller's.
* `extra` is an optional open-time hint passed straight to the factory (e.g.
* the interactive pane's `{nodeId}` from a rail click); it is NOT persisted,
@@ -253,11 +277,15 @@ export class PaneManager {
tab.setAttribute("role", "tab");
tab.setAttribute("aria-controls", "pane-" + cssId(pane.id));
if (pane.glyph) {
// Static decorative char (Dashboard ◇ / Admin ⚙). Stateful panes get NO
// static glyph — the shell paints a live .ui-glyph onto the slot via
// setTabGlyph (on activate + every Tier-1 render).
const g = document.createElement("span");
g.className = "glyph";
g.className = "glyph tab-glyph";
g.setAttribute("aria-hidden", "true"); // decorative glyph, not read as content
g.textContent = pane.glyph;
tab.append(g);
pane._glyphEl = g;
}
tab.append(document.createTextNode(pane.title));
// Tab-action menu (step 7): a pane that exposes `tabMenu()` gets a caret to
+4 -2
View File
@@ -27,8 +27,10 @@ const STATE_LABEL = {
};
/** A decorative shape+colour state glyph (the row/pill also carries the state
* in text/aria, so the glyph itself is aria-hidden). */
function glyph(state) {
* in text/aria, so the glyph itself is aria-hidden). Exported so the shell can
* paint the SAME glyph onto a conversational tab from the SAME Tier-1 source —
* rail row and tab stay consistent, one writer (no Tier-2 tab-glyph race). */
export function glyph(state) {
const s = GLYPH[state] ? state : "idle";
const el = document.createElement("span");
el.className = "ui-glyph ui-glyph-" + s;
+10 -5
View File
@@ -411,11 +411,17 @@
background: none;
font-family: var(--font-ui);
}
.tab .glyph {
/* Tab glyph spacing — applies to BOTH the static decorative char (Dashboard /
Admin, via .glyph) and the live state glyph (.ui-glyph-* on conversational
tabs). The static glyph is dim --ink-4; the live glyph keeps its own
.ui-glyph-* state colour, so it is NOT given a colour here. */
.tab .tab-glyph {
margin-right: 6px;
color: var(--ink-4);
font-size: 11px;
}
.tab .glyph {
color: var(--ink-4);
}
.tab:hover {
background: var(--panel-2);
}
@@ -424,9 +430,8 @@
border-color: var(--hair-2);
color: var(--ink);
}
/* Active tab's glyph brightens so an open session's `○` placeholder doesn't read
permanently "idle" beside its live (e.g. running ●) rail row. Step 7 makes
tab glyphs live state; this is the interim hint. */
/* Active tab's STATIC glyph (Dashboard / Admin) brightens a touch; live state
glyphs on conversational tabs keep their .ui-glyph-* state colour instead. */
.tab.active .glyph {
color: var(--ink-2);
}
+43 -3
View File
@@ -20,7 +20,7 @@
========================================================================== */
import { PaneManager, ShellPane } from "./pane.js";
import { mountRail, mountManage } from "./rail.js";
import { mountRail, mountManage, glyph } from "./rail.js";
// The interactive pane is a real ES module beside us in /shared (step 5a) — the
// shell imports it directly, and it exists in every deployment. The coordinator
// pane lives at an absolute /static path that only the CONSOLE serves, so it is
@@ -155,6 +155,36 @@ function nodeForWs(wsId) {
// 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).
// The live state of a workstream from the Tier-1 snapshot (the SAME source the
// rail reads) — so a conversational tab's state glyph stays consistent with its
// rail row and updates live rather than sitting at an open-time placeholder.
function stateForWs(wsId) {
try {
const cs =
window.TS_APP &&
window.TS_APP.getClusterState &&
window.TS_APP.getClusterState();
if (cs && cs.nodes) {
for (const nid in cs.nodes) {
for (const ws of cs.nodes[nid].workstreams || []) {
if (ws.id === wsId) return ws.state || "idle";
}
}
}
} catch (e) {
/* snapshot not ready */
}
return "idle";
}
// Repaint every stateful tab's glyph from Tier-1. Subscribed to the render
// signal (one Tier-1 writer for the tab glyph; the pane's Tier-2 stream drives
// its body, not the tab) and called on activate for the initial paint.
function paintConvTabGlyphs(pm) {
for (const t of pm.statefulTabs())
pm.setTabGlyph(t.id, glyph(stateForWs(t.rawId)));
}
function convTabMenu(pane, pm, wsId, opts) {
opts = opts || {};
const G = window;
@@ -294,7 +324,7 @@ async function mountShell() {
const pane = new ShellPane({
type: "interactive",
title: wsTitle(id),
glyph: "○",
stateful: true, // tab shows live Tier-1 state (no static placeholder)
});
pane.tabMenu = () =>
convTabMenu(pane, pm, id, {
@@ -319,6 +349,7 @@ async function mountShell() {
});
};
pane.onActivate = function () {
pm.setTabGlyph(pane.id, glyph(stateForWs(id))); // live Tier-1 state glyph
if (!this._ctl) return;
this._ctl.connect(); // idempotent — opens the stream once, re-marks focus
if (!this._loginArmed && window.TS_LOGIN && this._ctl.onLogin) {
@@ -352,7 +383,7 @@ async function mountShell() {
const pane = new ShellPane({
type: "coordinator",
title: wsTitle(id),
glyph: "◆",
stateful: true, // tab shows live Tier-1 state (no static placeholder)
});
pane.tabMenu = () =>
convTabMenu(pane, pm, id, {
@@ -366,6 +397,7 @@ async function mountShell() {
});
};
pane.onActivate = function () {
pm.setTabGlyph(pane.id, glyph(stateForWs(id))); // live Tier-1 state glyph
if (this._ctl && !this._connected) {
this._connected = true;
this._ctl.connect();
@@ -427,6 +459,14 @@ async function mountShell() {
// `pm` lets the rail seed its active marker when a restored Admin pane is open.
mountManage(shell.manageSec, pm);
// Live tab state-glyphs (step 7): repaint conversational tabs' state glyphs on
// every Tier-1 render — the SAME source + builder the rail uses, so tab and
// 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));
}
// Hand off to the legacy boot (login + Tier-1 stream) now that the shell and
// its status DOM exist.
if (window.TS_APP && typeof window.TS_APP.boot === "function") {