electron+tests: zoom plumbing (construction-only lights), hermeticity fence for the jobs suite

- ZOOM: the ⌘±/⌘0 ladder is owned, not stock roles — applyZoom sets the
  factor and broadcasts 'lh:zoom'; the renderer publishes --lh-zoom; the
  gutter lengths (.lh-lights-gutter / .lh-closed-toggle) divide by it so
  their RENDERED size is a physical constant at every zoom — the graph
  button stays glued next to the lights, nothing slides under them.
  before-input-event catches ⌘=/layout variants the menu's Plus may not
  claim; did-finish-load re-pushes on reload; getZoom covers first paint.
  The lights themselves stay at construction position — Electron 44 has
  NO setTrafficLightPosition (options-only; the first run crashed on
  it). HIG-acceptable: native lights hug the corner; the web chrome
  compensates around them.
- JOBS SUITE FLAKE, actually fixed this time: the seams are
  module-global and the sweep scans every record — a timed-out async
  test left a running orphan + late wake that poisoned its NEIGHBORS
  (respawned 3 !== 0). New afterEach fence: settle every tracked
  running job, unregister aborts, seams reset to inert no-ops — every
  test starts from identical state regardless of upstream flake.
  Verified: two consecutive full-suite runs 1326/1326.
This commit is contained in:
amosblomqvist
2026-10-05 05:42:27 +03:00
parent f4775ebc93
commit 6b58d87d9e
5 changed files with 147 additions and 6 deletions
+12 -3
View File
@@ -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
+17 -1
View File
@@ -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<number>;
};
}
}
@@ -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):
+78 -1
View File
@@ -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;
+12
View File
@@ -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"),
});
+28 -1
View File
@@ -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 {