@swiftbrowser/web 0.1.0 → 0.2.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 (98) hide show
  1. package/README.md +43 -6
  2. package/app/index.html +11 -0
  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 +11 -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 +18 -1
  69. package/src/landing.ts +2 -2
  70. package/src/layout/chart.ts +9 -3
  71. package/src/layout/engine.ts +279 -25
  72. package/src/layout/index.ts +3 -1
  73. package/src/layout/text.ts +217 -1
  74. package/src/layout/types.ts +31 -0
  75. package/src/layout/typography.ts +24 -7
  76. package/src/layout/video.ts +24 -0
  77. package/src/layoutDom.ts +53 -3
  78. package/src/main.ts +124 -4
  79. package/src/media.ts +132 -0
  80. package/src/mock.ts +2 -1
  81. package/src/notifications.ts +109 -0
  82. package/src/pathShape.ts +156 -0
  83. package/src/protocol.ts +761 -12
  84. package/src/renderer.ts +584 -36
  85. package/src/richText.ts +65 -0
  86. package/src/scanner.ts +234 -0
  87. package/src/scrollTo.ts +123 -0
  88. package/src/styles.css +212 -7
  89. package/src/styles.ts +31 -0
  90. package/src/swiftData.ts +128 -0
  91. package/src/symbols.ts +564 -21
  92. package/src/translation.ts +126 -0
  93. package/src/video.ts +299 -0
  94. package/src/vite-env.d.ts +6 -0
  95. package/src/wasm.ts +227 -32
  96. package/src/webAuth.ts +267 -0
  97. package/src/webSocket.ts +148 -0
  98. package/src/zoneinfo.ts +269 -0
@@ -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,8 @@
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';
38
40
 
39
41
  export interface LayoutDomContext {
40
42
  root: HTMLElement;
@@ -95,8 +97,8 @@ export function applyLayout(ctx: LayoutDomContext, result: LayoutResult): BoxCha
95
97
  boxes.delete(id);
96
98
  continue;
97
99
  }
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);
100
+ const box = drawnBox(node, boxOf(ctx, result, node, frame));
101
+ 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
102
  const relative = { x: box.x - origin.x, y: box.y - origin.y, width: box.width, height: box.height };
101
103
  const previous = ctx.boxes.get(id) ?? null;
102
104
  boxes.set(id, relative);
