aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/design/2026-07-15-velox-boot-failure-handoff.org61
-rw-r--r--docs/workflows/system-health-check.org2
-rw-r--r--todo.org63
-rw-r--r--working/clock-display-references/2026-07-30-maeda-cosmos-notes.org11
-rw-r--r--working/clock-display-references/2026-07-30-maeda-cosmos-standalone.html422
-rw-r--r--working/clock-display-references/2026-07-30-maeda-line-notes.org11
-rw-r--r--working/clock-display-references/2026-07-30-maeda-line-standalone.html457
-rw-r--r--working/velox-reinstall/2026-08-14-emacsd-velox-reinstall-gaps-1.org137
-rw-r--r--working/velox-reinstall/2026-08-14-emacsd-velox-reinstall-gaps-2.org63
-rw-r--r--working/velox-reinstall/2026-08-14-emacsd-velox-reinstall-gaps-3.org98
10 files changed, 1324 insertions, 1 deletions
diff --git a/docs/design/2026-07-15-velox-boot-failure-handoff.org b/docs/design/2026-07-15-velox-boot-failure-handoff.org
new file mode 100644
index 0000000..5ec996f
--- /dev/null
+++ b/docs/design/2026-07-15-velox-boot-failure-handoff.org
@@ -0,0 +1,61 @@
+#+TITLE: Velox boot failure — ZBM found no bootable kernel; diagnosis in progress, recovery plan attached
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-15
+
+* Why this is coming to archsetup
+
+Velox fails to boot: ZFSBootMenu reports it can't find a bootable environment with a kernel. Craig reports the last working velox session was an archsetup health-check run that included the pacman upgrade — so the breakage most likely happened inside archsetup's own workflow, and Craig wants the diagnosis + retrospective to continue here with full context. The .emacs.d session (where this was triaged, only because that's where Craig was sitting) hands off everything below.
+
+A phone photo of the zfs list output from velox's ZBM recovery shell accompanies this note in the inbox.
+
+* Timeline
+
+- 2026-07-13 ~23:50 CDT — velox last seen on the tailnet (per tailscale status read 2026-07-14 ~17:50).
+- During that last session: archsetup health-check workflow ran, including a pacman upgrade (Craig's recollection — pacman.log will confirm exact times).
+- 2026-07-14 late evening — Craig boots velox; ZBM: no bootable environment with a kernel.
+- 2026-07-14/15 — triage from the ZBM recovery shell, Craig driving, guided from the .emacs.d session.
+
+* Facts established so far (from the ZBM recovery shell)
+
+- zroot imported, health ONLINE. Every dataset's keystatus is "available" — encryption unlocked, not a key problem.
+- Layout confirmed from zfs list: zroot/ROOT/default (mountpoint /), separate datasets for home, home/root, media, var, var/cache, var/lib, var/lib/docker plus many docker layer children (legacy mountpoints). NOTE: no separate zroot/var/log dataset — /var/log lives inside zroot/var. That differs from the sanoid dataset list in archsetup's configure_zfs_snapshots (which configures zroot/var/log and zroot/var/lib/pacman as their own datasets) — worth reconciling in the retrospective.
+- Mounted the BE read-only style: mkdir -p /mnt/be && mount -t zfs -o zfsutil zroot/ROOT/default /mnt/be.
+- THE FINDING: /mnt/be/boot contains ONLY intel-ucode.img. vmlinuz-linux, initramfs-linux.img, and initramfs-linux-fallback.img are all gone.
+
+* Working hypothesis
+
+A kernel upgrade during the health-check run removed the old kernel files and never completed installing the new ones (interrupted transaction, mkinitcpio failure, or a /boot shadowing issue), and the machine was powered off with /boot empty. Arch's upgrade removes the running kernel's files at package-replace time, so a failure between "remove old" and "install new + mkinitcpio" leaves exactly this state: microcode present, kernel and initramfs absent.
+
+* Remaining diagnosis steps (not yet run — velox is sitting at the ZBM shell)
+
+1. Read pacman's log (on the zroot/var dataset):
+ #+begin_src sh
+ mkdir -p /mnt/var
+ mount -t zfs -o zfsutil zroot/var /mnt/var
+ tail -60 /mnt/var/log/pacman.log
+ #+end_src
+ Expect the failed/interrupted kernel transaction near the end; note its timestamp.
+2. List recovery candidates:
+ #+begin_src sh
+ zfs list -t snapshot zroot/ROOT/default | tail -20
+ #+end_src
+ Sanoid is configured for hourly=6/daily=7 on the ROOT dataset, so a pre-damage snapshot should exist. Check whether any pre-pacman_* snapshots appear — that tells us whether the 2026-06-29 pre-pacman hook design is actually installed on velox.
+
+* Recovery plan (agreed with Craig, pending the log read)
+
+1. Pick the newest zroot/ROOT/default snapshot that predates the failed transaction.
+2. If the pool is imported read-only (zpool get readonly zroot): zpool export zroot && zpool import -f -N zroot.
+3. zfs rollback -r zroot/ROOT/default@<snapshot> (the -r discards snapshots newer than the target; home/var/media are separate datasets and untouched).
+4. zpool export zroot, reboot — ZBM should now see the kernel.
+5. After first boot: re-run pacman -Syu attended, and confirm /boot holds vmlinuz-linux + initramfs-linux.img before any shutdown.
+
+* Retrospective candidates for archsetup
+
+- Does the health-check / upgrade flow verify /boot contents (kernel + initramfs present, mkinitcpio exit status) after a kernel upgrade? This failure would have been caught by a one-line post-upgrade assertion.
+- Is the pre-pacman snapshot hook (2026-06-29 design, zroot/ROOT/default@pre-pacman_<ts>) installed on velox? The snapshot listing in step 2 above answers this empirically.
+- The sanoid config vs actual dataset layout mismatch (var/log, var/lib/pacman) noted above.
+- Whether the upgrade step should refuse to end the session (or page Craig) when a kernel transaction errors.
+
+* Related loose end already in your inbox
+
+A separate note (2026-07-14-1751) asks to add inetutils to the install base; velox also still needs that package installed once it boots again.
diff --git a/docs/workflows/system-health-check.org b/docs/workflows/system-health-check.org
index 777e724..37c1932 100644
--- a/docs/workflows/system-health-check.org
+++ b/docs/workflows/system-health-check.org
@@ -1059,7 +1059,7 @@ Each entry is scoped to one host (or =any=). When Phase 1 cross-references findi
** 2026-09-12: any — fwupdmgr activates passim, a public LAN listener
:host: any
- Symptom: running =fwupdmgr= (refresh, update) D-Bus-activates =passim.service=, fwupd's LAN metadata-sharing daemon, which listens on =0.0.0.0:27500= and trips the maint listeners check to crit.
-- The unit is static (no =[Install]= section), so =systemctl disable= is a no-op and it comes back on the next fwupdmgr run. Masking is what holds: =systemctl mask passim.service=. Ratio has been masked since 2026-07-21; velox was only stopped and disabled on 2026-09-12 and still needs the mask. The installer masks it as part of installing fwupd. =P2pPolicy=nothing= under =[fwupd]= in =/etc/fwupd/fwupd.conf= also works, but that file is pacman-owned and invites pacnew churn, so the mask is the form in use.
+- The unit is static (no =[Install]= section), so =systemctl disable= is a no-op and it comes back on the next fwupdmgr run. Masking is what holds: =systemctl mask passim.service=. Ratio has been masked since 2026-07-21; velox was stopped and disabled on 2026-09-12 (the disable being the no-op) and got the mask on 2026-09-13. The installer masks it as part of installing fwupd. =P2pPolicy=nothing= under =[fwupd]= in =/etc/fwupd/fwupd.conf= also works, but that file is pacman-owned and invites pacnew churn, so the mask is the form in use.
- Classification: KNOWN — a passim listener means a machine that predates the mask or lost it; mask it, don't allowlist it.
** 2026-09-12: velox — topgrade containers step fails on locally built images
diff --git a/todo.org b/todo.org
index 6c087a0..598ed47 100644
--- a/todo.org
+++ b/todo.org
@@ -46,6 +46,34 @@ below):
input-side-spec.org (DRAFT, four decisions open).
* Archsetup Open Work
+** TODO [#B] Visual separator between adjacent waybar modules :feature:waybar:dotfiles:quick:
+:PROPERTIES:
+:CREATED: [2026-09-13 Sun]
+:LAST_REVIEWED: 2026-09-13
+:END:
+Captured by Craig 2026-07-20 and routed here from the roam inbox, then lost
+in the processed pile: the wind (weather) value runs straight into the date
+with no visual stop, so the wind figure reads as the start of the date. Add
+a light separator or spacing between adjacent modules so each one's edge is
+unmistakable. Check the current bar first (the mic/PTT merge and the weather
+chip grouping landed after the capture), then decide the form: a thin rule,
+a dot glyph, or just margin. That choice is mine, so not solo; the CSS itself
+is a quick change in the dotfiles waybar stylesheet.
+
+** TODO [#C] Saving and restoring a window configuration :feature:hyprland:research:
+:PROPERTIES:
+:CREATED: [2026-09-13 Sun]
+:LAST_REVIEWED: 2026-09-13
+:END:
+Research idea captured by Craig 2026-07-24 (the one item of that batch that
+never got filed): when I want a specific window orientation, I indicate it
+and the window-plus-app configuration reappears. What would we need to know
+or store to make that happen? Is there another desktop or OS that does it,
+what information do they keep, and what are their rules? Explore how far
+Hyprland can get, document thoroughly, and review the findings with me before
+building anything. The deliverable is a research note under docs/design, so
+this is not solo.
+
** TODO [#C] maint backup_freshness probe blind to backup_run remedy runs :bug:maint:dotfiles:solo:
:PROPERTIES:
:CREATED: [2026-09-12 Sat]
@@ -485,6 +513,19 @@ Finding folded in: velox's truenas backups silently stopped ~Jul 6 (newest is
DAILY.0 Jul 6; wolf.conf.gpg from Jul 29 is in NO backup). Salvage pass in the
runbook is therefore REQUIRED before partitioning, and the fresh install must
fix + verify the backup timer (runbook Phase 5).
+
+*** 2026-09-13 Sun @ 07:16:27 -0500 Applied the two live convergence steps on velox
+Ratio got both by hand on 2026-09-12 while velox was off the tailnet; velox
+came back on 2026-09-13 and got them over ssh: =systemctl disable --now
+wsdd.service= (no Samba host to advertise; 43acf51 stops the installer
+enabling it) and =systemctl mask passim.service= (the unit is static, so the
+09-12 disable was a no-op; 38b1758 masks it in the installer, but
+supplemental_software is a completed step there and doesn't re-run).
+Verified after: wsdd inactive/disabled, passim inactive/masked, zero
+listeners on 5357 and 27500. Same pass fast-forwarded velox's dotfiles to
+f56fd1a and archsetup to dc00a62, and confirmed the headless Proton Bridge
+service is still disabled there.
+
** TODO [#B] Truenas session-host VM for long-running agent sessions :feature:tooling:
:PROPERTIES:
:CREATED: [2026-08-13 Thu]
@@ -747,6 +788,14 @@ So the checklist wants two columns, not one.
Graded [#A] because item 5 is live right now and silently disables
paging, and because the flight is Sunday.
+
+*** 2026-09-13 Sun @ 07:14:54 -0500 Moved the three gap reports into the reinstall working dir
+The 2026-08-14 reports that define the five gaps moved out of inbox/ into
+[[file:working/velox-reinstall/2026-08-14-emacsd-velox-reinstall-gaps-1.org][gaps 1-4]],
+[[file:working/velox-reinstall/2026-08-14-emacsd-velox-reinstall-gaps-2.org][gap 5]] and
+[[file:working/velox-reinstall/2026-08-14-emacsd-velox-reinstall-gaps-3.org][the two email-side gaps]] (the Bridge cert and the Bridge password).
+They file with the rest of the reinstall artifacts when that task closes.
+
** TODO [#B] Restoring a git repo from backup can resurrect a dangerous diff :bug:
:PROPERTIES:
:CREATED: [2026-08-14 Fri]
@@ -1280,6 +1329,12 @@ split zroot/var/log and zroot/var/lib/pacman out as configure_zfs_snapshots
assumes, or change the config to match the layout velox actually has. That is
a call I have not made, so the task stays [#B] and not solo.
+*** 2026-09-13 Sun @ 07:14:54 -0500 Filed the original boot-failure handoff under docs/design
+The 2026-07-15 diagnosis and recovery plan that opened this task sat in
+inbox/ as a processed file; it now lives at
+[[file:docs/design/2026-07-15-velox-boot-failure-handoff.org][docs/design/2026-07-15-velox-boot-failure-handoff.org]]
+so the timeline survives the inbox sweep.
+
** TODO [#C] Assess a Hyprland left-drag window gesture :feature:hyprland:
:PROPERTIES:
:LAST_REVIEWED: 2026-08-26
@@ -1615,6 +1670,14 @@ Restyle the audio panel's GTK CSS onto =tokens-waybar.css= + the banked composit
After ~5 hand ports, weigh widget-level codegen with evidence (mechanical duplication vs judgment per port). Recorded as a dated decision in the spec; go spawns its own spec.
*** TODO Flip the spec to IMPLEMENTED
When the phases above close: status heading keyword → =IMPLEMENTED=, dated history line with the reason, Metadata =Status= mirror. Three lines, one file.
+
+*** 2026-09-13 Sun @ 07:21:18 -0500 Filed the two Maeda applets into the clock display references
+The Line (C2, 1997) and Cosmos (C1, 1995) standalone applets sent from the
+website project on 2026-07-30 sat in inbox/ as processed files; they and
+their notes now live beside the other references as
+=2026-07-30-maeda-{line,cosmos}-standalone.html= and =-notes.org=.
+Reference only, regenerate rather than edit.
+
** TODO [#B] Net doctor expansion v1 — VM live verification :feature:dotfiles:network:solo:
:PROPERTIES:
:SPEC_ID: ce29b103-ed9d-4f56-bf8c-9ed8fe680ff3
diff --git a/working/clock-display-references/2026-07-30-maeda-cosmos-notes.org b/working/clock-display-references/2026-07-30-maeda-cosmos-notes.org
new file mode 100644
index 0000000..04d8629
--- /dev/null
+++ b/working/clock-display-references/2026-07-30-maeda-cosmos-notes.org
@@ -0,0 +1,11 @@
+#+TITLE: Companion to the Line applet sent a few minutes ago: a copy
+#+SOURCE: from website
+#+DATE: 2026-07-30 18:48:00 -0500
+
+Companion to the Line applet sent a few minutes ago: a copy of Cosmos (C1, 1995), the orbits piece — 'numbers in orbit'. Delivered as 2026-07-30-maeda-cosmos-standalone.html in this inbox.
+
+Same shape as the Line copy: the <maeda-cosmos> web component plus its model/step/render modules esbuild-bundled inline as an IIFE, so it opens straight from file:// with no server and no network. Verified in Chrome from file:// — canvas 400x300, the month's days riding the rotated ellipse with today red on the sweep hand, seconds odometer live at the right edge, zero console errors.
+
+Controls: hover reshapes every orbit; press-drag pulls a new loop out of the system; the mark at bottom-left clears them and replays the fly-in credit; click the canvas then ESC toggles strip-move mode. Note this one has no readout event, unlike Line.
+
+Source of truth stays ~/code/maeda-tribute (src/pieces/cosmos/); regenerate rather than edit. Reference only — no action needed.
diff --git a/working/clock-display-references/2026-07-30-maeda-cosmos-standalone.html b/working/clock-display-references/2026-07-30-maeda-cosmos-standalone.html
new file mode 100644
index 0000000..bfa9fbb
--- /dev/null
+++ b/working/clock-display-references/2026-07-30-maeda-cosmos-standalone.html
@@ -0,0 +1,422 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Cosmos (C1, 1995) — Maeda × Shiseido tribute</title>
+<style>
+:root{
+ --ground:#151311;
+ --panel:#100f0f;
+ --well:#0a0c0d;
+ --raise:#1a1917;
+ --silver:#bfc4d0;
+ --cream:#f3e7c5;
+ --steel:#969385;
+ --dim:#7c838a;
+ --wash:#2c2f32;
+ --gold:#e2a038;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html{background:var(--ground);font-size:112%}
+body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 4rem;line-height:1.45;
+ background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)}
+.wrap{max-width:820px;margin:0 auto}
+.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase}
+h1{font-size:1.5rem;color:var(--cream);font-weight:600;margin:.35rem 0 .2rem}
+.sub{color:var(--dim);font-size:.9rem;margin-bottom:1.6rem}
+.card{background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320;
+ border-radius:12px;padding:1.2rem;margin-bottom:1.4rem}
+.stage{background:var(--well);border:1px solid #22201d;border-radius:8px;padding:1rem;
+ display:flex;justify-content:center}
+.wnote{color:var(--dim);font-size:.85rem;margin-top:1rem}
+.wnote b{color:var(--steel);font-weight:600}
+.igrid{display:grid;grid-template-columns:11rem 1fr;gap:.35rem .9rem;margin-top:1rem;
+ font-size:.82rem}
+.ik{color:var(--steel)}
+.iv{color:var(--dim)}
+</style>
+</head>
+<body>
+<div class="wrap">
+ <p class="eyebrow">Waste Time Beautifully · C1</p>
+ <h1>Cosmos — numbers in orbit</h1>
+ <p class="sub">John Maeda for Shiseido, 1995 (Cal0.class) — rebuilt as a web component. Standalone copy, no build step.</p>
+
+ <div class="card">
+ <div class="stage"><maeda-cosmos id="piece"></maeda-cosmos></div>
+ <p class="wnote"><b>Controls:</b> hover to reshape every orbit — the axes track 1.5× the
+ cursor's distance from each orbit's center; press and drag to pull a new loop out of the
+ system; the mark at bottom-left clears them and replays the fly-in credit. Click the
+ canvas first, then <b>ESC</b> toggles strip-move mode (the ground goes orange and the
+ clock strip comes free).</p>
+ <p class="wnote">The days of the month ride a rotated ellipse, today in red on the sweep
+ hand, with a seconds odometer scrolling at the right edge. The month as a gravitational
+ system, days as bodies in orbit, the user as a hand that perturbs the heavens. Time is
+ periodic, not linear.</p>
+ </div>
+
+ <div class="card">
+ <p class="eyebrow">Spec</p>
+ <div class="igrid">
+ <span class="ik">original</span><span class="iv">Cal0.class + UniverseCal, orbit, eint, efloatsin (Java 1.1)</span>
+ <span class="ik">dimensions</span><span class="iv">400×300, black ground, Helvetica 10</span>
+ <span class="ik">collection</span><span class="iv">SFMOMA 99.550</span>
+ <span class="ik">revolution</span><span class="iv">7,500 ms per sweep, phase = wall clock mod cycle — every orbit in lockstep forever; orientation wobbles ±10° on a 15,000 ms sine</span>
+ <span class="ik">interaction</span><span class="iv">every mouse move (not just drag) restyles all orbits; press spawns a zero-axis orbit that inflates as the cursor pulls away</span>
+ <span class="ik">period quirks</span><span class="iv">a cursor-driven rotation angle is computed and stored but never drawn — a dead store in the 1995 bytecode; Mac Java Date bug detected and adjusted</span>
+ <span class="ik">credit</span><span class="iv">chars fly in at 800+80i ms from random scatter, hold 1.5 s, vanish; logo click replays</span>
+ <span class="ik">tests</span><span class="iv">38 (Vitest + fast-check)</span>
+ </div>
+ </div>
+</div>
+
+<script>
+(() => {
+ // src/pieces/cosmos/model.js
+ var TAU = Math.PI * 2;
+ var CREDIT = "designed by john maeda";
+ var FAITHFUL_1995 = {
+ faceW: 400,
+ // applet tag WIDTH=400 HEIGHT=300
+ faceH: 300,
+ cycleMs: 7500,
+ // one revolution, wall-clock modulo
+ wobbleAmp: 10 * Math.PI / 180,
+ // efloatsin(0, 10°, 15000)
+ wobblePeriodMs: 15e3,
+ stretch: 1.5,
+ // axes = 1.5× cursor distance from center
+ initialAxes: [300, 100],
+ // the startup orbit at panel center
+ fontSize: 10,
+ // applet param default; Helvetica plain
+ stripWidth: 20,
+ // stringWidth("59")+6 at 10 px, rounded
+ clockReach: 120,
+ // readout + weekday row extent left of the strip
+ logo: { dx: 8, dy: 8, w: 70, h: 16 }
+ // shiseido.gif box, bottom-left
+ };
+ function sweepPhase(cfg, nowMs) {
+ return nowMs % cfg.cycleMs / cfg.cycleMs * TAU;
+ }
+ function wobbleAngle(cfg, elapsedMs) {
+ return cfg.wobbleAmp * Math.sin(elapsedMs * TAU / cfg.wobblePeriodMs);
+ }
+ function orbitPoint(o, theta, alpha) {
+ const ct = Math.cos(theta);
+ const st = Math.sin(theta);
+ const ca = Math.cos(alpha);
+ const sa = Math.sin(alpha);
+ return {
+ x: o.lmaj * ct * ca - o.lmin * st * sa + o.h,
+ y: o.lmaj * ct * sa + o.lmin * st * ca + o.k
+ };
+ }
+ function dayTheta(day, today, ndays, phase) {
+ return phase + (day - today) * TAU / ndays;
+ }
+ function makeOrbit(x, y, lmaj = 0, lmin = 0) {
+ return { h: x + 0.5, k: y + 0.5, lmaj, lmin, wobbleT: 0 };
+ }
+ function stretchOrbit(cfg, o, mx, my) {
+ return { ...o, lmaj: (mx - o.h) * cfg.stretch, lmin: (my - o.k) * cfg.stretch };
+ }
+ function creditDurations(len) {
+ const chars = [];
+ for (let i = 0; i < len; i++) chars.push(800 + 80 * i);
+ return { chars, sentinel: 800 + 80 * len + 1500 };
+ }
+ function easeLinear(t, a, b, dur) {
+ if (t <= 0) return a;
+ if (t >= dur) return b;
+ return a + (b - a) * t / dur;
+ }
+ function creditDone(len, t) {
+ return t >= creditDurations(len).sentinel;
+ }
+ function stripOffset(cfg, sec, msWithin) {
+ const ld = cfg.fontSize + 1;
+ const siddy = 3 + cfg.fontSize;
+ return -ld * sec - msWithin * ld / 1e3 + siddy - ld;
+ }
+ function logoHit(cfg, x, y) {
+ const { dx, dy, w, h } = cfg.logo;
+ return x < dx + w && y > cfg.faceH - h - dy;
+ }
+ function clockHit(cfg, strip, x, y) {
+ const ld = cfg.fontSize + 1;
+ const left = cfg.faceW - strip.sidex - cfg.clockReach;
+ const right = cfg.faceW - strip.sidex + cfg.stripWidth;
+ return x > left && x < right && y < strip.siddy && y > strip.siddy - ld;
+ }
+
+ // src/pieces/cosmos/step.js
+ function scatterStarts(cfg, rng) {
+ const starts = [];
+ for (let i = 0; i < CREDIT.length; i++) {
+ starts.push({
+ x: Math.floor(rng() * cfg.faceW) * (rng() < 0.5 ? 1 : -1) + cfg.faceW / 2,
+ y: Math.floor(rng() * cfg.faceH) * (rng() < 0.5 ? 1 : -1) + cfg.faceH / 2
+ });
+ }
+ return starts;
+ }
+ function initState(cfg, rng = Math.random) {
+ return {
+ orbits: [makeOrbit(cfg.faceW / 2, cfg.faceH / 2, cfg.initialAxes[0], cfg.initialAxes[1])],
+ credit: { t: 0, starts: scatterStarts(cfg, rng) },
+ strip: { sidex: cfg.stripWidth, siddy: 3 + cfg.fontSize },
+ killPending: false,
+ movable: false,
+ draggingStrip: false,
+ prevHeld: false,
+ prevCursor: null,
+ rng
+ };
+ }
+ function toggleMovable(state) {
+ state.movable = !state.movable;
+ return state;
+ }
+ function step(cfg, state, dtMs, input) {
+ const s = { ...state, strip: { ...state.strip }, credit: { ...state.credit } };
+ const { x, y, held } = input;
+ s.credit.t += dtMs;
+ s.orbits = s.orbits.map((o) => ({ ...o, wobbleT: o.wobbleT + dtMs }));
+ if (x == null) {
+ s.prevHeld = held;
+ return s;
+ }
+ if (held && !s.prevHeld) {
+ if (logoHit(cfg, x, y)) {
+ s.killPending = true;
+ s.credit = { t: 0, starts: scatterStarts(cfg, s.rng) };
+ } else if (clockHit(cfg, s.strip, x, y) || s.movable) {
+ s.draggingStrip = true;
+ } else {
+ s.orbits = [...s.orbits, makeOrbit(x, y)];
+ }
+ }
+ if (held && s.draggingStrip && s.prevCursor) {
+ s.strip.sidex -= x - s.prevCursor.x;
+ s.strip.siddy += y - s.prevCursor.y;
+ }
+ s.orbits = s.orbits.map((o) => stretchOrbit(cfg, o, x, y));
+ if (!held && s.prevHeld) {
+ if (s.killPending) {
+ s.orbits = [s.orbits[0]];
+ s.strip = { sidex: cfg.stripWidth, siddy: 3 + cfg.fontSize };
+ }
+ s.killPending = false;
+ s.draggingStrip = false;
+ }
+ s.prevHeld = held;
+ s.prevCursor = { x, y };
+ return s;
+ }
+
+ // src/lib/calendar.js
+ function isLeapYear(y) {
+ return y % 4 === 0 && (y % 100 !== 0 || y % 400 === 0);
+ }
+ var MDAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
+ function daysInMonth(y, m) {
+ return m === 2 && isLeapYear(y) ? 29 : MDAYS[m - 1];
+ }
+
+ // src/pieces/cosmos/render.js
+ var FACE = { w: 400, h: 300 };
+ var MONTHS = ["jan ", "feb ", "mar ", "apr ", "may ", "jun ", "jul ", "aug ", "sep ", "oct ", "nov ", "dec "];
+ var WEEKDAYS = ["S ", "M ", "T ", "W ", "T ", "F ", "S "];
+ function font(cfg) {
+ return `${cfg.fontSize}px Helvetica, Arial, sans-serif`;
+ }
+ function dateParts(nowMs) {
+ const d = new Date(nowMs);
+ return {
+ year: d.getFullYear(),
+ month: d.getMonth() + 1,
+ // 1-12
+ day: d.getDate(),
+ weekday: d.getDay(),
+ hours: d.getHours(),
+ minutes: d.getMinutes(),
+ seconds: d.getSeconds(),
+ msWithin: nowMs % 1e3
+ };
+ }
+ function drawOrbit(ctx, cfg, o, dp, phase, dim) {
+ const ndays = daysInMonth(dp.year, dp.month);
+ const alpha = wobbleAngle(cfg, o.wobbleT);
+ const daystr = `${dp.day} ${dp.year}`;
+ const monthstr = MONTHS[dp.month - 1];
+ for (let d = 1; d <= ndays; d++) {
+ const p = orbitPoint(o, dayTheta(d, dp.day, ndays, phase), alpha);
+ if (d === dp.day) {
+ ctx.fillStyle = "#f00";
+ ctx.fillText(daystr, p.x, p.y);
+ ctx.fillText(monthstr, p.x - ctx.measureText(monthstr).width, p.y);
+ } else {
+ ctx.fillStyle = dim ? "#808080" : "#fff";
+ ctx.fillText(String(d), p.x, p.y);
+ }
+ }
+ }
+ function drawStrip(ctx, cfg, state, dp) {
+ const ld = cfg.fontSize + 1;
+ const { sidex, siddy } = state.strip;
+ const x = FACE.w - sidex;
+ const tileH = ld * 60;
+ const off = stripOffset(cfg, dp.seconds, dp.msWithin) + (siddy - (3 + cfg.fontSize));
+ ctx.save();
+ ctx.beginPath();
+ ctx.rect(x, 0, cfg.stripWidth, FACE.h);
+ ctx.clip();
+ ctx.fillStyle = "#404040";
+ ctx.fillRect(x, 0, cfg.stripWidth, FACE.h);
+ ctx.fillStyle = "#808080";
+ for (const tile of [-1, 0, 1]) {
+ for (let i = 0; i < 60; i++) {
+ const y = off + tile * tileH + i * ld + ld;
+ if (y > -ld && y < FACE.h + ld) {
+ ctx.fillText(String(i).padStart(2, "0"), x + 3, y);
+ }
+ }
+ }
+ ctx.restore();
+ ctx.fillStyle = "#f00";
+ ctx.fillText(String(dp.seconds).padStart(2, "0"), x + 3, siddy);
+ const h12 = dp.hours % 12 === 0 ? 12 : dp.hours % 12;
+ const clock = `${h12}${dp.minutes < 10 ? ":0" : ":"}${dp.minutes}:`;
+ const cx = x + 3 - ctx.measureText(clock).width;
+ ctx.fillText(clock, cx, siddy);
+ let wx = cx - ctx.measureText("S M T W T F S ").width;
+ for (let i = 0; i < 7; i++) {
+ ctx.fillStyle = i === dp.weekday ? "#f00" : "#808080";
+ ctx.fillText(WEEKDAYS[i], wx, siddy);
+ wx += ctx.measureText(WEEKDAYS[i]).width;
+ }
+ }
+ function drawCredit(ctx, cfg, state) {
+ const { t, starts } = state.credit;
+ if (creditDone(CREDIT.length, t)) return;
+ const durs = creditDurations(CREDIT.length);
+ let fx = (FACE.w - ctx.measureText(CREDIT).width) / 2 + 1;
+ const fy = FACE.h / 2 + cfg.fontSize / 2;
+ ctx.fillStyle = "#fff";
+ for (let i = 0; i < CREDIT.length; i++) {
+ const ch = CREDIT[i];
+ const x = easeLinear(t, starts[i].x, fx, durs.chars[i]);
+ const y = easeLinear(t, starts[i].y, fy, durs.chars[i]);
+ ctx.fillText(ch, x, y);
+ fx += ctx.measureText(ch).width;
+ }
+ }
+ function drawLogo(ctx, cfg) {
+ const { dx, dy, h } = cfg.logo;
+ ctx.fillStyle = "#808080";
+ ctx.fillText("shiseido", dx, FACE.h - dy - h / 2 + cfg.fontSize / 2);
+ }
+ function draw(ctx, cfg, state, nowMs) {
+ const dp = dateParts(nowMs);
+ ctx.fillStyle = state.movable ? "#e08000" : "#000";
+ ctx.fillRect(0, 0, FACE.w, FACE.h);
+ ctx.font = font(cfg);
+ ctx.textBaseline = "alphabetic";
+ drawStrip(ctx, cfg, state, dp);
+ const phase = sweepPhase(cfg, nowMs);
+ state.orbits.forEach((o, i) => {
+ drawOrbit(ctx, cfg, o, dp, phase, state.killPending && i > 0);
+ });
+ drawLogo(ctx, cfg);
+ drawCredit(ctx, cfg, state);
+ ctx.strokeStyle = "#fff";
+ ctx.lineWidth = 1;
+ ctx.strokeRect(0.5, 0.5, FACE.w - 1, FACE.h - 1);
+ }
+
+ // src/lib/loop.js
+ var STEP_MS = 30;
+ function startLoop({ stepFn, renderFn, stepMs = STEP_MS }) {
+ let acc = 0;
+ let last = null;
+ let raf = null;
+ let running = true;
+ function frame(ts) {
+ if (!running) return;
+ if (last === null) last = ts;
+ acc += Math.min(ts - last, 250);
+ last = ts;
+ while (acc >= stepMs) {
+ stepFn(stepMs);
+ acc -= stepMs;
+ }
+ renderFn();
+ raf = requestAnimationFrame(frame);
+ }
+ raf = requestAnimationFrame(frame);
+ return () => {
+ running = false;
+ if (raf) cancelAnimationFrame(raf);
+ };
+ }
+
+ // src/pieces/cosmos/index.js
+ var MaedaCosmos = class extends HTMLElement {
+ connectedCallback() {
+ const canvas = document.createElement("canvas");
+ canvas.width = FACE.w;
+ canvas.height = FACE.h;
+ canvas.style.cssText = "display:block;width:100%;max-width:400px;cursor:crosshair;touch-action:none";
+ canvas.tabIndex = 0;
+ this.appendChild(canvas);
+ const ctx = canvas.getContext("2d");
+ const frozen = this.getAttribute("frozen-now");
+ const nowMs = () => frozen ? Number(frozen) * 1e3 : Date.now();
+ const cfg = FAITHFUL_1995;
+ let state = initState(cfg);
+ const input = { x: null, y: null, held: false };
+ const toFace = (ev) => {
+ const r = canvas.getBoundingClientRect();
+ return {
+ x: (ev.clientX - r.left) * (FACE.w / r.width),
+ y: (ev.clientY - r.top) * (FACE.h / r.height)
+ };
+ };
+ canvas.addEventListener("pointermove", (ev) => {
+ Object.assign(input, toFace(ev));
+ });
+ canvas.addEventListener("pointerdown", (ev) => {
+ canvas.setPointerCapture(ev.pointerId);
+ canvas.focus();
+ Object.assign(input, toFace(ev));
+ input.held = true;
+ });
+ const release = () => {
+ input.held = false;
+ };
+ canvas.addEventListener("pointerup", release);
+ canvas.addEventListener("pointercancel", release);
+ canvas.addEventListener("keydown", (ev) => {
+ if (ev.key === "Escape") toggleMovable(state);
+ });
+ this._stop = startLoop({
+ stepFn: (dtMs) => {
+ state = step(cfg, state, dtMs, input);
+ },
+ renderFn: () => draw(ctx, cfg, state, nowMs())
+ });
+ }
+ disconnectedCallback() {
+ if (this._stop) this._stop();
+ }
+ };
+ customElements.define("maeda-cosmos", MaedaCosmos);
+})();
+
+</script>
+</body>
+</html>
diff --git a/working/clock-display-references/2026-07-30-maeda-line-notes.org b/working/clock-display-references/2026-07-30-maeda-line-notes.org
new file mode 100644
index 0000000..ba50a80
--- /dev/null
+++ b/working/clock-display-references/2026-07-30-maeda-line-notes.org
@@ -0,0 +1,11 @@
+#+TITLE: Copy of the Line applet (C2, 1997) from the Maeda x Shiseido
+#+SOURCE: from website
+#+DATE: 2026-07-30 18:38:41 -0500
+
+Copy of the Line applet (C2, 1997) from the Maeda x Shiseido tribute — the diagonal timeline piece, 'zoom through time'. Delivered as 2026-07-30-1838-from-website-2026-07-30-maeda-line-standalone.html in this inbox.
+
+It is a single self-contained file: the <maeda-line> web component and its model/step/render modules are esbuild-bundled inline as an IIFE, so it opens straight from file:// with no server, no build step, and no network. Verified rendering in Chrome from file:// — canvas 460x400, zero console errors, live readout wired.
+
+Controls: move the pointer along the diagonal to aim, press and hold to zoom continuously into that moment, release to float back out. Full zoom is exactly 60 seconds and the release is symmetric.
+
+Source of truth stays ~/code/maeda-tribute (src/pieces/line/); this copy is a snapshot, so regenerate rather than edit it if the piece changes. Sent for reference — no action needed unless you want it wired into the panel-widget gallery.
diff --git a/working/clock-display-references/2026-07-30-maeda-line-standalone.html b/working/clock-display-references/2026-07-30-maeda-line-standalone.html
new file mode 100644
index 0000000..783939c
--- /dev/null
+++ b/working/clock-display-references/2026-07-30-maeda-line-standalone.html
@@ -0,0 +1,457 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Line (C2, 1997) — Maeda × Shiseido tribute</title>
+<style>
+:root{
+ --ground:#151311;
+ --panel:#100f0f;
+ --well:#0a0c0d;
+ --raise:#1a1917;
+ --silver:#bfc4d0;
+ --cream:#f3e7c5;
+ --steel:#969385;
+ --dim:#7c838a;
+ --wash:#2c2f32;
+ --gold:#e2a038;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html{background:var(--ground);font-size:112%}
+body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 4rem;line-height:1.45;
+ background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)}
+.wrap{max-width:820px;margin:0 auto}
+.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase}
+h1{font-size:1.5rem;color:var(--cream);font-weight:600;margin:.35rem 0 .2rem}
+.sub{color:var(--dim);font-size:.9rem;margin-bottom:1.6rem}
+.card{background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320;
+ border-radius:12px;padding:1.2rem;margin-bottom:1.4rem}
+.stage{background:var(--well);border:1px solid #22201d;border-radius:8px;padding:1rem;
+ display:flex;justify-content:center}
+.readout{margin-top:.8rem;color:var(--gold);font-size:.82rem;letter-spacing:.06em}
+.wnote{color:var(--dim);font-size:.85rem;margin-top:1rem}
+.wnote b{color:var(--steel);font-weight:600}
+.igrid{display:grid;grid-template-columns:11rem 1fr;gap:.35rem .9rem;margin-top:1rem;
+ font-size:.82rem}
+.ik{color:var(--steel)}
+.iv{color:var(--dim)}
+</style>
+</head>
+<body>
+<div class="wrap">
+ <p class="eyebrow">Waste Time Beautifully · C2</p>
+ <h1>Line — zoom through time</h1>
+ <p class="sub">John Maeda for Shiseido, 1997 (cal1.class) — rebuilt as a web component. Standalone copy, no build step.</p>
+
+ <div class="card">
+ <div class="stage"><maeda-line id="piece"></maeda-line></div>
+ <div class="readout" id="readout">scale — · counter —</div>
+ <p class="wnote"><b>Controls:</b> move the pointer along the diagonal to aim; press and
+ hold to zoom continuously into that moment; release and you float back out. Full zoom
+ is exactly 60 seconds, and the release is symmetric.</p>
+ <p class="wnote">Time as a single line — <b>diagonal</b>, bottom-left to top-right, the
+ detail every written description of this applet gets wrong. Maeda's note: it reflects
+ the relativity and comparability of time intervals. The zoom is the content.</p>
+ </div>
+
+ <div class="card">
+ <p class="eyebrow">Spec</p>
+ <div class="igrid">
+ <span class="ik">original</span><span class="iv">cal1.class, 12,339 bytes, single class, self-contained (Java 1.1)</span>
+ <span class="ik">dimensions</span><span class="iv">460×400, black ground, Helvetica throughout</span>
+ <span class="ik">collection</span><span class="iv">SFMOMA 99.554</span>
+ <span class="ik">zoom plateaus</span><span class="iv">72px per day / hour / minute / second — scn 1 → 72 → 1,728 → 103,680 → 6,220,800 px/day</span>
+ <span class="ik">easing</span><span class="iv">counter at 20 increments/sec over segments [150, 250, 350, 450]</span>
+ <span class="ik">labels</span><span class="iv">font = min(72, unit spacing); text under 5px degrades to a line; gray ramps 128→255 as a unit matures; the current unit is always red</span>
+ <span class="ik">period quirks</span><span class="iv">Mac JVM patch capped zoom at hour level; an unused HAPPY NEW YEAR string ships in the bytecode</span>
+ <span class="ik">tests</span><span class="iv">33 (Vitest + fast-check)</span>
+ </div>
+ </div>
+</div>
+
+<script>
+(() => {
+ // src/lib/calendar.js
+ function epochDayFromCivil(y, m, d) {
+ const yy = y - (m <= 2 ? 1 : 0);
+ const era = Math.floor(yy / 400);
+ const yoe = yy - era * 400;
+ const doy = Math.floor((153 * (m + (m > 2 ? -3 : 9)) + 2) / 5) + d - 1;
+ const doe = yoe * 365 + Math.floor(yoe / 4) - Math.floor(yoe / 100) + doy;
+ return era * 146097 + doe - 719468;
+ }
+ function civilFromEpochDay(ed) {
+ const z = ed + 719468;
+ const era = Math.floor(z / 146097);
+ const doe = z - era * 146097;
+ const yoe = Math.floor(
+ (doe - Math.floor(doe / 1460) + Math.floor(doe / 36524) - Math.floor(doe / 146096)) / 365
+ );
+ const y = yoe + era * 400;
+ const doy = doe - (365 * yoe + Math.floor(yoe / 4) - Math.floor(yoe / 100));
+ const mp = Math.floor((5 * doy + 2) / 153);
+ const d = doy - Math.floor((153 * mp + 2) / 5) + 1;
+ const m = mp + (mp < 10 ? 3 : -9);
+ return { y: y + (m <= 2 ? 1 : 0), m, d };
+ }
+
+ // src/pieces/line/model.js
+ var DAY = 86400;
+ var FAITHFUL_1997 = {
+ stops: [1, 72, 1728, 103680, 6220800],
+ segments: [150, 250, 350, 450],
+ incsPerSec: 20
+ };
+ function totalIncrements(cfg) {
+ return cfg.segments.reduce((a, b) => a + b, 0);
+ }
+ function scaleForCounter(cfg, counter) {
+ const total = totalIncrements(cfg);
+ const c = Math.max(0, Math.min(total, counter));
+ let acc = 0;
+ for (let i = 0; i < cfg.segments.length; i++) {
+ const seg = cfg.segments[i];
+ if (c <= acc + seg) {
+ const f = (c - acc) / seg;
+ return cfg.stops[i] + f * (cfg.stops[i + 1] - cfg.stops[i]);
+ }
+ acc += seg;
+ }
+ return cfg.stops[cfg.stops.length - 1];
+ }
+ function advanceCounter(cfg, counter, dtMs, held) {
+ const d = cfg.incsPerSec * dtMs / 1e3;
+ const next = held ? counter + d : counter - d;
+ return Math.max(0, Math.min(totalIncrements(cfg), next));
+ }
+ function pxOfTime(view, t) {
+ return (t - view.focus) * view.scale / DAY + view.anchorPx;
+ }
+ function timeOfPx(view, px) {
+ return (px - view.anchorPx) * DAY / view.scale + view.focus;
+ }
+ var MAX_FONT = 72;
+ var MIN_TEXT_PX = 5;
+ function fontSizeFor(spacingPx) {
+ return Math.min(MAX_FONT, spacingPx);
+ }
+ function textVisible(fontSize) {
+ return fontSize >= MIN_TEXT_PX;
+ }
+ function grayFor(fontSize) {
+ return Math.floor(Math.min(MAX_FONT, fontSize) * 127 / MAX_FONT) + 128;
+ }
+ var FIXED_UNITS = { second: 1, minute: 60, hour: 3600, day: DAY };
+ function* calendarTicks(unit, t0, t1) {
+ let { y, m } = civilFromEpochDay(Math.floor(t0 / DAY));
+ const stride = unit === "decade" ? 10 : unit === "century" ? 100 : 1;
+ if (unit === "month") {
+ for (; ; ) {
+ const t = epochDayFromCivil(y, m, 1) * DAY;
+ if (t >= t0) break;
+ m++;
+ if (m > 12) m = 1, y++;
+ }
+ for (; ; ) {
+ const t = epochDayFromCivil(y, m, 1) * DAY;
+ if (t > t1) return;
+ yield t;
+ m++;
+ if (m > 12) m = 1, y++;
+ }
+ } else {
+ let yy = Math.ceil(y / stride) * stride;
+ if (epochDayFromCivil(yy, 1, 1) * DAY < t0) yy += stride;
+ while (epochDayFromCivil(yy - stride, 1, 1) * DAY >= t0) yy -= stride;
+ for (; ; ) {
+ const t = epochDayFromCivil(yy, 1, 1) * DAY;
+ if (t > t1) return;
+ if (t >= t0) yield t;
+ yy += stride;
+ }
+ }
+ }
+ function ticksInRange(unit, t0, t1) {
+ if (unit in FIXED_UNITS) {
+ const w = FIXED_UNITS[unit];
+ const out = [];
+ for (let t = Math.ceil(t0 / w) * w; t <= t1; t += w) out.push(t);
+ return out;
+ }
+ return [...calendarTicks(unit, t0, t1)];
+ }
+ var MONTHS = [
+ "JANUARY",
+ "FEBRUARY",
+ "MARCH",
+ "APRIL",
+ "MAY",
+ "JUNE",
+ "JULY",
+ "AUGUST",
+ "SEPTEMBER",
+ "OCTOBER",
+ "NOVEMBER",
+ "DECEMBER"
+ ];
+ function formatMonth(m) {
+ return MONTHS[m - 1];
+ }
+ function formatDay(m, d) {
+ return `${m}/${d}`;
+ }
+ function formatHour(h) {
+ const twelve = h % 12 === 0 ? 12 : h % 12;
+ return { text: ` ${twelve}`, suffix: h >= 12 ? "pm" : "am" };
+ }
+ function formatMinute(mm) {
+ return `:${String(mm).padStart(2, "0")}`;
+ }
+
+ // src/pieces/line/step.js
+ function initState(cfg, range, lineLenPx) {
+ return {
+ counter: 0,
+ scale: scaleForCounter(cfg, 0),
+ focus: 0,
+ // epoch seconds under the anchor pixel
+ anchorPx: 0,
+ // pixel along the line where focus projects
+ range,
+ // {tMin, tMax} or null for the unbounded twist
+ lineLenPx
+ };
+ }
+ function step(cfg, s, dtMs, input) {
+ const cursorPx = Math.max(0, Math.min(s.lineLenPx, input.cursorPx));
+ let { focus, anchorPx } = s;
+ if (input.held || s.counter > 0) {
+ focus = timeOfPx({ focus: s.focus, scale: s.scale, anchorPx: s.anchorPx }, cursorPx);
+ anchorPx = cursorPx;
+ }
+ const counter = advanceCounter(cfg, s.counter, dtMs, input.held);
+ const scale = scaleForCounter(cfg, counter);
+ if (!input.held && counter < cfg.segments[0]) {
+ const target = cursorPx * DAY;
+ focus += (target - focus) / (counter + 1);
+ anchorPx = cursorPx;
+ }
+ if (s.range) {
+ focus = Math.max(s.range.tMin, Math.min(s.range.tMax, focus));
+ }
+ return { ...s, counter, scale, focus, anchorPx };
+ }
+
+ // src/pieces/line/render.js
+ var LEVELS = [
+ { unit: "month", secs: 30 * DAY },
+ { unit: "day", secs: DAY },
+ { unit: "hour", secs: 3600 },
+ { unit: "minute", secs: 60 },
+ { unit: "second", secs: 1 }
+ ];
+ var FACE = { w: 460, h: 400 };
+ function lineOrigin(state) {
+ const margin = (FACE.h - state.lineLenPx) / 2;
+ return { mx: FACE.w - FACE.h + margin, my: margin };
+ }
+ function labelFor(unit, t) {
+ const dayIdx = Math.floor(t / DAY);
+ const civ = civilFromEpochDay(dayIdx);
+ const rem = t - dayIdx * DAY;
+ const hh = Math.floor(rem / 3600);
+ const mm = Math.floor(rem % 3600 / 60);
+ const ss = Math.floor(rem % 60);
+ switch (unit) {
+ case "month":
+ return formatMonth(civ.m);
+ case "day":
+ return formatDay(civ.m, civ.d);
+ case "hour": {
+ const h = formatHour(hh);
+ return h.text + h.suffix;
+ }
+ case "minute":
+ return formatMinute(mm);
+ case "second":
+ return formatMinute(ss);
+ default:
+ return "";
+ }
+ }
+ function sameTick(unit, t, now) {
+ const w = { second: 1, minute: 60, hour: 3600, day: DAY }[unit];
+ if (w) return Math.floor(t / w) === Math.floor(now / w);
+ const a = civilFromEpochDay(Math.floor(t / DAY));
+ const b = civilFromEpochDay(Math.floor(now / DAY));
+ return a.y === b.y && a.m === b.m;
+ }
+ function draw(ctx, state, nowSec) {
+ const { mx, my } = lineOrigin(state);
+ ctx.fillStyle = "#000";
+ ctx.fillRect(0, 0, FACE.w, FACE.h);
+ const view = { focus: state.focus, scale: state.scale, anchorPx: state.anchorPx };
+ const tA = Math.max(
+ state.range ? state.range.tMin : -Infinity,
+ state.focus - state.anchorPx * DAY / state.scale
+ );
+ const tB = Math.min(
+ state.range ? state.range.tMax : Infinity,
+ state.focus + (state.lineLenPx - state.anchorPx) * DAY / state.scale
+ );
+ ctx.strokeStyle = "#666";
+ ctx.beginPath();
+ ctx.moveTo(mx, FACE.h - my);
+ ctx.lineTo(mx + state.lineLenPx, FACE.h - my - state.lineLenPx);
+ ctx.stroke();
+ const colWidth = {};
+ for (let i = LEVELS.length - 1; i >= 0; i--) {
+ const { unit, secs } = LEVELS[i];
+ const spacing = secs * state.scale / DAY;
+ if (spacing < 2 && unit !== "month") {
+ colWidth[unit] = 0;
+ continue;
+ }
+ const size = Math.floor(fontSizeFor(spacing));
+ colWidth[unit] = textVisible(size) ? size * 2.2 : 0;
+ }
+ for (let i = LEVELS.length - 1; i >= 0; i--) {
+ const { unit, secs } = LEVELS[i];
+ const spacing = secs * state.scale / DAY;
+ if (spacing < 2 && unit !== "month") continue;
+ const size = unit === "month" ? Math.floor(Math.min(72, 11 + state.scale)) : Math.floor(fontSizeFor(spacing));
+ const gray = unit === "month" ? 255 : grayFor(size);
+ let offset = 0;
+ for (let j = LEVELS.length - 1; j > i; j--) offset += colWidth[LEVELS[j].unit];
+ ctx.font = `${Math.max(size, 1)}px Helvetica, Arial, sans-serif`;
+ for (const t of ticksInRange(unit, tA, tB)) {
+ const n = pxOfTime(view, t);
+ if (n < 0 || n > state.lineLenPx) continue;
+ const x = mx + n;
+ const y = FACE.h - my - n;
+ const isNow = sameTick(unit, t, nowSec);
+ ctx.strokeStyle = ctx.fillStyle = isNow ? "#f00" : `rgb(${gray},${gray},${gray})`;
+ ctx.beginPath();
+ ctx.moveTo(x, y);
+ ctx.lineTo(x + 3, y);
+ ctx.stroke();
+ const text = labelFor(unit, t);
+ if (textVisible(size)) {
+ const w = ctx.measureText(text).width;
+ ctx.fillText(text, x - w - offset, y);
+ } else {
+ ctx.beginPath();
+ ctx.moveTo(x - 4 - offset, y);
+ ctx.lineTo(x - offset, y);
+ ctx.stroke();
+ }
+ }
+ }
+ ctx.fillStyle = "#ccc";
+ ctx.font = "15px Helvetica, Arial, sans-serif";
+ const label = `${Math.round(state.scale)}X${state.counter >= 1200 ? " (MAX)" : ""}`;
+ ctx.fillText(label, 3, 16);
+ }
+
+ // src/lib/loop.js
+ var STEP_MS = 30;
+ function startLoop({ stepFn, renderFn, stepMs = STEP_MS }) {
+ let acc = 0;
+ let last = null;
+ let raf = null;
+ let running = true;
+ function frame(ts) {
+ if (!running) return;
+ if (last === null) last = ts;
+ acc += Math.min(ts - last, 250);
+ last = ts;
+ while (acc >= stepMs) {
+ stepFn(stepMs);
+ acc -= stepMs;
+ }
+ renderFn();
+ raf = requestAnimationFrame(frame);
+ }
+ raf = requestAnimationFrame(frame);
+ return () => {
+ running = false;
+ if (raf) cancelAnimationFrame(raf);
+ };
+ }
+
+ // src/pieces/line/index.js
+ var MaedaLine = class extends HTMLElement {
+ connectedCallback() {
+ const canvas = document.createElement("canvas");
+ canvas.width = FACE.w;
+ canvas.height = FACE.h;
+ canvas.style.cssText = "display:block;width:100%;max-width:460px;cursor:crosshair;touch-action:none";
+ this.appendChild(canvas);
+ const ctx = canvas.getContext("2d");
+ const frozen = this.getAttribute("frozen-now");
+ const nowSec = () => frozen ? Number(frozen) : Date.now() / 1e3;
+ const { y } = (() => {
+ const d = new Date(nowSec() * 1e3);
+ return { y: d.getUTCFullYear() };
+ })();
+ const t0 = epochDayFromCivil(y, 1, 1) * DAY;
+ const t1 = epochDayFromCivil(y + 1, 1, 1) * DAY;
+ const lineLen = Math.round((t1 - t0) / DAY);
+ const cfg = FAITHFUL_1997;
+ let state = initState(cfg, { tMin: 0, tMax: t1 - t0 }, lineLen);
+ state.focus = nowSec() - t0;
+ state.anchorPx = state.focus / DAY;
+ const input = { cursorPx: state.anchorPx, held: false };
+ const toLinePx = (ev) => {
+ const r = canvas.getBoundingClientRect();
+ const sx = FACE.w / r.width;
+ const x = (ev.clientX - r.left) * sx;
+ const yy = (ev.clientY - r.top) * (FACE.h / r.height);
+ const margin = (FACE.h - lineLen) / 2;
+ const mx = FACE.w - FACE.h + margin;
+ return (x - mx + (FACE.h - margin - yy)) / 2;
+ };
+ canvas.addEventListener("pointermove", (ev) => {
+ input.cursorPx = toLinePx(ev);
+ });
+ canvas.addEventListener("pointerdown", (ev) => {
+ canvas.setPointerCapture(ev.pointerId);
+ input.cursorPx = toLinePx(ev);
+ input.held = true;
+ });
+ const release = () => {
+ input.held = false;
+ };
+ canvas.addEventListener("pointerup", release);
+ canvas.addEventListener("pointercancel", release);
+ this._stop = startLoop({
+ stepFn: (dtMs) => {
+ state = step(cfg, state, dtMs, input);
+ },
+ renderFn: () => {
+ draw(ctx, state, nowSec() - t0);
+ this.dispatchEvent(new CustomEvent("readout", {
+ detail: { scale: state.scale, counter: state.counter }
+ }));
+ }
+ });
+ }
+ disconnectedCallback() {
+ if (this._stop) this._stop();
+ }
+ };
+ customElements.define("maeda-line", MaedaLine);
+})();
+
+</script>
+<script>
+document.getElementById("piece").addEventListener("readout", (e) => {
+ const { scale, counter } = e.detail;
+ document.getElementById("readout").textContent =
+ "scale " + Number(scale).toFixed(3) + " · counter " + Math.round(counter);
+});
+</script>
+</body>
+</html>
diff --git a/working/velox-reinstall/2026-08-14-emacsd-velox-reinstall-gaps-1.org b/working/velox-reinstall/2026-08-14-emacsd-velox-reinstall-gaps-1.org
new file mode 100644
index 0000000..cf0d723
--- /dev/null
+++ b/working/velox-reinstall/2026-08-14-emacsd-velox-reinstall-gaps-1.org
@@ -0,0 +1,137 @@
+#+TITLE: What the velox reinstall left behind — four gaps the install could close
+#+AUTHOR: Craig Jennings
+
+* Heads-up: this was found from a .emacs.d session
+
+I opened a .emacs.d session on velox this morning, two days after the fresh
+Arch install, and the first thing it did was fail: there was no =.ai/=
+directory to read. Chasing that turned up four separate things the reinstall
+did not restore. Three I repaired from the session; one needs me at my phone.
+
+None of this is a .emacs.d bug. They are all install-side gaps, which is why
+they are landing in your inbox. Machine is velox; ratio was the reference for
+every comparison below.
+
+* Gap 1 — the gitignored tooling layer does not survive a reinstall
+
+=~/.emacs.d= was re-cloned on 2026-08-13. Git brought back every tracked file
+and none of the agent tooling, because =.gitignore= deliberately excludes it:
+=.ai/=, =.claude/=, =CLAUDE.md=, =todo.org=, and =inbox/= were all simply
+absent. That is the correct ignore policy — this repo relays to a public
+mirror — but it means a reinstall silently drops the entire working state of
+every gitignore-mode project.
+
+The damage on velox was total rather than partial: 374 files, 4.5 MB,
+including =todo.org= (556 KB) and 184 archived session files. Nothing carries
+it. Not git, not stow, not the bootstrap.
+
+I recovered it by rsyncing the set from ratio over the tailnet. Ratio was
+authoritative and velox held nothing, so there was no merge to adjudicate —
+which is luck, not design. Had velox held a few days of divergent state, this
+would have been a hand reconciliation. It has been one before: 2026-07-31, when
+the two machines' =.ai/= trees had forked to zero files in common.
+
+Worth knowing: this is fleet-general. Every project on the box that gitignores
+its =.ai/= has the same hole, not just =.emacs.d=.
+
+What the install could do: after cloning a project, check whether a sibling
+daily driver holds a =.ai/= for it, and offer to pull it across. Or at minimum,
+list the projects whose tooling layer is missing so the gap is visible on day
+one instead of at the first session that trips over it.
+
+* Gap 2 — stowed user timers come back linked but not enabled
+
+The unit files all arrived correctly through the dotfiles stow, symlinked into
+=~/.config/systemd/user/= and resolving fine. But being present is not being
+enabled, and the reinstall enabled only some of them:
+
+| unit | velox after reinstall | ratio |
+|---------------------------+-----------------------+----------|
+| calendar-sync.timer | enabled, active | enabled |
+| agenda-render-cache.timer | enabled, active | enabled |
+| roam-sync.timer | *linked, inactive* | enabled |
+| signal-receive.timer | *linked, inactive* | enabled |
+| emacs.service | linked, inactive | linked |
+
+=emacs.service= reads the same on both machines, so I take that one as
+intentional and left it alone. The other two are real drift: =systemctl --user
+enable= writes a =timers.target.wants= symlink into =~/.config/systemd/user/=,
+and that symlink is not stow-managed, so nothing in the dotfiles repo carries
+it. A stowed unit file is inert until something enables it.
+
+I enabled both with =systemctl --user enable --now=. Both fired immediately and
+exited clean, and both now show a next elapse.
+
+What the install could do: enable the units it stows, explicitly, as a named
+step. The inconsistency is the tell — two of four came back enabled, which
+suggests something enables a subset and nothing enumerates the rest.
+
+* Gap 3 — the roam clone was stale, and held a diff that would have destroyed data
+
+This one has an ordering constraint, so it matters more than its size suggests.
+
+velox's =~/org/roam= was ten commits behind ratio, stuck at the 2026-08-04
+auto-sync while ratio was at 2026-08-14 — a direct consequence of gap 2, since
+=roam-sync.timer= was never enabled here.
+
+The dangerous part: velox's clone also carried an *uncommitted* =inbox.org=
+that had been emptied. Seventeen deletions, file down to zero bytes, holding a
+pre-2026-08-04 state whose captures were long since processed on ratio.
+
+So the naive repair — enable =roam-sync.timer= and let it catch up — would have
+committed that emptying and pushed it, deleting the four live inbox items on
+ratio. The timer is the repo's only committer and it commits whatever it finds.
+
+I checked ratio's =inbox.org= first and confirmed it was a strict superset of
+velox's HEAD version (same three items plus an 2026-08-09 capture), which made
+the local change provably worthless. Then discarded it, fast-forwarded to
+=a411b43=, and only then enabled the timer. Clone is clean and current, first
+sync ran green.
+
+What the install could do: if it ever enables =roam-sync= on a rebuilt machine,
+reconcile the clone *before* enabling, not after. An auto-committing timer
+pointed at a stale dirty clone is a data-loss path, and the failure is silent
+and remote — it lands on the *other* machine.
+
+* Gap 4 — signal-cli lost its registration, and that breaks the whole fleet
+
+=signal-receive.service= ran for the first time and reported:
+
+: signal-receive: +15045173983 not registered on this machine — nothing to do
+
+velox's signal-cli data dir holds a 39-byte empty =accounts.json=. Ratio still
+has both numbers. So the reinstall wiped the registration, and per the design
+notes velox was supposed to be the *primary* — ratio is the linked device.
+
+The effect is wider than velox, because of how =agent-text= dispatches: if the
+local signal-cli holds the account it sends directly, otherwise it ssh-relays to
+a hardcoded velox. Velox no longer holds it, so a send from here relays to
+itself and fails; a send from any third machine relays to velox and fails the
+same way. Only ratio still works, and only via the direct branch. The error text
+blames "velox down or unreachable", which is misleading — velox is up and on the
+tailnet, it just is not registered.
+
+This is the one I could not repair from the session: re-linking needs me at my
+phone (Signal → Settings → Linked Devices, scanning the QR from =signal-cli
+link -n velox=). Filed in .emacs.d's todo.org as [#B].
+
+What the install could do: verify =signal-cli listAccounts= is non-empty after a
+rebuild and say so loudly if it is not. Silent loss of the phone channel is
+exactly the kind of thing nobody notices until the page that mattered never
+arrives.
+
+* Summary of what I changed on velox
+
+- Restored =.ai/=, =.claude/=, =CLAUDE.md=, =todo.org=, =inbox/= to
+ =~/.emacs.d= by rsync from ratio.
+- Discarded the stale local =inbox.org= diff in =~/org/roam= and fast-forwarded
+ the clone to current.
+- Enabled and started =roam-sync.timer= and =signal-receive.timer=.
+
+Left alone, deliberately: =emacs.service= (matches ratio), and velox's Signal
+registration (needs the phone).
+
+One unrelated thing I noticed while comparing the machines: ratio's signal-cli
+warns its messages were last received twelve days ago, even though its
+=signal-receive.timer= is enabled and active. That may be nothing, but the
+receive cadence there is worth a look.
diff --git a/working/velox-reinstall/2026-08-14-emacsd-velox-reinstall-gaps-2.org b/working/velox-reinstall/2026-08-14-emacsd-velox-reinstall-gaps-2.org
new file mode 100644
index 0000000..27f0da8
--- /dev/null
+++ b/working/velox-reinstall/2026-08-14-emacsd-velox-reinstall-gaps-2.org
@@ -0,0 +1,63 @@
+#+TITLE: Fifth reinstall gap — machine-local .local.el config, and a general shape
+#+AUTHOR: Craig Jennings
+
+* Follow-up to this morning's handoff
+
+Sent you four gaps an hour ago
+([[file:2026-08-14-emacsd-velox-reinstall-gaps-1.org][the first report]]). Here is a fifth,
+found straight afterwards when I noticed calendar sync was dead on velox.
+
+* What was broken
+
+=calendar-sync.timer= was enabled and firing every fifteen minutes, and failing
+every time with exit 255:
+
+: calendar-sync: No calendars configured (set calendar-sync-calendars)
+
+The three output files sat at zero bytes. The cause is that
+=~/.emacs.d/calendar-sync.local.el= is gitignored, so the reinstall deleted it
+along with everything else untracked, and the module's loader treats a missing
+file as a *silent* no-op. So the config vanished quietly and the only symptom
+was a failing unit nobody was watching.
+
+Cheap to fix once found: the repo tracks =calendar-sync.local.el.example=, and
+that template already encodes the shape velox uses — feeds resolved by
+=:secret-host= against =authinfo.gpg= rather than inlined. The authinfo entries
+had survived, because =~/.authinfo.gpg= is a stow symlink into the dotfiles repo.
+So rebuilding was one copy, and all three feeds now sync clean and land
+byte-identical to ratio's.
+
+* The general shape, which is the part worth acting on
+
+This is the same failure as gap 1, one layer down, and it is worth stating
+generally because the install can act on it:
+
+- A tracked =*.local.el.example= template plus a gitignored =*.local.el= is a
+ deliberate pattern in this config, not a one-off. =.gitignore= lines 56-58
+ list three of them: =calendar-sync.local.el=, =signal-config.local.el=,
+ =google-keep.local.el=. Every one of those is gone on velox right now. I have
+ only repaired the calendar one.
+- Secrets held *by reference* survive a rebuild; secrets held *inline* do not.
+ The calendar config came back for free because the tokens were in
+ =authinfo.gpg=, which is stow-managed and therefore travels. Ratio's copy of
+ the same file inlines its URLs, and had ratio been the machine rebuilt, those
+ three feed tokens would simply have been gone.
+- The failure was silent by design. A missing local config is a no-op, which is
+ right for a machine that never configured the feature and wrong for one that
+ just lost it.
+
+* What the install could do
+
+- After a rebuild, enumerate every tracked =*.local.el.example= in a project and
+ report which have no corresponding =*.local.el=. That is a one-line find and it
+ turns a silent no-op into a visible checklist item.
+- Same for any =*.local.*= convention elsewhere in the fleet — the pattern is not
+ specific to Emacs.
+- Worth pairing with gap 2: a unit that is enabled and failing every fifteen
+ minutes for two days is its own signal. A post-rebuild pass over
+ =systemctl --user list-units --state=failed= would have caught this one
+ without knowing anything about calendars.
+
+That last one generalizes best. Of the five gaps I have sent you, three were
+things that *looked* fine — a stowed unit file, an enabled timer, a present
+clone — and were not.
diff --git a/working/velox-reinstall/2026-08-14-emacsd-velox-reinstall-gaps-3.org b/working/velox-reinstall/2026-08-14-emacsd-velox-reinstall-gaps-3.org
new file mode 100644
index 0000000..0df4d75
--- /dev/null
+++ b/working/velox-reinstall/2026-08-14-emacsd-velox-reinstall-gaps-3.org
@@ -0,0 +1,98 @@
+#+TITLE: Reinstall gaps, part three — per-install certs and credentials, and one failure that hid the others
+#+AUTHOR: Craig Jennings
+
+* Third handoff today
+
+Two earlier notes covered five gaps
+([[file:2026-08-14-emacsd-velox-reinstall-gaps-1.org][the first report]] and
+[[file:2026-08-14-emacsd-velox-reinstall-gaps-2.org][the follow-up]]).
+Email was the last thing broken on velox after the 2026-08-13 rebuild, and it
+turned up two more — both the same shape, and one of them with a property worth
+generalizing.
+
+Email is fully working now: three accounts, 21,853 messages, 4.0 GB indexed.
+
+* Gap 6 — the Proton Bridge TLS cert is per-install, and its absence disabled every account
+
+=~/.mbsyncrc= carries =CertificateFile /home/cjennings/.config/protonbridge.pem=.
+That file did not exist after the rebuild, and it cannot be restored from backup
+or copied from the other machine: Proton Bridge generates a fresh self-signed
+cert per installation. Velox's is issued 2026-08-13 23:44 with a different
+fingerprint from ratio's 2026-01-30 one.
+
+*The part worth acting on is the blast radius.* mbsync parses its entire config
+before doing any work, so a missing =CertificateFile= referenced by *one* account
+aborts the run for *all* of them. Gmail and dmail need no bridge and no cert, and
+both were dead anyway. The error names only the missing pem, so the symptom
+("no mail at all") and the message ("this one file is missing") look unrelated.
+
+Recovery does not need the bridge GUI. The running bridge presents the cert on
+its own IMAP port, so it can be pulled straight off the handshake:
+
+: openssl s_client -connect 127.0.0.1:1143 -starttls imap -showcerts </dev/null \
+: | sed -n '/BEGIN CERTIFICATE/,/END CERTIFICATE/p' > ~/.config/protonbridge.pem
+
+That is a two-second, fully scriptable step, which makes it a good candidate for
+the install rather than a runbook line.
+
+* Gap 7 — the bridge password is per-install too, and reports a stale value misleadingly
+
+=~/.mbsyncrc= resolves the cmail password with =cat ~/.config/.cmailpass=. That
+file is plaintext and, unusually for my setup, a real file rather than a stow
+symlink — so it is not in the dotfiles repo, not encrypted, and not carried to a
+new machine.
+
+The file survived the rebuild but held the *previous* install's password, because
+the bridge regenerates it per installation. Ratio's and velox's differ by sha256,
+confirmed today.
+
+*The diagnostic trap:* Proton Bridge answers a wrong password with =no such
+user=. I read that as "the bridge has no account signed in" and went looking for
+a login problem. The account was configured the whole time. If the install ever
+validates bridge connectivity, it should not treat =no such user= as evidence
+about account state.
+
+* The generalization
+
+Gaps 6 and 7 are the same as 1 through 5, sharpened. Everything that broke in
+this rebuild was *generated on the machine by an application* rather than carried
+by git, stow, or the dotfiles repo:
+
+| gap | artifact | why it did not travel |
+| 1 | =.ai/=, =todo.org=, =CLAUDE.md= | gitignored |
+| 2 | =timers.target.wants= symlinks | written by systemctl enable |
+| 3 | roam clone state | local working tree |
+| 4 | signal-cli registration | per-device identity |
+| 5 | =*.local.el= configs | gitignored |
+| 6 | bridge TLS cert | per-install, regenerated |
+| 7 | bridge password | per-install, regenerated |
+
+Gaps 6 and 7 add a distinction the earlier note missed. For 1, 3 and 5 the old
+value is still correct, so *restoring* fixes them. For 4, 6 and 7 the old value is
+*worthless* — the application has generated a new one, and only *re-deriving*
+from the live system fixes them. An install that tries to restore these will
+produce exactly what happened here: a file that exists, looks right, and
+authenticates against nothing.
+
+So the install's post-rebuild checklist wants two columns, not one: what to
+restore, and what to re-derive.
+
+* What the install could do
+
+- Re-derive the bridge cert from the running bridge with the =openssl s_client=
+ line above. Scriptable, no GUI, no secrets.
+- Re-derive the bridge password from the bridge rather than expecting the file to
+ be right, and rewrite =.cmailpass=. (I have filed a task on my side to make
+ =PassCmd= ask the bridge directly, which would remove the file entirely.)
+- Add a cheap post-rebuild validation that =mbsync --list= parses. Config-parse
+ failures disable every account at once and say nothing about mail, so they are
+ worth catching explicitly rather than via "no new mail" hours later.
+- More generally: keep the restore list and the re-derive list separate, per the
+ table above.
+
+* Unrelated, but noticed while comparing the machines
+
+=~/.config/.gmailpass.gpg= and =~/.config/.dmailpass.gpg= resolve to mode 777 in
+the dotfiles repo, on both machines. They are gpg-encrypted so the contents are
+safe, but world-writable is wrong for a credential file. That is a dotfiles fix,
+not an archsetup one — noting it here only because it surfaced in the same pass.