a73x

042aad9a

feat(web): theme and font settings, uploaded not baked

a73x   2026-08-21 14:14

Commit message
feat(web): theme and font settings, uploaded not baked

A ghostty theme file is ~20 lines of `key = value`, so the browser reads
one directly: no theme library in the binary, no Zig, no protocol. The
panel lives inside the add tile because the wall keeps ONE standing
control, which also makes it unreachable while zoomed — the shade covers
it — so an open panel can never hold the keyboard the terminal owns.

Colour is paint-local by construction: a cell carries a palette index and
colorOf invents the hex at paint time, so a theme is repainted and never
sent. Font is the exception that talks back — it decides how many cells
fit the zoom box, and a zoomed tile claims that grid for every client on
the session, which is why the panel says so out loud.

Three things a browser found that the unit tests could not:

- A light theme over a dark one left the old colours in a lattice along
  every cell boundary. A scaled wall tile fills cells at fractional device
  pixels, so each antialiased seam keeps a blend of what was underneath.
  repaintForSettings clears before it repaints.
- The canvas element's background is terminal ground, not chrome: it shows
  before the first frame and through the backing store's rounding edge.
  Hardcoded black flashed through a light theme; it reads --term-bg now.
- Zoom hides the panel from the pointer, not from Tab. A key answered by
  both a focused field and the session typed a font size into a shell.
  The keydown guard is now the whole add tile, not just its one input.

Refusal is a feature: a file with no colours is rejected rather than
applied as an empty theme, so a PNG and a whole ghostty config land in the
same honest path — the latter reporting what it skipped. Font size is
clamped because a zero-width cell makes zoomCols Infinity, which reaches
the daemon as a resize to no columns at all.

Not covered: apps querying colours over OSC 10/11/4 still get the daemon's
answer, not the browser's theme. That is the replicated engine, not a gap
here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gWRPQchfcNB6CNLMtTAC5

