@dailies-studio/shot-editor 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (432) hide show
  1. package/dist/.vite/manifest.json +382 -0
  2. package/dist/assets/AgentPanel-CQejLbiX.js +2 -0
  3. package/dist/assets/AgentPanel-DJEphah0.css +1 -0
  4. package/dist/assets/AgentQuestionDialog-BqkD0kXF.js +1 -0
  5. package/dist/assets/CodeStudio-Dv4ihHpz.js +23 -0
  6. package/dist/assets/CommentsPanel-B4yhhT1T.js +1 -0
  7. package/dist/assets/ComponentLibraryRail-Ch_dsAru.js +1 -0
  8. package/dist/assets/ComponentStage-BRhRotyH.js +1 -0
  9. package/dist/assets/CurvePane-BjFVabKX.css +1 -0
  10. package/dist/assets/CurvePane-R22WzllJ.js +1 -0
  11. package/dist/assets/ExportDialog-CWLQWTmG.js +2 -0
  12. package/dist/assets/FontPicker-DWKaYL7F.js +1 -0
  13. package/dist/assets/FontPicker-v0IOIArM.css +1 -0
  14. package/dist/assets/GenerateDrawer-Bh3SqDKt.js +2 -0
  15. package/dist/assets/InsertDrawer-Ce1fod1L.js +1 -0
  16. package/dist/assets/LibraryBrowser-D9sEGz5w.js +2 -0
  17. package/dist/assets/MakeItYoursSection-CMO4319_.js +2 -0
  18. package/dist/assets/MaterialGraphCanvas-BFzl4wWR.js +23 -0
  19. package/dist/assets/MaterialGraphCanvas-BIUD59ik.css +1 -0
  20. package/dist/assets/MaterialWorkspace-CqTWqaRA.js +14 -0
  21. package/dist/assets/PreferencesDialog-CBf9_OoD.js +1 -0
  22. package/dist/assets/PresetsSection-CUEaoCVU.js +1 -0
  23. package/dist/assets/SoundHistory-BeS8EW1h.js +1 -0
  24. package/dist/assets/SoundMode-BmcZru3i.js +3 -0
  25. package/dist/assets/StockSounds-DdAUGRYK.js +1 -0
  26. package/dist/assets/TemplateFieldSection-CaeXpLFo.js +1 -0
  27. package/dist/assets/VersionHistoryPanel-CxG6XhNP.js +1 -0
  28. package/dist/assets/board-B88EPuLO.css +1 -0
  29. package/dist/assets/board-CL-he7SL.js +3 -0
  30. package/dist/assets/components-0oOSPN0l.js +1 -0
  31. package/dist/assets/googleFontRanges-gp32wJMo.js +1 -0
  32. package/dist/assets/googleFonts-BNon6M7X.js +1 -0
  33. package/dist/assets/iconsAndAliases-DL7gXP5j.js +1863 -0
  34. package/dist/assets/index-4F5NQmI8.css +1 -0
  35. package/dist/assets/index-BGHQGV5z.js +117 -0
  36. package/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  37. package/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  38. package/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  39. package/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  40. package/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  41. package/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  42. package/dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  43. package/dist/assets/libraryModel-CpIwfFha.js +1 -0
  44. package/dist/assets/lucide-6iK64tTB.js +9319 -0
  45. package/dist/assets/motionStamp-CH-Hew4E.js +1 -0
  46. package/dist/assets/takeMeasure-Tdg0TxEi.js +1 -0
  47. package/dist/assets/webmcp-BsB9gxWC.js +2 -0
  48. package/dist/index.html +34 -3
  49. package/package.json +12 -9
  50. package/src/addFamilies.ts +66 -0
  51. package/src/api.ts +1631 -55
  52. package/src/app/App.tsx +977 -198
  53. package/src/app/lazyPanel.tsx +137 -0
  54. package/src/app/lazySurfaces.tsx +203 -0
  55. package/src/audio/index.tsx +41 -10
  56. package/src/audio/meter.ts +485 -0
  57. package/src/audio/meterMath.ts +273 -0
  58. package/src/audio/meterStore.ts +155 -0
  59. package/src/audio/offlineMix.ts +289 -0
  60. package/src/audio/preview.ts +928 -118
  61. package/src/bridge.ts +15 -5
  62. package/src/bridgeTypes.ts +1611 -36
  63. package/src/channels.ts +17 -32
  64. package/src/fonts/googleFonts.ts +4 -0
  65. package/src/hostApi.ts +174 -0
  66. package/src/icons.tsx +770 -0
  67. package/src/layerWords.ts +61 -0
  68. package/src/main.tsx +56 -4
  69. package/src/panels/ResizeHandle.tsx +33 -43
  70. package/src/panels/agent/AgentDots.tsx +36 -0
  71. package/src/panels/agent/AgentPanel.tsx +119 -43
  72. package/src/panels/agent/AgentQuestionDialog.tsx +202 -0
  73. package/src/panels/agent/agent.css +113 -97
  74. package/src/panels/agent/agentGate.ts +35 -0
  75. package/src/panels/code/CodeDefaultsPanel.tsx +345 -0
  76. package/src/panels/code/CodeStudio.tsx +373 -0
  77. package/src/panels/code/FileTree.tsx +265 -0
  78. package/src/panels/code/codeBufferGuard.ts +52 -0
  79. package/src/panels/code/codeParts.tsx +321 -0
  80. package/src/panels/comments/CommentComposer.tsx +239 -0
  81. package/src/panels/comments/CommentThread.tsx +346 -0
  82. package/src/panels/comments/CommentsPanel.tsx +336 -0
  83. package/src/panels/comments/InboxButton.tsx +131 -0
  84. package/src/panels/comments/MentionMenu.tsx +98 -0
  85. package/src/panels/comments/parts.tsx +171 -0
  86. package/src/panels/export/ExportDialog.tsx +919 -96
  87. package/src/panels/export/ExportScrim.tsx +57 -0
  88. package/src/panels/files/AssetsTab.tsx +656 -0
  89. package/src/panels/files/FilePanel.tsx +1466 -216
  90. package/src/panels/files/LayerMarks.tsx +40 -0
  91. package/src/panels/files/LeftColumn.tsx +37 -0
  92. package/src/panels/files/McpSection.tsx +690 -47
  93. package/src/panels/files/MissingAssetsNotice.tsx +60 -0
  94. package/src/panels/files/assetDrag.ts +79 -0
  95. package/src/panels/files/resourceParts.tsx +255 -0
  96. package/src/panels/fonts/FontPicker.tsx +762 -0
  97. package/src/panels/fonts/faceCache.ts +207 -0
  98. package/src/panels/fonts/fontPicker.css +271 -0
  99. package/src/panels/fonts/pickerModel.ts +265 -0
  100. package/src/panels/fonts/pickerRequest.ts +46 -0
  101. package/src/panels/header/AccountAvatar.tsx +76 -0
  102. package/src/panels/header/AgentChip.tsx +77 -0
  103. package/src/panels/header/AppMenu.tsx +213 -0
  104. package/src/panels/header/Header.tsx +564 -84
  105. package/src/panels/header/HistoryControls.tsx +31 -5
  106. package/src/panels/header/PresenceStack.tsx +329 -0
  107. package/src/panels/header/ProjectTitle.tsx +232 -0
  108. package/src/panels/header/ShareButton.tsx +59 -0
  109. package/src/panels/header/StatusBadges.tsx +358 -27
  110. package/src/panels/header/SyncDeletedDialog.tsx +88 -0
  111. package/src/panels/header/ZoomControls.tsx +46 -15
  112. package/src/panels/header/agentHandoff.ts +52 -0
  113. package/src/panels/header/leave.ts +26 -0
  114. package/src/panels/help/HelpSection.tsx +189 -163
  115. package/src/panels/insert/InsertDrawer.tsx +723 -0
  116. package/src/panels/insert/iconSet.ts +109 -0
  117. package/src/panels/inspector/ExposeDot.tsx +117 -0
  118. package/src/panels/inspector/FramingRows.tsx +314 -44
  119. package/src/panels/inspector/InspectorFooter.tsx +91 -0
  120. package/src/panels/inspector/InspectorHead.tsx +425 -0
  121. package/src/panels/inspector/InspectorPanel.tsx +408 -124
  122. package/src/panels/inspector/KeyframeDot.tsx +373 -0
  123. package/src/panels/inspector/KeyframeSection.tsx +257 -0
  124. package/src/panels/inspector/MarkerSection.tsx +55 -11
  125. package/src/panels/inspector/ParamRow.tsx +84 -53
  126. package/src/panels/inspector/RightColumn.tsx +152 -0
  127. package/src/panels/inspector/TimeSection.tsx +866 -0
  128. package/src/panels/inspector/animate/EaseControl.tsx +244 -0
  129. package/src/panels/inspector/animate/PresetsSection.tsx +135 -0
  130. package/src/panels/inspector/animate/ShotSection.tsx +562 -48
  131. package/src/panels/inspector/design/AlignRow.tsx +95 -31
  132. package/src/panels/inspector/design/AppearanceSection.tsx +118 -9
  133. package/src/panels/inspector/design/ClonerSection.tsx +323 -62
  134. package/src/panels/inspector/design/ComponentSection.tsx +50 -43
  135. package/src/panels/inspector/design/CornerSection.tsx +45 -14
  136. package/src/panels/inspector/design/DefinitionProps.tsx +315 -0
  137. package/src/panels/inspector/design/DefinitionSection.tsx +136 -0
  138. package/src/panels/inspector/design/DeviceSection.tsx +210 -0
  139. package/src/panels/inspector/design/FillSection.tsx +83 -51
  140. package/src/panels/inspector/design/FontField.tsx +196 -0
  141. package/src/panels/inspector/design/HtmlSection.tsx +23 -12
  142. package/src/panels/inspector/design/InstanceSection.tsx +373 -154
  143. package/src/panels/inspector/design/MaskSection.tsx +84 -0
  144. package/src/panels/inspector/design/MaterialSection.tsx +177 -0
  145. package/src/panels/inspector/design/MediaSection.tsx +273 -120
  146. package/src/panels/inspector/design/MeshSection.tsx +910 -82
  147. package/src/panels/inspector/design/PaintRows.tsx +550 -0
  148. package/src/panels/inspector/design/PlateStyleSection.tsx +177 -0
  149. package/src/panels/inspector/design/ShadowSection.tsx +221 -48
  150. package/src/panels/inspector/design/SplineFollowSection.tsx +235 -0
  151. package/src/panels/inspector/design/StrokeSection.tsx +228 -43
  152. package/src/panels/inspector/design/TemplateFieldSection.tsx +57 -0
  153. package/src/panels/inspector/design/TransformSection.tsx +244 -50
  154. package/src/panels/inspector/design/TypographySection.tsx +679 -110
  155. package/src/panels/inspector/layout.ts +295 -0
  156. package/src/panels/inspector/propValue.ts +199 -0
  157. package/src/panels/inspector/scene/CameraSection.tsx +232 -22
  158. package/src/panels/inspector/scene/EffectsSection.tsx +722 -71
  159. package/src/panels/inspector/scene/MakeItYoursSection.tsx +180 -0
  160. package/src/panels/inspector/scene/SceneDocSection.tsx +191 -0
  161. package/src/panels/inspector/scene/SceneSection.tsx +219 -44
  162. package/src/panels/inspector/scene/TransitionSection.tsx +523 -72
  163. package/src/panels/inspector/storyboard/StoryboardReferenceSection.tsx +593 -0
  164. package/src/panels/inspector/storyboard/VideoStill.tsx +120 -0
  165. package/src/panels/inspector/timeModel.ts +190 -0
  166. package/src/panels/inspector/useDocWrite.ts +438 -146
  167. package/src/panels/library/LibraryBrowser.tsx +435 -0
  168. package/src/panels/library/LibraryGrid.tsx +190 -0
  169. package/src/panels/library/LibraryPreviewPanel.tsx +349 -0
  170. package/src/panels/library/LibraryRail.tsx +84 -0
  171. package/src/panels/library/LibrarySoundPanel.tsx +293 -0
  172. package/src/panels/library/SoundHistory.tsx +245 -0
  173. package/src/panels/library/StockSounds.tsx +390 -0
  174. package/src/panels/library/libraryModel.ts +147 -0
  175. package/src/panels/library/stockSearch.ts +77 -0
  176. package/src/panels/maskMarks.tsx +117 -0
  177. package/src/panels/material/MaterialCommentNode.tsx +54 -0
  178. package/src/panels/material/MaterialGraphCanvas.tsx +861 -0
  179. package/src/panels/material/MaterialGraphMenu.tsx +45 -0
  180. package/src/panels/material/MaterialGroupNode.tsx +56 -0
  181. package/src/panels/material/MaterialLibrary.tsx +253 -0
  182. package/src/panels/material/MaterialNode.tsx +453 -0
  183. package/src/panels/material/MaterialNodePalette.tsx +349 -0
  184. package/src/panels/material/MaterialParameterControls.tsx +525 -0
  185. package/src/panels/material/MaterialRerouteNode.tsx +42 -0
  186. package/src/panels/material/MaterialWorkspace.tsx +501 -0
  187. package/src/panels/material/NodePreview.tsx +70 -0
  188. package/src/panels/material/flowModel.ts +441 -0
  189. package/src/panels/material/parameterValues.ts +231 -0
  190. package/src/panels/material/previewScheduler.ts +235 -0
  191. package/src/panels/material/reactFlow.css +6 -0
  192. package/src/panels/material/socketGlyph.ts +85 -0
  193. package/src/panels/material/viewportMemory.ts +58 -0
  194. package/src/panels/preferences/PreferencesDialog.tsx +215 -0
  195. package/src/panels/share/ShareComponentDialog.tsx +247 -0
  196. package/src/panels/stage/StageMenu.tsx +35 -5
  197. package/src/panels/stage/StagePanel.tsx +338 -23
  198. package/src/panels/stage/cellSlots.tsx +70 -0
  199. package/src/panels/storyboard/BoardCanvas.tsx +739 -0
  200. package/src/panels/storyboard/BoardCursors.tsx +158 -0
  201. package/src/panels/storyboard/BoardDropDialog.tsx +161 -0
  202. package/src/panels/storyboard/BoardPlan.tsx +42 -0
  203. package/src/panels/storyboard/ComponentSheet.tsx +187 -0
  204. package/src/panels/storyboard/ComponentStage.tsx +235 -0
  205. package/src/panels/storyboard/MiniSketch.tsx +173 -0
  206. package/src/panels/storyboard/PartPicker.tsx +64 -0
  207. package/src/panels/storyboard/PlanPanel.tsx +617 -0
  208. package/src/panels/storyboard/ReferencePreview.tsx +217 -0
  209. package/src/panels/storyboard/ShotBrief.tsx +360 -0
  210. package/src/panels/storyboard/ShotCard.tsx +251 -0
  211. package/src/panels/storyboard/StagePreview.tsx +348 -0
  212. package/src/panels/storyboard/StillThumbs.tsx +162 -0
  213. package/src/panels/storyboard/StoryboardView.tsx +79 -0
  214. package/src/panels/storyboard/board.css +1065 -0
  215. package/src/panels/storyboard/board.ts +12 -0
  216. package/src/panels/storyboard/boardClipboard.ts +111 -0
  217. package/src/panels/storyboard/boardOrder.ts +68 -0
  218. package/src/panels/storyboard/boardRows.tsx +425 -0
  219. package/src/panels/storyboard/liveCap.ts +90 -0
  220. package/src/panels/storyboard/plan.css +295 -0
  221. package/src/panels/storyboard/posterCache.ts +133 -0
  222. package/src/panels/storyboard/shotboard/ComponentPicker.tsx +373 -0
  223. package/src/panels/storyboard/shotboard/ImagePicker.tsx +145 -0
  224. package/src/panels/storyboard/shotboard/ShotBoard.tsx +292 -0
  225. package/src/panels/storyboard/shotboard/SketchCanvas.tsx +443 -0
  226. package/src/panels/storyboard/shotboard/SketchInspector.tsx +471 -0
  227. package/src/panels/storyboard/shotboard/SketchItem.tsx +240 -0
  228. package/src/panels/storyboard/shotboard/SketchLayers.tsx +130 -0
  229. package/src/panels/storyboard/shotboard/SketchPlay.tsx +108 -0
  230. package/src/panels/storyboard/shotboard/SketchStrip.tsx +178 -0
  231. package/src/panels/storyboard/shotboard/SketchToolFooter.tsx +99 -0
  232. package/src/panels/storyboard/shotboard/sketch.css +1392 -0
  233. package/src/panels/storyboard/shotboard/sketchModel.ts +219 -0
  234. package/src/panels/storyboard/storyboardDrop.ts +164 -0
  235. package/src/panels/studio/ComponentLibraryRail.tsx +346 -0
  236. package/src/panels/studio/ComponentSidePanel.tsx +355 -0
  237. package/src/panels/studio/ComponentStudio.tsx +243 -0
  238. package/src/panels/studio/ComponentsHome.tsx +318 -0
  239. package/src/panels/studio/ComponentsPreview.tsx +179 -0
  240. package/src/panels/studio/EveryUse.tsx +244 -0
  241. package/src/panels/studio/ThisUsePanel.tsx +679 -0
  242. package/src/panels/studio/componentParts.tsx +341 -0
  243. package/src/panels/studio/components.ts +6 -0
  244. package/src/panels/studio/newCodeComponent.ts +66 -0
  245. package/src/panels/timeline/AudioFxButton.tsx +290 -0
  246. package/src/panels/timeline/AudioLane.tsx +674 -85
  247. package/src/panels/timeline/BlockRow.tsx +154 -85
  248. package/src/panels/timeline/ChannelRow.tsx +378 -129
  249. package/src/panels/timeline/CurvePane.tsx +508 -0
  250. package/src/panels/timeline/Filmstrip.tsx +361 -0
  251. package/src/panels/timeline/GenerateDrawer.tsx +407 -0
  252. package/src/panels/timeline/KeyStretch.tsx +115 -0
  253. package/src/panels/timeline/LifespanRow.tsx +730 -47
  254. package/src/panels/timeline/MarkerLane.tsx +46 -8
  255. package/src/panels/timeline/PlayButton.tsx +91 -0
  256. package/src/panels/timeline/PropertyPicker.tsx +254 -0
  257. package/src/panels/timeline/ShotRow.tsx +400 -0
  258. package/src/panels/timeline/SoundMode.tsx +1406 -0
  259. package/src/panels/timeline/TimelineDock.tsx +2338 -272
  260. package/src/panels/timeline/TransportVolume.tsx +163 -0
  261. package/src/panels/timeline/beatRuler.ts +74 -0
  262. package/src/panels/timeline/curveModel.ts +500 -0
  263. package/src/panels/timeline/duckUnderVoice.ts +68 -0
  264. package/src/panels/timeline/keyframeMenu.ts +138 -0
  265. package/src/panels/timeline/labelWidth.ts +49 -0
  266. package/src/panels/timeline/marquee.ts +155 -0
  267. package/src/panels/timeline/model.ts +1860 -199
  268. package/src/panels/timeline/snap.ts +125 -43
  269. package/src/panels/timeline/staggerPreview.tsx +92 -0
  270. package/src/panels/timeline/takeMeasure.ts +72 -0
  271. package/src/panels/timeline/useLive.ts +48 -0
  272. package/src/panels/timeline/viewport.ts +234 -0
  273. package/src/panels/toolbar/BackChip.tsx +116 -0
  274. package/src/panels/toolbar/CameraViewBar.tsx +118 -49
  275. package/src/panels/toolbar/CanvasBar.tsx +117 -0
  276. package/src/panels/toolbar/InsertButton.tsx +38 -0
  277. package/src/panels/toolbar/IsolationTrail.tsx +156 -0
  278. package/src/panels/toolbar/PanelToggles.tsx +86 -0
  279. package/src/panels/toolbar/ShadingBar.tsx +119 -26
  280. package/src/panels/toolbar/ToolDockBar.tsx +329 -80
  281. package/src/panels/toolbar/ToolFooter.tsx +102 -0
  282. package/src/panels/toolbar/ViewModeBar.tsx +247 -38
  283. package/src/panels/toolbar/WorkPlaneBar.tsx +94 -0
  284. package/src/panels/toolbar/useAddRows.tsx +326 -0
  285. package/src/panels/toolbar/useDismiss.ts +122 -0
  286. package/src/panels/versions/VersionHistoryPanel.tsx +274 -0
  287. package/src/panels/versions/versionModel.ts +103 -0
  288. package/src/panels/virtualWindow.ts +271 -0
  289. package/src/shotTokens.css +151 -60
  290. package/src/stage/FrustumGizmo.tsx +504 -84
  291. package/src/stage/GridPlane.tsx +225 -16
  292. package/src/stage/NodePreviews.tsx +628 -0
  293. package/src/stage/PerfHud.tsx +437 -47
  294. package/src/stage/SplitRenderer.tsx +816 -169
  295. package/src/stage/StageApp.tsx +4274 -486
  296. package/src/stage/StageRootBoundary.tsx +114 -0
  297. package/src/stage/StageTools.tsx +651 -151
  298. package/src/stage/TransformGizmo.tsx +696 -0
  299. package/src/stage/adaptivePresentation.ts +145 -0
  300. package/src/stage/bridgeClient.ts +2522 -41
  301. package/src/stage/exportWorker.ts +9 -0
  302. package/src/stage/feed.ts +305 -14
  303. package/src/stage/fontPreview.ts +176 -0
  304. package/src/stage/frustumGeometry.ts +319 -1
  305. package/src/stage/furnitureTokens.ts +33 -0
  306. package/src/stage/gizmoMath.ts +508 -16
  307. package/src/stage/gpuTimer.ts +276 -0
  308. package/src/stage/instanceModules.ts +194 -33
  309. package/src/stage/interactions/move.ts +24 -5
  310. package/src/stage/isolation.ts +291 -33
  311. package/src/stage/jsSelfProfiler.ts +325 -0
  312. package/src/stage/main.tsx +116 -16
  313. package/src/stage/modalState.ts +222 -23
  314. package/src/stage/moduleEpoch.ts +60 -0
  315. package/src/stage/motionPathStage.ts +279 -0
  316. package/src/stage/motionPreview.ts +74 -0
  317. package/src/stage/overlays/Overlays.tsx +1112 -159
  318. package/src/stage/overlays/Rulers.tsx +495 -0
  319. package/src/stage/panes.ts +159 -10
  320. package/src/stage/project.ts +68 -3
  321. package/src/stage/renderScaleController.ts +730 -0
  322. package/src/stage/rulers.ts +129 -0
  323. package/src/stage/sceneExtent.ts +75 -0
  324. package/src/stage/shading.ts +20 -0
  325. package/src/stage/splineStage.ts +632 -0
  326. package/src/stage/stagePerformanceTelemetry.ts +717 -0
  327. package/src/stage/stageTheme.ts +36 -0
  328. package/src/stage/stageWarmup.ts +11 -0
  329. package/src/stage/textAutoSize.ts +124 -0
  330. package/src/stage/tier1Export.ts +641 -0
  331. package/src/stage/tokenColor.ts +14 -9
  332. package/src/stage/view.ts +56 -9
  333. package/src/stage/workingView.ts +277 -33
  334. package/src/state/accountPrefs.ts +261 -0
  335. package/src/state/agentActions.ts +173 -15
  336. package/src/state/agentActivity.ts +198 -0
  337. package/src/state/agentQuestion.ts +239 -0
  338. package/src/state/bootOpen.ts +42 -0
  339. package/src/state/cameraVerbs.ts +139 -0
  340. package/src/state/clientId.ts +6 -1
  341. package/src/state/comments.ts +1099 -0
  342. package/src/state/context.tsx +19 -1
  343. package/src/state/editorPrefs.ts +220 -0
  344. package/src/state/errorReports.ts +121 -0
  345. package/src/state/fieldWrite.ts +161 -0
  346. package/src/state/fontRecents.ts +70 -0
  347. package/src/state/fonts.ts +656 -0
  348. package/src/state/graphClipboard.ts +414 -0
  349. package/src/state/imageThumbs.ts +216 -0
  350. package/src/state/insert.ts +444 -67
  351. package/src/state/isolationTrail.ts +92 -0
  352. package/src/state/keyMoves.ts +134 -0
  353. package/src/state/keyframeClipboard.ts +177 -0
  354. package/src/state/keymap.ts +1153 -117
  355. package/src/state/layoutPrefs.ts +272 -0
  356. package/src/state/layouts.ts +205 -0
  357. package/src/state/measureMedia.ts +167 -0
  358. package/src/state/menu.ts +2341 -28
  359. package/src/state/motionStamp.ts +7 -0
  360. package/src/state/panelSize.ts +375 -5
  361. package/src/state/pendingChanges.ts +119 -0
  362. package/src/state/playHold.ts +78 -0
  363. package/src/state/playbackPreparation.ts +562 -0
  364. package/src/state/posterCapture.ts +242 -0
  365. package/src/state/presence.ts +254 -0
  366. package/src/state/projectComponents.ts +160 -0
  367. package/src/state/renderEvents.ts +298 -18
  368. package/src/state/runtimeErrors.ts +46 -0
  369. package/src/state/selection.ts +12 -0
  370. package/src/state/shortcuts.ts +1606 -0
  371. package/src/state/sketchGuide.ts +51 -0
  372. package/src/state/splineRefs.ts +87 -0
  373. package/src/state/stageHealth.ts +259 -0
  374. package/src/state/stageWatchdog.ts +47 -0
  375. package/src/state/stillStage.ts +330 -0
  376. package/src/state/stillThumbs.ts +474 -0
  377. package/src/state/store.ts +23591 -2982
  378. package/src/state/storyboardModel.ts +528 -0
  379. package/src/state/sync.ts +1257 -0
  380. package/src/state/syncCache.ts +159 -0
  381. package/src/state/triangles.ts +13 -0
  382. package/src/state/types.ts +1698 -55
  383. package/src/state/unsavedGuard.ts +49 -0
  384. package/src/state/viewMode.ts +200 -0
  385. package/src/state/webmcp.ts +1128 -0
  386. package/src/state/workspace.ts +1147 -0
  387. package/src/styles/assets-tab.css +304 -0
  388. package/src/styles/canvas.css +545 -0
  389. package/src/styles/comments.css +1435 -0
  390. package/src/styles/controls.css +162 -0
  391. package/src/styles/dialogs.css +390 -0
  392. package/src/styles/insert-drawer.css +586 -0
  393. package/src/styles/inspector-board.css +207 -0
  394. package/src/styles/inspector-object.css +486 -0
  395. package/src/styles/inspector-shot.css +252 -0
  396. package/src/styles/inspector.css +360 -0
  397. package/src/styles/left-column.css +1628 -0
  398. package/src/styles/library-preview.css +575 -0
  399. package/src/styles/library.css +512 -0
  400. package/src/styles/modes/code.css +569 -0
  401. package/src/styles/modes/component.css +1561 -0
  402. package/src/styles/modes/material.css +1201 -0
  403. package/src/styles/modes/storyboard.css +518 -0
  404. package/src/styles/share-component.css +49 -0
  405. package/src/styles/shell.css +653 -0
  406. package/src/styles/shot-brief.css +360 -0
  407. package/src/styles/states.css +76 -0
  408. package/src/styles/sync.css +380 -0
  409. package/src/styles/timeline-graph.css +384 -0
  410. package/src/styles/timeline-meters.css +89 -0
  411. package/src/styles/timeline-rows.css +1959 -0
  412. package/src/styles/timeline-sound.css +1003 -0
  413. package/src/styles/timeline.css +754 -0
  414. package/src/styles/tool-footer.css +525 -0
  415. package/src/styles/topbar.css +886 -0
  416. package/src/styles/versions.css +262 -0
  417. package/src/styles.css +72 -2921
  418. package/src/tools.ts +108 -23
  419. package/dist/assets/index-B7notge7.css +0 -1
  420. package/dist/assets/index-BBE6Ta9L.js +0 -52
  421. package/src/panels/files/icons.tsx +0 -118
  422. package/src/panels/header/DocSwitcher.tsx +0 -85
  423. package/src/panels/header/TransportBar.tsx +0 -86
  424. package/src/panels/inspector/animate/BlockTiming.tsx +0 -341
  425. package/src/panels/inspector/animate/DuringBlocks.tsx +0 -85
  426. package/src/panels/inspector/animate/InOutPresets.tsx +0 -57
  427. package/src/panels/inspector/animate/ResolvedChannels.tsx +0 -150
  428. package/src/panels/inspector/animate/useBlocks.ts +0 -43
  429. package/src/panels/material/MaterialPicker.tsx +0 -172
  430. package/src/panels/timeline/SceneStrip.tsx +0 -145
  431. package/src/panels/toolbar/AddMenu.tsx +0 -230
  432. package/src/stage/MoveGizmo.tsx +0 -411
