| 1 | //! Page chrome. |
| 2 | //! |
| 3 | //! Every page renders standalone, with no JavaScript required (spec §7: |
| 4 | //! "Progressive enhancement is a hard requirement"). htmx is loaded for |
| 5 | //! enhancement only — nothing here depends on it running. |
| 6 | |
| 7 | use maud::{html, Markup, DOCTYPE}; |
| 8 | |
| 9 | use df_db::models::User; |
| 10 | |
| 11 | /// Per-request context the chrome needs. |
| 12 | pub struct Chrome<'a> { |
| 13 | pub title: &'a str, |
| 14 | pub user: Option<&'a User>, |
| 15 | /// CSRF token, attached to the body so htmx sends it on every non-GET. |
| 16 | pub csrf: &'a str, |
| 17 | /// Nonce for the one inline script. The CSP forbids `unsafe-inline` |
| 18 | /// (spec §9), so the nonce is what allows it. |
| 19 | pub nonce: &'a str, |
| 20 | } |
| 21 | |
| 22 | /// The wordmark: a bordered `df` tile beside the name, both monospace. |
| 23 | /// |
| 24 | /// The tile repeats the first two letters of the name rather than adding a |
| 25 | /// shape to interpret, so it is decorative and hidden from assistive |
| 26 | /// technology — "dogfood" beside it is the accessible name. |
| 27 | fn brand(href: &str) -> Markup { |
| 28 | html! { |
| 29 | a .brand href=(href) { |
| 30 | span .brand-mark aria-hidden="true" { "df" } |
| 31 | span { "dogfood" } |
| 32 | } |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | /// Wrap page content in the site chrome. |
| 37 | pub fn page(chrome: Chrome<'_>, content: Markup) -> Markup { |
| 38 | shell(chrome, None, content, true) |
| 39 | } |
| 40 | |
| 41 | /// Site chrome with a repository sub-bar pinned under the masthead. |
| 42 | /// |
| 43 | /// The bar is full-bleed, so it cannot live inside the page's `.wrap` — it is |
| 44 | /// passed separately rather than prepended to `content`. |
| 45 | pub fn page_with_bar(chrome: Chrome<'_>, bar: Markup, content: Markup) -> Markup { |
| 46 | shell(chrome, Some(bar), content, true) |
| 47 | } |
| 48 | |
| 49 | /// Site chrome with no content wrapper. |
| 50 | /// |
| 51 | /// For pages built from edge-to-edge bands — the landing page — where each |
| 52 | /// band draws its own full-width rule and carries its own `.wrap` inside it. |
| 53 | /// Wrapping those in a second `.wrap` would inset every rule by the gutter and |
| 54 | /// double the horizontal padding. |
| 55 | pub fn page_full(chrome: Chrome<'_>, content: Markup) -> Markup { |
| 56 | shell(chrome, None, content, false) |
| 57 | } |
| 58 | |
| 59 | fn shell(chrome: Chrome<'_>, bar: Option<Markup>, content: Markup, wrap: bool) -> Markup { |
| 60 | html! { |
| 61 | (DOCTYPE) |
| 62 | html lang="en" { |
| 63 | head { |
| 64 | meta charset="utf-8"; |
| 65 | meta name="viewport" content="width=device-width, initial-scale=1"; |
| 66 | title { (chrome.title) " · Dogfood" } |
| 67 | link rel="stylesheet" href="/assets/app.css"; |
| 68 | meta name="color-scheme" content="dark light"; |
| 69 | meta name="theme-color" media="(prefers-color-scheme: light)" content="#f6f6f4"; |
| 70 | meta name="theme-color" media="(prefers-color-scheme: dark)" content="#15161a"; |
| 71 | // Blocking (no `defer`) and first, so a stored "light" |
| 72 | // preference lands before the default dark theme paints. |
| 73 | script src="/assets/theme-init.js" nonce=(chrome.nonce) {} |
| 74 | } |
| 75 | body hx-headers=(format!(r#"{{"x-csrf-token": "{}"}}"#, chrome.csrf)) { |
| 76 | // The first focusable thing on the page, visible only when |
| 77 | // focused. Without it a keyboard user tabs through the whole |
| 78 | // masthead on every page before reaching the content. |
| 79 | a .skip-link href="#main" { "Skip to content" } |
| 80 | |
| 81 | header .masthead { |
| 82 | div .masthead-inner { |
| 83 | div .masthead-group.masthead-group-start { |
| 84 | (brand("/")) |
| 85 | span .vrule aria-hidden="true" {} |
| 86 | nav .masthead-nav aria-label="Main" { |
| 87 | @if chrome.user.is_some() { |
| 88 | a href="/dashboard" { "Dashboard" } |
| 89 | a href="/new" { "New repository" } |
| 90 | } |
| 91 | a href="/design" { "Design system" } |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | // Without scripting this is exactly what it looks like: |
| 96 | // a link to the search page. palette.js upgrades it in |
| 97 | // place and only then advertises the shortcut. Centred |
| 98 | // in its own grid column so its position holds steady |
| 99 | // regardless of how wide the groups either side of it |
| 100 | // are. |
| 101 | a .jump href="/search" data-palette-open { |
| 102 | span { "Jump to…" } |
| 103 | span .kbd data-palette-kbd hidden aria-hidden="true" { "⌘K" } |
| 104 | } |
| 105 | |
| 106 | div .masthead-group.masthead-group-end { |
| 107 | // JS-only: there is no server-side notion of theme |
| 108 | // to toggle without it, so it starts hidden and |
| 109 | // theme.js reveals it. The site's default dark |
| 110 | // theme is what every visitor without scripting |
| 111 | // sees. |
| 112 | button .theme-toggle type="button" data-theme-toggle |
| 113 | hidden aria-label="Switch between dark and light theme" { |
| 114 | span data-theme-label { "dark" } |
| 115 | } |
| 116 | |
| 117 | nav .masthead-account aria-label="Account" { |
| 118 | @if let Some(u) = chrome.user { |
| 119 | // A `<details>`, so the menu opens and |
| 120 | // closes with no JavaScript at all — Sign |
| 121 | // out is one tap away, but not a stray tap |
| 122 | // away. |
| 123 | details .switcher { |
| 124 | summary .btn.btn-mono { |
| 125 | span .account-handle { (u.handle) } |
| 126 | span .faint aria-hidden="true" { "▾" } |
| 127 | } |
| 128 | div .switcher-menu.switcher-menu-end { |
| 129 | a .switcher-item href="/settings" { "Settings" } |
| 130 | form method="post" action="/logout" { |
| 131 | input type="hidden" name="_csrf" value=(chrome.csrf); |
| 132 | button .switcher-item.switcher-item-danger type="submit" { |
| 133 | "Sign out" |
| 134 | } |
| 135 | } |
| 136 | } |
| 137 | } |
| 138 | } @else { |
| 139 | a .btn href="/login" { "Sign in" } |
| 140 | } |
| 141 | } |
| 142 | } |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | @if let Some(bar) = bar { |
| 147 | (bar) |
| 148 | } |
| 149 | |
| 150 | // `tabindex="-1"` so the skip link can move focus here, not |
| 151 | // just scroll to it — without it the next Tab press would go |
| 152 | // back to the top of the page. |
| 153 | main id="main" tabindex="-1" .flush[!wrap] { |
| 154 | @if wrap { |
| 155 | div .wrap { (content) } |
| 156 | } @else { |
| 157 | (content) |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | (palette()) |
| 162 | |
| 163 | footer { |
| 164 | div .wrap { |
| 165 | span .footer-mark { "dogfood" } |
| 166 | span .dim { "Built on jj. Speaks git. Run by people who use it all day." } |
| 167 | span .spacer {} |
| 168 | a href="https://github.com/jj-vcs/jj" { "jj" } |
| 169 | // A component sheet nobody can find does not get kept |
| 170 | // up to date, so it gets a real link. |
| 171 | a href="/design" { "design" } |
| 172 | a href="/design/rationale" { "rationale" } |
| 173 | span .footer-end .mono { "© 2026" } |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | // htmx is progressive enhancement only. If it fails to load, |
| 178 | // every link and form still works as plain HTML. |
| 179 | script src="/assets/htmx.min.js" nonce=(chrome.nonce) defer {} |
| 180 | script src="/assets/theme.js" nonce=(chrome.nonce) defer {} |
| 181 | script src="/assets/palette.js" nonce=(chrome.nonce) defer {} |
| 182 | script src="/assets/terminal.js" nonce=(chrome.nonce) defer {} |
| 183 | } |
| 184 | } |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | /// The ⌘K palette. |
| 189 | /// |
| 190 | /// Rendered on every page but inert until palette.js reveals it, so the |
| 191 | /// overlay never appears for a visitor without scripting — for them the |
| 192 | /// masthead control is a plain link to `/search`, which answers the same |
| 193 | /// question with a full page. |
| 194 | /// |
| 195 | /// The results list is fetched by htmx from the same `/search` handler the |
| 196 | /// full page uses, in fragment mode. There is no second search implementation |
| 197 | /// to keep in sync, and no second place for the visibility rule to be wrong. |
| 198 | fn palette() -> Markup { |
| 199 | html! { |
| 200 | div .palette-backdrop data-palette hidden { |
| 201 | div .palette.float-shadow role="dialog" aria-modal="true" aria-label="Jump to" { |
| 202 | div .palette-bar { |
| 203 | span .palette-prompt aria-hidden="true" { "›" } |
| 204 | input .palette-input type="search" name="q" autocomplete="off" |
| 205 | spellcheck="false" data-palette-input |
| 206 | placeholder="Change id, repository, issue, or command" |
| 207 | aria-label="Search repositories, changes and issues" |
| 208 | hx-get="/search?fragment=1" |
| 209 | hx-trigger="input changed delay:150ms" |
| 210 | hx-target="#palette-results" |
| 211 | hx-indicator=".palette"; |
| 212 | span .kbd aria-hidden="true" { "esc" } |
| 213 | } |
| 214 | div #palette-results .palette-results { |
| 215 | (palette_hint()) |
| 216 | } |
| 217 | div .palette-foot .mono { |
| 218 | "Paste any change id prefix. Two characters is usually enough." |
| 219 | } |
| 220 | } |
| 221 | } |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | /// Search results, grouped, for the palette overlay. |
| 226 | /// |
| 227 | /// Takes the same [`Hit`](crate::routes::search::Hit) rows the full search page |
| 228 | /// renders — the palette is a different presentation of one search, not a |
| 229 | /// second search. |
| 230 | pub fn palette_results( |
| 231 | repos: &[crate::routes::search::Hit], |
| 232 | changes: &[crate::routes::search::Hit], |
| 233 | issues: &[crate::routes::search::Hit], |
| 234 | query: &str, |
| 235 | ) -> Markup { |
| 236 | let groups: [(&str, &[crate::routes::search::Hit], &str); 3] = [ |
| 237 | ("Repositories", repos, "▸"), |
| 238 | ("Changes", changes, "○"), |
| 239 | ("Issues", issues, "◌"), |
| 240 | ]; |
| 241 | let empty = repos.is_empty() && changes.is_empty() && issues.is_empty(); |
| 242 | |
| 243 | html! { |
| 244 | @for (label, hits, glyph) in groups { |
| 245 | @if !hits.is_empty() { |
| 246 | div .palette-group { |
| 247 | div .label-condensed.palette-group-label { (label) } |
| 248 | @for h in hits.iter().take(5) { |
| 249 | a .palette-item href=(&h.url) { |
| 250 | span .palette-glyph aria-hidden="true" { (glyph) } |
| 251 | span .palette-label.mono { (h.title) } |
| 252 | @if !h.context.is_empty() { |
| 253 | span .palette-hint.dim { (h.context) } |
| 254 | } |
| 255 | @if let Some(b) = &h.badge { |
| 256 | span .kbd { (b) } |
| 257 | } |
| 258 | } |
| 259 | } |
| 260 | } |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | @if empty { |
| 265 | div .palette-empty.dim { |
| 266 | "Nothing you can see matches “" (query) "”." |
| 267 | } |
| 268 | } @else { |
| 269 | // The escape hatch: five per group is enough to jump, not enough |
| 270 | // to browse. |
| 271 | a .palette-item.palette-all |
| 272 | href=(format!("/search?q={}", crate::routes::settings::urlencode(query))) { |
| 273 | span .palette-glyph aria-hidden="true" { "›" } |
| 274 | span .palette-label { "All results for “" (query) "”" } |
| 275 | } |
| 276 | } |
| 277 | } |
| 278 | } |
| 279 | |
| 280 | /// What the palette shows before anything has been typed. |
| 281 | pub fn palette_hint() -> Markup { |
| 282 | html! { |
| 283 | div .palette-group { |
| 284 | div .label-condensed.palette-group-label { "Commands" } |
| 285 | a .palette-item href="/dashboard" { |
| 286 | span .palette-glyph aria-hidden="true" { "◑" } |
| 287 | span .palette-label { "Dashboard" } |
| 288 | span .palette-hint.dim { "your changes and reviews" } |
| 289 | } |
| 290 | a .palette-item href="/design" { |
| 291 | span .palette-glyph aria-hidden="true" { "◑" } |
| 292 | span .palette-label { "Design system" } |
| 293 | span .palette-hint.dim { "tokens, states, components" } |
| 294 | } |
| 295 | button .palette-item type="button" data-palette-theme { |
| 296 | span .palette-glyph aria-hidden="true" { "◑" } |
| 297 | span .palette-label { "Toggle theme" } |
| 298 | span .palette-hint.dim { "dark / light" } |
| 299 | span .kbd { "t" } |
| 300 | } |
| 301 | } |
| 302 | } |
| 303 | } |
| 304 | |
| 305 | /// A minimal error page rendered without request context. |
| 306 | /// |
| 307 | /// Used by the error type, which runs after extractors and so cannot rely on |
| 308 | /// the session or CSRF token being available. |
| 309 | pub fn bare_error(status: axum::http::StatusCode, message: &str) -> Markup { |
| 310 | html! { |
| 311 | (DOCTYPE) |
| 312 | html lang="en" { |
| 313 | head { |
| 314 | meta charset="utf-8"; |
| 315 | meta name="viewport" content="width=device-width, initial-scale=1"; |
| 316 | title { (status.as_u16()) " · Dogfood" } |
| 317 | link rel="stylesheet" href="/assets/app.css"; |
| 318 | } |
| 319 | body { |
| 320 | a .skip-link href="#main" { "Skip to content" } |
| 321 | header .masthead { |
| 322 | div .masthead-inner { |
| 323 | (brand("/")) |
| 324 | } |
| 325 | } |
| 326 | main id="main" tabindex="-1" { |
| 327 | div .wrap { |
| 328 | div .panel { |
| 329 | h1 { (status.as_u16()) " " (status.canonical_reason().unwrap_or("Error")) } |
| 330 | p .dim { (message) } |
| 331 | p { a href="/" { "Back to the dashboard" } } |
| 332 | } |
| 333 | } |
| 334 | } |
| 335 | } |
| 336 | } |
| 337 | } |
| 338 | } |
| 339 | |
| 340 | /// A standalone error page. |
| 341 | pub fn error_page(chrome: Chrome<'_>, heading: &str, detail: &str) -> Markup { |
| 342 | page( |
| 343 | chrome, |
| 344 | html! { |
| 345 | div .panel { |
| 346 | h1 { (heading) } |
| 347 | p .dim { (detail) } |
| 348 | p { a href="/" { "Back to the dashboard" } } |
| 349 | } |
| 350 | }, |
| 351 | ) |
| 352 | } |
352 lines · Rust