@cplieger/web-terminal-ui 5.2.0 → 5.2.2

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;
@@ -38,7 +38,11 @@
38
38
  user-select: none;
39
39
  }
40
40
 
41
- :where(.wt-root) .wt-btn:active {
41
+ /* Press. The pointerdown-cancelling bar buttons (holdFocusOnPress in
42
+ features/dom.ts) never enter :active in Firefox, so they paint .wt-pressed on
43
+ themselves and every press rule in this package matches either. */
44
+ :where(.wt-root) .wt-btn:active,
45
+ :where(.wt-root) .wt-btn.wt-pressed {
42
46
  background: var(--text-active);
43
47
  }
44
48
 
@@ -38,7 +38,11 @@
38
38
  border-color: color-mix(in oklch, var(--text) 35%, transparent);
39
39
  }
40
40
 
41
- :where(.wt-root) .wt-scroll-bottom:active {
41
+ /* Press fills the background. .wt-pressed rides along: this button cancels its
42
+ pointerdown default and so never enters :active in Firefox (holdFocusOnPress
43
+ in features/dom.ts). */
44
+ :where(.wt-root) .wt-scroll-bottom:active,
45
+ :where(.wt-root) .wt-scroll-bottom.wt-pressed {
42
46
  background: var(--text-active);
43
47
  }
44
48
 
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.
@@ -347,8 +371,11 @@
347
371
 
348
372
  /* Press: brighten the hover fill with the active-tab fg (the
349
373
  .wt-switcher-current press recipe — a lighter press over an already-filled
350
- control), keeping the accent glyph. */
351
- :where(.wt-root) .wt-tab-new:active {
374
+ control), keeping the accent glyph. .wt-pressed rides along because this
375
+ button cancels its pointerdown default and so never enters :active in Firefox
376
+ (holdFocusOnPress in features/dom.ts). */
377
+ :where(.wt-root) .wt-tab-new:active,
378
+ :where(.wt-root) .wt-tab-new.wt-pressed {
352
379
  background: color-mix(in oklch, var(--tab-active-bg), var(--tab-active-fg) 12%);
353
380
  }
354
381
 
@@ -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;
@@ -339,7 +342,10 @@
339
342
  }
340
343
  }
341
344
 
