Skip to content

For agents: an index of these docs is at /docs/llms.txt, and this page as markdown is at /docs/theme-contract.md.

Theme contract

Every variable a theme must define.

@dapplelight/signet-ui reads only the variables below. Any file that defines all of them is a valid Signet theme — that is the whole mechanism by which one component kit serves products with different design systems.

78 required, 16 optional. bun run test counts the code blocks in this file and fails if either number here, or the one in the repo's CLAUDE.md, drifts.

Define them inside @theme inline { ... } (Tailwind v4) so the class names resolve.

Required — color

Surfaces and text:

--color-background --color-foreground
--color-card --color-card-foreground
--color-popover --color-popover-foreground
--color-bg-base --color-bg-surface --color-bg-elevated
--color-text-primary --color-text-secondary --color-text-muted
--color-text-hint --color-text-disabled --color-text-inverse

Intent:

--color-primary --color-primary-foreground
--color-secondary --color-secondary-foreground
--color-muted --color-muted-foreground
--color-accent --color-accent-foreground
--color-destructive

Lines and focus:

--color-border --color-border-default --color-border-subtle --color-border-strong
--color-input --color-ring

Status (green = good, red = bad, amber = warn, blue = info — universal, not domain):

--color-accent-green --color-accent-green-subtle
--color-accent-red --color-accent-red-subtle
--color-accent-amber --color-accent-amber-subtle
--color-accent-blue --color-accent-blue-subtle

Required — form

Geometry, type and motion.

The size scale the components actually use

These are the names that matter. The components reference Tailwind's default scale — text-sm ×31, text-xs ×9, text-base ×4, rounded-lg, rounded-md. A theme that only redefines the semantic names below changes nothing, because no component references them.

--text-xs --text-sm --text-base --text-lg --text-xl
--radius --radius-sm --radius-md --radius-lg --radius-xl --radius-2xl
--radius-4xl --radius-full
--spacing

--spacing is the base density unit. Every h-*, px-*, gap-* and size-* in the components compiles to calc(<spacing> * n), inlined at build time, so this one token retunes the density of all 207 of them per theme.

This was wrong in the first version of this document. It claimed redefining the semantic scale reshaped every component. Compiling both themes and diffing the output showed text-sm resolving identically under each, because the themes defined --text-body and the components said text-sm. bun run test in this package now asserts these differ, so the claim cannot silently rot again.

Semantic scale — for app-level use

Available to products as text-body, text-headline and so on. Components do not use them; do not expect them to reshape anything on their own.

--text-caption --text-label --text-body --text-callout --text-subhead
--text-headline --text-title --text-display --text-hero --text-stat

Type and motion

--font-sans --font-display --font-mono
--font-weight-normal --font-weight-medium --font-weight-semibold --font-weight-bold
--shadow-xs --shadow-sm --shadow-md --shadow-lg --shadow-xl
--ease-default --ease-in --ease-out --ease-spring

Optional — named slots, never required

