Jump to…
snowfeat: given a new redesign and emulated terminal homepagerxkspqmsoknz1mo
Matt W1//! Page chrome.
Matt W2//!
Matt W3//! Every page renders standalone, with no JavaScript required (spec §7:
Matt W4//! "Progressive enhancement is a hard requirement"). htmx is loaded for
Matt W5//! enhancement only — nothing here depends on it running.
Matt W6
Matt W7use maud::{html, Markup, DOCTYPE};
Matt W8
Matt W9use df_db::models::User;
Matt W10
Matt W11/// Per-request context the chrome needs.
Matt W12pub struct Chrome<'a> {
Matt W13 pub title: &'a str,
Matt W14 pub user: Option<&'a User>,
Matt W15 /// CSRF token, attached to the body so htmx sends it on every non-GET.
Matt W16 pub csrf: &'a str,
Matt W17 /// Nonce for the one inline script. The CSP forbids `unsafe-inline`
Matt W18 /// (spec §9), so the nonce is what allows it.
Matt W19 pub nonce: &'a str,
Matt W20}
Matt W21
Matt W22/// The wordmark: a bordered `df` tile beside the name, both monospace.
Matt W23///
Matt W24/// The tile repeats the first two letters of the name rather than adding a
Matt W25/// shape to interpret, so it is decorative and hidden from assistive
Matt W26/// technology — "dogfood" beside it is the accessible name.
Matt W27fn brand(href: &str) -> Markup {
Matt W28 html! {
Matt W29 a .brand href=(href) {
Matt W30 span .brand-mark aria-hidden="true" { "df" }
Matt W31 span { "dogfood" }
Matt W32 }
Matt W33 }
Matt W34}
Matt W35
Matt W36/// Wrap page content in the site chrome.
Matt W37pub fn page(chrome: Chrome<'_>, content: Markup) -> Markup {
Matt W38 shell(chrome, None, content, true)
Matt W39}
Matt W40
Matt W41/// Site chrome with a repository sub-bar pinned under the masthead.
Matt W42///
Matt W43/// The bar is full-bleed, so it cannot live inside the page's `.wrap` — it is
Matt W44/// passed separately rather than prepended to `content`.
Matt W45pub fn page_with_bar(chrome: Chrome<'_>, bar: Markup, content: Markup) -> Markup {
Matt W46 shell(chrome, Some(bar), content, true)
Matt W47}
Matt W48
Matt W49/// Site chrome with no content wrapper.
Matt W50///
Matt W51/// For pages built from edge-to-edge bands — the landing page — where each
Matt W52/// band draws its own full-width rule and carries its own `.wrap` inside it.
Matt W53/// Wrapping those in a second `.wrap` would inset every rule by the gutter and
Matt W54/// double the horizontal padding.
Matt W55pub fn page_full(chrome: Chrome<'_>, content: Markup) -> Markup {
Matt W56 shell(chrome, None, content, false)
Matt W57}
Matt W58
Matt W59fn shell(chrome: Chrome<'_>, bar: Option<Markup>, content: Markup, wrap: bool) -> Markup {
Matt W60 html! {
Matt W61 (DOCTYPE)
Matt W62 html lang="en" {
Matt W63 head {
Matt W64 meta charset="utf-8";
Matt W65 meta name="viewport" content="width=device-width, initial-scale=1";
Matt W66 title { (chrome.title) " · Dogfood" }
Matt W67 link rel="stylesheet" href="/assets/app.css";
Matt W68 meta name="color-scheme" content="dark light";
Matt W69 meta name="theme-color" media="(prefers-color-scheme: light)" content="#f6f6f4";
Matt W70 meta name="theme-color" media="(prefers-color-scheme: dark)" content="#15161a";
Matt W71 // Blocking (no `defer`) and first, so a stored "light"
Matt W72 // preference lands before the default dark theme paints.
Matt W73 script src="/assets/theme-init.js" nonce=(chrome.nonce) {}
Matt W74 }
Matt W75 body hx-headers=(format!(r#"{{"x-csrf-token": "{}"}}"#, chrome.csrf)) {
Matt W76 // The first focusable thing on the page, visible only when
Matt W77 // focused. Without it a keyboard user tabs through the whole
Matt W78 // masthead on every page before reaching the content.
Matt W79 a .skip-link href="#main" { "Skip to content" }
Matt W80
Matt W81 header .masthead {
Matt W82 div .masthead-inner {
Matt W83 div .masthead-group.masthead-group-start {
Matt W84 (brand("/"))
Matt W85 span .vrule aria-hidden="true" {}
Matt W86 nav .masthead-nav aria-label="Main" {
Matt W87 @if chrome.user.is_some() {
Matt W88 a href="/dashboard" { "Dashboard" }
Matt W89 a href="/new" { "New repository" }
Matt W90 }
Matt W91 a href="/design" { "Design system" }
Matt W92 }
Matt W93 }
Matt W94
Matt W95 // Without scripting this is exactly what it looks like:
Matt W96 // a link to the search page. palette.js upgrades it in
Matt W97 // place and only then advertises the shortcut. Centred
Matt W98 // in its own grid column so its position holds steady
Matt W99 // regardless of how wide the groups either side of it
Matt W100 // are.
Matt W101 a .jump href="/search" data-palette-open {
Matt W102 span { "Jump to…" }
Matt W103 span .kbd data-palette-kbd hidden aria-hidden="true" { "⌘K" }
Matt W104 }
Matt W105
Matt W106 div .masthead-group.masthead-group-end {
Matt W107 // JS-only: there is no server-side notion of theme
Matt W108 // to toggle without it, so it starts hidden and
Matt W109 // theme.js reveals it. The site's default dark
Matt W110 // theme is what every visitor without scripting
Matt W111 // sees.
Matt W112 button .theme-toggle type="button" data-theme-toggle
Matt W113 hidden aria-label="Switch between dark and light theme" {
Matt W114 span data-theme-label { "dark" }
Matt W115 }
Matt W116
Matt W117 nav .masthead-account aria-label="Account" {
Matt W118 @if let Some(u) = chrome.user {
Matt W119 // A `<details>`, so the menu opens and
Matt W120 // closes with no JavaScript at all — Sign
Matt W121 // out is one tap away, but not a stray tap
Matt W122 // away.
Matt W123 details .switcher {
Matt W124 summary .btn.btn-mono {
Matt W125 span .account-handle { (u.handle) }
Matt W126 span .faint aria-hidden="true" { "▾" }
Matt W127 }
Matt W128 div .switcher-menu.switcher-menu-end {
Matt W129 a .switcher-item href="/settings" { "Settings" }
Matt W130 form method="post" action="/logout" {
Matt W131 input type="hidden" name="_csrf" value=(chrome.csrf);
Matt W132 button .switcher-item.switcher-item-danger type="submit" {
Matt W133 "Sign out"
Matt W134 }
Matt W135 }
Matt W136 }
Matt W137 }
Matt W138 } @else {
Matt W139 a .btn href="/login" { "Sign in" }
Matt W140 }
Matt W141 }
Matt W142 }
Matt W143 }
Matt W144 }
Matt W145
Matt W146 @if let Some(bar) = bar {
Matt W147 (bar)
Matt W148 }
Matt W149
Matt W150 // `tabindex="-1"` so the skip link can move focus here, not
Matt W151 // just scroll to it — without it the next Tab press would go
Matt W152 // back to the top of the page.
Matt W153 main id="main" tabindex="-1" .flush[!wrap] {
Matt W154 @if wrap {
Matt W155 div .wrap { (content) }
Matt W156 } @else {
Matt W157 (content)
Matt W158 }
Matt W159 }
Matt W160
Matt W161 (palette())
Matt W162
Matt W163 footer {
Matt W164 div .wrap {
Matt W165 span .footer-mark { "dogfood" }
Matt W166 span .dim { "Built on jj. Speaks git. Run by people who use it all day." }
Matt W167 span .spacer {}
Matt W168 a href="https://github.com/jj-vcs/jj" { "jj" }
Matt W169 // A component sheet nobody can find does not get kept
Matt W170 // up to date, so it gets a real link.
Matt W171 a href="/design" { "design" }
Matt W172 a href="/design/rationale" { "rationale" }
Matt W173 span .footer-end .mono { "© 2026" }
Matt W174 }
Matt W175 }
Matt W176
Matt W177 // htmx is progressive enhancement only. If it fails to load,
Matt W178 // every link and form still works as plain HTML.
Matt W179 script src="/assets/htmx.min.js" nonce=(chrome.nonce) defer {}
Matt W180 script src="/assets/theme.js" nonce=(chrome.nonce) defer {}
Matt W181 script src="/assets/palette.js" nonce=(chrome.nonce) defer {}
Matt W182 script src="/assets/terminal.js" nonce=(chrome.nonce) defer {}
Matt W183 }
Matt W184 }
Matt W185 }
Matt W186}
Matt W187
Matt W188/// The ⌘K palette.
Matt W189///
Matt W190/// Rendered on every page but inert until palette.js reveals it, so the
Matt W191/// overlay never appears for a visitor without scripting — for them the
Matt W192/// masthead control is a plain link to `/search`, which answers the same
Matt W193/// question with a full page.
Matt W194///
Matt W195/// The results list is fetched by htmx from the same `/search` handler the
Matt W196/// full page uses, in fragment mode. There is no second search implementation
Matt W197/// to keep in sync, and no second place for the visibility rule to be wrong.
Matt W198fn palette() -> Markup {
Matt W199 html! {
Matt W200 div .palette-backdrop data-palette hidden {
Matt W201 div .palette.float-shadow role="dialog" aria-modal="true" aria-label="Jump to" {
Matt W202 div .palette-bar {
Matt W203 span .palette-prompt aria-hidden="true" { "›" }
Matt W204 input .palette-input type="search" name="q" autocomplete="off"
Matt W205 spellcheck="false" data-palette-input
Matt W206 placeholder="Change id, repository, issue, or command"
Matt W207 aria-label="Search repositories, changes and issues"
Matt W208 hx-get="/search?fragment=1"
Matt W209 hx-trigger="input changed delay:150ms"
Matt W210 hx-target="#palette-results"
Matt W211 hx-indicator=".palette";
Matt W212 span .kbd aria-hidden="true" { "esc" }
Matt W213 }
Matt W214 div #palette-results .palette-results {
Matt W215 (palette_hint())
Matt W216 }
Matt W217 div .palette-foot .mono {
Matt W218 "Paste any change id prefix. Two characters is usually enough."
Matt W219 }
Matt W220 }
Matt W221 }
Matt W222 }
Matt W223}
Matt W224
Matt W225/// Search results, grouped, for the palette overlay.
Matt W226///
Matt W227/// Takes the same [`Hit`](crate::routes::search::Hit) rows the full search page
Matt W228/// renders — the palette is a different presentation of one search, not a
Matt W229/// second search.
Matt W230pub fn palette_results(
Matt W231 repos: &[crate::routes::search::Hit],
Matt W232 changes: &[crate::routes::search::Hit],
Matt W233 issues: &[crate::routes::search::Hit],
Matt W234 query: &str,
Matt W235) -> Markup {
Matt W236 let groups: [(&str, &[crate::routes::search::Hit], &str); 3] = [
Matt W237 ("Repositories", repos, "▸"),
Matt W238 ("Changes", changes, "○"),
Matt W239 ("Issues", issues, "◌"),
Matt W240 ];
Matt W241 let empty = repos.is_empty() && changes.is_empty() && issues.is_empty();
Matt W242
Matt W243 html! {
Matt W244 @for (label, hits, glyph) in groups {
Matt W245 @if !hits.is_empty() {
Matt W246 div .palette-group {
Matt W247 div .label-condensed.palette-group-label { (label) }
Matt W248 @for h in hits.iter().take(5) {
Matt W249 a .palette-item href=(&h.url) {
Matt W250 span .palette-glyph aria-hidden="true" { (glyph) }
Matt W251 span .palette-label.mono { (h.title) }
Matt W252 @if !h.context.is_empty() {
Matt W253 span .palette-hint.dim { (h.context) }
Matt W254 }
Matt W255 @if let Some(b) = &h.badge {
Matt W256 span .kbd { (b) }
Matt W257 }
Matt W258 }
Matt W259 }
Matt W260 }
Matt W261 }
Matt W262 }
Matt W263
Matt W264 @if empty {
Matt W265 div .palette-empty.dim {
Matt W266 "Nothing you can see matches “" (query) "”."
Matt W267 }
Matt W268 } @else {
Matt W269 // The escape hatch: five per group is enough to jump, not enough
Matt W270 // to browse.
Matt W271 a .palette-item.palette-all
Matt W272 href=(format!("/search?q={}", crate::routes::settings::urlencode(query))) {
Matt W273 span .palette-glyph aria-hidden="true" { "›" }
Matt W274 span .palette-label { "All results for “" (query) "”" }
Matt W275 }
Matt W276 }
Matt W277 }
Matt W278}
Matt W279
Matt W280/// What the palette shows before anything has been typed.
Matt W281pub fn palette_hint() -> Markup {
Matt W282 html! {
Matt W283 div .palette-group {
Matt W284 div .label-condensed.palette-group-label { "Commands" }
Matt W285 a .palette-item href="/dashboard" {
Matt W286 span .palette-glyph aria-hidden="true" { "◑" }
Matt W287 span .palette-label { "Dashboard" }
Matt W288 span .palette-hint.dim { "your changes and reviews" }
Matt W289 }
Matt W290 a .palette-item href="/design" {
Matt W291 span .palette-glyph aria-hidden="true" { "◑" }
Matt W292 span .palette-label { "Design system" }
Matt W293 span .palette-hint.dim { "tokens, states, components" }
Matt W294 }
Matt W295 button .palette-item type="button" data-palette-theme {
Matt W296 span .palette-glyph aria-hidden="true" { "◑" }
Matt W297 span .palette-label { "Toggle theme" }
Matt W298 span .palette-hint.dim { "dark / light" }
Matt W299 span .kbd { "t" }
Matt W300 }
Matt W301 }
Matt W302 }
Matt W303}
Matt W304
Matt W305/// A minimal error page rendered without request context.
Matt W306///
Matt W307/// Used by the error type, which runs after extractors and so cannot rely on
Matt W308/// the session or CSRF token being available.
Matt W309pub fn bare_error(status: axum::http::StatusCode, message: &str) -> Markup {
Matt W310 html! {
Matt W311 (DOCTYPE)
Matt W312 html lang="en" {
Matt W313 head {
Matt W314 meta charset="utf-8";
Matt W315 meta name="viewport" content="width=device-width, initial-scale=1";
Matt W316 title { (status.as_u16()) " · Dogfood" }
Matt W317 link rel="stylesheet" href="/assets/app.css";
Matt W318 }
Matt W319 body {
Matt W320 a .skip-link href="#main" { "Skip to content" }
Matt W321 header .masthead {
Matt W322 div .masthead-inner {
Matt W323 (brand("/"))
Matt W324 }
Matt W325 }
Matt W326 main id="main" tabindex="-1" {
Matt W327 div .wrap {
Matt W328 div .panel {
Matt W329 h1 { (status.as_u16()) " " (status.canonical_reason().unwrap_or("Error")) }
Matt W330 p .dim { (message) }
Matt W331 p { a href="/" { "Back to the dashboard" } }
Matt W332 }
Matt W333 }
Matt W334 }
Matt W335 }
Matt W336 }
Matt W337 }
Matt W338}
Matt W339
Matt W340/// A standalone error page.
Matt W341pub fn error_page(chrome: Chrome<'_>, heading: &str, detail: &str) -> Markup {
Matt W342 page(
Matt W343 chrome,
Matt W344 html! {
Matt W345 div .panel {
Matt W346 h1 { (heading) }
Matt W347 p .dim { (detail) }
Matt W348 p { a href="/" { "Back to the dashboard" } }
Matt W349 }
Matt W350 },
Matt W351 )
Matt W352}

352 lines · Rust