Salta ai contenuti

0018. Build the design system as typed tokens and copied components on React Aria, styled with Tailwind on CSS variables

Questi contenuti non sono ancora disponibili nella tua lingua.

  • Status: accepted; component adoption and app-shell scope extended by 0023
  • Date: 2026-10-26

Chapter 15 of the analysis designs the interface and its design system; the research report ui-ux-research.md verified the facts and the libraries. This ADR fixes the foundations of phase 0: the packages @guestcraft/tokens and @guestcraft/ui, their providers and a first set of components.

What shapes the decisions:

  • The people. Front desk, housekeeping and revenue staff work in GuestCraft all day, often under pressure and after a short training; some use only a keyboard, a screen reader or zoom. The goals are, in order: avoid errors that reach guests and channels, make repeated work fast, be learnable without a course.
  • The choices of the analysis.
    • shadcn/ui on React Aria Components (official since July 2026), with Base UI as the fallback;
    • DTCG tokens, translated by Terrazzo into CSS variables in Tailwind 4’s @theme;
    • the theme chosen from a cookie, without next-themes;
    • Sonner for toasts, while the toast of React Aria is not stable;
    • Lucide icons;
    • three densities of 28, 36 and 44 px;
    • WCAG 2.2 AA everywhere.
  • Facts checked for this layer.
    • React Aria Components 1.22 still exports its toast as UNSTABLE_Toast.
    • Its NumberField holds a JavaScript number, which is a binary float.
    • React Aria’s default toast-region label counts notifications. GuestCraft’s translated label must count too, so that the region registers for F6 when its first toast appears.
    • Tailwind 4.3 has logical utilities for sizes and insets (inline-*, block-*, inset-e-*, inset-be-*), so no physical property is needed.
    • jsdom computes no layout and no colours.
  • Constraints.
    • Nothing paid, and only MIT, Apache-2.0, BSD or ISC dependencies.
    • tokens is MIT and imports nothing; ui is AGPL and may import tokens and i18n (ADR 0004, repository.md).
    • Amounts are never floats (ADR 0007), and texts come from the catalogs (ADR 0013).

Tokens: typed data, with CSS and DTCG generated from it.

  • The source is TypeScript in packages/tokens/src:
    • colours by semantic role (fgDanger, surfaceRaised, stayConflictStrip) for four themes;
    • spacing, radius, type, elevation, layers, motion and density, with lengths in pixels and durations in milliseconds.
  • css/tokens.css is generated from it (css:build), with --gc- names and lengths in rem. toDtcg(theme, density) gives the same tokens as a DTCG 2025.10 document for design tools.
  • Terrazzo waits until a design tool or the mobile app needs a translator. Typed data are checked by the compiler and the tests, and need no build tool with a single maintainer.

Themes.

  • Light and dark take the values the research verified. Two themes with more contrast, lightMore and darkMore, promise 7:1 for text (WCAG 1.4.6) and 4.5:1 for controls.
  • CONTRAST_PAIRS lists every pair a theme draws: text and controls on every surface, text on filled buttons, and each state of a stay on its fill and on the page. A test computes every pair of every theme with a formula written apart from the package’s. checkContrast lets a brand theme prove the same before it ships.
  • The theme is an attribute, not a script:
    • ThemeProvider sets data-theme (system, light, dark) and data-contrast (system, standard, more) on one element;
    • tokens.css resolves system with prefers-color-scheme and prefers-contrast;
    • the server renders the saved choice from a cookie, so nothing flashes and next-themes is not needed.
  • Forced colours keep working, because focus is a real outline, not a shadow.

Density and targets.

  • DensityProvider sets data-density (compact, standard, comfortable); it nests, so a rates grid can be compact inside a standard page.
  • Density changes the custom properties of row height, control height, padding and gap, never the size of text.
  • Every control is at least its density’s height and never below --gc-target. That is 24 px for a pointer (WCAG 2.5.8) and 48 px when the primary pointer is coarse, the 48 × 48 that chapter 15 asks for on mobile. Icon buttons are square.

