@cplieger/web-terminal-ui 5.2.1 → 5.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/css/00-tokens.css CHANGED
@@ -144,17 +144,26 @@
144
144
  --font-mono: "MonaspiceNe NFM", monospace;
145
145
  /* How far a chip label's INK sits below its line box's centre, so flex
146
146
  centring can be corrected by exactly that much (see .wt-tab-label in
147
- 30-tabs.css). Derived from MonaspiceNe NFM's own tables and outlines:
147
+ 30-tabs.css).
148
148
 
149
- (ascent - descent) / 2 = (0.995 - 0.250) / 2 = 0.3725em
150
- (cap ink - descender ink)/2 = (0.7344 - 0.2188) / 2 = 0.2578em
151
- difference = 0.1147em
149
+ This value is only the PRE-MEASUREMENT DEFAULT — what the first paint uses
150
+ before the tabs feature replaces it, per site, with a pixel value measured
151
+ in the live engine (src/features/tabs/ink-centre.ts). It is not the answer,
152
+ because no single value can be: engines round the font's ascent and descent
153
+ to whole CSS pixels before building the line box, so the ideal shift is a
154
+ sawtooth in font size rather than a ratio. Against the bundled font in
155
+ WebKit it is 0.1190em at 13px and 0.0915em at 14px, since 0.25em x 13 =
156
+ 3.25 rounds to 3 (a 16px box) while 0.25em x 14 = 3.5 rounds to 4 (an 18px
157
+ box). Engines then disagree with each other (Firefox makes that 14px box
158
+ 17px) and platforms disagree within one engine (iOS reads metrics through
159
+ CoreText, Linux WebKit through FreeType). Four fixes were hardcoded
160
+ constants and each was correct only where it was tuned.
152
161
 
153
- A constant is safe here only because the bundled font leaves engines
154
- nothing to disagree about: hhea, OS/2 typo and usWin all declare the same
155
- 0.995 / 0.250 with lineGap 0, and fsSelection sets USE_TYPO_METRICS, so
156
- Chromium, Gecko and WebKit all compute the same line box. Re-derive this
157
- number if the bundled font ever changes; do not nudge it by eye. */
162
+ 0.1147em is kept as the default because it is the midpoint of that
163
+ sawtooth: worst case 0.34px before the measurement lands, versus 1.5px for
164
+ no correction at all. Do not tune it by eye — a wrong default is invisible
165
+ the moment the measured value arrives, so eye-tuning it optimises nothing
166
+ and pins the wrong number in place. */
158
167
  --label-ink-shift: 0.1147em;
159
168
  --dur-micro: 0.1s;
160
169
  --dur-standard: 0.2s;
@@ -191,13 +191,14 @@
191
191
  display: block;
192
192
  }
193
193
 
194
- /* Clickable URLs. OSC 8 hyperlinks (base .term-link) underline on HOVER only:
195
- an application can attach one OSC 8 link to a whole padded/bordered region —
196
- e.g. a markdown link whose URL wraps inside a table cell, where the terminal
197
- keeps the link open across the cell padding, the borders and the empty
198
- adjacent column — so a persistent underline would bleed across the whole cell
199
- and, on wrap, the full row. Hover-only matches iTerm2 / VTE / kitty. The app's
200
- own text colour (which the engine renders) keeps the link discoverable at rest.
194
+ /* Clickable URLs. OSC 8 hyperlinks (base .term-link) underline on HOVER where a
195
+ pointer can hover: an application can attach one OSC 8 link to a whole
196
+ padded/bordered region — e.g. a markdown link whose URL wraps inside a table
197
+ cell, where the terminal keeps the link open across the cell padding, the
198
+ borders and the empty adjacent column — so a persistent underline risks
199
+ bleeding across the whole cell and, on wrap, the full row. Hover-only matches
200
+ iTerm2 / VTE / kitty. The app's own text colour (which the engine renders)
201
+ keeps the link discoverable at rest.
201
202
  Auto-detected bare URLs (.term-autolink) are tightly scoped to the URL text and
202
203
  never bleed, so they keep a subtle persistent underline. (The engine also only
203
204
  anchors runs with real link text, so the underline hugs the text either way.) */
@@ -215,6 +216,36 @@
215
216
  text-decoration: underline solid;
216
217
  }
217
218
 
