Primary card
On the primary surface with white text.
Colour, typography, layout and UI components for small web tools. One stylesheet, no build step.
This page is the reference and the demo at once: every example below is
live markup using the real components, not a screenshot. It documents
the full vocabulary of
design-system.css, plus the two ES
modules for charts and search.
Colours are named by role, never by hue. There is no
--fm-color-blue. That is what lets a
consuming tool re-theme the system by overriding a dozen values and
nothing else — and it is why the names below say what a colour is
for rather than what it looks like.
Every ratio shown is measured, not estimated. If you change a value, re-measure it and update the number in the source.
The five brand roles. --fm-color-primary
and --fm-color-accent carry white text,
so they need at least 4.5:1 against white.
--fm-color-secondary is a surface that
carries dark text and must never be used as a text colour itself.
--fm-color-primary
#1f4a6d
Primary surface and action. Carries white text.9.30:1 on white
--fm-color-primary-strong
#14324a
Deepened variant for opacity stacking — stays AA at 80%.13.25:1 on white
--fm-color-secondary
#8fb4cc
Secondary surface. Carries dark text, never used as text itself.7.20:1 with ink
--fm-color-accent
#9a4a1c
Accent surface (badges). Carries white text.6.23:1 on white
--fm-color-highlight
#ffd84d
NOT decorative — the text colour of high-contrast mode.11.42:1 on ink
--fm-color-ink
#1f2328
Body text on light ground.15.80:1 on white
--fm-color-white
#ffffff
Base surface.
Components reference these, not the brand tokens directly, so the brand stays changeable in one place.
| Token | Resolves to | Use |
|---|---|---|
--fm-color-text | --fm-color-ink | Body text |
--fm-color-surface | --fm-color-white | Base surface |
--fm-color-on-primary | --fm-color-white | Text on the primary surface |
--fm-color-on-secondary | --fm-color-ink | Text on the secondary surface |
These three are fixed on purpose and are the one part of the palette that carries meaning rather than identity. Green means ok and red means error in every theme and every organisation. Re-tinting them to match a brand destroys the signal.
--fm-web-status-success
#3f7d55
Success. Green in every theme.
--fm-web-status-warn
#b78a1c
Warning. Yellow in every theme.
--fm-web-status-error
#b3401e
Error. Red in every theme.
The one deliberate exception is the info
variant, which draws its fill and border from the primary tint and
therefore does follow your brand.
A desaturated reading palette that sits alongside the brand layer, for
data tools with long reading stretches. Components use this layer. It
measures against --fm-web-text, not
against ink — a ratio without the text tone it was measured against is
worthless.
--fm-web-bg
#f4f6f8
Page ground for data tools.
--fm-web-surface
#ffffff
Card and panel surface.
--fm-web-surface-sunk
#f8fafc
Recessed surface — tag backgrounds, filter bars.
--fm-web-hairline
#dfe4ea
Borders and dividers. No text sits on it.
--fm-web-text
#22262b
Primary reading text.14.05:1 on web-bg
--fm-web-text-soft
#5a616b
Secondary text, sublines.5.77:1 on web-bg
--fm-web-text-mute
#69707a
Quietest text layer. Still AA.4.62:1 on web-bg
--fm-web-clay-text
#9c5a38
Warm accent text.5.34:1 on white
--fm-web-primary-deep
#245a80
Triple duty: surface with white text, text on light, text on a 14% mix.7.37:1 on white
--fm-web-primary
#5b8cad
Borders, accent rules, hover fills. No text on it.
--fm-web-primary-tint
#e4edf4
Soft fill — callouts, hero, hover, zebra.12.84:1 with web-text
--fm-web-highlight
#e8c44a
Desaturated highlight accent for the screen layer.
Eight categorical tones, low saturation, in the same order as
PALETTE in
fm-charts.js.
The constraint, and the reason the values look arbitrary: no two entries share both a close hue and a close luminance. That keeps them apart for a red-green colour-blind reader and in greyscale print. Two shades of the same brand colour side by side in a stacked bar is a problem, not brand consistency.
--fm-web-chart-1
#2f6f96
Blue — primary series
--fm-web-chart-2
#c08a2e
Ochre
--fm-web-chart-3
#4e8f6d
Green
--fm-web-chart-4
#a8503c
Rust
--fm-web-chart-5
#6d5b9e
Violet
--fm-web-chart-6
#4fb0ad
Light teal
--fm-web-chart-7
#6b4a2e
Brown
--fm-web-chart-8
#7d838c
Slate grey
ECharts itself is loaded by you — the design system bundles no third-party library. The static exports work without a DOM; the functions read the tokens at call time and follow a theme override.
Five type tokens, a modular size scale (base 1rem, ratio 1.25) and two line-height multipliers. Inter carries body and UI, Spectral the editorial emphasis, JetBrains Mono code and figures. Caveat exists for marginalia only and must never be used for body text.
| Token | Family | Use |
|---|---|---|
--fm-font-headline | Inter | Headlines and UI labels |
--fm-font-copy | Inter | Body text and UI |
--fm-font-emphasis | Spectral | Editorial emphasis (.fm-emphasis) |
--fm-font-mono | JetBrains Mono | Code, figures, captions |
--fm-font-hand | Caveat | Marginalia only — never body text |
Each class sets family, size, line height and text colour — never a background.
A subline sits under it and adds one sentence of context.
Body text carries the detail. It uses the wider copy line height of 1.3 so longer paragraphs stay readable across several lines.
Emphasis sets a line in the serif face, for a pulled-out statement.
Inline atoms: highlight, underline and a search hit.
The micro tier is for dense caption context — sub-labels, table footers, meta tags. Not for reading text.
Two multipliers, applied unchanged as
line-height.
Tight headline leading
across several lines
for compact titles
Wide copy leading
across several lines
for readable paragraphs
Layout classes, six spacing tokens, the component metrics and two documentation-only breakpoints.
| Class | Does |
|---|---|
.fm-container | Centred content column, capped at --fm-container-max |
.fm-section | Vertical section rhythm |
.fm-grid, --2, --3 | Two- and three-column grids that collapse on narrow viewports |
.fm-section-head | Section heading block with a rule |
Six tokens, base 0.25rem. Use them rather than hard-coded values — this is the layout rhythm, and it is not part of the brand.
| Token | Value | Use |
|---|---|---|
--fm-radius-sm | 0.25rem | Small brand radius |
--fm-radius-md | 0.5rem | Brand radius |
--fm-web-radius-control | 6px | Inputs, buttons, controls |
--fm-web-radius-card | 10px | Cards, panels, modal |
--fm-web-radius-mini | 4px | Lozenges, inline tags, chips |
--fm-border-width | 2px | Standard stroke, e.g. the secondary button outline |
--fm-web-input-min-h | 44px | WCAG touch-target floor on inputs and controls |
--fm-container-max | 72rem | Content column cap |
Two tokens, documentation only:
--fm-breakpoint-sm (36rem) and
--fm-breakpoint-md (48rem). A
@media condition cannot read
var(), so the values are repeated
literally in the source.
The page header above IS the live example. A white brand bar with a primary accent rule at its lower edge; the navigation wraps on narrow viewports instead of collapsing into a hamburger.
No logo file ships with the design system. The wordmark alone is the
honest default — a stretched or recoloured logo never looks
deliberate. Put your own file in the
fm-header__logo slot when you have one.
| Class | Does |
|---|---|
.fm-header--dark | Solid primary bar with white text instead of the white bar |
.fm-header--compact | Keeps everything on one line for search-first layouts — no wrap, tighter padding, truncation allowed |
.fm-header--fixed | Fixed to the top. Add a matching scroll-padding-top on <html> |
.fm-header__subbrand | A second line under the wordmark, e.g. "A project by …" |
.fm-header__support | Contact link in the nav zone; on narrow viewports only the icon remains |
Horizontal section navigation, meant to sit directly under the page headline — the quiet alternative to a vertical side menu. Tabs sit on a hairline baseline; the active tab carries a deep primary marker. Optional group labels structure longer navigations, and planned steps stay visible as disabled.
A button always carries a variant modifier —
--primary or
--secondary. Never the bare class.
:hover darkens the primary button and
fills the secondary one; :focus-visible
shows the focus ring.
A card holds one statement. Both variants set their own text colour so the contrast is right on their surface. A variant modifier is mandatory.
On the primary surface with white text.
On the secondary surface with dark text.
A small angled flash for one short phrase. Two variants: accent and highlight.
Five semantic variants. The optional
__lead slot replaces a hand-written
<strong> prefix.
Two families in one: structural (neutral, info, required, risk) and status (ok/success, warn, error/danger).
One sentence of framing, on the tinted surface.
The first interactive element on this page. Tab from the very top to reveal it — it is visually hidden until focused.
Panels frame a block of content; metric cards show a single figure with a semantic accent rule.
Panels frame content blocks and pair with the table body variant.
A quiet note at the foot of the panel.
A tab bar with folder-tab optics and a segment switcher for toggle
groups. Attach your own listeners; they toggle
.is-active between the buttons.
The panel body sits under the bar and shares its surface.
The button that opens the modal search, with its keyboard shortcut shown inline.
Input building blocks for data tools. Behaviour — validation, submit —
is yours; this supplies optics plus states. Native elements take the
class directly; the only wrapper is the optional
.fm-field.
Every control carries a 44px touch-target floor (WCAG 2.5.5 / 2.5.8). A placeholder is not a label — it disappears the moment someone types.
An opt-in class for the native <dialog>.
You supply the markup and the open/close logic
(showModal() /
close()); the design system supplies
backdrop, shadow, radius and the sub-element layout. Variants:
--narrow, --wide,
--blur.
Sticky head, optional zebra, compact and dense variants, a numeric
column helper and a sticky-first-column variant for wide comparisons.
Optics only: sorting and selection are yours, toggling
.is-active and
.is-selected.
Half your audience opens the tool on a phone. A twelve-column table is
unusable there however well it is styled — wrap it in
.fm-table-scroll, or show fewer columns.
| Item | Owner | Amount € |
|---|---|---|
| Cargo-bike sharing | Transport | 42 000 |
| School garden | Education | 18 500 |
| Energy advice | Facilities | 9 200 |
| Total | 69 700 | |
| Modifier | Does |
|---|---|
--zebra | Alternating row backgrounds |
--compact | Reduced cell padding for dense lists |
--dense | Micro type for data-heavy tables |
--sticky-col | Sticky first column; needs .fm-table-scroll |
File-drop zone with idle, hover, dragover and error states. Optics
only — wire up the listeners and toggle
.is-dragover and
.is-error.
The toaster is the fixed container, the toast a single message. Four
severity variants. The slide-in respects
prefers-reduced-motion. Auto-dismiss is
yours.
A sticky action bar for bulk actions. Sticks to the bottom of the
viewport by default; --top flips it.
A search field plus result overlay. Two variants of one family: an
inline overlay dropdown that sits over the content so there is no
layout shift, and a modal / Ctrl-K variant that reuses
.fm-modal.
The behaviour — open/close, arrow-key navigation, ARIA
combobox/listbox/option, focus trap, debounce with a race guard,
reduced motion — comes from
fm-search.js. The module is
engine-neutral: you pass an adapter.
Shown open and static here so the states are visible on the page.
| State class | Shown when |
|---|---|
--hint | Below the minimum query length |
--loading | A search is in flight |
--no-results | The adapter returned nothing |
--empty | The field is empty |
A theme is a flat set of token values and nothing else. It never touches a component — which is why switching one at the top of this page re-renders everything below without a single component rule being involved.
| Theme | For | Character |
|---|---|---|
tool (default) | interactive tools, dashboards, viewers | screen-first, neutral ground, ink-blue primary |
report | reports and audits | near-monochrome, tighter by default, made to be printed |
notebook | briefs and notes | warm paper, editorial ink |
| Attribute | Values | Does |
|---|---|---|
data-fm-theme | tool · report · notebook | The palette and its dark counterpart |
data-fm-scheme | light · dark · system | Light unless you say otherwise. system opts in to prefers-color-scheme |
data-fm-density | normal · compact · dense | Tightens the spacing scale, so it propagates to every component at once |
They compose: a theme carries a preferred density, and an explicit
data-fm-density still wins over it.
The default is light, including when the viewer's system is set to dark. That is deliberate, and it is the one place this system overrides a stated system preference.
These are tools that exist to show numbers — tables, figures, charts. On a dark ground thin numeric glyphs and chart fills lose contrast, and light-on-dark text blooms for astigmatic readers. That is exactly the dense material people most need to read accurately, so defaulting a data tool into dark trades legibility for a preference nobody expressed in the tool itself.
Dark is still there, measured and complete, and every theme carries
its own dark palette rather than a filter applied on top. It is opted
into: data-fm-scheme="dark" for always
dark, or data-fm-scheme="system" to
follow prefers-color-scheme after all.
If your tool is a reading surface rather than a data surface, that is
a reasonable thing to switch on.
--fm-web-status-* and the
warn/error/success variants are declared once and keep their meaning
everywhere. Switch through all three themes above and watch the
callouts: only info moves, because it
follows the brand on purpose..fm-mode-hc resolves to a fixed
near-black and amber pair through
--fm-hc-surface /
--fm-hc-text, deliberately outside the
theme system. It is an accessibility mode, so it has to land in the
same place under every theme and scheme.
The selectors are plain attribute selectors, not
:root-only, so a theme can be applied to a
subtree — useful for a preview pane or a side-by-side comparison.
If none of the three fits, do not add a fourth here — override the
tokens in your own stylesheet, as described in
Theming. A consuming tool's unlayered
:root block beats every theme above,
because these are declared inside
@layer base.
Link your own stylesheet after the design system and override tokens in
a plain :root block. That is enough
because the defaults live in @layer
rules, and unlayered declarations beat layered ones regardless of
document order.
.fm-btn, the next release
breaks your tool silently.--fm-web-status-* and the
warn/error/success variants alone. Green means ok everywhere.
This is the common failure, not an edge case.
--fm-color-primary and
--fm-web-primary-deep carry white text,
so they need at least 4.5:1 against white. Many brand colours were
chosen for print and logos and fail badly.
Do not change the brand colour — split the roles. Use
it as a surface (--fm-color-secondary,
--fm-web-primary-tint) and a darkened
variant wherever white text sits on it. Write down in the file that the
darker value is derived for legibility, not invented, or someone will
"correct" it back later.