diff --git a/CLAUDE.md b/CLAUDE.md index 5603fe13..099beae2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -318,6 +318,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Gesture control** (camera hand-tracking overlay, opt-in, default OFF): `CODEMAN_GESTURE=1` makes the feature *available*; `gestureControlEnabled` turns it on. The bundle is injected by `renderIndexHtml` only when enabled, which is why that method is `async` and reads settings with `readSettings(true)` (a fresh read: a post-save reload lands inside the 2s cache TTL and would otherwise render the pre-toggle state). **Source lives in `packages/gesture-control/`; edit there, run `npm run build:gesture`, and commit the regenerated bundle** because dev serves the committed bundle with no runtime bundler. The MediaPipe wasm + model are fetched separately and gitignored. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision`. → [architecture-invariants#gesture-control-the-source-package](docs/architecture-invariants.md#gesture-control-the-source-package) +**Terminal font weight** (`terminalFontWeight` / `terminalFontWeightBold`, per-device, default = xterm's own `normal`/`bold`): bold text on the theme's default foreground carries exactly ONE cue, the weight step. Claude Code marks its markdown bold with a bare `ESC[1m` and no colour change, and xterm substitutes a bright colour for bold only when the foreground is a palette index 0-7, so the substitution never fires there. A two-face family keeps that step small and 400 stays 400 whatever family is chosen, which is why the NORMAL slot is settable at all. `CodemanTerminalFont.resolveWeights()` (constants.js, pure) resolves both slots, each against **its own** xterm default, so an unset bold weight can never inherit `normal`. ⚠️ **The `@font-face` descriptor, not the file, is what the browser synthesizes from**: `fonts/jetbrains-mono-variable.woff2` carries a `wght` axis of 100-800, and while `styles.css` declared it `400 700` every weight below 400 rendered identically to 400 and 800 identically to 700 — measured — so the setting was a no-op for anyone without Fira Code or Cascadia Code installed, which is most installs. It is declared `100 800`; re-narrowing it silently guts the feature (`test/terminal-font-weight.test.ts` pins the range). ⚠️ A live save must reach **both echo overlays** (`refreshFont()` — they cache `terminal.options.fontWeight` and paint it into their spans, so typed characters otherwise keep the old weight, most visible on a phone) **and open Agent Teams panes** (they read their options at construction, exactly like `applyTerminalSkin()` propagates). ⚠️ `_awaitTerminalFont()` is deliberately untouched: `CharSizeService` measures through the CSS `font` shorthand, which RESETS the weight, so the measured face is always the 400 one and a weighted descriptor would ask for nothing new. + **Theme skins / branding / i18n**: `skin` selects a palette via `data-skin` on ``, applied by an **inline pre-paint script** in `index.html` reading `localStorage['codeman:skin']` to avoid a flash of wrong theme. ⚠️ A skin is **four things that must stay in sync**, and missing any one degrades silently: the `html[data-skin="…"]` token block in `styles.css`, the xterm ANSI palette in `terminal-ui.js`, the pre-paint allowlist, and the Settings picker (both in `index.html`). `test/skin-themes.test.ts` is the static guard. Light skins additionally need `color-scheme: light` and xterm `minimumContrastRatio: 4.5`, and `applyTerminalSkin()` must call the local-echo overlay's `refreshFont()` because it caches the terminal fg/bg. `displayName` changes user-facing browser branding only and must NEVER rename npm package, CLI, API, storage, CSS, or protocol identifiers. `language` (`en`/`zh-CN`) keeps English as the canonical source so live switching stays reversible. User display names flow through `textContent`/attribute APIs and the server title's HTML escaper, never `innerHTML`. → [architecture-invariants#theme-skins](docs/architecture-invariants.md#theme-skins) **Foldable settings identity**: responsive layout is width-driven via `MobileDetection.getDeviceType()`, but the localStorage namespace uses `MobileDetection.isHandheldDevice()` so an unfolded Android foldable keeps `codeman-app-settings-mobile`. ⚠️ Do not switch per-device settings namespaces from instantaneous viewport width: a posture-triggered WebView reload would lose opt-in UI. Regression profile: `OPPO Find N5 (unfolded)` in `test/mobile/devices.ts`. → [architecture-invariants#foldable-settings-identity](docs/architecture-invariants.md#foldable-settings-identity) diff --git a/src/web/public/constants.js b/src/web/public/constants.js index a9f677a3..be9780b0 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -709,6 +709,54 @@ function resolveTerminalFontFamily(custom) { return `${families.join(', ')}, ${TERMINAL_FONT_DEFAULT_STACK}`; } +/** + * xterm's own defaults for the two weight slots, one per slot. + * + * They are deliberately kept apart rather than collapsed into a single + * fallback: handing the bold slot `normal` (or the normal slot `bold`) would + * turn an unset setting into a visible change, which is exactly the thing this + * feature exists to make controllable. + */ +const TERMINAL_FONT_WEIGHT_DEFAULTS = { fontWeight: 'normal', fontWeightBold: 'bold' }; + +/** + * Resolve ONE weight slot against xterm's validation rules. + * + * xterm accepts a number in 1..1000, or one of its own keyword/numeric-string + * options, and silently falls back to the slot default for anything else + * (`OptionsService._sanitizeAndValidateOption`). Resolving here instead means a + * stored value the picker does not list (a hand-set 350) still reaches the + * terminal, while junk in localStorage never does. + */ +function resolveTerminalFontWeightSlot(value, fallback) { + if (value === 'normal' || value === 'bold') return value; + const numeric = typeof value === 'number' ? value : typeof value === 'string' ? Number(value.trim()) : NaN; + if (!Number.isFinite(numeric) || numeric < 1 || numeric > 1000) return fallback; + return Math.round(numeric); +} + +/** + * Resolve both xterm weight slots from the per-device settings blob. + * + * Bold text on the theme's default foreground carries exactly ONE cue, the + * weight step: Claude Code marks its markdown bold with a bare `ESC[1m` and no + * colour, and xterm's bold-to-bright substitution only fires for palette + * indices 0-7, so it never applies to default-foreground text. A family that + * ships only a regular and a bold face keeps that step small, and 400 stays + * 400 whatever family is chosen — lowering the NORMAL weight is the only way + * to widen the gap. + */ +function resolveTerminalFontWeights(settings) { + const s = settings && typeof settings === 'object' ? settings : {}; + return { + fontWeight: resolveTerminalFontWeightSlot(s.terminalFontWeight, TERMINAL_FONT_WEIGHT_DEFAULTS.fontWeight), + fontWeightBold: resolveTerminalFontWeightSlot( + s.terminalFontWeightBold, + TERMINAL_FONT_WEIGHT_DEFAULTS.fontWeightBold + ), + }; +} + // --------------------------------------------------------------------------- // Auto Copy (copy-on-select). Pure decision, so every guard below is testable // without a terminal, a clipboard, or a browser. @@ -809,6 +857,8 @@ if (typeof window !== 'undefined') { window.CodemanTerminalFont = { DEFAULT_STACK: TERMINAL_FONT_DEFAULT_STACK, resolve: resolveTerminalFontFamily, + WEIGHT_DEFAULTS: TERMINAL_FONT_WEIGHT_DEFAULTS, + resolveWeights: resolveTerminalFontWeights, }; } diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 4c47e7fa..23605d2f 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -335,6 +335,12 @@ 'Terminal font': '终端字体', 'Prepended to the built-in stack, so fallbacks (including bundled Nerd Font symbols) keep working. Must be installed on this device. Leave empty for the default.': '置于内置字体栈之前,回退字体(包括内置的 Nerd Font 图标)仍然生效。需已安装在本设备上。留空使用默认值。', + 'Normal font weight': '常规字重', + 'Weight for ordinary terminal text. Lowering it widens the step up to bold, which for a family shipping only a regular and a bold face is the only cue bold text carries. Needs a family with faces at that weight; the bundled font covers 100 to 800.': + '终端普通文本的字重。调低可拉大与粗体之间的差距;对于只提供常规和粗体两种字形的字体,这一差距是粗体文本唯一的视觉提示。需要字体具备该字重的字形,内置字体覆盖 100 至 800。', + 'Bold font weight': '粗体字重', + 'Weight for bold terminal text. Only useful with a family carrying something heavier than its bold face.': + '终端粗体文本的字重。仅当字体提供比其粗体更重的字形时才有意义。', 'Local Echo': '本地回显', 'CJK Input': '中日韩输入', 'Extended Keyboard Bar': '扩展键盘栏', diff --git a/src/web/public/index.html b/src/web/public/index.html index 5ddb00fa..8408c30b 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1704,6 +1704,42 @@ +
+
+ Normal font weight + Weight for ordinary terminal text. Lowering it widens the step up to bold, which for a family shipping only a regular and a bold face is the only cue bold text carries. Needs a family with faces at that weight; the bundled font covers 100 to 800. +
+ +
+
+
+ Bold font weight + Weight for bold terminal text. Only useful with a family carrying something heavier than its bold face. +
+ +
diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index b3b6fbf9..80e6ce83 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -2282,10 +2282,14 @@ Object.assign(CodemanApp.prototype, { return; } + const fontSettings = this.loadAppSettingsFromStorage?.() || {}; const terminal = new Terminal({ theme: { ...window.codemanCurrentXtermTheme() }, minimumContrastRatio: window.codemanCurrentSkinIsLight() ? 4.5 : 1, - fontFamily: window.CodemanTerminalFont.resolve(this.loadAppSettingsFromStorage?.().terminalFontFamily), + fontFamily: window.CodemanTerminalFont.resolve(fontSettings.terminalFontFamily), + // A pane opened after a weight change must match the main terminal; + // one open across the change is repainted by applyTerminalFontWeights(). + ...window.CodemanTerminalFont.resolveWeights(fontSettings), fontSize: 12, lineHeight: 1.2, cursorBlink: true, diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 2af5adf4..0633fc90 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -335,6 +335,32 @@ Object.assign(CodemanApp.prototype, { // App Settings Modal // ═══════════════════════════════════════════════════════════════ + /** + * Point one terminal-weight select at its stored value. + * + * A stored value the picker does not list (a hand-set 350, or a weight from a + * build whose options differ) is ADDED to the select rather than dropped: + * otherwise `select.value = '350'` silently selects nothing, the next save + * reads back '' and the setting resets itself just for having been opened. + * Empty means "use xterm's default for this slot". + */ + populateTerminalFontWeight(select, value) { + if (!select) return; + const stored = value === undefined || value === null ? '' : String(value).trim(); + if (stored && !Array.from(select.options).some((opt) => opt.value === stored)) { + const extra = document.createElement('option'); + extra.value = stored; + extra.textContent = `${stored} (custom)`; + select.appendChild(extra); + } + select.value = stored; + }, + + /** Read one terminal-weight select back. '' means default; the resolver in constants.js validates. */ + readTerminalFontWeight(select) { + return select?.value.trim() || ''; + }, + openAppSettings() { // Load current settings const settings = this.loadAppSettingsFromStorage(); @@ -411,6 +437,11 @@ Object.assign(CodemanApp.prototype, { // a way to read, so it is opt-in rather than a default anyone has to discover. document.getElementById('appSettingsAutoCopySelection').checked = settings.autoCopySelection === true; document.getElementById('appSettingsTerminalFont').value = settings.terminalFontFamily || ''; + this.populateTerminalFontWeight(document.getElementById('appSettingsTerminalFontWeight'), settings.terminalFontWeight); + this.populateTerminalFontWeight( + document.getElementById('appSettingsTerminalFontWeightBold'), + settings.terminalFontWeightBold + ); document.getElementById('appSettingsTerminalWheelLocal').checked = settings.terminalWheelLocalScrollback ?? defaults.terminalWheelLocalScrollback ?? false; document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false; @@ -2091,6 +2122,10 @@ Object.assign(CodemanApp.prototype, { localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked, autoCopySelection: document.getElementById('appSettingsAutoCopySelection').checked, terminalFontFamily: document.getElementById('appSettingsTerminalFont').value.trim(), + terminalFontWeight: this.readTerminalFontWeight(document.getElementById('appSettingsTerminalFontWeight')), + terminalFontWeightBold: this.readTerminalFontWeight( + document.getElementById('appSettingsTerminalFontWeightBold') + ), terminalWheelLocalScrollback: document.getElementById('appSettingsTerminalWheelLocal').checked, cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked, webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked, @@ -2145,6 +2180,7 @@ Object.assign(CodemanApp.prototype, { this.saveAppSettingsToStorage(settings); this._updateLocalEchoState(); this.applyTerminalFontFamily?.(settings.terminalFontFamily); + this.applyTerminalFontWeights?.(settings); // A real OFF→ON flip of the WebGL toggle retires the GPU-stall auto-fallback // marker so the next reload actually re-tries WebGL. Only the transition @@ -2302,6 +2338,11 @@ Object.assign(CodemanApp.prototype, { // Per-device by nature (the font must exist on the device) and absent // from SettingsUpdateSchema (.strict()) — sending it would 400 the PUT. terminalFontFamily: _tff, + // Same two reasons: which weights a family can actually render is a + // property of the faces installed on THIS device, and neither key is + // declared in the .strict() schema. + terminalFontWeight: _tfw, + terminalFontWeightBold: _tfwb, // Per-device header/toolbar button toggles — client-only, and absent from // SettingsUpdateSchema (.strict()), so sending them would 400 the PUT. showSessionButton: _ssb, @@ -3076,7 +3117,7 @@ Object.assign(CodemanApp.prototype, { 'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents', 'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar', 'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled', - 'terminalFontFamily', + 'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold', 'language', 'terminalWheelLocalScrollback', 'autoCopySelection', diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 49b26810..aedba597 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -9,11 +9,20 @@ font-weight: 400 800; src: url('fonts/manrope-variable.woff2') format('woff2'); } +/* The declared range is what the browser will synthesize from, NOT what the + file carries: the woff2 behind this has a `wght` axis of 100 to 800, and a + narrower descriptor clamps it — at `400 700`, requesting 100, 200 or 300 + rendered identically to 400 and 800 identically to 700. The terminal + font-weight settings would then be a no-op for anyone on the bundled face, + which is most installs (the two families ahead of it in the stack, Fira Code + and Cascadia Code, exist only if the user installed them). Nothing in the + stylesheets asks for a monospace weight outside 400-700, so widening it + changes nothing that renders today. */ @font-face { font-family: 'JetBrains Mono'; font-style: normal; font-display: swap; - font-weight: 400 700; + font-weight: 100 800; src: url('fonts/jetbrains-mono-variable.woff2') format('woff2'); } /* Icons-only per-glyph fallback for the terminal (Symbols Nerd Font Mono, MIT, diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 4919d0b0..11b7ea54 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -248,9 +248,13 @@ Object.assign(CodemanApp.prototype, { const scrollback = Number.isFinite(stored) && stored > 0 ? Math.max(stored, DEFAULT_SCROLLBACK) : DEFAULT_SCROLLBACK; this._destroyKeyCode229Recovery(); + const fontSettings = this.loadAppSettingsFromStorage?.() || {}; this.terminal = new Terminal({ theme: { ...window.codemanCurrentXtermTheme() }, - fontFamily: window.CodemanTerminalFont.resolve(this.loadAppSettingsFromStorage?.().terminalFontFamily), + fontFamily: window.CodemanTerminalFont.resolve(fontSettings.terminalFontFamily), + // Both weight slots, each falling back to xterm's own default for that + // slot, so an untouched install renders exactly as it always has. + ...window.CodemanTerminalFont.resolveWeights(fontSettings), // Use smaller font on mobile to fit more columns (prevents wrapping of Claude's status line) fontSize: MobileDetection.getDeviceType() === 'mobile' ? 10 : 14, lineHeight: 1.2, @@ -4905,6 +4909,57 @@ Object.assign(CodemanApp.prototype, { this._predictiveEcho?.refreshFont(); }, + /** + * Apply the per-device terminal font WEIGHTS to every live xterm. + * + * Both slots move together because they are resolved together: passing a + * settings blob with neither key restores xterm's own `normal`/`bold`. + * + * Three things follow the option write and none of them is optional: + * + * - The echo overlays cache `terminal.options.fontWeight` and paint it into + * their spans, so without `refreshFont()` the characters being typed keep + * the old weight while the rest of the screen changes. Most visible on a + * phone, where local echo is on by default. + * - Agent Teams panes read these options at CONSTRUCTION, so a live save + * would otherwise leave an open pane at the old weight beside a repainted + * terminal. `applyTerminalSkin()` propagates for the same reason. + * - The refit is insurance. `CharSizeService` measures through the CSS + * `font` shorthand, which resets the weight, so the canvas path measures + * the 400 face at every setting — but `DomRenderer` styles its measure + * span with `span:not(.xterm-bold)`, where the normal weight really can + * move the cell. + */ + applyTerminalFontWeights(settings) { + const { fontWeight, fontWeightBold } = window.CodemanTerminalFont.resolveWeights(settings); + if (!this.terminal) return; + if (this.terminal.options.fontWeight === fontWeight && this.terminal.options.fontWeightBold === fontWeightBold) { + return; + } + this.terminal.options.fontWeight = fontWeight; + this.terminal.options.fontWeightBold = fontWeightBold; + // Same race as a live family change: the option write makes xterm + // re-measure immediately, against a face the browser may not have + // rasterized yet. Re-arm the wait and fit again once it settles; the fit + // below still runs, so the terminal is never left unfitted. + this._terminalFontReady = this._awaitTerminalFont().then(() => { + if (this.terminal?.options?.fontWeight === fontWeight) this.fitAddon?.fit(); + }); + this.fitAddon?.fit(); + this._localEchoOverlay?.refreshFont(); + this._predictiveEcho?.refreshFont(); + for (const [, entry] of this.teammateTerminals || []) { + if (!entry?.terminal) continue; + entry.terminal.options.fontWeight = fontWeight; + entry.terminal.options.fontWeightBold = fontWeightBold; + try { + entry.fitAddon?.fit(); + } catch { + /* pane not laid out yet — its own resize observer refits it */ + } + } + }, + loadFontSize() { const saved = localStorage.getItem('codeman-font-size'); if (saved) { diff --git a/test/terminal-font-weight.test.ts b/test/terminal-font-weight.test.ts new file mode 100644 index 00000000..14605ef7 --- /dev/null +++ b/test/terminal-font-weight.test.ts @@ -0,0 +1,225 @@ +/** + * @fileoverview Terminal font weight: live apply, and the plumbing around it. + * + * Bold text on the theme's default foreground carries exactly ONE cue, the + * weight step. Claude Code marks its markdown bold with a bare `ESC[1m` and no + * colour change, and xterm substitutes a bright colour for bold only when the + * foreground is a palette index below 8, so nothing else distinguishes it. A + * family that ships only a regular and a bold face keeps that step small, and + * 400 stays 400 whatever family is picked — which is why the NORMAL slot is + * settable at all. + * + * Three things are pinned here because each fails silently: + * + * - A live save reaches the echo overlays and the Agent Teams panes. Both + * cache the weight (the overlays paint it into their spans, the panes read + * their options at construction), so without the propagation the characters + * being typed, or a pane left open across the save, keep the old weight + * beside a repainted terminal. + * - An unchanged save is a no-op, so opening and closing App Settings does not + * churn the terminal. + * - The bundled face is declared over its full axis. The `@font-face` + * descriptor, not the file, is what the browser synthesizes from: at + * `400 700` every weight below 400 renders identically to 400, so the + * setting would be inert for anyone without Fira Code or Cascadia Code + * installed. + * + * Loaded via `vm` with a stubbed context (no jsdom — jsdom is broken on this + * box; see connection-indicator.test.ts), matching terminal-font-settle.test.ts. + */ +import { readFileSync } from 'node:fs'; +import { performance } from 'node:perf_hooks'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; + +const publicDir = resolve(import.meta.dirname, '../src/web/public'); + +function loadTerminalMixin(): Record { + const FakeCodemanApp = function () {} as unknown as { prototype: Record }; + const context = vm.createContext({ + console, + performance, + setTimeout, + clearTimeout, + setInterval: vi.fn(), + clearInterval: vi.fn(), + requestAnimationFrame: vi.fn(), + CodemanApp: FakeCodemanApp, + window: { addEventListener: vi.fn(), removeEventListener: vi.fn() }, + document: undefined, + }); + const constants = readFileSync(resolve(publicDir, 'constants.js'), 'utf8'); + const source = readFileSync(resolve(publicDir, 'terminal-ui.js'), 'utf8'); + vm.runInContext(`${constants}\n${source}`, context); + // constants.js publishes CodemanTerminalFont onto the context's window, which + // is the one the mixin closes over. + return FakeCodemanApp.prototype; +} + +const mixin = loadTerminalMixin(); + +function fakeTerminal(options: Record = {}) { + return { options: { fontFamily: '"JetBrains Mono"', fontSize: 14, ...options } }; +} + +function makeApp(opts: { teammates?: number; terminal?: ReturnType | null } = {}) { + const fit = vi.fn(); + const teammateFits: ReturnType[] = []; + const teammateTerminals = new Map; fitAddon: unknown }>(); + for (let i = 0; i < (opts.teammates ?? 0); i++) { + const teammateFit = vi.fn(); + teammateFits.push(teammateFit); + teammateTerminals.set(`agent-${i}`, { terminal: fakeTerminal(), fitAddon: { fit: teammateFit } }); + } + const app = { + applyTerminalFontWeights: mixin.applyTerminalFontWeights, + _awaitTerminalFont: vi.fn(() => Promise.resolve()), + terminal: opts.terminal === undefined ? fakeTerminal() : opts.terminal, + fitAddon: { fit }, + teammateTerminals, + _localEchoOverlay: { refreshFont: vi.fn() }, + _predictiveEcho: { refreshFont: vi.fn() }, + _terminalFontReady: null as unknown, + }; + return { app, fit, teammateFits, teammateTerminals }; +} + +describe('applyTerminalFontWeights', () => { + it('writes both slots to the live terminal', () => { + const { app, fit } = makeApp(); + + (app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({ + terminalFontWeight: '300', + terminalFontWeightBold: '800', + }); + + expect(app.terminal?.options.fontWeight).toBe(300); + expect(app.terminal?.options.fontWeightBold).toBe(800); + expect(fit).toHaveBeenCalled(); + }); + + it('refreshes the echo overlays, which cache the weight and paint it', () => { + // Without this the characters being typed keep the old weight while the + // rest of the screen changes — most visible on a phone, where local echo + // is on by default. + const { app } = makeApp(); + + (app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({ + terminalFontWeight: '300', + }); + + expect(app._localEchoOverlay.refreshFont).toHaveBeenCalledTimes(1); + expect(app._predictiveEcho.refreshFont).toHaveBeenCalledTimes(1); + }); + + it('reaches Agent Teams panes, which read their options at construction', () => { + const { app, teammateTerminals, teammateFits } = makeApp({ teammates: 2 }); + + (app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({ + terminalFontWeight: '300', + terminalFontWeightBold: '800', + }); + + for (const [, entry] of teammateTerminals) { + expect(entry.terminal.options.fontWeight).toBe(300); + expect(entry.terminal.options.fontWeightBold).toBe(800); + } + for (const teammateFit of teammateFits) expect(teammateFit).toHaveBeenCalled(); + }); + + it('restores xterm’s own defaults when the setting is cleared', () => { + const { app } = makeApp({ terminal: fakeTerminal({ fontWeight: 300, fontWeightBold: 800 }) }); + + (app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({}); + + expect(app.terminal?.options.fontWeight).toBe('normal'); + expect(app.terminal?.options.fontWeightBold).toBe('bold'); + }); + + it('does nothing when neither slot changed', () => { + // saveAppSettings runs on every close of the modal. + const { app, fit } = makeApp({ terminal: fakeTerminal({ fontWeight: 300, fontWeightBold: 'bold' }) }); + + (app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({ + terminalFontWeight: 300, + }); + + expect(fit).not.toHaveBeenCalled(); + expect(app._localEchoOverlay.refreshFont).not.toHaveBeenCalled(); + expect(app._awaitTerminalFont).not.toHaveBeenCalled(); + }); + + it('re-arms the font wait, so a fit lands once the face is rasterized', () => { + const { app } = makeApp(); + + (app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({ + terminalFontWeight: '300', + }); + + expect(app._awaitTerminalFont).toHaveBeenCalledTimes(1); + expect(app._terminalFontReady).toBeInstanceOf(Promise); + }); + + it('survives a terminal that does not exist yet', () => { + const { app } = makeApp({ terminal: null }); + + expect(() => + (app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({ + terminalFontWeight: '300', + }) + ).not.toThrow(); + }); +}); + +describe('bundled terminal face', () => { + const styles = readFileSync(resolve(publicDir, 'styles.css'), 'utf8'); + + it('is declared over its full weight axis, not xterm’s default span', () => { + // The woff2 carries a `wght` axis of 100 to 800. A narrower @font-face + // descriptor CLAMPS it: at `400 700`, 100/200/300 all render identically to + // 400 and 800 identically to 700, so the settings above would be a no-op + // for every install without Fira Code or Cascadia Code. + const face = styles.slice(styles.indexOf("font-family: 'JetBrains Mono'")); + const declared = /font-weight:\s*(\d+)\s+(\d+)/.exec(face.slice(0, face.indexOf('}'))); + expect(declared, 'the bundled mono face must declare a weight RANGE').not.toBeNull(); + expect(Number(declared![1])).toBeLessThanOrEqual(100); + expect(Number(declared![2])).toBeGreaterThanOrEqual(800); + }); +}); + +describe('terminal font weight settings plumbing', () => { + const settingsUi = readFileSync(resolve(publicDir, 'settings-ui.js'), 'utf8'); + const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8'); + const keys = ['terminalFontWeight', 'terminalFontWeightBold'] as const; + + it('offers both selects with a Default entry and the 100-900 steps', () => { + for (const id of ['appSettingsTerminalFontWeight', 'appSettingsTerminalFontWeightBold']) { + const start = html.indexOf(`