Foundations

Tokens

Named design decisions that travel from a designer's file to a compiled binary without anyone retyping a hex code — and, in a regulated product, without anyone being able to change a safety-critical value by accident.

Stable · v1.0 shadcn/ui contract Tailwind

Overview

A token is a name bound to a value. The name expresses intent — --muted-foreground, --alarm-critical — and the value can change per theme without any component knowing. That indirection is what makes a dark theme a configuration change rather than a redesign.

In a SaMD the indirection buys something else as well: a single auditable place where safety-relevant visual decisions live. When a notified body asks how you guarantee that a high-priority alarm is red in every screen of every product, the answer is one token definition and the lint rule that protects it — not a review of four hundred components.

Intentional

A token name says what the value is for, never what it looks like. There is no --teal-button; there is --primary.

Layered

Primitives hold raw values, semantics bind them to roles, components consume only semantics. A component that reaches past its layer is a bug.

Governed

Most tokens are free to be re-themed. A small, explicitly marked set is not, because changing it changes what the interface means.

The three layers

A value travels through exactly three stops. Skipping one is what makes design systems drift.

How a value travels
1 --njt-teal-800: #0F3B35 Primitive — a raw ramp step. Has no meaning yet. Never used by a component.
2 --primary: var(--njt-teal-800) Semantic — binds the value to a role. This is the layer that changes between themes.
3 className="bg-primary" Component — consumes the role. Knows nothing about teal.
LayerPrefixExampleMay a product change it?
Primitive--njt-* --njt-teal-800No — the ramp is the brand.
Semanticshadcn names --primary, --mutedYes — this is the theming surface.
Semantic, governed--alarm-* --alarm-criticalNo — controlled characteristic.
Documentation--doc-* --doc-coral-textN/A — never ships in a product.

The shadcn contract

The semantic layer uses shadcn/ui's variable names verbatim. This is deliberate and it is the main reason to build on shadcn at all: any block, example or third-party component from the wider ecosystem drops into this system and is instantly on-brand, because it is already reaching for --primary and --muted.

/* app/globals.css */
:root {
  --background: #FDFCF8;   --foreground: #16231F;
  --card: #FFFFFF;         --card-foreground: #16231F;
  --popover: #FFFFFF;      --popover-foreground: #16231F;
  --primary: #0F3B35;      --primary-foreground: #F6F1E7;
  --secondary: #EFEDE4;    --secondary-foreground: #16504A;
  --muted: #EFEDE4;        --muted-foreground: #54635E;
  --accent: #E4EEE9;       --accent-foreground: #0F3B35;
  --destructive: #CE0E2D;  --destructive-foreground: #FFFFFF;
  --border: #DEE4DF;       --input: #DEE4DF;
  --ring: #2A8B7C;         --radius: 0.625rem;
}

.dark {
  --background: #0B2A26;   --foreground: #F6F1E7;
  --card: #0F3B35;         --primary: #3BC4AF;
  --muted: #16504A;        --muted-foreground: #A8BDB6;
  --border: #1E4A43;       --ring: #3BC4AF;
  --destructive: #D93650;
}

The alarm extension

Stock shadcn has no concept of alarm priority. It has --destructive, which means "this control destroys data" — a completely different job from "this patient may be having a STEMI." Conflating them is the single most likely way for a team new to medical software to build something unsafe.

/* Medical extension — IEC 60601-1-8 alarm priorities.
   GOVERNED: not re-themable by product, customer skin, or user preference.
   Identical in light and dark. Changing a value here requires re-validation. */
:root, .dark {
  --alarm-critical: #CE0E2D; --alarm-critical-foreground: #FFFFFF;
  --alarm-urgent:   #F2C200; --alarm-urgent-foreground:   #000000;
  --alarm-advisory: #4FB3D9; --alarm-advisory-foreground: #000000;
  --alarm-info:     #B3BEB9; --alarm-info-foreground:     #000000;
  --alarm-ok:       #1D6E62; --alarm-ok-foreground:       #FFFFFF;
}

Wiring into Tailwind

// tailwind.config.ts
theme: {
  extend: {
    colors: {
      // …stock shadcn keys…
      alarm: {
        critical: "var(--alarm-critical)",
        "critical-foreground": "var(--alarm-critical-foreground)",
        urgent:   "var(--alarm-urgent)",
        "urgent-foreground":   "var(--alarm-urgent-foreground)",
        advisory: "var(--alarm-advisory)",
        "advisory-foreground": "var(--alarm-advisory-foreground)",
        info:     "var(--alarm-info)",
        "info-foreground":     "var(--alarm-info-foreground)",
        ok:       "var(--alarm-ok)",
        "ok-foreground":       "var(--alarm-ok-foreground)",
      },
    },
  },
}

Governance

Tokens split into two governance classes. The distinction is the whole point of this page.

ClassTokensChange process
Open All shadcn semantics — --primary, --muted, --radius, spacing, motion Normal design review. Theme overrides permitted.
Governed --alarm-*, focus-ring visibility, minimum touch target, minimum body size Design change with safety consequence. Requires risk assessment, usability re-validation and a documented rationale.

Enforcement

A rule that lives only in documentation is not a control. The governed set is enforced by a dependency-free gate that runs on every pull request and fails the build on violation.

CheckAssertsCatches
parity Governed alarm tokens resolve identically in light and dark A theme quietly changing what a signal means
contrast Every declared token pair meets its documented minimum A "small tweak" pushing text below AA
sync The shipped stylesheet matches tokens/tokens.json exactly Drift between the source of truth and what actually renders
redefinition No governed token is re-declared outside its blessed block, and no governed value appears as a literal in product source A customer skin overriding an alarm hue; a hard-coded hex that survives a validated palette change
separation Brand hues stay perceptually distant (ΔE) from alarm hues A brand colour drifting close enough to alarm red to be confusable

