max / makenotwork
11 files changed,
+138 insertions,
-18 deletions
| @@ -278,6 +278,59 @@ Every full-page template needs at minimum: | |||
| 278 | 278 | - `csrf_token: Option<String>` — for the CSRF meta tag | |
| 279 | 279 | - `session_user: Option<SessionUser>` — for the header (login state, avatar) | |
| 280 | 280 | ||
| 281 | + | ## Frontend Performance | |
| 282 | + | ||
| 283 | + | These rules apply to all HTML templates. The goal is zero layout shift, no wasted pixels, and instant-feeling interactions. | |
| 284 | + | ||
| 285 | + | ### Images | |
| 286 | + | ||
| 287 | + | Every `<img>` must have explicit dimensions to prevent layout shift: | |
| 288 | + | ```html | |
| 289 | + | <!-- Inline style with both width and height --> | |
| 290 | + | <img src="{{ url }}" alt="{{ title }}" | |
| 291 | + | style="width: 120px; height: 120px; object-fit: cover;"> | |
| 292 | + | ||
| 293 | + | <!-- Or use aspect-ratio for responsive images --> | |
| 294 | + | <img src="{{ url }}" alt="{{ title }}" | |
| 295 | + | style="width: 100%; aspect-ratio: 1 / 1; object-fit: cover;"> | |
| 296 | + | ``` | |
| 297 | + | ||
| 298 | + | Never use `loading="lazy"` for images that may appear above the fold (the first visible screen before scrolling). | |
| 299 | + | ||
| 300 | + | ### Navigation and Reloads | |
| 301 | + | ||
| 302 | + | Never use `window.location.reload()` when an HTMX partial swap can do the job. Full-page reloads discard all client state, re-parse all JS/CSS, and feel slow. | |
| 303 | + | ||
| 304 | + | ```html | |
| 305 | + | <!-- Bad: full-page reload after action --> | |
| 306 | + | <button hx-put="/api/items/{{ id }}" | |
| 307 | + | hx-on::after-request="if(event.detail.successful) window.location.reload()"> | |
| 308 | + | ||
| 309 | + | <!-- Good: re-fetch the relevant tab/section --> | |
| 310 | + | <button hx-put="/api/items/{{ id }}" | |
| 311 | + | hx-on::after-request="if(event.detail.successful) document.getElementById('tab-settings').click()"> | |
| 312 | + | ``` | |
| 313 | + | ||
| 314 | + | Legitimate uses of `window.location.href`: | |
| 315 | + | - Navigating to a different page entirely (login, checkout redirect, file download) | |
| 316 | + | - After destructive account actions (delete account → login page) | |
| 317 | + | ||
| 318 | + | ### Loading States | |
| 319 | + | ||
| 320 | + | Prefer server-complete responses over client-side loading placeholders. The server should wait for data and send a complete fragment in one paint, rather than sending a skeleton and filling it in later. | |
| 321 | + | ||
| 322 | + | Use `hx-trigger="revealed"` with a loading placeholder only when the data is expensive to fetch AND hidden behind a `<details>` element (e.g., 2FA status, passkey list). Never use loading placeholders for content that's visible on initial tab load. | |
| 323 | + | ||
| 324 | + | ### Inline Scripts | |
| 325 | + | ||
| 326 | + | Keep inline `<script>` blocks under 20 lines. Extract larger scripts to static JS files in `server/static/`. Static files are cached by the browser; inline scripts are re-parsed on every HTMX fragment swap. | |
| 327 | + | ||
| 328 | + | ### Information Density | |
| 329 | + | ||
| 330 | + | Show more data in less space. Prefer tight rows over hero cards. Let users compare by scanning, not by clicking into detail pages. A page that shows 15 items is more useful than one that shows 3. | |
| 331 | + | ||
| 332 | + | Use typography (weight, size) and whitespace for hierarchy instead of borders and background colors. A 16px gap groups elements as well as a 1px border, with less visual noise. | |
| 333 | + | ||
| 281 | 334 | ## CSRF Protection | |
| 282 | 335 | ||
| 283 | 336 | The CSRF middleware (`src/csrf.rs`) validates all state-changing requests (POST, PUT, PATCH, DELETE) except exempted paths (webhooks, auth endpoints, OAuth). |
| @@ -0,0 +1,64 @@ | |||
| 1 | + | # Performance Philosophy | |
| 2 | + | ||
| 3 | + | Tufte's core insight: respect the user by showing them the data. McMaster-Carr proved it at scale — 700,000 products, no decoration, universally praised. The best interface disappears, leaving only the information the user came for. | |
| 4 | + | ||
| 5 | + | ## Principles | |
| 6 | + | ||
| 7 | + | ### 1. Every Element Earns Its Place | |
| 8 | + | ||
| 9 | + | Tufte's data-ink ratio: of all pixels on a page, what fraction communicates actual information? Maximize that fraction. McMaster-Carr has no hero images, no banners, no carousels. Their search results are dense text because text is faster to scan than thumbnails. | |
| 10 | + | ||
| 11 | + | A storefront shows the creator's work, not the platform's branding. Navigation is tight. Metadata (price, format, date, size) is visible without clicking. If a UI element doesn't help the user buy, sell, or browse, it doesn't ship. | |
| 12 | + | ||
| 13 | + | ### 2. Speed Is the Design | |
| 14 | + | ||
| 15 | + | McMaster-Carr's search feels instant because it is instant. Slow interfaces are a form of chartjunk — decoration where information should be. | |
| 16 | + | ||
| 17 | + | Server-rendered HTML + HTMX is the right architecture. A 50ms server response with a complete HTML fragment always feels faster than a 200ms JSON payload that a client framework then renders. Commitments: | |
| 18 | + | ||
| 19 | + | - No full-page reloads for navigation. HTMX swaps fragments. | |
| 20 | + | - No layout shift. Elements have known dimensions before content arrives. | |
| 21 | + | - No lazy-loading above the fold. First screen is complete on first paint. | |
| 22 | + | - Measure time-to-interactive, not time-to-first-byte. The user's clock starts when they click. | |
| 23 | + | - Pre-fetch on hover when the next action is predictable (tabs, navigation links). | |
| 24 | + | ||
| 25 | + | ### 3. Density Is Generosity | |
| 26 | + | ||
| 27 | + | Dense information is easier to read, not harder, when the design is clean. The problem was never complexity — it was chartjunk masquerading as organization. Minard fit six variables onto one map. McMaster-Carr puts dozens of products on a screen without scrolling. | |
| 28 | + | ||
| 29 | + | A catalog page should show 15-20 items without scrolling, not 3 hero cards. Each item gets a tight row: title, price, format badge, one-line description. Users compare by scanning, not by clicking into detail pages and pressing back. | |
| 30 | + | ||
| 31 | + | ### 4. Hierarchy Through Typography, Not Decoration | |
| 32 | + | ||
| 33 | + | McMaster-Carr uses almost no color. Hierarchy comes from weight, size, and spacing. Tufte's "smallest effective difference" — the least visual distinction that still communicates structure. | |
| 34 | + | ||
| 35 | + | - Weight and size for titles vs metadata vs body. | |
| 36 | + | - Whitespace to group related elements. | |
| 37 | + | - Consistent alignment so the eye can scan columns. | |
| 38 | + | - Borders only when spacing alone is ambiguous. | |
| 39 | + | ||
| 40 | + | ### 5. Complexity When the Data Demands It | |
| 41 | + | ||
| 42 | + | Tufte's sharpest criticism is oversimplification — the PowerPoint mentality. McMaster-Carr never hides specs behind "show more." If a bolt has 14 dimensions, all 14 are visible. | |
| 43 | + | ||
| 44 | + | A creator selling a sample pack might have format, sample rate, BPM, key, instrument, duration, license, and tier. Show all of it. Don't collapse it to make the page look cleaner. Clean is not the goal. Clear is the goal. | |
| 45 | + | ||
| 46 | + | ### 6. Refuse to Waste Attention | |
| 47 | + | ||
| 48 | + | McMaster-Carr has no concept of "engagement." No notification badges, no algorithmic feeds, no interruptions during checkout. The user has a task. The site completes it. | |
| 49 | + | ||
| 50 | + | - No interstitials. | |
| 51 | + | - No dark patterns. "No thanks" is the same size as "yes." | |
| 52 | + | - Search that works. Server-side, fast, honest results. | |
| 53 | + | - Checkout in as few steps as possible. | |
| 54 | + | ||
| 55 | + | ## Applied to MNW | |
| 56 | + | ||
| 57 | + | MNW already has the right foundations: server-rendered HTML, HTMX, warm beige/charcoal palette, intentional three-tier typography (Young Serif / IBM Plex Mono / Lato), no SPA overhead. The performance pass is about removing what slows things down, not adding what speeds things up. | |
| 58 | + | ||
| 59 | + | Concrete targets: | |
| 60 | + | - Dashboard tab switch: < 100ms perceived (pre-fetch on hover) | |
| 61 | + | - Discover page filter: < 150ms (HTMX partial swap, no full reload) | |
| 62 | + | - Item purchase to download: < 3 clicks from any page | |
| 63 | + | - Public project page: complete first paint with no layout shift | |
| 64 | + | - Zero skeleton loaders — if data isn't ready, the server waits; the browser paints once |
| @@ -30,7 +30,12 @@ Split checkout.rs (792 -> 434 + 308 helpers). yara-x bumped 1.14->1.15 (intaglio | |||
| 30 | 30 | Dashboard restructure complete (Phases 1-6 in todo_done.md). Tab layout: Projects, Payments, Analytics, Profile, Account, Plan + overflow. Fan/creator progressive disclosure implemented. Labels removed. Jargon renamed. | |
| 31 | 31 | ||
| 32 | 32 | #### Performance | |
| 33 | - | - [ ] Cross-site performance pass — pre-fetch tab data on hover, optimize perceived load times | |
| 33 | + | - [x] Add performance philosophy doc (`docs/performance_philosophy.md`) — Tufte/McMaster-Carr principles applied to MNW | |
| 34 | + | - [x] Add frontend performance rules to CONTRIBUTING.md — image dimensions, reload avoidance, loading states, density | |
| 35 | + | - [x] Fix layout shift: add aspect-ratio to all images missing dimensions (item page, audio player, buy page, wizards) | |
| 36 | + | - [x] Replace `window.location.reload()` with HTMX tab re-fetch on item settings (4x), item details (1x), user profile (2x) | |
| 37 | + | - [x] Remove "Loading..." placeholder text from 2FA and passkey sections (empty until revealed) | |
| 38 | + | - [ ] Extract heavy inline scripts to static JS — item_details.html (278 lines), upload handlers (~250 lines combined). Requires data-attribute refactor for template variables | |
| 34 | 39 | ||
| 35 | 40 | #### Discoverability | |
| 36 | 41 | - [ ] Add Media Library access from content editors — "Insert Image" button in blog/item editors |
| @@ -459,7 +459,7 @@ | |||
| 459 | 459 | {% match item.content %} | |
| 460 | 460 | {% when crate::types::ItemContent::Audio with { cover_url, .. } %} | |
| 461 | 461 | {% if let Some(url) = cover_url %} | |
| 462 | - | <img src="{{ url }}" alt="{{ item.title }} cover art"> | |
| 462 | + | <img src="{{ url }}" alt="{{ item.title }} cover art" style="width: 100%; aspect-ratio: 1 / 1; object-fit: cover;"> | |
| 463 | 463 | {% else %} | |
| 464 | 464 | [Cover Art] | |
| 465 | 465 | {% endif %} |
| @@ -155,7 +155,7 @@ | |||
| 155 | 155 | {% if item.cover_image_url.is_some() %} | |
| 156 | 156 | <div class="item-media"> | |
| 157 | 157 | {% if let Some(img) = item.cover_image_url %} | |
| 158 | - | <img src="{{ img }}" alt="{{ item.title }}" style="width: 100%; display: block;"> | |
| 158 | + | <img src="{{ img }}" alt="{{ item.title }}" style="width: 100%; aspect-ratio: 1 / 1; object-fit: cover; display: block;"> | |
| 159 | 159 | {% endif %} | |
| 160 | 160 | </div> | |
| 161 | 161 | {% endif %} |
| @@ -66,7 +66,7 @@ | |||
| 66 | 66 | hx-put="/api/items/{{ item.id }}/primary-tag" | |
| 67 | 67 | hx-vals='{"tag_id": "{{ tag.id }}"}' | |
| 68 | 68 | hx-swap="none" | |
| 69 | - | hx-on::after-request="window.location.reload()">☆</button> | |
| 69 | + | hx-on::after-request="document.getElementById('tab-details').click()">☆</button> | |
| 70 | 70 | {% endif %} | |
| 71 | 71 | </span> | |
| 72 | 72 | {% endfor %} |
| @@ -13,7 +13,7 @@ | |||
| 13 | 13 | hx-put="/api/items/{{ item.id }}" | |
| 14 | 14 | hx-vals='{"publish_at": ""}' | |
| 15 | 15 | hx-confirm="Cancel the scheduled publish?" | |
| 16 | - | hx-on::after-request="if(event.detail.successful) window.location.reload()">Cancel Schedule</button> | |
| 16 | + | hx-on::after-request="if(event.detail.successful) document.getElementById('tab-settings').click()">Cancel Schedule</button> | |
| 17 | 17 | </div> | |
| 18 | 18 | {% else if item.is_public %} | |
| 19 | 19 | <!-- Published: show unpublish button --> | |
| @@ -25,7 +25,7 @@ | |||
| 25 | 25 | hx-put="/api/items/{{ item.id }}" | |
| 26 | 26 | hx-vals='{"is_public": "false"}' | |
| 27 | 27 | hx-confirm="Unpublish this item? It will be hidden from public view." | |
| 28 | - | hx-on::after-request="if(event.detail.successful) window.location.reload()">Unpublish Item</button> | |
| 28 | + | hx-on::after-request="if(event.detail.successful) document.getElementById('tab-settings').click()">Unpublish Item</button> | |
| 29 | 29 | </div> | |
| 30 | 30 | {% else %} | |
| 31 | 31 | <!-- Draft: show publish now + schedule --> | |
| @@ -37,12 +37,12 @@ | |||
| 37 | 37 | hx-put="/api/items/{{ item.id }}" | |
| 38 | 38 | hx-vals='{"is_public": "true"}' | |
| 39 | 39 | hx-confirm="Publish this item?" | |
| 40 | - | hx-on::after-request="if(event.detail.successful) window.location.reload()">Publish Now</button> | |
| 40 | + | hx-on::after-request="if(event.detail.successful) document.getElementById('tab-settings').click()">Publish Now</button> | |
| 41 | 41 | <button class="secondary" onclick="document.getElementById('schedule-form').style.display='block'">Schedule</button> | |
| 42 | 42 | </div> | |
| 43 | 43 | <form id="schedule-form" style="display: none; margin-top: 1rem;" | |
| 44 | 44 | hx-put="/api/items/{{ item.id }}" | |
| 45 | - | hx-on::after-request="if(event.detail.successful) window.location.reload()"> | |
| 45 | + | hx-on::after-request="if(event.detail.successful) document.getElementById('tab-settings').click()"> | |
| 46 | 46 | <div class="form-group"> | |
| 47 | 47 | <label for="publish-at">Publish at (UTC)</label> | |
| 48 | 48 | <input type="datetime-local" id="publish-at" name="publish_at" required> |
| @@ -93,7 +93,6 @@ | |||
| 93 | 93 | hx-get="/api/users/me/totp/status" | |
| 94 | 94 | hx-trigger="revealed" | |
| 95 | 95 | hx-swap="innerHTML"> | |
| 96 | - | <p style="opacity: 0.7;">Loading...</p> | |
| 97 | 96 | </div> | |
| 98 | 97 | </details> | |
| 99 | 98 | ||
| @@ -103,7 +102,6 @@ | |||
| 103 | 102 | hx-get="/api/users/me/passkeys" | |
| 104 | 103 | hx-trigger="revealed" | |
| 105 | 104 | hx-swap="innerHTML"> | |
| 106 | - | <p style="opacity: 0.7;">Loading...</p> | |
| 107 | 105 | </div> | |
| 108 | 106 | </details> | |
| 109 | 107 |
| @@ -171,7 +171,7 @@ | |||
| 171 | 171 | hx-confirm="Remove this domain?" | |
| 172 | 172 | hx-target="closest .form-section" | |
| 173 | 173 | hx-swap="outerHTML" | |
| 174 | - | hx-on::after-request="if(event.detail.successful) location.reload()">Remove</button> | |
| 174 | + | hx-on::after-request="if(event.detail.successful) document.getElementById('tab-profile').click()">Remove</button> | |
| 175 | 175 | </div> | |
| 176 | 176 | <div id="domain-verify-result" style="margin-top: 0.75rem;"></div> | |
| 177 | 177 | {% else %} | |
| @@ -183,7 +183,7 @@ | |||
| 183 | 183 | hx-confirm="Remove this domain? Your domain will stop serving your content." | |
| 184 | 184 | hx-target="closest .form-section" | |
| 185 | 185 | hx-swap="outerHTML" | |
| 186 | - | hx-on::after-request="if(event.detail.successful) location.reload()">Remove Domain</button> | |
| 186 | + | hx-on::after-request="if(event.detail.successful) document.getElementById('tab-profile').click()">Remove Domain</button> | |
| 187 | 187 | {% endif %} | |
| 188 | 188 | {% when None %} | |
| 189 | 189 | <form hx-post="/api/domains" |
| @@ -27,7 +27,7 @@ | |||
| 27 | 27 | <div class="project-image-dropzone" id="image-dropzone"> | |
| 28 | 28 | {% if let Some(url) = cover_image_url %} | |
| 29 | 29 | <div class="project-image-current" id="image-current"> | |
| 30 | - | <img src="{{ url }}" alt="Item image"> | |
| 30 | + | <img src="{{ url }}" alt="Item image" style="width: 100%; aspect-ratio: 1 / 1; object-fit: cover;"> | |
| 31 | 31 | </div> | |
| 32 | 32 | {% else %} | |
| 33 | 33 | <div class="project-image-placeholder" id="image-placeholder"> | |
| @@ -154,7 +154,7 @@ | |||
| 154 | 154 | var div = document.createElement('div'); | |
| 155 | 155 | div.className = 'project-image-current'; | |
| 156 | 156 | div.id = 'image-current'; | |
| 157 | - | div.innerHTML = '<img src="' + url + '" alt="Item image">'; | |
| 157 | + | div.innerHTML = '<img src="' + url + '" alt="Item image" style="width: 100%; aspect-ratio: 1 / 1; object-fit: cover;">'; | |
| 158 | 158 | dropzone.insertBefore(div, fileInput); | |
| 159 | 159 | currentImg = div; | |
| 160 | 160 | } |
| @@ -15,7 +15,7 @@ | |||
| 15 | 15 | <div class="project-image-dropzone" id="image-dropzone"> | |
| 16 | 16 | {% if let Some(url) = cover_image_url %} | |
| 17 | 17 | <div class="project-image-current" id="image-current"> | |
| 18 | - | <img src="{{ url }}" alt="Project image"> | |
| 18 | + | <img src="{{ url }}" alt="Project image" style="width: 100%; aspect-ratio: 1 / 1; object-fit: cover;"> | |
| 19 | 19 | </div> | |
| 20 | 20 | {% else %} | |
| 21 | 21 | <div class="project-image-placeholder" id="image-placeholder"> | |
| @@ -52,7 +52,7 @@ | |||
| 52 | 52 | <div class="project-card-preview"> | |
| 53 | 53 | <div class="preview-cover" id="preview-cover"> | |
| 54 | 54 | {% if let Some(url) = cover_image_url %} | |
| 55 | - | <img src="{{ url }}" alt="Preview"> | |
| 55 | + | <img src="{{ url }}" alt="Preview" style="width: 100%; aspect-ratio: 1 / 1; object-fit: cover;"> | |
| 56 | 56 | {% else %} | |
| 57 | 57 | <div class="preview-cover-empty">No image</div> | |
| 58 | 58 | {% endif %} | |
| @@ -162,12 +162,12 @@ | |||
| 162 | 162 | var div = document.createElement('div'); | |
| 163 | 163 | div.className = 'project-image-current'; | |
| 164 | 164 | div.id = 'image-current'; | |
| 165 | - | div.innerHTML = '<img src="' + url + '" alt="Project image">'; | |
| 165 | + | div.innerHTML = '<img src="' + url + '" alt="Project image" style="width: 100%; aspect-ratio: 1 / 1; object-fit: cover;">'; | |
| 166 | 166 | dropzone.insertBefore(div, fileInput); | |
| 167 | 167 | currentImg = div; | |
| 168 | 168 | } | |
| 169 | 169 | var preview = document.getElementById('preview-cover'); | |
| 170 | - | preview.innerHTML = '<img src="' + url + '" alt="Preview">'; | |
| 170 | + | preview.innerHTML = '<img src="' + url + '" alt="Preview" style="width: 100%; aspect-ratio: 1 / 1; object-fit: cover;">'; | |
| 171 | 171 | } | |
| 172 | 172 | ||
| 173 | 173 | function showError(message) { |