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.
- commit
- a81976541a043c39cce699792bb5cc0f99b73690
- parent
- 9540f59d9d2839a93825ba52a3f2d414e4b84a63
- author
- cat <admin@dev.site>
- ci
- ✓ passed in 78s, signed by cat
- kind
- feat risk score 2.8 of 3, Jev's estimate
- review
- #16 Panels with a head strip for content, and an even row hover
ci attestation
forge-attestation 1 type ci commit a81976541a043c39cce699792bb5cc0f99b73690 tree 510c6d8e2db63959d7e8dbc129d5cacd842a11fe script .forge/ci 2dbcda6e044dc4b05503abde8369e2528413d3c7 result pass exit 0 started 2026-10-19T03:57:30Z duration 78s runner (not shown here; refs/notes/ci has it) system Darwin 25.5.0 arm64 env host log sha256:d357d13709eea7ebb26b7b2ddaf8142d27925e3fd15f55b1f1410cf411ee64c8 267 lines |pushed wip/i18n |1f4c88c running .forge/ci: greet.sh: say what it is | | sh: /bin/sh | | ok greet |1f4c88c pass in 0s, signed |pushed main |remote: review #4: /dami/greet/reviews/4/ |pushed wip/i18n |0e5299a running .forge/ci: wave at the world | | sh: /bin/sh | | FAIL greet : got 'hello, world o/', want 'hello, world' |0e5299a fail in 0s, signed |forge: 1 commit(s) did not pass |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |8a5cd15 running .forge/ci: update the test for the wave | | sh: /bin/sh | | ok greet |8a5cd15 pass in 0s, signed |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |warning: You appear to have cloned an empty repository. |site home: deployed 7d37a92 -> /sites/ana/dotfiles/home/ |created ana/notes |remote: review #1: /ana/notes/reviews/1/ |pushed ana/todo |landing #1 A todo list: ana/todo -> main |17adf79 running .forge/ci: web, server and Python versions of greet | | sh: /bin/sh | | ok greet |17adf79 pass in 0s, signed |pushed main |495fef5 running .forge/ci: messages: notes for the later regions | | sh: /bin/sh | | ok greet |495fef5 pass in 0s, signed |remote: review #7: /dami/greet/reviews/7/ |pushed dami/regions |created dami/forge |warning: You appear to have cloned an empty repository. |site guide: mkdir -p guide/fonts && cp site/assets/fonts/*.woff2 guide/fonts/ |site guide: deployed 7225f69 -> /sites/dami/forge/guide/ |(node:19172) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built ana/dotfiles in 0s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.p9wqBT/wt/demo/root/.forge/www |(node:19341) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built ana/notes in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.p9wqBT/wt/demo/root/.forge/www |(node:19820) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built dami/forge in 0s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.p9wqBT/wt/demo/root/.forge/www |(node:19954) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built dami/greet in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.p9wqBT/wt/demo/root/.forge/www |(node:20627) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built - in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.p9wqBT/wt/demo/root/.forge/www |234 of 234 pages render the same without 11ty |guide: 8 files, links ok |ok
Signature: good, cat
| M | .forge/ | 2 | +- |
| M | DESIGN.md | 54 | +++++++++-- |
| M | lib/ | 4 | +- |
| M | lib/ | 31 | +++++-- |
| M | site/ | 21 | ++-- |
| M | site/ | 72 | ++++++++------- |
| M | site/ | 8 | +- |
| M | site/ | 43 | ++++----- |
| M | site/ | 2 | +- |
| M | site/ | 2 | +- |
| M | site/ | 23 | ++++- |
| M | site/ | 2 | +- |
| M | site/ | 144 | ++++++++++++++++++------------ |
| M | site/ | 60 | +++++++++--- |
| M | site/ | 16 | ++-- |
| M | site/ | 15 | ++- |
| M | site/ | 30 | +++---- |
| M | site/ | 2 | +- |
| M | site/ | 14 | +- |
| M | site/ | 11 | +- |
| M | site/ | 7 | +- |
| M | site/ | 50 | ++++++----- |
| M | site/ | 21 | +--- |
| M | site/ | 12 | +- |
| M | site/ | 14 | +- |
| M | site/ | 8 | +- |
| M | site/ | 5 | +- |
| A | test/ | 21 | ++++ |
| A | test/ | 20 | ++++ |
| M | test/ | 8 | +- |
| M | test/ | 8 | +- |
| M | test/ | 4 | +- |
| M | test/ | 2 | +- |
@@ -48,7 +48,7 @@ grep -q '<meta property="og:title" content="#3 Usage and --help - dami/greet">'
4848 # Review discussion: description, an outdated comment, a stale approval, a4949 # comment inline in the combined diff, approvals in the list, reviews.json.5050 rv=demo/root/.forge/www/dami/greet51-grep -q 'class="box review-desc"' $rv/reviews/3/index.html51+grep -q 'class="panel review-desc"' $rv/reviews/3/index.html5252 grep -q 'class="comment outdated"' $rv/reviews/3/index.html5353 grep -q 'changed since' $rv/reviews/3/index.html5454 grep -q 'class="cmt" id="comment-3"' $rv/reviews/3/diff/index.html@@ -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@@ -487,8 +487,8 @@ export const filters = {
487487 if (!r || !text) return '';488488 return r.markdown(review.live ? review.branch : r.defaultBranch, '', escaped ? text.replaceAll('\\n', '\n') : text);489489 },490- diffstatHtml(files: DiffFile[] | undefined, prefix = ''): string {491- return html.diffstat(files ?? [], prefix);490+ diffstatHtml(files: DiffFile[] | undefined, prefix = '', tools = ''): string {491+ return html.diffstat(files ?? [], prefix, String(tools));492492 },493493 blobHtml(p: BlobPage): string {494494 if (p.image && !p.isSource) return `<div class="image-view"><img src="${html.esc(p.image)}" alt="${html.esc(p.path)}"></div>`;@@ -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 @@ -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 %}@@ -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>@@ -14,13 +14,13 @@
1414 {% block content %}1515 {% css %}1616 /* the sign-in page */17-.login { padding: 0 0 var(--space-4); max-width: 40em; }18-.login h1 { font-size: var(--text-xl); margin: var(--space-1) 0 var(--space-3); }17+.login { max-width: 40em; }18+.login h1 { font-size: var(--text-xl); margin: 0 0 var(--space-3); }1919 .login-status { min-height: 1.4em; }2020 .login-status.bad { color: var(--fail); }2121 .login form { display: inline; }2222 {% endcss %}23-<div class="box login" id="login" data-next="{{ next }}"{% if signal %} data-signal="{{ signal }}"{% endif %}>23+<div class="panel login" id="login" data-next="{{ next }}"{% if signal %} data-signal="{{ signal }}"{% endif %}><div class="panel-body">2424 {% if user %}2525 <h1>Signed in as {{ user }}</h1>2626 <p>You see the pages of the private repos you can read: <a href="/private/">your private repos</a>.</p>@@ -42,5 +42,5 @@
4242 <p class="login-status" id="login-status" role="status"></p>4343 <p class="desc">No passkey yet, or lost them all? <code>ssh {{ forge.config.ssh }} passkey</code> prints a link that adds one: your ssh key is how you get in.</p>4444 {% endif %}45-</div>45+</div></div>4646 {% endblock %}@@ -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) %}@@ -21,7 +21,7 @@
2121 <span>Signed in as <b>{{ user }}</b>. These are the private repos you can read; only their readers see them.</span>2222 <form method="post" action="/login/logout"><input type="hidden" name="csrf" value="{{ csrf }}">{{ ui.button('sign out', false, 'id="signout"', 'submit') }}</form>2323 </div>24-<div class="box">24+<div class="panel">2525 <table class="list stack nav">2626 <thead><tr><th>repository</th><th>description</th><th>updated</th></tr></thead>2727 <tbody>@@ -12,7 +12,7 @@
1212 #search input { width: 100%; font: var(--fw-regular) var(--text-lg) var(--sans); padding: var(--space-2) var(--space-3); border: var(--hairline) solid var(--border-strong); background: var(--bg); color: var(--fg); }1313 #search input:focus, #finder input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 var(--hairline) var(--accent); }1414 #search ol { list-style: none; margin: var(--space-1) 0 0; padding: 0; max-height: 65vh; overflow: auto; }15-#search li.group { margin: 0 0 var(--space-half); padding: var(--space-3) var(--space-2) var(--space-1); font-size: var(--text-sm); font-weight: var(--fw-strong); color: var(--muted); border-bottom: var(--hairline) solid var(--border); }15+#search li.group { margin: var(--space-2) 0 var(--space-half); padding: var(--space-1) var(--space-2); font-size: var(--text-sm); font-weight: var(--fw-medium); color: var(--fg); background: var(--tint); border-block: var(--hairline) solid var(--border); }1616 #search li.group span { font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--faint); margin-left: var(--space-1); }1717 #search li a { display: block; padding: var(--space-1) var(--space-2); color: var(--fg); }1818 #search li a:hover { background: var(--hover); text-decoration: none; }@@ -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 %}@@ -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); }@@ -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">✓</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">✓ 2/2</span><span class="ci fail">✗ 1/2</span><span class="ci partial">– 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">✓</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">✗</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>-- => !=</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 %}@@ -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 %}@@ -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 %}@@ -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 %}@@ -7,5 +7,5 @@ pagination:
77 {% import "ui.njk" as ui %}88 {% block content %}99 <div class="bar">{{ m.branches(p.repo, p.branch.name, 'log') }}{% if p.branch.review %}<span class="desc">This branch is <a href="{{ p.repo.urls.review(p.branch.review.id).href }}">review #{{ p.branch.review.id }}</a>.</span>{% endif %}</div>10-<div class="box">{{ m.log(p.commits, p.repo) }}</div>10+{% call ui.panel() %}{{ m.log(p.commits, p.repo) }}{% endcall %}1111 {% endblock %}@@ -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 %}@@ -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 && 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 && git push --tags</code></p></div>5554 {% endfor %}5655 {% endblock %}@@ -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 %}@@ -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> → <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 }} && 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 <path>:<line> 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>@@ -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 %}@@ -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>@@ -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 && 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 %}@@ -7,11 +7,9 @@ pagination:
77 {% import "ui.njk" as ui %}88 {% block content %}99 <div class="bar">{{ m.branches(p.repo, p.branch.name, 'tree') }}{{ m.crumbs(p.repo, p.branch.name, p.crumbs) }}<span class="grow"></span><button type="button" data-finder>find file <kbd>t</kbd></button></div>10-<div class="box">{{ m.files(p.entries, p.parentHref) }}</div>10+{% call ui.panel('', 'files-panel') %}{{ m.files(p.entries, p.parentHref) }}{% endcall %}1111 {% if p.readme %}12-<div class="box">13-<div class="box-head"><a href="{{ p.repo.urls.blob(p.branch.name, p.readme.path).href }}">{{ p.readme.name }}</a></div>14-<div class="markdown">{{ p.readme | dirReadmeHtml(p.repo, p.branch.name) | safe }}</div>15-</div>12+{% set head %}<a class="key mono" href="{{ p.repo.urls.blob(p.branch.name, p.readme.path).href }}">{{ p.readme.name }}</a>{% endset %}13+{% call ui.panel(head, 'readme') %}<div class="markdown">{{ p.readme | dirReadmeHtml(p.repo, p.branch.name) | safe }}</div>{% endcall %}1614 {% endif %}1715 {% endblock %}@@ -24,8 +24,9 @@ pagination:
2424 <div class="wiki-layout">2525 <nav class="wiki-nav">{% for w in p.pages %}<a href="{{ w.href }}"{% if w.slug == p.wiki.slug %} class="cur"{% endif %}>{{ w.title }}</a>{% endfor %}</nav>2626 <div>27-<div class="box"><div class="markdown">{{ p.wiki.html | safe }}</div></div>28-<p class="desc">Source: <a href="{{ p.repo.urls.blobSource(p.repo.defaultBranch, p.wiki.path).href }}">{{ p.wiki.path }}</a>. Edit it like code: commit, push, review.</p>27+{% set head %}<a class="key mono" href="{{ p.repo.urls.blobSource(p.repo.defaultBranch, p.wiki.path).href }}">{{ p.wiki.path }}</a>{% endset %}28+{% call ui.panel(head) %}<div class="markdown">{{ p.wiki.html | safe }}</div>{% endcall %}29+<p class="desc">Edit it like code: commit, push, review.</p>2930 </div>3031 </div>3132 {% endblock %}@@ -0,0 +1,21 @@
1+# A repo's page puts its content in panels with head strips: the files2+# under the last commit (its author, subject, short sha and age), and the3+# README under its file name. The chrome (tabs) stays on the page.4+. "$(dirname "$0")/../lib.sh"5+user dami6+repo dami/greet7+clone dami dami/greet "$T/w/g"8+printf '# greet\n\nSay hello.\n' >"$T/w/g/README.md"9+at "$T/w/g" commit "Say hello, politely" README.md10+push_as dami "$T/w/g" origin main11+export FORGE_STRICT=112+build dami/greet13+p=$FORGE_WWW/dami/greet/index.html14+sha=$(git -C "$T/w/g" rev-parse HEAD)15+has "$p" '<section class="panel files-panel"><header class="panel-head">'16+has "$p" '<span class="grow"><b>dami</b> <a class="key" href="/dami/greet/commit/'"$sha"'/">Say hello, politely</a></span>'17+has "$p" '<span class="end"><a class="sha" href="/dami/greet/commit/'"$sha"'/">'"$(printf %.7s "$sha")"'</a>.*<time '18+has "$p" '<td class="file-icon"><svg class="icon"'19+has "$p" '<section class="panel readme"><header class="panel-head"><a class="key mono" href="/dami/greet/blob/main/README.md/">README.md</a></header>'20+# The tabs are chrome: in the header, not in a panel.21+lacks "$p" '<section class="panel[^"]*"><header class="panel-head"><nav class="tabs'@@ -0,0 +1,20 @@
1+# The index's shelves are panels: each one's head strip names the shelf2+# and counts its repos, and the repos are rows inside it.3+. "$(dirname "$0")/../lib.sh"4+user dami5+for r in dami/chess dami/go dami/notes; do6+ repo "$r"7+ clone dami "$r" "$T/w/${r#*/}"8+ at "$T/w/${r#*/}" commit "first"9+ push_as dami "$T/w/${r#*/}" origin main10+done11+printf 'games\n' >"$FORGE_ROOT/dami/chess.git/category"12+printf 'games\n' >"$FORGE_ROOT/dami/go.git/category"13+export FORGE_STRICT=114+build15+i=$FORGE_WWW/index.html16+has "$i" '<section class="panel shelf" data-shelf="games" id="games"><header class="panel-head"><h2>games</h2><span class="n">2</span></header>'17+has "$i" '<section class="panel shelf" data-shelf="other" id="other"><header class="panel-head"><h2>other</h2><span class="n">1</span></header>'18+has "$i" '<ul class="rows cards nav-cards">'19+# The owner's page too.20+has "$FORGE_WWW/dami/index.html" '<section class="panel shelf" data-shelf="games" id="games"><header class="panel-head">'@@ -33,14 +33,12 @@ export FORGE_STRICT=1
3333 build dami/greet3434 p=$FORGE_WWW/dami/greet/reviews/index.html3535 lacks "$p" '<table'36-has "$p" '<details class="rv-group open" open>'37-has "$p" '<summary><h2>open</h2><span class="n">2</span></summary>'38-has "$p" '<details class="rv-group merged">'39-has "$p" '<summary><h2>merged</h2><span class="n">11</span></summary>'36+has "$p" '<details class="panel rv-group open" open><summary class="panel-head"><h2>open</h2><span class="n">2</span></summary>'37+has "$p" '<details class="panel rv-group merged"><summary class="panel-head"><h2>merged</h2><span class="n">11</span></summary>'4038 # The layer on top hangs under the base, one deep.4139 has "$p" '<li class="rv layer" style="--depth: 1">'4240 has "$p" '<span class="rv-title"><a href="/dami/greet/reviews/2/">Top</a></span>'4341 # Base's second line: number, branch, author, commits, diff size.4442 has "$p" '<span class="rv-meta"><span class="num">#1</span><span class="mono">dami/base → main</span><span>dami</span><span>1 commit</span><span class="num"><span class="add">\+2</span> <span class="del">−0</span></span>'4543 # The home page's open reviews are the same list.46-has "$FORGE_WWW/dami/greet/index.html" '<ul class="rv-list nav-list">'44+has "$FORGE_WWW/dami/greet/index.html" '<ul class="rows rv-list nav-list">'@@ -18,15 +18,15 @@ printf 'games\n' >"$FORGE_ROOT/dami/go.git/category"
1818 export FORGE_STRICT=11919 build2020 i=$FORGE_WWW/index.html21-has "$i" '<section class="shelf" data-shelf="games">'22-has "$i" '<section class="shelf" data-shelf="other">'21+has "$i" '<section class="panel shelf" data-shelf="games" id="games">'22+has "$i" '<section class="panel shelf" data-shelf="other" id="other">'2323 # games (two repos) before other (one); go was pushed last, so it leads.24-eq "$(grep -o '<section class="shelf" data-shelf="[a-z]*"' "$i" | sed 's/.*="//; s/"//' | tr '\n' ' ')" "games other "24+eq "$(grep -o '<section class="panel shelf" data-shelf="[a-z]*"' "$i" | sed 's/.*="//; s/"//' | tr '\n' ' ')" "games other "2525 eq "$(grep -o 'class="card-name" href="/[a-z]*/[a-z]*/"' "$i" | sed 's/.*href="//; s/"//' | tr '\n' ' ')" "/dami/go/ /dami/chess/ /ana/notes/ "2626 # Several owners: each card names its owner.2727 has "$i" '<span class="desc">dami / </span>go</a>'2828 lacks "$i" 'secret'29-has "$FORGE_WWW/dami/index.html" '<section class="shelf" data-shelf="games">'29+has "$FORGE_WWW/dami/index.html" '<section class="panel shelf" data-shelf="games" id="games">'3030 lacks "$FORGE_WWW/dami/index.html" '<span class="desc">dami / </span>'3131 # One owner's only shelf is "other": titled repositories.3232 has "$FORGE_WWW/ana/index.html" '<h2>repositories</h2>'@@ -19,8 +19,8 @@ tip=$(git --git-dir="$FORGE_ROOT/dami/greet.git" rev-parse dami/feature)
1919 2020 d=$FORGE_WWW/dami/greet/reviews/1/diff/index.html2121 has "$d" "<p class=\"desc\" id=\"line-comments\" data-post=\"/dami/greet/reviews/1/\" data-at=\"$tip\" hidden></p>"22-has "$d" '<section class="file jump" id="f0" data-path="greet.sh">'23-has "$d" '<section class="file jump" id="f1"><header'22+has "$d" '<section class="panel file jump" id="f0" data-path="greet.sh">'23+has "$d" '<section class="panel file jump" id="f1"><header'2424 has "$d" '<script src="/assets/discuss\.[A-Za-z0-9_-]{10}\.js" defer>'2525 lacks "$FORGE_WWW/dami/greet/reviews/1/index.html" 'data-path='2626 lacks "$FORGE_WWW/dami/greet/commit/$tip/index.html" 'data-path='@@ -24,7 +24,7 @@ build
2424 tip=$(git --git-dir="$FORGE_ROOT/dami/greet.git" rev-parse dami/feature)2525 2626 p=$FORGE_WWW/dami/greet/reviews/1/index.html27-has "$p" "<form class=\"reply\" id=\"reply\" data-post=\"/dami/greet/reviews/1/\" data-at=\"$tip\" data-author=\"dami\" hidden>"27+has "$p" "<form class=\"reply panel-body\" id=\"reply\" data-post=\"/dami/greet/reviews/1/\" data-at=\"$tip\" data-author=\"dami\" hidden>"2828 has "$p" '<textarea class="input" aria-label="comment on #1" placeholder="Comment on #1. Markdown is fine." name="body" maxlength="32768"'2929 has "$p" '<button type="button" data-approve '3030 has "$p" '<script src="/assets/discuss\.[A-Za-z0-9_-]{10}\.js" defer>'