Scale stage 2: continuous uiScale setting + the (-) 100% (+) stepper

AppSettings.textSize (compact|default|large) becomes uiScale: number, 0.5-4
in 0.1 steps, default 1. One shared clampUiScale (next to the
effectiveConcurrency precedent) defines "valid" for the store, the service
and the stepper: clamp to range, snap to the step grid, strip float noise,
and map NaN/Infinity to 1.

- SettingsStore.load migrates legacy files (compact/default/large ->
  0.9/1/1.2, never over an explicit uiScale) and clamps ALWAYS, not just on
  migration — buildPreload writes this value into every window's root
  font-size, so a hand-edited settings.json (uiScale: 100) must not be able
  to render the UI unusable. Verified against 100, 0.001, -3, NaN, Infinity
  and float noise; migration verified against a real legacy file.
- SettingsService: applyUiScale sets the root font-size (16 * scale, removed
  entirely at 1 per the delete-when-default rule). Rides the existing
  settingsChanged broadcast, so open editor/manager windows follow live.
- buildPreload bakes the same font-size so the first paint is already scaled
  — without it every window opens at 100% and snaps.
- Both tab-bars: the 3-button Interface row becomes the stepper. Clicking the
  percentage resets to 100% (tooltip says so). i18n: compact/default/large
  dropped, smaller/larger/reset added, all 7 locales.

Verified live, win7 + modern: scale 1 is pixel-identical to the pre-work
baseline (opaque interior diff exactly 0); 0.5x is a real dense mode (~2x
rows); 2x scales title bar, tabs, rows, icons and controls together with text
contained in its boxes — which is what stage 1c's 7.css bridge bought; 4x is
enormous but coherent, and the win7 title-bar controls stay on-screen at the
default window size. The known caveat stands: no minimum window size exists,
so 4x in a small window is legal but cramped.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-16 18:09:59 +02:00
co-authored by Claude
parent 9f2a076e08
commit f101257f20
14 changed files with 131 additions and 55 deletions
+16 -2
View File
@@ -1,8 +1,11 @@
import fs from "node:fs/promises";
import path from "node:path";
import { DEFAULT_SETTINGS, type AppSettings } from "../../shared/types";
import { clampUiScale, DEFAULT_SETTINGS, type AppSettings } from "../../shared/types";
import { appDataDir } from "./paths";
/** The retired 3-tier axis, mapped onto the scale that replaced it. */
const LEGACY_TEXT_SIZE: Record<string, number> = { compact: 0.9, default: 1, large: 1.2 };
/** App settings persisted bun-side (WKWebView localStorage on views:// is unreliable). */
export class SettingsStore {
private readonly file = path.join(appDataDir(), "settings.json");
@@ -10,7 +13,18 @@ export class SettingsStore {
async load(): Promise<AppSettings> {
try {
const data = JSON.parse(await fs.readFile(this.file, "utf8"));
return { ...DEFAULT_SETTINGS, ...data };
const settings: AppSettings = { ...DEFAULT_SETTINGS, ...data };
// migrate the retired textSize tier into the scale that replaced it —
// but never over an explicit uiScale (a file carrying both has already
// been migrated once and then adjusted)
if (data.uiScale === undefined && typeof data.textSize === "string") {
settings.uiScale = LEGACY_TEXT_SIZE[data.textSize] ?? 1;
}
// clamp ALWAYS, not just on migration: buildPreload writes this value
// into every window's root font-size, so a hand-edited settings.json
// (uiScale: 100, "2", NaN) must not be able to render the UI unusable
settings.uiScale = clampUiScale(Number(settings.uiScale));
return settings;
} catch {
return { ...DEFAULT_SETTINGS };
}
+6 -1
View File
@@ -59,7 +59,12 @@ export function buildPreload(
if (settings.theme !== "system") dataset["theme"] = settings.theme;
if (settings.darkTone !== "dark") dataset["darkTone"] = settings.darkTone;
if (settings.accent !== "graphite") dataset["accent"] = settings.accent;
if (settings.textSize !== "default") dataset["text"] = settings.textSize;
// interface scale: mirrors SettingsService.applyUiScale so the FIRST paint
// is already scaled — without this every window opens at 100% and snaps.
// Written as an inline style, not a data attribute: the scale is continuous.
if (settings.uiScale !== 1) {
script += `document.documentElement.style.fontSize=${JSON.stringify(`${16 * settings.uiScale}px`)};`;
}
for (const [key, value] of Object.entries(dataset)) {
script += `document.documentElement.dataset[${JSON.stringify(key)}]=${JSON.stringify(value)};`;
}