fix(desktop): visual consistency sweep — plan-signal teal + rubric hint compact + token cleanup

Batch fix for review-visual-flow's two P0 + five P1 findings across the
Chat / Rubrics / Growth surfaces. Everything routes through tokens — no
new hex family, no gradient hero, no violet competing with --accent.

P0-1 blue collision — plan-signal chip shared the exact --accent hue
with --turn-action-edge, so a .sig-plan chip inside an action turn fused
visually with the turn's left rail. Introduces --signal-plan-fg / -bg /
-border (+ restart variants) on a teal axis (light #0f766e, dark
#5eead4), rewires .turn-signal-chip.sig-plan, .sig-plan-restart, their
dark override, and the .trace-timeline-signal-badge / .trace-graph-
signal-ring SVG variants to the tokens. Now the four signal families
(error red / loop red / redundant amber / plan teal) all sit off the
blue axis so the turn-edge blue keeps its exclusive whole-turn-action
semantic.

P0-2 rubric-hint-card hero — was a full-width purple gradient banner
(12px radius, 20px icon, primary CTA) violating spec §2 'L1 material
styled as L0 hero'. Repainted as a 28px compact row: single hairline on
--surface, 4px radius, downgraded icon to '·', inlined title+subtitle,
CTA to ghost small. Reads as a Fields-tree-grade nudge, not page chrome.

P1 batch (5 CSS token conversions):
  1. .rubric-tile-stats-rate/-dot .pass/.fail: raw #79d17b/#f96e6e →
     --status-ok-fg / --status-err-fg.
  2. .rubrics-empty-illust svg [fill]: raw #a89a83 → --icon-mute.
  3. .growth-filter-chip.active: raw --accent-* → --nav-item-active-*.
  4. .kb-tabs .active hue: raw --accent-fg → --text.
  5. .rubric-grid-name size: 15px → 14px (three-size rule).

7 renderer test file (visual-consistency-polish.test.js) locks all the
above via style.css AST assertions. 3 shots + 3 before shots under
docs/qa-visual-fix/ document the delta on chat / rubrics / growth.

10 files, +400/-49. Visual-only, no behavioral changes.
This commit is contained in:
ZiyaZhang
2026-07-19 05:23:41 -07:00
parent 88bd4ac244
commit 767d65465d
10 changed files with 400 additions and 49 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

View File

@@ -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) })

View File

@@ -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) {

View File

@@ -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;
}
/* ==========================================================================

View File

@@ -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')
})