package/src/app/App.tsx CHANGED
@@ -3,7 +3,7 @@
3
3
  // ── THE ONE STRUCTURAL RULE ──────────────────────────────────────────────
4
4
  //
5
5
  // `<StagePanel>` is the UNCONDITIONAL, UNKEYED FIRST CHILD of the shell root,
6
- // and every panel floats over it. Nothing this component renders may reorder,
6
+ // and every panel is docked over it. Nothing this component renders may reorder,
7
7
  // reparent, key or conditionally wrap it, because a remount costs a full iframe
8
8
  // document load, a fresh WebGPU adapter request, every layer raster redrawn and
9
9
  // a dead stage for at least one connect poll. `test/stageIdentity.test.tsx`
@@ -15,71 +15,222 @@
15
15
  // element a containing block for fixed-position descendants, and the iframe is
16
16
  // `position: fixed; inset: 0`; the failure is the stage silently sizing itself
17
17
  // to a panel instead of the viewport.
18
- import { useCallback, useEffect, useRef, useState } from "react";
19
- import { FilePanel } from "../panels/files/FilePanel.js";
20
- import { HelpSection } from "../panels/help/HelpSection.js";
18
+ import { Activity, useCallback, useEffect, useRef, useState } from "react";
19
+ import { PanelBoundary, useToasts } from "@dailies-studio/ui";
20
+ import type { ToastHandle } from "@dailies-studio/ui";
21
+ import { requestEditAccess } from "../api.js";
22
+ import { LeftColumn } from "../panels/files/LeftColumn.js";
23
+ import { MissingAssetsNotice } from "../panels/files/MissingAssetsNotice.js";
21
24
  import { ResizeHandle } from "../panels/ResizeHandle.js";
