@cplieger/web-terminal-ui 5.2.1 → 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 +18 -9
- package/css/30-tabs.css +45 -21
- package/css/31-switcher.css +14 -8
- package/package.json +2 -2
- package/src/features/tabs/index.ts +8 -0
- package/src/features/tabs/ink-centre.ts +262 -0
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).
|
|
147
|
+
30-tabs.css).
|
|
148
148
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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;
|
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.
|
|
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 —
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
/*
|
|
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:
|
|
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.
|
package/css/31-switcher.css
CHANGED
|
@@ -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.
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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.
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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.
|
|
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.
|
|
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
|
+
}
|