Jump to…
rxkspqmsoknzmerged#4

feat: given a new redesign and emulated terminal homepage

Every version this change has been

Pick any two revisions; the interdiff is what a reviewer has not seen yet.
rev 3feat: given a new redesign and emulated terminal homepageAug 2 19:54
commit 1186c71a2ae7base d392d1ae0270+7275 −1484pushed by snowAB
rev 2feat: given a new redesign and emulated terminal homepageAug 2 19:52
commit 573909d8182dbase d392d1ae0270+7119 −1484pushed by snowAB
rev 1feat: given a new redesign and emulated terminal homepageAug 2 18:45
commit fced8e7d3098base d392d1ae0270+0 −0pushed by snowAB
Interdiff rev 1 → rev 2what the reviewer has not seen yet28 files · +7119 −1484
crates/df-store/src/lib.rs+40 −0
@@ -15,6 +15,7 @@
15//!
16//! No `gix` type appears anywhere in this module's public API.
17
18+use std::collections::HashMap;
19use std::path::Path;
20
21use async_trait::async_trait;
@@ -430,6 +431,21 @@
431 /// is a storage detail; nothing above this trait should know such a thing
432 /// exists.
433 async fn diff_from_parent(&self, id: RepoId, rev: &RevId, opts: DiffOpts) -> Result<Diff>;
434+
435+ /// Added/deleted line counts for many revisions, each against its first
436+ /// parent.
437+ ///
438+ /// The change list needs a diffstat per row, and the list is the hottest
439+ /// page in the product (spec §4). Calling [`Self::diff_from_parent`] in a
440+ /// loop would open the repository once per row and materialise every hunk
441+ /// of every patch to count two numbers; this opens it once and keeps only
442+ /// the totals.
443+ ///
444+ /// Best-effort per revision: a revision the store cannot resolve yields
445+ /// `None` rather than failing the batch, because one unreadable row must
446+ /// not blank out the other ninety-nine.
447+ async fn diff_stats(&self, id: RepoId, revs: &[RevId]) -> Result<Vec<Option<(usize, usize)>>>;
448+
449 async fn log(&self, id: RepoId, from: &RevId, limit: usize) -> Result<Vec<Revision>>;
450 async fn revision(&self, id: RepoId, rev: &RevId) -> Result<Revision>;
451 async fn bookmarks(&self, id: RepoId) -> Result<Vec<Bookmark>>;
@@ -501,6 +517,30 @@
517 path: &Path,
518 ) -> Result<Option<Revision>>;
519
520+ /// The most recent commit that touched each direct child of `dir`, in one
521+ /// history walk.
522+ ///
523+ /// This is [`last_commit_for_path`](Self::last_commit_for_path) generalised
524+ /// to a whole directory listing: rather than one history walk per entry —
525+ /// which is what a naive per-file implementation of this would cost, and
526+ /// what made a directory-listing "last commit" column too expensive to
527+ /// ship the first time around — this walks history once and, at each
528+ /// commit, checks its changed paths against every entry that has not yet
529+ /// been resolved. An entry resolves the first time a changed path falls
530+ /// under it; the walk stops early once every entry has resolved, or after
531+ /// the same 500-commit bound the single-file lookup uses.
532+ ///
533+ /// `entries` are names relative to `dir` (not full paths). An entry
534+ /// missing from the returned map was not modified within the walk bound —
535+ /// callers render that as "no info" rather than treating it as an error.
536+ async fn last_commits_in_dir(
537+ &self,
538+ id: RepoId,
539+ rev: &RevId,
540+ dir: &Path,
541+ entries: &[String],
542+ ) -> Result<HashMap<String, Revision>>;
543+
544 /// Total on-disk size, for the repo settings page and quota reporting.
545 async fn size_bytes(&self, id: RepoId) -> Result<u64>;
546}
crates/df-store/tests/git_store.rs+46 −0
@@ -125,6 +125,52 @@
125 assert_eq!(blob.size, blob.content.len() as u64);
126}
127
128+/// `basic` is two commits, each adding one file — "add a" then "add b" — so a
129+/// correct per-entry walk must resolve them to *different* commits rather
130+/// than both landing on the tip, which is the failure mode a naive
131+/// "just report the directory's last commit for everything" implementation
132+/// would have.
133+#[tokio::test]
134+async fn last_commits_in_dir_resolves_each_entry_to_the_commit_that_touched_it() {
135+ let Some(f) = load("basic") else { return };
136+ let rev = head(&f).await;
137+
138+ let entries = f.store.list_tree(f.id, &rev, Path::new("")).await.unwrap();
139+ let names: Vec<String> = entries.iter().map(|e| e.name.clone()).collect();
140+ assert!(names.contains(&"a.txt".to_string()));
141+ assert!(names.contains(&"b.txt".to_string()));
142+
143+ let history = f
144+ .store
145+ .last_commits_in_dir(f.id, &rev, Path::new(""), &names)
146+ .await
147+ .expect("last_commits_in_dir");
148+
149+ let a = history.get("a.txt").expect("a.txt has history");
150+ let b = history.get("b.txt").expect("b.txt has history");
151+
152+ assert_eq!(a.summary(), "add a");
153+ assert_eq!(b.summary(), "add b");
154+ assert_ne!(
155+ a.rev, b.rev,
156+ "each file must resolve to the commit that actually touched it, not both to the tip"
157+ );
158+}
159+
160+#[tokio::test]
161+async fn last_commits_in_dir_reports_nothing_for_an_unknown_entry() {
162+ let Some(f) = load("basic") else { return };
163+ let rev = head(&f).await;
164+
165+ let history = f
166+ .store
167+ .last_commits_in_dir(f.id, &rev, Path::new(""), &["nonexistent.txt".to_string()])
168+ .await
169+ .expect("last_commits_in_dir");
170+
171+ assert!(history.is_empty());
172+}
173+
174#[tokio::test]
175async fn directories_sort_before_files() {
176 let Some(f) = load("hostile") else { return };
crates/df-web/assets/app.css+2965 −679
@@ -1,74 +1,78 @@
1/*
2 * Dogfood design tokens and base styles.
3 *
4 * Ported from the Next.js design prototype (app/globals.css and
5 * app/design/page.tsx), so the Maud UI matches the design that was signed off.
4+ * Ported from the Claude Design project "Dogfood" (Dogfood.dc.html), so the
5+ * Maud UI matches the design that was signed off.
6 *
7 * Current revision carries the vermilion-brand redesign: a warm near-black
8 * neutral ramp, `--brand` as the single personality accent, and self-hosted
9 * IBM Plex / JetBrains Mono. `--action` is still the link colour — the two are
10 * not interchangeable, and the design is careful about which is which.
7+ * Two accents carry the whole product, and they are not interchangeable:
8+ *
9+ * --identity gold. Change ids, stack rails, the revision timeline — every
10+ * place the UI is pointing at a *thing that keeps its name*
11+ * through rewrites. This is the concept the product is about.
12+ * --action teal. Links and the one primary control per view. Ordinary
13+ * navigation, never identity.
14 *
15+ * Using identity for a button, or action for a change id, breaks the only
16+ * colour rule the interface has.
17+ *
18 * NOTE — deviation from spec §1, recorded deliberately: the spec chose Tailwind.
19 * This is hand-authored CSS instead, because Tailwind would put a Node
20 * toolchain in the release image for what is currently a small, stable set of
15 * styles. The tokens below are the same ones the prototype defines, so moving
21+ * styles. The tokens below are the same ones the design defines, so moving
22 * to Tailwind later is a mechanical change rather than a redesign.
23 */
24
25:root {
26 color-scheme: dark;
27
22 /* neutrals — warm near-black, dark is primary */
23 --bg: #12100e;
24 --surface: #1a1815;
25 --surface-raised: #23201c;
26 --border: #2f2b26;
27 --border-strong: #423d36;
28 --text: #ece7df;
29 --text-dim: #a39c90;
30 --text-faint: #6f6a5f;
28+ /* neutrals — cool near-black, dark is primary */
29+ --bg: #15161a;
30+ --surface: #1b1d22;
31+ --surface-raised: #22252b;
32+ --border: #2c3037;
33+ --border-strong: #3b4048;
34+ --text: #e7e9ec;
35+ --text-dim: #989ea9;
36+ --text-faint: #686e79;
37
32 /* brand — vermilion, the personality. Reserved for the wordmark, primary
33 actions, and the one accent per section heading; it loses its force the
34 moment it is used for ordinary body links. */
35 --brand: #ff4d1f;
36 --brand-ink: #12100e;
37 --grid-line: rgb(255 255 255 / 0.022);
38 --glow: rgb(255 77 31 / 0.14);
39
40 /* accents */
41 --identity: #e6a92e;
42 --action: #4fa3c7;
38+ /* the two accents */
39+ --identity: #d9a441;
40+ --action: #6ba9b8;
41+ /* Ink for text sitting *on* a filled --action surface. Dark in the dark
42+ theme, because the action colour is light enough to need it. */
43+ --on-action: #0e1417;
44+ /* The identity tint. Marks stacked rows and selected revisions without
45+ drawing a border — a border would read as a separate object. */
46+ --identity-wash: rgb(217 164 65 / 0.09);
47
48 /* state */
45 --open: #5aa86a;
46 --merged: #5b86bd;
47 --abandoned: #7c7568;
48 --conflict: #db5a86;
49 --danger: #e0603f;
49+ --open: #63a06b;
50+ --merged: #8b7fd4;
51+ --abandoned: #767d88;
52+ --conflict: #c77bc4;
53+ --danger: #d06b6b;
54
55 /* diff */
52 --diff-add-bg: #16281c;
53 --diff-add-text: #82cf98;
54 --diff-del-bg: #331c17;
55 --diff-del-text: #e79a86;
56 --diff-conflict-bg: #33202a;
57 --diff-conflict-text: #e59ab5;
56+ --diff-add-bg: #1b2f23;
57+ --diff-add-text: #7fcb99;
58+ --diff-del-bg: #33201f;
59+ --diff-del-text: #e39494;
60+ --diff-conflict-bg: #2b1f30;
61+ --diff-conflict-text: #d9a0d6;
62
59 /* syntax highlighting (spec §8), warmed to sit on the near-black ground */
60 --hl-comment: #7a7264;
61 --hl-keyword: #e0799f;
62 --hl-string: #96c47e;
63 --hl-number: #e6a92e;
64 --hl-function: #6fb3d1;
65 --hl-type: #e0b060;
66 --hl-constant: #e6a92e;
67 --hl-variable: #ece7df;
68 --hl-operator: #a39c90;
69 --hl-tag: #e0799f;
70 --hl-attribute: #6fb3d1;
71 --hl-punctuation: #7c7568;
63+ /* syntax highlighting (spec §8), cooled to sit on the near-black ground */
64+ --hl-comment: #6c727d;
65+ --hl-keyword: #c78fd0;
66+ --hl-string: #8fbf94;
67+ --hl-number: #d9a441;
68+ --hl-function: #6ba9b8;
69+ --hl-type: #d9b877;
70+ --hl-constant: #d9a441;
71+ --hl-variable: #e7e9ec;
72+ --hl-operator: #989ea9;
73+ --hl-tag: #c78fd0;
74+ --hl-attribute: #6ba9b8;
75+ --hl-punctuation: #686e79;
76
77 /* shape */
78 --radius: 3px;
@@ -84,63 +88,64 @@
88 --font-condensed: "IBM Plex Sans Condensed", ui-sans-serif,
89 "Roboto Condensed", "Arial Narrow", system-ui, sans-serif;
90
87 /* type scale, base 14 */
91+ /* Type scale, base 14/21. The half-pixel 12.5 is the design's, and it is
92+ load-bearing: metadata has to read as a rank below body text without
93+ dropping to 12, which goes fuzzy in the condensed face. */
94 --text-xs: 11px;
95 --text-sm: 12.5px;
96 --text-base: 14px;
97 --text-md: 16px;
98 --text-lg: 20px;
93 --text-xl: 28px;
94 --text-2xl: 40px;
95 --text-3xl: 60px;
99+ --text-xl: 24px;
100+ --text-2xl: 28px;
101+ --text-3xl: 44px;
102}
103
104:root.light,
105:root[data-theme="light"] {
106 color-scheme: light;
107
102 --bg: #f4f1ea;
103 --surface: #fffdf9;
104 --surface-raised: #fffdf9;
105 --border: #e2ddd1;
106 --border-strong: #c7c1b1;
107 --text: #1c1813;
108 --text-dim: #5f594e;
109 --text-faint: #8b8477;
108+ --bg: #f6f6f4;
109+ --surface: #ffffff;
110+ --surface-raised: #ffffff;
111+ --border: #e0e0dc;
112+ --border-strong: #c6c7c2;
113+ --text: #1a1c20;
114+ --text-dim: #5c616b;
115+ --text-faint: #8a8f98;
116
111 --brand: #e23c11;
112 --brand-ink: #fffdf9;
113 --grid-line: rgb(0 0 0 / 0.025);
114 --glow: rgb(226 60 17 / 0.1);
117+ /* Both accents darken sharply here. They are carrying the same meanings
118+ against a white ground, which needs far more depth to hold contrast. */
119+ --identity: #9a6b12;
120+ --action: #20707f;
121+ --on-action: #ffffff;
122+ --identity-wash: rgb(154 107 18 / 0.08);
123
116 --identity: #a06f0e;
117 --action: #226b83;
124+ --open: #2e7d3a;
125+ --merged: #5b4fb0;
126+ --abandoned: #6b7280;
127+ --conflict: #9b3e96;
128+ --danger: #b03434;
129
119 --open: #2d7d3c;
120 --merged: #3f5f9e;
121 --abandoned: #6b6355;
122 --conflict: #b23566;
123 --danger: #b23c22;
130+ --diff-add-bg: #e4f3e7;
131+ --diff-add-text: #1d6b32;
132+ --diff-del-bg: #fae7e6;
133+ --diff-del-text: #9e2b25;
134+ --diff-conflict-bg: #f6e9f5;
135+ --diff-conflict-text: #7e2b79;
136
125 --diff-add-bg: #e2f2e6;
126 --diff-add-text: #1c6b31;
127 --diff-del-bg: #fae6e0;
128 --diff-del-text: #9e3b25;
129 --diff-conflict-bg: #f7e5ec;
130 --diff-conflict-text: #9e2b58;
131
132 --hl-comment: #837c6e;
133 --hl-keyword: #a03060;
134 --hl-string: #376b2c;
135 --hl-number: #8a6510;
136 --hl-function: #226b83;
137+ --hl-comment: #6f7580;
138+ --hl-keyword: #7e2b79;
139+ --hl-string: #2f6b34;
140+ --hl-number: #8a5e10;
141+ --hl-function: #20707f;
142 --hl-type: #855c14;
138 --hl-constant: #8a6510;
139 --hl-variable: #1c1813;
140 --hl-operator: #5f594e;
141 --hl-tag: #a03060;
142 --hl-attribute: #226b83;
143 --hl-punctuation: #8b8477;
143+ --hl-constant: #8a5e10;
144+ --hl-variable: #1a1c20;
145+ --hl-operator: #5c616b;
146+ --hl-tag: #7e2b79;
147+ --hl-attribute: #20707f;
148+ --hl-punctuation: #8a8f98;
149}
150
151/* ─── fonts ──────────────────────────────────────────────────────────────── */
@@ -238,7 +243,7 @@
243}
244
245::selection {
241 background: color-mix(in srgb, var(--brand) 32%, transparent);
246+ background: color-mix(in srgb, var(--action) 32%, transparent);
247 color: var(--text);
248}
249
@@ -275,71 +280,107 @@
280
281/* ─── layout ─────────────────────────────────────────────────────────────── */
282
278/* Sticky so the wordmark and search stay reachable down a long diff.
279 `backdrop-filter` is an enhancement — the 85% background already keeps the
280 text legible on browsers that ignore it. */
283+/* The design runs a 1440px measure. That is far too wide for prose, so it is
284+ never the width of a paragraph — long-form text inside it is capped at its
285+ own `max-width` (see `.prose`, `.measure`). What the 1440 buys is the dense
286+ multi-column tables the forge is actually made of. */
287+.masthead-inner,
288+.subnav-inner,
289+.wrap {
290+ max-width: 1440px;
291+ margin: 0 auto;
292+ padding: 0 24px;
293+}
294+
295+/* Sticky so the wordmark and the jump control stay reachable down a long diff.
296+ Opaque rather than translucent: the rows beneath it are 28-34px hairline
297+ grids, and sliding them under a blur turns the header into mush. */
298.masthead {
299 position: sticky;
300 top: 0;
301 z-index: 30;
302 border-bottom: 1px solid var(--border);
286 background: color-mix(in srgb, var(--bg) 85%, transparent);
287 backdrop-filter: blur(6px);
303+ background: var(--surface);
304+}
305+
306+/* Three columns rather than a flex row with a spacer: a grid's centre column
307+ sizes to its content and stays put between two equal flexible columns, so
308+ the jump control holds the visual centre of the bar no matter how wide the
309+ brand/nav on one side or the account controls on the other happen to be. */
310+.masthead-inner {
311+ display: grid;
312+ grid-template-columns: 1fr auto 1fr;
313+ align-items: center;
314+ column-gap: 14px;
315+ height: 46px;
316}
317
290.masthead-rule {
291 display: block;
292 width: 100%;
293 height: 2px;
294 background: var(--brand);
318+.masthead-group {
319+ display: flex;
320+ align-items: center;
321+ gap: 14px;
322+ min-width: 0;
323}
324
297.masthead-inner,
298.wrap {
299 max-width: 1180px;
300 margin: 0 auto;
301 padding: 0 20px;
325+.masthead-group-start {
326+ justify-self: start;
327}
328
304.masthead-inner {
305 display: flex;
306 align-items: center;
307 gap: 16px;
308 height: 48px;
329+.masthead-group-end {
330+ justify-self: end;
331}
332
333+/* The wordmark: a bordered `df` tile beside the name, both monospace. The
334+ product is a command-line tool with a website attached, and the mark says
335+ so. */
336.brand {
312 display: flex;
337+ display: inline-flex;
338 align-items: center;
339 gap: 8px;
340 flex-shrink: 0;
341 color: var(--text);
317 font-family: var(--font-condensed);
318 font-size: var(--text-md);
319 font-weight: 700;
320 text-transform: uppercase;
321 letter-spacing: 0.02em;
342+ font-family: var(--font-mono);
343+ font-size: var(--text-base);
344}
345
346.brand:hover {
347 text-decoration: none;
326 color: var(--brand);
348}
349
350.brand-mark {
330 color: var(--brand);
331 flex-shrink: 0;
351+ width: 18px;
352+ height: 18px;
353+ flex: none;
354+ display: inline-flex;
355+ align-items: center;
356+ justify-content: center;
357+ border: 1px solid var(--identity);
358+ border-radius: var(--radius-sm);
359+ font-size: var(--text-xs);
360+ color: var(--identity);
361+}
362+
363+/* A hairline divider between groups in a horizontal bar. */
364+.vrule {
365+ width: 1px;
366+ height: 18px;
367+ flex: none;
368+ background: var(--border);
369+}
370+
371+.spacer {
372+ flex: 1;
373}
374
375.masthead nav {
376 display: flex;
336 gap: 14px;
337 margin-left: auto;
377+ gap: 8px;
378 align-items: center;
379}
380
381.masthead nav a {
382 color: var(--text-dim);
383+ white-space: nowrap;
384}
385
386.masthead nav a:hover {
@@ -347,21 +388,263 @@
388 text-decoration: none;
389}
390
391+/* The "Jump to…" control. A link, not a button: with scripting off it
392+ navigates to /search, which is the same destination the palette reaches by
393+ another route. The keycap is decorative in that case, so it is hidden from
394+ assistive technology and revealed as meaningful only by palette.js. */
395+.jump {
396+ display: inline-flex;
397+ align-items: center;
398+ gap: 8px;
399+ height: 24px;
400+ padding: 0 6px 0 8px;
401+ border: 1px solid var(--border-strong);
402+ border-radius: var(--radius);
403+ background: var(--bg);
404+ color: var(--text-faint);
405+ font-size: var(--text-sm);
406+ cursor: pointer;
407+}
408+
409+.jump:hover {
410+ border-color: var(--action);
411+ color: var(--action);
412+ text-decoration: none;
413+}
414+
415+/* Condensed uppercase, because it is a state readout ("DARK") rather than a
416+ verb — pressing it is what changes the state. */
417+.theme-toggle {
418+ height: 24px;
419+ padding: 0 8px;
420+ border: 1px solid var(--border-strong);
421+ border-radius: var(--radius);
422+ background: transparent;
423+ color: var(--text-dim);
424+ font-family: var(--font-condensed);
425+ font-size: var(--text-xs);
426+ font-weight: 500;
427+ letter-spacing: 0.06em;
428+ text-transform: uppercase;
429+ cursor: pointer;
430+}
431+
432+/* ─── repository sub-bar ─────────────────────────────────────────────────── */
433+
434+/* The second row of chrome, present on every page inside a repository: where
435+ you are, whether it is public, the four sections, and the repo's vital
436+ signs. Full-bleed and hairline-ruled so it reads as part of the frame
437+ rather than as page content. */
438+.subnav {
439+ border-bottom: 1px solid var(--border);
440+ background: var(--surface);
441+}
442+
443+.subnav-inner {
444+ display: flex;
445+ align-items: center;
446+ gap: 12px;
447+ height: 40px;
448+ overflow-x: auto;
449+ scrollbar-width: none;
450+}
451+
452+.subnav-inner::-webkit-scrollbar {
453+ display: none;
454+}
455+
456+.subnav-path {
457+ font-family: var(--font-mono);
458+ font-size: var(--text-sm);
459+ white-space: nowrap;
460+ flex: none;
461+}
462+
463+.subnav-path .sep {
464+ color: var(--text-faint);
465+}
466+
467+.subnav-meta {
468+ font-family: var(--font-mono);
469+ font-size: var(--text-xs);
470+ color: var(--text-faint);
471+ white-space: nowrap;
472+ flex: none;
473+}
474+
475main {
351 padding: 28px 0 64px;
476+ padding: 14px 0 48px;
477+}
478+
479+/* A page built from edge-to-edge bands owns its own vertical rhythm: each band
480+ carries its padding and its closing rule, so `main` adds neither. */
481+main.flush {
482+ padding: 0;
483+}
484+
485+/* ─── the ⌘K palette ─────────────────────────────────────────────────────── */
486+
487+/* Anchored near the top rather than centred: the list grows downward as you
488+ type, and a vertically-centred panel would shift the row under the cursor
489+ on every keystroke. */
490+.palette-backdrop {
491+ position: fixed;
492+ inset: 0;
493+ z-index: 50;
494+ display: flex;
495+ justify-content: center;
496+ padding: 96px 16px 16px;
497+ background: rgb(10 11 13 / 0.55);
498+}
499+
500+.palette-backdrop[hidden] {
501+ display: none;
502}
503
504+.palette {
505+ width: 560px;
506+ max-width: 100%;
507+ height: max-content;
508+ max-height: calc(100vh - 128px);
509+ display: flex;
510+ flex-direction: column;
511+ border: 1px solid var(--border-strong);
512+ border-radius: var(--radius);
513+ background: var(--surface-raised);
514+ overflow: hidden;
515+}
516+
517+.palette-bar {
518+ display: flex;
519+ align-items: center;
520+ gap: 8px;
521+ height: 36px;
522+ padding: 0 10px;
523+ border-bottom: 1px solid var(--border);
524+ flex: none;
525+}
526+
527+.palette-prompt {
528+ font-family: var(--font-mono);
529+ font-size: var(--text-sm);
530+ color: var(--identity);
531+}
532+
533+.palette-input {
534+ flex: 1;
535+ height: 100%;
536+ border: none;
537+ background: transparent;
538+ padding: 0;
539+ font-family: var(--font-mono);
540+ font-size: var(--text-base);
541+}
542+
543+.palette-input:focus {
544+ outline: none;
545+ border-color: transparent;
546+}
547+
548+/* Chrome and Safari draw their own clear button inside a search input; it
549+ collides with the esc keycap. */
550+.palette-input::-webkit-search-cancel-button {
551+ display: none;
552+}
553+
554+.palette-results {
555+ overflow-y: auto;
556+ padding-bottom: 4px;
557+}
558+
559+.palette-group-label {
560+ padding: 6px 10px 2px;
561+}
562+
563+.palette-item {
564+ display: flex;
565+ align-items: center;
566+ gap: 8px;
567+ min-height: 26px;
568+ padding: 0 10px;
569+ width: 100%;
570+ border: none;
571+ background: transparent;
572+ color: var(--text);
573+ font: inherit;
574+ text-align: left;
575+ cursor: pointer;
576+}
577+
578+.palette-item:hover,
579+.palette-item.is-active {
580+ background: var(--surface);
581+ text-decoration: none;
582+}
583+
584+.palette-glyph {
585+ width: 12px;
586+ flex: none;
587+ font-family: var(--font-mono);
588+ font-size: var(--text-xs);
589+ color: var(--action);
590+}
591+
592+.palette-label {
593+ font-size: var(--text-sm);
594+ white-space: nowrap;
595+ flex: none;
596+}
597+
598+.palette-label.mono {
599+ font-family: var(--font-mono);
600+ color: var(--identity);
601+ font-weight: 500;
602+}
603+
604+/* The context line gives up its space first — the label is what you are
605+ aiming at. */
606+.palette-hint {
607+ font-size: var(--text-sm);
608+ overflow: hidden;
609+ text-overflow: ellipsis;
610+ white-space: nowrap;
611+ min-width: 0;
612+ flex: 1;
613+}
614+
615+.palette-all {
616+ border-top: 1px solid var(--border);
617+ margin-top: 4px;
618+ padding-top: 4px;
619+}
620+
621+.palette-empty,
622+.palette-foot {
623+ padding: 10px;
624+ font-size: var(--text-sm);
625+}
626+
627+.palette-foot {
628+ border-top: 1px solid var(--border);
629+ font-family: var(--font-mono);
630+ font-size: var(--text-xs);
631+ color: var(--text-faint);
632+ flex: none;
633+}
634+
635/* ─── typography ─────────────────────────────────────────────────────────── */
636
637h1 {
357 font-size: 20px;
638+ font-size: var(--text-lg);
639+ line-height: 28px;
640 font-weight: 600;
641 letter-spacing: -0.01em;
642 margin: 0 0 6px;
643}
644
645h2 {
364 font-size: 15px;
646+ font-size: var(--text-md);
647+ line-height: 24px;
648 font-weight: 600;
649 margin: 0 0 10px;
650}
@@ -380,96 +663,111 @@
663 color: var(--text-faint);
664}
665
666+/* The eyebrow. Condensed, uppercase, tracked out — used for every section
667+ label in the design, and never for anything a reader has to actually read. */
668.label-condensed {
669 font-family: var(--font-condensed);
670 font-weight: 500;
671 font-size: var(--text-xs);
672 line-height: 16px;
388 letter-spacing: 0.08em;
673+ letter-spacing: 0.06em;
674 text-transform: uppercase;
675 color: var(--text-dim);
676}
677
393/* Section headings in the new design lead with a brand slash. It is
394 decorative, so it is a pseudo-element rather than markup — a screen reader
395 should hear "awaiting your review", not "slash awaiting your review". */
396.label-condensed.slash::before {
397 content: "/";
398 color: var(--brand);
399 margin-right: 0.4em;
400}
401
402/* Big expressive display type. Condensed, tight, and set at a line-height
403 below 1 — it only works at large sizes, so it is never applied below 20px. */
678+/* Display type: the landing headline and the change-detail id. Sans rather
679+ than condensed, tight tracking, and only ever used at 24px and above. */
680.display {
405 font-family: var(--font-condensed);
406 font-weight: 700;
407 letter-spacing: -0.01em;
408 line-height: 0.98;
681+ font-size: var(--text-3xl);
682+ line-height: 48px;
683+ font-weight: 600;
684+ letter-spacing: -0.02em;
685+ text-wrap: pretty;
686+ margin: 0;
687}
688
411/* Marker-highlight on a single keyword in a headline. `box-decoration-break`
412 keeps the highlight intact when the phrase wraps across lines. */
413.mark {
414 background: var(--brand);
415 color: var(--brand-ink);
416 padding: 0 0.18em;
417 box-decoration-break: clone;
418 -webkit-box-decoration-break: clone;
689+@media (max-width: 800px) {
690+ .display {
691+ font-size: 30px;
692+ line-height: 34px;
693+ }
694}
695
696.tnum {
697 font-variant-numeric: tabular-nums;
698}
699
425/* Faint engineering grid behind the page. Pure decoration, and deliberately
426 near-invisible — at 2.2% it reads as texture rather than as a table. */
427.grid-field {
428 background-image:
429 linear-gradient(var(--grid-line) 1px, transparent 1px),
430 linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
431 background-size: 44px 44px;
700+/* Prose caps its own line length regardless of how wide the column is. */
701+.measure {
702+ max-width: 72ch;
703+ text-wrap: pretty;
704}
705
434/* Warm radial glow, anchored to the top-left of whatever it is applied to. */
435.brand-glow {
436 background: radial-gradient(120% 120% at 0% 0%, var(--glow), transparent 60%);
706+/* A keycap. Bottom border doubled so it reads as a physical key at 11px
707+ without needing a shadow. */
708+.kbd {
709+ display: inline-flex;
710+ align-items: center;
711+ justify-content: center;
712+ height: 16px;
713+ min-width: 16px;
714+ padding: 0 4px;
715+ border: 1px solid var(--border-strong);
716+ border-bottom-width: 2px;
717+ border-radius: var(--radius-sm);
718+ font-family: var(--font-mono);
719+ font-size: var(--text-xs);
720+ color: var(--text-faint);
721+}
722+
723+/* A key/value row joined by a dotted leader — the design's stat blocks. The
724+ leader is a flexing border rather than a repeated character so it lands on
725+ the same baseline whatever the two ends are. */
726+.dotline {
727+ display: flex;
728+ align-items: baseline;
729+ gap: 8px;
730+ font-size: var(--text-sm);
731}
732
439/* Horizontal mono ticker strip along the bottom of the hero. */
440.ticker-rule {
441 border-top: 1px solid var(--border);
442 border-bottom: 1px solid var(--border);
733+.dotline > .dotline-key {
734+ color: var(--text-dim);
735}
736
445.float-shadow {
446 box-shadow: 0 6px 20px rgb(0 0 0 / 0.35);
737+/* The leader sits in source order after the value but is ordered before it,
738+ so the markup stays "key, value" and reads correctly to a screen reader. */
739+.dotline::after {
740+ content: "";
741+ flex: 1;
742+ border-bottom: 1px dotted var(--border);
743}
744
449:root.light .float-shadow,
450:root[data-theme="light"] .float-shadow {
451 box-shadow: 0 6px 20px rgb(0 0 0 / 0.1);
745+.dotline > .dotline-val {
746+ order: 3;
747+ font-family: var(--font-mono);
748+ font-size: var(--text-xs);
749+ font-variant-numeric: tabular-nums;
750}
751
454/* Brand hover accent for links that are not already brand-coloured. */
455.link-brand {
456 text-underline-offset: 3px;
457 text-decoration-thickness: 1px;
752+.float-shadow {
753+ box-shadow: 0 8px 28px rgb(0 0 0 / 0.4);
754}
755
460.link-brand:hover {
461 color: var(--brand);
462 text-decoration-line: underline;
463 text-decoration-color: var(--brand);
756+:root.light .float-shadow,
757+:root[data-theme="light"] .float-shadow {
758+ box-shadow: 0 8px 28px rgb(0 0 0 / 0.12);
759}
760
761/* ─── surfaces ───────────────────────────────────────────────────────────── */
762
763+/* The generic bordered block, used by the settings, profile, org and form
764+ pages. 14px rather than 20 — the whole system moved a density step, and a
765+ panel that still breathes like the old one reads as a different product. */
766.panel {
767 background: var(--surface);
768 border: 1px solid var(--border);
769 border-radius: var(--radius);
472 padding: 20px;
770+ padding: 14px;
771}
772
773.panel + .panel {
@@ -490,39 +788,43 @@
788
789/* ─── controls ───────────────────────────────────────────────────────────── */
790
791+/* Secondary is the default. Transparent, hairline, and it reaches for
792+ `--action` only on hover — a page full of filled buttons has no primary. */
793.btn {
794 display: inline-flex;
795 align-items: center;
796+ justify-content: center;
797 gap: 6px;
497 padding: 6px 14px;
798+ height: 28px;
799+ padding: 0 10px;
800 border-radius: var(--radius);
801 border: 1px solid var(--border-strong);
500 background: var(--surface-raised);
802+ background: transparent;
803 color: var(--text);
804 font: inherit;
503 font-size: 13px;
805+ font-size: var(--text-sm);
806+ white-space: nowrap;
807 cursor: pointer;
808}
809
810.btn:hover {
508 border-color: var(--text-faint);
811+ border-color: var(--action);
812+ color: var(--action);
813 text-decoration: none;
814}
815
512/* Brand, not action: the design reserves vermilion for the one primary move on
513 a page. Ordinary links stay `--action` so the accent keeps its weight. */
816+/* One per view. Filled `--action` — the single move the page is asking for. */
817.btn-primary {
515 background: var(--brand);
516 border-color: var(--brand);
517 color: var(--brand-ink);
518 font-family: var(--font-condensed);
519 font-weight: 600;
520 text-transform: uppercase;
521 letter-spacing: 0.03em;
818+ background: var(--action);
819+ border-color: var(--action);
820+ color: var(--on-action);
821+ font-weight: 500;
822}
823
824.btn-primary:hover {
825 filter: brightness(1.08);
826+ color: var(--on-action);
827+ border-color: var(--action);
828}
829
830.btn-danger {
@@ -530,6 +832,12 @@
832 color: var(--danger);
833}
834
835+.btn-danger:hover {
836+ border-color: var(--danger);
837+ color: var(--danger);
838+ background: color-mix(in srgb, var(--danger) 12%, transparent);
839+}
840+
841/* Sign out is destructive enough to warn about but too routine to shout, so it
842 sits quiet in the masthead and only reddens on approach. */
843.btn-quiet-danger {
@@ -543,24 +851,69 @@
851 color: var(--danger);
852}
853
854+.btn[disabled],
855+.btn[aria-disabled="true"] {
856+ border-color: var(--border);
857+ color: var(--text-faint);
858+ cursor: default;
859+}
860+
861+.btn[disabled]:hover,
862+.btn[aria-disabled="true"]:hover {
863+ border-color: var(--border);
864+ color: var(--text-faint);
865+}
866+
867+/* The compact monospace button used inside dense bars — blame, replay, edit,
868+ copy. Small enough that it has to be mono to stay legible. */
869+.btn-mono {
870+ height: 24px;
871+ padding: 0 8px;
872+ font-family: var(--font-mono);
873+ font-size: var(--text-xs);
874+ color: var(--text-dim);
875+}
876+
877+.btn-mono[aria-pressed="true"] {
878+ border-color: var(--action);
879+ color: var(--action);
880+}
881+
882input[type="text"],
883input[type="password"],
884+input[type="search"],
885+input[type="email"],
886textarea {
887 width: 100%;
550 padding: 7px 10px;
888+ padding: 0 8px;
889+ height: 30px;
890 background: var(--bg);
891 border: 1px solid var(--border-strong);
892 border-radius: var(--radius);
893 color: var(--text);
894 font: inherit;
556 font-size: 13px;
895+ font-size: var(--text-base);
896+}
897+
898+textarea {
899+ height: auto;
900+ min-height: 64px;
901+ padding: 6px 8px;
902+ line-height: 21px;
903+ resize: vertical;
904+}
905+
906+input:focus,
907+textarea:focus,
908+select:focus {
909+ border-color: var(--action);
910}
911
912input:focus,
913textarea:focus,
914.btn:focus-visible,
915a:focus-visible {
563 outline: 2px solid var(--brand);
916+ outline: 2px solid var(--action);
917 outline-offset: 1px;
918}
919
@@ -571,13 +924,17 @@
924.field label {
925 display: block;
926 margin-bottom: 5px;
574 font-size: 12px;
927+ font-family: var(--font-condensed);
928+ font-size: var(--text-xs);
929+ font-weight: 500;
930+ letter-spacing: 0.06em;
931+ text-transform: uppercase;
932 color: var(--text-dim);
933}
934
935.hint {
936 margin-top: 5px;
580 font-size: 12px;
937+ font-size: var(--text-sm);
938 color: var(--text-faint);
939}
940
@@ -586,41 +943,91 @@
943.chip {
944 display: inline-flex;
945 align-items: center;
589 padding: 1px 7px;
946+ height: 18px;
947+ padding: 0 5px;
948 border-radius: var(--radius-sm);
949 border: 1px solid var(--border-strong);
950 font-family: var(--font-mono);
593 font-size: 11.5px;
951+ font-size: var(--text-xs);
952 color: var(--text-dim);
953+ white-space: nowrap;
954}
955
597/* The change-identity chip. jj change ids are the product's central concept,
598 so they get the one strong accent colour in the palette. */
599.chip-change {
956+/* The change id, rendered inline rather than boxed.
957+ A jj change id has a *shortest unique prefix* — the part you actually type
958+ and paste. The design splits it: the prefix carries `--identity` at weight
959+ 500, the remainder drops to `--text-faint`. Both halves are selectable as
960+ one string, so copying still yields the whole id. This is the single most
961+ repeated element in the product; everything else defers to it. */
962+.cid {
963+ font-family: var(--font-mono);
964+ font-variant-numeric: tabular-nums;
965+ white-space: nowrap;
966+}
967+
968+.cid > .cid-p {
969 color: var(--identity);
601 border-color: color-mix(in srgb, var(--identity) 40%, transparent);
602 background: color-mix(in srgb, var(--identity) 10%, transparent);
970+ font-weight: 500;
971+}
972+
973+.cid > .cid-r {
974+ color: var(--text-faint);
975+}
976+
977+a.cid:hover {
978+ text-decoration: none;
979+}
980+
981+a.cid:hover > .cid-p {
982+ text-decoration: underline;
983+}
984+
985+/* Synthetic ids get no identity colour: a plain-git change has a derived id,
986+ and presenting it as a real one would be a lie the reader cannot detect. */
987+.cid-synthetic > .cid-p {
988+ color: var(--text-dim);
989+ font-weight: 400;
990}
991
992+/* State pills are outlined in their state colour, not filled with it. Four
993+ states appear side by side in listings, and four filled blocks of colour
994+ read as a chart rather than as metadata. */
995.badge {
996 display: inline-flex;
997 align-items: center;
608 padding: 2px 8px;
609 border-radius: 999px;
610 font-size: 11.5px;
998+ gap: 5px;
999+ height: 20px;
1000+ padding: 0 6px;
1001+ border: 1px solid var(--border-strong);
1002+ border-radius: var(--radius-sm);
1003+ font-family: var(--font-condensed);
1004+ font-size: var(--text-xs);
1005 font-weight: 500;
612 color: var(--brand-ink);
1006+ letter-spacing: 0.06em;
1007+ text-transform: uppercase;
1008+ color: var(--text-dim);
1009+ white-space: nowrap;
1010}
1011
615.badge-open { background: var(--open); }
616.badge-merged { background: var(--merged); }
617.badge-abandoned { background: var(--abandoned); }
618.badge-conflict { background: var(--conflict); }
1012+/* The state glyph. Decorative — the pill's text already names the state — so
1013+ call sites mark it `aria-hidden`. */
1014+.badge > .glyph {
1015+ font-family: var(--font-mono);
1016+ font-size: var(--text-xs);
1017+ line-height: 1;
1018+}
1019
1020+.badge-open { color: var(--open); border-color: var(--open); }
1021+.badge-merged { color: var(--merged); border-color: var(--merged); }
1022+.badge-abandoned { color: var(--abandoned); border-color: var(--abandoned); }
1023+.badge-conflict { color: var(--conflict); border-color: var(--conflict); }
1024+.badge-draft { color: var(--text-dim); border-color: var(--border-strong); }
1025+
1026/* ─── notices ────────────────────────────────────────────────────────────── */
1027
1028.banner {
623 padding: 12px 16px;
1029+ padding: 10px 12px;
1030+ font-size: var(--text-sm);
1031 border-radius: var(--radius);
1032 border: 1px solid var(--border-strong);
1033 background: var(--surface);
@@ -639,35 +1046,36 @@
1046
1047/* ─── empty states ───────────────────────────────────────────────────────── */
1048
1049+/* An empty state is a solid bordered block like every other listing, not a
1050+ dashed placeholder: on this system a dashed border means "an action goes
1051+ here" (see `.repo-card-new`), and an empty change list is not an action. */
1052.empty {
1053+ display: flex;
1054+ flex-direction: column;
1055+ align-items: center;
1056+ gap: 6px;
1057 text-align: center;
644 padding: 56px 20px;
1058+ padding: 28px 20px;
1059 color: var(--text-dim);
646 border: 1px dashed var(--border-strong);
1060+ border: 1px solid var(--border);
1061 border-radius: var(--radius);
1062+ background: var(--surface);
1063}
1064
1065.empty h2 {
1066+ margin: 0;
1067+ font-size: var(--text-md);
1068+ line-height: 24px;
1069 color: var(--text);
1070}
1071
654/* ─── code blocks ────────────────────────────────────────────────────────── */
655
656.clone-box {
657 display: flex;
658 align-items: center;
659 gap: 8px;
660 background: var(--bg);
661 border: 1px solid var(--border);
662 border-radius: var(--radius);
663 padding: 8px 10px;
664 overflow-x: auto;
1072+.empty p {
1073+ margin: 0;
1074+ font-size: var(--text-sm);
1075}
1076
667.clone-box code {
668 white-space: nowrap;
669 color: var(--text-dim);
670}
1077+/* ─── code blocks ────────────────────────────────────────────────────────── */
1078+
1079
1080/* Protocol toggle (HTTPS/SSH clone instructions). Two radios drive which
1081 panel shows via a sibling selector — no script needed, so the choice works
@@ -709,7 +1117,7 @@
1117
1118#proto-https:focus-visible ~ .proto-tabs label[for="proto-https"],
1119#proto-ssh:focus-visible ~ .proto-tabs label[for="proto-ssh"] {
712 outline: 2px solid var(--brand);
1120+ outline: 2px solid var(--action);
1121 outline-offset: 2px;
1122}
1123
@@ -724,16 +1132,17 @@
1132
1133#proto-https:checked ~ .proto-tabs label[for="proto-https"],
1134#proto-ssh:checked ~ .proto-tabs label[for="proto-ssh"] {
727 background: var(--brand);
728 color: var(--brand-ink);
1135+ background: var(--action);
1136+ color: var(--on-action);
1137}
1138
1139footer {
1140 border-top: 1px solid var(--border);
733 margin-top: 48px;
734 padding: 20px 0;
1141+ margin-top: 0;
1142+ padding: 16px 0;
1143+ background: var(--surface);
1144 color: var(--text-faint);
736 font-size: 12px;
1145+ font-size: var(--text-sm);
1146}
1147
1148footer .wrap {
@@ -745,20 +1154,24 @@
1154
1155footer a {
1156 color: var(--text-faint);
1157+ font-family: var(--font-mono);
1158+ font-size: var(--text-xs);
1159+}
1160+
1161+footer a:hover {
1162+ color: var(--action);
1163}
1164
1165.footer-mark {
751 font-family: var(--font-condensed);
752 font-weight: 700;
753 text-transform: uppercase;
754 letter-spacing: 0.04em;
755 color: var(--text-dim);
1166+ font-family: var(--font-mono);
1167+ font-size: var(--text-sm);
1168+ color: var(--text-faint);
1169}
1170
758/* Pushed to the far end rather than floated, so it stays put when the row
759 wraps on a narrow screen. */
1171+/* The spacer collapses to nothing once the row wraps, which is what keeps the
1172+ copyright from stranding itself on a line of its own. */
1173.footer-end {
761 margin-left: auto;
1174+ font-size: var(--text-xs);
1175}
1176
1177/* Respect a user's reduced-motion preference (spec §11 accessibility pass). */
@@ -791,448 +1204,805 @@
1204}
1205
1206
794/* ─── landing and dashboard ────────────────────────────────────────────────── */
1207+/* ─── full-bleed bands ─────────────────────────────────────────────────────── */
1208+
1209+/* The landing page is a stack of edge-to-edge bands, alternating ground and
1210+ surface, each separated by a single hairline. There are no cards and no
1211+ shadows: the rules do all the dividing. */
1212+.band {
1213+ border-bottom: 1px solid var(--border);
1214+}
1215+
1216+/* The footer draws its own top rule, so the last band must not draw one too —
1217+ two hairlines a pixel apart read as a rendering fault. */
1218+main.flush > .band:last-child {
1219+ border-bottom: none;
1220+}
1221
796.hero {
797 border: 1px solid var(--border);
798 border-radius: var(--radius);
1222+.band-surface {
1223 background: var(--surface);
800 overflow: hidden;
1224}
1225
803.hero-body {
804 padding: 40px 40px 36px;
1226+.band > .wrap {
1227+ padding-top: 32px;
1228+ padding-bottom: 32px;
1229}
1230
807.hero-eyebrow {
1231+.band-head {
1232 display: flex;
1233+ align-items: baseline;
1234+ gap: 12px;
1235+ flex-wrap: wrap;
1236+ margin-bottom: 12px;
1237+}
1238+
1239+.band-head h2 {
1240+ margin: 0;
1241+ font-size: var(--text-lg);
1242+ line-height: 28px;
1243+}
1244+
1245+.band-note {
1246+ font-size: var(--text-sm);
1247+ color: var(--text-dim);
1248+}
1249+
1250+/* ─── hero ─────────────────────────────────────────────────────────────────── */
1251+
1252+.hero > .wrap {
1253+ padding-top: 44px;
1254+ padding-bottom: 44px;
1255+ display: grid;
1256+ grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr);
1257+ gap: 44px;
1258 align-items: center;
810 gap: 8px;
811 margin-bottom: 20px;
1259}
1260
814.pill-brand {
1261+.hero-body {
1262+ display: flex;
1263+ flex-direction: column;
1264+ align-items: flex-start;
1265+ gap: 16px;
1266+}
1267+
1268+/* An outlined tag, not a filled one. The hero already has a primary button;
1269+ a second saturated block above it would compete with it. */
1270+.hero-eyebrow {
1271 display: inline-flex;
1272 align-items: center;
1273 height: 20px;
1274 padding: 0 6px;
819 border-radius: var(--radius);
820 background: var(--brand);
821 color: var(--brand-ink);
822 font-family: var(--font-mono);
823 font-size: var(--text-xs);
824 font-weight: 500;
1275+ border: 1px solid var(--border-strong);
1276+ border-radius: var(--radius-sm);
1277}
1278
1279+/* 15ch is what puts the break after "pointer." — the headline is a two-part
1280+ sentence and the line break is doing the punctuation. */
1281.hero-title {
828 font-size: var(--text-2xl);
829 max-width: 20ch;
830 margin: 0;
831 text-wrap: balance;
1282+ max-width: 15ch;
1283}
1284
1285.hero-lede {
835 max-width: 54ch;
836 margin: 24px 0 0;
1286+ margin: 0;
1287 font-size: var(--text-md);
838 line-height: 1.55;
1288+ line-height: 24px;
1289 color: var(--text-dim);
1290+ max-width: 44ch;
1291+ text-wrap: pretty;
1292+}
1293+
1294+.hero-lede .mono {
1295+ color: var(--text);
1296}
1297
1298.hero-actions {
1299 display: flex;
1300+ gap: 8px;
1301+ align-items: center;
1302 flex-wrap: wrap;
1303+ padding-top: 4px;
1304+}
1305+
1306+.hero-actions .btn {
1307+ height: 32px;
1308+ padding: 0 12px;
1309+ font-size: var(--text-base);
1310+}
1311+
1312+/* The clone line. A `$` and a command — the first thing a visitor actually
1313+ needs, so it sits in the hero rather than three scrolls down. */
1314+.clone-box {
1315+ display: flex;
1316+ align-items: center;
1317 gap: 8px;
846 margin-top: 28px;
1318+ height: 30px;
1319+ max-width: 100%;
1320+ padding: 0 8px;
1321+ border: 1px solid var(--border-strong);
1322+ border-radius: var(--radius);
1323+ background: var(--bg);
1324+ font-family: var(--font-mono);
1325+ font-size: var(--text-sm);
1326+ overflow-x: auto;
1327+}
1328+
1329+.clone-box .prompt {
1330+ color: var(--text-faint);
1331+ flex: none;
1332}
1333
849.btn-lg {
850 height: 38px;
851 padding: 0 20px;
1334+.clone-box code {
1335+ color: var(--text);
1336+ white-space: nowrap;
1337+ background: none;
1338+ padding: 0;
1339+ border: 0;
1340}
1341
854.hero .clone-box {
855 margin-top: 24px;
856 max-width: 54ch;
1342+/* The block form of the clone box, used on the empty-repository page where the
1343+ command sits on its own line rather than inline in a hero. */
1344+.proto-panel .clone-box {
1345+ height: auto;
1346+ padding: 8px 10px;
1347}
1348
859.ticker {
1349+/* ─── the terminal illustration ────────────────────────────────────────────── */
1350+
1351+/* A transcript, not a live console. It is captioned as an example and it never
1352+ animates: a fake terminal that types at you is indistinguishable from one
1353+ showing real state, and this one is showing neither. */
1354+.term {
1355+ margin: 0;
1356 display: flex;
861 flex-wrap: wrap;
862 gap: 4px 20px;
863 padding: 10px 40px;
1357+ flex-direction: column;
1358+ border: 1px solid var(--border-strong);
1359+ border-radius: var(--radius);
1360+ background: var(--bg);
1361+ overflow: hidden;
1362}
1363
866.ticker-item {
1364+.term-bar,
1365+.term-foot {
1366 display: flex;
1367 align-items: center;
1368 gap: 8px;
1369+ padding: 0 8px;
1370+ background: var(--surface-raised);
1371+ flex: none;
1372+}
1373+
1374+.term-bar {
1375+ height: 28px;
1376+ border-bottom: 1px solid var(--border);
1377+}
1378+
1379+.term-foot {
1380+ padding: 6px 12px;
1381+ border-top: 1px solid var(--border);
1382+ font-size: var(--text-sm);
1383+ color: var(--text-dim);
1384+}
1385+
1386+.term-dots {
1387+ display: flex;
1388+ gap: 4px;
1389+ flex: none;
1390+}
1391+
1392+.term-dots span {
1393+ width: 7px;
1394+ height: 7px;
1395+ border-radius: 50%;
1396+ background: var(--border-strong);
1397+}
1398+
1399+.term-host {
1400 font-family: var(--font-mono);
1401 font-size: var(--text-xs);
1402 color: var(--text-faint);
1403+}
1404+
1405+.term-body {
1406+ margin: 0;
1407+ padding: 10px 12px;
1408+ min-height: 300px;
1409+ font-family: var(--font-mono);
1410+ font-size: var(--text-sm);
1411+ line-height: 20px;
1412+ overflow-x: auto;
1413+ white-space: pre;
1414+}
1415+
1416+.term-cmd {
1417+ color: var(--text);
1418+}
1419+
1420+.term-cmd::before {
1421+ content: "$ ";
1422+ color: var(--identity);
1423+}
1424+
1425+.term-out { color: var(--text-dim); }
1426+.term-out.is-faint { color: var(--text-faint); }
1427+.term-out.is-add { color: var(--diff-add-text); }
1428+.term-out.is-action { color: var(--action); }
1429+.term-out.is-open { color: var(--open); }
1430+.term-out.is-conflict { color: var(--conflict); }
1431+.term-out.is-identity { color: var(--identity); }
1432+
1433+/* ─── terminal animation ───────────────────────────────────────────────────── */
1434+
1435+/* While animating, the body keeps its min-height so it does not collapse. */
1436+.term-body.term-animating {
1437+ min-height: 300px;
1438+}
1439+
1440+/* Lines start hidden and are revealed by adding .term-line-visible, rather
1441+ than via inline styles: the CSP's style-src has no 'unsafe-inline'. */
1442+.term-body.term-animating [data-term-line] {
1443+ visibility: hidden;
1444+ height: 0;
1445+ overflow: hidden;
1446+}
1447+
1448+.term-body.term-animating [data-term-line].term-line-visible {
1449+ visibility: visible;
1450+ height: auto;
1451+ overflow: visible;
1452+}
1453+
1454+/* The blinking cursor for the line currently being typed. */
1455+.term-typing::after {
1456+ content: "";
1457+ display: inline-block;
1458+ width: 0.55em;
1459+ height: 1.15em;
1460+ margin-left: 1px;
1461+ vertical-align: text-bottom;
1462+ background: var(--identity);
1463+ animation: term-blink 0.6s steps(2, start) infinite;
1464+}
1465+
1466+@keyframes term-blink {
1467+ to { opacity: 0; }
1468+}
1469+
1470+/* Once animation is done, no residual style. */
1471+.term-body.term-ready [data-term-line] {
1472+ visibility: visible !important;
1473+ height: auto !important;
1474+ overflow: visible !important;
1475+}
1476+
1477+/* ─── ticker ───────────────────────────────────────────────────────────────── */
1478+
1479+/* The design scrolls this. It does not scroll here: it is decoration carrying
1480+ six words of copy, and an infinite marquee is a permanent moving object on
1481+ the page for anyone who did not ask for one. It wraps instead. */
1482+.ticker {
1483+ display: flex;
1484+ flex-wrap: wrap;
1485+ overflow: hidden;
1486+}
1487+
1488+.ticker-item {
1489+ display: inline-flex;
1490+ align-items: center;
1491+ gap: 10px;
1492+ height: 34px;
1493+ padding: 0 14px;
1494 white-space: nowrap;
1495+ font-family: var(--font-condensed);
1496+ font-size: var(--text-xs);
1497+ font-weight: 500;
1498+ letter-spacing: 0.06em;
1499+ text-transform: uppercase;
1500+ color: var(--text-dim);
1501}
1502
1503.ticker-dot {
877 color: var(--brand);
1504+ font-family: var(--font-mono);
1505+ color: var(--identity);
1506}
1507
880@media (max-width: 700px) {
881 .hero-body {
882 padding: 28px 20px 24px;
883 }
884 .ticker {
885 padding: 10px 20px;
886 }
887 .hero-title {
888 font-size: var(--text-xl);
889 }
1508+/* ─── two-column body ──────────────────────────────────────────────────────── */
1509+
1510+.columns {
1511+ display: grid;
1512+ grid-template-columns: minmax(0, 1fr) 260px;
1513+ gap: 32px;
1514+ align-items: start;
1515}
1516
892/* Two-column body shared by the landing page and the dashboard. */
893.landing-columns {
1517+.columns-main {
1518+ min-width: 0;
1519 display: flex;
1520 flex-direction: column;
896 gap: 32px;
897 margin-top: 40px;
1521+ gap: 8px;
1522}
1523
900.landing-main {
901 min-width: 0;
902 flex: 1;
1524+.columns-aside {
1525 display: flex;
1526 flex-direction: column;
905 gap: 32px;
1527+ gap: 16px;
1528+ min-width: 0;
1529}
1530
908.landing-aside {
909 width: 100%;
910 flex-shrink: 0;
1531+/* Each aside block is separated by a rule rather than boxed. The last one
1532+ drops its rule so the column does not end on a line to nowhere. */
1533+.aside-block {
1534 display: flex;
1535 flex-direction: column;
913 gap: 32px;
1536+ gap: 6px;
1537+ padding-bottom: 14px;
1538+ border-bottom: 1px solid var(--border);
1539}
1540
916@media (min-width: 900px) {
917 .landing-columns {
918 flex-direction: row;
919 }
920 .landing-aside {
921 width: 320px;
922 }
1541+.aside-block:last-child {
1542+ padding-bottom: 0;
1543+ border-bottom: none;
1544}
1545
925.section-head {
1546+.aside-head {
1547 display: flex;
927 align-items: baseline;
928 justify-content: space-between;
929 gap: 12px;
930 border-bottom: 1px solid var(--border);
931 padding-bottom: 8px;
1548+ align-items: center;
1549+ gap: 8px;
1550}
1551
934.section-note {
935 margin: 8px 0 12px;
936}
937
938/* ─── feed ─────────────────────────────────────────────────────────────────── */
1552+/* ─── activity feed ────────────────────────────────────────────────────────── */
1553
1554.feed {
941 list-style: none;
942 margin: 8px 0 0;
943 padding: 0;
1555 border: 1px solid var(--border);
1556 border-radius: var(--radius);
1557+ background: var(--surface);
1558+ overflow: hidden;
1559+ margin: 0;
1560+ padding: 0;
1561+ list-style: none;
1562}
1563
1564.feed-row {
949 position: relative;
1565 display: flex;
951 align-items: flex-start;
952 gap: 12px;
953 padding: 12px 12px 12px 16px;
1566+ align-items: center;
1567+ gap: 8px;
1568+ height: 34px;
1569+ padding: 0 10px 0 0;
1570 border-bottom: 1px solid var(--border);
955 overflow: hidden;
1571+ font-size: var(--text-sm);
1572}
1573
1574.feed-row:last-child {
1575 border-bottom: none;
1576}
1577
962/* A hover rail rather than a background wash: it marks the row without
963 changing the contrast of the text sitting on it. */
964.feed-row::before {
965 content: "";
966 position: absolute;
967 inset-block: 0;
968 left: 0;
969 width: 2px;
970 background: var(--brand);
971 opacity: 0;
972 transition: opacity 80ms ease-out;
1578+.feed-row:hover {
1579+ background: var(--surface-raised);
1580+}
1581+
1582+/* The event glyph, in the colour of what happened. */
1583+.feed-glyph {
1584+ width: 22px;
1585+ flex: none;
1586+ text-align: center;
1587+ font-family: var(--font-mono);
1588+ font-size: var(--text-sm);
1589+}
1590+
1591+.feed-glyph.is-push { color: var(--action); }
1592+.feed-glyph.is-review { color: var(--open); }
1593+.feed-glyph.is-merge { color: var(--merged); }
1594+.feed-glyph.is-conflict { color: var(--conflict); }
1595+.feed-glyph.is-abandon { color: var(--abandoned); }
1596+.feed-glyph.is-open { color: var(--identity); }
1597+
1598+.feed-repo {
1599+ font-family: var(--font-mono);
1600+ font-size: var(--text-sm);
1601+ white-space: nowrap;
1602+ flex: none;
1603+ color: var(--text-faint);
1604}
1605
975.feed-row:hover::before {
976 opacity: 1;
1606+.feed-repo .owner {
1607+ color: var(--identity);
1608+ font-weight: 500;
1609}
1610
979.feed-main {
980 min-width: 0;
981 flex: 1;
1611+.feed-verb {
1612+ color: var(--text-dim);
1613+ white-space: nowrap;
1614+ flex: none;
1615}
1616
1617+/* The one element allowed to be cut off. Everything else in the row is a
1618+ fixed-width fact; the title is the part that can be finished by clicking. */
1619.feed-title {
985 display: block;
1620+ font-size: var(--text-base);
1621 color: var(--text);
987 font-weight: 500;
1622 overflow: hidden;
1623 text-overflow: ellipsis;
1624 white-space: nowrap;
1625+ min-width: 0;
1626+ flex: 1;
1627}
1628
993.feed-title:hover {
994 color: var(--brand);
995 text-decoration: none;
1629+.feed-age {
1630+ font-family: var(--font-mono);
1631+ font-size: var(--text-xs);
1632+ color: var(--text-faint);
1633+ white-space: nowrap;
1634+ flex: none;
1635+ width: 56px;
1636+ text-align: right;
1637}
1638
998.feed-meta {
1639+.feed-foot {
1640 display: flex;
1000 flex-wrap: wrap;
1641 align-items: center;
1002 gap: 4px 8px;
1003 margin-top: 4px;
1004 font-size: var(--text-xs);
1642+ gap: 8px;
1643+ font-size: var(--text-sm);
1644 color: var(--text-faint);
1645}
1646
1008.feed-actor {
1009 color: var(--text-dim);
1010 font-weight: 500;
1647+/* A live-ish dot. It does not blink — the page is server-rendered and does not
1648+ update itself, so a pulsing indicator would be claiming something false. */
1649+.live-dot {
1650+ width: 6px;
1651+ height: 6px;
1652+ border-radius: 50%;
1653+ background: var(--open);
1654+ display: inline-block;
1655}
1656
1013.feed-side {
1657+.live-indicator {
1658+ display: inline-flex;
1659+ align-items: center;
1660+ gap: 6px;
1661+ font-family: var(--font-mono);
1662+ font-size: var(--text-xs);
1663+ color: var(--text-faint);
1664+}
1665+
1666+/* ─── compact aside rows ───────────────────────────────────────────────────── */
1667+
1668+/* A change in an aside list: rail, glyph, id, title. The rail is what marks it
1669+ as part of a stack. */
1670+.mini-row {
1671 display: flex;
1015 flex-shrink: 0;
1016 flex-direction: column;
1017 align-items: flex-end;
1018 gap: 4px;
1672+ align-items: center;
1673+ gap: 6px;
1674+ min-height: 22px;
1675+ font-size: var(--text-sm);
1676+ min-width: 0;
1677}
1678
1021/* ─── activity ─────────────────────────────────────────────────────────────── */
1679+.mini-row:hover {
1680+ background: var(--surface);
1681+ text-decoration: none;
1682+}
1683
1023.activity {
1024 list-style: none;
1025 margin: 4px 0 0;
1026 padding: 0;
1684+.mini-rail {
1685+ width: 2px;
1686+ align-self: stretch;
1687+ background: var(--identity);
1688+ flex: none;
1689+}
1690+
1691+.mini-glyph {
1692+ width: 14px;
1693+ flex: none;
1694+ text-align: center;
1695+ font-family: var(--font-mono);
1696+ font-size: var(--text-xs);
1697+}
1698+
1699+.mini-title {
1700+ color: var(--text-dim);
1701+ overflow: hidden;
1702+ text-overflow: ellipsis;
1703+ white-space: nowrap;
1704+ min-width: 0;
1705+}
1706+
1707+.mini-age {
1708+ font-family: var(--font-mono);
1709+ font-size: var(--text-xs);
1710+ color: var(--text-faint);
1711+ margin-left: auto;
1712+ flex: none;
1713}
1714
1029.activity-row {
1715+/* ─── bookmark rows ────────────────────────────────────────────────────────── */
1716+
1717+.bookmark-line {
1718 display: flex;
1031 flex-wrap: wrap;
1032 align-items: baseline;
1033 gap: 0 6px;
1034 padding: 7px 0;
1035 border-bottom: 1px dashed var(--border);
1719+ align-items: center;
1720+ gap: 6px;
1721 font-size: var(--text-sm);
1722+ color: var(--text-dim);
1723}
1724
1039.activity-row:last-child {
1040 border-bottom: none;
1725+.bookmark-line:hover {
1726+ text-decoration: none;
1727}
1728
1043.activity-actor {
1044 color: var(--text);
1045 font-weight: 500;
1729+.bookmark-line:hover .chip {
1730+ border-color: var(--action);
1731+ color: var(--action);
1732}
1733
1048.activity-when {
1049 margin-left: auto;
1734+.bookmark-flag {
1735+ font-family: var(--font-condensed);
1736 font-size: var(--text-xs);
1737+ font-weight: 500;
1738+ letter-spacing: 0.06em;
1739+ text-transform: uppercase;
1740+ color: var(--text-faint);
1741}
1742
1053/* ─── comparison list ──────────────────────────────────────────────────────── */
1743+.bookmark-flag.is-diverged {
1744+ color: var(--conflict);
1745+}
1746
1747+/* ─── why switch ───────────────────────────────────────────────────────────── */
1748+
1749.compare {
1056 list-style: none;
1057 margin: 12px 0 0;
1750+ display: grid;
1751+ grid-template-columns: repeat(4, minmax(0, 1fr));
1752+ gap: 8px;
1753+ margin: 0;
1754 padding: 0;
1059 display: flex;
1060 flex-direction: column;
1061 gap: 10px;
1755+ list-style: none;
1756}
1757
1758+/* Each card is literally a two-line diff: the thing you put up with, then the
1759+ thing you get. The pitch is stated in the product's own notation. */
1760.compare-item {
1065 display: flex;
1066 flex-direction: column;
1067 gap: 4px;
1068 padding: 12px;
1761 border: 1px solid var(--border);
1762 border-radius: var(--radius);
1071 background: var(--surface);
1763+ background: var(--bg);
1764+ overflow: hidden;
1765}
1766
1767.compare-them,
1768.compare-us {
1769 display: flex;
1077 align-items: baseline;
1770+ align-items: flex-start;
1771 gap: 8px;
1079 font-size: var(--text-sm);
1772+ padding: 8px 10px;
1773+ font-size: var(--text-base);
1774+ text-wrap: pretty;
1775}
1776
1777.compare-them {
1083 color: var(--text-faint);
1084 text-decoration: line-through;
1085 text-decoration-color: color-mix(in srgb, var(--conflict) 60%, transparent);
1778+ border-bottom: 1px solid var(--border);
1779+ color: var(--text-dim);
1780}
1781
1782.compare-us {
1783+ background: var(--identity-wash);
1784 color: var(--text);
1785}
1786
1787.compare-sign {
1788 font-family: var(--font-mono);
1094 flex-shrink: 0;
1095}
1096
1097.compare-them .compare-sign {
1098 color: var(--conflict);
1789+ font-size: var(--text-sm);
1790+ flex: none;
1791}
1792
1101.compare-us .compare-sign {
1102 color: var(--merged);
1103}
1793+.compare-them .compare-sign { color: var(--danger); }
1794+.compare-us .compare-sign { color: var(--identity); }
1795
1796/* ─── repo cards ───────────────────────────────────────────────────────────── */
1797
1798.repo-cards {
1108 display: flex;
1109 flex-direction: column;
1799+ display: grid;
1800+ grid-template-columns: repeat(3, minmax(0, 1fr));
1801 gap: 8px;
1111 margin-top: 12px;
1802}
1803
1804.repo-card {
1115 position: relative;
1805 display: flex;
1806 flex-direction: column;
1807 gap: 6px;
1119 padding: 12px 12px 12px 16px;
1808+ padding: 12px;
1809 border: 1px solid var(--border);
1810 border-radius: var(--radius);
1811 background: var(--surface);
1812 color: var(--text);
1124 overflow: hidden;
1813}
1814
1815.repo-card:hover {
1128 border-color: var(--border-strong);
1816+ border-color: var(--action);
1817 text-decoration: none;
1818}
1819
1132.repo-card-rail {
1133 position: absolute;
1134 inset-block: 0;
1135 left: 0;
1136 width: 2px;
1137 background: var(--brand);
1138 opacity: 0;
1139 transition: opacity 80ms ease-out;
1820+.repo-card-name {
1821+ font-family: var(--font-mono);
1822+ font-size: var(--text-base);
1823+ color: var(--action);
1824}
1825
1142.repo-card:hover .repo-card-rail {
1143 opacity: 1;
1826+.repo-card-desc {
1827+ font-size: var(--text-sm);
1828+ line-height: 18px;
1829+ color: var(--text-dim);
1830+ text-wrap: pretty;
1831}
1832
1146.repo-card-name {
1833+.repo-card-meta {
1834 display: flex;
1835 align-items: center;
1149 gap: 6px;
1150 font-size: var(--text-sm);
1151}
1152
1153.repo-card-repo {
1154 font-weight: 500;
1836+ gap: 10px;
1837+ padding-top: 2px;
1838+ font-family: var(--font-mono);
1839+ font-size: var(--text-xs);
1840+ color: var(--text-faint);
1841}
1842
1157.repo-card:hover .repo-card-repo {
1158 color: var(--brand);
1159}
1160
1161.repo-card-desc {
1162 font-size: var(--text-xs);
1163 color: var(--text-dim);
1164}
1843+.repo-card-meta .is-open { color: var(--open); }
1844+.repo-card-meta .is-conflict { color: var(--conflict); }
1845+.repo-card-meta .at-end { margin-left: auto; }
1846
1847+/* A dashed slot rather than a filled card: it is an action in a list of
1848+ objects, and it should not look like one of the objects. */
1849.repo-card-new {
1167 padding: 9px 12px;
1168 border: 1px dashed var(--border);
1850+ display: flex;
1851+ align-items: center;
1852+ justify-content: center;
1853+ min-height: 84px;
1854+ border: 1px dashed var(--border-strong);
1855 border-radius: var(--radius);
1170 color: var(--text-dim);
1856+ font-family: var(--font-mono);
1857 font-size: var(--text-sm);
1172 text-align: center;
1858+ color: var(--text-dim);
1859}
1860
1861.repo-card-new:hover {
1176 border-color: var(--brand);
1177 color: var(--brand);
1862+ border-color: var(--action);
1863+ color: var(--action);
1864 text-decoration: none;
1865}
1866
1181/* ─── call to action band ──────────────────────────────────────────────────── */
1867+/* ─── call to action ───────────────────────────────────────────────────────── */
1868
1183.cta-band {
1184 margin-top: 48px;
1185 padding: 44px 24px;
1186 border: 1px solid var(--border);
1187 border-radius: var(--radius);
1188 background: var(--surface);
1189 text-align: center;
1869+.cta > .wrap {
1870+ padding-top: 40px;
1871+ padding-bottom: 40px;
1872+ display: flex;
1873+ align-items: center;
1874+ gap: 20px;
1875+ flex-wrap: wrap;
1876}
1877
1878.cta-title {
1193 margin: 0 auto;
1194 max-width: 22ch;
1879+ margin: 0;
1880 font-size: var(--text-xl);
1196 text-wrap: balance;
1881+ line-height: 32px;
1882+ font-weight: 600;
1883+ text-wrap: pretty;
1884}
1885
1886.cta-lede {
1200 margin: 16px auto 0;
1201 max-width: 48ch;
1202 font-size: var(--text-md);
1887+ margin: 0;
1888+ font-size: var(--text-base);
1889 color: var(--text-dim);
1204}
1205
1206.cta-actions {
1207 justify-content: center;
1890+ text-wrap: pretty;
1891}
1892
1210/* ─── dashboard header ─────────────────────────────────────────────────────── */
1893+/* ─── dashboard ────────────────────────────────────────────────────────────── */
1894
1895.dash-head {
1896 display: flex;
1897+ align-items: flex-start;
1898+ gap: 16px;
1899 flex-wrap: wrap;
1215 align-items: flex-end;
1216 justify-content: space-between;
1217 gap: 12px;
1218 padding-bottom: 16px;
1900+ padding-bottom: 14px;
1901+ margin-bottom: 16px;
1902 border-bottom: 1px solid var(--border);
1903}
1904
1905.dash-title {
1223 font-size: var(--text-lg);
1224 margin: 0 0 4px;
1906+ margin: 0;
1907+ font-size: var(--text-xl);
1908+ line-height: 32px;
1909+ font-weight: 600;
1910}
1911
1912.dash-name {
1228 color: var(--brand);
1913+ color: var(--identity);
1914+}
1915+
1916+.dash-section {
1917+ display: flex;
1918+ flex-direction: column;
1919+ gap: 8px;
1920+}
1921+
1922+.dash-section + .dash-section {
1923+ margin-top: 20px;
1924+}
1925+
1926+.section-head {
1927+ display: flex;
1928+ align-items: center;
1929+ gap: 10px;
1930+ flex-wrap: wrap;
1931+}
1932+
1933+.section-note {
1934+ margin: 0;
1935+ font-size: var(--text-sm);
1936+}
1937+
1938+/* ─── watched activity ─────────────────────────────────────────────────────── */
1939+
1940+.activity {
1941+ margin: 0;
1942+ padding: 0;
1943+ list-style: none;
1944+ display: flex;
1945+ flex-direction: column;
1946}
1947
1231/* The dashboard's own column block already sits under a header rule. */
1232.dash-head + .landing-columns {
1233 margin-top: 24px;
1948+/* A left rule instead of a box: these are one-line statements, and forty of
1949+ them in forty boxes is unreadable. */
1950+.activity-row {
1951+ display: flex;
1952+ align-items: baseline;
1953+ gap: 6px;
1954+ padding: 3px 8px;
1955+ border-left: 2px solid var(--border);
1956+ font-size: var(--text-sm);
1957+ color: var(--text-dim);
1958+ flex-wrap: wrap;
1959+}
1960+
1961+.activity-actor {
1962+ color: var(--text);
1963+ font-weight: 500;
1964+}
1965+
1966+.activity-when {
1967+ margin-left: auto;
1968+ font-family: var(--font-mono);
1969+ font-size: var(--text-xs);
1970+ white-space: nowrap;
1971}
1972
1973+/* ─── responsive ───────────────────────────────────────────────────────────── */
1974+
1975+@media (max-width: 1120px) {
1976+ .columns {
1977+ grid-template-columns: minmax(0, 1fr);
1978+ }
1979+
1980+ .compare {
1981+ grid-template-columns: repeat(2, minmax(0, 1fr));
1982+ }
1983+
1984+ .repo-cards {
1985+ grid-template-columns: repeat(2, minmax(0, 1fr));
1986+ }
1987+}
1988+
1989+@media (max-width: 800px) {
1990+ .hero > .wrap {
1991+ grid-template-columns: minmax(0, 1fr);
1992+ gap: 28px;
1993+ }
1994+
1995+ .compare,
1996+ .repo-cards {
1997+ grid-template-columns: minmax(0, 1fr);
1998+ }
1999+
2000+ /* The feed loses its verb and repo columns before it loses the title. */
2001+ .feed-verb,
2002+ .feed-repo {
2003+ display: none;
2004+ }
2005+}
2006/* ─── design system pages ──────────────────────────────────────────────────── */
2007
2008.design-head,
@@ -1245,8 +2015,51 @@
2015.design-title {
2016 margin: 0 0 6px;
2017 font-size: var(--text-xl);
2018+ line-height: 32px;
2019+ font-weight: 600;
2020+ letter-spacing: -0.01em;
2021+}
2022+
2023+/* The type specimen table: a fixed spec column and the sample beside it, so
2024+ the sizes line up down the page and can be compared. */
2025+.type-specimens {
2026+ display: flex;
2027+ flex-direction: column;
2028}
2029
2030+.type-row {
2031+ display: flex;
2032+ align-items: baseline;
2033+ gap: 12px;
2034+ padding-bottom: 6px;
2035+ margin-bottom: 6px;
2036+ border-bottom: 1px solid var(--border);
2037+}
2038+
2039+.type-spec {
2040+ width: 96px;
2041+ flex: none;
2042+ font-family: var(--font-mono);
2043+ font-size: var(--text-xs);
2044+ color: var(--text-faint);
2045+}
2046+
2047+.state-list {
2048+ display: flex;
2049+ flex-direction: column;
2050+ max-width: 560px;
2051+}
2052+
2053+.state-row {
2054+ display: flex;
2055+ align-items: center;
2056+ gap: 10px;
2057+ padding-bottom: 6px;
2058+ margin-bottom: 6px;
2059+ border-bottom: 1px solid var(--border);
2060+ font-size: var(--text-sm);
2061+}
2062+
2063.design-section {
2064 padding: 28px 0;
2065 border-bottom: 1px solid var(--border);
@@ -1330,63 +2143,14 @@
2143.rationale p {
2144 line-height: 1.6;
2145 margin: 0 0 12px;
1333}
1334
1335/* ─── repository header ────────────────────────────────────────────────────── */
1336
1337.repo-head {
1338 margin-bottom: 20px;
2146}
2147
1341.repo-title {
1342 display: flex;
1343 align-items: baseline;
1344 gap: 6px;
1345 margin: 0;
1346 font-family: var(--font-condensed);
1347 font-size: var(--text-lg);
1348}
2148+/* ─── page headings ────────────────────────────────────────────────────────── */
2149
1350.repo-owner {
1351 font-weight: 600;
1352 color: var(--text-dim);
1353}
1354
1355.repo-owner:hover {
1356 color: var(--text);
1357 text-decoration: none;
1358}
1359
1360.repo-slash {
1361 color: var(--brand);
1362 user-select: none;
1363}
1364
1365.repo-name {
1366 font-weight: 700;
1367 color: var(--text);
1368}
2150+/* Everything that used to live in a boxed repository header now lives in the
2151+ sub-bar (see `.subnav`), so all that is left here is the heading block a
2152+ full-width page opens with. */
2153
1370.repo-name:hover {
1371 color: var(--brand);
1372 text-decoration: none;
1373}
1374
1375.repo-desc {
1376 margin: 8px 0 0;
1377}
1378
1379.repo-tabs {
1380 margin-top: 14px;
1381 margin-bottom: 0;
1382}
1383
1384/* Pushed to the end of the tab strip rather than floated, so it keeps its
1385 place in the tab order. */
1386.subtabs-end {
1387 margin-left: auto;
1388}
1389
2154/* ─── sign in ──────────────────────────────────────────────────────────────── */
2155
2156.signin {
@@ -1394,30 +2158,40 @@
2158 margin: 48px auto;
2159 display: flex;
2160 flex-direction: column;
1397 gap: 24px;
2161+ gap: 14px;
2162}
2163
2164.signin-intro {
2165 display: flex;
2166 flex-direction: column;
1403 gap: 8px;
1404}
1405
1406.signin-eyebrow {
1407 color: var(--brand);
2167+ gap: 6px;
2168}
2169
2170.signin-title {
2171 margin: 0;
2172 font-size: var(--text-xl);
2173+ line-height: 32px;
2174 text-wrap: balance;
2175}
2176
2177+/* The live methods are boxed together; anything outside the box is context,
2178+ not a way in. */
2179+.signin-card {
2180+ display: flex;
2181+ flex-direction: column;
2182+ gap: 10px;
2183+ padding: 14px;
2184+ border: 1px solid var(--border);
2185+ border-radius: var(--radius);
2186+ background: var(--surface);
2187+}
2188+
2189.btn-block {
2190 display: flex;
2191 justify-content: center;
2192 width: 100%;
1420 height: 38px;
2193+ height: 34px;
2194+ font-size: var(--text-base);
2195}
2196
2197/* A control that is present but not offered. Not a `<button disabled>` because
@@ -1429,12 +2203,13 @@
2203
2204.is-disabled:hover {
2205 border-color: var(--border-strong);
2206+ color: var(--text);
2207}
2208
2209.signin-or {
2210 display: flex;
2211 align-items: center;
1437 gap: 12px;
2212+ gap: 8px;
2213}
2214
2215.signin-rule {
@@ -1443,9 +2218,24 @@
2218 background: var(--border);
2219}
2220
1446.signin-foot {
1447 margin: 0;
1448 text-align: center;
2221+.signin-note {
2222+ display: flex;
2223+ flex-direction: column;
2224+ gap: 4px;
2225+ padding: 10px;
2226+ border: 1px solid var(--border);
2227+ border-radius: var(--radius);
2228+ background: var(--bg);
2229+ font-size: var(--text-sm);
2230+}
2231+
2232+.signin-note code {
2233+ font-size: 12px;
2234+ color: var(--text);
2235+ background: none;
2236+ border: 0;
2237+ padding: 0;
2238+ overflow-x: auto;
2239}
2240
2241/* ─── segmented control ────────────────────────────────────────────────────── */
@@ -1460,9 +2250,12 @@
2250}
2251
2252.segmented-item {
1463 padding: 3px 10px;
2253+ display: inline-flex;
2254+ align-items: center;
2255+ height: 22px;
2256+ padding: 0 10px;
2257+ font-family: var(--font-mono);
2258 font-size: var(--text-xs);
1465 font-weight: 500;
2259 color: var(--text-dim);
2260}
2261
@@ -1477,13 +2270,13 @@
2270}
2271
2272.segmented-item.is-on {
1480 background: var(--brand);
1481 color: var(--brand-ink);
2273+ background: var(--action);
2274+ color: var(--on-action);
2275}
2276
2277.segmented-item.is-on:hover {
1485 background: var(--brand);
1486 color: var(--brand-ink);
2278+ background: var(--action);
2279+ color: var(--on-action);
2280}
2281
2282/* ─── file listing ─────────────────────────────────────────────────────────── */
@@ -1507,23 +2300,107 @@
2300}
2301
2302.tree-crumbs {
1510 margin-bottom: 14px;
2303+ display: flex;
2304+ align-items: center;
2305+ gap: 8px;
2306+ margin-bottom: 12px;
2307 flex-wrap: wrap;
2308}
2309
1514/* The tip-of-branch bar. Sits directly on top of the listing and shares its
1515 border, so the two read as one object. */
2310+/* ─── bookmark switcher ────────────────────────────────────────────────────── */
2311+
2312+/* A `<details>`, so it opens and closes with no JavaScript at all. */
2313+.switcher {
2314+ position: relative;
2315+ flex: none;
2316+}
2317+
2318+.switcher > summary {
2319+ list-style: none;
2320+ cursor: pointer;
2321+}
2322+
2323+.switcher > summary::-webkit-details-marker {
2324+ display: none;
2325+}
2326+
2327+.switcher-menu {
2328+ position: absolute;
2329+ z-index: 20;
2330+ top: calc(100% + 4px);
2331+ left: 0;
2332+ min-width: 220px;
2333+ max-height: 320px;
2334+ overflow-y: auto;
2335+ padding-bottom: 4px;
2336+ border: 1px solid var(--border-strong);
2337+ border-radius: var(--radius);
2338+ background: var(--surface-raised);
2339+ box-shadow: 0 8px 28px rgb(0 0 0 / 0.4);
2340+}
2341+
2342+.switcher-label {
2343+ padding: 6px 10px 2px;
2344+}
2345+
2346+.switcher-item {
2347+ display: flex;
2348+ align-items: center;
2349+ gap: 8px;
2350+ height: 26px;
2351+ padding: 0 10px;
2352+ color: var(--text);
2353+ font-size: var(--text-sm);
2354+}
2355+
2356+.switcher-item:hover {
2357+ background: var(--surface);
2358+ text-decoration: none;
2359+}
2360+
2361+.switcher-item.is-current {
2362+ background: var(--identity-wash);
2363+}
2364+
2365+.switcher-item.is-current .mono {
2366+ color: var(--identity);
2367+}
2368+
2369+/* A single-bookmark repository has nothing to switch to, so the control
2370+ becomes a readout. */
2371+.btn-mono.is-static {
2372+ cursor: default;
2373+ color: var(--text-dim);
2374+}
2375+
2376+.btn-mono.is-static:hover {
2377+ border-color: var(--border-strong);
2378+ color: var(--text-dim);
2379+}
2380+
2381+/* ─── the listing ──────────────────────────────────────────────────────────── */
2382+
2383+/* The tip-of-branch bar. Inside the listing's border rather than stacked on
2384+ top of it, so the two are literally one object. */
2385.commit-bar {
2386 display: flex;
2387 align-items: center;
1519 gap: 10px;
1520 padding: 8px 12px;
1521 border: 1px solid var(--border);
1522 border-radius: var(--radius) var(--radius) 0 0;
1523 background: var(--surface);
2388+ gap: 8px;
2389+ padding: 7px 8px;
2390+ border-bottom: 1px solid var(--border);
2391+ background: var(--surface-raised);
2392 font-size: var(--text-sm);
2393}
2394
2395+/* The identity rail. Two pixels of gold saying "this row is about a change",
2396+ the same mark the stack rails and revision timeline use. */
2397+.commit-bar-rail {
2398+ width: 2px;
2399+ height: 14px;
2400+ flex: none;
2401+ background: var(--identity);
2402+}
2403+
2404.commit-bar-author {
2405 font-weight: 500;
2406 flex-shrink: 0;
@@ -1531,69 +2408,91 @@
2408
2409.commit-bar-msg {
2410 min-width: 0;
1534 flex: 1;
1535 color: var(--text-dim);
2411+ color: var(--text);
2412 overflow: hidden;
2413 text-overflow: ellipsis;
2414 white-space: nowrap;
2415}
2416
1541.commit-bar-chip,
2417.commit-bar-when {
2418 flex-shrink: 0;
2419+ font-family: var(--font-mono);
2420+ font-size: var(--text-xs);
2421+ color: var(--text-faint);
2422}
2423
2424@media (max-width: 620px) {
1547 .commit-bar-chip {
2425+ .commit-bar .cid {
2426 display: none;
2427 }
2428}
2429
2430+/* The bordered container shared by the file listing, the bookmarks table and
2431+ the README panel. */
2432.filelist {
2433 border: 1px solid var(--border);
1554 border-top: none;
1555 border-radius: 0 0 var(--radius) var(--radius);
2434+ border-radius: var(--radius);
2435+ background: var(--surface);
2436 overflow: hidden;
2437}
2438
1559/* When there is no commit bar above it, the listing is the whole object and
1560 needs its own top edge back. */
1561.filelist-standalone {
1562 border-top: 1px solid var(--border);
1563 border-radius: var(--radius);
2439+.filelist + .filelist {
2440+ margin-top: 12px;
2441}
2442
2443+/* Fixed layout is what makes the `max-width: 0` ellipsis trick on
2444+ `.filelist-name` / `.bookmark-title` actually hold: under the default auto
2445+ layout the browser sizes columns from content instead, so the name column
2446+ never truncates and the icon column drifts away from it as the viewport
2447+ narrows. */
2448.filelist table {
2449 width: 100%;
2450+ table-layout: fixed;
2451 border-collapse: collapse;
2452}
2453
1571.filelist tr {
2454+.filelist thead th {
2455+ padding: 6px 8px;
2456+ text-align: left;
2457+ border-bottom: 1px solid var(--border);
2458+ font-family: var(--font-condensed);
2459+ font-size: var(--text-xs);
2460+ font-weight: 500;
2461+ letter-spacing: 0.06em;
2462+ text-transform: uppercase;
2463+ color: var(--text-dim);
2464+}
2465+
2466+.filelist tbody tr {
2467 border-bottom: 1px solid var(--border);
2468}
2469
1575.filelist tr:last-child {
2470+.filelist tbody tr:last-child {
2471 border-bottom: none;
2472}
2473
1579.filelist tr:hover {
1580 background: var(--surface);
2474+.filelist tbody tr:hover {
2475+ background: var(--surface-raised);
2476}
2477
2478.filelist td {
1584 padding: 6px 12px 6px 0;
2479+ padding: 0 8px;
2480+ height: 28px;
2481 vertical-align: middle;
2482}
2483
2484+/* A literal pixel width, not the old auto-layout `1px` shrink-to-content
2485+ hack: `table-layout: fixed` takes column widths at face value, so a hint
2486+ width no longer holds and has to be the icon's real footprint (8px
2487+ padding-left + 16px icon). */
2488.filelist-icon {
1589 width: 1px;
1590 padding-left: 12px !important;
1591 padding-right: 8px !important;
2489+ width: 28px;
2490+ padding-right: 0 !important;
2491 line-height: 0;
2492}
2493
2494.icon-dir {
1596 color: var(--brand);
2495+ color: var(--action);
2496}
2497
2498.icon-file {
@@ -1602,6 +2501,7 @@
2501
2502.filelist-name {
2503 max-width: 0;
2504+ width: 32%;
2505}
2506
2507.filelist-name a {
@@ -1619,7 +2519,7 @@
2519}
2520
2521.filelist tr:hover .filelist-name a {
1622 color: var(--brand);
2522+ color: var(--action);
2523 text-decoration: underline;
2524}
2525
@@ -1628,33 +2528,166 @@
2528 font-size: var(--text-xs);
2529}
2530
1631.filelist-size {
1632 width: 100px;
2531+/* The commit message column. `max-width: 0` is the same "shrink to let the
2532+ sibling ellipsis rule take over" trick `.filelist-name` uses — without it
2533+ an auto-layout table lets a long message push the row wider instead of
2534+ truncating. */
2535+.filelist-message {
2536+ max-width: 0;
2537+ font-size: var(--text-xs);
2538+}
2539+
2540+.filelist-message-link,
2541+.filelist-message-text {
2542+ overflow: hidden;
2543+ text-overflow: ellipsis;
2544+ white-space: nowrap;
2545+ display: inline-block;
2546+ max-width: 100%;
2547+ vertical-align: bottom;
2548+ color: var(--text-dim);
2549+}
2550+
2551+.filelist-message-link:hover {
2552+ color: var(--action);
2553+ text-decoration: underline;
2554+}
2555+
2556+.filelist-change {
2557+ width: 132px;
2558+ white-space: nowrap;
2559+ font-size: var(--text-xs);
2560+}
2561+
2562+.filelist-when {
2563+ width: 72px;
2564 text-align: right;
2565 white-space: nowrap;
2566+ font-family: var(--font-mono);
2567 font-size: var(--text-xs);
2568+ color: var(--text-faint);
2569+}
2570+
2571+/* Below the wide breakpoint the listing keeps only what a filename needs. */
2572+@media (max-width: 1120px) {
2573+ .filelist-change {
2574+ display: none;
2575+ }
2576+}
2577+
2578+@media (max-width: 800px) {
2579+ .filelist-message {
2580+ display: none;
2581+ }
2582+}
2583+
2584+/* ─── the repository sidebar ───────────────────────────────────────────────── */
2585+
2586+/* Narrower than the landing page's aside: it holds stat lines and clone
2587+ commands, not feed rows. */
2588+.columns-repo {
2589+ grid-template-columns: minmax(0, 1fr) 240px;
2590+ gap: 24px;
2591+}
2592+
2593+.aside-about {
2594+ font-size: var(--text-sm);
2595+ line-height: 18px;
2596+ color: var(--text-dim);
2597+ text-wrap: pretty;
2598}
2599
1638/* ─── readme ───────────────────────────────────────────────────────────────── */
2600+.aside-clone {
2601+ display: flex;
2602+ flex-direction: column;
2603+ gap: 2px;
2604+}
2605
1640.readme {
1641 margin-top: 24px;
1642 border: 1px solid var(--border);
2606+/* The command scrolls inside its own box rather than widening the column —
2607+ an SSH URL is longer than 240px and always will be. */
2608+.aside-clone code {
2609+ padding: 4px 6px;
2610+ border: 1px solid var(--border-strong);
2611 border-radius: var(--radius);
2612+ background: var(--bg);
2613+ font-family: var(--font-mono);
2614+ font-size: var(--text-xs);
2615+ color: var(--text);
2616+ white-space: nowrap;
2617+ overflow-x: auto;
2618+}
2619+
2620+/* ─── bookmarks page ───────────────────────────────────────────────────────── */
2621+
2622+.bookmark-table td {
2623+ height: 30px;
2624+ font-size: var(--text-sm);
2625+}
2626+
2627+/* Explicit width so fixed table layout does not split the remaining space
2628+ between this column and `.bookmark-title` — the title is the one column
2629+ meant to flex and truncate. */
2630+.bookmark-name {
2631+ width: 220px;
2632+ overflow: hidden;
2633+ text-overflow: ellipsis;
2634+ white-space: nowrap;
2635+}
2636+
2637+.bookmark-name .mono {
2638+ color: var(--text);
2639+}
2640+
2641+.bookmark-points {
2642+ width: 132px;
2643+ white-space: nowrap;
2644+}
2645+
2646+.bookmark-title {
2647+ max-width: 0;
2648+ color: var(--text-dim);
2649+ overflow: hidden;
2650+ text-overflow: ellipsis;
2651+ white-space: nowrap;
2652+}
2653+
2654+.bookmark-when {
2655+ width: 72px;
2656+ text-align: right;
2657+ font-family: var(--font-mono);
2658+ font-size: var(--text-xs);
2659+ color: var(--text-faint);
2660+ white-space: nowrap;
2661}
2662
2663+/* The heading block above a full-width page table. */
2664+.page-head {
2665+ display: flex;
2666+ align-items: flex-start;
2667+ gap: 12px;
2668+ flex-wrap: wrap;
2669+ margin-bottom: 10px;
2670+}
2671+
2672+/* ─── readme ───────────────────────────────────────────────────────────────── */
2673+
2674.readme-head {
2675 display: flex;
2676 align-items: center;
2677 gap: 8px;
1650 padding: 8px 16px;
2678+ padding: 6px 8px;
2679 border-bottom: 1px solid var(--border);
1652 background: var(--surface);
2680+ background: var(--surface-raised);
2681+ font-family: var(--font-mono);
2682+ font-size: var(--text-sm);
2683 line-height: 1;
2684}
2685
2686+/* A README is prose. The listing above it can use the full 1440; this cannot,
2687+ so it caps its own measure regardless of how wide the column is. */
2688.readme-body {
1657 padding: 16px 20px;
2689+ padding: 16px;
2690+ max-width: 76ch;
2691}
2692
2693.readme-body > :first-child {
@@ -1720,8 +2753,8 @@
2753}
2754
2755.markdown-body a:hover {
1723 text-decoration-color: var(--brand);
1724 color: var(--brand);
2756+ text-decoration-color: var(--action);
2757+ color: var(--action);
2758}
2759
2760.markdown-body strong {
@@ -1734,7 +2767,7 @@
2767 border: 1px solid var(--border);
2768 border-radius: var(--radius-sm);
2769 background: var(--surface-raised);
1737 color: var(--brand);
2770+ color: var(--action);
2771 font-family: var(--font-mono);
2772 font-size: 0.85em;
2773}
@@ -1760,7 +2793,7 @@
2793.markdown-body blockquote {
2794 margin: 16px 0;
2795 padding-left: 16px;
1763 border-left: 2px solid var(--brand);
2796+ border-left: 2px solid var(--action);
2797 color: var(--text-dim);
2798 font-style: italic;
2799}
@@ -1917,30 +2950,57 @@
2950
2951/* ─── settings ─────────────────────────────────────────────────────────────── */
2952
2953+/* The tab strip, shared by repository sections, change-detail sections and
2954+ settings. An inset box-shadow rather than a border-bottom, so activating a
2955+ tab does not displace its label by two pixels. */
2956.subtabs {
2957 display: flex;
1922 gap: 4px;
1923 border-bottom: 1px solid var(--border);
1924 margin-bottom: 16px;
2958+ gap: 0;
2959 overflow-x: auto;
2960+ scrollbar-width: none;
2961}
2962
2963+.subtabs::-webkit-scrollbar {
2964+ display: none;
2965+}
2966+
2967.subtabs a {
1929 padding: 8px 12px;
2968+ display: inline-flex;
2969+ align-items: center;
2970+ gap: 6px;
2971+ height: 30px;
2972+ padding: 0 10px;
2973 color: var(--text-dim);
1931 border-bottom: 2px solid transparent;
2974+ font-size: var(--text-sm);
2975 white-space: nowrap;
2976}
2977
2978.subtabs a:hover {
2979 color: var(--text);
2980+ text-decoration: none;
2981}
2982
2983.subtabs a.active {
2984 color: var(--text);
1941 border-bottom-color: var(--brand);
2985+ font-weight: 500;
2986+ box-shadow: inset 0 -2px 0 var(--action);
2987}
2988
2989+/* The count beside a tab label. Always monospace and always faint: it is a
2990+ quantity you glance at, never the thing you are clicking. */
2991+.subtabs .tab-count {
2992+ font-family: var(--font-mono);
2993+ font-size: var(--text-xs);
2994+ color: var(--text-faint);
2995+ font-weight: 400;
2996+}
2997+
2998+/* Outside the repository sub-bar the strip needs its own rule and some air. */
2999+.subtabs.ruled {
3000+ border-bottom: 1px solid var(--border);
3001+ margin-bottom: 16px;
3002+}
3003+
3004.listing {
3005 width: 100%;
3006 border-collapse: collapse;
@@ -1949,19 +3009,21 @@
3009
3010.listing th {
3011 text-align: left;
3012+ font-family: var(--font-condensed);
3013 font-weight: 500;
1953 font-size: 12px;
3014+ font-size: var(--text-xs);
3015+ letter-spacing: 0.06em;
3016 text-transform: uppercase;
1955 letter-spacing: 0.04em;
1956 color: var(--text-faint);
3017+ color: var(--text-dim);
3018 padding: 6px 10px 6px 0;
3019 border-bottom: 1px solid var(--border);
3020}
3021
3022.listing td {
1962 padding: 8px 10px 8px 0;
3023+ padding: 6px 10px 6px 0;
3024 border-bottom: 1px solid var(--border);
3025 vertical-align: middle;
3026+ font-size: var(--text-sm);
3027}
3028
3029.listing tr:last-child td {
@@ -1976,10 +3038,11 @@
3038}
3039
3040.kv dt {
1979 color: var(--text-faint);
1980 font-size: 12px;
3041+ color: var(--text-dim);
3042+ font-family: var(--font-condensed);
3043+ font-size: var(--text-xs);
3044 text-transform: uppercase;
1982 letter-spacing: 0.04em;
3045+ letter-spacing: 0.06em;
3046 align-self: center;
3047}
3048
@@ -2023,7 +3086,7 @@
3086
3087textarea:focus,
3088select:focus {
2026 outline: 2px solid var(--brand);
3089+ outline: 2px solid var(--action);
3090 outline-offset: -1px;
3091}
3092
@@ -2039,6 +3102,379 @@
3102}
3103
3104
3105+
3106+/* ─── the revset bar ───────────────────────────────────────────────────────── */
3107+
3108+/* One control, not three: the label, the field, the verdict and the submit
3109+ button share a single border so the whole thing reads as the query box it
3110+ is. */
3111+.revset-bar {
3112+ display: flex;
3113+ align-items: stretch;
3114+ margin-bottom: 8px;
3115+ border: 1px solid var(--border-strong);
3116+ border-radius: var(--radius);
3117+ background: var(--surface);
3118+ overflow: hidden;
3119+}
3120+
3121+.revset-tag {
3122+ display: flex;
3123+ align-items: center;
3124+ padding: 0 8px;
3125+ border-right: 1px solid var(--border);
3126+ background: var(--surface-raised);
3127+ font-family: var(--font-condensed);
3128+ font-size: var(--text-xs);
3129+ font-weight: 500;
3130+ letter-spacing: 0.06em;
3131+ text-transform: uppercase;
3132+ color: var(--identity);
3133+ flex: none;
3134+}
3135+
3136+/* The field is monospace because a revset is code. The design paints a
3137+ syntax-highlighted layer over a transparent input, which needs a script to
3138+ stay in sync with what is being typed; a stale highlight over live text is
3139+ worse than none, so the field is plainly monospace instead and the colour
3140+ goes on the saved expressions in the aside, where it is always correct. */
3141+.revset-bar input[type="text"] {
3142+ flex: 1;
3143+ min-width: 0;
3144+ height: 30px;
3145+ border: none;
3146+ border-radius: 0;
3147+ background: transparent;
3148+ font-family: var(--font-mono);
3149+}
3150+
3151+.revset-bar input[type="text"]:focus {
3152+ outline: none;
3153+ border: none;
3154+}
3155+
3156+.revset-status {
3157+ display: flex;
3158+ align-items: center;
3159+ gap: 4px;
3160+ padding: 0 8px;
3161+ font-family: var(--font-mono);
3162+ font-size: var(--text-xs);
3163+ color: var(--text-faint);
3164+ white-space: nowrap;
3165+ flex: none;
3166+}
3167+
3168+.revset-status.is-bad {
3169+ color: var(--danger);
3170+ white-space: normal;
3171+ max-width: 40ch;
3172+}
3173+
3174+.revset-bar .btn {
3175+ height: auto;
3176+ border: none;
3177+ border-left: 1px solid var(--border);
3178+ border-radius: 0;
3179+ flex: none;
3180+}
3181+
3182+/* ─── filter tabs ──────────────────────────────────────────────────────────── */
3183+
3184+.filter-tabs {
3185+ margin-bottom: 0;
3186+}
3187+
3188+.filter-glyph {
3189+ font-family: var(--font-mono);
3190+ font-size: var(--text-xs);
3191+ color: var(--text-faint);
3192+}
3193+
3194+.list-summary {
3195+ margin: 0;
3196+ height: 22px;
3197+ display: flex;
3198+ align-items: center;
3199+ font-family: var(--font-mono);
3200+ font-size: var(--text-xs);
3201+ color: var(--text-faint);
3202+}
3203+
3204+/* ─── the change list ──────────────────────────────────────────────────────── */
3205+
3206+/* Five columns. Four are fixed-width facts and one — the title — takes what is
3207+ left, so a long title truncates instead of pushing the diffstat off the
3208+ right edge. */
3209+.changelist {
3210+ --cl-cols: 148px minmax(240px, 1fr) 96px 120px 72px;
3211+}
3212+
3213+.changelist-head,
3214+.changelist-row {
3215+ display: grid;
3216+ grid-template-columns: var(--cl-cols);
3217+ align-items: center;
3218+ border-bottom: 1px solid var(--border);
3219+}
3220+
3221+.changelist-head {
3222+ font-family: var(--font-condensed);
3223+ font-size: var(--text-xs);
3224+ font-weight: 500;
3225+ letter-spacing: 0.06em;
3226+ text-transform: uppercase;
3227+ color: var(--text-dim);
3228+}
3229+
3230+.changelist-head > div {
3231+ padding: 6px 8px;
3232+}
3233+
3234+.changelist-head > .at-end {
3235+ text-align: right;
3236+}
3237+
3238+.changelist-row {
3239+ height: 32px;
3240+ font-size: var(--text-sm);
3241+}
3242+
3243+.changelist-row:hover {
3244+ background: var(--surface);
3245+}
3246+
3247+/* A stacked row carries the identity tint even at rest, so the group is
3248+ visible without reading the rail. */
3249+.changelist-row.in-stack {
3250+ background: var(--identity-wash);
3251+}
3252+
3253+.changelist-row.in-stack:hover {
3254+ background: var(--surface);
3255+}
3256+
3257+.cl-change {
3258+ display: flex;
3259+ align-self: stretch;
3260+ align-items: center;
3261+ min-width: 0;
3262+}
3263+
3264+.cl-indent {
3265+ flex: none;
3266+ align-self: stretch;
3267+}
3268+
3269+/* The rail runs the full height of the row so consecutive stacked rows join
3270+ into one continuous line. */
3271+.cl-rail {
3272+ width: 2px;
3273+ flex: none;
3274+ align-self: stretch;
3275+ background: var(--identity);
3276+}
3277+
3278+.cl-glyph {
3279+ width: 20px;
3280+ flex: none;
3281+ display: flex;
3282+ align-items: center;
3283+ justify-content: center;
3284+ font-family: var(--font-mono);
3285+}
3286+
3287+.cl-title {
3288+ display: flex;
3289+ align-items: center;
3290+ gap: 8px;
3291+ padding: 0 8px;
3292+ min-width: 0;
3293+}
3294+
3295+.cl-title > a {
3296+ font-size: var(--text-base);
3297+ color: var(--text);
3298+ overflow: hidden;
3299+ text-overflow: ellipsis;
3300+ white-space: nowrap;
3301+}
3302+
3303+.cl-title > a:hover {
3304+ color: var(--action);
3305+}
3306+
3307+.cl-byline,
3308+.cl-revs {
3309+ font-family: var(--font-mono);
3310+ font-size: var(--text-xs);
3311+ color: var(--text-faint);
3312+ white-space: nowrap;
3313+ flex: none;
3314+}
3315+
3316+.cl-byline a {
3317+ color: var(--text-faint);
3318+}
3319+
3320+.cl-review {
3321+ display: flex;
3322+ align-items: center;
3323+ gap: 4px;
3324+ padding: 0 8px;
3325+}
3326+
3327+/* A reviewer's mark. The ring says what their verdict is and the fill says
3328+ whether it still applies to the current revision — a stale approval gets a
3329+ conflict-coloured ring, because on a change that has been rewritten it is
3330+ no longer an approval of anything you can see. */
3331+.cl-avatar {
3332+ width: 18px;
3333+ height: 18px;
3334+ flex: none;
3335+ display: inline-flex;
3336+ align-items: center;
3337+ justify-content: center;
3338+ border: 1px solid var(--border-strong);
3339+ border-radius: 50%;
3340+ background: var(--surface-raised);
3341+ font-family: var(--font-mono);
3342+ font-size: var(--text-xs);
3343+ text-transform: uppercase;
3344+ user-select: none;
3345+}
3346+
3347+.cl-comments {
3348+ font-family: var(--font-mono);
3349+ font-size: var(--text-xs);
3350+ color: var(--text-faint);
3351+ white-space: nowrap;
3352+}
3353+
3354+.cl-diff {
3355+ display: flex;
3356+ align-items: center;
3357+ justify-content: flex-end;
3358+ gap: 6px;
3359+ padding: 0 8px;
3360+ font-family: var(--font-mono);
3361+ font-size: var(--text-xs);
3362+ white-space: nowrap;
3363+}
3364+
3365+.cl-bars {
3366+ display: inline-flex;
3367+ gap: 1px;
3368+ flex: none;
3369+}
3370+
3371+.cl-bars > span {
3372+ width: 3px;
3373+ height: 10px;
3374+}
3375+
3376+.cl-add { color: var(--diff-add-text); }
3377+.cl-del { color: var(--diff-del-text); }
3378+
3379+.cl-when {
3380+ padding: 0 8px;
3381+ text-align: right;
3382+ font-family: var(--font-mono);
3383+ font-size: var(--text-xs);
3384+ color: var(--text-faint);
3385+ white-space: nowrap;
3386+}
3387+
3388+/* ─── stack banner ─────────────────────────────────────────────────────────── */
3389+
3390+.stack-banner {
3391+ display: flex;
3392+ align-items: center;
3393+ gap: 8px;
3394+ height: 26px;
3395+ padding: 0 8px;
3396+ background: var(--identity-wash);
3397+ border-bottom: 1px solid var(--border);
3398+}
3399+
3400+.stack-banner-rail {
3401+ font-family: var(--font-mono);
3402+ font-size: var(--text-xs);
3403+ color: var(--identity);
3404+}
3405+
3406+.stack-banner-label {
3407+ font-family: var(--font-condensed);
3408+ font-size: var(--text-xs);
3409+ font-weight: 600;
3410+ letter-spacing: 0.06em;
3411+ text-transform: uppercase;
3412+ color: var(--identity);
3413+}
3414+
3415+.stack-banner-note {
3416+ font-size: var(--text-sm);
3417+ color: var(--text-dim);
3418+ overflow: hidden;
3419+ text-overflow: ellipsis;
3420+ white-space: nowrap;
3421+}
3422+
3423+/* ─── saved revsets ────────────────────────────────────────────────────────── */
3424+
3425+.saved-revset {
3426+ display: block;
3427+ height: 22px;
3428+ padding: 0 6px;
3429+ border: 1px solid var(--border);
3430+ border-radius: var(--radius-sm);
3431+ font-family: var(--font-mono);
3432+ font-size: var(--text-xs);
3433+ line-height: 20px;
3434+ color: var(--text-dim);
3435+ overflow: hidden;
3436+ text-overflow: ellipsis;
3437+ white-space: nowrap;
3438+}
3439+
3440+.saved-revset:hover {
3441+ border-color: var(--action);
3442+ color: var(--action);
3443+ text-decoration: none;
3444+}
3445+
3446+.saved-revset.is-current {
3447+ border-color: var(--action);
3448+ color: var(--action);
3449+}
3450+
3451+/* ─── responsive ───────────────────────────────────────────────────────────── */
3452+
3453+@media (max-width: 1120px) {
3454+ .columns-repo {
3455+ grid-template-columns: minmax(0, 1fr);
3456+ }
3457+
3458+ /* Review and diff go before the title does. */
3459+ .changelist {
3460+ --cl-cols: 148px minmax(0, 1fr) 72px;
3461+ }
3462+
3463+ .cl-review,
3464+ .cl-diff,
3465+ .changelist-head > div:nth-child(3),
3466+ .changelist-head > div:nth-child(4) {
3467+ display: none;
3468+ }
3469+}
3470+
3471+@media (max-width: 800px) {
3472+ .cl-byline,
3473+ .cl-revs {
3474+ display: none;
3475+ }
3476+}
3477+
3478/* ─── diffs and review ─────────────────────────────────────────────────────── */
3479
3480.filediff {
@@ -2082,6 +3518,47 @@
3518.line-add { background: var(--diff-add-bg); color: var(--diff-add-text); }
3519.line-del { background: var(--diff-del-bg); color: var(--diff-del-text); }
3520
3521+/* A diff line outside the commentable table: the interdiff and the design
3522+ sheet's specimen rows. A grid rather than a table, because there is no
3523+ comment column to align against — just a gutter and the code.
3524+
3525+ The 2px left rule is what carries the add/delete signal in a monochrome or
3526+ high-contrast rendering, where the background washes disappear. */
3527+.diffline {
3528+ display: grid;
3529+ grid-template-columns: 44px auto;
3530+ min-width: max-content;
3531+ font-family: var(--font-mono);
3532+ font-size: 13px;
3533+ line-height: 21px;
3534+ border-left: 2px solid transparent;
3535+}
3536+
3537+.diffline.line-add { border-left-color: var(--diff-add-text); }
3538+.diffline.line-del { border-left-color: var(--diff-del-text); }
3539+
3540+.diffline.diff-hunk {
3541+ background: var(--surface-raised);
3542+ color: var(--text-dim);
3543+}
3544+
3545+.diffline.line-ctx {
3546+ color: var(--text);
3547+}
3548+
3549+.diff-ln {
3550+ padding: 0 6px;
3551+ text-align: right;
3552+ font-size: var(--text-xs);
3553+ color: var(--text-faint);
3554+ user-select: none;
3555+}
3556+
3557+.diff-text {
3558+ padding: 0 6px;
3559+ white-space: pre;
3560+}
3561+
3562/* Word-level intra-line changes (spec §8). Rendered as a background wash
3563 rather than a colour change, so the line's add/delete colour still reads and
3564 the emphasis survives a high-contrast or monochrome display. */
@@ -2109,6 +3586,9 @@
3586 font-size: 12px;
3587}
3588
3589+/* A comment is a boxed object; an event is a line. That difference is the
3590+ timeline's whole structure — somebody wrote the boxes, the system recorded
3591+ the lines. */
3592.comment {
3593 border: 1px solid var(--border);
3594 border-radius: var(--radius);
@@ -2134,26 +3614,349 @@
3614 font-size: 12px;
3615}
3616
3617+/* An event: one line, hung off a left rule. No box and no rule between
3618+ events — a run of them reads as a log, which is what it is. */
3619.timeline-event {
2138 padding: 6px 0;
2139 border-bottom: 1px dashed var(--border);
2140 font-size: 13px;
3620+ display: flex;
3621+ align-items: baseline;
3622+ gap: 8px;
3623+ padding: 3px 8px;
3624+ border-left: 2px solid var(--border);
3625+ font-size: var(--text-sm);
3626+}
3627+
3628+.timeline-glyph {
3629+ width: 12px;
3630+ flex: none;
3631+ font-family: var(--font-mono);
3632+ font-size: var(--text-xs);
3633+}
3634+
3635+
3636+/* ─── change detail header ─────────────────────────────────────────────────── */
3637+
3638+.change-head {
3639+ margin-bottom: 4px;
3640+}
3641+
3642+.change-head-top {
3643+ display: flex;
3644+ align-items: stretch;
3645+ gap: 12px;
3646+ min-width: 0;
3647+ margin-bottom: 10px;
3648+}
3649+
3650+/* Three pixels of identity down the left of the whole header. The page is
3651+ about one change; this is the change. */
3652+.change-head-rail {
3653+ width: 3px;
3654+ flex: none;
3655+ background: var(--identity);
3656+}
3657+
3658+.change-head-id {
3659+ display: flex;
3660+ flex-direction: column;
3661+ gap: 6px;
3662+ min-width: 0;
3663+}
3664+
3665+.change-head-line {
3666+ display: flex;
3667+ align-items: center;
3668+ gap: 10px;
3669+ flex-wrap: wrap;
3670+}
3671+
3672+/* The id, larger than the title. Deliberate: the title is prose somebody can
3673+ retype, the id is what every review and permalink is attached to. */
3674+.change-id-display {
3675+ font-family: var(--font-mono);
3676+ font-size: var(--text-2xl);
3677+ line-height: 34px;
3678+ font-variant-numeric: tabular-nums;
3679+}
3680+
3681+.change-id-display > .cid-p {
3682+ font-weight: 600;
3683+}
3684+
3685+.change-id-display.is-synthetic {
3686+ font-size: var(--text-lg);
3687+ color: var(--text-dim);
3688+}
3689+
3690+.change-title {
3691+ margin: 0;
3692+ font-size: var(--text-lg);
3693+ line-height: 28px;
3694+ font-weight: 600;
3695+ text-wrap: pretty;
3696+}
3697+
3698+.change-byline {
3699+ display: flex;
3700+ align-items: center;
3701+ gap: 8px;
3702+ flex-wrap: wrap;
3703+ font-size: var(--text-sm);
3704+ color: var(--text-dim);
3705+}
3706+
3707+.change-byline .sep {
3708+ color: var(--text-faint);
3709+}
3710+
3711+/* The aside follows the reader down a long diff. `top` clears the 46px
3712+ masthead plus a hairline of air. */
3713+.columns-aside.is-sticky {
3714+ position: sticky;
3715+ top: 62px;
3716+}
3717+
3718+.reviewer-line {
3719+ display: flex;
3720+ align-items: center;
3721+ gap: 6px;
3722+ font-size: var(--text-sm);
3723+}
3724+
3725+.reviewer-glyph {
3726+ width: 10px;
3727+ flex: none;
3728+ font-family: var(--font-mono);
3729+ font-size: var(--text-xs);
3730+}
3731+
3732+.reviewer-meta {
3733+ font-family: var(--font-mono);
3734+ font-size: var(--text-xs);
3735+ white-space: nowrap;
3736+}
3737+
3738+.mini-row.is-current {
3739+ background: var(--identity-wash);
3740+}
3741+
3742+/* ─── revisions timeline ───────────────────────────────────────────────────── */
3743+
3744+.revtimeline {
3745+ display: flex;
3746+ flex-direction: column;
3747+ margin: 12px 0;
3748+}
3749+
3750+.revrow {
3751+ display: flex;
3752+ gap: 12px;
3753+ border-bottom: 1px solid var(--border);
3754+}
3755+
3756+.revrow.is-selected {
3757+ background: var(--identity-wash);
3758+}
3759+
3760+/* The rail is two flexing segments with the dot between them, so the line
3761+ joins consecutive revisions and stops cleanly at both ends. */
3762+.revrail {
3763+ width: 24px;
3764+ flex: none;
3765+ display: flex;
3766+ flex-direction: column;
3767+ align-items: center;
3768+ align-self: stretch;
3769+}
3770+
3771+.revrail-seg {
3772+ width: 2px;
3773+ flex: 1;
3774+}
3775+
3776+/* Square, not round: a revision is a discrete recorded state, and the shape
3777+ is doing that work — round would read as a generic bullet. */
3778+.revdot {
3779+ width: 9px;
3780+ height: 9px;
3781+ flex: none;
3782+ border: 1px solid var(--identity);
3783+ border-radius: 1px;
3784+}
3785+
3786+.revbody {
3787+ flex: 1;
3788+ min-width: 0;
3789+ display: flex;
3790+ flex-direction: column;
3791+ gap: 4px;
3792+ padding: 8px 8px 8px 0;
3793+}
3794+
3795+.revline {
3796+ display: flex;
3797+ align-items: center;
3798+ gap: 8px;
3799+ flex-wrap: wrap;
3800+}
3801+
3802+.revlabel {
3803+ font-family: var(--font-condensed);
3804+ font-size: var(--text-xs);
3805+ font-weight: 600;
3806+ letter-spacing: 0.06em;
3807+ text-transform: uppercase;
3808+}
3809+
3810+.revnote {
3811+ font-size: var(--text-base);
3812+ color: var(--text);
3813+ min-width: 0;
3814+ overflow: hidden;
3815+ text-overflow: ellipsis;
3816+ white-space: nowrap;
3817+}
3818+
3819+.revwhen {
3820+ font-family: var(--font-mono);
3821+ font-size: var(--text-xs);
3822+ color: var(--text-faint);
3823+ white-space: nowrap;
3824+}
3825+
3826+.revmeta {
3827+ display: flex;
3828+ align-items: center;
3829+ gap: 12px;
3830+ flex-wrap: wrap;
3831+ font-family: var(--font-mono);
3832+ font-size: var(--text-xs);
3833+ color: var(--text-faint);
3834+}
3835+
3836+.revmeta a {
3837+ color: var(--text-faint);
3838+}
3839+
3840+.revmeta a:hover {
3841+ color: var(--action);
3842+}
3843+
3844+/* The A/B pickers. Links, not radios: each pair is a URL, so a reviewer can
3845+ paste "rev 2 → rev 4" into a comment and it resolves for everyone. */
3846+.abpick {
3847+ width: 16px;
3848+ height: 16px;
3849+ flex: none;
3850+ display: inline-flex;
3851+ align-items: center;
3852+ justify-content: center;
3853+ border: 1px solid var(--border-strong);
3854+ border-radius: var(--radius-sm);
3855+ font-family: var(--font-mono);
3856+ font-size: var(--text-xs);
3857+ line-height: 1;
3858+ color: var(--text-faint);
3859+}
3860+
3861+.abpick:hover {
3862+ border-color: var(--action);
3863+ color: var(--action);
3864+ text-decoration: none;
3865+}
3866+
3867+.abpick.is-on {
3868+ border-color: var(--action);
3869+ background: var(--action);
3870+ color: var(--on-action);
3871+}
3872+
3873+.interdiff-file + .interdiff-file {
3874+ border-top: 1px solid var(--border);
3875+}
3876+
3877+.interdiff-path {
3878+ display: flex;
3879+ align-items: center;
3880+ gap: 8px;
3881+ padding: 6px 8px;
3882+ border-bottom: 1px solid var(--border);
3883+ background: var(--surface-raised);
3884+ font-size: var(--text-sm);
3885+}
3886+
3887+/* ─── stack page ───────────────────────────────────────────────────────────── */
3888+
3889+.stack-chain {
3890+ font-size: var(--text-sm);
3891+ color: var(--text-faint);
3892+}
3893+
3894+.stackrow {
3895+ display: flex;
3896+ align-items: center;
3897+ gap: 8px;
3898+ min-height: 38px;
3899+ padding-right: 10px;
3900+ border-bottom: 1px solid var(--border);
3901+ color: var(--text);
3902+ font-size: var(--text-sm);
3903}
3904
2143.timeline-dot {
2144 width: 6px;
2145 height: 6px;
2146 flex-shrink: 0;
2147 border-radius: 50%;
2148 background: var(--border-strong);
3905+.stackrow:last-child {
3906+ border-bottom: none;
3907}
3908
2151.timeline-dot-conflict { background: var(--conflict); }
2152.timeline-dot-merged { background: var(--merged); }
2153.timeline-dot-open { background: var(--open); }
2154.timeline-dot-abandoned { background: var(--abandoned); }
2155.timeline-dot-action { background: var(--action); }
2156.timeline-dot-dim { background: var(--border-strong); }
3909+.stackrow:hover {
3910+ background: var(--surface-raised);
3911+ text-decoration: none;
3912+}
3913+
3914+.stackrow.is-current {
3915+ background: var(--identity-wash);
3916+}
3917+
3918+.stackrow-glyph {
3919+ width: 22px;
3920+ flex: none;
3921+ text-align: center;
3922+ font-family: var(--font-mono);
3923+}
3924+
3925+.stackrow-title {
3926+ font-size: var(--text-base);
3927+ overflow: hidden;
3928+ text-overflow: ellipsis;
3929+ white-space: nowrap;
3930+ min-width: 0;
3931+}
3932+
3933+.stackrow-meta {
3934+ font-family: var(--font-mono);
3935+ font-size: var(--text-xs);
3936+ color: var(--text-faint);
3937+ white-space: nowrap;
3938+ flex: none;
3939+}
3940+
3941+/* The bottom of the graph: what the chain sits on. Not a link, because a
3942+ bookmark has no page — it is a pointer, not a thing. */
3943+.stackrow-base {
3944+ padding-left: 8px;
3945+ font-family: var(--font-mono);
3946+ font-size: var(--text-xs);
3947+ color: var(--text-faint);
3948+}
3949+
3950+.stack-cmd {
3951+ margin-top: 12px;
3952+ padding: 8px 10px;
3953+ border: 1px solid var(--border);
3954+ border-radius: var(--radius);
3955+ background: var(--bg);
3956+ font-size: var(--text-sm);
3957+ color: var(--text-dim);
3958+ white-space: pre-wrap;
3959+}
3960
3961/* ─── conflicts ────────────────────────────────────────────────────────────── */
3962
@@ -2183,105 +3986,355 @@
3986 max-height: 420px;
3987}
3988
2186/* ─── stack graph ──────────────────────────────────────────────────────────── */
3989+details summary {
3990+ cursor: pointer;
3991+}
3992
2188.stackgraph {
2189 list-style: none;
2190 margin: 16px 0 0;
2191 padding: 0;
3993+
3994+/* ─── issues ───────────────────────────────────────────────────────────────── */
3995+
3996+/* A label's colour comes from the repository, so it is validated as a hex
3997+ triple before it reaches the inline style. When it is not one, the chip falls
3998+ back to these theme colours rather than being dropped. */
3999+.label-chip {
4000+ border-width: 1px;
4001+ border-style: solid;
4002+}
4003+
4004+.issue-row {
4005+ display: flex;
4006+ align-items: center;
4007+ gap: 8px;
4008+ height: 36px;
4009+ padding: 0 10px;
4010+ border-bottom: 1px solid var(--border);
4011+ color: var(--text);
4012+ font-size: var(--text-sm);
4013+}
4014+
4015+.issue-row:last-child {
4016+ border-bottom: none;
4017+}
4018+
4019+.issue-row:hover {
4020+ background: var(--surface-raised);
4021+ text-decoration: none;
4022+}
4023+
4024+/* A closed issue is history. It stays fully legible — dimming it to 50% would
4025+ make the archive unreadable — but it steps back a little. */
4026+.issue-row.is-closed {
4027+ opacity: 0.85;
4028+}
4029+
4030+.issue-glyph {
4031+ width: 14px;
4032+ flex: none;
4033+ font-family: var(--font-mono);
4034}
4035
2194.stacknode {
2195 border-left: 2px solid var(--border);
2196 padding: 10px 0 10px 14px;
2197 position: relative;
4036+.issue-num {
4037+ font-family: var(--font-mono);
4038+ font-size: var(--text-xs);
4039+ color: var(--text-faint);
4040+ flex: none;
4041}
4042
2200.stacknode::before {
2201 content: "";
2202 position: absolute;
2203 left: -5px;
2204 top: 16px;
2205 width: 8px;
2206 height: 8px;
2207 border-radius: 50%;
2208 background: var(--border-strong);
4043+.issue-title {
4044+ font-size: 13.5px;
4045+ overflow: hidden;
4046+ text-overflow: ellipsis;
4047+ white-space: nowrap;
4048+ min-width: 0;
4049}
4050
2211.stacknode.current {
2212 border-left-color: var(--identity);
4051+.issue-row:hover .issue-title {
4052+ color: var(--action);
4053}
4054
2215.stacknode.current::before {
2216 background: var(--identity);
4055+.issue-meta {
4056+ font-size: var(--text-sm);
4057+ color: var(--text-faint);
4058+ white-space: nowrap;
4059+ flex: none;
4060}
4061
2219details summary {
2220 cursor: pointer;
4062+.issue-comments {
4063+ width: 34px;
4064+ text-align: right;
4065+ font-family: var(--font-mono);
4066+ font-size: var(--text-xs);
4067+ color: var(--text-faint);
4068+ flex: none;
4069}
4070
4071+.issue-when {
4072+ width: 56px;
4073+ text-align: right;
4074+ font-family: var(--font-mono);
4075+ font-size: var(--text-xs);
4076+ color: var(--text-faint);
4077+ flex: none;
4078+}
4079
2224/* ─── issues ───────────────────────────────────────────────────────────────── */
4080+/* ─── issue detail ─────────────────────────────────────────────────────────── */
4081
2226/* A label's colour comes from the repository, so it is validated as a hex
2227 triple before it reaches the inline style. When it is not one, the chip falls
2228 back to these theme colours rather than being dropped. */
2229.label-chip {
2230 border-width: 1px;
2231 border-style: solid;
4082+.backlink {
4083+ display: inline-block;
4084+ margin-bottom: 10px;
4085+ font-size: var(--text-sm);
4086}
4087
2234/* ─── masthead search ──────────────────────────────────────────────────────── */
4088+.issue-head {
4089+ display: flex;
4090+ align-items: center;
4091+ gap: 10px;
4092+ flex-wrap: wrap;
4093+ margin-bottom: 8px;
4094+}
4095
2236.masthead-search {
2237 margin-left: auto;
2238 margin-right: 16px;
2239 min-width: 0;
4096+.issue-body {
4097+ margin: 12px 0;
4098+ padding: 12px;
4099+ border: 1px solid var(--border);
4100+ border-radius: var(--radius);
4101+ background: var(--surface);
4102+ max-width: 76ch;
4103}
4104
2242.theme-toggle {
4105+.issue-ref {
4106 display: flex;
4107 align-items: center;
2245 justify-content: center;
2246 width: 30px;
4108+ gap: 8px;
4109+ margin: 8px 0;
4110+ padding: 2px 8px;
4111+ border-left: 2px solid var(--border);
4112+ font-size: var(--text-sm);
4113+ color: var(--text-dim);
4114+}
4115+
4116+.issue-ref-rail {
4117+ font-family: var(--font-mono);
4118+ color: var(--identity);
4119+}
4120+
4121+.aside-chips {
4122+ display: flex;
4123+ flex-wrap: wrap;
4124+ gap: 4px;
4125+}
4126+
4127+/* ─── filter bar ───────────────────────────────────────────────────────────── */
4128+
4129+/* Tabs and a filter form on one line. The tabs are links (each is a URL) and
4130+ the form is a form — they only look like one control. */
4131+.filterbar {
4132+ display: flex;
4133+ align-items: center;
4134+ gap: 8px;
4135+ flex-wrap: wrap;
4136+ margin-bottom: 10px;
4137+}
4138+
4139+.filterbar-tabs {
4140+ display: flex;
4141+ gap: 2px;
4142+}
4143+
4144+.filterbar select,
4145+.filterbar input[type="text"] {
4146+ height: 24px;
4147+ width: auto;
4148+ min-width: 120px;
4149+ padding: 0 6px;
4150+ border: 1px solid var(--border-strong);
4151+ border-radius: var(--radius);
4152+ background: var(--bg);
4153+ color: var(--text);
4154+ font-size: var(--text-sm);
4155+}
4156+
4157+.btn-mono.is-on {
4158+ border-color: var(--action);
4159+ color: var(--action);
4160+}
4161+
4162+/* The search bar reuses the revset bar's shell, so a select inside it has to
4163+ lose its own border and sit flush with the field beside it. */
4164+.revset-bar select {
4165 height: 30px;
2248 padding: 0;
2249 margin-right: 8px;
2250 flex-shrink: 0;
2251 background: transparent;
2252 border-color: transparent;
4166+ border: none;
4167+ border-left: 1px solid var(--border);
4168+ border-radius: 0;
4169+ background: var(--surface-raised);
4170+ color: var(--text-dim);
4171+ font-size: var(--text-sm);
4172+ padding: 0 6px;
4173+ flex: none;
4174+}
4175+
4176+/* ─── search results ───────────────────────────────────────────────────────── */
4177+
4178+.search-group + .search-group {
4179+ margin-top: 20px;
4180+}
4181+
4182+.search-group > h2 {
4183+ margin: 0 0 6px;
4184+}
4185+
4186+.search-hit {
4187+ display: flex;
4188+ align-items: center;
4189+ gap: 8px;
4190+ min-height: 32px;
4191+ padding: 0 10px;
4192+ border-bottom: 1px solid var(--border);
4193+ color: var(--text);
4194+ font-size: var(--text-sm);
4195+}
4196+
4197+.search-hit:last-child {
4198+ border-bottom: none;
4199+}
4200+
4201+.search-hit:hover {
4202+ background: var(--surface-raised);
4203+ text-decoration: none;
4204+}
4205+
4206+.search-kind {
4207+ width: 60px;
4208+ flex: none;
4209+ font-family: var(--font-condensed);
4210+ font-size: var(--text-xs);
4211+ font-weight: 500;
4212+ letter-spacing: 0.06em;
4213+ text-transform: uppercase;
4214+ color: var(--text-faint);
4215+}
4216+
4217+.search-title {
4218+ font-size: var(--text-base);
4219+ white-space: nowrap;
4220+ flex: none;
4221+}
4222+
4223+.search-hit:hover .search-title {
4224+ color: var(--action);
4225+}
4226+
4227+/* The context gives up its space first — the title is what you are aiming at. */
4228+.search-context {
4229+ flex: 1;
4230+ min-width: 0;
4231 color: var(--text-dim);
4232+ overflow: hidden;
4233+ text-overflow: ellipsis;
4234+ white-space: nowrap;
4235+}
4236+
4237+/* ─── masthead groups ──────────────────────────────────────────────────────── */
4238+
4239+.masthead-nav,
4240+.masthead-account {
4241+ display: flex;
4242+ align-items: center;
4243+ gap: 8px;
4244+ min-width: 0;
4245}
4246
2256.theme-toggle:hover {
4247+.masthead-nav a {
4248+ display: inline-flex;
4249+ align-items: center;
4250+ height: 26px;
4251+ padding: 0 9px;
4252+ border: 1px solid transparent;
4253+ border-radius: var(--radius);
4254+ font-size: var(--text-sm);
4255+}
4256+
4257+.masthead-nav a:hover {
4258 border-color: var(--border-strong);
2258 color: var(--text);
4259+ background: var(--surface-raised);
4260}
4261
2261.theme-toggle .icon-moon {
2262 display: none;
4262+.masthead-account a {
4263+ font-size: var(--text-sm);
4264}
4265
2265:root[data-theme="light"] .theme-toggle .icon-sun {
2266 display: none;
4266+/* The trigger sits in the right-hand grid column alongside the theme toggle;
4267+ a long handle should truncate rather than push that column wide enough to
4268+ fight the centred jump control for space. The caret truncates separately so
4269+ it always stays visible. */
4270+.account-handle {
4271+ display: inline-block;
4272+ max-width: 140px;
4273+ overflow: hidden;
4274+ text-overflow: ellipsis;
4275+ white-space: nowrap;
4276+ vertical-align: bottom;
4277+}
4278+
4279+.theme-toggle:hover {
4280+ border-color: var(--action);
4281+ color: var(--action);
4282+}
4283+
4284+/* The account menu opens under the handle, right-aligned rather than the
4285+ bookmark switcher's left: the trigger sits at the end of the bar, and a
4286+ left-aligned panel would hang off the edge of the viewport. */
4287+.switcher-menu-end {
4288+ left: auto;
4289+ right: 0;
4290+}
4291+
4292+.switcher-menu form {
4293+ margin: 0;
4294+}
4295+
4296+.switcher-item-danger {
4297+ width: 100%;
4298+ border: none;
4299+ background: transparent;
4300+ font: inherit;
4301+ text-align: left;
4302+ cursor: pointer;
4303+ color: var(--text);
4304}
4305
2269:root[data-theme="light"] .theme-toggle .icon-moon {
2270 display: block;
4306+.switcher-item-danger:hover {
4307+ color: var(--danger);
4308}
4309
2273.masthead-search input {
2274 width: 180px;
2275 max-width: 40vw;
2276 padding: 5px 9px;
2277 font-size: 13px;
4310+/* Narrow screens shed the chrome from the middle outward: the section links
4311+ go first, then the wordmark's "dogfood" text, leaving the jump control,
4312+ the handle and the theme readout as the last things standing. */
4313+@media (max-width: 900px) {
4314+ .masthead-nav,
4315+ .masthead-group-start > .vrule {
4316+ display: none;
4317+ }
4318}
4319
2280/* On a narrow screen the search box is the first thing to give up its space. */
4320@media (max-width: 620px) {
2282 .masthead-search {
4321+ .jump > span:first-child {
4322 display: none;
4323 }
4324+
4325+ .jump {
4326+ padding: 0 6px;
4327+ }
4328+
4329+ .brand > span:last-child {
4330+ display: none;
4331+ }
4332+}
4333+
4334+@media (max-width: 400px) {
4335+ .theme-toggle {
4336+ display: none;
4337+ }
4338}
4339
4340
@@ -2316,7 +4369,7 @@
4369textarea:focus-visible,
4370summary:focus-visible,
4371[tabindex]:focus-visible {
2319 outline: 2px solid var(--brand);
4372+ outline: 2px solid var(--action);
4373 outline-offset: 2px;
4374 border-radius: var(--radius-sm);
4375}
@@ -2365,19 +4418,69 @@
4418
4419/* ─── the in-browser editor ────────────────────────────────────────────────── */
4420
4421+/* The editor is one bordered object: a title bar, the code, and a commit bar
4422+ along the bottom. The commit message and the submit button live inside that
4423+ frame rather than as separate fields below it, because they are part of the
4424+ same act. */
4425+.editor-shell {
4426+ border: 1px solid var(--border);
4427+ border-radius: var(--radius);
4428+ background: var(--surface);
4429+ overflow: hidden;
4430+}
4431+
4432+.editor-bar,
4433+.editor-foot {
4434+ display: flex;
4435+ align-items: center;
4436+ gap: 8px;
4437+ padding: 6px 8px;
4438+ background: var(--surface-raised);
4439+ font-size: var(--text-sm);
4440+}
4441+
4442+.editor-bar {
4443+ border-bottom: 1px solid var(--border);
4444+}
4445+
4446+.editor-foot {
4447+ border-top: 1px solid var(--border);
4448+}
4449+
4450+.editor-foot input[type="text"] {
4451+ flex: 1;
4452+ min-width: 0;
4453+ height: 28px;
4454+}
4455+
4456+.editor-status {
4457+ font-size: var(--text-xs);
4458+ color: var(--text-faint);
4459+}
4460+
4461/* The textarea is the editor until CodeMirror replaces it, so it has to look
4462 like one on its own — not like a form field that happens to hold code. */
4463.editor-host textarea {
4464+ display: block;
4465 width: 100%;
4466 min-height: 420px;
4467+ border: none;
4468+ border-radius: 0;
4469+ padding: 8px 10px;
4470+ background: var(--bg);
4471 font-family: var(--font-mono);
4472 font-size: 13px;
2375 line-height: 1.5;
4473+ line-height: 21px;
4474 tab-size: 4;
4475 white-space: pre;
4476 overflow: auto;
4477}
4478
4479+.editor-host textarea:focus {
4480+ outline: none;
4481+ border: none;
4482+}
4483+
4484/* CodeMirror sizes itself; the host must not fight it. */
4485.editor-host .cm-editor {
4486 height: auto;
@@ -2531,11 +4634,14 @@
4634 color: var(--text-dim);
4635}
4636
2534.sk-fn { color: var(--hl-function, #d9a441); background: rgb(217 164 65 / 0.12); }
2535.sk-type { color: var(--hl-type, #6ba9b8); background: rgb(107 169 184 / 0.12); }
2536.sk-enum { color: var(--hl-constant, #c77dba); background: rgb(199 125 186 / 0.12); }
2537.sk-trait { color: var(--hl-keyword, #db5a86); background: rgb(219 90 134 / 0.12); }
2538.sk-const { color: var(--hl-constant, #c77dba); background: rgb(199 125 186 / 0.12); }
4637+/* Symbol kinds borrow the syntax palette, so an outline entry is the same
4638+ colour as the token it points at in the code beside it. `color-mix` on the
4639+ token itself rather than a hard-coded rgb, so both themes stay correct. */
4640+.sk-fn { color: var(--hl-function); background: color-mix(in srgb, var(--hl-function) 12%, transparent); }
4641+.sk-type { color: var(--hl-type); background: color-mix(in srgb, var(--hl-type) 12%, transparent); }
4642+.sk-enum { color: var(--hl-constant); background: color-mix(in srgb, var(--hl-constant) 12%, transparent); }
4643+.sk-trait { color: var(--hl-keyword); background: color-mix(in srgb, var(--hl-keyword) 12%, transparent); }
4644+.sk-const { color: var(--hl-constant); background: color-mix(in srgb, var(--hl-constant) 12%, transparent); }
4645.sk-mod { color: var(--text-dim); background: var(--border); }
4646
4647.symbol-name {
@@ -2597,3 +4703,183 @@
4703 display: none;
4704 }
4705}
4706+
4707+
4708+/* ─── responsive hardening ────────────────────────────────────────────────── */
4709+
4710+/* A terminal is rendered as a figure for its caption semantics. Browser
4711+ figure margins cost 80px of horizontal room unless explicitly reset, which
4712+ is especially destructive on a phone. */
4713+figure.term {
4714+ margin: 0;
4715+}
4716+
4717+/* Generic surfaces may contain grids, tables, or long rendered prose. Let the
4718+ component own any necessary scrolling instead of making the document wide. */
4719+.panel,
4720+.markdown-body {
4721+ min-width: 0;
4722+}
4723+
4724+/* The interdiff heading contains independent label, explanation, and summary
4725+ spans. Give them real layout and let the explanation move to a second line
4726+ rather than running directly into the comparison label. */
4727+.interdiff > .filediff-head {
4728+ display: flex;
4729+ align-items: center;
4730+ gap: 4px 8px;
4731+ flex-wrap: wrap;
4732+}
4733+
4734+@media (max-width: 900px) {
4735+ /* `.masthead nav` has greater specificity than a bare `.masthead-nav`.
4736+ Qualifying this rule ensures the intended mobile chrome actually sheds
4737+ the nonessential design-system link. */
4738+ .masthead .masthead-nav {
4739+ display: none;
4740+ }
4741+}
4742+
4743+@media (max-width: 620px) {
4744+ /* Sixteen pixels is a useful phone gutter; the desktop 24px gutter was
4745+ taking almost a sixth of a 320px viewport before content even began. */
4746+ .masthead-inner,
4747+ .subnav-inner,
4748+ .wrap {
4749+ padding-left: 16px;
4750+ padding-right: 16px;
4751+ }
4752+
4753+ /* On phones the centre control uses the room between the two edge groups.
4754+ Equal outer grid tracks can overlap when an account handle is wider than
4755+ the brand. */
4756+ .masthead-inner {
4757+ grid-template-columns: auto minmax(0, 1fr) auto;
4758+ column-gap: 10px;
4759+ }
4760+
4761+ .jump {
4762+ justify-self: center;
4763+ }
4764+
4765+ .account-handle {
4766+ max-width: 88px;
4767+ }
4768+
4769+ /* Repository identity gets one compact row and the four primary sections
4770+ get a full-width row. Previously the tab strip was flex-shrunk to as little
4771+ as 16px and then asked to host its own 273px scroll area. */
4772+ .subnav-inner {
4773+ min-height: 60px;
4774+ height: auto;
4775+ align-content: start;
4776+ align-items: center;
4777+ flex-wrap: wrap;
4778+ gap: 4px 10px;
4779+ padding-top: 6px;
4780+ padding-bottom: 0;
4781+ }
4782+
4783+ .subnav-inner > .vrule,
4784+ .subnav-inner > .spacer,
4785+ .subnav-inner > .subnav-meta {
4786+ display: none;
4787+ }
4788+
4789+ .subnav-inner > .subtabs {
4790+ order: 1;
4791+ flex: 0 0 100%;
4792+ width: 100%;
4793+ }
4794+
4795+ /* On a narrow bookmark table the title and age are secondary; retaining all
4796+ four columns made the bookmark and change id paint over one another. */
4797+ .bookmark-table th:nth-child(3),
4798+ .bookmark-table th:nth-child(4),
4799+ .bookmark-title,
4800+ .bookmark-when {
4801+ display: none;
4802+ }
4803+
4804+ .bookmark-table td {
4805+ height: 44px;
4806+ }
4807+
4808+ .bookmark-name {
4809+ width: 45%;
4810+ white-space: normal;
4811+ }
4812+
4813+ .bookmark-name > a {
4814+ display: block;
4815+ max-width: 100%;
4816+ overflow: hidden;
4817+ text-overflow: ellipsis;
4818+ white-space: nowrap;
4819+ }
4820+
4821+ .bookmark-name .bookmark-flag {
4822+ display: inline-block;
4823+ margin-right: 4px;
4824+ }
4825+
4826+ .bookmark-points {
4827+ width: 55%;
4828+ overflow: hidden;
4829+ text-overflow: ellipsis;
4830+ }
4831+
4832+ .bookmark-points .cid {
4833+ display: block;
4834+ max-width: 100%;
4835+ overflow: hidden;
4836+ text-overflow: ellipsis;
4837+ }
4838+
4839+ /* The context is supporting information. On phones the result title gets
4840+ the flexible space and truncates cleanly if even that is not enough. */
4841+ .search-context,
4842+ .palette-hint {
4843+ display: none;
4844+ }
4845+
4846+ .search-title,
4847+ .palette-label {
4848+ flex: 1 1 auto;
4849+ min-width: 0;
4850+ overflow: hidden;
4851+ text-overflow: ellipsis;
4852+ }
4853+
4854+ .issue-meta {
4855+ display: none;
4856+ }
4857+
4858+ /* Settings and rendered Markdown can contain genuinely tabular information.
4859+ Preserve every column and contain the exceptional width inside the table
4860+ instead of introducing a page-level scroll bay. */
4861+ .listing,
4862+ .markdown-body table {
4863+ display: block;
4864+ max-width: 100%;
4865+ overflow-x: auto;
4866+ }
4867+
4868+ .kv {
4869+ grid-template-columns: minmax(0, 1fr);
4870+ gap: 3px;
4871+ }
4872+
4873+ .row {
4874+ flex-wrap: wrap;
4875+ }
4876+}
4877+
4878+@media (max-width: 400px) {
4879+ /* Five change-detail tabs fit a 320px viewport at this density, avoiding an
4880+ almost-complete last label and a hard-to-discover tiny horizontal scroll. */
4881+ .subtabs a {
4882+ padding-left: 8px;
4883+ padding-right: 8px;
4884+ }
4885+}
crates/df-web/assets/theme.js+31 −18
@@ -1,35 +1,48 @@
1// Theme toggle. Enhancement only — the site is fully usable in its default
2// dark theme with this script blocked or absent; it just cannot be switched.
3+//
4+// The control reads out the theme it is currently *in* ("DARK"), not the one
5+// it would switch to. That is the design's choice, and it is the right one for
6+// a control that is also the only place the current theme is stated.
7(function () {
8 var KEY = "dogfood-theme";
9
10+ function current() {
11+ return document.documentElement.getAttribute("data-theme") === "light"
12+ ? "light"
13+ : "dark";
14+ }
15+
16 function apply(theme) {
17 if (theme === "light") {
18 document.documentElement.setAttribute("data-theme", "light");
19 } else {
20 document.documentElement.removeAttribute("data-theme");
21 }
22+ document.querySelectorAll("[data-theme-toggle]").forEach(function (btn) {
23+ btn.setAttribute("aria-pressed", String(theme === "light"));
24+ var label = btn.querySelector("[data-theme-label]");
25+ if (label) label.textContent = theme;
26+ });
27 }
28
29+ // Exposed so the palette's "Toggle theme" command drives this same code
30+ // path rather than reimplementing persistence next to it.
31+ window.dogfoodToggleTheme = function () {
32+ var next = current() === "light" ? "dark" : "light";
33+ apply(next);
34+ try {
35+ localStorage.setItem(KEY, next);
36+ } catch (e) {
37+ // Storage blocked (private mode, quota). The toggle still works for
38+ // this page load; it just will not persist across visits.
39+ }
40+ };
41+
42 document.querySelectorAll("[data-theme-toggle]").forEach(function (btn) {
15 var current = document.documentElement.getAttribute("data-theme") === "light"
16 ? "light"
17 : "dark";
18 btn.setAttribute("aria-pressed", String(current === "light"));
43 btn.hidden = false;
44+ btn.addEventListener("click", window.dogfoodToggleTheme);
45+ });
46
21 btn.addEventListener("click", function () {
22 var next = document.documentElement.getAttribute("data-theme") === "light"
23 ? "dark"
24 : "light";
25 apply(next);
26 try {
27 localStorage.setItem(KEY, next);
28 } catch (e) {
29 // Storage blocked (private mode, quota). The toggle still works for
30 // this page load; it just will not persist across visits.
31 }
32 btn.setAttribute("aria-pressed", String(next === "light"));
33 });
34 });
47+ apply(current());
48})();
crates/df-web/src/main.rs+34 −0
@@ -151,6 +151,8 @@
151 .route("/assets/htmx.min.js", get(serve_htmx))
152 .route("/assets/theme-init.js", get(serve_theme_init_js))
153 .route("/assets/theme.js", get(serve_theme_js))
154+ .route("/assets/palette.js", get(serve_palette_js))
155+ .route("/assets/terminal.js", get(serve_terminal_js))
156 .route("/assets/editor.js", get(serve_editor_js))
157 // ─── operational ─────────────────────────────────────────────────────
158 // Repository routes are declared last so their `{owner}` wildcard
@@ -196,6 +198,7 @@
198 .route("/{owner}/{repo}/changes/{reference}", get(routes::review::overview))
199 .route("/{owner}/{repo}/changes/{reference}/files", get(routes::review::files))
200 .route("/{owner}/{repo}/changes/{reference}/revisions", get(routes::review::revisions))
201+ .route("/{owner}/{repo}/changes/{reference}/checks", get(routes::review::checks))
202 .route("/{owner}/{repo}/changes/{reference}/conflicts", get(routes::review::conflicts))
203 .route("/{owner}/{repo}/changes/{reference}/comments", post(routes::review::create_comment))
204 .route(
@@ -416,6 +419,37 @@
419 )
420}
421
422+/// The ⌘K palette's behaviour. Deferred, like htmx — without it the masthead
423+/// control stays a plain link to `/search`.
424+async fn serve_palette_js() -> impl axum::response::IntoResponse {
425+ (
426+ [
427+ (
428+ axum::http::header::CONTENT_TYPE,
429+ "application/javascript; charset=utf-8",
430+ ),
431+ (axum::http::header::CACHE_CONTROL, "public, max-age=86400"),
432+ ],
433+ include_str!("../assets/palette.js"),
434+ )
435+}
436+
437+/// The homepage terminal's typewriter animation. Deferred, like the other
438+/// enhancement scripts — without it the example session just renders as
439+/// static text.
440+async fn serve_terminal_js() -> impl axum::response::IntoResponse {
441+ (
442+ [
443+ (
444+ axum::http::header::CONTENT_TYPE,
445+ "application/javascript; charset=utf-8",
446+ ),
447+ (axum::http::header::CACHE_CONTROL, "public, max-age=86400"),
448+ ],
449+ include_str!("../assets/terminal.js"),
450+ )
451+}
452+
453async fn not_found() -> error::AppError {
454 error::AppError::NotFound
455}
crates/df-web/src/repo_ctx.rs+39 −0
@@ -26,6 +26,25 @@
26 /// Display handle of the owning user or org.
27 pub owner: String,
28 pub access: RepoAccess,
29+ /// Counts for the repository sub-bar.
30+ pub nav: RepoNav,
31+}
32+
33+/// The numbers on the repository sub-bar, which is on every page inside a
34+/// repository.
35+///
36+/// Resolved once per request alongside permissions, for the same reason: the
37+/// bar is chrome, so every handler needs it, and making each one remember to
38+/// fetch it is how a page ends up with a bar that says something different
39+/// from the page under it.
40+#[derive(Debug, Clone, Copy, Default, sqlx::FromRow)]
41+pub struct RepoNav {
42+ pub open_changes: i64,
43+ /// Open changes that are currently conflicted. A subset of `open_changes` —
44+ /// a conflict is a state a change is *in*, not a state it is instead of.
45+ pub conflicted: i64,
46+ pub open_issues: i64,
47+ pub bookmarks: i64,
48}
49
50impl RepoContext {
@@ -96,9 +115,29 @@
115 return Err(AppError::NotFound);
116 }
117
118+ // Four scalar subqueries in one round trip. Deliberately *after* the
119+ // read check: an unauthorized viewer must not cost us the counts, and
120+ // must not be able to time the difference.
121+ let nav: RepoNav = sqlx::query_as(
122+ r#"
123+ SELECT
124+ (SELECT count(*) FROM changes
125+ WHERE repo_id = $1 AND state = 'open') AS open_changes,
126+ (SELECT count(*) FROM changes
127+ WHERE repo_id = $1 AND state = 'open' AND conflicted) AS conflicted,
128+ (SELECT count(*) FROM issues
129+ WHERE repo_id = $1 AND state = 'open') AS open_issues,
130+ (SELECT count(*) FROM bookmarks WHERE repo_id = $1) AS bookmarks
131+ "#,
132+ )
133+ .bind(row.id)
134+ .fetch_one(&state.db)
135+ .await?;
136+
137 Ok(RepoContext {
138 owner: row.owner_handle,
139 access,
140+ nav,
141 repo: Repo {
142 id: row.id,
143 owner_kind: row.owner_kind,
crates/df-web/src/security_tests.rs+93 −0
@@ -140,6 +140,18 @@
140 ) -> SResult<Option<Revision>> {
141 Ok(None)
142 }
143+ async fn last_commits_in_dir(
144+ &self,
145+ _: RepoId,
146+ _: &RevId,
147+ _: &Path,
148+ _: &[String],
149+ ) -> SResult<std::collections::HashMap<String, Revision>> {
150+ Ok(std::collections::HashMap::new())
151+ }
152+ async fn diff_stats(&self, _: RepoId, revs: &[RevId]) -> SResult<Vec<Option<(usize, usize)>>> {
153+ Ok(vec![None; revs.len()])
154+ }
155}
156
157/// Enough provider metadata for `Oidc` to construct without a network call.
@@ -650,6 +662,87 @@
662 h.drop_schema().await;
663}
664
665+/// The redesign added several aggregate queries that only run at request time —
666+/// the sub-bar counts, the filter-tab counts, and the weekly statistics. A
667+/// mistake in any of them is a 500 on the two most-visited pages in a
668+/// repository, and nothing else in the suite would catch it.
669+///
670+/// Not a security property, but it lives here because this is the only
671+/// harness that can serve a real request against a real schema.
672+#[tokio::test]
673+async fn the_repository_pages_render_with_their_real_counts() {
674+ let Some(h) = harness("counts").await else { return };
675+
676+ let (alice, session) = user(&h.db, "alice", false).await;
677+ let r = repo(&h.db, alice, "counted", false).await;
678+
679+ change(&h.db, r, 1, "klxqnvpqlnlvtkmuqmtmxktlnvnomvwv", "first change").await;
680+ change(&h.db, r, 2, "mmmmnvpqlnlvtkmuqmtmxktlnvnomvwv", "second change").await;
681+ issue(&h.db, r, 1, "an issue").await;
682+
683+ for path in ["/alice/counted/changes", "/alice/counted/issues", "/alice/counted/bookmarks"] {
684+ let res = h.get_as(path, &session).await;
685+ assert_eq!(res.status(), StatusCode::OK, "{path} did not render");
686+ let body = h.body(res).await;
687+
688+ // The sub-bar states the repository's vital signs on every page, so
689+ // its numbers are the ones that must be right everywhere.
690+ assert!(
691+ body.contains("2 open"),
692+ "{path} should report two open changes in the sub-bar: {body}"
693+ );
694+ }
695+
696+ // The change list additionally runs the tab counts and the weekly
697+ // aggregate, including a percentile over an empty set — which returns
698+ // NULL, and must decode as `None` rather than failing the query.
699+ let res = h.get_as("/alice/counted/changes", &session).await;
700+ let body = h.body(res).await;
701+ assert!(body.contains("second change"), "the list should show its changes: {body}");
702+ assert!(body.contains("Median time to first review"), "week stats missing: {body}");
703+
704+ h.drop_schema().await;
705+}
706+
707+/// The ⌘K palette is a second entry point into search, and a second entry
708+/// point is exactly where a visibility rule gets forgotten.
709+///
710+/// It must not be: the palette renders through the same handler in fragment
711+/// mode, so this asserts the property directly rather than trusting that it is
712+/// the same code path.
713+#[tokio::test]
714+async fn the_palette_fragment_obeys_the_same_visibility_rule_as_search() {
715+ let Some(h) = harness("palette").await else { return };
716+
717+ let (alice, alice_session) = user(&h.db, "alice", false).await;
718+ let (_, mallory) = user(&h.db, "mallory", false).await;
719+
720+ let hidden = repo(&h.db, alice, "widgetsecret", true).await;
721+ let public = repo(&h.db, alice, "widgetopen", false).await;
722+ change(&h.db, hidden, 1, "klxqnvpqlnlvtkmuqmtmxktlnvnomvwv", "widgetconfidential change").await;
723+ change(&h.db, public, 1, "mmmmnvpqlnlvtkmuqmtmxktlnvnomvwv", "widgetpublic change").await;
724+
725+ for session in [None, Some(mallory)] {
726+ let res = h.request("/search?q=widget&fragment=1", session).await;
727+ assert_eq!(res.status(), StatusCode::OK);
728+ let body = h.body(res).await;
729+
730+ // A fragment is a bare list, so it must not drag the page chrome with
731+ // it — that would nest a whole document inside the overlay.
732+ assert!(!body.contains("<html"), "the fragment must not be a full page: {body}");
733+
734+ assert!(!body.contains("widgetconfidential"), "private content in the palette: {body}");
735+ assert!(!body.contains("widgetsecret"), "private repo name in the palette: {body}");
736+ assert!(body.contains("widgetopen"), "the public repo should be findable");
737+ }
738+
739+ let res = h.get_as("/search?q=widget&fragment=1", &alice_session).await;
740+ let body = h.body(res).await;
741+ assert!(body.contains("widgetconfidential"), "the owner must see their own: {body}");
742+
743+ h.drop_schema().await;
744+}
745+
746/// A profile page must not enumerate repositories the viewer cannot open.
747#[tokio::test]
748async fn a_profile_does_not_list_private_repositories_to_strangers() {
crates/df-store/src/git/mod.rs+125 −0
@@ -470,6 +470,27 @@
470 .await
471 }
472
473+ async fn diff_stats(&self, id: RepoId, revs: &[RevId]) -> Result<Vec<Option<(usize, usize)>>> {
474+ let revs: Vec<RevId> = revs.to_vec();
475+
476+ self.with_repo(id, move |repo| {
477+ // No context lines: the totals do not depend on them, and asking
478+ // for three means building three times the hunk bodies we then
479+ // throw away.
480+ let opts = DiffOpts { context_lines: 0, ..DiffOpts::default() };
481+
482+ Ok(revs
483+ .iter()
484+ .map(|rev| {
485+ diff::compute_from_parent(repo, rev, opts)
486+ .ok()
487+ .map(|d| (d.total_additions, d.total_deletions))
488+ })
489+ .collect())
490+ })
491+ .await
492+ }
493+
494 async fn commit_file(
495 &self,
496 id: RepoId,
@@ -709,6 +730,110 @@
730 })
731 .await
732 }
733+
734+ async fn last_commits_in_dir(
735+ &self,
736+ id: RepoId,
737+ rev: &RevId,
738+ dir: &Path,
739+ entries: &[String],
740+ ) -> Result<std::collections::HashMap<String, Revision>> {
741+ let rev = rev.clone();
742+ let rel_dir = safe_path::normalise(&dir.to_string_lossy())?;
743+ let wanted: std::collections::HashSet<String> = entries.iter().cloned().collect();
744+
745+ if wanted.is_empty() {
746+ return Ok(std::collections::HashMap::new());
747+ }
748+
749+ self.with_repo(id, move |repo| {
750+ let commit = convert::find_commit(repo, &rev)?;
751+
752+ let walk = commit
753+ .ancestors()
754+ .all()
755+ .map_err(|e| StoreError::Other(anyhow::anyhow!("walking history: {e}")))?;
756+
757+ let mut remaining = wanted;
758+ // (entry name -> the commit that last touched it), filled in as the
759+ // walk resolves each entry.
760+ let mut resolved: std::collections::HashMap<String, gix::ObjectId> =
761+ std::collections::HashMap::new();
762+
763+ 'walk: for info in walk.take(500) {
764+ let info = info.map_err(|e| StoreError::Other(anyhow::anyhow!("walk: {e}")))?;
765+ let c = repo
766+ .find_object(info.id)
767+ .map_err(|e| StoreError::Other(anyhow::anyhow!("find: {e}")))?
768+ .try_into_commit()
769+ .map_err(|_| StoreError::NoSuchRevision)?;
770+
771+ let to_tree = c
772+ .tree()
773+ .map_err(|e| StoreError::Other(anyhow::anyhow!("tree: {e}")))?;
774+
775+ // First parent only, matching `diff_from_parent` — a merge's
776+ // "last commit" for a path is the mainline change, not every
777+ // side branch that happened to touch it too.
778+ let from_tree = match c.parent_ids().next() {
779+ Some(parent) => repo
780+ .find_object(parent.detach())
781+ .map_err(|e| StoreError::Other(anyhow::anyhow!("finding parent: {e}")))?
782+ .try_into_commit()
783+ .map_err(|_| StoreError::NoSuchRevision)?
784+ .tree()
785+ .map_err(|e| StoreError::Other(anyhow::anyhow!("parent tree: {e}")))?,
786+ None => repo.empty_tree(),
787+ };
788+
789+ // Path list only — no blob reads. This is what keeps a whole
790+ // directory resolvable in one walk rather than one walk per
791+ // file: the expensive part of a diff is reading and comparing
792+ // content, and a "which entry did this commit touch" check
793+ // never needs it.
794+ let mut touched: Vec<String> = Vec::new();
795+ from_tree
796+ .changes()
797+ .map_err(|e| StoreError::Other(anyhow::anyhow!("diffing trees: {e}")))?
798+ .for_each_to_obtain_tree(&to_tree, |change| {
799+ touched.push(change.location().to_string());
800+ Ok::<_, std::convert::Infallible>(
801+ gix::object::tree::diff::Action::Continue,
802+ )
803+ })
804+ .map_err(|e| StoreError::Other(anyhow::anyhow!("walking tree diff: {e}")))?;
805+
806+ for path in &touched {
807+ let rest = if rel_dir.is_empty() {
808+ Some(path.as_str())
809+ } else {
810+ path.strip_prefix(&rel_dir).and_then(|s| s.strip_prefix('/'))
811+ };
812+ let Some(rest) = rest else { continue };
813+ let entry_name = rest.split('/').next().unwrap_or(rest);
814+ if remaining.remove(entry_name) {
815+ resolved.insert(entry_name.to_string(), info.id);
816+ }
817+ }
818+
819+ if remaining.is_empty() {
820+ break 'walk;
821+ }
822+ }
823+
824+ let mut out = std::collections::HashMap::with_capacity(resolved.len());
825+ for (name, oid) in resolved {
826+ let c = repo
827+ .find_object(oid)
828+ .map_err(|e| StoreError::Other(anyhow::anyhow!("find: {e}")))?
829+ .try_into_commit()
830+ .map_err(|_| StoreError::NoSuchRevision)?;
831+ out.insert(name, convert::to_revision(&c)?);
832+ }
833+ Ok(out)
834+ })
835+ .await
836+ }
837}
838
839/// Recursive directory size, used for the repo settings page.
crates/df-web/src/routes/auth.rs+26 −1
@@ -59,9 +59,34 @@
59 None => "/login?continue=sso".to_string(),
60 };
61
62+ // A public repository to point at, so "read anything public first" is an
63+ // offer with a destination rather than a slogan.
64+ let sample: Option<(String, String)> = sqlx::query_as(
65+ r#"
66+ SELECT COALESCE(ou.handle, og.handle) AS owner, r.name::text
67+ FROM repos r
68+ LEFT JOIN users ou ON ou.id = r.owner_user_id
69+ LEFT JOIN orgs og ON og.id = r.owner_org_id
70+ WHERE r.archived = false AND r.visibility = 'public'
71+ ORDER BY r.pushed_at DESC NULLS LAST, r.created_at DESC
72+ LIMIT 1
73+ "#,
74+ )
75+ .fetch_optional(&state.db)
76+ .await?;
77+
78+ let sample_path = sample.as_ref().map(|(o, n)| format!("{o}/{n}"));
79+ let clone_hint = match &sample {
80+ Some((o, n)) => format!("jj git clone {}", state.config.https_clone_url(o, n)),
81+ None => format!(
82+ "jj git clone {}",
83+ state.config.https_clone_url("your-org", "your-repo")
84+ ),
85+ };
86+
87 Ok(views::page(
88 Chrome { title: "Sign in", user: None, csrf: &csrf, nonce: &nonce },
64 views::pages::signin(&sso_href),
89+ views::pages::signin(&sso_href, &clone_hint, sample_path.as_deref()),
90 )
91 .into_response())
92}
crates/df-web/src/routes/change.rs+245 −36
@@ -48,59 +48,103 @@
48
49 // On a revset error, show the message and no rows rather than silently
50 // listing everything — which would look like the filter had matched.
51 let rows = if revset_error.is_some() {
51+ let mut rows = if revset_error.is_some() {
52 Vec::new()
53 } else {
54 load_changes(&state, ctx.repo.id, state_filter, revset_sql, revset_vals).await?
54+ load_changes(
55+ &state,
56+ ctx.repo.id,
57+ state_filter,
58+ user.as_ref().map(|u| u.id),
59+ revset_sql,
60+ revset_vals,
61+ )
62+ .await?
63 };
64
65+ decorate(&state, &ctx, &mut rows).await;
66+ let rows = v::arrange(rows);
67+
68+ let counts = list_counts(&state, ctx.repo.id, user.as_deref()).await?;
69+ let week = week_stats(&state, ctx.repo.id).await?;
70+
71 let body = maud::html! {
58 (rv::header(&ctx, "changes"))
72 (v::list(&ctx, &rows, v::ListFilters {
73 state: state_filter,
74 revset: &revset_input,
75 revset_error: revset_error.as_deref(),
76+ counts,
77+ signed_in: user.is_some(),
78+ week,
79 }))
80 };
81
66 Ok(views::page(
82+ Ok(views::page_with_bar(
83 Chrome {
84 title: &format!("Changes · {}/{}", ctx.owner, ctx.repo.name),
85 user: user.as_deref(),
86 csrf: &csrf,
87 nonce: &nonce,
88 },
89+ rv::header(&ctx, "changes"),
90 body,
91 )
92 .into_response())
93}
94
95+/// The SQL predicate behind each filter tab.
96+///
97+/// "Conflicted" and "Mine" are not values of `changes.state` — they are views
98+/// over it. Keeping the mapping in one place is what stops the tab counts and
99+/// the tab contents from drifting apart: [`list_counts`] uses these same
100+/// fragments to count what each tab will show.
101+///
102+/// `$2` is the viewer's id, which is null for an anonymous request. The `mine`
103+/// clause therefore matches nothing when nobody is signed in, rather than
104+/// matching every change with no author.
105+fn state_predicate(filter: &str) -> &'static str {
106+ match filter {
107+ "conflicted" => "c.state = 'open' AND c.conflicted",
108+ "merged" => "c.state = 'merged'",
109+ "abandoned" => "c.state = 'abandoned'",
110+ "mine" => "c.author_user_id = $2 AND c.state IN ('open', 'draft')",
111+ "all" => "true",
112+ // Anything unrecognised falls back to the default tab rather than to
113+ // "everything": a typo'd query string should not widen a listing.
114+ _ => "c.state = 'open'",
115+ }
116+}
117+
118async fn load_changes(
119 state: &AppState,
120 repo_id: Uuid,
121 state_filter: &str,
122+ viewer: Option<Uuid>,
123 revset_sql: Option<String>,
124 revset_vals: Vec<String>,
125) -> AppResult<Vec<v::ChangeRow>> {
85 let mut sql = String::from(
86 // `hr.author_name` is the fallback when no account matched the commit's
87 // email — the person is still known, just not linkable.
126+ // `hr.author_name` is the fallback when no account matched the commit's
127+ // email — the person is still known, just not linkable.
128+ let mut sql = format!(
129 "SELECT c.number, c.change_id, c.synthetic, c.title, c.state::text,
130 c.conflicted, c.updated_at,
131 u.handle::text AS author,
132 hr.author_name,
133+ hr.rev AS head_rev,
134 (SELECT count(*) FROM revisions rr WHERE rr.change_id_fk = c.id) AS revcount,
135+ (SELECT count(*) FROM comments cm WHERE cm.change_id_fk = c.id) AS comments,
136 COALESCE((
94 SELECT array_agg(pc.change_id)
137+ SELECT array_agg(pp.change_id)
138 FROM change_edges e
96 JOIN changes pc ON pc.id = e.child_change
97 WHERE e.parent_change = c.id
98 ), '{}') AS children
139+ JOIN changes pp ON pp.id = e.parent_change
140+ WHERE e.child_change = c.id
141+ ), '{{}}') AS parents
142 FROM changes c
143 LEFT JOIN users u ON u.id = c.author_user_id
144 LEFT JOIN revisions hr ON hr.id = c.head_revision_id
145 WHERE c.repo_id = $1
103 AND ($2 = 'all' OR c.state::text = $2)",
146+ AND ({})",
147+ state_predicate(state_filter)
148 );
149
150 if let Some(frag) = &revset_sql {
@@ -109,24 +153,23 @@
153 }
154 sql.push_str(" ORDER BY c.updated_at DESC LIMIT 100");
155
112 let mut query = sqlx::query_as::<
113 _,
114 (
115 i64,
116 String,
117 bool,
118 String,
119 String,
120 bool,
121 chrono::DateTime<chrono::Utc>,
122 Option<String>,
123 Option<String>,
124 i64,
125 Vec<String>,
126 ),
127 >(&sql)
128 .bind(repo_id)
129 .bind(state_filter);
156+ type Row = (
157+ i64,
158+ String,
159+ bool,
160+ String,
161+ String,
162+ bool,
163+ chrono::DateTime<chrono::Utc>,
164+ Option<String>,
165+ Option<String>,
166+ Option<String>,
167+ i64,
168+ i64,
169+ Vec<String>,
170+ );
171+
172+ let mut query = sqlx::query_as::<_, Row>(&sql).bind(repo_id).bind(viewer);
173
174 for v in revset_vals {
175 query = query.bind(v);
@@ -147,8 +190,10 @@
190 updated_at,
191 author,
192 author_name,
193+ head_rev,
194 revcount,
151 children,
195+ comments,
196+ parents,
197 )| {
198 v::ChangeRow {
199 number,
@@ -160,14 +205,161 @@
205 updated_at,
206 author,
207 author_name,
208+ head_rev,
209 revision_count: revcount,
164 children,
210+ comments,
211+ parents,
212+ reviewers: Vec::new(),
213+ diffstat: None,
214+ depth: 0,
215+ stack_size: 0,
216 }
217 },
218 )
219 .collect())
220}
221
222+/// Fill in the two columns that need more than the changes table: who has
223+/// reviewed each row, and how big its diff is.
224+///
225+/// Both are best-effort. They are decoration on a list whose job is to link to
226+/// changes, and neither is worth turning a 200 into a 500 over.
227+async fn decorate(state: &AppState, ctx: &RepoContext, rows: &mut [v::ChangeRow]) {
228+ if rows.is_empty() {
229+ return;
230+ }
231+
232+ // One query for every reviewer of every row. `DISTINCT ON` keeps only each
233+ // reviewer's most recent verdict per change — an approval followed by a
234+ // rejection is one reviewer with one current position, not two marks.
235+ let numbers: Vec<i64> = rows.iter().map(|r| r.number).collect();
236+ let reviews: Vec<(i64, String, String, bool)> = sqlx::query_as(
237+ r#"
238+ SELECT DISTINCT ON (c.number, rv.reviewer_id)
239+ c.number,
240+ u.handle::text,
241+ rv.verdict::text,
242+ (rv.revision_id = c.head_revision_id) AS at_head
243+ FROM reviews rv
244+ JOIN changes c ON c.id = rv.change_id_fk
245+ JOIN users u ON u.id = rv.reviewer_id
246+ WHERE c.repo_id = $1 AND c.number = ANY($2)
247+ ORDER BY c.number, rv.reviewer_id, rv.created_at DESC
248+ "#,
249+ )
250+ .bind(ctx.repo.id)
251+ .bind(&numbers)
252+ .fetch_all(&state.db)
253+ .await
254+ .unwrap_or_default();
255+
256+ for (number, handle, verdict, at_head) in reviews {
257+ if let Some(row) = rows.iter_mut().find(|r| r.number == number) {
258+ row.reviewers.push(v::Reviewer { handle, verdict, at_head });
259+ }
260+ }
261+
262+ // One repository open for the whole page, not one per row.
263+ let revs: Vec<df_store::RevId> = rows
264+ .iter()
265+ .filter_map(|r| r.head_rev.as_deref())
266+ .map(df_store::RevId::from_stored)
267+ .collect();
268+
269+ if revs.is_empty() {
270+ return;
271+ }
272+
273+ let stats = state
274+ .store
275+ .diff_stats(ctx.store_id(), &revs)
276+ .await
277+ .unwrap_or_default();
278+
279+ let mut stats = stats.into_iter();
280+ for row in rows.iter_mut().filter(|r| r.head_rev.is_some()) {
281+ row.diffstat = stats.next().flatten();
282+ }
283+}
284+
285+/// The number behind each filter tab.
286+///
287+/// Counted with the same predicates the tabs filter by, so a tab that says 3
288+/// shows 3 rows. Deliberately *not* narrowed by the active revset: the counts
289+/// are how you decide where to go next, and a revset that matches nothing would
290+/// otherwise blank out every tab and leave no way back.
291+async fn list_counts(
292+ state: &AppState,
293+ repo_id: Uuid,
294+ viewer: Option<&df_db::models::User>,
295+) -> AppResult<v::ListCounts> {
296+ let sql = format!(
297+ r#"
298+ SELECT
299+ count(*) FILTER (WHERE {open}) AS open,
300+ count(*) FILTER (WHERE {conflicted}) AS conflicted,
301+ count(*) FILTER (WHERE {merged}) AS merged,
302+ count(*) FILTER (WHERE {abandoned}) AS abandoned,
303+ count(*) FILTER (WHERE {mine}) AS mine
304+ FROM changes c
305+ WHERE c.repo_id = $1
306+ "#,
307+ open = state_predicate("open"),
308+ conflicted = state_predicate("conflicted"),
309+ merged = state_predicate("merged"),
310+ abandoned = state_predicate("abandoned"),
311+ mine = state_predicate("mine"),
312+ );
313+
314+ Ok(sqlx::query_as(&sql)
315+ .bind(repo_id)
316+ .bind(viewer.map(|u| u.id))
317+ .fetch_one(&state.db)
318+ .await?)
319+}
320+
321+/// The aside's weekly numbers.
322+///
323+/// "Median time to first review" is computed from the event log: the gap
324+/// between a change being opened and the first `change.reviewed` event on it.
325+/// Only changes that have actually been reviewed count — including the
326+/// unreviewed ones as an infinite wait would be more honest but unplottable,
327+/// and including them as zero would be a lie.
328+async fn week_stats(state: &AppState, repo_id: Uuid) -> AppResult<v::WeekStats> {
329+ Ok(sqlx::query_as(
330+ r#"
331+ WITH firsts AS (
332+ SELECT e.subject_id,
333+ min(e.created_at) AS first_review
334+ FROM events e
335+ WHERE e.repo_id = $1
336+ AND e.subject_type = 'change'
337+ AND e.kind = 'change.reviewed'
338+ AND e.created_at > now() - interval '7 days'
339+ GROUP BY e.subject_id
340+ )
341+ SELECT
342+ (SELECT count(*) FROM changes
343+ WHERE repo_id = $1 AND state = 'merged'
344+ AND merged_at > now() - interval '7 days') AS merged,
345+ (SELECT count(*) FROM changes
346+ WHERE repo_id = $1
347+ AND created_at > now() - interval '7 days') AS opened,
348+ (SELECT count(*) FROM events
349+ WHERE repo_id = $1 AND kind = 'change.resolved'
350+ AND created_at > now() - interval '7 days') AS resolved,
351+ (SELECT (percentile_cont(0.5) WITHIN GROUP (
352+ ORDER BY EXTRACT(EPOCH FROM (f.first_review - c.created_at)) / 60
353+ ))::bigint
354+ FROM firsts f
355+ JOIN changes c ON c.id = f.subject_id) AS median_first_review_mins
356+ "#,
357+ )
358+ .bind(repo_id)
359+ .fetch_one(&state.db)
360+ .await?)
361+}
362+
363pub struct ChangeRecord {
364 pub id: Uuid,
365 pub number: i64,
@@ -178,6 +370,8 @@
370 pub state: String,
371 pub conflicted: bool,
372 pub target_bookmark: String,
373+ pub created_at: chrono::DateTime<chrono::Utc>,
374+ pub updated_at: chrono::DateTime<chrono::Utc>,
375}
376
377pub enum Resolution {
@@ -187,7 +381,19 @@
381 None,
382}
383
190type ChangeTuple = (Uuid, i64, String, bool, String, String, String, bool, String);
384+type ChangeTuple = (
385+ Uuid,
386+ i64,
387+ String,
388+ bool,
389+ String,
390+ String,
391+ String,
392+ bool,
393+ String,
394+ chrono::DateTime<chrono::Utc>,
395+ chrono::DateTime<chrono::Utc>,
396+);
397
398fn to_record(t: ChangeTuple) -> ChangeRecord {
399 ChangeRecord {
@@ -200,11 +406,14 @@
406 state: t.6,
407 conflicted: t.7,
408 target_bookmark: t.8,
409+ created_at: t.9,
410+ updated_at: t.10,
411 }
412}
413
414const SELECT_CHANGE: &str = "SELECT id, number, change_id, synthetic, title, description,
207 state::text, conflicted, target_bookmark
415+ state::text, conflicted, target_bookmark,
416+ created_at, updated_at
417 FROM changes";
418
419pub async fn resolve_change(state: &AppState, repo_id: Uuid, reference: &str) -> AppResult<Resolution> {
@@ -287,17 +496,17 @@
496 .await?;
497
498 let body = maud::html! {
290 (rv::header(&ctx, "changes"))
499 (v::new_change_form(&ctx, &csrf, &candidates, &bookmarks, q.error.as_deref()))
500 };
501
294 Ok(views::page(
502+ Ok(views::page_with_bar(
503 Chrome {
504 title: &format!("Open a change · {}/{}", ctx.owner, ctx.repo.name),
505 user: user.as_deref(),
506 csrf: &csrf,
507 nonce: &nonce,
508 },
509+ rv::header(&ctx, "changes"),
510 body,
511 )
512 .into_response())
crates/df-web/src/routes/edit.rs+2 −2
@@ -100,7 +100,6 @@
100 };
101
102 let body = maud::html! {
103 (rv::header(&ctx, "code"))
103 (v::editor(&ctx, v::Editor {
104 bookmark: &bookmark,
105 tip: tip.as_str(),
@@ -113,13 +112,14 @@
112 }))
113 };
114
116 Ok(views::page(
115+ Ok(views::page_with_bar(
116 Chrome {
117 title: &format!("Editing {} · {}/{}", p.path, ctx.owner, ctx.repo.name),
118 user: user.as_deref(),
119 csrf: &csrf,
120 nonce: &nonce,
121 },
122+ rv::header(&ctx, "code"),
123 // The one page that loads the editor bundle.
124 maud::html! {
125 (body)
crates/df-web/src/routes/home.rs+177 −21
@@ -6,7 +6,9 @@
6
7use crate::error::AppResult;
8use crate::state::{AppState, CsrfToken, CurrentUser, Nonce};
9use crate::views::pages::{ActivityItem, Dashboard, DashChange, FeedItem, RepoSummary};
9+use crate::views::pages::{
10+ ActivityItem, BookmarkItem, Dashboard, DashChange, FeedItem, RepoSummary, StackItem,
11+};
12use crate::views::{self, Chrome};
13
14/// Repos the viewer can reach: their own, plus any they collaborate on, plus
@@ -40,12 +42,30 @@
42 Nonce(nonce): Nonce,
43) -> AppResult<Response> {
44 let Some(user) = user else {
43 let hint = state.config.https_clone_url("your-org", "your-repo");
45 let feed = public_feed(&state).await?;
46 let repos = public_repos(&state).await?;
46 return Ok(views::page(
47+ let in_flight = public_in_flight(&state).await?;
48+ let bookmarks = public_bookmarks(&state).await?;
49+
50+ // The clone line names a repository the visitor can actually clone
51+ // when there is one, and only falls back to a placeholder on an
52+ // instance with nothing public in it.
53+ let sample = repos.first().map(|r| format!("{}/{}", r.owner, r.name));
54+ let hint = match repos.first() {
55+ Some(r) => state.config.https_clone_url(&r.owner, &r.name),
56+ None => state.config.https_clone_url("your-org", "your-repo"),
57+ };
58+
59+ return Ok(views::page_full(
60 Chrome { title: "Dogfood", user: None, csrf: &csrf, nonce: &nonce },
48 views::pages::landing(&format!("jj git clone {hint}"), &feed, &repos),
61+ views::pages::landing(views::pages::Landing {
62+ clone_hint: &format!("jj git clone {hint}"),
63+ sample_repo: sample.as_deref(),
64+ feed: &feed,
65+ repos: &repos,
66+ in_flight: &in_flight,
67+ bookmarks: &bookmarks,
68+ }),
69 )
70 .into_response());
71 };
@@ -96,21 +116,53 @@
116
117// ─── queries ─────────────────────────────────────────────────────────────────
118
99type RepoRow = (String, String, Option<String>, bool);
119+type RepoRow = (
120+ String,
121+ String,
122+ Option<String>,
123+ bool,
124+ i64,
125+ i64,
126+ Option<DateTime<Utc>>,
127+);
128
129fn to_summaries(rows: Vec<RepoRow>) -> Vec<RepoSummary> {
130 rows.into_iter()
103 .map(|(owner, name, description, private)| RepoSummary { owner, name, description, private })
131+ .map(
132+ |(owner, name, description, private, open_changes, conflicted, pushed_at)| RepoSummary {
133+ owner,
134+ name,
135+ description,
136+ private,
137+ open_changes,
138+ conflicted,
139+ pushed_at,
140+ },
141+ )
142 .collect()
143}
144
145+/// The card's counts, as correlated subqueries.
146+///
147+/// A `LEFT JOIN … GROUP BY` would need two conditional aggregates over the same
148+/// join and would still have to handle the no-changes case; two scalar
149+/// subqueries against `(repo_id, state)` are both cheaper and easier to read.
150+const REPO_CARD_COUNTS: &str = r#"
151+ (SELECT count(*) FROM changes c
152+ WHERE c.repo_id = r.id AND c.state = 'open') AS open_changes,
153+ (SELECT count(*) FROM changes c
154+ WHERE c.repo_id = r.id AND c.state = 'open' AND c.conflicted) AS conflicted,
155+ r.pushed_at
156+"#;
157+
158async fn visible_repos(state: &AppState, user: &df_db::models::User) -> AppResult<Vec<RepoSummary>> {
159 let sql = format!(
160 r#"
161 SELECT COALESCE(ou.handle, og.handle) AS owner,
162 r.name::text,
163 r.description,
113 (r.visibility = 'private') AS private
164+ (r.visibility = 'private') AS private,
165+ {REPO_CARD_COUNTS}
166 FROM repos r
167 LEFT JOIN users ou ON ou.id = r.owner_user_id
168 LEFT JOIN orgs og ON og.id = r.owner_org_id
@@ -131,32 +183,130 @@
183
184/// Public repositories, for the signed-out landing page.
185async fn public_repos(state: &AppState) -> AppResult<Vec<RepoSummary>> {
134 let rows: Vec<RepoRow> = sqlx::query_as(
186+ let rows: Vec<RepoRow> = sqlx::query_as(&format!(
187 r#"
188 SELECT COALESCE(ou.handle, og.handle) AS owner,
189 r.name::text,
190 r.description,
139 false AS private
191+ false AS private,
192+ {REPO_CARD_COUNTS}
193 FROM repos r
194 LEFT JOIN users ou ON ou.id = r.owner_user_id
195 LEFT JOIN orgs og ON og.id = r.owner_org_id
196 WHERE r.archived = false AND r.visibility = 'public'
197 ORDER BY r.pushed_at DESC NULLS LAST, r.created_at DESC
198 LIMIT 6
199+ "#
200+ ))
201+ .fetch_all(&state.db)
202+ .await?;
203+
204+ Ok(to_summaries(rows))
205+}
206+
207+/// Open public changes that are part of a stack — the landing page's
208+/// "in flight now".
209+///
210+/// "Part of a stack" is exactly "has an edge in `change_edges`": a change with
211+/// a parent or a child is one somebody is building on or building from. A lone
212+/// open change is work, but it is not a stack, and the panel is about the thing
213+/// branches cannot represent.
214+async fn public_in_flight(state: &AppState) -> AppResult<Vec<StackItem>> {
215+ /// `(owner, repo, number, change_id, synthetic, title, conflicted, updated_at)`
216+ type Row = (String, String, i64, String, bool, String, bool, DateTime<Utc>);
217+
218+ let rows: Vec<Row> = sqlx::query_as(
219+ r#"
220+ SELECT COALESCE(ou.handle, og.handle) AS owner,
221+ r.name::text,
222+ c.number,
223+ c.change_id,
224+ c.synthetic,
225+ c.title,
226+ c.conflicted,
227+ c.updated_at
228+ FROM changes c
229+ JOIN repos r ON r.id = c.repo_id
230+ LEFT JOIN users ou ON ou.id = r.owner_user_id
231+ LEFT JOIN orgs og ON og.id = r.owner_org_id
232+ WHERE r.archived = false
233+ AND r.visibility = 'public'
234+ AND c.state = 'open'
235+ AND EXISTS (
236+ SELECT 1 FROM change_edges e
237+ WHERE e.child_change = c.id OR e.parent_change = c.id
238+ )
239+ ORDER BY c.updated_at DESC
240+ LIMIT 4
241+ "#,
242+ )
243+ .fetch_all(&state.db)
244+ .await?;
245+
246+ Ok(rows
247+ .into_iter()
248+ .map(
249+ |(owner, repo, number, change_id, synthetic, title, conflicted, when)| StackItem {
250+ owner,
251+ repo,
252+ number,
253+ change_id,
254+ synthetic,
255+ title,
256+ conflicted,
257+ when,
258+ },
259+ )
260+ .collect())
261+}
262+
263+/// Recently moved bookmarks across public repositories.
264+async fn public_bookmarks(state: &AppState) -> AppResult<Vec<BookmarkItem>> {
265+ let rows: Vec<(String, String, String, bool, DateTime<Utc>)> = sqlx::query_as(
266+ r#"
267+ SELECT COALESCE(ou.handle, og.handle) AS owner,
268+ r.name::text,
269+ b.name AS bookmark,
270+ b.protected,
271+ b.updated_at
272+ FROM bookmarks b
273+ JOIN repos r ON r.id = b.repo_id
274+ LEFT JOIN users ou ON ou.id = r.owner_user_id
275+ LEFT JOIN orgs og ON og.id = r.owner_org_id
276+ WHERE r.archived = false AND r.visibility = 'public'
277+ ORDER BY b.updated_at DESC
278+ LIMIT 5
279 "#,
280 )
281 .fetch_all(&state.db)
282 .await?;
283
151 Ok(to_summaries(rows))
284+ Ok(rows
285+ .into_iter()
286+ .map(|(owner, repo, name, protected, updated_at)| BookmarkItem {
287+ owner,
288+ repo,
289+ name,
290+ protected,
291+ updated_at,
292+ })
293+ .collect())
294}
295
296/// The "shipping right now" feed.
297///
298/// Public repositories only, and no drafts: this renders for anonymous
299/// visitors, so anything it can reach is world-readable by definition.
300+///
301+/// Driven by the event log rather than by `changes.updated_at`, so each row can
302+/// state what happened. The join to `changes` is an inner join on
303+/// `subject_type = 'change'`, which also drops repository- and bookmark-scoped
304+/// events — the feed is about work, and "somebody renamed a bookmark" is not
305+/// what a visitor came to see.
306async fn public_feed(state: &AppState) -> AppResult<Vec<FeedItem>> {
159 let rows: Vec<(
307+ /// `(owner, repo, number, change_id, synthetic, title, actor, author_name,
308+ /// kind, created_at)`
309+ type Row = (
310 String,
311 String,
312 i64,
@@ -165,8 +315,11 @@
315 String,
316 Option<String>,
317 Option<String>,
318+ String,
319 DateTime<Utc>,
169 )> = sqlx::query_as(
320+ );
321+
322+ let rows: Vec<Row> = sqlx::query_as(
323 // `hr.author_name` is the fallback when no account matched the commit's
324 // email — the person is still known, just not linkable.
325 r#"
@@ -176,19 +329,21 @@
329 c.change_id,
330 c.synthetic,
331 c.title,
179 au.handle AS author,
332+ ac.handle AS actor,
333 hr.author_name,
181 c.updated_at
182 FROM changes c
183 JOIN repos r ON r.id = c.repo_id
334+ e.kind,
335+ e.created_at
336+ FROM events e
337+ JOIN changes c ON c.id = e.subject_id AND e.subject_type = 'change'
338+ JOIN repos r ON r.id = e.repo_id
339 LEFT JOIN users ou ON ou.id = r.owner_user_id
340 LEFT JOIN orgs og ON og.id = r.owner_org_id
186 LEFT JOIN users au ON au.id = c.author_user_id
341+ LEFT JOIN users ac ON ac.id = e.actor_id
342 LEFT JOIN revisions hr ON hr.id = c.head_revision_id
343 WHERE r.archived = false
344 AND r.visibility = 'public'
345 AND c.state <> 'draft'
191 ORDER BY c.updated_at DESC
346+ ORDER BY e.created_at DESC
347 LIMIT 12
348 "#,
349 )
@@ -198,7 +353,7 @@
353 Ok(rows
354 .into_iter()
355 .map(
201 |(owner, repo, number, change_id, synthetic, title, author, author_name, when)| {
356+ |(owner, repo, number, change_id, synthetic, title, actor, actor_name, kind, when)| {
357 FeedItem {
358 owner,
359 repo,
@@ -206,8 +361,9 @@
361 change_id,
362 synthetic,
363 title,
209 author,
210 author_name,
364+ actor,
365+ actor_name,
366+ kind,
367 when,
368 }
369 },
crates/df-web/src/routes/issue.rs+6 −6
@@ -48,7 +48,6 @@
48 let all_labels = load_labels(&state, ctx.repo.id).await?;
49
50 let body = maud::html! {
51 (rv::header(&ctx, "issues"))
51 (v::list(&ctx, &rows, v::ListFilters {
52 state: state_filter,
53 label,
@@ -57,13 +56,14 @@
56 }))
57 };
58
60 Ok(views::page(
59+ Ok(views::page_with_bar(
60 Chrome {
61 title: &format!("Issues · {}/{}", ctx.owner, ctx.repo.name),
62 user: user.as_deref(),
63 csrf: &csrf,
64 nonce: &nonce,
65 },
66+ rv::header(&ctx, "issues"),
67 body,
68 )
69 .into_response())
@@ -91,17 +91,17 @@
91 let labels = load_labels(&state, ctx.repo.id).await?;
92
93 let body = maud::html! {
94 (rv::header(&ctx, "issues"))
94 (v::new_form(&ctx, v::NewIssue { csrf: &csrf, labels: &labels, error: q.error.as_deref() }))
95 };
96
98 Ok(views::page(
97+ Ok(views::page_with_bar(
98 Chrome {
99 title: &format!("New issue · {}/{}", ctx.owner, ctx.repo.name),
100 user: user.as_deref(),
101 csrf: &csrf,
102 nonce: &nonce,
103 },
104+ rv::header(&ctx, "issues"),
105 body,
106 )
107 .into_response())
@@ -232,7 +232,6 @@
232 let body_html = render(&ctx, &issue.body);
233
234 let body = maud::html! {
235 (rv::header(&ctx, "issues"))
235 (v::detail(&ctx, v::Detail {
236 number: issue.number,
237 title: &issue.title,
@@ -251,13 +250,14 @@
250 }))
251 };
252
254 Ok(views::page(
253+ Ok(views::page_with_bar(
254 Chrome {
255 title: &format!("{} · {}/{}", issue.title, ctx.owner, ctx.repo.name),
256 user: user.as_deref(),
257 csrf: &csrf,
258 nonce: &nonce,
259 },
260+ rv::header(&ctx, "issues"),
261 body,
262 )
263 .into_response())
crates/df-web/src/routes/repo.rs+239 −10
@@ -1,5 +1,6 @@
1//! Repository browsing (M2) and creation.
2
3+use std::collections::HashMap;
4use std::path::Path;
5
6use axum::extract::{Path as UrlPath, Query, State};
@@ -69,12 +70,127 @@
70
71 let readme = render_readme(&state, &ctx, &rev, &entries).await;
72 let tip = tip_commit(&state, &ctx, &rev).await;
72 v::tree_listing(&ctx, &rev_label, "", &entries, tip.as_ref(), readme.as_ref())
73+ let last_commits = last_commits_for(&state, &ctx, &rev, Path::new(""), &entries).await;
74+
75+ let marks = bookmark_rows(&state, &ctx).await?;
76+ let (contributors, size_bytes) = repo_vitals(&state, &ctx).await;
77+ let https = state.config.https_clone_url(&ctx.owner, &ctx.repo.name);
78+ let ssh = state.config.ssh_clone_url(&ctx.owner, &ctx.repo.name);
79+ let sidebar = v::RepoSidebar {
80+ https: &https,
81+ ssh: &ssh,
82+ open_changes: ctx.nav.open_changes,
83+ conflicted: ctx.nav.conflicted,
84+ open_issues: ctx.nav.open_issues,
85+ contributors,
86+ size_bytes,
87+ bookmarks: &marks,
88+ };
89+
90+ v::tree_listing(
91+ &ctx,
92+ v::Tree {
93+ rev_label: &rev_label,
94+ path: "",
95+ entries: &entries,
96+ tip: tip.as_ref(),
97+ readme: readme.as_ref(),
98+ history: &last_commits,
99+ sidebar: Some(&sidebar),
100+ },
101+ )
102 };
103
104 Ok(render(&ctx, "code", &csrf, &nonce, user.as_deref(), body))
105}
106
107+/// Bookmarks with the change each one points at.
108+///
109+/// The `LATERAL` subquery is scoped to the bookmark's own repository: a
110+/// revision id is only unique *within* a repository, so joining `revisions` on
111+/// `rev` alone would let one repository's bookmark resolve to another's change.
112+/// `(name, protected, updated_at, change_id, number, title)`
113+type BookmarkTuple = (
114+ String,
115+ bool,
116+ chrono::DateTime<chrono::Utc>,
117+ Option<String>,
118+ Option<i64>,
119+ Option<String>,
120+);
121+
122+async fn bookmark_rows(state: &AppState, ctx: &RepoContext) -> AppResult<Vec<v::MarkRow>> {
123+ let rows: Vec<BookmarkTuple> = sqlx::query_as(
124+ r#"
125+ SELECT b.name,
126+ b.protected,
127+ b.updated_at,
128+ c.change_id,
129+ c.number,
130+ c.title
131+ FROM bookmarks b
132+ LEFT JOIN LATERAL (
133+ SELECT ch.change_id, ch.number, ch.title
134+ FROM revisions r
135+ JOIN changes ch ON ch.id = r.change_id_fk
136+ WHERE ch.repo_id = b.repo_id AND r.rev = b.target
137+ LIMIT 1
138+ ) c ON true
139+ WHERE b.repo_id = $1
140+ ORDER BY b.updated_at DESC
141+ "#,
142+ )
143+ .bind(ctx.repo.id)
144+ .fetch_all(&state.db)
145+ .await?;
146+
147+ Ok(rows
148+ .into_iter()
149+ .map(
150+ |(name, protected, updated_at, change_id, number, title)| v::MarkRow {
151+ name,
152+ protected,
153+ updated_at,
154+ change_id,
155+ number,
156+ title,
157+ },
158+ )
159+ .collect())
160+}
161+
162+/// Contributor count and on-disk size for the sidebar.
163+///
164+/// Best-effort on both counts: the sidebar is context, and neither number is
165+/// worth failing a page render over. Contributors are distinct commit *emails*
166+/// rather than linked accounts — somebody who has pushed but never signed in is
167+/// still a contributor.
168+async fn repo_vitals(state: &AppState, ctx: &RepoContext) -> (i64, u64) {
169+ let contributors: i64 = sqlx::query_scalar(
170+ r#"
171+ SELECT count(DISTINCT r.author_email)
172+ FROM revisions r
173+ JOIN changes c ON c.id = r.change_id_fk
174+ WHERE c.repo_id = $1
175+ "#,
176+ )
177+ .bind(ctx.repo.id)
178+ .fetch_one(&state.db)
179+ .await
180+ .unwrap_or(0);
181+
182+ // The stored size is what the last indexer pass recorded; asking the store
183+ // is exact but walks the directory, so it is the fallback rather than the
184+ // first choice on a page that renders on every visit.
185+ let size = if ctx.repo.size_bytes > 0 {
186+ ctx.repo.size_bytes as u64
187+ } else {
188+ state.store.size_bytes(ctx.store_id()).await.unwrap_or(0)
189+ };
190+
191+ (contributors, size)
192+}
193+
194#[derive(Deserialize)]
195pub struct RevPath {
196 pub owner: String,
@@ -113,8 +229,23 @@
229 };
230
231 let tip = tip_commit(&state, &ctx, &rev).await;
232+ let last_commits =
233+ last_commits_for(&state, &ctx, &rev, Path::new(&p.path), &entries).await;
234
117 let body = v::tree_listing(&ctx, &p.rev, &p.path, &entries, tip.as_ref(), readme.as_ref());
235+ // No sidebar below the root: the reader is looking at files, and repeating
236+ // the clone commands beside every folder is noise.
237+ let body = v::tree_listing(
238+ &ctx,
239+ v::Tree {
240+ rev_label: &p.rev,
241+ path: &p.path,
242+ entries: &entries,
243+ tip: tip.as_ref(),
244+ readme: readme.as_ref(),
245+ history: &last_commits,
246+ sidebar: None,
247+ },
248+ );
249 Ok(render(&ctx, "code", &csrf, &nonce, user.as_deref(), body))
250}
251
@@ -132,12 +263,103 @@
263
264 Some(v::TipCommit {
265 author: r.author.name.clone(),
266+ author_handle: handle_for_email(state, &r.author.email).await,
267 summary: r.summary().to_string(),
268 when: r.author.when,
269 change_id: r.change_id.clone(),
270 })
271}
272
273+/// The last commit that touched each entry of a directory listing.
274+///
275+/// One `last_commits_in_dir` call resolves the whole directory (see its docs
276+/// for why that beats a lookup per file). Best-effort like `tip_commit`: a
277+/// store that cannot walk history still renders the listing, just without
278+/// this column.
279+async fn last_commits_for(
280+ state: &AppState,
281+ ctx: &RepoContext,
282+ rev: &df_store::RevId,
283+ dir: &Path,
284+ entries: &[df_store::TreeEntry],
285+) -> HashMap<String, v::EntryHistory> {
286+ let names: Vec<String> = entries.iter().map(|e| e.name.clone()).collect();
287+
288+ let revisions = state
289+ .store
290+ .last_commits_in_dir(ctx.store_id(), rev, dir, &names)
291+ .await
292+ .unwrap_or_default();
293+
294+ revisions
295+ .into_iter()
296+ .map(|(name, r)| {
297+ (
298+ name,
299+ v::EntryHistory {
300+ summary: r.summary().to_string(),
301+ when: r.author.when,
302+ change_id: r.change_id,
303+ },
304+ )
305+ })
306+ .collect()
307+}
308+
309+/// The account that owns a commit-author email, if any.
310+///
311+/// The same rule the indexer attributes changes by, applied to a commit read
312+/// straight from the store rather than from the index. Best-effort: a lookup
313+/// failure renders the commit's own name rather than failing the page.
314+pub(crate) async fn handle_for_email(state: &AppState, email: &str) -> Option<String> {
315+ let email = email.trim();
316+ if email.is_empty() {
317+ return None;
318+ }
319+ sqlx::query_scalar::<_, String>("SELECT handle::text FROM users WHERE email = $1")
320+ .bind(email)
321+ .fetch_optional(&state.db)
322+ .await
323+ .ok()
324+ .flatten()
325+}
326+
327+/// The same lookup for a whole listing, in one round trip.
328+///
329+/// A page of 200 commits must not become 200 queries. Returns email → handle
330+/// for the ones that matched; callers fall back to the commit's own name.
331+pub(crate) async fn handles_for_emails<'a>(
332+ state: &AppState,
333+ emails: impl IntoIterator<Item = &'a str>,
334+) -> HashMap<String, String> {
335+ let mut wanted: Vec<String> = emails
336+ .into_iter()
337+ .map(str::trim)
338+ .filter(|e| !e.is_empty())
339+ .map(str::to_owned)
340+ .collect();
341+ wanted.sort();
342+ wanted.dedup();
343+
344+ if wanted.is_empty() {
345+ return HashMap::new();
346+ }
347+
348+ // `email` is citext, so the join is case-insensitive without lowering here.
349+ sqlx::query_as::<_, (String, String)>(
350+ "SELECT email::text, handle::text FROM users WHERE email = ANY($1)",
351+ )
352+ .bind(&wanted)
353+ .fetch_all(&state.db)
354+ .await
355+ .unwrap_or_else(|e| {
356+ tracing::warn!("resolving commit authors failed: {e}");
357+ Vec::new()
358+ })
359+ .into_iter()
360+ .collect()
361+}
362+
363/// Is this path a markdown document?
364///
365/// Extension-based on purpose: sniffing content would mean a file that happens
@@ -199,6 +421,11 @@
421 .ok()
422 .flatten();
423
424+ let last_commit_handle = match &last_commit {
425+ Some(c) => handle_for_email(&state, &c.author.email).await,
426+ None => None,
427+ };
428+
429 // Optionally fetch blame.
430 let blame_lines = if wants_blame {
431 state
@@ -252,6 +479,7 @@
479 sidebar_dir: &tree_dir,
480 symbols: &symbols,
481 last_commit: last_commit.as_ref(),
482+ last_commit_handle: last_commit_handle.as_deref(),
483 blame: blame_lines.as_deref(),
484 wants_blame,
485 };
@@ -390,7 +618,11 @@
618 .await
619 .map_err(store_err)?;
620
393 let body = v::log_view(&ctx, &rev_label, &revs);
621+ // One query for every author in the page rather than one per commit.
622+ let handles =
623+ handles_for_emails(&state, revs.iter().map(|r| r.author.email.as_str())).await;
624+
625+ let body = v::log_view(&ctx, &rev_label, &revs, &handles);
626 Ok(render(&ctx, "code", &csrf, &nonce, user.as_deref(), body))
627}
628
@@ -403,11 +635,7 @@
635 Nonce(nonce): Nonce,
636) -> AppResult<Response> {
637 let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
406 let marks = state
407 .store
408 .bookmarks(ctx.store_id())
409 .await
410 .unwrap_or_default();
638+ let marks = bookmark_rows(&state, &ctx).await?;
639
640 let body = v::bookmarks_view(&ctx, &marks);
641 Ok(render(&ctx, "bookmarks", &csrf, &nonce, user.as_deref(), body))
@@ -670,14 +898,15 @@
898 user: Option<&df_db::models::User>,
899 body: maud::Markup,
900) -> Response {
673 views::page(
901+ views::page_with_bar(
902 Chrome {
903 title: &format!("{}/{}", ctx.owner, ctx.repo.name),
904 user,
905 csrf,
906 nonce,
907 },
680 maud::html! { (v::header(ctx, tab)) (body) },
908+ v::header(ctx, tab),
909+ body,
910 )
911 .into_response()
912}
crates/df-web/src/routes/repo_settings.rs+2 −2
@@ -51,7 +51,6 @@
51 let tab = q.tab.as_deref().unwrap_or("general").to_owned();
52
53 let body = maud::html! {
54 (crate::views::repo::header(&ctx, "settings"))
54 (v::repo_settings(v::RepoSettings {
55 ctx: &ctx,
56 csrf: &csrf,
@@ -64,13 +63,14 @@
63 }))
64 };
65
67 Ok(views::page(
66+ Ok(views::page_with_bar(
67 Chrome {
68 title: &format!("{}/{} settings", ctx.owner, ctx.repo.name),
69 user: user.as_deref(),
70 csrf: &csrf,
71 nonce: &nonce,
72 },
73+ crate::views::repo::header(&ctx, "settings"),
74 body,
75 )
76 .into_response())
crates/df-web/src/routes/review.rs+256 −34
@@ -40,6 +40,9 @@
40 author: Option<String>,
41 author_name: Option<String>,
42 can_manage: bool,
43+ comment_count: i64,
44+ /// The right-hand column, identical on every tab.
45+ aside: v::ChangeAside,
46}
47
48impl Loaded {
@@ -65,11 +68,11 @@
68 Resolution::One(c) => *c,
69 Resolution::Ambiguous(candidates) => {
70 let body = maud::html! {
68 (rv::header(&ctx, "changes"))
71 (cv::ambiguous(&ctx, reference, &candidates))
72 };
71 return Ok(Err(views::page(
73+ return Ok(Err(views::page_with_bar(
74 Chrome { title: "Ambiguous change id", user, csrf, nonce },
75+ rv::header(&ctx, "changes"),
76 body,
77 )
78 .into_response()));
@@ -106,9 +109,118 @@
109 let is_author = matches!((user, &author), (Some(u), Some(a)) if &u.handle == a);
110 let can_manage = ctx.access.can_manage_changes() || is_author;
111
109 Ok(Ok(Loaded { ctx, change, revisions, author, author_name, can_manage }))
112+ let comment_count: i64 =
113+ sqlx::query_scalar("SELECT count(*) FROM comments WHERE change_id_fk = $1")
114+ .bind(change.id)
115+ .fetch_one(&state.db)
116+ .await?;
117+
118+ let aside = load_aside(state, &ctx, &change).await?;
119+
120+ Ok(Ok(Loaded {
121+ ctx,
122+ change,
123+ revisions,
124+ author,
125+ author_name,
126+ can_manage,
127+ comment_count,
128+ aside,
129+ }))
130}
131
132+/// Reviewers and the surrounding stack — the aside on every change tab.
133+async fn load_aside(
134+ state: &AppState,
135+ ctx: &RepoContext,
136+ change: &ChangeRecord,
137+) -> AppResult<v::ChangeAside> {
138+ // `DISTINCT ON` keeps each reviewer's most recent verdict: somebody who
139+ // approved and later requested changes has one current position, not two.
140+ let reviewers: Vec<(String, String, bool)> = sqlx::query_as(
141+ r#"
142+ SELECT DISTINCT ON (rv.reviewer_id)
143+ u.handle::text,
144+ rv.verdict::text,
145+ (rv.revision_id = c.head_revision_id) AS at_head
146+ FROM reviews rv
147+ JOIN changes c ON c.id = rv.change_id_fk
148+ JOIN users u ON u.id = rv.reviewer_id
149+ WHERE rv.change_id_fk = $1
150+ ORDER BY rv.reviewer_id, rv.created_at DESC
151+ "#,
152+ )
153+ .bind(change.id)
154+ .fetch_all(&state.db)
155+ .await?;
156+
157+ // The chain this change sits in, walked in both directions. A recursive CTE
158+ // rather than a fixed number of joins, because a stack has no maximum
159+ // depth — and `UNION` (not `UNION ALL`) is what makes a cycle in a
160+ // corrupted edge table terminate rather than run forever.
161+ let chain: Vec<(String, i64, String, bool, i32)> = sqlx::query_as(
162+ r#"
163+ WITH RECURSIVE down AS (
164+ SELECT c.id, 0 AS depth FROM changes c WHERE c.id = $1
165+ UNION
166+ SELECT p.id, d.depth - 1
167+ FROM down d
168+ JOIN change_edges e ON e.child_change = d.id
169+ JOIN changes p ON p.id = e.parent_change
170+ ),
171+ up AS (
172+ SELECT c.id, 0 AS depth FROM changes c WHERE c.id = $1
173+ UNION
174+ SELECT ch.id, u.depth + 1
175+ FROM up u
176+ JOIN change_edges e ON e.parent_change = u.id
177+ JOIN changes ch ON ch.id = e.child_change
178+ ),
179+ chain AS (
180+ SELECT id, min(depth) AS depth FROM (
181+ SELECT * FROM down UNION ALL SELECT * FROM up
182+ ) combined GROUP BY id
183+ )
184+ SELECT c.change_id, c.number, c.state::text, c.conflicted, chain.depth::int
185+ FROM chain
186+ JOIN changes c ON c.id = chain.id
187+ WHERE c.repo_id = $2
188+ ORDER BY chain.depth DESC
189+ "#,
190+ )
191+ .bind(change.id)
192+ .bind(ctx.repo.id)
193+ .fetch_all(&state.db)
194+ .await?;
195+
196+ // Depths come back relative to this change, which can make them negative.
197+ // Shift so the bottom of the stack sits at zero, because the rail indents
198+ // from there.
199+ let floor = chain.iter().map(|(_, _, _, _, d)| *d).min().unwrap_or(0);
200+
201+ Ok(v::ChangeAside {
202+ reviewers: reviewers
203+ .into_iter()
204+ .map(|(handle, verdict, at_head)| crate::views::change::Reviewer {
205+ handle,
206+ verdict,
207+ at_head,
208+ })
209+ .collect(),
210+ stack: chain
211+ .into_iter()
212+ .map(|(cid, number, st, conflicted, depth)| v::StackNodeMini {
213+ is_current: cid == change.change_id,
214+ change_id: cid,
215+ number,
216+ state: st,
217+ conflicted,
218+ depth: (depth - floor) as usize,
219+ })
220+ .collect(),
221+ })
222+}
223+
224impl Loaded {
225 fn head_view<'a>(&'a self, csrf: &'a str, can_comment: bool) -> v::ChangeHead<'a> {
226 v::ChangeHead {
@@ -122,6 +234,11 @@
234 author: self.author.as_deref(),
235 author_name: self.author_name.as_deref(),
236 revision_count: self.revisions.len(),
237+ head_commit: self.head().map(df_store::abbreviate_rev),
238+ created_at: self.change.created_at,
239+ updated_at: self.change.updated_at,
240+ file_count: None,
241+ comment_count: self.comment_count,
242 can_manage: self.can_manage,
243 can_comment,
244 csrf,
@@ -177,8 +294,8 @@
294 let description_html = crate::routes::issue::render(&l.ctx, &l.change.description);
295
296 let body = maud::html! {
180 (rv::header(&l.ctx, "changes"))
297 (v::header(&l.ctx, &head, "overview"))
298+ (v::tab_body(&l.ctx, &l.aside, maud::html! {
299 @if let Some(e) = &flash.error { div .banner.banner-error role="alert" { (e) } }
300 @if let Some(n) = &flash.notice { div .banner.banner-ok role="status" { (n) } }
301 (v::overview(&l.ctx, &head, v::Overview {
@@ -190,15 +307,17 @@
307 events: &events,
308 viewer_reviewed,
309 }))
310+ }))
311 };
312
195 Ok(views::page(
313+ Ok(views::page_with_bar(
314 Chrome {
315 title: &format!("{} · {}/{}", l.change.title, l.ctx.owner, l.ctx.repo.name),
316 user: user.as_deref(),
317 csrf: &csrf,
318 nonce: &nonce,
319 },
320+ rv::header(&l.ctx, "changes"),
321 body,
322 )
323 .into_response())
@@ -279,25 +398,28 @@
398 .filter(|c| c.anchor_path.is_some() && c.anchor_state != "orphaned")
399 .collect();
400
401+ // The tab strip can only count files once the diff has been computed.
402+ let head = v::ChangeHead { file_count: diff.as_ref().map(|d| d.files.len()), ..head };
403+
404 let body = maud::html! {
283 (rv::header(&l.ctx, "changes"))
405 (v::header(&l.ctx, &head, "files"))
285 (v::files(&l.ctx, &head, v::FilesView {
406+ (v::tab_body(&l.ctx, &l.aside, v::files(&l.ctx, &head, v::FilesView {
407 diff: diff.as_ref(),
408 comments: &comments,
409 rev: rev.as_deref().unwrap_or(""),
410 against: against.as_deref(),
411 revisions: &l.revisions,
291 }))
412+ })))
413 };
414
294 Ok(views::page(
415+ Ok(views::page_with_bar(
416 Chrome {
417 title: &format!("Files · {}", l.change.title),
418 user: user.as_deref(),
419 csrf: &csrf,
420 nonce: &nonce,
421 },
422+ rv::header(&l.ctx, "changes"),
423 body,
424 )
425 .into_response())
@@ -305,10 +427,18 @@
427
428// ─── revisions ───────────────────────────────────────────────────────────────
429
430+/// Which two revisions the interdiff compares.
431+#[derive(Deserialize, Default)]
432+pub struct CompareQuery {
433+ pub a: Option<i32>,
434+ pub b: Option<i32>,
435+}
436+
437/// `GET /{owner}/{repo}/changes/{ref}/revisions`
438pub async fn revisions(
439 State(state): State<AppState>,
440 UrlPath((owner, name, reference)): UrlPath<(String, String, String)>,
441+ Query(q): Query<CompareQuery>,
442 CurrentUser(user): CurrentUser,
443 CsrfToken(csrf): CsrfToken,
444 Nonce(nonce): Nonce,
@@ -319,7 +449,9 @@
449 };
450 let head = l.head_view(&csrf, false);
451
322 let rows: Vec<(
452+ /// `(seq, rev, message, author_name, pushed_at, conflicted, pushed_by,
453+ /// parents)`
454+ type RevRow = (
455 i32,
456 String,
457 String,
@@ -327,9 +459,12 @@
459 chrono::DateTime<chrono::Utc>,
460 bool,
461 Option<String>,
330 )> = sqlx::query_as(
462+ Vec<String>,
463+ );
464+
465+ let rows: Vec<RevRow> = sqlx::query_as(
466 "SELECT r.seq, r.rev, r.message, r.author_name, r.pushed_at, r.conflicted,
332 u.handle::text
467+ u.handle::text, r.parents
468 FROM revisions r
469 LEFT JOIN users u ON u.id = r.pushed_by
470 WHERE r.change_id_fk = $1 ORDER BY r.seq",
@@ -338,10 +473,22 @@
473 .fetch_all(&state.db)
474 .await?;
475
476+ // One repository open for every revision's diffstat, not one per row.
477+ let all: Vec<RevId> = rows
478+ .iter()
479+ .map(|r| RevId::from_stored(r.1.clone()))
480+ .collect();
481+ let stats = state
482+ .store
483+ .diff_stats(l.ctx.store_id(), &all)
484+ .await
485+ .unwrap_or_default();
486+
487 let revs: Vec<v::RevisionDetail> = rows
488 .into_iter()
489+ .enumerate()
490 .map(
344 |(seq, rev, message, author_name, pushed_at, conflicted, pushed_by)| {
491+ |(i, (seq, rev, message, author_name, pushed_at, conflicted, pushed_by, parents))| {
492 v::RevisionDetail {
493 seq,
494 rev,
@@ -350,29 +497,96 @@
497 pushed_at,
498 conflicted,
499 pushed_by,
500+ diffstat: stats.get(i).copied().flatten(),
501+ base: parents.first().map(|p| df_store::abbreviate_rev(p).to_owned()),
502 }
503 },
504 )
505 .collect();
506
507+ // Defaults: the previous revision against the head, which is the comparison
508+ // a returning reviewer wants. Out-of-range values are clamped rather than
509+ // rejected — a stale link from before a revision was removed should still
510+ // land on something sensible.
511+ let last = revs.last().map(|r| r.seq).unwrap_or(1);
512+ let first = revs.first().map(|r| r.seq).unwrap_or(1);
513+ let clamp = |n: i32| n.clamp(first, last);
514+ let b = clamp(q.b.unwrap_or(last));
515+ let a = clamp(q.a.unwrap_or((b - 1).max(first)));
516+
517+ // The interdiff itself: two revisions of the same change, diffed against
518+ // each other. This is the view a force-push destroys on a branch-based
519+ // forge, and the reason revisions are stored rather than derived.
520+ let diff = match (
521+ revs.iter().find(|r| r.seq == a),
522+ revs.iter().find(|r| r.seq == b),
523+ ) {
524+ (Some(ra), Some(rb)) if a != b => state
525+ .store
526+ .diff(
527+ l.ctx.store_id(),
528+ &RevId::from_stored(ra.rev.clone()),
529+ &RevId::from_stored(rb.rev.clone()),
530+ df_store::DiffOpts::default(),
531+ )
532+ .await
533+ .ok(),
534+ _ => None,
535+ };
536+
537 let body = maud::html! {
359 (rv::header(&l.ctx, "changes"))
538 (v::header(&l.ctx, &head, "revisions"))
361 (v::revisions(&l.ctx, &head, &revs))
539+ (v::tab_body(&l.ctx, &l.aside,
540+ v::revisions(&l.ctx, &head, &revs, v::Compare { a, b, diff: diff.as_ref() })))
541 };
542
364 Ok(views::page(
543+ Ok(views::page_with_bar(
544 Chrome {
545 title: &format!("Revisions · {}", l.change.title),
546 user: user.as_deref(),
547 csrf: &csrf,
548 nonce: &nonce,
549 },
550+ rv::header(&l.ctx, "changes"),
551 body,
552 )
553 .into_response())
554}
555
556+/// `GET /{owner}/{repo}/changes/{ref}/checks`
557+///
558+/// Renders an honest empty state — see [`views::review::checks`].
559+pub async fn checks(
560+ State(state): State<AppState>,
561+ UrlPath((owner, name, reference)): UrlPath<(String, String, String)>,
562+ CurrentUser(user): CurrentUser,
563+ CsrfToken(csrf): CsrfToken,
564+ Nonce(nonce): Nonce,
565+) -> AppResult<Response> {
566+ let l = match load(&state, &owner, &name, &reference, user.as_deref(), &csrf, &nonce).await? {
567+ Ok(l) => l,
568+ Err(res) => return Ok(res),
569+ };
570+ let head = l.head_view(&csrf, false);
571+
572+ let body = maud::html! {
573+ (v::header(&l.ctx, &head, "checks"))
574+ (v::tab_body(&l.ctx, &l.aside, v::checks(&l.ctx, &head)))
575+ };
576+
577+ Ok(views::page_with_bar(
578+ Chrome {
579+ title: &format!("Checks · {}", l.change.title),
580+ user: user.as_deref(),
581+ csrf: &csrf,
582+ nonce: &nonce,
583+ },
584+ rv::header(&l.ctx, "changes"),
585+ body,
586+ )
587+ .into_response())
588+}
589+
590// ─── conflicts ───────────────────────────────────────────────────────────────
591
592/// `GET /{owner}/{repo}/changes/{ref}/conflicts`
@@ -402,18 +616,18 @@
616 };
617
618 let body = maud::html! {
405 (rv::header(&l.ctx, "changes"))
619 (v::header(&l.ctx, &head, "conflicts"))
407 (v::conflicts(&l.ctx, &head, &files))
620+ (v::tab_body(&l.ctx, &l.aside, v::conflicts(&l.ctx, &head, &files)))
621 };
622
410 Ok(views::page(
623+ Ok(views::page_with_bar(
624 Chrome {
625 title: &format!("Conflicts · {}", l.change.title),
626 user: user.as_deref(),
627 csrf: &csrf,
628 nonce: &nonce,
629 },
630+ rv::header(&l.ctx, "changes"),
631 body,
632 )
633 .into_response())
@@ -775,30 +989,38 @@
989 return Err(AppError::NotFound);
990 }
991
992+ // Every change in a stack targets the same bookmark, so the bottom one
993+ // names what the whole chain lands on.
994+ let target: Option<String> = match nodes.first() {
995+ Some(bottom) => sqlx::query_scalar(
996+ "SELECT target_bookmark FROM changes WHERE repo_id = $1 AND number = $2",
997+ )
998+ .bind(ctx.repo.id)
999+ .bind(bottom.number)
1000+ .fetch_optional(&state.db)
1001+ .await?,
1002+ None => None,
1003+ };
1004+
1005 let body = maud::html! {
779 (rv::header(&ctx, "changes"))
780 (v::stack(&ctx, &nodes, &change_id))
781 @if nodes.len() > 1 && ctx.access.can_manage_changes() {
782 div .panel {
783 form method="post" action=(format!("{}/stacks/{change_id}/merge", ctx.base())) {
784 input type="hidden" name="_csrf" value=(csrf);
785 button .btn.btn-primary type="submit" { "Merge stack" }
786 p .hint {
787 "Lands the whole chain bottom-up in one action. If any change in \
788 the stack is conflicted or not ready, nothing is merged."
789 }
790 }
791 }
792 }
1006+ (v::stack(
1007+ &ctx,
1008+ &nodes,
1009+ &change_id,
1010+ target.as_deref(),
1011+ &csrf,
1012+ ctx.access.can_manage_changes(),
1013+ ))
1014 };
1015
795 Ok(views::page(
1016+ Ok(views::page_with_bar(
1017 Chrome {
1018 title: &format!("Stack · {}/{}", ctx.owner, ctx.repo.name),
1019 user: user.as_deref(),
1020 csrf: &csrf,
1021 nonce: &nonce,
1022 },
1023+ rv::header(&ctx, "changes"),
1024 body,
1025 )
1026 .into_response())
crates/df-web/src/routes/search.rs+42 −26
@@ -27,6 +27,10 @@
27 /// `repos` | `changes` | `issues`. Absent means all three.
28 #[serde(rename = "type")]
29 pub kind: Option<String>,
30+ /// `1` when the ⌘K palette is asking. Returns the results list on its own,
31+ /// with no page chrome, so the palette and the full page cannot disagree
32+ /// about what the viewer is allowed to see.
33+ pub fragment: Option<String>,
34}
35
36/// The visibility predicate, shared by all three queries.
@@ -97,50 +101,62 @@
101
102 let total = repos.len() + changes.len() + issues.len();
103
104+ // The palette asks for the same results without the page around them.
105+ // Short queries fall back to the standing command list rather than an
106+ // empty box, so the overlay is never blank.
107+ if q.fragment.as_deref() == Some("1") {
108+ let body = if raw.chars().count() < 2 {
109+ views::layout::palette_hint()
110+ } else {
111+ views::layout::palette_results(&repos, &changes, &issues, raw)
112+ };
113+ return Ok(body.into_response());
114+ }
115+
116 Ok(views::page(
117 Chrome { title: "Search", user: user.as_deref(), csrf: &csrf, nonce: &nonce },
118 maud::html! {
103 div .panel {
119+ div .page-head {
120 h1 { "Search" }
105 form method="get" action="/search" .row style="gap:8px" {
106 input type="search" name="q" value=(raw) autofocus
107 placeholder="repositories, changes and issues"
108 aria-label="Search query" style="flex:1";
109 select name="type" aria-label="Result type" {
110 @for (key, label) in [("all", "everything"), ("repos", "repositories"),
111 ("changes", "changes"), ("issues", "issues")] {
112 option value=(key) selected[kind == key] { (label) }
113 }
114 }
115 button .btn.btn-primary type="submit" { "Search" }
116 }
117 p .hint {
121+ span .band-note {
122 "Titles, descriptions and bodies. Code search is not part of v1 — \
123 clone the repository and use " code { "jj" } " or " code { "grep" } "."
124 }
125 }
126
127+ form .revset-bar method="get" action="/search" {
128+ label .revset-tag for="q" { "find" }
129+ input #q type="text" name="q" value=(raw) autofocus
130+ placeholder="repositories, changes and issues"
131+ aria-label="Search query";
132+ select name="type" aria-label="Result type" {
133+ @for (key, label) in [("all", "everything"), ("repos", "repositories"),
134+ ("changes", "changes"), ("issues", "issues")] {
135+ option value=(key) selected[kind == key] { (label) }
136+ }
137+ }
138+ button .btn.btn-mono type="submit" { "search" }
139+ }
140+
141 @if !raw.is_empty() && total == 0 {
124 div .panel { div .empty {
142+ div .empty {
143 h2 { "No results" }
144 p { "Nothing you can see matches that." }
127 } }
145+ }
146 }
147
148 @for (heading, hits) in [("Repositories", &repos), ("Changes", &changes), ("Issues", &issues)] {
149 @if !hits.is_empty() {
132 div .panel {
133 h2 { (heading) }
134 div .stack style="gap:0" {
150+ section .search-group {
151+ h2 .label-condensed { (heading) }
152+ div .filelist {
153 @for h in hits.iter() {
136 div style="padding:10px 0;border-bottom:1px solid var(--border)" {
137 div .row {
138 span .label-condensed .faint { (h.kind) }
139 @if let Some(b) = &h.badge { span .chip { (b) } }
140 a href=(&h.url) style="font-weight:500" { (h.title) }
141 }
154+ a .search-hit href=(&h.url) {
155+ span .search-kind { (h.kind) }
156+ span .search-title { (h.title) }
157+ @if let Some(b) = &h.badge { span .chip { (b) } }
158 @if !h.context.is_empty() {
143 p .dim style="margin:4px 0 0" { (h.context) }
159+ span .search-context { (h.context) }
160 }
161 }
162 }
crates/df-web/src/views/change.rs+570 −76
@@ -20,17 +20,68 @@
20 pub author: Option<String>,
21 /// The name the commit itself carries, used when no account matched.
22 pub author_name: Option<String>,
23+ /// The head revision's id, used to fetch the row's diffstat.
24+ pub head_rev: Option<String>,
25 pub revision_count: i64,
24 /// Changes stacked directly on top of this one.
25 pub children: Vec<String>,
26+ /// The changes this one is stacked on.
27+ pub parents: Vec<String>,
28+ pub comments: i64,
29+ /// Verdicts given on this change, most recent per reviewer.
30+ pub reviewers: Vec<Reviewer>,
31+ /// Added/deleted lines in the head revision. `None` when the store could
32+ /// not produce a diff for it.
33+ pub diffstat: Option<(usize, usize)>,
34+ /// Depth within its stack, and how many changes that stack has. Filled in
35+ /// by [`arrange`]; `stack_size` of 1 means "not in a stack".
36+ pub depth: usize,
37+ pub stack_size: usize,
38}
39
28/// The change chip — the product's central concept made visible.
40+/// One reviewer's standing verdict on a change.
41+pub struct Reviewer {
42+ pub handle: String,
43+ pub verdict: String,
44+ /// Whether the verdict was given on the change's *current* head revision.
45+ /// A stale approval is not an approval, and the list has to show the
46+ /// difference — that is the whole point of stable change ids.
47+ pub at_head: bool,
48+}
49+
50+impl Reviewer {
51+ /// Ring colour, fill colour and the tooltip a reader needs to decode them.
52+ fn marks(&self) -> (&'static str, &'static str, String) {
53+ match (self.verdict.as_str(), self.at_head) {
54+ ("approved", true) => ("var(--open)", "var(--open)", format!("{} · approved", self.handle)),
55+ ("approved", false) => (
56+ "var(--conflict)",
57+ "var(--text-dim)",
58+ format!("{} · approved an earlier revision", self.handle),
59+ ),
60+ ("rejected", _) => (
61+ "var(--danger)",
62+ "var(--danger)",
63+ format!("{} · requested changes", self.handle),
64+ ),
65+ _ => (
66+ "var(--border-strong)",
67+ "var(--text-dim)",
68+ format!("{} · commented", self.handle),
69+ ),
70+ }
71+ }
72+}
73+
74+/// The change id — the product's central concept made visible.
75+///
76+/// Rendered inline rather than boxed: this appears on nearly every row of every
77+/// listing, and a chip around each one turns a dense table into a field of
78+/// pills. The shortest-prefix half carries `--identity`, the remainder fades —
79+/// the emphasis is on the part you actually type.
80///
30/// A real jj change id gets the identity accent and a monospace prefix. A
31/// synthetic id gets no chip (spec §4: "the UI shows them without a change chip
32/// and with reduced revision-history guarantees"), because presenting a
33/// synthesised id as a change id would be a lie the user cannot detect.
81+/// A synthetic id gets no identity colour at all (spec §4: "the UI shows them
82+/// without a change chip and with reduced revision-history guarantees"), because
83+/// presenting a synthesised id as a change id would be a lie the reader cannot
84+/// detect.
85pub fn change_chip(change_id: &str, synthetic: bool) -> Markup {
86 html! {
87 @if synthetic {
@@ -38,111 +89,551 @@
89 "git"
90 }
91 } @else {
41 span .chip.chip-change title=(format!("jj change id: {change_id}")) {
42 (&change_id[..12.min(change_id.len())])
92+ span .cid title=(format!("jj change id: {change_id}")) {
93+ (crate::views::repo::cid_parts(change_id))
94 }
95 }
96 }
97}
98
99+/// The state pill.
100+///
101+/// Outlined, with a glyph. `conflicted` is not a state of its own — it is a
102+/// thing an *open* change can be — so a conflicted change renders one pill that
103+/// says so, rather than two pills that have to be read together.
104pub fn state_badge(state: &str, conflicted: bool) -> Markup {
105+ let (class, glyph, label) = match (conflicted, state) {
106+ (true, _) => ("badge-conflict", "◆", "conflicted"),
107+ (_, "merged") => ("badge-merged", "⤳", "merged"),
108+ (_, "abandoned") => ("badge-abandoned", "×", "abandoned"),
109+ (_, "draft") => ("badge-draft", "·", "draft"),
110+ _ => ("badge-open", "○", "open"),
111+ };
112+
113 html! {
50 @if conflicted {
51 span .badge.badge-conflict { "conflict" }
114+ span .badge.(class) {
115+ span .glyph aria-hidden="true" { (glyph) }
116+ (label)
117 }
53 @match state {
54 "merged" => span .badge.badge-merged { "merged" },
55 "abandoned" => span .badge.badge-abandoned { "abandoned" },
56 "draft" => span .chip { "draft" },
57 _ => span .badge.badge-open { "open" },
118+ }
119+}
120+
121+/// Reorder a page of changes so stacks appear as stacks.
122+///
123+/// Changes come out of the database newest-first, which scatters the members of
124+/// a stack through the list. A stack is the thing branches cannot represent, so
125+/// the list has to show one: this walks the parent/child edges *within the
126+/// loaded page*, assigns each row a depth, and re-emits the page with each
127+/// stack contiguous and deepest-first — the same order `jj log` uses, tip at
128+/// the top.
129+///
130+/// Edges pointing outside the page are ignored rather than followed. The page
131+/// is a filtered view (open only, or a revset), and silently pulling in a
132+/// merged parent to complete a stack would mean the list showed rows the
133+/// filter excluded.
134+///
135+/// Rows keep their relative recency: a stack takes the list position of its
136+/// most recently updated member.
137+pub fn arrange(mut rows: Vec<ChangeRow>) -> Vec<ChangeRow> {
138+ use std::collections::{HashMap, HashSet};
139+
140+ let present: HashSet<&str> = rows.iter().map(|r| r.change_id.as_str()).collect();
141+
142+ // Depth = how many ancestors this row has inside the page. Bounded by the
143+ // page size, so a cycle (which the indexer should never produce, but a
144+ // corrupted edge table could) terminates instead of hanging.
145+ let parents: HashMap<String, Vec<String>> = rows
146+ .iter()
147+ .map(|r| {
148+ let ps = r
149+ .parents
150+ .iter()
151+ .filter(|p| present.contains(p.as_str()))
152+ .cloned()
153+ .collect();
154+ (r.change_id.clone(), ps)
155+ })
156+ .collect();
157+
158+ let limit = rows.len();
159+ let depth_of = |start: &str| -> usize {
160+ let mut depth = 0;
161+ let mut cur = start.to_string();
162+ let mut seen = HashSet::new();
163+ while seen.insert(cur.clone()) && depth < limit {
164+ match parents.get(&cur).and_then(|ps| ps.first()) {
165+ Some(p) => {
166+ depth += 1;
167+ cur = p.clone();
168+ }
169+ None => break,
170+ }
171+ }
172+ depth
173+ };
174+
175+ // Group id = the bottom of the stack, found by walking down to a row with
176+ // no parent in the page.
177+ let root_of = |start: &str| -> String {
178+ let mut cur = start.to_string();
179+ let mut seen = HashSet::new();
180+ while seen.insert(cur.clone()) {
181+ match parents.get(&cur).and_then(|ps| ps.first()) {
182+ Some(p) => cur = p.clone(),
183+ None => break,
184+ }
185 }
186+ cur
187+ };
188+
189+ let mut roots: HashMap<String, String> = HashMap::new();
190+ for r in &mut rows {
191+ r.depth = depth_of(&r.change_id);
192+ roots.insert(r.change_id.clone(), root_of(&r.change_id));
193 }
194+
195+ let mut sizes: HashMap<&str, usize> = HashMap::new();
196+ for root in roots.values() {
197+ *sizes.entry(root.as_str()).or_insert(0) += 1;
198+ }
199+ for r in &mut rows {
200+ r.stack_size = sizes[roots[&r.change_id].as_str()];
201+ }
202+
203+ // A stack inherits the list position of its freshest member, so re-grouping
204+ // never pushes active work below stale work.
205+ let mut order: Vec<&str> = Vec::new();
206+ let mut seen: HashSet<&str> = HashSet::new();
207+ for r in &rows {
208+ let root = roots[&r.change_id].as_str();
209+ if seen.insert(root) {
210+ order.push(root);
211+ }
212+ }
213+ let rank: HashMap<&str, usize> = order.iter().enumerate().map(|(i, r)| (*r, i)).collect();
214+
215+ rows.sort_by_key(|r| {
216+ let root = roots[&r.change_id].as_str();
217+ // Deepest first within a stack: the tip is what you are working on.
218+ (rank[root], usize::MAX - r.depth)
219+ });
220+ rows
221}
222
223+#[cfg(test)]
224+mod arrange_tests {
225+ use super::{arrange, ChangeRow};
226+ use chrono::Utc;
227+
228+ fn row(id: &str, parents: &[&str]) -> ChangeRow {
229+ ChangeRow {
230+ number: 1,
231+ change_id: id.into(),
232+ synthetic: false,
233+ title: id.into(),
234+ state: "open".into(),
235+ conflicted: false,
236+ updated_at: Utc::now(),
237+ author: None,
238+ author_name: None,
239+ head_rev: None,
240+ revision_count: 1,
241+ parents: parents.iter().map(|s| (*s).to_string()).collect(),
242+ comments: 0,
243+ reviewers: vec![],
244+ diffstat: None,
245+ depth: 0,
246+ stack_size: 0,
247+ }
248+ }
249+
250+ #[test]
251+ fn a_stack_comes_out_contiguous_and_tip_first() {
252+ // Loaded newest-first and interleaved with an unrelated change.
253+ let out = arrange(vec![
254+ row("solo", &[]),
255+ row("mid", &["bottom"]),
256+ row("top", &["mid"]),
257+ row("bottom", &[]),
258+ ]);
259+
260+ let ids: Vec<&str> = out.iter().map(|r| r.change_id.as_str()).collect();
261+ assert_eq!(ids, ["solo", "top", "mid", "bottom"]);
262+ assert_eq!(out[1].depth, 2);
263+ assert_eq!(out[3].depth, 0);
264+ assert!(out[1..].iter().all(|r| r.stack_size == 3));
265+ assert_eq!(out[0].stack_size, 1);
266+ }
267+
268+ /// An edge to a change the filter excluded must not change the grouping.
269+ #[test]
270+ fn edges_leaving_the_page_are_ignored() {
271+ let out = arrange(vec![row("child", &["merged-parent-not-loaded"])]);
272+ assert_eq!(out[0].depth, 0);
273+ assert_eq!(out[0].stack_size, 1);
274+ }
275+
276+ /// A corrupted edge table must not hang the change list.
277+ #[test]
278+ fn a_cycle_terminates() {
279+ let out = arrange(vec![row("a", &["b"]), row("b", &["a"])]);
280+ assert_eq!(out.len(), 2);
281+ }
282+}
283+
284pub struct ListFilters<'a> {
285 pub state: &'a str,
286 pub revset: &'a str,
287 pub revset_error: Option<&'a str>,
288+ /// Counts for the filter tabs, in tab order.
289+ pub counts: ListCounts,
290+ /// Whether the viewer has an account, which decides if "Mine" is offered.
291+ pub signed_in: bool,
292+ pub week: WeekStats,
293+}
294+
295+/// Row counts behind the filter tabs.
296+#[derive(Debug, Clone, Copy, Default, sqlx::FromRow)]
297+pub struct ListCounts {
298+ pub open: i64,
299+ pub conflicted: i64,
300+ pub merged: i64,
301+ pub abandoned: i64,
302+ pub mine: i64,
303+}
304+
305+/// The aside's "this week" block.
306+#[derive(Debug, Clone, Copy, Default, sqlx::FromRow)]
307+pub struct WeekStats {
308+ pub merged: i64,
309+ pub opened: i64,
310+ pub resolved: i64,
311+ /// Median minutes from a change opening to its first review. `None` when
312+ /// nothing was reviewed this week — there is no median of an empty set,
313+ /// and printing "0m" would claim instant reviews.
314+ pub median_first_review_mins: Option<i64>,
315}
316
317+/// The change list — the hottest page in the product.
318+///
319+/// Five columns of fixed-width facts with one elastic column (the title), a
320+/// revset box that filters them, and a stack rail that makes a chain of
321+/// dependent work look like one thing. Everything here is a link or a form:
322+/// there is no state in this page that JavaScript owns.
323pub fn list(ctx: &RepoContext, rows: &[ChangeRow], f: ListFilters<'_>) -> Markup {
324 let base = ctx.base();
325+ let now = Utc::now();
326+ let conflicted_here = rows.iter().filter(|r| r.conflicted).count();
327+
328+ // The revset survives a tab click and vice versa, so narrowing by state
329+ // does not silently throw away the expression someone just wrote.
330+ let tab_href = |key: &str| {
331+ if f.revset.is_empty() {
332+ format!("{base}/changes?state={key}")
333+ } else {
334+ format!(
335+ "{base}/changes?state={key}&revset={}",
336+ crate::routes::settings::urlencode(f.revset)
337+ )
338+ }
339+ };
340+
341+ let tabs: Vec<(&str, &str, &str, &str, i64)> = [
342+ ("open", "Open", "○", "var(--open)", f.counts.open),
343+ ("conflicted", "Conflicted", "◆", "var(--conflict)", f.counts.conflicted),
344+ ("merged", "Merged", "⤳", "var(--merged)", f.counts.merged),
345+ ("abandoned", "Abandoned", "×", "var(--abandoned)", f.counts.abandoned),
346+ ("mine", "Mine", "·", "var(--text-faint)", f.counts.mine),
347+ ]
348+ .into_iter()
349+ .filter(|(key, ..)| *key != "mine" || f.signed_in)
350+ .collect();
351
352 html! {
72 div .panel {
73 form method="get" action=(format!("{base}/changes")) style="margin-bottom:16px" {
74 div .row {
75 @for (key, label) in [("open","Open"),("merged","Merged"),("abandoned","Abandoned"),("all","All")] {
76 a href=(format!("{base}/changes?state={key}"))
77 style=(if f.state == key { "color:var(--text);font-weight:500" } else { "" }) {
353+ div .page-head {
354+ h1 { "Changes" }
355+ span .spacer {}
356+ a .btn.btn-primary href=(format!("{base}/changes/new")) { "New change" }
357+ }
358+
359+ div .columns.columns-repo {
360+ div .columns-main {
361+ form .revset-bar method="get" action=(format!("{base}/changes")) {
362+ label .revset-tag for="revset" { "revset" }
363+ input #revset type="text" name="revset" value=(f.revset)
364+ spellcheck="false" autocapitalize="off" autocomplete="off"
365+ placeholder="open() | conflict()"
366+ aria-label="Filter changes by revset";
367+ input type="hidden" name="state" value=(f.state);
368+ @match f.revset_error {
369+ Some(e) => {
370+ span .revset-status.is-bad role="alert" {
371+ span aria-hidden="true" { "!" } " " (e)
372+ }
373+ }
374+ None => {
375+ span .revset-status {
376+ span aria-hidden="true" { "✓" }
377+ " " (rows.len()) @if rows.len() == 1 { " change" } @else { " changes" }
378+ }
379+ }
380+ }
381+ button .btn.btn-mono type="submit" { "filter" }
382+ }
383+
384+ nav .subtabs.ruled.filter-tabs aria-label="Filter by state" {
385+ @for (key, label, glyph, colour, n) in &tabs {
386+ a href=(tab_href(key)) .active[f.state == *key]
387+ aria-current=[(f.state == *key).then_some("page")] {
388+ span .filter-glyph aria-hidden="true"
389+ style=[(f.state == *key).then(|| format!("color:{colour}"))] {
390+ (glyph)
391+ }
392 (label)
393+ span .tab-count { (n) }
394 }
395 }
81 span style="margin-left:auto;flex:1;max-width:420px" {
82 input type="text" name="revset" value=(f.revset)
83 placeholder="revset — author(x) & ~conflicted()"
84 aria-label="Filter by revset";
396+ }
397+
398+ @if conflicted_here > 0 && f.state != "conflicted" {
399+ p .list-summary {
400+ (conflicted_here)
401+ @if conflicted_here == 1 { " of these is conflicted" }
402+ @else { " of these are conflicted" }
403 }
86 input type="hidden" name="state" value=(f.state);
87 button .btn type="submit" { "Filter" }
404 }
89 @if let Some(e) = f.revset_error {
90 div .banner.banner-error style="margin-top:12px" {
91 strong { "Unsupported expression. " }
92 (e)
405+
406+ @if rows.is_empty() {
407+ div .empty {
408+ h2 { "No changes here" }
409+ @if f.revset_error.is_some() {
410+ p { "Fix the expression above, or clear it to see everything." }
411+ } @else {
412+ p { "Push with " code { "jj git push" } " and changes appear here." }
413+ }
414+ }
415+ } @else {
416+ div .changelist {
417+ div .changelist-head aria-hidden="true" {
418+ div { "Change" }
419+ div { "Title" }
420+ div { "Review" }
421+ div .at-end { "Diff" }
422+ div .at-end { "Updated" }
423+ }
424+ @for (i, r) in rows.iter().enumerate() {
425+ // A stack banner opens each group of two or more.
426+ @if r.stack_size > 1 && rows.get(i.wrapping_sub(1))
427+ .is_none_or(|p| p.stack_size != r.stack_size
428+ || p.depth < r.depth) {
429+ div .stack-banner {
430+ span .stack-banner-rail aria-hidden="true" { "▌" }
431+ span .stack-banner-label { "stack of " (r.stack_size) }
432+ span .stack-banner-note {
433+ "Rebase moves all " (r.stack_size)
434+ "; the ids do not change."
435+ }
436+ }
437+ }
438+ (change_list_row(&base, r, now))
439+ }
440 }
441 }
442 }
443
97 @if rows.is_empty() {
98 div .empty {
99 h2 { "No changes" }
100 p { "Push with " code { "jj git push" } " and changes appear here." }
444+ aside .columns-aside {
445+ div .aside-block {
446+ div .label-condensed { "Saved revsets" }
447+ @for expr in ["mine()", "conflict()", "author(me) & ~merged()"] {
448+ a .saved-revset href=(format!("{base}/changes?state=all&revset={}",
449+ crate::routes::settings::urlencode(expr)))
450+ .is-current[f.revset == expr] {
451+ (expr)
452+ }
453+ }
454 }
102 } @else {
103 div .stack style="gap:0" {
104 @for r in rows {
105 div style="padding:12px 0;border-bottom:1px solid var(--border)" {
106 div .row {
107 (state_badge(&r.state, r.conflicted))
108 a href=(format!("{base}/changes/{}", r.number)) style="font-weight:500" {
109 (r.title)
110 }
111 }
112 div .row style="margin-top:6px;gap:10px" {
113 (change_chip(&r.change_id, r.synthetic))
114 span .faint { "#" (r.number) }
115 @match (r.author.as_deref(), r.author_name.as_deref()) {
116 (Some(h), _) => a .faint href=(format!("/{h}")) { (h) },
117 (None, Some(n)) => span .faint
118 title="this commit is not linked to a Dogfood account" { (n) },
119 (None, None) => {}
120 }
121 // Revision count is the visible payoff of stable
122 // identity: one review, many rewrites.
123 @if r.revision_count > 1 {
124 span .faint title="revisions of this change" {
125 (r.revision_count) " revisions"
126 }
127 }
128 span .faint { (r.updated_at.format("%Y-%m-%d").to_string()) }
129 }
130 @if !r.children.is_empty() {
131 div .row style="margin-top:6px;gap:6px" {
132 span .label-condensed { "stacked under" }
133 @for c in &r.children {
134 span .chip { (&c[..12.min(c.len())]) }
135 }
136 }
455+
456+ div .aside-block {
457+ div .label-condensed { "This week" }
458+ (week_stats(&f.week))
459+ }
460+ }
461+ }
462+ }
463+}
464+
465+/// The aside's weekly numbers.
466+fn week_stats(w: &WeekStats) -> Markup {
467+ html! {
468+ div .dotline {
469+ span .dotline-key { "Merged" }
470+ span .dotline-val style="color:var(--merged)" { (w.merged) }
471+ }
472+ div .dotline {
473+ span .dotline-key { "Opened" }
474+ span .dotline-val style="color:var(--open)" { (w.opened) }
475+ }
476+ div .dotline {
477+ span .dotline-key { "Conflicts resolved" }
478+ span .dotline-val style="color:var(--conflict)" { (w.resolved) }
479+ }
480+ div .dotline {
481+ span .dotline-key { "Median time to first review" }
482+ span .dotline-val style="color:var(--text-dim)" {
483+ @match w.median_first_review_mins {
484+ Some(m) => (humanise_minutes(m)),
485+ // Nothing reviewed this week. "—" says that; "0m" would
486+ // claim every change was reviewed instantly.
487+ None => "—",
488+ }
489+ }
490+ }
491+ }
492+}
493+
494+/// Minutes as the coarsest unit that still reads as a duration.
495+fn humanise_minutes(mins: i64) -> String {
496+ match mins {
497+ m if m < 60 => format!("{m}m"),
498+ m if m < 60 * 48 => format!("{}h", m / 60),
499+ m => format!("{}d", m / (60 * 24)),
500+ }
501+}
502+
503+/// One row of the change list.
504+fn change_list_row(base: &str, r: &ChangeRow, now: DateTime<Utc>) -> Markup {
505+ let href = format!("{base}/changes/{}", r.number);
506+ let (glyph, colour) = match (r.conflicted, r.state.as_str()) {
507+ (true, _) => ("◆", "var(--conflict)"),
508+ (_, "merged") => ("⤳", "var(--merged)"),
509+ (_, "abandoned") => ("×", "var(--abandoned)"),
510+ (_, "draft") => ("·", "var(--text-faint)"),
511+ _ => ("○", "var(--open)"),
512+ };
513+
514+ html! {
515+ div .changelist-row .in-stack[r.stack_size > 1] {
516+ div .cl-change {
517+ // The rail is drawn at the row's own depth, so a chain of three
518+ // reads as a chain rather than as three unrelated rows.
519+ @if r.stack_size > 1 {
520+ span .cl-indent style=(format!("width:{}px", r.depth * 8)) {}
521+ span .cl-rail aria-hidden="true" {}
522+ }
523+ span .cl-glyph aria-hidden="true" style=(format!("color:{colour}")) { (glyph) }
524+ a .cid href=(href) title=(format!("jj change id: {}", r.change_id)) {
525+ @if r.synthetic {
526+ span .cid-p.cid-synthetic { (&r.change_id[..8.min(r.change_id.len())]) }
527+ } @else {
528+ (crate::views::repo::cid_parts(&r.change_id))
529+ }
530+ }
531+ }
532+
533+ div .cl-title {
534+ a href=(href) { (r.title) }
535+ @if r.conflicted {
536+ span .badge.badge-conflict {
537+ span .glyph aria-hidden="true" { "◆" }
538+ "conflicted"
539+ }
540+ }
541+ span .cl-byline {
542+ (crate::views::person(r.author.as_deref(), r.author_name.as_deref()))
543+ }
544+ // The visible payoff of stable identity: one review, many
545+ // rewrites.
546+ @if r.revision_count > 1 {
547+ span .cl-revs title="revisions of this change" {
548+ (r.revision_count) " revs"
549+ }
550+ }
551+ }
552+
553+ div .cl-review {
554+ @for rv in &r.reviewers {
555+ @let (ring, fill, tip) = rv.marks();
556+ span .cl-avatar title=(tip)
557+ style=(format!("border-color:{ring};color:{fill}")) {
558+ (initials(&rv.handle))
559+ }
560+ }
561+ @if r.comments > 0 {
562+ span .cl-comments title="comments" { (r.comments) "⌾" }
563+ }
564+ }
565+
566+ div .cl-diff {
567+ @match r.diffstat {
568+ Some((add, del)) => {
569+ span .cl-bars title=(format!("+{add} −{del}")) aria-hidden="true" {
570+ @for filled in bars(add, del) {
571+ span style=(format!(
572+ "background:{}",
573+ if filled { "var(--diff-add-text)" } else { "var(--diff-del-text)" }
574+ )) {}
575 }
576 }
577+ span .cl-add { "+" (add) }
578+ span .cl-del { "−" (del) }
579 }
580+ None => span .faint { "—" },
581 }
582 }
583+
584+ div .cl-when title=(r.updated_at.format("%Y-%m-%d %H:%M UTC").to_string()) {
585+ (crate::views::relative_time(r.updated_at, now))
586+ }
587 }
588 }
589}
590
591+/// Five cells, filled green in proportion to how much of the diff was additions.
592+///
593+/// The same idea as GitHub's diffstat bar. A pure deletion shows five red
594+/// cells, a pure addition five green, and the mix in between is rounded — it is
595+/// a glanceable ratio, not a measurement, which is why the exact numbers sit
596+/// beside it.
597+fn bars(add: usize, del: usize) -> [bool; 5] {
598+ let total = add + del;
599+ if total == 0 {
600+ return [false; 5];
601+ }
602+ let green = ((add as f64 / total as f64) * 5.0).round() as usize;
603+ std::array::from_fn(|i| i < green)
604+}
605+
606+/// Up to two letters from a handle, for the reviewer marks.
607+fn initials(handle: &str) -> String {
608+ handle.chars().take(2).collect()
609+}
610+
611+#[cfg(test)]
612+mod bars_tests {
613+ use super::{bars, humanise_minutes};
614+
615+ #[test]
616+ fn the_ratio_reads_the_way_the_diff_does() {
617+ assert_eq!(bars(100, 0), [true; 5]);
618+ assert_eq!(bars(0, 100), [false; 5]);
619+ assert_eq!(bars(50, 50), [true, true, true, false, false]);
620+ assert_eq!(bars(20, 80), [true, false, false, false, false]);
621+ }
622+
623+ /// An empty diff must not divide by zero.
624+ #[test]
625+ fn an_empty_diff_is_all_empty() {
626+ assert_eq!(bars(0, 0), [false; 5]);
627+ }
628+
629+ #[test]
630+ fn durations_read_as_durations() {
631+ assert_eq!(humanise_minutes(42), "42m");
632+ assert_eq!(humanise_minutes(150), "2h");
633+ assert_eq!(humanise_minutes(60 * 24 * 3), "3d");
634+ }
635+}
636+
637/// Disambiguation page for an ambiguous change-id prefix (spec §7).
638pub fn ambiguous(ctx: &RepoContext, prefix: &str, candidates: &[(i64, String, String)]) -> Markup {
639 let base = ctx.base();
@@ -172,18 +663,21 @@
663 use super::*;
664
665 #[test]
175 fn synthetic_changes_render_without_a_change_chip() {
666+ fn synthetic_changes_render_without_a_change_id() {
667 // Spec §4: "Do not pretend a synthetic identity is a real change ID."
668 let synthetic = change_chip("ppwkwxvrwvxxyttp0000000000000000", true).into_string();
669 assert!(synthetic.contains("git"));
670 assert!(
180 !synthetic.contains("chip-change"),
181 "a synthetic id must not use the change-identity chip: {synthetic}"
671+ !synthetic.contains("cid-p"),
672+ "a synthetic id must not get the identity treatment: {synthetic}"
673 );
674
675+ // A real id is split into its shortest-prefix half and the remainder,
676+ // so the two carry different weight — but together they are still the
677+ // twelve characters the product displays.
678 let real = change_chip("klxqnvpqlnlvtkmuqmtmxktlnvnomvwv", false).into_string();
185 assert!(real.contains("chip-change"));
186 assert!(real.contains("klxqnvpqlnlv"));
679+ assert!(real.contains(r#"class="cid-p">klxq<"#), "{real}");
680+ assert!(real.contains(r#"class="cid-r">nvpqlnlv<"#), "{real}");
681 }
682
683 #[test]
crates/df-web/src/views/design.rs+118 −25
@@ -22,9 +22,9 @@
22];
23
24const ACCENTS: &[(&str, &str)] = &[
25 ("--brand", "the one primary move on a page"),
26 ("--identity", "change ids, stack rails"),
27 ("--action", "links"),
25+ ("--identity", "change ids, stacks, revisions"),
26+ ("--action", "links, the one primary move"),
27+ ("--identity-wash", "stacked rows, selected revisions"),
28];
29
30const STATES: &[(&str, &str)] = &[
@@ -83,16 +83,94 @@
83
84 (section("type", "Typography", html! {
85 div .type-specimens {
86 p .display style="font-size:var(--text-2xl)" { "Display 40 / condensed 700" }
87 p .display style="font-size:var(--text-xl)" { "Display 28 / condensed 700" }
88 p style="font-size:var(--text-lg)" { "Large 20 / sans" }
89 p style="font-size:var(--text-md)" { "Medium 16 / sans" }
90 p { "Base 14 / sans — the default for body copy and controls." }
91 p .mono { "Mono 12.5 / JetBrains — identifiers, paths, diffs, timestamps." }
92 p .label-condensed { "Label 11 / condensed uppercase" }
86+ @for (spec, sample, style) in [
87+ ("44 / 48 · 600", "Display", "font-size:var(--text-3xl);line-height:48px;font-weight:600;letter-spacing:-0.02em"),
88+ ("24 / 32 · 600", "Page title", "font-size:var(--text-xl);line-height:32px;font-weight:600"),
89+ ("20 / 28 · 600", "Section title", "font-size:var(--text-lg);line-height:28px;font-weight:600"),
90+ ("16 / 24 · 400", "Lead paragraph", "font-size:var(--text-md);line-height:24px"),
91+ ("14 / 21 · 400", "Body and row titles", "font-size:var(--text-base);line-height:21px"),
92+ ("12.5 / 18 · 400", "Metadata, secondary", "font-size:var(--text-sm);line-height:18px;color:var(--text-dim)"),
93+ ("11 · mono", "kntqzsqtwrln +18 −4", "font-family:var(--font-mono);font-size:var(--text-xs);color:var(--text-faint)"),
94+ ("11 · condensed", "EYEBROW LABEL", "font-family:var(--font-condensed);font-size:var(--text-xs);font-weight:500;letter-spacing:0.06em;text-transform:uppercase;color:var(--text-dim)"),
95+ ] {
96+ div .type-row {
97+ span .type-spec { (spec) }
98+ span style=(style) { (sample) }
99+ }
100+ }
101+ }
102+ }))
103+
104+ (section("states", "Change states", html! {
105+ div .state-list {
106+ @for (state, conflicted, meaning) in [
107+ ("open", false, "pushed and reviewable"),
108+ ("open", true, "mid-thought, still reviewable"),
109+ ("merged", false, "landed on a bookmark"),
110+ ("abandoned", false, "closed without landing"),
111+ ("draft", false, "the author's own flag; never inferred from a push"),
112+ ] {
113+ div .state-row {
114+ (state_badge(state, conflicted))
115+ span .dim { (meaning) }
116+ }
117+ }
118+ }
119+ p .hint.measure {
120+ "Conflicted is not a fifth state — it is something an open change can be. \
121+ That is why it renders as one pill rather than two, and why it is magenta \
122+ rather than red: a conflict is a state, not a failure."
123+ }
124+ }))
125+
126+ (section("keys", "Keycaps", html! {
127+ div .design-row {
128+ @for k in ["⌘K", "j", "k", "↵", "y", "t", "esc", "?"] {
129+ span .kbd { (k) }
130+ }
131+ }
132+ p .hint.measure {
133+ "Only ⌘K is bound. The rest are drawn but not wired — a keycap that does \
134+ nothing is a promise the product has not kept, so they appear here and \
135+ nowhere else."
136 }
137 }))
138
139+ (section("diffrows", "Diff rows", html! {
140+ div .filelist style="max-width:720px" {
141+ @for (class, ln, text) in [
142+ ("diff-hunk", "", "@@ hunk header — surface-raised, no rule"),
143+ ("line-add", "137", "+ addition — add-bg with a 2px add rule"),
144+ ("line-del", "136", "- deletion — del-bg with a 2px del rule"),
145+ ("line-ctx", "149", " context — transparent, body colour"),
146+ ] {
147+ div .diffline.(class) {
148+ span .diff-ln { (ln) }
149+ span .diff-text { (text) }
150+ }
151+ }
152+ }
153+ }))
154+
155+ (section("stats", "Stat lines", html! {
156+ div style="max-width:240px" {
157+ @for (k, v, colour) in [
158+ ("Open changes", "128", "var(--open)"),
159+ ("Conflicted", "3", "var(--conflict)"),
160+ ("Median time to first review", "42m", "var(--text-dim)"),
161+ ] {
162+ div .dotline {
163+ span .dotline-key { (k) }
164+ span .dotline-val style=(format!("color:{colour}")) { (v) }
165+ }
166+ }
167+ }
168+ p .hint.measure {
169+ "The leader is a flexing border rather than a run of periods, so both ends \
170+ sit on the same baseline whatever their lengths are."
171+ }
172+ }))
173+
174 (section("buttons", "Buttons", html! {
175 div .design-row {
176 a .btn.btn-primary href="#buttons" { "Primary" }
@@ -105,9 +183,10 @@
183 a .btn.btn-primary.btn-lg href="#buttons" { "Large primary" }
184 a .btn.btn-lg href="#buttons" { "Large default" }
185 }
108 p .hint {
109 "Primary is brand vermilion and there is at most one on a screen. A second
110 primary button means the page has not decided what it is for."
186+ p .hint.measure {
187+ "Primary is filled `--action` and there is at most one on a screen. A second
188+ primary button means the page has not decided what it is for. Everything
189+ else is the transparent default, which reaches for the accent only on hover."
190 }
191 }))
192
@@ -209,17 +288,24 @@
288 }
289
290 section {
212 h2 { "Three accents, three jobs" }
291+ h2 { "Two accents, two jobs" }
292 p .dim {
214 "Vermilion is " em { "brand" } " — the wordmark, the one primary action on a
215 page, the rule under the masthead, the slash before a section heading. Ochre
216 is " em { "identity" } " — change ids and stack rails, and nothing else. Teal
217 is " em { "action" } " — links. The test when adding an element: does it "
218 em { "name" } " a change, does it " em { "do" } " something, or is it the
219 product asserting itself? It is never two of those at once. If brand starts
220 appearing on ordinary links it stops meaning anything, which is the failure
221 mode to watch for."
293+ "Gold is " em { "identity" } " — change ids, stack rails, the revision
294+ timeline, the wash behind a stacked row. Every place the interface is
295+ pointing at a thing that keeps its name through a rewrite. Teal is "
296+ em { "action" } " — links, and the one primary control on a page. The test
297+ when adding an element: does it " em { "name" } " a change, or does it "
298+ em { "do" } " something? It is never both. A gold button or a teal change
299+ id breaks the only colour rule the interface has, and once either starts
300+ leaking the other stops meaning anything."
301 }
302+ p .dim {
303+ "There is deliberately no third brand colour. An earlier revision of this
304+ system had one — a vermilion reserved for the wordmark and marketing
305+ surfaces — and it spent its force competing with the two accents that
306+ carry meaning. Identity and action are the whole palette; everything else
307+ is neutral or a state."
308+ }
309 }
310
311 section {
@@ -257,9 +343,16 @@
343 "Radius is 3px, borders are one hairline, and shadows appear only on things
344 that genuinely float. The pages that matter — change lists, diffs, file
345 trees — are read for hours, and every pixel of chrome is one not spent on
260 code. The grid field and the brand glow are the only decorative elements in
261 the system, they sit behind content rather than around it, and both are
262 near-invisible by design."
346+ code. Rows are 28 to 34px, tables are separated by hairlines rather than
347+ boxed, and the only decorative element in the whole system is the tagline
348+ strip on the landing page."
349+ }
350+ p .dim {
351+ "Nothing animates. The design this was ported from types into a terminal
352+ and scrolls a marquee; neither ships. A console that types at you is
353+ indistinguishable from one showing live output, and a permanently moving
354+ strip is a permanently moving object on the page for a reader who did not
355+ ask for one."
356 }
357 }
358
crates/df-web/src/views/edit.rs+38 −38
@@ -33,56 +33,56 @@
33 let cancel = format!("{base}/blob/{}/{}", e.bookmark, e.path);
34
35 html! {
36 div .panel {
37 div .row style="margin-bottom:12px" {
38 span .chip { (e.bookmark) }
39 (breadcrumbs(&base, e.bookmark, e.path))
40 a .btn href=(cancel) style="margin-left:auto" { "Cancel" }
41 }
36+ div .tree-crumbs {
37+ span .btn.btn-mono.is-static { (e.bookmark) }
38+ (breadcrumbs(&base, e.bookmark, e.path))
39+ span .spacer {}
40+ a .btn.btn-mono href=(cancel) { "cancel" }
41+ }
42
43 @if let Some(msg) = e.error {
44 div .banner.banner-error role="alert" { (msg) }
45 }
43+ @if let Some(msg) = e.error {
44+ div .banner.banner-error role="alert" { (msg) }
45+ }
46
47 form method="post" action=(action) data-editor-form="1" .stack {
48 input type="hidden" name="_csrf" value=(e.csrf);
49 // What the edit was composed against. The server refuses the
50 // save if the bookmark has moved since.
51 input type="hidden" name="tip" value=(e.tip);
47+ form method="post" action=(action) data-editor-form="1" {
48+ input type="hidden" name="_csrf" value=(e.csrf);
49+ // What the edit was composed against. The server refuses the save
50+ // if the bookmark has moved since.
51+ input type="hidden" name="tip" value=(e.tip);
52
53 div .field {
54 label for="content" { "Contents of " (e.path) }
55 div .editor-host {
56 textarea id="content" name="content" rows="24"
57 data-editor="1"
58 data-language=[e.language]
59 spellcheck="false"
60 autocapitalize="off" autocomplete="off"
61 wrap="off" { (e.content) }
53+ div .editor-shell {
54+ div .editor-bar {
55+ span .mono { (e.path) }
56+ @if let Some(l) = e.language {
57+ span .label-condensed { (l) }
58 }
59+ span .spacer {}
60+ span .editor-status.mono { "editing on " (e.bookmark) }
61 }
62
65 div .field {
66 label for="message" { "Commit message" }
63+ label .sr-only for="content" { "Contents of " (e.path) }
64+ div .editor-host {
65+ textarea id="content" name="content" rows="24"
66+ data-editor="1"
67+ data-language=[e.language]
68+ spellcheck="false"
69+ autocapitalize="off" autocomplete="off"
70+ wrap="off" { (e.content) }
71+ }
72+
73+ div .editor-foot {
74+ label .sr-only for="message" { "Commit message" }
75 input type="text" id="message" name="message" maxlength="500"
76 placeholder=(format!("Update {}", e.path));
69 p .hint {
70 "Committed to " code { (e.bookmark) } " as " (e.author)
71 ". This is an ordinary commit — it appears in the log and \
72 is indexed like any push."
73 }
74 }
75
76 div .row {
77 button .btn.btn-primary type="submit" { "Commit changes" }
78 a .btn href=(cancel) { "Cancel" }
78 }
79 }
80+ }
81
82 p .hint {
83 "Editing here writes a commit on top of " code { (e.bookmark) }
84 ". It never rewrites history, so a protected bookmark accepts it."
85 }
82+ p .hint.measure {
83+ "Committed to " code { (e.bookmark) } " as " (e.author)
84+ ". Editing here writes a commit on top of the bookmark it never rewrites \
85+ history, so a protected bookmark accepts it, and it is indexed like any push."
86 }
87 }
88}
crates/df-web/src/views/issue.rs+138 −80
@@ -52,66 +52,88 @@
52 pub all_labels: &'a [Label],
53}
54
55+/// The issue list.
56+///
57+/// One row per issue, on the same grammar as the change list: a state glyph, a
58+/// number, the title, and metadata pinned to the right. The linked-change
59+/// column is the one thing here a general issue tracker does not have — an
60+/// issue closes when a change that references it merges, so the change is the
61+/// most useful thing to show beside it.
62pub fn list(ctx: &RepoContext, rows: &[IssueRow], f: ListFilters<'_>) -> Markup {
63 let base = ctx.base();
64+ let now = Utc::now();
65+
66 html! {
58 div .panel {
59 div .row style="margin-bottom:14px" {
67+ div .page-head {
68+ h1 { "Issues" }
69+ span .band-note {
70+ "An issue closes when a change that references it merges."
71+ }
72+ span .spacer {}
73+ a .btn.btn-primary href=(format!("{base}/issues/new")) { "New issue" }
74+ }
75+
76+ form .filterbar method="get" action=(format!("{base}/issues")) {
77+ div .filterbar-tabs {
78 @for (key, label) in [("open", "Open"), ("closed", "Closed"), ("all", "All")] {
61 a href=(format!("{base}/issues?state={key}"))
62 style=(if f.state == key { "color:var(--text);font-weight:500" } else { "" }) {
79+ a .btn.btn-mono .is-on[f.state == key]
80+ href=(format!("{base}/issues?state={key}"))
81+ aria-current=[(f.state == key).then_some("page")] {
82 (label)
83 }
84 }
66
67 form method="get" action=(format!("{base}/issues")) .row style="margin-left:auto;gap:8px" {
68 input type="hidden" name="state" value=(f.state);
69 select name="label" aria-label="Filter by label" {
70 option value="" { "any label" }
71 @for l in f.all_labels {
72 option value=(l.name) selected[f.label == Some(l.name.as_str())] { (l.name) }
73 }
74 }
75 input type="text" name="assignee" value=[f.assignee]
76 placeholder="assignee" aria-label="Filter by assignee";
77 button .btn type="submit" { "Filter" }
85+ }
86+ span .spacer {}
87+ input type="hidden" name="state" value=(f.state);
88+ select name="label" aria-label="Filter by label" {
89+ option value="" { "any label" }
90+ @for l in f.all_labels {
91+ option value=(l.name) selected[f.label == Some(l.name.as_str())] { (l.name) }
92 }
79
80 a .btn.btn-primary href=(format!("{base}/issues/new")) { "New issue" }
93 }
94+ input type="text" name="assignee" value=[f.assignee]
95+ placeholder="assignee" aria-label="Filter by assignee";
96+ button .btn.btn-mono type="submit" { "filter" }
97+ }
98
83 @if rows.is_empty() {
84 div .empty {
85 h2 { "No issues" }
86 p { "Nothing matches this filter." }
87 }
88 } @else {
89 div .stack style="gap:0" {
90 @for r in rows {
91 div style="padding:12px 0;border-bottom:1px solid var(--border)" {
92 div .row {
93 @if r.state == "closed" {
94 span .badge.badge-merged { "closed" }
95 } @else {
96 span .badge.badge-open { "open" }
99+ @if rows.is_empty() {
100+ div .empty {
101+ h2 { "No issues" }
102+ p { "Nothing matches this filter." }
103+ }
104+ } @else {
105+ div .filelist {
106+ @for r in rows {
107+ @let closed = r.state == "closed";
108+ a .issue-row .is-closed[closed] href=(format!("{base}/issues/{}", r.number)) {
109+ span .issue-glyph aria-hidden="true"
110+ style=(format!("color:{}",
111+ if closed { "var(--merged)" } else { "var(--open)" })) {
112+ @if closed { "⤳" } @else { "○" }
113+ }
114+ span .issue-num { "#" (r.number) }
115+ span .issue-title { (r.title) }
116+ @for l in &r.labels { (label_chip(l)) }
117+ span .spacer {}
118+ @if !r.assignees.is_empty() {
119+ span .issue-meta {
120+ "→ "
121+ @for (i, a) in r.assignees.iter().enumerate() {
122+ @if i > 0 { ", " }
123+ (a)
124 }
98 a href=(format!("{base}/issues/{}", r.number)) style="font-weight:500" {
99 (r.title)
100 }
101 @for l in &r.labels { (label_chip(l)) }
125 }
103 div .row style="margin-top:6px;gap:10px" {
104 span .faint { "#" (r.number) }
105 @if let Some(a) = &r.author { span .faint { "by " (a) } }
106 @if !r.assignees.is_empty() {
107 span .faint { "assigned to " (r.assignees.join(", ")) }
108 }
109 @if r.comment_count > 0 {
110 span .faint { (r.comment_count) " comment(s)" }
111 }
112 span .faint { (r.updated_at.format("%Y-%m-%d").to_string()) }
113 }
126 }
127+ @if let Some(a) = &r.author {
128+ span .issue-meta { (a) }
129+ }
130+ @if r.comment_count > 0 {
131+ span .issue-comments title="comments" { (r.comment_count) "⌾" }
132+ }
133+ span .issue-when
134+ title=(r.updated_at.format("%Y-%m-%d %H:%M UTC").to_string()) {
135+ (crate::views::relative_time(r.updated_at, now))
136+ }
137 }
138 }
139 }
@@ -182,45 +204,35 @@
204
205pub fn detail(ctx: &RepoContext, d: Detail<'_>) -> Markup {
206 let base = format!("{}/issues/{}", ctx.base(), d.number);
207+ let closed = d.state == "closed";
208
186 html! {
187 div .panel {
188 div .row {
189 @if d.state == "closed" {
190 span .badge.badge-merged { "closed" }
191 } @else {
192 span .badge.badge-open { "open" }
193 }
194 h1 style="margin:0" { (d.title) }
195 }
196 div .row style="margin-top:8px;gap:10px" {
197 span .faint { "#" (d.number) }
198 @if let Some(a) = d.author { span .faint { "opened by " (a) } }
199 span .faint { (d.created_at.format("%Y-%m-%d").to_string()) }
200 @for l in d.labels { (label_chip(l)) }
201 }
209+ let main = html! {
210+ a .backlink href=(format!("{}/issues", ctx.base())) { "← issues" }
211
203 @if !d.assignees.is_empty() {
204 p .dim style="margin-top:8px" { "Assigned to " (d.assignees.join(", ")) }
212+ div .issue-head {
213+ span .badge .badge-merged[closed] .badge-open[!closed] {
214+ span .glyph aria-hidden="true" { @if closed { "⤳" } @else { "○" } }
215+ @if closed { "closed" } @else { "open" }
216 }
217+ span .faint.mono { "#" (d.number) }
218+ }
219+ h1 .measure { (d.title) }
220
207 @if !d.body_html.is_empty() {
208 div .markdown-body style="margin-top:16px" { (PreEscaped(d.body_html)) }
209 }
221+ @if !d.body_html.is_empty() {
222+ div .issue-body.markdown-body { (PreEscaped(d.body_html)) }
223 }
224
212 @if !d.referenced_by.is_empty() {
213 div .panel {
214 h2 { "Referenced by" }
215 div .stack style="gap:6px" {
216 @for (kind, number, title) in d.referenced_by {
217 div .row {
218 span .faint { (kind) }
219 a href=(format!("{}/{}s/{number}", ctx.base(), kind)) {
220 "#" (number) " · " (title)
221 }
222 }
225+ // The line that states the product's rule about issues: they close
226+ // because work landed, not because somebody ticked a box.
227+ @for (kind, number, title) in d.referenced_by {
228+ div .issue-ref {
229+ span .issue-ref-rail aria-hidden="true" { "▌" }
230+ span {
231+ "referenced by "
232+ a href=(format!("{}/{}s/{number}", ctx.base(), kind)) {
233+ (kind) " #" (number) " · " (title)
234 }
235+ @if kind == "change" && !closed { " — closes on merge" }
236 }
237 }
238 }
@@ -300,6 +312,52 @@
312 }
313 }
314 }
315+ };
316+
317+ html! {
318+ div .columns.columns-repo {
319+ div .columns-main { (main) }
320+ aside .columns-aside.is-sticky {
321+ div .aside-block {
322+ div .label-condensed { "Details" }
323+ @if let Some(a) = d.author {
324+ div .dotline {
325+ span .dotline-key { "Author" }
326+ span .dotline-val { (crate::views::user_link(a)) }
327+ }
328+ }
329+ div .dotline {
330+ span .dotline-key { "Opened" }
331+ span .dotline-val
332+ title=(d.created_at.format("%Y-%m-%d %H:%M UTC").to_string()) {
333+ (crate::views::relative_time(d.created_at, Utc::now())) " ago"
334+ }
335+ }
336+ div .dotline {
337+ span .dotline-key { "Comments" }
338+ span .dotline-val { (d.comments.len()) }
339+ }
340+ }
341+
342+ @if !d.labels.is_empty() {
343+ div .aside-block {
344+ div .label-condensed { "Labels" }
345+ div .aside-chips {
346+ @for l in d.labels { (label_chip(l)) }
347+ }
348+ }
349+ }
350+
351+ @if !d.assignees.is_empty() {
352+ div .aside-block {
353+ div .label-condensed { "Assignees" }
354+ @for a in d.assignees {
355+ div { (crate::views::user_link(a)) }
356+ }
357+ }
358+ }
359+ }
360+ }
361 }
362}
363
@@ -307,7 +365,7 @@
365 html! {
366 div .comment {
367 div .row {
310 strong { (c.author) }
368+ strong { (crate::views::user_link(&c.author)) }
369 span .faint { (c.created_at.format("%Y-%m-%d %H:%M").to_string()) }
370 @if c.edited { span .faint { "edited" } }
371 }
crates/df-web/src/views/layout.rs+221 −59
@@ -19,27 +19,44 @@
19 pub nonce: &'a str,
20}
21
22/// The wordmark: a vermilion triangle with the page ground knocked out of it,
23/// beside the name.
22+/// The wordmark: a bordered `df` tile beside the name, both monospace.
23///
25/// The glyph is decorative — "Dogfood" beside it is the accessible name — so it
26/// is hidden from assistive technology rather than announced as a shape. The
27/// inner cutout is filled with `var(--bg)` rather than a fixed colour so the
28/// mark stays correct in both themes.
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) {
32 svg .brand-mark width="16" height="16" viewBox="0 0 16 16" aria-hidden="true" {
33 path d="M8 1 15 14H1L8 1Z" fill="currentColor" {}
34 path d="M8 6 11.5 12h-7L8 6Z" fill="var(--bg)" {}
35 }
36 span { "Dogfood" }
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`.
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" {
@@ -49,83 +66,110 @@
66 title { (chrome.title) " · Dogfood" }
67 link rel="stylesheet" href="/assets/app.css";
68 meta name="color-scheme" content="dark light";
52 meta name="theme-color" media="(prefers-color-scheme: light)" content="#f4f1ea";
53 meta name="theme-color" media="(prefers-color-scheme: dark)" content="#12100e";
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 }
58 body .grid-field
59 hx-headers=(format!(r#"{{"x-csrf-token": "{}"}}"#, chrome.csrf))
60 {
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 {
67 // The brand rule. Decorative, and the only full-bleed use
68 // of the accent in the chrome.
69 span .masthead-rule aria-hidden="true" {}
82 div .masthead-inner {
71 (brand("/"))
72 // A plain GET form, so search works with scripting
73 // disabled like everything else (spec §7).
74 form .masthead-search method="get" action="/search" role="search" {
75 input type="search" name="q" placeholder="Search"
76 aria-label="Search repositories, changes and issues";
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 }
78 // JS-only: there is no server-side notion of theme to
79 // toggle without it, so it starts hidden and theme.js
80 // reveals it. The site's default dark theme is what
81 // every visitor without scripting sees.
82 button .btn.theme-toggle type="button" data-theme-toggle
83 hidden aria-label="Switch between dark and light theme" {
84 svg .icon-sun width="16" height="16" viewBox="0 0 16 16"
85 fill="none" stroke="currentColor" stroke-width="1.5"
86 aria-hidden="true" {
87 circle cx="8" cy="8" r="3.25" {}
88 path d="M8 1.2v1.8M8 13v1.8M2.6 8H1M15 8h-1.6M3.6 3.6l1.3 1.3M11.1 11.1l1.3 1.3M12.4 3.6l-1.3 1.3M4.9 11.1l-1.3 1.3" stroke-linecap="round" {}
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 }
90 svg .icon-moon width="16" height="16" viewBox="0 0 16 16"
91 fill="currentColor" aria-hidden="true" {
92 path d="M13.8 10.2A6 6 0 0 1 5.8 2.2a6.3 6.3 0 1 0 8 8Z" {}
93 }
94 }
95 nav aria-label="Account" {
96 @if let Some(u) = chrome.user {
97 a href="/new" { "New repository" }
98 a href="/orgs/new" { "New organization" }
99 a href="/settings" { (u.handle) }
100 form method="post" action="/logout" style="display:inline" {
101 input type="hidden" name="_csrf" value=(chrome.csrf);
102 button .btn.btn-quiet-danger type="submit" { "Sign out" }
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 }
104 } @else {
105 a .btn href="/login" { "Sign in" }
106 a .btn.btn-primary href="/login" { "Start free" }
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.
115 main id="main" tabindex="-1" {
116 div .wrap {
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 {
123 span .footer-mark { "Dogfood" }
124 span { "code hosting built on Jujutsu" }
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.
128 a href="/design" { "Design" }
171+ a href="/design" { "design" }
172+ a href="/design/rationale" { "rationale" }
173 span .footer-end .mono { "© 2026" }
174 }
175 }
@@ -134,11 +178,130 @@
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
@@ -153,10 +316,9 @@
316 title { (status.as_u16()) " · Dogfood" }
317 link rel="stylesheet" href="/assets/app.css";
318 }
156 body .grid-field {
319+ body {
320 a .skip-link href="#main" { "Skip to content" }
321 header .masthead {
159 span .masthead-rule aria-hidden="true" {}
322 div .masthead-inner {
323 (brand("/"))
324 }
crates/df-web/src/views/mod.rs+29 −1
@@ -13,9 +13,37 @@
13pub mod review;
14pub mod settings;
15
16pub use layout::{error_page, page, Chrome};
16+pub use layout::{error_page, page, page_full, page_with_bar, Chrome};
17
18use chrono::{DateTime, Utc};
19+use maud::{html, Markup};
20+
21+/// A Dogfood handle, linked to its profile.
22+///
23+/// Every handle the UI renders is a person with a page, so linking is the
24+/// default rather than something each call site decides. Kept here rather than
25+/// in one view module because comments, reviews, timelines, and listings all
26+/// need it and none of them owns the concept.
27+pub fn user_link(handle: &str) -> Markup {
28+ html! {
29+ a .user-link href=(format!("/{handle}")) { (handle) }
30+ }
31+}
32+
33+/// A person who may or may not have an account.
34+///
35+/// `handle` links; a bare commit name renders as text with a tooltip saying
36+/// why it does not. Only a commit with no name at all falls through to
37+/// "someone" — see `pages::actor`, which this generalises.
38+pub fn person(handle: Option<&str>, name: Option<&str>) -> Markup {
39+ html! {
40+ @match (handle, name.map(str::trim).filter(|n| !n.is_empty())) {
41+ (Some(h), _) => (user_link(h)),
42+ (None, Some(n)) => span title="this commit is not linked to a Dogfood account" { (n) },
43+ (None, None) => span .faint { "someone" },
44+ }
45+ }
46+}
47
48/// A short, coarse "how long ago" label.
49///
crates/df-web/src/views/pages.rs+375 −166
@@ -7,9 +7,12 @@
7
8use crate::views::change::change_chip;
9use crate::views::relative_time;
10use crate::views::repo::avatar;
10
11/// One row of the public "shipping right now" feed.
12+///
13+/// Sourced from the event log rather than from `changes.updated_at`, so the row
14+/// can say what actually happened — pushed, approved, merged, conflicted —
15+/// instead of the uninformative "was touched" that a timestamp alone supports.
16pub struct FeedItem {
17 pub owner: String,
18 pub repo: String,
@@ -17,14 +20,54 @@
20 pub change_id: String,
21 pub synthetic: bool,
22 pub title: String,
20 /// `None` for a change pushed by somebody with no Dogfood account — the
21 /// indexer records those, and dropping the row would misrepresent activity.
22 pub author: Option<String>,
23+ /// `None` for an event by somebody with no Dogfood account — the indexer
24+ /// records those, and dropping the row would misrepresent activity.
25+ pub actor: Option<String>,
26 /// The name the commit itself carries, used when no account matched.
24 pub author_name: Option<String>,
27+ pub actor_name: Option<String>,
28+ /// The event kind, e.g. `change.pushed`.
29+ pub kind: String,
30+ pub when: DateTime<Utc>,
31+}
32+
33+/// An open change that is part of a stack — the "in flight now" list.
34+pub struct StackItem {
35+ pub owner: String,
36+ pub repo: String,
37+ pub number: i64,
38+ pub change_id: String,
39+ pub synthetic: bool,
40+ pub title: String,
41+ pub conflicted: bool,
42 pub when: DateTime<Utc>,
43}
44
45+/// A bookmark and where it currently points.
46+pub struct BookmarkItem {
47+ pub owner: String,
48+ pub repo: String,
49+ pub name: String,
50+ pub protected: bool,
51+ pub updated_at: DateTime<Utc>,
52+}
53+
54+/// The glyph and colour class for an event kind.
55+///
56+/// An unrecognised kind gets the neutral glyph rather than being dropped: an
57+/// event the UI does not know about still happened.
58+fn feed_glyph(kind: &str) -> (&'static str, &'static str) {
59+ match kind {
60+ "change.pushed" => ("↑", "is-push"),
61+ "change.opened" | "change.reopened" | "change.ready" => ("○", "is-open"),
62+ "change.reviewed" => ("✓", "is-review"),
63+ "change.resolved" => ("✓", "is-review"),
64+ "change.merged" => ("⤳", "is-merge"),
65+ "change.conflicted" => ("◆", "is-conflict"),
66+ "change.abandoned" => ("×", "is-abandon"),
67+ _ => ("●", "is-open"),
68+ }
69+}
70+
71/// Render whoever is responsible for something.
72///
73/// Three cases, in descending order of what we actually know:
@@ -38,11 +81,7 @@
81/// readable history and an anonymous one.
82pub fn actor(handle: Option<&str>, name: Option<&str>) -> Markup {
83 html! {
41 @match (handle, name.map(str::trim).filter(|n| !n.is_empty())) {
42 (Some(h), _) => a .feed-actor href=(format!("/{h}")) { (h) },
43 (None, Some(n)) => span .feed-actor title="this commit is not linked to a Dogfood account" { (n) },
44 (None, None) => span .feed-actor.faint { "someone" },
45 }
84+ span .feed-actor { (crate::views::person(handle, name)) }
85 }
86}
87
@@ -63,120 +102,161 @@
102 "no staging area",
103];
104
105+/// Everything the landing page renders.
106+pub struct Landing<'a> {
107+ /// The instance's own clone URL, e.g. `jj git clone https://host/owner/repo`.
108+ pub clone_hint: &'a str,
109+ /// `owner/name` of a real public repository, used in the example session so
110+ /// the transcript names something a visitor can actually go and open.
111+ pub sample_repo: Option<&'a str>,
112+ pub feed: &'a [FeedItem],
113+ pub repos: &'a [RepoSummary],
114+ /// Open public changes that are part of a stack.
115+ pub in_flight: &'a [StackItem],
116+ /// Recently moved bookmarks across public repositories.
117+ pub bookmarks: &'a [BookmarkItem],
118+}
119+
120/// Landing page for signed-out visitors.
67pub fn landing(clone_hint: &str, feed: &[FeedItem], repos: &[RepoSummary]) -> Markup {
121+pub fn landing(l: Landing<'_>) -> Markup {
122 let now = Utc::now();
123+ let first_repo = l.repos.first().map(|r| format!("/{}/{}", r.owner, r.name));
124
125 html! {
71 section .hero.brand-glow aria-labelledby="hero-h" {
72 div .hero-body {
73 div .hero-eyebrow {
74 span .pill-brand { "beta" }
75 span .label-condensed { "version control, evolved" }
76 }
77 h1 #hero-h .display.hero-title {
78 "Ship code in " span .mark { "changes" } ", not commits."
79 }
80 p .hero-lede {
81 "Dogfood is code hosting built on "
82 a href="https://github.com/jj-vcs/jj" { "Jujutsu" }
83 ". Every change keeps a stable id through every rebase, conflicts \
84 are recorded instead of blocking you, and stacks stay coherent \
85 from first push to merge."
86 }
87 div .hero-actions {
88 a .btn.btn-primary.btn-lg href="/login" { "Start for free" }
89 @if let Some(r) = repos.first() {
90 a .btn.btn-lg href=(format!("/{}/{}", r.owner, r.name)) {
91 "Explore a live repo"
126+ section .band.band-surface.hero aria-labelledby="hero-h" {
127+ div .wrap {
128+ div .hero-body {
129+ span .hero-eyebrow.label-condensed { "code hosting on jujutsu" }
130+ h1 #hero-h .display.hero-title { "A branch is a pointer. Your work is not." }
131+ p .hero-lede {
132+ "Push with " span .mono { "jj git push" } " and review opens itself. \
133+ The " span style="color:var(--text)" { "change" } " keeps one identity \
134+ through every amend, rebase, and force-push."
135+ }
136+ div .hero-actions {
137+ a .btn.btn-primary href="/login" { "Start for free" }
138+ @if let Some(href) = &first_repo {
139+ a .btn href=(href) { "Browse the code" }
140 }
141 }
142+ div .clone-box {
143+ span .prompt aria-hidden="true" { "$" }
144+ code { (l.clone_hint) }
145+ }
146 }
95 div .clone-box {
96 code { (clone_hint) }
97 }
147+ (terminal(l.sample_repo))
148 }
99 // Decorative marquee of taglines. Static text, not a marquee
100 // element — it wraps rather than scrolling, so nothing moves for a
101 // reader who did not ask for motion.
102 div .ticker-rule.ticker {
103 @for t in TICKER {
104 span .ticker-item {
105 span .ticker-dot aria-hidden="true" { "◆" }
106 (t)
107 }
149+ }
150+
151+ // Decorative taglines. Static text rather than a marquee — it wraps
152+ // instead of scrolling, so nothing moves for a reader who did not ask
153+ // for motion.
154+ div .band.ticker aria-hidden="true" {
155+ @for t in TICKER {
156+ span .ticker-item {
157+ span .ticker-dot { "◆" }
158+ (t)
159 }
160 }
161 }
162
112 div .landing-columns {
113 section .landing-main aria-labelledby="feed-h" {
114 div .section-head {
115 h2 #feed-h .label-condensed.slash { "shipping right now" }
116 span .faint.mono.live-indicator {
117 span .live-dot aria-hidden="true" {}
118 "live"
163+ div .band { div .wrap {
164+ div .columns {
165+ section .columns-main aria-labelledby="feed-h" {
166+ div .section-head {
167+ h2 #feed-h style="margin:0;font-size:var(--text-lg);line-height:28px" { "Live" }
168+ span .live-indicator {
169+ span .live-dot aria-hidden="true" {}
170+ "public activity"
171+ }
172+ }
173+ @if l.feed.is_empty() {
174+ div .empty {
175+ h2 { "Nothing public yet" }
176+ p { "Activity in public repositories will show up here." }
177+ }
178+ } @else {
179+ ul .feed {
180+ @for item in l.feed { (feed_row(item, now)) }
181+ }
182+ div .feed-foot {
183+ span {
184+ "Every row is a change id. The commit behind it may be \
185+ rewritten; the row will not move."
186+ }
187+ }
188 }
189 }
121 p .dim.section-note {
122 "Public changes people are pushing across Dogfood repositories."
123 }
124 @if feed.is_empty() {
125 div .empty {
126 h2 { "Nothing public yet" }
127 p { "Changes pushed to public repositories will show up here." }
190+
191+ aside .columns-aside {
192+ @if !l.in_flight.is_empty() {
193+ section .aside-block aria-labelledby="inflight-h" {
194+ h2 #inflight-h .label-condensed { "In flight now" }
195+ @for s in l.in_flight { (stack_mini_row(s, now)) }
196+ }
197 }
129 } @else {
130 ul .feed {
131 @for item in feed {
132 (feed_row(item, now))
198+ @if !l.bookmarks.is_empty() {
199+ section .aside-block aria-labelledby="marks-h" {
200+ h2 #marks-h .label-condensed { "Bookmarks" }
201+ @for b in l.bookmarks { (bookmark_line(b, now)) }
202 }
203 }
204 }
205 }
206+ } }
207
138 aside .landing-aside {
139 section aria-labelledby="why-h" {
140 h2 #why-h .label-condensed.slash { "why switch" }
141 ul .compare {
142 @for (them, us) in COMPARISONS {
143 li .compare-item {
144 span .compare-them {
145 span .compare-sign aria-hidden="true" { "−" }
146 (them)
147 }
148 span .compare-us {
149 span .compare-sign aria-hidden="true" { "+" }
150 (us)
151 }
208+ section .band.band-surface aria-labelledby="why-h" {
209+ div .wrap {
210+ div .band-head {
211+ h2 #why-h { "Why switch" }
212+ span .band-note { "Four things a branch pointer cannot represent." }
213+ }
214+ ul .compare {
215+ @for (them, us) in COMPARISONS {
216+ li .compare-item {
217+ span .compare-them {
218+ span .compare-sign aria-hidden="true" { "−" }
219+ (them)
220+ }
221+ span .compare-us {
222+ span .compare-sign aria-hidden="true" { "+" }
223+ (us)
224 }
225 }
226 }
227 }
228+ }
229+ }
230
157 @if !repos.is_empty() {
158 section aria-labelledby="repos-h" {
159 h2 #repos-h .label-condensed.slash { "explore public repos" }
160 div .repo-cards {
161 @for r in repos {
162 (repo_card(r))
163 }
231+ @if !l.repos.is_empty() {
232+ section .band aria-labelledby="repos-h" {
233+ div .wrap {
234+ div .band-head {
235+ h2 #repos-h { "Explore public repos" }
236+ span .band-note {
237+ "No account needed to read a repo, a change, or a stack."
238 }
239 }
240+ div .repo-cards {
241+ @for r in l.repos { (repo_card(r, now)) }
242+ }
243 }
244 }
245 }
246
170 section .cta-band.brand-glow aria-labelledby="cta-h" {
171 h2 #cta-h .display.cta-title { "Stop fighting your version control." }
172 p .cta-lede {
173 "Bring your team to a forge that finally matches how you actually work."
174 }
175 div .hero-actions.cta-actions {
176 a .btn.btn-primary.btn-lg href="/login" { "Start for free" }
177 @if let Some(r) = repos.first() {
178 a .btn.btn-lg href=(format!("/{}/{}", r.owner, r.name)) {
179 "Browse a repo first"
247+ section .band.band-surface.cta aria-labelledby="cta-h" {
248+ div .wrap {
249+ div {
250+ h2 #cta-h .cta-title { "Stop fighting your version control." }
251+ p .cta-lede {
252+ "Bring your team to a forge that matches how you already work."
253+ }
254+ }
255+ span .spacer {}
256+ div .hero-actions {
257+ a .btn.btn-primary href="/login" { "Start for free" }
258+ @if let Some(href) = &first_repo {
259+ a .btn href=(href) { "Browse a repo first" }
260 }
261 }
262 }
@@ -184,51 +264,146 @@
264 }
265}
266
267+/// The hero's terminal panel.
268+///
269+/// A transcript of a `jj` session that animates like a live terminal: commands
270+/// type out character by character, and output lines reveal after each command
271+/// finishes. The animation is driven by `terminal.js` using data attributes
272+/// on each line, respects `prefers-reduced-motion`, and only plays once when
273+/// the terminal scrolls into view.
274+fn terminal(sample_repo: Option<&str>) -> Markup {
275+ let repo = sample_repo.unwrap_or("your-org/your-repo");
276+
277+ html! {
278+ figure .term {
279+ div .term-bar {
280+ span .term-dots aria-hidden="true" { span {} span {} span {} }
281+ span .term-host { "~/" (repo.rsplit('/').next().unwrap_or("repo")) }
282+ span .spacer {}
283+ figcaption .label-condensed { "example session" }
284+ }
285+ pre .term-body data-term-animate="" {
286+ span .term-cmd data-term-line="" data-term-cmd="" data-term-text="jj new main -m 'Cache shortest-unique prefixes per repo'" { "jj new main -m 'Cache shortest-unique prefixes per repo'" } "\n"
287+ span .term-out data-term-line="" { "Working copy now at: vlrxqmpd 8f21c0ab (empty) Cache shortest-unique prefixes" } "\n"
288+ span .term-out.is-faint data-term-line="" { "Parent commit : mrukztqx c40d1f8 main | Drop the legacy branch-tip fallback" } "\n"
289+ span data-term-line="" data-term-blank="" { "" } "\n"
290+ span .term-cmd data-term-line="" data-term-cmd="" data-term-text="jj status" { "jj status" } "\n"
291+ span .term-out data-term-line="" { "Working copy changes:" } "\n"
292+ span .term-out.is-add data-term-line="" { "M crates/store/prefix.rs" } "\n"
293+ span .term-out.is-add data-term-line="" { "A crates/store/prefix_cache.rs" } "\n"
294+ span data-term-line="" data-term-blank="" { "" } "\n"
295+ span .term-cmd data-term-line="" data-term-cmd="" data-term-text="jj git push --change @" { "jj git push --change @" } "\n"
296+ span .term-out data-term-line="" { "Changes to push to origin:" } "\n"
297+ span .term-out.is-add data-term-line="" { " Add bookmark push-vlrxqmpd to 8f21c0ab" } "\n"
298+ span .term-out.is-action data-term-line="" { "remote: change vlrx is open for review" } "\n"
299+ span .term-out.is-action data-term-line="" { "remote: /" (repo) "/changes/vlrx" } "\n"
300+ span data-term-line="" data-term-blank="" { "" } "\n"
301+ span .term-cmd data-term-line="" data-term-cmd="" data-term-text="jj log -r 'stack(@)'" { "jj log -r 'stack(@)'" } "\n"
302+ span .term-out.is-conflict data-term-line="" { "◆ zmltxruqpvks conflict in revset.rs" } "\n"
303+ span .term-out.is-open data-term-line="" { "○ wpqvkrtnmzox approved" } "\n"
304+ span .term-out.is-identity data-term-line="" { "@ vlrxqmpdtwzk open for review" } "\n"
305+ span .term-out.is-faint data-term-line="" { "┴─ main c40d1f8" } "\n"
306+ }
307+ div .term-foot {
308+ "Push a bookmark, the change opens for review. No web form, no PR button."
309+ }
310+ }
311+ }
312+}
313+
314/// One feed row.
315///
316/// The exact timestamp goes in `title` because the visible label is coarse on
317/// purpose — see `relative_time`.
318fn feed_row(item: &FeedItem, now: DateTime<Utc>) -> Markup {
319 let href = format!("/{}/{}/changes/{}", item.owner, item.repo, item.number);
320+ let (glyph, class) = feed_glyph(&item.kind);
321
322 html! {
323 li .feed-row {
196 (avatar(item.author.as_deref().or(item.author_name.as_deref()).unwrap_or("?")))
197 div .feed-main {
198 a .feed-title href=(href) { (item.title) }
199 div .feed-meta {
200 (actor(item.author.as_deref(), item.author_name.as_deref()))
201 span .faint { "in" }
202 a .mono href=(format!("/{}/{}", item.owner, item.repo)) {
203 (item.owner) "/" (item.repo)
204 }
205 span .faint aria-hidden="true" { "·" }
206 span .faint.tnum title=(item.when.format("%Y-%m-%d %H:%M UTC").to_string()) {
207 (relative_time(item.when, now))
208 }
209 }
324+ span .feed-glyph.(class) aria-hidden="true" { (glyph) }
325+ a .feed-repo href=(format!("/{}/{}", item.owner, item.repo)) {
326+ span .owner { (item.owner) }
327+ "/" (item.repo)
328 }
211 div .feed-side {
212 (change_chip(&item.change_id, item.synthetic))
329+ span .feed-verb {
330+ (actor(item.actor.as_deref(), item.actor_name.as_deref()))
331+ " " (activity_verb(&item.kind))
332 }
333+ a .feed-title href=(href) { (item.title) }
334+ (change_chip(&item.change_id, item.synthetic))
335+ span .feed-age title=(item.when.format("%Y-%m-%d %H:%M UTC").to_string()) {
336+ (relative_time(item.when, now))
337+ }
338+ }
339+ }
340+}
341+
342+/// A change in an aside list, with the stack rail beside it.
343+fn stack_mini_row(s: &StackItem, now: DateTime<Utc>) -> Markup {
344+ let href = format!("/{}/{}/changes/{}", s.owner, s.repo, s.number);
345+ let (glyph, class) = if s.conflicted {
346+ ("◆", "is-conflict")
347+ } else {
348+ ("○", "is-open")
349+ };
350+
351+ html! {
352+ a .mini-row href=(href) {
353+ span .mini-rail aria-hidden="true" {}
354+ span .mini-glyph.(class) aria-hidden="true" { (glyph) }
355+ (change_chip(&s.change_id, s.synthetic))
356+ span .mini-title { (s.title) }
357+ span .mini-age title=(s.when.format("%Y-%m-%d %H:%M UTC").to_string()) {
358+ (relative_time(s.when, now))
359+ }
360 }
361 }
362}
363
218fn repo_card(r: &RepoSummary) -> Markup {
364+/// A bookmark and when it last moved.
365+///
366+/// Links to its repository's bookmark list rather than to the bookmark itself:
367+/// a bookmark has no page of its own, because it is a pointer, not a thing —
368+/// which is the distinction the whole product turns on.
369+fn bookmark_line(b: &BookmarkItem, now: DateTime<Utc>) -> Markup {
370 html! {
371+ a .bookmark-line href=(format!("/{}/{}/bookmarks", b.owner, b.repo)) {
372+ span .chip { (b.name) }
373+ @if b.protected {
374+ span .bookmark-flag { "protected" }
375+ }
376+ span .spacer {}
377+ span .mini-age title=(b.updated_at.format("%Y-%m-%d %H:%M UTC").to_string()) {
378+ (relative_time(b.updated_at, now))
379+ }
380+ }
381+ }
382+}
383+
384+fn repo_card(r: &RepoSummary, now: DateTime<Utc>) -> Markup {
385+ html! {
386 a .repo-card href=(format!("/{}/{}", r.owner, r.name)) {
221 span .repo-card-rail aria-hidden="true" {}
387 span .repo-card-name {
223 span .faint { (r.owner) "/" }
224 span .repo-card-repo { (r.name) }
225 @if r.private {
226 span .chip { "private" }
227 }
388+ (r.owner) "/" (r.name)
389+ @if r.private { " " span .chip { "private" } }
390 }
391 @if let Some(d) = &r.description {
392 span .repo-card-desc { (d) }
393 }
394+ span .repo-card-meta {
395+ @if r.open_changes > 0 {
396+ span .is-open { "○ " (r.open_changes) }
397+ }
398+ @if r.conflicted > 0 {
399+ span .is-conflict { "◆ " (r.conflicted) }
400+ }
401+ @if let Some(t) = r.pushed_at {
402+ span .at-end title=(t.format("%Y-%m-%d %H:%M UTC").to_string()) {
403+ (relative_time(t, now))
404+ }
405+ }
406+ }
407 }
408 }
409}
@@ -272,17 +447,23 @@
447}
448
449/// Signed-in dashboard.
450+///
451+/// The same two-column shape as the landing page, with the marketing bands
452+/// removed and the personalised lists in their place. The aside answers "what
453+/// is waiting on me" first, because that is the only question a dashboard is
454+/// actually for.
455pub fn dashboard(d: Dashboard<'_>) -> Markup {
456 let now = Utc::now();
457
458 html! {
459 div .dash-head {
460 div {
281 h1 .display.dash-title {
461+ h1 .dash-title {
462 "Welcome back, " span .dash-name { (d.user.label()) }
463 }
284 p .dim { "Here's what needs you across your repositories." }
464+ p .dim.section-note { "Here's what needs you across your repositories." }
465 }
466+ span .spacer {}
467 a .btn.btn-primary href="/new" { "New repository" }
468 }
469
@@ -293,12 +474,12 @@
474 p { a .btn.btn-primary href="/new" { "New repository" } }
475 }
476 } @else {
296 div .landing-columns {
297 div .landing-main {
298 section aria-labelledby="awaiting-h" {
477+ div .columns {
478+ div .columns-main {
479+ section .dash-section aria-labelledby="awaiting-h" {
480 div .section-head {
300 h2 #awaiting-h .label-condensed.slash { "awaiting your review" }
301 span .faint.mono.tnum { (d.awaiting.len()) }
481+ h2 #awaiting-h .label-condensed { "Awaiting your review" }
482+ span .live-indicator.tnum { (d.awaiting.len()) }
483 }
484 @if d.awaiting.is_empty() {
485 p .dim.section-note { "Nothing is waiting on you." }
@@ -309,9 +490,9 @@
490 }
491 }
492
312 section aria-labelledby="mine-h" {
493+ section .dash-section aria-labelledby="mine-h" {
494 div .section-head {
314 h2 #mine-h .label-condensed.slash { "your open changes" }
495+ h2 #mine-h .label-condensed { "Your open changes" }
496 }
497 @if d.mine.is_empty() {
498 p .dim.section-note { "You have no open changes." }
@@ -322,9 +503,9 @@
503 }
504 }
505
325 section aria-labelledby="activity-h" {
506+ section .dash-section aria-labelledby="activity-h" {
507 div .section-head {
327 h2 #activity-h .label-condensed.slash { "watched activity" }
508+ h2 #activity-h .label-condensed { "Watched activity" }
509 }
510 @if d.activity.is_empty() {
511 p .dim.section-note { "No recent activity in your repositories." }
@@ -336,11 +517,13 @@
517 }
518 }
519
339 aside .landing-aside aria-labelledby="dash-repos-h" {
340 h2 #dash-repos-h .label-condensed.slash { "your repositories" }
341 div .repo-cards {
342 @for r in d.repos { (repo_card(r)) }
343 a .repo-card-new href="/new" { "new repository" }
520+ aside .columns-aside aria-labelledby="dash-repos-h" {
521+ section .aside-block {
522+ h2 #dash-repos-h .label-condensed { "Your repositories" }
523+ div .repo-cards style="grid-template-columns:minmax(0,1fr)" {
524+ @for r in d.repos { (repo_card(r, now)) }
525+ a .repo-card-new href="/new" { "new repository" }
526+ }
527 }
528 }
529 }
@@ -348,30 +531,37 @@
531 }
532}
533
534+/// A change in one of the dashboard's lists.
535+///
536+/// Same row grammar as the public feed — glyph, where, who, what, id, when —
537+/// so the two lists scan identically. Here the glyph is the change's *state*
538+/// rather than an event kind, because these rows are things that are still
539+/// true, not things that happened.
540fn dash_change_row(c: &DashChange, now: DateTime<Utc>) -> Markup {
541 let href = format!("/{}/{}/changes/{}", c.owner, c.repo, c.number);
542+ let (glyph, class) = match (c.conflicted, c.state.as_str()) {
543+ (true, _) => ("◆", "is-conflict"),
544+ (_, "merged") => ("⤳", "is-merge"),
545+ (_, "abandoned") => ("×", "is-abandon"),
546+ (_, "draft") => ("·", "is-abandon"),
547+ _ => ("○", "is-review"),
548+ };
549
550 html! {
551 li .feed-row {
356 (avatar(c.author.as_deref().or(c.author_name.as_deref()).unwrap_or("?")))
357 div .feed-main {
358 a .feed-title href=(href) { (c.title) }
359 div .feed-meta {
360 (actor(c.author.as_deref(), c.author_name.as_deref()))
361 span .faint { "in" }
362 a .mono href=(format!("/{}/{}", c.owner, c.repo)) {
363 (c.owner) "/" (c.repo)
364 }
365 span .faint aria-hidden="true" { "·" }
366 span .faint.tnum title=(c.updated_at.format("%Y-%m-%d %H:%M UTC").to_string()) {
367 (relative_time(c.updated_at, now))
368 }
369 }
552+ span .feed-glyph.(class) aria-hidden="true" { (glyph) }
553+ a .feed-repo href=(format!("/{}/{}", c.owner, c.repo)) {
554+ span .owner { (c.owner) }
555+ "/" (c.repo)
556 }
371 div .feed-side {
372 (crate::views::change::state_badge(&c.state, c.conflicted))
373 (change_chip(&c.change_id, c.synthetic))
557+ span .feed-verb {
558+ (actor(c.author.as_deref(), c.author_name.as_deref()))
559 }
560+ a .feed-title href=(href) { (c.title) }
561+ (change_chip(&c.change_id, c.synthetic))
562+ span .feed-age title=(c.updated_at.format("%Y-%m-%d %H:%M UTC").to_string()) {
563+ (relative_time(c.updated_at, now))
564+ }
565 }
566 }
567}
@@ -427,6 +617,10 @@
617 pub name: String,
618 pub description: Option<String>,
619 pub private: bool,
620+ pub open_changes: i64,
621+ pub conflicted: i64,
622+ /// `None` for a repository nobody has pushed to yet.
623+ pub pushed_at: Option<DateTime<Utc>>,
624}
625
626/// The sign-in page.
@@ -436,34 +630,49 @@
630/// and a disabled control that says why is more honest than a live-looking
631/// button that does nothing. They carry `aria-disabled` and no `href`, so they
632/// are not in the tab order as if they were usable.
439pub fn signin(sso_href: &str) -> Markup {
633+pub fn signin(sso_href: &str, clone_hint: &str, sample_repo: Option<&str>) -> Markup {
634 html! {
635 div .signin {
636 div .signin-intro {
443 span .label-condensed.signin-eyebrow { "welcome back" }
444 h1 .display.signin-title { "Sign in to your changes." }
445 p .dim {
446 "Single sign-on through your organization. No passwords to leak, \
447 rotate, or forget."
637+ h1 .signin-title { "Sign in to Dogfood" }
638+ p .dim.measure {
639+ "Reading public repositories needs no account. Sign in to push, \
640+ review, and open issues."
641 }
642 }
643
451 a .btn.btn-primary.btn-block href=(sso_href) { "Continue with SSO" }
644+ div .signin-card {
645+ a .btn.btn-primary.btn-block href=(sso_href) {
646+ span aria-hidden="true" .mono { "↗" }
647+ " Continue with your SSO provider"
648+ }
649
453 div .signin-or {
454 span .signin-rule aria-hidden="true" {}
455 span .faint.mono { "or" }
456 span .signin-rule aria-hidden="true" {}
650+ div .signin-or {
651+ span .signin-rule aria-hidden="true" {}
652+ span .label-condensed { "or" }
653+ span .signin-rule aria-hidden="true" {}
654+ }
655+
656+ // Present but not offered. A live-looking control that silently
657+ // does nothing is worse than one that says why it cannot.
658+ span .btn.btn-block.is-disabled aria-disabled="true"
659+ title="Email sign-in links are not available on this instance" {
660+ "Email me a sign-in link"
661+ }
662+ span .btn.btn-block.is-disabled aria-disabled="true"
663+ title="Passkey sign-in is not available on this instance yet" {
664+ "Continue with a passkey"
665+ }
666 }
667
459 span .btn.btn-block.is-disabled aria-disabled="true"
460 title="Passkey sign-in is not available on this instance yet" {
461 "Continue with a passkey"
668+ div .signin-note {
669+ span .label-condensed { "No account yet" }
670+ span .dim { "This instance is invitation-only — but you can read anything public first:" }
671+ code .mono { (clone_hint) }
672 }
673
464 p .hint.signin-foot {
465 "New here? This instance is invitation-only — ask an administrator \
466 for an invitation."
674+ @if let Some(r) = sample_repo {
675+ a href=(format!("/{r}")) { "Browse a repo instead →" }
676 }
677 }
678 }
crates/df-web/src/views/repo.rs+477 −86
@@ -2,50 +2,119 @@
2
3use maud::{html, Markup, PreEscaped};
4
5use df_store::{Bookmark, EntryKind, Revision, TreeEntry};
5+use df_store::{EntryKind, Revision, TreeEntry};
6
7use crate::repo_ctx::RepoContext;
8
9/// Repository header: name, visibility, and the browse tabs.
9+/// The repository sub-bar: where you are, whether it is public, the four
10+/// sections, and the repository's vital signs.
11+///
12+/// Rendered full-bleed directly under the masthead by
13+/// [`views::page_with_bar`](crate::views::page_with_bar), so it is frame rather
14+/// than page content and stays identical across every tab.
15+///
16+/// The description does *not* appear here. It is repository metadata, not
17+/// navigation, and it belongs in the About panel on the repository's own page —
18+/// repeating it above every diff is noise on thirty screens to serve one.
19pub fn header(ctx: &RepoContext, active: &str) -> Markup {
20 let base = ctx.base();
12 let tabs = [
13 ("code", "Code", base.clone()),
14 ("changes", "Changes", format!("{base}/changes")),
15 ("issues", "Issues", format!("{base}/issues")),
16 ("bookmarks", "Bookmarks", format!("{base}/bookmarks")),
21+ let nav = &ctx.nav;
22+
23+ // `None` renders no count at all rather than a zero. "Issues 0" invites a
24+ // reader to wonder whether it is broken; a bare label does not.
25+ let count = |n: i64| (n > 0).then(|| n.to_string());
26+ let tabs: [(&str, &str, String, Option<String>); 4] = [
27+ ("code", "Code", base.clone(), None),
28+ ("changes", "Changes", format!("{base}/changes"), count(nav.open_changes)),
29+ ("issues", "Issues", format!("{base}/issues"), count(nav.open_issues)),
30+ ("bookmarks", "Bookmarks", format!("{base}/bookmarks"), count(nav.bookmarks)),
31 ];
32
33 html! {
20 div .repo-head {
21 div .row {
22 h1 .repo-title {
23 a .repo-owner href=(format!("/{}", ctx.owner)) { (ctx.owner) }
24 span .repo-slash aria-hidden="true" { "/" }
25 a .repo-name href=(base) { (ctx.repo.name) }
26 }
27 @if !ctx.repo.is_public() {
28 span .chip { "private" }
34+ div .subnav {
35+ div .subnav-inner {
36+ span .subnav-path {
37+ a href=(format!("/{}", ctx.owner)) { (ctx.owner) }
38+ span .sep aria-hidden="true" { "/" }
39+ a href=(base) { (ctx.repo.name) }
40 }
30 }
31 @if let Some(d) = &ctx.repo.description {
32 p .dim .repo-desc { (d) }
33 }
34 nav .subtabs .repo-tabs aria-label="Repository" {
35 @for (key, label, href) in &tabs {
36 a href=(href) .active[*key == active]
37 aria-current=[(*key == active).then_some("page")] {
38 (label)
41+ span .badge { @if ctx.repo.is_public() { "public" } @else { "private" } }
42+ span .vrule aria-hidden="true" {}
43+
44+ nav .subtabs aria-label="Repository" {
45+ @for (key, label, href, n) in &tabs {
46+ a href=(href) .active[*key == active]
47+ aria-current=[(*key == active).then_some("page")] {
48+ (label)
49+ @if let Some(n) = n {
50+ span .tab-count { (n) }
51+ }
52+ }
53 }
40 }
41 @if ctx.access.can_change_settings() {
42 a .subtabs-end href=(format!("{base}/settings")) { "Settings" }
54+ @if ctx.access.can_change_settings() {
55+ a href=(format!("{base}/settings")) .active[active == "settings"]
56+ aria-current=[(active == "settings").then_some("page")] {
57+ "Settings"
58+ }
59+ }
60 }
61+
62+ span .spacer {}
63+ span .subnav-meta { (nav_meta(nav)) }
64 }
65 }
66 }
67}
68
69+/// The right-hand readout on the sub-bar.
70+///
71+/// Only the facts that are true get a clause: a repository with no conflicts
72+/// says nothing about conflicts rather than claiming "0 conflicted", and an
73+/// empty repository gets an empty bar instead of three zeroes.
74+fn nav_meta(nav: &crate::repo_ctx::RepoNav) -> String {
75+ let plural = |n: i64, one: &str, many: &str| if n == 1 { one.to_string() } else { many.to_string() };
76+
77+ let mut parts = Vec::new();
78+ if nav.open_changes > 0 {
79+ parts.push(format!("{} open", nav.open_changes));
80+ }
81+ if nav.conflicted > 0 {
82+ parts.push(format!("{} conflicted", nav.conflicted));
83+ }
84+ if nav.bookmarks > 0 {
85+ parts.push(format!(
86+ "{} {}",
87+ nav.bookmarks,
88+ plural(nav.bookmarks, "bookmark", "bookmarks")
89+ ));
90+ }
91+ parts.join(" · ")
92+}
93+
94+#[cfg(test)]
95+mod nav_meta_tests {
96+ use super::nav_meta;
97+ use crate::repo_ctx::RepoNav;
98+
99+ #[test]
100+ fn only_true_facts_get_a_clause() {
101+ assert_eq!(
102+ nav_meta(&RepoNav { open_changes: 128, conflicted: 3, bookmarks: 9, open_issues: 42 }),
103+ "128 open · 3 conflicted · 9 bookmarks"
104+ );
105+ }
106+
107+ /// A quiet repository must not advertise three zeroes.
108+ #[test]
109+ fn a_zero_is_silence_not_a_zero() {
110+ assert_eq!(nav_meta(&RepoNav::default()), "");
111+ assert_eq!(
112+ nav_meta(&RepoNav { open_changes: 1, bookmarks: 1, ..RepoNav::default() }),
113+ "1 open · 1 bookmark"
114+ );
115+ }
116+}
117+
118/// Clone instructions, shown on an empty repository.
119pub fn empty_repo(https: &str, ssh: &str, default_bookmark: &str) -> Markup {
120 html! {
@@ -108,7 +177,10 @@
177/// This is the *directory's* tip, not a per-file blame — see the note on
178/// `tree_listing`.
179pub struct TipCommit {
180+ /// The name the commit carries.
181 pub author: String,
182+ /// The account that name resolved to, when its email matched one.
183+ pub author_handle: Option<String>,
184 pub summary: String,
185 pub when: chrono::DateTime<chrono::Utc>,
186 pub change_id: Option<String>,
@@ -144,56 +216,128 @@
216 }
217}
218
219+/// The last commit to touch one entry of a directory listing — the
220+/// GitHub-style message-and-date column, sourced from `last_commits_in_dir`.
221+pub struct EntryHistory {
222+ pub summary: String,
223+ pub when: chrono::DateTime<chrono::Utc>,
224+ /// The jj change id, when the touching commit had one — lets the message
225+ /// link to the change page. `None` for a plain-git commit; that history
226+ /// still shows the message and date, just not as a link.
227+ pub change_id: Option<String>,
228+}
229+
230+/// A bookmark as the sidebar, the switcher and the bookmarks page show it.
231+///
232+/// Read from the database rather than from git refs: the store knows a name and
233+/// an object id, but only the index knows *which change* that object belongs to,
234+/// and the change is the thing worth linking to.
235+pub struct MarkRow {
236+ pub name: String,
237+ pub protected: bool,
238+ pub updated_at: chrono::DateTime<chrono::Utc>,
239+ /// The change at the bookmark's tip, when the indexer knows one. `None` for
240+ /// a bookmark pointing at a commit the indexer has not seen — which is a
241+ /// real state after a restore, not a bug.
242+ pub change_id: Option<String>,
243+ pub number: Option<i64>,
244+ pub title: Option<String>,
245+}
246+
247+/// The right-hand column on a repository's own page.
248+///
249+/// Only rendered at the repository root. On a nested directory the reader is
250+/// looking at files, and repeating the clone commands beside every folder is
251+/// noise.
252+pub struct RepoSidebar<'a> {
253+ pub https: &'a str,
254+ pub ssh: &'a str,
255+ pub open_changes: i64,
256+ pub conflicted: i64,
257+ pub open_issues: i64,
258+ /// Distinct commit authors seen by the indexer.
259+ pub contributors: i64,
260+ pub size_bytes: u64,
261+ pub bookmarks: &'a [MarkRow],
262+}
263+
264+/// Everything the directory listing renders.
265+pub struct Tree<'a> {
266+ /// The bookmark or revision being browsed, as the reader typed it.
267+ pub rev_label: &'a str,
268+ /// The directory within the tree; empty at the root.
269+ pub path: &'a str,
270+ pub entries: &'a [TreeEntry],
271+ /// The tip of what is being browsed. `None` when the store could not
272+ /// produce a log — the listing is the point of the page, so it still
273+ /// renders.
274+ pub tip: Option<&'a TipCommit>,
275+ /// The rendered README and the filename it was actually found under.
276+ pub readme: Option<&'a (String, Markup)>,
277+ /// Last-commit data per entry name, from one bounded history walk. An
278+ /// entry the walk did not reach simply has no history cells.
279+ pub history: &'a std::collections::HashMap<String, EntryHistory>,
280+ /// Present only at the repository root.
281+ pub sidebar: Option<&'a RepoSidebar<'a>>,
282+}
283+
284/// The directory listing.
285///
149/// The design puts a per-file "last change that touched this path" column here.
150/// Dogfood cannot fill it: the index records changes and revisions but never
151/// the paths a change touched, so there is nothing to join against. The column
152/// is therefore left out rather than filled with a plausible-looking value —
153/// showing the directory tip's message on every row would read as per-file
154/// history and be wrong on all but one of them.
155pub fn tree_listing(
156 ctx: &RepoContext,
157 rev_label: &str,
158 path: &str,
159 entries: &[TreeEntry],
160 tip: Option<&TipCommit>,
161 readme: Option<&(String, Markup)>,
162) -> Markup {
286+/// One history column, not two: the commit message sits next to the filename
287+/// the way it does on GitHub, and takes the place a byte-size column used to
288+/// have, in favour of when the file was last touched — which is what a reader
289+/// scanning a repo for the first time actually wants to know. Backed by
290+/// `last_commits_in_dir`'s single bounded history walk, so an entry the walk
291+/// did not reach in 500 commits simply has no history cell rather than a wrong
292+/// or misleading one.
293+pub fn tree_listing(ctx: &RepoContext, t: Tree<'_>) -> Markup {
294+ let Tree { rev_label, path, entries, tip, readme, history, sidebar } = t;
295 let base = ctx.base();
296+ let now = chrono::Utc::now();
297
165 html! {
166 div .row.tree-crumbs {
167 span .label-condensed { "Browsing" }
168 span .chip { (rev_label) }
298+ let main = html! {
299+ div .tree-crumbs {
300+ (bookmark_switcher(&base, rev_label, path, sidebar.map(|s| s.bookmarks).unwrap_or(&[])))
301 (breadcrumbs(&base, rev_label, path))
302 }
303
172 @if let Some(t) = tip {
173 div .commit-bar {
174 (avatar(&t.author))
175 span .commit-bar-author { (t.author) }
176 span .commit-bar-msg { (t.summary) }
177 @if let Some(c) = &t.change_id {
178 span .chip.chip-change.commit-bar-chip title=(format!("jj change id: {c}")) {
179 (&c[..12.min(c.len())])
304+ div .filelist {
305+ @if let Some(t) = tip {
306+ div .commit-bar {
307+ span .commit-bar-rail aria-hidden="true" {}
308+ (avatar(t.author_handle.as_deref().unwrap_or(&t.author)))
309+ span .commit-bar-author {
310+ (crate::views::person(t.author_handle.as_deref(), Some(&t.author)))
311+ }
312+ span .commit-bar-msg { (t.summary) }
313+ span .spacer {}
314+ @if let Some(c) = &t.change_id {
315+ a .cid href=(format!("{base}/changes/{c}"))
316+ title=(format!("jj change id: {c}")) {
317+ (cid_parts(c))
318+ }
319+ }
320+ span .commit-bar-when
321+ title=(t.when.format("%Y-%m-%d %H:%M UTC").to_string()) {
322+ (crate::views::relative_time(t.when, now))
323 }
324 }
182 span .faint.tnum.commit-bar-when
183 title=(t.when.format("%Y-%m-%d %H:%M UTC").to_string()) {
184 (crate::views::relative_time(t.when, chrono::Utc::now()))
185 }
325 }
187 }
326
189 @if entries.is_empty() {
190 div .filelist.filelist-standalone {
327+ @if entries.is_empty() {
328 p .dim style="padding:16px" { "This directory is empty." }
192 }
193 } @else {
194 div .filelist .filelist-standalone[tip.is_none()] {
329+ } @else {
330 table {
331 caption .sr-only { "Files in this directory" }
332+ thead {
333+ tr {
334+ th .filelist-icon { span .sr-only { "Kind" } }
335+ th .filelist-name { "Name" }
336+ th .filelist-message { "Last change" }
337+ th .filelist-change { "Change" }
338+ th .filelist-when { "Updated" }
339+ }
340+ }
341 tbody {
342 @if !path.is_empty() {
343 tr {
@@ -202,7 +346,7 @@
346 (dir_icon())
347 }
348 }
205 td .filelist-name colspan="2" {
349+ td .filelist-name colspan="4" {
350 a .mono href=(parent_link(&base, rev_label, path)) { ".." }
351 }
352 }
@@ -223,8 +367,41 @@
367 span .faint .filelist-note { "symlink" }
368 }
369 }
226 td .filelist-size.faint.tnum {
227 @if let Some(s) = e.size { (human_size(s)) }
370+ @match history.get(&e.name) {
371+ Some(h) => {
372+ td .filelist-message {
373+ @match &h.change_id {
374+ Some(c) => {
375+ a .filelist-message-link
376+ href=(format!("{base}/changes/{c}"))
377+ title=(h.summary) {
378+ (h.summary)
379+ }
380+ }
381+ None => {
382+ span .filelist-message-text title=(h.summary) {
383+ (h.summary)
384+ }
385+ }
386+ }
387+ }
388+ td .filelist-change {
389+ @if let Some(c) = &h.change_id {
390+ a .cid href=(format!("{base}/changes/{c}")) {
391+ (cid_parts(c))
392+ }
393+ }
394+ }
395+ td .filelist-when
396+ title=(h.when.format("%Y-%m-%d %H:%M UTC").to_string()) {
397+ (crate::views::relative_time(h.when, now))
398+ }
399+ }
400+ None => {
401+ td .filelist-message {}
402+ td .filelist-change {}
403+ td .filelist-when {}
404+ }
405 }
406 }
407 }
@@ -234,17 +411,164 @@
411 }
412
413 @if let Some((name, body)) = readme {
237 article .readme {
414+ article .filelist.readme {
415 div .readme-head {
239 (file_icon())
240 span .mono.dim { (name) }
416+ span .mono { (name) }
417 }
418 div .readme-body.markdown-body { (body) }
419 }
420 }
421+ };
422+
423+ match sidebar {
424+ None => main,
425+ Some(s) => html! {
426+ div .columns.columns-repo {
427+ div .columns-main { (main) }
428+ (repo_aside(ctx, s, now))
429+ }
430+ },
431 }
432}
433
434+/// Split a change id into its short prefix and the rest.
435+///
436+/// Twelve characters is the display length the product settled on; the first
437+/// four carry `--identity` because that is the part people actually type and
438+/// paste. Selecting across both halves still copies one string.
439+pub fn cid_parts(change_id: &str) -> Markup {
440+ let shown = &change_id[..12.min(change_id.len())];
441+ let split = 4.min(shown.len());
442+
443+ html! {
444+ span .cid-p { (&shown[..split]) }
445+ span .cid-r { (&shown[split..]) }
446+ }
447+}
448+
449+/// The bookmark switcher.
450+///
451+/// A `<details>` rather than a scripted menu, so it opens and closes with no
452+/// JavaScript at all. Switching keeps the path you are on, which is the whole
453+/// point of switching from a directory page.
454+fn bookmark_switcher(base: &str, current: &str, path: &str, marks: &[MarkRow]) -> Markup {
455+ let target = |name: &str| {
456+ if path.is_empty() {
457+ format!("{base}/tree/{name}/")
458+ } else {
459+ format!("{base}/tree/{name}/{path}")
460+ }
461+ };
462+
463+ html! {
464+ @if marks.len() > 1 {
465+ details .switcher {
466+ summary .btn.btn-mono {
467+ (current)
468+ span .faint aria-hidden="true" { " ▾" }
469+ }
470+ div .switcher-menu {
471+ div .label-condensed.switcher-label { "Bookmarks" }
472+ @for m in marks {
473+ a .switcher-item href=(target(&m.name)) .is-current[m.name == current] {
474+ span .mono { (m.name) }
475+ @if m.protected {
476+ span .bookmark-flag { "protected" }
477+ }
478+ }
479+ }
480+ }
481+ }
482+ } @else {
483+ span .btn.btn-mono.is-static { (current) }
484+ }
485+ }
486+}
487+
488+/// About / Clone / Repo / Bookmarks.
489+fn repo_aside(
490+ ctx: &RepoContext,
491+ s: &RepoSidebar<'_>,
492+ now: chrono::DateTime<chrono::Utc>,
493+) -> Markup {
494+ let base = ctx.base();
495+
496+ // Only facts that exist get a line. A repository nobody has filed an issue
497+ // against should not be told it has zero issues.
498+ let stats: Vec<(&str, String, &str)> = [
499+ ("Open changes", s.open_changes, "var(--open)"),
500+ ("Conflicted", s.conflicted, "var(--conflict)"),
501+ ("Open issues", s.open_issues, "var(--text-dim)"),
502+ ("Contributors", s.contributors, "var(--text-dim)"),
503+ ]
504+ .into_iter()
505+ .filter(|(_, n, _)| *n > 0)
506+ .map(|(k, n, c)| (k, n.to_string(), c))
507+ .chain(std::iter::once((
508+ "Repository size",
509+ human_size(s.size_bytes),
510+ "var(--text-faint)",
511+ )))
512+ .collect();
513+
514+ html! {
515+ aside .columns-aside {
516+ @if let Some(d) = &ctx.repo.description {
517+ div .aside-block {
518+ div .label-condensed { "About" }
519+ div .aside-about { (d) }
520+ }
521+ }
522+
523+ div .aside-block {
524+ div .label-condensed { "Clone" }
525+ @for (label, cmd) in [("jj", format!("jj git clone {}", s.https)),
526+ ("ssh", format!("jj git clone {}", s.ssh))] {
527+ div .aside-clone {
528+ span .label-condensed { (label) }
529+ code { (cmd) }
530+ }
531+ }
532+ }
533+
534+ div .aside-block {
535+ div .label-condensed { "Repository" }
536+ @for (k, v, colour) in &stats {
537+ div .dotline {
538+ span .dotline-key { (k) }
539+ span .dotline-val style=(format!("color:{colour}")) { (v) }
540+ }
541+ }
542+ }
543+
544+ @if !s.bookmarks.is_empty() {
545+ div .aside-block {
546+ div .aside-head {
547+ div .label-condensed { "Bookmarks" }
548+ span .spacer {}
549+ a href=(format!("{base}/bookmarks")) style="font-size:var(--text-xs)" {
550+ "all →"
551+ }
552+ }
553+ @for m in s.bookmarks.iter().take(6) {
554+ div .bookmark-line {
555+ span .chip { (m.name) }
556+ @if m.protected {
557+ span .bookmark-flag { "protected" }
558+ }
559+ span .spacer {}
560+ span .mini-age
561+ title=(m.updated_at.format("%Y-%m-%d %H:%M UTC").to_string()) {
562+ (crate::views::relative_time(m.updated_at, now))
563+ }
564+ }
565+ }
566+ }
567+ }
568+ }
569+ }
570+}
571+
572/// How a markdown file is being shown.
573///
574/// `None` means the file is not markdown, so no toggle appears at all.
@@ -265,6 +589,8 @@
589 pub symbols: &'a [df_render::symbols::Symbol],
590 /// The last commit that modified this file.
591 pub last_commit: Option<&'a df_store::Revision>,
592+ /// The account that commit's author email resolved to, when it matched one.
593+ pub last_commit_handle: Option<&'a str>,
594 /// Per-line blame data (when the user toggled blame on).
595 pub blame: Option<&'a [df_store::BlameLine]>,
596 /// Whether blame was requested.
@@ -278,6 +604,7 @@
604 sidebar_dir: "",
605 symbols: &[],
606 last_commit: None,
607+ last_commit_handle: None,
608 blame: None,
609 wants_blame: false,
610 }
@@ -313,8 +640,10 @@
640 // Last commit bar for this file.
641 @if let Some(lc) = extras.last_commit {
642 div .file-commit-bar {
316 (avatar(&lc.author.name))
317 span .commit-bar-author { (lc.author.name) }
643+ (avatar(extras.last_commit_handle.unwrap_or(&lc.author.name)))
644+ span .commit-bar-author {
645+ (crate::views::person(extras.last_commit_handle, Some(&lc.author.name)))
646+ }
647 span .commit-bar-msg { (lc.summary()) }
648 @if let Some(c) = &lc.change_id {
649 span .chip.chip-change title="change id" {
@@ -500,7 +829,15 @@
829}
830
831/// Commit log.
503pub fn log_view(ctx: &RepoContext, rev_label: &str, revisions: &[Revision]) -> Markup {
832+/// The commit log. `handles` maps commit-author email to a Dogfood handle for
833+/// the authors that have accounts; everyone else renders as the name the commit
834+/// carries.
835+pub fn log_view(
836+ ctx: &RepoContext,
837+ rev_label: &str,
838+ revisions: &[Revision],
839+ handles: &std::collections::HashMap<String, String>,
840+) -> Markup {
841 let base = ctx.base();
842 html! {
843 div .panel {
@@ -530,7 +867,12 @@
867 } @else {
868 span .chip title="authored with plain git" { "git" }
869 }
533 span .faint { (r.author.name) }
870+ span .faint {
871+ (crate::views::person(
872+ handles.get(&r.author.email).map(String::as_str),
873+ Some(&r.author.name),
874+ ))
875+ }
876 span .faint { (r.author.when.format("%Y-%m-%d %H:%M").to_string()) }
877 }
878 }
@@ -542,27 +884,76 @@
884}
885
886/// Bookmark list.
545pub fn bookmarks_view(ctx: &RepoContext, marks: &[Bookmark]) -> Markup {
887+/// The bookmarks page.
888+///
889+/// Four columns, and the second one is the argument: a bookmark *points at* a
890+/// change. The name in column one can move to any other row tomorrow; the id in
891+/// column two is what the review, the approvals and the permalinks are attached
892+/// to. The page exists to make that asymmetry visible.
893+pub fn bookmarks_view(ctx: &RepoContext, marks: &[MarkRow]) -> Markup {
894 let base = ctx.base();
895+ let now = chrono::Utc::now();
896+
897 html! {
548 div .panel {
549 h2 { "Bookmarks" }
550 p .dim {
551 "Bookmarks are movable pointers, not identities. Reviews attach to changes."
898+ div .page-head {
899+ div {
900+ h1 { "Bookmarks" }
901+ p .dim.section-note {
902+ "Bookmarks are movable pointers, not identities. Reviews attach to changes."
903+ }
904 }
553 @if marks.is_empty() {
554 p .dim { "No bookmarks yet." }
555 } @else {
556 table style="width:100%;border-collapse:collapse" {
905+ }
906+
907+ @if marks.is_empty() {
908+ div .empty {
909+ h2 { "No bookmarks yet" }
910+ p { "Push one with " code { "jj git push --bookmark <name>" } "." }
911+ }
912+ } @else {
913+ div .filelist {
914+ table .bookmark-table {
915+ caption .sr-only { "Bookmarks in this repository" }
916+ thead {
917+ tr {
918+ th { "Bookmark" }
919+ th { "Points at" }
920+ th { "Title" }
921+ th { "Updated" }
922+ }
923+ }
924 tbody {
925 @for m in marks {
926 tr {
560 td style="padding:8px 4px;border-bottom:1px solid var(--border)" {
561 a href=(format!("{base}/tree/{}/", m.name)) { (m.name) }
927+ td .bookmark-name {
928+ a .mono href=(format!("{base}/tree/{}/", m.name)) { (m.name) }
929 @if m.name == ctx.repo.default_bookmark {
563 span .chip style="margin-left:8px" { "default" }
930+ span .bookmark-flag { "default" }
931+ }
932+ @if m.protected {
933+ span .bookmark-flag { "protected" }
934 }
935 }
936+ td .bookmark-points {
937+ @match (&m.change_id, m.number) {
938+ (Some(c), Some(n)) => {
939+ a .cid href=(format!("{base}/changes/{n}"))
940+ title=(format!("jj change id: {c}")) {
941+ (cid_parts(c))
942+ }
943+ }
944+ // A bookmark the indexer has not caught
945+ // up with. Saying so beats an empty cell
946+ // that reads as a rendering bug.
947+ _ => span .faint.mono { "not indexed" },
948+ }
949+ }
950+ td .bookmark-title {
951+ @if let Some(t) = &m.title { (t) }
952+ }
953+ td .bookmark-when
954+ title=(m.updated_at.format("%Y-%m-%d %H:%M UTC").to_string()) {
955+ (crate::views::relative_time(m.updated_at, now))
956+ }
957 }
958 }
959 }
crates/df-web/src/views/review.rs+534 −118
@@ -33,47 +33,103 @@
33 /// The name the commit itself carries, used when no account matched.
34 pub author_name: Option<&'a str>,
35 pub revision_count: usize,
36+ /// The head revision's commit id, abbreviated.
37+ pub head_commit: Option<&'a str>,
38+ pub created_at: DateTime<Utc>,
39+ pub updated_at: DateTime<Utc>,
40+ pub file_count: Option<usize>,
41+ pub comment_count: i64,
42 /// Whether the viewer may edit the change (author or maintainer).
43 pub can_manage: bool,
44 pub can_comment: bool,
45 pub csrf: &'a str,
46}
47
42/// Header and tab bar, shared by every change page.
48+/// One node of the stack rail in the aside.
49+pub struct StackNodeMini {
50+ pub change_id: String,
51+ pub number: i64,
52+ pub state: String,
53+ pub conflicted: bool,
54+ pub depth: usize,
55+ pub is_current: bool,
56+}
57+
58+/// The right-hand column, identical on every change tab.
59+pub struct ChangeAside {
60+ pub reviewers: Vec<crate::views::change::Reviewer>,
61+ pub stack: Vec<StackNodeMini>,
62+}
63+
64+/// The change header: the id at display size, the state, and the tabs.
65+///
66+/// The change id is the largest thing on the page — larger than the title.
67+/// That is deliberate and it is the argument the product is making: the title
68+/// is prose somebody typed and can retype, the id is what every review,
69+/// approval and permalink is attached to, and it does not change when the
70+/// commit underneath it does.
71pub fn header(ctx: &RepoContext, c: &ChangeHead<'_>, tab: &str) -> Markup {
72 let base = format!("{}/changes/{}", ctx.base(), c.number);
73
74 html! {
47 div .panel {
48 div .row {
49 (state_badge(c.state, c.conflicted))
50 h1 style="margin:0" { (c.title) }
51 }
52 div .row style="margin-top:8px;gap:10px" {
53 (change_chip(c.change_id, c.synthetic))
54 span .faint { "#" (c.number) }
55 @match (c.author, c.author_name) {
56 (Some(h), _) => span .faint { "by " a href=(format!("/{h}")) { (h) } },
57 (None, Some(n)) => span .faint
58 title="this commit is not linked to a Dogfood account" { "by " (n) },
59 (None, None) => {}
60 }
61 span .faint { "into" }
62 span .chip { (c.target_bookmark) }
63 @if c.revision_count > 1 {
64 span .faint title="revisions of this change" {
65 (c.revision_count) " revisions"
75+ div .change-head {
76+ div .change-head-top {
77+ span .change-head-rail aria-hidden="true" {}
78+ div .change-head-id {
79+ div .change-head-line {
80+ @if c.synthetic {
81+ span .change-id-display.is-synthetic
82+ title="Authored with plain git — identity derived from the patch" {
83+ (&c.change_id[..12.min(c.change_id.len())])
84+ }
85+ } @else {
86+ span .change-id-display title=(format!("jj change id: {}", c.change_id)) {
87+ (crate::views::repo::cid_parts(c.change_id))
88+ }
89+ }
90+ (state_badge(c.state, c.conflicted))
91+ span .faint.mono { "#" (c.number) }
92+ }
93+ h1 .change-title { (c.title) }
94+ div .change-byline {
95+ @if c.author.is_some() || c.author_name.is_some() {
96+ span { (crate::views::person(c.author, c.author_name)) " →" }
97+ }
98+ span .chip { (c.target_bookmark) }
99+ span .sep aria-hidden="true" { "·" }
100+ span { (revision_span(c)) }
101+ @if let Some(h) = c.head_commit {
102+ span .sep aria-hidden="true" { "·" }
103+ span { "commit " span .mono.faint { (h) } }
104+ }
105 }
106 }
107 }
108
70 nav .subtabs style="margin-top:14px;margin-bottom:0" aria-label="Change sections" {
109+ nav .subtabs.ruled aria-label="Change sections" {
110 a href=(base.clone()) .active[tab == "overview"]
72 aria-current=[(tab == "overview").then_some("page")] { "Overview" }
111+ aria-current=[(tab == "overview").then_some("page")] {
112+ "Overview"
113+ @if c.comment_count > 0 {
114+ span .tab-count { (c.comment_count) }
115+ }
116+ }
117 a href=(format!("{base}/files")) .active[tab == "files"]
74 aria-current=[(tab == "files").then_some("page")] { "Files" }
118+ aria-current=[(tab == "files").then_some("page")] {
119+ "Files"
120+ @if let Some(n) = c.file_count {
121+ span .tab-count { (n) }
122+ }
123+ }
124 a href=(format!("{base}/revisions")) .active[tab == "revisions"]
76 aria-current=[(tab == "revisions").then_some("page")] { "Revisions" }
125+ aria-current=[(tab == "revisions").then_some("page")] {
126+ "Revisions"
127+ @if c.revision_count > 1 {
128+ span .tab-count { (c.revision_count) }
129+ }
130+ }
131+ a href=(format!("{base}/checks")) .active[tab == "checks"]
132+ aria-current=[(tab == "checks").then_some("page")] { "Checks" }
133 @if c.conflicted {
134 a href=(format!("{base}/conflicts")) .active[tab == "conflicts"]
135 aria-current=[(tab == "conflicts").then_some("page")] { "Conflicts" }
@@ -84,6 +140,91 @@
140 }
141}
142
143+/// "4 revisions over 3 days" — the sentence stable identity makes possible.
144+///
145+/// A single-revision change gets "1 revision" and no span: "over 0 days" is
146+/// noise, and the interesting number is the one that says this change has been
147+/// rewritten and kept its name.
148+fn revision_span(c: &ChangeHead<'_>) -> String {
149+ let n = c.revision_count;
150+ let unit = if n == 1 { "revision" } else { "revisions" };
151+ let days = (c.updated_at - c.created_at).num_days();
152+
153+ match (n, days) {
154+ (1, _) => format!("1 {unit}"),
155+ (_, 0) => format!("{n} {unit}"),
156+ (_, 1) => format!("{n} {unit} over a day"),
157+ (_, d) => format!("{n} {unit} over {d} days"),
158+ }
159+}
160+
161+/// Wrap a change tab's body in the two-column shell with the aside.
162+pub fn tab_body(ctx: &RepoContext, aside: &ChangeAside, body: Markup) -> Markup {
163+ html! {
164+ div .columns.columns-repo {
165+ div .columns-main { (body) }
166+ aside .columns-aside.is-sticky {
167+ @if !aside.reviewers.is_empty() {
168+ div .aside-block {
169+ div .label-condensed { "Reviewers" }
170+ @for rv in &aside.reviewers {
171+ (reviewer_line(rv))
172+ }
173+ }
174+ }
175+ @if aside.stack.len() > 1 {
176+ div .aside-block {
177+ div .label-condensed { "Stack" }
178+ @for n in &aside.stack {
179+ (stack_rail_row(ctx, n))
180+ }
181+ }
182+ }
183+ }
184+ }
185+ }
186+}
187+
188+fn reviewer_line(rv: &crate::views::change::Reviewer) -> Markup {
189+ let (glyph, colour, meta, meta_colour) = match (rv.verdict.as_str(), rv.at_head) {
190+ ("approved", true) => ("✓", "var(--open)", "approved", "var(--text-faint)"),
191+ // A stale approval is the thing stable identity makes visible. It gets
192+ // the conflict colour because it is a state the author has to act on,
193+ // not a verdict they can bank.
194+ ("approved", false) => ("✓", "var(--open)", "stale", "var(--conflict)"),
195+ ("rejected", _) => ("×", "var(--danger)", "changes requested", "var(--danger)"),
196+ _ => ("○", "var(--text-faint)", "commented", "var(--text-faint)"),
197+ };
198+
199+ html! {
200+ div .reviewer-line {
201+ span .reviewer-glyph aria-hidden="true" style=(format!("color:{colour}")) { (glyph) }
202+ (crate::views::user_link(&rv.handle))
203+ span .spacer {}
204+ span .reviewer-meta style=(format!("color:{meta_colour}")) { (meta) }
205+ }
206+ }
207+}
208+
209+fn stack_rail_row(ctx: &RepoContext, n: &StackNodeMini) -> Markup {
210+ let (glyph, colour) = match (n.conflicted, n.state.as_str()) {
211+ (true, _) => ("◆", "var(--conflict)"),
212+ (_, "merged") => ("⤳", "var(--merged)"),
213+ (_, "abandoned") => ("×", "var(--abandoned)"),
214+ _ => ("○", "var(--open)"),
215+ };
216+
217+ html! {
218+ a .mini-row .is-current[n.is_current]
219+ href=(format!("{}/changes/{}", ctx.base(), n.number)) {
220+ span .cl-indent style=(format!("width:{}px", n.depth * 6)) {}
221+ span .mini-rail aria-hidden="true" {}
222+ span .mini-glyph aria-hidden="true" style=(format!("color:{colour}")) { (glyph) }
223+ (change_chip(&n.change_id, false))
224+ }
225+ }
226+}
227+
228// ─── overview ────────────────────────────────────────────────────────────────
229
230pub struct CommentRow {
@@ -185,7 +326,7 @@
326 @for r in o.reviews {
327 div .row {
328 (verdict_badge(&r.verdict))
188 strong { (r.reviewer) }
329+ strong { (crate::views::user_link(&r.reviewer)) }
330 span .faint { (r.created_at.format("%Y-%m-%d").to_string()) }
331 span .faint .mono { (r.rev) }
332 @if !r.is_head {
@@ -352,9 +493,23 @@
493 @match item {
494 Item::Comment(cm) => (comment(cm, c, base, false)),
495 Item::Event(e) => {
355 div .row .timeline-event {
356 span .timeline-dot.(event_dot_class(&e.kind)) aria-hidden="true" {}
357 span .faint { (event_text(e)) }
496+ @let (glyph, colour) = event_mark(&e.kind);
497+ div .timeline-event {
498+ span .timeline-glyph aria-hidden="true"
499+ style=(format!("color:{colour}")) { (glyph) }
500+ span .faint {
501+ @match event_parts(e) {
502+ EventSentence::Impersonal(s) => { (s) }
503+ EventSentence::By { actor, predicate, spaced } => {
504+ @match actor {
505+ Some(h) => (crate::views::user_link(h)),
506+ None => span .faint { "someone" },
507+ }
508+ @if spaced { " " }
509+ (predicate)
510+ }
511+ }
512+ }
513 span .faint style="margin-left:auto" {
514 (e.created_at.format("%Y-%m-%d %H:%M").to_string())
515 }
@@ -370,7 +525,7 @@
525 html! {
526 div .comment id=(format!("comment-{}", cm.id)) {
527 div .row {
373 strong { (cm.author) }
528+ strong { (crate::views::user_link(&cm.author)) }
529 span .faint { (cm.created_at.format("%Y-%m-%d %H:%M").to_string()) }
530 @if cm.edited { span .faint { "edited" } }
531 @if cm.anchor_state == "outdated" {
@@ -428,46 +583,98 @@
583/// Mirrors the grouping the design uses for its timeline markers: conflict
584/// states get the conflict colour, anything that lands a change gets the
585/// merged/open colours, and routine events (pushes, rebases) stay neutral.
431fn event_dot_class(kind: &str) -> &'static str {
586+/// The glyph and colour for a timeline event.
587+///
588+/// The same vocabulary the change list and the public feed use — `↑` pushed,
589+/// `✓` reviewed, `◆` conflicted, `⤳` merged — so a reader learns four marks
590+/// once and reads them everywhere.
591+fn event_mark(kind: &str) -> (&'static str, &'static str) {
592 match kind {
433 "change.conflicted" => "timeline-dot-conflict",
434 "change.resolved" | "change.merged" => "timeline-dot-merged",
435 "change.opened" | "change.reopened" | "change.ready" => "timeline-dot-open",
436 "change.abandoned" => "timeline-dot-abandoned",
437 "change.reviewed" => "timeline-dot-action",
438 _ => "timeline-dot-dim",
593+ "change.pushed" => ("↑", "var(--action)"),
594+ "change.conflicted" => ("◆", "var(--conflict)"),
595+ "change.resolved" | "change.reviewed" => ("✓", "var(--open)"),
596+ "change.merged" => ("⤳", "var(--merged)"),
597+ "change.opened" | "change.reopened" | "change.ready" => ("○", "var(--open)"),
598+ "change.abandoned" => ("×", "var(--abandoned)"),
599+ "change.rebased" => ("↻", "var(--text-dim)"),
600+ _ => ("·", "var(--text-faint)"),
601 }
602}
603
442fn event_text(e: &EventRow) -> String {
443 let who = e.actor.as_deref().unwrap_or("someone");
604+/// What an event says, with the actor split out so it can be a link.
605+enum EventSentence<'a> {
606+ /// Somebody did something: "<actor> merged this into main". `actor` is
607+ /// `None` when the event records no account — an older row, or a push by
608+ /// somebody with no Dogfood account — and reads "someone".
609+ By {
610+ actor: Option<&'a str>,
611+ /// The rest of the sentence. Never contains a name.
612+ predicate: String,
613+ /// Whether to put a space before the predicate. False for the
614+ /// unknown-event form, which is punctuation ("alice: some.new.kind").
615+ spaced: bool,
616+ },
617+ /// A sentence with no subject at all: "the conflict was resolved". These
618+ /// describe what became true, not who made it so, and must not acquire a
619+ /// "someone" that implies an unknown person acted.
620+ Impersonal(String),
621+}
622+
623+fn event_parts(e: &EventRow) -> EventSentence<'_> {
624+ let actor = e.actor.as_deref();
625 let n = |k: &str| e.payload.get(k).and_then(|v| v.as_str()).unwrap_or("");
626+ let by = |predicate: String| EventSentence::By { actor, predicate, spaced: true };
627
628 match e.kind.as_str() {
447 "change.opened" => format!("{who} opened this change"),
629+ "change.opened" => by("opened this change".into()),
630 "change.pushed" => {
631 let rev = n("rev");
450 if rev.is_empty() {
451 format!("{who} pushed a new revision")
632+ by(if rev.is_empty() {
633+ "pushed a new revision".into()
634 } else {
453 format!("{who} pushed revision {}", df_store::abbreviate_rev(rev))
454 }
635+ format!("pushed revision {}", df_store::abbreviate_rev(rev))
636+ })
637 }
456 "change.rebased" => format!("{who} rewrote this change"),
457 "change.conflicted" => "this change became conflicted".into(),
458 "change.resolved" => "the conflict was resolved".into(),
459 "change.merged" => format!("{who} merged this into {}", n("bookmark")),
460 "change.abandoned" => format!("{who} abandoned this change"),
461 "change.reopened" => format!("{who} reopened this change"),
462 "change.drafted" => format!("{who} converted this to a draft"),
463 "change.ready" => format!("{who} marked this ready for review"),
464 "change.reviewed" => format!("{who} reviewed this change"),
638+ "change.rebased" => by("rewrote this change".into()),
639+ "change.conflicted" => EventSentence::Impersonal("this change became conflicted".into()),
640+ "change.resolved" => EventSentence::Impersonal("the conflict was resolved".into()),
641+ "change.merged" => by(format!("merged this into {}", n("bookmark"))),
642+ "change.abandoned" => by("abandoned this change".into()),
643+ "change.reopened" => by("reopened this change".into()),
644+ "change.drafted" => by("converted this to a draft".into()),
645+ "change.ready" => by("marked this ready for review".into()),
646+ "change.reviewed" => by("reviewed this change".into()),
647 "comments.rebased" => {
648 let n_out = e.payload.get("outdated").and_then(|v| v.as_i64()).unwrap_or(0);
649 let n_orph = e.payload.get("orphaned").and_then(|v| v.as_i64()).unwrap_or(0);
468 format!("comments re-anchored onto the new revision — {n_out} outdated, {n_orph} orphaned")
650+ EventSentence::Impersonal(format!(
651+ "comments re-anchored onto the new revision — \
652+ {n_out} outdated, {n_orph} orphaned"
653+ ))
654+ }
655+ // An event the UI does not know about still happened. Naming it beats
656+ // dropping it, which would make the timeline quietly incomplete.
657+ other => EventSentence::By {
658+ actor,
659+ predicate: format!(": {other}"),
660+ spaced: false,
661+ },
662+ }
663+}
664+
665+/// The whole sentence as plain text, for contexts with no markup.
666+#[cfg(test)]
667+fn event_text(e: &EventRow) -> String {
668+ match event_parts(e) {
669+ EventSentence::Impersonal(s) => s,
670+ EventSentence::By { actor, predicate, spaced } => {
671+ let who = actor.unwrap_or("someone");
672+ if spaced {
673+ format!("{who} {predicate}")
674+ } else {
675+ format!("{who}{predicate}")
676+ }
677 }
470 other => format!("{who}: {other}"),
678 }
679}
680
@@ -650,60 +857,212 @@
857 pub pushed_at: DateTime<Utc>,
858 pub conflicted: bool,
859 pub pushed_by: Option<String>,
860+ /// Lines added and deleted against this revision's own parent.
861+ pub diffstat: Option<(usize, usize)>,
862+ /// The base this revision was built on, abbreviated.
863+ pub base: Option<String>,
864+}
865+
866+/// Which two revisions the interdiff compares.
867+pub struct Compare<'a> {
868+ pub a: i32,
869+ pub b: i32,
870+ /// `None` when A and B are the same revision, which has no interdiff.
871+ pub diff: Option<&'a Diff>,
872}
873
655pub fn revisions(ctx: &RepoContext, c: &ChangeHead<'_>, revs: &[RevisionDetail]) -> Markup {
874+/// The revisions timeline.
875+///
876+/// The heart of the product. Every rewrite of a change appends a revision here,
877+/// and picking any two produces the *interdiff* — what a reviewer has not seen
878+/// yet. On a branch-based forge this view cannot exist: a force-push destroys
879+/// the thing it would compare against.
880+///
881+/// A and B are chosen by link, not by script. Two query parameters, two sets of
882+/// radio-styled links, and the server does the diff — so this works with
883+/// scripting off and every comparison is a URL somebody can paste into a review.
884+pub fn revisions(
885+ ctx: &RepoContext,
886+ c: &ChangeHead<'_>,
887+ revs: &[RevisionDetail],
888+ cmp: Compare<'_>,
889+) -> Markup {
890 let base = format!("{}/changes/{}", ctx.base(), c.number);
891+ let pick = |a: i32, b: i32| format!("{base}/revisions?a={a}&b={b}");
892+
893 html! {
658 div .panel {
659 h2 { "Revisions" }
660 p .dim {
661 "Each rewrite of this change appends a revision. The review, and every \
662 comment on it, stays attached to the change — this is the thing a \
663 branch-based forge cannot do."
894+ div .band-head {
895+ h2 style="margin:0" { "Every version this change has been" }
896+ span .band-note {
897+ "Pick any two revisions; the interdiff is what a reviewer has not seen yet."
898 }
899+ }
900+
901+ div .revtimeline {
902+ @for (i, r) in revs.iter().enumerate().rev() {
903+ @let selected = r.seq == cmp.a || r.seq == cmp.b;
904+ @let colour = if r.conflicted { "var(--conflict)" } else { "var(--identity)" };
905+ div .revrow .is-selected[selected] {
906+ // The rail is drawn from two half-segments so the first and
907+ // last rows have no line dangling past the end of the list.
908+ span .revrail aria-hidden="true" {
909+ span .revrail-seg
910+ style=(format!("background:{}",
911+ if i == revs.len() - 1 { "transparent" } else { "var(--identity)" })) {}
912+ span .revdot
913+ style=(format!("border-color:{colour};background:{}",
914+ if selected { colour } else { "var(--bg)" })) {}
915+ span .revrail-seg
916+ style=(format!("background:{}",
917+ if i == 0 { "transparent" } else { "var(--identity)" })) {}
918+ }
919
666 table .listing {
667 thead { tr { th scope="col" { "" } th scope="col" { "Revision" } th scope="col" { "Summary" } th scope="col" { "Pushed" } th scope="col" { "" } } }
668 tbody {
669 @for (i, r) in revs.iter().enumerate() {
670 tr {
671 td .faint { "v" (r.seq) }
672 td {
673 a .mono href=(format!("{}/tree/{}/", ctx.base(), r.rev)) {
674 (df_store::abbreviate_rev(&r.rev))
920+ div .revbody {
921+ div .revline {
922+ span .revlabel style=(format!("color:{colour}")) { "rev " (r.seq) }
923+ span .revnote { (first_line(&r.message)) }
924+ @if r.conflicted {
925+ span .badge.badge-conflict {
926+ span .glyph aria-hidden="true" { "◆" }
927+ "conflicted"
928 }
676 @if r.conflicted {
677 " " span .badge.badge-conflict { "conflict" }
929+ }
930+ span .spacer {}
931+ span .revwhen
932+ title=(r.pushed_at.format("%Y-%m-%d %H:%M UTC").to_string()) {
933+ (r.pushed_at.format("%b %-d %H:%M").to_string())
934+ }
935+ }
936+ div .revmeta {
937+ a href=(format!("{}/tree/{}/", ctx.base(), r.rev)) {
938+ "commit " (df_store::abbreviate_rev(&r.rev))
939+ }
940+ @if let Some(b) = &r.base {
941+ span { "base " (b) }
942+ }
943+ @if let Some((add, del)) = r.diffstat {
944+ span {
945+ span .cl-add { "+" (add) }
946+ " "
947+ span .cl-del { "−" (del) }
948 }
949 }
680 td { (first_line(&r.message)) }
681 td .faint {
682 (r.pushed_at.format("%Y-%m-%d %H:%M").to_string())
683 @if let Some(p) = &r.pushed_by { " by " (p) }
684 @if r.author_name != *p_or_empty(&r.pushed_by) {
685 " · authored by " (r.author_name)
950+ @if let Some(p) = &r.pushed_by {
951+ span { "pushed by " (crate::views::user_link(p)) }
952+ } @else {
953+ span { "authored by " (r.author_name) }
954+ }
955+ span .spacer {}
956+ a .abpick .is-on[cmp.a == r.seq] href=(pick(r.seq, cmp.b))
957+ title=(format!("Compare from revision {}", r.seq)) { "A" }
958+ a .abpick .is-on[cmp.b == r.seq] href=(pick(cmp.a, r.seq))
959+ title=(format!("Compare to revision {}", r.seq)) { "B" }
960+ }
961+ }
962+ }
963+ }
964+ }
965+
966+ div .filediff.interdiff {
967+ div .filediff-head {
968+ span .label-condensed {
969+ "Interdiff rev " (cmp.a) " → rev " (cmp.b)
970+ }
971+ span .band-note { "what the reviewer has not seen yet" }
972+ span .spacer {}
973+ @if let Some(d) = cmp.diff {
974+ span .mono.faint {
975+ (d.files.len())
976+ @if d.files.len() == 1 { " file · " } @else { " files · " }
977+ span .cl-add { "+" (d.total_additions) }
978+ " "
979+ span .cl-del { "−" (d.total_deletions) }
980+ }
981+ }
982+ }
983+
984+ @match cmp.diff {
985+ None => {
986+ p .hint style="padding:12px" {
987+ "A and B are the same revision. Pick two different ones to see \
988+ what changed between them."
989+ }
990+ }
991+ Some(d) if d.files.is_empty() => {
992+ p .hint style="padding:12px" {
993+ "Nothing changed between these two revisions. A rebase that only \
994+ moved the change produces exactly this — which is the point."
995+ }
996+ }
997+ Some(d) => {
998+ @for f in &d.files {
999+ div .interdiff-file {
1000+ div .interdiff-path {
1001+ span .mono { (f.path) }
1002+ span .mono.faint {
1003+ span .cl-add { "+" (f.additions) }
1004+ " "
1005+ span .cl-del { "−" (f.deletions) }
1006 }
1007 }
688 td {
689 a .btn href=(format!("{base}/files?rev={}", r.rev)) { "Diff" }
690 @if i > 0 {
691 " "
692 a .btn href=(format!(
693 "{base}/files?rev={}&against={}", r.rev, revs[i - 1].rev
694 )) { "vs v" (revs[i - 1].seq) }
1008+ @for h in &f.hunks {
1009+ div .diffline.diff-hunk {
1010+ span .diff-ln {}
1011+ span .diff-text {
1012+ "@@ -" (h.old_start) "," (h.old_lines)
1013+ " +" (h.new_start) "," (h.new_lines) " @@"
1014+ }
1015+ }
1016+ @for l in &h.lines {
1017+ div .diffline.(line_class(l.kind)) {
1018+ span .diff-ln {
1019+ @if let Some(n) = l.new_lineno.or(l.old_lineno) { (n) }
1020+ }
1021+ span .diff-text {
1022+ (marker(l.kind)) (spans(&l.spans, l.kind))
1023+ }
1024+ }
1025 }
1026 }
1027 }
1028 }
1029+ @if d.truncated {
1030+ p .hint style="padding:12px" {
1031+ "This interdiff is too large to render in full."
1032+ }
1033+ }
1034 }
1035 }
1036 }
1037 }
1038}
1039
705fn p_or_empty(s: &Option<String>) -> &str {
706 s.as_deref().unwrap_or("")
1040+// ─── checks ──────────────────────────────────────────────────────────────────
1041+
1042+/// The Checks tab.
1043+///
1044+/// Dogfood has no CI integration: there is no checks table, no worker that
1045+/// records results, and nothing that receives them from outside. The tab exists
1046+/// because the design places it in the strip, and it says so plainly rather
1047+/// than showing invented rows — a fabricated "cargo test ✓ 412 passed" on a
1048+/// review page is the single most dangerous kind of placeholder, because a
1049+/// reviewer would act on it.
1050+pub fn checks(_ctx: &RepoContext, _c: &ChangeHead<'_>) -> Markup {
1051+ html! {
1052+ div .empty {
1053+ h2 { "No checks are wired up" }
1054+ p .measure {
1055+ "This instance has no CI integration, so nothing reports check results \
1056+ against a revision. Nothing is hidden here — there is genuinely no \
1057+ data behind this tab yet."
1058+ }
1059+ p .hint.measure {
1060+ "When there is, checks will run per revision rather than per branch: a \
1061+ conflicted revision still runs, because a conflict is a state, not a \
1062+ failure."
1063+ }
1064+ }
1065+ }
1066}
1067
1068// ─── conflicts (M4) ──────────────────────────────────────────────────────────
@@ -763,46 +1122,103 @@
1122 pub depth: usize,
1123}
1124
766pub fn stack(ctx: &RepoContext, nodes: &[StackNode], change_id: &str) -> Markup {
1125+/// The stack page.
1126+///
1127+/// One rebase moves every change in the chain, and every id survives it — so
1128+/// every review, approval and permalink in the stack stays attached to the work
1129+/// it was about. That sentence is the page; the rows are the evidence.
1130+pub fn stack(
1131+ ctx: &RepoContext,
1132+ nodes: &[StackNode],
1133+ change_id: &str,
1134+ target: Option<&str>,
1135+ csrf: &str,
1136+ can_merge: bool,
1137+) -> Markup {
1138 let base = ctx.base();
1139+
1140+ // Top of the stack first — that is how `jj log` reads, and the change a
1141+ // reviewer is looking at is usually near the top.
1142+ let chain: String = nodes
1143+ .iter()
1144+ .rev()
1145+ .map(|n| n.change_id[..4.min(n.change_id.len())].to_string())
1146+ .collect::<Vec<_>>()
1147+ .join(" → ");
1148+
1149 html! {
769 div .panel {
1150+ div .page-head {
1151 h1 { "Stack" }
771 p .lede {
772 "A stack is a chain of changes where each builds on the one below and \
773 none has landed yet. Edges are computed at index time from the commit \
774 graph, so a rebase moves the whole stack without breaking it."
1152+ @if nodes.len() > 1 {
1153+ span .stack-chain.mono {
1154+ (chain)
1155+ @if let Some(t) = target { " onto " (t) }
1156+ }
1157 }
776
777 @if nodes.len() <= 1 {
778 div .empty {
779 h2 { "Not stacked" }
780 p { "This change does not sit in a stack." }
781 p { a .btn href=(format!("{base}/changes/{change_id}")) { "Back to the change" } }
1158+ span .spacer {}
1159+ @if nodes.len() > 1 && can_merge {
1160+ form method="post" action=(format!("{base}/stacks/{change_id}/merge")) {
1161+ input type="hidden" name="_csrf" value=(csrf);
1162+ button .btn.btn-primary type="submit" {
1163+ "Merge stack into " (target.unwrap_or("the bookmark"))
1164+ }
1165 }
783 } @else {
784 // Top of the stack first — that is how jj log reads, and the
785 // change a reviewer is looking at is usually near the top.
786 ol .stackgraph {
787 @for n in nodes.iter().rev() {
788 li .stacknode .current[n.is_current] style=(format!("margin-left:{}px", n.depth * 18)) {
789 div .row {
790 (state_badge(&n.state, n.conflicted))
791 a href=(format!("{base}/changes/{}", n.number)) { (n.title) }
792 @if n.is_current { span .chip { "you are here" } }
793 }
794 div .row style="margin-top:4px;gap:8px" {
795 (change_chip(&n.change_id, n.synthetic))
796 span .faint { "#" (n.number) }
797 }
1166+ }
1167+ }
1168+
1169+ @if nodes.len() <= 1 {
1170+ div .empty {
1171+ h2 { "Not stacked" }
1172+ p { "This change does not sit in a stack." }
1173+ p { a .btn href=(format!("{base}/changes/{change_id}")) { "Back to the change" } }
1174+ }
1175+ } @else {
1176+ p .dim.measure {
1177+ "One rebase moves all " (nodes.len()) ". Every id survives it, so every \
1178+ review, approval, and permalink in the stack stays attached to the work \
1179+ it was about."
1180+ }
1181+
1182+ div .filelist {
1183+ @for n in nodes.iter().rev() {
1184+ @let (glyph, colour) = match (n.conflicted, n.state.as_str()) {
1185+ (true, _) => ("◆", "var(--conflict)"),
1186+ (_, "merged") => ("⤳", "var(--merged)"),
1187+ (_, "abandoned") => ("×", "var(--abandoned)"),
1188+ _ => ("○", "var(--open)"),
1189+ };
1190+ a .stackrow .is-current[n.is_current]
1191+ href=(format!("{base}/changes/{}", n.number)) {
1192+ span .cl-indent style=(format!("width:{}px", n.depth * 10 + 8)) {}
1193+ span .cl-rail aria-hidden="true" {}
1194+ span .stackrow-glyph aria-hidden="true" style=(format!("color:{colour}")) {
1195+ (glyph)
1196+ }
1197+ (change_chip(&n.change_id, n.synthetic))
1198+ span .stackrow-title { (n.title) }
1199+ span .spacer {}
1200+ @if n.is_current {
1201+ span .chip { "you are here" }
1202 }
1203+ span .stackrow-meta { "#" (n.number) }
1204 }
1205 }
1206+ // The base the whole chain sits on. `┴` is the same glyph
1207+ // `jj log` closes a graph with.
1208+ div .stackrow.stackrow-base {
1209+ span .stackrow-glyph aria-hidden="true" { "┴" }
1210+ @if let Some(t) = target { (t) } @else { "the target bookmark" }
1211+ }
1212+ }
1213+
1214+ div .stack-cmd.mono {
1215+ "$ jj rebase -s " (&change_id[..4.min(change_id.len())])
1216+ @if let Some(t) = target { " -d " (t) }
1217+ }
1218
802 p .hint {
803 "Merging the bottom of a stack lands only that change. \
804 Use " strong { "Merge stack" } " to land the whole chain bottom-up."
805 }
1219+ p .hint.measure {
1220+ "Merging the bottom of a stack lands only that change. "
1221+ strong { "Merge stack" } " lands the whole chain bottom-up in one action."
1222 }
1223 }
1224 }
crates/df-web/assets/palette.js+129 −0
@@ -0,0 +1,129 @@
1+// The ⌘K palette. Enhancement only.
2+//
3+// Without this script the masthead control is a plain link to /search and the
4+// overlay stays `hidden`, so nothing here is load-bearing — it is a faster
5+// route to a page that is reachable anyway. That is also why the ⌘K keycap
6+// starts hidden in the markup: advertising a shortcut that does not work is
7+// worse than not advertising it.
8+//
9+// Results come from htmx hitting /search?fragment=1. This file never renders a
10+// result, so there is no second copy of the visibility rule to get wrong.
11+(function () {
12+ var backdrop = document.querySelector("[data-palette]");
13+ var input = document.querySelector("[data-palette-input]");
14+ if (!backdrop || !input) return;
15+
16+ var opener = document.querySelector("[data-palette-open]");
17+ var kbd = document.querySelector("[data-palette-kbd]");
18+ var lastFocus = null;
19+
20+ // Mac gets ⌘, everything else Ctrl. Checking the platform is unreliable in
21+ // general but perfectly adequate for choosing which glyph to draw.
22+ var isMac = /Mac|iPhone|iPad/.test(navigator.platform || "");
23+ if (kbd) {
24+ kbd.textContent = isMac ? "⌘K" : "Ctrl K";
25+ kbd.hidden = false;
26+ }
27+
28+ function open() {
29+ if (!backdrop.hidden) return;
30+ lastFocus = document.activeElement;
31+ backdrop.hidden = false;
32+ input.value = "";
33+ input.focus();
34+ }
35+
36+ function close() {
37+ if (backdrop.hidden) return;
38+ backdrop.hidden = true;
39+ if (lastFocus && lastFocus.focus) lastFocus.focus();
40+ }
41+
42+ if (opener) {
43+ opener.addEventListener("click", function (e) {
44+ e.preventDefault();
45+ open();
46+ });
47+ }
48+
49+ document.addEventListener("keydown", function (e) {
50+ var accel = isMac ? e.metaKey : e.ctrlKey;
51+ if (accel && (e.key === "k" || e.key === "K")) {
52+ e.preventDefault();
53+ backdrop.hidden ? open() : close();
54+ return;
55+ }
56+ if (e.key === "Escape" && !backdrop.hidden) {
57+ e.preventDefault();
58+ close();
59+ }
60+ });
61+
62+ // Clicking the backdrop closes; clicking the panel inside it must not.
63+ backdrop.addEventListener("click", function (e) {
64+ if (e.target === backdrop) close();
65+ });
66+
67+ // Arrow keys walk the results. The input keeps focus throughout so typing
68+ // never has to be resumed — only Enter leaves, by following the active row.
69+ function items() {
70+ return Array.prototype.slice.call(
71+ backdrop.querySelectorAll(".palette-item")
72+ );
73+ }
74+
75+ function activeIndex(list) {
76+ for (var i = 0; i < list.length; i++) {
77+ if (list[i].classList.contains("is-active")) return i;
78+ }
79+ return -1;
80+ }
81+
82+ function activate(list, i) {
83+ list.forEach(function (el) {
84+ el.classList.remove("is-active");
85+ });
86+ if (list[i]) {
87+ list[i].classList.add("is-active");
88+ list[i].scrollIntoView({ block: "nearest" });
89+ }
90+ }
91+
92+ input.addEventListener("keydown", function (e) {
93+ var list = items();
94+ if (!list.length) return;
95+ var i = activeIndex(list);
96+
97+ if (e.key === "ArrowDown") {
98+ e.preventDefault();
99+ activate(list, i + 1 >= list.length ? 0 : i + 1);
100+ } else if (e.key === "ArrowUp") {
101+ e.preventDefault();
102+ activate(list, i <= 0 ? list.length - 1 : i - 1);
103+ } else if (e.key === "Enter") {
104+ // With nothing selected, fall through to the form's own behaviour:
105+ // a full search for whatever was typed.
106+ if (i < 0) return;
107+ e.preventDefault();
108+ list[i].click();
109+ }
110+ });
111+
112+ // Fresh results, fresh selection: the first row is pre-selected so Enter
113+ // does the obvious thing immediately after typing.
114+ document.body.addEventListener("htmx:afterSwap", function (e) {
115+ if (e.target && e.target.id === "palette-results") {
116+ var list = items();
117+ if (list.length) activate(list, 0);
118+ }
119+ });
120+
121+ backdrop.addEventListener("click", function (e) {
122+ var cmd = e.target.closest && e.target.closest("[data-palette-theme]");
123+ if (cmd && window.dogfoodToggleTheme) {
124+ e.preventDefault();
125+ window.dogfoodToggleTheme();
126+ close();
127+ }
128+ });
129+})();
crates/df-web/assets/terminal.js+122 −0
@@ -0,0 +1,122 @@
1+/**
2+ * Terminal typewriter animation for the homepage example session.
3+ *
4+ * Each line in the terminal body is either a command (typed out character by
5+ * character with a blinking cursor) or output (revealed instantly after the
6+ * command finishes "typing"). Blank lines pause briefly before continuing.
7+ *
8+ * The animation respects `prefers-reduced-motion`: when the user prefers
9+ * reduced motion, all lines are shown immediately with no animation.
10+ *
11+ * The animation starts when the terminal scrolls into view (IntersectionObserver)
12+ * and only plays once.
13+ */
14+(function () {
15+ "use strict";
16+
17+ var CHAR_DELAY = 32; // ms between typed characters
18+ var LINE_PAUSE = 80; // ms pause after a full line before next
19+ var CMD_PAUSE = 400; // ms pause after a command finishes before showing output
20+ var BLANK_PAUSE = 250; // ms pause for blank lines
21+ var INITIAL_DELAY = 600; // ms before animation starts after becoming visible
22+
23+ function initTerminalAnimation() {
24+ var body = document.querySelector("[data-term-animate]");
25+ if (!body) return;
26+
27+ // Respect prefers-reduced-motion
28+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
29+ body.classList.add("term-ready");
30+ return;
31+ }
32+
33+ var lines = body.querySelectorAll("[data-term-line]");
34+ if (!lines.length) return;
35+
36+ // Hide all lines initially. Toggled via a class, not inline styles: the
37+ // site's CSP has no `style-src 'unsafe-inline'`, so `el.style.x = y` is
38+ // silently blocked.
39+ body.classList.add("term-animating");
40+
41+ var played = false;
42+
43+ // Start when visible
44+ var observer = new IntersectionObserver(function (entries) {
45+ if (played) return;
46+ for (var j = 0; j < entries.length; j++) {
47+ if (entries[j].isIntersecting) {
48+ played = true;
49+ observer.disconnect();
50+ setTimeout(function () { playAnimation(body, lines); }, INITIAL_DELAY);
51+ break;
52+ }
53+ }
54+ }, { threshold: 0.3 });
55+
56+ observer.observe(body);
57+ }
58+
59+ function playAnimation(body, lines) {
60+ var idx = 0;
61+
62+ function next() {
63+ if (idx >= lines.length) {
64+ body.classList.remove("term-animating");
65+ body.classList.add("term-ready");
66+ return;
67+ }
68+
69+ var line = lines[idx];
70+ idx++;
71+
72+ line.classList.add("term-line-visible");
73+
74+ var isCmd = line.hasAttribute("data-term-cmd");
75+ var isBlank = line.hasAttribute("data-term-blank");
76+
77+ if (isBlank) {
78+ setTimeout(next, BLANK_PAUSE);
79+ } else if (isCmd) {
80+ typeCommand(line, function () {
81+ setTimeout(next, CMD_PAUSE);
82+ });
83+ } else {
84+ // Output lines: reveal instantly
85+ setTimeout(next, LINE_PAUSE);
86+ }
87+ }
88+
89+ next();
90+ }
91+
92+ function typeCommand(el, callback) {
93+ var text = el.getAttribute("data-term-text") || el.textContent;
94+ var original = el.textContent;
95+ el.textContent = "";
96+ el.classList.add("term-typing");
97+
98+ var charIdx = 0;
99+
100+ function typeChar() {
101+ if (charIdx >= text.length) {
102+ el.textContent = original;
103+ el.classList.remove("term-typing");
104+ callback();
105+ return;
106+ }
107+
108+ el.textContent = text.substring(0, charIdx + 1);
109+ charIdx++;
110+ setTimeout(typeChar, CHAR_DELAY);
111+ }
112+
113+ typeChar();
114+ }
115+
116+ // Init on DOMContentLoaded or immediately if already loaded
117+ if (document.readyState === "loading") {
118+ document.addEventListener("DOMContentLoaded", initTerminalAnimation);
119+ } else {
120+ initTerminalAnimation();
121+ }
122+})();