Design Tokens
How colours are defined — the ramps, the semantic roles built on them, the Tailwind utilities that expose those roles — and how to change one.
- The rule and file format: ramps as data
- Semantic roles: the neutral text scale, surfaces, borders, the status matrix, brand
- Tailwind utilities for the roles:
text-tertiaryisvar(--text-tertiary) - Bootstrap colour utilities: retired, and why
- The brand colour in SCSS
- Guards: tests and lint rules that keep all of the above true
The rule
Colour ramps are edited in one file:
packages/design-tokens/tokens/colors.json.
Never edit the file generated from it:
| Generated file | Consumer |
|---|---|
packages/design-tokens/src/css/primitives/colorRamps.css |
Tailwind and CSS variables (--color-gray-500, bg-success-600), declared once in @theme static |
1 2 3 4 | |
generateTokens.test.ts fails when a generated file no longer matches the JSON, whether the
JSON or the output was edited. The fix is always yarn tokens:generate.
tokenAudit.test.ts fails when an unexempted token has zero call sites in consumer code,
when consumer code references undeclared ghost tokens, when duplicate semantic token
declarations exist across stylesheets, or when two semantic tokens resolve to the same value
in both themes and the pair is not in DUPLICATE_BASELINE (see Guards).
File format
1 2 3 4 5 6 | |
Optional ramp-level key: css: false (leave the ramp out of the CSS).
Colours are lower-case #rrggbb; the generator rejects anything else. A step has one value:
the key is light for history, and the generator rejects a dark one. Ramps are physical and
the same in both themes; dark UI picks other steps through the semantic tokens. (The Sass
ramps used to be theme-inverted for a separate dark compile, style.dark.scss, which is gone.)
Gray in dark mode: gray and gray-dark
Dark UI wants neutral grays where the light gray is blue-tinted (500 is #667085 vs
#85888e), so there are two CSS ramps, both physical (step 900 is dark in both):
--color-gray-N(gray) is the same in both themes: nothing overrides the variable per theme, andtext-gray-500is--color-gray-500in dark mode too (Bootstrap no longer emits its theme-inverted.text-gray-N; see Bootstrap colour utilities). So a bare gray is dark-on-dark in dark mode: for neutral text, surfaces and borders use the semantic utilities, or pair the step with itsgray-darkmirror (bg-gray-300 dark:bg-gray-dark-600).--color-gray-dark-N(gray-dark) is the palette for dark surfaces. It is read by the dark-theme blocks ofbackground.cssandbutton.css, and by thedarksidebar style insidebar.css(a fixed look that is dark in either theme, so it readsgray-darkunconditionally, while thelightstyle readsgray). A component that needs a dark-specific gray writesdark:bg-[var(--color-gray-dark-800)]ordark:bg-gray-dark-800.
Sass never reads gray-dark. Its values are what the old theme-inverted SCSS grays were
in dark mode, re-indexed: gray-dark-N was the dark value of the mirrored $gray-N (25 ↔ 950,
50 ↔ 900, …). The mirrored-ramp-color() mixin keeps that pairing for one-off SCSS colours.
Semantic roles
The semantic layer (packages/design-tokens/src/css/semantic/) names what a colour is for.
Each role is defined for both themes in one file; prefer a role to a ramp step whenever one
fits. Values below are light / dark; gray-dark-N is written dN.
Neutral content: --text-* (text.css)
One ladder for text and icons, strongest first. Every step is a distinct gray in both themes.
| Token | Light | Dark | Use |
|---|---|---|---|
--text-primary |
gray-900 | d25 | Headings, primary content |
--text-secondary |
gray-700 | d200 | Body copy, strong labels |
--text-tertiary |
gray-600 | d300 | Supporting text, menu items |
--text-quaternary |
gray-500 | d400 | Muted text, subtitles, placeholders |
--text-quinary |
gray-400 | d500 | Faint text, decorative icons |
--text-senary |
gray-300 | d600 | Disabled marks, inactive indicators |
--text-disabled |
gray-300 | d500 | Disabled input and date-picker text |
The names follow the Untitled UI ordinal convention, and they match the Tailwind utilities
(text-secondary … text-senary). There is no separate --fg-* neutral family any more:
--fg-primary … --fg-senary, --text-body, --text-subdued and --text-muted were merged
into this scale. The renames, for reading old code or branches:
| Old | New |
|---|---|
--fg-primary |
--text-primary |
--text-body, --fg-secondary |
--text-secondary |
--text-subdued, --fg-tertiary |
--text-tertiary |
--text-secondary, --fg-quaternary |
--text-quaternary |
--text-muted, --fg-quinary |
--text-quinary |
--fg-senary |
--text-senary |
Note the trap in the middle row: the old --text-secondary was gray-500 and is now
--text-quaternary; the new --text-secondary is gray-700.
text.css also holds --fg-white (white text or icon in both themes). Every brand role is in
brand.css: the brand marks (--fg-brand-primary, --fg-brand-secondary,
--fg-brand-accent), brand text (--text-brand-primary / --text-brand-secondary, and
--text-primary-on-brand on a brand fill), brand surfaces and borders, and the focus ring.
Surfaces: --bg-* (background.css)
| Token | Light | Dark | Use |
|---|---|---|---|
--bg-primary |
white | d950 | The base surface |
--bg-primary-hover |
gray-50 | d800 | Hover/highlight of a row on that surface |
--bg-secondary |
gray-50 | d900 | Faint panel, alternate section |
--bg-tertiary |
gray-100 | d800 | Avatar tiles, chips, pressed states |
--bg-quaternary |
gray-200 | d700 | Stronger fill (selected day, track) |
Plus --page-bg and --card-bg, and the brand fills --bg-brand-*.
Borders and lines: --border-* (borders.css)
| Token | Light | Dark | Use |
|---|---|---|---|
--border-primary |
gray-300 | d700 | Input and control borders |
--border-secondary |
gray-200 | d800 | Card and section borders (the default) |
--border-tertiary |
gray-100 | d800 | Hairlines |
--border-divider |
gray-200 | d700 | A drawn line: rules, step connectors |
--border-divider-medium |
gray-300 | d600 | A line that must show on any surface: timeline and stepper connectors, dashed outlines (border-divider-medium) |
--border-divider-strong |
gray-400 | d500 | A strong line: drag handles |
--border-brand |
brand-300 | brand-400 | Subtle brand border |
--border-brand-solid |
brand-600 | brand-500 | Selected/active brand border |
State borders (success, warning, danger) are in the status matrix, not here.
State colours: the status matrix (status.css)
Every colour that signals a state — badges, banners, alerts, health indicators, validation errors, destructive menu items, status dots, focus rings on an invalid control — reads one matrix:
| Slot | success / warning | danger | Use |
|---|---|---|---|
-bg |
50 / 900 | 50 / 900 | Tinted surface (pill, banner, row) |
-bg-strong |
100 / 900 | 100 / 900 | Tint on one small shape (diagram node) |
-border |
300 / 400 | 300 / 400 | Subtle border, neutral or tinted surface |
-solid |
600 / 500 | 600 / 500 | Saturated fill or ring |
-fg |
700 / 400 | 700 / 300 | Text, icons and dots |
as --status-{success,warning,danger}-{bg,bg-strong,border,solid,fg}, plus -bg, -bg-strong
and -fg only for info (50 / 900, 200 / 900 and 600 / 300) and neutral. -bg-strong has no
utility: it is for Mermaid node fills (style A fill:var(--status-success-bg-strong)), where the
50-step -bg nearly vanishes against plain nodes. Steps refer to the intent's ramp (danger reads error-*).
Each slot has a Tailwind utility (tailwind/theme.css), named like Bootstrap 5.3's:
| Slot | Utility |
|---|---|
-fg |
text-danger, text-success, text-warning, text-info, text-neutral |
-solid |
bg-danger, border-danger, … |
-bg |
bg-danger-subtle, …, bg-info-subtle, bg-neutral-subtle |
-border |
border-danger-subtle, … |
Use these, not the arbitrary form: no-raw-status-ramp autofixes text-[var(--status-danger-fg)]
to text-danger. A slot outside that table (-solid as text, say) keeps the arbitrary form.
- One
-fgfor every surface. Each-fgclears WCAG AA (4.5:1) on the card, on the page and on its own-bg, in both themes, so the same slot serves text on a neutral surface and text on a tinted pill.statusContrast.test.tsreads the CSS and enforces it; this is why danger's dark-fgis error-300 (error-400 on error-900 is 3.5:1). danger, noterror. Roles are named by intent, like the button,ActionItemand Badge variants;erroris only the red ramp's name.- No raw ramps for states.
bg-success-50 dark:bg-success-900isbg-success-subtle; theno-raw-status-ramplint rule reports the raw form. A deliberate step outside the matrix (Badge'soutlinetone,FeaturedIcon's tile tint, a halo ring) carries aneslint-disablewith its reason. - Retired names:
--text-{error,warning,success}-primary,--fg-{error,warning,success}-*,--border-{error,success},--border-*-solid,--bg-{error,warning}-primary,--status-*-textand--status-*-dotall became-fg,-border,-solidor-bgslots.
Badge's solid and light tones and Progress's fills read -solid / -bg / -fg
directly, so a badge, a bar and an alert of the same intent cannot drift apart.
Brand
--brand-* is the tenant's runtime ramp (initBrandTokens()); --brand-color is its
step 600. Tailwind sees the ramp through the --color-brand-* bridge in tailwind/theme.css
(bg-brand-50, text-brand-700): physical steps like gray-N, the same in both themes. A
brand-tinted surface is bg-brand-subtle (--bg-brand-subtle: brand-50, brand-900 in dark). Brand text and lines are text-brand / border-brand /
outline-brand, which read the role --text-brand: --brand-color in light, --brand-300 in
dark, where the tenant colour is too dark to read. A solid brand fill is
bg-[var(--bg-brand-solid)]. Semantic brand roles: --bg-brand-*, --border-brand*,
--fg-brand-*, --text-brand*, --focus-ring-color.
Tailwind utilities for the roles
tailwind/theme.css in this package holds every utility whose value is a variable defined
elsewhere: the semantic roles and the runtime brand ramp. A primitive declares its own utility,
since there the variable is the token (--color-gray-500, --radius-md); a bridge never lives
next to a value, and a value never changes per theme outside semantic/. It bridges the roles to
Tailwind with property-scoped theme keys
(--text-color-*, --background-color-*, --border-color-*), not --color-*. A --color-*
entry fans out to every colour utility — bg-, border-, ring-, fill-, … — and a text gray
is not a background. Scoped keys give exactly one utility per role, and one rule:
It ships in the package and index.css imports it, so waldur-homeport, Storybook and the
micro-apps all get the same utilities; waldur-ui and shell depend on them.
tailwindTheme.test.ts fails if they use a semantic utility the package does not declare.
A utility and the variable of the same name are the same colour.
| Utility | Is |
|---|---|
text-primary … text-senary |
var(--text-primary) … var(--text-senary) |
bg-primary, bg-primary-hover |
var(--bg-primary), var(--bg-primary-hover) |
bg-secondary, bg-tertiary, bg-quaternary |
var(--bg-secondary) … |
border-primary, border-secondary, border-tertiary |
var(--border-primary) … |
border-divider |
var(--border-divider) |
text-brand, border-brand, outline-brand |
var(--brand-color) |
- No exceptions.
text-primaryused to be the brand colour; its brand uses were renamed totext-brand, andtext-primaryis nowvar(--text-primary)like every other step. - Prefer the named utility to the arbitrary form:
text-quaternary, nottext-[var(--text-quaternary)]. Roles with no utility (--bg-brand-*,--text-brand-*) use the arbitrary form; the status matrix has its own (above). - There is no
bg-secondarytext colour,text-secondarybackground and so on: each name exists only for the property it belongs to. - Removed aliases:
bg-surface-faint(nowbg-secondary),bg-bg-primary(bg-primary),border-border-*(border-*), and the--color-surface-faint,--color-bg-primary,--color-border-*,--color-secondary,--color-tertiary,--color-quaternarykeys. A brand border that used to beborder-primaryis nowborder-brand. - Bootstrap's neutral classes were migrated to the utility with the identical value in both
themes:
text-muted→text-quaternary,text-gray-400/500/600/700→text-quinary/quaternary/tertiary/secondary,bg-body→bg-primary,bg-gray-100/200→bg-tertiary/bg-quaternary,border-gray-200→border-divider. The rest followed when Bootstrap's colour utilities were retired (next section).
Bootstrap colour utilities
Bootstrap's utility API emitted .text-*, .bg-* and .border-* colour classes with
!important, in the bootstrap cascade layer. An !important declaration in an earlier layer
beats every Tailwind utility, important or not: a class Tailwind also defined (text-gray-500,
text-white) silently painted Bootstrap's compiled colour, theme-inverted in dark mode, and no
dark:/hover: variant beside it applied.
Bootstrap emits no colour utility any more. src/styles/bootstrap/_utilities.scss
removes color, background-color, border-color and border-top/-end/-bottom/-start
from $utilities, and the border: 0 .border-0 from its border utility. (Its width
utilities stay: .border-0 … .border-5, .border-top-0, … set only border-width.) Every colour class is Tailwind's and
follows the design tokens. ($theme-colors is an empty map, so no Bootstrap component emits a contextual colour either.)
Bootstrap's old class names paint nothing now, except those Tailwind defines itself (the status utilities). They were migrated as:
| Bootstrap | Now |
|---|---|
text-danger / -success / -warning |
Tailwind's text-danger, … (the -fg slot; a step darker in light mode) |
bg- / border- danger / success / warning |
Tailwind's bg-danger, … (the -solid slot) |
text-info, bg-info |
text-info-600 dark:text-info-300, bg-info-600 |
text-dark, text-body |
text-primary |
text-gray, text-muted, text-reset |
text-tertiary, text-quaternary, text-inherit |
bg-light, bg-dark |
bg-tertiary dark:bg-quaternary, bg-[var(--bg-primary-solid)] |
border-top / -bottom / -start / -end |
border-t-[1px] border-t-secondary, … (l / r for start / end) |
text-gray-800/900, border-gray-300 |
text-primary, border-primary |
FormText (waldur-ui) is text-tertiary; give it className="text-quaternary" for a quieter line, or text-danger for an error.
- The
waldur-custom/no-bootstrap-color-utilitylint rule reports a retired name with its replacement, and a class built as`text-${variant}`(map each variant to a full class name). It skips strings that hold a name rather than a class list: a BaseButtonvariant="text-danger", an object key outsideclassNames()/cn(), a comparison, a TypeScript literal type. src/tailwindBootstrapColors.test.tscompiles the Metronic SCSS and checks it emits no!importantcolour class, and that Tailwind defines none of the retired names.- The same trap on borders: Bootstrap's
.borderis still emitted, and it isborder: 1px solid var(--bs-border-color) !important, so a Tailwind border colour next to it never paints. Useborder-[1px](border-t-[1px], …) for the width and give every state an explicit colour, e.g.rounded border-[1px] border-secondaryandborder-brandwhen selected. Theno-bootstrap-border-with-tailwind-colorlint rule reports the clash and autofixes it when the same string sets a base colour.
The brand colour in SCSS
Bootstrap compiles $primary (and $primary-N) from a static default green
(primary in colors.json, css: false). The tenant's brand is the runtime --brand-*
ramp, which Sass never sees. Anything that paints $primary therefore shows the default
green, whatever the tenant configured. So:
- In custom SCSS, paint brand with the semantic brand tokens —
var(--bg-brand-solid),var(--border-brand-solid),var(--fg-brand-primary),var(--text-brand-secondary)— or a runtime step,var(--brand-600). For a tint that inverts like$primary-50did, writevar(--brand-50)with adark-themeoverride tovar(--brand-900). For a focus glow, usecolor-mix(in srgb, var(--focus-ring-color) 25%, transparent). - Never pass a CSS variable into a Sass colour function (
tint-color,shade-color,mix,rgba($x, …)): they need a compile-time colour. That is why the static$component-active-bg(the default green, which Bootstrap tints) stays a literal. src/styles/bootstrap/_variables.scssalready points the Bootstrap outputs that would otherwise paint the static green at the runtime brand: the active list-group row and the range thumb.
Font size
One scale, pinned in physical pixels in primitives/typography.css (@theme static) to
prevent distortion under Waldur's forced 13px root font size (html, body { font-size: 13px !important; }):
| Token | Size | Line Height | Tailwind Class | Former Bootstrap Utility |
|---|---|---|---|---|
--text-xs |
12px | 18px | text-xs |
fs-7, fs-8 |
--text-sm |
14px | 20px | text-sm |
fs-6 |
--text-base |
16px | 24px | text-base |
fs-4, fs-5 |
--text-lg |
18px | 28px | text-lg |
fs-3 |
--text-xl |
20px | 28px | text-xl |
fs-2 |
--text-2xl |
24px | 32px | text-2xl |
fs-1, fs-1x |
--text-3xl |
30px | 36px | text-3xl |
— |
--text-4xl |
36px | 44px | text-4xl |
— |
- No Bootstrap
fs-*. Bootstrap'sfs-1throughfs-8classes are retired and replaced by standard Tailwindtext-*classes via thewaldur-custom/require-font-size-scaleESLint rule. - Why pinned in pixels? Default Tailwind uses relative
remunits (e.g.text-lgis 1.125rem). With an unpinned root, 1.125rem at 13px resolves to 14.625px instead of the intended 18px. Pinned declarations ensure consistent typography across both desktop (13px root) and mobile (12px root). - No arbitrary
text-[14px]. Named scale tokens (text-sm) must be used instead. - No inline
fontSizeon JSX elements: static font sizes must use standard utility classes. Dynamic scaling (such as proportional avatar placeholder text or country flag emojis) requires an expliciteslint-disablewith rationale.
Font weight
One scale, the five weights fonts.css loads (Inter 300–700), declared in
primitives/typography.css:
| Weight | Utility | SCSS | Use |
|---|---|---|---|
| 300 | font-light |
var(--font-weight-light) |
Display text only |
| 400 | font-normal |
var(--font-weight-normal) |
Body |
| 500 | font-medium |
var(--font-weight-medium) |
Labels, emphasis |
| 600 | font-semibold |
var(--font-weight-semibold) |
Headings, titles |
| 700 | font-bold |
var(--font-weight-bold) |
Strong headings, figures |
- No Bootstrap
fw-*. Bootstrap no longer emits them. Its names meant other weights than Tailwind's: Metronic'sfw-boldwas 500,fw-bolder600,fw-boldest700, while Tailwind'sfont-boldis 700. The same word painted two weights, and thefw-*classes were!important.fw-semiboldandfw-mediumnever existed at all (they painted nothing). - No other steps. The theme clears Tailwind's
thin,extralight,extraboldandblack, so those classes compile to nothing, and an arbitraryfont-[600]is reported: Inter is not loaded at other weights, and the browser would only fall back to the nearest loaded one. - No inline
fontWeighton a JSX element: it outranks every stylesheet. Chart and PDF options (EChartsfontWeight) are not styles and keep their own. - SCSS reads
var(--font-weight-*): stylelint rejects a number or keyword in component SCSS, andsrc/tailwindBootstrapColors.test.tschecks the compiled Metronic CSS (which stylelint skips). Metronic's$font-weight-*variables point at the tokens, keeping its names: its$font-weight-boldisvar(--font-weight-medium)(500),$font-weight-boldersemibold,$font-weight-boldestbold. - A utility does not outrank an unlayered stylesheet (a component's
.scssimported from TSX), wherefw-*'s!importantused to. Where such a rule sets the weight of the same element, drop the weight from the rule, or, if the rule is right for everything else it matches, use the important modifier on that element (font-normal!) with a comment.
Spacing
One scale: Tailwind's, with --spacing pinned to 4px in primitives/spacing.css, so
gap-3 is 12px and mb-1.5 is 6px at any root font size.
Bootstrap generates no margin, padding or gap utility (#477). Its .m*-*, .p*-* and .gap-*
used Tailwind's names (mb-4, p-3, gap-2) for its own steps ($spacers, rem against the
13px root, with an irregular step 4) and were !important, so every spacing class rendered
Bootstrap's value. scripts/codemods/bootstrap-spacing.mjs rewrote each use to the nearest half
Tailwind step (SPACER_TO_TAILWIND in packages/eslint-plugin-waldur/rules/bootstrap-spacing.js):
| Bootstrap step | At 13px root | Tailwind | Bootstrap step | At 13px root | Tailwind |
|---|---|---|---|---|---|
1 |
3.25px | 1 |
8 |
24px | 6 |
2 |
6.5px | 1.5 |
9 |
29.25px | 7.5 |
3 |
9.75px | 2.5 |
10 |
32.5px | 8 |
4 |
12px | 3 |
12 |
39px | 10 |
5 |
16.25px | 4 |
15 |
48.75px | 12 |
6 |
20px | 5 |
20 |
65px | 16 |
7 |
22px | 5.5 |
8px |
8px | 2 |
- Write Tailwind's step:
mb-3is 12px. A step from older code or a Bootstrap-era design note is not the same size under the same name: Bootstrap'smb-4is Tailwind'smb-3. - No Bootstrap-only spellings:
mb-md-4ismd:mb-3,mt-n2is-mt-1.5,gap-8pxisgap-2.no-retired-bootstrap-utilityreports them with the class to use. - No arbitrary
[6.5px]to reproduce a Bootstrap step: use the nearest Tailwind step. - No runtime class names. Tailwind generates only the names it finds in source, so
`mb-${space}`styles nothing. Look the class up in a map of literals, asFormGroup'sspacedoes (SPACE_CLASSESinwaldur-form;spaceis a Tailwind step). - Mobile: below
mdthe root drops to 12px, so Bootstrap's rem steps shrank by about 8% there; Tailwind's px steps do not. Spacing follows the tokens: the difference is at most about 2.5px. - A spacing class now loses where Bootstrap's
!importantused to win: to an unlayered stylesheet rule on the same element (a component.scssimported from TSX) and to an inlinestyle. Set the spacing in one place. $spacersis left only for the grid gutters (.g-*), which go withRow/Col(#475).
Corner radius
All corner radius steps are pinned to physical pixels in primitives/radius.css (@theme static)
to prevent fractional rem compression under Waldur's forced 13px root font size
(html, body { font-size: 13px !important; }):
| Token | Size | Tailwind Class | Former Class / SCSS Variable | Use |
|---|---|---|---|---|
--radius-none |
0px | rounded-none |
rounded-0 |
Square corners, flush card attachments |
--radius-xs |
2px | rounded-xs |
— | Subtle inner corners, tiny tags |
--radius-sm |
4px | rounded-sm |
$border-radius-xs (4px) |
Compact inputs, checkboxes, small indicators |
--radius-md |
6px | rounded-md |
$border-radius-sm (6px) |
Standard buttons, form controls, dropdown items |
--radius-lg |
8px | rounded-lg |
$border-radius / -lg (8px) |
Cards, panels, dialog headers, table containers |
--radius-xl |
12px | rounded-xl |
$border-radius-xl (12px) |
Prominent cards, thread bubbles, chat panels |
--radius-2xl |
16px | rounded-2xl |
$border-radius-2xl (16px) |
Large feature cards, hero containers |
--radius-modal |
20px | rounded-modal |
— | Sidebar mode-switcher card |
--radius-full |
9999px | rounded-full |
rounded-pill, rounded-circle |
Avatars, pills, circular badges, status dots |
- No Bootstrap
rounded-0,rounded-pill,rounded-circle. Bootstrap radius utilities are retired and replaced by Tailwindrounded-noneandrounded-fullviawaldur-custom/enforce-border-radius-tokens. - Why pinned in pixels? Default Tailwind radii use relative
remunits (e.g.rounded-smis 0.25rem,rounded-xlis 0.75rem). Under Waldur's forced 13px root font size, unpinnedremtokens shrink unintentionally (rounded-smbecomes 3.25px,rounded-xlbecomes 9.75px). Pinning the scale preserves crisp, consistent corner geometry. - SCSS variables (
src/styles/bootstrap/_variables.scss) mirror the design tokens:$border-radius-xsis 4px (--radius-sm),$border-radius-smis 6px (--radius-md),$border-radiusand$border-radius-lgare 8px (--radius-lg),$border-radius-xlis 12px (--radius-xl), and$border-radius-2xlis 16px (--radius-2xl). - No inline
borderRadiusin JSX elements. Use standard utility classes (rounded-sm,rounded-md,rounded-lg, etc.).
Guards
| What | Enforced by |
|---|---|
Generated ramps match colors.json |
generateTokens.test.ts, yarn tokens:check |
| No dead, ghost or redundantly aliased tokens | tokenAudit.test.ts, yarn tokens:audit |
| No new duplicate roles (same value in both themes) | DUPLICATE_BASELINE in scripts/audit-tokens.mjs, via the same two |
Status -fg clears AA on every surface |
statusContrast.test.ts |
Badge solid and Progress fills stay identical |
progressTokens.test.ts |
| Bootstrap emits no colour utility | src/tailwindBootstrapColors.test.ts |
| No retired Bootstrap colour class name | waldur-custom/no-bootstrap-color-utility |
| Every semantic utility waldur-ui/shell use is declared | tailwindTheme.test.ts |
No Tailwind border colour next to Bootstrap's .border |
waldur-custom/no-bootstrap-border-with-tailwind-color (autofix) |
| State colours from the status matrix, not raw ramps | waldur-custom/no-raw-status-ramp |
| A colour token with a utility is written as the utility | waldur-custom/prefer-token-utility (autofix; table read from theme.css) |
Font size on the scale; no fs-* or inline size |
waldur-custom/require-font-size-scale (autofixes fs-*), stylelint |
Font weight on the scale; no fw-* or inline weight |
waldur-custom/require-weight-scale (autofixes fw-*), stylelint |
| Corner radius on the scale; no retired or inline radius | waldur-custom/enforce-border-radius-tokens (autofix), stylelint |
Bootstrap emits no fw-*; Tailwind exactly the scale |
src/tailwindBootstrapColors.test.ts |
Every physical colour has a dark: counterpart |
waldur-custom/require-dark-pair |
No --bs-* reads |
waldur-custom/no-bootstrap-css-vars, stylelint |
Hard-coded colours (hex, rgb(), named) only go down |
no-literal-color (ESLint and stylelint), yarn colors:check |
Duplicate roles. Two semantic tokens with the same value in both themes are one role with
two names until shown otherwise. The audit fails on any such group not listed in
DUPLICATE_BASELINE, and on a listed group that is no longer a duplicate (prune it). Either
merge the tokens, or add the group with the reason the roles must stay separate — today: a
surface and a hairline that share gray-100, a surface and a drawn line at gray-200, the brand
border/icon/focus ring, the strong divider and --text-quinary, and the card hairline and the
neutral diagram-node tint (--border-secondary, --status-neutral-bg-strong).
Dark pairs. A ramp step, white/black, a hex or a raw var(--color-*-N) /
var(--brand-N) is the same colour in both themes. require-dark-pair reports one that has no
dark: utility for the same property, under the same other variants, in its class list (the
className, the cn() call or the class-map entry it belongs to). Prefer the semantic utility
the message names (bg-primary, bg-brand-subtle, text-brand, text-danger, …); keep a
physical step only with its dark mirror (ring-brand-50 dark:ring-brand-900). A solid fill
(bg-* at 400 or stronger) and white or black text on one read the same in both themes and
pass. Tests are exempt: they assert class names, nothing renders.
Hard-coded colours. A hex value, an rgb()/hsl() call or a named colour in a string,
a style object, an SVG attribute, chart options or a stylesheet is the same colour in both
themes and ignores the brand. scripts/color-literals.mjs counts them per file against
scripts/color-literals.baseline.json, and yarn colors:check (in CI) fails when a file gains
one, and when a file loses one until yarn colors:save lowers its count. yarn colors:report
[path…] lists each literal with the ramp step it equals (=) or is closest to (≈). Replace
one with a utility, getChartThemeColors() for canvas charts, or currentColor plus a text-*
class for SVG.
The chart roles (src/dashboard/chartColors.ts) cover the usual needs: success, notice
(warning-400), warning, danger and progress (blue-500) for states; muted, neutral and
track for secondary series, neutralStrong for its paired series and remaining for the
unused part of a gauge; text, textSecondary and textQuaternary for labels; infoFg/infoBg
for an accent chip; shadow(alpha) for a canvas shadow; getCategoricalColors(n) (up to 16)
for series. Chart tooltips use the card surface (src/echarts/themes.ts), so tooltip HTML takes
the text utilities (class="text-quaternary"). A shadow or darkening tint in a stylesheet is
color-mix(in srgb, var(--shadow-color) 8%, transparent) (semantic/elevation.css). Files where the literal is the point are listed with
their reason in ALLOWED: token sources and the boot splash, the login-page layout artwork,
masks, output rendered outside the app's stylesheet (PDF, email preview, data-URI images,
off-screen exports) and third-party or generated colours. Tests, stories and mocks are skipped.
waldur-custom/no-literal-color (ESLint, scripts) and waldur/no-literal-color (stylelint,
stylesheets) run the same detector against the same baseline, so the editor flags a literal as
soon as a file has more than its count, each with its nearest ramp step. The detector and ALLOWED are in packages/design-tokens/scripts/color-literals.mjs. A short all-digit hex counts only where a
colour goes (first in its string, after :, ,, a function's (, a length or a border style),
so an issue number such as (#455) in a message is not one.
Reading a token in code
For code that cannot take var(--x) directly (a canvas chart, a third-party widget), read the
resolved value instead of hard-coding a hex or repeating getComputedStyle(...):
1 2 3 4 5 6 | |
getCssVar and isDarkTheme are snapshots, so re-read them when the theme or brand colour changes. useResolvedVar is
its own entry point so the main one stays free of React.
Seeing the tokens
Storybook, group Foundations, stories in packages/design-tokens/src/*.stories.tsx:
- Colors — the primitive ramps, one page per family in the same swatch style (step, resolved
hex, variable name on hover):
Neutral(gray,gray-darkand a side-by-side comparison),Status(success,warning,error,info),Accent(the decorative hues) andBrand(the runtime--brand-*ramp). Driven bytokens/colors.json. - Semantic Colors —
Text,Backgrounds,Borders,Brandand theStatusmatrix: every role with its Tailwind utility, its light and dark source values, the value the page resolves now, and (for text, brand and status) its live WCAG contrast. - Typography — the type scale and the pinned
--text-*tokens; Elevation — every shadow; Layout — spacing, radius, breakpoints and the z-index layers; Component Tokens — one page percomponents/*.cssfile.
Everything but the ramp swatches and the type specimens is read from the token stylesheets
themselves (stories/tokenSource.ts parses them through Vite's ?raw import), so a token added,
renamed or re-pointed shows up with no story edit, and its comment becomes its description.
tokenSource.test.ts checks that the parser keeps every declared token and that every component
token file has a page.
They live with the tokens they document, so a token change and its story change land in one package.
What is not generated (yet)
- The brand ramp —
--brand-*is tenant-configurable and injected at runtime (initBrandTokens()). - The semantic layer —
--border-*/--text-*/--btn-*/--status-*CSS files consumed directly across stylesheets and components (see Semantic roles), and the Tailwind bridge to them intailwind/theme.css. - Non-colour scales — spacing, type, radii, shadows.
Where a token file lives
Tokens are hand-written CSS files. Dark mode lives in the semantic layer only: a semantic file
holds both themes, a :root block and a :root[data-theme='dark'] block that redeclares only
what differs, and reads ramp steps (var(--color-gray-100)) and the runtime brand variable,
never raw hex (the modal backdrop is the one exception, as no ramp covers it). A component file
has a single :root block whose colours read semantic roles, and switches theme through them.
Tokens come in three tiers (a 3-layer architecture):
- Primitives (
primitives/): Raw scales and foundational values: colour ramps, the brand fallback, typography, spacing, radius, breakpoints, z-index. - Semantic roles (
semantic/): Theme-aware roles, one file per concern: neutral surfaces, lines and text (background.css,borders.css,text.css),brand.css(every brand role and the focus ring),status.css,elevation.css. - Component tokens (
components/): Dedicated tokens scoped to specificwaldur-uicomponent families.
Components read semantic roles or component tokens, never a raw ramp step. Each layer reads only the layer below it, so a colour resolves in at most two hops: component → semantic → primitive.
All tokens live in packages/design-tokens/src/css/ structured into three layer folders, each with an aggregate index.css. Two more locations hold what is not a token: fonts.css (@font-face rules) and tailwind/ (Tailwind wiring: the semantic utilities, custom variants, keyframes and motion utilities). Files in primitives/, semantic/ and components/ declare custom properties only.
| Layer | Directory | Files |
|---|---|---|
| 1. Primitives | primitives/ |
colors.css, colorRamps.css, typography.css, spacing.css, radius.css, breakpoints.css, zIndex.css |
| 2. Semantic | semantic/ |
background.css, borders.css, text.css, brand.css, status.css, elevation.css |
| 3. Components | components/ |
button.css, card.css, check.css, dialog.css, menu.css, progress.css, sheet.css, sidebar.css, tab.css, table.css |
| Not tokens | root, tailwind/ |
fonts.css; tailwind/theme.css, tailwind/variants.css, tailwind/animations.css |
index.css aggregates everything: fonts.css, the three layers, then tailwind/theme.css, tailwind/variants.css and tailwind/animations.css. Every app imports it, so adding a token to its layer, or a utility to tailwind/, reaches all consumers. A component file is named after its waldur-ui family (button.css, not buttonColors.css): it may hold sizes and shadows as well as colours.
App-only colours (read by one component in src/) don't need a token: write Tailwind
utilities with the dark: variant at the call site, e.g. text-brand-600 dark:text-brand-300.
Rules for a new token:
- Read by one call site in
src/? Use Tailwind utilities withdark:, no token. - Read by a
waldur-uicomponent, or by several call sites? Add it topackages/design-tokens, in the file for its domain or component, prefixed by its owner (--card-*,--table-*). - Import a new file from
index.css, which stays a categorized list of imports. - Keep the Metronic source note and the light and dark values in the same file.
- If it is a sidebar style token (
--nav-*,--sidebar-bg) it must not be theme-scoped. Add new files toFILESinsidebarTheme.test.ts, which checks this. - Custom properties (
--*) must not be declared in stylesheets outsidepackages/design-tokens/(enforced by Stylelint'sproperty-disallowed-list). - One hop per layer, never sideways: a semantic token binds to primitive ramps (
--color-*), runtime scales (--brand-*) or raw CSS values, never to another semantic domain's token; a component token binds to a semantic token, never to another component's token (auditRedundantAliases, enforced bytokenAudit.test.ts). - Component tokens don't handle dark mode: no
[data-theme]selector incomponents/, and no colour from below the semantic layer (var(--color-*),var(--brand-*), hex,rgb(),color-mix()). If no semantic role has the light and dark pair a component needs, add the role tosemantic/first. Sidebar style blocks ([data-sidebar-style]) are exempt: they are fixed looks that must not follow the theme. Enforced byauditComponentLayerintokenAudit.test.ts; files not yet converted are listed inCOMPONENT_LAYER_RATCHETinscripts/audit-tokens.mjs, which only shrinks. When converting a file, map each token by its light and dark values, not by name.
progress.css holds the --progress-* fills, track, label colours and heights;
progressTokens.test.ts checks that every variant has a fill and label, that fills match
Badge solid, and that labels stay readable.
Retiring --bs-*
Bootstrap's --bs-* custom properties are generated from the Metronic SCSS, so they sit outside
the token architecture: the grays behind them are the same in both themes (dark-on-dark in dark
mode), and some are
never defined (--bs-tertiary-bg, --bs-secondary-bg, --bs-secondary-color, --bs-card-bg,
--bs-heading-color), which makes a declaration that reads them silently resolve to nothing.
Read the design tokens instead:
--bs-* |
Token |
|---|---|
--bs-border-color |
--border-secondary |
--bs-body-bg |
--bg-primary |
--bs-body-color |
--text-primary |
--bs-primary, --bs-primary-N |
--brand-color, --brand-N |
--bs-gray-100 / -200 |
--bg-tertiary / --bg-quaternary |
--bs-gray-200 / -400 drawn as a line |
--border-divider / --border-divider-strong |
--bs-gray-50 |
--bg-secondary |
--bs-gray-400 / -500 / -600 / -700 |
--text-quinary / -quaternary / -tertiary / -secondary |
--bs-dark |
--text-primary |
--bs-success / -danger / -active-warning |
--status-success-fg / -danger-fg / -warning-fg |
--bs-primary-rgb |
--brand-color-rgb (follows the tenant brand) |
Never swap a --bs-gray-N for --color-gray-N: neither follows the theme. Pick the role token
by what the colour paints, not by its gray step: a connector
or handle that read --bs-gray-400 is --border-divider-strong, not the muted text role. A
one-off with no role (a --bs-gray-300 guide line, --bs-dark as a border) gets the ramp step
plus its gray-dark mirror at the call site.
Two checks keep new reads out: the waldur-custom/no-bootstrap-css-vars ESLint rule (TS/TSX,
including getCssVar('--bs-…')) and stylelint's declaration-property-value-disallowed-list and
property-disallowed-list (CSS/SCSS). stylelint has no ignore list, so both apply to
src/styles/bootstrap/** too: the few places that must set one of Bootstrap's own hooks (the
.table custom properties) carry a stylelint-disable comment saying why. The ESLint rule has no
exemption either and checks all TS/TSX.
Outside that SCSS nothing reads them any more, so neither check has a per-file allow list.
Adding or changing a value
- Edit
tokens/colors.json. yarn tokens:generate.- Commit the JSON and the generated file together.
Changing a light value changes what the app renders in both Bootstrap and Tailwind components
at once, which is the point. A new ramp or step appears in CSS as --color-<name>-<step> (and so as Tailwind utilities).
Sass defines no colour ramp. Every Sass colour variable (tokens as var(--…), plus $white,
$black and the static default green) lives in src/styles/bootstrap/_variables.scss, and Bootstrap's own
defaults fill every other $gray-* / $primary name, none of which reaches a compiled rule.
Verifying a refactor changes nothing
When the generator, the data model or the Metronic SCSS changes and no pixel should move, snapshot the Metronic stylesheet before and compare after:
1 2 | |
It compiles src/styles/bootstrap/index.scss and records what every declaration resolves to in
each theme, with the design tokens and the dark-theme / light-theme overrides applied, so a
change that keeps the rendered colour (a token in place of an SCSS gray of the same value)
reports nothing.
For the CSS side, compile src/tailwind.css with @tailwindcss/node and diff the emitted
--color-* declarations. To see the rendered result, screenshot every Storybook story on both
commits in both themes with scripts/theme-snapshots.mjs (see
visual-testing.md → "Storybook comparison").
Metronic stylesheets consume CSS custom properties directly (var(--text-primary),
var(--bg-primary), var(--border-primary), etc.), ensuring identical token resolution
across both Metronic and Tailwind components.