@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
@@ -40,9 +40,15 @@ import type { PathPoint } from "@dailies-studio/shot/doc";
40
40
  import type { NavBall } from "../gizmoMath.js";
41
41
  import type { ModalAxis, ModalMode } from "../modalState.js";
42
42
  import { HANDLE_SITES, handlePoint } from "../handles.js";
43
- import type { StageInset } from "../../bridgeTypes.js";
43
+ import type { ShotBoardPin, ShotSketchGuide, StageInset } from "../../bridgeTypes.js";
44
+ import { clearOf } from "../panes.js";
44
45
  import type { PaneRect, StagePane } from "../panes.js";
46
+ import type { FailedQuad, MaskQuad, PathHandleRef, PathKeyRef, PeerQuad } from "../feed.js";
47
+ import { PATH_HANDLE_MIN_PX } from "../motionPathStage.js";
48
+ import type { ScreenMotionPath, ScreenPathKey } from "../motionPathStage.js";
45
49
  import type { ScreenQuad } from "../project.js";
50
+ import type { ScreenSpline } from "../splineStage.js";
51
+ import type { Viewport } from "../view.js";
46
52
 
47
53
  /** A smart guide, already converted to CSS px by the caller — the overlay does
48
54
  * no coordinate maths of its own, so there is one conversion in the lane. */
@@ -63,6 +69,20 @@ export interface ScreenMarquee {
63
69
  }
64
70
 
