Patterns

The distilled rulebook behind every screen built on ground — surfaces, fields, selected states, table shape, sizing. Read it before building a page, not after something looks off.

Rules distilled from real usage of design-system’s components across dashboards, tables and toolbars. Apply them directly — don’t re-derive from scratch.

Tokens & theming

  • theme.css is the single source for the palette — an app’s app.css is only @import '@firmly/design-system/theme.css';. Never hex values, token overrides, or focus-visible:/ring-/outline- classes in app code; change theme.css once, every app updates. Exception: merchant-supplied branding (e.g. a brand-color swatch) is content, not chrome.
  • Contrast model: a background page with elevated card/popover surfaces (cards, headers, sidebar, overlays) on top. foreground = primary text, muted = secondary text (no separate muted-foreground token), hover = the one shared hover tint, field = fill for anything typed/picked in, primary/primary-foreground = accent, ring = focus color (same as primary). Light/dark both derive from these names via light-dark().
  • Focus is owned globally by one rule in theme.css: :focus-visible { outline: 1px solid var(--ring); outline-offset: -1px }. It draws inside the box on purpose — surface-sheen already puts a 1px lit rim there, so focus recolors that rim instead of adding a second shape outside it; nothing shifts or grows as focus moves. Components/apps must not add their own focus classes, and must never use outline-none without a replacement highlight (menu items use data-highlighted:bg-hover).
  • That rule must stay in @layer base. @import 'tailwindcss' puts every utility in the utilities layer, and unlayered CSS beats any layer regardless of specificity — so an unlayered global rule can’t be overridden by a class, and outline-none on a component would become silent dead code. Anything global a component may legitimately need to override belongs in a layer.
  • Exception: an overlay focused purely to trap focus (PopoverContent, DialogContent, SelectContent) can suppress the outline entirely — a ring around the whole panel says nothing the panel isn’t already saying, and its controls keep their own. Use focus-visible:outline-none there only.
  • Color transitions are global too — one theme.css rule gives every interactive element a 200ms ease transition on color/background-color/border-color. Never add transition-colors in components/apps; only non-color transitions (transition-transform) belong there.

State conventions — one treatment per role, everywhere:

StateClasses
Hoverbg-hover (+ hover:text-foreground when idle text is muted)
Anything the user picked — checked, on, chosen, the primary action (checkbox, Toggle, ToggleGroupItem, tab, date-range endpoint, Button default, Badge default, UserMenu trigger)bg-selected text-selected-foreground
Current page or section (Sidebar row, SectionNav entry, NavRail icon)bg-selected text-selected-foreground font-medium + aria-current (via navItemClass)
Current in-page location (pagination page)bg-card text-foreground + aria-current
Current location inside an overlay (a popover’s navigation rail)bg-hover font-medium + aria-current
Transient keyboard/menu highlightbg-hover
On/off track of a Switchbg-hoverbg-primary (the one deliberate exception, below)
  • bg-selected is mixed from --background, not the surface under it — so a save button on the page and a pressed segment in a track beside it land on the exact same blue. Never bg-primary, bg-primary/10 or bg-primary/15 for a chosen state — older spellings of the same idea, each a different blue.
  • Hover on an already-selected element is hover:bg-selected-hover, never an alpha film of blue. --selected-mix is 12% light / 30% dark, so a fixed primary/15 would sit above the mark in one mode and below it in the other (dimmer in dark mode); --selected-hover steps off --selected itself, so it moves the same direction in both modes.
  • Exception: Switch keeps raw bg-primary — its chip-sized track is its state with no label to help, so it needs to be darker/louder than any surface to read as on/off. bg-primary is also correct wherever blue isn’t marking a choice at all: Progress fill, the filter dot, a column-resize divider.
  • Never bg-secondary for a selected state — invisible against bg-background. The table’s “current location” row above splits in two because --popover equals --card: inside a popover, bg-card would mark nothing, so font weight carries the state instead.
  • In a column nav the tint is drawn around the label, not across the column: navItemRowClass on the anchor takes the full width so the whole row stays clickable, and navItemClass on a child inside it carries the fill at the label’s own width. A 288px band of colour for a six-letter word claims more of the eye than the word is worth, and a column of them reads as a stack of blocks rather than a list of names. The row gives up its own :focus-visible outline (outline-hidden) and hands it to the child through group-focus-visible, so focus and hover light the same shape — that is the replacement highlight the focus rule above requires, not an exemption from it. A horizontal nav (AppShell’s header, a marketing shell) uses navItemClass alone: its entries are already only as wide as their labels.
  • Sidebar’s current page takes the primary tint; every other row (even a section whose child is current) stays plain text-foreground on hover-only background — two filled rows would read as two locations. NavRail shares the exact same tint rule for its active icon, including a parent icon whose flyout contains the current route (the rail has no child row to carry that state otherwise). UserMenu’s trigger carries that tint at rest too, though it’s never a “current page” — the one deliberate exception, so the account control reads as part of navigation rather than a generic hoverable row.
  • Nav labels are never text-mutedmuted is for secondary/meta text (descriptions, timestamps, helper labels), and a navigable destination is none of those. Dimmed labels read as disabled controls and shift contrast inconsistently between light/dark. Reserve dimming for things that really are secondary (the email under a name in UserMenu).
  • Exception — AppShell’s header areas (areaLinkClass), the one nav that dims at rest. It sits on the shell’s glass beside the wordmark, where a filled pill per area would read as four controls competing with the page, so the set trades fill for weight: every area is text-muted, the current one is text-foreground font-medium, and none of them takes a background. The dimming can’t read as disabled here because exactly one member of the set is always lit — the contrast is between siblings, not between a label and its own normal state. This is the header only; a Sidebar row and a SectionNav entry keep navItemClass.

