Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 17 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,20 @@ Add primitives with `pnpm dlx shadcn@latest add <component>`. `components.json`
selects Base UI, Nova, and Lucide. Theme defaults live in `src/styles/globals.css`;
components own their Tailwind classes. See [architecture](architecture.md) for
module ownership and the current layout.

## Accessibility

Oxlint enables `jsx-a11y` correctness rules. The semantic-tag preference is disabled
because SVG/canvas images and live regions legitimately use ARIA. Documentation
table regions retain keyboard focus for scrolling. Shared links and labels forward
content and associations explicitly so lint can inspect them.

The browser suite runs axe on Home, About, API, settings tabs, and editor/export
dialogs. Run just these checks with `pnpm run test:browser --grep 'accessibility:'`
after building. Tests check the rendered DOM, including generated ARIA references
that static lint cannot validate. Base UI owns tab-panel IDs; use `data-panel` for
stable test selectors rather than overriding those IDs.

For release review, also run Lighthouse on the production build and manually check
keyboard navigation, focus restoration, and screen-reader labels. A clean automated
audit does not establish complete accessibility.
9 changes: 8 additions & 1 deletion oxlint.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { defineConfig } from "oxlint";

export default defineConfig({
plugins: ["eslint", "unicorn", "oxc", "vitest", "typescript"],
plugins: ["eslint", "unicorn", "oxc", "vitest", "typescript", "jsx-a11y"],
categories: { correctness: "error" },
env: { node: true },
ignorePatterns: [
Expand All @@ -16,13 +16,20 @@ export default defineConfig({
"test-results/**",
],
rules: {
// SVG/canvas images and live status regions intentionally use ARIA roles.
"jsx-a11y/prefer-tag-over-role": "off",
"typescript/consistent-type-definitions": ["error", "type"],
"no-undef": "error",
"no-var": "error",
"prefer-const": "error",
"one-var": ["error", "never"],
},
overrides: [
{
files: ["src/components/docs-page.tsx"],
// Keyboard users must be able to scroll wide documentation tables.
rules: { "jsx-a11y/no-noninteractive-tabindex": "off" },
},
{
files: ["src/**/*.js", "src/**/*.mjs", "src/**/*.ts", "src/**/*.tsx"],
env: { node: false, browser: true },
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@axe-core/playwright": "^4.13.0",
"@playwright/test": "^1.63.0",
"@tailwindcss/vite": "^4.3.3",
"@types/d3-color": "^3.1.3",
Expand Down
19 changes: 19 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 7 additions & 2 deletions src/components/dialogs/algorithm-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,12 @@ export function AlgorithmDialog({
</TabsList>
)}
{!adding && (
<TabsContent value="information" id="sort-information" keepMounted className="min-h-72">
<TabsContent
value="information"
data-panel="sort-information"
keepMounted
className="min-h-72"
>
<table>
<tbody>
{(
Expand Down Expand Up @@ -150,7 +155,7 @@ export function AlgorithmDialog({
)}
<TabsContent
value="algorithm"
id={adding ? "new-sort-algorithm" : "sort-algorithm"}
data-panel={adding ? "new-sort-algorithm" : "sort-algorithm"}
keepMounted
>
<div className="js-editor h-72 w-full" ref={host} />
Expand Down
4 changes: 2 additions & 2 deletions src/components/docs-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function DocsPage({
components?: Components;
}) {
return (
<article
<main
id={id}
className="mx-auto my-8 w-[calc(100%-2rem)] max-w-7xl text-base leading-relaxed [&_h1]:mb-6 [&_h1]:text-4xl [&_h1]:font-bold [&_h2]:mt-8 [&_li]:my-2 [&_ol]:mb-4 [&_ol]:list-decimal [&_ol]:pl-6 [&_ul]:mb-4 [&_ul]:list-disc [&_ul]:pl-6"
>
Expand Down Expand Up @@ -51,6 +51,6 @@ export function DocsPage({
>
{content}
</Markdown>
</article>
</main>
);
}
2 changes: 1 addition & 1 deletion src/components/layout/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function Footer() {
skratchdot{" "}
<img
className="inline size-4"
alt="skratchdot"
alt=""
src={`${import.meta.env.BASE_URL}img/favicon.ico`}
/>
</TextLink>
Expand Down
8 changes: 4 additions & 4 deletions src/components/playground/settings-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export function Settings({ runtime }: Props) {
))}
</TabsList>
<div id="settings-content" className="min-h-72 rounded-lg border bg-card p-4">
<TabsContent value="audio" id="audio" keepMounted>
<TabsContent value="audio" data-panel="audio" keepMounted>
{(
[
["volume", "Volume", selected.volume.toFixed(2)],
Expand Down Expand Up @@ -102,10 +102,10 @@ export function Settings({ runtime }: Props) {
{selected.audioType} settings
</Button>
</TabsContent>
<TabsContent value="waveform" id="waveform" keepMounted>
<TabsContent value="waveform" data-panel="waveform" keepMounted>
<WaveformControls store={store} />
</TabsContent>
<TabsContent value="scale" id="scale" keepMounted>
<TabsContent value="scale" data-panel="scale" keepMounted>
<FilteredOptions
id="scale"
label="Scale"
Expand All @@ -114,7 +114,7 @@ export function Settings({ runtime }: Props) {
onSelect={(value) => update({ key: "scale", value })}
/>
</TabsContent>
<TabsContent value="soundfont" id="soundfont" keepMounted>
<TabsContent value="soundfont" data-panel="soundfont" keepMounted>
<FilteredOptions
id="soundfont"
label="Soundfont"
Expand Down
6 changes: 4 additions & 2 deletions src/components/text-link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,17 @@ import { createLink } from "@tanstack/react-router";
import type { ComponentProps } from "react";
import { cn } from "@/utilities/cn";

export function TextLink({ className, ...props }: ComponentProps<"a">) {
export function TextLink({ className, children, ...props }: ComponentProps<"a">) {
return (
<a
className={cn(
"text-sky-700 underline-offset-4 hover:text-sky-800 hover:underline focus-visible:rounded focus-visible:outline-2 focus-visible:outline-ring",
className,
)}
{...props}
/>
>
{children}
</a>
);
}

Expand Down
3 changes: 2 additions & 1 deletion src/components/ui/label.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
import * as React from "react";
import { cn } from "cn";

function Label({ className, ...props }: React.ComponentProps<"label">) {
function Label({ className, htmlFor, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
htmlFor={htmlFor}
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
Expand Down
3 changes: 2 additions & 1 deletion src/components/ui/tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
);
}

function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
// Base UI owns panel IDs so tab aria-controls references stay in sync.
function TabsContent({ className, ...props }: Omit<TabsPrimitive.Panel.Props, "id">) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
Expand Down
48 changes: 48 additions & 0 deletions test/browser/accessibility.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";

async function checkAccessibility(page) {
const { violations } = await new AxeBuilder({ page }).analyze();
expect(
violations.map(({ id, nodes }) => ({
id,
nodes: nodes.map(({ target, failureSummary }) => ({ target, failureSummary })),
})),
).toEqual([]);
}

for (const route of ["./", "about", "api"]) {
test(`accessibility: ${route}`, async ({ page }) => {
await page.goto(route);
if (route === "./") await expect(page.locator("#base-svg rect")).toHaveCount(12);
await checkAccessibility(page);
});
}

test("accessibility: settings and dialogs", async ({ page }) => {
await page.goto("./");
await expect(page.locator("#base-svg rect")).toHaveCount(12);
for (const tab of await page.getByRole("tab").all()) {
await tab.click();
await checkAccessibility(page);
}
await page.getByRole("tab", { name: "Audio", exact: true }).click();
await page.locator('[data-audio-type="soundfont"]').click();
await page.getByRole("tab", { name: "SoundFont", exact: true }).click();
await checkAccessibility(page);
await page.locator("#modal-sort-open").click();
await checkAccessibility(page);
await page.getByRole("tab", { name: "Algorithm", exact: true }).click();
await expect(page.getByRole("button", { name: "Save changes" })).toBeEnabled();
await checkAccessibility(page);
await page.keyboard.press("Escape");
await page.locator("#add-algorithm-btn").click();
await expect(page.locator(".ace_editor")).toBeVisible();
await checkAccessibility(page);
await page.keyboard.press("Escape");
for (const id of ["base", "sort"]) {
await page.locator(`[data-midi-export="${id}"]`).click();
await checkAccessibility(page);
await page.keyboard.press("Escape");
}
});
Loading