| 1 |
# MNW Frontend |
| 2 |
|
| 3 |
Server-rendered HTML via Askama templates, HTMX for interactivity, hand-authored CSS. No JS framework. There is one build step: `frontend/` is TypeScript compiled to `static/dist/`, and the tab strips and several other regions are described in Rust under `src/quasi/` and rendered into the templates as strings. |
| 4 |
|
| 5 |
## Design System |
| 6 |
|
| 7 |
Typography and brand colors come from the cross-project brand system (three-tier type system, base palette, diamond mark rule). `docs/design-system.md` is the charter and the source of truth for token names; this file records how templates and CSS use them. |
| 8 |
|
| 9 |
### Where the tokens live |
| 10 |
|
| 11 |
|
| 12 |
|
| 13 |
| Surfaces | `--surface-page`, `--surface-raised`, `--surface-sunken`, `--surface-overlay`, `--surface-well`, `--bevel-light`, `--bevel-dark` | `static/style.css` `:root` | |
| 14 |
| Content | `--content`, `--content-secondary`, `--content-muted`, `--content-on-action` | `static/style.css` `:root` | |
| 15 |
| Action and status | `--action`, `--action-hover`, `--success`, `--warning`, `--danger`, `--info` | `static/style.css` `:root` | |
| 16 |
| Lines and focus | `--border`, `--border-strong`, `--focus-ring`, `--hover-surface`, `--overlay` | `static/style.css` `:root` | |
| 17 |
| Typeface | `--font-mono`, `--font-sans`, `--font-display` | `static/typography.css` (generated) | |
| 18 |
| Type size, spacing, radius | `--text-*`, `--gap-*`, `--radius-*` | `static/geometry.css` (generated) | |
| 19 |
|
| 20 |
The `:root` block in `style.css` holds the platform default. Creator profile, project and item pages inject a `<style>` that supersedes it with the chosen theme, so every one of these is themeable. |
| 21 |
|
| 22 |
### Rules |
| 23 |
|
| 24 |
- **No pure white/black.** Use `--surface-page` and `--content` instead. |
| 25 |
- **Accent color is for the dot only.** Do not use `--action` for arbitrary borders or decoration. |
| 26 |
- **No hex or `rgba()` literals outside `:root`.** Every colour elsewhere is a `var()` or a `color-mix()` over one. Write the token, not `var(--token, #fallback)`. |
| 27 |
|
| 28 |
## Template Structure |
| 29 |
|
| 30 |
``` |
| 31 |
templates/ |
| 32 |
base.html Head from crate::shell, page blocks, footer, script tail |
| 33 |
index.html Landing page |
| 34 |
pages/ Full page templates (extend base.html) |
| 35 |
login.html, discover.html, item.html, project.html, library.html, ... |
| 36 |
dashboards/ Dashboard pages (extend base.html, tabbed) |
| 37 |
dashboard-user.html User dashboard (Projects, Payments, Analytics, Settings, Support) |
| 38 |
dashboard-project.html Project dashboard (Overview, Content, Analytics, Monetization, Code, Cloud Sync, Settings) |
| 39 |
dashboard-item.html Item dashboard (Overview, Details, Pricing, Files, Sales) |
| 40 |
dashboard-blog-editor.html, dashboard-export.html, dashboard-import.html |
| 41 |
admin-*.html Admin panels (waitlist, users, uploads, appeals, reports, metrics) |
| 42 |
wizards/ Multi-step create flows (join, project, item) |
| 43 |
partials/ HTMX fragments (no base.html, returned directly) |
| 44 |
tabs/ Dashboard tab and settings-section content |
| 45 |
user_profile.html, user_account.html, user_payments.html, |
| 46 |
user_creator.html, user_media.html, user_sessions.html, |
| 47 |
user_settings.html, user_synckit.html |
| 48 |
project_code.html, project_monetization.html, project_promotions.html, |
| 49 |
project_settings.html, project_subscriptions.html, project_synckit.html |
| 50 |
item_details.html, item_embed.html, item_overview.html, item_pricing.html |
| 51 |
library_collections.html, library_purchases.html |
| 52 |
admin_*.html Admin table rows (HTMX swap targets) |
| 53 |
_ui.html Shared Askama macros |
| 54 |
alert.html, form_status.html, save_status.html Status feedback fragments |
| 55 |
``` |
| 56 |
|
| 57 |
### Conventions |
| 58 |
|
| 59 |
- Full pages extend `base.html` and define `{% block content %}`. |
| 60 |
- The document head is not written in `base.html`. `crate::shell::head()` emits it, so a described screen that owns its whole document and an Askama page get the same head. |
| 61 |
- Tab content is loaded via HTMX (`hx-get="/dashboard/tabs/payments"` etc). |
| 62 |
- Partials are standalone HTML fragments, no `{% extends %}`. |
| 63 |
- Template structs live in `src/templates/dashboard.rs` (pages), `src/templates/partials.rs` (fragments), `src/templates/public/` (landing, auth, git, health) and `src/templates/embed.rs`. |
| 64 |
|
| 65 |
## CSS Architecture |
| 66 |
|
| 67 |
Five stylesheets, loaded in this order by `crate::shell::head()`: `typography.css`, `geometry.css`, `timing.css`, `layout.css`, `style.css`. The first four are generated by `build.rs` through makeover-build and are marked do-not-edit at the top of each file. `style.css` is the only hand-authored one. No preprocessor, no minification pipeline. |
| 68 |
|
| 69 |
### Section Order |
| 70 |
|
| 71 |
`style.css` opens with a FILE STRUCTURE comment that indexes every section by name; search the file for a section name to jump to it. The order is: |
| 72 |
|
| 73 |
1. **Foundations** (`@font-face`, `:root` tokens, element base) |
| 74 |
2. **Buttons** (`.btn-primary`, `.btn-secondary`, `.btn-danger`, plus `.small` / `.btn-compact` / `.btn-link` / `.btn-tiny` modifiers) |
| 75 |
3. **Forms** (`.form-group`, `.form-section`, `.field-row`, the `input--*` modifiers) |
| 76 |
4. **Tabs** (`.tabs`, `.tab`, `.tab.chosen`) |
| 77 |
5. **Tables** (`.data-table`, then `.compact-table`) |
| 78 |
6. **Utilities** (spacing, sizing, text, state, shapes) |
| 79 |
7. **Badges** and **Tags** |
| 80 |
8. **Cards** and **Stats** (`.card`, `.card-muted`, `.card--bordered`, `.stat-box`) |
| 81 |
9. **Filter bar**, **Discussion**, **Notification surfaces** (the `.info-box` / `.warning-box` / `.error-message` roles) |
| 82 |
10. **Page-scoped layouts**, the long run: error, buy, sectioned content, item, project, library, article, admin, import wizard, delete account, export, receipt, Fan+, purchase, project settings, cart, profile |
| 83 |
11. **Sections, Navigation, Header and footer, Layout utilities, Specific components**, then **HTMX loading indicators**, **Toast**, **Save status**, **HTMX transitions**, **Focus styles**, **Paywall**, **Modals** |
| 84 |
12. A second, larger run of **page-scoped layouts** (landing, docs, use cases, git browser, discover, and one section per tab or partial), ending with **Layer handoffs**, the **described modal** and the **media picker** |
| 85 |
|
| 86 |
The notification-surface primitives are not all in the section that names them: `.empty-state` sits in Utilities, `.banner` just above Sections, `.toast` and `.modal` in their own sections after Specific components, `.progress-bar` with the audio upload, `.callout` in the Account tab section and `.alert` with the documentation pages. Search the file for the class, not for the section. |
| 87 |
|
| 88 |
Responsive rules are not all at the end either: the 768px and 480px blocks sit in the middle of the second page-scoped run, and the later sections carry their own `@media` queries. |
| 89 |
|
| 90 |
### Utility Classes |
| 91 |
|
| 92 |
|
| 93 |
|
| 94 |
| `.text-sm` | `font-size: var(--text-note)` | Secondary text (emails, dates) | |
| 95 |
| `.text-xs` | `font-size: var(--text-fine)` | Tertiary text (IDs, annotations) | |
| 96 |
| `.muted` | `opacity: 0.7` | De-emphasized content | |
| 97 |
| `.dimmed` | `opacity: 0.6` | Further de-emphasized | |
| 98 |
| `.dimmer` | `opacity: 0.5` | Furthest de-emphasized | |
| 99 |
| `.meta` | `font-size: var(--text-note); opacity: 0.7` | Labels, captions | |
| 100 |
| `.nowrap` | `white-space: nowrap` | Dates, status badges | |
| 101 |
| `.scroll-x` | `overflow-x: auto; -webkit-overflow-scrolling: touch` | Horizontal scroll wrappers | |
| 102 |
| `.text-center` | `text-align: center` | Centered blocks | |
| 103 |
| `.text-right` | `text-align: right` | Right-aligned cells | |
| 104 |
| `.fw-bold` | `font-weight: bold` | Emphasis without a heading | |
| 105 |
| `.small` | `padding: var(--gap-bound) var(--gap-group); font-size: var(--text-fine)` | Compact buttons in tables | |
| 106 |
| `.empty-state` | `text-align: center; padding: var(--gap-page); opacity: 0.6` | Empty list messages | |
| 107 |
|
| 108 |
### Table Classes |
| 109 |
|
| 110 |
**`.data-table`**: Full-featured tables with alternating row backgrounds, hover states, and sortable headers. Used for transactions, blog posts, content items. |
| 111 |
|
| 112 |
**`.compact-table`**: Lighter admin/dashboard tables. Provides `width: 100%`, `border-collapse`, cell padding, header styling and row borders. Used for admin user/waitlist/upload tables, invite codes, wave history. |
| 113 |
|
| 114 |
### Badges |
| 115 |
|
| 116 |
`.badge` is the base. Tone comes from a `data-tone` attribute rather than a modifier class: |
| 117 |
|
| 118 |
```html |
| 119 |
<span class="badge" data-tone="success">Trusted</span> |
| 120 |
``` |
| 121 |
|
| 122 |
`success`, `warning` and `danger` are the tones. The modifiers that exist beside them are `.badge--founder-locked`, `.badge--founder-pending`, `.badge--inline-l`, `.badge--inline-tiny`, `.badge-new`, the `.badge.ai-tier*` disclosure set and `.badge.is-faded`. |
| 123 |
|
| 124 |
### Button Variants |
| 125 |
|
| 126 |
Buttons use the `.btn-*` family. See `design-system.md` ยง Buttons for the current class names, colors, and modifiers. Do not use the bare `.primary` / `.secondary` / `.danger` classes; use `.btn-primary`, `.btn-secondary`, `.btn-danger`. |
| 127 |
|
| 128 |
## HTMX Patterns |
| 129 |
|
| 130 |
### Tab Navigation |
| 131 |
|
| 132 |
Tab strips are not written as markup. Each one is described in Rust and rendered into its Askama page as a string: `src/quasi/user_tabs.rs`, `project_tabs.rs`, `item_tabs.rs`, `library_tabs.rs` and `settings_tabs.rs`. The page passes the result through: |
| 133 |
|
| 134 |
```html |
| 135 |
{{ tabs|safe }} |
| 136 |
``` |
| 137 |
|
| 138 |
The description names each tab, the region its panel lands in, and what fetches that panel: |
| 139 |
|
| 140 |
```rust |
| 141 |
let mut strip = Slot::new(STRIP, RegionKind::TabGroup) |
| 142 |
.across(layout::Fallback::Menu) |
| 143 |
.showing_one(shown); |
| 144 |
|
| 145 |
for (at, tab) in tabs.iter().enumerate() { |
| 146 |
let mut region = Slot::bespoke(tab.panel, "item-panel").label(tab.label); |
| 147 |
if at != shown { |
| 148 |
region = region.fed_by( |
| 149 |
Action::get(format!("/dashboard/item/{item_id}/tabs/{}", tab.route)) |
| 150 |
.awaiting() |
| 151 |
.replacing(tab.panel), |
| 152 |
); |
| 153 |
} |
| 154 |
strip = strip.with(Node::Region(region)); |
| 155 |
} |
| 156 |
``` |
| 157 |
|
| 158 |
Three things follow from this and are worth knowing before touching a strip. |
| 159 |
|
| 160 |
- **Each panel is its own region.** There is no single `#tab-content` pane for every tab to swap into, so an action outside the strip names the panel it means (`#item-sales`, `#user-settings`). |
| 161 |
- **The shown tab is rendered inline by the page handler** and is the one tab that does not fetch. The page arrives at final geometry rather than fetching its first panel after load. |
| 162 |
- **A deep link is a `?tab=` query**, read server-side by each module's `shown_at`, not a `#tab-*` hash restored by JS. Unknown names answer the first tab. |
| 163 |
|
| 164 |
`Action::awaiting()` is what an act in flight says. There is no `setActiveTab`, no `tab-spinner` indicator and no hash restore. |
| 165 |
|
| 166 |
### Admin Table Updates |
| 167 |
|
| 168 |
Admin actions (suspend, approve, trust) return the full table partial to replace the table body: |
| 169 |
|
| 170 |
```html |
| 171 |
<button class="btn-secondary small" |
| 172 |
hx-post="/api/admin/users/{{ user.id }}/trust" |
| 173 |
hx-target="#users-table" |
| 174 |
hx-swap="innerHTML">Trust</button> |
| 175 |
``` |
| 176 |
|
| 177 |
### Form Feedback |
| 178 |
|
| 179 |
Form submissions target a status element for inline feedback: |
| 180 |
|
| 181 |
```html |
| 182 |
<form hx-put="/api/users/me" |
| 183 |
hx-target="#profile-save-status" |
| 184 |
hx-swap="innerHTML" |
| 185 |
hx-indicator="#profile-spinner"> |
| 186 |
``` |
| 187 |
|
| 188 |
The handler returns an `AlertTemplate` or `FormStatusTemplate` partial. `hx-indicator` is the idiom in Askama markup; a described region uses `Action::awaiting()` instead. |
| 189 |
|
| 190 |
### CSRF |
| 191 |
|
| 192 |
All mutating requests include CSRF tokens. `base.html` emits a `csrf-token` meta tag. `frontend/src/core/net.ts` reads it and returns the `X-CSRF-Token` header from `csrfHeaders()`, which `core/index.ts` also exports on `window` for hand-written `fetch()` calls. HTMX requests get the header from `frontend/src/core/htmx-glue.ts`, which attaches it on `htmx:config:request`. The token is read live on every request because it rotates mid-session. |
| 193 |
|
| 194 |
## JavaScript |
| 195 |
|
| 196 |
Two layers. `frontend/src/` is TypeScript, compiled to `static/dist/` and loaded as a module. Everything else in `static/` is hand-written JS loaded with a plain `<script>`. |
| 197 |
|
| 198 |
|
| 199 |
|
| 200 |
| `frontend/src/core/dispatch.ts` | Delegated event dispatcher: `data-action`, `data-change`, `data-input`, `data-submit`, `data-after` | |
| 201 |
| `frontend/src/core/net.ts` | `csrfHeaders()` and the fetch helpers | |
| 202 |
| `frontend/src/core/toast.ts` | `showToast(message)` | |
| 203 |
| `frontend/src/core/htmx-glue.ts` | htmx lifecycle hooks | |
| 204 |
| `static/htmx.min.js` | HTMX library (vendored), plus the `hx-history-cache` and `hx-prompt` extensions | |
| 205 |
| `static/quasi-*.js` | The renderer's behaviour modules, one per described capability | |
| 206 |
| `static/actions-*.js` | Verb registration shims for pages, partials, tabs and dashboards | |
| 207 |
| `static/passkey.js` | WebAuthn passkey registration and authentication | |
| 208 |
| `static/upload.js` | `S3Uploader`, file upload with progress bars | |
| 209 |
| `static/page-*.js`, `tab-*.js`, `wizard-*.js` | Per-screen behaviour, loaded by the screen that needs it | |
| 210 |
|
| 211 |
### Conventions |
| 212 |
|
| 213 |
- No inline `<script>` in templates and no `on*` or `hx-on::` attributes. `script-src 'self'` with no `unsafe-inline` is enforced, and logic in an attribute value breaks it. |
| 214 |
- Behaviour is named with a verb in `data-action` and resolved against the dispatcher's registry. Never invent a private attribute for a behaviour; add a verb. |
| 215 |
- Use `csrfHeaders()` for all `fetch()` calls. |
| 216 |
- Use `showToast(message)` for user notifications. |
| 217 |
- Use `hx-confirm` for destructive actions. |
| 218 |
- No external JS dependencies beyond HTMX. |
| 219 |
- No JS framework, vanilla DOM manipulation only. |
| 220 |
- `_hyperscript` is declined permanently. See `design-system.md` ยง Interaction idioms. |
| 221 |
|
| 222 |
## Static Assets |
| 223 |
|
| 224 |
``` |
| 225 |
static/ |
| 226 |
style.css Main stylesheet |
| 227 |
typography.css Generated: the three typeface tokens |
| 228 |
geometry.css Generated: type size, spacing, radius tokens |
| 229 |
timing.css Generated: duration tokens |
| 230 |
layout.css Generated: layout primitives and the bevel pair |
| 231 |
wizard.css, media-player.css, no-js.css, embed-*.css Loaded per screen |
| 232 |
htmx.min.js HTMX library |
| 233 |
dist/ Compiled output of frontend/src (core/ and islands/) |
| 234 |
quasi-*.js Renderer behaviour modules |
| 235 |
actions-*.js Verb registration shims |
| 236 |
passkey.js WebAuthn integration |
| 237 |
upload.js File upload with progress |
| 238 |
fonts/ Quasi Body, Quasi Mono (woff2), Young Serif (ttf + woff2) |
| 239 |
images/ Logo, favicons, og-card, screenshots |
| 240 |
bases/ Content-addressed theme base images |
| 241 |
patterns/ Background SVGs |
| 242 |
``` |
| 243 |
|
| 244 |
## Progressive Disclosure |
| 245 |
|
| 246 |
The dashboard conditionally shows features based on user state. The gates are the `Gate` enums in the tab-strip modules, not template conditionals: |
| 247 |
|
| 248 |
- **Projects and Analytics tabs** (`src/quasi/user_tabs.rs`): only visible when `can_create_projects` is true. |
| 249 |
- **Every user tab except Support**: hidden once the account is deactivated. Support is the one tab a deactivated account keeps, because it is the only thing it can do. |
| 250 |
- **Media settings section** (`src/quasi/settings_tabs.rs`): only when the reader can create projects. |
| 251 |
- **SSH Keys settings section**: only when git hosting is enabled on the server. |
| 252 |
- **Forums settings section**: only when the Multithreaded integration is configured. |
| 253 |
- **Cloud Sync settings section**: only when the reader owns at least one sync app. |
| 254 |
- **Code tab** (`src/quasi/project_tabs.rs`): only when the project has git enabled. |
| 255 |
- **Cloud Sync tab** (project dashboard): only when SyncKit is configured for the project. |
| 256 |
- **Files tab** (`src/quasi/item_tabs.rs`): absent on a bundle, which carries other items rather than files of its own. |
| 257 |
- **Onboarding checklist**: auto-dismissed when all steps complete (or manually skipped). |
| 258 |
|
| 259 |
## Key Paths |
| 260 |
|
| 261 |
|
| 262 |
|
| 263 |
| CSS | `static/style.css`, `static/layout.css` | |
| 264 |
| Generated tokens | `static/typography.css`, `static/geometry.css`, `static/timing.css` | |
| 265 |
| Document head and script tail | `src/shell.rs` | |
| 266 |
| Base template | `templates/base.html` | |
| 267 |
| Dashboard pages | `templates/dashboards/` | |
| 268 |
| Tab partials | `templates/partials/tabs/` | |
| 269 |
| Admin partials | `templates/partials/admin_*.html` | |
| 270 |
| Described regions | `src/quasi/` | |
| 271 |
| Template structs | `src/templates/dashboard.rs`, `src/templates/partials.rs` | |
| 272 |
| Dashboard handlers | `src/routes/pages/dashboard/` | |
| 273 |
| TypeScript core | `frontend/src/core/` | |
| 274 |
| JS (passkeys) | `static/passkey.js` | |
| 275 |
| JS (uploads) | `static/upload.js` | |
| 276 |
|