feat(paperclip-runner): Phase 7B issue-thread UX contract, mockup, and capture recipe

Binding interaction + screenshot contract for track 7G: thread taxonomy,
composer states, native interaction-card lifecycle, Evidence panel,
desktop/mobile layouts, session lifecycle (refresh/reconnect/stop/reset/
replay), identity markers, deterministic screenshot matrix, and
accessibility acceptance — expressed in the Phase 7A capability terms.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-10 07:25:53 +00:00
1 parent aa87487a2c
commit 6573cf4b44
3 files changed
+808

No files matched your search

@@ -0,0 +1,44 @@
import { chromium } from "@playwright/test";
import { mkdirSync } from "node:fs";
const root = new URL("./packages/paperclip-runner/docs/design/mockups/phase-7-issue-thread.html", import.meta.url).href;
const outDir = process.env.OUT_DIR;
mkdirSync(outDir, { recursive: true });
const shots = [
["thread-pending-confirmation", "?state=pending"],
["thread-baseline", "?state=baseline"],
["thread-resolved-denial-panel", "?state=resolved&panel=open"],
["turn-streaming", "?state=streaming"],
["replay-mode", "?state=replay"],
];
const viewports = {
desktop: { width: 1440, height: 900 },
mobile: { width: 390, height: 844 },
};
const browser = await chromium.launch({
executablePath: process.env.CHROMIUM_PATH || undefined,
});
for (const [vpName, vp] of Object.entries(viewports)) {
const page = await browser.newPage({ viewport: vp });
for (const [slug, params] of shots) {
let p = params;
// mobile: evidence panel state uses the segment instead of the side panel
if (vpName === "mobile" && slug === "thread-resolved-denial-panel") p = "?state=resolved&seg=evidence";
await page.goto(root + p);
await page.waitForSelector('html[data-thread-state="settled"]');
const hscroll = await page.evaluate(() => {
const el = document.scrollingElement;
return el.scrollWidth - el.clientWidth;
});
if (vp.width === 390 && hscroll > 0) {
console.error(`FAIL h-scroll ${slug} mobile: overflow ${hscroll}px`);
process.exitCode = 1;
}
await page.screenshot({ path: `${outDir}/${slug}--${vpName}.png` });
console.log(`ok ${slug}--${vpName} (hscroll=${hscroll})`);
}
await page.close();
}
await browser.close();
@@ -0,0 +1,373 @@
<!doctype html>
<html lang="en" data-session-mode="fake" data-thread-state="pending">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MCK-31 — Phase 7 issue thread (mockup)</title>
<style>
/* Deterministic mockup for the Phase 7B issue-thread UX contract.
States via query params: ?state=baseline|pending|resolved|replay|streaming
&panel=open &seg=thread|evidence
All timestamps are fixture-fixed. No animation (screenshot determinism). */
:root{
--background:oklch(0.17 0.005 260); --card:oklch(0.21 0.006 260);
--card-2:oklch(0.24 0.007 260); --foreground:oklch(0.93 0.004 260);
--muted-fg:oklch(0.68 0.008 260); --border:oklch(0.31 0.008 260);
--accent:oklch(0.28 0.01 260); --violet:oklch(0.62 0.19 295);
--indigo:oklch(0.62 0.17 275); --green:oklch(0.72 0.17 150);
--red:oklch(0.64 0.19 25); --amber:oklch(0.78 0.15 80);
--cyan:oklch(0.75 0.12 210); --ring:oklch(0.62 0.17 275);
--radius:10px;
}
*{box-sizing:border-box} html,body{margin:0;height:100%}
[hidden]{display:none!important}
body{background:var(--background);color:var(--foreground);
font:14px/1.55 "Inter","Segoe UI","DejaVu Sans","Liberation Sans",ui-sans-serif,system-ui,sans-serif;
display:flex;flex-direction:column;overflow:hidden}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}
.xs{font-size:12px;color:var(--muted-fg)}
button{font:inherit;color:inherit;background:none;border:none;cursor:pointer}
/* ---------- header ---------- */
header{border-bottom:1px solid var(--border);padding:10px 16px;flex:none;
display:flex;flex-direction:column;gap:8px;background:var(--background);z-index:5}
.hrow{display:flex;align-items:center;gap:10px;flex-wrap:wrap;min-width:0}
.ident{font-family:ui-monospace,Menlo,monospace;font-size:12px;color:var(--muted-fg)}
h1{font-size:17px;font-weight:700;margin:0;white-space:nowrap;overflow:hidden;
text-overflow:ellipsis;min-width:0;flex:1}
.badge{display:inline-flex;align-items:center;gap:5px;border-radius:999px;
padding:2px 9px;font-size:11.5px;font-weight:500;border:1px solid var(--border)}
.badge .dot{width:7px;height:7px;border-radius:999px}
.b-inprogress{color:var(--indigo);border-color:color-mix(in oklch,var(--indigo),transparent 55%)}
.b-done{color:var(--green);border-color:color-mix(in oklch,var(--green),transparent 55%)}
.chip{display:inline-flex;align-items:center;gap:6px;border-radius:999px;padding:3px 10px;
font-size:11.5px;font-weight:600;border:1px solid}
.c-agent{color:var(--cyan);border-color:color-mix(in oklch,var(--cyan),transparent 50%);
background:color-mix(in oklch,var(--cyan),transparent 92%)}
.c-runner{color:var(--foreground);border-color:var(--border);background:var(--card)}
.c-mock{color:var(--amber);border-color:color-mix(in oklch,var(--amber),transparent 50%);
background:color-mix(in oklch,var(--amber),transparent 92%)}
.c-runner .dot{background:var(--muted-fg)}
.hactions{display:flex;gap:8px;margin-left:auto;flex-wrap:wrap}
.btn{border:1px solid var(--border);border-radius:8px;padding:5px 12px;font-size:12.5px;
background:var(--card)}
.btn:hover{background:var(--accent)}
.btn-stop{color:var(--red);border-color:color-mix(in oklch,var(--red),transparent 55%)}
.btn-primary{background:var(--indigo);border-color:transparent;color:oklch(0.98 0 0);font-weight:600}
/* ---------- layout ---------- */
.shell{display:flex;flex:1;min-height:0}
.threadwrap{flex:1;display:flex;flex-direction:column;min-width:0}
.scroll{flex:1;overflow-y:auto;padding:18px 16px 8px}
.thread{max-width:760px;margin:0 auto;display:flex;flex-direction:column;gap:12px}
.turnhead{display:flex;align-items:center;gap:8px;color:var(--muted-fg);font-size:11.5px;
margin-top:6px}
.turnhead::before,.turnhead::after{content:"";flex:1;border-top:1px solid var(--border)}
.card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);
padding:10px 14px;position:relative}
.chead{display:flex;align-items:baseline;gap:8px;margin-bottom:4px;flex-wrap:wrap}
.author{font-weight:600;font-size:13px}
.durable{position:absolute;top:0;right:0;border-radius:0 var(--radius) 0 8px;
background:color-mix(in oklch,var(--indigo),transparent 82%);color:var(--indigo);
font-size:10.5px;font-weight:600;padding:2px 9px}
.sysnotice{display:flex;gap:8px;align-items:center;color:var(--muted-fg);font-size:12px;
padding:0 6px}
.strip{display:flex;align-items:center;gap:8px;border:1px solid var(--border);
border-radius:8px;padding:6px 10px;font-size:12.5px;background:var(--card-2)}
.strip .op{font-family:ui-monospace,Menlo,monospace;font-size:12px}
.strip .sum{color:var(--muted-fg);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
flex:1;min-width:0}
.ok{color:var(--green)} .deny{color:var(--red)} .run{color:var(--amber)}
.strip.denied{border-color:color-mix(in oklch,var(--red),transparent 60%);
background:color-mix(in oklch,var(--red),transparent 94%)}
.viewlink{color:var(--indigo);font-size:11.5px;white-space:nowrap}
/* interaction cards */
.icard{border-left:3px solid var(--violet)}
.icard.resolved{border-left-color:var(--green)}
.icard.rejected{border-left-color:var(--red)}
.icard.stale{border-left-color:var(--border);opacity:.62}
.statechip{display:inline-flex;align-items:center;gap:5px;border-radius:999px;
padding:1.5px 9px;font-size:11px;font-weight:600}
.s-pending{background:color-mix(in oklch,var(--violet),transparent 85%);color:var(--violet)}
.s-ok{background:color-mix(in oklch,var(--green),transparent 87%);color:var(--green)}
.s-rej{background:color-mix(in oklch,var(--red),transparent 88%);color:var(--red)}
.s-gray{background:var(--accent);color:var(--muted-fg)}
.opt{display:flex;gap:9px;align-items:flex-start;border:1px solid var(--border);
border-radius:8px;padding:9px 12px;margin-top:8px}
.opt input{margin-top:3px;accent-color:var(--violet)}
.iactions{display:flex;gap:8px;margin-top:12px}
.target{display:inline-flex;gap:6px;align-items:center;background:var(--card-2);
border:1px solid var(--border);border-radius:6px;padding:2px 8px;font-size:11.5px}
/* composer */
.composer{flex:none;border-top:1px solid var(--border);padding:10px 16px 14px;
background:var(--background)}
.cbox{max-width:760px;margin:0 auto}
.cfield{display:flex;gap:8px;align-items:flex-end;border:1px solid var(--border);
border-radius:12px;background:var(--card);padding:9px 10px 9px 14px}
.cfield textarea{flex:1;background:none;border:none;resize:none;color:inherit;font:inherit;
height:22px;outline:none}
.helper{margin-top:6px;font-size:11.5px;color:var(--muted-fg)}
.helper a{color:var(--indigo)}
.composer[data-composer-state="waiting"] .cfield,
.composer[data-composer-state="disabled"] .cfield{opacity:.55}
/* evidence panel */
aside{width:384px;flex:none;border-left:1px solid var(--border);display:none;
flex-direction:column;background:var(--card);min-height:0}
body[data-panel="open"] aside{display:flex}
.phead{padding:10px 14px;border-bottom:1px solid var(--border);display:flex;gap:8px;
align-items:center;font-weight:600;font-size:13px}
.pscroll{overflow-y:auto;flex:1}
.sect{border-bottom:1px solid var(--border)}
.sect>button{width:100%;display:flex;align-items:center;gap:8px;padding:9px 14px;
font-size:12.5px;font-weight:600;text-align:left}
.sect .count{margin-left:auto;color:var(--muted-fg);font-weight:400;font-size:11.5px}
.sbody{padding:2px 14px 12px;font-size:12px;color:var(--muted-fg);display:none}
.sect.open .sbody{display:block}
.rec{border:1px solid var(--border);border-radius:8px;padding:7px 10px;margin-top:6px;
background:var(--background)}
.rec .k{color:var(--muted-fg)} .rec .v{color:var(--foreground)}
.allow{color:var(--green);font-weight:600} .denyv{color:var(--red);font-weight:600}
.toolrow{display:flex;gap:8px;padding:3px 0;font-family:ui-monospace,Menlo,monospace;
font-size:11.5px;color:var(--foreground)}
.toolrow .grant{margin-left:auto;color:var(--amber);font-family:inherit}
.grouplabel{margin-top:9px;font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;
color:var(--muted-fg);font-weight:700}
/* replay + reconnect banners */
.banner{display:none;align-items:center;gap:10px;padding:7px 16px;font-size:12.5px;
border-bottom:1px solid var(--border)}
body[data-state="replay"] .banner.replay{display:flex;background:color-mix(in oklch,var(--violet),transparent 90%)}
/* mobile */
.seg{display:none}
@media (max-width:700px){
aside{position:static;width:100%;border-left:none}
body[data-seg="evidence"] aside{display:flex}
body[data-seg="evidence"] .threadwrap{display:none}
body:not([data-seg="evidence"]) aside{display:none}
.phead #pclose{display:none}
.seg{display:flex;gap:4px;background:var(--card);border:1px solid var(--border);
border-radius:10px;padding:3px;align-self:stretch}
.seg button{flex:1;border-radius:7px;padding:6px 0;font-size:13px;font-weight:600;
color:var(--muted-fg)}
.seg button[aria-selected="true"]{background:var(--accent);color:var(--foreground)}
.hactions .btn:not(.btn-stop):not(.menu){display:none}
h1{font-size:15px}
}
</style>
</head>
<body data-panel="closed" data-seg="thread" data-state="baseline">
<header>
<div class="hrow">
<span class="ident">MCK-31</span>
<h1>Ship the retry queue for outbound webhooks</h1>
<span class="badge b-inprogress"><span class="dot" style="background:var(--indigo)"></span>In&nbsp;progress</span>
<span class="badge" style="color:var(--amber)">▲ High</span>
<div class="hactions">
<button class="btn">Scenario ▾</button>
<button class="btn">Replay</button>
<button class="btn">Reset</button>
<button class="btn btn-stop" id="hstop" hidden>Stop</button>
<button class="btn menu" aria-haspopup="menu">⋯</button>
<button class="btn" id="ptoggle" aria-expanded="false">Evidence</button>
</div>
</div>
<div class="hrow">
<span class="chip c-agent" id="agentchip">◆ Fake agent</span>
<span class="chip c-runner"><span class="dot"></span><span id="runnerchip">In-process runner</span></span>
<span class="chip c-mock">⛁ Mock Paperclip</span>
<span class="xs">assignee&nbsp;<b style="color:var(--foreground)">mock-agent-01</b> · run <span class="mono">turn 3 idle</span></span>
<div class="seg" role="tablist" aria-label="Surface">
<button role="tab" aria-selected="true" id="segthread">Thread</button>
<button role="tab" aria-selected="false" id="segev">Evidence <span id="denybadge" hidden style="color:var(--red)">·1</span></button>
</div>
</div>
</header>
<div class="banner replay">▶ Replay · fake source — recorded run <span class="mono">rec-hb-baseline-01</span> · event 12 / 41 <button class="btn" style="margin-left:auto">Step</button><button class="btn">Next turn</button></div>
<div class="shell">
<div class="threadwrap">
<div class="scroll">
<div class="thread">
<div class="sysnotice">⚙ Wake: <span class="mono">issue_blockers_resolved</span> → checkout by mock-agent-01 · 09:14:02</div>
<div class="turnhead">Turn 1 · fake · 2 tool calls · 09:14:05</div>
<div class="card">
<div class="chead"><span class="author">You (board user)</span><span class="xs">09:14:05</span></div>
Please pick up MCK-31, summarize the current state, and update the plan document before you start.
</div>
<div class="strip"><span class="ok">✓</span><span class="op">get_task_context</span><span class="sum">read issue, 4 comments, plan r3, 1 blocker</span><a class="viewlink" href="#">View in Evidence</a><span>›</span></div>
<div class="card">
<div class="chead"><span class="author" style="color:var(--cyan)">Fake agent</span><span class="xs">09:14:07</span></div>
The retry queue work is unblocked — <span class="mono">MCK-29</span> (schema change) is done. I'll revise the plan to cover backoff policy and dead-letter handling, then report progress.
</div>
<div class="strip"><span class="ok">✓</span><span class="op">write_document</span><span class="sum">plan r3 → r4 · “Add exponential backoff + DLQ section”</span><a class="viewlink" href="#">View in Evidence</a><span>›</span></div>
<div class="card">
<div class="durable">Recorded to mock thread</div>
<div class="chead"><span class="author">mock-agent-01</span><span class="xs">progress · 09:14:09</span></div>
Plan updated to r4: added backoff policy, DLQ routing, and a rollout gate. Remaining: confirm the plan, then implement the queue worker. Next owner: board confirmation.
</div>
<div class="turnhead">Turn 2 · fake · 2 tool calls · 09:15:20</div>
<div class="strip denied" id="denystrip" hidden><span class="deny">✕</span><span class="op">create_task</span><span class="sum">denied: missing grant <span class="mono">su:read_or_write</span></span><a class="viewlink" href="#">View in Evidence</a><span>›</span></div>
<!-- pending confirmation interaction -->
<section class="card icard" id="pendingcard" aria-label="Confirm plan revision r4">
<div class="chead">
<span class="author">Confirmation requested</span>
<span class="target mono">⌘ plan · r4</span>
<span class="statechip s-pending" style="margin-left:auto">● Waiting for you</span>
</div>
Accept the revised implementation plan? Backoff policy caps at 6 retries over 32 minutes; failures beyond that route to the dead-letter queue for manual review.
<label class="opt"><input type="checkbox" checked> <span><b>Include rollout gate</b><br><span class="xs">Hold at 5% of traffic for the first hour.</span></span></label>
<div class="iactions">
<button class="btn btn-primary">Accept plan r4</button>
<button class="btn">Request changes…</button>
</div>
</section>
<!-- resolved history variants -->
<section class="card icard resolved" id="resolvedok" hidden aria-label="Answered question">
<div class="chead"><span class="author">Question</span><span class="statechip s-ok" style="margin-left:auto">✓ Answered</span></div>
Which environment should the bounded retry test target? <b style="color:var(--foreground)">→ staging</b>
</section>
<section class="card icard rejected" id="resolvedrej" hidden aria-label="Changes requested">
<div class="chead"><span class="author">Confirmation</span><span class="target mono">⌘ plan · r3</span><span class="statechip s-rej" style="margin-left:auto">✕ Changes requested</span></div>
<span class="xs">Reason: “Backoff window too aggressive — cap at 32 minutes, not 2 hours.”</span>
</section>
<section class="card icard stale" id="resolvedstale" hidden aria-label="Stale confirmation">
<div class="chead"><span class="author">Confirmation</span><span class="target mono">⌘ plan · r2</span><span class="statechip s-gray" style="margin-left:auto">◌ Stale — plan moved to r3</span></div>
<span class="xs">Superseding revision: plan r3 · request evidence →</span>
</section>
<section class="card icard stale" id="resolvedsuper" hidden aria-label="Superseded confirmation">
<div class="chead"><span class="author">Checkbox confirmation</span><span class="statechip s-gray" style="margin-left:auto">◌ Superseded by a later comment</span></div>
<span class="xs">See comment at 09:18:44 · request evidence →</span>
</section>
<div class="sysnotice" id="stopnotice" hidden>⏹ Turn 3 stopped by user · partial output retained · 09:16:31</div>
<div class="card" id="streamcard" hidden>
<div class="chead"><span class="author" style="color:var(--cyan)">Fake agent</span><span class="xs">streaming…</span></div>
Registering the deliverable now — the queue-worker design note is ready and I’ll attach it to the issue before requesting review▍
</div>
</div>
</div>
<div class="composer" data-composer-state="waiting" id="composer">
<div class="cbox">
<div class="cfield">
<textarea aria-label="Message the agent" placeholder="Message the agent about MCK-31…" disabled></textarea>
<button class="btn btn-primary" id="sendbtn" disabled>Send</button>
</div>
<div class="helper" id="chelper"><a href="#pendingcard">Answer the pending request above</a> to continue.</div>
</div>
</div>
</div>
<aside aria-label="Run evidence">
<div class="phead">Evidence <span class="xs" style="font-weight:400">· Turn 2 ▾</span>
<button class="btn" style="margin-left:auto" id="pclose">Close</button></div>
<div class="pscroll">
<div class="sect open">
<button aria-expanded="true">Tools exposed <span class="count">7 visible · 3 withheld</span></button>
<div class="sbody">
<div class="grouplabel">Agent tool — always</div>
<div class="toolrow">get_task_context</div>
<div class="toolrow">report_progress</div>
<div class="toolrow">write_document</div>
<div class="toolrow">request_human_input</div>
<div class="toolrow">register_deliverable</div>
<div class="toolrow">finish_task</div>
<div class="toolrow">block_task</div>
<div class="grouplabel">Agent tool — granted</div>
<div class="toolrow" style="color:var(--muted-fg)">— none in this scenario</div>
<div class="grouplabel">Control plane (not exposed to the agent)</div>
<div class="toolrow" style="color:var(--muted-fg)">checkout / wake routing<span class="grant">control_plane_owned</span></div>
<div class="toolrow" style="color:var(--muted-fg)">budget · audit · replay<span class="grant">control_plane_owned</span></div>
</div>
</div>
<div class="sect open">
<button aria-expanded="true">Authorization <span class="count">3 allow · <span class="denyv" id="denycount" hidden>1 deny</span></span></button>
<div class="sbody">
<div class="rec"><span class="allow">allow</span> <span class="mono">write_document</span><br><span class="k">claims:</span> <span class="v mono">task:active</span> · <span class="k">redactions:</span> none · <span class="k">state:</span> plan r3→r4</div>
<div class="rec" id="denyrec" hidden><span class="denyv">deny</span> <span class="mono">create_task</span><br><span class="k">claims considered:</span> <span class="v mono">task:active</span> · <span class="k">missing:</span> <span class="v mono">su:read_or_write</span><br><span class="k">reason:</span> delegation requires an explicit grant</div>
</div>
</div>
<div class="sect"><button aria-expanded="false">Calls &amp; results <span class="count">4</span></button><div class="sbody"></div></div>
<div class="sect"><button aria-expanded="false">Control plane <span class="count">6</span></button><div class="sbody"></div></div>
<div class="sect"><button aria-expanded="false">Runner &amp; events <span class="count">18</span></button><div class="sbody"></div></div>
<div class="sect open">
<button aria-expanded="true">State diff <span class="count">2 entities</span></button>
<div class="sbody">
<div class="rec"><span class="k">documents.plan</span> <span class="v mono">r3 → r4</span><br><span class="k">interactions</span> <span class="v mono">+1 pending (confirmation)</span></div>
</div>
</div>
<div class="sect"><button aria-expanded="false">Traceability <span class="count">ix/ix-confirmation-plan-01</span></button><div class="sbody"></div></div>
<div class="sect open">
<button aria-expanded="true">Parity <span class="count">5/5 pass</span></button>
<div class="sbody"><div class="rec"><span class="allow">pass</span> plan write is revision-bound · <span class="allow">pass</span> confirmation targets r4 · <span class="allow">pass</span> no forbidden op called</div></div>
</div>
</div>
</aside>
</div>
<script>
// Deterministic state switching — query params only, no clocks, no randomness.
const q = new URLSearchParams(location.search);
const state = q.get('state') || 'baseline';
const body = document.body;
body.dataset.state = state;
const show = id => document.getElementById(id).hidden = false;
const hide = id => document.getElementById(id).hidden = true;
const composer = document.getElementById('composer');
const helper = document.getElementById('chelper');
const send = document.getElementById('sendbtn');
if (state === 'baseline' || state === 'resolved' || state === 'replay' || state === 'streaming') hide('pendingcard');
if (state === 'baseline') { composer.dataset.composerState='ready';
helper.textContent='Cmd+Enter to send'; send.disabled=false;
composer.querySelector('textarea').disabled=false; }
if (state === 'resolved') { ['resolvedok','resolvedrej','resolvedstale','resolvedsuper','denystrip','denyrec','denybadge','denycount'].forEach(show);
show('stopnotice'); composer.dataset.composerState='ready';
helper.textContent='Cmd+Enter to send'; send.disabled=false;
composer.querySelector('textarea').disabled=false; }
if (state === 'streaming') { show('streamcard'); show('hstop');
composer.dataset.composerState='streaming'; send.textContent='Stop';
send.classList.remove('btn-primary'); send.classList.add('btn-stop'); send.disabled=false;
const ta=composer.querySelector('textarea'); ta.disabled=false;
helper.textContent='Codex is working — send to steer, or stop the turn.'; }
if (state === 'replay') { composer.dataset.composerState='disabled';
helper.textContent='Replay is read-only.';
document.getElementById('agentchip').textContent='▶ Replay';
document.getElementById('runnerchip').textContent='Recorded run'; }
if (q.get('panel') === 'open') { body.dataset.panel='open';
document.getElementById('ptoggle').setAttribute('aria-expanded','true'); }
if (q.get('seg') === 'evidence') { body.dataset.panel='open'; body.dataset.seg='evidence';
document.getElementById('segev').setAttribute('aria-selected','true');
document.getElementById('segthread').setAttribute('aria-selected','false'); }
document.getElementById('ptoggle').onclick=()=>{ body.dataset.panel = body.dataset.panel==='open'?'closed':'open'; };
document.getElementById('pclose').onclick=()=>{ body.dataset.panel='closed'; body.dataset.seg='thread'; };
// settle signal: scroll thread to bottom, then mark settled (capture hook)
const sc = document.querySelector('.scroll'); sc.scrollTop = sc.scrollHeight;
document.documentElement.dataset.threadState = 'settled';
</script>
</body>
</html>
@@ -0,0 +1,391 @@
# Phase 7B — Issue-Thread UX Contract
Status: **binding** for track 7G (Paperclip-style Web UI) and reviewable by tracks 7E/7K.
Source of authority: Phase 7 plan (PAP-16897 `plan` document), Revision 3 §"Page information
architecture", §"Native issue-thread interactions", §"Live execution contract", and Revision 4
§"Final evidence eligibility". Capability vocabulary: Phase 7A generated contract
(`generated/phase-07/capabilities.yaml`, `mcp-tool-map.yaml`, `eval-traceability.yaml`,
`contract-schema.json` — PAP-16939).
If a corner of this contract is underspecified, 7G comments on PAP-16940 and waits for a
revision. 7G does not diverge silently. Deviations are either fixed in the implementation or
written back into this document as an explicit revision.
---
## 0. Vocabulary (normative)
All UI copy and all implementation identifiers use the 7A terms. The UI never invents
capability language.
- **Dispositions** (closed enum): `control_plane_owned`, `always_agent_tool`,
`optional_agent_tool`.
- **Always semantic operations** (thread-visible verbs): `get_task_context`,
`report_progress`, `answer_status_question`, `finish_task`, `block_task`, `request_review`,
`write_document` (+ revision reads), `request_human_input` (kinds `questions`,
`confirmation`, `checkbox`, `suggest_tasks`, `item_verdicts`), `register_deliverable`.
- **Optional operation families**: discovery (`scoped_discovery`, `search_tasks`,
`list_agents`, …), delegation/dependencies (`create_task`, `delegate_task`,
`set_dependencies`, `create_blocked_task`), approvals (`request_approval`,
`decide_approval`, …), cases, workspace runtime, routines, company skills, secrets, admin,
escape hatch. Grants render exactly as 7A writes them (e.g. `rf:read_or_write`).
- **Mock-state kinds**: `operation_result`, `runtime_decision_record`, `active_task_context`.
- **Eval keys**: case `id` (e.g. `ix-confirmation-plan-01`), `group` (16 groups),
`fixtureProfile` (e.g. `hb-baseline`), `browserEvidenceRecipe` (e.g. `hb/hb-context-01`),
`expectedSemanticOperations`, `forbiddenOperations`.
- **Modes**: `live` (real Codex via real runnerd), `fake` (deterministic fake agent),
`replay` (recorded canonical events). A surface never shows an unlabeled mode.
Human-readable labels (UI copy) for the dispositions are fixed: `Agent tool — always`,
`Agent tool — granted`, `Control plane`. The machine term appears in the debug panel and in
tooltips, never invented synonyms ("system tool", "built-in", etc. are forbidden).
## 1. Identity and mode banner (always visible)
The page must answer, at all times and on every viewport, four questions: who is the agent,
what runs it, what control plane it talks to, and whether this session is live.
1. **Three identity chips**, rendered in the issue header and never hidden by scroll
(header is sticky):
- `Real Codex` — cyan chip, only when the active session mode is `live`. In `fake` mode the
chip reads `Fake agent`; in `replay` it reads `Replay`.
- `Real runnerd` — neutral chip with a live process dot (pulse while a runnerd session is
attached; static gray when detached). Shown in `live` mode only; `fake`/`replay` show
`In-process runner`.
- `Mock Paperclip` — amber chip, shown in **all** modes. Tooltip: "All issue records are
mock. No real Paperclip API is reachable."
2. **Mode is data, not styling**: the root element carries `data-session-mode="live|fake|replay"`
and the chips render from the server-reported session record. A session artifact with
`mode=fake` must be visually distinguishable from `live` in every screenshot (Revision 4
evidence eligibility).
3. **Mock identifier scheme**: mock issues use a reserved prefix that cannot be confused with
a real company (fixture default: `MCK-<n>`, e.g. `MCK-31`). The UI never renders a
`/PAP/...`-style link to a real control plane; mock entity links navigate inside the
explorer only.
4. **No credential surface**: no header, panel, tooltip, error, or diff may contain a token,
key, or `Authorization` value. Redacted fields render as `••• redacted` with the redaction
rule name from the authorization record.
## 2. Page information architecture
### 2.1 Desktop (reference viewport 1440×900)
```
┌──────────────────────────────────────────────────────────────┬───────────────┐
│ Issue header (sticky): │ │
│ MCK-31 · title · StatusBadge · PriorityIcon · assignee │ Debug panel │
│ [Fake agent|Real Codex] [Real runnerd] [Mock Paperclip] │ (collapsed │
│ run state · Scenario ▾ · Replay · Reset · Stop │ by default; │
├──────────────────────────────────────────────────────────────┤ resizable │
│ Thread column (centered, max-width 760px, single column): │ 320–640px) │
│ turn groups: user message → agent activity → responses │ │
│ comment / interaction / document / deliverable / │ │
│ disposition cards in chronological order │ │
│ system notices (wakes, reconciliation) as one-line rows │ │
├──────────────────────────────────────────────────────────────┤ │
│ Composer (pinned bottom of thread column) │ │
└──────────────────────────────────────────────────────────────┴───────────────┘
```
- The **thread is the primary readable surface**. Default panel state is **collapsed**; the
user's open/width choice persists per browser (`localStorage`), and deep links may force it
(§10). The page never leads with raw protocol events.
- Thread column measure: max-width 760px, centered in the remaining space; `text-sm` body
scale per the Paperclip type ramp.
- Debug panel: right side, resizable 320–640px with a keyboard-operable splitter
(`role="separator"`, `aria-valuenow`), collapse toggle in the header (`Evidence` button with
open/closed state).
- Below 1100px viewport width the panel switches from side-by-side to an overlay sheet from
the right (same content, same tab order), so the thread column never drops below ~600px.
### 2.2 Mobile (reference viewport 390×844)
- One column. Sticky condensed header: row 1 = `MCK-31` + StatusBadge + overflow menu `⋯`
(Scenario, Replay, Reset inside the menu; **Stop stays outside the menu** whenever a turn is
active). Row 2 = the three identity chips, wrapping to a second line if needed — chips never
cause horizontal page scroll.
- A **segmented control** with exactly two segments — `Thread` and `Evidence` — sits under the
header (pattern validated in the Phase 7I mobile review; a drawer was rejected there and
stays rejected). Thread is default. Badge on `Evidence` shows the current turn's
authorization-denial count when nonzero.
- Composer is fixed to the bottom of the `Thread` segment, above the keyboard inset
(`env(safe-area-inset-bottom)`).
- **No horizontal page scroll at 390px** (`document.scrollingElement.scrollWidth <=
clientWidth` is an automated acceptance check). `pre`/code/diff blocks wrap or scroll inside
their own container only.
- Touch targets ≥ 44×44 CSS px for every actionable element, including chip tooltips
(tap-to-toggle on touch), accordion headers, and interaction-card controls.
## 3. Thread item taxonomy
Every thread item renders from a **mock-core record or canonical event** — the browser holds
no state authority, computes no claim/policy/parity result, and never mutates mock state
outside an interaction response (§5). Items in chronological order, grouped by turn:
| # | Item | Source | Anatomy |
|---|------|--------|---------|
| T1 | **User message** | thread record | Right-aligned bubble style is **not** used; Paperclip comment card with author "You (board user)", timestamp, markdown body. |
| T2 | **Agent response** | model output items | Comment card, author = agent identity chip (`Fake agent` / `Real Codex`), streaming state per §6. Model prose only — never confused with durable records (see T3). |
| T3 | **Durable progress comment** | `report_progress` / `answer_status_question` `operation_result` | Distinct comment card with a `Recorded to mock thread` marker (filled corner tag + tooltip naming the semantic operation). This is the visual boundary between ephemeral model text (T2) and durable mock records. |
| T4 | **Tool activity strip** | semantic call + typed result | One line per call inside the turn group: status glyph (`✓ ok`, `✕ denied`, `⏳ running`), operation id (`write_document`), one-line human summary, `›` expander. Expanded: request args (redacted per rules), typed result, and a `View in Evidence` link that opens the debug panel pre-filtered to that call. Strips are collapsed by default; a turn shows at most 3 strips + `N more…` expander (progressive disclosure). |
| T5 | **Interaction card** | `request_human_input` record | §5. Rendered at its chronological position. |
| T6 | **Document card** | `write_document` result | Document key + title, revision chain (`r3 → r4`), author, `View diff` (opens Evidence → State), and stale marker when a later revision exists. |
| T7 | **Deliverable card** | `register_deliverable` result | File/ref name, kind (attachment bytes / external ref / workspace file), size, registered-by, download affordance for attachment-backed deliverables. |
| T8 | **Dependency / delegation card** | optional-op results (`create_task`, `set_dependencies`, …) | Created mock child issues with identifiers and blocker edges (`MCK-32 blocks MCK-31`). |
| T9 | **Disposition card** | `finish_task` / `block_task` / `request_review` result | Terminal banner card: new status via StatusBadge, explanation body, named blocker owner where applicable. After a terminal disposition the composer enters `disabled` (§6). |
| T10 | **Denial notice** | typed policy denial (`operation_result` with deny) | Inline red-bordered strip variant of T4: `✕ create_task — denied: missing grant su:read_or_write`. Shows deny reason **from the authorization record verbatim**; never leaks protected state or credentials. |
| T11 | **System notice** | `runtime_decision_record` (wake, checkout, reconciliation, budget stop, session events) | One-line, muted, icon + text (e.g. `⚙ Wake: issue_blockers_resolved → turn 3 started`). Progressive disclosure into Evidence → Control plane. Never a card; system notices must read quieter than work content. |
Turn grouping: each turn renders a hairline group header `Turn N · <mode> · <n> tool calls ·
<hh:mm:ss>` binding T2/T4/T5-T10 items produced within it. Thread auto-follows the newest item
only while the user is at the bottom; a `Jump to latest` pill appears when scrolled up
(≥ 300px) or when new items arrive off-screen.
## 4. Composer contract
States (mutually exclusive; `data-composer-state` attribute is the test/screenshot hook):
| State | Trigger | Visual | Controls |
|-------|---------|--------|----------|
| `ready` | session attached, no active turn, issue non-terminal | normal input, `Send` primary | Send (Cmd/Ctrl+Enter), attach disabled in Phase 7 |
| `sending` | message posted, turn not yet streaming | input cleared, inline spinner on Send | Send disabled |
| `streaming` | active turn | input stays **editable** (steer), primary button becomes `Stop` (destructive-outline), helper text `Codex is working — send to steer, or stop the turn.` | Send = steer (queued as next user input), Stop |
| `waiting` | pending interaction card requires the user | input disabled, helper `Answer the pending request above to continue.` with an anchor link that scrolls to and focuses the pending card | none |
| `reconnecting` | transport lost | input disabled, helper `Reconnecting… your session is preserved.` | Retry now |
| `disabled` | terminal disposition, replay mode, or budget stop | input disabled with reason line (`Issue is done`, `Replay is read-only`, `Budget limit reached`) | New scenario / Reset |
Draft text survives refresh (localStorage per session id). Stop never discards the transcript;
a stopped turn's partial output stays in the thread with a `Stopped by user` marker on the
turn header.
## 5. Interaction cards (native lifecycle)
All five `request_human_input` kinds render as native cards using the typed payload's prompt,
options, labels, and validation — never as markdown asking the user to type an answer.
Kinds and their answer controls:
- `questions` (ask_user_questions): typed form — per-question control (radio/select/short
text), one submit.
- `confirmation` (request_confirmation): target summary + Accept / Reject buttons; reject
reason textarea when the payload requires it. **Revision-bound**: the card shows the target
(`plan · r4`) and links the exact revision.
- `checkbox` (request_checkbox_confirmation): checkbox list with min/max enforcement,
default-selected ids, Accept label / Reject label from payload.
- `suggest_tasks`: proposed-task list (title + description); board accepts a subset; accepted
tasks appear as T8 cards afterwards.
- `item_verdicts` (request_item_verdicts): per-item Approve / Reject / Defer segmented
buttons; reason required per `requireReasonOn`; supports partial submit (submitted items
lock, remaining stay editable, card stays `pending`).
**Response authority path (normative):** the card's submit posts the typed response to the
package server; the **mock control plane stores the response before the runner receives it**,
and only then does the same Codex session resume with the typed result. The card's UI state
moves `pending → submitting → resolved` only on server acknowledgment (no optimistic
resolution). This is the *only* browser-initiated mock mutation; there is no other write path
from the page.
**State matrix (all states are required and visually distinct):**
| State | Store shape | Card treatment |
|-------|------------|----------------|
| `pending` | status `pending` | Accent left border (violet), controls enabled, `Waiting for you` chip, focus lands on first control when the card is the reason the composer is `waiting`. |
| `submitting` | in flight | Controls disabled, inline spinner. |
| `accepted` | status `accepted` | Green check chip `Accepted`, chosen values summarized inline, controls collapse to read-only summary. |
| `answered` | status `answered` | Same as accepted with `Answered` chip (questions / verdicts complete). |
| `rejected` | status `rejected` | Red chip `Changes requested`, reason quoted in the card. |
| `stale_target` | status `expired`, `result.outcome=stale_target` | Gray chip `Stale — plan moved to r5`, link to the superseding revision, controls removed, body dimmed (60% opacity). |
| `superseded_by_comment` | status `expired`, `result.outcome=superseded_by_comment` | Gray chip `Superseded by a later comment`, link to that comment. |
| `expired` | status `expired`, no outcome | Gray chip `Expired <relative time>`. |
| `withdrawn` | status `cancelled`, `result.outcome=withdrawn` | Gray chip `Withdrawn`, optional reason. |
| `issue_closed` | status `cancelled`/`expired`, `result.outcome=issue_closed` | Gray chip `Issue closed`. |
Resolved/expired cards are durable history: they stay in the thread at their chronological
position, are keyboard-reachable, and expose `View request evidence` linking the debug panel
to the related request, policy decision, mock mutation, wake, and resume events (the plan's
required debug linkage). Pending cards survive refresh and reconnect (§6) — rehydrated from
mock state, not from browser memory.
## 6. Session lifecycle behaviors
- **Refresh (F5)**: full restore from server state — same session, same turn ids, transcript,
pending interaction cards, composer state, and mode chips. The session **never restarts
because the browser reconnected**. Scroll restores to latest; a `Restored session` system
notice (T11) is *not* emitted (silent restore) — the evidence panel's session record is the
proof.
- **Reconnect**: on transport drop the composer enters `reconnecting`, a slim amber banner
pins under the header (`Connection lost — retrying (attempt n)`), and streaming indicators
freeze with a `paused` glyph. On reconnect, missed canonical events replay by ordinal (no
duplicates, no gaps) and the banner resolves to a 3s `Reconnected` confirmation.
`prefers-reduced-motion` replaces the banner slide with opacity.
- **Stop**: header Stop and composer Stop are the same action — bounded cancel of the active
turn. Post-state: partial output retained + `Stopped by user` turn marker; session and
pending interactions unaffected; composer returns to `ready`.
- **Reset**: destructive — always behind a confirm dialog (`Reset scenario? This clears the
mock state and starts a clean session. The transcript will be lost.`; confirm button
`Reset scenario`, destructive style; cancel is default focus). Reset re-seeds the fixture,
rotates/clears session authority (Revision 4), and lands on a clean thread with a fresh
`Turn 0` seeded context. Reset affects only the current browser session's scenario instance.
- **Replay**: mode `replay` re-renders a recorded run from canonical events. Composer
`disabled` (`Replay is read-only`), identity chip row shows `Replay` + `Mock Paperclip`,
and a top progress strip allows step/next-turn/play-all with a deterministic `?at=<ordinal>`
deep-link parameter. Replay of a `fake` recording must still be labeled as fake-derived
(chip `Replay · fake source`) so replay evidence can never satisfy a live criterion.
- **Stop/Reset/Replay/Scenario controls** live in the header on desktop; on mobile
Replay/Reset/Scenario collapse into `⋯`, Stop stays exposed while a turn is active (§2.2).
## 7. Side debug panel (Evidence)
Named **Evidence** in UI copy. Content scope: a **turn selector** at the top (`Turn N ▾`,
default = latest; `All turns` option for the state and parity sections). Below it, eight
accordion sections in this order (accordion, not tabs — >7 categories, and multiple sections
must be open simultaneously for review):
1. **Tools exposed** — the turn's visible tool list grouped `Agent tool — always`, then
`Agent tool — granted` (each with its grant, e.g. `rf:read_or_write`), then a separated
muted list `Control plane (not exposed to the agent)` naming `control_plane_owned`
operations relevant to the fixture. Negative evidence is first-class: the control-plane
list exists precisely to show what the model *cannot* call.
2. **Calls & results** — chronological semantic calls: raw Codex request → dispatched command
→ typed result/denial, with operation id, version, and redaction annotations.
3. **Authorization** — one record per decision: operation, claims considered, allow/deny +
reason, redactions applied, resulting state change ref. Deny records use the same red
accent as T10.
4. **Control plane** — `runtime_decision_record` stream: checkout/lock, wake scheduling,
budget, idempotency/retry, reconciliation, session lifecycle.
5. **Runner & events** — canonical PRP events and runnerd/Codex process diagnostics (session
id, thread id, process state, cleanup evidence).
6. **State diff** — before/after per entity class (tasks, comments, documents, interactions,
approvals, artifacts, blockers, workspace, budget, run). Per-turn by default; `All turns`
shows fixture-seed → current. Rendered from immutable snapshots; the browser never
computes a diff from its own bookkeeping.
7. **Traceability** — the fixture's 7A anchors: capability rows (id + `sourceAnchor`), eval
case id/group/`browserEvidenceRecipe`, `expectedSemanticOperations`,
`forbiddenOperations`, `requiredCapabilityGrants`.
8. **Parity** — assertion list with verdict chips (`pass` green / `fail` red /
`intentional gap` gray + note), summarized as `n/m` in the section header.
Cross-linking contract: every T4 strip, T10 denial, and interaction card deep-links into the
matching Evidence record (`View in Evidence`), and every Evidence record links back to its
thread anchor. Deep-link target = section + record id, e.g. `?panel=authorization&rec=<id>`.
Mobile: the same eight sections render inside the `Evidence` segment, full-width accordions,
turn selector pinned under the segmented control.
## 8. Visual language
Follow the Paperclip design language without importing the product `ui/` package:
- Dark theme default, OKLCH neutral grays; semantic tokens only (background/card/muted/
accent/destructive/border/ring equivalents defined package-locally). No raw hex in
components.
- Type ramp: page title `text-xl font-bold`; card titles `text-sm font-medium`; body
`text-sm`; metadata `text-xs text-muted-foreground`; identifiers and operation ids
`font-mono text-xs`.
- Status/priority renders with StatusBadge/StatusIcon-equivalent components using the
product's status hue table (todo blue, in_progress indigo, in_review violet, done green,
blocked red, backlog/cancelled gray).
- Radii ≤ `rounded-xl`; shadows ≤ `shadow-sm`; density = product issue page, not a marketing
layout.
- Interaction cards use the product interaction-card anatomy (title row + prompt + controls +
state chip) so the mock thread reads as a Paperclip issue thread (Jakob's Law is the point
of this phase's demo).
## 9. Accessibility acceptance (blocking)
7G is not acceptable until all of these pass; 7K re-verifies them clean-room:
1. **Keyboard tour** (documented, testable): Tab order = header controls → thread (each card
is a focusable group; Enter expands) → composer → Evidence toggle → panel. The splitter is
arrow-key resizable. Pending interaction controls are reachable without pointer; Escape
closes the mobile `⋯` menu and the desktop overlay sheet.
2. **Focus management**: opening Evidence moves focus to its heading; resolving a card moves
focus to the card's state chip; `waiting` composer's anchor link moves focus to the pending
card's first control. Focus ring = 3px ring token, never suppressed.
3. **Live regions**: streaming agent text in `aria-live="polite"` chunk announcements (throttled
≥ 2s); turn completion, denial notices, and interaction resolution announce via a single
polite status region; reconnect banner is `role="status"`, Stop confirmation `role="alert"`.
4. **Structure**: one `h1` (issue title), landmarks `header/main/complementary` (Evidence),
`form` for composer; interaction cards are `section`s labeled by their prompt; tool strips
are disclosure buttons with `aria-expanded`.
5. **Color independence**: every state chip pairs color with a glyph + text (`✓ Accepted`,
`✕ Denied`, `⏳`); parity verdicts likewise. Contrast ≥ 4.5:1 for text, ≥ 3:1 for UI
glyphs, verified in dark theme.
6. **Reduced motion**: `prefers-reduced-motion` disables pulse dots, banner slides, streaming
shimmer, and smooth scrolling (instant jumps).
7. **Automated gate**: axe (or equivalent) run against every screenshot route in §10 with
zero serious/critical violations, executed in CI alongside screenshot capture.
## 10. Deterministic screenshot contract
### 10.1 Route scheme
- Base route: `#/issue/<fixtureProfile>` (e.g. `#/issue/hb-baseline`). Scenario/fixture ids
come from 7A `fixtureProfile`; per-case evidence uses the 7A `browserEvidenceRecipe` path
(`<group>/<case-id>`) as the canonical evidence id.
- Screenshot state param: `?shot=<slug>` seeds the named deterministic state below in `fake`
mode with: fixed clock (all timestamps render from fixture time), animations/caret/pulse
disabled, network idle, fonts loaded.
- Panel/deep-link params: `?panel=<section>[&rec=<id>]`, `?at=<ordinal>` (replay),
`?seg=thread|evidence` (mobile segment).
- Settle signal: the root element sets `data-thread-state="settled"` when hydration, fixture
load, and auto-scroll are complete. Capture tooling waits for it — never for timeouts.
### 10.2 Required matrix (12 slugs × 2 viewports = 24 PNGs)
Viewports: desktop `1440×900`, mobile `390×844`. Output path:
`knowledge/evidence/phase-07/ui/<slug>--<desktop|mobile>.png` (package-local). Every capture
also asserts `scrollWidth <= clientWidth` on the scrolling element at 390×844.
| Slug | Seeded state | Must be visible |
|------|--------------|-----------------|
| `thread-baseline` | settled 3-turn fake run on `hb-baseline` | header w/ 3 identity chips + mode; T1/T2/T3/T4 items; turn headers; composer `ready` |
| `turn-streaming` | mid-turn stream | streaming indicator, composer `streaming` w/ Stop, editable steer input |
| `interaction-question-pending` | `ix-questions-01` pending card | typed question form, `Waiting for you` chip, composer `waiting` w/ anchor helper |
| `interaction-confirmation-pending` | `ix-confirmation-plan-01` pending | revision-bound target (`plan · r4`) on card, Accept/Reject, reject-reason affordance |
| `interaction-resolved-mixed` | history incl. accepted + rejected + `stale_target` + `superseded_by_comment` | four visually distinct resolved/expired treatments per §5 |
| `denial-optional-tool` | denied `create_task` (missing `su:read_or_write`) | T10 denial strip w/ verbatim deny reason; Evidence badge increment |
| `document-revision` | `dp-plan-doc-01` after `write_document` | T6 card w/ revision chain `r3 → r4` + View diff |
| `deliverable-registered` | `ar-upload-before-done-01` | T7 deliverable card w/ kind + registered-by |
| `disposition-terminal` | `st-done-comment-01` finished | T9 terminal card, StatusBadge `done`, composer `disabled` w/ reason |
| `debug-panel-open` | baseline + `?panel=authorization` | desktop: panel open at 384px w/ 8 sections, authorization records; mobile: `Evidence` segment active |
| `reconnect-banner` | forced transport drop | amber reconnect banner, composer `reconnecting`, frozen stream glyph |
| `replay-mode` | replay of recorded fake run `?at=12` | `Replay · fake source` chip, read-only composer, progress strip |
Determinism rule: two captures of the same slug/viewport from a clean checkout must be
pixel-identical (the Phase 7I byte-identical bar). Anything time-, random-, or
locale-dependent renders from fixture data.
### 10.3 Evidence naming
Per-eval-case evidence (7F/7K scope) reuses `browserEvidenceRecipe` verbatim:
`knowledge/evidence/phase-07/cases/<group>/<case-id>--<viewport>.png`. The §10.2 matrix is
the UI acceptance set; case evidence is additive and follows the same settle/determinism
rules.
## 11. Authority and safety rules (UI-side restatement)
- The browser renders mock-core records, snapshots, and canonical events. It computes no
claim, policy, diff, or parity result client-side. UI-side state math is a defect.
- The only browser-initiated mock mutation is an interaction response (§5). Composer messages
go to the runner session, not to mock state.
- No provider, runner, or control-plane credential ever reaches the browser; redactions render
by rule name. Real Paperclip URLs/API paths never appear.
- Policy and state authority live in the package server + mock `ControlPlanePort`; refresh
and reconnect re-derive everything from them.
## 12. 7G handoff checklist
Implementation acceptance (UXDesigner review) requires:
1. All §3 item types and all §5 interaction states implemented and reachable via fixtures.
2. All §4 composer states with `data-composer-state` hooks.
3. §6 behaviors demonstrated: refresh restore, reconnect replay, stop, reset confirm, replay
read-only.
4. §7 Evidence panel with all eight sections, turn selector, and bidirectional deep links.
5. §10 matrix: 24 deterministic PNGs recorded at the named routes, byte-stable across two
clean runs, plus the 390px no-horizontal-scroll assertion per capture.
6. §9 accessibility gate green (axe + documented keyboard tour).
7. Screenshot review posted to the 7G issue for UXDesigner acceptance before 7G closes.
Questions or gaps → comment on PAP-16940.