@swiftbrowser/web 0.1.0 → 0.3.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.
Files changed (99) hide show
  1. package/README.md +141 -63
  2. package/app/index.html +21 -10
  3. package/node/dist/node/apps.d.ts +16 -0
  4. package/node/dist/node/apps.d.ts.map +1 -1
  5. package/node/dist/node/apps.js +9 -0
  6. package/node/dist/node/apps.js.map +1 -1
  7. package/node/dist/node/config.d.ts +14 -1
  8. package/node/dist/node/config.d.ts.map +1 -1
  9. package/node/dist/node/config.js +20 -3
  10. package/node/dist/node/config.js.map +1 -1
  11. package/node/dist/node/examples.d.ts +2 -1
  12. package/node/dist/node/examples.d.ts.map +1 -1
  13. package/node/dist/node/examples.js +12 -2
  14. package/node/dist/node/examples.js.map +1 -1
  15. package/node/dist/plugins/app-manifests.d.ts +4 -1
  16. package/node/dist/plugins/app-manifests.d.ts.map +1 -1
  17. package/node/dist/plugins/app-manifests.js +3 -2
  18. package/node/dist/plugins/app-manifests.js.map +1 -1
  19. package/node/dist/plugins/asset-catalogs.d.ts +15 -3
  20. package/node/dist/plugins/asset-catalogs.d.ts.map +1 -1
  21. package/node/dist/plugins/asset-catalogs.js +111 -13
  22. package/node/dist/plugins/asset-catalogs.js.map +1 -1
  23. package/node/dist/plugins/included-sites.d.ts +26 -0
  24. package/node/dist/plugins/included-sites.d.ts.map +1 -0
  25. package/node/dist/plugins/included-sites.js +70 -0
  26. package/node/dist/plugins/included-sites.js.map +1 -0
  27. package/node/dist/plugins/pdfImages.d.ts +27 -0
  28. package/node/dist/plugins/pdfImages.d.ts.map +1 -0
  29. package/node/dist/plugins/pdfImages.js +145 -0
  30. package/node/dist/plugins/pdfImages.js.map +1 -0
  31. package/node/dist/plugins/swift-wasm.d.ts +19 -1
  32. package/node/dist/plugins/swift-wasm.d.ts.map +1 -1
  33. package/node/dist/plugins/swift-wasm.js +45 -7
  34. package/node/dist/plugins/swift-wasm.js.map +1 -1
  35. package/node/dist/plugins/symbolTemplates.d.ts +22 -0
  36. package/node/dist/plugins/symbolTemplates.d.ts.map +1 -0
  37. package/node/dist/plugins/symbolTemplates.js +83 -0
  38. package/node/dist/plugins/symbolTemplates.js.map +1 -0
  39. package/package.json +4 -2
  40. package/plugins/app-manifests.ts +3 -2
  41. package/plugins/asset-catalogs.ts +119 -14
  42. package/plugins/included-sites.ts +74 -0
  43. package/plugins/pdfImages.ts +163 -0
  44. package/plugins/swift-wasm.ts +52 -6
  45. package/plugins/symbolTemplates.ts +88 -0
  46. package/public/_headers +7 -0
  47. package/public/app/oauth-callback.html +62 -0
  48. package/src/animation.ts +235 -8
  49. package/src/appFiles.ts +201 -0
  50. package/src/base64.ts +43 -0
  51. package/src/bitmapPlan.ts +143 -0
  52. package/src/bitmapStore.ts +206 -0
  53. package/src/bitmapWorker.ts +41 -0
  54. package/src/bitmapWorkerProtocol.ts +15 -0
  55. package/src/bitmaps.ts +283 -0
  56. package/src/blockedHosts.ts +29 -0
  57. package/src/canvasDrawing.ts +361 -0
  58. package/src/chartDom.ts +2 -2
  59. package/src/chromeColor.ts +179 -0
  60. package/src/clipboard.ts +78 -0
  61. package/src/colorMultiply.ts +93 -0
  62. package/src/colorPicker.ts +44 -0
  63. package/src/corsProxy.ts +45 -0
  64. package/src/gaugeRing.ts +74 -0
  65. package/src/gif.ts +297 -0
  66. package/src/hostRequests.ts +426 -0
  67. package/src/imageOps.ts +592 -0
  68. package/src/interaction.ts +68 -3
  69. package/src/landing.ts +2 -2
  70. package/src/largeTitle.ts +79 -0
  71. package/src/layout/chart.ts +9 -3
  72. package/src/layout/engine.ts +681 -174
  73. package/src/layout/index.ts +3 -1
  74. package/src/layout/text.ts +217 -1
  75. package/src/layout/types.ts +31 -0
  76. package/src/layout/typography.ts +24 -7
  77. package/src/layout/video.ts +24 -0
  78. package/src/layoutDom.ts +66 -6
  79. package/src/main.ts +127 -4
  80. package/src/media.ts +132 -0
  81. package/src/mock.ts +4 -3
  82. package/src/notifications.ts +109 -0
  83. package/src/pathShape.ts +156 -0
  84. package/src/protocol.ts +782 -13
  85. package/src/renderer.ts +603 -44
  86. package/src/richText.ts +65 -0
  87. package/src/scanner.ts +234 -0
  88. package/src/scrollTo.ts +123 -0
  89. package/src/styles.css +521 -100
  90. package/src/styles.ts +61 -0
  91. package/src/swiftData.ts +128 -0
  92. package/src/symbols.ts +658 -28
  93. package/src/translation.ts +126 -0
  94. package/src/video.ts +299 -0
  95. package/src/vite-env.d.ts +6 -0
  96. package/src/wasm.ts +227 -32
  97. package/src/webAuth.ts +267 -0
  98. package/src/webSocket.ts +148 -0
  99. package/src/zoneinfo.ts +269 -0
