fix(runner): address the Phase 7G gate review

Round-1 of the UX acceptance gate requested changes on four items plus a
deep-link nit. All are fixed, with named browser regressions for the two
blocking accessibility items.

§9.1 — Escape now closes the desktop overlay sheet (768–1100px) and hands
focus back to the visible control that owns the panel. The reset confirm
dialog keeps priority over Escape while it is open.

§9.2 — resolving an interaction card moves focus to its state chip. The
controls the user just operated unmount on resolve, so focus was landing on
`body` at exactly the moment the card changed under them.

§6 — the replay strip gains `Play all`, and `?at=<ordinal>` becomes the single
source of truth for the parked ordinal, so step / next-turn / play-all and the
deep link all move the same value instead of the strip being pinned to its
fixture. At 390px the strip wraps so the progress bar keeps a full row rather
than being squeezed to a stub.

Deliverable sizes — the T4 strip said `18.4 kB` where the T7 card said
`18.0 kB` for the same artifact. Both now format through one shared
`phase7FormatBytes`.

`?panel=` without `rec=` now scrolls the opened section clear of the sticky
panel head, so `debug-panel-open` shows the authorization records §10.2 asks
for instead of leaving them below the fold.

`test:browser:phase7` never ran `build:issue-thread`, and the suite's
`vite preview` serves the built bundle — so the gate could pass against a
stale `dist-issue-thread/`. Fixed; that is how these regressions first came up
red against already-fixed source.

Verification: browser suite 50/50 (46 prior + 4 new), axe clean on all 24
routes; 24 PNGs re-recorded and byte-stable across two runs (5 changed);
16/16 view-model, 23/23 phase-7 unit; live runnerd + real Codex smoke 13/13.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-10 07:25:53 +00:00
1 parent dc4b433465
commit 46291d82cd
16 files changed
+305 -45

No files matched your search

