Files
deepseek-harness/examples/desktop/scripts/qa-cdp-shoot-visual-fix.mjs
ZiyaZhang 767d65465d 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.
2026-07-19 05:23:41 -07:00

186 lines
7.5 KiB
JavaScript

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