Skip to content
View as Markdown

Styling and Theming ​

Styling is done with Tailwind CSS v4 against @tailor-platform/app-shell's design system, which ships as CSS variables bridged into Tailwind's token namespace. Use the tokens whether you are consuming AppShell components (most cases) or building a custom component to fill a gap.

The tokens are the rails. Consistency across customers, apps, and AI runs comes from the token system, not from rules written in prose. A hand-typed #fff or padding: 13px is not a "small deviation" — it is the mechanism by which consistency dies.

Every token in this document is verified against the shipped CSS. If a token is not listed here, assume it does not exist. The colour scales (--primitive-*) are internal: they are not bridged to Tailwind and are not listed. Inventing a plausible-sounding token (bg-surface-1, text-fg-muted, --space-4) is the worst failure mode available: Tailwind emits no CSS at all for an unknown utility, so the class is silently dropped and the element renders unstyled — no error, no warning, nothing in the console. When unsure, read node_modules/@tailor-platform/app-shell/dist/themes/default.css; it is the ground truth.

Setup ​

AppShell exports @tailor-platform/app-shell/styles. Your app's CSS entrypoint (index.css / globals.css) needs exactly this:

css
@import "tailwindcss";
@import "@tailor-platform/app-shell/styles";

/* Optional: at most one palette override, imported AFTER styles */
@import "@tailor-platform/app-shell/themes/bloom";
  • tailwindcss — your app's own Tailwind build, which generates the utilities you write in your components.
  • @tailor-platform/app-shell/styles — the single required import. It ships the palette (light and dark), the Tailwind v4 @theme inline bridge (so bg-background / text-muted-foreground resolve in your Tailwind build), and AppShell's precompiled component CSS. Your entry CSS should declare none of these itself — a copy of any of them overrides AppShell's and silently breaks dark mode.
  • @tailor-platform/app-shell/themes/* — optional palette overrides (default, cream, bloom). Import at most one, after styles. Palette selection is by CSS import; there is no runtime palette prop.

Do not import @tailor-platform/app-shell/theme.css. That export is a deprecated no-op shim kept only so older apps keep building; it emits nothing. Older docs also referred to app-shell.css — prefer styles.

Tailwind v4 stays CSS-first; the vite / PostCSS wiring is minimal.

Theming with CSS variables ​

Tokens exist in two layers, and knowing which one you are touching matters:

  1. Raw CSS variables (--background, --primary, --radius) — defined on :root in themes/*.css. These are what you override.
  2. The Tailwind bridge (@theme inline in theme.bridge.css) — maps each raw variable into Tailwind's namespace (--background → --color-background), which is what makes bg-background a real utility. You do not edit this layer.

Override raw variables after the styles import, using :root for light and :root.dark for dark. Set every override in both modes — overriding just :root misbehaves either way: on the default palette the light value carries into dark mode, and on a branded palette the override stops applying in dark mode altogether.

css
:root {
  --primary: #3b82f6;
}

:root.dark {
  --primary: #60a5fa;
}

Use :root.dark, not a bare .dark. The default palette is imported inside a cascade layer (layer(theme.defaults)), so any unlayered declaration of yours beats it — but the branded palettes (cream, bloom) are imported unlayered and define their dark values on :root.dark, which outranks .dark. A .dark override would silently lose against those.

Override at the highest scope where the change applies. A narrower scope (per-section, per-tenant) needs the same both-modes treatment, and its dark rule must still outrank a branded palette's :root.dark — pair .tenant-a with :root.dark .tenant-a. Do not duplicate token values across files — change them at the source. Never copy the palette wholesale; tokens you did not copy stay on AppShell's values, so the two halves drift apart and any surface AppShell adds later has no value in the copy at all.

Do not paste a @theme inline block, a @custom-variant dark rule, or a copy of AppShell's palette into the app's entry CSS. styles provides all three. A copy of any of them in the app's own CSS is unlayered, so it beats AppShell's layered palette and silently breaks dark mode — the build succeeds and nothing warns.

Dark mode is a .dark class on <html>, not a data attribute. AppShell's theme provider toggles document.documentElement.classList between light and dark and persists the choice under the appshell-ui-theme localStorage key; the bundled AppearanceSwitcher component drives it. The bridge registers @custom-variant dark (&:where(.dark, .dark *)), so the dark: variant works in your own markup.

AppShell primitives respond to dark mode automatically. Custom components inherit it for free as long as they reference tokens (bg-card, text-muted-foreground) and never inline literal colors.

Theme Palettes ​

AppShell ships three palettes, each with light and dark variants:

PaletteCSS import
defaultIncluded automatically via @tailor-platform/app-shell/styles
cream@tailor-platform/app-shell/themes/cream
bloom@tailor-platform/app-shell/themes/bloom

Select a palette by importing its CSS file — no prop needed. Import it in your global CSS after @tailor-platform/app-shell/styles:

css
@import "tailwindcss";
@import "@tailor-platform/app-shell/styles";
@import "@tailor-platform/app-shell/themes/cream"; /* overrides default palette */

