npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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/date

It 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 -aa or -aaa token 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 brand and brand-subtle colours. pnpm lint runs scripts/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. defaultTheme is what shows before anyone chooses: system unless 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, and nonce takes a per-request nonce instead. pnpm test fails 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:

  1. Target: the React Aria element a pointer hits (<button>, <a>, <input>, a row, a cell). Transparent, at least --target-min both ways — 24px at AA, 44px at AAA. It takes className.
  2. Paint: what people see — ground, border, radius, hover, and the focus ring. Sized by visualSize and the painted tokens (--control-height …), equal at AA and AAA by default. It takes visualClassName.
  3. 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 (fieldBox and fieldInput), 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-aaa and --nav-item-height-aaa to 44px.
  • For your own button-like element: buttonTargetStyles() on a React Aria Button, Link or ToggleButton, 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), and brand doesn't have it. A plain <a href> with no class (Markdown, CMS content) gets the same underline from the base styles; text-link is the utility.
  • One cursor rule on every target (Layer 1): pointer on anything pressed, buttons included; text on text entry; grab on a slider thumb; not-allowed when disabled; wait while 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>
  • locale is 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's I18nProvider, so a product doesn't import React Aria to set it.
  • labels is keyed by component (SetsquareLabels): pagination, fileUpload, field for every field, and so on. A component's own labels prop wins, for one instance. Any word left out stays English. A nested SetsquareLocale lays its words over the outer one's.
  • Every word is an ICU MessageFormat template, formatted by the package with intl-messageformat (no eval, 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 field requiredMessage where 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-head and split.
  • Previews: site-preview on a box shows a site shell at the box's width instead of the window's, with no iframe. The shell's breakpoints are site-md, site-lg and site-xl, media queries on a real page and container queries in the box. A shell inside a site-preview box, or inside one with shell-preview for 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-list and tag.
  • Type: text-display, text-display-page, text-display-section and text-lead, fluid from a 320px viewport to 1280px. Each size has a rem part, so it still grows as the page zooms in.
  • Tokens: --site-max, --site-max-narrow, --site-gutter, --section-y and --section-y-tight, with max-w-site and max-w-site-narrow.
  • Button and LinkButton take visualSize="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 (with SectionHeader, Container and Eyebrow): a band with its heading. landmark names it by that heading; it's off by default.
  • Hero and HeroHighlight: the landing page's first band, with an optional aside and media.
  • Card and CardGrid: 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.
  • Steps and Step: numbered steps, read with their numbers. Split: text beside media. Tag: a label in a pill, not a status. StatList and Stat: figures, each with a required source.
  • CodeBlock: code with a label and a Copy button that says whether it worked; tone="terminal" copies the commands without their $. CopyButton is 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 the bp-lg token) 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. Give SitePathProvider the router's path, or pass current.
  • The header is static. sticky sticks it and sets scroll-padding-top to 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-hidden one with no text), with each scroll container scrolled to its start and then its end. It reports every time an overflow: hidden element scrolled as a result. It restores scroll positions and focus afterwards.

  • findSmallTargets(options?) lists every pointer target under --target-min in 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; ignore takes 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 as trapped rather 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: scrolls when the page scrolls sideways because of it, clipped when an overflow: hidden or clip box at the page's edge cuts off something with words or media in it. Content in a scroller of its own that fits (a ScrollRegion, 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: clipped for one that hides its overflow or truncates, overflows for 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, which page.evaluate awaits. 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 without controls, whether or not they are aria-hidden. Something is paused by a control when it sits inside an element whose id a visible button names in data-pauses. It can't see motion drawn with requestAnimationFrame, SVG animation, animated images or canvas, and only sees what is running when it is called. A duration comes back as Infinity for 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: text or auto; drag: grab; disabled: not-allowed; pending: wait or progress; readonly and current: default); expect changes 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: tsc on React 19.
  • Lint rules: ESLint's RuleTester, and oxlint loading the built plugin through jsPlugins.
  • 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 Rail and AppHeader as the server renders them: with navigation="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 on SidebarItem and RailItem: the status label, detail and warning words in the name (after a count), the badges hidden from it, a type error for status without statusLabel, 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, and Intl formatting 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.mjs computes 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 title or aria-labelledby, a panel that can't be closed beside main and its Sheet below 1024px, and body="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-min or overlapping, a menu's rows following their trigger; and disabledReason: 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 onReorder per 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; and aria-keyshortcuts reaching the element through Button, IconButton, LinkButton and Tree, 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 lg among 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 h1 naming main on the landing page, which doesn't scroll sideways at 320px, and the contact form's validation, confirmation and hidden honeypot;
    • ScrollRegion and the shell's main becoming 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-width at 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 in Highlight under 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, a SidebarItem and a SidebarDisclosure's page: the fill, the 3px edge, and the name in brand at 700, the same when hovered, and a bar in Highlight under 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 CanvasText and Highlight under 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 hiddenPanels modes (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 calling onBadgeClear, 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 SetsquareLocale at locale="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:verify packs 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 its nav-forward type. An AppSearch rendered from a server component, with href items, element icons and keywords, is checked to open on Ctrl+K and find its link by a keyword. The root layout's ThemeScript is checked to put a stored dark theme on <html> before any of the page's own JavaScript runs, and the footer's ThemeSwitcher to 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' ContentBuilder is 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:site loads 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 in apps/docs/lib/search-keywords.ts, if keywords are left for one that's gone, or if anything @setsquare/ui exports finds nothing. The findings it accepts are listed in test/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 order
  • tokens/tokens.json is the only place a token value lives. The CSS in dist/styles/ is generated from it.
  • docs/design-system/ holds the design-system artifact's text, previews and cover. pnpm docs:build assembles the artifact from it and from src/.
  • 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, in apps/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.json builds the package at the workspace root, then the site. Set ENABLE_EXPERIMENTAL_COREPACK=1 in the project's environment variables, so Vercel uses the pnpm pinned in packageManager.
  • CLAUDE.md has the rules this repository works by, and RELEASING.md has 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.