Jump to…
rxkspqmsoknzmerged#4

feat: given a new redesign and emulated terminal homepage

Every version this change has been

Pick any two revisions; the interdiff is what a reviewer has not seen yet.
rev 3feat: given a new redesign and emulated terminal homepageAug 2 19:54
commit 1186c71a2ae7base d392d1ae0270+7275 −1484pushed by snowAB
rev 2feat: given a new redesign and emulated terminal homepageAug 2 19:52
commit 573909d8182dbase d392d1ae0270+7119 −1484pushed by snowAB
rev 1feat: given a new redesign and emulated terminal homepageAug 2 18:45
commit fced8e7d3098base d392d1ae0270+0 −0pushed by snowAB
Interdiff rev 3 → rev 1what the reviewer has not seen yet29 files · +1484 −7275
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
18use 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]
134async 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]
161async 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. */
481main.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"],
884input[type="search"],
885input[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
898textarea {
899 height: auto;
900 min-height: 64px;
901 padding: 6px 8px;
902 line-height: 21px;
903 resize: vertical;
904}
905
906input:focus,
907textarea:focus,
908select: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
977a.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
981a.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
1161footer 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. */
1218main.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
3989details 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. */
4713figure.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`.
424async 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.
440async 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)]
41pub 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]
673async 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]
714async 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.
105fn 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.
227async 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.
291async 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.
328async 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
384type 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};
9use 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
119type 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.
150const 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.
214async 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.
264async 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
3use 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)`
113type BookmarkTuple = (
114 String,
115 bool,
116 chrono::DateTime<chrono::Utc>,
117 Option<String>,
118 Option<i64>,
119 Option<String>,
120);
121
122async 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.
168async 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.
279async 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.
314pub(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.
331pub(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.
133async 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)]
432pub 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`].
559pub 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.
41pub 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
50impl 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.
137pub 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)]
224mod 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)]
297pub 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)]
307pub 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.
466fn 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.
495fn 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.
504fn 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.
597fn 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.
607fn initials(handle: &str) -> String {
608 handle.chars().take(2).collect()
609}
610
611#[cfg(test)]
612mod 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`.
45pub fn page_with_bar(chrome: Chrome<'_>, bar: Markup, content: Markup) -> Markup {
46 shell(chrome, Some(bar), content, true)
47}
48
49/// Site chrome with no content wrapper.
50///
51/// For pages built from edge-to-edge bands — the landing page — where each
52/// band draws its own full-width rule and carries its own `.wrap` inside it.
53/// Wrapping those in a second `.wrap` would inset every rule by the gutter and
54/// double the horizontal padding.
55pub fn page_full(chrome: Chrome<'_>, content: Markup) -> Markup {
56 shell(chrome, None, content, false)
57}
58
59fn shell(chrome: Chrome<'_>, bar: Option<Markup>, content: Markup, wrap: bool) -> Markup {
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.
198fn palette() -> Markup {
199 html! {
200 div .palette-backdrop data-palette hidden {
201 div .palette.float-shadow role="dialog" aria-modal="true" aria-label="Jump to" {
202 div .palette-bar {
203 span .palette-prompt aria-hidden="true" { "›" }
204 input .palette-input type="search" name="q" autocomplete="off"
205 spellcheck="false" data-palette-input
206 placeholder="Change id, repository, issue, or command"
207 aria-label="Search repositories, changes and issues"
208 hx-get="/search?fragment=1"
209 hx-trigger="input changed delay:150ms"
210 hx-target="#palette-results"
211 hx-indicator=".palette";
212 span .kbd aria-hidden="true" { "esc" }
213 }
214 div #palette-results .palette-results {
215 (palette_hint())
216 }
217 div .palette-foot .mono {
218 "Paste any change id prefix. Two characters is usually enough."
219 }
220 }
221 }
222 }
223}
224
225/// Search results, grouped, for the palette overlay.
226///
227/// Takes the same [`Hit`](crate::routes::search::Hit) rows the full search page
228/// renders — the palette is a different presentation of one search, not a
229/// second search.
230pub fn palette_results(
231 repos: &[crate::routes::search::Hit],
232 changes: &[crate::routes::search::Hit],
233 issues: &[crate::routes::search::Hit],
234 query: &str,
235) -> Markup {
236 let groups: [(&str, &[crate::routes::search::Hit], &str); 3] = [
237 ("Repositories", repos, "▸"),
238 ("Changes", changes, "○"),
239 ("Issues", issues, "◌"),
240 ];
241 let empty = repos.is_empty() && changes.is_empty() && issues.is_empty();
242
243 html! {
244 @for (label, hits, glyph) in groups {
245 @if !hits.is_empty() {
246 div .palette-group {
247 div .label-condensed.palette-group-label { (label) }
248 @for h in hits.iter().take(5) {
249 a .palette-item href=(&h.url) {
250 span .palette-glyph aria-hidden="true" { (glyph) }
251 span .palette-label.mono { (h.title) }
252 @if !h.context.is_empty() {
253 span .palette-hint.dim { (h.context) }
254 }
255 @if let Some(b) = &h.badge {
256 span .kbd { (b) }
257 }
258 }
259 }
260 }
261 }
262 }
263
264 @if empty {
265 div .palette-empty.dim {
266 "Nothing you can see matches “" (query) "”."
267 }
268 } @else {
269 // The escape hatch: five per group is enough to jump, not enough
270 // to browse.
271 a .palette-item.palette-all
272 href=(format!("/search?q={}", crate::routes::settings::urlencode(query))) {
273 span .palette-glyph aria-hidden="true" { "›" }
274 span .palette-label { "All results for “" (query) "”" }
137 }
138 }
139 }
140}
141
280/// What the palette shows before anything has been typed.
281pub fn palette_hint() -> Markup {
282 html! {
283 div .palette-group {
284 div .label-condensed.palette-group-label { "Commands" }
285 a .palette-item href="/dashboard" {
286 span .palette-glyph aria-hidden="true" { "◑" }
287 span .palette-label { "Dashboard" }
288 span .palette-hint.dim { "your changes and reviews" }
289 }
290 a .palette-item href="/design" {
291 span .palette-glyph aria-hidden="true" { "◑" }
292 span .palette-label { "Design system" }
293 span .palette-hint.dim { "tokens, states, components" }
294 }
295 button .palette-item type="button" data-palette-theme {
296 span .palette-glyph aria-hidden="true" { "◑" }
297 span .palette-label { "Toggle theme" }
298 span .palette-hint.dim { "dark / light" }
299 span .kbd { "t" }
300 }
301 }
302 }
303}
304
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
16pub use layout::{error_page, page, page_full, page_with_bar, Chrome};
16+pub use layout::{error_page, page, Chrome};
17
18use chrono::{DateTime, Utc};
19use 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.
27pub 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.
38pub 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.
34pub 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.
46pub 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.
58fn 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.
106pub 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.
121pub 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.
274fn 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.
343fn 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.
369fn 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
384fn 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.
633pub 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
5use 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.
74fn 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)]
95mod 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`.
221pub 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.
235pub 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.
252pub 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.
265pub 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.
293pub 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.
439pub 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.
454fn 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.
489fn 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.
835pub 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.
893pub 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.
49pub 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.
59pub 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.
148fn 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.
162pub 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
188fn 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
209fn 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.
591fn 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.
605enum 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
623fn 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)]
667fn 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.
867pub 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.
884pub 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.
1050pub 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.
1130pub 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})();
docs/pushing-with-jj.md+0 −156
@@ -1,156 +0,0 @@
1# Pushing changes with jj
2
3How work gets from your working copy to a change on Dogfood.
4
5This repository is a **jj** repo with a git backend. `jj git export` keeps a git view in
6sync, which means `git status` and the git reflog see only part of the picture — the
7authoritative history is jj's, and `jj op log` is the thing that can undo a mistake.
8
9## The model, in one paragraph
10
11There is no staging area and no "uncommitted work". The working copy **is** a commit
12(`@`), and jj snapshots it into that commit before running any command. You do not
13create a commit to save work; the work is already in one. What you do instead is give
14that commit a description, and then push a bookmark pointing at it. Pushing a bookmark
15is what opens or updates a change — there is no web form and no PR button.
16
17## The normal loop
18
19```sh
20# 1. Start a new commit on top of the trunk.
21jj new main
22
23# 2. Edit files. jj snapshots them into @ automatically — nothing to add.
24
25# 3. Describe what you did.
26jj describe -m 'feat: cache shortest-unique prefixes per repo'
27
28# 4. Push. This creates a bookmark named push-<change-id> and opens a change.
29jj git push -c @
30
31# 5. Start the next piece of work on top.
32jj new
33```
34
35Step 4 prints the change URL. That change is now open for review.
36
37## Amending a change after review comments
38
39Because `@` is still the commit you pushed, you just keep editing it and push again:
40
41```sh
42# edit files …
43jj git push -b push-<change-id>
44```
45
46jj reports this as `[move sideways from <old> to <new>]`. "Sideways" means a rewrite
47rather than a fast-forward — expected, because you changed a commit that was already
48published. No `--force` is required: jj compares the remote against the position it last
49recorded, and pushes only when those agree. If someone else moved the bookmark
50underneath you, that check fails instead of clobbering their work.
51
52On Dogfood this lands as a **new revision on the same change**. The change id is stable
53across every amend, rebase and force-push, which is the whole point — reviews stay
54attached to the change, not to a commit hash.
55
56If you have already moved on with `jj new` and `@` is no longer the commit you want to
57amend:
58
59```sh
60jj edit <change-id> # make that commit the working copy again
61# edit files …
62jj git push -b push-<change-id>
63jj new # go back to working on top
64```
65
66## Checking before you push
67
68```sh
69jj st # what changed in @
70jj diff --stat # the diff, summarised
71jj log -r 'stack(@)' # the stack this change sits in
72jj git push -b <name> --dry-run # exactly what would move, without moving it
73```
74
75`--dry-run` is worth the extra command whenever you are about to rewrite something that
76is already published.
77
78## Naming the bookmark yourself
79
80`-c/--change` generates `push-<change-id>`, which is fine for most work. To push under a
81name a human chose:
82
83```sh
84jj bookmark create my-feature -r @
85jj git push -b my-feature
86```
87
88## Landing on main
89
90```sh
91jj bookmark move main --to @
92jj git push -b main
93```
94
95This bypasses review entirely, so it is for trivial or already-reviewed work only. The
96normal path is the bookmark push above.
97
98## The trap that will cost you a day
99
100```sh
101jj new main -m 'feat: my work' # ← DO NOT use this to save work in progress
102```
103
104This does **not** put your current work in the new commit. jj snapshots the working copy
105into the **current** commit first, then creates an **empty** commit whose parent is
106`main`. You end up pushing an empty change while all your work sits in a now-orphaned
107commit labelled `(no description set)` — and because the working copy was reset to
108something matching `main`, `git status` reports a clean tree and the work looks deleted.
109
110Use `jj describe -m '…'` to name work you already have. Use bare `jj new` (no revision)
111only when you genuinely want to start something new.
112
113### Recovering from it
114
115Nothing is lost — jj keeps every snapshot.
116
117```sh
118# 1. Find the stranded commit. It is a sibling with no description.
119jj log -r 'all()'
120
121# 2. Confirm it is the right one before touching anything.
122jj show --stat <change-id>
123
124# 3. Restore its contents into the current commit.
125jj restore --from <change-id>
126```
127
128`jj op log` shows the tell: a `snapshot working copy` operation immediately followed by
129`new empty commit`, both with the same `args:` line. To rewind the whole repository to
130just before a bad command:
131
132```sh
133jj op log
134jj op restore <operation-id>
135```
136
137Prefer `jj restore --from` when you only want the files back and do not want to rewind
138bookmarks that were pushed in the meantime.
139
140## Deploying is a separate step
141
142Pushing updates the change. It does not deploy. `./run.sh deploy` builds from the
143**working copy on disk**, not from what you pushed — so if the tree is in the stranded
144state above, a deploy will quietly ship the previous build. After deploying, confirm
145which build is actually live rather than assuming:
146
147```sh
148curl -s -o /dev/null -w '%{http_code}\n' https://dogfood.sh/assets/terminal.js
149```
150
151Any asset that exists only in the current build works as the probe.
152
153## See also
154
155- [`change-id-format.md`](change-id-format.md) — why change ids look the way they do
156- [`revset-semantics.md`](revset-semantics.md) — the revset language used by `-r`