Only import one palette at a time.

Adding a palette ​

Theme tokens live in packages/core/src/assets/themes/. Copy _template.css to start a new palette — it lists exactly which sections to fill in for light and dark mode.

SectionRequired?What to set
1. BrandYesprimary, secondary, accent (+ foregrounds) — both modes
2. Shell gradientBranded palettes only--shell-gradient-base, --shell-gradient-tint
3. SystemTune or copy defaultSurfaces: background, card, popover, muted, borders
4. PaletteOptionalRadius, chart colors, shadows
5. SemanticInheritColour scales, status and alert tokens inherit from default.css. Override a colour role (--{intent}-{role}) only to recolour the status components
6. StructuralBranded palettesCopy the structural override block from bloom.css or cream.css when needed

A palette is selected by CSS import, not by an AppShell prop. Import exactly one theme file after @tailor-platform/app-shell/styles; if you import none, the default palette from styles is used.

Preview token values at /showcase/colors in the Vite example app.

Or skip the local setup: theme.tailor.tech takes a primary color, previews it on real AppShell components, and exports a themes/{name}.css structured according to the tier table above.

Color modes: light / dark / system ​

AppShell supports three color modes: light, dark, and system (follows the OS setting).

Set the initial mode via the defaultColorTheme prop on <AppShell>. The user's selection is persisted to localStorage and restored on subsequent visits:

tsx
<AppShell defaultColorTheme="system" modules={modules}>
  {/* ... */}
</AppShell>

Use the useTheme hook to read or change the theme at runtime:

tsx
function ThemeToggle() {
  const { resolvedTheme, setTheme } = useTheme();
  return (
    <button onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}>
      Switch to {resolvedTheme === "dark" ? "light" : "dark"} mode
    </button>
  );
}

Drop the pre-built AppearanceSwitcher component anywhere in your layout for a ready-made light/dark/system toggle:

tsx
function AppearanceToggle() {
  return <AppearanceSwitcher />;
}

Tokens ​

Everything below is verified present in the shipped CSS. Use the token, never hand-type the value. A hex literal or magic px in a PR is a review failure.

Color ​

Colors follow shadcn-style semantic naming: a surface token and its matching -foreground pair. Pick by intent, not by visual taste. Pair a background with its own foreground — bg-card goes with text-card-foreground.

Surface & chrome ​

TokenUseTailwind
--background / --foregroundpage background, default textbg-background / text-foreground
--card / --card-foregroundcard and panel surfacesbg-card / text-card-foreground
--popover / --popover-foregroundmenus, popovers, tooltipsbg-popover / text-popover-foreground
--muted / --muted-foregroundsubtle fills; secondary textbg-muted / text-muted-foreground
--borderhairlines, dividersborder-border
--inputform control bordersborder-input
--ringfocus ringsring-ring / outline-ring

Surfaces are named by role, not by depth — there is no numbered surface-1/2/3 ladder, and no third tier of body text below muted-foreground. Stack depth with background → card → muted plus a border or shadow, and reach for popover when the surface actually floats.

Brand & action ​

TokenUseTailwind
--primary / --primary-foregroundprimary buttons, emphasisbg-primary / text-primary-foreground
--secondary / --secondary-foregroundsecondary buttons, neutral chipsbg-secondary / text-secondary-foreground
--accent / --accent-foregroundhover and selected nav statesbg-accent / text-accent-foreground
--destructive / --destructive-foregrounddestructive actions, errorsbg-destructive / text-destructive

