fuzzyworld

fuzzyworld-tools / commit

Site-wide header and footer, card styling, apps index without commit lists

0a08554d79457d735a3f3124c4b0660b306e0dec · Pavel Safronov · 2026-10-27 13:53:46 -0700 · parents 68a14e219f6c

Claude Code usage: this commit ~42.3K out / ~5.5M cached · cost unknown (no per-model trailer) · session 3 tally 212 in / 60.4K out / 7.7M cached · usage by commit and session

Site-wide header and footer, card styling, apps index without commit lists

- Shared header (fuzzyworld.net, Code, Apps, CI, Contribute, Why, Principles)
  and a three-column footer on every page of both sites, from one NAV list.
- /contribute/, /principles/ and 404.html move into server/content/ and use
  the common layout; /how/ is the unbuilt draft _how.html.
- style.css: tokens, header/footer, tabs, card grid for repos and apps.
- Apps index: remove the per-app commit lists and the code behind them.
- GEN_VERSION 8 (full rebuild); fw publish-all prints [n/total] name (secs)
  and also rebuilds the CI dashboard.

Usage: 212 in / 60.4K out / 7.7M cached (session tally)
rtk:   2.5M saved, 53.3% over 5861 cmds (global/lifetime)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
 PLAN.md                           |   7 +-
 README.md                         |   8 +--
 server/content/_how.html          |  88 +++++++++++++++++++++++++
 server/content/contribute.html    |  35 ++++++++++
 server/content/principles.html    |  13 ++++
 server/fw                         |   7 +-
 server/gen.py                     | 131 +++++++++++++++++---------------------
 server/install.sh                 |   4 +-
 server/site/404.html              |  17 -----
 server/site/contribute/index.html |  52 ---------------
 server/site/how/index.html        | 105 ------------------------------
 server/site/principles/index.html |  30 ---------
 server/site/style.css             |  57 +++++++++++------
 13 files changed, 244 insertions(+), 310 deletions(-)

diff --git a/PLAN.md b/PLAN.md
index 4aa51f8..e0bcb8a 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -22,7 +22,7 @@ never anything from a repo).
 
 All done: ✅ live since 2026-10-27 (v1.1.0), unless noted.
 
-1. ✅ Apps index: newest deploy first, with what changed. Live since 2026-10-26.
+1. ✅ Apps index: newest deploy first. (It once listed the commits of each deploy; removed 2026-10-27 as noise.)
 2. ✅ **LICENSE file** (MIT) in this repo. Each repo's license on its summary page.
 3. ✅ **Principles** in four places: `/why/`, `/how/`, a new `/principles/` page, and a "Philosophy" section in the
    README. `/how/` is still a draft that `install.sh` doesn't install (see Backlog); the principle is in it for
@@ -91,9 +91,8 @@ The order goes from cheap to expensive, and each step reuses what the one before
   - CI `cache:` support (a per-repo volume on TrueNAS), and `services:`
   - Branch pages: link to the default branch's page when a file is identical, instead of a copy
 - Found in review (2026-10-27): discussed, not done
-  - Site pages in Markdown: `/why/` done (`server/content/why.md`, `gen.py content`). Still HTML: `/principles/`,
-    `/contribute/`, `/how/` draft, `404.html`
-  - `/how/` goes live: finish the draft, add it to `server/install.sh` (`/why/` already says it is coming)
+  - Site pages: all now in `server/content/` (`gen.py content`), including `404.html`; `/how/` is `_how.html` (draft, not built)
+  - `/how/` goes live: finish the draft and rename `server/content/_how.html` to `how.html` (`/why/` already says it is coming)
   - CI runner tags (`docker`, `chrome`, `android`) and the laptop as a second runner (`fw-runner.py --once`):
     designed, not built; there is one runner, and it takes every job
   - CI secrets: none are set, so jobs that deploy elsewhere fail (`dreamhost`'s rsync, engye's store uploads).
diff --git a/README.md b/README.md
index 837b14e..265d695 100644
--- a/README.md
+++ b/README.md
@@ -61,7 +61,7 @@ Then `pnpm run deploy` / `npm run deploy`. **`fw deploy`**:
 
 `--dry-run` does everything except upload.
 
-The apps index lists the most recently deployed app first. Under each app from a **public** repo it shows the commit subjects deployed since its previous deploy (up to 5, linked to their commit pages); apps from private repos show only the date, so their history stays private. `.fuzzyworld.json` holds only the latest deploy, so the index remembers earlier ones in `~/fw/state/apps.json`.
+The apps index lists the most recently deployed app first, with its description, a screenshot if the runner has taken one, and a source link for apps from public repos.
 
 Apps hosted elsewhere are listed on the index from `server/site-apps/extra.json` (engye: Cloudflare Pages; `apps.fuzzyworld.net/engye/...` redirects there).
 
@@ -79,7 +79,7 @@ On the laptop, `bin/fw` runs `deploy` locally and forwards repo commands to Drea
 | `fw private <name>` | unpublish right away: pages, clone URL and index entry are removed |
 | `fw describe <name> TEXT` | set the description shown on the index and summary page |
 | `fw publish <name>` | refresh one repo's public copy and pages (the push hook does this) |
-| `fw publish-all` | refresh every repo, then the index |
+| `fw publish-all` | refresh every repo (printing `[n/total] name (seconds)` as each finishes), then the index and the CI dashboard |
 | `fw ci <name> [branch]` | queue a CI run of a branch's latest commit (default: the default branch) |
 | `fw ci-list` | queued and running CI jobs |
 | `fw ci-watch` | follow CI in the terminal (private repos too): running jobs with each job's state, the queue, the last 5 results; ends when nothing is queued or running |
