Design system

A clean, operational design language: near-black type on a neutral grey ramp, hairline structure, one teal accent, Public Sans in Polaris's sizes, 8px-rounded 32px controls, and tabular figures wherever money appears.

Principles

Foundations

Page layout

Every admin page is a Page: a back action named by the parent page, one title with the record's status beside it, a muted subtitle, and at the right up to three secondary actions, a “More actions” menu for the rest (destructive ones last), the one primary, and a record's previous / next arrows. A success is a toast; a success with consequences, and a refusal, are banners under the header. A record's page puts the work in a two-thirds column and who, how much and status in a one-third column beside it (Layout, Polaris's resource details), which stays in view whatever tab the work is on. Settings pages put each group's title and description beside its card and save the whole page from one bar. The booking detail page is the first built this way.

import { Page, Layout, LayoutSection, Section, FieldRow } from "@/components/page-layout";
import { Field, Input, btn, fieldBtn } from "@/components/ui";

<Page title="B-1042 · Standard clean" recordTitle width="default"
      backAction={{ href: "/admin/bookings", label: "Bookings" }}
      titleMetadata={<Badge value="SCHEDULED" />}
      primaryAction={<Link href="#crew" className={btn.primary}><PhoneCase text="Assign crew" /></Link>}>
  <Layout>
    <LayoutSection variant="primary">
      <Section title="Adjust">
        <form action={refund}>
          <FieldRow columns={["12rem", "minmax(0,1fr)"]}
                    action={<SubmitButton className={fieldBtn.secondary}>Issue refund</SubmitButton>}>
            <Field label="Refund" htmlFor="amount"><Input id="amount" prefix="$" size="short" /></Field>
            <Field label="Reason" htmlFor="note"><Input id="note" /></Field>
          </FieldRow>
        </form>
      </Section>
    </LayoutSection>
    <LayoutSection variant="secondary">…</LayoutSection>
  </Layout>
</Page>

Phones

Below 768px the product follows Apple's Human Interface Guidelines; Polaris governs 768px and up, and Grout's colours and Public Sans apply at every width. Inside the app — the admin, the crew and customer portals, the booking flow and the sign-in door — the text variants take Apple's sizes (17px body, a 34px Large Title, nothing under 11px), cards become rounded inset sections on a grouped background, and button labels, tab labels and page titles take Apple title case (“Save Length and Window”) through PhoneCase, which keeps one accessible name. The portals reach the edges of the glass and pay back the safe-area insets. A page with a back button is a detail screen: its back link reads “‹ Bookings”, and its actions move to a bottom toolbar — the primary trailing, the rest behind “More actions”. A disabled button's reason and an InfoTip's rule are printed as text, since a phone has no tooltips. The marketing site keeps its own look and gets only the 44px taps. DESIGN.md § Phones has the tables.

Color tokens

The mesh accents — --teal, --ochre, --violet, --coral — are decorative. They are tuned to read inside a 28px blur and sit between 2.3:1 and 4:1 against white, so they are never a button and never a state fill; --success, --warning and --destructive own state and carry the contrast to be read.

A fill answers to 3:1 and a word answers to 4.5:1, so the same hue cannot be both. --success (4.33:1 on white) and --warning (2.96:1) are fills — an icon, a dot, a badge ground. When the state is written out as a word, it takes the ink twin: --success-ink (5.47:1) or --warning-ink (6.16:1). --destructive needs no twin; it already clears the floor as text. Previously both fills were used directly as label text — the “Done” pill on the dashboard, the credit lines on the billing statement and the customer wallet, an inline “not offered” caution — and none of them reached AA.

Dimming is a token swap, never an opacity utility. Opacity multiplies over every colour in the subtree at once, including the ones already sitting at the floor: opacity-50 over --muted-foreground is 2.04:1. A retired row takes .is-dimmed, which swaps its type for --dimmed-foreground — 15.77:1 down to 5.52:1, a plain drop in weight with nothing pushed under the floor.

