mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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 changes no colour, and xterm substitutes a bright colour for bold only when the foreground is a palette index 0-7, so the substitution never fires for default-foreground text. A family shipping only a regular and a bold face keeps that step small (measured on Consolas: glyph ink rises from 14.25% to 16.57%), and picking a different family does not help, because 400 stays 400 whatever the family. Lowering the NORMAL weight is the only way to widen the gap. Two per-device settings beside "Terminal font" in the Font group, each defaulting to xterm's own value for its slot, so an untouched install renders exactly as it did before. Both thread into the main terminal and the Agent Teams panes, and apply on save without a reload. The bundled face had to be unclamped in the same change or the settings would look broken on a stock install. fonts/jetbrains-mono-variable.woff2 carries a wght axis of 100 to 800, but styles.css declared the face `400 700`, and the descriptor is what the browser synthesizes from: at that range 100, 200 and 300 rendered identically to 400 and 800 identically to 700 (measured in headless Chromium, both directions). The two families ahead of it in the default stack, Fira Code and Cascadia Code, exist only if the user installed them, so for most installs "normal = 300" would have been a no-op. Declared `100 800`, every step is distinct: 61%, 77% and 90% of the ink at 400, and 800 adds ~14% over 700. Nothing in the stylesheets asks for a monospace weight outside 400-700, so widening it changes nothing that rendered before. Details that are easy to get wrong and are pinned by tests: - Each slot falls back to its OWN xterm default, so an unset bold weight can never inherit `normal` and become a visible change. - A live save refreshes both echo overlays. They cache terminal.options.fontWeight and paint it into their spans, so without it 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. - A live save reaches open Agent Teams panes, which read their options at construction, exactly as applyTerminalSkin() propagates its own. - A stored weight the picker does not list (a hand-set 350) is added to the select rather than dropped, so merely opening App Settings cannot reset it. - _awaitTerminalFont() is 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 request nothing new. Verified end to end in a headless browser against a live server: the save reaches the running terminal with no reload, the settings PUT stays 200 (both keys are display keys and are stripped before it, since SettingsUpdateSchema is strict), the value survives a reload, and the painted terminal really changes weight with the bundled font (lit-pixel ink 0.83 / 0.95 / 1.00 / 1.13 / 1.21 at 100 / 300 / default / 700 / 800). Proposed and analysed by @irisitymichaelgrundberg in discussion #403. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
118 lines
5.1 KiB
TypeScript
118 lines
5.1 KiB
TypeScript
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import vm from 'node:vm';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
function loadFontHelper() {
|
|
const context = vm.createContext({ window: {}, globalThis: {} });
|
|
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
|
vm.runInContext(source, context, { filename: 'constants.js' });
|
|
return (
|
|
context.window as {
|
|
CodemanTerminalFont: {
|
|
DEFAULT_STACK: string;
|
|
resolve: (custom?: unknown) => string;
|
|
WEIGHT_DEFAULTS: { fontWeight: string; fontWeightBold: string };
|
|
resolveWeights: (settings?: unknown) => { fontWeight: string | number; fontWeightBold: string | number };
|
|
};
|
|
}
|
|
).CodemanTerminalFont;
|
|
}
|
|
|
|
const font = loadFontHelper();
|
|
|
|
describe('CodemanTerminalFont', () => {
|
|
it('returns the default stack for empty or missing input', () => {
|
|
expect(font.resolve(undefined)).toBe(font.DEFAULT_STACK);
|
|
expect(font.resolve('')).toBe(font.DEFAULT_STACK);
|
|
expect(font.resolve(' ')).toBe(font.DEFAULT_STACK);
|
|
expect(font.resolve(42)).toBe(font.DEFAULT_STACK);
|
|
});
|
|
|
|
it('keeps the symbols fallback ahead of monospace in the default stack', () => {
|
|
const symbolsAt = font.DEFAULT_STACK.indexOf('"Symbols Nerd Font Mono"');
|
|
const monoAt = font.DEFAULT_STACK.lastIndexOf('monospace');
|
|
expect(symbolsAt).toBeGreaterThan(-1);
|
|
expect(monoAt).toBeGreaterThan(symbolsAt);
|
|
});
|
|
|
|
it('prepends a custom family and preserves the full default stack', () => {
|
|
expect(font.resolve('Menlo')).toBe(`Menlo, ${font.DEFAULT_STACK}`);
|
|
});
|
|
|
|
it('quotes names that need quoting for CSS', () => {
|
|
expect(font.resolve('JetBrainsMono Nerd Font')).toBe(`"JetBrainsMono Nerd Font", ${font.DEFAULT_STACK}`);
|
|
});
|
|
|
|
it('normalizes already-quoted input instead of double-quoting', () => {
|
|
expect(font.resolve('"JetBrainsMono Nerd Font"')).toBe(`"JetBrainsMono Nerd Font", ${font.DEFAULT_STACK}`);
|
|
expect(font.resolve("'Iosevka Term'")).toBe(`"Iosevka Term", ${font.DEFAULT_STACK}`);
|
|
});
|
|
|
|
it('accepts a comma-separated list', () => {
|
|
expect(font.resolve('Iosevka, MesloLGS NF')).toBe(`Iosevka, "MesloLGS NF", ${font.DEFAULT_STACK}`);
|
|
});
|
|
|
|
it('drops generic families so they cannot shadow the symbols fallback', () => {
|
|
expect(font.resolve('monospace')).toBe(font.DEFAULT_STACK);
|
|
expect(font.resolve('Hack, monospace')).toBe(`Hack, ${font.DEFAULT_STACK}`);
|
|
});
|
|
});
|
|
|
|
describe('CodemanTerminalFont.resolveWeights', () => {
|
|
const DEFAULTS = { fontWeight: 'normal', fontWeightBold: 'bold' };
|
|
|
|
it("leaves an untouched install on xterm's own defaults", () => {
|
|
// The whole feature has to be invisible until someone asks for it.
|
|
expect(font.resolveWeights(undefined)).toEqual(DEFAULTS);
|
|
expect(font.resolveWeights({})).toEqual(DEFAULTS);
|
|
expect(font.resolveWeights('nonsense')).toEqual(DEFAULTS);
|
|
expect(font.WEIGHT_DEFAULTS).toEqual(DEFAULTS);
|
|
});
|
|
|
|
it('resolves each slot independently', () => {
|
|
expect(font.resolveWeights({ terminalFontWeight: 300 })).toEqual({ fontWeight: 300, fontWeightBold: 'bold' });
|
|
expect(font.resolveWeights({ terminalFontWeightBold: 800 })).toEqual({ fontWeight: 'normal', fontWeightBold: 800 });
|
|
expect(font.resolveWeights({ terminalFontWeight: 300, terminalFontWeightBold: 800 })).toEqual({
|
|
fontWeight: 300,
|
|
fontWeightBold: 800,
|
|
});
|
|
});
|
|
|
|
it('never hands one slot the other slot default', () => {
|
|
// A shared fallback would turn an unset bold weight into a visible change.
|
|
for (const bad of [null, '', ' ', 'heavy', NaN, {}, [], true]) {
|
|
expect(font.resolveWeights({ terminalFontWeight: bad, terminalFontWeightBold: bad })).toEqual(DEFAULTS);
|
|
}
|
|
});
|
|
|
|
it('accepts the string values the select stores', () => {
|
|
expect(font.resolveWeights({ terminalFontWeight: '300', terminalFontWeightBold: '900' })).toEqual({
|
|
fontWeight: 300,
|
|
fontWeightBold: 900,
|
|
});
|
|
});
|
|
|
|
it('keeps a hand-set weight the picker does not offer', () => {
|
|
expect(font.resolveWeights({ terminalFontWeight: '350' }).fontWeight).toBe(350);
|
|
});
|
|
|
|
it("passes through xterm's own keywords unchanged", () => {
|
|
expect(font.resolveWeights({ terminalFontWeight: 'bold', terminalFontWeightBold: 'normal' })).toEqual({
|
|
fontWeight: 'bold',
|
|
fontWeightBold: 'normal',
|
|
});
|
|
});
|
|
|
|
it('rejects what xterm would reject, rather than letting it silently reset the slot', () => {
|
|
// OptionsService accepts a number in 1..1000 and falls back otherwise, so
|
|
// anything outside that range must resolve to the default here instead of
|
|
// reaching the terminal and being swapped out underneath the setting.
|
|
expect(font.resolveWeights({ terminalFontWeight: 0 }).fontWeight).toBe('normal');
|
|
expect(font.resolveWeights({ terminalFontWeight: -400 }).fontWeight).toBe('normal');
|
|
expect(font.resolveWeights({ terminalFontWeight: 1001 }).fontWeight).toBe('normal');
|
|
expect(font.resolveWeights({ terminalFontWeight: 1000 }).fontWeight).toBe(1000);
|
|
expect(font.resolveWeights({ terminalFontWeight: 1 }).fontWeight).toBe(1);
|
|
});
|
|
});
|