diff --git a/app/globals.css b/app/globals.css index 0b62d28..fbbce21 100644 --- a/app/globals.css +++ b/app/globals.css @@ -516,12 +516,21 @@ body { -webkit-app-region: no-drag; } /* 92px = measured lights' right edge (~75pt) + the lights' own 16px - inset from the window edge — comfortable, symmetric air (owner). */ + inset from the window edge — comfortable, symmetric air (owner). + PHYSICAL-CONSTANT under zoom (owner round 7, 2026-10-05): ⌘±/⌘0 scale + every CSS px by the zoomFactor while the native lights never move — + so these lengths DIVIDE by --lh-zoom (published by the renderer from + the lh:zoom IPC) to render at the same physical size at every zoom. + The graph button stays glued next to the lights zoomed in; nothing + slides under them zoomed out. Default 1 until the first push lands. */ +.lh-electron { + --lh-zoom: 1; +} .lh-electron .lh-lights-gutter { - padding-left: 92px; + padding-left: calc(92px / var(--lh-zoom)); } .lh-electron .lh-closed-toggle { - margin-left: 92px; + margin-left: calc(92px / var(--lh-zoom)); } /* --font-title deliberately NOT @theme inline (see comment above): the diff --git a/components/sidebar.tsx b/components/sidebar.tsx index 88bf8c1..5aabc80 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -54,7 +54,12 @@ const IS_ELECTRON = typeof navigator !== "undefined" && navigator.userAgent.incl declare global { interface Window { /** Preload bridge (electron/preload.cjs) — Electron only, absent in the browser. */ - lh?: { expandWindow: () => void }; + lh?: { + expandWindow: () => void; + /** The zoom truth (owner round 7): subscribes to "lh:zoom" pushes; returns unsubscribe. */ + onZoom?: (cb: (factor: number) => void) => () => void; + getZoom?: () => Promise; + }; } } @@ -84,10 +89,21 @@ export function SidebarProvider({ children }: { children: React.ReactNode }) { document.documentElement.classList.add("lh-electron"); } + // THE ZOOM TRUTH (owner round 7, 2026-10-05): ⌘±/⌘0 zoom the UI + // (Electron zoomFactor) while the native traffic lights stay + // physical-fixed — the CSS gutter lengths divide by this factor so + // their RENDERED size never moves. getZoom covers the first-paint + // race; every later change arrives as an lh:zoom push. + const setZoomVar = (factor: number) => + document.documentElement.style.setProperty("--lh-zoom", String(factor)); + const off = window.lh?.onZoom?.(setZoomVar); + void window.lh?.getZoom?.().then(setZoomVar); + const stored = localStorage.getItem(STORAGE_KEY); // Default: open (first run introduces the sidebar). setOpen(stored === null ? true : stored === "1"); setMounted(true); + return () => off?.(); }, []); // Narrow-window auto-close/auto-restore (see SIDEBAR_AUTOCLOSE note): diff --git a/electron/main.cjs b/electron/main.cjs index 4345bce..411bb9e 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -252,10 +252,59 @@ function createWindow(url) { }); void win.loadURL(url); + // Zoom truth reaches the renderer for EVERY load (reload, nav): the + // renderer's --lh-zoom must never go stale. The fallback catcher below + // covers ⌘= / layout variants the menu's ⌘Plus may not claim — menu- + // claimed keys never reach before-input-event, so exactly one of the + // two applies any given zoom keypress. + win.webContents.on("did-finish-load", () => { + win.webContents.send("lh:zoom", win.webContents.getZoomFactor()); + }); + win.webContents.on("before-input-event", (event, input) => { + if (input.type !== "keyDown" || !(input.meta || input.control)) return; + if (input.key === "=" || input.key === "+") zoomStep(win, +1); + else if (input.key === "-") zoomStep(win, -1); + else if (input.key === "0") applyZoom(win, 1); + else return; + event.preventDefault(); + }); + win.once("ready-to-show", () => win.show()); return win; } +/* ── Zoom (owner round 7, 2026-10-05): ⌘±/⌘0 as an OWNED ladder ────────── + * The stock zoomIn/zoomOut roles change zoomLevel silently — the renderer + * never learns the factor, and the native traffic lights (physical-fixed, + * hiddenInset) drift out of relation to the scaled chrome: the graph + * button rides away from them zoomed in, content slides under them + * zoomed out. Here zoom is explicit: a factor ladder, applied through + * setZoomFactor, and broadcast to the renderer ("lh:zoom" — it publishes + * --lh-zoom so gutter lengths can divide by it and stay PHYSICAL- + * constant). The traffic lights themselves stay at their construction + * position ({x:16, y:22.5} — Electron 44 has NO runtime setter, only + * the BrowserWindow option), which is HIG-correct anyway: native lights + * hug the window corner while the web chrome compensates around them. + * Vertical note: at large factors the taller bands' content centers + * drift below the fixed lights — accepted (the alternative, moving the + * lights, is impossible without rebuilding the window). */ +const ZOOM_LADDER = [0.75, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2]; + +function zoomStep(win, dir) { + const current = win.webContents.getZoomFactor(); + const next = + dir > 0 + ? ZOOM_LADDER.find((f) => f > current + 0.001) ?? ZOOM_LADDER[ZOOM_LADDER.length - 1] + : [...ZOOM_LADDER].reverse().find((f) => f < current - 0.001) ?? ZOOM_LADDER[0]; + applyZoom(win, next); +} + +function applyZoom(win, factor) { + factor = Math.min(2, Math.max(0.75, factor)); // ladder clamps; ⌘0 = 1 + win.webContents.setZoomFactor(factor); + win.webContents.send("lh:zoom", factor); +} + /** macOS needs an application menu with the Edit roles — without one, * Cmd+C/V/A do nothing in the renderer. Everything else stays minimal. */ function installMenu() { @@ -274,7 +323,32 @@ function installMenu() { { label: "Edit", submenu: [{ role: "undo" }, { role: "redo" }, { type: "separator" }, { role: "cut" }, { role: "copy" }, { role: "paste" }, { role: "selectAll" }] }, { label: "View", - submenu: [{ role: "reload" }, { role: "toggleDevTools" }, { type: "separator" }, { role: "resetZoom" }, { role: "zoomIn" }, { role: "zoomOut" }, { type: "separator" }, { role: "togglefullscreen" }], + submenu: [ + { role: "reload" }, + { role: "toggleDevTools" }, + { type: "separator" }, + // OWNED zoom (see applyZoom): the roles change zoomLevel + // silently; these broadcast the factor and re-seat the + // traffic lights. The renderer's --lh-zoom depends on + // hearing every change. + { + label: "Actual Size", + accelerator: "CommandOrControl+0", + click: () => applyZoom(BrowserWindow.getAllWindows()[0], 1), + }, + { + label: "Zoom In", + accelerator: "CommandOrControl+Plus", + click: () => zoomStep(BrowserWindow.getAllWindows()[0], +1), + }, + { + label: "Zoom Out", + accelerator: "CommandOrControl+-", + click: () => zoomStep(BrowserWindow.getAllWindows()[0], -1), + }, + { type: "separator" }, + { role: "togglefullscreen" }, + ], }, { role: "windowMenu" }, ]; @@ -316,6 +390,9 @@ async function main() { // width asks the window to widen instead — the layout never has to // accommodate a narrow window + open sidebar. Top-left stays fixed, the // window grows right. No-op when already wide. + // The renderer's first paint can race did-finish-load — it asks for + // the current factor itself (mirrors "lh:zoom" pushes). + ipcMain.handle("lh:zoom:get", (event) => BrowserWindow.fromWebContents(event.sender)?.webContents.getZoomFactor() ?? 1); ipcMain.on("lh:expand-window", (event) => { const win = BrowserWindow.fromWebContents(event.sender); if (!win) return; diff --git a/electron/preload.cjs b/electron/preload.cjs index dda4ce6..7c4926f 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -8,10 +8,22 @@ * threshold (928px) when the sidebar is opened below it. The main * process clamps: no-op if the window is already wide enough. * + * window.lh.onZoom(cb) / window.lh.getZoom() — the zoom truth (owner + * round 7, 2026-10-05): every ⌘±/⌘0 change arrives as "lh:zoom" with + * the new factor; getZoom covers the first-paint race. The renderer + * publishes it as --lh-zoom so physical-constant lengths (the traffic- + * light gutter) can divide by it. + * * Channel names are mirrored in electron/main.cjs — keep them in sync. */ const { contextBridge, ipcRenderer } = require("electron"); contextBridge.exposeInMainWorld("lh", { expandWindow: () => ipcRenderer.send("lh:expand-window"), + onZoom: (cb) => { + const listener = (_event, factor) => cb(factor); + ipcRenderer.on("lh:zoom", listener); + return () => ipcRenderer.removeListener("lh:zoom", listener); + }, + getZoom: () => ipcRenderer.invoke("lh:zoom:get"), }); diff --git a/tests/jobs.test.ts b/tests/jobs.test.ts index 1366378..b086f8c 100644 --- a/tests/jobs.test.ts +++ b/tests/jobs.test.ts @@ -26,7 +26,7 @@ import assert from "node:assert/strict"; import * as fs from "node:fs"; import * as path from "node:path"; -import { after, describe, it } from "node:test"; +import { after, afterEach, describe, it } from "node:test"; import { createFauxCore, fauxAssistantMessage, fauxText, fauxToolCall, type AssistantMessage, type FauxResponseStep } from "@earendil-works/pi-ai"; import { accumulateJobUsage, @@ -123,6 +123,33 @@ function trackJob(id: string) { createdJobs.push(id); } +/* THE HERMETICITY FENCE (owner round 7, 2026-10-05): the jobs seams are + * MODULE-GLOBAL (setJobRespawn / setTurnRunner / the abort registry) and + * the sweep scans EVERY job in the store. An async test that times out + * under full-suite load leaves a "running" record and a late wake behind + * — and that stray state then poisons the NEXT test (a foreign respawn + * lands in its counter; a foreign orphan in its sweep). Under isolation + * nothing ever times out, which is why the file passes alone and flaked + * in the full run. The fence makes every test's start state identical: + * all tracked jobs settled, aborts unregistered, seams inert. */ +afterEach(() => { + for (const id of createdJobs) { + try { + const job = jobStore.getJob(id); + if (job && job.status === "running") { + jobStore.updateJob(id, { status: "done", activity: "settled (fence)" }); + } + unregisterJobAbort(id); + } catch { + /* already gone */ + } + } + // Late wakes from a prior test must be inert: a no-op respawn can't + // increment anyone's counter, a no-op runner can't spawn real work. + setJobRespawn(() => {}); + setTurnRunner(async () => 200); +}); + after(() => { for (const id of createdChats) { try {