peachsim 0.1.527

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 (324) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +640 -0
  3. package/cli/app-config.ts +82 -0
  4. package/cli/app-fonts.ts +388 -0
  5. package/cli/app-project.ts +59 -0
  6. package/cli/app-splash.ts +171 -0
  7. package/cli/app-state-reset.ts +26 -0
  8. package/cli/auth.ts +184 -0
  9. package/cli/bin.ts +59 -0
  10. package/cli/bridge-diagnostics.ts +226 -0
  11. package/cli/bridge-flow-runner.ts +2806 -0
  12. package/cli/browser-backend.ts +44 -0
  13. package/cli/browser-evals.ts +96 -0
  14. package/cli/cli-update.ts +261 -0
  15. package/cli/cloud-client.ts +1530 -0
  16. package/cli/cloud-dispatch.ts +206 -0
  17. package/cli/cloud-session.ts +227 -0
  18. package/cli/command-contract.ts +221 -0
  19. package/cli/command-registry.ts +304 -0
  20. package/cli/commands/agent-wrapper.ts +1048 -0
  21. package/cli/commands/agent.ts +439 -0
  22. package/cli/commands/app-fonts.ts +99 -0
  23. package/cli/commands/assert.ts +542 -0
  24. package/cli/commands/auth.ts +71 -0
  25. package/cli/commands/box/bundle-plane.ts +71 -0
  26. package/cli/commands/box/checkout-plane.ts +256 -0
  27. package/cli/commands/box/plane-share.ts +208 -0
  28. package/cli/commands/box/rnx-command.ts +60 -0
  29. package/cli/commands/box/search-command.ts +157 -0
  30. package/cli/commands/box.ts +925 -0
  31. package/cli/commands/camera.ts +266 -0
  32. package/cli/commands/cleanup.ts +169 -0
  33. package/cli/commands/compat.ts +176 -0
  34. package/cli/commands/config.ts +34 -0
  35. package/cli/commands/control.ts +2154 -0
  36. package/cli/commands/cpu-profile.ts +941 -0
  37. package/cli/commands/daemon-mac-app.ts +165 -0
  38. package/cli/commands/daemon.ts +800 -0
  39. package/cli/commands/debug.ts +751 -0
  40. package/cli/commands/desktop.ts +39 -0
  41. package/cli/commands/detect.ts +201 -0
  42. package/cli/commands/detox.ts +481 -0
  43. package/cli/commands/device.ts +134 -0
  44. package/cli/commands/diagnose.ts +589 -0
  45. package/cli/commands/do-chain.ts +408 -0
  46. package/cli/commands/electron.ts +94 -0
  47. package/cli/commands/film.ts +379 -0
  48. package/cli/commands/flow.ts +1240 -0
  49. package/cli/commands/inspect/actions.ts +516 -0
  50. package/cli/commands/inspect/core.ts +2086 -0
  51. package/cli/commands/inspect/count.ts +17 -0
  52. package/cli/commands/inspect/describe.ts +119 -0
  53. package/cli/commands/inspect/env.ts +25 -0
  54. package/cli/commands/inspect/find.ts +169 -0
  55. package/cli/commands/inspect/get-layout.ts +53 -0
  56. package/cli/commands/inspect/keyboard.ts +54 -0
  57. package/cli/commands/inspect/list.ts +175 -0
  58. package/cli/commands/inspect/memory.ts +215 -0
  59. package/cli/commands/inspect/redaction.ts +39 -0
  60. package/cli/commands/inspect/resolve-target.ts +77 -0
  61. package/cli/commands/inspect/screens.ts +78 -0
  62. package/cli/commands/inspect/settle.ts +32 -0
  63. package/cli/commands/inspect/settling.ts +197 -0
  64. package/cli/commands/inspect/shared.ts +259 -0
  65. package/cli/commands/inspect/sleep.ts +15 -0
  66. package/cli/commands/inspect/tree.ts +23 -0
  67. package/cli/commands/inspect/url.ts +17 -0
  68. package/cli/commands/inspect/wait-event.ts +211 -0
  69. package/cli/commands/inspect/wait-idle.ts +25 -0
  70. package/cli/commands/inspect/wait-ready.ts +87 -0
  71. package/cli/commands/inspect/wait-selector.ts +59 -0
  72. package/cli/commands/inspect/wait.ts +32 -0
  73. package/cli/commands/inspect.ts +4738 -0
  74. package/cli/commands/install-cli.ts +275 -0
  75. package/cli/commands/install-desktop.ts +385 -0
  76. package/cli/commands/login.ts +357 -0
  77. package/cli/commands/logout.ts +24 -0
  78. package/cli/commands/maestro-generate.ts +375 -0
  79. package/cli/commands/maestro.ts +536 -0
  80. package/cli/commands/mode.ts +58 -0
  81. package/cli/commands/no-bridge-hint.ts +80 -0
  82. package/cli/commands/perf.ts +67 -0
  83. package/cli/commands/permissions.ts +203 -0
  84. package/cli/commands/platform.ts +410 -0
  85. package/cli/commands/preview.ts +103 -0
  86. package/cli/commands/profile.ts +106 -0
  87. package/cli/commands/react.ts +353 -0
  88. package/cli/commands/record.ts +1435 -0
  89. package/cli/commands/report-issue.ts +305 -0
  90. package/cli/commands/reset.ts +89 -0
  91. package/cli/commands/runtime.ts +348 -0
  92. package/cli/commands/screenshot-command.ts +106 -0
  93. package/cli/commands/screenshot-layers.ts +144 -0
  94. package/cli/commands/screenshot-mode.ts +38 -0
  95. package/cli/commands/screenshot.ts +492 -0
  96. package/cli/commands/screenshots-capture.ts +608 -0
  97. package/cli/commands/screenshots.ts +127 -0
  98. package/cli/commands/serve.ts +139 -0
  99. package/cli/commands/setup.ts +416 -0
  100. package/cli/commands/shell-boolean-mode.ts +82 -0
  101. package/cli/commands/skills.ts +468 -0
  102. package/cli/commands/slides.ts +357 -0
  103. package/cli/commands/state.ts +94 -0
  104. package/cli/commands/storage.ts +58 -0
  105. package/cli/commands/telemetry.ts +55 -0
  106. package/cli/commands/test.ts +535 -0
  107. package/cli/commands/three-mode.ts +771 -0
  108. package/cli/commands/timeline.ts +121 -0
  109. package/cli/commands/upgrade.ts +167 -0
  110. package/cli/commands/upload.ts +1203 -0
  111. package/cli/commands/version.ts +63 -0
  112. package/cli/commands/welcome.ts +17 -0
  113. package/cli/commands/what-happened.ts +328 -0
  114. package/cli/current-sim.ts +217 -0
  115. package/cli/desktop-companion.ts +315 -0
  116. package/cli/drivers/electron.ts +70 -0
  117. package/cli/drivers/index.ts +20 -0
  118. package/cli/drivers/playwright-provisioning.ts +184 -0
  119. package/cli/drivers/playwright-sim-host.ts +537 -0
  120. package/cli/drivers/playwright.ts +209 -0
  121. package/cli/drivers/registry.ts +65 -0
  122. package/cli/drivers/types.ts +102 -0
  123. package/cli/flow-export.ts +55 -0
  124. package/cli/flow-file.ts +142 -0
  125. package/cli/flow-live-status.ts +126 -0
  126. package/cli/flow-session.ts +140 -0
  127. package/cli/help-core.ts +143 -0
  128. package/cli/help.ts +44 -0
  129. package/cli/hidden-runtime-alias.ts +19 -0
  130. package/cli/hints.ts +214 -0
  131. package/cli/inspect-notice-state.ts +114 -0
  132. package/cli/internal-child.ts +176 -0
  133. package/cli/maestro-js.ts +323 -0
  134. package/cli/main.ts +704 -0
  135. package/cli/open-url.ts +8 -0
  136. package/cli/outbound-endpoints.ts +830 -0
  137. package/cli/parent-pid.ts +204 -0
  138. package/cli/parse-args.ts +189 -0
  139. package/cli/privacy.ts +53 -0
  140. package/cli/prompt.ts +56 -0
  141. package/cli/recording-access.ts +108 -0
  142. package/cli/registry.ts +2 -0
  143. package/cli/resolve-assets.ts +63 -0
  144. package/cli/rnx-in-process.ts +98 -0
  145. package/cli/run-registry.ts +226 -0
  146. package/cli/run-rnx-core.ts +148 -0
  147. package/cli/run-rnx.ts +137 -0
  148. package/cli/runtime-notes.ts +66 -0
  149. package/cli/runtime-summary.ts +25 -0
  150. package/cli/secure-file.ts +31 -0
  151. package/cli/self-invocation.ts +58 -0
  152. package/cli/send-to-box.ts +109 -0
  153. package/cli/setup-repository.ts +191 -0
  154. package/cli/shell-init.ts +98 -0
  155. package/cli/standalone.ts +4 -0
  156. package/cli/telemetry.ts +84 -0
  157. package/cli/ui.ts +68 -0
  158. package/cli/ws-bridge.ts +1020 -0
  159. package/detox/attempt-events.cjs +23 -0
  160. package/detox/colors.ts +54 -0
  161. package/detox/config-loader.ts +135 -0
  162. package/detox/element-types.ts +48 -0
  163. package/detox/expectations.ts +534 -0
  164. package/detox/gestures.ts +501 -0
  165. package/detox/index.ts +2694 -0
  166. package/detox/jest-preset.cjs +29 -0
  167. package/detox/jest-setup-after-env.cjs +22 -0
  168. package/detox/matchers.ts +30 -0
  169. package/detox/motion-change.cjs +109 -0
  170. package/detox/motion-change.d.cts +35 -0
  171. package/detox/navigation-lifecycle.ts +290 -0
  172. package/detox/navigation.ts +43 -0
  173. package/detox/proof-frame.cjs +114 -0
  174. package/detox/record-block-execution.cjs +78 -0
  175. package/detox/screenshots/animated-color-test-rest-norngh.png +0 -0
  176. package/detox/screenshots/color-test-after-drag-norngh.png +0 -0
  177. package/detox/screenshots/color-test-rest-norngh.png +0 -0
  178. package/detox/screenshots/theme-blue-toggle.png +0 -0
  179. package/detox/screenshots/theme-blue.png +0 -0
  180. package/detox/screenshots/theme-red-toggle.png +0 -0
  181. package/detox/screenshots/theme-red.png +0 -0
  182. package/dist-lib/agent-daemon-client.cjs +502 -0
  183. package/dist-lib/agent-events.cjs +48 -0
  184. package/dist-lib/agent-identity.cjs +53 -0
  185. package/dist-lib/agent-sessions.cjs +759 -0
  186. package/dist-lib/attached-projects.cjs +526 -0
  187. package/dist-lib/auth/shared-session.cjs +208 -0
  188. package/dist-lib/backend-origin.cjs +88 -0
  189. package/dist-lib/beta.cjs +41 -0
  190. package/dist-lib/beta.mjs +13 -0
  191. package/dist-lib/bridge-constants.cjs +161 -0
  192. package/dist-lib/bridge-contract-input.cjs +451 -0
  193. package/dist-lib/bridge-contract-input.mjs +414 -0
  194. package/dist-lib/bridge-contract.cjs +130 -0
  195. package/dist-lib/bridge-contract.mjs +94 -0
  196. package/dist-lib/capture-contract.cjs +220 -0
  197. package/dist-lib/capture-contract.mjs +190 -0
  198. package/dist-lib/cli-constants.cjs +32 -0
  199. package/dist-lib/cloud-contract.cjs +194 -0
  200. package/dist-lib/cloud-contract.mjs +145 -0
  201. package/dist-lib/cloud.cjs +670 -0
  202. package/dist-lib/cloud.mjs +639 -0
  203. package/dist-lib/config.cjs +96 -0
  204. package/dist-lib/detox/index.cjs +3789 -0
  205. package/dist-lib/detox/jest-preset.cjs +29 -0
  206. package/dist-lib/dev-bundle-resolution.cjs +290 -0
  207. package/dist-lib/home-paths.cjs +638 -0
  208. package/dist-lib/host/bridge-host.cjs +10903 -0
  209. package/dist-lib/host/fetch-proxy-handler.cjs +421 -0
  210. package/dist-lib/host/fetch-proxy-overrides.cjs +63 -0
  211. package/dist-lib/host/fetch-proxy-overrides.mjs +37 -0
  212. package/dist-lib/host/replacement-module-handler.cjs +469 -0
  213. package/dist-lib/host/websocket-proxy.cjs +298 -0
  214. package/dist-lib/index.cjs +4268 -0
  215. package/dist-lib/jump-to-source-babel.cjs +93 -0
  216. package/dist-lib/jump-to-source-native.cjs +112 -0
  217. package/dist-lib/menu.cjs +646 -0
  218. package/dist-lib/menu.mjs +604 -0
  219. package/dist-lib/metro-fingerprint-registry.cjs +186 -0
  220. package/dist-lib/metro-fingerprint-registry.mjs +156 -0
  221. package/dist-lib/metro-production-bundle.cjs +329 -0
  222. package/dist-lib/metro-production-bundle.mjs +295 -0
  223. package/dist-lib/metro.cjs +904 -0
  224. package/dist-lib/profiles.cjs +246 -0
  225. package/dist-lib/public-brand.cjs +39 -0
  226. package/dist-lib/react-native-host-modules.cjs +51 -0
  227. package/dist-lib/react-native-host-modules.mjs +25 -0
  228. package/dist-lib/render-mode.cjs +54 -0
  229. package/dist-lib/scripts/dev-server-scanner.cjs +1232 -0
  230. package/dist-lib/sdk.cjs +2433 -0
  231. package/dist-lib/sdk.mjs +2336 -0
  232. package/dist-lib/skills.cjs +39132 -0
  233. package/dist-lib/swift-submit.cjs +92 -0
  234. package/dist-lib/swift-submit.mjs +65 -0
  235. package/dist-lib/swift.cjs +1646 -0
  236. package/dist-lib/vite.cjs +1591 -0
  237. package/package.json +290 -0
  238. package/scripts/dev-server-scanner.ts +1291 -0
  239. package/scripts/optional-demo-registry.ts +113 -0
  240. package/skills/contrast/SKILL.md +165 -0
  241. package/skills/rnx-debug/SKILL.md +433 -0
  242. package/skills/rnx-perf/SKILL.md +399 -0
  243. package/skills/rnx-setup/SKILL.md +172 -0
  244. package/skills/rnx-test/SKILL.md +321 -0
  245. package/skills/rnx-visual/SKILL.md +126 -0
  246. package/src/agent-daemon-client.ts +410 -0
  247. package/src/agent-events.ts +71 -0
  248. package/src/agent-identity.ts +23 -0
  249. package/src/agent-prompt.ts +71 -0
  250. package/src/agent-sessions.ts +580 -0
  251. package/src/app-url.ts +39 -0
  252. package/src/attached-projects.ts +539 -0
  253. package/src/auth/shared-session.ts +235 -0
  254. package/src/backend-origin.ts +70 -0
  255. package/src/beta.ts +19 -0
  256. package/src/bridge-constants.ts +175 -0
  257. package/src/bridge-contract-input.ts +502 -0
  258. package/src/bridge-contract.ts +558 -0
  259. package/src/browser-cache.ts +16 -0
  260. package/src/capture-contract.ts +587 -0
  261. package/src/cli-constants.ts +1 -0
  262. package/src/cli-version.ts +40 -0
  263. package/src/cloud-contract.ts +274 -0
  264. package/src/cloud.ts +762 -0
  265. package/src/codex-client.ts +234 -0
  266. package/src/config.ts +132 -0
  267. package/src/connect.ts +101 -0
  268. package/src/dev-bundle-resolution.ts +346 -0
  269. package/src/dev-server-open.ts +198 -0
  270. package/src/disk-cleanup.ts +540 -0
  271. package/src/flow-timeout-scale.ts +15 -0
  272. package/src/home-paths.ts +806 -0
  273. package/src/host/agent-host.ts +666 -0
  274. package/src/host/bridge-host.ts +2788 -0
  275. package/src/host/fetch-proxy-handler.ts +449 -0
  276. package/src/host/fetch-proxy-overrides.ts +51 -0
  277. package/src/host/open-url.ts +89 -0
  278. package/src/host/plane-host.ts +203 -0
  279. package/src/host/replacement-module-handler.ts +158 -0
  280. package/src/host/websocket-proxy.ts +315 -0
  281. package/src/index.ts +71 -0
  282. package/src/jump-to-source-babel.ts +135 -0
  283. package/src/jump-to-source-native.ts +102 -0
  284. package/src/menu.ts +777 -0
  285. package/src/metro-fingerprint-registry.ts +302 -0
  286. package/src/metro-plugin.ts +341 -0
  287. package/src/metro-production-bundle.ts +471 -0
  288. package/src/native-dev-bundle-url.ts +118 -0
  289. package/src/native-seam-manifest.ts +341 -0
  290. package/src/playwright-host-log.ts +16 -0
  291. package/src/plist.ts +27 -0
  292. package/src/poll-until.ts +123 -0
  293. package/src/profiles.ts +179 -0
  294. package/src/public-brand.ts +9 -0
  295. package/src/react-native-host-modules.ts +26 -0
  296. package/src/render-mode.ts +27 -0
  297. package/src/rolldown-plugin-swift.ts +240 -0
  298. package/src/runtime-assets.ts +233 -0
  299. package/src/runtime-delivery.ts +54 -0
  300. package/src/screenshots/compose.ts +422 -0
  301. package/src/screenshots/frame-compose.ts +514 -0
  302. package/src/screenshots/orchestrate.ts +256 -0
  303. package/src/screenshots/org-deck.ts +91 -0
  304. package/src/screenshots/registry.ts +58 -0
  305. package/src/screenshots/schema.ts +364 -0
  306. package/src/sdk.ts +15 -0
  307. package/src/settings-bundle.ts +418 -0
  308. package/src/shell-bridge-identity.ts +15 -0
  309. package/src/sim-client.ts +697 -0
  310. package/src/skills/builtin/a11y-review.ts +132 -0
  311. package/src/skills/builtin/compat-check.ts +71 -0
  312. package/src/skills/builtin/maestro-test.ts +121 -0
  313. package/src/skills/builtin/perf-profile.ts +90 -0
  314. package/src/skills/builtin/screenshot-all.ts +52 -0
  315. package/src/skills/builtin/visual-diff.ts +100 -0
  316. package/src/skills/registry.ts +107 -0
  317. package/src/skills/types.ts +41 -0
  318. package/src/swift-submit.ts +185 -0
  319. package/src/swift.ts +7 -0
  320. package/src/team-machine-prompt.ts +73 -0
  321. package/src/vite-plugin-one.ts +264 -0
  322. package/src/vite-plugin-swift.ts +620 -0
  323. package/src/vite-plugin.ts +1946 -0
  324. package/src/worklets-babel.ts +250 -0
