From 6429340ff54f4115a48c5a8afdc4020b5487e78a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 00:27:13 +0000 Subject: [PATCH 01/20] Phase 2 step 6: fail-first accessibility tests for the Astryx block surfaces Tests only; the fixes follow. Target: WCAG 2.2 AA on the Astryx beta path. - e2e/composer-blocks-a11y.spec.ts (experimental config): builds records-table, auth-form and guide-steps on Astryx through the hosted path with a stubbed gateway (no model call), for each block's approved example and three boundary calls (smallest valid call, every optional prop at its maximum length and count, and the same with unbroken tokens). It checks axe with wcag2a, wcag2aa, wcag21a, wcag21aa and wcag22aa on the canvas with no exclusion; the title as the surface's one level-2 heading; table regions named by their columns; Tab and Shift+Tab order with no trap; a focus indicator of 3:1 that is never entirely hidden (SC 1.4.11, 2.4.11); 24 px targets; text spacing; 200% zoom (640 px) and 320 px reflow, a table scrolling only inside its own region; and an accepted block in Preview. Checks are soft, so one run reports every defect. shadcn/ui v3 scans run for information only and never fail. - apps/composer/app/astryx-renderers-a11y.test.ts: server-rendered checks of the shared Astryx renderers (headings, table names, header and badge wrapping, long-word wrapping). Against main (d7251db): the renderer tests fail 8 of 10 (the two that pass guard what must not change); the e2e fails all 13 Astryx tests (no heading; tables named only "Table"; the studio's green focus ring at 2.41:1 on the canvas white; header cells cut off; long text and the auth form overflowing at 320 px; the page 11 px too wide at 320 px; focused controls entirely hidden under the sticky header at 640 and 320 px). The 6 shadcn/ui v3 scans pass, as information. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R6Ax8qvfN3gYUC85oSGnoi --- .../app/astryx-renderers-a11y.test.ts | 98 ++++ e2e/composer-blocks-a11y.spec.ts | 463 ++++++++++++++++++ playwright.composer-experimental.config.ts | 2 +- 3 files changed, 562 insertions(+), 1 deletion(-) create mode 100644 apps/composer/app/astryx-renderers-a11y.test.ts create mode 100644 e2e/composer-blocks-a11y.spec.ts diff --git a/apps/composer/app/astryx-renderers-a11y.test.ts b/apps/composer/app/astryx-renderers-a11y.test.ts new file mode 100644 index 0000000..fea5353 --- /dev/null +++ b/apps/composer/app/astryx-renderers-a11y.test.ts @@ -0,0 +1,98 @@ +import { createElement, type FC, type ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { astryxRegistry } from "@dspack-studio/astryx-renderers"; + +/** + * The Astryx preview renderers' semantics on the beta path (Phase 2 step 6, + * docs/blocks/ACCESSIBILITY.md): a heading variant is a heading, a table's + * scroll region is named by its columns, header text wraps instead of being + * cut off, and a long word or label wraps instead of overflowing. These + * renderers are shared with the default build. Server-rendered, so no browser + * is needed; e2e/composer-blocks-a11y.spec.ts checks the rendered blocks. + */ +const custom = astryxRegistry.custom as Record>; +const render = (name: string, props: Record, children: Record = {}) => + renderToStaticMarkup(createElement(custom[name]!, { props, buildChild: (id: string) => children[id] ?? null })); + +/** The opening tag of the first element whose class list carries `cls`. */ +const tagWith = (html: string, cls: string) => html.match(new RegExp(`<[a-z0-9]+[^>]*class="[^"]*\\b${cls}\\b[^"]*"[^>]*>`))?.[0] ?? ""; + +describe("Text", () => { + it("renders the heading variants as h1, h2 and h3, keeping Astryx's display type", () => { + expect(render("Text", { variant: "h1", text: "Title" })).toMatch(/

]*>Title<\/h1>/); + expect(render("Text", { variant: "h2", text: "Section" })).toMatch(/

]*>Section<\/h2>/); + expect(render("Text", { variant: "h3", text: "Group" })).toMatch(/

]*>Group<\/h3>/); + }); + + it("keeps body text a paragraph and a caption inline text", () => { + expect(render("Text", { variant: "body", text: "Body" })).toMatch(/

]*>Body<\/p>/); + expect(render("Text", { variant: "caption", text: "Note" })).toMatch(/]*>Note<\/span>/); + expect(render("Text", { text: "No variant" })).toMatch(/

]*>No variant<\/p>/); + }); + + it("keeps a container heading's own text a heading, and never puts its block children inside a heading or a paragraph", () => { + const html = render("Text", { variant: "h2", text: "Account", children: ["c1"] }, { c1: createElement("p", null, "Child") }); + expect(html).toMatch(/

]*>Account<\/span>

Child<\/p><\/span><\/div>/); + expect(html).not.toMatch(/ { + for (const variant of ["h1", "h2", "h3", "body", "caption"]) { + expect(tagWith(render("Text", { variant, text: "x".repeat(200) }), "astryx-text")).toContain("overflow-wrap:anywhere"); + } + }); +}); + +describe("Table", () => { + const data = { columns: ["Ticket", "Subject"], data: [{ cells: ["#1", "Export fails"] }] }; + + it("names a data table's scroll region by its columns, not only 'Table'", () => { + expect(tagWith(render("Table", data), "astryx-table-scroll-wrapper")).toContain('aria-label="Table: Ticket, Subject"'); + }); + + it("wraps header text instead of cutting it off with an ellipsis", () => { + const html = render("Table", data); + const headers = html.match(/]*>/g) ?? []; + expect(headers).toHaveLength(2); + for (const th of headers) { + expect(th).toContain("white-space:normal"); + expect(th).toContain("overflow:visible"); + expect(th).toContain("text-overflow:clip"); + expect(th).toContain("overflow-wrap:anywhere"); + } + }); + + it("names and wraps a children-mode table the same way", () => { + const html = render("Table", { columns: ["Step", "Instruction"], children: ["a", "b"] }, { a: "1", b: "Boil water" }); + expect(tagWith(html, "astryx-table-scroll-wrapper")).toContain('aria-label="Table: Step, Instruction"'); + const headers = html.match(/]*>/g) ?? []; + expect(headers).toHaveLength(2); + for (const th of headers) expect(th).toContain("white-space:normal"); + }); + + it("keeps Astryx's name for a table without columns", () => { + expect(tagWith(render("Table", { children: ["a"] }, { a: "1" }), "astryx-table-scroll-wrapper")).toContain('aria-label="Table"'); + }); + + it("wraps a long status label inside its badge", () => { + const html = render("Table", { columns: ["Name"], data: [{ cells: ["A"], status: { label: "y".repeat(120), variant: "warning" } }] }); + const badge = tagWith(html, "astryx-badge"); + expect(badge).toContain("white-space:normal"); + expect(badge).toContain("overflow-wrap:anywhere"); + }); +}); + +describe("Badge", () => { + it("wraps a long label inside the badge instead of overflowing its container", () => { + const badge = tagWith(render("Badge", { label: "Serves: four people with leftovers for lunch the next day", variant: "neutral" }), "astryx-badge"); + expect(badge).toContain("white-space:normal"); + expect(badge).toContain("height:auto"); + expect(badge).toContain("max-width:100%"); + expect(badge).toContain("overflow-wrap:anywhere"); + }); +}); diff --git a/e2e/composer-blocks-a11y.spec.ts b/e2e/composer-blocks-a11y.spec.ts new file mode 100644 index 0000000..4751c0c --- /dev/null +++ b/e2e/composer-blocks-a11y.spec.ts @@ -0,0 +1,463 @@ +/** + * Accessibility of the rendered interface blocks on the Astryx beta path + * (Phase 2 step 6; docs/blocks/PHASE-2-RELEASE-PLAN.md, §10). The target is + * WCAG 2.2 AA; docs/blocks/ACCESSIBILITY.md records what these specs prove and + * what still needs a person. + * + * Each spec builds one block through the hosted path with a stubbed gateway + * (no model is called) and checks the native Astryx surface: axe (WCAG 2.0, + * 2.1 and 2.2, A and AA), headings, table names, keyboard order, focus + * indicators, focus kept clear of the sticky header, target size, text + * spacing, 200% zoom (a 640 px viewport) and 320 px reflow. A data table may + * scroll inside its own focusable region, the SC 1.4.10 exception for content + * that needs two-dimensional layout; nothing else may. + * + * The calls are each block's approved example and three boundary calls: the + * smallest valid call, every optional prop at its maximum length and count, + * and the same with every string one unbroken token (a URL, an id). + * + * The shadcn/ui v3 scans at the end only report and never fail: shadcn/ui v3 + * blocks are withheld from the beta. + */ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import AxeBuilder from "@axe-core/playwright"; +import { expect, test, type Page } from "@playwright/test"; + +type Json = Record; +type Variant = "example" | "min" | "max" | "long-token"; + +const TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"]; +const CANVAS = '[data-testid="build-canvas-1"]'; +const SWITCH_ON = { enabled: true, references: ["shadcn", "astryx"], blocks: ["records-table", "auth-form", "guide-steps"] }; +const sidecar = (file: string) => JSON.parse(readFileSync(fileURLToPath(new URL(`../apps/composer/experimental/blocks/${file}`, import.meta.url)), "utf8")) as Json; +const ASTRYX = sidecar("astryx.blocks.json"); +const SHADCN = sidecar("shadcn-v3.blocks.json"); +const blockOf = (s: Json, id: string) => (s.blocks as Json[]).find((b) => b.id === id)!; + +// ---- Calls ----------------------------------------------------------------- + +const LOREM = "Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore "; +/** Words filling exactly `n` characters. */ +const words = (n: number) => { + let s = ""; + while (s.length < n) s += LOREM; + return s.slice(0, n).replace(/\s$/, "x"); +}; +/** One unbroken token of exactly `n` characters. */ +const token = (n: number) => "averyveryverylongunbrokentoken".repeat(Math.ceil(n / 30)).slice(0, n); + +const propOf = (b: Json, name: string) => (b.props as Json[]).find((p) => p.name === name)!; +const itemOf = (b: Json, name: string, inner: string) => (propOf(b, name).items.props as Json[]).find((p) => p.name === inner)!; + +/** A valid call for `b`: its approved example, or a boundary call built from its own prop limits. */ +function callFor(b: Json, variant: Variant): Json { + if (variant === "example") return b.examples[0].call; + const head = { block: b.id, version: b.version }; + const text = (spec: Json) => (variant === "long-token" ? token : words)(spec.maxLength); + const all = (name: string) => propOf(b, name).items.values as string[]; + if (b.id === "records-table") { + if (variant === "min") return { ...head, title: "T", caption: "C", columns: ["c"], rows: [{ cells: ["x"] }] }; + const n = propOf(b, "columns").maxItems as number; + const column = propOf(b, "columns").items; + const cell = itemOf(b, "rows", "cells").items; + const tones = itemOf(b, "rows", "tone").values as string[]; + return { + ...head, + title: text(propOf(b, "title")), + caption: text(propOf(b, "caption")), + columns: Array.from({ length: n }, (_, i) => `${i + 1} ${text(column)}`.slice(0, column.maxLength)), + rows: Array.from({ length: propOf(b, "rows").maxItems }, (_, r) => ({ + cells: Array.from({ length: n }, (_, c) => `${r + 1}.${c + 1} ${text(cell)}`.slice(0, cell.maxLength)), + tone: tones[r % tones.length], + })), + statusColumn: Math.min(propOf(b, "statusColumn").maximum, n), + description: text(propOf(b, "description")), + actions: all("actions"), + }; + } + if (b.id === "auth-form") { + if (variant === "min") return { ...head, mode: "reset", title: "T", fields: ["email"] }; + return { ...head, mode: "sign-up", title: text(propOf(b, "title")), fields: all("fields"), intro: text(propOf(b, "intro")), providers: all("providers"), recovery: true }; + } + if (variant === "min") return { ...head, title: "T", steps: ["x"] }; + return { + ...head, + title: text(propOf(b, "title")), + steps: Array.from({ length: propOf(b, "steps").maxItems }, () => text(propOf(b, "steps").items)), + summary: text(propOf(b, "summary")), + items: Array.from({ length: propOf(b, "items").maxItems }, () => text(propOf(b, "items").items)), + itemsHeading: text(propOf(b, "itemsHeading")), + meta: Array.from({ length: propOf(b, "meta").maxItems }, () => ({ label: text(itemOf(b, "meta", "label")), detail: text(itemOf(b, "meta", "detail")) })), + actions: all("actions"), + }; +} + +/** A table's scroll region is named by its columns. */ +const tableName = (columns: string[]) => `Table: ${columns.join(", ")}`; + +/** The approved templates' fixed labels (docs/blocks/HOST-ACTIONS.md). */ +const LABELS: Record> = { + records: { create: "New record", export: "Export", refresh: "Refresh" }, + field: { email: "Email address", password: "Password", name: "Full name", username: "Username" }, + submit: { "sign-in": "Sign in", "sign-up": "Create account", reset: "Send reset link" }, + provider: { google: "Continue with Google", github: "Continue with GitHub", microsoft: "Continue with Microsoft", apple: "Continue with Apple" }, + guide: { start: "Start", save: "Save guide" }, +}; +const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +/** Every keyboard stop the call's surface should have, in order, by accessible name. */ +function tabStops(blockId: string, call: Json): Array { + if (blockId === "records-table") return [tableName(call.columns), ...(call.actions ?? []).map((a: string) => LABELS.records![a]!)]; + if (blockId === "auth-form") + return [ + // A field's name is its label, then Astryx's visible "Required". + ...(call.fields as string[]).map((f) => new RegExp(`^${escape(LABELS.field![f]!)}( Required)?$`)), + LABELS.submit![call.mode]!, + ...(call.recovery ? ["Forgot password?"] : []), + ...(call.providers ?? []).map((p: string) => LABELS.provider![p]!), + ]; + return [ + ...(call.items?.length ? [tableName([call.itemsHeading ?? "What you need"])] : []), + tableName(["Step", "Instruction"]), + ...(call.actions ?? []).map((a: string) => LABELS.guide![a]!), + ]; +} + +// ---- Building a block -------------------------------------------------------- + +const MODEL = "claude-haiku-4-5-20251001"; +const ok = (json: unknown) => ({ status: 200, body: { json, raw: JSON.stringify(json), model: MODEL, usage: { inputTokens: 1000, outputTokens: 50 }, calls: [{ inputTokens: 1000, outputTokens: 50, outcome: "ok", durationMs: 400, model: MODEL }] } }); + +/** A new project on `reference`, and one hosted build whose planner picks `block` and whose block call is `call`. */ +async function buildBlock(page: Page, reference: "astryx" | "shadcn", block: Json, call: Json) { + await page.addInitScript(() => localStorage.setItem("composer.experimental.v1", JSON.stringify({ blocks: true }))); + await page.route("**/api/models", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ models: ["scripted", "hosted-ai"], blocks: SWITCH_ON }) })); + await page.route("**/api/propose", (route) => { + const schema = ((JSON.parse(route.request().postData() ?? "{}") as Json).jsonSchema ?? {}) as Json; + const kind = "$defs" in schema ? "generation" : schema.properties?.block?.const || schema.anyOf ? "block" : "plan"; + // A generation request would mean the block did not expand: fail it loudly. + const reply = + kind === "plan" + ? ok({ intent: block.intents[0], feasible: true, restated: "A surface.", reason: "it fits", block: block.id, blockReason: "it fits" }) + : kind === "block" + ? ok(call) + : { status: 502, body: { error: "provider-unavailable", cause: "provider", message: "not expected", usage: null, calls: [] } }; + return route.fulfill({ status: reply.status, contentType: "application/json", body: JSON.stringify(reply.body) }); + }); + await page.goto("/"); + await page.getByTestId("nav-projects").click(); + await page.getByTestId("new-project-name").fill(`${reference} ${block.id}`); + await page.getByTestId(`new-source-${reference}`).click(); + await page.getByTestId("new-project-create").click(); + await expect(page.getByTestId("build-prompt")).toBeVisible(); + await page.getByTestId("build-model").selectOption("hosted-ai"); + await page.getByTestId("build-prompt").fill(`a ${block.id} surface`); + await page.getByTestId("build-run").click(); + await expect(page.getByTestId("build-block-1")).toContainText(`block ${block.id} v${block.version}`, { timeout: 30_000 }); + await expect(page.getByTestId("build-block-1")).toContainText("expanded"); + await expect(page.locator(CANVAS)).toBeVisible(); +} + +// ---- Checks ------------------------------------------------------------------ + +/** axe over the canvas alone, with violations made readable. */ +async function axeOn(page: Page, include = CANVAS) { + const result = await new AxeBuilder({ page }).withTags(TAGS).include(include).analyze(); + return { + violations: result.violations.map((v) => ({ id: v.id, nodes: v.nodes.slice(0, 3).map((n) => `${n.target.join(" ")}: ${n.failureSummary}`) })), + incomplete: result.incomplete, + }; +} + +/** + * The color-contrast nodes axe could not decide (scrolled or overlapping + * content, a decorative separator): decided here from computed colors, by + * the same thresholds. Astryx's surfaces are opaque, so the first opaque + * ancestor background is what the text sits on. + */ +async function undecidedContrast(page: Page, incomplete: Array<{ id: string; nodes: Array<{ target: unknown[] }> }>) { + const targets = incomplete.filter((r) => r.id === "color-contrast").flatMap((r) => r.nodes.map((n) => String(n.target[0]))); + return page.evaluate((selectors) => { + const rgb = (c: string) => (c.match(/[\d.]+/g) ?? ["0", "0", "0", "0"]).map(Number); + const lum = ([r, g, b]: number[]) => + [r, g, b].map((v) => v! / 255).map((v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)).reduce((s, v, i) => s + v * [0.2126, 0.7152, 0.0722][i]!, 0); + const bgOf = (el: Element | null) => { + for (let n = el; n; n = n.parentElement) { + const c = rgb(getComputedStyle(n).backgroundColor); + if ((c[3] ?? 1) > 0) return c; + } + return [255, 255, 255, 1]; + }; + return selectors.map((sel) => { + const el = document.querySelector(sel); + if (!el) return { sel: `${sel} (not found)`, ratio: 0, needed: 4.5 }; + const cs = getComputedStyle(el); + const [a, b] = [lum(rgb(cs.color)), lum(bgOf(el))]; + const ratio = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); + const size = parseFloat(cs.fontSize); + const large = size >= 24 || (size >= 18.66 && Number(cs.fontWeight) >= 700); + return { sel, ratio: Math.round(ratio * 100) / 100, needed: large ? 3 : 4.5 }; + }); + }, targets); +} + +/** + * The focused element's indicator, and whether any of it is visible: an + * outline on the element, or, for an Astryx field, the field's border. + * Contrast is against what the indicator touches (SC 1.4.11), and an + * indicator is visible when a point of the element is not covered by another + * element, such as the sticky header (SC 2.4.11). + */ +async function focused(page: Page) { + return page.evaluate((sel) => { + const el = document.activeElement as HTMLElement; + const canvas = document.querySelector(sel)!; + const rgb = (c: string) => (c.match(/[\d.]+/g) ?? ["0", "0", "0", "0"]).map(Number); + const lum = ([r, g, b]: number[]) => + [r, g, b].map((v) => v! / 255).map((v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)).reduce((s, v, i) => s + v * [0.2126, 0.7152, 0.0722][i]!, 0); + const contrast = (x: number[], y: number[]) => { + const [a, b] = [lum(x), lum(y)]; + return Math.round(((Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)) * 100) / 100; + }; + const bgOf = (node: Element | null) => { + for (let n = node; n; n = n.parentElement) { + const c = rgb(getComputedStyle(n).backgroundColor); + if ((c[3] ?? 1) > 0) return c; + } + return [255, 255, 255, 1]; + }; + // Sample the part of the element inside the viewport: a table region can be taller than the screen. + const r = el.getBoundingClientRect(); + const [left, top, right, bottom] = [Math.max(r.left, 0), Math.max(r.top, 0), Math.min(r.right, innerWidth), Math.min(r.bottom, innerHeight)]; + let hits = 0; + if (right > left && bottom > top) + for (const fx of [0.1, 0.5, 0.9]) + for (const fy of [0.1, 0.5, 0.9]) { + const hit = document.elementFromPoint(left + (right - left) * fx, top + (bottom - top) * fy); + if (hit && (hit === el || el.contains(hit))) hits++; + } + const base = { inCanvas: canvas.contains(el), tag: el.tagName, visiblePoints: hits }; + const own = getComputedStyle(el); + if (own.outlineStyle !== "none" && parseFloat(own.outlineWidth) > 0) { + const color = rgb(own.outlineColor); + const inset = parseFloat(own.outlineOffset) < 0; + const touches = inset ? [bgOf(el)] : [bgOf(el.parentElement)]; + return { ...base, kind: "outline", style: own.outlineStyle, width: parseFloat(own.outlineWidth), color: own.outlineColor, contrast: Math.min(...touches.map((t) => contrast(color, t))) }; + } + const field = el.closest(".astryx-text-input"); + if (field) { + const s = getComputedStyle(field); + const color = rgb(s.borderTopColor); + return { ...base, kind: "field-border", style: s.borderTopStyle, width: parseFloat(s.borderTopWidth), color: s.borderTopColor, contrast: Math.min(contrast(color, bgOf(field)), contrast(color, bgOf(field.parentElement))) }; + } + return { ...base, kind: "none", style: "none", width: 0, color: "", contrast: 0 }; + }, CANVAS); +} + +/** The unfocused border color of every Astryx field in the canvas, in order. */ +const fieldBorders = (page: Page) => + page.locator(`${CANVAS} .astryx-text-input`).evaluateAll((els) => els.map((e) => getComputedStyle(e).borderTopColor)); + +/** Start sequential focus navigation at the canvas's top-left corner (its padding, not a control). */ +const startAtCanvas = (page: Page) => page.locator(CANVAS).click({ position: { x: 4, y: 4 } }); +const focusInCanvas = (page: Page) => page.evaluate((sel) => document.querySelector(sel)!.contains(document.activeElement), CANVAS); + +/** + * Tab forward through the canvas, then Shift+Tab back: every stop, in order, + * with an indicator a reader can see, and focus leaves at both ends (no trap). + */ +async function traverse(page: Page, stops: Array, { names, at }: { names: boolean; at: string }) { + const unfocused = await fieldBorders(page); + const failures: string[] = []; + const check = async (label: string) => { + const f = await focused(page); + if (!f.inCanvas) return failures.push(`${label}: focus is outside the canvas`); + if (f.visiblePoints === 0) failures.push(`${label}: the focused ${f.tag} is entirely hidden (SC 2.4.11)`); + if (f.kind === "none" || f.style === "auto") failures.push(`${label}: no indicator of its own on ${f.tag} (${f.style})`); + if (f.contrast < 3) failures.push(`${label}: indicator ${f.color} is ${f.contrast}:1 (SC 1.4.11 needs 3:1)`); + if (f.kind === "outline" && f.width < 2) failures.push(`${label}: outline is ${f.width}px`); + if (f.kind === "field-border" && unfocused.includes(f.color)) failures.push(`${label}: the field border does not change on focus`); + }; + await startAtCanvas(page); + for (const [i, stop] of stops.entries()) { + await page.keyboard.press("Tab"); + if (names) await expect.soft(page.locator(":focus"), `${at}: stop ${i + 1}`).toHaveAccessibleName(stop); + await check(`Tab ${i + 1}`); + } + await page.keyboard.press("Tab"); + expect.soft(await focusInCanvas(page), `${at}: Tab leaves the canvas after its last stop`).toBe(false); + await page.keyboard.press("Shift+Tab"); + await check(`back to stop ${stops.length}`); + for (let i = stops.length - 1; i > 0; i--) { + await page.keyboard.press("Shift+Tab"); + if (names) await expect.soft(page.locator(":focus"), `${at}: back to stop ${i}`).toHaveAccessibleName(stops[i - 1]!); + await check(`Shift+Tab to stop ${i}`); + } + await page.keyboard.press("Shift+Tab"); + expect.soft(await focusInCanvas(page), `${at}: Shift+Tab leaves the canvas before its first stop`).toBe(false); + expect.soft(failures, `${at}: focus`).toEqual([]); +} + +/** + * What would make a reader scroll sideways or lose text: the page wider than + * the viewport; anything in the canvas outside the canvas, except what a + * table's own scroll region holds; a table region wider than the canvas; and + * text cut off by an element that hides its overflow. + */ +async function reflow(page: Page) { + return page.evaluate((sel) => { + const canvas = document.querySelector(sel) as HTMLElement; + const c = canvas.getBoundingClientRect(); + const regions = Array.from(canvas.querySelectorAll(".astryx-table-scroll-wrapper")); + const inRegion = (el: Element) => regions.some((s) => s !== el && s.contains(el)); + const describe = (el: Element) => `${el.tagName.toLowerCase()}.${String(el.className).split(" ")[0]} "${(el.textContent ?? "").trim().slice(0, 30)}"`; + const outside: string[] = []; + const clipped: string[] = []; + for (const el of Array.from(canvas.querySelectorAll("*"))) { + const r = el.getBoundingClientRect(); + if (r.width === 0 && r.height === 0) continue; + if (!inRegion(el) && (r.right > c.right + 1 || r.left < c.left - 1)) outside.push(describe(el)); + const cs = getComputedStyle(el); + if (regions.includes(el)) continue; // the region scrolls; its content is checked by the rules above and below + const hidesX = cs.overflowX === "hidden" || cs.overflowX === "clip"; + const hidesY = cs.overflowY === "hidden" || cs.overflowY === "clip"; + if ((hidesX && el.scrollWidth > el.clientWidth + 1) || (hidesY && el.scrollHeight > el.clientHeight + 1)) clipped.push(describe(el)); + } + return { + pageOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, + canvasOverflow: canvas.scrollWidth - canvas.clientWidth, + outside: outside.slice(0, 8), + clipped: clipped.slice(0, 8), + }; + }, CANVAS); +} + +/** WCAG's text-spacing test values (SC 1.4.12). */ +const TEXT_SPACING = "* { line-height: 1.5 !important; letter-spacing: 0.12em !important; word-spacing: 0.16em !important; } p { margin-bottom: 2em !important; }"; + +/** Every keyboard stop's target, at least 24 by 24 px; a field's target is the field around its input, and pressing it focuses the input. */ +async function targets(page: Page) { + const small: string[] = []; + const stops = page.locator(`${CANVAS} button, ${CANVAS} .astryx-table-scroll-wrapper, ${CANVAS} .astryx-text-input`); + for (const box of await stops.evaluateAll((els) => els.map((e) => ({ tag: e.tagName, name: (e.textContent ?? "").trim().slice(0, 24), ...e.getBoundingClientRect().toJSON() })))) { + if (box.width < 24 || box.height < 24) small.push(`${box.tag} "${box.name}" is ${Math.round(box.width)}x${Math.round(box.height)}`); + } + const fields = page.locator(`${CANVAS} .astryx-text-input`); + for (let i = 0; i < (await fields.count()); i++) { + await fields.nth(i).click({ position: { x: 3, y: 3 } }); + if (!(await fields.nth(i).locator("input").evaluate((e) => e === document.activeElement))) small.push(`field ${i + 1}: pressing its edge does not focus the input`); + } + return small; +} + +// ---- The Astryx blocks ----------------------------------------------------- + +const VARIANTS: Variant[] = ["example", "min", "max", "long-token"]; + +for (const blockId of ["records-table", "auth-form", "guide-steps"]) { + const block = blockOf(ASTRYX, blockId); + for (const variant of VARIANTS) { + test(`Astryx ${blockId}, ${variant} call: WCAG 2.2 AA in the rendered surface`, async ({ page }) => { + test.setTimeout(180_000); + const call = callFor(block, variant); + const stops = tabStops(blockId, call); + await page.setViewportSize({ width: 1280, height: 900 }); + await buildBlock(page, "astryx", block, call); + const canvas = page.locator(CANVAS); + + // Each check is soft, so one run reports every defect it finds. + // Semantics: the title is the surface's one heading, and each table's region is named by its columns. + await expect.soft(canvas.getByRole("heading"), "headings").toHaveCount(1); + await expect.soft(canvas.getByRole("heading", { level: 2 }), "the title's heading").toHaveAccessibleName(call.title); + const regions = await canvas.getByRole("group").evaluateAll((els) => els.map((e) => e.getAttribute("aria-label"))); + expect.soft(regions, "table names").toEqual(stops.filter((s): s is string => typeof s === "string" && s.startsWith("Table: "))); + + // axe at 1280 px, and every contrast it could not decide. + const desktop = await axeOn(page); + expect.soft(desktop.violations, "axe at 1280 px").toEqual([]); + for (const node of await undecidedContrast(page, desktop.incomplete as never)) expect.soft(node.ratio, `contrast axe left undecided: ${node.sel}`).toBeGreaterThanOrEqual(node.needed); + + // Keyboard: every stop in order, a visible indicator with 3:1, no trap; targets of 24 px. + await traverse(page, stops, { names: true, at: "1280 px" }); + expect.soft(await targets(page), "target size").toEqual([]); + + // Text spacing (SC 1.4.12): nothing cut off or pushed out. + const spacing = await page.addStyleTag({ content: TEXT_SPACING }); + const spaced = await reflow(page); + expect.soft(spaced.clipped, "text spacing: cut off").toEqual([]); + expect.soft(spaced.outside, "text spacing: outside the canvas").toEqual([]); + await spacing.evaluate((e) => (e as Element).remove()); + + // 200% zoom (640 px) and 320 px reflow: no sideways scrolling outside a table's region, no text cut off, + // axe still clean, and keyboard focus never hidden under the sticky header. + for (const [width, height] of [ + [640, 450], + [320, 720], + ] as const) { + await page.setViewportSize({ width, height }); + await expect.poll(() => page.evaluate(() => innerWidth)).toBe(width); + expect.soft(await reflow(page), `reflow at ${width} px`).toEqual({ pageOverflow: 0, canvasOverflow: 0, outside: [], clipped: [] }); + expect.soft((await axeOn(page)).violations, `axe at ${width} px`).toEqual([]); + await traverse(page, stops, { names: false, at: `${width} px` }); + } + }); + } +} + +test("Astryx: an accepted block surface keeps its heading, its names and its focus indicators in Preview", async ({ page }) => { + test.setTimeout(120_000); + const block = blockOf(ASTRYX, "auth-form"); + const call = callFor(block, "example"); + await page.setViewportSize({ width: 1280, height: 900 }); + await buildBlock(page, "astryx", block, call); + await page.getByTestId("build-accept-1").click(); + await expect(page.getByTestId("build-accepted-1")).toContainText(/ex\.chat-\d+/); + const id = (await page.getByTestId("build-accepted-1").innerText()).match(/ex\.chat-\d+/)![0]; + await page.getByTestId("nav-preview").click(); + await page.getByTestId(`surface-${id}`).click(); + const preview = page.locator("[data-project-canvas][data-astryx-canvas]"); + await expect.soft(preview.getByRole("heading", { level: 2 }), "the title's heading").toHaveAccessibleName(call.title); + expect.soft((await axeOn(page, "[data-project-canvas]")).violations, "axe").toEqual([]); + const button = preview.getByRole("button", { name: LABELS.submit![call.mode]! }); + await button.focus(); + await page.keyboard.press("Shift+Tab"); + await page.keyboard.press("Tab"); + const ring = await button.evaluate((e) => { + const s = getComputedStyle(e); + return { style: s.outlineStyle, width: parseFloat(s.outlineWidth), color: s.outlineColor }; + }); + // Astryx's own accent ring (#0064E0, 5.4:1 against the white canvas), not the studio's green. + expect.soft(ring, "the submit button's focus ring").toEqual({ style: "solid", width: 2, color: "rgb(0, 100, 224)" }); +}); + +// ---- shadcn/ui v3: information only ---------------------------------------- + +test.describe("shadcn/ui v3, information only: never gates the Astryx-only beta", () => { + for (const blockId of ["records-table", "auth-form", "guide-steps"]) { + for (const variant of ["example", "max"] as Variant[]) { + test(`shadcn/ui v3 ${blockId}, ${variant} call: scanned and recorded`, async ({ page }, info) => { + test.setTimeout(120_000); + const report: Json = { block: blockId, variant }; + try { + await page.setViewportSize({ width: 1280, height: 900 }); + await buildBlock(page, "shadcn", blockOf(SHADCN, blockId), callFor(blockOf(SHADCN, blockId), variant)); + const scan = async () => (await new AxeBuilder({ page }).withTags(TAGS).include(CANVAS).analyze()).violations.map((v) => ({ id: v.id, nodes: v.nodes.length })); + report.axe1280 = await scan(); + report.headings = await page.locator(CANVAS).getByRole("heading").count(); + await page.setViewportSize({ width: 320, height: 720 }); + report.axe320 = await scan(); + report.reflow320 = await page.evaluate((sel) => { + const canvas = document.querySelector(sel) as HTMLElement; + return { pageOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, canvasOverflow: canvas.scrollWidth - canvas.clientWidth }; + }, CANVAS); + } catch (error) { + report.error = String(error).slice(0, 300); + } + await info.attach(`shadcn-${blockId}-${variant}.json`, { body: JSON.stringify(report, null, 2), contentType: "application/json" }); + info.annotations.push({ type: "information only", description: JSON.stringify(report) }); + console.log(`[shadcn/ui v3 a11y, information only] ${JSON.stringify(report)}`); + }); + } + } +}); diff --git a/playwright.composer-experimental.config.ts b/playwright.composer-experimental.config.ts index 0066319..0189d4b 100644 --- a/playwright.composer-experimental.config.ts +++ b/playwright.composer-experimental.config.ts @@ -24,7 +24,7 @@ export default defineConfig({ baseURL: `http://localhost:${PORT}`, contextOptions: { reducedMotion: "reduce" }, }, - projects: [{ name: "composer-experimental", testMatch: ["composer-blocks.spec.ts", "composer-blocks-provenance.spec.ts", "composer-blocks-receipts.spec.ts", "composer-beta.spec.ts"] }], + projects: [{ name: "composer-experimental", testMatch: ["composer-blocks.spec.ts", "composer-blocks-provenance.spec.ts", "composer-blocks-receipts.spec.ts", "composer-blocks-a11y.spec.ts", "composer-beta.spec.ts"] }], webServer: [ { command: "node e2e/serve-composer.mjs", From 59eb82242b1fa80f7b533b72ea597a60dd5ea189 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 00:41:38 +0000 Subject: [PATCH 02/20] Phase 2 step 6: fail-first tests for the column floor and the dark scheme Tests only. - A data table's columns get a minimum width (the renderer unit test). Without one, an 8-column table at 320 px crushed its first and last columns below a letter's width and cut their text off (the e2e's 320 px reflow check, still red after the first fixes). - The surface keeps axe and its focus indicators when the reader prefers a dark scheme (an e2e pass at 1280 px). Astryx's reset declares `color-scheme: light dark`, so the canvas's tokens turned dark inside a white canvas, and Astryx's dark primary button is 3.11:1 (white on #2694FE): 9 of 12 surfaces failed this pass. - A traversal fix in the e2e: it reads each field's unfocused border after taking focus off the fields. The target-size check had left the last field focused, so the 640 px pass read that field's focused border as its unfocused one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R6Ax8qvfN3gYUC85oSGnoi --- apps/composer/app/astryx-renderers-a11y.test.ts | 9 +++++++++ e2e/composer-blocks-a11y.spec.ts | 8 +++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/composer/app/astryx-renderers-a11y.test.ts b/apps/composer/app/astryx-renderers-a11y.test.ts index fea5353..a47cfa8 100644 --- a/apps/composer/app/astryx-renderers-a11y.test.ts +++ b/apps/composer/app/astryx-renderers-a11y.test.ts @@ -67,6 +67,15 @@ describe("Table", () => { } }); + it("gives every data column a minimum width, so a narrow table scrolls in its region instead of crushing its words", () => { + const columns = Array.from({ length: 8 }, (_, i) => `Column ${i + 1}`); + const html = render("Table", { columns, data: [{ cells: columns.map((c) => `${c} value`) }] }); + const headers = html.match(/]*>/g) ?? []; + expect(headers).toHaveLength(8); + for (const th of headers) expect(th).toContain("min-width:96px"); + expect(html.match(/]*>/)?.[0]).toContain("min-width:768px"); + }); + it("names and wraps a children-mode table the same way", () => { const html = render("Table", { columns: ["Step", "Instruction"], children: ["a", "b"] }, { a: "1", b: "Boil water" }); expect(tagWith(html, "astryx-table-scroll-wrapper")).toContain('aria-label="Table: Step, Instruction"'); diff --git a/e2e/composer-blocks-a11y.spec.ts b/e2e/composer-blocks-a11y.spec.ts index 4751c0c..1e9cee5 100644 --- a/e2e/composer-blocks-a11y.spec.ts +++ b/e2e/composer-blocks-a11y.spec.ts @@ -268,6 +268,7 @@ const focusInCanvas = (page: Page) => page.evaluate((sel) => document.querySelec * with an indicator a reader can see, and focus leaves at both ends (no trap). */ async function traverse(page: Page, stops: Array, { names, at }: { names: boolean; at: string }) { + await startAtCanvas(page); // also takes focus off any field, so its border reads unfocused const unfocused = await fieldBorders(page); const failures: string[] = []; const check = async (label: string) => { @@ -279,7 +280,6 @@ async function traverse(page: Page, stops: Array, { names, at } if (f.kind === "outline" && f.width < 2) failures.push(`${label}: outline is ${f.width}px`); if (f.kind === "field-border" && unfocused.includes(f.color)) failures.push(`${label}: the field border does not change on focus`); }; - await startAtCanvas(page); for (const [i, stop] of stops.entries()) { await page.keyboard.press("Tab"); if (names) await expect.soft(page.locator(":focus"), `${at}: stop ${i + 1}`).toHaveAccessibleName(stop); @@ -382,6 +382,12 @@ for (const blockId of ["records-table", "auth-form", "guide-steps"]) { await traverse(page, stops, { names: true, at: "1280 px" }); expect.soft(await targets(page), "target size").toEqual([]); + // Astryx's reset declares light and dark, so the surface follows a reader's dark preference: axe and focus again. + await page.emulateMedia({ colorScheme: "dark" }); + expect.soft((await axeOn(page)).violations, "axe in dark").toEqual([]); + await traverse(page, stops, { names: false, at: "1280 px, dark" }); + await page.emulateMedia({ colorScheme: "light" }); + // Text spacing (SC 1.4.12): nothing cut off or pushed out. const spacing = await page.addStyleTag({ content: TEXT_SPACING }); const spaced = await reflow(page); From f5c0ed9a6435d47921c1b78cc24bf03c2989b232 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 00:41:58 +0000 Subject: [PATCH 03/20] Phase 2 step 6: Astryx renderer semantics and reflow The shared Astryx renderers and the A2UI canvas. These reach the default build too: every Astryx surface in Preview and the Studio gains them. - Text: a heading variant renders as h1, h2 or h3 (Astryx Text renders heading elements for display types), so a block's title is a level-2 heading (WCAG 1.3.1, 2.4.6). A container heading keeps role="heading" on its own text and never wraps its block children in a heading. Every variant breaks a long unbroken word (overflow-wrap: anywhere, 1.4.10). Astryx's reset already zeroes heading margins and sizes, so nothing moves. - Table: the keyboard-scrollable region is named by its columns ("Table: Ticket, Subject, ...") instead of Astryx's "Table" for every table (2.4.6, 4.1.2); header text wraps instead of truncating with an ellipsis (1.4.4, 1.4.10); and a data column gets a 96 px floor with Astryx's proportional() widths, as Astryx advises, so a narrow table scrolls inside its region (the reflow exception for two-dimensional content) instead of crushing columns below a letter's width. Eight columns still fit the composer's canvas at 1280 px. - Badge: a long label wraps inside the badge (a guide's facts ran out of the card at 640 and 320 px); a one-line badge keeps Astryx's height. - A2uiCanvas: each surface's grid item may shrink with the canvas (min-width: 0); a form's inputs held it 24 px past a 320 px viewport. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R6Ax8qvfN3gYUC85oSGnoi --- packages/a2ui-ingest/src/A2uiCanvas.tsx | 6 ++- .../src/components/BadgeRender.tsx | 21 ++++++-- .../src/components/TableRender.tsx | 49 +++++++++++++++---- .../src/components/TextRender.tsx | 40 +++++++++++---- 4 files changed, 92 insertions(+), 24 deletions(-) diff --git a/packages/a2ui-ingest/src/A2uiCanvas.tsx b/packages/a2ui-ingest/src/A2uiCanvas.tsx index 0e27052..4db3b39 100644 --- a/packages/a2ui-ingest/src/A2uiCanvas.tsx +++ b/packages/a2ui-ingest/src/A2uiCanvas.tsx @@ -62,11 +62,13 @@ export const A2uiCanvas: FC = ({ catalog, registry, messages, o if (surfaces.length === 0) return null; // One shared grid spaces the surfaces; the per-surface wrapper exists only // to carry the data-a2ui-surface attribute (a gap on a single-child - // wrapper would space nothing). + // wrapper would space nothing). minWidth 0 lets a surface narrow with the + // canvas (a grid item otherwise keeps its content's minimum width, so a + // form's inputs pushed it past a 320 px viewport, WCAG 1.4.10). return (
{surfaces.map(({ id, surface }) => ( -
+
))} diff --git a/packages/astryx-renderers/src/components/BadgeRender.tsx b/packages/astryx-renderers/src/components/BadgeRender.tsx index b18e4d8..d254501 100644 --- a/packages/astryx-renderers/src/components/BadgeRender.tsx +++ b/packages/astryx-renderers/src/components/BadgeRender.tsx @@ -1,7 +1,22 @@ -/** Catalog `Badge` -> Astryx Badge. All fourteen variants carry verbatim. */ -import type { FC } from "react"; +/** + * Catalog `Badge` -> Astryx Badge. All fourteen variants carry verbatim. + * + * Astryx draws a badge on one line at a fixed height; a long label (a + * guide's "label: detail" fact) then runs out of its container at 200% zoom + * and 320 px. Here the label wraps inside the badge (WCAG 1.4.10), and a + * one-line badge keeps Astryx's height. + */ +import type { CSSProperties, FC } from "react"; import { Badge } from "@astryxdesign/core/Badge"; +export const BADGE_WRAP: CSSProperties = { + whiteSpace: "normal", + height: "auto", + minHeight: "var(--spacing-5, 20px)", + maxWidth: "100%", + overflowWrap: "anywhere", +}; + export const BadgeRender: FC = ({ props }) => ( - + ); diff --git a/packages/astryx-renderers/src/components/TableRender.tsx b/packages/astryx-renderers/src/components/TableRender.tsx index 0ed9312..6356240 100644 --- a/packages/astryx-renderers/src/components/TableRender.tsx +++ b/packages/astryx-renderers/src/components/TableRender.tsx @@ -6,32 +6,63 @@ * - children: nested child component IDs (how generating models routinely * express cells), arranged left-to-right, top-to-bottom into rows of one * cell per column via Astryx's native TableRow/TableCell composition. + * + * Accessibility (WCAG 2.2 AA): Astryx names every table's keyboard-scrollable + * region "Table"; here the region is named by its columns, so two tables on + * one surface are told apart (2.4.6, 4.1.2). Astryx header cells always + * truncate with an ellipsis; here header text wraps, so no heading is cut + * off at 200% zoom or 320 px (1.4.4, 1.4.10). Body cells already wrap. A + * data column gets a minimum width, as Astryx advises (proportional widths + * with a floor), so a table narrower than its columns scrolls inside its + * focusable region, the reflow exception for two-dimensional content, + * instead of crushing each column below a letter's width at 320 px. */ -import type { FC } from "react"; -import { Table, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow } from "@astryxdesign/core/Table"; +import { useMemo, type CSSProperties, type FC } from "react"; +import { Table, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow, proportional } from "@astryxdesign/core/Table"; import { Badge } from "@astryxdesign/core/Badge"; import { childIds } from "@dspack-studio/a2ui-ingest"; +import { BADGE_WRAP } from "./BadgeRender"; interface Row { cells?: unknown[]; status?: { label?: string; variant?: string }; } +const HEADER_WRAP: CSSProperties = { whiteSpace: "normal", overflow: "visible", textOverflow: "clip", overflowWrap: "anywhere" }; +/** Eight columns of this still fit the composer's canvas at 1280 px. */ +const COLUMN_FLOOR = 96; + +/** Names the scroll region by the table's columns, and wraps header text. */ +function accessibleTable(headers: string[]) { + return { + transformScrollWrapper: (p: any) => (headers.length > 0 ? { ...p, htmlProps: { ...p.htmlProps, "aria-label": `Table: ${headers.join(", ")}` } } : p), + transformHeaderCell: (p: any) => ({ ...p, htmlProps: { ...p.htmlProps, style: { ...p.htmlProps?.style, ...HEADER_WRAP } } }), + }; +} + export const TableRender: FC = ({ props, buildChild }) => { const headers: string[] = Array.isArray(props.columns) ? props.columns.map(String) : []; const nested = childIds(props.children); + const rows: Row[] = Array.isArray(props.data) ? props.data : []; + const hasStatus = nested.length === 0 && rows.some((r) => r?.status); + // Astryx caches table plugins by reference: one per set of column names. + const names = hasStatus ? [...headers, "Status"] : headers; + const key = names.join("\u0000"); + const plugins = useMemo(() => ({ accessible: accessibleTable(names) }), [key]); // eslint-disable-line react-hooks/exhaustive-deps if (nested.length > 0) { const width = Math.max(headers.length, 1); const bodyRows: string[][] = []; for (let i = 0; i < nested.length; i += width) bodyRows.push(nested.slice(i, i + width)); return ( -
+
{headers.length > 0 && ( {headers.map((header, i) => ( - {header} + + {header} + ))} @@ -49,17 +80,16 @@ export const TableRender: FC = ({ props, buildChild }) => { ); } - const rows: Row[] = Array.isArray(props.data) ? props.data : []; - const hasStatus = rows.some((r) => r?.status); - - const columns = headers.map((header, i) => ({ key: `c${i}`, header })); + const columnWidth = proportional(1, { minWidth: COLUMN_FLOOR }); + const columns = headers.map((header, i) => ({ key: `c${i}`, header, width: columnWidth })); if (hasStatus) { columns.push({ key: "status", header: "Status", + width: columnWidth, renderCell: (item: Record) => { const s = item.status as Row["status"]; - return s ? : null; + return s ? : null; }, } as any); } @@ -78,6 +108,7 @@ export const TableRender: FC = ({ props, buildChild }) => { density={props.density} dividers={props.dividers} isStriped={props.isStriped} + plugins={plugins} /> ); }; diff --git a/packages/astryx-renderers/src/components/TextRender.tsx b/packages/astryx-renderers/src/components/TextRender.tsx index 25af716..41fe514 100644 --- a/packages/astryx-renderers/src/components/TextRender.tsx +++ b/packages/astryx-renderers/src/components/TextRender.tsx @@ -4,32 +4,52 @@ * `type` system; the table below is the inverse of the profile's valueMap, so * a surface's declared type round-trips to the same Astryx rendering. * + * A heading variant renders as a heading element (Astryx Text renders h1-h3 + * for display types that need heading semantics), so a surface's headings + * reach assistive technology (WCAG 1.3.1, 2.4.6), and every variant breaks a + * long unbroken word instead of overflowing at 320 px (1.4.10). + * * Text may also carry nested children (the contract's "arbitrary children" * composition, which generating models use routinely); they render after the * node's own text, inside the same Astryx Text element — matching how Astryx * Text nests natively. */ -import type { FC } from "react"; +import type { CSSProperties, FC } from "react"; import { Text } from "@astryxdesign/core/Text"; import { childIds } from "@dspack-studio/a2ui-ingest"; -const VARIANT_TO_TYPE: Record = { - h1: { type: "display-2", as: "p", weight: "bold" }, - h2: { type: "display-3", as: "p", weight: "semibold" }, - h3: { type: "large", as: "p", weight: "semibold" }, +const VARIANT_TO_TYPE: Record = { + h1: { type: "display-2", as: "h1", level: 1, weight: "bold" }, + h2: { type: "display-3", as: "h2", level: 2, weight: "semibold" }, + h3: { type: "large", as: "h3", level: 3, weight: "semibold" }, body: { type: "body", as: "p" }, caption: { type: "supporting", as: "span" }, }; +const WRAP: CSSProperties = { overflowWrap: "anywhere" }; + export const TextRender: FC = ({ props, buildChild }) => { const m = VARIANT_TO_TYPE[props.variant as string] ?? VARIANT_TO_TYPE.body; const nested = childIds(props.children); - // A container Text (nested children) must not render

