@swiftbrowser/web 0.3.0 → 0.4.0

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.
@@ -16,7 +16,7 @@
16
16
  * | `section` | `rows` (the inset card), `header`, `footer` (when present) |
17
17
  * | list rows | `row` (the full-width row rect inside the card); `navlink` rows add `label` and `chevron` (20px at the trailing edge); rows with a Phase 6 `badge` style add `badge` (the text's rect at the trailing inset, full row height) |
18
18
  * | `scrollview` | `content` (the content rect at scroll offset 0) |
19
- * | `list`/`scrollview` | `bottomInset` (Phase 4, only under a tab bar that reaches the screen bottom: the part of the frame the bar covers; the DOM adds its height as scrollable content inset) |
19
+ * | `list`/`scrollview` | `bottomInset` (Phase 4, only under a tab bar that reaches the screen bottom, or a bottom toolbar or search field reaching its screen's bottom: the part of the frame the bar covers; the DOM adds its height as scrollable content inset), `topInset` / `largeTitle` (the screen's bar and the large title row it runs under: its screen's content, or a vertical one in it touching the bar) |
20
20
  * | `tabview` | `bar` (49 + bottom safe area at the bottom), `item0` … `itemN-1` (equal-width 49px items in the bar), `content` (above the bar) |
21
21
  * | `tab` | `content` (the selected tab's safe content area: below the status bar, above the bar); non-selected tabs are in `hidden` |
22
22
  * | `picker` | segmented: `segment0` … `segmentN-1` (equal widths, 32 high); menu: `label` (leading), `value` (the selected option plus chevron, trailing) |
@@ -74,7 +74,10 @@ export {
74
74
  makeFont,
75
75
  DYNAMIC_TYPE_SIZES,
76
76
  TEXT_STYLE_SIZES,
77
- LINE_HEIGHTS,
77
+ LINE_HEIGHT_RATIO,
78
+ LEADINGS,
79
+ leadingFor,
80
+ textHeight,
78
81
  FONT_FAMILIES,
79
82
  FONT_WEIGHTS,
80
83
  type TextStyle,
@@ -20,6 +20,8 @@ function canvasFont(font: ResolvedFont): string {
20
20
  * is available (Node), it falls back to the monospace estimate.
21
21
  */
22
22
  export class CanvasTextMeasurer implements TextMeasurer {
23
+ /** Set by the renderer: measures a `host` element's content in the DOM. */
24
+ measureHost?: TextMeasurer['measureHost'];
23
25
  private context: Canvas2D | null | undefined;
24
26
  private readonly cache = new Map<string, number>();
25
27
  private readonly fallback = new MonospaceTextMeasurer();
@@ -262,12 +262,15 @@ export function wrapRuns(
262
262
  }
263
263
 
264
264
  const merged = lines.map(mergePieces);
265
- const heights = merged.map((line) => {
266
- if (line.length === 0) return base.lineHeight;
265
+ // The first line is a line tall, each further one a leading (the
266
+ // distance from the line above's baseline).
267
+ const heights = merged.map((line, index) => {
268
+ const metric = (font: ResolvedFont): number => (index === 0 ? font.lineHeight : font.leading);
269
+ if (line.length === 0) return metric(base);
267
270
  let height = 0;
268
271
  for (const piece of line) {
269
272
  const font = (runs[piece.run] as TextRunInput).font;
270
- height = Math.max(height, piece.height ?? font.lineHeight, font.lineHeight);
273
+ height = Math.max(height, piece.height ?? metric(font), metric(font));
271
274
  }
272
275
  return height;
273
276
  });
@@ -62,8 +62,10 @@ export interface ResolvedFont {
62
62
  size: number;
63
63
  /** CSS numeric weight, 100...900. */
64
64
  weight: number;
65
- /** Line height in CSS px (an integer). */
65
+ /** A line's height in CSS px (a multiple of 1/3: a 3x device pixel). */
66
66
  lineHeight: number;
67
+ /** The distance between a paragraph's baselines (the HIG leading); an SF Symbol image is this square. */
68
+ leading: number;
67
69
  /** A CSS `font` shorthand (`weight size/lineHeight family`) usable verbatim. */
68
70
  css: string;
69
71
  /**
@@ -79,6 +81,12 @@ export interface ResolvedFont {
79
81
  export interface TextMeasurer {
80
82
  /** Width of one line of text in CSS px when drawn with `font`. */
81
83
  measureWidth(text: string, font: ResolvedFont): number;
84
+ /**
85
+ * A `host` element's content (src/hostContent.ts, by key) laid out at
86
+ * `width` CSS px, or at its own ideal width when null: its size. Absent in
87
+ * headless layouts, where a host measures as a 10×10 placeholder.
88
+ */
89
+ measureHost?: ((content: string, width: number | null) => Size) | undefined;
82
90
  }
83
91
 
84
92
  export interface LayoutEnvironment {
@@ -93,6 +101,13 @@ export interface LayoutEnvironment {
93
101
  * images then measure as unknown (44×44 placeholders).
94
102
  */
95
103
  assets?: AssetCatalog;
104
+ /**
105
+ * The root's height is its content's, not the screen's: a SwiftUI island
106
+ * in a page's own layout (@swiftbrowser/react's `SwiftUIView`). A plain
107
+ * root is proposed the screen width and no height and placed at the top;
108
+ * navigation and tab roots still fill the screen.
109
+ */
110
+ contentHeight?: boolean;
96
111
  }
97
112
 
98
113
  export type TextAlignment = 'leading' | 'center' | 'trailing';
@@ -4,8 +4,11 @@
4
4
  * Point sizes come from Apple's Human Interface Guidelines, "Typography" >
5
5
  * "Specifications" > "iOS, iPadOS Dynamic Type sizes" and "Larger
6
6
  * accessibility type sizes" (https://developer.apple.com/design/human-interface-guidelines/typography).
7
- * Line heights for the Large (default) sizes are the iOS values from the same
8
- * tables; other sizes use round(size * 1.2).
7
+ * A line is as tall as SwiftUI makes a `Text`: SF Pro's ascender plus
8
+ * descender (`UIFont.lineHeight`, 1.1934 × the point size), rounded up to a
9
+ * device pixel on a 3x iPhone (body 17 → 20.33, large title 34 → 40.67;
10
+ * measured in the iOS 26.2 simulator). The HIG's tables list a taller
11
+ * leading (body 22): the distance between a paragraph's lines (`LEADINGS`).
9
12
  */
10
13
 
11
14
  import type { DynamicTypeSize, FontFamily, LayoutEnvironment, ResolvedFont } from './types';
@@ -60,8 +63,13 @@ export const TEXT_STYLE_SIZES: Record<TextStyle, readonly number[]> = {
60
63
  caption2: [11, 11, 11, 11, 13, 15, 17, 20, 24, 29, 34, 40],
61
64
  };
62
65
 
63
- /** iOS line heights for the standard (Large) point sizes. */
64
- export const LINE_HEIGHTS: Readonly<Record<number, number>> = {
66
+ /**
67
+ * The HIG's leading for the standard (Large) point sizes: the distance
68
+ * between the baselines of a paragraph's lines, which SwiftUI keeps (a
69
+ * multi-line body `Text` is 20.33 + 22 per further line); other sizes use
70
+ * round(size * 1.2). An SF Symbol image takes a square of it.
71
+ */
72
+ export const LEADINGS: Readonly<Record<number, number>> = {
65
73
  34: 41,
66
74
  28: 34,
67
75
  22: 28,
@@ -120,10 +128,22 @@ export function textStyleSize(style: TextStyle, size: DynamicTypeSize): number {
120
128
  return row[sizeCategoryIndex(size)] ?? row[LARGE_INDEX] ?? 17;
121
129
  }
122
130
 
123
- /** Line height for a point size: the iOS value for standard sizes, else round(size * 1.2). */
131
+ /** SF Pro's ascender plus descender per point (1950 + 494 of 2048 units per em). */
132
+ export const LINE_HEIGHT_RATIO = 2444 / 2048;
133
+
134
+ /** A line's height at a point size: `UIFont.lineHeight`, rounded up to a 3x pixel. */
124
135
  export function lineHeightFor(size: number): number {
125
- const known = LINE_HEIGHTS[size];
126
- return known ?? Math.round(size * 1.2);
136
+ return Math.ceil(size * LINE_HEIGHT_RATIO * 3 - 1e-6) / 3;
137
+ }
138
+
139
+ /** The distance between a paragraph's baselines at a point size. */
140
+ export function leadingFor(size: number): number {
141
+ return LEADINGS[size] ?? Math.round(size * 1.2);
142
+ }
143
+
144
+ /** The height of `lines` lines of text in `font`: the first line, then its leading per further line. */
145
+ export function textHeight(lines: number, font: ResolvedFont): number {
146
+ return lines > 0 ? font.lineHeight + (lines - 1) * font.leading : 0;
127
147
  }
128
148
 
129
149
  function formatPx(value: number): string {
@@ -138,14 +158,14 @@ export function makeFont(
138
158
  modifiers: { weightModifier?: number | undefined; familyModifier?: FontFamily | undefined } = {},
139
159
  ): ResolvedFont {
140
160
  const lineHeight = lineHeightFor(size);
141
- const css = `${weight} ${formatPx(size)}/${lineHeight}px ${FONT_FAMILIES[family]}`;
142
- const font: ResolvedFont = { family, size, weight, lineHeight, css };
161
+ const css = `${weight} ${formatPx(size)}/${formatPx(lineHeight)} ${FONT_FAMILIES[family]}`;
162
+ const font: ResolvedFont = { family, size, weight, lineHeight, leading: leadingFor(size), css };
143
163
  if (modifiers.weightModifier !== undefined) font.weightModifier = modifiers.weightModifier;
144
164
  if (modifiers.familyModifier !== undefined) font.familyModifier = modifiers.familyModifier;
145
165
  return font;
146
166
  }
147
167
 
148
- /** Body text for the current `dynamicTypeSize` (17/22 at Large). */
168
+ /** Body text for the current `dynamicTypeSize` (17/20.33 at Large). */
149
169
  export function defaultFont(env: LayoutEnvironment): ResolvedFont {
150
170
  return makeFont('system', textStyleSize('body', env.dynamicTypeSize), 400);
151
171
  }
package/src/layoutDom.ts CHANGED
@@ -37,7 +37,8 @@ import type { LayoutEnvironment, LayoutNode, LayoutResult, Rect, ResolvedFont, T
37
37
  import { applyChartChrome } from './chartDom';
38
38
  import { bitmapLaidOut } from './bitmaps';
39
39
  import { drawRichText } from './richText';
40
- import { bindLargeTitle } from './largeTitle';
40
+ import { bindScrollUnderBar, largeTitleOf, largeTitleScrolls } from './largeTitle';
41
+ import { bindScrollEdgeBottom } from './scrollEdge';
41
42
 
42
43
  export interface LayoutDomContext {
43
44
  root: HTMLElement;
@@ -236,6 +237,27 @@ function positionBox(el: HTMLElement, box: Rect, origin: { x: number; y: number
236
237
  }
237
238
 
238
239
  /** Position a chrome node inside its element: `rect` is in screen coordinates. */
240
+ /**
241
+ * An edit-mode control fades in when it first appears (the list started
242
+ * editing), marked `data-sb-appear` for the animation's duration. A control
243
+ * that is laid out again while shown, or whose row is moved in the DOM (a
244
+ * reorder re-inserts the row, which would restart a CSS animation on it),
245
+ * is not marked and stays put.
246
+ */
247
+ function placeEditControl(node: HTMLElement | null, rect: Rect | undefined, origin: Rect): void {
248
+ if (!node) return;
249
+ const wasHidden = node.hidden;
250
+ placeChrome(node, rect, origin);
251
+ if (wasHidden && !node.hidden) {
252
+ node.dataset['sbAppear'] = '';
253
+ const settle = (): void => {
254
+ delete node.dataset['sbAppear'];
255
+ };
256
+ node.addEventListener('animationend', settle, { once: true });
257
+ node.addEventListener('animationcancel', settle, { once: true });
258
+ }
259
+ }
260
+
239
261
  function placeChrome(node: HTMLElement | null, rect: Rect | undefined, origin: Rect): void {
240
262
  if (!node) return;
241
263
  if (!rect) {
@@ -261,16 +283,21 @@ function positionSheetCard(sheet: HTMLElement, frame: Rect): void {
261
283
  s.height = `${frame.height}px`;
262
284
  }
263
285
 
264
- function setFont(el: HTMLElement, font: ResolvedFont | undefined): void {
286
+ /**
287
+ * `lines` > 1: a paragraph, whose lines are a leading apart (the engine makes
288
+ * it the first line's height plus a leading per further line).
289
+ */
290
+ function setFont(el: HTMLElement, font: ResolvedFont | undefined, lines = 1): void {
265
291
  if (!font) return;
266
- if (el.style.font !== font.css) el.style.font = font.css;
292
+ const css = lines > 1 ? font.css.replace(/\/[\d.]+px /, `/${Math.round(font.leading * 100) / 100}px `) : font.css;
293
+ if (el.style.font !== css) el.style.font = css;
267
294
  }
268
295
 
269
296
  const TEXT_ALIGN: Record<string, string> = { leading: 'left', center: 'center', trailing: 'right' };
270
297
 
271
298
  /** Render the engine's exact lines into a text node (`white-space: pre` keeps them). */
272
299
  function setTextLayout(el: HTMLElement, layout: TextLayout): void {
273
- setFont(el, layout.font);
300
+ setFont(el, layout.font, layout.lines.length);
274
301
  const align = TEXT_ALIGN[layout.alignment] ?? 'left';
275
302
  if (el.style.textAlign !== align) el.style.textAlign = align;
276
303
  if (layout.rich && el.dataset['sbRich'] !== undefined) {
@@ -373,8 +400,9 @@ function applyChrome(
373
400
  }
374
401
  case 'textfield':
375
402
  case 'texteditor':
376
- // A vertical text field draws in the font its lines were measured in.
377
- setFont(el, result.fonts.get(node.id));
403
+ // A vertical text field draws in the font its lines were measured in,
404
+ // a leading apart.
405
+ setFont(el, result.fonts.get(node.id), el instanceof HTMLTextAreaElement ? 2 : 1);
378
406
  padIntoFrame(el, box, frame);
379
407
  return;
380
408
  // Phase 8 controls: their chrome sits at the engine's rects inside the element's box.
@@ -463,7 +491,7 @@ function applyChrome(
463
491
  if (header && texts?.['header']) setTextLayout(header, texts['header']);
464
492
  if (footer && texts?.['footer']) {
465
493
  // The prop text in the engine's font and rect, wrapped there.
466
- setFont(footer, texts['footer'].font);
494
+ setFont(footer, texts['footer'].font, texts['footer'].lines.length);
467
495
  footer.style.textAlign = 'left';
468
496
  footer.style.whiteSpace = 'pre-wrap';
469
497
  const original = typeof node.props['footer'] === 'string' ? node.props['footer'] : texts['footer'].lines.join('\n');
@@ -479,7 +507,8 @@ function applyChrome(
479
507
  // Extra scrollable space at the end (the engine's frames are unchanged).
480
508
  content.style.height = `${size.height + bottomInset}px`;
481
509
  }
482
- bindLargeTitle(el, chrome?.['largeTitle'] !== undefined);
510
+ bindScrollUnderBar(el, frame, chrome?.['topInset'], chrome?.['largeTitle']);
511
+ bindScrollEdgeBottom(el, chrome?.['bottomInset']);
483
512
  return;
484
513
  }
485
514
  case 'scrollview': {
@@ -494,24 +523,33 @@ function applyChrome(
494
523
  s.marginLeft = `${rect ? rect.x - frame.x : 0}px`;
495
524
  s.marginTop = `${rect ? rect.y - frame.y : 0}px`;
496
525
  }
497
- bindLargeTitle(el, chrome?.['largeTitle'] !== undefined);
526
+ bindScrollUnderBar(el, frame, chrome?.['topInset'], chrome?.['largeTitle']);
527
+ bindScrollEdgeBottom(el, String(node.props['axes'] ?? 'vertical') === 'horizontal' ? undefined : chrome?.['bottomInset']);
498
528
  return;
499
529
  }
500
530
  case 'navscreen': {
501
531
  const bar = el.querySelector<HTMLElement>(':scope > .sb-navbar');
502
- const largeTitle = el.querySelector<HTMLElement>(':scope > .sb-navscreen-body > .sb-navbar-large-title');
532
+ const largeTitle = largeTitleOf(el);
503
533
  const inlineTitle = el.querySelector<HTMLElement>(':scope > .sb-navbar > .sb-navbar-title');
504
534
  const texts = result.chromeText.get(node.id);
505
535
  const barRect = chrome?.['bar'];
506
536
  if (bar && barRect) {
507
537
  placeChrome(bar, barRect, frame);
508
- // The 44px bar row sits under the status bar portion of the rect.
509
- bar.style.paddingTop = `${Math.max(0, barRect.height - NAV_BAR_HEIGHT)}px`;
538
+ // The 44px bar row sits under the status bar portion of the rect
539
+ // (iOS 26: an inline bar keeps 10 more below it).
540
+ const row = chrome?.['barRow'];
541
+ const top = row ? row.y - barRect.y : Math.max(0, barRect.height - NAV_BAR_HEIGHT);
542
+ const bottom = row ? Math.max(0, barRect.y + barRect.height - (row.y + row.height)) : 0;
543
+ bar.style.paddingTop = `${top}px`;
544
+ bar.style.paddingBottom = `${bottom}px`;
510
545
  }
511
546
  const largeRect = chrome?.['largeTitle'];
512
547
  if (largeTitle) {
513
548
  if (largeRect) {
514
- placeChrome(largeTitle, largeRect, frame);
549
+ // A title that scrolls with the screen's scroll view is placed by
550
+ // it (src/largeTitle.ts).
551
+ if (largeTitleScrolls(el, largeTitle)) largeTitle.hidden = false;
552
+ else placeChrome(largeTitle, largeRect, frame);
515
553
  if (texts?.['largeTitle']) {
516
554
  setFont(largeTitle, texts['largeTitle'].font);
517
555
  const line = texts['largeTitle'].lines.join('\n');
@@ -539,6 +577,10 @@ function applyChrome(
539
577
  if (back && backLabel && backRect && texts?.['back']) {
540
578
  const text = texts['back'].lines[0] ?? '';
541
579
  back.style.maxWidth = `${backRect.width}px`;
580
+ // The bar pads its row 16 (styles.css); a split view's sidebar
581
+ // toggle can push the back button further in (chrome.back).
582
+ const indent = barRect ? Math.max(0, backRect.x - barRect.x - 16) : 0;
583
+ back.style.marginLeft = indent > 0 ? `${indent}px` : '';
542
584
  backLabel.dataset['sbLayoutLabel'] = text;
543
585
  if (backLabel.textContent !== text) backLabel.textContent = text;
544
586
  setFont(back, texts['back'].font);
@@ -563,9 +605,15 @@ function applyChrome(
563
605
  items.forEach((item, i) => placeChrome(item, chrome?.[`item${i}`], barRect));
564
606
  return;
565
607
  }
608
+ case 'inspector': {
609
+ setFlag(el, 'sbPresented', node.props['presented'] === true ? '' : null);
610
+ return;
611
+ }
566
612
  case 'navsplit': {
567
- // Phase 7: the hairline between the two columns.
613
+ // Phase 7: the hairline between the two columns, and the sidebar toggle.
568
614
  placeChrome(el.querySelector<HTMLElement>(':scope > .sb-navsplit-separator'), chrome?.['separator'], box);
615
+ placeChrome(el.querySelector<HTMLElement>(':scope > .sb-navsplit-toggle'), chrome?.['toggle'], box);
616
+ setFlag(el, 'sbSidebarHidden', node.props['sidebarVisible'] === false ? '' : null);
569
617
  return;
570
618
  }
571
619
  case 'gauge': {
@@ -583,8 +631,10 @@ function applyChrome(
583
631
  return;
584
632
  case 'listrow':
585
633
  // Phase 8: the edit-mode control (selection circle / minus) at the
586
- // engine's `editControl` rect; hidden when the list is not editing.
587
- placeChrome(el.querySelector<HTMLElement>(':scope > .sb-listrow-control'), chrome?.['editControl'], box);
634
+ // engine's `editControl` rect and the reorder grip at `reorderControl`;
635
+ // hidden when the list is not editing.
636
+ placeEditControl(el.querySelector<HTMLElement>(':scope > .sb-listrow-control'), chrome?.['editControl'], box);
637
+ placeEditControl(el.querySelector<HTMLElement>(':scope > .sb-swipe-content > .sb-listrow-grip'), chrome?.['reorderControl'], box);
588
638
  return;
589
639
  case 'picker': {
590
640
  setFont(el, result.fonts.get(node.id));
@@ -0,0 +1,182 @@
1
+ /**
2
+ * What a page with a mouse adds to a phone's UI: `hover` events (`.onHover`),
3
+ * tooltips (`.help`), cursors (`.pointerStyle`), and SF Symbols' effects
4
+ * (`.symbolEffect`) drawn with CSS. The Swift shim draws an iPhone and sends
5
+ * none of these; producers in the page (@swiftbrowser/react) do.
6
+ */
7
+
8
+ import type { ElementId, Style, SymbolEffectStyle } from './protocol';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Hover
12
+ // ---------------------------------------------------------------------------
13
+
14
+ const HOVER_ATTR = 'sbHover';
15
+
16
+ /** Sends `hover` as a mouse enters and leaves the boxes marked `data-sb-hover` under `root`. */
17
+ export class HoverTracker {
18
+ private readonly hovered = new Set<HTMLElement>();
19
+
20
+ constructor(
21
+ private readonly root: HTMLElement,
22
+ private readonly send: (id: ElementId, value: boolean) => void,
23
+ ) {
24
+ root.addEventListener('pointerover', this.onOver);
25
+ root.addEventListener('pointerout', this.onOut);
26
+ }
27
+
28
+ dispose(): void {
29
+ this.root.removeEventListener('pointerover', this.onOver);
30
+ this.root.removeEventListener('pointerout', this.onOut);
31
+ this.hovered.clear();
32
+ }
33
+
34
+ private readonly onOver = (event: PointerEvent): void => {
35
+ if (event.pointerType === 'touch') return;
36
+ for (let el = event.target as HTMLElement | null; el && el !== this.root; el = el.parentElement) {
37
+ if (el.dataset[HOVER_ATTR] === undefined || this.hovered.has(el)) continue;
38
+ this.hovered.add(el);
39
+ this.report(el, true);
40
+ }
41
+ };
42
+
43
+ private readonly onOut = (event: PointerEvent): void => {
44
+ if (event.pointerType === 'touch') return;
45
+ const next = event.relatedTarget as Node | null;
46
+ for (const el of [...this.hovered]) {
47
+ if (next && el.contains(next)) continue;
48
+ this.hovered.delete(el);
49
+ this.report(el, false);
50
+ }
51
+ };
52
+
53
+ private report(el: HTMLElement, value: boolean): void {
54
+ const id = Number(el.dataset['sbId']);
55
+ if (Number.isInteger(id) && el.isConnected) this.send(id, value);
56
+ }
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Style fields
61
+ // ---------------------------------------------------------------------------
62
+
63
+ /** One run of each effect at speed 1 (seconds) and its keyframes on the CSS `scale`, `rotate`, `translate` and `opacity` properties. */
64
+ const EFFECTS: Record<string, { duration: number; frames: Keyframe[] }> = {
65
+ bounce: {
66
+ duration: 0.5,
67
+ frames: [
68
+ { scale: '1', translate: '0 0', offset: 0 },
69
+ { scale: '1.18', translate: '0 -12%', offset: 0.35 },
70
+ { scale: '0.94', translate: '0 0', offset: 0.7 },
71
+ { scale: '1', translate: '0 0', offset: 1 },
72
+ ],
73
+ },
74
+ pulse: { duration: 1.2, frames: [{ opacity: '1' }, { opacity: '0.35' }, { opacity: '1' }] },
75
+ variableColor: { duration: 1.2, frames: [{ opacity: '1' }, { opacity: '0.5', offset: 0.33 }, { opacity: '0.75', offset: 0.66 }, { opacity: '1' }] },
76
+ rotate: { duration: 1, frames: [{ rotate: '0deg' }, { rotate: '360deg' }] },
77
+ wiggle: {
78
+ duration: 0.6,
79
+ frames: [{ rotate: '0deg' }, { rotate: '-14deg', offset: 0.25 }, { rotate: '14deg', offset: 0.6 }, { rotate: '0deg' }],
80
+ },
81
+ breathe: { duration: 2, frames: [{ scale: '1', opacity: '1' }, { scale: '1.1', opacity: '0.7' }, { scale: '1', opacity: '1' }] },
82
+ };
83
+
84
+ const playing = new WeakMap<HTMLElement, { key: string; animation: globalThis.Animation }>();
85
+ const triggers = new WeakMap<HTMLElement, number>();
86
+
87
+ /** Apply the pointer and effect fields of a `styled` element's style (called on create and update). */
88
+ export function applyPointerAndEffects(el: HTMLElement, style: Style): void {
89
+ if (style.hover) el.dataset[HOVER_ATTR] = '';
90
+ if (style.help) {
91
+ el.title = style.help;
92
+ el.dataset['sbHelp'] = '';
93
+ } else if (el.hasAttribute('title') && el.classList.contains('sb-styled')) {
94
+ el.removeAttribute('title');
95
+ }
96
+ if (style.pointerStyle) el.style.cursor = style.pointerStyle;
97
+ applySymbolEffect(el, style.symbolEffect);
98
+ if (style.matchedGeometry) el.dataset['sbMatched'] = style.matchedGeometry;
99
+ if (style.contentTransition) {
100
+ el.dataset['sbContentTransition'] = style.contentTransition.kind;
101
+ const { countsDown } = style.contentTransition;
102
+ if (countsDown === undefined) delete el.dataset['sbCountsDown'];
103
+ else el.dataset['sbCountsDown'] = String(countsDown);
104
+ }
105
+ }
106
+
107
+ function applySymbolEffect(el: HTMLElement, effect: SymbolEffectStyle | undefined): void {
108
+ const s = el.style;
109
+ if (!effect) {
110
+ stopEffect(el);
111
+ return;
112
+ }
113
+ el.dataset['sbSymbolEffect'] = effect.effect;
114
+ // State effects: a look kept while the style carries them.
115
+ if (effect.effect === 'scale') {
116
+ s.transition = 'scale 0.25s';
117
+ s.scale = effect.direction === 'down' ? '0.85' : '1.2';
118
+ return;
119
+ }
120
+ if (effect.effect === 'appear' || effect.effect === 'disappear') {
121
+ s.transition = 'opacity 0.25s, scale 0.25s';
122
+ const shown = effect.effect === 'appear';
123
+ s.opacity = shown ? '1' : '0';
124
+ s.scale = shown ? '1' : '0.6';
125
+ return;
126
+ }
127
+ const spec = EFFECTS[effect.effect];
128
+ if (!spec || typeof el.animate !== 'function') return;
129
+ const duration = (spec.duration / Math.max(effect.speed ?? 1, 0.01)) * 1000;
130
+ const repeat = effect.repeat === 'forever' ? Infinity : Math.max(1, effect.repeat ?? 1);
131
+ if (effect.trigger !== undefined) {
132
+ // A discrete effect plays once per new trigger (never for the first value).
133
+ const last = triggers.get(el);
134
+ triggers.set(el, effect.trigger);
135
+ if (last === undefined || last === effect.trigger) return;
136
+ playing.get(el)?.animation.cancel();
137
+ playing.set(el, { key: `once:${effect.trigger}`, animation: el.animate(spec.frames, { duration, iterations: repeat, easing: 'ease-in-out' }) });
138
+ return;
139
+ }
140
+ // An indefinite effect keeps running across updates that leave it alone.
141
+ const key = `${effect.effect}:${duration}:${repeat === Infinity ? 'forever' : repeat}`;
142
+ const current = playing.get(el);
143
+ if (current && current.key === key && current.animation.playState !== 'finished') return;
144
+ current?.animation.cancel();
145
+ const iterations = effect.repeat === undefined ? Infinity : repeat;
146
+ playing.set(el, { key, animation: el.animate(spec.frames, { duration, iterations, easing: 'ease-in-out' }) });
147
+ }
148
+
149
+ function stopEffect(el: HTMLElement): void {
150
+ const current = playing.get(el);
151
+ if (current && !current.key.startsWith('once:')) {
152
+ current.animation.cancel();
153
+ playing.delete(el);
154
+ }
155
+ triggers.delete(el);
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // Geometry readers that report their frame
160
+ // ---------------------------------------------------------------------------
161
+
162
+ export interface SafeAreaInsets {
163
+ top: number;
164
+ leading: number;
165
+ bottom: number;
166
+ trailing: number;
167
+ }
168
+
169
+ /** How far the device's safe area reaches into a box at `rect` in a root of `size`. */
170
+ export function safeAreaOverlap(
171
+ rect: { x: number; y: number; width: number; height: number },
172
+ size: { width: number; height: number },
173
+ safeArea: SafeAreaInsets,
174
+ ): SafeAreaInsets {
175
+ const clamp = (value: number, max: number): number => Math.round(Math.min(Math.max(value, 0), max));
176
+ return {
177
+ top: clamp(safeArea.top - rect.y, rect.height),
178
+ bottom: clamp(rect.y + rect.height - (size.height - safeArea.bottom), rect.height),
179
+ leading: clamp(safeArea.leading - rect.x, rect.width),
180
+ trailing: clamp(rect.x + rect.width - (size.width - safeArea.trailing), rect.width),
181
+ };
182
+ }