web/index.html
Old New
@@ -44,7 +44,11 @@
44 that times devicePixelRatio, so glyphs rasterize at device resolution 44 that times devicePixelRatio, so glyphs rasterize at device resolution
45 instead of being a downscaled bitmap. No width:100% here: it would 45 instead of being a downscaled bitmap. No width:100% here: it would
46 stretch the backing store back out and undo exactly that. */ 46 stretch the backing store back out and undo exactly that. */
47 .tile canvas { display: block; background: #000; } 47 /* The canvas background is TERMINAL ground, not chrome: it is what
48 shows before the first frame lands and along the sub-pixel edge the
49 backing-store rounding leaves. mux.js publishes the theme's
50 background here, so a light theme does not flash black. */
51 .tile canvas { display: block; background: var(--term-bg, #000); }
48 /* The zoomed tile: same element, promoted. Keys go here. 52 /* The zoomed tile: same element, promoted. Keys go here.
49 INSET, not inset:0 — the ring of shade left visible around it is the 53 INSET, not inset:0 — the ring of shade left visible around it is the
50 only way back out (clicking it unzooms), and a tile covering the 54 only way back out (clicking it unzooms), and a tile covering the
@@ -70,6 +74,32 @@
70 color: var(--fg); font: inherit; text-align: center; 74 color: var(--fg); font: inherit; text-align: center;
71 } 75 }
72 .tile.add .err { color: #e07a7a; font-size: 11px; } 76 .tile.add .err { color: #e07a7a; font-size: 11px; }
77 /* Settings live INSIDE the add tile rather than in a toolbar of their
78 own: the wall keeps its one standing control. It costs nothing to
79 make them unreachable while zoomed, too — the shade covers the wall —
80 so an open panel can never be holding the keyboard the terminal owns. */
81 .tile.add .settings { width: 90%; font-size: 11px; }
82 .tile.add .settings summary { color: var(--dim); cursor: pointer; list-style: none; }
83 .tile.add .settings summary::-webkit-details-marker { display: none; }
84 .tile.add .settings summary:hover { color: var(--fg); }
85 .tile.add .settings .row {
86 display: flex; align-items: center; gap: 6px; margin-top: 6px;
87 }
88 .tile.add .settings .row > span { color: var(--dim); }
89 .tile.add .settings input[type="text"],
90 .tile.add .settings input[type="number"] {
91 flex: 1; min-width: 0; padding: 2px 4px; border: 1px solid #4a5261;
92 border-radius: 3px; background: transparent; color: var(--fg);
93 font: inherit; font-size: 11px; text-align: left;
94 }
95 .tile.add .settings input[type="number"] { flex: 0 0 4.5em; }
96 .tile.add .settings button {
97 padding: 2px 6px; border: 1px solid #4a5261; border-radius: 3px;
98 background: transparent; color: var(--fg); font: inherit;
99 font-size: 11px; cursor: pointer;
100 }
101 .tile.add .settings .note, .tile.add .settings .caveat { color: var(--dim); font-size: 11px; }
102 .tile.add .settings .note.bad { color: #e07a7a; }
73 .tile header .close, .tile header .spawn { 103 .tile header .close, .tile header .spawn {
74 padding: 0 6px; border: 0; background: transparent; color: var(--dim); 104 padding: 0 6px; border: 0; background: transparent; color: var(--dim);
75 font: inherit; cursor: pointer; 105 font: inherit; cursor: pointer;
web/mux.js
Old New
@@ -64,22 +64,48 @@ const TERMINAL = new Set(['stuck', 'exited', 'full']);
64 // the hub under a live page never flashes 'gone'. 64 // the hub under a live page never flashes 'gone'.
65 const GONE_AFTER_FAILURES = 4; 65 const GONE_AFTER_FAILURES = 4;
66 66
67 const FONT = '14px ui-monospace, monospace'; 67 // --- settings: the page's theme and font ---
68 const DEFAULT_FG = '#c8ccd4', DEFAULT_BG = '#000000'; 68 //
69 69 // Both belong to the browser alone. Colour never reaches either VT engine:
70 // The xterm 256 palette, computed once. 70 // a cell carries a palette INDEX and colorOf invents the hex at paint
71 const PALETTE = (() => { 71 // time, so a theme is repainted, never sent. Font is the exception that
72 const base = [ 72 // talks back — it decides how many cells fit the zoom box, which is a
73 '#000000', '#cd0000', '#00cd00', '#cdcd00', '#0000ee', '#cd00cd', '#00cdcd', '#e5e5e5', 73 // resize like any other, and only a zoomed tile is entitled to make it.
74 '#7f7f7f', '#ff0000', '#00ff00', '#ffff00', '#5c5cff', '#ff00ff', '#00ffff', '#ffffff', 74 const SETTINGS_KEY = 'mux.settings.v1';
75 ]; 75 const DEFAULT_FONT_FAMILY = 'ui-monospace, monospace';
76 const p = base.slice(); 76 const DEFAULT_FONT_SIZE = 14;
77 // A cell must stay measurable: at size 0 the zoom box divides by a
78 // zero-width cell, zoomCols is Infinity, and setUint16 hands the daemon a
79 // resize to no columns at all.
80 const FONT_SIZE_MIN = 6, FONT_SIZE_MAX = 72;
81 // A theme is a couple of dozen short lines. The cap is what stops a
82 // dropped PNG or a log file from being read into memory to find that out.
83 const THEME_BYTES_MAX = 1 << 16;
84
85 const ANSI_DEFAULT = [
86 '#000000', '#cd0000', '#00cd00', '#cdcd00', '#0000ee', '#cd00cd', '#00cdcd', '#e5e5e5',
87 '#7f7f7f', '#ff0000', '#00ff00', '#ffff00', '#5c5cff', '#ff00ff', '#00ffff', '#ffffff',
88 ];
89 const BUILTIN_FG = '#c8ccd4', BUILTIN_BG = '#000000', BUILTIN_SELECTION = '#6ab0e0';
90
91 // The 240 colours above the ANSI 16 are an xterm formula, not a theme's to
92 // set: a theme that redefined one would leave the cube incoherent with the
93 // sixteen it is derived alongside. Themes name 0-15; we compute the rest.
94 function buildPalette(base16) {
95 const p = base16.slice();
77 const lv = [0, 95, 135, 175, 215, 255]; 96 const lv = [0, 95, 135, 175, 215, 255];
78 for (let r = 0; r < 6; r++) for (let g = 0; g < 6; g++) for (let b = 0; b < 6; b++) 97 for (let r = 0; r < 6; r++) for (let g = 0; g < 6; g++) for (let b = 0; b < 6; b++)
79 p.push(`rgb(${lv[r]},${lv[g]},${lv[b]})`); 98 p.push(`rgb(${lv[r]},${lv[g]},${lv[b]})`);
80 for (let i = 0; i < 24; i++) { const v = 8 + i * 10; p.push(`rgb(${v},${v},${v})`); } 99 for (let i = 0; i < 24; i++) { const v = 8 + i * 10; p.push(`rgb(${v},${v},${v})`); }
81 return p; 100 return p;
82 })(); 101 }
102
103 let FONT = `${DEFAULT_FONT_SIZE}px ${DEFAULT_FONT_FAMILY}`;
104 let DEFAULT_FG = BUILTIN_FG, DEFAULT_BG = BUILTIN_BG;
105 let PALETTE = buildPalette(ANSI_DEFAULT);
106 // The overlays stay translucent so the glyph underneath survives them:
107 // they are a theme colour plus an alpha byte, not colours of their own.
108 let CURSOR_FILL = `${BUILTIN_FG}88`, SELECTION_FILL = `${BUILTIN_SELECTION}55`;
83 109
84 function colorOf(packed, dflt) { 110 function colorOf(packed, dflt) {
85 const kind = packed >>> 24; 111 const kind = packed >>> 24;
@@ -104,15 +130,156 @@ function clipboardText(base64Bytes) {
104 } 130 }
105 } 131 }
106 132
107 // One cell's font metrics, measured once against the real font. 133 // ghostty accepts #rrggbb, #rgb, and bare rrggbb. Anything else — a CSS
108 const METRICS = (() => { 134 // colour name, a truncated hex, a line of a PNG — is not a colour here:
135 // refusing it is what lets "0 colours found" mean "not a theme file".
136 function normalizeHex(raw) {
137 const s = raw.trim().replace(/^#/, '').toLowerCase();
138 if (/^[0-9a-f]{3}$/.test(s)) return `#${s[0]}${s[0]}${s[1]}${s[1]}${s[2]}${s[2]}`;
139 if (/^[0-9a-f]{6}$/.test(s)) return `#${s}`;
140 return null;
141 }
142
143 const THEME_KEYS = {
144 background: 'bg',
145 foreground: 'fg',
146 'cursor-color': 'cursor',
147 'selection-background': 'selection',
148 };
149
150 // A ghostty theme file is `key = value` lines with `#` comments. The one
151 // trap is that a palette line's VALUE contains an `=` of its own
152 // (`palette = 0=#45475a`), so every split here takes the FIRST one only —
153 // which is also what stops `keybind = ctrl+a=new_tab` in a whole config
154 // from being read as anything at all.
155 //
156 // Returns null when nothing was recognized: a file that is not a theme
157 // must be refused, never applied as an empty one.
158 function parseGhosttyTheme(text) {
159 const theme = { palette: new Array(16).fill(null), fg: null, bg: null, cursor: null, selection: null };
160 let applied = 0, ignored = 0;
161 for (const line of text.replace(/^\uFEFF/, '').split(/\r?\n/)) {
162 const trimmed = line.trim();
163 if (!trimmed || trimmed.startsWith('#')) continue;
164 const eq = trimmed.indexOf('=');
165 if (eq < 0) { ignored++; continue; }
166 const key = trimmed.slice(0, eq).trim();
167 const value = trimmed.slice(eq + 1).trim();
168 if (key === 'palette') {
169 const inner = value.indexOf('=');
170 const index = inner < 0 ? NaN : Number(value.slice(0, inner).trim());
171 const hex = inner < 0 ? null : normalizeHex(value.slice(inner + 1));
172 if (!Number.isInteger(index) || index < 0 || index > 15 || !hex) { ignored++; continue; }
173 theme.palette[index] = hex; // duplicates: last wins, as ghostty reads them
174 applied++;
175 continue;
176 }
177 const slot = THEME_KEYS[key];
178 const hex = slot ? normalizeHex(value) : null;
179 // cursor-text and selection-foreground land here: a translucent
180 // overlay cannot honour a text colour, so naming one is not applying
181 // it and the count must say so.
182 if (!slot || !hex) { ignored++; continue; }
183 theme[slot] = hex;
184 applied++;
185 }
186 return applied === 0 ? null : { theme, applied, ignored };
187 }
188
189 function clampFontSize(size) {
190 const n = Number(size);
191 if (!Number.isFinite(n) || n <= 0) return DEFAULT_FONT_SIZE;
192 return Math.min(FONT_SIZE_MAX, Math.max(FONT_SIZE_MIN, Math.round(n)));
193 }
194
195 // One cell's font metrics, measured against the real font. Re-measured
196 // whenever the font changes, because every geometry below — the grid a
197 // zoomed tile claims, the canvas size, the cell under the pointer —
198 // divides by this.
199 function measureMetrics(font) {
109 const c = document.createElement('canvas').getContext('2d'); 200 const c = document.createElement('canvas').getContext('2d');
110 c.font = FONT; 201 c.font = font;
111 const m = c.measureText('M'); 202 const m = c.measureText('M');
112 const w = Math.ceil(m.width); 203 // Fallbacks for browsers without fontBoundingBox*: proportions of the
113 const h = Math.ceil((m.fontBoundingBoxAscent || 11) + (m.fontBoundingBoxDescent || 3)); 204 // em, since the 11/3 they replace only ever matched 14px.
114 return { w, h, ascent: Math.ceil(m.fontBoundingBoxAscent || 11) }; 205 const px = parseFloat(font) || DEFAULT_FONT_SIZE;
115 })(); 206 const ascent = Math.ceil(m.fontBoundingBoxAscent || px * 0.79);
207 const descent = Math.ceil(m.fontBoundingBoxDescent || px * 0.21);
208 // Floored at one pixel: a zero-width cell is how a resize to zero
209 // columns reaches the daemon.
210 return { w: Math.max(1, Math.ceil(m.width)), h: Math.max(1, ascent + descent), ascent };
211 }
212
213 let METRICS = measureMetrics(FONT);
214
215 // Chrome with cookies blocked throws on ACCESSING localStorage, not just
216 // on write, and verify.js's shell has none at all — so every touch goes
217 // through here and a browser that says no costs us the setting, not boot.
218 function settingsStorage() {
219 try { return window.localStorage || null; } catch (_) { return null; }
220 }
221
222 function defaultSettings() {
223 return { theme: null, fontFamily: null, fontSize: DEFAULT_FONT_SIZE };
224 }
225
226 function loadSettings() {
227 // No try around the read: settingsStorage is the one place that knows
228 // storage can refuse, and a second catch here would make its guard
229 // untestable — both would have to be removed before anything failed.
230 const raw = settingsStorage()?.getItem(SETTINGS_KEY) ?? null;
231 if (!raw) return defaultSettings();
232 try {
233 const stored = JSON.parse(raw);
234 // Version-gated rather than merged: a shape from a build that spelled
235 // these differently is not worth guessing at.
236 if (!stored || stored.v !== 1) return defaultSettings();
237 return { ...defaultSettings(), ...stored };
238 } catch (_) {
239 return defaultSettings();
240 }
241 }
242
243 function saveSettings(settings) {
244 try {
245 settingsStorage()?.setItem(SETTINGS_KEY, JSON.stringify({ ...settings, v: 1 }));
246 } catch (_) {
247 // Quota, private mode, or a browser that refuses: the setting is
248 // still live on this page, it just will not outlive the tab.
249 }
250 }
251
252 // The one place the render globals move. Every tile repaints from what
253 // its core already holds — a theme sends nothing — and the zoomed tile
254 // re-claims the grid, because the cell it fits its box with just changed.
255 function applySettings(settings) {
256 const theme = settings.theme;
257 FONT = `${clampFontSize(settings.fontSize)}px ${settings.fontFamily || DEFAULT_FONT_FAMILY}`;
258 METRICS = measureMetrics(FONT);
259 PALETTE = buildPalette(ANSI_DEFAULT.map((builtin, i) => theme?.palette[i] || builtin));
260 DEFAULT_FG = theme?.fg || BUILTIN_FG;
261 DEFAULT_BG = theme?.bg || BUILTIN_BG;
262 CURSOR_FILL = `${theme?.cursor || DEFAULT_FG}88`;
263 SELECTION_FILL = `${theme?.selection || BUILTIN_SELECTION}55`;
264 // The canvas element's own background is terminal ground: it is what
265 // shows before the first frame lands and through the sub-pixel edge
266 // sizeCanvas's rounding leaves. Hardcoded black flashed through a light
267 // theme, so the CSS reads it from here.
268 document.documentElement?.style?.setProperty('--term-bg', DEFAULT_BG);
269 for (const tile of tilesById.values()) tile.repaintForSettings();
270 zoomedTile?.sendResizeIfDiffers();
271 }
272
273 // The page's live settings. Boot replaces this with what was persisted;
274 // the panel edits it through updateSettings so no caller can apply a
275 // change without saving it, or save one without applying it.
276 let settings = defaultSettings();
277
278 function updateSettings(patch) {
279 settings = { ...settings, ...patch };
280 applySettings(settings);
281 saveSettings(settings);
282 }
116 283
117 let compiledCore = null; // one compile, one instance per tile 284 let compiledCore = null; // one compile, one instance per tile
118 285
@@ -1112,6 +1279,18 @@ class Tile {
1112 // grid's worth of cells into the top-left corner of the bitmap. 1279 // grid's worth of cells into the top-left corner of the bitmap.
1113 this.ctx.setTransform(scale * dpr, 0, 0, scale * dpr, 0, 0); 1280 this.ctx.setTransform(scale * dpr, 0, 0, scale * dpr, 0, 0);
1114 } 1281 }
1282 // Repaint after the theme or the font moved under pixels that are
1283 // already on the canvas. Painting straight over them is not enough: a
1284 // wall tile draws through a scaled transform, so every cell fill lands
1285 // on fractional device pixels and its antialiased seam keeps a blend of
1286 // what was underneath. Repainting a light theme over a dark one that
1287 // way leaves the old colour in a lattice along every cell boundary.
1288 repaintForSettings() {
1289 if (!this.core) return;
1290 this.clearBackingCanvas();
1291 this.reflow();
1292 }
1293
1115 // The tile's CSS width moved (a window resize reflows the wall grid): 1294 // The tile's CSS width moved (a window resize reflows the wall grid):
1116 // re-fit and repaint from what the core already holds. No frame is 1295 // re-fit and repaint from what the core already holds. No frame is
1117 // requested and nothing is sent — a wall tile is passive. 1296 // requested and nothing is sent — a wall tile is passive.
@@ -1197,7 +1376,7 @@ class Tile {
1197 paintCursor() { 1376 paintCursor() {
1198 if (this.scrollPages !== 0) return; 1377 if (this.scrollPages !== 0) return;
1199 const x = this.core.mux_cursor_x(), y = this.core.mux_cursor_y(); 1378 const x = this.core.mux_cursor_x(), y = this.core.mux_cursor_y();
1200 this.ctx.fillStyle = '#c8ccd488'; 1379 this.ctx.fillStyle = CURSOR_FILL;
1201 this.ctx.fillRect(x * METRICS.w, y * METRICS.h, METRICS.w, METRICS.h); 1380 this.ctx.fillRect(x * METRICS.w, y * METRICS.h, METRICS.w, METRICS.h);
1202 } 1381 }
1203 1382
@@ -1206,7 +1385,7 @@ class Tile {
1206 if (!ordered) return; 1385 if (!ordered) return;
1207 const [start, end] = ordered; 1386 const [start, end] = ordered;
1208 const cols = this.core.mux_cols(), rows = this.core.mux_rows(); 1387 const cols = this.core.mux_cols(), rows = this.core.mux_rows();
1209 this.ctx.fillStyle = '#6ab0e055'; 1388 this.ctx.fillStyle = SELECTION_FILL;
1210 for (let y = 0; y < rows; y++) { 1389 for (let y = 0; y < rows; y++) {
1211 const screenRow = this.viewStartRow + y; 1390 const screenRow = this.viewStartRow + y;
1212 if (screenRow < start.row || screenRow > end.row) continue; 1391 if (screenRow < start.row || screenRow > end.row) continue;
@@ -1347,6 +1526,9 @@ function zoom(tile) {
1347 // core and no socket — connect() was never reached. 1526 // core and no socket — connect() was never reached.
1348 if (!tile.core || !tile.ws) return; 1527 if (!tile.core || !tile.ws) return;
1349 if (zoomedTile) unzoom(); 1528 if (zoomedTile) unzoom();
1529 // Settings belong to the wall, and the wall is what zoom covers. Closing
1530 // the panel keeps it from sitting open and focusable behind the shade.
1531 addTileEl?.querySelector('.settings')?.removeAttribute('open');
1350 zoomedTile = tile; 1532 zoomedTile = tile;
1351 tile.zoomed = true; 1533 tile.zoomed = true;
1352 tile.el.classList.add('zoomed'); 1534 tile.el.classList.add('zoomed');
@@ -1384,9 +1566,12 @@ shade.addEventListener('click', unzoom);
1384 1566
1385 // Keys go ONLY to the zoomed tile — no zoom, no bytes (spec). 1567 // Keys go ONLY to the zoomed tile — no zoom, no bytes (spec).
1386 document.addEventListener('keydown', (ev) => { 1568 document.addEventListener('keydown', (ev) => {
1387 // The add input is a real text field: while it has focus its keys are the 1569 // The add tile's fields are real form controls: while one has focus its
1388 // user spelling a target, not input for any terminal. 1570 // keys are a target being spelled or a setting being edited, not input
1389 if (addInput && document.activeElement === addInput) return; 1571 // for any terminal. The whole tile, not just the add input — zoom hides
1572 // the settings panel behind the shade, but Tab still reaches it, and a
1573 // key answered by both would type the user's font size into their shell.
1574 if (addTileEl?.contains(document.activeElement)) return;
1390 const t = zoomedTile; 1575 const t = zoomedTile;
1391 if (!t) return; 1576 if (!t) return;
1392 if (ev.isComposing) return; // IME owns it; compositionend delivers 1577 if (ev.isComposing) return; // IME owns it; compositionend delivers
@@ -1590,6 +1775,68 @@ async function removeTile(id) {
1590 }); 1775 });
1591 } 1776 }
1592 1777
1778 // The settings panel, wired inside the add tile. Every change goes through
1779 // updateSettings — no caller can apply one without saving it — and every
1780 // answer lands in .note, including the count of lines a file did NOT give
1781 // us, which is how "I uploaded my whole ghostty config" reads as partial
1782 // success instead of silence.
1783 function wireSettingsPanel(el) {
1784 const file = el.querySelector('.theme-file');
1785 const clear = el.querySelector('.theme-clear');
1786 const family = el.querySelector('.font-family');
1787 const size = el.querySelector('.font-size');
1788 const note = el.querySelector('.note');
1789 const say = (text, bad) => {
1790 note.textContent = text;
1791 note.className = bad ? 'note bad' : 'note';
1792 };
1793
1794 family.value = settings.fontFamily || '';
1795 size.value = clampFontSize(settings.fontSize);
1796
1797 file.addEventListener('change', async () => {
1798 const chosen = file.files?.[0];
1799 if (!chosen) return;
1800 // Capped before a byte is read: no theme needs 64 KiB, and this is
1801 // the whole defence against someone picking a video.
1802 if (chosen.size > THEME_BYTES_MAX) {
1803 say(`too big for a theme file (${chosen.size} bytes)`, true);
1804 return;
1805 }
1806 let text = '';
1807 try {
1808 text = await chosen.text();
1809 } catch (_) {
1810 say('could not read that file', true);
1811 return;
1812 }
1813 const parsed = parseGhosttyTheme(text);
1814 if (!parsed) {
1815 say('no colours in that file — is it a ghostty theme?', true);
1816 return;
1817 }
1818 updateSettings({ theme: parsed.theme });
1819 const skipped = parsed.ignored ? `, ${parsed.ignored} lines ignored` : '';
1820 say(`${parsed.applied} colours applied${skipped}`, false);
1821 });
1822
1823 clear.addEventListener('click', () => {
1824 updateSettings({ theme: null });
1825 file.value = ''; // else re-picking the same file fires no change event
1826 say('theme cleared', false);
1827 });
1828
1829 const applyFont = () => {
1830 const wanted = clampFontSize(size.value);
1831 size.value = wanted; // the clamp is shown, not applied behind the value
1832 updateSettings({ fontFamily: family.value.trim() || null, fontSize: wanted });
1833 };
1834 family.addEventListener('change', applyFont);
1835 // 'change', not 'input': typing 18 passes through 1, and each keystroke
1836 // would claim a new grid for every client on the zoomed session.
1837 size.addEventListener('change', applyFont);
1838 }
1839
1593 // The add tile: a tile-shaped door at the end of the wall. It is NOT a Tile 1840 // The add tile: a tile-shaped door at the end of the wall. It is NOT a Tile
1594 // — no core, no socket, never zooms — so the zoom click path (bound per 1841 // — no core, no socket, never zooms — so the zoom click path (bound per
1595 // Tile) cannot reach it. 1842 // Tile) cannot reach it.
@@ -1599,11 +1846,28 @@ function buildAddTile() {
1599 el.innerHTML = 1846 el.innerHTML =
1600 `<input type="text" name="target" aria-label="add a tile"` + 1847 `<input type="text" name="target" aria-label="add a tile"` +
1601 ` autocomplete="off" autocapitalize="off" spellcheck="false"` + 1848 ` autocomplete="off" autocapitalize="off" spellcheck="false"` +
1602 ` placeholder="HOST[#SESSION] | quic://HOST:PORT | --sock PATH"><span class="err"></span>`; 1849 ` placeholder="HOST[#SESSION] | quic://HOST:PORT | --sock PATH"><span class="err"></span>` +
1850 `<details class="settings"><summary>settings</summary>` +
1851 `<div class="row"><span>theme</span>` +
1852 `<input type="file" class="theme-file" aria-label="ghostty theme file">` +
1853 `<button type="button" class="theme-clear">clear</button></div>` +
1854 `<div class="row"><span>font</span>` +
1855 `<input type="text" class="font-family" aria-label="font family"` +
1856 ` autocomplete="off" spellcheck="false" placeholder="${DEFAULT_FONT_FAMILY}">` +
1857 `<input type="number" class="font-size" aria-label="font size"` +
1858 ` min="${FONT_SIZE_MIN}" max="${FONT_SIZE_MAX}"></div>` +
1859 `<div class="caveat">theme is this browser's alone; font size claims a` +
1860 ` new grid, so every client on the zoomed session reflows</div>` +
1861 `<div class="note"></div></details>`;
1603 const input = el.querySelector('input'); 1862 const input = el.querySelector('input');
1604 const err = el.querySelector('.err'); 1863 const err = el.querySelector('.err');
1864 const settingsEl = el.querySelector('.settings');
1865 wireSettingsPanel(settingsEl);
1605 // The whole tile is the affordance; a click anywhere in it lands in the 1866 // The whole tile is the affordance; a click anywhere in it lands in the
1606 // input and goes no further (nothing above it should read this click). 1867 // input and goes no further (nothing above it should read this click).
1868 // The panel is the exception: its own controls are the click target, so
1869 // it stops the click before the tile can treat it as "type a target".
1870 settingsEl.addEventListener('click', (ev) => ev.stopPropagation());
1607 el.addEventListener('click', (ev) => { ev.stopPropagation(); input.focus(); }); 1871 el.addEventListener('click', (ev) => { ev.stopPropagation(); input.focus(); });
1608 // The add tile never moves and is never dragged, but it is the wall's 1872 // The add tile never moves and is never dragged, but it is the wall's
1609 // last child — so dropping on it is the only way to say "put this at the 1873 // last child — so dropping on it is the only way to say "put this at the
@@ -1666,6 +1930,18 @@ function buildAddTile() {
1666 1930
1667 // --- boot --- 1931 // --- boot ---
1668 (async function boot() { 1932 (async function boot() {
1933 // Before any tile exists, so the first frame is painted in the user's
1934 // colours at the user's cell size rather than repainted into them.
1935 settings = loadSettings();
1936 applySettings(settings);
1937 // A second tab of the same hub is the same wall: settings follow it
1938 // live rather than waiting for a reload. A cleared key (newValue null)
1939 // means "back to defaults".
1940 window.addEventListener('storage', (ev) => {
1941 if (ev.key !== SETTINGS_KEY) return;
1942 settings = loadSettings();
1943 applySettings(settings);
1944 });
1669 compiledCore = await WebAssembly.compileStreaming(fetch('/mux_core.wasm')); 1945 compiledCore = await WebAssembly.compileStreaming(fetch('/mux_core.wasm'));
1670 addTileEl = buildAddTile(); 1946 addTileEl = buildAddTile();
1671 addInput = addTileEl.querySelector('input'); 1947 addInput = addTileEl.querySelector('input');
web/verify.js
Old New
@@ -307,7 +307,11 @@ function executableCss(source) {
307 comment.replace(/[^\n\r]/g, ' ')); 307 comment.replace(/[^\n\r]/g, ' '));
308 } 308 }
309 309
310 function browserShell(source) { 310 // opts.storage models what a browser is allowed to do with localStorage:
311 // a Map is a working store (prefill it to test what survives a reload),
312 // 'throws' is Chrome with cookies blocked — which throws on ACCESS, not
313 // just on write — and 'none' is no localStorage on window at all.
314 function browserShell(source, opts = {}) {
311 let activeElement = null; 315 let activeElement = null;
312 class FakeClassList { 316 class FakeClassList {
313 constructor(owner) { this.owner = owner; } 317 constructor(owner) { this.owner = owner; }
@@ -368,6 +372,13 @@ function browserShell(source) {
368 } 372 }
369 get innerHTML() { return this._innerHTML ?? ''; } 373 get innerHTML() { return this._innerHTML ?? ''; }
370 appendChild(child) { this.children.push(child); return child; } 374 appendChild(child) { this.children.push(child); return child; }
375 // Real nodes contain themselves, and the page asks this of the add
376 // tile to decide whether a key belongs to a form field or a terminal.
377 contains(node) {
378 if (node === this) return true;
379 return this.children.some((child) => child.contains(node));
380 }
381 removeAttribute() {}
371 querySelector(selector) { 382 querySelector(selector) {
372 const match = (el) => selector.startsWith('.') 383 const match = (el) => selector.startsWith('.')
373 ? el.className.split(/\s+/).includes(selector.slice(1)) 384 ? el.className.split(/\s+/).includes(selector.slice(1))
@@ -403,10 +414,23 @@ function browserShell(source) {
403 this.dispatchEvent('lostpointercapture', { pointerId }); 414 this.dispatchEvent('lostpointercapture', { pointerId });
404 } 415 }
405 getContext() { 416 getContext() {
406 return { 417 // measureText answers the font it was ASKED about: a fixed 8/11/3
407 measureText: () => ({ width: 8, fontBoundingBoxAscent: 11, fontBoundingBoxDescent: 3 }), 418 // made every font assertion vacuous, since the page's whole font
419 // feature is "the cell changed size". Scaled from the 14px default
420 // so the geometry every other test pins stays exactly 8/11/3.
421 const ctx = {
422 font: '',
423 measureText() {
424 const px = parseFloat(this.font) || 14;
425 return {
426 width: 8 * px / 14,
427 fontBoundingBoxAscent: 11 * px / 14,
428 fontBoundingBoxDescent: 3 * px / 14,
429 };
430 },
408 setTransform() {}, clearRect() {}, fillRect() {}, fillText() {}, 431 setTransform() {}, clearRect() {}, fillRect() {}, fillText() {},
409 }; 432 };
433 return ctx;
410 } 434 }
411 focus() { activeElement = this; } 435 focus() { activeElement = this; }
412 blur() { if (activeElement === this) activeElement = null; } 436 blur() { if (activeElement === this) activeElement = null; }
@@ -417,10 +441,15 @@ function browserShell(source) {
417 ime: new FakeElement('input'), 441 ime: new FakeElement('input'),
418 wall: new FakeElement('div'), 442 wall: new FakeElement('div'),
419 }; 443 };
444 const documentElement = new FakeElement('html');
445 // A CSS custom property is set through the style object, not assigned:
446 // the page publishes terminal ground this way and the fake has to see it.
447 documentElement.style.setProperty = (name, value) => { documentElement.style[name] = value; };
420 const documentListeners = new Map(); 448 const documentListeners = new Map();
421 const document = { 449 const document = {
422 createElement: (tag) => new FakeElement(tag), 450 createElement: (tag) => new FakeElement(tag),
423 getElementById: (id) => elements[id], 451 getElementById: (id) => elements[id],
452 documentElement,
424 get activeElement() { return activeElement; }, 453 get activeElement() { return activeElement; },
425 addEventListener(type, fn) { documentListeners.set(type, fn); }, 454 addEventListener(type, fn) { documentListeners.set(type, fn); },
426 dispatchEvent(type, event) { return documentListeners.get(type)?.(event); }, 455 dispatchEvent(type, event) { return documentListeners.get(type)?.(event); },
@@ -428,6 +457,22 @@ function browserShell(source) {
428 const timers = []; 457 const timers = [];
429 const intervals = []; 458 const intervals = [];
430 const navigator = { clipboard: undefined }; 459 const navigator = { clipboard: undefined };
460 const windowFake = { addEventListener() {}, devicePixelRatio: 1 };
461 if (opts.storage !== 'none') {
462 const store = opts.storage instanceof Map ? opts.storage : new Map();
463 const localStorage = {
464 getItem: (key) => (store.has(key) ? store.get(key) : null),
465 setItem: (key, value) => store.set(key, String(value)),
466 removeItem: (key) => store.delete(key),
467 };
468 if (opts.storage === 'throws') {
469 Object.defineProperty(windowFake, 'localStorage', {
470 get() { throw new Error('SecurityError: storage is denied'); },
471 });
472 } else {
473 windowFake.localStorage = localStorage;
474 }
475 }
431 const context = vm.createContext({ 476 const context = vm.createContext({
432 ArrayBuffer, DataView, JSON, Math, Promise, Set, TextDecoder, TextEncoder, 477 ArrayBuffer, DataView, JSON, Math, Promise, Set, TextDecoder, TextEncoder,
433 Uint8Array, WebAssembly, console: { warn() {}, error() {} }, document, 478 Uint8Array, WebAssembly, console: { warn() {}, error() {} }, document,
@@ -436,16 +481,44 @@ function browserShell(source) {
436 clearTimeout: (id) => { if (timers[id - 1]) timers[id - 1].cleared = true; }, 481 clearTimeout: (id) => { if (timers[id - 1]) timers[id - 1].cleared = true; },
437 setInterval: (fn, ms) => { intervals.push({ fn, ms, cleared: false }); return intervals.length; }, 482 setInterval: (fn, ms) => { intervals.push({ fn, ms, cleared: false }); return intervals.length; },
438 clearInterval: (id) => { if (intervals[id - 1]) intervals[id - 1].cleared = true; }, 483 clearInterval: (id) => { if (intervals[id - 1]) intervals[id - 1].cleared = true; },
439 window: { addEventListener() {}, devicePixelRatio: 1 }, 484 window: windowFake,
440 WebSocket: class { static OPEN = 1; }, 485 WebSocket: class { static OPEN = 1; },
441 atob: globalThis.atob, 486 atob: globalThis.atob,
442 }); 487 });
443 const beforeBoot = source.split('// --- boot ---')[0]; 488 const beforeBoot = source.split('// --- boot ---')[0];
444 new vm.Script(`${beforeBoot}\n;globalThis.__verify = {\n` + 489 new vm.Script(`${beforeBoot}\n;globalThis.__verify = {\n` +
445 'Tile, clipboardText: typeof clipboardText === "function" ? clipboardText : undefined, ' + 490 'Tile, clipboardText: typeof clipboardText === "function" ? clipboardText : undefined, ' +
446 'unzoom, setZoomedTile(tile) { zoomedTile = tile; }\n' + 491 'unzoom, setZoomedTile(tile) { zoomedTile = tile; }, ' +
492 'setAddTile(el, input) { addTileEl = el; addInput = input; }, ' +
493 'parseGhosttyTheme: typeof parseGhosttyTheme === "function" ? parseGhosttyTheme : undefined, ' +
494 'applySettings: typeof applySettings === "function" ? applySettings : undefined, ' +
495 'loadSettings: typeof loadSettings === "function" ? loadSettings : undefined, ' +
496 'saveSettings: typeof saveSettings === "function" ? saveSettings : undefined, ' +
497 'THEME_BYTES_MAX: typeof THEME_BYTES_MAX === "number" ? THEME_BYTES_MAX : undefined, ' +
498 // The render globals are re-bound by applySettings, so they are read
499 // through a call: a captured copy would pin the value at boot and
500 // pass however badly the settings failed to land.
501 'settingsState: () => ({ FONT, METRICS, PALETTE, DEFAULT_FG, DEFAULT_BG, CURSOR_FILL, SELECTION_FILL })\n' +
447 '};').runInContext(context); 502 '};').runInContext(context);
448 return { ...context.__verify, context, document, elements, navigator, timers, intervals }; 503 return {
504 ...context.__verify, context, document, documentElement, elements, navigator, timers, intervals,
505 // A tile the settings tests can paint through: not registered in the
506 // page's own map, so applySettings' reflow loop never reaches it and
507 // each assertion paints exactly when it says it does.
508 makeSettingsTile() {
509 const tile = new context.__verify.Tile(9, 'settings fixture', document.createElement('div'), '');
510 tile.core = {
511 mux_cols: () => 10,
512 mux_rows: () => 5,
513 mux_history_rows: () => 0,
514 mux_cursor_x: () => 1,
515 mux_cursor_y: () => 2,
516 mux_mark_all_dirty() {},
517 };
518 tile.scrollPages = 0;
519 return tile;
520 },
521 };
449 } 522 }
450 523
451 async function flushPromises() { 524 async function flushPromises() {
@@ -2510,6 +2583,267 @@ function deltaPayload({ seq, history, cx, cy }, rowEntries) {
2510 return Buffer.concat(parts); 2583 return Buffer.concat(parts);
2511 } 2584 }
2512 2585
2586 // Settings: the browser's own theme and font. Neither is a protocol
2587 // concern — colour is invented at paint time from a palette INDEX, and
2588 // the daemon has no notion of a font — with one exception this file
2589 // pins: font size moves the cell, so a ZOOMED tile's grid moves with it.
2590 async function verifySettingsShell(shell, html) {
2591 const h = browserShell(shell);
2592 const parse = h.parseGhosttyTheme;
2593 check('shell exposes a ghostty theme parser', typeof parse, 'function');
2594 check('shell exposes settings application', typeof h.applySettings, 'function');
2595 check('shell exposes persisted settings loading', typeof h.loadSettings, 'function');
2596 if (typeof parse !== 'function' || typeof h.applySettings !== 'function') return;
2597
2598 // --- the parser, against what a real theme file contains ---
2599 const mocha = [
2600 'palette = 0=#45475a',
2601 'palette = 15=#bac2de',
2602 'background = #1e1e2e',
2603 'foreground = #cdd6f4',
2604 'cursor-color = #f5e0dc',
2605 'cursor-text = #1e1e2e',
2606 'selection-background = #585b70',
2607 'selection-foreground = #cdd6f4',
2608 ].join('\n');
2609 const m = parse(mocha);
2610 // The value of a palette line CONTAINS an '=' — splitting on all of
2611 // them is the bug this asserts against.
2612 check('palette line splits on the first = only', m?.theme.palette[0], '#45475a');
2613 check('palette line reads the last ANSI slot', m?.theme.palette[15], '#bac2de');
2614 check('theme reads background', m?.theme.bg, '#1e1e2e');
2615 check('theme reads foreground', m?.theme.fg, '#cdd6f4');
2616 check('theme reads cursor colour', m?.theme.cursor, '#f5e0dc');
2617 check('theme reads selection background', m?.theme.selection, '#585b70');
2618 // A translucent overlay cannot honour a text colour: naming them is not
2619 // the same as applying them, so they count as ignored, not applied.
2620 check('theme counts keys it cannot honour as ignored', m?.ignored, 2);
2621
2622 check('parser tolerates missing spaces', parse('palette=8=#585b70')?.theme.palette[8], '#585b70');
2623 check('parser tolerates CRLF', parse('background = #112233\r\nforeground = #445566\r\n')?.theme.bg, '#112233');
2624 check('parser strips a BOM', parse('background = #112233')?.theme.bg, '#112233');
2625 check('parser ignores comments', parse('# background = #ffffff\nbackground = #112233')?.theme.bg, '#112233');
2626 check('parser accepts bare hex', parse('background = 1e1e2e')?.theme.bg, '#1e1e2e');
2627 check('parser expands short hex', parse('background = #abc')?.theme.bg, '#aabbcc');
2628 check('parser takes the last of duplicate keys', parse('background = #111111\nbackground = #222222')?.theme.bg, '#222222');
2629 check('parser is case-insensitive about hex', parse('background = #AABBCC')?.theme.bg, '#aabbcc');
2630
2631 // 16-255 are an xterm formula, not a theme's to set: taking one would
2632 // leave the cube incoherent with the sixteen below it.
2633 const beyond = parse('palette = 16=#ffffff\npalette = 255=#ffffff\nbackground = #111111');
2634 check('parser refuses palette indices above 15', beyond?.theme.palette.length, 16);
2635 check('parser counts an out-of-range palette line as ignored', beyond?.ignored, 2);
2636 check('parser refuses a negative palette index', parse('palette = -1=#ffffff'), null);
2637 check('parser refuses a non-numeric palette index', parse('palette = x=#ffffff'), null);
2638 check('parser refuses a malformed colour', parse('background = #12345'), null);
2639 check('parser refuses a colour name', parse('background = rebeccapurple'), null);
2640
2641 // A file that is not a theme must be REFUSED, not silently applied as
2642 // an empty one: a PNG and a whole ghostty config both land here.
2643 check('parser refuses a file with no colours', parse('PNG\r\n\n binary'), null);
2644 check('parser refuses an empty file', parse(''), null);
2645 const config = 'font-family = JetBrains Mono\nkeybind = ctrl+a=new_tab\nbackground = #101010\n';
2646 const fromConfig = parse(config);
2647 check('a whole ghostty config still yields its colours', fromConfig?.theme.bg, '#101010');
2648 check('a whole ghostty config reports what it skipped', fromConfig?.ignored, 2);
2649 // keybind's value contains '=' too, so a first-= split must not let it
2650 // through as a colour.
2651 check('a keybind line is never mistaken for a colour', fromConfig?.applied, 1);
2652
2653 // --- applying a theme ---
2654 const before = h.settingsState();
2655 check('the untouched palette is the full xterm 256', before.PALETTE.length, 256);
2656 const cube = before.PALETTE.slice(16).join('|');
2657 h.applySettings({ theme: parse(mocha).theme, fontFamily: null, fontSize: null });
2658 const themed = h.settingsState();
2659 check('applying a theme replaces the ANSI slots', themed.PALETTE[0], '#45475a');
2660 check('applying a theme leaves the 240-colour cube computed', themed.PALETTE.slice(16).join('|'), cube);
2661 check('applying a theme keeps the palette 256 long', themed.PALETTE.length, 256);
2662 // A theme names 0 and 15 here and nothing between: the unnamed slots
2663 // keep the built-in ANSI colour rather than becoming undefined.
2664 check('a partial theme keeps built-in colours for unnamed slots', themed.PALETTE[1], before.PALETTE[1]);
2665 check('applying a theme sets the default foreground', themed.DEFAULT_FG, '#cdd6f4');
2666 check('applying a theme sets the default background', themed.DEFAULT_BG, '#1e1e2e');
2667 // The overlays are translucent so the glyph under them survives: theme
2668 // colour plus an alpha byte, never a second hardcoded constant.
2669 check('the cursor overlay derives from the theme', themed.CURSOR_FILL, '#f5e0dc88');
2670 check('the selection overlay derives from the theme', themed.SELECTION_FILL, '#585b7055');
2671 // The canvas element's own background is terminal ground: it shows
2672 // before the first frame and through sizeCanvas's rounding edge, so a
2673 // light theme with a black canvas flashes black.
2674 check('applying a theme republishes terminal ground', h.documentElement.style['--term-bg'], '#1e1e2e');
2675
2676 const noCursor = parse('background = #101010\nforeground = #e0e0e0');
2677 h.applySettings({ theme: noCursor.theme, fontFamily: null, fontSize: null });
2678 const fallback = h.settingsState();
2679 check('a theme without a cursor colour falls back to its foreground', fallback.CURSOR_FILL, '#e0e0e088');
2680 check('a theme without a selection colour keeps the built-in blend', fallback.SELECTION_FILL, '#6ab0e055');
2681
2682 h.applySettings({ theme: null, fontFamily: null, fontSize: null });
2683 const reset = h.settingsState();
2684 check('clearing the theme restores the built-in foreground', reset.DEFAULT_FG, before.DEFAULT_FG);
2685 check('clearing the theme restores the built-in palette', reset.PALETTE.join('|'), before.PALETTE.join('|'));
2686
2687 // --- the overlays actually paint what the theme said ---
2688 const painted = h.makeSettingsTile();
2689 h.applySettings({ theme: parse(mocha).theme, fontFamily: null, fontSize: null });
2690 const fills = [];
2691 painted.ctx = {
2692 fillStyle: '',
2693 fillRect(x, y, width, height) { fills.push([x, y, width, height, this.fillStyle]); },
2694 };
2695 painted.paintCursor();
2696 check('the cursor paints the themed colour', fills.at(-1)?.[4], '#f5e0dc88');
2697 painted.viewStartRow = 0;
2698 painted.selection = { anchor: { row: 0, col: 0 }, active: { row: 0, col: 2 }, requestId: 1, text: null };
2699 painted.paintSelection();
2700 check('the selection paints the themed colour', fills.at(-1)?.[4], '#585b7055');
2701
2702 // A repaint after a settings change must CLEAR first. A wall tile paints
2703 // through a scaled transform, so each cell fill lands on fractional
2704 // device pixels and its antialiased seam keeps a blend of whatever was
2705 // underneath — a light theme painted straight over a dark one keeps the
2706 // old colour in a lattice along every cell boundary.
2707 const stale = h.makeSettingsTile();
2708 const order = [];
2709 stale.clearBackingCanvas = () => order.push('clear');
2710 stale.reflow = () => order.push('reflow');
2711 stale.repaintForSettings();
2712 check('a settings repaint clears the stale palette before painting', order.join('|'), 'clear|reflow');
2713 const coreless = h.makeSettingsTile();
2714 coreless.core = null;
2715 coreless.clearBackingCanvas = () => order.push('clear-coreless');
2716 coreless.repaintForSettings();
2717 check('a tile with no core is left alone by a settings repaint', order.join('|'), 'clear|reflow');
2718
2719 // --- font: the one setting that reaches the daemon ---
2720 h.applySettings({ theme: null, fontFamily: null, fontSize: null });
2721 const baseMetrics = h.settingsState().METRICS;
2722 check('the default cell is measured from the default font', `${baseMetrics.w}x${baseMetrics.h}`, '8x14');
2723 const zoomed = h.makeSettingsTile();
2724 zoomed.zoomed = true;
2725 const baseCols = zoomed.zoomCols();
2726 h.applySettings({ theme: null, fontFamily: null, fontSize: 28 });
2727 const bigger = h.settingsState().METRICS;
2728 check('a larger font measures a larger cell', `${bigger.w}x${bigger.h}`, '16x28');
2729 check('a larger font fits fewer columns in the same box', zoomed.zoomCols() < baseCols, true);
2730 check('the font string keeps the size-then-family shape', h.settingsState().FONT, '28px ui-monospace, monospace');
2731 check('a font family is applied verbatim', (h.applySettings({ theme: null, fontFamily: 'JetBrains Mono', fontSize: 14 }), h.settingsState().FONT), '14px JetBrains Mono');
2732
2733 // A zero size would divide the zoom box by a zero-width cell: zoomCols
2734 // becomes Infinity, setUint16 writes 0, and the daemon is told to
2735 // resize the session to no columns at all.
2736 h.applySettings({ theme: null, fontFamily: null, fontSize: 0 });
2737 const clamped = h.settingsState();
2738 check('a zero font size is clamped to something measurable', clamped.METRICS.w >= 1, true);
2739 check('a zero font size never yields an infinite column count', Number.isFinite(zoomed.zoomCols()), true);
2740 h.applySettings({ theme: null, fontFamily: null, fontSize: 4000 });
2741 check('an absurd font size is clamped too', h.settingsState().METRICS.w < 200, true);
2742 check('a non-numeric font size falls back to the default', (h.applySettings({ theme: null, fontFamily: null, fontSize: 'huge' }), h.settingsState().FONT), '14px ui-monospace, monospace');
2743
2744 // The resize is the SAME claim the window-resize handler makes: only
2745 // the zoomed tile sends it, and only when the grid really moved.
2746 const sent = [];
2747 const claiming = h.makeSettingsTile();
2748 claiming.sendFrame = (type, payload) => sent.push([type, payload]);
2749 claiming.zoomed = true;
2750 h.setZoomedTile(claiming);
2751 h.applySettings({ theme: null, fontFamily: null, fontSize: 28 });
2752 check('a font change claims the grid for the zoomed tile', sent.length, 1);
2753 const claimedCols = sent.length ? new DataView(sent[0][1].buffer, sent[0][1].byteOffset).getUint16(0, true) : 0;
2754 check('the claimed grid is the one the new cell fits', claimedCols, claiming.zoomCols());
2755 check('the claimed grid is never zero columns', claimedCols > 0, true);
2756 // Theme is paint-local: with the grid already agreeing, applying one
2757 // must put nothing on the wire at all.
2758 claiming.core.mux_cols = () => claiming.zoomCols();
2759 claiming.core.mux_rows = () => claiming.zoomRows();
2760 sent.length = 0;
2761 h.applySettings({ theme: parse(mocha).theme, fontFamily: null, fontSize: 28 });
2762 check('a theme change sends nothing to the daemon', sent.length, 0);
2763 h.setZoomedTile(null);
2764
2765 // An unzoomed tile claims nothing, so its font is paint-only — the
2766 // wall stays passive however the settings move.
2767 const passive = h.makeSettingsTile();
2768 const passiveSent = [];
2769 passive.sendFrame = (type, payload) => passiveSent.push([type, payload]);
2770 h.applySettings({ theme: null, fontFamily: null, fontSize: 20 });
2771 check('an unzoomed tile sends nothing when the font changes', passiveSent.length, 0);
2772
2773 // --- the panel must not hold the keyboard the terminal owns ---
2774 // Zoom hides the add tile behind the shade, so the panel is unreachable
2775 // by pointer — but Tab still reaches it, and a key answered by both the
2776 // focused field and the session types the user's font size into their
2777 // shell. Caught in a real browser, not here, which is why it is pinned.
2778 const keyed = h.makeSettingsTile();
2779 const typed = [];
2780 keyed.sendKey = () => typed.push('key');
2781 keyed.zoomed = true;
2782 h.setZoomedTile(keyed);
2783 const fakeAddTile = h.document.createElement('div');
2784 const fakeAddInput = fakeAddTile.appendChild(h.document.createElement('input'));
2785 const fakePanelField = fakeAddTile.appendChild(h.document.createElement('input'));
2786 h.setAddTile(fakeAddTile, fakeAddInput);
2787 const key = (target) => {
2788 target.focus();
2789 h.document.dispatchEvent('keydown', {
2790 key: '2', target, shiftKey: false, altKey: false, ctrlKey: false, metaKey: false,
2791 preventDefault() {}, isComposing: false,
2792 });
2793 };
2794 key(fakeAddInput);
2795 check('the add input still keeps its keys out of the session', typed.length, 0);
2796 key(fakePanelField);
2797 check('a focused settings field keeps its keys out of the session', typed.length, 0);
2798 // The guard is the add tile, not "any input": the terminal still has to
2799 // receive keys when focus is anywhere else.
2800 const elsewhere = h.document.createElement('input');
2801 key(elsewhere);
2802 check('a key with focus outside the add tile still reaches the session', typed.length > 0, true);
2803 h.setZoomedTile(null);
2804 h.setAddTile(null, null);
2805
2806 // --- persistence, where the browser is allowed to say no ---
2807 check('settings load to defaults with no stored value', h.loadSettings().fontSize, 14);
2808 const stored = browserShell(shell, { storage: new Map([['mux.settings.v1', JSON.stringify({ v: 1, fontSize: 20 })]]) });
2809 check('stored settings are read back', stored.loadSettings().fontSize, 20);
2810 const corrupt = browserShell(shell, { storage: new Map([['mux.settings.v1', '{not json']]) });
2811 check('corrupt stored settings fall back to defaults', corrupt.loadSettings().fontSize, 14);
2812 const older = browserShell(shell, { storage: new Map([['mux.settings.v1', JSON.stringify({ fontSize: 20 })]]) });
2813 check('settings without the current version are discarded', older.loadSettings().fontSize, 14);
2814 // Chrome with cookies blocked throws on ACCESSING localStorage, not
2815 // just on write, and the page must still boot.
2816 const denied = browserShell(shell, { storage: 'throws' });
2817 // Caught rather than allowed to propagate: an unguarded storage read
2818 // throws out of here, and a stack trace kills the whole run instead of
2819 // naming the one assertion that broke.
2820 let deniedSize = null;
2821 try { deniedSize = denied.loadSettings().fontSize; } catch (_) { deniedSize = 'threw'; }
2822 check('a browser that refuses storage still loads settings', deniedSize, 14);
2823 let saveThrew = false;
2824 try { denied.saveSettings({ theme: null, fontFamily: null, fontSize: 20 }); } catch (_) { saveThrew = true; }
2825 check('a browser that refuses storage still saves without throwing', saveThrew, false);
2826 const absent = browserShell(shell, { storage: 'none' });
2827 check('a browser with no storage at all still loads settings', absent.loadSettings().fontSize, 14);
2828
2829 // --- the file the user hands us ---
2830 check('the theme upload is capped before it is read', typeof h.THEME_BYTES_MAX, 'number');
2831 check('the cap is smaller than any plausible paste of a log', h.THEME_BYTES_MAX <= 1 << 20, true);
2832
2833 // --- the wall keeps its one standing control ---
2834 const executableHtmlCss = executableCss(html);
2835 check(
2836 'terminal ground is a themeable variable, not a hardcoded black',
2837 /\.tile\s+canvas\s*\{[^}]*background\s*:\s*var\(--term-bg/.test(executableHtmlCss),
2838 true,
2839 );
2840 check(
2841 'the settings panel lives inside the add tile',
2842 /\.tile\.add\s+\.settings/.test(executableHtmlCss),
2843 true,
2844 );
2845 }
2846
2513 async function main() { 2847 async function main() {
2514 const bin = fs.readFileSync(wasmPath); 2848 const bin = fs.readFileSync(wasmPath);
2515 const { instance } = await WebAssembly.instantiate(bin, {}); 2849 const { instance } = await WebAssembly.instantiate(bin, {});
@@ -2914,6 +3248,7 @@ async function main() {
2914 const html = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8'); 3248 const html = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8');
2915 await verifyClipboardShell(shell, html); 3249 await verifyClipboardShell(shell, html);
2916 await verifySelectionShell(shell, html); 3250 await verifySelectionShell(shell, html);
3251 await verifySettingsShell(shell, html);
2917 3252
2918 // Mutation fixture for the source checks below. A correct-looking route 3253 // Mutation fixture for the source checks below. A correct-looking route
2919 // in either kind of comment must be invisible, while live literal 3254 // in either kind of comment must be invisible, while live literal