Names for things a brand layer otherwise invents (app-a's Fern layer did, for all of these). A theme may define any of them; nothing requires them, and no component in @dapplelight/signet-ui reads them. They exist so two products that want a chart palette or a focus ring call it the same thing.

Each colour slot is a runtime variable --X, set in both the light and the dark block like --ring is, and exposed to Tailwind as --color-X: var(--X) so bg-chart-1, ring-focus-ring and so on work. A theme that defines a slot must do both. The code blocks list the @theme names.

Generic — editorial.css and cinematic.css define these, as aliases of their existing tokens, so an app can use them under any Signet theme:

--color-chart-1 --color-chart-2 --color-chart-3
--color-chart-4 --color-chart-5 --color-chart-6
--color-focus-ring --color-focus-ring-offset
--color-accent-primary --color-accent-primary-fg --color-accent-primary-subtle
--color-accent-red-fg
--leading-display
Slot Meaning Default in the base themes
--chart-1 … --chart-6 categorical series colours the four status accents (blue, green, amber, red), then two theme colours
--focus-ring focus outline colour var(--ring)
--focus-ring-offset the gap between ring and element var(--bg-base)
--accent-primary / -fg the brand accent and ink on it var(--primary) / var(--primary-fg)
--accent-primary-subtle tinted brand background --primary at 10% (12–14% dark), like the other -subtles
--accent-red-fg ink on a solid --accent-red fill var(--primary-fg)
--leading-display line height for text-display / text-hero editorial 1.1, cinematic 1

Override the slot, not what it aliases: a layer that sets --ring: var(--focus-ring) must also set --focus-ring, or the two form a cycle and both become invalid.

Domain — finance.css only, never a base theme (see the domain rule in the repo's CLAUDE.md):

--color-money-credit --color-money-debit --color-money-transfer
Slot Default in finance.css
--money-credit var(--accent-green)
--money-debit var(--text-primary) — spending is the normal case; not red
--money-transfer var(--text-muted)

bun run test checks every slot a theme defines: the runtime variable is set in both modes, --color-X maps to it, the utility compiles to var(--X) with nothing undefined behind it, and the money slots stay out of the base themes.

Not part of the contract

Product-specific token sets live in the product, never here. editorial.css currently still carries app-a's --color-cat-needs / -wants / -investments / -income / -transfer — those are a finance taxonomy, not a design system. Strip them when app-a moves to @dapplelight/signet-tokens, or keep them in a finance.css that imports editorial.css.

Tailwind's stock palettes (--color-red-*, --color-green-*, --color-violet-* …) are not the theme's either. A theme sets the semantic tokens (--destructive, --accent-red …) to the hex it wants and leaves the stock scales alone, so an app's bg-red-500 is Tailwind's red under every theme. editorial.css used to repaint red as rose and pin green/blue/amber/emerald to v3 hexes; cinematic.css did the same to violet. bun run test now fails if a theme defines any stock scale variable or changes what a stock bg-* utility paints.

Density — corrected

An earlier version of this document said the ~200 raw density literals (h-8, px-2, size-4) "do not respond to this contract". That was wrong. Compiling shows:

.h-8 { height: calc(var(--spacing) * 8); }
.px-3 { padding-inline: calc(var(--spacing) * 3); }

The value comes from --spacing and is inlined per theme, so a theme that sets it retunes every one of them. Editorial 0.25rem gives h-8 = 2rem; Cinematic 0.3rem gives 2.4rem. Asserted by bun run test.

Note it is inlined, not a runtime var() — so density cannot be flipped by overriding a CSS variable at runtime the way colour can. It is a build-time property of the theme.

The real limitation is narrower than claimed: density scales globally, not per component. You cannot make buttons roomier while leaving table rows tight without editing the components or adding cva size variants. That is worth doing when a product actually needs it, and is not the blocker the earlier wording implied.

This is the second claim in this file that compiling falsified, and the correction itself was wrong on first attempt — it described --spacing as a runtime reference, which a second compile disproved. Both original errors came from reading the source instead of building it.

Verified, not asserted

bun run test compiles Tailwind against the real @dapplelight/signet-ui components under every theme and asserts that the same utility class resolves to different values. Current results:

Class editorial cinematic
text-sm 0.8125rem 1rem
text-xs 0.75rem 0.875rem
text-base 0.875rem 1.125rem
rounded-lg 0.75rem 0.1875rem
rounded-md 0.625rem 0.125rem
rounded-full 9999px 9999px — invariant, correctly

It also asserts that no utility resolves to an undefined custom property. That check caught rounded-4xl in badge.tsx, which referenced an undefined --radius-4xl and silently produced no radius in both themes — a live bug that had shipped in app-a.

Scanning — the consuming app declares it, not the theme

A theme must not carry @source. It cannot know where it is installed, and a hardcoded sibling path (../../../ui/src) resolves only inside this monorepo. Installed from a package it points at nothing, Tailwind scans no components, and every one of them renders unstyled — while the app's own utility classes keep working, so it looks fine until you look closely.

The app declares it, after the imports, because CSS requires @import first:

@import "tailwindcss";
@import "@dapplelight/signet-tokens/editorial.css";
@import "@dapplelight/signet-tokens/base.css";
@source "../node_modules/@dapplelight/signet-ui/dist";

create-signet-app emits this. create-signet-app's test asserts the built bundle contains data-slot and .bg-primary, which is what catches a regression here — the theme variables alone are not evidence that components were scanned.