@@ -146,7 +148,7 @@ export function setTabHidden(tab: HTMLElement, hidden: boolean): void {
146
148
  export function boxOf(ctx: LayoutDomContext, result: LayoutResult, node: LayoutNode, frame: Rect): Rect {
147
149
  const row = result.chrome.get(node.id)?.['row'];
148
150
  if (row) return row;
149
- if (node.kind === 'section') return frame;
151
+ if (node.kind === 'section' || isSectionSupplement(node)) return frame;
150
152
  const parentId = ctx.parents.get(node.id);
151
153
  const parent = parentId !== undefined ? ctx.nodes.get(parentId) : undefined;
152
154
  if (!parent || (parent.kind !== 'section' && parent.kind !== 'list')) return frame;
@@ -156,6 +158,32 @@ export function boxOf(ctx: LayoutDomContext, result: LayoutResult, node: LayoutN
156
158
  return { x: card.x, y: frame.y - (height - frame.height) / 2, width: card.width, height };
157
159
  }
158
160
 
161
+ /**
162
+ * The box an element is drawn in: its frame, except a circle's, which is the
163
+ * square centered in it (SwiftUI draws the largest circle that fits, so
164
+ * `Circle().frame(width: 110)` in a taller space stays round). The engine's
165
+ * frame, which the siblings were laid out with, is unchanged.
166
+ */
167
+ function drawnBox(node: LayoutNode, box: Rect): Rect {
168
+ if (node.kind !== 'shape' || node.props['shape'] !== 'circle' || box.width === box.height) return box;
169
+ const side = Math.min(box.width, box.height);
170
+ return { x: box.x + (box.width - side) / 2, y: box.y + (box.height - side) / 2, width: side, height: side };
171
+ }
172
+
173
+ /** A section's view header or footer: not a row, and a DOM child of the section itself. */
174
+ function isSectionSupplement(node: LayoutNode): boolean {
175
+ return node.kind === 'sectionheader' || node.kind === 'sectionfooter';
176
+ }
177
+
178
+ /** A view header / footer sits in the section element, beside the rows' card: the section's box is its origin. */
179
+ function supplementOrigin(ctx: LayoutDomContext, result: LayoutResult, id: number): { x: number; y: number } {
180
+ const parentId = ctx.parents.get(id);
181
+ const parent = parentId === undefined ? undefined : ctx.nodes.get(parentId);
182
+ const frame = parentId === undefined ? undefined : result.frames.get(parentId);
183
+ if (!parent || !frame || parent.kind !== 'section') return originOf(ctx, result, parentId ?? ROOT_ID);
184
+ return boxOf(ctx, result, parent, frame);
185
+ }
186
+
159
187
  /** Screen-coordinate origin that the children of `parentId` are positioned against. */
160
188
  export function originOf(ctx: LayoutDomContext, result: LayoutResult, parentId: number): { x: number; y: number } {
161
189
  if (parentId === ROOT_ID) return { x: 0, y: 0 };
@@ -244,6 +272,10 @@ function setTextLayout(el: HTMLElement, layout: TextLayout): void {
244
272
  setFont(el, layout.font);
245
273
  const align = TEXT_ALIGN[layout.alignment] ?? 'left';
246
274
  if (el.style.textAlign !== align) el.style.textAlign = align;
275
+ if (layout.rich && el.dataset['sbRich'] !== undefined) {
276
+ drawRichText(el, layout.rich);
277
+ return;
278
+ }
247
279
  const content = layout.lines.join('\n');
248
280
  if (el.textContent !== content) el.textContent = content;
249
281
  }
@@ -275,6 +307,14 @@ function applyBadge(result: LayoutResult, node: LayoutNode, el: HTMLElement, box
275
307
  }
276
308
  }
277
309
 
310
+ function setFlag(el: HTMLElement, key: string, value: string | null): void {
311
+ if (value === null) {
312
+ if (key in el.dataset) delete el.dataset[key];
313
+ } else if (el.dataset[key] !== value) {
314
+ el.dataset[key] = value;
315
+ }
316
+ }
317
+
278
318
  /** For leaf rows: pad the content frame back into place inside the row box. */
279
319
  function padIntoFrame(el: HTMLElement, box: Rect, frame: Rect): void {
280
320
  const s = el.style;
@@ -298,6 +338,10 @@ function applyChrome(
298
338
  bottomInset: number,
299
339
  ): void {
300
340
  const chrome = result.chrome.get(node.id);
341
+ // `.listRowSeparator(.hidden)`: a list row leaves out the separator below
342
+ // it, or above it (the previous row's line; styles.css).
343
+ setFlag(el, 'sbSeparatorBottom', chrome?.['separatorBottomHidden'] ? 'hidden' : null);
344
+ setFlag(el, 'sbSeparatorTop', chrome?.['separatorTopHidden'] ? 'hidden' : null);
301
345
  switch (node.kind) {
302
346
  case 'styled':
303
347
  applyBadge(result, node, el, box);
@@ -315,10 +359,14 @@ function applyChrome(
315
359
  if (el.style.fontSize !== size) el.style.fontSize = size;
316
360
  }
317
361
  padIntoFrame(el, box, frame);
362
+ // A bitmap is drawn for the size it is shown at.
363
+ if (node.props['bitmap']) bitmapLaidOut(el, frame.width, frame.height);
318
364
  return;
319
365
  }
320
366
  case 'textfield':
321
367
  case 'texteditor':
368
+ // A vertical text field draws in the font its lines were measured in.
369
+ setFont(el, result.fonts.get(node.id));
322
370
  padIntoFrame(el, box, frame);
323
371
  return;
324
372
  // Phase 8 controls: their chrome sits at the engine's rects inside the element's box.
@@ -514,6 +562,8 @@ function applyChrome(
514
562
  // The track and fill are chrome, positioned from the element's own box.
515
563
  placeChrome(el.querySelector<HTMLElement>(':scope > .sb-gauge-track'), chrome?.['track'], box);
516
564
  placeChrome(el.querySelector<HTMLElement>(':scope > .sb-gauge-fill'), chrome?.['fill'], box);
565
+ // A circular style: the ring over the gauge's square (gaugeRing.ts).
566
+ placeChrome(el.querySelector<HTMLElement>(':scope > .sb-gauge-ring'), chrome?.['ring'], box);
517
567
  return;
518
568
  }
519
569
  case 'chart':
package/src/main.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /// <reference path="./vite-env.d.ts" />
2
2
  import { easingFor, springEasing, timingFor } from './animation';
3
3
  import type { BuildEndPayload, BuildErrorPayload, BuildStartPayload } from './devEvents';
4
+ import { ChromeColor } from './chromeColor';
4
5
  import { applyPageMode, decidePageMode, readViewportInsets, watchViewport, type PageMode } from './deviceMode';
5
6
  import { EMPTY_CATALOG, catalogPath, isAssetCatalog, type AssetCatalog } from './assets';
6
7
  import { createMockApp } from './mock';
@@ -14,9 +15,16 @@ import {
14
15
  type ScenePhase,
15
16
  } from './protocol';
16
17
  import { Renderer, RendererError } from './renderer';
18
+ import { withBlockedHosts } from './blockedHosts';
19
+ import { withCORSProxy } from './corsProxy';
20
+ import { browserEnvironment } from './hostRequests';
21
+ import { callbackPageURL } from './webAuth';
22
+ import { bitmapStore } from './bitmaps';
17
23
  import { DEFAULT_TYPE_SIZE, applyTypeSizeVariables } from './typography';
18
24
  import { JobScheduler, loadApp, type AppHandle, type JobTimers } from './wasm';
19
25
  import { chooseLocale, currentTimeZone } from './locale';
26
+ import { indexedDBPersistence, launchValue, type SwiftDataPersistence, type SwiftDataStores } from './swiftData';
27
+ import { applyHomeChanges, diffHome, homeDirectory, homeEntries, indexedDBFiles, type FilesPersistence, type HomeEntries } from './appFiles';
20
28
  import type { BundleManifest } from '../plugins/bundle-resources';
21
29
 
22
30
  const THEME_KEY = 'sb-theme';
@@ -70,7 +78,7 @@ function dispatch(event: RendererEvent): void {
70
78
  return;
71
79
  }
72
80
  try {
73
- if (event.type === 'tap') {
81
+ if (event.type === 'tap' && event.x === undefined) {
74
82
  app.sendEvent(event.id);
75
83
  } else if (event.type === 'batch' && !app.supportsBatchEvents) {
76
84
  // The mock apps handle events one at a time.
@@ -87,6 +95,11 @@ function dispatch(event: RendererEvent): void {
87
95
 
88
96
  const renderer = new Renderer(screen, dispatch);
89
97
 
98
+ // On a phone the browser's status bar strip and safe areas take the color the
99
+ // app draws at its top edge (chromeColor.ts).
100
+ const chromeColor = deviceMode ? new ChromeColor(device, screen) : null;
101
+ if (chromeColor) renderer.afterLayout = () => chromeColor.schedule();
102
+
90
103
  // Phase 12: `.preferredColorScheme` from the app overrides the viewer's theme
91
104
  // for this page (not persisted); `null` goes back to the toggle / OS setting.
92
105
  screen.addEventListener('sb:color-scheme', (event) => {
@@ -122,6 +135,7 @@ function applyTheme(theme: Theme, persist: boolean): void {
122
135
  themeToggle.setAttribute('aria-pressed', String(theme === 'dark'));
123
136
  themeToggle.textContent = theme === 'dark' ? 'Light mode' : 'Dark mode';
124
137
  renderer.setEnvironment({ colorScheme: theme });
138
+ chromeColor?.schedule();
125
139
  if (persist) {
126
140
  try {
127
141
  localStorage.setItem(THEME_KEY, theme);
@@ -210,8 +224,8 @@ function currentSizeClass(): HorizontalSizeClass {
210
224
  let sentSizeClass: HorizontalSizeClass | null = null;
211
225
 
212
226
  if (deviceMode) {
213
- // `#screen` fills the viewport (styles.css); the engine reads its size on
214
- // every pass. The safe areas come from env(safe-area-inset-*): non-zero
227
+ // `#screen` fills the viewport (styles.css); the renderer re-reads its size
228
+ // when it resizes. The safe areas come from env(safe-area-inset-*): non-zero
215
229
  // only in standalone (home screen) mode or landscape, which is what the
216
230
  // real app would get.
217
231
  renderer.setSafeArea(readViewportInsets());
@@ -357,9 +371,78 @@ function launchEnvironment(): string[] {
357
371
  if (zone) env.push(`TZ=${zone}`);
358
372
  const defaults = readStoredDefaults();
359
373
  if (defaults !== null) env.push(`SB_DEFAULTS=${defaults}`);
374
+ // Signing in: the app's URL schemes (its Info.plist's CFBundleURLTypes) and
375
+ // the callback page its OAuth redirects become (webAuth.ts).
376
+ const schemes = registeredApp()?.urlSchemes;
377
+ if (schemes?.length) env.push(`SB_URL_SCHEMES=${schemes.join(',')}`);
378
+ env.push(`SB_WEB_AUTH_CALLBACK=${callbackPageURL(new URL(import.meta.env.BASE_URL, location.href).href)}`);
360
379
  return env;
361
380
  }
362
381
 
382
+ // ---------------------------------------------------------------------------
383
+ // Phase 18: SwiftData's stores, kept in IndexedDB
384
+ // ---------------------------------------------------------------------------
385
+
386
+ /** The app's database, or null where the browser has no IndexedDB (or forbids it). */
387
+ const swiftData: SwiftDataPersistence | null = (() => {
388
+ try {
389
+ return typeof indexedDB === 'undefined' ? null : indexedDBPersistence(requestedApp);
390
+ } catch {
391
+ return null;
392
+ }
393
+ })();
394
+
395
+ /**
396
+ * Every SwiftData store the app saved, for `SB_SWIFTDATA`. `?swiftdata=reset`
397
+ * deletes them first (a model changed in a way the saved records cannot
398
+ * follow). A database that cannot be read starts the app empty.
399
+ */
400
+ async function loadSwiftData(): Promise<SwiftDataStores> {
401
+ if (!swiftData) return {};
402
+ try {
403
+ if (params.get('swiftdata') === 'reset') await swiftData.clear();
404
+ return await swiftData.load();
405
+ } catch (error) {
406
+ console.warn('[sb] could not read the SwiftData stores; starting empty', error);
407
+ return {};
408
+ }
409
+ }
410
+
411
+ // ---------------------------------------------------------------------------
412
+ // Phase 19: the app's home directory, kept in IndexedDB
413
+ // ---------------------------------------------------------------------------
414
+
415
+ const files: FilesPersistence | null = (() => {
416
+ try {
417
+ return typeof indexedDB === 'undefined' ? null : indexedDBFiles(requestedApp);
418
+ } catch {
419
+ return null;
420
+ }
421
+ })();
422
+
423
+ /** The home as last written to IndexedDB, to diff the live tree against. */
424
+ let savedHome: HomeEntries = new Map();
425
+
426
+ /** The saved home (`?files=reset` deletes it first); empty when it cannot be read. */
427
+ async function loadHome(): Promise<HomeEntries> {
428
+ if (!files) return new Map();
429
+ try {
430
+ if (params.get('files') === 'reset') await files.clear();
431
+ return await files.load();
432
+ } catch (error) {
433
+ console.warn('[sb] could not read the app\'s files; starting empty', error);
434
+ return new Map();
435
+ }
436
+ }
437
+
438
+ /** Writes what the app changed in its home since the last write. */
439
+ function saveHome(home: ReturnType<typeof homeDirectory>): void {
440
+ const changes = diffHome(savedHome, homeEntries(home));
441
+ if (changes.put.length === 0 && changes.remove.length === 0) return;
442
+ savedHome = applyHomeChanges(savedHome, changes);
443
+ files?.apply(changes).catch((error: unknown) => console.warn('[sb] could not save the app\'s files to IndexedDB', error));
444
+ }
445
+
363
446
  /** The app's resources (`/bundle/<App>.json` and its files), for the WASI `/bundle` mount; empty without a manifest. */
364
447
  async function loadBundle(app: string): Promise<Map<string, Uint8Array>> {
365
448
  const files = new Map<string, Uint8Array>();
@@ -489,7 +572,35 @@ async function boot(): Promise<void> {
489
572
  console.info(`[sb] restoring state for ${requestedApp}: ${restored}`);
490
573
  }
491
574
  const bundle = await loadBundle(requestedApp);
492
- app = await loadApp(url, onOps, { args: [requestedApp], env, bundle, onDefaults: storeDefaults });
575
+ const stores = launchValue(await loadSwiftData());
576
+ if (stores !== null) env.push(`SB_SWIFTDATA=${stores}`);
577
+ savedHome = await loadHome();
578
+ const home = homeDirectory(savedHome);
579
+ // The standard directories a new home gets are saved with its first write.
580
+ const corsProxy = registeredApp()?.corsProxy;
581
+ app = await loadApp(url, onOps, {
582
+ args: [requestedApp],
583
+ env,
584
+ bundle,
585
+ home,
586
+ onFilesWritten: () => saveHome(home),
587
+ onDefaults: storeDefaults,
588
+ // `ShareLink` shares asset-catalog images as their files. An app built
589
+ // with a CORS proxy retries a refused cross-origin read through it
590
+ // (corsProxy.ts); one built with blocked hosts never reaches them (blockedHosts.ts).
591
+ requests: {
592
+ ...browserEnvironment,
593
+ fetch: withBlockedHosts(
594
+ corsProxy ? withCORSProxy(browserEnvironment.fetch, corsProxy, location.origin) : browserEnvironment.fetch,
595
+ registeredApp()?.blockedHosts ?? [],
596
+ ),
597
+ assetURL: (name) => assets.images[name]?.src,
598
+ bitmapFile: (recipe) => bitmapStore.png(recipe),
599
+ },
600
+ onSwiftData: (op) => {
601
+ swiftData?.apply(op).catch((error: unknown) => console.warn('[sb] could not save to IndexedDB', error));
602
+ },
603
+ });
493
604
  setLoaded(requestedApp);
494
605
  sendEnvironment();
495
606
  flushQueuedEvents();
@@ -527,6 +638,10 @@ declare global {
527
638
  * module (or mock) has no executor.
528
639
  */
529
640
  runJobs: () => number | null;
641
+ /** Phase 19: the app's home as IndexedDB holds it now: path → text of each file (`null` for a directory). */
642
+ files: () => Promise<Record<string, string | null>>;
643
+ /** Phase 18: the app's SwiftData stores as IndexedDB holds them now (all saves written). */
644
+ swiftData: () => Promise<SwiftDataStores>;
530
645
  /** The executor driver class, so tests can run it against a stub. */
531
646
  JobScheduler: new (
532
647
  run: (nowMs: number) => number,
@@ -550,5 +665,10 @@ window.__sb = {
550
665
  assets: () => assets,
551
666
  animation: { easingFor, timingFor, springEasing },
552
667
  runJobs: () => app?.runJobs() ?? null,
668
+ swiftData: async () => (swiftData ? swiftData.load() : {}),
669
+ files: async () => {
670
+ const entries = files ? await files.load() : new Map<string, Uint8Array | null>();
671
+ return Object.fromEntries([...entries].map(([path, data]) => [path, data === null ? null : new TextDecoder().decode(data)]));
672
+ },
553
673
  JobScheduler,
554
674
  };