@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
@@ -1,44 +1,190 @@
1
- // The left sidebar's File tab: scenes, the layer tree inside the open one, and
2
- // how to connect an agent (McpSection.tsx).
1
+ // The left column's panel: the outline of the open container. The column is
2
+ // fixed rows over one scroller: the context head, the search, then
3
+ // `.rail-scroll`. The Project's files (Assets, Branding & References) live in the
4
+ // Storyboard workspace now, so there is no section switch here.
5
+ // Inside a Shot the scan order is deliberate: the context head, then Layers,
6
+ // then the Components library. The header claimed "Layers first" for a release
7
+ // while the JSX put Components above them; the tree is the SUBJECT of a shot
8
+ // and the library is the same list at every level, so the subject is what
9
+ // leads.
3
10
  //
4
- // TWO LEVELS, ONE BIT. `state.sceneId` decides which scene's layers are listed,
5
- // and it is the same bit the timeline's macro/micro split reads and the URL
6
- // mirrors. There is no second "which scene am I looking at" anywhere.
11
+ // ── ONE RULE: THE OUTLINE LISTS WHAT IS INSIDE WHERE YOU ARE ─────────────
12
+ //
13
+ // the SCENE -> its shots
14
+ // a SHOT -> its elements
15
+ // a DEF -> its interior
16
+ //
17
+ // It used to list the shots in EVERY context, so inside a shot the panel showed
18
+ // two levels stacked in one column: a shot list whose rows navigate away, above
19
+ // a layer tree of the shot you are in. And the shots were already drawn twice
20
+ // over — the timeline's shot rows carry the same list with the verbs that
21
+ // matter (reorder, trim, delete, transition, open), where their durations are
22
+ // legible. Three pictures of one list, one of which could act on it.
23
+ //
24
+ // The two pictures that are left agree by construction: this list's right-click
25
+ // and the timeline row's are the same `shotMenuEntries`, and both offer the
26
+ // same double-click into the shot.
27
+ //
28
+ // The scene context lost nothing by the change, it GAINED the list: it used to
29
+ // hold a sentence telling the author to go and open a shot, under a shot list it
30
+ // was standing next to.
31
+ //
32
+ // The COMPONENTS block follows that rule too, and it took two goes to get
33
+ // there. It is drawn INSIDE a shot and inside a definition, and not above one.
34
+ // The counter-argument was that a definition is document-scoped and lives in no
35
+ // shot, so "what is inside where you are" never excludes it. That is true, and
36
+ // it argues for the list being reachable from the scene level, not for it being
37
+ // mounted there. Every verb the block offers is refused above a shot except
38
+ // two, and the App menu carries both (Component ▸ Open in Components opens one,
39
+ // and its New empty component is disabled there for the same reason the rail's
40
+ // was not, an inconsistency this removes). What the scene level is FOR is the
41
+ // cut: the shots, in order. A library of things that cannot be placed into it
42
+ // is a second subject in a panel whose whole rule is one.
43
+ //
44
+ // IT LISTS BOTH COMPONENT FAMILIES, in two lists. A definition is one of the
45
+ // two things an author calls a component; the other is a component from their
46
+ // own repo, which is an `instance` layer naming a registry id. Only definitions
47
+ // were ever listed, so a shot built entirely out of repo components showed this
48
+ // header with nothing under it. The second list is `usedInstanceComponentsOf`,
49
+ // scoped to what the document actually places — browsing the whole registry is
50
+ // the Add menu's job. Two lists rather than one merge, because the rows do
51
+ // different things when clicked (one opens an editor, one selects layers) and
52
+ // because "the first row under `component-list` is a definition" is a fact
53
+ // other surfaces resolve against.
54
+ //
55
+ // The per-row `+` is still ABSENT rather than disabled wherever it cannot run,
56
+ // which is this panel's rule for a verb the current level cannot do at all. It
57
+ // just no longer has a level of its own to be absent at.
58
+ //
59
+ // TWO LEVELS, TWO BITS, AND THEY ARE NOT THE SAME BIT.
60
+ //
61
+ // `state.enteredScene` is the CONTEXT: null lists the shots and no layers at
62
+ // all, because an element belongs to exactly one shot and in the scene
63
+ // context nobody has said which.
64
+ // `state.sceneId` is the SUBJECT: which shot's layers, once one is open. It
65
+ // is derived from the playhead, and while a shot is entered the clamp holds
66
+ // the playhead inside it — so here the two always agree, by construction
67
+ // rather than by a second copy.
68
+ //
69
+ // This header used to claim `sceneId` was "the same bit the timeline's
70
+ // macro/micro split reads". It was, until that split was deleted; the sentence
71
+ // outlived the thing it described by a release, which is the failure mode this
72
+ // lane treats as a bug rather than as prose.
7
73
  //
8
74
  // THE TREE IS RENDERED FROM `scene.layers` AND `children`, never from
9
- // `layersById` — the outline is the forest, and `order` is a separate axis that
10
- // decides PAINT. Listing by `order` would put a group's children next to
11
- // strangers, and listing by `layersById` would have no order at all.
12
- import { useEffect, useState } from "react";
13
- import { IconChevron, useContextMenu } from "@dailies-studio/ui";
14
- import { componentDefsOf, homeOf, instancesOf, layerChildren, parentOf } from "@dailies-studio/shot/doc";
15
- import type { Layer3D } from "@dailies-studio/shot/doc";
16
- import { useShotEditor, useStore } from "../../state/context.js";
17
- import { layerMenuEntries, menuTargetsFor } from "../../state/menu.js";
75
+ // `layersById`: the outline is the forest, and from schema v35 its lists ARE
76
+ // paint order (back to front, a parent before its children), which `order`
77
+ // only numbers. Listing by `layersById` would have no order at all.
78
+ //
79
+ // FRONT FIRST, as Figma's layers panel reads: each sibling list is shown
80
+ // reversed (`frontFirst`), so the top row is the layer in front, and a parent's
81
+ // row still sits above its children. Only the DISPLAY flips. Every row keeps its
82
+ // index in the stored list, and a drop line is mapped back to that list: the
83
+ // line above a row is in front of it, the line below it is behind it.
84
+ import {
85
+ useCallback,
86
+ useEffect,
87
+ useLayoutEffect,
88
+ useMemo,
89
+ useRef,
90
+ useState,
91
+ } from "react";
92
+ import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent } from "react";
93
+ import { framesToSeconds } from "@dailies-studio/engine";
94
+ import { LAYER_ROW_H, formatDuration, frontFirst } from "../timeline/model.js";
95
+ import { IconChevron, useCollapsed, useContextMenu } from "@dailies-studio/ui";
96
+ import {
97
+ canHoldChildren,
98
+ componentDefsOf,
99
+ homeOf,
100
+ instanceExportOf,
101
+ instancesOf,
102
+ layerChildren,
103
+ } from "@dailies-studio/shot/doc";
104
+ import type { Layer3D, LayerHome } from "@dailies-studio/shot/doc";
105
+ import { useShotEditor, useShotEditorHot, useStore } from "../../state/context.js";
106
+ import type { ShotEditorStore } from "../../state/store.js";
107
+ import { isSceneContext } from "../../state/viewMode.js";
108
+ import { crumbsFor } from "../../state/isolationTrail.js";
109
+ import { usedComponentsOf } from "../../state/projectComponents.js";
110
+ import {
111
+ layerMenuEntries,
112
+ menuTargetsFor,
113
+ SHOT_MENU_OPTIONS,
114
+ shotMenuEntries,
115
+ } from "../../state/menu.js";
18
116
  import {
19
117
  TRIANGLE_SOFT_BUDGET,
20
118
  formatTriangles,
21
119
  sceneBudgetWarning,
22
- sumTriangles,
120
+ sumTrianglesCached,
23
121
  } from "../../state/triangles.js";
24
- import { McpSection } from "./McpSection.js";
122
+ import { selectionMembership } from "../../state/selection.js";
25
123
  import {
124
+ CHEVRON_LEFT_ICON,
125
+ ADD_ICON,
26
126
  COMPONENT_ICON,
27
127
  EYE_ICON,
28
128
  EYE_OFF_ICON,
29
129
  LOCK_ICON,
130
+ MASK_ICON,
30
131
  SCENE_ICON,
132
+ SEARCH_ICON,
31
133
  UNLOCK_ICON,
134
+ iconFor,
135
+ isComponentGlyph,
32
136
  kindIcon,
33
- } from "./icons.js";
137
+ } from "../../icons.js";
138
+ import { pinnedWindowSpans, useFixedVirtualWindow } from "../virtualWindow.js";
139
+ import { backTarget } from "../toolbar/BackChip.js";
140
+ import { PeerDots } from "../header/PresenceStack.js";
141
+ import { LayerMarks } from "./LayerMarks.js";
142
+ import { MaskBracket, NO_CONTENT_HINT, maskMarksOf } from "../maskMarks.js";
143
+ import type { MaskMarks } from "../maskMarks.js";
34
144
 