There are no -hover or -active brand tokens. Express interaction states with Tailwind variants and opacity — hover:bg-primary/90, active:bg-primary/80 — which is what AppShell's own components do.

Status ​

Deprecated. These five tokens and their utilities remain for existing code and will be removed in the next major. New code uses the semantic color roles below. In the default palette they are aliases of those roles, so status colors and Alert colors agree:

TokenUseTailwindUse instead
--status-defaultnone / not applicable (follows --muted-foreground)bg-status-defaultbg-neutral-indicator
--status-neutralinformationalbg-status-neutralbg-info-solid
--status-completedsuccess, completedbg-status-completedbg-success-solid
--status-attentionwarning, needs attentionbg-status-attentionbg-warning-solid
--status-dangererror, blockedbg-status-dangerbg-danger-solid

Prefer Badge with a semantic variant (success, warning, error, info, neutral) over applying a fill directly — the variants already pair fill and foreground correctly.

These are fill and indicator colors. --status-default follows --muted-foreground, so it is theme-dependent and translucent in cream and bloom; the four hue tokens are opaque in every palette. Do not use them as text color: --status-completed, --status-danger and --status-neutral are below 4.5:1 on a dark card, and --status-attention is below 3:1 on a light one. For text, use text-{intent}-text.

Semantic color roles ​

Each intent has eight roles. The intents are info, success, warning, danger and neutral. The hue intents read internal color scales; neutral reads the system tokens. Every role is a token named --{intent}-{role} and a Tailwind color named {intent}-{role}:

RoleTailwindUse for
surfacebg-info-surfaceSoft background
surface-hoverbg-info-surface-hoverHover state of a soft background
borderborder-info-borderBorder on a soft background
solidbg-info-solidFilled background
solid-hoverbg-info-solid-hoverHover state of a filled background
texttext-info-textText and icons on a soft background
contrasttext-info-contrastText on a filled background
indicatorbg-info-indicatorDots and small marks
tsx
<span className="rounded-md bg-success-surface px-2 py-0.5 text-success-text">Paid</span>

In the default palette, text on surface and contrast on solid and solid-hover are at least 4.5:1 for the four hue intents, in light and dark mode, on --card and --background. A unit test checks this from the shipped CSS. text on surface-hover is held at 4.2:1 by the same test; it is below 4.5:1 for danger and success in light mode. The test does not cover the cream and bloom palettes or the neutral intent.

Most surface and border values are translucent tints, so an opacity modifier compounds rather than replaces — bg-info-surface/50 halves the tint's alpha instead of setting it to 50%. Some light-mode values are opaque instead of translucent: the warning surface, surface-hover and border, the info border and the danger surface-hover. They do not blend with a tinted parent. All dark-mode values are translucent.

Badge, Alert, CsvImporter and MetricCard read these roles directly, not --status-* or --alert-*. To recolour them, override the roles. Badge error and subtle-error, and Alert error, follow --danger-*. They no longer follow --destructive, which still drives Button and text-destructive.

--sidebar, --sidebar-foreground, --sidebar-border, --sidebar-primary(-foreground), --sidebar-accent(-foreground), --sidebar-ring → bg-sidebar, text-sidebar-foreground, and so on. These let a palette tint the shell independently of page content.

--chart-1 … --chart-5 → bg-chart-1, text-chart-1, fill-chart-1. Use them in order for categorical series.

Alerts ​

Deprecated. --alert-{neutral,success,warning,error,info}-{background,foreground,foreground-muted,border} and the matching utilities (bg-alert-info-background, text-alert-info-foreground, ...) remain for existing code and will be removed in the next major. In the default palette they are aliases of the semantic color roles: background is surface, border is border, foreground is text, and foreground-muted follows foreground; error reads the danger intent. The Alert component reads the roles, so overriding --alert-* changes these utilities but not Alert.

For new custom surfaces, use the roles instead of the alert slots:

tsx
<div className="rounded-lg border bg-warning-surface text-warning-text border-warning-border">
  …
</div>

Across all colour tokens:

tsx
// Good — semantic token pairs, and a variant where one exists
<div className="bg-card text-card-foreground">…</div>
<Button variant="destructive">Delete</Button>
<Badge variant="warning">Pending review</Badge>