@@ -0,0 +1,771 @@
1
+ // rnx mode three — toggle the live browser 3d device stage.
2
+ //
3
+ // wraps the existing `toggle-three-mode` shell action so CLI, rail, and
4
+ // menu all use the same plugin-owned state path.
5
+
6
+ import { createHash } from 'node:crypto'
7
+ import { mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs'
8
+ import { dirname, extname, resolve } from 'node:path'
9
+ import {
10
+ isThreeModeSetting,
11
+ THREE_MODE_SETTING_IDS,
12
+ } from 'sootsim-engine/screenshots/tokens'
13
+ import { rnxExit } from '../run-rnx'
14
+ import {
15
+ callInBridgeWrite,
16
+ createBridgeFromParsed,
17
+ parseBridgeCliArgs,
18
+ type WsBridge,
19
+ } from '../ws-bridge'
20
+ import { runShellBooleanMode, type ShellBooleanModeOptions } from './shell-boolean-mode'
21
+
22
+ type ThreeModeConfig = Record<string, unknown>
23
+ type ThreeModeState = Record<string, unknown> & {
24
+ ready?: boolean
25
+ disabled?: boolean
26
+ }
27
+
28
+ const THREE_MODE_READY_TIMEOUT_MS = 12_000
29
+ // a supersampled path-traced frame (soft-orbs export) legitimately spends
30
+ // minutes on shader compile + sample convergence on slower GPUs; raster
31
+ // timeline batches stay far under this either way.
32
+ const THREE_MODE_RENDER_TIMEOUT_MS = 600_000
33
+ const TIMELINE_ACTIONS = new Set(['load', 'seek', 'play', 'pause', 'render', 'measure'])
34
+
35
+ export async function runThreeMode(args: string[], opts: ShellBooleanModeOptions) {
36
+ if (args.includes('--help') || args.includes('-h')) {
37
+ console.log(`
38
+ rnx mode three — toggle the 3d device stage
39
+
40
+ usage:
41
+ rnx mode three [on|off|toggle]
42
+ rnx mode three configure [options]
43
+ rnx mode three load <timeline.json>
44
+ rnx mode three seek <time-ms>
45
+ rnx mode three play|pause
46
+ rnx mode three render <timeline.json> --output <dir> [options]
47
+ rnx mode three measure <timeline.json> [options]
48
+
49
+ arguments:
50
+ on enable 3d mode
51
+ off disable 3d mode
52
+ toggle flip the current state (default)
53
+
54
+ configure options:
55
+ --device <id> set the phone body: iphone-17-air | iphone-17-pro |
56
+ pixel-10 (a film authors its own via initial.device)
57
+ --background <id> set the 3d background
58
+ --gradient <stops> custom gradient bg: comma-separated css hex stops,
59
+ e.g. "#0a0e1a,#16243f,#05080f" (overrides --background;
60
+ evenly spaced, or use "color@offset" for explicit %)
61
+ --gradient-angle <deg> custom gradient angle (default 180 = top→bottom)
62
+ --colorway <id> set the phone colorway
63
+ --script <id> apply a camera preset pose
64
+ --progress <0..1> script progress to sample (default: 1)
65
+ --animate run the script instead of sampling one frame
66
+ --slow run the script slowly when --animate is set
67
+ --environment on|off enable or disable environment objects
68
+ --env-shape <id> orb | pill | diamond | pane
69
+ --env-spread <id> tight | medium | wide
70
+ --env-size <id> small | medium | large
71
+ --env-color <id> peach | gold | mint | sky | violet
72
+ --env-glow on|off enable or disable environment glow
73
+ --env-plastic on|off enable or disable environment plastic material
74
+ --setting <id> none | room | window-wall | white-backdrop |
75
+ cyclorama | terrain
76
+ --focus on|off enable or disable Focus depth of field
77
+ --focus-amount <id> subtle | medium | strong | intense | extreme
78
+ --focus-range <id> tight | balanced | wide
79
+ --reset-pose return to the default hero pose before other changes
80
+
81
+ render options:
82
+ --output <dir> write frame-%05d.png + timeline-render.json
83
+ --fps <number> frames per second (default: 30)
84
+ --width <px> exact output width (default: 1920)
85
+ --height <px> exact output height (default: 1080)
86
+ --from <ms> first timeline time (default: 0)
87
+ --to <ms> exclusive end time (default: timeline duration)
88
+ --overwrite replace this command's existing frames in the dir
89
+
90
+ measure options (projected bounds only — no pixels rendered):
91
+ --fps <number> sample rate (default: 30)
92
+ --width <px> output width the bounds project into (default: 1920)
93
+ --height <px> output height the bounds project into (default: 1080)
94
+ --from <ms> first timeline time (default: 0)
95
+ --to <ms> exclusive end time (default: timeline duration)
96
+ --count <n> sample n frames from --from at the --fps spacing
97
+ --at <ms[,ms...]> sample exact timeline times (overrides from/to/count)
98
+ --output <file.json> write the measurement JSON here instead of stdout
99
+
100
+ examples:
101
+ rnx mode three
102
+ rnx mode three on
103
+ rnx mode three configure --script hero-arc --background gradient-tide
104
+ rnx mode three configure --environment on --env-shape pane --setting room
105
+ rnx mode three render ./cinematic.json --output ./frames --fps 30
106
+ rnx mode three measure ./cinematic.json --width 450 --height 900 --at 0,1200
107
+ rnx mode three off
108
+ `)
109
+ rnxExit(0)
110
+ }
111
+
112
+ const timelineAction = findTimelineAction(args)
113
+ if (timelineAction) {
114
+ await runThreeModeTimelineAction(timelineAction, args, opts)
115
+ return
116
+ }
117
+
118
+ if (shouldConfigure(args)) {
119
+ await runThreeModeConfigure(args, opts)
120
+ return
121
+ }
122
+
123
+ const result = await runShellBooleanMode(args, opts, {
124
+ modeKey: 'threeMode',
125
+ displayName: 'three-mode',
126
+ actionId: 'toggle-three-mode',
127
+ })
128
+ if (result.target) {
129
+ await waitForThreeModeRuntimeReady(args, opts)
130
+ }
131
+ }
132
+
133
+ function shouldConfigure(args: string[]) {
134
+ return (
135
+ args[0] === 'configure' ||
136
+ args.some((arg) =>
137
+ [
138
+ '--device',
139
+ '--background',
140
+ '--gradient',
141
+ '--gradient-angle',
142
+ '--colorway',
143
+ '--script',
144
+ '--progress',
145
+ '--animate',
146
+ '--slow',
147
+ '--environment',
148
+ '--env-shape',
149
+ '--env-spread',
150
+ '--env-size',
151
+ '--env-color',
152
+ '--env-glow',
153
+ '--env-plastic',
154
+ '--setting',
155
+ '--focus',
156
+ '--focus-amount',
157
+ '--focus-range',
158
+ '--reset-pose',
159
+ ].includes(arg),
160
+ )
161
+ )
162
+ }
163
+
164
+ async function runThreeModeConfigure(args: string[], opts: ShellBooleanModeOptions) {
165
+ const parsed = parseBridgeCliArgs(args, {
166
+ port: opts.port,
167
+ stripBooleanFlags: ['--animate', '--slow', '--reset-pose'],
168
+ stripValueFlags: [
169
+ '--device',
170
+ '--background',
171
+ '--gradient',
172
+ '--gradient-angle',
173
+ '--colorway',
174
+ '--script',
175
+ '--progress',
176
+ '--environment',
177
+ '--env-shape',
178
+ '--env-spread',
179
+ '--env-size',
180
+ '--env-color',
181
+ '--env-glow',
182
+ '--env-plastic',
183
+ '--setting',
184
+ '--focus',
185
+ '--focus-amount',
186
+ '--focus-range',
187
+ ],
188
+ })
189
+ const options = buildConfigureOptions(args)
190
+ const bridge = createBridgeFromParsed({
191
+ ...parsed,
192
+ commandTimeoutMs: THREE_MODE_READY_TIMEOUT_MS + 1000,
193
+ })
194
+ try {
195
+ await waitForThreeModeRuntimeReadyOnBridge(bridge)
196
+ const state = await callInBridgeWrite<Record<string, unknown>>(
197
+ bridge,
198
+ 'SootSim.bridges.threeMode.configure',
199
+ options,
200
+ )
201
+ console.log(` three-mode: configured (${summarizeState(state)})`)
202
+ } finally {
203
+ bridge.close()
204
+ }
205
+ }
206
+
207
+ async function waitForThreeModeRuntimeReady(
208
+ args: string[],
209
+ opts: ShellBooleanModeOptions,
210
+ ) {
211
+ const parsed = parseBridgeCliArgs(args, { port: opts.port })
212
+ const bridge = createBridgeFromParsed({
213
+ ...parsed,
214
+ commandTimeoutMs: THREE_MODE_READY_TIMEOUT_MS + 1000,
215
+ })
216
+ try {
217
+ await waitForThreeModeRuntimeReadyOnBridge(bridge)
218
+ } finally {
219
+ bridge.close()
220
+ }
221
+ }
222
+
223
+ export async function waitForThreeModeRuntimeReadyOnBridge(bridge: WsBridge) {
224
+ const state = (await bridge.send({
225
+ type: 'evaluate',
226
+ code: `(async () => {
227
+ const deadline = Date.now() + ${THREE_MODE_READY_TIMEOUT_MS}
228
+ while (Date.now() < deadline) {
229
+ const settings = window.SootSim?.bridges?.settings?.get?.()
230
+ if (settings && settings.threeMode !== true) {
231
+ return { ready: false, disabled: true }
232
+ }
233
+ const bridge = window.SootSim?.bridges?.threeMode
234
+ const state = typeof bridge?.getState === 'function' ? bridge.getState() : null
235
+ if (state?.ready === true) return state
236
+ await new Promise((resolve) => setTimeout(resolve, 50))
237
+ }
238
+ const bridge = window.SootSim?.bridges?.threeMode
239
+ const state = typeof bridge?.getState === 'function' ? bridge.getState() : null
240
+ return state || { ready: false }
241
+ })()`,
242
+ })) as ThreeModeState | null
243
+
244
+ if (state?.ready === true) return state
245
+ if (state?.disabled) {
246
+ throw new Error('3d mode is off; run `rnx mode three on` before configure')
247
+ }
248
+ throw new Error('3d mode runtime did not become ready')
249
+ }
250
+
251
+ type TimelineSampleResult = {
252
+ durationMs: number
253
+ timeMs: number
254
+ shotId: string | null
255
+ }
256
+
257
+ type TimelineRenderedFrame = {
258
+ index: number
259
+ timeMs: number
260
+ width: number
261
+ height: number
262
+ dataUrl: string
263
+ alpha: {
264
+ min: number
265
+ max: number
266
+ transparentPixels: number
267
+ }
268
+ phoneCollision: {
269
+ hull: Array<{ x: number; y: number }>
270
+ }
271
+ }
272
+
273
+ type TimelineRenderedFrames = {
274
+ frames: TimelineRenderedFrame[]
275
+ }
276
+
277
+ type TimelineMeasuredFrame = {
278
+ index: number
279
+ timeMs: number
280
+ camera: {
281
+ position: [number, number, number]
282
+ target: [number, number, number]
283
+ fov: number
284
+ }
285
+ targets: Record<
286
+ string,
287
+ {
288
+ hull: Array<{ x: number; y: number }>
289
+ bounds: { left: number; right: number; top: number; bottom: number }
290
+ }
291
+ >
292
+ }
293
+
294
+ type TimelineMeasuredFrames = {
295
+ fps: number
296
+ from: number
297
+ to: number
298
+ width: number
299
+ height: number
300
+ frames: TimelineMeasuredFrame[]
301
+ }
302
+
303
+ function findTimelineAction(args: string[]): string | null {
304
+ for (let index = 0; index < args.length; index += 1) {
305
+ const arg = args[index]
306
+ if (arg === '--sim' || arg === '--port' || arg === '-p') {
307
+ index += 1
308
+ continue
309
+ }
310
+ if (arg.startsWith('-')) continue
311
+ return TIMELINE_ACTIONS.has(arg) ? arg : null
312
+ }
313
+ return null
314
+ }
315
+
316
+ function readFlagValue(args: string[], flag: string) {
317
+ const index = args.indexOf(flag)
318
+ return index >= 0 ? args[index + 1] : undefined
319
+ }
320
+
321
+ function readNumberFlag(args: string[], flag: string, fallback: number, minimum = 0) {
322
+ const raw = readFlagValue(args, flag)
323
+ if (raw === undefined) return fallback
324
+ const value = Number.parseFloat(raw)
325
+ if (!Number.isFinite(value) || value < minimum) {
326
+ throw new Error(`${flag} expects a number greater than or equal to ${minimum}`)
327
+ }
328
+ return value
329
+ }
330
+
331
+ function isRecord(value: unknown): value is Record<string, unknown> {
332
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
333
+ }
334
+
335
+ function mimeTypeForAsset(path: string, contentType: unknown) {
336
+ const extension = extname(path).toLowerCase()
337
+ if (contentType === 'recording') {
338
+ if (extension === '.mp4' || extension === '.m4v') return 'video/mp4'
339
+ return 'video/webm'
340
+ }
341
+ if (extension === '.jpg' || extension === '.jpeg') return 'image/jpeg'
342
+ if (extension === '.webp') return 'image/webp'
343
+ return 'image/png'
344
+ }
345
+
346
+ function inlineTimelineAssets(value: unknown, baseDirectory: string): unknown {
347
+ if (Array.isArray(value)) {
348
+ return value.map((entry) => inlineTimelineAssets(entry, baseDirectory))
349
+ }
350
+ if (!isRecord(value)) return value
351
+
352
+ const result: Record<string, unknown> = {}
353
+ for (const [key, entry] of Object.entries(value)) {
354
+ result[key] = inlineTimelineAssets(entry, baseDirectory)
355
+ }
356
+ if (
357
+ (result.type === 'recording' ||
358
+ result.type === 'screenshot' ||
359
+ result.kind === 'screen') &&
360
+ typeof result.src === 'string' &&
361
+ !/^(?:blob:|data:|https?:)/i.test(result.src)
362
+ ) {
363
+ const assetPath = resolve(baseDirectory, result.src)
364
+ const bytes = readFileSync(assetPath)
365
+ result.src = `data:${mimeTypeForAsset(assetPath, result.type === 'recording' ? 'recording' : 'screenshot')};base64,${bytes.toString('base64')}`
366
+ }
367
+ return result
368
+ }
369
+
370
+ function readTimelineFile(path: string) {
371
+ const absolutePath = resolve(path)
372
+ const parsed: unknown = JSON.parse(readFileSync(absolutePath, 'utf8'))
373
+ return {
374
+ absolutePath,
375
+ timeline: inlineTimelineAssets(parsed, dirname(absolutePath)),
376
+ }
377
+ }
378
+
379
+ async function ensureThreeModeRuntimeOnBridge(bridge: WsBridge) {
380
+ await callInBridgeWrite(bridge, 'SootSim.bridges.settings.set', 'threeMode', true)
381
+ await waitForThreeModeRuntimeReadyOnBridge(bridge)
382
+ }
383
+
384
+ async function runThreeModeTimelineAction(
385
+ action: string,
386
+ args: string[],
387
+ opts: ShellBooleanModeOptions,
388
+ ) {
389
+ const parsed = parseBridgeCliArgs(args, {
390
+ port: opts.port,
391
+ commandTimeoutMs: THREE_MODE_RENDER_TIMEOUT_MS,
392
+ stripBooleanFlags: ['--overwrite'],
393
+ stripValueFlags: [
394
+ '--output',
395
+ '--fps',
396
+ '--width',
397
+ '--height',
398
+ '--from',
399
+ '--to',
400
+ '--count',
401
+ '--at',
402
+ ],
403
+ })
404
+ const bridge = createBridgeFromParsed(parsed)
405
+ try {
406
+ await ensureThreeModeRuntimeOnBridge(bridge)
407
+ if (action === 'seek') {
408
+ const rawTime = parsed.positional[1]
409
+ const timeMs = rawTime === undefined ? Number.NaN : Number.parseFloat(rawTime)
410
+ if (!Number.isFinite(timeMs)) throw new Error('three-mode seek expects time in ms')
411
+ const sample = await callInBridgeWrite<TimelineSampleResult>(
412
+ bridge,
413
+ 'SootSim.bridges.threeMode.seek',
414
+ timeMs,
415
+ )
416
+ console.log(
417
+ ` three-mode: ${Math.round(sample.timeMs)}ms / ${Math.round(sample.durationMs)}ms (${sample.shotId ?? 'no shot'})`,
418
+ )
419
+ return
420
+ }
421
+ if (action === 'play' || action === 'pause') {
422
+ const state = await callInBridgeWrite<Record<string, unknown>>(
423
+ bridge,
424
+ `SootSim.bridges.threeMode.${action}`,
425
+ )
426
+ const timeline = isRecord(state.timeline) ? state.timeline : {}
427
+ console.log(
428
+ ` three-mode: ${action} (${Math.round(Number(timeline.timeMs ?? 0))}ms)`,
429
+ )
430
+ return
431
+ }
432
+
433
+ const timelinePath = parsed.positional[1]
434
+ if (!timelinePath)
435
+ throw new Error(`three-mode ${action} expects a timeline JSON path`)
436
+ const loadedFile = readTimelineFile(timelinePath)
437
+ const loaded = await callInBridgeWrite<TimelineSampleResult>(
438
+ bridge,
439
+ 'SootSim.bridges.threeMode.loadTimeline',
440
+ loadedFile.timeline,
441
+ )
442
+ if (action === 'load') {
443
+ console.log(
444
+ ` three-mode: loaded ${loadedFile.absolutePath} (${Math.round(loaded.durationMs)}ms)`,
445
+ )
446
+ return
447
+ }
448
+
449
+ if (action === 'measure') {
450
+ const fps = readNumberFlag(args, '--fps', 30, 0.001)
451
+ const width = Math.round(readNumberFlag(args, '--width', 1_920, 1))
452
+ const height = Math.round(readNumberFlag(args, '--height', 1_080, 1))
453
+ const atValue = readFlagValue(args, '--at')
454
+ let measured: TimelineMeasuredFrames
455
+ if (atValue !== undefined) {
456
+ const times = atValue.split(',').map((token) => {
457
+ const value = Number.parseFloat(token)
458
+ if (!Number.isFinite(value) || value < 0) {
459
+ throw new Error(`--at expects comma-separated times in ms, got "${token}"`)
460
+ }
461
+ return Math.min(value, loaded.durationMs)
462
+ })
463
+ const frames: TimelineMeasuredFrame[] = []
464
+ for (const timeMs of times) {
465
+ const single = await callInBridgeWrite<TimelineMeasuredFrames>(
466
+ bridge,
467
+ 'SootSim.bridges.threeMode.measure',
468
+ { fps, width, height, from: timeMs, count: 1 },
469
+ )
470
+ frames.push({ ...single.frames[0], index: frames.length })
471
+ }
472
+ measured = {
473
+ fps,
474
+ from: times[0],
475
+ to: times[times.length - 1],
476
+ width,
477
+ height,
478
+ frames,
479
+ }
480
+ } else {
481
+ const from = readNumberFlag(args, '--from', 0, 0)
482
+ const countValue = readFlagValue(args, '--count')
483
+ const options: {
484
+ fps: number
485
+ width: number
486
+ height: number
487
+ from: number
488
+ to?: number
489
+ count?: number
490
+ } = { fps, width, height, from }
491
+ if (countValue === undefined) {
492
+ const to = readNumberFlag(args, '--to', loaded.durationMs, 0)
493
+ if (to <= from || to > loaded.durationMs) {
494
+ throw new Error(
495
+ `--to must be greater than --from and at most ${loaded.durationMs}`,
496
+ )
497
+ }
498
+ options.to = to
499
+ } else {
500
+ options.count = Math.round(readNumberFlag(args, '--count', 1, 1))
501
+ }
502
+ measured = await callInBridgeWrite<TimelineMeasuredFrames>(
503
+ bridge,
504
+ 'SootSim.bridges.threeMode.measure',
505
+ options,
506
+ )
507
+ }
508
+ const json = `${JSON.stringify(measured, null, 2)}\n`
509
+ const outputValue = readFlagValue(args, '--output')
510
+ if (outputValue) {
511
+ const outputPath = resolve(outputValue)
512
+ mkdirSync(dirname(outputPath), { recursive: true })
513
+ writeFileSync(outputPath, json)
514
+ // stderr keeps a caller piping stdout (phone-film bounds) JSON-clean.
515
+ process.stderr.write(
516
+ ` three-mode: measured ${measured.frames.length} frames → ${outputPath}\n`,
517
+ )
518
+ } else {
519
+ process.stdout.write(json)
520
+ }
521
+ return
522
+ }
523
+
524
+ const outputValue = readFlagValue(args, '--output')
525
+ if (!outputValue) throw new Error('three-mode render requires --output <dir>')
526
+ const outputDirectory = resolve(outputValue)
527
+ const fps = readNumberFlag(args, '--fps', 30, 0.001)
528
+ const width = Math.round(readNumberFlag(args, '--width', 1_920, 1))
529
+ const height = Math.round(readNumberFlag(args, '--height', 1_080, 1))
530
+ const from = readNumberFlag(args, '--from', 0, 0)
531
+ const to = readNumberFlag(args, '--to', loaded.durationMs, 0)
532
+ if (to <= from || to > loaded.durationMs) {
533
+ throw new Error(`--to must be greater than --from and at most ${loaded.durationMs}`)
534
+ }
535
+ const frameDurationMs = 1_000 / fps
536
+ const frameCount = Math.ceil((to - from) / frameDurationMs)
537
+ mkdirSync(outputDirectory, { recursive: true })
538
+ const ownedOutputs = readdirSync(outputDirectory).filter(
539
+ (name) => /^frame-\d{5}\.png$/.test(name) || name === 'timeline-render.json',
540
+ )
541
+ if (ownedOutputs.length > 0 && !args.includes('--overwrite')) {
542
+ throw new Error(
543
+ `${outputDirectory} already has timeline frames; pass --overwrite to replace them`,
544
+ )
545
+ }
546
+ for (const name of ownedOutputs) unlinkSync(resolve(outputDirectory, name))
547
+
548
+ const manifestFrames: Array<{
549
+ file: string
550
+ timeMs: number
551
+ sha256: string
552
+ alpha: TimelineRenderedFrame['alpha']
553
+ phoneCollision: TimelineRenderedFrame['phoneCollision']
554
+ }> = []
555
+ const batchSize = 4
556
+ for (let frameIndex = 0; frameIndex < frameCount; frameIndex += batchSize) {
557
+ const count = Math.min(batchSize, frameCount - frameIndex)
558
+ const batchFrom = from + frameIndex * frameDurationMs
559
+ const batchTo = Math.min(to, batchFrom + count * frameDurationMs)
560
+ const rendered = await callInBridgeWrite<TimelineRenderedFrames>(
561
+ bridge,
562
+ 'SootSim.bridges.threeMode.renderFrames',
563
+ { fps, from: batchFrom, to: batchTo, width, height, count },
564
+ )
565
+ if (rendered.frames.length !== count) {
566
+ throw new Error(
567
+ `3d renderer returned ${rendered.frames.length} frames for a ${count}-frame batch`,
568
+ )
569
+ }
570
+ for (const frame of rendered.frames) {
571
+ const globalIndex = frameIndex + frame.index
572
+ const file = `frame-${String(globalIndex).padStart(5, '0')}.png`
573
+ const separator = frame.dataUrl.indexOf(',')
574
+ if (
575
+ separator < 0 ||
576
+ !frame.dataUrl.slice(0, separator).includes('image/png;base64')
577
+ ) {
578
+ throw new Error(`3d renderer returned a non-PNG frame at ${frame.timeMs}ms`)
579
+ }
580
+ const bytes = Buffer.from(frame.dataUrl.slice(separator + 1), 'base64')
581
+ writeFileSync(resolve(outputDirectory, file), bytes)
582
+ manifestFrames.push({
583
+ file,
584
+ timeMs: frame.timeMs,
585
+ sha256: createHash('sha256').update(bytes).digest('hex'),
586
+ alpha: frame.alpha,
587
+ phoneCollision: frame.phoneCollision,
588
+ })
589
+ }
590
+ process.stderr.write(
591
+ `\r three-mode: rendered ${Math.min(frameIndex + count, frameCount)}/${frameCount}`,
592
+ )
593
+ }
594
+ process.stderr.write('\n')
595
+
596
+ const manifest = {
597
+ version: 2,
598
+ timeline: loadedFile.absolutePath,
599
+ fps,
600
+ from,
601
+ to,
602
+ width,
603
+ height,
604
+ frameCount,
605
+ transparent: manifestFrames.every((frame) => frame.alpha.transparentPixels > 0),
606
+ frames: manifestFrames,
607
+ }
608
+ writeFileSync(
609
+ resolve(outputDirectory, 'timeline-render.json'),
610
+ `${JSON.stringify(manifest, null, 2)}\n`,
611
+ )
612
+ console.log(
613
+ ` three-mode: wrote ${frameCount} deterministic RGBA frames to ${outputDirectory}`,
614
+ )
615
+ } finally {
616
+ bridge.close()
617
+ }
618
+ }
619
+
620
+ export function buildConfigureOptions(args: string[]): ThreeModeConfig {
621
+ const value = (flag: string) => args.find((_, i) => args[i - 1] === flag)
622
+ const options: ThreeModeConfig = {}
623
+
624
+ // the body itself. a timeline authors this through initial.device; this
625
+ // flag is for driving the live stage by hand.
626
+ const device = value('--device')
627
+ if (device) {
628
+ if (
629
+ device !== 'iphone-17-air' &&
630
+ device !== 'iphone-17-pro' &&
631
+ device !== 'pixel-10'
632
+ ) {
633
+ throw new Error(
634
+ `--device expects iphone-17-air, iphone-17-pro, or pixel-10, got "${device}"`,
635
+ )
636
+ }
637
+ options.device = device
638
+ }
639
+
640
+ const colorway = value('--colorway')
641
+ if (colorway) options.colorway = colorway
642
+
643
+ const background = value('--background')
644
+ if (background) options.background = background
645
+
646
+ // custom gradient: "color[@offset],color[@offset],…". offsets are
647
+ // 0..1 or 0..100 (auto-detected); omitted offsets spread evenly.
648
+ const gradient = value('--gradient')
649
+ if (gradient) {
650
+ const raw = gradient
651
+ .split(',')
652
+ .map((s) => s.trim())
653
+ .filter(Boolean)
654
+ const stops = raw.map((token, i) => {
655
+ const [color, offsetStr] = token.split('@')
656
+ let offset =
657
+ offsetStr !== undefined
658
+ ? Number.parseFloat(offsetStr)
659
+ : raw.length > 1
660
+ ? i / (raw.length - 1)
661
+ : 0
662
+ if (offset > 1) offset = offset / 100
663
+ if (!Number.isFinite(offset)) offset = raw.length > 1 ? i / (raw.length - 1) : 0
664
+ return { color: color.trim(), offset }
665
+ })
666
+ const angleStr = value('--gradient-angle')
667
+ const angleDeg = angleStr === undefined ? undefined : Number.parseFloat(angleStr)
668
+ if (angleStr !== undefined && !Number.isFinite(angleDeg)) {
669
+ throw new Error(`--gradient-angle expects a number, got "${angleStr}"`)
670
+ }
671
+ options.customGradient = { stops, ...(angleDeg !== undefined ? { angleDeg } : {}) }
672
+ }
673
+
674
+ const setting = value('--setting')
675
+ if (setting) {
676
+ if (!isThreeModeSetting(setting)) {
677
+ throw new Error(
678
+ `--setting expects ${THREE_MODE_SETTING_IDS.join(', ')}, got "${setting}"`,
679
+ )
680
+ }
681
+ options.setting = setting
682
+ }
683
+
684
+ const focus: Record<string, unknown> = {}
685
+ const focusEnabled = parseBooleanValue(value('--focus'), '--focus')
686
+ if (focusEnabled !== undefined) focus.enabled = focusEnabled
687
+ for (const [flag, key] of [
688
+ ['--focus-amount', 'amount'],
689
+ ['--focus-range', 'range'],
690
+ ] as const) {
691
+ const setting = value(flag)
692
+ if (setting) focus[key] = setting
693
+ }
694
+ if (Object.keys(focus).length > 0) options.focus = focus
695
+
696
+ if (args.includes('--reset-pose')) options.resetPose = true
697
+
698
+ const environment = parseBooleanValue(value('--environment'), '--environment')
699
+ const pins: Record<string, string> = {}
700
+ const effects: Record<string, boolean> = {}
701
+ for (const [flag, key] of [
702
+ ['--env-shape', 'shape'],
703
+ ['--env-spread', 'spread'],
704
+ ['--env-size', 'size'],
705
+ ['--env-color', 'color'],
706
+ ] as const) {
707
+ const pin = value(flag)
708
+ if (pin) pins[key] = pin
709
+ }
710
+ const glow = parseBooleanValue(value('--env-glow'), '--env-glow')
711
+ if (glow !== undefined) effects.glow = glow
712
+ const plastic = parseBooleanValue(value('--env-plastic'), '--env-plastic')
713
+ if (plastic !== undefined) effects.plastic = plastic
714
+
715
+ if (
716
+ environment !== undefined ||
717
+ Object.keys(pins).length > 0 ||
718
+ Object.keys(effects).length > 0
719
+ ) {
720
+ options.environment =
721
+ environment === false
722
+ ? false
723
+ : {
724
+ pins,
725
+ effects,
726
+ }
727
+ }
728
+
729
+ const scriptId = value('--script')
730
+ if (scriptId) {
731
+ const progressValue = value('--progress')
732
+ const progress = progressValue === undefined ? 1 : Number.parseFloat(progressValue)
733
+ if (!Number.isFinite(progress)) {
734
+ throw new Error(`--progress expects a number from 0 to 1, got "${progressValue}"`)
735
+ }
736
+ options.script = {
737
+ id: scriptId,
738
+ progress,
739
+ animate: args.includes('--animate'),
740
+ slow: args.includes('--slow'),
741
+ }
742
+ }
743
+
744
+ return options
745
+ }
746
+
747
+ function parseBooleanValue(value: string | undefined, flag: string): boolean | undefined {
748
+ if (value === undefined) return undefined
749
+ const normalized = value.toLowerCase()
750
+ if (['on', 'true', 'yes', '1'].includes(normalized)) return true
751
+ if (['off', 'false', 'no', '0'].includes(normalized)) return false
752
+ throw new Error(`${flag} expects on or off, got "${value}"`)
753
+ }
754
+
755
+ function summarizeState(state: Record<string, unknown>) {
756
+ const parts = [
757
+ `ready=${Boolean(state.ready)}`,
758
+ `background=${String(state.background ?? 'unknown')}`,
759
+ `colorway=${String(state.colorway ?? 'unknown')}`,
760
+ `environment=${state.environmentActive ? 'on' : 'off'}`,
761
+ `setting=${String(state.setting ?? 'none')}`,
762
+ `focus=${
763
+ state.focus &&
764
+ typeof state.focus === 'object' &&
765
+ (state.focus as { enabled?: boolean }).enabled
766
+ ? 'on'
767
+ : 'off'
768
+ }`,
769
+ ]
770
+ return parts.join(', ')
771
+ }