diff --git a/CLAUDE.md b/CLAUDE.md
index 72c3f0f..6256a2a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -221,6 +221,65 @@ venv/bin/python lib/tag_stats.py 5 # only tags with 5+ posts
- Tags should be capitalized (e.g., "Python" not "python")
- Avoid single-use tags; consolidate into broader categories
+## UNTURF SVG DIAGRAM STYLE GUIDE
+
+**All diagrams on russell.ballestrini.net & permacomputer sites follow this style.**
+
+### Color Palette (dark/light theme compatible)
+- **Background**: always `transparent` (never white or black)
+- **Node fills**: medium-dark saturated colors with `fill="white"` text
+ - Blues: `#4a90d9`, `#1e88e5`, `#0d47a1`, `#1a237e`
+ - Purples: `#9370db`, `#7b68ee`, `#6c5ce7`, `#4a148c`, `#ab47bc`
+ - Greens: `#7cb342`, `#1b5e20`, `#00897b`
+ - Teals: `#00acc1`, `#006064`
+ - Oranges: `#e65100`, `#e8834a`, `#ff6f00`
+ - Reds: `#bf360c`, `#b71c1c`, `#880e4f`
+ - Dark neutrals: `#2c3e50`, `#34495e`, `#546e7a`, `#616161`, `#4e342e`
+- **Gold/yellow nodes** (`#f9a825`, `#fdd835`): use `fill="#1a1a1a"` text (NOT `#333333`)
+- **Node strokes**: `#999999` (never black)
+- **Edges/arrows**: `#aaaaaa` stroke & fill
+- **Floating text labels**: `#cccccc` (cluster labels, "translates to", etc.)
+- **Accent text**: `#ef5350` (red warnings/claims), `#ff6f00` (orange highlights), `#66ff66` (green positive)
+- **Dashed cluster borders**: `stroke="#999999" stroke-dasharray="5,2"`
+- **Plaintext labels** (no background): `fill="transparent"` polygon, text `fill="#cccccc"`
+
+### SVG Dimensions
+- Root SVG: `width="100%" height="100%"` with `viewBox` (never fixed pt/px)
+- RST embed: `:width: 100%` and `:align: center`
+
+### Animation Pattern (CSS only, no JS)
+```xml
+
+
+
+```
+- Wrap each conceptual group in ``
+- Groups fade in sequentially (3s apart for major concepts, 1-2s for chain elements)
+- Keep total animation under 30s
+
+### Graphviz Dot Conventions
+```dot
+bgcolor="transparent"
+node [fontname="Helvetica" fontsize=12 style="filled,rounded" shape=box color="#999999"]
+edge [color="#AAAAAA" arrowsize=0.6 fontcolor="#AAAAAA"]
+```
+
+### Post-Processing Checklist
+1. Strip `` tags: `sed -i 's/[^<]*<\/title>//g'` (prevents tooltip artifacts)
+2. Replace fixed dimensions: `width="100%" height="100%"` keeping viewBox
+3. Change cluster label fontcolor from `#aaaaaa` to `#cccccc`
+4. Wrap groups with animation classes
+5. Verify: no `fill="black"`, no `stroke="black"`, no `fill="#333333"`
+
## RUSSELL'S PROJECT TECH STACKS
**NEVER assume or guess what language/framework a project uses. Check the actual repo.**
diff --git a/content/2026-03-04-royal-we-and-our-and-one-and-you.rst b/content/2026-03-04-royal-we-and-our-and-one-and-you.rst
index 8bf7025..6edfad8 100644
--- a/content/2026-03-04-royal-we-and-our-and-one-and-you.rst
+++ b/content/2026-03-04-royal-we-and-our-and-one-and-you.rst
@@ -38,7 +38,7 @@ our
"Our" claims ownership without proving it. "Our API handles 10,000 requests per second." Whose API? "Our documentation covers all edge cases." Whose documentation? Documentation never covers all edge cases. That sentence lies twice: once about coverage, once about possession.
-"Our" creates false intimacy too. "Our community" implies you belong to something. You cloned a repo. You filed an issue. You didn't join a community. You used software. But "our community" wraps you in belonging whether you asked for it or not. Contributors stay few & far between. Look at `Pyramid `_. A web framework that needs maintainers. `Remarkbox `_ & `MakePostSell `_ run on it. `RhodeCode `_ runs on it. Vital resources like `PyPI `_ itself run on it. "Our framework" carries weight that maybe five active maintainers shoulder.
+"Our" creates false intimacy too. "Our community" implies you belong to something. You cloned a repo. You filed an issue. You didn't join a community. You used software. But "our community" wraps you in belonging whether you asked for it or not. Contributors stay few & far between. Look at `Pyramid `_. A web framework that needs maintainers. `Remarkbox `_ & `MakePostSell `_ run on it. `RhodeCode `_ runs on it. Vital resources like `PyPI `_ itself run on it. "Our framework" carries weight that 4 humans shoulder.
.. image:: /uploads/2026/03/xkcd-2347-dependency.png
:alt: xkcd 2347 Dependency — a tower of blocks representing all modern digital infrastructure balanced on a single small block maintained by a random person in Nebraska
@@ -51,13 +51,157 @@ our
|
.. image:: /uploads/2026/03/load-bearing.svg
- :alt: diagram showing PyPI, RhodeCode, Remarkbox, MakePostSell, thousands of apps all depending on Pyramid with ~5 maintainers, Russell as unemployed volunteer load-bearing block, NASA shut down grants evaporate
+ :alt: diagram showing PyPI, RhodeCode, Remarkbox, MakePostSell, thousands of apps all depending on Pylons org with 98 repos, 28 lifetime contributors, 4 humans active last 3 months, Russell as only new contributor in the past year with PR 3805, NASA shut down grants evaporate
:align: center
:width: 100%
|
-I stepped up to help maintain Pyramid. Unemployed. Because someone has to play `that xkcd 2347 role `_, a single load-bearing block propping up everything above it. "Our" framework means I volunteer so "we" can keep a foundation standing. Maybe eventually get paid. Maybe not. NASA just shut down. Federal grants evaporate. "Our" community runs on donated labor & borrowed time.
+I wrote a script called ``unbury`` to exhume contributor stats from a graveyard of git repos. Run it against the `Pylons `_ org & the numbers speak for themselves:
+
+.. code-block:: text
+
+ scanning 98 repos in ~/git/pylons
+
+ ──────────────────────────────────────────────────
+ 3 months: 5 unique contributors across 5 active repos
+ russell@unturf.com 1 repos
+ dependabot[bot] 1 repos
+ Michael Merickel 1 repos
+ Rémi Dubois 1 repos
+ Gael Pasgrimaud 1 repos
+ ──────────────────────────────────────────────────
+ 1 year: 7 unique contributors across 8 active repos
+ ──────────────────────────────────────────────────
+ 3 years: 15 unique contributors across 31 active repos
+ ──────────────────────────────────────────────────
+ 6 years: 19 unique contributors across 46 active repos
+ ──────────────────────────────────────────────────
+ lifetime: 28 unique contributors across 97 active repos
+
+Five contributors in 3 months. One of them a bot. I stepped up to help maintain Pyramid. Unemployed. Filed `PR #3805 `_ to vendor ``pkg_resources`` & drop a setuptools runtime dependency. 310 lines vendored from setuptools 80.x. 67 new tests at 97.66% coverage. A ``pyramid.compat_resources`` public API so downstream packages could migrate without breaking asset overrides. Audited all 108 Pylons repos. Migrated 30 downstream packages. Ran every test suite. One reviewer approved. The maintainer who holds commit access shipped Pyramid 2.1 with a setuptools version constraint instead & went -1 on the approach. `Rejected `_.
+
+So I closed the PR, opened a new terminal, & pivoted. If "we" won't vendor the fix upstream, "I" will do the lifting downstream. Migrated 33 repos in a single session. Replaced ``pkg_resources`` with ``importlib.resources`` & ``importlib.metadata`` across every Pylons package that needed it. Remarkbox. MakePostSell. RhodeCode. Nine Pyramid-dependent packages. Nineteen ``docs/conf.py`` files. Substanced's ``EntryPoint.parse().load()`` hack. Deform's cursed module-level ``resource_filename`` call that fails at import time. All uncommitted, waiting to fork & push across the org.
+
+The only new contributor to the entire Pylons org in the past year. Because someone has to play `that xkcd 2347 role `_, a single load-bearing block propping up everything above it. "Our" framework means I volunteer so "we" can keep a foundation standing. Maybe eventually get paid. Maybe not. NASA just shut down. Federal grants evaporate. "Our" community runs on donated labor & borrowed time.
+
+Here sits ``unbury`` in full (`download `_). A script to exhume contributor stats from a graveyard of git repos:
+
+.. code-block:: python
+
+ #!/usr/bin/env python3
+ """unbury - exhume contributor stats from a graveyard of git repos.
+
+ Usage:
+ unbury [path] # defaults to current directory
+ unbury /home/fox/git/pylons
+ """
+
+ import os
+ import subprocess
+ import sys
+ from collections import defaultdict
+ from datetime import datetime, timedelta
+
+ PERIODS = [
+ ("3 months", 90),
+ ("1 year", 365),
+ ("3 years", 365 * 3),
+ ("6 years", 365 * 6),
+ ("18 years", 365 * 18),
+ ("lifetime", None),
+ ]
+
+
+ def git_contributors(repo_path, since=None):
+ """Return set of (name, email) tuples for a repo."""
+ cmd = ["git", "-C", repo_path, "log", "--format=%aN\t%aE"]
+ if since:
+ cmd.append(f"--since={since}")
+ try:
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
+ contributors = set()
+ for line in result.stdout.strip().split("\n"):
+ if "\t" in line:
+ name, email = line.split("\t", 1)
+ contributors.add((name.strip(), email.strip().lower()))
+ return contributors
+ except (subprocess.TimeoutExpired, Exception):
+ return set()
+
+
+ def find_repos(root):
+ """Find all git repos (immediate subdirs with .git)."""
+ repos = []
+ for entry in sorted(os.listdir(root)):
+ path = os.path.join(root, entry)
+ if os.path.isdir(path) and os.path.isdir(os.path.join(path, ".git")):
+ repos.append(path)
+ return repos
+
+
+ def main():
+ root = sys.argv[1] if len(sys.argv) > 1 else os.getcwd()
+ root = os.path.abspath(root)
+
+ repos = find_repos(root)
+ if not repos:
+ print(f"no git repos found in {root}")
+ sys.exit(1)
+
+ print(f"scanning {len(repos)} repos in {root}\n")
+
+ now = datetime.now()
+
+ for label, days in PERIODS:
+ since = (now - timedelta(days=days)).strftime("%Y-%m-%d") if days else None
+ all_contributors = set()
+ repo_counts = {}
+
+ for repo in repos:
+ contribs = git_contributors(repo, since)
+ if contribs:
+ repo_name = os.path.basename(repo)
+ repo_counts[repo_name] = len(contribs)
+ all_contributors.update(contribs)
+
+ active_repos = len(repo_counts)
+ print(f"{'─' * 50}")
+ print(f"{label}: {len(all_contributors)} unique contributors"
+ f" across {active_repos} active repos")
+
+ if all_contributors:
+ person_repos = defaultdict(list)
+ for repo in repos:
+ contribs = git_contributors(repo, since)
+ repo_name = os.path.basename(repo)
+ for c in contribs:
+ person_repos[c].append(repo_name)
+
+ ranked = sorted(person_repos.items(), key=lambda x: -len(x[1]))
+ for (name, email), touched in ranked[:10]:
+ print(f" {name:30s} {len(touched):3d} repos ({email})")
+ if len(ranked) > 10:
+ print(f" ... and {len(ranked) - 10} more")
+
+ print(f"{'─' * 50}")
+
+
+ if __name__ == "__main__":
+ main()
+
+|
+
+.. image:: /uploads/2026/03/contributor-decay.svg
+ :alt: diagram showing Pylons org contributor decay from 28 lifetime contributors across 97 repos down to 4 humans plus 1 bot across 5 repos in the last 3 months, ending with no BDFL, no formal general, snowflake governance
+ :align: center
+ :width: 100%
+
+|
+
+Pyramid sits as a microcosm of open source in general. Every org looks like this. 28 humans across a lifetime. 4 active today. No BDFL. No benevolent dictator for life. No formal general directing strategy. Just snowflake governance. Each org a unique crystal of informal agreements, stale GOVERNANCE.md files, & whoever still answers email. Python had Guido. Linux has Linus. Pyramid has nobody. Most open source projects have nobody. They drift on inertia & the stubbornness of whoever remains.
+
+"Our" framework. "Our" community. "Our" roadmap. Strip the pronoun & look at what remains. A handful of volunteers. A bot. Some stale CI configs. The word "our" papers over the structural void where leadership should live. Without a BDFL, "our" becomes a polite fiction. Nobody owns it. Nobody steers it. Everybody claims it.
I catch myself writing "our" in `unturf `_ docs. "Our permacomputer approach." Whose? Mine? Fox's? A philosophy doesn't belong to anyone. "A permacomputer approach" works better. Drops possession entirely. Lets an idea stand on its own merit instead of borrowed authority.
diff --git a/content/uploads/2026/03/contributor-decay.dot b/content/uploads/2026/03/contributor-decay.dot
new file mode 100644
index 0000000..3964786
--- /dev/null
+++ b/content/uploads/2026/03/contributor-decay.dot
@@ -0,0 +1,27 @@
+digraph contributor_decay {
+ bgcolor="transparent"
+ rankdir=LR
+ node [fontname="Helvetica" fontsize=11 style="filled,rounded" shape=box color="#999999"]
+ edge [color="#AAAAAA" arrowsize=0.5]
+
+ subgraph cluster_time {
+ label="Pylons org: contributor decay" fontname="Helvetica Bold" fontsize=12 fontcolor="#CCCCCC"
+ style=dashed color="#999999"
+
+ lifetime [label="lifetime\n28 humans\n97 repos" fillcolor="#1b5e20" fontcolor="white"]
+ y18 [label="18 years\n28 humans\n97 repos" fillcolor="#2e7d32" fontcolor="white"]
+ y6 [label="6 years\n19 humans\n46 repos" fillcolor="#0d47a1" fontcolor="white"]
+ y3 [label="3 years\n15 humans\n31 repos" fillcolor="#1565c0" fontcolor="white"]
+ y1 [label="1 year\n7 humans\n8 repos" fillcolor="#6a1b9a" fontcolor="white"]
+ m3 [label="3 months\n4 humans\n+ 1 bot\n5 repos" fillcolor="#bf360c" fontcolor="white"]
+ }
+
+ lifetime -> y18 [label=" same" fontcolor="#CCCCCC"]
+ y18 -> y6 [label=" -9" fontcolor="#ef5350" color="#ef5350"]
+ y6 -> y3 [label=" -4" fontcolor="#ef5350" color="#ef5350"]
+ y3 -> y1 [label=" -8" fontcolor="#ef5350" color="#ef5350"]
+ y1 -> m3 [label=" -3" fontcolor="#ef5350" color="#ef5350"]
+
+ no_bdfl [label="no BDFL\nno formal general\nsnowflake governance" fillcolor="#4e342e" fontcolor="white" shape=note]
+ m3 -> no_bdfl [style=dashed color="#ef5350" fontcolor="#ef5350" label=" result"]
+}
diff --git a/content/uploads/2026/03/contributor-decay.svg b/content/uploads/2026/03/contributor-decay.svg
new file mode 100644
index 0000000..d562638
--- /dev/null
+++ b/content/uploads/2026/03/contributor-decay.svg
@@ -0,0 +1,157 @@
+
+
+
diff --git a/content/uploads/2026/03/eight-eyes.svg b/content/uploads/2026/03/eight-eyes.svg
index 65a090d..e48f27c 100644
--- a/content/uploads/2026/03/eight-eyes.svg
+++ b/content/uploads/2026/03/eight-eyes.svg
@@ -9,17 +9,77 @@
from { opacity: 0; }
to { opacity: 1; }
}
-.anim { opacity: 0; animation: fadeIn 0.8s ease-out forwards; }
-.d0 { animation-delay: 0s; }
-.d1 { animation-delay: 2s; }
-.d2 { animation-delay: 4s; }
-.d3 { animation-delay: 6s; }
-.d4 { animation-delay: 8s; }
-.d5 { animation-delay: 10s; }
-.d6 { animation-delay: 12s; }
-.d7 { animation-delay: 15s; }
-.d8 { animation-delay: 18s; }
-.d9 { animation-delay: 21s; }
+@keyframes loop0 {
+ 0% { opacity: 0; }
+ 2.1% { opacity: 1; }
+ 90.9% { opacity: 1; }
+ 94.8%, 100% { opacity: 0; }
+}
+@keyframes loop1 {
+ 0%, 5.2% { opacity: 0; }
+ 7.3% { opacity: 1; }
+ 90.9% { opacity: 1; }
+ 94.8%, 100% { opacity: 0; }
+}
+@keyframes loop2 {
+ 0%, 10.4% { opacity: 0; }
+ 12.5% { opacity: 1; }
+ 90.9% { opacity: 1; }
+ 94.8%, 100% { opacity: 0; }
+}
+@keyframes loop3 {
+ 0%, 15.7% { opacity: 0; }
+ 17.8% { opacity: 1; }
+ 90.9% { opacity: 1; }
+ 94.8%, 100% { opacity: 0; }
+}
+@keyframes loop4 {
+ 0%, 20.9% { opacity: 0; }
+ 23.0% { opacity: 1; }
+ 90.9% { opacity: 1; }
+ 94.8%, 100% { opacity: 0; }
+}
+@keyframes loop5 {
+ 0%, 26.1% { opacity: 0; }
+ 28.2% { opacity: 1; }
+ 90.9% { opacity: 1; }
+ 94.8%, 100% { opacity: 0; }
+}
+@keyframes loop6 {
+ 0%, 31.3% { opacity: 0; }
+ 33.4% { opacity: 1; }
+ 90.9% { opacity: 1; }
+ 94.8%, 100% { opacity: 0; }
+}
+@keyframes loop7 {
+ 0%, 39.2% { opacity: 0; }
+ 41.3% { opacity: 1; }
+ 90.9% { opacity: 1; }
+ 94.8%, 100% { opacity: 0; }
+}
+@keyframes loop8 {
+ 0%, 47.0% { opacity: 0; }
+ 49.1% { opacity: 1; }
+ 90.9% { opacity: 1; }
+ 94.8%, 100% { opacity: 0; }
+}
+@keyframes loop9 {
+ 0%, 54.8% { opacity: 0; }
+ 56.9% { opacity: 1; }
+ 90.9% { opacity: 1; }
+ 94.8%, 100% { opacity: 0; }
+}
+.anim { opacity: 0; }
+.d0 { animation: loop0 38.3s ease-out infinite; }
+.d1 { animation: loop1 38.3s ease-out infinite; }
+.d2 { animation: loop2 38.3s ease-out infinite; }
+.d3 { animation: loop3 38.3s ease-out infinite; }
+.d4 { animation: loop4 38.3s ease-out infinite; }
+.d5 { animation: loop5 38.3s ease-out infinite; }
+.d6 { animation: loop6 38.3s ease-out infinite; }
+.d7 { animation: loop7 38.3s ease-out infinite; }
+.d8 { animation: loop8 38.3s ease-out infinite; }
+.d9 { animation: loop9 38.3s ease-out infinite; }
@@ -27,7 +87,7 @@
-Neville's Seven Eyes of God
+Neville's Seven Eyes of God
@@ -115,10 +175,10 @@
-7
-Jesus
-trinity
-man↔God
+7
+Jesus
+trinity
+man↔God
diff --git a/content/uploads/2026/03/load-bearing.dot b/content/uploads/2026/03/load-bearing.dot
index 412fd45..58c2af1 100644
--- a/content/uploads/2026/03/load-bearing.dot
+++ b/content/uploads/2026/03/load-bearing.dot
@@ -15,9 +15,9 @@ digraph load_bearing {
apps [label="thousands of\nPyramid apps" fillcolor="#546E7A" fontcolor="white"]
}
- pyramid [label="Pyramid\n~5 maintainers" fillcolor="#BF360C" fontcolor="white" fontsize=13]
+ pyramid [label="Pylons org: 98 repos\n28 lifetime contributors\n4 humans active last 3mo" fillcolor="#BF360C" fontcolor="white" fontsize=13]
- russell [label="Russell\nunemployed\nvolunteer" fillcolor="#E65100" fontcolor="white" shape=ellipse]
+ russell [label="Russell\nunemployed volunteer\nonly new contributor\nin the past year\nPR #3805" fillcolor="#E65100" fontcolor="white" shape=ellipse]
pypi -> pyramid
rhodecode -> pyramid
diff --git a/content/uploads/2026/03/load-bearing.svg b/content/uploads/2026/03/load-bearing.svg
index 7817e4c..e1f30e7 100644
--- a/content/uploads/2026/03/load-bearing.svg
+++ b/content/uploads/2026/03/load-bearing.svg
@@ -1,172 +1,163 @@
-
-
diff --git a/content/uploads/2026/03/mystic-pronouns.svg b/content/uploads/2026/03/mystic-pronouns.svg
index 1fb95a3..4bfe092 100644
--- a/content/uploads/2026/03/mystic-pronouns.svg
+++ b/content/uploads/2026/03/mystic-pronouns.svg
@@ -12,13 +12,49 @@
from { opacity: 0; }
to { opacity: 1; }
}
-.anim { opacity: 0; animation: fadeIn 0.8s ease-out forwards; }
-.d0 { animation-delay: 0s; }
-.d1 { animation-delay: 3s; }
-.d2 { animation-delay: 6s; }
-.d3 { animation-delay: 9s; }
-.d4 { animation-delay: 12s; }
-.d5 { animation-delay: 15s; }
+@keyframes loop0 {
+ 0% { opacity: 0; }
+ 2.5% { opacity: 1; }
+ 89.2% { opacity: 1; }
+ 93.8%, 100% { opacity: 0; }
+}
+@keyframes loop1 {
+ 0%, 9.3% { opacity: 0; }
+ 11.8% { opacity: 1; }
+ 89.2% { opacity: 1; }
+ 93.8%, 100% { opacity: 0; }
+}
+@keyframes loop2 {
+ 0%, 18.6% { opacity: 0; }
+ 21.1% { opacity: 1; }
+ 89.2% { opacity: 1; }
+ 93.8%, 100% { opacity: 0; }
+}
+@keyframes loop3 {
+ 0%, 27.9% { opacity: 0; }
+ 30.3% { opacity: 1; }
+ 89.2% { opacity: 1; }
+ 93.8%, 100% { opacity: 0; }
+}
+@keyframes loop4 {
+ 0%, 37.2% { opacity: 0; }
+ 39.6% { opacity: 1; }
+ 89.2% { opacity: 1; }
+ 93.8%, 100% { opacity: 0; }
+}
+@keyframes loop5 {
+ 0%, 46.4% { opacity: 0; }
+ 48.9% { opacity: 1; }
+ 89.2% { opacity: 1; }
+ 93.8%, 100% { opacity: 0; }
+}
+.anim { opacity: 0; }
+.d0 { animation: loop0 32.3s ease-out infinite; }
+.d1 { animation: loop1 32.3s ease-out infinite; }
+.d2 { animation: loop2 32.3s ease-out infinite; }
+.d3 { animation: loop3 32.3s ease-out infinite; }
+.d4 { animation: loop4 32.3s ease-out infinite; }
+.d5 { animation: loop5 32.3s ease-out infinite; }
@@ -49,7 +85,7 @@
- dissolves into
+ dissolves into
@@ -67,7 +103,7 @@
- dissolves into
+ dissolves into
@@ -85,7 +121,7 @@
- dissolves into
+ dissolves into
@@ -103,7 +139,7 @@
- dissolves into
+ dissolves into
@@ -113,9 +149,9 @@
-"I AM"
-singular grammar
-infinite referent
+"I AM"
+singular grammar
+infinite referent
@@ -124,14 +160,14 @@
- becomes
+ becomes
- becomes
+ becomes
diff --git a/content/uploads/2026/03/naming-dance.svg b/content/uploads/2026/03/naming-dance.svg
index a8c1804..1a68f87 100644
--- a/content/uploads/2026/03/naming-dance.svg
+++ b/content/uploads/2026/03/naming-dance.svg
@@ -12,12 +12,42 @@
from { opacity: 0; }
to { opacity: 1; }
}
-.anim { opacity: 0; animation: fadeIn 0.8s ease-out forwards; }
-.d0 { animation-delay: 0s; }
-.d1 { animation-delay: 3s; }
-.d2 { animation-delay: 6s; }
-.d3 { animation-delay: 9s; }
-.d4 { animation-delay: 12s; }
+@keyframes loop0 {
+ 0% { opacity: 0; }
+ 2.7% { opacity: 1; }
+ 88.1% { opacity: 1; }
+ 93.2%, 100% { opacity: 0; }
+}
+@keyframes loop1 {
+ 0%, 10.2% { opacity: 0; }
+ 13.0% { opacity: 1; }
+ 88.1% { opacity: 1; }
+ 93.2%, 100% { opacity: 0; }
+}
+@keyframes loop2 {
+ 0%, 20.5% { opacity: 0; }
+ 23.2% { opacity: 1; }
+ 88.1% { opacity: 1; }
+ 93.2%, 100% { opacity: 0; }
+}
+@keyframes loop3 {
+ 0%, 30.7% { opacity: 0; }
+ 33.4% { opacity: 1; }
+ 88.1% { opacity: 1; }
+ 93.2%, 100% { opacity: 0; }
+}
+@keyframes loop4 {
+ 0%, 41.0% { opacity: 0; }
+ 43.7% { opacity: 1; }
+ 88.1% { opacity: 1; }
+ 93.2%, 100% { opacity: 0; }
+}
+.anim { opacity: 0; }
+.d0 { animation: loop0 29.3s ease-out infinite; }
+.d1 { animation: loop1 29.3s ease-out infinite; }
+.d2 { animation: loop2 29.3s ease-out infinite; }
+.d3 { animation: loop3 29.3s ease-out infinite; }
+.d4 { animation: loop4 29.3s ease-out infinite; }
@@ -45,7 +75,7 @@
- closeness grows
+ closeness grows
@@ -62,7 +92,7 @@
- trust earned
+ trust earned
@@ -79,7 +109,7 @@
- family accepted
+ family accepted
@@ -96,9 +126,9 @@
- affection
- hides inside
- fake demand
+ affection
+ hides inside
+ fake demand
diff --git a/content/uploads/2026/03/one-multitudes.svg b/content/uploads/2026/03/one-multitudes.svg
index 12d790a..308c11b 100644
--- a/content/uploads/2026/03/one-multitudes.svg
+++ b/content/uploads/2026/03/one-multitudes.svg
@@ -9,18 +9,84 @@
from { opacity: 0; }
to { opacity: 1; }
}
-.anim { opacity: 0; animation: fadeIn 0.8s ease-out forwards; }
-.d0 { animation-delay: 0s; }
-.d1 { animation-delay: 3s; }
-.d2 { animation-delay: 6s; }
-.d3 { animation-delay: 7s; }
-.d4 { animation-delay: 8s; }
-.d5 { animation-delay: 9s; }
-.d6 { animation-delay: 10s; }
-.d7 { animation-delay: 11s; }
-.d8 { animation-delay: 12s; }
-.d9 { animation-delay: 13s; }
-.d10 { animation-delay: 15s; }
+@keyframes loop0 {
+ 0% { opacity: 0; }
+ 2.5% { opacity: 1; }
+ 89.2% { opacity: 1; }
+ 93.8%, 100% { opacity: 0; }
+}
+@keyframes loop1 {
+ 0%, 9.3% { opacity: 0; }
+ 11.8% { opacity: 1; }
+ 89.2% { opacity: 1; }
+ 93.8%, 100% { opacity: 0; }
+}
+@keyframes loop2 {
+ 0%, 18.6% { opacity: 0; }
+ 21.1% { opacity: 1; }
+ 89.2% { opacity: 1; }
+ 93.8%, 100% { opacity: 0; }
+}
+@keyframes loop3 {
+ 0%, 21.7% { opacity: 0; }
+ 24.1% { opacity: 1; }
+ 89.2% { opacity: 1; }
+ 93.8%, 100% { opacity: 0; }
+}
+@keyframes loop4 {
+ 0%, 24.8% { opacity: 0; }
+ 27.2% { opacity: 1; }
+ 89.2% { opacity: 1; }
+ 93.8%, 100% { opacity: 0; }
+}
+@keyframes loop5 {
+ 0%, 27.9% { opacity: 0; }
+ 30.3% { opacity: 1; }
+ 89.2% { opacity: 1; }
+ 93.8%, 100% { opacity: 0; }
+}
+@keyframes loop6 {
+ 0%, 31.0% { opacity: 0; }
+ 33.4% { opacity: 1; }
+ 89.2% { opacity: 1; }
+ 93.8%, 100% { opacity: 0; }
+}
+@keyframes loop7 {
+ 0%, 34.1% { opacity: 0; }
+ 36.5% { opacity: 1; }
+ 89.2% { opacity: 1; }
+ 93.8%, 100% { opacity: 0; }
+}
+@keyframes loop8 {
+ 0%, 37.2% { opacity: 0; }
+ 39.6% { opacity: 1; }
+ 89.2% { opacity: 1; }
+ 93.8%, 100% { opacity: 0; }
+}
+@keyframes loop9 {
+ 0%, 40.2% { opacity: 0; }
+ 42.7% { opacity: 1; }
+ 89.2% { opacity: 1; }
+ 93.8%, 100% { opacity: 0; }
+}
+@keyframes loop10 {
+ 0%, 46.4% { opacity: 0; }
+ 48.9% { opacity: 1; }
+ 89.2% { opacity: 1; }
+ 93.8%, 100% { opacity: 0; }
+}
+.anim { opacity: 0; }
+.d0 { animation: loop0 32.3s ease-out infinite; }
+.d1 { animation: loop1 32.3s ease-out infinite; }
+.d2 { animation: loop2 32.3s ease-out infinite; }
+.d3 { animation: loop3 32.3s ease-out infinite; }
+.d4 { animation: loop4 32.3s ease-out infinite; }
+.d5 { animation: loop5 32.3s ease-out infinite; }
+.d6 { animation: loop6 32.3s ease-out infinite; }
+.d7 { animation: loop7 32.3s ease-out infinite; }
+.d8 { animation: loop8 32.3s ease-out infinite; }
+.d9 { animation: loop9 32.3s ease-out infinite; }
+.d10 { animation: loop10 32.3s ease-out infinite; }
@@ -28,13 +94,13 @@
-pretends to contain
+pretends to contain
-actually contains
+actually contains
@@ -67,13 +133,13 @@
-holds
+holds
-photons
+photons
@@ -126,9 +192,9 @@
-as above
+as above
-so below
+so below
diff --git a/content/uploads/2026/03/pronoun-masks.svg b/content/uploads/2026/03/pronoun-masks.svg
index bb8cd33..b4aadb4 100644
--- a/content/uploads/2026/03/pronoun-masks.svg
+++ b/content/uploads/2026/03/pronoun-masks.svg
@@ -9,11 +9,35 @@
from { opacity: 0; }
to { opacity: 1; }
}
-.anim { opacity: 0; animation: fadeIn 0.8s ease-out forwards; }
-.d0 { animation-delay: 0s; }
-.d1 { animation-delay: 3s; }
-.d2 { animation-delay: 6s; }
-.d3 { animation-delay: 9s; }
+@keyframes loop0 {
+ 0% { opacity: 0; }
+ 3.0% { opacity: 1; }
+ 86.7% { opacity: 1; }
+ 92.4%, 100% { opacity: 0; }
+}
+@keyframes loop1 {
+ 0%, 11.4% { opacity: 0; }
+ 14.4% { opacity: 1; }
+ 86.7% { opacity: 1; }
+ 92.4%, 100% { opacity: 0; }
+}
+@keyframes loop2 {
+ 0%, 22.8% { opacity: 0; }
+ 25.9% { opacity: 1; }
+ 86.7% { opacity: 1; }
+ 92.4%, 100% { opacity: 0; }
+}
+@keyframes loop3 {
+ 0%, 34.2% { opacity: 0; }
+ 37.3% { opacity: 1; }
+ 86.7% { opacity: 1; }
+ 92.4%, 100% { opacity: 0; }
+}
+.anim { opacity: 0; }
+.d0 { animation: loop0 26.3s ease-out infinite; }
+.d1 { animation: loop1 26.3s ease-out infinite; }
+.d2 { animation: loop2 26.3s ease-out infinite; }
+.d3 { animation: loop3 26.3s ease-out infinite; }
@@ -21,9 +45,9 @@
-what they write
+what they write
-what they mean
+what they mean
@@ -32,7 +56,7 @@
"we"
- translates to
+ translates to
I, but lonelier
@@ -43,7 +67,7 @@
"our"
- translates to
+ translates to
mine, but I want
you to feel invested
@@ -55,7 +79,7 @@
"one"
- translates to
+ translates to
I'm afraid
to say I
@@ -67,7 +91,7 @@
"you"
- translates to
+ translates to
talking to myself
6 months from now
diff --git a/content/uploads/2026/03/royal-we-unmasked.svg b/content/uploads/2026/03/royal-we-unmasked.svg
index 9d8401a..7887326 100644
--- a/content/uploads/2026/03/royal-we-unmasked.svg
+++ b/content/uploads/2026/03/royal-we-unmasked.svg
@@ -12,12 +12,42 @@
from { opacity: 0; }
to { opacity: 1; }
}
-.anim { opacity: 0; animation: fadeIn 0.8s ease-out forwards; }
-.d0 { animation-delay: 0s; }
-.d1 { animation-delay: 3s; }
-.d2 { animation-delay: 6s; }
-.d3 { animation-delay: 9s; }
-.d4 { animation-delay: 12s; }
+@keyframes loop0 {
+ 0% { opacity: 0; }
+ 2.7% { opacity: 1; }
+ 88.1% { opacity: 1; }
+ 93.2%, 100% { opacity: 0; }
+}
+@keyframes loop1 {
+ 0%, 10.2% { opacity: 0; }
+ 13.0% { opacity: 1; }
+ 88.1% { opacity: 1; }
+ 93.2%, 100% { opacity: 0; }
+}
+@keyframes loop2 {
+ 0%, 20.5% { opacity: 0; }
+ 23.2% { opacity: 1; }
+ 88.1% { opacity: 1; }
+ 93.2%, 100% { opacity: 0; }
+}
+@keyframes loop3 {
+ 0%, 30.7% { opacity: 0; }
+ 33.4% { opacity: 1; }
+ 88.1% { opacity: 1; }
+ 93.2%, 100% { opacity: 0; }
+}
+@keyframes loop4 {
+ 0%, 41.0% { opacity: 0; }
+ 43.7% { opacity: 1; }
+ 88.1% { opacity: 1; }
+ 93.2%, 100% { opacity: 0; }
+}
+.anim { opacity: 0; }
+.d0 { animation: loop0 29.3s ease-out infinite; }
+.d1 { animation: loop1 29.3s ease-out infinite; }
+.d2 { animation: loop2 29.3s ease-out infinite; }
+.d3 { animation: loop3 29.3s ease-out infinite; }
+.d4 { animation: loop4 29.3s ease-out infinite; }
@@ -70,7 +100,7 @@
-implies
+implies
@@ -79,7 +109,7 @@
-behind the curtain
+behind the curtain
@@ -95,7 +125,7 @@
-prompting
+prompting
@@ -112,14 +142,14 @@
-typing
+typing
-generating
+generating
diff --git a/content/uploads/2026/03/unbury.py b/content/uploads/2026/03/unbury.py
new file mode 100755
index 0000000..327f193
--- /dev/null
+++ b/content/uploads/2026/03/unbury.py
@@ -0,0 +1,102 @@
+#!/usr/bin/env python3
+"""unbury - exhume contributor stats from a graveyard of git repos.
+
+Usage:
+ unbury [path] # defaults to current directory
+ unbury /home/fox/git/pylons
+"""
+
+import os
+import subprocess
+import sys
+from collections import defaultdict
+from datetime import datetime, timedelta
+
+PERIODS = [
+ ("3 months", 90),
+ ("1 year", 365),
+ ("3 years", 365 * 3),
+ ("6 years", 365 * 6),
+ ("18 years", 365 * 18),
+ ("lifetime", None),
+]
+
+
+def git_contributors(repo_path, since=None):
+ """Return set of (name, email) tuples for a repo."""
+ cmd = ["git", "-C", repo_path, "log", "--format=%aN\t%aE"]
+ if since:
+ cmd.append(f"--since={since}")
+ try:
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
+ contributors = set()
+ for line in result.stdout.strip().split("\n"):
+ if "\t" in line:
+ name, email = line.split("\t", 1)
+ contributors.add((name.strip(), email.strip().lower()))
+ return contributors
+ except (subprocess.TimeoutExpired, Exception):
+ return set()
+
+
+def find_repos(root):
+ """Find all git repos (immediate subdirs with .git)."""
+ repos = []
+ for entry in sorted(os.listdir(root)):
+ path = os.path.join(root, entry)
+ if os.path.isdir(path) and os.path.isdir(os.path.join(path, ".git")):
+ repos.append(path)
+ return repos
+
+
+def main():
+ root = sys.argv[1] if len(sys.argv) > 1 else os.getcwd()
+ root = os.path.abspath(root)
+
+ repos = find_repos(root)
+ if not repos:
+ print(f"no git repos found in {root}")
+ sys.exit(1)
+
+ print(f"scanning {len(repos)} repos in {root}\n")
+
+ now = datetime.now()
+
+ for label, days in PERIODS:
+ since = (now - timedelta(days=days)).strftime("%Y-%m-%d") if days else None
+ all_contributors = set()
+ repo_counts = {}
+
+ for repo in repos:
+ contribs = git_contributors(repo, since)
+ if contribs:
+ repo_name = os.path.basename(repo)
+ repo_counts[repo_name] = len(contribs)
+ all_contributors.update(contribs)
+
+ active_repos = len(repo_counts)
+ print(f"{'─' * 50}")
+ print(f"{label}: {len(all_contributors)} unique contributors across {active_repos} active repos")
+
+ if all_contributors:
+ # count contributions per person across repos
+ person_repos = defaultdict(list)
+ for repo in repos:
+ contribs = git_contributors(repo, since)
+ repo_name = os.path.basename(repo)
+ for c in contribs:
+ person_repos[c].append(repo_name)
+
+ # sort by number of repos touched
+ ranked = sorted(person_repos.items(), key=lambda x: -len(x[1]))
+ top = ranked[:10]
+ for (name, email), touched in top:
+ print(f" {name:30s} {len(touched):3d} repos ({email})")
+ if len(ranked) > 10:
+ print(f" ... and {len(ranked) - 10} more")
+
+ print(f"{'─' * 50}")
+
+
+if __name__ == "__main__":
+ main()