@nullwert/design-system (0.3.1)

Published 2026-11-06 08:57:30 +01:00 by nullwert

Installation

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

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: one minified CSS file with the fonts base64-inlined (~283 KB total — ~255 KB of that is the two inlined Iosevka woff2, the CSS itself is ~28 KB minified), plus 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), reset, focus/selection, .cursor
  components.css   all component classes
  textures.css     dither / halftone decorations
  icons.json       icon registry — every stroke icon used across the five 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 single-file bundle (~283 KB: ~255 KB base64 fonts + ~28 KB minified CSS)
  ds.js            helpers + injected icon registry (minified, ~14 KB)
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.

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, 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 five 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 five 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 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 from node_modules (panics if missing — the committed src/assets/ds.* fallback was removed); include_str!(env!(…)) embeds them
launchctl same as ebook (the committed static/ fallback was removed too)
dashboard static page, <link>/<script> straight into node_modules/…/dist/
dockhand build.rs resolves node_modules ds.css/ds.js (panics if missing) and embeds them via include_str! next to its own CodeMirror bundle; serves /ds.css /ds.js; 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 five 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.

Performance

  • Bundle size, exactly. dist/ds.css is ~283 KB (289.9 KB): ~255 KB of that is the two base64-inlined fonts (Iosevka-Regular.woff2 + Iosevka-Bold.woff2 = 191.6 KB raw → 255.5 KB base64 incl. the data: prefixes); the actual CSS is ~28 KB minified. dist/ds.js is ~14 KB minified (helpers + the 40-icon registry). The charset dominates the size by design — don't shrink it (see Fonts).
  • No preload/fetch hints needed. The woff2 files are base64 data URIs inside ds.css itself: the fonts arrive with the render-blocking stylesheet in the same request. There is no second font request to preload, no font-display: swap network window, and no CORS dance — <link rel="stylesheet"> is the whole delivery.
  • 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). 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

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, --icon, --sm; .filelabel upload
  • inputs: .input, .textarea, .select, .field label, .field-row, .checkline, .switch toggle (label › hidden checkbox › .switch-slider)
  • data: .tbl (+ .tbl--stack responsive, td[data-label]), .rowlist › .row, .card, .tiles › .tile launcher grid, .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
  • 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 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, .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.esc(s)                                           // HTML-escape for markup interpolation
DS.icons                                            // the raw registry object
DS.version                                          // build identity, e.g. "v0.3.1+89934b3"

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 five 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) — 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 (plus a rasterized dist/icons/apple-touch-icon-<app>.png); apps reference those files from the installed package instead of keeping local copies.
  • 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 five 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 --s1…--s6 tokens 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: .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: solid #0c0a0c tile with the #f2a0c6 glyph (stroke-width 2, round caps) — the same glyph the app gets on the dashboard. A solid tile is deliberate: transparency would take on the browser tab strip's color (white in light-mode browsers). 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 five apps)

Apps serve dist/ds.css + dist/ds.js next to their HTML and keep a small page-level <style> for layout only:

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

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-06 08:57:30 +01:00
1
218 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