Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
6429340
Phase 2 step 6: fail-first accessibility tests for the Astryx block s…
claude Oct 17, 2026
59eb822
Phase 2 step 6: fail-first tests for the column floor and the dark sc…
claude Oct 17, 2026
f5c0ed9
Phase 2 step 6: Astryx renderer semantics and reflow
claude Oct 17, 2026
22ba739
Phase 2 step 6: the Astryx canvas keeps Astryx's focus indicators and…
claude Oct 17, 2026
709725f
Phase 2 step 6: composer shell keeps focus clear of the sticky header…
claude Oct 17, 2026
7aa423c
Phase 2 step 6: narrow surfaces in the Astryx canvas only
claude Oct 17, 2026
6a5ec26
Phase 2 step 6: accessibility record, the auth-form exception, and th…
claude Oct 17, 2026
057606a
Docs: record the owner's step-5 confirmations (outside step 6's scope)
claude Oct 17, 2026
be6864f
Phase 2 step 6: fail-first tests for the accessibility review's findings
claude Oct 17, 2026
3521742
Phase 2 step 6: h3 stays body text, no empty heading, and a children-…
claude Oct 17, 2026
3203749
Phase 2 step 6: in the Astryx canvas, nothing overlaps, overflows or …
claude Oct 17, 2026
ec1a3e9
Phase 2 step 6: a narrow or short window lets the composer header scr…
claude Oct 17, 2026
09f4e60
Phase 2 step 6: the accessibility record after the review
claude Oct 17, 2026
38082fe
Phase 2 step 6: fail-first checks for the re-review's findings
claude Oct 17, 2026
a6ffba4
Phase 2 step 6: inline dialogs show their whole content; the button r…
claude Oct 17, 2026
0af44ba
Phase 2 step 6: the accessibility record after the second review
claude Oct 17, 2026
316081b
Phase 2 step 6: fail-first checks for the verification's findings
claude Oct 17, 2026
f04061d
Phase 2 step 6: neighbours overlap only where their boxes intersect
claude Oct 17, 2026
c8824ee
Phase 2 step 6: a canvas dialog takes its content's height, and its f…
claude Oct 17, 2026
d6b2859
Phase 2 step 6: the accessibility record after the verification
claude Oct 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/composer/app/af-ui.css
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,18 @@ button.af-choice {
}
}

