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,326 @@
1
+ /**
2
+ * Pure layout of every device frame: body box, screen rect and the extra
3
+ * measurements the painters need. All proportions are fractions of the
4
+ * device width so a frame looks identical at 40 px and at 4 000 px.
5
+ *
6
+ * Kept free of drawing code because the renderer calls `deviceGeometry`
7
+ * for hit-testing and layout on every pointer move.
8
+ */
9
+ import type { DeviceFrameId } from '../../model/types';
10
+ import type { DeviceGeometry, ScreenRect } from '../types';
11
+
12
+ export const MIN_ASPECT = 0.3;
13
+ export const MAX_ASPECT = 4;
14
+ export const MIN_WIDTH = 1;
15
+ export const MAX_WIDTH = 100_000;
16
+
17
+ /** Screen aspect (height / width) used when the caller passes something unusable. */
18
+ export const DEFAULT_ASPECT: Record<DeviceFrameId, number> = {
19
+ iphone: 2868 / 1320,
20
+ ipad: 2752 / 2064,
21
+ 'android-phone': 2400 / 1080,
22
+ 'android-tablet': 2560 / 1600,
23
+ macbook: 1800 / 2880,
24
+ watch: 514 / 422,
25
+ tv: 2160 / 3840,
26
+ vision: 2160 / 3840,
27
+ browser: 1800 / 2880,
28
+ none: 2868 / 1320,
29
+ };
30
+
31
+ /** Corner radius of frame "none" when no element radius is known, fraction of width. */
32
+ export const NONE_DEFAULT_RADIUS = 0.1;
33
+
34
+ const FRAMES: readonly DeviceFrameId[] = ['iphone', 'ipad', 'android-phone', 'android-tablet', 'macbook', 'watch', 'tv', 'vision', 'browser', 'none'];
35
+
36
+ /** Unknown ids (old or hand-edited files) degrade to the bare screenshot. */
37
+ export function knownFrame(frame: string): DeviceFrameId {
38
+ return (FRAMES as readonly string[]).includes(frame) ? (frame as DeviceFrameId) : 'none';
39
+ }
40
+
41
+ export function sanitizeWidth(widthPx: number): number {
42
+ if (Number.isNaN(widthPx)) return MIN_WIDTH;
43
+ return Math.min(Math.max(widthPx, MIN_WIDTH), MAX_WIDTH);
44
+ }
45
+
46
+ export function sanitizeAspect(frame: DeviceFrameId, aspect: number): number {
47
+ const a = Number.isFinite(aspect) && aspect > 0 ? aspect : DEFAULT_ASPECT[frame];
48
+ return Math.min(Math.max(a, MIN_ASPECT), MAX_ASPECT);
49
+ }
50
+
51
+ const clampRadius = (r: number, w: number, h: number) => Math.max(0, Math.min(r, w / 2, h / 2));
52
+
53
+ /* ------------------------------------------------------------------ */
54
+ /* Slabs: phones and tablets */
55
+ /* ------------------------------------------------------------------ */
56
+
57
+ export type SlabFrame = 'iphone' | 'ipad' | 'android-phone' | 'android-tablet' | 'watch' | 'tv';
58
+
59
+ /** Slabs that can lie on their side (a screen aspect under 1 turns them). */
60
+ export const TURNABLE: ReadonlySet<SlabFrame> = new Set(['iphone', 'ipad', 'android-phone', 'android-tablet']);
61
+
62
+ export interface SlabSpec {
63
+ /** Outer corner radius, fraction of width. */
64
+ radius: number;
65
+ /** Corner smoothing (0 circular .. 1 very continuous). */
66
+ smoothing: number;
67
+ /** Visible metal band, fraction of width. */
68
+ band: number;
69
+ /** Band + bezel: distance from the outer edge to the screen, fraction of width. */
70
+ inset: number;
71
+ /**
72
+ * Screen radius as a fraction of width. Omitted = concentric with the body
73
+ * (outer radius - inset). Tablets use a fixed one because their displays
74
+ * are rounder than a concentric radius would give.
75
+ */
76
+ screenRadius?: number;
77
+ screenSmoothing: number;
78
+ }
79
+
80
+ export const SLAB_SPECS: Record<SlabFrame, SlabSpec> = {
81
+ // iPhone 17 Pro: titanium/aluminium band, thin uniform black border, continuous corners.
82
+ iphone: { radius: 0.158, smoothing: 0.42, band: 0.0125, inset: 0.0125 + 0.022, screenSmoothing: 0.42 },
83
+ // iPad Pro: thin aluminium edge, uniform bezel, softly rounded display.
84
+ ipad: { radius: 0.06, smoothing: 0.3, band: 0.0075, inset: 0.045, screenRadius: 0.016, screenSmoothing: 0.3 },
85
+ // Pixel Pro: polished frame, slim bezel, rounder (less continuous) corners.
86
+ 'android-phone': { radius: 0.12, smoothing: 0.12, band: 0.0095, inset: 0.0095 + 0.024, screenSmoothing: 0.12 },
87
+ // Pixel Tablet.
88
+ 'android-tablet': { radius: 0.056, smoothing: 0.12, band: 0.0075, inset: 0.05, screenRadius: 0.024, screenSmoothing: 0.12 },
89
+ // Apple Watch: a soft, deep case with a wide black border around a rounded display.
90
+ watch: { radius: 0.26, smoothing: 0.6, band: 0.03, inset: 0.03 + 0.07, screenRadius: 0.17, screenSmoothing: 0.6 },
91
+ // A thin-bezel television.
92
+ tv: { radius: 0.008, smoothing: 0.2, band: 0.003, inset: 0.012, screenRadius: 0.002, screenSmoothing: 0.2 },
93
+ };
94
+
95
+ export interface SlabLayout {
96
+ kind: 'slab';
97
+ frame: SlabFrame;
98
+ w: number;
99
+ h: number;
100
+ radius: number;
101
+ smoothing: number;
102
+ band: number;
103
+ inset: number;
104
+ screen: ScreenRect;
105
+ screenSmoothing: number;
106
+ /**
107
+ * Set on a device lying on its side: the upright layout it is painted
108
+ * from, turned a quarter to the left (its top edge on the left). Its
109
+ * insets and radii follow the short side, as on the real device.
110
+ */
111
+ upright?: SlabLayout;
112
+ }
113
+
114
+ function slabLayout(frame: SlabFrame, w: number, aspect: number): SlabLayout {
115
+ if (aspect < 1 && TURNABLE.has(frame)) {
116
+ // The upright device whose height is this width.
117
+ const k = SLAB_SPECS[frame].inset;
118
+ const up = uprightSlab(frame, w / ((1 - 2 * k) / aspect + 2 * k), 1 / aspect);
119
+ const s = up.screen;
120
+ // `w` itself rather than up.h, which equals it give or take float error.
121
+ return { ...up, w, h: up.w, screen: { x: s.y, y: up.w - s.x - s.w, w: s.h, h: s.w, radius: s.radius }, upright: up };
122
+ }
123
+ return uprightSlab(frame, w, aspect);
124
+ }
125
+
126
+ function uprightSlab(frame: SlabFrame, w: number, aspect: number): SlabLayout {
127
+ const spec = SLAB_SPECS[frame];
128
+ const inset = spec.inset * w;
129
+ const sw = w - 2 * inset;
130
+ const sh = sw * aspect;
131
+ const h = sh + 2 * inset;
132
+ const radius = clampRadius(spec.radius * w, w, h);
133
+ const screenRadius = spec.screenRadius !== undefined ? spec.screenRadius * w : radius - inset;
134
+ return {
135
+ kind: 'slab',
136
+ frame,
137
+ w,
138
+ h,
139
+ radius,
140
+ smoothing: spec.smoothing,
141
+ band: spec.band * w,
142
+ inset,
143
+ screen: { x: inset, y: inset, w: sw, h: sh, radius: clampRadius(screenRadius, sw, sh) },
144
+ screenSmoothing: spec.screenSmoothing,
145
+ };
146
+ }
147
+
148
+ /* ------------------------------------------------------------------ */
149
+ /* MacBook */
150
+ /* ------------------------------------------------------------------ */
151
+
152
+ export interface Box {
153
+ x: number;
154
+ y: number;
155
+ w: number;
156
+ h: number;
157
+ }
158
+
159
+ export interface MacLayout {
160
+ kind: 'macbook';
161
+ w: number;
162
+ h: number;
163
+ /** Display lid; its outer rim is aluminium, the rest black glass. */
164
+ lid: Box & { rTop: number; rBottom: number };
165
+ rim: number;
166
+ hinge: Box;
167
+ /** The thin front slab of the base, as wide as the whole device. */
168
+ base: Box;
169
+ notch: { w: number; h: number };
170
+ screen: ScreenRect;
171
+ }
172
+
173
+ /** Proportions of a 14″ MacBook Pro seen straight on, lid open. */
174
+ export const MAC_SPEC = {
175
+ lid: 0.86,
176
+ rim: 0.0032,
177
+ bezel: 0.0165,
178
+ chin: 0.034,
179
+ lidRadiusTop: 0.026,
180
+ lidRadiusBottom: 0.008,
181
+ screenRadius: 0.0105,
182
+ hinge: 0.0062,
183
+ base: 0.0235,
184
+ notchW: 0.082,
185
+ notchH: 0.0215,
186
+ };
187
+
188
+ function macLayout(w: number, aspect: number): MacLayout {
189
+ const S = MAC_SPEC;
190
+ const lidW = w * S.lid;
191
+ const lidX = (w - lidW) / 2;
192
+ const rim = lidW * S.rim;
193
+ const bezel = lidW * S.bezel;
194
+ const chin = lidW * S.chin;
195
+ const sw = lidW - 2 * (rim + bezel);
196
+ const sh = sw * aspect;
197
+ const lidH = rim + bezel + sh + chin + rim;
198
+ const hingeH = w * S.hinge;
199
+ const hingeW = lidW * 0.9;
200
+ const baseY = lidH + hingeH * 0.5;
201
+ const baseH = w * S.base;
202
+ return {
203
+ kind: 'macbook',
204
+ w,
205
+ h: baseY + baseH,
206
+ lid: {
207
+ x: lidX,
208
+ y: 0,
209
+ w: lidW,
210
+ h: lidH,
211
+ rTop: clampRadius(lidW * S.lidRadiusTop, lidW, lidH),
212
+ rBottom: clampRadius(lidW * S.lidRadiusBottom, lidW, lidH),
213
+ },
214
+ rim,
215
+ hinge: { x: (w - hingeW) / 2, y: lidH - hingeH * 0.5, w: hingeW, h: hingeH * 1.4 },
216
+ base: { x: 0, y: baseY, w, h: baseH },
217
+ notch: { w: sw * S.notchW, h: Math.min(sw * S.notchH, sh * 0.25) },
218
+ screen: { x: lidX + rim + bezel, y: rim + bezel, w: sw, h: sh, radius: clampRadius(sw * S.screenRadius, sw, sh) },
219
+ };
220
+ }
221
+
222
+ /* ------------------------------------------------------------------ */
223
+ /* Browser window and bare screenshot */
224
+ /* ------------------------------------------------------------------ */
225
+
226
+ export interface BrowserLayout {
227
+ kind: 'browser';
228
+ w: number;
229
+ h: number;
230
+ radius: number;
231
+ toolbar: number;
232
+ screen: ScreenRect;
233
+ }
234
+
235
+ export const BROWSER_SPEC = { radius: 0.018, toolbar: 0.055 };
236
+
237
+ function browserLayout(w: number, aspect: number): BrowserLayout {
238
+ const toolbar = w * BROWSER_SPEC.toolbar;
239
+ const sh = w * aspect;
240
+ const h = toolbar + sh;
241
+ const radius = clampRadius(w * BROWSER_SPEC.radius, w, h);
242
+ return { kind: 'browser', w, h, radius, toolbar, screen: { x: 0, y: toolbar, w, h: sh, radius: clampRadius(radius, w, sh) } };
243
+ }
244
+
245
+ /** Apple Vision Pro: a floating glass window with its grab bar below. */
246
+ export interface VisionLayout {
247
+ kind: 'vision';
248
+ w: number;
249
+ h: number;
250
+ /** The glass window; the grab bar sits under it. */
251
+ window: Box & { radius: number };
252
+ bar: Box;
253
+ screen: ScreenRect;
254
+ }
255
+
256
+ export const VISION_SPEC = { glass: 0.012, radius: 0.04, barGap: 0.022, barW: 0.12, barH: 0.011 };
257
+
258
+ function visionLayout(w: number, aspect: number): VisionLayout {
259
+ const S = VISION_SPEC;
260
+ const glass = w * S.glass;
261
+ const sw = w - 2 * glass;
262
+ const sh = sw * aspect;
263
+ const winH = sh + 2 * glass;
264
+ const radius = clampRadius(w * S.radius, w, winH);
265
+ const barW = w * S.barW;
266
+ const barH = w * S.barH;
267
+ const barY = winH + w * S.barGap;
268
+ return {
269
+ kind: 'vision',
270
+ w,
271
+ h: barY + barH,
272
+ window: { x: 0, y: 0, w, h: winH, radius },
273
+ bar: { x: (w - barW) / 2, y: barY, w: barW, h: barH },
274
+ screen: { x: glass, y: glass, w: sw, h: sh, radius: clampRadius(radius - glass, sw, sh) },
275
+ };
276
+ }
277
+
278
+ export interface BareLayout {
279
+ kind: 'none';
280
+ w: number;
281
+ h: number;
282
+ screen: ScreenRect;
283
+ }
284
+
285
+ function bareLayout(w: number, aspect: number, cornerRadius: number): BareLayout {
286
+ const h = w * aspect;
287
+ const r = Number.isFinite(cornerRadius) ? cornerRadius : NONE_DEFAULT_RADIUS;
288
+ return { kind: 'none', w, h, screen: { x: 0, y: 0, w, h, radius: clampRadius(r * w, w, h) } };
289
+ }
290
+
291
+ /* ------------------------------------------------------------------ */
292
+
293
+ export type FrameLayout = SlabLayout | MacLayout | VisionLayout | BrowserLayout | BareLayout;
294
+
295
+ /**
296
+ * Full layout of a frame. `cornerRadius` (fraction of width) only affects
297
+ * frame "none"; every input is sanitised so the result is always finite.
298
+ */
299
+ export function frameLayout(frame: DeviceFrameId, widthPx: number, screenAspect: number, cornerRadius = NONE_DEFAULT_RADIUS): FrameLayout {
300
+ const f = knownFrame(frame);
301
+ const w = sanitizeWidth(widthPx);
302
+ const a = sanitizeAspect(f, screenAspect);
303
+ switch (f) {
304
+ case 'iphone':
305
+ case 'ipad':
306
+ case 'android-phone':
307
+ case 'android-tablet':
308
+ case 'watch':
309
+ case 'tv':
310
+ return slabLayout(f, w, a);
311
+ case 'vision':
312
+ return visionLayout(w, a);
313
+ case 'macbook':
314
+ return macLayout(w, a);
315
+ case 'browser':
316
+ return browserLayout(w, a);
317
+ case 'none':
318
+ return bareLayout(w, a, cornerRadius);
319
+ }
320
+ }
321
+
322
+ /** The public geometry (body box + screen rect) of a layout. */
323
+ export function toGeometry(layout: FrameLayout): DeviceGeometry {
324
+ const s = layout.screen;
325
+ return { width: layout.w, height: layout.h, screen: { x: s.x, y: s.y, w: s.w, h: s.h, radius: s.radius } };
326
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Device frames: procedural vector artwork for every `DeviceFrameId`.
3
+ *
4
+ * geometry.ts pure layout (body box, screen rect) used for hit-testing
5
+ * finish.ts metal / matte / clay colour recipes
6
+ * shadow.ts body-shaped drop shadows without ctx.filter
7
+ * screen.ts screenshot cover-fit, mock and empty placeholders
8
+ * slab.ts shared phone/tablet/watch/TV painter (+ iphone, ipad, android, watch)
9
+ * macbook.ts, vision.ts, browser.ts
10
+ */
11
+ import type { FamilyId } from '../../model/types';
12
+ import type { Ctx2D, DeviceGeometryFn, DrawDeviceFn } from '../types';
13
+ import { androidPhoneHardware, androidTabletHardware } from './android';
14
+ import { paintBrowser } from './browser';
15
+ import { framePaint } from './finish';
16
+ import type { FrameLayout, SlabFrame } from './geometry';
17
+ import { frameLayout, knownFrame, toGeometry } from './geometry';
18
+ import { iphoneHardware } from './iphone';
19
+ import { ipadHardware } from './ipad';
20
+ import { macBodyPath, paintMacbook } from './macbook';
21
+ import { insetSmoothRect, roundRectPath, smoothRectPath } from './paths';
22
+ import type { ScreenContent } from './screen';
23
+ import { paintScreen } from './screen';
24
+ import { currentScale, drawShadow } from './shadow';
25
+ import type { SlabHardware } from './slab';
26
+ import { paintSlab } from './slab';
27
+ import { paintVision, visionBodyPath } from './vision';
28
+ import { tvHardware, watchHardware } from './watch';
29
+
30
+ export { frameLayout } from './geometry';
31
+ export type { FrameLayout } from './geometry';
32
+
33
+ /** Corner smoothing used for frame "none", so bare screenshots get iOS-like corners. */
34
+ const BARE_SMOOTHING = 0.45;
35
+
36
+ const HARDWARE: Record<SlabFrame, SlabHardware> = {
37
+ iphone: iphoneHardware,
38
+ ipad: ipadHardware,
39
+ 'android-phone': androidPhoneHardware,
40
+ 'android-tablet': androidTabletHardware,
41
+ watch: watchHardware,
42
+ tv: tvHardware,
43
+ };
44
+
45
+ const MOCK_FAMILY: Partial<Record<string, FamilyId>> = {
46
+ iphone: 'iphone',
47
+ ipad: 'ipad',
48
+ 'android-phone': 'android-phone',
49
+ 'android-tablet': 'android-tablet',
50
+ macbook: 'mac',
51
+ watch: 'watch',
52
+ tv: 'apple-tv',
53
+ vision: 'vision',
54
+ };
55
+
56
+ /** Outline of the device body (what casts the shadow), top-left at the origin. */
57
+ function bodyPath(ctx: Ctx2D, L: FrameLayout): void {
58
+ switch (L.kind) {
59
+ case 'slab':
60
+ insetSmoothRect(ctx, L.w, L.h, L.radius, L.smoothing, 0);
61
+ return;
62
+ case 'macbook':
63
+ macBodyPath(ctx, L);
64
+ return;
65
+ case 'vision':
66
+ visionBodyPath(ctx, L);
67
+ return;
68
+ case 'browser':
69
+ roundRectPath(ctx, 0, 0, L.w, L.h, L.radius);
70
+ return;
71
+ case 'none':
72
+ smoothRectPath(ctx, 0, 0, L.w, L.h, L.screen.radius, BARE_SMOOTHING);
73
+ return;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Outer size and screen rect of a frame at `widthPx`. Pure and cheap.
79
+ * Inputs are sanitised (aspect clamped to 0.3..4, NaN replaced by the
80
+ * frame's default), so the result is always finite and positive. For
81
+ * frame "none" the radius is a nominal default; drawDevice reports the
82
+ * element's own corner radius.
83
+ */
84
+ export const deviceGeometry: DeviceGeometryFn = (frame, widthPx, screenAspect) => toGeometry(frameLayout(frame, widthPx, screenAspect));
85
+
86
+ /**
87
+ * Draws the device centred at the current origin: shadow, body and finish,
88
+ * bezel, screen (screenshot, mock or empty), hardware overlays. Side buttons
89
+ * are painted just before the body so it hides where they attach. Restores
90
+ * every context property it touches. Returns the geometry it drew, which
91
+ * equals `deviceGeometry` except for the corner radius of frame "none".
92
+ */
93
+ export const drawDevice: DrawDeviceFn = (ctx, opts) => {
94
+ const el = opts.el;
95
+ const frame = knownFrame(el.frame);
96
+ const L = frameLayout(frame, opts.widthPx, opts.screenAspect, el.cornerRadius);
97
+ const geometry = toGeometry(L);
98
+ const paint = framePaint(el.style);
99
+
100
+ ctx.save();
101
+ // Never inherit a caller's shadow: every fill below would cast one.
102
+ ctx.shadowColor = 'rgba(0, 0, 0, 0)';
103
+ ctx.shadowBlur = 0;
104
+ ctx.shadowOffsetX = 0;
105
+ ctx.shadowOffsetY = 0;
106
+ ctx.globalCompositeOperation = 'source-over';
107
+ ctx.translate(-L.w / 2, -L.h / 2);
108
+ const px = currentScale(ctx);
109
+
110
+ // Bare screenshots may be translucent, so they get no contact shading under their edges.
111
+ if (el.shadow) drawShadow(ctx, el.shadow, opts.unit, L.w, L.h, (c) => bodyPath(c, L), L.kind !== 'none');
112
+
113
+ const content: ScreenContent = {
114
+ image: opts.screen,
115
+ placeholder: opts.placeholder,
116
+ mock: opts.mock ?? { seed: 0, palette: [], dark: false },
117
+ family: MOCK_FAMILY[frame],
118
+ chrome: frame === 'browser' ? 'web' : 'device',
119
+ backdrop: frame !== 'none',
120
+ };
121
+
122
+ switch (L.kind) {
123
+ case 'slab':
124
+ paintSlab(ctx, L, paint, px, content, HARDWARE[L.frame]);
125
+ break;
126
+ case 'macbook':
127
+ paintMacbook(ctx, L, paint, px, content);
128
+ break;
129
+ case 'vision':
130
+ paintVision(ctx, L, px, content);
131
+ break;
132
+ case 'browser':
133
+ paintBrowser(ctx, L, paint, px, content);
134
+ break;
135
+ case 'none': {
136
+ const s = L.screen;
137
+ paintScreen(ctx, s, (c) => smoothRectPath(c, s.x, s.y, s.w, s.h, s.radius, BARE_SMOOTHING), content);
138
+ break;
139
+ }
140
+ }
141
+ ctx.restore();
142
+ return geometry;
143
+ };
@@ -0,0 +1,21 @@
1
+ /** iPad Pro: uniform bezel, front camera centred on the top bezel, top button, volume on the right. */
2
+ import type { SlabHardware } from './slab';
3
+ import { paintButton, paintLens } from './slab';
4
+
5
+ const BUTTON_OUT = 0.0042;
6
+
7
+ export const ipadHardware: SlabHardware = {
8
+ buttons(ctx, L, paint) {
9
+ const out = L.w * BUTTON_OUT;
10
+ paintButton(ctx, 'top', 0, L.w * 0.76, L.w * 0.86, out, paint);
11
+ const limit = L.h - L.radius;
12
+ if (L.w * 0.25 < limit) {
13
+ paintButton(ctx, 'right', L.w, L.w * 0.1, L.w * 0.165, out, paint);
14
+ paintButton(ctx, 'right', L.w, L.w * 0.18, L.w * 0.245, out, paint);
15
+ }
16
+ },
17
+ bezel(ctx, L) {
18
+ const bezel = L.inset - L.band;
19
+ paintLens(ctx, L.w / 2, L.band + bezel / 2, Math.min(L.w * 0.0048, bezel * 0.32));
20
+ },
21
+ };
@@ -0,0 +1,55 @@
1
+ /**
2
+ * iPhone Pro: continuous corners, metal band, thin uniform black border,
3
+ * Dynamic Island, Action button + volume on the left, side button on the right.
4
+ */
5
+ import type { ScreenRect } from '../types';
6
+ import type { SlabHardware } from './slab';
7
+ import { paintButton } from './slab';
8
+ import { pillPath } from './paths';
9
+
10
+ /** Dynamic Island, as fractions of the screen width. */
11
+ export const ISLAND = { width: 0.3, height: 0.088, top: 0.032 };
12
+
13
+ /** Buttons as [from, to] spans in fractions of the device width, measured from the top edge. */
14
+ const LEFT_BUTTONS: ReadonlyArray<readonly [number, number]> = [
15
+ [0.3, 0.385], // Action button
16
+ [0.47, 0.6], // volume up
17
+ [0.64, 0.77], // volume down
18
+ ];
19
+ const RIGHT_BUTTONS: ReadonlyArray<readonly [number, number]> = [[0.5, 0.71]];
20
+ const BUTTON_OUT = 0.006;
21
+
22
+ export function islandRect(screen: ScreenRect): { x: number; y: number; w: number; h: number } {
23
+ const w = screen.w * ISLAND.width;
24
+ const h = Math.min(screen.w * ISLAND.height, screen.h * 0.5);
25
+ return { x: screen.x + (screen.w - w) / 2, y: screen.y + Math.min(screen.w * ISLAND.top, screen.h * 0.1), w, h };
26
+ }
27
+
28
+ export const iphoneHardware: SlabHardware = {
29
+ buttons(ctx, L, paint) {
30
+ const out = L.w * BUTTON_OUT;
31
+ // Spans are tied to the width so tall screens do not stretch them; skip any that would reach the bottom corner.
32
+ const limit = L.h - L.radius;
33
+ for (const [a, b] of LEFT_BUTTONS) if (b * L.w < limit) paintButton(ctx, 'left', 0, a * L.w, b * L.w, out, paint);
34
+ for (const [a, b] of RIGHT_BUTTONS) if (b * L.w < limit) paintButton(ctx, 'right', L.w, a * L.w, b * L.w, out, paint);
35
+ },
36
+ overlays(ctx, L) {
37
+ const r = islandRect(L.screen);
38
+ ctx.beginPath();
39
+ pillPath(ctx, r.x, r.y, r.w, r.h);
40
+ ctx.fillStyle = '#000000';
41
+ ctx.fill();
42
+ // The front camera hides at the right end of the island; just a hint of glass.
43
+ const lr = r.h * 0.2;
44
+ const lx = r.x + r.w - r.h * 0.5;
45
+ const ly = r.y + r.h / 2;
46
+ const g = ctx.createRadialGradient(lx - lr * 0.3, ly - lr * 0.3, 0, lx, ly, lr);
47
+ g.addColorStop(0, 'rgba(70, 82, 120, 0.55)');
48
+ g.addColorStop(0.6, 'rgba(18, 22, 40, 0.6)');
49
+ g.addColorStop(1, 'rgba(0, 0, 0, 0)');
50
+ ctx.beginPath();
51
+ ctx.arc(lx, ly, lr, 0, Math.PI * 2);
52
+ ctx.fillStyle = g;
53
+ ctx.fill();
54
+ },
55
+ };