Jump to…
snowfeat: given a new redesign and emulated terminal homepagerxkspqmsoknz1mo
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
7use maud::{html, Markup, DOCTYPE};
8
9use df_db::models::User;
10
11/// Per-request context the chrome needs.
12pub 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.
27fn 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.
37pub 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`.
45pub 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.
55pub fn page_full(chrome: Chrome<'_>, content: Markup) -> Markup {
56 shell(chrome, None, content, false)
57}
58
59fn 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.
198fn 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.
230pub 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.
281pub 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.
309pub 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.
341pub 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