Color

  • Page-level content — cards, and a secondary button sitting directly on the page — uses bg-card, never bg-secondary: --card and --secondary are different tones, and mixing them reads as a bug, not a choice.
  • Overlays (popover, dropdown, dialog, select) use bg-popover — the same value as bg-card by design; don’t override it.
  • Text: text-foreground for primary content, text-muted for secondary/meta text (descriptions, timestamps, helper labels). Never a one-off gray.
  • Exception — a Badge chip on a bg-card surface uses bg-secondary, contrasting against the surface underneath. A chip directly on the page background (e.g. Brand’s status chip in AppShell’s transparent header) follows the general rule and uses bg-card instead — bg-secondary is nearly invisible on bg-background in light mode.
  • A segmented control’s track (ToggleGroup root, TabsList) is a field, not a card (see below) — the same well-shape problem an Input has, on the page or inside a card alike.

Fields — the one rule that keeps contrast in sync

A field (Input, Textarea, SelectTrigger, DateRangePicker’s trigger, Checkbox, the ToggleGroup/TabsList track) never names a color — it wears bg-field surface-field, and the surface underneath decides what that resolves to. (Checkbox uses bg-field alone: at 20px there’s no room for a lit edge, nothing to step off.) Two forms only, same declaration:

Where the field sitsWhat bg-field resolves to
Directly on the page background (a table toolbar, a filter bar)--card — the first surface, lit like any panel
Inside anything already wearing a surface (Card, popover, dialog, sheet, a [--surface:…] track)a --field-mix step off that surface, in oklab

Why the split: the page background is an extreme, not a surface — nothing sits below pure black to step away from, so a field there becomes the first surface (keeping the light-catching sheen, reading like the bg-card buttons beside it) instead of a step above one. Once something else already holds the surface, only the step is left, so the field becomes a hole cut into it.

Consequences (previously hand-patched per call site):

  • Never set a background on a field from the call site. bg-secondary! on an Input inside a Card was the old fix for a field vanishing into the card — now wrong everywhere and inert too, since surface-field paints an opaque layer over background-color.
  • A field in a new container needs no extra work as long as the container sets its surface normally (surface-sheen, plus [--surface:var(--popover)] when it isn’t a card) — the field reads it and lands the right distance away on its own.
  • --field-mix (6% light / 12% dark, in theme.css) is deliberately smaller than --hover-mix, so hover:bg-hover on a field still moves it.
  • A segmented track is a field for the same reason an input is: items inside resolve bg-hover/bg-selected against the track, so the chosen item steps off the well it sits in, not off whatever’s behind the whole control — which is why a selected tab is bg-selected (same as a selected ToggleGroupItem), never bg-card.
  • Exception: CommandInput stays unboxed — it’s a Command palette’s header, transparent with a border-b; a fill would put a second panel inside the popover.
  • A field must never also carry surface-sheen — a surface derives --field-surface from its own --surface, so doing both creates a self-referential cycle CSS drops entirely, leaving it unpainted.