Locale and direction.

  • LocaleProvider takes the formatting locale, the catalog and the property’s time zone, and reads nothing from the machine. It gives them to use-intl and to React Aria’s I18nProvider, and sets lang and dir, with dir from textDirection of @guestcraft/i18n.
  • UIProvider composes the three providers and the toasts. It hands React Aria a container inside itself for portals, so dialogs, popovers and toasts inherit theme, density, language and direction.
  • Component texts come from the ui namespace of the catalogs. The keys are typed through use-intl’s AppConfig, so a missing message does not compile. Text from people inside a message, such as a property’s name, is bidi-isolated.

Components: shadcn’s model, copied, on React Aria.

  • No shadcn CLI, no shadcn package and no @base-ui code. The components are written in the repository on React Aria Components, as shadcn’s aria base does: composition, utility classes and data-* states for hover, press, focus and validity. They are then adapted to the tokens and to GuestCraft’s rules.
  • The only UI library at runtime is React Aria (Apache-2.0), plus Lucide (ISC) for icons. React Aria remains the chosen base. A comparison with Base UI is needed only if a concrete problem appears on the hardest screens.
  • This layer delivers Button, IconButton, TextField, MoneyInput, Select, Dialog, ConfirmDialog, toasts with Undo, Banner and ErrorSummary. Their rules are in the README of @guestcraft/ui.
  • Two choices differ from the obvious one:
    • MoneyInput is a text field, not React Aria’s NumberField, whose value is a float. It reads with parseLocaleDecimal and writes back with formatMoney. The parent gets the API’s decimal string with exactly the ISO 4217 decimals, which the component takes as a prop and never guesses; HUF has 2 even though people write none. The currency code is always visible and read with the field. When the text is not an amount, the parent gets no amount and the name of the problem, so a form cannot save the previous value by mistake.

    • Toasts use React Aria’s unstable toast, not Sonner. This replaces a choice of the analysis, for four reasons:

      1. F6 reaches the toast region as a landmark;
      2. focus moves to the next toast when a focused one closes;
      3. timers pause under the pointer and the focus;
      4. it shares the base of everything else, without a library of a single maintainer.

      The unstable API is confined to components/toast.tsx, behind GuestCraft’s own useToast, and the version is pinned. The region’s name counts its toasts (“1 notification”), so that React Aria registers it for F6 when it appears. If React Aria drops or breaks the API, Sonner replaces that one file.

  • Undo replaces confirmation for routine actions: a toast with Undo stays 10 seconds while not hovered or focused. Errors are never toasts. ConfirmDialog is for actions with costs or effects on the channels: it names both choices with verbs and starts on the one that changes nothing; for rare, destructive actions people type the name.

Styling: Tailwind 4 utilities on CSS variables.

  • css/theme.css, generated from the tokens (theme:build), replaces Tailwind’s palette, type scale, radii and shadows with @theme inline variables that read --gc- properties. Only tested colours exist, and bg-red-500 generates nothing.
  • The app imports Tailwind, tokens.css and theme.css, and adds @source for the sources of ui.
  • No class-variance-authority, tailwind-merge or clsx: variants are typed maps of class strings, and className is appended.
  • Only logical properties: ps-, me-, inline-, block-, inset-e-, text-start. Directional icons are mirrored with rtl:; digits, logos and clocks are not.

Icons. Lucide, the analysis’s choice and shadcn’s default, with the same set on mobile. Icons are decorative (aria-hidden) next to words; an icon alone is an IconButton with its name.

Tests.

  • Vitest with jsdom, Testing Library and user-event, in *.unit.test.tsx. The *.component.test.tsx of ADR 0005 stays reserved for tests in a real browser.

  • Every component, in each of its states, passes axe-core with the tags of WCAG 2.2 A and AA. Its contrast rule is off, since jsdom has no colours; the tokens test contrast.

  • Keyboard tests cover Tab, Enter, Space, the arrows, Escape and F6, and check where the focus goes. Right to left is tested with ar-XB and with Arabic, and the long pseudo-locale with en-XA.

  • A style test renders every component in its states and compiles their classes with Tailwind and the theme. It fails on:

    • a class that generates nothing;
    • a physical property;
    • a colour pair missing from CONTRAST_PAIRS;
    • a control without the focus ring;
    • a control below the target.

    A probe in the same test proves that it sees each of these.

  • Any warning or error logged by React, React Aria or use-intl fails the test.

  • axe-core is MPL-2.0, which the analysis admits. Here it is only a devDependency of the tests and is never shipped.

  • Coverage thresholds are 95/90/95/95 for both packages, above the 85/80/85/85 that ADR 0005 sets for the UI, because every screen builds on them. The gates test pins them.