35
145
  export function FilePanel() {
36
146
  const store = useStore();
37
147
  const doc = useShotEditor((s) => s.doc);
38
- const sceneId = useShotEditor((s) => s.sceneId);
148
+ const name = useShotEditor((s) => s.name);
149
+ // Hot: the cold lane sleeps through a cut the transport plays (store
150
+ // `set`), and the Shots list marks what is playing. A string, so this
151
+ // renders at a cut and not per frame; inside a Shot it never moves.
152
+ const sceneId = useShotEditorHot((s) => s.sceneId);
153
+ const enteredScene = useShotEditor((s) => s.enteredScene);
39
154
  const isolation = useShotEditor((s) => s.isolation);
40
155
  const editingDef = useShotEditor((s) => s.editingDef);
156
+ /** The registry, for the DISPLAY NAME of a code component. The list renders
157
+ * without it (the export name is parsed from the id), so this is a
158
+ * refinement arriving late, never a gate on the rows appearing. */
159
+ const library = useShotEditor((s) => s.library);
160
+ const libraryLoaded = useShotEditor((s) => s.libraryLoaded);
161
+ /** The SCENE context: arranging shots, so nothing here may insert an element.
162
+ * A definition counts as a container, which is why `isSceneContext` excludes
163
+ * one. Both are the shared predicates rather than this panel's own copy: the
164
+ * rail, the dock and the store answered "where am I" from three spellings. */
165
+ const inScene = useShotEditor(isSceneContext);
41
166
  const [query, setQuery] = useState("");
167
+ /** `/` asks for the caret here (`searchFocus`, a mailbox keyed on identity),
168
+ * and the field takes it. The store already refused the key while focus was
169
+ * in a field, so this never steals the caret from one.
170
+ *
171
+ * ONE REQUEST, ONE FOCUS. The panel sleeps in a hidden `<Activity>` on the
172
+ * board (LeftColumn.tsx), and waking re-runs every effect: keyed on the
173
+ * value alone, an old request would pull the caret here on each return
174
+ * from the board. So the effect remembers the last request it answered,
175
+ * and a wake (the layout effect, which runs first) counts a request made
176
+ * while the panel slept as answered: it was not this panel's to take. */
177
+ const searchRef = useRef<HTMLInputElement>(null);
178
+ const searchFocus = useShotEditor((s) => s.searchFocus);
179
+ const answeredFocus = useRef<typeof searchFocus>(null);
180
+ useLayoutEffect(() => {
181
+ answeredFocus.current = store.getState().searchFocus;
182
+ }, [store]);
183
+ useEffect(() => {
184
+ if (searchFocus === null || searchFocus === answeredFocus.current) return;
185
+ answeredFocus.current = searchFocus;
186
+ searchRef.current?.focus();
187
+ }, [searchFocus]);
42
188
  /** Collapsed subtree roots. PER-SESSION on purpose, never a workspace pref:
43
189
  * prefs survive document switches and layer ids are document-scoped, so a
44
190
  * persisted set would carry stale ids into every other document. */
@@ -46,6 +192,21 @@ export function FilePanel() {
46
192
  /** The last plainly-clicked row — shift-click ranges from it over the
47
193
  * VISIBLE flattened order, the Figma/Blender behaviour. */
48
194
  const [anchor, setAnchor] = useState<string | null>(null);
195
+ /** Which shot row is being renamed inline, or null. The menu says "Rename"
196
+ * and the surface decides what that looks like — see `shotMenuEntries`. */
197
+ const [renamingShot, setRenamingShot] = useState<string | null>(null);
198
+ /** ONE MENU for the shot list. The layer rows own theirs per row (they carry
199
+ * their own drag state); this list has one opener and one open menu. */
200
+ const menu = useContextMenu();
201
+ /**
202
+ * COMPONENTS FOLDS, and this one IS a workspace pref, unlike the collapsed
203
+ * layer subtrees above. It keys on nothing document-scoped: the group is the
204
+ * same group in every Project, so a fold carried across a document switch is
205
+ * the author's answer to "do I want this library open", not a stale id.
206
+ */
207
+ const [componentsCollapsed, toggleComponents] = useCollapsed({
208
+ persistKey: "shot:rail:components",
209
+ });
49
210
  /**
50
211
  * BOTH ARE DROPPED WHEN THE DOCUMENT CHANGES, and that is not tidiness.
51
212
  * This panel is mounted unconditionally and never keyed (App.tsx), and a
@@ -54,12 +215,33 @@ export function FilePanel() {
54
215
  * every shot. Kept, the set folds a group in the NEW document that nobody
55
216
  * folded, and the anchor makes the author's first shift-click there a range
56
217
  * measured from a click they made in a different document.
218
+ *
219
+ * Only on a REAL change: a wake from the board's hidden `<Activity>` re-runs
220
+ * this effect with the same id, and must not unfold the author's tree.
57
221
  */
58
222
  const docId = doc?.id ?? null;
223
+ const clearedFor = useRef(docId);
59
224
  useEffect(() => {
225
+ if (clearedFor.current === docId) return;
226
+ clearedFor.current = docId;
60
227
  setCollapsed(new Set());
61
228
  setAnchor(null);
62
229
  }, [docId]);
230
+ /** The code components this document places, grouped. Memoised because it
231
+ * walks every layer in every scene AND every definition, and this panel
232
+ * re-renders on selection. */
233
+ const usedComponents = useMemo(
234
+ () => (doc === null ? [] : usedComponentsOf(doc)),
235
+ [doc],
236
+ );
237
+ /** Fetched ONLY when there is something to name. A shot with no code
238
+ * components never touches the route, which keeps the panel silent in every
239
+ * document that has no use for the registry. */
240
+ const wantsLibrary = usedComponents.length > 0 && !libraryLoaded;
241
+ useEffect(() => {
242
+ if (wantsLibrary) void store.refreshLibrary();
243
+ }, [wantsLibrary, store]);
244
+ const justPlaced = useShotEditor((s) => s.justPlaced);
63
245
 
64
246
  if (doc === null) return null;
65
247
  const scene = sceneId === null ? undefined : doc.scenesById[sceneId];
@@ -67,6 +249,7 @@ export function FilePanel() {
67
249
  // The forest the outline shows: a definition's while one is being edited,
68
250
  // the open shot's otherwise. ONE lookup, so the rows below never branch.
69
251
  const home = homeOf(doc, editingDef ?? sceneId ?? "");
252
+ const placedHere = justPlaced !== null && home?.layersById[justPlaced] !== undefined;
70
253
  const trimmedQuery = query.trim().toLowerCase();
71
254
  const rootIds =
72
255
  home === null
@@ -75,6 +258,123 @@ export function FilePanel() {
75
258
  ? home.layers
76
259
  : layerChildren(home.layersById[isolation] ?? ({} as Layer3D));
77
260
 
261
+ // ── WHAT THE HEAD IS DESCRIBING ─────────────────────────────────────────
262
+ //
263
+ // Four contexts, ONE decision, made here rather than four times in the JSX.
264
+ // The chrome always knew which one it was in (`enteredScene`, `editingDef`,
265
+ // `isolation`); what it never did was say so in a shape the author could act
266
+ // on, and a head that branches inline is how the old crumb strip ended up
267
+ // stating each fact in a different grammar.
268
+ //
269
+ // A DEFINITION WINS over an isolation. The two have been mutually exclusive
270
+ // since v14, so they should never disagree; if they ever do, the definition
271
+ // is the one whose interior is actually on screen.
272
+ const editedDef = editingDef === null ? undefined : defs[editingDef];
273
+ const isolatedLayer =
274
+ editedDef !== undefined || isolation === null || scene === undefined
275
+ ? undefined
276
+ : scene.layersById[isolation];
277
+ const subject: "project" | "shot" | "isolation" | "definition" =
278
+ editedDef !== undefined
279
+ ? "definition"
280
+ : isolatedLayer !== undefined
281
+ ? "isolation"
282
+ : enteredScene !== null && scene !== undefined
283
+ ? "shot"
284
+ : "project";
285
+ /** The isolated layer and its ancestors, outermost first. Empty unless an
286
+ * isolation is the subject. Its length says whether one level up is the
287
+ * shot (1) or a group. */
288
+ const isolationTrail =
289
+ isolatedLayer === undefined || scene === undefined
290
+ ? []
291
+ : crumbsFor(scene, isolatedLayer.id);
292
+ const projectName = doc.title ?? name ?? doc.name;
293
+ const PROJECT_TITLE = "The whole Project: arrange its Shots";
294
+ /** All the way out to the Project, from whichever level the author is on.
295
+ * The three calls are one gesture and have always travelled together. */
296
+ const exitToProject = (): void => {
297
+ store.exitIsolationAll();
298
+ store.editComponent(null);
299
+ store.leaveScene();
300
+ };
301
+
302
+ /**
303
+ * THE WAY BACK, EXACTLY ONE LEVEL, which is the rule Escape already follows.
304
+ *
305
+ * This is the control the chrome did not have. The only thing that looked
306
+ * like a way out was a dim 11px crumb, and the one control that read as a
307
+ * button was the timeline head's chip carrying the Project's own name, which
308
+ * reads as a title. A row with a chevron and full ink is the affordance.
309
+ *
310
+ * `testId` keeps the old crumb ids on whichever control now performs their
311
+ * job: `scene-crumb` is whatever lands the author in the Project context,
312
+ * `shot-crumb` is whatever lands them in the Shot. Between contexts the
313
+ * control moves between the back row and the subject; the promise the id
314
+ * makes does not. A back row that climbs to a group is `isolation-back`.
315
+ *
316
+ * The destination and its verb are `backTarget`'s, the one the canvas bar's
317
+ * back chip reads, so the two ways back cannot disagree.
318
+ *
319
+ * AN ISOLATION GETS THE SAME HEAD as a shot or a definition: one level
320
+ * back, then the subject. The full trail lives over the canvas
321
+ * (IsolationTrail.tsx), so the rail does not repeat it. The Project is the
322
+ * top, and a back row there would point at nothing.
323
+ */
324
+ const backRow = (): {
325
+ label: string;
326
+ title: string;
327
+ testId: string;
328
+ onClick: () => void;
329
+ } | null => {
330
+ if (subject === "project") return null;
331
+ const target = backTarget(store.getState());
332
+ if (target === null) return null;
333
+ if (subject === "isolation") {
334
+ const toShot = isolationTrail.length <= 1;
335
+ return {
336
+ label: target.label ?? "",
337
+ title: `Back to ${target.label ?? ""}`,
338
+ testId: toShot ? "shot-crumb" : "isolation-back",
339
+ onClick: () => target.run(store),
340
+ };
341
+ }
342
+ // Inside a definition the way back is the Components home (mock C6).
343
+ if (subject === "definition") {
344
+ return {
345
+ label: "All components",
346
+ title: "Every component of this Project",
347
+ testId: "components-home-back",
348
+ onClick: () => target.run(store),
349
+ };
350
+ }
351
+ return {
352
+ label: target.label ?? "All shots",
353
+ title: target.label === null ? PROJECT_TITLE : `Back to ${target.label}`,
354
+ testId: target.label === null ? "scene-crumb" : "shot-crumb",
355
+ onClick: () => target.run(store),
356
+ };
357
+ };
358
+ const back = backRow();
359
+ /** The subject's glyph, from the same typed maps the outliner rows read. The
360
+ * Project deliberately has none: `icons.tsx` is the one source for chrome
361
+ * glyphs, it carries no Doc3D glyph, and minting one at a call site would
362
+ * put a second source of truth beside it. */
363
+ const subjectIcon =
364
+ subject === "definition"
365
+ ? COMPONENT_ICON
366
+ : isolatedLayer !== undefined
367
+ ? iconFor(isolatedLayer)
368
+ : subject === "shot"
369
+ ? SCENE_ICON
370
+ : null;
371
+ /** The component family keeps the accent here for the reason it keeps it in
372
+ * the outliner: a definition is a different KIND of subject, not just
373
+ * another one. */
374
+ const subjectIsComponent =
375
+ subject === "definition" ||
376
+ (isolatedLayer !== undefined && isComponentGlyph(isolatedLayer));
377
+
78
378
  const toggleCollapse = (id: string): void => {
79
379
  setCollapsed((prev) => {
80
380
  const next = new Set(prev);
@@ -84,201 +384,855 @@ export function FilePanel() {
84
384
  });
85
385
  };
86
386
 
87
- const rowClick = (id: string, e: { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean }): void => {
88
- const selection = store.getState().selection;
89
- if (e.shiftKey && anchor !== null && home !== null) {
90
- // The range is over WHAT IS ON SCREEN — the same flattening the rows
91
- // render from, so a collapsed group's invisible children are never
92
- // swept into a selection the author cannot see.
93
- const flat = flattenVisible(home, rootIds, collapsed, trimmedQuery);
94
- const a = flat.indexOf(anchor);
95
- const b = flat.indexOf(id);
96
- if (a !== -1 && b !== -1) {
97
- store.select(flat.slice(Math.min(a, b), Math.max(a, b) + 1));
98
- return;
99
- }
100
- // A stale anchor (deleted, scoped away) falls through to a plain click.
101
- }
102
- if (e.ctrlKey || e.metaKey) {
103
- store.select(selection.includes(id) ? selection.filter((x) => x !== id) : [...selection, id]);
104
- setAnchor(id);
105
- return;
106
- }
107
- store.select([id]);
108
- setAnchor(id);
109
- };
387
+ const { rowClick, rowKeyMove } = layerRowSelection({
388
+ store,
389
+ scene: home,
390
+ ids: rootIds,
391
+ collapsed,
392
+ query: trimmedQuery,
393
+ anchor,
394
+ setAnchor,
395
+ });
110
396
 
111
397
  return (
112
398
  <div className="file-panel" data-testid="file-panel">
113
- <label className="file-search">
114
- <input
115
- type="search"
116
- value={query}
117
- placeholder="Search layers"
118
- aria-label="Search layers"
119
- onChange={(e) => setQuery(e.target.value)}
120
- />
121
- </label>
122
-
123
- <div className="file-section-label">Shots</div>
124
- <ul className="scene-list">
125
- {doc.scenes.map((id) => {
126
- const s = doc.scenesById[id];
127
- if (s === undefined) return null;
128
- return (
129
- <li key={id}>
399
+ {/* ── NO SECTION SWITCH ──────────────────────────────────────────────
400
+ The column used to open on a three-way switch (the outline, Assets,
401
+ Branding & References). The Paper redesign moved the two Project
402
+ libraries to the Storyboard workspace, so this column is ONE thing in
403
+ every context: the outline (Shots at the Project level, Layers inside
404
+ a shot or a definition) and, inside a container, Components. */}
405
+ {/* ── THE CONTEXT HEAD ───────────────────────────────────────────────
406
+ It used to sit mid-panel, directly above the layer list it scoped,
407
+ which was right while the list below it was the only thing here. The
408
+ panel's contents change with the context now, so the rows that say
409
+ WHICH context go above all of them, and this is the panel's FIRST
410
+ child in every context: the column's head, one panel head tall.
411
+
412
+ IT USED TO BE A FLAT CRUMB STRIP, and that is the defect. Every level
413
+ was 11px --text-dim with no chevron and no back affordance, so the
414
+ only control on screen that read as a way out was the timeline head's
415
+ chip carrying the Project's own name, which reads as a title. Two
416
+ rows answer the two questions the one strip answered neither of: row
417
+ one is the way OUT, one level, in full ink; row two is the SUBJECT
418
+ the tree under it belongs to, at heading size.
419
+
420
+ ONE SHAPE FOR EVERY MODE. Isolation and the component context are
421
+ mutually exclusive since v14, but each scopes this outline, and
422
+ differently-shaped heads per mode would make "where am I" a lesson the
423
+ author has to learn once per mode. */}
424
+ <nav className="rail-head" data-testid="rail-head">
425
+ {back === null ? null : (
130
426
  <button
131
427
  type="button"
132
- className={`scene-row${id === sceneId ? " is-current" : ""}`}
133
- aria-current={id === sceneId}
134
- // `focusScene`, not `setScene`: a bare pin leaves the playhead
135
- // in whatever scene it was already in, and every store call
136
- // that pairs `state.sceneId` with `masterToScene(state.frame)`
137
- // — insertion, block placement, split, camera shots — then
138
- // reads its scene from one scene and its frame from another.
139
- // The stage renders the playhead's scene, so a pin the playhead
140
- // is not in is also a scene the author cannot see.
141
- onClick={() => store.focusScene(id)}
428
+ className="rail-back"
429
+ data-testid={back.testId}
430
+ title={back.title}
431
+ onClick={back.onClick}
142
432
  >
143
- <span className="layer-kind" aria-hidden="true">
144
- {SCENE_ICON}
145
- </span>
146
- <span className="row-label">{s.name}</span>
433
+ {CHEVRON_LEFT_ICON}
434
+ {back.label}
147
435
  </button>
148
- </li>
149
- );
150
- })}
151
- </ul>
152
-
153
- {/* COMPONENTS, above the layers and below the shots — a definition is
154
- not in any shot, and the list is what makes a zero-instance one
155
- reachable at all. Absent entirely when the document has none, so a
156
- shot that never made one looks exactly as it did. */}
157
- {Object.keys(defs).length === 0 ? null : (
158
- <>
159
- <div className="file-section-label">Components</div>
160
- <ul className="component-list" data-testid="component-list">
161
- {Object.values(defs).map((d) => (
162
- <li key={d.id} className={`component-row${d.id === editingDef ? " is-current" : ""}`}>
163
- <span className="layer-kind" aria-hidden="true">
164
- {COMPONENT_ICON}
436
+ )}
437
+ <div
438
+ className={`rail-subject${subject === "definition" ? " is-definition" : ""}`}
439
+ >
440
+ {subjectIcon === null ? null : (
441
+ <span
442
+ className={`rail-subject-icon${subjectIsComponent ? " is-component" : ""}`}
443
+ aria-hidden="true"
444
+ >
445
+ {subjectIcon}
446
+ </span>
447
+ )}
448
+ {/* THE SUBJECT'S NAME, and in two of the four contexts it is also a
449
+ control, so it carries the crumb id that used to name that
450
+ control. In the Project context it is the only thing standing for
451
+ "the whole Project", which is what `scene-crumb` has always
452
+ meant. In the Shot context it is the way back UP to the shot from
453
+ a subtree or a definition, unchanged. Where the subject cannot
454
+ act (a definition) it is a plain span: the back row above owns
455
+ leaving, and a heading that navigates away on click is the
456
+ crumb-as-title confusion again. */}
457
+ {subject === "project" ? (
458
+ <button
459
+ type="button"
460
+ className="rail-subject-name"
461
+ aria-current
462
+ data-testid="scene-crumb"
463
+ title={PROJECT_TITLE}
464
+ onClick={exitToProject}
465
+ >
466
+ {projectName}
467
+ </button>
468
+ ) : subject === "shot" ? (
469
+ <button
470
+ type="button"
471
+ className="rail-subject-name"
472
+ aria-current={isolation === null && editingDef === null}
473
+ data-testid="shot-crumb"
474
+ onClick={() => {
475
+ store.exitIsolationAll();
476
+ store.editComponent(null);
477
+ }}
478
+ >
479
+ {scene?.name}
480
+ </button>
481
+ ) : subject === "isolation" ? (
482
+ <span
483
+ className="rail-subject-name"
484
+ aria-current
485
+ data-testid="isolation-subject"
486
+ title="Isolated: only this layer and what is inside it can be picked"
487
+ >
488
+ {isolatedLayer?.label}
165
489
  </span>
490
+ ) : (
491
+ <span
492
+ className="rail-subject-name"
493
+ aria-current
494
+ data-testid="definition-crumb"
495
+ title="Editing this definition: every instance follows"
496
+ >
497
+ {editedDef?.name}
498
+ </span>
499
+ )}
500
+ </div>
501
+ </nav>
502
+
503
+ {/* SEARCH, IN EVERY CONTEXT, because every context has a list under it.
504
+ It used to render only inside a shot, on the reasoning that it belongs
505
+ to the layer tree. True, and it left the Project context with a list
506
+ and no way to filter it. One box, filtering whatever the list below
507
+ it is: the placeholder says which.
508
+
509
+ The ui package's `.search-field` recipe: the wrapper carries the edge
510
+ so the magnifier and the "/" hint sit inside it. The key is real
511
+ (`focusSearch` in keymap.ts, with a row in the manual); the `kbd` is
512
+ `aria-hidden` because it repeats that binding and names nothing. */}
513
+ <div className="file-search">
514
+ <label className="search-field">
515
+ {SEARCH_ICON}
516
+ <input
517
+ ref={searchRef}
518
+ type="search"
519
+ value={query}
520
+ placeholder={inScene ? "Search shots" : "Search layers"}
521
+ aria-label={inScene ? "Search shots" : "Search layers"}
522
+ onChange={(e) => setQuery(e.target.value)}
523
+ />
524
+ <kbd className="kbd" aria-hidden="true">
525
+ /
526
+ </kbd>
527
+ </label>
528
+ </div>
529
+
530
+ {/* THE ONE SCROLLER. Everything above it is fixed; its height is
531
+ bounded by the column, which is what lets the outline virtualise. */}
532
+ <div className="rail-scroll">
533
+ <>
534
+ {/* THE SCENE CONTEXT'S OUTLINE: the document's children are its shots.
535
+ Absent inside one, where the children are elements and the tree below
536
+ lists them. */}
537
+ {!inScene ? null : (
538
+ <>
539
+ {menu.menu}
540
+ {/* The count is a SIBLING of `.file-section-label`, never a child:
541
+ two rail tests compare that element's textContent by equality, so
542
+ a nested span would rename the section rather than annotate it.
543
+ `.file-section-head` (left-column.css) lays the row out as a flex
544
+ line, so the count's `margin-left: auto` pulls it to the right.
545
+ The Layers head below uses the same markup. */}
546
+ <div className="file-section-head file-section-label--actions">
547
+ <div className="file-section-label">Shots</div>
548
+ <span className="file-section-count">{doc.scenes.length}</span>
549
+ </div>
550
+ <ul className="scene-list">
551
+ {doc.scenes.map((id) => {
552
+ const s = doc.scenesById[id];
553
+ if (s === undefined) return null;
554
+ // The same predicate the layer tree filters on, on the one field
555
+ // a shot row shows. A shot has no children in this list, so
556
+ // there is no subtree rule to mirror here.
557
+ if (
558
+ trimmedQuery.length > 0 &&
559
+ !s.name.toLowerCase().includes(trimmedQuery)
560
+ ) {
561
+ return null;
562
+ }
563
+ // The LI is the row: the band has to run under the Open
564
+ // button too, so hover and the current mark live here.
565
+ return (
566
+ <li
567
+ key={id}
568
+ className={`scene-list-item${id === sceneId ? " is-current" : ""}`}
569
+ >
570
+ {renamingShot === id ? (
571
+ <input
572
+ className="shot-rename"
573
+ defaultValue={s.name}
574
+ autoFocus
575
+ aria-label={`Rename ${s.name}`}
576
+ data-testid={`shot-rename-row-${id}`}
577
+ onBlur={(e) => {
578
+ setRenamingShot(null);
579
+ store.renameScene(id, e.currentTarget.value);
580
+ }}
581
+ onKeyDown={(e) => {
582
+ e.stopPropagation();
583
+ if (e.key === "Enter") {
584
+ setRenamingShot(null);
585
+ store.renameScene(id, e.currentTarget.value);
586
+ } else if (e.key === "Escape")
587
+ setRenamingShot(null);
588
+ }}
589
+ />
590
+ ) : (
591
+ <button
592
+ type="button"
593
+ className={`scene-row${id === sceneId ? " is-current" : ""}`}
594
+ aria-current={id === sceneId}
595
+ // THE WAY IN, on the gesture every file browser gives "go
596
+ // inside". The `›` beside it stays: a double-click is fast
597
+ // and a chevron is discoverable, and the two cost one 12px
598
+ // button between them.
599
+ onDoubleClick={() => store.enterScene(id)}
600
+ // ONE MENU, TWO SURFACES. The same entries the timeline's
601
+ // shot row opens. `shotMenuEntries` is the single builder,
602
+ // for `layerMenuEntries`' reason: a Delete that meant one
603
+ // thing here and another there is worse than one of them.
604
+ onContextMenu={(e) => {
605
+ e.preventDefault();
606
+ e.stopPropagation();
607
+ store.focusScene(id);
608
+ menu.open(
609
+ { x: e.clientX, y: e.clientY },
610
+ shotMenuEntries(store, store.getState(), id, () =>
611
+ setRenamingShot(id),
612
+ ),
613
+ SHOT_MENU_OPTIONS,
614
+ );
615
+ }}
616
+ // `focusScene`, not `setScene`: a bare pin leaves the
617
+ // playhead in whatever scene it was already in, and every
618
+ // store call that pairs `state.sceneId` with
619
+ // `masterToScene(state.frame)` (insertion, placement,
620
+ // split, camera shots) then reads its scene from one scene
621
+ // and its frame from another. The stage renders the
622
+ // playhead's scene, so a pin the playhead is not in is also
623
+ // a scene the author cannot see.
624
+ onClick={() => store.focusScene(id)}
625
+ >
626
+ <span className="layer-kind" aria-hidden="true">
627
+ {SCENE_ICON}
628
+ </span>
629
+ <span className="row-label">{s.name}</span>
630
+ {/* THE SHOT'S LENGTH, in the chrome's one duration
631
+ string (`formatDuration`), so the column and the
632
+ timeline never spell one duration two ways. Inside the
633
+ button, so the whole row stays one click and one
634
+ double-click target. */}
635
+ <span className="scene-row-duration">
636
+ {formatDuration(framesToSeconds(s.durationFrames, doc.fps))}
637
+ </span>
638
+ </button>
639
+ )}
640
+ {/* The SECOND door in, beside the timeline row's. A row click
641
+ seeks (the scene context's own gesture — look at this shot)
642
+ and this OPENS. Two verbs on one row rather than one that
643
+ guesses, because the two are different requests and the
644
+ second one changes the whole chrome. */}
645
+ <button
646
+ type="button"
647
+ className="layer-flag"
648
+ data-testid={`shot-open-${id}`}
649
+ aria-label={`Open ${s.name}`}
650
+ title="Open this shot: add and animate its elements"
651
+ onClick={() => store.enterScene(id)}
652
+ >
653
+ ›
654
+ </button>
655
+ </li>
656
+ );
657
+ })}
658
+ </ul>
659
+ {/* ADD SHOT, the dock's `+ Shot` verb (`store.addScene`) where the
660
+ list it grows is. Paper's dashed "add one more" slot, the ui
661
+ package's `.btn.is-dashed`. Only here: inside a shot this list is
662
+ not on screen, and the dock keeps its own door. */}
663
+ <div className="scene-list-add">
166
664
  <button
167
665
  type="button"
168
- className="layer-name"
169
- aria-current={d.id === editingDef}
170
- // Scoping the OUTLINE, not the stage: a definition's layers
171
- // are in no scene and have no camera to point at them.
172
- onClick={() => store.editComponent(d.id === editingDef ? null : d.id)}
666
+ className="btn is-dashed"
667
+ data-testid="rail-add-shot"
668
+ onClick={() => store.addScene()}
173
669
  >
174
- {d.name}
670
+ {ADD_ICON}
671
+ Add shot
175
672
  </button>
176
- <span className="component-count" aria-hidden="true">
177
- {instancesOf(doc, d.id).length}
673
+ </div>
674
+ </>
675
+ )}
676
+
677
+ {/* ── NO LAYER TREE IN THE SCENE CONTEXT ────────────────────────────
678
+ An element belongs to exactly ONE shot, and in the scene context the
679
+ author has not said which. Listing the playhead's shot instead would
680
+ be a tree that silently changed under every scrub across a cut, with
681
+ every row a door into a context the panel does not say it is in, the
682
+ same failure the two-level dock was deleted for.
683
+ There is no placeholder here any more, and that is the point: the shots
684
+ list ABOVE is the scene context's outline, so there is no empty panel
685
+ to explain. The sentence that used to stand here pointed at the
686
+ timeline for a `›` this panel carries itself.
687
+
688
+ IT RENDERS BEFORE COMPONENTS, which the header at the top of this file
689
+ has claimed for a release while the JSX did the opposite. The layer
690
+ tree is the SUBJECT of the shot context; the components list is a
691
+ library every context shows, above and below and inside a shot alike.
692
+ The subject goes first. Nothing in state depends on the order. */}
693
+ {inScene || home === null ? null : (
694
+ <>
695
+ {/* Count outside the label, for the reason the Shots head gives. It
696
+ counts what is directly INSIDE this container, not what the search
697
+ leaves on screen: the section names the container, and a number
698
+ that shrank as the author typed would be naming the filter. */}
699
+ <div className="file-section-head file-section-label--actions">
700
+ <div className="file-section-label">
701
+ Layers
702
+ </div>
703
+ <span className="file-section-count">{rootIds.length}</span>
704
+ </div>
705
+ <WindowedLayerRows
706
+ // The CONTAINER, which is a definition while one is being edited.
707
+ // Every op the rows drive takes a container id, so the drag, the
708
+ // rename and the flags all work inside a definition unchanged.
709
+ scene={home}
710
+ containerId={editingDef ?? sceneId ?? ""}
711
+ // SCOPED to the isolated subtree's own children, so the outline
712
+ // says the same thing the stage does about what is being edited.
713
+ ids={rootIds}
714
+ parent={editingDef === null ? isolation : null}
715
+ query={trimmedQuery}
716
+ collapsed={collapsed}
717
+ onToggleCollapse={toggleCollapse}
718
+ onRowClick={rowClick}
719
+ onRowKeyMove={rowKeyMove}
720
+ />
721
+ {/* Says where an unselected placement went and how to reach it:
722
+ a locked layer is skipped by stage clicks, so its row is the
723
+ way in. */}
724
+ {placedHere ? (
725
+ <p className="layer-placed-note" role="status">
726
+ Just added. Locked at the back. Unlock to edit or delete.
727
+ </p>
728
+ ) : null}
729
+ </>
730
+ )}
731
+
732
+ {/* COMPONENTS, INSIDE A CONTAINER ONLY. See the file header for the
733
+ argument. Above a shot the block's verbs are all refused (`+` places
734
+ an instance, and `insertContext` refuses every insert in the scene
735
+ context) or better reached from the App menu (Component ▸ Edit
736
+ definition), and the scene level's subject is the cut.
737
+
738
+ The header is always present at the levels that DO draw it (unify P2):
739
+ New Component is how a definition can exist from NOTHING, so the way
740
+ in cannot hide behind having one already.
741
+
742
+ A NATIVE `details`, and NOT `InspectorSection`: that one unmounts its
743
+ body when collapsed, and these rows have to stay queryable at rest.
744
+ The library is the one block here that is not about where the author
745
+ is standing, so it is also the one worth folding out of the way, and
746
+ the fold persists because the answer to "do I want this open" is the
747
+ author's habit rather than anything document-scoped. */}
748
+ {/* Not inside a definition: Components is the workspace there, with
749
+ its own list one press away (All components). Not inside an
750
+ isolation either: the rail is about the subtree there, and a
751
+ library under it reads as part of what is isolated. Add and the
752
+ Assets tab still place components into it. */}
753
+ {inScene || editingDef !== null || subject === "isolation" ? null : (
754
+ <details className="rail-group" open={!componentsCollapsed}>
755
+ <summary
756
+ onClick={(e) => {
757
+ // The native toggle is refused and the store answers instead: `open`
758
+ // is a controlled prop here, so letting the browser flip the DOM
759
+ // attribute would leave it disagreeing with React until some
760
+ // unrelated render put it back.
761
+ e.preventDefault();
762
+ toggleComponents();
763
+ }}
764
+ >
765
+ <span className="rail-group-chevron" aria-hidden="true">
766
+ <IconChevron />
178
767
  </span>
768
+ <span className="file-section-label">Components</span>
769
+ {/* BOTH LISTS, because the count exists to say what a CLOSED group
770
+ holds and this group holds two lists: the authored definitions
771
+ and the code components the document actually places. Counting
772
+ only `defs` read "0" over seven visible rows in every project
773
+ that writes its components in code rather than authoring them
774
+ here, which is the common case. */}
775
+ <span className="file-section-count">
776
+ {Object.keys(defs).length + usedComponents.length}
777
+ </span>
778
+ {/* NOT `.layer-flag`, which is what this was. That class rests at 0.28
779
+ outside a row that lifts it, and this button is not inside a row,
780
+ so the one way to make a definition from nothing sat permanently
781
+ dimmed with no hover that could reach it.
782
+ Both handlers matter: `stopPropagation` keeps the summary's React
783
+ toggle out of it, and `preventDefault` keeps the browser's own
784
+ summary activation out of it too. */}
179
785
  <button
180
786
  type="button"
181
- className="layer-flag"
182
- aria-label={`Place ${d.name}`}
183
- title="Place another one"
184
- onClick={() => store.placeComponent(d.id)}
787
+ className="rail-group-action"
788
+ data-testid="new-component"
789
+ aria-label="New component"
790
+ title="New component"
791
+ onClick={(e) => {
792
+ e.preventDefault();
793
+ e.stopPropagation();
794
+ store.createEmptyComponent("Component");
795
+ }}
185
796
  >
186
797
  +
187
798
  </button>
188
- </li>
189
- ))}
190
- </ul>
191
- </>
192
- )}
193
-
194
- <div className="file-section-label">{editingDef === null ? "Layers" : "Inside"}</div>
195
- {/* THE WAY BACK OUT. Isolation scopes the outline to one subtree, and a
196
- scoped list with no path above it reads as "the rest of my layers are
197
- gone" — which is exactly what an author reports. Each crumb is a
198
- button, so leaving is one click from any depth rather than N presses
199
- of Escape. */}
200
- {scene !== undefined && isolation !== null ? (
201
- <nav className="isolation-crumbs" data-testid="isolation-crumbs">
202
- <button type="button" className="crumb" onClick={() => store.exitIsolationAll()}>
203
- {scene.name}
204
- </button>
205
- {crumbsFor(scene, isolation).map((id, i, all) => (
206
- <button
207
- key={id}
208
- type="button"
209
- className={`crumb${i === all.length - 1 ? " is-current" : ""}`}
210
- aria-current={i === all.length - 1}
211
- onClick={() => store.enterIsolation(id)}
212
- >
213
- {scene.layersById[id]?.label ?? id}
214
- </button>
215
- ))}
216
- </nav>
217
- ) : null}
218
- {home === null ? null : (
219
- <ul className="layer-list" data-testid="layer-list">
220
- <LayerRows
221
- // The CONTAINER, which is a definition while one is being edited.
222
- // Every op the rows drive takes a container id, so the drag, the
223
- // rename and the flags all work inside a definition unchanged.
224
- scene={home}
225
- containerId={editingDef ?? sceneId ?? ""}
226
- // SCOPED to the isolated subtree's own children, so the outline
227
- // says the same thing the stage does about what is being edited.
228
- ids={rootIds}
229
- depth={0}
230
- parent={editingDef === null ? isolation : null}
231
- query={trimmedQuery}
232
- collapsed={collapsed}
233
- onToggleCollapse={toggleCollapse}
234
- onRowClick={rowClick}
235
- />
236
- </ul>
237
- )}
799
+ </summary>
800
+ {Object.keys(defs).length === 0 ? null : (
801
+ <ul className="component-list" data-testid="component-list">
802
+ {Object.values(defs).map((d) => (
803
+ <li
804
+ key={d.id}
805
+ className={`component-row${d.id === editingDef ? " is-current" : ""}`}
806
+ >
807
+ {/* The four-diamond is the component family's own glyph, so it
808
+ carries the family's accent like every other diamond. */}
809
+ <span
810
+ className="layer-kind is-component"
811
+ aria-hidden="true"
812
+ >
813
+ {COMPONENT_ICON}
814
+ </span>
815
+ <button
816
+ type="button"
817
+ className="layer-name"
818
+ aria-current={d.id === editingDef}
819
+ // OPENS THE COMPONENT ITSELF: this row names a definition and
820
+ // no layer inside it, so `editComponent` lands on the Studio.
821
+ // Its interior is one hand-off further in.
822
+ onClick={() =>
823
+ store.editComponent(d.id === editingDef ? null : d.id)
824
+ }
825
+ >
826
+ {d.name}
827
+ </button>
828
+ <span className="component-count" aria-hidden="true">
829
+ {instancesOf(doc, d.id).length}
830
+ </span>
831
+ {/* Placing an instance is an INSERT: it lands an element at the
832
+ playhead, and `insertContext` refuses every insert in the
833
+ scene context. ABSENT rather than disabled, which is this
834
+ panel's rule for a verb the current level cannot do at all:
835
+ the store's refusal stays the backstop underneath it. */}
836
+ {inScene ? null : (
837
+ <button
838
+ type="button"
839
+ className="layer-flag"
840
+ aria-label={`Place ${d.name}`}
841
+ title="Place another one"
842
+ onClick={() => store.placeComponent(d.id)}
843
+ >
844
+ +
845
+ </button>
846
+ )}
847
+ </li>
848
+ ))}
849
+ </ul>
850
+ )}
238
851
 
239
- {/* Last, and below the layer tree: connecting an agent is about the
240
- workspace, not the document being edited right now. */}
241
- <McpSection />
852
+ {/* THE OTHER COMPONENT FAMILY, and the reason this section used to look
853
+ empty in a shot full of components. A definition above is authored
854
+ here and lives in `doc.components`; a row below is a component from
855
+ the author's own repo, placed as an `instance` layer, which nothing in
856
+ this chrome ever listed — so a shot built entirely from repo
857
+ components showed a header and nothing else.
858
+ A SEPARATE list, not merged into the one above: the two families are
859
+ different things (one can be entered and edited, one is a .tsx file
860
+ the author owns) and the rows do different things when clicked. */}
861
+ {usedComponents.length === 0 ? null : (
862
+ <ul
863
+ className="component-list"
864
+ data-testid="used-component-list"
865
+ >
866
+ {usedComponents.map((used) => {
867
+ // The registry's display name once it has loaded, the export name
868
+ // parsed out of the id until then. Never a spinner and never an
869
+ // empty row: the id always carries a usable name.
870
+ const row = library.find((r) => r.id === used.component);
871
+ const name = row?.name ?? instanceExportOf(used.component);
872
+ // The ones in the CONTAINER the author is looking at. A count is
873
+ // document-wide, but selecting reaches only what is in front of
874
+ // them, so a row whose instances all live elsewhere does not
875
+ // pretend the click did something.
876
+ const here = used.layerIds.filter(
877
+ (id) => home?.layersById[id] !== undefined,
878
+ );
879
+ return (
880
+ <li
881
+ key={used.component}
882
+ className="component-row component-row--code"
883
+ >
884
+ <span
885
+ className="layer-kind is-component"
886
+ aria-hidden="true"
887
+ >
888
+ {kindIcon("instance")}
889
+ </span>
890
+ {/* OPENS THE COMPONENT, which is what a row naming a component
891
+ should do — the definition row above opens one too. For this
892
+ family that means the code studio: the source file on the
893
+ left and a render of it on the right, because a code
894
+ component's interior IS its source.
895
+ NEVER DISABLED. Whether a shot happens to place this
896
+ component in the container the author is standing in has
897
+ nothing to do with whether its file can be opened. */}
898
+ <button
899
+ type="button"
900
+ className="layer-name"
901
+ // The module path, which is the "which file is this" this row
902
+ // owes: it is a file in the author's own repo.
903
+ title={used.component}
904
+ onClick={() =>
905
+ void store.openCodeComponent(used.component)
906
+ }
907
+ >
908
+ {name}
909
+ </button>
910
+ {/* THE COUNT IS THE OTHER DOOR. It was the whole row's click
911
+ before the code studio existed, and selecting the placements
912
+ is still worth reaching — so it moved onto the number that
913
+ names them rather than being lost. Disabled when they all
914
+ live in another container, because selection reaches only
915
+ what is in front of the author. */}
916
+ <button
917
+ type="button"
918
+ className="component-count"
919
+ aria-label={`Select the ${here.length} placed here`}
920
+ title="Select this component's layers in the open container"
921
+ disabled={here.length === 0}
922
+ onClick={() => store.select(here)}
923
+ >
924
+ {used.layerIds.length}
925
+ </button>
926
+ {/* Same insert gate as a definition's `+` above, and the same
927
+ verb the Add menu places registry components with. */}
928
+ {inScene ? null : (
929
+ <button
930
+ type="button"
931
+ className="layer-flag"
932
+ aria-label={`Place ${name}`}
933
+ title="Place another one"
934
+ onClick={() =>
935
+ void store
936
+ .addInstanceLayer(used.component)
937
+ .catch(() => {})
938
+ }
939
+ >
940
+ +
941
+ </button>
942
+ )}
943
+ </li>
944
+ );
945
+ })}
946
+ </ul>
947
+ )}
948
+ {Object.keys(defs).length === 0 && usedComponents.length === 0 ? (
949
+ <div className="resource-empty">
950
+ Nothing yet. Group layers into a component, or add one from
951
+ the + menu.
952
+ </div>
953
+ ) : null}
954
+ </details>
955
+ )}
956
+ </>
957
+ </div>
242
958
  </div>
243
959
  );
244
960
  }
245
961
 
246
- function LayerRows({
962
+ const OUTLINE_WINDOW_THRESHOLD = 60;
963
+
964
+ interface LayerEntry {
965
+ layer: Layer3D;
966
+ /** Its index in the STORED sibling list (0 = the back), not its row. */
967
+ index: number;
968
+ parent: string | null;
969
+ depth: number;
970
+ siblingCount: number;
971
+ hasChildren: boolean;
972
+ open: boolean;
973
+ }
974
+
975
+ interface VirtualLayerInteraction {
976
+ tabIndex: number;
977
+ registerName(id: string, node: HTMLButtonElement | null): void;
978
+ onNameFocus(id: string): void;
979
+ onNameKeyDown(id: string, event: ReactKeyboardEvent<HTMLButtonElement>): void;
980
+ onRenameChange(id: string, active: boolean): void;
981
+ onDragChange(id: string | null): void;
982
+ }
983
+
984
+ /** A cell a host panel adds at the END of every row (the board's Layers tab
985
+ * puts each layer's life bar there). The Files panel passes none, so its rows
986
+ * are unchanged. */
987
+ export type LayerRowTrailing = (layer: Layer3D) => React.ReactNode;
988
+
989
+ /** Large outlines are flattened to the same visible order shift-selection
990
+ * uses, then windowed. Small trees keep the original nested DOM and guide
991
+ * rails exactly. */
992
+ export function WindowedLayerRows(props: {
993
+ scene: LayerHome;
994
+ containerId: string;
995
+ ids: readonly string[];
996
+ parent: string | null;
997
+ query: string;
998
+ collapsed: ReadonlySet<string>;
999
+ onToggleCollapse: (id: string) => void;
1000
+ onRowClick: (
1001
+ id: string,
1002
+ e: { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean },
1003
+ ) => void;
1004
+ onRowKeyMove: (fromId: string, toId: string, extend: boolean) => void;
1005
+ trailing?: LayerRowTrailing;
1006
+ }) {
1007
+ const entries = useMemo(
1008
+ () =>
1009
+ flattenLayerEntries(
1010
+ props.scene,
1011
+ props.ids,
1012
+ props.parent,
1013
+ props.collapsed,
1014
+ props.query,
1015
+ ),
1016
+ [props.scene, props.ids, props.parent, props.collapsed, props.query],
1017
+ );
1018
+ const listRef = useRef<HTMLUListElement>(null);
1019
+ const nameRefs = useRef(new Map<string, HTMLButtonElement>());
1020
+ const [activeId, setActiveId] = useState<string | null>(null);
1021
+ const [focusRequest, setFocusRequest] = useState<string | null>(null);
1022
+ const [renamingId, setRenamingId] = useState<string | null>(null);
1023
+ const [draggingId, setDraggingId] = useState<string | null>(null);
1024
+ const virtual = entries.length > OUTLINE_WINDOW_THRESHOLD;
1025
+ const range = useFixedVirtualWindow({
1026
+ listRef,
1027
+ scrollSelector: ".rail-scroll",
1028
+ count: entries.length,
1029
+ rowHeight: LAYER_ROW_H,
1030
+ enabled: virtual,
1031
+ });
1032
+
1033
+ const entryIndex = useMemo(
1034
+ () =>
1035
+ new Map(entries.map((entry, index) => [entry.layer.id, index] as const)),
1036
+ [entries],
1037
+ );
1038
+ const effectiveActiveId =
1039
+ activeId !== null && entryIndex.has(activeId)
1040
+ ? activeId
1041
+ : (entries[0]?.layer.id ?? null);
1042
+ const registerName = useCallback(
1043
+ (id: string, node: HTMLButtonElement | null): void => {
1044
+ if (node === null) nameRefs.current.delete(id);
1045
+ else nameRefs.current.set(id, node);
1046
+ },
1047
+ [],
1048
+ );
1049
+
1050
+ useLayoutEffect(() => {
1051
+ if (focusRequest === null) return;
1052
+ const button = nameRefs.current.get(focusRequest);
1053
+ if (button === undefined) return;
1054
+ // The requested row is already pinned into the correct logical position.
1055
+ // Let the browser reveal it, then focus without asking for a second scroll.
1056
+ if (typeof button.scrollIntoView === "function") {
1057
+ button.scrollIntoView({ block: "nearest", inline: "nearest" });
1058
+ }
1059
+ button.focus({ preventScroll: true });
1060
+ setFocusRequest(null);
1061
+ }, [focusRequest]);
1062
+
1063
+ const focusEntry = (
1064
+ fromId: string,
1065
+ index: number,
1066
+ extend: boolean,
1067
+ select: boolean,
1068
+ ): void => {
1069
+ const target = entries[index];
1070
+ if (target === undefined) return;
1071
+ const id = target.layer.id;
1072
+ if (select) props.onRowKeyMove(fromId, id, extend);
1073
+ setActiveId(id);
1074
+ setFocusRequest(id);
1075
+ };
1076
+
1077
+ const onNameKeyDown = (
1078
+ id: string,
1079
+ event: ReactKeyboardEvent<HTMLButtonElement>,
1080
+ ): void => {
1081
+ const index = entryIndex.get(id);
1082
+ if (index === undefined) return;
1083
+ const entry = entries[index]!;
1084
+ let target: number | null = null;
1085
+ if (event.key === "ArrowDown")
1086
+ target = Math.min(entries.length - 1, index + 1);
1087
+ else if (event.key === "ArrowUp") target = Math.max(0, index - 1);
1088
+ else if (event.key === "Home") target = 0;
1089
+ else if (event.key === "End") target = entries.length - 1;
1090
+ else if (event.key === "ArrowRight") {
1091
+ if (entry.hasChildren && !entry.open && props.query.length === 0) {
1092
+ event.preventDefault();
1093
+ event.stopPropagation();
1094
+ props.onToggleCollapse(id);
1095
+ return;
1096
+ }
1097
+ if (entry.hasChildren && entry.open)
1098
+ target = Math.min(entries.length - 1, index + 1);
1099
+ } else if (event.key === "ArrowLeft") {
1100
+ if (entry.hasChildren && entry.open && props.query.length === 0) {
1101
+ event.preventDefault();
1102
+ event.stopPropagation();
1103
+ props.onToggleCollapse(id);
1104
+ return;
1105
+ }
1106
+ if (entry.parent !== null) target = entryIndex.get(entry.parent) ?? null;
1107
+ }
1108
+ if (target === null || target === index) return;
1109
+ event.preventDefault();
1110
+ event.stopPropagation();
1111
+ // Ctrl/Cmd moves focus without changing selection. Shift extends from the
1112
+ // stable range anchor; an ordinary move selects and moves that anchor.
1113
+ focusEntry(id, target, event.shiftKey, !event.ctrlKey && !event.metaKey);
1114
+ };
1115
+
1116
+ const pinnedIndexes = [effectiveActiveId, renamingId, draggingId]
1117
+ .map((id) => (id === null ? undefined : entryIndex.get(id)))
1118
+ .filter((index): index is number => index !== undefined);
1119
+ const spans = pinnedWindowSpans(range, entries.length, pinnedIndexes);
1120
+ const virtualRows: React.ReactNode[] = [];
1121
+ let cursor = 0;
1122
+ for (const span of spans) {
1123
+ if (span.start > cursor) {
1124
+ virtualRows.push(
1125
+ <li
1126
+ key={`outline-gap-${cursor}-${span.start}`}
1127
+ aria-hidden="true"
1128
+ role="presentation"
1129
+ data-outline-spacer="true"
1130
+ style={{ height: (span.start - cursor) * LAYER_ROW_H }}
1131
+ />,
1132
+ );
1133
+ }
1134
+ for (let index = span.start; index < span.end; index += 1) {
1135
+ const entry = entries[index]!;
1136
+ virtualRows.push(
1137
+ <LayerRow
1138
+ key={entry.layer.id}
1139
+ layer={entry.layer}
1140
+ index={entry.index}
1141
+ parent={entry.parent}
1142
+ containerId={props.containerId}
1143
+ hasChildren={entry.hasChildren && props.query.length === 0}
1144
+ open={entry.open}
1145
+ childRowBelow={entries[index + 1]?.parent === entry.layer.id}
1146
+ mask={maskMarksOf(props.scene, entry.layer.id, entry.depth)}
1147
+ onToggleCollapse={props.onToggleCollapse}
1148
+ onRowClick={props.onRowClick}
1149
+ {...(props.trailing === undefined ? {} : { trailing: props.trailing })}
1150
+ tree={{
1151
+ level: entry.depth + 1,
1152
+ // Where the row SITS among its siblings, which is front first; the
1153
+ // array index counts from the back.
1154
+ position: entry.siblingCount - entry.index,
1155
+ size: entry.siblingCount,
1156
+ }}
1157
+ interaction={{
1158
+ tabIndex: entry.layer.id === effectiveActiveId ? 0 : -1,
1159
+ registerName,
1160
+ onNameFocus: setActiveId,
1161
+ onNameKeyDown,
1162
+ onRenameChange: (id, active) => setRenamingId(active ? id : null),
1163
+ onDragChange: setDraggingId,
1164
+ }}
1165
+ />,
1166
+ );
1167
+ }
1168
+ cursor = span.end;
1169
+ }
1170
+ if (cursor < entries.length) {
1171
+ virtualRows.push(
1172
+ <li
1173
+ key={`outline-gap-${cursor}-${entries.length}`}
1174
+ aria-hidden="true"
1175
+ role="presentation"
1176
+ data-outline-spacer="true"
1177
+ style={{ height: (entries.length - cursor) * LAYER_ROW_H }}
1178
+ />,
1179
+ );
1180
+ }
1181
+
1182
+ return (
1183
+ <ul
1184
+ ref={listRef}
1185
+ className="layer-list"
1186
+ data-testid="layer-list"
1187
+ role="tree"
1188
+ >
1189
+ {!virtual ? <LayerRows {...props} depth={0} /> : virtualRows}
1190
+ </ul>
1191
+ );
1192
+ }
1193
+
1194
+ export function LayerRows({
247
1195
  scene,
248
1196
  containerId,
249
1197
  ids,
250
- depth,
251
1198
  parent,
252
1199
  query,
253
1200
  collapsed,
254
1201
  onToggleCollapse,
255
1202
  onRowClick,
1203
+ trailing,
1204
+ depth = 0,
256
1205
  }: {
257
- scene: { layersById: Record<string, Layer3D> };
1206
+ scene: LayerHome;
258
1207
  /** The scene OR component definition these layers live in. Every op the rows
259
1208
  * drive takes one, so a definition's interior is editable through exactly
260
1209
  * the same drag, rename and flag handlers. */
261
1210
  containerId: string;
262
1211
  ids: readonly string[];
263
- depth: number;
264
1212
  /** The layer these rows are children of, or null at the scene root. A drop
265
1213
  * BETWEEN rows lands here; a drop ON one lands inside that row. */
266
1214
  parent: string | null;
267
1215
  query: string;
268
1216
  collapsed: ReadonlySet<string>;
269
1217
  onToggleCollapse: (id: string) => void;
270
- onRowClick: (id: string, e: { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean }) => void;
1218
+ onRowClick: (
1219
+ id: string,
1220
+ e: { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean },
1221
+ ) => void;
1222
+ trailing?: LayerRowTrailing;
1223
+ depth?: number;
271
1224
  }) {
272
1225
  return (
273
1226
  <>
274
- {ids.map((id, index) => {
1227
+ {frontFirst(ids).map(({ id, index }) => {
275
1228
  const layer = scene.layersById[id];
276
1229
  if (layer === undefined) return null;
277
1230
  const children = layerChildren(layer);
278
1231
  // A search hides a row only when NEITHER it nor anything below it
279
1232
  // matches — hiding a matching layer because its group did not match
280
1233
  // would make the search unable to find anything nested.
281
- if (query.length > 0 && !subtreeMatches(scene, layer, query)) return null;
1234
+ if (query.length > 0 && !subtreeMatches(scene, layer, query))
1235
+ return null;
282
1236
  // A SEARCH OVERRIDES A COLLAPSE: a match under a collapsed group must
283
1237
  // still be findable, the same rule the subtree gate above states. The
284
1238
  // one flattening in `flattenVisible` mirrors both, so a range select
@@ -288,7 +1242,6 @@ function LayerRows({
288
1242
  <LayerRow
289
1243
  key={id}
290
1244
  layer={layer}
291
- depth={depth}
292
1245
  index={index}
293
1246
  parent={parent}
294
1247
  containerId={containerId}
@@ -300,21 +1253,38 @@ function LayerRows({
300
1253
  // what is on screen, so the row simply does not offer one.
301
1254
  hasChildren={children.length > 0 && query.length === 0}
302
1255
  open={open}
1256
+ // A search hides the chevron and still lists the matching
1257
+ // children, and a parent matched by its own label may list none.
1258
+ childRowBelow={
1259
+ open &&
1260
+ children.some((c) => {
1261
+ const kid = scene.layersById[c];
1262
+ return kid !== undefined && (query.length === 0 || subtreeMatches(scene, kid, query));
1263
+ })
1264
+ }
303
1265
  onToggleCollapse={onToggleCollapse}
304
1266
  onRowClick={onRowClick}
1267
+ {...(trailing === undefined ? {} : { trailing })}
1268
+ mask={maskMarksOf(scene, id, depth)}
1269
+ tree={{ level: depth + 1 }}
305
1270
  >
1271
+ {/* Each row indents by its own `--depth` (set on its LI), in the
1272
+ nested tree and the windowed list alike. The nesting `ul` only
1273
+ draws the guide, see `.layer-children`, so the step
1274
+ cannot be applied twice. */}
306
1275
  {children.length > 0 && open ? (
307
- <ul className="layer-children">
1276
+ <ul className="layer-children" role="group">
308
1277
  <LayerRows
309
1278
  scene={scene}
310
1279
  containerId={containerId}
311
1280
  ids={children}
312
- depth={depth + 1}
313
1281
  parent={layer.id}
314
1282
  query={query}
315
1283
  collapsed={collapsed}
316
1284
  onToggleCollapse={onToggleCollapse}
317
1285
  onRowClick={onRowClick}
1286
+ {...(trailing === undefined ? {} : { trailing })}
1287
+ depth={depth + 1}
318
1288
  />
319
1289
  </ul>
320
1290
  ) : null}
@@ -325,6 +1295,84 @@ function LayerRows({
325
1295
  );
326
1296
  }
327
1297
 
1298
+ /**
1299
+ * THE OUTLINE'S CLICK POLICY, shared by every panel that lists layer rows
1300
+ * (this one and the board's Layers tab): shift = range over the visible order,
1301
+ * ctrl/meta = toggle, plain = select + anchor. The panel owns the anchor and
1302
+ * the collapsed set; this only reads them.
1303
+ */
1304
+ export function layerRowSelection(o: {
1305
+ store: ShotEditorStore;
1306
+ scene: { layersById: Record<string, Layer3D> } | null;
1307
+ ids: readonly string[];
1308
+ collapsed: ReadonlySet<string>;
1309
+ query: string;
1310
+ anchor: string | null;
1311
+ setAnchor: (id: string) => void;
1312
+ }): {
1313
+ rowClick: (id: string, e: { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean }) => void;
1314
+ rowKeyMove: (fromId: string, toId: string, extend: boolean) => void;
1315
+ } {
1316
+ const { store, scene: home, ids: rootIds, collapsed, query: trimmedQuery, anchor, setAnchor } = o;
1317
+ const rowClick = (
1318
+ id: string,
1319
+ e: { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean },
1320
+ ): void => {
1321
+ const selection = store.getState().selection;
1322
+ if (e.shiftKey && anchor !== null && home !== null) {
1323
+ // The range is over WHAT IS ON SCREEN — the same flattening the rows
1324
+ // render from, so a collapsed group's invisible children are never
1325
+ // swept into a selection the author cannot see.
1326
+ const flat = flattenVisible(home, rootIds, collapsed, trimmedQuery);
1327
+ const a = flat.indexOf(anchor);
1328
+ const b = flat.indexOf(id);
1329
+ if (a !== -1 && b !== -1) {
1330
+ store.select(flat.slice(Math.min(a, b), Math.max(a, b) + 1));
1331
+ store.revealSelection(id, false);
1332
+ return;
1333
+ }
1334
+ // A stale anchor (deleted, scoped away) falls through to a plain click.
1335
+ }
1336
+ if (e.ctrlKey || e.metaKey) {
1337
+ store.select(
1338
+ selection.includes(id)
1339
+ ? selection.filter((x) => x !== id)
1340
+ : [...selection, id],
1341
+ );
1342
+ // Nothing when this click took the row OUT: `revealSelection` checks.
1343
+ store.revealSelection(id, false);
1344
+ setAnchor(id);
1345
+ return;
1346
+ }
1347
+ store.select([id]);
1348
+ store.revealSelection(id, true);
1349
+ setAnchor(id);
1350
+ };
1351
+
1352
+ /** Keyboard range navigation starts from the focused row when no pointer
1353
+ * anchor exists, then keeps that same anchor while Shift extends/retracts.
1354
+ * It operates over the complete visible flattening, not the mounted window. */
1355
+ const rowKeyMove = (fromId: string, toId: string, extend: boolean): void => {
1356
+ if (home === null) return;
1357
+ const flat = flattenVisible(home, rootIds, collapsed, trimmedQuery);
1358
+ if (!extend) {
1359
+ store.select([toId]);
1360
+ store.revealSelection(toId, true);
1361
+ setAnchor(toId);
1362
+ return;
1363
+ }
1364
+ const candidate =
1365
+ anchor !== null && flat.includes(anchor) ? anchor : fromId;
1366
+ const from = flat.indexOf(candidate);
1367
+ const to = flat.indexOf(toId);
1368
+ if (from === -1 || to === -1) return;
1369
+ store.select(flat.slice(Math.min(from, to), Math.max(from, to) + 1));
1370
+ store.revealSelection(toId, false);
1371
+ setAnchor(candidate);
1372
+ };
1373
+ return { rowClick, rowKeyMove };
1374
+ }
1375
+
328
1376
  /**
329
1377
  * The row order AS RENDERED — same recursion, same predicates as `LayerRows`
330
1378
  * (subtree match, collapse-with-search-override) — for the shift range.
@@ -336,36 +1384,76 @@ function flattenVisible(
336
1384
  collapsed: ReadonlySet<string>,
337
1385
  query: string,
338
1386
  ): string[] {
339
- const out: string[] = [];
340
- for (const id of ids) {
1387
+ return flattenLayerEntries(scene, ids, null, collapsed, query).map(
1388
+ (entry) => entry.layer.id,
1389
+ );
1390
+ }
1391
+
1392
+ function flattenLayerEntries(
1393
+ scene: { layersById: Record<string, Layer3D> },
1394
+ ids: readonly string[],
1395
+ parent: string | null,
1396
+ collapsed: ReadonlySet<string>,
1397
+ query: string,
1398
+ depth = 0,
1399
+ ): LayerEntry[] {
1400
+ const out: LayerEntry[] = [];
1401
+ for (const { id, index } of frontFirst(ids)) {
341
1402
  const layer = scene.layersById[id];
342
1403
  if (layer === undefined) continue;
343
1404
  if (query.length > 0 && !subtreeMatches(scene, layer, query)) continue;
344
- out.push(id);
345
1405
  const children = layerChildren(layer);
346
1406
  const open = !collapsed.has(id) || query.length > 0;
347
- if (children.length > 0 && open) out.push(...flattenVisible(scene, children, collapsed, query));
1407
+ out.push({
1408
+ layer,
1409
+ index,
1410
+ parent,
1411
+ depth,
1412
+ siblingCount: ids.length,
1413
+ hasChildren: children.length > 0,
1414
+ open,
1415
+ });
1416
+ if (children.length > 0 && open) {
1417
+ out.push(
1418
+ ...flattenLayerEntries(
1419
+ scene,
1420
+ children,
1421
+ layer.id,
1422
+ collapsed,
1423
+ query,
1424
+ depth + 1,
1425
+ ),
1426
+ );
1427
+ }
348
1428
  }
349
1429
  return out;
350
1430
  }
351
1431
 
352
- /** The isolated layer and every ancestor above it, outermost first. The path
353
- * the breadcrumb walks, and the reason entering a nested subtree is reversible
354
- * one level at a time. */
355
- function crumbsFor(scene: { layersById: Record<string, Layer3D> }, id: string): string[] {
356
- const out: string[] = [];
357
- let at: string | null = id;
358
- for (let i = 0; at !== null && i <= Object.keys(scene.layersById).length; i += 1) {
359
- out.push(at);
360
- at = parentOf(scene as never, at);
361
- }
362
- return out.reverse();
1432
+ /**
1433
+ * What a drag from `grabbed` carries: the whole selection when that row is part
1434
+ * of it, otherwise just that row — Figma's rule, and the only one that does not
1435
+ * lie about what is selected.
1436
+ *
1437
+ * Order is NOT decided here: `moveLayersTo` sorts into paint order (the walk)
1438
+ * against the container it is moving within. Doing it there and not here means
1439
+ * an MCP or menu caller gets the same guarantee.
1440
+ */
1441
+ function dragPayload(
1442
+ state: { selection: readonly string[] },
1443
+ grabbed: string,
1444
+ ): string[] {
1445
+ return state.selection.includes(grabbed) ? [...state.selection] : [grabbed];
363
1446
  }
364
1447
 
365
1448
  /** Which third of a row the pointer is over. The middle is the biggest, so
366
1449
  * "drop it in there" is the easy gesture and reordering is the deliberate
367
- * one — Figma's proportions, and for the same reason. */
368
- function dropZone(e: { clientY: number }, row: HTMLElement): "before" | "into" | "after" {
1450
+ * one: Figma's proportions, and for the same reason. "before" and "after"
1451
+ * name where the line is DRAWN, above and below the row, which in a front
1452
+ * first outline is in front of it and behind it. */
1453
+ function dropZone(
1454
+ e: { clientY: number },
1455
+ row: HTMLElement,
1456
+ ): "before" | "into" | "after" {
369
1457
  const rect = row.getBoundingClientRect();
370
1458
  const t = (e.clientY - rect.top) / Math.max(1, rect.height);
371
1459
  if (t < 0.28) return "before";
@@ -385,48 +1473,99 @@ function subtreeMatches(
385
1473
  });
386
1474
  }
387
1475
 
388
- function LayerRow({
1476
+ export function LayerRow({
389
1477
  layer,
390
- depth,
391
1478
  index,
392
1479
  parent,
393
1480
  containerId,
394
1481
  hasChildren,
395
1482
  open,
1483
+ childRowBelow,
396
1484
  onToggleCollapse,
397
1485
  onRowClick,
1486
+ trailing,
398
1487
  children,
1488
+ tree,
1489
+ interaction,
1490
+ mask,
399
1491
  }: {
400
1492
  layer: Layer3D;
401
- depth: number;
402
1493
  index: number;
403
1494
  parent: string | null;
404
1495
  /** Scene or definition — see `LayerRows`. */
405
1496
  containerId: string;
1497
+ /** Offers the disclosure chevron, which a search takes away. */
406
1498
  hasChildren: boolean;
407
1499
  open: boolean;
1500
+ /** The row directly below this one is one of its own children, so the
1501
+ * lower drop line sits over that child, not over the next row out. */
1502
+ childRowBelow: boolean;
408
1503
  onToggleCollapse: (id: string) => void;
409
- onRowClick: (id: string, e: { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean }) => void;
410
- children: React.ReactNode;
1504
+ onRowClick: (
1505
+ id: string,
1506
+ e: { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean },
1507
+ ) => void;
1508
+ /** A host panel's cell after the flags, see `LayerRowTrailing`. */
1509
+ trailing?: LayerRowTrailing;
1510
+ children?: React.ReactNode;
1511
+ /** `level` is 1-based (it is `aria-level`), so the depth is one less. */
1512
+ tree: { level: number; position?: number; size?: number };
1513
+ interaction?: VirtualLayerInteraction;
1514
+ /** The row's mask icon, badge and bracket slices (gap 7), see maskMarks. */
1515
+ mask?: MaskMarks;
411
1516
  }) {
412
1517
  const store = useStore();
413
- const selected = useShotEditor((s) => s.selection.includes(layer.id));
1518
+ const selected = useShotEditor((s) =>
1519
+ selectionMembership(s.selection).has(layer.id),
1520
+ );
1521
+ // The drawer placed this WITHOUT selecting it ("Use as background"), so the
1522
+ // row lights once to show where it went. The next selection clears it.
1523
+ const justPlaced = useShotEditor((s) => s.justPlaced === layer.id);
414
1524
  // Two primitives, never the record: `useShotEditor` compares with `Object.is`
415
1525
  // per notification, so returning a slice of the map would re-render every row
416
1526
  // on every unrelated change.
417
1527
  const triangles = useShotEditor((s) => s.meshTriangles[layer.id]);
418
- const sceneTriangles = useShotEditor((s) => sumTriangles(s.meshTriangles));
419
- const overBudget = triangles !== undefined && sceneTriangles > TRIANGLE_SOFT_BUDGET;
1528
+ const sceneTriangles = useShotEditor((s) =>
1529
+ sumTrianglesCached(s.meshTriangles),
1530
+ );
1531
+ const overBudget =
1532
+ triangles !== undefined && sceneTriangles > TRIANGLE_SOFT_BUDGET;
420
1533
  const [renaming, setRenaming] = useState(false);
1534
+ // A viewer cannot restack, so the row does not lift.
1535
+ const readOnly = useShotEditor((s) => s.readOnly);
421
1536
  const [over, setOver] = useState<"before" | "into" | "after" | null>(null);
422
1537
  const menu = useContextMenu();
1538
+ /** The lower line of an EXPANDED parent sits over its first child row, the
1539
+ * frontmost, so it drops into the parent at the front (founder decision 7),
1540
+ * and it is drawn at the children's indent to say so. Expanded means a
1541
+ * child row is on screen under it, a search's forced open included. */
1542
+ const intoFront = childRowBelow;
1543
+ const overClass =
1544
+ over === null
1545
+ ? ""
1546
+ : over === "after" && intoFront
1547
+ ? " is-drop-inside"
1548
+ : ` is-drop-${over}`;
423
1549
 
424
1550
  return (
425
- <li className="layer-item">
1551
+ <li
1552
+ className="layer-item"
1553
+ role="treeitem"
1554
+ // On the LI and not the row, so a nested `.layer-children` inherits its
1555
+ // parent's depth and draws the guide at the parent's indent.
1556
+ // `--mask-inset` rides the same LI, so the nested guide moves with it.
1557
+ style={{ "--depth": tree.level - 1, "--mask-inset": `${mask?.inset ?? 0}px` } as CSSProperties}
1558
+ aria-level={tree.level}
1559
+ {...(tree.position === undefined
1560
+ ? {}
1561
+ : { "aria-posinset": tree.position })}
1562
+ {...(tree.size === undefined ? {} : { "aria-setsize": tree.size })}
1563
+ {...(hasChildren ? { "aria-expanded": open } : {})}
1564
+ aria-selected={selected}
1565
+ >
426
1566
  {menu.menu}
427
1567
  <div
428
- className={`layer-row${selected ? " is-selected" : ""}${over === null ? "" : ` is-drop-${over}`}`}
429
- style={{ paddingLeft: 8 + depth * 12 }}
1568
+ className={`layer-row${selected ? " is-selected" : ""}${justPlaced ? " is-just-placed" : ""}${mask?.off === true ? " is-mask-off" : ""}${overClass}`}
430
1569
  data-layer={layer.id}
431
1570
  // The SAME menu the stage opens, from the same builder. A row and a
432
1571
  // click on the canvas are two ways of pointing at one layer, and a
@@ -434,36 +1573,95 @@ function LayerRow({
434
1573
  // having only one of them.
435
1574
  onContextMenu={(e) => {
436
1575
  e.preventDefault();
1576
+ interaction?.onNameFocus(layer.id);
437
1577
  // Select first, unless the row is already part of the selection — in
438
1578
  // which case the whole selection stands and the menu acts on all of
439
1579
  // it. The stage's `onContextMenu` policy, deliberately identical.
440
1580
  const state = store.getState();
441
- const ids = state.selection.includes(layer.id) ? state.selection : [layer.id];
1581
+ const ids = state.selection.includes(layer.id)
1582
+ ? state.selection
1583
+ : [layer.id];
442
1584
  if (!state.selection.includes(layer.id)) store.select([layer.id]);
443
1585
  const next = store.getState();
444
- menu.open({ x: e.clientX, y: e.clientY }, layerMenuEntries(store, next, menuTargetsFor(next, ids)));
1586
+ menu.open(
1587
+ { x: e.clientX, y: e.clientY },
1588
+ // Rename here is the row's own inline edit, the one F2 opens.
1589
+ layerMenuEntries(store, next, menuTargetsFor(next, ids), {
1590
+ subject: layer.id,
1591
+ onRename: () => {
1592
+ interaction?.onRenameChange(layer.id, true);
1593
+ setRenaming(true);
1594
+ },
1595
+ }),
1596
+ );
445
1597
  }}
1598
+ // Any focusable control owns this row while it is active. Pinning only
1599
+ // the name would still let a scroll unmount the disclosure or flag a
1600
+ // keyboard user was operating.
1601
+ onFocusCapture={() => interaction?.onNameFocus(layer.id)}
446
1602
  // THE OUTLINE DRAG. Dropping on the MIDDLE of a row puts the layer
447
- // inside it; dropping near an edge puts it beside it, among that row's
448
- // own siblings. Both go through `moveLayer`, which re-bases the
449
- // transform so the tree changes and the picture does not.
450
- draggable
451
- onDragStart={(e) => e.dataTransfer.setData("text/plain", layer.id)}
1603
+ // inside it, at the front of its children (their top row); dropping
1604
+ // near an edge puts it beside it, among that row's own siblings, at the
1605
+ // gap the line shows. The rows are FRONT FIRST, so the line ABOVE a row
1606
+ // is in front of it (stored index + 1) and the line BELOW it is behind
1607
+ // it (its own index). The lower line of an EXPANDED parent sits over
1608
+ // its first child row, so it means "into it, at the front" (Figma's
1609
+ // rule, founder decision 7), not "behind its whole subtree". All go
1610
+ // through `moveLayer`, which re-bases the transform on a reparent so
1611
+ // the layer keeps its place in the frame; the list is paint order, so
1612
+ // a drop is a paint edit.
1613
+ //
1614
+ // THE WHOLE SELECTION TRAVELS, when the grabbed row is part of it.
1615
+ // Dragging one of three selected rows and watching one move is the
1616
+ // gesture lying about what is selected. `moveLayersTo` lands them
1617
+ // together at the gap, in the order they painted in.
1618
+ draggable={!readOnly}
1619
+ onDragStart={(e) => {
1620
+ e.dataTransfer.setData(
1621
+ "text/plain",
1622
+ dragPayload(store.getState(), layer.id).join("\n"),
1623
+ );
1624
+ interaction?.onDragChange(layer.id);
1625
+ }}
1626
+ onDragEnd={() => interaction?.onDragChange(null)}
452
1627
  onDragOver={(e) => {
453
1628
  e.preventDefault();
454
- setOver(dropZone(e, e.currentTarget));
1629
+ const zone = dropZone(e, e.currentTarget);
1630
+ // REFUSE BEFORE THE DROP, not after. A leaf cannot hold children
1631
+ // (`canHoldChildren` — the schema's rule, not the panel's), so the
1632
+ // into-zone shows the not-allowed cursor and paints no ring rather
1633
+ // than accepting a gesture whose only outcome is an error strip.
1634
+ //
1635
+ // NAMED CEILING: this is the only refusal expressible here. A cycle
1636
+ // ("inside itself") needs the dragged ids, and `dataTransfer` data is
1637
+ // unreadable during dragover by spec, so cycles stay drop-time
1638
+ // refusals — `moveLayer` still produces them and `sayRefusal` still
1639
+ // shows them.
1640
+ const refused = zone === "into" && !canHoldChildren(layer);
1641
+ e.dataTransfer.dropEffect = refused ? "none" : "move";
1642
+ setOver(refused ? null : zone);
455
1643
  }}
456
1644
  onDragLeave={() => setOver(null)}
457
1645
  onDrop={(e) => {
458
1646
  e.preventDefault();
459
1647
  const zone = dropZone(e, e.currentTarget);
460
1648
  setOver(null);
461
- const from = e.dataTransfer.getData("text/plain");
462
- if (from.length === 0 || from === layer.id) return;
463
- if (zone === "into") store.moveLayerTo(from, { parent: layer.id });
464
- else store.moveLayerTo(from, { parent, index: zone === "before" ? index : index + 1 });
1649
+ const ids = e.dataTransfer
1650
+ .getData("text/plain")
1651
+ .split("\n")
1652
+ .filter((id) => id.length > 0);
1653
+ interaction?.onDragChange(null);
1654
+ if (ids.length === 0 || ids.includes(layer.id)) return;
1655
+ if (zone === "into" || (zone === "after" && intoFront))
1656
+ store.moveLayersTo(ids, { parent: layer.id });
1657
+ else
1658
+ store.moveLayersTo(ids, {
1659
+ parent,
1660
+ index: zone === "before" ? index + 1 : index,
1661
+ });
465
1662
  }}
466
1663
  >
1664
+ {mask === undefined ? null : <MaskBracket segments={mask.segments} base="var(--space-6)" />}
467
1665
  {/* The disclosure chevron, a SEPARATE button so the name button's
468
1666
  textContent stays the label alone. Leaf rows get a spacer, so the
469
1667
  kind icons line up whether or not a row can fold. */}
@@ -483,8 +1681,14 @@ function LayerRow({
483
1681
  {/* OUTSIDE the name button, never inside it: the button's textContent is
484
1682
  the layer's label and nothing else — filePanel.test.tsx compares it
485
1683
  by equality, and the label is also what a screen reader announces. */}
486
- <span className="layer-kind" aria-hidden="true">
487
- {kindIcon(layer.kind)}
1684
+ <span
1685
+ className={`layer-kind${mask?.icon === "mask" ? " is-mask" : isComponentGlyph(layer) && mask?.icon == null ? " is-component" : ""}`}
1686
+ aria-hidden="true"
1687
+ >
1688
+ {/* Keyed on the THING, not the kind: a Box row shows a box, an
1689
+ emitter row its spark — the seam the shape-as-mesh decision must
1690
+ never leak (basics plan, Part F). */}
1691
+ {mask?.icon == null ? iconFor(layer) : MASK_ICON}
488
1692
  </span>
489
1693
  {renaming ? (
490
1694
  <input
@@ -495,25 +1699,63 @@ function LayerRow({
495
1699
  onBlur={(e) => {
496
1700
  store.renameLayer(layer.id, e.target.value);
497
1701
  setRenaming(false);
1702
+ interaction?.onRenameChange(layer.id, false);
498
1703
  }}
499
1704
  onKeyDown={(e) => {
500
1705
  if (e.key === "Enter") e.currentTarget.blur();
501
- if (e.key === "Escape") setRenaming(false);
1706
+ if (e.key === "Escape") {
1707
+ setRenaming(false);
1708
+ interaction?.onRenameChange(layer.id, false);
1709
+ }
502
1710
  }}
503
1711
  />
504
1712
  ) : (
505
1713
  <button
506
1714
  type="button"
507
1715
  className="layer-name"
1716
+ ref={(node) => interaction?.registerName(layer.id, node)}
1717
+ {...(interaction === undefined
1718
+ ? {}
1719
+ : { tabIndex: interaction.tabIndex })}
508
1720
  // The panel owns the click policy (shift = range over the visible
509
1721
  // order, ctrl/meta = toggle, plain = select + anchor) because only
510
1722
  // it knows the flattened row order a range is measured over.
511
1723
  onClick={(e) => onRowClick(layer.id, e)}
512
- onDoubleClick={() => setRenaming(true)}
1724
+ onFocus={() => interaction?.onNameFocus(layer.id)}
1725
+ onKeyDown={(e) => {
1726
+ if (e.key === "F2") {
1727
+ e.preventDefault();
1728
+ e.stopPropagation();
1729
+ if (store.viewOnly()) return;
1730
+ interaction?.onRenameChange(layer.id, true);
1731
+ setRenaming(true);
1732
+ return;
1733
+ }
1734
+ interaction?.onNameKeyDown(layer.id, e);
1735
+ }}
1736
+ onDoubleClick={() => {
1737
+ if (store.viewOnly()) return;
1738
+ interaction?.onRenameChange(layer.id, true);
1739
+ setRenaming(true);
1740
+ }}
513
1741
  >
514
1742
  {layer.label}
515
1743
  </button>
516
1744
  )}
1745
+ {/* The mask's mode, a sibling of the name for the kind icon's reason.
1746
+ In the windowed list a row is one fixed height, so the empty-run
1747
+ hint rides the badge's title there instead of its own line. */}
1748
+ {mask?.badge == null ? null : (
1749
+ <span
1750
+ className="mask-badge"
1751
+ title={mask.empty && interaction !== undefined ? NO_CONTENT_HINT : mask.badge}
1752
+ >
1753
+ {mask.badge}
1754
+ </span>
1755
+ )}
1756
+ {/* Who else holds this layer (Paper page 21), a dot each. */}
1757
+ <PeerDots at={(p) => p.sel.includes(layer.id)} />
1758
+ <LayerMarks layer={layer} />
517
1759
  {/* The model's cost, for the layers that HAVE one. A sibling span for
518
1760
  the kind icon's reason: `layer-name`'s textContent is the label and
519
1761
  nothing else. Absent until the model has loaded, and absent forever
@@ -523,17 +1765,23 @@ function LayerRow({
523
1765
  <span
524
1766
  className={`layer-tris${overBudget ? " is-over-budget" : ""}`}
525
1767
  aria-hidden="true"
526
- title={overBudget ? sceneBudgetWarning(sceneTriangles) : `${triangles.toLocaleString()} triangles`}
1768
+ title={
1769
+ overBudget
1770
+ ? sceneBudgetWarning(sceneTriangles)
1771
+ : `${triangles.toLocaleString()} triangles`
1772
+ }
527
1773
  >
528
1774
  {formatTriangles(triangles)}
529
1775
  </span>
530
1776
  )}
531
- {/* The hover-eye and the lock. `aria-pressed` rather than a checkbox
532
- because they are toggles on a row, and a screen reader should hear
533
- the layer's name with them. */}
1777
+ {/* The eye and the lock, VISIBLE AT REST in the tertiary ink (Paper's
1778
+ layer rows), so a row's hidden and locked state reads without a
1779
+ hover. `aria-pressed` rather than a checkbox because they are
1780
+ toggles on a row, and a screen reader should hear the layer's name
1781
+ with them. */}
534
1782
  <button
535
1783
  type="button"
536
- className="layer-flag"
1784
+ className="layer-flag is-eye"
537
1785
  aria-pressed={layer.hidden === true}
538
1786
  aria-label={`${layer.hidden === true ? "Show" : "Hide"} ${layer.label}`}
539
1787
  onClick={() => {
@@ -545,7 +1793,7 @@ function LayerRow({
545
1793
  </button>
546
1794
  <button
547
1795
  type="button"
548
- className="layer-flag"
1796
+ className="layer-flag is-lock"
549
1797
  aria-pressed={layer.locked === true}
550
1798
  aria-label={`${layer.locked === true ? "Unlock" : "Lock"} ${layer.label}`}
551
1799
  onClick={() => {
@@ -555,10 +1803,12 @@ function LayerRow({
555
1803
  >
556
1804
  {layer.locked === true ? LOCK_ICON : UNLOCK_ICON}
557
1805
  </button>
1806
+ {trailing?.(layer)}
558
1807
  </div>
1808
+ {mask?.empty === true && interaction === undefined ? (
1809
+ <div className="layer-mask-hint">{NO_CONTENT_HINT}</div>
1810
+ ) : null}
559
1811
  {children}
560
1812
  </li>
561
1813
  );
562
1814
  }
563
-
564
-