@setsquare/ui
v0.16.0
Published
React Aria components, design tokens, a dashboard shell and a site shell, with a switch between WCAG 2.2 AA and AAA sizing, spacing and contrast.
Maintainers
Readme
@setsquare/ui
React components, design tokens, a dashboard shell and a site shell for public pages, built on React Aria Components and Tailwind CSS 4. One attribute switches every size, space and colour that WCAG 2.2 sets a level for between AA and AAA:
<html data-conformance="aaa">Setsquare's own portal runs at AAA. Docuccino runs at AA and can offer AAA as a per-person setting.
Controls are built in three layers, so the switch changes targets without changing what people see: at AAA a 36px button is still painted at 36px, inside a 44px target.
What this is and isn't. The components are built to meet WCAG 2.2 AA, and AAA when AAA is switched on, and they are tested for it (see What is tested). Automated tests are evidence that the automated checks passed. They are not a conformance claim, and a product is not conformant because it uses these components: content, reading level, link text, headings and error wording are the product's.
Install
pnpm add @setsquare/ui react-aria-components @internationalized/dateIt needs React 19.2 or later, React Aria Components 1.21 or later, and Tailwind CSS 4 in the app. It brings lucide-react as a dependency at ^1.48.0, so an app on a newer 1.x shares one copy rather than bundling two. A module is a client component ('use client') only when it needs to be: React Aria, a hook, a context or an event handler. The rest — StatusBadge, Avatar, ProductMark, Kbd, EmptyState, IconButton, PageHeader, PageContent, the site shell's SiteShell, SiteMain, SiteHeader, SiteNav, SiteFooter and SiteFooterGroup, the content components Section, SectionHeader, Container, Eyebrow, Hero, HeroHighlight, Card, CardGrid, Prose, Steps, Step, Split, Tag, StatList and Stat, CodeBlock, ThemeScript, buttonTargetStyles() and buttonVisualStyles() — have no directive, so a server component uses them without shipping them to the browser. Server components can render either kind. A server component that gives a client component an icon (Alert, MenuItem, SidebarItem, RailItem, a search item) passes the element, icon={<Folder />}: a component can't cross into a client component.
In the app's global stylesheet, after Tailwind:
@import "tailwindcss";
@import "@setsquare/ui/styles.css";styles.css brings the tokens as CSS variables (four themes), Tailwind's theme mapping (bg-primary, h-control, min-h-target…), the focus-ring and type utilities, the dark: and aaa: variants, and an @source that points Tailwind at the package's compiled components, which Tailwind doesn't scan in node_modules by itself.
Load the fonts yourself: the tokens name Atkinson Hyperlegible Next and Atkinson Hyperlegible Mono. next/font/google downloads them at build time and serves them from your own origin; or set --font-sans and --font-mono to your own.
To serve them from the app with no Google request at build time either (a build with no network, a Content-Security-Policy at font-src 'self'), install the variable fonts from Fontsource (@fontsource-variable/atkinson-hyperlegible-next and -mono, SIL OFL 1.1) and load them with next/font/local:
// app/layout.tsx
import localFont from 'next/font/local';
const sans = localFont({
src: [
{ path: '../node_modules/@fontsource-variable/atkinson-hyperlegible-next/files/atkinson-hyperlegible-next-latin-wght-normal.woff2', weight: '200 800', style: 'normal' },
{ path: '../node_modules/@fontsource-variable/atkinson-hyperlegible-next/files/atkinson-hyperlegible-next-latin-wght-italic.woff2', weight: '200 800', style: 'italic' },
],
variable: '--font-atkinson-next',
display: 'swap',
});
const mono = localFont({
src: [
{ path: '../node_modules/@fontsource-variable/atkinson-hyperlegible-mono/files/atkinson-hyperlegible-mono-latin-wght-normal.woff2', weight: '200 800', style: 'normal' },
],
variable: '--font-atkinson-mono',
display: 'swap',
});
// <html className={`${sans.variable} ${mono.variable}`}>/* globals.css, after @setsquare/ui/styles.css. The fallback inside var() keeps the stack valid on a page
that doesn't load the fonts (a global-error page). */
:root {
--font-sans: var(--font-atkinson-next, 'Atkinson Hyperlegible Next'), ui-sans-serif, system-ui, sans-serif;
--font-mono: var(--font-atkinson-mono, 'Atkinson Hyperlegible Mono'), ui-monospace, monospace;
}next/font gives the face a generated family name, which is why the stack points at its variable rather than at the name the tokens use. Add the latin-ext files too if your copy needs them.
import { Button, DashboardShell, ShellMain, TextField } from '@setsquare/ui';Add-ons
Diagrams of workflows and resource maps are in an add-on, @setsquare/ui-flows. It's installed on its own, so a product that doesn't draw diagrams carries none of React Flow.
A page builder is in another, @setsquare/ui-blocks, on Puck. A product that doesn't build pages carries none of Puck.
Themes and levels
<html data-conformance="aa|aaa" class="dark?">picks one of four themes: Light · AA, Dark · AA, Light · AAA, Dark · AAA.- Components never read an
-aaor-aaatoken directly. They read the alias (--target-min,--control-height,--choice-size,--focus-width…) through its utility (min-h-target,h-control,size-choice,focus-ring), so no component code changes between levels. The design system's README lists every one. - A product brings its
brandandbrand-subtlecolours.pnpm lintrunsscripts/check-contrast.mjs, which checks every pair the token notes promise in all four themes. Run it again after changing a colour.
Switching theme and level
ThemeScript goes in the root layout's <head>. It puts the stored choice on <html> before the first paint, so the page doesn't flash the wrong theme. ThemeSwitcher offers Light, Dark and System (with level, AA and AAA too) and keeps the choice where the script reads it.
// app/layout.tsx: the script changes <html> before React hydrates it.
<html lang="en" suppressHydrationWarning>
<head>
<ThemeScript />
</head>
<body>{children}</body>
</html>
// in a header or a footer
<ThemeSwitcher />- Give both the same options.
storage="cookie"sends the choice with every request as well, so the server can render<html>with it; the default,localStorage, keeps it in the browser. - System follows the operating system, including when it changes while the page is open.
defaultThemeis what shows before anyone chooses:systemunless you say otherwise. - For a Content-Security-Policy that lists inline scripts by hash, these are the default scripts' hashes.
themeScriptHash(options)gives the hash for any other options, andnoncetakes a per-request nonce instead.pnpm testfails if this table and the scripts disagree.
| Script | Hash |
| --- | --- |
| <ThemeScript /> | sha256-Z5PHa59cVDw2tw79t4evpPh8wFvKN39vFaHoJmsq5XY= |
| <ThemeScript storage="cookie" /> | sha256-RLgTmJnd9qBgn/qicRT4Fvi49baxiD9DFiphOXks2jc= |
Three layers
Every control that can paint smaller than its target — buttons, links, icon buttons, segments, fields, the select trigger, menu and list rows, sidebar items, calendar days, slider thumbs, breadcrumbs — is built the way Themis (@tribepad/themis) builds its buttons:
- Target: the React Aria element a pointer hits (
<button>,<a>,<input>, a row, a cell). Transparent, at least--target-minboth ways — 24px at AA, 44px at AAA. It takesclassName. - Paint: what people see — ground, border, radius, hover, and the focus ring. Sized by
visualSizeand the painted tokens (--control-height…), equal at AA and AAA by default. It takesvisualClassName. - Content: the label, icons and spinner.
<Button visualSize="sm" fullWidth visualClassName="justify-start">Archive</Button>- The target is the element itself, so checkers measure the size a pointer gets. A field's
<input>is 44px tall at AAA with its 36px box painted behind it (fieldBoxandfieldInput), rather than a wrapper that focuses the input with a script. - The target is in the layout, so targets don't overlap. At AAA that makes things with small paint take more room than they look. The design system's README lists what that costs, and what a 44px target costs whatever the paint.
- To paint at the target's size instead, raise
--control-height-aaa,--control-height-sm-aaaand--nav-item-height-aaato 44px. - For your own button-like element:
buttonTargetStyles()on a React AriaButton,LinkorToggleButton,buttonVisualStyles()on a span inside it.
Links and cursors
- A link in a sentence is a
TextLink, always underlined: colour alone would need 3:1 against the text around it (WCAG's G183), andbranddoesn't have it. A plain<a href>with noclass(Markdown, CMS content) gets the same underline from the base styles;text-linkis the utility. - One cursor rule on every target (Layer 1):
pointeron anything pressed, buttons included;texton text entry;grabon a slider thumb;not-allowedwhen disabled;waitwhile a button is pending; the default arrow on the current breadcrumb and read-only choices. The design system's README has the table and the reasons.
<p>Invite people by email, or share the <TextLink href="/settings/invite">workspace link</TextLink>.</p>Languages
Every word a component shows or says comes in English, and a product replaces any of them with its own, in any language. Set the locale and the app-wide words once, high in the root layout:
import { SetsquareLocale } from '@setsquare/ui';
<html lang="de">
<body>
<SetsquareLocale
locale="de"
labels={{
alert: { dismiss: 'Schließen' },
selectionBar: {
selected: '{count, plural, one {# Projekt ausgewählt} other {# Projekte ausgewählt}}',
},
}}
>
{children}
</SetsquareLocale>
</body>
</html>localeis a BCP 47 tag. React Aria's own words (a calendar's buttons, "Clear search", a table's "Select all"), numbers, dates and lists follow it; without it they follow the server's default while rendering there and the browser's after hydration. It wraps React Aria'sI18nProvider, so a product doesn't import React Aria to set it.labelsis keyed by component (SetsquareLabels):pagination,fileUpload,fieldfor every field, and so on. A component's ownlabelsprop wins, for one instance. Any word left out stays English. A nestedSetsquareLocalelays its words over the outer one's.- Every word is an ICU MessageFormat template, formatted by the package with
intl-messageformat(noeval, so it runs under a CSP without'unsafe-eval'):'Remove {name}','{count, plural, one {# file} few {# pliki} …}',select. A product passes its catalogue's lines through unformatted.<b>is text, as PHP's ICU reads it. - No sentence is put together in code: no lowercasing, joining or plural logic. A field's required message is
'Enter {label}'with the label as written; give a fieldrequiredMessagewhere its own sentence reads better. - A word that can't be formatted (malformed, or a value missing) throws in development. In production it shows the English default with the same values.
- Developer-facing text stays English: thrown errors, lint messages and the page checks' reports. Right-to-left layout isn't done yet: React Aria reads the direction from the locale, but the components' own classes are physical (
left-,ml-).
The design system's README and the Labels card list every word and placeholder.
Site layer
For a public page — a marketing site, a privacy notice — the stylesheet also brings Tailwind 4 utilities built from the tokens. They work in any Tailwind 4 app, with or without React:
<section class="section-band">
<div class="site-container">
<div class="section-head">
<p class="eyebrow">How it works</p>
<h2 class="text-display-section">Three steps, no annotations</h2>
</div>
<ol role="list" class="steps">…</ol>
</div>
</section>- Layout:
site-container,site-container-narrow,section-band,section-band-tight,section-band-raised,section-headandsplit. - Previews:
site-previewon a box shows a site shell at the box's width instead of the window's, with no iframe. The shell's breakpoints aresite-md,site-lgandsite-xl, media queries on a real page and container queries in the box. A shell inside asite-previewbox, or inside one withshell-previewfor a dashboard shell, leaves the page its view-transition names, so the page's own transitions still run. - Content:
eyebrow,site-prose,card-surface,card-grid,steps,stat-listandtag. - Type:
text-display,text-display-page,text-display-sectionandtext-lead, fluid from a 320px viewport to 1280px. Each size has arempart, so it still grows as the page zooms in. - Tokens:
--site-max,--site-max-narrow,--site-gutter,--section-yand--section-y-tight, withmax-w-siteandmax-w-site-narrow. ButtonandLinkButtontakevisualSize="lg"for a page's call to action, painted at--control-height-lg(44px).
The design-system README's Site layer section has the rules. React components apply the same utilities, all server-safe except LinkCard:
<Hero
title={<>API docs that know <HeroHighlight>their history</HeroHighlight></>}
actions={<LinkButton href="/start" variant="primary" visualSize="lg">Get started</LinkButton>}
/>
<Section eyebrow="What it reads" title="Everything you already wrote" raised>
<CardGrid>
<Card icon={<FileText />} title="Requests" description="Validation rules become request bodies." />
<LinkCard href="/docs" title="Getting started" description="Install it in a minute." />
</CardGrid>
</Section>Section(withSectionHeader,ContainerandEyebrow): a band with its heading.landmarknames it by that heading; it's off by default.HeroandHeroHighlight: the landing page's first band, with an optional aside and media.CardandCardGrid: cards in a list, as many to a row as fit.LinkCard: a card that is one link, named by its heading.Prose: long text, such as a privacy notice.StepsandStep: numbered steps, read with their numbers.Split: text beside media.Tag: a label in a pill, not a status.StatListandStat: figures, each with a requiredsource.CodeBlock: code with a label and a Copy button that says whether it worked;tone="terminal"copies the commands without their$.CopyButtonis the button on its own.
Site shell
For public pages: a header, the page and a footer in one column, with the document as the scroller. SiteShell, SiteMain, SiteHeader, SiteNav, SiteFooter and SiteFooterGroup have no directive; SiteMenu, SiteNavLink and SitePathProvider are the client parts.
const NAV = [
{ href: '/product', label: 'Product' },
{ href: '/pricing', label: 'Pricing' },
];
<SiteShell>
<SiteHeader
lockup={<a href="/">Product</a>}
nav={<SiteNav items={NAV} />}
menu={<SiteMenu items={NAV} />}
/>
<SiteMain>{children}</SiteMain>
<SiteFooter>
<SiteFooterGroup id="site-nav" title="Product" items={NAV} />
</SiteFooter>
</SiteShell>- One list feeds the header, the phone menu and a footer group, so every copy of the navigation keeps one order.
- Below
lg(1024px, generated from thebp-lgtoken) the nav gives way to a Menu button. Its panel pushes the page down rather than covering it, and Escape closes it and puts focus back on the button. - Until the page hydrates, the Menu is a link to
#site-nav: the footer group with that id. So it works with JavaScript off, as long as that group lists every nav item. - The current page gets
aria-current="page", bold and a marker. GiveSitePathProviderthe router's path, or passcurrent. - The header is static.
stickysticks it and setsscroll-padding-topto its height, so a focused control doesn't end up under it. Disclosure, the panel the Menu uses, is also a component of its own: a heading whose button shows and hides what follows.
Templates
templates/next/root-layout.tsx is the root layout, app/layout.tsx, with ThemeScript in its <head>.
templates/next/ has App Router files to copy into app/(portal)/: portal-layout.tsx (the dashboard shell, with React Aria links wired to Next's router), loading.tsx, error.tsx, not-found.tsx, page-transition.tsx (React's <ViewTransition>, for React 19.3 and later) and prefetch-on-intent.ts (prefetches a page when someone is about to open it, which also keeps the page transition). Each page wraps its ShellMain in PageTransition, not the other way round.
For public pages, into app/(site)/: site-layout.tsx (the site shell, a server component, with a ThemeSwitcher in the footer), site-router.tsx (React Aria links through Next's router, and the current path for the navigation), site-nav.ts (the navigation, once) and site-not-found.tsx. The root layout keeps <html> and <head>.
Guards
React Aria renders the real <input> of a checkbox, radio, switch, slider or date field, and the drop button of a drop zone, in a visually hidden span that is position: absolute with no offsets. Tailwind's sr-only is the same. Such an element is laid out against its nearest positioned ancestor. With a scroll container between the two, it doesn't scroll with the content, and when something scrolls it into view — focus, a screen reader's cursor, find in page — the browser scrolls the outer, often overflow: hidden, element instead. The page shifts and leaves blank space at the bottom.
The components avoid it: every scroll container and every control that hides an input is relative. And the three layers come apart as soon as a className shrinks a target or a link gets only the paint. Two guards keep product code from bringing either back.
Lint rules — @setsquare/ui/lint
| Rule | Reports |
| --- | --- |
| setsquare-ui/no-raw-aria-controls | Importing React Aria's Checkbox, Radio, Switch, Slider, date fields, Calendar, DropZone, GridList or Tree instead of the Setsquare version (and ColorArea, ColorSlider, ColorWheel, which have none yet). |
| setsquare-ui/positioned-scroll-container | A class list with overflow-auto or overflow-scroll (any axis or variant) and no relative, absolute, fixed or sticky, in className, cn(), clsx(), cva() and similar; and inline overflow styles without a position. Fixes itself by adding relative. |
| setsquare-ui/no-static-control-root | static passed in className to a Setsquare component whose root has to stay positioned. |
| setsquare-ui/no-target-shrink | min-h-* or min-w-* in the className of Button, LinkButton, IconButton or Segment. className is the target, and these replace its --target-min floor. |
| setsquare-ui/no-visual-only-target | buttonVisualStyles() in the className of a link or button: the paint on its own, which makes the painted box the pointer target. |
| setsquare-ui/no-builder-surface-override | In a file that uses @setsquare/ui-blocks or Puck, a Puck overrides key (actionBar, iframe, outline, fieldTypes and the rest) that replaces one of the builder's own surfaces, whose tests then don't reach it. A warning in recommended. |
| setsquare-ui/client-directive | A module without 'use client' that needs it (it imports React Aria or Puck's editor entry, @puckeditor/core, calls a hook other than use, useCallback, useDebugValue, useId or useMemo, calls createContext, or passes an event handler or a function as a prop or child), and one with it that needs none of those. Not in recommended: turn it on for the modules of a component library, where server components import them. |
ESLint (flat config, with a parser that reads TSX, such as typescript-eslint's):
// eslint.config.js
import setsquare from '@setsquare/ui/lint';
export default [setsquare.configs.recommended];oxlint (jsPlugins, which oxlint marks as alpha):
// .oxlintrc.json
{
"jsPlugins": [{ "name": "setsquare-ui", "specifier": "@setsquare/ui/lint" }],
"rules": {
"setsquare-ui/no-raw-aria-controls": "error",
"setsquare-ui/positioned-scroll-container": "error",
"setsquare-ui/no-static-control-root": "error",
"setsquare-ui/no-target-shrink": "error",
"setsquare-ui/no-visual-only-target": "error"
}
}Turn no-raw-aria-controls off in the directory where you wrap React Aria yourself. positioned-scroll-container can't see positioning that a library sets inline at runtime (React Aria's popovers are position: absolute from a style attribute): disable it on that line and say why.
Page checks — @setsquare/ui/testing
The lint rules only see class names. These check what the browser laid out, so they also find wrappers, third-party widgets and CSS from elsewhere.
import {
expectAnimationsPausable,
expectCursorsMatch,
expectFocusVisible,
expectLinksDistinguished,
expectPageReflows,
expectPositioningContained,
expectTargetsSized,
expectTextUnclipped,
} from '@setsquare/ui/testing';
test('settings page', async ({ page }) => {
await page.goto('/settings');
await expectPositioningContained(page); // each throws, listing every finding
await expectTargetsSized(page);
await expectFocusVisible(page);
await expectLinksDistinguished(page, { underline: true });
await expectCursorsMatch(page);
await expectTextUnclipped(page);
await expectAnimationsPausable(page);
await page.setViewportSize({ width: 320, height: 640 });
await expectPageReflows(page);
});findUncontainedPositioning()lists every absolutely positioned element whose containing block is outside its nearest scroll container.findHiddenOverflowScrolls()focuses every focusable element, and brings every absolutely positioned one into view the way a screen reader's cursor or find-in-page would (so not a decorative,aria-hiddenone with no text), with each scroll container scrolled to its start and then its end. It reports every time anoverflow: hiddenelement scrolled as a result. It restores scroll positions and focus afterwards.findSmallTargets(options?)lists every pointer target under--target-minin either axis, measured as laid out: for a visually hidden input, the control around it. Disabled, hidden and inline elements (links in a sentence) are left out;ignoretakes a selector for anything the Equivalent exception covers.findOverlappingTargets()lists targets whose boxes overlap on screen within one layer (a toast or popover over the page is left out), since WCAG doesn't count the overlapping area.expectTargetsSized(page)throws with both lists.findInvisibleFocus(page)focuses every control a keyboard reaches, one at a time, and compares a screenshot of it with focus on it and with focus on another control. Any that didn't change have no visible focus indicator (2.4.7). Tooltips, transitions and the caret are turned off for the comparison. A control that focus can't leave (the only one in a modal) comes back astrappedrather than passed.{ ignore }leaves controls unchecked, such as parts checked on another page, while focus can still move to them.expectFocusVisible(page)throws for the rest.findUndistinguishedLinks(options?)lists links in running text (laid out inline, in a block with words of its own) that nothing but colour marks, at under 3:1 against the text around them — WCAG's F73. An underline or bottom border, bold or italics the surrounding text doesn't have, or 3:1 passes.{ underline: true }holds links to this package's rule instead: underlined.expectLinksDistinguished(page, options?)throws with them.findPageOverflow(options?)lists everything that reaches past the right edge of the page at the width it is laid out at:scrollswhen the page scrolls sideways because of it,clippedwhen anoverflow: hiddenorclipbox at the page's edge cuts off something with words or media in it. Content in a scroller of its own that fits (aScrollRegion, a code block), content clipped inside a component, and fixed-position elements are left out, so a decorative glow bleeding off the edge isn't reported. Set the viewport to 320px for WCAG 1.4.10's reflow width.expectPageReflows(page, options?)throws with them.findClippedText(options?)applies WCAG 1.4.12's four spacing metrics to the page (line height 1.5, 2em after paragraphs, letter spacing 0.12em, word spacing 0.16em) and lists every box whose content now runs out of it further than before:clippedfor one that hides its overflow or truncates,overflowsfor one with a set size that the text spills out of. Scroll containers are left out, since their content can still be scrolled to, and so is text that runs out of a box inside a scroller that scrolls that way (code in a code block). It measures two animation frames after the spacing goes on, so a component that adapts in a ResizeObserver (a rail name refitting its tile) is measured as a reader sees it, not halfway; it returns a Promise, whichpage.evaluateawaits. The spacing comes off again afterwards.expectTextUnclipped(page, options?)throws with them.findEndlessAnimations(options?)lists what moves for longer than five seconds (max), or forever, with nothing to pause it: the CSS animations, CSS transitions and Web Animations running now (document.getAnimations()), and playing videos withoutcontrols, whether or not they arearia-hidden. Something is paused by a control when it sits inside an element whoseida visible button names indata-pauses. It can't see motion drawn withrequestAnimationFrame, SVG animation, animated images or canvas, and only sees what is running when it is called. A duration comes back asInfinityfor anything that repeats forever.expectAnimationsPausable(page, options?)throws with them.findCursorMismatches(page, options?)hovers every target and reads the cursor at its centre and just inside each corner, so the whole target is checked, not only its paint. Each kind has its expected cursors (press:pointer;text:textorauto;drag:grab;disabled:not-allowed;pending:waitorprogress;readonlyandcurrent:default);expectchanges them, and{ press: ['default'] }checks a product that keeps the arrow on buttons.expectCursorsMatch(page, options?)throws with them.
The positioning, target, link, reflow, text-spacing and motion scans run in the page (page.evaluate(findUncontainedPositioning)), so they work with any runner that has evaluate. The focus check takes screenshots and the cursor check moves the mouse, so they need a Playwright page. Nothing here imports Playwright. The checks that scroll turn smooth scrolling off while they measure, so a page with scroll-behavior: smooth is read where things land. Run them on each state that matters, such as with a panel open or errors showing: they only check what is rendered.
What is tested
pnpm ci:local runs all of it; each line says what it does and doesn't cover.
- Types:
tscon React 19. - Lint rules: ESLint's
RuleTester, and oxlint loading the built plugin throughjsPlugins. - Unit: the timeline's time formats and spoken times (English and German), tick steps, snapping by pointer and key, flag tiers, and its parts as the server renders them. The pagination window: seven slots on every page of an 8-, 20- and 239-page list, and a gap only where two or more pages are left out. The
RailandAppHeaderas the server renders them: withnavigation="rail", no collapse toggle and no rule, only the phone's trigger (hidden by a container query until the shell measures, absent at a forced size wider than a phone), and the rail's words over the sidebar's app-wide ones. Live status onSidebarItemandRailItem: the status label, detail and warning words in the name (after a count), the badges hidden from it, a type error forstatuswithoutstatusLabel, a custom drawing as the icon, and the collapsed sidebar's tooltip in English and in a product's words. The search's matching: every word of a query in the label or keywords in any order, case and accents ignored by the locale's collation, keyword-only matches after label matches and word starts before the middle of words, within each section. Its shortcut's rules (what counts as ⌘K / Ctrl+K, a field or editor keeping it, the first search to ask having it) and its check for an item id used twice. - Words: a lint rule of this repository's (
scripts/lint/words.mjs) fails English written into the components' JSX, a template that builds a sentence, a list joined in code, andIntlformatting in the runtime's locale; its tests fail each way 0.5.0 did it. Unit tests cover the ICU formatter, its development and production fallbacks,SetsquareLocale's layering, and every count label in Polish (one, few, many). - Contrast:
scripts/check-contrast.mjscomputes every token pair the usage notes promise, in all four themes. It doesn't check colours a product adds. - Browser (Chromium only): the design-system previews on React 19, loaded as the design-system artifact loads them:
- axe-core on every preview in all four themes;
- Dialog sizes from their tokens, a tall dialog's title and footer staying while only the body scrolls, and its body a tab stop only while it scrolls with nothing focusable; RightPanel's widths, hidden title bar, its name by
titleoraria-labelledby, a panel that can't be closed beside main and its Sheet below 1024px, andbody="fill"in all four themes, beside main and as a Sheet (the content filling the body, its own list scrolling and the composer staying at the bottom); PageHeader and Dialog icons leaving the name to the words; - compact density: each control's paint, padding, gap and text at AA, unchanged at AAA, no target under
--target-minor overlapping, a menu's rows following their trigger; anddisabledReason: in the tab order, described by the reason, its tooltip on focus and hover, a press doing nothing; - GridList cards in all four themes at default and compact density: their width and targets, arrow keys between them, a keyboard drag with nothing moving while it's on, Escape, a pointer drag, the menu's moves (2.5.7) and what they say, selected cards moving together, one
onReorderper move, forced colours, and one card to a line at 320px; - the Timeline in all four themes at default and compact density: playback, the playhead's keys and a followed jump rendering only the playhead, counted from React's own commits (in the light theme); lanes, items, flags and the ruler a lane tall (32px, 24px compact, 44px at AAA); the playhead by key and pointer, its value said as a time; lanes and items by keyboard, named with their times, with their tooltip on keyboard focus; markers snapping by key and drag and saying so, Alt to move freely, moved without dragging and put back with Escape, never covering one another; one finished move (
onMoveEnd) per drop, run of keys, put-down and reset and none on Escape or a press; a press and Enter opening a marker; its menu by Shift+F10 and its button, with Move and Reset; ghosts of the items that move with a marker while it's dragged; focus clear of the sticky ruler and label column; the grab cursors; forced colours; reduced motion; and the date ruler's hours in the schedule's zone. Not tried with a screen reader; - the search's ⌘K / Ctrl+K on a page of its own: left to text fields, text areas, editors and a handler that took the key; one search on a page holding it, showing it and setting
aria-keyshortcuts, and the next taking over;shortcut={false}; the console error for an item id used twice; andaria-keyshortcutsreaching the element throughButton,IconButton,LinkButtonandTree, which React Aria's own components drop, and the page builder's Undo, Redo and outline saying their keys; - keyboard and focus behaviour for the dashboard shell (with its search finding by keyword, in ranked order), forms, feedback states, and the slider, date, file and selection controls (a narrow Tree's long words breaking only with a hyphen, and its rows named without soft hyphens; a narrow FileUpload's names breaking only where they divide themselves or at a ↩ mark, said and copied plain; a vertical Tabs rail's long names breaking only with a hyphen; each with and without text spacing);
- the site shell: the phone menu opening, pushing the page down and closing on Escape from the panel or the button; the current page's weight and marker; one order for the header, the phone menu and the footer; a sticky header covering no focused control; and the header fitting at seven widths from 320px to 1280px, either side of
lgamong them; - the motion rule on the Hero card's media: the motion check exempts it only for its visible pause control, Pause stops it, reduced motion never starts it, and nothing waits hidden for it;
- code blocks copying, announcing a second copy, a terminal copying its commands only, and a refused copy; the theme switcher's choice, System following the operating system, the level, and the theme script running under a CSP that allows only its published hash;
- the content components: link cards named by their titles alone, card grids as lists, a region landmark only where a section asks for one, one
h1namingmainon the landing page, which doesn't scroll sideways at 320px, and the contact form's validation, confirmation and hidden honeypot; ScrollRegionand the shell'smainbecoming tab stops only while they scroll with nothing focusable in them;- every target on every preview at least
--target-min, and no two overlapping, at AA and AAA; - the paint of every three-layer control the same height at AA and AAA, and the focus ring on the paint;
- every control a keyboard reaches visibly changing when it takes focus, in two themes;
- every link in running text underlined, and every target showing the cursor the rule gives it, across its whole area;
- toasts measured against the focused control;
- the
Rail, in all four themes and two brands:--rail-widthat AA and AAA, every target at least--target-min, "Art direction" and "Brand kits" in two lines or fewer (in the system face the suites render with: they load no web fonts), no name cut off; long single words ("Storyboard", "Einstellungen", "Sprachausgabe", and "Story\u00ADboard" with a soft hyphen), plain and current, in English and German, never broken between two letters without a hyphen, never under 11.5px, two lines at most and named without the soft hyphen, again under text spacing and at 200% zoom; the current page's weight, fill, edge, and icon and name in brand, groups named by hidden headings with a rule between them, the footer pinned while the items scroll, no transition or animation on hover or focus but a working badge's pulse, the current page's marker inHighlightunder Chromium's emulated forced colours, no collapse toggle at 1280px and 768px, the phone's Sheet from the menu button with the items as rows and focus back on the button after Escape, and the Sidebar's shell keeping its toggle; - the current page marked alike in a
Rail, aSidebarItemand aSidebarDisclosure's page: the fill, the 3px edge, and the name in brand at 700, the same when hovered, and a bar inHighlightunder emulated forced colours; - live status on nav items, in all four themes and two brands: working a hollow ring and attention a dot in a ring, each in its colour, with a cut-out that matches the ground at rest, hovered, current and current while hovered; working pulsing three times at AA and not at all at AAA or under reduced motion, attention never moving, and nothing on a badge moving after five seconds; names with the status, detail and warning words; details in tabular numbers, under the name in a rail and right-aligned with a count in the sidebar, the warning one with its icon and colour; a product's own drawing as the icon at 20px; the collapsed sidebar's tooltips and badges; and both badges in
CanvasTextandHighlightunder Chromium's emulated forced colours; - tabs: the arrow keys in a row and a rail, Home and End, names with counts and badges, tooltips on hover (after 500ms, hoverable, closed by Escape and by a press) and on keyboard focus, the rail becoming a row below 480px, the three
hiddenPanelsmodes (a draft surviving, a hidden panel's effects stopping, a Suspense read loading while pre-rendered), an important badge's pulse ending inside five seconds and replaying when its count rises, clear-on-open callingonBadgeClear, only opacity and colour changing at AAA, nothing animating under reduced motion, and no badge, ring or halo over any text in all three displays at full width and 360px; - pagination: the tiers at 1, 4, 7, 8, 20, 21 and 239 pages, a row that never changes length, the names of page links and arrows, hrefs that keep the query and never use
meta.links, rows per page keeping the first row in view, focus after paging, the page field's validation and alert, the pending mark and its message after 400ms (and the mark stopping inside five seconds), and motion at AA, AAA and under reduced motion; - nothing on any preview running off its width, no text cut off or spilling with WCAG 1.4.12's spacing applied, and nothing but a loading indicator moving past five seconds, at AA and AAA;
- every preview inside
SetsquareLocaleatlocale="ar"with every word pseudo-translated, failing on any of the package's English left in text, hidden text or names, with a control run that leaves the words in English and has to find them. It reads what a card shows as it loads, not what it shows after it's used; - the page checks above, each also run on a page built to fail.
- Packaging:
pnpm package:verifypacks the tarball and installs it into a new Next.js app. It builds the app, loads it in both levels, and runs the page checks and the lint plugin from the installed package. It also follows a sidebar link on the freshly started app, to check the templates' page transition runs with itsnav-forwardtype. AnAppSearchrendered from a server component, withhrefitems, element icons and keywords, is checked to open on Ctrl+K and find its link by a keyword. The root layout'sThemeScriptis checked to put a stored dark theme on<html>before any of the page's own JavaScript runs, and the footer'sThemeSwitcherto change it for the next page load. A route on the site templates is checked too: the current page, reflow at 320px, the Menu as a link to the footer's navigation with JavaScript off, and the swap to the Menu button when the page hydrates, in the same place and keeping focus.@setsquare/ui-blocks'ContentBuilderis rendered from a Next.js page and checked to hydrate with no console errors, with the browser on a different platform from the server's. - The docs site (
apps/docs):pnpm test:siteloads every page of its production build in all four themes at 1280px, and at AA and AAA at 390px (and each component the Playground shows). Each run has axe, positioning, targets, underlined links, sideways overflow and console errors. The dark run puts the browser on a different platform from the server's (Windows beside a Mac server, a Mac beside any other), so anything rendered differently on the two shows up as a hydration error. Visible focus runs in three of them (light, dark AAA and 390px AAA: both themes, both levels, both layouts) and cursors in three (light, light AAA and 390px AAA), and both check the sidebar and header, which are the same on every page, on the home page only; a check fails if that selector ever leaves out a control in a page's content. It also fails on console errors, sideways overflow, and any internal link to a page it doesn't check, and it follows a sidebar link, in Chromium and in WebKit, to check that the navigation stays client-side, runs the page transition, and covers the old page while it leaves. It also tries a shadcn/ui theme from the Playground: that it reaches every page, lasts through a reload without a flash, resets, and that a paste can set nothing but valid token values. It searches from the header by keyword ("modal" finds Dialog, "tap target" finds--target-min) and by names read from the repository (ConfirmDialog, a page's heading, a lint rule, a page check). The site's build fails if a page, component or token search lists has no keywords inapps/docs/lib/search-keywords.ts, if keywords are left for one that's gone, or if anything@setsquare/uiexports finds nothing. The findings it accepts are listed intest/site/pages.mjs, each with its reason. The site brings its own palette, so its build runs the contrast audit against those colours. - Not tested: other browsers; real screen readers (VoiceOver, NVDA and JAWS haven't been run on these components); Windows High Contrast; pasting files into the drop zone; everything that depends on a product's content.
Developing
pnpm install
pnpm build # CSS from tokens/tokens.json, then tsc into dist/
pnpm test # lint-rule and unit tests (vitest)
pnpm docs:build # the design-system artifact, into docs/.artifact/
pnpm test:browser # browser suites (after docs:build); pnpm test:axe for axe on every card
pnpm docs:site # the docs site (apps/docs) in development
pnpm docs:site:build # its production build; pnpm test:site then checks every page of it
pnpm ci:local # everything above, in CI ordertokens/tokens.jsonis the only place a token value lives. The CSS indist/styles/is generated from it.docs/design-system/holds the design-system artifact's text, previews and cover.pnpm docs:buildassembles the artifact from it and fromsrc/.apps/docs/is the documentation site, a Next.js app built on the package. Its text is generated from this repository: the READMEs,index.d.ts, the lint fixtures, the templates and the changelog. Its colours are Setsquare's, inapps/docs/palette/setsquare.json.- The site deploys to Vercel as a project whose Root Directory is
apps/docs. Keep Include source files outside of the Root Directory in the Build Step on (the default for new projects): the build reads the READMEs, templates and scripts.apps/docs/vercel.jsonbuilds the package at the workspace root, then the site. SetENABLE_EXPERIMENTAL_COREPACK=1in the project's environment variables, so Vercel uses the pnpm pinned inpackageManager. CLAUDE.mdhas the rules this repository works by, andRELEASING.mdhas how a version is published.
Licence
Apache-2.0 licensed. See LICENSE and NOTICE. "Setsquare" is a trademark of WORKTREE AMIGOS LTD; the licence grants no right to use it.
