sootsim 0.1.82 → 0.1.84

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 (209) hide show
  1. package/README.md +0 -1
  2. package/detox/colors.ts +54 -0
  3. package/detox/config-loader.ts +135 -0
  4. package/detox/expectations.ts +477 -0
  5. package/detox/gestures.ts +442 -0
  6. package/detox/index.ts +1436 -0
  7. package/detox/jest-environment.ts +86 -0
  8. package/detox/jest-preset.cjs +50 -0
  9. package/detox/matchers.ts +29 -0
  10. package/detox/navigation.ts +43 -0
  11. package/detox/run-test.ts +113 -0
  12. package/detox/screenshots/animated-color-test-rest-norngh.png +0 -0
  13. package/detox/screenshots/color-test-after-drag-norngh.png +0 -0
  14. package/detox/screenshots/color-test-rest-norngh.png +0 -0
  15. package/detox/screenshots/theme-blue-toggle.png +0 -0
  16. package/detox/screenshots/theme-blue.png +0 -0
  17. package/detox/screenshots/theme-red-toggle.png +0 -0
  18. package/detox/screenshots/theme-red.png +0 -0
  19. package/dist-cli/bin.js +3 -3
  20. package/dist-cli/chunks/{agent-3C6Z6YXA.js → agent-2CWD6W6P.js} +2 -2
  21. package/dist-cli/chunks/{agent-wrapper-7Z4UFACX.js → agent-wrapper-5W3LOX6S.js} +2 -2
  22. package/dist-cli/chunks/{assert-XYBIZRDK.js → assert-ZOMAMKRT.js} +2 -2
  23. package/dist-cli/chunks/auto-bootstrap-NYYSMTIM.js +2 -0
  24. package/dist-cli/chunks/beta-4K2SQACK.js +2 -0
  25. package/dist-cli/chunks/chunk-3HXQ7MJK.js +79 -0
  26. package/dist-cli/chunks/{chunk-EJGEDUOC.js → chunk-4K7BH2D4.js} +3 -3
  27. package/dist-cli/chunks/{chunk-2EFQQWEC.js → chunk-4OPRODFA.js} +2 -2
  28. package/dist-cli/chunks/{chunk-Z6G5SDG7.js → chunk-4OWVPRZV.js} +2 -2
  29. package/dist-cli/chunks/{chunk-DCFGNIJC.js → chunk-5XCXOLG2.js} +2 -2
  30. package/dist-cli/chunks/chunk-67ZZ2CM5.js +1 -0
  31. package/dist-cli/chunks/{chunk-M3OULYY3.js → chunk-73UZXB4B.js} +2 -2
  32. package/dist-cli/chunks/{chunk-QPDWMYCA.js → chunk-7NWNTUJF.js} +1 -1
  33. package/dist-cli/chunks/chunk-7YHDJLO2.js +119 -0
  34. package/dist-cli/chunks/{chunk-EX6IOT23.js → chunk-AJVTY6KY.js} +2 -2
  35. package/dist-cli/chunks/chunk-AWSQUOAS.js +67 -0
  36. package/dist-cli/chunks/{chunk-JVNGH5S7.js → chunk-BCBNVJVG.js} +1 -1
  37. package/dist-cli/chunks/{chunk-WZLKUS54.js → chunk-BKBL6K2G.js} +1 -1
  38. package/dist-cli/chunks/{chunk-DSYW2NOW.js → chunk-C3DPQZ4J.js} +2 -2
  39. package/dist-cli/chunks/chunk-D3ZSBIIY.js +2 -0
  40. package/dist-cli/chunks/{chunk-PYDAVGCZ.js → chunk-D4HUVLZR.js} +1 -1
  41. package/dist-cli/chunks/{chunk-H6NBDJIO.js → chunk-DUUSJDES.js} +1 -1
  42. package/dist-cli/chunks/{chunk-BVXP2GDN.js → chunk-ELJLF4SG.js} +3 -3
  43. package/dist-cli/chunks/{chunk-TR7NIFSL.js → chunk-EQ7TFQ2F.js} +1 -1
  44. package/dist-cli/chunks/{chunk-UOWBKSSI.js → chunk-EQCKGC4B.js} +1 -1
  45. package/dist-cli/chunks/chunk-FUCGLWNN.js +1 -0
  46. package/dist-cli/chunks/{chunk-BISEHRNE.js → chunk-HYPJW65U.js} +2 -2
  47. package/dist-cli/chunks/chunk-IILJQCZA.js +2 -0
  48. package/dist-cli/chunks/{chunk-2XULSYS6.js → chunk-KU6MSPAH.js} +2 -2
  49. package/dist-cli/chunks/{chunk-QTJJHBCI.js → chunk-OOOR7NT2.js} +1 -1
  50. package/dist-cli/chunks/{chunk-U3XCDQRH.js → chunk-P7WDNKOS.js} +3 -3
  51. package/dist-cli/chunks/{chunk-C7JOLDDQ.js → chunk-PPKKA5VW.js} +2 -2
  52. package/dist-cli/chunks/{chunk-JUCV3VHM.js → chunk-PS2G44GT.js} +2 -2
  53. package/dist-cli/chunks/{chunk-PO64TMRT.js → chunk-QMSJR5R2.js} +2 -2
  54. package/dist-cli/chunks/{chunk-4QUAOBUB.js → chunk-RF4R2U46.js} +2 -2
  55. package/dist-cli/chunks/{chunk-D3SM2JYB.js → chunk-RIXUH3NK.js} +2 -2
  56. package/dist-cli/chunks/{chunk-2JQIKL3B.js → chunk-SFGUPL2X.js} +2 -2
  57. package/dist-cli/chunks/{chunk-GI5MF6LP.js → chunk-SQX5CAYG.js} +1 -1
  58. package/dist-cli/chunks/{chunk-Q4JNA5VO.js → chunk-SQZAC7C4.js} +1 -1
  59. package/dist-cli/chunks/{chunk-M4ERVRM4.js → chunk-SV7FOGJ3.js} +2 -2
  60. package/dist-cli/chunks/{chunk-ZN2C7V5R.js → chunk-TK3OJSEO.js} +2 -2
  61. package/dist-cli/chunks/{chunk-7SCQEPXK.js → chunk-TL7SIZ7S.js} +1 -1
  62. package/dist-cli/chunks/{chunk-IZ2OO47Y.js → chunk-V2GQ4WXJ.js} +2 -2
  63. package/dist-cli/chunks/{chunk-JUDJXJSE.js → chunk-VH7F45CN.js} +1 -1
  64. package/dist-cli/chunks/chunk-WNVNU2OW.js +4 -0
  65. package/dist-cli/chunks/{chunk-O3AOQP3V.js → chunk-XQ2OBHBE.js} +2 -2
  66. package/dist-cli/chunks/{chunk-MQXYJTXM.js → chunk-YCIA4BHJ.js} +2 -2
  67. package/dist-cli/chunks/chunk-ZSMMJMPA.js +1 -0
  68. package/dist-cli/chunks/cli-version-QB4VH24H.js +2 -0
  69. package/dist-cli/chunks/{compat-2DVSCCR7.js → compat-FWSEEGEH.js} +3 -3
  70. package/dist-cli/chunks/{config-YDX4Q4XM.js → config-CYI2WAGP.js} +2 -2
  71. package/dist-cli/chunks/control-UXY7YQVX.js +2 -0
  72. package/dist-cli/chunks/{cpu-profile-GU62WVZZ.js → cpu-profile-IKAE3KTY.js} +2 -2
  73. package/dist-cli/chunks/{daemon-V5NLDTSB.js → daemon-ZUMF53YB.js} +2 -2
  74. package/dist-cli/chunks/{debug-HAOCONNB.js → debug-P6KULKKS.js} +3 -3
  75. package/dist-cli/chunks/{detox-YLC4DLXB.js → detox-SPWAZCYG.js} +2 -2
  76. package/dist-cli/chunks/{device-ZQ4DN4H6.js → device-JWEPK6I2.js} +2 -2
  77. package/dist-cli/chunks/{diagnose-HNUO3Z5F.js → diagnose-IZODTXV2.js} +2 -2
  78. package/dist-cli/chunks/drivers-MK6WJKBC.js +2 -0
  79. package/dist-cli/chunks/{electron-ZAASAHSW.js → electron-R5GP6RVB.js} +3 -3
  80. package/dist-cli/chunks/flow-6O4GEOPJ.js +2 -0
  81. package/dist-cli/chunks/{hints-BA3GE5W5.js → hints-DYDNYX7N.js} +2 -2
  82. package/dist-cli/chunks/{home-paths-MQXRHBTW.js → home-paths-GLMX5OKL.js} +2 -2
  83. package/dist-cli/chunks/{inspect-T4RMS5KX.js → inspect-FJOPCTY2.js} +3 -3
  84. package/dist-cli/chunks/install-A3TUGGHN.js +2 -0
  85. package/dist-cli/chunks/{install-desktop-MH26VONS.js → install-desktop-YPJZMZM5.js} +3 -3
  86. package/dist-cli/chunks/{keys-IELIDRGB.js → keys-GSYPHWNY.js} +2 -2
  87. package/dist-cli/chunks/{launch-VMT3OWOB.js → launch-4G2PKW5X.js} +3 -3
  88. package/dist-cli/chunks/{login-VZBANVLU.js → login-KJQGHA64.js} +4 -4
  89. package/dist-cli/chunks/{logout-GWXBTQ4H.js → logout-XM2SYH5C.js} +2 -2
  90. package/dist-cli/chunks/{maestro-JYHR4HFR.js → maestro-EOWGI7DG.js} +2 -2
  91. package/dist-cli/chunks/{preview-RPZ4UQ2B.js → preview-F73TKK37.js} +2 -2
  92. package/dist-cli/chunks/{profile-7FLDF2AP.js → profile-22FDKBUO.js} +2 -2
  93. package/dist-cli/chunks/{react-3RC4CNDZ.js → react-5L6VPFUP.js} +2 -2
  94. package/dist-cli/chunks/record-JZXCQ4IN.js +70 -0
  95. package/dist-cli/chunks/runtime-EEBX7CFV.js +2 -0
  96. package/dist-cli/chunks/{runtime-delivery-Z7I2KIRB.js → runtime-delivery-LXUM3R4A.js} +2 -2
  97. package/dist-cli/chunks/{screenshot-GRCZ6AM4.js → screenshot-HDRRG33Q.js} +2 -2
  98. package/dist-cli/chunks/{screenshot-mode-E4YHXHH5.js → screenshot-mode-WY63LZIX.js} +2 -2
  99. package/dist-cli/chunks/{screenshots-7SXMX2AY.js → screenshots-MPV2ENL5.js} +2 -2
  100. package/dist-cli/chunks/{server-GDJ2TCRV.js → server-5LBMCJ3G.js} +2 -2
  101. package/dist-cli/chunks/setup-repo-SZSYNKNI.js +2 -0
  102. package/dist-cli/chunks/{skills-62E7NDRC.js → skills-BQ73YOBF.js} +2 -2
  103. package/dist-cli/chunks/{start-Y7KR5ZQ3.js → start-2WU4W6ZU.js} +4 -4
  104. package/dist-cli/chunks/store-RE45SUBF.js +2 -0
  105. package/dist-cli/chunks/telemetry-DG6GJLCP.js +2 -0
  106. package/dist-cli/chunks/{test-IYMSUPVC.js → test-OVO4CQTG.js} +3 -3
  107. package/dist-cli/chunks/{three-mode-QKKXCCC2.js → three-mode-BKM3KFM7.js} +2 -2
  108. package/dist-cli/chunks/{timeline-PF6NQ7RT.js → timeline-MDXGEDQL.js} +2 -2
  109. package/dist-cli/chunks/{upgrade-CE2Y3TAN.js → upgrade-JGQABWVF.js} +2 -2
  110. package/dist-cli/chunks/upload-UJNUA4ZV.js +2 -0
  111. package/dist-cli/chunks/{web-XEO3ZCPF.js → web-WYFAYQ72.js} +2 -2
  112. package/dist-cli/chunks/{what-happened-372J7YF7.js → what-happened-PZW2KW6A.js} +2 -2
  113. package/dist-cli/chunks/{whoami-B4E7KCT5.js → whoami-7ATWJQS6.js} +2 -2
  114. package/dist-lib/agent-daemon-client.cjs +1 -1
  115. package/dist-lib/agent-events.cjs +1 -1
  116. package/dist-lib/agent-sessions.cjs +1 -1
  117. package/dist-lib/attached-projects.cjs +1 -1
  118. package/dist-lib/auth/shared-session.cjs +1 -1
  119. package/dist-lib/backend-origin.cjs +1 -1
  120. package/dist-lib/beta.cjs +44 -0
  121. package/dist-lib/bridge-constants.cjs +1 -1
  122. package/dist-lib/cli-constants.cjs +1 -1
  123. package/dist-lib/config.cjs +1 -1
  124. package/dist-lib/detox/index.cjs +1770 -0
  125. package/dist-lib/detox/jest-preset.cjs +50 -0
  126. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  127. package/dist-lib/home-paths.cjs +1 -1
  128. package/dist-lib/host/bridge-host.cjs +1 -1
  129. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  130. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  131. package/dist-lib/index.cjs +1 -1
  132. package/dist-lib/metro.cjs +1 -1
  133. package/dist-lib/profiles.cjs +1 -1
  134. package/dist-lib/render-mode.cjs +1 -1
  135. package/dist-lib/scripts/demo-app-registry.cjs +809 -0
  136. package/dist-lib/scripts/dev-server-scanner.cjs +1269 -0
  137. package/dist-lib/skills.cjs +8322 -0
  138. package/dist-lib/vite-base.cjs +3 -3
  139. package/dist-lib/vite.cjs +1 -1
  140. package/package.json +39 -10
  141. package/scripts/demo-app-registry.ts +989 -0
  142. package/scripts/dev-server-scanner.ts +674 -0
  143. package/src/agent-daemon-client.ts +390 -0
  144. package/src/agent-events.ts +71 -0
  145. package/src/agent-prompt.ts +71 -0
  146. package/src/agent-sessions.ts +572 -0
  147. package/src/attached-projects.ts +536 -0
  148. package/src/auth/shared-session.ts +199 -0
  149. package/src/backend-origin.ts +49 -0
  150. package/src/beta.ts +21 -0
  151. package/src/bridge-constants.ts +10 -0
  152. package/src/cli-constants.ts +1 -0
  153. package/src/cli-version.ts +30 -0
  154. package/src/codex-client.ts +215 -0
  155. package/src/config.ts +110 -0
  156. package/src/dev-bundle-resolution.ts +180 -0
  157. package/src/home-paths.ts +382 -0
  158. package/src/host/agent-host.ts +576 -0
  159. package/src/host/bridge-host.ts +2293 -0
  160. package/src/host/fetch-proxy-handler.ts +288 -0
  161. package/src/host/fetch-proxy-overrides.ts +39 -0
  162. package/src/host/open-url.ts +234 -0
  163. package/src/index.ts +9 -0
  164. package/src/metro-plugin.ts +207 -0
  165. package/src/native-dev-bundle-url.ts +62 -0
  166. package/src/native-seam-manifest.ts +313 -0
  167. package/src/profiles.ts +179 -0
  168. package/src/render-mode.ts +27 -0
  169. package/src/runtime-delivery.ts +334 -0
  170. package/src/screenshots/compose.ts +422 -0
  171. package/src/screenshots/frame-compose.ts +438 -0
  172. package/src/screenshots/orchestrate.ts +244 -0
  173. package/src/screenshots/registry.ts +58 -0
  174. package/src/screenshots/schema.ts +364 -0
  175. package/src/skills/builtin/a11y-review.ts +126 -0
  176. package/src/skills/builtin/compat-check.ts +104 -0
  177. package/src/skills/builtin/perf-profile.ts +84 -0
  178. package/src/skills/builtin/screenshot-all.ts +46 -0
  179. package/src/skills/builtin/test-flow.ts +118 -0
  180. package/src/skills/builtin/visual-diff.ts +94 -0
  181. package/src/skills/registry.ts +107 -0
  182. package/src/skills/types.ts +41 -0
  183. package/src/vite-plugin-one.ts +187 -0
  184. package/src/vite-plugin.ts +1381 -0
  185. package/src/worklets-babel.ts +132 -0
  186. package/dist-cli/chunks/auto-bootstrap-D2EQVL7R.js +0 -2
  187. package/dist-cli/chunks/beta-VHPXECZY.js +0 -2
  188. package/dist-cli/chunks/chunk-27HBWBE6.js +0 -4
  189. package/dist-cli/chunks/chunk-2W5C5J4O.js +0 -64
  190. package/dist-cli/chunks/chunk-3OH4VCJA.js +0 -1
  191. package/dist-cli/chunks/chunk-45HLFQRI.js +0 -2
  192. package/dist-cli/chunks/chunk-7YLCK5HG.js +0 -5
  193. package/dist-cli/chunks/chunk-BRDUKIZI.js +0 -119
  194. package/dist-cli/chunks/chunk-GADW2Q5S.js +0 -1
  195. package/dist-cli/chunks/chunk-HST43CVE.js +0 -2
  196. package/dist-cli/chunks/chunk-QJBQOGTK.js +0 -73
  197. package/dist-cli/chunks/chunk-V26REV7G.js +0 -1
  198. package/dist-cli/chunks/cli-version-WF7T6IKI.js +0 -2
  199. package/dist-cli/chunks/control-EAK2OPGB.js +0 -2
  200. package/dist-cli/chunks/demo-app-registry-52A2MI72.js +0 -2
  201. package/dist-cli/chunks/drivers-B4QPIZ4B.js +0 -2
  202. package/dist-cli/chunks/flow-PFLHFNVM.js +0 -2
  203. package/dist-cli/chunks/install-ZCPEMK6U.js +0 -2
  204. package/dist-cli/chunks/record-CZ33G5FT.js +0 -70
  205. package/dist-cli/chunks/runtime-AZKHZHJ4.js +0 -2
  206. package/dist-cli/chunks/setup-repo-3Y2QAZRK.js +0 -2
  207. package/dist-cli/chunks/store-TDTFZMGA.js +0 -2
  208. package/dist-cli/chunks/telemetry-G3NIU5NP.js +0 -2
  209. package/dist-cli/chunks/upload-CLWFS7IL.js +0 -2