// Bad — raw colors bypass the theme
<div style={{ background: "#fff", color: "#111" }}>…</div>

// Bad — these classes do not exist and render as nothing at all
<div className="bg-surface-1 text-fg-muted">…</div>

Spacing ​

AppShell defines no spacing tokens. Use Tailwind's default 4px-based scale (p-4, gap-2, mt-8) — the same scale AppShell's own components use internally. There is no --space-* variable.

tsx
// Good — scale step
<div className="flex gap-3 p-4">…</div>

// Bad — magic value
<div className="p-[13px]">…</div>

Hand-typing padding: 13px is a smell. Round to the nearest scale step; if nothing fits, the layout is wrong, not the scale.

Typography ​

AppShell defines no typography scale tokens. There is no text-h1, text-body, or text-caption. The only typography token is --font-sans (Inter Variable for Latin, Noto Sans JP Variable for Japanese) → font-sans, which the base layer already applies to body. Both are variable fonts, so every weight token is real in both scripts. Override the whole stack by setting --app-shell-font-sans on :root.

Compose roles from stock Tailwind utilities. These pairings are what AppShell's own components use — match them so your screens sit consistently alongside the primitives:

RoleUtilities
Page title (Layout.Header)text-2xl font-bold tracking-tight
Section headingtext-lg font-semibold
Card titletext-lg font-semibold leading-none
Body copytext-sm
Secondary copy, descriptionstext-sm text-muted-foreground
Caption, timestamp, metadatatext-xs text-muted-foreground
Numeric value in a table or fieldtext-sm font-medium tabular-nums
ID, code, keyboard hintfont-mono text-xs text-muted-foreground
tsx
<h2 className="text-lg font-semibold">Section</h2>
<p className="text-sm text-muted-foreground">Description copy</p>
<span className="text-xs text-muted-foreground">Updated 2h ago</span>

Always use tabular-nums for numbers that stack in a column — without it, digits jitter between rows.

Radius ​

--radius (0.625rem) is the base; the bridge derives four steps from it. Pick by component role — a card is always md, regardless of its size on screen.

TokenValueUseTailwind
--radius-smradius − 4pxinputs, small chipsrounded-sm
--radius-mdradius − 2pxbuttons, cardsrounded-md
--radius-lgradiusmodals, sheetsrounded-lg
--radius-xlradius + 4pxlarge surfacesrounded-xl

rounded-full (pills, avatars) is a stock Tailwind utility — there is no --radius-full token, but the class works.

Changing --radius alone rescales all four steps together.

Shadow ​

Four mode-aware shadows. There are no --elevation-* tokens; the scale is expressed as shadows. Never hand-craft a box-shadow.

TokenBridged toUse
--semantic-shadow-xsshadow-xshairline lift, active nav item
--semantic-shadow-smshadow-smcards, persistent panels
--semantic-shadow-mdshadow-mdpopovers, menus, hovered surfaces
--semantic-shadow-lgshadow-lgmodals, dialogs, sheets

Higher shadow reads as "more transient" — match it to the component's lifetime. Each token carries a different value in dark mode, so using the token (rather than a literal) is what keeps depth legible on both backgrounds.

Motion ​

AppShell defines no motion tokens. There is no --motion-fast or --ease-out variable. Use Tailwind's duration-* and ease-* utilities. AppShell uses tw-animate-css internally for its own enter/exit animations, but those classes are prefixed and are not available to your app — add @import "tw-animate-css"; to your own entrypoint if you want them.

IntentUtilities
Hover, focus, button pressduration-150 ease-out
State change (toggle, select)duration-200 ease-in-out
Entrance, dialog openduration-300 ease-out
tsx
<div className="transition-colors duration-150 ease-out motion-reduce:transition-none" />

Always give motion a reduced-motion escape (motion-reduce:transition-none, or a @media (prefers-reduced-motion: reduce) block in CSS). AppShell components handle this internally; custom components must do the same.

Z-index ​

These exist as raw :root variables in the shipped base layer, but are not bridged into Tailwind — z-sidebar is not a class. Use the stock numeric utility, or reference the variable when you need it to track AppShell's layering:

