| 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 |
|
| 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 | Everyday depth: `--shadow-raised` (buttons, tabs), `--shadow-card` (cards, panels), `--shadow-inset` (inputs, recessed). All three alias the bevel pair `--bevel-raised` / `--bevel-inset` from `layout.css`, which makeover derives from each theme's own raised surface. Blurred (true elevation, floats over page): `--shadow-1` (subtle), `--shadow-2` (raised), `--shadow-3` (modal/overlay). | `style.css:224-243` | |
| 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 |
|
| 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 |
|
| 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 |
|
| 93 |
|
| 94 |
| Page section box | `.content-section` | (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 set `.badge--live` / `.badge--pending` / `.badge--failed`, no modifier for a neutral terminal state; `.free` (price fact); `.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` (rich), `.compact-table` (small mono) | `.minw-300..800` for horizontal scroll min-width | `.sortable.ascending`, `.sortable.descending` | |
| 116 |
| Tabs | `.tabs` + `.tab` | — | `.tab.is-selected` | |
| 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. `.tab.active`, `.filter-item.active`, `.view-btn.active`, and the `:checked + .card--selectable-inner` recipe all read it. |
| 166 |
- **Status**: a badge names the status it reports, and the colour follows from the status. The set is `.badge--live` (Active, Trialing, Completed, Approved, Published, Connected), `.badge--pending` (Pending, Processing, Scheduled, Draft, Incomplete) and `.badge--failed` (Failed, Past due, Unpaid, Suspended, Spam). A terminal state nobody has to act on (Refunded, Canceled) takes the plain `.badge` with no modifier, 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. `.free` and the `.ai-tier-*` trio stay outside the set: a price fact and a disclosure level are not lifecycle. |
| 167 |
|
| 168 |
The class is chosen in Rust, by `BadgeStatus` in `src/types/badge.rs`, and reaches the template as a `status_class` 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. |
| 169 |
- **Disabled**: `:disabled` and `[aria-disabled="true"]` show `opacity: 0.5` and `cursor: not-allowed`. |
| 170 |
- **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. |
| 171 |
|
| 172 |
## Page-level layouts |
| 173 |
|
| 174 |
- `.padded-page`: standard content padding (`1.5rem`). |
| 175 |
- `.centered-page`: landing / login / signup vertical-center layout. |
| 176 |
- `.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. |
| 177 |
- Wizard layout in `static/wizard.css` (`.wizard-layout` + `.wizard-sidebar` + `.wizard-content`). |
| 178 |
- Media layout in `static/media-player.css` (`.media-container`). |
| 179 |
|
| 180 |
No new top-level layout containers without an entry in this list. |
| 181 |
|
| 182 |
## Rules templates must follow |
| 183 |
|
| 184 |
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). |
| 185 |
2. **No page-scoped `<style>` blocks.** All page-isolated style blocks have been migrated. New templates do not get this exemption. |
| 186 |
3. **No raw hex.** Use tokens. Adding a new color means adding a token. |
| 187 |
4. **No checkmarks, emoji, or icon glyphs in copy.** Words only. The diamond mark is the only graphic element. Status uses `.badge`, not `✓`. |
| 188 |
5. **No new typefaces.** Three tiers, no exceptions. |
| 189 |
6. **Empty / error / loading states use the shared partial.** Never assemble these inline. |
| 190 |
7. **Destructive actions** use `.danger` button class plus the `confirm_dialog` macro. No bare destructive buttons. |
| 191 |
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. |
| 192 |
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. |
| 193 |
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. |
| 194 |
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. |
| 195 |
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. |
| 196 |
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. |
| 197 |
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. |
| 198 |
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. |
| 199 |
|
| 200 |
## How to extend |
| 201 |
|
| 202 |
When a screen genuinely needs something not in this charter: |
| 203 |
|
| 204 |
1. Propose the primitive in this file (name, canonical class/partial, variants, states). |
| 205 |
2. Add the class to `style.css` in the matching section, or a partial under `templates/partials/`. |
| 206 |
3. Update the inventory table above. |
| 207 |
4. Then use it. Three usages without an entry in the charter means a missing primitive, not a license to inline. |
| 208 |
|
| 209 |
## Audit cadence |
| 210 |
|
| 211 |
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. |
| 212 |
|