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
+18 -13
View File
@@ -1,6 +1,6 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { resolveLocale } from '@shared/i18n';
import { DEFAULT_SETTINGS, type AppSettings, type ColumnKey } from '@shared/types';
import { clampUiScale, DEFAULT_SETTINGS, type AppSettings, type ColumnKey } from '@shared/types';
import { I18nService } from './i18n.service';
import { RpcService } from './rpc.service';
@@ -14,7 +14,7 @@ export class SettingsService {
readonly iconColors = signal<AppSettings['iconColors']>(DEFAULT_SETTINGS.iconColors);
readonly columns = signal<ColumnKey[]>(DEFAULT_SETTINGS.columns);
readonly speedUnit = signal<AppSettings['speedUnit']>(DEFAULT_SETTINGS.speedUnit);
readonly textSize = signal<AppSettings['textSize']>(DEFAULT_SETTINGS.textSize);
readonly uiScale = signal<number>(DEFAULT_SETTINGS.uiScale);
readonly transferMode = signal<AppSettings['transferMode']>(DEFAULT_SETTINGS.transferMode);
readonly transferConcurrency = signal(DEFAULT_SETTINGS.transferConcurrency);
readonly autoClearCompleted = signal(DEFAULT_SETTINGS.autoClearCompleted);
@@ -58,7 +58,7 @@ export class SettingsService {
this.iconColors.set(settings.iconColors);
this.columns.set(settings.columns);
this.speedUnit.set(settings.speedUnit);
this.textSize.set(settings.textSize);
this.uiScale.set(settings.uiScale);
this.transferMode.set(settings.transferMode);
this.transferConcurrency.set(settings.transferConcurrency);
this.autoClearCompleted.set(settings.autoClearCompleted);
@@ -70,7 +70,7 @@ export class SettingsService {
this.applyTheme();
this.applyDarkTone();
this.applyAccent();
this.applyTextSize();
this.applyUiScale();
this.applyLocale();
}
@@ -140,9 +140,9 @@ export class SettingsService {
void this.persist();
}
setTextSize(size: AppSettings['textSize']): void {
this.textSize.set(size);
this.applyTextSize();
setUiScale(scale: number): void {
this.uiScale.set(clampUiScale(scale));
this.applyUiScale();
void this.persist();
}
@@ -204,12 +204,17 @@ export class SettingsService {
}
}
private applyTextSize(): void {
const size = this.textSize();
if (size === 'default') {
delete document.documentElement.dataset['text'];
/** The whole interface scale is ONE declaration: every size in the app is
* rem, so moving the root font-size moves both skins in lockstep. 1 = the
* browser-default 16px root; delete-when-default keeps the pristine state
* attribute-free like the other axes. buildPreload mirrors this for a
* flash-free first paint. */
private applyUiScale(): void {
const scale = this.uiScale();
if (scale === 1) {
document.documentElement.style.removeProperty('font-size');
} else {
document.documentElement.dataset['text'] = size;
document.documentElement.style.fontSize = `${16 * scale}px`;
}
}
@@ -227,7 +232,7 @@ export class SettingsService {
iconColors: this.iconColors(),
columns: this.columns(),
speedUnit: this.speedUnit(),
textSize: this.textSize(),
uiScale: this.uiScale(),
transferMode: this.transferMode(),
transferConcurrency: this.transferConcurrency(),
autoClearCompleted: this.autoClearCompleted(),