Running it

$ npm run check:tokens

  Token governance gate — NotJustAnyMed.Tech Design System
  ──────────────────────────────────────────────────────────────
  PASS  parity        10 governed alarm tokens identical across both themes
  PASS  contrast      34 colour pairs meet their documented minimum
  PASS  sync          assets/njt-ds.css matches tokens.json
  PASS  redefinition  8 files scanned, no governed token redefined or hard-coded
  ──────────────────────────────────────────────────────────────
  note  ΔE(alarm-critical #CE0E2D, #D8593A) = 22.6
  note    ↳ below the 25 separation threshold — this is why coral is
          banned from product UI.
  ──────────────────────────────────────────────────────────────
  All governance contracts hold.

What a violation looks like

  FAIL  parity        --alarm-critical differs between themes
                      light #CE0E2D vs dark #E24359. IEC 60601-1-8 hues are
                      absolute; a theme may not change what a signal means.

  FAIL  redefinition  --alarm-urgent is declared outside the blessed blocks
                      assets/njt-ds.css:405 → #FF9900. Governed tokens have
                      exactly one definition point; a skin may not override them.

  FAIL  redefinition  Literal #CE0E2D appears in src/Widget.tsx
                      That is the value of --alarm-critical. Use the token:
                      a literal is invisible to this gate.

$ echo $?
1
The gate found real defects

On first run against this system it failed. --input had inherited shadcn's default of matching --border, giving a control boundary of 1.10:1 against the worst surface it sits on — below the 3:1 that WCAG 2.2 SC 1.4.11 requires for identifying a component. The token is now #76827C at 3.41:1 against the worst surface it can sit on, and is governed, because a clinician who cannot locate the edge of a field is a use-error risk, not a visual preference.

This is what a control is for. A design system that only documents its rules discovers this at audit; one that enforces them discovers it on the first commit.

Files

PathRole
tokens/tokens.json Single source of truth. Every token, both themes, governance class, and a rationale string on each governed entry.
scripts/colour.mjs WCAG relative luminance and contrast, CIE76 ΔE. No dependencies.
scripts/check-tokens.mjs The token gate. Exits non-zero on violation. --json for machine output.
scripts/check-docs.mjs The documentation gate — the same discipline applied to the pages that explain the tokens. Ten checks: cross-references, class and var() resolution, required sections, table scopes, unqualified validation claims, page structure, sitemap reachability, build-status counts and the reference-application register.
.github/workflows/design-system.yml Runs both on every PR as independent jobs, and retains the JSON reports for 90 days as design-control evidence.
Why the rationale field matters

Every governed token carries a written rationale in tokens.json. When a reviewer asks why high-priority alarms are #CE0E2D, the answer is in the same file as the value, in version control, with the commit that introduced it — rather than in a designer's memory.

Non-colour scales

Spacing — 4 px base

Every gap is a multiple of four. Tailwind's default scale already is, so no override is needed; the constraint is on usage, not configuration.

TokenValueTypical use
--njt-s-14pxIcon to label
--njt-s-28pxWithin a control
--njt-s-312pxBetween related controls
--njt-s-416pxCard padding, default gap
--njt-s-624pxBetween groups
--njt-s-832pxBetween sections
--njt-s-1248pxBetween major regions

Radius

One base token, two derived. Matches the brand's 6 / 10 / 16 geometry.

TokenValueUse
--radius-sm6pxButtons, inputs, chips
--radius-md8pxAlerts, popovers
--radius / -lg10pxCards, dialogs, panels

Motion

TokenValueUse
--njt-dur-180msHover, press feedback
--njt-dur-2140msColour and state change
--njt-dur-3220msEntry and exit
--njt-dur-4320msLarger surfaces, panels
--njt-ease-standardcubic-bezier(.2,0,0,1) Begins and ends at rest
--njt-ease-entercubic-bezier(0,0,0,1) Incoming elements
--njt-ease-exitcubic-bezier(.3,0,1,1) Exiting elements

Do's and don'ts

Do
<div className="bg-alarm-critical
     text-alarm-critical-foreground">

Consume the semantic role. The component stays correct when the theme changes and cannot drift from the standard.

Don't
<div className="bg-[#CE0E2D]
     text-white">

A hard-coded hex is invisible to the contrast test and the lint rule. It will survive a palette change that everything else follows.

Do
--primary: #0F3B35;   /* open  */
--alarm-urgent: #F2C200;  /* governed */

Theme the open set freely. Leave the governed set alone.

Don't
.customer-acme {
  --alarm-urgent: #FF9900; /* on-brand! */
}

A customer skin overriding an alarm hue. Now "urgent" means something different on this deployment than every other device in the room.

More do's and don'ts

Do
"alarm-urgent": {
  "class": "governed",
  "light": "#F2C200",
  "dark":  "#F2C200",
  "rationale": "IEC 60601-1-8 medium priority."
}

A governed value carries its rationale in the same file, in version control, with the commit that set it.

Don't
/* hotfix: customer said the
   yellow looked harsh */
--alarm-urgent: #FFE066;

A safety-relevant value changed in a hotfix, with the reason in a comment that will not survive the next refactor.

Do
.alert { background: var(--alarm-urgent); }

Components consume the semantic layer, so they stay correct when a theme changes and cannot drift from the standard.

Don't
.alert { background: var(--njt-warm-400); }

A component reaching past its layer into a primitive. It now has a colour with no role and no governance.

Clinical safety notes

Risk controls carried by this foundation

Trace these in your risk file (ISO 14971).

NotJustAnyMed.Tech Design System · Tokens · v1.0 · draft for review