@@ -104,7 +104,7 @@ Other settings in the repo config: `fuzzyworld.description`, `fuzzyworld.ci` (`o
 | `~/apps.fuzzyworld.net/<app>/` | deployed apps (`fw deploy`); `index.html` at the root is generated |
 | `~/fw/tools/server/` | this repo's `server/` directory, installed by `server/install.sh` |
 | `~/bin/fw` | symlink to `~/fw/tools/server/fw` |
-| `~/fw/state/` | per-repo page manifests and locks; `apps.json`: recent deploys per app (for the apps index) |
+| `~/fw/state/` | per-repo page manifests and locks |
 | `~/fw/logs/<name>.log` | output of each push's publish run |
 | `~/fw/pylib/` | Python-Markdown 3.7 (README rendering) and Pygments 2.21.0 (syntax highlighting), `pip install --target`; `install.sh` adds Pygments |
 | `~/fw/queue/pending/`, `running/` | CI jobs, one JSON file each (see [CI](#ci)) |
@@ -133,7 +133,7 @@ Every repo uses the shared hook via `core.hooksPath=~/fw/tools/server/hooks`. `p
 | `/<name>/ci/badge.svg` | status of the latest pipeline on the default branch |
 | `/ci/` | CI dashboard: running pipelines (each job's state and time), the queue, the last 50 results, for public repos |
 | `/<name>/files.json`, `/<name>/objects/<sha>` | for VS Code in the browser: every file's blob SHA and size, and each file's contents (≤ 1 MB) |
-| `/why/`, `/principles/`, `/contribute/` | about the site. `/why/` is written in Markdown (`server/content/why.md`, rendered by `fw content`); the other two are still hand-written HTML in `server/site/` |
+| `/why/`, `/principles/`, `/contribute/` | about the site. All are files in `server/content/` (Markdown, or an HTML fragment with a leading `<!-- title: … -->`), rendered by `fw content` into the common page layout. A file starting with `_` (the `/how/` draft) is not built. `fw content` also writes `404.html` |
 
 **Branches:** the default branch, and every branch **not yet merged** into it, get browse pages. A merged branch
 adds nothing new to read and would duplicate every page, so the branches page lists it with a link to its last
diff --git a/server/content/_how.html b/server/content/_how.html
new file mode 100644
index 0000000..7a7be2d
--- /dev/null
+++ b/server/content/_how.html
@@ -0,0 +1,88 @@
+<!-- title: How I moved off GitLab -->
+<!-- DRAFT 2026-10-27 for code.fuzzyworld.net/how/ (plan Backlog item 3). Pavel edits before it goes live; on hold until the item 2 features. Not installed or linked. -->
+<h1>How I moved off GitLab</h1>
+<p><a href="/why/">Why</a> I left is short. <em>How</em> took about a day, 89 repositories and 35 web
+    apps, and turned up enough surprises that it seemed worth writing down for anyone thinking about the same
+    move.</p>
+
+<h2>What I ended up with</h2>
+<ul>
+    <li><strong>Bare git repos on cheap shared hosting</strong> (DreamHost). I push over SSH; nothing else
+        runs there permanently.</li>
+    <li><strong>code.fuzzyworld.net</strong>: static browse pages, generated by a git hook when I push, plus
+        read-only <code>git clone https://…</code>. No app server, no accounts, no rate limits beyond what
+        Apache can serve.</li>
+    <li><strong>apps.fuzzyworld.net</strong>: every former GitLab Pages site, at the same relative path, built
+        and uploaded from my laptop with one command.</li>
+    <li><strong>A backup mirror at home</strong> that can read, but not write.</li>
+</ul>
+<p>What I gave up: issues, merge requests and search. Only the default branch can be browsed. Cloning over
+    plain HTTP is slower for big repos (a 244 MB repo takes about 50 seconds).</p>
+
+<h2>Getting everything out of GitLab</h2>
+<p>The code is the easy part: <code>git clone --mirror</code> gets every branch and tag. The rest lives in
+    GitLab's settings and is easy to forget:</p>
+<ul>
+    <li><strong>CI/CD variables.</strong> Signing keys, API tokens, deploy keys. Export them before anything
+        else; losing an Android signing key means you can't update that app in the Play Store again.</li>
+    <li><strong>Release files.</strong> APKs and tarballs attached to releases are often CI <em>job
+        artifacts</em>, and those expire. Five of mine already had.</li>
+    <li><strong>Pipeline schedules</strong>, issues, merge requests and wiki pages. Confidential issues in a
+        public project need separate handling, or the export makes them public.</li>
+    <li><strong>Merge-request commits</strong> live under <code>refs/merge-requests/*</code>, which a normal
+        clone doesn't fetch. I kept them as <code>refs/gitlab/merge-requests/*</code>.</li>
+</ul>
+
+<h2>Traps I fell into</h2>
+<ul>
+    <li><strong>The rate limit bit me during the export.</strong> The <code>glab</code> CLI's login is an
+        OAuth token, which has to be sent as <code>Authorization: Bearer</code>. Sent as
+        <code>PRIVATE-TOKEN</code>, every call counted as anonymous, and my home IP was blocked within
+        minutes. Web URLs for job artifacts and uploads count as anonymous even with a token; use the API
+        endpoints instead.</li>
+    <li><strong>My own git hook refused my own history.</strong> A global pre-push hook that rejects
+        lightweight tags (good for new releases) blocked mirroring old ones. Historical tags have to move
+        unchanged, so the migration pushes skip the hook.</li>
+    <li><strong>Shared hosting kills big git operations.</strong> Cloning a 284 MB repo over SSH failed with
+        "git-pack-objects died": the host's watchdog killed git's packing step. Limiting git's threads and
+        memory (<code>pack.threads 1</code>, <code>pack.windowMemory 64m</code>) fixed it for good.</li>
+    <li><strong>The host executes things you might not expect.</strong> DreamHost runs <code>.php</code> files
+        and server-side includes by default. A code browser that wrote repository files into the web root
+        with their real names would have run them. Browse pages are pre-generated HTML, raw copies are
+        images only, and every response says <code>script-src 'none'</code>.</li>
+    <li><strong>Node on a hardened kernel.</strong> The host's kernel refuses executable memory, so modern
+        Node crashes as soon as its JIT starts. <code>--jitless</code> works; native npm modules don't build,
+        so installs use <code>--ignore-scripts</code>.</li>
+</ul>
+
+<h2>Moving the apps</h2>
+<p>Every app moved to the same path under a new domain, and they all share one origin, exactly as they did on
+    <code>*.gitlab.io</code>. That means two apps can still clash over <code>localStorage</code> keys, a
+    service worker can claim more than its own folder, and two installed web apps can merge on Android if
+    their manifests look alike. So the deploy script refuses to upload anything whose URLs, manifest or
+    service worker reach outside the app's own path.</p>
+<p>Small things broke in surprising places: GitLab-style links (<code>/-/blob/main/…</code>,
+    <code>/-/commit/…</code>) baked into apps, short commit IDs where the new site expected long ones, a
+    vite config whose root was a subfolder, Google sign-in that only works on domains Firebase knows about,
+    and OAuth redirect URIs registered with the old address.</p>
+
+<h2>Saying goodbye without breaking links</h2>
+<p>Each GitLab project got one last commit: a README pointing to the new home, and, for sites, a redirect
+    page in place of the app. The redirect keeps the path, query and <code>#hash</code>, so deep links land on
+    the matching new page. It also retires any old service worker, which would otherwise keep serving the
+    cached old app to returning visitors forever.</p>
+
+<h2>Would I do it again?</h2>
+<p>Yes. Everything is static, it costs nothing extra on hosting I already paid for, and nobody needs an
+    account to read my code. The one real loss is easy contributions: there are no merge requests here
+    yet.</p>
+
+<h2>Principles</h2>
+<p>Things made with AI should be shared freely with the world. The code and apps here were made with
+    AI, so they are MIT-licensed: use them, change them, share them.</p>
+<p>My conversations with the AI are different. My words and my way of working are mine. I publish a
+    conversation only when I choose to, I can take that permission back, and nobody may copy my words or
+    my style of work without my express permission.</p>
+<p>More on the <a href="/principles/">principles</a> page.</p>
+
+<p><a href="/why/">← Why I left GitLab</a></p>
diff --git a/server/content/contribute.html b/server/content/contribute.html
new file mode 100644
index 0000000..f722966
--- /dev/null
+++ b/server/content/contribute.html
@@ -0,0 +1,35 @@
+<!-- title: How to contribute -->
+<h1>How to contribute</h1>
+<p>Thanks for wanting to help! This site has no accounts, issue tracker or merge requests, so contributions
+    arrive by email. Two ways work, both with plain git.</p>
+
+<h2>1. Share a branch</h2>
+<p>Best for anything bigger than a small fix.</p>
+<ol>
+    <li>Clone the repository: <code>git clone https://code.fuzzyworld.net/&lt;repo&gt;.git</code></li>
+    <li>Make your change on a branch, and push that branch to <strong>any</strong> public git host you like
+        (GitHub, Codeberg, GitLab, your own server).</li>
+    <li>Email the repository URL, the branch name and a short description to
+        <code>contrib-68uq+&lt;repo&gt;@fuzzyworld.net</code>, using the repository's name in place of
+        <code>&lt;repo&gt;</code>.</li>
+</ol>
+<p>I fetch the branch, review it locally, and reply by email.</p>
+
+<h2>2. Email a patch</h2>
+<p>Good for small changes. Commit your change, then create a patch file:</p>
+<pre>git format-patch origin/main</pre>
+<p>and attach the <code>.patch</code> file(s) to an email to <code>contrib-68uq+&lt;repo&gt;@fuzzyworld.net</code>.
+    If you use <code>git send-email</code>, that address works there too.</p>
+
+<h2>Bug reports and ideas</h2>
+<p>No git needed: email <code>bugs-k7q4+&lt;repo&gt;@fuzzyworld.net</code>. Many of the apps have a
+    <strong>report</strong> link that fills in the details for you.</p>
+
+<h2>Before you send</h2>
+<ul>
+    <li>Check the repository's README (and CONTRIBUTING, if it has one) for how to build and test it.</li>
+    <li>One change per branch or patch, please; it makes review much quicker.</li>
+    <li>By contributing you agree that your change is shared under the repository's license.</li>
+</ul>
+
+<p><a href="/why/">Why there are no merge requests here</a></p>
diff --git a/server/content/principles.html b/server/content/principles.html
new file mode 100644
index 0000000..1d3b3ae
--- /dev/null
+++ b/server/content/principles.html
@@ -0,0 +1,13 @@
+<!-- title: Principles -->
+<h1>Principles</h1>
+<h2>What's made with AI is shared</h2>
+<p>Things made with AI should be shared freely with the world. The code and apps here were made with
+    AI, so they are MIT-licensed: use them, change them, share them.</p>
+<p>My conversations with the AI are different. My words and my way of working are mine. I publish a
+    conversation only when I choose to, I can take that permission back, and nobody may copy my words or
+    my style of work without my express permission.</p>
+<p class="meta">Copyright protects my words. It generally doesn't protect a style or a way of working, so
+    the last part is a statement of my terms and wishes, not something a license can enforce.</p>
+<h2>No sign-in, no limits</h2>
+<p>Everything here is static files: no accounts, no rate limits, nothing that runs code from a
+    repository. See <a href="/why/">why this site exists</a>.</p>
diff --git a/server/fw b/server/fw
index 248b773..0ad433e 100755
--- a/server/fw
+++ b/server/fw
@@ -146,12 +146,17 @@ cmd_publish() {
 }
 
 cmd_publish_all() {
-  local r name
+  local r name n=0 total started
+  total=$(ls -d "$GIT_ROOT"/*.git | wc -l | tr -d ' ')
   for r in "$GIT_ROOT"/*.git; do
     name=$(basename "$r" .git)
+    n=$((n + 1))
+    started=$SECONDS
     ( with_lock "$name" publish_one "$name" ) || echo "fw: publish failed for $name" >&2
+    echo "fw: [$n/$total] $name ($((SECONDS - started))s)"
   done
   "${GEN[@]}" index
+  "${GEN[@]}" ci-dashboard
 }
 
 cmd_install_hooks() {
diff --git a/server/gen.py b/server/gen.py
index 096ad01..7c0b75a 100755
--- a/server/gen.py
+++ b/server/gen.py
@@ -41,7 +41,7 @@ BASE_URL = "https://code.fuzzyworld.net"
 APPS_URL = "https://apps.fuzzyworld.net"
 
 # Bump when page templates change, so every repo gets a full rebuild once.
-GEN_VERSION = 7
+GEN_VERSION = 8
 
 COMMIT_LIMIT = 2000    # commit pages per repo, across all branches; older commits: clone
 HISTORY_LIMIT = 200    # commits listed on a file's history page
@@ -51,9 +51,6 @@ CI_REFRESH = 15        # seconds: pages showing something running reload themsel
 SHOTS = "_shots"       # apps.fuzzyworld.net/_shots/<app>/: screenshots from CI runners (no app name starts with "_")
 SHOT_EXTENSIONS = (".png", ".jpg", ".jpeg", ".webp")
 VSCODE_APP = "vscode"  # apps.fuzzyworld.net/<this>/: the static VS Code web build (vscode-web/)
-APP_HISTORY = 20       # deploys remembered per app (~/fw/state/apps.json)
-APP_CHANGES = 5        # commit subjects shown per app on the apps index
-APP_FIRST_CHANGES = 3  # shown for an app's first recorded deploy, which has no previous one
 MAX_TEXT_BYTES = 1024 * 1024
 MAX_DIFF_BYTES = 300 * 1024
 MAX_RAW_BYTES = 2 * 1024 * 1024
@@ -143,15 +140,39 @@ def url(*parts):
     return "/" + "/".join(urllib.parse.quote(s) for s in path.split("/"))
 
 
-def page(title, body, crumbs=None, view=None, site="code", refresh=None):
-    if site == "apps":
-        host, footer = "apps.fuzzyworld.net", (f'<a href="/">All apps</a> · <a href="{BASE_URL}/">Code</a> · '
-                                                f'<a href="{BASE_URL}/why/">Why this site exists</a> · '
-                                                f'<a href="{BASE_URL}/principles/">Principles</a>')
-    else:
-        host, footer = "code.fuzzyworld.net", (f'<a href="/">All repositories</a> · <a href="{APPS_URL}/">Apps</a> · '
-                                                f'<a href="/contribute/">Contribute</a> · <a href="/ci/">CI</a> · <a href="/why/">Why this site exists</a> · '
-                                                f'<a href="/principles/">Principles</a>')
+HOME_URL = "https://fuzzyworld.net"
+# Site-wide navigation, in the header of every page on both sites: (section, label, site, path).
+NAV = [("code", "Code", "code", "/"), ("apps", "Apps", "apps", "/"), ("ci", "CI", "code", "/ci/"),
+       ("contribute", "Contribute", "code", "/contribute/"), ("why", "Why", "code", "/why/"),
+       ("principles", "Principles", "code", "/principles/")]
+SITE_URLS = {"code": BASE_URL, "apps": APPS_URL}
+
+
+def site_link(site, path, here):
+    """Links within a site stay relative; links to the other site are absolute."""
+    return path if site == here else SITE_URLS[site] + path
+
+
+def page(title, body, crumbs=None, view=None, site="code", refresh=None, section=None):
+    """`site` is the site being generated; `section` is the nav entry to highlight (default: the site's home)."""
+    host = "apps.fuzzyworld.net" if site == "apps" else "code.fuzzyworld.net"
+    section = section or site
+    links = "".join(
+        f'<a href="{site_link(st, path, site)}"' + (' aria-current="page"' if key == section else "") + f'>{label}</a>'
+        for key, label, st, path in NAV)
+    header = (f'<header class="site"><div class="bar"><a class="brand" href="{HOME_URL}/">fuzzyworld</a>'
+              f'<nav class="site-nav" aria-label="Site">{links}</nav></div></header>')
+    footer = ('<footer class="site"><div class="cols">'
+              f'<div><h2>fuzzyworld</h2><a href="{HOME_URL}/">fuzzyworld.net</a>'
+              f'<a href="{site_link("code", "/", site)}">Code: all repositories</a>'
+              f'<a href="{site_link("apps", "/", site)}">Apps</a>'
+              f'<a href="{site_link("code", "/ci/", site)}">CI: what is building</a></div>'
+              f'<div><h2>About this site</h2><a href="{site_link("code", "/why/", site)}">Why it exists</a>'
+              f'<a href="{site_link("code", "/principles/", site)}">Principles</a>'
+              f'<a href="{site_link("code", "/contribute/", site)}">Contribute and report bugs</a></div>'
+              f'<div><h2>Built with</h2><a href="{site_link("code", "/fuzzyworld-tools/", site)}">fuzzyworld-tools</a>'
+              '<span>Static files only: no accounts, no sign-in, no rate limits. MIT-licensed.</span></div>'
+              '</div></footer>')
     nav = ""
     if view:
         nav = (f'<nav class="repo-nav"><a href="{url(view.name)}/">Summary</a>'
@@ -173,13 +194,13 @@ def page(title, body, crumbs=None, view=None, site="code", refresh=None):
 <link rel="stylesheet" href="/style.css">
 </head>
 <body>
-<header class="site"><a class="home" href="/">{host}</a></header>
+{header}
 <main>
 {nav}
 {crumb_html}
 {body}
 </main>
-<footer class="site">{footer}</footer>
+{footer}
 </body>
 </html>
 """
@@ -428,7 +449,7 @@ def build_ci_dashboard():
             + (f'<h2>Running</h2><table class="log">{run_rows}</table>' if run_rows else "")
             + (f'<h2>Queued</h2><table class="log">{queue_rows}</table>' if queue_rows else "")
             + (f'<h2>Recent</h2><table class="log">{done_rows}</table>' if done_rows else "<p>No pipelines yet.</p>"))
-    write(os.path.join(SITE, "ci", "index.html"), page("CI", body, refresh=CI_REFRESH if busy else None))
+    write(os.path.join(SITE, "ci", "index.html"), page("CI", body, refresh=CI_REFRESH if busy else None, section="ci"))
 
 
 def ci_status_text():
@@ -513,19 +534,29 @@ CONTENT = os.environ.get("FW_CONTENT", os.path.join(os.path.dirname(os.path.absp
 
 
 def build_content():
-    """Site pages written in Markdown: content/<name>.md becomes <site>/<name>/index.html.
-    A `title: ...` line at the top of the file is the page title (Python-Markdown's meta extension)."""
+    """Site pages: content/<name>.md (Markdown) or content/<name>.html (a fragment) becomes
+    <site>/<name>/index.html. The title is a `title: ...` line at the top of a Markdown file
+    (Python-Markdown's meta extension) or a leading `<!-- title: ... -->` in an HTML one.
+    Files starting with "_" are drafts and are skipped. Also writes 404.html."""
     if not markdown:
         raise SystemExit("gen.py content needs Python-Markdown in " + PYLIB)
     for name in sorted(os.listdir(CONTENT)):
         stem, ext = os.path.splitext(name)
-        if ext != ".md":
+        if ext not in (".md", ".html") or stem.startswith("_"):
             continue
         with open(os.path.join(CONTENT, name), encoding="utf-8") as f:
+            text = f.read()
+        if ext == ".md":
             md = markdown.Markdown(extensions=["meta", "tables", "fenced_code", "sane_lists"], output_format="html")
-            body = md.convert(f.read())
-        title = " ".join(md.Meta.get("title", [stem]))
-        write(os.path.join(SITE, stem, "index.html"), page(title, body))
+            body = md.convert(text)
+            title = " ".join(md.Meta.get("title", [stem]))
+        else:
+            m = re.match(r"<!-- title: (.*?) -->\n", text)
+            title, body = (m.group(1), text[m.end():]) if m else (stem, text)
+        write(os.path.join(SITE, stem, "index.html"), page(title, body, section=stem))
+    write(os.path.join(SITE, "404.html"), page("Not found", (
+        "<h1>Not found</h1><p>There's nothing at this address. Private repositories aren't listed here, "
+        'and only the default branch can be browsed.</p><p><a href="/">All repositories</a></p>'), section="-"))
 
 
 # ---------------------------------------------------------------- highlighting
@@ -1153,7 +1184,7 @@ def build_index():
         app = f' · <a href="{esc(a)}">app</a>' if a else ""
         desc = f'<p class="desc">{esc(d)}</p>' if d else ""
         items.append(f'<li><a class="name" href="{url(n)}/">{esc(n)}</a>'
-                     f'<span class="meta"> · updated {esc(last or "never (empty)")}{app}</span>{desc}</li>')
+                     f'<span class="meta">updated {esc(last or "never (empty)")}{app}</span>{desc}</li>')
     body = (f"<h1>Pavel's code</h1>"
             f"<p>Browse, or clone with <code>git clone {BASE_URL}/&lt;repo&gt;.git</code>. No account or sign-in needed. "
             f'Small web apps are at <a href="{APPS_URL}/">apps.fuzzyworld.net</a>. '
@@ -1163,51 +1194,6 @@ def build_index():
     print(f"index: {len(rows)} public repos")
 
 
-def app_history(apps):
-    """Deploys seen per app, newest first, in ~/fw/state/apps.json.
-
-    .fuzzyworld.json holds only the latest deploy, so the previous one (needed to say
-    what changed) is remembered here each time the index is rebuilt.
-    """
-    path = os.path.join(STATE, "apps.json")
-    try:
-        with open(path) as f:
-            hist = json.load(f)
-    except (FileNotFoundError, ValueError):
-        hist = {}
-    for a in apps:
-        deploys = hist.setdefault(a["app"], [])
-        if not deploys or deploys[0]["deployed"] != a["deployed"]:
-            deploys.insert(0, {"commit": a["commit"], "deployed": a["deployed"]})
-            del deploys[APP_HISTORY:]
-    write(path, json.dumps(hist))
-    return hist
-
-
-def app_changes(repo, name, deploys):
-    """Commits in the latest deploy since the previous one with a different commit
-    (or the last few, the first time an app is seen), as list items."""
-    cur = deploys[0]["commit"]
-    if not git_text(repo, "rev-parse", "-q", "--verify", cur + "^{commit}", check=False).strip():
-        return ""  # e.g. `fw deploy --here` from unpushed work
-    prev = next((d["commit"] for d in deploys[1:] if d["commit"] != cur), None)
-    if prev and git_text(repo, "rev-parse", "-q", "--verify", prev + "^{commit}", check=False).strip():
-        rng, limit = f"{prev}..{cur}", APP_CHANGES + 1
-    else:
-        rng, limit = cur, APP_FIRST_CHANGES
-    lines = git_text(repo, "log", "--format=%H%x09%s", "-n", str(limit), rng, check=False).splitlines()
-    items = []
-    for line in lines[:APP_CHANGES]:
-        sha, _, subject = line.partition("\t")
-        page_ = os.path.join(SITE, name, "commit", sha + ".html")
-        subj = (f'<a href="{BASE_URL}{url(name, "commit", sha)}.html">{esc(subject)}</a>'
-                if os.path.exists(page_) else esc(subject))
-        items.append(f"<li>{subj}</li>")
-    if len(lines) > APP_CHANGES:
-        items.append(f'<li><a href="{BASE_URL}{url(name, "log")}/">and more</a></li>')
-    return f'<ul class="changes">{"".join(items)}</ul>' if items else ""
-
-
 def build_apps_index():
     """apps.fuzzyworld.net/index.html from each deployed app's .fuzzyworld.json, newest deploy first."""
     apps = []
@@ -1223,7 +1209,6 @@ def build_apps_index():
         apps.append({"app": entry, "href": f"/{entry}/", "title": meta.get("title") or entry,
                      "description": meta.get("description", ""), "repo": meta.get("repo", entry),
                      "commit": meta.get("commit", ""), "deployed": meta.get("deployed", "")})
-    hist = app_history(apps)
     for a in apps:
         try:
             a["time"] = datetime.datetime.fromisoformat(a["deployed"].replace("Z", "+00:00")).timestamp()
@@ -1243,17 +1228,15 @@ def build_apps_index():
     for a in apps:
         repo = os.path.join(GIT_ROOT, a.get("repo", "") + ".git")
         public = a.get("repo") and os.path.isdir(repo) and is_public(repo)
-        src = f' · <a href="{BASE_URL}/{urllib.parse.quote(a["repo"])}/">source</a>' if public else ""
-        when = (" · updated " + time.strftime("%Y-%m-%d", time.gmtime(a["time"]))) if a["time"] else ""
+        src = f'{" · " if a["time"] else ""}<a href="{BASE_URL}/{urllib.parse.quote(a["repo"])}/">source</a>' if public else ""
+        when = ("updated " + time.strftime("%Y-%m-%d", time.gmtime(a["time"]))) if a["time"] else ""
         desc = f'<p class="desc">{esc(a["description"])}</p>' if a.get("description") else ""
-        # Commit messages only for public repos: a private repo's history stays private.
-        changes = app_changes(repo, a["repo"], hist[a["app"]]) if public and a.get("app") in hist else ""
         shots = app_shots(a["app"], a["repo"] if public else None) if a.get("app") else []
         thumb = (f'<a class="thumb" href="{esc(a["href"])}"><img src="{esc(shots[0][0])}" alt="" loading="lazy"></a>'
                  if shots else "")
         cls = ' class="has-shot"' if shots else ""
         items.append(f'<li{cls}>{thumb}<div><a class="name" href="{esc(a["href"])}">{esc(a["title"])}</a>'
-                     f'<span class="meta">{when}{src}</span>{desc}{changes}</div></li>')
+                     f'<span class="meta">{when}{src}</span>{desc}</div></li>')
     body = (f"<h1>Pavel's apps</h1>"
             f'<p>Small web apps and games. No accounts, no sign-in. Source code is at '
             f'<a href="{BASE_URL}/">code.fuzzyworld.net</a>.</p>'
diff --git a/server/install.sh b/server/install.sh
index be55591..57fb2e2 100755
--- a/server/install.sh
+++ b/server/install.sh
@@ -25,9 +25,7 @@ chmod +x ~/fw/tools/server/runner-api ~/fw/tools/server/mirror-only
 ln -sfn ~/fw/tools/server/fw ~/bin/fw
 site=~/code.fuzzyworld.net
 cp ~/fw/tools/server/site/htaccess "$site/.htaccess"
-cp ~/fw/tools/server/site/style.css ~/fw/tools/server/site/robots.txt ~/fw/tools/server/site/404.html "$site/"
-mkdir -p "$site/contribute" && cp ~/fw/tools/server/site/contribute/index.html "$site/contribute/"
-mkdir -p "$site/principles" && cp ~/fw/tools/server/site/principles/index.html "$site/principles/"
+cp ~/fw/tools/server/site/style.css ~/fw/tools/server/site/robots.txt "$site/"
 apps=~/apps.fuzzyworld.net
 cp ~/fw/tools/server/site-apps/htaccess "$apps/.htaccess"
 cp ~/fw/tools/server/site/style.css "$apps/"
diff --git a/server/site/404.html b/server/site/404.html
deleted file mode 100644
index 610f289..0000000
--- a/server/site/404.html
+++ /dev/null
@@ -1,17 +0,0 @@
-<!doctype html>
-<html lang="en">
-<head>
-<meta charset="utf-8">
-<meta name="viewport" content="width=device-width, initial-scale=1">
-<title>Not found · code.fuzzyworld.net</title>
-<link rel="stylesheet" href="/style.css">
-</head>
-<body>
-<header class="site"><a class="home" href="/">code.fuzzyworld.net</a></header>
-<main>
-<h1>Not found</h1>
-<p>There's nothing at this address. Private repositories aren't listed here, and only the default branch can be browsed.</p>
-<p><a href="/">All repositories</a></p>
-</main>
-</body>
-</html>
diff --git a/server/site/contribute/index.html b/server/site/contribute/index.html
deleted file mode 100644
index cecb6d2..0000000
--- a/server/site/contribute/index.html
+++ /dev/null
@@ -1,52 +0,0 @@
-<!doctype html>
-<html lang="en">
-
-<head>
-    <meta charset="utf-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1">
-    <title>How to contribute · code.fuzzyworld.net</title>
-    <link rel="stylesheet" href="/style.css">
-</head>
-
-<body>
-    <header class="site"><a class="home" href="/">code.fuzzyworld.net</a></header>
-    <main>
-        <h1>How to contribute</h1>
-        <p>Thanks for wanting to help! This site has no accounts, issue tracker or merge requests, so contributions
-            arrive by email. Two ways work, both with plain git.</p>
-
-        <h2>1. Share a branch</h2>
-        <p>Best for anything bigger than a small fix.</p>
-        <ol>
-            <li>Clone the repository: <code>git clone https://code.fuzzyworld.net/&lt;repo&gt;.git</code></li>
-            <li>Make your change on a branch, and push that branch to <strong>any</strong> public git host you like
-                (GitHub, Codeberg, GitLab, your own server).</li>
-            <li>Email the repository URL, the branch name and a short description to
-                <code>contrib-68uq+&lt;repo&gt;@fuzzyworld.net</code>, using the repository's name in place of
-                <code>&lt;repo&gt;</code>.</li>
-        </ol>
-        <p>I fetch the branch, review it locally, and reply by email.</p>
-
-        <h2>2. Email a patch</h2>
-        <p>Good for small changes. Commit your change, then create a patch file:</p>
-        <pre>git format-patch origin/main</pre>
-        <p>and attach the <code>.patch</code> file(s) to an email to <code>contrib-68uq+&lt;repo&gt;@fuzzyworld.net</code>.
-            If you use <code>git send-email</code>, that address works there too.</p>
-
-        <h2>Bug reports and ideas</h2>
-        <p>No git needed: email <code>bugs-k7q4+&lt;repo&gt;@fuzzyworld.net</code>. Many of the apps have a
-            <strong>report</strong> link that fills in the details for you.</p>
-
-        <h2>Before you send</h2>
-        <ul>
-            <li>Check the repository's README (and CONTRIBUTING, if it has one) for how to build and test it.</li>
-            <li>One change per branch or patch, please; it makes review much quicker.</li>
-            <li>By contributing you agree that your change is shared under the repository's license.</li>
-        </ul>
-
-        <p><a href="/why/">Why there are no merge requests here</a></p>
-    </main>
-    <footer class="site"><a href="/">All repositories</a> · <a href="https://apps.fuzzyworld.net/">Apps</a> · <a href="/principles/">Principles</a></footer>
-</body>
-
-</html>
diff --git a/server/site/how/index.html b/server/site/how/index.html
deleted file mode 100644
index 357384f..0000000
--- a/server/site/how/index.html
+++ /dev/null
@@ -1,105 +0,0 @@
-<!doctype html>
-<html lang="en">
-
-<head>
-    <meta charset="utf-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1">
-    <title>How I moved off GitLab · code.fuzzyworld.net</title>
-    <link rel="stylesheet" href="/style.css">
-</head>
-
-<body>
-    <header class="site"><a class="home" href="/">code.fuzzyworld.net</a></header>
-    <main>
-        <!-- DRAFT 2026-10-27 for code.fuzzyworld.net/how/ (plan Backlog item 3). Pavel edits before it goes live; on hold until the item 2 features. Not installed or linked. -->
-        <h1>How I moved off GitLab</h1>
-        <p><a href="/why/">Why</a> I left is short. <em>How</em> took about a day, 89 repositories and 35 web
-            apps, and turned up enough surprises that it seemed worth writing down for anyone thinking about the same
-            move.</p>
-
-        <h2>What I ended up with</h2>
-        <ul>
-            <li><strong>Bare git repos on cheap shared hosting</strong> (DreamHost). I push over SSH; nothing else
-                runs there permanently.</li>
-            <li><strong>code.fuzzyworld.net</strong>: static browse pages, generated by a git hook when I push, plus
-                read-only <code>git clone https://…</code>. No app server, no accounts, no rate limits beyond what
-                Apache can serve.</li>
-            <li><strong>apps.fuzzyworld.net</strong>: every former GitLab Pages site, at the same relative path, built
-                and uploaded from my laptop with one command.</li>
-            <li><strong>A backup mirror at home</strong> that can read, but not write.</li>
-        </ul>
-        <p>What I gave up: issues, merge requests and search. Only the default branch can be browsed. Cloning over
-            plain HTTP is slower for big repos (a 244 MB repo takes about 50 seconds).</p>
-
-        <h2>Getting everything out of GitLab</h2>
-        <p>The code is the easy part: <code>git clone --mirror</code> gets every branch and tag. The rest lives in
-            GitLab's settings and is easy to forget:</p>
-        <ul>
-            <li><strong>CI/CD variables.</strong> Signing keys, API tokens, deploy keys. Export them before anything
-                else; losing an Android signing key means you can't update that app in the Play Store again.</li>
-            <li><strong>Release files.</strong> APKs and tarballs attached to releases are often CI <em>job
-                artifacts</em>, and those expire. Five of mine already had.</li>
-            <li><strong>Pipeline schedules</strong>, issues, merge requests and wiki pages. Confidential issues in a
-                public project need separate handling, or the export makes them public.</li>
-            <li><strong>Merge-request commits</strong> live under <code>refs/merge-requests/*</code>, which a normal
-                clone doesn't fetch. I kept them as <code>refs/gitlab/merge-requests/*</code>.</li>
-        </ul>
-
-        <h2>Traps I fell into</h2>
-        <ul>
-            <li><strong>The rate limit bit me during the export.</strong> The <code>glab</code> CLI's login is an
-                OAuth token, which has to be sent as <code>Authorization: Bearer</code>. Sent as
-                <code>PRIVATE-TOKEN</code>, every call counted as anonymous, and my home IP was blocked within
-                minutes. Web URLs for job artifacts and uploads count as anonymous even with a token; use the API
-                endpoints instead.</li>
-            <li><strong>My own git hook refused my own history.</strong> A global pre-push hook that rejects
-                lightweight tags (good for new releases) blocked mirroring old ones. Historical tags have to move
-                unchanged, so the migration pushes skip the hook.</li>
-            <li><strong>Shared hosting kills big git operations.</strong> Cloning a 284 MB repo over SSH failed with
-                "git-pack-objects died": the host's watchdog killed git's packing step. Limiting git's threads and
-                memory (<code>pack.threads 1</code>, <code>pack.windowMemory 64m</code>) fixed it for good.</li>
-            <li><strong>The host executes things you might not expect.</strong> DreamHost runs <code>.php</code> files
-                and server-side includes by default. A code browser that wrote repository files into the web root
-                with their real names would have run them. Browse pages are pre-generated HTML, raw copies are
-                images only, and every response says <code>script-src 'none'</code>.</li>
-            <li><strong>Node on a hardened kernel.</strong> The host's kernel refuses executable memory, so modern
-                Node crashes as soon as its JIT starts. <code>--jitless</code> works; native npm modules don't build,
-                so installs use <code>--ignore-scripts</code>.</li>
-        </ul>
-
-        <h2>Moving the apps</h2>
-        <p>Every app moved to the same path under a new domain, and they all share one origin, exactly as they did on
-            <code>*.gitlab.io</code>. That means two apps can still clash over <code>localStorage</code> keys, a
-            service worker can claim more than its own folder, and two installed web apps can merge on Android if
-            their manifests look alike. So the deploy script refuses to upload anything whose URLs, manifest or
-            service worker reach outside the app's own path.</p>
-        <p>Small things broke in surprising places: GitLab-style links (<code>/-/blob/main/…</code>,
-            <code>/-/commit/…</code>) baked into apps, short commit IDs where the new site expected long ones, a
-            vite config whose root was a subfolder, Google sign-in that only works on domains Firebase knows about,
-            and OAuth redirect URIs registered with the old address.</p>
-
-        <h2>Saying goodbye without breaking links</h2>
-        <p>Each GitLab project got one last commit: a README pointing to the new home, and, for sites, a redirect
-            page in place of the app. The redirect keeps the path, query and <code>#hash</code>, so deep links land on
-            the matching new page. It also retires any old service worker, which would otherwise keep serving the
-            cached old app to returning visitors forever.</p>
-
-        <h2>Would I do it again?</h2>
-        <p>Yes. Everything is static, it costs nothing extra on hosting I already paid for, and nobody needs an
-            account to read my code. The one real loss is easy contributions: there are no merge requests here
-            yet.</p>
-
-        <h2>Principles</h2>
-        <p>Things made with AI should be shared freely with the world. The code and apps here were made with
-            AI, so they are MIT-licensed: use them, change them, share them.</p>
-        <p>My conversations with the AI are different. My words and my way of working are mine. I publish a
-            conversation only when I choose to, I can take that permission back, and nobody may copy my words or
-            my style of work without my express permission.</p>
-        <p>More on the <a href="/principles/">principles</a> page.</p>
-
-        <p><a href="/why/">← Why I left GitLab</a></p>
-    </main>
-    <footer class="site"><a href="/">All repositories</a> · <a href="https://apps.fuzzyworld.net/">Apps</a> · <a href="/principles/">Principles</a></footer>
-</body>
-
-</html>
diff --git a/server/site/principles/index.html b/server/site/principles/index.html
deleted file mode 100644
index bd2e956..0000000
--- a/server/site/principles/index.html
+++ /dev/null
@@ -1,30 +0,0 @@
-<!doctype html>
-<html lang="en">
-
-<head>
-    <meta charset="utf-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1">
-    <title>Principles · code.fuzzyworld.net</title>
-    <link rel="stylesheet" href="/style.css">
-</head>
-
-<body>
-    <header class="site"><a class="home" href="/">code.fuzzyworld.net</a></header>
-    <main>
-        <h1>Principles</h1>
-        <h2>What's made with AI is shared</h2>
-        <p>Things made with AI should be shared freely with the world. The code and apps here were made with
-            AI, so they are MIT-licensed: use them, change them, share them.</p>
-        <p>My conversations with the AI are different. My words and my way of working are mine. I publish a
-            conversation only when I choose to, I can take that permission back, and nobody may copy my words or
-            my style of work without my express permission.</p>
-        <p class="meta">Copyright protects my words. It generally doesn't protect a style or a way of working, so
-            the last part is a statement of my terms and wishes, not something a license can enforce.</p>
-        <h2>No sign-in, no limits</h2>
-        <p>Everything here is static files: no accounts, no rate limits, nothing that runs code from a
-            repository. See <a href="/why/">why this site exists</a>.</p>
-    </main>
-    <footer class="site"><a href="/">All repositories</a> · <a href="https://apps.fuzzyworld.net/">Apps</a> · <a href="/why/">Why this site exists</a></footer>
-</body>
-
-</html>
diff --git a/server/site/style.css b/server/site/style.css
index cb08d1e..cff2bec 100644
--- a/server/site/style.css
+++ b/server/site/style.css
@@ -1,10 +1,12 @@
 :root {
-  --bg: #ffffff; --fg: #1d1d1f; --muted: #6b6b70; --line: #e4e4e8; --soft: #f5f5f7;
+  --bg: #ffffff; --fg: #1d1d1f; --muted: #62626a; --line: #e1e1e6; --soft: #f6f6f8; --card: #ffffff; --accent-soft: #e8f0fe;
+  --radius: 8px; --shadow: 0 1px 2px rgb(0 0 0 / 6%);
   --link: #0a58ca; --add: #e6f4ea; --add-fg: #116329; --del: #fdecea; --del-fg: #a40e26; --hunk: #6f42c1;
 }
 @media (prefers-color-scheme: dark) {
   :root {
-    --bg: #111113; --fg: #e8e8ea; --muted: #9a9aa2; --line: #2a2a2f; --soft: #1a1a1e;
+    --bg: #111113; --fg: #e8e8ea; --muted: #9a9aa2; --line: #2a2a2f; --soft: #1a1a1e; --card: #17171a; --accent-soft: #16243a;
+    --shadow: none;
     --link: #79b8ff; --add: #12261a; --add-fg: #7ee2a8; --del: #2d1517; --del-fg: #ff9492; --hunk: #c5a5ff;
   }
 }
@@ -15,21 +17,37 @@ body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.55 syste
 a { color: var(--link); text-decoration: none; }
 a:hover { text-decoration: underline; }
 main { max-width: 72rem; margin: 0 auto; padding: 0 16px 3rem; }
-header.site, footer.site { max-width: 72rem; margin: 0 auto; padding: 1rem 16px; color: var(--muted); }
-header.site .home { font-weight: 600; color: var(--fg); }
-footer.site { border-top: 1px solid var(--line); font-size: 0.9rem; }
-h1 { font-size: 1.6rem; margin: 1rem 0 0.5rem; overflow-wrap: anywhere; }
-h2 { font-size: 1.2rem; margin: 1.5rem 0 0.5rem; }
+main > p, main > ul:not(.repos), main > ol { max-width: 46rem; }
+:focus-visible { outline: 2px solid var(--link); outline-offset: 2px; border-radius: 3px; }
+/* Site header and footer: the same links on every page of both sites (gen.py NAV). */
+header.site { background: var(--soft); border-bottom: 1px solid var(--line); }
+header.site .bar, footer.site .cols { max-width: 72rem; margin: 0 auto; padding: 0 16px; }
+header.site .bar { display: flex; flex-wrap: wrap; align-items: center; gap: 0 1.5rem; }
+header.site .brand { font-weight: 700; font-size: 1.1rem; color: var(--fg); padding: 0.75rem 0; letter-spacing: -0.01em; }
+header.site .brand:hover { text-decoration: none; color: var(--link); }
+.site-nav { display: flex; flex-wrap: wrap; gap: 0 1.25rem; }
+.site-nav a { color: var(--muted); padding: 0.75rem 0 calc(0.75rem - 2px); border-bottom: 2px solid transparent; }
+.site-nav a:hover { color: var(--fg); text-decoration: none; }
+.site-nav a[aria-current] { color: var(--fg); font-weight: 600; border-bottom-color: var(--link); }
+footer.site { margin-top: 2rem; border-top: 1px solid var(--line); background: var(--soft); color: var(--muted); font-size: 0.9rem; }
+footer.site .cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: 1.5rem; padding-top: 1.5rem; padding-bottom: 2rem; }
+footer.site h2 { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin: 0 0 0.4rem; }
+footer.site a, footer.site span { display: block; padding: 0.15rem 0; }
+h1 { font-size: 1.75rem; line-height: 1.25; margin: 1.5rem 0 0.5rem; overflow-wrap: anywhere; letter-spacing: -0.01em; }
+h2 { font-size: 1.2rem; margin: 1.75rem 0 0.5rem; }
 code, pre, .source, .diff { font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; tab-size: 4; }
-pre { background: var(--soft); border: 1px solid var(--line); border-radius: 6px; padding: 0.75rem; overflow-x: auto; }
-.repo-nav { display: flex; flex-wrap: wrap; gap: 0.25rem 1rem; padding: 0.5rem 0; border-bottom: 1px solid var(--line); }
+pre { background: var(--soft); border: 1px solid var(--line); border-radius: var(--radius); padding: 0.75rem; overflow-x: auto; }
+.repo-nav { display: flex; flex-wrap: wrap; gap: 0 1.25rem; margin-top: 1rem; border-bottom: 1px solid var(--line); }
+.repo-nav a { color: var(--muted); padding: 0.5rem 0 calc(0.5rem - 2px); border-bottom: 2px solid transparent; }
+.repo-nav a:hover { color: var(--fg); text-decoration: none; border-bottom-color: var(--line); }
 .crumbs { color: var(--muted); overflow-wrap: anywhere; }
 .meta, .desc { color: var(--muted); overflow-wrap: anywhere; }
 pre.message { white-space: pre-wrap; overflow-wrap: anywhere; }
 .clone { user-select: all; }
 table { border-collapse: collapse; width: 100%; }
 td, th { padding: 0.3rem 0.5rem; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
-th { color: var(--muted); font-weight: 500; }
+th { color: var(--muted); font-weight: 500; font-size: 0.9rem; }
+table:not(.source) tr:hover > td { background: var(--soft); }
 td.num, td.date { white-space: nowrap; color: var(--muted); }
 td.date { width: 7rem; }
 td.author { color: var(--muted); white-space: nowrap; }
@@ -47,13 +65,12 @@ td.author { color: var(--muted); white-space: nowrap; }
 .markdown img, img.blob { max-width: 100%; height: auto; }
 .markdown table { display: block; overflow-x: auto; }
 .readme { border-top: 1px solid var(--line); margin-top: 1.5rem; }
-ul.repos { list-style: none; padding: 0; margin: 1rem 0; }
-ul.repos li { padding: 0.6rem 0; border-bottom: 1px solid var(--line); }
-ul.repos .name { font-weight: 600; overflow-wrap: anywhere; }
-ul.repos .meta { font-size: 0.9rem; white-space: nowrap; }
-ul.repos .desc { margin: 0.2rem 0 0; }
-ul.repos ul.changes { margin: 0.3rem 0 0; padding-left: 1.2rem; font-size: 0.9rem; color: var(--muted); }
-ul.repos ul.changes li { padding: 0; border: 0; overflow-wrap: anywhere; }
+ul.repos { list-style: none; padding: 0; margin: 1.25rem 0; display: grid; gap: 0.75rem; grid-template-columns: repeat(auto-fill, minmax(min(100%, 21rem), 1fr)); }
+ul.repos li { padding: 0.9rem 1rem; background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
+ul.repos li:hover { border-color: var(--link); }
+ul.repos .name { font-weight: 600; font-size: 1.05rem; overflow-wrap: anywhere; }
+ul.repos .meta { display: block; font-size: 0.85rem; }
+ul.repos .desc { margin: 0.35rem 0 0; font-size: 0.95rem; }
 details summary { cursor: pointer; color: var(--muted); margin: 1rem 0 0.5rem; }
 .branch-note { background: var(--soft); border: 1px solid var(--line); border-radius: 6px; padding: 0.4rem 0.75rem; overflow-wrap: anywhere; }
 /* Blame: who last changed each run of lines. */
@@ -77,7 +94,7 @@ pre.ci-log { max-height: 40rem; overflow: auto; white-space: pre-wrap; overflow-
   td.author, .log td.date { display: none; }
 }
 /* Screenshots: thumbnails on the apps index, a row on a repo's summary page. */
-ul.repos li.has-shot { display: grid; grid-template-columns: 4.5rem 1fr; gap: 0.75rem; align-items: start; }
-ul.repos .thumb img { display: block; width: 4.5rem; height: 6rem; object-fit: contain; object-position: left top; border: 1px solid var(--line); border-radius: 6px; }
+ul.repos li.has-shot { display: grid; grid-template-columns: 5.5rem 1fr; gap: 0.9rem; align-items: start; }
+ul.repos .thumb img { display: block; width: 5.5rem; height: 7.5rem; object-fit: contain; object-position: left top; border: 1px solid var(--line); border-radius: 6px; background: var(--soft); }
 .shots { display: flex; gap: 0.75rem; overflow-x: auto; }
-.shots img { display: block; height: 16rem; width: auto; border: 1px solid var(--line); border-radius: 6px; }
+.shots img { display: block; height: 16rem; width: auto; border: 1px solid var(--line); border-radius: var(--radius); }