22
- import { RAIL_WIDTH } from "../state/panelSize.js";
25
+ import {
26
+ CANVAS_MIN,
27
+ INSPECTOR_WIDTH,
28
+ RAIL_WIDTH,
29
+ boardColumns,
30
+ fitShell,
31
+ layoutFor,
32
+ toolFooterShown,
33
+ } from "../state/panelSize.js";
34
+ import { CellSlot, CellSlotsProvider } from "../panels/stage/cellSlots.js";
35
+ import { VIEW_ONLY, boardRightPanel, componentEditOpen, componentsHideTimeline, workspaceOf } from "../state/workspace.js";
36
+ import { splitRatioAt, splitX } from "../stage/panes.js";
23
37
  import { Header } from "../panels/header/Header.js";
24
- import { InspectorPanel } from "../panels/inspector/InspectorPanel.js";
38
+ import { SyncQuotaBanner } from "../panels/header/StatusBadges.js";
39
+ import { SyncDeletedDialog } from "../panels/header/SyncDeletedDialog.js";
40
+ import { RightColumn } from "../panels/inspector/RightColumn.js";
25
41
  import { StagePanel } from "../panels/stage/StagePanel.js";
26
42
  import { TimelineDock } from "../panels/timeline/TimelineDock.js";
27
- import { ToolDockBar } from "../panels/toolbar/ToolDockBar.js";
28
- import { ViewModeBar } from "../panels/toolbar/ViewModeBar.js";
29
- import { ShadingBar } from "../panels/toolbar/ShadingBar.js";
30
- import { CameraViewBar } from "../panels/toolbar/CameraViewBar.js";
31
- import { ExportDialog } from "../panels/export/ExportDialog.js";
43
+ import { ToolFooter } from "../panels/toolbar/ToolFooter.js";
44
+ import { CanvasBar } from "../panels/toolbar/CanvasBar.js";
32
45
  import { StageMenu } from "../panels/stage/StageMenu.js";
33
- import { MaterialPicker } from "../panels/material/MaterialPicker.js";
34
- import { AgentPanel } from "../panels/agent/AgentPanel.js";
46
+ import { StillThumbsProvider, stillThumbsActive } from "../panels/storyboard/StillThumbs.js";
47
+ import {
48
+ CodeStudio,
49
+ ComponentStudio,
50
+ ComponentsPreview,
51
+ ExportDialog,
52
+ LibraryBrowser,
53
+ AgentQuestionDialog,
54
+ MaterialWorkspace,
55
+ StoryboardView,
56
+ prefetchStoryboard,
57
+ prefetchInspectorSections,
58
+ } from "./lazySurfaces.js";
59
+ import { whenIdle } from "./lazyPanel.js";
35
60
  import { AudioPreviewBridge } from "../audio/index.js";
61
+ import { ExportScrim } from "../panels/export/ExportScrim.js";
36
62
  import { subscribeStudioEvents } from "../state/renderEvents.js";
63
+ import { reportStageFailures } from "../state/errorReports.js";
64
+ import { startPosterCapture } from "../state/posterCapture.js";
65
+ import { bootOpenFor, wantedDocOf } from "../state/bootOpen.js";
37
66
  import { useShotEditor, useStore } from "../state/context.js";
67
+ import { useUnsavedWorkGuard } from "../state/unsavedGuard.js";
68
+ import { useAccountPrefs } from "../state/accountPrefs.js";
69
+ import { shownLayoutMode } from "../state/viewMode.js";
70
+ import { CURRENT_SHOT_SCHEMA_VERSION } from "@dailies-studio/shot/doc";
38
71
  import type { StageInset } from "../bridgeTypes.js";
72
+ import type { ShotEditorState } from "../state/types.js";
73
+
74
+ /**
75
+ * The two halves disagreeing about the schema, said in a sentence.
76
+ *
77
+ * DERIVED FROM THE OPEN DOCUMENT, never stored, and that is the whole point.
78
+ * It began as one `set({ error })` at load, which is how every other message
79
+ * in this shell works — and it was wrong for this one, because `state.error`
80
+ * is transient by design: `beginScrub` and `openMaterialWorkspace` both clear
81
+ * it, so the warning was gone by the author's second gesture and stayed gone
82
+ * while every save the session would attempt was still doomed.
83
+ *
84
+ * A condition that lasts until a process is restarted has to be said for as
85
+ * long as it lasts. Derived, it cannot be dismissed, it cannot go stale, and
86
+ * it disappears by itself the moment a document at the right version loads.
87
+ *
88
+ * ── WHY IT IS A WARNING AND NOT A LOCKED DOOR ──────────────────────────
89
+ *
90
+ * The document is readable and everything in it is real; the editor simply
91
+ * cannot save what it is about to let the author build. Refusing to open it
92
+ * would take away the one thing that still works. So: keep working, and know.
93
+ */
94
+ export function schemaMismatchNotice(version: number | null): string | null {
95
+ if (version === null || version === CURRENT_SHOT_SCHEMA_VERSION) return null;
96
+ return version < CURRENT_SHOT_SCHEMA_VERSION
97
+ ? `The dev server is older than this editor: it migrated this document to schema ${version}, and the editor is at ${CURRENT_SHOT_SCHEMA_VERSION}. Saves will be refused (and new node kinds rejected) until you restart it: stop it and run \`dailies dev\` again.`
98
+ : `This editor is older than the dev server: the document is at schema ${version} and the editor is at ${CURRENT_SHOT_SCHEMA_VERSION}. Rebuild it: \`pnpm --filter @dailies-studio/shot-editor build\`.`;
99
+ }
100
+
101
+ /** Components with nothing in the right column: the home, unless Agent or
102
+ * Comments is open (`boardRightPanel`). The column is drawn 0 wide and its
103
+ * resize handle is not mounted. The Version history takes the panel width. */
104
+ function componentsColumnEmpty(s: ShotEditorState): boolean {
105
+ return workspaceOf(s) === "components" && !s.versionsOpen && boardRightPanel(s) === null;
106
+ }
39
107
 
