Design system — the web UI¶
The /ui surface (internal/httpapi/templates/) is a server-rendered reference
consumer of the API. Its look is a single token-driven CSS system in
templates/style.css — no build step; Alpine.js for interactivity (see
Interactivity layer)
plus a little inline JS in layout.html. This doc is the contract for that system: the principles, the
tokens, the components, and the theming hooks. The default theme (the values
in :root) is the reference implementation of clean UI — every theme and
every new component is judged against it.
Metaphor: a pre-press job board¶
The page is a silver-grey press sheet with a registration dot-grid. Each card is a white note card printed on it. The UI that controls the cards (nav, filters, modal) is a distinct ink layer floating above the card world.
- Printing stays consistent, stock varies. Black registration marks (lane headers, labels, the corner type mark's ink) are the constant "printing"; colour is "stock" that a type or a board can vary.
- Line weights carry meaning, dividers are dots not rules, chips are rectangles not pills, labels are mono, uppercase, tracked.
Principles (normative)¶
These four rules gate every change; violations are bugs, not taste.
1 · Containers own spacing¶
Spacing is dynamic: rhythm comes from container gap, never from margins
sprinkled on children.
- A component sets no external margin. Its parent spaces it — via
gapon flex/grid containers or the.stack/.row/.clusterprimitives. - One scale only:
--s-1..6. No ad-hoc pixel margins/paddings. - Equal air: a container's padding equals the gap between its children's
level — e.g. card padding (
--s-3) = the lane's card gap (--s-3), so the space inside a card's edge and between two cards reads identical. - Page-level containers (main padding, modal width) use
clamp()/min()so container sizing stays proportional across viewports; components inherit the space, they don't hardcode it.
The mechanical rules (enforce these on every CSS change):
- No element carries a UA or ad-hoc external margin.
h1–h4,p, and the list primitives are reset tomargin: 0globally; a component that wants space around it gets it from its parent'sgap, never amarginon itself. If you catch yourself writingmargin-bottomon a child, the fix is agapon the parent. - A stack of siblings ⇒ the parent is
display: flex; flex-direction: column; gap: var(--s-N). This is how.board-view,.home, the home sections,.home__board-card,.entries-box/.comments-box, and.search-resultsspace their children — adding a child later needs no new rule, the gap already spaces it. - Never double the rhythm. A gapped parent plus a margined child stacks two
spacings. The classic trap (fixed in the 2026-07 spacing pass):
.feed/.entries-boxgapped and.entrycarriedmargin— the visible gap wasgap + 2×margin. The parent owns it; the child has none. - One scale, tokens only. Every spacing and rhythmic size reads
--s-1..6(type reads--t-*/--role-*). No literalrem/pxfor spacing or font-size in a component rule — e.g..icon-btn's glyph isvar(--t-sm), not.8rem. Fixed icon/hairline dimensions (a button's width, a 1px border) may stay literal; everything rhythmic is a token. - Themes remap the scale, never re-add margins. Because rhythm lives in
container
gapreading--s-*, a theme retunes density by remapping--s-3..5(see thejournal/labelsblocks) — it must not reintroduce a per-componentmargin(the labels.board-controls { margin-bottom }override was removed for exactly this reason: the.board-viewgap owns it).
2 · Type is compact but never small¶
- One scale:
--t-xs..xl(body--t-base.9rem/1.5; headings 1.2). Density comes from spacing, not from shrinking type — nothing renders below--t-xs(.7rem). - Roles, not sizes:
--font-sansfor titles/body;--font-monofor labels, meta, ids, buttons, timestamps (the "typed detail" voice — uppercase + tracked when it's a label). - Any text inside a control renders at the size of the display text it edits or sits beside (see Principle 3).
3 · Editing is WYSIWYG¶
Click-to-edit must feel like typing on the card, not opening a form.
- Every editable field is one
.fieldwith a[data-view](display) and a[data-edit](control). The control renders at the same font-size, line-height, weight, and box padding as the view — enforced by the.field__edit .input/.select/.textareaparity rules; extend those rules, never per-field overrides. - Activating edit changes chrome, not geometry: a border/focus ring appears; width, height, and neighbours must not shift.
- The view's empty state and the control's
placeholderuse the same words ("Add a description…" both ways). - This covers selects and enums too: they are restyled (
appearance:none, masked chevron, matching type) so a value doesn't change voice when it becomes editable.
4 · Themes remap tokens, never structure¶
A theme is a set of token values plus (optionally) scoped rules on the public hooks below. Themes must not require markup changes, and structural class names are a stable API.
Tokens (:root, remapped in the prefers-color-scheme: dark block)¶
Everything visual reads through custom properties; the whole system reskins by
remapping :root. Categories:
- Neutrals / substrate —
--c-flat(press sheet),--c-flat-dot,--c-surface(card stock),--c-surface-2/3,--c-ink,--c-text,--c-text-2,--c-muted,--c-faint,--c-border,--c-border-2. - Label stamp —
--c-label-bg/--c-label-fg(inverts in dark). Nav chrome —--c-nav-bg/--c-nav-fgdeliberately do not remap. - Accent + semantic —
--c-accent/-2/-soft,--c-success,--c-danger/--c-danger-soft. - Per-type stock —
--type-<id>(ink) +--type-<id>-bg(wash); a board card reads them as--card-stock/--card-stock-bg(set per[data-type]) to paint its corner type mark. Every-bghas a paired dark value. - Relationship / link hues —
--rel-out/--rel-in,--link-<type>. - Scales — spacing
--s-1..6(4px base), type--t-xs..xl, radius--r-sm/md/lg/pill, shadows--sh-sm/hover/md/lg. - Semantic line weights —
--edge(thick),--stroke(outline),--rule(hairline / dotted divider). - Fonts —
--font-sans,--font-mono. - Typography roles —
--role-*(see "Typography roles" below). These are the tokens a theme actually edits;--font-*/--t-*are the raw scale a role points at. - Modal geometry —
--modal-width,--modal-height,--modal-ratio; a theme shrinks/grows the detail card by overriding these (e.g.labelssets a compact fixed-height card).
Convention: tokens, not literals. The only intentional literals are the nav
chrome colours and #fff/#000 "max ink" hovers.
Typography roles (--role-*)¶
Every text-bearing rule in style.css reads a role token, never a literal
font-size/font-weight/font-family/line-height/letter-spacing. Roles
are the seam between "what a piece of text is" (a card title, a field
label, header metadata, body copy) and "how big/heavy it renders" — so a theme
retunes the whole hierarchy from one block instead of hunting selectors, and a
size/weight can never drift out of sync between two elements that are supposed
to match (e.g. the header's status/owner/id/version/updated used to each carry
their own hardcoded size before this existed; now they all read
--role-meta-*).
| Role | Governs | Tokens |
|---|---|---|
| Heading | h1–h4, lane header, lane count, board card type badge |
--role-heading-weight |
| Strong/emphasis | <strong>/<b>, nav links, secondary chrome |
--role-strong-weight |
| Card title | Board card title (.card__title) |
--role-title-card-font/size/weight/leading/tracking |
| Detail title | Modal/detail title (.card-title__view, its edit .input) |
--role-title-detail-font/size/weight/leading/tracking |
| Field label | .field__label ("TAGS", "STATUS", …) |
--role-label-font/size/weight/tracking (+ leading on the default theme) |
| Header metadata | .modal__meta line — status/owner/id/version/updated |
--role-meta-font/size/weight/leading/tracking, --role-meta-value-weight (status/owner value emphasis) |
| Body/value | .field__val, .field__view, form controls |
--role-body-font/size/weight/leading |
Units. font-size/line-height are rem (root-relative) or unitless
(line-height) — never vw/vh. A vw font resizes with the window, which
means the same element is a different px size on every visitor's screen and a
different size than what you measure in devtools on your own window; rem
resizes only with the root font-size (zoom, or a deliberate html { font-size
} override), which is what devtools "Computed" reports and what a user's
browser zoom setting expects. clamp(min, preferred-vw, max) is fine for
layout (.modal width, main padding — Principle 1) where fluid sizing is
the point; it is deliberately not used for type roles. Hairlines/icons
(--edge/--stroke/--rule, mask-icon width/height) use px, because a
1px border must render as exactly one device pixel's worth of thickness
regardless of the root font-size.
Tweaking in devtools. Inspect the element, open the Styles pane, and find
the custom property the matched rule reads (e.g. .card__title reads
--role-title-card-size). That property is declared once per theme scope
(:root for the default theme, html[data-theme="labels"] { ... } for a
named theme) — edit it there, in the scope shown next to the declaration, and
every element sharing the role updates together. Don't edit the resolved
font-size on the specific rule; you'll fix one element and leave its
siblings inconsistent again.
Adding a theme. Override only the --role-* custom properties that need
to change (usually --font-sans/--font-mono plus a handful of
--role-title-*/--role-label-* weights); the component rules already read
those roles and need no per-selector overrides. The labels theme is the
worked example: it loads Sono (weights 200/400/600) and maps ExtraLight→body,
Regular→emphasis/meta, Semibold→every heading/title/label role — titles are
therefore always bold, by construction, not by remembering to set
font-weight on every title selector.
Components¶
.btn (+ --primary/--ghost/--danger/--sm), .input/.select/.textarea,
.chip (+ --tag/--owner), .card__type-badge (modal/detail/home type
stamp), .card__type-mark (board card's corner stock tab), .card +
.card__title/__meta/__preview/__secondary/__stats + .card__stat[data-stat], .lane +
.lane__head/__count/__body, .modal + __head/__meta/__body/__footer and the
shared card_body block, .field (view/edit), .rel (relationship rows),
.toast, .search, home cards. Layout primitives:
.stack/.row/.cluster/.grid/.between/.muted/.faint/.truncate/.vh.
Combobox (rebuild P5) — the filter-as-you-type enhancement over a native
single <select> for enum/user fields: .combobox / .combobox__control
(carries .select so its geometry is exactly the native control's — WYSIWYG,
no shift on enhance) / .combobox__menu / .combobox__filter /
.combobox__list / .combobox__option (+ --free, .is-active,
.is-selected) / .combobox__empty. Stable theme hooks — renames are
breaking. Token discipline: these rules read neutral + role tokens only,
never --type-* hues (board inline styles override those); pinned by a
docaudit test. The native select stays in the DOM as the submitted control
and the no-JS fallback.
Multiselect (rebuild P6) — the chip control over a native
<select multiple> (multiple enum/user) or the tags comma input:
.multiselect / .multiselect__chips / .multiselect__input / .chip__x /
.chip.is-invalid / .chip-cluster (read-only view wrapper). The dropdown
reuses the .combobox__menu/__option hooks — one menu language. Stable
theme hooks — renames are breaking. Chips are .chip, so the view cluster
and the edit control share sizing tokens (WYSIWYG). The edit form carries a
hidden "" sentinel input so clear-all posts and unsets server-side, JS or
no JS. Tags chips are policy-aware: free-add under open, tag_set-only under
locked (the default). The control fails closed on any other value, matching
Service.validateTags — the two used to disagree, so a free tag the chip
accepted was rejected by the API, losing the whole save.
Icons are monochromatic currentColor mask-images (data-URI SVG) keyed by
[data-type], optional config-emitted [data-icon], and [data-stat] — one
colour, consistent size, no emoji. data-icon wins over type defaults so users
can choose visual identity without changing CSS selectors.
Anatomy¶
- Board card (
card_partial.html): corner type mark (.card__type-mark— wash stock + ink icon; type name in a.vhlabel +title) · title · owner chip · preview line · compact secondary line hook (.card__secondary, hidden by default for dense named themes) · stats row (updated-time left; comment / ↗ out / ↙ in counts right). The card root is not arole=button; the title<a>is the keyboard affordance. - Modal (
card_modal.html→.modal): a note card sized by the--modal-width/--modal-height/--modal-ratiogeometry tokens (defaultmin(1040px, 92vw, 135vh)×3/2), soft shadow, lightly-dimmed board behind. Fixed header (title + one metadata line) and footer (actions); the body is the single scroll region..modal[open]gatesdisplay. A compact theme overrides the geometry tokens (e.g.labels→min(660px,94vw)× fixedmin(80vh,680px)/auto) so detail cards can be smaller than the default; documented presets: standard / compact / wide. - Modal keyboard nav:
Esccloses (or reverts the field being edited);←/→move to the previous/next card on the board (a test affordance — only fires when the modal is open and no input/select/textarea is focused, so editing text and tabbing the board are never hijacked). The board card's title<a>remains the keyboard entry point. - Relationships: outbound = blue ↗ type-label-left; inbound = brick ↙ title-left. Direction = colour + arrow + order.
Theming — the contract¶
Themes hook onto four stable attach points; component class names and
data-* attributes are a public API (renames are breaking changes):
| Hook | Scope | Set by |
|---|---|---|
:root token remap |
whole app | a theme stylesheet / the dark block |
html[data-theme="<name>"] |
named theme | settings.theme (workspace default), overridable per-visitor via ?theme=<name> (sticky cookie; ?theme=default clears). Resolved in httpapi.resolveTheme. |
[data-board="<id>"] wrapper |
one board | Board.theme → httpapi.boardStyle (whitelisted inline tokens) |
.card[data-type="<id>"] + [data-icon="<name>"] |
one card type or styled value | CSS defaults; CardType.type_theme accent/muted override inline — board corner mark as --card-stock/--card-stock-bg, modal/home badge as --badge-ink (printing: text + outline) / --badge-wash (stock: background). type_theme.icon emits data-icon, which overrides the monochrome [data-type] mask glyph. Board presentation.style_field overlays FieldDef.option_themes for the card's enum value on the same hooks (see docs/design/style-field.md). |
Rules:
- Board themes may override only non-inverting hue tokens
(
boardThemeTokenswhitelist: accents, flat, label,--type-*,--link-*,--rel-*) — never neutral/ink/surface tokens, so dark mode keeps working. Example (examples/demo-workspace/definitions/boards/welcome.json):
- Board-tinting the neutral substrate across light+dark would need a generated
@media<style>block (inline props can't respond toprefers-color-scheme); the whitelist deliberately avoids that. - A theme that needs a hook that doesn't exist is a design-system change first (add the hook + document it here), a theme second.
Named themes (html[data-theme])¶
A named theme is one self-contained block at the end of style.css:
html[data-theme="<name>"] { … } — a token remap plus, unlike a board theme,
scoped component rules (fonts, shapes, decoration) that reskin structure
without touching markup. It may override any token, including neutrals, because
it's a full stylesheet scope (not inline props), so it owns its own light/dark
story. Select it with ?theme=<name> (sticky) or a workspace settings.theme
default; the conditional web-font <link> for a theme lives in layout.html
keyed on .Theme.
Workspace-loaded themes. A named theme need not be embedded in
style.css— it can be loaded fromdefinitions/themes/<name>.{css,json}and concatenated after the base stylesheet. Loaded themes are validated at load time byinternal/themecss(braces balance · every rule scoped underhtml[data-theme="<name>"]· no@import· no remoteurl()), and a failing theme is rejected with a422(naming theme/file/line/rule) rather than served — a broken theme degrades to "absent," never to an error. The resolution precedence,board.presentation.themelayering, back-compat, and the font-manifest policy are specified indocs/design/themes.md→ "Load-time contract". The/ui/style.css?v=<stamp>cache-buster is per-composition-generation, so a reload that changes the served CSS also rotates the URL.
The worked reference is journal (?theme=journal): a hand-kept
meeting-notes look — warm paper desk, pastel sticky-note cards scattered at a
slight rotation with varied shadow depth, handwritten type (Caveat/Kalam),
rubber-stamp chips, and a lined-notebook modal with a red margin rule and
taped-on repeating entries. It demonstrates how far a theme can go on the same
tokens + components: the default :root theme stays the reference for clean,
information-dense UI; journal is the proof the contract is expressive.
The second reference is labels (?theme=labels): a compact sorting view
where each board card is small adhesive label stock with a left colour/icon
spine, heavy Sono title, and one cropped secondary metadata row. Sono is loaded
at weights 200/400/600; body copy uses 200, bold/emphasis uses 400, and
headings/labels use 600 (titles are always bold). The detail card is smaller
than the default (--modal-width: min(660px,94vw), fixed height, no ratio) and
uses textured white cardstock generated with an SVG feTurbulence/
feDiffuseLighting data-URI (baseFrequency=.2, numOctaves=6,
surfaceScale=.7). Its header is a two-row attribute table: the top row
(icon · title · close · copy-id) carries the card's solid colour; the bottom
row is four label/value cells (Status · Owner · Updated · Version) with
dark-grey label cells (white text) and white value cells (black text). Only
the title is editable in the header; status/owner are editable fields in the
body (type is read-only — a card's type is immutable). Body field labels are
uniform dark-grey squares with white text, matching the header attribute
labels. It demonstrates that a theme may change hierarchy, density, modal
geometry, and field-label treatment through documented hooks. NB: the labels
detail layout is the one place a theme does branch the shared card_body
template ({{if eq $.Theme "labels"}} in card_modal.html); every other
theme shares one header + body assembly.
Interactivity layer (Alpine.js) — decision + division of labor¶
Adopted in the frontend rebuild (see docs/plans/frontend-rebuild-plan.md):
Alpine.js is the UI's one sanctioned interactivity layer, replacing the
hand-rolled wire*() vanilla JS incrementally. The decision and its rules:
- Self-hosted + embedded, pinned.
templates/assets/alpine.min.js(v3.15.0) ships inside the binary and is served at/ui/assets/alpine.min.js?v=<assetStamp>— no CDN at runtime, survives a future CSP, cache-busted per composition generation like the stylesheet. - Division of labor (normative). Go templates render all server data
and the first paint; Alpine handles ephemeral local state only
(open/closed, drag, filter-as-you-type, dirty tracking). Never
x-forover server JSON — that forks the API contract and breaks no-JS rendering. Enforced byinternal/docaudit/frontend_test.go(x-for allowlist). - One swap seam. Server HTML enters the live DOM only through
swapHTML(container, html)inui.js—innerHTML+Alpine.initTreeon the fresh subtree (never ondocumentor a persistent root — that double-binds) +refreshAgo. Guard-tested: exactly one.innerHTML =in our JS. - JS lives in embedded assets, not templates.
templates/assets/ui.js(behavior) andhelpers.js(pure functions, unit-tested bynode --test tests/js/— zero npm dependencies). Template inline<script>blocks only hand over server parameters (budget-guarded). - CSS drift guards. Type is never sized in
px/vw/vh(test-pinned), and hex color literals are ratcheted — new colors go through tokens. - Component conventions (Alpine
datafactories, combobox/multiselect controls) land phase-by-phase per the rebuild plan; Pinemix components are a behavior reference only — their Tailwind styling is always replaced with our token classes.
Substrate & upgrade path¶
The token layer is hand-rolled (~600 lines, zero dependencies). The sanctioned
investment, if/when taken, is to adopt a standard token substrate —
Open Props (custom-property scales, no build step) and/or Utopia-style
clamp() fluid space/type scales — mapping our --s-*/--t-* names onto it
and keeping the component + theme layers unchanged. Utility-first frameworks
that require a build step (Tailwind et al.) are out of scope: the aesthetic is
bespoke, and the server intentionally has no asset pipeline. Tracked on the
board (substrate card).
Quality floor¶
Dark mode + prefers-reduced-motion supported; keyboard focus visible
(:focus-visible); the native <dialog> modal (Esc / backdrop / × dismiss
identically). Timestamps render via <time data-ago="{{iso …}}"> + the client
refreshAgo() helper — always emit iso (RFC3339), never a raw Go
time.Time.
Definitions visibility (decided 2026-07-12)¶
Workspace schema stays git-backed files + docs; the web UI's job is
visibility, not editing (closed design card card_8b5a4937: the UI only
ever selects subsets of workspace columns/types — board-create — and never
authors field contracts). The sanctioned surface is a read-only JSON view
of the authored definition files (workspace, card types, boards), each shown
with its file path so the viewer knows exactly what to edit and where.
Rendering: pretty-printed <pre> on the existing type tokens — no JSON
formatter/viewer library. Investigated and declined (card_cec11535): the
largest shipped definition is ~116 lines, so collapsible trees buy nothing,
and a vendored formatter fights the no-build-step, no-dependency asset
discipline. Optional light syntax tinting must be a few lines of our own
code, not a dependency.