diff --git a/src/observability/dashboard/ui/pages/approvals.js b/src/observability/dashboard/ui/pages/approvals.js
index 641c856..721e5f2 100644
--- a/src/observability/dashboard/ui/pages/approvals.js
+++ b/src/observability/dashboard/ui/pages/approvals.js
@@ -50,6 +50,31 @@ function renderOpsAuditRejections(s) {
setHTML('ops-audit-list', items.map(opsAuditRejectionHtml).join('') || emptyHtml('No audit rejections', 'Every approval record read in the recent event window passed the consistency audit.'));
}
+// #544: the gate as a state machine rather than a colour.
+//
+// Every part is present in EVERY state — undrawn or transparent, never
+// absent. A part rendered only for its own state would be a new node at the
+// flip, and a new node cannot transition, so the change would never animate
+// on the one occasion it means something (the #541 Proof Rail lesson).
+//
+// The shackle carries the meaning: shut while a human is awaited, open once
+// passage is granted, and shut AGAIN once a one-shot override is spent —
+// which is the thing the card's colour alone could never say.
+//
+// pathLength="1" keeps the dash resolution-independent (the #525 idiom).
+// aria-hidden because the pill beside it already states the status in words;
+// a second announcement would be noise, not access.
+function gateGlyphHtml(status) {
+ return '' +
+ '' +
+ ' ' +
+ ' ' +
+ ' ' +
+ ' ' +
+ ' ' +
+ ' ';
+}
+
function approvalHtml(item, canAct) {
var status = item.status || 'pending';
// CONSUMED lifecycle (#156): the server cross-references the run-level
@@ -65,7 +90,8 @@ function approvalHtml(item, canAct) {
: isOverride
? '
' + esc(item.title || item.type || 'Approval required') + '
' + esc(item.detail || item.reason || '') + '
' + overrideNote + '
' + esc(shortName(item.projectRoot)) + ' ' + esc((item.runId || '').slice(-16)) + ' ' + timeHtml(item.ts) + '
' + pill(status, status) + '
' +
+ return '
' + esc(item.title || item.type || 'Approval required') + '
' + esc(item.detail || item.reason || '') + '
' + overrideNote + '
' + esc(shortName(item.projectRoot)) + ' ' + esc((item.runId || '').slice(-16)) + ' ' + timeHtml(item.ts) + '
' +
+ '
' + gateGlyphHtml(status) + pill(status, status) + '
' +
(canAct ? '
Approve Reject
' : '') +
'
';
}
diff --git a/src/observability/dashboard/ui/styles.js b/src/observability/dashboard/ui/styles.js
index bd08eea..769bb85 100644
--- a/src/observability/dashboard/ui/styles.js
+++ b/src/observability/dashboard/ui/styles.js
@@ -1162,6 +1162,51 @@ tr.clickable:hover td { background: #f8fbff; }
entirely while its pill stayed styled. It is a SPENT credential, not a
standing approval — muted, and distinct from the green of a live one. */
.approval-card.consumed { border-left: 4px solid var(--line-strong); opacity: .72; background: var(--soft); }
+
+/* ── #544: the gate glyph — a state machine, not four pictures ─────────────
+ Every part is always in the DOM; state only moves and reveals it, so the
+ nodes survive the morph and their transitions are the animation. The
+ shackle is the sentence: shut / open / shut again. */
+.approval-state { display: flex; align-items: center; gap: 8px; }
+.gate-glyph { display: inline-flex; flex: 0 0 auto; width: 26px; height: 26px; }
+.gate-glyph svg { width: 100%; height: 100%; overflow: visible; }
+.gate-shackle {
+ fill: none; stroke: var(--muted); stroke-width: 2.4; stroke-linecap: round;
+ transform-origin: 10px 14px;
+ transition: transform var(--motion-base) var(--motion-ease-emphatic), stroke var(--motion-base) var(--motion-ease);
+}
+.gate-body { fill: var(--panel); stroke: var(--muted); stroke-width: 2.2; transition: stroke var(--motion-base) var(--motion-ease), fill var(--motion-base) var(--motion-ease); }
+.gate-check {
+ fill: none; stroke: var(--green); stroke-width: 2.6; stroke-linecap: round; stroke-linejoin: round;
+ stroke-dasharray: 1; stroke-dashoffset: 1; opacity: 0;
+ transition: stroke-dashoffset var(--motion-slow) var(--motion-ease-emphatic), opacity var(--motion-fast) var(--motion-ease);
+}
+.gate-cross {
+ fill: none; stroke: var(--red); stroke-width: 2.6; stroke-linecap: round;
+ stroke-dasharray: 1; stroke-dashoffset: 1; opacity: 0;
+ transition: stroke-dashoffset var(--motion-base) var(--motion-ease-emphatic), opacity var(--motion-fast) var(--motion-ease);
+}
+/* The spent mark: what is left of a grant that has been used up. */
+.gate-bar { fill: none; stroke: var(--muted); stroke-width: 2.4; stroke-linecap: round; opacity: 0; transition: opacity var(--motion-base) var(--motion-ease); }
+
+.approval-card.pending .gate-shackle { stroke: var(--amber); }
+.approval-card.pending .gate-body { stroke: var(--amber); }
+
+/* Passage granted: the shackle lifts and swings clear. */
+.approval-card.approved .gate-shackle { transform: translateY(-3px) rotate(-24deg); stroke: var(--green); }
+.approval-card.approved .gate-body { stroke: var(--green); }
+.approval-card.approved .gate-check { stroke-dashoffset: 0; opacity: 1; }
+
+.approval-card.rejected .gate-shackle { stroke: var(--red); }
+.approval-card.rejected .gate-body { stroke: var(--red); }
+.approval-card.rejected .gate-cross { stroke-dashoffset: 0; opacity: 1; }
+
+/* Spent: the one granted attempt was used, so the gate is shut again. The
+ check goes with it — the grant is no longer in force. */
+.approval-card.consumed .gate-shackle { transform: translateY(0) rotate(0deg); stroke: var(--muted); }
+.approval-card.consumed .gate-body { stroke: var(--muted); fill: var(--soft); }
+.approval-card.consumed .gate-check { opacity: 0; }
+.approval-card.consumed .gate-bar { opacity: 1; }
.approval-actions { display: flex; gap: 8px; margin-top: 10px; }
.btn {
border: 1px solid var(--line);
diff --git a/tests/browser/dashboard-516-micro.test.js b/tests/browser/dashboard-516-micro.test.js
index 1b3387c..ae31410 100644
--- a/tests/browser/dashboard-516-micro.test.js
+++ b/tests/browser/dashboard-516-micro.test.js
@@ -9,7 +9,7 @@
import assert from 'node:assert/strict';
import { fixtureReadyRun, fixtureBlockedRun } from '../helpers/dashboard-fixtures.js';
-import { setupBrowserSuite, gotoDashboard as harnessGoto } from './helpers/harness.js';
+import { setupBrowserSuite, gotoDashboard as harnessGoto, holdLiveTicks } from './helpers/harness.js';
// Shared harness (#515): server boot, browser lifecycle, per-test teardown.
const suite = setupBrowserSuite();
@@ -40,8 +40,8 @@ suite.browserTest('pills carry token transitions and feed rows keep keyed identi
suite.browserTest('a genuinely-new feed event arrives as a new node; existing rows persist (#516)', { seed: seedRichProject, tmpPrefix: 'rstack-browser-516-' }, async ({ page, server }) => {
await gotoDashboard(page, server);
+ await holdLiveTicks(page); // asserts on injected state
const result = await page.evaluate(() => {
- window.handleGlobalSnapshot = function () {}; // hold live ticks: this test asserts on injected state
const rows = [...document.querySelectorAll('#command-feed .feed-row')];
rows.forEach((row) => { row.__rs516 = 'existing'; });
const next = structuredClone(window.STATE);
@@ -66,8 +66,8 @@ suite.browserTest('a genuinely-new feed event arrives as a new node; existing ro
suite.browserTest('KPI count-up lands on the exact value and snaps instantly under reduced motion (#516)', { seed: seedRichProject, contextOptions: { reducedMotion: 'reduce' }, tmpPrefix: 'rstack-browser-516-' }, async ({ page, server }) => {
await gotoDashboard(page, server);
+ await holdLiveTicks(page); // asserts on injected state
const snap = await page.evaluate(() => {
- window.handleGlobalSnapshot = function () {}; // hold live ticks: this test asserts on injected state
const next = structuredClone(window.STATE);
next.totalRuns = 42;
window.applyState(next, {});
@@ -79,8 +79,8 @@ suite.browserTest('KPI count-up lands on the exact value and snaps instantly und
suite.browserTest('KPI count-up reaches the target value with motion enabled (#516)', { seed: seedRichProject, tmpPrefix: 'rstack-browser-516-' }, async ({ page, server }) => {
await gotoDashboard(page, server);
+ await holdLiveTicks(page); // asserts on injected state
await page.evaluate(() => {
- window.handleGlobalSnapshot = function () {}; // hold live ticks: this test asserts on injected state
const next = structuredClone(window.STATE);
next.totalRuns = 37;
window.applyState(next, {});
diff --git a/tests/browser/dashboard-541-motion.test.js b/tests/browser/dashboard-541-motion.test.js
index 589be07..6776514 100644
--- a/tests/browser/dashboard-541-motion.test.js
+++ b/tests/browser/dashboard-541-motion.test.js
@@ -21,6 +21,7 @@ import { fixtureReadyRun, fixtureBlockedRun } from '../helpers/dashboard-fixture
import {
setupBrowserSuite, gotoDashboard as harnessGoto, navigate,
STATE_LOAD_TIMEOUT_MS, PAGE_VISIBLE_TIMEOUT_MS,
+ holdLiveTicks,
} from './helpers/harness.js';
const require = createRequire(import.meta.url);
@@ -46,6 +47,7 @@ const motionTest = (name, body, options = {}) => suite.browserTest(
async function open(page, server) {
await harnessGoto(page, server, { waitForState: true });
+ await holdLiveTicks(page); // every test here asserts on injected state
await page.waitForFunction(() => document.querySelectorAll('.overview-proof-step').length > 0,
null, { timeout: STATE_LOAD_TIMEOUT_MS });
}
@@ -56,7 +58,6 @@ motionTest('a stage that flips to PASS draws its check on the surviving node (#5
await open(page, server);
const flipped = await page.evaluate(() => {
- window.handleGlobalSnapshot = function () {}; // hold live ticks; this asserts on injected state
const next = structuredClone(window.STATE);
const stages = (next.overview && next.overview.stages) || [];
const target = stages.find((stage) => stage.state !== 'passed');
@@ -98,7 +99,6 @@ motionTest('a stage that ARRIVES passed is drawn instantly, never replayed (#541
await open(page, server);
const arrival = await page.evaluate(() => {
- window.handleGlobalSnapshot = function () {};
const next = structuredClone(window.STATE);
const stages = (next.overview && next.overview.stages) || [];
// A stage id that was never on screen: its
is created fresh, so the
@@ -127,7 +127,6 @@ motionTest('a stage that ARRIVES passed is drawn instantly, never replayed (#541
motionTest('the check never animates under reduced motion (#541)', async ({ page, server }) => {
await open(page, server);
const index = await page.evaluate(() => {
- window.handleGlobalSnapshot = function () {};
const next = structuredClone(window.STATE);
const stages = (next.overview && next.overview.stages) || [];
const target = stages.find((stage) => stage.state !== 'passed');
@@ -160,7 +159,6 @@ motionTest('a consumed override is visually distinct from a live approval (#541)
null, { timeout: PAGE_VISIBLE_TIMEOUT_MS });
const styles = await page.evaluate(() => {
- window.handleGlobalSnapshot = function () {};
const card = document.querySelector('#page-approvals .approval-card');
// Each state is read on its OWN fresh node. Re-classing one node and
// reading immediately samples the value mid-transition — the very
diff --git a/tests/browser/dashboard-544-gate-glyph.test.js b/tests/browser/dashboard-544-gate-glyph.test.js
new file mode 100644
index 0000000..c0e3442
--- /dev/null
+++ b/tests/browser/dashboard-544-gate-glyph.test.js
@@ -0,0 +1,190 @@
+/**
+ * #544 — the approval gate glyph in a real browser.
+ *
+ * The claim being tested is behavioural, not cosmetic: the shackle is OPEN
+ * only while passage is actually granted, and shut again once the grant is
+ * spent. Plus the #541 pair — a flip animates on the surviving node, an
+ * arrival does not.
+ *
+ * owner: RStack developed by Richardson Gunde
+ */
+
+/* global document, window, getComputedStyle */
+
+import assert from 'node:assert/strict';
+import { createRequire } from 'node:module';
+
+import { fixtureBlockedRun, fixtureReadyRun } from '../helpers/dashboard-fixtures.js';
+import {
+ setupBrowserSuite, gotoDashboard as harnessGoto, navigate,
+ PAGE_VISIBLE_TIMEOUT_MS,
+ holdLiveTicks,
+} from './helpers/harness.js';
+
+const require = createRequire(import.meta.url);
+let AXE_PATH = null;
+try { AXE_PATH = require.resolve('axe-core/axe.min.js'); } catch { /* dep absent */ }
+const AXE_KNOWN_DEBT = new Set(['color-contrast']);
+const AXE_BLOCKING_IMPACTS = new Set(['serious', 'critical']);
+
+// Past --motion-slow (.4s) with room to spare.
+const SETTLE_MS = 900;
+const REDUCED_MOTION_SETTLE_MS = 150;
+
+const seedRuns = async (root) => { await fixtureBlockedRun(root); await fixtureReadyRun(root); };
+
+const suite = setupBrowserSuite();
+const glyphTest = (name, body, options = {}) => suite.browserTest(
+ name,
+ { seed: seedRuns, tmpPrefix: 'rstack-browser-544-', ...options },
+ body,
+);
+
+async function openApprovals(page, server) {
+ await harnessGoto(page, server, { waitForState: true });
+ await holdLiveTicks(page); // every test here asserts on injected state
+ await navigate(page, 'approvals', { assertVisible: false });
+ await page.waitForFunction(() => document.querySelector('#page-approvals .gate-glyph') != null,
+ null, { timeout: PAGE_VISIBLE_TIMEOUT_MS });
+}
+
+// Read each state on its OWN fresh node: re-classing one node and reading
+// immediately samples mid-transition (the #541 lesson).
+const readStates = (states) => page => page.evaluate((wanted) => {
+ // Named lookups: a selector or fixture regression should surface as a
+ // readable assertion, not a TypeError from dereferencing null (Qodo, #545).
+ const need = (root, selector) => {
+ const found = root.querySelector(selector);
+ if (!found) throw new Error(`expected ${selector} to exist — the glyph markup or the fixture changed`);
+ return found;
+ };
+ const card = need(document, '#page-approvals .approval-card');
+ const out = {};
+ for (const state of wanted) {
+ const clone = card.cloneNode(true);
+ clone.className = 'approval-card ' + state;
+ card.parentNode.appendChild(clone);
+ const style = getComputedStyle(need(clone, '.gate-shackle'));
+ out[state] = {
+ transform: style.transform,
+ shackleStroke: style.stroke,
+ check: getComputedStyle(need(clone, '.gate-check')).opacity,
+ cross: getComputedStyle(need(clone, '.gate-cross')).opacity,
+ bar: getComputedStyle(need(clone, '.gate-bar')).opacity,
+ };
+ clone.remove();
+ }
+ return out;
+}, states);
+
+glyphTest('the shackle is open only while passage is granted (#544)', async ({ page, server }) => {
+ await openApprovals(page, server);
+ const seen = await readStates(['pending', 'approved', 'rejected', 'consumed'])(page);
+
+ const closed = (state) => seen[state].transform === 'none' || /matrix\(1,\s*0,\s*0,\s*1,\s*0,\s*0\)/.test(seen[state].transform);
+ assert.equal(closed('pending'), true, `pending keeps the gate shut — got ${seen.pending.transform}`);
+ assert.equal(closed('approved'), false, `approved opens it — got ${seen.approved.transform}`);
+ assert.equal(closed('rejected'), true, `rejected keeps it shut — got ${seen.rejected.transform}`);
+ // The whole point of the child: a spent override is BACK TO BLOCKING.
+ assert.equal(closed('consumed'), true,
+ `a spent one-shot override shuts the gate again — got ${seen.consumed.transform}`);
+});
+
+glyphTest('each state shows its own mark and only its own (#544)', async ({ page, server }) => {
+ await openApprovals(page, server);
+ const seen = await readStates(['pending', 'approved', 'rejected', 'consumed'])(page);
+
+ assert.deepEqual(
+ { check: seen.approved.check, cross: seen.approved.cross, bar: seen.approved.bar },
+ { check: '1', cross: '0', bar: '0' }, 'approved shows the check alone');
+ assert.deepEqual(
+ { check: seen.rejected.check, cross: seen.rejected.cross, bar: seen.rejected.bar },
+ { check: '0', cross: '1', bar: '0' }, 'rejected shows the cross alone');
+ assert.deepEqual(
+ { check: seen.consumed.check, cross: seen.consumed.cross, bar: seen.consumed.bar },
+ { check: '0', cross: '0', bar: '1' }, 'consumed shows the spent bar, and the check is gone');
+ assert.deepEqual(
+ { check: seen.pending.check, cross: seen.pending.cross, bar: seen.pending.bar },
+ { check: '0', cross: '0', bar: '0' }, 'pending makes no claim at all');
+});
+
+glyphTest('a real approval animates the glyph on the surviving node (#544)', async ({ page, server }) => {
+ await openApprovals(page, server);
+
+ const flip = await page.evaluate(() => {
+ const card = document.querySelector('#page-approvals .approval-card');
+ if (!card) throw new Error('no approval card rendered — the fixture changed');
+ card.querySelector('.gate-check').__rs544 = 'original';
+ card.className = 'approval-card approved';
+ const check = card.querySelector('.gate-check');
+ return {
+ sameNode: check.__rs544 === 'original',
+ offsetAtFlip: parseFloat(getComputedStyle(check).strokeDashoffset),
+ };
+ });
+ assert.equal(flip.sameNode, true, 'the check survived — a replaced node could never transition');
+ assert.ok(flip.offsetAtFlip > 0, `and starts undrawn, so it animates — got ${flip.offsetAtFlip}`);
+
+ await page.waitForTimeout(SETTLE_MS);
+ const settled = await page.evaluate(() =>
+ parseFloat(getComputedStyle(document.querySelector('#page-approvals .approval-card .gate-check')).strokeDashoffset));
+ assert.ok(settled < 0.02, `and finishes drawn — settled at ${settled}`);
+});
+
+glyphTest('a card that ARRIVES approved is already drawn, never replayed (#544)', async ({ page, server }) => {
+ await openApprovals(page, server);
+ const arrival = await page.evaluate(() => {
+ const card = document.querySelector('#page-approvals .approval-card');
+ if (!card) throw new Error('no approval card rendered — the fixture changed');
+ // A brand-new node: nothing was observed to change, so nothing may animate.
+ const fresh = card.cloneNode(true);
+ fresh.className = 'approval-card approved';
+ card.parentNode.appendChild(fresh);
+ return parseFloat(getComputedStyle(fresh.querySelector('.gate-check')).strokeDashoffset);
+ });
+ assert.ok(arrival < 0.02,
+ `an arriving approval shows its check without drawing it — got ${arrival}. Motion here would claim an approval nobody watched happen.`);
+});
+
+glyphTest('reduced motion lands on the final state at once (#544)', async ({ page, server }) => {
+ await openApprovals(page, server);
+ await page.evaluate(() => {
+ const card = document.querySelector('#page-approvals .approval-card');
+ if (!card) throw new Error('no approval card rendered — the fixture changed');
+ card.className = 'approval-card approved';
+ });
+ await page.waitForTimeout(REDUCED_MOTION_SETTLE_MS);
+ const offset = await page.evaluate(() =>
+ parseFloat(getComputedStyle(document.querySelector('#page-approvals .approval-card .gate-check')).strokeDashoffset));
+ assert.ok(offset < 0.02, `no draw under reduced motion — got ${offset}`);
+}, { contextOptions: { reducedMotion: 'reduce' } });
+
+glyphTest('the glyph never changes the card geometry (#544)', async ({ page, server }) => {
+ await openApprovals(page, server);
+ const box = await page.evaluate(() => {
+ const card = document.querySelector('#page-approvals .approval-card');
+ if (!card) throw new Error('no approval card rendered — the fixture changed');
+ const before = card.getBoundingClientRect();
+ card.className = 'approval-card approved';
+ const after = card.getBoundingClientRect();
+ const glyph = card.querySelector('.gate-glyph').getBoundingClientRect();
+ return {
+ shifted: Math.abs(after.height - before.height) + Math.abs(after.top - before.top),
+ glyphW: Math.round(glyph.width), glyphH: Math.round(glyph.height),
+ };
+ });
+ assert.equal(box.shifted, 0, 'the card does not resize or move when the gate opens');
+ assert.ok(box.glyphW >= 20 && box.glyphH >= 20, `the glyph is large enough to read — ${box.glyphW}x${box.glyphH}`);
+});
+
+glyphTest('no serious/critical axe violations with the glyph present (#544)', async ({ page, server, t }) => {
+ if (!AXE_PATH) { t.skip('axe-core not resolvable'); return; }
+ await openApprovals(page, server);
+ await page.addScriptTag({ path: AXE_PATH });
+ const violations = await page.evaluate(async () => {
+ const result = await window.axe.run(document, { resultTypes: ['violations'] });
+ return result.violations.map((v) => ({ id: v.id, impact: v.impact, nodes: v.nodes.length }));
+ });
+ const blocking = violations.filter((v) => AXE_BLOCKING_IMPACTS.has(v.impact) && !AXE_KNOWN_DEBT.has(v.id));
+ assert.deepEqual(blocking, [], `approvals stays axe-clean: ${JSON.stringify(blocking)}`);
+});
diff --git a/tests/browser/helpers/harness.js b/tests/browser/helpers/harness.js
index 0945bea..c68df21 100644
--- a/tests/browser/helpers/harness.js
+++ b/tests/browser/helpers/harness.js
@@ -170,3 +170,34 @@ export async function navigate(page, pageId, { assertVisible = true } = {}) {
if (assertVisible) await waited;
else await waited.catch(() => { /* assertions in the caller report the real state */ });
}
+
+/**
+ * Stop live WS snapshots from overwriting state a test injected.
+ *
+ * Three suites did this with a bare `window.handleGlobalSnapshot = ...`,
+ * which only works because the client bundle is a classic