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 fs from "node:fs/promises";
import path from "node:path"; 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"; 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). */ /** App settings persisted bun-side (WKWebView localStorage on views:// is unreliable). */
export class SettingsStore { export class SettingsStore {
private readonly file = path.join(appDataDir(), "settings.json"); private readonly file = path.join(appDataDir(), "settings.json");
@@ -10,7 +13,18 @@ export class SettingsStore {
async load(): Promise<AppSettings> { async load(): Promise<AppSettings> {
try { try {
const data = JSON.parse(await fs.readFile(this.file, "utf8")); 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 { } catch {
return { ...DEFAULT_SETTINGS }; return { ...DEFAULT_SETTINGS };
} }
+6 -1
View File
@@ -59,7 +59,12 @@ export function buildPreload(
if (settings.theme !== "system") dataset["theme"] = settings.theme; if (settings.theme !== "system") dataset["theme"] = settings.theme;
if (settings.darkTone !== "dark") dataset["darkTone"] = settings.darkTone; if (settings.darkTone !== "dark") dataset["darkTone"] = settings.darkTone;
if (settings.accent !== "graphite") dataset["accent"] = settings.accent; 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)) { for (const [key, value] of Object.entries(dataset)) {
script += `document.documentElement.dataset[${JSON.stringify(key)}]=${JSON.stringify(value)};`; script += `document.documentElement.dataset[${JSON.stringify(key)}]=${JSON.stringify(value)};`;
} }
+1 -1
View File
@@ -53,7 +53,7 @@ export const de = {
teal: "Petrol", teal: "Petrol",
}, },
fileIcons: { label: "Datei-Symbole", plain: "Einfach", multi: "Farbig" }, 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" }, speedUnit: { label: "Geschwindigkeitseinheit" },
transfers: { label: "Übertragungen", single: "Einzeln", multi: "Mehrere" }, transfers: { label: "Übertragungen", single: "Einzeln", multi: "Mehrere" },
atOnce: "Gleichzeitig", atOnce: "Gleichzeitig",
+1 -1
View File
@@ -57,7 +57,7 @@ export const en = {
teal: "Teal", teal: "Teal",
}, },
fileIcons: { label: "File icons", plain: "Plain", multi: "Multi" }, 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" }, speedUnit: { label: "Speed unit" },
transfers: { label: "Transfers", single: "Single", multi: "Multi" }, transfers: { label: "Transfers", single: "Single", multi: "Multi" },
atOnce: "At once", atOnce: "At once",
+1 -1
View File
@@ -53,7 +53,7 @@ export const es = {
teal: "Turquesa", teal: "Turquesa",
}, },
fileIcons: { label: "Iconos", plain: "Simple", multi: "Color" }, 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" }, speedUnit: { label: "Unidad de velocidad" },
transfers: { label: "Transferencias", single: "Una", multi: "Varias" }, transfers: { label: "Transferencias", single: "Una", multi: "Varias" },
atOnce: "A la vez", atOnce: "A la vez",
+1 -1
View File
@@ -55,7 +55,7 @@ export const eu = {
teal: "Turkesa", teal: "Turkesa",
}, },
fileIcons: { label: "Fitxategi-ikonoak", plain: "Soila", multi: "Koloretsua" }, 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" }, speedUnit: { label: "Abiadura-unitatea" },
transfers: { label: "Transferentziak", single: "Banaka", multi: "Anitz" }, transfers: { label: "Transferentziak", single: "Banaka", multi: "Anitz" },
atOnce: "Aldi berean", atOnce: "Aldi berean",
+1 -1
View File
@@ -53,7 +53,7 @@ export const fr = {
teal: "Sarcelle", teal: "Sarcelle",
}, },
fileIcons: { label: "Icônes", plain: "Simple", multi: "Couleur" }, 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" }, speedUnit: { label: "Unité de vitesse" },
transfers: { label: "Transferts", single: "Un", multi: "Plusieurs" }, transfers: { label: "Transferts", single: "Un", multi: "Plusieurs" },
atOnce: "Simultanés", atOnce: "Simultanés",
+1 -1
View File
@@ -54,7 +54,7 @@ export const ja = {
teal: "ティール", teal: "ティール",
}, },
fileIcons: { label: "ファイルアイコン", plain: "シンプル", multi: "カラー" }, fileIcons: { label: "ファイルアイコン", plain: "シンプル", multi: "カラー" },
interface: { label: "インターフェース", compact: "コンパクト", default: "標準", large: "大" }, interface: { label: "インターフェース", smaller: "縮小", larger: "拡大", reset: "100%に戻す" },
speedUnit: { label: "速度の単位" }, speedUnit: { label: "速度の単位" },
transfers: { label: "転送", single: "1件ずつ", multi: "複数" }, transfers: { label: "転送", single: "1件ずつ", multi: "複数" },
atOnce: "同時数", atOnce: "同時数",
+1 -1
View File
@@ -54,7 +54,7 @@ export const zh = {
teal: "青色", teal: "青色",
}, },
fileIcons: { label: "文件图标", plain: "简约", multi: "彩色" }, fileIcons: { label: "文件图标", plain: "简约", multi: "彩色" },
interface: { label: "界面", compact: "紧凑", default: "默认", large: "大" }, interface: { label: "界面", smaller: "缩小", larger: "放大", reset: "重置为100%" },
speedUnit: { label: "速度单位" }, speedUnit: { label: "速度单位" },
transfers: { label: "传输", single: "单个", multi: "多个" }, transfers: { label: "传输", single: "单个", multi: "多个" },
atOnce: "同时", atOnce: "同时",
+25 -3
View File
@@ -100,8 +100,13 @@ export interface AppSettings {
/** Visible optional file-list columns (both panels; Name always shows). */ /** Visible optional file-list columns (both panels; Name always shows). */
columns: ColumnKey[]; columns: ColumnKey[];
speedUnit: "MBps" | "Mbps"; 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. */ /** Run transfer jobs one at a time, or several in parallel. */
transferMode: "single" | "multi"; transferMode: "single" | "multi";
/** How many jobs run at once when transferMode is "multi". */ /** How many jobs run at once when transferMode is "multi". */
@@ -127,7 +132,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
iconColors: "multi", iconColors: "multi",
columns: ["size", "mtime"], columns: ["size", "mtime"],
speedUnit: "MBps", speedUnit: "MBps",
textSize: "default", uiScale: 1,
transferMode: "single", transferMode: "single",
transferConcurrency: 3, transferConcurrency: 3,
autoClearCompleted: true, autoClearCompleted: true,
@@ -136,6 +141,23 @@ export const DEFAULT_SETTINGS: AppSettings = {
// autoRefresh: true, // 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 { export function effectiveConcurrency(settings: AppSettings): number {
if (settings.transferMode === "single") return 1; if (settings.transferMode === "single") return 1;
return Math.max(1, Math.min(8, Math.round(settings.transferConcurrency) || 1)); return Math.max(1, Math.min(8, Math.round(settings.transferConcurrency) || 1));
+18 -13
View File
@@ -1,6 +1,6 @@
import { Injectable, computed, inject, signal } from '@angular/core'; import { Injectable, computed, inject, signal } from '@angular/core';
import { resolveLocale } from '@shared/i18n'; 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 { I18nService } from './i18n.service';
import { RpcService } from './rpc.service'; import { RpcService } from './rpc.service';
@@ -14,7 +14,7 @@ export class SettingsService {
readonly iconColors = signal<AppSettings['iconColors']>(DEFAULT_SETTINGS.iconColors); readonly iconColors = signal<AppSettings['iconColors']>(DEFAULT_SETTINGS.iconColors);
readonly columns = signal<ColumnKey[]>(DEFAULT_SETTINGS.columns); readonly columns = signal<ColumnKey[]>(DEFAULT_SETTINGS.columns);
readonly speedUnit = signal<AppSettings['speedUnit']>(DEFAULT_SETTINGS.speedUnit); 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 transferMode = signal<AppSettings['transferMode']>(DEFAULT_SETTINGS.transferMode);
readonly transferConcurrency = signal(DEFAULT_SETTINGS.transferConcurrency); readonly transferConcurrency = signal(DEFAULT_SETTINGS.transferConcurrency);
readonly autoClearCompleted = signal(DEFAULT_SETTINGS.autoClearCompleted); readonly autoClearCompleted = signal(DEFAULT_SETTINGS.autoClearCompleted);
@@ -58,7 +58,7 @@ export class SettingsService {
this.iconColors.set(settings.iconColors); this.iconColors.set(settings.iconColors);
this.columns.set(settings.columns); this.columns.set(settings.columns);
this.speedUnit.set(settings.speedUnit); this.speedUnit.set(settings.speedUnit);
this.textSize.set(settings.textSize); this.uiScale.set(settings.uiScale);
this.transferMode.set(settings.transferMode); this.transferMode.set(settings.transferMode);
this.transferConcurrency.set(settings.transferConcurrency); this.transferConcurrency.set(settings.transferConcurrency);
this.autoClearCompleted.set(settings.autoClearCompleted); this.autoClearCompleted.set(settings.autoClearCompleted);
@@ -70,7 +70,7 @@ export class SettingsService {
this.applyTheme(); this.applyTheme();
this.applyDarkTone(); this.applyDarkTone();
this.applyAccent(); this.applyAccent();
this.applyTextSize(); this.applyUiScale();
this.applyLocale(); this.applyLocale();
} }
@@ -140,9 +140,9 @@ export class SettingsService {
void this.persist(); void this.persist();
} }
setTextSize(size: AppSettings['textSize']): void { setUiScale(scale: number): void {
this.textSize.set(size); this.uiScale.set(clampUiScale(scale));
this.applyTextSize(); this.applyUiScale();
void this.persist(); void this.persist();
} }
@@ -204,12 +204,17 @@ export class SettingsService {
} }
} }
private applyTextSize(): void { /** The whole interface scale is ONE declaration: every size in the app is
const size = this.textSize(); * rem, so moving the root font-size moves both skins in lockstep. 1 = the
if (size === 'default') { * browser-default 16px root; delete-when-default keeps the pristine state
delete document.documentElement.dataset['text']; * 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 { } else {
document.documentElement.dataset['text'] = size; document.documentElement.style.fontSize = `${16 * scale}px`;
} }
} }
@@ -227,7 +232,7 @@ export class SettingsService {
iconColors: this.iconColors(), iconColors: this.iconColors(),
columns: this.columns(), columns: this.columns(),
speedUnit: this.speedUnit(), speedUnit: this.speedUnit(),
textSize: this.textSize(), uiScale: this.uiScale(),
transferMode: this.transferMode(), transferMode: this.transferMode(),
transferConcurrency: this.transferConcurrency(), transferConcurrency: this.transferConcurrency(),
autoClearCompleted: this.autoClearCompleted(), autoClearCompleted: this.autoClearCompleted(),
+29 -14
View File
@@ -30,7 +30,6 @@ const SPEED_UNITS = [
{ value: 'MBps', label: 'MB/s' }, { value: 'MBps', label: 'MB/s' },
{ value: 'Mbps', label: 'Mbps' }, { value: 'Mbps', label: 'Mbps' },
] as const; ] as const;
const TEXT_SIZES = ['compact', 'default', 'large'] as const;
const ICON_COLOR_MODES = ['plain', 'multi'] as const; const ICON_COLOR_MODES = ['plain', 'multi'] as const;
const TRANSFER_MODES = ['single', 'multi'] as const; const TRANSFER_MODES = ['single', 'multi'] as const;
const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] 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;
</div> </div>
</div> </div>
<!-- Interface scale: a continuous stepper, 50%..400% in 10-point
steps. One lever moves the whole UI (root font-size; everything
is rem), which is also why this popover itself grows with it. -->
<div class="mt-2.5 flex items-center justify-between gap-2"> <div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.interface.label') }}</span> <span class="text-xs text-muted-foreground">{{ i18n.t('options.interface.label') }}</span>
<div class="flex rounded-md border border-border p-0.5"> <div class="flex items-center rounded-md border border-border p-0.5">
@for (size of textSizes; track size) { <button
<button (click)="settings.setUiScale(settings.uiScale() - 0.1)"
(click)="settings.setTextSize(size)" [disabled]="settings.uiScale() <= 0.5"
class="rounded px-2 py-0.5 text-[0.6875rem]" [title]="i18n.t('options.interface.smaller')"
[class.bg-accent]="settings.textSize() === size" class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
[class.text-foreground]="settings.textSize() === size" >
[class.text-muted-foreground]="settings.textSize() !== size" −
> </button>
{{ i18n.t('options.interface.' + size) }} <button
</button> (click)="settings.setUiScale(1)"
} [title]="i18n.t('options.interface.reset')"
class="w-11 rounded px-1 py-0.5 text-center text-[0.6875rem] tabular-nums hover:bg-accent"
>
{{ scalePercent() }}
</button>
<button
(click)="settings.setUiScale(settings.uiScale() + 0.1)"
[disabled]="settings.uiScale() >= 4"
[title]="i18n.t('options.interface.larger')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
>
+
</button>
</div> </div>
</div> </div>
@@ -474,7 +488,8 @@ export class TabBar {
readonly accents = ACCENTS; readonly accents = ACCENTS;
readonly iconColorModes = ICON_COLOR_MODES; readonly iconColorModes = ICON_COLOR_MODES;
readonly speedUnits = SPEED_UNITS; 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 transferModes = TRANSFER_MODES;
readonly concurrencyChoices = CONCURRENCY_CHOICES; readonly concurrencyChoices = CONCURRENCY_CHOICES;
+1 -1
View File
@@ -5,7 +5,7 @@
Intentionally near-empty, and that is not an oversight. Intentionally near-empty, and that is not an oversight.
This skin IS the shared foundation: the OKLCH token system, `@theme inline`, 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 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 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 framework to bridge and no cascade inversion to undo, so there is simply
+29 -14
View File
@@ -30,7 +30,6 @@ const SPEED_UNITS = [
{ value: 'MBps', label: 'MB/s' }, { value: 'MBps', label: 'MB/s' },
{ value: 'Mbps', label: 'Mbps' }, { value: 'Mbps', label: 'Mbps' },
] as const; ] as const;
const TEXT_SIZES = ['compact', 'default', 'large'] as const;
const ICON_COLOR_MODES = ['plain', 'multi'] as const; const ICON_COLOR_MODES = ['plain', 'multi'] as const;
const TRANSFER_MODES = ['single', 'multi'] as const; const TRANSFER_MODES = ['single', 'multi'] as const;
const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] 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;
</div> </div>
</div> </div>
<!-- Interface scale: a continuous stepper, 50%..400% in 10-point
steps. One lever moves the whole UI (root font-size; everything
is rem), which is also why this popover itself grows with it. -->
<div class="mt-2.5 flex items-center justify-between gap-2"> <div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.interface.label') }}</span> <span class="text-xs text-muted-foreground">{{ i18n.t('options.interface.label') }}</span>
<div class="flex rounded-md border border-border p-0.5"> <div class="flex items-center rounded-md border border-border p-0.5">
@for (size of textSizes; track size) { <button
<button (click)="settings.setUiScale(settings.uiScale() - 0.1)"
(click)="settings.setTextSize(size)" [disabled]="settings.uiScale() <= 0.5"
class="rounded px-2 py-0.5 text-[0.6875rem]" [title]="i18n.t('options.interface.smaller')"
[class.bg-accent]="settings.textSize() === size" class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
[class.text-foreground]="settings.textSize() === size" >
[class.text-muted-foreground]="settings.textSize() !== size" −
> </button>
{{ i18n.t('options.interface.' + size) }} <button
</button> (click)="settings.setUiScale(1)"
} [title]="i18n.t('options.interface.reset')"
class="w-11 rounded px-1 py-0.5 text-center text-[0.6875rem] tabular-nums hover:bg-accent"
>
{{ scalePercent() }}
</button>
<button
(click)="settings.setUiScale(settings.uiScale() + 0.1)"
[disabled]="settings.uiScale() >= 4"
[title]="i18n.t('options.interface.larger')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
>
+
</button>
</div> </div>
</div> </div>
@@ -479,7 +493,8 @@ export class TabBar {
readonly accents = ACCENTS; readonly accents = ACCENTS;
readonly iconColorModes = ICON_COLOR_MODES; readonly iconColorModes = ICON_COLOR_MODES;
readonly speedUnits = SPEED_UNITS; 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 transferModes = TRANSFER_MODES;
readonly concurrencyChoices = CONCURRENCY_CHOICES; readonly concurrencyChoices = CONCURRENCY_CHOICES;