@swiftbrowser/web 0.1.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 (84) hide show
  1. package/README.md +600 -0
  2. package/app/index.html +125 -0
  3. package/index.html +56 -0
  4. package/node/dist/node/apps.d.ts +58 -0
  5. package/node/dist/node/apps.d.ts.map +1 -0
  6. package/node/dist/node/apps.js +60 -0
  7. package/node/dist/node/apps.js.map +1 -0
  8. package/node/dist/node/config.d.ts +42 -0
  9. package/node/dist/node/config.d.ts.map +1 -0
  10. package/node/dist/node/config.js +99 -0
  11. package/node/dist/node/config.js.map +1 -0
  12. package/node/dist/node/examples.d.ts +39 -0
  13. package/node/dist/node/examples.d.ts.map +1 -0
  14. package/node/dist/node/examples.js +116 -0
  15. package/node/dist/node/examples.js.map +1 -0
  16. package/node/dist/node/index.d.ts +47 -0
  17. package/node/dist/node/index.d.ts.map +1 -0
  18. package/node/dist/node/index.js +52 -0
  19. package/node/dist/node/index.js.map +1 -0
  20. package/node/dist/plugins/app-manifests.d.ts +61 -0
  21. package/node/dist/plugins/app-manifests.d.ts.map +1 -0
  22. package/node/dist/plugins/app-manifests.js +168 -0
  23. package/node/dist/plugins/app-manifests.js.map +1 -0
  24. package/node/dist/plugins/asset-catalogs.d.ts +119 -0
  25. package/node/dist/plugins/asset-catalogs.d.ts.map +1 -0
  26. package/node/dist/plugins/asset-catalogs.js +488 -0
  27. package/node/dist/plugins/asset-catalogs.js.map +1 -0
  28. package/node/dist/plugins/bundle-resources.d.ts +49 -0
  29. package/node/dist/plugins/bundle-resources.d.ts.map +1 -0
  30. package/node/dist/plugins/bundle-resources.js +172 -0
  31. package/node/dist/plugins/bundle-resources.js.map +1 -0
  32. package/node/dist/plugins/swift-wasm.d.ts +33 -0
  33. package/node/dist/plugins/swift-wasm.d.ts.map +1 -0
  34. package/node/dist/plugins/swift-wasm.js +241 -0
  35. package/node/dist/plugins/swift-wasm.js.map +1 -0
  36. package/node/dist/src/assets.d.ts +60 -0
  37. package/node/dist/src/assets.d.ts.map +1 -0
  38. package/node/dist/src/assets.js +54 -0
  39. package/node/dist/src/assets.js.map +1 -0
  40. package/node/dist/src/devEvents.d.ts +31 -0
  41. package/node/dist/src/devEvents.d.ts.map +1 -0
  42. package/node/dist/src/devEvents.js +9 -0
  43. package/node/dist/src/devEvents.js.map +1 -0
  44. package/package.json +56 -0
  45. package/plugins/app-manifests.ts +192 -0
  46. package/plugins/asset-catalogs.ts +558 -0
  47. package/plugins/bundle-resources.ts +195 -0
  48. package/plugins/swift-wasm.ts +259 -0
  49. package/public/_headers +23 -0
  50. package/public/apple-touch-icon.png +0 -0
  51. package/public/icon.svg +14 -0
  52. package/public/manifest.webmanifest +14 -0
  53. package/src/animation.ts +521 -0
  54. package/src/assets.ts +86 -0
  55. package/src/chartDom.ts +481 -0
  56. package/src/devEvents.ts +35 -0
  57. package/src/deviceMode.ts +80 -0
  58. package/src/ghost.ts +25 -0
  59. package/src/interaction.ts +1113 -0
  60. package/src/landing.css +261 -0
  61. package/src/landing.ts +141 -0
  62. package/src/layout/chart.ts +477 -0
  63. package/src/layout/controls.ts +116 -0
  64. package/src/layout/engine.ts +3581 -0
  65. package/src/layout/index.ts +91 -0
  66. package/src/layout/measure.ts +86 -0
  67. package/src/layout/text.ts +124 -0
  68. package/src/layout/types.ts +153 -0
  69. package/src/layout/typography.ts +188 -0
  70. package/src/layoutDom.ts +549 -0
  71. package/src/layoutEvents.ts +110 -0
  72. package/src/locale.ts +64 -0
  73. package/src/main.ts +554 -0
  74. package/src/mock.ts +752 -0
  75. package/src/nodeQueries.ts +60 -0
  76. package/src/protocol.ts +1421 -0
  77. package/src/renderer.ts +2882 -0
  78. package/src/styles.css +2834 -0
  79. package/src/styles.ts +373 -0
  80. package/src/swipe.ts +110 -0
  81. package/src/symbols.ts +595 -0
  82. package/src/typography.ts +76 -0
  83. package/src/vite-env.d.ts +26 -0
  84. package/src/wasm.ts +400 -0