219
+ /* Where nothing can hover, hover-only is no affordance at all: a touch user sees
220
+ an app-declared link as plain text and has to guess. Measured on kiro-cli at 41
221
+ columns: it emits OSC 8 for every markdown link (its hyperlink capability
222
+ unlocks on the TERM_PROGRAM=iTerm.app identity the engine advertises), so a
223
+ phone session is full of tappable, invisible links. Give them the same subtle
224
+ dotted underline the auto-detected ones carry.
225
+
226
+ The bleed risk above is what kept this off, and it is narrower than it reads:
227
+ the engine refuses to anchor a run with no real link text, so the padding, the
228
+ box-drawing borders and the indent cells a wrapped link leaks onto the next row
229
+ stay unanchored and unmarked (verified against a captured OSC 8 link that wraps
230
+ mid-URL: the three leaked indent cells carry the href on the wire and get no
231
+ anchor). What remains is an app leaving one link open across genuine text in an
232
+ adjacent column, which would be underlined too. On a device that cannot hover,
233
+ an occasional over-underline beats a link with no affordance.
234
+
235
+ `hover: none` is the pointing device's capability, not a viewport width, so a
236
+ narrow window on a desktop keeps hover styling and a tablet with a trackpad is
237
+ treated as hoverable. */
238
+ @media (hover: none) {
239
+ :where(.wt-root) .term-link {
240
+ text-decoration: underline dotted;
241
+ }
242
+
243
+ /* Keep the pressed/solid distinction reachable without hover. */
244
+ :where(.wt-root) .term-link:active {
245
+ text-decoration: underline solid;
246
+ }
247
+ }
248
+
218
249
  /* Inline cursor cell (xterm.js convention): the character at the cursor
219
250
  position gets the .term-cursor class. CSS gives it inverted colors;
220
251
  the blink is driven by a JS interval that toggles .cursor-blink-off on
package/css/30-tabs.css CHANGED
@@ -198,11 +198,11 @@
198
198
 
199
199
  Centre the INK, not the line box. A font's metrics box reaches far higher above
200
200
  the baseline than below it (the bundled font: 0.995em over, 0.250em under),
201
- while the ink of a mixed-case title runs 0.7344em over (cap height) to 0.2188em
201
+ while the ink of a mixed-case title runs ~0.734em over (cap height) to ~0.203em
202
202
  under (descender). Flex centring centres the BOX, which leaves the visible text
203
- --label-ink-shift low — 0.1147em, 1.5px at this size — reading as
204
- baseline-aligned with the activity dot's bottom edge instead of level with its
205
- centre. Two declarations, one job, and a third that must NOT be added:
203
+ --label-ink-shift low — about 1.5px at this size — reading as baseline-aligned
204
+ with the activity dot's bottom edge instead of level with its centre. Two
205
+ declarations, one job, and a third that must NOT be added:
206
206
 
207
207
  - padding-block is the ink allowance, not spacing. `overflow: hidden` (which
208
208
  the ellipsis requires) clips at the padding box, and the line box clears the
@@ -222,21 +222,31 @@
222
222
  -0.25 floored to -1) while the 13px strip label was fine, which is exactly
223
223
  the kind of works-where-I-looked bug this rule exists to end.
224
224
 
225
- What this deliberately does NOT use is `text-box: trim-both`, which all three
226
- previous attempts were built on. It ships in Blink and WebKit only (Firefox has
227
- it in Nightly behind layout.css.text-box.enabled), so it can only be applied
228
- behind an @supports gate, and a gate means two engines render two different
229
- geometries of which only the one the author is looking at ever gets tuned.
230
- Measured on the gated version: Chromium centred to 0.02px while Firefox sat
231
- 1.50px low with its baseline half a pixel BELOW the dot. The keyword vocabulary
232
- is too coarse for this text besides -- `cap alphabetic` centres the cap band and
233
- reads 1.14px low on lowercase, `ex alphabetic` centres the x-band and reads high
234
- on capitals, and a mixed-case tab title wants neither edge.
235
-
236
- The residual error is engines rounding ascent and descent to whole pixels, which
237
- makes the ideal shift 0.127em at 13px and 0.099em at 14px; the one authored
238
- 0.1147em is the unrounded truth and lands within 0.22px at both sizes, in every
239
- engine, with no fork. */
225
+ --label-ink-shift is MEASURED, not declared. The tabs feature writes a pixel
226
+ value per site from the line box the running engine produced for this font at
227
+ this size (src/features/tabs/ink-centre.ts); the em value in 00-tokens.css is
228
+ only what the first paint uses. Four earlier fixes were constants, and the
229
+ reason none held is that engines round the font's ascent and descent to whole
230
+ CSS pixels before laying out the line box, so the required shift is a sawtooth
231
+ in font size, not a ratio: in WebKit 0.1190em at 13px against 0.0915em at 14px,
232
+ because 0.25em x 13 = 3.25 rounds to 3 (a 16px box) while 0.25em x 14 = 3.5
233
+ rounds to 4 (an 18px box). The three engines also report slightly different ink
234
+ extents for one font file (cap 0.7344 / 0.7350 / 0.7300em in WebKit, Blink and
235
+ Gecko), Firefox makes that 14px box 17px rather than 18px, and iOS resolves
236
+ metrics through CoreText where Linux WebKit uses FreeType — which is why the
237
+ 13px strip measured centred in every local check and still read high on an
238
+ iPad. Nothing here is tunable by eye; if the centring looks wrong, run
239
+ scripts/verify-chip-geometry.mjs and fix what it measures.
240
+
241
+ What this deliberately does NOT use is `text-box: trim-both`, which three of
242
+ the four previous attempts were built on. It ships in Blink and WebKit only
243
+ (Firefox 151 still reports false for it), so it can only be applied behind an
244
+ @supports gate, and a gate means two engines render two different geometries of
245
+ which only the one the author is looking at ever gets tuned. Trimming to
246
+ `cap alphabetic` and giving the descender back as asymmetric padding IS
247
+ size-independent and holds to 0.05px in both engines that implement it — but
248
+ ungated it swings to 0.39px in Firefox, and a measured shift needs no gate and
249
+ no second code path to beat it. */
240
250
  :where(.wt-root) .wt-tab-label {
241
251
  flex: 1 1 auto;
242
252
  min-width: 0;
@@ -247,9 +257,23 @@
247
257
  margin-block: 0 calc(2 * var(--label-ink-shift));
248
258
  }