/* 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;
}
}

@media (prefers-reduced-motion: reduce) {
.af-nav__link::after,
.af-card,
Expand Down
130 changes: 130 additions & 0 deletions apps/composer/app/astryx-renderers-a11y.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
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 display variant is a heading, a table's
* scroll region is named by its columns, header text wraps instead of being
* cut off, a column keeps a minimum width, 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<string, FC<any>>;
const render = (name: string, props: Record<string, unknown>, children: Record<string, ReactNode> = {}) =>
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 display variants as h1 and h2, keeping Astryx's display type", () => {
expect(render("Text", { variant: "h1", text: "Title" })).toMatch(/<h1 class="astryx-text display-2[^"]*"[^>]*>Title<\/h1>/);
expect(render("Text", { variant: "h2", text: "Section" })).toMatch(/<h2 class="astryx-text display-3[^"]*"[^>]*>Section<\/h2>/);
});

it("keeps h3 a paragraph: it comes from Astryx's large type, body text in the contract, whose headings are its display types", () => {
const html = render("Text", { variant: "h3", text: "Group" });
expect(html).toMatch(/<p class="astryx-text large[^"]*"[^>]*>Group<\/p>/);
expect(html).not.toMatch(/<h[1-6]|role="heading"/);
const container = render("Text", { variant: "h3", text: "Group", children: ["c1"] }, { c1: "Child" });
expect(container).not.toMatch(/<h[1-6]|role="heading"/);
});

it("gives a display variant without text no heading, which a screen reader would announce blank", () => {
for (const variant of ["h1", "h2"]) {
for (const text of [undefined, "", " "]) {
const html = render("Text", { variant, text });
expect(html, `${variant} ${JSON.stringify(text)}`).not.toMatch(/<h[1-6]/);
expect(html, `${variant} ${JSON.stringify(text)}`).toMatch(/<p class="astryx-text display-[^"]*"[^>]*>[^<]*<\/p>/);
}
}
// A container's blank text gets no heading either.
expect(render("Text", { variant: "h2", text: " ", children: ["c1"] }, { c1: "Child" })).not.toContain('role="heading"');
});

it("keeps body text a paragraph and a caption inline text", () => {
expect(render("Text", { variant: "body", text: "Body" })).toMatch(/<p class="astryx-text body[^"]*"[^>]*>Body<\/p>/);
expect(render("Text", { variant: "caption", text: "Note" })).toMatch(/<span class="astryx-text supporting[^"]*"[^>]*>Note<\/span>/);
expect(render("Text", { text: "No variant" })).toMatch(/<p class="astryx-text body[^"]*"[^>]*>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(/<div class="astryx-text display-3[^"]*"[^>]*><span role="heading" aria-level="2">Account<\/span><span style="display:contents"><p>Child<\/p><\/span><\/div>/);
expect(html).not.toMatch(/<h[1-6]|<p class/);
// A container without its own text gets no empty heading.
expect(render("Text", { variant: "h2", children: ["c1"] }, { c1: "Child" })).not.toContain('role="heading"');
// A body container is unchanged: no heading role.
expect(render("Text", { variant: "body", text: "Intro", children: ["c1"] }, { c1: "Child" })).not.toContain('role="heading"');
});

it("breaks a long unbroken word instead of overflowing, for every variant", () => {
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(/<th [^>]*>/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("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(/<th [^>]*>/g) ?? [];
expect(headers).toHaveLength(8);
for (const th of headers) expect(th).toContain("min-width:96px");
expect(html.match(/<table [^>]*>/)?.[0]).toContain("min-width:768px");
});

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(/<th [^>]*>/g) ?? [];
expect(headers).toHaveLength(2);
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", () => {
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");
});
});
27 changes: 26 additions & 1 deletion apps/composer/app/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,31 @@ 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, 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<HTMLElement>(null);
useEffect(() => {
const header = headerRef.current;
if (!header || typeof ResizeObserver === "undefined") return;
const root = document.documentElement;
const pad = () => {
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 = "";
};
}, []);

// 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.
Expand Down Expand Up @@ -87,7 +112,7 @@ function Shell() {

return (
<div>
<header className="af-top">
<header className="af-top" ref={headerRef}>
<div className="af-top__in">
{/* Quiet ecosystem identity: the canonical Aesthetic Function mark
beside the app name. [ AF ] COMPOSER, not "Aesthetic Function Composer". */}
Expand Down
106 changes: 103 additions & 3 deletions apps/composer/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -225,14 +225,114 @@ 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;
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.
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;
}