package/src/styles.ts ADDED
@@ -0,0 +1,373 @@
1
+ /**
2
+ * Colors, ShapeStyles and visual effects → CSS (docs/phase6/styles.md).
3
+ *
4
+ * Everything a view is painted with (`style.foreground`, `style.background`,
5
+ * a shape's `fill` and `stroke.color`) is a `ShapeStyle`: a `Color`
6
+ * (explicit sRGB, a semantic token or a catalog color), a hierarchical level,
7
+ * a gradient or a material. A ShapeStyle resolves differently by *role*:
8
+ * `.secondary` is the secondary label color as text and the secondary fill
9
+ * color as a background. `styleToCSS` therefore takes the role and returns
10
+ * the CSS pieces the renderer needs; the renderer decides which property
11
+ * each piece lands in.
12
+ *
13
+ * This module is DOM-free so it can be unit-tested under node.
14
+ */
15
+
16
+ import { assetColorVar } from './assets';
17
+ import {
18
+ isAssetColor,
19
+ isColor,
20
+ isColorGradientStyle,
21
+ isHierarchicalStyle,
22
+ isLinearGradientStyle,
23
+ isMaterialStyle,
24
+ isNamedColor,
25
+ isRadialGradientStyle,
26
+ type Color,
27
+ type GradientStop,
28
+ type MaterialName,
29
+ type ShapeStyle,
30
+ type Style,
31
+ type UnitPoint,
32
+ } from './protocol';
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Colors
36
+ // ---------------------------------------------------------------------------
37
+
38
+ const SEMANTIC_COLOR_VAR: Record<string, string> = {
39
+ primary: 'var(--sb-color-primary)',
40
+ secondary: 'var(--sb-color-secondary)',
41
+ accent: 'var(--sb-color-accent)',
42
+ systemBackground: 'var(--sb-color-system-background)',
43
+ secondarySystemBackground: 'var(--sb-color-secondary-system-background)',
44
+ clear: 'transparent',
45
+ };
46
+
47
+ /** CSS `<color>` for a protocol `Color`. */
48
+ export function colorToCSS(color: Color): string {
49
+ if (isAssetColor(color)) {
50
+ // The catalog's light/dark values live in `--sb-asset-color-<slug>`
51
+ // (see `Renderer.setAssetCatalog`); a name the catalog lacks has no
52
+ // property and falls back to magenta so it stands out.
53
+ return withOpacity(`var(${assetColorVar(color.named)}, magenta)`, color.opacity);
54
+ }
55
+ if (isNamedColor(color)) {
56
+ const token = SEMANTIC_COLOR_VAR[color.name] ?? `var(--sb-color-${camelToKebab(color.name)})`;
57
+ if (token === 'transparent') return token;
58
+ // Opacity multiplier on a semantic token. `color-mix` keeps the token
59
+ // live across theme changes; mixing with `transparent` scales alpha.
60
+ return withOpacity(token, color.a);
61
+ }
62
+ const c = (v: number) => Math.round(clamp01(v) * 255);
63
+ const a = color.a ?? 1;
64
+ return `rgba(${c(color.r)}, ${c(color.g)}, ${c(color.b)}, ${clamp01(a)})`;
65
+ }
66
+
67
+ function clamp01(v: number): number {
68
+ return Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : 1;
69
+ }
70
+
71
+ /** `token` at `alpha` (0...1): `color-mix` with transparent keeps a `var()` live across themes. */
72
+ function withOpacity(token: string, alpha: number | undefined): string {
73
+ if (alpha == null || !Number.isFinite(alpha) || alpha >= 1) return token;
74
+ const pct = Math.round(clamp01(alpha) * 1000) / 10;
75
+ return `color-mix(in srgb, ${token} ${pct}%, transparent)`;
76
+ }
77
+
78
+ function camelToKebab(name: string): string {
79
+ return name.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // ShapeStyles
84
+ // ---------------------------------------------------------------------------
85
+
86
+ /**
87
+ * `text`: the style colors glyphs (`style.foreground`). `paint`: it fills an
88
+ * area (`style.background`, a shape's `fill` or `stroke.color`).
89
+ */
90
+ export type PaintRole = 'text' | 'paint';
91
+
92
+ export interface PaintCSS {
93
+ /** `color` for a flat style, `gradient` for a CSS `<image>`, `material` for a translucent fill. */
94
+ kind: 'color' | 'gradient' | 'material';
95
+ /**
96
+ * What to paint with: a `<color>` (`color`, `material`) or an `<image>`
97
+ * (`gradient`). Valid as a `background` shorthand value either way.
98
+ */
99
+ value: string;
100
+ /**
101
+ * A `<color>` that stands in for the style where an image cannot be used:
102
+ * border colors, `currentColor` consumers such as symbols, the text color
103
+ * behind a gradient. The value itself for flat styles, the first stop for
104
+ * gradients, the base color for materials.
105
+ */
106
+ solid: string;
107
+ /** Materials only: the `backdrop-filter` to apply alongside `value`. */
108
+ backdropFilter?: string;
109
+ }
110
+
111
+ /** Label colors (text role) and fill colors (paint role) per hierarchical level. */
112
+ const HIERARCHICAL_TEXT: Record<1 | 2 | 3 | 4, string> = {
113
+ 1: 'var(--sb-color-primary)',
114
+ 2: 'var(--sb-color-secondary)',
115
+ 3: 'var(--sb-color-tertiary-label)',
116
+ 4: 'var(--sb-color-quaternary-label)',
117
+ };
118
+ const HIERARCHICAL_PAINT: Record<1 | 2 | 3 | 4, string> = {
119
+ 1: 'var(--sb-color-fill)',
120
+ 2: 'var(--sb-color-secondary-fill)',
121
+ 3: 'var(--sb-color-tertiary-fill)',
122
+ 4: 'var(--sb-color-quaternary-fill)',
123
+ };
124
+
125
+ /** Opacity of the system background each material shows over the blurred backdrop. */
126
+ export const MATERIAL_ALPHA: Record<MaterialName, number> = {
127
+ ultraThin: 0.55,
128
+ thin: 0.7,
129
+ regular: 0.82,
130
+ thick: 0.9,
131
+ ultraThick: 0.96,
132
+ };
133
+ export const MATERIAL_BLUR = 'blur(20px)';
134
+
135
+ /**
136
+ * Resolve a ShapeStyle for a role. Unknown shapes resolve to magenta so they
137
+ * stand out. A non-color style's top-level `opacity` multiplies the result
138
+ * (each stop of a gradient, the material's alpha) like a semantic color's `a`.
139
+ */
140
+ export function styleToCSS(style: ShapeStyle, role: PaintRole): PaintCSS {
141
+ if (isColor(style)) {
142
+ const value = colorToCSS(style);
143
+ return { kind: 'color', value, solid: value };
144
+ }
145
+ const opacity = style.opacity;
146
+ if (isHierarchicalStyle(style)) {
147
+ const level = hierarchicalLevel(style.hierarchical);
148
+ const value = withOpacity(role === 'text' ? HIERARCHICAL_TEXT[level] : HIERARCHICAL_PAINT[level], opacity);
149
+ return { kind: 'color', value, solid: value };
150
+ }
151
+ if (isLinearGradientStyle(style)) {
152
+ const { stops, start, end } = style.linearGradient;
153
+ return gradient(linearGradientCSS(stops ?? [], start, end, opacity), stops, opacity);
154
+ }
155
+ if (isRadialGradientStyle(style)) {
156
+ const { stops, center, startRadius, endRadius } = style.radialGradient;
157
+ return gradient(radialGradientCSS(stops ?? [], center, startRadius, endRadius, opacity), stops, opacity);
158
+ }
159
+ if (isColorGradientStyle(style)) {
160
+ const base = colorToCSS(style.colorGradient);
161
+ const light = withOpacity(`color-mix(in srgb, ${base}, white 12%)`, opacity);
162
+ const dark = withOpacity(`color-mix(in srgb, ${base}, black 12%)`, opacity);
163
+ return { kind: 'gradient', value: `linear-gradient(to bottom, ${light}, ${dark})`, solid: withOpacity(base, opacity) };
164
+ }
165
+ if (isMaterialStyle(style)) {
166
+ const alpha = MATERIAL_ALPHA[style.material] ?? MATERIAL_ALPHA.regular;
167
+ const value = `rgba(var(--sb-color-material-base), ${round(alpha * (opacity == null || !Number.isFinite(opacity) ? 1 : clamp01(opacity)))})`;
168
+ return { kind: 'material', value, solid: value, backdropFilter: MATERIAL_BLUR };
169
+ }
170
+ return { kind: 'color', value: 'magenta', solid: 'magenta' };
171
+ }
172
+
173
+ function hierarchicalLevel(level: number): 1 | 2 | 3 | 4 {
174
+ return level === 2 || level === 3 || level === 4 ? level : 1;
175
+ }
176
+
177
+ function gradient(value: string, stops: readonly GradientStop[] | undefined, opacity: number | undefined): PaintCSS {
178
+ const first = stops?.[0]?.color;
179
+ return { kind: 'gradient', value, solid: first ? withOpacity(colorToCSS(first), opacity) : 'transparent' };
180
+ }
181
+
182
+ /**
183
+ * `linear-gradient(<angle>, ...)` for a gradient from `start` to `end` (unit
184
+ * points). CSS measures angles clockwise from "to top", so the angle is
185
+ * atan2(dx, −dy). The gradient line of a CSS gradient always spans the whole
186
+ * box, so the stop locations are mapped onto the projections of `start` and
187
+ * `end` on that line (exact for axis-aligned gradients; a unit square is
188
+ * assumed otherwise, which is close enough for the usual corner-to-corner
189
+ * gradients).
190
+ */
191
+ export function linearGradientCSS(
192
+ stops: readonly GradientStop[],
193
+ start: UnitPoint | undefined,
194
+ end: UnitPoint | undefined,
195
+ opacity?: number,
196
+ ): string {
197
+ const sx = unit(start?.x, 0.5);
198
+ const sy = unit(start?.y, 0);
199
+ const ex = unit(end?.x, 0.5);
200
+ const ey = unit(end?.y, 1);
201
+ const dx = ex - sx;
202
+ const dy = ey - sy;
203
+ const radians = dx === 0 && dy === 0 ? Math.PI : Math.atan2(dx, -dy);
204
+ const degrees = round((((radians * 180) / Math.PI) % 360 + 360) % 360);
205
+ // Position of a unit point along the gradient line (0 at its start, 1 at its end).
206
+ const sin = Math.sin(radians);
207
+ const cos = Math.cos(radians);
208
+ const length = Math.abs(sin) + Math.abs(cos);
209
+ const along = (x: number, y: number) => ((x - 0.5) * sin - (y - 0.5) * cos) / length + 0.5;
210
+ const from = along(sx, sy);
211
+ const to = along(ex, ey);
212
+ const stopList = stopsCSS(stops, (location) => from + location * (to - from), opacity);
213
+ return `linear-gradient(${degrees}deg, ${stopList})`;
214
+ }
215
+
216
+ /** `radial-gradient(circle <endRadius>px at <cx>% <cy>%, ...)`; the stops span startRadius...endRadius. */
217
+ export function radialGradientCSS(
218
+ stops: readonly GradientStop[],
219
+ center: UnitPoint | undefined,
220
+ startRadius: number | undefined,
221
+ endRadius: number | undefined,
222
+ opacity?: number,
223
+ ): string {
224
+ const cx = unit(center?.x, 0.5);
225
+ const cy = unit(center?.y, 0.5);
226
+ const r1 = endRadius != null && Number.isFinite(endRadius) && endRadius > 0 ? endRadius : null;
227
+ const r0 = Math.max(0, startRadius != null && Number.isFinite(startRadius) ? startRadius : 0);
228
+ const size = r1 == null ? 'farthest-corner' : `${round(r1)}px`;
229
+ const list = stopsCSS(stops, (location) => (r1 == null ? location : (r0 + location * (r1 - r0)) / r1), opacity);
230
+ return `radial-gradient(circle ${size} at ${round(cx * 100)}% ${round(cy * 100)}%, ${list})`;
231
+ }
232
+
233
+ /**
234
+ * `color pct%, ...`, with `map` turning a stop location into a 0...1 position
235
+ * and `opacity` multiplying every stop. Degenerate lists are padded to two stops.
236
+ */
237
+ function stopsCSS(stops: readonly GradientStop[], map: (location: number) => number, opacity: number | undefined): string {
238
+ const list = stops
239
+ .filter((stop) => stop && typeof stop === 'object' && stop.color)
240
+ .map((stop) => `${withOpacity(colorToCSS(stop.color), opacity)} ${round(clamp01(map(unit(stop.location, 0))) * 100)}%`);
241
+ if (list.length === 0) return 'transparent 0%, transparent 100%';
242
+ if (list.length === 1) return `${list[0]}, ${list[0]!.replace(/ [\d.]+%$/, ' 100%')}`;
243
+ return list.join(', ');
244
+ }
245
+
246
+ function unit(v: number | undefined, fallback: number): number {
247
+ return v != null && Number.isFinite(v) ? v : fallback;
248
+ }
249
+
250
+ function round(v: number): number {
251
+ return Math.round(v * 1000) / 1000;
252
+ }
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // Visual effects
256
+ // ---------------------------------------------------------------------------
257
+
258
+ export interface EffectsCSS {
259
+ /**
260
+ * `translate(...) rotate(...) perspective(...) rotate3d(...) scale(...)`,
261
+ * in that order (Phase 8 adds the 3D pair); undefined when none applies.
262
+ */
263
+ transform?: string;
264
+ /** From `rotationAnchor`, else `rotation3D.anchor`, else `scaleAnchor` (unit point → percentages). */
265
+ transformOrigin?: string;
266
+ /** `blur(radius)` (Phase 8) then `drop-shadow(x y radius color)`, whichever apply. */
267
+ filter?: string;
268
+ /** Phase 7 `.hidden()`: `visibility: hidden; pointer-events: none` (the layout is kept). */
269
+ hidden?: boolean;
270
+ /** Phase 7 `.mask { }`: the `mask-image` (and `-webkit-mask-image`) value, see `maskImageCSS`. */
271
+ maskImage?: string;
272
+ }
273
+
274
+ /** CSS perspective distance (px) for SwiftUI's default `perspective: 1`. */
275
+ export const PERSPECTIVE_BASE_PX = 500;
276
+
277
+ /**
278
+ * The CSS of a style's offset, rotation, 3D rotation, scale, blur and shadow.
279
+ * The transform is composed as translate, then rotate, then (Phase 8)
280
+ * `perspective() rotate3d()`, then scale: CSS applies the list right to left,
281
+ * so the box is scaled first, turned in 3D and projected, turned in 2D and
282
+ * finally offset, which is the order SwiftUI's modifiers nest in
283
+ * (`.scaleEffect` innermost, `.offset` outermost) in the HackingWithSwift
284
+ * examples. CSS has one `transform-origin`, so when several anchors are
285
+ * present and differ the rotation anchor wins, then the 3D anchor, then the
286
+ * scale anchor (see docs/phase6/styles.md). The 3D perspective distance is
287
+ * `500px / perspective` (a smaller SwiftUI perspective is a flatter
288
+ * projection, as `perspective: 0.5` is in SwiftUI).
289
+ */
290
+ export function effectsCSS(style: Style): EffectsCSS {
291
+ const out: EffectsCSS = {};
292
+ const transforms: string[] = [];
293
+ if (style.offset) {
294
+ const x = finite(style.offset.x);
295
+ const y = finite(style.offset.y);
296
+ if (x || y) transforms.push(`translate(${x}px, ${y}px)`);
297
+ }
298
+ if (style.rotation != null && Number.isFinite(style.rotation) && style.rotation !== 0) {
299
+ transforms.push(`rotate(${round(style.rotation)}deg)`);
300
+ }
301
+ // Phase 8 `.rotation3DEffect`: a zero angle or a zero axis is the identity.
302
+ const r3 = style.rotation3D;
303
+ if (r3 && typeof r3 === 'object' && Number.isFinite(r3.angle) && r3.angle !== 0) {
304
+ const ax = finite(r3.axis?.x);
305
+ const ay = finite(r3.axis?.y);
306
+ const az = finite(r3.axis?.z);
307
+ if (ax || ay || az) {
308
+ const p = r3.perspective != null && Number.isFinite(r3.perspective) && r3.perspective > 0 ? r3.perspective : 1;
309
+ transforms.push(`perspective(${round(PERSPECTIVE_BASE_PX / p)}px)`);
310
+ transforms.push(`rotate3d(${round(ax)}, ${round(ay)}, ${round(az)}, ${round(r3.angle)}deg)`);
311
+ }
312
+ }
313
+ if (style.scale) {
314
+ const x = style.scale.x != null && Number.isFinite(style.scale.x) ? style.scale.x : 1;
315
+ const y = style.scale.y != null && Number.isFinite(style.scale.y) ? style.scale.y : 1;
316
+ if (x !== 1 || y !== 1) transforms.push(`scale(${round(x)}, ${round(y)})`);
317
+ }
318
+ if (transforms.length) out.transform = transforms.join(' ');
319
+ const anchor = style.rotationAnchor ?? (r3 && typeof r3 === 'object' ? r3.anchor : undefined) ?? style.scaleAnchor;
320
+ if (anchor && transforms.length) {
321
+ out.transformOrigin = `${round(unit(anchor.x, 0.5) * 100)}% ${round(unit(anchor.y, 0.5) * 100)}%`;
322
+ }
323
+ const filters: string[] = [];
324
+ // Phase 8 `.blur(radius:)` first, so the shadow is cast by the blurred shape.
325
+ if (style.blur != null && Number.isFinite(style.blur) && style.blur > 0) {
326
+ filters.push(`blur(${round(style.blur)}px)`);
327
+ }
328
+ if (style.shadow) {
329
+ const { color, radius, x, y } = style.shadow;
330
+ const css = color ? colorToCSS(color) : 'rgba(0, 0, 0, 0.33)';
331
+ filters.push(`drop-shadow(${finite(x)}px ${finite(y)}px ${Math.max(0, finite(radius))}px ${css})`);
332
+ }
333
+ if (filters.length) out.filter = filters.join(' ');
334
+ // Phase 7.
335
+ if (style.hidden === true) out.hidden = true;
336
+ if (style.mask && typeof style.mask === 'object') out.maskImage = maskImageCSS(style.mask);
337
+ return out;
338
+ }
339
+
340
+ /**
341
+ * Phase 7 `.mask { }` with a ShapeStyle: a CSS `mask-image` whose alpha is
342
+ * the style's. A `linearGradient` / `radialGradient` becomes the matching CSS
343
+ * gradient (only its alpha matters to a mask, so the stop colors are kept as
344
+ * they are); `Color.gradient` the same vertical gradient the paint uses; a
345
+ * `Color` a solid mask at the color's alpha (`rgba(0, 0, 0, a)` twice, so a
346
+ * clear color hides the view and an opaque one shows it whole); a
347
+ * hierarchical style or material a solid mask at its `opacity` (1 by default).
348
+ */
349
+ export function maskImageCSS(style: ShapeStyle): string {
350
+ if (isLinearGradientStyle(style)) {
351
+ const { stops, start, end } = style.linearGradient;
352
+ return linearGradientCSS(stops ?? [], start, end, style.opacity);
353
+ }
354
+ if (isRadialGradientStyle(style)) {
355
+ const { stops, center, startRadius, endRadius } = style.radialGradient;
356
+ return radialGradientCSS(stops ?? [], center, startRadius, endRadius, style.opacity);
357
+ }
358
+ if (isColorGradientStyle(style)) return styleToCSS(style, 'paint').value;
359
+ const alpha = isColor(style) ? colorAlpha(style) : clamp01(style.opacity ?? 1);
360
+ const solid = `rgba(0, 0, 0, ${round(alpha)})`;
361
+ return `linear-gradient(${solid}, ${solid})`;
362
+ }
363
+
364
+ /** The alpha of a protocol `Color`: `a` (explicit and semantic), `opacity` (catalog); `clear` is 0. */
365
+ function colorAlpha(color: Color): number {
366
+ if (isAssetColor(color)) return clamp01(color.opacity ?? 1);
367
+ if (isNamedColor(color)) return color.name === 'clear' ? 0 : clamp01(color.a ?? 1);
368
+ return clamp01(color.a ?? 1);
369
+ }
370
+
371
+ function finite(v: number | undefined): number {
372
+ return v != null && Number.isFinite(v) ? v : 0;
373
+ }
package/src/swipe.ts ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Phase 8 swipe-action geometry (docs/ops-protocol.md, "Swipe actions"),
3
+ * DOM-free so it can be unit-tested: how far a row's content may slide, and
4
+ * where it settles when the pointer lifts.
5
+ *
6
+ * Offsets are the content's horizontal translation in points: negative
7
+ * reveals the trailing actions (on the right), positive the leading ones.
8
+ */
9
+
10
+ /** Width of one swipe action button. */
11
+ export const SWIPE_ACTION_WIDTH = 74;
12
+ /** Pulling this far past the actions' total width performs the first action. */
13
+ export const SWIPE_FULL_EXTRA = 60;
14
+ /** The content must travel this far before a pan counts as a swipe. */
15
+ export const SWIPE_START_DISTANCE = 8;
16
+ /** A release faster than this (points per second) opens or closes in the direction of travel. */
17
+ export const SWIPE_FLING_VELOCITY = 300;
18
+
19
+ export type SwipeEdge = 'leading' | 'trailing';
20
+
21
+ export interface SwipeEdgeSpec {
22
+ /** Number of actions on this edge (0: nothing to reveal). */
23
+ count: number;
24
+ /** Whether pulling past the actions' width + 60 performs the first one. */
25
+ fullSwipe: boolean;
26
+ }
27
+
28
+ export interface SwipeSpec {
29
+ trailing: SwipeEdgeSpec;
30
+ leading: SwipeEdgeSpec;
31
+ }
32
+
33
+ /** Total width of an edge's action buttons. */
34
+ export function swipeActionsWidth(count: number): number {
35
+ return Math.max(0, count) * SWIPE_ACTION_WIDTH;
36
+ }
37
+
38
+ /** The edge a content offset reveals (`null` at rest). */
39
+ export function swipeEdgeOf(offset: number): SwipeEdge | null {
40
+ if (offset < 0) return 'trailing';
41
+ if (offset > 0) return 'leading';
42
+ return null;
43
+ }
44
+
45
+ /**
46
+ * Clamp a raw pan offset to what the row can show: nothing toward an edge
47
+ * without actions; otherwise up to the actions' width, then with rubber-band
48
+ * resistance (a full swipe is still reachable) up to the row's width.
49
+ */
50
+ export function clampSwipeOffset(raw: number, spec: SwipeSpec, rowWidth: number): number {
51
+ const edge = swipeEdgeOf(raw);
52
+ if (!edge) return 0;
53
+ const count = spec[edge].count;
54
+ if (count === 0) return 0;
55
+ const sign = edge === 'trailing' ? -1 : 1;
56
+ const distance = Math.abs(raw);
57
+ const total = swipeActionsWidth(count);
58
+ if (distance <= total) return sign * distance;
59
+ // Past the buttons the content keeps following, slowed down, up to the row width.
60
+ const extra = distance - total;
61
+ const slowed = spec[edge].fullSwipe ? extra * 0.85 : extra * 0.35;
62
+ return sign * Math.min(rowWidth, total + slowed);
63
+ }
64
+
65
+ export type SwipeSettle = 'closed' | 'open' | 'full';
66
+
67
+ /**
68
+ * Where the content goes when the pointer lifts at `offset` with `velocity`
69
+ * (points per second, same sign convention): `full` performs the edge's
70
+ * first action, `open` parks at the actions' width, `closed` returns to 0.
71
+ */
72
+ export function settleSwipe(offset: number, velocity: number, spec: SwipeSpec): SwipeSettle {
73
+ const edge = swipeEdgeOf(offset);
74
+ if (!edge) return 'closed';
75
+ const { count, fullSwipe } = spec[edge];
76
+ if (count === 0) return 'closed';
77
+ const total = swipeActionsWidth(count);
78
+ const distance = Math.abs(offset);
79
+ const sign = edge === 'trailing' ? -1 : 1;
80
+ // Velocity in the revealing direction is positive.
81
+ const along = velocity * sign;
82
+ if (fullSwipe && distance > total + SWIPE_FULL_EXTRA) return 'full';
83
+ if (along > SWIPE_FLING_VELOCITY) return 'open';
84
+ if (along < -SWIPE_FLING_VELOCITY) return 'closed';
85
+ return distance > total / 2 ? 'open' : 'closed';
86
+ }
87
+
88
+ /** The resting offset of a settled row. */
89
+ export function swipeRestOffset(settle: SwipeSettle, edge: SwipeEdge, spec: SwipeSpec): number {
90
+ if (settle === 'closed') return 0;
91
+ const sign = edge === 'trailing' ? -1 : 1;
92
+ return sign * swipeActionsWidth(spec[edge].count);
93
+ }
94
+
95
+ /**
96
+ * Velocity estimate from pointer samples: the displacement over the samples
97
+ * of the last `windowMs` (at least the last two), in points per second.
98
+ */
99
+ export function velocityFrom(samples: readonly { t: number; x: number; y: number }[], windowMs = 100): { x: number; y: number } {
100
+ if (samples.length < 2) return { x: 0, y: 0 };
101
+ const last = samples[samples.length - 1]!;
102
+ let first = samples[samples.length - 2]!;
103
+ for (let i = samples.length - 2; i >= 0; i--) {
104
+ const s = samples[i]!;
105
+ if (last.t - s.t > windowMs) break;
106
+ first = s;
107
+ }
108
+ const dt = Math.max(1, last.t - first.t);
109
+ return { x: ((last.x - first.x) / dt) * 1000, y: ((last.y - first.y) / dt) * 1000 };
110
+ }