From f101257f20e91a1702ab5133d743aab0de6ded29 Mon Sep 17 00:00:00 2001 From: Jokin Date: Sun, 16 Aug 2026 18:09:59 +0200 Subject: [PATCH] Scale stage 2: continuous uiScale setting + the (-) 100% (+) stepper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/bun/store/settings.ts | 18 +++++++- src/bun/windows.ts | 7 +++- src/shared/i18n/de.ts | 2 +- src/shared/i18n/en.ts | 2 +- src/shared/i18n/es.ts | 2 +- src/shared/i18n/eu.ts | 2 +- src/shared/i18n/fr.ts | 2 +- src/shared/i18n/ja.ts | 2 +- src/shared/i18n/zh.ts | 2 +- src/shared/types.ts | 28 +++++++++++-- ui/src/app/core/settings.service.ts | 31 ++++++++------ ui/src/app/skins/7like/tab-bar/tab-bar.ts | 43 +++++++++++++------- ui/src/app/skins/shadlike/shadlike.css | 2 +- ui/src/app/skins/shadlike/tab-bar/tab-bar.ts | 43 +++++++++++++------- 14 files changed, 131 insertions(+), 55 deletions(-) diff --git a/src/bun/store/settings.ts b/src/bun/store/settings.ts index efbac68..d7cefb5 100644 --- a/src/bun/store/settings.ts +++ b/src/bun/store/settings.ts @@ -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 = { 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 { 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 }; } diff --git a/src/bun/windows.ts b/src/bun/windows.ts index 65e6b3a..92e92ce 100644 --- a/src/bun/windows.ts +++ b/src/bun/windows.ts @@ -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)};`; } diff --git a/src/shared/i18n/de.ts b/src/shared/i18n/de.ts index ac530b8..911d772 100644 --- a/src/shared/i18n/de.ts +++ b/src/shared/i18n/de.ts @@ -53,7 +53,7 @@ export const de = { teal: "Petrol", }, fileIcons: { label: "Datei-Symbole", plain: "Einfach", multi: "Farbig" }, - interface: { label: "Oberfläche", compact: "Kompakt", default: "Normal", large: "Groß" }, + interface: { label: "Oberfläche", smaller: "Kleiner", larger: "Größer", reset: "Auf 100% zurücksetzen" }, speedUnit: { label: "Geschwindigkeitseinheit" }, transfers: { label: "Übertragungen", single: "Einzeln", multi: "Mehrere" }, atOnce: "Gleichzeitig", diff --git a/src/shared/i18n/en.ts b/src/shared/i18n/en.ts index 0cb4170..68cf2be 100644 --- a/src/shared/i18n/en.ts +++ b/src/shared/i18n/en.ts @@ -57,7 +57,7 @@ export const en = { teal: "Teal", }, fileIcons: { label: "File icons", plain: "Plain", multi: "Multi" }, - interface: { label: "Interface", compact: "Compact", default: "Default", large: "Large" }, + interface: { label: "Interface", smaller: "Smaller", larger: "Larger", reset: "Reset to 100%" }, speedUnit: { label: "Speed unit" }, transfers: { label: "Transfers", single: "Single", multi: "Multi" }, atOnce: "At once", diff --git a/src/shared/i18n/es.ts b/src/shared/i18n/es.ts index 6dae928..f371359 100644 --- a/src/shared/i18n/es.ts +++ b/src/shared/i18n/es.ts @@ -53,7 +53,7 @@ export const es = { teal: "Turquesa", }, fileIcons: { label: "Iconos", plain: "Simple", multi: "Color" }, - interface: { label: "Interfaz", compact: "Compacta", default: "Normal", large: "Grande" }, + interface: { label: "Interfaz", smaller: "Más pequeña", larger: "Más grande", reset: "Restablecer al 100%" }, speedUnit: { label: "Unidad de velocidad" }, transfers: { label: "Transferencias", single: "Una", multi: "Varias" }, atOnce: "A la vez", diff --git a/src/shared/i18n/eu.ts b/src/shared/i18n/eu.ts index 495d4af..166c6ec 100644 --- a/src/shared/i18n/eu.ts +++ b/src/shared/i18n/eu.ts @@ -55,7 +55,7 @@ export const eu = { teal: "Turkesa", }, fileIcons: { label: "Fitxategi-ikonoak", plain: "Soila", multi: "Koloretsua" }, - interface: { label: "Interfazea", compact: "Trinkoa", default: "Lehenetsia", large: "Handia" }, + interface: { label: "Interfazea", smaller: "Txikiagoa", larger: "Handiagoa", reset: "Berrezarri %100era" }, speedUnit: { label: "Abiadura-unitatea" }, transfers: { label: "Transferentziak", single: "Banaka", multi: "Anitz" }, atOnce: "Aldi berean", diff --git a/src/shared/i18n/fr.ts b/src/shared/i18n/fr.ts index 5a227fc..444b6fb 100644 --- a/src/shared/i18n/fr.ts +++ b/src/shared/i18n/fr.ts @@ -53,7 +53,7 @@ export const fr = { teal: "Sarcelle", }, fileIcons: { label: "Icônes", plain: "Simple", multi: "Couleur" }, - interface: { label: "Interface", compact: "Compacte", default: "Normale", large: "Grande" }, + interface: { label: "Interface", smaller: "Plus petite", larger: "Plus grande", reset: "Réinitialiser à 100%" }, speedUnit: { label: "Unité de vitesse" }, transfers: { label: "Transferts", single: "Un", multi: "Plusieurs" }, atOnce: "Simultanés", diff --git a/src/shared/i18n/ja.ts b/src/shared/i18n/ja.ts index eb02d58..e7a561e 100644 --- a/src/shared/i18n/ja.ts +++ b/src/shared/i18n/ja.ts @@ -54,7 +54,7 @@ export const ja = { teal: "ティール", }, fileIcons: { label: "ファイルアイコン", plain: "シンプル", multi: "カラー" }, - interface: { label: "インターフェース", compact: "コンパクト", default: "標準", large: "大" }, + interface: { label: "インターフェース", smaller: "縮小", larger: "拡大", reset: "100%に戻す" }, speedUnit: { label: "速度の単位" }, transfers: { label: "転送", single: "1件ずつ", multi: "複数" }, atOnce: "同時数", diff --git a/src/shared/i18n/zh.ts b/src/shared/i18n/zh.ts index 355f641..6485dd4 100644 --- a/src/shared/i18n/zh.ts +++ b/src/shared/i18n/zh.ts @@ -54,7 +54,7 @@ export const zh = { teal: "青色", }, fileIcons: { label: "文件图标", plain: "简约", multi: "彩色" }, - interface: { label: "界面", compact: "紧凑", default: "默认", large: "大" }, + interface: { label: "界面", smaller: "缩小", larger: "放大", reset: "重置为100%" }, speedUnit: { label: "速度单位" }, transfers: { label: "传输", single: "单个", multi: "多个" }, atOnce: "同时", diff --git a/src/shared/types.ts b/src/shared/types.ts index 4fac792..fb7acd2 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -100,8 +100,13 @@ export interface AppSettings { /** Visible optional file-list columns (both panels; Name always shows). */ columns: ColumnKey[]; speedUnit: "MBps" | "Mbps"; - /** Size of the panel interface: file list, path bar, header controls. */ - textSize: "compact" | "default" | "large"; + /** + * Interface scale factor, 0.5–4 in 0.1 steps (shown as 50%–400%). Works by + * setting the root font-size — every size in the UI is rem, so one lever + * scales BOTH skins whole. Replaced the 3-tier `textSize` + * (compact|default|large); SettingsStore.load migrates old files. + */ + uiScale: number; /** Run transfer jobs one at a time, or several in parallel. */ transferMode: "single" | "multi"; /** How many jobs run at once when transferMode is "multi". */ @@ -127,7 +132,7 @@ export const DEFAULT_SETTINGS: AppSettings = { iconColors: "multi", columns: ["size", "mtime"], speedUnit: "MBps", - textSize: "default", + uiScale: 1, transferMode: "single", transferConcurrency: 3, autoClearCompleted: true, @@ -136,6 +141,23 @@ export const DEFAULT_SETTINGS: AppSettings = { // autoRefresh: true, }; +export const UI_SCALE_MIN = 0.5; +export const UI_SCALE_MAX = 4; +export const UI_SCALE_STEP = 0.1; + +/** + * Clamp + snap a scale factor to the legal range and step grid. Shared by the + * store (a hand-edited settings.json must not be able to render the UI + * unusable), the settings service and the stepper — one definition of "valid", + * same as effectiveConcurrency below. + */ +export function clampUiScale(value: number): number { + if (!Number.isFinite(value)) return 1; + const clamped = Math.min(UI_SCALE_MAX, Math.max(UI_SCALE_MIN, value)); + // snap to the step grid; toFixed strips float noise (0.7000000000000001 -> 0.7) + return Number((Math.round(clamped / UI_SCALE_STEP) * UI_SCALE_STEP).toFixed(2)); +} + export function effectiveConcurrency(settings: AppSettings): number { if (settings.transferMode === "single") return 1; return Math.max(1, Math.min(8, Math.round(settings.transferConcurrency) || 1)); diff --git a/ui/src/app/core/settings.service.ts b/ui/src/app/core/settings.service.ts index fba3708..36f48d5 100644 --- a/ui/src/app/core/settings.service.ts +++ b/ui/src/app/core/settings.service.ts @@ -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(DEFAULT_SETTINGS.iconColors); readonly columns = signal(DEFAULT_SETTINGS.columns); readonly speedUnit = signal(DEFAULT_SETTINGS.speedUnit); - readonly textSize = signal(DEFAULT_SETTINGS.textSize); + readonly uiScale = signal(DEFAULT_SETTINGS.uiScale); readonly transferMode = signal(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(), diff --git a/ui/src/app/skins/7like/tab-bar/tab-bar.ts b/ui/src/app/skins/7like/tab-bar/tab-bar.ts index 8e70dd0..746050b 100644 --- a/ui/src/app/skins/7like/tab-bar/tab-bar.ts +++ b/ui/src/app/skins/7like/tab-bar/tab-bar.ts @@ -30,7 +30,6 @@ const SPEED_UNITS = [ { value: 'MBps', label: 'MB/s' }, { value: 'Mbps', label: 'Mbps' }, ] as const; -const TEXT_SIZES = ['compact', 'default', 'large'] as const; const ICON_COLOR_MODES = ['plain', 'multi'] as const; const TRANSFER_MODES = ['single', 'multi'] as const; const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const; @@ -276,20 +275,35 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const; +
{{ i18n.t('options.interface.label') }} -
- @for (size of textSizes; track size) { - - } +
+ + +
@@ -474,7 +488,8 @@ export class TabBar { readonly accents = ACCENTS; readonly iconColorModes = ICON_COLOR_MODES; readonly speedUnits = SPEED_UNITS; - readonly textSizes = TEXT_SIZES; + /** "100%" for the stepper readout; the value itself lives in settings. */ + readonly scalePercent = computed(() => `${Math.round(this.settings.uiScale() * 100)}%`); readonly transferModes = TRANSFER_MODES; readonly concurrencyChoices = CONCURRENCY_CHOICES; diff --git a/ui/src/app/skins/shadlike/shadlike.css b/ui/src/app/skins/shadlike/shadlike.css index d63bdd6..7fe667d 100644 --- a/ui/src/app/skins/shadlike/shadlike.css +++ b/ui/src/app/skins/shadlike/shadlike.css @@ -5,7 +5,7 @@ Intentionally near-empty, and that is not an oversight. This skin IS the shared foundation: the OKLCH token system, `@theme inline`, - the data-text interface sizes and the accent axis all live in ui/src/styles.css + the panel-chrome metrics and the accent axis all live in ui/src/styles.css because both skins consume them, and everything else the modern look needs is a Tailwind utility written inline in its templates. There is no third-party framework to bridge and no cascade inversion to undo, so there is simply diff --git a/ui/src/app/skins/shadlike/tab-bar/tab-bar.ts b/ui/src/app/skins/shadlike/tab-bar/tab-bar.ts index e4efbbe..17331de 100644 --- a/ui/src/app/skins/shadlike/tab-bar/tab-bar.ts +++ b/ui/src/app/skins/shadlike/tab-bar/tab-bar.ts @@ -30,7 +30,6 @@ const SPEED_UNITS = [ { value: 'MBps', label: 'MB/s' }, { value: 'Mbps', label: 'Mbps' }, ] as const; -const TEXT_SIZES = ['compact', 'default', 'large'] as const; const ICON_COLOR_MODES = ['plain', 'multi'] as const; const TRANSFER_MODES = ['single', 'multi'] as const; const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const; @@ -282,20 +281,35 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
+
{{ i18n.t('options.interface.label') }} -
- @for (size of textSizes; track size) { - - } +
+ + +
@@ -479,7 +493,8 @@ export class TabBar { readonly accents = ACCENTS; readonly iconColorModes = ICON_COLOR_MODES; readonly speedUnits = SPEED_UNITS; - readonly textSizes = TEXT_SIZES; + /** "100%" for the stepper readout; the value itself lives in settings. */ + readonly scalePercent = computed(() => `${Math.round(this.settings.uiScale() * 100)}%`); readonly transferModes = TRANSFER_MODES; readonly concurrencyChoices = CONCURRENCY_CHOICES;