@growth-labs/tokens
v0.2.0
Published
Shared design tokens for Growth Labs React/SPA apps: a pinned, tested subset of [DTCG 2025.10](https://tr.designtokens.org/format/) compiled to CSS custom properties scoped `[data-theme][data-mode]`, plus TypeScript types and values. **Pixel** (light and
Downloads
1,993
Readme
@growth-labs/tokens
Shared design tokens for Growth Labs React/SPA apps: a pinned, tested subset of
DTCG 2025.10 compiled to CSS custom
properties scoped [data-theme][data-mode], plus TypeScript types and values.
Pixel (light and dark) is the first theme.
This package owns base semantic roles — surfaces, text, borders, focus,
action, type — and their per-theme values. It does not own component markup
(one consumer does not warrant a kit yet, see growth-labs-tokens-package.spec.md)
and it does not own a consumer's own state semantics: a consumer maps its own
meanings (e.g. "stale", "waiting", "decision-needed") onto the base roles and
the small status/chart layer below.
Install
pnpm add @growth-labs/tokensUse the pre-built CSS
@import '@growth-labs/tokens/pixel.css';<html data-theme="pixel" data-mode="system">Self-hosted fonts
font.family.* names Pixelify Sans (display), Source Sans 3 (body) and
JetBrains Mono (mono) — this package also ships the actual font files, so a
consumer never has to load them from Google Fonts or any other third party
(the public hub runs behind a strict font-src 'self' CSP). Import the
generated stylesheet once, anywhere the bundle already resolves package
imports:
@import '@growth-labs/tokens/fonts.css';It declares exactly the weights the contract and the prototype use — not
every weight each font ships — and nothing else: Pixelify Sans 600 (page
titles and big numbers only), Source Sans 3 400/600/700 (all body text), and
JetBrains Mono 500 (section labels, ids, timestamps). The .woff2 files it
references live alongside it at dist/fonts/ and are part of the published
package; there is no runtime dependency on @fontsource/* — those packages
are a devDependency this package's own build reads once, at build time, to
get correctly subsetted, versioned font files instead of hand-rolling
@font-face rules or vendoring binaries by hand (scripts/build-fonts.mjs,
src/fonts.ts). Regenerate both fonts.css and dist/fonts/ with
pnpm build after bumping a @fontsource/* version or changing
FONT_MANIFEST. Every @font-face rule carries font-display: swap.
Only the latin subset of each family ships (not latin-ext or cyrillic) —
this package's consumers are English-language operator tools, and the plain
latin-<weight>.css file each @fontsource/* package ships is what
scripts/build-fonts.mjs reads; add a subset to src/fonts.ts and the build
step's copy logic together if a consumer ever needs more coverage.
Fonts and licensing
Pixelify Sans, Source Sans 3 and JetBrains Mono are each licensed under the
SIL Open Font License 1.1, which requires the
license text to accompany a redistributed copy of the font — and this package
publishes its self-hosted files publicly to npm, so dist/fonts/ is exactly
that. The build step copies each family's own license next to its .woff2
files as dist/fonts/LICENSE-<family-slug>.txt (pixelify-sans,
source-sans-3, jetbrains-mono) and fails the build if a vendor package's
LICENSE is missing.
data-mode is exactly three values (DataMode in TypeScript): "system"
(everyone's default), "light" and "dark". system resolves with no JS and
no flash — the pre-built CSS gives it the light values directly, then the
dark values again under @media (prefers-color-scheme: dark), so the browser
paints the right one on first render before any script runs. light/dark
are explicit, OS-independent per-viewer overrides (e.g. from a stored
preference, once available). A theme change is colour and type only — it
never changes information, behaviour, status meaning or action availability.
Use the values and types from TypeScript
import { themes, themeCss, type DataMode, type ResolvedTheme } from '@growth-labs/tokens'
themes.pixel.light['color.status.danger'].value // '#c23b2a'
themeCss('pixel') // the same CSS the pre-built file shipsMounting Pixel under your own selector
[data-theme="pixel"] is this package's own scope. A consumer whose
data-theme attribute already means something else (a multi-brand app, say)
mounts Pixel under its own selector with cssFor instead of the pre-built
CSS — every declaration is a live reference to what resolveTheme() actually
produced, never a hand-copied value, so there is nothing to keep in sync by
hand and no drift test to write:
import { themes, cssFor } from '@growth-labs/tokens'
const css = cssFor(themes.pixel, {
system: '[data-theme="acme"]',
light: '[data-theme="acme"][data-scheme="light"]',
dark: '[data-theme="acme"][data-scheme="dark"]',
})Call this at your own build time (a small prebuild script, or a Vite plugin)
and write the result to a .css file your app imports — cssFor is a pure
function over already-resolved values, not a runtime dependency. See
fulcrum-projects's adoption for a worked example: its own data-theme
already selects between two brands, so it supplies its own selector shapes
(no data-mode attribute at all) instead of the package's.
Token contract
The full contract (every token this package guarantees, in both modes) is
CONTRACT in src/contract.ts. Groups:
| Group | CSS prefix | Role |
|---|---|---|
| color.surface.* | --gl-color-surface-* | Page, panel, nested/raised panel, overlay |
| color.text.* | --gl-color-text-* | Primary, secondary, de-emphasized-but-legible tertiary, text-on-a-filled-action |
| color.border.* | --gl-color-border-* | Default hairline, strong/structural edge, faint/disabled |
| color.focus.* | --gl-color-focus-* | The visible-focus ring |
| color.action.* | --gl-color-action-* | Primary action fill |
| color.status.* | --gl-color-status-* | Info/success/danger/warning tone + tint pairs, solid success/attention/warning fills, and an advisory (agent-written-content) tone |
| color.chart.* | --gl-color-chart-* | A 5-series categorical sequence, aliased to the status/action tones above — no new hues |
| color.code.* | --gl-color-code-* | Code/inline-code surface, default code text, selection background, and a 5-role syntax set (keyword/string/number/function/comment) |
| color.diff.* | --gl-color-diff-* | Added/removed/changed line text+background pairs, and the line-number gutter |
| font.family.*, font.weight.* | --gl-font-* | Display (titles only), body, mono; four weights |
| dimension.border.*, shadow.panel | --gl-dimension-*, --gl-shadow-* | The theme's 1px/2px rule weights and its hard shadow |
color.status.*, color.chart.*, color.code.* and color.diff.* are the
full extent of what this package opinionates about state: a consumer's own
state names (Cockpit's stale, unknown, disconnected, failed, waiting,
decision-needed) are that consumer's CSS, aliasing onto these tokens — never
redefined here.
Code and diff
Ruling 18 names code/diff explicitly ("the contract also covers dense
surfaces, code/diff, charts... and focus on nested dark panels"), so both get
pinned roles rather than being left for a consumer to compose ad hoc. The
prototype defines no code/diff CSS at all (no .code/.diff/.hl-/.tok-
classes anywhere in prototype.html), so every color.code.*/color.diff.*
value is an alias onto an existing base/status tone — no new hex values —
chosen so it passes a real contrast check against color.code.surface
(color.surface.panel-raised, deliberately the nested-panel case) and reads
as a defensible syntax role (see tokens/pixel.tokens.json's color.code
group description for exactly which existing tones were excluded and why:
color.status.info/color.status.success, though contract colors, fall
under 4.5:1 against panel-raised specifically in light mode). There is no
dedicated punctuation role — it reads as color.code.text, a common
syntax-theme convention, once every other safe distinct hue was spent on the
five roles that exist. Diff roles are named independently of color.status.*
(color.diff.added-fg, not a direct color.status.success reference in a
consumer's CSS) precisely so a future status-palette edit can't silently
reskin diff rendering too, even though today's values are the same alias.
Extending to a second theme
Add tokens/<theme>.tokens.json in the same DTCG-subset shape (see the
top-level $description in tokens/pixel.tokens.json for the exact rules:
$value is the light baseline, $extensions["com.growthlabs.tokens.modes"]
carries a non-light override, and a "{a.b.c}" string aliases another token).
CONTRACT in src/contract.ts is the same list for every theme — a theme
missing a contract token in either mode fails resolveTheme(), which the test
suite exercises directly (__tests__/contract.test.ts).
A primitive/palette token layer (raw swatches that semantic tokens alias to) is deliberately not built yet: with one theme there is nothing to share it with. Add it at the second theme that needs shared primitives, not before (the estate's "extract at the second real use" rule).
Contrast
__tests__/contrast.test.ts computes real WCAG contrast ratios (src/contrast.ts)
for every text/status/focus pairing this contract defines, across light, dark,
and the nested-dark-panel case (color.surface.panel-raised in dark mode) —
including the code/diff roles above, whose default surface (color.code.surface)
is color.surface.panel-raised, so nested-dark-panel coverage for code
comes for free.