TokenValueUse
--z-sidebar10persistent sidebar
--z-sidebar-rail20sidebar collapsed rail
--z-popup50menu, tooltip, popover
--z-overlay50modal, sheet, dialog backdrop
tsx
<div className="z-50" />
<div className="z-[var(--z-popup)]" />

Never invent a z value — z-index: 9999 is always wrong. Popups and overlays share 50 intentionally: sequencing comes from DOM order, not z escalation.

Icons ​

AppShell exports no Icon component and defines no --icon-* tokens. Icons come from lucide-react, which AppShell already depends on. Size them with Tailwind, pairing icon size to the adjacent text:

Text sizeIcon class
text-xssize-3
text-smsize-4
text-lgsize-5
text-2xl (title)size-6
tsx
import { Check } from "lucide-react";

<Check className="size-4" />;

Breakpoints ​

Stock Tailwind breakpoints — AppShell does not change them.

TokenWidth
sm640px
md768px
lg1024px
xl1280px
2xl1536px

ERP target is xl/2xl desktop. Pages should be designed for those widths first; smaller breakpoints exist for graceful degradation, not parity. Don't waste effort on mobile-first composition unless a screen explicitly calls for it. A list page that collapses gracefully at md is fine; a list page redesigned for sm is over-investment.

Two-column behavior (right rail stacks under lg): respect AppShell defaults — do not force side-by-side grids on narrow viewports. The Layout column width table lives in Layout; reuse those numbers instead of guessing rem values here.

Fonts ​

AppShell bundles Inter Variable for Latin and Noto Sans JP Variable for Japanese, and applies both via the body rule in @tailor-platform/app-shell/styles. No extra import:

css
@import "tailwindcss";
@import "@tailor-platform/app-shell/styles";

Both are variable fonts with a continuous 100–900 weight axis, so every weight the design system names — font-normal, font-medium, font-semibold, font-bold — resolves to a real weight in both scripts rather than to whatever faces happen to be installed.

Why Japanese needs its own font ​

Inter has no CJK glyphs, so without a bundled Japanese font, Japanese characters fall through to the operating system's. On Windows that font is Yu Gothic UI, which ships only Light/Semilight/Regular/Semibold/Bold — no 500 — so CSS weight matching resolves a font-weight: 500 request down to Regular, and font-medium becomes indistinguishable from body text in Japanese. Current macOS is not affected: it ships Hiragino Sans W0–W9 including W5, which font-weight: 500 resolves to correctly. Bundling the font makes the weight scale hold on every platform rather than depending on what the OS happens to install.

Noto Sans JP is metric-harmonised against Inter (size-adjust: 94% plus ascent/descent overrides) so mixed Japanese/Latin strings read at one optical size, and a line containing Japanese is exactly as tall as one without. A metric-matched local() fallback covers the window before a subset arrives, so rows do not change height as fonts stream in.

What it costs ​

The Japanese faces are roughly 5 MB of woff2 subsets, and they land in your build output whether or not your app renders Japanese — a bundler emits every subset it can see, because it cannot know at build time which characters your data will contain. The stylesheet grows from about 98 KB to 200 KB uncompressed, or 15 KB to 45 KB gzipped.

What your users download is much smaller, and proportional to what they actually read. Each subset carries a unicode-range, so the browser fetches one only when it is about to paint a character in that range. The faces are restricted to Japanese blocks — kana, kanji, CJK punctuation, fullwidth forms and the compatibility blocks carrying ㈱ ㍿ ㎡ — so shared symbols such as ✓ or → resolve to Inter or the system font as before, and an app that renders no Japanese downloads none of them:

what the user has seendownloaded
all hiragana + katakana~237 KB
+ ~230 common ERP kanji~458 KB
+ ~30 name kanji including variant forms~467 KB
typical first Japanese screen~910 KB

Files are content-hashed and cached, so the cost is front-loaded rather than per-navigation, and it grows slowly as unusual characters appear in your data. Weight costs nothing extra — every weight lives in the same file, so using font-medium and font-bold downloads no more than font-normal alone.

To drop the Japanese faces entirely, override the stack without naming them (see below). Nothing references them, so nothing is downloaded — though the files still ship in your build output.

Using your own font ​

Set --app-shell-font-sans on :root after importing AppShell styles to replace the whole stack:

