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-inverseIntent:
--color-primary --color-primary-foreground--color-secondary --color-secondary-foreground--color-muted --color-muted-foreground--color-accent --color-accent-foreground--color-destructiveLines and focus:
--color-border --color-border-default --color-border-subtle --color-border-strong--color-input --color-ringStatus (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-subtleRequired — 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-statType 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-springOptional — 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.