@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
@@ -0,0 +1,29 @@
1
+ // Hosts an app is built not to reach (`swiftbrowser build --block-host
2
+ // <host>`): a request to one of them, or to a subdomain, fails at once as if
3
+ // the network were down, without leaving the page. For a public deploy of
4
+ // an app whose analytics or other services belong to someone else (Ice Cubes'
5
+ // TelemetryDeck signals): the app keeps working, those requests do not go out.
6
+
7
+ /** Whether `url` is on one of `hosts` (or a subdomain of one). */
8
+ export function isBlocked(url: string, hosts: readonly string[]): boolean {
9
+ let host: string;
10
+ try {
11
+ host = new URL(url).hostname.toLowerCase();
12
+ } catch {
13
+ return false;
14
+ }
15
+ return hosts.some((blocked) => {
16
+ const name = blocked.toLowerCase();
17
+ return host === name || host.endsWith(`.${name}`);
18
+ });
19
+ }
20
+
21
+ /** `fetchImpl`, failing requests to `hosts` as a network error does. */
22
+ export function withBlockedHosts(fetchImpl: typeof fetch, hosts: readonly string[]): typeof fetch {
23
+ if (hosts.length === 0) return fetchImpl;
24
+ return async (input, init) => {
25
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
26
+ if (isBlocked(url, hosts)) throw new TypeError(`Failed to fetch: ${new URL(url).hostname} is blocked in this build`);
27
+ return fetchImpl(input, init);
28
+ };
29
+ }
@@ -0,0 +1,361 @@
1
+ /**
2
+ * A `Canvas` (`canvas` element): the commands its renderer recorded in Swift
3
+ * (Sources/SwiftUI/API/Canvas.swift), drawn as SVG over the element's box and
4
+ * clipped to it. SVG rather than a `<canvas>` so paints keep the page's CSS
5
+ * colors (asset, semantic and accent colors follow the color scheme) and
6
+ * images are the catalog's files as `<image>`s.
7
+ *
8
+ * Each command is a `<g>` (its opacity, blend mode, filters and clip) around
9
+ * one element (its transform). An update reuses the elements in place, so a
10
+ * canvas redrawn on every frame (a `TimelineView`) keeps its decoded images
11
+ * instead of reloading them; only an image whose source changed is swapped.
12
+ */
13
+
14
+ import type { AssetCatalog } from './assets';
15
+ import { paintFor, type IdSource } from './chartDom';
16
+ import { colorToCSS } from './styles';
17
+ import { renderSymbol } from './symbols';
18
+ import { resolveTextStyle, textStyleVar, DEFAULT_TYPE_SIZE, TEXT_STYLE_WEIGHT } from './typography';
19
+ import {
20
+ isAssetImage,
21
+ isBitmapImage,
22
+ isUrlImage,
23
+ type CanvasCommand,
24
+ type CanvasFilter,
25
+ type CanvasProps,
26
+ type CanvasShading,
27
+ type Font,
28
+ type FontWeight,
29
+ type GradientStop,
30
+ type ImageProps,
31
+ type ShapeStyle,
32
+ } from './protocol';
33
+
34
+ const SVG_NS = 'http://www.w3.org/2000/svg';
35
+
36
+ /** The size a system symbol is drawn at without a rectangle (the body font's). */
37
+ const SYMBOL_SIZE = 20;
38
+ /** The body font's size, for text drawn without a font. */
39
+ const BODY_SIZE = 17;
40
+
41
+ const WEIGHTS: Record<FontWeight, string> = {
42
+ ultraLight: '100',
43
+ thin: '200',
44
+ light: '300',
45
+ regular: '400',
46
+ medium: '500',
47
+ semibold: '600',
48
+ bold: '700',
49
+ heavy: '800',
50
+ black: '900',
51
+ };
52
+
53
+ let canvases = 0;
54
+
55
+ interface CanvasState {
56
+ svg: SVGSVGElement;
57
+ defs: SVGDefsElement;
58
+ content: SVGGElement;
59
+ prefix: string;
60
+ }
61
+
62
+ const STATE = new WeakMap<HTMLElement, CanvasState>();
63
+
64
+ /** The element of a `canvas`: a box that clips the SVG it holds. */
65
+ export function createCanvasElement(): HTMLElement {
66
+ const el = document.createElement('div');
67
+ el.className = 'sb-canvas';
68
+ el.setAttribute('aria-hidden', 'true');
69
+ return el;
70
+ }
71
+
72
+ /** Draws `props` into `el` (made by {@link createCanvasElement}). */
73
+ export function applyCanvas(el: HTMLElement, props: CanvasProps, assets: AssetCatalog): void {
74
+ let state = STATE.get(el);
75
+ if (!state) {
76
+ const svg = document.createElementNS(SVG_NS, 'svg');
77
+ const defs = document.createElementNS(SVG_NS, 'defs');
78
+ const content = document.createElementNS(SVG_NS, 'g');
79
+ svg.append(defs, content);
80
+ el.append(svg);
81
+ state = { svg, defs, content, prefix: `sb-canvas${++canvases}` };
82
+ STATE.set(el, state);
83
+ }
84
+ const width = finite(props.width, 0);
85
+ const height = finite(props.height, 0);
86
+ state.svg.setAttribute('width', String(width));
87
+ state.svg.setAttribute('height', String(height));
88
+ state.svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
89
+ state.defs.replaceChildren();
90
+ const ids: IdSource = { next: 0, prefix: state.prefix };
91
+ const box = { x: 0, y: 0, width, height };
92
+ const commands = Array.isArray(props.commands) ? props.commands : [];
93
+ const groups = state.content.children;
94
+ let index = 0;
95
+ for (const command of commands) {
96
+ if (!command || typeof command !== 'object') continue;
97
+ const tag = tagFor(command, assets);
98
+ if (!tag) continue;
99
+ let group = groups[index] as SVGGElement | undefined;
100
+ let shape = group?.firstElementChild as SVGElement | null | undefined;
101
+ if (!group || !shape || shape.localName !== tag) {
102
+ const fresh = document.createElementNS(SVG_NS, 'g');
103
+ shape = tag === 'svg' ? null : document.createElementNS(SVG_NS, tag);
104
+ if (shape) fresh.append(shape);
105
+ if (group) group.replaceWith(fresh);
106
+ else state.content.append(fresh);
107
+ group = fresh;
108
+ }
109
+ const drawn = draw(group, shape ?? null, command, assets, state.defs, ids, box);
110
+ applyState(group, drawn, command, state.defs, ids);
111
+ index++;
112
+ }
113
+ while (groups.length > index) groups[groups.length - 1]!.remove();
114
+ }
115
+
116
+ /** The SVG element a command draws with; null for what is not drawn (a bitmap, a missing image). */
117
+ function tagFor(command: CanvasCommand, assets: AssetCatalog): string | null {
118
+ switch (command.op) {
119
+ case 'fill':
120
+ case 'stroke':
121
+ return 'path';
122
+ case 'text':
123
+ return 'text';
124
+ case 'image': {
125
+ const image = command.image;
126
+ if (!image || typeof image !== 'object' || isBitmapImage(image)) return null;
127
+ if (isUrlImage(image)) return 'image';
128
+ if (isAssetImage(image)) return assets.images[image.name] ? 'image' : null;
129
+ return 'svg';
130
+ }
131
+ default:
132
+ return null;
133
+ }
134
+ }
135
+
136
+ /** Draws `command` with `shape` (made anew for a symbol); returns the element drawn. */
137
+ function draw(
138
+ group: SVGGElement,
139
+ shape: SVGElement | null,
140
+ command: CanvasCommand,
141
+ assets: AssetCatalog,
142
+ defs: SVGDefsElement,
143
+ ids: IdSource,
144
+ box: { x: number; y: number; width: number; height: number },
145
+ ): SVGElement {
146
+ switch (command.op) {
147
+ case 'fill': {
148
+ const path = shape!;
149
+ setAttr(path, 'd', command.path ?? '');
150
+ path.style.fill = paint(command.shading, defs, ids, box);
151
+ path.style.stroke = 'none';
152
+ setAttr(path, 'fill-rule', command.evenOdd ? 'evenodd' : null);
153
+ clearStroke(path);
154
+ return path;
155
+ }
156
+ case 'stroke': {
157
+ const path = shape!;
158
+ setAttr(path, 'd', command.path ?? '');
159
+ path.style.fill = 'none';
160
+ path.style.stroke = paint(command.shading, defs, ids, box);
161
+ setAttr(path, 'fill-rule', null);
162
+ setAttr(path, 'stroke-width', String(finite(command.lineWidth, 1)));
163
+ setAttr(path, 'stroke-linecap', command.lineCap ?? 'butt');
164
+ setAttr(path, 'stroke-linejoin', command.lineJoin ?? 'miter');
165
+ setAttr(path, 'stroke-miterlimit', String(finite(command.miterLimit, 10)));
166
+ const dash = Array.isArray(command.dash) && command.dash.length > 0 ? command.dash.join(' ') : null;
167
+ setAttr(path, 'stroke-dasharray', dash);
168
+ setAttr(path, 'stroke-dashoffset', dash ? String(finite(command.dashPhase, 0)) : null);
169
+ return path;
170
+ }
171
+ case 'text': {
172
+ const text = shape!;
173
+ if (text.textContent !== command.text) text.textContent = command.text ?? '';
174
+ setAttr(text, 'x', String(finite(command.x, 0)));
175
+ setAttr(text, 'y', String(finite(command.y, 0)));
176
+ const ax = finite(command.anchor?.x, 0.5);
177
+ const ay = finite(command.anchor?.y, 0.5);
178
+ setAttr(text, 'text-anchor', ax < 0.25 ? 'start' : ax > 0.75 ? 'end' : 'middle');
179
+ setAttr(text, 'dominant-baseline', ay < 0.25 ? 'text-before-edge' : ay > 0.75 ? 'text-after-edge' : 'central');
180
+ text.style.fill = paint(command.shading, defs, ids, box);
181
+ applyFont(text, command.font);
182
+ return text;
183
+ }
184
+ case 'image':
185
+ return drawImage(group, shape, command, assets, defs, ids, box);
186
+ }
187
+ }
188
+
189
+ function drawImage(
190
+ group: SVGGElement,
191
+ shape: SVGElement | null,
192
+ command: Extract<CanvasCommand, { op: 'image' }>,
193
+ assets: AssetCatalog,
194
+ defs: SVGDefsElement,
195
+ ids: IdSource,
196
+ box: { x: number; y: number; width: number; height: number },
197
+ ): SVGElement {
198
+ const size = imageSize(command.image, assets);
199
+ const width = finite(command.width, size.width);
200
+ const height = finite(command.height, size.height);
201
+ const x = finite(command.x, 0) - finite(command.anchor?.x, 0.5) * width;
202
+ const y = finite(command.y, 0) - finite(command.anchor?.y, 0.5) * height;
203
+ if (shape === null || shape.localName === 'svg') {
204
+ // A system symbol: its glyph, painted with the shading (else the foreground).
205
+ const name = (command.image as { systemName?: string }).systemName ?? '';
206
+ if (shape === null || shape.dataset['sbSymbol'] !== name) {
207
+ const glyph = renderSymbol(name) ?? document.createElementNS(SVG_NS, 'svg');
208
+ glyph.dataset['sbSymbol'] = name;
209
+ group.replaceChildren(glyph);
210
+ shape = glyph;
211
+ }
212
+ shape.style.color = command.shading ? paint(command.shading, defs, ids, box) : colorToCSS({ name: 'primary' });
213
+ }
214
+ setAttr(shape, 'x', String(x));
215
+ setAttr(shape, 'y', String(y));
216
+ setAttr(shape, 'width', String(Math.max(0, width)));
217
+ setAttr(shape, 'height', String(Math.max(0, height)));
218
+ if (shape.localName === 'image') {
219
+ setAttr(shape, 'preserveAspectRatio', 'none');
220
+ const src = imageSource(command.image, assets);
221
+ if (src && shape.getAttribute('href') !== src) shape.setAttribute('href', src);
222
+ }
223
+ return shape;
224
+ }
225
+
226
+ function imageSize(image: ImageProps, assets: AssetCatalog): { width: number; height: number } {
227
+ if (isUrlImage(image)) return { width: finite(image.width, 0), height: finite(image.height, 0) };
228
+ if (isAssetImage(image)) {
229
+ const asset = assets.images[image.name];
230
+ return { width: finite(asset?.width, 0), height: finite(asset?.height, 0) };
231
+ }
232
+ return { width: SYMBOL_SIZE, height: SYMBOL_SIZE };
233
+ }
234
+
235
+ function imageSource(image: ImageProps, assets: AssetCatalog): string | null {
236
+ if (isUrlImage(image)) return image.url;
237
+ if (isAssetImage(image)) return assets.images[image.name]?.src ?? null;
238
+ return null;
239
+ }
240
+
241
+ /** The context state of `command`: transform on the shape; the rest on its group. */
242
+ function applyState(group: SVGGElement, shape: SVGElement, command: CanvasCommand, defs: SVGDefsElement, ids: IdSource): void {
243
+ const t = command.transform;
244
+ setAttr(shape, 'transform', Array.isArray(t) && t.length === 6 ? `matrix(${t.map((n) => finite(n, 0)).join(' ')})` : null);
245
+ const opacity = command.opacity;
246
+ group.style.opacity = opacity != null && opacity < 1 ? String(Math.max(0, opacity)) : '';
247
+ group.style.mixBlendMode = command.blendMode && command.blendMode !== 'normal' ? command.blendMode : '';
248
+ group.style.filter = filterCSS(command.filters);
249
+ let clip: string | null = null;
250
+ for (const d of Array.isArray(command.clips) ? command.clips : []) {
251
+ const id = `${ids.prefix}-clip${ids.next++}`;
252
+ const clipPath = document.createElementNS(SVG_NS, 'clipPath');
253
+ clipPath.id = id;
254
+ // A clip within the previous one: the intersection.
255
+ if (clip) clipPath.setAttribute('clip-path', `url(#${clip})`);
256
+ const path = document.createElementNS(SVG_NS, 'path');
257
+ path.setAttribute('d', d);
258
+ clipPath.append(path);
259
+ defs.append(clipPath);
260
+ clip = id;
261
+ }
262
+ setAttr(group, 'clip-path', clip ? `url(#${clip})` : null);
263
+ }
264
+
265
+ function filterCSS(filters: CanvasFilter[] | undefined): string {
266
+ if (!Array.isArray(filters)) return '';
267
+ const parts: string[] = [];
268
+ for (const filter of filters) {
269
+ if (!filter || typeof filter !== 'object') continue;
270
+ if ('blur' in filter) parts.push(`blur(${finite(filter.blur, 0)}px)`);
271
+ else if ('shadow' in filter) {
272
+ const { color, radius, x, y } = filter.shadow;
273
+ parts.push(`drop-shadow(${finite(x, 0)}px ${finite(y, 0)}px ${finite(radius, 0)}px ${colorToCSS(color)})`);
274
+ } else if ('grayscale' in filter) parts.push(`grayscale(${finite(filter.grayscale, 0)})`);
275
+ else if ('saturation' in filter) parts.push(`saturate(${finite(filter.saturation, 1)})`);
276
+ // SwiftUI's brightness adds to each component (0 leaves it as is).
277
+ else if ('brightness' in filter) parts.push(`brightness(${1 + finite(filter.brightness, 0)})`);
278
+ else if ('contrast' in filter) parts.push(`contrast(${finite(filter.contrast, 1)})`);
279
+ else if ('hueRotation' in filter) parts.push(`hue-rotate(${finite(filter.hueRotation, 0)}deg)`);
280
+ else if ('invert' in filter) parts.push(`invert(${finite(filter.invert, 1)})`);
281
+ }
282
+ return parts.join(' ');
283
+ }
284
+
285
+ /** The SVG paint of a shading; a canvas gradient is in the shape's own coordinates. */
286
+ function paint(shading: CanvasShading | undefined, defs: SVGDefsElement, ids: IdSource, box: { x: number; y: number; width: number; height: number }): string {
287
+ if (!shading || typeof shading !== 'object') return colorToCSS({ name: 'primary' });
288
+ if ('canvasLinearGradient' in shading) {
289
+ const { stops, start, end } = shading.canvasLinearGradient;
290
+ const gradient = document.createElementNS(SVG_NS, 'linearGradient');
291
+ setAttrs(gradient, {
292
+ gradientUnits: 'userSpaceOnUse',
293
+ x1: finite(start?.x, 0),
294
+ y1: finite(start?.y, 0),
295
+ x2: finite(end?.x, 0),
296
+ y2: finite(end?.y, 0),
297
+ });
298
+ return addGradient(gradient, stops, defs, ids);
299
+ }
300
+ if ('canvasRadialGradient' in shading) {
301
+ const { stops, center, startRadius, endRadius } = shading.canvasRadialGradient;
302
+ const gradient = document.createElementNS(SVG_NS, 'radialGradient');
303
+ setAttrs(gradient, {
304
+ gradientUnits: 'userSpaceOnUse',
305
+ cx: finite(center?.x, 0),
306
+ cy: finite(center?.y, 0),
307
+ r: Math.max(0, finite(endRadius, 0)),
308
+ fr: Math.max(0, finite(startRadius, 0)),
309
+ });
310
+ return addGradient(gradient, stops, defs, ids);
311
+ }
312
+ return paintFor(document, defs, shading as ShapeStyle, box, ids);
313
+ }
314
+
315
+ function addGradient(gradient: SVGElement, stops: GradientStop[] | undefined, defs: SVGDefsElement, ids: IdSource): string {
316
+ const id = `${ids.prefix}-grad${ids.next++}`;
317
+ gradient.id = id;
318
+ const valid = (Array.isArray(stops) ? stops : []).filter((stop) => stop && typeof stop === 'object' && stop.color);
319
+ const colors = valid.length === 0 ? [] : valid.length === 1 ? [valid[0]!, { ...valid[0]!, location: 1 }] : valid;
320
+ for (const stop of colors) {
321
+ const el = document.createElementNS(SVG_NS, 'stop');
322
+ el.setAttribute('offset', String(Math.min(1, Math.max(0, finite(stop.location, 0)))));
323
+ el.style.stopColor = colorToCSS(stop.color);
324
+ gradient.append(el);
325
+ }
326
+ defs.append(gradient);
327
+ return `url(#${id})`;
328
+ }
329
+
330
+ function applyFont(text: SVGElement, font: Font | undefined): void {
331
+ const s = text.style;
332
+ s.fontSize = `${BODY_SIZE}px`;
333
+ s.fontWeight = '';
334
+ if (!font || typeof font !== 'object') return;
335
+ if (font.size != null) s.fontSize = `${finite(font.size, BODY_SIZE)}px`;
336
+ else if (font.textStyle) {
337
+ s.fontSize = `var(${textStyleVar(font.textStyle, 'size')}, ${resolveTextStyle(font.textStyle, DEFAULT_TYPE_SIZE).fontSize}px)`;
338
+ const implied = TEXT_STYLE_WEIGHT[font.textStyle];
339
+ if (!font.weight && implied) s.fontWeight = WEIGHTS[implied];
340
+ }
341
+ if (font.weight) s.fontWeight = WEIGHTS[font.weight] ?? '400';
342
+ }
343
+
344
+ function clearStroke(path: SVGElement): void {
345
+ for (const name of ['stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-dasharray', 'stroke-dashoffset']) {
346
+ path.removeAttribute(name);
347
+ }
348
+ }
349
+
350
+ function setAttr(el: Element, name: string, value: string | null): void {
351
+ if (value == null) el.removeAttribute(name);
352
+ else if (el.getAttribute(name) !== value) el.setAttribute(name, value);
353
+ }
354
+
355
+ function setAttrs(el: Element, attrs: Record<string, string | number>): void {
356
+ for (const [name, value] of Object.entries(attrs)) el.setAttribute(name, String(value));
357
+ }
358
+
359
+ function finite(value: number | undefined | null, fallback: number): number {
360
+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
361
+ }
package/src/chartDom.ts CHANGED
@@ -159,7 +159,7 @@ function gridLines(axis: ChartAxis | null | undefined): number[] {
159
159
  return axis && Array.isArray(axis.gridLines) ? axis.gridLines.filter((v) => typeof v === 'number' && Number.isFinite(v)) : [];
160
160
  }
