diff options
Diffstat (limited to 'working')
7 files changed, 1199 insertions, 0 deletions
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. |
