Tokens and Theming
| Field | Value |
|---|---|
| Type | Skill Resource |
| Source | ~/.copilot/skills/design/references/tokens-and-theming.md |
| Description | Not specified |
Source Content
Tokens and Theming
Contents:
- Three token layers: primitive, semantic, component
- Theming: flip the values, never the classes
- Multiple themes are the default assumption
- Store color channels so alpha is free
- Map tokens to Tailwind once
- Color discipline: literals live in one place
- Always check contrast
- Generating a token set from a brand color
- Component architecture: naming, hierarchy, variants
- Responsive and fluid calculations
- Developer and design-tool handoff
Three token layers: primitive, semantic, component
A token is a CSS custom property. Tokens are the only place a raw value (hex, rgb, hsl, px) is allowed to live. Everything else reads a token with var(--token). There are three layers, and mixing their jobs is the most common token mistake.
Primitive tokens
The raw palette — named by what they are, not where they are used. No opinion about light, dark, foreground, or background.
/* tokens.css — PRIMITIVE LAYER Raw palette. Named by value, not by role. Components never touch these. */:root { --blue-600: 220 90% 56%; /* channels only — see "Store color channels" below */ --gray-900: 0 0% 10%; --gray-50: 0 0% 98%; --white: 0 0% 100%;}Semantic tokens
Named by role — what the value means in the UI. These are the only tokens a component is allowed to consume.
/* tokens.css — SEMANTIC LAYER Named by role. These are the public token API. Components read ONLY these. */:root { --color-bg: var(--white); /* page surface */ --color-fg: var(--gray-900); /* primary text on --color-bg */ --color-primary: var(--blue-600); /* main action color */ --color-border: var(--gray-50); /* hairline dividers */}Rule: components consume semantic tokens only. Reaching past the semantic layer to a primitive (color: var(--blue-600)) breaks theming — the primitive does not flip. If a component needs a color the semantic layer does not expose, add a semantic token; do not borrow a primitive.
Component tokens
A third, narrower layer maps semantic tokens onto specific component parts — sizes, variants, states — so a component’s whole visual API lives in one named place instead of being re-derived at every call site.
// Variant tokens: each named variant maps every state to semantic values.const variantTokens = { primary: { background: 'var(--color-primary)', backgroundHover: 'var(--color-primary-600)', text: 'var(--color-primary-foreground)', border: 'transparent', }, outline: { background: 'transparent', backgroundHover: 'var(--color-primary-50)', text: 'var(--color-primary)', border: 'var(--color-primary)', }, ghost: { background: 'transparent', backgroundHover: 'var(--color-muted)', text: 'var(--color-fg-muted)', border: 'transparent', },};
// Size tokens: each size maps height/padding/font/icon to the scale.const sizeTokens = { sm: { height: '32px', paddingX: 'var(--space-3)', fontSize: 'var(--ui-type-body-sm-size)', iconSize: '16px' }, md: { height: '40px', paddingX: 'var(--space-4)', fontSize: 'var(--ui-type-body-size)', iconSize: '20px' }, lg: { height: '48px', paddingX: 'var(--space-6)', fontSize: 'var(--ui-type-title-sm-size)', iconSize: '24px' },};Component tokens still bottom out in semantic tokens — never a raw hex or literal spacing value. Naming convention: {category}-{property}-{variant}-{state} (e.g. color-primary-500-hover, spacing-md, radius-lg).
Theming: flip the values, never the classes
A theme is a block that re-points the same semantic token names at different primitives. The default (light) theme lives on :root. Each alternate theme overrides those same names under a [data-theme="…"] selector on the same element or an ancestor.
/* themes.css — THEME LAYER Same semantic names, different values. Components never change between themes. */
/* Light / default */:root { --color-bg: var(--white); --color-fg: var(--gray-900); --color-primary: var(--blue-600);}
/* Dark — identical token NAMES, re-pointed values */[data-theme="dark"] { --color-bg: var(--gray-900); --color-fg: var(--gray-50); --color-primary: var(--blue-600); /* primary may stay; bg/fg flip */}The component stays exactly the same in both themes — it only ever wrote var(--color-bg):
/* card/card.css — one rule, every theme. Nothing here knows light from dark. */.card { background: var(--color-bg); color: var(--color-fg); border: 1px solid var(--color-border);}Two rules that this pattern exists to enforce:
- Never hardcode a color. A literal in a component cannot flip with the theme, so it is wrong in at least one theme.
- Never conditionally swap classes per theme. No
class={isDark ? "card--dark" : "card--light"}, nodark:bg-…duplicate of every utility. Let the tokens flip; the markup is theme-blind.
The dmwd-io theme surface
Real theme tokens shipped in src/index.css, hsl(var(--token)):
| CSS var | HSL (light) | HSL ([data-theme="night"]) | Tailwind class | Use |
|---|---|---|---|---|
--background | 0 0% 100% | 220 14% 9% | bg-background | Page background |
--foreground | 48 3% 26% | 210 20% 94% | text-foreground | Primary text |
--card | 0 0% 100% | 220 12% 12% | bg-card | Card surface |
--primary | 168 79% 25% | 168 65% 50% | bg-primary / text-primary | Actions, CTAs, selected state |
--primary-foreground | 0 0% 100% | 166 23% 11% | text-primary-foreground | Text on primary |
--secondary | 45 3% 97% | - | bg-secondary | Subtle alternate surfaces |
--muted | 45 3% 93% | 220 9% 18% | bg-muted | Empty states, skeleton, quiet zones |
--muted-foreground | 45 4% 36% | 215 12% 70% | text-muted-foreground | Supporting text, icons |
--accent | 45 3% 95% | - | bg-accent | Hover state, highlight surface |
--destructive | 5 74% 48% | 8 80% 63% | bg-destructive / text-destructive | Errors, delete actions |
--border | 45 10% 55% | 220 8% 30% | border-border | Default neutral border |
--ring | 168 79% 25% | - | ring-ring | Focus ring |
Never use text-muted-foreground for primary interactive labels — only for supporting/secondary text.
Brand tokens
| CSS var | Tailwind class | Light HSL | Dark HSL | Use |
|---|---|---|---|---|
--brand-teal | text-brand-teal / bg-brand-teal | 168 79% 25% | 168 65% 50% | Primary brand color |
--brand-navy | text-brand-navy / bg-brand-navy | 216 44% 20% | 214 46% 78% | Deep accent complement |
--brand-amber | text-brand-amber / bg-brand-amber | 38 92% 58% | 41 92% 64% | Highlight / warning |
--brand-mist | text-brand-mist / bg-brand-mist | 44 35% 96% | 220 9% 18% | Soft background tint |
Status tokens
| CSS var | Use |
|---|---|
--info / --info-foreground | Informational messages (light: 209 66% 37%) |
--success / --success-foreground | Confirmation, completed state (aliases --primary) |
--warning / --warning-foreground | Caution (aliases --brand-amber) |
--destructive / --destructive-foreground | Error, danger |
Surface tokens and accent overrides
--surface-accent (aliases --primary), --surface-strong (deepened primary, light: 168 72% 18%), --surface-strong-foreground. When data-surface-style="palette" is on an ancestor, background/card/muted/border shift to be tinted by --accent-surface-hue.
[data-accent="name"] on any ancestor overrides --primary, --ring, --info, --success, --brand-teal, --brand-navy. 30+ named accents ship built-in across greens, blues, purples, reds/pinks, warm, and neutrals — each with day + night variants that switch automatically with [data-theme="night"].
Multiple themes are the default assumption
Design as if there will be many themes, not two. Light and dark are just the first two entries. A high-contrast theme, a brand sub-theme, or a per-tenant theme is a new selector block of the same semantic names — no component changes, no new classes.
/* themes.css — add a theme = add a block. That is the entire cost. */[data-theme="high-contrast"] { --color-bg: var(--white); --color-fg: 0 0% 0%; /* pure black text */ --color-primary: 220 100% 40%; --color-border: 0 0% 0%;}Because every theme defines the same names, a missing token in one theme is a real bug, not a fallback. Run ../scripts/check_theme_completeness.py to verify every token exists in every theme.
Store color channels so alpha is free
Store the color channels (HSL components, or the parts a modern color()/rgb() takes) rather than a finished color string. Then any consumer can apply alpha with one syntax — no second token per opacity level.
/* PRIMITIVE: bare channels, no hsl() wrapper */:root { --blue-600: 220 90% 56%; }
/* SEMANTIC + CONSUMER: wrap at use, vary alpha freely */.button { background: hsl(var(--color-primary)); }.button:hover { background: hsl(var(--color-primary) / 0.9); } /* 90% — no new token */.button__ghost { background: hsl(var(--color-primary) / 0.1); } /* 10% tint — no new token */This is also what lets a utility framework apply opacity modifiers (e.g. bg-primary/10) without you defining a token for every step.
Map tokens to Tailwind once
Tailwind utilities and hand-written CSS must read from one source of truth. Map the semantic tokens into the Tailwind theme so bg-primary and var(--color-primary) resolve to the same value; config shape lives in ./css/tailwind-config.md. The takeaway here: a token is defined once in CSS and referenced by the config, never copied into it.
Tailwind class map (all overridden values)
All four rounded-* radius classes are overridden to read var(--radius-*) — see radius.md. Font families: font-sans → system stack, font-body / font-serif also resolve to the system stack (ADR-003, no web fonts — see typography.md). Box shadow: shadow-soft → 0 14px 24px -18px rgba(68, 68, 65, 0.22) for cards/panels needing depth without harsh shadows (avoid in @media print). Max width: max-w-measure → 68ch for optimal reading line length on prose blocks. Plugins active: @tailwindcss/forms (form element base resets), tailwindcss-animate (animate-in/animate-out/fade-*/zoom-*/slide-*).
Color discipline: literals live in one place
A raw color literal (#3b82f6, rgb(...), hsl(...), 220 90% 56%) is allowed only in the token-definition files — the primitive and semantic/theme blocks above. Everywhere else, every color is var(--token).
| Location | Raw literal allowed? | What to write |
|---|---|---|
tokens.css primitive block | Yes | --blue-600: 220 90% 56%; |
themes.css theme blocks | Yes | re-point semantic names |
| Component CSS | No | hsl(var(--color-primary)) |
| Utility classes in markup | No | bg-primary, mapped to the token |
Stylelint flags stray color literals found outside the token-definition layer (see ./css/linting.md), so this rule is enforced, not just hoped for.
Always check contrast
Run the contrast checker on your token file whenever you add or change a color. It auto-pairs each --x-foreground against its matching --x and checks the pair against WCAG: 4.5:1 for normal text, 3:1 for large text and UI components (AA), and reports AAA where it is met (7:1 / 4.5:1).
python3 ~/.copilot/skills/design/scripts/check_contrast.py path/to/tokens.cssFor the pairing to work, name foregrounds as --<surface>-foreground next to their surface — e.g. --color-primary / --color-primary-foreground, --color-bg / --color-fg is paired by convention too. Run it against every theme block, not just the default: a pair that passes in light can fail in dark. Details on the checker live in ../scripts/check_contrast.py.
Design-system contrast status (default light theme)
| Combination | Approx ratio | Status |
|---|---|---|
text-foreground on bg-background | ~14:1 | AAA |
text-foreground on bg-card | ~14:1 | AAA |
text-muted-foreground on bg-card | ~5.5:1 | AA |
text-primary on bg-background | ~4.8:1 | AA |
text-primary-foreground on bg-primary | ~7:1 | AAA |
text-destructive-foreground on bg-destructive | >4.5:1 | AA |
Generating a token set from a brand color
Generate a full three-layer system from one brand color with ../scripts/design_token_generator.py:
python3 ~/.copilot/skills/design/scripts/design_token_generator.py "#0066CC" modern jsonpython3 ~/.copilot/skills/design/scripts/design_token_generator.py "#8B4513" classic csspython3 ~/.copilot/skills/design/scripts/design_token_generator.py "#FF6B6B" playful summaryArguments: brand_color (hex, default #0066CC), style (modern | classic | playful, default modern), format (json | css | scss | summary, default json).
Color scale generation (HSV)
The generator converts hex → RGB → HSV, then for each step (50, 100, 200 … 900) adjusts value (brightness) and saturation while holding hue constant:
Light shades (step < 500): value fixed at 95% brightness.Dark shades (step >= 500): value = base_value * (1 - (step - 500) / 500).Saturation: base_saturation * (0.3 + 0.7 * (step / 900)) — rises with step.Complementary/secondary color: hue + 180°.| Step | Use case | Brightness | Saturation |
|---|---|---|---|
| 50 | Subtle backgrounds | 95% (fixed) | 30% |
| 300 | Borders | 95% (fixed) | 54% |
| 500 | Base color | Original | 70% |
| 700 | Active states | Original × 0.6 | 86% |
| 900 | Headings | Original × 0.2 | 100% |
Typography scale (modular, 1.25× major third default)
Base: 16pxSmaller: 16px ÷ 1.25^n Larger: 16px × 1.25^nxs 10px · sm 13px · base 16px · lg 20px · xl 25px · 2xl 31px · 3xl 39px · 4xl 49px · 5xl 61px| Ratio | Name | Character |
|---|---|---|
| 1.125 | Major Second | Subtle — app interfaces |
| 1.200 | Minor Third | Moderate — general use |
| 1.250 | Major Third | Balanced — default |
| 1.333 | Perfect Fourth | Pronounced — marketing |
| 1.618 | Golden Ratio | Dramatic — headlines |
Spacing grid (8pt)
Base unit 8px; multipliers 0, 0.5, 1, 1.5, 2, 2.5, 3, 4, 5, 6, 7, 8… produce 0, 4, 8, 12, 16, 20, 24… px. Semantic aliases: xs=4px, sm=8px, md=16px, lg=24px, xl=32px, 2xl=48px, 3xl=64px. Why 8pt: divides evenly into common screen widths, gives predictable vertical rhythm, and touch targets naturally land on 48px (8×6).
Style differences
| Aspect | Modern | Classic | Playful |
|---|---|---|---|
| Border Radius | 8px default | 4px default | 16px default |
| Shadows | Layered, subtle | Single layer | Soft, pronounced |
Note: the generator’s own font-family defaults (Inter, Helvetica, Poppins, etc.) are historical scaffolding from the tool’s original design — the dmwd-io stack overrides these with the system font stack only (ADR-003; see typography.md). Treat the generator’s typography/font output as a structural starting point, not a license to load a web font.
Component architecture: naming, hierarchy, variants
Atomic hierarchy
Tokens (foundation) → atoms (Button, Input, Icon, Badge) → molecules (FormField, Card, ListItem) → organisms (Header, DataTable, Modal) → templates (DashboardLayout) → pages (HomePage).
| Category | Description | Examples |
|---|---|---|
| Primitives | Base HTML wrapper | Box, Text, Flex, Grid |
| Inputs | User interaction | Button, Input, Select, Checkbox |
| Display | Content presentation | Card, Badge, Avatar, Icon |
| Feedback | User feedback | Alert, Toast, Progress, Skeleton |
| Navigation | Route management | Link, Menu, Tabs, Breadcrumb |
| Overlay | Layer above content | Modal, Drawer, Popover, Tooltip |
| Layout | Structure | Stack, Container, Divider |
Naming conventions
Token: {category}-{property}-{variant}-{state} e.g. color-primary-500-hover, spacing-mdComponent: {ComponentName} / {componentName}{Variant} e.g. Button, ButtonPrimary, ButtonOutlineCSS (BEM): .block__element--modifier e.g. .button__icon--loadingFile structure
components/├── Button/│ ├── Button.tsx # Main component│ ├── Button.css # Co-located styles (see css/architecture.md)│ ├── Button.test.tsx # Tests│ ├── Button.stories.tsx # Storybook│ ├── Button.types.ts # TypeScript types│ └── index.ts # ExportProps interface pattern
interface ButtonProps { /** Visual variant of the button */ variant?: 'primary' | 'secondary' | 'ghost' | 'danger'; /** Size of the button */ size?: 'sm' | 'md' | 'lg'; /** Whether button is disabled */ disabled?: boolean; /** Whether button shows loading state */ loading?: boolean; /** Left icon element */ leftIcon?: React.ReactNode; /** Click handler */ onClick?: () => void; /** Button content */ children: React.ReactNode;}Component checklist before release
- All sizes implemented (sm, md, lg); all variants implemented (primary, secondary, etc.)
- All states working (hover, active, focus, disabled, loading)
- Uses only design tokens (no hardcoded values); TypeScript types complete
- Storybook stories for all variants; unit tests passing
- Correct semantic HTML element; ARIA attributes where needed
- Visible focus indicator; color contrast meets AA; keyboard-only operable
- Touch target ≥ 44×44px
Responsive and fluid calculations
Breakpoints
| Name | Min width | Target devices |
|---|---|---|
| xs | 0 | Small phones |
| sm | 480px | Large phones |
| md | 640px | Small tablets |
| lg | 768px | Tablets, small laptops |
| xl | 1024px | Laptops, desktops |
| 2xl | 1280px | Large desktops |
| 3xl | 1536px | Extra large displays |
/* Mobile-first: base styles are mobile, media queries layer up */.component { padding: var(--space-2); }@media (min-width: 768px) { .component { padding: var(--space-6); } }Fluid typography (clamp)
font-size: clamp(min, preferred, max);/* 16px to 24px between 320px and 1200px viewport */font-size: clamp(1rem, 0.5rem + 2vw, 1.5rem);| Style | Mobile (320px) | Desktop (1200px) | Clamp |
|---|---|---|---|
| h1 | 32px | 64px | clamp(2rem, 1rem + 3.6vw, 4rem) |
| h2 | 28px | 48px | clamp(1.75rem, 1rem + 2.3vw, 3rem) |
| body | 16px | 18px | clamp(1rem, 0.95rem + 0.2vw, 1.125rem) |
Fluid spacing
--space-section: clamp(3rem, 2rem + 4vw, 7.5rem);--space-component: clamp(1rem, 0.5rem + 1vw, 2rem);Container queries
.card-container { container-type: inline-size; container-name: card; }@container card (min-width: 400px) { .card { display: flex; flex-direction: row; }}Grid systems
12-column grid (grid-template-columns: repeat(12, 1fr), .col-{n} { grid-column: span n; }) and auto-fit card grids (grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));) cover most layouts without a bespoke framework.
Developer and design-tool handoff
Export formats
Three formats cover practically every consumer:
- JSON — Figma plugins, Storybook, JS/TS projects, design-tool APIs.
- CSS custom properties — plain CSS, CSS-in-JS, any web project; the format this stack uses by default.
- SCSS variables — SASS pipelines, component libraries, theming maps.
:root { --color-primary-500: #0066CC; --font-size-base: 16px; --spacing-4: 16px;}Tailwind wiring
// tailwind.config.js — reference the token file, never copy values into itconst tokens = require('./design-tokens.json');module.exports = { theme: { extend: { colors: tokens.colors, fontFamily: { sans: [tokens.typography.fontFamily.sans] }, spacing: tokens.spacing, borderRadius: tokens.borders.radius, }, },};Figma sync
Tokens Studio plugin imports design-tokens.json directly; Figma Variables (native) can also import via plugin or API. Sync path: design_token_generator.py → design-tokens.json → Tokens Studio → Figma Styles & Variables.
Handoff checklist
- Brand color + style selected; tokens generated and all formats exported (JSON, CSS, SCSS)
- Token files added to project; build pipeline configured; hot reload working for token changes
- Figma/design tool updated; component library aligned; Storybook stories created
- Colors render correctly; typography scales properly; spacing matches design; dark mode tokens present (if applicable)
Breaking-change policy
| Change type | Version bump | Migration |
|---|---|---|
| Add new token | Patch (1.0.x) | None |
| Change token value | Minor (1.x.0) | Optional |
| Rename/remove token | Major (x.0.0) | Required |