autokap 1.0.7 → 1.0.8

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 (278) hide show
  1. package/assets/cursors/macos.svg +4 -0
  2. package/assets/cursors/windows.svg +15 -0
  3. package/assets/skill/OPCODE-REFERENCE.md +607 -0
  4. package/assets/skill/README.md +39 -0
  5. package/assets/skill/SKILL.md +453 -468
  6. package/assets/skill/STUDIO-SKILL.md +476 -0
  7. package/assets/skill/references/examples.md +104 -0
  8. package/assets/skill/references/interactive-demo.md +225 -0
  9. package/assets/skill/references/mock-data.md +178 -0
  10. package/dist/action-verifier.d.ts +29 -0
  11. package/dist/action-verifier.js +133 -0
  12. package/dist/agent-action-recovery.d.ts +45 -0
  13. package/dist/agent-action-recovery.js +370 -0
  14. package/dist/agent-message-utils.d.ts +21 -0
  15. package/dist/agent-message-utils.js +77 -0
  16. package/dist/agent-url-utils.d.ts +30 -0
  17. package/dist/agent-url-utils.js +138 -0
  18. package/dist/agent.d.ts +92 -8
  19. package/dist/agent.js +2936 -781
  20. package/dist/ak-tree.d.ts +39 -0
  21. package/dist/ak-tree.js +368 -0
  22. package/dist/alt-text.d.ts +26 -0
  23. package/dist/alt-text.js +55 -0
  24. package/dist/auth-capture.d.ts +17 -0
  25. package/dist/auth-capture.js +164 -0
  26. package/dist/benchmark.d.ts +59 -0
  27. package/dist/benchmark.js +135 -0
  28. package/dist/browser-bar.d.ts +14 -6
  29. package/dist/browser-bar.js +145 -8
  30. package/dist/browser-pool.d.ts +7 -0
  31. package/dist/browser-pool.js +15 -5
  32. package/dist/browser-utils.d.ts +31 -0
  33. package/dist/browser-utils.js +97 -0
  34. package/dist/browser.d.ts +51 -1
  35. package/dist/browser.js +1481 -31
  36. package/dist/capture-alt-text.js +2 -1
  37. package/dist/capture-language-preflight.js +14 -0
  38. package/dist/capture-llm-page-identity.js +22 -10
  39. package/dist/capture-page-identity.d.ts +5 -7
  40. package/dist/capture-page-identity.js +211 -78
  41. package/dist/capture-preset-credentials.d.ts +50 -0
  42. package/dist/capture-preset-credentials.js +127 -0
  43. package/dist/capture-request-plan.d.ts +2 -2
  44. package/dist/capture-request-plan.js +64 -16
  45. package/dist/capture-run-optimizer.js +48 -33
  46. package/dist/capture-selector-memory.d.ts +5 -0
  47. package/dist/capture-selector-memory.js +18 -0
  48. package/dist/capture-strategy.d.ts +36 -0
  49. package/dist/capture-strategy.js +95 -0
  50. package/dist/capture-studio-sync.d.ts +1 -0
  51. package/dist/capture-studio-sync.js +9 -3
  52. package/dist/capture-surface-contract.d.ts +36 -0
  53. package/dist/capture-surface-contract.js +299 -0
  54. package/dist/capture-transition-engine.d.ts +28 -0
  55. package/dist/capture-transition-engine.js +292 -0
  56. package/dist/capture-variant-state.d.ts +2 -0
  57. package/dist/capture-variant-state.js +26 -0
  58. package/dist/capture-verification.d.ts +35 -0
  59. package/dist/capture-verification.js +95 -0
  60. package/dist/capture-viewport-lock.d.ts +48 -0
  61. package/dist/capture-viewport-lock.js +74 -0
  62. package/dist/circuit-breaker.d.ts +42 -0
  63. package/dist/circuit-breaker.js +119 -0
  64. package/dist/cli-config.d.ts +8 -1
  65. package/dist/cli-config.js +62 -6
  66. package/dist/cli-contract.d.ts +15 -0
  67. package/dist/cli-contract.js +167 -0
  68. package/dist/cli-runner-local.d.ts +12 -0
  69. package/dist/cli-runner-local.js +102 -0
  70. package/dist/cli-runner.d.ts +34 -0
  71. package/dist/cli-runner.js +433 -0
  72. package/dist/cli-utils.d.ts +0 -1
  73. package/dist/cli-utils.js +2 -5
  74. package/dist/cli.js +1005 -267
  75. package/dist/clip-orchestrator.js +9 -2
  76. package/dist/clip-postprocess.js +25 -16
  77. package/dist/cookie-dismiss.d.ts +2 -0
  78. package/dist/cookie-dismiss.js +48 -13
  79. package/dist/cost-logging.d.ts +8 -0
  80. package/dist/cost-logging.js +160 -46
  81. package/dist/cost-resolution-monitor.d.ts +16 -0
  82. package/dist/cost-resolution-monitor.js +34 -0
  83. package/dist/credential-templates.js +2 -2
  84. package/dist/cursor-overlay-script.d.ts +6 -0
  85. package/dist/cursor-overlay-script.js +169 -0
  86. package/dist/dom-css-purger.d.ts +65 -0
  87. package/dist/dom-css-purger.js +333 -0
  88. package/dist/dom-font-inliner.d.ts +45 -0
  89. package/dist/dom-font-inliner.js +148 -0
  90. package/dist/dom-patch-resolver.d.ts +52 -0
  91. package/dist/dom-patch-resolver.js +242 -0
  92. package/dist/dom-serializer.d.ts +82 -0
  93. package/dist/dom-serializer.js +378 -0
  94. package/dist/element-capture.d.ts +1 -41
  95. package/dist/element-capture.js +202 -446
  96. package/dist/env-validation.d.ts +5 -0
  97. package/dist/env-validation.js +29 -0
  98. package/dist/execution-schema.d.ts +4423 -0
  99. package/dist/execution-schema.js +507 -0
  100. package/dist/execution-types.d.ts +886 -0
  101. package/dist/execution-types.js +65 -0
  102. package/dist/fonts-loader.d.ts +14 -0
  103. package/dist/fonts-loader.js +55 -0
  104. package/dist/hybrid-navigator.js +12 -12
  105. package/dist/index.d.ts +9 -6
  106. package/dist/index.js +10 -4
  107. package/dist/legacy/agent-action-recovery.d.ts +45 -0
  108. package/dist/legacy/agent-action-recovery.js +370 -0
  109. package/dist/legacy/agent-message-utils.d.ts +21 -0
  110. package/dist/legacy/agent-message-utils.js +77 -0
  111. package/dist/legacy/agent-url-utils.d.ts +30 -0
  112. package/dist/legacy/agent-url-utils.js +138 -0
  113. package/dist/legacy/agent.d.ts +226 -0
  114. package/dist/legacy/agent.js +6666 -0
  115. package/dist/legacy/clip-orchestrator.d.ts +148 -0
  116. package/dist/legacy/clip-orchestrator.js +957 -0
  117. package/dist/legacy/credential-templates.d.ts +5 -0
  118. package/dist/legacy/credential-templates.js +60 -0
  119. package/dist/legacy/hybrid-navigator.d.ts +138 -0
  120. package/dist/legacy/hybrid-navigator.js +468 -0
  121. package/dist/legacy/llm-usage.d.ts +17 -0
  122. package/dist/legacy/llm-usage.js +45 -0
  123. package/dist/legacy/prompt-cache.d.ts +10 -0
  124. package/dist/legacy/prompt-cache.js +24 -0
  125. package/dist/legacy/prompts.d.ts +175 -0
  126. package/dist/legacy/prompts.js +1038 -0
  127. package/dist/legacy/tools.d.ts +4 -0
  128. package/dist/legacy/tools.js +216 -0
  129. package/dist/legacy/video-agent.d.ts +143 -0
  130. package/dist/legacy/video-agent.js +4788 -0
  131. package/dist/legacy/video-observation.d.ts +36 -0
  132. package/dist/legacy/video-observation.js +192 -0
  133. package/dist/legacy/video-planner.d.ts +12 -0
  134. package/dist/legacy/video-planner.js +501 -0
  135. package/dist/legacy/video-prompts.d.ts +37 -0
  136. package/dist/legacy/video-prompts.js +569 -0
  137. package/dist/legacy/video-tools.d.ts +3 -0
  138. package/dist/legacy/video-tools.js +59 -0
  139. package/dist/legacy/video-variant-state.d.ts +29 -0
  140. package/dist/legacy/video-variant-state.js +80 -0
  141. package/dist/legacy/vision-model.d.ts +17 -0
  142. package/dist/legacy/vision-model.js +74 -0
  143. package/dist/llm-healer.d.ts +63 -0
  144. package/dist/llm-healer.js +166 -0
  145. package/dist/llm-provider.d.ts +29 -0
  146. package/dist/llm-provider.js +80 -0
  147. package/dist/logger.d.ts +6 -2
  148. package/dist/logger.js +15 -1
  149. package/dist/mockup-html.js +35 -25
  150. package/dist/mockup.d.ts +95 -2
  151. package/dist/mockup.js +427 -166
  152. package/dist/mouse-animation.d.ts +2 -2
  153. package/dist/mouse-animation.js +34 -20
  154. package/dist/opcode-actions.d.ts +42 -0
  155. package/dist/opcode-actions.js +511 -0
  156. package/dist/opcode-runner.d.ts +51 -0
  157. package/dist/opcode-runner.js +770 -0
  158. package/dist/openrouter-client.d.ts +40 -0
  159. package/dist/openrouter-client.js +16 -0
  160. package/dist/overlay-engine.d.ts +24 -0
  161. package/dist/overlay-engine.js +176 -0
  162. package/dist/postcondition.d.ts +16 -0
  163. package/dist/postcondition.js +269 -0
  164. package/dist/program-patcher.d.ts +25 -0
  165. package/dist/program-patcher.js +44 -0
  166. package/dist/prompts.d.ts +13 -5
  167. package/dist/prompts.js +224 -351
  168. package/dist/provider-config.d.ts +12 -0
  169. package/dist/provider-config.js +15 -0
  170. package/dist/recovery-chain.d.ts +37 -0
  171. package/dist/recovery-chain.js +350 -0
  172. package/dist/remote-browser.d.ts +28 -4
  173. package/dist/remote-browser.js +60 -5
  174. package/dist/safari-browser-bar.d.ts +15 -0
  175. package/dist/safari-browser-bar.js +95 -0
  176. package/dist/safari-toolbar-asset.d.ts +15 -0
  177. package/dist/safari-toolbar-asset.js +12 -0
  178. package/dist/security.d.ts +2 -1
  179. package/dist/security.js +49 -10
  180. package/dist/selector-resolver.d.ts +34 -0
  181. package/dist/selector-resolver.js +181 -0
  182. package/dist/semantic-resolver.d.ts +35 -0
  183. package/dist/semantic-resolver.js +161 -0
  184. package/dist/server-capture-runtime.d.ts +5 -3
  185. package/dist/server-capture-runtime.js +42 -95
  186. package/dist/server-credit-usage.d.ts +2 -2
  187. package/dist/server-project-webhooks.d.ts +15 -1
  188. package/dist/server-project-webhooks.js +34 -8
  189. package/dist/server-screenshot-watermark.js +27 -5
  190. package/dist/session-profile.js +164 -1
  191. package/dist/sf-pro-symbols.d.ts +1 -0
  192. package/dist/sf-pro-symbols.js +55 -0
  193. package/dist/skill-packaging.d.ts +28 -0
  194. package/dist/skill-packaging.js +169 -0
  195. package/dist/smart-wait.d.ts +27 -0
  196. package/dist/smart-wait.js +81 -0
  197. package/dist/status-bar-render.d.ts +20 -0
  198. package/dist/status-bar-render.js +410 -0
  199. package/dist/status-bar.d.ts +9 -0
  200. package/dist/status-bar.js +298 -14
  201. package/dist/svg-browser-bar.d.ts +33 -0
  202. package/dist/svg-browser-bar.js +206 -0
  203. package/dist/svg-status-bar.d.ts +36 -0
  204. package/dist/svg-status-bar.js +597 -0
  205. package/dist/svg-text.d.ts +61 -0
  206. package/dist/svg-text.js +118 -0
  207. package/dist/tools.js +89 -451
  208. package/dist/types.d.ts +240 -5
  209. package/dist/types.js +23 -1
  210. package/dist/v2/action-verifier.d.ts +29 -0
  211. package/dist/v2/action-verifier.js +133 -0
  212. package/dist/v2/alt-text.d.ts +26 -0
  213. package/dist/v2/alt-text.js +55 -0
  214. package/dist/v2/benchmark.d.ts +59 -0
  215. package/dist/v2/benchmark.js +135 -0
  216. package/dist/v2/capture-strategy.d.ts +30 -0
  217. package/dist/v2/capture-strategy.js +67 -0
  218. package/dist/v2/capture-verification.d.ts +35 -0
  219. package/dist/v2/capture-verification.js +95 -0
  220. package/dist/v2/circuit-breaker.d.ts +42 -0
  221. package/dist/v2/circuit-breaker.js +119 -0
  222. package/dist/v2/cli-runner-local.d.ts +11 -0
  223. package/dist/v2/cli-runner-local.js +91 -0
  224. package/dist/v2/cli-runner.d.ts +34 -0
  225. package/dist/v2/cli-runner.js +300 -0
  226. package/dist/v2/compiler-prompts.d.ts +27 -0
  227. package/dist/v2/compiler-prompts.js +123 -0
  228. package/dist/v2/compiler.d.ts +37 -0
  229. package/dist/v2/compiler.js +147 -0
  230. package/dist/v2/explorer.d.ts +41 -0
  231. package/dist/v2/explorer.js +56 -0
  232. package/dist/v2/index.d.ts +37 -0
  233. package/dist/v2/index.js +31 -0
  234. package/dist/v2/llm-healer.d.ts +62 -0
  235. package/dist/v2/llm-healer.js +166 -0
  236. package/dist/v2/llm-provider.d.ts +29 -0
  237. package/dist/v2/llm-provider.js +80 -0
  238. package/dist/v2/opcode-runner.d.ts +47 -0
  239. package/dist/v2/opcode-runner.js +634 -0
  240. package/dist/v2/overlay-engine.d.ts +24 -0
  241. package/dist/v2/overlay-engine.js +150 -0
  242. package/dist/v2/postcondition.d.ts +16 -0
  243. package/dist/v2/postcondition.js +249 -0
  244. package/dist/v2/program-patcher.d.ts +25 -0
  245. package/dist/v2/program-patcher.js +44 -0
  246. package/dist/v2/recovery-chain.d.ts +30 -0
  247. package/dist/v2/recovery-chain.js +368 -0
  248. package/dist/v2/schema.d.ts +2580 -0
  249. package/dist/v2/schema.js +295 -0
  250. package/dist/v2/selector-resolver.d.ts +34 -0
  251. package/dist/v2/selector-resolver.js +181 -0
  252. package/dist/v2/semantic-resolver.d.ts +35 -0
  253. package/dist/v2/semantic-resolver.js +161 -0
  254. package/dist/v2/smart-wait.d.ts +27 -0
  255. package/dist/v2/smart-wait.js +81 -0
  256. package/dist/v2/types.d.ts +444 -0
  257. package/dist/v2/types.js +19 -0
  258. package/dist/v2/web-playwright-local.d.ts +69 -0
  259. package/dist/v2/web-playwright-local.js +392 -0
  260. package/dist/version.d.ts +1 -0
  261. package/dist/version.js +5 -0
  262. package/dist/video-agent.js +18 -13
  263. package/dist/video-planner.js +2 -1
  264. package/dist/video-prompts.js +3 -3
  265. package/dist/web-playwright-local.d.ts +126 -0
  266. package/dist/web-playwright-local.js +819 -0
  267. package/dist/ws-auth.js +4 -1
  268. package/dist/ws-broadcast.d.ts +34 -0
  269. package/dist/ws-broadcast.js +85 -0
  270. package/dist/ws-connection-limits.d.ts +12 -0
  271. package/dist/ws-connection-limits.js +44 -0
  272. package/dist/ws-handler-utils.d.ts +32 -0
  273. package/dist/ws-handler-utils.js +139 -0
  274. package/dist/ws-handler.js +294 -164
  275. package/dist/ws-metrics-server.d.ts +9 -0
  276. package/dist/ws-metrics-server.js +31 -0
  277. package/dist/ws-server.js +41 -1
  278. package/package.json +51 -34