@@ -61,6 +61,8 @@ export type {
61
61
  Size,
62
62
  TextAlignment,
63
63
  TextLayout,
64
+ TextFragment,
65
+ RichTextLayout,
64
66
  TextMeasurer,
65
67
  } from './types';
66
68
  export { layoutTree, LayoutPass, type Context as LayoutContext } from './engine';
@@ -88,4 +90,4 @@ export {
88
90
  snapSliderValue,
89
91
  timeInputValue,
90
92
  } from './controls';
91
- export { wrapText, ELLIPSIS, type WrappedText } from './text';
93
+ export { wrapText, wrapRuns, ELLIPSIS, type WrappedText, type WrappedRuns, type TextRunInput } from './text';
@@ -2,7 +2,7 @@
2
2
  * Greedy word wrapping with character fallback and `lineLimit` truncation.
3
3
  */
4
4
 
5
- import type { ResolvedFont, TextMeasurer } from './types';
5
+ import type { ResolvedFont, Size, TextFragment, TextMeasurer } from './types';
6
6
 
7
7
  export const ELLIPSIS = '…';
8
8
  /**
@@ -122,3 +122,219 @@ function truncateLine(
122
122
  }
123
123
  return chars.join('') + ELLIPSIS;
124
124
  }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Rich text: runs
128
+ // ---------------------------------------------------------------------------
129
+
130
+ /** A run of rich text as the wrapper reads it: its text or image, and its font. */
131
+ export interface TextRunInput {
132
+ text?: string;
133
+ /** An inline image's size (the run has no text). */
134
+ image?: Size;
135
+ font: ResolvedFont;
136
+ /** Extra space after every character, in px. */
137
+ kerning: number;
138
+ }
139
+
140
+ export interface WrappedRuns {
141
+ lines: TextFragment[][];
142
+ /** Each line's height: its tallest font's line height or image. */
143
+ heights: number[];
144
+ /** Width of the widest line. */
145
+ width: number;
146
+ truncated: boolean;
147
+ }
148
+
149
+ type Piece = TextFragment;
150
+ type Token = { kind: 'word'; pieces: Piece[] } | { kind: 'space'; piece: Piece } | { kind: 'break' };
151
+
152
+ /**
153
+ * `wrapText` for runs of differently styled text and inline images: lines
154
+ * break at spaces (and around images), a word may span runs, a word wider
155
+ * than the line breaks by characters, and `lineLimit` truncates the last
156
+ * kept line with an ellipsis in its last run's font. `base` sets the height
157
+ * of an empty line.
158
+ */
159
+ export function wrapRuns(
160
+ runs: TextRunInput[],
161
+ base: ResolvedFont,
162
+ maxWidth: number | null,
163
+ lineLimit: number | null,
164
+ measurer: TextMeasurer,
165
+ ): WrappedRuns {
166
+ const measure = (run: number, text: string): number => {
167
+ if (text.length === 0) return 0;
168
+ const input = runs[run] as TextRunInput;
169
+ return measurer.measureWidth(text, input.font) + input.kerning * Array.from(text).length;
170
+ };
171
+ const constrained = maxWidth !== null && Number.isFinite(maxWidth);
172
+ const limit = constrained ? maxWidth + EPSILON : Infinity;
173
+
174
+ // Tokens: words (which may span runs), spaces and hard breaks.
175
+ const tokens: Token[] = [];
176
+ let word: Piece[] | null = null;
177
+ runs.forEach((input, run) => {
178
+ if (input.image) {
179
+ word = null;
180
+ tokens.push({ kind: 'word', pieces: [{ run, width: input.image.width, height: input.image.height }] });
181
+ return;
182
+ }
183
+ for (const part of (input.text ?? '').split(/(\n| +)/)) {
184
+ if (part.length === 0) continue;
185
+ if (part === '\n') {
186
+ word = null;
187
+ tokens.push({ kind: 'break' });
188
+ } else if (part.startsWith(' ')) {
189
+ word = null;
190
+ tokens.push({ kind: 'space', piece: { run, text: part, width: measure(run, part) } });
191
+ } else {
192
+ const piece = { run, text: part, width: measure(run, part) };
193
+ if (word) word.push(piece);
194
+ else {
195
+ word = [piece];
196
+ tokens.push({ kind: 'word', pieces: word });
197
+ }
198
+ }
199
+ }
200
+ });
201
+
202
+ let lines: Piece[][] = [];
203
+ let current: Piece[] = [];
204
+ let width = 0;
205
+ let spaces: Piece[] = [];
206
+ const sum = (pieces: Piece[]): number => pieces.reduce((total, piece) => total + piece.width, 0);
207
+ const endLine = (): void => {
208
+ lines.push(current);
209
+ current = [];
210
+ width = 0;
211
+ spaces = [];
212
+ };
213
+ for (const token of tokens) {
214
+ if (token.kind === 'break') {
215
+ endLine();
216
+ continue;
217
+ }
218
+ if (token.kind === 'space') {
219
+ if (current.length > 0) spaces.push(token.piece);
220
+ continue;
221
+ }
222
+ const wordWidth = sum(token.pieces);
223
+ const spaceWidth = sum(spaces);
224
+ if (current.length === 0 ? wordWidth <= limit : width + spaceWidth + wordWidth <= limit) {
225
+ current.push(...spaces, ...token.pieces);
226
+ width += spaceWidth + wordWidth;
227
+ spaces = [];
228
+ continue;
229
+ }
230
+ if (current.length > 0) endLine();
231
+ spaces = [];
232
+ if (wordWidth <= limit) {
233
+ current = [...token.pieces];
234
+ width = wordWidth;
235
+ continue;
236
+ }
237
+ // The word alone is wider than the line: break it by characters.
238
+ for (const piece of token.pieces) {
239
+ const units = piece.text === undefined ? [piece] : Array.from(piece.text).map((ch) => ({ run: piece.run, text: ch, width: measure(piece.run, ch) }));
240
+ for (const unit of units) {
241
+ if (current.length > 0 && width + unit.width > limit) endLine();
242
+ current.push(unit);
243
+ width += unit.width;
244
+ }
245
+ }
246
+ }
247
+ lines.push(current);
248
+
249
+ let truncated = false;
250
+ if (lineLimit !== null && lineLimit >= 1 && lines.length > lineLimit) {
251
+ const kept = lines.slice(0, lineLimit - 1);
252
+ const rest: Piece[] = [];
253
+ for (const line of lines.slice(lineLimit - 1)) {
254
+ if (line.length === 0) continue;
255
+ const first = line[0] as Piece;
256
+ if (rest.length > 0) rest.push({ run: first.run, text: ' ', width: measure(first.run, ' ') });
257
+ rest.push(...line);
258
+ }
259
+ kept.push(truncatePieces(rest, constrained ? maxWidth : null, measure, runs.length));
260
+ lines = kept;
261
+ truncated = true;
262
+ }
263
+
264
+ const merged = lines.map(mergePieces);
265
+ const heights = merged.map((line) => {
266
+ if (line.length === 0) return base.lineHeight;
267
+ let height = 0;
268
+ for (const piece of line) {
269
+ const font = (runs[piece.run] as TextRunInput).font;
270
+ height = Math.max(height, piece.height ?? font.lineHeight, font.lineHeight);
271
+ }
272
+ return height;
273
+ });
274
+ let widest = 0;
275
+ for (const line of merged) widest = Math.max(widest, sum(line));
276
+ return { lines: merged, heights, width: widest, truncated };
277
+ }
278
+
279
+ /** The pieces cut to fit `maxWidth` with an ellipsis (in the last run's font) after them. */
280
+ function truncatePieces(
281
+ pieces: Piece[],
282
+ maxWidth: number | null,
283
+ measure: (run: number, text: string) => number,
284
+ runCount: number,
285
+ ): Piece[] {
286
+ const out = pieces.map((piece) => ({ ...piece }));
287
+ const lastRun = (): number => (out.length > 0 ? (out[out.length - 1] as Piece).run : Math.max(0, runCount - 1));
288
+ const ellipsis = (): number => measure(lastRun(), ELLIPSIS);
289
+ const total = (): number => out.reduce((sum, piece) => sum + piece.width, 0);
290
+ const trimEnd = (): void => {
291
+ for (;;) {
292
+ const last = out[out.length - 1];
293
+ if (!last) return;
294
+ if (last.text !== undefined && last.text.length === 0) {
295
+ out.pop();
296
+ continue;
297
+ }
298
+ if (last.text === undefined || !last.text.endsWith(' ')) return;
299
+ last.text = last.text.replace(/ +$/, '');
300
+ last.width = measure(last.run, last.text);
301
+ }
302
+ };
303
+ if (maxWidth !== null) {
304
+ while (out.length > 0 && total() + ellipsis() > maxWidth + EPSILON) {
305
+ const last = out[out.length - 1] as Piece;
306
+ if (last.text === undefined || last.text.length <= 1) out.pop();
307
+ else {
308
+ const chars = Array.from(last.text);
309
+ chars.pop();
310
+ last.text = chars.join('');
311
+ last.width = measure(last.run, last.text);
312
+ }
313
+ trimEnd();
314
+ }
315
+ }
316
+ const run = lastRun();
317
+ const last = out[out.length - 1];
318
+ if (last && last.text !== undefined) {
319
+ last.text += ELLIPSIS;
320
+ last.width = measure(last.run, last.text);
321
+ } else {
322
+ out.push({ run, text: ELLIPSIS, width: measure(run, ELLIPSIS) });
323
+ }
324
+ return out;
325
+ }
326
+
327
+ /** Adjacent text pieces of the same run as one fragment. */
328
+ function mergePieces(line: Piece[]): Piece[] {
329
+ const out: Piece[] = [];
330
+ for (const piece of line) {
331
+ const last = out[out.length - 1];
332
+ if (last && last.run === piece.run && last.text !== undefined && piece.text !== undefined) {
333
+ last.text += piece.text;
334
+ last.width += piece.width;
335
+ } else {
336
+ out.push({ ...piece });
337
+ }
338
+ }
339
+ return out;
340
+ }
@@ -66,6 +66,14 @@ export interface ResolvedFont {
66
66
  lineHeight: number;
67
67
  /** A CSS `font` shorthand (`weight size/lineHeight family`) usable verbatim. */
68
68
  css: string;
69
+ /**
70
+ * A weight or design set by a view modifier (`.fontWeight`, `.bold()`,
71
+ * `.fontDesign`): SwiftUI applies it to whatever font the text inside ends
72
+ * up with, so a `.font(...)` set further in keeps it unless that font
73
+ * names its own (typography.ts, `resolveFont`).
74
+ */
75
+ weightModifier?: number;
76
+ familyModifier?: FontFamily;
69
77
  }
