max / makenotwork
- Co-Authored-By
- Claude Opus 4.6 (1M context) <noreply@anthropic.com>
11 files changed,
+138 insertions,
-18 deletions
| @@ -278,6 +278,59 @@ | |||
| 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). |
| @@ -30,7 +30,12 @@ | |||
| 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) { |