Skip to main content

max / makenotwork

24.3 KB · 251 lines History Blame Raw
1 # MNW Design System Charter
2
3 The MNW UI is composed from a small fixed set of primitives. Every screen is assembled from these, like an OS rendering applications, not like a website where each page reinvents its own widgets. If a screen needs something not in this charter, the answer is to extend the charter, not to write a one-off.
4
5 Source of truth for visual identity: the cross-project brand system (internal). Source of truth for primitives: this file plus `static/style.css`.
6
7 ## Tokens
8
9 Every visual value is a token defined in `:root` (`static/style.css`). No raw hex, no off-scale spacing, no bespoke shadow.
10
11 | Tier | Token group | Where defined |
12 |---|---|---|
13 | Color | Surfaces `--surface-page/-raised/-sunken/-overlay/-well` and the bevel pair; content `--content/-secondary/-muted/-on-action`; `--action/-hover`; semantic `--success`, `--warning`, `--danger`, `--info`; `--border/-strong`, `--focus-ring`, `--hover-surface`, `--overlay`; `--category-one`..`--category-six`; fixed anchors `--primary-dark/-light`, `--stripe`, diff (`--diff-add/-bg`, `--diff-del/-bg`) and the base16 Tomorrow `--syntax-*` set | `style.css` `:root` |
14 | Typeface | `--font-mono` (Quasi Mono), `--font-sans` (Quasi Body), `--font-display` (Young Serif) | `typography.css` (generated by makeover-build) |
15 | Type size | `--text-fine` / `--text-note` / `--text-body` / `--text-lead` / `--text-subhead` / `--text-head` / `--text-title` / `--text-display` / `--text-hero`, named for what the text is, not the size | `geometry.css` (generated by makeover-build) |
16 | Spacing | `--gap-bound` / `--gap-peer` / `--gap-group` / `--gap-section` / `--gap-pane` / `--gap-page`, named for the relationship they express, not the size | `geometry.css` (generated by makeover-build) |
17 | Radius | `--radius-square` (0, containers: cards, panels, dropdowns), `--radius-fine` (2px, inline code, small badges), `--radius-control` (4px, buttons, inputs, selects), `--radius-panel` (8px, media covers, callout boxes), `--radius-round` (50%), named for what the corner belongs to | `geometry.css` (generated by makeover-build) |
18 | Stacking | `--z-raised` / `--z-nav` / `--z-header` / `--z-dropdown` / `--z-picker` / `--z-context` / `--z-overlay` / `--z-modal` / `--z-toast` / `--z-banner` / `--z-skip-link`, in that order, named for what floats over what | `style.css:213-228` |
19 | Shadow | Two kinds, and only two. A surface IN the page takes a bevel, an edge rather than a distance: `--shadow-raised` (buttons, tabs), `--shadow-card` (cards, panels), `--shadow-inset` (inputs, recessed), all aliasing the `--bevel-raised` / `--bevel-inset` pair from `layout.css`. A surface floating OVER the page takes `--elevation-overlay`, also generated: menus, toasts, popovers, dropdowns and pickers float; a card, a plate and a framed image do not, and are `.raised`. A consumer needing different blur geometry composes it from `var(--elevation)`, never from a literal. The old `--shadow-1/-2/-3` blurred scale was rgba-black literals picked against parchment and is gone (wave 2 tier B5). | `style.css` `:root`, `layout.css` |
20
21 Every colour outside `:root` is a `var()` or a `color-mix()` over one; there are no hex or `rgba()` literals in the stylesheets at all. Write the token, not a `var(--token, #fallback)`: nine of those named tokens that never existed and quietly rendered their hardcoded fallback on all 31 themes. Pure `#000` and `#fff` are forbidden outside the token table. Bootstrap-derived yellows (`#fff3cd`, `#ffc107`) are forbidden. Use `--warning-bg` / `--warning-border`.
22
23 ## Type
24
25 Two independent choices, and every text element makes both: which typeface, and
26 which size.
27
28 ### Typeface tiers (from `brand.md`)
29
30 - **H1 / wordmark / section heads**: `--font-display` (Young Serif), `normal` weight, color `--detail`.
31 - **H2 / H3 / meta / taglines / footer**: `--font-mono` (Quasi Mono), `normal` weight.
32 - **Body / lists / table cells / labels**: `--font-sans` (Quasi Body).
33
34 Three tokens and no fourth. No `font-family` declarations in templates, and
35 none of the three is defined in `style.css`: `--font-mono` and `--font-sans`
36 are the house slots every product shares, and `--font-display` is MNW's own
37 override of the brand slot, declared in `build.rs`. All three arrive in the
38 generated `typography.css`. Adding a face means adding an override there, not a
39 `font-family` here. See wiki `typography-standard`.
40
41 ### Size tiers
42
43 Nine, generated from makeover-geometry as ratios of `--geometry-base`, so every
44 size tracks the reader's own root font size. Named for what the text is, on the
45 same argument as `--gap-*`: whether a caption should be 13px or 14px cannot be
46 reviewed, whether a piece of text is a caption can.
47
48 | Token | What it is | At the default base |
49 |---|---|---|
50 | `--text-fine` | timestamps, badges, legal lines | 12px |
51 | `--text-note` | metadata, table cells, captions, form help | 14px |
52 | `--text-body` | running copy | 16px |
53 | `--text-lead` | emphasised copy, card titles | 18px |
54 | `--text-subhead` | h3 | 20px |
55 | `--text-head` | h2, section heads | 24px |
56 | `--text-title` | h1, page titles | 32px |
57 | `--text-display` | display copy | 40px |
58 | `--text-hero` | a landing hero, at most one per page | 48px |
59
60 Nothing below `--text-fine`. A size under 12px is a legibility problem rather
61 than a tier; text that should recede does it with colour or weight.
62
63 Sizes do not shift under a coarse pointer, unlike spacing. Density is a claim
64 about the contact patch and text is not a tap target, so a phone gets the same
65 tiers a desktop does and the reader's root size is the knob.
66
67 The `em` unit is banned for font sizes: it compounds against whatever the parent
68 happens to be, so the same rule renders at different sizes depending on where it
69 is used. Where text needs to sit one rung below its container (inline `code`
70 inside long-form copy), name the rung.
71
72 ## Components: canonical primitive table
73
74 For each primitive, exactly one canonical class **or** one canonical partial. Variants are class modifiers; nothing else.
75
76 ### Layout primitives (page scaffolding)
77
78 | Primitive | Canonical class | Variants | Notes |
79 |---|---|---|---|
80 | Container | `.container` | width set by the page's body class, one rule per tier in `style.css` | Default `max-width: 1200px` |
81 | Toolbar / header bar | `.stack-row` | `--bordered`, `--tight`, `--top` | Title-left + actions-right |
82 | Inline form row | `.field-row` | child `.form-group.is-grow` / `.is-grow-2` | Input(s) + button bottom-aligned |
83 | Vertical list row | `.list-row` | parts: `-title`, `-meta` | Flex with bottom border |
84 | Two-col form grid | `.form-row` | (none) | `grid-template-columns: 1fr 1fr` |
85 | Cover-image row | `.cover-row` + `.cover-thumb` + `.cover-empty` | (none) | 120×120 thumbnail picker |
86
87 ### Component primitives (reusable blocks)
88
89 `.card`'s fill and depth are not defined here. They come from `layout.css`,
90 generated by makeover-webview, which paints the card `--surface-raised` plus
91 `--bevel-raised` and hovers it to `--hover-surface` on a fine pointer. Do not
92 restate either in `style.css`: that file is unlayered and unlayered CSS beats
93 the `makeover` layer whatever the specificity, so a restated fill silently takes
94 ownership back. Everything else about the card — padding, border, margin — is
95 the site's.
96
97 | Primitive | Canonical class | Variants | States |
98 |---|---|---|---|
99 | Page section box | `.content-section` + `.raised` | (none) ||
100 | Card | `.card` | `.card-muted` (surface-muted fill), `.card--bordered`, `.card--selectable` (+ `.is-text-heavy` for left-aligned content) | `:hover`, `:focus-within`, `.is-selected`, `.is-disabled` |
101 | Button | `.btn-primary` / `.btn-secondary` / `.btn-danger` | `.btn--large`, `.btn--icon`, `.btn--link`, `.small`, `.saved` | `:hover`, `:focus-visible`, `:disabled`, `.htmx-request` |
102 | Heading | `.brand-h1` (wordmark), `.page-title` (h1), `.subtitle-h2` (auth/wizard h2), `.subsection-title` (default h2), `.section-header` (h2 with bottom border) |||
103 | Input shape | (base `input`/`textarea`/`select`) | `.input--xs`, `.input--sm`, `.input--mono`, `.input--upper`, `.input--numeric` | `:focus`, `:disabled` |
104 | Form field | `.form-group` + `.hint` | `.form-group--error` + `.field-error` | (via `_ui.html` macro `form_field`) |
105 | Section lead | `.section-lead` | with `.mb-section` / `.text-sm` / `.dimmed` utilities ||
106 | Section divider | `.section-divider` |||
107 | Section grouping label | `.section-group-label` |||
108 | Badge | `.badge` | status via `data-tone="success" / "warning" / "danger"`, no tone for a neutral terminal state; `.ai-tier-*` (disclosure level) | `.is-faded` |
109 | Tag | `.tag` (inside `.tag-input` for editing) |||
110 | Callout (solid-tint inline) | `.callout` | `--danger`, `--warning`, `--solid-warning` ||
111 | Alert (left-border inline) | `.alert` | `-note`, `-tip`, `-important`, `-warning`, `-caution` ||
112 | Banner (full-bleed page top) | `.banner` | `--info`, `--warning` ||
113 | Modal | `.modal-overlay` + `.modal` + `.form-actions` |||
114 | Confirm dialog | `_ui.html` macro `confirm_dialog` |||
115 | Toast | `partials/toast.html``.toast` | `--success`, `--error`, `--warning` ||
116 | Empty state | `.empty-state` (or `_ui.html` macro) | `--compact`, `--chart`, `--lg` ||
117 | Progress bar | `.progress-bar-container` + `.progress-bar` | `--slim` (6px), `--rounded`, `.progress-bar--highlight` (purple, default green) ||
118 | Upload status block | `.upload-status` + `-row` | `-msg.is-success`, `-msg.is-error` ||
119 | Status pill | `.field-status` / `.save-status` | `.success`, `.error`, `.saving` ||
120 | Table | `.data-table` + `.well` (rich), `.compact-table` (small mono) | `.minw-300..800` for horizontal scroll min-width ||
121 | Tabs | `.tabs` + `.tab` || `.tab.chosen` (makeover's name, not the site's) |
122 | Breadcrumb | `.breadcrumb` |||
123 | Pagination | `_ui.html` macro `pagination``.pagination` || `.active` |
124
125 Tokens, the four parameterized macros (`empty_state`, `form_field`, `confirm_dialog`, `pagination`), and the consolidation pass have all shipped. Tokens live in `static/style.css` `:root`. Macros are in `templates/partials/_ui.html`.
126
127 ### Composition guide for new features
128
129 Build new UI by composing primitives, not by writing fresh CSS. Order of preference:
130
131 1. **Use a utility class** for one-off spacing/sizing, `.mb-section`, `.text-sm`, `.nowrap`, `.danger-text`.
132 2. **Use a layout primitive** to position content, `.container`, `.stack-row`, `.field-row`, `.list-row`.
133 3. **Use a component primitive** for a UI element, `.card`, `.callout`, `.badge`, `.progress-bar`, etc.
134 4. **Extend a primitive with a modifier**: `.card--bordered`, `.callout--warning`, `.stack-row--bordered`, `.input--sm`.
135 5. **Only then consider a new class**: and add it to the design system table here AND to `style.css` in the matching section.
136
137 A new class is a smell, not a goal. Three usages without an entry above means a missing primitive, not a license to keep inlining.
138
139 **No aliases.** A shared recipe is reached by writing its canonical name in the markup, never by adding a page-specific name to the recipe's selector list. A component that also needs rules of its own keeps its own class and puts the canonical modifier beside it: `class="card--bordered tier-card"`. The alias habit is what let `.card--selectable` and `.card--bordered` sit in this table for months while zero templates used either name, so a developer following the charter wrote a class that matched nothing else in the codebase.
140
141 Page-scoped CSS (`.foo-page .bar`) is a last resort. Most page-scoped rules in `style.css` exist for genuine page-specific layout (e.g. `.item-page .item-layout` grid, `.article-page .article-body` typography). Don't add new ones for shapes that are really cards or list rows in disguise.
142
143 ### How to use the macros
144
145 The codebase has two partial conventions:
146
147 - **`{% include %}` partials**: share the parent template's context. Good for header / nav / chrome that doesn't need parameters. Examples: `partials/site_header.html`, `partials/admin_nav.html`.
148 - **Macros in `partials/_ui.html`**: parameterized primitives. Import once and call:
149
150 ```jinja
151 {%- import "partials/_ui.html" as ui -%}
152 ...
153 {% call ui::empty_state("No items yet", "Create one to get started.") %}
154 {% call ui::form_field("Title", "title", value, "Up to 80 characters.", error) %}
155 {% call ui::confirm_dialog("Delete item?", "This can't be undone.", "/item/123/delete", "Delete", "/item/123") %}
156 {% call ui::pagination(current_page, total_pages, "/items?page=") %}
157 ```
158
159 When a primitive needs an HTMX endpoint (e.g. server-rendered confirm dialogs returned to a swap target), wrap it in a small Rust template struct that calls the macro in its body.
160
161 ## State vocabulary
162
163 Exactly one spelling for each interaction state, applied to every interactive primitive:
164
165 - **Hover**: fill shift to `var(--hover-surface)`, on every interactive primitive. One spelling, no component-type branch. The exception is `.btn--link`, which is semantically a link and has no surface to shift: it fades to opacity `0.6`.
166 - **Press**: `:active` inverts the bevel, `box-shadow: var(--shadow-inset)`. Nothing moves; there is no `transform: translate(2px, 2px)`, and no control opts out. A flat control inverts on press the same as a raised one, including `.btn--link` and the menu and disclosure openers.
167
168 Hover used to have three options picked by component type, and the second of them was a depth lift: the shadow grew from `var(--shadow-raised)` to `3px 3px var(--shadow-edge)`, reserved for controls that commit a write, so that pressing "Pay" looked different from pressing "Next". That signal is gone rather than moved, and it was spent deliberately. A bevel is an edge and not a distance, so it has no growth axis to extend along; keeping a commit-versus-navigate cue would have meant a local override on top of the `layout.css` every other make-family app takes unmodified. Decided 2026-07-31 (wiki `mnw-platinum`). A control that commits a write is distinguished by its label and its placement, the same way it is in GoingsOn and Balanced Breakfast.
169 - **Focus**: `:focus-visible` shows the `--focus-ring` violet outline. Custom interactive containers (`.card--selectable`, sort headers) must opt in by adding `:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }`. A container the browser does not focus on its own also needs `tabindex="0"` in the markup, or the rule never matches and the outline is decoration for a state that cannot happen. The `data-action` dispatcher activates such a container on Enter and Space.
170 - **Selected / active**: `.is-selected` modifier applies `background: var(--highlight-faint)` plus the focus-ring border. `.filter-item.active`, `.view-btn.active`, and the `:checked + .card--selectable-inner` recipe all read it. A tab is the exception and takes `.tab.chosen` instead: makeover names that state and emits its rule, so spelling it `.is-selected` here as well made the site a second name for something the design system already named. Where makeover has a primitive, its name wins; the components it has none for keep `.is-selected`.
171 - **Status**: a badge names the status it reports, and the colour follows from the status. The set is `data-tone="success"` (Active, Trialing, Completed, Approved, Published, Connected, Free), `data-tone="warning"` (Pending, Processing, Scheduled, Draft, Incomplete) and `data-tone="danger"` (Failed, Past due, Unpaid, Suspended, Spam). A terminal state nobody has to act on (Refunded, Canceled) takes the plain `.badge` with no tone, which already reads as neutral: colouring it red would ask for an action that does not exist. A badge reports data, not selection, so it never takes `.is-selected` and there is no `.badge.active`. A class that names a colour rather than a state is the same category error as `.mb-4` naming a number.
172
173 The tone is an attribute rather than a class because that is makeover's spelling: `layout.css` carries `.badge[data-tone="…"]` and the four tone names are the design system's. The badge itself is coloured text, not a filled pill. It was a filled pill until wave 2 tier B2, painted per status by a local `.badge--live/pending/failed` set, and that fill was saying exactly what the generated colour says — keeping it meant the tone could never do anything. `.ai-tier-*` is the one thing that stays local, because makeover's four tones all read as alarm and a disclosure level is not alarm: a handmade item is not a "success".
174
175 The tone is chosen in Rust, by `BadgeStatus` in `src/types/badge.rs`, and reaches the template as a `status_tone` field. A template never interpolates a status column into a class attribute. That habit made the stylesheet mirror whatever the schema spelled, so a column rename unstyled the badge and a status nobody had written a rule for rendered plain, which is what "Scheduled" and every subscription status other than `active` did until 2026-08-02. Each status enum maps into the set with an exhaustive match, so a new variant is a compile error.
176 - **Disabled**: `:disabled` and `[aria-disabled="true"]` show `opacity: 0.5` and `cursor: not-allowed`.
177 - **Busy / loading**: HTMX-driven via `.htmx-request` on the trigger. There is no skeleton primitive: one was documented here for months and never built, so it was removed rather than left as a promise.
178
179 ## Page-level layouts
180
181 - `.padded-page`: standard content padding (`1.5rem`).
182 - `.centered-page`: landing / login / signup vertical-center layout.
183 - `.container`, whose width comes from the page's body class. One rule per width tier in `style.css`; a new page adds its body class there rather than a new modifier.
184 - Wizard layout in `static/wizard.css` (`.wizard-layout` + `.wizard-sidebar` + `.wizard-content`).
185 - Media layout in `static/media-player.css` (`.media-container`).
186
187 No new top-level layout containers without an entry in this list.
188
189 ## Rules templates must follow
190
191 1. **No inline `style="..."`.** If you need a one-off, add a utility class or extend a primitive. The single exception: `style="--var: dynamic"` or `style="width: {{ pct }}%"` for server-computed values (progress bars, chart bars, avatar fallback colors).
192 2. **No page-scoped `<style>` blocks.** All page-isolated style blocks have been migrated. New templates do not get this exemption.
193 3. **No raw hex.** Use tokens. Adding a new color means adding a token.
194 4. **No checkmarks, emoji, or icon glyphs in copy.** Words only. The diamond mark is the only graphic element. Status uses `.badge`, not ``.
195 5. **No new typefaces.** Three tiers, no exceptions.
196 6. **Empty / error / loading states use the shared partial.** Never assemble these inline.
197 7. **Destructive actions** use `.danger` button class plus the `confirm_dialog` macro. No bare destructive buttons.
198 8. **Spacing values come from `--gap-*` tokens.** Pick the one that names what the rule separates: `bound` inside a thing, `peer` between siblings, `group` between related controls, `section` between parts of a pane, `pane` between panes, `page` at page level. Raw values and off-scale values are bugs. The tokens are generated from makeover-geometry and shift under a coarse pointer, so a hardcoded value silently opts that rule out of touch density.
199 9. **Corner radius comes from `--radius-*` tokens.** Rounding says a thing is meant to be pressed, so the scale is four rungs and a circle. `--radius-square` is a rung rather than the absence of one: a container states that it is square, which a reader can tell apart from a rule nobody wrote. Raw px values are bugs.
200 10. **Font sizes come from `--text-*` tokens.** Pick the one that names what the text is: `fine` for timestamps and badges, `note` for metadata and table cells, `body` for running copy, `lead` for emphasised copy and card titles, `subhead` / `head` / `title` for the three heading levels, `display` and `hero` above the hierarchy. Raw values and off-scale values are bugs, including a value that differs from a tier by a fraction of a pixel. Nothing sits below `fine`: text that should recede does it with colour or weight.
201 11. **`z-index` values come from `--z-*` tokens.** Pick the one that names what the thing is, and read the block in `:root` to see what it will float over. A raw number is a bug: it is how the old range reached 10000, each new floating thing picking a value bigger than whatever it happened to need to beat. If nothing in the scale describes the new thing, add a step to the scale rather than a number to the rule.
202 12. **Page-scoped CSS is a last resort.** Default to composing layout + component primitives. Page-scoped sections exist for genuinely page-specific layout (grids, long-form typography, marketing heroes), not for shapes that are cards or list rows in disguise.
203 13. **No bare `<h1>` / `<h2>` in templates.** Pick a heading class: `.brand-h1` (wordmark), `.page-title` (page h1), `.subtitle-h2` (page subtitle on auth/wizards), `.subsection-title` (default h2 inside dashboards/tabs/partials), or `.section-header` (prose sub-section with bottom border). Two exceptions, both because the heading classes are the wrong tool rather than because the rule is optional. **Prose containers** style their own descendant headings — `.doc-body`, `.article-body`, `.markdown-preview` — so a heading inside one stays bare whether the prose was rendered from markdown or hand-written in the template (`pages/economics.html`). **Standalone creator pages** (`templates/custom/*.html`, `templates/embed/*.html`) do not load `style.css` at all, so no heading class exists to pick.
204 14. **Button class is `.btn-primary` / `.btn-secondary` / `.btn-danger`.** Works on both `<button>` and `<a>` (the `<a><button>` antipattern is banned. Use `<a class="btn-primary">…</a>`). The bare `button.primary` shorthand has been retired.
205 15. **Class names use kebab-case only.** No BEM `__` separator (retired 2026-05-20, was 112 sites across 20 prefixes, now flattened to single-dash). Modifier `--` (e.g. `.card--bordered`) is still allowed.
206
207 ## Interaction idioms, and the one that is refused
208
209 Two idioms carry every behaviour on this site. htmx moves markup over the wire. The
210 delegated dispatcher in `frontend/src/core/dispatch.ts` names behaviour with a verb,
211 resolved against a typed registry. Both are installed once, at the document level, and
212 neither puts logic in an attribute value.
213
214 The dispatcher is one vocabulary over several hooks. The verb says what happens, the
215 attribute says when: `data-action` on click, `data-change` / `data-input` / `data-submit`
216 on the other three DOM events, `data-after` and `data-after-always` on htmx's request
217 lifecycle. Arguments come from `data-arg` / `data-arg2`, and a verb acting on another
218 element takes `data-target` (element ids) or `data-href`. Never invent a private attribute
219 for a behaviour; add a verb.
220
221 **`_hyperscript` is declined, permanently.** It appears in zero files today, so this
222 records a closed question rather than a reversal. Four reasons, heaviest first:
223
224 1. **It fails the renderer-honesty test that refused `Node::Html`.** Hyperscript is
225 behaviour written into markup: behaviour no description can see, and that no TUI or
226 egui renderer could ever honour. The description layer exists so a behaviour is named
227 once and emitted per host. An idiom that only one host can read defeats it.
228 2. **It would be a third interaction idiom** beside the two above, in a codebase that is
229 converging on one.
230 3. **It reverses the CSP posture.** `script-src 'self'` with no `unsafe-inline` was earned
231 by moving every inline `on*` and `hx-on::` handler out to delegated listeners
232 (`src/lib.rs:765`). Logic in attributes undoes that even where `_hyperscript` parses
233 without `eval`.
234 4. **Nothing needs it.** The ground it would cover is the dispatcher's, which quasi's own
235 stack table credits as "htmx 2 + GO's delegated dispatcher, proven on MNW".
236
237 Decided 2026-08-09. Wiki note `look-wave-2`, decision 6.
238
239 ## How to extend
240
241 When a screen genuinely needs something not in this charter:
242
243 1. Propose the primitive in this file (name, canonical class/partial, variants, states).
244 2. Add the class to `style.css` in the matching section, or a partial under `templates/partials/`.
245 3. Update the inventory table above.
246 4. Then use it. Three usages without an entry in the charter means a missing primitive, not a license to inline.
247
248 ## Audit cadence
249
250 Phase 0 establishes the charter. Subsequent phases (tracked internally) audit conformance: every finding in those phases should be expressible as "screen X uses an off-charter primitive Y" or "primitive Y has a gap the charter should address." Free-form aesthetic critique is out of scope. That belongs to brand work, not this charter.
251