ADR-0010 — A design system for the admin, with no framework and no build¶
TL;DR. The admin builds its HTML by gluing text strings together, with no
"component" layer and no spacing/typography scale. To turn it into a system without betraying the
"zero dependency / 100% local" constraint, we lay four building blocks, from the bottom
up: (1) CSS tokens for scales, (2) an html `` function that escapes by
default plus a thin set of vanilla component functions, (3) a micro-router
that formalizes views, (4) a reusable list/thumbnails toggle. We roll out
screen by screen, pilot = the Sessions page.
Status: proposed (2026-08-30). Visual specs live in
docs/design/design-system-admin.md; this ADR carries the structure.
Context¶
The entire front end fits in admin/index.html (~1960 lines: <style>, HTML, vanilla JS).
Rendering goes through render*() functions that concatenate strings set as
innerHTML. A strong project constraint: no bundler, no front-end npm, everything
local. Colors are already CSS variables (light/dark); the rest (spacing,
typography, radii) is hard-coded and drifts.
Five structural facts make a "pro" polish pass painful:
- Rendering by concatenation → manual escaping (
esc(),attr()), to be recalled at every sheet; one miss = injection. - No component layer: the same chip / badge / avatar is rewritten in 4
functions. Only two helpers (
lockChip,portChip) are extracted — proof that the pattern is useful, and that it is missing everywhere else. - No scale token: 13 font sizes, ~10 radii, ad hoc paddings. No single "button" to turn to tighten the visual rhythm.
- View mixed with rendering:
VIEWis reassigned inside therender*()functions. Rendering has the side effect of navigating. Sessions is already a hand-coded exception (early-return, dedicated timer and diff). - Nested modals → resume flags (
zonesResumePolicy…) to reopen the parent modal.
The padlock countdown already patches textContent in place instead of re-rendering, so as
not to destroy the DOM: the team knows that re-rendering breaks focus/scroll and hand-codes
the exception.
Decision¶
Four building blocks, dependencies pointing downward (a page depends on components and tokens; never the reverse).
-
CSS scale tokens. Extend
:root:--sp-*,--fs-*,--r-*,--fw-*,--elev-*. Theme-neutral → the dark block redefines only colors. -
**
html` + component functions.** A *tagged template* (~15 lines) that escapes every interpolation by default, with an explicit marker for fragments that are already safe. It eliminates the "I forgotesc()" class of bug. On top of it, aui.*module of **pure string→string** functions (chip,badge,card,avatar,iconBtn,pageHeader) — hence testable undernode`, without jsdom. -
Micro-router. Extract
go(mode, params)which setsVIEW, callssyncChrome, and dispatches to the rightrender*. Therender*functions stop mutatingVIEW. A per-route poll registry replaces the three ad hoc loops. Rule: a surface is a page if it has its own data flow / refresh cadence; otherwise it is a modal. Pages = list, detail, sessions, and Setup (candidate for promotion). -
List/thumbnails toggle. A page concern, not an item concern:
VIEW.layoutpersisted inlocalStorage, the page chooses betweenaccountRow()andaccountCard()over the same view-model. Wired into the page header, not into the item renderer.
Sequence: foundations (tokens) → component layer → screen by screen, pilot = Sessions.
Options considered¶
- Native Web Components (
customElements) for everything — rejected for now. Real encapsulation and surgical updates, but an oversized paradigm shift for a single-dev POC. Reserved for the 2 widgets truly penalized by re-rendering (countdown padlock, session card) if the pain persists after P1. <template>+cloneNode— rejected. Automatic escaping viatextContent, but verbose imperative filling that composes poorly for lists/variants and clashes with the "innerHTML replaced wholesale" model.- Micro-framework via CDN (lit-html, Alpine, Preact) — rejected. Directly violates "zero dependency / no front-end npm".
Consequences¶
- + A single place for the visual rhythm: the "pro" pass becomes an edit of variables.
- + Escaping stops being a diffuse risk (
html`` by default). - + Screen-by-screen migration, no big bang; each slice shippable on its own.
- − Bootstrap cost (tokens + helpers) before any visible gain.
- − Discipline to maintain: no color/spacing outside a token. Safeguard: grep in review.
Risks & safeguards. Dark theme: invariant "the dark block redefines only colors".
Reduced-motion: do not animate in JS. <dialog> focus: a small modal-stack
manager removes the resume flags. No layout shift (the
real danger): before a full re-render, capture/restore document.activeElement and
do not re-render while a modal is open above the list. Tests: the front end
is not covered (scripts/test.sh tests the API only); a light safety net = run the
pure functions (including html ` andui.*) undernodeintest.sh, with an
injection test">