@nullwert/design-system (0.9.0)

Published 2026-11-08 23:38:25 +01:00 by nullwert

Installation

@nullwert:registry=https://gitea.think-jeckel.de/api/packages/nullwert/npm/
npm install @nullwert/design-system@0.9.0
"@nullwert/design-system": "0.9.0"

About this package

OS1: monochrome-dark retro design system (Iosevka) for local web apps

OS1 — design system

A monochrome-dark retro design system (Iosevka, square corners, hard shadows, pink as the signal color) for local web apps. Zero runtime dependencies: a ~31 KB minified CSS file plus the two Iosevka woff2 (191 KB raw) shipped alongside it in dist/fonts/ — fetched only when Iosevka is not already installed on the system (local() first in every @font-face) — and a small vanilla JS helper.

Derived from the TypeSafeAI "TS.AI.OS1" poster — this is its dark mode.

Files

src/
  tokens.css       design tokens — raw palette, semantic layer, z-scale, geometry, light theme,
                   metric-compatible fallback font face
  base.css         @font-face (Iosevka 400/700, local()-first), reset, focus/selection, .cursor
  components.css   all component classes
  textures.css     dither / halftone decorations
  icons.json       icon registry — every stroke icon used across the six apps
  ds.js            DS.toast / DS.dialog / DS.confirm / DS.prompt / DS.tabs / DS.closeMenus / DS.icon / DS.esc / DS.version