Motion

  • .fade-in-load — plain opacity fade on mount. The default: every top-level template wraps its main content in this, cheap enough for dense app pages (dashboards, tables) as well as landing pages.
  • .rise-in / .rise-in-view (.rise-in-2/.rise-in-3 to stagger siblings) — scroll-linked reveal (animation-timeline: view()), reserved for marketing/landing sections below the fold. Outside @supports (animation-timeline: view()) it does nothing at all — content just renders — a deliberate no-JS fallback, not a bug to patch.
  • .rise-in-load — the same rise+scale look as .rise-in-view, mount-triggered instead of scroll-linked, for above-the-fold content with no scroll to drive a timeline (a hero). Pair with an inline animation-delay per element to stagger.
  • flyAndScale (@firmly/design-system/composables/use-transition) — the Svelte transition:/in: for popover-shaped overlays: Popover, Select, Tooltip, DateRangePicker.
  • A directional slide or a plain scrim fade that isn’t flyAndScale’s rise+scale is fine as a raw svelte/transition (fly/fade) — see DialogContent’s edge-sheet fly and DialogOverlay’s fade, or a tab-panel crossfade like channel-tabs.svelte. Don’t force flyAndScale where the vocabulary doesn’t fit; don’t hand-roll @keyframes either.
  • scrollReveal (use:, @firmly/design-system/composables/use-scroll-reveal) — a JS reveal-on-scroll for when .rise-in’s @supports gate would otherwise leave a target browser with no reveal at all.
  • scrollFade and lightSource aren’t part of this entrance vocabulary — see Scrollable regions below for scrollFade; lightSource is the pointer-driven ambient light AppShell already applies.
  • --ease-entrance is the one entrance easing token — every primitive above already uses it; never hand-roll a cubic-bezier alongside it.
  • Reduced motion is handled inside each primitive already (a prefers-reduced-motion media query or matchMedia check) — never add a second guard around them.
  • The global color-transition rule (see Tokens & theming above) is a separate, always-on concern, not part of this list.

Borders — don’t use them

  • No border on cards, buttons, inputs, pickers, dialogs or sheets — a surface separates from the page by tone (bg-card on bg-background) alone; that step needs no help.
  • Shadow means “floating above the page” — nothing else. Only a layer that renders over other content casts one: shadow-md for popover/dropdown/select/date-picker, shadow-lg for dialog. A card, table, toolbar or selected item never floats, so a shadow there is a lie about depth. The theme defines only --shadow-md/--shadow-lg on purpose — typing shadow-sm means you’ve reached for a non-token: it falls back to Tailwind’s stock value (hard-coded black alpha, no light-dark()), so it looks wrong in light mode. That’s the tell the shadow shouldn’t be there.
  • Divider lines inside a component (e.g. border-b under a search input, border-r beside a preset list) are fine — those are functional separators, not surface borders.
  • Cascade trap: a call-site class like p-0/bg-card doesn’t reliably beat a component’s own default of the same property — source order in the stylesheet decides, not attribute order. If an override seems ignored, use ! (e.g. p-1!) instead of guessing at specificity.

Sizing

  • Every control in a page-level toolbar row (filter buttons, search input, date range picker, its nav arrows) is h-9, matching Button size="sm" — a mismatched height in a shared row is a bug, not a style choice.
  • Applies to a table’s footer too: rows-per-page select, pagination buttons and filter chips are all h-9/text-sm. Any control that shares a band with another matches its height — a 30px button beside a 36px select reads as broken, however small the delta.
  • Give the search input a fixed width (w-64), never w-full: inside a flex-wrap toolbar, a full-width child forces everything after it onto a second row.
  • A control nested inside a Card header (e.g. a ToggleGroup next to a chart title) should be visibly smaller than a page-level toolbar control — it’s secondary to the card title, not standing on its own in a toolbar.

Layout

  • One toolbar row per page: entity filters + reset on the left, date range picker on the right (justify-between on the row), directly above the content grid.
  • PageHeader owns page-level chrome — title, description, breadcrumb snippet, actions snippet — in one place. Don’t hand-roll a separate <Breadcrumb> block above <PageHeader>; pass it through the breadcrumb snippet instead.
  • breadcrumb: only on pages nested under a parent list/section (e.g. Merchants → Luxe Fashion, where the first crumb links back) — omit it on top-level pages (Dashboard, the Merchants list itself). A crumb that just repeats the page’s own title is noise, not a smaller version of the rule.
  • Loading skeletons must mirror the real layout: same toolbar row (including the left/right grouping), same grid spans, same order. A skeleton with a different shape than the loaded page causes a layout jump.

Scrollable regions

  • Any box that clips its content — a CommandList, a scrolling panel, the frame’s content area — gets use:scrollFade (@firmly/design-system/composables/use-scroll-fade). A hard edge with content behind it reads as the end of the list; a fade reads as “there is more”, the only cue a user gets that scrolling is worth doing.
  • It fades with a mask-image, not a colored overlay, so it costs nothing to place on any surface and can never disagree with the background behind it. Each edge’s fade is sized by that edge’s distance from the end, so it grows and shrinks with the scroll and is simply absent at the top and bottom — no transition to tune, no fade at all when the content fits.
  • It watches the box and every direct child, re-synced via MutationObserver, and paints once itself instead of waiting on the observer’s first callback — a full-height pane never resizes (only its contents do), the growing child is often not the first one, and the observer’s first callback is async, so relying on it alone renders one frame with no fade.
  • Pass { top: false } (or { bottom: false }) where an edge is already handled by something else. The frame’s content area does this: the translucent header already sits over the top of the scroll and blurs it, so a second treatment there would just muddy it.

