@nullwert/design-system (0.1.2)
Installation
@nullwert:registry=https://gitea.think-jeckel.de/api/packages/nullwert/npm/npm install @nullwert/design-system@0.1.2"@nullwert/design-system": "0.1.2"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 dependencies: one CSS file with the fonts base64-inlined, 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
ds.js DS.toast / DS.dialog / DS.confirm / DS.prompt / DS.tabs
fonts/ Iosevka woff2 (latin) + license — fetched by scripts/fetch-fonts.mjs
scripts/
fetch-fonts.mjs downloads the fonts (only step that needs network)
build.mjs concatenates src/*.css, inlines fonts as base64 → dist/
dist/
ds.css the single-file bundle (~2.6 MB, fonts included)
ds.js copy of src/ds.js
preview/
poster.html dark-mode recreation of the poster, built from real components
gallery.html every component, interactive
Commands
npm run fonts # fetch Iosevka woff2 + license (network)
npm run build # rebuild dist/ds.css + dist/ds.js (stamped with version + git hash)
npm run preview # serve repo root at :8123 → open /preview/poster.html
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 |
--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 (›.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 - 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)}
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).
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 ›.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: inline stroke SVGs with
class="icon-svg"(15px, 24px viewBox,stroke-width: 2, round caps,currentColor) — no emoji in UI chrome:<svg class="icon-svg" viewBox="0 0 24 24"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg> -
favicon: inline SVG data-URI in the brand palette —
#0c0a0cbackground rect,#f2a0c6glyph, square corners:<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><rect width='24' height='24' fill='%230c0a0c'/><path d='…' stroke='%23f2a0c6' stroke-width='2' fill='none' stroke-linecap='round' stroke-linejoin='round'/></svg>" /> -
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).