diff --git a/tests/test_app_js.py b/tests/test_app_js.py index 07b45f4c..14feac09 100644 --- a/tests/test_app_js.py +++ b/tests/test_app_js.py @@ -428,9 +428,11 @@ def test_phase8_mcp_error_helpers_defined() -> None: (interactive consent / forbidden / operator card) moved into the shared interactive module with the Pane. The consent-badge state (``_pendingConsentServers`` / ``_onConsentDetected``) stays in the - standalone shell — it drives the settings-gear badge — and the pane reaches - it through the ``host.onConsentDetected`` seam (a no-op in the console, - which has no gear badge). Pin both halves and the seam.""" + standalone shell — it drives the rail's Manage-row badge — and the pane + reaches it through the ``host.onConsentDetected`` seam. The shared host + bridges that seam to the standalone via ``window.TS_APP.onConsentDetected`` + (undefined on the console, so it stays a no-op there). Pin both halves and + the bridge.""" inter = _INTERACTIVE_JS.read_text(encoding="utf-8") assert "function tryParseMcpError" in inter assert "function buildMcpErrorEmbed" in inter @@ -440,14 +442,42 @@ def test_phase8_mcp_error_helpers_defined() -> None: assert "onConsentDetected(s)" in inter, ( "the pane must notify consent through host.onConsentDetected" ) + # The shared host bridges the seam to the standalone subsystem (feature- + # detected, so the console — which never defines the hook — no-ops). + assert "window.TS_APP.onConsentDetected(server)" in inter, ( + "the shared interactive host must bridge onConsentDetected to the TS_APP seam" + ) app = _APP_JS.read_text(encoding="utf-8") assert "_pendingConsentServers" in app assert "function _onConsentDetected" in app - assert "onConsentDetected(server)" in app, ( - "STANDALONE_HOST must wire host.onConsentDetected -> _onConsentDetected" + assert "window.TS_APP.onConsentDetected = _onConsentDetected" in app, ( + "the standalone must expose _onConsentDetected on the TS_APP seam for the pane bridge" ) +def test_consent_badge_drives_rail_manage_row() -> None: + """The pending-consent badge was re-homed off the retired settings gear + (``#settings-btn``, deleted in the L-shell renovation, which silently made + the badge invisible) onto the rail's Manage > Connections row. Classic + app.js can't import the ESM rail module, so it drives the rail's generic + ``setRowBadge`` hook through the ``window.TS_SHELL`` bridge — keyed on the + standalone's Connections tab. Pin the new lane and the absence of the dead + gear lookup.""" + app = _APP_JS.read_text(encoding="utf-8") + # The badge refresh must drive the rail bridge, not the deleted gear. + assert 'getElementById("settings-btn")' not in app, ( + "the consent badge must no longer target the retired #settings-btn gear" + ) + assert "shell.setRowBadge(_CONSENT_BADGE_TAB" in app, ( + "_refreshConsentBadge must drive the rail Manage-row badge via the TS_SHELL bridge" + ) + assert 'const _CONSENT_BADGE_TAB = "connections"' in app, ( + "the standalone badge rides the Connections Manage tab (its MCP surface)" + ) + # The hydrate + clear paths must still funnel through the single refresh. + assert "function loadPendingConsents" in app and "_refreshConsentBadge()" in app + + def test_media_player_activation_not_duplicated_in_standalone() -> None: """The media-player activation (``_loadHls`` / ``_activatePlayer`` + the click/keydown delegate) moved into the shared interactive pane so BOTH the @@ -728,7 +758,9 @@ def test_phase8_css_classes_present_in_stylesheet() -> None: connections render in the Admin pane's Connections panel (#view-admin), not a floating dialog — so #settings-overlay / #settings-box are no longer pinned. The revoke confirm's chrome moved to /shared/hatch.css with the dialog-tier - conversion, so no #revoke-mcp-* rule is pinned here either.""" + conversion, so no #revoke-mcp-* rule is pinned here either. The pending- + consent badge moved off the retired settings gear onto the rail's Manage row + (shell.css `.rail-badge`), so `.settings-consent-badge` is gone from here.""" css = _STYLE_CSS.read_text(encoding="utf-8") for selector in [ ".mcp-error-card", @@ -736,9 +768,13 @@ def test_phase8_css_classes_present_in_stylesheet() -> None: ".mcp-error-action-btn", ".mcp-scope-pill", ".settings-revoke-btn", - ".settings-consent-badge", ]: assert selector in css, f"Missing CSS rule for {selector}" + # The dead gear-badge rule must be GONE (its host #settings-btn was retired). + assert ".settings-consent-badge" not in css, ( + "the retired settings-gear consent badge CSS must be removed " + "(the badge now lives on the rail Manage row — shell.css .rail-badge)" + ) def test_phase8_consent_url_prefix_check_in_click_handler() -> None: diff --git a/tests/test_shell_js.py b/tests/test_shell_js.py index 1ee779b6..d5d2caa1 100644 --- a/tests/test_shell_js.py +++ b/tests/test_shell_js.py @@ -330,6 +330,63 @@ def test_step3_rail_manage_builds_from_admin_seam() -> None: assert "aria-expanded" in body, "collapsible group heads must expose aria-expanded" +def test_rail_manage_row_badge_hook() -> None: + """rail.js owns a GENERIC Manage-row count badge — `setRowBadge(tabKey, count, + label?)` stamps a glyph+count chip on a tab row (DS warn `.rail-badge`), and so + a COLLAPSED group never hides the signal, mirrors the group's running total onto + its head. rail.js stays agnostic about what the count means (no consent + specifics here); a subsystem drives it. `mountManage` registers the row/head + refs and re-applies live counts across a (re)mount. This is the re-homing + target for the MCP consent badge after its settings-gear host was deleted.""" + body = _RAIL_JS.read_text(encoding="utf-8") + assert "export function setRowBadge(tabKey, count, label)" in body, ( + "rail must export the generic setRowBadge hook (mechanism, not meaning)" + ) + # The chip pairs colour with a glyph (never colour alone — chip-contrast rule). + assert "rail-badge" in body and '"⚠"' in body, ( + "the badge must carry a ⚠ glyph alongside the count (not colour alone)" + ) + # The collapsed-group head must carry the group total so a hidden row's signal + # still surfaces — pin the head propagation + the per-group sum. + assert "function _groupCount(" in body, "the head badge must sum the group's tab counts" + assert "_groupEls" in body and "_rowEls" in body, ( + "mountManage must register row + owning-group-head refs for the badge hook" + ) + assert "_reapplyBadges()" in body, ( + "a (re)mount must re-apply any live badge state (the refs are rebuilt)" + ) + # rail.js stays agnostic — the hook takes a generic tabKey/count, with no + # consent-specific endpoint, fetch, or branch (an explanatory comment naming a + # sample caller is fine; logic is not). `setRowBadge` itself never fetches. + badge_fn = body[body.index("export function setRowBadge") :] + badge_fn = badge_fn[: badge_fn.index("\n}\n") + 3] + assert "fetch" not in badge_fn and "/v1/" not in badge_fn, ( + "the generic badge hook must not reach into a subsystem (no fetch / endpoint)" + ) + # No colour-only treatment: the chip uses DS warn tokens AND a glyph. + css = _SHELL_CSS.read_text(encoding="utf-8") + assert ".rail-badge" in css, "shell.css must carry the .rail-badge chip rule" + badge_block = css[css.index(".rail-badge") :] + badge_block = badge_block[: badge_block.index("\n.grp") if "\n.grp" in badge_block else 800] + assert "var(--warn" in badge_block, ( + "the badge chip must use the DS --warn family (theme-flips by construction)" + ) + assert "#" not in badge_block, "the badge chip must be token-only (no hex) so themes flip" + + +def test_shell_bridges_setrowbadge_for_classic_subsystems() -> None: + """shell.js is the ESM module bridge: a classic-script subsystem (the + standalone consent badge in ui/static/app.js) can't import rail.js, so the + shell re-exports `setRowBadge` on the `window.TS_SHELL` seam. Pin the import + and the seam so the bridge can't be silently dropped (which would re-break the + badge the same way the gear deletion did).""" + body = _SHELL_JS.read_text(encoding="utf-8") + assert 'setRowBadge } from "./rail.js"' in body, "shell must import setRowBadge from rail.js" + assert "notifySessionClosed, setRowBadge }" in body, ( + "TS_SHELL must expose setRowBadge for classic subsystems (the consent-badge bridge)" + ) + + def test_step3_admin_seam_and_thin_show_admin() -> None: """admin.js exposes the TS_ADMIN seam (IA + shared perm gate + active-tab + openTab) and showAdmin is now a thin delegator that opens the singleton @@ -615,7 +672,7 @@ def test_step7_live_tab_state_glyphs() -> None: ) 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, ( + assert 'import { mountRail, mountManage, glyph, setRowBadge } 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 paintConvTabs(" in shell @@ -749,7 +806,7 @@ def test_shell_marks_pane_dead_on_ws_closed() -> None: assert "const notifySessionClosed = (wsId)" in shell assert 'pm.getPane("interactive", wsId)' in shell assert "p._ctl.markDead()" in shell - assert "window.TS_SHELL = { panes: pm, caps, notifySessionClosed }" in shell, ( + assert "window.TS_SHELL = { panes: pm, caps, notifySessionClosed, setRowBadge }" in shell, ( "the seam must be exported on TS_SHELL for the console's Tier-1 handler" ) app = _CONSOLE_APP.read_text(encoding="utf-8") diff --git a/turnstone/shared_static/interactive.js b/turnstone/shared_static/interactive.js index 18e99926..e6896f6d 100644 --- a/turnstone/shared_static/interactive.js +++ b/turnstone/shared_static/interactive.js @@ -3403,10 +3403,18 @@ function createInteractivePane(root, wsId, opts) { warningTarget(pane) { return pane.messagesEl; }, - // MCP re-consent surfaces inline in the pane card; the console has no - // settings-gear badge to drive (a future console consent surface can hook - // here). - onConsentDetected() {}, + // MCP re-consent surfaces inline in the pane card; the STANDALONE additionally + // drives a Manage-row attention badge — bridged through the TS_APP seam so the + // shared factory stays deployment-agnostic (the console doesn't define the + // hook, so this stays a no-op there). + onConsentDetected(server) { + if ( + window.TS_APP && + typeof window.TS_APP.onConsentDetected === "function" + ) { + window.TS_APP.onConsentDetected(server); + } + }, }; const pane = new Pane(wsId, { diff --git a/turnstone/shared_static/rail.js b/turnstone/shared_static/rail.js index 22261a33..bd2729a3 100644 --- a/turnstone/shared_static/rail.js +++ b/turnstone/shared_static/rail.js @@ -39,6 +39,24 @@ export function glyph(state) { return el; } +/** A small count chip for a Manage row/group head — glyph + count (never colour + * alone), DS warn vocabulary (shell.css `.rail-badge`). The count rides the + * text; the ⚠ glyph is aria-hidden because the supplied `label` already names + * the condition for assistive tech. Returns a detached span the caller mounts. */ +function badge(count, label) { + const el = document.createElement("span"); + el.className = "rail-badge"; + const g = document.createElement("span"); + g.className = "rail-badge-glyph"; + g.setAttribute("aria-hidden", "true"); + g.textContent = "⚠"; // ⚠ — pairs the colour with a glyph (chip-contrast rule) + const n = document.createElement("b"); + n.textContent = String(count); + el.append(g, n); + if (label) el.setAttribute("aria-label", label); + return el; +} + /** Derive a node's overall state glyph from its workstream mix + health. */ function nodeState(info) { if (!info.reachable) return "error"; @@ -325,6 +343,80 @@ export function mountRail(sections, caps) { // ---- Manage section (admin IA → collapsible discovery groups) -------------- +// Generic Manage-row badge state. A subsystem (e.g. the standalone consent +// badge) drives a count onto a tab row by KEY via `setRowBadge`; rail.js stays +// agnostic about what the count means. Kept module-level so a `mountManage` +// rebuild (the IA is static, but the section re-mounts on shell init) re-applies +// the live counts rather than dropping them. `null` label = clear. +const _rowBadges = new Map(); // tabKey -> { count, label } +// Rebuilt each mount: the row