@nullwert/design-system (0.5.0)
Installation
@nullwert:registry=https://gitea.think-jeckel.de/api/packages/nullwert/npm/npm install @nullwert/design-system@0.5.0"@nullwert/design-system": "0.5.0"About this package
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/ (155 classes, 69 token names, 42 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
- Gitea → Settings → Applications → create a token with
package:write(publishing) orpackage:read(consuming). - Copy
.npmrc.exampleto.npmrcandexport GITEA_NPM_TOKEN="<token>", thennpm publish— or just push tomainand 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 from node_modules/…/dist/ per request (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 — css/js/fonts embedded, fonts served at /fonts/…; 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 / #938c98 / #635c68 |
text |
--accent / --accent-ink / --accent-dim |
#f2a0c6 / #1c0a14 / #8f5170 |
pink signal — ink on it / dimmed fill (disabled checks) |
--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 |
--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-pad |
5px 12px |
.btn, .filelabel |
--field-pad |
7px 9px |
.input, .textarea, .select |
--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.cssis ~31 KB minified. The two Iosevka woff2 (Iosevka-Regular.woff2+Iosevka-Bold.woff2, 191.6 KB raw) ship as separate files indist/fonts/— cacheable, and fetched only when the system has no installed Iosevka (thelocal()sources match first; on a font-equipped machine zero font bytes cross the network).dist/ds.jsis ~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.cssand 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, whenlocal()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. Relativeurl(fonts/…)resolves against the stylesheet URL, so anything that servesds.cssfrom a flat path just needs the/fonts/…route (see Integration pattern). - Metric-compatible fallback.
--fontis"Iosevka", "Iosevka Fallback", ui-monospace, …— the "Iosevka Fallback" face (declared at the bottom ofsrc/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 woff2hheatables:unitsPerEm 1000,ascent 965,descent 215,lineGap 70(Iosevka's 1.25 line), advance0.5em.size-adjust: 83.3333%converts Menlo's0.6emadvance to0.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 withsize-adjustin 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 rawui-monospacefallback 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). Light-theme values were already AA and are untouched:
| 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 mathematically caps how far --faint can sit below
--muted (~1.2:1 between the two greys); hierarchy still reads via the
color step and size.
Supported
| area | behavior |
|---|---|
| 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 |
| 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-tipnames, not describes. When a control already has a visible label oraria-label, the tip is decorative — it is not exposed as an accessible description, and the CSS::afterbubble itself never reaches assistive tech. Only icon-only controls get it as theiraria-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: hiddenancestors remains (documented in Components); dismissal works regardless.
Components
- app shell:
.app-headersticky page header (› optional.tabsnav,.spacer),.contentpage column (.content--narrow= 680px reading width), layout conventions under App shell & layout - window:
.win(+.win--flat) ›.win-bar/.win-body/.win-foot,.displayinverted heading chunk,.widget-body - badges:
.badge,--solid,--accent,--inverse,--ok/--warn/--err - buttons:
.btn,--primary,--danger,--icon,--sm,--plain(the button-as-text reset for keyboard-a11y conversions);.filelabelupload - inputs:
.input,.textarea,.select,.fieldlabel,.field-row,.checkline,.switchtoggle (label › hidden checkbox ›.switch-slider) - data:
.tbl(+.tbl--stackresponsive,td[data-label]),.rowlist›.row,.card,.tiles›.tilelauncher 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),.tabbarbottom nav,.crumbs - overlays:
dialog.dlg›.dlg-bar/.dlg-body/.dlg-foot,.dlg-xclose button,.toasts›.toast(--info/--warn/--err/--out) - status:
.dot(--ok/--warn/--err/--busy/--off),.pill,.statusline,.warnbox,.banner(--ok/--warn/--err)page-level notice,.stripheartbeat bar (fixed equal segments, classless = ok,b.warn/.err/.off; per-segment tooltips via nativetitle; aria-hidden the visual, keep the numbers in text) - content:
.mdrendered 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-belowflips it down,.tooltip-left/.tooltip-rightpin it to the host's edge; clipped byoverflow: hiddenancestors) - 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 stack vertically ≤640px - accordion:
details.acc›summary.acc-bar›.acc-body— native<details>, no JS; titlebar microtype, ■ swaps to ▼ while open - pagination:
.pager—.btn--smpage buttons,.activecurrent 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-xsedge) - misc:
.progress(<b>fill /<i>square marker),.logview,.empty,.dither(--fine/--dense/--halftone),.burst,.cursor,.muted/.faint/.accent/.nowrap/.ellip/.clamp2/.mono-file,.kvkey/value rows,.codefieldcopyable one-line value ([data-copy]button wired by ds.js viaDS.copy),.icon-svginline stroke icons (--lg= 22px),.has-tabbarpage-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 itsdinsrc/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#0c0a0ctile, in the same style as the dashboard tile icons. The build writes each one todist/icons/favicon-<name>.svg(tile rect stripped — transparent, so the browser tab strip shows through in SVG-capable browsers), a rasterized 32×32dist/icons/favicon-32-<app>.pngon the solid tile (Safari ≤ 18 ignores SVG favicons — without a PNG fallback it shows its white default page icon), and a 180×180dist/icons/apple-touch-icon-<app>.png(rasterized from the path data byscripts/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,--panelbackground,padding: var(--s3) var(--s4)(12px 16px; 12px all around on mobile),gap: var(--s2),flex-wrapfor narrow screens. Children in order:.displayapp name (lowercase) › optional.mutedsubtitle › optional.tabsnav row (regular.tabs/.tab, vertically centered) ›.app-header .spacer› actions (.btn,.input,.filelabel,.pill). -
main:
<main class="content">— full width for dashboards and grids, pluscontent--narrowfor reading-focused columns (<main class="content content--narrow">, capped at--content-narrow, 680px, centered). The class provides the gutters:padding: var(--s4)(--s3on mobile) andpadding-bottom: var(--s6). Don't hand-rollmax-width/paddingonmainin app CSS. -
spacing: use the
--s1…--s6tokens instead of bare px values; the page gutter is--gutter(16px, 12px on mobile) if you need it outside.content. -
mobile (≤640px): 12px gutters, and
.input, .select, .textarea { font-size: 16px }to prevent iOS zoom-on-focus. Two responsive behaviors ship built-in:.boardcolumns stack vertically (each column becomes a full-width row; count bars and card styles unchanged), and.heatmapcells drop to--heat-cell: 9pxwhile the grid scrolls inside its own box — the page never widens, at any width (a 52-week grid ≈ 690px also overflows a.content--narrowcolumn on desktop, where the same cap applies). -
bottom nav clearance:
.tabbarisposition: fixedand will cover the end of the page. Keep it opt-in per app: add the.has-tabbarutility to the page column —<main class="content has-tabbar">— which raises bottom padding tocalc(64px + env(safe-area-inset-bottom)). Pages without a tabbar must not use it. -
grids that hold wide children: the
.heatmapcap (above) works when its ancestors can shrink — in block or flex layouts that's automatic. Inside a grid parent, give the columnminmax(0, 1fr)(ormin-width: 0on 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) orDS.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:
#f2a0c6glyph (stroke-width 2, round caps) on a solid#0c0a0ctile — the same glyph the app gets on the dashboard. The svg ships transparent (tile stripped at generation): SVG-capable browsers composite the glyph straight onto the tab strip, so dark and light themes both work. The raster fallbacks keep the solid tile on purpose — Safari (≤ 18) ignores SVG favicons and would otherwise show its white default page icon, and iOS composites transparent touch icons onto black. Generated from the registry intodist/icons/bynpm run build, referenced straight from the package — PNG link first, SVG second (browsers pick the last format they support):<link rel="icon" type="image/png" sizes="32x32" href="./node_modules/@nullwert/design-system/dist/icons/favicon-32-dl-manager.png" /> <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()insrc/server.tsreadsnode_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 inweb.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 same way — the SVG (for SVG-capable browsers) plus the 32×32 PNG tile (Safari ≤ 18 ignores SVG favicons); 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", "icons/favicon-32-<app>.png"] {
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}");
let favicon_png = format!("{ds}/icons/favicon-32-<app>.png");
assert!(std::path::Path::new(&favicon_png).exists(), "dist/icons/favicon-32-<app>.png missing — run npm install (design-system >= 0.5.x)");
println!("cargo:rustc-env=DS_FAVICON_PNG_PATH={favicon_png}");
// main.rs / web.rs — embed and serve
const DS_FAVICON: &str = include_str!(env!("DS_FAVICON_PATH"));
const DS_FAVICON_PNG: &[u8] = include_bytes!(env!("DS_FAVICON_PNG_PATH"));
// route /favicon.svg → (["image/svg+xml"], DS_FAVICON)
// route /favicon-32.png → (["image/png"], DS_FAVICON_PNG)
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png">
<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 |