161
161
 
162
- interface IdSource {
162
+ export interface IdSource {
163
163
  next: number;
164
164
  prefix: string;
165
165
  }
@@ -325,7 +325,7 @@ function clipPathFor(doc: Document, defs: SVGDefsElement, mask: ChartMark, plot:
325
325
  * a `<linearGradient>` / `<radialGradient>` to `defs`. `bbox` is the mark's
326
326
  * pixel bounds, which a radial gradient's center and radii refer to.
327
327
  */
328
- function paintFor(doc: Document, defs: SVGDefsElement, style: ShapeStyle | undefined, bbox: Rect, ids: IdSource): string {
328
+ export function paintFor(doc: Document, defs: SVGDefsElement, style: ShapeStyle | undefined, bbox: Rect, ids: IdSource): string {
329
329
  if (!style || typeof style !== 'object') return 'var(--sb-color-accent)';
330
330
  if (isColor(style)) return colorToCSS(style);
331
331
  if (isHierarchicalStyle(style)) return styleToCSS(style, 'paint').value;
@@ -0,0 +1,179 @@
1
+ /**
2
+ * The browser's own chrome around the app on a phone (device mode): the
3
+ * status bar strip and the safe areas Safari tints, which take their color
4
+ * from `theme-color`, the page background and (in recent Safari) the fixed
5
+ * element at the top edge. The page's defaults follow the OS appearance, not
6
+ * the app, so an app with its own bar color (Ice Cubes' themes) or its own
7
+ * `.preferredColorScheme` sat under a strip of a different color.
8
+ *
9
+ * After each layout this samples the color the app draws at the middle of
10
+ * its top edge (every layer there, translucent bars over what is behind
11
+ * them) and gives it to `theme-color`, the page background and the device
12
+ * element; the page's `color-scheme` follows the app's.
13
+ */
14
+
15
+ /** An RGBA color, 0–255 channels and 0–1 alpha. */
16
+ interface Rgba {
17
+ r: number;
18
+ g: number;
19
+ b: number;
20
+ a: number;
21
+ }
22
+
23
+ /** `rgb(…)` / `rgba(…)` as computed styles write it, or `#rgb` / `#rrggbb`; null for anything else. */
24
+ export function parseCssColor(value: string): Rgba | null {
25
+ const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(value.trim());
26
+ if (hex) {
27
+ const digits = hex[1]!.length === 3 ? [...hex[1]!].map((d) => d + d).join('') : hex[1]!;
28
+ const n = Number.parseInt(digits, 16);
29
+ return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255, a: 1 };
30
+ }
31
+ const match = /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?\s*\)$/.exec(value.trim());
32
+ if (!match) return null;
33
+ let a = 1;
34
+ if (match[4] !== undefined) a = match[4].endsWith('%') ? Number.parseFloat(match[4]) / 100 : Number(match[4]);
35
+ return { r: Number(match[1]), g: Number(match[2]), b: Number(match[3]), a };
36
+ }
37
+
38
+ /**
39
+ * The color seen through `layers`, topmost first, over an opaque `base`
40
+ * (source-over compositing; layers below an opaque one do not show).
41
+ */
42
+ export function compositeColors(layers: readonly Rgba[], base: Rgba): Rgba {
43
+ let r = 0;
44
+ let g = 0;
45
+ let b = 0;
46
+ let a = 0;
47
+ for (const layer of [...layers, { ...base, a: 1 }]) {
48
+ const weight = (1 - a) * layer.a;
49
+ r += layer.r * weight;
50
+ g += layer.g * weight;
51
+ b += layer.b * weight;
52
+ a += weight;
53
+ if (a >= 0.999) break;
54
+ }
55
+ return { r: r / a, g: g / a, b: b / a, a: 1 };
56
+ }
57
+
58
+ /** `#rrggbb`. */
59
+ export function hexColor(color: Rgba): string {
60
+ const channel = (value: number): string =>
61
+ Math.round(Math.min(255, Math.max(0, value)))
62
+ .toString(16)
63
+ .padStart(2, '0');
64
+ return `#${channel(color.r)}${channel(color.g)}${channel(color.b)}`;
65
+ }
66
+
67
+ /** At most one sample in this many milliseconds. */
68
+ const MIN_INTERVAL = 250;
69
+
70
+ /** A painted background under the sampled point, and where it paints in the stack. */
71
+ interface Layer {
72
+ color: Rgba;
73
+ /** The explicit `z-index`es from the screen down to it. */
74
+ z: number[];
75
+ /** Its document order. */
76
+ order: number;
77
+ }
78
+
79
+ /** Whether `a` paints above `b`: by stacking order, then later in the document. */
80
+ function above(a: Layer, b: Layer): number {
81
+ for (let i = 0; i < Math.max(a.z.length, b.z.length); i++) {
82
+ const d = (a.z[i] ?? 0) - (b.z[i] ?? 0);
83
+ if (d !== 0) return -d;
84
+ }
85
+ return b.order - a.order;
86
+ }
87
+
88
+ /**
89
+ * The backgrounds painted at (`x`, `y`) inside `root`, topmost first. Only
90
+ * elements whose box holds the point are walked (the engine places children
91
+ * inside their parents; a zero-size slot is walked through), so this reads
92
+ * tens of boxes and styles, not the page. `elementsFromPoint` would skip the many app layers that let
93
+ * pointers through (`pointer-events: none`), and turning that off restyles
94
+ * every element.
95
+ */
96
+ export function layersAt(root: Element, x: number, y: number): Rgba[] {
97
+ const view = root.ownerDocument.defaultView;
98
+ if (!view) return [];
99
+ const layers: Layer[] = [];
100
+ let order = 0;
101
+ const walk = (parent: Element, opacity: number, z: number[]): void => {
102
+ for (const el of parent.children) {
103
+ const r = el.getBoundingClientRect();
104
+ const inside = x >= r.left && x < r.right && y >= r.top && y < r.bottom;
105
+ // A zero-size wrapper (a slot) is walked through: its children overflow it.
106
+ if (!inside && r.width > 0 && r.height > 0) continue;
107
+ const style = view.getComputedStyle(el);
108
+ if (style.display === 'none') continue;
109
+ const alpha = opacity * Number(style.opacity || 1);
110
+ if (alpha <= 0.001) continue;
111
+ const own = style.position !== 'static' && style.zIndex !== 'auto' ? [...z, Number(style.zIndex) || 0] : z;
112
+ const color = inside && style.visibility === 'visible' ? parseCssColor(style.backgroundColor) : null;
113
+ if (color && color.a > 0) layers.push({ color: { ...color, a: color.a * alpha }, z: own, order: order++ });
114
+ walk(el, alpha, own);
115
+ }
116
+ };
117
+ walk(root, 1, []);
118
+ return layers.sort(above).map((layer) => layer.color);
119
+ }
120
+
121
+ /** Keeps the browser chrome's color in step with the app's top edge. */
122
+ export class ChromeColor {
123
+ private scheduled = false;
124
+ private last = 0;
125
+ private applied = '';
126
+ private readonly meta: HTMLMetaElement;
127
+
128
+ constructor(
129
+ private readonly device: HTMLElement,
130
+ private readonly screen: HTMLElement,
131
+ ) {
132
+ const doc = device.ownerDocument;
133
+ // One `theme-color` the page sets, in place of the per-appearance pair.
134
+ for (const meta of doc.querySelectorAll('meta[name="theme-color"]')) meta.remove();
135
+ this.meta = doc.createElement('meta');
136
+ this.meta.name = 'theme-color';
137
+ doc.head.append(this.meta);
138
+ // A push, pop or sheet changes the top edge when its animation ends.
139
+ screen.addEventListener('animationend', () => this.schedule());
140
+ screen.addEventListener('transitionend', () => this.schedule());
141
+ }
142
+
143
+ /** Sample again once the current frame is drawn (call after each layout and theme change). */
144
+ schedule(): void {
145
+ if (this.scheduled) return;
146
+ this.scheduled = true;
147
+ const wait = Math.max(0, this.last + MIN_INTERVAL - performance.now());
148
+ setTimeout(() => {
149
+ requestAnimationFrame(() => {
150
+ this.scheduled = false;
151
+ this.last = performance.now();
152
+ this.sample();
153
+ });
154
+ }, wait);
155
+ }
156
+
157
+ private sample(): void {
158
+ const doc = this.device.ownerDocument;
159
+ const rect = this.screen.getBoundingClientRect();
160
+ if (rect.width === 0 || rect.height === 0) return;
161
+ const view = doc.defaultView;
162
+ if (!view) return;
163
+ const layers = layersAt(this.screen, rect.left + rect.width / 2, rect.top + 1);
164
+ const theme = this.device.dataset['theme'] === 'dark' ? 'dark' : 'light';
165
+ const base =
166
+ parseCssColor(view.getComputedStyle(this.device).getPropertyValue('--sb-color-system-background')) ??
167
+ (theme === 'dark' ? { r: 0, g: 0, b: 0, a: 1 } : { r: 255, g: 255, b: 255, a: 1 });
168
+ const color = hexColor(compositeColors(layers, base));
169
+ const key = `${color}|${theme}`;
170
+ if (key === this.applied) return;
171
+ this.applied = key;
172
+ this.meta.content = color;
173
+ const root = doc.documentElement;
174
+ root.style.colorScheme = theme;
175
+ root.style.backgroundColor = color;
176
+ doc.body.style.backgroundColor = color;
177
+ this.device.style.backgroundColor = color;
178
+ }
179
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * `UIPasteboard.general` (Sources/UIKit/Pasteboard.swift): what the app
3
+ * copies also goes to the system clipboard, through the Async Clipboard API.
4
+ * Text is written with `writeText`; a picture as an `image/png` clipboard
5
+ * item (the one image type browsers accept), converted from JPEG or another
6
+ * format when needed. The write is started before the request's first
7
+ * `await`, while the tap that copied may still count as user activation
8
+ * (Safari requires it; Chromium wants the document focused). Reading
9
+ * (`readText`) may ask the viewer, or be refused; the app's own pasteboard
10
+ * keeps what it copied either way. See docs/ops-protocol.md, "Sharing and media".
11
+ */
12
+
13
+ import type { ClipboardRequestParams } from './protocol';
14
+
15
+ /** The system clipboard; tests substitute it. */
16
+ export interface ClipboardHost {
17
+ writeText(text: string): Promise<void>;
18
+ /** Writes a PNG. `png` may still be on its way: the write itself must start within the tap. */
19
+ writeImage(png: Promise<Blob>): Promise<void>;
20
+ readText(): Promise<string>;
21
+ }
22
+
23
+ /** The browser's `navigator.clipboard`, where the page has one (a secure context). */
24
+ export function browserClipboardHost(): ClipboardHost | undefined {
25
+ if (typeof navigator === 'undefined' || !navigator.clipboard) return undefined;
26
+ const clipboard = navigator.clipboard;
27
+ return {
28
+ writeText: (text) => clipboard.writeText(text),
29
+ writeImage: (png) =>
30
+ typeof ClipboardItem === 'function'
31
+ ? clipboard.write([new ClipboardItem({ 'image/png': png })])
32
+ : Promise.reject(new Error('unsupported: this browser cannot copy images')),
33
+ readText: () => clipboard.readText(),
34
+ };
35
+ }
36
+
37
+ /**
38
+ * Performs a `clipboard` request. `pictures` gathers the request's files,
39
+ * catalog images and bitmaps (hostRequests.ts); the first becomes the
40
+ * copied image.
41
+ */
42
+ export async function performClipboard(
43
+ params: ClipboardRequestParams,
44
+ host: ClipboardHost | undefined,
45
+ pictures: () => Promise<File[]>,
46
+ ): Promise<{ text?: string }> {
47
+ if (!host) throw new Error('unsupported: this page has no clipboard');
48
+ if (params.action === 'readText') return { text: await host.readText() };
49
+ if (typeof params.text === 'string') {
50
+ await host.writeText(params.text);
51
+ return {};
52
+ }
53
+ const png = pictures().then((files) => {
54
+ const first = files[0];
55
+ if (!first) throw new Error('unsupported: nothing to copy');
56
+ return toPNG(first);
57
+ });
58
+ await host.writeImage(png);
59
+ return {};
60
+ }
61
+
62
+ /** The picture as PNG: as it is when it is one, else drawn on a canvas and encoded. */
63
+ export async function toPNG(blob: Blob): Promise<Blob> {
64
+ if (blob.type === 'image/png') return blob;
65
+ const bitmap = await createImageBitmap(blob);
66
+ if (typeof OffscreenCanvas === 'function') {
67
+ const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
68
+ canvas.getContext('2d')?.drawImage(bitmap, 0, 0);
69
+ return canvas.convertToBlob({ type: 'image/png' });
70
+ }
71
+ const canvas = document.createElement('canvas');
72
+ canvas.width = bitmap.width;
73
+ canvas.height = bitmap.height;
74
+ canvas.getContext('2d')?.drawImage(bitmap, 0, 0);
75
+ return new Promise((resolve, reject) =>
76
+ canvas.toBlob((png) => (png ? resolve(png) : reject(new Error('unsupported: could not encode PNG'))), 'image/png'),
77
+ );
78
+ }