249
259
 
250
- /* A little breathing room after the activity dot, before the label. */
260
+ /* The dot sits between two gaps that must read as one gap: the chip's own
261
+ padding on its left, and the run to the label on its right. The chip's flex
262
+ `gap` is --sp-1 (it also spaces label -> close, which wants the tighter
263
+ value), so the dot makes up the difference itself and the two flanking gaps
264
+ both land on --sp-2. Expressed as the arithmetic rather than a literal 4px so
265
+ the equality survives a change to either token — a literal 2px was 6px against
266
+ the padding's 8px, which reads as the dot being crowded toward the title.
267
+
268
+ This equalises the LAYOUT gaps, deliberately not the ink gaps. A monospace
269
+ glyph is centred in a fixed advance, so a title's first letter starts about a
270
+ pixel inside its box, and how far in depends on the letter: compensating would
271
+ mean a correction that changes per title and per font, and the engines do not
272
+ even agree on the measurement (for this face WebKit reports no left bearing
273
+ where Blink reports 1.01px). A title-independent definition beats a
274
+ sub-pixel one that is wrong on the next title. */
251
275
  :where(.wt-root) .wt-tab-dot {
252
- margin-right: 2px;
276
+ margin-right: calc(var(--sp-2) - var(--sp-1));
253
277
  }
254
278
 