: nested Text - // children render their own

/, and

cannot contain

. - const as = nested.length > 0 ? "div" : m.as; + if (nested.length === 0) { + return ( + + {props.text} + + ); + } + // A container Text (nested children) renders a div: nested Text children + // render their own

//heading, and neither a

nor a heading may + // contain them. A heading's own text keeps its heading role. return ( - - {props.text} + + {m.level && props.text ? ( + + {props.text} + + ) : ( + props.text + )} {nested.map((id) => ( {buildChild(id)} From 22ba739cc9a75ea23729b2a789eb4405ed0ea0b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 00:41:58 +0000 Subject: [PATCH 04/20] Phase 2 step 6: the Astryx canvas keeps Astryx's focus indicators and draws light Composer CSS scoped to [data-astryx-canvas] (Build and Preview). It ships in the default build's stylesheet. - Focus: the studio's unlayered :focus-visible ring (#97B063, 2.41:1 on the canvas white, below SC 1.4.11's 3:1) replaced Astryx's own. Inside the canvas, revert-layer hands each focused element back to Astryx: its accent ring on buttons, its accent border on fields (#0064E0, 5.4:1). Astryx's table scroll region has no indicator of its own, so it gets the same accent ring, inset because the region bleeds to its card's edge. - Scheme: Astryx's reset declares `color-scheme: light dark` on the root, so a reader who prefers dark got Astryx's dark tokens inside the white canvas, where its primary button is 3.11:1 (SC 1.4.3 needs 4.5:1). The canvas now draws light, as its white background and the existing note in this file ("a global dark scheme would silently retint the canvas surfaces") intend. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R6Ax8qvfN3gYUC85oSGnoi --- apps/composer/app/globals.css | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/apps/composer/app/globals.css b/apps/composer/app/globals.css index 945badc..6b75b29 100644 --- a/apps/composer/app/globals.css +++ b/apps/composer/app/globals.css @@ -233,6 +233,32 @@ input[type="range"] { margin-bottom: 0; } +/* The Astryx canvas draws light, on its white background, whatever the + reader's OS preference. Astryx's reset declares `color-scheme: light dark` + on the root, which retinted the canvas's tokens dark inside a white + canvas for a reader who prefers dark; Astryx 0.1.4's dark primary button + (white on #2694FE) is 3.1:1 there, below WCAG 1.4.3's 4.5:1. */ +[data-astryx-canvas] { + color-scheme: light; +} + +/* Keyboard focus in the rendered Astryx canvas keeps Astryx's own + indicators: its accent ring on buttons and its accent border on fields + (#0064E0, 5.4:1 against the canvas white). The studio's green ring above + (#97B063) is 2.4:1 against that white, below WCAG 2.2 SC 1.4.11's 3:1, + and as an unlayered rule it replaced them; revert-layer hands each + focused element back to Astryx's layer. Astryx's table scroll region has + no indicator of its own, so it gets the same accent ring, drawn inside its + edge because the region bleeds to its card's edge. */ +[data-astryx-canvas] :focus-visible { + outline: revert-layer; + outline-offset: revert-layer; +} +[data-astryx-canvas] .astryx-table-scroll-wrapper:focus-visible { + outline: 2px solid var(--color-accent, #0064e0); + outline-offset: -2px; +} + @media (prefers-reduced-motion: reduce) { .st-btn, .st-link { From 709725f0ab11b245d73107838d451623cbef4914 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 00:41:58 +0000 Subject: [PATCH 05/20] Phase 2 step 6: composer shell keeps focus clear of the sticky header and fits 320 px Two composer shell defects on the beta path's Build page; both ship in the default build, and neither touches a request or a block code path. - Focus not obscured (SC 2.4.11): Shift+Tab through a block surface at 640 and 320 px left focused fields, buttons and short table regions entirely under the sticky header (141 and 255 px tall there). The root's scroll-padding-top now follows the header's measured height, so the browser scrolls a focused control into view below it. - Reflow (SC 1.4.10): the Build options row's context select ("auto - inferred from your goal") made the page 11 px wider than a 320 px viewport. Each option now wraps under its label and is capped at the row's width. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R6Ax8qvfN3gYUC85oSGnoi --- apps/composer/app/composer.tsx | 23 ++++++++++++++++++++++- apps/composer/app/views/build-view.tsx | 13 +++++++------ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/apps/composer/app/composer.tsx b/apps/composer/app/composer.tsx index 05bbb1c..87e8eca 100644 --- a/apps/composer/app/composer.tsx +++ b/apps/composer/app/composer.tsx @@ -58,6 +58,27 @@ function Shell() { applyTheme(getStoredTheme()); }, []); + // Keep keyboard focus clear of the sticky header (WCAG 2.2 SC 2.4.11): the + // browser scrolls a focused control into view below the root's + // scroll-padding. The header's height changes with the viewport and with + // how its nav wraps (about 60 px wide, 255 px at 320 px), so it is measured. + const headerRef = useRef(null); + useEffect(() => { + const header = headerRef.current; + if (!header || typeof ResizeObserver === "undefined") return; + const root = document.documentElement; + const pad = () => { + root.style.scrollPaddingTop = `${header.offsetHeight + 8}px`; + }; + pad(); + const observer = new ResizeObserver(pad); + observer.observe(header); + return () => { + observer.disconnect(); + root.style.scrollPaddingTop = ""; + }; + }, []); + // Follow the project lifecycle: opening a project lands on Build; closing (or // first run with none) lands on the hub. Fires only when the identity changes, // so navigation within a project is never yanked away. @@ -87,7 +108,7 @@ function Shell() { return (

