diff --git a/desktop-next/README.md b/desktop-next/README.md index c0f4d06b22..0fe5e34c4c 100644 --- a/desktop-next/README.md +++ b/desktop-next/README.md @@ -13,6 +13,20 @@ pnpm --filter buzz-desktop-next dev Open `http://localhost:1430/design`. Use another port when needed: `pnpm --filter buzz-desktop-next dev --port 5188`. +## Focused component pages + +Click a component heading or **Explore states** in the catalog to open +`/design/components/` (for example, `/design/components/input`). Every catalog +entry has a dedicated URL with an isolated live playground, component-specific +state guidance, and its example module. Fields, checkboxes, switches, tabs, +progress, and meters also include side-by-side state samples using the production +components. Reset example restores both the playground and its state samples. + +Use the component picker or Previous / Next links to move through the catalog. +All components returns to that component's original catalog anchor. Direct links +and refreshes work with the same SPA fallback as the rest of the site; unknown +component IDs have a recovery link. Theme and density preferences are shared. + ## Appearance The navigation contains independent theme and Normal / Compact controls. Density diff --git a/desktop-next/TESTING.md b/desktop-next/TESTING.md index 1e02454541..60fce71561 100644 --- a/desktop-next/TESTING.md +++ b/desktop-next/TESTING.md @@ -48,3 +48,11 @@ Density coverage checks pointer/keyboard switching, root and portal geometry, unchanged text size, retained drafts, reload and cross-tab persistence, invalid stored values, save failure and retry, live foundation values, compositions, Glass tab selection, and all 55 examples at 390/884/1440 in both themes. + +Focused-page coverage visits all 55 catalog deep links and checks that only the +selected playground renders. It exercises catalog links, previous/next navigation, +the component picker, anchor return, unknown-ID recovery, reset of live and state +examples, invalid/read-only/disabled fields, indeterminate checkbox interaction, +disabled and selected tabs, portal density, refresh, and appearance preferences. +Representative pages are checked at 390/884/1440 in both densities and audited in +both themes, with distinct per-component screenshots. diff --git a/desktop-next/src/app/routes/design/components_.$componentId.tsx b/desktop-next/src/app/routes/design/components_.$componentId.tsx new file mode 100644 index 0000000000..b7263321ea --- /dev/null +++ b/desktop-next/src/app/routes/design/components_.$componentId.tsx @@ -0,0 +1,24 @@ +import { createFileRoute, notFound } from "@tanstack/react-router"; +import { CATALOG } from "@/features/design-system/catalog/registry"; +import { + ComponentNotFound, + ComponentPage, +} from "@/features/design-system/ui/ComponentPage"; + +// The underscore keeps the URL nested without rendering the entire catalog above it. +export const Route = createFileRoute("/design/components_/$componentId")({ + beforeLoad: ({ params }) => { + const entry = CATALOG.find( + (candidate) => candidate.id === params.componentId, + ); + if (!entry) throw notFound(); + return { entry }; + }, + component: FocusedComponent, + notFoundComponent: ComponentNotFound, +}); + +function FocusedComponent() { + const { entry } = Route.useRouteContext(); + return ; +} diff --git a/desktop-next/src/features/design-system/catalog/catalog.css b/desktop-next/src/features/design-system/catalog/catalog.css index 46a2fa2b55..cc94cca40d 100644 --- a/desktop-next/src/features/design-system/catalog/catalog.css +++ b/desktop-next/src/features/design-system/catalog/catalog.css @@ -43,6 +43,34 @@ outline: 2px solid var(--ring-focus); outline-offset: 4px; } +.component-workspace { + display: grid; + gap: var(--space-page-inset); + grid-template-columns: minmax(0, 1fr); +} +.component-playground { + display: grid; + align-items: center; + min-height: var(--size-catalog-preview); + padding-block: var(--space-panel-inset); +} +.component-playground > * { + min-width: 0; + max-width: 100%; +} +.component-state-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr)); + gap: var(--space-panel-inset); +} +.component-page > header h1:focus { + outline: none; +} +@media (min-width: 1280px) { + .component-workspace { + grid-template-columns: minmax(0, 1fr) 16rem; + } +} @media (max-width: 760px) { .catalog-grid { grid-template-columns: minmax(0, 1fr); diff --git a/desktop-next/src/features/design-system/catalog/state-guides.ts b/desktop-next/src/features/design-system/catalog/state-guides.ts new file mode 100644 index 0000000000..9b06368481 --- /dev/null +++ b/desktop-next/src/features/design-system/catalog/state-guides.ts @@ -0,0 +1,228 @@ +/** States reachable in the live examples, rather than simulated screenshots. */ +export const STATE_GUIDES: Record = { + "ai-composer": [ + "Start empty, then write a multiline draft with Shift+Enter.", + "Add or remove context and attachments; choose a model and action policy.", + "Send to see generation and Stop. Simulate a send failure to inspect recovery.", + ], + buttons: [ + "Compare all six emphasis variants and the three sizes.", + "Inspect disabled and loading actions alongside enabled buttons.", + "Hover, press, or Tab to a button to compare pointer and keyboard feedback.", + ], + input: [ + "Compare empty, filled, invalid, read-only, and disabled fields below.", + "Tab into the editable field and type to inspect its focus and content states.", + ], + textarea: [ + "Compare empty, filled, invalid, read-only, and disabled writing areas.", + "Write multiple lines and resize the editable example.", + ], + "input-group": [ + "Type a query and inspect how the leading icon and shortcut stay aligned.", + "Tab into the input to see the shared focus treatment.", + ], + "form-field": [ + "Submit empty or malformed email to inspect validation.", + "Enter a valid email and submit to see confirmation.", + ], + checkbox: [ + "Toggle the live choice with a click or Space.", + "Compare unchecked, checked, indeterminate, and disabled states below.", + ], + "checkbox-group-fieldset": [ + "Select several channels, then deselect them independently.", + "Tab between choices and use Space to change selection.", + ], + "radio-group": [ + "Select either visibility option.", + "Use arrow keys to move the single selection within the group.", + ], + switch: [ + "Switch the live setting on and off with a click or Space.", + "Compare on, off, and disabled examples below.", + ], + slider: [ + "Drag the thumb or use arrow keys to adjust the value.", + "Use Home and End to inspect the minimum and maximum.", + ], + "number-field": [ + "Type a number, or use the increment and decrement actions.", + "Try the bounds of 1 and 12 to inspect unavailable step actions.", + ], + "input-otp": [ + "Enter a six-digit code; inspect the active digit as focus advances.", + "Paste a code, or use Backspace to edit earlier digits.", + ], + combobox: [ + "Open the choices, then type to filter them.", + "Search for a nonexistent project to see the empty state.", + "Use arrow keys and Enter to select; Escape dismisses the popup.", + ], + autocomplete: [ + "Type a known project to inspect suggestions.", + "Enter a new project name to retain free text.", + "Use arrow keys to explore suggestions and Escape to dismiss.", + ], + command: [ + "Type to filter actions or show an empty result.", + "Choose with Enter or a click; filtering alone does not execute an action.", + ], + calendar: [ + "Select or clear a date and navigate between months.", + "Compare today and the selected day, including keyboard focus.", + ], + dialog: [ + "Open the dialog to inspect its title, description, and action.", + "Tab through the modal, then dismiss with Done or Escape to restore trigger focus.", + ], + "alert-dialog": [ + "Open the decision and cancel with Keep draft.", + "Confirm deletion to see the local result, then Restore.", + ], + sheet: [ + "Open the side surface and inspect its content at different widths.", + "Dismiss with Close details or Escape to return focus.", + ], + drawer: [ + "Open the bottom surface and inspect its anchored position.", + "Use Continue building or Escape to dismiss.", + ], + popover: [ + "Open the anchored share options.", + "Dismiss with Got it, Escape, or a click outside.", + ], + tooltip: [ + "Hover the trigger or focus it with the keyboard.", + "Move away or press Escape to dismiss the help.", + ], + "hover-card": [ + "Hover the team link to inspect its preview.", + "Move away to close; essential link text stays on the page.", + ], + "dropdown-menu": [ + "Open the menu and explore the highlighted item with arrow keys.", + "Choose an action to see the local result, or Escape to dismiss.", + ], + "context-menu": [ + "Right-click the target, or focus it and press Shift+F10.", + "Choose Copy link or Mark as unread to inspect the result.", + ], + select: [ + "Open the choices and inspect the selected indicator.", + "Use arrow keys and Enter to choose another digest frequency.", + ], + toast: [ + "Show a notification to inspect its entrance and status text.", + "Show several to inspect stacking; dismiss with the close action.", + ], + accordion: [ + "Expand and collapse each heading.", + "Use Tab and Enter or Space to compare keyboard interaction.", + ], + collapsible: [ + "Reveal the implementation notes and hide them again.", + "Focus the trigger to inspect its expanded and collapsed semantics.", + ], + tabs: [ + "Switch between Overview, Activity, and Files to inspect the sliding indicator.", + "Use arrow keys to focus a tab, then Enter to select it.", + "Compare the solid and glass tracks and the disabled tab below.", + ], + toggle: [ + "Press to pin, then press again to unpin.", + "Use Space to compare the same states with the keyboard.", + ], + "toggle-group": [ + "Switch the timeline scale between day, week, and month.", + "Inspect selected and unselected choices with keyboard navigation.", + ], + toolbar: [ + "Use arrow keys to move among the grouped actions.", + "Activate an action to inspect its local status result.", + ], + menubar: [ + "Open Project or View, then move among their menu items.", + "Choose an action or dismiss with Escape.", + ], + "navigation-menu": [ + "Open Foundations to reveal its nested destinations.", + "Follow a link to inspect navigation into a foundation page.", + ], + breadcrumb: [ + "Compare linked ancestors and the current location.", + "Tab to an ancestor and follow it back to the catalog.", + ], + sidebar: [ + "Inspect the current destination alongside the other links.", + "Tab through the destinations and follow a link.", + ], + pagination: [ + "Move through pages 1 to 3.", + "Compare the current page and the disabled Previous or Next action at each bound.", + ], + card: [ + "Inspect the label, grouped heading, body, and action as one composition.", + "Switch density to compare panel insets and heading spacing.", + ], + badge: [ + "Compare neutral, accent, success, warning, danger, and information tones.", + "Compare the text-only badges with the icon and text success badge.", + ], + alert: [ + "Compare information, success, warning, and failure messages.", + "Switch theme to inspect text and fill pairings.", + ], + avatar: [ + "Compare initials and the agent icon fallback.", + "Switch theme and density to inspect identity at the shared control size.", + ], + progress: [ + "Compare empty, in-progress, and complete values below.", + "Inspect both the visual fill and the accessible value.", + ], + meter: [ + "Compare low, medium, and full measurements below.", + "Inspect the value label alongside the fill.", + ], + skeleton: [ + "Inspect the loading region and its differently sized placeholders.", + "Enable reduced motion in your system to remove the pulse.", + ], + spinner: [ + "Inspect the named loading indicator.", + "Enable reduced motion in your system to remove rotation.", + ], + "empty-state": [ + "Inspect the purpose, explanation, and next action together.", + "Follow the action to see a populated composition.", + ], + separator: [ + "Inspect the boundary between two related text regions.", + "Switch theme to compare the quiet divider treatment.", + ], + table: [ + "Inspect column headings, left alignment, and status cells.", + "At a narrow width, scroll within the table rather than the whole page.", + ], + chart: [ + "Compare category labels, values, and proportional bars.", + "Switch density to inspect the spacing between data rows.", + ], + "aspect-ratio": [ + "Resize the viewport to inspect the preserved 16:9 frame.", + "Content keeps its proportions in either density.", + ], + carousel: [ + "Move through the three slides using Next and Previous.", + "Inspect the announced position and disabled actions at the ends.", + ], + "scroll-area": [ + "Scroll through all 20 activities.", + "Inspect the thin thumb as its position changes; keyboard scrolling works in the viewport.", + ], + resizable: [ + "Drag the separator to resize the two regions.", + "Focus the separator and use arrow keys to resize; the visible grip remains separate from its hit area.", + ], +}; diff --git a/desktop-next/src/features/design-system/catalog/states/fields.tsx b/desktop-next/src/features/design-system/catalog/states/fields.tsx new file mode 100644 index 0000000000..c51dacb414 --- /dev/null +++ b/desktop-next/src/features/design-system/catalog/states/fields.tsx @@ -0,0 +1,39 @@ +import { Field, Input, Textarea } from "@buzz/ui"; + +/** Field states use the production field and input semantics. */ +export function FieldStates({ + kind = "input", +}: { + kind?: "input" | "textarea"; +}) { + const Control = kind === "textarea" ? Textarea : Input; + return ( +
+ {(["Empty", "Filled", "Invalid", "Read-only", "Disabled"] as const).map( + (state) => ( + + {state} + + {state === "Invalid" && ( + Choose a different project name. + )} + + ), + )} +
+ ); +} + +export function TextareaStates() { + return ; +} diff --git a/desktop-next/src/features/design-system/catalog/states/measurements.tsx b/desktop-next/src/features/design-system/catalog/states/measurements.tsx new file mode 100644 index 0000000000..414864e507 --- /dev/null +++ b/desktop-next/src/features/design-system/catalog/states/measurements.tsx @@ -0,0 +1,47 @@ +import { Meter, Progress } from "@buzz/ui"; + +/** Empty, partial, and complete values rendered by the actual progress API. */ +export function ProgressStates() { + return ( +
+ {[0, 50, 100].map((value) => ( + +
+ + {value === 0 + ? "Not started" + : value === 100 + ? "Complete" + : "In progress"} + + +
+ + + +
+ ))} +
+ ); +} + +/** A meter measures a bounded quantity independently of task completion. */ +export function MeterStates() { + return ( +
+ {[10, 50, 100].map((value) => ( + +
+ + {value === 10 ? "Low usage" : value === 50 ? "Half used" : "Full"} + + +
+ + + +
+ ))} +
+ ); +} diff --git a/desktop-next/src/features/design-system/catalog/states/selection.tsx b/desktop-next/src/features/design-system/catalog/states/selection.tsx new file mode 100644 index 0000000000..18d5f7ef63 --- /dev/null +++ b/desktop-next/src/features/design-system/catalog/states/selection.tsx @@ -0,0 +1,98 @@ +import { useId, useState } from "react"; +import { Checkbox, Switch, Tabs } from "@buzz/ui"; +import { Check, Minus } from "lucide-react"; + +function CheckboxSample({ label }: { label: string }) { + const id = useId(); + const [checked, setChecked] = useState( + label === "Indeterminate" ? "mixed" : label === "Checked", + ); + return ( + + ); +} + +/** Independent, interactive check states with native disabled semantics. */ +export function CheckboxStates() { + return ( +
+ {["Unchecked", "Checked", "Indeterminate", "Disabled"].map((label) => ( + + ))} +
+ ); +} + +/** Switch samples retain the same keyboard and pointer behavior as the catalog. */ +export function SwitchStates() { + const id = useId(); + return ( +
+ {["Off", "On", "Disabled off", "Disabled on"].map((state) => ( + + ))} +
+ ); +} + +/** Both supported tab materials and a disabled destination. */ +export function TabsStates() { + return ( +
+ {(["solid", "glass"] as const).map((variant) => ( +
+

+ {variant === "glass" ? "Glass" : "Solid"} +

+ + + Overview + Activity + + Files + + + + People building together. + Three tasks completed. + +
+ ))} +
+ ); +} diff --git a/desktop-next/src/features/design-system/ui/ComponentPage.tsx b/desktop-next/src/features/design-system/ui/ComponentPage.tsx new file mode 100644 index 0000000000..2d0f48d8b8 --- /dev/null +++ b/desktop-next/src/features/design-system/ui/ComponentPage.tsx @@ -0,0 +1,207 @@ +import { useEffect, useRef, useState } from "react"; +import { Link, useNavigate } from "@tanstack/react-router"; +import { Button, Select } from "@buzz/ui"; +import { Check, ChevronDown, RotateCcw } from "lucide-react"; +import { CATALOG, type CatalogEntry } from "../catalog/registry"; +import { STATE_GUIDES } from "../catalog/state-guides"; +import { FieldStates, TextareaStates } from "../catalog/states/fields"; +import { + CheckboxStates, + SwitchStates, + TabsStates, +} from "../catalog/states/selection"; +import { MeterStates, ProgressStates } from "../catalog/states/measurements"; +import { PageHeader } from "./primitives"; + +const STATE_EXAMPLES = { + input: FieldStates, + textarea: TextareaStates, + checkbox: CheckboxStates, + switch: SwitchStates, + tabs: TabsStates, + progress: ProgressStates, + meter: MeterStates, +}; +const choices = CATALOG.map((entry) => ({ + value: entry.id, + label: entry.name, +})); + +function ComponentPicker({ entry }: { entry: CatalogEntry }) { + const navigate = useNavigate(); + return ( + { + if (value) + void navigate({ + to: "/design/components/$componentId", + params: { componentId: value }, + }); + }} + > + + + + + + + + + + {choices.map((choice) => ( + + {choice.label} + + + + ))} + + + + + + ); +} + +/** One component's live behavior, visible states, and navigation through the catalog. */ +export function ComponentPage({ entry }: { entry: CatalogEntry }) { + const [revision, setRevision] = useState(0); + const heading = useRef(null); + const index = CATALOG.findIndex((candidate) => candidate.id === entry.id); + const previous = CATALOG[index - 1]; + const next = CATALOG[index + 1]; + const StateExamples = STATE_EXAMPLES[entry.id as keyof typeof STATE_EXAMPLES]; + + useEffect(() => { + const originalTitle = document.title; + document.title = `${entry.name} — Buzz Design System`; + heading.current?.focus({ preventScroll: true }); + return () => { + document.title = originalTitle; + }; + }, [entry.name]); + + return ( +
+
+ + ← All components + + +
+
+

{entry.category}

+

+ {entry.name} +

+

+ {entry.description} +

+
+
+
+
+

+ Playground +

+ +
+
+ +
+
+ +
+ {StateExamples && ( +
+

+ State examples +

+
+ +
+
+ )} +
+ + View example module + +
+          {entry.source}
+        
+
+ +
+ ); +} + +/** Keep an unknown deep link recoverable within the design-system navigation. */ +export function ComponentNotFound() { + return ( + <> + + + Browse all components + + + ); +} diff --git a/desktop-next/src/features/design-system/ui/ComponentsPage.tsx b/desktop-next/src/features/design-system/ui/ComponentsPage.tsx index 44546f562c..a6a051641e 100644 --- a/desktop-next/src/features/design-system/ui/ComponentsPage.tsx +++ b/desktop-next/src/features/design-system/ui/ComponentsPage.tsx @@ -1,4 +1,5 @@ import { useMemo, useState } from "react"; +import { Link } from "@tanstack/react-router"; import { Button, Field, Input } from "@buzz/ui"; import { CATALOG } from "../catalog/registry"; import { PageHeader } from "./primitives"; @@ -67,7 +68,12 @@ export function ComponentsPage() {

{entry.category}

- {entry.name} + + {entry.name} +

{entry.description} @@ -76,6 +82,14 @@ export function ComponentsPage() {

+ + Explore states → +
View example source diff --git a/desktop-next/tests/component-pages.spec.ts b/desktop-next/tests/component-pages.spec.ts new file mode 100644 index 0000000000..3179bb62cd --- /dev/null +++ b/desktop-next/tests/component-pages.spec.ts @@ -0,0 +1,162 @@ +import AxeBuilder from "@axe-core/playwright"; +import { expect, test } from "@playwright/test"; +import { waitForAnimations } from "../../desktop/tests/helpers/animations"; + +test("every catalog entry links to its own focused component page", async ({ + page, +}) => { + await page.goto("/design/components"); + const entries = await page + .locator(".catalog-entry h2 a") + .evaluateAll((links) => + links.map((link) => ({ + name: link.textContent ?? "", + href: link.getAttribute("href") ?? "", + })), + ); + expect(entries).toHaveLength(55); + expect(new Set(entries.map((entry) => entry.href)).size).toBe(55); + for (const entry of entries) { + await page.goto(entry.href); + await expect( + page.getByRole("heading", { level: 1, name: entry.name, exact: true }), + ).toBeVisible(); + await expect(page.locator(".component-playground")).toHaveCount(1); + await expect(page.locator(".catalog-entry")).toHaveCount(0); + expect( + await page + .getByRole("complementary", { name: "States to explore" }) + .getByRole("listitem") + .count(), + ).toBeGreaterThan(0); + await expect(page).toHaveTitle(`${entry.name} — Buzz Design System`); + } +}); + +test("catalog navigation, picker, previous/next, reset, and invalid links work", async ({ + page, +}) => { + await page.goto("/design/components"); + await page.getByRole("button", { name: "Compact", exact: true }).click(); + await page.getByRole("link", { name: "Explore Input", exact: true }).click(); + await expect(page).toHaveURL(/\/design\/components\/input$/); + await expect(page.getByRole("heading", { level: 1 })).toBeFocused(); + const input = page.getByRole("textbox", { + name: "Project name", + exact: true, + }); + await input.fill("A temporary draft"); + await expect(input).toHaveCSS("height", "32px"); + await page.getByRole("button", { name: "Reset example" }).click(); + await expect(input).toHaveValue(""); + await page + .getByRole("link", { name: "Next: Textarea", exact: false }) + .click(); + await expect(page).toHaveURL(/\/textarea$/); + await page + .getByRole("link", { name: "Previous: Input", exact: false }) + .click(); + await expect(page).toHaveURL(/\/input$/); + await page.getByRole("combobox", { name: "Jump to component" }).click(); + await page.getByRole("option", { name: "Dialog", exact: true }).click(); + await expect(page).toHaveURL(/\/dialog$/); + await page.getByRole("button", { name: "Edit project", exact: true }).click(); + await expect(page.getByRole("dialog")).toBeVisible(); + await expect(page.getByRole("dialog")).toHaveCSS("padding", "16px"); + await page.keyboard.press("Escape"); + await page.reload(); + await expect( + page.getByRole("heading", { level: 1, name: "Dialog" }), + ).toBeVisible(); + await page + .getByRole("link", { name: "All components", exact: false }) + .filter({ hasText: "←" }) + .click(); + await expect(page).toHaveURL(/\/design\/components#dialog$/); + await expect(page.locator("#dialog")).toBeInViewport(); + await page.goto("/design/components/not-a-component"); + await expect( + page.getByRole("heading", { name: "Component not found" }), + ).toBeVisible(); + await page.getByRole("link", { name: "Browse all components" }).click(); + await expect(page.locator(".catalog-entry")).toHaveCount(55); +}); + +test("state samples use real validation, disabled, selection, and reset behavior", async ({ + page, +}) => { + await page.goto("/design/components/input"); + const invalid = page.getByRole("textbox", { name: "Invalid", exact: true }); + await expect(invalid).toHaveAttribute("aria-invalid", "true"); + await expect( + page.getByText("Choose a different project name."), + ).toBeVisible(); + await expect( + page.getByRole("textbox", { name: "Disabled", exact: true }), + ).toBeDisabled(); + await expect( + page.getByRole("textbox", { name: "Read-only", exact: true }), + ).not.toBeEditable(); + await page.goto("/design/components/checkbox"); + const mixed = page.getByRole("checkbox", { + name: "Indeterminate", + exact: true, + }); + await expect(mixed).toHaveAttribute("aria-checked", "mixed"); + await mixed.focus(); + await mixed.press("Space"); + await expect(mixed).toBeChecked(); + await expect(mixed).not.toHaveAttribute("aria-checked", "mixed"); + await page.getByRole("button", { name: "Reset example" }).click(); + await expect(mixed).toHaveAttribute("aria-checked", "mixed"); + await page.goto("/design/components/tabs"); + const tabs = page.getByRole("tablist", { name: "glass example" }); + await expect(tabs.getByRole("tab", { name: "Files" })).toHaveAttribute( + "aria-disabled", + "true", + ); + await tabs.getByRole("tab", { name: "Activity" }).click(); + await expect(tabs.getByRole("tab", { name: "Activity" })).toHaveAttribute( + "aria-selected", + "true", + ); + await page.getByRole("button", { name: "Reset example" }).click(); + await expect(tabs.getByRole("tab", { name: "Overview" })).toHaveAttribute( + "aria-selected", + "true", + ); +}); + +test("focused pages remain readable and accessible in both themes and densities", async ({ + page, +}, testInfo) => { + for (const component of ["input", "ai-composer", "tabs"]) { + await page.goto(`/design/components/${component}`); + for (const width of [390, 884, 1440]) { + await page.setViewportSize({ width, height: 960 }); + for (const density of ["Normal", "Compact"]) { + await page.getByRole("button", { name: density, exact: true }).click(); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth, + ), + ).toBe(true); + } + } + for (const theme of ["light", "dark"]) { + const toggle = page.getByRole("button", { + name: `Switch to ${theme} mode`, + }); + if (await toggle.count()) await toggle.click(); + const result = await new AxeBuilder({ page }) + .disableRules(["color-contrast"]) + .analyze(); + expect(result.violations).toEqual([]); + await page.getByRole("heading", { level: 1 }).scrollIntoViewIfNeeded(); + await waitForAnimations(page); + await page.screenshot({ + path: testInfo.outputPath(`${component}-${theme}.png`), + }); + } + } +});