From d194e20db813d1b7aab85d236c11f6d5f01cb16c Mon Sep 17 00:00:00 2001 From: Hussain Phalasiya Date: Fri, 18 Sep 2026 21:29:19 +0300 Subject: [PATCH 1/5] fix(a11y): restore dialog invokers and record release checklist (VC-322) --- .../desktop/e2e/vc322-accessibility-smoke.mjs | 369 ++++++++++++++++++ apps/desktop/e2e/vc322-browser-boundary.mjs | 72 ++++ .../board/new-ticket/new-ticket-dialog.tsx | 3 + .../src/components/command-palette.tsx | 59 ++- .../components/ticket/ticket-title.test.tsx | 40 ++ .../src/components/ticket/ticket-title.tsx | 2 +- .../src/components/ui/dialog-focus.test.tsx | 130 ++++++ .../src/hooks/use-dialog-focus-return.ts | 31 ++ docs/vc322-accessibility-release-pass.md | 122 ++++++ 9 files changed, 811 insertions(+), 17 deletions(-) create mode 100644 apps/desktop/e2e/vc322-accessibility-smoke.mjs create mode 100644 apps/desktop/e2e/vc322-browser-boundary.mjs create mode 100644 apps/desktop/src/renderer/src/components/ticket/ticket-title.test.tsx create mode 100644 apps/desktop/src/renderer/src/components/ui/dialog-focus.test.tsx create mode 100644 apps/desktop/src/renderer/src/hooks/use-dialog-focus-return.ts create mode 100644 docs/vc322-accessibility-release-pass.md diff --git a/apps/desktop/e2e/vc322-accessibility-smoke.mjs b/apps/desktop/e2e/vc322-accessibility-smoke.mjs new file mode 100644 index 000000000..76a6784e1 --- /dev/null +++ b/apps/desktop/e2e/vc322-accessibility-smoke.mjs @@ -0,0 +1,369 @@ +/** VC-322 built-app observational release probe; artifacts stay in the workspace. + * Run after pnpm build: node apps/desktop/e2e/vc322-accessibility-smoke.mjs + * No provider calls, credentials, live profile, clipboard, or VoiceOver automation. + */ +import { promises as fs } from "node:fs"; +import { resolve, join } from "node:path"; +import { execFileSync } from "node:child_process"; +import { + launch, + assertBuiltRendererLoaded, + assertProfileIsolated, + seedProjects, + makeGitRepo, + startTerminalSession, +} from "./lib/smoke-kit.mjs"; +const output = resolve( + process.argv.slice(2).find((arg) => !arg.startsWith("--")) ?? "evidence/vc322", +); +const gateFixes = process.argv.includes("--gate-fixes"); +await fs.mkdir(output, { recursive: true }); +const scratch = await fs.mkdtemp(join(output, "run-")); +const userDataDir = join(scratch, "user-data"); +await fs.mkdir(userDataDir); +const project = await makeGitRepo(scratch, "project-"); +const app = await launch({ + dbPath: join(scratch, "volli.db"), + userDataDir, + extraEnv: { HOME: join(scratch, "home"), VOLLI_AGENT_HOME: join(scratch, "home") }, +}); +const page = await app.firstWindow(); +page.setDefaultTimeout(8000); +const report = { + sha: execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(), + dirty: execFileSync("git", ["status", "--short"], { encoding: "utf8" }), + checks: [], + observations: [], +}; +const active = () => + page.evaluate(() => { + const e = document.activeElement, + s = getComputedStyle(e); + return { + tag: e.tagName, + role: e.getAttribute("role"), + label: e.getAttribute("aria-label"), + text: e.textContent?.slice(0, 100), + placeholder: e.getAttribute("placeholder"), + focusVisible: e.matches(":focus-visible"), + outline: s.outline, + boxShadow: s.boxShadow, + border: s.border, + background: s.backgroundColor, + dialog: !!e.closest("[role=dialog]"), + }; + }); +async function check(name, fn) { + try { + const detail = await fn(); + report.checks.push({ name, ...detail }); + console.log(JSON.stringify(report.checks.at(-1))); + } catch (e) { + report.checks.push({ name, pass: false, error: e.message }); + console.log(name, e.message); + await page.keyboard.press("Escape"); + } +} +async function tabTour(name, count = 45) { + const steps = []; + for (let i = 0; i < count; i++) { + await page.keyboard.press("Tab"); + await page.waitForTimeout(200); + steps.push(await active()); + } + report.observations.push({ name, steps }); +} +try { + await page.waitForLoadState("domcontentloaded"); + assertBuiltRendererLoaded(page); + await assertProfileIsolated(app, userDataDir); + await app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows()[0].setContentSize(1440, 900), + ); + await seedProjects(page, [ + { id: "a11y-project", name: "Accessibility release", path: project, prefix: "AX" }, + ]); + await page.evaluate(async () => { + const b = await window.api.data.bootstrap(); + const p = b.data.projects[0]; + for (const [title, status] of [ + ["Keyboard ticket", "todo"], + ["Second ticket", "doing"], + ]) + await window.api.tickets.create({ + projectId: p.id, + title, + status, + priority: "medium", + description: "# Release body\n\nKeyboard and contrast fixture.", + }); + }); + await page.reload(); + await page.getByRole("button", { name: "New ticket", exact: true }).waitFor(); + await page.screenshot({ path: join(output, "board.png") }); + await fs.writeFile(join(output, "board-ax.txt"), await page.locator("body").ariaSnapshot()); + await tabTour("board keyboard focus"); + await check("New ticket keyboard entry, trapped focus, Escape and focus return", async () => { + const trigger = page.getByRole("button", { name: "New ticket", exact: true }); + await trigger.focus(); + await page.keyboard.press("Enter"); + await page.getByRole("dialog").waitFor(); + const entered = await active(); + const inside = []; + for (let i = 0; i < 18; i++) { + await page.keyboard.press("Tab"); + await page.waitForTimeout(200); + inside.push(await active()); + } + report.observations.push({ name: "dialog tab path before Escape", inside }); + await page.getByPlaceholder("Ticket title").focus(); + await page.keyboard.press("Escape"); + await page.getByRole("dialog").waitFor({ state: "hidden" }); + await page.waitForTimeout(250); + const returned = await trigger.evaluate((e) => e === document.activeElement); + return { + pass: entered.dialog && inside.every((e) => e.dialog) && returned, + entered, + returned, + after: await active(), + inside, + }; + }); + await check("Command palette Escape returns keyboard focus", async () => { + const trigger = page.getByRole("button", { name: "New ticket", exact: true }); + await trigger.focus(); + await page.keyboard.press("Meta+K"); + await page.getByRole("dialog").waitFor(); + const entered = await active(); + await page.keyboard.press("Escape"); + await page.getByRole("dialog").waitFor({ state: "hidden" }); + await page.waitForTimeout(250); + const returned = await trigger.evaluate((e) => e === document.activeElement); + return { pass: entered.dialog && returned, entered, returned, after: await active() }; + }); + await check("Board card Enter opens ticket; Escape returns to card", async () => { + const card = page + .locator('[data-board-ticket-slot] > [role="button"]') + .filter({ hasText: "AX-1" }) + .first(); + await card.focus(); + await page.keyboard.press("Enter"); + await page.getByRole("tablist", { name: "Ticket tabs" }).waitFor(); + const opened = await active(); + await page.keyboard.press("Escape"); + await page.getByRole("tab", { name: "Board", exact: true }).waitFor(); + await page.waitForTimeout(250); + return { + pass: await card.evaluate((e) => e === document.activeElement), + opened, + after: await active(), + }; + }); + await check("List row keyboard open and return", async () => { + await page.getByRole("button", { name: "List view", exact: true }).click(); + await fs.writeFile(join(output, "list-ax.txt"), await page.locator("body").ariaSnapshot()); + const row = page.locator('[data-ticket-id="AX-1"]').first(); + await row.focus(); + await page.keyboard.press("Enter"); + await page.getByRole("tablist", { name: "Ticket tabs" }).waitFor(); + await page.screenshot({ path: join(output, "ticket.png") }); + await fs.writeFile(join(output, "ticket-ax.txt"), await page.locator("body").ariaSnapshot()); + await tabTour("ticket body comments and rail", 12); + await page.keyboard.press("Control+Shift+M"); + await page.keyboard.press("Tab"); + report.observations.push({ + name: "Monaco Tab escape after Control+Shift+M", + after: await active(), + }); + await page.getByRole("tablist", { name: "Ticket tabs" }).getByRole("tab").first().focus(); + await page.keyboard.press("Escape"); + await page.getByRole("button", { name: "List view", exact: true }).waitFor(); + await page.waitForTimeout(250); + return { pass: await row.evaluate((e) => e === document.activeElement), after: await active() }; + }); + // Native theme is the app's source of truth in auto mode. No custom canvases. + for (const mode of ["dark", "light"]) { + await app.evaluate(({ nativeTheme }, m) => { + nativeTheme.themeSource = m; + }, mode); + await page.waitForFunction((m) => document.documentElement.classList.contains(m), mode); + await page.getByRole("button", { name: "New ticket", exact: true }).focus(); + await page.keyboard.press("Enter"); + await page.getByRole("dialog").waitFor(); + const tokens = await page.evaluate(() => { + const root = getComputedStyle(document.documentElement), + names = [ + "background", + "card", + "popover", + "foreground", + "muted-foreground", + "primary", + "primary-foreground", + "primary-text", + "ring", + "border", + "destructive", + "positive", + "attention", + "info", + ]; + // Runs in the renderer, not the Node process that owns this probe. + // eslint-disable-next-line unicorn/consistent-function-scoping + const rgb = (value) => { + const e = document.createElement("span"); + e.style.color = value; + document.body.append(e); + const c = getComputedStyle(e).color; + e.remove(); + return c; + }; + return Object.fromEntries(names.map((n) => [n, rgb(root.getPropertyValue("--" + n))])); + }); + report.observations.push({ name: `${mode} default canvas tokens`, tokens }); + report.observations.push({ + name: `${mode} dialog controls`, + controls: await page + .getByRole("dialog") + .locator("button,input,textarea") + .evaluateAll((es) => + es.map((e) => { + const s = getComputedStyle(e); + return { + text: e.textContent, + label: e.getAttribute("aria-label"), + color: s.color, + background: s.backgroundColor, + fontSize: s.fontSize, + disabled: e.disabled, + }; + }), + ), + }); + await page.screenshot({ path: join(output, `${mode}-dialog.png`) }); + await page.keyboard.press("Escape"); + await page.getByRole("dialog").waitFor({ state: "hidden" }); + } + for (const reducedMotion of ["no-preference", "reduce"]) { + await page.emulateMedia({ reducedMotion }); + await page.getByRole("button", { name: "New ticket", exact: true }).focus(); + await page.keyboard.press("Enter"); + await page.getByRole("dialog").waitFor(); + report.observations.push({ + name: `motion ${reducedMotion}`, + matches: await page.evaluate(() => matchMedia("(prefers-reduced-motion: reduce)").matches), + animations: await page.evaluate(() => + document.getAnimations().map((a) => ({ + target: a.effect?.target?.outerHTML.slice(0, 250), + timing: a.effect?.getTiming(), + keyframes: a.effect?.getKeyframes(), + state: a.playState, + })), + ), + styles: await page.getByRole("dialog").evaluate((e) => { + const s = getComputedStyle(e); + return { animation: s.animation, transition: s.transition }; + }), + }); + await page.keyboard.press("Escape"); + await page.getByRole("dialog").waitFor({ state: "hidden" }); + } + for (const zoom of [1.25, 1.5, 2]) { + await app.evaluate( + ({ BrowserWindow }, z) => BrowserWindow.getAllWindows()[0].webContents.setZoomFactor(z), + zoom, + ); + await page.getByRole("button", { name: "New ticket", exact: true }).focus(); + await page.keyboard.press("Enter"); + await page.getByRole("dialog").waitFor(); + const bounds = await page.getByRole("dialog").evaluate((e) => { + const r = e.getBoundingClientRect(); + return { + x: r.x, + y: r.y, + width: r.width, + height: r.height, + viewport: [innerWidth, innerHeight], + scrollWidth: e.scrollWidth, + clientWidth: e.clientWidth, + }; + }); + await page.screenshot({ path: join(output, `zoom-${zoom}.png`) }); + report.checks.push({ + name: `BrowserWindow zoom ${zoom} dialog fits`, + pass: + bounds.x >= 0 && + bounds.y >= 0 && + bounds.x + bounds.width <= bounds.viewport[0] + 1 && + bounds.y + bounds.height <= bounds.viewport[1] + 1, + bounds, + }); + await page.keyboard.press("Escape"); + await page.getByRole("dialog").waitFor({ state: "hidden" }); + } + + await app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows()[0].webContents.setZoomFactor(1), + ); + for (let step = 0; step < 5; step++) { + await app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows()[0].webContents.send("volli:ui-zoom-command", "in"), + ); + await page.waitForTimeout(250); + const scale = await page.evaluate( + async () => + JSON.parse((await window.api.data.bootstrap()).data.appState["volli:ui"]).state.uiScale, + ); + if (step >= 1) { + await page.screenshot({ path: join(output, `app-zoom-step-${step}.png`) }); + report.observations.push({ name: "native app zoom command", step, scale }); + } + } + await app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows()[0].webContents.send("volli:ui-zoom-command", "reset"), + ); + await check("Terminal keyboard boundary", async () => { + await startTerminalSession(page); + const input = page.locator(".xterm-helper-textarea").last(); + await input.waitFor({ state: "attached" }); + await input.focus(); + await page.keyboard.type("printf 'VC322-TERMINAL-OUTPUT\\n'"); + await page.keyboard.press("Enter"); + await page.waitForTimeout(800); + const steps = []; + for (const key of ["Tab", "Shift+Tab", "Escape", "Meta+Alt+Enter", "Meta+Alt+Enter"]) { + await page.keyboard.press(key); + await page.waitForTimeout(200); + steps.push({ key, active: await active() }); + } + const semantics = await page + .locator("[data-terminal-renderer]") + .last() + .evaluate((e) => ({ + role: e.getAttribute("role"), + label: e.getAttribute("aria-label"), + tree: e.outerHTML.slice(0, 1200), + screenReaderLayer: !!e.querySelector(".xterm-accessibility"), + })); + await fs.writeFile(join(output, "terminal-ax.txt"), await page.locator("body").ariaSnapshot()); + return { pass: steps.some((s) => s.active.tag !== "TEXTAREA"), steps, semantics }; + }); +} finally { + await fs.writeFile(join(output, "report.json"), JSON.stringify(report, null, 2)); + await app.close(); +} +// The broader release findings are observational and deliberately remain red. +// Only opt-in fix regression checks determine this probe's process exit code. +if (gateFixes) { + const dialogs = report.checks.slice(0, 2); + const title = report.observations + .find((item) => item.name === "ticket body comments and rail") + ?.steps.find((item) => item.tag === "H1"); + if ( + dialogs.length !== 2 || + dialogs.some((item) => !item.pass) || + !title?.focusVisible || + !title.boxShadow.includes("2px") + ) + process.exitCode = 1; +} diff --git a/apps/desktop/e2e/vc322-browser-boundary.mjs b/apps/desktop/e2e/vc322-browser-boundary.mjs new file mode 100644 index 000000000..157b06cfa --- /dev/null +++ b/apps/desktop/e2e/vc322-browser-boundary.mjs @@ -0,0 +1,72 @@ +/** Built-app VC-301 keyboard-boundary observation. Local fixture only. */ +import { promises as fs } from "node:fs"; +import http from "node:http"; +import { resolve, join } from "node:path"; +import { + launch, + seedProjects, + makeGitRepo, + assertBuiltRendererLoaded, + assertProfileIsolated, +} from "./lib/smoke-kit.mjs"; +const out = resolve("evidence/vc322/browser-boundary"); +await fs.mkdir(out, { recursive: true }); +const scratch = await fs.mkdtemp(join(out, "run-")); +const userDataDir = join(scratch, "user-data"); +await fs.mkdir(userDataDir); +const server = http.createServer((_req, res) => + res.end( + 'Keyboard fixture', + ), +); +await new Promise((r) => server.listen(0, "127.0.0.1", r)); +const url = `http://127.0.0.1:${server.address().port}/`; +const app = await launch({ + dbPath: join(scratch, "volli.db"), + userDataDir, + extraEnv: { HOME: join(scratch, "home") }, +}); +const results = []; +try { + const page = await app.firstWindow(); + await page.waitForLoadState("domcontentloaded"); + assertBuiltRendererLoaded(page); + await assertProfileIsolated(app, userDataDir); + const path = await makeGitRepo(scratch, "project-"); + await seedProjects(page, [{ id: "boundary", name: "Boundary", prefix: "KB", path }]); + await page.getByRole("button", { name: "Other things to open", exact: true }).click(); + await page.getByRole("menuitem", { name: "Browser", exact: true }).click(); + const address = page.getByRole("textbox", { name: "Address", exact: true }); + await address.fill(url); + await address.press("Enter"); + await page.getByRole("tab", { name: "Keyboard fixture", exact: true }).waitFor(); + for (const key of [ + { keyCode: "k", modifiers: ["meta"] }, + { keyCode: "Escape", modifiers: [] }, + { keyCode: "Tab", modifiers: ["shift"] }, + ]) { + await app.evaluate( + ({ webContents }, input) => { + const wc = webContents.getAllWebContents().find((w) => w.getURL() === input.url); + wc.focus(); + wc.sendInputEvent({ type: "keyDown", ...input.key }); + wc.sendInputEvent({ type: "keyUp", ...input.key }); + }, + { url, key }, + ); + await page.waitForTimeout(300); + results.push({ + key, + dialogs: await page.getByRole("dialog").count(), + focus: await app.evaluate(({ webContents }) => { + const w = webContents.getFocusedWebContents(); + return w ? { url: w.getURL(), type: w.getType() } : null; + }), + }); + } + await fs.writeFile(join(out, "report.json"), JSON.stringify(results, null, 2)); + console.log(JSON.stringify(results, null, 2)); +} finally { + await app.close(); + await new Promise((r) => server.close(r)); +} diff --git a/apps/desktop/src/renderer/src/components/board/new-ticket/new-ticket-dialog.tsx b/apps/desktop/src/renderer/src/components/board/new-ticket/new-ticket-dialog.tsx index 11242bfcc..ff02a63e9 100644 --- a/apps/desktop/src/renderer/src/components/board/new-ticket/new-ticket-dialog.tsx +++ b/apps/desktop/src/renderer/src/components/board/new-ticket/new-ticket-dialog.tsx @@ -4,6 +4,7 @@ import { PROMPT_SURFACE } from "@renderer/components/chat/composer-chrome"; import { ComposerForm } from "@renderer/components/board/new-ticket/composer-form"; import { Dialog, DialogContent, DialogTitle } from "@renderer/components/ui/dialog"; import { useSelectedProject } from "@renderer/hooks/use-selected-project"; +import { useDialogFocusReturn } from "@renderer/hooks/use-dialog-focus-return"; import { cn } from "@renderer/lib/utils"; import { useUiStore } from "@renderer/stores/ui"; @@ -27,6 +28,7 @@ export function NewTicketDialog() { const project = useSelectedProject(); const newTicketOpen = useUiStore((state) => state.newTicketOpen); const open = newTicketOpen && project !== null; + const { restoreFocus } = useDialogFocusReturn(open); const [expanded, setExpanded] = React.useState(false); return ( @@ -41,6 +43,7 @@ export function NewTicketDialog() { > + + + + + Search tickets and sessions + + + {children} + + + + + ); +} + /** No tickets/sessions to show while closed — keeps the derivation below free. */ const EMPTY_COMMAND_PALETTE_ITEMS: CommandPaletteItems = { tickets: [], sessions: [] }; @@ -132,6 +167,8 @@ function ShowAllRow({ sectionId, total, noun, onExpand }: ShowAllRowProps) { * surfaces — narrows the palette to one section. */ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) { + const { restoreFocus, skipFocusReturn } = useDialogFocusReturn(open); + const projects = useProjectsStore((state) => state.projects); const selectedProjectId = useProjectsStore((state) => state.selectedProjectId); const ticketsByProject = useBoardStore((state) => state.ticketsByProject); @@ -340,25 +377,15 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) { }, [open, projectIds]); const finishNavigation = React.useCallback(() => { + // Navigation intentionally hands focus to the destination rather than the + // palette's invoker. Ordinary dismissal restores after the focus scope closes. + skipFocusReturn(); useUiStore.getState().setSettingsOpen(false); onOpenChange(false); - }, [onOpenChange]); + }, [onOpenChange, skipFocusReturn]); return ( - + {/* 36px, matching the footer strip: the header was 48 — a full rung taller than any control this app draws — so the field read as a hero banner rather than as the search box the rows answer to. `text-ui` is the @@ -655,6 +682,6 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) { ↑↓ navigate ↵ open - + ); } diff --git a/apps/desktop/src/renderer/src/components/ticket/ticket-title.test.tsx b/apps/desktop/src/renderer/src/components/ticket/ticket-title.test.tsx new file mode 100644 index 000000000..40d0c8daa --- /dev/null +++ b/apps/desktop/src/renderer/src/components/ticket/ticket-title.test.tsx @@ -0,0 +1,40 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vite-plus/test"; +import type { Ticket } from "@volli/shared"; + +import { TicketTitle } from "./ticket-title"; + +vi.mock("@renderer/stores/board", () => ({ + useBoardStore: () => vi.fn(), +})); + +const ticket: Ticket = { + id: "t1", + projectId: "p1", + ticketNumber: 1, + title: "Keyboard focus", + body: "", + status: "todo", + priority: "medium", + labels: [], + usesWorktree: false, + preferredHarnessId: "claude-code", + order: 0, + worktreePath: null, + branch: null, + baseBranch: null, + prUrl: null, + createdAt: 1, + updatedAt: 1, +}; + +describe("TicketTitle", () => { + it("keeps a visible semantic-token focus ring on the keyboard trigger", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain('role="button"'); + expect(html).toContain("focus-visible:ring-2"); + expect(html).toContain("focus-visible:ring-ring/45"); + expect(html).toContain("focus-visible:outline-none"); + }); +}); diff --git a/apps/desktop/src/renderer/src/components/ticket/ticket-title.tsx b/apps/desktop/src/renderer/src/components/ticket/ticket-title.tsx index 5e37b0d77..b504b1177 100644 --- a/apps/desktop/src/renderer/src/components/ticket/ticket-title.tsx +++ b/apps/desktop/src/renderer/src/components/ticket/ticket-title.tsx @@ -54,7 +54,7 @@ export function TicketTitle({ ticket }: { ticket: Ticket }) { setEditing(true); } }} - className="cursor-text text-title font-semibold text-foreground outline-none" + className="cursor-text text-title font-semibold text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/45 focus-visible:outline-none" > {ticket.title} diff --git a/apps/desktop/src/renderer/src/components/ui/dialog-focus.test.tsx b/apps/desktop/src/renderer/src/components/ui/dialog-focus.test.tsx new file mode 100644 index 000000000..4ad8539e5 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/ui/dialog-focus.test.tsx @@ -0,0 +1,130 @@ +// @vitest-environment jsdom +import * as React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { useDialogFocusReturn } from "@renderer/hooks/use-dialog-focus-return"; +import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "./dialog"; + +let root: Root; +let host: HTMLDivElement; +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); +}); +afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + vi.unstubAllGlobals(); +}); + +async function settle() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); +} +function Fixture({ + trigger = false, + override = false, + skip = false, +}: { + trigger?: boolean; + override?: boolean; + skip?: boolean; +}) { + const [open, setOpen] = React.useState(false); + const { restoreFocus, skipFocusReturn } = useDialogFocusReturn(open); + return ( + <> + + + {trigger ? ( + Open + ) : ( + + )} + { + event.preventDefault(); + document.getElementById("destination")?.focus(); + restoreFocus(event); + } + : restoreFocus + } + > + Fixture + + + + + + ); +} +for (const trigger of [false, true]) + it(`restores keyboard invoker after dismissal (Radix trigger ${trigger})`, async () => { + await act(async () => root.render()); + const invoker = document.getElementById("invoker")!; + await act(async () => { + invoker.focus(); + invoker.click(); + }); + await settle(); + expect(document.activeElement?.closest('[role="dialog"]')).not.toBeNull(); + await act(async () => document.getElementById("dismiss")!.click()); + await settle(); + expect(document.activeElement).toBe(invoker); + }); +it("honours a caller's explicit focus handoff", async () => { + await act(async () => root.render()); + await act(async () => { + document.getElementById("invoker")!.focus(); + document.getElementById("invoker")!.click(); + }); + await settle(); + await act(async () => document.getElementById("dismiss")!.click()); + await settle(); + expect(document.activeElement).toBe(document.getElementById("destination")); +}); +it("does not pull focus back after a navigation selection", async () => { + await act(async () => root.render()); + const invoker = document.getElementById("invoker")!; + await act(async () => { + invoker.focus(); + invoker.click(); + }); + await settle(); + const focus = vi.spyOn(invoker, "focus"); + await act(async () => document.getElementById("dismiss")!.click()); + await settle(); + expect(focus).not.toHaveBeenCalled(); +}); +it("ignores an invoker removed while the dialog is open", async () => { + await act(async () => root.render()); + const invoker = document.getElementById("invoker")!; + await act(async () => { + invoker.focus(); + invoker.click(); + }); + await settle(); + invoker.remove(); + const focus = vi.spyOn(invoker, "focus"); + await act(async () => document.getElementById("dismiss")!.click()); + await settle(); + expect(focus).not.toHaveBeenCalled(); + // Restore React's host node before teardown. + host.append(invoker); +}); diff --git a/apps/desktop/src/renderer/src/hooks/use-dialog-focus-return.ts b/apps/desktop/src/renderer/src/hooks/use-dialog-focus-return.ts new file mode 100644 index 000000000..0e57cf258 --- /dev/null +++ b/apps/desktop/src/renderer/src/hooks/use-dialog-focus-return.ts @@ -0,0 +1,31 @@ +import * as React from "react"; + +/** A controlled dialog has no Radix Trigger to restore. Sample its invoker + * during the opening render: an autoFocus child has already taken focus by + * onOpenAutoFocus. Restore only after Radix releases the closing focus scope. + */ +export function useDialogFocusReturn(open: boolean) { + const candidate = React.useMemo(() => { + const active = open ? document.activeElement : null; + return active instanceof HTMLElement ? active : null; + }, [open]); + const invoker = React.useRef(null); + const skipped = React.useRef(false); + React.useLayoutEffect(() => { + if (open) { + invoker.current = candidate; + skipped.current = false; + } + }, [open, candidate]); + const skipFocusReturn = React.useCallback(() => { + skipped.current = true; + }, []); + const restoreFocus = React.useCallback((event: Event) => { + const target = invoker.current; + invoker.current = null; + if (event.defaultPrevented || skipped.current || !target?.isConnected) return; + event.preventDefault(); + target.focus({ preventScroll: true }); + }, []); + return { restoreFocus, skipFocusReturn }; +} diff --git a/docs/vc322-accessibility-release-pass.md b/docs/vc322-accessibility-release-pass.md new file mode 100644 index 000000000..993a888e8 --- /dev/null +++ b/docs/vc322-accessibility-release-pass.md @@ -0,0 +1,122 @@ +# VC-322 — accessibility/motion candidate checklist + +**Release verdict: BLOCKED, not accessibility sign-off.** Focused fixes are ready for review; +remaining failures and unmeasured native behavior must not be counted as passes. +Merge order requested by release coordination: **VC-343 → VC-344 → VC-322**. +This patch does not change terminal shortcuts or terminal source. + +## Candidate and method + +- Baseline: fetched `origin/main` **3bf58945273ea876fac744f2f50483eec69ab378**, canary.11. +- `pnpm install` (including native rebuild) and `pnpm run build` passed. +- Real Electron main/preload and `volli-app://bundle/index.html`, not the UI lab. + Disposable database, HOME, project repository, and profile live inside this worktree's + ignored `evidence/` directory. Profile and built-renderer identity asserted at runtime. +- Playwright keyboard events, DOM focus/computed styles, accessibility snapshots, screenshots, + actual native-theme changes, and persisted app zoom commands. These are **not VoiceOver speech**. +- Baseline: `evidence/vc322/baseline/`; fixed build: `evidence/vc322/candidate/`. + Reports contain exact focus paths, styles, animation keyframes, and dialog geometry. + The initial exploratory `report.json` had an incorrect board locator and sampled focus + transitions too early; use `baseline/report.json`, not that initial run. + +## Per-surface checklist + +| Surface/check | Result | Built evidence / remaining gate | +| --- | --- | --- | +| Project navigation/chrome | Partial | Tab reaches project tile, Add Project, Home, Automations, Configure, Settings and chrome controls; settled computed focus rings recorded. Full project switching and native folder picker/VoiceOver not tested. | +| Board/list | **Fail — VC-419** | Enter opens the selected ticket; Escape from its tab returns to board/list. Both entry and return lose focus to BODY, not ticket/selected row. | +| Ticket title | **Fixed** | Baseline H1 button had no outline/shadow under `:focus-visible`; candidate has a 2px semantic ring. | +| Ticket body | Partial | Monaco exposes `Ticket description`; Tab inserts indentation. **Control+Shift+M, then Tab** successfully reaches Add a comment. Escape inside Monaco is editor-owned, not a close failure. Human discoverability/VoiceOver still required. | +| Comments | Partial | Add a comment textarea reachable using Monaco's tab-focus toggle. Submission, async completion announcements, error recovery and edit/delete confirmations not exercised. No claim of full focus-visibility compliance. | +| New-ticket dialog | **Fixed; tested pass** | Keyboard Enter opens; focus stays inside across sampled Tab path; Escape from title closes and restores the actual New ticket invoker. Baseline returned BODY. | +| Command palette | **Fixed; tested pass** | Meta+K → input → Escape restores actual pre-open control. Explicit navigation selections skip restoration. | +| Other overlays/dialogs | **Not verified** | Destructive confirms, permission overlays, nested menus and all focus-return paths require candidate sweep. Shared Radix behavior is not substituted for evidence. | +| Pending questions / permission cards | **Not verified** | No live provider turn or credentials used. Need seeded/signed candidate with pending ask/approve/deny/cancel/retry states at 320/480/720px and each zoom. | +| Files | **Incomplete — VC-311** | Existing built smoke: listing and dirty Cancel/Save guard pass; multiple row click timeouts cascade into preview/save/restore failures; refresh also times out. Requires independent triage, not assumed product failure. | +| Diffs | Partial — VC-311 | 7/8 numbered smoke checks pass: real Monaco diff, keyboard edit/save to disk, shared file/diff model and presentation toggle. Untracked-file refresh check fails. Full keyboard/VoiceOver/narrow matrix not proven. | +| Embedded browser | **Incomplete — VC-301** | Existing browser smoke 10/10 passes isolation, tab lifecycle and hold behavior, not app-shortcut routing. Separate synthetic Meta+K injection opens no palette, but quiet-window native focused WebContents is null: **inconclusive native-focus evidence**, retain human check. | +| Terminal | **Fail baseline — VC-344** | Current xterm input is named `Terminal input`. Tab, Shift+Tab, Escape and two Meta+Alt+Enter presses all retain that textarea. Host role/name null; no xterm accessibility layer. No native VoiceOver claim. Re-run after VC-344; this is not a verdict on its pending fix. | +| Pane resizing / async feedback | **Not verified** | Keyboard divider interaction, selected row retention after refresh, completion announcements and retry focus remain manual/fixture gates. | + +## Default-canvas contrast + +WCAG 2.x relative luminance, using computed RGB in the built renderer (no custom canvas): + +| Pair | Dark | Light | Result | +| --- | ---: | ---: | --- | +| foreground/background | 14.46:1 | 15.51:1 | Pass normal text | +| muted-foreground/card | 9.02:1 | 6.63:1 | Pass normal text | +| primary-text/background | 8.71:1 | 5.08:1 | Pass normal text | +| white primary label / #d37550 | **3.27:1** | **3.27:1** | **Fail 4.5:1 at 13px — VC-418** | +| opaque ring/background | 5.59:1 | **2.57:1** | Light below 3:1; actual 45% rings need composite measurement — VC-418 | + +New ticket is an enabled primary control using the failing text pair. Disabled Create & start +is not used to establish this finding. This table is a sampled semantic-token check, not an +all-controls contrast audit or a verdict on user-selected colors. + +## Motion and zoom + +- **Pass, dialog entrance only:** default modal/scrim have 200ms entrance animations; + with `prefers-reduced-motion: reduce`, dialog animation computes to `none` and those + entrance animations are absent. Preference changes can produce `scale: none → 1` + transitions on controls (identical visual scale), not evidence of unwanted movement. +- No blanket pass for drag, split, streaming, scroll anchoring, exit interruption or delayed + async state. These were not comprehensively exercised. +- Shipped app zoom commands persist **125% → 150% → 150%**: **200% is unavailable**. + Recorded on VC-288. Native CSS zoom screenshots retained for 125/150; full per-pane + functionality at those settings remains unverified. +- Exploratory BrowserWindow zoom at 125/150/200 gives in-viewport dialog DOM bounds. + **This is not a product 200% pass.** Playwright zoom screenshots can crop differently + from measured viewport geometry; do not use those captures as proof of visual fit. + +## Focused patch and checks + +- New-ticket and palette capture the focused invoker during the opening render, **before** + autofocus children take it. Restore using Radix's close-focus lifecycle, after trap teardown. + Do not restore a removed invoker or override explicit caller handoff/navigation. +- Palette keeps its original chrome/command contents, exposing the Radix lifecycle instead + of cmdk's convenience Dialog. No shared-dialog global policy change. +- Ticket title gains a keyboard-only semantic focus ring. +- Focused unit run: **3 files / 7 tests passed** (dialog focus lifecycle, title, palette order). +- Focus-hook coverage: **100% statements/branches/functions/lines**, 5 tests passed. +- Renderer TypeScript check passed. Full monorepo coverage/CI and signed candidate not claimed. + +Reproduce (workspace-local artifacts): + +```sh +pnpm install +pnpm run build +node apps/desktop/e2e/vc322-accessibility-smoke.mjs evidence/vc322/recheck --gate-fixes +node apps/desktop/e2e/vc322-browser-boundary.mjs +pnpm -C apps/desktop exec vp test run \ + src/renderer/src/components/ui/dialog-focus.test.tsx \ + src/renderer/src/components/ticket/ticket-title.test.tsx \ + src/renderer/src/components/command-palette-order.test.tsx \ + --maxWorkers=$VOLLI_CONCURRENCY_HINT +pnpm exec tsc --noEmit -p apps/desktop/tsconfig.web.json --composite false +``` + +The accessibility probe is observational by default. `--gate-fixes` fails on regression of +these two dialogs or the title ring; it deliberately does **not** turn the other known release +failures into a passing gate. The JSON checklist remains the broader verdict. + +## Human / signed-candidate handoff (all unchecked) + +Record candidate SHA/signature, macOS version, appearance, zoom, person, result and issue per row: + +- [ ] VoiceOver: project tile names, board/list ticket names/status/selection; open and return. +- [ ] Ticket title/body/comment editing, Monaco Control+Shift+M discovery, reading order, + persisted submission, error announcement and draft preservation. +- [ ] Pending question and permission cards: question/options announced, keyboard answer, + approve/deny/cancel/retry; no clipped action at 320/480/720px, 125/150/200%. +- [ ] Every modal/menu/confirm: initial focus, trapped Tab/Shift+Tab, nested Escape, + close/cancel/submit and invoker return; no focus behind native browser view. +- [ ] Browser page input → app search/navigation/close/return, correct pane, page edit keys intact. +- [ ] VC-344 integrated terminal: read output before/after split/hide/show; advertised escape + reaches app controls, Tab reaches terminal again, PTY keys preserved. +- [ ] File tree/diff names, selected line/change, editor escape, dirty-save guard, inline fallback. +- [ ] Light/Dark focus visibility and contrast after VC-418; reduced-motion launches and live + preference changes, resize, streaming, completion and recoverable failures. + +A person must execute these checks; installing a signed candidate or toggling VoiceOver on the +shared machine was not performed by this pass. From fcffd228bbaa971a1f7a481f662e4246cceb2baf Mon Sep 17 00:00:00 2001 From: Hussain Phalasiya Date: Fri, 18 Sep 2026 21:33:10 +0300 Subject: [PATCH 2/5] docs(a11y): record current-main candidate recheck and release hold --- docs/vc322-accessibility-release-pass.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/vc322-accessibility-release-pass.md b/docs/vc322-accessibility-release-pass.md index 993a888e8..25072e3d5 100644 --- a/docs/vc322-accessibility-release-pass.md +++ b/docs/vc322-accessibility-release-pass.md @@ -8,6 +8,10 @@ This patch does not change terminal shortcuts or terminal source. ## Candidate and method - Baseline: fetched `origin/main` **3bf58945273ea876fac744f2f50483eec69ab378**, canary.11. +- Re-synced with `origin/main` **10e8ada5** (VC-412 included), then installed and rebuilt. + Clean candidate **3497b6786f3c9fed770a5159862c84732a139405** passed the built + `--gate-fixes` rerun; `evidence/vc322/synced/report.json` retains the same remaining + board/list/terminal failures and 3.27:1 primary contrast. VC-343/344 are not integrated yet. - `pnpm install` (including native rebuild) and `pnpm run build` passed. - Real Electron main/preload and `volli-app://bundle/index.html`, not the UI lab. Disposable database, HOME, project repository, and profile live inside this worktree's @@ -79,7 +83,12 @@ all-controls contrast audit or a verdict on user-selected colors. - Ticket title gains a keyboard-only semantic focus ring. - Focused unit run: **3 files / 7 tests passed** (dialog focus lifecycle, title, palette order). - Focus-hook coverage: **100% statements/branches/functions/lines**, 5 tests passed. -- Renderer TypeScript check passed. Full monorepo coverage/CI and signed candidate not claimed. +- Renderer TypeScript, changed-file lint and whitespace checks passed after sync. +- An additional combined composer-form/palette-model/palette-search test invocation timed out + at 45s before producing test results; it is not counted as passed. Full monorepo coverage/CI + and signed candidate not claimed. +- Draft PR: https://github.com/hussainph/volli-code/pull/579. CI was pending when submitted; + keep merge held for the terminal integration order and rerun the integrated candidate. Reproduce (workspace-local artifacts): From f8b1926e6b73ed7d69efa99ca7f259446b6f6a5e Mon Sep 17 00:00:00 2001 From: Hussain Phalasiya Date: Fri, 18 Sep 2026 23:28:51 +0300 Subject: [PATCH 3/5] fix(a11y): guard SSR focus sampling and reconcile merged release fixes --- .../desktop/e2e/vc322-accessibility-smoke.mjs | 31 ++++++++++++++++-- apps/desktop/e2e/vc322-browser-boundary.mjs | 32 +++++++++++++++---- .../components/ticket/ticket-title.test.tsx | 3 +- .../src/components/ticket/ticket-title.tsx | 2 +- .../use-dialog-focus-return.node.test.tsx | 22 +++++++++++++ .../src/hooks/use-dialog-focus-return.ts | 5 ++- 6 files changed, 84 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/src/renderer/src/hooks/use-dialog-focus-return.node.test.tsx diff --git a/apps/desktop/e2e/vc322-accessibility-smoke.mjs b/apps/desktop/e2e/vc322-accessibility-smoke.mjs index 76a6784e1..bd4912e41 100644 --- a/apps/desktop/e2e/vc322-accessibility-smoke.mjs +++ b/apps/desktop/e2e/vc322-accessibility-smoke.mjs @@ -12,6 +12,7 @@ import { seedProjects, makeGitRepo, startTerminalSession, + closeAppBounded, } from "./lib/smoke-kit.mjs"; const output = resolve( process.argv.slice(2).find((arg) => !arg.startsWith("--")) ?? "evidence/vc322", @@ -336,6 +337,23 @@ try { await page.waitForTimeout(200); steps.push({ key, active: await active() }); } + // VC-344 deliberately preserves PTY Tab bytes by default. Test the + // advertised focus-navigation mode, rather than calling raw Tab a trap. + await input.focus(); + await page.keyboard.press("Control+Shift+M"); + const navigation = []; + for (const key of ["Tab", "Shift+Tab"]) { + await input.focus(); + await page.keyboard.press(key); + await page.waitForTimeout(200); + navigation.push({ + key, + outsideTerminal: await page.evaluate( + () => !document.activeElement?.closest("[data-terminal-renderer]"), + ), + active: await active(), + }); + } const semantics = await page .locator("[data-terminal-renderer]") .last() @@ -346,11 +364,20 @@ try { screenReaderLayer: !!e.querySelector(".xterm-accessibility"), })); await fs.writeFile(join(output, "terminal-ax.txt"), await page.locator("body").ariaSnapshot()); - return { pass: steps.some((s) => s.active.tag !== "TEXTAREA"), steps, semantics }; + return { + pass: + navigation.every((step) => step.outsideTerminal) && + semantics.role === "region" && + Boolean(semantics.label) && + semantics.screenReaderLayer, + steps, + navigation, + semantics, + }; }); } finally { await fs.writeFile(join(output, "report.json"), JSON.stringify(report, null, 2)); - await app.close(); + await closeAppBounded(app); } // The broader release findings are observational and deliberately remain red. // Only opt-in fix regression checks determine this probe's process exit code. diff --git a/apps/desktop/e2e/vc322-browser-boundary.mjs b/apps/desktop/e2e/vc322-browser-boundary.mjs index 157b06cfa..1327dd8af 100644 --- a/apps/desktop/e2e/vc322-browser-boundary.mjs +++ b/apps/desktop/e2e/vc322-browser-boundary.mjs @@ -8,15 +8,23 @@ import { makeGitRepo, assertBuiltRendererLoaded, assertProfileIsolated, + closeAppBounded, } from "./lib/smoke-kit.mjs"; -const out = resolve("evidence/vc322/browser-boundary"); +const nativeWindow = process.argv.includes("--native-window"); +const out = resolve( + process.argv.slice(2).find((arg) => !arg.startsWith("--")) ?? "evidence/vc322/browser-boundary", +); await fs.mkdir(out, { recursive: true }); const scratch = await fs.mkdtemp(join(out, "run-")); const userDataDir = join(scratch, "user-data"); await fs.mkdir(userDataDir); const server = http.createServer((_req, res) => res.end( - 'Keyboard fixture', + `Keyboard fixture + + `, ), ); await new Promise((r) => server.listen(0, "127.0.0.1", r)); @@ -24,7 +32,10 @@ const url = `http://127.0.0.1:${server.address().port}/`; const app = await launch({ dbPath: join(scratch, "volli.db"), userDataDir, - extraEnv: { HOME: join(scratch, "home") }, + extraEnv: { + HOME: join(scratch, "home"), + VOLLI_QUIET_WINDOWS: nativeWindow ? "0" : "1", + }, }); const results = []; try { @@ -46,17 +57,26 @@ try { { keyCode: "Tab", modifiers: ["shift"] }, ]) { await app.evaluate( - ({ webContents }, input) => { + ({ webContents, BrowserWindow }, input) => { + if (input.nativeWindow) BrowserWindow.getAllWindows()[0].focus(); const wc = webContents.getAllWebContents().find((w) => w.getURL() === input.url); wc.focus(); wc.sendInputEvent({ type: "keyDown", ...input.key }); wc.sendInputEvent({ type: "keyUp", ...input.key }); }, - { url, key }, + { url, key, nativeWindow }, ); await page.waitForTimeout(300); results.push({ key, + nativeWindow, + // The fixture is authored above: prove delivery, not just a missing modal. + delivery: await app.evaluate(async ({ webContents }, fixtureUrl) => { + const wc = webContents.getAllWebContents().find((w) => w.getURL() === fixtureUrl); + return wc.executeJavaScript( + "({keys:window.fixtureKeys,active:document.activeElement.tagName})", + ); + }, url), dialogs: await page.getByRole("dialog").count(), focus: await app.evaluate(({ webContents }) => { const w = webContents.getFocusedWebContents(); @@ -67,6 +87,6 @@ try { await fs.writeFile(join(out, "report.json"), JSON.stringify(results, null, 2)); console.log(JSON.stringify(results, null, 2)); } finally { - await app.close(); + await closeAppBounded(app); await new Promise((r) => server.close(r)); } diff --git a/apps/desktop/src/renderer/src/components/ticket/ticket-title.test.tsx b/apps/desktop/src/renderer/src/components/ticket/ticket-title.test.tsx index 40d0c8daa..8090588eb 100644 --- a/apps/desktop/src/renderer/src/components/ticket/ticket-title.test.tsx +++ b/apps/desktop/src/renderer/src/components/ticket/ticket-title.test.tsx @@ -34,7 +34,8 @@ describe("TicketTitle", () => { expect(html).toContain('role="button"'); expect(html).toContain("focus-visible:ring-2"); - expect(html).toContain("focus-visible:ring-ring/45"); + expect(html).toContain("focus-visible:ring-ring"); + expect(html).not.toContain("focus-visible:ring-ring/"); expect(html).toContain("focus-visible:outline-none"); }); }); diff --git a/apps/desktop/src/renderer/src/components/ticket/ticket-title.tsx b/apps/desktop/src/renderer/src/components/ticket/ticket-title.tsx index b504b1177..441dc4f31 100644 --- a/apps/desktop/src/renderer/src/components/ticket/ticket-title.tsx +++ b/apps/desktop/src/renderer/src/components/ticket/ticket-title.tsx @@ -54,7 +54,7 @@ export function TicketTitle({ ticket }: { ticket: Ticket }) { setEditing(true); } }} - className="cursor-text text-title font-semibold text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/45 focus-visible:outline-none" + className="cursor-text text-title font-semibold text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none" > {ticket.title} diff --git a/apps/desktop/src/renderer/src/hooks/use-dialog-focus-return.node.test.tsx b/apps/desktop/src/renderer/src/hooks/use-dialog-focus-return.node.test.tsx new file mode 100644 index 000000000..886b2c8fe --- /dev/null +++ b/apps/desktop/src/renderer/src/hooks/use-dialog-focus-return.node.test.tsx @@ -0,0 +1,22 @@ +// @vitest-environment node +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { useDialogFocusReturn } from "./use-dialog-focus-return"; + +function Fixture({ open }: { open: boolean }) { + useDialogFocusReturn(open); + return {open ? "open" : "closed"}; +} + +describe("dialog focus return without a DOM", () => { + for (const open of [false, true]) { + it(`renders with open=${open} without reading browser globals`, () => { + expect(typeof document).toBe("undefined"); + expect(typeof HTMLElement).toBe("undefined"); + expect(renderToStaticMarkup()).toBe( + `${open ? "open" : "closed"}`, + ); + }); + } +}); diff --git a/apps/desktop/src/renderer/src/hooks/use-dialog-focus-return.ts b/apps/desktop/src/renderer/src/hooks/use-dialog-focus-return.ts index 0e57cf258..f4bfdddb9 100644 --- a/apps/desktop/src/renderer/src/hooks/use-dialog-focus-return.ts +++ b/apps/desktop/src/renderer/src/hooks/use-dialog-focus-return.ts @@ -6,7 +6,10 @@ import * as React from "react"; */ export function useDialogFocusReturn(open: boolean) { const candidate = React.useMemo(() => { - const active = open ? document.activeElement : null; + // The chrome is also rendered by Node-only static-markup tests. A closed + // palette needs no invoker, and server rendering cannot supply one. + if (!open || typeof document === "undefined") return null; + const active = document.activeElement; return active instanceof HTMLElement ? active : null; }, [open]); const invoker = React.useRef(null); From fc00c628c9a26646db0ad0327a10f691fdf7ac5b Mon Sep 17 00:00:00 2001 From: Hussain Phalasiya Date: Fri, 18 Sep 2026 23:45:28 +0300 Subject: [PATCH 4/5] test(a11y): verify integrated focus gates and record release blockers --- .../desktop/e2e/vc322-accessibility-smoke.mjs | 7 +- .../src/components/ui/dialog-focus.test.tsx | 15 +++ docs/vc322-accessibility-release-pass.md | 4 + docs/vc322-integration-results.md | 122 ++++++++++++++++++ 4 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 docs/vc322-integration-results.md diff --git a/apps/desktop/e2e/vc322-accessibility-smoke.mjs b/apps/desktop/e2e/vc322-accessibility-smoke.mjs index bd4912e41..d01289107 100644 --- a/apps/desktop/e2e/vc322-accessibility-smoke.mjs +++ b/apps/desktop/e2e/vc322-accessibility-smoke.mjs @@ -169,6 +169,9 @@ try { await page.getByRole("tablist", { name: "Ticket tabs" }).waitFor(); await page.screenshot({ path: join(output, "ticket.png") }); await fs.writeFile(join(output, "ticket-ax.txt"), await page.locator("body").ariaSnapshot()); + await page.locator('h1[role="button"]').focus(); + await page.waitForTimeout(200); + report.observations.push({ name: "ticket title focus", state: await active() }); await tabTour("ticket body comments and rail", 12); await page.keyboard.press("Control+Shift+M"); await page.keyboard.press("Tab"); @@ -383,9 +386,7 @@ try { // Only opt-in fix regression checks determine this probe's process exit code. if (gateFixes) { const dialogs = report.checks.slice(0, 2); - const title = report.observations - .find((item) => item.name === "ticket body comments and rail") - ?.steps.find((item) => item.tag === "H1"); + const title = report.observations.find((item) => item.name === "ticket title focus")?.state; if ( dialogs.length !== 2 || dialogs.some((item) => !item.pass) || diff --git a/apps/desktop/src/renderer/src/components/ui/dialog-focus.test.tsx b/apps/desktop/src/renderer/src/components/ui/dialog-focus.test.tsx index 4ad8539e5..9e80d8fe8 100644 --- a/apps/desktop/src/renderer/src/components/ui/dialog-focus.test.tsx +++ b/apps/desktop/src/renderer/src/components/ui/dialog-focus.test.tsx @@ -88,6 +88,21 @@ for (const trigger of [false, true]) await settle(); expect(document.activeElement).toBe(invoker); }); +it("leaves default close focus alone when no HTML invoker exists", async () => { + const close = new Event("closeAutoFocus", { cancelable: true }); + function NoInvoker() { + const { restoreFocus } = useDialogFocusReturn(true); + React.useLayoutEffect(() => restoreFocus(close), [restoreFocus]); + return null; + } + const active = vi.spyOn(document, "activeElement", "get").mockReturnValue(null); + try { + await act(async () => root.render()); + expect(close.defaultPrevented).toBe(false); + } finally { + active.mockRestore(); + } +}); it("honours a caller's explicit focus handoff", async () => { await act(async () => root.render()); await act(async () => { diff --git a/docs/vc322-accessibility-release-pass.md b/docs/vc322-accessibility-release-pass.md index 25072e3d5..6575b6790 100644 --- a/docs/vc322-accessibility-release-pass.md +++ b/docs/vc322-accessibility-release-pass.md @@ -1,5 +1,9 @@ # VC-322 — accessibility/motion candidate checklist +> **Historical baseline:** merged terminal, contrast and navigation fixes supersede +> several failures below. See [the integrated release results](vc322-integration-results.md) +> for the latest evidence and outstanding gates. The human checklist below still applies. + **Release verdict: BLOCKED, not accessibility sign-off.** Focused fixes are ready for review; remaining failures and unmeasured native behavior must not be counted as passes. Merge order requested by release coordination: **VC-343 → VC-344 → VC-322**. diff --git a/docs/vc322-integration-results.md b/docs/vc322-integration-results.md new file mode 100644 index 000000000..5e2b93b77 --- /dev/null +++ b/docs/vc322-integration-results.md @@ -0,0 +1,122 @@ +# VC-322 — integrated release pass + +**Verdict: BLOCKED for accessibility sign-off. PR #579 remains draft; do not merge.** +This supersedes the original baseline verdicts in +[`vc322-accessibility-release-pass.md`](vc322-accessibility-release-pass.md). + +## CI failure and focused fix + +PR #579's original Check + Test job failed one test, `chrome-bar.test.tsx`: +`ReferenceError: HTMLElement is not defined` in `use-dialog-focus-return.ts`. +The palette renders closed in a Node-only static-markup test; `null instanceof +HTMLElement` still resolves the absent browser constructor. + +Added a Node-environment regression fixture for **both open and closed** hook +renders. Both failed before the fix. The hook now returns no invoker when closed +or when `document` is absent, without changing browser focus restoration. +The ticket-title ring also adopts VC-418's opaque semantic ring instead of the +old 45% ring. Terminal source/shortcuts and navigation-focus code stay owned by +VC-344 and VC-419. + +## Integrated built evidence + +First integration base: `origin/main` **31002471**, including VC-343, VC-344 and +VC-418. These runs used merged commit `095f4730` plus the focus-hook/title changes +subsequently committed as `f8b1926e`. The probe records the dirty state explicitly. +The desktop pipeline was rebuilt before the accepted measurements. + +| Gate | Result | Evidence | +| --- | --- | --- | +| New-ticket Escape + focus return | Pass | Real built Electron; returns to New ticket button after trap teardown. | +| Command palette Escape + focus return | Pass | Actual pre-open control restored. | +| Ticket-title keyboard focus | Pass | Computed 2px opaque semantic ring. | +| Terminal keyboard + Chromium/native macOS AX | **35/35 pass** | PID-scoped native AX trusted and exposes parsed PTY markers in named terminal regions, before/after focus, focus mode, hide/show and both split panes. | +| Terminal default PTY behavior | Pass | Tab/Shift+Tab remain PTY input by design. Control+Shift+M enables Tab/Shift+Tab focus navigation; navigation emits no PTY bytes. | +| Default primary-control contrast | Pass | Enabled New ticket rest/hover text **4.5056:1** in Light and Dark; focused ring/offset **5.0797:1 Light**, **8.7074:1 Dark**. | +| Dialog reduced-motion entrance | Pass, limited | Modal entrance animation becomes `none`. Not an all-motion sign-off. | +| Native browser → app keyboard boundary | **Fail — VC-301** | Focused Browser WebContents URL is the local fixture; fixture logs Meta+K delivered to INPUT; app dialogs remain 0. Escape stays in page; Shift+Tab traverses the page. | +| Product zoom 200% | **Unavailable** | App zoom ladder caps at 150%. BrowserWindow zoom injection is not a product acceptance pass. | +| Spoken VoiceOver and interactive reading | **Not performed** | Native AX exposure is not speech, reading-order, rotor, or scrollback-navigation acceptance. | + +Raw workspace-local evidence: + +- `evidence/vc322/integration/terminal-rebuilt/record.json` and six `mac-ax-*.json` dumps. +- `evidence/vc322/integration/candidate/report.json` (focus, motion, zoom, terminal). +- `evidence/vc418/post-Z9wlZ2/report.json` (composited contrast and focus screenshots). +- `evidence/vc322/integration/browser-native/report.json` (native focus plus delivered keys). + +An initial delegated terminal run in `integration/terminal/` is **invalid as +integration evidence**: it started at 20:21:39Z, while the new renderer was not +built until 20:23:52Z. It measured stale assets. It was not used to file a new +terminal regression. The post-build `terminal-rebuilt/` run is the accepted one. + +## Latest-main integration and quality gates + +Integrated `origin/main` **69855470** as **a4181520**, including VC-419 (#585) +and VC-413 (#581). Rebuilt the desktop pipeline successfully. The built VC-419 +navigation probe passes **9/9**: board/list entry and return, back shortcut, +filtered origin, filtered-out neighbour, moved-column origin and virtualized +origin restoration. Evidence: `integration/latest-navigation/report.json`. +The general candidate probe also now reports board/list return focus passing; +all eight reported checks pass (three are injected BrowserWindow zoom geometry, +not product zoom acceptance). Its title-ring gate passes after sampling the +heading explicitly rather than relying on the old tab-order position. + +On this exact build, terminal keyboard/native AX repeats **35/35 passing**, and +Light/Dark contrast repeats passing with the ratios above. Accepted latest +artifacts are `integration/latest-candidate/report.json`, +`integration/latest-terminal/record.json`, and +`evidence/vc418/post-DAxYOJ/report.json`. Probe/document changes are recorded as +dirty; the app source was clean at a4181520. None of these replaces VoiceOver +speech acceptance. The native-window Browser probe repeats VC-301's failure: +`integration/latest-browser-native/report.json` again records Meta+K reaching +the page INPUT, focused fixture WebContents and zero app dialogs. + +- Focused lifecycle/Node SSR/title/ChromeBar tests: **10/10 pass**, hook-only + coverage **100% statements, branches, functions and lines**. The first focused + run exposed an untested null-invoker branch (92.3% branches); a regression test + now verifies that no-HTML-invoker leaves default close focus untouched. + Log: `integration/latest-focused-complete.log`. This scoped coverage command + is not a substitute for the full protected coverage gate. +- `pnpm exec vp check` and `pnpm typecheck`: **pass** after integration. +- All eleven current CI design/compliance commands: **pass** (workspace licenses, + excluded dependencies, notices, theme CSS, design tokens, Node version, + vendored themes, dependency licenses, notice inputs, LGPL source, library + validation). Logs: `integration/latest-*.log`. +- Website and docs builds including font/license checks: **pass** on f8b1926e. +- The original SSR exception is gone in CI. The f8b1926e run **35391689247** + passed boot and all three smoke shards, but Check + Test **failed** seven + five-second timeouts in `agent-dispatch.test.ts` and `db/migrations.test.ts`. + No timeout or coverage threshold was relaxed. Latest integration CI must + independently pass; this earlier run is not green. +- Full local recursive coverage with workspace-contained `TMPDIR`: **failed**, + three packages failing / six passing. Agent-runtime: seven sidecar migration + failures (**VC-420**, fixture encoding disagrees with Pi on `.volli` paths). + CLI: eleven Unix-socket failures. Desktop: 23 failed / 10,130 passed / two + skipped, plus two unhandled errors. Failures include overlong Unix socket + paths, filesystem/path assertions, git directory enumeration, and one worker + startup timeout. Full details: `integration/coverage-all.log`. These failures + are retained, not excluded or claimed as accessibility regressions. +- The first compliance run found stale `apca-w3` / `colorparsley` directories + in reused generated dependencies. `pnpm prune --ignore-scripts --yes` removed + them; all compliance gates above then passed without policy exceptions. + +## Human / signed candidate gates still required + +Use the detailed human checklist in the baseline report. Record candidate SHA, +macOS version, appearance, zoom, operator and a pass/fail per surface: + +- VoiceOver speech/rotor/reading order across project navigation, board/list, + ticket body/comments and file/diff editors. +- Terminal output reading and scrollback traversal in both split panes; + discovery/announcement of Control+Shift+M, hide/show and focus return. +- Pending questions/permission cards, approve/deny/cancel/retry, asynchronous + completion/error announcements and draft preservation. +- All dialogs/nested menus/destructive confirmations and focus return; native + browser escape to app navigation (VC-301). +- Narrow panes and the complete 125/150/200% matrix, keyboard resizing and + selection retention; all non-essential motion under Reduce Motion. + +These are not signed off by source inspection, jsdom tests, or AX dumps. +No real user profile, provider credential, clipboard or global VoiceOver setting +was used or modified by these probes. From 17f85997572d0b604b7815de51f2ec34f0dda3d0 Mon Sep 17 00:00:00 2001 From: Hussain Phalasiya Date: Tue, 20 Oct 2026 13:12:49 +0300 Subject: [PATCH 5/5] fix(a11y): gate focus regressions and remove internal release notes --- .../desktop/e2e/vc322-accessibility-smoke.mjs | 10 +- .../src/components/command-palette.tsx | 6 +- docs/vc322-accessibility-release-pass.md | 135 ------------------ docs/vc322-integration-results.md | 122 ---------------- 4 files changed, 11 insertions(+), 262 deletions(-) delete mode 100644 docs/vc322-accessibility-release-pass.md delete mode 100644 docs/vc322-integration-results.md diff --git a/apps/desktop/e2e/vc322-accessibility-smoke.mjs b/apps/desktop/e2e/vc322-accessibility-smoke.mjs index d01289107..cfeb1b9a0 100644 --- a/apps/desktop/e2e/vc322-accessibility-smoke.mjs +++ b/apps/desktop/e2e/vc322-accessibility-smoke.mjs @@ -1,5 +1,7 @@ -/** VC-322 built-app observational release probe; artifacts stay in the workspace. +/** Built-app dialog focus and title-ring regression probe. * Run after pnpm build: node apps/desktop/e2e/vc322-accessibility-smoke.mjs + * Broader accessibility measurements are observational, not release sign-off. + * --observe disables the focus/title gate for exploratory measurements. * No provider calls, credentials, live profile, clipboard, or VoiceOver automation. */ import { promises as fs } from "node:fs"; @@ -17,7 +19,7 @@ import { const output = resolve( process.argv.slice(2).find((arg) => !arg.startsWith("--")) ?? "evidence/vc322", ); -const gateFixes = process.argv.includes("--gate-fixes"); +const gateFixes = !process.argv.includes("--observe"); await fs.mkdir(output, { recursive: true }); const scratch = await fs.mkdtemp(join(output, "run-")); const userDataDir = join(scratch, "user-data"); @@ -382,8 +384,8 @@ try { await fs.writeFile(join(output, "report.json"), JSON.stringify(report, null, 2)); await closeAppBounded(app); } -// The broader release findings are observational and deliberately remain red. -// Only opt-in fix regression checks determine this probe's process exit code. +// The broader accessibility measurements remain observational. The CI runner +// invokes probes without flags, so focus/title regressions must fail by default. if (gateFixes) { const dialogs = report.checks.slice(0, 2); const title = report.observations.find((item) => item.name === "ticket title focus")?.state; diff --git a/apps/desktop/src/renderer/src/components/command-palette.tsx b/apps/desktop/src/renderer/src/components/command-palette.tsx index d0e16ccd7..e92d21445 100644 --- a/apps/desktop/src/renderer/src/components/command-palette.tsx +++ b/apps/desktop/src/renderer/src/components/command-palette.tsx @@ -105,7 +105,11 @@ function PaletteDialog({ Search tickets, sessions, and commands - + {children} diff --git a/docs/vc322-accessibility-release-pass.md b/docs/vc322-accessibility-release-pass.md deleted file mode 100644 index 6575b6790..000000000 --- a/docs/vc322-accessibility-release-pass.md +++ /dev/null @@ -1,135 +0,0 @@ -# VC-322 — accessibility/motion candidate checklist - -> **Historical baseline:** merged terminal, contrast and navigation fixes supersede -> several failures below. See [the integrated release results](vc322-integration-results.md) -> for the latest evidence and outstanding gates. The human checklist below still applies. - -**Release verdict: BLOCKED, not accessibility sign-off.** Focused fixes are ready for review; -remaining failures and unmeasured native behavior must not be counted as passes. -Merge order requested by release coordination: **VC-343 → VC-344 → VC-322**. -This patch does not change terminal shortcuts or terminal source. - -## Candidate and method - -- Baseline: fetched `origin/main` **3bf58945273ea876fac744f2f50483eec69ab378**, canary.11. -- Re-synced with `origin/main` **10e8ada5** (VC-412 included), then installed and rebuilt. - Clean candidate **3497b6786f3c9fed770a5159862c84732a139405** passed the built - `--gate-fixes` rerun; `evidence/vc322/synced/report.json` retains the same remaining - board/list/terminal failures and 3.27:1 primary contrast. VC-343/344 are not integrated yet. -- `pnpm install` (including native rebuild) and `pnpm run build` passed. -- Real Electron main/preload and `volli-app://bundle/index.html`, not the UI lab. - Disposable database, HOME, project repository, and profile live inside this worktree's - ignored `evidence/` directory. Profile and built-renderer identity asserted at runtime. -- Playwright keyboard events, DOM focus/computed styles, accessibility snapshots, screenshots, - actual native-theme changes, and persisted app zoom commands. These are **not VoiceOver speech**. -- Baseline: `evidence/vc322/baseline/`; fixed build: `evidence/vc322/candidate/`. - Reports contain exact focus paths, styles, animation keyframes, and dialog geometry. - The initial exploratory `report.json` had an incorrect board locator and sampled focus - transitions too early; use `baseline/report.json`, not that initial run. - -## Per-surface checklist - -| Surface/check | Result | Built evidence / remaining gate | -| --- | --- | --- | -| Project navigation/chrome | Partial | Tab reaches project tile, Add Project, Home, Automations, Configure, Settings and chrome controls; settled computed focus rings recorded. Full project switching and native folder picker/VoiceOver not tested. | -| Board/list | **Fail — VC-419** | Enter opens the selected ticket; Escape from its tab returns to board/list. Both entry and return lose focus to BODY, not ticket/selected row. | -| Ticket title | **Fixed** | Baseline H1 button had no outline/shadow under `:focus-visible`; candidate has a 2px semantic ring. | -| Ticket body | Partial | Monaco exposes `Ticket description`; Tab inserts indentation. **Control+Shift+M, then Tab** successfully reaches Add a comment. Escape inside Monaco is editor-owned, not a close failure. Human discoverability/VoiceOver still required. | -| Comments | Partial | Add a comment textarea reachable using Monaco's tab-focus toggle. Submission, async completion announcements, error recovery and edit/delete confirmations not exercised. No claim of full focus-visibility compliance. | -| New-ticket dialog | **Fixed; tested pass** | Keyboard Enter opens; focus stays inside across sampled Tab path; Escape from title closes and restores the actual New ticket invoker. Baseline returned BODY. | -| Command palette | **Fixed; tested pass** | Meta+K → input → Escape restores actual pre-open control. Explicit navigation selections skip restoration. | -| Other overlays/dialogs | **Not verified** | Destructive confirms, permission overlays, nested menus and all focus-return paths require candidate sweep. Shared Radix behavior is not substituted for evidence. | -| Pending questions / permission cards | **Not verified** | No live provider turn or credentials used. Need seeded/signed candidate with pending ask/approve/deny/cancel/retry states at 320/480/720px and each zoom. | -| Files | **Incomplete — VC-311** | Existing built smoke: listing and dirty Cancel/Save guard pass; multiple row click timeouts cascade into preview/save/restore failures; refresh also times out. Requires independent triage, not assumed product failure. | -| Diffs | Partial — VC-311 | 7/8 numbered smoke checks pass: real Monaco diff, keyboard edit/save to disk, shared file/diff model and presentation toggle. Untracked-file refresh check fails. Full keyboard/VoiceOver/narrow matrix not proven. | -| Embedded browser | **Incomplete — VC-301** | Existing browser smoke 10/10 passes isolation, tab lifecycle and hold behavior, not app-shortcut routing. Separate synthetic Meta+K injection opens no palette, but quiet-window native focused WebContents is null: **inconclusive native-focus evidence**, retain human check. | -| Terminal | **Fail baseline — VC-344** | Current xterm input is named `Terminal input`. Tab, Shift+Tab, Escape and two Meta+Alt+Enter presses all retain that textarea. Host role/name null; no xterm accessibility layer. No native VoiceOver claim. Re-run after VC-344; this is not a verdict on its pending fix. | -| Pane resizing / async feedback | **Not verified** | Keyboard divider interaction, selected row retention after refresh, completion announcements and retry focus remain manual/fixture gates. | - -## Default-canvas contrast - -WCAG 2.x relative luminance, using computed RGB in the built renderer (no custom canvas): - -| Pair | Dark | Light | Result | -| --- | ---: | ---: | --- | -| foreground/background | 14.46:1 | 15.51:1 | Pass normal text | -| muted-foreground/card | 9.02:1 | 6.63:1 | Pass normal text | -| primary-text/background | 8.71:1 | 5.08:1 | Pass normal text | -| white primary label / #d37550 | **3.27:1** | **3.27:1** | **Fail 4.5:1 at 13px — VC-418** | -| opaque ring/background | 5.59:1 | **2.57:1** | Light below 3:1; actual 45% rings need composite measurement — VC-418 | - -New ticket is an enabled primary control using the failing text pair. Disabled Create & start -is not used to establish this finding. This table is a sampled semantic-token check, not an -all-controls contrast audit or a verdict on user-selected colors. - -## Motion and zoom - -- **Pass, dialog entrance only:** default modal/scrim have 200ms entrance animations; - with `prefers-reduced-motion: reduce`, dialog animation computes to `none` and those - entrance animations are absent. Preference changes can produce `scale: none → 1` - transitions on controls (identical visual scale), not evidence of unwanted movement. -- No blanket pass for drag, split, streaming, scroll anchoring, exit interruption or delayed - async state. These were not comprehensively exercised. -- Shipped app zoom commands persist **125% → 150% → 150%**: **200% is unavailable**. - Recorded on VC-288. Native CSS zoom screenshots retained for 125/150; full per-pane - functionality at those settings remains unverified. -- Exploratory BrowserWindow zoom at 125/150/200 gives in-viewport dialog DOM bounds. - **This is not a product 200% pass.** Playwright zoom screenshots can crop differently - from measured viewport geometry; do not use those captures as proof of visual fit. - -## Focused patch and checks - -- New-ticket and palette capture the focused invoker during the opening render, **before** - autofocus children take it. Restore using Radix's close-focus lifecycle, after trap teardown. - Do not restore a removed invoker or override explicit caller handoff/navigation. -- Palette keeps its original chrome/command contents, exposing the Radix lifecycle instead - of cmdk's convenience Dialog. No shared-dialog global policy change. -- Ticket title gains a keyboard-only semantic focus ring. -- Focused unit run: **3 files / 7 tests passed** (dialog focus lifecycle, title, palette order). -- Focus-hook coverage: **100% statements/branches/functions/lines**, 5 tests passed. -- Renderer TypeScript, changed-file lint and whitespace checks passed after sync. -- An additional combined composer-form/palette-model/palette-search test invocation timed out - at 45s before producing test results; it is not counted as passed. Full monorepo coverage/CI - and signed candidate not claimed. -- Draft PR: https://github.com/hussainph/volli-code/pull/579. CI was pending when submitted; - keep merge held for the terminal integration order and rerun the integrated candidate. - -Reproduce (workspace-local artifacts): - -```sh -pnpm install -pnpm run build -node apps/desktop/e2e/vc322-accessibility-smoke.mjs evidence/vc322/recheck --gate-fixes -node apps/desktop/e2e/vc322-browser-boundary.mjs -pnpm -C apps/desktop exec vp test run \ - src/renderer/src/components/ui/dialog-focus.test.tsx \ - src/renderer/src/components/ticket/ticket-title.test.tsx \ - src/renderer/src/components/command-palette-order.test.tsx \ - --maxWorkers=$VOLLI_CONCURRENCY_HINT -pnpm exec tsc --noEmit -p apps/desktop/tsconfig.web.json --composite false -``` - -The accessibility probe is observational by default. `--gate-fixes` fails on regression of -these two dialogs or the title ring; it deliberately does **not** turn the other known release -failures into a passing gate. The JSON checklist remains the broader verdict. - -## Human / signed-candidate handoff (all unchecked) - -Record candidate SHA/signature, macOS version, appearance, zoom, person, result and issue per row: - -- [ ] VoiceOver: project tile names, board/list ticket names/status/selection; open and return. -- [ ] Ticket title/body/comment editing, Monaco Control+Shift+M discovery, reading order, - persisted submission, error announcement and draft preservation. -- [ ] Pending question and permission cards: question/options announced, keyboard answer, - approve/deny/cancel/retry; no clipped action at 320/480/720px, 125/150/200%. -- [ ] Every modal/menu/confirm: initial focus, trapped Tab/Shift+Tab, nested Escape, - close/cancel/submit and invoker return; no focus behind native browser view. -- [ ] Browser page input → app search/navigation/close/return, correct pane, page edit keys intact. -- [ ] VC-344 integrated terminal: read output before/after split/hide/show; advertised escape - reaches app controls, Tab reaches terminal again, PTY keys preserved. -- [ ] File tree/diff names, selected line/change, editor escape, dirty-save guard, inline fallback. -- [ ] Light/Dark focus visibility and contrast after VC-418; reduced-motion launches and live - preference changes, resize, streaming, completion and recoverable failures. - -A person must execute these checks; installing a signed candidate or toggling VoiceOver on the -shared machine was not performed by this pass. diff --git a/docs/vc322-integration-results.md b/docs/vc322-integration-results.md deleted file mode 100644 index 5e2b93b77..000000000 --- a/docs/vc322-integration-results.md +++ /dev/null @@ -1,122 +0,0 @@ -# VC-322 — integrated release pass - -**Verdict: BLOCKED for accessibility sign-off. PR #579 remains draft; do not merge.** -This supersedes the original baseline verdicts in -[`vc322-accessibility-release-pass.md`](vc322-accessibility-release-pass.md). - -## CI failure and focused fix - -PR #579's original Check + Test job failed one test, `chrome-bar.test.tsx`: -`ReferenceError: HTMLElement is not defined` in `use-dialog-focus-return.ts`. -The palette renders closed in a Node-only static-markup test; `null instanceof -HTMLElement` still resolves the absent browser constructor. - -Added a Node-environment regression fixture for **both open and closed** hook -renders. Both failed before the fix. The hook now returns no invoker when closed -or when `document` is absent, without changing browser focus restoration. -The ticket-title ring also adopts VC-418's opaque semantic ring instead of the -old 45% ring. Terminal source/shortcuts and navigation-focus code stay owned by -VC-344 and VC-419. - -## Integrated built evidence - -First integration base: `origin/main` **31002471**, including VC-343, VC-344 and -VC-418. These runs used merged commit `095f4730` plus the focus-hook/title changes -subsequently committed as `f8b1926e`. The probe records the dirty state explicitly. -The desktop pipeline was rebuilt before the accepted measurements. - -| Gate | Result | Evidence | -| --- | --- | --- | -| New-ticket Escape + focus return | Pass | Real built Electron; returns to New ticket button after trap teardown. | -| Command palette Escape + focus return | Pass | Actual pre-open control restored. | -| Ticket-title keyboard focus | Pass | Computed 2px opaque semantic ring. | -| Terminal keyboard + Chromium/native macOS AX | **35/35 pass** | PID-scoped native AX trusted and exposes parsed PTY markers in named terminal regions, before/after focus, focus mode, hide/show and both split panes. | -| Terminal default PTY behavior | Pass | Tab/Shift+Tab remain PTY input by design. Control+Shift+M enables Tab/Shift+Tab focus navigation; navigation emits no PTY bytes. | -| Default primary-control contrast | Pass | Enabled New ticket rest/hover text **4.5056:1** in Light and Dark; focused ring/offset **5.0797:1 Light**, **8.7074:1 Dark**. | -| Dialog reduced-motion entrance | Pass, limited | Modal entrance animation becomes `none`. Not an all-motion sign-off. | -| Native browser → app keyboard boundary | **Fail — VC-301** | Focused Browser WebContents URL is the local fixture; fixture logs Meta+K delivered to INPUT; app dialogs remain 0. Escape stays in page; Shift+Tab traverses the page. | -| Product zoom 200% | **Unavailable** | App zoom ladder caps at 150%. BrowserWindow zoom injection is not a product acceptance pass. | -| Spoken VoiceOver and interactive reading | **Not performed** | Native AX exposure is not speech, reading-order, rotor, or scrollback-navigation acceptance. | - -Raw workspace-local evidence: - -- `evidence/vc322/integration/terminal-rebuilt/record.json` and six `mac-ax-*.json` dumps. -- `evidence/vc322/integration/candidate/report.json` (focus, motion, zoom, terminal). -- `evidence/vc418/post-Z9wlZ2/report.json` (composited contrast and focus screenshots). -- `evidence/vc322/integration/browser-native/report.json` (native focus plus delivered keys). - -An initial delegated terminal run in `integration/terminal/` is **invalid as -integration evidence**: it started at 20:21:39Z, while the new renderer was not -built until 20:23:52Z. It measured stale assets. It was not used to file a new -terminal regression. The post-build `terminal-rebuilt/` run is the accepted one. - -## Latest-main integration and quality gates - -Integrated `origin/main` **69855470** as **a4181520**, including VC-419 (#585) -and VC-413 (#581). Rebuilt the desktop pipeline successfully. The built VC-419 -navigation probe passes **9/9**: board/list entry and return, back shortcut, -filtered origin, filtered-out neighbour, moved-column origin and virtualized -origin restoration. Evidence: `integration/latest-navigation/report.json`. -The general candidate probe also now reports board/list return focus passing; -all eight reported checks pass (three are injected BrowserWindow zoom geometry, -not product zoom acceptance). Its title-ring gate passes after sampling the -heading explicitly rather than relying on the old tab-order position. - -On this exact build, terminal keyboard/native AX repeats **35/35 passing**, and -Light/Dark contrast repeats passing with the ratios above. Accepted latest -artifacts are `integration/latest-candidate/report.json`, -`integration/latest-terminal/record.json`, and -`evidence/vc418/post-DAxYOJ/report.json`. Probe/document changes are recorded as -dirty; the app source was clean at a4181520. None of these replaces VoiceOver -speech acceptance. The native-window Browser probe repeats VC-301's failure: -`integration/latest-browser-native/report.json` again records Meta+K reaching -the page INPUT, focused fixture WebContents and zero app dialogs. - -- Focused lifecycle/Node SSR/title/ChromeBar tests: **10/10 pass**, hook-only - coverage **100% statements, branches, functions and lines**. The first focused - run exposed an untested null-invoker branch (92.3% branches); a regression test - now verifies that no-HTML-invoker leaves default close focus untouched. - Log: `integration/latest-focused-complete.log`. This scoped coverage command - is not a substitute for the full protected coverage gate. -- `pnpm exec vp check` and `pnpm typecheck`: **pass** after integration. -- All eleven current CI design/compliance commands: **pass** (workspace licenses, - excluded dependencies, notices, theme CSS, design tokens, Node version, - vendored themes, dependency licenses, notice inputs, LGPL source, library - validation). Logs: `integration/latest-*.log`. -- Website and docs builds including font/license checks: **pass** on f8b1926e. -- The original SSR exception is gone in CI. The f8b1926e run **35391689247** - passed boot and all three smoke shards, but Check + Test **failed** seven - five-second timeouts in `agent-dispatch.test.ts` and `db/migrations.test.ts`. - No timeout or coverage threshold was relaxed. Latest integration CI must - independently pass; this earlier run is not green. -- Full local recursive coverage with workspace-contained `TMPDIR`: **failed**, - three packages failing / six passing. Agent-runtime: seven sidecar migration - failures (**VC-420**, fixture encoding disagrees with Pi on `.volli` paths). - CLI: eleven Unix-socket failures. Desktop: 23 failed / 10,130 passed / two - skipped, plus two unhandled errors. Failures include overlong Unix socket - paths, filesystem/path assertions, git directory enumeration, and one worker - startup timeout. Full details: `integration/coverage-all.log`. These failures - are retained, not excluded or claimed as accessibility regressions. -- The first compliance run found stale `apca-w3` / `colorparsley` directories - in reused generated dependencies. `pnpm prune --ignore-scripts --yes` removed - them; all compliance gates above then passed without policy exceptions. - -## Human / signed candidate gates still required - -Use the detailed human checklist in the baseline report. Record candidate SHA, -macOS version, appearance, zoom, operator and a pass/fail per surface: - -- VoiceOver speech/rotor/reading order across project navigation, board/list, - ticket body/comments and file/diff editors. -- Terminal output reading and scrollback traversal in both split panes; - discovery/announcement of Control+Shift+M, hide/show and focus return. -- Pending questions/permission cards, approve/deny/cancel/retry, asynchronous - completion/error announcements and draft preservation. -- All dialogs/nested menus/destructive confirmations and focus return; native - browser escape to app navigation (VC-301). -- Narrow panes and the complete 125/150/200% matrix, keyboard resizing and - selection retention; all non-essential motion under Reduce Motion. - -These are not signed off by source inspection, jsdom tests, or AX dumps. -No real user profile, provider credential, clipboard or global VoiceOver setting -was used or modified by these probes.