-
+
{/* Quiet ecosystem identity: the canonical Aesthetic Function mark beside the app name. [ AF ] COMPOSER, not "Aesthetic Function Composer". */} diff --git a/apps/composer/app/views/build-view.tsx b/apps/composer/app/views/build-view.tsx index 6996ee9..cff8468 100644 --- a/apps/composer/app/views/build-view.tsx +++ b/apps/composer/app/views/build-view.tsx @@ -902,12 +902,13 @@ export function BuildView({ onNavigate }: { onNavigate?: (view: "surfaces" | "va
)} + {/* Each option wraps under its label instead of widening the page at 320 px (WCAG 1.4.10). */}
-
]*>/)?.[0]).toContain("min-width:768px"); }); - it("names and wraps a children-mode table the same way", () => { + it("names and wraps a children-mode table the same way, with the same column floor", () => { const html = render("Table", { columns: ["Step", "Instruction"], children: ["a", "b"] }, { a: "1", b: "Boil water" }); expect(tagWith(html, "astryx-table-scroll-wrapper")).toContain('aria-label="Table: Step, Instruction"'); const headers = html.match(/
]*>/g) ?? []; expect(headers).toHaveLength(2); - for (const th of headers) expect(th).toContain("white-space:normal"); + for (const th of headers) { + expect(th).toContain("white-space:normal"); + expect(th).toContain("min-width:96px"); + } }); it("keeps Astryx's name for a table without columns", () => { diff --git a/e2e/composer-blocks-a11y.spec.ts b/e2e/composer-blocks-a11y.spec.ts index 1e9cee5..39474a2 100644 --- a/e2e/composer-blocks-a11y.spec.ts +++ b/e2e/composer-blocks-a11y.spec.ts @@ -7,14 +7,18 @@ * Each spec builds one block through the hosted path with a stubbed gateway * (no model is called) and checks the native Astryx surface: axe (WCAG 2.0, * 2.1 and 2.2, A and AA), headings, table names, keyboard order, focus - * indicators, focus kept clear of the sticky header, target size, text - * spacing, 200% zoom (a 640 px viewport) and 320 px reflow. A data table may - * scroll inside its own focusable region, the SC 1.4.10 exception for content - * that needs two-dimensional layout; nothing else may. + * indicators (in forced colors too), focus kept clear of the sticky header, + * target size, content that overlaps other content, and, at 640 x 512 and + * 320 x 256 (200% and 400% zoom of a 1280 x 1024 window) and 320 x 720, + * reflow and text spacing. A data table may scroll inside its own focusable + * region, the SC 1.4.10 exception for content that needs two-dimensional + * layout; nothing else may. * * The calls are each block's approved example and three boundary calls: the * smallest valid call, every optional prop at its maximum length and count, - * and the same with every string one unbroken token (a URL, an id). + * and the same with every string one unbroken token (a URL, an id). Preview + * gets the same checks, for an accepted block and for every Astryx reference + * surface, since the canvas rules reach all of them. * * The shadcn/ui v3 scans at the end only report and never fail: shadcn/ui v3 * blocks are withheld from the beta. @@ -129,6 +133,17 @@ function tabStops(blockId: string, call: Json): Array { const MODEL = "claude-haiku-4-5-20251001"; const ok = (json: unknown) => ({ status: 200, body: { json, raw: JSON.stringify(json), model: MODEL, usage: { inputTokens: 1000, outputTokens: 50 }, calls: [{ inputTokens: 1000, outputTokens: 50, outcome: "ok", durationMs: 400, model: MODEL }] } }); +/** A new project on `reference`, opened on Build. */ +async function openProject(page: Page, reference: "astryx" | "shadcn", name: string) { + await page.addInitScript(INSTALL_HELPERS); + await page.goto("/"); + await page.getByTestId("nav-projects").click(); + await page.getByTestId("new-project-name").fill(name); + await page.getByTestId(`new-source-${reference}`).click(); + await page.getByTestId("new-project-create").click(); + await expect(page.getByTestId("build-prompt")).toBeVisible(); +} + /** A new project on `reference`, and one hosted build whose planner picks `block` and whose block call is `call`. */ async function buildBlock(page: Page, reference: "astryx" | "shadcn", block: Json, call: Json) { await page.addInitScript(() => localStorage.setItem("composer.experimental.v1", JSON.stringify({ blocks: true }))); @@ -145,12 +160,7 @@ async function buildBlock(page: Page, reference: "astryx" | "shadcn", block: Jso : { status: 502, body: { error: "provider-unavailable", cause: "provider", message: "not expected", usage: null, calls: [] } }; return route.fulfill({ status: reply.status, contentType: "application/json", body: JSON.stringify(reply.body) }); }); - await page.goto("/"); - await page.getByTestId("nav-projects").click(); - await page.getByTestId("new-project-name").fill(`${reference} ${block.id}`); - await page.getByTestId(`new-source-${reference}`).click(); - await page.getByTestId("new-project-create").click(); - await expect(page.getByTestId("build-prompt")).toBeVisible(); + await openProject(page, reference, `${reference} ${block.id}`); await page.getByTestId("build-model").selectOption("hosted-ai"); await page.getByTestId("build-prompt").fill(`a ${block.id} surface`); await page.getByTestId("build-run").click(); @@ -161,7 +171,46 @@ async function buildBlock(page: Page, reference: "astryx" | "shadcn", block: Jso // ---- Checks ------------------------------------------------------------------ -/** axe over the canvas alone, with violations made readable. */ +/** + * Color arithmetic for the in-page checks, installed before the app loads: + * WCAG relative luminance, with a translucent color composited over what lies + * beneath it (an element's background over its ancestors', then white). + */ +const INSTALL_HELPERS = () => { + type RGBA = [number, number, number, number]; + const parse = (c: string): RGBA | null => { + const m = c.match(/^rgba?\(([^)]+)\)$/); + if (!m) return null; + const [r, g, b, a] = m[1]!.split(/[\s,/]+/).filter(Boolean).map(Number); + return [r!, g!, b!, a ?? 1]; + }; + const over = (top: RGBA, base: RGBA): RGBA => { + const a = top[3]; + return [top[0] * a + base[0] * (1 - a), top[1] * a + base[1] * (1 - a), top[2] * a + base[2] * (1 - a), 1]; + }; + const background = (node: Element | null): RGBA => { + const layers: RGBA[] = []; + for (let n = node; n; n = n.parentElement) { + const c = parse(getComputedStyle(n).backgroundColor) ?? [0, 0, 0, 0]; + if (c[3] > 0) layers.push(c); + if (c[3] >= 1) break; + } + return layers.reverse().reduce((base, top) => over(top, base), [255, 255, 255, 1]); + }; + const lum = ([r, g, b]: RGBA) => + [r, g, b].map((v) => v / 255).map((v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)).reduce((sum, v, i) => sum + v * [0.2126, 0.7152, 0.0722][i]!, 0); + /** Contrast of a (possibly translucent) color drawn over `node`'s background; null when the color cannot be read. */ + const contrastOver = (color: string, node: Element | null): number | null => { + const c = parse(color); + if (!c) return null; + const bg = background(node); + const [x, y] = [lum(over(c, bg)), lum(bg)]; + return Math.round(((Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05)) * 100) / 100; + }; + (window as any).__a11y = { contrastOver }; +}; + +/** axe over one canvas, with violations made readable. */ async function axeOn(page: Page, include = CANVAS) { const result = await new AxeBuilder({ page }).withTags(TAGS).include(include).analyze(); return { @@ -171,62 +220,50 @@ async function axeOn(page: Page, include = CANVAS) { } /** - * The color-contrast nodes axe could not decide (scrolled or overlapping - * content, a decorative separator): decided here from computed colors, by - * the same thresholds. Astryx's surfaces are opaque, so the first opaque - * ancestor background is what the text sits on. + * The color-contrast nodes axe could not decide. Text that another element in + * the canvas covers is a failure: its contrast cannot be met, and what it says + * is lost. Any other undecided text (a decorative separator, a scrolled cell) + * is decided here from computed colors, by the same thresholds. */ -async function undecidedContrast(page: Page, incomplete: Array<{ id: string; nodes: Array<{ target: unknown[] }> }>) { - const targets = incomplete.filter((r) => r.id === "color-contrast").flatMap((r) => r.nodes.map((n) => String(n.target[0]))); - return page.evaluate((selectors) => { - const rgb = (c: string) => (c.match(/[\d.]+/g) ?? ["0", "0", "0", "0"]).map(Number); - const lum = ([r, g, b]: number[]) => - [r, g, b].map((v) => v! / 255).map((v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)).reduce((s, v, i) => s + v * [0.2126, 0.7152, 0.0722][i]!, 0); - const bgOf = (el: Element | null) => { - for (let n = el; n; n = n.parentElement) { - const c = rgb(getComputedStyle(n).backgroundColor); - if ((c[3] ?? 1) > 0) return c; - } - return [255, 255, 255, 1]; - }; - return selectors.map((sel) => { - const el = document.querySelector(sel); - if (!el) return { sel: `${sel} (not found)`, ratio: 0, needed: 4.5 }; - const cs = getComputedStyle(el); - const [a, b] = [lum(rgb(cs.color)), lum(bgOf(el))]; - const ratio = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); - const size = parseFloat(cs.fontSize); - const large = size >= 24 || (size >= 18.66 && Number(cs.fontWeight) >= 700); - return { sel, ratio: Math.round(ratio * 100) / 100, needed: large ? 3 : 4.5 }; - }); - }, targets); +async function undecidedContrast(page: Page, incomplete: Array<{ id: string; nodes: Array<{ target: unknown[]; any: Array<{ data?: { messageKey?: string } }> }> }>, sel = CANVAS) { + const nodes = incomplete + .filter((r) => r.id === "color-contrast") + .flatMap((r) => r.nodes.map((n) => ({ target: String(n.target[0]), overlap: n.any.some((a) => a.data?.messageKey === "bgOverlap") }))); + return page.evaluate( + ({ nodes, sel }) => { + const canvas = document.querySelector(sel)!; + const describe = (el: Element) => `${el.tagName.toLowerCase()} "${(el.textContent ?? "").trim().slice(0, 30)}"`; + return nodes.map(({ target, overlap }) => { + const el = document.querySelector(target); + if (!el) return { sel: `${target} (not found)`, ratio: 0, needed: 4.5 }; + if (overlap) { + el.scrollIntoView({ block: "center" }); + const r = el.getBoundingClientRect(); + const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); + if (hit && !el.contains(hit) && canvas.contains(hit)) return { sel: `${target} is covered by ${describe(hit)}`, ratio: 0, needed: 4.5 }; + } + const cs = getComputedStyle(el); + const size = parseFloat(cs.fontSize); + const large = size >= 24 || (size >= 18.66 && Number(cs.fontWeight) >= 700); + return { sel: target, ratio: (window as any).__a11y.contrastOver(cs.color, el) ?? 0, needed: large ? 3 : 4.5 }; + }); + }, + { nodes, sel }, + ); } /** * The focused element's indicator, and whether any of it is visible: an - * outline on the element, or, for an Astryx field, the field's border. - * Contrast is against what the indicator touches (SC 1.4.11), and an + * outline on the element or on the Astryx field around it, else the field's + * border. Contrast is against what the indicator touches (SC 1.4.11), and an * indicator is visible when a point of the element is not covered by another * element, such as the sticky header (SC 2.4.11). */ -async function focused(page: Page) { +async function focused(page: Page, sel = CANVAS) { return page.evaluate((sel) => { const el = document.activeElement as HTMLElement; const canvas = document.querySelector(sel)!; - const rgb = (c: string) => (c.match(/[\d.]+/g) ?? ["0", "0", "0", "0"]).map(Number); - const lum = ([r, g, b]: number[]) => - [r, g, b].map((v) => v! / 255).map((v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)).reduce((s, v, i) => s + v * [0.2126, 0.7152, 0.0722][i]!, 0); - const contrast = (x: number[], y: number[]) => { - const [a, b] = [lum(x), lum(y)]; - return Math.round(((Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)) * 100) / 100; - }; - const bgOf = (node: Element | null) => { - for (let n = node; n; n = n.parentElement) { - const c = rgb(getComputedStyle(n).backgroundColor); - if ((c[3] ?? 1) > 0) return c; - } - return [255, 255, 255, 1]; - }; + const { contrastOver } = (window as any).__a11y; // Sample the part of the element inside the viewport: a table region can be taller than the screen. const r = el.getBoundingClientRect(); const [left, top, right, bottom] = [Math.max(r.left, 0), Math.max(r.top, 0), Math.min(r.right, innerWidth), Math.min(r.bottom, innerHeight)]; @@ -238,46 +275,63 @@ async function focused(page: Page) { if (hit && (hit === el || el.contains(hit))) hits++; } const base = { inCanvas: canvas.contains(el), tag: el.tagName, visiblePoints: hits }; - const own = getComputedStyle(el); - if (own.outlineStyle !== "none" && parseFloat(own.outlineWidth) > 0) { - const color = rgb(own.outlineColor); - const inset = parseFloat(own.outlineOffset) < 0; - const touches = inset ? [bgOf(el)] : [bgOf(el.parentElement)]; - return { ...base, kind: "outline", style: own.outlineStyle, width: parseFloat(own.outlineWidth), color: own.outlineColor, contrast: Math.min(...touches.map((t) => contrast(color, t))) }; - } const field = el.closest(".astryx-text-input"); + for (const node of [el, field]) { + if (!node) continue; + const s = getComputedStyle(node); + if (s.outlineStyle === "none" || parseFloat(s.outlineWidth) <= 0) continue; + const inset = parseFloat(s.outlineOffset) < 0; + return { ...base, kind: "outline", style: s.outlineStyle, width: parseFloat(s.outlineWidth), color: s.outlineColor, contrast: contrastOver(s.outlineColor, inset ? node : node.parentElement) ?? 0 }; + } if (field) { const s = getComputedStyle(field); - const color = rgb(s.borderTopColor); - return { ...base, kind: "field-border", style: s.borderTopStyle, width: parseFloat(s.borderTopWidth), color: s.borderTopColor, contrast: Math.min(contrast(color, bgOf(field)), contrast(color, bgOf(field.parentElement))) }; + return { ...base, kind: "field-border", style: s.borderTopStyle, width: parseFloat(s.borderTopWidth), color: s.borderTopColor, contrast: Math.min(contrastOver(s.borderTopColor, field) ?? 0, contrastOver(s.borderTopColor, field.parentElement) ?? 0) }; } return { ...base, kind: "none", style: "none", width: 0, color: "", contrast: 0 }; - }, CANVAS); + }, sel); } -/** The unfocused border color of every Astryx field in the canvas, in order. */ -const fieldBorders = (page: Page) => - page.locator(`${CANVAS} .astryx-text-input`).evaluateAll((els) => els.map((e) => getComputedStyle(e).borderTopColor)); +/** The unfocused border color of every Astryx field in a canvas, in order. */ +const fieldBorders = (page: Page, sel: string) => page.locator(`${sel} .astryx-text-input`).evaluateAll((els) => els.map((e) => getComputedStyle(e).borderTopColor)); -/** Start sequential focus navigation at the canvas's top-left corner (its padding, not a control). */ -const startAtCanvas = (page: Page) => page.locator(CANVAS).click({ position: { x: 4, y: 4 } }); -const focusInCanvas = (page: Page) => page.evaluate((sel) => document.querySelector(sel)!.contains(document.activeElement), CANVAS); +/** + * Start sequential focus navigation at a canvas, as a click in its padding + * would, but from script: a header covering the canvas must fail the checks + * below, not block a click. The canvas takes focus only from script + * (tabindex -1), so Tab and Shift+Tab never stop on it. + */ +const startAtCanvas = (page: Page, sel: string) => + page.evaluate((sel) => { + const canvas = document.querySelector(sel)!; + canvas.tabIndex = -1; + canvas.focus({ preventScroll: true }); + }, sel); +const endAtCanvas = (page: Page, sel: string) => page.evaluate((sel) => document.querySelector(sel)!.removeAttribute("tabindex"), sel); +const focusIn = (page: Page, sel: string) => page.evaluate((sel) => document.querySelector(sel)!.contains(document.activeElement), sel); /** - * Tab forward through the canvas, then Shift+Tab back: every stop, in order, + * Tab forward through a canvas, then Shift+Tab back: every stop, in order, * with an indicator a reader can see, and focus leaves at both ends (no trap). + * In forced colors the system chooses every color, so an indicator must be an + * outline there: a field's border color change does not survive. */ -async function traverse(page: Page, stops: Array, { names, at }: { names: boolean; at: string }) { - await startAtCanvas(page); // also takes focus off any field, so its border reads unfocused - const unfocused = await fieldBorders(page); +async function traverse(page: Page, stops: Array, { names, at, sel = CANVAS, forced = false }: { names: boolean; at: string; sel?: string; forced?: boolean }) { + await startAtCanvas(page, sel); // also takes focus off any field, so its border reads unfocused + expect(await focusIn(page, sel), `${at}: focus starts at the canvas`).toBe(true); + const unfocused = await fieldBorders(page, sel); const failures: string[] = []; const check = async (label: string) => { - const f = await focused(page); + const f = await focused(page, sel); if (!f.inCanvas) return failures.push(`${label}: focus is outside the canvas`); if (f.visiblePoints === 0) failures.push(`${label}: the focused ${f.tag} is entirely hidden (SC 2.4.11)`); if (f.kind === "none" || f.style === "auto") failures.push(`${label}: no indicator of its own on ${f.tag} (${f.style})`); + if (forced) { + if (f.kind !== "outline") failures.push(`${label}: no outline on ${f.tag} in forced colors`); + return; + } if (f.contrast < 3) failures.push(`${label}: indicator ${f.color} is ${f.contrast}:1 (SC 1.4.11 needs 3:1)`); if (f.kind === "outline" && f.width < 2) failures.push(`${label}: outline is ${f.width}px`); + // The config's reduced motion makes Astryx's border transition instant, so the color reads settled. if (f.kind === "field-border" && unfocused.includes(f.color)) failures.push(`${label}: the field border does not change on focus`); }; for (const [i, stop] of stops.entries()) { @@ -286,7 +340,7 @@ async function traverse(page: Page, stops: Array, { names, at } await check(`Tab ${i + 1}`); } await page.keyboard.press("Tab"); - expect.soft(await focusInCanvas(page), `${at}: Tab leaves the canvas after its last stop`).toBe(false); + expect.soft(await focusIn(page, sel), `${at}: Tab leaves the canvas after its last stop`).toBe(false); await page.keyboard.press("Shift+Tab"); await check(`back to stop ${stops.length}`); for (let i = stops.length - 1; i > 0; i--) { @@ -295,17 +349,20 @@ async function traverse(page: Page, stops: Array, { names, at } await check(`Shift+Tab to stop ${i}`); } await page.keyboard.press("Shift+Tab"); - expect.soft(await focusInCanvas(page), `${at}: Shift+Tab leaves the canvas before its first stop`).toBe(false); + expect.soft(await focusIn(page, sel), `${at}: Shift+Tab leaves the canvas before its first stop`).toBe(false); expect.soft(failures, `${at}: focus`).toEqual([]); + await endAtCanvas(page, sel); } /** - * What would make a reader scroll sideways or lose text: the page wider than - * the viewport; anything in the canvas outside the canvas, except what a - * table's own scroll region holds; a table region wider than the canvas; and - * text cut off by an element that hides its overflow. + * What would make a reader scroll sideways or lose content: the page wider + * than the viewport; anything in the canvas outside the canvas, except what a + * table's own scroll region holds; text cut off by an element that hides its + * overflow; one part of a surface drawn over the next (each Astryx stack's + * children, through display: contents wrappers); and a table column crushed + * below its floor. */ -async function reflow(page: Page) { +async function reflow(page: Page, sel = CANVAS) { return page.evaluate((sel) => { const canvas = document.querySelector(sel) as HTMLElement; const c = canvas.getBoundingClientRect(); @@ -324,18 +381,44 @@ async function reflow(page: Page) { const hidesY = cs.overflowY === "hidden" || cs.overflowY === "clip"; if ((hidesX && el.scrollWidth > el.clientWidth + 1) || (hidesY && el.scrollHeight > el.clientHeight + 1)) clipped.push(describe(el)); } + const laidOut = (el: Element): Element[] => + Array.from(el.children).flatMap((k) => (getComputedStyle(k).display === "contents" ? laidOut(k) : [k])); + const overlaps: string[] = []; + for (const stack of Array.from(canvas.querySelectorAll(".astryx-stack"))) { + const across = stack.classList.contains("horizontal"); + const kids = laidOut(stack).filter((k) => k.getBoundingClientRect().height > 0); + for (let i = 1; i < kids.length; i++) { + const [a, b] = [kids[i - 1]!.getBoundingClientRect(), kids[i]!.getBoundingClientRect()]; + const by = across ? a.right - b.left : a.bottom - b.top; + if (by > 0.5) overlaps.push(`${describe(kids[i - 1]!)} overlaps ${describe(kids[i]!)} by ${Math.round(by)}px`); + } + } + const crushed = Array.from(canvas.querySelectorAll("th")) + .filter((th) => th.getBoundingClientRect().width < 90) + .map((th) => `${describe(th)} is ${Math.round(th.getBoundingClientRect().width)}px`); return { pageOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, canvasOverflow: canvas.scrollWidth - canvas.clientWidth, outside: outside.slice(0, 8), clipped: clipped.slice(0, 8), + overlaps: overlaps.slice(0, 8), + crushed: crushed.slice(0, 8), }; - }, CANVAS); + }, sel); } +const FITS = { pageOverflow: 0, canvasOverflow: 0, outside: [], clipped: [], overlaps: [], crushed: [] }; /** WCAG's text-spacing test values (SC 1.4.12). */ const TEXT_SPACING = "* { line-height: 1.5 !important; letter-spacing: 0.12em !important; word-spacing: 0.16em !important; } p { margin-bottom: 2em !important; }"; +/** Reflow with WCAG's text spacing applied: nothing cut off, pushed out or drawn over. */ +async function spaced(page: Page, sel = CANVAS) { + const style = await page.addStyleTag({ content: TEXT_SPACING }); + const r = await reflow(page, sel); + await style.evaluate((e) => (e as Element).remove()); + return { clipped: r.clipped, outside: r.outside, overlaps: r.overlaps }; +} + /** Every keyboard stop's target, at least 24 by 24 px; a field's target is the field around its input, and pressing it focuses the input. */ async function targets(page: Page) { const small: string[] = []; @@ -351,6 +434,18 @@ async function targets(page: Page) { return small; } +/** 200% and 400% zoom of a 1280 x 1024 window, and a tall 320 px screen. */ +const NARROW = [ + [640, 512], + [320, 720], + [320, 256], +] as const; + +async function resize(page: Page, width: number, height: number) { + await page.setViewportSize({ width, height }); + await expect.poll(() => page.evaluate(() => [innerWidth, innerHeight])).toEqual([width, height]); +} + // ---- The Astryx blocks ----------------------------------------------------- const VARIANTS: Variant[] = ["example", "min", "max", "long-token"]; @@ -359,7 +454,7 @@ for (const blockId of ["records-table", "auth-form", "guide-steps"]) { const block = blockOf(ASTRYX, blockId); for (const variant of VARIANTS) { test(`Astryx ${blockId}, ${variant} call: WCAG 2.2 AA in the rendered surface`, async ({ page }) => { - test.setTimeout(180_000); + test.setTimeout(240_000); const call = callFor(block, variant); const stops = tabStops(blockId, call); await page.setViewportSize({ width: 1280, height: 900 }); @@ -373,10 +468,11 @@ for (const blockId of ["records-table", "auth-form", "guide-steps"]) { const regions = await canvas.getByRole("group").evaluateAll((els) => els.map((e) => e.getAttribute("aria-label"))); expect.soft(regions, "table names").toEqual(stops.filter((s): s is string => typeof s === "string" && s.startsWith("Table: "))); - // axe at 1280 px, and every contrast it could not decide. + // axe at 1280 px, every contrast it could not decide, and layout. const desktop = await axeOn(page); expect.soft(desktop.violations, "axe at 1280 px").toEqual([]); for (const node of await undecidedContrast(page, desktop.incomplete as never)) expect.soft(node.ratio, `contrast axe left undecided: ${node.sel}`).toBeGreaterThanOrEqual(node.needed); + expect.soft(await reflow(page), "layout at 1280 px").toEqual(FITS); // Keyboard: every stop in order, a visible indicator with 3:1, no trap; targets of 24 px. await traverse(page, stops, { names: true, at: "1280 px" }); @@ -386,33 +482,32 @@ for (const blockId of ["records-table", "auth-form", "guide-steps"]) { await page.emulateMedia({ colorScheme: "dark" }); expect.soft((await axeOn(page)).violations, "axe in dark").toEqual([]); await traverse(page, stops, { names: false, at: "1280 px, dark" }); - await page.emulateMedia({ colorScheme: "light" }); - - // Text spacing (SC 1.4.12): nothing cut off or pushed out. - const spacing = await page.addStyleTag({ content: TEXT_SPACING }); - const spaced = await reflow(page); - expect.soft(spaced.clipped, "text spacing: cut off").toEqual([]); - expect.soft(spaced.outside, "text spacing: outside the canvas").toEqual([]); - await spacing.evaluate((e) => (e as Element).remove()); - - // 200% zoom (640 px) and 320 px reflow: no sideways scrolling outside a table's region, no text cut off, - // axe still clean, and keyboard focus never hidden under the sticky header. - for (const [width, height] of [ - [640, 450], - [320, 720], - ] as const) { - await page.setViewportSize({ width, height }); - await expect.poll(() => page.evaluate(() => innerWidth)).toBe(width); - expect.soft(await reflow(page), `reflow at ${width} px`).toEqual({ pageOverflow: 0, canvasOverflow: 0, outside: [], clipped: [] }); - expect.soft((await axeOn(page)).violations, `axe at ${width} px`).toEqual([]); - await traverse(page, stops, { names: false, at: `${width} px` }); + // Forced colors (Windows high contrast): every stop keeps an indicator the system can draw. + await page.emulateMedia({ colorScheme: "light", forcedColors: "active" }); + await traverse(page, stops, { names: false, at: "1280 px, forced colors", forced: true }); + await page.emulateMedia({ forcedColors: "none" }); + + // Text spacing (SC 1.4.12) at 1280 px. + expect.soft(await spaced(page), "text spacing at 1280 px").toEqual({ clipped: [], outside: [], overlaps: [] }); + + // 200% and 400% zoom, and a tall 320 px screen: no sideways scrolling outside a table's region, nothing + // cut off or drawn over (with text spacing too), axe still clean, and focus never hidden under the header. + for (const [width, height] of NARROW) { + const at = `${width} x ${height}`; + await resize(page, width, height); + expect.soft(await reflow(page), `reflow at ${at}`).toEqual(FITS); + expect.soft(await spaced(page), `text spacing at ${at}`).toEqual({ clipped: [], outside: [], overlaps: [] }); + expect.soft((await axeOn(page)).violations, `axe at ${at}`).toEqual([]); + await traverse(page, stops, { names: false, at }); } }); } } +const PREVIEW = "[data-project-canvas][data-astryx-canvas]"; + test("Astryx: an accepted block surface keeps its heading, its names and its focus indicators in Preview", async ({ page }) => { - test.setTimeout(120_000); + test.setTimeout(180_000); const block = blockOf(ASTRYX, "auth-form"); const call = callFor(block, "example"); await page.setViewportSize({ width: 1280, height: 900 }); @@ -422,7 +517,7 @@ test("Astryx: an accepted block surface keeps its heading, its names and its foc const id = (await page.getByTestId("build-accepted-1").innerText()).match(/ex\.chat-\d+/)![0]; await page.getByTestId("nav-preview").click(); await page.getByTestId(`surface-${id}`).click(); - const preview = page.locator("[data-project-canvas][data-astryx-canvas]"); + const preview = page.locator(PREVIEW); await expect.soft(preview.getByRole("heading", { level: 2 }), "the title's heading").toHaveAccessibleName(call.title); expect.soft((await axeOn(page, "[data-project-canvas]")).violations, "axe").toEqual([]); const button = preview.getByRole("button", { name: LABELS.submit![call.mode]! }); @@ -435,6 +530,33 @@ test("Astryx: an accepted block surface keeps its heading, its names and its foc }); // Astryx's own accent ring (#0064E0, 5.4:1 against the white canvas), not the studio's green. expect.soft(ring, "the submit button's focus ring").toEqual({ style: "solid", width: 2, color: "rgb(0, 100, 224)" }); + const stops = tabStops("auth-form", call); + for (const [width, height] of NARROW) { + const at = `Preview at ${width} x ${height}`; + await resize(page, width, height); + expect.soft(await reflow(page, PREVIEW), at).toEqual(FITS); + await traverse(page, stops, { names: false, at, sel: PREVIEW }); + } +}); + +test("Astryx: every reference surface in Preview fits 1280 and 320 px, with nothing cut off or drawn over", async ({ page }) => { + test.setTimeout(180_000); + await page.setViewportSize({ width: 1280, height: 900 }); + await openProject(page, "astryx", "astryx references"); + await page.getByTestId("nav-preview").click(); + const ids = await page.locator('[data-testid^="surface-"]:not([data-testid^="surface-refused-"])').evaluateAll((els) => els.map((e) => e.getAttribute("data-testid")!.slice("surface-".length))); + expect(ids.length).toBeGreaterThan(0); + for (const id of ids) { + await resize(page, 1280, 900); + await page.getByTestId(`surface-${id}`).click(); + await expect(page.locator(PREVIEW)).toBeVisible(); + for (const [width, height] of [[1280, 900], ...NARROW] as const) { + await resize(page, width, height); + // A data table may scroll inside its region; this checks only that nothing is lost or drawn over. + const r = await reflow(page, PREVIEW); + expect.soft({ outside: r.outside, clipped: r.clipped, overlaps: r.overlaps, pageOverflow: r.pageOverflow }, `${id} at ${width} x ${height}`).toEqual({ outside: [], clipped: [], overlaps: [], pageOverflow: 0 }); + } + } }); // ---- shadcn/ui v3: information only ---------------------------------------- From 3521742523d8528f80fab024881341b121ea51fd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 02:06:33 +0000 Subject: [PATCH 10/20] Phase 2 step 6: h3 stays body text, no empty heading, and a children-mode column floor Review findings on the shared Astryx renderers: - h3 is the profile's projection of Astryx's `large`, which the contract keeps body text ("Headings are expressed via the display types ..., never via the rendered element"). It rendered as

; it is a paragraph again, as on main. h1 and h2, from the display types, stay headings. - A display variant without text rendered an empty heading, which a screen reader announces blank. It renders a paragraph. - A children-mode table (a model's nested cells; no block uses one) crushed six columns to 37 px at 320 px. Its header cells get the same 96 px floor as data columns, so it scrolls in its focusable region. In Chromium's automatic table layout the floor holds: six columns make a 588 px table that scrolls in a 200 px region, where they were 26 to 47 px. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R6Ax8qvfN3gYUC85oSGnoi --- .../src/components/TableRender.tsx | 12 ++++++----- .../src/components/TextRender.tsx | 20 +++++++++++-------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/packages/astryx-renderers/src/components/TableRender.tsx b/packages/astryx-renderers/src/components/TableRender.tsx index 6356240..881ccd0 100644 --- a/packages/astryx-renderers/src/components/TableRender.tsx +++ b/packages/astryx-renderers/src/components/TableRender.tsx @@ -12,10 +12,10 @@ * one surface are told apart (2.4.6, 4.1.2). Astryx header cells always * truncate with an ellipsis; here header text wraps, so no heading is cut * off at 200% zoom or 320 px (1.4.4, 1.4.10). Body cells already wrap. A - * data column gets a minimum width, as Astryx advises (proportional widths - * with a floor), so a table narrower than its columns scrolls inside its - * focusable region, the reflow exception for two-dimensional content, - * instead of crushing each column below a letter's width at 320 px. + * column gets a minimum width, as Astryx advises (proportional widths with a + * floor), so a table narrower than its columns scrolls inside its focusable + * region, the reflow exception for two-dimensional content, instead of + * crushing each column below a letter's width at 320 px. */ import { useMemo, type CSSProperties, type FC } from "react"; import { Table, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow, proportional } from "@astryxdesign/core/Table"; @@ -31,6 +31,8 @@ interface Row { const HEADER_WRAP: CSSProperties = { whiteSpace: "normal", overflow: "visible", textOverflow: "clip", overflowWrap: "anywhere" }; /** Eight columns of this still fit the composer's canvas at 1280 px. */ const COLUMN_FLOOR = 96; +/** A children-mode header cell: its table lays out automatically, so the floor goes on the cell. */ +const CHILD_HEADER: CSSProperties = { ...HEADER_WRAP, minWidth: COLUMN_FLOOR }; /** Names the scroll region by the table's columns, and wraps header text. */ function accessibleTable(headers: string[]) { @@ -60,7 +62,7 @@ export const TableRender: FC = ({ props, buildChild }) => { {headers.map((header, i) => ( - + {header} ))} diff --git a/packages/astryx-renderers/src/components/TextRender.tsx b/packages/astryx-renderers/src/components/TextRender.tsx index 41fe514..b1e0d39 100644 --- a/packages/astryx-renderers/src/components/TextRender.tsx +++ b/packages/astryx-renderers/src/components/TextRender.tsx @@ -4,10 +4,13 @@ * `type` system; the table below is the inverse of the profile's valueMap, so * a surface's declared type round-trips to the same Astryx rendering. * - * A heading variant renders as a heading element (Astryx Text renders h1-h3 - * for display types that need heading semantics), so a surface's headings - * reach assistive technology (WCAG 1.3.1, 2.4.6), and every variant breaks a - * long unbroken word instead of overflowing at 320 px (1.4.10). + * A display variant (h1, h2) renders as a heading element, so a surface's + * headings reach assistive technology (WCAG 1.3.1, 2.4.6); without text it + * stays a paragraph, since an empty heading is announced blank. h3 is the + * profile's projection of Astryx's `large`, which the contract keeps body + * text (its headings are the display types), so it stays a paragraph. Every + * variant breaks a long unbroken word instead of overflowing at 320 px + * (1.4.10). * * Text may also carry nested children (the contract's "arbitrary children" * composition, which generating models use routinely); they render after the @@ -18,10 +21,10 @@ import type { CSSProperties, FC } from "react"; import { Text } from "@astryxdesign/core/Text"; import { childIds } from "@dspack-studio/a2ui-ingest"; -const VARIANT_TO_TYPE: Record = { +const VARIANT_TO_TYPE: Record = { h1: { type: "display-2", as: "h1", level: 1, weight: "bold" }, h2: { type: "display-3", as: "h2", level: 2, weight: "semibold" }, - h3: { type: "large", as: "h3", level: 3, weight: "semibold" }, + h3: { type: "large", as: "p", weight: "semibold" }, body: { type: "body", as: "p" }, caption: { type: "supporting", as: "span" }, }; @@ -31,9 +34,10 @@ const WRAP: CSSProperties = { overflowWrap: "anywhere" }; export const TextRender: FC = ({ props, buildChild }) => { const m = VARIANT_TO_TYPE[props.variant as string] ?? VARIANT_TO_TYPE.body; const nested = childIds(props.children); + const heading = m.level !== undefined && String(props.text ?? "").trim() !== ""; if (nested.length === 0) { return ( - + {props.text} ); @@ -43,7 +47,7 @@ export const TextRender: FC = ({ props, buildChild }) => { // contain them. A heading's own text keeps its heading role. return ( - {m.level && props.text ? ( + {heading ? ( {props.text} From 320374904542d5911f507ecd3ce7733a2462b767 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 02:06:33 +0000 Subject: [PATCH 11/20] Phase 2 step 6: in the Astryx canvas, nothing overlaps, overflows or is cut off, and fields keep focus in forced colors Review findings, each caught by the new tests: - Table regions overlapped their neighbours. Astryx bleeds a table's scroll region 16 px on every side; main's rule zeroing the vertical bleed matched only the Studio's [data-canvas], never Composer's canvas. guide-steps' two tables overlapped by 20 px, hiding a row behind the next header, and every table region overlapped the element after it by 4 px. The rule now matches [data-astryx-canvas] too. - An inline Astryx dialog (400 px, capped at 90vw) was wider than a 320 px canvas once surfaces narrowed with it: its edge and buttons were cut off. It now fits its container. - Astryx keeps a button's label on one line with an ellipsis. With WCAG's text spacing at 320 px, "Continue with Microsoft" was cut off (SC 1.4.12). Labels wrap. Astryx's height for the button's size is a minimum, and the vertical padding is what one line leaves of it. So a one-line button keeps Astryx's height (32 px, measured), and a wrapped one grows. - In forced colors an Astryx field's focus (an accent border and an inset shadow) disappeared, leaving only the caret. A focused field gets an outline there. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R6Ax8qvfN3gYUC85oSGnoi --- apps/composer/app/globals.css | 47 ++++++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/apps/composer/app/globals.css b/apps/composer/app/globals.css index acc4f28..fc9f76c 100644 --- a/apps/composer/app/globals.css +++ b/apps/composer/app/globals.css @@ -225,10 +225,14 @@ input[type="range"] { affordance so a lone table spans a padded Card edge to edge. The horizontal bleed is wanted; the vertical bleed is not, because two tables stacked in a Column (VStack gap 12px) then compute 12 - 16 - 16 - = -20px and overlap (the recipe's ingredients and instructions tables). + = -20px and overlap (the recipe's ingredients and instructions tables), + and the element after a table covers its last 4px (WCAG 1.4.10). This zeroes only the vertical bleed, keeping the horizontal full-bleed. - globals.css is loaded unlayered so it wins over Astryx's layered sheets. */ -[data-canvas] .astryx-table-scroll-wrapper { + The Studio's canvas is [data-canvas]; Composer's Astryx canvas is + [data-astryx-canvas]. globals.css is loaded unlayered so it wins over + Astryx's layered sheets. */ +[data-canvas] .astryx-table-scroll-wrapper, +[data-astryx-canvas] .astryx-table-scroll-wrapper { margin-top: 0; margin-bottom: 0; } @@ -251,6 +255,32 @@ input[type="range"] { min-width: 0; } +/* What narrows with the canvas must fit it. Astryx draws an inline dialog + 400 px wide, capped at 90% of the viewport, wider than a 320 px canvas: + its edge and buttons were cut off. And Astryx keeps a button's label on + one line, cut off with an ellipsis: with WCAG's text spacing at 320 px, + "Continue with Microsoft" lost its last word (SC 1.4.12). Here a dialog + fits its container, and a label wraps. Astryx fixes a button's height + for its size; here that height is a minimum, and the vertical padding is + what one line leaves of it, so a one-line button keeps Astryx's height + and a wrapped one grows by its extra lines. */ +[data-astryx-canvas] .astryx-dialog { + max-width: 100%; +} +[data-astryx-canvas] .astryx-button { + --af-button-height: var(--size-element-md, 32px); + white-space: normal; + height: auto; + min-height: var(--af-button-height); + padding-block: max(0px, (var(--af-button-height) - 1lh) / 2); +} +[data-astryx-canvas] .astryx-button[data-size="sm"] { + --af-button-height: var(--size-element-sm, 28px); +} +[data-astryx-canvas] .astryx-button[data-size="lg"] { + --af-button-height: var(--size-element-lg, 36px); +} + /* Keyboard focus in the rendered Astryx canvas keeps Astryx's own indicators: its accent ring on buttons and its accent border on fields (#0064E0, 5.4:1 against the canvas white). The studio's green ring above @@ -268,6 +298,17 @@ input[type="range"] { outline-offset: -2px; } +/* In forced colors (Windows high contrast) the system draws every color + and drops shadows, so an Astryx field's focus, an accent border and an + inset shadow, no longer showed: only the caret did (WCAG 2.4.7). There + a focused field gets an outline, which forced colors keep. */ +@media (forced-colors: active) { + [data-astryx-canvas] .astryx-text-input:focus-within { + outline: 2px solid Highlight; + outline-offset: 2px; + } +} + @media (prefers-reduced-motion: reduce) { .st-btn, .st-link { From ec1a3e93e1c2c548c66386ff6c11cf52e9309da5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 02:06:33 +0000 Subject: [PATCH 12/20] Phase 2 step 6: a narrow or short window lets the composer header scroll away At 320 x 256 (400% zoom of a 1280 x 1024 window) the sticky header was 255 px tall, its nav wrapped, and it covered the whole viewport: every focused control was hidden under it (WCAG 2.2 SC 2.4.11), in Build and Preview. Under 720 px wide, where the nav wraps, or under 600 px tall, the header is static and scrolls away with the page. The scroll padding that keeps focus clear of the header applies only while the header is sticky. A resize re-checks it, since the header's position can change without its size changing (1280 x 900 sticky, 1280 x 500 static, measured). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R6Ax8qvfN3gYUC85oSGnoi --- apps/composer/app/af-ui.css | 10 ++++++++++ apps/composer/app/composer.tsx | 8 ++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/composer/app/af-ui.css b/apps/composer/app/af-ui.css index a1d79e2..7754098 100644 --- a/apps/composer/app/af-ui.css +++ b/apps/composer/app/af-ui.css @@ -611,6 +611,16 @@ button.af-choice { } } +/* A narrow or short window lets the header scroll away. Under 720 px its + nav wraps (255 px tall at 320 px wide); kept sticky, it covered most of a + short window, and all of one at 320 x 256 (400% zoom of 1280 x 1024), + hiding every control focus moved to (WCAG 2.2 SC 2.4.11). */ +@media (max-width: 720px), (max-height: 600px) { + .af-top { + position: static; + } +} + @media (prefers-reduced-motion: reduce) { .af-nav__link::after, .af-card, diff --git a/apps/composer/app/composer.tsx b/apps/composer/app/composer.tsx index 87e8eca..ecdb2c0 100644 --- a/apps/composer/app/composer.tsx +++ b/apps/composer/app/composer.tsx @@ -61,20 +61,24 @@ function Shell() { // Keep keyboard focus clear of the sticky header (WCAG 2.2 SC 2.4.11): the // browser scrolls a focused control into view below the root's // scroll-padding. The header's height changes with the viewport and with - // how its nav wraps (about 60 px wide, 255 px at 320 px), so it is measured. + // how its nav wraps, so it is measured. A narrow or short window lets the + // header scroll away (af-ui.css), and then there is nothing to clear; that + // follows a media query, so a resize re-checks it too. const headerRef = useRef(null); useEffect(() => { const header = headerRef.current; if (!header || typeof ResizeObserver === "undefined") return; const root = document.documentElement; const pad = () => { - root.style.scrollPaddingTop = `${header.offsetHeight + 8}px`; + root.style.scrollPaddingTop = getComputedStyle(header).position === "sticky" ? `${header.offsetHeight + 8}px` : ""; }; pad(); const observer = new ResizeObserver(pad); observer.observe(header); + window.addEventListener("resize", pad); return () => { observer.disconnect(); + window.removeEventListener("resize", pad); root.style.scrollPaddingTop = ""; }; }, []); From 09f4e60d317270a1eb3d29f6cda19f403cc34533 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 02:10:40 +0000 Subject: [PATCH 13/20] Phase 2 step 6: the accessibility record after the review docs/blocks/ACCESSIBILITY.md: - The checks, with exact sizes: 1280 x 900, 640 x 512, 320 x 256 and 320 x 720. Also overlap, forced colors, crushed columns, covered text, and both Preview specs. - The review's fixes, marked "review", and the fail-first counts. - The review's corrections. The table names are 2.4.6, not 4.1.2. The table region had the studio's ring, not none. The first round's sizes were 640 x 450 and 320 x 720. The Studio exhibit gets the renderer changes only, not Composer's canvas CSS or shell. - "Still needs a person" gains TalkBack, desktop-height zoom, text-only zoom, Windows forced colors beyond Chromium's emulation, landscape (1.3.4), speech input (2.5.3), the guide-steps overlap by eye, and Astryx surfaces that are not blocks. - Outside the blocks, for information: axe finds aria-required-children on the plan-comparison reference, unchanged by this step. The release plan's step 6 note and docs/BLOCKS.md name the added checks. Two CSS comments are corrected: the header is over 200 px tall at 320 px wide (223 px measured with a short project name, not a fixed 255), and a table overlaps the elements on both sides of it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R6Ax8qvfN3gYUC85oSGnoi --- apps/composer/app/af-ui.css | 9 +++-- apps/composer/app/globals.css | 5 ++- docs/BLOCKS.md | 2 +- docs/blocks/ACCESSIBILITY.md | 59 ++++++++++++++++++----------- docs/blocks/PHASE-2-RELEASE-PLAN.md | 2 +- 5 files changed, 46 insertions(+), 31 deletions(-) diff --git a/apps/composer/app/af-ui.css b/apps/composer/app/af-ui.css index 7754098..d51d485 100644 --- a/apps/composer/app/af-ui.css +++ b/apps/composer/app/af-ui.css @@ -611,10 +611,11 @@ button.af-choice { } } -/* A narrow or short window lets the header scroll away. Under 720 px its - nav wraps (255 px tall at 320 px wide); kept sticky, it covered most of a - short window, and all of one at 320 x 256 (400% zoom of 1280 x 1024), - hiding every control focus moved to (WCAG 2.2 SC 2.4.11). */ +/* A narrow or short window lets the header scroll away. At 720 px or + less its nav takes a row of its own (over 200 px tall at 320 px wide); + kept sticky, it covered most of a short window, and nearly all of one at + 320 x 256 (400% zoom of 1280 x 1024), hiding every control focus moved + to (WCAG 2.2 SC 2.4.11). */ @media (max-width: 720px), (max-height: 600px) { .af-top { position: static; diff --git a/apps/composer/app/globals.css b/apps/composer/app/globals.css index fc9f76c..73f2b3e 100644 --- a/apps/composer/app/globals.css +++ b/apps/composer/app/globals.css @@ -225,8 +225,9 @@ input[type="range"] { affordance so a lone table spans a padded Card edge to edge. The horizontal bleed is wanted; the vertical bleed is not, because two tables stacked in a Column (VStack gap 12px) then compute 12 - 16 - 16 - = -20px and overlap (the recipe's ingredients and instructions tables), - and the element after a table covers its last 4px (WCAG 1.4.10). + = -20px and overlap (the recipe's ingredients and instructions tables; + guide-steps' tables, where a row hid behind the next header), and a + table overlaps the elements next to it by 4px. This zeroes only the vertical bleed, keeping the horizontal full-bleed. The Studio's canvas is [data-canvas]; Composer's Astryx canvas is [data-astryx-canvas]. globals.css is loaded unlayered so it wins over diff --git a/docs/BLOCKS.md b/docs/BLOCKS.md index d40278d..25e8f8d 100644 --- a/docs/BLOCKS.md +++ b/docs/BLOCKS.md @@ -204,7 +204,7 @@ Two rules apply in every experimental build. A block is offered only when it and - **The diagnostics export.** Settings saves, only when asked, a file of the call records, timings, outcomes and block-trace shape of the builds listed in Build, with no prompt, schema, model text, surface content, credential, entered value, action parameter or local model name. It sends nothing. - **The canary and alarms** are documented, not run. Field data is never ADR-B3 decision evidence. -**Accessibility (step 6; [blocks/PHASE-2-RELEASE-PLAN.md](blocks/PHASE-2-RELEASE-PLAN.md), §10).** The target is WCAG 2.2 AA. `e2e/composer-blocks-a11y.spec.ts` checks each Astryx block surface on example and boundary calls: axe with the WCAG 2.0, 2.1 and 2.2 A and AA tags, headings, table names, keyboard order and focus, target size, text spacing, 200% zoom and 320 px reflow. The fixes are in the shared Astryx renderers and the composer, so the default build carries them too. What the checks prove, what changed, and what still needs a person (every screen-reader pass among them) are in [blocks/ACCESSIBILITY.md](blocks/ACCESSIBILITY.md). auth-form ships under its SC 1.3.5 exception and host mitigation ([blocks/BETA.md](blocks/BETA.md)). +**Accessibility (step 6; [blocks/PHASE-2-RELEASE-PLAN.md](blocks/PHASE-2-RELEASE-PLAN.md), §10).** The target is WCAG 2.2 AA. `e2e/composer-blocks-a11y.spec.ts` checks each Astryx block surface on example and boundary calls: axe with the WCAG 2.0, 2.1 and 2.2 A and AA tags, headings, table names, keyboard order and focus (in forced colors too), target size, text spacing, overlapping content, and reflow at 200% and 400% zoom and 320 px. The fixes are in the shared Astryx renderers and the composer, so the default build carries them too. What the checks prove, what changed, and what still needs a person (every screen-reader pass among them) are in [blocks/ACCESSIBILITY.md](blocks/ACCESSIBILITY.md). auth-form ships under its SC 1.3.5 exception and host mitigation ([blocks/BETA.md](blocks/BETA.md)). ## The live evaluation (Phase 1B) diff --git a/docs/blocks/ACCESSIBILITY.md b/docs/blocks/ACCESSIBILITY.md index 1b4c7fd..9746c4e 100644 --- a/docs/blocks/ACCESSIBILITY.md +++ b/docs/blocks/ACCESSIBILITY.md @@ -12,42 +12,47 @@ It covers the three approved blocks (records-table, auth-form and guide-steps) a - every optional prop at its maximum length and count; - the same, with every string one unbroken token (a URL, an id). -For each of the 12 surfaces it checks: +The sizes are 1280 x 900, then 640 x 512 and 320 x 256 (200% and 400% zoom of a 1280 x 1024 window) and 320 x 720 (a tall phone). For each of the 12 surfaces it checks: | Check | WCAG 2.2 | How | |---|---|---| -| axe at 1280, 640 and 320 px, and with a dark preference, over the whole canvas | A and AA, through the tags `wcag2a`, `wcag2aa`, `wcag21a`, `wcag21aa` and `wcag22aa` (target size included) | Contrast that axe leaves undecided (scrolled or overlapping content, a decorative separator) is decided from computed colors, by the same thresholds | +| axe at every size, and with a dark preference, over the whole canvas | A and AA, through the tags `wcag2a`, `wcag2aa`, `wcag21a`, `wcag21aa` and `wcag22aa` (target size included) | Contrast that axe leaves undecided (scrolled content, a decorative separator) is decided from computed colors, by the same thresholds, compositing a translucent color over what lies beneath it. Text that another element in the canvas covers fails | | The title is the surface's one heading, at level 2 | 1.3.1, 2.4.6 | The accessibility tree | -| Each table's scrollable region is named by its columns | 2.4.6, 4.1.2 | The accessibility tree | +| Each table's scrollable region is named by its columns | 2.4.6 | The accessibility tree | | Tab and Shift+Tab reach every control in order, by name, and leave the surface at both ends | 2.1.1, 2.1.2, 2.4.3 | Key presses | | Every focused control shows an indicator of its own, of 3:1 or more against what it touches | 1.4.11, 2.4.7 | Computed styles at each stop, never the browser's default ring | -| No focused control is entirely hidden, at 1280, 640 and 320 px | 2.4.11 | Hit-testing at each stop | +| In forced colors, at 1280 px, every focused control keeps an outline, which the system can draw | 2.4.7 | Chromium's forced-colors emulation | +| No focused control is entirely hidden, at every size | 2.4.11 | Hit-testing at each stop | | Targets are 24 by 24 px or larger, and pressing a field's edge focuses its input | 2.5.8 | Bounding boxes and clicks | -| Text spacing cuts nothing off and pushes nothing out | 1.4.12 | WCAG's test values | -| At 640 px (200% zoom) and 320 px, nothing scrolls sideways or is cut off; a data table may scroll inside its own focusable region | 1.4.4, 1.4.10 | Layout measurement | +| Text spacing, at every size, cuts nothing off, pushes nothing out and draws nothing over other content | 1.4.12 | WCAG's test values | +| At every size, nothing scrolls sideways, is cut off or is drawn over other content, and no table column is narrower than 90 px; a data table may scroll inside its own focusable region | 1.4.4, 1.4.10 | Layout measurement: each Astryx stack's children, through `display: contents` wrappers, must not overlap | -A further spec checks an accepted block in Preview: its heading, axe, and Astryx's focus ring. Every check is soft, so one run reports every defect. +Two further specs check Preview, which the canvas rules reach too. One checks an accepted block: its heading, axe, Astryx's focus ring, and reflow and the keyboard pass at the narrow sizes. The other checks every Astryx reference surface, for layout only: nothing cut off, outside the canvas or drawn over, at every size. Every check is soft, so one run reports every defect. -`apps/composer/app/astryx-renderers-a11y.test.ts` server-renders the Astryx renderers: heading elements, table names, header and badge wrapping, long-word wrapping and the column floor. +`apps/composer/app/astryx-renderers-a11y.test.ts` server-renders the Astryx renderers: heading elements (h1 and h2, never an empty one, and h3 a paragraph), table names, header and badge wrapping, long-word wrapping, and the column floor in both table modes. ## What changed -Run against main first, the e2e failed all 13 Astryx specs, and the renderer tests failed 8 of 10; a column-floor test and the dark pass were then added, and failed before their fixes. Each defect and its fix: +Run against main first, the e2e failed all 13 Astryx specs, and the renderer tests failed 8 of 10; a column-floor test and the dark pass were then added, and failed before their fixes. An independent accessibility review of that work found defects the checks could not see. Checks for them were added, and failed against the reviewed head (all 14 Astryx specs, and 3 of 13 renderer tests), before the fixes the table marks "review". Each defect and its fix: | Defect on main | WCAG 2.2 | Fix | Where | |---|---|---|---| -| Block titles were `

`, so no surface had a heading | 1.3.1, 2.4.6 | Heading variants render h1 to h3; a container heading keeps `role="heading"` on its own text | `packages/astryx-renderers/src/components/TextRender.tsx` | -| Every table region was named "Table" | 2.4.6, 4.1.2 | Each region is named by its columns | `TableRender.tsx` | +| Block titles were `

`, so no surface had a heading | 1.3.1, 2.4.6 | The display variants render h1 and h2, and a container heading keeps `role="heading"` on its own text. Without text, a variant stays a paragraph (review). h3 stays a paragraph too (review): it is Astryx's `large`, which the contract keeps body text, its headings being the display types | `packages/astryx-renderers/src/components/TextRender.tsx` | +| Every table region was named "Table" | 2.4.6 | Each region is named by its columns | `TableRender.tsx` | | Header text was cut off with an ellipsis at 320 px | 1.4.4, 1.4.10 | Header text wraps | `TableRender.tsx` | -| An 8-column table crushed its columns below a letter's width at 320 px, cutting text off | 1.4.10 | A 96 px column floor, through Astryx's `proportional()` widths, so the table scrolls inside its focusable region | `TableRender.tsx` | +| An 8-column table crushed its columns below a letter's width at 320 px, cutting text off; a children-mode table (a model's nested cells; no block uses one) did the same | 1.4.10 | A 96 px column floor, so the table scrolls inside its focusable region: Astryx's `proportional()` widths in a data table, and the header cells' minimum width in a children-mode one (review) | `TableRender.tsx` | | Long words and labels ran out of the card (titles, intros, a guide's facts) | 1.4.10 | A word breaks when it must; a badge's label wraps | `TextRender.tsx`, `BadgeRender.tsx` | -| The auth form's inputs held the surface 24 px past a 320 px viewport | 1.4.10 | A surface narrows with the canvas | `apps/composer/app/globals.css`, Astryx canvas only | -| The studio's green focus ring was 2.41:1 against the canvas white | 1.4.11 | Astryx's own indicators (#0064E0, 5.4:1), and the same ring on the table region, which had none | `globals.css` | +| guide-steps' two tables overlapped by 20 px, hiding a row of the first behind the second's header, and every table region overlapped the elements next to it by 4 px. A rule removing Astryx's vertical table bleed matched only the Studio's canvas | 1.4.3, 1.4.10 | The rule matches the Astryx canvas too (review) | `apps/composer/app/globals.css` | +| The auth form's inputs held the surface 24 px past a 320 px viewport | 1.4.10 | A surface narrows with the canvas | `globals.css`, Astryx canvas only | +| An inline dialog made the page wider than a 320 px viewport (by 43 px for the delete-project reference, as the review measured it); once surfaces narrowed with the canvas, it was cut off instead | 1.4.10 | A dialog fits its container (review) | `globals.css` | +| A button's label stayed on one line, cut off with an ellipsis: with text spacing at 320 px, "Continue with Microsoft" lost a word | 1.4.12 | A label wraps, and its button grows; a one-line button keeps Astryx's height (review) | `globals.css` | +| The studio's green focus ring was 2.41:1 against the canvas white | 1.4.11 | Astryx's own indicators (#0064E0, 5.4:1), and an inset accent ring on the table region, for which Astryx draws none | `globals.css` | +| In forced colors a focused field showed only its caret: Astryx marks focus with a border color and a shadow, which forced colors replace | 2.4.7 | A focused field gets an outline in forced colors (review) | `globals.css` | | With a dark OS preference, Astryx's dark tokens drew inside the white canvas, where its primary button is 3.11:1 | 1.4.3 | The Astryx canvas draws light | `globals.css` | -| Shift+Tab left focused fields, buttons and short table regions entirely under the sticky header at 640 and 320 px | 2.4.11 | The root's scroll padding follows the header's measured height | `apps/composer/app/composer.tsx` | +| The sticky header hid focused controls: with Shift+Tab at 640 x 450 and 320 x 720, and every one at 320 x 256, where the header filled nearly all of the viewport | 2.4.11 | At 720 px wide or less, or 600 px tall or less, the header scrolls away (review); otherwise the root's scroll padding follows its measured height | `apps/composer/app/af-ui.css`, `composer.tsx` | | The Build options made the page 11 px too wide at 320 px | 1.4.10 | Each option wraps under its label | `apps/composer/app/views/build-view.tsx` | -All of these ship in the default build too: every Astryx surface in Preview gains them, and the shared renderer fixes reach the Studio exhibit, whose suite passes. None changes a block signature, a template, a prompt, model selection, a request or any evaluation evidence. +All of these ship in the default build too, and every Astryx surface in Preview gains them. The Studio exhibit (apps/web) gets the renderer changes only, not Composer's canvas CSS or shell; its suite passes. None changes a block signature, a template, a prompt, model selection, a request or any evaluation evidence. ## What this environment could and could not check by hand @@ -56,17 +61,21 @@ All of these ship in the default build too: every Astryx surface in Preview gain - records-table: the level-2 heading "Open support tickets"; the focusable group "Table: Ticket, Subject, Status, Priority", holding a table with its column headers; the button "Refresh". - auth-form: the level-2 heading "Create your account"; the textboxes "Full name Required", "Email address Required" and "Password Required", each marked required; the buttons "Create account" and "Continue with GitHub". - guide-steps: the level-2 heading "Weeknight pasta"; the groups "Table: Ingredients" and "Table: Step, Instruction"; the buttons "Start" and "Save guide". -- **Keyboard,** in Chromium only, by the test runner's key events rather than a person. A screenshot of each focused control was inspected: buttons show Astryx's 2 px blue ring, fields their blue border, and table regions an inset blue ring. +- **Keyboard,** in Chromium only, by the test runner's key events rather than a person. A screenshot of each focused control was inspected: buttons show Astryx's 2 px blue ring, fields their blue border, and table regions an inset blue ring. In Chromium's forced-colors emulation, a focused field shows its outline. +- **Narrow sizes,** in screenshots: at 320 px with text spacing, "Continue with Microsoft" wraps onto two lines inside its button, and the delete-project reference's dialog fits the canvas, its button label wrapping. ## Still needs a person -1. A screen-reader pass per block: NVDA with Firefox or Chrome on Windows, and VoiceOver with Safari on macOS and iOS. Listen for the heading, the table regions' names, table navigation, each field's name and required state, and the buttons. +1. A screen-reader pass per block: NVDA with Firefox or Chrome on Windows, VoiceOver with Safari on macOS and iOS, and TalkBack with Chrome on Android. Listen for the heading, the table regions' names, table navigation, each field's name and required state, and the buttons. 2. A keyboard pass in Firefox and Safari. Safari reaches buttons with Tab only when its "Press Tab to highlight each item" setting is on. -3. Real browser zoom at 200% and 400%, and text spacing with a bookmarklet, in more than one browser. -4. Windows forced colors (high contrast): that focus indicators and field boundaries survive. -5. Touch: target size, and scrolling a wide table's region, on a phone. -6. Input purpose by hand (SC 1.3.5): it fails today, under the exception in [BETA.md](BETA.md), and a host's mitigation must be checked in that host. -7. The judgements in "Proposed waivers". +3. Real browser zoom at 200% and 400% in more than one browser, at desktop window heights as well as the sizes above, and text spacing with a bookmarklet. Text-only zoom to 200% (Firefox's "Zoom text only", or a browser's minimum font size), which viewport sizes do not reproduce (SC 1.4.4). +4. Windows forced colors (high contrast), on Windows: that focus indicators and field boundaries survive. The automated pass uses Chromium's emulation. +5. Touch: target size, and scrolling a wide table's region, on a phone, in portrait and landscape (SC 1.3.4). +6. Speech input (Voice Control, Dragon): each control can be operated by saying its visible label (SC 2.5.3). +7. Input purpose by hand (SC 1.3.5): it fails today, under the exception in [BETA.md](BETA.md), and a host's mitigation must be checked in that host. +8. By eye, in Build and in Preview: guide-steps' two tables sit apart, and no row hides behind the next header. +9. Astryx surfaces that are not blocks: model-generated, imported and the reference surfaces. They share the renderers and the canvas rules, and only the reference surfaces' layout is checked automatically (below). +10. The judgements in "Proposed waivers". ## Proposed waivers @@ -88,6 +97,10 @@ The same spec scans each shadcn/ui v3 block's example and maximum call, records The contrast nodes are the wireframe fallback's Separator chips (1.19:1 and 1.26:1): shadcn/ui has no Separator renderer (the plan's §10). Narrowing the surface, as the Astryx canvas now does, would move shadcn's wide table into its own table container, which is not keyboard-focusable. That renderer fix is deferred. +## Outside the blocks (information) + +axe over each Astryx reference surface in Preview (the tags above, at 1280 px) finds one violation, unchanged by this step: on `ex.plan-comparison`, `aria-required-children` (critical). Its Astryx list, a `ul` with the role `list`, holds checkboxes and a definition list's `dt` and `dd`, where a list allows only list items. The List, SelectableCard and MetadataList renderers are unchanged here. It is not a block, so it is left for a separate change. + ## Re-running ``` diff --git a/docs/blocks/PHASE-2-RELEASE-PLAN.md b/docs/blocks/PHASE-2-RELEASE-PLAN.md index 803882e..0741d85 100644 --- a/docs/blocks/PHASE-2-RELEASE-PLAN.md +++ b/docs/blocks/PHASE-2-RELEASE-PLAN.md @@ -7,7 +7,7 @@ > - The owner authorized step 3 on 2026-10-16, limited to H1, H2 and H4 (§7), and it is built: the B0 action-id proof, the action manifest in the beta's Catalog export, and the host guide ([HOST-ACTIONS.md](HOST-ACTIONS.md)). The Catalog export that §7 locates at `exportBundle` in `preview-view.tsx` is now built by `catalogBundle` in `apps/composer/app/catalog-export.ts`. The manifest reads each surface's actions with `actionEvents` (`packages/composer-core/src/blocks/actions.ts`), which also reads each context, rather than `emittedActionNames`. H3, the Blocks catalog view and Preview's label, belongs to step 4. > - The owner authorized step 4 on 2026-10-16: the read-only Blocks catalog and provenance UI (§7 H3, §8), and the first three host-guide clarifications from step 3's review. It is built: a Blocks section in the Catalog view; in Preview, a surface's provenance ("made with block X vN", only by exact expansion identity) with its status and specific reason, and the note that block actions are host-owned and not executed here; and §8's lifecycle, with an Example session's save, its duplicate and every import now pruning stale provenance too. Refinable states what a record is, not that a build will use the block. Nothing grants an action anything, and the default build is unchanged. > - The owner authorized step 5 on 2026-10-16: receipts and operator signals (§9). It is built ([BETA-OPERATIONS.md](BETA-OPERATIONS.md)): the beta receipt (block and surface tokens apart, provider-reported only; each call's own served model with the top-level fallback; the exact budget; the refinement mode in words), the Worker's shape-only operator log behind `BETA_OPERATOR_LOG`, which production never sets, and a diagnostics export the user asks for. It takes decision 7's recommended course (§13): no beacon or client telemetry. The canary and alarm rules are documented; no hosted canary has run, and the canary first needs the harness to send the beta's Access service token. Field data is never ADR-B3 decision evidence. Production's request bytes are unchanged, and its default build carries none of this code. -> - The owner authorized step 6 on 2026-10-16: accessibility hardening for the Astryx beta path (§10), to WCAG 2.2 AA. It is built ([ACCESSIBILITY.md](ACCESSIBILITY.md)): an e2e runs axe with the WCAG 2.0, 2.1 and 2.2 A and AA tags over each rendered Astryx block, on example and boundary calls, with no canvas exclusion, and checks headings, table names, keyboard order, focus indicators, focus never hidden, target size, text spacing, 200% zoom and 320 px reflow. Its fixes reach the default build too: §10's note that the Astryx `TextRender` renders headings as `

` no longer holds, table regions are named by their columns, header and long text wrap, the Astryx canvas keeps Astryx's focus indicators and draws light, and the composer keeps focus clear of its sticky header. auth-form's SC 1.3.5 exception and its host mitigation are in [BETA.md](BETA.md). No screen-reader pass has been done; the checks still needing a person, and the waivers proposed to the owner, are in [ACCESSIBILITY.md](ACCESSIBILITY.md). The shadcn/ui v3 scans are information only. +> - The owner authorized step 6 on 2026-10-16: accessibility hardening for the Astryx beta path (§10), to WCAG 2.2 AA. It is built ([ACCESSIBILITY.md](ACCESSIBILITY.md)): an e2e runs axe with the WCAG 2.0, 2.1 and 2.2 A and AA tags over each rendered Astryx block, on example and boundary calls, with no canvas exclusion, and checks headings, table names, keyboard order, focus indicators (in forced colors too), focus never hidden, target size, text spacing, content drawn over other content, and reflow at 200% and 400% zoom and 320 px. Its fixes reach the default build too: §10's note that the Astryx `TextRender` renders headings as `

` no longer holds for the display variants (h3, Astryx's body-scale `large`, stays a paragraph), table regions are named by their columns, header text, long text and button labels wrap, stacked tables no longer overlap, the Astryx canvas keeps Astryx's focus indicators and draws light, and the composer's header scrolls away in a narrow or short window and otherwise keeps focus clear of itself. auth-form's SC 1.3.5 exception and its host mitigation are in [BETA.md](BETA.md). No screen-reader pass has been done; the checks still needing a person, and the waivers proposed to the owner, are in [ACCESSIBILITY.md](ACCESSIBILITY.md). The shadcn/ui v3 scans are information only. Final version, 2026-10-16. The owner accepted the plan on 2026-10-15 as the conditional working plan for the next AF Composer release, and this version carries the owner's three clarifications (below) and the shadcn/ui v3 result. It is not authorization to begin Phase 2: nothing in it starts until the owner authorizes it. No code. From 38082fec429717dea0b8c6237cc56f19bdf818ad Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 03:06:22 +0000 Subject: [PATCH 14/20] Phase 2 step 6: fail-first checks for the re-review's findings The re-review of 09f4e60 found the spec's "nothing drawn over" claim broader than its checks, and an inline dialog cut off at 320 x 256 that no check saw. These checks close both gaps: - Text, line by line: every line of text in the canvas is sampled across its width and height, after scrolling it into view. It fails when another element in the canvas draws over it, or when a box that does not scroll cuts it off. Text in a table's region may be scrolled out of view, and screen-reader-only text is skipped. It runs with every reflow measurement: 1280 px, every narrow size, and with text spacing at each. - A box other than a table's region that scrolls its own content fails. - Contrast axe leaves undecided is decided at every size and in dark, not only at 1280 px in light. - Preview: the accepted block gets text spacing, axe and the contrast rule at each narrow size. Every Astryx reference surface gets axe, the contrast rule, reflow and text spacing at every size. One violation outside the blocks that predates step 6 is allowed by name: plan-comparison's aria-required-children, recorded in ACCESSIBILITY.md. Against 09f4e60's build, only the reference spec fails: the delete-project dialog at 320 x 256 scrolls its content inside a 108 px box, with and without text spacing, and axe reports scrollable-region-focusable on it. Every block surface passes the new checks. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R6Ax8qvfN3gYUC85oSGnoi --- e2e/composer-blocks-a11y.spec.ts | 128 +++++++++++++++++++++++++------ 1 file changed, 103 insertions(+), 25 deletions(-) diff --git a/e2e/composer-blocks-a11y.spec.ts b/e2e/composer-blocks-a11y.spec.ts index 39474a2..b99d5a3 100644 --- a/e2e/composer-blocks-a11y.spec.ts +++ b/e2e/composer-blocks-a11y.spec.ts @@ -16,9 +16,10 @@ * * The calls are each block's approved example and three boundary calls: the * smallest valid call, every optional prop at its maximum length and count, - * and the same with every string one unbroken token (a URL, an id). Preview - * gets the same checks, for an accepted block and for every Astryx reference - * surface, since the canvas rules reach all of them. + * and the same with every string one unbroken token (a URL, an id). Preview, + * which the canvas rules reach too, gets a subset: an accepted block's + * heading, axe, focus ring, layout and keyboard pass, and every Astryx + * reference surface's axe and layout at every size. * * The shadcn/ui v3 scans at the end only report and never fail: shadcn/ui v3 * blocks are withheld from the beta. @@ -207,7 +208,60 @@ const INSTALL_HELPERS = () => { const [x, y] = [lum(over(c, bg)), lum(bg)]; return Math.round(((Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05)) * 100) / 100; }; - (window as any).__a11y = { contrastOver }; + /** + * Every line of text in a canvas, sampled across its width and height: + * text that another element in the canvas draws over, or that a box which + * does not scroll cuts off. Text in a scroll container (a table's region) + * may be scrolled out of view; text a screen-reader-only style hides is + * skipped. Each text is scrolled into view first. + */ + const coveredText = (sel: string): string[] => { + const canvas = document.querySelector(sel)!; + const describe = (el: Element) => `${el.tagName.toLowerCase()}.${String(el.className).split(" ")[0]} "${(el.textContent ?? "").trim().slice(0, 20)}"`; + const visuallyHidden = (el: Element) => { + for (let a: Element | null = el; a && a !== canvas; a = a.parentElement) { + if (getComputedStyle(a).display === "contents") continue; + const r = a.getBoundingClientRect(); + if (r.width <= 1 || r.height <= 1) return true; + } + return false; + }; + const out = new Set(); + const walker = document.createTreeWalker(canvas, NodeFilter.SHOW_TEXT); + for (let n = walker.nextNode(); n; n = walker.nextNode()) { + const text = (n.textContent ?? "").trim().slice(0, 30); + const p = n.parentElement; + if (!text || !p || visuallyHidden(p)) continue; + const range = document.createRange(); + range.selectNodeContents(n); + const lines = () => Array.from(range.getClientRects()).filter((r) => r.width > 2 && r.height > 2); + const first = lines()[0]; + if (!first) continue; + if (first.top < 0 || first.bottom > innerHeight || first.left < 0 || first.right > innerWidth) p.scrollIntoView({ block: "center", inline: "nearest" }); + for (const r of lines()) + for (const fx of [0.1, 0.5, 0.9]) + for (const fy of [0.25, 0.5, 0.75]) { + const [x, y] = [r.left + r.width * fx, r.top + r.height * fy]; + if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) continue; + const hit = document.elementFromPoint(x, y); + if (!hit || hit === p || p.contains(hit)) continue; + if (hit.contains(p)) { + // The point shows an ancestor: the text is cut off there, by the nearest box that clips it. + for (let a: Element | null = p; a && a !== hit; a = a.parentElement) { + const cs = getComputedStyle(a); + const box = a.getBoundingClientRect(); + if ((cs.overflowX === "visible" && cs.overflowY === "visible") || (x >= box.left && x <= box.right && y >= box.top && y <= box.bottom)) continue; + if (!/auto|scroll/.test(cs.overflowX + cs.overflowY)) out.add(`"${text}" is cut off by ${describe(a)}`); + break; + } + continue; + } + if (canvas.contains(hit)) out.add(`"${text}" is drawn over by ${describe(hit)}`); + } + } + return [...out].slice(0, 12); + }; + (window as any).__a11y = { contrastOver, coveredText }; }; /** axe over one canvas, with violations made readable. */ @@ -357,10 +411,11 @@ async function traverse(page: Page, stops: Array, { names, at, /** * What would make a reader scroll sideways or lose content: the page wider * than the viewport; anything in the canvas outside the canvas, except what a - * table's own scroll region holds; text cut off by an element that hides its - * overflow; one part of a surface drawn over the next (each Astryx stack's - * children, through display: contents wrappers); and a table column crushed - * below its floor. + * table's own scroll region holds; an element that hides its overflow and + * cuts content off; a box other than a table's region that scrolls its own + * content; one part of a surface drawn over the next (each Astryx stack's + * children, through display: contents wrappers); text drawn over or cut off, + * line by line; and a table column crushed below its floor. */ async function reflow(page: Page, sel = CANVAS) { return page.evaluate((sel) => { @@ -371,15 +426,19 @@ async function reflow(page: Page, sel = CANVAS) { const describe = (el: Element) => `${el.tagName.toLowerCase()}.${String(el.className).split(" ")[0]} "${(el.textContent ?? "").trim().slice(0, 30)}"`; const outside: string[] = []; const clipped: string[] = []; + const scrollers: string[] = []; for (const el of Array.from(canvas.querySelectorAll("*"))) { const r = el.getBoundingClientRect(); if (r.width === 0 && r.height === 0) continue; if (!inRegion(el) && (r.right > c.right + 1 || r.left < c.left - 1)) outside.push(describe(el)); const cs = getComputedStyle(el); if (regions.includes(el)) continue; // the region scrolls; its content is checked by the rules above and below - const hidesX = cs.overflowX === "hidden" || cs.overflowX === "clip"; - const hidesY = cs.overflowY === "hidden" || cs.overflowY === "clip"; - if ((hidesX && el.scrollWidth > el.clientWidth + 1) || (hidesY && el.scrollHeight > el.clientHeight + 1)) clipped.push(describe(el)); + const overX = el.scrollWidth > el.clientWidth + 1; + const overY = el.scrollHeight > el.clientHeight + 1; + const hides = (o: string) => o === "hidden" || o === "clip"; + const scrolls = (o: string) => o === "auto" || o === "scroll"; + if ((hides(cs.overflowX) && overX) || (hides(cs.overflowY) && overY)) clipped.push(describe(el)); + if ((scrolls(cs.overflowX) && overX) || (scrolls(cs.overflowY) && overY)) scrollers.push(describe(el)); } const laidOut = (el: Element): Element[] => Array.from(el.children).flatMap((k) => (getComputedStyle(k).display === "contents" ? laidOut(k) : [k])); @@ -401,22 +460,33 @@ async function reflow(page: Page, sel = CANVAS) { canvasOverflow: canvas.scrollWidth - canvas.clientWidth, outside: outside.slice(0, 8), clipped: clipped.slice(0, 8), + scrollers: scrollers.slice(0, 8), overlaps: overlaps.slice(0, 8), crushed: crushed.slice(0, 8), + // Last, since it scrolls each text into view. + covered: (window as any).__a11y.coveredText(sel) as string[], }; }, sel); } -const FITS = { pageOverflow: 0, canvasOverflow: 0, outside: [], clipped: [], overlaps: [], crushed: [] }; +const FITS = { pageOverflow: 0, canvasOverflow: 0, outside: [], clipped: [], scrollers: [], overlaps: [], crushed: [], covered: [] }; /** WCAG's text-spacing test values (SC 1.4.12). */ const TEXT_SPACING = "* { line-height: 1.5 !important; letter-spacing: 0.12em !important; word-spacing: 0.16em !important; } p { margin-bottom: 2em !important; }"; -/** Reflow with WCAG's text spacing applied: nothing cut off, pushed out or drawn over. */ +/** Reflow with WCAG's text spacing applied: nothing cut off, pushed out, scrolled in a box or drawn over. */ async function spaced(page: Page, sel = CANVAS) { const style = await page.addStyleTag({ content: TEXT_SPACING }); const r = await reflow(page, sel); await style.evaluate((e) => (e as Element).remove()); - return { clipped: r.clipped, outside: r.outside, overlaps: r.overlaps }; + return { clipped: r.clipped, outside: r.outside, scrollers: r.scrollers, overlaps: r.overlaps, covered: r.covered }; +} +const SPACED_FITS = { clipped: [], outside: [], scrollers: [], overlaps: [], covered: [] }; + +/** axe's violations, and every contrast it left undecided, decided here (see undecidedContrast). */ +async function axeAndContrast(page: Page, at: string, include = CANVAS) { + const scan = await axeOn(page, include); + expect.soft(scan.violations, `axe ${at}`).toEqual([]); + for (const node of await undecidedContrast(page, scan.incomplete as never, include)) expect.soft(node.ratio, `contrast axe left undecided ${at}: ${node.sel}`).toBeGreaterThanOrEqual(node.needed); } /** Every keyboard stop's target, at least 24 by 24 px; a field's target is the field around its input, and pressing it focuses the input. */ @@ -469,9 +539,7 @@ for (const blockId of ["records-table", "auth-form", "guide-steps"]) { expect.soft(regions, "table names").toEqual(stops.filter((s): s is string => typeof s === "string" && s.startsWith("Table: "))); // axe at 1280 px, every contrast it could not decide, and layout. - const desktop = await axeOn(page); - expect.soft(desktop.violations, "axe at 1280 px").toEqual([]); - for (const node of await undecidedContrast(page, desktop.incomplete as never)) expect.soft(node.ratio, `contrast axe left undecided: ${node.sel}`).toBeGreaterThanOrEqual(node.needed); + await axeAndContrast(page, "at 1280 px"); expect.soft(await reflow(page), "layout at 1280 px").toEqual(FITS); // Keyboard: every stop in order, a visible indicator with 3:1, no trap; targets of 24 px. @@ -480,7 +548,7 @@ for (const blockId of ["records-table", "auth-form", "guide-steps"]) { // Astryx's reset declares light and dark, so the surface follows a reader's dark preference: axe and focus again. await page.emulateMedia({ colorScheme: "dark" }); - expect.soft((await axeOn(page)).violations, "axe in dark").toEqual([]); + await axeAndContrast(page, "in dark"); await traverse(page, stops, { names: false, at: "1280 px, dark" }); // Forced colors (Windows high contrast): every stop keeps an indicator the system can draw. await page.emulateMedia({ colorScheme: "light", forcedColors: "active" }); @@ -488,7 +556,7 @@ for (const blockId of ["records-table", "auth-form", "guide-steps"]) { await page.emulateMedia({ forcedColors: "none" }); // Text spacing (SC 1.4.12) at 1280 px. - expect.soft(await spaced(page), "text spacing at 1280 px").toEqual({ clipped: [], outside: [], overlaps: [] }); + expect.soft(await spaced(page), "text spacing at 1280 px").toEqual(SPACED_FITS); // 200% and 400% zoom, and a tall 320 px screen: no sideways scrolling outside a table's region, nothing // cut off or drawn over (with text spacing too), axe still clean, and focus never hidden under the header. @@ -496,8 +564,8 @@ for (const blockId of ["records-table", "auth-form", "guide-steps"]) { const at = `${width} x ${height}`; await resize(page, width, height); expect.soft(await reflow(page), `reflow at ${at}`).toEqual(FITS); - expect.soft(await spaced(page), `text spacing at ${at}`).toEqual({ clipped: [], outside: [], overlaps: [] }); - expect.soft((await axeOn(page)).violations, `axe at ${at}`).toEqual([]); + expect.soft(await spaced(page), `text spacing at ${at}`).toEqual(SPACED_FITS); + await axeAndContrast(page, `at ${at}`); await traverse(page, stops, { names: false, at }); } }); @@ -535,12 +603,17 @@ test("Astryx: an accepted block surface keeps its heading, its names and its foc const at = `Preview at ${width} x ${height}`; await resize(page, width, height); expect.soft(await reflow(page, PREVIEW), at).toEqual(FITS); + expect.soft(await spaced(page, PREVIEW), `${at}, text spacing`).toEqual(SPACED_FITS); + await axeAndContrast(page, at, PREVIEW); await traverse(page, stops, { names: false, at, sel: PREVIEW }); } }); -test("Astryx: every reference surface in Preview fits 1280 and 320 px, with nothing cut off or drawn over", async ({ page }) => { - test.setTimeout(180_000); +/** A violation outside the blocks that predates step 6, recorded in docs/blocks/ACCESSIBILITY.md ("Outside the blocks"). */ +const KNOWN: Record = { "ex.plan-comparison": ["aria-required-children"] }; + +test("Astryx: every reference surface in Preview, at every size: axe, and nothing cut off, scrolled in a box or drawn over", async ({ page }) => { + test.setTimeout(240_000); await page.setViewportSize({ width: 1280, height: 900 }); await openProject(page, "astryx", "astryx references"); await page.getByTestId("nav-preview").click(); @@ -551,10 +624,15 @@ test("Astryx: every reference surface in Preview fits 1280 and 320 px, with noth await page.getByTestId(`surface-${id}`).click(); await expect(page.locator(PREVIEW)).toBeVisible(); for (const [width, height] of [[1280, 900], ...NARROW] as const) { + const at = `${id} at ${width} x ${height}`; await resize(page, width, height); - // A data table may scroll inside its region; this checks only that nothing is lost or drawn over. + // A data table may scroll inside its region; nothing else may scroll, be cut off or be drawn over. const r = await reflow(page, PREVIEW); - expect.soft({ outside: r.outside, clipped: r.clipped, overlaps: r.overlaps, pageOverflow: r.pageOverflow }, `${id} at ${width} x ${height}`).toEqual({ outside: [], clipped: [], overlaps: [], pageOverflow: 0 }); + expect.soft({ pageOverflow: r.pageOverflow, outside: r.outside, clipped: r.clipped, scrollers: r.scrollers, overlaps: r.overlaps, covered: r.covered }, at).toEqual({ pageOverflow: 0, outside: [], clipped: [], scrollers: [], overlaps: [], covered: [] }); + expect.soft(await spaced(page, PREVIEW), `${at}, text spacing`).toEqual(SPACED_FITS); + const scan = await axeOn(page, PREVIEW); + expect.soft(scan.violations.filter((v) => !(KNOWN[id] ?? []).includes(v.id)), `${at}: axe`).toEqual([]); + for (const node of await undecidedContrast(page, scan.incomplete as never, PREVIEW)) expect.soft(node.ratio, `${at}: contrast axe left undecided: ${node.sel}`).toBeGreaterThanOrEqual(node.needed); } } }); From a6ffba42ecc5e0edeef07b347a7cf2f0a217d76b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 03:14:37 +0000 Subject: [PATCH 15/20] Phase 2 step 6: inline dialogs show their whole content; the button rule spares icon-only buttons; the header scrolls away while its nav wraps The re-review's findings on 09f4e60: - F1. An inline dialog kept Astryx's 75vh height cap, and so did the Layout inside it, which reads --container-max-height. At 320 x 256 the delete-project dialog showed 108 px of its content in a scroll box of its own. axe flags that box as not keyboard-focusable (scrollable-region-focusable). In the canvas an inline dialog is part of the page, so it now takes its content's height. The new reference checks fail before this and pass after. - F3. The button rule's `height: auto` met Astryx's aspect-ratio on an icon-only button. A clone with Astryx's compiled icon-only classes was 976 x 976 in a stretching column, where Astryx draws it 976 x 32. The rule now applies only to a button named by its visible label. Astryx gives icon-only buttons and custom-content ones an aria-label and sizes them itself. Measured: 976 x 32 in a column, 32 x 32 in a row or a box, as in Astryx. - F4. A label's unbroken word now breaks where it would overflow (overflow-wrap: break-word). A 70-character word fills a full-width button in three lines (204 x 72). `anywhere` was tried and rejected: it let a row of buttons split "Cancel" into "Canc / el" at 320 px. The remainder is a word wider than its whole row, which only a model-written label could hold; it is recorded in ACCESSIBILITY.md. - F5. From 721 to 960 px wide the nav wraps by itself, and the sticky header was up to 325 px tall: 54% of a 601 px window. The header now scrolls away at 960 px wide or less, as well as at 600 px tall or less. Measured with a project open: static to 960 px, with no scroll padding; sticky from 961 px, 135 px tall, with its padding. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R6Ax8qvfN3gYUC85oSGnoi --- apps/composer/app/af-ui.css | 13 +++++++------ apps/composer/app/globals.css | 35 +++++++++++++++++++++++++---------- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/apps/composer/app/af-ui.css b/apps/composer/app/af-ui.css index d51d485..7c73d64 100644 --- a/apps/composer/app/af-ui.css +++ b/apps/composer/app/af-ui.css @@ -611,12 +611,13 @@ button.af-choice { } } -/* A narrow or short window lets the header scroll away. At 720 px or - less its nav takes a row of its own (over 200 px tall at 320 px wide); - kept sticky, it covered most of a short window, and nearly all of one at - 320 x 256 (400% zoom of 1280 x 1024), hiding every control focus moved - to (WCAG 2.2 SC 2.4.11). */ -@media (max-width: 720px), (max-height: 600px) { +/* A narrow or short window lets the header scroll away. At 960 px or + less its nav wraps: at 720 px or less onto a row of its own (over 200 px + tall at 320 px wide), and above that onto rows as it fits (up to 325 px + tall at 721 px with a project open). Kept sticky, it covered half of a + short window, and nearly all of one at 320 x 256 (400% zoom of + 1280 x 1024), hiding every control focus moved to (WCAG 2.2 SC 2.4.11). */ +@media (max-width: 960px), (max-height: 600px) { .af-top { position: static; } diff --git a/apps/composer/app/globals.css b/apps/composer/app/globals.css index 73f2b3e..e235159 100644 --- a/apps/composer/app/globals.css +++ b/apps/composer/app/globals.css @@ -258,27 +258,42 @@ input[type="range"] { /* What narrows with the canvas must fit it. Astryx draws an inline dialog 400 px wide, capped at 90% of the viewport, wider than a 320 px canvas: - its edge and buttons were cut off. And Astryx keeps a button's label on - one line, cut off with an ellipsis: with WCAG's text spacing at 320 px, - "Continue with Microsoft" lost its last word (SC 1.4.12). Here a dialog - fits its container, and a label wraps. Astryx fixes a button's height - for its size; here that height is a minimum, and the vertical padding is - what one line leaves of it, so a one-line button keeps Astryx's height - and a wrapped one grows by its extra lines. */ + its edge and buttons were cut off. It also caps the dialog's height at + 75% of the viewport and scrolls what does not fit: at 320 x 256 the + delete-project dialog showed 108 px of its content in a scroll box of its + own, which axe flags as not keyboard-focusable. An inline dialog in the + canvas is part of the page, so here it fits its container's width and + takes its content's height. + And Astryx keeps a button's label on one line, cut off with an ellipsis: + with WCAG's text spacing at 320 px, "Continue with Microsoft" lost its + last word (SC 1.4.12). Here a label wraps, and a word too long for its + line breaks (break-word, not anywhere, so a row of buttons never splits + a short word to squeeze them in). Astryx fixes a button's height for its size; here that height is a + minimum, and the vertical padding is what one line leaves of it, so a + one-line button keeps Astryx's height and a wrapped one grows by its + extra lines. Only a button named by its visible label: Astryx gives an + icon-only button (or one with custom content) an aria-label and sizes it + itself, so those keep Astryx's fixed size. */ [data-astryx-canvas] .astryx-dialog { max-width: 100%; } -[data-astryx-canvas] .astryx-button { +[data-astryx-canvas] .astryx-dialog, +[data-astryx-canvas] .astryx-dialog > * { + max-height: none; + --container-max-height: none; +} +[data-astryx-canvas] .astryx-button:not([aria-label]) { --af-button-height: var(--size-element-md, 32px); white-space: normal; + overflow-wrap: break-word; height: auto; min-height: var(--af-button-height); padding-block: max(0px, (var(--af-button-height) - 1lh) / 2); } -[data-astryx-canvas] .astryx-button[data-size="sm"] { +[data-astryx-canvas] .astryx-button:not([aria-label])[data-size="sm"] { --af-button-height: var(--size-element-sm, 28px); } -[data-astryx-canvas] .astryx-button[data-size="lg"] { +[data-astryx-canvas] .astryx-button:not([aria-label])[data-size="lg"] { --af-button-height: var(--size-element-lg, 36px); } From 0af44babb7a93ab701c32836189daf0ec515f444 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 03:16:00 +0000 Subject: [PATCH 16/20] Phase 2 step 6: the accessibility record after the second review docs/blocks/ACCESSIBILITY.md now claims what the checks prove: - the line-by-line text check; - the scroll-box check; - undecided contrast at every size and in dark; - Preview's subset of checks, stated exactly; - the one violation outside the blocks, allowed by name in the reference spec. It records the detection evidence: each injected defect caught. It also records the second review's fixes, marked "second review": the dialog's height, word breaks in labels, icon-only buttons spared, and the header static to 960 px. "Still needs a person" gains: - listening to long and shared table-region names; - Windows high contrast in a dark theme too; - focus indicators with motion on; - the shell at tablet and 721 to 1000 px sizes; - the Studio exhibit's heading outline. "Outside the blocks" records the one residual from the label fix: a model-written word wider than its whole row of buttons. The release plan's step 6 note names the line-by-line check and the dialog fix. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R6Ax8qvfN3gYUC85oSGnoi --- docs/blocks/ACCESSIBILITY.md | 50 +++++++++++++++++++---------- docs/blocks/PHASE-2-RELEASE-PLAN.md | 2 +- 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/docs/blocks/ACCESSIBILITY.md b/docs/blocks/ACCESSIBILITY.md index 9746c4e..09fd156 100644 --- a/docs/blocks/ACCESSIBILITY.md +++ b/docs/blocks/ACCESSIBILITY.md @@ -16,7 +16,7 @@ The sizes are 1280 x 900, then 640 x 512 and 320 x 256 (200% and 400% zoom of a | Check | WCAG 2.2 | How | |---|---|---| -| axe at every size, and with a dark preference, over the whole canvas | A and AA, through the tags `wcag2a`, `wcag2aa`, `wcag21a`, `wcag21aa` and `wcag22aa` (target size included) | Contrast that axe leaves undecided (scrolled content, a decorative separator) is decided from computed colors, by the same thresholds, compositing a translucent color over what lies beneath it. Text that another element in the canvas covers fails | +| axe at every size, and with a dark preference, over the whole canvas | A and AA, through the tags `wcag2a`, `wcag2aa`, `wcag21a`, `wcag21aa` and `wcag22aa` (target size included) | Contrast that axe leaves undecided (scrolled content, a decorative separator) is decided from computed colors, at every size and in dark, by the same thresholds, compositing a translucent color over what lies beneath it. Text that another element in the canvas covers fails | | The title is the surface's one heading, at level 2 | 1.3.1, 2.4.6 | The accessibility tree | | Each table's scrollable region is named by its columns | 2.4.6 | The accessibility tree | | Tab and Shift+Tab reach every control in order, by name, and leave the surface at both ends | 2.1.1, 2.1.2, 2.4.3 | Key presses | @@ -24,16 +24,22 @@ The sizes are 1280 x 900, then 640 x 512 and 320 x 256 (200% and 400% zoom of a | In forced colors, at 1280 px, every focused control keeps an outline, which the system can draw | 2.4.7 | Chromium's forced-colors emulation | | No focused control is entirely hidden, at every size | 2.4.11 | Hit-testing at each stop | | Targets are 24 by 24 px or larger, and pressing a field's edge focuses its input | 2.5.8 | Bounding boxes and clicks | -| Text spacing, at every size, cuts nothing off, pushes nothing out and draws nothing over other content | 1.4.12 | WCAG's test values | -| At every size, nothing scrolls sideways, is cut off or is drawn over other content, and no table column is narrower than 90 px; a data table may scroll inside its own focusable region | 1.4.4, 1.4.10 | Layout measurement: each Astryx stack's children, through `display: contents` wrappers, must not overlap | +| With text spacing, at every size, nothing is cut off, pushed outside the canvas, scrolled in a box or drawn over, line by line | 1.4.12 | WCAG's test values | +| At every size, nothing scrolls sideways or is cut off, no box but a table's region scrolls its own content, no line of text is drawn over or cut off, and no table column is narrower than 90 px. A data table may scroll inside its own focusable region | 1.4.4, 1.4.10 | Layout measurement. Each Astryx stack's children, through `display: contents` wrappers, must not overlap. Every line of text is sampled across its width and height, after scrolling it into view: another element in the canvas drawn over it, or a box that does not scroll cutting it off, fails | -Two further specs check Preview, which the canvas rules reach too. One checks an accepted block: its heading, axe, Astryx's focus ring, and reflow and the keyboard pass at the narrow sizes. The other checks every Astryx reference surface, for layout only: nothing cut off, outside the canvas or drawn over, at every size. Every check is soft, so one run reports every defect. +Two further specs check Preview, which the canvas rules reach too. One checks an accepted block: its heading, axe and Astryx's focus ring at 1280 px, and at the narrow sizes the layout checks, text spacing, axe and the keyboard pass. The other checks every Astryx reference surface at every size: axe, the undecided contrast, the layout checks and text spacing. One violation outside the blocks that predates this step is allowed there by name ("Outside the blocks", below). Every check is soft, so one run reports every defect. + +The checks were shown to catch what they claim. Against the reviewed builds they failed, as "What changed" records. With defects injected into a page, the text check caught each one: +- a table row moved over the next; +- a covered lower half of a caption; +- text cut off by a box; +- a dialog's footer pulled over its description. `apps/composer/app/astryx-renderers-a11y.test.ts` server-renders the Astryx renderers: heading elements (h1 and h2, never an empty one, and h3 a paragraph), table names, header and badge wrapping, long-word wrapping, and the column floor in both table modes. ## What changed -Run against main first, the e2e failed all 13 Astryx specs, and the renderer tests failed 8 of 10; a column-floor test and the dark pass were then added, and failed before their fixes. An independent accessibility review of that work found defects the checks could not see. Checks for them were added, and failed against the reviewed head (all 14 Astryx specs, and 3 of 13 renderer tests), before the fixes the table marks "review". Each defect and its fix: +Run against main first, the e2e failed all 13 Astryx specs, and the renderer tests failed 8 of 10; a column-floor test and the dark pass were then added, and failed before their fixes. An independent accessibility review of that work found defects the checks could not see. Checks for them were added, and failed against the reviewed head (all 14 Astryx specs, and 3 of 13 renderer tests), before the fixes the table marks "review". A second independent review then found an inline dialog cut off at 320 x 256, and the checks' claims broader than their reach. The strengthened checks failed the reference spec against that head before the fixes marked "second review". Each defect and its fix: | Defect on main | WCAG 2.2 | Fix | Where | |---|---|---|---| @@ -44,12 +50,12 @@ Run against main first, the e2e failed all 13 Astryx specs, and the renderer tes | Long words and labels ran out of the card (titles, intros, a guide's facts) | 1.4.10 | A word breaks when it must; a badge's label wraps | `TextRender.tsx`, `BadgeRender.tsx` | | guide-steps' two tables overlapped by 20 px, hiding a row of the first behind the second's header, and every table region overlapped the elements next to it by 4 px. A rule removing Astryx's vertical table bleed matched only the Studio's canvas | 1.4.3, 1.4.10 | The rule matches the Astryx canvas too (review) | `apps/composer/app/globals.css` | | The auth form's inputs held the surface 24 px past a 320 px viewport | 1.4.10 | A surface narrows with the canvas | `globals.css`, Astryx canvas only | -| An inline dialog made the page wider than a 320 px viewport (by 43 px for the delete-project reference, as the review measured it); once surfaces narrowed with the canvas, it was cut off instead | 1.4.10 | A dialog fits its container (review) | `globals.css` | -| A button's label stayed on one line, cut off with an ellipsis: with text spacing at 320 px, "Continue with Microsoft" lost a word | 1.4.12 | A label wraps, and its button grows; a one-line button keeps Astryx's height (review) | `globals.css` | +| An inline dialog made the page wider than a 320 px viewport (by 43 px for the delete-project reference, as the review measured it); once surfaces narrowed with the canvas, it was cut off instead. Narrowed, its 75vh height cap then showed 108 px of its content at 320 x 256, in a scroll box of its own that axe flags as not keyboard-focusable (Chromium makes it an unnamed tab stop; Safari does not make it focusable) | 1.4.10, 2.1.1 | A dialog fits its container's width (review), and takes its content's height (second review) | `globals.css` | +| A button's label stayed on one line, cut off with an ellipsis: with text spacing at 320 px, "Continue with Microsoft" lost a word | 1.4.12 | A label wraps, and its button grows; a one-line button keeps Astryx's height (review). A word too long for its line breaks (second review). The rule spares icon-only buttons, which Astryx sizes itself (second review) | `globals.css` | | The studio's green focus ring was 2.41:1 against the canvas white | 1.4.11 | Astryx's own indicators (#0064E0, 5.4:1), and an inset accent ring on the table region, for which Astryx draws none | `globals.css` | | In forced colors a focused field showed only its caret: Astryx marks focus with a border color and a shadow, which forced colors replace | 2.4.7 | A focused field gets an outline in forced colors (review) | `globals.css` | | With a dark OS preference, Astryx's dark tokens drew inside the white canvas, where its primary button is 3.11:1 | 1.4.3 | The Astryx canvas draws light | `globals.css` | -| The sticky header hid focused controls: with Shift+Tab at 640 x 450 and 320 x 720, and every one at 320 x 256, where the header filled nearly all of the viewport | 2.4.11 | At 720 px wide or less, or 600 px tall or less, the header scrolls away (review); otherwise the root's scroll padding follows its measured height | `apps/composer/app/af-ui.css`, `composer.tsx` | +| The sticky header hid focused controls: with Shift+Tab at 640 x 450 and 320 x 720, and every one at 320 x 256, where the header filled nearly all of the viewport. From 721 to 960 px wide it was up to 325 px tall | 2.4.11 | At 960 px wide or less, where its nav wraps, or 600 px tall or less, the header scrolls away (review, widened in the second review); otherwise the root's scroll padding follows its measured height | `apps/composer/app/af-ui.css`, `composer.tsx` | | The Build options made the page 11 px too wide at 320 px | 1.4.10 | Each option wraps under its label | `apps/composer/app/views/build-view.tsx` | All of these ship in the default build too, and every Astryx surface in Preview gains them. The Studio exhibit (apps/web) gets the renderer changes only, not Composer's canvas CSS or shell; its suite passes. None changes a block signature, a template, a prompt, model selection, a request or any evaluation evidence. @@ -66,16 +72,19 @@ All of these ship in the default build too, and every Astryx surface in Preview ## Still needs a person -1. A screen-reader pass per block: NVDA with Firefox or Chrome on Windows, VoiceOver with Safari on macOS and iOS, and TalkBack with Chrome on Android. Listen for the heading, the table regions' names, table navigation, each field's name and required state, and the buttons. +1. A screen-reader pass per block: NVDA with Firefox or Chrome on Windows, VoiceOver with Safari on macOS and iOS, and TalkBack with Chrome on Android. Listen for the heading, the table regions' names, table navigation, each field's name and required state, and the buttons. A maximum call names its table region with about 330 characters, and two tables with the same columns share a name: judge both by ear. 2. A keyboard pass in Firefox and Safari. Safari reaches buttons with Tab only when its "Press Tab to highlight each item" setting is on. 3. Real browser zoom at 200% and 400% in more than one browser, at desktop window heights as well as the sizes above, and text spacing with a bookmarklet. Text-only zoom to 200% (Firefox's "Zoom text only", or a browser's minimum font size), which viewport sizes do not reproduce (SC 1.4.4). -4. Windows forced colors (high contrast), on Windows: that focus indicators and field boundaries survive. The automated pass uses Chromium's emulation. -5. Touch: target size, and scrolling a wide table's region, on a phone, in portrait and landscape (SC 1.3.4). -6. Speech input (Voice Control, Dragon): each control can be operated by saying its visible label (SC 2.5.3). -7. Input purpose by hand (SC 1.3.5): it fails today, under the exception in [BETA.md](BETA.md), and a host's mitigation must be checked in that host. -8. By eye, in Build and in Preview: guide-steps' two tables sit apart, and no row hides behind the next header. -9. Astryx surfaces that are not blocks: model-generated, imported and the reference surfaces. They share the renderers and the canvas rules, and only the reference surfaces' layout is checked automatically (below). -10. The judgements in "Proposed waivers". +4. Windows forced colors (high contrast), on Windows, in both a light and a dark theme: that focus indicators and field boundaries survive. The automated pass uses Chromium's emulation, with a light palette. +5. Focus indicators with motion on. Every automated pass runs with reduced motion, which makes Astryx's border transition instant. +6. Touch: target size, and scrolling a wide table's region, on a phone, in portrait and landscape (SC 1.3.4). +7. Speech input (Voice Control, Dragon): each control can be operated by saying its visible label (SC 2.5.3). +8. Input purpose by hand (SC 1.3.5): it fails today, under the exception in [BETA.md](BETA.md), and a host's mitigation must be checked in that host. +9. By eye, in Build and in Preview: guide-steps' two tables sit apart, and no row hides behind the next header. +10. Astryx surfaces that are not blocks: model-generated and imported ones. They share the renderers and the canvas rules; only the reference surfaces are checked automatically. +11. The composer shell at sizes the checks skip: a tablet in portrait, and windows from 721 to 1000 px wide. +12. The Studio exhibit's heading outline, now that its surface titles are h1 and h2. +13. The judgements in "Proposed waivers". ## Proposed waivers @@ -99,7 +108,14 @@ The contrast nodes are the wireframe fallback's Separator chips (1.19:1 and 1.26 ## Outside the blocks (information) -axe over each Astryx reference surface in Preview (the tags above, at 1280 px) finds one violation, unchanged by this step: on `ex.plan-comparison`, `aria-required-children` (critical). Its Astryx list, a `ul` with the role `list`, holds checkboxes and a definition list's `dt` and `dd`, where a list allows only list items. The List, SelectableCard and MetadataList renderers are unchanged here. It is not a block, so it is left for a separate change. +- **One axe violation, unchanged by this step.** axe runs over each Astryx reference surface in Preview, with the tags above, at every size. It finds one violation: on `ex.plan-comparison`, `aria-required-children` (critical). + - Its Astryx list, a `ul` with the role `list`, holds checkboxes and a definition list's `dt` and `dd`, where a list allows only list items. + - The List, SelectableCard and MetadataList renderers are unchanged here. + - It is not a block, so it is left for a separate change. The reference spec allows it by name. +- **A word wider than its row of buttons.** A model-written label whose single word is wider than its whole row of buttons still overflows that row. + - A word wider than its own line breaks (`overflow-wrap: break-word`). + - Breaking anywhere would also split short labels: it split "Cancel" in a dialog's two-button row at 320 px. + - The blocks' labels are fixed template text. ## Re-running diff --git a/docs/blocks/PHASE-2-RELEASE-PLAN.md b/docs/blocks/PHASE-2-RELEASE-PLAN.md index 0741d85..6b6560b 100644 --- a/docs/blocks/PHASE-2-RELEASE-PLAN.md +++ b/docs/blocks/PHASE-2-RELEASE-PLAN.md @@ -7,7 +7,7 @@ > - The owner authorized step 3 on 2026-10-16, limited to H1, H2 and H4 (§7), and it is built: the B0 action-id proof, the action manifest in the beta's Catalog export, and the host guide ([HOST-ACTIONS.md](HOST-ACTIONS.md)). The Catalog export that §7 locates at `exportBundle` in `preview-view.tsx` is now built by `catalogBundle` in `apps/composer/app/catalog-export.ts`. The manifest reads each surface's actions with `actionEvents` (`packages/composer-core/src/blocks/actions.ts`), which also reads each context, rather than `emittedActionNames`. H3, the Blocks catalog view and Preview's label, belongs to step 4. > - The owner authorized step 4 on 2026-10-16: the read-only Blocks catalog and provenance UI (§7 H3, §8), and the first three host-guide clarifications from step 3's review. It is built: a Blocks section in the Catalog view; in Preview, a surface's provenance ("made with block X vN", only by exact expansion identity) with its status and specific reason, and the note that block actions are host-owned and not executed here; and §8's lifecycle, with an Example session's save, its duplicate and every import now pruning stale provenance too. Refinable states what a record is, not that a build will use the block. Nothing grants an action anything, and the default build is unchanged. > - The owner authorized step 5 on 2026-10-16: receipts and operator signals (§9). It is built ([BETA-OPERATIONS.md](BETA-OPERATIONS.md)): the beta receipt (block and surface tokens apart, provider-reported only; each call's own served model with the top-level fallback; the exact budget; the refinement mode in words), the Worker's shape-only operator log behind `BETA_OPERATOR_LOG`, which production never sets, and a diagnostics export the user asks for. It takes decision 7's recommended course (§13): no beacon or client telemetry. The canary and alarm rules are documented; no hosted canary has run, and the canary first needs the harness to send the beta's Access service token. Field data is never ADR-B3 decision evidence. Production's request bytes are unchanged, and its default build carries none of this code. -> - The owner authorized step 6 on 2026-10-16: accessibility hardening for the Astryx beta path (§10), to WCAG 2.2 AA. It is built ([ACCESSIBILITY.md](ACCESSIBILITY.md)): an e2e runs axe with the WCAG 2.0, 2.1 and 2.2 A and AA tags over each rendered Astryx block, on example and boundary calls, with no canvas exclusion, and checks headings, table names, keyboard order, focus indicators (in forced colors too), focus never hidden, target size, text spacing, content drawn over other content, and reflow at 200% and 400% zoom and 320 px. Its fixes reach the default build too: §10's note that the Astryx `TextRender` renders headings as `

` no longer holds for the display variants (h3, Astryx's body-scale `large`, stays a paragraph), table regions are named by their columns, header text, long text and button labels wrap, stacked tables no longer overlap, the Astryx canvas keeps Astryx's focus indicators and draws light, and the composer's header scrolls away in a narrow or short window and otherwise keeps focus clear of itself. auth-form's SC 1.3.5 exception and its host mitigation are in [BETA.md](BETA.md). No screen-reader pass has been done; the checks still needing a person, and the waivers proposed to the owner, are in [ACCESSIBILITY.md](ACCESSIBILITY.md). The shadcn/ui v3 scans are information only. +> - The owner authorized step 6 on 2026-10-16: accessibility hardening for the Astryx beta path (§10), to WCAG 2.2 AA. It is built ([ACCESSIBILITY.md](ACCESSIBILITY.md)): an e2e runs axe with the WCAG 2.0, 2.1 and 2.2 A and AA tags over each rendered Astryx block, on example and boundary calls, with no canvas exclusion, and checks headings, table names, keyboard order, focus indicators (in forced colors too), focus never hidden, target size, text spacing, text drawn over or cut off line by line, and reflow at 200% and 400% zoom and 320 px. Its fixes reach the default build too: §10's note that the Astryx `TextRender` renders headings as `

` no longer holds for the display variants (h3, Astryx's body-scale `large`, stays a paragraph), table regions are named by their columns, header text, long text and button labels wrap, stacked tables no longer overlap, the Astryx canvas keeps Astryx's focus indicators and draws light, inline dialogs show their whole content, and the composer's header scrolls away in a narrow or short window and otherwise keeps focus clear of itself. auth-form's SC 1.3.5 exception and its host mitigation are in [BETA.md](BETA.md). No screen-reader pass has been done; the checks still needing a person, and the waivers proposed to the owner, are in [ACCESSIBILITY.md](ACCESSIBILITY.md). The shadcn/ui v3 scans are information only. Final version, 2026-10-16. The owner accepted the plan on 2026-10-15 as the conditional working plan for the next AF Composer release, and this version carries the owner's three clarifications (below) and the shadcn/ui v3 result. It is not authorization to begin Phase 2: nothing in it starts until the owner authorizes it. No code. From 316081bc55ca1fe62ebe05e4a09c4f860a9f80c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 03:47:32 +0000 Subject: [PATCH 17/20] Phase 2 step 6: fail-first checks for the verification's findings The verification of 0af44ba found gaps in the text check, and two defects outside the blocks that no check reached: - The text check now brings every line of a text to the middle of the window before sampling it. At 320 x 256 a tall cell's lines had run past the window unsampled: 487 of guide-steps' 940 lines. It also hit-tests every element in the canvas, so a cover that ignores the pointer no longer escapes. Neither change fails a block or reference surface. - A new Preview spec imports two model-shaped surfaces the blocks never produce, under the same renderers and canvas rules. The first holds a standard, a fullscreen and an alert dialog with long labels. The second holds a children-mode table, Astryx's large type and display text with no words. Each is checked at every size with layout, text spacing, axe and the contrast rule; the second's headings are checked too. Against 0af44ba's build only the new spec fails, with two defects: - At 320 x 256 the fullscreen dialog keeps Astryx's 100dvh height and cuts its content off, and the next dialog draws over the overflow. - With text spacing at 640 x 512 and 320 x 720, the alert dialog's footer cuts off "Keep my archived projects". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R6Ax8qvfN3gYUC85oSGnoi --- e2e/composer-blocks-a11y.spec.ts | 92 ++++++++++++++++++++++++++++++-- 1 file changed, 89 insertions(+), 3 deletions(-) diff --git a/e2e/composer-blocks-a11y.spec.ts b/e2e/composer-blocks-a11y.spec.ts index b99d5a3..49580cd 100644 --- a/e2e/composer-blocks-a11y.spec.ts +++ b/e2e/composer-blocks-a11y.spec.ts @@ -19,7 +19,8 @@ * and the same with every string one unbroken token (a URL, an id). Preview, * which the canvas rules reach too, gets a subset: an accepted block's * heading, axe, focus ring, layout and keyboard pass, and every Astryx - * reference surface's axe and layout at every size. + * reference surface's axe and layout at every size, as do two model-shaped + * surfaces the blocks never produce (dialogs, a children-mode table). * * The shadcn/ui v3 scans at the end only report and never fail: shadcn/ui v3 * blocks are withheld from the beta. @@ -213,10 +214,15 @@ const INSTALL_HELPERS = () => { * text that another element in the canvas draws over, or that a box which * does not scroll cuts off. Text in a scroll container (a table's region) * may be scrolled out of view; text a screen-reader-only style hides is - * skipped. Each text is scrolled into view first. + * skipped. Each line is scrolled into view before it is sampled, and every + * element in the canvas is hit-tested, even one that ignores the pointer + * (a cover that lets clicks through still hides what is under it). */ const coveredText = (sel: string): string[] => { const canvas = document.querySelector(sel)!; + const hitTestAll = document.createElement("style"); + hitTestAll.textContent = `${sel} * { pointer-events: auto !important; }`; + document.head.append(hitTestAll); const describe = (el: Element) => `${el.tagName.toLowerCase()}.${String(el.className).split(" ")[0]} "${(el.textContent ?? "").trim().slice(0, 20)}"`; const visuallyHidden = (el: Element) => { for (let a: Element | null = el; a && a !== canvas; a = a.parentElement) { @@ -238,7 +244,13 @@ const INSTALL_HELPERS = () => { const first = lines()[0]; if (!first) continue; if (first.top < 0 || first.bottom > innerHeight || first.left < 0 || first.right > innerWidth) p.scrollIntoView({ block: "center", inline: "nearest" }); - for (const r of lines()) + for (let i = 0; lines()[i]; i++) { + let r = lines()[i]!; + // A text taller than the window runs past it: bring each of its lines to the middle. + if (r.top < 0 || r.bottom > innerHeight) { + window.scrollBy(0, r.top + r.height / 2 - innerHeight / 2); + r = lines()[i]!; + } for (const fx of [0.1, 0.5, 0.9]) for (const fy of [0.25, 0.5, 0.75]) { const [x, y] = [r.left + r.width * fx, r.top + r.height * fy]; @@ -258,7 +270,9 @@ const INSTALL_HELPERS = () => { } if (canvas.contains(hit)) out.add(`"${text}" is drawn over by ${describe(hit)}`); } + } } + hitTestAll.remove(); return [...out].slice(0, 12); }; (window as any).__a11y = { contrastOver, coveredText }; @@ -637,6 +651,78 @@ test("Astryx: every reference surface in Preview, at every size: axe, and nothin } }); +// ---- Astryx surfaces beyond the blocks ---------------------------------------- + +/** + * Model-shaped surfaces the blocks never produce, which Preview renders under + * the same renderers and canvas rules: inline dialogs taller than a short + * window, an alert dialog with long labels, a children-mode table, Astryx's + * `large` type and display text with no words. They come in as an imported + * project's examples. + */ +const said = (value: string, type = "body") => ({ component: "text", props: { type }, text: value }); +const press = (label: string, variant: string) => ({ component: "button", props: { label, variant } }); +const paragraphs = (n: number) => Array.from({ length: n }, (_, i) => said(`Paragraph ${i + 1}. ${LOREM.trim()}.`)); +const surface = (id: string, intent: string, name: string, children: Json[]) => ({ + id, + intent, + name, + prompt: name, + description: name, + surface: { dspackSurface: "0.1", system: "Astryx", intent, root: { component: "card", children } }, +}); +const BEYOND: Json[] = [ + surface("ex.a11y-dialogs", "destructive-action", "Dialogs", [ + said("Dialogs", "display-3"), + { component: "dialog", props: { variant: "standard", purpose: "form" }, children: [said("A standard dialog"), ...paragraphs(4), press("The standard dialog's last button", "primary")] }, + { component: "dialog", props: { variant: "fullscreen", purpose: "info" }, children: [said("A fullscreen dialog"), ...paragraphs(4), press("The fullscreen dialog's last button", "secondary")] }, + { + component: "alert-dialog", + props: { title: "Permanently delete every archived project in this workspace?", description: "This cannot be undone.", actionLabel: "Delete every archived project permanently", cancelLabel: "Keep my archived projects", actionVariant: "destructive" }, + }, + ]), + surface("ex.a11y-structure", "record-collection", "Structure", [ + said("Structure", "display-3"), + said("Large text is body text", "large"), + said("", "display-3"), + { + component: "table", + props: { columns: ["Ticket", "Subject", "Requester", "Status", "Priority", "Updated"], data: [] }, + children: Array.from({ length: 12 }, (_, i) => said(["#4812", "Cannot export session receipts", "Ada Lovelace", "Waiting on support", "Urgent", "2026-10-16"][i % 6]!)), + }, + ]), +]; + +test("Astryx: surfaces beyond the blocks in Preview, at every size: axe, and nothing cut off, scrolled in a box or drawn over", async ({ page }) => { + test.setTimeout(240_000); + await page.setViewportSize({ width: 1280, height: 900 }); + await openProject(page, "astryx", "export source"); + const [download] = await Promise.all([page.waitForEvent("download"), page.getByTestId("project-export").click()]); + const exported = JSON.parse(readFileSync((await download.path())!, "utf8")) as Json; + exported.name = "astryx beyond the blocks"; + exported.contract.examples = [...(exported.contract.examples as Json[]), ...BEYOND]; + await page.getByTestId("nav-projects").click(); + await page.getByTestId("import-project-input").setInputFiles({ name: "beyond.composerproject.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify(exported)) }); + await expect(page.getByTestId("build-prompt")).toBeVisible(); + await page.getByTestId("nav-preview").click(); + for (const { id } of BEYOND as Array<{ id: string }>) { + await resize(page, 1280, 900); + await page.getByTestId(`surface-${id}`).click(); + await expect(page.locator(PREVIEW)).toBeVisible(); + if (id === "ex.a11y-structure") { + // Astryx's large type is body text, and display text with no words is no heading. + await expect.soft(page.locator(PREVIEW).getByRole("heading"), `${id}: headings`).toHaveText(["Structure"]); + } + for (const [width, height] of [[1280, 900], ...NARROW] as const) { + const at = `${id} at ${width} x ${height}`; + await resize(page, width, height); + expect.soft(await reflow(page, PREVIEW), at).toEqual(FITS); + expect.soft(await spaced(page, PREVIEW), `${at}, text spacing`).toEqual(SPACED_FITS); + await axeAndContrast(page, at, PREVIEW); + } + } +}); + // ---- shadcn/ui v3: information only ---------------------------------------- test.describe("shadcn/ui v3, information only: never gates the Astryx-only beta", () => { From f04061da679cbb84f8f1af0a5f22cdc68aa72357 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 03:51:39 +0000 Subject: [PATCH 18/20] Phase 2 step 6: neighbours overlap only where their boxes intersect The overlap check compared neighbours in a horizontal stack by their edges alone. A row that wraps puts the next item on the line below, to the left of the first one's right edge. That read as an overlap: a false failure of 114 px once a dialog's footer row wraps, which the next commit makes it do. Neighbours now overlap only where their boxes intersect, in either direction. Detection holds. With Astryx's 16 px vertical table bleed injected again into guide-steps, the check reports the original 4, 20 and 4 px overlaps; without it, none. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R6Ax8qvfN3gYUC85oSGnoi --- e2e/composer-blocks-a11y.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/e2e/composer-blocks-a11y.spec.ts b/e2e/composer-blocks-a11y.spec.ts index 49580cd..68c9a57 100644 --- a/e2e/composer-blocks-a11y.spec.ts +++ b/e2e/composer-blocks-a11y.spec.ts @@ -462,8 +462,10 @@ async function reflow(page: Page, sel = CANVAS) { const kids = laidOut(stack).filter((k) => k.getBoundingClientRect().height > 0); for (let i = 1; i < kids.length; i++) { const [a, b] = [kids[i - 1]!.getBoundingClientRect(), kids[i]!.getBoundingClientRect()]; - const by = across ? a.right - b.left : a.bottom - b.top; - if (by > 0.5) overlaps.push(`${describe(kids[i - 1]!)} overlaps ${describe(kids[i]!)} by ${Math.round(by)}px`); + // Neighbours overlap where their boxes intersect: a row that wraps puts the next one on a line below. + const w = Math.min(a.right, b.right) - Math.max(a.left, b.left); + const h = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top); + if (w > 0.5 && h > 0.5) overlaps.push(`${describe(kids[i - 1]!)} overlaps ${describe(kids[i]!)} by ${Math.round(across ? w : h)}px`); } } const crushed = Array.from(canvas.querySelectorAll("th")) From c8824eec6314e168bc867c15475696187eb80255 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 03:51:55 +0000 Subject: [PATCH 19/20] Phase 2 step 6: a canvas dialog takes its content's height, and its footer's buttons wrap The verification of 0af44ba: - N1. A fullscreen inline dialog kept Astryx's 100dvh height. At 320 x 256 it cut its own content off, and the next dialog drew over the overflow. A canvas dialog's height is now its content's (`height: auto`), as for the height cap before. Standard and alert dialogs measure as they did. - F4, the rest. An alert dialog's two long labels were each narrower than its footer but too wide together. With WCAG's text spacing at 640 x 512 and 320 x 720, "Keep my archived projects" was cut off. Astryx's footer row now wraps, so each button takes a row of its own when they do not fit. At 320 px the delete-project dialog shows "Cancel" and then "Delete project", each whole; at 640 px they still sit side by side. The spec that failed for both now passes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R6Ax8qvfN3gYUC85oSGnoi --- apps/composer/app/globals.css | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/composer/app/globals.css b/apps/composer/app/globals.css index e235159..d2f63a9 100644 --- a/apps/composer/app/globals.css +++ b/apps/composer/app/globals.css @@ -261,9 +261,13 @@ input[type="range"] { its edge and buttons were cut off. It also caps the dialog's height at 75% of the viewport and scrolls what does not fit: at 320 x 256 the delete-project dialog showed 108 px of its content in a scroll box of its - own, which axe flags as not keyboard-focusable. An inline dialog in the - canvas is part of the page, so here it fits its container's width and - takes its content's height. + own, which axe flags as not keyboard-focusable; a fullscreen one keeps + the viewport's height and cut its content off at 320 x 256. An inline + dialog in the canvas is part of the page, so here it fits its + container's width and takes its content's height, and its footer's + buttons wrap onto a row of their own when together they are too wide + (with WCAG's text spacing at 320 px, "Keep my archived projects" was + cut off). And Astryx keeps a button's label on one line, cut off with an ellipsis: with WCAG's text spacing at 320 px, "Continue with Microsoft" lost its last word (SC 1.4.12). Here a label wraps, and a word too long for its @@ -276,12 +280,16 @@ input[type="range"] { itself, so those keep Astryx's fixed size. */ [data-astryx-canvas] .astryx-dialog { max-width: 100%; + height: auto; } [data-astryx-canvas] .astryx-dialog, [data-astryx-canvas] .astryx-dialog > * { max-height: none; --container-max-height: none; } +[data-astryx-canvas] .astryx-layout-footer .astryx-stack.horizontal { + flex-wrap: wrap; +} [data-astryx-canvas] .astryx-button:not([aria-label]) { --af-button-height: var(--size-element-md, 32px); white-space: normal; From d6b28596a4453d9e5f2f46f4adfd27a120135294 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 03:55:08 +0000 Subject: [PATCH 20/20] Phase 2 step 6: the accessibility record after the verification docs/blocks/ACCESSIBILITY.md now records the verification round: - The text check scrolls every line into view and hit-tests every element, even one that ignores the pointer. It caught a cover over the last two lines of a 2,529 px cell at 320 x 256, and a cover with pointer-events: none. The check before the verification missed both; the record says so. - Neighbours overlap only where their boxes intersect. - A new spec imports model-shaped surfaces the blocks never produce: dialogs, a children-mode table, Astryx's large type and empty display text. - The dialog row in "What changed" gains two fixes, marked "verification": a fullscreen dialog's viewport height, and footer buttons that wrap. "Outside the blocks" narrows the label residual: a row of buttons outside a dialog's footer whose longest words together are wider than it. It also records the button rule's two latent edges: an icon-only button with an empty label, and a labelled button while it loads. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R6Ax8qvfN3gYUC85oSGnoi --- docs/blocks/ACCESSIBILITY.md | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/docs/blocks/ACCESSIBILITY.md b/docs/blocks/ACCESSIBILITY.md index 09fd156..df0d8c6 100644 --- a/docs/blocks/ACCESSIBILITY.md +++ b/docs/blocks/ACCESSIBILITY.md @@ -25,21 +25,29 @@ The sizes are 1280 x 900, then 640 x 512 and 320 x 256 (200% and 400% zoom of a | No focused control is entirely hidden, at every size | 2.4.11 | Hit-testing at each stop | | Targets are 24 by 24 px or larger, and pressing a field's edge focuses its input | 2.5.8 | Bounding boxes and clicks | | With text spacing, at every size, nothing is cut off, pushed outside the canvas, scrolled in a box or drawn over, line by line | 1.4.12 | WCAG's test values | -| At every size, nothing scrolls sideways or is cut off, no box but a table's region scrolls its own content, no line of text is drawn over or cut off, and no table column is narrower than 90 px. A data table may scroll inside its own focusable region | 1.4.4, 1.4.10 | Layout measurement. Each Astryx stack's children, through `display: contents` wrappers, must not overlap. Every line of text is sampled across its width and height, after scrolling it into view: another element in the canvas drawn over it, or a box that does not scroll cutting it off, fails | +| At every size, nothing scrolls sideways or is cut off, no box but a table's region scrolls its own content, no line of text is drawn over or cut off, and no table column is narrower than 90 px. A data table may scroll inside its own focusable region | 1.4.4, 1.4.10 | Layout measurement. Each Astryx stack's children, through `display: contents` wrappers, must not overlap: their boxes must not intersect. Every line of text is scrolled into view and sampled across its width and height, with every element in the canvas hit-tested, even one that ignores the pointer. Another element drawn over the line, or a box that does not scroll cutting it off, fails | -Two further specs check Preview, which the canvas rules reach too. One checks an accepted block: its heading, axe and Astryx's focus ring at 1280 px, and at the narrow sizes the layout checks, text spacing, axe and the keyboard pass. The other checks every Astryx reference surface at every size: axe, the undecided contrast, the layout checks and text spacing. One violation outside the blocks that predates this step is allowed there by name ("Outside the blocks", below). Every check is soft, so one run reports every defect. +Two further specs check Preview, which the canvas rules reach too. One checks an accepted block: its heading, axe and Astryx's focus ring at 1280 px, and at the narrow sizes the layout checks, text spacing, axe and the keyboard pass. The other checks every Astryx reference surface at every size: axe, the undecided contrast, the layout checks and text spacing. One violation outside the blocks that predates this step is allowed there by name ("Outside the blocks", below). A third imports two model-shaped surfaces the blocks never produce, and checks them the same way, with their headings: +- a standard, a fullscreen and an alert dialog with long labels; +- a children-mode table, Astryx's `large` type and display text with no words. + +Every check is soft, so one run reports every defect. The checks were shown to catch what they claim. Against the reviewed builds they failed, as "What changed" records. With defects injected into a page, the text check caught each one: - a table row moved over the next; - a covered lower half of a caption; - text cut off by a box; -- a dialog's footer pulled over its description. +- a dialog's footer pulled over its description; +- a cover over the last two lines of a 2,529 px table cell at 320 x 256; +- a cover that ignores the pointer. + +The check before the verification missed the last two: it sampled only the lines in view, and hit-tested only elements that take the pointer. `apps/composer/app/astryx-renderers-a11y.test.ts` server-renders the Astryx renderers: heading elements (h1 and h2, never an empty one, and h3 a paragraph), table names, header and badge wrapping, long-word wrapping, and the column floor in both table modes. ## What changed -Run against main first, the e2e failed all 13 Astryx specs, and the renderer tests failed 8 of 10; a column-floor test and the dark pass were then added, and failed before their fixes. An independent accessibility review of that work found defects the checks could not see. Checks for them were added, and failed against the reviewed head (all 14 Astryx specs, and 3 of 13 renderer tests), before the fixes the table marks "review". A second independent review then found an inline dialog cut off at 320 x 256, and the checks' claims broader than their reach. The strengthened checks failed the reference spec against that head before the fixes marked "second review". Each defect and its fix: +Run against main first, the e2e failed all 13 Astryx specs, and the renderer tests failed 8 of 10; a column-floor test and the dark pass were then added, and failed before their fixes. An independent accessibility review of that work found defects the checks could not see. Checks for them were added, and failed against the reviewed head (all 14 Astryx specs, and 3 of 13 renderer tests), before the fixes the table marks "review". A second independent review then found an inline dialog cut off at 320 x 256, and the checks' claims broader than their reach. The strengthened checks failed the reference spec against that head before the fixes marked "second review". A verification of those fixes found two defects outside the blocks: a fullscreen dialog's height, and an alert dialog's footer with long labels. A new spec failed on both before the fixes marked "verification". Each defect and its fix: | Defect on main | WCAG 2.2 | Fix | Where | |---|---|---|---| @@ -50,7 +58,7 @@ Run against main first, the e2e failed all 13 Astryx specs, and the renderer tes | Long words and labels ran out of the card (titles, intros, a guide's facts) | 1.4.10 | A word breaks when it must; a badge's label wraps | `TextRender.tsx`, `BadgeRender.tsx` | | guide-steps' two tables overlapped by 20 px, hiding a row of the first behind the second's header, and every table region overlapped the elements next to it by 4 px. A rule removing Astryx's vertical table bleed matched only the Studio's canvas | 1.4.3, 1.4.10 | The rule matches the Astryx canvas too (review) | `apps/composer/app/globals.css` | | The auth form's inputs held the surface 24 px past a 320 px viewport | 1.4.10 | A surface narrows with the canvas | `globals.css`, Astryx canvas only | -| An inline dialog made the page wider than a 320 px viewport (by 43 px for the delete-project reference, as the review measured it); once surfaces narrowed with the canvas, it was cut off instead. Narrowed, its 75vh height cap then showed 108 px of its content at 320 x 256, in a scroll box of its own that axe flags as not keyboard-focusable (Chromium makes it an unnamed tab stop; Safari does not make it focusable) | 1.4.10, 2.1.1 | A dialog fits its container's width (review), and takes its content's height (second review) | `globals.css` | +| An inline dialog made the page wider than a 320 px viewport (by 43 px for the delete-project reference, as the review measured it); once surfaces narrowed with the canvas, it was cut off instead. Narrowed, its 75vh height cap then showed 108 px of its content at 320 x 256, in a scroll box of its own that axe flags as not keyboard-focusable (Chromium makes it an unnamed tab stop; Safari does not make it focusable) | 1.4.10, 2.1.1 | A dialog fits its container's width (review), and takes its content's height (second review; a fullscreen one's viewport height too, in the verification). Its footer's buttons wrap onto rows of their own when together they are too wide: with text spacing at 320 px, "Keep my archived projects" was cut off (verification) | `globals.css` | | A button's label stayed on one line, cut off with an ellipsis: with text spacing at 320 px, "Continue with Microsoft" lost a word | 1.4.12 | A label wraps, and its button grows; a one-line button keeps Astryx's height (review). A word too long for its line breaks (second review). The rule spares icon-only buttons, which Astryx sizes itself (second review) | `globals.css` | | The studio's green focus ring was 2.41:1 against the canvas white | 1.4.11 | Astryx's own indicators (#0064E0, 5.4:1), and an inset accent ring on the table region, for which Astryx draws none | `globals.css` | | In forced colors a focused field showed only its caret: Astryx marks focus with a border color and a shadow, which forced colors replace | 2.4.7 | A focused field gets an outline in forced colors (review) | `globals.css` | @@ -112,10 +120,13 @@ The contrast nodes are the wireframe fallback's Separator chips (1.19:1 and 1.26 - Its Astryx list, a `ul` with the role `list`, holds checkboxes and a definition list's `dt` and `dd`, where a list allows only list items. - The List, SelectableCard and MetadataList renderers are unchanged here. - It is not a block, so it is left for a separate change. The reference spec allows it by name. -- **A word wider than its row of buttons.** A model-written label whose single word is wider than its whole row of buttons still overflows that row. +- **A row of buttons too wide for its line.** Outside a dialog's footer, which wraps, a row of buttons still overflows when the longest words of its labels together are wider than the row. - A word wider than its own line breaks (`overflow-wrap: break-word`). - Breaking anywhere would also split short labels: it split "Cancel" in a dialog's two-button row at 320 px. - - The blocks' labels are fixed template text. + - Only a model-written row could overflow. The blocks' labels are fixed template text, and their rows fit at every size, with text spacing too. +- **The button rule's scoping.** The label-wrapping rule applies only to a button named by its visible text, which Astryx marks by giving every other button an `aria-label`. That has two latent edges; neither occurs today, since ButtonRender renders neither: + - An icon-only button with an empty label gets no `aria-label` from Astryx, and so would still get the rule. It would have no accessible name at all. + - Astryx gives a button an `aria-label` while it is loading, so a wrapped label would drop back to one line while it loads. ## Re-running