) : null}
@@ -527,11 +619,7 @@ export function App() {
: [...current, section],
)
}
- onClose={() => {
- setPanelOpen(false);
- persistPanel(false, panelWidth);
- if (layout === "segment") setSegment("thread");
- }}
+ onClose={closeEvidence}
onJumpToThread={jumpToThread}
/>
) : null}
diff --git a/packages/paperclip-runner/devtools/issue-thread/src/EvidencePanel.tsx b/packages/paperclip-runner/devtools/issue-thread/src/EvidencePanel.tsx
index 71e576cc396..9198e4b1e1d 100644
--- a/packages/paperclip-runner/devtools/issue-thread/src/EvidencePanel.tsx
+++ b/packages/paperclip-runner/devtools/issue-thread/src/EvidencePanel.tsx
@@ -118,6 +118,21 @@ export function EvidencePanel(props: EvidencePanelProps) {
headingRef.current?.focus();
}, []);
+ useEffect(() => {
+ // Contract §9.1: Escape closes the desktop overlay sheet. The side panel is
+ // a persistent region rather than a sheet, and the mobile segment is a tab
+ // view, so neither of those is dismissible this way.
+ if (layout !== "overlay") return;
+ function onKeyDown(event: KeyboardEvent) {
+ if (event.key !== "Escape") return;
+ // A modal dialog owns Escape while it is open (§6 reset confirm).
+ if (document.querySelector('[role="dialog"][aria-modal="true"]') !== null) return;
+ onClose();
+ }
+ document.addEventListener("keydown", onKeyDown);
+ return () => document.removeEventListener("keydown", onKeyDown);
+ }, [layout, onClose]);
+
const isOpen = (section: Phase7EvidenceSectionId) => openSections.includes(section);
return (
diff --git a/packages/paperclip-runner/devtools/issue-thread/src/InteractionCard.tsx b/packages/paperclip-runner/devtools/issue-thread/src/InteractionCard.tsx
index 4ede8ce903a..2f60553e6b8 100644
--- a/packages/paperclip-runner/devtools/issue-thread/src/InteractionCard.tsx
+++ b/packages/paperclip-runner/devtools/issue-thread/src/InteractionCard.tsx
@@ -1,4 +1,4 @@
-import { useId, useRef, useState } from "react";
+import { useEffect, useId, useRef, useState } from "react";
import type {
Phase7ThreadInteractionCard,
@@ -79,9 +79,23 @@ export function InteractionCard({
const [rejectReason, setRejectReason] = useState("");
const [error, setError] = useState(null);
const firstControl = useRef(null);
+ const stateChip = useRef(null);
+ const previousState = useRef(card.state);
const resolved = RESOLVED_STATES.has(card.state);
const busy = card.state === "submitting";
+ useEffect(() => {
+ // Contract §9.2: resolving a card moves focus to its state chip. The
+ // controls the user just used unmount on resolve, so without this the
+ // keyboard caret would fall back to `body` exactly when the card changes.
+ const from = previousState.current;
+ previousState.current = card.state;
+ if (from === card.state) return;
+ if (!RESOLVED_STATES.has(from) && RESOLVED_STATES.has(card.state)) {
+ stateChip.current?.focus();
+ }
+ }, [card.state]);
+
function respond(response: Phase7InteractionResponse) {
setError(null);
onRespond?.(response);
@@ -104,7 +118,12 @@ export function InteractionCard({
{card.title}
-
+ {STATE_GLYPH[card.state]}
{card.stateLabel}
diff --git a/packages/paperclip-runner/devtools/issue-thread/src/issue-thread.css b/packages/paperclip-runner/devtools/issue-thread/src/issue-thread.css
index 10f66e3594b..9cb7fb0eec5 100644
--- a/packages/paperclip-runner/devtools/issue-thread/src/issue-thread.css
+++ b/packages/paperclip-runner/devtools/issue-thread/src/issue-thread.css
@@ -1156,6 +1156,21 @@ textarea {
.pit-thread-scroll {
padding: var(--pit-space-4) var(--pit-space-3);
}
+
+ /*
+ * Three transport controls plus a progress bar do not fit on one 390px row —
+ * squeezing them leaves the bar as an unreadable stub. Wrap so the bar keeps
+ * the full width on its own line, below the label and buttons.
+ */
+ .pit-replay-strip {
+ flex-wrap: wrap;
+ padding: var(--pit-space-2) var(--pit-space-3);
+ }
+
+ .pit-replay-strip .pit-progress {
+ order: 1;
+ flex-basis: 100%;
+ }
}
@media (prefers-reduced-motion: reduce) {
diff --git a/packages/paperclip-runner/devtools/issue-thread/src/primitives.tsx b/packages/paperclip-runner/devtools/issue-thread/src/primitives.tsx
index 6cfc8f93dda..6fe68ed1e27 100644
--- a/packages/paperclip-runner/devtools/issue-thread/src/primitives.tsx
+++ b/packages/paperclip-runner/devtools/issue-thread/src/primitives.tsx
@@ -1,4 +1,4 @@
-import type { ReactNode } from "react";
+import type { ReactNode, Ref } from "react";
import type { Phase7TaskStatus } from "../../../src/mock-core/phase7-control-plane-types";
@@ -49,14 +49,26 @@ export function Chip({
children,
title,
testId,
+ /** Set to -1 so focus management can land on a chip without adding a tab stop. */
+ tabIndex,
+ chipRef,
}: {
tone?: "live" | "mock" | "accent" | "success" | "danger";
children: ReactNode;
title?: string;
testId?: string;
+ tabIndex?: number;
+ chipRef?: Ref;
}) {
return (
-
+
{children}
);
@@ -75,8 +87,5 @@ export function Timestamp({ value }: { value: string }) {
);
}
-export function formatBytes(byteSize: number): string {
- if (byteSize < 1024) return `${byteSize} B`;
- if (byteSize < 1024 * 1024) return `${(byteSize / 1024).toFixed(1)} kB`;
- return `${(byteSize / (1024 * 1024)).toFixed(1)} MB`;
-}
+/** Re-exported so the thread strip and the deliverable card share one unit. */
+export { phase7FormatBytes as formatBytes } from "../../../src/issue-thread/types";
diff --git a/packages/paperclip-runner/docs/phase-07-issue-thread-ui.md b/packages/paperclip-runner/docs/phase-07-issue-thread-ui.md
index 0d0c75dd659..e3a7e8b0124 100644
--- a/packages/paperclip-runner/docs/phase-07-issue-thread-ui.md
+++ b/packages/paperclip-runner/docs/phase-07-issue-thread-ui.md
@@ -56,9 +56,17 @@ the reserved `MCK-` prefix, and no real Paperclip URL is ever rendered.
into the matching record, and each record links back to its thread anchor.
The panel is collapsed by default and resizable between 320px and 640px with a
-keyboard-operable splitter. Below 1100px it becomes an overlay sheet; below
-768px the page switches to a `Thread` / `Evidence` segmented control, with Stop
-kept outside the `⋯` menu while a turn is active.
+keyboard-operable splitter. Below 1100px it becomes an overlay sheet that
+Escape dismisses; below 768px the page switches to a `Thread` / `Evidence`
+segmented control, with Stop kept outside the `⋯` menu while a turn is active.
+Closing the panel by either route hands focus back to the visible control that
+owns it.
+
+- **Replay strip** — in `mode=replay` a progress strip pins under the header
+ with `Step back`, `Next turn`, and `Play all`. `?at=` is the single
+ source of truth for the parked ordinal, so the three controls and the deep
+ link all move the same value; `Play all` advances one ordinal every 800 ms
+ and parks itself at the end of the recording.
## Routes
@@ -104,11 +112,20 @@ The suite enforces the contract's blocking gate: axe reports zero serious or
critical WCAG 2.1 A/AA violations on every screenshot route at both viewports.
Structure is one `h1`, `header`/`main`/`complementary` landmarks, a `form`
composer, `section` interaction cards labelled by their prompt, and tool strips
-as disclosure buttons with `aria-expanded`. Focus moves to the Evidence heading
-when the panel opens and to the pending card's first control from the `waiting`
-composer anchor. Every state chip pairs color with a glyph and text, all
-actionable controls clear 44×44 CSS px on mobile, and `prefers-reduced-motion`
-disables the pulse dot, banner slide, and smooth scrolling.
+as disclosure buttons with `aria-expanded`. Every state chip pairs color with a
+glyph and text, all actionable controls clear 44×44 CSS px on mobile, and
+`prefers-reduced-motion` disables the pulse dot, banner slide, and smooth
+scrolling.
+
+Focus management (§9.2) is covered by named regressions in the browser suite:
+
+- Opening Evidence moves focus to its heading; closing it — with the `Close`
+ button or with Escape on the overlay sheet — returns focus to the toggle.
+- Resolving an interaction card moves focus to the card's state chip. The
+ controls the user just operated unmount on resolve, so without this the
+ keyboard caret drops to `body` at the moment the card changes.
+- The `waiting` composer's anchor moves focus to the pending card's first
+ control.
## Contract deviations
diff --git a/packages/paperclip-runner/knowledge/evidence/2026-08-10-phase-07g-issue-thread-ui-verification.md b/packages/paperclip-runner/knowledge/evidence/2026-08-10-phase-07g-issue-thread-ui-verification.md
index 981e71b71c9..171786d5819 100644
--- a/packages/paperclip-runner/knowledge/evidence/2026-08-10-phase-07g-issue-thread-ui-verification.md
+++ b/packages/paperclip-runner/knowledge/evidence/2026-08-10-phase-07g-issue-thread-ui-verification.md
@@ -39,8 +39,12 @@ non-empty, and no fixture or projected view matches a credential pattern.
# Browser suite
-`pnpm exec playwright test --config devtools/issue-thread/playwright.config.ts`
-— **46/46 passed**.
+`pnpm test:browser:phase7` — **50/50 passed**.
+
+The script now runs `build:issue-thread` before Playwright. It did not, and the
+suite's `vite preview` server serves the built bundle, so a run could pass
+against a stale `dist-issue-thread/` that predated the source under test. That
+is how the four round-2 regressions below first came up red against fixed code.
Functional coverage: baseline item types and turn headers; durable comments
distinguished from model prose; collapsed tool strips with `aria-expanded` and
@@ -57,8 +61,18 @@ draft survival across refresh; the 390px no-horizontal-scroll assertion; the
mobile denial badge; 44px touch targets; Escape closing the `⋯` menu; focus
moving to the Evidence heading; and the `waiting` composer anchor.
+Round-2 additions from the PAP-16952 gate review:
+
+* **Escape closes the desktop overlay sheet** at 1000×800 and returns focus to
+ the `Evidence` toggle (§9.1, was blocking).
+* **Resolving a card moves focus to its state chip** (§9.2, was blocking).
+* **The replay strip steps, advances, and plays through** to 18/18, then parks
+ itself with `Next turn` disabled (§6 `play-all`).
+* **The deliverable strip and card report one size in one unit** — both now
+ format through `phase7FormatBytes`.
+
Accessibility gate: axe (WCAG 2.1 A/AA) on all **12 slugs × 2 viewports** —
-zero serious or critical violations.
+zero serious or critical violations, re-run after the round-2 fixes.
# Screenshot matrix
@@ -73,6 +87,14 @@ slugs at 1440×900 and 390×844. Every mobile capture asserts
frames are deterministic `fake` mode rendered from package fixtures, so no
provider, runner process, or credential is involved.
+Five frames were re-recorded for the round-2 fixes and re-verified byte-stable:
+`replay-mode--{desktop,mobile}` (the `Play all` control, and the progress bar
+wrapping to its own row at 390px so it is not squeezed to a stub),
+`debug-panel-open--{desktop,mobile}` (`?panel=` without `rec=` now scrolls the
+opened section clear of the sticky panel head, so the §10.2 "authorization
+records" requirement is met in frame rather than below the fold), and
+`deliverable-registered--desktop` (`18.4 kB` → `18.0 kB`, matching its card).
+
# Live runnerd and Codex
```sh
diff --git a/packages/paperclip-runner/knowledge/evidence/phase-07/ui/debug-panel-open--desktop.png b/packages/paperclip-runner/knowledge/evidence/phase-07/ui/debug-panel-open--desktop.png
index 06452eaed88..f3e9c2a0a06 100644
Binary files a/packages/paperclip-runner/knowledge/evidence/phase-07/ui/debug-panel-open--desktop.png and b/packages/paperclip-runner/knowledge/evidence/phase-07/ui/debug-panel-open--desktop.png differ
diff --git a/packages/paperclip-runner/knowledge/evidence/phase-07/ui/debug-panel-open--mobile.png b/packages/paperclip-runner/knowledge/evidence/phase-07/ui/debug-panel-open--mobile.png
index fddf8ef3511..8d5acb61f7c 100644
Binary files a/packages/paperclip-runner/knowledge/evidence/phase-07/ui/debug-panel-open--mobile.png and b/packages/paperclip-runner/knowledge/evidence/phase-07/ui/debug-panel-open--mobile.png differ
diff --git a/packages/paperclip-runner/knowledge/evidence/phase-07/ui/deliverable-registered--desktop.png b/packages/paperclip-runner/knowledge/evidence/phase-07/ui/deliverable-registered--desktop.png
index f4990f54a3a..d738799e6a1 100644
Binary files a/packages/paperclip-runner/knowledge/evidence/phase-07/ui/deliverable-registered--desktop.png and b/packages/paperclip-runner/knowledge/evidence/phase-07/ui/deliverable-registered--desktop.png differ
diff --git a/packages/paperclip-runner/knowledge/evidence/phase-07/ui/replay-mode--desktop.png b/packages/paperclip-runner/knowledge/evidence/phase-07/ui/replay-mode--desktop.png
index 938643f497d..e4f552741c6 100644
Binary files a/packages/paperclip-runner/knowledge/evidence/phase-07/ui/replay-mode--desktop.png and b/packages/paperclip-runner/knowledge/evidence/phase-07/ui/replay-mode--desktop.png differ
diff --git a/packages/paperclip-runner/knowledge/evidence/phase-07/ui/replay-mode--mobile.png b/packages/paperclip-runner/knowledge/evidence/phase-07/ui/replay-mode--mobile.png
index dd6e61412cb..f83d2b33c3f 100644
Binary files a/packages/paperclip-runner/knowledge/evidence/phase-07/ui/replay-mode--mobile.png and b/packages/paperclip-runner/knowledge/evidence/phase-07/ui/replay-mode--mobile.png differ
diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json
index d1353a100a7..62dd8594b4a 100644
--- a/packages/paperclip-runner/package.json
+++ b/packages/paperclip-runner/package.json
@@ -81,7 +81,7 @@
"verify:rootless": "bash scripts/verify-rootless-linux.sh",
"build:issue-thread": "vite build --config vite.phase7.config.ts",
"console:phase7": "pnpm run build:typescript && vite --config vite.phase7.config.ts --host 127.0.0.1 --port 4183",
- "test:browser:phase7": "pnpm run build:typescript && playwright test --config devtools/issue-thread/playwright.config.ts",
+ "test:browser:phase7": "pnpm run build:typescript && pnpm run build:issue-thread && playwright test --config devtools/issue-thread/playwright.config.ts",
"record:phase7:ui": "pnpm run build:typescript && pnpm run build:issue-thread && node scripts/record-phase7-ui-evidence.mjs",
"check:phase7:ui": "pnpm run build:typescript && pnpm run build:issue-thread && node scripts/record-phase7-ui-evidence.mjs --check",
"smoke:phase7:ui": "pnpm run build:typescript && cargo build --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --bin paperclip-runnerd && node scripts/phase7-issue-thread-smoke.mjs",
diff --git a/packages/paperclip-runner/src/issue-thread/fixtures.ts b/packages/paperclip-runner/src/issue-thread/fixtures.ts
index e078220cd24..c2469475a78 100644
--- a/packages/paperclip-runner/src/issue-thread/fixtures.ts
+++ b/packages/paperclip-runner/src/issue-thread/fixtures.ts
@@ -12,7 +12,7 @@ import type {
Phase7ThreadItem,
Phase7ThreadTurn,
} from "./types.js";
-import { PHASE7_ISSUE_THREAD_VIEW_SCHEMA } from "./types.js";
+import { PHASE7_ISSUE_THREAD_VIEW_SCHEMA, phase7FormatBytes } from "./types.js";
export const PHASE7_UI_SHOT_SLUGS = [
"thread-baseline",
@@ -896,6 +896,7 @@ const BUILDERS: Record Phase7IssueThreadSnapshot> = {
"deliverable-registered": () => {
const snapshot = baseline();
const turn = lastTurn(snapshot);
+ const deliverableBytes = 18_432;
turn.toolCallCount = 1;
turn.items.push(
{
@@ -904,7 +905,8 @@ const BUILDERS: Record Phase7IssueThreadSnapshot> = {
at: at(4, 20),
status: "ok",
operationId: "register_deliverable",
- summary: "spike-trace.json registered · 18.4 kB",
+ // Same formatter as the deliverable card so the two never disagree.
+ summary: `spike-trace.json registered · ${phase7FormatBytes(deliverableBytes)}`,
input: { filename: "spike-trace.json", contentType: "application/json" },
result: { commandKind: "register_deliverable", stateRevision: 4 },
evidenceRef: { section: "calls", recordId: "call-5" },
@@ -915,7 +917,7 @@ const BUILDERS: Record Phase7IssueThreadSnapshot> = {
at: at(4, 20),
filename: "spike-trace.json",
deliverableKind: "attachment bytes",
- byteSize: 18_432,
+ byteSize: deliverableBytes,
registeredBy: AGENT,
contentRef: "mock://artifacts/artifact-7",
evidenceRef: { section: "calls", recordId: "call-5" },
diff --git a/packages/paperclip-runner/src/issue-thread/types.ts b/packages/paperclip-runner/src/issue-thread/types.ts
index 160d0869a3c..ab7058295fe 100644
--- a/packages/paperclip-runner/src/issue-thread/types.ts
+++ b/packages/paperclip-runner/src/issue-thread/types.ts
@@ -426,6 +426,18 @@ export function phase7DispositionLabel(disposition: Phase7ToolDisposition): stri
return PHASE7_DISPOSITION_LABELS[disposition];
}
+/**
+ * The one byte formatter for the whole surface. Deliverable sizes surface in
+ * two places — the tool-activity strip summary and the deliverable card — and
+ * they must agree, so both sides (fixtures, live projection, and the browser
+ * components) format through here rather than rolling their own division.
+ */
+export function phase7FormatBytes(byteSize: number): string {
+ if (byteSize < 1024) return `${byteSize} B`;
+ if (byteSize < 1024 * 1024) return `${(byteSize / 1024).toFixed(1)} kB`;
+ return `${(byteSize / (1024 * 1024)).toFixed(1)} MB`;
+}
+
/**
* Authorization denials that the agent actually hit. Drives the `Evidence`
* badge (§2.2) and reads straight off authorization records — the browser never