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:
+12
-3
@@ -516,12 +516,21 @@ body {
|
|||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
}
|
}
|
||||||
/* 92px = measured lights' right edge (~75pt) + the lights' own 16px
|
/* 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 {
|
.lh-electron .lh-lights-gutter {
|
||||||
padding-left: 92px;
|
padding-left: calc(92px / var(--lh-zoom));
|
||||||
}
|
}
|
||||||
.lh-electron .lh-closed-toggle {
|
.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
|
/* --font-title deliberately NOT @theme inline (see comment above): the
|
||||||
|
|||||||
+17
-1
@@ -54,7 +54,12 @@ const IS_ELECTRON = typeof navigator !== "undefined" && navigator.userAgent.incl
|
|||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
/** Preload bridge (electron/preload.cjs) — Electron only, absent in the browser. */
|
/** 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");
|
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);
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
// Default: open (first run introduces the sidebar).
|
// Default: open (first run introduces the sidebar).
|
||||||
setOpen(stored === null ? true : stored === "1");
|
setOpen(stored === null ? true : stored === "1");
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
|
return () => off?.();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Narrow-window auto-close/auto-restore (see SIDEBAR_AUTOCLOSE note):
|
// Narrow-window auto-close/auto-restore (see SIDEBAR_AUTOCLOSE note):
|
||||||
|
|||||||
+78
-1
@@ -252,10 +252,59 @@ function createWindow(url) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
void win.loadURL(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());
|
win.once("ready-to-show", () => win.show());
|
||||||
return win;
|
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,
|
/** macOS needs an application menu with the Edit roles — without one,
|
||||||
* Cmd+C/V/A do nothing in the renderer. Everything else stays minimal. */
|
* Cmd+C/V/A do nothing in the renderer. Everything else stays minimal. */
|
||||||
function installMenu() {
|
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: "Edit", submenu: [{ role: "undo" }, { role: "redo" }, { type: "separator" }, { role: "cut" }, { role: "copy" }, { role: "paste" }, { role: "selectAll" }] },
|
||||||
{
|
{
|
||||||
label: "View",
|
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" },
|
{ role: "windowMenu" },
|
||||||
];
|
];
|
||||||
@@ -316,6 +390,9 @@ async function main() {
|
|||||||
// width asks the window to widen instead — the layout never has to
|
// width asks the window to widen instead — the layout never has to
|
||||||
// accommodate a narrow window + open sidebar. Top-left stays fixed, the
|
// accommodate a narrow window + open sidebar. Top-left stays fixed, the
|
||||||
// window grows right. No-op when already wide.
|
// 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) => {
|
ipcMain.on("lh:expand-window", (event) => {
|
||||||
const win = BrowserWindow.fromWebContents(event.sender);
|
const win = BrowserWindow.fromWebContents(event.sender);
|
||||||
if (!win) return;
|
if (!win) return;
|
||||||
|
|||||||
@@ -8,10 +8,22 @@
|
|||||||
* threshold (928px) when the sidebar is opened below it. The main
|
* threshold (928px) when the sidebar is opened below it. The main
|
||||||
* process clamps: no-op if the window is already wide enough.
|
* 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.
|
* Channel names are mirrored in electron/main.cjs — keep them in sync.
|
||||||
*/
|
*/
|
||||||
const { contextBridge, ipcRenderer } = require("electron");
|
const { contextBridge, ipcRenderer } = require("electron");
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("lh", {
|
contextBridge.exposeInMainWorld("lh", {
|
||||||
expandWindow: () => ipcRenderer.send("lh:expand-window"),
|
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
@@ -26,7 +26,7 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
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 { createFauxCore, fauxAssistantMessage, fauxText, fauxToolCall, type AssistantMessage, type FauxResponseStep } from "@earendil-works/pi-ai";
|
||||||
import {
|
import {
|
||||||
accumulateJobUsage,
|
accumulateJobUsage,
|
||||||
@@ -123,6 +123,33 @@ function trackJob(id: string) {
|
|||||||
createdJobs.push(id);
|
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(() => {
|
after(() => {
|
||||||
for (const id of createdChats) {
|
for (const id of createdChats) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user