clone

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

Show repos as cards on shelves on the index and owner pages

cat committed e0704ad
The index and each owner's page grouped nothing: one table, newest
first. Repos now sit on shelves by category, as cards with a mark, the
description, sites, open reviews and the last push. A repo's category
is one line in its bare repo's category file, next to description; a
repo without one goes on the shelf for Jev's topic, and failing that on
"other". Fuller shelves come first and "other" last.

Chips above the shelves jump to one; with JavaScript they show that
shelf alone, only repos with a site, or sort by name, and j and k walk
the cards. Backups carry the category file with the rest of a repo.

Written in a parallel session; this adds the shelves scenario, and
renames the card's mark from .mono, which already means monospace.
commit
e0704ad293d2408d39739fec3a11ffd90aa59fbf
parent
844fa108ec9a508d6d2b58fb5636f2f156f825d3
author
cat <admin@dev.site>
ci
✓ passed in 67s, signed by cat
kind
feat risk score 2.1 of 3, Jev's estimate
review
#1 Show repos as cards on shelves
ci attestation
forge-attestation 1
type ci
commit e0704ad293d2408d39739fec3a11ffd90aa59fbf
tree 92381ce3275925ee4e9f8de71384a3c46634a685
script .forge/ci 05d612a331a46d602928c28846e036e53299df48
result pass
exit 0
started 2026-10-18T21:56:07Z
duration 67s
runner (not shown here; refs/notes/ci has it)
system Darwin 25.5.0 arm64
env host
log sha256:426a4a70bc2e6917b58035eb3f98d69334bedba711082ed8f3845cf51d1e1085 234 lines
|remote: review #4: /dami/greet/reviews/4/
|pushed wip/i18n
|dc78966 running .forge/ci: greet.sh: say what it is
|  | sh: /bin/sh
|  | ok   greet
|dc78966 pass in 0s, signed
|pushed main
|remote: review #4: /dami/greet/reviews/4/
|pushed wip/i18n
|321fa7f running .forge/ci: wave at the world
|  | sh: /bin/sh
|  | FAIL greet : got 'hello, world o/', want 'hello, world'
|321fa7f fail in 1s, signed
|forge: 1 commit(s) did not pass
|remote: review #6: /dami/greet/reviews/6/
|pushed ana/wave
|53729b4 running .forge/ci: update the test for the wave
|  | sh: /bin/sh
|  | ok   greet
|53729b4 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 97e2be8 -> /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
|fe98956 running .forge/ci: web, server and Python versions of greet
|  | sh: /bin/sh
|  | ok   greet
|fe98956 pass in 0s, signed
|pushed main
|a4f1e2a running .forge/ci: messages: notes for the later regions
|  | sh: /bin/sh
|  | ok   greet
|a4f1e2a 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 3e62e40 -> /sites/dami/forge/guide/
|(node:84029) 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 -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www
|(node:84152) 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 -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www
|(node:84289) 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 -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www
|(node:84428) 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 2s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www
|(node:85095) 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

