@nullwert/design-system (0.2.0)
Installation
@nullwert:registry=https://gitea.think-jeckel.de/api/packages/nullwert/npm/npm install @nullwert/design-system@0.2.0"@nullwert/design-system": "0.2.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: one minified CSS file with the fonts base64-inlined (~200 KB total), 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 (colors, type, spacing, borders, shadows)
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 four apps
ds.js DS.toast / DS.dialog / DS.confirm / DS.prompt / DS.tabs / DS.closeMenus / DS.icon
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/
dist/
ds.css the single-file bundle (~200 KB, subset fonts inlined, minified)
ds.js helpers + injected icon registry (minified)
preview/
poster.html dark-mode recreation of the poster, built from real components
gallery.html every component, interactive
icons.html icon overview — every glyph, grouped, with source + usage
Commands
npm install # one-time (subset-font + esbuild 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 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.
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 four 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/, falling back to the committed copies in public/ |
| ebook | build.rs sets DS_CSS_PATH/DS_JS_PATH (node_modules, falling back to committed src/assets/ds.*); include_str!(env!(…)) embeds them |
| launchctl | same as ebook, fallback dir static/ |
| dashboard | static page, <link>/<script> straight into node_modules/…/dist/ |
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; apps still build and run against their committed fallback copies.
Changing the design spec
# in design-system/
npm version patch # 0.1.0 → 0.1.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 */
Tokens
| 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 |
#f2a0c6 / #1c0a14 |
pink signal — selection/active/focus only |
--ok / --warn / --err |
#74c58c / #d9b36a / #e07a72 |
status |
--info / --violet |
#7a9cc4 / #9a86c8 |
extended status (info text, special labels) |
--font |
Iosevka, ui-monospace, Menlo, monospace | everything |
--shadow / --shadow-sm |
4px 4px 0 / 2px 2px 0 black |
hard offset shadows |
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;.filelabelupload - inputs:
.input,.textarea,.select,.fieldlabel,.field-row,.checkline - data:
.tbl(+.tbl--stackresponsive,td[data-label]),.rowlist›.row,.card,.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),.pill,.statusline,.warnbox - 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)›.dropdown-title/.dropdown-item(.active)/.dropdown-sep;[data-tip]css-only tooltip (data-tip-belowto flip it down) - boards:
.heatmap›.cell(.l1…l4)+.heatmap-legend;.board›.board-col›.board-col-bar(.count)/.board-col-body›.board-card(.done) - misc:
.progress(<b>fill /<i>square marker),.logview,.empty,.dither(--fine/--dense/--halftone),.burst,.cursor,.muted/.faint/.accent/.nowrap/.mono-file,.icon-svginline stroke icons
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, body, footer}) -> {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.icons // the raw registry object
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).
Icons
All UI icons of the four 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) — 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(plus a rasterizeddist/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 four 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. -
mobile (≤640px): 12px gutters, and
.input, .select, .textarea { font-size: 16px }to prevent iOS zoom-on-focus. -
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: solid
#0c0a0ctile with the#f2a0c6glyph (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 intodist/icons/bynpm 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 four 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): files inpublic/,serveAsset()routes/ds.css/ds.js,<link>+<script src>. - ebook (axum): files in
src/assets/,include_str!handlers for/ds.css/ds.jsinweb.rs. - launchctl (axum): files in
static/, sameinclude_str!pattern inmain.rs.
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.
Updating the committed fallback copies
The committed ds.css / ds.js in each app are offline fallbacks for fresh
clones without npm install (build.rs and serveAsset prefer the package).
Refresh them directly:
node scripts/build.mjs
cp dist/ds.css dist/ds.js ../dl-manager/public/
cp dist/ds.css dist/ds.js ../../rust/ebook/src/assets/
cp dist/ds.css dist/ds.js ../../rust/launchctl/static/
Then rebuild the Rust apps (assets embed at compile time).
Dependencies
Development Dependencies
| ID | Version |
|---|---|
| esbuild | ^0.28.2 |
| subset-font | ^2.9.0 |