/* 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;
}

/* A surface in the Astryx canvas narrows with the canvas. A2uiCanvas wraps
each surface in a grid item, which otherwise keeps its content's minimum
width: a form's inputs pushed the auth form 24 px past a 320 px viewport
(WCAG 1.4.10). Astryx only: a wide shadcn/ui table would then scroll in a
container shadcn does not make focusable (its fix is deferred). */
[data-astryx-canvas] [data-a2ui-surface] {
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. 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; 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
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%;
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;
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:not([aria-label])[data-size="sm"] {
--af-button-height: var(--size-element-sm, 28px);
}
[data-astryx-canvas] .astryx-button:not([aria-label])[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
(#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;
}

/* 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 {
Expand Down
13 changes: 7 additions & 6 deletions apps/composer/app/views/build-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -902,24 +902,25 @@ export function BuildView({ onNavigate }: { onNavigate?: (view: "surfaces" | "va
</div>
)}

{/* Each option wraps under its label instead of widening the page at 320 px (WCAG 1.4.10). */}
<div style={{ display: "flex", gap: 18, flexWrap: "wrap", alignItems: "center", margin: "2px 0 8px", fontSize: 12, color: "var(--fg-dim)" }}>
<label style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
<label style={{ display: "inline-flex", flexWrap: "wrap", alignItems: "center", gap: 6, maxWidth: "100%" }}>
<span className="af-label" style={{ margin: 0 }}>
provider
</span>
<select value={activeModel} onChange={(e) => setActiveModel(e.target.value)} data-testid="build-model" aria-label="Provider" style={{ fontFamily: "var(--mono)", fontSize: 12, background: "var(--bg-1)", color: "var(--fg)", border: "1px solid var(--line)", padding: "4px 6px", borderRadius: 2 }}>
<select value={activeModel} onChange={(e) => setActiveModel(e.target.value)} data-testid="build-model" aria-label="Provider" style={{ fontFamily: "var(--mono)", fontSize: 12, background: "var(--bg-1)", color: "var(--fg)", border: "1px solid var(--line)", padding: "4px 6px", borderRadius: 2, maxWidth: "100%" }}>
{selectableModels.map((m) => (
<option key={m} value={m}>
{providerLabel(m)}
</option>
))}
</select>
</label>
<label style={{ display: "inline-flex", alignItems: "center", gap: 6 }} title="Advanced: force a governed context instead of inferring it from your goal.">
<label style={{ display: "inline-flex", flexWrap: "wrap", alignItems: "center", gap: 6, maxWidth: "100%" }} title="Advanced: force a governed context instead of inferring it from your goal.">
<span className="af-label" style={{ margin: 0 }}>
context
</span>
<select value={intentOverride} onChange={(e) => setIntentOverride(e.target.value)} data-testid="build-intent" aria-label="Governance context (advanced override)" style={{ fontFamily: "var(--mono)", fontSize: 12, background: "var(--bg-1)", color: "var(--fg)", border: "1px solid var(--line)", padding: "4px 6px", borderRadius: 2 }}>
<select value={intentOverride} onChange={(e) => setIntentOverride(e.target.value)} data-testid="build-intent" aria-label="Governance context (advanced override)" style={{ fontFamily: "var(--mono)", fontSize: 12, background: "var(--bg-1)", color: "var(--fg)", border: "1px solid var(--line)", padding: "4px 6px", borderRadius: 2, maxWidth: "100%" }}>
<option value="">auto — inferred from your goal</option>
{intents.map((i) => (
<option key={i} value={i}>
Expand All @@ -930,7 +931,7 @@ export function BuildView({ onNavigate }: { onNavigate?: (view: "surfaces" | "va
</label>
{flows.some((f) => f.steps.length > 0) && (
<label
style={{ display: "inline-flex", alignItems: "center", gap: 6 }}
style={{ display: "inline-flex", flexWrap: "wrap", alignItems: "center", gap: 6, maxWidth: "100%" }}
title="Optional: accepting the next build also re-points this flow step at the accepted surface. Cleared after each accept."
>
<span className="af-label" style={{ margin: 0 }}>
Expand All @@ -941,7 +942,7 @@ export function BuildView({ onNavigate }: { onNavigate?: (view: "surfaces" | "va
onChange={(e) => setIntoStepKey(e.target.value)}
data-testid="build-flow-step"
aria-label="Flow step to re-bind on accept (optional)"
style={{ fontFamily: "var(--mono)", fontSize: 12, background: "var(--bg-1)", color: "var(--fg)", border: "1px solid var(--line)", padding: "4px 6px", borderRadius: 2 }}
style={{ fontFamily: "var(--mono)", fontSize: 12, background: "var(--bg-1)", color: "var(--fg)", border: "1px solid var(--line)", padding: "4px 6px", borderRadius: 2, maxWidth: "100%" }}
>
<option value="">none — accept without binding</option>
{flows.flatMap((f) =>
Expand Down
Loading
Loading