| 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 | `--background`, `--detail`, `--highlight`, `--light-background`, surface family, `--text-muted`, `--border`, semantic (`--success/-bg`, `--warning/-bg/-border`, `--danger/-bg`, `--error/-bg`), `--stripe`, health (`--health-ok/-warn/-error/-unknown`), diff (`--diff-add/-bg`, `--diff-del/-bg`), `--focus-ring`, `--highlight-faint`, `--overlay` | `style.css:40-95` | |
| 14 |
| Type | `--font-heading` (Young Serif), `--font-mono` (IBM Plex Mono), `--font-body` (Lato) | `style.css:42-44` | |
| 15 |
| Spacing | `--space-1`...`--space-6` mapping to `0.25 / 0.5 / 0.75 / 1 / 1.5 / 2 rem` | `style.css:96-103` | |
| 16 |
| Radius | `--radius-sm` (2px), `--radius-md` (4px), `--radius-round` (50%) | `style.css:105-108` | |
| 17 |
| Shadow | Hard-offset (everyday affordance): `--shadow-raised` (buttons, tabs), `--shadow-card` (cards, panels), `--shadow-inset` (inputs, recessed). Edge colors `--shadow-edge` / `--shadow-edge-soft`. Blurred (true elevation, floats over page): `--shadow-1` (subtle), `--shadow-2` (raised), `--shadow-3` (modal/overlay). | `style.css:208-222` | |
| 18 |
|
| 19 |
Pure `#000` and `#fff` are forbidden outside the token table. Bootstrap-derived yellows (`#fff3cd`, `#ffc107`) are forbidden — use `--warning-bg` / `--warning-border`. |
| 20 |
|
| 21 |
## Type tiers (from `brand.md`) |
| 22 |
|
| 23 |
Every text element maps to exactly one tier: |
| 24 |
|
| 25 |
- **H1 / wordmark / section heads** — `--font-heading` (Young Serif), `normal` weight, color `--detail`. |
| 26 |
- **H2 / H3 / meta / taglines / footer** — `--font-mono` (IBM Plex Mono), `normal` weight. |
| 27 |
- **Body / lists / table cells / labels** — `--font-body` (Lato). |
| 28 |
|
| 29 |
No fourth typeface. No `font-family` declarations in templates. |
| 30 |
|
| 31 |
## Components — canonical primitive table |
| 32 |
|
| 33 |
For each primitive, exactly one canonical class **or** one canonical partial. Variants are class modifiers; nothing else. |
| 34 |
|
| 35 |
### Layout primitives (page scaffolding) |
| 36 |
|
| 37 |
|
| 38 |
|
| 39 |
| Container | `.container` | `--narrow` (600px), `--medium` (800px), `--wide` (900px) | Default `max-width: 1200px` | |
| 40 |
| Toolbar / header bar | `.stack-row` | `--bordered`, `--tight`, `--top` | Title-left + actions-right | |
| 41 |
| Inline form row | `.field-row` | child `.form-group.is-grow` / `.is-grow-2` | Input(s) + button bottom-aligned | |
| 42 |
| Vertical list row | `.list-row` | parts: `-title`, `-meta` | Flex with bottom border | |
| 43 |
| Two-col form grid | `.form-row` | (none) | `grid-template-columns: 1fr 1fr` | |
| 44 |
| Cover-image row | `.cover-row` + `.cover-thumb` + `.cover-empty` | (none) | 120×120 thumbnail picker | |
| 45 |
|
| 46 |
### Component primitives (reusable blocks) |
| 47 |
|
| 48 |
|
| 49 |
|
| 50 |
| Page section box | `.content-section` | (none) | — | |
| 51 |
| Card | `.card` | `.card-muted` (surface-muted fill), `.card--bordered`, `.card--selectable` | `:hover`, `:focus-within`, `.is-selected` | |
| 52 |
| Button | `.btn-primary` / `.btn-secondary` / `.btn-danger` | `.btn--large`, `.btn--icon`, `.btn--link`, `.small`, `.saved` | `:hover`, `:focus-visible`, `:disabled`, `.htmx-request` | |
| 53 |
| Heading | `.brand-h1` (wordmark), `.page-title` (h1), `.subtitle-h2` (auth/wizard h2), `.subsection-title` (default h2), `.section-header` (h2 with bottom border) | — | — | |
| 54 |
| Input shape | (base `input`/`textarea`/`select`) | `.input--xs`, `.input--sm`, `.input--mono`, `.input--upper`, `.input--numeric` | `:focus`, `:disabled` | |
| 55 |
| Form field | `.form-group` + `.hint` | `.form-group--error` + `.field-error` | (via `_ui.html` macro `form_field`) | |
| 56 |
| Section lead | `.section-lead` | with `.mb-3` / `.text-sm` / `.dimmed` utilities | — | |
| 57 |
| Section divider | `.section-divider` | — | — | |
| 58 |
| Section grouping label | `.section-group-label` | — | — | |
| 59 |
| Badge | `.badge` | `.badge-success`, `.badge-warning`, `.badge-danger`, `.free` | `.is-selected`, `.is-faded`, `.active` (= completion, not selection) | |
| 60 |
| Tag | `.tag` (inside `.tag-input` for editing) | — | — | |
| 61 |
| Callout (solid-tint inline) | `.callout` | `--danger`, `--warning`, `--solid-warning` | — | |
| 62 |
| Alert (left-border inline) | `.alert` | `-note`, `-tip`, `-important`, `-warning`, `-caution` | — | |
| 63 |
| Banner (full-bleed page top) | `.banner` | `--info`, `--warning` | — | |
| 64 |
| Modal | `.modal-overlay` + `.modal` + `.form-actions` | — | — | |
| 65 |
| Confirm dialog | `_ui.html` macro `confirm_dialog` | — | — | |
| 66 |
| Toast | `partials/toast.html` → `.toast` | `--success`, `--error`, `--warning` | — | |
| 67 |
| Empty state | `.empty-state` (or `_ui.html` macro) | `--compact`, `--chart`, `--lg` | — | |
| 68 |
| Loading skeleton | `partials/loading_skeleton.html` | row, card, list | — | |
| 69 |
| Progress bar | `.progress-bar-container` + `.progress-bar` | `--slim` (6px), `--rounded`, `.progress-bar--highlight` (purple, default green) | — | |
| 70 |
| Upload status block | `.upload-status` + `-row` | `-msg.is-success`, `-msg.is-error` | — | |
| 71 |
| Status pill | `.field-status` / `.save-status` | `.success`, `.error`, `.saving` | — | |
| 72 |
| Table | `.data-table` (rich), `.compact-table` (small mono) | `.minw-300..800` for horizontal scroll min-width | `.sortable.ascending`, `.sortable.descending` | |
| 73 |
| Tabs | `.tabs` + `.tab` | — | `.tab.is-selected` | |
| 74 |
| Breadcrumb | `.breadcrumb` | — | — | |
| 75 |
| Pagination | `_ui.html` macro `pagination` → `.pagination` | — | `.active` | |
| 76 |
|
| 77 |
Tokens, the four parameterized macros (`empty_state`, `loading_skeleton`, `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`. |
| 78 |
|
| 79 |
### Composition guide for new features |
| 80 |
|
| 81 |
Build new UI by composing primitives, not by writing fresh CSS. Order of preference: |
| 82 |
|
| 83 |
1. **Use a utility class** for one-off spacing/sizing — `.mb-4`, `.text-sm`, `.nowrap`, `.danger-text`. |
| 84 |
2. **Use a layout primitive** to position content — `.container`, `.stack-row`, `.field-row`, `.list-row`. |
| 85 |
3. **Use a component primitive** for a UI element — `.card`, `.callout`, `.badge`, `.progress-bar`, etc. |
| 86 |
4. **Extend a primitive with a modifier** — `.card--bordered`, `.callout--warning`, `.stack-row--bordered`, `.input--sm`. |
| 87 |
5. **Only then consider a new class** — and add it to the design system table here AND to `style.css` in the matching section. |
| 88 |
|
| 89 |
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. |
| 90 |
|
| 91 |
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. |
| 92 |
|
| 93 |
### How to use the macros |
| 94 |
|
| 95 |
The codebase has two partial conventions: |
| 96 |
|
| 97 |
- **`{% 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`. |
| 98 |
- **Macros in `partials/_ui.html`** — parameterized primitives. Import once and call: |
| 99 |
|
| 100 |
```jinja |
| 101 |
{%- import "partials/_ui.html" as ui -%} |
| 102 |
... |
| 103 |
{% call ui::empty_state("No items yet", "Create one to get started.") %} |
| 104 |
{% call ui::form_field("Title", "title", value, "Up to 80 characters.", error) %} |
| 105 |
{% call ui::confirm_dialog("Delete item?", "This can't be undone.", "/item/123/delete", "Delete", "/item/123") %} |
| 106 |
{% call ui::pagination(current_page, total_pages, "/items?page=") %} |
| 107 |
``` |
| 108 |
|
| 109 |
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. |
| 110 |
|
| 111 |
## State vocabulary |
| 112 |
|
| 113 |
Exactly one spelling for each interaction state, applied to every interactive primitive: |
| 114 |
|
| 115 |
- **Hover** — three options, picked by component type; do not mix on the same component: |
| 116 |
- Surface lift: shift background one step (`--background` -> `--light-background` -> `--surface-muted`). For inline lists, cards-as-links, table rows, and navigation controls (tabs, pagination, breadcrumbs) — anything that moves the camera without committing. |
| 117 |
- Depth lift: shadow extends from `var(--shadow-raised)` to `3px 3px var(--shadow-edge)`. Reserved for controls that **cause a state change** — DB write, payment, file upload, delete, save. Pair with `:active { box-shadow: none; transform: translate(2px, 2px); }` so the press feels tactile. Default carriers: solid-fill buttons (`.btn-primary` / `-secondary` / `-danger`, bare `button`, `input[type="submit"]`, `form button`). The shadow is an opt-in signal that "this commits something," so a `.btn-primary` used purely for navigation (e.g. "Go to checkout" link) is the exception, not the rule — those keep depth because they lead to a commit, but pure view-switching uses of these classes should override to `box-shadow: none`. |
| 118 |
- Opacity fade to `0.6`: reserved for `.btn--link` (semantically a link, no surface). |
| 119 |
- **Focus** — `:focus-visible` shows the `--focus-ring` violet outline. Custom interactive containers (`.type-card`, `.pricing-card`, sort headers) must opt in by adding `:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }`. |
| 120 |
- **Selected / active** — `.is-selected` modifier applies `background: var(--highlight-faint)` plus the focus-ring border. Migrate `.tab.active`, `.filter-item.active`, `.view-btn.active`, `.badge.active`, and the `:checked + .type-card-inner` recipe onto this single class. |
| 121 |
- **Disabled** — `:disabled` and `[aria-disabled="true"]` show `opacity: 0.5` and `cursor: not-allowed`. |
| 122 |
- **Busy / loading** — HTMX-driven via `.htmx-request` on the trigger, plus a `partials/loading_skeleton.html` for content placeholders. |
| 123 |
|
| 124 |
## Page-level layouts |
| 125 |
|
| 126 |
- `.padded-page` — standard content padding (`1.5rem`). |
| 127 |
- `.centered-page` — landing / login / signup vertical-center layout. |
| 128 |
- `.container` + `--narrow` / `--medium` / `--wide` modifiers. |
| 129 |
- Wizard layout in `static/wizard.css` (`.wizard-layout` + `.wizard-sidebar` + `.wizard-content`). |
| 130 |
- Media layout in `static/media-player.css` (`.media-container`). |
| 131 |
|
| 132 |
No new top-level layout containers without an entry in this list. |
| 133 |
|
| 134 |
## Rules templates must follow |
| 135 |
|
| 136 |
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). |
| 137 |
2. **No page-scoped `<style>` blocks.** All page-isolated style blocks have been migrated. New templates do not get this exemption. |
| 138 |
3. **No raw hex.** Use tokens. Adding a new color means adding a token. |
| 139 |
4. **No checkmarks, emoji, or icon glyphs in copy.** Words only. The diamond mark is the only graphic element. Status uses `.badge`, not `✓`. |
| 140 |
5. **No new typefaces.** Three tiers, no exceptions. |
| 141 |
6. **Empty / error / loading states use the shared partial.** Never assemble these inline. |
| 142 |
7. **Destructive actions** use `.danger` button class plus the `confirm_dialog` macro. No bare destructive buttons. |
| 143 |
8. **Spacing values come from `--space-*` tokens.** Off-scale values are bugs. |
| 144 |
9. **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. |
| 145 |
10. **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). Bare tags inside server-rendered markdown / user content (e.g. `partials/item_text_editor.html` JS preview) are the only exception. |
| 146 |
11. **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. |
| 147 |
12. **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. |
| 148 |
|
| 149 |
## How to extend |
| 150 |
|
| 151 |
When a screen genuinely needs something not in this charter: |
| 152 |
|
| 153 |
1. Propose the primitive in this file (name, canonical class/partial, variants, states). |
| 154 |
2. Add the class to `style.css` in the matching section, or a partial under `templates/partials/`. |
| 155 |
3. Update the inventory table above. |
| 156 |
4. Then use it. Three usages without an entry in the charter means a missing primitive, not a license to inline. |
| 157 |
|
| 158 |
## Audit cadence |
| 159 |
|
| 160 |
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. |
| 161 |
|