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
| M | README.md | 7 | ++++ |
| M | lib/ | 29 | ++++++++++++++++ |
| M | lib/ | 3 | +- |
| M | lib/ | 3 | ++ |
| M | site/ | 54 | ++++++++++++++++++++++++++++++ |
| M | site/ | 5 | ++- |
| M | site/ | 38 | +++++++++++++++++++++ |
| M | site/ | 11 | +------ |
| M | site/ | 11 | +------ |
| A | test/ | 34 | +++++++++++++++++++ |
@@ -1128,6 +1128,13 @@ Live updates have no event stream there, so the status line stays empty.
11281128 11291129 ## Reading pages11301130 1131+The index and each owner's page show repos as cards on shelves by category,1132+newest first. A repo's category is one line in the bare repo's `category`1133+file, next to `description` (`printf 'games\n' > /srv/git/dami/greet.git/category`).1134+A repo without one goes on the shelf for what Jev says it is (a web app, a1135+library), and failing that on "other". Chips above the shelves show one shelf1136+alone, only repos with a site, or sort by name.1137+11311138 A review page reads top to bottom: the stack, the layer's commits, then each11321139 commit's diff. While you read the diffs, a bar sticks to the top with the11331140 layers below and above and the previous and next commit. Every file folds@@ -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 {@@ -40,7 +40,8 @@ export function summarize(cfg: Config, owner: string, name: string): RepoSummary
4040 const head = tryRun(dir, ['symbolic-ref', '--short', 'HEAD'])?.trim() || 'main';4141 const last = tryRun(dir, ['for-each-ref', '--sort=-committerdate', '--count=1', '--format=%(committerdate:iso-strict)', 'refs/heads'])?.trim() || '';4242 const isPrivate = !fs.existsSync(path.join(dir, 'git-daemon-export-ok'));43- return { owner, name, dir, description, defaultBranch: head, lastCommit: last, slug: `${owner}/${name}`, urls: repoUrls(cfg, owner, name), private: isPrivate };43+ const category = readFile(path.join(dir, 'category')).toLowerCase();44+ return { owner, name, dir, description, category, defaultBranch: head, lastCommit: last, slug: `${owner}/${name}`, urls: repoUrls(cfg, owner, name), private: isPrivate };4445 }4546 4647 // Record markers carry a per-build nonce: patches can contain any byte,@@ -365,6 +365,9 @@ export interface RepoSummary {
365365 sites?: SiteLink[];366366 // What the repo is, by Jev (lib/jev-commits.ts).367367 topic?: string | null;368+ // Its shelf on the index, from the bare repo's category file (its owner's369+ // word, lowercase; '' when there is none).370+ category: string;368371 }369372 370373 export interface SiteLink {@@ -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 %}@@ -24,8 +24,9 @@
2424 [['g'], 'all repositories'],2525 ];2626 27- const rows = (): HTMLTableRowElement[] =>28- [...document.querySelectorAll<HTMLTableRowElement>('table.nav tbody tr')].filter((tr) => tr.querySelector('a'));27+ // Table rows, and the repo cards on the index (filtered ones are hidden).28+ const rows = (): HTMLElement[] =>29+ [...document.querySelectorAll<HTMLElement>('table.nav tbody tr, .nav-cards > li')].filter((el) => el.querySelector('a') && el.getClientRects().length);2930 let at = -1;3031 3132 function select(i: number): void {@@ -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);@@ -4,15 +4,6 @@ permalink: /index.html
44 {% extends "base.njk" %}55 {% import "macros.njk" as m with context %}66 {% block content %}7-<div class="box">8-<table class="list stack nav">9-<thead><tr><th>Repository</th><th>Description</th><th class="num">Reviews</th><th>Sites</th><th>Updated</th></tr></thead>10-<tbody>11-{% for s in forge.repos %}12-<tr><td class="nowrap"><a href="{{ s.urls.home.href }}"><span class="desc">{{ s.owner }} /</span> <b>{{ s.name }}</b></a></td><td class="main">{{ s.description }}{{ m.topic(s) }}</td><td class="num">{{ s.counts.reviews if s.counts and s.counts.reviews else '' }}</td><td>{{ m.sitesLink(s) }}</td><td class="date">{{ s.lastCommit | date }}</td></tr>13-{% endfor %}14-</tbody>15-</table>16-</div>7+{{ m.shelves(forge.repos, forge.owners.length > 1) }}178 <p class="desc"><a href="/insights/">Insights</a>: builds, pushes, reviews and CI across these repos, over time.</p>189 {% endblock %}@@ -10,14 +10,5 @@ permalink: "/{{ o.owner | safe }}/index.html"
1010 {% block heading %}<b>{{ o.owner }}</b>1111 {% endblock %}1212 {% block content %}13-<div class="box">14-<table class="list stack nav">15-<thead><tr><th>Repository</th><th>Description</th><th>Sites</th><th>Updated</th></tr></thead>16-<tbody>17-{% for s in o.repos %}18-<tr><td class="nowrap"><a href="{{ s.urls.home.href }}"><b>{{ s.name }}</b></a></td><td class="main">{{ s.description }}{{ m.topic(s) }}</td><td>{{ m.sitesLink(s) }}</td><td class="date">{{ s.lastCommit | date }}</td></tr>19-{% endfor %}20-</tbody>21-</table>22-</div>13+{{ m.shelves(o.repos) }}2314 {% endblock %}@@ -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$'