@@ -0,0 +1,438 @@
1
+ import { BEZEL_PADDING, SIDE_BUTTON_RESERVE } from 'sootsim-engine/ios/chrome-metrics'
2
+ import { getDevice, type DeviceModel } from 'sootsim-engine/settings'
3
+
4
+ const LEGACY_FRAME_RADIUS = 52
5
+ const LEGACY_SIDE_BEZEL = 18
6
+ const LEGACY_TOP_BEZEL = 42
7
+ const LEGACY_BOTTOM_BEZEL = 96
8
+ const LEGACY_HOME_BUTTON_SIZE = 54
9
+ const LEGACY_HOME_BUTTON_RING = 1.5
10
+ const METALLIC_RING_SHADOW =
11
+ 'inset 0 0 0 0.5px #000, inset 0 0 0 2px #757575, inset 0 0 0 5px #212121'
12
+ const FRAME_OUTLINE_BLEED = 1
13
+
14
+ type FrameButtonLayout = {
15
+ side: 'left' | 'right'
16
+ top: number
17
+ height: number
18
+ width: number
19
+ }
20
+
21
+ type LegacyHomeButtonLayout = {
22
+ top: number
23
+ left: number
24
+ size: number
25
+ ring: number
26
+ }
27
+
28
+ export interface FramedScreenshotLayout {
29
+ model: DeviceModel
30
+ renderScale: number
31
+ outerWidth: number
32
+ outerHeight: number
33
+ logicalOuterWidth: number
34
+ logicalOuterHeight: number
35
+ logicalFrameWidth: number
36
+ logicalFrameHeight: number
37
+ logicalFrameLeft: number
38
+ logicalFrameTop: number
39
+ logicalScreenWidth: number
40
+ logicalScreenHeight: number
41
+ logicalScreenLeft: number
42
+ logicalScreenTop: number
43
+ logicalScreenRadius: number
44
+ logicalFrameRadius: number
45
+ frameBackground: string
46
+ frameOutline: string
47
+ metallicRingShadow: string | null
48
+ buttons: FrameButtonLayout[]
49
+ legacyHomeButton: LegacyHomeButtonLayout | null
50
+ showHomeIndicator: boolean
51
+ logicalHomeIndicatorStripHeight: number
52
+ }
53
+
54
+ // Exact shell device frame layout for still exports.
55
+ // Mirrors DeviceFrame's bezel/button geometry while intentionally excluding:
56
+ // - the electron simulator top bar
57
+ // - the browser rail / gutter
58
+ // The screen bitmap itself still comes from the raw bridge screenshot.
59
+ export function getFramedScreenshotLayout(model: DeviceModel): FramedScreenshotLayout {
60
+ const spec = getDevice(model)
61
+ const renderScale = spec.scale
62
+ const isLegacyHardware =
63
+ !spec.dynamicIsland && spec.homeIndicatorHeight === 0 && spec.cornerRadius === 0
64
+ const logicalScreenWidth = spec.width
65
+ const logicalScreenHeight = spec.height
66
+ const logicalFrameWidth = isLegacyHardware
67
+ ? spec.width + LEGACY_SIDE_BEZEL * 2
68
+ : spec.width + BEZEL_PADDING * 2
69
+ const logicalFrameHeight = isLegacyHardware
70
+ ? spec.height + LEGACY_TOP_BEZEL + LEGACY_BOTTOM_BEZEL
71
+ : spec.height + BEZEL_PADDING * 2
72
+ const logicalFrameLeft = SIDE_BUTTON_RESERVE
73
+ const logicalFrameTop = FRAME_OUTLINE_BLEED
74
+ const logicalOuterWidth = logicalFrameWidth + logicalFrameLeft * 2
75
+ const logicalOuterHeight = logicalFrameHeight + FRAME_OUTLINE_BLEED * 2
76
+ const logicalScreenLeft =
77
+ logicalFrameLeft + (isLegacyHardware ? LEGACY_SIDE_BEZEL : BEZEL_PADDING)
78
+ const logicalScreenTop = isLegacyHardware ? LEGACY_TOP_BEZEL : BEZEL_PADDING
79
+ const logicalFrameRadius = isLegacyHardware
80
+ ? LEGACY_FRAME_RADIUS
81
+ : spec.cornerRadius + BEZEL_PADDING
82
+ const buttons: FrameButtonLayout[] = [
83
+ {
84
+ side: 'right',
85
+ top: spec.hardwareButtons.lock.top,
86
+ height: spec.hardwareButtons.lock.height,
87
+ width: spec.hardwareButtons.width,
88
+ },
89
+ {
90
+ side: 'left',
91
+ top: spec.hardwareButtons.ringToggle.top,
92
+ height: spec.hardwareButtons.ringToggle.height,
93
+ width: spec.hardwareButtons.width,
94
+ },
95
+ {
96
+ side: 'left',
97
+ top: spec.hardwareButtons.volumeUp.top,
98
+ height: spec.hardwareButtons.volumeUp.height,
99
+ width: spec.hardwareButtons.width,
100
+ },
101
+ {
102
+ side: 'left',
103
+ top: spec.hardwareButtons.volumeDown.top,
104
+ height: spec.hardwareButtons.volumeDown.height,
105
+ width: spec.hardwareButtons.width,
106
+ },
107
+ ]
108
+
109
+ return {
110
+ model,
111
+ renderScale,
112
+ outerWidth: Math.round(logicalOuterWidth * renderScale),
113
+ outerHeight: Math.round(logicalOuterHeight * renderScale),
114
+ logicalOuterWidth,
115
+ logicalOuterHeight,
116
+ logicalFrameWidth,
117
+ logicalFrameHeight,
118
+ logicalFrameLeft,
119
+ logicalFrameTop,
120
+ logicalScreenWidth,
121
+ logicalScreenHeight,
122
+ logicalScreenLeft,
123
+ logicalScreenTop,
124
+ logicalScreenRadius: isLegacyHardware ? 0 : spec.cornerRadius,
125
+ logicalFrameRadius,
126
+ frameBackground: isLegacyHardware
127
+ ? 'linear-gradient(180deg, #1b1c20 0%, #0f1012 42%, #050608 100%)'
128
+ : '#000000',
129
+ frameOutline: isLegacyHardware ? '0 0 0 1px #1f2125' : '0 0 0 1px #333',
130
+ metallicRingShadow: isLegacyHardware ? null : METALLIC_RING_SHADOW,
131
+ buttons,
132
+ legacyHomeButton: isLegacyHardware
133
+ ? {
134
+ top:
135
+ LEGACY_TOP_BEZEL +
136
+ spec.height +
137
+ (LEGACY_BOTTOM_BEZEL - LEGACY_HOME_BUTTON_SIZE) / 2,
138
+ left: spec.width / 2 - LEGACY_HOME_BUTTON_SIZE / 2 + LEGACY_SIDE_BEZEL,
139
+ size: LEGACY_HOME_BUTTON_SIZE,
140
+ ring: LEGACY_HOME_BUTTON_RING,
141
+ }
142
+ : null,
143
+ showHomeIndicator: spec.homeIndicatorHeight > 0,
144
+ logicalHomeIndicatorStripHeight: spec.homeIndicatorHeight,
145
+ }
146
+ }
147
+
148
+ function renderSideButtonHtml(button: FrameButtonLayout): string {
149
+ const isRight = button.side === 'right'
150
+ const clip = isRight ? 'inset(-3px -3px -3px 0)' : 'inset(-3px 0 -3px -3px)'
151
+ return `
152
+ <div
153
+ aria-hidden="true"
154
+ style="
155
+ position:absolute;
156
+ ${isRight ? `right:-${button.width}px;` : `left:-${button.width}px;`}
157
+ top:${button.top}px;
158
+ width:${button.width}px;
159
+ height:${button.height}px;
160
+ background-color:#212121;
161
+ border-top-left-radius:${isRight ? 0 : 1.5}px;
162
+ border-bottom-left-radius:${isRight ? 0 : 1.5}px;
163
+ border-top-right-radius:${isRight ? 1.5 : 0}px;
164
+ border-bottom-right-radius:${isRight ? 1.5 : 0}px;
165
+ box-shadow:
166
+ inset 0 0.5px 0 #616161,
167
+ inset 0 -0.5px 0 #3F3F3F,
168
+ ${isRight ? 'inset -0.5px 0 0 #3F3F3F,' : 'inset 0.5px 0 0 #3F3F3F,'}
169
+ 0 0 0 0.5px #676767,
170
+ 0 0 0 1.5px #000;
171
+ clip-path:${clip};
172
+ pointer-events:none;
173
+ z-index:2;
174
+ "
175
+ ></div>
176
+ `
177
+ }
178
+
179
+ function renderFrameHtml(
180
+ layout: FramedScreenshotLayout,
181
+ rawImageDataUrl: string,
182
+ ): string {
183
+ const buttonsHtml = layout.buttons.map(renderSideButtonHtml).join('')
184
+
185
+ const ringHtml = layout.metallicRingShadow
186
+ ? `
187
+ <div
188
+ aria-hidden="true"
189
+ style="
190
+ position:absolute;
191
+ inset:0;
192
+ border-radius:${layout.logicalFrameRadius}px;
193
+ box-shadow:${layout.metallicRingShadow};
194
+ pointer-events:none;
195
+ z-index:10;
196
+ "
197
+ ></div>
198
+ `
199
+ : ''
200
+
201
+ const legacyHomeButtonHtml = layout.legacyHomeButton
202
+ ? `
203
+ <div
204
+ aria-hidden="true"
205
+ style="
206
+ position:absolute;
207
+ top:${layout.legacyHomeButton.top}px;
208
+ left:${layout.legacyHomeButton.left}px;
209
+ width:${layout.legacyHomeButton.size}px;
210
+ height:${layout.legacyHomeButton.size}px;
211
+ border-radius:50%;
212
+ background:
213
+ radial-gradient(circle at 35% 32%, rgba(62,64,68,0.35) 0%, rgba(17,18,20,0.98) 72%, rgba(8,9,11,1) 100%);
214
+ box-shadow:
215
+ inset 0 0 0 1px rgba(255,255,255,0.06),
216
+ inset 0 0 0 ${layout.legacyHomeButton.ring}px rgba(184,192,204,0.45),
217
+ 0 0 0 1px rgba(0,0,0,0.5);
218
+ z-index:3;
219
+ pointer-events:none;
220
+ display:flex;
221
+ align-items:center;
222
+ justify-content:center;
223
+ "
224
+ >
225
+ <div
226
+ style="
227
+ width:16px;
228
+ height:16px;
229
+ border-radius:4px;
230
+ box-shadow:inset 0 0 0 1.5px rgba(188,194,202,0.5);
231
+ "
232
+ ></div>
233
+ </div>
234
+ `
235
+ : ''
236
+
237
+ const homeIndicatorHtml = layout.showHomeIndicator
238
+ ? `
239
+ <div
240
+ aria-hidden="true"
241
+ style="
242
+ position:absolute;
243
+ left:0;
244
+ right:0;
245
+ bottom:4px;
246
+ height:${layout.logicalHomeIndicatorStripHeight}px;
247
+ display:flex;
248
+ align-items:center;
249
+ justify-content:center;
250
+ pointer-events:none;
251
+ z-index:10;
252
+ "
253
+ >
254
+ <div
255
+ style="
256
+ width:134px;
257
+ height:5px;
258
+ border-radius:2.5px;
259
+ background-color:rgb(150, 150, 150);
260
+ opacity:0.5;
261
+ "
262
+ ></div>
263
+ </div>
264
+ `
265
+ : ''
266
+
267
+ return `<!doctype html>
268
+ <html>
269
+ <head>
270
+ <meta charset="utf-8" />
271
+ <style>
272
+ html, body {
273
+ margin: 0;
274
+ width: ${layout.outerWidth}px;
275
+ height: ${layout.outerHeight}px;
276
+ background: transparent;
277
+ }
278
+ body {
279
+ overflow: hidden;
280
+ }
281
+ img {
282
+ display: block;
283
+ width: 100%;
284
+ height: 100%;
285
+ }
286
+ </style>
287
+ </head>
288
+ <body>
289
+ <div
290
+ id="frame-export-root"
291
+ style="
292
+ position:relative;
293
+ width:${layout.outerWidth}px;
294
+ height:${layout.outerHeight}px;
295
+ overflow:hidden;
296
+ background:transparent;
297
+ "
298
+ >
299
+ <div
300
+ id="frame-scale-layer"
301
+ style="
302
+ position:absolute;
303
+ top:0;
304
+ left:0;
305
+ width:${layout.logicalOuterWidth}px;
306
+ height:${layout.logicalOuterHeight}px;
307
+ transform:scale(${layout.renderScale});
308
+ transform-origin:top left;
309
+ overflow:visible;
310
+ "
311
+ >
312
+ <div
313
+ id="frame"
314
+ style="
315
+ position:absolute;
316
+ top:${layout.logicalFrameTop}px;
317
+ left:${layout.logicalFrameLeft}px;
318
+ width:${layout.logicalFrameWidth}px;
319
+ height:${layout.logicalFrameHeight}px;
320
+ border-radius:${layout.logicalFrameRadius}px;
321
+ box-sizing:border-box;
322
+ overflow:visible;
323
+ background:${layout.frameBackground};
324
+ box-shadow:${layout.frameOutline};
325
+ "
326
+ >
327
+ ${buttonsHtml}
328
+ ${ringHtml}
329
+ ${legacyHomeButtonHtml}
330
+ <div
331
+ id="screen"
332
+ style="
333
+ position:absolute;
334
+ top:${layout.logicalScreenTop}px;
335
+ left:${layout.logicalScreenLeft - layout.logicalFrameLeft}px;
336
+ width:${layout.logicalScreenWidth}px;
337
+ height:${layout.logicalScreenHeight}px;
338
+ border-radius:${layout.logicalScreenRadius}px;
339
+ overflow:hidden;
340
+ background:#000;
341
+ "
342
+ >
343
+ <img src="${rawImageDataUrl}" alt="" />
344
+ ${homeIndicatorHtml}
345
+ </div>
346
+ </div>
347
+ </div>
348
+ </div>
349
+ </body>
350
+ </html>`
351
+ }
352
+
353
+ export async function composeFramedScreenshot(
354
+ rawPng: Buffer,
355
+ model: DeviceModel,
356
+ ): Promise<Buffer> {
357
+ const { chromium } = await import('playwright')
358
+ const browser = await chromium.launch({ headless: true })
359
+ try {
360
+ const composer = await createFramedScreenshotComposer(browser)
361
+ try {
362
+ return await composer.compose(rawPng, model)
363
+ } finally {
364
+ await composer.close()
365
+ }
366
+ } finally {
367
+ await browser.close()
368
+ }
369
+ }
370
+
371
+ type BrowserLike = {
372
+ newContext: (opts: {
373
+ viewport: { width: number; height: number }
374
+ deviceScaleFactor: number
375
+ }) => Promise<{
376
+ newPage: () => Promise<{
377
+ setContent: (html: string, opts: { waitUntil: 'load' }) => Promise<void>
378
+ waitForTimeout: (ms: number) => Promise<void>
379
+ screenshot: (opts: {
380
+ type: 'png'
381
+ clip: { x: number; y: number; width: number; height: number }
382
+ omitBackground: true
383
+ }) => Promise<Buffer>
384
+ }>
385
+ close: () => Promise<void>
386
+ }>
387
+ }
388
+
389
+ type CachedFramePage = {
390
+ layout: FramedScreenshotLayout
391
+ context: Awaited<ReturnType<BrowserLike['newContext']>>
392
+ page: Awaited<ReturnType<Awaited<ReturnType<BrowserLike['newContext']>>['newPage']>>
393
+ }
394
+
395
+ export async function createFramedScreenshotComposer(browser: BrowserLike) {
396
+ const cache = new Map<DeviceModel, CachedFramePage>()
397
+
398
+ async function getCachedPage(model: DeviceModel): Promise<CachedFramePage> {
399
+ const existing = cache.get(model)
400
+ if (existing) return existing
401
+ const layout = getFramedScreenshotLayout(model)
402
+ const context = await browser.newContext({
403
+ viewport: { width: layout.outerWidth, height: layout.outerHeight },
404
+ deviceScaleFactor: 1,
405
+ })
406
+ const page = await context.newPage()
407
+ const created = { layout, context, page }
408
+ cache.set(model, created)
409
+ return created
410
+ }
411
+
412
+ return {
413
+ async compose(rawPng: Buffer, model: DeviceModel): Promise<Buffer> {
414
+ const cached = await getCachedPage(model)
415
+ const rawImageDataUrl = `data:image/png;base64,${rawPng.toString('base64')}`
416
+ await cached.page.setContent(renderFrameHtml(cached.layout, rawImageDataUrl), {
417
+ waitUntil: 'load',
418
+ })
419
+ await cached.page.waitForTimeout(20)
420
+ return (await cached.page.screenshot({
421
+ type: 'png',
422
+ clip: {
423
+ x: 0,
424
+ y: 0,
425
+ width: cached.layout.outerWidth,
426
+ height: cached.layout.outerHeight,
427
+ },
428
+ omitBackground: true,
429
+ })) as Buffer
430
+ },
431
+ async close(): Promise<void> {
432
+ for (const entry of cache.values()) {
433
+ await entry.context.close()
434
+ }
435
+ cache.clear()
436
+ },
437
+ }
438
+ }
@@ -0,0 +1,244 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'fs'
2
+ import { tmpdir } from 'os'
3
+ import path from 'path'
4
+ import { runFlowPlayback } from '../../cli/commands/flow'
5
+ import {
6
+ composeMarketingScreenshots,
7
+ type MarketingBrowserLike,
8
+ type ComposeResult,
9
+ type ComposeSlideSource,
10
+ } from './compose'
11
+ import { createFramedScreenshotComposer } from './frame-compose'
12
+ import { loadScreenshotsPlan, type NormalizedScreenshotsPlan } from './schema'
13
+
14
+ export interface RunScreenshotsOverrides {
15
+ appTarget?: string | null
16
+ deviceModel?: string | null
17
+ simId?: string | null
18
+ captureOnly?: boolean
19
+ composeOnly?: boolean
20
+ }
21
+
22
+ export interface CaptureResult {
23
+ manifestPath: string
24
+ rawDir: string
25
+ framedDir: string
26
+ rawFiles: string[]
27
+ framedFiles: string[]
28
+ }
29
+
30
+ export interface ScreenshotsRunResult {
31
+ plan: NormalizedScreenshotsPlan
32
+ capture: CaptureResult
33
+ compose: ComposeResult | null
34
+ }
35
+
36
+ function resolveCapturePathMode(plan: NormalizedScreenshotsPlan): 'plan' | 'flow' {
37
+ if (plan.capture.pathMode === 'plan' || plan.capture.pathMode === 'flow') {
38
+ return plan.capture.pathMode
39
+ }
40
+ return plan.capture.fromDir ? 'flow' : 'plan'
41
+ }
42
+
43
+ export function buildCaptureFlowArgs(
44
+ plan: NormalizedScreenshotsPlan,
45
+ overrides: RunScreenshotsOverrides,
46
+ ): string[] {
47
+ if (!plan.capture.flowPath) {
48
+ throw new Error('capture flow path is required to build flow args')
49
+ }
50
+ const args = [plan.capture.flowPath]
51
+ const pathMode = resolveCapturePathMode(plan)
52
+ args.push('--screenshots', plan.capture.rawDir)
53
+ if (pathMode === 'flow') {
54
+ args.push('--screenshot-paths', 'flow')
55
+ }
56
+ const appTarget = overrides.appTarget ?? plan.appTarget
57
+ const deviceModel = overrides.deviceModel ?? plan.deviceModel
58
+ const simId = overrides.simId ?? plan.capture.simId
59
+ if (appTarget) args.push('--url', appTarget)
60
+ if (deviceModel) args.push('--device', deviceModel)
61
+ if (simId) args.push('--sim', simId)
62
+ if (plan.capture.openInNewSim) args.push('--new')
63
+ return args
64
+ }
65
+
66
+ function resolveSlideRawPath(
67
+ plan: NormalizedScreenshotsPlan,
68
+ screenshot: string,
69
+ ): string {
70
+ return path.isAbsolute(screenshot)
71
+ ? screenshot
72
+ : path.join(plan.capture.rawDir, screenshot)
73
+ }
74
+
75
+ function resolveCaptureManifestPath(plan: NormalizedScreenshotsPlan): string {
76
+ return path.join(plan.capture.outDir, 'manifest.json')
77
+ }
78
+
79
+ async function runCaptureStage(
80
+ plan: NormalizedScreenshotsPlan,
81
+ overrides: RunScreenshotsOverrides,
82
+ ): Promise<void> {
83
+ if (!plan.capture.flowPath) return
84
+ mkdirSync(plan.capture.rawDir, { recursive: true })
85
+ const args = buildCaptureFlowArgs(plan, overrides)
86
+ const exitCode = await runFlowPlayback(args)
87
+ if (exitCode !== 0) {
88
+ throw new Error(`flow capture failed with exit code ${exitCode}`)
89
+ }
90
+ }
91
+
92
+ async function buildFramedSources(
93
+ plan: NormalizedScreenshotsPlan,
94
+ browserOverride?: MarketingBrowserLike,
95
+ ): Promise<Map<string, string>> {
96
+ const sources = new Map<string, string>()
97
+ mkdirSync(plan.capture.framedDir, { recursive: true })
98
+ const browser =
99
+ browserOverride ??
100
+ (await (async () => {
101
+ const { chromium } = await import('playwright')
102
+ return chromium.launch({ headless: true })
103
+ })())
104
+ try {
105
+ const composer = await createFramedScreenshotComposer(browser)
106
+ try {
107
+ for (const slide of plan.compose.slides) {
108
+ const rawPath = resolveSlideRawPath(plan, slide.screenshot)
109
+ const rawBuffer = readFileSync(rawPath)
110
+ const framedBuffer = await composer.compose(rawBuffer, plan.compose.frame.style)
111
+ const framedPath = path.join(plan.capture.framedDir, `${slide.assetKey}.png`)
112
+ writeFileSync(framedPath, framedBuffer)
113
+ sources.set(slide.id, framedPath)
114
+ }
115
+ } finally {
116
+ await composer.close()
117
+ }
118
+ } finally {
119
+ if (!browserOverride && typeof browser.close === 'function') {
120
+ await browser.close()
121
+ }
122
+ }
123
+ return sources
124
+ }
125
+
126
+ function writeCaptureManifest(
127
+ plan: NormalizedScreenshotsPlan,
128
+ framedSources: Map<string, string>,
129
+ effectiveDeviceModel: string | null,
130
+ ): CaptureResult {
131
+ const rawFiles: string[] = []
132
+ const framedFiles: string[] = []
133
+ mkdirSync(plan.capture.outDir, { recursive: true })
134
+ const manifestPath = resolveCaptureManifestPath(plan)
135
+ const slides = plan.compose.slides.map((slide) => {
136
+ const rawPath = resolveSlideRawPath(plan, slide.screenshot)
137
+ const framedPath = framedSources.get(slide.id) ?? null
138
+ rawFiles.push(rawPath)
139
+ if (framedPath) framedFiles.push(framedPath)
140
+ return {
141
+ id: slide.id,
142
+ screenshot: slide.screenshot,
143
+ rawPath,
144
+ framedPath,
145
+ }
146
+ })
147
+ writeFileSync(
148
+ manifestPath,
149
+ JSON.stringify(
150
+ {
151
+ generatedAt: new Date().toISOString(),
152
+ deviceModel: effectiveDeviceModel,
153
+ mode: plan.capture.mode,
154
+ rawDir: plan.capture.rawDir,
155
+ framedDir: plan.capture.framedDir,
156
+ slides,
157
+ },
158
+ null,
159
+ 2,
160
+ ),
161
+ )
162
+ return {
163
+ manifestPath,
164
+ rawDir: plan.capture.rawDir,
165
+ framedDir: plan.capture.framedDir,
166
+ rawFiles,
167
+ framedFiles,
168
+ }
169
+ }
170
+
171
+ function resolveComposeSources(
172
+ plan: NormalizedScreenshotsPlan,
173
+ framedSources: Map<string, string>,
174
+ ): ComposeSlideSource[] {
175
+ return plan.compose.slides.map((slide) => ({
176
+ slide,
177
+ imagePath:
178
+ plan.compose.frame.show === true
179
+ ? (framedSources.get(slide.id) ?? resolveSlideRawPath(plan, slide.screenshot))
180
+ : resolveSlideRawPath(plan, slide.screenshot),
181
+ }))
182
+ }
183
+
184
+ export async function runScreenshotsPlan(
185
+ planPath: string,
186
+ overrides: RunScreenshotsOverrides = {},
187
+ ): Promise<ScreenshotsRunResult> {
188
+ const plan = loadScreenshotsPlan(planPath)
189
+ const effectiveDeviceModel = overrides.deviceModel ?? plan.deviceModel
190
+ if (!overrides.composeOnly) {
191
+ await runCaptureStage(plan, overrides)
192
+ }
193
+ const needsFramedSources =
194
+ plan.capture.mode !== 'raw' ||
195
+ (!overrides.captureOnly && plan.compose.frame.show === true)
196
+ const needsSharedBrowser = needsFramedSources || !overrides.captureOnly
197
+ const sharedBrowser = needsSharedBrowser
198
+ ? await (async () => {
199
+ const { chromium } = await import('playwright')
200
+ return chromium.launch({ headless: true })
201
+ })()
202
+ : null
203
+ try {
204
+ const framedSources = needsFramedSources
205
+ ? await buildFramedSources(plan, sharedBrowser ?? undefined)
206
+ : new Map()
207
+ const capture = writeCaptureManifest(plan, framedSources, effectiveDeviceModel)
208
+
209
+ if (overrides.captureOnly) {
210
+ return { plan, capture, compose: null }
211
+ }
212
+
213
+ const composeSources = resolveComposeSources(plan, framedSources)
214
+ const compose = await composeMarketingScreenshots(
215
+ plan.compose,
216
+ composeSources,
217
+ sharedBrowser ?? undefined,
218
+ )
219
+ return { plan, capture, compose }
220
+ } finally {
221
+ if (sharedBrowser && typeof sharedBrowser.close === 'function') {
222
+ await sharedBrowser.close()
223
+ }
224
+ }
225
+ }
226
+
227
+ export async function runScreenshotsPlanFromExistingRaw(
228
+ planPath: string,
229
+ rawDir: string,
230
+ ): Promise<ScreenshotsRunResult> {
231
+ const plan = loadScreenshotsPlan(planPath)
232
+ plan.capture.rawDir = rawDir
233
+ plan.capture.flowPath = null
234
+ plan.capture.fromDir = rawDir
235
+ const framedDir = path.join(tmpdir(), 'sootsim-screenshots-framed')
236
+ plan.capture.framedDir = framedDir
237
+ const framedSources = await buildFramedSources(plan)
238
+ const capture = writeCaptureManifest(plan, framedSources, plan.deviceModel)
239
+ const compose = await composeMarketingScreenshots(
240
+ plan.compose,
241
+ resolveComposeSources(plan, framedSources),
242
+ )
243
+ return { plan, capture, compose }
244
+ }