| 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