# MNW Design System Charter 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. Source of truth for visual identity: the cross-project brand system (internal). Source of truth for primitives: this file plus `static/style.css`. ## Tokens Every visual value is a token defined in `:root` (`static/style.css`). No raw hex, no off-scale spacing, no bespoke shadow. | Tier | Token group | Where defined | |---|---|---| | 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` | | Typeface | `--font-mono` (Quasi Mono), `--font-sans` (Quasi Body), `--font-display` (Young Serif) | `typography.css` (generated by makeover-build) | | 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) | | 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) | | 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) | | 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` | | 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. | `style.css` `:root`, `layout.css` | 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)`: a fallback renders silently when the token does not exist, so a typo looks like a working rule. Pure `#000` and `#fff` are forbidden outside the token table. Bootstrap-derived yellows (`#fff3cd`, `#ffc107`) are forbidden. Use `--warning-bg` / `--warning-border`. ## Type Two independent choices, and every text element makes both: which typeface, and which size. ### Typeface tiers (from `brand.md`) - **H1 / wordmark / section heads**: `--font-display` (Young Serif), `normal` weight, color `--detail`. - **H2 / H3 / meta / taglines / footer**: `--font-mono` (Quasi Mono), `normal` weight. - **Body / lists / table cells / labels**: `--font-sans` (Quasi Body). Three tokens and no fourth. No `font-family` declarations in templates, and none of the three is defined in `style.css`: `--font-mono` and `--font-sans` are the house slots every product shares, and `--font-display` is MNW's own override of the brand slot, declared in `build.rs`. All three arrive in the generated `typography.css`. Adding a face means adding an override there, not a `font-family` here. See wiki `typography-standard`. ### Size tiers Nine, generated from makeover-geometry as ratios of `--geometry-base`, so every size tracks the reader's own root font size. Named for what the text is, on the same argument as `--gap-*`: whether a caption should be 13px or 14px cannot be reviewed, whether a piece of text is a caption can. | Token | What it is | At the default base | |---|---|---| | `--text-fine` | timestamps, badges, legal lines | 12px | | `--text-note` | metadata, table cells, captions, form help | 14px | | `--text-body` | running copy | 16px | | `--text-lead` | emphasised copy, card titles | 18px | | `--text-subhead` | h3 | 20px | | `--text-head` | h2, section heads | 24px | | `--text-title` | h1, page titles | 32px | | `--text-display` | display copy | 40px | | `--text-hero` | a landing hero, at most one per page | 48px | Nothing below `--text-fine`. A size under 12px is a legibility problem rather than a tier; text that should recede does it with colour or weight. Sizes do not shift under a coarse pointer, unlike spacing. Density is a claim about the contact patch and text is not a tap target, so a phone gets the same tiers a desktop does and the reader's root size is the knob. The `em` unit is banned for font sizes: it compounds against whatever the parent happens to be, so the same rule renders at different sizes depending on where it is used. Where text needs to sit one rung below its container (inline `code` inside long-form copy), name the rung. ## Components: canonical primitive table For each primitive, exactly one canonical class **or** one canonical partial. Variants are class modifiers; nothing else. ### Layout primitives (page scaffolding) | Primitive | Canonical class | Variants | Notes | |---|---|---|---| | Container | `.container` | width set by the page's body class, one rule per tier in `style.css` | Default `max-width: 1200px` | | Toolbar / header bar | `.stack-row` | `--bordered`, `--tight`, `--top` | Title-left + actions-right | | Inline form row | `.field-row` | child `.form-group.is-grow` / `.is-grow-2` | Input(s) + button bottom-aligned | | Vertical list row | `.list-row` | parts: `-title`, `-meta` | Flex with bottom border | | Two-col form grid | `.form-row` | (none) | `grid-template-columns: 1fr 1fr` | | Cover-image row | `.cover-row` + `.cover-thumb` + `.cover-empty` | (none) | 120×120 thumbnail picker | ### Component primitives (reusable blocks) `.card`'s fill and depth are not defined here. They come from `layout.css`, generated by makeover-webview, which paints the card `--surface-raised` plus `--bevel-raised` and hovers it to `--hover-surface` on a fine pointer. Do not restate either in `style.css`: that file is unlayered and unlayered CSS beats the `makeover` layer whatever the specificity, so a restated fill silently takes ownership back. Everything else about the card (padding, border, margin) is the site's. | Primitive | Canonical class | Variants | States | |---|---|---|---| | Page section box | `.content-section` + `.raised` | (none) | — | | 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` | | Button | `.btn-primary` / `.btn-secondary` / `.btn-danger` | `.btn--large`, `.btn--icon`, `.btn--link`, `.small`, `.saved` | `:hover`, `:focus-visible`, `:disabled`, `.htmx-request` | | Heading | `.brand-h1` (wordmark), `.page-title` (h1), `.subtitle-h2` (auth/wizard h2), `.subsection-title` (default h2), `.section-header` (h2 with bottom border) | — | — | | Input shape | (base `input`/`textarea`/`select`) | `.input--xs`, `.input--sm`, `.input--mono`, `.input--upper`, `.input--numeric` | `:focus`, `:disabled` | | Form field | `.form-group` + `.hint` | `.form-group--error` + `.field-error` | (via `_ui.html` macro `form_field`) | | Section lead | `.section-lead` | with `.mb-section` / `.text-sm` / `.dimmed` utilities | — | | Section divider | `.section-divider` | — | — | | Section grouping label | `.section-group-label` | — | — | | Badge | `.badge` | status via `data-tone="success" / "warning" / "danger"`, no tone for a neutral terminal state; `.ai-tier-*` (disclosure level) | `.is-faded` | | Tag | `.tag` (inside `.tag-input` for editing) | — | — | | Callout (solid-tint inline) | `.callout` | `--danger`, `--warning`, `--solid-warning` | — | | Alert (left-border inline) | `.alert` | `-note`, `-tip`, `-important`, `-warning`, `-caution` | — | | Banner (full-bleed page top) | `.banner` | `--info`, `--warning` | — | | Modal | `.modal-overlay` + `.modal` + `.form-actions` | — | — | | Confirm dialog | `_ui.html` macro `confirm_dialog` | — | — | | Toast | `partials/toast.html` → `.toast` | `--success`, `--error`, `--warning` | — | | Empty state | `.empty-state` (or `_ui.html` macro) | `--compact`, `--chart`, `--lg` | — | | Progress bar | `.progress-bar-container` + `.progress-bar` | `--slim` (6px), `--rounded`, `.progress-bar--highlight` (purple, default green) | — | | Upload status block | `.upload-status` + `-row` | `-msg.is-success`, `-msg.is-error` | — | | Status pill | `.field-status` / `.save-status` | `.success`, `.error`, `.saving` | — | | Table | `.data-table` + `.well` (rich), `.compact-table` (small mono) | `.minw-300..800` for horizontal scroll min-width | — | | Tabs | `.tabs` + `.tab` | — | `.tab.chosen` (makeover's name, not the site's) | | Breadcrumb | `.breadcrumb` | — | — | | Pagination | `_ui.html` macro `pagination` → `.pagination` | — | `.active` | Tokens live in `static/style.css` `:root`. The four parameterized macros (`empty_state`, `form_field`, `confirm_dialog`, `pagination`) are in `templates/partials/_ui.html`. ### Composition guide for new features Build new UI by composing primitives, not by writing fresh CSS. Order of preference: 1. **Use a utility class** for one-off spacing/sizing, `.mb-section`, `.text-sm`, `.nowrap`, `.danger-text`. 2. **Use a layout primitive** to position content, `.container`, `.stack-row`, `.field-row`, `.list-row`. 3. **Use a component primitive** for a UI element, `.card`, `.callout`, `.badge`, `.progress-bar`, etc. 4. **Extend a primitive with a modifier**: `.card--bordered`, `.callout--warning`, `.stack-row--bordered`, `.input--sm`. 5. **Only then consider a new class**: and add it to the design system table here AND to `style.css` in the matching section. 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. **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"`. 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. ### How to use the macros The codebase has two partial conventions: - **`{% 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`. - **Macros in `partials/_ui.html`**: parameterized primitives. Import once and call: ```jinja {%- import "partials/_ui.html" as ui -%} ... {% call ui::empty_state("No items yet", "Create one to get started.") %} {% call ui::form_field("Title", "title", value, "Up to 80 characters.", error) %} {% call ui::confirm_dialog("Delete item?", "This can't be undone.", "/item/123/delete", "Delete", "/item/123") %} {% call ui::pagination(current_page, total_pages, "/items?page=") %} ``` 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. ## State vocabulary Exactly one spelling for each interaction state, applied to every interactive primitive: - **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`. - **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. There is no depth-lift variant for controls that commit a write. A bevel is an edge and not a distance, so it has no growth axis to extend along, and a commit-versus-navigate cue would mean a local override on top of the `layout.css` every other make-family app takes unmodified. A control that commits a write is distinguished by its label and its placement. See wiki `mnw-platinum`. - **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. - **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. Where makeover has a primitive, its name wins; the components it has none for keep `.is-selected`. - **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. 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. `.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". 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 makes the stylesheet mirror whatever the schema spells, so a column rename unstyles the badge and a status nobody wrote a rule for renders plain. Each status enum maps into the set with an exhaustive match, so a new variant is a compile error. - **Disabled**: `:disabled` and `[aria-disabled="true"]` show `opacity: 0.5` and `cursor: not-allowed`. - **Busy / loading**: HTMX-driven via `.htmx-request` on the trigger. There is no skeleton primitive. ## Page-level layouts - `.padded-page`: standard content padding (`1.5rem`). - `.centered-page`: landing / login / signup vertical-center layout. - `.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. - Wizard layout in `static/wizard.css` (`.wizard-layout` + `.wizard-sidebar` + `.wizard-content`). - Media layout in `static/media-player.css` (`.media-container`). No new top-level layout containers without an entry in this list. ## Rules templates must follow 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). 2. **No page-scoped `