Skip to main content

max / makenotwork

23.9 KB · 246 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-heading` (Young Serif), `--font-mono` (IBM Plex Mono), `--font-body` (Lato) | `style.css:42-44` |
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-heading` (Young Serif), `normal` weight, color `--detail`.
31 - **H2 / H3 / meta / taglines / footer**: `--font-mono` (IBM Plex Mono), `normal` weight.
32 - **Body / lists / table cells / labels**: `--font-body` (Lato).
33
34 No fourth typeface. No `font-family` declarations in templates.
35
36 ### Size tiers
37
38 Nine, generated from makeover-geometry as ratios of `--geometry-base`, so every
39 size tracks the reader's own root font size. Named for what the text is, on the
40 same argument as `--gap-*`: whether a caption should be 13px or 14px cannot be
41 reviewed, whether a piece of text is a caption can.
42
43 | Token | What it is | At the default base |
44 |---|---|---|
45 | `--text-fine` | timestamps, badges, legal lines | 12px |
46 | `--text-note` | metadata, table cells, captions, form help | 14px |
47 | `--text-body` | running copy | 16px |
48 | `--text-lead` | emphasised copy, card titles | 18px |
49 | `--text-subhead` | h3 | 20px |
50 | `--text-head` | h2, section heads | 24px |
51 | `--text-title` | h1, page titles | 32px |
52 | `--text-display` | display copy | 40px |
53 | `--text-hero` | a landing hero, at most one per page | 48px |
54
55 Nothing below `--text-fine`. A size under 12px is a legibility problem rather
56 than a tier; text that should recede does it with colour or weight.
57
58 Sizes do not shift under a coarse pointer, unlike spacing. Density is a claim
59 about the contact patch and text is not a tap target, so a phone gets the same
60 tiers a desktop does and the reader's root size is the knob.
61
62 The `em` unit is banned for font sizes: it compounds against whatever the parent
63 happens to be, so the same rule renders at different sizes depending on where it
64 is used. Where text needs to sit one rung below its container (inline `code`
65 inside long-form copy), name the rung.
66
67 ## Components: canonical primitive table
68
69 For each primitive, exactly one canonical class **or** one canonical partial. Variants are class modifiers; nothing else.
70
71 ### Layout primitives (page scaffolding)
72
73 | Primitive | Canonical class | Variants | Notes |
74 |---|---|---|---|
75 | Container | `.container` | width set by the page's body class, one rule per tier in `style.css` | Default `max-width: 1200px` |
76 | Toolbar / header bar | `.stack-row` | `--bordered`, `--tight`, `--top` | Title-left + actions-right |
77 | Inline form row | `.field-row` | child `.form-group.is-grow` / `.is-grow-2` | Input(s) + button bottom-aligned |
78 | Vertical list row | `.list-row` | parts: `-title`, `-meta` | Flex with bottom border |
79 | Two-col form grid | `.form-row` | (none) | `grid-template-columns: 1fr 1fr` |
80 | Cover-image row | `.cover-row` + `.cover-thumb` + `.cover-empty` | (none) | 120×120 thumbnail picker |
81
82 ### Component primitives (reusable blocks)
83
84 `.card`'s fill and depth are not defined here. They come from `layout.css`,
85 generated by makeover-webview, which paints the card `--surface-raised` plus
86 `--bevel-raised` and hovers it to `--hover-surface` on a fine pointer. Do not
87 restate either in `style.css`: that file is unlayered and unlayered CSS beats
88 the `makeover` layer whatever the specificity, so a restated fill silently takes
89 ownership back. Everything else about the card — padding, border, margin — is
90 the site's.
91
92 | Primitive | Canonical class | Variants | States |
93 |---|---|---|---|
94 | Page section box | `.content-section` + `.raised` | (none) ||
95 | 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` |
96 | Button | `.btn-primary` / `.btn-secondary` / `.btn-danger` | `.btn--large`, `.btn--icon`, `.btn--link`, `.small`, `.saved` | `:hover`, `:focus-visible`, `:disabled`, `.htmx-request` |
97 | Heading | `.brand-h1` (wordmark), `.page-title` (h1), `.subtitle-h2` (auth/wizard h2), `.subsection-title` (default h2), `.section-header` (h2 with bottom border) |||
98 | Input shape | (base `input`/`textarea`/`select`) | `.input--xs`, `.input--sm`, `.input--mono`, `.input--upper`, `.input--numeric` | `:focus`, `:disabled` |
99 | Form field | `.form-group` + `.hint` | `.form-group--error` + `.field-error` | (via `_ui.html` macro `form_field`) |
100 | Section lead | `.section-lead` | with `.mb-section` / `.text-sm` / `.dimmed` utilities ||
101 | Section divider | `.section-divider` |||
102 | Section grouping label | `.section-group-label` |||
103 | Badge | `.badge` | status via `data-tone="success" / "warning" / "danger"`, no tone for a neutral terminal state; `.ai-tier-*` (disclosure level) | `.is-faded` |
104 | Tag | `.tag` (inside `.tag-input` for editing) |||
105 | Callout (solid-tint inline) | `.callout` | `--danger`, `--warning`, `--solid-warning` ||
106 | Alert (left-border inline) | `.alert` | `-note`, `-tip`, `-important`, `-warning`, `-caution` ||
107 | Banner (full-bleed page top) | `.banner` | `--info`, `--warning` ||
108 | Modal | `.modal-overlay` + `.modal` + `.form-actions` |||
109 | Confirm dialog | `_ui.html` macro `confirm_dialog` |||
110 | Toast | `partials/toast.html``.toast` | `--success`, `--error`, `--warning` ||
111 | Empty state | `.empty-state` (or `_ui.html` macro) | `--compact`, `--chart`, `--lg` ||
112 | Progress bar | `.progress-bar-container` + `.progress-bar` | `--slim` (6px), `--rounded`, `.progress-bar--highlight` (purple, default green) ||
113 | Upload status block | `.upload-status` + `-row` | `-msg.is-success`, `-msg.is-error` ||
114 | Status pill | `.field-status` / `.save-status` | `.success`, `.error`, `.saving` ||
115 | Table | `.data-table` + `.well` (rich), `.compact-table` (small mono) | `.minw-300..800` for horizontal scroll min-width | `.sortable.ascending`, `.sortable.descending` |
116 | Tabs | `.tabs` + `.tab` || `.tab.chosen` (makeover's name, not the site's) |
117 | Breadcrumb | `.breadcrumb` |||
118 | Pagination | `_ui.html` macro `pagination``.pagination` || `.active` |
119
120 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`.
121
122 ### Composition guide for new features
123
124 Build new UI by composing primitives, not by writing fresh CSS. Order of preference:
125
126 1. **Use a utility class** for one-off spacing/sizing, `.mb-section`, `.text-sm`, `.nowrap`, `.danger-text`.
127 2. **Use a layout primitive** to position content, `.container`, `.stack-row`, `.field-row`, `.list-row`.
128 3. **Use a component primitive** for a UI element, `.card`, `.callout`, `.badge`, `.progress-bar`, etc.
129 4. **Extend a primitive with a modifier**: `.card--bordered`, `.callout--warning`, `.stack-row--bordered`, `.input--sm`.
130 5. **Only then consider a new class**: and add it to the design system table here AND to `style.css` in the matching section.
131
132 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.
133
134 **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.
135
136 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.
137
138 ### How to use the macros
139
140 The codebase has two partial conventions:
141
142 - **`{% 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`.
143 - **Macros in `partials/_ui.html`**: parameterized primitives. Import once and call:
144
145 ```jinja
146 {%- import "partials/_ui.html" as ui -%}
147 ...
148 {% call ui::empty_state("No items yet", "Create one to get started.") %}
149 {% call ui::form_field("Title", "title", value, "Up to 80 characters.", error) %}
150 {% call ui::confirm_dialog("Delete item?", "This can't be undone.", "/item/123/delete", "Delete", "/item/123") %}
151 {% call ui::pagination(current_page, total_pages, "/items?page=") %}
152 ```
153
154 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.
155
156 ## State vocabulary
157
158 Exactly one spelling for each interaction state, applied to every interactive primitive:
159
160 - **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`.
161 - **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.
162
163 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.
164 - **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.
165 - **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`.
166 - **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.
167
168 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".
169
170 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.
171 - **Disabled**: `:disabled` and `[aria-disabled="true"]` show `opacity: 0.5` and `cursor: not-allowed`.
172 - **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.
173
174 ## Page-level layouts
175
176 - `.padded-page`: standard content padding (`1.5rem`).
177 - `.centered-page`: landing / login / signup vertical-center layout.
178 - `.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.
179 - Wizard layout in `static/wizard.css` (`.wizard-layout` + `.wizard-sidebar` + `.wizard-content`).
180 - Media layout in `static/media-player.css` (`.media-container`).
181
182 No new top-level layout containers without an entry in this list.
183
184 ## Rules templates must follow
185
186 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).
187 2. **No page-scoped `<style>` blocks.** All page-isolated style blocks have been migrated. New templates do not get this exemption.
188 3. **No raw hex.** Use tokens. Adding a new color means adding a token.
189 4. **No checkmarks, emoji, or icon glyphs in copy.** Words only. The diamond mark is the only graphic element. Status uses `.badge`, not ``.
190 5. **No new typefaces.** Three tiers, no exceptions.
191 6. **Empty / error / loading states use the shared partial.** Never assemble these inline.
192 7. **Destructive actions** use `.danger` button class plus the `confirm_dialog` macro. No bare destructive buttons.
193 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.
194 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.
195 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.
196 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.
197 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.
198 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.
199 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.
200 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.
201
202 ## Interaction idioms, and the one that is refused
203
204 Two idioms carry every behaviour on this site. htmx moves markup over the wire. The
205 delegated dispatcher in `frontend/src/core/dispatch.ts` names behaviour with a verb,
206 resolved against a typed registry. Both are installed once, at the document level, and
207 neither puts logic in an attribute value.
208
209 The dispatcher is one vocabulary over several hooks. The verb says what happens, the
210 attribute says when: `data-action` on click, `data-change` / `data-input` / `data-submit`
211 on the other three DOM events, `data-after` and `data-after-always` on htmx's request
212 lifecycle. Arguments come from `data-arg` / `data-arg2`, and a verb acting on another
213 element takes `data-target` (element ids) or `data-href`. Never invent a private attribute
214 for a behaviour; add a verb.
215
216 **`_hyperscript` is declined, permanently.** It appears in zero files today, so this
217 records a closed question rather than a reversal. Four reasons, heaviest first:
218
219 1. **It fails the renderer-honesty test that refused `Node::Html`.** Hyperscript is
220 behaviour written into markup: behaviour no description can see, and that no TUI or
221 egui renderer could ever honour. The description layer exists so a behaviour is named
222 once and emitted per host. An idiom that only one host can read defeats it.
223 2. **It would be a third interaction idiom** beside the two above, in a codebase that is
224 converging on one.
225 3. **It reverses the CSP posture.** `script-src 'self'` with no `unsafe-inline` was earned
226 by moving every inline `on*` and `hx-on::` handler out to delegated listeners
227 (`src/lib.rs:765`). Logic in attributes undoes that even where `_hyperscript` parses
228 without `eval`.
229 4. **Nothing needs it.** The ground it would cover is the dispatcher's, which quasi's own
230 stack table credits as "htmx 2 + GO's delegated dispatcher, proven on MNW".
231
232 Decided 2026-08-09. Wiki note `look-wave-2`, decision 6.
233
234 ## How to extend
235
236 When a screen genuinely needs something not in this charter:
237
238 1. Propose the primitive in this file (name, canonical class/partial, variants, states).
239 2. Add the class to `style.css` in the matching section, or a partial under `templates/partials/`.
240 3. Update the inventory table above.
241 4. Then use it. Three usages without an entry in the charter means a missing primitive, not a license to inline.
242
243 ## Audit cadence
244
245 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.
246