70
78
 
71
79
  export interface TextMeasurer {
@@ -96,6 +104,29 @@ export interface TextLayout {
96
104
  alignment: TextAlignment;
97
105
  /** True when `lineLimit` cut text off (the last line ends with an ellipsis). */
98
106
  truncated: boolean;
107
+ /**
108
+ * Rich text (a `text` with `runs`): each line as fragments of the runs, the
109
+ * height of each line, and each run's font. `lines` then holds the same
110
+ * lines as plain strings (an inline image as U+FFFC).
111
+ */
112
+ rich?: RichTextLayout;
113
+ }
114
+
115
+ /** One piece of a line of rich text: some of a run's text, or its image. */
116
+ export interface TextFragment {
117
+ /** Index into the text's `runs`. */
118
+ run: number;
119
+ /** The text drawn; absent for an image run. */
120
+ text?: string;
121
+ width: number;
122
+ /** An image run's height (its width is `width`). */
123
+ height?: number;
124
+ }
125
+
126
+ export interface RichTextLayout {
127
+ lines: TextFragment[][];
128
+ heights: number[];
129
+ fonts: ResolvedFont[];
99
130
  }
100
131
 
101
132
  export interface LayoutResult {
@@ -131,10 +131,18 @@ function formatPx(value: number): string {
131
131
  return `${Math.round(value * 100) / 100}px`;
132
132
  }
133
133
 
134
- export function makeFont(family: FontFamily, size: number, weight: number): ResolvedFont {
134
+ export function makeFont(
135
+ family: FontFamily,
136
+ size: number,
137
+ weight: number,
138
+ modifiers: { weightModifier?: number | undefined; familyModifier?: FontFamily | undefined } = {},
139
+ ): ResolvedFont {
135
140
  const lineHeight = lineHeightFor(size);
136
141
  const css = `${weight} ${formatPx(size)}/${lineHeight}px ${FONT_FAMILIES[family]}`;
137
- return { family, size, weight, lineHeight, css };
142
+ const font: ResolvedFont = { family, size, weight, lineHeight, css };
143
+ if (modifiers.weightModifier !== undefined) font.weightModifier = modifiers.weightModifier;
144
+ if (modifiers.familyModifier !== undefined) font.familyModifier = modifiers.familyModifier;
145
+ return font;
138
146
  }
139
147
 
140
148
  /** Body text for the current `dynamicTypeSize` (17/22 at Large). */
@@ -146,7 +154,11 @@ export function defaultFont(env: LayoutEnvironment): ResolvedFont {
146
154
  * Apply a Font JSON object (`textStyle` | `size`, `weight`, `design`,
147
155
  * `relativeTo`) to an inherited font. A `textStyle` or `size` defines a new
148
156
  * font (weight defaults to regular, or semibold for `headline`; design to the
149
- * system family); without either, only the given `weight`/`design` change.
157
+ * system family); without either, only the given `weight`/`design` change,
158
+ * as a view modifier (`.fontWeight(.semibold)`, `.bold()`, `.fontDesign`)
159
+ * that also applies to a new font set further in which names no weight or
160
+ * design of its own: `.font(.system(size: 15)).fontWeight(.semibold)` is a
161
+ * semibold 15.
150
162
  */
151
163
  export function resolveFont(
152
164
  fontProps: unknown,
@@ -181,8 +193,13 @@ export function resolveFont(
181
193
  size = inherited.size;
182
194
  }
183
195
 
184
- const weight = explicitWeight ?? (isNewFont ? defaultWeight : inherited.weight);
185
- const family = explicitFamily ?? (isNewFont ? 'system' : inherited.family);
186
- if (!isNewFont && weight === inherited.weight && family === inherited.family) return inherited;
187
- return makeFont(family, size, weight);
196
+ const weight = explicitWeight ?? (isNewFont ? (inherited.weightModifier ?? defaultWeight) : inherited.weight);
197
+ const family = explicitFamily ?? (isNewFont ? (inherited.familyModifier ?? 'system') : inherited.family);
198
+ // A weight or design without a new font is a modifier the text further in keeps.
199
+ const weightModifier = !isNewFont && explicitWeight !== undefined ? explicitWeight : inherited.weightModifier;
200
+ const familyModifier = !isNewFont && explicitFamily !== undefined ? explicitFamily : inherited.familyModifier;
201
+ if (!isNewFont && weight === inherited.weight && family === inherited.family && weightModifier === inherited.weightModifier && familyModifier === inherited.familyModifier) {
202
+ return inherited;
203
+ }
204
+ return makeFont(family, size, weight, { weightModifier, familyModifier });
188
205
  }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Measuring a `video` element (docs/ops-protocol.md, "Video and audio"). A
3
+ * player view fills what it is offered, like a resizable image: a finite
4
+ * proposal is taken as is; an axis left open follows the video's aspect
5
+ * ratio (its `width` / `height` props once the page has read the metadata,
6
+ * 16:9 before); with both open it is the video's own size, or 320 × 180.
7
+ */
8
+
9
+ import type { Proposal, Size } from './types';
10
+
11
+ export const VIDEO_DEFAULT_SIZE: Size = { width: 320, height: 180 };
12
+
13
+ export function videoSize(props: Record<string, unknown>, p: Proposal): Size {
14
+ const width = typeof props['width'] === 'number' && props['width'] > 0 ? props['width'] : null;
15
+ const height = typeof props['height'] === 'number' && props['height'] > 0 ? props['height'] : null;
16
+ const aspect = width !== null && height !== null ? width / height : VIDEO_DEFAULT_SIZE.width / VIDEO_DEFAULT_SIZE.height;
17
+ const pw = p.width !== null && Number.isFinite(p.width) ? p.width : null;
18
+ const ph = p.height !== null && Number.isFinite(p.height) ? p.height : null;
19
+ if (pw !== null && ph !== null) return { width: pw, height: ph };
20
+ if (pw !== null) return { width: pw, height: pw / aspect };
21
+ if (ph !== null) return { width: ph * aspect, height: ph };
22
+ if (width !== null && height !== null) return { width, height };
23
+ return { ...VIDEO_DEFAULT_SIZE };
24
+ }
package/src/layoutDom.ts CHANGED
@@ -35,6 +35,9 @@
35
35
 
36
36
  import type { LayoutEnvironment, LayoutNode, LayoutResult, Rect, ResolvedFont, TextLayout } from './layout';
37
37
  import { applyChartChrome } from './chartDom';
38
+ import { bitmapLaidOut } from './bitmaps';
39
+ import { drawRichText } from './richText';
40
+ import { bindLargeTitle } from './largeTitle';
38
41
 
39
42
  export interface LayoutDomContext {
40
43
  root: HTMLElement;
@@ -95,8 +98,8 @@ export function applyLayout(ctx: LayoutDomContext, result: LayoutResult): BoxCha
95
98
  boxes.delete(id);
96
99
  continue;
97
100
  }
98
- const box = boxOf(ctx, result, node, frame);
99
- const origin = node.kind === 'sheet' ? { x: 0, y: 0 } : originOf(ctx, result, ctx.parents.get(id) ?? ROOT_ID);
101
+ const box = drawnBox(node, boxOf(ctx, result, node, frame));
102
+ const origin = node.kind === 'sheet' ? { x: 0, y: 0 } : isSectionSupplement(node) ? supplementOrigin(ctx, result, id) : originOf(ctx, result, ctx.parents.get(id) ?? ROOT_ID);
100
103
  const relative = { x: box.x - origin.x, y: box.y - origin.y, width: box.width, height: box.height };
101
104
  const previous = ctx.boxes.get(id) ?? null;
102
105
  boxes.set(id, relative);
@@ -146,7 +149,7 @@ export function setTabHidden(tab: HTMLElement, hidden: boolean): void {
146
149
  export function boxOf(ctx: LayoutDomContext, result: LayoutResult, node: LayoutNode, frame: Rect): Rect {
147
150
  const row = result.chrome.get(node.id)?.['row'];
148
151
  if (row) return row;
149
- if (node.kind === 'section') return frame;
152
+ if (node.kind === 'section' || isSectionSupplement(node)) return frame;
150
153
  const parentId = ctx.parents.get(node.id);
151
154
  const parent = parentId !== undefined ? ctx.nodes.get(parentId) : undefined;
152
155
  if (!parent || (parent.kind !== 'section' && parent.kind !== 'list')) return frame;
@@ -156,6 +159,32 @@ export function boxOf(ctx: LayoutDomContext, result: LayoutResult, node: LayoutN
156
159
  return { x: card.x, y: frame.y - (height - frame.height) / 2, width: card.width, height };
157
160
  }
158
161
 
162
+ /**
163
+ * The box an element is drawn in: its frame, except a circle's, which is the
164
+ * square centered in it (SwiftUI draws the largest circle that fits, so
165
+ * `Circle().frame(width: 110)` in a taller space stays round). The engine's
166
+ * frame, which the siblings were laid out with, is unchanged.
167
+ */
168
+ function drawnBox(node: LayoutNode, box: Rect): Rect {
169
+ if (node.kind !== 'shape' || node.props['shape'] !== 'circle' || box.width === box.height) return box;
170
+ const side = Math.min(box.width, box.height);
171
+ return { x: box.x + (box.width - side) / 2, y: box.y + (box.height - side) / 2, width: side, height: side };
172
+ }
173
+
174
+ /** A section's view header or footer: not a row, and a DOM child of the section itself. */
175
+ function isSectionSupplement(node: LayoutNode): boolean {
176
+ return node.kind === 'sectionheader' || node.kind === 'sectionfooter';
177
+ }
178
+
179
+ /** A view header / footer sits in the section element, beside the rows' card: the section's box is its origin. */
180
+ function supplementOrigin(ctx: LayoutDomContext, result: LayoutResult, id: number): { x: number; y: number } {
181
+ const parentId = ctx.parents.get(id);
182
+ const parent = parentId === undefined ? undefined : ctx.nodes.get(parentId);
183
+ const frame = parentId === undefined ? undefined : result.frames.get(parentId);
184
+ if (!parent || !frame || parent.kind !== 'section') return originOf(ctx, result, parentId ?? ROOT_ID);
185
+ return boxOf(ctx, result, parent, frame);
186
+ }
187
+
159
188
  /** Screen-coordinate origin that the children of `parentId` are positioned against. */
160
189
  export function originOf(ctx: LayoutDomContext, result: LayoutResult, parentId: number): { x: number; y: number } {
161
190
  if (parentId === ROOT_ID) return { x: 0, y: 0 };
@@ -244,6 +273,10 @@ function setTextLayout(el: HTMLElement, layout: TextLayout): void {
244
273
  setFont(el, layout.font);
245
274
  const align = TEXT_ALIGN[layout.alignment] ?? 'left';
246
275
  if (el.style.textAlign !== align) el.style.textAlign = align;
276
+ if (layout.rich && el.dataset['sbRich'] !== undefined) {
277
+ drawRichText(el, layout.rich);
278
+ return;
279
+ }
247
280
  const content = layout.lines.join('\n');
248
281
  if (el.textContent !== content) el.textContent = content;
249
282
  }
@@ -275,6 +308,14 @@ function applyBadge(result: LayoutResult, node: LayoutNode, el: HTMLElement, box
275
308
  }
276
309
  }
277
310
 
311
+ function setFlag(el: HTMLElement, key: string, value: string | null): void {
312
+ if (value === null) {
313
+ if (key in el.dataset) delete el.dataset[key];
314
+ } else if (el.dataset[key] !== value) {
315
+ el.dataset[key] = value;
316
+ }
317
+ }
318
+
278
319
  /** For leaf rows: pad the content frame back into place inside the row box. */
279
320
  function padIntoFrame(el: HTMLElement, box: Rect, frame: Rect): void {
280
321
  const s = el.style;
@@ -298,6 +339,17 @@ function applyChrome(
298
339
  bottomInset: number,
299
340
  ): void {
300
341
  const chrome = result.chrome.get(node.id);
342
+ // `.listRowSeparator(.hidden)`: a list row leaves out the separator below
343
+ // it, or above it (the previous row's line; styles.css).
344
+ setFlag(el, 'sbSeparatorBottom', chrome?.['separatorBottomHidden'] ? 'hidden' : null);
345
+ setFlag(el, 'sbSeparatorTop', chrome?.['separatorTopHidden'] ? 'hidden' : null);
346
+ // iOS 26: the separator under a list row starts at its first text (styles.css).
347
+ const separator = chrome?.['separator'];
348
+ const leading = separator ? `${separator.x}px` : '';
349
+ if (el.style.getPropertyValue('--sb-separator-leading') !== leading) {
350
+ if (leading) el.style.setProperty('--sb-separator-leading', leading);
351
+ else el.style.removeProperty('--sb-separator-leading');
352
+ }
301
353
  switch (node.kind) {
302
354
  case 'styled':
303
355
  applyBadge(result, node, el, box);
@@ -315,10 +367,14 @@ function applyChrome(
315
367
  if (el.style.fontSize !== size) el.style.fontSize = size;
316
368
  }
317
369
  padIntoFrame(el, box, frame);
370
+ // A bitmap is drawn for the size it is shown at.
371
+ if (node.props['bitmap']) bitmapLaidOut(el, frame.width, frame.height);
318
372
  return;
319
373
  }
320
374
  case 'textfield':
321
375
  case 'texteditor':
376
+ // A vertical text field draws in the font its lines were measured in.
377
+ setFont(el, result.fonts.get(node.id));
322
378
  padIntoFrame(el, box, frame);
323
379
  return;
324
380
  // Phase 8 controls: their chrome sits at the engine's rects inside the element's box.
@@ -406,9 +462,7 @@ function applyChrome(
406
462
  placeChrome(footer, chrome?.['footer'], frame);
407
463
  if (header && texts?.['header']) setTextLayout(header, texts['header']);
408
464
  if (footer && texts?.['footer']) {
409
- // The engine measures footers upper-cased like headers; iOS footers
410
- // keep their case, so draw the prop text in the engine's font and
411
- // rect and let it wrap there.
465
+ // The prop text in the engine's font and rect, wrapped there.
412
466
  setFont(footer, texts['footer'].font);
413
467
  footer.style.textAlign = 'left';
414
468
  footer.style.whiteSpace = 'pre-wrap';
@@ -425,6 +479,7 @@ function applyChrome(
425
479
  // Extra scrollable space at the end (the engine's frames are unchanged).
426
480
  content.style.height = `${size.height + bottomInset}px`;
427
481
  }
482
+ bindLargeTitle(el, chrome?.['largeTitle'] !== undefined);
428
483
  return;
429
484
  }
430
485
  case 'scrollview': {
@@ -439,6 +494,7 @@ function applyChrome(
439
494
  s.marginLeft = `${rect ? rect.x - frame.x : 0}px`;
440
495
  s.marginTop = `${rect ? rect.y - frame.y : 0}px`;
441
496
  }
497
+ bindLargeTitle(el, chrome?.['largeTitle'] !== undefined);
442
498
  return;
443
499
  }
444
500
  case 'navscreen': {
@@ -493,6 +549,8 @@ function applyChrome(
493
549
  const card = el.querySelector<HTMLElement>(':scope > .sb-sheet-card');
494
550
  const grabber = card?.querySelector<HTMLElement>(':scope > .sb-sheet-grabber') ?? null;
495
551
  placeChrome(grabber, chrome?.['grabber'], frame);
552
+ // iOS 26: a sheet at a partial detent floats as a rounded card (styles.css).
553
+ setFlag(el, 'sbFloating', chrome?.['floating'] ? '' : null);
496
554
  return;
497
555
  }
498
556
  case 'tabview': {
@@ -514,6 +572,8 @@ function applyChrome(
514
572
  // The track and fill are chrome, positioned from the element's own box.
515
573
  placeChrome(el.querySelector<HTMLElement>(':scope > .sb-gauge-track'), chrome?.['track'], box);
516
574
  placeChrome(el.querySelector<HTMLElement>(':scope > .sb-gauge-fill'), chrome?.['fill'], box);
575
+ // A circular style: the ring over the gauge's square (gaugeRing.ts).
576
+ placeChrome(el.querySelector<HTMLElement>(':scope > .sb-gauge-ring'), chrome?.['ring'], box);
517
577
  return;
518
578
  }
519
579
  case 'chart':