diff --git a/examples/desktop/docs/qa-visual-fix/01-chat-signal-plan.png b/examples/desktop/docs/qa-visual-fix/01-chat-signal-plan.png new file mode 100644 index 0000000000..0a41396720 Binary files /dev/null and b/examples/desktop/docs/qa-visual-fix/01-chat-signal-plan.png differ diff --git a/examples/desktop/docs/qa-visual-fix/02-rubrics-hint-row.png b/examples/desktop/docs/qa-visual-fix/02-rubrics-hint-row.png new file mode 100644 index 0000000000..66b0a5734c Binary files /dev/null and b/examples/desktop/docs/qa-visual-fix/02-rubrics-hint-row.png differ diff --git a/examples/desktop/docs/qa-visual-fix/03-growth-filter-chip.png b/examples/desktop/docs/qa-visual-fix/03-growth-filter-chip.png new file mode 100644 index 0000000000..2d02448b1f Binary files /dev/null and b/examples/desktop/docs/qa-visual-fix/03-growth-filter-chip.png differ diff --git a/examples/desktop/docs/qa-visual-fix/before/01-chat-signal-plan.png b/examples/desktop/docs/qa-visual-fix/before/01-chat-signal-plan.png new file mode 100644 index 0000000000..f75c663fbd Binary files /dev/null and b/examples/desktop/docs/qa-visual-fix/before/01-chat-signal-plan.png differ diff --git a/examples/desktop/docs/qa-visual-fix/before/02-rubrics-hint-row.png b/examples/desktop/docs/qa-visual-fix/before/02-rubrics-hint-row.png new file mode 100644 index 0000000000..5bbc05b0b9 Binary files /dev/null and b/examples/desktop/docs/qa-visual-fix/before/02-rubrics-hint-row.png differ diff --git a/examples/desktop/docs/qa-visual-fix/before/03-growth-filter-chip.png b/examples/desktop/docs/qa-visual-fix/before/03-growth-filter-chip.png new file mode 100644 index 0000000000..23511e78ae Binary files /dev/null and b/examples/desktop/docs/qa-visual-fix/before/03-growth-filter-chip.png differ diff --git a/examples/desktop/scripts/qa-cdp-shoot-visual-fix.mjs b/examples/desktop/scripts/qa-cdp-shoot-visual-fix.mjs new file mode 100644 index 0000000000..3397911a28 --- /dev/null +++ b/examples/desktop/scripts/qa-cdp-shoot-visual-fix.mjs @@ -0,0 +1,185 @@ +// scripts/qa-cdp-shoot-visual-fix.mjs — visual-consistency-polish verification. +// +// Boots an isolated Electron on CDP :9403 (its own --user-data-dir + +// $DSH_DESKTOP_HOME so real user config is never touched, per the +// 2026-07-18 postmortem), seeds a session that fires a `plan-update` +// signal (so the .sig-plan chip appears next to an action turn's blue +// left rail — the P0-1 collision surface), then captures three PNGs +// covering the three fix surfaces: +// +// 01-chat-signal-plan.png — Chat pane, mixed action turn + +// .sig-plan chip. Plan chip must NOT match the turn left-rail blue. +// 02-rubrics-hint-row.png — Rubrics catalog page. The similar- +// sessions hint must read as a 28px compact row, not a purple hero. +// 03-growth-filter-chip.png — Growth page. Filter chip active +// state must use the app --accent-soft, not raw violet. +// +// Isolation follows scripts/qa-cdp-shoot-chat-triple.mjs precedent. + +import { spawn } from 'node:child_process' +import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { resolve, join } from 'node:path' +import { setTimeout as sleep } from 'node:timers/promises' +import { tmpdir } from 'node:os' + +const WORKTREE = resolve(process.env.DSH_WORKTREE || process.cwd()) +const PARENT = resolve(process.env.DSH_REPO || '/Users/ziya/harness/dsh-desktop-demo') +const ELECTRON = join(PARENT, 'node_modules/.bin/electron') +const CDP_PORT = Number(process.env.DSH_VISUAL_FIX_PORT || 9403) +const USER_DATA = join(tmpdir(), 'dsh-visual-fix-userdata') +const DSH_HOME = join(tmpdir(), 'dsh-visual-fix-home') +const OUTDIR = join(WORKTREE, 'docs/qa-visual-fix') + +if (!existsSync(ELECTRON)) { + console.error(`electron binary not found at ${ELECTRON}`) + process.exit(2) +} +mkdirSync(OUTDIR, { recursive: true }) +for (const dir of [USER_DATA, DSH_HOME]) { + try { rmSync(dir, { recursive: true, force: true }) } catch {} + mkdirSync(dir, { recursive: true }) +} +writeFileSync(join(DSH_HOME, 'config.json'), JSON.stringify({ + role: 'coding', approvalMode: 'never', +})) +writeFileSync(join(DSH_HOME, '.onboarded'), new Date().toISOString()) + +async function bootElectron() { + const child = spawn(ELECTRON, [ + `--remote-debugging-port=${CDP_PORT}`, + `--user-data-dir=${USER_DATA}`, + '--disable-gpu', + '--no-sandbox', + '.', + ], { + cwd: WORKTREE, + env: { + ...process.env, + DSH_DESKTOP_HOME: DSH_HOME, + DSH_MAXIMIZE: '1', + DSH_QA: '1', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + const logs = [] + child.stdout.on('data', d => logs.push(String(d))) + child.stderr.on('data', d => logs.push(String(d))) + for (let i = 0; i < 40; i++) { + await sleep(500) + try { + const r = await fetch(`http://localhost:${CDP_PORT}/json/list`) + if (r.ok) return { child, logs } + } catch {} + } + child.kill('SIGKILL') + console.error('electron CDP did not come up. logs:\n' + logs.join('')) + process.exit(3) +} + +async function newCdp() { + const targets = await (await fetch(`http://localhost:${CDP_PORT}/json/list`)).json() + const target = targets.find(t => t.type === 'page') + if (!target) throw new Error('no page target on port ' + CDP_PORT) + const ws = new WebSocket(target.webSocketDebuggerUrl) + await new Promise((ok, err) => { ws.onopen = ok; ws.onerror = e => err(e) }) + let id = 1 + const pending = new Map() + ws.onmessage = ev => { + const msg = JSON.parse(typeof ev.data === 'string' ? ev.data : String(ev.data)) + if (msg.id != null && pending.has(msg.id)) { + const [ok, err] = pending.get(msg.id); pending.delete(msg.id) + if (msg.error) err(new Error(msg.error.message)); else ok(msg.result) + } + } + const call = (m, p = {}, ms = 15000) => new Promise((ok, err) => { + const _id = id++ + const t = setTimeout(() => { pending.delete(_id); err(new Error('cdp timeout: ' + m)) }, ms) + pending.set(_id, [v => { clearTimeout(t); ok(v) }, e => { clearTimeout(t); err(e) }]) + ws.send(JSON.stringify({ id: _id, method: m, params: p })) + }) + const evj = async expr => { + const r = await call('Runtime.evaluate', { + expression: `(async()=>{try{return (${expr})}catch(e){return {__err:String(e)}}})()`, + returnByValue: true, awaitPromise: true, + }) + if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text) + return r.result?.value + } + return { ws, call, evj } +} + +// Seed a demo session that (a) is a real action turn (so its blue left +// rail is painted) and (b) contains an assistant message that trips the +// numbered-plan heuristic in trace-signal-detect._looksLikePlanUpdate, +// producing a .sig-plan chip. The chip should now read teal, not the +// same blue as the rail — that's the P0-1 fix surface. +const SEED = `(async () => { + const R = window.__dshRenderer + if (!R) return { __err: 'renderer seam missing' } + const sid = 'visual-fix-demo-' + Date.now() + R.ensureSession(sid) + await R.selectSession(sid) + const emit = (ev) => R.onSessionEvent(sid, ev) + let seq = 1 + const now = () => Date.now() + emit({ type: 'user/message', seq: seq++, time: now(), + data: { content: [{ type: 'text', text: 'plan out the release' }] } }) + emit({ type: 'turn/start', seq: seq++, time: now(), + data: { turnId: 't0', model: 'deepseek-r1' } }) + emit({ type: 'assistant/message', seq: seq++, time: now(), + data: { text: 'Here is the plan:\\n1. Cut the release branch.\\n2. Run the smoke suite.\\n3. Publish the tag.' } }) + emit({ type: 'tool/call', seq: seq++, time: now(), + data: { callId: 'c1', name: 'git', arguments: '{"cmd":"checkout -b release"}' } }) + emit({ type: 'tool/result', seq: seq++, time: now(), + data: { callId: 'c1', ok: true, output: 'Switched to a new branch', durationMs: 42 } }) + emit({ type: 'turn/end', seq: seq++, time: now(), + data: { turnId: 't0', usage: { total_tokens: 240 }, durationMs: 620 } }) + return { sid, count: seq - 1 } +})()` + +async function shoot(cdp, name) { + const shot = await cdp.call('Page.captureScreenshot', { format: 'png', fromSurface: false }) + const buf = Buffer.from(shot.data, 'base64') + writeFileSync(join(OUTDIR, name), buf) + console.log(' shot', name, buf.length, 'bytes') +} + +async function main() { + const { child } = await bootElectron() + try { + await sleep(1500) + const cdp = await newCdp() + await cdp.call('Page.enable') + await cdp.call('Emulation.setDeviceMetricsOverride', { + width: 1400, height: 900, deviceScaleFactor: 2, mobile: false, + }) + for (let i = 0; i < 20; i++) { + const ready = await cdp.evj(`!!(window.__dshRenderer && window.__dshRenderer.onSessionEvent)`) + if (ready) break + await sleep(250) + } + + // Shot 1: Chat pane, action turn with sig-plan chip + await cdp.evj(`window.__dshTabs && window.__dshTabs.switchTo && window.__dshTabs.switchTo('chat')`) + const seedRes = await cdp.evj(SEED) + console.log('seed:', JSON.stringify(seedRes)) + await sleep(700) + await shoot(cdp, '01-chat-signal-plan.png') + + // Shot 2: Rubrics page, hint row above the tile grid + await cdp.evj(`window.__dshTabs && window.__dshTabs.switchTo && window.__dshTabs.switchTo('rubrics')`) + await sleep(600) + await shoot(cdp, '02-rubrics-hint-row.png') + + // Shot 3: Growth page, filter chip active + await cdp.evj(`window.__dshTabs && window.__dshTabs.switchTo && window.__dshTabs.switchTo('growth')`) + await sleep(600) + await shoot(cdp, '03-growth-filter-chip.png') + + console.log('shots saved to', OUTDIR) + } finally { + child.kill('SIGKILL') + } +} + +main().catch(e => { console.error(e); process.exit(1) }) diff --git a/examples/desktop/src/renderer/rubrics-page.js b/examples/desktop/src/renderer/rubrics-page.js index 9c93581067..88f93662ae 100644 --- a/examples/desktop/src/renderer/rubrics-page.js +++ b/examples/desktop/src/renderer/rubrics-page.js @@ -480,15 +480,15 @@ const cls = classes.find(c => !state.dismissedHints.has(c.id)) if (!cls) return null return el('div', { className: 'rubric-hint-card', 'data-testid': 'rubric-hint-card', role: 'note' }, [ - el('span', { className: 'rubric-hint-icon', text: '✦' }), + el('span', { className: 'rubric-hint-icon', text: '·' }), el('div', { className: 'rubric-hint-body' }, [ - el('div', { className: 'rubric-hint-title', text: `Detected ${cls.count} similar sessions this week` }), - el('div', { className: 'rubric-hint-sub muted small', text: cls.promptSummary || 'These look like a repeated task class — a rubric would let you track it.' }), + el('span', { className: 'rubric-hint-title', text: `Detected ${cls.count} similar sessions this week` }), + el('span', { className: 'rubric-hint-sub', text: cls.promptSummary || 'These look like a repeated task class — a rubric would let you track it.' }), ]), el('button', { - className: 'primary small rubric-hint-cta', + className: 'ghost small rubric-hint-cta', type: 'button', - text: 'Enable a rubric for this task class', + text: 'Enable a rubric', onclick: () => { openCreateForm('llm-judge') if (state.createForm) { diff --git a/examples/desktop/src/renderer/style.css b/examples/desktop/src/renderer/style.css index 42f2465383..318726c5d8 100644 --- a/examples/desktop/src/renderer/style.css +++ b/examples/desktop/src/renderer/style.css @@ -52,6 +52,18 @@ --turn-output-edge: var(--border-strong); --turn-interrupt-marker: #ea580c; + /* Signal chip colors — kept off the --accent/blue axis so a plan chip + * sitting inside an action turn does not fuse with the turn's blue left + * rail (--turn-action-edge = --accent). Teal is far enough from blue, + * red (error/loop) and amber (redundant) that all four signal families + * remain distinguishable in a mixed-signal row. */ + --signal-plan-fg: #0f766e; + --signal-plan-bg: rgba(13, 148, 136, 0.08); + --signal-plan-border: rgba(13, 148, 136, 0.35); + --signal-plan-restart-fg: #115e59; + --signal-plan-restart-bg: rgba(13, 148, 136, 0.14); + --signal-plan-restart-border: rgba(13, 148, 136, 0.45); + /* Semantic */ --ok: #16a34a; --ok-soft: rgba(22, 163, 74, 0.12); @@ -158,6 +170,12 @@ --turn-action-edge: var(--accent); --turn-output-edge: var(--border-strong); --turn-interrupt-marker: #f97316; + --signal-plan-fg: #5eead4; + --signal-plan-bg: rgba(20, 184, 166, 0.14); + --signal-plan-border: rgba(94, 234, 212, 0.35); + --signal-plan-restart-fg: #99f6e4; + --signal-plan-restart-bg: rgba(20, 184, 166, 0.20); + --signal-plan-restart-border: rgba(94, 234, 212, 0.45); --user-bubble: #22262f; --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.4); --shadow-2: 0 2px 8px rgba(0, 0, 0, 0.5); @@ -186,6 +204,12 @@ --turn-action-edge: var(--accent); --turn-output-edge: var(--border-strong); --turn-interrupt-marker: #f97316; + --signal-plan-fg: #5eead4; + --signal-plan-bg: rgba(20, 184, 166, 0.14); + --signal-plan-border: rgba(94, 234, 212, 0.35); + --signal-plan-restart-fg: #99f6e4; + --signal-plan-restart-bg: rgba(20, 184, 166, 0.20); + --signal-plan-restart-border: rgba(94, 234, 212, 0.45); --user-bubble: #22262f; } * { box-sizing: border-box; } @@ -11558,24 +11582,26 @@ details.devtools-row[open] > .devtools-row-summary::before { transform: rotate(9 /* --- trace signal badges (lane-trace-signals; see docs/upstream-ledger.md L-2) -- * The detector runs renderer-side until upstream emits `trace/signal` events. - * Colors follow the palette: red=error/loop, amber=redundant, blue=plan. - * Multiple signals stack horizontally on the Timeline; the Graph uses a single - * outer ring per node (highest-priority signal wins the color). + * Colors follow the palette: red=error/loop, amber=redundant, teal=plan + * (kept off the --accent/blue axis so plan doesn't fuse with the action-turn + * left rail — see --signal-plan-fg). Multiple signals stack horizontally on + * the Timeline; the Graph uses a single outer ring per node (highest-priority + * signal wins the color). */ .trace-timeline-signal-badge { stroke: rgba(0,0,0,0.15); stroke-width: 0.75; } .trace-timeline-signal-badge.sig-error { fill: #dc2626; } .trace-timeline-signal-badge.sig-loop { fill: #ef4444; } .trace-timeline-signal-badge.sig-redundant{ fill: #f59e0b; } -.trace-timeline-signal-badge.sig-plan { fill: #2563eb; } -.trace-timeline-signal-badge.sig-plan-restart { fill: #1d4ed8; } +.trace-timeline-signal-badge.sig-plan { fill: var(--signal-plan-fg); } +.trace-timeline-signal-badge.sig-plan-restart { fill: var(--signal-plan-restart-fg); } .trace-timeline-signal-badge.sig-generic { fill: #6b7280; } .trace-graph-signal-ring { stroke-width: 2.5; } .trace-graph-signal-ring.sig-error { stroke: #dc2626; } .trace-graph-signal-ring.sig-loop { stroke: #ef4444; } .trace-graph-signal-ring.sig-redundant{ stroke: #f59e0b; } -.trace-graph-signal-ring.sig-plan { stroke: #2563eb; } -.trace-graph-signal-ring.sig-plan-restart { stroke: #1d4ed8; } +.trace-graph-signal-ring.sig-plan { stroke: var(--signal-plan-fg); } +.trace-graph-signal-ring.sig-plan-restart { stroke: var(--signal-plan-restart-fg); } .trace-graph-signal-ring.sig-generic { stroke: #6b7280; } /* Main-flow marker chips — small pill row above the assistant turn body */ @@ -11602,8 +11628,8 @@ details.devtools-row[open] > .devtools-row-summary::before { transform: rotate(9 .turn-signal-chip.sig-error { color: #b91c1c; border-color: rgba(220,38,38,0.35); background: rgba(220,38,38,0.06); } .turn-signal-chip.sig-loop { color: #b91c1c; border-color: rgba(239,68,68,0.35); background: rgba(239,68,68,0.06); } .turn-signal-chip.sig-redundant { color: #92400e; border-color: rgba(245,158,11,0.4); background: rgba(245,158,11,0.08); } -.turn-signal-chip.sig-plan { color: #1d4ed8; border-color: rgba(37,99,235,0.35); background: rgba(37,99,235,0.06); } -.turn-signal-chip.sig-plan-restart{ color: #1e3a8a; border-color: rgba(29,78,216,0.4); background: rgba(29,78,216,0.08); } +.turn-signal-chip.sig-plan { color: var(--signal-plan-fg); border-color: var(--signal-plan-border); background: var(--signal-plan-bg); } +.turn-signal-chip.sig-plan-restart{ color: var(--signal-plan-restart-fg); border-color: var(--signal-plan-restart-border); background: var(--signal-plan-restart-bg); } /* Dark theme reads the badges/chips against the dark surface, so bump the * saturation a touch — the palette is designed for both. */ @@ -11611,7 +11637,8 @@ details.devtools-row[open] > .devtools-row-summary::before { transform: rotate(9 .turn-signal-chip { background: rgba(255,255,255,0.05); color: #f3f4f6; } .turn-signal-chip.sig-error, .turn-signal-chip.sig-loop { color: #fca5a5; } .turn-signal-chip.sig-redundant { color: #fcd34d; } - .turn-signal-chip.sig-plan, .turn-signal-chip.sig-plan-restart { color: #93c5fd; } + .turn-signal-chip.sig-plan { color: var(--signal-plan-fg); } + .turn-signal-chip.sig-plan-restart { color: var(--signal-plan-restart-fg); } } /* --- onboarding overlay: first-frame FOUC guard ------------------------ */ @@ -12149,28 +12176,52 @@ details.devtools-row[open] > .devtools-row-summary::before { transform: rotate(9 /* --- Rubrics view: hint card + per-tile stats strip --- */ +/* --- Rubrics view: hint row + per-tile stats strip --- + * + * The similar-sessions detector nudges you to open the create-rubric form. + * It lives above the tile grid as a *compact row* (28px), NOT a hero banner — + * L1 material has to read as L1, per density-layering-spec §2. Tokens only; + * no gradient, no radius bump, no icon push. */ + .rubric-hint-card { display: flex; align-items: center; gap: 12px; - padding: 12px 14px; - margin: 0 0 16px 0; - border-radius: 12px; - background: linear-gradient(90deg, rgba(122, 90, 248, 0.10), rgba(225, 170, 255, 0.06)); - border: 1px solid rgba(122, 90, 248, 0.35); + padding: 4px 12px; + min-height: 28px; + margin: 0 0 8px 0; + border-radius: 4px; + background: var(--surface); + border: 1px solid var(--border); } .rubric-hint-icon { - font-size: 20px; + font-size: 12px; + line-height: 1; + color: var(--muted); flex: 0 0 auto; } .rubric-hint-body { flex: 1 1 auto; min-width: 0; + display: flex; + align-items: baseline; + gap: 8px; + overflow: hidden; } .rubric-hint-title { - font-weight: 600; - font-size: 14px; - margin-bottom: 2px; + font-weight: 500; + font-size: 13px; + color: var(--text); + white-space: nowrap; + flex: 0 0 auto; +} +.rubric-hint-sub { + color: var(--muted); + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; } .rubric-hint-cta { flex: 0 0 auto; @@ -12178,8 +12229,9 @@ details.devtools-row[open] > .devtools-row-summary::before { transform: rotate(9 } .rubric-hint-dismiss { flex: 0 0 auto; - min-width: 28px; - padding: 2px 8px; + min-width: 24px; + padding: 0 6px; + color: var(--muted); } .rubric-tile-stats { @@ -12202,16 +12254,12 @@ details.devtools-row[open] > .devtools-row-summary::before { transform: rotate(9 font-weight: 600; } .rubric-tile-stats-rate.pass { - background: rgba(121, 209, 123, 0.18); - color: #1f7a2b; + background: var(--ok-soft); + color: var(--ok); } .rubric-tile-stats-rate.fail { - background: rgba(249, 110, 110, 0.18); - color: #a83232; -} -@media (prefers-color-scheme: dark) { - .rubric-tile-stats-rate.pass { color: #79d17b; } - .rubric-tile-stats-rate.fail { color: #f96e6e; } + background: var(--err-soft); + color: var(--err); } .rubric-tile-stats-spark { display: inline-flex; @@ -12224,8 +12272,8 @@ details.devtools-row[open] > .devtools-row-summary::before { transform: rotate(9 height: 10px; border-radius: 1px; } -.rubric-tile-stats-dot.pass { background: #79d17b; } -.rubric-tile-stats-dot.fail { background: #f96e6e; } +.rubric-tile-stats-dot.pass { background: var(--ok); } +.rubric-tile-stats-dot.fail { background: var(--err); } /* --- Growth view: time-series chart --- */ @@ -12254,8 +12302,8 @@ details.devtools-row[open] > .devtools-row-summary::before { transform: rotate(9 border-radius: 4px; } .growth-fusion-filter-chip.active { - background: rgba(122, 90, 248, 0.18); - border-color: rgba(122, 90, 248, 0.6); + background: var(--accent-soft); + border-color: var(--accent); color: inherit; } .growth-fusion-chart { @@ -12313,8 +12361,8 @@ details.devtools-row[open] > .devtools-row-summary::before { transform: rotate(9 border-radius: 4px; } .runtimes-tab.active { - background: rgba(122, 90, 248, 0.18); - border-color: rgba(122, 90, 248, 0.6); + background: var(--accent-soft); + border-color: var(--accent); } .rubric-grid-card { @@ -12333,7 +12381,7 @@ details.devtools-row[open] > .devtools-row-summary::before { transform: rotate(9 } .rubric-grid-name { font-weight: 600; - font-size: 15px; + font-size: 14px; } .rubric-grid-rubric-id { margin-left: auto; @@ -12350,14 +12398,10 @@ details.devtools-row[open] > .devtools-row-summary::before { transform: rotate(9 text-align: left; padding: 4px 8px; font-weight: 500; - color: rgba(0, 0, 0, 0.6); + color: var(--muted); background: var(--surface-2, #f7f7f8); border: 1px solid var(--surface-hover, rgba(0, 0, 0, 0.08)); } -@media (prefers-color-scheme: dark) { - .rubric-grid-corner, - .rubric-grid-row-head { color: rgba(255, 255, 255, 0.7); } -} .rubric-grid-col-head { text-align: center; padding: 4px 6px; @@ -12373,11 +12417,11 @@ details.devtools-row[open] > .devtools-row-summary::before { transform: rotate(9 cursor: default; } .rubric-grid-cell--pass { - background: #79d17b; + background: color-mix(in oklab, var(--ok) 55%, transparent); cursor: pointer; } .rubric-grid-cell--fail { - background: #f96e6e; + background: color-mix(in oklab, var(--err) 55%, transparent); cursor: pointer; } .rubric-grid-cell--empty { @@ -12391,7 +12435,7 @@ details.devtools-row[open] > .devtools-row-summary::before { transform: rotate(9 } .rubric-grid-cell--pass:hover, .rubric-grid-cell--fail:hover { - outline: 2px solid rgba(122, 90, 248, 0.8); + outline: 2px solid var(--accent); outline-offset: -2px; } /* ========================================================================== diff --git a/examples/desktop/test/visual-consistency-polish.test.js b/examples/desktop/test/visual-consistency-polish.test.js new file mode 100644 index 0000000000..f0804479f8 --- /dev/null +++ b/examples/desktop/test/visual-consistency-polish.test.js @@ -0,0 +1,122 @@ +// Static CSS assertions for fix/visual-consistency-polish. +// +// Guards the invariants that the review-visual-flow P0/P1 findings turn on: +// - .rubric-hint-card is a compact row (no gradient, no 12px radius). +// - .turn-signal-chip.sig-plan sits off the --accent axis so it doesn't +// fuse with --turn-action-edge (which is bound to --accent). +// - Rubric-grid pass/fail cells route through --ok / --err tokens, not +// the raw hex family (#79d17b / #f96e6e) Lane D shipped with. + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') + +const CSS_PATH = path.join(__dirname, '..', 'src', 'renderer', 'style.css') +const CSS = fs.readFileSync(CSS_PATH, 'utf8') + +function findBlock(selector) { + // Escape regex metacharacters in the selector, then match the first + // occurrence: `selector { ... }` up to the closing brace. + const esc = selector.replace(/[-.[\]/{}()*+?^$|]/g, '\\$&') + const re = new RegExp(esc + '\\s*\\{[^}]*\\}') + const m = CSS.match(re) + assert.ok(m, 'expected to find CSS block for ' + selector) + return m[0] +} + +test('rubric-hint-card is a compact row: no gradient, no 12px radius', () => { + const block = findBlock('.rubric-hint-card') + assert.ok(!/linear-gradient/.test(block), + '.rubric-hint-card must not use linear-gradient (spec §2: L1 material not hero)') + assert.ok(!/border-radius:\s*12px/.test(block), + '.rubric-hint-card must not use 12px radius (spec §7 grid: 4/6/8)') + assert.match(block, /border-radius:\s*4px/, + '.rubric-hint-card should carry the compact-row 4px radius') + assert.match(block, /background:\s*var\(--surface\)/, + '.rubric-hint-card should tokenize its background') +}) + +test('plan-signal color tokens are declared and NOT on the --accent/blue axis', () => { + // Tokens exist on :root + assert.match(CSS, /--signal-plan-fg\s*:\s*#0f766e/, + 'light --signal-plan-fg should be the teal family, not blue') + // Also declared for both dark blocks + const darkBlocks = CSS.match(/--signal-plan-fg\s*:\s*#5eead4/g) || [] + assert.ok(darkBlocks.length >= 2, + 'dark --signal-plan-fg (#5eead4) should be declared for @media dark and [data-theme="dark"]') +}) + +test('plan signal chips route through the token, not the raw blue hex', () => { + const planBlock = findBlock('.turn-signal-chip.sig-plan') + assert.match(planBlock, /var\(--signal-plan-fg\)/, + '.sig-plan color must reference --signal-plan-fg, not raw blue') + assert.ok(!/#1d4ed8|#2563eb/.test(planBlock), + '.sig-plan must not use the --accent-strong / --accent hex') + const restartBlock = findBlock('.turn-signal-chip.sig-plan-restart') + assert.match(restartBlock, /var\(--signal-plan-restart-fg\)/, + '.sig-plan-restart color must reference --signal-plan-restart-fg') + + // The Trace timeline/graph SVG variants (which share the same semantic) + // must also route through the token so a future palette change stays in + // sync across the chip + SVG surfaces. + assert.match(CSS, /\.trace-timeline-signal-badge\.sig-plan\s*\{[^}]*var\(--signal-plan-fg\)/, + 'trace-timeline-signal-badge.sig-plan must fill via --signal-plan-fg') + assert.match(CSS, /\.trace-graph-signal-ring\.sig-plan\s*\{[^}]*var\(--signal-plan-fg\)/, + 'trace-graph-signal-ring.sig-plan must stroke via --signal-plan-fg') +}) + +test('rubric-grid pass/fail cells route through --ok / --err tokens', () => { + const passBlock = findBlock('.rubric-grid-cell--pass') + const failBlock = findBlock('.rubric-grid-cell--fail') + assert.match(passBlock, /var\(--ok\)/, + '.rubric-grid-cell--pass background must reference var(--ok)') + assert.match(failBlock, /var\(--err\)/, + '.rubric-grid-cell--fail background must reference var(--err)') + assert.ok(!/#79d17b|#f96e6e/.test(passBlock + failBlock), + 'rubric-grid pass/fail cells must not use raw #79d17b / #f96e6e hex') + + const passRateBlock = findBlock('.rubric-tile-stats-rate.pass') + const failRateBlock = findBlock('.rubric-tile-stats-rate.fail') + assert.match(passRateBlock, /var\(--ok-soft\)/, + '.rubric-tile-stats-rate.pass background must reference var(--ok-soft)') + assert.match(failRateBlock, /var\(--err-soft\)/, + '.rubric-tile-stats-rate.fail background must reference var(--err-soft)') + + const passDotBlock = findBlock('.rubric-tile-stats-dot.pass') + const failDotBlock = findBlock('.rubric-tile-stats-dot.fail') + assert.match(passDotBlock, /var\(--ok\)/, + '.rubric-tile-stats-dot.pass background must reference var(--ok)') + assert.match(failDotBlock, /var\(--err\)/, + '.rubric-tile-stats-dot.fail background must reference var(--err)') +}) + +test('rubric filter chip / runtimes tab active state routes through --accent-soft', () => { + const chipActive = findBlock('.growth-fusion-filter-chip.active') + assert.match(chipActive, /var\(--accent-soft\)/, + '.growth-fusion-filter-chip.active must use --accent-soft, not raw violet') + assert.ok(!/122,\s*90,\s*248/.test(chipActive), + '.growth-fusion-filter-chip.active must not use raw violet rgba') + + const tabActive = findBlock('.runtimes-tab.active') + assert.match(tabActive, /var\(--accent-soft\)/, + '.runtimes-tab.active must use --accent-soft, not raw violet') +}) + +test('rubric-grid corner/row-head route through --muted (no manual dark override)', () => { + const cornerBlock = findBlock('.rubric-grid-corner,\n.rubric-grid-row-head') + assert.match(cornerBlock, /color:\s*var\(--muted\)/, + 'corner/row-head should color via --muted so dark mode follows automatically') + // The pair-selector followed by a manual @media dark override was the + // exact anti-pattern the review called out. Confirm it's gone. + assert.ok(!/\.rubric-grid-corner,\s*\.rubric-grid-row-head\s*\{[^}]*rgba\(255,\s*255,\s*255/.test(CSS), + 'manual dark override for corner/row-head must not exist — --muted handles it') +}) + +test('rubric-grid-name size drops to 14px (three-size rule)', () => { + const nameBlock = findBlock('.rubric-grid-name') + assert.match(nameBlock, /font-size:\s*14px/, + '.rubric-grid-name should be 14px to stay within the 3-size ladder') +})