255
279
  /* The tab close (x): a centered, muted square icon button with a box on hover.
@@ -174,10 +174,13 @@
174
174
 
175
175
  /* Ink-centring geometry, identical to .wt-tab-label: a symmetric ink allowance
176
176
  the ellipsis clipping needs, plus a bottom margin of twice --label-ink-shift
177
- that lifts the ink to the row centre. See 30-tabs.css for the derivation, for
178
- why line-height must stay `normal` here, and for why this is margin arithmetic
179
- over the bundled font rather than a text-box trim behind an @supports gate that
180
- only one engine ever takes. */
177
+ that lifts the ink to the row centre. These labels are 14px against the strip's
178
+ 13px, which is exactly why --label-ink-shift is MEASURED per site at runtime
179
+ (src/features/tabs/ink-centre.ts) rather than declared once: the shift an engine
180
+ needs is a sawtooth in font size, not a ratio, so the single em constant that
181
+ served both sizes was correct at 13px and 0.33px high here. See 30-tabs.css for
182
+ the full derivation, for why line-height must stay `normal`, and for why a
183
+ measured shift beats a text-box trim behind an @supports gate. */
181
184
  :where(.wt-root) .wt-switcher-label {
182
185
  flex: 1 1 auto;
183
186
  overflow: hidden;
@@ -446,10 +449,13 @@
446
449
 
447
450
  /* Ink-centring geometry, identical to .wt-tab-label: a symmetric ink allowance
448
451
  the ellipsis clipping needs, plus a bottom margin of twice --label-ink-shift
449
- that lifts the ink to the row centre. See 30-tabs.css for the derivation, for
450
- why line-height must stay `normal` here, and for why this is margin arithmetic
451
- over the bundled font rather than a text-box trim behind an @supports gate that
452
- only one engine ever takes. */
452
+ that lifts the ink to the row centre. These labels are 14px against the strip's
453
+ 13px, which is exactly why --label-ink-shift is MEASURED per site at runtime
454
+ (src/features/tabs/ink-centre.ts) rather than declared once: the shift an engine
455
+ needs is a sawtooth in font size, not a ratio, so the single em constant that
456
+ served both sizes was correct at 13px and 0.33px high here. See 30-tabs.css for
457
+ the full derivation, for why line-height must stay `normal`, and for why a
458
+ measured shift beats a text-box trim behind an @supports gate. */
453
459
  :where(.wt-root) .wt-switcher-row-label {
454
460
  flex: 1 1 auto;
455
461
  overflow: hidden;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "5.2.1",
3
+ "version": "5.2.3",
4
4
  "type": "module",
5
5
  "description": "Reference touch-first browser UI for @cplieger/web-terminal-engine: a composable kernel + feature modules (tabs, activity monitor, mobile toolbar, context menu, IME, predictive echo, viewport handling) and CSS. One createTerminal() call.",
6
6
  "license": "GPL-3.0-or-later",
@@ -56,8 +56,8 @@
56
56
  "eslint": "10.8.0",
57
57
  "fast-check": "4.9.0",
58
58
  "happy-dom": "20.11.1",
59
- "html-validate": "11.6.0",
60
- "knip": "6.30.0",
59
+ "html-validate": "11.6.2",
60
+ "knip": "6.31.0",
61
61
  "prettier": "3.9.6",
62
62
  "stylelint": "17.14.1",
63
63
  "stylelint-config-standard": "40.0.0",
@@ -14,6 +14,7 @@ import type { ActivityMonitorApi } from "../activity-monitor.js";
14
14
  import type { MobileToolbarApi } from "../mobile-toolbar.js";
15
15
  import { fromHTML, holdFocusOnPress } from "../dom.js";
16
16
  import { createClickSwallow, placeMenuAt } from "../menu-position.js";
17
+ import { centreChipLabels } from "./ink-centre.js";
17
18
  import type { CueStatus, SessionInfo, StatusRecord, Tab } from "./model.js";
18
19
  import {
19
20
  ACTIVE_TAB_KEY,
@@ -600,6 +601,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
600
601
  offArmed = kbApi.onCtrlArmedChange(reflectArmed);
601
602
  }
602
603
  }
604
+ // Measured optical centring for every chip label in both layouts: writes
605
+ // --label-ink-shift onto the strip and the switcher from the line box THIS
606
+ // engine produced for THIS font at THIS size, rather than the em constant
607
+ // in 00-tokens.css that can only be right at one size (see ink-centre.ts).
608
+ const stopInkCentring = centreChipLabels(varRoot, { strip: bar, switcher });
609
+
603
610
  // Mark the root so the CSS lifts the bottom-anchored chrome (banner, toast,
604
611
  // scroll-to-bottom, key grid) above the switcher bar on a coarse pointer.
605
612
  const root = ctx.surface().parentElement;
@@ -3213,6 +3220,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3213
3220
  }
3214
3221
  barResize.disconnect();
3215
3222
  swReserve.disconnect();
3223
+ stopInkCentring();
3216
3224
  surface.classList.remove("wt-with-tabbar");
3217
3225
  varRoot.style.removeProperty("--wt-tabbar-h");
3218
3226
  root?.classList.remove("wt-tabbed");
