| 1/1 | Front matter for people and repos, bauhaus avatars, and a grid of days | +2011 | -128 | ✓ |
revisions3r3: behavior change in 1 of 1 commit
| r3 | cat | a283b86 | what changed since r2 behavior change in 1 of 1 commit | |
| r2 | cat | 0ef36d0 | what changed since r1 behavior change in 1 of 1 commit | |
| r1 | cat | b60992a |
No comments yet: forge comment 28 adds one.
1/1 Front matter for people and repos, bauhaus avatars, and a grid of days
An owner's page opens with who they are: an avatar drawn from their name (lib/avatar.ts, cat's bauhaus pattern generator cut down to a 3 by 3 grid in five new avatar tokens), their name, the bio and link they set with ssh profile, and their repos, sites and the month they joined. Then the repos they pinned, or their three most recently active, the shelf tabs, their activity grid and the list. The avatars replace the home page's letters. ssh profile prints what a page says; profile bio, link and pin set it, --clear clears one. Anyone sets their own; lib/profile.ts checks it (one line of 120 characters, one https:// link, up to three of their own public repos) and keeps it in .forge/profiles, which backups carry. A change rebuilds that page and the home page only (forge build @owner). A repo's code page opens with its name, what it is about, its topics (its shelf and its .forge/topics) and a column of facts: its site, latest release, main's CI, open reviews, the license (an SPDX id from the text, else a link to the file) and its languages by bytes in the default branch, without vendored, generated or lock files. The activity grid (ui.activity, lib/activity.ts, ui.ts) is a year of days, a button each, on an owner's page (commits on their public repos' default branches, following the picked tab) and a repo's insights (its pushes, landed reviews, releases, site deploys and CI results). Picking a day narrows the list under it; the address keeps it as #<day> or #<shelf>/<day>. Cells are 13px at most, left in the panel; narrower, they shrink to 10px and then the oldest weeks go. forge lake now keeps the default branches' commits and exports each owner's commit times and each repo's events as activity.json, which the build puts in the page and beside it, and which the page fetches for fresher counts. With JS the days are the reader's, without they are UTC. A private repo counts on no public page. A shelf's list under its tabs no longer has a head strip saying the tab again; the tabs' counts follow the globe. DESIGN.md says so, and how people and the grid are shown.
@@ -29,7 +29,7 @@ grep -q '<figure class="diagram"><svg xmlns="http://www.w3.org/2000/svg" class="
2929 grep -q '"reviews":\[\[7,"Regional notes"' $www/dami/greet/search.json3030 grep -q '"dami/greet","say hello, with flags"' $www/repos.json3131 grep -q 'data-search="/dami/greet/search.json"' $www/dami/greet/index.html32-# Insights: the demo runs forge lake last, which puts its exports next to32+# Insights: the demo runs forge lake before its build, which puts the exports next to3333 # the insights pages; the forge's leave out nothing public.3434 grep -q '"scope":"dami/greet".*"landed":1,"closed":1,' $www/dami/greet/insights/insights.json3535 grep -q '"ci":17,"ci_pass":16,' $www/dami/greet/insights/insights.json@@ -40,6 +40,11 @@ picker, breadcrumbs, filters) sit on the page. Use `ui.panel` in
4040 in the panel it would fill.4141 - A section heading on the page (`ui.heading`) names several panels; one4242 panel's name goes in its own head strip.43+- A head strip says something the tabs or page heading don't; never44+ repeat them. A shelf's list under its tab has none (the tab names it and45+ counts it); it gets one only while a day is picked in the activity grid46+ above it, since then it says what the tab doesn't: "oct 8, 2026",47+ "3 repos, 11 commits", "show all".4348 4449 ## Tokens4550 @@ -60,9 +65,10 @@ it starts a group: the style guide titles groups by those comments).
6065 One accent, `#3148F6`: `--link` for links (lighter in dark mode),6166 `--accent` for the current tab's bar, focus, primary fills, `--deep` on6267 hover. Meaning: `--add-*`, `--del-*`, `--pass`, `--fail`, `--warn`,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.68+ `--status-*` fills. Avatars are drawn in `--avatar-ground`, `-ink`,69+ `-red`, `-blue` and `-yellow`; the activity grid's days in `--heat-0`70+ (nothing) to `--heat-4`; a repo's languages in `--lang-1` to `-3` and71+ `--lang-rest`.6672 - Type: `--text-xs` 12, `--text-code` 12.5, `--text-sm` 13.5, `--text-md`6773 15 (body), `--text-prose` 16, `--text-lg` 18, `--text-xl` 22,6874 `--text-2xl` 28. Weights `--fw-regular` 400 (body), `--fw-medium` 500@@ -130,6 +136,33 @@ dates like Oct. 8, 2026. Interface chrome follows the lowercase rules
130136 above. Code, CLI output and the interface's compact times keep their own131137 forms.132138 139+## People and activity140+141+- A person is shown with their avatar (`| avatar(size)`, `lib/avatar.ts`):142+ a 3 by 3 square of bauhaus tiles drawn from their name at build time,143+ after cat's bauhaus pattern generator. The same name gives the same art144+ everywhere and forever; there are no uploads. 72px on their page, 36px145+ in lists. Decoration beside the name, so `aria-hidden`.146+- The activity grid (`ui.activity`, data from `lib/activity.ts`, behavior147+ in `site/assets/ui.ts`) is the one way the forge shows a year of days.148+ A column a week, Sunday at the top, no month row, no weekday column, no149+ range picker; square cells of 13px at most with `--space-half` gaps,150+ left in the panel. Narrower, the cells shrink to 10px and then the oldest151+ weeks go, so the latest always show (27 weeks at 390px). Days are the152+ reader's with JS (the page has each commit's or event's time), UTC153+ without. Each day is a button named154+ "2026-10-08: 11 commits"; clicking it (or Enter, Space) picks it with an155+ ink outline and narrows the list directly under it, clicking it again or156+ "show all" lets it go; arrow keys move by a day or a week. Without JS157+ it is a static picture. It lives on an owner's page (commits on the158+ default branches of their public repos, following the picked tab) and a159+ repo's insights (its pushes, landed reviews, releases, site deploys and160+ CI results); not on the commits tab.161+- The address keeps a picked day so a narrowed view can be shared:162+ `#<day>` (`#2026-10-08`), or `#<shelf>/<day>` on an owner's page with a163+ shelf picked, beside the shelves' own `#<shelf>`. The day is always last164+ and always `YYYY-MM-DD`.165+133166 ## Controls134167 135168 Words for where you are, symbols for how you look at it.@@ -31,7 +31,7 @@
3131 # forge repo import [--private] <owner>/<name> <url> [description] (server-side fetch)3232 # forge repo rm <owner>/<name> into the trash, with everything that served it3333 # forge repo restore <owner>/<name> back from the trash (the newest removal)34-# forge build [owner/repo]34+# forge build [owner/repo|-|@owner] everything, one repo, the index and owner pages, or the index and one owner's3535 # forge jev [owner/repo] ask Jev about new commits and reviews (needs TYPESAFE_API_KEY)3636 # forge metrics [n] [owner/repo] recent builds and where their time went3737 # forge lake [--dry-run] add new events, builds, reviews and CI results to the lake; export insights@@ -1038,7 +1038,7 @@ cmd_server_init() {
10381038 # FORGE_SITES_URL=/sites/{owner}/{repo}/{site}/10391039 # FORGE_PREVIEWS_URL=/previews/{owner}/{repo}/{site}/{branch}/10401040 EOF1041- for c in new ls whoami signers site-url reviews comment approve approvals land private public readers collaborators who release rm passkey passkeys sessions token tokens help; do1041+ for c in new ls whoami signers site-url reviews comment approve approvals land private public readers collaborators who release rm passkey passkeys sessions token tokens profile help; do10421042 printf '#!/bin/sh\nexec "%s" shell-cmd %s "$@"\n' "$FORGE_BIN" "$c" >"$FORGE_ROOT/git-shell-commands/$c"10431043 chmod +x "$FORGE_ROOT/git-shell-commands/$c"10441044 done@@ -1273,6 +1273,8 @@ user_add() {
12731273 [ -z "$o" ] || die "that key is already on this forge, as $o's"12741274 printf '%s %s\n' "$1" "$2" >>"$FORGE_STATE/keys"12751275 mkdir -p "$FORGE_ROOT/$1"1276+ # When they joined, for their page (lib/profile.ts), unless known.1277+ node "$FORGE_HOME/lib/profile.ts" --joined "$1" || true12761278 cmd_keys_sync12771279 }12781280 @@ -1545,6 +1547,24 @@ cmd_motd() {
15451547 esac15461548 }15471549 1550+# ssh profile [bio <text>|link <url>|pin <repo>...] (--clear clears one):1551+# what the caller's page (/<user>/) says about them, above their repos.1552+# Without arguments it prints it. A bio is one line of plain text, at most1553+# 120 characters; a link one https:// address; pins up to three of the1554+# caller's own public repos, in order (none: the three most recently1555+# active). lib/profile.ts checks them and keeps them in1556+# .forge/profiles/<user>, which backups carry. A change rebuilds that page1557+# and the home page only (build @<user>).1558+cmd_profile() {1559+ st=01560+ node "$FORGE_HOME/lib/profile.ts" "$user" "$@" || st=$?1561+ case $st in1562+ 0) ;;1563+ 3) rebuild "@$user" profile ;;1564+ *) exit 1 ;;1565+ esac1566+}1567+15481568 # is_admin <user>: is user one of the forge's admins? They are the values15491569 # of forge.admins in the server's git config, $FORGE_ROOT/.gitconfig (the15501570 # git user's own; several values, or names split by spaces or commas).@@ -1751,15 +1771,15 @@ approvers() {
17511771 # rebuild <owner>/<repo> <reason>: refresh a repo's pages after a change, in17521772 # the background unless FORGE_SYNC_BUILD (or not at all with FORGE_NO_BUILD).17531773 # Jev's answers are asked for first, so the build can show them. "-" is the1754-# index and owner pages only.1774+# index and owner pages only, "@<owner>" the index and that owner's page.17551775 rebuild() {17561776 if [ "${FORGE_NO_BUILD:-0}" = 1 ]; then17571777 :17581778 elif [ "${FORGE_SYNC_BUILD:-0}" = 1 ]; then1759- [ "$1" = - ] || "$FORGE_BIN" jev "$1" >&21779+ case $1 in - | @*) ;; *) "$FORGE_BIN" jev "$1" >&2 ;; esac17601780 FORGE_BUILD_REASON=$2 "$FORGE_BIN" build "$1" >&217611781 else1762- (cd / && FORGE_BUILD_REASON=$2 nohup sh -c '[ "$1" = - ] || "$0" jev "$1"; exec "$0" build "$1"' "$FORGE_BIN" "$1" </dev/null >>"$FORGE_STATE/build.log" 2>&1 &)1782+ (cd / && FORGE_BUILD_REASON=$2 nohup sh -c 'case $1 in - | @*) ;; *) "$0" jev "$1" ;; esac; exec "$0" build "$1"' "$FORGE_BIN" "$1" </dev/null >>"$FORGE_STATE/build.log" 2>&1 &)17631783 fi17641784 }17651785 @@ -2221,6 +2241,7 @@ cmd_shell_cmd() {
22212241 release) sc_release "$@" ;;22222242 rm) sc_rm "$@" ;;22232243 passkey | passkeys | sessions | tokens) credentials "$c" "$@" ;;2244+ profile) cmd_profile "$@" ;;22242245 invite | invites | users | motd)22252246 # Reached only through the admins' git-shell-commands (cmd_shell).22262247 is_admin "$user" || die "unknown command $c"@@ -2242,7 +2263,7 @@ cmd_shell_cmd() {
22422263 [ -z "$r" ] || open_repo "$r"22432264 credentials token "$@"22442265 ;;2245- *) printf 'commands:\n new [--private] <name> [description] create %s/<name>\n ls list your repos, and private ones you may read\n signers keys that may sign CI results (for forge verify)\n site-url <owner>/<repo> <site> [branch] where a site deploy is served\n reviews <owner>/<repo> open and draft reviews (forge reviews)\n land <owner>/<repo> <id|branch> land a review here, with the same gates as a push\n comment, approve, approvals used by forge comment, approve and land\n private <owner>/<repo> only its owner, collaborators and readers can see it\n public <owner>/<repo> [--force] anyone can, once its whole history passes the secret check\n readers <owner>/<repo> [add|rm <user>] who may read it while it is private\n collaborators <owner>/<repo> [add|rm <user>] who may push anything to it; only its owner changes the list\n who <owner>/<repo> its owner, collaborators and readers\n release ls <owner>/<repo> its releases: tag, file, size, sha256\n release get <owner>/<repo> <tag> <file> a release file, on stdout\n rm <owner>/<repo> --yes remove a repo of yours (the admin can restore it for 14 days)\n passkey [name] a one-time link that adds a passkey, to sign in on the web\n passkeys [rm <id>] your passkeys\n sessions [rm <id>|--all] where you are signed in on the web; rm signs one out, --all all\n token [--repo <owner>/<repo>] [--ttl 30d] [name] a token for curl and HTTPS clones, shown once\n tokens [rm <id>] your tokens\na push to %s/<name>.git creates that repo when it is missing\n' "$user" "$user"2266+ *) printf 'commands:\n new [--private] <name> [description] create %s/<name>\n ls list your repos, and private ones you may read\n signers keys that may sign CI results (for forge verify)\n site-url <owner>/<repo> <site> [branch] where a site deploy is served\n reviews <owner>/<repo> open and draft reviews (forge reviews)\n land <owner>/<repo> <id|branch> land a review here, with the same gates as a push\n comment, approve, approvals used by forge comment, approve and land\n private <owner>/<repo> only its owner, collaborators and readers can see it\n public <owner>/<repo> [--force] anyone can, once its whole history passes the secret check\n readers <owner>/<repo> [add|rm <user>] who may read it while it is private\n collaborators <owner>/<repo> [add|rm <user>] who may push anything to it; only its owner changes the list\n who <owner>/<repo> its owner, collaborators and readers\n release ls <owner>/<repo> its releases: tag, file, size, sha256\n release get <owner>/<repo> <tag> <file> a release file, on stdout\n rm <owner>/<repo> --yes remove a repo of yours (the admin can restore it for 14 days)\n passkey [name] a one-time link that adds a passkey, to sign in on the web\n passkeys [rm <id>] your passkeys\n sessions [rm <id>|--all] where you are signed in on the web; rm signs one out, --all all\n token [--repo <owner>/<repo>] [--ttl 30d] [name] a token for curl and HTTPS clones, shown once\n tokens [rm <id>] your tokens\n profile [bio <text>|link <url>|pin <repo>...] what your page says above your repos: print it or set one (--clear clears one)\na push to %s/<name>.git creates that repo when it is missing\n' "$user" "$user"22462267 if is_admin "$user"; then22472268 printf 'for admins of the forge:\n invite <name> [--ttl 7d] [--repo <owner>/<repo> --as reader|collaborator] a one-time link that makes someone a user\n invites [rm <name>] invites not taken up yet; rm makes a link dead\n users everyone here: keys, passkeys, tokens; then the invites\n motd [<text>|--clear|--default] the line under the header on the home page: print it, set it (72 characters at most), hide it, or go back to the default\n'22482269 fi@@ -2819,6 +2840,7 @@ cmd_build() {
28192840 load_config28202841 [ $# -gt 0 ] || { build_all; return; }28212842 only=$12843+ case $only in @*) valid_name "${only#@}" || die "usage: forge build [owner/repo|-|@owner]" ;; esac28222844 # A private repo's pages go to the private root. One that still has28232845 # public pages (made private by hand, say) is built here too: the public28242846 # build leaves it out, so those go and the index and owner pages are@@ -2857,8 +2879,12 @@ cmd_build() {
28572879 b=$(basename "$e")28582880 case $b in sites | previews | hosts) continue ;; esac28592881 if [ -n "$only" ] && [ -d "$e" ] && [ "$b" != assets ]; then2882+ # @<owner>: that owner's page, and the index (a file, below).2883+ case $only in @*) [ "$b" = "${only#@}" ] || continue ;; esac28602884 mkdir -p "$FORGE_WWW/$b"28612885 [ -f "$e/index.html" ] && cp "$e/index.html" "$FORGE_WWW/$b/index.html"2886+ # An owner's activity (lib/forge.ts), beside their page.2887+ if [ -f "$e/activity.json" ]; then cp "$e/activity.json" "$FORGE_WWW/$b/.activity.json.$$" && mv "$FORGE_WWW/$b/.activity.json.$$" "$FORGE_WWW/$b/activity.json"; fi28622888 [ "$b" = "${only%%/*}" ] || continue28632889 if [ -d "$out/$only" ]; then swap "$out/$only" "$FORGE_WWW/$only"; else rm -rf "${FORGE_WWW:?}/$only"; fi28642890 else@@ -529,10 +529,12 @@ commit "the guide"
529529 git push -q origin main530530 "$forge" site deploy guide531531 532-step "build"533-unset FORGE_USER534-"$forge" build535-532+# The lake first, so the pages have its activity grids and their joined533+# months; the build copies its insights exports next to the insights pages.536534 step "lake: the facts behind the insights pages"535+unset FORGE_USER537536 "$forge" lake537+538+step "build"539+"$forge" build538540 printf '\nserve it: python3 -m http.server 8080 -d %s\n' "$FORGE_WWW"@@ -0,0 +1,78 @@
1+// The activity grid (ui.activity in site/_includes/ui.njk): a year of days,2+// a column a week (Sunday at the top), each day a cell coloured by how much3+// happened on it. The counts come from the lake's exports (lib/insights.ts):4+// commits for an owner's page, events for a repo's insights. Days are UTC.5+// site/assets/ui.ts recounts and filters it in the browser; this is the6+// grid as the page has it without JS.7+import fs from 'node:fs';8+import path from 'node:path';9+10+export const WEEKS = 53;11+12+export interface Cell {13+ day: string;14+ n: number;15+ level: number;16+ // Weeks before the last column (0 for this week's): where the grid is too17+ // narrow for every week at its smallest, the oldest go (style.css).18+ age: number;19+}20+21+export interface Grid {22+ // Columns of up to seven days; the last ends today.23+ weeks: Cell[][];24+ total: number;25+ noun: string;26+ today: string;27+}28+29+// 0 for nothing, then 1 to 4 by the share of the busiest day (as ui.ts does).30+export const level = (n: number, max: number): number => (n <= 0 || max <= 0 ? 0 : Math.min(4, Math.ceil((4 * n) / max)));31+32+const today = (): Date => new Date(Number(process.env.FORGE_NOW) * 1000 || Date.now());33+const iso = (t: Date): string => t.toISOString().slice(0, 10);34+35+export function grid(counts: Record<string, number>, noun: string): Grid {36+ const end = today();37+ const last = Date.UTC(end.getUTCFullYear(), end.getUTCMonth(), end.getUTCDate());38+ const start = last - (new Date(last).getUTCDay() + (WEEKS - 1) * 7) * 86400000;39+ const days: { day: string; n: number }[] = [];40+ for (let t = start; t <= last; t += 86400000) {41+ const day = iso(new Date(t));42+ days.push({ day, n: counts[day] ?? 0 });43+ }44+ const max = Math.max(0, ...days.map((d) => d.n));45+ const weeks: Cell[][] = [];46+ days.forEach((d, i) => {47+ const w = Math.floor(i / 7);48+ (weeks[w] ??= []).push({ ...d, level: level(d.n, max), age: WEEKS - 1 - w });49+ });50+ return { weeks, total: days.reduce((a, d) => a + d.n, 0), noun, today: iso(end) };51+}52+53+// An export of the lake's, or null (no lake yet, or not exported).54+export function readExport<T>(state: string, ...p: string[]): T | null {55+ try {56+ return JSON.parse(fs.readFileSync(path.join(state, 'lake', 'export', ...p), 'utf8')) as T;57+ } catch {58+ return null;59+ }60+}61+62+export interface PersonExport {63+ // Repo name: its commits' times in minutes, delta-encoded (encodeMinutes64+ // in lib/insights.ts).65+ repos: Record<string, number[]>;66+ joined: number | null;67+}68+69+export const decodeMinutes = (enc: number[]): number[] => {70+ let m = 0;71+ return enc.map((d, i) => (m = i ? m + d : d));72+};73+74+export interface RepoActivityExport {75+ days: Record<string, number>;76+ // [time (ms), kind, text, link from the repo's page], newest first.77+ events: [number, string, string, string][];78+}@@ -0,0 +1,230 @@
1+// A person's avatar: a small square of bauhaus tiles drawn from their name,2+// at build time, as SVG. No uploads and no network; the same name gives the3+// same art on every page and every build.4+//5+// The composition is cat's bauhaus pattern generator6+// (https://src.rest/cat/bauhaus-pattern-generator, its index.html), cut down7+// to a 3 by 3 grid: its seeded random numbers (hash32 and the sfc32-style8+// makeRng, keyed by the seed and a path), its grammars (weave, cadence,9+// blocks) that give each tile a place in a repeating sequence of motifs and10+// colours, its rare mutations, and its nesting, which splits a tile into11+// four (one level here, so a tile is never smaller than a sixth of the12+// square). The motifs are the generator's: a quarter circle, a half circle,13+// a bar, a triangle, a ring, an inset square, a filled tile, an empty one.14+// Its colour themes give way to five tokens (--avatar-ground, -ink, -red,15+// -blue, -yellow in style.css), with the order of the three accents seeded,16+// so the art reads in light and dark.17+//18+// Changing anything here changes everyone's avatar: don't, unless that is19+// the point.20+//21+// node lib/avatar.ts <name> [size] the SVG22+import fs from 'node:fs';23+import { fileURLToPath } from 'node:url';24+25+const VIEW = 72;26+const GRID = 3;27+const TILE = VIEW / GRID;28+29+const MOTIFS = ['quarter', 'half', 'bar', 'diagonal', 'ring', 'inset', 'field', 'void'] as const;30+type Motif = (typeof MOTIFS)[number];31+const GRAMMARS = ['weave', 'cadence', 'blocks'] as const;32+33+// The generator's, unchanged.34+function hash32(text: string): number {35+ let hash = 2166136261;36+ for (let i = 0; i < text.length; i += 1) {37+ hash ^= text.charCodeAt(i);38+ hash = Math.imul(hash, 16777619);39+ }40+ hash += hash << 13;41+ hash ^= hash >>> 7;42+ hash += hash << 3;43+ hash ^= hash >>> 17;44+ hash += hash << 5;45+ return hash >>> 0;46+}47+48+interface Rng {49+ float(): number;50+ int(min: number, max: number): number;51+ bool(chance?: number): boolean;52+ pick<T>(items: readonly T[]): T;53+ weighted<T>(entries: [T, number][]): T;54+}55+56+function makeRng(seed: string, path: string): Rng {57+ let a = hash32(`${seed}|${path}|a`);58+ let b = hash32(`${seed}|${path}|b`);59+ let c = hash32(`${seed}|${path}|c`);60+ let d = hash32(`${seed}|${path}|d`);61+ const float = (): number => {62+ a >>>= 0; b >>>= 0; c >>>= 0; d >>>= 0;63+ const result = (a + b + d) >>> 0;64+ d = (d + 1) >>> 0;65+ a = b ^ (b >>> 9);66+ b = (c + (c << 3)) >>> 0;67+ c = ((c << 21) | (c >>> 11)) >>> 0;68+ c = (c + result) >>> 0;69+ return result / 4294967296;70+ };71+ return {72+ float,73+ int: (min, max) => Math.floor(float() * (max - min + 1)) + min,74+ bool: (chance = 0.5) => float() < chance,75+ pick: (items) => items[Math.floor(float() * items.length)],76+ weighted(entries) {77+ const total = entries.reduce((sum, e) => sum + e[1], 0);78+ let cursor = float() * total;79+ for (const [value, weight] of entries) {80+ cursor -= weight;81+ if (cursor <= 0) return value;82+ }83+ return entries[entries.length - 1][0];84+ },85+ };86+}87+88+interface System {89+ // Class names of the five colours: ground, ink, then the accents.90+ palette: string[];91+ grammar: (typeof GRAMMARS)[number];92+ period: number;93+ blockSize: number;94+ mutationRate: number;95+ motifSequence: Motif[];96+ colorSequence: number[];97+ direction: number;98+ nesting: { enabled: boolean; rootRate: number };99+}100+101+function buildSystem(seed: string): System {102+ const paletteRng = makeRng(seed, 'palette');103+ const structureRng = makeRng(seed, 'structure');104+ const rhythmRng = makeRng(seed, 'rhythm');105+ const nestingRng = makeRng(seed, 'nesting');106+ const accents = ['av-r', 'av-b', 'av-y'];107+ for (let i = accents.length - 1; i > 0; i--) {108+ const j = paletteRng.int(0, i);109+ [accents[i], accents[j]] = [accents[j], accents[i]];110+ }111+ const grammar = structureRng.pick(GRAMMARS);112+ // The generator draws how many tiles span the short side here; the grid113+ // is fixed, but the draw stays so what follows is the generator's.114+ structureRng.int(6, 11);115+ const period = structureRng.int(3, 6);116+ const blockSize = structureRng.pick([2, 2, 3]);117+ const mutationRate = structureRng.pick([0.035, 0.05, 0.07, 0.09]);118+ const motifSequence = Array.from({ length: period }, () => rhythmRng.pick(MOTIFS.slice(0, 7)));119+ const colorSequence = Array.from({ length: period + 1 }, () => rhythmRng.weighted([[0, 6], [1, 4], [2, 2], [3, 2], [4, 1.5]]));120+ const direction = rhythmRng.pick([1, 1, -1]);121+ const enabled = nestingRng.bool(0.55);122+ nestingRng.weighted([[1, 3], [2, 1]]);123+ const rootRate = nestingRng.pick([0.14, 0.18, 0.22, 0.26]);124+ return { palette: ['av-g', 'av-i', ...accents], grammar, period, blockSize, mutationRate, motifSequence, colorSequence, direction, nesting: { enabled, rootRate } };125+}126+127+function cadenceIndex(s: System, col: number, row: number): number {128+ if (s.grammar === 'weave') return (row + (col % 2) * 2) % s.period;129+ if (s.grammar === 'blocks') return (Math.floor(col / s.blockSize) + 2 * Math.floor(row / s.blockSize)) % s.period;130+ return (col + s.direction * row * 2 + s.period * 8) % s.period;131+}132+133+function chooseForeground(s: System, bg: number, cadence: number, rng: Rng): number {134+ const preferred = s.colorSequence[(cadence + 1) % s.colorSequence.length];135+ if (preferred !== bg && rng.bool(0.72)) return preferred;136+ if (bg >= 2 && bg <= 4) return rng.weighted([[0, 3], [1, 5]]);137+ return rng.pick([0, 1, 2, 3, 4].filter((i) => i !== bg));138+}139+140+const num = (n: number): string => String(Math.round(n * 100) / 100);141+142+// A motif in a tile of side z at x, y, turned a quarter `turn` times143+// clockwise about its centre: SVG, or '' for none.144+function motif(m: Motif, x: number, y: number, z: number, turn: number, cls: string, rng: Rng): string {145+ const h = z / 2;146+ const t = turn ? ` transform="rotate(${turn * 90} ${num(x + h)} ${num(y + h)})"` : '';147+ const rect = (rx: number, ry: number, w: number, hh: number): string =>148+ `<rect class="${cls}" x="${num(x + rx)}" y="${num(y + ry)}" width="${num(w)}" height="${num(hh)}"${t}/>`;149+ const path = (d: string, rule = ''): string => `<path class="${cls}"${rule} d="${d}"${t}/>`;150+ switch (m) {151+ case 'quarter':152+ return path(`M${num(x)} ${num(y)}H${num(x + z)}A${num(z)} ${num(z)} 0 0 1 ${num(x)} ${num(y + z)}Z`);153+ case 'half':154+ return path(`M${num(x)} ${num(y + z)}A${num(h)} ${num(h)} 0 0 1 ${num(x + z)} ${num(y + z)}Z`);155+ case 'bar': {156+ const w = z * rng.pick([0.25, 0.333333, 0.5]);157+ return rect((z - w) / 2, 0, w, z);158+ }159+ case 'diagonal':160+ return path(`M${num(x)} ${num(y)}H${num(x + z)}V${num(y + z)}Z`);161+ case 'ring': {162+ const ring = z * rng.pick([0.14, 0.18, 0.22]);163+ const r1 = z * 0.38;164+ const r2 = Math.max(0.5, r1 - ring);165+ const cx = x + h, cy = y + h;166+ const circle = (r: number): string => `M${num(cx - r)} ${num(cy)}a${num(r)} ${num(r)} 0 1 0 ${num(2 * r)} 0a${num(r)} ${num(r)} 0 1 0 ${num(-2 * r)} 0Z`;167+ return path(circle(r1) + circle(r2), ' fill-rule="evenodd"');168+ }169+ case 'inset': {170+ const i = z * rng.pick([0.18, 0.24, 0.3]);171+ return rect(i, i, z - 2 * i, z - 2 * i);172+ }173+ case 'field':174+ return rect(0, 0, z, z);175+ default:176+ return '';177+ }178+}179+180+function leaf(seed: string, s: System, col: number, row: number, x: number, y: number, z: number, path: string, depth: number, cadence: number): string {181+ const rng = makeRng(seed, `${path}/leaf`);182+ let m = s.motifSequence[cadence % s.motifSequence.length];183+ let turn = (((cadence + (s.direction < 0 ? row : col) + depth) % 4) + 4) % 4;184+ if (rng.bool(s.mutationRate)) m = rng.pick(MOTIFS);185+ if (rng.bool(s.mutationRate * 1.4)) turn = rng.int(0, 3);186+ const bg = s.colorSequence[cadence % s.colorSequence.length];187+ const fg = chooseForeground(s, bg, cadence, rng);188+ // The whole square is the ground already.189+ const back = bg ? `<rect class="${s.palette[bg]}" x="${num(x)}" y="${num(y)}" width="${num(z)}" height="${num(z)}"/>` : '';190+ return back + motif(m, x, y, z, turn, s.palette[fg], rng);191+}192+193+function node(seed: string, s: System, col: number, row: number, x: number, y: number, z: number, path: string, depth: number, cadence: number): string {194+ const rng = makeRng(seed, `nesting/${path}`);195+ if (s.nesting.enabled && depth < 1 && rng.bool(s.nesting.rootRate)) {196+ const c = z / 2;197+ let out = '';198+ for (let r = 0; r < 2; r++) {199+ for (let k = 0; k < 2; k++) {200+ const child = (cadence + k + r * s.direction + depth + 1 + s.period * 4) % s.period;201+ out += node(seed, s, col, row, x + k * c, y + r * c, c, `${path}/branch-${depth}/${k + r * 2}`, depth + 1, child);202+ }203+ }204+ return out;205+ }206+ return leaf(seed, s, col, row, x, y, z, path, depth, cadence);207+}208+209+// The avatar of a name, `size` pixels square. Decoration beside the name,210+// which says who it is.211+export function avatar(name: string, size = 36): string {212+ const seed = String(name);213+ const s = buildSystem(seed);214+ let body = `<rect class="av-g" width="${VIEW}" height="${VIEW}"/>`;215+ for (let row = 0; row < GRID; row++) {216+ for (let col = 0; col < GRID; col++) {217+ body += node(seed, s, col, row, col * TILE, row * TILE, TILE, `cell/${col}/${row}`, 0, cadenceIndex(s, col, row));218+ }219+ }220+ return `<svg class="avatar" viewBox="0 0 ${VIEW} ${VIEW}" width="${size}" height="${size}" aria-hidden="true">${body}</svg>`;221+}222+223+if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {224+ const [name, size] = process.argv.slice(2);225+ if (!name) {226+ console.error('usage: node lib/avatar.ts <name> [size]');227+ process.exit(2);228+ }229+ console.log(avatar(name, Number(size) || 72));230+}@@ -24,6 +24,11 @@ import { HighlightCache } from './highlight.ts';
2424 import { assets } from './assets.ts';2525 import { recent, type RecentItem } from './recent.ts';2626 import { motd } from './motd.ts';27+import { avatar } from './avatar.ts';28+import { readProfile, linkText } from './profile.ts';29+import { decodeMinutes, grid, readExport, type Grid, type PersonExport, type RepoActivityExport } from './activity.ts';30+import { extraTopics, languages, license } from './front.ts';31+import { catFileBatch } from './git.ts';2732 import type { CiStatus, Commit, Config, DiffFile, Repo, RepoCounts, RepoSummary, RepoUrls, Review, ReviewComment, SiteLink } from './types.ts';2833 2934 export function config(env: NodeJS.ProcessEnv = process.env): Config {@@ -118,7 +123,7 @@ export interface RawFile {
118123 export interface Forge {119124 config: Config;120125 repos: RepoSummary[];121- owners: { owner: string; repos: RepoSummary[] }[];126+ owners: Owner[];122127 // The index (site/index.njk): its line under the header (null: none),123128 // the public owners, the shelves across them, and what happened lately.124129 motd: string | null;@@ -139,9 +144,29 @@ export interface Profile {
139144 repos: number;140145 // When they last did something public (lib/recent.ts), ISO.141146 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;147+}148+149+// An owner's page: their public repos, their front matter (what they set150+// with ssh profile, lib/profile.ts, and facts) and their activity grid.151+export interface Owner {152+ owner: string;153+ repos: RepoSummary[];154+ person: Person;155+ // data: each repo's commit times, as forge lake exports them (ui.ts puts156+ // them on the reader's days); the grid without JS has UTC days.157+ activity: { grid: Grid; data: { repos: Record<string, number[]> } } | null;158+}159+160+export interface Person {161+ bio: string;162+ link: string;163+ linkText: string;164+ sites: number;165+ // The month they joined, "Sep 2026", or ''.166+ joined: string;167+ // What they pinned (still public), or their most recently active repos.168+ pins: RepoSummary[];169+ pinned: boolean;145170 }146171 147172 export interface Topic {@@ -152,13 +177,11 @@ export interface Topic {
152177 href: string;153178 }154179 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-}180+const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];181+const month = (ms: number): string => {182+ const t = new Date(ms);183+ return `${MONTHS[t.getUTCMonth()]} ${t.getUTCFullYear()}`;184+};162185 163186 let last: Forge | null = null;164187 export const current = (): Forge | null => last;@@ -191,7 +214,7 @@ export function loadForge(env: NodeJS.ProcessEnv = process.env): Forge {
191214 // Index and owner pages list every repo, but a single-repo build only loads192215 // one. Each repo's counts and sites are cached when it is built.193216 const summaryCache = path.join(cfg.cacheDir, priv ? 'summaries-private.json' : 'summaries.json');194- let cached: Record<string, { counts: RepoCounts; sites: SiteLink[]; topic?: string | null }> = {};217+ let cached: Record<string, { counts: RepoCounts; sites: SiteLink[]; topic?: string | null; release?: { tag: string; date: string; href: string } | null }> = {};195218 try {196219 cached = JSON.parse(fs.readFileSync(summaryCache, 'utf8'));197220 } catch {}@@ -262,12 +285,32 @@ export function loadForge(env: NodeJS.ProcessEnv = process.env): Forge {
262285 const notes = releases(repo, cache.ranges).map((r) => ({ ...r, files: repo.releaseFiles.get(r.tag.name) ?? null, ci: repo.commits.get(r.tag.sha)?.ci ?? null }));263286 // The newest release with files, linked from the home page.264287 const shipped = notes.find((r) => r.files);265- const release = shipped ? { tag: shipped.tag.name, files: shipped.files?.files.length ?? 0, href: `${u.releases.href}#${shipped.tag.name}` } : null;288+ const release = shipped ? { tag: shipped.tag.name, files: shipped.files?.files.length ?? 0, href: `${u.releases.href}#${shipped.tag.name}`, date: shipped.tag.date } : null;289+ cached[s.slug].release = release && { tag: release.tag, date: release.date, href: release.href };290+ // The front matter's facts (lib/front.ts); its topics need every repo's291+ // shelf, so they are filled in once all are loaded.292+ const rootEntries = rootTree?.dirs.get('') ?? [];293+ const topicsFile = rootTree?.blobs.find((e) => e.path === '.forge/topics' && (e.size ?? 0) <= 4096);294+ const tip = defaultLog[0];295+ const lic = license(repo.dir, rootEntries);296+ const langs = rootTree ? languages(repo.dir, rootTree.blobs) : [];297+ const front = {298+ shelf: s.category || (topic && SHELVES[topic]) || '',299+ extras: topicsFile ? extraTopics(catFileBatch(repo.dir, [topicsFile.oid]).get(topicsFile.oid)?.data.toString('utf8') ?? '') : [],300+ topics: [] as { title: string; href: string | null }[],301+ sites: repo.sites.length === 1 ? { name: repo.sites[0].name, url: repo.sites[0].url } : repo.sites.length ? { n: repo.sites.length, href: u.sites.href } : null,302+ release,303+ ci: tip?.ci ? { state: tip.ci.state, short: tip.short, href: u.commit(tip.sha).href } : defaultLog.some((c) => c.ci) && tip ? { state: 'none', short: tip.short, href: u.commit(tip.sha).href } : null,304+ reviews: repo.reviews.filter((r) => r.status === 'open').length,305+ license: lic && { id: lic.id, path: lic.path, href: u.blob(repo.defaultBranch, lic.path).href },306+ languages: langs.slice(0, 3),307+ rest: Math.round(langs.slice(3).reduce((a, l) => a + l.pct, 0) * 10) / 10,308+ };266309 add(pages.homes, page(u.home.path, `${repo.slug}`, 'home', {267310 readme: repo.readme, latest: defaultLog[0] ?? null, commitCount: defaultLog.length, branchCount: repo.branches.length,268311 entries: rootTree ? entryLinks(repo.defaultBranch, rootTree.dirs.get('') ?? []) : [],269312 filesHref: rootTree ? u.files(repo.defaultBranch).href : null,270- reviews: firstStacks(nest(repo.reviews.filter((r) => r.status === 'open')), 5), topic, release,313+ reviews: firstStacks(nest(repo.reviews.filter((r) => r.status === 'open')), 5), topic, release, front,271314 }));272315 pages.atom.push({ permalink: u.atom.path, repo: head, commits: defaultLog.slice(0, 30) });273316 for (const kind of ['tree', 'log']) pages.branchMenus.push({ permalink: u.branchMenu(kind).path, repo: head, kind, branches: repo.branches });@@ -387,7 +430,14 @@ export function loadForge(env: NodeJS.ProcessEnv = process.env): Forge {
387430 // The page is the same until the code changes; its charts fetch the388431 // lake's exports, which go next to it (and which forge lake replaces in389432 // the live pages after each run).390- add(pages.insights, page(u.insights.path, `Insights - ${repo.slug}`, 'insights', {}), 'insights');433+ // The activity grid comes from the lake's last export (lib/insights.ts);434+ // with JS the page fetches the export forge lake keeps fresh.435+ const act = readExport<RepoActivityExport>(cfg.state, repo.owner, repo.name, 'insights', 'activity.json');436+ const activity = act ? {437+ grid: grid(act.days, 'event'),438+ recent: act.events.slice(0, 20).map(([t, kind, text, rel]) => ({ at: new Date(t).toISOString(), kind, text, href: rel ? u.home.href + rel : null })),439+ } : null;440+ add(pages.insights, page(u.insights.path, `Insights - ${repo.slug}`, 'insights', { activity }), `insights ${fingerprint(activity)}`);391441 raw.push(...insightFiles(cfg, path.join(repo.owner, repo.name, 'insights'), u.insights.path));392442 for (const w of repo.wiki) {393443 add(pages.wiki, page(u.wiki(w.slug).path, `${w.title} - wiki - ${repo.slug}`, 'wiki', { wiki: w, pages: repo.wiki.map((x) => ({ title: x.title, slug: x.slug, href: u.wiki(x.slug).href })) }));@@ -418,14 +468,27 @@ export function loadForge(env: NodeJS.ProcessEnv = process.env): Forge {
418468 fs.mkdirSync(cfg.cacheDir, { recursive: true });419469 fs.writeFileSync(summaryCache, JSON.stringify(Object.fromEntries(summaries.map((s) => [s.slug, cached[s.slug] ?? { counts: {}, sites: [] }]))));420470 421- const owners = ownerNames.map((owner) => ({ owner, repos: summaries.filter((s) => s.owner === owner) }));422471 // The index's panels; a private build has no index.423472 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) }));473+ const owners = ownerNames.filter((o) => !cfg.only?.startsWith('@') || cfg.only === `@${o}`).map((owner) => ownerPage(cfg, owner, summaries.filter((s) => s.owner === owner)));474+ for (const o of owners) {475+ if (!priv && o.activity) raw.push({ path: `/${o.owner}/activity.json`, data: Buffer.from(JSON.stringify(o.activity.data) + '\n') });476+ }477+ const profiles = ownerNames.map((owner) => ({ owner, repos: summaries.filter((s) => s.owner === owner).length, active: lately.active[owner] ?? '' })).filter((p) => p.repos);425478 const topics = filters.shelves(summaries).map((sh) => {426479 const who = [...new Set(sh.repos.map((r) => r.owner))];427480 return { id: sh.id, title: sh.title, repos: sh.repos.length, href: who.length === 1 ? `/${who[0]}/#${sh.id}` : `/topics/#${sh.id}` };428481 });482+ // A repo's topics: its shelf, then its .forge/topics; each links where483+ // the home page's topics do, if any shelf has that name.484+ const topicHref = new Map(topics.map((t) => [t.id, t.href]));485+ for (const p of pages.homes) {486+ const f = p.front as { shelf: string; extras: string[]; topics: { title: string; href: string | null }[] };487+ for (const title of [f.shelf, ...f.extras]) {488+ if (!title || f.topics.some((t) => t.title === title)) continue;489+ f.topics.push({ title, href: topicHref.get(title.replace(/[^a-z0-9]+/g, '-')) ?? null });490+ }491+ }429492 // Repo search on the index and owner pages.430493 raw.push({ path: '/repos.json', data: Buffer.from(reposIndex(summaries)) });431494 if (!cfg.only || cfg.only === '-') raw.push(...insightFiles(cfg, 'insights', '/insights/'));@@ -433,6 +496,39 @@ export function loadForge(env: NodeJS.ProcessEnv = process.env): Forge {
433496 return last;434497 }435498 499+// An owner's page: front matter and activity. Pins are checked again here,500+// so a pinned repo that went private or away just drops out.501+function ownerPage(cfg: Config, owner: string, repos: RepoSummary[]): Owner {502+ const prof = readProfile(cfg.state, owner);503+ const byName = new Map(repos.map((r) => [r.name, r]));504+ const pinned = prof.pins.map((n) => byName.get(n)).filter((r): r is RepoSummary => !!r);505+ const pins = pinned.length ? pinned : [...repos].sort((a, b) => b.lastCommit.localeCompare(a.lastCommit)).slice(0, 3);506+ const exp = readExport<PersonExport>(cfg.state, 'people', `${owner}.json`);507+ const joinedAt = [exp?.joined, prof.joined ? prof.joined * 1000 : null].filter((t): t is number => !!t);508+ // Only the repos on the page count: one gone private since the export isn't.509+ const kept: Record<string, number[]> = {};510+ const totals: Record<string, number> = {};511+ for (const [name, enc] of Object.entries(exp?.repos ?? {})) {512+ if (!byName.has(name) || !Array.isArray(enc)) continue;513+ kept[name] = enc;514+ for (const m of decodeMinutes(enc)) {515+ const d = new Date(m * 60000).toISOString().slice(0, 10);516+ totals[d] = (totals[d] ?? 0) + 1;517+ }518+ }519+ return {520+ owner,521+ repos,522+ person: {523+ bio: prof.bio, link: prof.link, linkText: prof.link ? linkText(prof.link) : '',524+ sites: repos.reduce((a, r) => a + (r.counts?.sites ?? 0), 0),525+ joined: joinedAt.length ? month(Math.min(...joinedAt)) : '',526+ pins, pinned: pinned.length > 0,527+ },528+ activity: repos.length ? { grid: grid(totals, 'commit'), data: { repos: kept } } : null,529+ };530+}531+436532 // The lake's insights exports for a scope (lib/insights.ts), read now: forge437533 // lake may swap the directory while the build runs.438534 function insightFiles(cfg: Config, dir: string, at: string): RawFile[] {@@ -506,7 +602,7 @@ const SHELVES: Record<string, string> = {
506602 };507603 508604 // Filters that return markup; setup() in render-page.ts marks their output safe.509-export const markupFilters = new Set(['ago', 'date']);605+export const markupFilters = new Set(['ago', 'date', 'avatar', 'json']);510606 511607 // Lazy renderers, registered as Nunjucks filters (setup() in render-page.ts).512608 export const filters = {@@ -605,6 +701,27 @@ export const filters = {
605701 return text.slice(0, cut > n / 2 ? cut : n) + '…';606702 },607703 plural: (n: number, word: string, many?: string): string => `${n} ${n === 1 ? word : many ?? word + 's'}`,704+ // 1,284.705+ thousands: (n: number): string => Number(n).toLocaleString('en-US'),706+ // A made-up year for the style guide's activity grid: busier on707+ // weekdays and lately, the same every build.708+ demoActivity(noun: string): Grid {709+ const counts: Record<string, number> = {};710+ const end = Number(process.env.FORGE_NOW) * 1000 || Date.now();711+ let h = 7;712+ for (let i = 0; i < 371; i++) {713+ h = (h * 1103515245 + 12345) >>> 0;714+ const t = new Date(end - i * 86400000);715+ const weekday = t.getUTCDay() % 6 ? 1 : 0.3;716+ const n = Math.floor(((h >>> 16) % 100) / 100 * 9 * weekday * (1 - i / 500)) - 2;717+ if (n > 0) counts[t.toISOString().slice(0, 10)] = n;718+ }719+ return grid(counts, noun);720+ },721+ // A person's avatar (lib/avatar.ts), size pixels square.722+ avatar: (name: string, size = 36): string => avatar(name, Number(size)),723+ // Data for a page's script, in a <script type="application/json">.724+ json: (v: unknown): string => JSON.stringify(v ?? null).replace(/</g, '\\u003c'),608725 indent: (depth: number): string => ' '.repeat(depth),609726 mode: (m: string): string => MODES[m] ?? m,610727 size(n: number | null): string {@@ -0,0 +1,129 @@
1+// A repo's front matter (the top of its code page): what it is about, its2+// topics, and facts read from its default branch at build time, which are3+// its license and the languages it is written in.4+import { catFileBatch, isBinary } from './git.ts';5+import type { TreeEntry } from './types.ts';6+7+// ---------------------------------------------------------------- license8+9+const LICENSE_FILE = /^(licen[cs]e|copying|unlicense)([.-][\w.-]*)?$/i;10+11+// SPDX ids of the licenses whose text says which it is, by phrases in the12+// text, first match wins (more specific first).13+const LICENSES: [string, RegExp[]][] = [14+ ['AGPL-3.0', [/gnu affero general public license/, /version 3/]],15+ ['LGPL-3.0', [/gnu lesser general public license/, /version 3/]],16+ ['LGPL-2.1', [/gnu lesser general public license/, /version 2\.1/]],17+ ['GPL-3.0', [/gnu general public license/, /version 3/]],18+ ['GPL-2.0', [/gnu general public license/, /version 2/]],19+ ['Apache-2.0', [/apache license/, /version 2\.0/]],20+ ['MPL-2.0', [/mozilla public license,? version 2\.0/]],21+ ['EPL-2.0', [/eclipse public license - v 2\.0/]],22+ ['BSL-1.0', [/boost software license - version 1\.0/]],23+ ['OFL-1.1', [/sil open font license/, /version 1\.1/]],24+ ['CC0-1.0', [/cc0 1\.0 universal/]],25+ ['Unlicense', [/this is free and unencumbered software released into the public domain/]],26+ ['MIT-0', [/permission is hereby granted, free of charge/, /^(?![\s\S]*the above copyright notice and this permission notice shall be included)/]],27+ ['MIT', [/permission is hereby granted, free of charge/]],28+ ['0BSD', [/permission to use, copy, modify, and\/or distribute this software for any purpose with or without fee is hereby granted/, /^(?![\s\S]*provided that the above copyright notice)/]],29+ ['ISC', [/permission to use, copy, modify, and\/or distribute this software for any purpose with or without fee is hereby granted/]],30+ ['BSD-3-Clause', [/redistribution and use in source and binary forms/, /neither the name/]],31+ ['BSD-2-Clause', [/redistribution and use in source and binary forms/]],32+ ['Zlib', [/this software is provided 'as-is'/, /altered source versions must be plainly marked/]],33+];34+35+export function spdx(text: string): string | null {36+ const t = text.toLowerCase().replace(/\s+/g, ' ');37+ for (const [id, res] of LICENSES) if (res.every((r) => r.test(t))) return id;38+ return null;39+}40+41+export interface License {42+ // The SPDX id, or null: "see LICENSE".43+ id: string | null;44+ path: string;45+}46+47+// The root's license file, and what it is.48+export function license(dir: string, root: TreeEntry[]): License | null {49+ const files = root.filter((e) => e.type === 'blob' && LICENSE_FILE.test(e.name)).sort((a, b) => a.name.length - b.name.length);50+ const f = files[0];51+ if (!f) return null;52+ const data = (f.size ?? 0) <= 1 << 16 ? catFileBatch(dir, [f.oid]).get(f.oid)?.data : null;53+ return { id: data && !isBinary(data) ? spdx(data.toString('utf8')) : null, path: f.path };54+}55+56+// ---------------------------------------------------------------- languages57+58+const EXT: Record<string, string> = {59+ ts: 'TypeScript', tsx: 'TypeScript', mts: 'TypeScript', cts: 'TypeScript',60+ js: 'JavaScript', jsx: 'JavaScript', mjs: 'JavaScript', cjs: 'JavaScript',61+ go: 'Go', rs: 'Rust', py: 'Python', rb: 'Ruby', java: 'Java', kt: 'Kotlin', swift: 'Swift',62+ c: 'C', h: 'C', cc: 'C++', cpp: 'C++', cxx: 'C++', hpp: 'C++', cs: 'C#', zig: 'Zig', lua: 'Lua',63+ php: 'PHP', ex: 'Elixir', exs: 'Elixir', erl: 'Erlang', hs: 'Haskell', ml: 'OCaml', clj: 'Clojure',64+ scala: 'Scala', dart: 'Dart', r: 'R', jl: 'Julia', nim: 'Nim', sql: 'SQL',65+ sh: 'Shell', bash: 'Shell', zsh: 'Shell', fish: 'Shell',66+ html: 'HTML', htm: 'HTML', css: 'CSS', scss: 'SCSS', sass: 'SCSS', vue: 'Vue', svelte: 'Svelte',67+ njk: 'Nunjucks', liquid: 'Liquid', tex: 'TeX',68+ nix: 'Nix', tf: 'HCL', hcl: 'HCL', ps1: 'PowerShell', vim: 'Vim Script', el: 'Emacs Lisp',69+};70+const NAMES: Record<string, string> = { makefile: 'Makefile', dockerfile: 'Dockerfile', gnumakefile: 'Makefile', justfile: 'Just' };71+// What a script's #! line runs.72+const SHEBANG: [RegExp, string][] = [73+ [/\b(ba|z|da|k)?sh\b/, 'Shell'], [/\bnode\b|\bdeno\b|\bbun\b/, 'JavaScript'], [/\bpython[0-9.]*\b/, 'Python'],74+ [/\bruby\b/, 'Ruby'], [/\bperl\b/, 'Perl'], [/\blua\b/, 'Lua'],75+];76+// Not the repo's own code: dependencies, build output, lock files and77+// minified or generated files.78+const VENDORED = /(^|\/)(node_modules|vendor|third_party|third-party|bower_components|dist|build|out|target|\.yarn|_site|\.git)\//;79+const GENERATED = /(\.min\.(js|css)|\.map|\.lock|-lock\.json|-lock\.yaml|\.pb\.go|_pb2\.py|\.generated\.\w+|\.snap)$|(^|\/)(go\.sum|package-lock\.json|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock|poetry\.lock|composer\.lock)$/;80+81+export interface Language {82+ name: string;83+ bytes: number;84+ // Of the counted bytes, rounded to tenths.85+ pct: number;86+}87+88+// Bytes of each language in a tree: by extension or name, and for an89+// executable without either, its #! line. Vendored, generated and binary90+// files (anything the map doesn't know) aren't counted.91+export function languages(dir: string, blobs: TreeEntry[]): Language[] {92+ const by = new Map<string, number>();93+ const scripts: TreeEntry[] = [];94+ for (const e of blobs) {95+ if (e.type !== 'blob' || e.mode === '120000' || !e.size || VENDORED.test(e.path) || GENERATED.test(e.path)) continue;96+ const ext = /\.([A-Za-z0-9]+)$/.exec(e.name)?.[1]?.toLowerCase();97+ const lang = (ext && EXT[ext]) || NAMES[e.name.toLowerCase()];98+ if (lang) by.set(lang, (by.get(lang) ?? 0) + e.size);99+ else if (!ext && e.mode === '100755') scripts.push(e);100+ }101+ if (scripts.length) {102+ const data = catFileBatch(dir, scripts.slice(0, 200).map((e) => e.oid));103+ for (const e of scripts.slice(0, 200)) {104+ const head = data.get(e.oid)?.data.subarray(0, 128).toString('utf8') ?? '';105+ if (!head.startsWith('#!')) continue;106+ const line = head.split('\n')[0];107+ const lang = SHEBANG.find(([re]) => re.test(line))?.[1];108+ if (lang) by.set(lang, (by.get(lang) ?? 0) + (e.size ?? 0));109+ }110+ }111+ const total = [...by.values()].reduce((a, b) => a + b, 0);112+ if (!total) return [];113+ return [...by].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([name, bytes]) => ({ name, bytes, pct: Math.round((bytes / total) * 1000) / 10 }));114+}115+116+// ---------------------------------------------------------------- topics117+118+// Extra topics from the repo's .forge/topics: one a line, lowercase words,119+// # for comments, ten at most.120+export function extraTopics(text: string): string[] {121+ const out: string[] = [];122+ for (const raw of text.split('\n')) {123+ const t = raw.replace(/#.*/, '').trim().toLowerCase().replace(/\s+/g, ' ');124+ if (!t || t.length > 32 || !/^[a-z0-9][a-z0-9 .+-]*$/.test(t) || out.includes(t)) continue;125+ out.push(t);126+ if (out.length >= 10) break;127+ }128+ return out;129+}@@ -15,7 +15,9 @@ import path from 'node:path';
1515 import { listRepos } from './lake-sources.ts';1616 import { lit, type Lake, type LakeConfig } from './lake.ts';1717 18-export const FILES = ['insights.json', 'events.parquet', 'builds.parquet', 'reviews.parquet', 'ci.parquet'];18+export const FILES = ['insights.json', 'activity.json', 'events.parquet', 'builds.parquet', 'reviews.parquet', 'ci.parquet'];19+// How far back the activity exports go: a year's grid, and some.20+const DAYS = 400;1921 // The most points a chart gets; older ones are only in the Parquet files.2022 const CAP = 1000;2123 @@ -98,7 +100,8 @@ async function exportScope(lake: Lake, s: Scope, publicRepos: string[]): Promise
98100 UNION ALL SELECT repo, review, kind, ts, "user", NULL, sha, NULL, NULL, NULL, NULL, observed FROM lake.review_ends WHERE ${s.where}99101 ORDER BY repo, review, ts`);100102 await copy('ci.parquet', `SELECT repo, "commit", tree, result, exit, started, duration_s, system, env, signer, verified FROM lake.ci WHERE ${s.where} ORDER BY started`);101- out.files = FILES.slice(1).map((f) => [f, fs.statSync(path.join(tmp, f)).size]);103+ out.files = FILES.slice(2).map((f) => [f, fs.statSync(path.join(tmp, f)).size]);104+ if (s.slug) fs.writeFileSync(path.join(tmp, 'activity.json'), JSON.stringify(await repoActivity(lake, s.slug)) + '\n');102105 fs.writeFileSync(path.join(tmp, 'insights.json'), JSON.stringify(out) + '\n');103106 104107 // Swapped in whole; a build reading it meanwhile (lib/forge.ts) skips what@@ -110,6 +113,96 @@ async function exportScope(lake: Lake, s: Scope, publicRepos: string[]): Promise
110113 fs.rmSync(old, { recursive: true, force: true });111114 }112115 116+const since = (): string => {117+ const now = Number(process.env.FORGE_NOW) * 1000 || Date.now();118+ return lit(new Date(now - DAYS * 86400000).toISOString().slice(0, 10));119+};120+const day = (ms: number): string => new Date(ms).toISOString().slice(0, 10);121+122+// A repo's activity, for the grid on its insights page (ui.activity): what123+// happened each day (pushes, landed reviews, releases, site deploys, CI124+// results) and the events themselves, newest first, each [time (ms), kind,125+// text, link from the repo's page]. Days are UTC.126+export async function repoActivity(lake: Lake, slug: string): Promise<{ days: Record<string, number>; events: [number, string, string, string][] }> {127+ const where = `repo = ${lit(slug)}`;128+ const events: [number, string, string, string][] = [];129+ for (const [t, user, refs] of await lake.rows(`SELECT epoch_ms(t), "user", refs FROM lake.events WHERE type = 'push' AND ${where} AND t >= ${since()} AND refs IS NOT NULL`)) {130+ const at = Number(t);131+ const heads: string[] = [];132+ const named = new Set<string>();133+ for (const ref of String(refs).split(/\s+/).filter(Boolean)) {134+ const m = /^refs\/(heads|tags|releases|sites)\/(.+)$/.exec(ref);135+ if (!m) continue;136+ if (m[1] === 'heads') heads.push(m[2]);137+ else if (m[1] === 'sites') events.push([at, 'site', `deployed ${m[2]}`, 'sites/']);138+ else if (!named.has(m[2])) {139+ // A tag and its release in one push are one release.140+ named.add(m[2]);141+ events.push([at, 'release', m[2], `releases/#${encodeURIComponent(m[2])}`]);142+ }143+ }144+ if (heads.length) events.push([at, 'pushed', `${user ?? 'someone'} pushed ${heads.join(', ')}`, '']);145+ }146+ for (const [t, id, title] of await lake.rows(`147+ SELECT epoch_ms(e.ts), e.review, (SELECT any_value(title) FROM lake.reviews r WHERE r.repo = e.repo AND r.review = e.review AND kind = 'created')148+ FROM lake.review_ends e WHERE e.kind = 'landed' AND e.${where} AND e.ts >= ${since()}`)) {149+ events.push([Number(t), 'landed', `#${id}${title ? ' ' + title : ''}`, `reviews/${id}/`]);150+ }151+ for (const [t, result, sha, subject] of await lake.rows(`152+ SELECT epoch_ms(c.started), c.result, c."commit", (SELECT any_value(subject) FROM lake.commits m WHERE m.repo = c.repo AND m.sha = c."commit")153+ FROM lake.ci c WHERE c.${where} AND c.started >= ${since()} AND c."commit" IS NOT NULL`)) {154+ const s = String(sha);155+ events.push([Number(t), result === 'pass' ? 'passed' : 'failed', `${s.slice(0, 7)}${subject ? ' ' + subject : ''}`, `commit/${s}/`]);156+ }157+ events.sort((a, b) => b[0] - a[0]);158+ const days: Record<string, number> = {};159+ for (const e of events) days[day(e[0])] = (days[day(e[0])] ?? 0) + 1;160+ return { days, events };161+}162+163+// Each owner's activity, for the grid on their page: when each commit on164+// the default branches of their public repos was made (its author's time),165+// by repo, as minutes since 1970, oldest first, each after the first as166+// the minutes since the one before (lib/activity.ts reads them), so the page167+// can put them on the reader's days; and when they joined: their first168+// public event, or the first push to a public repo of theirs. A private169+// repo's commits are in no export.170+async function exportPeople(lake: Lake, pub: string[]): Promise<number> {171+ const dir = path.join(lake.cfg.exportDir, 'people');172+ const tmp = `${dir}.tmp`;173+ fs.rmSync(tmp, { recursive: true, force: true });174+ fs.mkdirSync(tmp, { recursive: true });175+ const people = new Map<string, { repos: Record<string, number[]>; joined: number | null }>();176+ const of = (o: string) => {177+ let p = people.get(o);178+ if (!p) people.set(o, (p = { repos: {}, joined: null }));179+ return p;180+ };181+ for (const slug of pub) of(slug.split('/')[0]);182+ for (const [repo, mins] of await lake.rows(`SELECT repo, list(floor(epoch(t) / 60)::BIGINT ORDER BY t) FROM lake.commits WHERE ${inList('repo', pub)} AND t >= ${since()} GROUP BY repo ORDER BY repo`)) {183+ const [o, name] = String(repo).split('/');184+ of(o).repos[name] = encodeMinutes((mins as unknown[]).map(Number));185+ }186+ for (const [who, t] of await lake.rows(`187+ SELECT who, epoch_ms(min(t)) FROM (188+ SELECT "user" AS who, t FROM lake.events WHERE ${inList('repo', pub)} AND "user" IS NOT NULL189+ UNION ALL SELECT split_part(repo, '/', 1), t FROM lake.events WHERE ${inList('repo', pub)}190+ ) WHERE t IS NOT NULL GROUP BY who`)) {191+ if (people.has(String(who))) of(String(who)).joined = Number(t);192+ }193+ for (const [o, p] of people) fs.writeFileSync(path.join(tmp, `${o}.json`), JSON.stringify(p) + '\n');194+ const old = `${dir}.old`;195+ fs.rmSync(old, { recursive: true, force: true });196+ if (fs.existsSync(dir)) fs.renameSync(dir, old);197+ fs.renameSync(tmp, dir);198+ fs.rmSync(old, { recursive: true, force: true });199+ return people.size;200+}201+202+// Ascending minutes as the first, then each one's distance from the one203+// before (decodeMinutes in lib/activity.ts undoes it).204+export const encodeMinutes = (mins: number[]): number[] => mins.map((m, i) => (i ? m - mins[i - 1] : m));205+113206 const exportFingerprint = (repos: string[]): string => repos.join('\n');114207 115208 // Export the forge's insights, and those of each repo in `touched` (and of@@ -136,10 +229,11 @@ export async function exportInsights(lake: Lake, touched: Set<string>): Promise<
136229 fs.writeFileSync(mark, exportFingerprint(pub));137230 count++;138231 }232+ await exportPeople(lake, pub);139233 // Repos that are gone.140234 const live = new Set(repos.map((r) => r.slug));141235 for (const o of fs.readdirSync(cfg.exportDir, { withFileTypes: true })) {142- if (!o.isDirectory() || o.name === 'insights' || o.name.endsWith('.tmp') || o.name.endsWith('.old')) continue;236+ if (!o.isDirectory() || o.name === 'insights' || o.name === 'people' || o.name.endsWith('.tmp') || o.name.endsWith('.old')) continue;143237 for (const r of fs.readdirSync(path.join(cfg.exportDir, o.name))) {144238 if (!live.has(`${o.name}/${r}`)) fs.rmSync(path.join(cfg.exportDir, o.name, r), { recursive: true, force: true });145239 }@@ -172,6 +266,15 @@ export function publishInsights(cfg: LakeConfig): number {
172266 };173267 const forge = path.join(cfg.exportDir, 'insights');174268 if (fs.existsSync(cfg.www) && fs.existsSync(forge)) put(forge, path.join(cfg.www, 'insights'));269+ // An owner's activity, beside their page (/<owner>/activity.json).270+ const people = path.join(cfg.exportDir, 'people');271+ for (const f of fs.existsSync(people) ? fs.readdirSync(people) : []) {272+ const page = path.join(cfg.www, f.slice(0, -'.json'.length));273+ if (!f.endsWith('.json') || !fs.existsSync(path.join(page, 'index.html'))) continue;274+ const tmp = path.join(page, `.activity.json.${process.pid}`);275+ fs.copyFileSync(path.join(people, f), tmp);276+ fs.renameSync(tmp, path.join(page, 'activity.json'));277+ }175278 for (const r of listRepos(cfg.root)) {176279 const from = path.join(cfg.exportDir, r.slug, 'insights');177280 const pages = path.join(r.public ? cfg.www : cfg.priv, r.slug);@@ -377,3 +377,22 @@ export function ciRows(slug: string, dir: string, known: Set<string>, verifier:
377377 }378378 return out;379379 }380+381+export interface CommitRow {382+ repo: string;383+ sha: string;384+ t: string | null;385+ author: string | null;386+ subject: string | null;387+}388+389+// The commits reachable from tip and not from since (all of them without390+// it), first parent or not: every commit the default branch took in.391+export function commitRows(slug: string, dir: string, tip: string, since: string | null): CommitRow[] {392+ const out = tryRun(dir, ['log', '-z', '--no-color', '--format=%H%x1f%at%x1f%an%x1f%s', tip, ...(since ? [`^${since}`] : []), '--']);393+ if (!out) return [];394+ return out.split('\0').filter(Boolean).map((rec) => {395+ const [sha, at, author, subject] = rec.replace(/^\n/, '').split('\x1f');396+ return { repo: slug, sha, t: ts(Number(at) * 1000), author: author || null, subject: subject ?? null };397+ });398+}@@ -24,10 +24,10 @@ import { createRequire } from 'node:module';
2424 import { fileURLToPath } from 'node:url';2525 import { DuckDBInstance, StatementType, type DuckDBConnection } from '@duckdb/node-api';2626 import { Verifier } from './attest.ts';27-import { tryRun } from './git.ts';27+import { isAncestor, tryRun } from './git.ts';2828 import {29- buildRows, ciRows, eventRow, listRepos, notesTip, readNew, reviewEnd, reviewLogs, reviewRows,30- type BuildRepoRow, type BuildRow, type CiRow, type EventRow, type Offset, type Read, type ReviewEnd, type ReviewRow,29+ buildRows, ciRows, commitRows, eventRow, listRepos, notesTip, readNew, reviewEnd, reviewLogs, reviewRows,30+ type BuildRepoRow, type BuildRow, type CiRow, type CommitRow, type EventRow, type Offset, type Read, type ReviewEnd, type ReviewRow,3131 } from './lake-sources.ts';3232 import { exportInsights, publishInsights } from './insights.ts';3333 @@ -85,6 +85,9 @@ const TABLES: Record<string, [string, string][]> = {
8585 // Review logs: created, rev, approve, deleted; and how each review ended.8686 reviews: [['repo', 'VARCHAR'], ['review', 'INTEGER'], ['kind', 'VARCHAR'], ['ts', 'TIMESTAMP'], ['user', 'VARCHAR'], ['rev', 'INTEGER'], ['sha', 'VARCHAR'], ['base', 'VARCHAR'], ['tree', 'VARCHAR'], ['branch', 'VARCHAR'], ['title', 'VARCHAR']],8787 review_ends: [['repo', 'VARCHAR'], ['review', 'INTEGER'], ['kind', 'VARCHAR'], ['ts', 'TIMESTAMP'], ['user', 'VARCHAR'], ['sha', 'VARCHAR'], ['observed', 'BOOLEAN']],88+ // Commits on each repo's default branch, as it moved: its author's time89+ // (the day it counts for on activity grids) and subject.90+ commits: [['repo', 'VARCHAR'], ['sha', 'VARCHAR'], ['t', 'TIMESTAMP'], ['author', 'VARCHAR'], ['subject', 'VARCHAR']],8891 // refs/notes/ci: one row an attestation.8992 ci: [['id', 'VARCHAR'], ['repo', 'VARCHAR'], ['commit', 'VARCHAR'], ['tree', 'VARCHAR'], ['result', 'VARCHAR'], ['exit', 'INTEGER'], ['started', 'TIMESTAMP'], ['duration_s', 'INTEGER'], ['runner', 'VARCHAR'], ['system', 'VARCHAR'], ['env', 'VARCHAR'], ['signer', 'VARCHAR'], ['verified', 'BOOLEAN']],9093 // Where each source was read up to (lib/lake-sources.ts), written in the@@ -212,6 +215,7 @@ export interface Summary {
212215 reviews: number;213216 ended: number;214217 ci: number;218+ commits: number;215219 bad: number;216220 notes: string[];217221 // Repos whose facts changed, for the exports.@@ -232,7 +236,7 @@ export async function ingest(lake: Lake, { dry = false, now = Date.now() }: { dr
232236 for (const [source, ino, pos, head, tail] of await lake.rows(`SELECT source, ino, pos, head, tail FROM lake.sources`)) {233237 sources.set(String(source), { source: String(source), ino: Number(ino), pos: Number(pos), head: String(head ?? ''), tail: JSON.parse(String(tail ?? '[]')) });234238 }235- const sum: Summary = { events: 0, builds: 0, reviews: 0, ended: 0, ci: 0, bad: 0, notes: [], touched: new Set() };239+ const sum: Summary = { events: 0, builds: 0, reviews: 0, ended: 0, ci: 0, commits: 0, bad: 0, notes: [], touched: new Set() };236240 const next = new Map<string, Offset>();237241 const events: EventRow[] = [];238242 const builds: BuildRow[] = [];@@ -241,6 +245,7 @@ export async function ingest(lake: Lake, { dry = false, now = Date.now() }: { dr
241245 const redo: [string, number][] = [];242246 const ends: ReviewEnd[] = [];243247 const ci: CiRow[] = [];248+ const commits: CommitRow[] = [];244249 245250 const jsonl = <T>(name: string, file: string, parse: (line: string) => T | null, take: (row: T) => void): void => {246251 const r: Read | null = readNew(file, offsetOf(sources.get(name)));@@ -297,6 +302,17 @@ export async function ingest(lake: Lake, { dry = false, now = Date.now() }: { dr
297302 }298303 }299304 }305+ // Commits: what the default branch gained since the last run (all of306+ // it, less what the lake has, when it was rewritten or is new).307+ const ckey = `commits ${repo.slug}`;308+ const was = sources.get(ckey)?.head ?? '';309+ if (tip && was !== tip) {310+ const have = was && isAncestor(repo.dir, was, tip) ? null : new Set((await lake.rows(`SELECT sha FROM lake.commits WHERE repo = ${lit(repo.slug)}`)).map((r) => String(r[0])));311+ const rows = commitRows(repo.slug, repo.dir, tip, have ? null : was).filter((c) => !have?.has(c.sha));312+ commits.push(...rows);313+ if (rows.length) sum.touched.add(repo.slug);314+ next.set(ckey, { ino: 0, pos: 0, head: tip, tail: [] });315+ }300316 const notes = notesTip(repo.dir);301317 const nkey = `notes ${repo.slug}`;302318 if (notes && sources.get(nkey)?.head !== notes) {@@ -317,6 +333,7 @@ export async function ingest(lake: Lake, { dry = false, now = Date.now() }: { dr
317333 sum.builds = builds.length;318334 sum.ended = ends.length;319335 sum.ci = ci.length;336+ sum.commits = commits.length;320337 if (!next.size) return sum;321338 322339 const scratch = fs.mkdtempSync(path.join(cfg.dir, 'tmp.'));@@ -333,6 +350,7 @@ export async function ingest(lake: Lake, { dry = false, now = Date.now() }: { dr
333350 await lake.insert('reviews', reviews, scratch);334351 await lake.insert('review_ends', ends, scratch);335352 await lake.insert('ci', ci, scratch);353+ await lake.insert('commits', commits, scratch);336354 const names = [...next.keys()];337355 for (let i = 0; i < names.length; i += 500) {338356 await lake.run(`DELETE FROM lake.sources WHERE source IN (${names.slice(i, i + 500).map(lit).join(', ')})`);@@ -519,7 +537,7 @@ async function main(argv: string[]): Promise<void> {
519537 const lake = await Lake.open(scratch ? { ...cfg, dir: scratch, catalog: path.join(scratch, 'catalog.sqlite'), data: scratch + '/data/', local: true } : cfg, { write: true });520538 try {521539 const s = await ingest(lake, { dry, now });522- const what = [plural(s.events, 'event'), plural(s.builds, 'build'), plural(s.reviews, 'review log line'), `${s.ended} ended`, plural(s.ci, 'attestation')].join(', ');540+ const what = [plural(s.events, 'event'), plural(s.builds, 'build'), plural(s.reviews, 'review log line'), `${s.ended} ended`, plural(s.ci, 'attestation'), plural(s.commits, 'commit')].join(', ');523541 for (const n of s.notes) console.log(`lake: ${n}`);524542 if (s.bad) console.log(`lake: skipped ${plural(s.bad, 'line')} that aren't JSON`);525543 if (dry) {@@ -0,0 +1,183 @@
1+// What a person says about themselves on their page (/<owner>/), which2+// they set over ssh (bin/forge profile): a bio line, a link and the repos3+// they pin. Kept in .forge/profiles/<user>, which backups carry, a line4+// each ("bio <text>", "link <url>", "pin <repo> <repo>"), with "joined5+// <unix seconds>", the time their first key was added (forge user add).6+//7+// node lib/profile.ts <user> print it8+// node lib/profile.ts <user> bio <text>|--clear set or clear a field9+// node lib/profile.ts <user> link <url>|--clear10+// node lib/profile.ts <user> pin <repo>...|--clear11+// node lib/profile.ts --joined <user> note now as when they joined, unless noted12+import fs from 'node:fs';13+import path from 'node:path';14+import { fileURLToPath } from 'node:url';15+16+export const BIO_MAX = 120;17+export const LINK_MAX = 200;18+export const PINS = 3;19+20+export interface Profile {21+ bio: string;22+ link: string;23+ pins: string[];24+ // Unix seconds, 0 for not known.25+ joined: number;26+}27+28+const file = (state: string, user: string): string => path.join(state, 'profiles', user);29+30+export function readProfile(state: string, user: string): Profile {31+ const p: Profile = { bio: '', link: '', pins: [], joined: 0 };32+ let text = '';33+ try {34+ text = fs.readFileSync(file(state, user), 'utf8');35+ } catch {36+ return p;37+ }38+ for (const line of text.split('\n')) {39+ const sp = line.indexOf(' ');40+ const [k, v] = sp < 0 ? [line, ''] : [line.slice(0, sp), line.slice(sp + 1)];41+ if (k === 'bio' && check.bio(v).ok) p.bio = v;42+ else if (k === 'link' && check.link(v).ok) p.link = v;43+ else if (k === 'pin') p.pins = v.split(' ').filter((n) => NAME.test(n)).slice(0, PINS);44+ else if (k === 'joined' && /^\d+$/.test(v)) p.joined = Number(v);45+ }46+ return p;47+}48+49+function writeProfile(state: string, user: string, p: Profile): void {50+ const lines = [p.bio && `bio ${p.bio}`, p.link && `link ${p.link}`, p.pins.length && `pin ${p.pins.join(' ')}`, p.joined && `joined ${p.joined}`].filter(Boolean);51+ const f = file(state, user);52+ fs.mkdirSync(path.dirname(f), { recursive: true });53+ fs.writeFileSync(`${f}.tmp`, lines.length ? lines.join('\n') + '\n' : '');54+ fs.renameSync(`${f}.tmp`, f);55+}56+57+const NAME = /^[A-Za-z0-9._-]+$/;58+const CONTROL = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;59+60+type Checked = { ok: true; value: string } | { ok: false; error: string };61+62+export const check = {63+ bio(text: string): Checked {64+ const s = text.trim();65+ const n = [...s].length;66+ if (CONTROL.test(s)) return { ok: false, error: 'a bio is one line of plain text, without control characters' };67+ if (!n) return { ok: false, error: 'an empty bio; profile bio --clear removes it' };68+ if (n > BIO_MAX) return { ok: false, error: `a bio of ${n} characters; it can have ${BIO_MAX} at most` };69+ return { ok: true, value: s };70+ },71+ link(text: string): Checked {72+ const s = text.trim();73+ if (!s) return { ok: false, error: 'no link; profile link --clear removes it' };74+ if (CONTROL.test(s) || /\s/.test(s)) return { ok: false, error: 'a link is one https:// address, without spaces' };75+ if (s.length > LINK_MAX) return { ok: false, error: `a link of ${s.length} characters; it can have ${LINK_MAX} at most` };76+ let u: URL;77+ try {78+ u = new URL(s);79+ } catch {80+ return { ok: false, error: `${s} isn't an address; want https://...` };81+ }82+ if (u.protocol !== 'https:' || !/^https:\/\//i.test(s)) return { ok: false, error: 'only https:// links' };83+ if (u.username || u.password) return { ok: false, error: 'a link without a user name or password in it' };84+ if (!u.hostname.includes('.') || u.hostname.endsWith('.')) return { ok: false, error: `${u.hostname} isn't a host name the web can reach` };85+ return { ok: true, value: u.href };86+ },87+};88+89+// A link as the page shows it: no scheme, no lone trailing slash.90+export const linkText = (href: string): string => href.replace(/^https:\/\//, '').replace(/^([^/?#]+)\/$/, '$1');91+92+// Check pins against the user's repos: each one theirs, public, once.93+function checkPins(root: string, user: string, names: string[]): { ok: true; value: string[] } | { ok: false; error: string } {94+ if (names.length > PINS) return { ok: false, error: `${names.length} repos; pin ${PINS} at most` };95+ const out: string[] = [];96+ for (const raw of names) {97+ const [o, n] = raw.includes('/') ? raw.split('/', 2) : [user, raw];98+ const name = n.replace(/\.git$/, '');99+ if (o !== user) return { ok: false, error: `${raw} isn't yours; pin your own repos` };100+ if (!NAME.test(name) || name.startsWith('.')) return { ok: false, error: `${raw} isn't a repo name` };101+ const dir = path.join(root, user, `${name}.git`);102+ if (!fs.existsSync(path.join(dir, 'HEAD'))) return { ok: false, error: `you have no repo ${name}` };103+ if (!fs.existsSync(path.join(dir, 'git-daemon-export-ok'))) return { ok: false, error: `${name} is private; only public repos are pinned` };104+ if (out.includes(name)) return { ok: false, error: `${name} is there twice` };105+ out.push(name);106+ }107+ return { ok: true, value: out };108+}109+110+const FIELDS = 'profile [bio <text>|link <url>|pin <repo>...] (--clear clears one)';111+112+// One profile command; what it prints, or an error. changed: write it, and113+// rebuild the page.114+export function command(root: string, state: string, user: string, args: string[]): { out: string[]; note: string; changed: boolean } | { error: string } {115+ const p = readProfile(state, user);116+ if (!args.length) {117+ return {118+ out: [119+ `bio: ${p.bio || '(none)'}`,120+ `link: ${p.link || '(none)'}`,121+ `pin: ${p.pins.length ? p.pins.join(' ') : `(none: your page shows your ${PINS} most recently active repos)`}`,122+ ],123+ note: '',124+ changed: false,125+ };126+ }127+ const [field, ...rest] = args;128+ const clear = rest.length === 1 && rest[0] === '--clear';129+ if (field !== 'bio' && field !== 'link' && field !== 'pin') return { error: `usage: ${FIELDS}` };130+ if (!rest.length || (rest.includes('--clear') && !clear)) return { error: `usage: profile ${field} ${field === 'bio' ? '<text>' : field === 'link' ? '<url>' : '<repo> [<repo> <repo>]'} | profile ${field} --clear` };131+ let note: string;132+ if (field === 'bio') {133+ if (clear) [p.bio, note] = ['', 'your page has no bio now'];134+ else {135+ const r = check.bio(rest.join(' '));136+ if (!r.ok) return { error: r.error };137+ [p.bio, note] = [r.value, `your page's bio: ${r.value}`];138+ }139+ } else if (field === 'link') {140+ if (clear) [p.link, note] = ['', 'your page has no link now'];141+ else {142+ if (rest.length > 1) return { error: 'one link, without spaces' };143+ const r = check.link(rest[0]);144+ if (!r.ok) return { error: r.error };145+ [p.link, note] = [r.value, `your page links to ${linkText(r.value)}`];146+ }147+ } else if (clear) [p.pins, note] = [[], `your page shows your ${PINS} most recently active repos`];148+ else {149+ const r = checkPins(root, user, rest);150+ if (!r.ok) return { error: r.error };151+ [p.pins, note] = [r.value, `your page pins ${r.value.join(', ')}`];152+ }153+ writeProfile(state, user, p);154+ return { out: [], note, changed: true };155+}156+157+if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {158+ const root = path.resolve(process.env.FORGE_ROOT || 'demo/root');159+ const state = process.env.FORGE_STATE || path.join(root, '.forge');160+ const [user, ...args] = process.argv.slice(2);161+ if (user === '--joined') {162+ const who = args[0];163+ if (!who || !NAME.test(who)) process.exit(2);164+ const p = readProfile(state, who);165+ if (!p.joined) {166+ p.joined = Number(process.env.FORGE_NOW) || Math.floor(Date.now() / 1000);167+ writeProfile(state, who, p);168+ }169+ } else if (!user || !NAME.test(user)) {170+ console.error('usage: node lib/profile.ts <user> [bio <text>|link <url>|pin <repo>...] | --joined <user>');171+ process.exit(2);172+ } else {173+ const r = command(root, state, user, args);174+ if ('error' in r) {175+ console.error(`forge: profile: ${r.error}`);176+ process.exit(1);177+ }178+ for (const l of r.out) console.log(l);179+ if (r.note) console.error(r.note);180+ // 3: changed, so the page is rebuilt (bin/forge cmd_profile).181+ if (r.changed) process.exit(3);182+ }183+}@@ -367,6 +367,8 @@ export interface RepoSummary {
367367 sites?: SiteLink[];368368 // What the repo is, by Jev (lib/jev-commits.ts).369369 topic?: string | null;370+ // Its newest release with files, for the pins on its owner's page.371+ release?: { tag: string; date: string; href: string } | null;370372 // Its shelf on the index, from the bare repo's category file (its owner's371373 // word, lowercase; '' when there is none).372374 category: string;@@ -1,6 +1,7 @@
11 {#- The layout every page extends. A page fills the content block, and may22 replace head (its description and link preview), scripts (any script only3- it needs) and heading (the title in the header). -#}3+ it needs), heading (the title in the header) and front (what is between4+ a repo's title and its tabs). -#}45 {% import "macros.njk" as m with context %}56 {% import "ui.njk" as ui %}67 {% set r = p.repo if p else null %}@@ -53,7 +54,8 @@
5354 </details>5455 {% endif %}5556 </div>56-{% if r and r.description %}<p class="repo-desc">{{ r.description }}</p>{% endif %}57+{# A repo's code page puts its front matter here (repo/home.njk). #}58+{% block front %}{% if r and r.description %}<p class="repo-desc">{{ r.description }}</p>{% endif %}{% endblock %}5759 {% if r %}5860 {{ ui.tabs('repository', [5961 {href: r.urls.home.href, text: 'code', key: 'f', cur: p.nav == 'files' or p.nav == 'home'},@@ -53,6 +53,21 @@
5353 <div class="insights" data-insights="{{ base }}insights.json"{% if forgeWide %} data-forge="1"{% endif %}>5454 <p class="desc ins-meta" id="ins-meta">From the forge's lake (<code>forge lake</code>).<noscript> The charts need JavaScript; the numbers are in the files below.</noscript></p>5555 <div class="panel kpis" id="ins-kpis"></div>56+{% if not forgeWide and p.activity %}57+{# The repo's activity grid (ui.activity): what happened each day; a day58+ picked lists its events under it, in place of the latest ones. #}59+{% set a = p.activity %}60+{% set head %}<b class="act-total">{{ a.grid.total | thousands }} {{ 'event' if a.grid.total == 1 else 'events' }}</b><span>in the last year: pushes, landed reviews, releases, site deploys and ci results</span>{% endset %}61+{% call ui.panel(head, 'activity-panel') %}<div class="panel-body">{{ ui.activity(a.grid, 'events a day', 'data-src="activity.json" data-base="' + p.repo.urls.home.href + '"') }}</div>{% endcall %}62+<section class="panel act-list">63+<header class="panel-head list-head"><h3>latest</h3></header>64+<header class="panel-head day-head" hidden><h3 class="day-title"></h3><span class="day-note"></span><span class="end"><a href="#" data-day-reset>show all</a></span></header>65+<ul class="rows ev-rows">66+{% for e in a.recent %}<li class="ev"><span class="ev-kind">{{ e.kind }}</span>{% if e.href %}<a class="ev-what" href="{{ e.href }}">{{ e.text }}</a>{% else %}<span class="ev-what">{{ e.text }}</span>{% endif %}{{ e.at | date }}</li>67+{% endfor %}</ul>68+<p class="empty day-empty"{% if a.recent.length %} hidden{% endif %}>Nothing happened {{ 'that day' if a.recent.length else 'in the last year' }}.</p>69+</section>70+{% endif %}5671 <div class="charts">5772 <figure class="panel" data-chart="builds"><figcaption class="panel-head"><h3>build time</h3><span class="grow">seconds per build of {{ 'the pages' if forgeWide else "this repo's pages" }}</span></figcaption><div class="plot"></div><details class="table"><summary>table</summary></details></figure>5873 <figure class="panel" data-chart="pushes"><figcaption class="panel-head"><h3>pushes</h3><span class="grow">pushes a <span class="ins-bucket">day</span></span></figcaption><div class="plot"></div><details class="table"><summary>table</summary></details></figure>@@ -141,12 +141,17 @@
141141 {# Owner pages and /topics/: repos as rows, one panel at a time. Tabs142142 above name the shelves (a repo's kind), "all" first: "all" is every repo143143 in one panel, newest first, so what changed lately is at the top; a144- shelf's tab shows its repos alone. Without JS the page shows "all", or145- the shelf its address names (/cat/#games, :target); with it (ui.ts) the146- tabs pick the panel. Beside them, how to look at the list, which needs147- JS and shows with it: the globe keeps only repos with a site, and the148- sort is newest first or by name. keys.ts walks the rows with j and k. #}149-{% macro shelves(repos, owners = false) %}144+ shelf's tab shows its repos alone. The tabs name the panel, so it has no145+ head strip. Without JS the page shows "all", or the shelf its address146+ names (/cat/#games, :target); with it (ui.ts) the tabs pick the panel.147+ Beside them, how to look at the list, which needs JS and shows with it:148+ the globe keeps only repos with a site (the tabs' counts follow it), and149+ the sort is newest first or by name. keys.ts walks the rows with j and k.150+ activity (an owner's page): their activity grid between the tabs and151+ the list, counting the picked tab's repos; a day picked in it narrows152+ the list to the repos with commits that day, and only then does the153+ list have a head strip: the day, how many, and "show all". #}154+{% macro shelves(repos, owners = false, activity = null) %}150155 {% css %}151156 .shelf-bar { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-6); align-items: center; margin: 0 0 var(--space-4); border-bottom: var(--hairline) solid var(--border); }152157 .shelf-bar nav.tabs { flex: 0 1 auto; min-width: 0; }@@ -155,6 +160,11 @@
155160 .shelf-tools { display: flex; gap: var(--space-2); align-items: center; padding: var(--space-1) 0; }156161 .shelf-tools[hidden] { display: none; }157162 .shelf { scroll-margin-top: var(--space-3); }163+.shelf-bar + .activity-panel { margin-top: calc(-1 * var(--space-1)); }164+/* A day picked in the activity grid: each repo's commits that day, in165+ place of its sites, reviews and time. */166+.card.on-day .card-foot > :not(.card-day) { display: none; }167+.card-day { min-width: var(--col-time); text-align: right; font-variant-numeric: tabular-nums; color: var(--muted); }158168 /* Without JS: "all", unless the address names a shelf. ui.ts marks the159169 page with shelves-js and picks the panel itself. */160170 .shelves:not(.shelves-js) > .shelf[data-shelf]:not([data-shelf=""]):not(:target) { display: none; }@@ -201,27 +211,37 @@
201211 {{ ui.segmented('sort', [{icon: 'clock', label: 'newest first', pressed: true, attrs: 'data-sort="date"'}, {icon: 'az', label: 'by name, a to z', attrs: 'data-sort="name"'}]) }}202212 </span>203213 </div>214+{% if activity %}215+{% set head %}<b class="act-total">{{ activity.grid.total | thousands }} {{ 'commit' if activity.grid.total == 1 else 'commits' }}</b><span>in the last year</span>{% endset %}216+{% call ui.panel(head, 'activity-panel') %}<div class="panel-body">{{ ui.activity(activity.grid, 'commits a day', 'data-src="activity.json"') }}</div>217+<script type="application/json" class="activity-data">{{ activity.data | json }}</script>{% endcall %}218+{% endif %}204219 {% if list.length > 1 %}205-{% set head %}<h2>all</h2><span class="n">{{ repos.length }}</span>{% endset %}206-{% call ui.panel(head, 'shelf', 'data-shelf=""') %}{{ cards(repos | sort(true, true, 'lastCommit'), owners) }}{% endcall %}220+{% call ui.panel('', 'shelf', 'data-shelf=""') %}{{ dayHead() if activity }}{{ cards(repos | sort(true, true, 'lastCommit'), owners, activity) }}{% endcall %}207221 {% endif %}208222 {% for sh in list %}209-{% set head %}<h2>{{ sh.title }}</h2><span class="n">{{ sh.repos.length }}</span>{% endset %}210223 {# A lone shelf is all there is: it shows whatever the address says. #}211-{% call ui.panel(head, 'shelf', 'data-shelf="' + (sh.id if list.length > 1 else '') + '" id="' + sh.id + '"') %}{{ cards(sh.repos, owners) }}{% endcall %}224+{% call ui.panel('', 'shelf', 'data-shelf="' + (sh.id if list.length > 1 else '') + '" id="' + sh.id + '"') %}{{ dayHead() if activity }}{{ cards(sh.repos, owners, activity) }}{% endcall %}212225 {% endfor %}213226 <div class="panel shelves-empty" hidden><p class="empty">No repos here have a site.</p></div>214227 </div>215228 {% endmacro %}216229 217-{# A shelf's repos as rows: name, what it is, its sites, reviews and last push. #}218-{% macro cards(repos, owners) %}230+{# The head strip a shelf's panel has while a day is picked in the activity231+ grid (ui.ts fills it in), and what it says when no repo had commits. #}232+{% macro dayHead() %}<header class="panel-head day-head" hidden><h2 class="day-title"></h2><span class="day-note"></span><span class="end"><a href="#shelves" data-day-reset>show all</a></span></header>233+<p class="empty day-empty" hidden>No commits that day.</p>234+{% endmacro %}235+236+{# A shelf's repos as rows: name, what it is, its sites, reviews and last237+ push. activity: with room for a picked day's commits. #}238+{% macro cards(repos, owners, activity = null) %}219239 <ul class="rows cards nav-cards">220240 {% for s in repos %}221-<li class="card" data-name="{{ s.name | lower }}" data-date="{{ s.lastCommit }}"{% if s.counts.sites %} data-site{% endif %}>241+<li class="card" data-name="{{ s.name | lower }}" data-date="{{ s.lastCommit }}"{% if activity %} data-repo="{{ s.name }}"{% endif %}{% if s.counts.sites %} data-site{% endif %}>222242 <div class="card-top"><a class="card-name" href="{{ s.urls.home.href }}">{% if owners %}<span class="desc">{{ s.owner }} / </span>{% endif %}{{ s.name }}</a></div>223243 {% if s.description %}<p class="card-desc">{{ s.description }}</p>{% endif %}224-<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>244+<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 }}{% if activity %}<span class="card-day" hidden></span>{% endif %}</div>225245 </li>226246 {% endfor %}227247 </ul>@@ -109,3 +109,19 @@
109109 {% macro heading(text, n = null, desc = '', id = '') -%}110110 <h2{% if id %} id="{{ id }}"{% endif %}>{{ text }}{% if n != null %} <span class="n">{{ n }}</span>{% endif %}{% if desc %} <span class="desc">{{ desc }}</span>{% endif %}</h2>111111 {%- endmacro %}112+113+{# The activity grid: a year of days, a column a week (Sunday at the top),114+ each day a button coloured by how much happened (--heat-0 to -4), named115+ "2026-10-08: 11 commits". grid is lib/activity.ts's. Without JS it is a116+ picture; with it (ui.ts) clicking a day, or Enter or Space on it, picks117+ it (an ink outline) and narrows the list under it, the arrow keys move118+ between days, and the address keeps the day (#2026-10-08, or119+ #<shelf>/2026-10-08 on an owner's page with a shelf picked). Cells are120+ 13px at most, left-aligned; narrower, they shrink to 10px, then the121+ oldest weeks go (style.css). attrs: data-src="activity.json", what ui.ts122+ fetches for fresher counts (and a repo's events). #}123+{% macro activity(grid, label, attrs = '') -%}124+<div class="activity-fit"><div class="activity" role="group" aria-label="{{ label }}" data-activity data-noun="{{ grid.noun }}"{% if attrs %} {{ attrs | safe }}{% endif %}>125+{%- for w in grid.weeks %}{% for c in w %}{% set what = c.day + ': ' + (c.n | plural(grid.noun)) %}<button type="button" class="l{{ c.level }} a{{ c.age }}" data-day="{{ c.day }}" data-n="{{ c.n }}" aria-pressed="false" tabindex="{{ '0' if c.day == grid.today else '-1' }}" aria-label="{{ what }}" title="{{ what }}"></button>{% endfor %}{% endfor -%}126+</div></div>127+{%- endmacro %}@@ -55,13 +55,26 @@
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;58+ /* avatars (lib/avatar.ts): bauhaus tiles drawn from a name, on a paper59+ ground with an ink and three accents; in dark mode the ground and ink60+ swap, so the art keeps its figure and ground */61+ --avatar-ground: #eee8dc;62+ --avatar-ink: #171717;63+ --avatar-red: #c83b2e;64+ --avatar-blue: #1e5484;65+ --avatar-yellow: #dfb631;66+ /* the activity grid (ui.activity): a day with nothing, then four steps67+ of the accent as the day gets busier */68+ --heat-0: #e7e8eb;69+ --heat-1: #c3cafd;70+ --heat-2: #8796fa;71+ --heat-3: #5468f7;72+ --heat-4: #2a3fd6;73+ /* languages on a repo's front matter: the three biggest, then the rest */74+ --lang-1: #1e5484;75+ --lang-2: #c83b2e;76+ --lang-3: #dfb631;77+ --lang-rest: #a9aeb4;6578 /* a linked line, floating panels, dialogs' backdrop, masks */6679 --target-bg: rgba(255, 213, 79, 0.25);6780 --target-gut: rgba(255, 213, 79, 0.4);@@ -162,6 +175,20 @@
162175 --viz-fail: #f0606e;163176 --viz-grid: #1a1e23;164177 --viz-axis: #3a4048;178+ --avatar-ground: #2b2823;179+ --avatar-ink: #ece6da;180+ --avatar-red: #df5443;181+ --avatar-blue: #5c8fd3;182+ --avatar-yellow: #e4bb3d;183+ --heat-0: #1b1f25;184+ --heat-1: #232f73;185+ --heat-2: #3046b8;186+ --heat-3: #5a6ff2;187+ --heat-4: #9aa6ff;188+ --lang-1: #5c8fd3;189+ --lang-2: #df5443;190+ --lang-3: #e4bb3d;191+ --lang-rest: #4a5058;165192 }166193 }167194 /* == end tokens == */@@ -286,6 +313,99 @@ details.picker #branch-menu { position: absolute; z-index: 10; top: calc(100% +
286313 details.picker #branch-menu a { padding: var(--space-1) var(--space-3); font-family: var(--mono); font-size: var(--text-sm); }287314 details.picker #branch-menu a:hover { background: var(--hover); text-decoration: none; }288315 316+/* avatars (lib/avatar.ts): a person's bauhaus tiles, square, on the317+ five avatar tokens; the drawing's own classes pick the colour. */318+svg.avatar { display: block; flex: none; }319+svg.avatar .av-g { fill: var(--avatar-ground); }320+svg.avatar .av-i { fill: var(--avatar-ink); }321+svg.avatar .av-r { fill: var(--avatar-red); }322+svg.avatar .av-b { fill: var(--avatar-blue); }323+svg.avatar .av-y { fill: var(--avatar-yellow); }324+325+/* the activity grid (ui.activity): a column a week, a square button a326+ day, coloured by how busy it was. Cells are 13px with --space-half gaps,327+ left in the panel; in a narrower box they shrink to fit all 53 weeks,328+ down to 10px, and below that the oldest weeks go (a<n>: n weeks before329+ this one), a week each 12px, so the latest always show. A picked day has330+ an ink outline. */331+.activity-fit { container: activity / inline-size; }332+.activity { --cell: 13px; display: grid; grid-template-rows: repeat(7, var(--cell)); grid-auto-columns: var(--cell); grid-auto-flow: column; gap: var(--space-half); justify-content: start; }333+@container activity (width < 793px) { .activity { --cell: max(10px, calc((100cqw + 2px) / 53 - 2px)); } }334+.activity > button { display: block; min-height: 0; width: 100%; height: 100%; padding: 0; border: 0; background: var(--heat-0); cursor: pointer; }335+.activity > button.l1 { background: var(--heat-1); }336+.activity > button.l2 { background: var(--heat-2); }337+.activity > button.l3 { background: var(--heat-3); }338+.activity > button.l4 { background: var(--heat-4); }339+.activity > button:hover { outline: var(--hairline) solid var(--muted); outline-offset: 0; }340+.activity > button[aria-pressed="true"] { outline: 2px solid var(--fg); outline-offset: 0; position: relative; z-index: 1; }341+.activity > button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; position: relative; z-index: 2; }342+@container activity (width < 634px) { .activity > .a52 { display: none; } }343+@container activity (width < 622px) { .activity > .a51 { display: none; } }344+@container activity (width < 610px) { .activity > .a50 { display: none; } }345+@container activity (width < 598px) { .activity > .a49 { display: none; } }346+@container activity (width < 586px) { .activity > .a48 { display: none; } }347+@container activity (width < 574px) { .activity > .a47 { display: none; } }348+@container activity (width < 562px) { .activity > .a46 { display: none; } }349+@container activity (width < 550px) { .activity > .a45 { display: none; } }350+@container activity (width < 538px) { .activity > .a44 { display: none; } }351+@container activity (width < 526px) { .activity > .a43 { display: none; } }352+@container activity (width < 514px) { .activity > .a42 { display: none; } }353+@container activity (width < 502px) { .activity > .a41 { display: none; } }354+@container activity (width < 490px) { .activity > .a40 { display: none; } }355+@container activity (width < 478px) { .activity > .a39 { display: none; } }356+@container activity (width < 466px) { .activity > .a38 { display: none; } }357+@container activity (width < 454px) { .activity > .a37 { display: none; } }358+@container activity (width < 442px) { .activity > .a36 { display: none; } }359+@container activity (width < 430px) { .activity > .a35 { display: none; } }360+@container activity (width < 418px) { .activity > .a34 { display: none; } }361+@container activity (width < 406px) { .activity > .a33 { display: none; } }362+@container activity (width < 394px) { .activity > .a32 { display: none; } }363+@container activity (width < 382px) { .activity > .a31 { display: none; } }364+@container activity (width < 370px) { .activity > .a30 { display: none; } }365+@container activity (width < 358px) { .activity > .a29 { display: none; } }366+@container activity (width < 346px) { .activity > .a28 { display: none; } }367+@container activity (width < 334px) { .activity > .a27 { display: none; } }368+@container activity (width < 322px) { .activity > .a26 { display: none; } }369+@container activity (width < 310px) { .activity > .a25 { display: none; } }370+@container activity (width < 298px) { .activity > .a24 { display: none; } }371+@container activity (width < 286px) { .activity > .a23 { display: none; } }372+@container activity (width < 274px) { .activity > .a22 { display: none; } }373+@container activity (width < 262px) { .activity > .a21 { display: none; } }374+@container activity (width < 250px) { .activity > .a20 { display: none; } }375+@container activity (width < 238px) { .activity > .a19 { display: none; } }376+@container activity (width < 226px) { .activity > .a18 { display: none; } }377+@container activity (width < 214px) { .activity > .a17 { display: none; } }378+@container activity (width < 202px) { .activity > .a16 { display: none; } }379+@container activity (width < 190px) { .activity > .a15 { display: none; } }380+@container activity (width < 178px) { .activity > .a14 { display: none; } }381+@container activity (width < 166px) { .activity > .a13 { display: none; } }382+@container activity (width < 154px) { .activity > .a12 { display: none; } }383+@container activity (width < 142px) { .activity > .a11 { display: none; } }384+@container activity (width < 130px) { .activity > .a10 { display: none; } }385+@container activity (width < 118px) { .activity > .a9 { display: none; } }386+@container activity (width < 106px) { .activity > .a8 { display: none; } }387+@container activity (width < 94px) { .activity > .a7 { display: none; } }388+@container activity (width < 82px) { .activity > .a6 { display: none; } }389+@container activity (width < 70px) { .activity > .a5 { display: none; } }390+@container activity (width < 58px) { .activity > .a4 { display: none; } }391+@container activity (width < 46px) { .activity > .a3 { display: none; } }392+@container activity (width < 34px) { .activity > .a2 { display: none; } }393+@container activity (width < 22px) { .activity > .a1 { display: none; } }394+395+/* event rows: what happened, a row each: its kind (as wide as396+ "release"), what, and when. The rows share the list's columns (subgrid),397+ so every title ends at the same edge; the time is --col-time wide. */398+.ev-rows { display: grid; grid-template-columns: 3.6em minmax(0, 1fr) auto; column-gap: var(--space-3); }399+.ev { position: relative; grid-column: 1 / -1; display: grid; grid-template-columns: 3.6em minmax(0, 1fr) auto; grid-template-columns: subgrid; column-gap: var(--space-3); align-items: baseline; padding: var(--space-2) var(--space-3); }400+.ev:hover, .ev:focus-within { background: var(--hover); }401+.ev-kind { font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--muted); }402+.ev-what { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fg); }403+a.ev-what:hover { color: var(--fg); }404+a.ev-what::after { content: ''; position: absolute; inset: 0; }405+.ev-what b { font-weight: var(--fw-strong); }406+.ev-what .tag { font-family: var(--mono); font-size: var(--text-inline-mono); color: var(--link); }407+.ev time { min-width: var(--col-time); font-size: var(--text-xs); color: var(--faint); text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }408+289409 /* panels (ui.panel): where content sits. Lists, file viewers, READMEs,290410 diffs and discussions sit in panels; the header, a page's title, tabs291411 and toolbars sit on the page. A panel is a raised surface with a@@ -85,15 +85,18 @@
8585 // the page shows "all", or the shelf its address names; here the tabs pick8686 // the panel: "all" (every repo in one) or one shelf's. The globe hides8787 // repos without a site, the sort is newest first or by name, and the88- // panel's head counts what it shows. The choices outlive live.ts swapping89- // in a rebuilt page.90- const pick = { shelf: '', site: false, az: false };88+ // tabs count what they show. On an owner's page the activity grid counts89+ // the picked tab's repos, and a day picked in it narrows the list to the90+ // repos with commits that day. The choices outlive live.ts swapping in a91+ // rebuilt page.92+ const pick = { shelf: '', site: false, az: false, day: '' };9193 const shelves = (): void => {9294 const bar = document.querySelector<HTMLElement>('[data-shelves]');9395 if (!bar) return;9496 bar.closest('.shelves')?.classList.add('shelves-js');9597 for (const b of bar.querySelectorAll<HTMLElement>('[hidden]')) b.hidden = false;96- for (const t of bar.querySelectorAll<HTMLElement>('a[data-shelf]')) {98+ const tabs = [...bar.querySelectorAll<HTMLElement>('a[data-shelf]')];99+ for (const t of tabs) {97100 const cur = t.dataset.shelf === pick.shelf;98101 t.classList.toggle('cur', cur);99102 if (cur) t.setAttribute('aria-current', 'page');@@ -103,6 +106,9 @@
103106 for (const b of bar.querySelectorAll<HTMLElement>('[data-sort]')) b.setAttribute('aria-pressed', String((b.dataset.sort === 'name') === pick.az));104107 const panels = [...document.querySelectorAll<HTMLElement>('section.shelf')];105108 const on = panels.find((sec) => sec.dataset.shelf === pick.shelf) ?? panels[0];109+ const grid = document.querySelector<HTMLElement>('.shelves [data-activity]');110+ const data = grid ? ownerData() : null;111+ const day = data && pick.day ? data.days[pick.day] ?? {} : null;106112 let shown = 0;107113 for (const sec of panels) {108114 const ul = sec.querySelector('ul')!;@@ -111,13 +117,43 @@
111117 ul.append(...cards);112118 let n = 0;113119 for (const c of cards) n += Number(!(c.hidden = pick.site && !c.hasAttribute('data-site')));114- const count = sec.querySelector('.panel-head .n');115- if (count) count.textContent = String(n);116- sec.hidden = sec !== on || !n;120+ const tab = tabs.find((t) => t.dataset.shelf === sec.dataset.shelf)?.querySelector('.n');121+ if (tab) tab.textContent = String(n);122+ // A picked day: this panel's repos with commits that day.123+ let repos = 0, commits = 0;124+ for (const c of cards) {125+ const k = day && sec === on && !c.hidden ? day[c.dataset.repo ?? ''] ?? 0 : 0;126+ const span = c.querySelector<HTMLElement>('.card-day');127+ c.classList.toggle('on-day', !!day);128+ if (span) {129+ span.hidden = !k;130+ span.textContent = k ? plural(k, 'commit') : '';131+ }132+ if (day && sec === on && !c.hidden) {133+ c.hidden = !k;134+ if (k) [repos, commits] = [repos + 1, commits + k];135+ }136+ }137+ const head = sec.querySelector<HTMLElement>('.day-head');138+ if (head) {139+ head.hidden = !day;140+ head.querySelector('.day-title')!.textContent = day ? words(pick.day) : '';141+ head.querySelector('.day-note')!.textContent = day ? `${plural(repos, 'repo')}, ${plural(commits, 'commit')}` : '';142+ }143+ const none = sec.querySelector<HTMLElement>('.day-empty');144+ if (none) none.hidden = !day || repos > 0;145+ sec.hidden = sec !== on || (!n && !day);117146 if (!sec.hidden) shown++;118147 }119148 const empty = document.querySelector<HTMLElement>('.shelves-empty');120149 if (empty) empty.hidden = shown > 0;150+ // The grid counts what the picked tab (and the globe) leave.151+ if (grid && data) {152+ const names = new Set([...(on?.querySelectorAll<HTMLElement>('.card') ?? [])].filter((c) => !(pick.site && !c.hasAttribute('data-site'))).map((c) => c.dataset.repo));153+ const counts: Record<string, number> = {};154+ for (const [d, per] of Object.entries(data.days)) for (const [r, k] of Object.entries(per)) if (names.has(r)) counts[d] = (counts[d] ?? 0) + k;155+ paint(grid, counts);156+ }121157 };122158 document.addEventListener('click', (e) => {123159 const el = (e.target as Element | null)?.closest<HTMLElement>('[data-shelves] [data-shelf], [data-shelves] [data-has-site], [data-shelves] [data-sort]');@@ -127,6 +163,158 @@
127163 else if (el.hasAttribute('data-has-site')) pick.site = !pick.site;128164 else if (el.dataset.sort) pick.az = el.dataset.sort === 'name';129165 shelves();166+ if (el.dataset.shelf !== undefined && document.querySelector('[data-activity]')) address();167+ });168+169+ // The activity grid (ui.activity in ui.njk, lib/activity.ts): a day a170+ // button, coloured by its count's share of the busiest day. A click (or171+ // Enter or Space) picks a day or, picked, lets it go; "show all" lets it172+ // go too; the arrow keys move a day (up, down) or a week (left, right).173+ // The address keeps the day so a narrowed list can be shared: #<day>, or174+ // #<shelf>/<day> with a shelf picked on an owner's page, beside the175+ // shelves' own #<shelf>. On an owner's page the counts are commits by176+ // repo (the page's JSON, then the fresher activity.json forge lake177+ // writes beside it); on a repo's insights, events, whose list for a day178+ // comes from its activity.json.179+ const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];180+ const words = (d: string): string => `${MONTHS[Number(d.slice(5, 7)) - 1]} ${Number(d.slice(8))}, ${d.slice(0, 4)}`;181+ const plural = (n: number, w: string): string => `${n.toLocaleString('en-US')} ${w}${n === 1 ? '' : 's'}`;182+ const level = (n: number, max: number): number => (n <= 0 || max <= 0 ? 0 : Math.min(4, Math.ceil((4 * n) / max)));183+ // A repo's events carry their times, so their days are the reader's,184+ // as their times are; the page without JS has UTC days.185+ const localDay = (ms: number): string => {186+ const t = new Date(ms);187+ return `${t.getFullYear()}-${two(t.getMonth() + 1)}-${two(t.getDate())}`;188+ };189+ const esc = (v: unknown): string => String(v).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);190+ type Days = Record<string, Record<string, number>>;191+ type Ev = [number, string, string, string];192+ let fresh: { repos?: Record<string, number[]>; events?: Ev[] } | null = null;193+ let asked = false;194+ let lost = false;195+ // An owner's commits by the reader's day, then repo: each repo's commit196+ // times come as minutes, the first since 1970 and each after it since the197+ // one before (lib/activity.ts).198+ let decoded: { from: unknown; days: Days } | null = null;199+ const ownerData = (): { days: Days } => {200+ const from = fresh?.repos ? fresh : document.querySelector('.activity-data')?.textContent ?? '';201+ if (decoded?.from === from) return decoded;202+ let src: { repos?: Record<string, number[]> } | null = null;203+ try {204+ src = typeof from === 'string' ? JSON.parse(from) : from;205+ } catch {}206+ const days: Days = {};207+ for (const [repo, enc] of Object.entries(src?.repos ?? {})) {208+ let m = 0;209+ enc.forEach((d, i) => {210+ m = i ? m + d : d;211+ const day = localDay(m * 60000);212+ (days[day] ??= {})[repo] = (days[day][repo] ?? 0) + 1;213+ });214+ }215+ decoded = { from, days };216+ return decoded;217+ };218+219+ // Paint a grid from counts by day (or its own, without).220+ const paint = (g: HTMLElement, counts: Record<string, number> | null): void => {221+ const cells = [...g.querySelectorAll<HTMLElement>('button[data-day]')];222+ const ns = cells.map((c) => (counts ? counts[c.dataset.day!] ?? 0 : Number(c.dataset.n)));223+ const max = Math.max(0, ...ns);224+ const noun = g.dataset.noun ?? 'commit';225+ let total = 0;226+ cells.forEach((c, i) => {227+ total += ns[i];228+ const what = `${c.dataset.day}: ${plural(ns[i], noun)}`;229+ c.setAttribute('data-n', String(ns[i]));230+ c.setAttribute('class', (c.getAttribute('class') ?? '').replace(/\bl\d\b/, `l${level(ns[i], max)}`));231+ c.setAttribute('aria-label', what);232+ c.title = what;233+ c.setAttribute('aria-pressed', String(c.dataset.day === pick.day));234+ });235+ const t = g.closest('.panel')?.querySelector('.act-total');236+ if (t) t.textContent = plural(total, noun);237+ };238+ // A repo's insights: the latest events, or a picked day's.239+ const evRow = (base: string, [t, kind, text, rel]: Ev): string => {240+ const iso = new Date(t).toISOString().replace(/\.\d+Z$/, 'Z');241+ const what = rel ? `<a class="ev-what" href="${esc(base + rel)}">${esc(text)}</a>` : `<span class="ev-what">${esc(text)}</span>`;242+ return `<li class="ev"><span class="ev-kind">${esc(kind)}</span>${what}<time datetime="${iso}">${iso.slice(0, 10)}</time></li>`;243+ };244+ const insights = (): void => {245+ const g = document.querySelector<HTMLElement>('.insights [data-activity]');246+ const list = document.querySelector<HTMLElement>('.act-list');247+ if (!g || !list) return;248+ const events = fresh?.events;249+ if (events) {250+ const counts: Record<string, number> = {};251+ for (const e of events) {252+ const d = localDay(e[0]);253+ counts[d] = (counts[d] ?? 0) + 1;254+ }255+ paint(g, counts);256+ } else paint(g, null);257+ const head = list.querySelector<HTMLElement>('.day-head')!;258+ const none = list.querySelector<HTMLElement>('.day-empty')!;259+ list.querySelector<HTMLElement>('.list-head')!.hidden = !!pick.day;260+ head.hidden = !pick.day;261+ if (!pick.day && !events) return;262+ const day = pick.day ? (events ?? []).filter((e) => localDay(e[0]) === pick.day) : events!.slice(0, 20);263+ head.querySelector('.day-title')!.textContent = pick.day ? words(pick.day) : '';264+ head.querySelector('.day-note')!.textContent = pick.day ? (events ? plural(day.length, 'event') : lost ? "couldn't load the day's events" : 'loading') : '';265+ list.querySelector('ul')!.innerHTML = day.map((e) => evRow(g.dataset.base ?? '', e)).join('');266+ none.hidden = !!day.length || (!!pick.day && !events);267+ none.textContent = pick.day ? 'Nothing happened that day.' : 'Nothing happened in the last year.';268+ times();269+ };270+ // The fresher export, once a page (and again after live.ts swaps one in).271+ const refresh = (): void => {272+ const g = document.querySelector<HTMLElement>('[data-activity][data-src]');273+ if (asked || !g || typeof fetch !== 'function') return;274+ asked = true;275+ fetch(g.dataset.src!, { cache: 'no-cache' })276+ .then((r) => (r.ok ? r.json() : null))277+ .then((d) => {278+ if (d && typeof d === 'object') fresh = d;279+ else lost = true;280+ activity();281+ })282+ .catch(() => {283+ lost = true;284+ activity();285+ });286+ };287+ const activity = (): void => {288+ shelves();289+ insights();290+ // Elsewhere (the style guide's), a grid only shows the pick.291+ for (const g of document.querySelectorAll<HTMLElement>('[data-activity]')) if (!g.closest('.shelves, .insights')) paint(g, null);292+ };293+ const address = (): void => {294+ const h = [pick.shelf, pick.day].filter(Boolean).join('/');295+ history.replaceState(null, '', h ? `#${h}` : location.pathname + location.search);296+ };297+ document.addEventListener('click', (e) => {298+ const t = e.target as Element | null;299+ const cell = t?.closest<HTMLElement>('[data-activity] button[data-day]');300+ const reset = t?.closest<HTMLElement>('[data-day-reset]');301+ if (!cell && !reset) return;302+ e.preventDefault();303+ pick.day = cell && pick.day !== cell.dataset.day ? cell.dataset.day! : '';304+ activity();305+ address();306+ });307+ document.addEventListener('keydown', (e) => {308+ const cell = (e.target as Element | null)?.closest<HTMLElement>('[data-activity] button[data-day]');309+ const step = ({ ArrowUp: -1, ArrowDown: 1, ArrowLeft: -7, ArrowRight: 7 } as Record<string, number>)[e.key];310+ if (!cell || !step) return;311+ e.preventDefault();312+ const cells = [...cell.parentElement!.querySelectorAll<HTMLElement>('button[data-day]')].filter((c) => c.offsetParent !== null);313+ const to = cells[cells.indexOf(cell) + step];314+ if (!to) return;315+ cell.tabIndex = -1;316+ to.tabIndex = 0;317+ to.focus();130318 });131319 132320 // The style guide's examples (design.njk): a toggle flips, a segment is@@ -139,28 +327,38 @@
139327 else b.setAttribute('aria-pressed', String(b.getAttribute('aria-pressed') !== 'true'));140328 });141329 330+ // A link to one shelf (/cat/#games, from the home page's topics) opens on142331 // A link to one shelf (/cat/#games, from the home page's topics) opens on143332 // it, and so does following one, or going back, on the page itself: the144- // address changes but the page doesn't load again. Any other address is "all".333+ // address changes but the page doesn't load again. A day after it334+ // (#games/2026-10-08), or alone, picks that day in the activity grid.335+ // Any other address is "all".145336 const atHash = (): void => {146337 const at = decodeURIComponent(location.hash.slice(1));147- pick.shelf = at && document.querySelector(`[data-shelves] a[data-shelf="${CSS.escape(at)}"]`) ? at : '';338+ const dated = /^(?:(.+)\/)?(\d{4}-\d{2}-\d{2})$/.exec(at);339+ const shelf = dated ? dated[1] ?? '' : at;340+ pick.shelf = shelf && document.querySelector(`[data-shelves] a[data-shelf="${CSS.escape(shelf)}"]`) ? shelf : '';341+ pick.day = dated && document.querySelector(`[data-activity] button[data-day="${dated[2]}"]`) ? dated[2] : '';148342 };149343 atHash();150344 window.addEventListener('hashchange', () => {151345 atHash();152- shelves();346+ activity();153347 });154348 155349 const content = (): void => {156350 wrap(wrapped());157351 times();158- shelves();352+ activity();353+ refresh();159354 };160355 content();161356 setInterval(times, 60000);162357 // live.ts swaps in rebuilt content.163- document.addEventListener('forge:content', content);358+ document.addEventListener('forge:content', () => {359+ [fresh, asked, lost] = [null, false, false];360+ content();361+ });164362 165363 // Clicking into a clone URL selects it, for copying by hand.166364 for (const input of document.querySelectorAll<HTMLInputElement>('.copyrow input')) input.addEventListener('focus', () => input.select());@@ -36,6 +36,9 @@ table.dg-tokens td.v { font: var(--fw-regular) var(--text-xs) var(--mono); color
3636 .dg-fam .use { font-size: var(--text-sm); color: var(--muted); }3737 .dg-legible { margin: 0; padding: var(--space-2) var(--space-3); border-top: var(--hairline) solid var(--border); font: var(--fw-regular) var(--text-code)/1.6 var(--mono); }3838 .dg-page { margin: 0 0 var(--space-4); }39+.dg-avatars { display: grid; grid-template-columns: repeat(auto-fill, minmax(124px, 1fr)); gap: var(--space-4); padding: var(--space-3); }40+.dg-avatars figure { display: grid; grid-template-columns: auto auto; align-items: end; justify-content: start; gap: var(--space-2); margin: 0; }41+.dg-avatars figcaption { grid-column: 1 / -1; font-size: var(--text-xs); color: var(--muted); }3942 .dg-legible span + span { margin-left: var(--space-4); }4043 @media (max-width: 720px) {4144 .dg-row > .label { flex-basis: 100%; }@@ -93,6 +96,17 @@ table.dg-tokens td.v { font: var(--fw-regular) var(--text-xs) var(--mono); color
9396 <div class="dg-row"><span class="label">icons</span>{% for i in ['globe', 'clock', 'az', 'branch', 'folder', 'file', 'link', 'up', 'search'] %}<span title="{{ i }}">{{ ui.icon(i, i) }}</span>{% endfor %}</div>9497 {% endcall %}9598 99+{{ ui.heading('people and activity', null, 'avatars from a name; the activity grid') }}100+{% set head %}<b>avatars</b><span class="grow">bauhaus tiles drawn from the name (lib/avatar.ts), 72px on a person's page, 36px in lists</span>{% endset %}101+{% call ui.panel(head) %}102+<div class="dg-avatars">103+{% for n in ['cat', 'fox', 'bob', 'kim', 'lee', 'mo', 'jun', 'ola', 'sam', 'tess', 'yuki', 'ren'] %}<figure>{{ n | avatar(72) }}{{ n | avatar(36) }}<figcaption class="mono">{{ n }}</figcaption></figure>{% endfor %}104+</div>105+{% endcall %}106+{% set grid = 'commit' | demoActivity %}107+{% set head %}<b class="act-total">{{ grid.total | thousands }} commits</b><span>in the last year: ui.activity, a day a button; pick one</span>{% endset %}108+{% call ui.panel(head, 'activity-panel') %}<div class="panel-body">{{ ui.activity(grid, 'commits a day') }}</div>{% endcall %}109+96110 {{ ui.heading('section heading', 4, 'a count, then a quieter note') }}97111 {% call ui.panel() %}98112 {% call ui.listTable(['commit', 'author', {text: '+', cls: 'num'}, {text: '-', cls: 'num'}, 'ci']) %}@@ -24,15 +24,9 @@ main.wrap > p.motd { margin: calc(-1 * var(--space-5)) 0 0; padding: var(--space
2424 .home { grid-template-columns: minmax(0, 1fr); grid-template-areas: "profiles" "recent" "topics"; grid-template-rows: none; }2525 }2626 .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. */27+/* A profile: their avatar, the name, how many repos and when. */2828 .profile { position: relative; display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) var(--space-3); }2929 .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); }3630 .profile-text { display: flex; flex-direction: column; min-width: 0; }3731 .profile-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--text-prose); font-weight: var(--fw-strong); color: var(--fg); }3832 .profile-name:hover { color: var(--fg); }@@ -51,19 +45,6 @@ main.wrap > p.motd { margin: calc(-1 * var(--space-5)) 0 0; padding: var(--space
5145 @media (max-width: 720px) {5246 .topic-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }5347 }54-/* What happened: its kind, the repo and what, when. The rows share the55- list's columns (subgrid), so every title ends at the same edge; the56- kind's is as wide as "release", the time's as "Sep 24" (--col-time). */57-.recent .rows { display: grid; grid-template-columns: 3.6em minmax(0, 1fr) auto; column-gap: var(--space-3); }58-.ev { position: relative; grid-column: 1 / -1; display: grid; grid-template-columns: 3.6em minmax(0, 1fr) auto; grid-template-columns: subgrid; column-gap: var(--space-3); align-items: baseline; padding: var(--space-2) var(--space-3); }59-.ev:hover, .ev:focus-within { background: var(--hover); }60-.ev-kind { font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--muted); }61-.ev-what { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fg); }62-.ev-what:hover { color: var(--fg); }63-.ev-what::after { content: ''; position: absolute; inset: 0; }64-.ev-what b { font-weight: var(--fw-strong); }65-.ev-what .tag { font-family: var(--mono); font-size: var(--text-inline-mono); color: var(--link); }66-.ev time { min-width: var(--col-time); font-size: var(--text-xs); color: var(--faint); text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }6748 {% endcss %}6849 {% if forge.motd %}<p class="motd">{{ forge.motd }}</p>6950 {% endif %}@@ -73,7 +54,7 @@ main.wrap > p.motd { margin: calc(-1 * var(--space-5)) 0 0; padding: var(--space
7354 {% if forge.profiles.length %}7455 <ul class="rows">7556 {% for pr in forge.profiles %}76-<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 | ago }}{% endif %}</span></span></li>57+<li class="profile">{{ pr.owner | avatar(36) }}<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 | ago }}{% endif %}</span></span></li>7758 {% endfor %}7859 </ul>7960 {% else %}<p class="empty">No public repos here yet.</p>@@ -82,7 +63,7 @@ main.wrap > p.motd { margin: calc(-1 * var(--space-5)) 0 0; padding: var(--space
8263 {% set head %}<h2>recent</h2><span class="end"><a href="/insights/">insights</a></span>{% endset %}8364 {% call ui.panel(head, 'recent') %}8465 {% if forge.recent.length %}85-<ul class="rows">66+<ul class="rows ev-rows">8667 {% for e in forge.recent %}8768 <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>8869 {% endfor %}@@ -5,11 +5,71 @@ pagination:
55 alias: o66 permalink: "/{{ o.owner | safe }}/index.html"77 ---8+{#- An owner's page: their front matter (avatar, name, what they set with9+ ssh profile, facts), the repos they pinned (or their most recently10+ active), then their repos on shelves with their activity grid. -#}811 {% extends "base.njk" %}912 {% import "macros.njk" as m with context %}1013 {% import "ui.njk" as ui %}14+{% set pr = o.person %}15+{% block head %}{{ m.meta(o.owner + ' - ' + forge.config.name, pr.bio or (o.repos.length | plural('public repo')) + ' on ' + forge.config.name, '/' + o.owner + '/') }}{% endblock %}1116 {% block heading %}<b>{{ o.owner }}</b>1217 {% endblock %}1318 {% block content %}14-{{ m.shelves(o.repos) }}19+{% css %}20+/* Front matter: who this is, on the page, over their repos. */21+.person { display: flex; align-items: center; gap: var(--space-4); margin: 0 0 var(--space-5); min-width: 0; }22+.person .avatar { width: 72px; height: 72px; }23+.person-text { min-width: 0; }24+.person h1 { margin: 0; font-size: var(--text-2xl); line-height: 1.15; overflow-wrap: anywhere; }25+.person .bio { margin: var(--space-1) 0 0; color: var(--fg); overflow-wrap: anywhere; }26+.person-facts { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-1) var(--space-4); margin: var(--space-1) 0 0; font-size: var(--text-sm); color: var(--muted); }27+.person-facts .link { display: inline-flex; align-items: center; gap: var(--space-1); min-width: 0; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: var(--fw-medium); }28+.person-facts .link .icon { align-self: center; }29+@media (max-width: 480px) {30+ .person { align-items: flex-start; gap: var(--space-3); }31+ .person h1 { font-size: var(--text-xl); }32+}33+/* Pinned: up to three repos side by side, a hairline between them; the34+ panel's edge is the rule round them. */35+.pins { overflow: hidden; }36+.pin-grid { display: grid; grid-template-columns: repeat(var(--pins), minmax(0, 1fr)); margin: 0 calc(-1 * var(--hairline)) 0 0; padding: 0; list-style: none; }37+.pin-grid > li { position: relative; display: flex; flex-direction: column; gap: var(--space-1); min-width: 0; padding: var(--space-3); border-right: var(--hairline) solid var(--border); }38+.pin-grid > li:hover, .pin-grid > li:focus-within { background: var(--hover); }39+.pin-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: var(--fw-strong); color: var(--fg); }40+.pin-name:hover { color: var(--fg); }41+.pin-name::after { content: ''; position: absolute; inset: 0; }42+.pin-desc { flex: 1; margin: 0; font-size: var(--text-sm); color: var(--muted); overflow-wrap: anywhere; }43+.pin-foot { display: flex; align-items: baseline; gap: var(--space-3); font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--faint); white-space: nowrap; }44+.pin-foot a { position: relative; z-index: 1; }45+.pin-foot .site { display: inline-flex; align-items: center; gap: var(--space-1); align-self: center; min-width: 0; overflow: hidden; text-overflow: ellipsis; }46+.pin-foot .grow { flex: 1; }47+.pin-foot time { font-size: inherit; font-variant-numeric: tabular-nums; }48+@media (max-width: 720px) {49+ .pin-grid { grid-template-columns: minmax(0, 1fr); margin: 0; }50+ .pin-grid > li { border-right: 0; border-bottom: var(--hairline) solid var(--border); }51+ .pin-grid > li:last-child { border-bottom: 0; }52+}53+{% endcss %}54+<section class="person">55+{{ o.owner | avatar(72) }}56+<div class="person-text">57+<h1>{{ o.owner }}</h1>58+{% if pr.bio %}<p class="bio">{{ pr.bio }}</p>{% endif %}59+<p class="person-facts">{% if pr.link %}<a class="link" href="{{ pr.link }}" rel="me nofollow">{{ ui.icon('link') }}{{ pr.linkText }}</a>{% endif %}<span>{{ o.repos.length | plural('repo') }}{% if pr.sites %}, {{ pr.sites | plural('site') }}{% endif %}{% if pr.joined %}, joined {{ pr.joined }}{% endif %}</span></p>60+</div>61+</section>62+{% if pr.pins.length %}63+{% set head %}<h2>{{ 'pinned' if pr.pinned else 'recently active' }}</h2>{% endset %}64+{% call ui.panel(head, 'pins') %}65+<ul class="pin-grid" style="--pins: {{ pr.pins.length }}">66+{% for s in pr.pins %}67+<li><a class="pin-name" href="{{ s.urls.home.href }}">{{ s.name }}</a>68+<p class="pin-desc">{{ s.description }}</p>69+<div class="pin-foot">{% if s.release %}<a class="mono" href="{{ s.release.href }}" title="its latest release">{{ s.release.tag }}</a>{% else %}{{ m.sitesLink(s) }}{% endif %}<span class="grow"></span>{{ s.lastCommit | date }}</div></li>70+{% endfor %}71+</ul>72+{% endcall %}73+{% endif %}74+{{ m.shelves(o.repos, false, o.activity) }}1575 {% endblock %}@@ -7,6 +7,60 @@ pagination:
77 {% import "ui.njk" as ui %}88 {% set u = p.repo.urls %}99 {% block head %}{{ m.meta(p.title, p.repo.description or (p.readme.html | excerpt(true) if p.readme and p.readme.html else ''), u.home.href) }}{% endblock %}10+{% block front %}11+{% css %}12+/* A repo's front matter, above its tabs: its name, what it is about and13+ its topics; its facts in a column at the right, behind a hairline; on14+ phones the facts go under the about line. */15+.repo-front { display: grid; grid-template-columns: minmax(0, 1fr) minmax(15em, 21em); gap: var(--space-2) var(--space-6); margin: var(--space-2) 0 var(--space-3); }16+.repo-front h1 { margin: 0; font-size: var(--text-2xl); line-height: 1.15; overflow-wrap: anywhere; }17+.repo-front .about { margin: var(--space-2) 0 0; max-width: 46em; font-size: var(--text-prose); color: var(--fg); overflow-wrap: anywhere; }18+.rf-topics { display: flex; flex-wrap: wrap; gap: var(--space-1) var(--space-3); margin: var(--space-2) 0 0; font-size: var(--text-sm); }19+.rf-topics span { color: var(--muted); }20+.facts { align-self: start; margin: 0; padding: var(--space-1) 0 var(--space-1) var(--space-4); border-left: var(--hairline) solid var(--border); font-size: var(--text-sm); }21+.facts > div { display: grid; grid-template-columns: 5.5em minmax(0, 1fr); gap: var(--space-2); align-items: baseline; padding: var(--space-half) 0; }22+.facts dt { color: var(--muted); }23+.facts dd { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-1) var(--space-2); margin: 0; min-width: 0; color: var(--fg); }24+.facts dd > a:first-child:not(.mono) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }25+.facts .site { display: inline-flex; align-items: center; gap: var(--space-1); }26+.facts time { font-size: var(--text-xs); color: var(--faint); font-variant-numeric: tabular-nums; }27+.facts .sha { font-size: var(--text-xs); }28+.facts .pass { color: var(--pass); }29+.facts .fail { color: var(--fail); }30+.facts .unverified { color: var(--warn); }31+.facts .none { color: var(--muted); }32+.lang-bar { display: flex; width: 100%; height: var(--space-1); margin-top: var(--space-1); background: var(--lang-rest); overflow: hidden; }33+.lang-bar > i { display: block; height: 100%; }34+.langs { display: flex; flex-wrap: wrap; gap: 0 var(--space-3); color: var(--muted); }35+.langs b { font-weight: var(--fw-medium); color: var(--fg); }36+.lc1 { background: var(--lang-1); }37+.lc2 { background: var(--lang-2); }38+.lc3 { background: var(--lang-3); }39+.langs i { display: inline-block; width: var(--space-2); height: var(--space-2); margin-right: var(--space-1); }40+@media (max-width: 720px) {41+ .repo-front { grid-template-columns: minmax(0, 1fr); }42+ .facts { padding: var(--space-2) 0 0; border-left: 0; }43+}44+{% endcss %}45+{% set f = p.front %}46+<div class="repo-front">47+<div class="rf-main">48+<h1>{{ p.repo.name }}</h1>49+{% if p.repo.description %}<p class="about">{{ p.repo.description }}</p>{% endif %}50+{% if f.topics.length %}<p class="rf-topics" aria-label="topics">{% for t in f.topics %}{% if t.href %}<a href="{{ t.href }}">{{ t.title }}</a>{% else %}<span>{{ t.title }}</span>{% endif %}{% endfor %}</p>{% endif %}51+</div>52+{% if f.sites or f.release or f.ci or f.reviews or f.license or f.languages.length %}53+<dl class="facts">54+{% if f.sites %}<div><dt>site</dt><dd>{% if f.sites.url %}<a class="site" href="{{ f.sites.url }}">{{ ui.icon('globe') }}{{ f.sites.name }}</a>{% else %}<a class="site" href="{{ f.sites.href }}">{{ ui.icon('globe') }}{{ f.sites.n }} sites</a>{% endif %}</dd></div>{% endif %}55+{% if f.release %}<div><dt>release</dt><dd><a class="mono" href="{{ f.release.href }}">{{ f.release.tag }}</a>{{ f.release.date | date }}</dd></div>{% endif %}56+{% if f.ci %}<div><dt>ci</dt><dd><span class="{{ f.ci.state }}">{{ 'passed' if f.ci.state == 'pass' else ('failed' if f.ci.state == 'fail' else f.ci.state) }}</span><a class="sha" href="{{ f.ci.href }}">{{ f.ci.short }}</a></dd></div>{% endif %}57+{% if f.reviews %}<div><dt>reviews</dt><dd><a href="{{ p.repo.urls.reviews.href }}">{{ f.reviews }} open</a></dd></div>{% endif %}58+{% if f.license %}<div><dt>license</dt><dd><a href="{{ f.license.href }}"{% if not f.license.id %} class="mono"{% endif %}>{{ f.license.id if f.license.id else 'see ' + f.license.path }}</a></dd></div>{% endif %}59+{% if f.languages.length %}<div><dt>languages</dt><dd><span class="lang-bar" aria-hidden="true">{% for l in f.languages %}<i class="lc{{ loop.index }}" style="width: {{ l.pct }}%"></i>{% endfor %}</span><span class="langs">{% for l in f.languages %}<span><i class="lc{{ loop.index }}" aria-hidden="true"></i>{{ l.name }} <b>{{ l.pct }}%</b></span>{% endfor %}</span></dd></div>{% endif %}60+</dl>61+{% endif %}62+</div>63+{% endblock %}1064 {% block content %}1165 {% if p.reviews.length %}1266 {% set head %}<b>open reviews</b><span class="n">{{ p.reviews.length }}</span><span class="end"><a href="{{ u.reviews.href }}">all reviews</a></span>{% endset %}@@ -0,0 +1,28 @@
1+# The activity grid scenarios' forge (sourced after lib.sh): dami's games2+# chess and go and tool notes, with commits on two days (Sep 1: chess 2,3+# go 1; Sep 3: go 3, notes 2), and a private secret with commits on Sep 14+# that must count nowhere. Then forge lake, and the pages.5+user dami6+for r in chess go notes; do7+ repo "dami/$r"8+ clone dami "dami/$r" "$T/w/$r"9+done10+repo --private dami/secret11+clone dami dami/secret "$T/w/secret"12+printf 'games\n' >"$FORGE_ROOT/dami/chess.git/category"13+printf 'games\n' >"$FORGE_ROOT/dami/go.git/category"14+printf 'tools\n' >"$FORGE_ROOT/dami/notes.git/category"15+commits() { # repo n16+ k=017+ while [ "$k" -lt "$2" ]; do tick; at "$T/w/$1" commit "$1 $k"; k=$((k + 1)); done18+ push_as dami "$T/w/$1" origin main19+}20+commits chess 221+commits go 122+commits secret 423+tick $((2 * 86400))24+commits go 325+commits notes 226+ok forge lake27+export FORGE_STRICT=128+build@@ -54,6 +54,9 @@ if [ -n "${FORGE_TEST_NODE:-}" ]; then
5454 PATH=$T/node:$PATH5555 fi5656 57+# Days and times in UTC, whatever the machine's zone: the activity grid58+# puts a repo's events on the reader's local day (site/assets/ui.ts).59+export TZ=UTC5760 export FORGE_ROOT=$T/root5861 export FORGE_WWW=$FORGE_ROOT/.forge/www5962 export FORGE_SSH=git@forge.test@@ -5,10 +5,17 @@
55 // attributes, descendants and lists.66 //77 // node test/page.ts shelves <page.html> [#fragment] [click <selector> | go <#fragment>]...8+// node test/page.ts activity <page.html> [#fragment] [click <selector> | go <#fragment>]...89 //910 // After loading the page at the fragment, then clicking each selector or10-// going to each fragment on the page (hashchange) in turn, prints the shelf panels that show, a line each:11-// `<head strip>: <repo> <repo>...`, the repos as their rows show them.11+// going to each fragment on the page (hashchange) in turn, shelves prints12+// the shelf panels that show, a line each: `<head strip>: <repo>13+// <repo>...`, the repos as their rows show them (a panel without a head14+// strip is named by the picked tab). activity prints the activity grid's15+// head strip, its days with something on them (`*` marks the picked one),16+// then the list under it: the shelves as above, with each repo's count for17+// a picked day, or a repo's events; and the address's fragment. fetch()18+// reads files beside the page.1219 import fs from 'node:fs';1320 import vm from 'node:vm';1421 import path from 'node:path';@@ -131,6 +138,19 @@ class El {
131138 get textContent(): string {132139 return text(this.raw);133140 }141+ set innerHTML(v: string) {142+ const frag = parseDocument(v) as unknown as Raw;143+ this.raw.children = frag.children ?? [];144+ for (const c of this.raw.children) c.parent = this.raw;145+ }146+ get parentElement(): El | null {147+ return this.raw.parent?.type === 'tag' ? el(this.raw.parent) : null;148+ }149+ get offsetParent(): El | null {150+ return this.closest('[hidden]') ? null : this;151+ }152+ tabIndex = 0;153+ focus(): void {}134154 set textContent(v: string) {135155 this.raw.children = [{ type: 'text', data: v, parent: this.raw }];136156 }@@ -185,6 +205,7 @@ class El {
185205 186206 // The page as ui.ts sees it, at a fragment; click() as a reader would.187207 function load(file: string, hash: string) {208+ const location = { hash, pathname: '/', search: '' };188209 const root = parseDocument(fs.readFileSync(file, 'utf8')) as unknown as Raw;189210 const handlers: Record<string, ((e: object) => void)[]> = {};190211 const html = all(root, 'html')[0] ?? root;@@ -197,11 +218,15 @@ function load(file: string, hash: string) {
197218 },198219 });199220 const noop = (): void => {};200- const location = { hash };201221 const page = vm.createContext({202222 document: doc,203223 window: { addEventListener: (type: string, f: (e: object) => void) => (handlers['window ' + type] ??= []).push(f) },204224 location,225+ history: { replaceState: (_: unknown, __: string, url: string) => void (location.hash = url.startsWith('#') ? url : '') },226+ fetch: (url: string) => {227+ const f = path.join(path.dirname(file), url);228+ return Promise.resolve(fs.existsSync(f) ? { ok: true, json: async () => JSON.parse(fs.readFileSync(f, 'utf8')) } : { ok: false, json: async () => null });229+ },205230 localStorage: { getItem: () => null, setItem: noop },206231 matchMedia: () => ({ matches: false }),207232 setInterval: noop,@@ -227,27 +252,57 @@ function load(file: string, hash: string) {
227252 location.hash = to;228253 for (const f of handlers['window hashchange'] ?? []) f({ type: 'hashchange' });229254 };230- return { doc, click, go };255+ return { doc, click, go, location };231256 }232257 233258 const shown = (e: El): boolean => !e.closest('[hidden]');234259 const words = (s: string): string => s.replace(/\s+/g, ' ').trim();235260 236261 const [mode, file, ...rest] = process.argv.slice(2);237-if (mode !== 'shelves' || !file) {238- console.error('usage: node test/page.ts shelves <page.html> [#fragment] [click <selector> | go <#fragment>]...');262+if ((mode !== 'shelves' && mode !== 'activity') || !file) {263+ console.error('usage: node test/page.ts shelves|activity <page.html> [#fragment] [click <selector> | go <#fragment>]...');239264 process.exit(2);240265 }241266 const hash = rest[0]?.startsWith('#') ? rest.shift()! : '';242-const { doc, click, go } = load(file, hash);267+const { doc, click, go, location } = load(file, hash);268+// What the page's fetches bring, before and after each step.269+const settle = async (): Promise<void> => {270+ for (let i = 0; i < 5; i++) await new Promise((r) => setImmediate(r));271+};272+await settle();243273 for (let i = 0; i < rest.length; i += 2) {244274 if (rest[i] === 'click' && rest[i + 1]) click(rest[i + 1]);245275 else if (rest[i] === 'go' && rest[i + 1]) go(rest[i + 1]);246276 else throw new Error(`test/page.ts: expected click <selector> or go <#fragment>, got ${rest.slice(i).join(' ')}`);277+ await settle();278+}279+const headOf = (h: El): string => h.children.filter(shown).map((c) => words(c.textContent)).filter(Boolean).join(' ');280+if (mode === 'activity') {281+ const g = doc.querySelector('[data-activity]');282+ if (!g) throw new Error('test/page.ts: no activity grid on the page');283+ console.log(headOf(g.closest('.panel')!.querySelector('.panel-head')!));284+ for (const c of g.querySelectorAll('button[data-day]')) {285+ if (c.dataset.n !== '0' || c.getAttribute('aria-pressed') === 'true') console.log(`${c.getAttribute('aria-pressed') === 'true' ? '*' : ' '} ${c.getAttribute('aria-label')} l${(c.getAttribute('class') ?? '').match(/l(\d)/)?.[1]}`);286+ }247287 }248288 for (const sec of doc.querySelectorAll('section.shelf').filter(shown)) {249- const names = sec.querySelectorAll('.card').filter(shown).map((c) => words(c.querySelector('.card-name')!.textContent).replace(' / ', '/'));250- const head = sec.querySelector('.panel-head')!.children.map((c) => words(c.textContent)).join(' ');251- console.log(`${head}: ${names.join(' ')}`);289+ const names = sec.querySelectorAll('.card').filter(shown).map((c) => {290+ const day = c.querySelector('.card-day');291+ return words(c.querySelector('.card-name')!.textContent).replace(' / ', '/') + (day && shown(day) ? ` (${words(day.textContent)})` : '');292+ });293+ const head = sec.querySelectorAll('.panel-head').filter(shown)[0];294+ const tab = doc.querySelector('[data-shelves] a.cur');295+ const n = tab?.querySelector('.n');296+ const name = head ? headOf(head) : tab ? words(`${tab.textContent.slice(0, tab.textContent.length - (n?.textContent.length ?? 0))} ${n?.textContent ?? ''}`) : 'all';297+ const none = sec.querySelector('.day-empty');298+ console.log(`${name}: ${names.join(' ')}${none && shown(none) ? words(none.textContent) : ''}`);252299 }253300 if (doc.querySelector('.shelves-empty') && shown(doc.querySelector('.shelves-empty')!)) console.log('empty: ' + words(doc.querySelector('.shelves-empty')!.textContent));301+const list = doc.querySelector('.act-list');302+if (list) {303+ console.log(headOf(list.querySelectorAll('.panel-head').filter(shown)[0]) + ':');304+ for (const li of list.querySelectorAll('.ev')) console.log(` ${words(li.querySelector('.ev-kind')!.textContent)} ${words(li.querySelector('.ev-what')!.textContent)}`);305+ const none = list.querySelector('.day-empty');306+ if (none && shown(none)) console.log(' ' + words(none.textContent));307+}308+if (mode === 'activity') console.log(`address: ${location.hash}`);@@ -0,0 +1,24 @@
1+# An owner's page has their activity grid between the shelf tabs and the2+# list: a year of days, a button each named with its date and commits,3+# coloured by its share of the busiest day, the panel's head strip the4+# year's total. It counts commits on the default branches of their public5+# repos, from the lake (forge lake), and is there without JS.6+. "$(dirname "$0")/../lib.sh"7+. "$(dirname "$0")/../activity-fixture.sh"8+p=$FORGE_WWW/dami/index.html9+has "$p" '<section class="panel activity-panel"><header class="panel-head"><b class="act-total">8 commits</b><span>in the last year</span></header>'10+has "$p" 'aria-label="2026-09-01: 3 commits" title="2026-09-01: 3 commits"'11+has "$p" 'class="l3 a0" data-day="2026-09-01" data-n="3"'12+has "$p" 'class="l4 a0" data-day="2026-09-03" data-n="5"'13+has "$p" 'aria-label="2026-09-02: 0 commits"'14+# 53 weeks to today, Sunday first, each day marked with its week's age15+# (so a narrow grid can leave out the oldest).16+has "$p" 'class="l0 a52" data-day="2025-08-31"'17+eq "$(grep -o 'data-day="[0-9-]*"' "$p" | tail -n 1)" 'data-day="2026-09-03"'18+eq "$(grep -o 'class="l[0-4] a52"' "$p" | wc -l | tr -d ' ')" 719+# The same with JS, from the page's data and the activity.json beside it.20+eq "$(node --no-warnings "$T_HOME/test/page.ts" activity "$p")" "8 commits in the last year21+ 2026-09-01: 3 commits l322+ 2026-09-03: 5 commits l423+all 3: notes go chess24+address: "@@ -0,0 +1,30 @@
1+# Picking a day narrows the list under the grid to the repos with commits2+# that day, each with its count, under a head strip saying the day, how3+# many, and "show all". The address keeps it (#<day>, or #<shelf>/<day>),4+# so a link opens on it; picking it again, or "show all", lets it go.5+. "$(dirname "$0")/../lib.sh"6+. "$(dirname "$0")/../activity-fixture.sh"7+p=$FORGE_WWW/dami/index.html8+page() { node --no-warnings "$T_HOME/test/page.ts" activity "$p" "$@" | grep -v '^[ *] 20'; }9+day='[data-activity] button[data-day="2026-09-03"]'10+eq "$(page click "$day")" "8 commits in the last year11+sep 3, 2026 2 repos, 5 commits show all: notes (2 commits) go (3 commits)12+address: #2026-09-03"13+eq "$(page '#2026-09-03')" "8 commits in the last year14+sep 3, 2026 2 repos, 5 commits show all: notes (2 commits) go (3 commits)15+address: #2026-09-03"16+eq "$(page '#games/2026-09-03')" "6 commits in the last year17+sep 3, 2026 1 repo, 3 commits show all: go (3 commits)18+address: #games/2026-09-03"19+eq "$(page '#games/2026-09-03' click '[data-day-reset]')" "6 commits in the last year20+games 2: go chess21+address: #games"22+eq "$(page click "$day" click "$day")" "8 commits in the last year23+all 3: notes go chess24+address: "25+# A day with nothing on it says so.26+eq "$(page '#2026-09-02')" "8 commits in the last year27+sep 2, 2026 0 repos, 0 commits show all: No commits that day.28+address: #2026-09-02"29+# The picked day has an ink outline: aria-pressed.30+node --no-warnings "$T_HOME/test/page.ts" activity "$p" '#2026-09-01' | grep -q '^\* 2026-09-01: 3 commits l3$' || fail "Sep 1 isn't picked"@@ -0,0 +1,36 @@
1+# A repo's insights page has the same grid of its activity from the lake:2+# pushes, landed reviews, releases, site deploys and CI results a day.3+# Under it the latest of them; picking a day lists that day's instead,4+# kind, what and when, with "show all" back. (forge attest stamps CI with5+# the real clock, after this forge's fixed one: listed, but in no day.)6+. "$(dirname "$0")/../lib.sh"7+user dami8+repo dami/greet9+clone dami dami/greet "$T/w/d"10+mkdir -p "$T/w/d/.forge"11+printf '#!/bin/sh\ntrue\n' >"$T/w/d/.forge/ci"12+chmod +x "$T/w/d/.forge/ci"13+at "$T/w/d" commit "first"14+push_as dami "$T/w/d" origin main15+tick $((2 * 86400))16+at "$T/w/d" commit "second"17+at "$T/w/d" git tag v118+push_as dami "$T/w/d" origin main v119+export FORGE_USER=dami20+ok at "$T/w/d" "$FORGE" attest21+ok push_as dami "$T/w/d" origin refs/notes/ci22+unset FORGE_USER23+ok forge lake24+export FORGE_STRICT=125+build26+p=$FORGE_WWW/dami/greet/insights/index.html27+has "$p" 'aria-label="2026-09-01: 1 event"'28+exists "$FORGE_WWW/dami/greet/insights/activity.json"29+page() { node --no-warnings "$T_HOME/test/page.ts" activity "$p" "$@" | grep -v '^[ *] 20'; }30+sha=$(git -C "$T/w/d" rev-parse --short=7 HEAD)31+eq "$(page)" "$(printf '%s\n' "3 events in the last year: pushes, landed reviews, releases, site deploys and ci results" "latest:" " passed $sha second" " release v1" " pushed dami pushed main" " pushed dami pushed main" "address: ")"32+eq "$(page click '[data-activity] button[data-day="2026-09-01"]')" "3 events in the last year: pushes, landed reviews, releases, site deploys and ci results33+sep 1, 2026 1 event show all:34+ pushed dami pushed main35+address: #2026-09-01"36+eq "$(page '#2026-09-01' click '[data-day-reset]' | sed -n 2p)" "latest:"@@ -0,0 +1,14 @@
1+# An owner's grid puts commits on the reader's days: the page carries each2+# commit's time, and ui.ts counts them by the reader's zone. Without JS the3+# days are UTC. The fixture's commits are just after midnight UTC on Sep 14+# and Sep 3, which is the afternoon before in Honolulu.5+. "$(dirname "$0")/../lib.sh"6+. "$(dirname "$0")/../activity-fixture.sh"7+p=$FORGE_WWW/dami/index.html8+has "$p" 'aria-label="2026-09-01: 3 commits"'9+eq "$(TZ=Pacific/Honolulu node --no-warnings "$T_HOME/test/page.ts" activity "$p")" "8 commits in the last year10+ 2026-08-31: 3 commits l311+ 2026-09-02: 5 commits l412+all 3: notes go chess13+address: "14+eq "$(TZ=Pacific/Honolulu node --no-warnings "$T_HOME/test/page.ts" activity "$p" '#2026-08-31' | sed -n 4p)" "aug 31, 2026 2 repos, 3 commits show all: go (1 commit) chess (2 commits)"@@ -0,0 +1,11 @@
1+# A private repo's commits count on no public page: not in its owner's2+# grid, not in the data beside the page, not in the lake's export of them.3+. "$(dirname "$0")/../lib.sh"4+. "$(dirname "$0")/../activity-fixture.sh"5+p=$FORGE_WWW/dami/index.html6+eq "$(lq "SELECT count(*) FROM commits WHERE repo = 'dami/secret'")" 47+has "$p" 'aria-label="2026-09-01: 3 commits"'8+for f in "$p" "$FORGE_WWW/dami/activity.json" "$FORGE_ROOT/.forge/lake/export/people/dami.json"; do lacks "$f" 'secret'; done9+# Each repo's commit times: minutes, then minutes since the one before.10+has "$FORGE_WWW/dami/activity.json" '"chess":\[[0-9]+,1\]'11+has "$FORGE_WWW/dami/activity.json" '"notes":\[[0-9]+,1\]'@@ -0,0 +1,14 @@
1+# The grid follows the picked shelf tab: on "games" it counts the games'2+# commits alone, and the head strip says how many.3+. "$(dirname "$0")/../lib.sh"4+. "$(dirname "$0")/../activity-fixture.sh"5+p=$FORGE_WWW/dami/index.html6+eq "$(node --no-warnings "$T_HOME/test/page.ts" activity "$p" click '[data-shelves] a[data-shelf="games"]')" "6 commits in the last year7+ 2026-09-01: 3 commits l48+ 2026-09-03: 3 commits l49+games 2: go chess10+address: #games"11+eq "$(node --no-warnings "$T_HOME/test/page.ts" activity "$p" '#tools')" "2 commits in the last year12+ 2026-09-03: 2 commits l413+tools 1: notes14+address: #tools"@@ -0,0 +1,27 @@
1+# A person's avatar is drawn from their name: the same name gives the same2+# art every time and at every size (on their page and on the home page's3+# profiles), and two names give two different ones. It is SVG in the4+# avatar tokens' classes, no colours of its own.5+. "$(dirname "$0")/../lib.sh"6+user dami7+user ana8+for r in dami/chess 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+a=$(node "$T_HOME/lib/avatar.ts" dami 72)15+eq "$(node "$T_HOME/lib/avatar.ts" dami 72)" "$a"16+b=$(node "$T_HOME/lib/avatar.ts" ana 72)17+[ "$a" != "$b" ] || fail "dami and ana have the same avatar"18+case $a in '<svg class="avatar" viewBox="0 0 72 72" width="72" height="72" aria-hidden="true"><rect class="av-g" width="72" height="72"/>'*) ;; *) fail "not an avatar: $a" ;; esac19+printf '%s' "$a" | grep -q '#' && fail "an avatar names a colour: $a"20+export FORGE_STRICT=121+build22+# The same drawing on dami's page (72px) and the home page (36px).23+art=$(printf '%s' "$a" | sed 's|^<svg[^>]*>||')24+grep -qF "$art" "$FORGE_WWW/dami/index.html" || fail "dami's page draws dami differently"25+grep -qF "$art" "$FORGE_WWW/index.html" || fail "the home page draws dami differently"26+has "$FORGE_WWW/index.html" '<li class="profile"><svg class="avatar" viewBox="0 0 72 72" width="36" height="36" aria-hidden="true">'27+has "$FORGE_WWW/dami/index.html" '<section class="person">'@@ -1,5 +1,5 @@
11 # The home page has a profile for each owner with public repos: their2-# letter, their name linking to their page, how many public repos they2+# avatar, their name linking to their page, how many public repos they33 # have and when they were last active. No shelves of repos: those are on44 # each owner's page. The header says the forge's name.55 . "$(dirname "$0")/../lib.sh"@@ -23,13 +23,10 @@ has "$i" '<div class="title">
2323 has "$i" '<section class="panel profiles"><header class="panel-head"><h2>profiles</h2><span class="n">2</span></header>'2424 has "$i" '<a class="profile-name" href="/ana/">ana</a><span class="profile-meta">1 repo, active <time datetime="2026-09-01T03:01:00Z" '2525 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>'26+has "$i" '<li class="profile"><svg class="avatar" viewBox="0 0 72 72" width="36" height="36" aria-hidden="true">'2727 # bob has only a private repo: no profile.2828 lacks "$i" 'bob'2929 lacks "$i" 'data-shelves|class="card'3030 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>'3431 # The owner page keeps its shelves.3532 has "$FORGE_WWW/dami/index.html" 'data-shelves'@@ -24,7 +24,7 @@ has "$i" '<li><a href="/ana/#other">other<span class="n">1 repo</span></a></li>'
2424 has "$FORGE_WWW/dami/index.html" '<a href="#tools" data-shelf="tools">tools<span class="n">1</span></a>'2525 has "$FORGE_WWW/dami/index.html" 'data-shelf="tools" id="tools"'2626 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>'27+has "$t" '<section class="panel shelf" data-shelf="games" id="games"><ul class="rows cards'2828 has "$t" '<a href="#games" data-shelf="games">games<span class="n">3</span></a>'2929 has "$t" '<span class="desc">ana / </span>cards</a>'3030 lacks "$t" 'secret'@@ -33,5 +33,5 @@ has site/assets/ui.ts 'location.hash'
3333 # A single repo's build redoes /topics/ too.3434 printf 'tools\n' >"$FORGE_ROOT/ana/notes.git/category"3535 build ana/notes36-has "$t" 'data-shelf="tools" id="tools"><header class="panel-head"><h2>tools</h2><span class="n">2</span>'36+has "$t" '<a href="#tools" data-shelf="tools">tools<span class="n">2</span></a>'3737 has "$i" '<li><a href="/topics/#tools">tools<span class="n">2 repos</span></a></li>'@@ -14,16 +14,16 @@ at "$T/w/d" commit "add ok" ok
1414 ok at "$T/w/d" "$FORGE" attest1515 ok push_as dami "$T/w/d" origin main refs/notes/ci1616 ok forge lake17-has "$T_LOG" ', 2 attestations;'17+has "$T_LOG" ', 2 attestations, '1818 pass=$(git -C "$T/w/d" rev-parse HEAD)1919 eq "$(lq "SELECT string_agg(result, ' ' ORDER BY result) FROM ci")" "fail pass"2020 eq "$(lq "SELECT \"commit\" || ' ' || tree FROM ci WHERE result = 'pass'")" "$pass $(git -C "$T/w/d" rev-parse HEAD^{tree})"2121 eq "$(lq "SELECT count(*) FROM ci WHERE verified AND signer = 'dami' AND runner LIKE '%@%' AND duration_s >= 0 AND exit = (result = 'fail')::INT AND started IS NOT NULL")" 22222 ok forge lake23-has "$T_LOG" ', 0 attestations;'23+has "$T_LOG" ', 0 attestations, '2424 at "$T/w/d" commit "more"2525 ok at "$T/w/d" "$FORGE" attest2626 ok push_as dami "$T/w/d" origin main refs/notes/ci2727 ok forge lake28-has "$T_LOG" ', 1 attestation;'28+has "$T_LOG" ', 1 attestation, '2929 eq "$(lq "SELECT count(*) || ' ' || count(DISTINCT id) FROM ci")" "3 3"@@ -13,7 +13,7 @@ ok forge lake
1313 counts() { lq "SELECT (SELECT count(*) FROM events) || ' ' || (SELECT count(*) FROM builds) || ' ' || (SELECT count(*) FROM reviews)"; }1414 eq "$(counts)" "3 1 2"1515 ok forge lake16-has "$T_LOG" '^lake: added 0 events, 0 builds, 0 review log lines, 0 ended, 0 attestations;'16+has "$T_LOG" '^lake: added 0 events, 0 builds, 0 review log lines, 0 ended, 0 attestations, 0 commits;'1717 eq "$(counts)" "3 1 2"1818 # A revision: one push event and the review's log read again, whole (its1919 # old rows are replaced, not repeated); one more build.@@ -13,7 +13,7 @@ push_as ana "$T/w/a" origin HEAD:ana/idea
1313 echo "looks fine" | ssh_as dami comment dami/greet 1 >"$T_LOG" 2>&1 || fail "comment failed"1414 build dami/greet1515 ok forge lake16-has "$T_LOG" '^lake: added 4 events, 1 build, 2 review log lines, 0 ended, 0 attestations;'16+has "$T_LOG" '^lake: added 4 events, 1 build, 2 review log lines, 0 ended, 0 attestations, [0-9]+ commits?;'1717 exists "$FORGE_ROOT/.forge/lake/catalog.sqlite"1818 exists "$FORGE_ROOT/.forge/lake/data"1919 eq "$(lq "SELECT string_agg(type, ' ' ORDER BY type) FROM events")" "built comment push push"@@ -1,5 +1,6 @@
1-# The shelves (owner pages, /topics/) are panels: each one's head strip names the shelf2-# and counts its repos, and the repos are rows inside it.1+# The shelves (owner pages, /topics/) are panels, the repos rows inside2+# them. The tabs over them name the shelf and count its repos, so a3+# shelf's panel has no head strip saying it again.34 . "$(dirname "$0")/../lib.sh"45 user dami56 for r in dami/chess dami/go dami/notes; do@@ -13,8 +14,11 @@ printf 'games\n' >"$FORGE_ROOT/dami/go.git/category"
1314 export FORGE_STRICT=11415 build1516 i=$FORGE_WWW/topics/index.html16-has "$i" '<section class="panel shelf" data-shelf="games" id="games"><header class="panel-head"><h2>games</h2><span class="n">2</span></header>'17-has "$i" '<section class="panel shelf" data-shelf="other" id="other"><header class="panel-head"><h2>other</h2><span class="n">1</span></header>'17+has "$i" '<section class="panel shelf" data-shelf="games" id="games"><ul class="rows cards'18+has "$i" '<section class="panel shelf" data-shelf="other" id="other"><ul class="rows cards'19+has "$i" '<a href="#games" data-shelf="games">games<span class="n">2</span></a>'20+lacks "$i" 'class="panel-head"><h2>(games|other|all)</h2>'1821 has "$i" '<ul class="rows cards nav-cards">'1922 # The owner's page too.20-has "$FORGE_WWW/dami/index.html" '<section class="panel shelf" data-shelf="games" id="games"><header class="panel-head">'23+has "$FORGE_WWW/dami/index.html" '<section class="panel shelf" data-shelf="games" id="games"><header class="panel-head day-head" hidden>'24+lacks "$FORGE_WWW/dami/index.html" 'class="panel-head"><h2>(games|other|all)</h2>'@@ -0,0 +1,18 @@
1+# A page's facts, on one line under the name: how many public repos and2+# sites, and the month its owner joined, from the first key added (forge3+# user add notes it) or the lake's first event, whichever is earlier.4+. "$(dirname "$0")/../lib.sh"5+user ana6+repo ana/notes7+clone ana ana/notes "$T/w/notes"8+at "$T/w/notes" commit "first"9+push_as ana "$T/w/notes" origin main10+repo --private ana/secret11+export FORGE_STRICT=112+build13+p=$FORGE_WWW/ana/index.html14+has "$p" '<span>1 repo, joined Sep 2026</span>'15+# Noted earlier (a key added in March): that month.16+printf 'joined %s\n' "$(date -u -j -f %Y-%m-%d 2026-03-15 +%s 2>/dev/null || date -u -d 2026-03-15 +%s)" >"$FORGE_ROOT/.forge/profiles/ana"17+build18+has "$p" '<span>1 repo, joined Mar 2026</span>'@@ -0,0 +1,21 @@
1+# Unpinned, a page shows its owner's three most recently active public2+# repos, newest first, as "recently active"; each with what it is, its3+# latest release or its site, and when it last changed.4+. "$(dirname "$0")/../lib.sh"5+user ana6+for r in one two three four; do7+ repo "ana/$r" "the $r repo"8+ clone ana "ana/$r" "$T/w/$r"9+ tick 360010+ at "$T/w/$r" commit "first"11+ push_as ana "$T/w/$r" origin main12+done13+tick 360014+at "$T/w/one" commit "again"15+push_as ana "$T/w/one" origin main16+export FORGE_STRICT=117+build18+p=$FORGE_WWW/ana/index.html19+has "$p" '<section class="panel pins"><header class="panel-head"><h2>recently active</h2></header>'20+eq "$(grep -o 'class="pin-name" href="/ana/[a-z]*/"' "$p" | sed 's|.*/ana/||; s|/"||' | tr '\n' ' ')" "one four three "21+has "$p" '<p class="pin-desc">the one repo</p>'@@ -0,0 +1,29 @@
1+# Only public repos are pinned: a pinned repo made private drops out of its2+# owner's page (and the defaults never take one), and no private repo shows3+# on another owner's page or the home page.4+. "$(dirname "$0")/../lib.sh"5+user ana6+user bob7+for r in ana/notes ana/cards bob/game; do8+ repo "$r"9+ clone "${r%%/*}" "$r" "$T/w/${r#*/}"10+ tick 360011+ at "$T/w/${r#*/}" commit "first"12+ push_as "${r%%/*}" "$T/w/${r#*/}" origin main13+done14+repo --private bob/hidden15+clone bob bob/hidden "$T/w/hidden"16+tick 360017+at "$T/w/hidden" commit "newest of all"18+push_as bob "$T/w/hidden" origin main19+ok ssh_as ana profile pin cards notes20+export FORGE_STRICT=121+build22+p=$FORGE_WWW/ana/index.html23+eq "$(grep -o 'class="pin-name" href="/ana/[a-z]*/"' "$p" | sed 's|.*/ana/||; s|/"||' | tr '\n' ' ')" "cards notes "24+rm "$FORGE_ROOT/ana/cards.git/git-daemon-export-ok"25+build26+eq "$(grep -o 'class="pin-name" href="/ana/[a-z]*/"' "$p" | sed 's|.*/ana/||; s|/"||' | tr '\n' ' ')" "notes "27+lacks "$p" '/ana/cards/'28+has "$FORGE_WWW/bob/index.html" '<h2>recently active</h2>'29+for f in "$FORGE_WWW/bob/index.html" "$FORGE_WWW/index.html" "$FORGE_WWW/ana/index.html"; do lacks "$f" '/bob/hidden/|newest of all'; done@@ -0,0 +1,28 @@
1+# profile refuses what it can't show as asked, saying why, and keeps what2+# was there: a bio over 120 characters or with control characters, a link3+# that isn't one https:// address (http, a password in it, no host), pins4+# that aren't the caller's own public repos, or more than three.5+. "$(dirname "$0")/../lib.sh"6+user ana7+user bob8+for r in ana/a ana/b ana/c ana/d bob/x; do repo "$r"; done9+repo --private ana/secret10+ok ssh_as ana profile bio "short and fine"11+f=$FORGE_ROOT/.forge/profiles/ana12+fails_with 'a bio of 121 characters; it can have 120 at most' ssh_as ana profile bio "$(printf 'x%.0s' $(seq 121))"13+ok ssh_as ana profile bio "$(printf 'é%.0s' $(seq 120))"14+ok ssh_as ana profile bio "short and fine"15+fails_with 'without control characters' ssh_as ana profile bio "$(printf 'one\001two')"16+fails_with 'only https:// links' ssh_as ana profile link http://ana.example/17+fails_with 'only https:// links' ssh_as ana profile link 'javascript:alert(1)'18+fails_with 'without a user name or password' ssh_as ana profile link https://me:pw@ana.example/19+fails_with "isn't a host name" ssh_as ana profile link https://localhost/20+fails_with 'one link' ssh_as ana profile link https://a.example/ https://b.example/21+fails_with "bob/x isn't yours" ssh_as ana profile pin bob/x22+fails_with 'secret is private' ssh_as ana profile pin a secret23+fails_with 'you have no repo nope' ssh_as ana profile pin nope24+fails_with '4 repos; pin 3 at most' ssh_as ana profile pin a b c d25+fails_with 'a is there twice' ssh_as ana profile pin a a26+fails_with 'usage: profile' ssh_as ana profile colour red27+has "$f" '^bio short and fine$'28+lacks "$f" '^(link|pin) '@@ -0,0 +1,52 @@
1+# Anyone sets what their own page says over ssh: profile prints it,2+# profile bio, link and pin set it (rebuilding their page and the home3+# page, and no one else's), --clear clears one. The page shows the bio, the link without its4+# scheme, and the pinned repos in order; backups carry it.5+. "$(dirname "$0")/../lib.sh"6+user ana7+user bob8+repo bob/game9+for r in notes cards site; do10+ repo "ana/$r" "about $r"11+ clone ana "ana/$r" "$T/w/$r"12+ tick 360013+ at "$T/w/$r" commit "first"14+ push_as ana "$T/w/$r" origin main15+done16+export FORGE_STRICT=117+build18+p=$FORGE_WWW/ana/index.html19+ok ssh_as ana profile20+eq "$(output)" "bio: (none)21+link: (none)22+pin: (none: your page shows your 3 most recently active repos)"23+mtime() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1"; }24+bob=$(mtime "$FORGE_WWW/bob/index.html")25+home=$(mtime "$FORGE_WWW/index.html")26+sleep 127+sync_builds28+ok ssh_as ana profile bio 'writes <small> tools & games'29+eq "$(mtime "$FORGE_WWW/bob/index.html")" "$bob"30+[ "$(mtime "$FORGE_WWW/index.html")" != "$home" ] || fail "the home page wasn't rebuilt"31+has "$T_LOG" "^your page's bio: writes <small> tools & games$"32+has "$p" '<p class="bio">writes <small> tools & games</p>'33+ok ssh_as ana profile link https://ana.example/34+has "$p" '<a class="link" href="https://ana.example/" rel="me nofollow">.*ana.example</a>'35+ok ssh_as ana profile pin cards ana/notes36+has "$p" '<h2>pinned</h2>'37+eq "$(grep -o 'class="pin-name" href="/ana/[a-z]*/"' "$p" | sed 's|.*/ana/||; s|/"||' | tr '\n' ' ')" "cards notes "38+ok ssh_as ana profile39+eq "$(output)" "bio: writes <small> tools & games40+link: https://ana.example/41+pin: cards notes"42+ok forge backup --dry-run43+has "$FORGE_ROOT/.forge/backup/stage/.forge/profiles/ana" '^pin cards notes$'44+ok ssh_as ana profile bio --clear45+ok ssh_as ana profile link --clear46+ok ssh_as ana profile pin --clear47+lacks "$p" 'class="bio"|class="link"'48+has "$p" '<h2>recently active</h2>'49+ok ssh_as ana profile50+eq "$(output)" "bio: (none)51+link: (none)52+pin: (none: your page shows your 3 most recently active repos)"@@ -0,0 +1,39 @@
1+# A repo's code page opens with its front matter: its name, what it is2+# about, its topics (its shelf, then its .forge/topics, linked where the3+# home page's topics go when a shelf has that name), and facts read from4+# the default branch: the license (an SPDX id when the text says which)5+# and its languages by bytes, without vendored, generated or lock files,6+# an executable counted by its #! line. Facts with nothing to say are left7+# out.8+. "$(dirname "$0")/../lib.sh"9+user ana10+repo ana/chess "a chess engine for the terminal"11+clone ana ana/chess "$T/w/c"12+mkdir -p "$T/w/c/.forge" "$T/w/c/src" "$T/w/c/vendor" "$T/w/c/bin"13+printf 'chess\n# a comment\nBoard Games\n\n' >"$T/w/c/.forge/topics"14+printf 'MIT License\n\nCopyright (c) 2026 ana\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the "Software"), to deal\nin the Software without restriction.\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n' >"$T/w/c/LICENSE"15+head -c 6000 /dev/zero | tr '\0' 'a' >"$T/w/c/src/engine.ts"16+head -c 3000 /dev/zero | tr '\0' 'b' >"$T/w/c/src/board.go"17+head -c 1000 /dev/zero | tr '\0' 'c' >"$T/w/c/src/style.css"18+head -c 90000 /dev/zero | tr '\0' 'v' >"$T/w/c/vendor/lib.js"19+head -c 50000 /dev/zero | tr '\0' 'l' >"$T/w/c/package-lock.json"20+{ printf '#!/bin/sh\n'; head -c 2000 /dev/zero | tr '\0' 's'; } >"$T/w/c/bin/chess"21+chmod +x "$T/w/c/bin/chess"22+at "$T/w/c" commit "first" README.md23+push_as ana "$T/w/c" origin main24+printf 'games\n' >"$FORGE_ROOT/ana/chess.git/category"25+export FORGE_STRICT=126+build27+p=$FORGE_WWW/ana/chess/index.html28+has "$p" '<h1>chess</h1>'29+has "$p" '<p class="about">a chess engine for the terminal</p>'30+has "$p" '<p class="rf-topics" aria-label="topics"><a href="/ana/#games">games</a><span>chess</span><span>board games</span></p>'31+has "$p" '<dt>license</dt><dd><a href="/ana/chess/blob/main/LICENSE/">MIT</a></dd>'32+has "$p" '<span class="langs"><span><i class="lc1" aria-hidden="true"></i>TypeScript <b>50%</b></span><span><i class="lc2" aria-hidden="true"></i>Go <b>25%</b></span><span><i class="lc3" aria-hidden="true"></i>Shell <b>16.7%</b></span></span>'33+lacks "$p" 'JavaScript|<dt>(site|release|ci|reviews)</dt>'34+# A license the page can't name is a link to its file.35+printf 'You may do anything with this, except sell it.\n' >"$T/w/c/LICENSE"36+at "$T/w/c" commit "own terms" README.md37+push_as ana "$T/w/c" origin main38+build ana/chess39+has "$p" '<dt>license</dt><dd><a href="/ana/chess/blob/main/LICENSE/" class="mono">see LICENSE</a></dd>'@@ -0,0 +1,32 @@
1+# The front matter's facts column: the site, the latest release (its tag2+# and when), main's CI (passed or failed, and the commit), and how many3+# reviews are open, each linking to where it is.4+. "$(dirname "$0")/../lib.sh"5+user dami6+repo dami/greet "say hello"7+clone dami dami/greet "$T/w/d"8+mkdir -p "$T/w/d/.forge"9+printf '#!/bin/sh\ntrue\n' >"$T/w/d/.forge/ci"10+chmod +x "$T/w/d/.forge/ci"11+at "$T/w/d" commit "first"12+at "$T/w/d" git tag v113+push_as dami "$T/w/d" origin main v114+export FORGE_USER=dami15+ok at "$T/w/d" "$FORGE" attest16+ok push_as dami "$T/w/d" origin refs/notes/ci17+mkdir "$T/f" && printf 'notes\n' >"$T/f/notes.txt"18+ok at "$T/w/d" "$FORGE" release publish v1 "$T/f/notes.txt"19+mkdir -p "$T/site" && printf '<h1>docs</h1>\n' >"$T/site/index.html"20+ok at "$T/w/d" "$FORGE" site push "$T/site" docs21+unset FORGE_USER22+at "$T/w/d" git switch -q -c dami/wave23+at "$T/w/d" commit "wave"24+push_as dami "$T/w/d" -o title=Wave origin dami/wave25+export FORGE_STRICT=126+build27+p=$FORGE_WWW/dami/greet/index.html28+short=$(git -C "$T/w/d" rev-parse --short=7 main)29+has "$p" '<dt>site</dt><dd><a class="site" href="/sites/dami/greet/docs/">'30+has "$p" '<dt>release</dt><dd><a class="mono" href="/dami/greet/releases/#v1">v1</a><time datetime="2026-09-01T00:01:00Z"'31+has "$p" "<dt>ci</dt><dd><span class=\"pass\">passed</span><a class=\"sha\" href=\"/dami/greet/commit/[0-9a-f]{40}/\">$short</a></dd>"32+has "$p" '<dt>reviews</dt><dd><a href="/dami/greet/reviews/">1 open</a></dd>'@@ -19,6 +19,7 @@ build
1919 i=$FORGE_WWW/dami/index.html2020 # The page as built: "all" first, then the shelves, fuller first.2121 eq "$(grep -o '<section class="panel shelf" data-shelf="[a-z]*"' "$i" | sed 's/.*="//; s/"$//' | tr '\n' ' ')" " games tools other "22-has "$i" '<section class="panel shelf" data-shelf=""><header class="panel-head"><h2>all</h2><span class="n">4</span></header>'22+has "$i" '<section class="panel shelf" data-shelf=""><header class="panel-head day-head" hidden>'23+has "$i" '<a href="#shelves" class="cur" aria-current="page" data-shelf="">all<span class="n">4</span></a>'2324 # As a reader sees it: one panel, newest first.2425 eq "$(node --no-warnings "$T_HOME/test/page.ts" shelves "$i")" "all 4: blog notes go chess"@@ -30,7 +30,7 @@ lacks "$i" 'secret'
3030 # One shelf is all there is: no "all" panel, and the shelf stands for it.3131 has "$FORGE_WWW/dami/index.html" '<section class="panel shelf" data-shelf="" id="games">'3232 lacks "$FORGE_WWW/dami/index.html" '<span class="desc">dami / </span>'33-# One owner's only shelf is "other": titled repositories.34-has "$FORGE_WWW/ana/index.html" '<h2>repositories</h2>'33+# One owner's only shelf is "other": no tabs, and no head strip either.34+lacks "$FORGE_WWW/ana/index.html" 'class="panel-head"><h2>(repositories|other)</h2>'3535 ok forge backup --dry-run3636 has "$FORGE_ROOT/.forge/backup/stage/dami/chess.git/category" '^Games$'