css
@import "@tailor-platform/app-shell/styles";

:root {
  --app-shell-font-sans: "Your Brand Sans", ui-sans-serif, system-ui, sans-serif;
}

A replacement should be a variable font, or otherwise supply real 400/500/600/700 faces — AppShell's weight scale assumes all four exist. If your app renders Japanese, include a Japanese family with a continuous weight axis for the same reason.

Styling AppShell components ​

Write plain Tailwind utilities in your application code. Three rules cover every case:

  1. Your own markup — ordinary utilities, exactly as in any Tailwind app.

  2. A documented layout hook — props like Table.Root's containerClassName also take ordinary utilities from your Tailwind build:

    tsx
    <Table.Root containerClassName="max-h-96 overflow-y-auto" />
  3. An AppShell component's own appearance — reach for its documented props, variants, or composition rather than styling over its internals. Sheet.Content takes size, Table.Head and Table.Cell take align, Grid takes columns, Layout takes gap, Button takes variant. A prop is a supported contract; a utility class aimed at a component's internals is not.

tsx
// Props, not utilities, for a component's own appearance
<Sheet.Content size="lg" />
<Layout gap={6} />
<Button variant="destructive">Delete</Button>

// Ordinary utilities on your own markup and on documented hooks
<div className="flex flex-col gap-4">
  <Table.Root containerClassName="max-h-96 overflow-y-auto" />
</div>

When no prop exists and you genuinely have to override a value a component sets, add Tailwind's importance modifier — a trailing !. It is the last resort, not the first: a plain utility silently loses (see below). Check for a prop before reaching for it. Sitting a table flush inside a card, for example, used to need className="px-0!"; it is now a prop:

tsx
<Card.Root>
  <Card.Header title="Line items" />
  <Card.Content padding="none">
    <Table.Root>{/* … */}</Table.Root>
  </Card.Content>
</Card.Root>

Note the table container adds no horizontal padding of its own there: Table.Head and Table.Cell already carry first:pl-6 / last:pr-6, so a containerClassName="px-6" would stack on top and push the first column 24px past the card title.

Never write the astw: prefix in application code ​

AppShell's own components are styled with utilities carrying an astw: prefix (AppShell TailWind). This exists because Tailwind generates classes at build time: AppShell's stylesheet is compiled and published before your application's is generated, and Tailwind cannot reconcile two independently-generated stylesheets against each other. The prefix keeps the library's utilities from clashing with yours.

The prefix is internal to the library, and it is not a customization API. Your Tailwind build has no astw prefix configured, so it never generates astw:* classes — an astw: class written in application code only resolves if AppShell happens to already ship that exact utility for its own use. Many do not — 17 of the 55 classes this documentation used to recommend are absent from the shipped stylesheet — and Tailwind emits nothing for an unknown utility: no error, no warning, nothing in the console. The class lands in the DOM and does nothing.

Worse, one className string can be half-applied. AppShell merges class names with tailwind-merge, which is not configured with the astw prefix, so it strips a conflicting internal class while leaving an unshipped astw: class in place — the element keeps a dead class and loses the style it had.

@tailor-platform/eslint-plugin-app-shell ships a no-astw-prefix rule that catches this in your own source. Enable it via the recommended preset in your oxlint.config.ts.

Why a plain utility can't override an AppShell default ​

This is the reason rule 3 sends you to props rather than to a more specific class. tailwind-merge groups astw:px-6 and px-0 separately — it reads the unknown astw: as a variant — so both survive on the element rather than the later one replacing the earlier. Both land in the same @layer utilities at equal specificity, and AppShell's precompiled sheet is imported after your Tailwind output, so its rule is later in the cascade and the library's value wins:

tsx
// Element keeps both classes; padding stays at AppShell's 24px
<Card.Content className="px-0" />

(For this particular case, use padding="none".)

State variants make it worse rather than better: Button's ghost variant sets a hover color, and a :hover rule outranks an unprefixed utility on specificity, not merely order — so a plain text-destructive on a ghost button is red only until the pointer reaches it. variant="destructive" is the supported way to express that; where no such prop exists, ! wins in every state because importance beats specificity.

Styling by state (data attributes) ​

