clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

Panels with a head strip for content, and an even row hover #16

commits4oldest firstcombined diff 36 files, +510 -309
discussion0 comments

No comments yet: forge comment 16 adds one.

1/4 Tint a hovered row as one block, not its cells

A file list's icon cell had the class icon, which the stylesheet gives
the svg icon: inline-block, 16px square. The cell stopped being a table
cell, so a hovered row painted a small block behind the icon and another
behind the name, offset from each other. The cell is file-icon now, the
icon rule names svg.icon, and the hover background goes on the row, not
on each cell. A row's own link stays ink on hover, underlined, rather
than turning blue.
3 files changed, +35 -14
Msite/_includes/macros.njk4++--
Msite/assets/style.css22++++++++++------------
Atest/scenarios/panel-hover.sh23+++++++++++++++++++++++
M site/_includes/macros.njk +2 -2 mechanical
M site/assets/style.css +10 -12
@@ -178,7 +178,7 @@ h3 { font-size: var(--text-md); margin: var(--space-6) 0 var(--space-2); }
178178 .wrap { max-width: 1280px; margin: 0 auto; padding: 0 var(--space-4); }179179 kbd { border: var(--hairline) solid var(--border-strong); padding: 0 var(--space-1); color: var(--muted); background: var(--raised); }180180 hr { border: 0; border-top: var(--hairline) solid var(--border); margin: var(--space-6) 0; }181-.icon { display: inline-block; width: 16px; height: 16px; flex: none; vertical-align: -3px; }181+svg.icon { display: inline-block; width: 16px; height: 16px; flex: none; vertical-align: -3px; }182182 183183 /* header: on the page, over a rule that the current tab's bar sits on */184184 header.site { border-bottom: var(--hairline) solid var(--border); }
@@ -282,11 +282,13 @@ table.list td.date time { font-size: inherit; }
282282 table.list .nowrap, table.list .date, table.list .sha, table.list td.mono { white-space: nowrap; }283283 table.list td.mono { font-size: var(--text-sm); }284284 table.list td.main { width: 100%; overflow-wrap: anywhere; }285-/* A row's own link (a subject, a file, a branch) is in ink; other links stay blue. */286-table.list td.main > a:first-of-type, .files td.main a { color: var(--fg); font-weight: var(--fw-medium); }287-table.list td.main > a:first-of-type:hover, .files td.main a:hover { color: var(--deep); }288-table.list tbody tr:hover td, table.list tbody tr:focus-within td, table.list tbody tr.sel td { background: var(--hover); }289-table.list tbody tr.sel td:first-child { box-shadow: inset 2px 0 0 var(--accent); }285+/* A row's own link (a subject, a file, a branch) is in ink, and stays ink286+ on hover; other links are blue. */287+table.list td.main > a:first-of-type { color: var(--fg); font-weight: var(--fw-medium); }288+table.list td.main > a:first-of-type:hover { color: var(--fg); }289+/* Hover tints the row, never a cell or a link, so it is one even block. */290+table.list tbody tr:hover, table.list tbody tr:focus-within, table.list tbody tr.sel { background: var(--hover); }291+table.list tbody tr.sel > td:first-child { box-shadow: inset 2px 0 0 var(--accent); }290292 /* A box holds a list or prose under a heading row; only a file viewer291293 (.viewer) is a bordered region. */292294 .box + .box { margin-top: var(--space-6); }
@@ -297,8 +299,7 @@ table.list tbody tr.sel td:first-child { box-shadow: inset 2px 0 0 var(--accent)
297299 .box.viewer > .box-head { padding: var(--space-1) var(--space-3); align-items: center; }298300 .box.viewer > table.list tr:last-child td { border-bottom: 0; }299301 .files td { padding-top: var(--space-1); padding-bottom: var(--space-1); }300-.files td.icon { width: 1.75em; color: var(--muted); padding-right: 0; }301-.files td.icon .icon { vertical-align: -3px; }302+.files td.file-icon { width: 1.75em; color: var(--muted); padding-right: 0; }302303 .files td.size { color: var(--muted); }303304 .empty { color: var(--muted); padding: var(--space-6); text-align: center; }304305
@@ -307,11 +308,8 @@ table.list tbody tr.sel td:first-child { box-shadow: inset 2px 0 0 var(--accent)
307308 table.list.stack tr { display: flex; flex-wrap: wrap; gap: var(--space-half) var(--space-3); padding: var(--space-2); border-bottom: var(--hairline) solid var(--border); }308309 table.list.stack td { border: 0; padding: 0; font-size: var(--text-sm); color: var(--muted); }309310 table.list.stack td.main { flex: 0 0 100%; font-size: var(--text-md); color: var(--fg); }310- /* a stacked row is one block: paint the row, not each cell, so the gaps between cells don't show */311- table.list.stack tbody tr:hover, table.list.stack tbody tr:focus-within, table.list.stack tbody tr.sel { background: var(--hover); }312- table.list.stack tbody tr td { background: none; }313311 table.list.stack tbody tr.sel { box-shadow: inset 2px 0 0 var(--accent); }314- table.list.stack tbody tr.sel td:first-child { box-shadow: none; }312+ table.list.stack tbody tr.sel > td:first-child { box-shadow: none; }315313 table.list.stack .hide-sm { display: none; }316314 table.list.stack td { order: 2; }317315 table.list.stack td.main { order: 0; }
A test/scenarios/panel-hover.sh +23 -0

2/4 Keep inline code whole at its hyphens

Prose broke inline code after a hyphen, so lib/text-widths.sh could end
a line at lib/text- and start the next at widths.sh. A hyphenated word
in inline code is kept whole now, and a path may break after its slashes
instead; the text copies as written.
3 files changed, +30 -2
Mlib/markdown.ts15+++++++++++++--
Msite/assets/style.css3+++
Atest/scenarios/markdown-inline-code.sh14++++++++++++++
M lib/markdown.ts +13 -2
@@ -33,6 +33,16 @@ md.renderer.rules.fence = (tokens, idx, options, env: Env, self) => {
3333 return `<div>${env.token}:${env.diagrams.length - 1}</div>\n`;3434 };3535 36+// Inline code never breaks at a hyphen (lib/text-widths.sh isn't37+// lib/text- and widths.sh): a word with one is kept whole (.nobr in38+// style.css), and a path may break after its slashes instead. The text is39+// unchanged, for copying.40+md.renderer.rules.code_inline = (tokens, idx, _options, _env, self) => {41+ const t = tokens[idx];42+ const word = (w: string): string => w.split('/').map((part) => (part.includes('-') ? `<span class="nobr">${md.utils.escapeHtml(part)}</span>` : md.utils.escapeHtml(part))).join('/<wbr>');43+ return `<code${self.renderAttrs(t)}>${t.content.split(/(\s+)/).map((w, i) => (i % 2 ? md.utils.escapeHtml(w) : word(w))).join('')}</code>`;44+};45+3646 const isRelative = (u: string | null | undefined): u is string => !!u && !/^([a-z][a-z0-9+.-]*:|\/\/|#|\/)/i.test(u);3747 3848 // GitHub-style heading ids, so README links to #some-section resolve.
@@ -57,7 +67,7 @@ const ALLOWED = {
5767 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'blockquote',5868 'pre', 'code', 'kbd', 'samp', 'var', 'sub', 'sup', 'b', 'i', 'em', 'strong', 's', 'del', 'ins', 'mark', 'small',5969 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', 'colgroup', 'col', 'figure', 'figcaption',60- 'input',70+ 'input', 'wbr',6171 ],6272 allowedAttributes: {6373 '*': ['align', 'id', 'title', 'class', 'dir', 'lang'],
@@ -71,9 +81,10 @@ const ALLOWED = {
7181 input: ['type', 'checked', 'disabled'],7282 },7383 // highlight.js tokens: "hljs-title" plus sub-scopes like "function_".74- allowedClasses: { '*': [/^language-/], span: [/^hljs-/, /^[a-z]+_+$/] },84+ allowedClasses: { '*': [/^language-/], span: [/^hljs-/, /^[a-z]+_+$/, 'nobr'] },7585 allowedSchemes: ['http', 'https', 'mailto'],7686 allowProtocolRelative: false,87+ selfClosing: [...sanitize.defaults.selfClosing, 'wbr'],7788 };7889 7990 export function renderMarkdown(src: string, { link = (u) => u, image = (u) => u }: LinkResolver = {}): string {
M site/assets/style.css +3 -0
A test/scenarios/markdown-inline-code.sh +14 -0

3/4 Content in panels with a head strip, chrome on the page

Lists, file viewers, READMEs, diffs and discussions sit in panels now: a
raised surface with a hairline edge, square and without a shadow, and a
tinted head strip naming what each holds. The header, a page's title,
tabs and toolbars stay on the page. ui.panel draws one (a details when it
folds), /design/ shows it, and DESIGN.md has the rules; the one-off boxes
(.box, .box.viewer, section.file) are folded into it.

A repo's files sit under their last commit (author, subject, short sha,
age), its README under its file name; the index's shelves are panels
named and counted in their head strips; reviews group into folding
panels; a review's description, stack, commits, revisions and discussion
are panels; a diff's files and its diffstat (with the page's tools in its
head strip) are, and so are range-diffs' pairs, releases, sites, wiki
pages, insights' charts and tables, and the sign-in page.

New tokens: --tint for head strips and code in prose, and a --hover that
shows on the raised surface in both modes. --faint keeps 4.5:1 on a
panel, and the dark mode's hairline is a step stronger so panel edges
show. Diagrams draw on the panel's surface.
33 files changed, +442 -294
M.forge/ci2+-
MDESIGN.md54+++++++++--
Mlib/forge.ts4+-
Mlib/render.ts31+++++--
Msite/_includes/discussion.njk21++--
Msite/_includes/insights-body.njk72++++++++-------
Msite/_includes/login.njk8+-
Msite/_includes/macros.njk43++++-----
Msite/_includes/private.njk2+-
Msite/_includes/search.njk2+-
Msite/_includes/ui.njk23++++-
Msite/assets/discuss.ts2+-
Msite/assets/style.css144++++++++++++++++++------------
Msite/design.njk60+++++++++---
Msite/repo/blob.njk16++--
Msite/repo/commit.njk15++-
Msite/repo/home.njk30+++----
Msite/repo/log.njk2+-
Msite/repo/refs.njk14+-
Msite/repo/releases.njk11+-
Msite/repo/review-diff.njk7+-
Msite/repo/review.njk50++++++-----
Msite/repo/reviews.njk21+---
Msite/repo/revision.njk12+-
Msite/repo/sites.njk14+-
Msite/repo/tree.njk8+-
Msite/repo/wiki.njk5+-
Atest/scenarios/panels-repo.sh21++++
Atest/scenarios/panels-shelves.sh20++++
Mtest/scenarios/review-list.sh8+-
Mtest/scenarios/shelves.sh8+-
Mtest/scenarios/web-comment-line-page.sh4+-
Mtest/scenarios/web-comment-page.sh2+-
M .forge/ci +1 -1
M DESIGN.md +44 -10
@@ -8,11 +8,38 @@ the real stylesheet and macros. Check it after a change.
88 99 ## The look1010 11-A plain page (no texture, no grid), square corners, and hairline rules12-between things rather than boxes around them; only a file viewer, a diff or13-a code block is a bordered region. Structure comes from the order of things14-and the rules between them, in the spirit of the Vanilla framework. Phone15-first: lists restack, tabs scroll sideways.11+A plain page (no texture, no grid) with the chrome on it, and the content12+in panels. Square corners, no shadows, hairline rules. Structure comes from13+the order of things, the panels and the rules between rows, in the spirit14+of the Vanilla framework. Phone first: lists restack, tabs scroll sideways.15+16+## Panels17+18+Lists, file viewers, READMEs, diffs and discussions sit in panels; the19+header, a page's title and description, tabs and toolbars (the branch20+picker, breadcrumbs, filters) sit on the page. Use `ui.panel` in21+`site/_includes/ui.njk` rather than writing a box of your own.22+23+- A panel is `--raised` with a `--hairline` `--border` edge: square, no24+ shadow. Panels stack `--space-4` (16px) apart.25+- It may have a head strip, on `--tint` over a hairline, naming what the26+ panel holds: quiet `--muted` sans at `--text-sm`, the key item in ink at27+ `--fw-medium` (`<b>`, or a link with class `key`), shas, counts and ages28+ in the mono, faint. The page's controls for what is in the panel (wrap29+ lines, copy, viewed) go at its end (`.end`). A table's column names are a30+ head strip too. A panel that folds is a `details` whose summary is its31+ head strip.32+- Inside, rows are between hairlines and the last has none: the panel's33+ edge is its rule. Rows have `--space-2` `--space-3` padding.34+- Hover tints the whole row with `--hover`: the background goes on the row35+ (`tr`, `li`), never on a cell or a link, so it is one even block. A row's36+ own link (a subject, a file, a branch) is in ink and stays ink on hover,37+ underlined; other links are blue.38+- Prose in a panel (`.markdown`) has its code on `--tint`; diagrams take39+ the panel's surface. An empty state is a quiet centred sentence (`.empty`)40+ in the panel it would fill.41+- A section heading on the page (`ui.heading`) names several panels; one42+ panel's name goes in its own head strip.1643 1744 ## Tokens1845
@@ -25,9 +52,11 @@ family: not `style.css` below it, not a template's `{% css %}` block, not
2552 a `style=""` attribute. If no token fits, add one there (with a comment if2653 it starts a group: the style guide titles groups by those comments).2754 28-- Colour: `--bg` the page, `--raised` surfaces over it (white in light29- mode), `--hover`, ink in three strengths `--fg`, `--muted` (at least30- 4.5:1 on the page) and `--faint`, rules `--border` and `--border-strong`.55+- Colour: `--bg` the page, `--raised` panels over it (white in light56+ mode), `--tint` a panel's head strip, `--hover` a hovered row, ink in57+ three strengths `--fg`, `--muted` (at least 4.5:1 on the page and on58+ every surface) and `--faint` (at least 4.5:1 on `--raised`), rules59+ `--border` and `--border-strong`.3160 One accent, `#3148F6`: `--link` for links (lighter in dark mode),3261 `--accent` for the current tab's bar, focus, primary fills, `--deep` on3362 hover. Meaning: `--add-*`, `--del-*`, `--pass`, `--fail`, `--warn`,
@@ -64,6 +93,8 @@ Self-hosted variable woff2 in `site/assets/fonts/` (how they were cut:
6493 - Kinds, topics and filters are plain words, not chips.6594 - Never the middle dot (U+00B7), in code, comments, UI strings or6695 commits; use a comma, a hyphen or a pipe.96+- Inline code never breaks at a hyphen (`lib/markdown.ts` keeps a97+ hyphenated word whole and lets a path break after its slashes).6798 6899 ## Controls69100
@@ -83,9 +114,12 @@ Words for where you are, symbols for how you look at it.
83114 - Links only go places. Never use a link, or a word that changes when84115 clicked, as a toggle.85116 86-Use the macros in `site/_includes/ui.njk` (also `ui.input`,117+Use the macros in `site/_includes/ui.njk` (also `ui.panel`, `ui.input`,87118 `ui.textarea`, `ui.listTable`, `ui.status`, `ui.heading`, `ui.icon`)88-rather than writing the markup again. A new control goes there first,119+rather than writing the markup again. A macro's class belongs to its120+element: `.icon` is `ui.icon`'s svg and nothing else (a file list's icon121+cell is `td.file-icon`), since a cell that took the icon's class once took122+its 16px inline box too, and painted a hovered row in pieces. A new control goes there first,89123 with its CSS in `style.css` under "controls", and onto `/design/`.90124 91125 ## After a change
M lib/forge.ts +2 -2
M lib/render.ts +22 -9
@@ -50,7 +50,9 @@ function hunkCode(h: Hunk, lang: string | null): string[] {
5050 });5151 }5252 53-export function diffstat(files: DiffFile[], prefix = ''): string {53+// The diffstat: a panel whose head strip sums it up, with `tools` (the54+// page's controls, like wrap lines) at its end.55+export function diffstat(files: DiffFile[], prefix = '', tools = ''): string {5456 if (!files.length) return '';5557 const max = Math.max(...files.map((f) => f.add + f.del), 1);5658 const width = 30;
@@ -64,8 +66,8 @@ export function diffstat(files: DiffFile[], prefix = ''): string {
6466 });6567 const add = files.reduce((s, f) => s + f.add, 0);6668 const del = files.reduce((s, f) => s + f.del, 0);67- return `<p class="desc">${files.length} file${files.length === 1 ? '' : 's'} changed, <span class="i">+${add}</span> <span class="d">-${del}</span></p>` +68- `<table class="diffstat list">${rows.join('')}</table>`;69+ return `<section class="panel"><header class="panel-head"><span class="grow">${files.length} file${files.length === 1 ? '' : 's'} changed, <span class="i">+${add}</span> <span class="d">-${del}</span></span>` +70+ (tools ? `<span class="end">${tools}</span>` : '') + `</header><table class="diffstat list">${rows.join('')}</table></section>`;6971 }7072 7173 // `annotate(path, newLine)` may return HTML to show under that line (review comments).
@@ -82,12 +84,12 @@ export function diff(files: DiffFile[], { prefix = '', blobHref, annotate, mecha
8284 const title = fileTitle(f);8385 const fileHref = blobHref && f.status !== 'D' ? blobHref(f.path) : null;8486 const mech = !!mechanical?.(f);85- const head = `<header class="fh"><span class="st ${f.status}" title="${statusWord[f.status]}">${f.status}</span> <a href="#${id}" class="fname">${title}</a>` +87+ const head = `<header class="panel-head fh"><span class="st ${f.status}" title="${statusWord[f.status]}">${f.status}</span> <a href="#${id}" class="fname">${title}</a>` +8688 ` <span class="counts"><span class="i">+${f.add}</span> <span class="d">-${f.del}</span></span>` +8789 (mech ? ' <span class="jev-mech" title="Likely mechanical (Jev): a rename, reformatting, generated or vendored code, a lockfile, a version bump or a search-and-replace">mechanical</span>' : '') +8890 (fileHref ? ` <a class="view" href="${fileHref}">view file</a>` : '') + '</header>';8991 const at = paths && f.status !== 'D' ? ` data-path="${esc(f.path)}"` : '';90- const wrap = (body: string): string => `<section class="file jump" id="${id}"${at}${mech ? ' data-mechanical' : ''}>${head}${body}</section>`;92+ const wrap = (body: string): string => `<section class="panel file jump" id="${id}"${at}${mech ? ' data-mechanical' : ''}>${head}${body}</section>`;9193 if (f.omitted) return wrap('<p class="note">Diff not shown: this commit changes too many lines.</p>');9294 if (f.binary) return wrap('<p class="note">Binary file.</p>');9395 if (!f.hunks.length) return wrap(`<p class="note">${f.mode ? 'File mode ' + esc(f.mode) : 'No content changes.'}</p>`);
@@ -174,22 +176,33 @@ export function rangeDiff(text: string, { commitHref, change }: { commitHref?: (
174176 175177 const n = (mark: string): number => pairs.filter((p) => p.mark === mark).length;176178 const sum = Object.keys(PAIR).filter((k) => n(k)).map((k) => `${n(k)} ${PAIR[k][1]}`);179+ // Pairs without a diff are rows, in a panel until the next rewritten180+ // pair, which is a panel of its own like a diff's file.177181 let out = `<p class="desc">${pairs.length} commit${pairs.length === 1 ? '' : 's'}: ${sum.join(', ')}.</p>`;182+ let rows = '';178183 let same: string[] = [];179184 const flush = (): void => {180- if (same.length) out += `<details class="pairs"><summary>${same.length} unchanged commit${same.length === 1 ? '' : 's'}</summary>${same.join('')}</details>`;185+ if (same.length) rows += `<details class="pairs"><summary>${same.length} unchanged commit${same.length === 1 ? '' : 's'}</summary>${same.join('')}</details>`;181186 same = [];182187 };188+ const panel = (): void => {189+ flush();190+ if (rows) out += `<div class="panel pairlist">${rows}</div>`;191+ rows = '';192+ };183193 pairs.forEach((p, i) => {184194 if (p.mark === '=') {185195 same.push(`<div class="pairrow">${label(p)}</div>`);186196 return;187197 }188198 flush();189- if (p.mark !== '!' || !p.body.length) out += `<div class="pairrow">${label(p)}</div>`;190- else out += `<section class="file pair jump" id="p${i + 1}"><header class="fh">${label(p)}</header><div class="code diff range"><div class="rows">${p.body.map(row).join('')}</div></div></section>`;199+ if (p.mark !== '!' || !p.body.length) rows += `<div class="pairrow">${label(p)}</div>`;200+ else {201+ panel();202+ out += `<section class="panel file pair jump" id="p${i + 1}"><header class="panel-head fh">${label(p)}</header><div class="code diff range"><div class="rows">${p.body.map(row).join('')}</div></div></section>`;203+ }191204 });192- flush();205+ panel();193206 return out;194207 }195208
M site/_includes/discussion.njk +11 -10
@@ -6,21 +6,20 @@
66 {% import "ui.njk" as ui %}77 {% set u = p.repo.urls %}88 {% css %}9-ul.approvals { list-style: none; margin: 0; padding: var(--space-3) 0; border-bottom: var(--hairline) solid var(--border); display: flex; flex-wrap: wrap; gap: var(--space-1) var(--space-5); font-size: var(--text-sm); }9+ul.approvals { list-style: none; margin: 0; padding: var(--space-2) var(--space-3); border-bottom: var(--hairline) solid var(--border); display: flex; flex-wrap: wrap; gap: var(--space-1) var(--space-5); font-size: var(--text-sm); }1010 ul.approvals .tick { color: var(--pass); }1111 ul.approvals .stale { color: var(--muted); }1212 ul.approvals .stale .tick { color: var(--warn); }1313 ul.approvals .date { font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--muted); }1414 ul.approvals .date time { font-size: inherit; }15-ol.comments { list-style: none; margin: 0; padding: 0; }16-.comment { border-bottom: var(--hairline) solid var(--border); padding: 0 0 var(--space-1); }15+.comment { padding: 0 0 var(--space-1); }1716 .comment:target { box-shadow: inset var(--bar) 0 0 var(--accent); }1817 .comment-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-half) var(--space-3); padding: var(--space-3) var(--space-3) 0; font-size: var(--text-sm); }1918 .comment-head .date { font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--muted); }2019 .comment-head .date time { font-size: inherit; }2120 .comment-head .rev { font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--muted); }2221 .comment .tag { font-size: var(--text-sm); font-weight: var(--fw-medium); color: var(--warn); }23-.comment .quote { margin: var(--space-2) var(--space-3) 0; border: var(--hairline) solid var(--border); background: var(--raised); overflow: hidden; }22+.comment .quote { margin: var(--space-2) var(--space-3) 0; border: var(--hairline) solid var(--border); background: var(--tint); overflow: hidden; }2423 .comment .quote .where { padding: var(--space-1) var(--space-3); font: var(--fw-regular) var(--text-xs)/1.6 var(--mono); border-bottom: var(--hairline) solid var(--border); overflow-wrap: anywhere; }2524 .comment .quote pre { margin: 0; padding: var(--space-1) var(--space-3); font: var(--fw-regular) var(--text-code)/1.55 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; tab-size: 4; }2625 .comment.outdated .quote pre { color: var(--muted); }
@@ -29,13 +28,15 @@ ol.comments { list-style: none; margin: 0; padding: 0; }
2928 /* Sent from this page, until the rebuilt page shows it. */3029 .comment.pending { border-bottom-style: dashed; }3130 .comment.pending .text { padding: var(--space-2) var(--space-3); white-space: pre-wrap; overflow-wrap: anywhere; color: var(--muted); }32-.reply { margin: var(--space-3) 0 0; }31+.discussion .panel-body { border-top: var(--hairline) solid var(--border); }32+.discussion > .panel-head + .panel-body { border-top: 0; }33+#no-comments { margin: 0; padding: var(--space-3); }3334 .reply-bar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); margin-top: var(--space-2); font-size: var(--text-sm); }3435 .reply-bar .grow { flex: 1; }3536 .reply-status.bad { color: var(--fail); white-space: pre-line; }3637 {% endcss %}37-<section class="discussion" id="discussion">38-{{ ui.heading('discussion', null, (rv.comments.length | plural('comment')) + ((', ' + (rv.approvals.length | plural('approval'))) if rv.approvals.length else '')) }}38+{% set head %}<b>discussion</b><span class="grow">{{ rv.comments.length | plural('comment') }}{% if rv.approvals.length %}, {{ rv.approvals.length | plural('approval') }}{% endif %}</span>{% endset %}39+{% call ui.panel(head, 'discussion', 'id="discussion"') %}3940 {% if rv.approvals.length %}4041 <ul class="approvals">4142 {% for a in rv.approvals %}
@@ -44,7 +45,7 @@ ol.comments { list-style: none; margin: 0; padding: 0; }
4445 </ul>4546 {% endif %}4647 {% if rv.comments.length %}47-<ol class="comments">48+<ol class="rows comments">4849 {% for c in rv.comments %}4950 <li class="comment{% if c.outdated %} outdated{% endif %}" id="comment-{{ c.n }}">5051 <div class="comment-head"><b>{{ c.user }}</b> <a class="date" href="#comment-{{ c.n }}">{{ c.ts | ago }}</a>{% if c.rev %} <span class="rev">r{{ c.rev }}</span>{% endif %}{% if c.outdated %} <span class="tag">outdated</span>{% endif %}{% if c.addressed %} <span class="tag jev-addressed" title="According to Jev, the change since this comment addresses it">likely addressed in r{{ c.addressed }}</span>{% endif %}</div>
@@ -59,8 +60,8 @@ ol.comments { list-style: none; margin: 0; padding: 0; }
5960 {% else %}6061 <p class="desc" id="no-comments">No comments yet: <code>forge comment {{ rv.id }}</code> adds one.</p>6162 {% endif %}62-<form class="reply" id="reply" data-post="{{ u.review(rv.id).href }}" data-at="{{ rv.tip }}" data-author="{{ rv.author or '' }}" hidden>63+<form class="reply panel-body" id="reply" data-post="{{ u.review(rv.id).href }}" data-at="{{ rv.tip }}" data-author="{{ rv.author or '' }}" hidden>6364 {{ ui.textarea('comment on #' + rv.id, 'Comment on #' + rv.id + '. Markdown is fine.', 'name="body" maxlength="32768"') }}6465 <div class="reply-bar"><span class="desc who"></span><span class="grow"></span><span class="reply-status" role="status"></span>{% if rv.live %}{{ ui.button('approve', false, 'data-approve title="approve this tree of #' + rv.id + ', as forge approve ' + rv.id + ' does"') }}{% endif %}{{ ui.button('comment', true, '', 'submit') }}</div>6566 </form>66-</section>67+{% endcall %}
M site/_includes/insights-body.njk +37 -35
@@ -2,23 +2,25 @@
22 (repo/insights.njk): stat tiles and charts that insights.ts draws from33 insights.json, each chart with its table, and the Parquet files behind44 them. A repo's page has `p`; the exports are in the page's directory. -#}5+{% import "ui.njk" as ui %}56 {% set forgeWide = not p %}67 {% set base = '/insights/' if forgeWide else p.repo.urls.insights.href %}78 {% css %}89 /* insights (insights.ts): the chart colours are tokens (--viz-*, checked910 for colour-blind readers against both pages); text keeps the page's own. */1011 .insights .ins-meta { margin: var(--space-1) 0 var(--space-4); }11-.insights > h3 { margin: var(--space-8) 0 0; padding-bottom: var(--space-2); border-bottom: var(--hairline) solid var(--border); font-size: var(--text-md); }12-.insights .kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0 var(--space-5); margin: 0 0 var(--space-2); }13-.insights .kpi { border-top: var(--hairline) solid var(--border); padding: var(--space-3) 0; }12+/* The totals: tiles in one panel, a hairline between them. */13+.insights .kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: var(--hairline); background: var(--border); }14+.insights .kpis:empty { display: none; }15+.insights .kpi { background: var(--raised); padding: var(--space-3); }1416 .insights .kpi .label { font-size: var(--text-sm); color: var(--muted); }1517 .insights .kpi .value { font: var(--fw-medium) var(--text-xl)/1.2 var(--mono); margin-top: var(--space-1); font-variant-numeric: tabular-nums; }1618 .insights .kpi .sub { color: var(--muted); font-size: var(--text-sm); }17-.insights .charts { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 440px), 1fr)); gap: var(--space-3) var(--space-5); }18-.insights figure { margin: 0; min-width: 0; position: relative; }19-.insights figcaption h3 { margin: var(--space-6) 0 var(--space-half); font-size: var(--text-md); }20-.insights figcaption .desc { font-size: var(--text-sm); }21-.insights .plot { position: relative; margin-top: var(--space-2); }19+/* Each chart is a panel: its name and what it counts in the head strip,20+ the plot, then its table folded. */21+.insights .charts { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 440px), 1fr)); gap: var(--space-4); margin: 0 0 var(--space-4); }22+.insights figure.panel { margin: 0; position: relative; }23+.insights .plot { position: relative; margin: var(--space-3) var(--space-3) 0; }2224 .insights .plot:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }2325 .insights svg { display: block; width: 100%; overflow: visible; font: var(--fw-regular) var(--text-xs) var(--mono); font-variant-numeric: tabular-nums; }2426 .insights svg .grid { stroke: var(--viz-grid); stroke-width: 1; shape-rendering: crispEdges; }
@@ -26,57 +28,57 @@
2628 .insights svg .tick { fill: var(--muted); }2729 .insights svg .s1 { fill: var(--viz-1); }2830 .insights svg .fail { fill: var(--viz-fail); }29-.insights svg .dot { stroke: var(--bg); stroke-width: 2; }31+.insights svg .dot { stroke: var(--raised); stroke-width: 2; }3032 .insights svg .hair { stroke: var(--muted); stroke-width: 1; shape-rendering: crispEdges; }3133 .insights svg .on { filter: brightness(1.15); }32-.insights svg .mark-label { fill: var(--fg); paint-order: stroke; stroke: var(--bg); stroke-width: 3px; stroke-linejoin: round; }33-.insights .empty-chart { color: var(--muted); font-size: var(--text-sm); padding: var(--space-8) 0; text-align: center; border-block: var(--hairline) solid var(--border); }34-.insights .legend { display: flex; gap: var(--space-4); font-size: var(--text-sm); color: var(--muted); margin-top: var(--space-1); }34+.insights svg .mark-label { fill: var(--fg); paint-order: stroke; stroke: var(--raised); stroke-width: 3px; stroke-linejoin: round; }35+.insights .empty-chart { color: var(--muted); font-size: var(--text-sm); padding: var(--space-8) 0; text-align: center; }36+.insights .legend { display: flex; gap: var(--space-4); font-size: var(--text-sm); color: var(--muted); margin: var(--space-1) var(--space-3) 0; }3537 .insights .legend i { display: inline-block; width: 10px; height: 10px; margin-right: var(--space-1); vertical-align: -1px; }3638 .insights .tip { position: absolute; z-index: 2; pointer-events: none; background: var(--raised); border: var(--hairline) solid var(--border); box-shadow: var(--shadow); padding: var(--space-1) var(--space-2); font-size: var(--text-sm); white-space: nowrap; }3739 .insights .tip b { font-size: var(--text-md); }3840 .insights .tip .k { display: inline-block; width: 10px; height: 2px; margin-right: var(--space-2); vertical-align: middle; }39-.insights details.table { margin-top: var(--space-1); font-size: var(--text-sm); }41+.insights details.table { margin-top: var(--space-2); padding: var(--space-2) var(--space-3); border-top: var(--hairline) solid var(--border); font-size: var(--text-sm); }4042 .insights details.table summary { color: var(--muted); cursor: pointer; font-weight: var(--fw-medium); }4143 .insights details.table table { border-collapse: collapse; margin-top: var(--space-1); font-variant-numeric: tabular-nums; }4244 .insights details.table td, .insights details.table th { padding: var(--space-half) var(--space-3) var(--space-half) 0; text-align: left; border-bottom: var(--hairline) solid var(--border); }4345 .insights details.table th { font-weight: var(--fw-medium); color: var(--muted); }4446 .insights details.table .scroll { max-height: 240px; overflow: auto; }45-.insights .files td { vertical-align: top; padding: var(--space-half) var(--space-3) var(--space-half) 0; }46-.insights .files td:first-child { white-space: nowrap; }47-.insights pre.cmd { overflow-x: auto; background: var(--raised); border: var(--hairline) solid var(--border); padding: var(--space-2) var(--space-3); font-size: var(--text-sm); }47+.insights .data td.mono { font-size: var(--text-sm); }48+.insights pre.cmd { margin: 0; overflow-x: auto; white-space: pre; padding: var(--space-3); border-top: var(--hairline) solid var(--border); font-size: var(--text-sm); }4849 @media (forced-colors: active) {4950 .insights svg .s1, .insights svg .fail { fill: CanvasText; }5051 }5152 {% endcss %}5253 <div class="insights" data-insights="{{ base }}insights.json"{% if forgeWide %} data-forge="1"{% endif %}>5354 <p class="desc ins-meta" id="ins-meta">From the forge's lake (<code>forge lake</code>).<noscript> The charts need JavaScript; the numbers are in the files below.</noscript></p>54-<div class="kpis" id="ins-kpis"></div>55+<div class="panel kpis" id="ins-kpis"></div>5556 <div class="charts">56-<figure data-chart="builds"><figcaption><h3>build time</h3><span class="desc">seconds per build of {{ 'the pages' if forgeWide else "this repo's pages" }}</span></figcaption><div class="plot"></div><details class="table"><summary>table</summary></details></figure>57-<figure data-chart="pushes"><figcaption><h3>pushes</h3><span class="desc">pushes a <span class="ins-bucket">day</span></span></figcaption><div class="plot"></div><details class="table"><summary>table</summary></details></figure>58-<figure data-chart="land"><figcaption><h3>time to land</h3><span class="desc">hours from a review's first push to landing, by when it landed</span></figcaption><div class="plot"></div><details class="table"><summary>table</summary></details></figure>59-<figure data-chart="revisions"><figcaption><h3>revisions per review</h3><span class="desc">reviews by how many revisions they were pushed in</span></figcaption><div class="plot"></div><details class="table"><summary>table</summary></details></figure>60-<figure data-chart="ci"><figcaption><h3>ci results</h3><span class="desc">attestations a <span class="ins-bucket">day</span>, passed and failed</span></figcaption><div class="plot"></div><details class="table"><summary>table</summary></details></figure>61-<figure data-chart="ciTime"><figcaption><h3>ci duration</h3><span class="desc">seconds per run of <code>.forge/ci</code></span></figcaption><div class="plot"></div><details class="table"><summary>table</summary></details></figure>57+<figure class="panel" data-chart="builds"><figcaption class="panel-head"><h3>build time</h3><span class="grow">seconds per build of {{ 'the pages' if forgeWide else "this repo's pages" }}</span></figcaption><div class="plot"></div><details class="table"><summary>table</summary></details></figure>58+<figure class="panel" data-chart="pushes"><figcaption class="panel-head"><h3>pushes</h3><span class="grow">pushes a <span class="ins-bucket">day</span></span></figcaption><div class="plot"></div><details class="table"><summary>table</summary></details></figure>59+<figure class="panel" data-chart="land"><figcaption class="panel-head"><h3>time to land</h3><span class="grow">hours from a review's first push to landing, by when it landed</span></figcaption><div class="plot"></div><details class="table"><summary>table</summary></details></figure>60+<figure class="panel" data-chart="revisions"><figcaption class="panel-head"><h3>revisions per review</h3><span class="grow">reviews by how many revisions they were pushed in</span></figcaption><div class="plot"></div><details class="table"><summary>table</summary></details></figure>61+<figure class="panel" data-chart="ci"><figcaption class="panel-head"><h3>ci results</h3><span class="grow">attestations a <span class="ins-bucket">day</span>, passed and failed</span></figcaption><div class="plot"></div><details class="table"><summary>table</summary></details></figure>62+<figure class="panel" data-chart="ciTime"><figcaption class="panel-head"><h3>ci duration</h3><span class="grow">seconds per run of <code>.forge/ci</code></span></figcaption><div class="plot"></div><details class="table"><summary>table</summary></details></figure>6263 </div>6364 {% if forgeWide %}64-<h3>by repo</h3>65-<div class="box">65+{% call ui.panel('<b>by repo</b>') %}6666 <table class="list nav" id="ins-repos">6767 <thead><tr><th>repository</th><th class="num">pushes</th><th class="num">builds</th><th class="num">median build</th><th class="num">landed</th><th class="num">ci passed</th><th>last push</th></tr></thead>6868 <tbody></tbody>6969 </table>70-</div>70+{% endcall %}7171 {% endif %}72-<h3>data</h3>73-<p class="desc">The rows behind the charts{{ ', for public repos only' if forgeWide else '' }}, as Parquet. DuckDB reads them over HTTP in byte ranges, so a query fetches only what it needs:</p>74-<table class="files desc">75-<tr><td><a href="{{ base }}insights.json">insights.json</a></td><td>what the charts draw</td></tr>76-<tr><td><a href="{{ base }}events.parquet">events.parquet</a></td><td>pushes, comments, approvals and builds as they happened</td></tr>77-<tr><td><a href="{{ base }}builds.parquet">builds.parquet</a></td><td>each page build: time spent, pages written</td></tr>78-<tr><td><a href="{{ base }}reviews.parquet">reviews.parquet</a></td><td>each review's lifecycle: created, revisions, approvals, landed or closed</td></tr>79-<tr><td><a href="{{ base }}ci.parquet">ci.parquet</a></td><td>each CI attestation: commit, tree, result, duration, system, signer</td></tr>80-</table>72+{% set head %}<b>data</b><span class="grow">the rows behind the charts{{ ', for public repos only' if forgeWide else '' }}, as Parquet</span>{% endset %}73+{% call ui.panel(head, 'data') %}74+{% call ui.listTable([], 'data') %}75+<tr><td class="mono nowrap"><a href="{{ base }}insights.json">insights.json</a></td><td class="main">what the charts draw</td></tr>76+<tr><td class="mono nowrap"><a href="{{ base }}events.parquet">events.parquet</a></td><td class="main">pushes, comments, approvals and builds as they happened</td></tr>77+<tr><td class="mono nowrap"><a href="{{ base }}builds.parquet">builds.parquet</a></td><td class="main">each page build: time spent, pages written</td></tr>78+<tr><td class="mono nowrap"><a href="{{ base }}reviews.parquet">reviews.parquet</a></td><td class="main">each review's lifecycle: created, revisions, approvals, landed or closed</td></tr>79+<tr><td class="mono nowrap"><a href="{{ base }}ci.parquet">ci.parquet</a></td><td class="main">each CI attestation: commit, tree, result, duration, system, signer</td></tr>80+{% endcall %}81+<p class="desc">DuckDB reads them over HTTP in byte ranges, so a query fetches only what it needs:</p>8182 <pre class="cmd">duckdb -c "SELECT result, count(*), median(duration_s) FROM '{{ forge.config.baseUrl or 'https://<this forge>' }}{{ base }}ci.parquet' GROUP BY result"</pre>83+{% endcall %}8284 </div>
M site/_includes/login.njk +4 -4
M site/_includes/macros.njk +21 -22
@@ -81,14 +81,13 @@
8181 revised review links what changed in its latest push. #}8282 {% macro reviewItem(r, repo, depth = 0) %}8383 {% css %}84-.rv-list { list-style: none; margin: 0; padding: 0; }85-.rv { position: relative; display: grid; grid-template-columns: auto 1fr auto; column-gap: var(--space-3); row-gap: var(--space-1); align-items: baseline; padding: var(--space-3) var(--space-2); border-bottom: var(--hairline) solid var(--border); }86-.rv:hover, .rv.sel { background: var(--hover); }84+.rv { position: relative; display: grid; grid-template-columns: auto 1fr auto; column-gap: var(--space-3); row-gap: var(--space-1); align-items: baseline; padding: var(--space-2) var(--space-3); }85+.rv:hover, .rv:focus-within, .rv.sel { background: var(--hover); }8786 .rv.sel { box-shadow: inset 2px 0 0 var(--accent); }8887 .rv > .status { grid-row: 1; }8988 .rv-title { min-width: 0; font-weight: var(--fw-strong); overflow-wrap: anywhere; }9089 .rv-title a { color: var(--fg); }91-.rv-title a:hover { color: var(--deep); }90+.rv-title a:hover { color: var(--fg); }9291 .rv-title a::after { content: ''; position: absolute; inset: 0; }9392 .rv-side { display: flex; gap: var(--space-3); align-items: baseline; justify-content: flex-end; white-space: nowrap; font: var(--fw-regular) var(--text-xs) var(--mono); }9493 .rv-side .desc { font-family: var(--sans); font-size: var(--text-sm); }
@@ -98,7 +97,8 @@
9897 .rv-meta .add { color: var(--add-fg); }9998 .rv-meta .del { color: var(--del-fg); }10099 .rv-meta a, .rv-side a { position: relative; z-index: 1; }101-.rv.layer { margin-left: calc(var(--depth) * var(--space-6)); border-left: var(--hairline) solid var(--border); }100+.rv.layer { padding-left: calc(var(--space-3) + var(--depth) * var(--space-6)); }101+.rv.layer::before { content: ''; position: absolute; top: 0; bottom: 0; left: calc(var(--space-1) + var(--depth) * var(--space-6)); border-left: var(--hairline) solid var(--border-strong); }102102 .appr { white-space: nowrap; font-weight: var(--fw-strong); color: var(--pass); }103103 .appr + .appr { margin-left: var(--space-2); }104104 .appr.stale { color: var(--warn); font-weight: var(--fw-regular); }
@@ -106,7 +106,8 @@
106106 .rv { grid-template-columns: auto 1fr; }107107 .rv-side { grid-column: 2; justify-content: flex-start; }108108 .rv-meta { grid-column: 1 / 3; }109- .rv.layer { margin-left: calc(var(--depth) * var(--space-3)); }109+ .rv.layer { padding-left: calc(var(--space-3) + var(--depth) * var(--space-3)); }110+ .rv.layer::before { left: calc(var(--space-1) + var(--depth) * var(--space-3)); }110111 }111112 {% endcss %}112113 {% set last = r.revisions[r.revisions.length - 1] %}
@@ -120,7 +121,7 @@
120121 121122 {# list: reviews nested by stack, as {review, depth} (nest() in lib/reviews.ts). #}122123 {% macro reviews(list, repo) %}123-<ul class="rv-list nav-list">124+<ul class="rows rv-list nav-list">124125 {% for e in list %}{{ reviewItem(e.review, repo, e.depth) }}{% endfor %}125126 </ul>126127 {% endmacro %}
@@ -145,23 +146,21 @@
145146 rows with j and k. #}146147 {% macro shelves(repos, owners = false) %}147148 {% css %}148-.shelf-bar { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-6); align-items: center; margin: 0 0 var(--space-2); border-bottom: var(--hairline) solid var(--border); }149+.shelf-bar { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-6); align-items: center; margin: 0 0 var(--space-4); border-bottom: var(--hairline) solid var(--border); }149150 .shelf-bar nav.tabs { flex: 0 1 auto; min-width: 0; }150-.shelf-bar.plain { border-bottom: 0; margin-bottom: calc(-1 * var(--control-h) - var(--space-2)); }151+.shelf-bar.plain { border-bottom: 0; }151152 .shelf-bar .grow { flex: 1; }152153 .shelf-tools { display: flex; gap: var(--space-2); align-items: center; padding: var(--space-1) 0; }153154 .shelf-tools[hidden] { display: none; }154-.shelf-head { display: flex; align-items: baseline; gap: var(--space-2); margin: var(--space-6) 0 0; padding-bottom: var(--space-2); border-bottom: var(--hairline) solid var(--border); scroll-margin-top: var(--space-3); }155-.shelf-head h2 { margin: 0; padding: 0; border: 0; }156-.shelf-head .n { font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--faint); }157-/* Repos as rows between hairlines: name, what it is, then its sites, reviews and last push. */158-.cards { list-style: none; margin: 0; padding: 0; }159-.card { position: relative; display: grid; grid-template-columns: minmax(8em, 15em) 1fr auto; column-gap: var(--space-4); align-items: baseline; min-width: 0; padding: var(--space-2); border-bottom: var(--hairline) solid var(--border); }155+.shelf { scroll-margin-top: var(--space-3); }156+/* Repos as rows in their shelf's panel: name, what it is, then its sites,157+ reviews and last push. */158+.card { position: relative; display: grid; grid-template-columns: minmax(8em, 15em) 1fr auto; column-gap: var(--space-4); align-items: baseline; min-width: 0; padding: var(--space-2) var(--space-3); }160159 .card:hover, .card:focus-within, .card.sel { background: var(--hover); }161160 .card.sel { box-shadow: inset 2px 0 0 var(--accent); }162161 .card-top { min-width: 0; }163162 .card-name { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: var(--fw-strong); color: var(--fg); }164-.card-name:hover { color: var(--deep); }163+.card-name:hover { color: var(--fg); text-decoration: underline; }165164 .card-name::after { content: ''; position: absolute; inset: 0; }166165 .card-name .desc { font-weight: var(--fw-regular); }167166 .card-desc { grid-column: 2; margin: 0; color: var(--muted); font-size: var(--text-sm); overflow-wrap: anywhere; }
@@ -174,7 +173,7 @@
174173 .card { grid-template-columns: 1fr; row-gap: var(--space-1); }175174 .card-desc, .card-foot { grid-column: 1; }176175 }177-.shelves-empty { color: var(--muted); padding: var(--space-6) 0; text-align: center; }176+178177 {% endcss %}179178 {% set list = repos | shelves %}180179 <div class="shelf-bar{% if list.length < 2 %} plain{% endif %}" id="shelves" data-shelves>
@@ -190,9 +189,9 @@
190189 </span>191190 </div>192191 {% for sh in list %}193-<section class="shelf" data-shelf="{{ sh.id }}">194-<div class="shelf-head" id="{{ sh.id }}"><h2>{{ sh.title }}</h2><span class="n">{{ sh.repos.length }}</span></div>195-<ul class="cards nav-cards">192+{% set head %}<h2>{{ sh.title }}</h2><span class="n">{{ sh.repos.length }}</span>{% endset %}193+{% call ui.panel(head, 'shelf', 'data-shelf="' + sh.id + '" id="' + sh.id + '"') %}194+<ul class="rows cards nav-cards">196195 {% for s in sh.repos %}197196 <li class="card" data-name="{{ s.name | lower }}" data-date="{{ s.lastCommit }}"{% if s.counts.sites %} data-site{% endif %}>198197 <div class="card-top"><a class="card-name" href="{{ s.urls.home.href }}">{% if owners %}<span class="desc">{{ s.owner }} / </span>{% endif %}{{ s.name }}</a></div>
@@ -201,9 +200,9 @@
201200 </li>202201 {% endfor %}203202 </ul>204-</section>203+{% endcall %}205204 {% endfor %}206-<p class="shelves-empty" hidden>No repos here have a site.</p>205+<div class="panel shelves-empty" hidden><p class="empty">No repos here have a site.</p></div>207206 {% endmacro %}208207 209208 {% macro sitesLink(s) %}
M site/_includes/private.njk +1 -1 mechanical
M site/_includes/search.njk +1 -1
M site/_includes/ui.njk +20 -3
@@ -3,7 +3,8 @@
33 control looks and behaves the same everywhere; /design/ shows each one.44 Import it with: {% import "ui.njk" as ui %}55 6- The rule: tabs say where you are; a checkbox turns one thing on or off;6+ The rule: content sits in panels (ui.panel), chrome on the page; tabs7+ say where you are; a checkbox turns one thing on or off;78 a segmented control picks one of a few; an icon toggle is a checkbox89 drawn as a symbol (with aria-pressed, a label and a title); buttons do910 things; links only go places. Chrome is written in lowercase here and
@@ -72,6 +73,21 @@
7273 <textarea class="input" aria-label="{{ label }}"{% if placeholder %} placeholder="{{ placeholder }}"{% endif %}{% if attrs %} {{ attrs | safe }}{% endif %}></textarea>7374 {%- endmacro %}7475 76+{# A panel: where content sits (lists, file viewers, READMEs, diffs,77+ discussions), raised over the page; the header, tabs and toolbars stay on78+ the page. head: the markup of its head strip, naming what it holds: quiet79+ words, the key item in <b> (or a link with class "key"), shas and ages in80+ .sha and time; capture it with {% set head %}...{% endset %} so what users81+ wrote is escaped. fold: it folds, its head strip the summary ('open'82+ starts it open). The body is the call's, if any:83+ {% call ui.panel(head) %}{{ m.files(entries) }}{% endcall %} #}84+{% macro panel(head = '', cls = '', attrs = '', fold = '') -%}85+{% if fold %}<details class="panel{% if cls %} {{ cls }}{% endif %}"{% if fold == 'open' %} open{% endif %}{% if attrs %} {{ attrs | safe }}{% endif %}><summary class="panel-head">{{ head | safe }}</summary>86+{% else %}<section class="panel{% if cls %} {{ cls }}{% endif %}"{% if attrs %} {{ attrs | safe }}{% endif %}>{% if head %}<header class="panel-head">{{ head | safe }}</header>{% endif %}87+{% endif %}{% if caller %}{{ caller() }}{% endif %}88+{% if fold %}</details>{% else %}</section>{% endif %}89+{%- endmacro %}90+7591 {# A list: a table with rows between hairlines that restacks on phones7692 (stack). heads: column names, or {text, cls, title}. The rows are the7793 call's body: {% call ui.listTable(['branch', 'updated']) %}<tr>...{% endcall %} #}
@@ -86,8 +102,9 @@
86102 {# A review's status: the one filled label, white on its colour. #}87103 {% macro status(s) %}<span class="status {{ s }}">{{ s }}</span>{% endmacro %}88104 89-{# A section heading on a rule: its words, then a count (n) and a quieter90- note (desc). #}105+{# A section heading on the page, over the panels it names: its words, then106+ a count (n) and a quieter note (desc). A single panel's name goes in its107+ head strip instead. #}91108 {% macro heading(text, n = null, desc = '', id = '') -%}92109 <h2{% if id %} id="{{ id }}"{% endif %}>{{ text }}{% if n != null %} <span class="n">{{ n }}</span>{% endif %}{% if desc %} <span class="desc">{{ desc }}</span>{% endif %}</h2>93110 {%- endmacro %}
M site/assets/discuss.ts +1 -1 mechanical
M site/assets/style.css +86 -58
@@ -4,8 +4,9 @@
44 pages that use it. The rules for all of it are in DESIGN.md, and55 lib/check-css.ts holds every stylesheet to them in CI.66 7- The look: a plain page, square corners, content between 1px rules rather8- than in boxes; Open Sans for the interface, Noto Sans Mono for what is7+ The look: a plain page with the chrome on it (header, tabs, toolbars) and8+ the content in panels: raised, square, a hairline edge, an optional9+ tinted head strip, rows between hairlines inside; Open Sans for the interface, Noto Sans Mono for what is910 copied or compared, Noto Serif for prose people wrote; hierarchy from1011 weight, size and colour, never capitals or tracking; one accent1112 (#3148F6) for links, the current tab's bar, focus and primary actions. */
@@ -19,14 +20,16 @@
1920 @font-face { font-family: "Noto Serif"; src: url(./fonts/noto-serif.woff2) format("woff2"); font-weight: 400 700; font-style: normal; font-display: swap; }2021 @font-face { font-family: "Noto Serif"; src: url(./fonts/noto-serif-italic.woff2) format("woff2"); font-weight: 400 700; font-style: italic; font-display: swap; }2122 :root {22- /* colour: the page, surfaces raised over it, ink in three strengths23- (muted keeps 4.5:1 on the page), rules, and the accent */23+ /* colour: the page, panels raised over it with their tinted head strip24+ and a row's hover, ink in three strengths (muted keeps 4.5:1 on the25+ page and every surface, faint on a panel), rules, and the accent */2426 --bg: #e9e9e9;2527 --raised: #ffffff;26- --hover: #f4f4f4;28+ --tint: #f6f6f6;29+ --hover: #f2f3f5;2730 --fg: #1c2127;2831 --muted: #5b6169;29- --faint: #7c828a;32+ --faint: #6f757d;3033 --border: #d2d4d7;3134 --border-strong: #a9aeb4;3235 --link: #3148f6;
@@ -112,11 +115,12 @@
112115 :root {113116 --bg: #08090b;114117 --raised: #111418;115- --hover: #15181d;118+ --tint: #15191e;119+ --hover: #191d23;116120 --fg: #e2e4e7;117121 --muted: #8f959d;118- --faint: #6b7178;119- --border: #1f2329;122+ --faint: #7d838b;123+ --border: #252a31;120124 --border-strong: #3a4048;121125 --link: #8e9bff;122126 --deep: #b4bcff;
@@ -165,8 +169,10 @@ code, kbd, pre, time, .mono, .sha { font-family: var(--mono); }
165169 code, kbd, time, .sha, .mono { font-size: var(--text-inline-mono); }166170 .mono .sha, .mono time, .mono code, pre code { font-size: inherit; }167171 h1, h2, h3 { line-height: 1.25; font-weight: var(--fw-strong); }168-/* Section headings: sans, strong, on a rule; a count or note after them is quieter. */169-h2 { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-1) var(--space-2); margin: var(--space-8) 0 0; padding-bottom: var(--space-2); border-bottom: var(--hairline) solid var(--border); font-size: var(--text-md); color: var(--fg); }172+/* Section headings on the page: sans, strong, over the panel they name; a173+ count or note after them is quieter. A panel's own name goes in its head174+ strip instead. */175+h2 { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-1) var(--space-2); margin: var(--space-8) 0 var(--space-3); font-size: var(--text-md); color: var(--fg); }170176 h2 .desc, h2 .n { font-weight: var(--fw-regular); color: var(--faint); }171177 h2 .n { font: var(--fw-regular) var(--text-xs) var(--mono); font-variant-numeric: tabular-nums; }172178 h2 a { font-weight: var(--fw-medium); }
@@ -270,14 +276,50 @@ details.picker #branch-menu { position: absolute; z-index: 10; top: calc(100% +
270276 details.picker #branch-menu a { padding: var(--space-1) var(--space-3); font-family: var(--mono); font-size: var(--text-sm); }271277 details.picker #branch-menu a:hover { background: var(--hover); text-decoration: none; }272278 273-/* lists: tables without outer borders, rows between hairlines, headers in274- small quiet words; they restack on phones */279+/* panels (ui.panel): where content sits. Lists, file viewers, READMEs,280+ diffs and discussions sit in panels; the header, a page's title, tabs281+ and toolbars sit on the page. A panel is a raised surface with a282+ hairline edge (square, no shadow) and may have a head strip, tinted,283+ naming what it holds: quiet sans words, the key item in ink, shas and284+ ages in the mono. Rows inside are between hairlines, none under the285+ last, and a row's hover tints the whole row. Panels stack 16px apart. */286+.panel { display: block; min-width: 0; margin: 0 0 var(--space-4); background: var(--raised); border: var(--hairline) solid var(--border); }287+.panel:last-child { margin-bottom: 0; }288+.panel-head { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-1) var(--space-3); min-height: calc(var(--control-h) + 2 * var(--space-1)); padding: var(--space-1) var(--space-3); background: var(--tint); border-bottom: var(--hairline) solid var(--border); font-size: var(--text-sm); line-height: 1.5; color: var(--muted); }289+.panel-head :is(b, strong, h2, h3) { display: inline; margin: 0; padding: 0; border: 0; font-size: inherit; font-weight: var(--fw-medium); color: var(--fg); }290+.panel-head .key, .panel-head .key:hover, .panel-head :is(b, h2, h3) a, .panel-head :is(b, h2, h3) a:hover { color: var(--fg); }291+.panel-head :is(.n, time, .sha, .date) { font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--faint); font-variant-numeric: tabular-nums; white-space: nowrap; }292+.panel-head a.sha { color: var(--link); }293+.panel-head .grow { flex: 1 1 12em; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }294+.panel-head .end { margin-left: auto; display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-1) var(--space-3); }295+.panel-head .check, .panel-head button { min-height: 0; }296+.panel-head button { padding-block: var(--space-half); }297+/* A panel that folds: a details whose summary is the head strip. */298+details.panel > summary.panel-head { cursor: pointer; list-style: none; }299+details.panel > summary.panel-head::-webkit-details-marker { display: none; }300+details.panel > summary.panel-head::before { content: "\25BE"; width: 1em; margin-right: calc(-1 * var(--space-2)); color: var(--muted); }301+details.panel:not([open]) > summary.panel-head::before { content: "\25B8"; }302+details.panel:not([open]) > summary.panel-head { border-bottom: 0; }303+details.panel > summary.panel-head:hover { color: var(--fg); }304+.panel-body { padding: var(--space-3); }305+.panel-body > :first-child { margin-top: 0; }306+.panel-body > :last-child { margin-bottom: 0; }307+/* Rows: a list's items between hairlines; the last has none, the panel's edge is its rule. */308+.rows { list-style: none; margin: 0; padding: 0; }309+.rows > li { border-bottom: var(--hairline) solid var(--border); }310+.panel :is(.rows > li:last-child, table.list > tbody > tr:last-child > td, table.list > tbody > tr:last-child) { border-bottom: 0; }311+.empty { margin: 0; color: var(--muted); padding: var(--space-6) var(--space-3); text-align: center; }312+313+/* lists: tables of rows between hairlines, column names in the panel's314+ head strip; they restack on phones */275315 table.list { width: 100%; border-collapse: collapse; }276316 table.list th, table.list td { text-align: left; padding: var(--space-2); border-bottom: var(--hairline) solid var(--border); vertical-align: baseline; }277-table.list thead th { font-size: var(--text-xs); font-weight: var(--fw-medium); color: var(--muted); padding-top: var(--space-3); padding-bottom: var(--space-1); }317+table.list :is(th, td):first-child { padding-left: var(--space-3); }318+table.list :is(th, td):last-child { padding-right: var(--space-3); }319+table.list thead th { font-size: var(--text-sm); font-weight: var(--fw-regular); color: var(--muted); background: var(--tint); white-space: nowrap; }278320 table.list .num { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }279321 table.list td.num, table.list td.date { font: var(--fw-regular) var(--text-xs)/1.6 var(--mono); }280-table.list td.date { color: var(--muted); }322+table.list td.date { color: var(--faint); }281323 table.list td.date time { font-size: inherit; }282324 table.list .nowrap, table.list .date, table.list .sha, table.list td.mono { white-space: nowrap; }283325 table.list td.mono { font-size: var(--text-sm); }
@@ -289,24 +331,14 @@ table.list td.main > a:first-of-type:hover { color: var(--fg); }
289331 /* Hover tints the row, never a cell or a link, so it is one even block. */290332 table.list tbody tr:hover, table.list tbody tr:focus-within, table.list tbody tr.sel { background: var(--hover); }291333 table.list tbody tr.sel > td:first-child { box-shadow: inset 2px 0 0 var(--accent); }292-/* A box holds a list or prose under a heading row; only a file viewer293- (.viewer) is a bordered region. */294-.box + .box { margin-top: var(--space-6); }295-.box-head { border-bottom: var(--hairline) solid var(--border); padding: var(--space-2); display: flex; flex-wrap: wrap; gap: var(--space-1) var(--space-4); align-items: baseline; font-size: var(--text-sm); }296-.box-head .grow { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }297-.box-head .label { margin-right: auto; color: var(--fg); font-weight: var(--fw-strong); }298-.box.viewer { border: var(--hairline) solid var(--border); background: var(--raised); }299-.box.viewer > .box-head { padding: var(--space-1) var(--space-3); align-items: center; }300-.box.viewer > table.list tr:last-child td { border-bottom: 0; }301-.files td { padding-top: var(--space-1); padding-bottom: var(--space-1); }302-.files td.file-icon { width: 1.75em; color: var(--muted); padding-right: 0; }303-.files td.size { color: var(--muted); }304-.empty { color: var(--muted); padding: var(--space-6); text-align: center; }334+/* A directory: an icon cell, the name, the size. */335+table.files td.file-icon { width: 1%; padding-right: 0; color: var(--muted); }336+table.files td.size { color: var(--muted); }305337 306338 @media (max-width: 720px) {307339 table.list thead { display: none; }308- table.list.stack tr { display: flex; flex-wrap: wrap; gap: var(--space-half) var(--space-3); padding: var(--space-2); border-bottom: var(--hairline) solid var(--border); }309- table.list.stack td { border: 0; padding: 0; font-size: var(--text-sm); color: var(--muted); }340+ table.list.stack tr { display: flex; flex-wrap: wrap; gap: var(--space-half) var(--space-3); padding: var(--space-2) var(--space-3); border-bottom: var(--hairline) solid var(--border); }341+ table.list.stack td, table.list.stack td:first-child, table.list.stack td:last-child { border: 0; padding: 0; font-size: var(--text-sm); color: var(--muted); }310342 table.list.stack td.main { flex: 0 0 100%; font-size: var(--text-md); color: var(--fg); }311343 table.list.stack tbody tr.sel { box-shadow: inset 2px 0 0 var(--accent); }312344 table.list.stack tbody tr.sel > td:first-child { box-shadow: none; }
@@ -369,10 +401,10 @@ body.wrap-lines .code .hunk { white-space: pre-wrap; overflow-wrap: anywhere; }
369401 body:not(.no-wrap) .code .lc, body:not(.no-wrap) .code .hunk { white-space: pre-wrap; overflow-wrap: anywhere; }370402 body:not(.no-wrap) .code .rows { display: block; }371403 }372-section.file { margin: var(--space-4) 0; border: var(--hairline) solid var(--border); background: var(--raised); }373-.file > .code { border: 0; }374-.box > .code { border: 0; }375-.fh { position: sticky; top: 0; z-index: 2; display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-1) var(--space-2); padding: var(--space-2) var(--space-3); background: var(--raised); border-bottom: var(--hairline) solid var(--border); font-size: var(--text-sm); }404+/* In a panel, code is the panel's body: no second edge. A diff's file is a405+ panel whose head strip (.fh) sticks while its lines scroll by. */406+.panel > .code { border: 0; }407+.fh { position: sticky; top: 0; z-index: 2; gap: var(--space-1) var(--space-2); }376408 /* A long path shares the line with the status and counts, wrapping at its377409 slashes, rather than wrapping onto a line of its own. */378410 .fh .fname { font-family: var(--mono); color: var(--fg); font-weight: var(--fw-medium); overflow-wrap: break-word; flex: 1 1 0; min-width: min(4em, 100%); max-width: max-content; }
@@ -411,20 +443,19 @@ table.diffstat td.main a { font-family: var(--mono); font-size: var(--text-sm);
411443 .meta-head { margin: 0 0 var(--space-4); }412444 .meta-head h1 { font-size: var(--text-xl); margin: 0 0 var(--space-2); overflow-wrap: anywhere; }413445 .meta-head h1 a { color: var(--fg); }414-.meta-head h1 a:hover { color: var(--deep); }446+.meta-head h1 a:hover { color: var(--fg); }415447 .meta-head h1 .desc { font: var(--fw-regular) var(--text-lg) var(--mono); color: var(--faint); }416448 .meta-head .byline { color: var(--muted); font-size: var(--text-sm); }417-pre.msg { white-space: pre-wrap; overflow-wrap: anywhere; font: var(--fw-regular) var(--text-sm)/1.55 var(--mono); background: var(--raised); border: var(--hairline) solid var(--border); padding: var(--space-3); margin: var(--space-3) 0; }418-details.att > summary, details.howto > summary { cursor: pointer; color: var(--muted); font-size: var(--text-sm); font-weight: var(--fw-medium); padding: var(--space-2) 0; }419-details.att > summary:hover, details.howto > summary:hover { color: var(--fg); }420-details.att pre, details.howto pre { white-space: pre-wrap; overflow-wrap: anywhere; font: var(--fw-regular) var(--text-xs)/1.55 var(--mono); background: var(--raised); border: var(--hairline) solid var(--border); padding: var(--space-3); }449+/* A commit message, or a command to copy, as a panel's body. */450+.msg, .panel > pre { margin: 0; padding: var(--space-3); white-space: pre-wrap; overflow-wrap: anywhere; font: var(--fw-regular) var(--text-sm)/1.55 var(--mono); }451+.panel > pre { font-size: var(--text-xs); }452+.panel > .msg + .facts { border-top: var(--hairline) solid var(--border); }453+/* A note under a panel's body, on a rule. */454+.panel > p.desc { margin: 0; padding: var(--space-2) var(--space-3); border-top: var(--hairline) solid var(--border); font-size: var(--text-sm); }455+.panel > p.desc:first-child, .panel > .panel-head + p.desc { border-top: 0; }421456 .depth { color: var(--faint); white-space: pre; }422457 423-/* Above a diff: the diffstat, with the page's tools on its summary line. */424-.changes { display: grid; grid-template-columns: 1fr auto; align-items: center; column-gap: var(--space-3); }425-.changes > * { grid-column: 1 / -1; }426-.changes > .tools { grid-column: 2; grid-row: 1; display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: var(--space-1) var(--space-3); }427-.changes > .tools + * { grid-column: 1; grid-row: 1; }458+/* Above a diff: the diffstat, a panel with the page's tools in its head strip. */428459 .count { color: var(--muted); font-size: var(--text-sm); white-space: nowrap; }429460 @media (max-width: 480px) { .count .wide { display: none; } }430461 .file .code .l { scroll-margin-top: 2.75rem; }
@@ -443,7 +474,7 @@ details.att pre, details.howto pre { white-space: pre-wrap; overflow-wrap: anywh
443474 444475 /* prose: README, wiki, release notes, review descriptions, comments: the445476 serif, for what people wrote */446-.markdown { font: var(--fw-regular) var(--text-prose)/1.65 var(--serif); overflow-wrap: break-word; padding: var(--space-4) var(--space-2); }477+.markdown { font: var(--fw-regular) var(--text-prose)/1.65 var(--serif); overflow-wrap: break-word; padding: var(--space-5) var(--space-6); }447478 .markdown > :first-child { margin-top: 0; }448479 .markdown h1, .markdown h2, .markdown h3, .markdown h4 { font-family: var(--sans); font-weight: var(--fw-strong); color: var(--fg); }449480 .markdown h1, .markdown h2 { display: block; border-bottom: var(--hairline) solid var(--border); padding-bottom: var(--space-1); }
@@ -454,15 +485,13 @@ details.att pre, details.howto pre { white-space: pre-wrap; overflow-wrap: anywh
454485 .markdown a:visited { color: var(--deep); }455486 .markdown img { max-width: 100%; height: auto; }456487 .markdown p[align="center"], .markdown div[align="center"] { text-align: center; }457-.markdown code { background: var(--raised); border: var(--hairline) solid var(--border); padding: 0 var(--space-1); }458-/* Inline code never breaks at a hyphen: lib/markdown.ts keeps hyphenated459- words whole (.nobr) and lets a path break after its slashes (<wbr>). */488+/* Code in prose sits on the panel's tint. Inline code never breaks at a489+ hyphen: lib/markdown.ts keeps hyphenated words whole (.nobr) and lets a490+ path break after its slashes (<wbr>). */491+.markdown code { background: var(--tint); border: var(--hairline) solid var(--border); padding: 0 var(--space-1); }460492 .markdown code .nobr { white-space: nowrap; }461-.markdown pre { font: var(--fw-regular) var(--text-code)/1.55 var(--mono); background: var(--raised); border: var(--hairline) solid var(--border); padding: var(--space-3) var(--space-4); overflow-x: auto; tab-size: 4; }493+.markdown pre { font: var(--fw-regular) var(--text-code)/1.55 var(--mono); background: var(--tint); border: var(--hairline) solid var(--border); padding: var(--space-3) var(--space-4); overflow-x: auto; tab-size: 4; }462494 .markdown pre code { background: none; border: 0; padding: 0; }463-/* In the raised file viewer, code sits on the page's colour, not in a second box. */464-.viewer .markdown pre, .viewer .markdown code { background: var(--bg); border-color: var(--bg); }465-.viewer .markdown pre code { background: none; }466495 /* Diagrams, drawn at build time (lib/diagrams.ts): Pele's SVG takes its467496 colours and fonts from these, so it follows light and dark as it is. */468497 .markdown .diagram { margin: var(--space-4) 0; text-align: center; container-type: inline-size; }
@@ -475,8 +504,8 @@ details.att pre, details.howto pre { white-space: pre-wrap; overflow-wrap: anywh
475504 .markdown .diagram-source { margin: var(--space-4) 0; }476505 .markdown .diagram-source figcaption { color: var(--muted); font-size: var(--text-sm); }477506 .pele {478- --pele-bg: var(--bg); --pele-fg: var(--fg); --pele-muted: var(--muted); --pele-line: var(--muted);479- --pele-surface: var(--raised); --pele-surface-alt: var(--hover); --pele-border: var(--border-strong); --pele-accent: var(--accent);507+ --pele-bg: var(--raised); --pele-fg: var(--fg); --pele-muted: var(--muted); --pele-line: var(--muted);508+ --pele-surface: var(--tint); --pele-surface-alt: var(--hover); --pele-border: var(--border-strong); --pele-accent: var(--accent);480509 --pele-critical: var(--fail); --pele-error: var(--fail); --pele-font: var(--sans); --pele-font-mono: var(--mono); --pele-radius: var(--radius);481510 --pele-title-weight: var(--fw-strong); --pele-heading-weight: var(--fw-strong); --pele-bold-weight: var(--fw-strong);482511 --pele-series-1: var(--viz-1); --pele-series-2: var(--pass); --pele-series-3: var(--warn); --pele-series-4: var(--fail);
@@ -489,7 +518,7 @@ details.att pre, details.howto pre { white-space: pre-wrap; overflow-wrap: anywh
489518 .markdown hr { border-top: var(--hairline) solid var(--border); }490519 .markdown :target { scroll-margin-top: var(--space-4); }491520 @media (max-width: 720px) {492- .markdown { padding: var(--space-3) var(--space-1); }521+ .markdown { padding: var(--space-4) var(--space-3); }493522 .markdown h1 { font-size: var(--text-xl); }494523 .markdown h2 { font-size: var(--text-lg); }495524 }
@@ -512,14 +541,13 @@ table.log td.risk { width: 1%; }
512541 table.log td.kind { order: 1; }513542 table.log td.kind:empty { display: none; }514543 }515-.releases-link { margin-left: var(--space-1); }516-.notes { padding: var(--space-half) var(--space-2) var(--space-3); font: var(--fw-regular) var(--text-md)/1.6 var(--serif); }544+.notes { padding: var(--space-1) var(--space-3) var(--space-3); font: var(--fw-regular) var(--text-md)/1.6 var(--serif); }517545 .notes h3 { margin: var(--space-4) 0 var(--space-1); font: var(--fw-strong) var(--text-sm)/1.4 var(--sans); color: var(--muted); }518546 .notes ul { margin: 0; padding-left: var(--space-5); }519547 .notes li { margin: var(--space-half) 0; overflow-wrap: anywhere; }520548 /* Each note is a commit's subject, linked: in ink, like a list's rows. */521549 .notes li > a:first-child { color: var(--fg); }522-.notes li > a:first-child:hover { color: var(--deep); }550+.notes li > a:first-child:hover { color: var(--fg); }523551 .notes details { margin-top: var(--space-3); }524552 .notes summary { cursor: pointer; font: var(--fw-strong) var(--text-sm)/1.4 var(--sans); color: var(--muted); }525553 .notes summary:hover { color: var(--fg); }
M site/design.njk +44 -16
@@ -19,9 +19,9 @@ permalink: /design/index.html
1919 .dg-intro p { margin: 0 0 var(--space-2); }2020 .dg-rules { margin: 0; padding-left: var(--space-5); }2121 .dg-rules li { margin: var(--space-1) 0; }22-.dg-row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3) var(--space-4); padding: var(--space-4) 0; border-bottom: var(--hairline) solid var(--border); }22+.dg-row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3) var(--space-4); padding: var(--space-3); border-bottom: var(--hairline) solid var(--border); }23+.dg-row:last-child { border-bottom: 0; }2324 .dg-row > .label { flex: 0 0 9em; }24-.dg-group { margin: var(--space-4) 0 var(--space-1); font-size: var(--text-sm); font-weight: var(--fw-medium); color: var(--muted); }2525 .dg-swatch { display: inline-block; width: 28px; height: 20px; border: var(--hairline) solid var(--border-strong); vertical-align: middle; }2626 table.dg-tokens { table-layout: fixed; }2727 table.dg-tokens td { vertical-align: middle; overflow: hidden; text-overflow: ellipsis; }
@@ -30,10 +30,12 @@ table.dg-tokens td:nth-child(2) { width: 22%; }
3030 table.dg-tokens td.v { font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--muted); white-space: nowrap; }3131 .dg-sample { white-space: nowrap; }3232 .dg-bar { display: inline-block; height: var(--space-3); background: var(--accent); vertical-align: middle; }33-.dg-fam { padding: var(--space-3) 0; border-bottom: var(--hairline) solid var(--border); }33+.dg-fam { padding: var(--space-3); border-bottom: var(--hairline) solid var(--border); }34+.dg-fam:last-child { border-bottom: 0; }3435 .dg-fam .big { font-size: var(--text-xl); line-height: 1.3; }3536 .dg-fam .use { font-size: var(--text-sm); color: var(--muted); }36-.dg-legible { font: var(--fw-regular) var(--text-code)/1.6 var(--mono); }37+.dg-legible { margin: 0; padding: var(--space-2) var(--space-3); border-top: var(--hairline) solid var(--border); font: var(--fw-regular) var(--text-code)/1.6 var(--mono); }38+.dg-page { margin: 0 0 var(--space-4); }3739 .dg-legible span + span { margin-left: var(--space-4); }3840 @media (max-width: 720px) {3941 .dg-row > .label { flex-basis: 100%; }
@@ -48,12 +50,37 @@ table.dg-tokens td.v { font: var(--fw-regular) var(--text-xs) var(--mono); color
4850 <li>Tokens only: no colour, size, weight or family is written anywhere else.</li>4951 <li>Open Sans for the interface, Noto Sans Mono for what is copied or compared, Noto Serif for prose people wrote.</li>5052 <li>Chrome is lowercase, written that way; no capitals by CSS, no letter-spacing. Hierarchy is weight, size and colour.</li>51-<li>Square corners, hairline rules between things rather than boxes around them.</li>53+<li>Content sits in panels: lists, file viewers, READMEs, diffs and discussions, each on a raised surface with a hairline edge and a tinted head strip naming what it holds. The header, a page's title, tabs and toolbars sit on the page. Square corners, no shadows.</li>54+<li>Inside a panel, rows are between hairlines, none under the last; hover tints the whole row, and a row's own link stays in ink.</li>5255 <li>Tabs say where you are; checkboxes turn one thing on or off; segmented controls pick one of a few; buttons act; links go places. Words for where you are, symbols for how you look at it.</li>5356 </ul>5457 </div>5558 59+{{ ui.heading('on the page', null, 'chrome: a toolbar, tabs') }}60+<div class="bar dg-page"><details class="picker"><summary>{{ ui.icon('branch') }}<b>main</b></summary></details><span class="crumbs"><a href="#">forge</a> / <a href="#">site</a> / <a href="#">assets</a></span><span class="grow"></span>{{ ui.iconToggle('globe', 'only repos with a site') }}{{ ui.segmented('sort', [{icon: 'clock', label: 'newest first', pressed: true}, {icon: 'az', label: 'by name, a to z'}]) }}{{ ui.button('find file') }}</div>61+<div class="dg-page">{{ ui.tabs('example', [{href: '#tabs', text: 'all', n: 12, cur: true}, {href: '#tabs', text: 'tools', n: 5}, {href: '#tabs', text: 'games', n: 4}, {href: '#tabs', text: 'other', n: 3}]) }}</div>62+63+{{ ui.heading('panels', null, 'where content sits: ui.panel') }}64+{% set head %}<span class="grow"><b>dami</b> <a class="key" href="#panels">Keep a range-diff's marker at the left edge</a></span><span class="end"><a class="sha" href="#panels">8318d80</a><span class="ci pass">&#10003;</span><time>2h ago</time><a href="#panels">412 commits</a></span>{% endset %}65+{% call ui.panel(head) %}66+<table class="list files nav"><tbody>67+<tr><td class="file-icon">{{ ui.icon('folder') }}</td><td class="main"><a href="#panels">lib</a></td><td class="num size"></td></tr>68+<tr><td class="file-icon">{{ ui.icon('folder') }}</td><td class="main"><a href="#panels">site</a></td><td class="num size"></td></tr>69+<tr><td class="file-icon">{{ ui.icon('file') }}</td><td class="main"><a href="#panels">DESIGN.md</a></td><td class="num size">5.1K</td></tr>70+<tr><td class="file-icon">{{ ui.icon('file') }}</td><td class="main"><a href="#panels">README.md</a></td><td class="num size">9.8K</td></tr>71+</tbody></table>72+{% endcall %}73+{% call ui.panel('<a class="key mono" href="#panels">README.md</a>') %}<div class="markdown">74+<h2>A README in the serif</h2>75+<p>Prose people wrote is set in <em>Noto Serif</em>: READMEs, the wiki, release notes, a review's description and its comments. Code inside it, like <code>forge attest --stack</code> or <code>lib/<wbr><span class="nobr">text-widths.sh</span></code>, is in the mono, and never breaks at a hyphen.</p>76+<blockquote><p>Interface chrome never uses it.</p></blockquote>77+</div>{% endcall %}78+{% call ui.panel('<b>a panel that folds</b><span class="n">3</span><span class="grow">its head strip is the summary</span>', '', '', 'open') %}79+<p class="empty">An empty state: a quiet sentence, centred, in the panel it would fill.</p>80+{% endcall %}81+5682 {{ ui.heading('controls') }}83+{% call ui.panel('<b>in a panel</b><span class="grow">controls in a head strip shrink to fit it</span><span class="end">' + ui.checkbox('wrap lines') + ui.button('copy') + '</span>') %}5784 <div class="dg-row"><span class="label">buttons</span>{{ ui.button('copy') }}{{ ui.button('comment', true) }}{{ ui.button('approve', false, 'disabled') }}</div>5885 <div class="dg-row"><span class="label">icon toggle</span><span data-demo>{{ ui.iconToggle('globe', 'only repos with a site') }}</span><span data-demo>{{ ui.iconToggle('globe', 'only repos with a site, on', true) }}</span><span class="desc">a checkbox drawn as a symbol: aria-pressed, a label, a title</span></div>5986 <div class="dg-row"><span class="label">segmented</span><span data-demo>{{ ui.segmented('sort', [{icon: 'clock', label: 'newest first', pressed: true}, {icon: 'az', label: 'by name, a to z'}]) }}</span><span data-demo>{{ ui.segmented('view', [{label: 'unified', pressed: true}, {label: 'split'}]) }}</span></div>
@@ -64,16 +91,18 @@ table.dg-tokens td.v { font: var(--fw-regular) var(--text-xs) var(--mono); color
6491 <div class="dg-row"><span class="label">status labels</span>{{ ui.status('open') }}{{ ui.status('draft') }}{{ ui.status('merged') }}{{ ui.status('closed') }}</div>6592 <div class="dg-row"><span class="label">ci and diffs</span><span class="ci pass">&#10003; 2/2</span><span class="ci fail">&#10007; 1/2</span><span class="ci partial">&ndash; 1/2</span><span class="mono"><span class="i">+12</span> <span class="d">-3</span></span><span class="st A">A</span><span class="st M">M</span><span class="st D">D</span><span class="st R">R</span></div>6693 <div class="dg-row"><span class="label">icons</span>{% for i in ['globe', 'clock', 'az', 'branch', 'folder', 'file', 'link', 'up', 'search'] %}<span title="{{ i }}">{{ ui.icon(i, i) }}</span>{% endfor %}</div>94+{% endcall %}6795 6896 {{ ui.heading('section heading', 4, 'a count, then a quieter note') }}69-<div class="box">97+{% call ui.panel() %}7098 {% call ui.listTable(['commit', 'author', {text: '+', cls: 'num'}, {text: '-', cls: 'num'}, 'ci']) %}7199 <tr><td class="main"><a href="#list">Parse --name and --shout</a></td><td class="nowrap">dami</td><td class="num i">+12</td><td class="num d">-3</td><td><span class="ci pass">&#10003;</span></td></tr>72100 <tr><td class="main"><a href="#list">Keep a range-diff's marker at the left edge</a></td><td class="nowrap">ana</td><td class="num i">+4</td><td class="num d">-1</td><td><span class="ci fail">&#10007;</span></td></tr>73101 {% endcall %}74-</div>102+{% endcall %}75103 76-{{ ui.heading('code and prose') }}104+{{ ui.heading('code') }}105+<section class="panel file"><header class="panel-head fh"><span class="st M" title="modified">M</span> <a href="#code" class="fname">greet.sh</a> <span class="counts"><span class="i">+2</span> <span class="d">-1</span></span></header>77106 <div class="code diff" style="--w:2"><div class="rows">78107 <div class="hunk"><span>@@ -1,3 +1,4 @@ greet()</span></div>79108 <span class="l"><span class="ln">1</span><span class="ln">1</span><span class="mk"></span><span class="lc"><span class="hljs-meta">#!/bin/sh</span></span></span>
@@ -82,20 +111,19 @@ table.dg-tokens td.v { font: var(--fw-regular) var(--text-xs) var(--mono); color
82111 <span class="l add"><span class="ln"></span><span class="ln">3</span><span class="mk">+</span><span class="lc"><span class="hljs-built_in">echo</span> <span class="hljs-string">"hello, <span class="hljs-variable">$name</span>"</span> <span class="hljs-comment"># 0O 1lI 5S 8B rn m</span></span></span>83112 </div></div>84113 <p class="dg-legible">legibility at the code size: <span>0O o</span><span>1lI| i</span><span>rn m</span><span>{} () []</span><span>-- =&gt; !=</span></p>85-<div class="box"><div class="markdown">86-<h2>A README in the serif</h2>87-<p>Prose people wrote is set in <em>Noto Serif</em>: READMEs, the wiki, release notes, a review's description and its comments. Code inside it, like <code>forge attest --stack</code>, is in the mono.</p>88-<blockquote><p>Interface chrome never uses it.</p></blockquote>89-</div></div>114+</section>90115 91116 {{ ui.heading('type') }}117+{% call ui.panel() %}92118 <div class="dg-fam"><div class="big" style="font-family: var(--sans)">Open Sans: tabs, lists, subjects, buttons</div><div class="use"><code>--sans</code> all interface text</div></div>93119 <div class="dg-fam"><div class="big" style="font-family: var(--mono)">Noto Sans Mono: 0f0215e dami/help +12</div><div class="use"><code>--mono</code> anything copied or compared: hashes, ids, branches, paths, counts, code, diffs, commands</div></div>94120 <div class="dg-fam"><div class="big" style="font-family: var(--serif)">Noto Serif: what people wrote</div><div class="use"><code>--serif</code> rendered prose only</div></div>121+{% endcall %}95122 123+{{ ui.heading('tokens') }}96124 {% for g in designTokens %}97-<p class="dg-group">{{ g.title }}</p>98-<div class="box">125+{% set head %}<span>{{ g.title }}</span>{% endset %}126+{% call ui.panel(head) %}99127 <table class="list dg-tokens">100128 <tbody>101129 {% for t in g.tokens %}
@@ -112,6 +140,6 @@ table.dg-tokens td.v { font: var(--fw-regular) var(--text-xs) var(--mono); color
112140 {% endfor %}113141 </tbody>114142 </table>115-</div>143+{% endcall %}116144 {% endfor %}117145 {% endblock %}
M site/repo/blob.njk +8 -8
@@ -7,16 +7,16 @@ pagination:
77 {% import "ui.njk" as ui %}88 {% block content %}99 {% css %}10-.image-view { padding: var(--space-4); text-align: center; background: var(--raised); }10+.image-view { padding: var(--space-4); text-align: center; }1111 .image-view img { max-width: 100%; height: auto; }1212 {% endcss %}1313 <div class="bar">{{ m.branches(p.repo, p.branch.name, 'tree') }}{{ m.crumbs(p.repo, p.branch.name, p.crumbs) }}</div>14-<div class="box viewer">15-<div class="box-head">16-<span class="grow desc">{{ p.size | size }}{% if p.mode == '100755' %}, executable{% endif %}</span>17-{% if p.markdown %}<a href="{{ p.sourceHref }}">source</a>{% elif p.isSource %}<a href="{{ p.renderedHref }}">rendered</a>{% endif %}18-{% if not p.markdown and not p.image %}{{ ui.checkbox('wrap lines', false, 'data-wrap') }}{{ ui.button('copy', false, 'data-copy-code') }}{% endif %}19-</div>14+{% set head %}15+<span class="grow"><b class="mono">{{ p.path.split('/') | last }}</b> <span class="n">{{ p.size | size }}</span>{% if p.mode == '100755' %} executable{% endif %}</span>16+<span class="end">{% if p.markdown %}<a href="{{ p.sourceHref }}">source</a>{% elif p.isSource %}<a href="{{ p.renderedHref }}">rendered</a>{% endif %}17+{% if not p.markdown and not p.image %}{{ ui.checkbox('wrap lines', false, 'data-wrap') }}{{ ui.button('copy', false, 'data-copy-code') }}{% endif %}</span>18+{% endset %}19+{% call ui.panel(head, 'viewer') %}2020 {% if p.markdown %}<div class="markdown">{{ p | markdownHtml | safe }}</div>{% else %}{{ p | blobHtml | safe }}{% endif %}21-</div>21+{% endcall %}2222 {% endblock %}
M site/repo/commit.njk +8 -7
@@ -13,14 +13,15 @@ pagination:
1313 {% endblock %}1414 {% block content %}1515 {% css %}16-dl.facts { display: grid; grid-template-columns: max-content 1fr; align-items: baseline; gap: var(--space-1) var(--space-4); margin: var(--space-3) 0; font-size: var(--text-sm); }16+dl.facts { display: grid; grid-template-columns: max-content 1fr; align-items: baseline; gap: var(--space-1) var(--space-4); margin: 0; padding: var(--space-3); font-size: var(--text-sm); }1717 dl.facts dt { color: var(--muted); }1818 dl.facts dd { margin: 0; overflow-wrap: anywhere; }1919 {% endcss %}2020 <div class="meta-head">2121 <h1>{{ c.subject }}</h1>22-<div class="byline">{{ c.author.name }} committed {{ c.author.date | date }} <span class="sha">{{ c.short }}</span></div>2322 </div>23+{% set head %}<span class="grow"><b>{{ c.author.name }}</b> committed {{ c.author.date | date }}</span><span class="end"><span class="sha">{{ c.short }}</span></span>{% endset %}24+{% call ui.panel(head) %}2425 {% if c.body %}<pre class="msg">{{ c.body }}</pre>{% endif %}2526 <dl class="facts">2627 <dt>commit</dt><dd class="sha">{{ c.sha }}</dd>
@@ -30,14 +31,14 @@ dl.facts dd { margin: 0; overflow-wrap: anywhere; }
3031 {% if c.jev %}<dt>kind</dt><dd>{{ m.jevLabel(c) }}</dd>{% endif %}3132 {% for rv in c.reviews %}<dt>review</dt><dd><a href="{{ u.review(rv.id).href }}">#{{ rv.id }} {{ rv.title }}</a></dd>{% endfor %}3233 </dl>34+{% endcall %}3335 {% if c.ci %}34-<details class="att"><summary>ci attestation</summary>36+{% call ui.panel('<b>ci attestation</b>', '', '', 'closed') %}3537 <pre>{{ c.ci.latest.payload | statement }}</pre>3638 <p class="desc">Signature: {% if c.ci.latest.verification.ok %}good, {{ c.ci.latest.verification.signer }}{% if c.ci.latest.verification.key %} {{ c.ci.latest.verification.key }}{% endif %}{% else %}{{ c.ci.latest.verification.reason }}{% endif %}</p>37-</details>39+{% endcall %}3840 {% endif %}39-<div class="changes"><span class="tools">{{ ui.checkbox('wrap lines', false, 'data-wrap') }}</span>40-{{ c.files | diffstatHtml | safe }}41-</div>41+{% set tools %}{{ ui.checkbox('wrap lines', false, 'data-wrap') }}{% endset %}42+{{ c.files | diffstatHtml('', tools) | safe }}4243 {{ c.files | diffHtml(p.repo) | safe }}4344 {% endblock %}
M site/repo/home.njk +12 -18
@@ -9,27 +9,21 @@ pagination:
99 {% block head %}{{ m.meta(p.title, p.repo.description or (p.readme.html | excerpt(true) if p.readme and p.readme.html else ''), u.home.href) }}{% endblock %}1010 {% block content %}1111 {% if p.reviews.length %}12-<div class="box">13-<div class="box-head"><span class="label">open reviews</span><a href="{{ u.reviews.href }}">all reviews</a></div>14-{{ m.reviews(p.reviews, p.repo) }}15-</div>12+{% set head %}<b>open reviews</b><span class="n">{{ p.reviews.length }}</span><span class="end"><a href="{{ u.reviews.href }}">all reviews</a></span>{% endset %}13+{% call ui.panel(head) %}{{ m.reviews(p.reviews, p.repo) }}{% endcall %}1614 {% endif %}17-<div class="box">18-<div class="box-head">15+{# The files, under the last commit: who, what, which and when. #}16+{% set head %}1917 {% if p.latest %}20-<span class="grow"><a href="{{ u.commit(p.latest.sha).href }}">{{ p.latest.subject }}</a> <span class="desc">{{ p.latest.author.name }}, {{ p.latest.author.date | date }}</span> {{ p.latest.ci | ciHtml | safe }}</span>21-<a href="{{ u.log(p.repo.defaultBranch).href }}">{{ p.commitCount | plural('commit') }}</a>22-<a href="{{ u.refs.href }}">{{ p.branchCount | plural('branch', 'branches') }}</a>23-{% if p.release %}<a href="{{ p.release.href }}" title="the latest release: {{ p.release.files | plural('file') }}">release {{ p.release.tag }}</a>{% endif %}24-{{ m.topic(p) }}25-{% else %}<span class="grow desc">No commits yet. Push to {{ u.cloneSsh }}</span>{% endif %}26-</div>27-{% if p.entries.length %}{{ m.files(p.entries) }}{% endif %}28-</div>18+<span class="grow"><b>{{ p.latest.author.name }}</b> <a class="key" href="{{ u.commit(p.latest.sha).href }}">{{ p.latest.subject }}</a></span>19+<span class="end"><a class="sha" href="{{ u.commit(p.latest.sha).href }}">{{ p.latest.short }}</a>{{ p.latest.ci | ciHtml | safe }}{{ p.latest.author.date | date }}<a href="{{ u.log(p.repo.defaultBranch).href }}">{{ p.commitCount | plural('commit') }}</a><a href="{{ u.refs.href }}">{{ p.branchCount | plural('branch', 'branches') }}</a>{% if p.release %}<a href="{{ p.release.href }}" title="the latest release: {{ p.release.files | plural('file') }}">release {{ p.release.tag }}</a>{% endif %}{{ m.topic(p) }}</span>20+{% else %}<span class="grow">No commits yet. Push to <span class="mono">{{ u.cloneSsh }}</span></span>{% endif %}21+{% endset %}22+{% call ui.panel(head, 'files-panel') %}{% if p.entries.length %}{{ m.files(p.entries) }}{% endif %}{% endcall %}2923 {% if p.readme %}30-<div class="box">31-<div class="box-head"><a href="{{ u.blob(p.repo.defaultBranch, p.readme.path).href }}">{{ p.readme.path }}</a></div>24+{% set head %}<a class="key mono" href="{{ u.blob(p.repo.defaultBranch, p.readme.path).href }}">{{ p.readme.path }}</a>{% endset %}25+{% call ui.panel(head, 'readme') %}3226 {% if p.readme.html %}<div class="markdown">{{ p.readme.html | safe }}</div>{% else %}<div class="markdown"><pre>{{ p.readme.text }}</pre></div>{% endif %}33-</div>27+{% endcall %}3428 {% endif %}3529 {% endblock %}
M site/repo/log.njk +1 -1
M site/repo/refs.njk +7 -7
@@ -7,22 +7,22 @@ pagination:
77 {% import "ui.njk" as ui %}88 {% set u = p.repo.urls %}99 {% block content %}10-{{ ui.heading('branches', p.branches.length) }}11-<div class="box">12-{% call ui.listTable(['branch', 'updated', 'author', {text: 'behind', cls: 'num'}, {text: 'ahead', cls: 'num'}, 'review']) %}10+{% set head %}<b>branches</b><span class="n">{{ p.branches.length }}</span>{% endset %}11+{% call ui.panel(head) %}12+{% call ui.listTable() %}1313 {% for b in p.branches %}1414 <tr><td class="main mono"><a href="{{ u.tree(b.name).href }}">{{ b.name }}</a>{% if b.isDefault %} <span class="desc">default</span>{% endif %}</td><td class="date">{{ b.date | date }}</td><td class="nowrap hide-sm">{{ b.author }}</td><td class="num">{{ ('' ~ b.behind + ' behind') if not b.isDefault else '' }}</td><td class="num">{{ ('' ~ b.ahead + ' ahead') if not b.isDefault else '' }}</td><td>{% if b.review %}<a href="{{ u.review(b.review.id).href }}">#{{ b.review.id }}</a> {{ m.status(b.review.status) }}{% endif %}</td></tr>1515 {% endfor %}1616 {% endcall %}17-</div>17+{% endcall %}1818 {% if p.tags.length %}19-<h2>tags <span class="n">{{ p.tags.length }}</span> <a class="releases-link" href="{{ u.releases.href }}">releases</a></h2>20-<div class="box">19+{% set head %}<b>tags</b><span class="n">{{ p.tags.length }}</span><span class="end"><a href="{{ u.releases.href }}">releases</a></span>{% endset %}20+{% call ui.panel(head) %}2121 {% call ui.listTable() %}2222 {% for t in p.tags %}2323 <tr><td class="main mono">{{ t.sha | commitLink(p.repo, t.name) | safe }}</td><td class="date">{{ t.date | date }}</td><td class="hide-sm">{{ t.subject }}</td></tr>2424 {% endfor %}2525 {% endcall %}26-</div>26+{% endcall %}2727 {% endif %}2828 {% endblock %}
M site/repo/releases.njk +5 -6
@@ -8,7 +8,7 @@ pagination:
88 {% block content %}99 {% css %}1010 /* A release's files: name, size, sha256; then who signed SHA256SUMS. */11-.downloads { padding: var(--space-2) var(--space-2) var(--space-1); border-bottom: var(--hairline) solid var(--border); }11+.downloads { padding: var(--space-2) var(--space-3); border-bottom: var(--hairline) solid var(--border); }1212 .downloads table { width: 100%; border-collapse: collapse; }1313 .downloads td { padding: var(--space-1) var(--space-3) var(--space-1) 0; vertical-align: top; }1414 .downloads td.file { white-space: nowrap; font-weight: var(--fw-medium); }
@@ -21,10 +21,9 @@ pagination:
2121 commit subjects; commits without an answer are under Other. A tag with2222 a release (forge release publish, lib/releases.ts) lists its files2323 first. #}24-{{ ui.heading('releases', p.releases.length) }}2524 {% for r in p.releases %}26-<div class="box" id="{{ r.tag.name }}">27-<div class="box-head"><b class="mono">{{ r.tag.sha | commitLink(p.repo, r.tag.name) | safe }}</b><span class="grow desc">{{ ('since ' ~ r.prev) if r.prev else 'first release' }}, {{ r.count | plural('commit') }}{{ ' or more' if r.more }}</span><span class="date">{{ r.tag.date | date }}</span></div>25+{% set head %}<b class="mono release-tag">{{ r.tag.sha | commitLink(p.repo, r.tag.name) | safe }}</b><span class="grow">{{ ('since ' ~ r.prev) if r.prev else 'first release' }}, {{ r.count | plural('commit') }}{{ ' or more' if r.more }}</span><span class="end">{{ r.tag.date | date }}</span>{% endset %}26+{% call ui.panel(head, '', 'id="' + r.tag.name + '"') %}2827 {% if r.files %}2928 <div class="downloads">3029 <table>
@@ -49,8 +48,8 @@ pagination:
4948 {% endfor %}5049 {% if r.more %}<p class="desc">Older commits in this release aren't listed.</p>{% endif %}5150 </div>52-</div>51+{% endcall %}5352 {% else %}54-<p class="empty">No tags yet. Tag a commit and push it: <code>git tag v0.1 &amp;&amp; git push --tags</code></p>53+<div class="panel"><p class="empty">No tags yet. Tag a commit and push it: <code>git tag v0.1 &amp;&amp; git push --tags</code></p></div>5554 {% endfor %}5655 {% endblock %}
M site/repo/review-diff.njk +3 -4
@@ -24,7 +24,7 @@ pagination:
2424 .cmt .markdown > :last-child { margin-bottom: 0; }2525 /* In a diff, a comment is as wide as the code box, not its longest line. */2626 .code:has(.cmts) { container-type: inline-size; }27-.code .cmts { position: sticky; left: 0; width: 100cqw; padding: 0 var(--space-3) var(--space-2); background: var(--bg); border-block: var(--hairline) solid var(--border); font: var(--fw-regular) var(--text-md)/1.5 var(--sans); white-space: normal; }27+.code .cmts { position: sticky; left: 0; width: 100cqw; padding: 0 var(--space-3) var(--space-2); background: var(--tint); border-block: var(--hairline) solid var(--border); font: var(--fw-regular) var(--text-md)/1.5 var(--sans); white-space: normal; }2828 .code .cmts > .cmt:first-child { border-top: 0; }2929 .code .cmts > form { padding-top: var(--space-2); }3030 /* A new line comment, signed in (discuss.ts): the + on the line under the
@@ -45,8 +45,7 @@ pagination:
4545 <div class="byline">Combined diff of {{ rv.layer.length | plural('commit') }} on <span class="mono">{{ rv.branch }}</span></div>4646 </div>4747 <p class="desc" id="line-comments" data-post="{{ p.repo.urls.review(rv.id).href }}" data-at="{{ rv.tip }}" hidden></p>48-<div class="changes"><span class="tools"><span class="count" data-viewed="{{ p.repo.slug }}" hidden></span>{{ ui.checkbox('wrap lines', false, 'data-wrap') }}</span>49-{{ rv.combined | diffstatHtml | safe }}50-</div>48+{% set tools %}<span class="count" data-viewed="{{ p.repo.slug }}" hidden></span>{{ ui.checkbox('wrap lines', false, 'data-wrap') }}{% endset %}49+{{ rv.combined | diffstatHtml('', tools) | safe }}5150 {{ rv.combined | diffHtml(p.repo, '', rv.branch if rv.live else null, rv, true) | safe }}5251 {% endblock %}
M site/repo/review.njk +28 -22
@@ -13,13 +13,15 @@ pagination:
1313 {% endblock %}1414 {% block content %}1515 {% css %}16-.review-desc { margin: 0 0 var(--space-4); border-block: var(--hairline) solid var(--border); }1716 .review-desc .markdown > :last-child { margin-bottom: 0; }17+/* Each commit of the layer: its subject on the page, then its message,18+ diffstat and files in panels. */1819 .stacked { margin-top: var(--space-8); }19-.stacked > h3 { display: flex; flex-wrap: wrap; gap: var(--space-1) var(--space-3); align-items: baseline; margin: 0 0 var(--space-1); padding-top: var(--space-3); border-top: var(--hairline) solid var(--border); font-size: var(--text-lg); }20+.stacked > h3 { display: flex; flex-wrap: wrap; gap: var(--space-1) var(--space-3); align-items: baseline; margin: 0 0 var(--space-1); font-size: var(--text-lg); }2021 .stacked > h3 .step { color: var(--faint); font: var(--fw-regular) var(--text-xs) var(--mono); }22+.stacked > h3 a { color: var(--fg); }23+.stacked > .byline { margin: 0 0 var(--space-3); font-size: var(--text-sm); }2124 #stack tr.this td { font-weight: var(--fw-strong); }22-.review-tools { margin-top: var(--space-3); }2325 /* The sticky bar: layers of the stack, commits of the layer. It has a fixed2426 height so file headers can stick right below it. */2527 .revnav { position: sticky; top: 0; z-index: 3; height: 2.75rem; display: flex; align-items: center; gap: var(--space-3); margin: var(--space-8) 0 0; background: var(--bg); border-bottom: var(--hairline) solid var(--border); white-space: nowrap; overflow-x: auto; scrollbar-width: none; font: var(--fw-regular) var(--text-xs) var(--mono); }
@@ -44,11 +46,14 @@ pagination:
4446 <div class="byline">{{ m.status(rv.status) }} <span class="mono">{{ rv.branch }}</span> &rarr; <span class="mono">{{ rv.target }}</span>{% if rv.author %}, opened by {{ rv.author }}{% endif %}, {{ rv.revisions.length | plural('revision') }}, updated {{ rv.updated | ago }}</div>4547 </div>4648 47-{% if rv.opts.description %}<div class="box review-desc"><div class="markdown">{{ rv.opts.description | reviewMarkdown(p.repo, rv, true) | safe }}</div></div>{% endif %}49+{% if rv.opts.description %}50+{% set head %}{% if rv.author %}<b>{{ rv.author }}</b> {% endif %}<span>description</span>{% endset %}51+{% call ui.panel(head, 'review-desc') %}<div class="markdown">{{ rv.opts.description | reviewMarkdown(p.repo, rv, true) | safe }}</div>{% endcall %}52+{% endif %}4853 4954 {% if p.stack.length > 1 %}50-{{ ui.heading('stack', p.stack.length) }}51-<div class="box">55+{% set head %}<b>stack</b><span class="n">{{ p.stack.length }}</span>{% endset %}56+{% call ui.panel(head) %}5257 <table class="list stack" id="stack">5358 <tbody>5459 {% for s in p.stack | reverse %}
@@ -57,11 +62,11 @@ pagination:
5762 <tr><td class="main mono"><a href="{{ u.log(p.repo.defaultBranch).href }}">{{ p.repo.defaultBranch }}</a> <span class="desc">base</span></td><td class="hide-sm"></td><td></td><td></td><td></td></tr>5863 </tbody>5964 </table>60-</div>65+{% endcall %}6166 {% endif %}6267 63-{{ ui.heading('commits', rv.layer.length, 'oldest first') }}64-<div class="box">68+{% set head %}<b>commits</b><span class="n">{{ rv.layer.length }}</span><span class="grow">oldest first</span><span class="end"><a href="{{ u.reviewDiff(rv.id).href }}">combined diff</a> <span>{{ rv.combinedStats.files | plural('file') }}, <span class="i">+{{ rv.combinedStats.add }}</span> <span class="d">-{{ rv.combinedStats.del }}</span></span></span>{% endset %}69+{% call ui.panel(head) %}6570 <table class="list stack nav" id="layer">6671 <tbody>6772 {% for c in rv.layer %}
@@ -69,16 +74,12 @@ pagination:
6974 {% endfor %}7075 </tbody>7176 </table>72-</div>73-74-<div class="bar review-tools">75-<a href="{{ u.reviewDiff(rv.id).href }}">combined diff</a> <span class="desc">{{ rv.combinedStats.files | plural('file') }}, <span class="i">+{{ rv.combinedStats.add }}</span> <span class="d">-{{ rv.combinedStats.del }}</span></span>76-{% for s in rv.previews %}<span>preview <a href="{{ s.url }}">{{ s.name }}</a>{% if s.history[0].source != rv.tip %} <span class="ci partial" title="the branch has moved since this preview was built">stale</span>{% endif %}</span>{% endfor %}77-<span class="grow"></span>{% if p.stack.length < 2 and rv.layer.length < 2 %}<span class="count" data-viewed="{{ p.repo.slug }}" hidden></span>{% endif %}{{ ui.checkbox('wrap lines', false, 'data-wrap') }}78-</div>77+{% endcall %}7978 8079 {% if rv.revisions.length > 1 %}81-<details class="howto"><summary>{{ rv.revisions.length }} revisions{% set lastrev = rv.revisions[rv.revisions.length - 1] %}{% if lastrev.summary %} <span class="jev-sum {{ lastrev.summary.kind }}" title="What the latest revision changed, according to Jev">r{{ lastrev.n }}: {{ lastrev.summary.text }}</span>{% endif %}</summary>80+{% set lastrev = rv.revisions[rv.revisions.length - 1] %}81+{% set head %}<b>revisions</b><span class="n">{{ rv.revisions.length }}</span>{% if lastrev.summary %}<span class="grow jev-sum {{ lastrev.summary.kind }}" title="What the latest revision changed, according to Jev">r{{ lastrev.n }}: {{ lastrev.summary.text }}</span>{% endif %}{% endset %}82+{% call ui.panel(head, '', '', 'closed') %}8283 <table class="list stack">8384 <tbody>8485 {% for v in rv.revisions | reverse %}
@@ -86,11 +87,11 @@ pagination:
8687 {% endfor %}8788 </tbody>8889 </table>89-</details>90+{% endcall %}9091 {% endif %}9192 9293 {% if rv.status == 'open' or rv.status == 'draft' %}93-<details class="howto"><summary>check out or land</summary>94+{% call ui.panel('<b>check out or land</b>', '', '', 'closed') %}9495 <pre>git fetch {{ u.cloneSsh }} {{ rv.branch }} &amp;&amp; git switch --detach FETCH_HEAD9596 forge checkout {{ rv.id }} # the same, on a local tracking branch9697 forge attest --stack # CI for each commit, on your machine
@@ -98,9 +99,14 @@ forge comment {{ rv.id }} -m "..." # add &lt;path&gt;:&lt;line&gt; to comment
9899 forge approve {{ rv.id }}99100 forge land {{ rv.id }} # checks CI and approval, then fast-forwards100101 ssh {{ forge.config.ssh }} land {{ p.repo.slug }} {{ rv.id }} # the forge lands it, with the same gates: no clone needed</pre>101-</details>102+{% endcall %}102103 {% endif %}103104 105+<div class="bar review-tools">106+{% for s in rv.previews %}<span>preview <a href="{{ s.url }}">{{ s.name }}</a>{% if s.history[0].source != rv.tip %} <span class="ci partial" title="the branch has moved since this preview was built">stale</span>{% endif %}</span>{% endfor %}107+<span class="grow"></span>{% if p.stack.length < 2 and rv.layer.length < 2 %}<span class="count" data-viewed="{{ p.repo.slug }}" hidden></span>{% endif %}{{ ui.checkbox('wrap lines', false, 'data-wrap') }}108+</div>109+104110 {% include "discussion.njk" %}105111 {# Sticks to the top while reading the diffs: [ and ] move through the stack,106112 the arrows through this layer's commits (diffs.ts points them at the
@@ -124,8 +130,8 @@ ssh {{ forge.config.ssh }} land {{ p.repo.slug }} {{ rv.id }} # the forge land
124130 {% for c in rv.layer %}125131 <section class="stacked jump" id="c-{{ c.sha }}">126132 <h3><span class="step">{{ loop.index }}/{{ rv.layer.length }}</span> <a href="{{ u.commit(c.sha).href }}">{{ c.subject }}</a></h3>127-<p class="desc">{{ c.author.name }}, {{ c.author.date | date }}, <span class="sha">{{ c.short }}</span>{% if c.ci %}. CI {{ c.ci | ciHtml(true) | safe }}{% endif %}</p>128-{% if c.body %}<pre class="msg">{{ c.body }}</pre>{% endif %}133+<p class="byline desc">{{ c.author.name }}, {{ c.author.date | date }}, <span class="sha">{{ c.short }}</span>{% if c.ci %}. CI {{ c.ci | ciHtml(true) | safe }}{% endif %}</p>134+{% if c.body %}<pre class="panel msg">{{ c.body }}</pre>{% endif %}129135 {{ c.files | diffstatHtml('c' ~ loop.index ~ '-') | safe }}130136 {{ c.files | diffHtml(p.repo, 'c' ~ loop.index ~ '-', rv.branch if rv.live else null, null, true) | safe }}131137 </section>
M site/repo/reviews.njk +5 -16
@@ -6,19 +6,10 @@ pagination:
66 {% import "macros.njk" as m with context %}77 {% import "ui.njk" as ui %}88 {% block content %}9-{% css %}10-/* Reviews by status: a heading per group, the list under it. Long merged11- and closed groups start folded. */12-.rv-group { margin: 0 0 var(--space-6); }13-.rv-group > summary { display: flex; align-items: baseline; gap: var(--space-2); margin: 0; padding-bottom: var(--space-2); border-bottom: var(--hairline) solid var(--border); cursor: pointer; list-style: none; }14-.rv-group > summary::-webkit-details-marker { display: none; }15-.rv-group > summary h2 { margin: 0; padding: 0; border: 0; }16-.rv-group > summary .n { font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--faint); }17-.rv-group > summary::before { content: '\25BE'; width: 1em; color: var(--muted); }18-.rv-group:not([open]) > summary::before { content: '\25B8'; }19-{% endcss %}9+{# Reviews by status: a panel per group, folding. Long merged and closed10+ groups start folded. #}2011 {% if not p.groups.length %}21-<div class="box"><div class="markdown">12+<div class="panel"><div class="markdown">2213 <p>No reviews yet. Push a branch and it becomes one (branches imported with the repo don't):</p>2314 <pre>git switch -c you/topic2415 git commit ...
@@ -27,10 +18,8 @@ forge push -t "Title" # the branch and its CI results</pre>
2718 </div></div>2819 {% else %}2920 {% for g in p.groups %}30-<details class="rv-group {{ g.status }}"{% if g.status == 'open' or g.status == 'draft' or g.items.length <= 10 %} open{% endif %}>31-<summary><h2>{{ g.status }}</h2><span class="n">{{ g.items.length }}</span></summary>32-<div class="box">{{ m.reviews(g.items, p.repo) }}</div>33-</details>21+{% set head %}<h2>{{ g.status }}</h2><span class="n">{{ g.items.length }}</span>{% endset %}22+{% call ui.panel(head, 'rv-group ' + g.status, '', 'open' if (g.status == 'open' or g.status == 'draft' or g.items.length <= 10) else 'closed') %}{{ m.reviews(g.items, p.repo) }}{% endcall %}3423 {% endfor %}3524 {% endif %}3625 {% endblock %}
M site/repo/revision.njk +6 -6
@@ -18,8 +18,9 @@ pagination:
1818 .pk.add { color: var(--add-fg); }1919 .pk.del { color: var(--del-fg); }2020 .subj { font-weight: var(--fw-strong); overflow-wrap: anywhere; }21-.pairrow { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-1) var(--space-3); margin: 0; padding: var(--space-2); border-bottom: var(--hairline) solid var(--border); font-size: var(--text-sm); }22-details.pairs { margin: 0; padding: var(--space-2); border-bottom: var(--hairline) solid var(--border); font-size: var(--text-sm); }21+.pairrow { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-1) var(--space-3); margin: 0; padding: var(--space-2) var(--space-3); border-bottom: var(--hairline) solid var(--border); font-size: var(--text-sm); }22+details.pairs { margin: 0; padding: var(--space-2) var(--space-3); border-bottom: var(--hairline) solid var(--border); font-size: var(--text-sm); }23+.pairlist > :last-child { border-bottom: 0; }2324 details.pairs > summary { cursor: pointer; color: var(--muted); font-weight: var(--fw-medium); }2425 details.pairs .pairrow { margin: 0; padding: var(--space-2) 0 0; border: 0; }2526 .pair .fh .subj { color: var(--fg); }
@@ -36,10 +37,9 @@ details.pairs .pairrow { margin: 0; padding: var(--space-2) 0 0; border: 0; }
3637 {% if p.rev.view == 'diff' %}3738 <div class="byline">What changed from r{{ p.rev.prev }} ({{ p.from.sha | commitLink(p.repo) | safe }}) to r{{ p.rev.n }} ({{ p.rev.sha | commitLink(p.repo) | safe }}), pushed {{ p.rev.ts | ago }}. Both are on the same base, so this is the diff between their tips{% if rv.parent %}, including any change to the layers below{% endif %}.</div>3839 </div>39-<div class="changes"><span class="tools">{{ ui.checkbox('wrap lines', false, 'data-wrap') }}</span>40-{{ p.files | diffstatHtml | safe }}41-</div>42-{% if not p.files.length %}<p class="empty">The trees are identical: only commit messages or history changed.</p>{% endif %}40+{% set tools %}{{ ui.checkbox('wrap lines', false, 'data-wrap') }}{% endset %}41+{{ p.files | diffstatHtml('', tools) | safe }}42+{% if not p.files.length %}<div class="panel"><p class="empty">The trees are identical: only commit messages or history changed.</p></div>{% endif %}4343 {{ p.files | diffHtml(p.repo) | safe }}4444 {% else %}4545 <div class="byline">What changed from r{{ p.rev.prev }} to r{{ p.rev.n }}, pushed {{ p.rev.ts | ago }}. {% if p.from.base and p.rev.base %}The branch was rebased, from {{ p.from.base | commitLink(p.repo) | safe }} onto {{ p.rev.base | commitLink(p.repo) | safe }}{% else %}The base isn't known for both revisions{% endif %}, so a diff of the tips would mix in everything that landed in between. Instead each commit is paired with its earlier version (git range-diff).</div>
M site/repo/sites.njk +7 -7
@@ -7,7 +7,7 @@ pagination:
77 {% import "ui.njk" as ui %}88 {% block content %}99 {% if not p.sites.length and not p.previews.length %}10-<div class="box"><div class="markdown">10+<div class="panel"><div class="markdown">1111 <p>No sites yet. Declare one in <code>.forge/sites</code> and deploy it from your machine:</p>1212 <pre># name output build command1313 docs build npm ci &amp;&amp; npm run build
@@ -16,8 +16,8 @@ forge site deploy docs</pre>
1616 </div></div>1717 {% endif %}1818 {% for s in p.sites %}19-<div class="box">20-<div class="box-head"><b><a href="{{ s.url }}">{{ s.name }}</a></b> <span class="desc mono grow">{{ s.url }}</span></div>19+{% set head %}<b><a href="{{ s.url }}">{{ s.name }}</a></b><span class="grow mono">{{ s.url }}</span><span class="n">{{ s.history.length | plural('deploy') }}</span>{% endset %}20+{% call ui.panel(head) %}2121 <table class="list stack">2222 <tbody>2323 {% for h in s.history %}
@@ -25,11 +25,11 @@ forge site deploy docs</pre>
2525 {% endfor %}2626 </tbody>2727 </table>28-</div>28+{% endcall %}2929 {% endfor %}3030 {% if p.previews.length %}31-{{ ui.heading('review previews', p.previews.length) }}32-<div class="box">31+{% set head %}<b>review previews</b><span class="n">{{ p.previews.length }}</span>{% endset %}32+{% call ui.panel(head) %}3333 <table class="list stack">3434 <tbody>3535 {% for v in p.previews %}
@@ -37,6 +37,6 @@ forge site deploy docs</pre>
3737 {% endfor %}3838 </tbody>3939 </table>40-</div>40+{% endcall %}4141 {% endif %}4242 {% endblock %}
M site/repo/tree.njk +3 -5
M site/repo/wiki.njk +3 -2
A test/scenarios/panels-repo.sh +21 -0
A test/scenarios/panels-shelves.sh +20 -0
M test/scenarios/review-list.sh +3 -5 mechanical
M test/scenarios/shelves.sh +4 -4 mechanical
M test/scenarios/web-comment-line-page.sh +2 -2 mechanical
M test/scenarios/web-comment-page.sh +1 -1 mechanical

4/4 Branches keep a review's number beside its status, tags their subject

In a panel the review column wrapped its status label under the number,
and a tag's subject sat in a narrow last column, a word to a line. The
review cell doesn't wrap now, and a tag's subject follows its name.
1 file changed, +6 -2
Msite/repo/refs.njk8++++++--
M site/repo/refs.njk +6 -2
@@ -7,11 +7,15 @@ pagination:
77 {% import "ui.njk" as ui %}88 {% set u = p.repo.urls %}99 {% block content %}10+{% css %}11+/* A tag's name, in ink like a branch's, then its commit's subject. */12+td.tag > a:first-child { font-family: var(--mono); font-size: var(--text-sm); }13+{% endcss %}1014 {% set head %}<b>branches</b><span class="n">{{ p.branches.length }}</span>{% endset %}1115 {% call ui.panel(head) %}1216 {% call ui.listTable() %}1317 {% for b in p.branches %}14-<tr><td class="main mono"><a href="{{ u.tree(b.name).href }}">{{ b.name }}</a>{% if b.isDefault %} <span class="desc">default</span>{% endif %}</td><td class="date">{{ b.date | date }}</td><td class="nowrap hide-sm">{{ b.author }}</td><td class="num">{{ ('' ~ b.behind + ' behind') if not b.isDefault else '' }}</td><td class="num">{{ ('' ~ b.ahead + ' ahead') if not b.isDefault else '' }}</td><td>{% if b.review %}<a href="{{ u.review(b.review.id).href }}">#{{ b.review.id }}</a> {{ m.status(b.review.status) }}{% endif %}</td></tr>18+<tr><td class="main mono"><a href="{{ u.tree(b.name).href }}">{{ b.name }}</a>{% if b.isDefault %} <span class="desc">default</span>{% endif %}</td><td class="date">{{ b.date | date }}</td><td class="nowrap hide-sm">{{ b.author }}</td><td class="num">{{ ('' ~ b.behind + ' behind') if not b.isDefault else '' }}</td><td class="num">{{ ('' ~ b.ahead + ' ahead') if not b.isDefault else '' }}</td><td class="nowrap">{% if b.review %}<a href="{{ u.review(b.review.id).href }}">#{{ b.review.id }}</a> {{ m.status(b.review.status) }}{% endif %}</td></tr>1519 {% endfor %}1620 {% endcall %}1721 {% endcall %}
@@ -20,7 +24,7 @@ pagination:
2024 {% call ui.panel(head) %}2125 {% call ui.listTable() %}2226 {% for t in p.tags %}23-<tr><td class="main mono">{{ t.sha | commitLink(p.repo, t.name) | safe }}</td><td class="date">{{ t.date | date }}</td><td class="hide-sm">{{ t.subject }}</td></tr>27+<tr><td class="main tag">{{ t.sha | commitLink(p.repo, t.name) | safe }} <span class="desc">{{ t.subject }}</span></td><td class="date">{{ t.date | date }}</td></tr>2428 {% endfor %}2529 {% endcall %}2630 {% endcall %}