fonts/             Iosevka woff2 (lean UI subset, ligatures kept) + license
scripts/
  fetch-fonts.mjs  downloads Iosevka, subsets to the UI charset (only step that needs network)
  build.mjs        concatenates src/*.css, inlines fonts + icons, minifies via esbuild → dist/
stylelint.config.mjs  source discipline (`npm run lint`) — hex/z-index/radius/!important rules
test/              node:test suite — tokens, contrast, icons, ds, build, api-compat
                   (+ test/fixtures/ snapshots of the consumer-facing names)
dist/
  ds.css           the bundle (~31 KB minified; @font-face url()s point into fonts/)
  ds.js            helpers + injected icon registry (minified, ~16 KB)
  fonts/           the two Iosevka woff2 + license — what ds.css references
  icons/           favicons + apple-touch PNGs generated by the build
preview/
  poster.html      dark-mode recreation of the poster, built from real components
  gallery.html     every component, interactive (incl. light-theme toggle)
  icons.html       icon overview — every glyph, grouped, with source + usage

Commands

npm install       # one-time (esbuild + subset-font + stylelint devDependencies)
npm run fonts     # fetch Iosevka woff2 + license (network), subset locally
npm run build     # rebuild dist/ds.css + dist/ds.js (stamped with version + git hash)
npm run lint      # stylelint — token discipline over src/*.css
npm test          # node:test suite (builds dist/ on the spot if missing; offline)
npm run preview   # serve repo root at :8123 → /preview/gallery.html, /preview/icons.html

Fonts

npm run fonts downloads the full latin woff2 per weight, subsets it to the UI charset and writes the small result to fonts/ — the build itself stays offline. The subset covers basic latin, the latin-1 supplement (umlauts, ß — German and most western European text), latin extended-A (central/eastern European letters) and the symbols the UI uses (– — ' ' " " … · × ÷ › ‹ ✕ ■ □ ● ○ → ← ↑ ↓ ✓ ✗); Iosevka's code ligatures are kept. Anything outside the charset falls back to ui-monospace, Menlo, monospace per glyph — widen the ranges in scripts/fetch-fonts.mjs and re-run npm run fonts if an app ever needs more. Between Iosevka and that system stack sits the "Iosevka Fallback" face — see Performance.

Local-first delivery. Every @font-face in src/base.css lists local() sources before the url() — a system-installed Iosevka (full face names and PostScript names for 400/700) satisfies the face and the woff2 files are never fetched; font-display: swap plus the metric fallback face cover everyone else. The build copies fonts/*.woff2 + license to dist/fonts/, next to ds.css, so the default url(fonts/…) resolves against the stylesheet's own directory. Consumers serving the stylesheet from a different path set DS_FONT_BASE when building this repo — or simply rewrite the url(fonts/… prefix in their own build (the gitea theme rewrites it to /assets/fonts/).

Testing & governance

The five apps hard-depend on names and values in this repo, so the invariants are mechanical: npm run lint + npm test. The suite is node:test + regex parsers + a hand-rolled DOM stub — no jsdom/playwright, no new runtime surface, fully offline.

check enforces
stylelint no raw hex outside src/tokens.css (the palette; baked checkbox data URIs use %23… and are sync-checked); z-index only from --z-*; border-radius only from --radius/--radius-sm (+ checkbox 0, radio/.burst 50%); no !important outside .hidden/[hidden]; flat cascade (nesting 0), no ID selectors
tokens.test every var(--x) in src/*.css resolves in the dark :root; no duplicate definitions per theme scope; responsive :root overrides touch --gutter only; light theme retunes exactly the themable set and invents nothing; color-scheme per theme; checkbox mark ink == --accent-ink in both themes
contrast.test the WCAG matrix as a test: 9 ink tokens × 4 surfaces ≥ 4.5:1, dark and light, plus the baked pairs (titlebar / accent-ink / danger-ink). Any palette value regression fails CI (no 3:1 exceptions are used)
icons.test registry hygiene: unique names, documented categories, balanced fragment markup (no nested <svg>/scripts/handlers), favicons carry full + both baked hexes
ds.test the shipped dist/ds.js IIFE (loaded in a minimal DOM stub) exports the 10 documented DS functions, carries the icon registry entry-for-entry, renders icons and hydrates placeholders
build.test dist freshness: stamp version == package version, every token and every class from src/ survives into dist (the staleness detector — forgot to rebuild ⇒ red), artifacts still minified, the woff2 files ship to dist/fonts/ and are referenced (not inlined) with the local() sources intact, favicon files generated
api-compat.test the consumer contract: exact DS export list; committed snapshots in test/fixtures/ (163 classes, 74 token names, 46 icon names) fail on any removal — additions are fine
npm run lint       # stylelint over src/*.css
npm test           # builds dist/ on the spot when missing (fresh clone)
npm run build      # refresh dist/ after src edits — build.test fails on stale dist

CI (.gitea/workflows/ci.yml) runs build → lint → test on every push and PR; publish.yml gained a checks job — it only publishes when they are green. dist/ stays untracked: the workflows build it from src/ fresh on every run, so a stale local dist can never reach the registry or the six apps.

API extensions are deliberate. After adding a DS function, class, token or icon, regenerate the snapshots and commit them with the change:

UPDATE_SNAPSHOTS=1 npm test   # rewrites test/fixtures/*.json — diff should show only additions

Pre-release checklist

# edit src/
npm run lint && npm test   # must be green
npm version patch          # 0.3.0 → 0.3.1
npm run build              # stamp picks up version + git hash
git push                   # checks gate; publish fires on the version bump

Distribution via Gitea npm registry

The bundle ships as the npm package @nullwert/design-system. The Gitea npm registry requires the scope to match the owner, so publish it from the nullwert user/org (or change the scope everywhere if you use a different owner).

One-time setup

  1. Gitea → Settings → Applications → create a token with package:write (publishing) or package:read (consuming).
  2. Copy .npmrc.example to .npmrc and export GITEA_NPM_TOKEN="<token>", then npm publish — or just push to main and let the Gitea Actions workflow (.gitea/workflows/publish.yml) publish when the version changes.

Consumer repos

All six apps declare "@nullwert/design-system" and consume it directly — no copy scripts, no per-repo npmrc:

repo how the package is used
dl-manager serveAsset() resolves /ds.css /ds.js /fonts/Iosevka-*.woff2 /favicon.svg from node_modules/…/dist/ at boot (no committed fallback — the old public/ copies were dropped)
ebook build.rs sets DS_CSS_PATH/DS_JS_PATH/DS_FAVICON_PATH + the two DS_FONT_*_PATH from node_modules (panics if missing — the committed src/assets/ds.* fallback was removed); include_str! embeds css/js, include_bytes! the woff2, served at /fonts/…
launchctl same as ebook incl. the favicon (the committed static/ fallback was removed too)
dashboard static page, <link>/<script> straight into node_modules/…/dist/; ships the package's apple-touch PNG — the relative url(fonts/…) resolves into dist/fonts/ on its own, no serving code
dockhand build.rs resolves node_modules ds.css/ds.js + favicon + fonts (panics if missing) and embeds them via include_str!/include_bytes! next to its own CodeMirror bundle; serves /ds.css /ds.js /fonts/… /favicon.svg; Renovate automerges DS bumps
uptime-server same as ebook incl. the favicons — css/js/fonts embedded, fonts served at /fonts/… (the committed src/assets/favicon.svg fallback was removed); Renovate automerges DS bumps

Registry auth lives once in ~/.npmrc (not per repo):

@nullwert:registry=https://gitea.think-jeckel.de/api/packages/nullwert/npm/
//gitea.think-jeckel.de/api/packages/nullwert/npm/:_authToken=${GITEA_NPM_TOKEN}

${GITEA_NPM_TOKEN} is a package:read token exported in the shell. Without it, installs fail — no app keeps committed fallback copies anymore, so the token is required to build the Rust apps and deploy the dashboard.

Changing the design spec

# in design-system/
npm run lint && npm test  # checks gate the publish workflow
npm version patch        # 0.3.0 → 0.3.1 (edit src/*.css first)
npm run build            # stamp picks up version + git hash
git push                 # CI publishes; or `npm publish` locally

# in each app repo
npm update @nullwert/design-system
cargo build              # Rust apps only — build.rs re-resolves and re-embeds

dl-manager picks the new files up on its next restart (it reads them per request). The first line of every served ds.css identifies its exact build: /*! @nullwert/design-system v0.1.0+89934b3 built 2026-10-31 */ — and DS.version in the page console reports the same identity for the loaded ds.js (handy when hunting served-vs-lockfile version drift).