AppShell's UI components support data-attribute-based styling, following the Base UI data attributes convention. Components expose data-* attributes that reflect their internal state, enabling CSS-only style control without JavaScript:

css
/* Style a component based on its state */
.SwitchThumb[data-checked] {
  background-color: var(--success-solid);
}

.MenuItem[data-highlighted] {
  background-color: var(--accent);
  color: var(--accent-foreground);
}

This works with Tailwind as well — use theme tokens, not raw Tailwind grays:

tsx
<Switch.Thumb className="bg-muted data-[checked]:bg-primary" />

Check each component's own page under docs/components/ for the data attributes it exposes. Custom components must follow the same convention (see Custom components).

One treatment for every text link — inside DescriptionCard, in table rows, in cards:

  • text-primary at rest, hover:underline only. Colour carries the affordance; a resting underline is noise. Pair with underline-offset-4.
  • Internal routes use app-shell's Link (to), never a bare <a href>.
  • External destinations use <a target="_blank" rel="noopener noreferrer"> with a lucide ExternalLink at size-3 after the label. There is no dedicated component for this; that markup is the convention.
  • Identifiers (document numbers, SKUs) keep font-mono on top of the link style.

This is the treatment DescriptionCard already applies to its type: "link" fields — match it rather than inventing a second one.

Composition & emphasis rules ​

These are visual-composition rules every screen must follow, regardless of pattern. They exist because emphasis only works when it is scarce.

Emphasis budget. Attention is a budget you spend once per scan region.

  • One primary action per view. A screen (or a card/section) has at most one filled/primary Button; everything else is outline, secondary, or ghost. If two things look equally important, neither reads as important.
  • Badges encode status by semantic color, with a clear primary/secondary split:
    • A record's primary / lifecycle status (PO status, SO status) → a filled semantic variant (success / warning / error / info / neutral) — one per row in a list, one in a detail header.
    • Secondary statuses (delivery, billing, fulfilment) and dense supporting columns → outline-* (with status dot).
    • Tags / labels ("New", "Returned") → subtle-*.
    • Reserve default (brand fill) for non-status emphasis — never the brand color as a routine status. The defect to avoid: making every chip a loud fill, or giving secondary statuses the same weight as the primary one. (Variants: Badge.)
  • Color: status colors signal meaning, not decoration — don't tint neutral content.

Hierarchy. One h1 per page (the Layout.Header title). Section headings step down in weight and size; never skip levels for size — pick the role from the Tokens → Typography table, not the pixel size.

States — never ship only the happy path. Every data-backed screen handles:

  • Loading — skeleton/placeholder, not a blank flash.
  • Empty — a labelled empty state (what it is, how to add the first record), not a bare empty table.
  • Error — an inline error with a retry affordance, not a silent failure.

Spacing rhythm. Use the spacing scale (see Tokens) consistently — equal gaps between sibling sections, consistent card padding. A one-off gap or padding that doesn't match its siblings reads as a mistake.

Quick reference ​

Where to look ​

ConcernWhere
Tokens, theming, the astw: styling boundary, conformancethis document
A component's imports, props, variants, compositionits page under docs/components/
A hook / function APIits page under docs/api/
Screen / page layout recipesdocs/patterns/ and docs/pages/
Building a custom component to fill a gapCustom components

Semantic decisions ​

IntentPick
Destructive action (delete, void)Button variant="destructive"; bg-destructive on custom surfaces; confirm in a dialog at shadow-lg
Non-blocking cautionBadge variant="warning", or bg-warning-surface text-warning-text
Confirmation / completed stateBadge variant="success", or bg-success-surface text-success-text
Informational calloutBadge variant="info", or Alert variant="info"
Neutral calloutBadge variant="neutral", or Alert variant="neutral"
Persistent panel (sidebar, header)shadow-sm
Hovered / sticky surfaceshadow-md
Popover / menu / tooltipbg-popover, shadow-md, duration-150
Modal / sheet / dialogshadow-lg, duration-300 entrance
Hover / focus transitionduration-150 ease-out
State change (toggle, select)duration-200 ease-in-out
Two-column detail at <1024right column collapses below main — do not override
Inline ID, code, table numberfont-mono text-xs (identifiers) / tabular-nums (figures)
Timestamp, label, subtle metadatatext-xs text-muted-foreground