@amsterdamdatalabs/enact-design-system
v0.2.0
Published
Enact design system — authored React + CSS-Modules components and 3-tier design tokens. Single source of truth; no local CSS/Tailwind overrides.
Downloads
101
Readme
@amsterdamdatalabs/enact-design-system
The single source of truth for Enact product UI: React components + a 3-tier design-token system. Change a token or a component once, and every product that consumes the package updates everywhere. Product code uses only the public components and semantic tokens — never raw colors, pixels, fonts, or component internals.
Ported from the proven Amsterdam Data Labs (ADL) system in net-revenue-demo.
Stack
- Bun for install/scripts, React 19 + TypeScript, styled with CSS Modules.
- Tokens are plain CSS custom properties (primitive
--adl-*→ semantic → dark mode). - Built with Vite (library mode, ESM) +
tscfor declarations.
Install & develop
bun install
bun run dev # theme workbench + component catalog at localhost:5180/?p=components
bun run typecheck # tsc --noEmit (also enforces prop/variant contracts)
bun run lint # Biome + Ultracite + GritQL adherence (see below)
bun run format # Biome formatter (write)
bun run test # bun test — render/behavior + contract + styling guards
bun run test:visual # Playwright visual regression (light + dark)
bun run test:visual:update # refresh visual baselines after an intended change
bun run check # typecheck + lint + test (the pre-merge gate)
bun run build # dist/: ESM + .d.ts + design-system.css + tokens.cssLocal development against a consumer app
dev-install-and-run.sh builds the library and installs the real publishable
tarball (bun pm pack) into a consumer app — so it resolves exactly like a
registry install (built dist/ + the exports map), not a source symlink. This
mirrors what apps get from npm, for rapid local iteration.
bash dev-install-and-run.sh --target ../net-revenue-demo # build → test → pack → install into the app
bash dev-install-and-run.sh pack # just build + produce the tarball
bash dev-install-and-run.sh link # global `bun link`; then `bun link <pkg>` in the app
bash dev-install-and-run.sh doctor # list exactly what the published tarball contains
bash dev-install-and-run.sh --dry-run --target ../app # print the plan without running itUse --skip-build to re-pack without reinstalling deps, and --docs to launch
the showcase afterwards.
Usage in a product app
Import styles first, then components — all from the single public entry point:
import "@amsterdamdatalabs/enact-design-system/tokens.css"; // design tokens (incl. dark mode)
import "@amsterdamdatalabs/enact-design-system/styles.css"; // component styles
import { Button, Card, Stat } from "@amsterdamdatalabs/enact-design-system";
export function Example() {
return (
<Card variant="raised">
<Stat label="Net revenue" value="$1.24M" delta="12.4%" deltaDirection="up" />
<Button variant="primary">View report</Button>
</Card>
);
}Dark mode: set data-theme="dark" on <html> (or any subtree root).
ThemeProvider and useTheme
Wrap your application root with ThemeProvider to enable theme switching.
useTheme surfaces the current theme and a setter anywhere in the tree.
import {
ThemeProvider,
useTheme,
} from "@amsterdamdatalabs/enact-design-system";
// 1. Wrap the app root
export function App() {
return (
<ThemeProvider defaultTheme="system">
<Shell />
</ThemeProvider>
);
}
// 2. Read and change the theme from any descendant
function ThemeToggle() {
const { theme, resolvedTheme, setTheme } = useTheme();
return (
<button
type="button"
onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
>
Switch to {resolvedTheme === "dark" ? "light" : "dark"} mode
</button>
);
}ThemeProvider persists the user's choice to localStorage under the key
"enact-theme" (configurable via the storageKey prop). The resolved theme
("light" or "dark") is derived from the user's explicit choice or from
window.matchMedia("(prefers-color-scheme: dark)") when the theme is "system".
Preventing theme flash on first paint
Without extra work the page renders in the default (light) state for one frame
while React hydrates — producing a visible flash when the user prefers dark.
Inline themeInitScript in <head> before your app bundle to apply the
correct data-theme attribute at browser-parse time, before any paint:
import {
themeInitScript,
} from "@amsterdamdatalabs/enact-design-system";
// In your HTML template, _document.tsx, or root layout:
export function Document() {
return (
<html lang="en">
<head>
{/* Must come before the app bundle — runs at parse time, before paint. */}
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
{/* ...other head tags, then the app bundle */}
</head>
<body>...</body>
</html>
);
}If your app uses a non-default storage key, use getThemeInitScript to
produce a matching script:
import {
getThemeInitScript,
} from "@amsterdamdatalabs/enact-design-system";
const script = getThemeInitScript("my-app-theme");
// Pass the same key to ThemeProvider:
// <ThemeProvider storageKey="my-app-theme">...</ThemeProvider>The script is a self-contained IIFE:
- reads
localStorage[storageKey] "dark"→ setsdata-theme="dark"on<html>"light"→ ensuresdata-themeis absent"system"or missing → falls back towindow.matchMedia("(prefers-color-scheme: dark)")- wrapped in
try/catchso storage or matchMedia failures are inert
Keying is identical to ThemeProvider so there is no conflict on hydration.
Layout primitives
Five composable, token-based layout components handle spacing, alignment, and
grid structure. All accept standard HTML div attributes in addition to the
props below.
import {
Box,
Container,
Grid,
Inline,
Stack,
} from "@amsterdamdatalabs/enact-design-system";
// Box — single block with optional padding, surface tint, and radius
<Box padding="4" surface="raised" radius="md">
content
</Box>
// Stack — vertical flex container
<Stack gap="4" align="center">
<div>Item A</div>
<div>Item B</div>
</Stack>
// Inline — horizontal flex container
<Inline gap="2">
<Button variant="primary">Save</Button>
<Button variant="ghost">Cancel</Button>
</Inline>
// Grid — CSS grid with a named column count
<Grid columns={3} gap="6">
<Card>One</Card>
<Card>Two</Card>
<Card>Three</Card>
</Grid>
// Container — centred, max-width wrapper
<Container width="narrow">
<Stack gap="4">...</Stack>
</Container>Typography
Heading and Text map directly to the system's type scale.
import {
Heading,
Text,
} from "@amsterdamdatalabs/enact-design-system";
// Heading — semantic element + visual size, decoupled for accessibility
<Heading level={1} size="xl">Page title</Heading>
<Heading level={2} size="md">Section title</Heading>Accessibility note — level vs size:
level controls the rendered HTML tag (h1–h6) and therefore the document
outline used by screen readers. size is a purely visual override — you can
render an h2 that looks like an h4 (or vice versa) without affecting
semantics. Always set level to match the document hierarchy; use size only
to meet visual design requirements.
// Text — inline/block body copy
<Text size="sm" tone="secondary">Last updated 3 days ago</Text>
<Text size="md" tone="primary">Main body copy</Text>Available sizes for both: "xs" "sm" "md" "lg" "xl" "2xl".
Available tones: "primary" "secondary" "muted" "inverse" "link".
Components (17)
| Group | Components |
|---|---|
| core | Button IconButton Badge Avatar Card Stat Tag |
| feedback | Alert Progress Tooltip |
| forms | Input Select Checkbox Switch Field |
| navigation | Tabs Breadcrumb |
Tooling
Biome + Ultracite is the linter/formatter (bun run lint / format), with
strict accessibility, correctness, and style rules. Ultracite's a11y rules are
enforced — decorative SVGs are hidden, roles/labels are required, etc.
Enforcement — "no local overrides"
Four mechanisms, each independently verified to fire:
| Rule | Enforced by |
|---|---|
| no raw hex colors in TS/JS | GritQL plugin .grit/no-raw-hex.grit |
| no raw px in TS/JS | GritQL plugin .grit/no-raw-px.grit |
| only system fonts (Space Grotesk / Manrope / IBM Plex Mono) | GritQL plugin .grit/no-offsystem-font.grit |
| valid component props + enum values (variant="huge" fails) | TypeScript (bun run typecheck) |
| no deep imports into internals | the package exports map (physical — deep paths aren't exported) |
Biome lacks ESLint's no-restricted-syntax, so the raw-value rules are authored as
GritQL plugins (engine biome(1.0), matching JsStringLiteralExpression). Biome
errors on a broken plugin, so the enforcement can't silently rot. The library
lints itself, and the showcase doubles as a live adherence example.
Testing — drift protection
| Layer | What it catches | Run |
|---|---|---|
| A. Render + behavior | a component breaks; controlled/uncontrolled state; ARIA roles | bun test |
| B. Contract snapshots | a token or variant renamed/removed | bun test |
| C. Styling guard | raw hex in component CSS; a var(--…) that references an undefined token | bun test |
| D. Visual regression | contrast/visual drift in light or dark, every section | bun run test:visual |
After an intended visual change, refresh baselines with bun run test:visual:update.
Layout
src/
tokens/ CSS custom properties (ported): colors typography spacing effects fonts base + index.css
components/ core/ feedback/ forms/ navigation/ — each: Name.tsx + Name.module.css + index.ts
index.ts the ONLY public entry point
studio/ Theme workbench + Components tab (bun run studio)
.grit/ GritQL adherence plugins (raw hex/px/font bans)
test/ bun test (A/B/C) + visual/ Playwright specs (D)
biome.jsonc Biome + Ultracite config (extends + GritQL plugins)Notes
- Components were re-authored from the ADL bundle's source (the bundle's behavior is the reference). Visual values map 1:1 to the same semantic tokens.
- Prop contracts are TypeScript types: components extend the native HTML attribute
types, so standard attributes (
id,aria-*, form attrs) are allowed by design — required for accessible label association (Field htmlFor→ inputid). - Distribution model (npm publish vs. monorepo workspace) is intentionally deferred; the build artifact supports either.
