clone

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

List reviews as two-line items, not a seven-column table

cat committed 93e7cb4
The reviews table put number, title, branch, author, time, CI and
approvals side by side in every row, with each status group's label
inside the table, so it read as a dense grid rather than a list. Each
review is now one item: a status mark and the title, with CI and
approvals at the right, then a quieter line with the number, branch and
target, author, commit count, diff size, when and the latest revision.
Status groups get a heading with a count, like the index's shelves;
merged and closed groups past ten start folded. A stacked layer hangs
under the review below it, joined by a rule. On a phone, CI and
approvals move under the title. j and k walk the items, and the home
page's open reviews use the same list.
commit
93e7cb4184d710b4dd2389c7f2ebd41c7147c081
parent
8e63d4d0b4ae096eeb8e55f5e70d6a33a09b2259
author
cat <admin@dev.site>
ci
✓ passed in 106s, signed by cat
kind
refactor risk score 1.9 of 3, Jev's estimate
review
#6 List reviews as two-line items
ci attestation
forge-attestation 1
type ci
commit 93e7cb4184d710b4dd2389c7f2ebd41c7147c081
tree 099e0e9238bbe0fca2f370b6ddf40a8fa6e81be6
script .forge/ci 38df37c0d5a6382f60569079a0dd821094e37e4a
result pass
exit 0
started 2026-10-18T23:37:57Z
duration 106s
runner (not shown here; refs/notes/ci has it)
system Darwin 25.5.0 arm64
env host
log sha256:d1576ee7dbafcc61ac535bfb555260f8bc6e1f9912ae2d419074c743fe95b243 241 lines
|remote: review #4: /dami/greet/reviews/4/
|pushed wip/i18n
|1a390bf running .forge/ci: greet.sh: say what it is
|  | sh: /bin/sh
|  | ok   greet
|1a390bf pass in 0s, signed
|pushed main
|remote: review #4: /dami/greet/reviews/4/
|pushed wip/i18n
|edc0e3f running .forge/ci: wave at the world
|  | sh: /bin/sh
|  | FAIL greet : got 'hello, world o/', want 'hello, world'
|edc0e3f fail in 0s, signed
|forge: 1 commit(s) did not pass
|remote: review #6: /dami/greet/reviews/6/
|pushed ana/wave
|8f8ade1 running .forge/ci: update the test for the wave
|  | sh: /bin/sh
|  | ok   greet
|8f8ade1 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 4bff3cf -> /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
|1884ab2 running .forge/ci: web, server and Python versions of greet
|  | sh: /bin/sh
|  | ok   greet
|1884ab2 pass in 0s, signed
|pushed main
|2f82721 running .forge/ci: messages: notes for the later regions
|  | sh: /bin/sh
|  | ok   greet
|2f82721 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: deployed 911602b -> /sites/dami/forge/guide/
|(node:21602) 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 1s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www
|(node:21726) 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 0s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www
|(node:21869) 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 1s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www
|(node:22000) 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 -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www
|(node:22671) 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 0s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www
|226 of 226 pages render the same without 11ty
|guide: 4 files, links ok
|ok

Signature: good, cat

