@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 +18 -9
- package/css/10-primitives.css +5 -1
- package/css/21-scroll-to-bottom.css +5 -1
- package/css/30-tabs.css +50 -23
- package/css/31-switcher.css +18 -9
- package/package.json +2 -2
- package/src/features/dom.ts +63 -3
- package/src/features/scroll-to-bottom.ts +8 -9
- package/src/features/tabs/index.ts +18 -23
- 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/10-primitives.css
CHANGED
|
@@ -38,7 +38,11 @@
|
|
|
38
38
|
user-select: none;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
@@ -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
|
-
|
|
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
|
|
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;
|
|
@@ -339,7 +342,10 @@
|
|
|
339
342
|
}
|
|
340
343
|
}
|
|
341
344
|
|
|
342
|
-
|
|
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.
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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.
|
|
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",
|
package/src/features/dom.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
// Small shared
|
|
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
|
|
4
|
-
// here, so it carries no injection
|
|
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
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
|
|
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
|
-
|
|
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
|
|
379
|
-
//
|
|
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
|
|
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
|
+
}
|