diff --git a/electron/recorder-window-sizing.test.ts b/electron/recorder-window-sizing.test.ts
new file mode 100644
index 0000000..413b048
--- /dev/null
+++ b/electron/recorder-window-sizing.test.ts
@@ -0,0 +1,74 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ fitRecorderHeight,
+ type RecorderWindowSizingTarget,
+} from "./recorder-window-sizing";
+
+class WindowsFramedWindow implements RecorderWindowSizingTarget {
+ destroyed = false;
+ resizable = false;
+ size: [number, number] = [400, 500];
+ readonly resizeChanges: boolean[] = [];
+ readonly sizeChanges: Array<[number, number]> = [];
+
+ isDestroyed(): boolean {
+ return this.destroyed;
+ }
+
+ getContentSize(): [number, number] {
+ const frame: [number, number] = this.resizable ? [16, 39] : [2, 31];
+ return [this.size[0] - frame[0], this.size[1] - frame[1]];
+ }
+
+ getSize(): [number, number] {
+ return [...this.size];
+ }
+
+ isResizable(): boolean {
+ return this.resizable;
+ }
+
+ setResizable(resizable: boolean): void {
+ this.resizable = resizable;
+ this.resizeChanges.push(resizable);
+ }
+
+ setSize(width: number, height: number): void {
+ this.size = [width, height];
+ this.sizeChanges.push([width, height]);
+ }
+}
+
+test("recorder fitting preserves outer width across Windows frame changes", () => {
+ const win = new WindowsFramedWindow();
+
+ fitRecorderHeight(win, 600);
+ assert.deepEqual(win.size, [400, 631]);
+ assert.deepEqual(win.getContentSize(), [398, 600]);
+ assert.deepEqual(win.resizeChanges, [true, false]);
+ assert.deepEqual(win.sizeChanges, [[400, 631]]);
+
+ for (let i = 0; i < 5; i++) fitRecorderHeight(win, 600);
+ assert.deepEqual(win.size, [400, 631]);
+ assert.deepEqual(win.sizeChanges, [[400, 631]]);
+});
+
+test("recorder fitting clamps content height and ignores invalid requests", () => {
+ const win = new WindowsFramedWindow();
+
+ fitRecorderHeight(win, 10);
+ assert.equal(win.getContentSize()[1], 320);
+
+ fitRecorderHeight(win, 10_000);
+ assert.equal(win.getContentSize()[1], 720);
+
+ const size = [...win.size];
+ fitRecorderHeight(win, Number.NaN);
+ assert.deepEqual(win.size, size);
+
+ win.destroyed = true;
+ fitRecorderHeight(win, 500);
+ assert.deepEqual(win.size, size);
+});
diff --git a/electron/recorder-window-sizing.ts b/electron/recorder-window-sizing.ts
new file mode 100644
index 0000000..27676ac
--- /dev/null
+++ b/electron/recorder-window-sizing.ts
@@ -0,0 +1,40 @@
+const RECORDER_MIN_HEIGHT = 320;
+const RECORDER_MAX_HEIGHT = 720;
+
+export interface RecorderWindowSizingTarget {
+ isDestroyed(): boolean;
+ getContentSize(): number[];
+ getSize(): number[];
+ isResizable(): boolean;
+ setResizable(resizable: boolean): void;
+ setSize(width: number, height: number, animate?: boolean): void;
+}
+
+/**
+ * Fit the recorder to its rendered content while preserving its fixed outer width.
+ * Windows changes the non-client frame thickness when resizability is toggled, so
+ * feeding a content width measured before that toggle into `setContentSize` grows
+ * the outer window on every ResizeObserver callback.
+ */
+export function fitRecorderHeight(
+ win: RecorderWindowSizingTarget,
+ contentHeight: number,
+): void {
+ if (win.isDestroyed() || !Number.isFinite(contentHeight)) return;
+
+ const targetContentHeight = Math.round(
+ Math.max(RECORDER_MIN_HEIGHT, Math.min(RECORDER_MAX_HEIGHT, contentHeight)),
+ );
+ const [, currentContentHeight] = win.getContentSize();
+ if (Math.abs(currentContentHeight - targetContentHeight) < 1) return;
+
+ const [outerWidth, outerHeight] = win.getSize();
+ const targetOuterHeight = outerHeight + targetContentHeight - currentContentHeight;
+ const wasResizable = win.isResizable();
+ if (!wasResizable) win.setResizable(true);
+ try {
+ win.setSize(outerWidth, targetOuterHeight);
+ } finally {
+ if (!wasResizable && !win.isDestroyed()) win.setResizable(false);
+ }
+}
diff --git a/electron/window.ts b/electron/window.ts
index 586b42e..dd77e1e 100644
--- a/electron/window.ts
+++ b/electron/window.ts
@@ -10,6 +10,7 @@ import {
resizeRecordingControlsBounds,
} from "./recording-controls-bounds";
import { windowIcon } from "./icons";
+export { fitRecorderHeight } from "./recorder-window-sizing";
const dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -17,10 +18,6 @@ const dirname = path.dirname(fileURLToPath(import.meta.url));
* `fitRecorderHeight`). The initial height is a sensible first paint before the
* renderer reports its true content height. */
const RECORDER = { width: 400, height: 500 };
-/** Guard rails for the content-driven height so a bad measurement can't produce a
- * degenerate window. */
-const RECORDER_MIN_HEIGHT = 320;
-const RECORDER_MAX_HEIGHT = 720;
/** Library sizing bounds; actual width adapts to the space beside the recorder. */
const LIBRARY = { desiredWidth: 1140, minWidth: 720, floorWidth: 520, maxHeight: 820 };
const MARGIN = 12;
@@ -70,26 +67,6 @@ export function createRecorderWindow(): BrowserWindow {
return win;
}
-/**
- * Fit the recorder window to the height the renderer reports for its content, so the
- * fixed-width HUD shows neither dead space (short states) nor a clipped row (when the
- * doctor reveals an extra model row). The width is preserved; the top-left is kept so
- * it grows/shrinks downward. `resizable:false` blocks user resizing but not this
- * programmatic sizing — on platforms that also block that, we briefly re-enable it.
- */
-export function fitRecorderHeight(win: BrowserWindow, contentHeight: number): void {
- if (win.isDestroyed() || !Number.isFinite(contentHeight)) return;
- const target = Math.round(
- Math.max(RECORDER_MIN_HEIGHT, Math.min(RECORDER_MAX_HEIGHT, contentHeight)),
- );
- const [width, current] = win.getContentSize();
- if (Math.abs(current - target) < 1) return;
- const wasResizable = win.isResizable();
- if (!wasResizable) win.setResizable(true);
- win.setContentSize(width, target);
- if (!wasResizable) win.setResizable(false);
-}
-
/** Always-on-top recording transport shown without stealing focus from the task. */
export function createRecordingControlsWindow(): BrowserWindow {
let bounds: Bounds;
diff --git a/package.json b/package.json
index ba6b2d8..42a5c27 100644
--- a/package.json
+++ b/package.json
@@ -19,7 +19,7 @@
"check:lockfile": "node scripts/check-lockfile-portability.mjs",
"typecheck": "tsc --noEmit",
"typecheck:evals": "tsc --noEmit -p evals/tsconfig.json",
- "test": "node --experimental-transform-types --no-warnings --import ./evals/register.mjs --test evals/builder-imports.test.ts common/architecture-registry.test.ts electron/architectures/catalogue-registry.test.ts common/audio.test.ts common/microphone.test.ts common/narration.test.ts common/sensitive.test.ts electron/recording-controls-bounds.test.ts electron/recording-privacy.test.ts electron/crash-guards.test.ts electron/recorder/controller.test.ts electron/recorder/session-store.test.ts electron/frames/extractor.test.ts electron/narration/audio-analysis.test.ts electron/narration/analyze-gate.test.ts electron/narration/transcribe.test.ts electron/narration/whisper.test.ts electron/sensitive/scanner.test.ts electron/sensitive/secrets.test.ts electron/sensitive/tessdata-source.test.ts electron/sensitive/ocr.test.ts electron/sensitive/frame-redact.test.ts electron/sensitive/frame-heuristics.test.ts electron/sessions.test.ts electron/debug-bundle.test.ts electron/skillbuilder/placement.test.ts src/skill-placement.test.ts scripts/compliance.test.mjs",
+ "test": "node --experimental-transform-types --no-warnings --import ./evals/register.mjs --test evals/builder-imports.test.ts common/architecture-registry.test.ts electron/architectures/catalogue-registry.test.ts common/audio.test.ts common/microphone.test.ts common/narration.test.ts common/sensitive.test.ts electron/recording-controls-bounds.test.ts electron/recorder-window-sizing.test.ts electron/recording-privacy.test.ts electron/crash-guards.test.ts electron/recorder/controller.test.ts electron/recorder/session-store.test.ts electron/frames/extractor.test.ts electron/narration/audio-analysis.test.ts electron/narration/analyze-gate.test.ts electron/narration/transcribe.test.ts electron/narration/whisper.test.ts electron/sensitive/scanner.test.ts electron/sensitive/secrets.test.ts electron/sensitive/tessdata-source.test.ts electron/sensitive/ocr.test.ts electron/sensitive/frame-redact.test.ts electron/sensitive/frame-heuristics.test.ts electron/sessions.test.ts electron/debug-bundle.test.ts electron/skillbuilder/placement.test.ts src/skill-placement.test.ts scripts/compliance.test.mjs",
"eval": "node --experimental-transform-types --no-warnings --import ./evals/register.mjs evals/run.ts",
"eval:builder": "node --experimental-transform-types --no-warnings --import ./evals/register.mjs evals/builder/run.ts",
"eval:skill": "node --experimental-transform-types --no-warnings --import ./evals/register.mjs evals/skillbuilder/run.ts",
diff --git a/src/Recorder.tsx b/src/Recorder.tsx
index b301c1f..bf409c4 100644
--- a/src/Recorder.tsx
+++ b/src/Recorder.tsx
@@ -741,7 +741,7 @@ function SensitiveModelRow({
return
;
}
if (status.ocr === "ready") {
- return
;
+ return
;
}
const failed = status.ocr === "error";
return (