342
- :where(.wt-root) .wt-switcher-new:active {
345
+ /* Press. .wt-pressed rides along: this button cancels its pointerdown default
346
+ and so never enters :active in Firefox (holdFocusOnPress in features/dom.ts). */
347
+ :where(.wt-root) .wt-switcher-new:active,
348
+ :where(.wt-root) .wt-switcher-new.wt-pressed {
343
349
  background: var(--tab-hover-bg);
344
350
  }
345
351
 
@@ -443,10 +449,13 @@
443
449
 
444
450
  /* Ink-centring geometry, identical to .wt-tab-label: a symmetric ink allowance
445
451
  the ellipsis clipping needs, plus a bottom margin of twice --label-ink-shift
446
- that lifts the ink to the row centre. See 30-tabs.css for the derivation, for
447
- why line-height must stay `normal` here, and for why this is margin arithmetic
448
- over the bundled font rather than a text-box trim behind an @supports gate that
449
- 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. */
450
459
  :where(.wt-root) .wt-switcher-row-label {
451
460
  flex: 1 1 auto;
452
461
  overflow: hidden;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "5.2.0",
3
+ "version": "5.2.2",
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",
@@ -57,7 +57,7 @@
57
57
  "fast-check": "4.9.0",
58
58
  "happy-dom": "20.11.1",
59
59
  "html-validate": "11.6.0",
60
- "knip": "6.29.0",
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",
@@ -1,7 +1,8 @@
1
- // Small shared helper for features: build a chrome element from a static,
1
+ // Small shared helpers for features: building a chrome element from a static,
2
2
  // trusted HTML string (icons, buttons) via a <template> clone, the same pattern
3
- // the kernel uses for its core subtree. No interpolation ever passes through
4
- // here, so it carries no injection surface.
3
+ // the kernel uses for its core subtree, and the press wiring the bar buttons
4
+ // share. No interpolation ever passes through here, so it carries no injection
5
+ // surface.
5
6
 
6
7
  /** Parse a static HTML string and return its first element. */
7
8
  export function fromHTML(html: string): HTMLElement {
@@ -13,3 +14,62 @@ export function fromHTML(html: string): HTMLElement {
13
14
  }
14
15
  return el;
15
16
  }
17
+
18
+ /** The press class the chrome buttons wired by holdFocusOnPress paint on
19
+ * themselves. Every `:active` press rule for such a button pairs with it (see
20
+ * the CSS contract test), because those buttons never enter `:active` in
21
+ * Firefox. */
22
+ export const PRESSED_CLASS = "wt-pressed";
23
+
24
+ /** holdFocusOnPress wires a chrome button that must NOT take the keyboard off
25
+ * the terminal, and paints its own press state because of it.
26
+ *
27
+ * Focus: the hidden terminal textarea stays focused. A bar button needs no
28
+ * focus of its own, and letting the press shift focus makes iOS/iPadOS consume
29
+ * the FIRST tap to blur the input (so "+" only fired on the second tap — the
30
+ * reported double-tap), while on a desktop it parks the keyboard on the button,
31
+ * where the strip's own keydown handling eats arrows as "switch tab" and Delete
32
+ * as "close tab". Cancelling pointerdown's default prevents both.
33
+ *
34
+ * Press feedback: cancelling that default is also what suppresses the browser's
35
+ * OWN activation state — Firefox ties `:active` to the mousedown default action,
36
+ * and preventDefault on pointerdown suppresses the compatibility mousedown
37
+ * entirely (measured on Firefox 140: no mousedown event, `matches(":active")`
38
+ * false, background unchanged for as long as the button is held; Chromium and
39
+ * WebKit still apply it). So these buttons — and ONLY these — carry the press
40
+ * class, and their CSS press rules match either. That is why the two jobs live
41
+ * in one helper: a caller that cancels the default without painting the class
42
+ * ships a control with no press feedback in one engine, which is exactly the
43
+ * defect this replaced.
44
+ *
45
+ * The class is dropped on release, on cancel, and when the pointer leaves while
46
+ * held (a press dragged off the button is not an activation), mirroring what
47
+ * `:active` does on its own. A non-primary button press paints nothing, also
48
+ * matching it. The release listeners live on `window` for the life of the press
49
+ * (an `AbortController`, the pattern the switcher gesture uses) rather than on
50
+ * the button: `.wt-scroll-bottom` acts on the press and hides itself, so by the
51
+ * time the release lands the button is `display: none` and receives no
52
+ * `pointerup` at all — bound to the button, the class would stay painted and the
53
+ * control would come back already lit. */
54
+ export function holdFocusOnPress(btn: HTMLElement): void {
55
+ let release: (() => void) | null = null;
56
+ btn.addEventListener("pointerdown", (e) => {
57
+ e.preventDefault();
58
+ if (e.button !== 0 || release !== null) {
59
+ return;
60
+ }
61
+ btn.classList.add(PRESSED_CLASS);
62
+ const held = new AbortController();
63
+ release = () => {
64
+ held.abort();
65
+ release = null;
66
+ btn.classList.remove(PRESSED_CLASS);
67
+ };
68
+ const end = (): void => {
69
+ release?.();
70
+ };
71
+ window.addEventListener("pointerup", end, { signal: held.signal });
72
+ window.addEventListener("pointercancel", end, { signal: held.signal });
73
+ btn.addEventListener("pointerleave", end, { signal: held.signal });
74
+ });
75
+ }
@@ -5,7 +5,7 @@
5
5
  // scroll.scrollToBottom.
6
6
 
7
7
  import type { TerminalFeature } from "../kernel/types.js";
8
- import { fromHTML } from "./dom.js";
8
+ import { fromHTML, holdFocusOnPress } from "./dom.js";
9
9
 
10
10
  const BUTTON_HTML = `
11
11
  <button type="button" class="wt-btn wt-scroll-bottom" aria-label="Scroll to bottom">
@@ -39,14 +39,13 @@ export function scrollToBottom(): TerminalFeature {
39
39
  surface.scrollTo({ top: surface.scrollHeight, behavior: "smooth" });
40
40
  };
41
41
 
42
- // pointerdown (like the toolbar keys) so touch devices show press
43
- // feedback; preventDefault keeps focus on the terminal. click is kept for
44
- // keyboard activation. jump is idempotent, so the pair is safe.
45
- const onDown = (e: PointerEvent): void => {
46
- e.preventDefault();
47
- jump();
48
- };
49
- btn.addEventListener("pointerdown", onDown);
42
+ // pointerdown (like the toolbar keys) so touch devices get the jump on the
43
+ // press rather than the release; click is kept for keyboard activation.
44
+ // jump is idempotent, so the pair is safe. holdFocusOnPress keeps the
45
+ // keyboard on the terminal across the press and paints the press class the
46
+ // cancelled default costs this button.
47
+ holdFocusOnPress(btn);
48
+ btn.addEventListener("pointerdown", jump);
50
49
  btn.addEventListener("click", jump);
51
50
 
52
51
  // Visible only while scrolled up; the region reflects it via a class.
@@ -12,8 +12,9 @@ import { LineStore, modes } from "@cplieger/web-terminal-engine";
12
12
  import type { SessionRef, TerminalContext, TerminalFeature } from "../../kernel/types.js";
13
13
  import type { ActivityMonitorApi } from "../activity-monitor.js";
14
14
  import type { MobileToolbarApi } from "../mobile-toolbar.js";
15
- import { fromHTML } from "../dom.js";
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,
@@ -336,16 +337,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
336
337
  // and reflects its open state on every keyboard button.
337
338
  function makeNewButton(cls: string): HTMLElement {
338
339
  const btn = fromHTML(newButtonHTML(cls));
339
- // Keep the hidden terminal textarea focused: a bar button needs no focus
340
- // of its own, and letting the tap shift focus makes iOS/iPadOS consume
341
- // the FIRST tap to blur the input (so "+" only fired create on the second
342
- // tap — the reported double-tap). preventDefault on pointerdown fires the
343
- // action on the first tap; switchTo then re-focuses the input for the new
344
- // tab (see focusAfterSwitch). It does NOT make one press one activation —
345
- // that is create()'s in-flight coalescing, for the opposite failure.
346
- btn.addEventListener("pointerdown", (e) => {
347
- e.preventDefault();
348
- });
340
+ // Keeps the keyboard on the terminal and paints its own press state;
341
+ // holdFocusOnPress explains why those two are one job. It does NOT make
342
+ // one press one activation — that is create()'s in-flight coalescing,
343
+ // for the opposite failure.
344
+ holdFocusOnPress(btn);
349
345
  btn.addEventListener("click", () => {
350
346
  void create();
351
347
  });
@@ -353,11 +349,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
353
349
  }
354
350
  function makeKbButton(cls: string): HTMLElement {
355
351
  const btn = fromHTML(kbButtonHTML(cls));
356
- // First-tap focus retention (see makeNewButton): keep the terminal
357
- // textarea focused so the toggle fires on the first tap on iOS/iPadOS.
358
- btn.addEventListener("pointerdown", (e) => {
359
- e.preventDefault();
360
- });
352
+ holdFocusOnPress(btn);
361
353
  btn.addEventListener("click", () => {
362
354
  const kb = opts.keyboardToggle ? ctx.use(opts.keyboardToggle) : undefined;
363
355
  if (!kb) {
@@ -375,15 +367,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
375
367
  }
376
368
  // makeSwitchButton builds the mobile switcher's dedicated open/close
377
369
  // button (its notification dot is painted by paintSwitchDot). Like the
378
- // other bar buttons it preventDefaults pointerdown for first-tap focus
379
- // retention (keeps the terminal textarea focused so it fires on the first
380
- // tap on iOS/iPadOS); its click toggles the list (toggleSwitcher opens when
381
- // collapsed, closes when expanded).
370
+ // other bar buttons it goes through holdFocusOnPress; its click toggles the
371
+ // list (toggleSwitcher opens when collapsed, closes when expanded).
382
372
  function makeSwitchButton(): HTMLElement {
383
373
  const btn = fromHTML(switchButtonHTML("wt-switcher-switch wt-btn"));
384
- btn.addEventListener("pointerdown", (e) => {
385
- e.preventDefault();
386
- });
374
+ holdFocusOnPress(btn);
387
375
  btn.addEventListener("click", () => {
388
376
  toggleSwitcher();
389
377
  });
@@ -613,6 +601,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
613
601
  offArmed = kbApi.onCtrlArmedChange(reflectArmed);
614
602
  }
615
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
+
616
610
  // Mark the root so the CSS lifts the bottom-anchored chrome (banner, toast,
617
611
  // scroll-to-bottom, key grid) above the switcher bar on a coarse pointer.
618
612
  const root = ctx.surface().parentElement;
@@ -3226,6 +3220,7 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
3226
3220
  }
3227
3221
  barResize.disconnect();
3228
3222
  swReserve.disconnect();
3223
+ stopInkCentring();
3229
3224
  surface.classList.remove("wt-with-tabbar");
3230
3225
  varRoot.style.removeProperty("--wt-tabbar-h");
3231
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
+ }