Boundaries. dependency-cruiser’s not-to-dev-dep rule now accepts a package that is both a peer and a devDependency, as React is for ui: the consumer provides it where the code runs, and the dev copy serves the tests. The boundaries fixture has a case for it.

  • A screen of the app gets theme, density, direction and the focus and target rules from the providers and components, with no extra work. A component that draws a new colour pair must add it to CONTRAST_PAIRS, where its contrast is tested in every theme.
  • Changing a token means regenerating two stylesheets; the tests say when one is stale.
  • Every new component goes into the style test’s page and gets its axe and keyboard tests; reviews check it.
  • The unstable toast is a known risk, confined to one file.
  • What waits, and why:
    • Storybook 10 with addon-a11y, one story per state. It arrives with the app shell, together with its build.
    • Browser and screenshot tests: Vitest browser mode or Playwright, with toHaveScreenshot in a fixed container, the pseudo-locales en-XA and ar-XB, three densities, the dark theme and forced colours. They need the app shell and a browser in CI. Until then, contrast and target sizes in a real layout are not measured.
    • Terrazzo, until a design tool or the Uniwind theme of the mobile app consumes DTCG.
    • A lint against raw colours in components. The theme already makes Tailwind’s palette generate nothing, but an inline style or an arbitrary value could still carry a hex.
    • Fonts. The app serves Inter and JetBrains Mono under the OFL; tokens only names them.
    • The other components of chapter 15, from the announcer and the skip link to the date pickers, the data table and the domain components, together with packages/ui-native.
    • The React Aria base spike of the analysis (three to four days), which can still move the components to Base UI.
  • Sonner for toasts, as the analysis chose. Postponed: it has no landmark navigation or focus management of its own, and has a single maintainer. It stays the fallback if React Aria’s toast breaks.
  • DTCG JSON as the source, with Terrazzo or Style Dictionary. Rejected for now. JSON gives no types, and the tools add a build step and, for Terrazzo, a single maintainer; the DTCG document is still generated for whoever needs it.
  • Plain CSS or CSS modules instead of Tailwind. Rejected: the analysis and shadcn use Tailwind 4, its logical utilities cover RTL, and with the palette replaced the theme limits colours to the tested ones.
  • React Aria’s NumberField for money. Rejected: its value is a float, and an amount must stay a decimal string.
  • next-themes. Rejected, as in the analysis: attributes and media queries need no script, and next-themes has open problems with React 19.2.
  • happy-dom instead of jsdom. Rejected: React Aria’s own tests run on jsdom, and axe-core supports it.
  • Lower coverage thresholds, as ADR 0005 allows for the UI. Rejected: the components are small, and their states are what the tests are about.
  • MoneyInput reports its parsed value and validation problem on every edit, before an implicit Enter submission. Blur formats the displayed text without rounding. Invalid and empty input clear the form value (undefined); a form library such as TanStack Form must store both the amount and its problem, and refuse submission while the problem exists.
  • ConfirmDialog accepts one confirmation per opening. A ref guards rapid input; an asynchronous confirmation keeps the dialog pending until its promise settles. Pending work disables cancellation and Escape. The parent closes the dialog and handles any application error.
  • Every provider, component and hook module declares "use client". Pure helpers remain server-safe. Next.js must transpile the source packages @guestcraft/ui, @guestcraft/tokens and @guestcraft/i18n.
  • The app calls documentAttributes in its server layout and spreads the result on <html>. This sets language, direction, theme, contrast and density before paint, including viewport scrollbars. The provider wrappers remain for nested themes and overlays; they do not replace the document attributes.
  • Toast timeouts have a five-second floor, or ten seconds with Undo.
  • DTCG spacing step names replace decimal points with underscores. TypeScript spacing keys keep their original names.
  • The contrast inventory now has 122 distinct pairs per theme, including every stay strip on every surface and foreground, muted text, focus and controls on every stay fill. Control colours were adjusted to pass these additional pairs.
  • Class checks reject opacity colour modifiers and arbitrary colours. The modal backdrop alone has an explicit decorative opacity exception.

jsdom does not measure layout: target sizes are checked through generated CSS, not axe’s target-size rule. ui exports Tooltip; a VisuallyHidden export remains among the components to add when the app’s screens need it.