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:
BIN
examples/desktop/docs/qa-visual-fix/01-chat-signal-plan.png
Normal file
BIN
examples/desktop/docs/qa-visual-fix/01-chat-signal-plan.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 314 KiB |
BIN
examples/desktop/docs/qa-visual-fix/02-rubrics-hint-row.png
Normal file
BIN
examples/desktop/docs/qa-visual-fix/02-rubrics-hint-row.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 403 KiB |
BIN
examples/desktop/docs/qa-visual-fix/03-growth-filter-chip.png
Normal file
BIN
examples/desktop/docs/qa-visual-fix/03-growth-filter-chip.png
Normal file
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 |
185
examples/desktop/scripts/qa-cdp-shoot-visual-fix.mjs
Normal file
185
examples/desktop/scripts/qa-cdp-shoot-visual-fix.mjs
Normal 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) })
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
/* ==========================================================================
|
||||
|
||||
122
examples/desktop/test/visual-consistency-polish.test.js
Normal file
122
examples/desktop/test/visual-consistency-polish.test.js
Normal 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')
|
||||
})
|
||||
Reference in New Issue
Block a user