package/dist/mockup.js CHANGED
@@ -2,7 +2,9 @@ import { readFile, readdir } from 'fs/promises';
2
2
  import path from 'path';
3
3
  import sharp from 'sharp';
4
4
  import { fileURLToPath } from 'url';
5
- import { generateMockupPage, computeMockupLayout } from './mockup-html.js';
5
+ import { renderStatusBarBuffer } from './status-bar-render.js';
6
+ import { generateBrowserBarSvg } from './browser-bar.js';
7
+ import { computeMockupLayout } from './mockup-html.js';
6
8
  const __filename = fileURLToPath(import.meta.url);
7
9
  const __dirname = path.dirname(__filename);
8
10
  const DEVICES_DIR = path.join(__dirname, '..', 'assets', 'devices');
@@ -30,6 +32,16 @@ const DEFAULT_MOCKUP_OPTIONS = {
30
32
  browserBar: {},
31
33
  windowBorder: { color: '', width: 0, radius: 0 },
32
34
  };
35
+ /**
36
+ * Strip anything that is not a valid CSS color token.
37
+ * Accepts hex (#fff, #aabbcc, #aabbccdd), rgb/rgba/hsl/hsla/oklch functions,
38
+ * and named CSS colors. Rejects anything else to prevent CSS injection.
39
+ */
40
+ const CSS_COLOR_RE = /^(#[0-9a-f]{3,8}|(?:rgb|rgba|hsl|hsla|oklch|oklab|lch|lab)\([^)]{1,80}\)|[a-z]{3,24}|transparent)$/i;
41
+ function sanitizeCssColor(value) {
42
+ const trimmed = value.trim();
43
+ return CSS_COLOR_RE.test(trimmed) ? trimmed : 'transparent';
44
+ }
33
45
  const CONFIG_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
34
46
  let configCache = null;
35
47
  // In-memory cache for downloaded frame asset Buffers
@@ -39,13 +51,17 @@ export function invalidateDeviceConfigCache() {
39
51
  configCache = null;
40
52
  frameBufferCache.clear();
41
53
  }
42
- function resolveBrowserOrientation(config, requestedOrientation) {
54
+ function resolveSupportedOrientation(config, requestedOrientation) {
43
55
  const supported = config.supportedOrientations ?? [];
44
56
  if (supported.length > 0) {
45
- return supported[0] ?? requestedOrientation;
57
+ return supported.includes(requestedOrientation)
58
+ ? requestedOrientation
59
+ : (supported[0] ?? requestedOrientation);
46
60
  }
47
61
  const available = Object.keys(config.orientations ?? {});
48
- return available[0] ?? config.frameOrientation ?? requestedOrientation;
62
+ return available.includes(requestedOrientation)
63
+ ? requestedOrientation
64
+ : (available[0] ?? config.frameOrientation ?? requestedOrientation);
49
65
  }
50
66
  function normalizeBrowserSafeAreaTop(configuredTop, dpr, fallbackLogicalTop) {
51
67
  const rawTop = configuredTop ?? 0;
@@ -86,6 +102,7 @@ function resolveOrientationConfig(config, requestedOrientation) {
86
102
  if (orientationData) {
87
103
  return {
88
104
  screen: orientationData.screen,
105
+ viewport: orientationData.viewport,
89
106
  safeArea: orientationData.safeArea ?? { top: 0, bottom: 0 },
90
107
  statusBar: orientationData.statusBar,
91
108
  homeIndicator: orientationData.homeIndicator,
@@ -94,6 +111,7 @@ function resolveOrientationConfig(config, requestedOrientation) {
94
111
  frameDarkUrl: orientationData.frameDarkUrl,
95
112
  windowBorder: orientationData.windowBorder,
96
113
  browserBarZones: orientationData.browserBarZones,
114
+ browserStyle: orientationData.browserStyle,
97
115
  frameRotation: orientationData.frameRotation ?? 0,
98
116
  needsRotation: false,
99
117
  disableOverlays: false,
@@ -106,14 +124,20 @@ function resolveOrientationConfig(config, requestedOrientation) {
106
124
  const disableOverlays = config.category === 'phone' && needsRotation;
107
125
  return {
108
126
  screen: config.screen ?? { logicalWidth: 0, logicalHeight: 0, scale: 1, cornerRadius: 0 },
127
+ viewport: config.viewport,
109
128
  safeArea: config.safeArea ?? { top: 0, bottom: 0 },
110
129
  statusBar: config.statusBar,
111
130
  homeIndicator: config.homeIndicator,
112
131
  frame: config.frame,
113
- frameRotation: 0,
132
+ frameUrl: config._rowFrameUrl,
133
+ frameDarkUrl: config.frameDarkUrl,
134
+ windowBorder: config.windowBorder,
135
+ browserBarZones: config.browserBarZones,
136
+ browserStyle: config.browserStyle,
137
+ frameRotation: config.frameRotation ?? 0,
114
138
  needsRotation,
115
139
  disableOverlays,
116
- frameBehindContent: false,
140
+ frameBehindContent: config.frameBehindContent ?? false,
117
141
  };
118
142
  }
119
143
  async function loadDeviceConfigs() {
@@ -124,7 +148,7 @@ async function loadDeviceConfigs() {
124
148
  const { url: supabaseUrl, serviceKey: supabaseServiceKey } = getSupabaseMockupConfig();
125
149
  if (supabaseUrl && supabaseServiceKey) {
126
150
  try {
127
- const res = await fetch(`${supabaseUrl}/rest/v1/device_mockups?is_active=eq.true&order=category.asc,name.asc&select=id,config,frame_url`, {
151
+ const res = await fetch(`${supabaseUrl}/rest/v1/device_mockups?is_active=eq.true&order=category.asc,name.asc&select=id,slug,config,frame_url`, {
128
152
  headers: {
129
153
  apikey: supabaseServiceKey,
130
154
  Authorization: `Bearer ${supabaseServiceKey}`,
@@ -138,6 +162,9 @@ async function loadDeviceConfigs() {
138
162
  if (row.frame_url)
139
163
  config._rowFrameUrl = row.frame_url;
140
164
  configs.set(row.id, config);
165
+ if (row.slug) {
166
+ configs.set(row.slug, config);
167
+ }
141
168
  }
142
169
  configCache = { configs, expiresAt: Date.now() + CONFIG_CACHE_TTL_MS };
143
170
  return configs;
@@ -252,15 +279,31 @@ export async function resolveDeviceFrameDescriptor(id, options) {
252
279
  const config = configs.get(id);
253
280
  if (!config)
254
281
  return null;
255
- const orientation = options?.orientation ?? config.frameOrientation ?? 'portrait';
282
+ // Normalize against supported orientations so stale extra configs in Supabase
283
+ // do not override landscape-only desktop/tablet frames.
284
+ const requestedOrientation = options?.orientation ?? config.frameOrientation ?? 'portrait';
285
+ const orientation = resolveSupportedOrientation(config, requestedOrientation);
256
286
  const resolved = resolveOrientationConfig(config, orientation);
257
287
  const geometry = computeResolvedFrameGeometry(resolved);
288
+ // When auto-rotation is applied (legacy device with needsRotation), the frame
289
+ // geometry is already correctly rotated by computeResolvedFrameGeometry(), but
290
+ // screen logical dimensions still reflect the native orientation. Swap them so
291
+ // that consumers (e.g. resolveDeviceViewport) compute the correct viewport.
292
+ const screen = resolved.needsRotation
293
+ ? {
294
+ logicalWidth: resolved.screen.logicalHeight,
295
+ logicalHeight: resolved.screen.logicalWidth,
296
+ scale: resolved.screen.scale,
297
+ cornerRadius: resolved.screen.cornerRadius,
298
+ }
299
+ : resolved.screen;
258
300
  return {
259
301
  id: config.id,
260
302
  name: config.name,
261
303
  category: config.category,
262
304
  orientation,
263
- screen: resolved.screen,
305
+ viewport: resolved.viewport,
306
+ screen,
264
307
  safeArea: resolved.safeArea,
265
308
  statusBar: resolved.statusBar,
266
309
  homeIndicator: resolved.homeIndicator,
@@ -276,6 +319,7 @@ export async function resolveDeviceFrameDescriptor(id, options) {
276
319
  screenRect: geometry.screenRect,
277
320
  frameRotation: geometry.frameRotation,
278
321
  frameBehindContent: resolved.frameBehindContent,
322
+ browserStyle: resolved.browserStyle,
279
323
  disableOverlays: resolved.disableOverlays,
280
324
  };
281
325
  }
@@ -307,6 +351,33 @@ export async function rasterizeDeviceFrame(descriptor, outputScale) {
307
351
  }
308
352
  return rendered;
309
353
  }
354
+ async function rasterizeFrameAssetBuffer(options) {
355
+ const sourceRasterWidth = Math.max(1, Math.round(options.sourceWidth * options.outputScale));
356
+ const sourceRasterHeight = Math.max(1, Math.round(options.sourceHeight * options.outputScale));
357
+ const targetRasterWidth = Math.max(1, Math.round(options.targetWidth * options.outputScale));
358
+ const targetRasterHeight = Math.max(1, Math.round(options.targetHeight * options.outputScale));
359
+ const svgDensity = Math.max(72, Math.round(72 * options.outputScale));
360
+ let rendered = options.frameType === "svg"
361
+ ? await sharp(options.rawFrame, { density: svgDensity })
362
+ .resize(sourceRasterWidth, sourceRasterHeight)
363
+ .png()
364
+ .toBuffer()
365
+ : await sharp(options.rawFrame)
366
+ .resize(sourceRasterWidth, sourceRasterHeight)
367
+ .png()
368
+ .toBuffer();
369
+ if (options.frameRotation !== 0) {
370
+ rendered = await sharp(rendered).rotate(options.frameRotation).png().toBuffer();
371
+ }
372
+ const renderedMeta = await sharp(rendered).metadata();
373
+ if (renderedMeta.width !== targetRasterWidth || renderedMeta.height !== targetRasterHeight) {
374
+ rendered = await sharp(rendered)
375
+ .resize(targetRasterWidth, targetRasterHeight)
376
+ .png()
377
+ .toBuffer();
378
+ }
379
+ return rendered;
380
+ }
310
381
  // ── Public API ─────────────────────────────────────────────────────────
311
382
  export async function getDeviceFrames() {
312
383
  const configs = await loadDeviceConfigs();
@@ -321,20 +392,75 @@ export async function getDeviceFrame(id) {
321
392
  return undefined;
322
393
  return { id: c.id, name: c.name, category: c.category, viewport: c.viewport };
323
394
  }
324
- export async function applyDeviceFrame(screenshot, deviceId, browserContext, options) {
395
+ // ── Sharp Compositing Helpers ──────────────────────────────────────────
396
+ /** Rasterize an SVG string to a PNG buffer using resvg-js.
397
+ * Resolves external image references (e.g. favicon URLs) before rendering. */
398
+ async function rasterizeSvg(svg, width) {
399
+ const { Resvg } = await import('@resvg/resvg-js');
400
+ const opts = {
401
+ fitTo: { mode: 'width', value: width },
402
+ };
403
+ const resvg = new Resvg(svg, opts);
404
+ // Resolve external image references (e.g. <image href="https://..."/>)
405
+ const imagesToLoad = resvg.imagesToResolve();
406
+ for (const href of imagesToLoad) {
407
+ if (href.startsWith('http://') || href.startsWith('https://')) {
408
+ try {
409
+ const res = await fetch(href);
410
+ if (res.ok) {
411
+ resvg.resolveImage(href, Buffer.from(await res.arrayBuffer()));
412
+ }
413
+ }
414
+ catch {
415
+ // Skip unresolvable images — fallback globe icon is already inline
416
+ }
417
+ }
418
+ }
419
+ const rendered = resvg.render();
420
+ return Buffer.from(rendered.asPng());
421
+ }
422
+ /** Create a solid-color PNG rectangle. */
423
+ async function createColorRect(width, height, color) {
424
+ const rgba = parseCssColor(color);
425
+ return sharp({
426
+ create: { width: Math.max(1, width), height: Math.max(1, height), channels: 4, background: rgba },
427
+ }).png().toBuffer();
428
+ }
429
+ /** Parse a CSS color string (#hex or rgb()) into sharp-compatible RGBA. */
430
+ function parseCssColor(color) {
431
+ if (color.startsWith('#')) {
432
+ const hex = color.slice(1);
433
+ if (hex.length === 3) {
434
+ return { r: parseInt(hex[0] + hex[0], 16), g: parseInt(hex[1] + hex[1], 16), b: parseInt(hex[2] + hex[2], 16), alpha: 1 };
435
+ }
436
+ if (hex.length === 6) {
437
+ return { r: parseInt(hex.slice(0, 2), 16), g: parseInt(hex.slice(2, 4), 16), b: parseInt(hex.slice(4, 6), 16), alpha: 1 };
438
+ }
439
+ if (hex.length === 8) {
440
+ return { r: parseInt(hex.slice(0, 2), 16), g: parseInt(hex.slice(2, 4), 16), b: parseInt(hex.slice(4, 6), 16), alpha: parseInt(hex.slice(6, 8), 16) / 255 };
441
+ }
442
+ }
443
+ const rgbMatch = color.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
444
+ if (rgbMatch) {
445
+ return { r: +rgbMatch[1], g: +rgbMatch[2], b: +rgbMatch[3], alpha: 1 };
446
+ }
447
+ const rgbaMatch = color.match(/^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([\d.]+)\s*\)$/);
448
+ if (rgbaMatch) {
449
+ return { r: +rgbaMatch[1], g: +rgbaMatch[2], b: +rgbaMatch[3], alpha: +rgbaMatch[4] };
450
+ }
451
+ // Fallback: transparent
452
+ return { r: 0, g: 0, b: 0, alpha: 0 };
453
+ }
454
+ export async function applyDeviceFrame(screenshot, deviceId, options) {
325
455
  const configs = await loadDeviceConfigs();
326
456
  const config = configs.get(deviceId);
327
457
  if (!config)
328
458
  throw new Error(`Unknown device frame: ${deviceId}`);
329
459
  const opts = { ...DEFAULT_MOCKUP_OPTIONS, ...options };
330
460
  const requestedOrientation = opts.orientation ?? 'portrait';
331
- // Resolve orientation-specific config
332
- // Browser devices ignore requested orientation. Capture uses a single browser
333
- // geometry, and Studio must mirror that behavior even if stale configs contain
334
- // bogus extra orientations.
335
- const effectiveOrientation = config.category === 'browser'
336
- ? resolveBrowserOrientation(config, requestedOrientation)
337
- : requestedOrientation;
461
+ // Normalize against supported orientations so stale extra configs in Supabase
462
+ // do not override landscape-only desktop/tablet/browser frames.
463
+ const effectiveOrientation = resolveSupportedOrientation(config, requestedOrientation);
338
464
  const resolved = resolveOrientationConfig(config, effectiveOrientation);
339
465
  // Disable overlays if rotation requires it (legacy phone rotation)
340
466
  if (resolved.disableOverlays) {
@@ -346,10 +472,8 @@ export async function applyDeviceFrame(screenshot, deviceId, browserContext, opt
346
472
  }
347
473
  const scale = resolved.screen?.scale ?? 1;
348
474
  const isBrowserDevice = config.category === 'browser';
349
- const hasFrameEarly = !!(resolved.frameUrl || resolved.frame.asset);
350
- // For frameless browser devices, the screenshot is already at final pixel resolution
351
- // (captured at the device's native viewport). outputScale would double it again.
352
- const os = (isBrowserDevice && !hasFrameEarly) ? 1 : Math.max(0.5, Math.min(4, opts.outputScale));
475
+ const os = Math.max(0.5, Math.min(4, opts.outputScale));
476
+ const geometry = computeResolvedFrameGeometry(resolved);
353
477
  console.log(`[mockup] applyDeviceFrame: id=${deviceId}, category=${config.category}, orientation=${requestedOrientation}`);
354
478
  console.log(`[mockup] hasOrientationConfig=${!!config.orientations?.[requestedOrientation]}, orientations=${JSON.stringify(Object.keys(config.orientations ?? {}))}`);
355
479
  console.log(`[mockup] resolved.screen:`, JSON.stringify(resolved.screen));
@@ -359,56 +483,29 @@ export async function applyDeviceFrame(screenshot, deviceId, browserContext, opt
359
483
  // Browser devices can work without a frame image
360
484
  const hasFrame = !!(resolved.frameUrl || resolved.frame.asset);
361
485
  let frameData = null;
362
- if (hasFrame) {
363
- // Load and potentially rotate frame asset
364
- const rawFrame = await loadFrameAsset(config, resolved);
365
- const isSvg = resolved.frame.type === 'svg';
366
- frameData = isSvg
367
- ? await sharp(rawFrame, { density: 72 * os })
368
- .resize(Math.round(resolved.frame.width * os), Math.round(resolved.frame.height * os))
369
- .png().toBuffer()
370
- : await sharp(rawFrame)
371
- .resize(Math.round(resolved.frame.width * os), Math.round(resolved.frame.height * os))
372
- .png().toBuffer();
373
- }
374
- // Apply frame rotation
375
- const geometry = computeResolvedFrameGeometry(resolved);
376
486
  let geo = {
377
487
  frameWidth: geometry.frameWidth,
378
488
  frameHeight: geometry.frameHeight,
379
489
  screenRect: geometry.screenRect,
380
490
  };
381
- let browserDeviceResolutionFactor = 1;
382
- // For frameless browser devices: derive dimensions from the actual screenshot
383
- // and ensure safe area top is set for the Chrome toolbar (86px @ 1x = 129px @ 1.5x)
384
- const BROWSER_BAR_HEIGHT = 86; // Chrome toolbar logical height at 1x
491
+ // For frameless browser devices: use device config logical dimensions as reference.
492
+ // The screenshot is resized to fit the content area (sharp fit:'fill').
493
+ // This matches how the Studio client renders frameless browsers.
494
+ // Safari uses a 52px visible toolbar (cropped from the 88px asset);
495
+ // Chrome uses a two-row 86px toolbar.
496
+ const BROWSER_BAR_HEIGHT = resolved.browserStyle === 'safari' ? 52 : 86;
385
497
  if (isBrowserDevice && !hasFrame) {
386
- const sMeta = await sharp(screenshot).metadata();
387
- const sw = sMeta.width ?? 1440;
388
- const sh = sMeta.height ?? 900;
389
- // Detect the actual DPR from the screenshot dimensions vs the device's logical viewport.
390
- // This handles the case where the caller doesn't know the original capture DPR (e.g. Studio editor).
391
498
  const logicalW = resolved.screen?.logicalWidth || 1440;
392
- const inferredDpr = logicalW > 0 && sw > logicalW
393
- ? Math.round(sw / logicalW * 10) / 10
394
- : 1;
395
- // Use whichever is larger: the explicitly requested outputScale or the inferred DPR.
396
- // This ensures the browser bar is correctly sized even when outputScale=1 is passed
397
- // but the screenshot is at a higher pixel density.
398
- const dpr = Math.max(inferredDpr, Math.max(0.5, Math.min(4, opts.outputScale)));
399
- browserDeviceResolutionFactor = dpr;
400
- const safeAreaTop = normalizeBrowserSafeAreaTop(resolved.safeArea.top, dpr, BROWSER_BAR_HEIGHT);
499
+ const logicalH = resolved.screen?.logicalHeight || 900;
500
+ // Normalize safe area top for Chrome toolbar
501
+ const safeAreaTop = normalizeBrowserSafeAreaTop(resolved.safeArea.top, 1, // logical pixels — pixelScale (os) handles final scaling
502
+ BROWSER_BAR_HEIGHT);
401
503
  resolved.safeArea = { ...resolved.safeArea, top: safeAreaTop };
402
- resolved.screen = {
403
- ...resolved.screen,
404
- cornerRadius: normalizeBrowserCornerRadius(resolved.screen?.cornerRadius, dpr),
405
- };
406
- const saTop = resolved.safeArea.top * scale;
407
- // Frame = screenshot width × (screenshot height + safe area for toolbar)
408
- geo.frameWidth = sw;
409
- geo.frameHeight = sh + saTop;
410
- geo.screenRect = { x: 0, y: 0, width: sw, height: sh + saTop };
411
- console.log(`[mockup] frameless browser: screenshot=${sw}x${sh}, logicalW=${logicalW}, inferredDpr=${inferredDpr}, dpr=${dpr}, safeAreaTop=${saTop}, geo=${geo.frameWidth}x${geo.frameHeight}`);
504
+ // Frame = device config logical dimensions (not screenshot dimensions)
505
+ geo.frameWidth = logicalW;
506
+ geo.frameHeight = logicalH;
507
+ geo.screenRect = { x: 0, y: 0, width: logicalW, height: logicalH };
508
+ console.log(`[mockup] frameless browser: logicalW=${logicalW}, logicalH=${logicalH}, os=${os}, geo=${geo.frameWidth}x${geo.frameHeight}`);
412
509
  }
413
510
  else if (!hasFrame && geo.frameWidth === 0 && geo.frameHeight === 0) {
414
511
  // Non-browser frameless fallback
@@ -417,9 +514,18 @@ export async function applyDeviceFrame(screenshot, deviceId, browserContext, opt
417
514
  if (geo.screenRect.width === 0)
418
515
  geo.screenRect = { x: 0, y: 0, width: geo.frameWidth, height: geo.frameHeight };
419
516
  }
420
- if (frameData && geometry.frameRotation !== 0) {
421
- // Rotate the frame image by the specified angle
422
- frameData = await sharp(frameData).rotate(geometry.frameRotation).png().toBuffer();
517
+ if (hasFrame) {
518
+ const rawFrame = await loadFrameAsset(config, resolved);
519
+ frameData = await rasterizeFrameAssetBuffer({
520
+ rawFrame,
521
+ frameType: resolved.frame.type,
522
+ sourceWidth: resolved.frame.width,
523
+ sourceHeight: resolved.frame.height,
524
+ targetWidth: geo.frameWidth,
525
+ targetHeight: geo.frameHeight,
526
+ frameRotation: geometry.frameRotation,
527
+ outputScale: os,
528
+ });
423
529
  }
424
530
  // Always compute content area with all safe areas visible — this matches
425
531
  // the browser viewport the screenshot was captured at.
@@ -446,25 +552,36 @@ export async function applyDeviceFrame(screenshot, deviceId, browserContext, opt
446
552
  // Get incoming screenshot dimensions for logging
447
553
  const screenshotMeta = await sharp(screenshot).metadata();
448
554
  console.log(`[mockup] input screenshot: ${screenshotMeta.width}x${screenshotMeta.height}`);
449
- console.log(`[mockup] resize target: ${Math.round(contentW * os)}x${Math.round(contentH * os)}`);
450
- // Resize screenshot to outputScale× of the content area for crisp rendering
555
+ const physicalContentW = Math.round(contentW * os);
556
+ const physicalContentH = Math.round(contentH * os);
557
+ console.log(`[mockup] resize target: ${physicalContentW}x${physicalContentH}`);
558
+ // Sample edge colors from the ORIGINAL screenshot before resize.
559
+ // Resizing (especially large downscales with fit:'fill') averages edge pixels,
560
+ // producing grayish artifacts that make safe area fills look darker than intended.
561
+ const colors = await sampleEdgeColors(screenshot);
562
+ // Resize screenshot to physical content dimensions (contentW*os × contentH*os).
563
+ // The mockup HTML container is sized at logical dimensions (contentW × contentH)
564
+ // with the <img> at width:100%;height:100%. Playwright renders the mockup at
565
+ // deviceScaleFactor=os, so the physical-resolution image maps 1:1 to output pixels.
451
566
  const screenshotForMockup = await sharp(screenshot)
452
- .resize(Math.round(contentW * os), Math.round(contentH * os), { fit: 'fill' })
567
+ .resize(physicalContentW, physicalContentH, { fit: 'fill' })
453
568
  .png()
454
569
  .toBuffer();
455
- const screenshotBase64 = screenshotForMockup.toString('base64');
456
- // Sample edge colors for dynamic safe area fills
457
- const colors = await sampleEdgeColors(screenshotForMockup);
458
570
  if (opts.safeAreaTopColor)
459
- colors.topColor = opts.safeAreaTopColor;
571
+ colors.topColor = sanitizeCssColor(opts.safeAreaTopColor);
460
572
  if (opts.safeAreaBottomColor)
461
- colors.bottomColor = opts.safeAreaBottomColor;
573
+ colors.bottomColor = sanitizeCssColor(opts.safeAreaBottomColor);
462
574
  if (opts.safeAreaLeftColor)
463
- colors.leftColor = opts.safeAreaLeftColor;
575
+ colors.leftColor = sanitizeCssColor(opts.safeAreaLeftColor);
464
576
  if (opts.safeAreaRightColor)
465
- colors.rightColor = opts.safeAreaRightColor;
466
- // Determine color scheme from edge colors
467
- const autoColorScheme = isDarkBackground(colors.topColor) ? 'dark' : 'light';
577
+ colors.rightColor = sanitizeCssColor(opts.safeAreaRightColor);
578
+ console.log(`[mockup] sampled colors: top=${colors.topColor} bottom=${colors.bottomColor} left=${colors.leftColor} right=${colors.rightColor}`);
579
+ // Determine color scheme: use explicit override if provided, otherwise auto-detect from edge colors.
580
+ // Laptops (MacBook) always use dark menu bar (white text on black background).
581
+ const isLaptop = config.category === 'laptop';
582
+ const autoColorScheme = isLaptop
583
+ ? 'dark'
584
+ : (opts.colorScheme ?? (isDarkBackground(colors.topColor) ? 'dark' : 'light'));
468
585
  const lum = parseLuminance(colors.bottomColor);
469
586
  const hiColor = lum > 170 ? 'rgba(0,0,0,0.35)' : 'rgba(255,255,255,0.5)';
470
587
  // Browser devices: swap frame to dark variant if needed
@@ -473,96 +590,262 @@ export async function applyDeviceFrame(screenshot, deviceId, browserContext, opt
473
590
  const darkRes = await fetch(resolved.frameDarkUrl);
474
591
  if (darkRes.ok) {
475
592
  const darkRaw = Buffer.from(await darkRes.arrayBuffer());
476
- const isSvg = resolved.frame.type === 'svg';
477
- frameData = isSvg
478
- ? await sharp(darkRaw, { density: 72 * os })
479
- .resize(Math.round(resolved.frame.width * os), Math.round(resolved.frame.height * os))
480
- .png().toBuffer()
481
- : await sharp(darkRaw)
482
- .resize(Math.round(resolved.frame.width * os), Math.round(resolved.frame.height * os))
483
- .png().toBuffer();
484
- if (geometry.frameRotation !== 0) {
485
- frameData = await sharp(frameData).rotate(geometry.frameRotation).png().toBuffer();
486
- }
593
+ frameData = await rasterizeFrameAssetBuffer({
594
+ rawFrame: darkRaw,
595
+ frameType: resolved.frame.type,
596
+ sourceWidth: resolved.frame.width,
597
+ sourceHeight: resolved.frame.height,
598
+ targetWidth: geo.frameWidth,
599
+ targetHeight: geo.frameHeight,
600
+ frameRotation: geometry.frameRotation,
601
+ outputScale: os,
602
+ });
487
603
  }
488
604
  }
489
605
  catch {
490
606
  // Keep light frame as fallback
491
607
  }
492
608
  }
493
- const frameBase64 = frameData ? frameData.toString('base64') : '';
609
+ const rawWindowBorder = opts.windowBorder?.width ? opts.windowBorder : resolved.windowBorder;
494
610
  const browserWindowBorder = isBrowserDevice
495
- ? normalizeBrowserWindowBorder(opts.windowBorder?.width ? opts.windowBorder : resolved.windowBorder, browserDeviceResolutionFactor)
611
+ ? normalizeBrowserWindowBorder(rawWindowBorder ? { ...rawWindowBorder, color: sanitizeCssColor(rawWindowBorder.color) } : rawWindowBorder, 1)
496
612
  : undefined;
497
613
  // Safe area toggles: when hidden, make fills transparent instead of removing them.
498
614
  // This keeps the content area (screenshot placement) at the viewport-matching size.
499
615
  const safeAreaFillColors = {
500
- top: opts.showSafeAreaTop ? colors.topColor : 'transparent',
616
+ top: isLaptop ? '#000000' : (opts.showSafeAreaTop ? colors.topColor : 'transparent'),
501
617
  bottom: opts.showSafeAreaBottom ? colors.bottomColor : 'transparent',
502
618
  left: opts.showSafeAreaLeft ? colors.leftColor : 'transparent',
503
619
  right: opts.showSafeAreaRight ? colors.rightColor : 'transparent',
504
620
  };
505
- // Use shared HTML generator (single source of truth for mockup rendering)
506
- // Always pass showSafeArea=true so the content area matches the viewport.
507
- const html = generateMockupPage({
508
- frameSrc: frameBase64 ? `data:image/png;base64,${frameBase64}` : undefined,
509
- frameWidth: geo.frameWidth,
510
- frameHeight: geo.frameHeight,
511
- frameRotation: 0, // frame already rotated by sharp
512
- frameBehindContent: resolved.frameBehindContent,
513
- screenRect: geo.screenRect,
514
- cornerRadius: (resolved.screen?.cornerRadius ?? 0) * scale,
515
- screenBackground: 'transparent',
516
- safeArea: resolved.safeArea,
517
- scale,
518
- showSafeAreaTop: true,
519
- showSafeAreaBottom: true,
520
- showSafeAreaLeft: true,
521
- showSafeAreaRight: true,
522
- safeAreaColors: safeAreaFillColors,
523
- statusBar: !isBrowserDevice && resolved.statusBar ? {
524
- height: resolved.statusBar.height,
525
- width: resolved.statusBar.width,
526
- type: resolved.statusBar.type ?? 'iphone-dynamic-island',
527
- layout: resolved.statusBar.layout,
528
- } : undefined,
529
- showStatusBar: !isBrowserDevice && opts.showStatusBar,
530
- statusBarConfig: { ...opts.statusBar, colorScheme: autoColorScheme },
531
- colorScheme: autoColorScheme,
532
- showBrowserBar: isBrowserDevice,
533
- browserBarConfig: isBrowserDevice ? { ...opts.browserBar, colorScheme: autoColorScheme } : undefined,
534
- homeIndicator: resolved.homeIndicator,
535
- showHomeIndicator: opts.showHomeIndicator,
536
- homeIndicatorColor: hiColor,
537
- windowBorder: browserWindowBorder,
538
- contentHtml: `<img style="width:100%;height:100%;display:block" src="data:image/png;base64,${screenshotBase64}">`,
539
- pixelScale: os,
540
- });
541
- // Compute final render size — must match the HTML page container (including window border)
542
621
  const wbw = browserWindowBorder?.width ?? 0;
543
- const renderW = Math.round((geo.frameWidth + wbw * 2) * os);
544
- const renderH = Math.round((geo.frameHeight + wbw * 2) * os);
545
- console.log(`[mockup] renderMockup: ${renderW}x${renderH}, showBrowserBar=${isBrowserDevice}, windowBorderWidth=${wbw}, browserDpr=${browserDeviceResolutionFactor}`);
546
- return renderMockup(browserContext, html, renderW, renderH);
622
+ const renderW = Math.round(geo.frameWidth);
623
+ const renderH = Math.round(geo.frameHeight);
624
+ const cornerRadius = (resolved.screen?.cornerRadius ?? 0) * scale;
625
+ console.log(`[mockup] composeMockup: ${renderW}x${renderH} @${os}x, showBrowserBar=${isBrowserDevice}, windowBorderWidth=${wbw}`);
626
+ // ── Sharp compositing — layer-by-layer mockup assembly ──
627
+ // All dimensions in physical pixels (logical * os).
628
+ const pw = Math.round(renderW * os);
629
+ const ph = Math.round(renderH * os);
630
+ const p = (v) => Math.round(v * os);
631
+ // Browser window border is painted as an overlay within the existing geometry.
632
+ // It must not change the mockup's intrinsic size or shift the screen rect.
633
+ const sr = { x: geo.screenRect.x, y: geo.screenRect.y, width: geo.screenRect.width, height: geo.screenRect.height };
634
+ const compositeInputs = [];
635
+ // z:0 — Frame behind content
636
+ if (frameData && resolved.frameBehindContent) {
637
+ compositeInputs.push({ input: frameData, left: 0, top: 0 });
638
+ }
639
+ // z:1 — Screen background with rounded corners + safe area fills
640
+ // Build the screen content as a sub-composition with rounded-corner mask
641
+ const screenW = p(sr.width);
642
+ const screenH = p(sr.height);
643
+ if (screenW > 0 && screenH > 0) {
644
+ const screenLayers = [];
645
+ // Safe area fill rectangles (relative to screen rect origin)
646
+ const showTop = resolved.safeArea.top > 0;
647
+ const showBottom = resolved.safeArea.bottom > 0;
648
+ const showLeft = (resolved.safeArea.left ?? 0) > 0;
649
+ const showRight = (resolved.safeArea.right ?? 0) > 0;
650
+ const topPx = showTop ? resolved.safeArea.top * scale : 0;
651
+ const bottomPx = showBottom ? resolved.safeArea.bottom * scale : 0;
652
+ const leftPx = showLeft ? (resolved.safeArea.left ?? 0) * scale : 0;
653
+ const rightPx = showRight ? (resolved.safeArea.right ?? 0) * scale : 0;
654
+ if (showLeft && safeAreaFillColors.left !== 'transparent') {
655
+ screenLayers.push({
656
+ input: await createColorRect(p(leftPx), screenH, safeAreaFillColors.left),
657
+ left: 0, top: 0,
658
+ });
659
+ }
660
+ if (showRight && safeAreaFillColors.right !== 'transparent') {
661
+ screenLayers.push({
662
+ input: await createColorRect(p(rightPx), screenH, safeAreaFillColors.right),
663
+ left: screenW - p(rightPx), top: 0,
664
+ });
665
+ }
666
+ if (showTop && safeAreaFillColors.top !== 'transparent') {
667
+ screenLayers.push({
668
+ input: await createColorRect(screenW - p(leftPx) - p(rightPx), p(topPx), safeAreaFillColors.top),
669
+ left: p(leftPx), top: 0,
670
+ });
671
+ }
672
+ if (showBottom && safeAreaFillColors.bottom !== 'transparent') {
673
+ screenLayers.push({
674
+ input: await createColorRect(screenW - p(leftPx) - p(rightPx), p(bottomPx), safeAreaFillColors.bottom),
675
+ left: p(leftPx), top: screenH - p(bottomPx),
676
+ });
677
+ }
678
+ // z:2 — Screenshot content
679
+ screenLayers.push({
680
+ input: screenshotForMockup,
681
+ left: p(layout.contentArea.x - sr.x),
682
+ top: p(layout.contentArea.y - sr.y),
683
+ });
684
+ // z:4 — Home indicator
685
+ if (opts.showHomeIndicator && resolved.homeIndicator && resolved.safeArea.bottom > 0) {
686
+ const hi = resolved.homeIndicator;
687
+ const hiW = p(hi.width * scale);
688
+ const hiH = p(hi.height * scale);
689
+ const hiR = p(hi.cornerRadius * scale);
690
+ const hiLeft = p((sr.width - hi.width * scale) / 2);
691
+ const hiTop = p(sr.height - hi.bottomOffset * scale - hi.height * scale);
692
+ const hiSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${hiW}" height="${hiH}"><rect width="${hiW}" height="${hiH}" rx="${hiR}" fill="${hiColor}"/></svg>`;
693
+ screenLayers.push({
694
+ input: await rasterizeSvg(hiSvg, hiW),
695
+ left: hiLeft, top: hiTop,
696
+ });
697
+ }
698
+ // z:5 — Status bar or Browser bar
699
+ // Status bar uses satori (CSS flexbox engine) for pixel-perfect parity
700
+ // with the client HTML rendering. Generated at logical dimensions, then
701
+ // rasterized at physical pixel width.
702
+ const showStatusBar = !isBrowserDevice && (isLaptop || opts.showStatusBar);
703
+ if (showStatusBar && resolved.statusBar && resolved.safeArea.top > 0) {
704
+ const sbW_logical = Math.round(sr.width);
705
+ const sbH_logical = Math.round(resolved.safeArea.top * scale);
706
+ const sbPng = await renderStatusBarBuffer({
707
+ config: { ...opts.statusBar, colorScheme: autoColorScheme },
708
+ width: sbW_logical,
709
+ height: sbH_logical,
710
+ scale,
711
+ deviceType: resolved.statusBar.type ?? 'iphone-dynamic-island',
712
+ layout: resolved.statusBar.layout,
713
+ }, screenW);
714
+ screenLayers.push({ input: sbPng, left: 0, top: 0 });
715
+ }
716
+ if (isBrowserDevice && resolved.safeArea.top > 0) {
717
+ // Browser bar uses viewBox-based scaling internally, so it can accept
718
+ // either logical or physical dimensions. We pass physical for consistency
719
+ // with the compositing canvas.
720
+ const bbW = screenW;
721
+ const bbH = p(resolved.safeArea.top * scale);
722
+ const bbSvg = generateBrowserBarSvg({
723
+ config: {
724
+ ...opts.browserBar,
725
+ colorScheme: autoColorScheme,
726
+ style: resolved.browserStyle ?? 'chrome',
727
+ },
728
+ width: bbW,
729
+ height: bbH,
730
+ scale,
731
+ });
732
+ screenLayers.push({
733
+ input: await rasterizeSvg(bbSvg, bbW),
734
+ left: 0, top: 0,
735
+ });
736
+ }
737
+ // Compose screen content, then apply rounded-corner mask
738
+ let screenBuffer = await sharp({
739
+ create: { width: screenW, height: screenH, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
740
+ }).composite(screenLayers).png().toBuffer();
741
+ // Apply rounded corner mask if radius > 0
742
+ if (cornerRadius > 0) {
743
+ const cr = p(cornerRadius);
744
+ const borderRadius = isLaptop ? `${cr},${cr},0,0` : `${cr}`;
745
+ const maskSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${screenW}" height="${screenH}"><rect width="${screenW}" height="${screenH}" rx="${borderRadius.split(',')[0]}" ry="${borderRadius.split(',')[0]}" fill="white"/></svg>`;
746
+ const maskBuffer = await rasterizeSvg(maskSvg, screenW);
747
+ // For laptop: only top corners are rounded
748
+ let finalMask;
749
+ if (isLaptop) {
750
+ const laptopMaskSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${screenW}" height="${screenH}">
751
+ <path d="M${cr},0 L${screenW - cr},0 Q${screenW},0 ${screenW},${cr} L${screenW},${screenH} L0,${screenH} L0,${cr} Q0,0 ${cr},0 Z" fill="white"/>
752
+ </svg>`;
753
+ finalMask = await rasterizeSvg(laptopMaskSvg, screenW);
754
+ }
755
+ else {
756
+ finalMask = maskBuffer;
757
+ }
758
+ // Apply mask as alpha channel
759
+ screenBuffer = await sharp(screenBuffer)
760
+ .composite([{ input: finalMask, blend: 'dest-in' }])
761
+ .png().toBuffer();
762
+ }
763
+ compositeInputs.push({ input: screenBuffer, left: p(sr.x), top: p(sr.y) });
764
+ }
765
+ // z:8 — Frame on top (when !frameBehindContent)
766
+ if (frameData && !resolved.frameBehindContent) {
767
+ compositeInputs.push({ input: frameData, left: 0, top: 0 });
768
+ }
769
+ // z:9 — Window border (browser frameless)
770
+ if (browserWindowBorder && browserWindowBorder.width > 0) {
771
+ const bw = p(browserWindowBorder.width);
772
+ const br = p(browserWindowBorder.radius ?? 0);
773
+ const bc = sanitizeCssColor(browserWindowBorder.color);
774
+ const borderSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${pw}" height="${ph}">
775
+ <rect x="${bw / 2}" y="${bw / 2}" width="${pw - bw}" height="${ph - bw}" rx="${br}" ry="${br}" fill="none" stroke="${bc}" stroke-width="${bw}"/>
776
+ </svg>`;
777
+ compositeInputs.push({
778
+ input: await rasterizeSvg(borderSvg, pw),
779
+ left: 0, top: 0,
780
+ });
781
+ }
782
+ // Final composition
783
+ return sharp({
784
+ create: { width: pw, height: ph, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
785
+ }).composite(compositeInputs).png().toBuffer();
547
786
  }
548
787
  // ── Edge Color Sampling ────────────────────────────────────────────────
549
- async function sampleEdgeColors(preparedScreenshot) {
550
- const meta = await sharp(preparedScreenshot).metadata();
788
+ async function sampleEdgeColors(screenshot) {
789
+ const meta = await sharp(screenshot).metadata();
551
790
  const w = meta.width ?? 1;
552
791
  const h = meta.height ?? 1;
553
- const [topStats, bottomStats, leftStats, rightStats] = await Promise.all([
554
- sharp(preparedScreenshot).extract({ left: 0, top: 0, width: w, height: 1 }).stats(),
555
- sharp(preparedScreenshot).extract({ left: 0, top: h - 1, width: w, height: 1 }).stats(),
556
- sharp(preparedScreenshot).extract({ left: 0, top: 0, width: 1, height: h }).stats(),
557
- sharp(preparedScreenshot).extract({ left: w - 1, top: 0, width: 1, height: h }).stats(),
792
+ // Sample a centered strip slightly inset from each edge:
793
+ // - Center third horizontally (for top/bottom) or vertically (for left/right)
794
+ // to avoid scrollbars and corner artefacts
795
+ // - Inset 2px from the edge to skip viewport border artefacts
796
+ // - Use MEDIAN instead of MEAN to ignore dark foreground elements (text, icons)
797
+ // that drag the average down and produce falsely darker safe area fills
798
+ const inset = Math.min(2, Math.floor(Math.min(w, h) / 10));
799
+ const stripH = Math.max(1, Math.min(6, Math.floor(h / 20)));
800
+ const stripW = Math.max(1, Math.min(6, Math.floor(w / 20)));
801
+ const cX = Math.floor(w / 3);
802
+ const cW = Math.max(1, Math.floor(w / 3));
803
+ const cY = Math.floor(h / 3);
804
+ const cH = Math.max(1, Math.floor(h / 3));
805
+ const [topRgb, bottomRgb, leftRgb, rightRgb] = await Promise.all([
806
+ medianColor(screenshot, { left: cX, top: inset, width: cW, height: stripH }),
807
+ medianColor(screenshot, { left: cX, top: h - inset - stripH, width: cW, height: stripH }),
808
+ medianColor(screenshot, { left: inset, top: cY, width: stripW, height: cH }),
809
+ medianColor(screenshot, { left: w - inset - stripW, top: cY, width: stripW, height: cH }),
558
810
  ]);
559
811
  return {
560
- topColor: channelsToRgb(topStats.channels),
561
- bottomColor: channelsToRgb(bottomStats.channels),
562
- leftColor: channelsToRgb(leftStats.channels),
563
- rightColor: channelsToRgb(rightStats.channels),
812
+ topColor: snapEdgeColor(topRgb),
813
+ bottomColor: snapEdgeColor(bottomRgb),
814
+ leftColor: snapEdgeColor(leftRgb),
815
+ rightColor: snapEdgeColor(rightRgb),
564
816
  };
565
817
  }
818
+ /** Extract the median RGB color from a region — robust to dark text/icons on light backgrounds */
819
+ async function medianColor(buf, region) {
820
+ const { data, info } = await sharp(buf)
821
+ .extract(region)
822
+ .removeAlpha()
823
+ .raw()
824
+ .toBuffer({ resolveWithObject: true });
825
+ const n = info.width * info.height;
826
+ const r = new Array(n);
827
+ const g = new Array(n);
828
+ const b = new Array(n);
829
+ for (let i = 0; i < n; i++) {
830
+ r[i] = data[i * 3];
831
+ g[i] = data[i * 3 + 1];
832
+ b[i] = data[i * 3 + 2];
833
+ }
834
+ r.sort((a, b) => a - b);
835
+ g.sort((a, b) => a - b);
836
+ b.sort((a, b) => a - b);
837
+ const mid = Math.floor(n / 2);
838
+ return `rgb(${r[mid]},${g[mid]},${b[mid]})`;
839
+ }
840
+ /** Snap near-white and near-black edge colors to pure values */
841
+ function snapEdgeColor(rgb) {
842
+ const lum = parseLuminance(rgb);
843
+ if (lum > 240)
844
+ return 'rgb(255,255,255)';
845
+ if (lum < 15)
846
+ return 'rgb(0,0,0)';
847
+ return rgb;
848
+ }
566
849
  function channelsToRgb(channels) {
567
850
  const r = Math.round(channels[0].mean);
568
851
  const g = Math.round(channels[1].mean);
@@ -583,26 +866,4 @@ function parseLuminance(rgbStr) {
583
866
  const [r, g, b] = match.map(Number);
584
867
  return r * 0.299 + g * 0.587 + b * 0.114;
585
868
  }
586
- // ── Playwright Renderer ────────────────────────────────────────────────
587
- async function renderMockup(browserContext, html, width, height) {
588
- const browser = browserContext.browser();
589
- if (!browser)
590
- throw new Error('Browser not available for mockup rendering');
591
- const renderCtx = await browser.newContext({
592
- viewport: { width, height },
593
- deviceScaleFactor: 1,
594
- });
595
- const page = await renderCtx.newPage();
596
- try {
597
- await page.setContent(html, { waitUntil: 'load' });
598
- await page.evaluate(() => document.fonts.ready);
599
- await page.waitForTimeout(50);
600
- const buffer = await page.screenshot({ type: 'png', fullPage: false, omitBackground: true });
601
- return Buffer.from(buffer);
602
- }
603
- finally {
604
- await page.close();
605
- await renderCtx.close();
606
- }
607
- }
608
869
  //# sourceMappingURL=mockup.js.map