@swiftbrowser/web 0.3.1 → 0.5.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.
@@ -9,15 +9,15 @@
9
9
  *
10
10
  * | kind | keys |
11
11
  * |---------------------|------|
12
- * | `navscreen` | `bar` (status bar + 44), `largeTitle` (only when the title is large), `back` (only when depth > 0), `title` (Phase 6: the centered inline title's rect, truncated to the room the bar items leave; absent for a large title or a `principal` toolbar), `searchField` (Phase 6: the 36px field of a `searchfield` child), `bottomBar` (Phase 6: 49 + bottom safe area, when a `bottom` toolbar is present), `content` |
12
+ * | `navscreen` | `bar` (status bar + 44), `largeTitle` (only when the title is large), `back` (only when depth > 0), `title` (Phase 6: the centered inline title's rect, truncated to the room the bar items leave; absent for a large title or a `principal` toolbar), `searchField` (Phase 6: the 36px field of a `searchfield` child), `bottomBar` (iOS 26: the band from a `bottom` toolbar's 48 glass row to the screen bottom, when one is present), `bottomGlass0` … (its glass capsules, in order), `content` |
13
13
  * | `sheet` | `grabber` (36×5), `content` (below the 20px grabber row) |
14
14
  * | `toggle` | `label`, `switch` (51×31 at the trailing edge) |
15
15
  * | `button` (bordered) | `label` |
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) |
20
- * | `tabview` | `bar` (49 + bottom safe area at the bottom), `item0` … `itemN-1` (equal-width 49px items in the bar), `content` (above the bar) |
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
+ * | `tabview` | `bar` (iOS 26: the 62 glass capsule, as wide as its tabs up to the width less 21 either side, centered), `item0` … `itemN-1` (equal-width items inside its 4 inset), `content` (above the bar and the gap under it) |
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) |
23
23
  * | `gauge` | `track` (the 4px bar), `fill` (the leading `value` share of the track) (Phase 6) |
@@ -25,7 +25,7 @@
25
25
  * `LayoutResult.layouts` (Phase 6) holds, per `layout` element, the proposal
26
26
  * it was placed with and its children's min / ideal / max sizes.
27
27
  * | `picker` | segmented: `segment0` … `segmentN-1` (equal widths, 32 high); menu: `label` (leading), `value` (the selected option plus chevron, trailing); inline (Phase 6): `label` (a 44 row, only for a non-empty label), `option0` … `optionN-1` (44 rows spanning the list row when there is one) |
28
- * | `toolbar` | none; the element's frame is its item group in the bar (or the whole bottom bar) and the items have ordinary frames |
28
+ * | `toolbar` | none; the element's frame is its item group in the bar (in a bottom bar: the span of its items in the glass row; a `spacer` toolbar is hidden) and the items have ordinary frames |
29
29
  * | `navsplit` | `sidebar` (the sidebar column, including the leading safe-area inset), `separator` (the 1px hairline), `detail` (the detail column, including the trailing inset) (Phase 7) |
30
30
  * | `listrow` | Phase 8: `editControl` (the 44px leading control, full row height) while the enclosing `list` has `editing`; the row content is shifted right by 44. `swipeactions` / `contextmenu` have no keys: their children after the first are in `hidden` without frames |
