The home page was the shelves of every repo, which each owner's page has
too. Now it says who is here and what they have been doing, in two
columns (one on phones: profiles, recent, topics):
- a quiet line under the header, .forge/motd ("a personal forge on plain
git" without the file, nothing when it is empty);
- profiles: each owner with public repos, a letter on a colour seeded
from the name (the accent for FORGE_OWNER, new --avatar-* tokens the
same in both modes), how many repos and when they were last active;
- recent: the last eight landed reviews, pushes to a default branch,
releases and site deploys of public repos, newest first, each linking
to what it names, with "insights" at the end of its head strip;
- topics: the shelves across every public repo, in tiles. One owner's
links to that shelf on their page (/cat/#tools, which ui.ts now opens on
its tab); one several owners share to /topics/#tools, a new page of
every public repo by shelf, each naming its owner.
lib/recent.ts makes the list. Landed reviews come from the review logs;
pushes, releases and site deploys from the events log, whose push lines
now carry the default branch's new tip as head, so a push can be named
by its subject, and a push whose head is a review's tip shows once, as
that review landing. Every build redoes the home page and live.ts swaps
it in, so it is as fresh as the last push. Private repos are never read,
and owners with only private repos have no profile.- commit
- a92a26928f539747da142a4a61470cc01ea3182a
- parent
- c22212941adb076fda008e3cf7e8ddd1ea4e6978
- author
- cat <admin@dev.site>
- ci
- ✓ passed in 88s, signed by cat
- kind
- feat risk score 2.6 of 3, Jev's estimate
- review
- #21 A home page of profiles, recent and topics; motd for admins; /login/ in panels
ci attestation
forge-attestation 1 type ci commit a92a26928f539747da142a4a61470cc01ea3182a tree e58d32f3c68703d0e4d365e1ae0c47547f6529c3 script .forge/ci 2dbcda6e044dc4b05503abde8369e2528413d3c7 result pass exit 0 started 2026-10-19T04:42:23Z duration 88s runner (not shown here; refs/notes/ci has it) system Darwin 25.5.0 arm64 env host log sha256:046de8c13a925c3d2c1f7fa8c3e17bcbb4eac63a899a5aef9706c0016d550b59 279 lines |pushed wip/i18n |c5e91ec running .forge/ci: greet.sh: say what it is | | sh: /bin/sh | | ok greet |c5e91ec pass in 0s, signed |pushed main |remote: review #4: /dami/greet/reviews/4/ |pushed wip/i18n |c194ebb running .forge/ci: wave at the world | | sh: /bin/sh | | FAIL greet : got 'hello, world o/', want 'hello, world' |c194ebb fail in 0s, signed |forge: 1 commit(s) did not pass |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |5008b8b running .forge/ci: update the test for the wave | | sh: /bin/sh | | ok greet |5008b8b 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 d4f0dd4 -> /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 |f45c858 running .forge/ci: web, server and Python versions of greet | | sh: /bin/sh | | ok greet |f45c858 pass in 0s, signed |pushed main |e1ebc74 running .forge/ci: messages: notes for the later regions | | sh: /bin/sh | | ok greet |e1ebc74 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 abeeb6b -> /sites/dami/forge/guide/ |(node:80360) 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.AqAagy/wt/demo/root/.forge/www |(node:80523) 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.AqAagy/wt/demo/root/.forge/www |(node:80698) 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 -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.AqAagy/wt/demo/root/.forge/www |(node:80858) 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.AqAagy/wt/demo/root/.forge/www |(node:81556) 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 -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.AqAagy/wt/demo/root/.forge/www |235 of 235 pages render the same without 11ty |guide: 8 files, links ok |ok
Signature: good, cat
| M | DESIGN.md | 6 | +- |
| M | README.md | 22 | ++++- |
| M | bin/ | 15 | ++- |
| M | lib/ | 47 | ++++++++- |
| A | lib/ | 49 | ++++++++ |
| A | lib/ | 175 | ++++++++++++++++++++++++++++++ |
| M | lib/ | 5 | +- |
| M | lib/ | 5 | +- |
| M | site/ | 2 | +- |
| M | site/ | 7 | + |
| M | site/ | 6 | +- |
| M | site/ | 95 | ++++++++++++++++- |
| A | site/ | 16 | +++ |
| A | test/ | 35 | ++++++ |
| A | test/ | 58 | ++++++++++ |
| A | test/ | 37 | ++++++ |
| M | test/ | 2 | +- |
| M | test/ | 4 | +- |
| M | test/ | 4 | +- |
| M | test/ | 4 | +- |
@@ -60,7 +60,9 @@ it starts a group: the style guide titles groups by those comments).
6060 One accent, `#3148F6`: `--link` for links (lighter in dark mode),6161 `--accent` for the current tab's bar, focus, primary fills, `--deep` on6262 hover. Meaning: `--add-*`, `--del-*`, `--pass`, `--fail`, `--warn`,63- `--status-*` fills.63+ `--status-*` fills. The home page's letter avatars are white on64+ `--avatar-1` to `--avatar-5`, seeded from the name, or the accent for65+ the forge's own owner.6466 - Type: `--text-xs` 12, `--text-code` 12.5, `--text-sm` 13.5, `--text-md`6567 15 (body), `--text-prose` 16, `--text-lg` 18, `--text-xl` 22,6668 `--text-2xl` 28. Weights `--fw-regular` 400 (body), `--fw-medium` 500@@ -101,7 +103,7 @@ Self-hosted variable woff2 in `site/assets/fonts/` (how they were cut:
101103 Words for where you are, symbols for how you look at it.102104 103105 - Tabs say where you are (`ui.tabs`): the repo's sections, the shelves on104- the index (with an "all" tab).106+ an owner's page and `/topics/` (with an "all" tab).105107 - A checkbox turns one thing on or off (`ui.checkbox`): wrap lines,106108 viewed.107109 - An icon toggle is a checkbox drawn as a symbol (`ui.iconToggle`): a@@ -1326,7 +1326,27 @@ first build takes minutes.
13261326 13271327 ## Reading pages13281328 1329-The index and each owner's page show repos as rows on shelves by category,1329+The home page has a quiet line under its header, `.forge/motd` (no file1330+is "a personal forge on plain git", an empty one no line), then three panels. **profiles**: each1331+owner with public repos, how many and when they were last active.1332+**recent**: the last eight things that happened on public repos, newest1333+first: landed reviews, pushes to a default branch, releases (a tag or1334+release pushed) and site deploys, each linking to what it names1335+(`lib/recent.ts`). Landed reviews come from the review logs; the rest1336+from `.forge/events.jsonl`, whose push lines carry the default branch's new1337+tip as `head`. A push whose head is a review's tip is that review landing,1338+and shows once, as landed. Every build redoes the home page, and open1339+copies of it swap in the new one when the build is done, so the list is1340+as fresh as the last push; its times stay relative in the browser. The1341+events log is cut and left out of backups, so after a restore the list has1342+only landed reviews until the next pushes. **topics**: the shelves (below)1343+across every public repo; one owner's links to that shelf on their page1344+(`/dami/#games`, which opens on its tab), and one that several owners1345+share to `/topics/#games`, every public repo on its shelf, each naming its1346+owner. Private repos, and owners with only private ones, are never on1347+any of it.1348+1349+Each owner's page, and `/topics/`, show repos as rows on shelves by category,13301350 newest first. A repo's category is one line in the bare repo's `category`13311351 file, next to `description` (`printf 'games\n' > /srv/git/dami/greet.git/category`).13321352 A repo without one goes on the shelf for what Jev says it is (a web app, a@@ -1020,7 +1020,7 @@ load_config() {
10201020 10211021 valid_name() {10221022 case $1 in1023- '' | .* | *[!A-Za-z0-9._-]* | *.git | assets | sites | previews | hosts | insights | design | git-shell-commands) return 1 ;;1023+ '' | .* | *[!A-Za-z0-9._-]* | *.git | assets | sites | previews | hosts | insights | design | topics | git-shell-commands) return 1 ;;10241024 esac10251025 }10261026 @@ -2636,13 +2636,13 @@ noreview
26362636 DEFAULT=$first26372637 fi26382638 fi2639- PUSHED= rel=02639+ PUSHED= rel=0 head=26402640 while read -r old new ref; do26412641 case $ref in refs/heads/* | refs/tags/* | refs/sites/* | refs/releases/*) PUSHED="$PUSHED $ref" ;; esac26422642 case $ref in refs/tags/* | refs/releases/*) rel=1 ;; esac26432643 [ "$new" != "$ZERO" ] || case $ref in refs/heads/*) drop_previews "${ref#refs/heads/}" ;; esac26442644 case $ref in2645- "refs/heads/$DEFAULT") ;;2645+ "refs/heads/$DEFAULT") [ "$new" = "$ZERO" ] || head=$new ;;26462646 refs/heads/*) [ "$noreview" = 1 ] || record_review "${ref#refs/heads/}" "$new" ;;26472647 refs/sites/*) push_site "sites/$OWNER/$NAME/${ref#refs/sites/}" "$new" ;;26482648 refs/site-previews/*)@@ -2658,7 +2658,10 @@ noreview
26582658 if [ "$rel" = 1 ]; then release_latest "$(pages_root "$GIT_DIR")"; fi26592659 git update-server-info26602660 refs=$(printf '%s' "$PUSHED" | tr -d '"\\')2661- printf '{"t":%s000,"repo":"%s","type":"push","user":"%s","refs":"%s"%s}\n' "${FORGE_NOW:-$(date +%s)}" "$OWNER/$NAME" "${USER_:-admin}" "$refs" "$(ev_private "$GIT_DIR")" >>"$FORGE_STATE/events.jsonl"2661+ # head: the default branch's new tip, for the index's recent list (lib/recent.ts).2662+ hf=2663+ [ -z "$head" ] || hf=",\"head\":\"$head\""2664+ printf '{"t":%s000,"repo":"%s","type":"push","user":"%s"%s,"refs":"%s"%s}\n' "${FORGE_NOW:-$(date +%s)}" "$OWNER/$NAME" "${USER_:-admin}" "$hf" "$refs" "$(ev_private "$GIT_DIR")" >>"$FORGE_STATE/events.jsonl"26622665 rebuild "$OWNER/$NAME" push26632666 }26642667 @@ -2695,7 +2698,7 @@ build_all() {
26952698 [ -d "$w" ] || continue26962699 slug=${w#"$FORGE_WWW"/}26972700 slug=${slug%/}2698- case $slug in assets/* | sites/* | previews/* | hosts/* | insights/* | design/*) continue ;; esac2701+ case $slug in assets/* | sites/* | previews/* | hosts/* | insights/* | design/* | topics/*) continue ;; esac26992702 [ -f "$FORGE_ROOT/$slug.git/HEAD" ] && [ -f "$FORGE_ROOT/$slug.git/git-daemon-export-ok" ] || rm -rf "$w"27002703 done27012704 for w in "$FORGE_WWW"/sites/*/*/ "$FORGE_WWW"/previews/*/*/; do@@ -2706,7 +2709,7 @@ build_all() {
27062709 done27072710 for w in "$FORGE_WWW"/*/; do27082711 b=$(basename "$w")2709- case $b in assets | sites | previews | hosts | insights | design) continue ;; esac2712+ case $b in assets | sites | previews | hosts | insights | design | topics) continue ;; esac27102713 [ -d "$FORGE_ROOT/$b" ] || rm -rf "$w"27112714 done27122715 if [ -d "$FORGE_WWW/hosts" ]; then node "$FORGE_HOME/lib/hosts.ts" prune; fi@@ -22,6 +22,8 @@ import { labelCommits, labelKey, releases, repoTopic } from './jev-commits.ts';
2222 import { annotateReviews, answersStamp, isMechanical, pairChanges } from './jev-reviews.ts';2323 import { HighlightCache } from './highlight.ts';2424 import { assets } from './assets.ts';25+import { recent, type RecentItem } from './recent.ts';26+import { motd } from './motd.ts';2527 import type { CiStatus, Commit, Config, DiffFile, Repo, RepoCounts, RepoSummary, RepoUrls, Review, ReviewComment, SiteLink } from './types.ts';2628 2729 export function config(env: NodeJS.ProcessEnv = process.env): Config {@@ -52,7 +54,7 @@ export function config(env: NodeJS.ProcessEnv = process.env): Config {
5254 const caseless = (a: string, b: string): number => a.localeCompare(b, 'en', { sensitivity: 'base' }) || (a < b ? -1 : a > b ? 1 : 0);5355 5456 // Top-level names used by the forge itself; never owners.55-const RESERVED = new Set(['assets', 'sites', 'previews', 'hosts', 'insights', 'index.html', 'git-shell-commands', 'lost+found', 'login', 'private']);57+const RESERVED = new Set(['assets', 'sites', 'previews', 'hosts', 'insights', 'topics', 'index.html', 'git-shell-commands', 'lost+found', 'login', 'private']);5658 5759 // Every repo, public or not (RepoSummary.private); builds filter.5860 export function discover(cfg: Config): { repos: RepoSummary[]; owners: string[] } {@@ -117,6 +119,12 @@ export interface Forge {
117119 config: Config;118120 repos: RepoSummary[];119121 owners: { owner: string; repos: RepoSummary[] }[];122+ // The index (site/index.njk): its line under the header (null: none),123+ // the public owners, the shelves across them, and what happened lately.124+ motd: string | null;125+ profiles: Profile[];126+ topics: Topic[];127+ recent: RecentItem[];120128 pages: Record<string, Page[]>;121129 raw: RawFile[];122130 // Incremental builds: the live site, and the pages taken from it unchanged.@@ -126,6 +134,32 @@ export interface Forge {
126134 pageCaches: { cache: RepoCache; fps: Record<string, string> }[];127135 }128136 137+export interface Profile {138+ owner: string;139+ repos: number;140+ // When they last did something public (lib/recent.ts), ISO.141+ active: string;142+ // Their letter avatar's colour: avatar-1 to avatar-5, seeded from the143+ // name, or the accent for the forge's own owner (FORGE_OWNER).144+ colour: string;145+}146+147+export interface Topic {148+ id: string;149+ title: string;150+ repos: number;151+ // One owner's: that shelf on their page; several owners': on /topics/.152+ href: string;153+}154+155+const AVATARS = 5;156+function colour(owner: string, env: NodeJS.ProcessEnv): string {157+ if (owner === env.FORGE_OWNER) return 'accent';158+ let h = 0;159+ for (const ch of owner) h = (h * 31 + ch.codePointAt(0)!) >>> 0;160+ return `avatar-${(h % AVATARS) + 1}`;161+}162+129163 let last: Forge | null = null;130164 export const current = (): Forge | null => last;131165 @@ -385,10 +419,17 @@ export function loadForge(env: NodeJS.ProcessEnv = process.env): Forge {
385419 fs.writeFileSync(summaryCache, JSON.stringify(Object.fromEntries(summaries.map((s) => [s.slug, cached[s.slug] ?? { counts: {}, sites: [] }]))));386420 387421 const owners = ownerNames.map((owner) => ({ owner, repos: summaries.filter((s) => s.owner === owner) }));422+ // The index's panels; a private build has no index.423+ const lately = priv ? { items: [], active: {} } : recent(cfg, summaries);424+ const profiles = owners.filter((o) => o.repos.length).map((o) => ({ owner: o.owner, repos: o.repos.length, active: lately.active[o.owner] ?? '', colour: colour(o.owner, env) }));425+ const topics = filters.shelves(summaries).map((sh) => {426+ const who = [...new Set(sh.repos.map((r) => r.owner))];427+ return { id: sh.id, title: sh.title, repos: sh.repos.length, href: who.length === 1 ? `/${who[0]}/#${sh.id}` : `/topics/#${sh.id}` };428+ });388429 // Repo search on the index and owner pages.389430 raw.push({ path: '/repos.json', data: Buffer.from(reposIndex(summaries)) });390431 if (!cfg.only || cfg.only === '-') raw.push(...insightFiles(cfg, 'insights', '/insights/'));391- last = { config: cfg, repos: summaries, owners, pages, raw: raw.filter((r) => r.data || r.file), live, code, skipped, pageCaches };432+ last = { config: cfg, repos: summaries, owners, motd: motd(cfg.state), profiles, topics, recent: lately.items, pages, raw: raw.filter((r) => r.data || r.file), live, code, skipped, pageCaches };392433 return last;393434 }394435 @@ -529,7 +570,7 @@ export const filters = {
529570 return html.ci(status, { long });530571 },531572 date: html.date,532- // The index and owner pages: repos on shelves, newest first. A shelf is573+ // Owner pages, /topics/ and the home page's topics: repos on shelves, newest first. A shelf is533574 // the category the repo's owner gave it, else Jev's topic, else "other".534575 // Fuller shelves come first, "other" last.535576 shelves(repos: RepoSummary[]): { id: string; title: string; repos: RepoSummary[] }[] {@@ -0,0 +1,49 @@
1+// The motd: the quiet line under the header on the home page, which an2+// admin sets (bin/forge motd). It is kept in .forge/motd: no file is the3+// default, an empty one no line at all.4+//5+// node lib/motd.ts default the default line6+// node lib/motd.ts check <text> the text as stored (trimmed), or why not7+import fs from 'node:fs';8+import path from 'node:path';9+import { fileURLToPath } from 'node:url';10+11+export const MOTD_DEFAULT = 'a personal forge on plain git';12+export const MOTD_MAX = 72;13+14+// What the index shows: null for no line.15+export function motd(state: string): string | null {16+ let text: string;17+ try {18+ text = fs.readFileSync(path.join(state, 'motd'), 'utf8');19+ } catch {20+ return MOTD_DEFAULT;21+ }22+ return text.split('\n')[0].trim() || null;23+}24+25+// Plain text on one line, counted in characters; the page escapes it.26+export function check(text: string): { text: string } | { error: string } {27+ const s = text.trim();28+ const n = [...s].length;29+ if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(s)) return { error: 'one line of plain text, without control characters' };30+ if (!n) return { error: 'empty; motd --clear shows no line' };31+ if (n > MOTD_MAX) return { error: `${n} characters; it can have ${MOTD_MAX} at most` };32+ return { text: s };33+}34+35+if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {36+ const [cmd, text = ''] = process.argv.slice(2);37+ if (cmd === 'default') console.log(MOTD_DEFAULT);38+ else if (cmd === 'check') {39+ const r = check(text);40+ if ('error' in r) {41+ console.error(`forge: motd: ${r.error}`);42+ process.exit(1);43+ }44+ console.log(r.text);45+ } else {46+ console.error('usage: node lib/motd.ts default | check <text>');47+ process.exit(2);48+ }49+}@@ -0,0 +1,175 @@
1+// What happened lately on the forge's public repos, for the index's recent2+// list and its profiles: landed reviews, pushes to a default branch,3+// releases and site deploys, newest first.4+//5+// Pushes, releases and site deploys come from the events log6+// (.forge/events.jsonl): post-receive writes a line for each push with the7+// refs it moved, and the default branch's new tip as head. Landed reviews8+// come from the review logs, which last: a review whose branch was deleted9+// with its tip in the default branch, at the time it was deleted. A push10+// whose head is a review's tip is that landing, and shows as landed. The11+// events log is cut to its last 1000 lines and left out of backups, so12+// after a restore only landed reviews show until the next pushes.13+//14+// Private repos are never read: the list is made from the public repos the15+// build was given, and their events, which carry no "private" mark.16+import fs from 'node:fs';17+import path from 'node:path';18+import { isAncestor, tryRun } from './git.ts';19+import { readReviewLogs } from './reviews.ts';20+import type { Config, RepoSummary } from './types.ts';21+22+export type RecentKind = 'landed' | 'pushed' | 'release' | 'site';23+24+export interface RecentItem {25+ kind: RecentKind;26+ // ISO time, for <time datetime>.27+ at: string;28+ repo: { slug: string; name: string; owner: string };29+ href: string;30+ // The review's #id or the tag, before the text (a release's is its tag only).31+ ref: string;32+ text: string;33+}34+35+// When each owner last did something public: their newest event, landed36+// review or commit. ISO, '' for never.37+export type Active = Record<string, string>;38+39+interface Candidate {40+ kind: RecentKind;41+ t: number;42+ repo: RepoSummary;43+ // pushed: the new head; landed: the review's tip; release, site: the name.44+ key: string;45+ // landed: the review.46+ id?: number;47+ title?: string;48+ base?: string | null;49+}50+51+interface Event {52+ t?: number;53+ repo?: string;54+ type?: string;55+ refs?: string;56+ head?: string;57+ private?: boolean;58+}59+60+function events(state: string): Event[] {61+ let text = '';62+ try {63+ text = fs.readFileSync(path.join(state, 'events.jsonl'), 'utf8');64+ } catch {65+ return [];66+ }67+ const out: Event[] = [];68+ for (const line of text.split('\n')) {69+ if (!line.includes('"type":"push"')) continue;70+ try {71+ const e = JSON.parse(line);72+ if (e && e.type === 'push' && !e.private && typeof e.repo === 'string' && typeof e.t === 'number') out.push(e);73+ } catch {74+ // A line cut short by a crash.75+ }76+ }77+ return out;78+}79+80+const iso = (ms: number): string => new Date(ms).toISOString().replace(/\.\d+Z$/, 'Z');81+82+export function recent(cfg: Config, repos: RepoSummary[], n = 8): { items: RecentItem[]; active: Active } {83+ const bySlug = new Map(repos.map((r) => [r.slug, r]));84+ const latest: Record<string, number> = {};85+ const seen = (owner: string, t: number): void => {86+ if (t > (latest[owner] ?? 0)) latest[owner] = t;87+ };88+ for (const r of repos) if (r.lastCommit) seen(r.owner, Date.parse(r.lastCommit));89+90+ const list: Candidate[] = [];91+ for (const e of events(cfg.state)) {92+ const repo = bySlug.get(e.repo!);93+ if (!repo) continue;94+ seen(repo.owner, e.t!);95+ const named = new Set<string>();96+ for (const ref of (e.refs ?? '').split(/\s+/).filter(Boolean)) {97+ if (ref === `refs/heads/${repo.defaultBranch}`) {98+ if (e.head && /^[0-9a-f]{40,64}$/.test(e.head)) list.push({ kind: 'pushed', t: e.t!, repo, key: e.head });99+ } else {100+ const m = /^refs\/(tags|releases|sites)\/(.+)$/.exec(ref);101+ if (!m) continue;102+ const kind = m[1] === 'sites' ? 'site' : 'release';103+ // A tag and its release in one push are one release.104+ if (named.has(kind + ' ' + m[2])) continue;105+ named.add(kind + ' ' + m[2]);106+ list.push({ kind, t: e.t!, repo, key: m[2] });107+ }108+ }109+ }110+111+ // Every review's revisions, by repo: a push of one to the default branch landed it.112+ const revs = new Map<string, Set<string>>();113+ for (const repo of repos) {114+ const shas = new Set<string>();115+ for (const r of readReviewLogs(repo.dir, false)) {116+ for (const v of r.revs) shas.add(v.sha);117+ const tip = r.revs.at(-1);118+ if (!r.deleted || !tip) continue;119+ seen(repo.owner, r.deleted.ts * 1000);120+ list.push({ kind: 'landed', t: r.deleted.ts * 1000, repo, key: tip.sha, id: r.id, title: r.opts.title, base: tip.base });121+ }122+ revs.set(repo.slug, shas);123+ }124+125+ // The refs each repo has now: a deleted tag or site isn't news.126+ const refs = new Map<string, Set<string>>();127+ const refsOf = (repo: RepoSummary): Set<string> => {128+ let s = refs.get(repo.slug);129+ if (!s) {130+ s = new Set((tryRun(repo.dir, ['for-each-ref', '--format=%(refname)', 'refs/tags', 'refs/releases', 'refs/sites']) ?? '').split('\n').filter(Boolean));131+ refs.set(repo.slug, s);132+ }133+ return s;134+ };135+ const subject = (repo: RepoSummary, rev: string): string | null => tryRun(repo.dir, ['log', '-1', '--format=%s', rev, '--'])?.trim() ?? null;136+137+ // Newest first; checked one by one (git), until there are n.138+ list.sort((a, b) => b.t - a.t || Number(b.kind === 'landed') - Number(a.kind === 'landed'));139+ const items: RecentItem[] = [];140+ const done = new Set<string>();141+ for (const c of list) {142+ if (items.length >= n) break;143+ const { repo } = c;144+ const once = `${c.kind} ${repo.slug} ${c.key}`;145+ if (done.has(once)) continue;146+ let href = '', ref = '', text = '';147+ if (c.kind === 'pushed') {148+ if (revs.get(repo.slug)?.has(c.key)) continue;149+ const s = subject(repo, c.key);150+ if (s == null) continue;151+ href = repo.urls.commit(c.key).href;152+ text = s;153+ } else if (c.kind === 'landed') {154+ if (!isAncestor(repo.dir, c.key, `refs/heads/${repo.defaultBranch}`)) continue;155+ // Untitled, a review is named for its first commit (as lib/repo.ts names it).156+ const first = c.title ? null : tryRun(repo.dir, ['log', '--reverse', '--format=%s', c.base ? `${c.base}..${c.key}` : c.key, '--'])?.split('\n')[0];157+ href = repo.urls.review(c.id!).href;158+ ref = `#${c.id}`;159+ text = c.title || first || '';160+ } else if (c.kind === 'release') {161+ const have = refsOf(repo);162+ if (!have.has(`refs/tags/${c.key}`) && !have.has(`refs/releases/${c.key}`)) continue;163+ href = `${repo.urls.releases.href}#${encodeURIComponent(c.key)}`;164+ ref = c.key;165+ } else {166+ if (!refsOf(repo).has(`refs/sites/${c.key}`)) continue;167+ const sites = repo.sites ?? [];168+ href = sites.find((s) => s.name === c.key)?.url ?? repo.urls.site(c.key);169+ text = sites.length > 1 ? `deployed its site ${c.key}` : 'deployed its site';170+ }171+ done.add(once);172+ items.push({ kind: c.kind, at: iso(c.t), repo: { slug: repo.slug, name: repo.name, owner: repo.owner }, href, ref, text });173+ }174+ return { items, active: Object.fromEntries(Object.entries(latest).map(([o, t]) => [o, iso(t)])) };175+}@@ -161,12 +161,13 @@ if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import
161161 const forge = loadForge();162162 const render = pageRenderer();163163 const templates = listTemplates();164- // Every page, unless lists are named: the index, owners, the forge's insights, the style guide, and forge.pages.<list>.164+ // Every page, unless lists are named: the index, owners, the forge's insights, the style guide, topics, and forge.pages.<list>.165165 const jobs: { file: string; template: string; data: Record<string, unknown> }[] = [];166- for (const list of lists.length ? lists : ['index', 'owners', 'forgeInsights', 'design', ...templates.keys()]) {166+ for (const list of lists.length ? lists : ['index', 'owners', 'forgeInsights', 'design', 'topics', ...templates.keys()]) {167167 if (list === 'index') jobs.push({ file: '/index.html', template: 'index.njk', data: { forge } });168168 else if (list === 'forgeInsights') jobs.push({ file: '/insights/index.html', template: 'insights.njk', data: { forge } });169169 else if (list === 'design') jobs.push({ file: '/design/index.html', template: 'design.njk', data: { forge } });170+ else if (list === 'topics') jobs.push({ file: '/topics/index.html', template: 'topics.njk', data: { forge } });170171 else if (list === 'owners') for (const o of forge.owners) jobs.push({ file: `/${o.owner}/index.html`, template: 'owner.njk', data: { forge, o } });171172 else {172173 const template = templates.get(list);@@ -7,11 +7,12 @@ import { revList, isAncestor, tryRun } from './git.ts';
77 import { readComments, parseApproval } from './discussion.ts';88 import type { Review, ReviewLog, Revision } from './types.ts';99 10-export function readReviewLogs(gitDir: string): ReviewLog[] {10+// comments: false leaves them out (the index's recent list, lib/recent.ts).11+export function readReviewLogs(gitDir: string, comments = true): ReviewLog[] {1112 const dir = path.join(gitDir, 'forge', 'reviews');1213 if (!fs.existsSync(dir)) return [];1314 return fs.readdirSync(dir).filter((f) => /^\d+$/.test(f)).map((f): ReviewLog => {14- const r: ReviewLog = { id: Number(f), branch: '', created: null, opts: {}, draft: false, revs: [], deleted: null, approvals: [], comments: readComments(gitDir, Number(f)) };15+ const r: ReviewLog = { id: Number(f), branch: '', created: null, opts: {}, draft: false, revs: [], deleted: null, approvals: [], comments: comments ? readComments(gitDir, Number(f)) : [] };1516 for (const line of fs.readFileSync(path.join(dir, f), 'utf8').split('\n')) {1617 const sp = line.indexOf(' ');1718 const k = sp < 0 ? line : line.slice(0, sp);@@ -138,7 +138,7 @@
138138 </table>139139 {% endmacro %}140140 141-{# The index and owner pages: repos as rows on shelves by kind. Tabs above141+{# Owner pages and /topics/: repos as rows on shelves by kind. Tabs above142142 them name the shelves, "all" first: without JS they jump to a shelf,143143 with it (ui.ts) one shows that shelf alone. Beside them, how to look at144144 the list, which needs JS and shows with it: the globe keeps only repos@@ -55,6 +55,13 @@
5555 --status-merged: #3148f6;5656 --status-closed: #c7162b;5757 --status-other: #5b6169;58+ /* letter avatars on the home page: white on a fill seeded from the59+ name (the forge's owner has the accent), the same in both modes */60+ --avatar-1: #00796b;61+ --avatar-2: #7a3fc4;62+ --avatar-3: #a8461d;63+ --avatar-4: #b02a66;64+ --avatar-5: #2f7a1f;5865 /* a linked line, floating panels, dialogs' backdrop, masks */5966 --target-bg: rgba(255, 213, 79, 0.25);6067 --target-gut: rgba(255, 213, 79, 0.4);@@ -72,7 +72,7 @@
7272 if (over > 0) tabs.scrollLeft += over + 24;7373 }7474 75- // The index and owner pages' shelves (shelves in macros.njk). Without JS75+ // The owner pages' and /topics/' shelves (shelves in macros.njk). Without JS7676 // the tabs jump to a shelf; here one shows that shelf alone ("all" shows7777 // every one), the globe hides repos without a site, and the sort is7878 // newest first or by name. The choices outlive live.ts swapping in a@@ -124,6 +124,10 @@
124124 else b.setAttribute('aria-pressed', String(b.getAttribute('aria-pressed') !== 'true'));125125 });126126 127+ // A link to one shelf (/cat/#games, from the home page's topics) opens on it.128+ const at = decodeURIComponent(location.hash.slice(1));129+ if (at && document.querySelector(`[data-shelves] a[data-shelf="${CSS.escape(at)}"]`)) pick.shelf = at;130+127131 const content = (): void => {128132 wrap(wrapped());129133 times();@@ -1,10 +1,101 @@
11 ---22 permalink: /index.html33 ---4+{#- The home page: the motd an admin sets (bin/forge motd), then who is5+ here (profiles), the shelves across them (topics) and what happened6+ lately on public repos (recent, lib/recent.ts). Every build redoes it,7+ so it is as fresh as the last push; ui.ts keeps its times relative, and8+ live.ts swaps it in when a build finishes. Each owner's page has their9+ repos on shelves. -#}410 {% extends "base.njk" %}511 {% import "macros.njk" as m with context %}612 {% import "ui.njk" as ui %}713 {% block content %}8-{{ m.shelves(forge.repos, forge.owners.length > 1) }}9-<p class="desc"><a href="/insights/">Insights</a>: builds, pushes, reviews and CI across these repos, over time.</p>14+{% css %}15+main.wrap > p.motd { margin: calc(-1 * var(--space-5)) 0 0; padding: var(--space-3) 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--text-sm); color: var(--muted); }16+/* Two columns: profiles and topics, then what happened; on phones one,17+ recent after profiles. */18+.home { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr); grid-template-areas: "profiles recent" "topics recent"; grid-template-rows: auto 1fr; gap: var(--space-4); align-items: start; }19+.home > .panel { margin: 0; }20+.home .profiles { grid-area: profiles; }21+.home .topics { grid-area: topics; }22+.home .recent { grid-area: recent; }23+@media (max-width: 720px) {24+ .home { grid-template-columns: minmax(0, 1fr); grid-template-areas: "profiles" "recent" "topics"; grid-template-rows: none; }25+}26+.home .panel-head .end a { font-weight: var(--fw-medium); }27+/* A profile: a letter on a colour, the name, how many repos and when. */28+.profile { position: relative; display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) var(--space-3); }29+.profile:hover, .profile:focus-within { background: var(--hover); }30+.avatar { display: grid; place-items: center; flex: none; width: 36px; height: 36px; font: var(--fw-strong) var(--text-lg)/1 var(--mono); color: var(--on-accent); background: var(--avatar-1); }31+.avatar.accent { background: var(--accent); }32+.avatar.avatar-2 { background: var(--avatar-2); }33+.avatar.avatar-3 { background: var(--avatar-3); }34+.avatar.avatar-4 { background: var(--avatar-4); }35+.avatar.avatar-5 { background: var(--avatar-5); }36+.profile-text { display: flex; flex-direction: column; min-width: 0; }37+.profile-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--text-prose); font-weight: var(--fw-strong); color: var(--fg); }38+.profile-name:hover { color: var(--fg); }39+.profile-name::after { content: ''; position: absolute; inset: 0; }40+.profile-meta { font-size: var(--text-sm); color: var(--muted); }41+.profile-meta time { font: inherit; }42+/* Topics: tiles with hairlines between them; the panel's edge is the rule43+ round them, so the last column's and row's own rules are cut off. */44+.topics { overflow: hidden; }45+.topic-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0 calc(-1 * var(--hairline)) calc(-1 * var(--hairline)) 0; padding: 0; list-style: none; }46+.topic-grid > li { position: relative; border-right: var(--hairline) solid var(--border); border-bottom: var(--hairline) solid var(--border); }47+.topic-grid > li:hover, .topic-grid > li:focus-within { background: var(--hover); }48+.topic-grid a { display: flex; flex-direction: column; padding: var(--space-2) var(--space-3); color: var(--fg); font-weight: var(--fw-medium); overflow-wrap: anywhere; }49+.topic-grid a:hover { color: var(--fg); }50+.topic-grid .n { font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--faint); }51+@media (max-width: 720px) {52+ .topic-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }53+}54+/* What happened: its kind, the repo and what, when. */55+.ev { position: relative; display: grid; grid-template-columns: 3.6em minmax(0, 1fr) auto; column-gap: var(--space-3); align-items: baseline; padding: var(--space-2) var(--space-3); }56+.ev:hover, .ev:focus-within { background: var(--hover); }57+.ev-kind { font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--muted); }58+.ev-what { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fg); }59+.ev-what:hover { color: var(--fg); }60+.ev-what::after { content: ''; position: absolute; inset: 0; }61+.ev-what b { font-weight: var(--fw-strong); }62+.ev-what .tag { font-family: var(--mono); font-size: var(--text-inline-mono); color: var(--link); }63+.ev time { font-size: var(--text-xs); color: var(--faint); white-space: nowrap; }64+{% endcss %}65+{% if forge.motd %}<p class="motd">{{ forge.motd }}</p>66+{% endif %}67+<div class="home">68+{% set head %}<h2>profiles</h2><span class="n">{{ forge.profiles.length }}</span>{% endset %}69+{% call ui.panel(head, 'profiles') %}70+{% if forge.profiles.length %}71+<ul class="rows">72+{% for pr in forge.profiles %}73+<li class="profile"><span class="avatar {{ pr.colour }}" aria-hidden="true">{{ pr.owner | first }}</span><span class="profile-text"><a class="profile-name" href="/{{ pr.owner }}/">{{ pr.owner }}</a><span class="profile-meta">{{ pr.repos | plural("repo") }}{% if pr.active %}, active {{ pr.active | date }}{% endif %}</span></span></li>74+{% endfor %}75+</ul>76+{% else %}<p class="empty">No public repos here yet.</p>77+{% endif %}78+{% endcall %}79+{% set head %}<h2>recent</h2><span class="end"><a href="/insights/">insights</a></span>{% endset %}80+{% call ui.panel(head, 'recent') %}81+{% if forge.recent.length %}82+<ul class="rows">83+{% for e in forge.recent %}84+<li class="ev"><span class="ev-kind">{{ e.kind }}</span><a class="ev-what" href="{{ e.href }}"><b>{{ e.repo.name }}</b> {% if e.kind == 'release' %}<span class="tag">{{ e.ref }}</span>{% else %}{{ (e.ref + ' ') if e.ref }}{{ e.text }}{% endif %}</a>{{ e.at | date }}</li>85+{% endfor %}86+</ul>87+{% else %}<p class="empty">Nothing has happened here yet.</p>88+{% endif %}89+{% endcall %}90+{% if forge.topics.length %}91+{% set head %}<h2>topics</h2>{% endset %}92+{% call ui.panel(head, 'topics') %}93+<ul class="topic-grid">94+{% for t in forge.topics %}95+<li><a href="{{ t.href }}">{{ t.title }}<span class="n">{{ t.repos | plural('repo') }}</span></a></li>96+{% endfor %}97+</ul>98+{% endcall %}99+{% endif %}100+</div>10101 {% endblock %}@@ -0,0 +1,16 @@
1+---2+permalink: /topics/index.html3+---4+{#- Every public repo on its shelf, across owners: where the home page's5+ topics link when more than one owner has repos on a shelf (/topics/#<id>,6+ which ui.ts opens on that shelf). One owner's go to their own page. -#}7+{% extends "base.njk" %}8+{% import "macros.njk" as m with context %}9+{% import "ui.njk" as ui %}10+{% block title %}Topics - {{ forge.config.name }}{% endblock %}11+{% block head %}{{ m.meta('Topics - ' + forge.config.name, 'Every repo on ' + forge.config.name + ', by topic.', '/topics/') }}{% endblock %}12+{% block heading %}<a href="/">{{ forge.config.name }}</a> / <b>topics</b>13+{% endblock %}14+{% block content %}15+{{ m.shelves(forge.repos, true) }}16+{% endblock %}@@ -0,0 +1,35 @@
1+# The home page has a profile for each owner with public repos: their2+# letter, their name linking to their page, how many public repos they3+# have and when they were last active. No shelves of repos: those are on4+# each owner's page. The header says the forge's name.5+. "$(dirname "$0")/../lib.sh"6+user dami7+user ana8+user bob9+for r in dami/chess dami/go ana/notes; do10+ repo "$r"11+ clone "${r%%/*}" "$r" "$T/w/${r#*/}"12+ tick 360013+ at "$T/w/${r#*/}" commit "first"14+ push_as "${r%%/*}" "$T/w/${r#*/}" origin main15+done16+repo --private dami/secret17+repo --private bob/hidden18+export FORGE_STRICT=119+build20+i=$FORGE_WWW/index.html21+has "$i" '<div class="title">22+<b>forge</b></div>'23+has "$i" '<section class="panel profiles"><header class="panel-head"><h2>profiles</h2><span class="n">2</span></header>'24+has "$i" '<a class="profile-name" href="/ana/">ana</a><span class="profile-meta">1 repo, active <time datetime="2026-09-01T03:01:00Z">'25+has "$i" '<a class="profile-name" href="/dami/">dami</a><span class="profile-meta">2 repos, active <time datetime="2026-09-01T02:01:00Z">'26+has "$i" '<span class="avatar avatar-[1-5]" aria-hidden="true">d</span>'27+# bob has only a private repo: no profile.28+lacks "$i" 'bob'29+lacks "$i" 'data-shelves|class="card'30+lacks "$i" 'Insights</a>:'31+# The forge's owner has the accent.32+FORGE_OWNER=dami build33+has "$i" '<span class="avatar accent" aria-hidden="true">d</span>'34+# The owner page keeps its shelves.35+has "$FORGE_WWW/dami/index.html" 'data-shelves'@@ -0,0 +1,58 @@
1+# The home page's recent list: landed reviews, pushes to a default branch,2+# releases and site deploys of public repos, newest first, each linking to3+# what it names, with its time for ui.ts to say relative. A landing's push4+# shows once, as landed. Nothing of a private repo is in it.5+. "$(dirname "$0")/../lib.sh"6+user dami7+user ana8+repo dami/greet9+repo --private dami/secret10+clone dami dami/greet "$T/w/d"11+at "$T/w/d" commit "first"12+push_as dami "$T/w/d" origin main13+first=$(git -C "$T/w/d" rev-parse HEAD)14+tick15+clone ana dami/greet "$T/w/a"16+at "$T/w/a" commit "ana's change"17+push_as ana "$T/w/a" -o 'title=Say it louder' origin HEAD:ana/change18+tick19+ok ssh_as dami land dami/greet 120+tick21+at "$T/w/d" git pull -q origin main22+at "$T/w/d" git tag v123+push_as dami "$T/w/d" origin v124+tick25+push_as dami "$T/w/d" origin "$(site_commit "$T/w/d" docs):refs/sites/docs"26+tick27+at "$T/w/d" commit "polish <b>the</b> greeting"28+push_as dami "$T/w/d" origin main29+last=$(git -C "$T/w/d" rev-parse HEAD)30+# Newest of all, but private.31+tick32+clone dami dami/secret "$T/w/s"33+at "$T/w/s" commit "hidden thing"34+at "$T/w/s" git tag v935+push_as dami "$T/w/s" origin main v9 "$(site_commit "$T/w/s" docs):refs/sites/docs"36+has "$FORGE_ROOT/.forge/events.jsonl" "\"head\":\"$last\",\"refs\":\" refs/heads/main\"}$"37+38+export FORGE_STRICT=139+build40+i=$FORGE_WWW/index.html41+has "$i" '<section class="panel recent"><header class="panel-head"><h2>recent</h2><span class="end"><a href="/insights/">insights</a></span></header>'42+eq "$(grep -o 'class="ev-kind">[a-z]*' "$i" | sed 's/.*>//' | tr '\n' ' ')" "pushed site release landed pushed "43+has "$i" "<span class=\"ev-kind\">pushed</span><a class=\"ev-what\" href=\"/dami/greet/commit/$last/\"><b>greet</b> polish <b>the</b> greeting</a><time datetime=\"2026-09-01T00:05:00Z\">"44+has "$i" '<span class="ev-kind">site</span><a class="ev-what" href="/sites/dami/greet/docs/"><b>greet</b> deployed its site</a><time datetime="2026-09-01T00:04:00Z">'45+has "$i" '<span class="ev-kind">release</span><a class="ev-what" href="/dami/greet/releases/#v1"><b>greet</b> <span class="tag">v1</span></a><time datetime="2026-09-01T00:03:00Z">'46+has "$i" '<span class="ev-kind">landed</span><a class="ev-what" href="/dami/greet/reviews/1/"><b>greet</b> #1 Say it louder</a><time datetime="2026-09-01T00:02:00Z">'47+has "$i" "<span class=\"ev-kind\">pushed</span><a class=\"ev-what\" href=\"/dami/greet/commit/$first/\"><b>greet</b> first</a>"48+lacks "$i" 'secret|hidden thing|v9'49+# The links go somewhere.50+exists "$FORGE_WWW/dami/greet/commit/$last/index.html"51+exists "$FORGE_WWW/dami/greet/reviews/1/index.html"52+has "$FORGE_WWW/dami/greet/releases/index.html" 'id="v1"'53+exists "$FORGE_WWW/sites/dami/greet/docs/index.html"54+55+# Made private, the repo leaves the list.56+ok ssh_as dami private dami/greet57+build58+has "$i" '<p class="empty">Nothing has happened here yet.</p>'@@ -0,0 +1,37 @@
1+# The home page's topics are the shelves across the public repos, each2+# with its count. One owner's shelf links to it on their page, where ui.ts3+# opens the tab the fragment names; a shelf several owners share links to4+# it on /topics/, every public repo by shelf, each naming its owner.5+. "$(dirname "$0")/../lib.sh"6+user dami7+user ana8+for r in dami/chess dami/go dami/hammer ana/cards ana/notes; do9+ repo "$r"10+ clone "${r%%/*}" "$r" "$T/w/${r#*/}"11+ at "$T/w/${r#*/}" commit "first"12+ push_as "${r%%/*}" "$T/w/${r#*/}" origin main13+done14+repo --private ana/secret15+for r in dami/chess dami/go ana/cards ana/secret; do printf 'games\n' >"$FORGE_ROOT/$r.git/category"; done16+printf 'tools\n' >"$FORGE_ROOT/dami/hammer.git/category"17+export FORGE_STRICT=118+build19+i=$FORGE_WWW/index.html20+has "$i" '<li><a href="/topics/#games">games<span class="n">3 repos</span></a></li>'21+has "$i" '<li><a href="/dami/#tools">tools<span class="n">1 repo</span></a></li>'22+has "$i" '<li><a href="/ana/#other">other<span class="n">1 repo</span></a></li>'23+# Where they go.24+has "$FORGE_WWW/dami/index.html" '<a href="#tools" data-shelf="tools">tools<span class="n">1</span></a>'25+has "$FORGE_WWW/dami/index.html" 'data-shelf="tools" id="tools"'26+t=$FORGE_WWW/topics/index.html27+has "$t" '<section class="panel shelf" data-shelf="games" id="games"><header class="panel-head"><h2>games</h2><span class="n">3</span></header>'28+has "$t" '<a href="#games" data-shelf="games">games<span class="n">3</span></a>'29+has "$t" '<span class="desc">ana / </span>cards</a>'30+lacks "$t" 'secret'31+# ui.ts opens the shelf a link names.32+has site/assets/ui.ts 'location.hash'33+# A single repo's build redoes /topics/ too.34+printf 'tools\n' >"$FORGE_ROOT/ana/notes.git/category"35+build ana/notes36+has "$t" 'data-shelf="tools" id="tools"><header class="panel-head"><h2>tools</h2><span class="n">2</span>'37+has "$i" '<li><a href="/topics/#tools">tools<span class="n">2 repos</span></a></li>'@@ -18,7 +18,7 @@ has "$w/dami/greet/insights/index.html" 'data-insights="/dami/greet/insights/ins
1818 has "$w/dami/greet/insights/index.html" '<script src="/assets/insights\.[A-Za-z0-9_-]{10}\.js" defer>'1919 has "$w/dami/greet/index.html" '<a href="/dami/greet/insights/" data-key="i">insights</a>'2020 has "$w/insights/index.html" 'data-insights="/insights/insights.json" data-forge="1"'21-has "$w/index.html" '<a href="/insights/">Insights</a>'21+has "$w/index.html" '<span class="end"><a href="/insights/">insights</a></span>'2222 # The charts' script is only on insights pages.2323 lacks "$w/dami/greet/index.html" 'assets/insights\.'2424 # No lake yet: no data, and the page says so when it can't fetch it.@@ -1,4 +1,4 @@
1-# The index's shelves are panels: each one's head strip names the shelf1+# The shelves (owner pages, /topics/) are panels: each one's head strip names the shelf22 # and counts its repos, and the repos are rows inside it.33 . "$(dirname "$0")/../lib.sh"44 user dami@@ -12,7 +12,7 @@ printf 'games\n' >"$FORGE_ROOT/dami/chess.git/category"
1212 printf 'games\n' >"$FORGE_ROOT/dami/go.git/category"1313 export FORGE_STRICT=11414 build15-i=$FORGE_WWW/index.html15+i=$FORGE_WWW/topics/index.html1616 has "$i" '<section class="panel shelf" data-shelf="games" id="games"><header class="panel-head"><h2>games</h2><span class="n">2</span></header>'1717 has "$i" '<section class="panel shelf" data-shelf="other" id="other"><header class="panel-head"><h2>other</h2><span class="n">1</span></header>'1818 has "$i" '<ul class="rows cards nav-cards">'@@ -1,4 +1,4 @@
1-# The index's "has a site" filter is a control, not words: a globe icon1+# The shelves' "has a site" filter is a control, not words: a globe icon22 # toggle (the globe repo rows show beside a site) with aria-pressed, a33 # label and a title; the sort is a segmented control of two icon toggles;44 # shelves are tabs, "all" first and current. ui.ts makes them work.@@ -13,7 +13,7 @@ done
1313 printf 'games\n' >"$FORGE_ROOT/dami/chess.git/category"1414 export FORGE_STRICT=11515 build16-i=$FORGE_WWW/index.html16+i=$FORGE_WWW/dami/index.html1717 has "$i" '<button type="button" class="icon-btn" aria-pressed="false" aria-label="only repos with a site" title="only repos with a site" data-has-site><svg class="icon"'1818 has "$i" '<span class="segmented" role="group" aria-label="sort">'1919 has "$i" 'aria-label="newest first" title="newest first" aria-pressed="true" data-sort="date"'@@ -1,4 +1,4 @@
1-# The index and owner pages show repos as cards on shelves: the category1+# Owner pages and /topics/ show repos as cards on shelves: the category22 # its owner wrote in the bare repo's category file, else "other" (Jev's33 # topic is off in scenarios); fuller shelves first, "other" last, newest44 # first on each. Private repos have no card, and backups keep the file.@@ -17,7 +17,7 @@ printf 'Games\n' >"$FORGE_ROOT/dami/chess.git/category"
1717 printf 'games\n' >"$FORGE_ROOT/dami/go.git/category"1818 export FORGE_STRICT=11919 build20-i=$FORGE_WWW/index.html20+i=$FORGE_WWW/topics/index.html2121 has "$i" '<section class="panel shelf" data-shelf="games" id="games">'2222 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.