Inline links take .bb-link: --primary, underlined at rest with a 2px offset, thickening on hover. The underline is not decoration — --primary sits 1.03:1 against --muted-foreground, so a link marked by colour alone is marked to nobody who cannot separate the two hues, and a hover underline reaches a mouse and nothing else. Previously inline links carried no underline until hover.

Defined as CSS variables in globals.css (light + .dark) and mapped into Tailwind via @theme inline — use classes like bg-card, text-muted-foreground, border-border:

--backgroundPage canvas and the deep-navy ink used for all type
--canvasWhite panels, the cool band under heroes, the warm interlude
--cardElevated surfaces: cards, tables, sidebars
--primaryBrand teal: filled buttons, links, active states, press
--primary-subduedPale fill behind tags and eyebrow pills
--brand-darkDeep navy fill for inverted bands and code blocks, and the light type that sits on it
--ink-secondaryBody copy that sits one step below the headline
--mutedSubdued fills and helper text
--dimmed-foregroundThe type colour of a retired row — a revoked key, a reversed payment
--borderHairlines — inputs sit on a slightly cooler line than cards
--destructiveOutcome colors — fills, under icons and shapes
--success-inkThe same two outcomes taken down to where a word can be read
--chart-1 … chart-5Categorical data-viz palette
bg-primary
bg-[var(--brand-dark)]
bg-canvas-soft
bg-muted
bg-destructive

Components

shadcn-style components live in src/components/ui/ (Button with cva variants, Card, Skeleton, AlertDialog, Dialog, Command, Select, Checkbox, Switch, RadioGroup, Tooltip, ToggleGroup, Collapsible, Alert); the shared kit in src/components/ui.tsx carries the clarity primitives.

import { Button } from "@/components/ui/button";

<Button>Book now</Button>
<Button variant="secondary" size="sm">Details</Button>
<Button variant="destructive">Cancel booking</Button>

Select replaces a native select styled with className={input} inside a Field — same shape from the outside, and it posts its value in a server-action form the same way, via a synced hidden input.

import { Field, Select, SelectItem } from "@/components/ui";

<Field label="Service" description="What the customer booked." htmlFor="serviceId">
  <Select id="serviceId" name="serviceId" defaultValue={service.id}>
    {services.map((s) => (
      <SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
    ))}
  </Select>
</Field>

A tooltip is a HintTooltip and nothing else. It opens on hover and on keyboard focus, Escape closes it, and its text is also in the page as sr-only text the trigger is described by, so a screen reader hears it whether or not it is open. A phone or a touch screen has no tooltips: there the popup never opens and the same text shows as a muted line instead. A title= attribute reaches a mouse and nothing else, so it never carries meaning.

import { HintTooltip } from "@/components/hint-tooltip";

<HintTooltip content="Held back by the customer's own notification preferences.">
  <button type="button">Skipped</button>
</HintTooltip>

ToggleGroup replaces a hand-rolled row of aria-pressed buttons with a manual active/inactive class string — a real pressed state and a working keyboard come for free. Pass multiple for an independent on/off set (the booking wizard's addon toggle); leave it off for a single choice (packages, services, frequency, date, time slots).

import { ToggleGroup, ToggleGroupItem } from "@/components/ui";

<ToggleGroup aria-label="How often?" value={[frequencySlug]} onValueChange={([slug]) => {
  if (slug) setFrequencySlug(slug);
}}>
  {frequencies.map((f) => (
    <ToggleGroupItem key={f.slug} value={f.slug}>{f.name}</ToggleGroupItem>
  ))}
</ToggleGroup>

Callout — the bordered, tinted box with an icon that carries a redirect's outcome, a warning, or a note — is now backed by Alert/AlertTitle/AlertDescription. Its four tones (info, warning, danger, success) tint the border, background and icon only; the title and body stay on the neutral text colours, and the tone is always spoken too (a visible title or an sr-only word), never carried by the tint alone.

import { Callout } from "@/components/ui";

<Callout tone="warning" title="Confirm your email address">
  We sent a confirmation link — check your inbox.
</Callout>

Writing a settings screen

Group decisions with AnnotatedSection (pages not yet moved keep SettingsSection), name every control with Field, and push expert options into Advanced. A settings group may carry a 1–3 sentence description beside its card that doesn't repeat the title; the page is one form whose SaveBar appears on the first change and asks before discarding. Labels name the decision in one to three words, never a question (“Free-cancellation cutoff”), help text sits below the control only when it adds a consequence the label cannot carry and never restates the label, the unit is a prefix or suffix inside the field (or in the label), and an example in the hint reads “For example, …”. Other sections and disclosures take no subtitle; the rule worth knowing goes in info, rendered as an InfoTip.

<SettingsSection
  title="Booking rules"
>
  <Field
    label="Minimum notice"
    description="How far ahead someone must book. With 4, a customer at noon
                 cannot pick a time before 4pm today."
    htmlFor="bookingLeadHours"
    hint="Raise it if same-day jobs catch you short-staffed."
  >
    <Input id="bookingLeadHours" name="bookingLeadHours" type="number"
           size="short" suffix="hours" />
  </Field>

  <Advanced title="Travel time and booking horizon">
    …
  </Advanced>
</SettingsSection>

Interaction patterns

Signature surfaces

The .bb-mesh class paints the gradient-mesh backdrop used on the marketing hero: soft organic washes of brand blue, warm sand, and teal across the upper third of the page, with content floating above on white. It dims automatically in dark mode.

.bb-mesh-tall is the same wash in a full-height column rather than a band — the brand panel beside every sign-in. Each stop sits in the top third of its own box, which fills a hero and leaves two thirds of a 900px panel bare, so the variant stretches the box and softens the blur to spread the same stops down it.

.bb-mesh-foot turns that wash upside down, and is the Grout app’s own screen. A phone has no room for a panel beside the form, so it wears the mesh behind it — at the foot, where the content keeps the clear top it needs to be read outdoors and the colour fills the part of a phone screen a sign-in can never fill.

Dark mode

next-themes toggles a .dark class (system-aware). Token-based components adapt automatically; a small compatibility layer in globals.css remaps the status-color literals (bg-emerald-100, text-amber-700…) that badges still use.

The rules are tested

tests/unit/interface-rules.test.ts enforces the parts of this page a machine can see — emoji in chrome, a colour literal with no dark-mode value, a bolded display tier, a bare outline-none, an unnamed nav or select, a missing skip link, three dots where an ellipsis belongs, a size or radius off the scale, an opacity-50 on a row of text, a state fill used as a word, an inline link whose underline waits for a mouse. It computes the WCAG ratio of every text token against every surface it is written on, in both themes, so a colour cannot be nudged under 4.5:1 without a failure. It reads the structure too — an empty th, a second main nested inside a portal's, anything but a div, dt or dd directly inside a dl. It holds the layout standard: every admin page on Page (through a list of older pages that only shrinks), and on a templated page no hand-built header, no margin, no filled button outside the header's primary action, no short button in a FieldRow, and no !important override anywhere. It also parses DESIGN.md and fails when globals.css drifts from it, role by role and colour by colour, so the document and the stylesheet cannot quietly describe different systems. It needs nothing running. tests/e2e/accessibility.spec.ts runs axe over every route in both themes for what only the rendered page shows.

Rule of thumb for new code: never hardcode a hex or a neutral-* — reach for a semantic token class. Charts read their colors from --chart-* so they follow the theme too. Note that --brand-dark pairs with --brand-dark-foreground, never --primary-foreground: the first stays dark in both themes while the second flips, so that pairing goes dark-on-dark.