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 ? '
🛡 One-shot override: approving grants exactly one more attempt for this task, then the override is consumed and further attempts block again.
' : ''; - 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) + '
' + 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 ? '
' : '') + '
'; } 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