Popover / dropdown padding

  • Outer content container: a small p-1, not p-0 (reads as cramped, content touches the rounded corners) and not the component’s default p-4 (reads as bloated for a short list).

Tables

  • Any list with search/filter/sort/pagination uses DataTable + createDataTable (@firmly/design-system/data-table) — hand-rolling from raw Table primitives is how the same toolbar/empty-row/skeleton/pagination got copied across three screens. Raw Table/TableRow/TableCell are for static tables only (a handful of rows, no controls).
  • Columns are declared once as data ({ id, accessor, label, sortable, searchable, filter, align, format, hidden }); a custom cell is a snippet named after the column id. createDataTable owns search/filter/sort/pagination state and is the only thing that talks to @tanstack/table-core — app code never imports TanStack.
  • Filtering is one Filters button, never a row of per-column buttons — pre-declared FacetedFilter buttons don’t scale past two or three columns and silently cap what’s filterable. FacetedFilter itself stays available for filtering something that isn’t a table column.
  • The Filters button opens a two-pane panel — a rail of filterable columns on the left, that column’s editor on the right, both visible at once (same shape as DateRangePicker’s preset rail + calendar); a single swapping column with a back button doesn’t survive more than a couple of operators. The rail marks filtered columns with a bg-primary dot; the panel foots with the composed expression so the whole query stays readable while you build it.
  • Operators carry a symbol plus a word — ∈ = ≠ > < ↔, and a…/…a/…a… for starts-with/ends-with/contains. The panel shows both (symbol learnable next to its word); the active-filter readout shows only the symbol (aria-hidden, words in an sr-only span) — prose operators are what make a filter line long enough to wrap the toolbar.
  • Active filters render on their own row beneath the toolbar, never inline — three inline will wrap the toolbar regardless of control sizing. They read as a caption, not chips: text-muted text-sm, no surface, no height, just the value in text-foreground and a small ×. A filled h-9 pill would carry the weight of a control, but the only control here is the × — the Filters button is the one way back into the panel.
  • A column already covered by the search box (searchable) should set filter: false unless you genuinely need a comparison search can’t express — “Merchant contains…” beside a search that already matches on merchant is two controls for one job, and on a server table the text filter has nowhere to be sent.
  • Ticking a value in a list applies immediately, so facet counts stay honest as you tick. A typed comparison waits for Apply or Enter, because a server-backed table would refetch on every keystroke.
  • Column visibility (columnToggle) and rows-per-page (pageSizeOptions) are opt-in props on DataTable; turn them on for any full-page table.
  • Column width has two mechanisms, not interchangeable. A column that’s always one size — an icon or actions column — gets headClass: 'w-10', and the table keeps sizing every other column to its content. Draggable columns get resizable on DataTable, which switches the whole table to table-fixed, truncates overflowing cells, and marks every resizable head with an idle (not hover-only) bg-border divider on its right edge — an edge you can only find by hovering is one nobody finds. The divider turns bg-primary on hover and thickens while dragging; width/minWidth/maxWidth on a column then set the starting size and the limits. Don’t turn resizable on just to set one width — fixed layout means every column then needs a sensible width.
  • A control that lives inside a cell takes part in that cell’s accessible name, so a resize handle labelled “Resize Total column” turns every cell of the column into “Total, Resize Total column” — give the cell its own aria-label when it holds one. For the same reason an unlabelled column gets no handle at all: there is nothing to name it after, and an actions gutter is not something anyone drags.
  • Anything that is only a color or a shape needs a name of its own: the rail’s filtered dot is role="img" aria-label="filtered", and a selected operator carries aria-pressed. A state a sighted user can see and nobody else can is half a state — and it’s also the half that can’t be tested.
  • Server-driven tables pass server: true + total, then feed table.query into their fetch. Tables whose filters should only apply on “Apply” pass commit: true and call table.apply().
  • Wrap tables in a plain bg-card rounded-lg overflow-hidden container — no border, and no shadow either: a table sits on the page, it doesn’t float above it. Row dividers (TableRow’s own border-b) stay — that’s a content separator, not a surface edge.

Charts

  • Never use a chart library’s built-in absolutely-positioned legend — it floats over the plot area and overlaps the axis. Render your own legend below the chart instead: a <ul> of color dot + label, same pattern for every chart (see donut-chart.svelte, map-chart.svelte).
  • High-cardinality series (per-state, per-SKU, anything that could be a long list) skip the legend entirely and use a hover caption instead (e.g. “Hover a region to see revenue”). A full legend list floods once the series count grows past a handful.