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
rev 2feat: given a new redesign and emulated terminal homepageAug 2 19:52
rev 1feat: given a new redesign and emulated terminal homepageAug 2 18:45
Interdiff rev 1 → rev 3what the reviewer has not seen yet29 files · +7275 −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
2219−details 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
190−type 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};
9−use 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
99−type 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
16−pub 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;
10−use 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.
67−pub 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
218−fn 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.
439−pub 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
5−use 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.
155−pub 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.
503−pub 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.
545−pub 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.
431−fn 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
442−fn 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
655−pub 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
705−fn 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
766−pub 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+})();
docs/pushing-with-jj.md+156 −0
@@ -0,0 +1,156 @@
1+# Pushing changes with jj
2+
3+How work gets from your working copy to a change on Dogfood.
4+
5+This repository is a **jj** repo with a git backend. `jj git export` keeps a git view in
6+sync, which means `git status` and the git reflog see only part of the picture — the
7+authoritative history is jj's, and `jj op log` is the thing that can undo a mistake.
8+
9+## The model, in one paragraph
10+
11+There is no staging area and no "uncommitted work". The working copy **is** a commit
12+(`@`), and jj snapshots it into that commit before running any command. You do not
13+create a commit to save work; the work is already in one. What you do instead is give
14+that commit a description, and then push a bookmark pointing at it. Pushing a bookmark
15+is what opens or updates a change — there is no web form and no PR button.
16+
17+## The normal loop
18+
19+```sh
20+# 1. Start a new commit on top of the trunk.
21+jj new main
22+
23+# 2. Edit files. jj snapshots them into @ automatically — nothing to add.
24+
25+# 3. Describe what you did.
26+jj describe -m 'feat: cache shortest-unique prefixes per repo'
27+
28+# 4. Push. This creates a bookmark named push-<change-id> and opens a change.
29+jj git push -c @
30+
31+# 5. Start the next piece of work on top.
32+jj new
33+```
34+
35+Step 4 prints the change URL. That change is now open for review.
36+
37+## Amending a change after review comments
38+
39+Because `@` is still the commit you pushed, you just keep editing it and push again:
40+
41+```sh
42+# edit files …
43+jj git push -b push-<change-id>
44+```
45+
46+jj reports this as `[move sideways from <old> to <new>]`. "Sideways" means a rewrite
47+rather than a fast-forward — expected, because you changed a commit that was already
48+published. No `--force` is required: jj compares the remote against the position it last
49+recorded, and pushes only when those agree. If someone else moved the bookmark
50+underneath you, that check fails instead of clobbering their work.
51+
52+On Dogfood this lands as a **new revision on the same change**. The change id is stable
53+across every amend, rebase and force-push, which is the whole point — reviews stay
54+attached to the change, not to a commit hash.
55+
56+If you have already moved on with `jj new` and `@` is no longer the commit you want to
57+amend:
58+
59+```sh
60+jj edit <change-id> # make that commit the working copy again
61+# edit files …
62+jj git push -b push-<change-id>
63+jj new # go back to working on top
64+```
65+
66+## Checking before you push
67+
68+```sh
69+jj st # what changed in @
70+jj diff --stat # the diff, summarised
71+jj log -r 'stack(@)' # the stack this change sits in
72+jj git push -b <name> --dry-run # exactly what would move, without moving it
73+```
74+
75+`--dry-run` is worth the extra command whenever you are about to rewrite something that
76+is already published.
77+
78+## Naming the bookmark yourself
79+
80+`-c/--change` generates `push-<change-id>`, which is fine for most work. To push under a
81+name a human chose:
82+
83+```sh
84+jj bookmark create my-feature -r @
85+jj git push -b my-feature
86+```
87+
88+## Landing on main
89+
90+```sh
91+jj bookmark move main --to @
92+jj git push -b main
93+```
94+
95+This bypasses review entirely, so it is for trivial or already-reviewed work only. The
96+normal path is the bookmark push above.
97+
98+## The trap that will cost you a day
99+
100+```sh
101+jj new main -m 'feat: my work' # ← DO NOT use this to save work in progress
102+```
103+
104+This does **not** put your current work in the new commit. jj snapshots the working copy
105+into the **current** commit first, then creates an **empty** commit whose parent is
106+`main`. You end up pushing an empty change while all your work sits in a now-orphaned
107+commit labelled `(no description set)` — and because the working copy was reset to
108+something matching `main`, `git status` reports a clean tree and the work looks deleted.
109+
110+Use `jj describe -m '…'` to name work you already have. Use bare `jj new` (no revision)
111+only when you genuinely want to start something new.
112+
113+### Recovering from it
114+
115+Nothing is lost — jj keeps every snapshot.
116+
117+```sh
118+# 1. Find the stranded commit. It is a sibling with no description.
119+jj log -r 'all()'
120+
121+# 2. Confirm it is the right one before touching anything.
122+jj show --stat <change-id>
123+
124+# 3. Restore its contents into the current commit.
125+jj restore --from <change-id>
126+```
127+
128+`jj op log` shows the tell: a `snapshot working copy` operation immediately followed by
129+`new empty commit`, both with the same `args:` line. To rewind the whole repository to
130+just before a bad command:
131+
132+```sh
133+jj op log
134+jj op restore <operation-id>
135+```
136+
137+Prefer `jj restore --from` when you only want the files back and do not want to rewind
138+bookmarks that were pushed in the meantime.
139+
140+## Deploying is a separate step
141+
142+Pushing updates the change. It does not deploy. `./run.sh deploy` builds from the
143+**working copy on disk**, not from what you pushed — so if the tree is in the stranded
144+state above, a deploy will quietly ship the previous build. After deploying, confirm
145+which build is actually live rather than assuming:
146+
147+```sh
148+curl -s -o /dev/null -w '%{http_code}\n' https://dogfood.sh/assets/terminal.js
149+```
150+
151+Any asset that exists only in the current build works as the probe.
152+
153+## See also
154+
155+- [`change-id-format.md`](change-id-format.md) — why change ids look the way they do
156+- [`revset-semantics.md`](revset-semantics.md) — the revset language used by `-r`