4 files changed, +102 -45
Msite/_includes/macros.njk62+++++++++++++++++++-----------
Msite/assets/keys.ts5+-
Msite/repo/reviews.njk34++++++----------
Atest/scenarios/review-list.sh46++++++++++++++++++++++
M site/_includes/macros.njk +40 -22
@@ -66,39 +66,57 @@
6666 {% for a in r.approvals %}<span class="appr{% if not a.current %} stale{% endif %}" title="{{ a.user }} approved{% if a.rev %} r{{ a.rev }}{% endif %}{% if not a.current %}, changed since{% endif %}">&#10004; {{ a.user }}{% if not a.current %}{% if a.rev %} r{{ a.rev }}{% endif %}, stale{% endif %}</span>{% endfor %}6767 {% endmacro %}6868 69-{% macro reviewHead() %}70-<thead><tr><th class="num">#</th><th>Review</th><th>Branch</th><th>Author</th><th>Updated</th><th>CI</th><th>Approved</th></tr></thead>71-{% endmacro %}72-73-{# depth: how far up a stack, shown nested under the layer below. A revised74- review links what changed in its latest push. #}75-{% macro reviewRow(r, repo, depth = 0) %}69+{# One review in a list: its title on the first line with CI and approvals70+ at the right, the rest on a quieter second line. depth: how far up a71+ stack, shown nested under the layer below with a rule joining them. A72+ revised review links what changed in its latest push. #}73+{% macro reviewItem(r, repo, depth = 0) %}7674 {% css %}77-/* Review lists: a layer hangs under the one below it in the stack. */78-tr.layer .depth { display: inline-block; width: 1.4em; margin-left: calc((var(--depth) - 1) * 1.4em); }79-table.list tr:has(+ tr.layer) td { border-bottom-color: transparent; }80-@media (max-width: 720px) {81- table.list.stack tr:has(+ tr.layer) { border-bottom-color: transparent; padding-bottom: 2px; }82- table.list.stack tr.layer { padding-left: calc(8px + var(--depth) * 1.4em); }83- table.list.stack tr.layer .depth { margin-left: -1.4em; }84-}75+.rv-list { list-style: none; margin: 0; padding: 0; }76+.rv { position: relative; display: grid; grid-template-columns: auto 1fr auto; column-gap: 10px; row-gap: 3px; align-items: baseline; padding: 11px 14px; border-top: 1px solid var(--border); }77+.rv:first-child { border-top: 0; }78+.rv:hover, .rv.sel { background: var(--hover); }79+.rv.sel { box-shadow: inset 2px 0 0 var(--accent); }80+.rv-mark { grid-row: 1 / 3; align-self: start; width: 9px; height: 9px; margin-top: 6px; border-radius: 50%; background: var(--muted); }81+.rv-mark.open { background: var(--pass); }82+.rv-mark.draft { background: none; box-shadow: inset 0 0 0 2px var(--muted); }83+.rv-mark.merged { background: var(--merged); }84+.rv-mark.closed { background: var(--fail); }85+.rv-title { min-width: 0; font-weight: 600; overflow-wrap: anywhere; }86+.rv-title a::after { content: ''; position: absolute; inset: 0; }87+.rv-side { display: flex; gap: 12px; align-items: baseline; justify-content: flex-end; white-space: nowrap; font-size: 0.9em; }88+.rv-meta { grid-column: 2 / 4; display: flex; flex-wrap: wrap; column-gap: 14px; row-gap: 2px; font-size: 0.85em; color: var(--muted); }89+.rv-meta .mono { font-size: 0.95em; }90+.rv-meta .num { font-variant-numeric: tabular-nums; }91+.rv-meta .add { color: var(--pass); }92+.rv-meta .del { color: var(--fail); }93+.rv-meta a, .rv-side a { position: relative; z-index: 1; }94+.rv.layer { margin-left: calc(var(--depth) * 22px); border-left: 2px solid var(--border); }8595 a.rev { font-size: 0.85em; color: var(--muted); border: 1px solid var(--border); border-radius: 4px; padding: 0 4px; }8696 .appr { white-space: nowrap; font-weight: 600; color: var(--pass); }8797 .appr + .appr { margin-left: 8px; }8898 .appr.stale { color: var(--warn); font-weight: normal; }99+@media (max-width: 720px) {100+ .rv { grid-template-columns: auto 1fr; }101+ .rv-side { grid-column: 2; justify-content: flex-start; }102+ .rv-meta { grid-column: 2; }103+ .rv.layer { margin-left: calc(var(--depth) * 12px); }104+}89105 {% endcss %}90106 {% set last = r.revisions[r.revisions.length - 1] %}91-<tr{% if depth %} class="layer" style="--depth: {{ depth }}"{% endif %}><td class="num id">#{{ r.id }}</td><td class="main">{% if depth %}<span class="depth">&#8627;</span>{% endif %}<a href="{{ repo.urls.review(r.id).href }}">{{ r.title }}</a></td><td class="mono hide-sm">{{ r.branch }} &rarr; {{ r.target }}</td><td class="nowrap">{{ r.author or '' }}</td><td class="date">{{ r.updated | ago }}{% if last and last.view %} <a class="rev" href="{{ repo.urls.revision(r.id, last.n).href }}" title="what changed since r{{ last.prev }}">r{{ last.n }}</a>{% endif %}</td><td>{{ reviewci(r.ci) }}</td><td class="nowrap">{{ approvals(r) }}</td></tr>107+<li class="rv{% if depth %} layer{% endif %}"{% if depth %} style="--depth: {{ depth }}"{% endif %}>108+<span class="rv-mark {{ r.status }}" title="{{ r.status }}"></span>109+<span class="rv-title"><a href="{{ repo.urls.review(r.id).href }}">{{ r.title }}</a></span>110+<span class="rv-side">{{ reviewci(r.ci) }}{% if r.approvals.length %}<span>{{ approvals(r) }}</span>{% endif %}</span>111+<span class="rv-meta"><span class="num">#{{ r.id }}</span><span class="mono">{{ r.branch }} &rarr; {{ r.target }}</span>{% if r.author %}<span>{{ r.author }}</span>{% endif %}{% if r.commits.length %}<span>{{ r.commits.length | plural('commit') }}</span>{% endif %}{% if r.combinedStats and (r.combinedStats.add or r.combinedStats.del) %}<span class="num"><span class="add">+{{ r.combinedStats.add }}</span> <span class="del">&minus;{{ r.combinedStats.del }}</span></span>{% endif %}<span class="date">{{ r.updated | ago }}</span>{% if last and last.view %}<a class="rev" href="{{ repo.urls.revision(r.id, last.n).href }}" title="what changed since r{{ last.prev }}">r{{ last.n }}</a>{% endif %}</span>112+</li>92113 {% endmacro %}93114 94115 {# list: reviews nested by stack, as {review, depth} (nest() in lib/reviews.ts). #}95116 {% macro reviews(list, repo) %}96-<table class="list stack nav reviews">97-{{ reviewHead() }}98-<tbody>99-{% for e in list %}{{ reviewRow(e.review, repo, e.depth) }}{% endfor %}100-</tbody>101-</table>117+<ul class="rv-list nav-list">118+{% for e in list %}{{ reviewItem(e.review, repo, e.depth) }}{% endfor %}119+</ul>102120 {% endmacro %}103121 104122 {# A directory listing: folders first, then files. #}
M site/assets/keys.ts +3 -2
M site/repo/reviews.njk +13 -21
@@ -6,18 +6,15 @@ pagination:
66 {% import "macros.njk" as m with context %}77 {% block content %}88 {% css %}9-/* Reviews by status, each status a group of rows. */10-table.list tr.group td { border: 0; padding: 18px 8px 6px; }11-table.list tr.group:hover td { background: none; }12-tr.group h3 { margin: 0; font-size: 0.95rem; }13-tr.group.open h3 { color: var(--pass); }14-tr.group.draft h3 { color: var(--muted); }15-tr.group.merged h3 { color: var(--merged); }16-tr.group.closed h3 { color: var(--fail); }17-@media (max-width: 720px) {18- table.list.stack tr.group { padding: 18px 8px 4px; border: 0; }19- table.list.stack tr.group td { font-size: 1rem; padding: 0; }20-}9+/* Reviews by status: a heading per group, the list under it. Long merged10+ and closed groups start folded. */11+.rv-group { margin: 0 0 18px; }12+.rv-group > summary { display: flex; align-items: baseline; gap: 8px; margin: 0 0 8px; cursor: pointer; list-style: none; }13+.rv-group > summary::-webkit-details-marker { display: none; }14+.rv-group > summary h2 { margin: 0; font-size: 0.78rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); }15+.rv-group > summary .n { color: var(--faint); font-size: 0.78rem; }16+.rv-group > summary::after { content: ''; flex: 1; border-top: 1px solid var(--border); transform: translateY(-3px); }17+.rv-group:not([open]) > summary h2::before { content: '\25B8 '; }2118 {% endcss %}2219 {% if not p.groups.length %}2320 <div class="box"><div class="markdown">
@@ -28,16 +25,11 @@ forge attest --stack # CI on your machine, signed per commit
2825 forge push -t "Title" # the branch and its CI results</pre>2926 </div></div>3027 {% else %}31-<div class="box">32-<table class="list stack nav reviews">33-{{ m.reviewHead() }}3428 {% for g in p.groups %}35-<tbody>36-<tr class="group {{ g.status }}"><td colspan="7"><h3>{{ g.status }} <span class="desc">{{ g.items.length }}</span></h3></td></tr>37-{% for e in g.items %}{{ m.reviewRow(e.review, p.repo, e.depth) }}{% endfor %}38-</tbody>29+<details class="rv-group {{ g.status }}"{% if g.status == 'open' or g.status == 'draft' or g.items.length <= 10 %} open{% endif %}>30+<summary><h2>{{ g.status }}</h2><span class="n">{{ g.items.length }}</span></summary>31+<div class="box">{{ m.reviews(g.items, p.repo) }}</div>32+</details>3933 {% endfor %}40-</table>41-</div>4234 {% endif %}4335 {% endblock %}
A test/scenarios/review-list.sh +46 -0
@@ -0,0 +1,46 @@
1+# The reviews page lists reviews by status under a heading with a count,2+# each review two lines: its title (CI and approvals at the right), then3+# number, branch, author, commits, diff size and when. A stacked layer4+# hangs under the review below it. Long merged and closed groups start5+# folded; open and draft never do.6+. "$(dirname "$0")/../lib.sh"7+user dami8+user ana9+repo dami/greet10+clone dami dami/greet "$T/w/d"11+at "$T/w/d" commit "first"12+push_as dami "$T/w/d" origin main13+at "$T/w/d" git switch -q -c dami/base14+printf 'one\ntwo\n' >"$T/w/d/a.txt"15+at "$T/w/d" git add a.txt16+at "$T/w/d" git commit -q -m "base layer"17+push_as dami "$T/w/d" -o title=Base origin dami/base18+at "$T/w/d" git switch -q -c dami/top19+at "$T/w/d" commit "top layer"20+push_as dami "$T/w/d" -o title=Top origin dami/top21+# Eleven merged reviews: their group starts folded.22+i=023+while [ $i -lt 11 ]; do24+ i=$((i + 1))25+ at "$T/w/d" git switch -q -c "dami/m$i" main26+ at "$T/w/d" commit "merged $i"27+ push_as dami "$T/w/d" -o "title=Merged $i" origin "dami/m$i"28+ at "$T/w/d" git switch -q main29+ at "$T/w/d" git merge -q --ff-only "dami/m$i"30+ push_as dami "$T/w/d" origin main31+done32+export FORGE_STRICT=133+build dami/greet34+p=$FORGE_WWW/dami/greet/reviews/index.html35+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>'40+# The layer on top hangs under the base, one deep.41+has "$p" '<li class="rv layer" style="--depth: 1">'42+has "$p" '<span class="rv-title"><a href="/dami/greet/reviews/2/">Top</a></span>'43+# Base's second line: number, branch, author, commits, diff size.44+has "$p" '<span class="rv-meta"><span class="num">#1</span><span class="mono">dami/base &rarr; main</span><span>dami</span><span>1 commit</span><span class="num"><span class="add">\+2</span> <span class="del">&minus;0</span></span>'45+# The home page's open reviews are the same list.46+has "$FORGE_WWW/dami/greet/index.html" '<ul class="rv-list nav-list">'