diff --git a/apps/composer/app/af-ui.css b/apps/composer/app/af-ui.css index a1d79e2..7c73d64 100644 --- a/apps/composer/app/af-ui.css +++ b/apps/composer/app/af-ui.css @@ -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, 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..195d91b --- /dev/null +++ b/apps/composer/app/astryx-renderers-a11y.test.ts @@ -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>; +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 display variants as h1 and h2, 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>/); + }); + + 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(/

]*>Group<\/p>/); + expect(html).not.toMatch(/ { + 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(/]*>[^<]*<\/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(/

]*>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("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, 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"); + 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"); + }); +}); diff --git a/apps/composer/app/composer.tsx b/apps/composer/app/composer.tsx index 05bbb1c..ecdb2c0 100644 --- a/apps/composer/app/composer.tsx +++ b/apps/composer/app/composer.tsx @@ -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(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. @@ -87,7 +112,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/globals.css b/apps/composer/app/globals.css index 945badc..d2f63a9 100644 --- a/apps/composer/app/globals.css +++ b/apps/composer/app/globals.css @@ -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 { 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). */}
-