Tokens

Two layers: the raw palette is the source of truth, the semantic layer is what components actually read. Every semantic token is an alias of a raw one (--surface-raised: var(--panel)), so the dark default resolves to exactly the raw values — and a retheme can override either layer (see Theming). Never delete or rename raw tokens: the six apps reference them.

Raw palette

token value role
--bg / --panel / --panel2 / --inset #0c0a0c / #141117 / #1b171e / #08070a surfaces
--titlebar / --titlebar-ink #e8e4ea / #0c0a0c inverted title strips
--line / --line-strong #2b262e / #554b58 borders, rules
--text / --muted / --faint #ece8ee / #a29aa7 / #867f8b text — three distinct steps (≈2.2:1, then ≈1.4:1)
--accent / --accent-ink / --accent-dim #f2a0c6 / #1c0a14 / #8f5170 pink signal — ink on it / dimmed fill (disabled checks). Signal = what you can act on or have picked: links, primary action, selection, active tab/item, focus. Never status or info (use --info/--ok/--warn/--err); the heatmap ramp is the one data use
--ok / --warn / --err #74c58c / #d9b36a / #e07a72 status (--danger-ink = text on --err fills)
--info / --violet #7a9cc4 / #9a86c8 extended status (info text, special labels)
--font Iosevka, Iosevka Fallback, ui-monospace, Menlo, monospace everything
--fs-xs … --fs-2xl 0.6875rem 0.78125rem 0.875rem 1rem 1.25rem 1.75rem type scale — rem, so 11 / 12.5 / 14 / 16 / 20 / 28px at the default 16px root, and it follows a raised browser font size. A host whose own stylesheet sets a different html { font-size } (check the gitea theme) must re-pin the --fs-* tokens in px
--shadow / --shadow-sm / --shadow-xs / --shadow-lg 4px 4px 0 / 2px 2px 0 / 1px 1px 0 / 6px 6px 0 black hard offset shadows (--shadow-xs = tiny raised chips like .kbd; --shadow-lg = dialogs)

Semantic layer — what components read

token alias of role
--surface-base --bg page background
--surface-raised --panel windows, cards, sticky header
--surface-raised-2 --panel2 hover fills + floating chips (toasts, pills, tooltips, board cards)
--surface-recessed --inset sunken: inputs, badges, log view, progress track, toolbar
--surface-overlay --panel modal dialog + dropdown-menu chrome
--text-primary / --text-secondary / --text-tertiary --text / --muted / --faint body / labels / placeholders & line numbers
--border-weak / --border-strong --line / --line-strong hairlines / container outlines
--signal / --signal-ink --accent / --accent-ink the signal color + the ink that sits on it

Stacking

--z-header: 30 (sticky .app-header + mobile .tabbar share the plane) · --z-dropdown: 40 · --z-tooltip: 50 · --z-toasts: 100 (only the fallback for browsers without the Popover API — real toasts and modal <dialog>s live in the top layer, above all of these).

Geometry

Shared measures the components consume — one-off dimensions stay inline:

token value consumed by
--gutter --s4 (12px ≤640px) .content, .app-header page gutter
--control-h / --control-h-sm 1.875rem (30px) / 1.5rem (24px) one height for every single-line control — .btn, .filelabel, .input, .select, .tab, .seg-item, .pill line up in a row; -sm = .btn--sm (the WCAG 2.2 target minimum). .btn--icon is square at either size
--control-pad 0 var(--s3) .btn, .filelabel (height comes from --control-h)
--field-pad 7px 9px .textarea; single-line fields keep only the 9px inline padding
--gap-tight 6px icon ↔ label inside a control, tight button groups (.actions)
--s0 2px hairline gaps — .tabs, .strip segments, heatmap cells
--titlebar-pad 3px 8px .win-bar, .dlg-bar, .diff-bar, .board-col-bar
--cell-pad 10px .tbl cells (x-step in .md tables)
--dot-size / --heat-cell 9px / 11px (9px ≤640px) .dot / .heatmap cells
--progress-h / --toast-offset 6px / 16px .progress / .toasts corner inset
--radius / --radius-sm 0px / 0px every component / tiny chips (inline code)

Theming

Rounded variant. Every component carries border-radius: var(--radius) (tiny chips use --radius-sm); both default to 0px, so OS1 stays square. Set them once on :root for a coherent rounded theme — titlebars and first/last .rowlist rows follow the container corners automatically. The checkbox stays square by design (the radio is a circle).