40
108
  export function App() {
41
109
  const store = useStore();
42
110
  const [stageError, setStageError] = useState<string | null>(null);
43
- const onConnectError = useCallback((message: string | null) => setStageError(message), []);
111
+ const onConnectError = useCallback(
112
+ (message: string | null) => setStageError(message),
113
+ [],
114
+ );
44
115
 
45
116
  const name = useShotEditor((s) => s.name);
46
117
  const error = useShotEditor((s) => s.error);
47
118
  const reportedStageError = useShotEditor((s) => s.stageError);
48
119
  const exportOpen = useShotEditor((s) => s.exportOpen);
49
- const agentOpen = useShotEditor((s) => s.agentOpen);
120
+ const materialWorkspace = useShotEditor((s) => s.materialWorkspace);
121
+ // An open definition is always the Components workspace. Its Edit tab is
122
+ // the editor (mock C6): the layers outline in the left column, the tool
123
+ // footer and the def timeline (`componentEditOpen`).
124
+ const componentStudio = useShotEditor((s) => s.editingDef !== null);
125
+ const componentEdit = useShotEditor(componentEditOpen);
126
+ // The OTHER family's studio. Mutually exclusive with the one above by
127
+ // construction: each entry clears the other's subject, so the two
128
+ // `.stage-window` holes can never both be mounted and `measureInset` can
129
+ // never have to choose.
130
+ const codeStudio = useShotEditor((s) => s.codeComponent !== null);
131
+ // The fourth mode, the storyboard. Its preview is a hole in the bottom row.
132
+ const storyboardOpen = useShotEditor((s) => s.storyboardOpen);
133
+ const stillsActive = useShotEditor(stillThumbsActive);
134
+ // WHICH WORKSPACE, for the one class every mode rule keys on (`is-mode`):
135
+ // the four legacy classes above stay, because tests and the CLI's e2e read
136
+ // them, and each mode's own rules are about that mode alone.
137
+ const workspace = useShotEditor(workspaceOf);
138
+ // Components without the timeline: nothing open, the Preview tab (its
139
+ // transport is in the canvas cell), or a code component (its Edit tab has
140
+ // its own footer). Only a Studio-made component's Edit tab keeps it.
141
+ const componentsEmpty = useShotEditor(componentsHideTimeline);
142
+ // And the right column with nothing in it there (the home): 0 wide, no seam.
143
+ const columnEmpty = useShotEditor(componentsColumnEmpty);
144
+ // THE TOOL FOOTER'S ROW, opened by a class in the same commit that mounts
145
+ // the footer (see `.shell.has-tool-footer` in styles/shell.css). One
146
+ // predicate for the class, the mount, the inset's edge and the dock's ceiling.
147
+ const footerShown = useShotEditor(toolFooterShown);
148
+ // Preferences > Canvas > Toolbar position: the same row, moved above the
149
+ // stage (`.shell.tool-footer-top` in styles/shell.css).
150
+ const footerTop = useShotEditor((s) => s.toolbarPosition === "top");
151
+ // The VERSION alone and not the document: this re-renders the shell, and
152
+ // subscribing to `doc` here would do it on every keystroke.
153
+ const schemaNotice = schemaMismatchNotice(
154
+ useShotEditor((s) => s.doc?.schemaVersion ?? null),
155
+ );
156
+ const saveConflict = useShotEditor((s) => s.saveConflict);
50
157
 
51
158
  useOpenFromUrl();
52
159
  useKeymap();
53
160
  useExternalChangeRelay();
161
+ useWindowDropGuard();
162
+ // Hosted only: the editor prefs on the account (boot merge, then saves).
163
+ useAccountPrefs();
54
164
 
55
165
  // Subscribed for the WHOLE session, not while the dialog is open: closing the
56
166
  // dialog must not stop the render or lose its progress, and the header badge
57
167
  // reads the same store bit.
58
168
  useEffect(() => subscribeStudioEvents(store), [store]);
169
+ // Hosted only: what breaks on the stage reaches the admin panel.
170
+ useEffect(() => reportStageFailures(store), [store]);
171
+
172
+ // The browser's own close confirmation, while anything is not on disk. See
173
+ // unsavedGuard.ts: a failed save deliberately does not retry, so the
174
+ // in-memory document IS the only copy until the author acts on it.
175
+ useUnsavedWorkGuard(
176
+ useShotEditor((s) => s.dirty),
177
+ useShotEditor((s) => s.saveState),
178
+ useShotEditor((s) => s.syncUnstored),
179
+ );
59
180
 
60
- const railCollapsed = useShotEditor((s) => s.railCollapsed);
181
+
182
+ // A debounced hosted-lane poster attempt after a save settles: the stage
183
+ // captures the poster frame through the Tier-1 exact capture and it is
184
+ // uploaded for the dashboard card. Owed until it lands (see posterCapture.ts);
185
+ // every failure stays silent and the card keeps its gradient.
186
+ useEffect(() => startPosterCapture({ store }), [store]);
187
+
188
+ // THE OTHER MAIN WORKSPACE, FETCHED AT IDLE. Studio is in the entry; the
189
+ // Storyboard is a chunk (lazySurfaces.tsx). Fetched once the first paint is
190
+ // done, so switching to it is as quick as it was when it shipped in the
191
+ // entry, and never on the boot's critical path. A failure is silent: the
192
+ // board fetches again when it opens, and says so there if it cannot. The
193
+ // inspector's two template sections come at the same idle moment, so the
194
+ // first text, image or video layer selected draws its Template field.
195
+ useEffect(
196
+ () =>
197
+ whenIdle(() => {
198
+ if (workspaceOf(store.getState()) !== "storyboard") prefetchStoryboard().catch(() => {});
199
+ prefetchInspectorSections().catch(() => {});
200
+ }),
201
+ [store],
202
+ );
203
+
204
+ // THE INSERT DRAWER SHOWS THE RAIL: it docks in the left column, so a
205
+ // collapsed rail draws open while it is up, without writing the author's
206
+ // collapse (it comes back when the drawer closes).
207
+ const railCollapsed = useShotEditor((s) => s.railCollapsed && (s.insertDrawer === null || s.readOnly));
61
208
  const inspectorCollapsed = useShotEditor((s) => s.inspectorCollapsed);
62
209
  // The MODE alone, not the whole layout: the divider's own drag writes the
63
- // ratio dozens of times a second, and subscribing to that here would
210
+ // split ratio dozens of times a second, and subscribing to that here would
64
211
  // re-render the entire shell on every frame of it. `--split-x` carries the
65
212
  // position, written imperatively beside `--rail-w`.
66
- const stageLayout = { mode: useShotEditor((s) => s.stageLayout.mode) };
213
+ //
214
+ // WHAT IS ON SCREEN, not what is chosen: Video draws the frame over the
215
+ // author's split without writing it, so the seam handle would otherwise be
216
+ // left lying across a picture with no seam under it.
217
+ const stageLayout = { mode: useShotEditor(shownLayoutMode) };
67
218
 
68
- // The dragged sizes reach CSS as variables on the shell, which is what lets
69
- // the control strip and the timeline dock position themselves off the rail
70
- // without any of the three knowing about the others.
219
+ // The dragged sizes reach CSS as variables on the shell, and the variables
220
+ // ARE the grid's tracks: `--rail-w`, `--inspector-w` and `--dock-row-h`.
71
221
  //
72
- // WRITTEN IMPERATIVELY, OUTSIDE REACT. The values exist only to become two
222
+ // WRITTEN IMPERATIVELY, OUTSIDE REACT. The values exist only to become
73
223
  // custom properties, and subscribing them here re-rendered the entire shell
74
224
  // — every panel, every dock row — on every pointermove of a resize drag,
75
- // to do a style write. A raw store subscription writes the same two
76
- // variables and renders nothing.
225
+ // to do a style write. A raw store subscription writes the same variables
226
+ // and renders nothing.
77
227
  //
78
- // `--rail-w` is omitted while the rail is collapsed ON PURPOSE: an inline
79
- // style beats a class rule, so writing it unconditionally would override
80
- // `.is-rail-collapsed { --rail-w: 0px }` and the strip would keep a gap
81
- // where the hidden rail used to be.
228
+ // THROUGH `fitShell`, which is display only: a window too small for the
229
+ // stored sizes shows smaller tracks, and the stored ones come back when the
230
+ // window can hold them. A collapsed column keeps its track (the canvas spans
231
+ // over it in CSS), so these are written unconditionally.
82
232
  const shellRef = useRef<HTMLDivElement | null>(null);
233
+ const shownShell = () => fitShell(window.innerWidth, window.innerHeight, store.getState());
83
234
  useEffect(() => {
84
235
  const el = shellRef.current;
85
236
  if (el === null) return;
@@ -91,31 +242,106 @@ export function App() {
91
242
  // is a FORCED LAYOUT, and this is a raw store subscription — so it ran once
92
243
  // per playback frame, for four numbers that a seek cannot possibly change.
93
244
  //
94
- // These seven fields are every piece of state that moves an edge. `name` is
95
- // on the list because the header's title is content-sized and a long
96
- // document name wraps the strip; the window's own resize comes through the
97
- // ResizeObserver instead, which forces the measure outright.
245
+ // These fields are every piece of state that moves an edge. The window's
246
+ // own resize comes through the ResizeObserver instead, which forces the
247
+ // measure outright.
98
248
  let lastEdges = "";
99
249
  const edgesOf = (s: ReturnType<typeof store.getState>): string =>
100
250
  [
101
251
  s.railCollapsed,
252
+ s.insertDrawer === null,
102
253
  s.inspectorCollapsed,
103
254
  s.dockCollapsed,
104
255
  s.railWidth,
105
256
  s.dockHeight,
106
257
  s.agentOpen,
258
+ s.inspectorWidth,
259
+ // The tool footer opens and closes its own grid row, and its top is
260
+ // the inset's bottom edge. Its own predicate, plus the two fields that
261
+ // can move an edge while that answer stays the same.
262
+ toolFooterShown(s),
263
+ // And which edge it eats: the toolbar position pref moves it.
264
+ s.toolbarPosition,
265
+ s.enteredScene ?? "",
107
266
  s.name,
267
+ s.materialWorkspace?.layerId ?? "",
268
+ s.materialColumnWidth,
269
+ s.codeLiveWidth,
270
+ // The modes can MOVE the hole without mounting or unmounting it: a
271
+ // subject change re-lays the canvas cell around one continuous
272
+ // `.stage-window`.
273
+ // The querySelector bit below only says whether A hole exists, so
274
+ // without these keys the measure was skipped and the stage kept
275
+ // framing the subject into the old rect.
276
+ s.codeComponent ?? "",
277
+ s.editingDef ?? "",
278
+ // Components' Preview | Edit swaps the hole for the studio's.
279
+ s.componentsTab,
280
+ // The board's right cell opens, closes and changes width with its
281
+ // occupant (`boardColumns`).
282
+ boardRightPanel(s) ?? "",
283
+ s.versionsOpen,
284
+ // A workspace swap moves content between cells without mounting or
285
+ // unmounting a shell child, so the child-list observer below cannot
286
+ // see it: the workspace itself is an edge.
287
+ workspaceOf(s),
108
288
  ].join("|");
109
289
  const write = (force: boolean): void => {
110
290
  raf = 0;
111
291
  const s = store.getState();
112
- if (s.railCollapsed) el.style.removeProperty("--rail-w");
113
- else el.style.setProperty("--rail-w", `${s.railWidth}px`);
114
- el.style.setProperty("--dock-h", `${s.dockHeight}px`);
292
+ const fit = fitShell(window.innerWidth, window.innerHeight, s);
293
+ const ws = workspaceOf(s);
294
+ // THE BOARD DRAWS ITS COLUMNS AT DISPLAY WIDTHS (`boardColumns`): no
295
+ // rail, and the right cell only while it has an occupant, at 320, or 400
296
+ // for the component sheet. None of it is
297
+ // written back to `inspectorWidth` or `railWidth`, so Studio keeps the
298
+ // author's own sizes. The Version history takes the panel width.
299
+ const board = ws === "storyboard" ? boardColumns(window.innerWidth, s.versionsOpen ? "inspector" : boardRightPanel(s)) : null;
300
+ el.style.setProperty("--rail-w", `${board?.railWidth ?? fit.railWidth}px`);
301
+ // The Components home has no right cell either: the card grid runs to
302
+ // the window's edge.
303
+ const inspectorW = board?.inspectorWidth ?? (componentsColumnEmpty(s) ? 0 : fit.inspectorWidth);
304
+ el.style.setProperty("--inspector-w", `${inspectorW}px`);
305
+ // No document, no timeline (TimelineDock renders nothing): the bottom
306
+ // row closes rather than showing the stage through an empty cell. The
307
+ // board is one canvas to the window's foot, so it closes there too.
308
+ // Components closes it too, except on a Studio-made component's Edit
309
+ // tab (`componentsHideTimeline`).
310
+ const row = s.doc === null || ws === "storyboard" || componentsHideTimeline(s) ? 0 : fit.dockRow;
311
+ el.style.setProperty("--dock-row-h", `${row}px`);
312
+ // The material workspace's object: the stored width of its hole at the
313
+ // right of the canvas cell (material.css caps it at half the cell).
314
+ el.style.setProperty("--material-col-w", `${s.materialColumnWidth}px`);
315
+ // A code component's live pane, beside the code (code.css).
316
+ el.style.setProperty("--code-live-w", `${s.codeLiveWidth}px`);
115
317
  // Where the stage's own divider is, so the handle can sit on it without
116
318
  // the shell re-rendering per frame of the drag.
117
- el.style.setProperty("--split-x", `${Math.round(s.stageLayout.splitRatio * window.innerWidth)}px`);
118
- const edges = edgesOf(s);
319
+ //
320
+ // THROUGH `splitX`, WITH THE INSET, which is the same call the renderer's
321
+ // scissor makes — the seam divides the canvas the author can SEE rather
322
+ // than the window. Doing the arithmetic here as well is how the handle
323
+ // and the seam it claims to sit on drift a column apart.
324
+ el.style.setProperty(
325
+ "--split-x",
326
+ `${splitX(s.stageLayout.splitRatio, window.innerWidth, s.stageInset)}px`,
327
+ );
328
+ // ── AND ONE BIT THAT IS NOT STATE ────────────────────────────────
329
+ //
330
+ // Whether the stage's WINDOW is mounted. In the material mode the stage
331
+ // is visible through exactly one element and nowhere else, and that
332
+ // element appears the moment the mesh gets a graph — a document write,
333
+ // with every field above it unchanged. So the edge list alone said
334
+ // "nothing moved", the measure was skipped, and the object rendered
335
+ // somewhere behind the mode instead of into the hole left for it.
336
+ //
337
+ // A `querySelector` and not a store field, because the honest question is
338
+ // about the DOM: no bit of state means "the hole is up" without deriving
339
+ // it a second way and being wrong when the two disagree. It costs one
340
+ // selector match per store notification and forces no layout — the
341
+ // rect is only read past the early return below.
342
+ // The dock row as DRAWN, too: an untouched dock fits its folded content
343
+ // (`fitShell`), so a document write can move the bottom edge alone.
344
+ const edges = `${edgesOf(s)}|${fit.dockRow}|${el.querySelector(STAGE_WINDOW) === null ? 0 : 1}`;
119
345
  if (!force && edges === lastEdges) return;
120
346
  lastEdges = edges;
121
347
  // ── AND THE STAGE IS TOLD WHERE IT IS COVERED ────────────────────
@@ -124,15 +350,28 @@ export function App() {
124
350
  // panes are the whole window and it cannot see any of this. It used to
125
351
  // carry a hardcoded 116 for the top edge and nothing at all for the
126
352
  // other three, which put two mode bars behind the opaque header and the
127
- // G / R / S pill behind the 232px left rail — where it has never once
128
- // been visible in the default layout.
353
+ // stage's G / R / S readout behind the 232px left rail, where it had
354
+ // never once been visible in the default layout.
129
355
  //
130
- // MEASURED, not derived from the CSS constants. `.control-strip` is
131
- // `flex-wrap: wrap` and grows past any constant on a narrow window, and
132
- // `.timeline-dock` is content-driven under a max-height. Reading the
133
- // boxes is both shorter than reproducing the layout and correct when
134
- // someone changes it.
135
- store.stage()?.setStageInset(measureInset(el));
356
+ // MEASURED, not derived from the track sizes: the modes swap their own
357
+ // holes in, the control strip wraps, and reading the boxes is both
358
+ // shorter than reproducing the layout and correct when someone changes
359
+ // it.
360
+ // THROUGH THE STORE, not straight down the bridge. This runs at mount,
361
+ // where `store.stage()` is still null because the iframe has not
362
+ // connected — so the push was simply dropped, and nothing re-sent it
363
+ // until a panel edge happened to move. The store remembers it and
364
+ // re-pushes it on every attach with the rest of the workspace.
365
+ const inset = measureInset(el, store.getState().toolbarPosition);
366
+ store.setStageInset(inset);
367
+ // The scrims cover exactly what the author can see of the stage, which
368
+ // is the inset: every box around the picture is docked now, the tool
369
+ // footer included, so what frames the picture and what covers it are
370
+ // one rect.
371
+ el.style.setProperty("--window-t", `${inset.top}px`);
372
+ el.style.setProperty("--window-r", `${inset.right}px`);
373
+ el.style.setProperty("--window-b", `${inset.bottom}px`);
374
+ el.style.setProperty("--window-l", `${inset.left}px`);
136
375
  };
137
376
  // COALESCED TO THE FRAME. This is a raw store subscription so it fires on
138
377
  // every pointermove of a panel resize, and `getBoundingClientRect` is a
@@ -148,156 +387,609 @@ export function App() {
148
387
  });
149
388
  };
150
389
  write(true);
151
- const unsubscribe = store.subscribe(() => schedule(false));
390
+ // The COLD channel: every edge above is a cold field, so a transport
391
+ // tick can move none of them, and the raw channel ran this measure (a
392
+ // state read, a join and a `querySelector`) sixty times a second during
393
+ // playback to find that out. The observers below cover what the store
394
+ // cannot see.
395
+ const unsubscribe = store.subscribeCold(() => schedule(false));
396
+ // ── AND A WINDOW THAT CHANGES SIZE GETS THE LAYOUT FOR ITS NEW SIZE ──
397
+ //
398
+ // `bootLayout` fits the three panels to the window once, at startup. A
399
+ // window can change size afterwards — a laptop docked to a monitor, a
400
+ // split-screen tile — and leaving the boot sizes there would keep a 1366
401
+ // laptop's 200px rail on a 2560 display for the rest of the session.
402
+ //
403
+ // ONLY WHAT THE AUTHOR HAS NOT TOUCHED. The test is whether a panel still
404
+ // holds exactly the number `layoutFor` last gave it: a dragged panel is
405
+ // some other number and is left alone forever after, which is the whole
406
+ // rule and needs no flag to record it. A drag that happens to land on the
407
+ // fitted value to the pixel is indistinguishable from no drag, and being
408
+ // re-fitted is the right answer for it anyway.
409
+ let fitted = layoutFor(window.innerWidth, window.innerHeight);
410
+ const refit = (): void => {
411
+ const next = layoutFor(window.innerWidth, window.innerHeight);
412
+ const s = store.getState();
413
+ if (s.railWidth === fitted.railWidth) store.setRailWidth(next.railWidth);
414
+ if (s.inspectorWidth === fitted.inspectorWidth)
415
+ store.setInspectorWidth(next.inspectorWidth);
416
+ if (s.dockHeight === fitted.dockHeight)
417
+ store.setDockHeight(next.dockHeight);
418
+ fitted = next;
419
+ };
152
420
  // The store cannot see a WINDOW resize, and that is exactly when the
153
421
  // control strip wraps and the top edge moves — so its tick FORCES the
154
422
  // measure rather than asking the store whether anything changed.
155
- const observer = new ResizeObserver(() => schedule(true));
423
+ const observer = new ResizeObserver(() => {
424
+ refit();
425
+ schedule(true);
426
+ });
156
427
  observer.observe(el);
428
+ // ── AND A PANEL THAT ARRIVES AFTER THE MEASURE WAS SCHEDULED ─────────
429
+ //
430
+ // A RACE, and the losing side is unrecoverable. Everything above schedules
431
+ // the measure from STATE: the store notifies, `edgesOf` says an edge could
432
+ // have moved, and a frame is booked. But the panels themselves arrive when
433
+ // REACT COMMITS, and nothing orders that commit against the booked frame —
434
+ // opening a document sets `name` synchronously, and whether the rail, the
435
+ // inspector and the dock are in the DOM by the time the callback runs is a
436
+ // question about React's scheduler, not about this code.
437
+ //
438
+ // Losing that race is permanent, which is what makes it worth closing.
439
+ // `edgesOf` is a list of state, and no state changes when a panel finishes
440
+ // mounting, so a measure taken one frame early is never retried: the stage
441
+ // spends the session believing the inspector and the dock are not there,
442
+ // and everything derived from the inset — the overlay's furniture, the
443
+ // head-on framing, the split seam — is quietly wrong by the width of a
444
+ // panel. It reads as "the framing is a bit off", never as a bug.
445
+ //
446
+ // The commit IS the signal: a panel appearing or disappearing is a change
447
+ // to the shell's own child list, so this fires after it, whichever way the
448
+ // race went. DIRECT CHILDREN ONLY, and that is what makes it affordable —
449
+ // every panel the inset is measured from is a child of the shell, while a
450
+ // `subtree` observer would fire on every timeline row and every inspector
451
+ // keystroke and force a layout read for each, sixty times a second during
452
+ // playback, for four numbers a seek cannot move.
453
+ const panels = new MutationObserver(() => schedule(true));
454
+ panels.observe(el, { childList: true });
157
455
  return () => {
158
456
  if (raf !== 0) cancelAnimationFrame(raf);
159
457
  unsubscribe();
160
458
  observer.disconnect();
459
+ panels.disconnect();
161
460
  };
162
461
  }, [store]);
163
462
 
164
463
  return (
165
- <div
166
- ref={shellRef}
167
- className={`shell${railCollapsed ? " is-rail-collapsed" : ""}${
168
- inspectorCollapsed ? " is-inspector-collapsed" : ""
169
- }`}
170
- >
171
- {/* FIRST, unconditional, unkeyed. See the header. */}
172
- <StagePanel onConnectError={onConnectError} />
173
-
174
- <Header />
175
- <aside className="left-rail">
176
- <FilePanel />
177
- {/* Pinned to the rail's bottom (margin-top: auto) — the manual lives in
178
- the corner that is otherwise empty, and stays put when the layer
179
- tree grows because the rail scrolls as one column. */}
180
- <HelpSection />
181
- </aside>
182
- {/* A SIBLING of the rail, not a child: the rail scrolls, and a handle
183
- inside a scrolling box scrolls away from the edge it is supposed to
184
- be on. Positioned off the same --rail-w the rail's width comes from,
185
- so the two cannot disagree. */}
186
- {railCollapsed ? null : (
187
- <ResizeHandle
188
- axis="x"
189
- getValue={() => store.getState().railWidth}
190
- sign={1}
191
- onChange={(w) => store.setRailWidth(w)}
192
- onReset={() => store.setRailWidth(RAIL_WIDTH.default)}
193
- className="rail-resize"
194
- label="Resize the left panel"
195
- />
196
- )}
197
- {/* ── THE SPLIT DIVIDER DRAGS ────────────────────────────────────────
198
- `splitRatio` has been in the schema, clamped twice and read by the
199
- scissor since the split landed, and NOTHING ever moved it: the stage
200
- drew a 1px seam and `panes.ts`'s header claimed the chrome laid out a
201
- handle from the same rects. It did not.
202
-
203
- CHROME-SIDE, because a stage-side handle needs a new stage-to-chrome
204
- bridge message AND `pointerEvents: auto` inside an overlay whose
205
- `pointerEvents: none` is a stated invariant — without it every drag
206
- would end the moment the cursor crossed the divider.
207
-
208
- Against `window.innerWidth`, which IS the pane width: the stage iframe
209
- is pinned `inset: 0`, so its viewport is the whole window (panes.ts).
210
- `clampSplitRatio` and `setStageLayout` already bound the value twice,
211
- so there is no third validation here. */}
212
- {stageLayout.mode !== "split" ? null : (
213
- <ResizeHandle
214
- axis="x"
215
- getValue={() => store.getState().stageLayout.splitRatio * window.innerWidth}
216
- sign={1}
217
- onChange={(px) =>
218
- store.setStageLayout({
219
- ...store.getState().stageLayout,
220
- splitRatio: px / Math.max(1, window.innerWidth),
221
- })
222
- }
223
- onReset={() => store.setStageLayout({ ...store.getState().stageLayout, splitRatio: 0.5 })}
224
- className="split-resize"
225
- label="Resize the split"
226
- />
227
- )}
228
- {/* ONE control strip along the top of the stage. These three were three
229
- separately-positioned floats — tools centred on the bottom edge, the
230
- view modes and camera verbs stacked at the top left — which cost the
231
- bottom of the frame to a bar and split "what am I doing" across two
232
- corners. As one flex row they read left to right in the order a
233
- gesture is decided: which pane, what the camera does, which tool. */}
234
- <div className="control-strip">
235
- <ViewModeBar />
236
- <ShadingBar />
237
- <CameraViewBar />
238
- <ToolDockBar />
239
- </div>
240
- {agentOpen ? <AgentPanel /> : <InspectorPanel />}
241
- <TimelineDock />
242
- {/* Renders nothing. Mounted HERE and not in the stage: audio is not part
243
- of the document, and the stage is a GL iframe that must never
244
- remount. Unconditional, so it survives every shell state. */}
245
- <AudioPreviewBridge />
246
-
247
- {exportOpen && name !== null && <ExportDialog onClose={() => store.setExportOpen(false)} />}
248
-
249
- {/* Both portal out of the layout, so they hang here rather than inside a
250
- panel: the stage menu opens over whichever half was right-clicked, and
251
- the picker is a mode over the whole shell. Unconditional, because each
252
- renders nothing until its own state field is set. */}
253
- <StageMenu />
254
- <MaterialPicker />
255
-
256
- {name === null ? (
257
- <div className="empty-state">
258
- <h1>Dailies shot</h1>
259
- <p>Open a scene, or create one, to start.</p>
260
- </div>
261
- ) : null}
262
-
263
- <div className="overlay-strip" role="status" aria-live="polite">
264
- {[stageError, reportedStageError, error].filter((m): m is string => m !== null).map((message) => (
265
- <div key={message} className="overlay-message">
266
- <span>{message}</span>
267
- <button type="button" onClick={() => store.dismissError()} title="Dismiss">
268
- ×
269
- </button>
464
+ // THE BOARD'S STILL QUEUE wraps the cells, so the strip (a cell portal)
465
+ // and the inspector's Move section (a shell child) share one queue. It is
466
+ // a stable wrapper, so the shell under it never remounts; `active` makes
467
+ // the queue only while the board is open or the Brief shows its sketches.
468
+ <StillThumbsProvider active={stillsActive}>
469
+ <CellSlotsProvider>
470
+ <div
471
+ ref={shellRef}
472
+ className={`shell${railCollapsed ? " is-rail-collapsed" : ""}${
473
+ inspectorCollapsed ? " is-inspector-collapsed" : ""
474
+ }${
475
+ materialWorkspace === null ? "" : " is-material-workspace"
476
+ }${componentStudio ? " is-component-studio" : ""}${componentEdit ? " is-component-edit" : ""}${
477
+ codeStudio ? " is-code-studio" : ""
478
+ }${componentsEmpty ? " is-components-empty" : ""}${storyboardOpen ? " is-storyboard" : ""}${workspace === "stage" ? "" : " is-mode"}${
479
+ footerShown ? " has-tool-footer" : ""
480
+ }${footerTop ? " tool-footer-top" : ""}`}
481
+ >
482
+ {/* FIRST, unconditional, unkeyed. See the header. */}
483
+ <StagePanel onConnectError={onConnectError} />
484
+
485
+ <Header />
486
+ {/* A BOUNDARY EACH, and OUTSIDE the mode rather than inside it. The
487
+ placement rule in ErrorBoundary.tsx says a boundary belongs inside
488
+ the panel container; these four ARE their own container, and a
489
+ boundary inside one cannot catch a throw in that component's own
490
+ body — which is exactly the bug worth catching here (a malformed
491
+ `storyboard.parts`, a reference row of an unexpected shape). The
492
+ rule's actual concern is the stage, and the stage is an earlier
493
+ sibling that a catch here never touches. `.shell > .panel-crash` in
494
+ styles/shell.css puts the fallback in the centre column on the
495
+ overlay tier, so a crash card is never painted underneath the stage
496
+ iframe.
497
+
498
+ Each mode below is its eager shell (lazySurfaces.tsx): null until
499
+ the mode is first wanted, then its chunk, behind the mode's own
500
+ skeleton. A chunk that fails to load lands in the same boundary. */}
501
+ <PanelBoundary label="Material workspace">
502
+ <MaterialWorkspace />
503
+ </PanelBoundary>
504
+ {/* The Component Studio. A SIBLING of the stage like every panel, and
505
+ it renders nothing until a definition is open on the Studio face,
506
+ so it is unconditional here for the reason `StageMenu` is. Like
507
+ every mode it shows its parts in the docked cells (cellSlots.tsx). */}
508
+ <PanelBoundary label="Component studio">
509
+ <ComponentStudio />
510
+ </PanelBoundary>
511
+ {/* And the third, for the OTHER component family: one code component's
512
+ source beside its own render. Unconditional here for the same reason,
513
+ and it renders nothing until `codeComponent` is set. */}
514
+ <PanelBoundary label="Code studio">
515
+ <CodeStudio />
516
+ </PanelBoundary>
517
+ {/* Components' Preview tab and its empty state (the two studios
518
+ above are its Edit tab). Unconditional for the same reason. */}
519
+ <PanelBoundary label="Components">
520
+ <ComponentsPreview />
521
+ </PanelBoundary>
522
+ {/* And the fourth: the Project's planning board. Unconditional here for
523
+ the same reason, and it renders nothing until `storyboardOpen`. */}
524
+ <PanelBoundary label="Storyboard">
525
+ <StoryboardView />
526
+ </PanelBoundary>
527
+ <LeftColumn />
528
+ {/* A SIBLING of the rail, not a child: a grid item on the rail's seam,
529
+ on the same --rail-w track the rail's width comes from, so the two
530
+ cannot disagree. It runs down through the timeline, because the
531
+ timeline's name column is that same track.
532
+
533
+ AND IT SERVES EVERY WORKSPACE BUT THE BOARD. A mode shows its own
534
+ left column in this same cell (the material library, the
535
+ component library, the file tree), so the seam drags there too.
536
+ The board has no left column: it is one full-width canvas.
537
+
538
+ FROM THE SHOWN WIDTH, AND NO WIDER THAN THE CANVAS ALLOWS. A drag
539
+ starts from what the author sees, not from a stored size the window
540
+ is too small for, and a drag that would squeeze the canvas under its
541
+ minimum stops where the track stops. */}
542
+ {railCollapsed || workspace === "storyboard" ? null : (
543
+ <ResizeHandle
544
+ axis="x"
545
+ getValue={() => shownShell().railWidth}
546
+ sign={1}
547
+ onChange={(w) =>
548
+ store.setRailWidth(
549
+ Math.min(w, window.innerWidth - CANVAS_MIN.width - shownShell().inspectorWidth),
550
+ )
551
+ }
552
+ onReset={() => store.setRailWidth(RAIL_WIDTH.default)}
553
+ className="rail-resize"
554
+ label="Resize the left panel"
555
+ />
556
+ )}
557
+ {/* ── THE SPLIT DIVIDER DRAGS ────────────────────────────────────────
558
+ `splitRatio` has been in the schema, clamped twice and read by the
559
+ scissor since the split landed, and NOTHING ever moved it: the stage
560
+ drew a 1px seam and `panes.ts`'s header claimed the chrome laid out a
561
+ handle from the same rects. It did not.
562
+
563
+ CHROME-SIDE, because a stage-side handle needs a new stage-to-chrome
564
+ bridge message AND `pointerEvents: auto` inside an overlay whose
565
+ `pointerEvents: none` is a stated invariant — without it every drag
566
+ would end the moment the cursor crossed the divider.
567
+
568
+ Against `window.innerWidth`, which IS the pane width: the stage iframe
569
+ is pinned `inset: 0`, so its viewport is the whole window (panes.ts).
570
+ `clampSplitRatio` and `setStageLayout` already bound the value twice,
571
+ so there is no third validation here. */}
572
+ {/* The stage's own panes are on screen only in the stage workspace. In
573
+ a mode the stage frames one subject into a hole, and a divider at
574
+ the split's x would lie across the hole and take its drags. */}
575
+ {stageLayout.mode !== "split" || workspace !== "stage" ? null : (
576
+ <ResizeHandle
577
+ axis="x"
578
+ getValue={() =>
579
+ splitX(
580
+ store.getState().stageLayout.splitRatio,
581
+ window.innerWidth,
582
+ store.getState().stageInset,
583
+ )
584
+ }
585
+ sign={1}
586
+ onChange={(px) =>
587
+ store.setStageLayout({
588
+ ...store.getState().stageLayout,
589
+ splitRatio: splitRatioAt(
590
+ px,
591
+ window.innerWidth,
592
+ store.getState().stageInset,
593
+ ),
594
+ })
595
+ }
596
+ onReset={() =>
597
+ store.setStageLayout({
598
+ ...store.getState().stageLayout,
599
+ splitRatio: 0.5,
600
+ })
601
+ }
602
+ className="split-resize"
603
+ label="Resize the split"
604
+ />
605
+ )}
606
+ {/* THE CANVAS BAR, docked over the canvas cell: where am I, which panes
607
+ are up and how each is drawn, then the badges and the zoom. It names
608
+ the PLACE; the tools name the GESTURE and live in the footer under the
609
+ canvas. See CanvasBar.tsx. */}
610
+ <div className="control-strip">
611
+ {/* INSIDE the positioned strip, per PanelBoundary's placement rule.
612
+ Hidden on the board, which has no canvas bar: see the timeline
613
+ dock below for why an `<Activity>`. */}
614
+ <Activity mode={workspace === "storyboard" ? "hidden" : "visible"}>
615
+ <PanelBoundary label="Toolbar">
616
+ <CanvasBar />
617
+ </PanelBoundary>
618
+ </Activity>
619
+ </div>
620
+ {/* THE TOOL FOOTER, docked under the canvas cell: a grid row of its own
621
+ (`foot-t / main-b`), so it follows every dock drag and collapse with
622
+ no arithmetic and nothing floats over the picture. See `.tool-footer`
623
+ in styles/tool-footer.css.
624
+
625
+ MOUNTED AT EVERY LEVEL OF THE STAGE (`toolFooterShown`): a loaded
626
+ document in the stage workspace, the Project context included. In
627
+ every mode the row closes and the canvas takes its height back,
628
+ rather than drawing a bar about a picture that is not on screen.
629
+
630
+ ToolFooter holds the tools (or, in the Project context, the shot
631
+ under the playhead) on the left and the view track on the right.
632
+ The boundary is INSIDE the positioned box, per PanelBoundary's
633
+ placement rule.
634
+
635
+ RIGHT AFTER THE CANVAS BAR in the DOM, though the grid draws it
636
+ further down: the grid places every cell explicitly, so source
637
+ order is only the TAB order, and the tools belong with the canvas
638
+ they act on, before the inspector and the timeline. */}
639
+ {footerShown ? (
640
+ <div className="tool-footer">
641
+ <PanelBoundary label="Tools">
642
+ <ToolFooter />
643
+ </PanelBoundary>
644
+ </div>
645
+ ) : null}
646
+ {/* ── AND THE RIGHT RAIL DRAGS TOO ──────────────────────────────────
647
+ The inspector was a CSS literal with no writer, and the panel had
648
+ been worked around rather than widened: a comment in SceneDocSection
649
+ records a label being SHORTENED because `.insp-label` is capped at
650
+ 60% of a 288px pill. A width the author can set is the honest fix.
651
+
652
+ A SIBLING of the column, never a child: a grid item on its seam, the
653
+ same reason `.rail-resize` sits out here, and it runs down through
654
+ the timeline's meters cell, which is the same track.
655
+
656
+ `sign={-1}` because this rail is on the RIGHT: moving the pointer
657
+ along +x makes it narrower.
658
+
659
+ Hidden while collapsed, and on the Components home, whose column
660
+ has no occupant and is drawn at 0: a handle for a hidden column
661
+ is an 8px strip of dead canvas, and its drag would size a column
662
+ nobody can see.
663
+
664
+ NOT ON THE BOARD, whose right panel is drawn at fixed display
665
+ widths (`boardColumns`) that a drag would never move. */}
666
+ {inspectorCollapsed || workspace === "storyboard" || columnEmpty ? null : (
667
+ <ResizeHandle
668
+ axis="x"
669
+ getValue={() => shownShell().inspectorWidth}
670
+ sign={-1}
671
+ onChange={(w) =>
672
+ store.setInspectorWidth(
673
+ Math.min(w, window.innerWidth - CANVAS_MIN.width - shownShell().railWidth),
674
+ )
675
+ }
676
+ onReset={() => store.setInspectorWidth(INSPECTOR_WIDTH.default)}
677
+ className="inspector-resize"
678
+ label="Resize the right panel"
679
+ />
680
+ )}
681
+ <RightColumn />
682
+ {/* STUDIO'S PANELS SLEEP ON THE BOARD. The board shows none of them
683
+ (storyboard.css hides their cells), but a hidden cell still
684
+ re-rendered on every store change the board made. Inside a hidden
685
+ `<Activity>` they keep their state (the dock's scroll, its open
686
+ rows) and stop rendering and running effects until Studio is back.
687
+ The stage is never inside one: it is the shell's first child, and
688
+ stageIdentity.test.tsx holds it there. The left column's Files
689
+ panel sleeps the same way (LeftColumn.tsx); the tool footer is
690
+ not mounted on the board at all (`toolFooterShown`). */}
691
+ <Activity mode={workspace === "storyboard" ? "hidden" : "visible"}>
692
+ <TimelineDock />
693
+ </Activity>
694
+ {/* THE MODES' CELLS (panels/stage/cellSlots.tsx). The canvas cell holds
695
+ nothing in the stage workspace, so the picture shows through it; a
696
+ mode portals its centre here. The bottom cell stands in for the
697
+ timeline in the two modes whose strip replaces it. */}
698
+ <CellSlot name="centre" className="canvas-cell" />
699
+ <CellSlot name="bottom" className="bottom-cell" />
700
+ {/* Renders nothing. Mounted HERE and not in the stage: audio is not part
701
+ of the document, and the stage is a GL iframe that must never
702
+ remount. Unconditional, so it survives every shell state. */}
703
+ <AudioPreviewBridge />
704
+ {/* A SIBLING of the stage panel, never a wrapper: the iframe must not
705
+ remount, and anything inside it would be captured by the export it
706
+ describes. Renders nothing unless an in-browser export is running. */}
707
+ <ExportScrim />
708
+
709
+ {exportOpen && name !== null && (
710
+ <ExportDialog onClose={() => store.setExportOpen(false)} />
711
+ )}
712
+
713
+ {/* Portals out of the layout, so it hangs here rather than inside a
714
+ panel: the stage menu opens over whichever half was right-clicked.
715
+ Unconditional, because it renders nothing until its state field is set. */}
716
+ <StageMenu />
717
+ {/* The library browser hangs here for the same reason. It is a `Dialog`
718
+ and not a workspace: it portals to `document.body` at 1100, so it
719
+ opens over the board and both studios rather than refusing while
720
+ one of them is up. */}
721
+ <LibraryBrowser />
722
+ {/* An MCP agent's question to the person ("The storyboard is
723
+ ready"): a Dialog over whatever is on screen, its chunk loaded
724
+ the first time one opens. */}
725
+ <AgentQuestionDialog />
726
+
727
+ {name === null ? (
728
+ <div className="empty-state">
729
+ <h1>No Project open</h1>
730
+ <p>Open a Project to start.</p>
731
+ {/* A plain link: with no Project there is nothing to flush first. */}
732
+ <a className="btn is-primary" href="/projects/" data-testid="empty-go-projects">
733
+ Go to Projects
734
+ </a>
735
+ </div>
736
+ ) : null}
737
+
738
+ {/* Top of the work area (Paper page 21): editing is held until it clears. */}
739
+ <SyncQuotaBanner />
740
+ {/* A delete under live sync, with changes still waiting (R10). */}
741
+ <SyncDeletedDialog />
742
+
743
+ <div className="overlay-strip" role="status" aria-live="polite">
744
+ {/* FIRST, and with no dismiss: it outlives every message under it and
745
+ is the reason some of them are about to appear. */}
746
+ {schemaNotice === null ? null : (
747
+ <div
748
+ className="overlay-message is-sticky"
749
+ data-testid="schema-mismatch"
750
+ >
751
+ <span>{schemaNotice}</span>
752
+ </div>
753
+ )}
754
+ {/* Sticky too: autosave is stopped until the author acts on it. */}
755
+ {saveConflict === null ? null : (
756
+ <div className="overlay-message is-sticky" data-testid="save-conflict">
757
+ {saveConflict === "deleted" ? (
758
+ <>
759
+ <span>This Project was deleted or renamed somewhere else, so your edits are not saving.</span>
760
+ <a className="overlay-action" href="/projects/">
761
+ Back to Projects
762
+ </a>
763
+ </>
764
+ ) : (
765
+ <>
766
+ <span>
767
+ This Project changed in another tab or by an agent, so your edits are not saving. Reload
768
+ takes the latest version, and Undo then brings yours back.
769
+ </span>
770
+ <button type="button" className="overlay-action" onClick={() => void store.reloadLatest()}>
771
+ Reload
772
+ </button>
773
+ </>
774
+ )}
775
+ </div>
776
+ )}
777
+ {/* Sticky: it goes when the file is relinked or removed. */}
778
+ <MissingAssetsNotice />
779
+ <ViewOnlyToast />
780
+ {/* Each row clears its own message: a stage error is not `error`.
781
+ VIEW_ONLY is not a row: ViewOnlyToast says it, with a way on. */}
782
+ {(
783
+ [
784
+ [stageError, () => setStageError(null)],
785
+ [reportedStageError, () => store.dismissStageError()],
786
+ [error === VIEW_ONLY ? null : error, () => store.dismissError()],
787
+ ] as const
788
+ )
789
+ .filter((row): row is readonly [string, () => void] => row[0] !== null)
790
+ .map(([message, dismiss]) => (
791
+ <div key={message} className="overlay-message">
792
+ <span>{message}</span>
793
+ <button
794
+ type="button"
795
+ onClick={dismiss}
796
+ title="Dismiss"
797
+ >
798
+ ×
799
+ </button>
800
+ </div>
801
+ ))}
270
802
  </div>
271
- ))}
272
- </div>
273
- </div>
803
+ </div>
804
+ </CellSlotsProvider>
805
+ </StillThumbsProvider>
274
806
  );
275
807
  }
