// Reshoot script for task #221 (centered-card root fix). Reproduces the // t141 selfie set (19-approval / 21-exit-plan / 22-steer-chip+card / // 23-triggers-t2-t4-t5) via CDP against an Electron shell running with // DSH_QA=1, so we can verify the four in-stream card families now render // full-width and left-aligned. See docs/design-refs/density-layering-spec.md // §7 "centered-card ban" for the rule these shots prove. // // Approach: fire the real renderer entry points where they exist // (`__dshRenderer.showSteerCard`, `.maybeAppendTriggerCard`), and click // the shipped `mock-approval` debug button for the approval card. For the // exit-plan-mode card, whose builder is module-local, hand-mount a DOM // tree that matches renderer.js:3967-4050 class-for-class — the point of // the shot is the outer `.card.form.exit-plan-mode` box, which is what // the CSS rule under test governs. // // Runs on a spare CDP port (default 9237) so it doesn't collide with a // user-visible Electron instance. Node built-in WebSocket sends no Origin // header, which Chromium accepts (avoids the 403 gotcha browsers hit). import { writeFileSync, mkdirSync } from 'node:fs' import { resolve } from 'node:path' const port = process.argv[2] || '9237' const outdir = process.argv[3] || 'docs/demo-shots/centered-fix-01' mkdirSync(outdir, { recursive: true }) async function main() { const targets = await (await fetch(`http://localhost:${port}/json/list`)).json() const target = targets.find((t) => t.type === 'page') if (!target) throw new Error('no page target on port ' + port) const ws = new WebSocket(target.webSocketDebuggerUrl) await new Promise((r, x) => { ws.onopen = r; ws.onerror = (e) => x(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 = {}, timeoutMs = 15000) => new Promise((ok, err) => { const _id = id++ const timer = setTimeout(() => { pending.delete(_id); err(new Error('cdp timeout: ' + m)) }, timeoutMs) pending.set(_id, [(v) => { clearTimeout(timer); ok(v) }, (e) => { clearTimeout(timer); err(e) }]) ws.send(JSON.stringify({ id: _id, method: m, params: p })) }) // evj runs `expr` inside an IIFE with an implicit `return`. Wrap any // multi-statement expression yourself if you need `;`. 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 } await call('Page.enable') const reveal = await evj(`window.dshQa && window.dshQa.revealWindow ? await window.dshQa.revealWindow() : null`) console.error('reveal ->', JSON.stringify(reveal)) await call('Emulation.setDeviceMetricsOverride', { width: 1440, height: 900, deviceScaleFactor: 2, mobile: false, }) const shoot = async (name) => { // Settle longer to let post-mount layout finish; some cards (exit-plan // wrap, steer card status strip) trigger a second reflow after the // initial paint. 700ms is empirically enough on this machine. await new Promise((r) => setTimeout(r, 700)) // Screenshot with a 30s timeout — the default 15s occasionally trips on // the second/third shot when the compositor is under load, and retry // once on timeout so a single flake doesn't lose the shot. let shot for (let attempt = 0; attempt < 3; attempt++) { try { shot = await call('Page.captureScreenshot', { format: 'png', clip: { x: 0, y: 0, width: 1440, height: 900, scale: 2 }, }, 30000) break } catch (e) { if (attempt === 2) throw e console.error(` shoot ${name} attempt ${attempt + 1} failed: ${e.message}, retrying`) await new Promise((r) => setTimeout(r, 500)) } } const path = resolve(outdir, `${name}.png`) writeFileSync(path, Buffer.from(shot.data, 'base64')) console.log(path) } // Dismiss the onboarding modal (first-boot) if it's up. It grays out the // whole app and none of the mock injectors reach the stream through it. // The modal's "Skip and use defaults" button dismisses without asking // the user to pick a profile. await evj(`(function(){ const btns = Array.from(document.querySelectorAll('button')); const skip = btns.find(b => /skip and use defaults/i.test(b.textContent || '')); if (skip) { skip.click(); return 'onboarding dismissed'; } return 'no-onboarding'; })()`).then(r => console.error('onboarding ->', r)) await new Promise((r) => setTimeout(r, 400)) // Fresh boots land on whichever tab was last saved (often PRs). Force // chat via the tab seam if present, or by clicking the sidebar nav item. await evj(`(function(){ if (window.__dshTabs && window.__dshTabs.switchTo) { window.__dshTabs.switchTo('chat'); return 'via seam'; } // Fallback: click the "Chat" nav-item in the observation group. const items = Array.from(document.querySelectorAll('.nav-item, [data-tab]')); for (const el of items) { if (/^chat$/i.test((el.textContent || '').trim()) || el.dataset.tab === 'chat') { el.click(); return 'via click'; } } return 'no-chat-target'; })()`).then(r => console.error('switch chat ->', r)) // Close the Devtools drawer if it's open — it obscures the right edge of // in-stream cards and is unrelated to the shot's intent. The real class // (per src/renderer/devtools-panel.js:88) is `.devtools-drawer`; we hide // it via style since there's no exposed close button on the drawer chrome. await evj(`(function(){ const d = document.querySelector('.devtools-drawer'); if (d) { d.style.display = 'none'; return 'hidden'; } return 'no-drawer'; })()`).then(r => console.error('devtools close ->', r)) await new Promise((r) => setTimeout(r, 300)) // Report what the stream looks like right now (before any injection). const preflight = await evj(`(function(){ const s = document.querySelector('.stream'); return { hasStream: !!s, streamWidth: s ? Math.round(s.getBoundingClientRect().width) : null, hasRenderer: !!window.__dshRenderer, showSteer: !!(window.__dshRenderer && window.__dshRenderer.showSteerCard), maybeTrigger: !!(window.__dshRenderer && window.__dshRenderer.maybeAppendTriggerCard), hasMockApproval: !!document.getElementById('mock-approval'), activeSession: window.__dshRenderer && window.__dshRenderer.getActiveSessionId && window.__dshRenderer.getActiveSessionId(), }; })()`) console.error('preflight ->', JSON.stringify(preflight)) // Helper: wipe any leftover cards / plan wraps / steer chips from // the stream so successive shots don't stack. Kept as a callable expr. const clearExpr = `(function(){ const s = document.querySelector('.stream'); if (!s) return 'no-stream'; for (const c of Array.from(s.querySelectorAll('.card, .exit-plan-mode-wrap, .steer-chip'))) c.remove(); return 'cleared'; })()` // --- 19: approval card ---------------------------------------------------- await evj(clearExpr) const clickRes = await evj(`(function(){ const b = document.getElementById('mock-approval'); if (!b) return 'no-button'; b.click(); return { present: !!document.querySelector('.card.approval') }; })()`) console.error('19 mock-approval click ->', JSON.stringify(clickRes)) await shoot('19-approval-waiting') // --- 21: exit-plan-mode --------------------------------------------------- await evj(clearExpr) const planMount = await evj(`(function(){ const stream = document.querySelector('.stream'); if (!stream) return 'no-stream'; // Mount the exact DOM the renderer builds at renderer.js:3967-4050. // The shot's job is to verify the outer .card.form.exit-plan-mode box // is full-width — that comes from the density-spec §7 rule under test. const wrap = document.createElement('div'); wrap.className = 'exit-plan-mode-wrap'; const el = document.createElement('div'); el.className = 'card form exit-plan-mode'; const h = document.createElement('h4'); h.textContent = 'Exit plan mode?'; el.appendChild(h); const desc = document.createElement('div'); desc.className = 'label'; desc.textContent = 'Review the plan below. Edit if needed, add a comment, then confirm to leave plan mode.'; el.appendChild(desc); const planLabel = document.createElement('div'); planLabel.className = 'exit-plan-mode-section-label'; planLabel.textContent = 'PLAN'; el.appendChild(planLabel); const planInput = document.createElement('textarea'); planInput.className = 'exit-plan-mode-plan'; planInput.rows = 5; planInput.value = '1. Read src/renderer/session-tree-page.js for the fork-tree render path.\\n2. Add a highlight to selected fork rows.\\n3. Update snapshot tests.\\n4. Reshoot selfie 01 and 02.'; el.appendChild(planInput); const commentLabel = document.createElement('div'); commentLabel.className = 'exit-plan-mode-section-label'; commentLabel.textContent = 'COMMENT'; el.appendChild(commentLabel); const commentInput = document.createElement('textarea'); commentInput.className = 'exit-plan-mode-comment'; commentInput.rows = 2; commentInput.placeholder = 'add a note before confirming'; el.appendChild(commentInput); const actions = document.createElement('div'); actions.className = 'actions'; actions.style.marginTop = '10px'; actions.innerHTML = ''; el.appendChild(actions); wrap.appendChild(el); const rail = document.createElement('div'); rail.className = 'plan-diff-rail'; rail.innerHTML = "