@@ -0,0 +1,262 @@
1
+ // Optical centring for the three chip labels (desktop strip, mobile switcher
2
+ // chip, overview rows), measured in the engine instead of predicted from the
3
+ // font's tables.
4
+ //
5
+ // Flex centring centres a label's LINE BOX. A font's metrics box reaches much
6
+ // further above the baseline than below (the bundled MonaspiceNe NFM: 0.995em
7
+ // over, 0.250em under) while a mixed-case title's ink runs cap height to
8
+ // descender (0.734em over, 0.203em under), so the visible text sits low inside
9
+ // the box it is centred by. The correction is the gap between those two centres.
10
+ //
11
+ // That gap is NOT a font-relative constant, which is what four previous fixes
12
+ // assumed. Engines round the font's ascent and descent to whole CSS pixels
13
+ // before building the line box, so the ideal shift jumps whenever a size
14
+ // crosses a rounding boundary: measured in WebKit against this font it is
15
+ // 0.1190em at 13px but 0.0915em at 14px, because 0.25em x 13 = 3.25 rounds to 3
16
+ // (16px box) while 0.25em x 14 = 3.5 rounds to 4 (18px box). Sweeping 11-20px
17
+ // gives a sawtooth between 0.092em and 0.119em, so one em value is right only at
18
+ // the sizes whose rounding it was tuned against — the shipped 0.1147em was
19
+ // correct on the 13px desktop strip and 0.33px high on the 14px mobile chip.
20
+ // Engines disagree with each other too (Firefox makes that same 14px box 17px,
21
+ // not 18px), and so do platforms within one engine: iOS resolves metrics through
22
+ // CoreText, Linux WebKit through FreeType, which is why the strip read correct
23
+ // in every local check and high on an iPad.
24
+ //
25
+ // So nothing here predicts. Each measurement asks the engine for the line box
26
+ // and baseline it actually produced, asks the font for its actual ink extents,
27
+ // and writes the difference to --label-ink-shift as pixels. The CSS keeps an em
28
+ // default for the pre-measurement paint (see 00-tokens.css); this only ever
29
+ // narrows the error.
30
+ import { fromHTML } from "../dom.js";
31
+
32
+ /** Ink extents are measured at this size and scaled back to em: WebKit and Blink
33
+ * quantise canvas TextMetrics ink values to whole pixels, which at 13px leaves
34
+ * 4% of error in a number the correction halves. At 400px the same quantisation
35
+ * is worth 0.00125em. */
36
+ const REF_PX = 400;
37
+
38
+ /** Marks the deepest element of a probe chain — the one whose resolved font is
39
+ * the thing being measured. */
40
+ const PROBE_CLASS = "wt-ink-probe";
41
+
42
+ /** Cap ink, x-height ink and descender ink in one string, so the DOM probe's
43
+ * line box is the one a real mixed-case title produces. */
44
+ const PROBE_TEXT = "Hxp";
45
+
46
+ /** Class chains that resolve each site's label font. Deliberately the shortest
47
+ * chain that carries the font-size (`.wt-tab` and `.wt-switcher-current`) and
48
+ * not the real ancestry: `.wt-switcher` is `display: none` off a coarse pointer
49
+ * and `.wt-tab-bar` is `display: none` on a narrow one, so a probe reproducing
50
+ * the full chain would measure zeros in exactly one of the two layouts. */
51
+ const STRIP_PROBE = `<div class="wt-tab"><span class="wt-tab-label ${PROBE_CLASS}">${PROBE_TEXT}</span></div>`;
52
+ const SWITCHER_PROBE = `<div class="wt-switcher-current"><span class="wt-switcher-label ${PROBE_CLASS}">${PROBE_TEXT}</span></div>`;
53
+
54
+ /** What an engine and a font report about one label's typography. */
55
+ export interface InkMetrics {
56
+ /** The line box the engine produced for this font at this size. */
57
+ readonly fontBoxPx: number;
58
+ /** Baseline offset from the line box's over edge. */
59
+ readonly baselinePx: number;
60
+ /** Cap ink above the baseline, em. */
61
+ readonly capInkEm: number;
62
+ /** Descender ink below the baseline, em. */
63
+ readonly descInkEm: number;
64
+ readonly fontSizePx: number;
65
+ }
66
+
67
+ /** How far a label's visible ink sits BELOW its line box's centre. Positive is
68
+ * the normal case (ink low), and the CSS lifts the box by exactly this much. */
69
+ export function inkShiftPx(m: InkMetrics): number {
70
+ const inkCentre = m.baselinePx + ((m.descInkEm - m.capInkEm) * m.fontSizePx) / 2;
71
+ return inkCentre - m.fontBoxPx / 2;
72
+ }
73
+
74
+ let ctx2d: CanvasRenderingContext2D | null | undefined;
75
+
76
+ function canvas2d(): CanvasRenderingContext2D | null {
77
+ ctx2d ??= document.createElement("canvas").getContext("2d");
78
+ return ctx2d;
79
+ }
80
+
81
+ /** The font's own ink extents, in em. Canvas rather than the DOM because only
82
+ * TextMetrics reports where the glyphs' ink actually starts and stops; a DOM
83
+ * rect only ever reports the box around it. */
84
+ function inkExtents(font: string): Pick<InkMetrics, "capInkEm" | "descInkEm"> | null {
85
+ const ctx = canvas2d();
86
+ if (!ctx) {
87
+ return null;
88
+ }
89
+ ctx.font = font;
90
+ const capInk = ctx.measureText("H").actualBoundingBoxAscent;
91
+ const descInk = ctx.measureText("p").actualBoundingBoxDescent;
92
+ if (!(capInk > 0) || !(descInk > 0)) {
93
+ return null;
94
+ }
95
+ return { capInkEm: capInk / REF_PX, descInkEm: descInk / REF_PX };
96
+ }
97
+
98
+ /** The engine's line box and baseline for a laid-out element's own font. The
99
+ * baseline is read off a zero-height inline-block, whose bottom margin edge
100
+ * sits ON the baseline by definition — the only thing in the DOM that reports
101
+ * a baseline position. */
102
+ function lineBox(label: HTMLElement): Pick<InkMetrics, "fontBoxPx" | "baselinePx"> | null {
103
+ const line = document.createElement("span");
104
+ line.style.position = "absolute";
105
+ line.style.whiteSpace = "pre";
106
+ line.style.font = "inherit";
107
+ line.textContent = PROBE_TEXT;
108
+ const strut = document.createElement("span");
109
+ strut.style.display = "inline-block";
110
+ strut.style.width = "0";
111
+ strut.style.height = "0";
112
+ line.appendChild(strut);
113
+ label.appendChild(line);
114
+ const box = line.getBoundingClientRect();
115
+ const baselinePx = strut.getBoundingClientRect().bottom - box.top;
116
+ line.remove();
117
+ if (!(box.height > 0) || !Number.isFinite(baselinePx)) {
118
+ return null;
119
+ }
120
+ return { fontBoxPx: box.height, baselinePx };
121
+ }
122
+
123
+ /** Measures one site and returns its shift plus the font signature it belongs
124
+ * to. Returns null when the resolved font is unchanged since `cached` (the
125
+ * resize path, which then does no ink or line-box work at all) and when the
126
+ * site cannot be measured at all (no layout, no canvas, a display: none
127
+ * probe) — callers leave the CSS default in place either way rather than
128
+ * writing a wrong number.
129
+ *
130
+ * The probe lives only for the duration of this synchronous call, deliberately.
131
+ * Keeping a permanent hidden one would save a DOM mutation per resize, and it
132
+ * would also put a second `.wt-tab` inside the root forever — which the strip's
133
+ * test suite counts as a tab (`root.querySelectorAll(".wt-tab")`). Transient
134
+ * means no other code, and no assertion, can ever observe it. */
135
+ function measureSite(
136
+ varRoot: HTMLElement,
137
+ probeHTML: string,
138
+ cached: string | undefined,
139
+ ): { shiftPx: number; signature: string } | null {
140
+ const wrap = document.createElement("div");
141
+ wrap.setAttribute("aria-hidden", "true");
142
+ wrap.style.position = "absolute";
143
+ wrap.style.top = "0";
144
+ wrap.style.left = "0";
145
+ wrap.style.visibility = "hidden";
146
+ wrap.style.pointerEvents = "none";
147
+ wrap.appendChild(fromHTML(probeHTML));
148
+ varRoot.appendChild(wrap);
149
+ try {
150
+ const label = wrap.querySelector<HTMLElement>(`.${PROBE_CLASS}`);
151
+ if (!label) {
152
+ return null;
153
+ }
154
+ const cs = getComputedStyle(label);
155
+ const signature = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
156
+ if (signature === cached) {
157
+ return null;
158
+ }
159
+ const fontSizePx = Number.parseFloat(cs.fontSize);
160
+ if (!(fontSizePx > 0)) {
161
+ return null;
162
+ }
163
+ const ink = inkExtents(`${cs.fontStyle} ${cs.fontWeight} ${REF_PX}px ${cs.fontFamily}`);
164
+ const box = lineBox(label);
165
+ if (!ink || !box) {
166
+ return null;
167
+ }
168
+ const shiftPx = inkShiftPx({ ...box, ...ink, fontSizePx });
169
+ if (!Number.isFinite(shiftPx)) {
170
+ return null;
171
+ }
172
+ return { shiftPx, signature };
173
+ } finally {
174
+ wrap.remove();
175
+ }
176
+ }
177
+
178
+ /** The element a measured shift is written to, and the probe that resolves its
179
+ * font. --label-ink-shift is scoped per host rather than set once on the root
180
+ * because the two label sizes need two different pixel values, and every label
181
+ * inside a host shares one size. */
182
+ interface Site {
183
+ readonly host: HTMLElement;
184
+ readonly probeHTML: string;
185
+ }
186
+
187
+ /**
188
+ * Writes a measured --label-ink-shift onto the tab strip and the mobile
189
+ * switcher, and keeps it current.
190
+ *
191
+ * Re-measures on two kinds of event, which need two different policies:
192
+ *
193
+ * - A font finishing (or failing) to load. The first pass can run inside
194
+ * `font-display: block`, where the line box is still the fallback's, and the
195
+ * real face can change every number here. These FORCE a re-measure, because
196
+ * the signature below cannot see them: computed style reports the DECLARED
197
+ * font-family list whether or not the webfont ever arrived, so a cached
198
+ * signature would match and pin the fallback's shift permanently. (It did,
199
+ * until scripts/verify-chip-geometry.mjs caught it.)
200
+ * - A resize. rem-relative label sizes move with Safari's per-site page zoom,
201
+ * and a new size means new rounding; but most resizes change neither, so this
202
+ * path keeps the signature check and costs one computed-style read per site
203
+ * when nothing moved.
204
+ *
205
+ * @returns teardown that drops the listeners and the written properties.
206
+ */
207
+ export function centreChipLabels(
208
+ varRoot: HTMLElement,
209
+ hosts: { readonly strip: HTMLElement; readonly switcher: HTMLElement },
210
+ ): () => void {
211
+ const sites: readonly Site[] = [
212
+ { host: hosts.strip, probeHTML: STRIP_PROBE },
213
+ { host: hosts.switcher, probeHTML: SWITCHER_PROBE },
214
+ ];
215
+ const measured = new WeakMap<HTMLElement, string>();
216
+ let disposed = false;
217
+
218
+ const remeasure = (force: boolean): void => {
219
+ if (disposed) {
220
+ return;
221
+ }
222
+ for (const site of sites) {
223
+ const result = measureSite(
224
+ varRoot,
225
+ site.probeHTML,
226
+ force ? undefined : measured.get(site.host),
227
+ );
228
+ if (!result) {
229
+ continue;
230
+ }
231
+ site.host.style.setProperty("--label-ink-shift", `${result.shiftPx.toFixed(3)}px`);
232
+ measured.set(site.host, result.signature);
233
+ }
234
+ };
235
+
236
+ const onResize = (): void => {
237
+ remeasure(false);
238
+ };
239
+ const onFontsDone = (): void => {
240
+ remeasure(true);
241
+ };
242
+
243
+ remeasure(false);
244
+ // The DOM lib types document.fonts as always present; happy-dom ships no
245
+ // FontFaceSet at all, and neither does any engine without the CSS Font
246
+ // Loading API, so the honest type is the optional one.
247
+ const fonts = document.fonts as FontFaceSet | undefined;
248
+ // `ready` settles the initial load (including a failed one); `loadingdone`
249
+ // covers a face the host page adds afterwards, which `ready` never reports.
250
+ void fonts?.ready.then(onFontsDone);
251
+ fonts?.addEventListener("loadingdone", onFontsDone);
252
+ window.addEventListener("resize", onResize);
253
+
254
+ return () => {
255
+ disposed = true;
256
+ fonts?.removeEventListener("loadingdone", onFontsDone);
257
+ window.removeEventListener("resize", onResize);
258
+ for (const site of sites) {
259
+ site.host.style.removeProperty("--label-ink-shift");
260
+ }
261
+ };
262
+ }