Jump to…
rxkspqmsoknzmerged#4

feat: given a new redesign and emulated terminal homepage

29 files+7275−1484
Expand all
Comparingv3 against its parent
Mcrates/df-store/src/lib.rs+40−0
@@ −15,6 +15,7 @@
1515//!
1616//! No `gix` type appears anywhere in this module's public API.
1717
18+use std::collections::HashMap;
1819use std::path::Path;
1920
2021use async_trait::async_trait;
@@ −430,6 +431,21 @@
430431 /// is a storage detail; nothing above this trait should know such a thing
431432 /// exists.
432433 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+
433449 async fn log(&self, id: RepoId, from: &RevId, limit: usize) -> Result<Vec<Revision>>;
434450 async fn revision(&self, id: RepoId, rev: &RevId) -> Result<Revision>;
435451 async fn bookmarks(&self, id: RepoId) -> Result<Vec<Bookmark>>;
@@ −501,6 +517,30 @@
501517 path: &Path,
502518 ) -> Result<Option<Revision>>;
503519
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+
504544 /// Total on-disk size, for the repo settings page and quota reporting.
505545 async fn size_bytes(&self, id: RepoId) -> Result<u64>;
506546}
Mcrates/df-store/tests/git_store.rs+46−0
@@ −125,6 +125,52 @@
125125 assert_eq!(blob.size, blob.content.len() as u64);
126126}
127127
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+
128174#[tokio::test]
129175async fn directories_sort_before_files() {
130176 let Some(f) = load("hostile") else { return };
Mcrates/df-web/assets/app.css4505 lines+2965−679
@@ −1,74 +1,78 @@
11/*
22 * Dogfood design tokens and base styles.
33 *
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.
66 *
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.
1114 *
15+ * Using identity for a button, or action for a change id, breaks the only
16+ * colour rule the interface has.
17+ *
1218 * NOTE — deviation from spec §1, recorded deliberately: the spec chose Tailwind.
1319 * This is hand-authored CSS instead, because Tailwind would put a Node
1420 * 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
1622 * to Tailwind later is a mechanical change rather than a redesign.
1723 */
1824
1925:root {
2026 color-scheme: dark;
2127
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;
3137
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);
4347
4448 /* 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;
5054
5155 /* 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;
5862
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;
7276
7377 /* shape */
7478 --radius: 3px;
@@ −84,63 +88,64 @@
8488 --font-condensed: "IBM Plex Sans Condensed", ui-sans-serif,
8589 "Roboto Condensed", "Arial Narrow", system-ui, sans-serif;
8690
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. */
8894 --text-xs: 11px;
8995 --text-sm: 12.5px;
9096 --text-base: 14px;
9197 --text-md: 16px;
9298 --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;
96102}
97103
98104:root.light,
99105:root[data-theme="light"] {
100106 color-scheme: light;
101107
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;
110116
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);
115123
116 --identity: #a06f0e;
117 --action: #226b83;
124+ --open: #2e7d3a;
125+ --merged: #5b4fb0;
126+ --abandoned: #6b7280;
127+ --conflict: #9b3e96;
128+ --danger: #b03434;
118129
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;
124136
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;
137142 --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;
144149}
145150
146151/* ─── fonts ──────────────────────────────────────────────────────────────── */
@@ −238,7 +243,7 @@
238243}
239244
240245::selection {
241 background: color-mix(in srgb, var(--brand) 32%, transparent);
246+ background: color-mix(in srgb, var(--action) 32%, transparent);
242247 color: var(--text);
243248}
244249
@@ −275,71 +280,107 @@
275280
276281/* ─── layout ─────────────────────────────────────────────────────────────── */
277282
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. */
281298.masthead {
282299 position: sticky;
283300 top: 0;
284301 z-index: 30;
285302 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;
288316}
289317
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;
295323}
296324
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;
302327}
303328
304.masthead-inner {
305 display: flex;
306 align-items: center;
307 gap: 16px;
308 height: 48px;
329+.masthead-group-end {
330+ justify-self: end;
309331}
310332
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. */
311336.brand {
312 display: flex;
337+ display: inline-flex;
313338 align-items: center;
314339 gap: 8px;
315340 flex-shrink: 0;
316341 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);
322344}
323345
324346.brand:hover {
325347 text-decoration: none;
326 color: var(--brand);
327348}
328349
329350.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;
332373}
333374
334375.masthead nav {
335376 display: flex;
336 gap: 14px;
337 margin-left: auto;
377+ gap: 8px;
338378 align-items: center;
339379}
340380
341381.masthead nav a {
342382 color: var(--text-dim);
383+ white-space: nowrap;
343384}
344385
345386.masthead nav a:hover {
@@ −347,21 +388,263 @@
347388 text-decoration: none;
348389}
349390
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+
350475main {
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;
352502}
353503
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+
354635/* ─── typography ─────────────────────────────────────────────────────────── */
355636
356637h1 {
357 font-size: 20px;
638+ font-size: var(--text-lg);
639+ line-height: 28px;
358640 font-weight: 600;
359641 letter-spacing: -0.01em;
360642 margin: 0 0 6px;
361643}
362644
363645h2 {
364 font-size: 15px;
646+ font-size: var(--text-md);
647+ line-height: 24px;
365648 font-weight: 600;
366649 margin: 0 0 10px;
367650}
@@ −380,96 +663,111 @@
380663 color: var(--text-faint);
381664}
382665
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. */
383668.label-condensed {
384669 font-family: var(--font-condensed);
385670 font-weight: 500;
386671 font-size: var(--text-xs);
387672 line-height: 16px;
388 letter-spacing: 0.08em;
673+ letter-spacing: 0.06em;
389674 text-transform: uppercase;
390675 color: var(--text-dim);
391676}
392677
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. */
404680.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;
409687}
410688
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+ }
419694}
420695
421696.tnum {
422697 font-variant-numeric: tabular-nums;
423698}
424699
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;
432704}
433705
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);
437731}
438732
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);
443735}
444736
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);
447743}
448744
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;
452750}
453751
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);
458754}
459755
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);
464759}
465760
466761/* ─── surfaces ───────────────────────────────────────────────────────────── */
467762
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. */
468766.panel {
469767 background: var(--surface);
470768 border: 1px solid var(--border);
471769 border-radius: var(--radius);
472 padding: 20px;
770+ padding: 14px;
473771}
474772
475773.panel + .panel {
@@ −490,39 +788,43 @@
490788
491789/* ─── controls ───────────────────────────────────────────────────────────── */
492790
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. */
493793.btn {
494794 display: inline-flex;
495795 align-items: center;
796+ justify-content: center;
496797 gap: 6px;
497 padding: 6px 14px;
798+ height: 28px;
799+ padding: 0 10px;
498800 border-radius: var(--radius);
499801 border: 1px solid var(--border-strong);
500 background: var(--surface-raised);
802+ background: transparent;
501803 color: var(--text);
502804 font: inherit;
503 font-size: 13px;
805+ font-size: var(--text-sm);
806+ white-space: nowrap;
504807 cursor: pointer;
505808}
506809
507810.btn:hover {
508 border-color: var(--text-faint);
811+ border-color: var(--action);
812+ color: var(--action);
509813 text-decoration: none;
510814}
511815
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. */
514817.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;
522822}
523823
524824.btn-primary:hover {
525825 filter: brightness(1.08);
826+ color: var(--on-action);
827+ border-color: var(--action);
526828}
527829
528830.btn-danger {
@@ −530,6 +832,12 @@
530832 color: var(--danger);
531833}
532834
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+
533841/* Sign out is destructive enough to warn about but too routine to shout, so it
534842 sits quiet in the masthead and only reddens on approach. */
535843.btn-quiet-danger {
@@ −543,24 +851,69 @@
543851 color: var(--danger);
544852}
545853
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+
546882input[type="text"],
547883input[type="password"],
884+input[type="search"],
885+input[type="email"],
548886textarea {
549887 width: 100%;
550 padding: 7px 10px;
888+ padding: 0 8px;
889+ height: 30px;
551890 background: var(--bg);
552891 border: 1px solid var(--border-strong);
553892 border-radius: var(--radius);
554893 color: var(--text);
555894 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);
557910}
558911
559912input:focus,
560913textarea:focus,
561914.btn:focus-visible,
562915a:focus-visible {
563 outline: 2px solid var(--brand);
916+ outline: 2px solid var(--action);
564917 outline-offset: 1px;
565918}
566919
@@ −571,13 +924,17 @@
571924.field label {
572925 display: block;
573926 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;
575932 color: var(--text-dim);
576933}
577934
578935.hint {
579936 margin-top: 5px;
580 font-size: 12px;
937+ font-size: var(--text-sm);
581938 color: var(--text-faint);
582939}
583940
@@ −586,41 +943,91 @@
586943.chip {
587944 display: inline-flex;
588945 align-items: center;
589 padding: 1px 7px;
946+ height: 18px;
947+ padding: 0 5px;
590948 border-radius: var(--radius-sm);
591949 border: 1px solid var(--border-strong);
592950 font-family: var(--font-mono);
593 font-size: 11.5px;
951+ font-size: var(--text-xs);
594952 color: var(--text-dim);
953+ white-space: nowrap;
595954}
596955
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 {
600969 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;
603990}
604991
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. */
605995.badge {
606996 display: inline-flex;
607997 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);
6111005 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;
6131010}
6141011
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+}
6191019
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+
6201026/* ─── notices ────────────────────────────────────────────────────────────── */
6211027
6221028.banner {
623 padding: 12px 16px;
1029+ padding: 10px 12px;
1030+ font-size: var(--text-sm);
6241031 border-radius: var(--radius);
6251032 border: 1px solid var(--border-strong);
6261033 background: var(--surface);
@@ −639,35 +1046,36 @@
6391046
6401047/* ─── empty states ───────────────────────────────────────────────────────── */
6411048
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. */
6421052.empty {
1053+ display: flex;
1054+ flex-direction: column;
1055+ align-items: center;
1056+ gap: 6px;
6431057 text-align: center;
644 padding: 56px 20px;
1058+ padding: 28px 20px;
6451059 color: var(--text-dim);
646 border: 1px dashed var(--border-strong);
1060+ border: 1px solid var(--border);
6471061 border-radius: var(--radius);
1062+ background: var(--surface);
6481063}
6491064
6501065.empty h2 {
1066+ margin: 0;
1067+ font-size: var(--text-md);
1068+ line-height: 24px;
6511069 color: var(--text);
6521070}
6531071
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);
6651075}
6661076
667.clone-box code {
668 white-space: nowrap;
669 color: var(--text-dim);
670}
1077+/* ─── code blocks ────────────────────────────────────────────────────────── */
1078+
6711079
6721080/* Protocol toggle (HTTPS/SSH clone instructions). Two radios drive which
6731081 panel shows via a sibling selector — no script needed, so the choice works
@@ −709,7 +1117,7 @@
7091117
7101118#proto-https:focus-visible ~ .proto-tabs label[for="proto-https"],
7111119#proto-ssh:focus-visible ~ .proto-tabs label[for="proto-ssh"] {
712 outline: 2px solid var(--brand);
1120+ outline: 2px solid var(--action);
7131121 outline-offset: 2px;
7141122}
7151123
@@ −724,16 +1132,17 @@
7241132
7251133#proto-https:checked ~ .proto-tabs label[for="proto-https"],
7261134#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);
7291137}
7301138
7311139footer {
7321140 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);
7351144 color: var(--text-faint);
736 font-size: 12px;
1145+ font-size: var(--text-sm);
7371146}
7381147
7391148footer .wrap {
@@ −745,20 +1154,24 @@
7451154
7461155footer a {
7471156 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);
7481163}
7491164
7501165.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);
7561169}
7571170
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. */
7601173.footer-end {
761 margin-left: auto;
1174+ font-size: var(--text-xs);
7621175}
7631176
7641177/* Respect a user's reduced-motion preference (spec §11 accessibility pass). */
@@ −791,448 +1204,805 @@
7911204}
7921205
7931206
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+}
7951221
796.hero {
797 border: 1px solid var(--border);
798 border-radius: var(--radius);
1222+.band-surface {
7991223 background: var(--surface);
800 overflow: hidden;
8011224}
8021225
803.hero-body {
804 padding: 40px 40px 36px;
1226+.band > .wrap {
1227+ padding-top: 32px;
1228+ padding-bottom: 32px;
8051229}
8061230
807.hero-eyebrow {
1231+.band-head {
8081232 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;
8091258 align-items: center;
810 gap: 8px;
811 margin-bottom: 20px;
8121259}
8131260
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 {
8151271 display: inline-flex;
8161272 align-items: center;
8171273 height: 20px;
8181274 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);
8251277}
8261278
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. */
8271281.hero-title {
828 font-size: var(--text-2xl);
829 max-width: 20ch;
830 margin: 0;
831 text-wrap: balance;
1282+ max-width: 15ch;
8321283}
8331284
8341285.hero-lede {
835 max-width: 54ch;
836 margin: 24px 0 0;
1286+ margin: 0;
8371287 font-size: var(--text-md);
838 line-height: 1.55;
1288+ line-height: 24px;
8391289 color: var(--text-dim);
1290+ max-width: 44ch;
1291+ text-wrap: pretty;
1292+}
1293+
1294+.hero-lede .mono {
1295+ color: var(--text);
8401296}
8411297
8421298.hero-actions {
8431299 display: flex;
1300+ gap: 8px;
1301+ align-items: center;
8441302 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;
8451317 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;
8471332}
8481333
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;
8521340}
8531341
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;
8571347}
8581348
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;
8601356 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;
8641362}
8651363
866.ticker-item {
1364+.term-bar,
1365+.term-foot {
8671366 display: flex;
8681367 align-items: center;
8691368 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 {
8701400 font-family: var(--font-mono);
8711401 font-size: var(--text-xs);
8721402 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;
8731494 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);
8741501}
8751502
8761503.ticker-dot {
877 color: var(--brand);
1504+ font-family: var(--font-mono);
1505+ color: var(--identity);
8781506}
8791507
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;
8901515}
8911516
892/* Two-column body shared by the landing page and the dashboard. */
893.landing-columns {
1517+.columns-main {
1518+ min-width: 0;
8941519 display: flex;
8951520 flex-direction: column;
896 gap: 32px;
897 margin-top: 40px;
1521+ gap: 8px;
8981522}
8991523
900.landing-main {
901 min-width: 0;
902 flex: 1;
1524+.columns-aside {
9031525 display: flex;
9041526 flex-direction: column;
905 gap: 32px;
1527+ gap: 16px;
1528+ min-width: 0;
9061529}
9071530
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 {
9111534 display: flex;
9121535 flex-direction: column;
913 gap: 32px;
1536+ gap: 6px;
1537+ padding-bottom: 14px;
1538+ border-bottom: 1px solid var(--border);
9141539}
9151540
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;
9231544}
9241545
925.section-head {
1546+.aside-head {
9261547 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;
9321550}
9331551
934.section-note {
935 margin: 8px 0 12px;
936}
937
938/* ─── feed ─────────────────────────────────────────────────────────────────── */
1552+/* ─── activity feed ────────────────────────────────────────────────────────── */
9391553
9401554.feed {
941 list-style: none;
942 margin: 8px 0 0;
943 padding: 0;
9441555 border: 1px solid var(--border);
9451556 border-radius: var(--radius);
1557+ background: var(--surface);
1558+ overflow: hidden;
1559+ margin: 0;
1560+ padding: 0;
1561+ list-style: none;
9461562}
9471563
9481564.feed-row {
949 position: relative;
9501565 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;
9541570 border-bottom: 1px solid var(--border);
955 overflow: hidden;
1571+ font-size: var(--text-sm);
9561572}
9571573
9581574.feed-row:last-child {
9591575 border-bottom: none;
9601576}
9611577
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);
9731604}
9741605
975.feed-row:hover::before {
976 opacity: 1;
1606+.feed-repo .owner {
1607+ color: var(--identity);
1608+ font-weight: 500;
9771609}
9781610
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;
9821615}
9831616
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. */
9841619.feed-title {
985 display: block;
1620+ font-size: var(--text-base);
9861621 color: var(--text);
987 font-weight: 500;
9881622 overflow: hidden;
9891623 text-overflow: ellipsis;
9901624 white-space: nowrap;
1625+ min-width: 0;
1626+ flex: 1;
9911627}
9921628
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;
9961637}
9971638
998.feed-meta {
1639+.feed-foot {
9991640 display: flex;
1000 flex-wrap: wrap;
10011641 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);
10051644 color: var(--text-faint);
10061645}
10071646
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;
10111655}
10121656
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 {
10141671 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;
10191677}
10201678
1021/* ─── activity ─────────────────────────────────────────────────────────────── */
1679+.mini-row:hover {
1680+ background: var(--surface);
1681+ text-decoration: none;
1682+}
10221683
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;
10271713}
10281714
1029.activity-row {
1715+/* ─── bookmark rows ────────────────────────────────────────────────────────── */
1716+
1717+.bookmark-line {
10301718 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;
10361721 font-size: var(--text-sm);
1722+ color: var(--text-dim);
10371723}
10381724
1039.activity-row:last-child {
1040 border-bottom: none;
1725+.bookmark-line:hover {
1726+ text-decoration: none;
10411727}
10421728
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);
10461732}
10471733
1048.activity-when {
1049 margin-left: auto;
1734+.bookmark-flag {
1735+ font-family: var(--font-condensed);
10501736 font-size: var(--text-xs);
1737+ font-weight: 500;
1738+ letter-spacing: 0.06em;
1739+ text-transform: uppercase;
1740+ color: var(--text-faint);
10511741}
10521742
1053/* ─── comparison list ──────────────────────────────────────────────────────── */
1743+.bookmark-flag.is-diverged {
1744+ color: var(--conflict);
1745+}
10541746
1747+/* ─── why switch ───────────────────────────────────────────────────────────── */
1748+
10551749.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;
10581754 padding: 0;
1059 display: flex;
1060 flex-direction: column;
1061 gap: 10px;
1755+ list-style: none;
10621756}
10631757
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. */
10641760.compare-item {
1065 display: flex;
1066 flex-direction: column;
1067 gap: 4px;
1068 padding: 12px;
10691761 border: 1px solid var(--border);
10701762 border-radius: var(--radius);
1071 background: var(--surface);
1763+ background: var(--bg);
1764+ overflow: hidden;
10721765}
10731766
10741767.compare-them,
10751768.compare-us {
10761769 display: flex;
1077 align-items: baseline;
1770+ align-items: flex-start;
10781771 gap: 8px;
1079 font-size: var(--text-sm);
1772+ padding: 8px 10px;
1773+ font-size: var(--text-base);
1774+ text-wrap: pretty;
10801775}
10811776
10821777.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);
10861780}
10871781
10881782.compare-us {
1783+ background: var(--identity-wash);
10891784 color: var(--text);
10901785}
10911786
10921787.compare-sign {
10931788 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;
10991791}
11001792
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); }
11041795
11051796/* ─── repo cards ───────────────────────────────────────────────────────────── */
11061797
11071798.repo-cards {
1108 display: flex;
1109 flex-direction: column;
1799+ display: grid;
1800+ grid-template-columns: repeat(3, minmax(0, 1fr));
11101801 gap: 8px;
1111 margin-top: 12px;
11121802}
11131803
11141804.repo-card {
1115 position: relative;
11161805 display: flex;
11171806 flex-direction: column;
11181807 gap: 6px;
1119 padding: 12px 12px 12px 16px;
1808+ padding: 12px;
11201809 border: 1px solid var(--border);
11211810 border-radius: var(--radius);
11221811 background: var(--surface);
11231812 color: var(--text);
1124 overflow: hidden;
11251813}
11261814
11271815.repo-card:hover {
1128 border-color: var(--border-strong);
1816+ border-color: var(--action);
11291817 text-decoration: none;
11301818}
11311819
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);
11401824}
11411825
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;
11441831}
11451832
1146.repo-card-name {
1833+.repo-card-meta {
11471834 display: flex;
11481835 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);
11551841}
11561842
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; }
11651846
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. */
11661849.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);
11691855 border-radius: var(--radius);
1170 color: var(--text-dim);
1856+ font-family: var(--font-mono);
11711857 font-size: var(--text-sm);
1172 text-align: center;
1858+ color: var(--text-dim);
11731859}
11741860
11751861.repo-card-new:hover {
1176 border-color: var(--brand);
1177 color: var(--brand);
1862+ border-color: var(--action);
1863+ color: var(--action);
11781864 text-decoration: none;
11791865}
11801866
1181/* ─── call to action band ──────────────────────────────────────────────────── */
1867+/* ─── call to action ───────────────────────────────────────────────────────── */
11821868
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;
11901876}
11911877
11921878.cta-title {
1193 margin: 0 auto;
1194 max-width: 22ch;
1879+ margin: 0;
11951880 font-size: var(--text-xl);
1196 text-wrap: balance;
1881+ line-height: 32px;
1882+ font-weight: 600;
1883+ text-wrap: pretty;
11971884}
11981885
11991886.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);
12031889 color: var(--text-dim);
1204}
1205
1206.cta-actions {
1207 justify-content: center;
1890+ text-wrap: pretty;
12081891}
12091892
1210/* ─── dashboard header ─────────────────────────────────────────────────────── */
1893+/* ─── dashboard ────────────────────────────────────────────────────────────── */
12111894
12121895.dash-head {
12131896 display: flex;
1897+ align-items: flex-start;
1898+ gap: 16px;
12141899 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;
12191902 border-bottom: 1px solid var(--border);
12201903}
12211904
12221905.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;
12251910}
12261911
12271912.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;
12291946}
12301947
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;
12341971}
12351972
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+}
12362006/* ─── design system pages ──────────────────────────────────────────────────── */
12372007
12382008.design-head,
@@ −1245,8 +2015,51 @@
12452015.design-title {
12462016 margin: 0 0 6px;
12472017 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;
12482028}
12492029
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+
12502063.design-section {
12512064 padding: 28px 0;
12522065 border-bottom: 1px solid var(--border);
@@ −1330,63 +2143,14 @@
13302143.rationale p {
13312144 line-height: 1.6;
13322145 margin: 0 0 12px;
1333}
1334
1335/* ─── repository header ────────────────────────────────────────────────────── */
1336
1337.repo-head {
1338 margin-bottom: 20px;
13392146}
13402147
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 ────────────────────────────────────────────────────────── */
13492149
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. */
13692153
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
13902154/* ─── sign in ──────────────────────────────────────────────────────────────── */
13912155
13922156.signin {
@@ −1394,30 +2158,40 @@
13942158 margin: 48px auto;
13952159 display: flex;
13962160 flex-direction: column;
1397 gap: 24px;
2161+ gap: 14px;
13982162}
13992163
14002164.signin-intro {
14012165 display: flex;
14022166 flex-direction: column;
1403 gap: 8px;
1404}
1405
1406.signin-eyebrow {
1407 color: var(--brand);
2167+ gap: 6px;
14082168}
14092169
14102170.signin-title {
14112171 margin: 0;
14122172 font-size: var(--text-xl);
2173+ line-height: 32px;
14132174 text-wrap: balance;
14142175}
14152176
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+
14162189.btn-block {
14172190 display: flex;
14182191 justify-content: center;
14192192 width: 100%;
1420 height: 38px;
2193+ height: 34px;
2194+ font-size: var(--text-base);
14212195}
14222196
14232197/* A control that is present but not offered. Not a `<button disabled>` because
@@ −1429,12 +2203,13 @@
14292203
14302204.is-disabled:hover {
14312205 border-color: var(--border-strong);
2206+ color: var(--text);
14322207}
14332208
14342209.signin-or {
14352210 display: flex;
14362211 align-items: center;
1437 gap: 12px;
2212+ gap: 8px;
14382213}
14392214
14402215.signin-rule {
@@ −1443,9 +2218,24 @@
14432218 background: var(--border);
14442219}
14452220
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;
14492239}
14502240
14512241/* ─── segmented control ────────────────────────────────────────────────────── */
@@ −1460,9 +2250,12 @@
14602250}
14612251
14622252.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);
14642258 font-size: var(--text-xs);
1465 font-weight: 500;
14662259 color: var(--text-dim);
14672260}
14682261
@@ −1477,13 +2270,13 @@
14772270}
14782271
14792272.segmented-item.is-on {
1480 background: var(--brand);
1481 color: var(--brand-ink);
2273+ background: var(--action);
2274+ color: var(--on-action);
14822275}
14832276
14842277.segmented-item.is-on:hover {
1485 background: var(--brand);
1486 color: var(--brand-ink);
2278+ background: var(--action);
2279+ color: var(--on-action);
14872280}
14882281
14892282/* ─── file listing ─────────────────────────────────────────────────────────── */
@@ −1507,23 +2300,107 @@
15072300}
15082301
15092302.tree-crumbs {
1510 margin-bottom: 14px;
2303+ display: flex;
2304+ align-items: center;
2305+ gap: 8px;
2306+ margin-bottom: 12px;
15112307 flex-wrap: wrap;
15122308}
15132309
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. */
15162385.commit-bar {
15172386 display: flex;
15182387 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);
15242392 font-size: var(--text-sm);
15252393}
15262394
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+
15272404.commit-bar-author {
15282405 font-weight: 500;
15292406 flex-shrink: 0;
@@ −1531,69 +2408,91 @@
15312408
15322409.commit-bar-msg {
15332410 min-width: 0;
1534 flex: 1;
1535 color: var(--text-dim);
2411+ color: var(--text);
15362412 overflow: hidden;
15372413 text-overflow: ellipsis;
15382414 white-space: nowrap;
15392415}
15402416
1541.commit-bar-chip,
15422417.commit-bar-when {
15432418 flex-shrink: 0;
2419+ font-family: var(--font-mono);
2420+ font-size: var(--text-xs);
2421+ color: var(--text-faint);
15442422}
15452423
15462424@media (max-width: 620px) {
1547 .commit-bar-chip {
2425+ .commit-bar .cid {
15482426 display: none;
15492427 }
15502428}
15512429
2430+/* The bordered container shared by the file listing, the bookmarks table and
2431+ the README panel. */
15522432.filelist {
15532433 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);
15562436 overflow: hidden;
15572437}
15582438
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;
15642441}
15652442
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. */
15662448.filelist table {
15672449 width: 100%;
2450+ table-layout: fixed;
15682451 border-collapse: collapse;
15692452}
15702453
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 {
15722467 border-bottom: 1px solid var(--border);
15732468}
15742469
1575.filelist tr:last-child {
2470+.filelist tbody tr:last-child {
15762471 border-bottom: none;
15772472}
15782473
1579.filelist tr:hover {
1580 background: var(--surface);
2474+.filelist tbody tr:hover {
2475+ background: var(--surface-raised);
15812476}
15822477
15832478.filelist td {
1584 padding: 6px 12px 6px 0;
2479+ padding: 0 8px;
2480+ height: 28px;
15852481 vertical-align: middle;
15862482}
15872483
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). */
15882488.filelist-icon {
1589 width: 1px;
1590 padding-left: 12px !important;
1591 padding-right: 8px !important;
2489+ width: 28px;
2490+ padding-right: 0 !important;
15922491 line-height: 0;
15932492}
15942493
15952494.icon-dir {
1596 color: var(--brand);
2495+ color: var(--action);
15972496}
15982497
15992498.icon-file {
@@ −1602,6 +2501,7 @@
16022501
16032502.filelist-name {
16042503 max-width: 0;
2504+ width: 32%;
16052505}
16062506
16072507.filelist-name a {
@@ −1619,7 +2519,7 @@
16192519}
16202520
16212521.filelist tr:hover .filelist-name a {
1622 color: var(--brand);
2522+ color: var(--action);
16232523 text-decoration: underline;
16242524}
16252525
@@ −1628,33 +2528,166 @@
16282528 font-size: var(--text-xs);
16292529}
16302530
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;
16332564 text-align: right;
16342565 white-space: nowrap;
2566+ font-family: var(--font-mono);
16352567 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;
16362598}
16372599
1638/* ─── readme ───────────────────────────────────────────────────────────────── */
2600+.aside-clone {
2601+ display: flex;
2602+ flex-direction: column;
2603+ gap: 2px;
2604+}
16392605
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);
16432611 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;
16442661}
16452662
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+
16462674.readme-head {
16472675 display: flex;
16482676 align-items: center;
16492677 gap: 8px;
1650 padding: 8px 16px;
2678+ padding: 6px 8px;
16512679 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);
16532683 line-height: 1;
16542684}
16552685
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. */
16562688.readme-body {
1657 padding: 16px 20px;
2689+ padding: 16px;
2690+ max-width: 76ch;
16582691}
16592692
16602693.readme-body > :first-child {
@@ −1720,8 +2753,8 @@
17202753}
17212754
17222755.markdown-body a:hover {
1723 text-decoration-color: var(--brand);
1724 color: var(--brand);
2756+ text-decoration-color: var(--action);
2757+ color: var(--action);
17252758}
17262759
17272760.markdown-body strong {
@@ −1734,7 +2767,7 @@
17342767 border: 1px solid var(--border);
17352768 border-radius: var(--radius-sm);
17362769 background: var(--surface-raised);
1737 color: var(--brand);
2770+ color: var(--action);
17382771 font-family: var(--font-mono);
17392772 font-size: 0.85em;
17402773}
@@ −1760,7 +2793,7 @@
17602793.markdown-body blockquote {
17612794 margin: 16px 0;
17622795 padding-left: 16px;
1763 border-left: 2px solid var(--brand);
2796+ border-left: 2px solid var(--action);
17642797 color: var(--text-dim);
17652798 font-style: italic;
17662799}
@@ −1917,30 +2950,57 @@
19172950
19182951/* ─── settings ─────────────────────────────────────────────────────────────── */
19192952
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. */
19202956.subtabs {
19212957 display: flex;
1922 gap: 4px;
1923 border-bottom: 1px solid var(--border);
1924 margin-bottom: 16px;
2958+ gap: 0;
19252959 overflow-x: auto;
2960+ scrollbar-width: none;
19262961}
19272962
2963+.subtabs::-webkit-scrollbar {
2964+ display: none;
2965+}
2966+
19282967.subtabs a {
1929 padding: 8px 12px;
2968+ display: inline-flex;
2969+ align-items: center;
2970+ gap: 6px;
2971+ height: 30px;
2972+ padding: 0 10px;
19302973 color: var(--text-dim);
1931 border-bottom: 2px solid transparent;
2974+ font-size: var(--text-sm);
19322975 white-space: nowrap;
19332976}
19342977
19352978.subtabs a:hover {
19362979 color: var(--text);
2980+ text-decoration: none;
19372981}
19382982
19392983.subtabs a.active {
19402984 color: var(--text);
1941 border-bottom-color: var(--brand);
2985+ font-weight: 500;
2986+ box-shadow: inset 0 -2px 0 var(--action);
19422987}
19432988
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+
19443004.listing {
19453005 width: 100%;
19463006 border-collapse: collapse;
@@ −1949,19 +3009,21 @@
19493009
19503010.listing th {
19513011 text-align: left;
3012+ font-family: var(--font-condensed);
19523013 font-weight: 500;
1953 font-size: 12px;
3014+ font-size: var(--text-xs);
3015+ letter-spacing: 0.06em;
19543016 text-transform: uppercase;
1955 letter-spacing: 0.04em;
1956 color: var(--text-faint);
3017+ color: var(--text-dim);
19573018 padding: 6px 10px 6px 0;
19583019 border-bottom: 1px solid var(--border);
19593020}
19603021
19613022.listing td {
1962 padding: 8px 10px 8px 0;
3023+ padding: 6px 10px 6px 0;
19633024 border-bottom: 1px solid var(--border);
19643025 vertical-align: middle;
3026+ font-size: var(--text-sm);
19653027}
19663028
19673029.listing tr:last-child td {
@@ −1976,10 +3038,11 @@
19763038}
19773039
19783040.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);
19813044 text-transform: uppercase;
1982 letter-spacing: 0.04em;
3045+ letter-spacing: 0.06em;
19833046 align-self: center;
19843047}
19853048
@@ −2023,7 +3086,7 @@
20233086
20243087textarea:focus,
20253088select:focus {
2026 outline: 2px solid var(--brand);
3089+ outline: 2px solid var(--action);
20273090 outline-offset: -1px;
20283091}
20293092
@@ −2039,6 +3102,379 @@
20393102}
20403103
20413104
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+
20423478/* ─── diffs and review ─────────────────────────────────────────────────────── */
20433479
20443480.filediff {
@@ −2082,6 +3518,47 @@
20823518.line-add { background: var(--diff-add-bg); color: var(--diff-add-text); }
20833519.line-del { background: var(--diff-del-bg); color: var(--diff-del-text); }
20843520
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+
20853562/* Word-level intra-line changes (spec §8). Rendered as a background wash
20863563 rather than a colour change, so the line's add/delete colour still reads and
20873564 the emphasis survives a high-contrast or monochrome display. */
@@ −2109,6 +3586,9 @@
21093586 font-size: 12px;
21103587}
21113588
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. */
21123592.comment {
21133593 border: 1px solid var(--border);
21143594 border-radius: var(--radius);
@@ −2134,26 +3614,349 @@
21343614 font-size: 12px;
21353615}
21363616
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. */
21373619.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);
21413903}
21423904
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;
21493907}
21503908
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+}
21573960
21583961/* ─── conflicts ────────────────────────────────────────────────────────────── */
21593962
@@ −2183,105 +3986,355 @@
21833986 max-height: 420px;
21843987}
21853988
2186/* ─── stack graph ──────────────────────────────────────────────────────────── */
3989+details summary {
3990+ cursor: pointer;
3991+}
21873992
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);
21924034}
21934035
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;
21984041}
21994042
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;
22094049}
22104050
2211.stacknode.current {
2212 border-left-color: var(--identity);
4051+.issue-row:hover .issue-title {
4052+ color: var(--action);
22134053}
22144054
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;
22174060}
22184061
2219details summary {
2220 cursor: pointer;
4062+.issue-comments {
4063+ width: 34px;
4064+ text-align: right;
4065+ font-family: var(--font-mono);
4066+ font-size: var(--text-xs);
4067+ color: var(--text-faint);
4068+ flex: none;
22214069}
22224070
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+}
22234079
2224/* ─── issues ───────────────────────────────────────────────────────────────── */
4080+/* ─── issue detail ─────────────────────────────────────────────────────────── */
22254081
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);
22324086}
22334087
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+}
22354095
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;
22404103}
22414104
2242.theme-toggle {
4105+.issue-ref {
22434106 display: flex;
22444107 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 {
22474165 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;
22534231 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;
22544245}
22554246
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 {
22574258 border-color: var(--border-strong);
2258 color: var(--text);
4259+ background: var(--surface-raised);
22594260}
22604261
2261.theme-toggle .icon-moon {
2262 display: none;
4262+.masthead-account a {
4263+ font-size: var(--text-sm);
22634264}
22644265
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);
22674304}
22684305
2269:root[data-theme="light"] .theme-toggle .icon-moon {
2270 display: block;
4306+.switcher-item-danger:hover {
4307+ color: var(--danger);
22714308}
22724309
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+ }
22784318}
22794319
2280/* On a narrow screen the search box is the first thing to give up its space. */
22814320@media (max-width: 620px) {
2282 .masthead-search {
4321+ .jump > span:first-child {
22834322 display: none;
22844323 }
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+ }
22854338}
22864339
22874340
@@ −2316,7 +4369,7 @@
23164369textarea:focus-visible,
23174370summary:focus-visible,
23184371[tabindex]:focus-visible {
2319 outline: 2px solid var(--brand);
4372+ outline: 2px solid var(--action);
23204373 outline-offset: 2px;
23214374 border-radius: var(--radius-sm);
23224375}
@@ −2365,19 +4418,69 @@
23654418
23664419/* ─── the in-browser editor ────────────────────────────────────────────────── */
23674420
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+
23684461/* The textarea is the editor until CodeMirror replaces it, so it has to look
23694462 like one on its own — not like a form field that happens to hold code. */
23704463.editor-host textarea {
4464+ display: block;
23714465 width: 100%;
23724466 min-height: 420px;
4467+ border: none;
4468+ border-radius: 0;
4469+ padding: 8px 10px;
4470+ background: var(--bg);
23734471 font-family: var(--font-mono);
23744472 font-size: 13px;
2375 line-height: 1.5;
4473+ line-height: 21px;
23764474 tab-size: 4;
23774475 white-space: pre;
23784476 overflow: auto;
23794477}
23804478
4479+.editor-host textarea:focus {
4480+ outline: none;
4481+ border: none;
4482+}
4483+
23814484/* CodeMirror sizes itself; the host must not fight it. */
23824485.editor-host .cm-editor {
23834486 height: auto;
@@ −2531,11 +4634,14 @@
25314634 color: var(--text-dim);
25324635}
25334636
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); }
25394645.sk-mod { color: var(--text-dim); background: var(--border); }
25404646
25414647.symbol-name {
@@ −2597,3 +4703,183 @@
25974703 display: none;
25984704 }
25994705}
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+}
Mcrates/df-web/assets/theme.js+31−18
@@ −1,35 +1,48 @@
11// Theme toggle. Enhancement only — the site is fully usable in its default
22// 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.
37(function () {
48 var KEY = "dogfood-theme";
59
10+ function current() {
11+ return document.documentElement.getAttribute("data-theme") === "light"
12+ ? "light"
13+ : "dark";
14+ }
15+
616 function apply(theme) {
717 if (theme === "light") {
818 document.documentElement.setAttribute("data-theme", "light");
919 } else {
1020 document.documentElement.removeAttribute("data-theme");
1121 }
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+ });
1227 }
1328
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+
1442 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"));
1943 btn.hidden = false;
44+ btn.addEventListener("click", window.dogfoodToggleTheme);
45+ });
2046
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());
3548})();
Mcrates/df-web/src/main.rs+34−0
@@ −151,6 +151,8 @@
151151 .route("/assets/htmx.min.js", get(serve_htmx))
152152 .route("/assets/theme-init.js", get(serve_theme_init_js))
153153 .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))
154156 .route("/assets/editor.js", get(serve_editor_js))
155157 // ─── operational ─────────────────────────────────────────────────────
156158 // Repository routes are declared last so their `{owner}` wildcard
@@ −196,6 +198,7 @@
196198 .route("/{owner}/{repo}/changes/{reference}", get(routes::review::overview))
197199 .route("/{owner}/{repo}/changes/{reference}/files", get(routes::review::files))
198200 .route("/{owner}/{repo}/changes/{reference}/revisions", get(routes::review::revisions))
201+ .route("/{owner}/{repo}/changes/{reference}/checks", get(routes::review::checks))
199202 .route("/{owner}/{repo}/changes/{reference}/conflicts", get(routes::review::conflicts))
200203 .route("/{owner}/{repo}/changes/{reference}/comments", post(routes::review::create_comment))
201204 .route(
@@ −416,6 +419,37 @@
416419 )
417420}
418421
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+
419453async fn not_found() -> error::AppError {
420454 error::AppError::NotFound
421455}
Mcrates/df-web/src/repo_ctx.rs+39−0
@@ −26,6 +26,25 @@
2626 /// Display handle of the owning user or org.
2727 pub owner: String,
2828 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,
2948}
3049
3150impl RepoContext {
@@ −96,9 +115,29 @@
96115 return Err(AppError::NotFound);
97116 }
98117
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+
99137 Ok(RepoContext {
100138 owner: row.owner_handle,
101139 access,
140+ nav,
102141 repo: Repo {
103142 id: row.id,
104143 owner_kind: row.owner_kind,
Mcrates/df-web/src/security_tests.rs+93−0
@@ −140,6 +140,18 @@
140140 ) -> SResult<Option<Revision>> {
141141 Ok(None)
142142 }
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+ }
143155}
144156
145157/// Enough provider metadata for `Oidc` to construct without a network call.
@@ −650,6 +662,87 @@
650662 h.drop_schema().await;
651663}
652664
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+
653746/// A profile page must not enumerate repositories the viewer cannot open.
654747#[tokio::test]
655748async fn a_profile_does_not_list_private_repositories_to_strangers() {
Mcrates/df-store/src/git/mod.rs+125−0
@@ −470,6 +470,27 @@
470470 .await
471471 }
472472
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+
473494 async fn commit_file(
474495 &self,
475496 id: RepoId,
@@ −709,6 +730,110 @@
709730 })
710731 .await
711732 }
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+ }
712837}
713838
714839/// Recursive directory size, used for the repo settings page.
Mcrates/df-web/src/routes/auth.rs+26−1
@@ −59,9 +59,34 @@
5959 None => "/login?continue=sso".to_string(),
6060 };
6161
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+
6287 Ok(views::page(
6388 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()),
6590 )
6691 .into_response())
6792}
Mcrates/df-web/src/routes/change.rs+245−36
@@ −48,59 +48,103 @@
4848
4949 // On a revset error, show the message and no rows rather than silently
5050 // 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() {
5252 Vec::new()
5353 } 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?
5563 };
5664
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+
5771 let body = maud::html! {
58 (rv::header(&ctx, "changes"))
5972 (v::list(&ctx, &rows, v::ListFilters {
6073 state: state_filter,
6174 revset: &revset_input,
6275 revset_error: revset_error.as_deref(),
76+ counts,
77+ signed_in: user.is_some(),
78+ week,
6379 }))
6480 };
6581
66 Ok(views::page(
82+ Ok(views::page_with_bar(
6783 Chrome {
6884 title: &format!("Changes · {}/{}", ctx.owner, ctx.repo.name),
6985 user: user.as_deref(),
7086 csrf: &csrf,
7187 nonce: &nonce,
7288 },
89+ rv::header(&ctx, "changes"),
7390 body,
7491 )
7592 .into_response())
7693}
7794
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+
78118async fn load_changes(
79119 state: &AppState,
80120 repo_id: Uuid,
81121 state_filter: &str,
122+ viewer: Option<Uuid>,
82123 revset_sql: Option<String>,
83124 revset_vals: Vec<String>,
84125) -> 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!(
88129 "SELECT c.number, c.change_id, c.synthetic, c.title, c.state::text,
89130 c.conflicted, c.updated_at,
90131 u.handle::text AS author,
91132 hr.author_name,
133+ hr.rev AS head_rev,
92134 (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,
93136 COALESCE((
94 SELECT array_agg(pc.change_id)
137+ SELECT array_agg(pp.change_id)
95138 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
99142 FROM changes c
100143 LEFT JOIN users u ON u.id = c.author_user_id
101144 LEFT JOIN revisions hr ON hr.id = c.head_revision_id
102145 WHERE c.repo_id = $1
103 AND ($2 = 'all' OR c.state::text = $2)",
146+ AND ({})",
147+ state_predicate(state_filter)
104148 );
105149
106150 if let Some(frag) = &revset_sql {
@@ −109,24 +153,23 @@
109153 }
110154 sql.push_str(" ORDER BY c.updated_at DESC LIMIT 100");
111155
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);
130173
131174 for v in revset_vals {
132175 query = query.bind(v);
@@ −147,8 +190,10 @@
147190 updated_at,
148191 author,
149192 author_name,
193+ head_rev,
150194 revcount,
151 children,
195+ comments,
196+ parents,
152197 )| {
153198 v::ChangeRow {
154199 number,
@@ −160,14 +205,161 @@
160205 updated_at,
161206 author,
162207 author_name,
208+ head_rev,
163209 revision_count: revcount,
164 children,
210+ comments,
211+ parents,
212+ reviewers: Vec::new(),
213+ diffstat: None,
214+ depth: 0,
215+ stack_size: 0,
165216 }
166217 },
167218 )
168219 .collect())
169220}
170221
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+
171363pub struct ChangeRecord {
172364 pub id: Uuid,
173365 pub number: i64,
@@ −178,6 +370,8 @@
178370 pub state: String,
179371 pub conflicted: bool,
180372 pub target_bookmark: String,
373+ pub created_at: chrono::DateTime<chrono::Utc>,
374+ pub updated_at: chrono::DateTime<chrono::Utc>,
181375}
182376
183377pub enum Resolution {
@@ −187,7 +381,19 @@
187381 None,
188382}
189383
190type ChangeTuple = (Uuid, i64, String, bool, String, String, String, bool, String);
384+type ChangeTuple = (
385+ Uuid,
386+ i64,
387+ String,
388+ bool,
389+ String,
390+ String,
391+ String,
392+ bool,
393+ String,
394+ chrono::DateTime<chrono::Utc>,
395+ chrono::DateTime<chrono::Utc>,
396+);
191397
192398fn to_record(t: ChangeTuple) -> ChangeRecord {
193399 ChangeRecord {
@@ −200,11 +406,14 @@
200406 state: t.6,
201407 conflicted: t.7,
202408 target_bookmark: t.8,
409+ created_at: t.9,
410+ updated_at: t.10,
203411 }
204412}
205413
206414const 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
208417 FROM changes";
209418
210419pub async fn resolve_change(state: &AppState, repo_id: Uuid, reference: &str) -> AppResult<Resolution> {
@@ −287,17 +496,17 @@
287496 .await?;
288497
289498 let body = maud::html! {
290 (rv::header(&ctx, "changes"))
291499 (v::new_change_form(&ctx, &csrf, &candidates, &bookmarks, q.error.as_deref()))
292500 };
293501
294 Ok(views::page(
502+ Ok(views::page_with_bar(
295503 Chrome {
296504 title: &format!("Open a change · {}/{}", ctx.owner, ctx.repo.name),
297505 user: user.as_deref(),
298506 csrf: &csrf,
299507 nonce: &nonce,
300508 },
509+ rv::header(&ctx, "changes"),
301510 body,
302511 )
303512 .into_response())
Mcrates/df-web/src/routes/edit.rs+2−2
@@ −100,7 +100,6 @@
100100 };
101101
102102 let body = maud::html! {
103 (rv::header(&ctx, "code"))
104103 (v::editor(&ctx, v::Editor {
105104 bookmark: &bookmark,
106105 tip: tip.as_str(),
@@ −113,13 +112,14 @@
113112 }))
114113 };
115114
116 Ok(views::page(
115+ Ok(views::page_with_bar(
117116 Chrome {
118117 title: &format!("Editing {} · {}/{}", p.path, ctx.owner, ctx.repo.name),
119118 user: user.as_deref(),
120119 csrf: &csrf,
121120 nonce: &nonce,
122121 },
122+ rv::header(&ctx, "code"),
123123 // The one page that loads the editor bundle.
124124 maud::html! {
125125 (body)
Mcrates/df-web/src/routes/home.rs+177−21
@@ −6,7 +6,9 @@
66
77use crate::error::AppResult;
88use crate::state::{AppState, CsrfToken, CurrentUser, Nonce};
9use crate::views::pages::{ActivityItem, Dashboard, DashChange, FeedItem, RepoSummary};
9+use crate::views::pages::{
10+ ActivityItem, BookmarkItem, Dashboard, DashChange, FeedItem, RepoSummary, StackItem,
11+};
1012use crate::views::{self, Chrome};
1113
1214/// Repos the viewer can reach: their own, plus any they collaborate on, plus
@@ −40,12 +42,30 @@
4042 Nonce(nonce): Nonce,
4143) -> AppResult<Response> {
4244 let Some(user) = user else {
43 let hint = state.config.https_clone_url("your-org", "your-repo");
4445 let feed = public_feed(&state).await?;
4546 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(
4760 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+ }),
4969 )
5070 .into_response());
5171 };
@@ −96,21 +116,53 @@
96116
97117// ─── queries ─────────────────────────────────────────────────────────────────
98118
99type RepoRow = (String, String, Option<String>, bool);
119+type RepoRow = (
120+ String,
121+ String,
122+ Option<String>,
123+ bool,
124+ i64,
125+ i64,
126+ Option<DateTime<Utc>>,
127+);
100128
101129fn to_summaries(rows: Vec<RepoRow>) -> Vec<RepoSummary> {
102130 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+ )
104142 .collect()
105143}
106144
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+
107158async fn visible_repos(state: &AppState, user: &df_db::models::User) -> AppResult<Vec<RepoSummary>> {
108159 let sql = format!(
109160 r#"
110161 SELECT COALESCE(ou.handle, og.handle) AS owner,
111162 r.name::text,
112163 r.description,
113 (r.visibility = 'private') AS private
164+ (r.visibility = 'private') AS private,
165+ {REPO_CARD_COUNTS}
114166 FROM repos r
115167 LEFT JOIN users ou ON ou.id = r.owner_user_id
116168 LEFT JOIN orgs og ON og.id = r.owner_org_id
@@ −131,32 +183,130 @@
131183
132184/// Public repositories, for the signed-out landing page.
133185async 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!(
135187 r#"
136188 SELECT COALESCE(ou.handle, og.handle) AS owner,
137189 r.name::text,
138190 r.description,
139 false AS private
191+ false AS private,
192+ {REPO_CARD_COUNTS}
140193 FROM repos r
141194 LEFT JOIN users ou ON ou.id = r.owner_user_id
142195 LEFT JOIN orgs og ON og.id = r.owner_org_id
143196 WHERE r.archived = false AND r.visibility = 'public'
144197 ORDER BY r.pushed_at DESC NULLS LAST, r.created_at DESC
145198 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
146279 "#,
147280 )
148281 .fetch_all(&state.db)
149282 .await?;
150283
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())
152294}
153295
154296/// The "shipping right now" feed.
155297///
156298/// Public repositories only, and no drafts: this renders for anonymous
157299/// 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.
158306async 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 = (
160310 String,
161311 String,
162312 i64,
@@ −165,8 +315,11 @@
165315 String,
166316 Option<String>,
167317 Option<String>,
318+ String,
168319 DateTime<Utc>,
169 )> = sqlx::query_as(
320+ );
321+
322+ let rows: Vec<Row> = sqlx::query_as(
170323 // `hr.author_name` is the fallback when no account matched the commit's
171324 // email — the person is still known, just not linkable.
172325 r#"
@@ −176,19 +329,21 @@
176329 c.change_id,
177330 c.synthetic,
178331 c.title,
179 au.handle AS author,
332+ ac.handle AS actor,
180333 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
184339 LEFT JOIN users ou ON ou.id = r.owner_user_id
185340 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
187342 LEFT JOIN revisions hr ON hr.id = c.head_revision_id
188343 WHERE r.archived = false
189344 AND r.visibility = 'public'
190345 AND c.state <> 'draft'
191 ORDER BY c.updated_at DESC
346+ ORDER BY e.created_at DESC
192347 LIMIT 12
193348 "#,
194349 )
@@ −198,7 +353,7 @@
198353 Ok(rows
199354 .into_iter()
200355 .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)| {
202357 FeedItem {
203358 owner,
204359 repo,
@@ −206,8 +361,9 @@
206361 change_id,
207362 synthetic,
208363 title,
209 author,
210 author_name,
364+ actor,
365+ actor_name,
366+ kind,
211367 when,
212368 }
213369 },
Mcrates/df-web/src/routes/issue.rs+6−6
@@ −48,7 +48,6 @@
4848 let all_labels = load_labels(&state, ctx.repo.id).await?;
4949
5050 let body = maud::html! {
51 (rv::header(&ctx, "issues"))
5251 (v::list(&ctx, &rows, v::ListFilters {
5352 state: state_filter,
5453 label,
@@ −57,13 +56,14 @@
5756 }))
5857 };
5958
60 Ok(views::page(
59+ Ok(views::page_with_bar(
6160 Chrome {
6261 title: &format!("Issues · {}/{}", ctx.owner, ctx.repo.name),
6362 user: user.as_deref(),
6463 csrf: &csrf,
6564 nonce: &nonce,
6665 },
66+ rv::header(&ctx, "issues"),
6767 body,
6868 )
6969 .into_response())
@@ −91,17 +91,17 @@
9191 let labels = load_labels(&state, ctx.repo.id).await?;
9292
9393 let body = maud::html! {
94 (rv::header(&ctx, "issues"))
9594 (v::new_form(&ctx, v::NewIssue { csrf: &csrf, labels: &labels, error: q.error.as_deref() }))
9695 };
9796
98 Ok(views::page(
97+ Ok(views::page_with_bar(
9998 Chrome {
10099 title: &format!("New issue · {}/{}", ctx.owner, ctx.repo.name),
101100 user: user.as_deref(),
102101 csrf: &csrf,
103102 nonce: &nonce,
104103 },
104+ rv::header(&ctx, "issues"),
105105 body,
106106 )
107107 .into_response())
@@ −232,7 +232,6 @@
232232 let body_html = render(&ctx, &issue.body);
233233
234234 let body = maud::html! {
235 (rv::header(&ctx, "issues"))
236235 (v::detail(&ctx, v::Detail {
237236 number: issue.number,
238237 title: &issue.title,
@@ −251,13 +250,14 @@
251250 }))
252251 };
253252
254 Ok(views::page(
253+ Ok(views::page_with_bar(
255254 Chrome {
256255 title: &format!("{} · {}/{}", issue.title, ctx.owner, ctx.repo.name),
257256 user: user.as_deref(),
258257 csrf: &csrf,
259258 nonce: &nonce,
260259 },
260+ rv::header(&ctx, "issues"),
261261 body,
262262 )
263263 .into_response())
Mcrates/df-web/src/routes/repo.rs+239−10
@@ −1,5 +1,6 @@
11//! Repository browsing (M2) and creation.
22
3+use std::collections::HashMap;
34use std::path::Path;
45
56use axum::extract::{Path as UrlPath, Query, State};
@@ −69,12 +70,127 @@
6970
7071 let readme = render_readme(&state, &ctx, &rev, &entries).await;
7172 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+ )
73102 };
74103
75104 Ok(render(&ctx, "code", &csrf, &nonce, user.as_deref(), body))
76105}
77106
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+
78194#[derive(Deserialize)]
79195pub struct RevPath {
80196 pub owner: String,
@@ −113,8 +229,23 @@
113229 };
114230
115231 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;
116234
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+ );
118249 Ok(render(&ctx, "code", &csrf, &nonce, user.as_deref(), body))
119250}
120251
@@ −132,12 +263,103 @@
132263
133264 Some(v::TipCommit {
134265 author: r.author.name.clone(),
266+ author_handle: handle_for_email(state, &r.author.email).await,
135267 summary: r.summary().to_string(),
136268 when: r.author.when,
137269 change_id: r.change_id.clone(),
138270 })
139271}
140272
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+
141363/// Is this path a markdown document?
142364///
143365/// Extension-based on purpose: sniffing content would mean a file that happens
@@ −199,6 +421,11 @@
199421 .ok()
200422 .flatten();
201423
424+ let last_commit_handle = match &last_commit {
425+ Some(c) => handle_for_email(&state, &c.author.email).await,
426+ None => None,
427+ };
428+
202429 // Optionally fetch blame.
203430 let blame_lines = if wants_blame {
204431 state
@@ −252,6 +479,7 @@
252479 sidebar_dir: &tree_dir,
253480 symbols: &symbols,
254481 last_commit: last_commit.as_ref(),
482+ last_commit_handle: last_commit_handle.as_deref(),
255483 blame: blame_lines.as_deref(),
256484 wants_blame,
257485 };
@@ −390,7 +618,11 @@
390618 .await
391619 .map_err(store_err)?;
392620
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);
394626 Ok(render(&ctx, "code", &csrf, &nonce, user.as_deref(), body))
395627}
396628
@@ −403,11 +635,7 @@
403635 Nonce(nonce): Nonce,
404636) -> AppResult<Response> {
405637 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?;
411639
412640 let body = v::bookmarks_view(&ctx, &marks);
413641 Ok(render(&ctx, "bookmarks", &csrf, &nonce, user.as_deref(), body))
@@ −670,14 +898,15 @@
670898 user: Option<&df_db::models::User>,
671899 body: maud::Markup,
672900) -> Response {
673 views::page(
901+ views::page_with_bar(
674902 Chrome {
675903 title: &format!("{}/{}", ctx.owner, ctx.repo.name),
676904 user,
677905 csrf,
678906 nonce,
679907 },
680 maud::html! { (v::header(ctx, tab)) (body) },
908+ v::header(ctx, tab),
909+ body,
681910 )
682911 .into_response()
683912}
Mcrates/df-web/src/routes/repo_settings.rs+2−2
@@ −51,7 +51,6 @@
5151 let tab = q.tab.as_deref().unwrap_or("general").to_owned();
5252
5353 let body = maud::html! {
54 (crate::views::repo::header(&ctx, "settings"))
5554 (v::repo_settings(v::RepoSettings {
5655 ctx: &ctx,
5756 csrf: &csrf,
@@ −64,13 +63,14 @@
6463 }))
6564 };
6665
67 Ok(views::page(
66+ Ok(views::page_with_bar(
6867 Chrome {
6968 title: &format!("{}/{} settings", ctx.owner, ctx.repo.name),
7069 user: user.as_deref(),
7170 csrf: &csrf,
7271 nonce: &nonce,
7372 },
73+ crate::views::repo::header(&ctx, "settings"),
7474 body,
7575 )
7676 .into_response())
Mcrates/df-web/src/routes/review.rs449 lines+256−34
@@ −40,6 +40,9 @@
4040 author: Option<String>,
4141 author_name: Option<String>,
4242 can_manage: bool,
43+ comment_count: i64,
44+ /// The right-hand column, identical on every tab.
45+ aside: v::ChangeAside,
4346}
4447
4548impl Loaded {
@@ −65,11 +68,11 @@
6568 Resolution::One(c) => *c,
6669 Resolution::Ambiguous(candidates) => {
6770 let body = maud::html! {
68 (rv::header(&ctx, "changes"))
6971 (cv::ambiguous(&ctx, reference, &candidates))
7072 };
71 return Ok(Err(views::page(
73+ return Ok(Err(views::page_with_bar(
7274 Chrome { title: "Ambiguous change id", user, csrf, nonce },
75+ rv::header(&ctx, "changes"),
7376 body,
7477 )
7578 .into_response()));
@@ −106,9 +109,118 @@
106109 let is_author = matches!((user, &author), (Some(u), Some(a)) if &u.handle == a);
107110 let can_manage = ctx.access.can_manage_changes() || is_author;
108111
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+ }))
110130}
111131
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+
112224impl Loaded {
113225 fn head_view<'a>(&'a self, csrf: &'a str, can_comment: bool) -> v::ChangeHead<'a> {
114226 v::ChangeHead {
@@ −122,6 +234,11 @@
122234 author: self.author.as_deref(),
123235 author_name: self.author_name.as_deref(),
124236 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,
125242 can_manage: self.can_manage,
126243 can_comment,
127244 csrf,
@@ −177,8 +294,8 @@
177294 let description_html = crate::routes::issue::render(&l.ctx, &l.change.description);
178295
179296 let body = maud::html! {
180 (rv::header(&l.ctx, "changes"))
181297 (v::header(&l.ctx, &head, "overview"))
298+ (v::tab_body(&l.ctx, &l.aside, maud::html! {
182299 @if let Some(e) = &flash.error { div .banner.banner-error role="alert" { (e) } }
183300 @if let Some(n) = &flash.notice { div .banner.banner-ok role="status" { (n) } }
184301 (v::overview(&l.ctx, &head, v::Overview {
@@ −190,15 +307,17 @@
190307 events: &events,
191308 viewer_reviewed,
192309 }))
310+ }))
193311 };
194312
195 Ok(views::page(
313+ Ok(views::page_with_bar(
196314 Chrome {
197315 title: &format!("{} · {}/{}", l.change.title, l.ctx.owner, l.ctx.repo.name),
198316 user: user.as_deref(),
199317 csrf: &csrf,
200318 nonce: &nonce,
201319 },
320+ rv::header(&l.ctx, "changes"),
202321 body,
203322 )
204323 .into_response())
@@ −279,25 +398,28 @@
279398 .filter(|c| c.anchor_path.is_some() && c.anchor_state != "orphaned")
280399 .collect();
281400
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+
282404 let body = maud::html! {
283 (rv::header(&l.ctx, "changes"))
284405 (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 {
286407 diff: diff.as_ref(),
287408 comments: &comments,
288409 rev: rev.as_deref().unwrap_or(""),
289410 against: against.as_deref(),
290411 revisions: &l.revisions,
291 }))
412+ })))
292413 };
293414
294 Ok(views::page(
415+ Ok(views::page_with_bar(
295416 Chrome {
296417 title: &format!("Files · {}", l.change.title),
297418 user: user.as_deref(),
298419 csrf: &csrf,
299420 nonce: &nonce,
300421 },
422+ rv::header(&l.ctx, "changes"),
301423 body,
302424 )
303425 .into_response())
@@ −305,10 +427,18 @@
305427
306428// ─── revisions ───────────────────────────────────────────────────────────────
307429
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+
308437/// `GET /{owner}/{repo}/changes/{ref}/revisions`
309438pub async fn revisions(
310439 State(state): State<AppState>,
311440 UrlPath((owner, name, reference)): UrlPath<(String, String, String)>,
441+ Query(q): Query<CompareQuery>,
312442 CurrentUser(user): CurrentUser,
313443 CsrfToken(csrf): CsrfToken,
314444 Nonce(nonce): Nonce,
@@ −319,7 +449,9 @@
319449 };
320450 let head = l.head_view(&csrf, false);
321451
322 let rows: Vec<(
452+ /// `(seq, rev, message, author_name, pushed_at, conflicted, pushed_by,
453+ /// parents)`
454+ type RevRow = (
323455 i32,
324456 String,
325457 String,
@@ −327,9 +459,12 @@
327459 chrono::DateTime<chrono::Utc>,
328460 bool,
329461 Option<String>,
330 )> = sqlx::query_as(
462+ Vec<String>,
463+ );
464+
465+ let rows: Vec<RevRow> = sqlx::query_as(
331466 "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
333468 FROM revisions r
334469 LEFT JOIN users u ON u.id = r.pushed_by
335470 WHERE r.change_id_fk = $1 ORDER BY r.seq",
@@ −338,10 +473,22 @@
338473 .fetch_all(&state.db)
339474 .await?;
340475
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+
341487 let revs: Vec<v::RevisionDetail> = rows
342488 .into_iter()
489+ .enumerate()
343490 .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))| {
345492 v::RevisionDetail {
346493 seq,
347494 rev,
@@ −350,29 +497,96 @@
350497 pushed_at,
351498 conflicted,
352499 pushed_by,
500+ diffstat: stats.get(i).copied().flatten(),
501+ base: parents.first().map(|p| df_store::abbreviate_rev(p).to_owned()),
353502 }
354503 },
355504 )
356505 .collect();
357506
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+
358537 let body = maud::html! {
359 (rv::header(&l.ctx, "changes"))
360538 (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() })))
362541 };
363542
364 Ok(views::page(
543+ Ok(views::page_with_bar(
365544 Chrome {
366545 title: &format!("Revisions · {}", l.change.title),
367546 user: user.as_deref(),
368547 csrf: &csrf,
369548 nonce: &nonce,
370549 },
550+ rv::header(&l.ctx, "changes"),
371551 body,
372552 )
373553 .into_response())
374554}
375555
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+
376590// ─── conflicts ───────────────────────────────────────────────────────────────
377591
378592/// `GET /{owner}/{repo}/changes/{ref}/conflicts`
@@ −402,18 +616,18 @@
402616 };
403617
404618 let body = maud::html! {
405 (rv::header(&l.ctx, "changes"))
406619 (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)))
408621 };
409622
410 Ok(views::page(
623+ Ok(views::page_with_bar(
411624 Chrome {
412625 title: &format!("Conflicts · {}", l.change.title),
413626 user: user.as_deref(),
414627 csrf: &csrf,
415628 nonce: &nonce,
416629 },
630+ rv::header(&l.ctx, "changes"),
417631 body,
418632 )
419633 .into_response())
@@ −775,30 +989,38 @@
775989 return Err(AppError::NotFound);
776990 }
777991
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+
7781005 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+ ))
7931014 };
7941015
795 Ok(views::page(
1016+ Ok(views::page_with_bar(
7961017 Chrome {
7971018 title: &format!("Stack · {}/{}", ctx.owner, ctx.repo.name),
7981019 user: user.as_deref(),
7991020 csrf: &csrf,
8001021 nonce: &nonce,
8011022 },
1023+ rv::header(&ctx, "changes"),
8021024 body,
8031025 )
8041026 .into_response())
Mcrates/df-web/src/routes/search.rs+42−26
@@ −27,6 +27,10 @@
2727 /// `repos` | `changes` | `issues`. Absent means all three.
2828 #[serde(rename = "type")]
2929 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>,
3034}
3135
3236/// The visibility predicate, shared by all three queries.
@@ −97,50 +101,62 @@
97101
98102 let total = repos.len() + changes.len() + issues.len();
99103
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+
100116 Ok(views::page(
101117 Chrome { title: "Search", user: user.as_deref(), csrf: &csrf, nonce: &nonce },
102118 maud::html! {
103 div .panel {
119+ div .page-head {
104120 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 {
118122 "Titles, descriptions and bodies. Code search is not part of v1 — \
119123 clone the repository and use " code { "jj" } " or " code { "grep" } "."
120124 }
121125 }
122126
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+
123141 @if !raw.is_empty() && total == 0 {
124 div .panel { div .empty {
142+ div .empty {
125143 h2 { "No results" }
126144 p { "Nothing you can see matches that." }
127 } }
145+ }
128146 }
129147
130148 @for (heading, hits) in [("Repositories", &repos), ("Changes", &changes), ("Issues", &issues)] {
131149 @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 {
135153 @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) } }
142158 @if !h.context.is_empty() {
143 p .dim style="margin:4px 0 0" { (h.context) }
159+ span .search-context { (h.context) }
144160 }
145161 }
146162 }
Mcrates/df-web/src/views/change.rs716 lines+570−76
@@ −20,17 +20,68 @@
2020 pub author: Option<String>,
2121 /// The name the commit itself carries, used when no account matched.
2222 pub author_name: Option<String>,
23+ /// The head revision's id, used to fetch the row's diffstat.
24+ pub head_rev: Option<String>,
2325 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,
2638}
2739
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.
2980///
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.
3485pub fn change_chip(change_id: &str, synthetic: bool) -> Markup {
3586 html! {
3687 @if synthetic {
@@ −38,111 +89,551 @@
3889 "git"
3990 }
4091 } @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))
4394 }
4495 }
4596 }
4697}
4798
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.
48104pub 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+
49113 html! {
50 @if conflicted {
51 span .badge.badge-conflict { "conflict" }
114+ span .badge.(class) {
115+ span .glyph aria-hidden="true" { (glyph) }
116+ (label)
52117 }
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+ }
58185 }
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));
59193 }
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
60221}
61222
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+
62284pub struct ListFilters<'a> {
63285 pub state: &'a str,
64286 pub revset: &'a str,
65287 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>,
66315}
67316
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.
68323pub fn list(ctx: &RepoContext, rows: &[ChangeRow], f: ListFilters<'_>) -> Markup {
69324 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();
70351
71352 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+ }
78392 (label)
393+ span .tab-count { (n) }
79394 }
80395 }
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" }
85403 }
86 input type="hidden" name="state" value=(f.state);
87 button .btn type="submit" { "Filter" }
88404 }
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+ }
93440 }
94441 }
95442 }
96443
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+ }
101454 }
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+ )) {}
137575 }
138576 }
577+ span .cl-add { "+" (add) }
578+ span .cl-del { "−" (del) }
139579 }
580+ None => span .faint { "—" },
140581 }
141582 }
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+ }
142587 }
143588 }
144589}
145590
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+
146637/// Disambiguation page for an ambiguous change-id prefix (spec §7).
147638pub fn ambiguous(ctx: &RepoContext, prefix: &str, candidates: &[(i64, String, String)]) -> Markup {
148639 let base = ctx.base();
@@ −172,18 +663,21 @@
172663 use super::*;
173664
174665 #[test]
175 fn synthetic_changes_render_without_a_change_chip() {
666+ fn synthetic_changes_render_without_a_change_id() {
176667 // Spec §4: "Do not pretend a synthetic identity is a real change ID."
177668 let synthetic = change_chip("ppwkwxvrwvxxyttp0000000000000000", true).into_string();
178669 assert!(synthetic.contains("git"));
179670 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}"
182673 );
183674
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.
184678 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}");
187681 }
188682
189683 #[test]
Mcrates/df-web/src/views/design.rs+118−25
@@ −22,9 +22,9 @@
2222];
2323
2424const 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"),
2828];
2929
3030const STATES: &[(&str, &str)] = &[
@@ −83,16 +83,94 @@
8383
8484 (section("type", "Typography", html! {
8585 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."
93136 }
94137 }))
95138
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+
96174 (section("buttons", "Buttons", html! {
97175 div .design-row {
98176 a .btn.btn-primary href="#buttons" { "Primary" }
@@ −105,9 +183,10 @@
105183 a .btn.btn-primary.btn-lg href="#buttons" { "Large primary" }
106184 a .btn.btn-lg href="#buttons" { "Large default" }
107185 }
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."
111190 }
112191 }))
113192
@@ −209,17 +288,24 @@
209288 }
210289
211290 section {
212 h2 { "Three accents, three jobs" }
291+ h2 { "Two accents, two jobs" }
213292 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."
222301 }
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+ }
223309 }
224310
225311 section {
@@ −257,9 +343,16 @@
257343 "Radius is 3px, borders are one hairline, and shadows appear only on things
258344 that genuinely float. The pages that matter — change lists, diffs, file
259345 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."
263356 }
264357 }
265358
Mcrates/df-web/src/views/edit.rs+38−38
@@ −33,56 +33,56 @@
3333 let cancel = format!("{base}/blob/{}/{}", e.bookmark, e.path);
3434
3535 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+ }
4242
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+ }
4646
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);
5252
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) }
6258 }
59+ span .spacer {}
60+ span .editor-status.mono { "editing on " (e.bookmark) }
6361 }
6462
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" }
6775 input type="text" id="message" name="message" maxlength="500"
6876 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 {
7777 button .btn.btn-primary type="submit" { "Commit changes" }
78 a .btn href=(cancel) { "Cancel" }
7978 }
8079 }
80+ }
8181
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."
8686 }
8787 }
8888}
Mcrates/df-web/src/views/issue.rs+138−80
@@ −52,66 +52,88 @@
5252 pub all_labels: &'a [Label],
5353}
5454
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.
5562pub fn list(ctx: &RepoContext, rows: &[IssueRow], f: ListFilters<'_>) -> Markup {
5663 let base = ctx.base();
64+ let now = Utc::now();
65+
5766 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 {
6078 @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")] {
6382 (label)
6483 }
6584 }
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) }
7892 }
79
80 a .btn.btn-primary href=(format!("{base}/issues/new")) { "New issue" }
8193 }
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+ }
8298
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)
97124 }
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)) }
102125 }
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 }
114126 }
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+ }
115137 }
116138 }
117139 }
@@ −182,45 +204,35 @@
182204
183205pub fn detail(ctx: &RepoContext, d: Detail<'_>) -> Markup {
184206 let base = format!("{}/issues/{}", ctx.base(), d.number);
207+ let closed = d.state == "closed";
185208
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" }
202211
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" }
205216 }
217+ span .faint.mono { "#" (d.number) }
218+ }
219+ h1 .measure { (d.title) }
206220
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)) }
210223 }
211224
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)
223234 }
235+ @if kind == "change" && !closed { " — closes on merge" }
224236 }
225237 }
226238 }
@@ −300,6 +312,52 @@
300312 }
301313 }
302314 }
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+ }
303361 }
304362}
305363
@@ −307,7 +365,7 @@
307365 html! {
308366 div .comment {
309367 div .row {
310 strong { (c.author) }
368+ strong { (crate::views::user_link(&c.author)) }
311369 span .faint { (c.created_at.format("%Y-%m-%d %H:%M").to_string()) }
312370 @if c.edited { span .faint { "edited" } }
313371 }
Mcrates/df-web/src/views/layout.rs+221−59
@@ −19,27 +19,44 @@
1919 pub nonce: &'a str,
2020}
2121
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.
2423///
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.
2927fn brand(href: &str) -> Markup {
3028 html! {
3129 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" }
3732 }
3833 }
3934}
4035
4136/// Wrap page content in the site chrome.
4237pub 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 {
4360 html! {
4461 (DOCTYPE)
4562 html lang="en" {
@@ −49,83 +66,110 @@
4966 title { (chrome.title) " · Dogfood" }
5067 link rel="stylesheet" href="/assets/app.css";
5168 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";
5471 // Blocking (no `defer`) and first, so a stored "light"
5572 // preference lands before the default dark theme paints.
5673 script src="/assets/theme-init.js" nonce=(chrome.nonce) {}
5774 }
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)) {
6176 // The first focusable thing on the page, visible only when
6277 // focused. Without it a keyboard user tabs through the whole
6378 // masthead on every page before reaching the content.
6479 a .skip-link href="#main" { "Skip to content" }
6580
6681 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" {}
7082 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" }
77104 }
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" }
89115 }
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" }
103140 }
104 } @else {
105 a .btn href="/login" { "Sign in" }
106 a .btn.btn-primary href="/login" { "Start free" }
107141 }
108142 }
109143 }
110144 }
111145
146+ @if let Some(bar) = bar {
147+ (bar)
148+ }
149+
112150 // `tabindex="-1"` so the skip link can move focus here, not
113151 // just scroll to it — without it the next Tab press would go
114152 // 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 {
117157 (content)
118158 }
119159 }
120160
161+ (palette())
162+
121163 footer {
122164 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 {}
125168 a href="https://github.com/jj-vcs/jj" { "jj" }
126169 // A component sheet nobody can find does not get kept
127170 // 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" }
129173 span .footer-end .mono { "© 2026" }
130174 }
131175 }
@@ −134,11 +178,130 @@
134178 // every link and form still works as plain HTML.
135179 script src="/assets/htmx.min.js" nonce=(chrome.nonce) defer {}
136180 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) "”" }
137275 }
138276 }
139277 }
140278}
141279
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+
142305/// A minimal error page rendered without request context.
143306///
144307/// Used by the error type, which runs after extractors and so cannot rely on
@@ −153,10 +316,9 @@
153316 title { (status.as_u16()) " · Dogfood" }
154317 link rel="stylesheet" href="/assets/app.css";
155318 }
156 body .grid-field {
319+ body {
157320 a .skip-link href="#main" { "Skip to content" }
158321 header .masthead {
159 span .masthead-rule aria-hidden="true" {}
160322 div .masthead-inner {
161323 (brand("/"))
162324 }
Mcrates/df-web/src/views/mod.rs+29−1
@@ −13,9 +13,37 @@
1313pub mod review;
1414pub mod settings;
1515
16pub use layout::{error_page, page, Chrome};
16+pub use layout::{error_page, page, page_full, page_with_bar, Chrome};
1717
1818use 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+}
1947
2048/// A short, coarse "how long ago" label.
2149///
Mcrates/df-web/src/views/pages.rs708 lines+375−166
@@ −7,9 +7,12 @@
77
88use crate::views::change::change_chip;
99use crate::views::relative_time;
10use crate::views::repo::avatar;
1110
1211/// 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.
1316pub struct FeedItem {
1417 pub owner: String,
1518 pub repo: String,
@@ −17,14 +20,54 @@
1720 pub change_id: String,
1821 pub synthetic: bool,
1922 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>,
2326 /// 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,
2542 pub when: DateTime<Utc>,
2643}
2744
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+
2871/// Render whoever is responsible for something.
2972///
3073/// Three cases, in descending order of what we actually know:
@@ −38,11 +81,7 @@
3881/// readable history and an anonymous one.
3982pub fn actor(handle: Option<&str>, name: Option<&str>) -> Markup {
4083 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)) }
4685 }
4786}
4887
@@ −63,120 +102,161 @@
63102 "no staging area",
64103];
65104
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+
66120/// Landing page for signed-out visitors.
67pub fn landing(clone_hint: &str, feed: &[FeedItem], repos: &[RepoSummary]) -> Markup {
121+pub fn landing(l: Landing<'_>) -> Markup {
68122 let now = Utc::now();
123+ let first_repo = l.repos.first().map(|r| format!("/{}/{}", r.owner, r.name));
69124
70125 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" }
92140 }
93141 }
142+ div .clone-box {
143+ span .prompt aria-hidden="true" { "$" }
144+ code { (l.clone_hint) }
145+ }
94146 }
95 div .clone-box {
96 code { (clone_hint) }
97 }
147+ (terminal(l.sample_repo))
98148 }
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)
108159 }
109160 }
110161 }
111162
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+ }
119188 }
120189 }
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+ }
128197 }
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)) }
133202 }
134203 }
135204 }
136205 }
206+ } }
137207
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)
152224 }
153225 }
154226 }
155227 }
228+ }
229+ }
156230
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."
164238 }
165239 }
240+ div .repo-cards {
241+ @for r in l.repos { (repo_card(r, now)) }
242+ }
166243 }
167244 }
168245 }
169246
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" }
180260 }
181261 }
182262 }
@@ −184,51 +264,146 @@
184264 }
185265}
186266
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+
187314/// One feed row.
188315///
189316/// The exact timestamp goes in `title` because the visible label is coarse on
190317/// purpose — see `relative_time`.
191318fn feed_row(item: &FeedItem, now: DateTime<Utc>) -> Markup {
192319 let href = format!("/{}/{}/changes/{}", item.owner, item.repo, item.number);
320+ let (glyph, class) = feed_glyph(&item.kind);
193321
194322 html! {
195323 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)
210328 }
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))
213332 }
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+ }
214360 }
215361 }
216362}
217363
218fn repo_card(r: &RepoSummary) -> Markup {
364+/// A bookmark and when it last moved.
365+///
366+/// Links to its repository's bookmark list rather than to the bookmark itself:
367+/// a bookmark has no page of its own, because it is a pointer, not a thing —
368+/// which is the distinction the whole product turns on.
369+fn bookmark_line(b: &BookmarkItem, now: DateTime<Utc>) -> Markup {
219370 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! {
220386 a .repo-card href=(format!("/{}/{}", r.owner, r.name)) {
221 span .repo-card-rail aria-hidden="true" {}
222387 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" } }
228390 }
229391 @if let Some(d) = &r.description {
230392 span .repo-card-desc { (d) }
231393 }
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+ }
232407 }
233408 }
234409}
@@ −272,17 +447,23 @@
272447}
273448
274449/// 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.
275455pub fn dashboard(d: Dashboard<'_>) -> Markup {
276456 let now = Utc::now();
277457
278458 html! {
279459 div .dash-head {
280460 div {
281 h1 .display.dash-title {
461+ h1 .dash-title {
282462 "Welcome back, " span .dash-name { (d.user.label()) }
283463 }
284 p .dim { "Here's what needs you across your repositories." }
464+ p .dim.section-note { "Here's what needs you across your repositories." }
285465 }
466+ span .spacer {}
286467 a .btn.btn-primary href="/new" { "New repository" }
287468 }
288469
@@ −293,12 +474,12 @@
293474 p { a .btn.btn-primary href="/new" { "New repository" } }
294475 }
295476 } @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" {
299480 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()) }
302483 }
303484 @if d.awaiting.is_empty() {
304485 p .dim.section-note { "Nothing is waiting on you." }
@@ −309,9 +490,9 @@
309490 }
310491 }
311492
312 section aria-labelledby="mine-h" {
493+ section .dash-section aria-labelledby="mine-h" {
313494 div .section-head {
314 h2 #mine-h .label-condensed.slash { "your open changes" }
495+ h2 #mine-h .label-condensed { "Your open changes" }
315496 }
316497 @if d.mine.is_empty() {
317498 p .dim.section-note { "You have no open changes." }
@@ −322,9 +503,9 @@
322503 }
323504 }
324505
325 section aria-labelledby="activity-h" {
506+ section .dash-section aria-labelledby="activity-h" {
326507 div .section-head {
327 h2 #activity-h .label-condensed.slash { "watched activity" }
508+ h2 #activity-h .label-condensed { "Watched activity" }
328509 }
329510 @if d.activity.is_empty() {
330511 p .dim.section-note { "No recent activity in your repositories." }
@@ −336,11 +517,13 @@
336517 }
337518 }
338519
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+ }
344527 }
345528 }
346529 }
@@ −348,30 +531,37 @@
348531 }
349532}
350533
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.
351540fn dash_change_row(c: &DashChange, now: DateTime<Utc>) -> Markup {
352541 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+ };
353549
354550 html! {
355551 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)
370556 }
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()))
374559 }
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+ }
375565 }
376566 }
377567}
@@ −427,6 +617,10 @@
427617 pub name: String,
428618 pub description: Option<String>,
429619 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>>,
430624}
431625
432626/// The sign-in page.
@@ −436,34 +630,49 @@
436630/// and a disabled control that says why is more honest than a live-looking
437631/// button that does nothing. They carry `aria-disabled` and no `href`, so they
438632/// are not in the tab order as if they were usable.
439pub fn signin(sso_href: &str) -> Markup {
633+pub fn signin(sso_href: &str, clone_hint: &str, sample_repo: Option<&str>) -> Markup {
440634 html! {
441635 div .signin {
442636 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."
448641 }
449642 }
450643
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+ }
452649
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+ }
457666 }
458667
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) }
462672 }
463673
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 →" }
467676 }
468677 }
469678 }
Mcrates/df-web/src/views/repo.rs683 lines+477−86
@@ −2,50 +2,119 @@
22
33use maud::{html, Markup, PreEscaped};
44
5use df_store::{Bookmark, EntryKind, Revision, TreeEntry};
5+use df_store::{EntryKind, Revision, TreeEntry};
66
77use crate::repo_ctx::RepoContext;
88
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.
1019pub fn header(ctx: &RepoContext, active: &str) -> Markup {
1120 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)),
1731 ];
1832
1933 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) }
2940 }
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+ }
3953 }
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+ }
4360 }
61+
62+ span .spacer {}
63+ span .subnav-meta { (nav_meta(nav)) }
4464 }
4565 }
4666 }
4767}
4868
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+
49118/// Clone instructions, shown on an empty repository.
50119pub fn empty_repo(https: &str, ssh: &str, default_bookmark: &str) -> Markup {
51120 html! {
@@ −108,7 +177,10 @@
108177/// This is the *directory's* tip, not a per-file blame — see the note on
109178/// `tree_listing`.
110179pub struct TipCommit {
180+ /// The name the commit carries.
111181 pub author: String,
182+ /// The account that name resolved to, when its email matched one.
183+ pub author_handle: Option<String>,
112184 pub summary: String,
113185 pub when: chrono::DateTime<chrono::Utc>,
114186 pub change_id: Option<String>,
@@ −144,56 +216,128 @@
144216 }
145217}
146218
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+
147284/// The directory listing.
148285///
149/// The design puts a per-file "last change that touched this path" column here.
150/// Dogfood cannot fill it: the index records changes and revisions but never
151/// the paths a change touched, so there is nothing to join against. The column
152/// is therefore left out rather than filled with a plausible-looking value —
153/// showing the directory tip's message on every row would read as per-file
154/// history and be wrong on all but one of them.
155pub fn tree_listing(
156 ctx: &RepoContext,
157 rev_label: &str,
158 path: &str,
159 entries: &[TreeEntry],
160 tip: Option<&TipCommit>,
161 readme: Option<&(String, Markup)>,
162) -> Markup {
286+/// One history column, not two: the commit message sits next to the filename
287+/// the way it does on GitHub, and takes the place a byte-size column used to
288+/// have, in favour of when the file was last touched — which is what a reader
289+/// scanning a repo for the first time actually wants to know. Backed by
290+/// `last_commits_in_dir`'s single bounded history walk, so an entry the walk
291+/// did not reach in 500 commits simply has no history cell rather than a wrong
292+/// or misleading one.
293+pub fn tree_listing(ctx: &RepoContext, t: Tree<'_>) -> Markup {
294+ let Tree { rev_label, path, entries, tip, readme, history, sidebar } = t;
163295 let base = ctx.base();
296+ let now = chrono::Utc::now();
164297
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(&[])))
169301 (breadcrumbs(&base, rev_label, path))
170302 }
171303
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))
180323 }
181324 }
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 }
186325 }
187 }
188326
189 @if entries.is_empty() {
190 div .filelist.filelist-standalone {
327+ @if entries.is_empty() {
191328 p .dim style="padding:16px" { "This directory is empty." }
192 }
193 } @else {
194 div .filelist .filelist-standalone[tip.is_none()] {
329+ } @else {
195330 table {
196331 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+ }
197341 tbody {
198342 @if !path.is_empty() {
199343 tr {
@@ −202,7 +346,7 @@
202346 (dir_icon())
203347 }
204348 }
205 td .filelist-name colspan="2" {
349+ td .filelist-name colspan="4" {
206350 a .mono href=(parent_link(&base, rev_label, path)) { ".." }
207351 }
208352 }
@@ −223,8 +367,41 @@
223367 span .faint .filelist-note { "symlink" }
224368 }
225369 }
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+ }
228405 }
229406 }
230407 }
@@ −234,17 +411,164 @@
234411 }
235412
236413 @if let Some((name, body)) = readme {
237 article .readme {
414+ article .filelist.readme {
238415 div .readme-head {
239 (file_icon())
240 span .mono.dim { (name) }
416+ span .mono { (name) }
241417 }
242418 div .readme-body.markdown-body { (body) }
243419 }
244420 }
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+ },
245431 }
246432}
247433
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+
248572/// How a markdown file is being shown.
249573///
250574/// `None` means the file is not markdown, so no toggle appears at all.
@@ −265,6 +589,8 @@
265589 pub symbols: &'a [df_render::symbols::Symbol],
266590 /// The last commit that modified this file.
267591 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>,
268594 /// Per-line blame data (when the user toggled blame on).
269595 pub blame: Option<&'a [df_store::BlameLine]>,
270596 /// Whether blame was requested.
@@ −278,6 +604,7 @@
278604 sidebar_dir: "",
279605 symbols: &[],
280606 last_commit: None,
607+ last_commit_handle: None,
281608 blame: None,
282609 wants_blame: false,
283610 }
@@ −313,8 +640,10 @@
313640 // Last commit bar for this file.
314641 @if let Some(lc) = extras.last_commit {
315642 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+ }
318647 span .commit-bar-msg { (lc.summary()) }
319648 @if let Some(c) = &lc.change_id {
320649 span .chip.chip-change title="change id" {
@@ −500,7 +829,15 @@
500829}
501830
502831/// Commit log.
503pub fn log_view(ctx: &RepoContext, rev_label: &str, revisions: &[Revision]) -> Markup {
832+/// The commit log. `handles` maps commit-author email to a Dogfood handle for
833+/// the authors that have accounts; everyone else renders as the name the commit
834+/// carries.
835+pub fn log_view(
836+ ctx: &RepoContext,
837+ rev_label: &str,
838+ revisions: &[Revision],
839+ handles: &std::collections::HashMap<String, String>,
840+) -> Markup {
504841 let base = ctx.base();
505842 html! {
506843 div .panel {
@@ −530,7 +867,12 @@
530867 } @else {
531868 span .chip title="authored with plain git" { "git" }
532869 }
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+ }
534876 span .faint { (r.author.when.format("%Y-%m-%d %H:%M").to_string()) }
535877 }
536878 }
@@ −542,27 +884,76 @@
542884}
543885
544886/// Bookmark list.
545pub fn bookmarks_view(ctx: &RepoContext, marks: &[Bookmark]) -> Markup {
887+/// The bookmarks page.
888+///
889+/// Four columns, and the second one is the argument: a bookmark *points at* a
890+/// change. The name in column one can move to any other row tomorrow; the id in
891+/// column two is what the review, the approvals and the permalinks are attached
892+/// to. The page exists to make that asymmetry visible.
893+pub fn bookmarks_view(ctx: &RepoContext, marks: &[MarkRow]) -> Markup {
546894 let base = ctx.base();
895+ let now = chrono::Utc::now();
896+
547897 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+ }
552904 }
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+ }
557924 tbody {
558925 @for m in marks {
559926 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) }
562929 @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" }
564934 }
565935 }
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+ }
566957 }
567958 }
568959 }
Mcrates/df-web/src/views/review.rs762 lines+534−118
@@ −33,47 +33,103 @@
3333 /// The name the commit itself carries, used when no account matched.
3434 pub author_name: Option<&'a str>,
3535 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,
3642 /// Whether the viewer may edit the change (author or maintainer).
3743 pub can_manage: bool,
3844 pub can_comment: bool,
3945 pub csrf: &'a str,
4046}
4147
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.
4371pub fn header(ctx: &RepoContext, c: &ChangeHead<'_>, tab: &str) -> Markup {
4472 let base = format!("{}/changes/{}", ctx.base(), c.number);
4573
4674 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+ }
66105 }
67106 }
68107 }
69108
70 nav .subtabs style="margin-top:14px;margin-bottom:0" aria-label="Change sections" {
109+ nav .subtabs.ruled aria-label="Change sections" {
71110 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+ }
73117 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+ }
75124 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" }
77133 @if c.conflicted {
78134 a href=(format!("{base}/conflicts")) .active[tab == "conflicts"]
79135 aria-current=[(tab == "conflicts").then_some("page")] { "Conflicts" }
@@ −84,6 +140,91 @@
84140 }
85141}
86142
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+
87228// ─── overview ────────────────────────────────────────────────────────────────
88229
89230pub struct CommentRow {
@@ −185,7 +326,7 @@
185326 @for r in o.reviews {
186327 div .row {
187328 (verdict_badge(&r.verdict))
188 strong { (r.reviewer) }
329+ strong { (crate::views::user_link(&r.reviewer)) }
189330 span .faint { (r.created_at.format("%Y-%m-%d").to_string()) }
190331 span .faint .mono { (r.rev) }
191332 @if !r.is_head {
@@ −352,9 +493,23 @@
352493 @match item {
353494 Item::Comment(cm) => (comment(cm, c, base, false)),
354495 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+ }
358513 span .faint style="margin-left:auto" {
359514 (e.created_at.format("%Y-%m-%d %H:%M").to_string())
360515 }
@@ −370,7 +525,7 @@
370525 html! {
371526 div .comment id=(format!("comment-{}", cm.id)) {
372527 div .row {
373 strong { (cm.author) }
528+ strong { (crate::views::user_link(&cm.author)) }
374529 span .faint { (cm.created_at.format("%Y-%m-%d %H:%M").to_string()) }
375530 @if cm.edited { span .faint { "edited" } }
376531 @if cm.anchor_state == "outdated" {
@@ −428,46 +583,98 @@
428583/// Mirrors the grouping the design uses for its timeline markers: conflict
429584/// states get the conflict colour, anything that lands a change gets the
430585/// merged/open colours, and routine events (pushes, rebases) stay neutral.
431fn event_dot_class(kind: &str) -> &'static str {
586+/// The glyph and colour for a timeline event.
587+///
588+/// The same vocabulary the change list and the public feed use — `↑` pushed,
589+/// `✓` reviewed, `◆` conflicted, `⤳` merged — so a reader learns four marks
590+/// once and reads them everywhere.
591+fn event_mark(kind: &str) -> (&'static str, &'static str) {
432592 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)"),
439601 }
440602}
441603
442fn event_text(e: &EventRow) -> String {
443 let who = e.actor.as_deref().unwrap_or("someone");
604+/// What an event says, with the actor split out so it can be a link.
605+enum EventSentence<'a> {
606+ /// Somebody did something: "<actor> merged this into main". `actor` is
607+ /// `None` when the event records no account — an older row, or a push by
608+ /// somebody with no Dogfood account — and reads "someone".
609+ By {
610+ actor: Option<&'a str>,
611+ /// The rest of the sentence. Never contains a name.
612+ predicate: String,
613+ /// Whether to put a space before the predicate. False for the
614+ /// unknown-event form, which is punctuation ("alice: some.new.kind").
615+ spaced: bool,
616+ },
617+ /// A sentence with no subject at all: "the conflict was resolved". These
618+ /// describe what became true, not who made it so, and must not acquire a
619+ /// "someone" that implies an unknown person acted.
620+ Impersonal(String),
621+}
622+
623+fn event_parts(e: &EventRow) -> EventSentence<'_> {
624+ let actor = e.actor.as_deref();
444625 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 };
445627
446628 match e.kind.as_str() {
447 "change.opened" => format!("{who} opened this change"),
629+ "change.opened" => by("opened this change".into()),
448630 "change.pushed" => {
449631 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()
452634 } else {
453 format!("{who} pushed revision {}", df_store::abbreviate_rev(rev))
454 }
635+ format!("pushed revision {}", df_store::abbreviate_rev(rev))
636+ })
455637 }
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()),
465647 "comments.rebased" => {
466648 let n_out = e.payload.get("outdated").and_then(|v| v.as_i64()).unwrap_or(0);
467649 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+ }
469677 }
470 other => format!("{who}: {other}"),
471678 }
472679}
473680
@@ −650,60 +857,212 @@
650857 pub pushed_at: DateTime<Utc>,
651858 pub conflicted: bool,
652859 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>,
653872}
654873
655pub fn revisions(ctx: &RepoContext, c: &ChangeHead<'_>, revs: &[RevisionDetail]) -> Markup {
874+/// The revisions timeline.
875+///
876+/// The heart of the product. Every rewrite of a change appends a revision here,
877+/// and picking any two produces the *interdiff* — what a reviewer has not seen
878+/// yet. On a branch-based forge this view cannot exist: a force-push destroys
879+/// the thing it would compare against.
880+///
881+/// A and B are chosen by link, not by script. Two query parameters, two sets of
882+/// radio-styled links, and the server does the diff — so this works with
883+/// scripting off and every comparison is a URL somebody can paste into a review.
884+pub fn revisions(
885+ ctx: &RepoContext,
886+ c: &ChangeHead<'_>,
887+ revs: &[RevisionDetail],
888+ cmp: Compare<'_>,
889+) -> Markup {
656890 let base = format!("{}/changes/{}", ctx.base(), c.number);
891+ let pick = |a: i32, b: i32| format!("{base}/revisions?a={a}&b={b}");
892+
657893 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."
664898 }
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+ }
665919
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"
675928 }
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) }
678948 }
679949 }
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) }
6861006 }
6871007 }
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+ }
6951025 }
6961026 }
6971027 }
6981028 }
1029+ @if d.truncated {
1030+ p .hint style="padding:12px" {
1031+ "This interdiff is too large to render in full."
1032+ }
1033+ }
6991034 }
7001035 }
7011036 }
7021037 }
7031038}
7041039
705fn p_or_empty(s: &Option<String>) -> &str {
706 s.as_deref().unwrap_or("")
1040+// ─── checks ──────────────────────────────────────────────────────────────────
1041+
1042+/// The Checks tab.
1043+///
1044+/// Dogfood has no CI integration: there is no checks table, no worker that
1045+/// records results, and nothing that receives them from outside. The tab exists
1046+/// because the design places it in the strip, and it says so plainly rather
1047+/// than showing invented rows — a fabricated "cargo test ✓ 412 passed" on a
1048+/// review page is the single most dangerous kind of placeholder, because a
1049+/// reviewer would act on it.
1050+pub fn checks(_ctx: &RepoContext, _c: &ChangeHead<'_>) -> Markup {
1051+ html! {
1052+ div .empty {
1053+ h2 { "No checks are wired up" }
1054+ p .measure {
1055+ "This instance has no CI integration, so nothing reports check results \
1056+ against a revision. Nothing is hidden here — there is genuinely no \
1057+ data behind this tab yet."
1058+ }
1059+ p .hint.measure {
1060+ "When there is, checks will run per revision rather than per branch: a \
1061+ conflicted revision still runs, because a conflict is a state, not a \
1062+ failure."
1063+ }
1064+ }
1065+ }
7071066}
7081067
7091068// ─── conflicts (M4) ──────────────────────────────────────────────────────────
@@ −763,46 +1122,103 @@
7631122 pub depth: usize,
7641123}
7651124
766pub fn stack(ctx: &RepoContext, nodes: &[StackNode], change_id: &str) -> Markup {
1125+/// The stack page.
1126+///
1127+/// One rebase moves every change in the chain, and every id survives it — so
1128+/// every review, approval and permalink in the stack stays attached to the work
1129+/// it was about. That sentence is the page; the rows are the evidence.
1130+pub fn stack(
1131+ ctx: &RepoContext,
1132+ nodes: &[StackNode],
1133+ change_id: &str,
1134+ target: Option<&str>,
1135+ csrf: &str,
1136+ can_merge: bool,
1137+) -> Markup {
7671138 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+
7681149 html! {
769 div .panel {
1150+ div .page-head {
7701151 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+ }
7751157 }
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+ }
7821165 }
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" }
7981202 }
1203+ span .stackrow-meta { "#" (n.number) }
7991204 }
8001205 }
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+ }
8011218
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."
8061222 }
8071223 }
8081224 }
Acrates/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+})();
Acrates/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+})();
Adocs/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`