31
31
  * | `alert` | Phase 8: `card` (270 wide, centered; a dialog's button card), `title` / `message` (when non-empty; `chromeText` carries the centered lines), `button<i>` (the i-th child's row), `ok` (the renderer-drawn OK, only without button children), `cancelCard` (dialog: the `cancel` button's own card) |
@@ -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,47 @@ 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
+
261
+ /**
262
+ * iOS 26: the glass a screen's bottom toolbar items float on, one element
263
+ * per capsule (engine `bottomGlass0` …), under the items (styles.css).
264
+ */
265
+ function syncBottomGlass(screen: HTMLElement, chrome: Record<string, Rect> | undefined, frame: Rect): void {
266
+ const existing = Array.from(screen.querySelectorAll<HTMLElement>(':scope > .sb-bottom-glass'));
267
+ let count = 0;
268
+ for (let rect = chrome?.['bottomGlass0']; rect; rect = chrome?.[`bottomGlass${count}`]) {
269
+ let glass = existing[count];
270
+ if (!glass) {
271
+ glass = document.createElement('div');
272
+ glass.className = 'sb-bottom-glass';
273
+ screen.append(glass);
274
+ }
275
+ placeChrome(glass, rect, frame);
276
+ count += 1;
277
+ }
278
+ for (const extra of existing.slice(count)) extra.remove();
279
+ }
280
+
239
281
  function placeChrome(node: HTMLElement | null, rect: Rect | undefined, origin: Rect): void {
240
282
  if (!node) return;
241
283
  if (!rect) {
@@ -261,16 +303,21 @@ function positionSheetCard(sheet: HTMLElement, frame: Rect): void {
261
303
  s.height = `${frame.height}px`;
262
304
  }
263
305
 
264
- function setFont(el: HTMLElement, font: ResolvedFont | undefined): void {
306
+ /**
307
+ * `lines` > 1: a paragraph, whose lines are a leading apart (the engine makes
308
+ * it the first line's height plus a leading per further line).
309
+ */
310
+ function setFont(el: HTMLElement, font: ResolvedFont | undefined, lines = 1): void {
265
311
  if (!font) return;
266
- if (el.style.font !== font.css) el.style.font = font.css;
312
+ const css = lines > 1 ? font.css.replace(/\/[\d.]+px /, `/${Math.round(font.leading * 100) / 100}px `) : font.css;
313
+ if (el.style.font !== css) el.style.font = css;
267
314
  }
268
315
 
269
316
  const TEXT_ALIGN: Record<string, string> = { leading: 'left', center: 'center', trailing: 'right' };
270
317
 
271
318
  /** Render the engine's exact lines into a text node (`white-space: pre` keeps them). */
272
319
  function setTextLayout(el: HTMLElement, layout: TextLayout): void {
273
- setFont(el, layout.font);
320
+ setFont(el, layout.font, layout.lines.length);
274
321
  const align = TEXT_ALIGN[layout.alignment] ?? 'left';
275
322
  if (el.style.textAlign !== align) el.style.textAlign = align;
276
323
  if (layout.rich && el.dataset['sbRich'] !== undefined) {
@@ -373,8 +420,9 @@ function applyChrome(
373
420
  }
374
421
  case 'textfield':
375
422
  case 'texteditor':
376
- // A vertical text field draws in the font its lines were measured in.
377
- setFont(el, result.fonts.get(node.id));
423
+ // A vertical text field draws in the font its lines were measured in,
424
+ // a leading apart.
425
+ setFont(el, result.fonts.get(node.id), el instanceof HTMLTextAreaElement ? 2 : 1);
378
426
  padIntoFrame(el, box, frame);
379
427
  return;
380
428
  // Phase 8 controls: their chrome sits at the engine's rects inside the element's box.
@@ -463,7 +511,7 @@ function applyChrome(
463
511
  if (header && texts?.['header']) setTextLayout(header, texts['header']);
464
512
  if (footer && texts?.['footer']) {
465
513
  // The prop text in the engine's font and rect, wrapped there.
466
- setFont(footer, texts['footer'].font);
514
+ setFont(footer, texts['footer'].font, texts['footer'].lines.length);
467
515
  footer.style.textAlign = 'left';
468
516
  footer.style.whiteSpace = 'pre-wrap';
469
517
  const original = typeof node.props['footer'] === 'string' ? node.props['footer'] : texts['footer'].lines.join('\n');
@@ -479,7 +527,8 @@ function applyChrome(
479
527
  // Extra scrollable space at the end (the engine's frames are unchanged).
480
528
  content.style.height = `${size.height + bottomInset}px`;
481
529
  }
482
- bindLargeTitle(el, chrome?.['largeTitle'] !== undefined);
530
+ bindScrollUnderBar(el, frame, chrome?.['topInset'], chrome?.['largeTitle']);
531
+ bindScrollEdgeBottom(el, chrome?.['bottomInset']);
483
532
  return;
484
533
  }
485
534
  case 'scrollview': {
@@ -494,24 +543,34 @@ function applyChrome(
494
543
  s.marginLeft = `${rect ? rect.x - frame.x : 0}px`;
495
544
  s.marginTop = `${rect ? rect.y - frame.y : 0}px`;
496
545
  }
497
- bindLargeTitle(el, chrome?.['largeTitle'] !== undefined);
546
+ bindScrollUnderBar(el, frame, chrome?.['topInset'], chrome?.['largeTitle']);
547
+ bindScrollEdgeBottom(el, String(node.props['axes'] ?? 'vertical') === 'horizontal' ? undefined : chrome?.['bottomInset']);
498
548
  return;
499
549
  }
500
550
  case 'navscreen': {
501
551
  const bar = el.querySelector<HTMLElement>(':scope > .sb-navbar');
502
- const largeTitle = el.querySelector<HTMLElement>(':scope > .sb-navscreen-body > .sb-navbar-large-title');
552
+ const largeTitle = largeTitleOf(el);
503
553
  const inlineTitle = el.querySelector<HTMLElement>(':scope > .sb-navbar > .sb-navbar-title');
504
554
  const texts = result.chromeText.get(node.id);
505
555
  const barRect = chrome?.['bar'];
506
556
  if (bar && barRect) {
507
557
  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`;
558
+ // The 44px bar row sits under the status bar portion of the rect
559
+ // (iOS 26: an inline bar keeps 10 more below it).
560
+ const row = chrome?.['barRow'];
561
+ const top = row ? row.y - barRect.y : Math.max(0, barRect.height - NAV_BAR_HEIGHT);
562
+ const bottom = row ? Math.max(0, barRect.y + barRect.height - (row.y + row.height)) : 0;
563
+ bar.style.paddingTop = `${top}px`;
564
+ bar.style.paddingBottom = `${bottom}px`;
510
565
  }
566
+ syncBottomGlass(el, chrome, frame);
511
567
  const largeRect = chrome?.['largeTitle'];
512
568
  if (largeTitle) {
513
569
  if (largeRect) {
514
- placeChrome(largeTitle, largeRect, frame);
570
+ // A title that scrolls with the screen's scroll view is placed by
571
+ // it (src/largeTitle.ts).
572
+ if (largeTitleScrolls(el, largeTitle)) largeTitle.hidden = false;
573
+ else placeChrome(largeTitle, largeRect, frame);
515
574
  if (texts?.['largeTitle']) {
516
575
  setFont(largeTitle, texts['largeTitle'].font);
517
576
  const line = texts['largeTitle'].lines.join('\n');
@@ -539,6 +598,10 @@ function applyChrome(
539
598
  if (back && backLabel && backRect && texts?.['back']) {
540
599
  const text = texts['back'].lines[0] ?? '';
541
600
  back.style.maxWidth = `${backRect.width}px`;
601
+ // The bar pads its row 16 (styles.css); a split view's sidebar
602
+ // toggle can push the back button further in (chrome.back).
603
+ const indent = barRect ? Math.max(0, backRect.x - barRect.x - 16) : 0;
604
+ back.style.marginLeft = indent > 0 ? `${indent}px` : '';
542
605
  backLabel.dataset['sbLayoutLabel'] = text;
543
606
  if (backLabel.textContent !== text) backLabel.textContent = text;
544
607
  setFont(back, texts['back'].font);
@@ -563,9 +626,15 @@ function applyChrome(
563
626
  items.forEach((item, i) => placeChrome(item, chrome?.[`item${i}`], barRect));
564
627
  return;
565
628
  }
629
+ case 'inspector': {
630
+ setFlag(el, 'sbPresented', node.props['presented'] === true ? '' : null);
631
+ return;
632
+ }
566
633
  case 'navsplit': {
567
- // Phase 7: the hairline between the two columns.
634
+ // Phase 7: the hairline between the two columns, and the sidebar toggle.
568
635
  placeChrome(el.querySelector<HTMLElement>(':scope > .sb-navsplit-separator'), chrome?.['separator'], box);
636
+ placeChrome(el.querySelector<HTMLElement>(':scope > .sb-navsplit-toggle'), chrome?.['toggle'], box);
637
+ setFlag(el, 'sbSidebarHidden', node.props['sidebarVisible'] === false ? '' : null);
569
638
  return;
570
639
  }
571
640
  case 'gauge': {
@@ -583,8 +652,10 @@ function applyChrome(
583
652
  return;
584
653
  case 'listrow':
585
654
  // 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);
655
+ // engine's `editControl` rect and the reorder grip at `reorderControl`;
656
+ // hidden when the list is not editing.
657
+ placeEditControl(el.querySelector<HTMLElement>(':scope > .sb-listrow-control'), chrome?.['editControl'], box);
658
+ placeEditControl(el.querySelector<HTMLElement>(':scope > .sb-swipe-content > .sb-listrow-grip'), chrome?.['reorderControl'], box);
588
659
  return;
589
660
  case 'picker': {
590
661
  setFont(el, result.fonts.get(node.id));
package/src/page.css ADDED
@@ -0,0 +1,234 @@
1
+ /* ---------------------------------------------------------------------------
2
+ The device page (app/index.html): the page around the iPhone frame, with
3
+ its toolbar, its stage and its error banner. Not the renderer's: a page
4
+ that draws an island (@swiftbrowser/react's `SwiftUIView`) imports
5
+ styles.css alone and keeps its own layout.
6
+ ------------------------------------------------------------------------- */
7
+
8
+ :root {
9
+ --page-bg: #e9e9ee;
10
+ --page-fg: #1c1c1e;
11
+ --page-muted: #6e6e73;
12
+ --page-border: rgba(0, 0, 0, 0.08);
13
+ --page-surface: rgba(255, 255, 255, 0.8);
14
+ --page-error-bg: #ffe5e5;
15
+ --page-error-fg: #a1000d;
16
+ }
17
+
18
+ @media (prefers-color-scheme: dark) {
19
+ :root {
20
+ --page-bg: #111114;
21
+ --page-fg: #f2f2f7;
22
+ --page-muted: #98989f;
23
+ --page-border: rgba(255, 255, 255, 0.1);
24
+ --page-surface: rgba(28, 28, 30, 0.85);
25
+ --page-error-bg: #3a0b10;
26
+ --page-error-fg: #ff8a8a;
27
+ }
28
+ }
29
+
30
+ *,
31
+ *::before,
32
+ *::after {
33
+ box-sizing: border-box;
34
+ }
35
+
36
+ html,
37
+ body {
38
+ margin: 0;
39
+ min-height: 100%;
40
+ }
41
+
42
+ body {
43
+ display: flex;
44
+ flex-direction: column;
45
+ min-height: 100vh;
46
+ background: var(--page-bg);
47
+ color: var(--page-fg);
48
+ font-family: var(--sb-font-family);
49
+ font-size: 14px;
50
+ -webkit-font-smoothing: antialiased;
51
+ }
52
+
53
+ .toolbar {
54
+ position: sticky;
55
+ top: 0;
56
+ z-index: 10;
57
+ display: flex;
58
+ align-items: center;
59
+ gap: 10px;
60
+ padding: 10px 16px;
61
+ background: var(--page-surface);
62
+ border-bottom: 1px solid var(--page-border);
63
+ backdrop-filter: blur(16px);
64
+ }
65
+
66
+ .toolbar-brand {
67
+ font-weight: 600;
68
+ letter-spacing: -0.01em;
69
+ }
70
+
71
+ .toolbar-sep {
72
+ width: 1px;
73
+ height: 18px;
74
+ background: var(--page-border);
75
+ }
76
+
77
+ .toolbar-label {
78
+ color: var(--page-muted);
79
+ }
80
+
81
+ .toolbar-value {
82
+ font-variant-numeric: tabular-nums;
83
+ padding: 2px 8px;
84
+ border-radius: 6px;
85
+ background: var(--page-border);
86
+ }
87
+
88
+ .toolbar-spacer {
89
+ flex: 1 1 0;
90
+ }
91
+
92
+ /* Hot-reload indicator, driven by the dev server's sb:build-* events (the
93
+ `sb-spin` keyframes are styles.css's, loaded with this file). */
94
+ .toolbar-build {
95
+ display: inline-flex;
96
+ align-items: center;
97
+ gap: 6px;
98
+ color: var(--page-muted);
99
+ }
100
+
101
+ .toolbar-build[hidden] {
102
+ display: none;
103
+ }
104
+
105
+ .toolbar-build::before {
106
+ content: "";
107
+ width: 10px;
108
+ height: 10px;
109
+ border-radius: 50%;
110
+ border: 1.5px solid currentColor;
111
+ border-top-color: transparent;
112
+ animation: sb-spin 0.9s linear infinite;
113
+ }
114
+
115
+ .toolbar-build[data-state="error"] {
116
+ color: var(--page-error-fg);
117
+ }
118
+
119
+ .toolbar-build[data-state="error"]::before {
120
+ animation: none;
121
+ border-color: currentColor;
122
+ }
123
+
124
+ .toolbar-button {
125
+ font: inherit;
126
+ padding: 6px 12px;
127
+ border: 1px solid var(--page-border);
128
+ border-radius: 8px;
129
+ background: transparent;
130
+ color: var(--page-fg);
131
+ cursor: pointer;
132
+ }
133
+
134
+ .toolbar-button:hover {
135
+ background: var(--page-border);
136
+ }
137
+
138
+ .toolbar-button[aria-pressed="true"] {
139
+ background: var(--page-fg);
140
+ color: var(--page-bg);
141
+ border-color: transparent;
142
+ }
143
+
144
+ .toolbar-select {
145
+ font: inherit;
146
+ padding: 5px 8px;
147
+ border: 1px solid var(--page-border);
148
+ border-radius: 8px;
149
+ background: transparent;
150
+ color: var(--page-fg);
151
+ cursor: pointer;
152
+ }
153
+
154
+ .stage {
155
+ display: flex;
156
+ flex-direction: column;
157
+ align-items: center;
158
+ gap: 16px;
159
+ padding: 32px 16px 48px;
160
+ }
161
+
162
+ .status {
163
+ max-width: 560px;
164
+ margin: 0;
165
+ padding: 10px 14px;
166
+ border-radius: 10px;
167
+ background: var(--page-error-bg);
168
+ color: var(--page-error-fg);
169
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
170
+ font-size: 12px;
171
+ white-space: pre-wrap;
172
+ word-break: break-word;
173
+ }
174
+
175
+ /* ---------------------------------------------------------------------------
176
+ Device mode (html[data-sb-mode="device"], see src/deviceMode.ts): no
177
+ toolbar, no stage padding, the page behind the app in the OS appearance's
178
+ system background from the first paint (Safari tints its status bar strip
179
+ from it; chromeColor.ts then keeps it at the color the app draws at its
180
+ top edge), and the error banner floating clear of the home indicator.
181
+ ------------------------------------------------------------------------- */
182
+
183
+ html[data-sb-mode="device"] {
184
+ height: 100%;
185
+ overflow: hidden;
186
+ overscroll-behavior: none;
187
+ /* No double-tap zoom, no text inflation in landscape. */
188
+ touch-action: manipulation;
189
+ -webkit-text-size-adjust: 100%;
190
+ text-size-adjust: 100%;
191
+ }
192
+
193
+ html[data-sb-mode="device"] body {
194
+ min-height: 100dvh;
195
+ overflow: hidden;
196
+ overscroll-behavior: none;
197
+ touch-action: manipulation;
198
+ }
199
+
200
+ /* The page behind the app is the OS appearance's system background from the
201
+ first paint (Safari tints its status bar strip from it); chromeColor.ts
202
+ then keeps it at the color the app draws at its top edge. */
203
+ html[data-sb-mode="device"],
204
+ html[data-sb-mode="device"] body {
205
+ background: #ffffff;
206
+ }
207
+
208
+ @media (prefers-color-scheme: dark) {
209
+ html[data-sb-mode="device"],
210
+ html[data-sb-mode="device"] body {
211
+ background: #000000;
212
+ }
213
+ }
214
+
215
+ html[data-sb-mode="device"] .toolbar {
216
+ display: none;
217
+ }
218
+
219
+ html[data-sb-mode="device"] .stage {
220
+ padding: 0;
221
+ gap: 0;
222
+ }
223
+
224
+ /* The error banner floats over the app, clear of the home indicator. */
225
+ html[data-sb-mode="device"] .status {
226
+ position: fixed;
227
+ left: 16px;
228
+ right: 16px;
229
+ bottom: calc(16px + var(--sb-inset-bottom));
230
+ z-index: 10;
231
+ max-width: none;
232
+ max-height: 40dvh;
233
+ overflow: auto;
234
+ }