10 files changed, +172 -23
MREADME.md7++++
Mlib/forge.ts29++++++++++++++++
Mlib/repo.ts3+-
Mlib/types.ts3++
Msite/_includes/macros.njk54++++++++++++++++++++++++++++++
Msite/assets/keys.ts5++-
Msite/assets/ui.ts38+++++++++++++++++++++
Msite/index.njk11+------
Msite/owner.njk11+------
Atest/scenarios/shelves.sh34+++++++++++++++++++
M README.md +7 -0
M lib/forge.ts +29 -0
@@ -450,6 +450,20 @@ const decode = (s: string): string =>
450450 451451 const MODES: Record<string, string> = { '040000': 'drwxr-xr-x', '100644': '-rw-r--r--', '100755': '-rwxr-xr-x', '120000': 'lrwxrwxrwx', '160000': 'm---------' };452452 453+// Shelves for Jev's topics (repoTopic in lib/jev-commits.ts), for repos454+// whose owner hasn't given a category.455+const SHELVES: Record<string, string> = {456+ 'web app': 'web apps',457+ cli: 'terminal',458+ service: 'services',459+ library: 'libraries',460+ game: 'games',461+ 'static site': 'sites',462+ docs: 'writing',463+ experiment: 'experiments',464+ config: 'config',465+};466+453467 // Filters that return markup; setup() in render-page.ts marks their output safe.454468 export const markupFilters = new Set(['ago', 'date']);455469
@@ -514,6 +528,21 @@ export const filters = {
514528 return html.ci(status, { long });515529 },516530 date: html.date,531+ // The index and owner pages: repos on shelves, newest first. A shelf is532+ // the category the repo's owner gave it, else Jev's topic, else "other".533+ // Fuller shelves come first, "other" last.534+ shelves(repos: RepoSummary[]): { id: string; title: string; repos: RepoSummary[] }[] {535+ const by = new Map<string, RepoSummary[]>();536+ for (const r of [...repos].sort((a, b) => b.lastCommit.localeCompare(a.lastCommit))) {537+ const k = r.category || (r.topic && SHELVES[r.topic]) || 'other';538+ by.set(k, [...(by.get(k) ?? []), r]);539+ }540+ return [...by.keys()]541+ .sort((a, b) => Number(a === 'other') - Number(b === 'other') || by.get(b)!.length - by.get(a)!.length || a.localeCompare(b))542+ .map((k) => ({ id: k.replace(/[^a-z0-9]+/g, '-'), title: k === 'other' && by.size === 1 ? 'repositories' : k, repos: by.get(k)! }));543+ },544+ // A repo card's color: a hue from its name, the same on every build.545+ hue: (name: string): number => [...name].reduce((h, c) => (h * 31 + c.charCodeAt(0)) % 360, 7),517546 short: (sha: string | null | undefined): string => String(sha ?? '').slice(0, 7),518547 // Unix seconds (review logs) or an ISO date.519548 ago(ts: number | string | null): string {
M lib/repo.ts +2 -1
M lib/types.ts +3 -0
M site/_includes/macros.njk +54 -0
@@ -113,6 +113,60 @@ a.rev { font-size: 0.85em; color: var(--muted); border: 1px solid var(--border);
113113 </table>114114 {% endmacro %}115115 116+{# The index and owner pages: repos as cards on shelves by kind (the shelves117+ filter), with chips that jump to a shelf. ui.ts makes the chips filters and118+ adds "has a site" and the sort; keys.ts walks the cards with j and k. #}119+{% macro shelves(repos, owners = false) %}120+{% css %}121+.shelf-bar { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 0 0 4px; }122+.shelf-bar .grow { flex: 1; }123+.chip { font: inherit; font-size: 0.85em; color: var(--muted); background: none; border: 1px solid var(--border); border-radius: 999px; padding: 2px 11px; cursor: pointer; white-space: nowrap; }124+.chip:hover { color: var(--fg); background: var(--hover); text-decoration: none; }125+.chip[aria-pressed="true"] { color: var(--fg); background: var(--hover); border-color: var(--faint); }126+.chip .n { color: var(--faint); margin-left: 4px; font-variant-numeric: tabular-nums; }127+.shelf-head { display: flex; align-items: baseline; gap: 8px; margin: 22px 0 10px; scroll-margin-top: 12px; }128+.shelf-head h2 { margin: 0; font-size: 0.78rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); }129+.shelf-head .n { color: var(--faint); font-size: 0.78rem; }130+.shelf-head::after { content: ''; flex: 1; border-top: 1px solid var(--border); transform: translateY(-3px); }131+.cards { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); gap: 10px; }132+.card { position: relative; display: flex; flex-direction: column; gap: 6px; min-width: 0; padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--subtle); }133+.card:hover, .card:focus-within, .card.sel { border-color: var(--faint); background: var(--hover); }134+.card.sel { box-shadow: inset 2px 0 0 var(--accent); }135+.card-top { display: flex; align-items: center; gap: 10px; min-width: 0; }136+.card-mark { flex: none; display: grid; place-items: center; width: 28px; height: 28px; border-radius: 6px; background: hsl(var(--h) 55% 68%); color: #0d1117; font-weight: 700; font-size: 0.8rem; }137+.card-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 650; }138+.card-name::after { content: ''; position: absolute; inset: 0; border-radius: var(--radius); }139+.card-name .desc { font-weight: normal; }140+.card-desc { margin: 0; color: var(--muted); font-size: 0.9em; overflow-wrap: anywhere; }141+.card-foot { display: flex; align-items: center; gap: 10px; margin-top: auto; padding-top: 2px; font-size: 0.8em; color: var(--faint); }142+.card-foot a { position: relative; z-index: 1; }143+.card-foot .grow { flex: 1; }144+.shelves-empty { color: var(--muted); padding: 24px 0; text-align: center; }145+{% endcss %}146+{% set list = repos | shelves %}147+<div class="shelf-bar" data-shelves>148+{% if list.length > 1 %}{% for sh in list %}<a class="chip" href="#{{ sh.id }}" data-shelf="{{ sh.id }}">{{ sh.title }}<span class="n">{{ sh.repos.length }}</span></a>{% endfor %}{% endif %}149+<button type="button" class="chip" data-has-site hidden aria-pressed="false">has a site</button>150+<span class="grow"></span>151+<button type="button" class="chip" data-sort hidden title="Sort by name or by last push">newest first</button>152+</div>153+{% for sh in list %}154+<section class="shelf" data-shelf="{{ sh.id }}">155+<div class="shelf-head" id="{{ sh.id }}"><h2>{{ sh.title }}</h2><span class="n">{{ sh.repos.length }}</span></div>156+<ul class="cards nav-cards">157+{% for s in sh.repos %}158+<li class="card" data-name="{{ s.name | lower }}" data-date="{{ s.lastCommit }}"{% if s.counts.sites %} data-site{% endif %}>159+<div class="card-top"><span class="card-mark" style="--h: {{ s.name | hue }}" aria-hidden="true">{{ s.name[0] | upper }}</span><a class="card-name" href="{{ s.urls.home.href }}">{% if owners %}<span class="desc">{{ s.owner }} / </span>{% endif %}{{ s.name }}</a></div>160+{% if s.description %}<p class="card-desc">{{ s.description }}</p>{% endif %}161+<div class="card-foot">{{ sitesLink(s) }}{% if s.counts.reviews %}<a href="{{ s.urls.reviews.href }}">{{ s.counts.reviews | plural('review') }}</a>{% endif %}<span class="grow"></span>{{ s.lastCommit | date }}</div>162+</li>163+{% endfor %}164+</ul>165+</section>166+{% endfor %}167+<p class="shelves-empty" hidden>No repos here have a site.</p>168+{% endmacro %}169+116170 {% macro sitesLink(s) %}117171 {% if s.counts.sites == 1 and s.sites.length %}<a href="{{ s.sites[0].url }}">{{ s.sites[0].name }}</a>118172 {% elif s.counts.sites %}<a href="{{ s.urls.sites.href }}">sites</a>{% endif %}
M site/assets/keys.ts +3 -2
M site/assets/ui.ts +38 -0
@@ -71,9 +71,47 @@
7171 if (over > 0) tabs.scrollLeft += over + 24;7272 }7373 74+ // The index and owner pages' shelves (shelves in macros.njk). Without JS the75+ // chips jump to a shelf; here one shows that shelf alone, "has a site"76+ // hides repos without one, and the sort switches between newest and a-z.77+ // The choices outlive live.ts swapping in a rebuilt page.78+ const pick = { shelf: '', site: false, az: false };79+ const shelves = (): void => {80+ const bar = document.querySelector<HTMLElement>('[data-shelves]');81+ if (!bar) return;82+ for (const b of bar.querySelectorAll<HTMLElement>('[hidden]')) b.hidden = false;83+ for (const c of bar.querySelectorAll<HTMLElement>('[data-shelf]')) c.setAttribute('aria-pressed', String(c.dataset.shelf === pick.shelf));84+ bar.querySelector('[data-has-site]')?.setAttribute('aria-pressed', String(pick.site));85+ const sort = bar.querySelector<HTMLElement>('[data-sort]');86+ if (sort) sort.textContent = pick.az ? 'a-z' : 'newest first';87+ let shown = 0;88+ for (const sec of document.querySelectorAll<HTMLElement>('section.shelf')) {89+ const ul = sec.querySelector('ul')!;90+ const cards = [...ul.children] as HTMLElement[];91+ cards.sort((a, b) => (pick.az ? a.dataset.name!.localeCompare(b.dataset.name!) : b.dataset.date!.localeCompare(a.dataset.date!)));92+ ul.append(...cards);93+ let n = 0;94+ for (const c of cards) n += Number(!(c.hidden = pick.site && !c.hasAttribute('data-site')));95+ sec.hidden = !n || (!!pick.shelf && sec.dataset.shelf !== pick.shelf);96+ if (!sec.hidden) shown++;97+ }98+ const empty = document.querySelector<HTMLElement>('.shelves-empty');99+ if (empty) empty.hidden = shown > 0;100+ };101+ document.addEventListener('click', (e) => {102+ const el = (e.target as Element | null)?.closest<HTMLElement>('[data-shelves] .chip');103+ if (!el) return;104+ e.preventDefault();105+ if (el.dataset.shelf !== undefined) pick.shelf = pick.shelf === el.dataset.shelf ? '' : el.dataset.shelf;106+ else if (el.hasAttribute('data-has-site')) pick.site = !pick.site;107+ else if (el.hasAttribute('data-sort')) pick.az = !pick.az;108+ shelves();109+ });110+74111 const content = (): void => {75112 wrap(wrapped());76113 times();114+ shelves();77115 };78116 content();79117 setInterval(times, 60000);
M site/index.njk +1 -10
M site/owner.njk +1 -10
A test/scenarios/shelves.sh +34 -0
@@ -0,0 +1,34 @@
1+# The index and owner pages show repos as cards on shelves: the category2+# its owner wrote in the bare repo's category file, else "other" (Jev's3+# topic is off in scenarios); fuller shelves first, "other" last, newest4+# first on each. Private repos have no card, and backups keep the file.5+. "$(dirname "$0")/../lib.sh"6+user dami7+user ana8+for r in dami/chess dami/go ana/notes; do9+ repo "$r"10+ clone dami "$r" "$T/w/${r#*/}"11+ tick 360012+ at "$T/w/${r#*/}" commit "first"13+ push_as "${r%%/*}" "$T/w/${r#*/}" origin main14+done15+repo --private dami/secret16+printf 'Games\n' >"$FORGE_ROOT/dami/chess.git/category"17+printf 'games\n' >"$FORGE_ROOT/dami/go.git/category"18+export FORGE_STRICT=119+build20+i=$FORGE_WWW/index.html21+has "$i" '<section class="shelf" data-shelf="games">'22+has "$i" '<section class="shelf" data-shelf="other">'23+# 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 "25+eq "$(grep -o 'class="card-name" href="/[a-z]*/[a-z]*/"' "$i" | sed 's/.*href="//; s/"//' | tr '\n' ' ')" "/dami/go/ /dami/chess/ /ana/notes/ "26+# Several owners: each card names its owner.27+has "$i" '<span class="desc">dami / </span>go</a>'28+lacks "$i" 'secret'29+has "$FORGE_WWW/dami/index.html" '<section class="shelf" data-shelf="games">'30+lacks "$FORGE_WWW/dami/index.html" '<span class="desc">dami / </span>'31+# One owner's only shelf is "other": titled repositories.32+has "$FORGE_WWW/ana/index.html" '<h2>repositories</h2>'33+ok forge backup --dry-run34+has "$FORGE_ROOT/.forge/backup/stage/dami/chess.git/category" '^Games$'