@@ -146,6 +146,15 @@ test.describe("Phase 7G issue thread", () => {
).toBeVisible();
});
test("the deliverable strip and card report one size in one unit", async ({ page }) => {
await open(page, "deliverable-registered");
const card = page.locator('[data-thread-item="deliverable"]');
await expect(card).toContainText("18.0 kB");
await expect(
page.locator('[data-tool-strip="register_deliverable"]'),
).toContainText("18.0 kB");
});
test("a denial quotes the authorization record verbatim and badges Evidence", async ({
page,
}) => {
@@ -249,6 +258,28 @@ test.describe("Phase 7G issue thread", () => {
await expect(page.locator('[data-composer-state="disabled"] textarea')).toBeDisabled();
});
test("the replay strip steps, advances, and plays through to the end", async ({ page }) => {
await open(page, "replay-mode");
const strip = page.getByTestId("replay-strip");
const progress = page.getByRole("progressbar", { name: "Replay progress" });
await page.getByTestId("replay-step-back").click();
await expect(strip).toContainText("Replay 11/18");
await expect(progress).toHaveAttribute("aria-valuenow", "11");
await page.getByTestId("replay-next-turn").click();
await expect(strip).toContainText("Replay 12/18");
// Play-all runs the recording out and parks itself at the last ordinal
// instead of leaving the operator to click Next turn eighteen times (§6).
const playAll = page.getByTestId("replay-play-all");
await playAll.click();
await expect(playAll).toHaveAttribute("aria-pressed", "true");
await expect(strip).toContainText("Replay 18/18", { timeout: 15_000 });
await expect(playAll).toHaveAttribute("aria-pressed", "false");
await expect(page.getByTestId("replay-next-turn")).toBeDisabled();
});
test("a terminal disposition disables the composer with a reason", async ({ page }) => {
await open(page, "disposition-terminal");
await expect(page.locator('[data-thread-item="disposition"]')).toContainText("Done");
@@ -321,6 +352,36 @@ test.describe("Phase 7G issue thread", () => {
await expect(page.getByRole("heading", { name: "Evidence" })).toBeFocused();
});
test("Escape closes the desktop overlay sheet and returns focus to its toggle", async ({
page,
}) => {
// Between the mobile segment and the docked side panel, Evidence renders as
// an overlay sheet; §9.1 makes Escape dismiss it.
await page.setViewportSize({ width: 1000, height: 800 });
await open(page, "thread-baseline");
await page.getByTestId("evidence-toggle").click();
const panel = page.getByTestId("evidence-panel");
await expect(panel).toHaveAttribute("data-layout", "overlay");
await expect(page.getByRole("heading", { name: "Evidence" })).toBeFocused();
await page.keyboard.press("Escape");
await expect(panel).toHaveCount(0);
await expect(page.getByTestId("evidence-toggle")).toBeFocused();
});
test("resolving an interaction card moves focus to its state chip", async ({ page }) => {
await open(page, "interaction-question-pending");
const card = page.locator('[data-interaction-id="ix-questions-01"]');
await card.getByRole("radio", { name: "Yes — runner owns it" }).check();
await card.getByRole("combobox").selectOption("hb-baseline");
await card.getByRole("button", { name: "Submit answers" }).click();
await expect(card).toHaveAttribute("data-interaction-state", "answered");
await expect(card.getByTestId("interaction-state-chip")).toBeFocused();
});
test("the waiting composer anchor focuses the pending card", async ({ page }) => {
await open(page, "interaction-question-pending");
await page.getByTestId("pending-anchor").click();
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import {
PHASE7_DEFAULT_FIXTURE_PROFILE,
@@ -22,6 +22,8 @@ const PANEL_OPEN_KEY = "paperclip-runner.phase7.panel.open";
const PANEL_WIDTH_KEY = "paperclip-runner.phase7.panel.width";
const PANEL_MIN = 320;
const PANEL_MAX = 640;
/** Play-all cadence for the replay strip (§6); slow enough to read a turn. */
const REPLAY_STEP_MS = 800;
const SCENARIOS = ["hb-baseline", "dp-documents", "ix-interactions", "ar-artifacts"];
function readStoredNumber(key: string, fallback: number): number {
@@ -43,6 +45,20 @@ function readStoredFlag(key: string, fallback: boolean): boolean {
}
}
/**
* Scroll an Evidence target into the panel viewport. `start` alignment has to
* account for the sticky panel head, which would otherwise sit on top of the
* section header the deep link was supposed to reveal.
*/
function scrollEvidenceIntoView(target: Element, block: "start" | "center"): void {
target.scrollIntoView({ block });
if (block !== "start") return;
const panel = target.closest<HTMLElement>(".pit-panel");
const head = panel?.querySelector<HTMLElement>(".pit-panel-head");
if (panel == null || head == null) return;
panel.scrollTop = Math.max(0, panel.scrollTop - head.offsetHeight);
}
function useRoute(): Phase7Route {
const [route, setRoute] = useState(() => parsePhase7Route(window.location));
useEffect(() => {
@@ -87,6 +103,7 @@ export function App() {
const [announcement, setAnnouncement] = useState("");
const [showJump, setShowJump] = useState(false);
const [reconnected, setReconnected] = useState(false);
const [playing, setPlaying] = useState(false);
const scrollRef = useRef<HTMLDivElement | null>(null);
const cancelResetRef = useRef<HTMLButtonElement | null>(null);
@@ -128,6 +145,19 @@ export function App() {
setHighlightedRecordId(route.record);
}, [route.panel, route.record]);
useLayoutEffect(() => {
// A deep link has to land on what it addressed. With `rec` that is the
// record; without it, the opened section's header — otherwise the link
// half-arrives with the section still below the fold. Layout effect so the
// scroll is committed before paint and a capture can never race it.
if (route.panel === null || snapshot === null) return;
const target =
route.record === null
? document.querySelector(`[data-evidence-section="${CSS.escape(route.panel)}"]`)
: document.querySelector(`[data-record-id="${CSS.escape(route.record)}"]`);
if (target !== null) scrollEvidenceIntoView(target, route.record === null ? "start" : "center");
}, [route.panel, route.record, snapshot, panelOpen, segment, openSections]);
useEffect(() => {
setSegment(route.segment);
}, [route.segment]);
@@ -189,6 +219,24 @@ export function App() {
[panelWidth, persistPanel],
);
const closeEvidence = useCallback(() => {
setPanelOpen(false);
persistPanel(false, panelWidth);
if (layout === "segment") setSegment("thread");
// Focus came into the panel when it opened (§9.2), so hand it back to the
// visible control that owns the panel rather than dropping it on `body`.
window.setTimeout(() => {
const candidates = ['[data-testid="evidence-toggle"]', '[data-testid="segment-thread"]'];
for (const selector of candidates) {
const control = document.querySelector<HTMLElement>(selector);
if (control !== null && control.offsetParent !== null) {
control.focus();
return;
}
}
}, 0);
}, [layout, panelWidth, persistPanel]);
const jumpToThread = useCallback((anchorId: string) => {
setSegment("thread");
const anchor = document.getElementById(anchorId) ?? document.querySelector(`#${CSS.escape(anchorId)}`);
@@ -309,6 +357,42 @@ export function App() {
return () => document.removeEventListener("keydown", onKeyDown);
}, [confirmReset]);
/* ----------------------------------------------------------- replay (§6) */
// `?at=<ordinal>` is the source of truth for where the recording is parked,
// so step / next-turn / play-all and the deep link all move the same value.
const replay = useMemo(() => {
if (snapshot === null || snapshot.replay === null) return null;
const { total } = snapshot.replay;
const ordinal =
route.at === null ? snapshot.replay.ordinal : Math.min(total, Math.max(0, route.at));
return { ordinal, total };
}, [snapshot, route.at]);
const seekReplay = useCallback(
(ordinal: number) => {
setPlaying(false);
window.location.hash = phase7RouteHref(route, { at: ordinal });
},
[route],
);
useEffect(() => {
if (!playing || replay === null) return;
if (replay.ordinal >= replay.total) {
setPlaying(false);
return;
}
const timer = window.setTimeout(() => {
window.location.hash = phase7RouteHref(route, { at: replay.ordinal + 1 });
}, REPLAY_STEP_MS);
return () => window.clearTimeout(timer);
}, [playing, replay, route]);
useEffect(() => {
if (replay === null) setPlaying(false);
}, [replay]);
/* ---------------------------------------------------------------- render */
const denialCount = useMemo(
@@ -379,46 +463,54 @@ export function App() {
</p>
) : null}
{snapshot.replay !== null ? (
{replay !== null ? (
<div className="pit-replay-strip" data-testid="replay-strip">
<span>
Replay {snapshot.replay.ordinal}/{snapshot.replay.total}
Replay {replay.ordinal}/{replay.total}
</span>
<div
className="pit-progress"
role="progressbar"
aria-valuemin={0}
aria-valuemax={snapshot.replay.total}
aria-valuenow={snapshot.replay.ordinal}
aria-valuemax={replay.total}
aria-valuenow={replay.ordinal}
aria-label="Replay progress"
>
<div
className="pit-progress-fill"
style={{ width: `${(snapshot.replay.ordinal / snapshot.replay.total) * 100}%` }}
style={{ width: `${(replay.ordinal / replay.total) * 100}%` }}
/>
</div>
<button
type="button"
className="pit-button"
onClick={() => {
window.location.hash = phase7RouteHref(route, {
at: Math.max(0, (route.at ?? snapshot.replay?.ordinal ?? 0) - 1),
});
}}
disabled={replay.ordinal <= 0}
data-testid="replay-step-back"
onClick={() => seekReplay(replay.ordinal - 1)}
>
Step back
</button>
<button
type="button"
className="pit-button"
onClick={() => {
window.location.hash = phase7RouteHref(route, {
at: (route.at ?? snapshot.replay?.ordinal ?? 0) + 1,
});
}}
disabled={replay.ordinal >= replay.total}
data-testid="replay-next-turn"
onClick={() => seekReplay(replay.ordinal + 1)}
>
Next turn
</button>
<button
type="button"
className="pit-button"
data-variant={playing ? undefined : "primary"}
aria-pressed={playing}
disabled={!playing && replay.ordinal >= replay.total}
data-testid="replay-play-all"
onClick={() => setPlaying((current) => !current)}
>
<span aria-hidden="true">{playing ? "❚❚" : "▶"}</span>
{playing ? "Pause" : "Play all"}
</button>
</div>
) : 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}
@@ -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 (
@@ -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<string | null>(null);
const firstControl = useRef<HTMLElement | null>(null);
const stateChip = useRef<HTMLSpanElement | null>(null);
const previousState = useRef<Phase7ThreadInteractionState>(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({
<h3 className="pit-interaction-title" id={titleId}>
{card.title}
</h3>
<Chip tone={stateTone(card.state)} testId="interaction-state-chip">
<Chip
tone={stateTone(card.state)}
testId="interaction-state-chip"
tabIndex={-1}
chipRef={stateChip}
>
<span aria-hidden="true">{STATE_GLYPH[card.state]}</span>
{card.stateLabel}
</Chip>
@@ -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) {
@@ -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<HTMLSpanElement>;
}) {
return (
<span className="pit-chip" data-tone={tone} title={title} data-testid={testId}>
<span
className="pit-chip"
data-tone={tone}
title={title}
data-testid={testId}
tabIndex={tabIndex}
ref={chipRef}
>
{children}
</span>
);
@@ -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";
@@ -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=<ordinal>` 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
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 45 KiB

+1 -1
View File
@@ -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",
@@ -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<Phase7UiShotSlug, () => 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<Phase7UiShotSlug, () => 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<Phase7UiShotSlug, () => 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" },
@@ -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