276
808
 
277
- /** `?shot=<name>` on load. The URL is a MIRROR the store writes, and reading it
278
- * once at boot is what makes a reload land back where the author was — the
279
- * same reason the canvas lane mirrors its focused board.
280
- *
281
- * With NO name in the URL, open the first document there is. A bare `/shot`
282
- * is how a hosted workspace is entered — nobody types a query string at a
283
- * front door — and greeting a new author with an empty stage hides the fact
284
- * that their project already has content. Guarded on `name === null` so it
285
- * can only ever fill a vacuum, never replace something already open. */
809
+ /** A Viewer's edit attempt (Paper page 21, "View only"): a toast with the way
810
+ * on, "Request edit access", in place of the error row. The request is
811
+ * always 202 (packages/worker/src/requests.ts), so "sent" is all it can say.
812
+ * One at a time: a second attempt replaces the first. */
813
+ function ViewOnlyToast() {
814
+ const store = useStore();
815
+ const error = useShotEditor((s) => s.error);
816
+ const { toast, node } = useToasts();
817
+ const shown = useRef<ToastHandle | null>(null);
818
+ useEffect(() => {
819
+ if (error !== VIEW_ONLY) return;
820
+ store.dismissError();
821
+ shown.current?.dismiss();
822
+ shown.current = toast({
823
+ message: VIEW_ONLY,
824
+ action: {
825
+ label: "Request edit access",
826
+ run: () => {
827
+ const projectId = store.getState().doc?.id;
828
+ if (projectId === undefined) return;
829
+ requestEditAccess(projectId).then(
830
+ () => toast({ message: "Request sent. A Project admin will see it.", severity: "success" }),
831
+ (e: unknown) => toast({ message: e instanceof Error ? e.message : String(e), severity: "error" }),
832
+ );
833
+ },
834
+ },
835
+ });
836
+ }, [error, store, toast]);
837
+ return node;
838
+ }
839
+
840
+ /** `?scene=<stable-slug>` opens exactly one Project. A bare editor never picks
841
+ * one on the author's behalf; Project Home owns that choice. `?shot=` remains
842
+ * the original document-key spelling for old deep links. */
286
843
  function useOpenFromUrl(): void {
287
844
  const store = useStore();
288
845
  useEffect(() => {
289
- const wanted = new URLSearchParams(window.location.search).get("shot");
290
- if (wanted !== null && wanted.length > 0) {
291
- void store.openShot(wanted);
292
- return;
293
- }
294
- void (async () => {
295
- await store.refreshShots();
296
- const first = store.getState().shots[0];
297
- if (first !== undefined && store.getState().name === null) {
298
- await store.openShot(first.name);
846
+ const params = new URLSearchParams(window.location.search);
847
+ // ── `?scene=` IS THE DOCUMENT AND `?shot=` IS THE SEGMENT ─────────────
848
+ //
849
+ // The words moved when the chrome's vocabulary did, and `?shot=<docName>`
850
+ // is the ORIGINAL spelling of the first — old links carry it and must keep
851
+ // working. So one precedence rule rather than a guess: `scene=` names the
852
+ // document and wins, and `shot=` is read AS A DOCUMENT NAME only when
853
+ // `scene=` is absent.
854
+ //
855
+ // A URL carrying BOTH is the ordinary shape this chrome now writes (see
856
+ // `mirrorUrl`), not a stale bookmark: `scene=` is the document and `shot=`
857
+ // is the shot inside it, read below once that document is loaded.
858
+ const wanted = wantedDocOf(params);
859
+ // ?def= REOPENS A COMPONENT CONTEXT. It rides the open rather than being a
860
+ // second effect because it addresses INTO the document: a definition id
861
+ // means nothing until the document holding it is loaded, and `editComponent`
862
+ // called against an empty store would be validated away on the first write.
863
+ // A stale id (the definition was deleted since the link was made) opens the
864
+ // shot and nothing else, which is the same degradation `?focus=` takes.
865
+ const reopenDef = (): void => {
866
+ // ?shot=<sc_…> REOPENS THE SHOT CONTEXT, and it rides the open for the
867
+ // same reason `?def=` does: a shot id means nothing until the document
868
+ // holding it is loaded. It is read ONLY when `scene=` supplied the
869
+ // document — otherwise `shot=` is the original spelling of the document
870
+ // name and `wanted` has already consumed it, so treating it as a shot id
871
+ // would refuse a link that used to work.
872
+ const shot = params.get("shot");
873
+ // NON-EMPTY `scene=`, not merely present. `?scene=&shot=sc_x` is a URL a
874
+ // hand edit or a truncating share sheet produces, and `wanted` already
875
+ // rejects it as a document name — so a bare presence check here would
876
+ // enter a shot id against whatever document the "open the first one"
877
+ // fallback happened to load.
878
+ const named = params.get("scene");
879
+ if (
880
+ named !== null &&
881
+ named.length > 0 &&
882
+ shot !== null &&
883
+ shot.length > 0
884
+ ) {
885
+ // Membership in `doc.scenes`, never `scenesById[shot] !== undefined`:
886
+ // that map is a plain object and `?shot=constructor` is a string a link
887
+ // can carry, so the presence guard would pass on `Object`'s own
888
+ // constructor. `enterScene` refuses it too; this is the outer half.
889
+ if (store.getState().doc?.scenes.includes(shot) === true)
890
+ store.enterScene(shot);
891
+ }
892
+ const def = params.get("def");
893
+ if (
894
+ def !== null &&
895
+ def.length > 0 &&
896
+ store.getState().doc?.components?.[def] !== undefined
897
+ ) {
898
+ // `?def=` reopens the definition in Components. An old `&edit=1` face
899
+ // flag means nothing now: there is one place.
900
+ store.editComponent(def);
299
901
  }
300
- })();
902
+ // `?code=` REOPENS THE OTHER FAMILY'S SUBJECT. Validated by the REGISTRY
903
+ // rather than by the document, which is the whole difference between the
904
+ // two: a code component is a file in the repo, so whether a link to it
905
+ // still works has nothing to do with which shot is open. `openCodeComponent`
906
+ // does that lookup itself and reports an id the registry has lost, so
907
+ // there is nothing to check here.
908
+ const code = params.get("code");
909
+ if (code !== null && code.length > 0) {
910
+ // `?file=` IS THE BUFFER, and it rides BEHIND the subject rather than
911
+ // beside it: it names a file to read inside the code studio, and there
912
+ // is no code studio until `code=` has opened one. Awaited for that
913
+ // reason — opening the file first would land a buffer in a mode that
914
+ // is about to replace the whole buffer set.
915
+ //
916
+ // GATED ON THE SUBJECT ACTUALLY OPENING, not on the promise settling:
917
+ // `openCodeComponent` refuses by RETURNING (an id the registry lost, a
918
+ // builtin, an unreadable file), so a resolved promise says nothing
919
+ // about whether the mode is on screen. `openCodeFile` refuses the same
920
+ // case itself; this check keeps the reason legible at the call site.
921
+ //
922
+ // The path is otherwise unvalidated here, like `?material=`:
923
+ // `openCodeFile` reads through `/api/files`, which is the only thing
924
+ // that knows whether a path is editable, and it reports its own refusal.
925
+ const file = params.get("file");
926
+ void store.openCodeComponent(code).then(() => {
927
+ if (file === null || file.length === 0) return;
928
+ if (store.getState().codeComponent === null) return;
929
+ void store.openCodeFile(file);
930
+ });
931
+ }
932
+ reopenMaterial();
933
+ // ?storyboard=1 REOPENS THE BOARD, after the material reopen and only
934
+ // over the stage: opening hands over from a mode, but a link carrying
935
+ // both is a link the mirror never wrote, and the board yields.
936
+ if (params.get("storyboard") === "1" && workspaceOf(store.getState()) === "stage") store.setStoryboardOpen(true);
937
+ // ?plan=<partId>&sketch=<id> REOPENS THE SHOT BOARD on the board just
938
+ // opened, and only on it: a plan that is gone opens the whole board.
939
+ // `openShotBoard` takes a Shot id too, so it is checked as a part here.
940
+ const plan = params.get("plan");
941
+ if (
942
+ plan !== null &&
943
+ store.getState().storyboardOpen &&
944
+ store.getState().doc?.storyboard?.parts.some((p) => p.id === plan) === true
945
+ ) {
946
+ store.openShotBoard(plan);
947
+ const sketch = params.get("sketch");
948
+ if (sketch !== null && sketch.length > 0) store.selectSketch(sketch);
949
+ }
950
+ // ?thread=<id> OPENS A COMMENT THREAD (an inbox item, an email link)
951
+ // where it sits (`openThread`), and in the Comments tab. A DOOR, NOT A
952
+ // PLACE: the mirror never writes it, so it leaves the URL once read.
953
+ // Left in, every reload would open the thread again over the mode the
954
+ // author has moved to since, and a card thread hands over to the board.
955
+ const thread = params.get("thread");
956
+ if (thread !== null && thread !== "") {
957
+ const url = new URL(window.location.href);
958
+ url.searchParams.delete("thread");
959
+ window.history.replaceState(null, "", url);
960
+ void store.openThread(thread);
961
+ }
962
+ };
963
+ // ?material=<layerId>&slot=<index> REOPENS THE SHADER, and it rides the
964
+ // open behind the shot and the definition for the same reason both of them
965
+ // do: a layer id means nothing until the document holding it is loaded, and
966
+ // `openMaterialWorkspace` resolves the layer through whichever container
967
+ // the chrome is currently in — so entering the shot has to have happened
968
+ // first or the mesh is not reachable and the call refuses.
969
+ //
970
+ // The SLOT is passed through unchecked on purpose. The slot list is pushed
971
+ // up by the stage when the model finishes parsing, which is well after
972
+ // boot, so validating here would reject every deep link into a submesh.
973
+ // `openMaterialWorkspace` already drops a slot on a primitive, and
974
+ // `onHistoryChange` drops one the loaded model turns out not to have.
975
+ const reopenMaterial = (): void => {
976
+ const material = params.get("material");
977
+ if (material === null || material.length === 0) return;
978
+ const raw = params.get("slot");
979
+ const slot = raw === null || raw === "" ? null : Number(raw);
980
+ if (slot !== null && !Number.isInteger(slot)) return;
981
+ store.openMaterialWorkspace(material, { slot });
982
+ };
983
+ if (wanted !== null) {
984
+ // ADOPT the boot open when main.tsx already started it (see bootOpen.ts:
985
+ // the fetch overlaps the mount instead of following it); initiate only
986
+ // when there is none — a test's own store, or a future second mount.
987
+ // The continuation still runs after the doc lands and against live store
988
+ // state, exactly as before.
989
+ void (bootOpenFor(store, wanted) ?? store.openShot(wanted)).then(
990
+ reopenDef,
991
+ );
992
+ }
301
993
  }, [store]);
302
994
  }
303
995
 
@@ -309,32 +1001,81 @@ function useOpenFromUrl(): void {
309
1001
  * panel and a stage is a keymap where "what does ⇧A do" has three answers and
310
1002
  * a reader has to find all three to be sure.
311
1003
  */
312
- /** The panels that float over the stage, per edge they eat into. Hidden ones
313
- * measure 0x0 and are skipped, which is what makes a collapsed rail give the
314
- * stage its corner back. */
315
- const INSET_PANELS = {
316
- top: [".shell-header", ".control-strip"],
317
- left: [".left-rail"],
318
- right: [".inspector", ".agent-panel"],
319
- bottom: [".timeline-dock"],
320
- } as const;
1004
+ /** The docked cells around the canvas, per edge they eat into. Hidden ones
1005
+ * are skipped, which is what makes a collapsed rail give the stage its
1006
+ * corner back.
1007
+ *
1008
+ * The tool footer is a docked cell like the rest: the picture is framed away
1009
+ * from it and the scrims stop at it. WHICH edge is the toolbar position pref:
1010
+ * under the stage it sits above the dock, so it is the larger of the bottom
1011
+ * edges whenever it is mounted; on top it sits under the canvas bar, so it is
1012
+ * the larger of the top edges. Built from the pref, never a special case in
1013
+ * the measure. */
1014
+ function insetPanels(toolbar: "bottom" | "top") {
1015
+ const footer = ".tool-footer";
1016
+ return {
1017
+ top: [".shell-header", ".control-strip", ...(toolbar === "top" ? [footer] : [])],
1018
+ left: [".left-rail"],
1019
+ right: [".inspector-column"],
1020
+ bottom: [".timeline-dock", ".bottom-cell", ...(toolbar === "bottom" ? [footer] : [])],
1021
+ };
1022
+ }
1023
+
1024
+ /**
1025
+ * The one element that is a WINDOW onto the stage rather than a lid over it.
1026
+ *
1027
+ * A mode paints its own parts in the cells around this one, the canvas cell
1028
+ * included, so the stage's whole visible area IS this rect. That makes the
1029
+ * per-side max below the wrong sum: it knows only the docked cells, so it
1030
+ * would frame the subject into the whole canvas cell, under the mode's parts.
1031
+ *
1032
+ * A SHARED CLASS and not one selector per mode. There are four (the material
1033
+ * workspace's object, the Component Studio's subject, Code Studio's render and
1034
+ * the board's preview); a second selector here is a third place the rule lives,
1035
+ * and the failure when someone forgets it is silent — the mode paints, the
1036
+ * measure falls through to the per-panel maxes, and the stage frames the
1037
+ * subject into a rect it can see none of.
1038
+ */
1039
+ const STAGE_WINDOW = ".stage-window";
321
1040
 
322
1041
  /**
323
1042
  * How far the chrome's furniture reaches into the stage, per side, in CSS px.
324
1043
  *
325
- * ZERO-SIZED ELEMENTS ARE SKIPPED and that is not an optimisation. A
326
- * `display: none` panel reports an all-zero rect, and for the RIGHT edge
327
- * `shell.right - 0` is the whole window — so a collapsed inspector would tell
328
- * the stage its entire width was covered and every badge would render off
329
- * screen.
1044
+ * A PANEL NOBODY CAN SEE COVERS NOTHING, and there are two ways to be one.
1045
+ * Zero-sized is the older test and it is not an optimisation: for the RIGHT
1046
+ * edge `shell.right - 0` is the whole window, so a collapsed inspector that
1047
+ * measured zero would tell the stage its entire width was covered and every
1048
+ * badge would render off screen. Hidden is the second, and it is why the two
1049
+ * are asked separately now. See `edge`.
330
1050
  */
331
- function measureInset(shell: HTMLElement): StageInset {
1051
+ export function measureInset(shell: HTMLElement, toolbar: "bottom" | "top"): StageInset {
1052
+ const panels = insetPanels(toolbar);
332
1053
  const box = shell.getBoundingClientRect();
333
- const edge = (selectors: readonly string[], of: (r: DOMRect) => number): number => {
1054
+ // A WINDOW WINS OUTRIGHT. Not merged with the panels below: when one is on
1055
+ // screen the stage is visible THERE and nowhere else, so the four numbers are
1056
+ // that rect's four distances and no panel can add to them.
1057
+ const port = shell.querySelector(STAGE_WINDOW)?.getBoundingClientRect();
1058
+ if (port !== undefined && port.width > 0 && port.height > 0) {
1059
+ const hole = {
1060
+ top: port.top - box.top,
1061
+ left: port.left - box.left,
1062
+ right: box.right - port.right,
1063
+ bottom: box.bottom - port.bottom,
1064
+ };
1065
+ return hole;
1066
+ }
1067
+ const edge = (
1068
+ selectors: readonly string[],
1069
+ of: (r: DOMRect) => number,
1070
+ ): number => {
334
1071
  let most = 0;
335
1072
  for (const selector of selectors) {
336
1073
  const el = shell.querySelector(selector);
337
1074
  if (el === null) continue;
1075
+ // AND A HIDDEN ONE, which is not the same question. A collapsed column is
1076
+ // `visibility: hidden` and keeps its whole track, so it measures full
1077
+ // width while covering nothing: the canvas spans over it.
1078
+ if (window.getComputedStyle(el).visibility === "hidden") continue;
338
1079
  const rect = el.getBoundingClientRect();
339
1080
  if (rect.width === 0 || rect.height === 0) continue;
340
1081
  most = Math.max(most, of(rect));
@@ -342,18 +1083,44 @@ function measureInset(shell: HTMLElement): StageInset {
342
1083
  return most;
343
1084
  };
344
1085
  return {
345
- top: edge(INSET_PANELS.top, (r) => r.bottom - box.top),
346
- left: edge(INSET_PANELS.left, (r) => r.right - box.left),
347
- right: edge(INSET_PANELS.right, (r) => box.right - r.left),
348
- bottom: edge(INSET_PANELS.bottom, (r) => box.bottom - r.top),
1086
+ top: edge(panels.top, (r) => r.bottom - box.top),
1087
+ left: edge(panels.left, (r) => r.right - box.left),
1088
+ right: edge(panels.right, (r) => box.right - r.left),
1089
+ bottom: edge(panels.bottom, (r) => box.bottom - r.top),
349
1090
  };
350
1091
  }
351
1092
 
1093
+ /**
1094
+ * A file dropped on the chrome must not navigate the tab away from the
1095
+ * editor. The panels that take drops prevent the default themselves, so this
1096
+ * only answers for everywhere else, and it leaves text fields their own drop.
1097
+ * The stage iframe is its own document and guards itself (StagePanel.tsx).
1098
+ */
1099
+ function useWindowDropGuard(): void {
1100
+ useEffect(() => {
1101
+ const guard = (e: DragEvent): void => {
1102
+ if (e.defaultPrevented) return;
1103
+ const t = e.target;
1104
+ if (t instanceof HTMLElement && (t.isContentEditable || t.closest("input, textarea") !== null))
1105
+ return;
1106
+ e.preventDefault();
1107
+ if (e.dataTransfer !== null) e.dataTransfer.dropEffect = "none";
1108
+ };
1109
+ window.addEventListener("dragover", guard);
1110
+ window.addEventListener("drop", guard);
1111
+ return () => {
1112
+ window.removeEventListener("dragover", guard);
1113
+ window.removeEventListener("drop", guard);
1114
+ };
1115
+ }, []);
1116
+ }
1117
+
352
1118
  function useKeymap(): void {
353
1119
  const store = useStore();
354
1120
  useEffect(() => {
355
1121
  const onKeyDown = (e: KeyboardEvent): void => {
356
- if (store.handleKey({ ...keyOf(e), target: e.target })) e.preventDefault();
1122
+ if (store.handleKey({ ...keyOf(e), target: e.target }))
1123
+ e.preventDefault();
357
1124
  };
358
1125
  window.addEventListener("keydown", onKeyDown);
359
1126
  return () => window.removeEventListener("keydown", onKeyDown);
@@ -370,6 +1137,8 @@ function keyOf(e: KeyboardEvent) {
370
1137
  ctrlKey: e.ctrlKey,
371
1138
  metaKey: e.metaKey,
372
1139
  code: e.code,
1140
+ // A held key's OS repeats: the play toggle ignores them (`handleKey`).
1141
+ repeat: e.repeat,
373
1142
  };
374
1143
  }
375
1144
 
@@ -385,10 +1154,20 @@ function useExternalChangeRelay(): void {
385
1154
  if (e.origin !== window.location.origin) return;
386
1155
  const data = e.data as { type?: unknown; data?: unknown } | null;
387
1156
  if (data?.type !== "studio:shot-doc-changed") return;
388
- store.adoptExternalChange((data.data ?? {}) as { name?: string; origin?: string; clientId?: string });
1157
+ // THE MAIN STAGE ONLY. The inspector's component preview is a second
1158
+ // stage page (ComponentStage), and it relays the same push: two relays
1159
+ // meant two reloads of one write.
1160
+ const main = document.querySelector<HTMLIFrameElement>("iframe.stage-frame")?.contentWindow;
1161
+ if (main !== undefined && main !== null && e.source !== main) return;
1162
+ store.adoptExternalChange(
1163
+ (data.data ?? {}) as {
1164
+ name?: string;
1165
+ origin?: string;
1166
+ clientId?: string;
1167
+ },
1168
+ );
389
1169
  };
390
1170
  window.addEventListener("message", onMessage);
391
1171
  return () => window.removeEventListener("message", onMessage);
392
1172
  }, [store]);
393
1173
  }
394
-