Light theme. Opt in per page with <html data-theme="light"> (any subtree element works too). It is a real paper-light pass, not an inversion: warm-grey surfaces (--bg #ece9e8, raised #f8f6f5, recessed #e3dfde), the titlebar signature flips to a dark strip (#1a161d) with light ink, and the signal pink deepens (#f2a0c6 → #a03e6d) because light pink fails on paper. Every text pair clears WCAG AA (4.5:1) on its actual surface — verified with the luminance formula for primary/secondary/tertiary text and all five status colors on all four surfaces, plus accent-on-paper, ink-on-accent, titlebar pair, badge text, warnbox tint and the diff stat mixes. Shadows keep the hard offset with translucent ink (rgba(26,22,29,.16…24)). No auto-switching: to follow the OS preference, either flip the attribute before first paint

<script>
  if (matchMedia("(prefers-color-scheme: light)").matches)
    document.documentElement.dataset.theme = "light";
</script>

or mirror the token block under a media query in app CSS (keep it in sync with [data-theme="light"] in src/tokens.css):

@media (prefers-color-scheme: light) {
  html:not([data-theme]) {
    /* paste the [data-theme="light"] token block from src/tokens.css */
  }
}

Two baked constraints: the checked-checkbox mark is a data-URI SVG that can't read custom properties, so each theme ships its own mark ink — keep the two hexes in sync with --accent-ink when retuning the signal color. And the dialog ::backdrop scrim is a fixed dark rgba on purpose: a dimming layer reads right over both themes.

Themes

Two sibling repos carry the OS1 spec onto other surfaces. Both build from the design system — npm package first, ../design-system sibling checkout as fallback — and stamp their output with the exact DS build they consumed. Since ds 0.4.0 the fonts are files, not inlined data URIs: each theme handles its own delivery.

repo builds font delivery
gitea-theme dist/theme-os1.css — the ds bundle + Gitea variable bridge (gitea-vars.css) + selector map (gitea-map.css), minified; deploy to custom/public/assets/css/ build rewrites the url(fonts/… prefix to /assets/fonts/ and copies the woff2 to dist/fonts/ — scp them to custom/public/assets/fonts/
homeasistant-theme themes/os1.yaml — the HA theme, generated from the DS tokens (raw palette → HA variables, ha-* design tokens, state colors) + preview/theme.css replica www/os1-fonts.js injects the @font-face rules pointing at /local/fonts/… — also local()-first, so an HA host with Iosevka installed never fetches them
# gitea
cd ../gitea-theme && npm run build
scp dist/theme-os1.css gitea:/custom/public/assets/css/
ssh gitea mkdir -p /custom/public/assets/fonts
scp dist/fonts/*.woff2 gitea:/custom/public/assets/fonts/

# home assistant
cd ../homeasistant-theme && npm run build
scp themes/os1.yaml ha:config/themes/
ssh ha mkdir -p config/www/fonts
scp www/fonts/Iosevka-*.woff2 ha:config/www/fonts/
scp www/os1-fonts.js ha:config/www/

Performance

  • Bundle size, exactly. dist/ds.css is ~31 KB minified. The two Iosevka woff2 (Iosevka-Regular.woff2 + Iosevka-Bold.woff2, 191.6 KB raw) ship as separate files in dist/fonts/ — cacheable, and fetched only when the system has no installed Iosevka (the local() sources match first; on a font-equipped machine zero font bytes cross the network). dist/ds.js is ~16 KB minified (helpers + the 44-icon registry). The charset dominates the font size by design — don't shrink it (see Fonts).
  • Delivery. The woff2 files sit next to ds.css and load per the normal font fetch rules: the render-blocking stylesheet arrives in the first request, text renders in the "Iosevka Fallback" face and swaps in (or never swaps, when local() already matched). Apps that want the font earlier can <link rel="preload" as="font" type="font/woff2" crossorigin> the two files — there are no data URIs inside the stylesheet anymore. Relative url(fonts/…) resolves against the stylesheet URL, so anything that serves ds.css from a flat path just needs the /fonts/… route (see Integration pattern).
  • Metric-compatible fallback. --font is "Iosevka", "Iosevka Fallback", ui-monospace, … — the "Iosevka Fallback" face (declared at the bottom of src/tokens.css) is the local system mono (Menlo, then Consolas/DejaVu) resized to Iosevka's real metrics, so a swapped, failed or missing Iosevka reflows nothing. The metrics were read from the shipped woff2 hhea tables: unitsPerEm 1000, ascent 965, descent 215, lineGap 70 (Iosevka's 1.25 line), advance 0.5em. size-adjust: 83.3333% converts Menlo's 0.6em advance to 0.5em; the ascent/descent/line-gap overrides restate Iosevka's line metrics on the scaled face and are therefore pre-divided by 0.83333 — override percentages compose with size-adjust in Chrome (verified by measurement, not assumption). Measured result in Chrome 143: identical single-line boxes (25.5px vs 25.5px at 20px) and an identical paragraph wrap — same height (105px vs 105px at 14px/1.5 in a 320px column), where the old raw ui-monospace fallback ran +20% wider and one line taller (+21px). One face covers 400–700 (font-weight: 400 700); it's a swap-time stand-in, not a typographic statement. Verified in Chrome 157 (Playwright, macOS).

Retheme recipe. Override the semantic layer on :root (surfaces, text ramp, borders, --signal) — or just the raw swatch you want to retune, the aliases follow — and set --radius/--radius-sm for rounded corners. Preview both modes in preview/gallery.html (theme section).

Accessibility

Targets WCAG 2.1 AA (plus the 2.2 24px touch-target minimum). Verified with the relative-luminance formula; preview/gallery.html demos the keyboard behavior.

Contrast

Every text pair clears 4.5:1 on the surface it actually renders on, in both themes (UI sizes stay under the large-text threshold, so no 3:1 exceptions are used). --faint was retuned for AA — it is the dimmest step on the faint→muted hue line that clears 4.5:1 on all four dark surfaces (--panel2 binds):

dark pair before after
--faint on --bg 3.06 5.09
--faint on --panel 2.91 4.83
--faint on --panel2 2.75 4.56
--faint on --inset (placeholders) 3.12 5.19

The 4.5:1 floor pins --faint near the bottom of the usable range, so the ramp is kept distinct by moving --muted up instead: dark #938c98 → #a29aa7, light #5e565b → #4d474b. That takes the muted→faint step from ~1.19:1 (dark) / ~1.13:1 (light) — visually the same grey — to ≈1.4:1 in both themes, with text→muted still ≈2.2:1 (dark) / ≈1.8:1 (light). Both moves only raise contrast; the small uppercase labels that read --muted (table heads, fields, badges, .kv keys) gain the most.

Hover on flat surfaces. --panel2 sits only ~1.06:1 above --panel, so the .tbl / .row hover fill alone barely registers; a 2px inset --border-strong edge bar carries the hover instead (a brighter fill would drop --faint below 4.5:1 on it).

Supported

area behavior
text size the --fs-* scale is rem, so a raised browser default font size scales all UI text (spacing stays px)
color-scheme dark on :root, light under [data-theme="light"] — native select popups, scrollbars, form controls match the theme
focus global 2px --signal :focus-visible outline; inputs/selects carry their own; .filelabel shows the ring when its hidden file input has keyboard focus
disabled :disabled / aria-disabled="true" buttons ignore hover and press (a disabled danger button never fills red); disabled fields dim
invalid fields aria-invalid="true" (or :user-invalid) → err border and err focus ring; pair with a .field-error referenced by aria-describedby
menus ↓/↑ open and rove items (wrap-around), Home/End jump, Enter/Space activate, Tab closes, Esc closes + refocuses the trigger. role="menu"/menuitem/aria-haspopup="menu" are set by ds.js — app markup stays plain; items may be <button> or <a>
tabs roving tabindex (one tab stop per group), ←/→/Home/End move focus, Enter/Space or click activates, active tab gets aria-current="true". Deliberately not tablist/tabpanel: these tabs double as page navigation, and arrows move focus (activation stays explicit)
dialogs native <dialog> (free focus trap + Esc); title bar wired via aria-labelledby, DS.dialog({label}) names title-less dialogs, DS.prompt links label/input with for/id
toasts the .toasts container is role="status" + aria-live="polite" (also when an app pre-creates it) — messages announce without moving focus; errors use the same polite channel, no duplicate live regions
reduced motion prefers-reduced-motion: reduce stops the .cursor/.dot--busy blinks and the toast in/out motion (show/hide instantly); nothing else in the system animates
touch targets .btn--icon.btn--sm is ≥ 24×24px (WCAG 2.2 2.5.8); the 15px glyph and row rhythm are unchanged
tooltips Esc dismisses the [data-tip] bubble (1.4.13); released by the next pointermove or any other key. Icon-only controls (no text, no aria-label) get data-tip copied into aria-label at load — after re-rendering markup, run DS.hydrateTips() (and DS.hydrateIcons()) yourself

Known limits

  • data-tip names, not describes. When a control already has a visible label or aria-label, the tip is decorative — it is not exposed as an accessible description, and the CSS ::after bubble itself never reaches assistive tech. Only icon-only controls get it as their aria-label.
  • Errors stay polite. kind="err" changes color only; announcements don't interrupt.
  • Tab arrows move focus, not selection — activation is an explicit Enter/Space/click.
  • The tooltip's clip risk inside overflow: hidden ancestors remains (documented in Components); dismissal works regardless.

Components

  • app shell: .app-header sticky page header (› optional .tabs nav, .spacer), .content page column (.content--narrow = 680px reading width), layout conventions under App shell & layout
  • window: .win (+ .win--flat) › .win-bar / .win-body / .win-foot, .display inverted heading chunk, .widget-body
  • badges: .badge, --solid, --accent, --inverse, --ok/--warn/--err
  • buttons: .btn, --primary, --danger (err ink + tinted edge at rest, err fill on hover), --icon, --sm, --plain. Hover order is deliberate: a default .btn only brightens its edge and face, so it never outshouts --primary (the button-as-text reset for keyboard-a11y conversions); .filelabel upload
  • inputs: .input, .textarea, .select (draws its own ▼ in the theme ink), .field label, .field-row (stacks ≤640px), .checkline (one control, or a group of <label>s that wraps whole options), .switch toggle (label › hidden checkbox › .switch-slider). States: aria-invalid="true" or :user-invalid → err border (+ .field-error message, linked via aria-describedby), [readonly] → flat, :disabled → dimmed like .btn
  • selection state: style follows ARIA as well as .active — .tab and the .pager current page also match aria-current="page"|"true", .seg-item matches aria-pressed/aria-selected="true", .dropdown-item aria-checked/aria-current="true"; selected .tbl rows and .rows take .selected or aria-selected="true" (raised fill + signal edge bar)
  • data: .tbl (+ .tbl--stack responsive, td[data-label]), .rowlist › .row, .card, .tiles › .tile launcher grid (labels render at the component default --fs-sm, 12.5px — smaller than body text by design), .history-row-style rows are app-level
  • nav: .tabs › .tab(.active), .tabbar bottom nav, .crumbs
  • overlays: dialog.dlg › .dlg-bar / .dlg-body / .dlg-foot, .dlg-x close button, .toasts › .toast(--info/--warn/--err/--out)
  • status: .dot(--ok/--warn/--err/--busy/--off), .pill, .statusline, .warnbox, .banner(--ok/--warn/--err) page-level notice, .strip heartbeat bar (fixed equal segments, classless = ok, b.warn/.err/.off; per-segment tooltips via native title; aria-hidden the visual, keep the numbers in text)
  • content: .md rendered markdown (headings, lists, quotes, alerts, tasklists, tables, code), .diff-file › .diff-bar / .diff-line(.add/.del/.ctx) with .word-add/.word-del
  • menus: .dropdown › .dropdown-menu(--right/--up) › .dropdown-title / .dropdown-item(.active/--danger) / .dropdown-sep; [data-tip] css-only tooltip (data-tip-below flips it down, .tooltip-left/.tooltip-right pin it to the host's edge; clipped by overflow: hidden ancestors)
  • boards: .heatmap › .cell(.l1…l4) + .heatmap-legend (scrolls in its own box when the grid outgrows the container; cells drop to 9px ≤640px); .board › .board-col › .board-col-bar(.count) / .board-col-body › .board-card(.done) — columns stay ≥240px and scroll sideways inside the board when the container is too narrow; they stack vertically ≤640px
  • accordion: details.acc › summary.acc-bar › .acc-body — native <details>, no JS; titlebar microtype, ■ swaps to ▼ while open
  • pagination: .pager — .btn--sm page buttons, .active current page (pink inset underline), [disabled] prev/next at the ends
  • segmented control: .seg › .seg-item(.active) — tabs joined into one box (shared borders), active = pink inset underline + raised fill
  • skeleton: .skeleton(--line/--title/--inline) — static fine-dither loading placeholder, no shimmer, no animation
  • keys: .kbd — single-line key cap (recessed face, --shadow-xs edge)
  • misc: .progress (<b> fill / <i> square marker), .logview, .empty, .dither(--fine/--dense/--halftone), .burst, .cursor, .muted/.faint/.accent/.nowrap/.ellip/.clamp2/.mono-file, .kv key/value rows, .codefield copyable one-line value ([data-copy] button wired by ds.js via DS.copy), .icon-svg inline stroke icons (--lg = 22px), .has-tabbar page-column clearance

ds.js

DS.toast(msg, kind?, ms?)                 // kind: ok | info | warn | err; ms=0 stays
DS.confirm(msg, {title, okLabel, cancelLabel, danger}) -> Promise<boolean>
DS.prompt(msg, value?, {title, ...})                -> Promise<string|null>
DS.dialog({title, label, body, footer, static})      -> {el, open, close}
DS.tabs(container, onChange?)                       -> {set(index)}
DS.icon(name, cls?)                                 -> inline SVG string (from src/icons.json)
DS.hydrateIcons(root?)                              // replace <i data-icon="name"> placeholders
DS.hydrateTips(root?)                               // copy data-tip → aria-label on label-less controls
DS.copy(text)                                       -> Promise<boolean> — clipboard write, execCommand
                                                     //   fallback for plain-http LAN origins
DS.esc(s)                                           // HTML-escape for markup interpolation
DS.icons                                            // the raw registry object
DS.version                                          // build identity, e.g. "v0.3.1+89934b3"

Re-rendered markup (poll loops via innerHTML, dialogs, generated rows) skips the automatic load-time hydration — call DS.hydrateIcons(el) and DS.hydrateTips(el) on the new markup after injecting it. Without the tips pass, icon-only buttons would show the CSS tooltip but carry no accessible name (three apps hit this independently before hydrateTips was exported).

Dialogs created by ds.js get a .dlg-x ✕ close button in the titlebar (closing a confirm/prompt counts as cancel). ds.js also wires light-dismiss for every dialog.dlg in the page: clicking the backdrop closes it (guarded so drags that started inside don't). Dialogs holding unsaved state opt out — set data-static on the <dialog> (or pass {static: true} to DS.dialog); Esc and .dlg-x still close it.

Icons

All UI icons of the six apps live in src/icons.json — one entry per glyph with category, source repo, and a usage note. The registry is injected into dist/ds.js at build time; no icon paths are hardcoded in app markup anymore.

DS.icon("download")                 // '<svg class="icon-svg" …><path …/></svg>'
DS.icon("edit", "chev")             // extra class forwarded
DS.hydrateIcons()                   // replaces <i data-icon="name"> placeholders (auto-runs on load)
DS.icons                            // raw registry for lookups

Static markup uses placeholders; classes on the <i> are forwarded to the svg:

<i data-icon="settings"></i>
<i class="type-ic" data-icon="torrent"></i>
  • overview: npm run preview → /preview/icons.html — every glyph grouped (actions / transfer / nav / content / infra / feeds / favicons) with name, source repo, and usage. To change an icon, edit its d in src/icons.json, rebuild, refresh.
  • canonical merges: variants that differed per app were unified — download (was 4 variants), upload (2), edit (2, already identical), clock (Lucide r10 variant kept).
  • favicons are part of the registry too (favicon-* entries, one per app) — pink-stroke glyphs on a solid #0c0a0c tile, in the same style as the dashboard tile icons. The build writes each one to dist/icons/favicon-<name>.svg (tile rect stripped — transparent, so the browser tab strip shows through) plus a rasterized 180×180 dist/icons/apple-touch-icon-<app>.png (rasterized from the path data by scripts/make-icons.mjs — favicon glyphs must stay stroke-only paths, the registry test enforces it); apps reference those files from the installed package instead of keeping local copies. The Rust wiring is the same few lines in every app — see Integration pattern.
  • most glyphs are Lucide paths (24px viewBox, stroke-width 2, round caps) — keep new icons in that style.

App shell & layout

Conventions shared by the six apps — page CSS may add app-specific rules on top, but start from these:

  • header: <header class="app-header"> — sticky, --panel background, padding: var(--s3) var(--s4) (12px 16px; 12px all around on mobile), gap: var(--s2), flex-wrap for narrow screens. Children in order: .display app name (lowercase) › optional .muted subtitle › optional .tabs nav row (regular .tabs/.tab, vertically centered) › .app-header .spacer › actions (.btn, .input, .filelabel, .pill).

  • main: <main class="content"> — full width for dashboards and grids, plus content--narrow for reading-focused columns (<main class="content content--narrow">, capped at --content-narrow, 680px, centered). The class provides the gutters: padding: var(--s4) (--s3 on mobile) and padding-bottom: var(--s6). Don't hand-roll max-width/padding on main in app CSS.

  • spacing: use the --s0…--s6 tokens instead of bare px values — the components do too (the only literals left are 1px optical chip padding and the tabbar clearance); the page gutter is --gutter (16px, 12px on mobile) if you need it outside .content.

  • status tones: .badge--ok/--warn/--err and .banner(--ok/--warn/--err) (and .warnbox) only set a component-scoped --tone; the recipe reads it once. An app-level tone is one line, e.g. .banner--violet { --tone: var(--violet) }.

  • mobile (≤640px): 12px gutters. Fields render at 16px on phones and pointer: coarse screens (built in — prevents iOS zoom-on-focus; drop any app-level copy of that rule), and .field-row stacks one field per line. Two more responsive behaviors ship built-in: .board columns stack vertically (each column becomes a full-width row; count bars and card styles unchanged), and .heatmap cells drop to --heat-cell: 9px while the grid scrolls inside its own box — the page never widens, at any width (a 52-week grid ≈ 690px also overflows a .content--narrow column on desktop, where the same cap applies).

  • bottom nav clearance: .tabbar is position: fixed and will cover the end of the page. Keep it opt-in per app: add the .has-tabbar utility to the page column — <main class="content has-tabbar"> — which raises bottom padding to calc(64px + env(safe-area-inset-bottom)). Pages without a tabbar must not use it.

  • grids that hold wide children: the .heatmap cap (above) works when its ancestors can shrink — in block or flex layouts that's automatic. Inside a grid parent, give the column minmax(0, 1fr) (or min-width: 0 on the item) so the track can't be stretched by the grid's intrinsic min-content — the standard grid-blowout fix, used by the gallery's own page styles.

  • icons: use the shared registry — <i data-icon="name"> (hydrated by ds.js on load) or DS.icon(name) for JS-built markup; 15px, 24px viewBox, stroke-width: 2, round caps, currentColor — no emoji in UI chrome:

    <i data-icon="edit"></i>   <!-- renders <svg class="icon-svg">…</svg> -->
    
  • favicon: #f2a0c6 glyph (stroke-width 2, round caps) — the same glyph the app gets on the dashboard. The svg ships transparent (the registry's tile rect is stripped at generation): the browser composites the glyph straight onto its tab strip, so dark and light themes both work. Safari ≤ 18 ignores SVG favicons and falls back to its default page icon — accepted trade-off, the only browser in use is Safari 26+ (renders transparent SVGs fine). Generated from the registry into dist/icons/ by npm run build, referenced straight from the package:

    <link rel="icon" type="image/svg+xml"
          href="./node_modules/@nullwert/design-system/dist/icons/favicon-dl-manager.svg" />
    <!-- apple-touch-icon: solid #0c0a0c PNG, generated alongside -->
    <link rel="apple-touch-icon" sizes="180x180"
          href="./node_modules/@nullwert/design-system/dist/icons/apple-touch-icon-dashboard.png" />
    
  • app name: lowercase everywhere — <title>, header .display, breadcrumbs.

Integration pattern (used by the six apps)

Apps serve dist/ds.css + dist/ds.js next to their HTML and keep a small page-level <style> for layout only. Since ds 0.4.0 they also serve the two woff2 files — the CSS's url(fonts/…) resolves to /fonts/… when the stylesheet is served flat, and the local() sources in the @font-face rules mean machines with Iosevka installed never request them:

  • dl-manager (Node http): serveAsset() in src/server.ts reads node_modules/…/dist/ per request (ds.css, ds.js, fonts/*.woff2), <link> + <script src>.
  • ebook (axum): include_str! handlers for /ds.css /ds.js + include_bytes! font routes in web.rs.
  • launchctl (axum): same pattern in main.rs.
  • dockhand (axum): same as ebook, plus an esbuild-bundled CodeMirror editor (/cm.js) themed from ds tokens.
  • uptime-server (axum): same pattern in web.rs.

Favicons (Rust apps) — the canonical recipe

Every Rust app embeds its registry favicon (the transparent svg) the same way; this is the pattern to copy (it lives verbatim in ebook, launchctl, dockhand and uptime):

// build.rs — resolve from node_modules, fail loudly, rebuild when it changes
let ds = std::env::var("CARGO_MANIFEST_DIR").unwrap() + "/node_modules/@nullwert/design-system/dist";
for f in ["ds.css", "ds.js", "icons/favicon-<app>.svg"] {
    let p = format!("{ds}/{f}");
    println!("cargo:rerun-if-changed={p}");
}
let favicon = format!("{ds}/icons/favicon-<app>.svg");
assert!(std::path::Path::new(&favicon).exists(), "dist/icons/favicon-<app>.svg missing — run npm install (design-system >= 0.3.x)");
println!("cargo:rustc-env=DS_FAVICON_PATH={favicon}");

// main.rs / web.rs — embed and serve
const DS_FAVICON: &str = include_str!(env!("DS_FAVICON_PATH"));
// route /favicon.svg → (["image/svg+xml"], DS_FAVICON)
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<!-- optional, iOS home-screen: dist/icons/apple-touch-icon-<app>.png -->

The build.rs rerun-if-changed lines mean a DS bump re-embeds everything on the next cargo build — no copy steps anywhere.

Class-name collisions to watch for

App markup must not reuse ds component classes for other meanings. dl-manager's content panes were renamed section.tab → section.pane (ds .tab is the tab button), and its generic .row flex helper coexists with ds .rowlist .row via a late override in the page style.

No committed fallback copies anymore

The apps used to keep committed copies of ds.css / ds.js as an offline fallback; every consumer has since dropped them. All apps resolve the package from node_modules — the Rust build.rs scripts hard-fail with a "run npm install" hint when it's missing — so a fresh clone needs npm install (registry token required) before cargo build, and after a DS release there is nothing to copy: bump the dependency, rebuild, done.

Dependencies

Development Dependencies

ID Version
esbuild ^0.28.2
stylelint ^18.0.0
subset-font ^2.9.0
Details
npm
2026-11-08 23:38:25 +01:00
2
latest
230 KiB
Assets (1)
Versions (16) View all
0.9.0 2026-11-08
0.7.0 2026-11-07
0.6.0 2026-11-06
0.5.0 2026-11-06
0.4.0 2026-11-06