rxkspqmsoknzmerged#4
feat: given a new redesign and emulated terminal homepage
Every version this change has been
Pick any two revisions; the interdiff is what a reviewer has not seen yet.rev 3feat: given a new redesign and emulated terminal homepageAug 2 19:54
rev 2feat: given a new redesign and emulated terminal homepageAug 2 19:52
rev 1feat: given a new redesign and emulated terminal homepageAug 2 18:45
Interdiff rev 2 → rev 1what the reviewer has not seen yet28 files · +1484 −7119
crates/df-store/src/lib.rs+0 −40
@@ -15,7 +15,6 @@
15//!
16//! No `gix` type appears anywhere in this module's public API.
17
18−use std::collections::HashMap;
18use std::path::Path;
19
20use async_trait::async_trait;
@@ -431,21 +430,6 @@
430 /// is a storage detail; nothing above this trait should know such a thing
431 /// exists.
432 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−
433 async fn log(&self, id: RepoId, from: &RevId, limit: usize) -> Result<Vec<Revision>>;
434 async fn revision(&self, id: RepoId, rev: &RevId) -> Result<Revision>;
435 async fn bookmarks(&self, id: RepoId) -> Result<Vec<Bookmark>>;
@@ -516,30 +500,6 @@
500 rev: &RevId,
501 path: &Path,
502 ) -> Result<Option<Revision>>;
519−
520− /// The most recent commit that touched each direct child of `dir`, in one
521− /// history walk.
522− ///
523− /// This is [`last_commit_for_path`](Self::last_commit_for_path) generalised
524− /// to a whole directory listing: rather than one history walk per entry —
525− /// which is what a naive per-file implementation of this would cost, and
526− /// what made a directory-listing "last commit" column too expensive to
527− /// ship the first time around — this walks history once and, at each
528− /// commit, checks its changed paths against every entry that has not yet
529− /// been resolved. An entry resolves the first time a changed path falls
530− /// under it; the walk stops early once every entry has resolved, or after
531− /// the same 500-commit bound the single-file lookup uses.
532− ///
533− /// `entries` are names relative to `dir` (not full paths). An entry
534− /// missing from the returned map was not modified within the walk bound —
535− /// callers render that as "no info" rather than treating it as an error.
536− async fn last_commits_in_dir(
537− &self,
538− id: RepoId,
539− rev: &RevId,
540− dir: &Path,
541− entries: &[String],
542− ) -> Result<HashMap<String, Revision>>;
503
504 /// Total on-disk size, for the repo settings page and quota reporting.
505 async fn size_bytes(&self, id: RepoId) -> Result<u64>;
crates/df-store/tests/git_store.rs+0 −46
@@ -125,52 +125,6 @@
125 assert_eq!(blob.size, blob.content.len() as u64);
126}
127
128−/// `basic` is two commits, each adding one file — "add a" then "add b" — so a
129−/// correct per-entry walk must resolve them to *different* commits rather
130−/// than both landing on the tip, which is the failure mode a naive
131−/// "just report the directory's last commit for everything" implementation
132−/// would have.
133−#[tokio::test]
134−async fn last_commits_in_dir_resolves_each_entry_to_the_commit_that_touched_it() {
135− let Some(f) = load("basic") else { return };
136− let rev = head(&f).await;
137−
138− let entries = f.store.list_tree(f.id, &rev, Path::new("")).await.unwrap();
139− let names: Vec<String> = entries.iter().map(|e| e.name.clone()).collect();
140− assert!(names.contains(&"a.txt".to_string()));
141− assert!(names.contains(&"b.txt".to_string()));
142−
143− let history = f
144− .store
145− .last_commits_in_dir(f.id, &rev, Path::new(""), &names)
146− .await
147− .expect("last_commits_in_dir");
148−
149− let a = history.get("a.txt").expect("a.txt has history");
150− let b = history.get("b.txt").expect("b.txt has history");
151−
152− assert_eq!(a.summary(), "add a");
153− assert_eq!(b.summary(), "add b");
154− assert_ne!(
155− a.rev, b.rev,
156− "each file must resolve to the commit that actually touched it, not both to the tip"
157− );
158−}
159−
160−#[tokio::test]
161−async fn last_commits_in_dir_reports_nothing_for_an_unknown_entry() {
162− let Some(f) = load("basic") else { return };
163− let rev = head(&f).await;
164−
165− let history = f
166− .store
167− .last_commits_in_dir(f.id, &rev, Path::new(""), &["nonexistent.txt".to_string()])
168− .await
169− .expect("last_commits_in_dir");
170−
171− assert!(history.is_empty());
172−}
173−
128#[tokio::test]
129async fn directories_sort_before_files() {
130 let Some(f) = load("hostile") else { return };
crates/df-web/assets/app.css+679 −2965
@@ -1,78 +1,74 @@
1/*
2 * Dogfood design tokens and base styles.
3 *
4− * Ported from the Claude Design project "Dogfood" (Dogfood.dc.html), so the
5− * Maud UI matches the design that was signed off.
6− *
7− * 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.
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.
6 *
15− * Using identity for a button, or action for a change id, breaks the only
16− * colour rule the interface has.
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.
11 *
12 * NOTE — deviation from spec §1, recorded deliberately: the spec chose Tailwind.
13 * This is hand-authored CSS instead, because Tailwind would put a Node
14 * toolchain in the release image for what is currently a small, stable set of
21− * styles. The tokens below are the same ones the design defines, so moving
15+ * styles. The tokens below are the same ones the prototype defines, so moving
16 * to Tailwind later is a mechanical change rather than a redesign.
17 */
18
19:root {
20 color-scheme: dark;
21
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;
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;
31+
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
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);
40+ /* accents */
41+ --identity: #e6a92e;
42+ --action: #4fa3c7;
43
44 /* state */
49− --open: #63a06b;
50− --merged: #8b7fd4;
51− --abandoned: #767d88;
52− --conflict: #c77bc4;
53− --danger: #d06b6b;
45+ --open: #5aa86a;
46+ --merged: #5b86bd;
47+ --abandoned: #7c7568;
48+ --conflict: #db5a86;
49+ --danger: #e0603f;
50
51 /* diff */
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;
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;
58
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;
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;
72
73 /* shape */
74 --radius: 3px;
@@ -88,64 +84,63 @@
84 --font-condensed: "IBM Plex Sans Condensed", ui-sans-serif,
85 "Roboto Condensed", "Arial Narrow", system-ui, sans-serif;
86
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. */
87+ /* type scale, base 14 */
88 --text-xs: 11px;
89 --text-sm: 12.5px;
90 --text-base: 14px;
91 --text-md: 16px;
92 --text-lg: 20px;
99− --text-xl: 24px;
100− --text-2xl: 28px;
101− --text-3xl: 44px;
93+ --text-xl: 28px;
94+ --text-2xl: 40px;
95+ --text-3xl: 60px;
96}
97
98:root.light,
99:root[data-theme="light"] {
100 color-scheme: light;
101
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;
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;
110
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);
111+ --brand: #e23c11;
112+ --brand-ink: #fffdf9;
113+ --grid-line: rgb(0 0 0 / 0.025);
114+ --glow: rgb(226 60 17 / 0.1);
115
124− --open: #2e7d3a;
125− --merged: #5b4fb0;
126− --abandoned: #6b7280;
127− --conflict: #9b3e96;
128− --danger: #b03434;
116+ --identity: #a06f0e;
117+ --action: #226b83;
118+
119+ --open: #2d7d3c;
120+ --merged: #3f5f9e;
121+ --abandoned: #6b6355;
122+ --conflict: #b23566;
123+ --danger: #b23c22;
124
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;
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
137− --hl-comment: #6f7580;
138− --hl-keyword: #7e2b79;
139− --hl-string: #2f6b34;
140− --hl-number: #8a5e10;
141− --hl-function: #20707f;
132+ --hl-comment: #837c6e;
133+ --hl-keyword: #a03060;
134+ --hl-string: #376b2c;
135+ --hl-number: #8a6510;
136+ --hl-function: #226b83;
137 --hl-type: #855c14;
143− --hl-constant: #8a5e10;
144− --hl-variable: #1a1c20;
145− --hl-operator: #5c616b;
146− --hl-tag: #7e2b79;
147− --hl-attribute: #20707f;
148− --hl-punctuation: #8a8f98;
138+ --hl-constant: #8a6510;
139+ --hl-variable: #1c1813;
140+ --hl-operator: #5f594e;
141+ --hl-tag: #a03060;
142+ --hl-attribute: #226b83;
143+ --hl-punctuation: #8b8477;
144}
145
146/* ─── fonts ──────────────────────────────────────────────────────────────── */
@@ -243,7 +238,7 @@
238}
239
240::selection {
246− background: color-mix(in srgb, var(--action) 32%, transparent);
241+ background: color-mix(in srgb, var(--brand) 32%, transparent);
242 color: var(--text);
243}
244
@@ -280,371 +275,93 @@
275
276/* ─── layout ─────────────────────────────────────────────────────────────── */
277
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. */
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. */
281.masthead {
282 position: sticky;
283 top: 0;
284 z-index: 30;
285 border-bottom: 1px solid var(--border);
303− background: var(--surface);
286+ background: color-mix(in srgb, var(--bg) 85%, transparent);
287+ backdrop-filter: blur(6px);
288+}
289+
290+.masthead-rule {
291+ display: block;
292+ width: 100%;
293+ height: 2px;
294+ background: var(--brand);
295}
296
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;
297+.masthead-inner,
298+.wrap {
299+ max-width: 1180px;
300+ margin: 0 auto;
301+ padding: 0 20px;
302}
303
318−.masthead-group {
304+.masthead-inner {
305 display: flex;
306 align-items: center;
321− gap: 14px;
322− min-width: 0;
323−}
324−
325−.masthead-group-start {
326− justify-self: start;
327−}
328−
329−.masthead-group-end {
330− justify-self: end;
307+ gap: 16px;
308+ height: 48px;
309}
310
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. */
311.brand {
337− display: inline-flex;
312+ display: flex;
313 align-items: center;
314 gap: 8px;
315 flex-shrink: 0;
316 color: var(--text);
342− font-family: var(--font-mono);
343− font-size: var(--text-base);
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;
322}
323
324.brand:hover {
325 text-decoration: none;
326+ color: var(--brand);
327}
328
329.brand-mark {
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;
330+ color: var(--brand);
331+ flex-shrink: 0;
332}
333
334.masthead nav {
335 display: flex;
377− gap: 8px;
336+ gap: 14px;
337+ margin-left: auto;
338 align-items: center;
339}
340
341.masthead nav a {
342 color: var(--text-dim);
383− white-space: nowrap;
343}
344
345.masthead nav a:hover {
346 color: var(--text);
347 text-decoration: none;
389−}
390−
391−/* The "Jump to…" control. A link, not a button: with scripting off it
392− navigates to /search, which is the same destination the palette reaches by
393− another route. The keycap is decorative in that case, so it is hidden from
394− assistive technology and revealed as meaningful only by palette.js. */
395−.jump {
396− display: inline-flex;
397− align-items: center;
398− gap: 8px;
399− height: 24px;
400− padding: 0 6px 0 8px;
401− border: 1px solid var(--border-strong);
402− border-radius: var(--radius);
403− background: var(--bg);
404− color: var(--text-faint);
405− font-size: var(--text-sm);
406− cursor: pointer;
407−}
408−
409−.jump:hover {
410− border-color: var(--action);
411− color: var(--action);
412− text-decoration: none;
413−}
414−
415−/* Condensed uppercase, because it is a state readout ("DARK") rather than a
416− verb — pressing it is what changes the state. */
417−.theme-toggle {
418− height: 24px;
419− padding: 0 8px;
420− border: 1px solid var(--border-strong);
421− border-radius: var(--radius);
422− background: transparent;
423− color: var(--text-dim);
424− font-family: var(--font-condensed);
425− font-size: var(--text-xs);
426− font-weight: 500;
427− letter-spacing: 0.06em;
428− text-transform: uppercase;
429− cursor: pointer;
430−}
431−
432−/* ─── repository sub-bar ─────────────────────────────────────────────────── */
433−
434−/* The second row of chrome, present on every page inside a repository: where
435− you are, whether it is public, the four sections, and the repo's vital
436− signs. Full-bleed and hairline-ruled so it reads as part of the frame
437− rather than as page content. */
438−.subnav {
439− border-bottom: 1px solid var(--border);
440− background: var(--surface);
441−}
442−
443−.subnav-inner {
444− display: flex;
445− align-items: center;
446− gap: 12px;
447− height: 40px;
448− overflow-x: auto;
449− scrollbar-width: none;
348}
349
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−
350main {
476− padding: 14px 0 48px;
477−}
478−
479−/* A page built from edge-to-edge bands owns its own vertical rhythm: each band
480− carries its padding and its closing rule, so `main` adds neither. */
481−main.flush {
482− padding: 0;
483−}
484−
485−/* ─── the ⌘K palette ─────────────────────────────────────────────────────── */
486−
487−/* Anchored near the top rather than centred: the list grows downward as you
488− type, and a vertically-centred panel would shift the row under the cursor
489− on every keystroke. */
490−.palette-backdrop {
491− position: fixed;
492− inset: 0;
493− z-index: 50;
494− display: flex;
495− justify-content: center;
496− padding: 96px 16px 16px;
497− background: rgb(10 11 13 / 0.55);
498−}
499−
500−.palette-backdrop[hidden] {
501− display: none;
502−}
503−
504−.palette {
505− width: 560px;
506− max-width: 100%;
507− height: max-content;
508− max-height: calc(100vh - 128px);
509− display: flex;
510− flex-direction: column;
511− border: 1px solid var(--border-strong);
512− border-radius: var(--radius);
513− background: var(--surface-raised);
514− overflow: hidden;
515−}
516−
517−.palette-bar {
518− display: flex;
519− align-items: center;
520− gap: 8px;
521− height: 36px;
522− padding: 0 10px;
523− border-bottom: 1px solid var(--border);
524− flex: none;
525−}
526−
527−.palette-prompt {
528− font-family: var(--font-mono);
529− font-size: var(--text-sm);
530− color: var(--identity);
531−}
532−
533−.palette-input {
534− flex: 1;
535− height: 100%;
536− border: none;
537− background: transparent;
538− padding: 0;
539− font-family: var(--font-mono);
540− font-size: var(--text-base);
541−}
542−
543−.palette-input:focus {
544− outline: none;
545− border-color: transparent;
546−}
547−
548−/* Chrome and Safari draw their own clear button inside a search input; it
549− collides with the esc keycap. */
550−.palette-input::-webkit-search-cancel-button {
551− display: none;
552−}
553−
554−.palette-results {
555− overflow-y: auto;
556− padding-bottom: 4px;
557−}
558−
559−.palette-group-label {
560− padding: 6px 10px 2px;
561−}
562−
563−.palette-item {
564− display: flex;
565− align-items: center;
566− gap: 8px;
567− min-height: 26px;
568− padding: 0 10px;
569− width: 100%;
570− border: none;
571− background: transparent;
572− color: var(--text);
573− font: inherit;
574− text-align: left;
575− cursor: pointer;
576−}
577−
578−.palette-item:hover,
579−.palette-item.is-active {
580− background: var(--surface);
581− text-decoration: none;
582−}
583−
584−.palette-glyph {
585− width: 12px;
586− flex: none;
587− font-family: var(--font-mono);
588− font-size: var(--text-xs);
589− color: var(--action);
590−}
591−
592−.palette-label {
593− font-size: var(--text-sm);
594− white-space: nowrap;
595− flex: none;
596−}
597−
598−.palette-label.mono {
599− font-family: var(--font-mono);
600− color: var(--identity);
601− font-weight: 500;
602−}
603−
604−/* The context line gives up its space first — the label is what you are
605− aiming at. */
606−.palette-hint {
607− font-size: var(--text-sm);
608− overflow: hidden;
609− text-overflow: ellipsis;
610− white-space: nowrap;
611− min-width: 0;
612− flex: 1;
613−}
614−
615−.palette-all {
616− border-top: 1px solid var(--border);
617− margin-top: 4px;
618− padding-top: 4px;
351+ padding: 28px 0 64px;
352}
353
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−
354/* ─── typography ─────────────────────────────────────────────────────────── */
355
356h1 {
638− font-size: var(--text-lg);
639− line-height: 28px;
357+ font-size: 20px;
358 font-weight: 600;
359 letter-spacing: -0.01em;
360 margin: 0 0 6px;
361}
362
363h2 {
646− font-size: var(--text-md);
647− line-height: 24px;
364+ font-size: 15px;
365 font-weight: 600;
366 margin: 0 0 10px;
367}
@@ -663,111 +380,96 @@
380 color: var(--text-faint);
381}
382
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. */
383.label-condensed {
384 font-family: var(--font-condensed);
385 font-weight: 500;
386 font-size: var(--text-xs);
387 line-height: 16px;
673− letter-spacing: 0.06em;
388+ letter-spacing: 0.08em;
389 text-transform: uppercase;
390 color: var(--text-dim);
391}
392
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. */
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. */
404.display {
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;
405+ font-family: var(--font-condensed);
406+ font-weight: 700;
407+ letter-spacing: -0.01em;
408+ line-height: 0.98;
409}
410
689−@media (max-width: 800px) {
690− .display {
691− font-size: 30px;
692− line-height: 34px;
693− }
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;
419}
420
421.tnum {
422 font-variant-numeric: tabular-nums;
423}
424
700−/* Prose caps its own line length regardless of how wide the column is. */
701−.measure {
702− max-width: 72ch;
703− text-wrap: pretty;
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;
432}
433
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);
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%);
437}
438
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);
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);
443}
444
733−.dotline > .dotline-key {
734− color: var(--text-dim);
445+.float-shadow {
446+ box-shadow: 0 6px 20px rgb(0 0 0 / 0.35);
447}
448
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);
449+:root.light .float-shadow,
450+:root[data-theme="light"] .float-shadow {
451+ box-shadow: 0 6px 20px rgb(0 0 0 / 0.1);
452}
453
745−.dotline > .dotline-val {
746− order: 3;
747− font-family: var(--font-mono);
748− font-size: var(--text-xs);
749− font-variant-numeric: tabular-nums;
750−}
751−
752−.float-shadow {
753− box-shadow: 0 8px 28px rgb(0 0 0 / 0.4);
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;
458}
459
756−:root.light .float-shadow,
757−:root[data-theme="light"] .float-shadow {
758− box-shadow: 0 8px 28px rgb(0 0 0 / 0.12);
460+.link-brand:hover {
461+ color: var(--brand);
462+ text-decoration-line: underline;
463+ text-decoration-color: var(--brand);
464}
465
466/* ─── surfaces ───────────────────────────────────────────────────────────── */
467
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. */
468.panel {
469 background: var(--surface);
470 border: 1px solid var(--border);
471 border-radius: var(--radius);
770− padding: 14px;
472+ padding: 20px;
473}
474
475.panel + .panel {
@@ -788,54 +490,44 @@
490
491/* ─── controls ───────────────────────────────────────────────────────────── */
492
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. */
493.btn {
494 display: inline-flex;
495 align-items: center;
796− justify-content: center;
496 gap: 6px;
798− height: 28px;
799− padding: 0 10px;
497+ padding: 6px 14px;
498 border-radius: var(--radius);
499 border: 1px solid var(--border-strong);
802− background: transparent;
500+ background: var(--surface-raised);
501 color: var(--text);
502 font: inherit;
805− font-size: var(--text-sm);
806− white-space: nowrap;
503+ font-size: 13px;
504 cursor: pointer;
505}
506
507.btn:hover {
811− border-color: var(--action);
812− color: var(--action);
508+ border-color: var(--text-faint);
509 text-decoration: none;
510}
511
816−/* One per view. Filled `--action` — the single move the page is asking for. */
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. */
514.btn-primary {
818− background: var(--action);
819− border-color: var(--action);
820− color: var(--on-action);
821− font-weight: 500;
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;
522}
523
524.btn-primary:hover {
525 filter: brightness(1.08);
826− color: var(--on-action);
827− border-color: var(--action);
526}
527
528.btn-danger {
831− border-color: var(--danger);
832− color: var(--danger);
833−}
834−
835−.btn-danger:hover {
529 border-color: var(--danger);
530 color: var(--danger);
838− background: color-mix(in srgb, var(--danger) 12%, transparent);
531}
532
533/* Sign out is destructive enough to warn about but too routine to shout, so it
@@ -849,71 +541,26 @@
541.btn-quiet-danger:hover {
542 border-color: color-mix(in srgb, var(--danger) 50%, transparent);
543 color: var(--danger);
852−}
853−
854−.btn[disabled],
855−.btn[aria-disabled="true"] {
856− border-color: var(--border);
857− color: var(--text-faint);
858− cursor: default;
544}
545
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−
546input[type="text"],
547input[type="password"],
884−input[type="search"],
885−input[type="email"],
548textarea {
549 width: 100%;
888− padding: 0 8px;
889− height: 30px;
550+ padding: 7px 10px;
551 background: var(--bg);
552 border: 1px solid var(--border-strong);
553 border-radius: var(--radius);
554 color: var(--text);
555 font: inherit;
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);
556+ font-size: 13px;
557}
558
559input:focus,
560textarea:focus,
561.btn:focus-visible,
562a:focus-visible {
916− outline: 2px solid var(--action);
563+ outline: 2px solid var(--brand);
564 outline-offset: 1px;
565}
566
@@ -924,17 +571,13 @@
571.field label {
572 display: block;
573 margin-bottom: 5px;
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;
574+ font-size: 12px;
575 color: var(--text-dim);
576}
577
578.hint {
579 margin-top: 5px;
937− font-size: var(--text-sm);
580+ font-size: 12px;
581 color: var(--text-faint);
582}
583
@@ -943,91 +586,41 @@
586.chip {
587 display: inline-flex;
588 align-items: center;
946− height: 18px;
947− padding: 0 5px;
589+ padding: 1px 7px;
590 border-radius: var(--radius-sm);
591 border: 1px solid var(--border-strong);
592 font-family: var(--font-mono);
951− font-size: var(--text-xs);
593+ font-size: 11.5px;
594 color: var(--text-dim);
953− white-space: nowrap;
954−}
955−
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;
595}
596
968−.cid > .cid-p {
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 {
600 color: var(--identity);
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;
601+ border-color: color-mix(in srgb, var(--identity) 40%, transparent);
602+ background: color-mix(in srgb, var(--identity) 10%, transparent);
603}
604
981−a.cid:hover > .cid-p {
982− text-decoration: underline;
983−}
984−
985−/* Synthetic ids get no identity colour: a plain-git change has a derived id,
986− and presenting it as a real one would be a lie the reader cannot detect. */
987−.cid-synthetic > .cid-p {
988− color: var(--text-dim);
989− font-weight: 400;
990−}
991−
992−/* State pills are outlined in their state colour, not filled with it. Four
993− states appear side by side in listings, and four filled blocks of colour
994− read as a chart rather than as metadata. */
605.badge {
606 display: inline-flex;
607 align-items: center;
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);
608+ padding: 2px 8px;
609+ border-radius: 999px;
610+ font-size: 11.5px;
611 font-weight: 500;
1006− letter-spacing: 0.06em;
1007− text-transform: uppercase;
1008− color: var(--text-dim);
1009− white-space: nowrap;
1010−}
1011−
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;
612+ color: var(--brand-ink);
613}
614
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); }
615+.badge-open { background: var(--open); }
616+.badge-merged { background: var(--merged); }
617+.badge-abandoned { background: var(--abandoned); }
618+.badge-conflict { background: var(--conflict); }
619
620/* ─── notices ────────────────────────────────────────────────────────────── */
621
622.banner {
1029− padding: 10px 12px;
1030− font-size: var(--text-sm);
623+ padding: 12px 16px;
624 border-radius: var(--radius);
625 border: 1px solid var(--border-strong);
626 background: var(--surface);
@@ -1046,37 +639,36 @@
639
640/* ─── empty states ───────────────────────────────────────────────────────── */
641
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. */
642.empty {
1053− display: flex;
1054− flex-direction: column;
1055− align-items: center;
1056− gap: 6px;
643 text-align: center;
1058− padding: 28px 20px;
644+ padding: 56px 20px;
645 color: var(--text-dim);
1060− border: 1px solid var(--border);
646+ border: 1px dashed var(--border-strong);
647 border-radius: var(--radius);
1062− background: var(--surface);
648}
649
650.empty h2 {
1066− margin: 0;
1067− font-size: var(--text-md);
1068− line-height: 24px;
651 color: var(--text);
652}
653
1072−.empty p {
1073− margin: 0;
1074− font-size: var(--text-sm);
1075−}
1076−
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;
665+}
666
667+.clone-box code {
668+ white-space: nowrap;
669+ color: var(--text-dim);
670+}
671+
672/* Protocol toggle (HTTPS/SSH clone instructions). Two radios drive which
673 panel shows via a sibling selector — no script needed, so the choice works
674 identically with JavaScript on or off. */
@@ -1117,7 +709,7 @@
709
710#proto-https:focus-visible ~ .proto-tabs label[for="proto-https"],
711#proto-ssh:focus-visible ~ .proto-tabs label[for="proto-ssh"] {
1120− outline: 2px solid var(--action);
712+ outline: 2px solid var(--brand);
713 outline-offset: 2px;
714}
715
@@ -1132,17 +724,16 @@
724
725#proto-https:checked ~ .proto-tabs label[for="proto-https"],
726#proto-ssh:checked ~ .proto-tabs label[for="proto-ssh"] {
1135− background: var(--action);
1136− color: var(--on-action);
727+ background: var(--brand);
728+ color: var(--brand-ink);
729}
730
731footer {
732 border-top: 1px solid var(--border);
1141− margin-top: 0;
1142− padding: 16px 0;
1143− background: var(--surface);
733+ margin-top: 48px;
734+ padding: 20px 0;
735 color: var(--text-faint);
1145− font-size: var(--text-sm);
736+ font-size: 12px;
737}
738
739footer .wrap {
@@ -1154,24 +745,20 @@
745
746footer a {
747 color: var(--text-faint);
1157− font-family: var(--font-mono);
1158− font-size: var(--text-xs);
748}
749
1161−footer a:hover {
1162− color: var(--action);
1163−}
1164−
750.footer-mark {
1166− font-family: var(--font-mono);
1167− font-size: var(--text-sm);
1168− color: var(--text-faint);
751+ font-family: var(--font-condensed);
752+ font-weight: 700;
753+ text-transform: uppercase;
754+ letter-spacing: 0.04em;
755+ color: var(--text-dim);
756}
757
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. */
758+/* Pushed to the far end rather than floated, so it stays put when the row
759+ wraps on a narrow screen. */
760.footer-end {
1174− font-size: var(--text-xs);
761+ margin-left: auto;
762}
763
764/* Respect a user's reduced-motion preference (spec §11 accessibility pass). */
@@ -1204,805 +791,448 @@
791}
792
793
1207−/* ─── full-bleed bands ─────────────────────────────────────────────────────── */
794+/* ─── landing and dashboard ────────────────────────────────────────────────── */
795
1209−/* The landing page is a stack of edge-to-edge bands, alternating ground and
1210− surface, each separated by a single hairline. There are no cards and no
1211− shadows: the rules do all the dividing. */
1212−.band {
1213− border-bottom: 1px solid var(--border);
1214−}
1215−
1216−/* The footer draws its own top rule, so the last band must not draw one too —
1217− two hairlines a pixel apart read as a rendering fault. */
1218−main.flush > .band:last-child {
1219− border-bottom: none;
1220−}
1221−
1222−.band-surface {
796+.hero {
797+ border: 1px solid var(--border);
798+ border-radius: var(--radius);
799 background: var(--surface);
800+ overflow: hidden;
801}
802
1226−.band > .wrap {
1227− padding-top: 32px;
1228− padding-bottom: 32px;
803+.hero-body {
804+ padding: 40px 40px 36px;
805}
806
1231−.band-head {
807+.hero-eyebrow {
808 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;
809 align-items: center;
810+ gap: 8px;
811+ margin-bottom: 20px;
812}
813
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 {
814+.pill-brand {
815 display: inline-flex;
816 align-items: center;
817 height: 20px;
818 padding: 0 6px;
1275− border: 1px solid var(--border-strong);
1276− border-radius: var(--radius-sm);
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;
825}
826
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. */
827.hero-title {
1282− max-width: 15ch;
828+ font-size: var(--text-2xl);
829+ max-width: 20ch;
830+ margin: 0;
831+ text-wrap: balance;
832}
833
834.hero-lede {
1286− margin: 0;
835+ max-width: 54ch;
836+ margin: 24px 0 0;
837 font-size: var(--text-md);
1288− line-height: 24px;
838+ line-height: 1.55;
839 color: var(--text-dim);
1290− max-width: 44ch;
1291− text-wrap: pretty;
1292−}
1293−
1294−.hero-lede .mono {
1295− color: var(--text);
840}
841
842.hero-actions {
843 display: flex;
1300− gap: 8px;
1301− align-items: center;
844 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;
845 gap: 8px;
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;
846+ margin-top: 28px;
847}
848
1329−.clone-box .prompt {
1330− color: var(--text-faint);
1331− flex: none;
849+.btn-lg {
850+ height: 38px;
851+ padding: 0 20px;
852}
853
1334−.clone-box code {
1335− color: var(--text);
1336− white-space: nowrap;
1337− background: none;
1338− padding: 0;
1339− border: 0;
1340−}
1341−
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;
854+.hero .clone-box {
855+ margin-top: 24px;
856+ max-width: 54ch;
857}
858
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;
859+.ticker {
860 display: flex;
1357− flex-direction: column;
1358− border: 1px solid var(--border-strong);
1359− border-radius: var(--radius);
1360− background: var(--bg);
1361− overflow: hidden;
861+ flex-wrap: wrap;
862+ gap: 4px 20px;
863+ padding: 10px 40px;
864}
865
1364−.term-bar,
1365−.term-foot {
866+.ticker-item {
867 display: flex;
868 align-items: center;
869 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 {
870 font-family: var(--font-mono);
871 font-size: var(--text-xs);
872 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;
873+ white-space: nowrap;
874}
875
1416−.term-cmd {
1417− color: var(--text);
1418−}
1419−
1420−.term-cmd::before {
1421− content: "$ ";
1422− color: var(--identity);
876+.ticker-dot {
877+ color: var(--brand);
878}
879
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;
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+ }
890}
891
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 {
892+/* Two-column body shared by the landing page and the dashboard. */
893+.landing-columns {
894 display: flex;
1484− flex-wrap: wrap;
1485− overflow: hidden;
1486−}
1487−
1488−.ticker-item {
1489− display: inline-flex;
1490− align-items: center;
1491− gap: 10px;
1492− height: 34px;
1493− padding: 0 14px;
1494− white-space: nowrap;
1495− font-family: var(--font-condensed);
1496− font-size: var(--text-xs);
1497− font-weight: 500;
1498− letter-spacing: 0.06em;
1499− text-transform: uppercase;
1500− color: var(--text-dim);
1501−}
1502−
1503−.ticker-dot {
1504− font-family: var(--font-mono);
1505− color: var(--identity);
1506−}
1507−
1508−/* ─── two-column body ──────────────────────────────────────────────────────── */
1509−
1510−.columns {
1511− display: grid;
1512− grid-template-columns: minmax(0, 1fr) 260px;
895+ flex-direction: column;
896 gap: 32px;
1514− align-items: start;
897+ margin-top: 40px;
898}
899
1517−.columns-main {
900+.landing-main {
901 min-width: 0;
902+ flex: 1;
903 display: flex;
904 flex-direction: column;
1521− gap: 8px;
905+ gap: 32px;
906}
907
1524−.columns-aside {
908+.landing-aside {
909+ width: 100%;
910+ flex-shrink: 0;
911 display: flex;
912 flex-direction: column;
1527− gap: 16px;
1528− min-width: 0;
913+ gap: 32px;
914}
915
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 {
916+@media (min-width: 900px) {
917+ .landing-columns {
918+ flex-direction: row;
919+ }
920+ .landing-aside {
921+ width: 320px;
922+ }
923+}
924+
925+.section-head {
926 display: flex;
1535− flex-direction: column;
1536− gap: 6px;
1537− padding-bottom: 14px;
927+ align-items: baseline;
928+ justify-content: space-between;
929+ gap: 12px;
930 border-bottom: 1px solid var(--border);
931+ padding-bottom: 8px;
932}
933
1541−.aside-block:last-child {
1542− padding-bottom: 0;
1543− border-bottom: none;
934+.section-note {
935+ margin: 8px 0 12px;
936}
937
1546−.aside-head {
1547− display: flex;
1548− align-items: center;
1549− gap: 8px;
1550−}
1551−
1552−/* ─── activity feed ────────────────────────────────────────────────────────── */
938+/* ─── feed ─────────────────────────────────────────────────────────────────── */
939
940.feed {
941+ list-style: none;
942+ margin: 8px 0 0;
943+ padding: 0;
944 border: 1px solid var(--border);
945 border-radius: var(--radius);
1557− background: var(--surface);
1558− overflow: hidden;
1559− margin: 0;
1560− padding: 0;
1561− list-style: none;
946}
947
948.feed-row {
949+ position: relative;
950 display: flex;
1566− align-items: center;
1567− gap: 8px;
1568− height: 34px;
1569− padding: 0 10px 0 0;
951+ align-items: flex-start;
952+ gap: 12px;
953+ padding: 12px 12px 12px 16px;
954 border-bottom: 1px solid var(--border);
1571− font-size: var(--text-sm);
955+ overflow: hidden;
956}
957
958.feed-row:last-child {
959 border-bottom: none;
960}
961
1578−.feed-row:hover {
1579− background: var(--surface-raised);
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;
973}
974
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);
975+.feed-row:hover::before {
976+ opacity: 1;
977}
978
1591−.feed-glyph.is-push { color: var(--action); }
1592−.feed-glyph.is-review { color: var(--open); }
1593−.feed-glyph.is-merge { color: var(--merged); }
1594−.feed-glyph.is-conflict { color: var(--conflict); }
1595−.feed-glyph.is-abandon { color: var(--abandoned); }
1596−.feed-glyph.is-open { color: var(--identity); }
1597−
1598−.feed-repo {
1599− font-family: var(--font-mono);
1600− font-size: var(--text-sm);
1601− white-space: nowrap;
1602− flex: none;
1603− color: var(--text-faint);
1604−}
1605−
1606−.feed-repo .owner {
1607− color: var(--identity);
1608− font-weight: 500;
979+.feed-main {
980+ min-width: 0;
981+ flex: 1;
982}
983
1611−.feed-verb {
1612− color: var(--text-dim);
1613− white-space: nowrap;
1614− flex: none;
1615−}
1616−
1617−/* The one element allowed to be cut off. Everything else in the row is a
1618− fixed-width fact; the title is the part that can be finished by clicking. */
984.feed-title {
1620− font-size: var(--text-base);
985+ display: block;
986 color: var(--text);
987+ font-weight: 500;
988 overflow: hidden;
989 text-overflow: ellipsis;
990 white-space: nowrap;
1625− min-width: 0;
1626− flex: 1;
991}
992
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;
993+.feed-title:hover {
994+ color: var(--brand);
995+ text-decoration: none;
996}
997
1639−.feed-foot {
998+.feed-meta {
999 display: flex;
1000+ flex-wrap: wrap;
1001 align-items: center;
1642− gap: 8px;
1643− font-size: var(--text-sm);
1644− color: var(--text-faint);
1645−}
1646−
1647−/* A live-ish dot. It does not blink — the page is server-rendered and does not
1648− update itself, so a pulsing indicator would be claiming something false. */
1649−.live-dot {
1650− width: 6px;
1651− height: 6px;
1652− border-radius: 50%;
1653− background: var(--open);
1654− display: inline-block;
1655−}
1656−
1657−.live-indicator {
1658− display: inline-flex;
1659− align-items: center;
1660− gap: 6px;
1661− font-family: var(--font-mono);
1002+ gap: 4px 8px;
1003+ margin-top: 4px;
1004 font-size: var(--text-xs);
1005 color: var(--text-faint);
1006}
1007
1666−/* ─── compact aside rows ───────────────────────────────────────────────────── */
1667−
1668−/* A change in an aside list: rail, glyph, id, title. The rail is what marks it
1669− as part of a stack. */
1670−.mini-row {
1671− display: flex;
1672− align-items: center;
1673− gap: 6px;
1674− min-height: 22px;
1675− font-size: var(--text-sm);
1676− min-width: 0;
1677−}
1678−
1679−.mini-row:hover {
1680− background: var(--surface);
1681− text-decoration: none;
1682−}
1683−
1684−.mini-rail {
1685− width: 2px;
1686− align-self: stretch;
1687− background: var(--identity);
1688− flex: none;
1008+.feed-actor {
1009+ color: var(--text-dim);
1010+ font-weight: 500;
1011}
1012
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);
1013+.feed-side {
1014+ display: flex;
1015+ flex-shrink: 0;
1016+ flex-direction: column;
1017+ align-items: flex-end;
1018+ gap: 4px;
1019}
1020
1699−.mini-title {
1700− color: var(--text-dim);
1701− overflow: hidden;
1702− text-overflow: ellipsis;
1703− white-space: nowrap;
1704− min-width: 0;
1705−}
1021+/* ─── activity ─────────────────────────────────────────────────────────────── */
1022
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;
1023+.activity {
1024+ list-style: none;
1025+ margin: 4px 0 0;
1026+ padding: 0;
1027}
1028
1715−/* ─── bookmark rows ────────────────────────────────────────────────────────── */
1716−
1717−.bookmark-line {
1029+.activity-row {
1030 display: flex;
1719− align-items: center;
1720− gap: 6px;
1031+ flex-wrap: wrap;
1032+ align-items: baseline;
1033+ gap: 0 6px;
1034+ padding: 7px 0;
1035+ border-bottom: 1px dashed var(--border);
1036 font-size: var(--text-sm);
1722− color: var(--text-dim);
1037}
1038
1725−.bookmark-line:hover {
1726− text-decoration: none;
1727−}
1728−
1729−.bookmark-line:hover .chip {
1730− border-color: var(--action);
1731− color: var(--action);
1039+.activity-row:last-child {
1040+ border-bottom: none;
1041}
1042
1734−.bookmark-flag {
1735− font-family: var(--font-condensed);
1736− font-size: var(--text-xs);
1043+.activity-actor {
1044+ color: var(--text);
1045 font-weight: 500;
1738− letter-spacing: 0.06em;
1739− text-transform: uppercase;
1740− color: var(--text-faint);
1046}
1047
1743−.bookmark-flag.is-diverged {
1744− color: var(--conflict);
1048+.activity-when {
1049+ margin-left: auto;
1050+ font-size: var(--text-xs);
1051}
1052
1747−/* ─── why switch ───────────────────────────────────────────────────────────── */
1053+/* ─── comparison list ──────────────────────────────────────────────────────── */
1054
1055.compare {
1750− display: grid;
1751− grid-template-columns: repeat(4, minmax(0, 1fr));
1752− gap: 8px;
1753− margin: 0;
1056+ list-style: none;
1057+ margin: 12px 0 0;
1058 padding: 0;
1755− list-style: none;
1059+ display: flex;
1060+ flex-direction: column;
1061+ gap: 10px;
1062}
1063
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. */
1064.compare-item {
1065+ display: flex;
1066+ flex-direction: column;
1067+ gap: 4px;
1068+ padding: 12px;
1069 border: 1px solid var(--border);
1070 border-radius: var(--radius);
1763− background: var(--bg);
1764− overflow: hidden;
1071+ background: var(--surface);
1072}
1073
1074.compare-them,
1075.compare-us {
1076 display: flex;
1770− align-items: flex-start;
1077+ align-items: baseline;
1078 gap: 8px;
1772− padding: 8px 10px;
1773− font-size: var(--text-base);
1774− text-wrap: pretty;
1079+ font-size: var(--text-sm);
1080}
1081
1082.compare-them {
1778− border-bottom: 1px solid var(--border);
1779− color: var(--text-dim);
1083+ color: var(--text-faint);
1084+ text-decoration: line-through;
1085+ text-decoration-color: color-mix(in srgb, var(--conflict) 60%, transparent);
1086}
1087
1088.compare-us {
1783− background: var(--identity-wash);
1089 color: var(--text);
1090}
1091
1092.compare-sign {
1093 font-family: var(--font-mono);
1789− font-size: var(--text-sm);
1790− flex: none;
1094+ flex-shrink: 0;
1095}
1096
1793−.compare-them .compare-sign { color: var(--danger); }
1794−.compare-us .compare-sign { color: var(--identity); }
1097+.compare-them .compare-sign {
1098+ color: var(--conflict);
1099+}
1100
1101+.compare-us .compare-sign {
1102+ color: var(--merged);
1103+}
1104+
1105/* ─── repo cards ───────────────────────────────────────────────────────────── */
1106
1107.repo-cards {
1799− display: grid;
1800− grid-template-columns: repeat(3, minmax(0, 1fr));
1108+ display: flex;
1109+ flex-direction: column;
1110 gap: 8px;
1111+ margin-top: 12px;
1112}
1113
1114.repo-card {
1115+ position: relative;
1116 display: flex;
1117 flex-direction: column;
1118 gap: 6px;
1808− padding: 12px;
1119+ padding: 12px 12px 12px 16px;
1120 border: 1px solid var(--border);
1121 border-radius: var(--radius);
1122 background: var(--surface);
1123 color: var(--text);
1124+ overflow: hidden;
1125}
1126
1127.repo-card:hover {
1816− border-color: var(--action);
1128+ border-color: var(--border-strong);
1129 text-decoration: none;
1130}
1131
1820−.repo-card-name {
1821− font-family: var(--font-mono);
1822− font-size: var(--text-base);
1823− color: var(--action);
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;
1140}
1141
1826−.repo-card-desc {
1827− font-size: var(--text-sm);
1828− line-height: 18px;
1829− color: var(--text-dim);
1830− text-wrap: pretty;
1142+.repo-card:hover .repo-card-rail {
1143+ opacity: 1;
1144}
1145
1833−.repo-card-meta {
1146+.repo-card-name {
1147 display: flex;
1148 align-items: center;
1836− gap: 10px;
1837− padding-top: 2px;
1838− font-family: var(--font-mono);
1839− font-size: var(--text-xs);
1840− color: var(--text-faint);
1149+ gap: 6px;
1150+ font-size: var(--text-sm);
1151+}
1152+
1153+.repo-card-repo {
1154+ font-weight: 500;
1155}
1156
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; }
1157+.repo-card:hover .repo-card-repo {
1158+ color: var(--brand);
1159+}
1160
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. */
1161+.repo-card-desc {
1162+ font-size: var(--text-xs);
1163+ color: var(--text-dim);
1164+}
1165+
1166.repo-card-new {
1850− display: flex;
1851− align-items: center;
1852− justify-content: center;
1853− min-height: 84px;
1854− border: 1px dashed var(--border-strong);
1167+ padding: 9px 12px;
1168+ border: 1px dashed var(--border);
1169 border-radius: var(--radius);
1856− font-family: var(--font-mono);
1857− font-size: var(--text-sm);
1170 color: var(--text-dim);
1171+ font-size: var(--text-sm);
1172+ text-align: center;
1173}
1174
1175.repo-card-new:hover {
1862− border-color: var(--action);
1863− color: var(--action);
1176+ border-color: var(--brand);
1177+ color: var(--brand);
1178 text-decoration: none;
1179}
1180
1867−/* ─── call to action ───────────────────────────────────────────────────────── */
1181+/* ─── call to action band ──────────────────────────────────────────────────── */
1182
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;
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;
1190}
1191
1192.cta-title {
1879− margin: 0;
1193+ margin: 0 auto;
1194+ max-width: 22ch;
1195 font-size: var(--text-xl);
1881− line-height: 32px;
1882− font-weight: 600;
1883− text-wrap: pretty;
1196+ text-wrap: balance;
1197}
1198
1199.cta-lede {
1887− margin: 0;
1888− font-size: var(--text-base);
1200+ margin: 16px auto 0;
1201+ max-width: 48ch;
1202+ font-size: var(--text-md);
1203 color: var(--text-dim);
1890− text-wrap: pretty;
1204}
1205
1893−/* ─── dashboard ────────────────────────────────────────────────────────────── */
1206+.cta-actions {
1207+ justify-content: center;
1208+}
1209+
1210+/* ─── dashboard header ─────────────────────────────────────────────────────── */
1211
1212.dash-head {
1213 display: flex;
1897− align-items: flex-start;
1898− gap: 16px;
1214 flex-wrap: wrap;
1900− padding-bottom: 14px;
1901− margin-bottom: 16px;
1215+ align-items: flex-end;
1216+ justify-content: space-between;
1217+ gap: 12px;
1218+ padding-bottom: 16px;
1219 border-bottom: 1px solid var(--border);
1220}
1221
1222.dash-title {
1906− margin: 0;
1907− font-size: var(--text-xl);
1908− line-height: 32px;
1909− font-weight: 600;
1223+ font-size: var(--text-lg);
1224+ margin: 0 0 4px;
1225}
1226
1227.dash-name {
1913− color: var(--identity);
1914−}
1915−
1916−.dash-section {
1917− display: flex;
1918− flex-direction: column;
1919− gap: 8px;
1228+ color: var(--brand);
1229}
1230
1922−.dash-section + .dash-section {
1923− margin-top: 20px;
1924−}
1925−
1926−.section-head {
1927− display: flex;
1928− align-items: center;
1929− gap: 10px;
1930− flex-wrap: wrap;
1931−}
1932−
1933−.section-note {
1934− margin: 0;
1935− font-size: var(--text-sm);
1936−}
1937−
1938−/* ─── watched activity ─────────────────────────────────────────────────────── */
1939−
1940−.activity {
1941− margin: 0;
1942− padding: 0;
1943− list-style: none;
1944− display: flex;
1945− flex-direction: column;
1946−}
1947−
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;
1231+/* The dashboard's own column block already sits under a header rule. */
1232+.dash-head + .landing-columns {
1233+ margin-top: 24px;
1234}
1235
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−}
1236/* ─── design system pages ──────────────────────────────────────────────────── */
1237
1238.design-head,
@@ -2015,51 +1245,8 @@
1245.design-title {
1246 margin: 0 0 6px;
1247 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;
1248}
1249
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−
1250.design-section {
1251 padding: 28px 0;
1252 border-bottom: 1px solid var(--border);
@@ -2145,12 +1332,61 @@
1332 margin: 0 0 12px;
1333}
1334
2148−/* ─── page headings ────────────────────────────────────────────────────────── */
1335+/* ─── repository header ────────────────────────────────────────────────────── */
1336
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. */
1337+.repo-head {
1338+ margin-bottom: 20px;
1339+}
1340
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+}
1349+
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+}
1369+
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+
1390/* ─── sign in ──────────────────────────────────────────────────────────────── */
1391
1392.signin {
@@ -2158,40 +1394,30 @@
1394 margin: 48px auto;
1395 display: flex;
1396 flex-direction: column;
2161− gap: 14px;
1397+ gap: 24px;
1398}
1399
1400.signin-intro {
1401 display: flex;
1402 flex-direction: column;
2167− gap: 6px;
1403+ gap: 8px;
1404+}
1405+
1406+.signin-eyebrow {
1407+ color: var(--brand);
1408}
1409
1410.signin-title {
1411 margin: 0;
1412 font-size: var(--text-xl);
2173− line-height: 32px;
1413 text-wrap: balance;
1414}
1415
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−
1416.btn-block {
1417 display: flex;
1418 justify-content: center;
1419 width: 100%;
2193− height: 34px;
2194− font-size: var(--text-base);
1420+ height: 38px;
1421}
1422
1423/* A control that is present but not offered. Not a `<button disabled>` because
@@ -2203,13 +1429,12 @@
1429
1430.is-disabled:hover {
1431 border-color: var(--border-strong);
2206− color: var(--text);
1432}
1433
1434.signin-or {
1435 display: flex;
1436 align-items: center;
2212− gap: 8px;
1437+ gap: 12px;
1438}
1439
1440.signin-rule {
@@ -2218,24 +1443,9 @@
1443 background: var(--border);
1444}
1445
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;
1446+.signin-foot {
1447+ margin: 0;
1448+ text-align: center;
1449}
1450
1451/* ─── segmented control ────────────────────────────────────────────────────── */
@@ -2250,12 +1460,9 @@
1460}
1461
1462.segmented-item {
2253− display: inline-flex;
2254− align-items: center;
2255− height: 22px;
2256− padding: 0 10px;
2257− font-family: var(--font-mono);
1463+ padding: 3px 10px;
1464 font-size: var(--text-xs);
1465+ font-weight: 500;
1466 color: var(--text-dim);
1467}
1468
@@ -2270,13 +1477,13 @@
1477}
1478
1479.segmented-item.is-on {
2273− background: var(--action);
2274− color: var(--on-action);
1480+ background: var(--brand);
1481+ color: var(--brand-ink);
1482}
1483
1484.segmented-item.is-on:hover {
2278− background: var(--action);
2279− color: var(--on-action);
1485+ background: var(--brand);
1486+ color: var(--brand-ink);
1487}
1488
1489/* ─── file listing ─────────────────────────────────────────────────────────── */
@@ -2300,105 +1507,21 @@
1507}
1508
1509.tree-crumbs {
2303− display: flex;
2304− align-items: center;
2305− gap: 8px;
2306− margin-bottom: 12px;
1510+ margin-bottom: 14px;
1511 flex-wrap: wrap;
1512}
2309−
2310−/* ─── bookmark switcher ────────────────────────────────────────────────────── */
1513
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 {
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. */
1516+.commit-bar {
1517 display: flex;
1518 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 {
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);
2358− text-decoration: none;
2359−}
2360−
2361−.switcher-item.is-current {
2362− background: var(--identity-wash);
2363−}
2364−
2365−.switcher-item.is-current .mono {
2366− color: var(--identity);
2367−}
2368−
2369−/* A single-bookmark repository has nothing to switch to, so the control
2370− becomes a readout. */
2371−.btn-mono.is-static {
2372− cursor: default;
2373− color: var(--text-dim);
2374−}
2375−
2376−.btn-mono.is-static:hover {
2377− border-color: var(--border-strong);
2378− color: var(--text-dim);
2379−}
2380−
2381−/* ─── the listing ──────────────────────────────────────────────────────────── */
2382−
2383−/* The tip-of-branch bar. Inside the listing's border rather than stacked on
2384− top of it, so the two are literally one object. */
2385−.commit-bar {
2386− display: flex;
2387− align-items: center;
2388− gap: 8px;
2389− padding: 7px 8px;
2390− border-bottom: 1px solid var(--border);
2391− background: var(--surface-raised);
1524 font-size: var(--text-sm);
2393−}
2394−
2395−/* The identity rail. Two pixels of gold saying "this row is about a change",
2396− the same mark the stack rails and revision timeline use. */
2397−.commit-bar-rail {
2398− width: 2px;
2399− height: 14px;
2400− flex: none;
2401− background: var(--identity);
1525}
1526
1527.commit-bar-author {
@@ -2408,91 +1531,69 @@
1531
1532.commit-bar-msg {
1533 min-width: 0;
2411− color: var(--text);
1534+ flex: 1;
1535+ color: var(--text-dim);
1536 overflow: hidden;
1537 text-overflow: ellipsis;
1538 white-space: nowrap;
1539}
1540
1541+.commit-bar-chip,
1542.commit-bar-when {
1543 flex-shrink: 0;
2419− font-family: var(--font-mono);
2420− font-size: var(--text-xs);
2421− color: var(--text-faint);
1544}
1545
1546@media (max-width: 620px) {
2425− .commit-bar .cid {
1547+ .commit-bar-chip {
1548 display: none;
1549 }
1550}
1551
2430−/* The bordered container shared by the file listing, the bookmarks table and
2431− the README panel. */
1552.filelist {
1553 border: 1px solid var(--border);
2434− border-radius: var(--radius);
2435− background: var(--surface);
1554+ border-top: none;
1555+ border-radius: 0 0 var(--radius) var(--radius);
1556 overflow: hidden;
1557}
1558
2439−.filelist + .filelist {
2440− margin-top: 12px;
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);
1564}
1565
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. */
1566.filelist table {
1567 width: 100%;
2450− table-layout: fixed;
1568 border-collapse: collapse;
1569}
1570
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 {
1571+.filelist tr {
1572 border-bottom: 1px solid var(--border);
1573}
1574
2470−.filelist tbody tr:last-child {
1575+.filelist tr:last-child {
1576 border-bottom: none;
1577}
1578
2474−.filelist tbody tr:hover {
2475− background: var(--surface-raised);
1579+.filelist tr:hover {
1580+ background: var(--surface);
1581}
1582
1583.filelist td {
2479− padding: 0 8px;
2480− height: 28px;
1584+ padding: 6px 12px 6px 0;
1585 vertical-align: middle;
1586}
1587
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). */
1588.filelist-icon {
2489− width: 28px;
2490− padding-right: 0 !important;
1589+ width: 1px;
1590+ padding-left: 12px !important;
1591+ padding-right: 8px !important;
1592 line-height: 0;
1593}
1594
1595.icon-dir {
2495− color: var(--action);
1596+ color: var(--brand);
1597}
1598
1599.icon-file {
@@ -2501,7 +1602,6 @@
1602
1603.filelist-name {
1604 max-width: 0;
2504− width: 32%;
1605}
1606
1607.filelist-name a {
@@ -2519,7 +1619,7 @@
1619}
1620
1621.filelist tr:hover .filelist-name a {
2522− color: var(--action);
1622+ color: var(--brand);
1623 text-decoration: underline;
1624}
1625
@@ -2528,166 +1628,33 @@
1628 font-size: var(--text-xs);
1629}
1630
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;
1631+.filelist-size {
1632+ width: 100px;
1633 text-align: right;
1634 white-space: nowrap;
2566− font-family: var(--font-mono);
1635 font-size: var(--text-xs);
2568− color: var(--text-faint);
1636}
1637
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−}
1638+/* ─── readme ───────────────────────────────────────────────────────────────── */
1639
2578−@media (max-width: 800px) {
2579− .filelist-message {
2580− display: none;
2581− }
2582−}
2583−
2584−/* ─── the repository sidebar ───────────────────────────────────────────────── */
2585−
2586−/* Narrower than the landing page's aside: it holds stat lines and clone
2587− commands, not feed rows. */
2588−.columns-repo {
2589− grid-template-columns: minmax(0, 1fr) 240px;
2590− gap: 24px;
2591−}
2592−
2593−.aside-about {
2594− font-size: var(--text-sm);
2595− line-height: 18px;
2596− color: var(--text-dim);
2597− text-wrap: pretty;
2598−}
2599−
2600−.aside-clone {
2601− display: flex;
2602− flex-direction: column;
2603− gap: 2px;
2604−}
2605−
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);
1640+.readme {
1641+ margin-top: 24px;
1642+ border: 1px solid var(--border);
1643 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;
1644}
1645
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−
1646.readme-head {
1647 display: flex;
1648 align-items: center;
1649 gap: 8px;
2678− padding: 6px 8px;
1650+ padding: 8px 16px;
1651 border-bottom: 1px solid var(--border);
2680− background: var(--surface-raised);
2681− font-family: var(--font-mono);
2682− font-size: var(--text-sm);
1652+ background: var(--surface);
1653 line-height: 1;
1654}
1655
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. */
1656.readme-body {
2689− padding: 16px;
2690− max-width: 76ch;
1657+ padding: 16px 20px;
1658}
1659
1660.readme-body > :first-child {
@@ -2753,8 +1720,8 @@
1720}
1721
1722.markdown-body a:hover {
2756− text-decoration-color: var(--action);
2757− color: var(--action);
1723+ text-decoration-color: var(--brand);
1724+ color: var(--brand);
1725}
1726
1727.markdown-body strong {
@@ -2767,7 +1734,7 @@
1734 border: 1px solid var(--border);
1735 border-radius: var(--radius-sm);
1736 background: var(--surface-raised);
2770− color: var(--action);
1737+ color: var(--brand);
1738 font-family: var(--font-mono);
1739 font-size: 0.85em;
1740}
@@ -2793,7 +1760,7 @@
1760.markdown-body blockquote {
1761 margin: 16px 0;
1762 padding-left: 16px;
2796− border-left: 2px solid var(--action);
1763+ border-left: 2px solid var(--brand);
1764 color: var(--text-dim);
1765 font-style: italic;
1766}
@@ -2950,55 +1917,28 @@
1917
1918/* ─── settings ─────────────────────────────────────────────────────────────── */
1919
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. */
1920.subtabs {
1921 display: flex;
2958− gap: 0;
1922+ gap: 4px;
1923+ border-bottom: 1px solid var(--border);
1924+ margin-bottom: 16px;
1925 overflow-x: auto;
2960− scrollbar-width: none;
1926}
1927
2963−.subtabs::-webkit-scrollbar {
2964− display: none;
2965−}
2966−
1928.subtabs a {
2968− display: inline-flex;
2969− align-items: center;
2970− gap: 6px;
2971− height: 30px;
2972− padding: 0 10px;
1929+ padding: 8px 12px;
1930 color: var(--text-dim);
2974− font-size: var(--text-sm);
1931+ border-bottom: 2px solid transparent;
1932 white-space: nowrap;
1933}
1934
1935.subtabs a:hover {
1936 color: var(--text);
2980− text-decoration: none;
1937}
1938
1939.subtabs a.active {
1940 color: var(--text);
2985− font-weight: 500;
2986− box-shadow: inset 0 -2px 0 var(--action);
2987−}
2988−
2989−/* The count beside a tab label. Always monospace and always faint: it is a
2990− quantity you glance at, never the thing you are clicking. */
2991−.subtabs .tab-count {
2992− font-family: var(--font-mono);
2993− font-size: var(--text-xs);
2994− color: var(--text-faint);
2995− font-weight: 400;
2996−}
2997−
2998−/* Outside the repository sub-bar the strip needs its own rule and some air. */
2999−.subtabs.ruled {
3000− border-bottom: 1px solid var(--border);
3001− margin-bottom: 16px;
1941+ border-bottom-color: var(--brand);
1942}
1943
1944.listing {
@@ -3009,21 +1949,19 @@
1949
1950.listing th {
1951 text-align: left;
3012− font-family: var(--font-condensed);
1952 font-weight: 500;
3014− font-size: var(--text-xs);
3015− letter-spacing: 0.06em;
1953+ font-size: 12px;
1954 text-transform: uppercase;
3017− color: var(--text-dim);
1955+ letter-spacing: 0.04em;
1956+ color: var(--text-faint);
1957 padding: 6px 10px 6px 0;
1958 border-bottom: 1px solid var(--border);
1959}
1960
1961.listing td {
3023− padding: 6px 10px 6px 0;
1962+ padding: 8px 10px 8px 0;
1963 border-bottom: 1px solid var(--border);
1964 vertical-align: middle;
3026− font-size: var(--text-sm);
1965}
1966
1967.listing tr:last-child td {
@@ -3038,11 +1976,10 @@
1976}
1977
1978.kv dt {
3041− color: var(--text-dim);
3042− font-family: var(--font-condensed);
3043− font-size: var(--text-xs);
1979+ color: var(--text-faint);
1980+ font-size: 12px;
1981 text-transform: uppercase;
3045− letter-spacing: 0.06em;
1982+ letter-spacing: 0.04em;
1983 align-self: center;
1984}
1985
@@ -3086,7 +2023,7 @@
2023
2024textarea:focus,
2025select:focus {
3089− outline: 2px solid var(--action);
2026+ outline: 2px solid var(--brand);
2027 outline-offset: -1px;
2028}
2029
@@ -3100,381 +2037,8 @@
2037 width: auto;
2038 margin-right: 6px;
2039}
3103−
3104−
2040
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−}
2041
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−
2042/* ─── diffs and review ─────────────────────────────────────────────────────── */
2043
2044.filediff {
@@ -3518,47 +2082,6 @@
2082.line-add { background: var(--diff-add-bg); color: var(--diff-add-text); }
2083.line-del { background: var(--diff-del-bg); color: var(--diff-del-text); }
2084
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−
2085/* Word-level intra-line changes (spec §8). Rendered as a background wash
2086 rather than a colour change, so the line's add/delete colour still reads and
2087 the emphasis survives a high-contrast or monochrome display. */
@@ -3586,9 +2109,6 @@
2109 font-size: 12px;
2110}
2111
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. */
2112.comment {
2113 border: 1px solid var(--border);
2114 border-radius: var(--radius);
@@ -3614,350 +2134,27 @@
2134 font-size: 12px;
2135}
2136
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. */
2137.timeline-event {
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);
2138+ padding: 6px 0;
2139+ border-bottom: 1px dashed var(--border);
2140+ font-size: 13px;
2141}
2142
3628−.timeline-glyph {
3629− width: 12px;
3630− flex: none;
3631− font-family: var(--font-mono);
3632− font-size: var(--text-xs);
2143+.timeline-dot {
2144+ width: 6px;
2145+ height: 6px;
2146+ flex-shrink: 0;
2147+ border-radius: 50%;
2148+ background: var(--border-strong);
2149}
2150
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−}
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); }
2157
3665−.change-head-line {
3666− display: flex;
3667− align-items: center;
3668− gap: 10px;
3669− flex-wrap: wrap;
3670−}
3671−
3672−/* The id, larger than the title. Deliberate: the title is prose somebody can
3673− retype, the id is what every review and permalink is attached to. */
3674−.change-id-display {
3675− font-family: var(--font-mono);
3676− font-size: var(--text-2xl);
3677− line-height: 34px;
3678− font-variant-numeric: tabular-nums;
3679−}
3680−
3681−.change-id-display > .cid-p {
3682− font-weight: 600;
3683−}
3684−
3685−.change-id-display.is-synthetic {
3686− font-size: var(--text-lg);
3687− color: var(--text-dim);
3688−}
3689−
3690−.change-title {
3691− margin: 0;
3692− font-size: var(--text-lg);
3693− line-height: 28px;
3694− font-weight: 600;
3695− text-wrap: pretty;
3696−}
3697−
3698−.change-byline {
3699− display: flex;
3700− align-items: center;
3701− gap: 8px;
3702− flex-wrap: wrap;
3703− font-size: var(--text-sm);
3704− color: var(--text-dim);
3705−}
3706−
3707−.change-byline .sep {
3708− color: var(--text-faint);
3709−}
3710−
3711−/* The aside follows the reader down a long diff. `top` clears the 46px
3712− masthead plus a hairline of air. */
3713−.columns-aside.is-sticky {
3714− position: sticky;
3715− top: 62px;
3716−}
3717−
3718−.reviewer-line {
3719− display: flex;
3720− align-items: center;
3721− gap: 6px;
3722− font-size: var(--text-sm);
3723−}
3724−
3725−.reviewer-glyph {
3726− width: 10px;
3727− flex: none;
3728− font-family: var(--font-mono);
3729− font-size: var(--text-xs);
3730−}
3731−
3732−.reviewer-meta {
3733− font-family: var(--font-mono);
3734− font-size: var(--text-xs);
3735− white-space: nowrap;
3736−}
3737−
3738−.mini-row.is-current {
3739− background: var(--identity-wash);
3740−}
3741−
3742−/* ─── revisions timeline ───────────────────────────────────────────────────── */
3743−
3744−.revtimeline {
3745− display: flex;
3746− flex-direction: column;
3747− margin: 12px 0;
3748−}
3749−
3750−.revrow {
3751− display: flex;
3752− gap: 12px;
3753− border-bottom: 1px solid var(--border);
3754−}
3755−
3756−.revrow.is-selected {
3757− background: var(--identity-wash);
3758−}
3759−
3760−/* The rail is two flexing segments with the dot between them, so the line
3761− joins consecutive revisions and stops cleanly at both ends. */
3762−.revrail {
3763− width: 24px;
3764− flex: none;
3765− display: flex;
3766− flex-direction: column;
3767− align-items: center;
3768− align-self: stretch;
3769−}
3770−
3771−.revrail-seg {
3772− width: 2px;
3773− flex: 1;
3774−}
3775−
3776−/* Square, not round: a revision is a discrete recorded state, and the shape
3777− is doing that work — round would read as a generic bullet. */
3778−.revdot {
3779− width: 9px;
3780− height: 9px;
3781− flex: none;
3782− border: 1px solid var(--identity);
3783− border-radius: 1px;
3784−}
3785−
3786−.revbody {
3787− flex: 1;
3788− min-width: 0;
3789− display: flex;
3790− flex-direction: column;
3791− gap: 4px;
3792− padding: 8px 8px 8px 0;
3793−}
3794−
3795−.revline {
3796− display: flex;
3797− align-items: center;
3798− gap: 8px;
3799− flex-wrap: wrap;
3800−}
3801−
3802−.revlabel {
3803− font-family: var(--font-condensed);
3804− font-size: var(--text-xs);
3805− font-weight: 600;
3806− letter-spacing: 0.06em;
3807− text-transform: uppercase;
3808−}
3809−
3810−.revnote {
3811− font-size: var(--text-base);
3812− color: var(--text);
3813− min-width: 0;
3814− overflow: hidden;
3815− text-overflow: ellipsis;
3816− white-space: nowrap;
3817−}
3818−
3819−.revwhen {
3820− font-family: var(--font-mono);
3821− font-size: var(--text-xs);
3822− color: var(--text-faint);
3823− white-space: nowrap;
3824−}
3825−
3826−.revmeta {
3827− display: flex;
3828− align-items: center;
3829− gap: 12px;
3830− flex-wrap: wrap;
3831− font-family: var(--font-mono);
3832− font-size: var(--text-xs);
3833− color: var(--text-faint);
3834−}
3835−
3836−.revmeta a {
3837− color: var(--text-faint);
3838−}
3839−
3840−.revmeta a:hover {
3841− color: var(--action);
3842−}
3843−
3844−/* The A/B pickers. Links, not radios: each pair is a URL, so a reviewer can
3845− paste "rev 2 → rev 4" into a comment and it resolves for everyone. */
3846−.abpick {
3847− width: 16px;
3848− height: 16px;
3849− flex: none;
3850− display: inline-flex;
3851− align-items: center;
3852− justify-content: center;
3853− border: 1px solid var(--border-strong);
3854− border-radius: var(--radius-sm);
3855− font-family: var(--font-mono);
3856− font-size: var(--text-xs);
3857− line-height: 1;
3858− color: var(--text-faint);
3859−}
3860−
3861−.abpick:hover {
3862− border-color: var(--action);
3863− color: var(--action);
3864− text-decoration: none;
3865−}
3866−
3867−.abpick.is-on {
3868− border-color: var(--action);
3869− background: var(--action);
3870− color: var(--on-action);
3871−}
3872−
3873−.interdiff-file + .interdiff-file {
3874− border-top: 1px solid var(--border);
3875−}
3876−
3877−.interdiff-path {
3878− display: flex;
3879− align-items: center;
3880− gap: 8px;
3881− padding: 6px 8px;
3882− border-bottom: 1px solid var(--border);
3883− background: var(--surface-raised);
3884− font-size: var(--text-sm);
3885−}
3886−
3887−/* ─── stack page ───────────────────────────────────────────────────────────── */
3888−
3889−.stack-chain {
3890− font-size: var(--text-sm);
3891− color: var(--text-faint);
3892−}
3893−
3894−.stackrow {
3895− display: flex;
3896− align-items: center;
3897− gap: 8px;
3898− min-height: 38px;
3899− padding-right: 10px;
3900− border-bottom: 1px solid var(--border);
3901− color: var(--text);
3902− font-size: var(--text-sm);
3903−}
3904−
3905−.stackrow:last-child {
3906− border-bottom: none;
3907−}
3908−
3909−.stackrow:hover {
3910− background: var(--surface-raised);
3911− text-decoration: none;
3912−}
3913−
3914−.stackrow.is-current {
3915− background: var(--identity-wash);
3916−}
3917−
3918−.stackrow-glyph {
3919− width: 22px;
3920− flex: none;
3921− text-align: center;
3922− font-family: var(--font-mono);
3923−}
3924−
3925−.stackrow-title {
3926− font-size: var(--text-base);
3927− overflow: hidden;
3928− text-overflow: ellipsis;
3929− white-space: nowrap;
3930− min-width: 0;
3931−}
3932−
3933−.stackrow-meta {
3934− font-family: var(--font-mono);
3935− font-size: var(--text-xs);
3936− color: var(--text-faint);
3937− white-space: nowrap;
3938− flex: none;
3939−}
3940−
3941−/* The bottom of the graph: what the chain sits on. Not a link, because a
3942− bookmark has no page — it is a pointer, not a thing. */
3943−.stackrow-base {
3944− padding-left: 8px;
3945− font-family: var(--font-mono);
3946− font-size: var(--text-xs);
3947− color: var(--text-faint);
3948−}
3949−
3950−.stack-cmd {
3951− margin-top: 12px;
3952− padding: 8px 10px;
3953− border: 1px solid var(--border);
3954− border-radius: var(--radius);
3955− background: var(--bg);
3956− font-size: var(--text-sm);
3957− color: var(--text-dim);
3958− white-space: pre-wrap;
3959−}
3960−
2158/* ─── conflicts ────────────────────────────────────────────────────────────── */
2159
2160.conflict-columns {
@@ -3984,355 +2181,105 @@
2181 overflow-x: auto;
2182 font-size: 12px;
2183 max-height: 420px;
3987−}
3988−
3989−details summary {
3990− cursor: pointer;
2184}
2185
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−}
2186+/* ─── stack graph ──────────────────────────────────────────────────────────── */
2187
4015−.issue-row:last-child {
4016− border-bottom: none;
2188+.stackgraph {
2189+ list-style: none;
2190+ margin: 16px 0 0;
2191+ padding: 0;
2192}
2193
4019−.issue-row:hover {
4020− background: var(--surface-raised);
4021− text-decoration: none;
4022−}
4023−
4024−/* A closed issue is history. It stays fully legible — dimming it to 50% would
4025− make the archive unreadable — but it steps back a little. */
4026−.issue-row.is-closed {
4027− opacity: 0.85;
4028−}
4029−
4030−.issue-glyph {
4031− width: 14px;
4032− flex: none;
4033− font-family: var(--font-mono);
4034−}
4035−
4036−.issue-num {
4037− font-family: var(--font-mono);
4038− font-size: var(--text-xs);
4039− color: var(--text-faint);
4040− flex: none;
4041−}
4042−
4043−.issue-title {
4044− font-size: 13.5px;
4045− overflow: hidden;
4046− text-overflow: ellipsis;
4047− white-space: nowrap;
4048− min-width: 0;
4049−}
4050−
4051−.issue-row:hover .issue-title {
4052− color: var(--action);
4053−}
4054−
4055−.issue-meta {
4056− font-size: var(--text-sm);
4057− color: var(--text-faint);
4058− white-space: nowrap;
4059− flex: none;
4060−}
4061−
4062−.issue-comments {
4063− width: 34px;
4064− text-align: right;
4065− font-family: var(--font-mono);
4066− font-size: var(--text-xs);
4067− color: var(--text-faint);
4068− flex: none;
4069−}
4070−
4071−.issue-when {
4072− width: 56px;
4073− text-align: right;
4074− font-family: var(--font-mono);
4075− font-size: var(--text-xs);
4076− color: var(--text-faint);
4077− flex: none;
4078−}
4079−
4080−/* ─── issue detail ─────────────────────────────────────────────────────────── */
4081−
4082−.backlink {
4083− display: inline-block;
4084− margin-bottom: 10px;
4085− font-size: var(--text-sm);
4086−}
4087−
4088−.issue-head {
4089− display: flex;
4090− align-items: center;
4091− gap: 10px;
4092− flex-wrap: wrap;
4093− margin-bottom: 8px;
4094−}
4095−
4096−.issue-body {
4097− margin: 12px 0;
4098− padding: 12px;
4099− border: 1px solid var(--border);
4100− border-radius: var(--radius);
4101− background: var(--surface);
4102− max-width: 76ch;
4103−}
4104−
4105−.issue-ref {
4106− display: flex;
4107− align-items: center;
4108− gap: 8px;
4109− margin: 8px 0;
4110− padding: 2px 8px;
2194+.stacknode {
2195 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);
2196+ padding: 10px 0 10px 14px;
2197+ position: relative;
2198}
2199
4157−.btn-mono.is-on {
4158− border-color: var(--action);
4159− color: var(--action);
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);
2209}
2210
4162−/* The search bar reuses the revset bar's shell, so a select inside it has to
4163− lose its own border and sit flush with the field beside it. */
4164−.revset-bar select {
4165− height: 30px;
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;
2211+.stacknode.current {
2212+ border-left-color: var(--identity);
2213}
2214
4176−/* ─── search results ───────────────────────────────────────────────────────── */
4177−
4178−.search-group + .search-group {
4179− margin-top: 20px;
2215+.stacknode.current::before {
2216+ background: var(--identity);
2217}
2218
4182−.search-group > h2 {
4183− margin: 0 0 6px;
2219+details summary {
2220+ cursor: pointer;
2221}
2222
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−}
2223
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−}
2224+/* ─── issues ───────────────────────────────────────────────────────────────── */
2225
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;
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;
2232}
2233
4223−.search-hit:hover .search-title {
4224− color: var(--action);
4225−}
2234+/* ─── masthead search ──────────────────────────────────────────────────────── */
2235
4227−/* The context gives up its space first — the title is what you are aiming at. */
4228−.search-context {
4229− flex: 1;
2236+.masthead-search {
2237+ margin-left: auto;
2238+ margin-right: 16px;
2239 min-width: 0;
4231− color: var(--text-dim);
4232− overflow: hidden;
4233− text-overflow: ellipsis;
4234− white-space: nowrap;
2240}
2241
4237−/* ─── masthead groups ──────────────────────────────────────────────────────── */
4238−
4239−.masthead-nav,
4240−.masthead-account {
2242+.theme-toggle {
2243 display: flex;
2244 align-items: center;
4243− gap: 8px;
4244− min-width: 0;
2245+ justify-content: center;
2246+ width: 30px;
2247+ height: 30px;
2248+ padding: 0;
2249+ margin-right: 8px;
2250+ flex-shrink: 0;
2251+ background: transparent;
2252+ border-color: transparent;
2253+ color: var(--text-dim);
2254}
2255
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 {
2256+.theme-toggle:hover {
2257 border-color: var(--border-strong);
4259− background: var(--surface-raised);
4260−}
4261−
4262−.masthead-account a {
4263− font-size: var(--text-sm);
2258+ color: var(--text);
2259}
2260
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;
2261+.theme-toggle .icon-moon {
2262+ display: none;
2263}
2264
4279−.theme-toggle:hover {
4280− border-color: var(--action);
4281− color: var(--action);
2265+:root[data-theme="light"] .theme-toggle .icon-sun {
2266+ display: none;
2267}
2268
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;
2269+:root[data-theme="light"] .theme-toggle .icon-moon {
2270+ display: block;
2271}
2272
4292−.switcher-menu form {
4293− margin: 0;
2273+.masthead-search input {
2274+ width: 180px;
2275+ max-width: 40vw;
2276+ padding: 5px 9px;
2277+ font-size: 13px;
2278}
2279
4296−.switcher-item-danger {
4297− width: 100%;
4298− border: none;
4299− background: transparent;
4300− font: inherit;
4301− text-align: left;
4302− cursor: pointer;
4303− color: var(--text);
4304−}
4305−
4306−.switcher-item-danger:hover {
4307− color: var(--danger);
4308−}
4309−
4310−/* Narrow screens shed the chrome from the middle outward: the section links
4311− go first, then the wordmark's "dogfood" text, leaving the jump control,
4312− the handle and the theme readout as the last things standing. */
4313−@media (max-width: 900px) {
4314− .masthead-nav,
4315− .masthead-group-start > .vrule {
4316− display: none;
4317− }
4318−}
4319−
2280+/* On a narrow screen the search box is the first thing to give up its space. */
2281@media (max-width: 620px) {
4321− .jump > span:first-child {
4322− display: none;
4323− }
4324−
4325− .jump {
4326− padding: 0 6px;
4327− }
4328−
4329− .brand > span:last-child {
4330− display: none;
4331− }
4332−}
4333−
4334−@media (max-width: 400px) {
4335− .theme-toggle {
2282+ .masthead-search {
2283 display: none;
2284 }
2285}
@@ -4369,7 +2316,7 @@
2316textarea:focus-visible,
2317summary:focus-visible,
2318[tabindex]:focus-visible {
4372− outline: 2px solid var(--action);
2319+ outline: 2px solid var(--brand);
2320 outline-offset: 2px;
2321 border-radius: var(--radius-sm);
2322}
@@ -4417,70 +2364,20 @@
2364
2365
2366/* ─── the in-browser editor ────────────────────────────────────────────────── */
4420−
4421−/* The editor is one bordered object: a title bar, the code, and a commit bar
4422− along the bottom. The commit message and the submit button live inside that
4423− frame rather than as separate fields below it, because they are part of the
4424− same act. */
4425−.editor-shell {
4426− border: 1px solid var(--border);
4427− border-radius: var(--radius);
4428− background: var(--surface);
4429− overflow: hidden;
4430−}
2367
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−
2368/* The textarea is the editor until CodeMirror replaces it, so it has to look
2369 like one on its own — not like a form field that happens to hold code. */
2370.editor-host textarea {
4464− display: block;
2371 width: 100%;
2372 min-height: 420px;
4467− border: none;
4468− border-radius: 0;
4469− padding: 8px 10px;
4470− background: var(--bg);
2373 font-family: var(--font-mono);
2374 font-size: 13px;
4473− line-height: 21px;
2375+ line-height: 1.5;
2376 tab-size: 4;
2377 white-space: pre;
2378 overflow: auto;
2379}
2380
4479−.editor-host textarea:focus {
4480− outline: none;
4481− border: none;
4482−}
4483−
2381/* CodeMirror sizes itself; the host must not fight it. */
2382.editor-host .cm-editor {
2383 height: auto;
@@ -4634,14 +2531,11 @@
2531 color: var(--text-dim);
2532}
2533
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); }
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); }
2539.sk-mod { color: var(--text-dim); background: var(--border); }
2540
2541.symbol-name {
@@ -4700,186 +2594,6 @@
2594 }
2595
2596 .code-sidebar-right {
4703− display: none;
4704− }
4705−}
4706−
4707−
4708−/* ─── responsive hardening ────────────────────────────────────────────────── */
4709−
4710−/* A terminal is rendered as a figure for its caption semantics. Browser
4711− figure margins cost 80px of horizontal room unless explicitly reset, which
4712− is especially destructive on a phone. */
4713−figure.term {
4714− margin: 0;
4715−}
4716−
4717−/* Generic surfaces may contain grids, tables, or long rendered prose. Let the
4718− component own any necessary scrolling instead of making the document wide. */
4719−.panel,
4720−.markdown-body {
4721− min-width: 0;
4722−}
4723−
4724−/* The interdiff heading contains independent label, explanation, and summary
4725− spans. Give them real layout and let the explanation move to a second line
4726− rather than running directly into the comparison label. */
4727−.interdiff > .filediff-head {
4728− display: flex;
4729− align-items: center;
4730− gap: 4px 8px;
4731− flex-wrap: wrap;
4732−}
4733−
4734−@media (max-width: 900px) {
4735− /* `.masthead nav` has greater specificity than a bare `.masthead-nav`.
4736− Qualifying this rule ensures the intended mobile chrome actually sheds
4737− the nonessential design-system link. */
4738− .masthead .masthead-nav {
2597 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;
2598 }
2599}
crates/df-web/assets/theme.js+18 −31
@@ -1,48 +1,35 @@
1// Theme toggle. Enhancement only — the site is fully usable in its default
2// dark theme with this script blocked or absent; it just cannot be switched.
3−//
4−// The control reads out the theme it is currently *in* ("DARK"), not the one
5−// it would switch to. That is the design's choice, and it is the right one for
6−// a control that is also the only place the current theme is stated.
3(function () {
4 var KEY = "dogfood-theme";
5
10− function current() {
11− return document.documentElement.getAttribute("data-theme") === "light"
12− ? "light"
13− : "dark";
14− }
15−
6 function apply(theme) {
7 if (theme === "light") {
8 document.documentElement.setAttribute("data-theme", "light");
9 } else {
10 document.documentElement.removeAttribute("data-theme");
11 }
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− });
12 }
13
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−
14 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"));
19 btn.hidden = false;
44− btn.addEventListener("click", window.dogfoodToggleTheme);
20+
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 });
46−
47− apply(current());
35})();
crates/df-web/src/main.rs+0 −34
@@ -151,8 +151,6 @@
151 .route("/assets/htmx.min.js", get(serve_htmx))
152 .route("/assets/theme-init.js", get(serve_theme_init_js))
153 .route("/assets/theme.js", get(serve_theme_js))
154− .route("/assets/palette.js", get(serve_palette_js))
155− .route("/assets/terminal.js", get(serve_terminal_js))
154 .route("/assets/editor.js", get(serve_editor_js))
155 // ─── operational ─────────────────────────────────────────────────────
156 // Repository routes are declared last so their `{owner}` wildcard
@@ -198,7 +196,6 @@
196 .route("/{owner}/{repo}/changes/{reference}", get(routes::review::overview))
197 .route("/{owner}/{repo}/changes/{reference}/files", get(routes::review::files))
198 .route("/{owner}/{repo}/changes/{reference}/revisions", get(routes::review::revisions))
201− .route("/{owner}/{repo}/changes/{reference}/checks", get(routes::review::checks))
199 .route("/{owner}/{repo}/changes/{reference}/conflicts", get(routes::review::conflicts))
200 .route("/{owner}/{repo}/changes/{reference}/comments", post(routes::review::create_comment))
201 .route(
@@ -416,37 +413,6 @@
413 (axum::http::header::CACHE_CONTROL, "public, max-age=86400"),
414 ],
415 include_str!("../assets/theme.js"),
419− )
420−}
421−
422−/// The ⌘K palette's behaviour. Deferred, like htmx — without it the masthead
423−/// control stays a plain link to `/search`.
424−async fn serve_palette_js() -> impl axum::response::IntoResponse {
425− (
426− [
427− (
428− axum::http::header::CONTENT_TYPE,
429− "application/javascript; charset=utf-8",
430− ),
431− (axum::http::header::CACHE_CONTROL, "public, max-age=86400"),
432− ],
433− include_str!("../assets/palette.js"),
434− )
435−}
436−
437−/// The homepage terminal's typewriter animation. Deferred, like the other
438−/// enhancement scripts — without it the example session just renders as
439−/// static text.
440−async fn serve_terminal_js() -> impl axum::response::IntoResponse {
441− (
442− [
443− (
444− axum::http::header::CONTENT_TYPE,
445− "application/javascript; charset=utf-8",
446− ),
447− (axum::http::header::CACHE_CONTROL, "public, max-age=86400"),
448− ],
449− include_str!("../assets/terminal.js"),
416 )
417}
418
crates/df-web/src/repo_ctx.rs+0 −39
@@ -26,25 +26,6 @@
26 /// Display handle of the owning user or org.
27 pub owner: String,
28 pub access: RepoAccess,
29− /// Counts for the repository sub-bar.
30− pub nav: RepoNav,
31−}
32−
33−/// The numbers on the repository sub-bar, which is on every page inside a
34−/// repository.
35−///
36−/// Resolved once per request alongside permissions, for the same reason: the
37−/// bar is chrome, so every handler needs it, and making each one remember to
38−/// fetch it is how a page ends up with a bar that says something different
39−/// from the page under it.
40−#[derive(Debug, Clone, Copy, Default, sqlx::FromRow)]
41−pub struct RepoNav {
42− pub open_changes: i64,
43− /// Open changes that are currently conflicted. A subset of `open_changes` —
44− /// a conflict is a state a change is *in*, not a state it is instead of.
45− pub conflicted: i64,
46− pub open_issues: i64,
47− pub bookmarks: i64,
29}
30
31impl RepoContext {
@@ -115,29 +96,9 @@
96 return Err(AppError::NotFound);
97 }
98
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−
99 Ok(RepoContext {
100 owner: row.owner_handle,
101 access,
140− nav,
102 repo: Repo {
103 id: row.id,
104 owner_kind: row.owner_kind,
crates/df-web/src/security_tests.rs+0 −93
@@ -140,18 +140,6 @@
140 ) -> SResult<Option<Revision>> {
141 Ok(None)
142 }
143− async fn last_commits_in_dir(
144− &self,
145− _: RepoId,
146− _: &RevId,
147− _: &Path,
148− _: &[String],
149− ) -> SResult<std::collections::HashMap<String, Revision>> {
150− Ok(std::collections::HashMap::new())
151− }
152− async fn diff_stats(&self, _: RepoId, revs: &[RevId]) -> SResult<Vec<Option<(usize, usize)>>> {
153− Ok(vec![None; revs.len()])
154− }
143}
144
145/// Enough provider metadata for `Oidc` to construct without a network call.
@@ -656,87 +644,6 @@
644
645 // The owner does see their own private content, or the feature is useless.
646 let res = h.get_as("/search?q=widget", &alice_session).await;
659− let body = h.body(res).await;
660− assert!(body.contains("widgetconfidential"), "the owner must see their own: {body}");
661−
662− h.drop_schema().await;
663−}
664−
665−/// The redesign added several aggregate queries that only run at request time —
666−/// the sub-bar counts, the filter-tab counts, and the weekly statistics. A
667−/// mistake in any of them is a 500 on the two most-visited pages in a
668−/// repository, and nothing else in the suite would catch it.
669−///
670−/// Not a security property, but it lives here because this is the only
671−/// harness that can serve a real request against a real schema.
672−#[tokio::test]
673−async fn the_repository_pages_render_with_their_real_counts() {
674− let Some(h) = harness("counts").await else { return };
675−
676− let (alice, session) = user(&h.db, "alice", false).await;
677− let r = repo(&h.db, alice, "counted", false).await;
678−
679− change(&h.db, r, 1, "klxqnvpqlnlvtkmuqmtmxktlnvnomvwv", "first change").await;
680− change(&h.db, r, 2, "mmmmnvpqlnlvtkmuqmtmxktlnvnomvwv", "second change").await;
681− issue(&h.db, r, 1, "an issue").await;
682−
683− for path in ["/alice/counted/changes", "/alice/counted/issues", "/alice/counted/bookmarks"] {
684− let res = h.get_as(path, &session).await;
685− assert_eq!(res.status(), StatusCode::OK, "{path} did not render");
686− let body = h.body(res).await;
687−
688− // The sub-bar states the repository's vital signs on every page, so
689− // its numbers are the ones that must be right everywhere.
690− assert!(
691− body.contains("2 open"),
692− "{path} should report two open changes in the sub-bar: {body}"
693− );
694− }
695−
696− // The change list additionally runs the tab counts and the weekly
697− // aggregate, including a percentile over an empty set — which returns
698− // NULL, and must decode as `None` rather than failing the query.
699− let res = h.get_as("/alice/counted/changes", &session).await;
700− let body = h.body(res).await;
701− assert!(body.contains("second change"), "the list should show its changes: {body}");
702− assert!(body.contains("Median time to first review"), "week stats missing: {body}");
703−
704− h.drop_schema().await;
705−}
706−
707−/// The ⌘K palette is a second entry point into search, and a second entry
708−/// point is exactly where a visibility rule gets forgotten.
709−///
710−/// It must not be: the palette renders through the same handler in fragment
711−/// mode, so this asserts the property directly rather than trusting that it is
712−/// the same code path.
713−#[tokio::test]
714−async fn the_palette_fragment_obeys_the_same_visibility_rule_as_search() {
715− let Some(h) = harness("palette").await else { return };
716−
717− let (alice, alice_session) = user(&h.db, "alice", false).await;
718− let (_, mallory) = user(&h.db, "mallory", false).await;
719−
720− let hidden = repo(&h.db, alice, "widgetsecret", true).await;
721− let public = repo(&h.db, alice, "widgetopen", false).await;
722− change(&h.db, hidden, 1, "klxqnvpqlnlvtkmuqmtmxktlnvnomvwv", "widgetconfidential change").await;
723− change(&h.db, public, 1, "mmmmnvpqlnlvtkmuqmtmxktlnvnomvwv", "widgetpublic change").await;
724−
725− for session in [None, Some(mallory)] {
726− let res = h.request("/search?q=widget&fragment=1", session).await;
727− assert_eq!(res.status(), StatusCode::OK);
728− let body = h.body(res).await;
729−
730− // A fragment is a bare list, so it must not drag the page chrome with
731− // it — that would nest a whole document inside the overlay.
732− assert!(!body.contains("<html"), "the fragment must not be a full page: {body}");
733−
734− assert!(!body.contains("widgetconfidential"), "private content in the palette: {body}");
735− assert!(!body.contains("widgetsecret"), "private repo name in the palette: {body}");
736− assert!(body.contains("widgetopen"), "the public repo should be findable");
737− }
738−
739− let res = h.get_as("/search?q=widget&fragment=1", &alice_session).await;
647 let body = h.body(res).await;
648 assert!(body.contains("widgetconfidential"), "the owner must see their own: {body}");
649
crates/df-store/src/git/mod.rs+0 −125
@@ -470,27 +470,6 @@
470 .await
471 }
472
473− async fn diff_stats(&self, id: RepoId, revs: &[RevId]) -> Result<Vec<Option<(usize, usize)>>> {
474− let revs: Vec<RevId> = revs.to_vec();
475−
476− self.with_repo(id, move |repo| {
477− // No context lines: the totals do not depend on them, and asking
478− // for three means building three times the hunk bodies we then
479− // throw away.
480− let opts = DiffOpts { context_lines: 0, ..DiffOpts::default() };
481−
482− Ok(revs
483− .iter()
484− .map(|rev| {
485− diff::compute_from_parent(repo, rev, opts)
486− .ok()
487− .map(|d| (d.total_additions, d.total_deletions))
488− })
489− .collect())
490− })
491− .await
492− }
493−
473 async fn commit_file(
474 &self,
475 id: RepoId,
@@ -727,110 +706,6 @@
706 }
707 None => Ok(None),
708 }
730− })
731− .await
732− }
733−
734− async fn last_commits_in_dir(
735− &self,
736− id: RepoId,
737− rev: &RevId,
738− dir: &Path,
739− entries: &[String],
740− ) -> Result<std::collections::HashMap<String, Revision>> {
741− let rev = rev.clone();
742− let rel_dir = safe_path::normalise(&dir.to_string_lossy())?;
743− let wanted: std::collections::HashSet<String> = entries.iter().cloned().collect();
744−
745− if wanted.is_empty() {
746− return Ok(std::collections::HashMap::new());
747− }
748−
749− self.with_repo(id, move |repo| {
750− let commit = convert::find_commit(repo, &rev)?;
751−
752− let walk = commit
753− .ancestors()
754− .all()
755− .map_err(|e| StoreError::Other(anyhow::anyhow!("walking history: {e}")))?;
756−
757− let mut remaining = wanted;
758− // (entry name -> the commit that last touched it), filled in as the
759− // walk resolves each entry.
760− let mut resolved: std::collections::HashMap<String, gix::ObjectId> =
761− std::collections::HashMap::new();
762−
763− 'walk: for info in walk.take(500) {
764− let info = info.map_err(|e| StoreError::Other(anyhow::anyhow!("walk: {e}")))?;
765− let c = repo
766− .find_object(info.id)
767− .map_err(|e| StoreError::Other(anyhow::anyhow!("find: {e}")))?
768− .try_into_commit()
769− .map_err(|_| StoreError::NoSuchRevision)?;
770−
771− let to_tree = c
772− .tree()
773− .map_err(|e| StoreError::Other(anyhow::anyhow!("tree: {e}")))?;
774−
775− // First parent only, matching `diff_from_parent` — a merge's
776− // "last commit" for a path is the mainline change, not every
777− // side branch that happened to touch it too.
778− let from_tree = match c.parent_ids().next() {
779− Some(parent) => repo
780− .find_object(parent.detach())
781− .map_err(|e| StoreError::Other(anyhow::anyhow!("finding parent: {e}")))?
782− .try_into_commit()
783− .map_err(|_| StoreError::NoSuchRevision)?
784− .tree()
785− .map_err(|e| StoreError::Other(anyhow::anyhow!("parent tree: {e}")))?,
786− None => repo.empty_tree(),
787− };
788−
789− // Path list only — no blob reads. This is what keeps a whole
790− // directory resolvable in one walk rather than one walk per
791− // file: the expensive part of a diff is reading and comparing
792− // content, and a "which entry did this commit touch" check
793− // never needs it.
794− let mut touched: Vec<String> = Vec::new();
795− from_tree
796− .changes()
797− .map_err(|e| StoreError::Other(anyhow::anyhow!("diffing trees: {e}")))?
798− .for_each_to_obtain_tree(&to_tree, |change| {
799− touched.push(change.location().to_string());
800− Ok::<_, std::convert::Infallible>(
801− gix::object::tree::diff::Action::Continue,
802− )
803− })
804− .map_err(|e| StoreError::Other(anyhow::anyhow!("walking tree diff: {e}")))?;
805−
806− for path in &touched {
807− let rest = if rel_dir.is_empty() {
808− Some(path.as_str())
809− } else {
810− path.strip_prefix(&rel_dir).and_then(|s| s.strip_prefix('/'))
811− };
812− let Some(rest) = rest else { continue };
813− let entry_name = rest.split('/').next().unwrap_or(rest);
814− if remaining.remove(entry_name) {
815− resolved.insert(entry_name.to_string(), info.id);
816− }
817− }
818−
819− if remaining.is_empty() {
820− break 'walk;
821− }
822− }
823−
824− let mut out = std::collections::HashMap::with_capacity(resolved.len());
825− for (name, oid) in resolved {
826− let c = repo
827− .find_object(oid)
828− .map_err(|e| StoreError::Other(anyhow::anyhow!("find: {e}")))?
829− .try_into_commit()
830− .map_err(|_| StoreError::NoSuchRevision)?;
831− out.insert(name, convert::to_revision(&c)?);
832− }
833− Ok(out)
709 })
710 .await
711 }
crates/df-web/src/routes/auth.rs+1 −26
@@ -59,34 +59,9 @@
59 None => "/login?continue=sso".to_string(),
60 };
61
62− // A public repository to point at, so "read anything public first" is an
63− // offer with a destination rather than a slogan.
64− let sample: Option<(String, String)> = sqlx::query_as(
65− r#"
66− SELECT COALESCE(ou.handle, og.handle) AS owner, r.name::text
67− FROM repos r
68− LEFT JOIN users ou ON ou.id = r.owner_user_id
69− LEFT JOIN orgs og ON og.id = r.owner_org_id
70− WHERE r.archived = false AND r.visibility = 'public'
71− ORDER BY r.pushed_at DESC NULLS LAST, r.created_at DESC
72− LIMIT 1
73− "#,
74− )
75− .fetch_optional(&state.db)
76− .await?;
77−
78− let sample_path = sample.as_ref().map(|(o, n)| format!("{o}/{n}"));
79− let clone_hint = match &sample {
80− Some((o, n)) => format!("jj git clone {}", state.config.https_clone_url(o, n)),
81− None => format!(
82− "jj git clone {}",
83− state.config.https_clone_url("your-org", "your-repo")
84− ),
85− };
86−
62 Ok(views::page(
63 Chrome { title: "Sign in", user: None, csrf: &csrf, nonce: &nonce },
89− views::pages::signin(&sso_href, &clone_hint, sample_path.as_deref()),
64+ views::pages::signin(&sso_href),
65 )
66 .into_response())
67}
crates/df-web/src/routes/change.rs+36 −245
@@ -48,103 +48,59 @@
48
49 // On a revset error, show the message and no rows rather than silently
50 // listing everything — which would look like the filter had matched.
51− let mut rows = if revset_error.is_some() {
51+ let rows = if revset_error.is_some() {
52 Vec::new()
53 } else {
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?
54+ load_changes(&state, ctx.repo.id, state_filter, revset_sql, revset_vals).await?
55 };
64−
65− decorate(&state, &ctx, &mut rows).await;
66− let rows = v::arrange(rows);
67−
68− let counts = list_counts(&state, ctx.repo.id, user.as_deref()).await?;
69− let week = week_stats(&state, ctx.repo.id).await?;
56
57 let body = maud::html! {
58+ (rv::header(&ctx, "changes"))
59 (v::list(&ctx, &rows, v::ListFilters {
60 state: state_filter,
61 revset: &revset_input,
62 revset_error: revset_error.as_deref(),
76− counts,
77− signed_in: user.is_some(),
78− week,
63 }))
64 };
65
82− Ok(views::page_with_bar(
66+ Ok(views::page(
67 Chrome {
68 title: &format!("Changes · {}/{}", ctx.owner, ctx.repo.name),
69 user: user.as_deref(),
70 csrf: &csrf,
71 nonce: &nonce,
72 },
89− rv::header(&ctx, "changes"),
73 body,
74 )
75 .into_response())
76}
77
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−
78async fn load_changes(
79 state: &AppState,
80 repo_id: Uuid,
81 state_filter: &str,
122− viewer: Option<Uuid>,
82 revset_sql: Option<String>,
83 revset_vals: Vec<String>,
84) -> AppResult<Vec<v::ChangeRow>> {
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!(
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.
88 "SELECT c.number, c.change_id, c.synthetic, c.title, c.state::text,
89 c.conflicted, c.updated_at,
90 u.handle::text AS author,
91 hr.author_name,
133− hr.rev AS head_rev,
92 (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,
93 COALESCE((
137− SELECT array_agg(pp.change_id)
94+ SELECT array_agg(pc.change_id)
95 FROM change_edges e
139− JOIN changes pp ON pp.id = e.parent_change
140− WHERE e.child_change = c.id
141− ), '{{}}') AS parents
96+ JOIN changes pc ON pc.id = e.child_change
97+ WHERE e.parent_change = c.id
98+ ), '{}') AS children
99 FROM changes c
100 LEFT JOIN users u ON u.id = c.author_user_id
101 LEFT JOIN revisions hr ON hr.id = c.head_revision_id
102 WHERE c.repo_id = $1
146− AND ({})",
147− state_predicate(state_filter)
103+ AND ($2 = 'all' OR c.state::text = $2)",
104 );
105
106 if let Some(frag) = &revset_sql {
@@ -153,23 +109,24 @@
109 }
110 sql.push_str(" ORDER BY c.updated_at DESC LIMIT 100");
111
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);
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);
130
131 for v in revset_vals {
132 query = query.bind(v);
@@ -190,10 +147,8 @@
147 updated_at,
148 author,
149 author_name,
193− head_rev,
150 revcount,
195− comments,
196− parents,
151+ children,
152 )| {
153 v::ChangeRow {
154 number,
@@ -205,161 +160,14 @@
160 updated_at,
161 author,
162 author_name,
208− head_rev,
163 revision_count: revcount,
210− comments,
211− parents,
212− reviewers: Vec::new(),
213− diffstat: None,
214− depth: 0,
215− stack_size: 0,
164+ children,
165 }
166 },
167 )
168 .collect())
169}
170
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−
171pub struct ChangeRecord {
172 pub id: Uuid,
173 pub number: i64,
@@ -370,8 +178,6 @@
178 pub state: String,
179 pub conflicted: bool,
180 pub target_bookmark: String,
373− pub created_at: chrono::DateTime<chrono::Utc>,
374− pub updated_at: chrono::DateTime<chrono::Utc>,
181}
182
183pub enum Resolution {
@@ -381,19 +187,7 @@
187 None,
188}
189
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−);
190+type ChangeTuple = (Uuid, i64, String, bool, String, String, String, bool, String);
191
192fn to_record(t: ChangeTuple) -> ChangeRecord {
193 ChangeRecord {
@@ -406,14 +200,11 @@
200 state: t.6,
201 conflicted: t.7,
202 target_bookmark: t.8,
409− created_at: t.9,
410− updated_at: t.10,
203 }
204}
205
206const SELECT_CHANGE: &str = "SELECT id, number, change_id, synthetic, title, description,
415− state::text, conflicted, target_bookmark,
416− created_at, updated_at
207+ state::text, conflicted, target_bookmark
208 FROM changes";
209
210pub async fn resolve_change(state: &AppState, repo_id: Uuid, reference: &str) -> AppResult<Resolution> {
@@ -496,17 +287,17 @@
287 .await?;
288
289 let body = maud::html! {
290+ (rv::header(&ctx, "changes"))
291 (v::new_change_form(&ctx, &csrf, &candidates, &bookmarks, q.error.as_deref()))
292 };
293
502− Ok(views::page_with_bar(
294+ Ok(views::page(
295 Chrome {
296 title: &format!("Open a change · {}/{}", ctx.owner, ctx.repo.name),
297 user: user.as_deref(),
298 csrf: &csrf,
299 nonce: &nonce,
300 },
509− rv::header(&ctx, "changes"),
301 body,
302 )
303 .into_response())
crates/df-web/src/routes/edit.rs+2 −2
@@ -100,6 +100,7 @@
100 };
101
102 let body = maud::html! {
103+ (rv::header(&ctx, "code"))
104 (v::editor(&ctx, v::Editor {
105 bookmark: &bookmark,
106 tip: tip.as_str(),
@@ -112,14 +113,13 @@
113 }))
114 };
115
115− Ok(views::page_with_bar(
116+ Ok(views::page(
117 Chrome {
118 title: &format!("Editing {} · {}/{}", p.path, ctx.owner, ctx.repo.name),
119 user: user.as_deref(),
120 csrf: &csrf,
121 nonce: &nonce,
122 },
122− rv::header(&ctx, "code"),
123 // The one page that loads the editor bundle.
124 maud::html! {
125 (body)
crates/df-web/src/routes/home.rs+21 −177
@@ -6,9 +6,7 @@
6
7use crate::error::AppResult;
8use crate::state::{AppState, CsrfToken, CurrentUser, Nonce};
9−use crate::views::pages::{
10− ActivityItem, BookmarkItem, Dashboard, DashChange, FeedItem, RepoSummary, StackItem,
11−};
9+use crate::views::pages::{ActivityItem, Dashboard, DashChange, FeedItem, RepoSummary};
10use crate::views::{self, Chrome};
11
12/// Repos the viewer can reach: their own, plus any they collaborate on, plus
@@ -42,30 +40,12 @@
40 Nonce(nonce): Nonce,
41) -> AppResult<Response> {
42 let Some(user) = user else {
43+ let hint = state.config.https_clone_url("your-org", "your-repo");
44 let feed = public_feed(&state).await?;
45 let repos = public_repos(&state).await?;
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(
46+ return Ok(views::page(
47 Chrome { title: "Dogfood", user: None, csrf: &csrf, nonce: &nonce },
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− }),
48+ views::pages::landing(&format!("jj git clone {hint}"), &feed, &repos),
49 )
50 .into_response());
51 };
@@ -116,53 +96,21 @@
96
97// ─── queries ─────────────────────────────────────────────────────────────────
98
119−type RepoRow = (
120− String,
121− String,
122− Option<String>,
123− bool,
124− i64,
125− i64,
126− Option<DateTime<Utc>>,
127−);
99+type RepoRow = (String, String, Option<String>, bool);
100
101fn to_summaries(rows: Vec<RepoRow>) -> Vec<RepoSummary> {
102 rows.into_iter()
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− )
103+ .map(|(owner, name, description, private)| RepoSummary { owner, name, description, private })
104 .collect()
105}
106
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−
107async fn visible_repos(state: &AppState, user: &df_db::models::User) -> AppResult<Vec<RepoSummary>> {
108 let sql = format!(
109 r#"
110 SELECT COALESCE(ou.handle, og.handle) AS owner,
111 r.name::text,
112 r.description,
164− (r.visibility = 'private') AS private,
165− {REPO_CARD_COUNTS}
113+ (r.visibility = 'private') AS private
114 FROM repos r
115 LEFT JOIN users ou ON ou.id = r.owner_user_id
116 LEFT JOIN orgs og ON og.id = r.owner_org_id
@@ -183,130 +131,32 @@
131
132/// Public repositories, for the signed-out landing page.
133async fn public_repos(state: &AppState) -> AppResult<Vec<RepoSummary>> {
186− let rows: Vec<RepoRow> = sqlx::query_as(&format!(
134+ let rows: Vec<RepoRow> = sqlx::query_as(
135 r#"
136 SELECT COALESCE(ou.handle, og.handle) AS owner,
137 r.name::text,
138 r.description,
191− false AS private,
192− {REPO_CARD_COUNTS}
139+ false AS private
140 FROM repos r
141 LEFT JOIN users ou ON ou.id = r.owner_user_id
142 LEFT JOIN orgs og ON og.id = r.owner_org_id
143 WHERE r.archived = false AND r.visibility = 'public'
144 ORDER BY r.pushed_at DESC NULLS LAST, r.created_at DESC
145 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
146 "#,
147 )
148 .fetch_all(&state.db)
149 .await?;
150
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())
151+ Ok(to_summaries(rows))
152}
153
154/// The "shipping right now" feed.
155///
156/// Public repositories only, and no drafts: this renders for anonymous
157/// 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.
158async fn public_feed(state: &AppState) -> AppResult<Vec<FeedItem>> {
307− /// `(owner, repo, number, change_id, synthetic, title, actor, author_name,
308− /// kind, created_at)`
309− type Row = (
159+ let rows: Vec<(
160 String,
161 String,
162 i64,
@@ -315,11 +165,8 @@
165 String,
166 Option<String>,
167 Option<String>,
318− String,
168 DateTime<Utc>,
320− );
321−
322− let rows: Vec<Row> = sqlx::query_as(
169+ )> = sqlx::query_as(
170 // `hr.author_name` is the fallback when no account matched the commit's
171 // email — the person is still known, just not linkable.
172 r#"
@@ -329,21 +176,19 @@
176 c.change_id,
177 c.synthetic,
178 c.title,
332− ac.handle AS actor,
179+ au.handle AS author,
180 hr.author_name,
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
181+ c.updated_at
182+ FROM changes c
183+ JOIN repos r ON r.id = c.repo_id
184 LEFT JOIN users ou ON ou.id = r.owner_user_id
185 LEFT JOIN orgs og ON og.id = r.owner_org_id
341− LEFT JOIN users ac ON ac.id = e.actor_id
186+ LEFT JOIN users au ON au.id = c.author_user_id
187 LEFT JOIN revisions hr ON hr.id = c.head_revision_id
188 WHERE r.archived = false
189 AND r.visibility = 'public'
190 AND c.state <> 'draft'
346− ORDER BY e.created_at DESC
191+ ORDER BY c.updated_at DESC
192 LIMIT 12
193 "#,
194 )
@@ -353,7 +198,7 @@
198 Ok(rows
199 .into_iter()
200 .map(
356− |(owner, repo, number, change_id, synthetic, title, actor, actor_name, kind, when)| {
201+ |(owner, repo, number, change_id, synthetic, title, author, author_name, when)| {
202 FeedItem {
203 owner,
204 repo,
@@ -361,9 +206,8 @@
206 change_id,
207 synthetic,
208 title,
364− actor,
365− actor_name,
366− kind,
209+ author,
210+ author_name,
211 when,
212 }
213 },
crates/df-web/src/routes/issue.rs+6 −6
@@ -48,6 +48,7 @@
48 let all_labels = load_labels(&state, ctx.repo.id).await?;
49
50 let body = maud::html! {
51+ (rv::header(&ctx, "issues"))
52 (v::list(&ctx, &rows, v::ListFilters {
53 state: state_filter,
54 label,
@@ -56,14 +57,13 @@
57 }))
58 };
59
59− Ok(views::page_with_bar(
60+ Ok(views::page(
61 Chrome {
62 title: &format!("Issues · {}/{}", ctx.owner, ctx.repo.name),
63 user: user.as_deref(),
64 csrf: &csrf,
65 nonce: &nonce,
66 },
66− rv::header(&ctx, "issues"),
67 body,
68 )
69 .into_response())
@@ -91,17 +91,17 @@
91 let labels = load_labels(&state, ctx.repo.id).await?;
92
93 let body = maud::html! {
94+ (rv::header(&ctx, "issues"))
95 (v::new_form(&ctx, v::NewIssue { csrf: &csrf, labels: &labels, error: q.error.as_deref() }))
96 };
97
97− Ok(views::page_with_bar(
98+ Ok(views::page(
99 Chrome {
100 title: &format!("New issue · {}/{}", ctx.owner, ctx.repo.name),
101 user: user.as_deref(),
102 csrf: &csrf,
103 nonce: &nonce,
104 },
104− rv::header(&ctx, "issues"),
105 body,
106 )
107 .into_response())
@@ -232,6 +232,7 @@
232 let body_html = render(&ctx, &issue.body);
233
234 let body = maud::html! {
235+ (rv::header(&ctx, "issues"))
236 (v::detail(&ctx, v::Detail {
237 number: issue.number,
238 title: &issue.title,
@@ -250,14 +251,13 @@
251 }))
252 };
253
253− Ok(views::page_with_bar(
254+ Ok(views::page(
255 Chrome {
256 title: &format!("{} · {}/{}", issue.title, ctx.owner, ctx.repo.name),
257 user: user.as_deref(),
258 csrf: &csrf,
259 nonce: &nonce,
260 },
260− rv::header(&ctx, "issues"),
261 body,
262 )
263 .into_response())
crates/df-web/src/routes/repo.rs+10 −239
@@ -1,6 +1,5 @@
1//! Repository browsing (M2) and creation.
2
3−use std::collections::HashMap;
3use std::path::Path;
4
5use axum::extract::{Path as UrlPath, Query, State};
@@ -70,127 +69,12 @@
69
70 let readme = render_readme(&state, &ctx, &rev, &entries).await;
71 let tip = tip_commit(&state, &ctx, &rev).await;
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− )
72+ v::tree_listing(&ctx, &rev_label, "", &entries, tip.as_ref(), readme.as_ref())
73 };
74
75 Ok(render(&ctx, "code", &csrf, &nonce, user.as_deref(), body))
76}
77
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−
78#[derive(Deserialize)]
79pub struct RevPath {
80 pub owner: String,
@@ -229,23 +113,8 @@
113 };
114
115 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;
116
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− );
117+ let body = v::tree_listing(&ctx, &p.rev, &p.path, &entries, tip.as_ref(), readme.as_ref());
118 Ok(render(&ctx, "code", &csrf, &nonce, user.as_deref(), body))
119}
120
@@ -263,101 +132,10 @@
132
133 Some(v::TipCommit {
134 author: r.author.name.clone(),
266− author_handle: handle_for_email(state, &r.author.email).await,
135 summary: r.summary().to_string(),
136 when: r.author.when,
137 change_id: r.change_id.clone(),
270− })
271−}
272−
273−/// The last commit that touched each entry of a directory listing.
274−///
275−/// One `last_commits_in_dir` call resolves the whole directory (see its docs
276−/// for why that beats a lookup per file). Best-effort like `tip_commit`: a
277−/// store that cannot walk history still renders the listing, just without
278−/// this column.
279−async fn last_commits_for(
280− state: &AppState,
281− ctx: &RepoContext,
282− rev: &df_store::RevId,
283− dir: &Path,
284− entries: &[df_store::TreeEntry],
285−) -> HashMap<String, v::EntryHistory> {
286− let names: Vec<String> = entries.iter().map(|e| e.name.clone()).collect();
287−
288− let revisions = state
289− .store
290− .last_commits_in_dir(ctx.store_id(), rev, dir, &names)
291− .await
292− .unwrap_or_default();
293−
294− revisions
295− .into_iter()
296− .map(|(name, r)| {
297− (
298− name,
299− v::EntryHistory {
300− summary: r.summary().to_string(),
301− when: r.author.when,
302− change_id: r.change_id,
303− },
304− )
305− })
306− .collect()
307−}
308−
309−/// The account that owns a commit-author email, if any.
310−///
311−/// The same rule the indexer attributes changes by, applied to a commit read
312−/// straight from the store rather than from the index. Best-effort: a lookup
313−/// failure renders the commit's own name rather than failing the page.
314−pub(crate) async fn handle_for_email(state: &AppState, email: &str) -> Option<String> {
315− let email = email.trim();
316− if email.is_empty() {
317− return None;
318− }
319− sqlx::query_scalar::<_, String>("SELECT handle::text FROM users WHERE email = $1")
320− .bind(email)
321− .fetch_optional(&state.db)
322− .await
323− .ok()
324− .flatten()
325−}
326−
327−/// The same lookup for a whole listing, in one round trip.
328−///
329−/// A page of 200 commits must not become 200 queries. Returns email → handle
330−/// for the ones that matched; callers fall back to the commit's own name.
331−pub(crate) async fn handles_for_emails<'a>(
332− state: &AppState,
333− emails: impl IntoIterator<Item = &'a str>,
334−) -> HashMap<String, String> {
335− let mut wanted: Vec<String> = emails
336− .into_iter()
337− .map(str::trim)
338− .filter(|e| !e.is_empty())
339− .map(str::to_owned)
340− .collect();
341− wanted.sort();
342− wanted.dedup();
343−
344− if wanted.is_empty() {
345− return HashMap::new();
346− }
347−
348− // `email` is citext, so the join is case-insensitive without lowering here.
349− sqlx::query_as::<_, (String, String)>(
350− "SELECT email::text, handle::text FROM users WHERE email = ANY($1)",
351− )
352− .bind(&wanted)
353− .fetch_all(&state.db)
354− .await
355− .unwrap_or_else(|e| {
356− tracing::warn!("resolving commit authors failed: {e}");
357− Vec::new()
138 })
359− .into_iter()
360− .collect()
139}
140
141/// Is this path a markdown document?
@@ -421,11 +199,6 @@
199 .ok()
200 .flatten();
201
424− let last_commit_handle = match &last_commit {
425− Some(c) => handle_for_email(&state, &c.author.email).await,
426− None => None,
427− };
428−
202 // Optionally fetch blame.
203 let blame_lines = if wants_blame {
204 state
@@ -479,7 +252,6 @@
252 sidebar_dir: &tree_dir,
253 symbols: &symbols,
254 last_commit: last_commit.as_ref(),
482− last_commit_handle: last_commit_handle.as_deref(),
255 blame: blame_lines.as_deref(),
256 wants_blame,
257 };
@@ -618,11 +390,7 @@
390 .await
391 .map_err(store_err)?;
392
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);
393+ let body = v::log_view(&ctx, &rev_label, &revs);
394 Ok(render(&ctx, "code", &csrf, &nonce, user.as_deref(), body))
395}
396
@@ -635,7 +403,11 @@
403 Nonce(nonce): Nonce,
404) -> AppResult<Response> {
405 let ctx = RepoContext::load(&state, &owner, &name, user.as_deref()).await?;
638− let marks = bookmark_rows(&state, &ctx).await?;
406+ let marks = state
407+ .store
408+ .bookmarks(ctx.store_id())
409+ .await
410+ .unwrap_or_default();
411
412 let body = v::bookmarks_view(&ctx, &marks);
413 Ok(render(&ctx, "bookmarks", &csrf, &nonce, user.as_deref(), body))
@@ -898,15 +670,14 @@
670 user: Option<&df_db::models::User>,
671 body: maud::Markup,
672) -> Response {
901− views::page_with_bar(
673+ views::page(
674 Chrome {
675 title: &format!("{}/{}", ctx.owner, ctx.repo.name),
676 user,
677 csrf,
678 nonce,
679 },
908− v::header(ctx, tab),
909− body,
680+ maud::html! { (v::header(ctx, tab)) (body) },
681 )
682 .into_response()
683}
crates/df-web/src/routes/repo_settings.rs+2 −2
@@ -51,6 +51,7 @@
51 let tab = q.tab.as_deref().unwrap_or("general").to_owned();
52
53 let body = maud::html! {
54+ (crate::views::repo::header(&ctx, "settings"))
55 (v::repo_settings(v::RepoSettings {
56 ctx: &ctx,
57 csrf: &csrf,
@@ -63,14 +64,13 @@
64 }))
65 };
66
66− Ok(views::page_with_bar(
67+ Ok(views::page(
68 Chrome {
69 title: &format!("{}/{} settings", ctx.owner, ctx.repo.name),
70 user: user.as_deref(),
71 csrf: &csrf,
72 nonce: &nonce,
73 },
73− crate::views::repo::header(&ctx, "settings"),
74 body,
75 )
76 .into_response())
crates/df-web/src/routes/review.rs+34 −256
@@ -40,9 +40,6 @@
40 author: Option<String>,
41 author_name: Option<String>,
42 can_manage: bool,
43− comment_count: i64,
44− /// The right-hand column, identical on every tab.
45− aside: v::ChangeAside,
43}
44
45impl Loaded {
@@ -68,11 +65,11 @@
65 Resolution::One(c) => *c,
66 Resolution::Ambiguous(candidates) => {
67 let body = maud::html! {
68+ (rv::header(&ctx, "changes"))
69 (cv::ambiguous(&ctx, reference, &candidates))
70 };
73− return Ok(Err(views::page_with_bar(
71+ return Ok(Err(views::page(
72 Chrome { title: "Ambiguous change id", user, csrf, nonce },
75− rv::header(&ctx, "changes"),
73 body,
74 )
75 .into_response()));
@@ -109,116 +106,7 @@
106 let is_author = matches!((user, &author), (Some(u), Some(a)) if &u.handle == a);
107 let can_manage = ctx.access.can_manage_changes() || is_author;
108
112− let comment_count: i64 =
113− sqlx::query_scalar("SELECT count(*) FROM comments WHERE change_id_fk = $1")
114− .bind(change.id)
115− .fetch_one(&state.db)
116− .await?;
117−
118− let aside = load_aside(state, &ctx, &change).await?;
119−
120− Ok(Ok(Loaded {
121− ctx,
122− change,
123− revisions,
124− author,
125− author_name,
126− can_manage,
127− comment_count,
128− aside,
129− }))
130−}
131−
132−/// Reviewers and the surrounding stack — the aside on every change tab.
133−async fn load_aside(
134− state: &AppState,
135− ctx: &RepoContext,
136− change: &ChangeRecord,
137−) -> AppResult<v::ChangeAside> {
138− // `DISTINCT ON` keeps each reviewer's most recent verdict: somebody who
139− // approved and later requested changes has one current position, not two.
140− let reviewers: Vec<(String, String, bool)> = sqlx::query_as(
141− r#"
142− SELECT DISTINCT ON (rv.reviewer_id)
143− u.handle::text,
144− rv.verdict::text,
145− (rv.revision_id = c.head_revision_id) AS at_head
146− FROM reviews rv
147− JOIN changes c ON c.id = rv.change_id_fk
148− JOIN users u ON u.id = rv.reviewer_id
149− WHERE rv.change_id_fk = $1
150− ORDER BY rv.reviewer_id, rv.created_at DESC
151− "#,
152− )
153− .bind(change.id)
154− .fetch_all(&state.db)
155− .await?;
156−
157− // The chain this change sits in, walked in both directions. A recursive CTE
158− // rather than a fixed number of joins, because a stack has no maximum
159− // depth — and `UNION` (not `UNION ALL`) is what makes a cycle in a
160− // corrupted edge table terminate rather than run forever.
161− let chain: Vec<(String, i64, String, bool, i32)> = sqlx::query_as(
162− r#"
163− WITH RECURSIVE down AS (
164− SELECT c.id, 0 AS depth FROM changes c WHERE c.id = $1
165− UNION
166− SELECT p.id, d.depth - 1
167− FROM down d
168− JOIN change_edges e ON e.child_change = d.id
169− JOIN changes p ON p.id = e.parent_change
170− ),
171− up AS (
172− SELECT c.id, 0 AS depth FROM changes c WHERE c.id = $1
173− UNION
174− SELECT ch.id, u.depth + 1
175− FROM up u
176− JOIN change_edges e ON e.parent_change = u.id
177− JOIN changes ch ON ch.id = e.child_change
178− ),
179− chain AS (
180− SELECT id, min(depth) AS depth FROM (
181− SELECT * FROM down UNION ALL SELECT * FROM up
182− ) combined GROUP BY id
183− )
184− SELECT c.change_id, c.number, c.state::text, c.conflicted, chain.depth::int
185− FROM chain
186− JOIN changes c ON c.id = chain.id
187− WHERE c.repo_id = $2
188− ORDER BY chain.depth DESC
189− "#,
190− )
191− .bind(change.id)
192− .bind(ctx.repo.id)
193− .fetch_all(&state.db)
194− .await?;
195−
196− // Depths come back relative to this change, which can make them negative.
197− // Shift so the bottom of the stack sits at zero, because the rail indents
198− // from there.
199− let floor = chain.iter().map(|(_, _, _, _, d)| *d).min().unwrap_or(0);
200−
201− Ok(v::ChangeAside {
202− reviewers: reviewers
203− .into_iter()
204− .map(|(handle, verdict, at_head)| crate::views::change::Reviewer {
205− handle,
206− verdict,
207− at_head,
208− })
209− .collect(),
210− stack: chain
211− .into_iter()
212− .map(|(cid, number, st, conflicted, depth)| v::StackNodeMini {
213− is_current: cid == change.change_id,
214− change_id: cid,
215− number,
216− state: st,
217− conflicted,
218− depth: (depth - floor) as usize,
219− })
220− .collect(),
221− })
109+ Ok(Ok(Loaded { ctx, change, revisions, author, author_name, can_manage }))
110}
111
112impl Loaded {
@@ -234,11 +122,6 @@
122 author: self.author.as_deref(),
123 author_name: self.author_name.as_deref(),
124 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,
125 can_manage: self.can_manage,
126 can_comment,
127 csrf,
@@ -294,8 +177,8 @@
177 let description_html = crate::routes::issue::render(&l.ctx, &l.change.description);
178
179 let body = maud::html! {
180+ (rv::header(&l.ctx, "changes"))
181 (v::header(&l.ctx, &head, "overview"))
298− (v::tab_body(&l.ctx, &l.aside, maud::html! {
182 @if let Some(e) = &flash.error { div .banner.banner-error role="alert" { (e) } }
183 @if let Some(n) = &flash.notice { div .banner.banner-ok role="status" { (n) } }
184 (v::overview(&l.ctx, &head, v::Overview {
@@ -307,17 +190,15 @@
190 events: &events,
191 viewer_reviewed,
192 }))
310− }))
193 };
194
313− Ok(views::page_with_bar(
195+ Ok(views::page(
196 Chrome {
197 title: &format!("{} · {}/{}", l.change.title, l.ctx.owner, l.ctx.repo.name),
198 user: user.as_deref(),
199 csrf: &csrf,
200 nonce: &nonce,
201 },
320− rv::header(&l.ctx, "changes"),
202 body,
203 )
204 .into_response())
@@ -397,48 +278,37 @@
278 .into_iter()
279 .filter(|c| c.anchor_path.is_some() && c.anchor_state != "orphaned")
280 .collect();
400−
401− // The tab strip can only count files once the diff has been computed.
402− let head = v::ChangeHead { file_count: diff.as_ref().map(|d| d.files.len()), ..head };
281
282 let body = maud::html! {
283+ (rv::header(&l.ctx, "changes"))
284 (v::header(&l.ctx, &head, "files"))
406− (v::tab_body(&l.ctx, &l.aside, v::files(&l.ctx, &head, v::FilesView {
285+ (v::files(&l.ctx, &head, v::FilesView {
286 diff: diff.as_ref(),
287 comments: &comments,
288 rev: rev.as_deref().unwrap_or(""),
289 against: against.as_deref(),
290 revisions: &l.revisions,
412− })))
291+ }))
292 };
293
415− Ok(views::page_with_bar(
294+ Ok(views::page(
295 Chrome {
296 title: &format!("Files · {}", l.change.title),
297 user: user.as_deref(),
298 csrf: &csrf,
299 nonce: &nonce,
300 },
422− rv::header(&l.ctx, "changes"),
301 body,
302 )
303 .into_response())
304}
305
306// ─── revisions ───────────────────────────────────────────────────────────────
429−
430−/// Which two revisions the interdiff compares.
431−#[derive(Deserialize, Default)]
432−pub struct CompareQuery {
433− pub a: Option<i32>,
434− pub b: Option<i32>,
435−}
307
308/// `GET /{owner}/{repo}/changes/{ref}/revisions`
309pub async fn revisions(
310 State(state): State<AppState>,
311 UrlPath((owner, name, reference)): UrlPath<(String, String, String)>,
441− Query(q): Query<CompareQuery>,
312 CurrentUser(user): CurrentUser,
313 CsrfToken(csrf): CsrfToken,
314 Nonce(nonce): Nonce,
@@ -449,9 +319,7 @@
319 };
320 let head = l.head_view(&csrf, false);
321
452− /// `(seq, rev, message, author_name, pushed_at, conflicted, pushed_by,
453− /// parents)`
454− type RevRow = (
322+ let rows: Vec<(
323 i32,
324 String,
325 String,
@@ -459,12 +327,9 @@
327 chrono::DateTime<chrono::Utc>,
328 bool,
329 Option<String>,
462− Vec<String>,
463− );
464−
465− let rows: Vec<RevRow> = sqlx::query_as(
330+ )> = sqlx::query_as(
331 "SELECT r.seq, r.rev, r.message, r.author_name, r.pushed_at, r.conflicted,
467− u.handle::text, r.parents
332+ u.handle::text
333 FROM revisions r
334 LEFT JOIN users u ON u.id = r.pushed_by
335 WHERE r.change_id_fk = $1 ORDER BY r.seq",
@@ -473,22 +338,10 @@
338 .fetch_all(&state.db)
339 .await?;
340
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−
341 let revs: Vec<v::RevisionDetail> = rows
342 .into_iter()
489− .enumerate()
343 .map(
491− |(i, (seq, rev, message, author_name, pushed_at, conflicted, pushed_by, parents))| {
344+ |(seq, rev, message, author_name, pushed_at, conflicted, pushed_by)| {
345 v::RevisionDetail {
346 seq,
347 rev,
@@ -497,96 +350,29 @@
350 pushed_at,
351 conflicted,
352 pushed_by,
500− diffstat: stats.get(i).copied().flatten(),
501− base: parents.first().map(|p| df_store::abbreviate_rev(p).to_owned()),
353 }
354 },
355 )
356 .collect();
357
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−
358 let body = maud::html! {
359+ (rv::header(&l.ctx, "changes"))
360 (v::header(&l.ctx, &head, "revisions"))
539− (v::tab_body(&l.ctx, &l.aside,
540− v::revisions(&l.ctx, &head, &revs, v::Compare { a, b, diff: diff.as_ref() })))
361+ (v::revisions(&l.ctx, &head, &revs))
362 };
363
543− Ok(views::page_with_bar(
364+ Ok(views::page(
365 Chrome {
366 title: &format!("Revisions · {}", l.change.title),
367 user: user.as_deref(),
368 csrf: &csrf,
369 nonce: &nonce,
370 },
550− rv::header(&l.ctx, "changes"),
371 body,
372 )
373 .into_response())
374}
375
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−
376// ─── conflicts ───────────────────────────────────────────────────────────────
377
378/// `GET /{owner}/{repo}/changes/{ref}/conflicts`
@@ -616,18 +402,18 @@
402 };
403
404 let body = maud::html! {
405+ (rv::header(&l.ctx, "changes"))
406 (v::header(&l.ctx, &head, "conflicts"))
620− (v::tab_body(&l.ctx, &l.aside, v::conflicts(&l.ctx, &head, &files)))
407+ (v::conflicts(&l.ctx, &head, &files))
408 };
409
623− Ok(views::page_with_bar(
410+ Ok(views::page(
411 Chrome {
412 title: &format!("Conflicts · {}", l.change.title),
413 user: user.as_deref(),
414 csrf: &csrf,
415 nonce: &nonce,
416 },
630− rv::header(&l.ctx, "changes"),
417 body,
418 )
419 .into_response())
@@ -988,39 +774,31 @@
774 if nodes.is_empty() {
775 return Err(AppError::NotFound);
776 }
991−
992− // Every change in a stack targets the same bookmark, so the bottom one
993− // names what the whole chain lands on.
994− let target: Option<String> = match nodes.first() {
995− Some(bottom) => sqlx::query_scalar(
996− "SELECT target_bookmark FROM changes WHERE repo_id = $1 AND number = $2",
997− )
998− .bind(ctx.repo.id)
999− .bind(bottom.number)
1000− .fetch_optional(&state.db)
1001− .await?,
1002− None => None,
1003− };
777
778 let body = maud::html! {
1006− (v::stack(
1007− &ctx,
1008− &nodes,
1009− &change_id,
1010− target.as_deref(),
1011− &csrf,
1012− ctx.access.can_manage_changes(),
1013− ))
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+ }
793 };
794
1016− Ok(views::page_with_bar(
795+ Ok(views::page(
796 Chrome {
797 title: &format!("Stack · {}/{}", ctx.owner, ctx.repo.name),
798 user: user.as_deref(),
799 csrf: &csrf,
800 nonce: &nonce,
801 },
1023− rv::header(&ctx, "changes"),
802 body,
803 )
804 .into_response())
crates/df-web/src/routes/search.rs+26 −42
@@ -27,10 +27,6 @@
27 /// `repos` | `changes` | `issues`. Absent means all three.
28 #[serde(rename = "type")]
29 pub kind: Option<String>,
30− /// `1` when the ⌘K palette is asking. Returns the results list on its own,
31− /// with no page chrome, so the palette and the full page cannot disagree
32− /// about what the viewer is allowed to see.
33− pub fragment: Option<String>,
30}
31
32/// The visibility predicate, shared by all three queries.
@@ -101,62 +97,50 @@
97
98 let total = repos.len() + changes.len() + issues.len();
99
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−
100 Ok(views::page(
101 Chrome { title: "Search", user: user.as_deref(), csrf: &csrf, nonce: &nonce },
102 maud::html! {
119− div .page-head {
103+ div .panel {
104 h1 { "Search" }
121− span .band-note {
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 {
118 "Titles, descriptions and bodies. Code search is not part of v1 — \
119 clone the repository and use " code { "jj" } " or " code { "grep" } "."
124− }
125− }
126−
127− form .revset-bar method="get" action="/search" {
128− label .revset-tag for="q" { "find" }
129− input #q type="text" name="q" value=(raw) autofocus
130− placeholder="repositories, changes and issues"
131− aria-label="Search query";
132− select name="type" aria-label="Result type" {
133− @for (key, label) in [("all", "everything"), ("repos", "repositories"),
134− ("changes", "changes"), ("issues", "issues")] {
135− option value=(key) selected[kind == key] { (label) }
136− }
120 }
138− button .btn.btn-mono type="submit" { "search" }
121 }
122
123 @if !raw.is_empty() && total == 0 {
142− div .empty {
124+ div .panel { div .empty {
125 h2 { "No results" }
126 p { "Nothing you can see matches that." }
145− }
127+ } }
128 }
129
130 @for (heading, hits) in [("Repositories", &repos), ("Changes", &changes), ("Issues", &issues)] {
131 @if !hits.is_empty() {
150− section .search-group {
151− h2 .label-condensed { (heading) }
152− div .filelist {
132+ div .panel {
133+ h2 { (heading) }
134+ div .stack style="gap:0" {
135 @for h in hits.iter() {
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) } }
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+ }
142 @if !h.context.is_empty() {
159− span .search-context { (h.context) }
143+ p .dim style="margin:4px 0 0" { (h.context) }
144 }
145 }
146 }
crates/df-web/src/views/change.rs+76 −570
@@ -20,68 +20,17 @@
20 pub author: Option<String>,
21 /// The name the commit itself carries, used when no account matched.
22 pub author_name: Option<String>,
23− /// The head revision's id, used to fetch the row's diffstat.
24− pub head_rev: Option<String>,
23 pub revision_count: i64,
26− /// The changes this one is stacked on.
27− pub parents: Vec<String>,
28− pub comments: i64,
29− /// Verdicts given on this change, most recent per reviewer.
30− pub reviewers: Vec<Reviewer>,
31− /// Added/deleted lines in the head revision. `None` when the store could
32− /// not produce a diff for it.
33− pub diffstat: Option<(usize, usize)>,
34− /// Depth within its stack, and how many changes that stack has. Filled in
35− /// by [`arrange`]; `stack_size` of 1 means "not in a stack".
36− pub depth: usize,
37− pub stack_size: usize,
38−}
39−
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− }
24+ /// Changes stacked directly on top of this one.
25+ pub children: Vec<String>,
26}
27
74−/// The change id — the product's central concept made visible.
28+/// The change chip — the product's central concept made visible.
29///
76−/// Rendered inline rather than boxed: this appears on nearly every row of every
77−/// listing, and a chip around each one turns a dense table into a field of
78−/// pills. The shortest-prefix half carries `--identity`, the remainder fades —
79−/// the emphasis is on the part you actually type.
80−///
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.
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.
34pub fn change_chip(change_id: &str, synthetic: bool) -> Markup {
35 html! {
36 @if synthetic {
@@ -89,549 +38,109 @@
38 "git"
39 }
40 } @else {
92− span .cid title=(format!("jj change id: {change_id}")) {
93− (crate::views::repo::cid_parts(change_id))
41+ span .chip.chip-change title=(format!("jj change id: {change_id}")) {
42+ (&change_id[..12.min(change_id.len())])
43 }
44 }
45 }
46}
47
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.
48pub 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−
49 html! {
114− span .badge.(class) {
115− span .glyph aria-hidden="true" { (glyph) }
116− (label)
117− }
118− }
119−}
120−
121−/// Reorder a page of changes so stacks appear as stacks.
122−///
123−/// Changes come out of the database newest-first, which scatters the members of
124−/// a stack through the list. A stack is the thing branches cannot represent, so
125−/// the list has to show one: this walks the parent/child edges *within the
126−/// loaded page*, assigns each row a depth, and re-emits the page with each
127−/// stack contiguous and deepest-first — the same order `jj log` uses, tip at
128−/// the top.
129−///
130−/// Edges pointing outside the page are ignored rather than followed. The page
131−/// is a filtered view (open only, or a revset), and silently pulling in a
132−/// merged parent to complete a stack would mean the list showed rows the
133−/// filter excluded.
134−///
135−/// Rows keep their relative recency: a stack takes the list position of its
136−/// most recently updated member.
137−pub fn arrange(mut rows: Vec<ChangeRow>) -> Vec<ChangeRow> {
138− use std::collections::{HashMap, HashSet};
139−
140− let present: HashSet<&str> = rows.iter().map(|r| r.change_id.as_str()).collect();
141−
142− // Depth = how many ancestors this row has inside the page. Bounded by the
143− // page size, so a cycle (which the indexer should never produce, but a
144− // corrupted edge table could) terminates instead of hanging.
145− let parents: HashMap<String, Vec<String>> = rows
146− .iter()
147− .map(|r| {
148− let ps = r
149− .parents
150− .iter()
151− .filter(|p| present.contains(p.as_str()))
152− .cloned()
153− .collect();
154− (r.change_id.clone(), ps)
155− })
156− .collect();
157−
158− let limit = rows.len();
159− let depth_of = |start: &str| -> usize {
160− let mut depth = 0;
161− let mut cur = start.to_string();
162− let mut seen = HashSet::new();
163− while seen.insert(cur.clone()) && depth < limit {
164− match parents.get(&cur).and_then(|ps| ps.first()) {
165− Some(p) => {
166− depth += 1;
167− cur = p.clone();
168− }
169− None => break,
170− }
171− }
172− depth
173− };
174−
175− // Group id = the bottom of the stack, found by walking down to a row with
176− // no parent in the page.
177− let root_of = |start: &str| -> String {
178− let mut cur = start.to_string();
179− let mut seen = HashSet::new();
180− while seen.insert(cur.clone()) {
181− match parents.get(&cur).and_then(|ps| ps.first()) {
182− Some(p) => cur = p.clone(),
183− None => break,
184− }
185− }
186− cur
187− };
188−
189− let mut roots: HashMap<String, String> = HashMap::new();
190− for r in &mut rows {
191− r.depth = depth_of(&r.change_id);
192− roots.insert(r.change_id.clone(), root_of(&r.change_id));
193− }
194−
195− let mut sizes: HashMap<&str, usize> = HashMap::new();
196− for root in roots.values() {
197− *sizes.entry(root.as_str()).or_insert(0) += 1;
198− }
199− for r in &mut rows {
200− r.stack_size = sizes[roots[&r.change_id].as_str()];
201− }
202−
203− // A stack inherits the list position of its freshest member, so re-grouping
204− // never pushes active work below stale work.
205− let mut order: Vec<&str> = Vec::new();
206− let mut seen: HashSet<&str> = HashSet::new();
207− for r in &rows {
208− let root = roots[&r.change_id].as_str();
209− if seen.insert(root) {
210− order.push(root);
50+ @if conflicted {
51+ span .badge.badge-conflict { "conflict" }
52 }
212− }
213− let rank: HashMap<&str, usize> = order.iter().enumerate().map(|(i, r)| (*r, i)).collect();
214−
215− rows.sort_by_key(|r| {
216− let root = roots[&r.change_id].as_str();
217− // Deepest first within a stack: the tip is what you are working on.
218− (rank[root], usize::MAX - r.depth)
219− });
220− rows
221−}
222−
223−#[cfg(test)]
224−mod arrange_tests {
225− use super::{arrange, ChangeRow};
226− use chrono::Utc;
227−
228− fn row(id: &str, parents: &[&str]) -> ChangeRow {
229− ChangeRow {
230− number: 1,
231− change_id: id.into(),
232− synthetic: false,
233− title: id.into(),
234− state: "open".into(),
235− conflicted: false,
236− updated_at: Utc::now(),
237− author: None,
238− author_name: None,
239− head_rev: None,
240− revision_count: 1,
241− parents: parents.iter().map(|s| (*s).to_string()).collect(),
242− comments: 0,
243− reviewers: vec![],
244− diffstat: None,
245− depth: 0,
246− stack_size: 0,
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" },
58 }
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);
59 }
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− }
60}
61
62pub struct ListFilters<'a> {
63 pub state: &'a str,
64 pub revset: &'a str,
65 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>,
66}
67
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.
68pub fn list(ctx: &RepoContext, rows: &[ChangeRow], f: ListFilters<'_>) -> Markup {
69 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();
70
71 html! {
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− }
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 { "" }) {
78+ (label)
79 }
374− None => {
375− span .revset-status {
376− span aria-hidden="true" { "✓" }
377− " " (rows.len()) @if rows.len() == 1 { " change" } @else { " changes" }
378− }
379− }
80 }
381− button .btn.btn-mono type="submit" { "filter" }
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";
85+ }
86+ input type="hidden" name="state" value=(f.state);
87+ button .btn type="submit" { "Filter" }
88 }
383−
384− nav .subtabs.ruled.filter-tabs aria-label="Filter by state" {
385− @for (key, label, glyph, colour, n) in &tabs {
386− a href=(tab_href(key)) .active[f.state == *key]
387− aria-current=[(f.state == *key).then_some("page")] {
388− span .filter-glyph aria-hidden="true"
389− style=[(f.state == *key).then(|| format!("color:{colour}"))] {
390− (glyph)
391− }
392− (label)
393− span .tab-count { (n) }
394− }
89+ @if let Some(e) = f.revset_error {
90+ div .banner.banner-error style="margin-top:12px" {
91+ strong { "Unsupported expression. " }
92+ (e)
93 }
94 }
95+ }
96
398− @if conflicted_here > 0 && f.state != "conflicted" {
399− p .list-summary {
400− (conflicted_here)
401− @if conflicted_here == 1 { " of these is conflicted" }
402− @else { " of these are conflicted" }
403− }
97+ @if rows.is_empty() {
98+ div .empty {
99+ h2 { "No changes" }
100+ p { "Push with " code { "jj git push" } " and changes appear here." }
101 }
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."
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 }
438− (change_list_row(&base, r, now))
439− }
440− }
441− }
442− }
443−
444− aside .columns-aside {
445− div .aside-block {
446− div .label-condensed { "Saved revsets" }
447− @for expr in ["mine()", "conflict()", "author(me) & ~merged()"] {
448− a .saved-revset href=(format!("{base}/changes?state=all&revset={}",
449− crate::routes::settings::urlencode(expr)))
450− .is-current[f.revset == expr] {
451− (expr)
452− }
453− }
454− }
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− )) {}
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+ }
137 }
138 }
577− span .cl-add { "+" (add) }
578− span .cl-del { "−" (del) }
139 }
580− None => span .faint { "—" },
140 }
582− }
583−
584− div .cl-when title=(r.updated_at.format("%Y-%m-%d %H:%M UTC").to_string()) {
585− (crate::views::relative_time(r.updated_at, now))
141 }
142 }
588− }
589−}
590−
591−/// Five cells, filled green in proportion to how much of the diff was additions.
592−///
593−/// The same idea as GitHub's diffstat bar. A pure deletion shows five red
594−/// cells, a pure addition five green, and the mix in between is rounded — it is
595−/// a glanceable ratio, not a measurement, which is why the exact numbers sit
596−/// beside it.
597−fn bars(add: usize, del: usize) -> [bool; 5] {
598− let total = add + del;
599− if total == 0 {
600− return [false; 5];
601− }
602− let green = ((add as f64 / total as f64) * 5.0).round() as usize;
603− std::array::from_fn(|i| i < green)
604−}
605−
606−/// Up to two letters from a handle, for the reviewer marks.
607−fn initials(handle: &str) -> String {
608− handle.chars().take(2).collect()
609−}
610−
611−#[cfg(test)]
612−mod bars_tests {
613− use super::{bars, humanise_minutes};
614−
615− #[test]
616− fn the_ratio_reads_the_way_the_diff_does() {
617− assert_eq!(bars(100, 0), [true; 5]);
618− assert_eq!(bars(0, 100), [false; 5]);
619− assert_eq!(bars(50, 50), [true, true, true, false, false]);
620− assert_eq!(bars(20, 80), [true, false, false, false, false]);
621− }
622−
623− /// An empty diff must not divide by zero.
624− #[test]
625− fn an_empty_diff_is_all_empty() {
626− assert_eq!(bars(0, 0), [false; 5]);
143 }
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− }
144}
145
146/// Disambiguation page for an ambiguous change-id prefix (spec §7).
@@ -663,21 +172,18 @@
172 use super::*;
173
174 #[test]
666− fn synthetic_changes_render_without_a_change_id() {
175+ fn synthetic_changes_render_without_a_change_chip() {
176 // Spec §4: "Do not pretend a synthetic identity is a real change ID."
177 let synthetic = change_chip("ppwkwxvrwvxxyttp0000000000000000", true).into_string();
178 assert!(synthetic.contains("git"));
179 assert!(
671− !synthetic.contains("cid-p"),
672− "a synthetic id must not get the identity treatment: {synthetic}"
180+ !synthetic.contains("chip-change"),
181+ "a synthetic id must not use the change-identity chip: {synthetic}"
182 );
183
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.
184 let real = change_chip("klxqnvpqlnlvtkmuqmtmxktlnvnomvwv", false).into_string();
679− assert!(real.contains(r#"class="cid-p">klxq<"#), "{real}");
680− assert!(real.contains(r#"class="cid-r">nvpqlnlv<"#), "{real}");
185+ assert!(real.contains("chip-change"));
186+ assert!(real.contains("klxqnvpqlnlv"));
187 }
188
189 #[test]
crates/df-web/src/views/design.rs+25 −118
@@ -22,9 +22,9 @@
22];
23
24const ACCENTS: &[(&str, &str)] = &[
25− ("--identity", "change ids, stacks, revisions"),
26− ("--action", "links, the one primary move"),
27− ("--identity-wash", "stacked rows, selected revisions"),
25+ ("--brand", "the one primary move on a page"),
26+ ("--identity", "change ids, stack rails"),
27+ ("--action", "links"),
28];
29
30const STATES: &[(&str, &str)] = &[
@@ -83,92 +83,14 @@
83
84 (section("type", "Typography", html! {
85 div .type-specimens {
86− @for (spec, sample, style) in [
87− ("44 / 48 · 600", "Display", "font-size:var(--text-3xl);line-height:48px;font-weight:600;letter-spacing:-0.02em"),
88− ("24 / 32 · 600", "Page title", "font-size:var(--text-xl);line-height:32px;font-weight:600"),
89− ("20 / 28 · 600", "Section title", "font-size:var(--text-lg);line-height:28px;font-weight:600"),
90− ("16 / 24 · 400", "Lead paragraph", "font-size:var(--text-md);line-height:24px"),
91− ("14 / 21 · 400", "Body and row titles", "font-size:var(--text-base);line-height:21px"),
92− ("12.5 / 18 · 400", "Metadata, secondary", "font-size:var(--text-sm);line-height:18px;color:var(--text-dim)"),
93− ("11 · mono", "kntqzsqtwrln +18 −4", "font-family:var(--font-mono);font-size:var(--text-xs);color:var(--text-faint)"),
94− ("11 · condensed", "EYEBROW LABEL", "font-family:var(--font-condensed);font-size:var(--text-xs);font-weight:500;letter-spacing:0.06em;text-transform:uppercase;color:var(--text-dim)"),
95− ] {
96− div .type-row {
97− span .type-spec { (spec) }
98− span style=(style) { (sample) }
99− }
100− }
101− }
102− }))
103−
104− (section("states", "Change states", html! {
105− div .state-list {
106− @for (state, conflicted, meaning) in [
107− ("open", false, "pushed and reviewable"),
108− ("open", true, "mid-thought, still reviewable"),
109− ("merged", false, "landed on a bookmark"),
110− ("abandoned", false, "closed without landing"),
111− ("draft", false, "the author's own flag; never inferred from a push"),
112− ] {
113− div .state-row {
114− (state_badge(state, conflicted))
115− span .dim { (meaning) }
116− }
117− }
118− }
119− p .hint.measure {
120− "Conflicted is not a fifth state — it is something an open change can be. \
121− That is why it renders as one pill rather than two, and why it is magenta \
122− rather than red: a conflict is a state, not a failure."
123− }
124− }))
125−
126− (section("keys", "Keycaps", html! {
127− div .design-row {
128− @for k in ["⌘K", "j", "k", "↵", "y", "t", "esc", "?"] {
129− span .kbd { (k) }
130− }
131− }
132− p .hint.measure {
133− "Only ⌘K is bound. The rest are drawn but not wired — a keycap that does \
134− nothing is a promise the product has not kept, so they appear here and \
135− nowhere else."
136− }
137− }))
138−
139− (section("diffrows", "Diff rows", html! {
140− div .filelist style="max-width:720px" {
141− @for (class, ln, text) in [
142− ("diff-hunk", "", "@@ hunk header — surface-raised, no rule"),
143− ("line-add", "137", "+ addition — add-bg with a 2px add rule"),
144− ("line-del", "136", "- deletion — del-bg with a 2px del rule"),
145− ("line-ctx", "149", " context — transparent, body colour"),
146− ] {
147− div .diffline.(class) {
148− span .diff-ln { (ln) }
149− span .diff-text { (text) }
150− }
151− }
152− }
153− }))
154−
155− (section("stats", "Stat lines", html! {
156− div style="max-width:240px" {
157− @for (k, v, colour) in [
158− ("Open changes", "128", "var(--open)"),
159− ("Conflicted", "3", "var(--conflict)"),
160− ("Median time to first review", "42m", "var(--text-dim)"),
161− ] {
162− div .dotline {
163− span .dotline-key { (k) }
164− span .dotline-val style=(format!("color:{colour}")) { (v) }
165− }
166− }
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" }
93 }
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− }
94 }))
95
96 (section("buttons", "Buttons", html! {
@@ -183,10 +105,9 @@
105 a .btn.btn-primary.btn-lg href="#buttons" { "Large primary" }
106 a .btn.btn-lg href="#buttons" { "Large default" }
107 }
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."
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."
111 }
112 }))
113
@@ -288,23 +209,16 @@
209 }
210
211 section {
291− h2 { "Two accents, two jobs" }
292− p .dim {
293− "Gold is " em { "identity" } " — change ids, stack rails, the revision
294− timeline, the wash behind a stacked row. Every place the interface is
295− pointing at a thing that keeps its name through a rewrite. Teal is "
296− em { "action" } " — links, and the one primary control on a page. The test
297− when adding an element: does it " em { "name" } " a change, or does it "
298− em { "do" } " something? It is never both. A gold button or a teal change
299− id breaks the only colour rule the interface has, and once either starts
300− leaking the other stops meaning anything."
301− }
212+ h2 { "Three accents, three jobs" }
213 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."
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."
222 }
223 }
224
@@ -343,16 +257,9 @@
257 "Radius is 3px, borders are one hairline, and shadows appear only on things
258 that genuinely float. The pages that matter — change lists, diffs, file
259 trees — are read for hours, and every pixel of chrome is one not spent on
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."
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."
263 }
264 }
265
crates/df-web/src/views/edit.rs+38 −38
@@ -33,56 +33,56 @@
33 let cancel = format!("{base}/blob/{}/{}", e.bookmark, e.path);
34
35 html! {
36− div .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− }
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+ }
42
43− @if let Some(msg) = e.error {
44− div .banner.banner-error role="alert" { (msg) }
45− }
43+ @if let Some(msg) = e.error {
44+ div .banner.banner-error role="alert" { (msg) }
45+ }
46
47− form method="post" action=(action) data-editor-form="1" {
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);
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);
52
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) }
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) }
62 }
59− span .spacer {}
60− span .editor-status.mono { "editing on " (e.bookmark) }
63 }
64
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) }
65+ div .field {
66+ label for="message" { "Commit message" }
67+ input type="text" id="message" name="message" maxlength="500"
68+ 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
73− div .editor-foot {
74− label .sr-only for="message" { "Commit message" }
75− input type="text" id="message" name="message" maxlength="500"
76− placeholder=(format!("Update {}", e.path));
76+ div .row {
77 button .btn.btn-primary type="submit" { "Commit changes" }
78+ a .btn href=(cancel) { "Cancel" }
79 }
80 }
80− }
81
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."
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+ }
86 }
87 }
88}
crates/df-web/src/views/issue.rs+80 −138
@@ -52,88 +52,66 @@
52 pub all_labels: &'a [Label],
53}
54
55−/// The issue list.
56−///
57−/// One row per issue, on the same grammar as the change list: a state glyph, a
58−/// number, the title, and metadata pinned to the right. The linked-change
59−/// column is the one thing here a general issue tracker does not have — an
60−/// issue closes when a change that references it merges, so the change is the
61−/// most useful thing to show beside it.
55pub fn list(ctx: &RepoContext, rows: &[IssueRow], f: ListFilters<'_>) -> Markup {
56 let base = ctx.base();
64− let now = Utc::now();
65−
57 html! {
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 {
58+ div .panel {
59+ div .row style="margin-bottom:14px" {
60 @for (key, label) in [("open", "Open"), ("closed", "Closed"), ("all", "All")] {
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")] {
61+ a href=(format!("{base}/issues?state={key}"))
62+ style=(if f.state == key { "color:var(--text);font-weight:500" } else { "" }) {
63 (label)
64 }
65 }
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) }
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" }
78 }
93− }
94− input type="text" name="assignee" value=[f.assignee]
95− placeholder="assignee" aria-label="Filter by assignee";
96− button .btn.btn-mono type="submit" { "filter" }
97− }
79
99− @if rows.is_empty() {
100− div .empty {
101− h2 { "No issues" }
102− p { "Nothing matches this filter." }
80+ a .btn.btn-primary href=(format!("{base}/issues/new")) { "New issue" }
81 }
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)
82+
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" }
97+ }
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)) }
102+ }
103+ div .row style="margin-top:6px;gap:10px" {
104+ span .faint { "#" (r.number) }
105+ @if let Some(a) = &r.author { span .faint { "by " (a) } }
106+ @if !r.assignees.is_empty() {
107+ span .faint { "assigned to " (r.assignees.join(", ")) }
108 }
109+ @if r.comment_count > 0 {
110+ span .faint { (r.comment_count) " comment(s)" }
111+ }
112+ span .faint { (r.updated_at.format("%Y-%m-%d").to_string()) }
113 }
126− }
127− @if let Some(a) = &r.author {
128− span .issue-meta { (a) }
114 }
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− }
115 }
116 }
117 }
@@ -204,35 +182,45 @@
182
183pub fn detail(ctx: &RepoContext, d: Detail<'_>) -> Markup {
184 let base = format!("{}/issues/{}", ctx.base(), d.number);
207− let closed = d.state == "closed";
185
209− let main = html! {
210− a .backlink href=(format!("{}/issues", ctx.base())) { "← issues" }
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+ }
202
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" }
203+ @if !d.assignees.is_empty() {
204+ p .dim style="margin-top:8px" { "Assigned to " (d.assignees.join(", ")) }
205 }
217− span .faint.mono { "#" (d.number) }
218− }
219− h1 .measure { (d.title) }
206
221− @if !d.body_html.is_empty() {
222− div .issue-body.markdown-body { (PreEscaped(d.body_html)) }
207+ @if !d.body_html.is_empty() {
208+ div .markdown-body style="margin-top:16px" { (PreEscaped(d.body_html)) }
209+ }
210 }
211
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)
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+ }
223 }
235− @if kind == "change" && !closed { " — closes on merge" }
224 }
225 }
226 }
@@ -308,54 +296,8 @@
296 value=(if d.state == "closed" { "open" } else { "closed" });
297 button .btn type="submit" {
298 @if d.state == "closed" { "Reopen issue" } @else { "Close issue" }
311− }
312− }
313− }
314− }
315− };
316−
317− html! {
318− div .columns.columns-repo {
319− div .columns-main { (main) }
320− aside .columns-aside.is-sticky {
321− div .aside-block {
322− div .label-condensed { "Details" }
323− @if let Some(a) = d.author {
324− div .dotline {
325− span .dotline-key { "Author" }
326− span .dotline-val { (crate::views::user_link(a)) }
327− }
328− }
329− div .dotline {
330− span .dotline-key { "Opened" }
331− span .dotline-val
332− title=(d.created_at.format("%Y-%m-%d %H:%M UTC").to_string()) {
333− (crate::views::relative_time(d.created_at, Utc::now())) " ago"
334− }
335− }
336− div .dotline {
337− span .dotline-key { "Comments" }
338− span .dotline-val { (d.comments.len()) }
299 }
300 }
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− }
301 }
302 }
303 }
@@ -365,7 +307,7 @@
307 html! {
308 div .comment {
309 div .row {
368− strong { (crate::views::user_link(&c.author)) }
310+ strong { (c.author) }
311 span .faint { (c.created_at.format("%Y-%m-%d %H:%M").to_string()) }
312 @if c.edited { span .faint { "edited" } }
313 }
crates/df-web/src/views/layout.rs+59 −221
@@ -19,44 +19,27 @@
19 pub nonce: &'a str,
20}
21
22−/// The wordmark: a bordered `df` tile beside the name, both monospace.
22+/// The wordmark: a vermilion triangle with the page ground knocked out of it,
23+/// beside the name.
24///
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.
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.
29fn brand(href: &str) -> Markup {
30 html! {
31 a .brand href=(href) {
30− span .brand-mark aria-hidden="true" { "df" }
31− span { "dogfood" }
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" }
37 }
38 }
39}
40
41/// Wrap page content in the site chrome.
42pub 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 {
43 html! {
44 (DOCTYPE)
45 html lang="en" {
@@ -66,110 +49,83 @@
49 title { (chrome.title) " · Dogfood" }
50 link rel="stylesheet" href="/assets/app.css";
51 meta name="color-scheme" content="dark light";
69− meta name="theme-color" media="(prefers-color-scheme: light)" content="#f6f6f4";
70− meta name="theme-color" media="(prefers-color-scheme: dark)" content="#15161a";
52+ meta name="theme-color" media="(prefers-color-scheme: light)" content="#f4f1ea";
53+ meta name="theme-color" media="(prefers-color-scheme: dark)" content="#12100e";
54 // Blocking (no `defer`) and first, so a stored "light"
55 // preference lands before the default dark theme paints.
56 script src="/assets/theme-init.js" nonce=(chrome.nonce) {}
57 }
75− body hx-headers=(format!(r#"{{"x-csrf-token": "{}"}}"#, chrome.csrf)) {
58+ body .grid-field
59+ hx-headers=(format!(r#"{{"x-csrf-token": "{}"}}"#, chrome.csrf))
60+ {
61 // The first focusable thing on the page, visible only when
62 // focused. Without it a keyboard user tabs through the whole
63 // masthead on every page before reaching the content.
64 a .skip-link href="#main" { "Skip to content" }
65
66 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" {}
70 div .masthead-inner {
83− div .masthead-group.masthead-group-start {
84− (brand("/"))
85− span .vrule aria-hidden="true" {}
86− nav .masthead-nav aria-label="Main" {
87− @if chrome.user.is_some() {
88− a href="/dashboard" { "Dashboard" }
89− a href="/new" { "New repository" }
90− }
91− a href="/design" { "Design system" }
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";
77+ }
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" {}
89+ }
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 }
94−
95− // Without scripting this is exactly what it looks like:
96− // a link to the search page. palette.js upgrades it in
97− // place and only then advertises the shortcut. Centred
98− // in its own grid column so its position holds steady
99− // regardless of how wide the groups either side of it
100− // are.
101− a .jump href="/search" data-palette-open {
102− span { "Jump to…" }
103− span .kbd data-palette-kbd hidden aria-hidden="true" { "⌘K" }
104− }
105−
106− div .masthead-group.masthead-group-end {
107− // JS-only: there is no server-side notion of theme
108− // to toggle without it, so it starts hidden and
109− // theme.js reveals it. The site's default dark
110− // theme is what every visitor without scripting
111− // sees.
112− button .theme-toggle type="button" data-theme-toggle
113− hidden aria-label="Switch between dark and light theme" {
114− span data-theme-label { "dark" }
115− }
116−
117− nav .masthead-account aria-label="Account" {
118− @if let Some(u) = chrome.user {
119− // A `<details>`, so the menu opens and
120− // closes with no JavaScript at all — Sign
121− // out is one tap away, but not a stray tap
122− // away.
123− details .switcher {
124− summary .btn.btn-mono {
125− span .account-handle { (u.handle) }
126− span .faint aria-hidden="true" { "▾" }
127− }
128− div .switcher-menu.switcher-menu-end {
129− a .switcher-item href="/settings" { "Settings" }
130− form method="post" action="/logout" {
131− input type="hidden" name="_csrf" value=(chrome.csrf);
132− button .switcher-item.switcher-item-danger type="submit" {
133− "Sign out"
134− }
135− }
136− }
137− }
138− } @else {
139− a .btn href="/login" { "Sign in" }
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" }
103 }
104+ } @else {
105+ a .btn href="/login" { "Sign in" }
106+ a .btn.btn-primary href="/login" { "Start free" }
107 }
108 }
109 }
144− }
145−
146− @if let Some(bar) = bar {
147− (bar)
110 }
111
112 // `tabindex="-1"` so the skip link can move focus here, not
113 // just scroll to it — without it the next Tab press would go
114 // back to the top of the page.
153− main id="main" tabindex="-1" .flush[!wrap] {
154− @if wrap {
155− div .wrap { (content) }
156− } @else {
115+ main id="main" tabindex="-1" {
116+ div .wrap {
117 (content)
118 }
119 }
160−
161− (palette())
120
121 footer {
122 div .wrap {
165− span .footer-mark { "dogfood" }
166− span .dim { "Built on jj. Speaks git. Run by people who use it all day." }
167− span .spacer {}
123+ span .footer-mark { "Dogfood" }
124+ span { "code hosting built on Jujutsu" }
125 a href="https://github.com/jj-vcs/jj" { "jj" }
126 // A component sheet nobody can find does not get kept
127 // up to date, so it gets a real link.
171− a href="/design" { "design" }
172− a href="/design/rationale" { "rationale" }
128+ a href="/design" { "Design" }
129 span .footer-end .mono { "© 2026" }
130 }
131 }
@@ -178,130 +134,11 @@
134 // every link and form still works as plain HTML.
135 script src="/assets/htmx.min.js" nonce=(chrome.nonce) defer {}
136 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) "”" }
137 }
138 }
139 }
140}
141
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−
142/// A minimal error page rendered without request context.
143///
144/// Used by the error type, which runs after extractors and so cannot rely on
@@ -316,9 +153,10 @@
153 title { (status.as_u16()) " · Dogfood" }
154 link rel="stylesheet" href="/assets/app.css";
155 }
319− body {
156+ body .grid-field {
157 a .skip-link href="#main" { "Skip to content" }
158 header .masthead {
159+ span .masthead-rule aria-hidden="true" {}
160 div .masthead-inner {
161 (brand("/"))
162 }
crates/df-web/src/views/mod.rs+1 −29
@@ -13,37 +13,9 @@
13pub mod review;
14pub mod settings;
15
16−pub use layout::{error_page, page, page_full, page_with_bar, Chrome};
16+pub use layout::{error_page, page, Chrome};
17
18use chrono::{DateTime, Utc};
19−use maud::{html, Markup};
20−
21−/// A Dogfood handle, linked to its profile.
22−///
23−/// Every handle the UI renders is a person with a page, so linking is the
24−/// default rather than something each call site decides. Kept here rather than
25−/// in one view module because comments, reviews, timelines, and listings all
26−/// need it and none of them owns the concept.
27−pub fn user_link(handle: &str) -> Markup {
28− html! {
29− a .user-link href=(format!("/{handle}")) { (handle) }
30− }
31−}
32−
33−/// A person who may or may not have an account.
34−///
35−/// `handle` links; a bare commit name renders as text with a tooltip saying
36−/// why it does not. Only a commit with no name at all falls through to
37−/// "someone" — see `pages::actor`, which this generalises.
38−pub fn person(handle: Option<&str>, name: Option<&str>) -> Markup {
39− html! {
40− @match (handle, name.map(str::trim).filter(|n| !n.is_empty())) {
41− (Some(h), _) => (user_link(h)),
42− (None, Some(n)) => span title="this commit is not linked to a Dogfood account" { (n) },
43− (None, None) => span .faint { "someone" },
44− }
45− }
46−}
19
20/// A short, coarse "how long ago" label.
21///
crates/df-web/src/views/pages.rs+166 −375
@@ -7,12 +7,9 @@
7
8use crate::views::change::change_chip;
9use crate::views::relative_time;
10+use crate::views::repo::avatar;
11
12/// 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.
13pub struct FeedItem {
14 pub owner: String,
15 pub repo: String,
@@ -20,52 +17,12 @@
17 pub change_id: String,
18 pub synthetic: bool,
19 pub title: 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>,
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 /// The name the commit itself carries, used when no account matched.
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,
24+ pub author_name: Option<String>,
25 pub when: DateTime<Utc>,
43−}
44−
45−/// A bookmark and where it currently points.
46−pub struct BookmarkItem {
47− pub owner: String,
48− pub repo: String,
49− pub name: String,
50− pub protected: bool,
51− pub updated_at: DateTime<Utc>,
52−}
53−
54−/// The glyph and colour class for an event kind.
55−///
56−/// An unrecognised kind gets the neutral glyph rather than being dropped: an
57−/// event the UI does not know about still happened.
58−fn feed_glyph(kind: &str) -> (&'static str, &'static str) {
59− match kind {
60− "change.pushed" => ("↑", "is-push"),
61− "change.opened" | "change.reopened" | "change.ready" => ("○", "is-open"),
62− "change.reviewed" => ("✓", "is-review"),
63− "change.resolved" => ("✓", "is-review"),
64− "change.merged" => ("⤳", "is-merge"),
65− "change.conflicted" => ("◆", "is-conflict"),
66− "change.abandoned" => ("×", "is-abandon"),
67− _ => ("●", "is-open"),
68− }
26}
27
28/// Render whoever is responsible for something.
@@ -81,7 +38,11 @@
38/// readable history and an anonymous one.
39pub fn actor(handle: Option<&str>, name: Option<&str>) -> Markup {
40 html! {
84− span .feed-actor { (crate::views::person(handle, name)) }
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+ }
46 }
47}
48
@@ -101,162 +62,121 @@
62 "undo anything",
63 "no staging area",
64];
104−
105−/// Everything the landing page renders.
106−pub struct Landing<'a> {
107− /// The instance's own clone URL, e.g. `jj git clone https://host/owner/repo`.
108− pub clone_hint: &'a str,
109− /// `owner/name` of a real public repository, used in the example session so
110− /// the transcript names something a visitor can actually go and open.
111− pub sample_repo: Option<&'a str>,
112− pub feed: &'a [FeedItem],
113− pub repos: &'a [RepoSummary],
114− /// Open public changes that are part of a stack.
115− pub in_flight: &'a [StackItem],
116− /// Recently moved bookmarks across public repositories.
117− pub bookmarks: &'a [BookmarkItem],
118−}
65
66/// Landing page for signed-out visitors.
121−pub fn landing(l: Landing<'_>) -> Markup {
67+pub fn landing(clone_hint: &str, feed: &[FeedItem], repos: &[RepoSummary]) -> Markup {
68 let now = Utc::now();
123− let first_repo = l.repos.first().map(|r| format!("/{}/{}", r.owner, r.name));
69
70 html! {
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" }
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"
92 }
93 }
142− div .clone-box {
143− span .prompt aria-hidden="true" { "$" }
144− code { (l.clone_hint) }
145− }
94 }
147− (terminal(l.sample_repo))
95+ div .clone-box {
96+ code { (clone_hint) }
97+ }
98 }
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)
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+ }
108 }
109 }
110 }
111
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− }
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"
119 }
173− @if l.feed.is_empty() {
174− div .empty {
175− h2 { "Nothing public yet" }
176− p { "Activity in public repositories will show up here." }
177− }
178− } @else {
179− ul .feed {
180− @for item in l.feed { (feed_row(item, now)) }
181− }
182− div .feed-foot {
183− span {
184− "Every row is a change id. The commit behind it may be \
185− rewritten; the row will not move."
186− }
187− }
188− }
120 }
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− }
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." }
128 }
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)) }
129+ } @else {
130+ ul .feed {
131+ @for item in feed {
132+ (feed_row(item, now))
133 }
134 }
135 }
136 }
206− } }
137
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)
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+ }
152 }
153 }
154 }
155 }
228− }
229− }
156
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."
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+ }
164 }
239− }
240− div .repo-cards {
241− @for r in l.repos { (repo_card(r, now)) }
165 }
166 }
167 }
168 }
169
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" }
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"
180 }
181 }
182 }
@@ -264,145 +184,50 @@
184 }
185}
186
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−
187/// One feed row.
188///
189/// The exact timestamp goes in `title` because the visible label is coarse on
190/// purpose — see `relative_time`.
191fn feed_row(item: &FeedItem, now: DateTime<Utc>) -> Markup {
192 let href = format!("/{}/{}/changes/{}", item.owner, item.repo, item.number);
320− let (glyph, class) = feed_glyph(&item.kind);
193
194 html! {
195 li .feed-row {
324− span .feed-glyph.(class) aria-hidden="true" { (glyph) }
325− a .feed-repo href=(format!("/{}/{}", item.owner, item.repo)) {
326− span .owner { (item.owner) }
327− "/" (item.repo)
328− }
329− span .feed-verb {
330− (actor(item.actor.as_deref(), item.actor_name.as_deref()))
331− " " (activity_verb(&item.kind))
332− }
333− a .feed-title href=(href) { (item.title) }
334− (change_chip(&item.change_id, item.synthetic))
335− span .feed-age title=(item.when.format("%Y-%m-%d %H:%M UTC").to_string()) {
336− (relative_time(item.when, now))
337− }
338− }
339− }
340−}
341−
342−/// A change in an aside list, with the stack rail beside it.
343−fn stack_mini_row(s: &StackItem, now: DateTime<Utc>) -> Markup {
344− let href = format!("/{}/{}/changes/{}", s.owner, s.repo, s.number);
345− let (glyph, class) = if s.conflicted {
346− ("◆", "is-conflict")
347− } else {
348− ("○", "is-open")
349− };
350−
351− html! {
352− a .mini-row href=(href) {
353− span .mini-rail aria-hidden="true" {}
354− span .mini-glyph.(class) aria-hidden="true" { (glyph) }
355− (change_chip(&s.change_id, s.synthetic))
356− span .mini-title { (s.title) }
357− span .mini-age title=(s.when.format("%Y-%m-%d %H:%M UTC").to_string()) {
358− (relative_time(s.when, now))
359− }
360− }
361− }
362−}
363−
364−/// A bookmark and when it last moved.
365−///
366−/// Links to its repository's bookmark list rather than to the bookmark itself:
367−/// a bookmark has no page of its own, because it is a pointer, not a thing —
368−/// which is the distinction the whole product turns on.
369−fn bookmark_line(b: &BookmarkItem, now: DateTime<Utc>) -> Markup {
370− html! {
371− a .bookmark-line href=(format!("/{}/{}/bookmarks", b.owner, b.repo)) {
372− span .chip { (b.name) }
373− @if b.protected {
374− span .bookmark-flag { "protected" }
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+ }
210 }
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))
211+ div .feed-side {
212+ (change_chip(&item.change_id, item.synthetic))
213 }
214 }
215 }
216}
217
384−fn repo_card(r: &RepoSummary, now: DateTime<Utc>) -> Markup {
218+fn repo_card(r: &RepoSummary) -> Markup {
219 html! {
220 a .repo-card href=(format!("/{}/{}", r.owner, r.name)) {
221+ span .repo-card-rail aria-hidden="true" {}
222 span .repo-card-name {
388− (r.owner) "/" (r.name)
389− @if r.private { " " span .chip { "private" } }
223+ span .faint { (r.owner) "/" }
224+ span .repo-card-repo { (r.name) }
225+ @if r.private {
226+ span .chip { "private" }
227+ }
228 }
229 @if let Some(d) = &r.description {
230 span .repo-card-desc { (d) }
393− }
394− span .repo-card-meta {
395− @if r.open_changes > 0 {
396− span .is-open { "○ " (r.open_changes) }
397− }
398− @if r.conflicted > 0 {
399− span .is-conflict { "◆ " (r.conflicted) }
400− }
401− @if let Some(t) = r.pushed_at {
402− span .at-end title=(t.format("%Y-%m-%d %H:%M UTC").to_string()) {
403− (relative_time(t, now))
404− }
405− }
231 }
232 }
233 }
@@ -447,23 +272,17 @@
272}
273
274/// 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.
275pub fn dashboard(d: Dashboard<'_>) -> Markup {
276 let now = Utc::now();
277
278 html! {
279 div .dash-head {
280 div {
461− h1 .dash-title {
281+ h1 .display.dash-title {
282 "Welcome back, " span .dash-name { (d.user.label()) }
283 }
464− p .dim.section-note { "Here's what needs you across your repositories." }
284+ p .dim { "Here's what needs you across your repositories." }
285 }
466− span .spacer {}
286 a .btn.btn-primary href="/new" { "New repository" }
287 }
288
@@ -474,12 +293,12 @@
293 p { a .btn.btn-primary href="/new" { "New repository" } }
294 }
295 } @else {
477− div .columns {
478− div .columns-main {
479− section .dash-section aria-labelledby="awaiting-h" {
296+ div .landing-columns {
297+ div .landing-main {
298+ section aria-labelledby="awaiting-h" {
299 div .section-head {
481− h2 #awaiting-h .label-condensed { "Awaiting your review" }
482− span .live-indicator.tnum { (d.awaiting.len()) }
300+ h2 #awaiting-h .label-condensed.slash { "awaiting your review" }
301+ span .faint.mono.tnum { (d.awaiting.len()) }
302 }
303 @if d.awaiting.is_empty() {
304 p .dim.section-note { "Nothing is waiting on you." }
@@ -490,9 +309,9 @@
309 }
310 }
311
493− section .dash-section aria-labelledby="mine-h" {
312+ section aria-labelledby="mine-h" {
313 div .section-head {
495− h2 #mine-h .label-condensed { "Your open changes" }
314+ h2 #mine-h .label-condensed.slash { "your open changes" }
315 }
316 @if d.mine.is_empty() {
317 p .dim.section-note { "You have no open changes." }
@@ -503,9 +322,9 @@
322 }
323 }
324
506− section .dash-section aria-labelledby="activity-h" {
325+ section aria-labelledby="activity-h" {
326 div .section-head {
508− h2 #activity-h .label-condensed { "Watched activity" }
327+ h2 #activity-h .label-condensed.slash { "watched activity" }
328 }
329 @if d.activity.is_empty() {
330 p .dim.section-note { "No recent activity in your repositories." }
@@ -517,13 +336,11 @@
336 }
337 }
338
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− }
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" }
344 }
345 }
346 }
@@ -531,36 +348,29 @@
348 }
349}
350
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.
351fn dash_change_row(c: &DashChange, now: DateTime<Utc>) -> Markup {
352 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− };
353
354 html! {
355 li .feed-row {
552− span .feed-glyph.(class) aria-hidden="true" { (glyph) }
553− a .feed-repo href=(format!("/{}/{}", c.owner, c.repo)) {
554− span .owner { (c.owner) }
555− "/" (c.repo)
556− }
557− span .feed-verb {
558− (actor(c.author.as_deref(), c.author_name.as_deref()))
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+ }
370 }
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))
371+ div .feed-side {
372+ (crate::views::change::state_badge(&c.state, c.conflicted))
373+ (change_chip(&c.change_id, c.synthetic))
374 }
375 }
376 }
@@ -617,10 +427,6 @@
427 pub name: String,
428 pub description: Option<String>,
429 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>>,
430}
431
432/// The sign-in page.
@@ -630,49 +436,34 @@
436/// and a disabled control that says why is more honest than a live-looking
437/// button that does nothing. They carry `aria-disabled` and no `href`, so they
438/// are not in the tab order as if they were usable.
633−pub fn signin(sso_href: &str, clone_hint: &str, sample_repo: Option<&str>) -> Markup {
439+pub fn signin(sso_href: &str) -> Markup {
440 html! {
441 div .signin {
442 div .signin-intro {
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."
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."
448 }
449 }
450
644− div .signin-card {
645− a .btn.btn-primary.btn-block href=(sso_href) {
646− span aria-hidden="true" .mono { "↗" }
647− " Continue with your SSO provider"
648− }
649−
650− div .signin-or {
651− span .signin-rule aria-hidden="true" {}
652− span .label-condensed { "or" }
653− span .signin-rule aria-hidden="true" {}
654− }
451+ a .btn.btn-primary.btn-block href=(sso_href) { "Continue with SSO" }
452
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− }
453+ div .signin-or {
454+ span .signin-rule aria-hidden="true" {}
455+ span .faint.mono { "or" }
456+ span .signin-rule aria-hidden="true" {}
457 }
458
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) }
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"
462 }
463
674− @if let Some(r) = sample_repo {
675− a href=(format!("/{r}")) { "Browse a repo instead →" }
464+ p .hint.signin-foot {
465+ "New here? This instance is invitation-only — ask an administrator \
466+ for an invitation."
467 }
468 }
469 }
crates/df-web/src/views/repo.rs+86 −477
@@ -2,117 +2,48 @@
2
3use maud::{html, Markup, PreEscaped};
4
5−use df_store::{EntryKind, Revision, TreeEntry};
5+use df_store::{Bookmark, EntryKind, Revision, TreeEntry};
6
7use crate::repo_ctx::RepoContext;
8
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.
9+/// Repository header: name, visibility, and the browse tabs.
10pub fn header(ctx: &RepoContext, active: &str) -> Markup {
11 let base = ctx.base();
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)),
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")),
17 ];
18
19 html! {
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) }
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 }
41− span .badge { @if ctx.repo.is_public() { "public" } @else { "private" } }
42− span .vrule aria-hidden="true" {}
43−
44− nav .subtabs aria-label="Repository" {
45− @for (key, label, href, n) in &tabs {
46− a href=(href) .active[*key == active]
47− aria-current=[(*key == active).then_some("page")] {
48− (label)
49− @if let Some(n) = n {
50− span .tab-count { (n) }
51− }
52− }
53− }
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− }
27+ @if !ctx.repo.is_public() {
28+ span .chip { "private" }
29+ }
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)
39 }
40 }
61−
62− span .spacer {}
63− span .subnav-meta { (nav_meta(nav)) }
41+ @if ctx.access.can_change_settings() {
42+ a .subtabs-end href=(format!("{base}/settings")) { "Settings" }
43+ }
44 }
45 }
66− }
67−}
68−
69−/// The right-hand readout on the sub-bar.
70−///
71−/// Only the facts that are true get a clause: a repository with no conflicts
72−/// says nothing about conflicts rather than claiming "0 conflicted", and an
73−/// empty repository gets an empty bar instead of three zeroes.
74−fn nav_meta(nav: &crate::repo_ctx::RepoNav) -> String {
75− let plural = |n: i64, one: &str, many: &str| if n == 1 { one.to_string() } else { many.to_string() };
76−
77− let mut parts = Vec::new();
78− if nav.open_changes > 0 {
79− parts.push(format!("{} open", nav.open_changes));
80− }
81− if nav.conflicted > 0 {
82− parts.push(format!("{} conflicted", nav.conflicted));
83− }
84− if nav.bookmarks > 0 {
85− parts.push(format!(
86− "{} {}",
87− nav.bookmarks,
88− plural(nav.bookmarks, "bookmark", "bookmarks")
89− ));
90− }
91− parts.join(" · ")
92−}
93−
94−#[cfg(test)]
95−mod nav_meta_tests {
96− use super::nav_meta;
97− use crate::repo_ctx::RepoNav;
98−
99− #[test]
100− fn only_true_facts_get_a_clause() {
101− assert_eq!(
102− nav_meta(&RepoNav { open_changes: 128, conflicted: 3, bookmarks: 9, open_issues: 42 }),
103− "128 open · 3 conflicted · 9 bookmarks"
104− );
46 }
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− }
47}
48
49/// Clone instructions, shown on an empty repository.
@@ -177,10 +108,7 @@
108/// This is the *directory's* tip, not a per-file blame — see the note on
109/// `tree_listing`.
110pub struct TipCommit {
180− /// The name the commit carries.
111 pub author: String,
182− /// The account that name resolved to, when its email matched one.
183− pub author_handle: Option<String>,
112 pub summary: String,
113 pub when: chrono::DateTime<chrono::Utc>,
114 pub change_id: Option<String>,
@@ -214,130 +142,58 @@
142 path d="M9 1.5v3h3" stroke="currentColor" stroke-width="1.2" {}
143 }
144 }
217−}
218−
219−/// The last commit to touch one entry of a directory listing — the
220−/// GitHub-style message-and-date column, sourced from `last_commits_in_dir`.
221−pub struct EntryHistory {
222− pub summary: String,
223− pub when: chrono::DateTime<chrono::Utc>,
224− /// The jj change id, when the touching commit had one — lets the message
225− /// link to the change page. `None` for a plain-git commit; that history
226− /// still shows the message and date, just not as a link.
227− pub change_id: Option<String>,
228−}
229−
230−/// A bookmark as the sidebar, the switcher and the bookmarks page show it.
231−///
232−/// Read from the database rather than from git refs: the store knows a name and
233−/// an object id, but only the index knows *which change* that object belongs to,
234−/// and the change is the thing worth linking to.
235−pub struct MarkRow {
236− pub name: String,
237− pub protected: bool,
238− pub updated_at: chrono::DateTime<chrono::Utc>,
239− /// The change at the bookmark's tip, when the indexer knows one. `None` for
240− /// a bookmark pointing at a commit the indexer has not seen — which is a
241− /// real state after a restore, not a bug.
242− pub change_id: Option<String>,
243− pub number: Option<i64>,
244− pub title: Option<String>,
245−}
246−
247−/// The right-hand column on a repository's own page.
248−///
249−/// Only rendered at the repository root. On a nested directory the reader is
250−/// looking at files, and repeating the clone commands beside every folder is
251−/// noise.
252−pub struct RepoSidebar<'a> {
253− pub https: &'a str,
254− pub ssh: &'a str,
255− pub open_changes: i64,
256− pub conflicted: i64,
257− pub open_issues: i64,
258− /// Distinct commit authors seen by the indexer.
259− pub contributors: i64,
260− pub size_bytes: u64,
261− pub bookmarks: &'a [MarkRow],
145}
146
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−
147/// The directory listing.
148///
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;
149+/// The design puts a per-file "last change that touched this path" column here.
150+/// Dogfood cannot fill it: the index records changes and revisions but never
151+/// the paths a change touched, so there is nothing to join against. The column
152+/// is therefore left out rather than filled with a plausible-looking value —
153+/// showing the directory tip's message on every row would read as per-file
154+/// history and be wrong on all but one of them.
155+pub fn tree_listing(
156+ ctx: &RepoContext,
157+ rev_label: &str,
158+ path: &str,
159+ entries: &[TreeEntry],
160+ tip: Option<&TipCommit>,
161+ readme: Option<&(String, Markup)>,
162+) -> Markup {
163 let base = ctx.base();
296− let now = chrono::Utc::now();
164
298− let main = html! {
299− div .tree-crumbs {
300− (bookmark_switcher(&base, rev_label, path, sidebar.map(|s| s.bookmarks).unwrap_or(&[])))
165+ html! {
166+ div .row.tree-crumbs {
167+ span .label-condensed { "Browsing" }
168+ span .chip { (rev_label) }
169 (breadcrumbs(&base, rev_label, path))
170 }
171
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)))
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())])
180 }
312− span .commit-bar-msg { (t.summary) }
313− span .spacer {}
314− @if let Some(c) = &t.change_id {
315− a .cid href=(format!("{base}/changes/{c}"))
316− title=(format!("jj change id: {c}")) {
317− (cid_parts(c))
318− }
319− }
320− span .commit-bar-when
321− title=(t.when.format("%Y-%m-%d %H:%M UTC").to_string()) {
322− (crate::views::relative_time(t.when, now))
323− }
181 }
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+ }
186 }
187+ }
188
327− @if entries.is_empty() {
189+ @if entries.is_empty() {
190+ div .filelist.filelist-standalone {
191 p .dim style="padding:16px" { "This directory is empty." }
329− } @else {
192+ }
193+ } @else {
194+ div .filelist .filelist-standalone[tip.is_none()] {
195 table {
196 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− }
197 tbody {
198 @if !path.is_empty() {
199 tr {
@@ -346,7 +202,7 @@
202 (dir_icon())
203 }
204 }
349− td .filelist-name colspan="4" {
205+ td .filelist-name colspan="2" {
206 a .mono href=(parent_link(&base, rev_label, path)) { ".." }
207 }
208 }
@@ -367,41 +223,8 @@
223 span .faint .filelist-note { "symlink" }
224 }
225 }
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− }
226+ td .filelist-size.faint.tnum {
227+ @if let Some(s) = e.size { (human_size(s)) }
228 }
229 }
230 }
@@ -411,164 +234,17 @@
234 }
235
236 @if let Some((name, body)) = readme {
414− article .filelist.readme {
237+ article .readme {
238 div .readme-head {
416− span .mono { (name) }
239+ (file_icon())
240+ span .mono.dim { (name) }
241 }
242 div .readme-body.markdown-body { (body) }
243 }
244 }
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− },
245 }
246}
433−
434−/// Split a change id into its short prefix and the rest.
435−///
436−/// Twelve characters is the display length the product settled on; the first
437−/// four carry `--identity` because that is the part people actually type and
438−/// paste. Selecting across both halves still copies one string.
439−pub fn cid_parts(change_id: &str) -> Markup {
440− let shown = &change_id[..12.min(change_id.len())];
441− let split = 4.min(shown.len());
247
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−
248/// How a markdown file is being shown.
249///
250/// `None` means the file is not markdown, so no toggle appears at all.
@@ -589,8 +265,6 @@
265 pub symbols: &'a [df_render::symbols::Symbol],
266 /// The last commit that modified this file.
267 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>,
268 /// Per-line blame data (when the user toggled blame on).
269 pub blame: Option<&'a [df_store::BlameLine]>,
270 /// Whether blame was requested.
@@ -604,7 +278,6 @@
278 sidebar_dir: "",
279 symbols: &[],
280 last_commit: None,
607− last_commit_handle: None,
281 blame: None,
282 wants_blame: false,
283 }
@@ -640,10 +313,8 @@
313 // Last commit bar for this file.
314 @if let Some(lc) = extras.last_commit {
315 div .file-commit-bar {
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− }
316+ (avatar(&lc.author.name))
317+ span .commit-bar-author { (lc.author.name) }
318 span .commit-bar-msg { (lc.summary()) }
319 @if let Some(c) = &lc.change_id {
320 span .chip.chip-change title="change id" {
@@ -829,15 +500,7 @@
500}
501
502/// Commit log.
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 {
503+pub fn log_view(ctx: &RepoContext, rev_label: &str, revisions: &[Revision]) -> Markup {
504 let base = ctx.base();
505 html! {
506 div .panel {
@@ -867,12 +530,7 @@
530 } @else {
531 span .chip title="authored with plain git" { "git" }
532 }
870− span .faint {
871− (crate::views::person(
872− handles.get(&r.author.email).map(String::as_str),
873− Some(&r.author.name),
874− ))
875− }
533+ span .faint { (r.author.name) }
534 span .faint { (r.author.when.format("%Y-%m-%d %H:%M").to_string()) }
535 }
536 }
@@ -884,75 +542,26 @@
542}
543
544/// Bookmark list.
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 {
545+pub fn bookmarks_view(ctx: &RepoContext, marks: &[Bookmark]) -> Markup {
546 let base = ctx.base();
895− let now = chrono::Utc::now();
896−
547 html! {
898− div .page-head {
899− div {
900− h1 { "Bookmarks" }
901− p .dim.section-note {
902− "Bookmarks are movable pointers, not identities. Reviews attach to changes."
903− }
904− }
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>" } "." }
548+ div .panel {
549+ h2 { "Bookmarks" }
550+ p .dim {
551+ "Bookmarks are movable pointers, not identities. Reviews attach to changes."
552 }
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− }
553+ @if marks.is_empty() {
554+ p .dim { "No bookmarks yet." }
555+ } @else {
556+ table style="width:100%;border-collapse:collapse" {
557 tbody {
558 @for m in marks {
559 tr {
927− td .bookmark-name {
928− a .mono href=(format!("{base}/tree/{}/", m.name)) { (m.name) }
560+ td style="padding:8px 4px;border-bottom:1px solid var(--border)" {
561+ a href=(format!("{base}/tree/{}/", m.name)) { (m.name) }
562 @if m.name == ctx.repo.default_bookmark {
930− span .bookmark-flag { "default" }
931− }
932− @if m.protected {
933− span .bookmark-flag { "protected" }
934− }
935− }
936− td .bookmark-points {
937− @match (&m.change_id, m.number) {
938− (Some(c), Some(n)) => {
939− a .cid href=(format!("{base}/changes/{n}"))
940− title=(format!("jj change id: {c}")) {
941− (cid_parts(c))
942− }
943− }
944− // A bookmark the indexer has not caught
945− // up with. Saying so beats an empty cell
946− // that reads as a rendering bug.
947− _ => span .faint.mono { "not indexed" },
563+ span .chip style="margin-left:8px" { "default" }
564 }
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))
565 }
566 }
567 }
crates/df-web/src/views/review.rs+118 −534
@@ -33,198 +33,57 @@
33 /// The name the commit itself carries, used when no account matched.
34 pub author_name: Option<&'a str>,
35 pub revision_count: usize,
36− /// The head revision's commit id, abbreviated.
37− pub head_commit: Option<&'a str>,
38− pub created_at: DateTime<Utc>,
39− pub updated_at: DateTime<Utc>,
40− pub file_count: Option<usize>,
41− pub comment_count: i64,
36 /// Whether the viewer may edit the change (author or maintainer).
37 pub can_manage: bool,
38 pub can_comment: bool,
39 pub csrf: &'a str,
40}
41
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.
42+/// Header and tab bar, shared by every change page.
43pub fn header(ctx: &RepoContext, c: &ChangeHead<'_>, tab: &str) -> Markup {
44 let base = format!("{}/changes/{}", ctx.base(), c.number);
45
46 html! {
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− }
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"
66 }
67 }
68 }
69
109− nav .subtabs.ruled aria-label="Change sections" {
70+ nav .subtabs style="margin-top:14px;margin-bottom:0" aria-label="Change sections" {
71 a href=(base.clone()) .active[tab == "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− }
72+ aria-current=[(tab == "overview").then_some("page")] { "Overview" }
73 a href=(format!("{base}/files")) .active[tab == "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− }
74+ aria-current=[(tab == "files").then_some("page")] { "Files" }
75 a href=(format!("{base}/revisions")) .active[tab == "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" }
76+ aria-current=[(tab == "revisions").then_some("page")] { "Revisions" }
77 @if c.conflicted {
78 a href=(format!("{base}/conflicts")) .active[tab == "conflicts"]
79 aria-current=[(tab == "conflicts").then_some("page")] { "Conflicts" }
80 }
81 a href=(format!("{}/stacks/{}", ctx.base(), c.change_id)) { "Stack" }
82 }
139− }
140− }
141−}
142−
143−/// "4 revisions over 3 days" — the sentence stable identity makes possible.
144−///
145−/// A single-revision change gets "1 revision" and no span: "over 0 days" is
146−/// noise, and the interesting number is the one that says this change has been
147−/// rewritten and kept its name.
148−fn revision_span(c: &ChangeHead<'_>) -> String {
149− let n = c.revision_count;
150− let unit = if n == 1 { "revision" } else { "revisions" };
151− let days = (c.updated_at - c.created_at).num_days();
152−
153− match (n, days) {
154− (1, _) => format!("1 {unit}"),
155− (_, 0) => format!("{n} {unit}"),
156− (_, 1) => format!("{n} {unit} over a day"),
157− (_, d) => format!("{n} {unit} over {d} days"),
158− }
159−}
160−
161−/// Wrap a change tab's body in the two-column shell with the aside.
162−pub fn tab_body(ctx: &RepoContext, aside: &ChangeAside, body: Markup) -> Markup {
163− html! {
164− div .columns.columns-repo {
165− div .columns-main { (body) }
166− aside .columns-aside.is-sticky {
167− @if !aside.reviewers.is_empty() {
168− div .aside-block {
169− div .label-condensed { "Reviewers" }
170− @for rv in &aside.reviewers {
171− (reviewer_line(rv))
172− }
173− }
174− }
175− @if aside.stack.len() > 1 {
176− div .aside-block {
177− div .label-condensed { "Stack" }
178− @for n in &aside.stack {
179− (stack_rail_row(ctx, n))
180− }
181− }
182− }
183− }
184− }
185− }
186−}
187−
188−fn reviewer_line(rv: &crate::views::change::Reviewer) -> Markup {
189− let (glyph, colour, meta, meta_colour) = match (rv.verdict.as_str(), rv.at_head) {
190− ("approved", true) => ("✓", "var(--open)", "approved", "var(--text-faint)"),
191− // A stale approval is the thing stable identity makes visible. It gets
192− // the conflict colour because it is a state the author has to act on,
193− // not a verdict they can bank.
194− ("approved", false) => ("✓", "var(--open)", "stale", "var(--conflict)"),
195− ("rejected", _) => ("×", "var(--danger)", "changes requested", "var(--danger)"),
196− _ => ("○", "var(--text-faint)", "commented", "var(--text-faint)"),
197− };
198−
199− html! {
200− div .reviewer-line {
201− span .reviewer-glyph aria-hidden="true" style=(format!("color:{colour}")) { (glyph) }
202− (crate::views::user_link(&rv.handle))
203− span .spacer {}
204− span .reviewer-meta style=(format!("color:{meta_colour}")) { (meta) }
83 }
84 }
85}
86
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−
87// ─── overview ────────────────────────────────────────────────────────────────
88
89pub struct CommentRow {
@@ -326,7 +185,7 @@
185 @for r in o.reviews {
186 div .row {
187 (verdict_badge(&r.verdict))
329− strong { (crate::views::user_link(&r.reviewer)) }
188+ strong { (r.reviewer) }
189 span .faint { (r.created_at.format("%Y-%m-%d").to_string()) }
190 span .faint .mono { (r.rev) }
191 @if !r.is_head {
@@ -493,23 +352,9 @@
352 @match item {
353 Item::Comment(cm) => (comment(cm, c, base, false)),
354 Item::Event(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− }
355+ div .row .timeline-event {
356+ span .timeline-dot.(event_dot_class(&e.kind)) aria-hidden="true" {}
357+ span .faint { (event_text(e)) }
358 span .faint style="margin-left:auto" {
359 (e.created_at.format("%Y-%m-%d %H:%M").to_string())
360 }
@@ -525,7 +370,7 @@
370 html! {
371 div .comment id=(format!("comment-{}", cm.id)) {
372 div .row {
528− strong { (crate::views::user_link(&cm.author)) }
373+ strong { (cm.author) }
374 span .faint { (cm.created_at.format("%Y-%m-%d %H:%M").to_string()) }
375 @if cm.edited { span .faint { "edited" } }
376 @if cm.anchor_state == "outdated" {
@@ -583,98 +428,46 @@
428/// Mirrors the grouping the design uses for its timeline markers: conflict
429/// states get the conflict colour, anything that lands a change gets the
430/// merged/open colours, and routine events (pushes, rebases) stay neutral.
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) {
431+fn event_dot_class(kind: &str) -> &'static str {
432 match kind {
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)"),
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",
439 }
440}
441
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();
442+fn event_text(e: &EventRow) -> String {
443+ let who = e.actor.as_deref().unwrap_or("someone");
444 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 };
445
446 match e.kind.as_str() {
629− "change.opened" => by("opened this change".into()),
447+ "change.opened" => format!("{who} opened this change"),
448 "change.pushed" => {
449 let rev = n("rev");
632− by(if rev.is_empty() {
633− "pushed a new revision".into()
450+ if rev.is_empty() {
451+ format!("{who} pushed a new revision")
452 } else {
635− format!("pushed revision {}", df_store::abbreviate_rev(rev))
636− })
453+ format!("{who} pushed revision {}", df_store::abbreviate_rev(rev))
454+ }
455 }
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()),
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"),
465 "comments.rebased" => {
466 let n_out = e.payload.get("outdated").and_then(|v| v.as_i64()).unwrap_or(0);
467 let n_orph = e.payload.get("orphaned").and_then(|v| v.as_i64()).unwrap_or(0);
650− EventSentence::Impersonal(format!(
651− "comments re-anchored onto the new revision — \
652− {n_out} outdated, {n_orph} orphaned"
653− ))
468+ format!("comments re-anchored onto the new revision — {n_out} outdated, {n_orph} orphaned")
469 }
655− // An event the UI does not know about still happened. Naming it beats
656− // dropping it, which would make the timeline quietly incomplete.
657− other => EventSentence::By {
658− actor,
659− predicate: format!(": {other}"),
660− spaced: false,
661− },
662− }
663−}
664−
665−/// The whole sentence as plain text, for contexts with no markup.
666−#[cfg(test)]
667−fn event_text(e: &EventRow) -> String {
668− match event_parts(e) {
669− EventSentence::Impersonal(s) => s,
670− EventSentence::By { actor, predicate, spaced } => {
671− let who = actor.unwrap_or("someone");
672− if spaced {
673− format!("{who} {predicate}")
674− } else {
675− format!("{who}{predicate}")
676− }
677− }
470+ other => format!("{who}: {other}"),
471 }
472}
473
@@ -857,212 +650,60 @@
650 pub pushed_at: DateTime<Utc>,
651 pub conflicted: bool,
652 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>,
653}
654
866−/// Which two revisions the interdiff compares.
867−pub struct Compare<'a> {
868− pub a: i32,
869− pub b: i32,
870− /// `None` when A and B are the same revision, which has no interdiff.
871− pub diff: Option<&'a Diff>,
872−}
873−
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 {
655+pub fn revisions(ctx: &RepoContext, c: &ChangeHead<'_>, revs: &[RevisionDetail]) -> Markup {
656 let base = format!("{}/changes/{}", ctx.base(), c.number);
891− let pick = |a: i32, b: i32| format!("{base}/revisions?a={a}&b={b}");
892−
657 html! {
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."
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."
664 }
899− }
665
901− div .revtimeline {
902− @for (i, r) in revs.iter().enumerate().rev() {
903− @let selected = r.seq == cmp.a || r.seq == cmp.b;
904− @let colour = if r.conflicted { "var(--conflict)" } else { "var(--identity)" };
905− div .revrow .is-selected[selected] {
906− // The rail is drawn from two half-segments so the first and
907− // last rows have no line dangling past the end of the list.
908− span .revrail aria-hidden="true" {
909− span .revrail-seg
910− style=(format!("background:{}",
911− if i == revs.len() - 1 { "transparent" } else { "var(--identity)" })) {}
912− span .revdot
913− style=(format!("border-color:{colour};background:{}",
914− if selected { colour } else { "var(--bg)" })) {}
915− span .revrail-seg
916− style=(format!("background:{}",
917− if i == 0 { "transparent" } else { "var(--identity)" })) {}
918− }
919−
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"
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))
675 }
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) }
676+ @if r.conflicted {
677+ " " span .badge.badge-conflict { "conflict" }
678 }
949− }
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) }
679 }
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) }
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)
686 }
687 }
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− }
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) }
695 }
696 }
697 }
698 }
1029− @if d.truncated {
1030− p .hint style="padding:12px" {
1031− "This interdiff is too large to render in full."
1032− }
1033− }
699 }
700 }
701 }
702 }
703}
704
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− }
705+fn p_or_empty(s: &Option<String>) -> &str {
706+ s.as_deref().unwrap_or("")
707}
708
709// ─── conflicts (M4) ──────────────────────────────────────────────────────────
@@ -1122,103 +763,46 @@
763 pub depth: usize,
764}
765
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 {
766+pub fn stack(ctx: &RepoContext, nodes: &[StackNode], change_id: &str) -> Markup {
767 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−
768 html! {
1150− div .page-head {
769+ div .panel {
770 h1 { "Stack" }
1152− @if nodes.len() > 1 {
1153− span .stack-chain.mono {
1154− (chain)
1155− @if let Some(t) = target { " onto " (t) }
1156− }
1157− }
1158− span .spacer {}
1159− @if nodes.len() > 1 && can_merge {
1160− form method="post" action=(format!("{base}/stacks/{change_id}/merge")) {
1161− input type="hidden" name="_csrf" value=(csrf);
1162− button .btn.btn-primary type="submit" {
1163− "Merge stack into " (target.unwrap_or("the bookmark"))
1164− }
1165− }
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."
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."
775 }
776
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" }
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" } }
782+ }
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+ }
798 }
1203− span .stackrow-meta { "#" (n.number) }
799 }
800 }
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" }
801+
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 }
1212− }
1213−
1214− div .stack-cmd.mono {
1215− "$ jj rebase -s " (&change_id[..4.min(change_id.len())])
1216− @if let Some(t) = target { " -d " (t) }
1217− }
1218−
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."
806 }
807 }
808 }
crates/df-web/assets/palette.js+0 −129
@@ -1,129 +0,0 @@
1−// The ⌘K palette. Enhancement only.
2−//
3−// Without this script the masthead control is a plain link to /search and the
4−// overlay stays `hidden`, so nothing here is load-bearing — it is a faster
5−// route to a page that is reachable anyway. That is also why the ⌘K keycap
6−// starts hidden in the markup: advertising a shortcut that does not work is
7−// worse than not advertising it.
8−//
9−// Results come from htmx hitting /search?fragment=1. This file never renders a
10−// result, so there is no second copy of the visibility rule to get wrong.
11−(function () {
12− var backdrop = document.querySelector("[data-palette]");
13− var input = document.querySelector("[data-palette-input]");
14− if (!backdrop || !input) return;
15−
16− var opener = document.querySelector("[data-palette-open]");
17− var kbd = document.querySelector("[data-palette-kbd]");
18− var lastFocus = null;
19−
20− // Mac gets ⌘, everything else Ctrl. Checking the platform is unreliable in
21− // general but perfectly adequate for choosing which glyph to draw.
22− var isMac = /Mac|iPhone|iPad/.test(navigator.platform || "");
23− if (kbd) {
24− kbd.textContent = isMac ? "⌘K" : "Ctrl K";
25− kbd.hidden = false;
26− }
27−
28− function open() {
29− if (!backdrop.hidden) return;
30− lastFocus = document.activeElement;
31− backdrop.hidden = false;
32− input.value = "";
33− input.focus();
34− }
35−
36− function close() {
37− if (backdrop.hidden) return;
38− backdrop.hidden = true;
39− if (lastFocus && lastFocus.focus) lastFocus.focus();
40− }
41−
42− if (opener) {
43− opener.addEventListener("click", function (e) {
44− e.preventDefault();
45− open();
46− });
47− }
48−
49− document.addEventListener("keydown", function (e) {
50− var accel = isMac ? e.metaKey : e.ctrlKey;
51− if (accel && (e.key === "k" || e.key === "K")) {
52− e.preventDefault();
53− backdrop.hidden ? open() : close();
54− return;
55− }
56− if (e.key === "Escape" && !backdrop.hidden) {
57− e.preventDefault();
58− close();
59− }
60− });
61−
62− // Clicking the backdrop closes; clicking the panel inside it must not.
63− backdrop.addEventListener("click", function (e) {
64− if (e.target === backdrop) close();
65− });
66−
67− // Arrow keys walk the results. The input keeps focus throughout so typing
68− // never has to be resumed — only Enter leaves, by following the active row.
69− function items() {
70− return Array.prototype.slice.call(
71− backdrop.querySelectorAll(".palette-item")
72− );
73− }
74−
75− function activeIndex(list) {
76− for (var i = 0; i < list.length; i++) {
77− if (list[i].classList.contains("is-active")) return i;
78− }
79− return -1;
80− }
81−
82− function activate(list, i) {
83− list.forEach(function (el) {
84− el.classList.remove("is-active");
85− });
86− if (list[i]) {
87− list[i].classList.add("is-active");
88− list[i].scrollIntoView({ block: "nearest" });
89− }
90− }
91−
92− input.addEventListener("keydown", function (e) {
93− var list = items();
94− if (!list.length) return;
95− var i = activeIndex(list);
96−
97− if (e.key === "ArrowDown") {
98− e.preventDefault();
99− activate(list, i + 1 >= list.length ? 0 : i + 1);
100− } else if (e.key === "ArrowUp") {
101− e.preventDefault();
102− activate(list, i <= 0 ? list.length - 1 : i - 1);
103− } else if (e.key === "Enter") {
104− // With nothing selected, fall through to the form's own behaviour:
105− // a full search for whatever was typed.
106− if (i < 0) return;
107− e.preventDefault();
108− list[i].click();
109− }
110− });
111−
112− // Fresh results, fresh selection: the first row is pre-selected so Enter
113− // does the obvious thing immediately after typing.
114− document.body.addEventListener("htmx:afterSwap", function (e) {
115− if (e.target && e.target.id === "palette-results") {
116− var list = items();
117− if (list.length) activate(list, 0);
118− }
119− });
120−
121− backdrop.addEventListener("click", function (e) {
122− var cmd = e.target.closest && e.target.closest("[data-palette-theme]");
123− if (cmd && window.dogfoodToggleTheme) {
124− e.preventDefault();
125− window.dogfoodToggleTheme();
126− close();
127− }
128− });
129−})();
crates/df-web/assets/terminal.js+0 −122
@@ -1,122 +0,0 @@
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−})();