Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion src/observability/dashboard/ui/pages/approvals.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<span class="gate-glyph ' + esc(status || 'pending') + '" aria-hidden="true">' +
'<svg viewBox="0 0 32 32" focusable="false">' +
'<path class="gate-shackle" d="M10 14v-3a6 6 0 0 1 12 0v3" />' +
'<rect class="gate-body" x="7" y="14" width="18" height="14" rx="3" />' +
'<path class="gate-check" pathLength="1" d="M11.5 21.2l3 3 6-6" />' +
'<path class="gate-cross" pathLength="1" d="M12 17.5l8 8M20 17.5l-8 8" />' +
'<path class="gate-bar" d="M11.5 21h9" />' +
'</svg></span>';
}

function approvalHtml(item, canAct) {
var status = item.status || 'pending';
// CONSUMED lifecycle (#156): the server cross-references the run-level
Expand All @@ -65,7 +90,8 @@ function approvalHtml(item, canAct) {
: isOverride
? '<div class="muted" style="margin-top:6px">🛡 One-shot override: approving grants exactly <span class="strong">one</span> more attempt for this task, then the override is consumed and further attempts block again.</div>'
: '';
return '<div class="approval-card ' + esc(status) + '"><div class="agent-head"><div><div class="strong">' + esc(item.title || item.type || 'Approval required') + '</div><div class="muted">' + esc(item.detail || item.reason || '') + '</div>' + overrideNote + '<div class="feed-meta"><span>' + esc(shortName(item.projectRoot)) + '</span><span>' + esc((item.runId || '').slice(-16)) + '</span><span>' + timeHtml(item.ts) + '</span></div></div>' + pill(status, status) + '</div>' +
return '<div class="approval-card ' + esc(status) + '"><div class="agent-head"><div><div class="strong">' + esc(item.title || item.type || 'Approval required') + '</div><div class="muted">' + esc(item.detail || item.reason || '') + '</div>' + overrideNote + '<div class="feed-meta"><span>' + esc(shortName(item.projectRoot)) + '</span><span>' + esc((item.runId || '').slice(-16)) + '</span><span>' + timeHtml(item.ts) + '</span></div></div>' +
'<div class="approval-state">' + gateGlyphHtml(status) + pill(status, status) + '</div></div>' +
(canAct ? '<div class="approval-actions"><button class="btn primary" data-id="' + esc(item.id) + '" onclick="approveFromButton(this)">Approve</button><button class="btn danger" data-id="' + esc(item.id) + '" onclick="rejectFromButton(this)">Reject</button></div>' : '') +
'</div>';
}
Expand Down
45 changes: 45 additions & 0 deletions src/observability/dashboard/ui/styles.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
8 changes: 4 additions & 4 deletions tests/browser/dashboard-516-micro.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand All @@ -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, {});
Expand All @@ -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, {});
Expand Down
6 changes: 2 additions & 4 deletions tests/browser/dashboard-541-motion.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 });
}
Expand All @@ -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');
Expand Down Expand Up @@ -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 <li> is created fresh, so the
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading