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.
InfoTip, the “i” beside the heading, a tooltip that opens on hover and keyboard focus; on a phone its text is printed instead. A Field carries a description only when it says something the label cannot, and wires it to the control with aria-describedby — a description nobody hears is not a description.title tooltip — that reaches a mouse and nothing else. Every state says what it is in words, visibly or for a screen reader.tnum so columns line up — the quiet signal that this is financial software.Advanced disclosure rather than being hidden or removed.ss01 stylistic set rides globally. Previously body at 400, display at 500 and headings at 600.heading-3xl/2xl/xl (36/30/24, bold) · heading-lg/md/sm/xs (20/14/13/12, semibold) · body-lg/md/sm/xs (14/13/12/11; body-md is the page default). A button label is body-sm font-medium, an eyebrow body-xs font-medium in sentence case, and money adds tabular-nums. display-xxl/xl (56/48) are the marketing hero's and appear on no product surface. Tailwind's own sizes, and any arbitrary pixel value, are not this scale and do not pass review. Previously fifteen roles: display-lg/md, body-tabular, button-md/sm, caption, micro and an all-caps micro-cap are gone.lucide-react only, no emoji in chrome. 16px inside controls, 20px in feature spots.rounded-0 … rounded-750 and rounded-full (0, 2, 4, 6, 8, 12, 16, 20, 30px). Buttons, fields, tags and menu items are rounded-200 (8px), cards and popovers rounded-300 (12px), dialogs rounded-400 (16px); rounded-full is for pills that are not buttons. Previously seven radii with full-round buttons, squared to 6px inside the portals.p-4 and its 150 step p-1.5; 10px and 28px are not on it. Fields and the button that finishes a field row are 32px and buttons 28px from 768px; below it both are 44px (Apple). Component dimensions are separate: the 44px phone hit area is a requirement, not a spacing choice.shadow-bevel-100 for cards, shadow-300 for popovers and menus, shadow-400 for tooltips and floating bars, shadow-500 for toasts, shadow-600 for dialogs, as Polaris's components use them. The gradient mesh, not shadow, is the depth medium on marketing surfaces. Previously two levels: a card lift (now shadow-100) and a floating-panel lift (now shadow-400)./ on Grout's own host, and only that page, adds a sub-language of its own: the text-fp-* roles, the fp-sheet job-sheet card (a 2px ink rule with a hard 4px 4px 0 ink offset) and the fp-mark ochre highlighter, on warm canvas instead of the gradient mesh. It is page-scoped on purpose — a second surface reaching for one of these is a bug. DESIGN.md § The front page is the full list.animate-fade-up for page and step entry. Everything respects prefers-reduced-motion, including animate-spin and animate-pulse.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.
default 998px, narrow 662px, full.Section is a Polaris card: 16px padding, an h2, a 16px stack. Subsection sets off a part by space alone (24px), with its own h3. A card's Edit is an icon button with a tooltip in its header corner; its next steps sit in a right-aligned footer.Stack and Inline take a Polaris space token ("200" 8px, "400" 16px …). No margins on a templated page.FieldRow puts fields and their button on one 32px line with help text under each field, and stacks below 490px (the field: breakpoint). helpSpansRow lets a single narrow field keep its width while its help runs on under the button.HintTooltip opens on hover and keyboard focus with a mouse from 768px (tip:), its text is in the page for a screen reader, and on a phone or touch screen (tipless:) the text is printed instead.SettingsForm per page; its action returns { ok?, error? }, an error keeps the bar and every edit, and a success must revalidate the page, which the form remounts from.IndexFilters (views as tabs with counts, search, a FilterMenu, a SortMenu, Columns, removable chips), rows that open their record from anywhere (IndexRow with one RowLink), a bulk-action bar that replaces the filters when rows are ticked (IndexSelection, BulkActions), and 50 a page with Previous / Next and “1–50 of 312” (ListPagination, the page in the address). On a phone the table is a PhoneList of cells, the views a segmented control, and the paging one “Load more”. The bookings list is the reference.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>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.
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 paletteshadcn-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>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>?ok= / ?error=; the global FlashToaster converts those to toasts, scales the duration to the message length, and strips the params from the URL.?ok= / ?error= renders ActionResult below its title: a role="status" region announcing the message on arrival, still there to be read after the toast fades, with the tone spoken and drawn as well as tinted. A toast alone is not an announcement — it leaves in five seconds, and a refusal has to outlast that.⌘K anywhere in admin: jump to a page (each listed with its description) or search bookings, customers, and crews.ConfirmButton, which spells out the consequence and blocks Enter-key submission of the underlying form. Its words follow Polaris: the title is “{Verb} {object}?”, the body says what happens (and “This can't be undone” when that's true), the required confirmLabel repeats the verb and object (“Delete 3 crew members”), and the other button is “Cancel”. Previously the confirm button defaulted to “Yes, continue” and the cancel read “Never mind”./api/live (server-sent events) and refresh the server-component tree when data changes.LiveRefresh, search, bulk selection, and the tracking timeline.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.
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.
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.
--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.