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>
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
|
||||
|
||||
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 45 KiB |
@@ -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
|
||||
|
||||