capture-flow 0.0.0-stage → 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 (104) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +238 -2
  3. package/bin/capture-flow.mjs +12 -0
  4. package/package.json +55 -4
  5. package/src/build.ts +186 -0
  6. package/src/capture.ts +77 -0
  7. package/src/cli.ts +139 -0
  8. package/src/index.ts +11 -0
  9. package/src/manifest.ts +98 -0
  10. package/src/render.ts +75 -0
  11. package/src/verify.ts +90 -0
  12. package/vendor/wajha/LICENSE +21 -0
  13. package/vendor/wajha/NOTICE +21 -0
  14. package/vendor/wajha/engine/background.ts +55 -0
  15. package/vendor/wajha/engine/bidi.ts +589 -0
  16. package/vendor/wajha/engine/cache.ts +96 -0
  17. package/vendor/wajha/engine/color.ts +125 -0
  18. package/vendor/wajha/engine/devices/android.ts +53 -0
  19. package/vendor/wajha/engine/devices/browser.ts +170 -0
  20. package/vendor/wajha/engine/devices/finish.ts +154 -0
  21. package/vendor/wajha/engine/devices/geometry.ts +326 -0
  22. package/vendor/wajha/engine/devices/index.ts +143 -0
  23. package/vendor/wajha/engine/devices/ipad.ts +21 -0
  24. package/vendor/wajha/engine/devices/iphone.ts +55 -0
  25. package/vendor/wajha/engine/devices/macbook.ts +187 -0
  26. package/vendor/wajha/engine/devices/mock/apps/calendar.ts +145 -0
  27. package/vendor/wajha/engine/devices/mock/apps/chat.ts +213 -0
  28. package/vendor/wajha/engine/devices/mock/apps/finance.ts +164 -0
  29. package/vendor/wajha/engine/devices/mock/apps/fitness.ts +145 -0
  30. package/vendor/wajha/engine/devices/mock/apps/index.ts +14 -0
  31. package/vendor/wajha/engine/devices/mock/apps/meditation.ts +167 -0
  32. package/vendor/wajha/engine/devices/mock/apps/music.ts +152 -0
  33. package/vendor/wajha/engine/devices/mock/apps/shop.ts +116 -0
  34. package/vendor/wajha/engine/devices/mock/apps/social.ts +152 -0
  35. package/vendor/wajha/engine/devices/mock/apps/tasks.ts +131 -0
  36. package/vendor/wajha/engine/devices/mock/apps/travel.ts +121 -0
  37. package/vendor/wajha/engine/devices/mock/art.ts +557 -0
  38. package/vendor/wajha/engine/devices/mock/chrome.ts +337 -0
  39. package/vendor/wajha/engine/devices/mock/icons.ts +614 -0
  40. package/vendor/wajha/engine/devices/mock/kit.ts +213 -0
  41. package/vendor/wajha/engine/devices/mock/rng.ts +40 -0
  42. package/vendor/wajha/engine/devices/mock/theme.ts +133 -0
  43. package/vendor/wajha/engine/devices/mock/widgets.ts +231 -0
  44. package/vendor/wajha/engine/devices/paths.ts +113 -0
  45. package/vendor/wajha/engine/devices/screen.ts +75 -0
  46. package/vendor/wajha/engine/devices/shadow.ts +124 -0
  47. package/vendor/wajha/engine/devices/slab.ts +200 -0
  48. package/vendor/wajha/engine/devices/vision.ts +43 -0
  49. package/vendor/wajha/engine/devices/watch.ts +14 -0
  50. package/vendor/wajha/engine/geometry.ts +152 -0
  51. package/vendor/wajha/engine/images.ts +96 -0
  52. package/vendor/wajha/engine/index.ts +20 -0
  53. package/vendor/wajha/engine/linebreak.ts +226 -0
  54. package/vendor/wajha/engine/markup.ts +84 -0
  55. package/vendor/wajha/engine/mock.ts +158 -0
  56. package/vendor/wajha/engine/paint.ts +474 -0
  57. package/vendor/wajha/engine/patterns.ts +263 -0
  58. package/vendor/wajha/engine/render.ts +364 -0
  59. package/vendor/wajha/engine/shapes.ts +260 -0
  60. package/vendor/wajha/engine/testing/canvasNode.ts +121 -0
  61. package/vendor/wajha/engine/testing/fakeDevices.ts +85 -0
  62. package/vendor/wajha/engine/text.ts +694 -0
  63. package/vendor/wajha/engine/types.ts +190 -0
  64. package/vendor/wajha/export/paths.ts +197 -0
  65. package/vendor/wajha/export/png.ts +223 -0
  66. package/vendor/wajha/export/types.ts +60 -0
  67. package/vendor/wajha/model/adapt.ts +889 -0
  68. package/vendor/wajha/model/catalog.ts +369 -0
  69. package/vendor/wajha/model/clone.ts +58 -0
  70. package/vendor/wajha/model/colorMath.ts +128 -0
  71. package/vendor/wajha/model/defaults.ts +386 -0
  72. package/vendor/wajha/model/geometry.ts +95 -0
  73. package/vendor/wajha/model/ids.ts +13 -0
  74. package/vendor/wajha/model/instantiate.ts +280 -0
  75. package/vendor/wajha/model/mirror.ts +43 -0
  76. package/vendor/wajha/model/resolve.ts +87 -0
  77. package/vendor/wajha/model/rules.ts +249 -0
  78. package/vendor/wajha/model/stub.ts +4 -0
  79. package/vendor/wajha/model/types.ts +456 -0
  80. package/vendor/wajha/model/typography.ts +28 -0
  81. package/vendor/wajha/model/validate.ts +917 -0
  82. package/vendor/wajha/templates/builtin/aurora.ts +163 -0
  83. package/vendor/wajha/templates/builtin/bubblegum.ts +132 -0
  84. package/vendor/wajha/templates/builtin/citrusPop.ts +124 -0
  85. package/vendor/wajha/templates/builtin/clayGarden.ts +114 -0
  86. package/vendor/wajha/templates/builtin/desertDune.ts +97 -0
  87. package/vendor/wajha/templates/builtin/glassPrism.ts +139 -0
  88. package/vendor/wajha/templates/builtin/launchpad.ts +140 -0
  89. package/vendor/wajha/templates/builtin/loudAndClear.ts +109 -0
  90. package/vendor/wajha/templates/builtin/midnightNeon.ts +108 -0
  91. package/vendor/wajha/templates/builtin/monoSwiss.ts +149 -0
  92. package/vendor/wajha/templates/builtin/noirGold.ts +149 -0
  93. package/vendor/wajha/templates/builtin/noor.ts +107 -0
  94. package/vendor/wajha/templates/builtin/obsidian.ts +137 -0
  95. package/vendor/wajha/templates/builtin/panoramaPeaks.ts +145 -0
  96. package/vendor/wajha/templates/builtin/panoramaRibbon.ts +138 -0
  97. package/vendor/wajha/templates/builtin/panoramaWaves.ts +138 -0
  98. package/vendor/wajha/templates/builtin/paperAndInk.ts +98 -0
  99. package/vendor/wajha/templates/builtin/sage.ts +94 -0
  100. package/vendor/wajha/templates/builtin/splitTone.ts +129 -0
  101. package/vendor/wajha/templates/builtin/sunsetBoulevard.ts +127 -0
  102. package/vendor/wajha/templates/index.ts +55 -0
  103. package/vendor/wajha/templates/kit.ts +761 -0
  104. package/vendor/wajha/ui/sortShots.ts +239 -0
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Path builders shared by the device frames and the mock screens.
3
+ *
4
+ * Everything is hand-rolled on moveTo/lineTo/arc/bezierCurveTo because
5
+ * `roundRect` is missing on the older WebKit builds we support, and because
6
+ * Apple hardware needs continuous ("squircle") corners that no canvas API
7
+ * offers. Builders only add to the current path; callers begin, fill, stroke
8
+ * or clip.
9
+ */
10
+ import type { Ctx2D } from '../types';
11
+
12
+ /** Corner radii clockwise from the top-left corner. */
13
+ export type Radii = readonly [number, number, number, number];
14
+
15
+ const HALF_PI = Math.PI / 2;
16
+
17
+ const finitePos = (v: number) => (Number.isFinite(v) && v > 0 ? v : 0);
18
+
19
+ /**
20
+ * Adds a rectangle with circular corners. Radii are clamped to half of the
21
+ * shorter side, so a huge radius gives a pill or a circle, never a bow tie.
22
+ */
23
+ export function roundRectPath(ctx: Ctx2D, x: number, y: number, w: number, h: number, r: number | Radii): void {
24
+ if (!(w > 0) || !(h > 0) || !Number.isFinite(x + y + w + h)) return;
25
+ const max = Math.min(w, h) / 2;
26
+ const [a, b, c, d] = typeof r === 'number' ? [r, r, r, r] : r;
27
+ const tl = Math.min(finitePos(a), max);
28
+ const tr = Math.min(finitePos(b), max);
29
+ const br = Math.min(finitePos(c), max);
30
+ const bl = Math.min(finitePos(d), max);
31
+ ctx.moveTo(x + tl, y);
32
+ ctx.lineTo(x + w - tr, y);
33
+ if (tr > 0) ctx.arc(x + w - tr, y + tr, tr, -HALF_PI, 0);
34
+ ctx.lineTo(x + w, y + h - br);
35
+ if (br > 0) ctx.arc(x + w - br, y + h - br, br, 0, HALF_PI);
36
+ ctx.lineTo(x + bl, y + h);
37
+ if (bl > 0) ctx.arc(x + bl, y + h - bl, bl, HALF_PI, Math.PI);
38
+ ctx.lineTo(x, y + tl);
39
+ if (tl > 0) ctx.arc(x + tl, y + tl, tl, Math.PI, Math.PI + HALF_PI);
40
+ ctx.closePath();
41
+ }
42
+
43
+ /** Corner frames for a clockwise walk: corner point, incoming edge direction, outgoing edge direction. */
44
+ type Corner = { ox: number; oy: number; ux: number; uy: number; vx: number; vy: number };
45
+
46
+ /**
47
+ * Adds a rectangle with continuous-curvature corners (the iOS look), using
48
+ * Figma's corner-smoothing construction: each corner starts `(1 + smoothing)
49
+ * * radius` before the corner point, eases in with a cubic, follows a short
50
+ * circular arc and eases out with a mirrored cubic. `smoothing` 0 is a plain
51
+ * circular corner; 0.6 matches iOS app icons. Smoothing is reduced when the
52
+ * longer corner would not fit, which keeps small shapes well-formed.
53
+ */
54
+ export function smoothRectPath(ctx: Ctx2D, x: number, y: number, w: number, h: number, radius: number, smoothing: number): void {
55
+ if (!(w > 0) || !(h > 0) || !Number.isFinite(x + y + w + h)) return;
56
+ const budget = Math.min(w, h) / 2;
57
+ const R = Math.min(finitePos(radius), budget);
58
+ let xi = Number.isFinite(smoothing) ? Math.min(Math.max(smoothing, 0), 1) : 0;
59
+ if (R > 0) xi = Math.min(xi, budget / R - 1);
60
+ if (R <= 0 || xi < 0.01) {
61
+ roundRectPath(ctx, x, y, w, h, R);
62
+ return;
63
+ }
64
+ const p = Math.min((1 + xi) * R, budget);
65
+ const arcMeasure = HALF_PI * (1 - xi);
66
+ const L = Math.sin(arcMeasure / 2) * R * Math.SQRT2;
67
+ const alpha = (HALF_PI - arcMeasure) / 2;
68
+ const beta = (Math.PI / 4) * xi;
69
+ const c = R * Math.tan(alpha / 2) * Math.cos(beta);
70
+ const d = c * Math.tan(beta);
71
+ const b = (p - L - c - d) / 3;
72
+ const a = 2 * b;
73
+
74
+ const corners: Corner[] = [
75
+ { ox: x + w, oy: y, ux: 1, uy: 0, vx: 0, vy: 1 },
76
+ { ox: x + w, oy: y + h, ux: 0, uy: 1, vx: -1, vy: 0 },
77
+ { ox: x, oy: y + h, ux: -1, uy: 0, vx: 0, vy: -1 },
78
+ { ox: x, oy: y, ux: 0, uy: -1, vx: 1, vy: 0 },
79
+ ];
80
+ // Point at (along u, along v) in a corner's frame.
81
+ const at = (k: Corner, su: number, sv: number): [number, number] => [k.ox + k.ux * su + k.vx * sv, k.oy + k.uy * su + k.vy * sv];
82
+
83
+ ctx.moveTo(x + p, y);
84
+ for (const k of corners) {
85
+ const [x0, y0] = at(k, -p, 0);
86
+ ctx.lineTo(x0, y0);
87
+ const [c1x, c1y] = at(k, -p + a, 0);
88
+ const [c2x, c2y] = at(k, -p + a + b, 0);
89
+ const [x1, y1] = at(k, -p + a + b + c, d);
90
+ ctx.bezierCurveTo(c1x, c1y, c2x, c2y, x1, y1);
91
+ const [cx, cy] = at(k, -R, R);
92
+ const [x2, y2] = at(k, -p + a + b + c + L, d + L);
93
+ const t1 = Math.atan2(y1 - cy, x1 - cx);
94
+ let t2 = Math.atan2(y2 - cy, x2 - cx);
95
+ if (t2 < t1) t2 += Math.PI * 2;
96
+ ctx.arc(cx, cy, R, t1, t2);
97
+ const [c3x, c3y] = at(k, -p + a + b + c + L + d, d + L + c);
98
+ const [c4x, c4y] = at(k, -p + a + b + c + L + d, d + L + b + c);
99
+ const [x3, y3] = at(k, 0, p);
100
+ ctx.bezierCurveTo(c3x, c3y, c4x, c4y, x3, y3);
101
+ }
102
+ ctx.closePath();
103
+ }
104
+
105
+ /** Adds a rounded rectangle inset by `inset` on every side, shrinking the radius to stay concentric. */
106
+ export function insetSmoothRect(ctx: Ctx2D, w: number, h: number, radius: number, smoothing: number, inset: number): void {
107
+ smoothRectPath(ctx, inset, inset, w - 2 * inset, h - 2 * inset, Math.max(0, radius - inset), smoothing);
108
+ }
109
+
110
+ /** Adds a horizontal pill (fully rounded ends). */
111
+ export function pillPath(ctx: Ctx2D, x: number, y: number, w: number, h: number): void {
112
+ roundRectPath(ctx, x, y, w, h, Math.min(w, h) / 2);
113
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * What appears on a device screen: the user's screenshot (cover-fit), a
3
+ * generated mock app, or a calm empty gradient. Callers provide the clip
4
+ * path so every frame keeps its own corner shape.
5
+ */
6
+ import type { Color, FamilyId } from '../../model/types';
7
+ import type { Ctx2D, ImageLike } from '../types';
8
+ import { paintMockScreen } from '../mock';
9
+
10
+ export interface ScreenContent {
11
+ image: ImageLike | null;
12
+ placeholder: 'mock' | 'empty';
13
+ mock: { seed: number; palette: Color[]; dark: boolean };
14
+ /** Mock screen flavour for this frame. */
15
+ family?: FamilyId;
16
+ chrome: 'device' | 'web';
17
+ /** Paint black under the screenshot, like a real panel (devices), or not (bare screenshots). */
18
+ backdrop: boolean;
19
+ }
20
+
21
+ export interface Rect {
22
+ x: number;
23
+ y: number;
24
+ w: number;
25
+ h: number;
26
+ }
27
+
28
+ /** True when an image can actually be drawn. */
29
+ export function isDrawable(img: ImageLike | null): img is ImageLike {
30
+ return !!img && Number.isFinite(img.width) && Number.isFinite(img.height) && img.width > 0 && img.height > 0;
31
+ }
32
+
33
+ /** Source rectangle that covers a `w` x `h` box with an `iw` x `ih` image, centred. */
34
+ export function coverSource(iw: number, ih: number, w: number, h: number): Rect {
35
+ const scale = Math.max(w / iw, h / ih);
36
+ const sw = Math.min(iw, w / scale);
37
+ const sh = Math.min(ih, h / scale);
38
+ return { x: (iw - sw) / 2, y: (ih - sh) / 2, w: sw, h: sh };
39
+ }
40
+
41
+ /** Neutral placeholder: one calm vertical gradient. */
42
+ export function paintEmptyScreen(ctx: Ctx2D, r: Rect, dark: boolean): void {
43
+ const g = ctx.createLinearGradient(0, r.y, 0, r.y + r.h);
44
+ g.addColorStop(0, dark ? '#202026' : '#f8f8fa');
45
+ g.addColorStop(0.55, dark ? '#18181c' : '#eeeef2');
46
+ g.addColorStop(1, dark ? '#101013' : '#e3e3e9');
47
+ ctx.fillStyle = g;
48
+ ctx.fillRect(r.x, r.y, r.w, r.h);
49
+ }
50
+
51
+ /** Fills the screen rect `r`, clipped by the path `clip` adds. Restores all state. */
52
+ export function paintScreen(ctx: Ctx2D, r: Rect, clip: (ctx: Ctx2D) => void, content: ScreenContent): void {
53
+ if (!(r.w > 0) || !(r.h > 0)) return;
54
+ ctx.save();
55
+ ctx.beginPath();
56
+ clip(ctx);
57
+ ctx.clip();
58
+ const img = content.image;
59
+ if (isDrawable(img)) {
60
+ if (content.backdrop) {
61
+ ctx.fillStyle = '#000';
62
+ ctx.fillRect(r.x, r.y, r.w, r.h);
63
+ }
64
+ const src = coverSource(img.width, img.height, r.w, r.h);
65
+ ctx.imageSmoothingEnabled = true;
66
+ if ('imageSmoothingQuality' in ctx) ctx.imageSmoothingQuality = 'high';
67
+ ctx.drawImage(img, src.x, src.y, src.w, src.h, r.x, r.y, r.w, r.h);
68
+ } else if (content.placeholder === 'mock') {
69
+ ctx.translate(r.x, r.y);
70
+ paintMockScreen(ctx, r.w, r.h, { ...content.mock, family: content.family, chrome: content.chrome });
71
+ } else {
72
+ paintEmptyScreen(ctx, r, content.mock.dark);
73
+ }
74
+ ctx.restore();
75
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Soft drop shadows shaped like a device body, without ctx.filter.
3
+ *
4
+ * Canvas shadows ignore the current transform: shadowBlur and shadowOffset
5
+ * are in canvas pixels. That is what we want for the light direction (a
6
+ * tilted phone still casts its shadow straight down), but it means lengths
7
+ * must be scaled by the transform's zoom (DPR, editor zoom) by hand.
8
+ */
9
+ import type { Shadow } from '../../model/types';
10
+ import type { Ctx2D } from '../types';
11
+ import type { RGBA } from '../color';
12
+ import { parseColor, rgba } from '../color';
13
+
14
+ interface Affine {
15
+ a: number;
16
+ b: number;
17
+ c: number;
18
+ d: number;
19
+ e: number;
20
+ f: number;
21
+ }
22
+
23
+ /** The current transform, or null on engines without getTransform. */
24
+ export function currentMatrix(ctx: Ctx2D): Affine | null {
25
+ if (typeof ctx.getTransform !== 'function') return null;
26
+ const m = ctx.getTransform();
27
+ const vals = [m.a, m.b, m.c, m.d, m.e, m.f];
28
+ return vals.every(Number.isFinite) ? { a: m.a, b: m.b, c: m.c, d: m.d, e: m.e, f: m.f } : null;
29
+ }
30
+
31
+ /** Canvas pixels per local unit (geometric mean of the axis scales), 1 when unknown. */
32
+ export function currentScale(ctx: Ctx2D): number {
33
+ const m = currentMatrix(ctx);
34
+ if (!m) return 1;
35
+ const s = Math.sqrt(Math.abs(m.a * m.d - m.b * m.c));
36
+ return Number.isFinite(s) && s > 0 ? s : 1;
37
+ }
38
+
39
+ const fin = (v: number) => (Number.isFinite(v) ? v : 0);
40
+
41
+ /**
42
+ * Contact shading: a few soft strokes along the outline, nudged down,
43
+ * standing in for a tight second shadow that grounds the device like real
44
+ * product shots. Canvas blurs are full-area convolutions and a second one
45
+ * doubled the cost of a device; strokes cost a fraction and read the same.
46
+ * `c` is the falloff in canvas pixels; the inner half of each stroke is
47
+ * hidden under the opaque body painted afterwards.
48
+ */
49
+ function contactEdge(ctx: Ctx2D, color: RGBA, c: number, offsetY: number, m: Affine, scale: number, addPath: (ctx: Ctx2D) => void): void {
50
+ if (!(c >= 0.75)) return;
51
+ const det = m.a * m.d - m.b * m.c;
52
+ if (Math.abs(det) < 1e-12) return;
53
+ ctx.save();
54
+ // A downward canvas offset expressed in local units, so it survives rotation.
55
+ ctx.translate((-m.c * offsetY) / det, (m.a * offsetY) / det);
56
+ ctx.lineJoin = 'round';
57
+ for (const [k, a] of CONTACT_STEPS) {
58
+ ctx.lineWidth = (2 * c * k) / scale;
59
+ ctx.strokeStyle = rgba(color, a);
60
+ ctx.beginPath();
61
+ addPath(ctx);
62
+ ctx.stroke();
63
+ }
64
+ ctx.restore();
65
+ }
66
+
67
+ /** [half-width as a fraction of the falloff, alpha]; overlapping strokes build a smooth ramp. */
68
+ const CONTACT_STEPS: ReadonlyArray<readonly [number, number]> = [
69
+ [1, 0.03],
70
+ [0.62, 0.04],
71
+ [0.36, 0.055],
72
+ [0.16, 0.07],
73
+ ];
74
+
75
+ /**
76
+ * Paints only the shadow of the shape that `addPath` describes (local
77
+ * coordinates, box `w` x `h` at the current origin): the designer's shadow
78
+ * as specified, plus optional contact shading (`contact`) along the outline.
79
+ */
80
+ export function drawShadow(ctx: Ctx2D, shadow: Shadow, unit: number, w: number, h: number, addPath: (ctx: Ctx2D) => void, contact = true): void {
81
+ const color = parseColor(shadow.color);
82
+ if (!(color.a > 0)) return;
83
+ const m = currentMatrix(ctx);
84
+ const det = m ? m.a * m.d - m.b * m.c : 1;
85
+ const scale = Math.sqrt(Math.abs(det)) || 1;
86
+ const u = Math.max(0, fin(unit)) * scale;
87
+ const blur = Math.max(0, fin(shadow.blur)) * u;
88
+ const x = fin(shadow.x) * u;
89
+ const y = fin(shadow.y) * u;
90
+ if (blur === 0 && x === 0 && y === 0) return;
91
+
92
+ ctx.save();
93
+ ctx.shadowColor = rgba(color);
94
+ ctx.shadowBlur = blur;
95
+ if (m && Math.abs(det) > 1e-12) {
96
+ // Draw the casting shape far to the left of the canvas and push its
97
+ // shadow back by the same distance: only the shadow lands on screen,
98
+ // so translucent screens or antialiased edges never show a dark copy.
99
+ const cx = m.a * (w / 2) + m.c * (h / 2) + m.e;
100
+ const extent = (w + h) * scale;
101
+ const canvasW = ctx.canvas && Number.isFinite(ctx.canvas.width) ? ctx.canvas.width : 0;
102
+ const far = Math.abs(cx) + extent + canvasW + blur * 3 + 256;
103
+ ctx.translate((-far * m.d) / det, (far * m.b) / det);
104
+ ctx.shadowOffsetX = far + x;
105
+ ctx.shadowOffsetY = y;
106
+ } else {
107
+ // No transform access: cast from the shape itself but clip the shape
108
+ // away, which is the CSS box-shadow look (shadow only outside the body).
109
+ const pad = blur * 4 + 1e4;
110
+ ctx.beginPath();
111
+ ctx.rect(-w - pad, -h - pad, 3 * w + 2 * pad, 3 * h + 2 * pad);
112
+ addPath(ctx);
113
+ ctx.clip('evenodd');
114
+ ctx.shadowOffsetX = x;
115
+ ctx.shadowOffsetY = y;
116
+ }
117
+ ctx.fillStyle = '#000';
118
+ ctx.beginPath();
119
+ addPath(ctx);
120
+ ctx.fill();
121
+ ctx.restore();
122
+
123
+ if (contact && blur > 0 && m) contactEdge(ctx, color, Math.min(blur * 0.2, 24 * scale), Math.max(0, y) * 0.12, m, scale, addPath);
124
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Shared painter for "slab" devices (phones and tablets): metal band,
3
+ * uniform bezel, screen, then per-device hardware. Frames plug their
4
+ * cameras and buttons in through `SlabHardware`.
5
+ */
6
+ import type { Ctx2D } from '../types';
7
+ import type { SlabLayout } from './geometry';
8
+ import type { FramePaint } from './finish';
9
+ import { sampleStops } from './finish';
10
+ import { insetSmoothRect, roundRectPath, smoothRectPath } from './paths';
11
+ import type { Rect, ScreenContent } from './screen';
12
+ import { paintScreen } from './screen';
13
+
14
+ export interface SlabHardware {
15
+ /** Side buttons, painted before the body so the body hides where they attach. */
16
+ buttons?(ctx: Ctx2D, L: SlabLayout, paint: FramePaint): void;
17
+ /** Cameras sitting in the bezel, painted before the screen. */
18
+ bezel?(ctx: Ctx2D, L: SlabLayout, px: number): void;
19
+ /** Islands and punch holes, painted over the screen. */
20
+ overlays?(ctx: Ctx2D, L: SlabLayout, px: number): void;
21
+ }
22
+
23
+ /** Fills the ring between two insets of a shape (even-odd), touching only the ring's pixels. */
24
+ export function fillRing(ctx: Ctx2D, pathAt: (inset: number) => void, outer: number, inner: number, fill: string | CanvasGradient): void {
25
+ ctx.beginPath();
26
+ pathAt(outer);
27
+ pathAt(inner);
28
+ ctx.fillStyle = fill;
29
+ ctx.fill('evenodd');
30
+ }
31
+
32
+ /**
33
+ * Paints a band from the outer edge of a shape to `band` inside it.
34
+ * `pathAt(inset)` must add the shape inset by `inset`. The profile is
35
+ * built from thin concentric strokes over a base fill, which gives a true
36
+ * cross-band gradient (bright glint, darker edges) that linear gradients
37
+ * cannot, and the base fill hides seams between strokes. Fills are rings,
38
+ * not whole shapes: the inside is covered later anyway and full-size fills
39
+ * dominated the raster cost.
40
+ */
41
+ export function paintBand(ctx: Ctx2D, pathAt: (inset: number) => void, band: number, paint: FramePaint, px: number, box: Rect): void {
42
+ // Reaches one pixel under whatever is painted inside the band, so no seam shows.
43
+ fillRing(ctx, pathAt, 0, band + Math.min(1 / px, band), sampleStops(paint.band, 0.55));
44
+ if (band > 0) {
45
+ const n = Math.max(1, Math.min(24, Math.ceil((band * px) / 0.75)));
46
+ const step = band / n;
47
+ ctx.lineWidth = step;
48
+ for (let i = 0; i < n; i++) {
49
+ ctx.strokeStyle = sampleStops(paint.band, (i + 0.5) / n);
50
+ ctx.beginPath();
51
+ pathAt((i + 0.5) * step);
52
+ ctx.stroke();
53
+ }
54
+ }
55
+ if (paint.sheen && band > 0) {
56
+ const g = ctx.createLinearGradient(box.x, box.y, box.x + box.w, box.y + box.h);
57
+ for (const [offset, color] of paint.sheen) g.addColorStop(offset, color);
58
+ fillRing(ctx, pathAt, 0, band, g);
59
+ }
60
+ }
61
+
62
+ /** Soft shadow cast inwards along a screen's edge, so a body-coloured bezel reads as a raised frame. */
63
+ export function paintInnerShadow(ctx: Ctx2D, r: Rect, clip: (ctx: Ctx2D) => void, blur: number, offsetY: number, alpha: number): void {
64
+ ctx.save();
65
+ ctx.beginPath();
66
+ clip(ctx);
67
+ ctx.clip();
68
+ ctx.beginPath();
69
+ ctx.rect(r.x - r.w, r.y - r.h, r.w * 3, r.h * 3);
70
+ clip(ctx);
71
+ ctx.shadowColor = `rgba(0, 0, 0, ${alpha})`;
72
+ ctx.shadowBlur = blur;
73
+ ctx.shadowOffsetY = offsetY;
74
+ ctx.fillStyle = '#000';
75
+ ctx.fill('evenodd');
76
+ ctx.restore();
77
+ }
78
+
79
+ export type Edge = 'left' | 'right' | 'top';
80
+
81
+ /**
82
+ * A hardware button sticking out of a straight body edge. `edge` is the
83
+ * x (left/right) or y (top) of that edge, `from`/`to` the span along it.
84
+ */
85
+ export function paintButton(ctx: Ctx2D, side: Edge, edge: number, from: number, to: number, out: number, paint: FramePaint): void {
86
+ if (!(to > from) || !(out > 0)) return;
87
+ const root = out * 1.6;
88
+ const len = to - from;
89
+ let x: number;
90
+ let y: number;
91
+ let w: number;
92
+ let h: number;
93
+ let g: CanvasGradient;
94
+ if (side === 'top') {
95
+ x = from;
96
+ y = edge - out;
97
+ w = len;
98
+ h = out + root;
99
+ g = ctx.createLinearGradient(0, edge - out, 0, edge);
100
+ } else {
101
+ x = side === 'left' ? edge - out : edge - root;
102
+ y = from;
103
+ w = out + root;
104
+ h = len;
105
+ g = side === 'left' ? ctx.createLinearGradient(edge - out, 0, edge, 0) : ctx.createLinearGradient(edge + out, 0, edge, 0);
106
+ }
107
+ g.addColorStop(0, paint.buttonEdge);
108
+ g.addColorStop(0.45, paint.buttonFace);
109
+ g.addColorStop(1, paint.buttonFace);
110
+ ctx.fillStyle = g;
111
+ ctx.beginPath();
112
+ roundRectPath(ctx, x, y, w, h, out * 0.95);
113
+ ctx.fill();
114
+ }
115
+
116
+ /** A front camera lens: dark ring, deep blue glass, one tiny glint. */
117
+ export function paintLens(ctx: Ctx2D, cx: number, cy: number, r: number): void {
118
+ if (!(r > 0)) return;
119
+ ctx.beginPath();
120
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
121
+ ctx.fillStyle = '#16171c';
122
+ ctx.fill();
123
+ const g = ctx.createRadialGradient(cx - r * 0.2, cy - r * 0.25, 0, cx, cy, r * 0.72);
124
+ g.addColorStop(0, '#2a3350');
125
+ g.addColorStop(0.55, '#0d1120');
126
+ g.addColorStop(1, '#05060a');
127
+ ctx.beginPath();
128
+ ctx.arc(cx, cy, r * 0.68, 0, Math.PI * 2);
129
+ ctx.fillStyle = g;
130
+ ctx.fill();
131
+ ctx.beginPath();
132
+ ctx.arc(cx - r * 0.24, cy - r * 0.26, r * 0.16, 0, Math.PI * 2);
133
+ ctx.fillStyle = 'rgba(170, 190, 255, 0.35)';
134
+ ctx.fill();
135
+ }
136
+
137
+ /** Body, band, bezel, screen and hardware of a phone or tablet, at the current origin (device top-left). */
138
+ export function paintSlab(ctx: Ctx2D, L: SlabLayout, paint: FramePaint, px: number, content: ScreenContent, hw: SlabHardware): void {
139
+ if (L.upright) {
140
+ // Lying on its side: the upright device turned a quarter to the left,
141
+ // its screen content painted the right way up.
142
+ ctx.save();
143
+ ctx.translate(0, L.h);
144
+ ctx.rotate(-Math.PI / 2);
145
+ paintUpright(ctx, L.upright, paint, px, content, hw, true);
146
+ ctx.restore();
147
+ return;
148
+ }
149
+ paintUpright(ctx, L, paint, px, content, hw, false);
150
+ }
151
+
152
+ function paintUpright(ctx: Ctx2D, L: SlabLayout, paint: FramePaint, px: number, content: ScreenContent, hw: SlabHardware, turned: boolean): void {
153
+ const body = (inset: number) => insetSmoothRect(ctx, L.w, L.h, L.radius, L.smoothing, inset);
154
+ hw.buttons?.(ctx, L, paint);
155
+ paintBand(ctx, body, L.band, paint, px, { x: 0, y: 0, w: L.w, h: L.h });
156
+
157
+ // Bezel ring. Its hole follows the screen's own corners (tablets use a
158
+ // rounder screen than the body), tucked one pixel under the screen edge.
159
+ const s = L.screen;
160
+ const o = Math.min(1 / px, s.w / 4, s.h / 4);
161
+ ctx.beginPath();
162
+ body(L.band);
163
+ smoothRectPath(ctx, s.x + o, s.y + o, s.w - 2 * o, s.h - 2 * o, Math.max(0, s.radius - o), L.screenSmoothing);
164
+ ctx.fillStyle = paint.bezel;
165
+ ctx.fill('evenodd');
166
+ if (!paint.bezelMatchesBody) {
167
+ // Cover glass catches a hairline of light where it meets the frame.
168
+ const hair = Math.min(1 / px, (L.inset - L.band) * 0.25);
169
+ ctx.lineWidth = hair;
170
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.09)';
171
+ ctx.beginPath();
172
+ body(L.band + hair * 0.5);
173
+ ctx.stroke();
174
+ }
175
+ hw.bezel?.(ctx, L, px);
176
+
177
+ const screenPath = (c: Ctx2D) => smoothRectPath(c, s.x, s.y, s.w, s.h, s.radius, L.screenSmoothing);
178
+ if (turned) paintTurnedScreen(ctx, s, screenPath, content, L.w);
179
+ else paintScreen(ctx, s, screenPath, content);
180
+ if (paint.bezelMatchesBody) paintInnerShadow(ctx, s, screenPath, L.w * 0.014 * px, L.w * 0.003 * px, 0.2);
181
+ hw.overlays?.(ctx, L, px);
182
+ }
183
+
184
+ /**
185
+ * The screen of a device painted turned (see paintSlab): clipped to its
186
+ * shape in the device's own coordinates, then filled with the turn undone,
187
+ * so a landscape screenshot or mock reads the right way up. `uprightW` is
188
+ * the upright device's width, the turned device's height.
189
+ */
190
+ function paintTurnedScreen(ctx: Ctx2D, s: Rect, clip: (ctx: Ctx2D) => void, content: ScreenContent, uprightW: number): void {
191
+ ctx.save();
192
+ ctx.beginPath();
193
+ clip(ctx);
194
+ ctx.clip();
195
+ ctx.rotate(Math.PI / 2);
196
+ ctx.translate(0, -uprightW);
197
+ const r = { x: s.y, y: uprightW - s.x - s.w, w: s.h, h: s.w };
198
+ paintScreen(ctx, r, (c) => c.rect(r.x, r.y, r.w, r.h), content);
199
+ ctx.restore();
200
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Apple Vision Pro: an app window as visionOS shows it, a sheet of frosted
3
+ * glass floating in space with the grab bar under it. The glass ignores the
4
+ * frame colour: visionOS windows are always glass.
5
+ */
6
+ import type { Ctx2D } from '../types';
7
+ import type { VisionLayout } from './geometry';
8
+ import { pillPath, roundRectPath } from './paths';
9
+ import type { ScreenContent } from './screen';
10
+ import { paintScreen } from './screen';
11
+
12
+ export function visionBodyPath(ctx: Ctx2D, L: VisionLayout): void {
13
+ const g = L.window;
14
+ roundRectPath(ctx, g.x, g.y, g.w, g.h, g.radius);
15
+ }
16
+
17
+ export function paintVision(ctx: Ctx2D, L: VisionLayout, px: number, content: ScreenContent): void {
18
+ const g = L.window;
19
+ // Frosted glass: a light tint that brightens towards the top edge.
20
+ const tint = ctx.createLinearGradient(0, g.y, 0, g.y + g.h);
21
+ tint.addColorStop(0, 'rgba(255, 255, 255, 0.34)');
22
+ tint.addColorStop(1, 'rgba(255, 255, 255, 0.16)');
23
+ ctx.beginPath();
24
+ visionBodyPath(ctx, L);
25
+ ctx.fillStyle = tint;
26
+ ctx.fill();
27
+ // A bright hairline where the glass catches the light.
28
+ const hair = Math.max(1 / px, g.w * 0.0012);
29
+ ctx.lineWidth = hair;
30
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.55)';
31
+ ctx.beginPath();
32
+ roundRectPath(ctx, g.x + hair / 2, g.y + hair / 2, g.w - hair, g.h - hair, Math.max(0, g.radius - hair / 2));
33
+ ctx.stroke();
34
+
35
+ const s = L.screen;
36
+ paintScreen(ctx, s, (c) => roundRectPath(c, s.x, s.y, s.w, s.h, s.radius), content);
37
+
38
+ const b = L.bar;
39
+ ctx.beginPath();
40
+ pillPath(ctx, b.x, b.y, b.w, b.h);
41
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.62)';
42
+ ctx.fill();
43
+ }
@@ -0,0 +1,14 @@
1
+ /** Apple Watch: Digital Crown and side button on the right edge; TVs have no hardware to draw. */
2
+ import type { SlabHardware } from './slab';
3
+ import { paintButton } from './slab';
4
+
5
+ export const watchHardware: SlabHardware = {
6
+ buttons(ctx, L, paint) {
7
+ // Digital Crown: wide and proud of the case, a third of the way down.
8
+ paintButton(ctx, 'right', L.w, L.h * 0.22, L.h * 0.38, L.w * 0.055, paint);
9
+ // Side button below it, flatter and longer.
10
+ paintButton(ctx, 'right', L.w, L.h * 0.5, L.h * 0.68, L.w * 0.022, paint);
11
+ },
12
+ };
13
+
14
+ export const tvHardware: SlabHardware = {};