65
71
  export interface OverlayProps {
72
+ /**
73
+ * THE AUTHOR IS WATCHING: draw the frame, and nothing else in it.
74
+ *
75
+ * Most of the furniture is already gone by the time it reaches here: the
76
+ * selection quads because the stage empties the selection it projects, the
77
+ * marquee and the pen because those lanes never run, the modal badge because
78
+ * the draft is abandoned, the isolation badge because the chrome leaves
79
+ * isolation on the way in. This prop is for the two that ARE still live:
80
+ * the chrome's row-hover outline (`hintCamera`, pushed by a panel that is
81
+ * still on screen and still hovering) and the snap guides. It is also the
82
+ * honest way to say the rule once, instead of leaving it as an emergent
83
+ * property of six other things being empty.
84
+ */
85
+ watching: boolean;
66
86
  width: number;
67
87
  height: number;
68
88
  panes: { working: PaneRect | null; camera: PaneRect | null };
@@ -95,22 +115,79 @@ export interface OverlayProps {
95
115
  * which in a group tree means undoing a selection they did not want.
96
116
  */
97
117
  hover: { pane: StagePane; quad: ScreenQuad } | null;
98
- /** "Camera follows this view" is armed, so orbiting writes the document. */
99
- followsView: boolean;
100
- /** The isolated layer's label, or null when nothing is isolated. Isolation
101
- * REPLACES the working pane's camera with a derived head-on orthographic rig
102
- * (isolation.ts), so orbit, pan and dolly all stop answering — the badge is
103
- * what turns that from "the view froze" into a mode with a way out. */
104
- isolation: string | null;
118
+ /**
119
+ * The CHROME's hover hint — the layer a timeline row under the cursor names,
120
+ * projected per pane by the stage (bridge `hoverLayer`, v22). Distinct from
121
+ * `hover` (the stage's own pointer preview) and drawn identically: pointing
122
+ * at either representation of a layer lights the other.
123
+ */
124
+ hintCamera: ScreenQuad | null;
125
+ hintWorking: ScreenQuad | null;
126
+ /**
127
+ * A BROKEN PIECE'S PLACEHOLDER: the outlines of the layers the runtime
128
+ * isolated because they threw, per pane, and one line naming what failed
129
+ * (every isolated piece, layer or not), or null when nothing did. Drawn in
130
+ * the danger colour, dashed, over a faint wash, so a hole in the picture
131
+ * reads as a failure and never as a layer meant to be empty. Editor paint
132
+ * only: the export draws none of it, and refuses the frame instead.
133
+ */
134
+ failedCamera: readonly FailedQuad[];
135
+ failedWorking: readonly FailedQuad[];
136
+ /** A selected Mask group's mask, dashed (gap 7). See `feed.maskCamera`. */
137
+ maskCamera: readonly MaskQuad[];
138
+ maskWorking: readonly MaskQuad[];
139
+ /**
140
+ * OTHER PEOPLE'S SELECTIONS (bridge v43, Paper page 21): an outline in each
141
+ * person's colour, their name tagged above one layer. Under our own
142
+ * selection, and ringing it when we both hold the same layer.
143
+ */
144
+ peersCamera: readonly PeerQuad[];
145
+ peersWorking: readonly PeerQuad[];
146
+ failure: string | null;
147
+ /**
148
+ * THE STUDIO'S COMMENT PINS (bridge v45) on the frame at the playhead, with
149
+ * what a pin click calls, or null. Furniture, after everything the author
150
+ * is editing through.
151
+ */
152
+ comments: CommentOverlay | null;
153
+ /**
154
+ * THE SKETCH GUIDE (bridge v47) in camera-pane CSS px, or null: one
155
+ * storyboard sketch as dashed outlines with label chips, laid into the
156
+ * exportable frame by `sketchGuideScreen`, so it moves with the frame.
157
+ * Furniture, under the selection.
158
+ */
159
+ sketchGuide: ScreenSketchGuide | null;
160
+ /** The open Studio pin's layer in the camera pane (S5), ringed dashed.
161
+ * Drawn only while `comments` is up. */
162
+ pinLayer?: ScreenQuad | null;
163
+ /** The empty Shot's one line of help ("Press R for a rectangle..."), centred
164
+ * in the frame, or null once the Shot has a layer or a gesture is live.
165
+ * Furniture, so it never reaches an export or a still. */
166
+ emptyHint: string | null;
167
+ /**
168
+ * PILOTING the shot camera from the Camera pane, or null.
169
+ *
170
+ * The badge is drawn INSIDE the pane whose navigation is writing the
171
+ * document, with its EJECT on it: a mode the author armed has to be
172
+ * leavable from the picture it is changing (the isolation badge's rule).
173
+ * `text` is composed by the chrome, which knows what holds the camera.
174
+ */
175
+ pilot: { text: string; onEject: () => void } | null;
176
+ /**
177
+ * The component driving the render camera, by name, or null while the
178
+ * document's rig does. Said INSIDE the 3D pane, where the camera object
179
+ * follows a camera nothing in the document describes: without it, the
180
+ * gizmo's grips going dead and the inspector's fields moving nothing read
181
+ * as two unrelated breakages.
182
+ */
183
+ driver: string | null;
184
+ /** A subtree is isolated (and still in the scene). Isolation REPLACES the
185
+ * working pane's camera with a derived head-on orthographic rig
186
+ * (isolation.ts), so the pane wears a ring that says it is in a mode. The
187
+ * NAME of the subtree and the way out are the chrome's breadcrumb. */
188
+ isolating: boolean;
105
189
  marquee: ScreenMarquee | null;
106
190
  guides: readonly ScreenGuide[];
107
- /** The pen's path so far, in CAMERA-PANE CSS px, or null when no path is
108
- * being drawn. Converted by the caller, like the guides — the overlay does
109
- * no coordinate maths of its own.
110
- *
111
- * `cursor` is where the rubber band ends; `closeHot` is true when a press
112
- * there would CLOSE the loop, which the preview says by filling the first
113
- * anchor rather than by moving anything. */
114
191
  /**
115
192
  * The same guides, PROJECTED, for the working pane.
116
193
  *
@@ -125,11 +202,33 @@ export interface OverlayProps {
125
202
  * this lane keeps removing.
126
203
  */
127
204
  guidesWorking: readonly { a: readonly [number, number]; b: readonly [number, number] }[];
205
+ splineGuides: readonly { id: string; pts: readonly number[]; selected: boolean }[];
206
+ /** The selected layers' motion paths per pane (gap 3), and the key dot the
207
+ * pointer is over or dragging. */
208
+ motionPathsCamera: readonly ScreenMotionPath[];
209
+ motionPathsWorking: readonly ScreenMotionPath[];
210
+ pathKeyHover: PathKeyRef | null;
211
+ /** The dot whose Bezier handles show (schema v40), and the handle the
212
+ * pointer is over or dragging. Both null in a read-only viewer. */
213
+ pathKeySelected: PathKeyRef | null;
214
+ pathHandleHover: PathHandleRef | null;
215
+ /** The pen's path so far, in the CSS px of its pane (`ScreenPen.pane`), or
216
+ * null when no path is being drawn. Converted by the caller, like the
217
+ * guides: the overlay does no coordinate maths of its own.
218
+ *
219
+ * `cursor` is where the rubber band ends; `closeHot` is true when a press
220
+ * there would CLOSE the loop, which the preview says by filling the first
221
+ * anchor rather than by moving anything. */
128
222
  pen: ScreenPen | null;
223
+ /** A 3D spline draft or point-edit overlay, in working-pane CSS px. */
224
+ spline: ScreenSpline | null;
129
225
  /** A live modal transform's pill, or null. In-pane for the FollowBadge's
130
226
  * reason: a mode the author is inside belongs over the picture it is
131
227
  * changing, not in a panel they are not looking at. */
132
228
  modal: ScreenModal | null;
229
+ /** The constraint axis through the pivot, while a gizmo arm is held. Working
230
+ * pane only, because that is the only pane the gizmo is drawn in. */
231
+ axisGuide: ScreenAxisGuide | null;
133
232
  /** The working pane's axis indicator: six projected axis ends, and what a
134
233
  * click on one should do. Null when there is no working pane. */
135
234
  nav: ScreenNav | null;
@@ -138,17 +237,26 @@ export interface OverlayProps {
138
237
  * the camera pane has no way to show one. */
139
238
  keyLight: ScreenKeyLight | null;
140
239
  /**
141
- * How far the chrome's floating panels reach into the stage, per side.
240
+ * How far the chrome's docked panels reach into the stage, per side.
142
241
  *
143
242
  * EVERY piece of overlay furniture lays out inside it — the mode bars, the
144
- * modal pill, the nav gizmo — because the panes are the whole window and the
145
- * chrome sits on top of them. Before this, two mode bars drew entirely behind
146
- * the opaque shell header and the modal pill drew behind the left rail, so
147
- * the only thing that said a G / R / S transform was live had never been
148
- * visible in the default layout.
243
+ * modal pill, the nav gizmo — because the panes TILE the whole window and the
244
+ * chrome's opaque cells sit on top of them. In a split they tile it between
245
+ * the two of them, which is why no piece of furniture may read a raw edge
246
+ * (below). Before this, two mode bars drew entirely behind the opaque shell
247
+ * header and the modal pill drew behind the left rail, so the only thing
248
+ * that said a G / R / S transform was live had never been visible in the
249
+ * default layout.
250
+ *
251
+ * NO FURNITURE SUBTRACTS THESE EDGES ITSELF. The inset describes the WINDOW's
252
+ * clear band, not each pane's, so it is only ever read through `clearOf`
253
+ * below — see its header for the two pieces that hand-rolled the subtraction
254
+ * and both landed a whole panel's width away from where they meant to be.
149
255
  *
150
- * The pane RECTS are untouched: insetting those would move the scissor and
151
- * shrink the rendered picture. This is paint only.
256
+ * The pane RECTS still partition the whole viewport: the scissor draws
257
+ * full-bleed under the docked chrome, so nothing here shrinks the rendered
258
+ * picture or moves the hit test. (`paneRects` does take the inset now, but
259
+ * for one thing only — where the split SEAM falls.)
152
260
  */
153
261
  inset: StageInset;
154
262
  }
@@ -214,6 +322,17 @@ export interface ScreenModal {
214
322
  * "S then X" says which direction is about to move without a second look at
215
323
  * the label. */
216
324
  axis: ModalAxis;
325
+ /**
326
+ * Which pane the pill belongs in.
327
+ *
328
+ * A MODAL is working-pane-only and always says `"working"`, but this badge is
329
+ * now also every POINTER drag's readout (StageApp's `DRAG_BADGES`), and a
330
+ * body drag or a resize happens in whichever pane the author pressed. Pinning
331
+ * it to the working pane would have put the numbers for a camera-pane resize
332
+ * in a pane the author is not looking at — or, in the camera-only layout,
333
+ * nowhere at all.
334
+ */
335
+ pane: StagePane;
217
336
  }
218
337
 
219
338
  /**
@@ -230,14 +349,82 @@ export interface ScreenModal {
230
349
  */
231
350
  export const modalReadoutSink: { write: ((text: string) => void) | null } = { write: null };
232
351
 
352
+ /**
353
+ * The constraint axis, drawn through the pivot while a gesture is locked to it.
354
+ *
355
+ * ── WHY BLENDER DRAWS THIS AND WHY IT MATTERS HERE ──────────────────────
356
+ *
357
+ * A locked transform's only cue was the tinted glyph in the pill, at the far
358
+ * corner of the pane. The line puts the constraint AT THE POINT OF ACTION: it
359
+ * says which way the motion runs, where it runs off screen, and — once the
360
+ * layer has moved — how far off the axis the cursor has wandered while the
361
+ * layer stayed on it (which is exactly what an axis lock is for).
362
+ *
363
+ * A screen segment and not a world one: a perspective projection maps the 3D
364
+ * axis to a 2D line, so this IS the axis, without near-plane clipping. The
365
+ * pane's own clip path trims it.
366
+ */
367
+ export interface ScreenAxisGuide {
368
+ a: readonly [number, number];
369
+ b: readonly [number, number];
370
+ axis: "x" | "y" | "z";
371
+ /** The pane it is drawn in. The gizmo's guide is the working pane's; a
372
+ * motion path key drag (gap 3) draws in whichever pane it started in. */
373
+ pane?: StagePane;
374
+ }
375
+
233
376
  export interface ScreenPen {
377
+ /** The pane the pen draws on, the frame pane: the camera pane over a Shot,
378
+ * a definition's one pane inside it. `ScreenModal.pane`'s rule. */
379
+ pane: StagePane;
234
380
  points: readonly PathPoint[];
235
381
  closed: boolean;
236
382
  cursor: readonly [number, number] | null;
237
383
  closeHot: boolean;
238
384
  }
239
385
 
386
+ /** The sketch guide laid into the camera pane: CSS px from its top-left. */
387
+ export interface ScreenSketchGuide {
388
+ rects: readonly { x: number; y: number; width: number; height: number; radius: number; label?: string }[];
389
+ arrows: readonly { x1: number; y1: number; x2: number; y2: number; label?: string }[];
390
+ }
391
+
392
+ /**
393
+ * A guide's design px (origin at the canvas centre, +y down) laid into the
394
+ * exportable frame's rect, the one conversion the guide takes: a sketch is a
395
+ * picture of the frame, so it scales and moves with the frame and never with
396
+ * a layer.
397
+ */
398
+ export function sketchGuideScreen(
399
+ guide: ShotSketchGuide,
400
+ frame: OverlayProps["frame"],
401
+ size: { width: number; height: number },
402
+ ): ScreenSketchGuide {
403
+ const kx = frame.width / size.width;
404
+ const ky = frame.height / size.height;
405
+ const x = (dx: number): number => frame.x + (dx + size.width / 2) * kx;
406
+ const y = (dy: number): number => frame.y + (dy + size.height / 2) * ky;
407
+ return {
408
+ rects: guide.rects.map((r) => ({
409
+ x: x(r.at[0] - r.size[0] / 2),
410
+ y: y(r.at[1] - r.size[1] / 2),
411
+ width: r.size[0] * kx,
412
+ height: r.size[1] * ky,
413
+ radius: (r.radius ?? 0) * Math.min(kx, ky),
414
+ ...(r.label === undefined ? {} : { label: r.label }),
415
+ })),
416
+ arrows: guide.arrows.map((a) => ({
417
+ x1: x(a.from[0]),
418
+ y1: y(a.from[1]),
419
+ x2: x(a.to[0]),
420
+ y2: y(a.to[1]),
421
+ ...(a.label === undefined ? {} : { label: a.label }),
422
+ })),
423
+ };
424
+ }
425
+
240
426
  export function Overlays({
427
+ watching,
241
428
  width,
242
429
  height,
243
430
  panes,
@@ -248,21 +435,51 @@ export function Overlays({
248
435
  unionCamera,
249
436
  unionWorking,
250
437
  hover,
251
- followsView,
252
- isolation,
438
+ hintCamera,
439
+ hintWorking,
440
+ failedCamera,
441
+ failedWorking,
442
+ maskCamera,
443
+ maskWorking,
444
+ peersCamera,
445
+ peersWorking,
446
+ failure,
447
+ comments,
448
+ sketchGuide,
449
+ pinLayer,
450
+ emptyHint,
451
+ pilot,
452
+ driver,
453
+ isolating,
253
454
  marquee,
254
455
  guides,
255
456
  guidesWorking,
457
+ splineGuides,
458
+ motionPathsCamera,
459
+ motionPathsWorking,
460
+ pathKeyHover,
461
+ pathKeySelected,
462
+ pathHandleHover,
256
463
  pen,
464
+ spline,
257
465
  modal,
466
+ axisGuide,
258
467
  nav,
259
468
  keyLight,
260
469
  inset,
261
470
  }: OverlayProps) {
262
471
  const { working, camera } = panes;
472
+ // ONE reading of the inset per pane, and every piece of furniture below lays
473
+ // out inside it. See `clearOf`: the two sites that did this arithmetic
474
+ // themselves both charged their pane for a panel on the other side of the
475
+ // seam.
476
+ const viewport: Viewport = { width, height };
477
+ const clearWorking = working === null ? null : clearOf(working, inset, viewport);
478
+ const clearCamera = camera === null ? null : clearOf(camera, inset, viewport);
263
479
  return (
264
480
  <svg
265
481
  className="stage-overlay"
482
+ data-dailies-stage-furniture=""
266
483
  width={width}
267
484
  height={height}
268
485
  viewBox={`0 0 ${width} ${height}`}
@@ -275,7 +492,7 @@ export function Overlays({
275
492
  {camera === null ? null : <PaneClip id="shot-pane-camera" rect={camera} />}
276
493
  </defs>
277
494
 
278
- {working === null ? null : (
495
+ {working === null || clearWorking === null ? null : (
279
496
  <g clipPath="url(#shot-pane-working)" transform={`translate(${working.x} ${working.y})`}>
280
497
  {/* NO FRAME BORDER. The exportable frame is a property of the shot
281
498
  camera's projection; drawn in an orbit view it would be a
@@ -283,6 +500,36 @@ export function Overlays({
283
500
  {/* UNDER the selection: a layer that is both hovered and selected
284
501
  must read as selected, not as a heavier version of hovered. */}
285
502
  {hover?.pane === "working" ? <HoverQuad quad={hover.quad} /> : null}
503
+ {/* The chrome's row-hover, same treatment: which layer is this. */}
504
+ {hintWorking === null ? null : <HoverQuad quad={hintWorking} />}
505
+ {peersWorking.map((row, i) => (
506
+ <PeerOutline key={`peer-${i}`} {...row} />
507
+ ))}
508
+ {/* Under the selection, like the hover: a broken layer that is
509
+ selected must still read as selected. */}
510
+ {failedWorking.map((row, i) => (
511
+ <FailedPlaceholder key={`failed-${i}`} {...row} />
512
+ ))}
513
+ {/* UNDER everything the author is judging — it is a reference line,
514
+ not a mark. Dashed and half-lit for the same reason: at full
515
+ weight a line clean across the pane reads as geometry. */}
516
+ {axisGuide === null || (axisGuide.pane ?? "working") !== "working" ? null : (
517
+ <AxisGuideLine guide={axisGuide} />
518
+ )}
519
+ {splineGuides.map((guide) => (
520
+ <polyline
521
+ key={`spline-guide-${guide.id}`}
522
+ points={flatPolyline(guide.pts)}
523
+ fill="none"
524
+ stroke="var(--accent)"
525
+ strokeWidth={guide.selected ? 1.5 : 1}
526
+ strokeDasharray={guide.selected ? undefined : "4 4"}
527
+ opacity={guide.selected ? 1 : 0.72}
528
+ />
529
+ ))}
530
+ {maskWorking.map((row, i) => (
531
+ <MaskOutline key={`mask-${i}`} {...row} />
532
+ ))}
286
533
  {quadsWorking.map((q, i) => (
287
534
  // HANDLES HERE TOO, since `runTransformGesture` learned to measure
288
535
  // a resize by unprojecting two rays onto the layer's own plane. The
@@ -296,6 +543,16 @@ export function Overlays({
296
543
  />
297
544
  ))}
298
545
  {unionWorking === null ? null : <UnionBox quad={unionWorking} />}
546
+ {motionPathsWorking.map((path) => (
547
+ <MotionPathMark
548
+ key={`path-${path.layerId}`}
549
+ path={path}
550
+ hover={pathKeyHover}
551
+ selected={pathKeySelected}
552
+ handleHover={pathHandleHover}
553
+ />
554
+ ))}
555
+ {spline === null ? null : <SplinePreview spline={spline} />}
299
556
  {/* The snap guides, as the SEGMENTS they are once the plane is seen
300
557
  at an angle. Same colour and weight as the camera pane's, because
301
558
  they are the same guides. */}
@@ -306,43 +563,107 @@ export function Overlays({
306
563
  y1={g.a[1]}
307
564
  x2={g.b[0]}
308
565
  y2={g.b[1]}
309
- stroke="var(--guide, #f43f5e)"
566
+ stroke="var(--guide, #f5a524)"
310
567
  strokeWidth={1}
311
568
  />
312
569
  ))}
313
570
  {marquee?.pane === "working" ? <Marquee rect={marquee} /> : null}
571
+ {pen?.pane === "working" ? <PenPreview pen={pen} /> : null}
572
+ {isolating ? <IsolationRing clear={clearWorking} /> : null}
314
573
  {/* The mode bars STACK DOWN FROM the chrome's bottom edge, so they
315
574
  are the first thing under the header rather than behind it. */}
316
- {isolation === null ? null : (
317
- <IsolationBadge
575
+ {driver === null ? null : (
576
+ <ModeBar
318
577
  width={working.width}
319
- y={inset.top}
320
- left={inset.left}
321
- label={isolation}
578
+ y={clearWorking.y}
579
+ left={clearWorking.x}
580
+ color="var(--frustum, #eab308)"
581
+ text={`CAMERA DRIVEN BY ${driver.toUpperCase()}`}
322
582
  />
323
583
  )}
324
- {/* Under the isolation bar when both are on. Two bars at the same y
325
- would hide one mode behind the other, and isolation is the one
326
- that changed the camera. */}
327
- {followsView ? (
328
- <FollowBadge
584
+ {keyLight === null ? null : <KeyLightGizmo light={keyLight} />}
585
+ {modal?.pane === "working" ? <ModalBadge modal={modal} clear={clearWorking} /> : null}
586
+ {nav === null ? null : <NavGizmo nav={nav} clear={clearWorking} />}
587
+ {/* The failure bar in the one pane there is, when there is no
588
+ camera pane to carry it. */}
589
+ {failure === null || camera !== null ? null : (
590
+ <ModeBar
329
591
  width={working.width}
330
- y={inset.top + (isolation === null ? 0 : BAR_H)}
331
- left={inset.left}
592
+ y={clearWorking.y + clearWorking.height - BAR_H}
593
+ left={clearWorking.x}
594
+ color="var(--danger, #ff5c69)"
595
+ text={failure}
332
596
  />
333
- ) : null}
334
- {keyLight === null ? null : <KeyLightGizmo light={keyLight} />}
335
- {modal === null ? null : <ModalBadge modal={modal} rect={working} inset={inset} />}
336
- {nav === null ? null : (
337
- <NavGizmo nav={nav} width={working.width} top={inset.top} right={inset.right} />
338
597
  )}
339
598
  </g>
340
599
  )}
341
600
 
342
- {camera === null ? null : (
601
+ {camera === null || clearCamera === null ? null : (
343
602
  <g clipPath="url(#shot-pane-camera)" transform={`translate(${camera.x} ${camera.y})`}>
603
+ {/* THE FRAME BORDER SURVIVES VIDEO, and nothing else does. It is not
604
+ furniture: it is the edge of the picture being watched, and
605
+ without it a letterboxed shot has no visible boundary at all. */}
344
606
  <FrameBorder rect={frame} width={camera.width} height={camera.height} />
607
+ {/* A BROKEN PIECE SURVIVES VIDEO TOO: watching a picture with a hole
608
+ in it is exactly when the author must be told it has one. At the
609
+ bottom of the clear band, where no mode bar stacks. */}
610
+ {failedCamera.map((row, i) => (
611
+ <FailedPlaceholder key={`failed-${i}`} {...row} />
612
+ ))}
613
+ {failure === null ? null : (
614
+ <ModeBar
615
+ width={camera.width}
616
+ y={clearCamera.y + clearCamera.height - BAR_H}
617
+ left={clearCamera.x}
618
+ color="var(--danger, #ff5c69)"
619
+ text={failure}
620
+ />
621
+ )}
622
+ {watching ? null : (
623
+ <>
624
+ {/* PILOTING is this pane's mode: its middle-drag, shift-drag and
625
+ wheel are writing the shot camera. Top of the clear band. */}
626
+ {pilot === null ? null : (
627
+ <PilotBadge
628
+ width={camera.width}
629
+ y={clearCamera.y}
630
+ left={clearCamera.x}
631
+ right={clearCamera.x + clearCamera.width}
632
+ text={pilot.text}
633
+ onEject={pilot.onEject}
634
+ />
635
+ )}
636
+ {emptyHint === null ? null : (
637
+ <text
638
+ data-testid="empty-hint"
639
+ x={frame.x + frame.width / 2}
640
+ y={frame.y + frame.height / 2}
641
+ textAnchor="middle"
642
+ dominantBaseline="middle"
643
+ fill="var(--ink-3, #9c9c9c)"
644
+ // A ground under the words, painted first: the frame is
645
+ // the Shot's background, which can be any colour.
646
+ stroke="var(--surface-app, #080808)"
647
+ strokeWidth={3}
648
+ paintOrder="stroke"
649
+ fontFamily="var(--font-sans, system-ui)"
650
+ fontSize={13}
651
+ >
652
+ {emptyHint}
653
+ </text>
654
+ )}
655
+ {/* THE SKETCH GUIDE (bridge v47), first: a reference to animate
656
+ toward, under everything the author is editing. */}
657
+ {sketchGuide === null ? null : <SketchGuideOverlay guide={sketchGuide} />}
345
658
  {hover?.pane === "camera" ? <HoverQuad quad={hover.quad} /> : null}
659
+ {/* The chrome's row-hover, same treatment: which layer is this. */}
660
+ {hintCamera === null ? null : <HoverQuad quad={hintCamera} />}
661
+ {peersCamera.map((row, i) => (
662
+ <PeerOutline key={`peer-${i}`} {...row} />
663
+ ))}
664
+ {maskCamera.map((row, i) => (
665
+ <MaskOutline key={`mask-${i}`} {...row} />
666
+ ))}
346
667
  {quadsCamera.map((q, i) => (
347
668
  // A LOCKED LAYER DRAWS NO HANDLES, matching `grabAt`, which refuses
348
669
  // to answer one. A handle that cannot be grabbed is a handle the
@@ -354,6 +675,16 @@ export function Overlays({
354
675
  <SelectionQuad key={i} quad={q} handles={locked[i] !== true && unionCamera === null} />
355
676
  ))}
356
677
  {unionCamera === null ? null : <UnionBox quad={unionCamera} />}
678
+ {axisGuide?.pane !== "camera" ? null : <AxisGuideLine guide={axisGuide} />}
679
+ {motionPathsCamera.map((path) => (
680
+ <MotionPathMark
681
+ key={`path-${path.layerId}`}
682
+ path={path}
683
+ hover={pathKeyHover}
684
+ selected={pathKeySelected}
685
+ handleHover={pathHandleHover}
686
+ />
687
+ ))}
357
688
  {guides.map((g, i) =>
358
689
  g.axis === "x" ? (
359
690
  <line
@@ -362,7 +693,7 @@ export function Overlays({
362
693
  y1={0}
363
694
  x2={g.at}
364
695
  y2={camera.height}
365
- stroke="var(--guide, #f43f5e)"
696
+ stroke="var(--guide, #f5a524)"
366
697
  strokeWidth={1}
367
698
  />
368
699
  ) : (
@@ -372,13 +703,16 @@ export function Overlays({
372
703
  y1={g.at}
373
704
  x2={camera.width}
374
705
  y2={g.at}
375
- stroke="var(--guide, #f43f5e)"
706
+ stroke="var(--guide, #f5a524)"
376
707
  strokeWidth={1}
377
708
  />
378
709
  ),
379
710
  )}
380
711
  {marquee?.pane === "camera" ? <Marquee rect={marquee} /> : null}
381
- {pen === null || pen.points.length === 0 ? null : <PenPreview pen={pen} />}
712
+ {pen?.pane === "camera" ? <PenPreview pen={pen} /> : null}
713
+ {/* THE COMMENT PINS (bridge v45), over what the author is editing
714
+ through and never under it. */}
715
+ {comments === null ? null : <CommentPinsOverlay comments={comments} pinLayer={pinLayer ?? null} frame={frame} />}
382
716
  {/* THE BADGE FOLLOWS THE MODE, NOT THE PANE. Camera-only plus
383
717
  isolated is a legitimate state — the author can switch layout
384
718
  while inside a subtree — and it had zero indicators while the dim
@@ -386,13 +720,12 @@ export function Overlays({
386
720
  outside the card did nothing with nothing to say why. Drawn here
387
721
  only when the working pane is off screen, or the two would both
388
722
  render it. */}
389
- {isolation === null || working !== null ? null : (
390
- <IsolationBadge
391
- width={camera.width}
392
- y={inset.top}
393
- left={inset.left}
394
- label={isolation}
395
- />
723
+ {isolating && working === null ? <IsolationRing clear={clearCamera} /> : null}
724
+ {/* A drag that STARTED HERE reads its numbers here. Modals never do
725
+ — the keymap arms them over the working pane alone — so in
726
+ practice this is the resize / body-move / roll readout. */}
727
+ {modal?.pane === "camera" ? <ModalBadge modal={modal} clear={clearCamera} /> : null}
728
+ </>
396
729
  )}
397
730
  </g>
398
731
  )}
@@ -426,57 +759,115 @@ function PaneClip({ id, rect }: { id: string; rect: PaneRect }) {
426
759
  * A bar and not a border: a border here would be one more rectangle in a pane
427
760
  * that already draws a frustum, a selection and a divider.
428
761
  */
429
- /** Height of a mode bar. Shared so isolation can stack follow under itself
430
- * without either one restating the other's geometry. */
762
+ /** Height of a mode bar. Shared so the bars that stack can do so without
763
+ * restating each other's geometry. */
431
764
  const BAR_H = 22;
432
765
 
433
- function FollowBadge({ width, y, left }: { width: number; y: number; left: number }) {
434
- return (
435
- <ModeBar
436
- width={width}
437
- y={y}
438
- left={left}
439
- color="var(--frustum, #eab308)"
440
- text="CAMERA FOLLOWS THIS VIEW"
441
- />
442
- );
443
- }
766
+ /** How wide the EJECT plate on the pilot badge is, and its inset from the
767
+ * clear band's right edge. */
768
+ const EJECT_W = 58;
769
+ const EJECT_GAP = 8;
444
770
 
445
771
  /**
446
- * "You are inside this layer", said INSIDE the pane it is true of.
772
+ * "You are piloting the shot camera", said in the pane whose navigation is
773
+ * writing it, with the way out on the badge.
447
774
  *
448
- * Isolation is the largest mode this pane has: `StageTools` swaps a derived
449
- * orthographic head-on rig into the working camera's slot, so orbit, pan, dolly
450
- * and the nav gizmo all stop answering, and picking is scoped to the subtree.
451
- * With no in-pane sign, that reads as the view having frozen on whatever was
452
- * double-clicked — and the only thing that said otherwise was a breadcrumb in
453
- * the left panel, which `[` and `.` can hide entirely.
454
- *
455
- * THE WAY OUT IS IN THE BADGE, for the reason the badge exists at all: a mode
456
- * the author entered by accident has to be leavable without finding the panel
457
- * that documents it.
458
- *
459
- * The accent, not the frustum's yellow: the subject is a LAYER, which is the
460
- * same subject-colour rule ModalBadge follows.
775
+ * Unreal's pilot banner: the piloted thing named top-left and an eject beside
776
+ * it, because a mode that turns every drag into a document write is the one
777
+ * an author must never have to hunt for the exit of. The text is the chrome's
778
+ * (it knows whether a move or a component holds the camera); the plate is the
779
+ * one piece of this bar that takes pointer events, on the nav gizmo's pattern
780
+ * (`pointerEvents="auto"` opting back in under a `none` root).
461
781
  */
462
- function IsolationBadge({
782
+ function PilotBadge({
463
783
  width,
464
784
  y,
465
- label,
466
785
  left,
786
+ right,
787
+ text,
788
+ onEject,
467
789
  }: {
468
790
  width: number;
469
791
  y: number;
470
- label: string;
471
792
  left: number;
793
+ right: number;
794
+ text: string;
795
+ onEject: () => void;
472
796
  }) {
797
+ const x = Math.max(left, right - EJECT_GAP - EJECT_W);
473
798
  return (
474
- <ModeBar
475
- width={width}
476
- y={y}
477
- left={left}
478
- color="var(--accent, #3b82f6)"
479
- text={`INSIDE ${label.toUpperCase()} — ESC TO LEAVE`}
799
+ <g data-testid="pilot-badge">
800
+ <ModeBar width={width} y={y} left={left} color="var(--frustum, #eab308)" text={text} />
801
+ <g
802
+ pointerEvents="auto"
803
+ style={{ cursor: "pointer" }}
804
+ data-testid="pilot-eject"
805
+ onPointerDown={(e) => {
806
+ // The press is CONSUMED here: the stage's own lane would otherwise
807
+ // read it as a camera-pane press and start a marquee under the badge.
808
+ e.stopPropagation();
809
+ e.preventDefault();
810
+ onEject();
811
+ }}
812
+ >
813
+ <rect
814
+ x={x}
815
+ y={y + 3}
816
+ width={EJECT_W}
817
+ height={BAR_H - 6}
818
+ rx={3}
819
+ fill="var(--frustum, #eab308)"
820
+ fillOpacity={0.28}
821
+ />
822
+ <text
823
+ x={x + EJECT_W / 2}
824
+ y={y + 15}
825
+ textAnchor="middle"
826
+ fill="var(--frustum, #eab308)"
827
+ fontFamily="var(--font-sans, system-ui)"
828
+ fontSize={10}
829
+ fontWeight={700}
830
+ letterSpacing={0.6}
831
+ >
832
+ EJECT
833
+ </text>
834
+ </g>
835
+ </g>
836
+ );
837
+ }
838
+
839
+ /**
840
+ * "You are inside a layer", said INSIDE the pane it is true of, as a ring.
841
+ *
842
+ * Isolation is the largest mode this pane has: `StageTools` swaps a derived
843
+ * orthographic head-on rig into the working camera's slot, and picking is
844
+ * scoped to the subtree. With no in-pane sign, that reads as the view having
845
+ * frozen. The sign was once a tinted bar of capitals across the top of the
846
+ * picture ("INSIDE CARD. ESC TO LEAVE"), which the author read as cheap and
847
+ * which covered the top of the very subject they had entered to edit.
848
+ *
849
+ * The ring says only "a mode is on". WHICH layer, the trail back up and the
850
+ * way out are the breadcrumb in the stage header (`IsolationTrail`), where
851
+ * every other piece of navigation lives. It traces the clear band, so it sits
852
+ * inside the docked chrome rather than under it.
853
+ *
854
+ * The accent, not the frustum's yellow: the subject is a LAYER, which is the
855
+ * same subject-colour rule ModalBadge follows.
856
+ */
857
+ function IsolationRing({ clear }: { clear: PaneRect }) {
858
+ return (
859
+ <rect
860
+ data-testid="isolation-ring"
861
+ x={clear.x + 1}
862
+ y={clear.y + 1}
863
+ width={Math.max(0, clear.width - 2)}
864
+ height={Math.max(0, clear.height - 2)}
865
+ rx={6}
866
+ fill="none"
867
+ stroke="var(--accent, #0099ff)"
868
+ strokeOpacity={0.55}
869
+ strokeWidth={2}
870
+ pointerEvents="none"
480
871
  />
481
872
  );
482
873
  }
@@ -486,11 +877,11 @@ function ModeBar({
486
877
  y,
487
878
  color,
488
879
  text,
489
- /** How far the chrome's left rail reaches into this pane. THE TEXT IS THE
490
- * ONLY PART THAT MOVES: the tinted band is a full-width mode indicator and
491
- * reads correctly under a panel, but the words did not — they drew at x = 10,
492
- * behind an opaque rail, so the only thing naming isolation or follow mode
493
- * was invisible at every default panel width. */
880
+ /** The left edge of this pane's clear band, pane-local (`clearOf`). THE TEXT
881
+ * IS THE ONLY PART THAT MOVES: the tinted band is a full-width mode
882
+ * indicator and reads correctly under a panel, but the words did not — they
883
+ * drew at x = 10, behind an opaque rail, so the only thing naming isolation
884
+ * or follow mode was invisible at every default panel width. */
494
885
  left,
495
886
  }: {
496
887
  width: number;
@@ -507,7 +898,7 @@ function ModeBar({
507
898
  x={left + 10}
508
899
  y={y + 16}
509
900
  fill={color}
510
- fontFamily="var(--font-ui, system-ui)"
901
+ fontFamily="var(--font-sans, system-ui)"
511
902
  fontSize={11}
512
903
  fontWeight={600}
513
904
  letterSpacing={0.4}
@@ -545,26 +936,33 @@ const MODAL_GLYPHS: Record<ModalMode, string> = {
545
936
  const GLYPH_PX = 14;
546
937
  const GLYPH_GAP_PX = 6;
547
938
 
548
- /** The axis palette, the same tokens the nav gizmo and the move arms wear. */
939
+ /** The axis palette, the same tokens the nav gizmo and the move arms wear. ONE
940
+ * map for both overlay sites: the nav ball kept its own copy of the three hexes
941
+ * and drew from those instead, so re-pointing an --axis-* token moved the modal
942
+ * badge's glyph and left the ball behind. */
549
943
  const AXIS_COLORS: Record<"x" | "y" | "z", string> = {
550
944
  x: "var(--axis-x, #ef4444)",
551
945
  y: "var(--axis-y, #22c55e)",
552
946
  z: "var(--axis-z, #3b82f6)",
553
947
  };
554
948
 
555
- function ModalBadge({ modal, rect, inset }: { modal: ScreenModal; rect: PaneRect; inset: StageInset }) {
556
- const color = modal.camera ? "var(--frustum, #eab308)" : "var(--accent, #3b82f6)";
949
+ function ModalBadge({ modal, clear }: { modal: ScreenModal; clear: PaneRect }) {
950
+ const color = modal.camera ? "var(--frustum, #eab308)" : "var(--accent, #0099ff)";
557
951
  // The glyph wears the AXIS's colour when one is locked, and the pill's own
558
952
  // otherwise. The lock is the thing most worth seeing at a glance, and it is
559
953
  // the one piece of modal state that changes mid-gesture.
560
954
  const glyphColor = modal.axis === null ? color : AXIS_COLORS[modal.axis];
561
955
  const shift = GLYPH_PX + GLYPH_GAP_PX;
562
- // INSIDE the chrome on both axes. It sat at x = 10 and y = height - 34, which
563
- // is behind the 232px left rail and behind the timeline dock — so the only
564
- // thing that says a transform is live has never been visible in the default
565
- // layout.
566
- const x = inset.left + 10;
567
- const y = rect.height - inset.bottom - 34;
956
+ // INSIDE THIS PANE'S CLEAR BAND, on both axes. It sat at x = 10 and
957
+ // y = height - 34, which is behind the 232px left rail and behind the
958
+ // timeline dock — so the only thing that says a transform is live had never
959
+ // been visible in the default layout. It then sat at `inset.left + 10`, which
960
+ // is right for the working pane (whose origin IS the window's) and wrong for
961
+ // a camera pane in a split, where pane-local x is measured from the seam: a
962
+ // camera-pane resize put its numbers a rail's width in from an edge no rail
963
+ // touches. `clear` is the answer for whichever pane this is. See `clearOf`.
964
+ const x = clear.x + 10;
965
+ const y = clear.y + clear.height - 34;
568
966
  // The live numbers, written straight into the node. See `modalReadoutSink`:
569
967
  // the readout changes per pointermove and the pill is memoised on state, so
570
968
  // the two have to meet outside React or motion costs a stage commit.
@@ -578,7 +976,7 @@ function ModalBadge({ modal, rect, inset }: { modal: ScreenModal; rect: PaneRect
578
976
  };
579
977
  }, []);
580
978
  return (
581
- <g pointerEvents="none">
979
+ <g data-testid="modal-badge" pointerEvents="none">
582
980
  <rect
583
981
  x={x}
584
982
  y={y}
@@ -588,7 +986,7 @@ function ModalBadge({ modal, rect, inset }: { modal: ScreenModal; rect: PaneRect
588
986
  // plate; a pill too NARROW would clip the figure the author is reading.
589
987
  width={modal.label.length * 7.5 + 96 + shift}
590
988
  height={24}
591
- fill="var(--panel, #1c1c20)"
989
+ fill="var(--surface-panel, #111111)"
592
990
  fillOpacity={0.88}
593
991
  stroke={color}
594
992
  strokeWidth={1}
@@ -609,7 +1007,7 @@ function ModalBadge({ modal, rect, inset }: { modal: ScreenModal; rect: PaneRect
609
1007
  x={x + 10 + shift}
610
1008
  y={y + 16}
611
1009
  fill={color}
612
- fontFamily="var(--font-ui, system-ui)"
1010
+ fontFamily="var(--font-sans, system-ui)"
613
1011
  fontSize={11}
614
1012
  fontWeight={600}
615
1013
  letterSpacing={0.6}
@@ -620,7 +1018,7 @@ function ModalBadge({ modal, rect, inset }: { modal: ScreenModal; rect: PaneRect
620
1018
  ref={readout}
621
1019
  x={x + modal.label.length * 7.5 + 22 + shift}
622
1020
  y={y + 16}
623
- fill="var(--text, #e7e7ea)"
1021
+ fill="var(--ink-1, #f5f5f5)"
624
1022
  fontFamily="var(--font-mono, ui-monospace, monospace)"
625
1023
  fontSize={11}
626
1024
  fontWeight={500}
@@ -659,7 +1057,7 @@ function KeyLightGizmo({ light }: { light: ScreenKeyLight }) {
659
1057
  style={{ cursor: "grab" }}
660
1058
  onPointerDown={light.onGrab}
661
1059
  >
662
- <circle r={KEY_DISC_PX} fill="var(--panel, #1c1c20)" fillOpacity={0.9} stroke={color} strokeWidth={1.5} />
1060
+ <circle r={KEY_DISC_PX} fill="var(--surface-panel, #111111)" fillOpacity={0.9} stroke={color} strokeWidth={1.5} />
663
1061
  {/* Four short rays, so the disc reads as a key rather than as another
664
1062
  handle. Drawn at the diagonals to stay clear of the arm. */}
665
1063
  {[45, 135, 225, 315].map((deg) => {
@@ -710,29 +1108,23 @@ const NAV_MARGIN = 16;
710
1108
  * letter Y on the arm that moves a layer the opposite way from the number the
711
1109
  * author is watching.
712
1110
  *
713
- * Top-right, which is where every 3D tool puts it — but pushed down past the
714
- * chrome that floats over this pane's own top edge. `top` is the chrome's own
715
- * measurement, sent across the bridge, and it replaced a hardcoded 116 that a
1111
+ * Top-right of the part of this pane the chrome is not covering, which is where
1112
+ * every 3D tool puts it. Staying clear of the panels is not cosmetic here: the
1113
+ * gizmo is the only piece of overlay furniture that TAKES POINTER EVENTS, so
1114
+ * drawing it under an opaque panel makes it unclickable rather than merely
1115
+ * hidden. That reach used to be a hardcoded 116 for the top edge, which a
716
1116
  * wrapping control strip on a narrow window grew straight past.
1117
+ *
1118
+ * IT ASKS FOR A CLEAR RECT AND SUBTRACTS NOTHING. It used to compute
1119
+ * `width - inset.right - gap` from the pane's own width, and in a split that is
1120
+ * a double count: the inspector sits on the far side of the SEAM and covers no
1121
+ * part of the working pane, so the gizmo was shoved an inspector's width left,
1122
+ * into the middle of the picture it is meant to sit beside. See `clearOf`.
717
1123
  */
718
- function NavGizmo({
719
- nav,
720
- width,
721
- top,
722
- /** How far the inspector reaches into this pane from the right. Unlike the
723
- * mode bars', this inset is not cosmetic: the gizmo is the only piece of
724
- * overlay furniture that TAKES POINTER EVENTS, so drawing it under an opaque
725
- * panel makes it unclickable rather than merely hidden. */
726
- right,
727
- }: {
728
- nav: ScreenNav;
729
- width: number;
730
- top: number;
731
- right: number;
732
- }) {
1124
+ function NavGizmo({ nav, clear }: { nav: ScreenNav; clear: PaneRect }) {
733
1125
  const gap = NAV_RADIUS + NAV_BALL + NAV_MARGIN;
734
- const cx = width - right - gap;
735
- const cy = top + gap;
1126
+ const cx = clear.x + clear.width - gap;
1127
+ const cy = clear.y + gap;
736
1128
  // FAR FIRST. A flat drawing has no depth buffer, so paint order is the only
737
1129
  // cue for which end of an axis is pointing at the author — and without it a
738
1130
  // gizmo seen from behind reads exactly like one seen from the front.
@@ -746,7 +1138,7 @@ function NavGizmo({
746
1138
  cx={cx}
747
1139
  cy={cy}
748
1140
  r={NAV_RADIUS + NAV_BALL}
749
- fill="var(--panel, #0c0c0e)"
1141
+ fill="var(--surface-panel, #111111)"
750
1142
  fillOpacity={0.6}
751
1143
  pointerEvents="auto"
752
1144
  style={{ cursor: "grab" }}
@@ -755,7 +1147,7 @@ function NavGizmo({
755
1147
  {balls.map((ball) => {
756
1148
  const x = cx + ball.x * NAV_RADIUS;
757
1149
  const y = cy + ball.y * NAV_RADIUS;
758
- const color = `var(--axis-${ball.axis}, ${NAV_FALLBACK[ball.axis]})`;
1150
+ const color = AXIS_COLORS[ball.axis];
759
1151
  return (
760
1152
  <g
761
1153
  key={`${ball.axis}${ball.positive ? "+" : "-"}`}
@@ -780,7 +1172,7 @@ function NavGizmo({
780
1172
  // FILLED positive, HOLLOW negative — the one thing that tells the
781
1173
  // author whether they are looking at +Z or at -Z when the two
782
1174
  // project to the same place.
783
- fill={ball.positive ? color : "var(--panel, #0c0c0e)"}
1175
+ fill={ball.positive ? color : "var(--surface-panel, #111111)"}
784
1176
  stroke={color}
785
1177
  strokeWidth={1.5}
786
1178
  />
@@ -789,8 +1181,8 @@ function NavGizmo({
789
1181
  x={x}
790
1182
  y={y + 3.5}
791
1183
  textAnchor="middle"
792
- fill="var(--bg, #000)"
793
- fontFamily="var(--font-ui, system-ui)"
1184
+ fill="var(--surface-app, #080808)"
1185
+ fontFamily="var(--font-sans, system-ui)"
794
1186
  fontSize={10}
795
1187
  fontWeight={700}
796
1188
  >
@@ -804,12 +1196,6 @@ function NavGizmo({
804
1196
  );
805
1197
  }
806
1198
 
807
- const NAV_FALLBACK: Readonly<Record<"x" | "y" | "z", string>> = {
808
- x: "#ef4444",
809
- y: "#22c55e",
810
- z: "#3b82f6",
811
- };
812
-
813
1199
  /** The seam between the panes. It belongs to the CAMERA pane's first column,
814
1200
  * which is what `paneAt` decides, so it is drawn ON that column rather than
815
1201
  * straddling it — a half-pixel straddle would put the drawn edge and the
@@ -821,7 +1207,7 @@ function Divider({ x, height }: { x: number; height: number }) {
821
1207
  y={0}
822
1208
  width={1}
823
1209
  height={height}
824
- fill="var(--stage-divider, #232328)"
1210
+ fill="var(--stage-divider, #2a2a2a)"
825
1211
  shapeRendering="crispEdges"
826
1212
  />
827
1213
  );
@@ -857,12 +1243,13 @@ function FrameBorder({
857
1243
  fill="rgba(0,0,0,0.45)"
858
1244
  />
859
1245
  <rect
1246
+ data-testid="stage-frame-border"
860
1247
  x={rect.x}
861
1248
  y={rect.y}
862
1249
  width={rect.width}
863
1250
  height={rect.height}
864
1251
  fill="none"
865
- stroke="var(--frame-border, #6b7280)"
1252
+ stroke="var(--frame-border, rgba(255, 255, 255, 0.22))"
866
1253
  strokeWidth={1}
867
1254
  shapeRendering="crispEdges"
868
1255
  />
@@ -878,18 +1265,290 @@ function HoverQuad({ quad }: { quad: ScreenQuad }) {
878
1265
  <polygon
879
1266
  points={pointsOf(quad)}
880
1267
  fill="none"
881
- stroke="var(--accent, #3b82f6)"
1268
+ stroke="var(--accent, #0099ff)"
882
1269
  strokeOpacity={0.5}
883
1270
  strokeWidth={1}
884
1271
  />
885
1272
  );
886
1273
  }
887
1274
 
1275
+ /** Paper page 21: a peer's tag is 19 px tall, 6 px each side, sitting on the
1276
+ * outline's top-left corner, which it squares. */
1277
+ const PEER_TAG_H = 19;
1278
+ const PEER_RING = 3;
1279
+
1280
+ /** A peer's outline in their colour, and when it carries the tag, their name
1281
+ * on that colour above it. A layer we hold too gets the outline as a ring
1282
+ * `PEER_RING` px outside our own, so both read. */
1283
+ function PeerOutline({ quad, name, color, tag, ring }: PeerQuad) {
1284
+ const shape = ring ? ringQuad(quad, PEER_RING) : quad;
1285
+ const x = Math.min(shape[0], shape[2], shape[4], shape[6]);
1286
+ const y = Math.min(shape[1], shape[3], shape[5], shape[7]);
1287
+ const label = peerLabel(name);
1288
+ const width = label.length * PLACEHOLDER_CHAR_PX + 12;
1289
+ return (
1290
+ <g data-testid="stage-peer-selection" data-peer={name}>
1291
+ <polygon points={pointsOf(shape)} fill="none" stroke={color} strokeWidth={1.5} />
1292
+ {tag ? (
1293
+ <>
1294
+ <rect x={x - 0.75} y={y - PEER_TAG_H} width={width} height={PEER_TAG_H} rx={4} fill={color} />
1295
+ {/* The corner on the outline is square. */}
1296
+ <rect x={x - 0.75} y={y - 8} width={8} height={8} fill={color} />
1297
+ <text
1298
+ x={x + 5.25}
1299
+ y={y - 5}
1300
+ fill="#0B0B0B"
1301
+ fontFamily="var(--font-sans, Inter, system-ui)"
1302
+ fontSize={11}
1303
+ fontWeight={600}
1304
+ >
1305
+ {label}
1306
+ </text>
1307
+ </>
1308
+ ) : null}
1309
+ </g>
1310
+ );
1311
+ }
1312
+
1313
+ /** A name fits a tag of at most ~200 px. */
1314
+ const PEER_NAME_MAX = 28;
1315
+ export function peerLabel(name: string): string {
1316
+ return name.length <= PEER_NAME_MAX ? name : `${name.slice(0, PEER_NAME_MAX - 1).trimEnd()}…`;
1317
+ }
1318
+
1319
+ /** The quad grown by `by` px on every side: each corner pushed away from the
1320
+ * centre along both axes. Exact for an upright box, close for a turned one. */
1321
+ export function ringQuad(quad: ScreenQuad, by: number): ScreenQuad {
1322
+ const cx = (quad[0] + quad[2] + quad[4] + quad[6]) / 4;
1323
+ const cy = (quad[1] + quad[3] + quad[5] + quad[7]) / 4;
1324
+ const out: number[] = [...quad];
1325
+ for (let i = 0; i < 8; i += 2) {
1326
+ out[i] = quad[i]! + Math.sign(quad[i]! - cx) * by;
1327
+ out[i + 1] = quad[i + 1]! + Math.sign(quad[i + 1]! - cy) * by;
1328
+ }
1329
+ return out as unknown as ScreenQuad;
1330
+ }
1331
+
1332
+ /** Where an isolated layer would draw: a dashed danger outline over a faint
1333
+ * danger wash, and a short label in its top-left corner. Dashed so it never
1334
+ * reads as a selection, washed so the hole reads as a place and not only an
1335
+ * edge, labelled so an empty box is never mistaken for an empty layer. The
1336
+ * reason is the inspector's. The layer stays selectable, movable and
1337
+ * deletable: picking reads the layer's frame, which the runtime still writes,
1338
+ * never what it drew. */
1339
+ function FailedPlaceholder({ quad, label }: FailedQuad) {
1340
+ const x = Math.min(quad[0], quad[2], quad[4], quad[6]);
1341
+ const y = Math.min(quad[1], quad[3], quad[5], quad[7]);
1342
+ const width = Math.max(quad[0], quad[2], quad[4], quad[6]) - x;
1343
+ return (
1344
+ <g data-testid="stage-failed-piece">
1345
+ <polygon
1346
+ points={pointsOf(quad)}
1347
+ fill="var(--danger, #ff5c69)"
1348
+ fillOpacity={0.1}
1349
+ stroke="var(--danger, #ff5c69)"
1350
+ strokeWidth={1.5}
1351
+ strokeDasharray="6 4"
1352
+ />
1353
+ <text
1354
+ x={x + 6}
1355
+ y={y + 16}
1356
+ fill="var(--danger, #ff5c69)"
1357
+ fontFamily="var(--font-sans, system-ui)"
1358
+ fontSize={11}
1359
+ fontWeight={600}
1360
+ >
1361
+ {placeholderLine(label, width - 12)}
1362
+ </text>
1363
+ </g>
1364
+ );
1365
+ }
1366
+
1367
+ /** About one 11 px semibold sans character. An estimate: SVG text has no
1368
+ * ellipsis, and measuring would need a layout pass per frame. */
1369
+ const PLACEHOLDER_CHAR_PX = 6.6;
1370
+ const DID_NOT_LOAD = " did not load";
1371
+
1372
+ /** "<name> did not load", cut to `room` px. The name gives way first, so the
1373
+ * line keeps saying what happened for as long as it fits. */
1374
+ export function placeholderLine(label: string, room: number): string {
1375
+ const chars = Math.floor(room / PLACEHOLDER_CHAR_PX);
1376
+ const line = `${label}${DID_NOT_LOAD}`;
1377
+ if (line.length <= chars) return line;
1378
+ const nameRoom = chars - DID_NOT_LOAD.length - 1;
1379
+ if (nameRoom >= 1) return `${label.slice(0, nameRoom).trimEnd()}…${DID_NOT_LOAD}`;
1380
+ return chars >= 2 ? `${line.slice(0, chars - 1).trimEnd()}…` : "";
1381
+ }
1382
+
1383
+ /** A selected Mask group's mask (Paper page 25, "Mask outline (dashed)"): the
1384
+ * selection's accent at its 1.5 px, dashed so it reads as the matte's edge
1385
+ * and never as a second selection. */
1386
+ function MaskOutline({ quad, round }: MaskQuad) {
1387
+ return round ? (
1388
+ <path
1389
+ data-testid="stage-mask-outline"
1390
+ d={inscribedEllipse(quad)}
1391
+ fill="none"
1392
+ stroke="var(--accent, #0099ff)"
1393
+ strokeWidth={1.5}
1394
+ strokeDasharray="4 3"
1395
+ />
1396
+ ) : (
1397
+ <polygon
1398
+ data-testid="stage-mask-outline"
1399
+ points={pointsOf(quad)}
1400
+ fill="none"
1401
+ stroke="var(--accent, #0099ff)"
1402
+ strokeWidth={1.5}
1403
+ strokeDasharray="4 3"
1404
+ />
1405
+ );
1406
+ }
1407
+
1408
+ /** The ellipse inscribed in a quad, as four cubics through its edge midpoints.
1409
+ * Each control point sits `KAPPA` of the way from a midpoint to the corner it
1410
+ * turns about, the circle's cubic, and an affine map keeps it exact for any
1411
+ * parallelogram. A perspective quad gets a close approximation. */
1412
+ const KAPPA = 0.5523;
1413
+ export function inscribedEllipse(quad: ScreenQuad): string {
1414
+ const pt = (i: number): [number, number] => [quad[(i % 4) * 2]!, quad[(i % 4) * 2 + 1]!];
1415
+ const mid = (i: number): [number, number] => {
1416
+ const [ax, ay] = pt(i);
1417
+ const [bx, by] = pt(i + 1);
1418
+ return [(ax + bx) / 2, (ay + by) / 2];
1419
+ };
1420
+ const toward = (m: [number, number], c: [number, number]): string =>
1421
+ `${m[0] + KAPPA * (c[0] - m[0])} ${m[1] + KAPPA * (c[1] - m[1])}`;
1422
+ let d = `M ${mid(0)[0]} ${mid(0)[1]}`;
1423
+ for (let i = 0; i < 4; i++) {
1424
+ // From the midpoint of edge i, about corner i + 1, to the midpoint of edge i + 1.
1425
+ const corner = pt(i + 1);
1426
+ const to = mid(i + 1);
1427
+ d += ` C ${toward(mid(i), corner)} ${toward(to, corner)} ${to[0]} ${to[1]}`;
1428
+ }
1429
+ return `${d} Z`;
1430
+ }
1431
+
1432
+ /** Half the current-frame tick's length, CSS px. */
1433
+ const PATH_TICK_HALF = 6;
1434
+
1435
+ function AxisGuideLine({ guide }: { guide: ScreenAxisGuide }) {
1436
+ return (
1437
+ <line
1438
+ x1={guide.a[0]}
1439
+ y1={guide.a[1]}
1440
+ x2={guide.b[0]}
1441
+ y2={guide.b[1]}
1442
+ stroke={AXIS_COLORS[guide.axis]}
1443
+ strokeWidth={1}
1444
+ strokeDasharray="6 5"
1445
+ opacity={0.7}
1446
+ />
1447
+ );
1448
+ }
1449
+
1450
+ /**
1451
+ * One layer's motion path (gap 3): a 1px accent line through hollow key dots,
1452
+ * the key under the playhead filled, a hold's step dashed, and a short tick
1453
+ * across the line where the layer is now. A path outside the layer's lifetime
1454
+ * draws dimmed, since the layer itself is not on screen.
1455
+ */
1456
+ function MotionPathMark({
1457
+ path,
1458
+ hover,
1459
+ selected,
1460
+ handleHover,
1461
+ }: {
1462
+ path: ScreenMotionPath;
1463
+ hover: PathKeyRef | null;
1464
+ selected: PathKeyRef | null;
1465
+ handleHover: PathHandleRef | null;
1466
+ }) {
1467
+ const picked = selected !== null && selected.layerId === path.layerId ? selected.keyId : null;
1468
+ const pickedKey = picked === null ? undefined : path.keys.find((k) => k.id === picked);
1469
+ return (
1470
+ <g data-testid="motion-path" data-layer={path.layerId} opacity={path.dim ? 0.4 : 1}>
1471
+ {path.runs.map((run, i) => (
1472
+ <polyline
1473
+ key={i}
1474
+ points={flatPolyline(run.pts)}
1475
+ fill="none"
1476
+ stroke="var(--accent, #0099ff)"
1477
+ strokeWidth={1}
1478
+ strokeDasharray={run.jump ? "4 4" : undefined}
1479
+ />
1480
+ ))}
1481
+ {path.tick === null ? null : (
1482
+ <line
1483
+ x1={path.tick.x - path.tick.nx * PATH_TICK_HALF}
1484
+ y1={path.tick.y - path.tick.ny * PATH_TICK_HALF}
1485
+ x2={path.tick.x + path.tick.nx * PATH_TICK_HALF}
1486
+ y2={path.tick.y + path.tick.ny * PATH_TICK_HALF}
1487
+ stroke="var(--ink-1, #f5f5f5)"
1488
+ strokeWidth={1.5}
1489
+ />
1490
+ )}
1491
+ {/* Off the bezier arm a dot has no handles, so nothing draws. */}
1492
+ {pickedKey === undefined || (pickedKey.in === undefined && pickedKey.out === undefined) ? null : (
1493
+ <PathKeyHandles
1494
+ dot={pickedKey}
1495
+ hot={handleHover !== null && handleHover.layerId === path.layerId && handleHover.keyId === pickedKey.id ? handleHover.part : null}
1496
+ />
1497
+ )}
1498
+ {path.keys.map((key) => {
1499
+ const hot =
1500
+ (hover !== null && hover.layerId === path.layerId && hover.keyId === key.id) || key.id === picked;
1501
+ return (
1502
+ <circle
1503
+ key={key.id}
1504
+ data-testid="motion-path-key"
1505
+ data-current={key.current ? "" : undefined}
1506
+ cx={key.x}
1507
+ cy={key.y}
1508
+ r={key.current ? 4.5 : hot ? 5 : 4}
1509
+ fill={key.current ? "var(--accent, #0099ff)" : "var(--surface-panel, #111111)"}
1510
+ stroke={key.current || hot ? "var(--ink-1, #f5f5f5)" : "var(--accent, #0099ff)"}
1511
+ strokeWidth={hot ? 2 : 1.5}
1512
+ />
1513
+ );
1514
+ })}
1515
+ </g>
1516
+ );
1517
+ }
1518
+
1519
+ /** The selected dot's Bezier handles (schema v40), in the spline editor's
1520
+ * look. A tip on its dot (a corner) is not drawn: the dot is what a press
1521
+ * there grabs, and `motionPathHandleAt` skips it by the same distance. */
1522
+ function PathKeyHandles({ dot, hot }: { dot: ScreenPathKey; hot: "in" | "out" | null }) {
1523
+ return (
1524
+ <g data-testid="motion-path-handles">
1525
+ {(["in", "out"] as const).map((part) => {
1526
+ const tip = dot[part];
1527
+ if (tip === undefined || Math.hypot(tip.x - dot.x, tip.y - dot.y) < PATH_HANDLE_MIN_PX) return null;
1528
+ return (
1529
+ <g key={part} data-testid={`motion-path-handle-${part}`}>
1530
+ <line
1531
+ x1={dot.x}
1532
+ y1={dot.y}
1533
+ x2={tip.x}
1534
+ y2={tip.y}
1535
+ stroke="var(--accent, #0099ff)"
1536
+ strokeWidth={1}
1537
+ opacity={0.64}
1538
+ />
1539
+ <SplineHandle at={[tip.x, tip.y]} selected={hot === part} />
1540
+ </g>
1541
+ );
1542
+ })}
1543
+ </g>
1544
+ );
1545
+ }
1546
+
888
1547
  function SelectionQuad({ quad, handles }: { quad: ScreenQuad; handles?: boolean }) {
889
1548
  const points = pointsOf(quad);
890
1549
  return (
891
1550
  <>
892
- <polygon points={points} fill="none" stroke="var(--accent, #3b82f6)" strokeWidth={1.5} />
1551
+ <polygon points={points} fill="none" stroke="var(--accent, #0099ff)" strokeWidth={1.5} />
893
1552
  {/* THE SAME TABLE `grabAt` HIT-TESTS, imported rather than restated: a
894
1553
  drawn handle the hit test does not answer, or an answered one nobody
895
1554
  drew, is the exact bug the hover cursor exists to make impossible. */}
@@ -904,8 +1563,8 @@ function SelectionQuad({ quad, handles }: { quad: ScreenQuad; handles?: boolean
904
1563
  y={y - 3.5}
905
1564
  width={7}
906
1565
  height={7}
907
- fill="var(--panel, #1c1c20)"
908
- stroke="var(--accent, #3b82f6)"
1566
+ fill="var(--surface-panel, #111111)"
1567
+ stroke="var(--accent, #0099ff)"
909
1568
  strokeWidth={1.5}
910
1569
  />
911
1570
  );
@@ -934,7 +1593,7 @@ function UnionBox({ quad }: { quad: ScreenQuad }) {
934
1593
  <polygon
935
1594
  points={pointsOf(quad)}
936
1595
  fill="none"
937
- stroke="var(--accent, #3b82f6)"
1596
+ stroke="var(--accent, #0099ff)"
938
1597
  strokeWidth={1}
939
1598
  strokeDasharray="4 3"
940
1599
  />
@@ -947,8 +1606,8 @@ function UnionBox({ quad }: { quad: ScreenQuad }) {
947
1606
  y={y - 3.5}
948
1607
  width={7}
949
1608
  height={7}
950
- fill="var(--panel, #1c1c20)"
951
- stroke="var(--accent, #3b82f6)"
1609
+ fill="var(--surface-panel, #111111)"
1610
+ stroke="var(--accent, #0099ff)"
952
1611
  strokeWidth={1.5}
953
1612
  />
954
1613
  );
@@ -957,6 +1616,206 @@ function UnionBox({ quad }: { quad: ScreenQuad }) {
957
1616
  );
958
1617
  }
959
1618
 
1619
+ /** The comment pins on the frame, with what a pin click calls. */
1620
+ export interface CommentOverlay {
1621
+ pins: readonly ShotBoardPin[];
1622
+ onPin: (threadId: string) => void;
1623
+ }
1624
+
1625
+ /** A pin's outline (S5): a 28px teardrop, radii 14/14/14/3, the square
1626
+ * corner bottom-left, sitting ON the point it marks. */
1627
+ const PIN_PATH = "M0 -14 A14 14 0 0 1 14 -28 A14 14 0 0 1 28 -14 A14 14 0 0 1 14 0 H3 A3 3 0 0 1 0 -3 Z";
1628
+
1629
+ /** S5's ring around the open Studio pin's layer: dashed, 4px out. */
1630
+ const PIN_LAYER_OFFSET = 4;
1631
+
1632
+ /** `quad` grown by `d` px on every side: each edge pushed out along its
1633
+ * normal, corners where the pushed edges meet. Right for a rotated or
1634
+ * skewed layer, which an axis box is not. */
1635
+ export function outsetQuad(quad: ScreenQuad, d: number): ScreenQuad {
1636
+ const pts = [0, 1, 2, 3].map((i) => [quad[i * 2]!, quad[i * 2 + 1]!] as const);
1637
+ let area = 0;
1638
+ for (let i = 0; i < 4; i++) {
1639
+ const [x0, y0] = pts[i]!;
1640
+ const [x1, y1] = pts[(i + 1) % 4]!;
1641
+ area += x0 * y1 - x1 * y0;
1642
+ }
1643
+ const side = area >= 0 ? 1 : -1;
1644
+ const normal = (a: readonly [number, number], b: readonly [number, number]): [number, number] => {
1645
+ const dx = b[0] - a[0];
1646
+ const dy = b[1] - a[1];
1647
+ const len = Math.hypot(dx, dy) || 1;
1648
+ return [(dy / len) * side, (-dx / len) * side];
1649
+ };
1650
+ const out: number[] = [];
1651
+ for (let i = 0; i < 4; i++) {
1652
+ const prev = pts[(i + 3) % 4]!;
1653
+ const here = pts[i]!;
1654
+ const next = pts[(i + 1) % 4]!;
1655
+ const n1 = normal(prev, here);
1656
+ const n2 = normal(here, next);
1657
+ const k = d / Math.max(0.2, 1 + n1[0] * n2[0] + n1[1] * n2[1]);
1658
+ out.push(here[0] + (n1[0] + n2[0]) * k, here[1] + (n1[1] + n2[1]) * k);
1659
+ }
1660
+ return out as unknown as ScreenQuad;
1661
+ }
1662
+
1663
+ /** The label chip's text size and the room it takes, CSS px (Paper SB6:
1664
+ * 10/14 mono on the accent, padding 1 / 5). */
1665
+ const GUIDE_CHIP_FONT = 10;
1666
+ const GUIDE_CHIP_H = 16;
1667
+ const GUIDE_CHIP_PAD = 5;
1668
+ /** A monospace glyph's advance at that size, to size a chip without
1669
+ * measuring text. */
1670
+ const GUIDE_CHIP_ADVANCE = 6.1;
1671
+
1672
+ /** A label chip's width for `label`: mono advance plus padding. */
1673
+ export function guideChipWidth(label: string): number {
1674
+ return Math.ceil(label.length * GUIDE_CHIP_ADVANCE) + GUIDE_CHIP_PAD * 2;
1675
+ }
1676
+
1677
+ function GuideChip({ x, y, label }: { x: number; y: number; label: string }) {
1678
+ return (
1679
+ <g transform={`translate(${x} ${y})`}>
1680
+ <rect width={guideChipWidth(label)} height={GUIDE_CHIP_H} rx={3} fill="var(--accent, #0099ff)" />
1681
+ <text
1682
+ x={GUIDE_CHIP_PAD}
1683
+ y={GUIDE_CHIP_H / 2}
1684
+ dominantBaseline="central"
1685
+ fill="var(--on-accent, #ffffff)"
1686
+ fontFamily="var(--font-numeric, ui-monospace, monospace)"
1687
+ fontSize={GUIDE_CHIP_FONT}
1688
+ >
1689
+ {label}
1690
+ </text>
1691
+ </g>
1692
+ );
1693
+ }
1694
+
1695
+ /**
1696
+ * THE SKETCH GUIDE (bridge v47): each sketch item as a 1px dashed accent
1697
+ * outline with its own corner, its label chip on the outline's top edge, and
1698
+ * each arrow as a dashed line with a head. Paint only.
1699
+ */
1700
+ function SketchGuideOverlay({ guide }: { guide: ScreenSketchGuide }) {
1701
+ return (
1702
+ <g data-testid="sketch-guide" pointerEvents="none">
1703
+ {guide.rects.map((r, i) => (
1704
+ <g key={`r-${i}`}>
1705
+ <rect
1706
+ x={r.x}
1707
+ y={r.y}
1708
+ width={r.width}
1709
+ height={r.height}
1710
+ rx={r.radius}
1711
+ fill="none"
1712
+ stroke="var(--accent, #0099ff)"
1713
+ strokeWidth={1}
1714
+ strokeDasharray="6 4"
1715
+ />
1716
+ {r.label === undefined ? null : <GuideChip x={r.x} y={r.y - GUIDE_CHIP_H - 2} label={r.label} />}
1717
+ </g>
1718
+ ))}
1719
+ {guide.arrows.map((a, i) => {
1720
+ const len = Math.hypot(a.x2 - a.x1, a.y2 - a.y1) || 1;
1721
+ const ux = (a.x2 - a.x1) / len;
1722
+ const uy = (a.y2 - a.y1) / len;
1723
+ const head = `${a.x2},${a.y2} ${a.x2 - ux * 8 - uy * 4},${a.y2 - uy * 8 + ux * 4} ${a.x2 - ux * 8 + uy * 4},${a.y2 - uy * 8 - ux * 4}`;
1724
+ return (
1725
+ <g key={`a-${i}`}>
1726
+ <line x1={a.x1} y1={a.y1} x2={a.x2} y2={a.y2} stroke="var(--accent, #0099ff)" strokeWidth={1} strokeDasharray="6 4" />
1727
+ <polygon points={head} fill="var(--accent, #0099ff)" />
1728
+ {a.label === undefined ? null : (
1729
+ <GuideChip x={(a.x1 + a.x2) / 2 - guideChipWidth(a.label) / 2} y={(a.y1 + a.y2) / 2 - GUIDE_CHIP_H - 4} label={a.label} />
1730
+ )}
1731
+ </g>
1732
+ );
1733
+ })}
1734
+ </g>
1735
+ );
1736
+ }
1737
+
1738
+ /** One comment pin (bridge v42). The one thing on the frame's comment layer
1739
+ * that takes the pointer: the press is consumed, like the pilot badge's
1740
+ * eject, so it opens the thread and never reaches a pick under it. */
1741
+ function CommentPin({ pin, x, y, onPin }: { pin: ShotBoardPin; x: number; y: number; onPin: (threadId: string) => void }) {
1742
+ return (
1743
+ <g
1744
+ data-testid="comment-pin"
1745
+ data-thread={pin.threadId}
1746
+ role="button"
1747
+ aria-label={pin.label}
1748
+ transform={`translate(${x} ${y})`}
1749
+ pointerEvents="auto"
1750
+ style={{ cursor: "pointer" }}
1751
+ onPointerDown={(e) => {
1752
+ e.stopPropagation();
1753
+ e.preventDefault();
1754
+ onPin(pin.threadId);
1755
+ }}
1756
+ >
1757
+ <title>{pin.label}</title>
1758
+ {pin.active ? (
1759
+ <path d={PIN_PATH} fill="none" stroke="var(--accent, #0099ff)" strokeOpacity={0.35} strokeWidth={12} />
1760
+ ) : null}
1761
+ {/* S5: the accent on a 2px white border, so a pin never reads white
1762
+ on a white picture. */}
1763
+ <path d={PIN_PATH} fill="var(--accent, #0099ff)" stroke="#ffffff" strokeWidth={2} />
1764
+ <text
1765
+ x={14}
1766
+ y={-9.5}
1767
+ textAnchor="middle"
1768
+ fill="var(--on-accent, #ffffff)"
1769
+ fontFamily="var(--font-sans, system-ui)"
1770
+ fontSize={12}
1771
+ fontWeight={600}
1772
+ >
1773
+ {pin.initials}
1774
+ </text>
1775
+ </g>
1776
+ );
1777
+ }
1778
+
1779
+ /**
1780
+ * The Studio's comment layer over the camera frame (bridge v45): the open
1781
+ * pin's layer ringed dashed, and each pin at its `u, v` of the frame rect.
1782
+ * ALL PAINT but the pins: the root takes no pointer events and only a pin
1783
+ * opts back in, so every other click still lands on the layer under it.
1784
+ */
1785
+ function CommentPinsOverlay({
1786
+ comments,
1787
+ pinLayer,
1788
+ frame,
1789
+ }: {
1790
+ comments: CommentOverlay;
1791
+ pinLayer: ScreenQuad | null;
1792
+ frame: OverlayProps["frame"];
1793
+ }) {
1794
+ return (
1795
+ <g data-testid="comment-pins" pointerEvents="none">
1796
+ {pinLayer === null ? null : (
1797
+ <polygon
1798
+ data-testid="studio-pin-layer"
1799
+ points={pointsOf(outsetQuad(pinLayer, PIN_LAYER_OFFSET))}
1800
+ fill="none"
1801
+ stroke="var(--accent, #0099ff)"
1802
+ strokeWidth={1.5}
1803
+ strokeDasharray="4 3"
1804
+ />
1805
+ )}
1806
+ {comments.pins.map((pin) => (
1807
+ <CommentPin
1808
+ key={pin.threadId}
1809
+ pin={pin}
1810
+ x={frame.x + pin.u * frame.width}
1811
+ y={frame.y + pin.v * frame.height}
1812
+ onPin={comments.onPin}
1813
+ />
1814
+ ))}
1815
+ </g>
1816
+ );
1817
+ }
1818
+
960
1819
  /**
961
1820
  * The path as it is being drawn, through THE SAME PRINTER the committed layer
962
1821
  * uses.
@@ -980,7 +1839,7 @@ function PenPreview({ pen }: { pen: ScreenPen }) {
980
1839
  return (
981
1840
  <g pointerEvents="none">
982
1841
  {points.length >= 2 ? (
983
- <path d={pathToD({ points: [...points], closed: pen.closed })} fill="none" stroke="var(--accent, #3b82f6)" strokeWidth={1.5} />
1842
+ <path d={pathToD({ points: [...points], closed: pen.closed })} fill="none" stroke="var(--accent, #0099ff)" strokeWidth={1.5} />
984
1843
  ) : null}
985
1844
  {/* The rubber band to the cursor, drawn as the segment it WOULD become:
986
1845
  curved when the anchor behind it carries an outgoing handle. */}
@@ -988,7 +1847,7 @@ function PenPreview({ pen }: { pen: ScreenPen }) {
988
1847
  <path
989
1848
  d={pathToD({ points: [last, { p: [cursor[0], cursor[1]] }], closed: false })}
990
1849
  fill="none"
991
- stroke="var(--accent, #3b82f6)"
1850
+ stroke="var(--accent, #0099ff)"
992
1851
  strokeWidth={1}
993
1852
  strokeDasharray="4 3"
994
1853
  opacity={0.7}
@@ -998,10 +1857,10 @@ function PenPreview({ pen }: { pen: ScreenPen }) {
998
1857
  {points.map((p, i) => (
999
1858
  <g key={`h${i}`}>
1000
1859
  {p.out === undefined ? null : (
1001
- <line x1={p.p[0]} y1={p.p[1]} x2={p.out[0]} y2={p.out[1]} stroke="var(--accent, #3b82f6)" strokeWidth={1} opacity={0.6} />
1860
+ <line x1={p.p[0]} y1={p.p[1]} x2={p.out[0]} y2={p.out[1]} stroke="var(--accent, #0099ff)" strokeWidth={1} opacity={0.6} />
1002
1861
  )}
1003
1862
  {p.in === undefined ? null : (
1004
- <line x1={p.p[0]} y1={p.p[1]} x2={p.in[0]} y2={p.in[1]} stroke="var(--accent, #3b82f6)" strokeWidth={1} opacity={0.6} />
1863
+ <line x1={p.p[0]} y1={p.p[1]} x2={p.in[0]} y2={p.in[1]} stroke="var(--accent, #0099ff)" strokeWidth={1} opacity={0.6} />
1005
1864
  )}
1006
1865
  </g>
1007
1866
  ))}
@@ -1013,8 +1872,8 @@ function PenPreview({ pen }: { pen: ScreenPen }) {
1013
1872
  r={i === 0 && pen.closeHot ? 5 : 3}
1014
1873
  // A FILLED first anchor is the whole close affordance: the author
1015
1874
  // needs to know the next click ends the path before they make it.
1016
- fill={i === 0 && pen.closeHot ? "var(--accent, #3b82f6)" : "var(--panel, #1c1c20)"}
1017
- stroke="var(--accent, #3b82f6)"
1875
+ fill={i === 0 && pen.closeHot ? "var(--accent, #0099ff)" : "var(--surface-panel, #111111)"}
1876
+ stroke="var(--accent, #0099ff)"
1018
1877
  strokeWidth={1.5}
1019
1878
  />
1020
1879
  ))}
@@ -1022,6 +1881,100 @@ function PenPreview({ pen }: { pen: ScreenPen }) {
1022
1881
  );
1023
1882
  }
1024
1883
 
1884
+ function SplinePreview({ spline }: { spline: ScreenSpline }) {
1885
+ const last = spline.points[spline.points.length - 1];
1886
+ return (
1887
+ <g pointerEvents="none">
1888
+ {spline.line.length >= 2 ? (
1889
+ <polyline
1890
+ points={spline.line.map((point) => `${point[0]},${point[1]}`).join(" ")}
1891
+ fill="none"
1892
+ stroke="var(--accent, #0099ff)"
1893
+ strokeWidth={1.5}
1894
+ strokeLinejoin="round"
1895
+ />
1896
+ ) : null}
1897
+ {!spline.editing && last !== undefined && spline.cursor !== null && !spline.closed ? (
1898
+ <line
1899
+ x1={last.p[0]}
1900
+ y1={last.p[1]}
1901
+ x2={spline.cursor[0]}
1902
+ y2={spline.cursor[1]}
1903
+ stroke="var(--accent, #0099ff)"
1904
+ strokeWidth={1}
1905
+ strokeDasharray="4 4"
1906
+ opacity={0.72}
1907
+ />
1908
+ ) : null}
1909
+ {spline.points.map((point) => (
1910
+ <g key={`arms-${point.id}`}>
1911
+ {point.in === undefined ? null : (
1912
+ <line
1913
+ x1={point.p[0]}
1914
+ y1={point.p[1]}
1915
+ x2={point.in[0]}
1916
+ y2={point.in[1]}
1917
+ stroke="var(--accent, #0099ff)"
1918
+ strokeWidth={1}
1919
+ opacity={0.64}
1920
+ />
1921
+ )}
1922
+ {point.out === undefined ? null : (
1923
+ <line
1924
+ x1={point.p[0]}
1925
+ y1={point.p[1]}
1926
+ x2={point.out[0]}
1927
+ y2={point.out[1]}
1928
+ stroke="var(--accent, #0099ff)"
1929
+ strokeWidth={1}
1930
+ opacity={0.64}
1931
+ />
1932
+ )}
1933
+ {point.in === undefined ? null : <SplineHandle at={point.in} selected={point.selected} />}
1934
+ {point.out === undefined ? null : <SplineHandle at={point.out} selected={point.selected} />}
1935
+ </g>
1936
+ ))}
1937
+ {spline.points.map((point, index) => (
1938
+ <circle
1939
+ key={point.id}
1940
+ cx={point.p[0]}
1941
+ cy={point.p[1]}
1942
+ r={!spline.editing && index === 0 && spline.closeHot ? 5 : point.selected ? 4 : 3.5}
1943
+ fill={
1944
+ (!spline.editing && index === 0 && spline.closeHot) || point.selected
1945
+ ? "var(--accent, #0099ff)"
1946
+ : "var(--surface-panel, #111111)"
1947
+ }
1948
+ stroke="var(--accent, #0099ff)"
1949
+ strokeWidth={1.5}
1950
+ />
1951
+ ))}
1952
+ </g>
1953
+ );
1954
+ }
1955
+
1956
+ function flatPolyline(points: readonly number[]): string {
1957
+ const out: string[] = [];
1958
+ for (let index = 0; index + 1 < points.length; index += 2) {
1959
+ out.push(`${points[index]},${points[index + 1]}`);
1960
+ }
1961
+ return out.join(" ");
1962
+ }
1963
+
1964
+ function SplineHandle({ at, selected }: { at: readonly [number, number]; selected: boolean }) {
1965
+ return (
1966
+ <rect
1967
+ x={at[0] - 3}
1968
+ y={at[1] - 3}
1969
+ width={6}
1970
+ height={6}
1971
+ fill={selected ? "var(--accent, #0099ff)" : "var(--surface-panel, #111111)"}
1972
+ stroke="var(--accent, #0099ff)"
1973
+ strokeWidth={1.25}
1974
+ />
1975
+ );
1976
+ }
1977
+
1025
1978
  function Marquee({ rect }: { rect: ScreenMarquee }) {
1026
1979
  const x = Math.min(rect.x0, rect.x1);
1027
1980
  const y = Math.min(rect.y0, rect.y1);
@@ -1031,9 +1984,9 @@ function Marquee({ rect }: { rect: ScreenMarquee }) {
1031
1984
  y={y}
1032
1985
  width={Math.abs(rect.x1 - rect.x0)}
1033
1986
  height={Math.abs(rect.y1 - rect.y0)}
1034
- fill="var(--accent, #3b82f6)"
1987
+ fill="var(--accent, #0099ff)"
1035
1988
  fillOpacity={0.12}
1036
- stroke="var(--accent, #3b82f6)"
1989
+ stroke="var(--accent, #0099ff)"
1037
1990
  strokeWidth={1}
1038
1991
  />
1039
1992
  );