@pascal-app/editor 1.0.4 → 1.0.5

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 (309) hide show
  1. package/README.md +106 -0
  2. package/package.json +39 -37
  3. package/src/components/editor/__bench__/room-label-placement.bench.ts +108 -0
  4. package/src/components/editor/bake-thumbnail.tsx +6 -15
  5. package/src/components/editor/construction-reveal-audio.test.tsx +150 -0
  6. package/src/components/editor/construction-reveal-audio.tsx +40 -0
  7. package/src/components/editor/custom-camera-controls.tsx +81 -8
  8. package/src/components/editor/editor-layout-mobile.tsx +1 -1
  9. package/src/components/editor/editor-layout-v2.tsx +1 -1
  10. package/src/components/editor/export-manager.tsx +14 -19
  11. package/src/components/editor/first-person/build-collider-world.test.ts +269 -0
  12. package/src/components/editor/first-person/build-collider-world.ts +22 -5
  13. package/src/components/editor/first-person/ground-holes.test.ts +128 -0
  14. package/src/components/editor/first-person/ground-holes.ts +130 -0
  15. package/src/components/editor/first-person/mechanism-targets.test.ts +89 -2
  16. package/src/components/editor/first-person/mechanism-targets.ts +52 -0
  17. package/src/components/editor/first-person-controls.tsx +19 -11
  18. package/src/components/editor/floor-aware-camera-controls.test.ts +24 -0
  19. package/src/components/editor/floorplan-mode-coordinator.tsx +2 -2
  20. package/src/components/editor/floorplan-panel.tsx +72 -23
  21. package/src/components/editor/follow-pascal.test.tsx +912 -0
  22. package/src/components/editor/follow-pascal.tsx +354 -0
  23. package/src/components/editor/group-actions.ts +2 -0
  24. package/src/components/editor/group-move-3d.ts +2 -0
  25. package/src/components/editor/group-rotate-handle.tsx +2 -0
  26. package/src/components/editor/group-transform-shared.test.ts +460 -0
  27. package/src/components/editor/index.tsx +166 -79
  28. package/src/components/editor/riser-diagram-panel.tsx +14 -7
  29. package/src/components/editor/room-highlight.tsx +13 -5
  30. package/src/components/editor/room-labels.test.ts +621 -128
  31. package/src/components/editor/room-labels.tsx +1090 -190
  32. package/src/components/editor/selection-manager.tsx +38 -117
  33. package/src/components/editor/slab-hole-highlights.tsx +2 -2
  34. package/src/components/editor/snapshot-capture-overlay.tsx +3 -2
  35. package/src/components/editor/snapshot-capture.test.ts +69 -58
  36. package/src/components/editor/snapshot-capture.ts +7 -1
  37. package/src/components/editor/thumbnail-generator.test.ts +350 -4
  38. package/src/components/editor/thumbnail-generator.tsx +128 -54
  39. package/src/components/editor/use-floorplan-background-placement.ts +3 -1
  40. package/src/components/editor/views/plugins-view.tsx +1 -8
  41. package/src/components/editor/views/view-bar.test.tsx +54 -0
  42. package/src/components/editor/views/view-bar.tsx +140 -17
  43. package/src/components/editor/wall-snap-beacon-layer.tsx +5 -4
  44. package/src/components/editor-2d/floorplan-group-move.tsx +3 -0
  45. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +2 -2
  46. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +3 -4
  47. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.test.ts +1 -1
  48. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +29 -7
  49. package/src/components/editor-2d/renderers/floorplan-reveal.test.tsx +254 -0
  50. package/src/components/editor-2d/renderers/floorplan-reveal.ts +141 -0
  51. package/src/components/point-ask/bubble.tsx +538 -0
  52. package/src/components/point-ask/hover-chip.tsx +91 -0
  53. package/src/components/point-ask/mode-pill.tsx +92 -0
  54. package/src/components/point-ask/optional-motion.ts +22 -0
  55. package/src/components/point-ask/outline-corners.tsx +93 -0
  56. package/src/components/point-ask/pins.tsx +711 -0
  57. package/src/components/point-ask/point-ask-layer.tsx +83 -0
  58. package/src/components/point-ask/point-ask-styles.ts +105 -0
  59. package/src/components/point-ask/region-marquee-styles.ts +15 -0
  60. package/src/components/point-ask/region-marquee.tsx +64 -0
  61. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +12 -2
  62. package/src/components/systems/roof/roof-edit-system.tsx +1 -0
  63. package/src/components/tools/distribution/connection-compatibility.test.ts +34 -0
  64. package/src/components/tools/distribution/connection-compatibility.ts +43 -0
  65. package/src/components/tools/distribution/distribution-public.ts +9 -0
  66. package/src/components/tools/distribution/distribution-run-contract.test.ts +26 -0
  67. package/src/components/tools/distribution/distribution-run-contract.ts +94 -0
  68. package/src/components/tools/distribution/distribution-run-tool.test.ts +241 -0
  69. package/src/components/tools/distribution/distribution-run-tool.test.tsx +125 -0
  70. package/src/components/tools/distribution/distribution-run-tool.tsx +1323 -0
  71. package/src/components/tools/distribution/draw-alignment.ts +35 -0
  72. package/src/components/tools/distribution/ports.test.ts +56 -0
  73. package/src/components/tools/distribution/ports.ts +378 -0
  74. package/src/components/tools/distribution/run-cursor.test.ts +135 -0
  75. package/src/components/tools/distribution/run-cursor.ts +36 -0
  76. package/src/components/tools/distribution/run-direction-feedback.test.ts +60 -0
  77. package/src/components/tools/distribution/run-direction-feedback.tsx +251 -0
  78. package/src/components/tools/distribution/run-port-snap.test.ts +79 -0
  79. package/src/components/tools/distribution/run-port-snap.ts +25 -0
  80. package/src/components/tools/distribution/run-screen-direction.test.ts +23 -0
  81. package/src/components/tools/distribution/run-screen-direction.ts +30 -0
  82. package/src/components/tools/elevator/elevator-tool.tsx +3 -6
  83. package/src/components/tools/elevator/move-elevator-tool.tsx +2 -3
  84. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +67 -68
  85. package/src/components/tools/item/placement-history.test.tsx +38 -0
  86. package/src/components/tools/item/placement-strategies.test.ts +46 -1
  87. package/src/components/tools/item/placement-strategies.ts +3 -6
  88. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +56 -56
  89. package/src/components/tools/item/use-draft-node.test.tsx +94 -94
  90. package/src/components/tools/item/use-placement-coordinator.tsx +55 -49
  91. package/src/components/tools/registry/item-surface-move.test.ts +62 -63
  92. package/src/components/tools/registry/item-surface-move.ts +2 -0
  93. package/src/components/tools/registry/item-surface-resolver.test.ts +26 -26
  94. package/src/components/tools/registry/move-registry-node-tool.test.ts +268 -1
  95. package/src/components/tools/registry/move-registry-node-tool.tsx +63 -29
  96. package/src/components/tools/tool-manager.tsx +14 -5
  97. package/src/components/tools/wall/wall-drafting.test.ts +170 -57
  98. package/src/components/ui/action-menu/action-button.tsx +6 -1
  99. package/src/components/ui/action-menu/camera-actions.tsx +7 -3
  100. package/src/components/ui/action-menu/control-modes.tsx +13 -7
  101. package/src/components/ui/action-menu/follow-pascal-pill.tsx +32 -0
  102. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +36 -0
  103. package/src/components/ui/action-menu/index.tsx +56 -33
  104. package/src/components/ui/action-menu/measurement-control.tsx +11 -9
  105. package/src/components/ui/action-menu/placement.ts +20 -0
  106. package/src/components/ui/action-menu/point-ask-button.tsx +52 -0
  107. package/src/components/ui/action-menu/view-toggles.tsx +47 -42
  108. package/src/components/ui/build-panel/build-panel.tsx +10 -3
  109. package/src/components/ui/command-palette/editor-commands.tsx +3 -12
  110. package/src/components/ui/command-palette/index.tsx +45 -15
  111. package/src/components/ui/command-palette/ranked-groups.test.ts +31 -0
  112. package/src/components/ui/controls/action-button.tsx +5 -2
  113. package/src/components/ui/controls/metric-control.tsx +1 -1
  114. package/src/components/ui/controls/segmented-control.tsx +21 -8
  115. package/src/components/ui/controls/select-control.tsx +1 -1
  116. package/src/components/ui/controls/slider-control.tsx +1 -1
  117. package/src/components/ui/controls/toggle-control.tsx +3 -3
  118. package/src/components/ui/controls/tool-options-panel.tsx +1 -1
  119. package/src/components/ui/floating-level-selector.tsx +150 -45
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +32 -7
  121. package/src/components/ui/helpers/helper-manager.test.tsx +54 -2
  122. package/src/components/ui/helpers/helper-manager.tsx +7 -1
  123. package/src/components/ui/helpers/item-helper.tsx +9 -1
  124. package/src/components/ui/panels/collections/collections-popover.tsx +8 -8
  125. package/src/components/ui/panels/mobile-panel-sheet.tsx +2 -2
  126. package/src/components/ui/panels/mobile-selection-bar.tsx +6 -3
  127. package/src/components/ui/panels/multi-selection-panel.tsx +120 -3
  128. package/src/components/ui/panels/panel-wrapper.tsx +53 -27
  129. package/src/components/ui/panels/parametric-field-control.tsx +2 -2
  130. package/src/components/ui/panels/parametric-inspector.tsx +3 -3
  131. package/src/components/ui/panels/room-panel.tsx +65 -0
  132. package/src/components/ui/primitives/badge.tsx +8 -0
  133. package/src/components/ui/primitives/sidebar.tsx +1 -1
  134. package/src/components/ui/right-stack.tsx +21 -2
  135. package/src/components/ui/sidebar/app-sidebar.tsx +1 -1
  136. package/src/components/ui/sidebar/icon-rail.tsx +3 -3
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
  138. package/src/components/ui/sidebar/panels/items-panel/index.tsx +6 -6
  139. package/src/components/ui/sidebar/panels/settings-panel/animation-speed-setting.tsx +40 -0
  140. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +7 -8
  141. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.test.tsx +170 -0
  142. package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +92 -0
  143. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +107 -23
  144. package/src/components/ui/sidebar/panels/settings-panel/interface-theme.test.tsx +20 -0
  145. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +4 -10
  146. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +3 -11
  147. package/src/components/ui/sidebar/panels/site-panel/index.tsx +5 -12
  148. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +13 -10
  149. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +2 -2
  150. package/src/components/ui/sidebar/tab-bar.tsx +4 -4
  151. package/src/components/viewer/viewer-controls-bar.tsx +18 -6
  152. package/src/components/viewer/viewer-rooms.tsx +16 -0
  153. package/src/hooks/use-auto-save.integration.test.tsx +96 -38
  154. package/src/hooks/use-auto-save.ts +26 -1
  155. package/src/hooks/use-ceiling-events.test.ts +123 -118
  156. package/src/hooks/use-grid-events.test.ts +105 -105
  157. package/src/hooks/use-keyboard.test.ts +65 -16
  158. package/src/hooks/use-keyboard.ts +29 -12
  159. package/src/hooks/use-placement-notice.test.ts +24 -1
  160. package/src/hooks/use-placement-notice.ts +37 -6
  161. package/src/index.tsx +71 -24
  162. package/src/lib/__fixtures__/owner-victor-seattle-parking.json +1 -0
  163. package/src/lib/__fixtures__/owner-villa-aurelia-basement.json +1 -0
  164. package/src/lib/add-storey.test.ts +177 -0
  165. package/src/lib/add-storey.ts +59 -0
  166. package/src/lib/below-grade.test.tsx +171 -0
  167. package/src/lib/below-grade.ts +133 -0
  168. package/src/lib/camera-facing.test.ts +24 -0
  169. package/src/lib/camera-facing.ts +24 -0
  170. package/src/lib/direct-manipulation.ts +2 -0
  171. package/src/lib/download-project-file.ts +17 -0
  172. package/src/lib/editor-level-display.ts +9 -2
  173. package/src/lib/editor-views.ts +35 -1
  174. package/src/lib/export-texture-utils.ts +2 -4
  175. package/src/lib/floor-foundation-support.test.ts +1 -0
  176. package/src/lib/floorplan/floorplan-pdfkit-document.ts +31 -16
  177. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +39 -0
  178. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +4 -0
  179. package/src/lib/floorplan/pdfkit-standard-fonts.d.ts +5 -0
  180. package/src/lib/floorplan/sheet-export.test.ts +24 -0
  181. package/src/lib/floorplan/site-plan/build-site-plan-drawing.ts +34 -45
  182. package/src/lib/floorplan/site-plan/coverage.ts +2 -4
  183. package/src/lib/floorplan/site-plan/front-edge.ts +2 -1
  184. package/src/lib/floorplan/site-plan/geometry.test.ts +9 -15
  185. package/src/lib/floorplan/site-plan/geometry.ts +1 -416
  186. package/src/lib/floorplan/site-plan/index.ts +0 -16
  187. package/src/lib/floorplan/site-plan/site-annotations.test.ts +117 -0
  188. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -2
  189. package/src/lib/floorplan/site-plan/site-parts.ts +14 -106
  190. package/src/lib/floorplan/site-plan/yards.test.ts +2 -1
  191. package/src/lib/follow-pascal-exterior.test-fixture.ts +17 -0
  192. package/src/lib/follow-pascal-exterior.test.ts +171 -0
  193. package/src/lib/follow-pascal-exterior.ts +39 -0
  194. package/src/lib/follow-pascal-presentation.test.ts +203 -0
  195. package/src/lib/follow-pascal-presentation.ts +129 -0
  196. package/src/lib/follow-pascal-sight.ts +84 -0
  197. package/src/lib/follow-pascal-targets.test.ts +406 -0
  198. package/src/lib/follow-pascal-targets.ts +198 -0
  199. package/src/lib/follow-pascal.test.ts +350 -0
  200. package/src/lib/follow-pascal.ts +284 -0
  201. package/src/lib/fresh-planar-placement.test.ts +112 -0
  202. package/src/lib/fresh-planar-placement.ts +57 -3
  203. package/src/lib/geometry-script/author.ts +6 -1
  204. package/src/lib/geometry-script/client.ts +8 -1
  205. package/src/lib/geometry-script/finish-scripts.ts +127 -0
  206. package/src/lib/geometry-script/protocol.ts +1 -0
  207. package/src/lib/glb-export.test.ts +187 -162
  208. package/src/lib/glb-export.ts +4 -1
  209. package/src/lib/history.test.ts +12 -7
  210. package/src/lib/interaction/scope.ts +4 -0
  211. package/src/lib/level-selection.test.ts +57 -0
  212. package/src/lib/level-selection.ts +16 -1
  213. package/src/lib/lot/clean-ring.test.ts +2 -1
  214. package/src/lib/lot/drop-in.ts +9 -24
  215. package/src/lib/lot/lot-patch.test.ts +1 -1
  216. package/src/lib/lot/lot-patch.ts +2 -2
  217. package/src/lib/measurement-kind.test.ts +6 -8
  218. package/src/lib/measurements.test.ts +23 -0
  219. package/src/lib/orbit-floor-clearance.test.ts +36 -0
  220. package/src/lib/orbit-floor-clearance.ts +48 -0
  221. package/src/lib/paint-pass-through.test.ts +11 -0
  222. package/src/lib/paint-pass-through.ts +4 -2
  223. package/src/lib/paint-scope.ts +1 -0
  224. package/src/lib/parametric-node-update.ts +16 -7
  225. package/src/lib/plugin-chat.ts +15 -0
  226. package/src/lib/point-ask/__fixtures__/consumer-browser.tsx +76 -0
  227. package/src/lib/point-ask/__fixtures__/house.ts +81 -0
  228. package/src/lib/point-ask/ask-created.test.ts +195 -0
  229. package/src/lib/point-ask/ask-flow.test.ts +380 -0
  230. package/src/lib/point-ask/build-context.test.ts +173 -0
  231. package/src/lib/point-ask/build-context.ts +94 -0
  232. package/src/lib/point-ask/capture-crop.ts +129 -0
  233. package/src/lib/point-ask/choreography.ts +69 -0
  234. package/src/lib/point-ask/compare.test.ts +145 -0
  235. package/src/lib/point-ask/compare.ts +73 -0
  236. package/src/lib/point-ask/describe-target.test.ts +165 -0
  237. package/src/lib/point-ask/describe-target.ts +234 -0
  238. package/src/lib/point-ask/dictation.ts +79 -0
  239. package/src/lib/point-ask/edge-marker.test.ts +61 -0
  240. package/src/lib/point-ask/edge-marker.ts +27 -0
  241. package/src/lib/point-ask/frame-loop.ts +25 -0
  242. package/src/lib/point-ask/history-marks.test.ts +94 -0
  243. package/src/lib/point-ask/history-marks.ts +39 -0
  244. package/src/lib/point-ask/host-api.ts +32 -0
  245. package/src/lib/point-ask/outline-box.test.ts +30 -0
  246. package/src/lib/point-ask/outline-box.ts +11 -0
  247. package/src/lib/point-ask/overlay-metrics.ts +6 -0
  248. package/src/lib/point-ask/pick.test.ts +220 -0
  249. package/src/lib/point-ask/pick.ts +121 -0
  250. package/src/lib/point-ask/pin-machine.test.ts +494 -0
  251. package/src/lib/point-ask/pin-machine.ts +217 -0
  252. package/src/lib/point-ask/placement.test.ts +203 -0
  253. package/src/lib/point-ask/placement.ts +141 -0
  254. package/src/lib/point-ask/pointer-tracker.ts +34 -0
  255. package/src/lib/point-ask/projector.ts +83 -0
  256. package/src/lib/point-ask/region.test.ts +157 -0
  257. package/src/lib/point-ask/region.ts +136 -0
  258. package/src/lib/point-ask/retry.test.ts +217 -0
  259. package/src/lib/point-ask/retry.ts +37 -0
  260. package/src/lib/point-ask/scene-commit.test.ts +312 -0
  261. package/src/lib/point-ask/scene-commit.ts +32 -0
  262. package/src/lib/point-ask/session.ts +1391 -0
  263. package/src/lib/point-ask/spring-keyframes.test.ts +45 -0
  264. package/src/lib/point-ask/spring-keyframes.ts +38 -0
  265. package/src/lib/point-ask/spring.test.ts +63 -0
  266. package/src/lib/point-ask/spring.ts +107 -0
  267. package/src/lib/point-ask/suggestions.test.ts +80 -0
  268. package/src/lib/point-ask/suggestions.ts +125 -0
  269. package/src/lib/point-ask/target-scope.test.ts +622 -0
  270. package/src/lib/point-ask/target-scope.ts +338 -0
  271. package/src/lib/point-ask/types.ts +48 -0
  272. package/src/lib/portable-export.test.ts +34 -1
  273. package/src/lib/portable-export.ts +119 -53
  274. package/src/lib/print-feature-thickness.ts +10 -1
  275. package/src/lib/print-roof-solids.test.ts +39 -1
  276. package/src/lib/print-roof-solids.ts +43 -10
  277. package/src/lib/print-shell-compiler-manifold-core.ts +1 -1
  278. package/src/lib/room-assembly-overlay.ts +2 -3
  279. package/src/lib/room-selection.test.ts +45 -1
  280. package/src/lib/scene-clipboard.test.ts +246 -1
  281. package/src/lib/scene-clipboard.ts +44 -14
  282. package/src/lib/selection-routing.ts +1 -1
  283. package/src/lib/sfx-bus.ts +13 -0
  284. package/src/lib/sfx-player.test.ts +70 -9
  285. package/src/lib/sfx-player.ts +6 -0
  286. package/src/lib/stair-levels.test.ts +25 -0
  287. package/src/lib/stair-levels.ts +22 -3
  288. package/src/lib/terrace-draft.ts +2 -1
  289. package/src/lib/viewer-selection.ts +22 -11
  290. package/src/lib/wall-placement-pointer.test.ts +42 -0
  291. package/src/lib/wall-placement-pointer.ts +57 -0
  292. package/src/lib/window-changes.ts +35 -0
  293. package/src/store/use-audio.test.ts +146 -0
  294. package/src/store/use-audio.tsx +127 -3
  295. package/src/store/use-construction-reveal.test.ts +173 -0
  296. package/src/store/use-construction-reveal.ts +145 -0
  297. package/src/store/use-corner-snap-hint.test.ts +24 -0
  298. package/src/store/use-corner-snap-hint.ts +47 -0
  299. package/src/store/use-follow-pascal.test.ts +62 -0
  300. package/src/store/use-follow-pascal.ts +53 -0
  301. package/src/store/use-point-ask.ts +247 -0
  302. package/src/store/use-stair-build-preview.ts +25 -5
  303. package/src/store/view-layout.test.ts +27 -1
  304. package/src/store/view-layout.ts +4 -1
  305. package/src/test-utils/render-current-markup.tsx +16 -0
  306. package/src/components/tools/stair/stair-click-guard.test.ts +0 -58
  307. package/src/components/tools/stair/stair-click-guard.ts +0 -72
  308. package/src/components/tools/stair/stair-defaults.ts +0 -15
  309. package/src/components/tools/stair/stair-tool.tsx +0 -682
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test'
2
2
  import { renderToStaticMarkup } from 'react-dom/server'
3
3
  import { SELECT_HUD_TITLE, toolHudTitle } from '../../../lib/hud-title'
4
4
  import { snapContextOf } from '../../../lib/snapping-mode'
5
+ import { cornerHintRows } from '../../../store/use-corner-snap-hint'
5
6
  import { TooltipProvider } from '../primitives/tooltip'
6
7
  import { ContextualHelperPanel } from './contextual-helper-panel'
7
8
  import { ROOM_DIVIDE_HINTS } from './helper-manager'
@@ -18,7 +19,7 @@ describe('Divide HUD', () => {
18
19
  ).toBe('wall')
19
20
  })
20
21
 
21
- test('reads like the wall draft: snapping chips and the path keys', () => {
22
+ test('shows snapping controls and the primary gesture with expandable secondary shortcuts', () => {
22
23
  // Server rendering reads each store's initial state: the wall context starts
23
24
  // on Grid with a 0.5 m step.
24
25
  const html = renderToStaticMarkup(
@@ -28,8 +29,11 @@ describe('Divide HUD', () => {
28
29
  )
29
30
  expect(html).toContain('Snapping: Grid')
30
31
  expect(html).toContain('Grid: 0.50 m')
31
- for (const label of ['Add point', 'Remove last point', 'Finish at the edge', 'Cancel'])
32
+ for (const label of ['Add point', 'More shortcuts', 'Cancel'])
32
33
  expect(html).toContain(label)
34
+ expect(html).toContain('aria-expanded="false"')
35
+ for (const label of ['Remove last point', 'Finish at the edge'])
36
+ expect(html).not.toContain(label)
33
37
  expect(ROOM_DIVIDE_HINTS.map((hint) => hint.keys)).toEqual([
34
38
  ['Left click'],
35
39
  ['Backspace'],
@@ -37,6 +41,54 @@ describe('Divide HUD', () => {
37
41
  ['Esc'],
38
42
  ])
39
43
  })
44
+
45
+ test('keeps active secondary gestures visible while shortcuts are collapsed', () => {
46
+ const html = renderToStaticMarkup(
47
+ <TooltipProvider>
48
+ <ContextualHelperPanel
49
+ hints={ROOM_DIVIDE_HINTS.map((hint) => ({
50
+ ...hint,
51
+ active: hint.keys.includes('Enter'),
52
+ }))}
53
+ snapContext="wall"
54
+ />
55
+ </TooltipProvider>,
56
+ )
57
+ for (const label of ['Add point', 'Finish at the edge', 'Cancel'])
58
+ expect(html).toContain(label)
59
+ expect(html).not.toContain('Remove last point')
60
+ expect(html).toContain('aria-expanded="false"')
61
+ })
62
+
63
+ test('keeps a note row visible while shortcuts are collapsed', () => {
64
+ const html = renderToStaticMarkup(
65
+ <TooltipProvider>
66
+ <ContextualHelperPanel
67
+ hints={[
68
+ { keys: ['Left click'], label: 'Place' },
69
+ ...cornerHintRows('free_end'),
70
+ { keys: ['R', 'T'], label: 'Rotate' },
71
+ { keys: ['Esc'], label: 'Cancel' },
72
+ ]}
73
+ />
74
+ </TooltipProvider>,
75
+ )
76
+ for (const label of ['Place', 'No wall meets here', 'More shortcuts', 'Cancel'])
77
+ expect(html).toContain(label)
78
+ expect(html).not.toContain('Rotate')
79
+ })
80
+
81
+ test('offers More shortcuts only when collapsing hides a row', () => {
82
+ const html = renderToStaticMarkup(
83
+ <TooltipProvider>
84
+ <ContextualHelperPanel
85
+ hints={[{ keys: ['Left click'], label: 'Place' }, ...cornerHintRows('free_end')]}
86
+ />
87
+ </TooltipProvider>,
88
+ )
89
+ expect(html).toContain('No wall meets here')
90
+ expect(html).not.toContain('More shortcuts')
91
+ })
40
92
  })
41
93
 
42
94
  describe('HUD header', () => {
@@ -66,6 +66,7 @@ import {
66
66
  usePaintRegionMode,
67
67
  } from '../../../lib/paint-region-mode'
68
68
  import { snapContextOf } from '../../../lib/snapping-mode'
69
+ import { cornerHintRows, useCornerSnapHint } from '../../../store/use-corner-snap-hint'
69
70
  import useEditor, { getActiveContinuationContext } from '../../../store/use-editor'
70
71
  import useInteractionScope, {
71
72
  useActiveHandleDrag,
@@ -309,10 +310,11 @@ export function HelperManager() {
309
310
  const openingShape = useOpeningDraft((s) => (s.host ? s.shape : null))
310
311
  const terraceShape = useTerraceDraft((s) => (s.host ? s.shape : null))
311
312
  const movingNode = useMovingNode()
313
+ const cornerHint = useCornerSnapHint((s) => s.hint)
312
314
  // A tool whose kind gives placement notices: its transient draft is the node in hand.
313
315
  const toolNotices = !!(tool && nodeRegistry.get(tool)?.placementNotice)
314
316
  const toolDraftId = useScene((s) => (toolNotices ? transientDraftId(s.nodes) : null))
315
- const placementNotice = usePlacementNotice(movingNode?.id ?? toolDraftId)
317
+ const placementNotice = usePlacementNotice(movingNode?.id ?? toolDraftId, toolNotices)
316
318
  const reshapingNode = useReshapingNode()
317
319
  const activeHandleDrag = useActiveHandleDrag()
318
320
  const mezzanineHandle = useScene((s) => mezzanineGesture(activeHandleDrag, s.nodes))
@@ -560,6 +562,10 @@ export function HelperManager() {
560
562
  continuationContext={movingContinuationContext}
561
563
  notice={placementNotice?.line}
562
564
  showEsc
565
+ extraHints={movingNode.type === 'window' ? cornerHintRows(cornerHint) : []}
566
+ replacesPlace={
567
+ movingNode.type === 'window' && (cornerHint === 'join' || cornerHint === 'new')
568
+ }
563
569
  showForce={collisionValidatesDrop}
564
570
  snapContext={snapContext}
565
571
  title={movingTitle}
@@ -15,6 +15,10 @@ interface ItemHelperProps {
15
15
  title?: HudTitle | null
16
16
  // Why the item would not fit where it is (a door's way, a room too small): a warning, not a block.
17
17
  notice?: string | null
18
+ // Rows a kind adds while it is in hand (a window near a corner says what dropping it does).
19
+ extraHints?: { keys: string[]; label: string }[]
20
+ // The extra rows say what a click does, so the plain Place row is left out.
21
+ replacesPlace?: boolean
18
22
  }
19
23
 
20
24
  // Snapping mode is the chip on the right (Shift cycles it), so it's not repeated
@@ -26,13 +30,17 @@ export function ItemHelper({
26
30
  continuationContext = null,
27
31
  title = null,
28
32
  notice = null,
33
+ extraHints = [],
34
+ replacesPlace = false,
29
35
  }: ItemHelperProps) {
30
36
  return (
31
37
  <ContextualHelperPanel
32
38
  continuationContext={continuationContext}
33
39
  notice={notice}
34
40
  hints={[
35
- { keys: ['Left click'], label: 'Place' },
41
+ // Where a click does something else (joining a corner), that row says so instead.
42
+ ...(replacesPlace ? [] : [{ keys: ['Left click'], label: 'Place' }]),
43
+ ...extraHints,
36
44
  { keys: ['R', 'T'], label: 'Rotate' },
37
45
  ...(showForce ? [{ keys: ['Alt'], label: 'Force place' }] : []),
38
46
  { keys: [showEsc ? 'Esc' : 'Right click'], label: 'Cancel' },
@@ -106,7 +106,7 @@ export function CollectionsPopover({ nodeIds, children }: CollectionsPopoverProp
106
106
  </span>
107
107
  </div>
108
108
  <button
109
- className="flex items-center gap-1 rounded-md px-2 py-1 font-medium text-[11px] text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground"
109
+ className="flex items-center gap-1 rounded-md px-2 py-1 font-medium text-[11px] text-muted-foreground transition-colors hover:bg-black/6 dark:hover:bg-white/10 hover:text-foreground"
110
110
  onClick={() => {
111
111
  setShowCreateInput((v) => !v)
112
112
  setCreateName('')
@@ -120,7 +120,7 @@ export function CollectionsPopover({ nodeIds, children }: CollectionsPopoverProp
120
120
 
121
121
  {/* Create input */}
122
122
  {showCreateInput && (
123
- <div className="flex items-center gap-1.5 border-border/50 border-b bg-white/5 px-3 py-2">
123
+ <div className="flex items-center gap-1.5 border-border/50 border-b bg-black/4 dark:bg-white/5 px-3 py-2">
124
124
  <input
125
125
  autoFocus
126
126
  className="min-w-0 flex-1 rounded-md border border-border/50 bg-background/50 px-2 py-1 text-foreground text-xs outline-none placeholder:text-muted-foreground/60 focus:border-ring focus:ring-1 focus:ring-ring/30"
@@ -144,7 +144,7 @@ export function CollectionsPopover({ nodeIds, children }: CollectionsPopoverProp
144
144
  <Check className="h-3.5 w-3.5" />
145
145
  </button>
146
146
  <button
147
- className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-white/10"
147
+ className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-black/6 dark:hover:bg-white/10"
148
148
  onClick={() => {
149
149
  setShowCreateInput(false)
150
150
  setCreateName('')
@@ -186,7 +186,7 @@ export function CollectionsPopover({ nodeIds, children }: CollectionsPopoverProp
186
186
  </span>
187
187
  <div className="flex shrink-0 items-center gap-1">
188
188
  <button
189
- className="rounded-md bg-red-500/20 px-2 py-0.5 font-medium text-[11px] text-red-400 transition-colors hover:bg-red-500/30"
189
+ className="rounded-md bg-red-500/20 px-2 py-0.5 font-medium text-[11px] text-red-600 dark:text-red-400 transition-colors hover:bg-red-500/30"
190
190
  onClick={() => {
191
191
  deleteCollection(collection.id)
192
192
  setDeletingId(null)
@@ -196,7 +196,7 @@ export function CollectionsPopover({ nodeIds, children }: CollectionsPopoverProp
196
196
  Delete
197
197
  </button>
198
198
  <button
199
- className="rounded-md px-2 py-0.5 font-medium text-[11px] text-muted-foreground transition-colors hover:bg-white/10"
199
+ className="rounded-md px-2 py-0.5 font-medium text-[11px] text-muted-foreground transition-colors hover:bg-black/6 dark:hover:bg-white/10"
200
200
  onClick={() => setDeletingId(null)}
201
201
  type="button"
202
202
  >
@@ -229,7 +229,7 @@ export function CollectionsPopover({ nodeIds, children }: CollectionsPopoverProp
229
229
  <Check className="h-3.5 w-3.5" />
230
230
  </button>
231
231
  <button
232
- className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-white/10"
232
+ className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-black/6 dark:hover:bg-white/10"
233
233
  onClick={() => setRenamingId(null)}
234
234
  type="button"
235
235
  >
@@ -241,7 +241,7 @@ export function CollectionsPopover({ nodeIds, children }: CollectionsPopoverProp
241
241
 
242
242
  return (
243
243
  <li key={collection.id}>
244
- <div className="group flex items-center gap-2 px-3 py-2 transition-colors hover:bg-white/5">
244
+ <div className="group flex items-center gap-2 px-3 py-2 transition-colors hover:bg-black/4 dark:hover:bg-white/5">
245
245
  {/* Color dot — click to pick color */}
246
246
  <ColorDot
247
247
  color={collection.color ?? '#6366f1'}
@@ -299,7 +299,7 @@ export function CollectionsPopover({ nodeIds, children }: CollectionsPopoverProp
299
299
  <DropdownMenu modal={false}>
300
300
  <DropdownMenuTrigger asChild>
301
301
  <button
302
- className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground opacity-0 transition-colors hover:bg-white/10 hover:text-foreground group-hover:opacity-100"
302
+ className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground opacity-0 transition-colors hover:bg-black/6 dark:hover:bg-white/10 hover:text-foreground group-hover:opacity-100"
303
303
  type="button"
304
304
  >
305
305
  <MoreHorizontal className="h-3.5 w-3.5" />
@@ -56,7 +56,7 @@ export function MobilePanelSheet({ open, onClose, icon, title, children }: Mobil
56
56
  {open && (
57
57
  <motion.div
58
58
  animate={{ y: 0 }}
59
- className="dark fixed right-0 bottom-0 left-0 z-[60] flex flex-col overflow-hidden rounded-t-2xl bg-sidebar text-sidebar-foreground shadow-[0_-8px_24px_rgba(0,0,0,0.24)]"
59
+ className="fixed right-0 bottom-0 left-0 z-[60] flex flex-col overflow-hidden rounded-t-2xl bg-sidebar text-sidebar-foreground shadow-[0_-8px_24px_rgba(0,0,0,0.24)]"
60
60
  drag="y"
61
61
  dragConstraints={{ top: 0, bottom: 0 }}
62
62
  dragElastic={{ top: 0, bottom: 0.4 }}
@@ -89,7 +89,7 @@ export function MobilePanelSheet({ open, onClose, icon, title, children }: Mobil
89
89
  </div>
90
90
  <button
91
91
  aria-label="Close"
92
- className="flex h-8 w-8 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground"
92
+ className="flex h-8 w-8 items-center justify-center rounded-md bg-black/4 dark:bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-black/8 dark:hover:bg-[#3e3e3e] hover:text-foreground"
93
93
  onClick={onClose}
94
94
  type="button"
95
95
  >
@@ -18,7 +18,7 @@ interface MobileSelectionBarProps {
18
18
  }
19
19
 
20
20
  const ACTION_BTN =
21
- 'flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-white/8 hover:text-foreground'
21
+ 'flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/8 hover:text-foreground'
22
22
 
23
23
  export function MobileSelectionBar({
24
24
  node,
@@ -40,7 +40,7 @@ export function MobileSelectionBar({
40
40
  <button
41
41
  aria-label={`Edit ${resolvedLabel}`}
42
42
  className={cn(
43
- 'flex min-w-0 flex-1 items-center gap-2 rounded-lg px-2 text-left transition-colors hover:bg-white/8',
43
+ 'flex min-w-0 flex-1 items-center gap-2 rounded-lg px-2 text-left transition-colors hover:bg-black/5 dark:hover:bg-white/8',
44
44
  )}
45
45
  onClick={onEdit}
46
46
  type="button"
@@ -80,7 +80,10 @@ export function MobileSelectionBar({
80
80
  </button>
81
81
  <button
82
82
  aria-label="Delete"
83
- className={cn(ACTION_BTN, 'hover:bg-red-500/15 hover:text-red-400')}
83
+ className={cn(
84
+ ACTION_BTN,
85
+ 'hover:bg-red-500/15 hover:text-red-600 dark:hover:bg-red-500/15 dark:hover:text-red-400',
86
+ )}
84
87
  onClick={(e) => {
85
88
  stop(e)
86
89
  onDelete()
@@ -1,15 +1,21 @@
1
1
  'use client'
2
2
 
3
3
  import {
4
+ type AnyNode,
4
5
  type AnyNodeId,
5
6
  type BuildingNode,
6
7
  resolveBuildingForLevel,
8
+ runAsSingleSceneHistoryStep,
7
9
  useScene,
8
10
  } from '@pascal-app/core'
11
+ import { unwrapCorner, windowMergeOffer } from '@pascal-app/core/building'
9
12
  import { useViewer } from '@pascal-app/viewer'
10
- import { Building2, Copy, Group, Trash2, Ungroup } from 'lucide-react'
13
+ import { Building2, Copy, Group, Merge, Split, Trash2, Ungroup } from 'lucide-react'
11
14
  import { useMemo } from 'react'
12
15
  import { deleteSelection, duplicateSelectionAndPickUp } from '../../editor/group-actions'
16
+ import { formatLinearMeasurement } from '../../../lib/measurements'
17
+ import { triggerSFX } from '../../../lib/sfx-bus'
18
+ import { writeWindowChanges } from '../../../lib/window-changes'
13
19
  import { collectSelectableCandidateIds } from '../../tools/select/select-candidates'
14
20
  import {
15
21
  canCreateSessionGroup,
@@ -43,6 +49,116 @@ function selectBuilding(buildingId: AnyNodeId) {
43
49
  useViewer.getState().setSelection({ buildingId: buildingId as BuildingNode['id'] })
44
50
  }
45
51
 
52
+ /**
53
+ * Why two selected windows will not merge, for the person who selected them: the refusal's own
54
+ * message is the agents' (node ids, metres), so the panel words its code, in the editor's units.
55
+ */
56
+ function mergeRefusalNote(
57
+ refusal: { code: string; details: Record<string, unknown> },
58
+ nodes: Record<string, AnyNode>,
59
+ length: (meters: number) => string,
60
+ ): string {
61
+ const { blockingId, limit } = refusal.details as { blockingId?: string; limit?: number }
62
+ const reach = length(limit ?? 0)
63
+ const blocking = nodes[blockingId ?? '']
64
+ const blockingKind = !blocking
65
+ ? 'something'
66
+ : blocking.type === 'item'
67
+ ? 'an item'
68
+ : `a ${blocking.type}`
69
+ switch (refusal.code) {
70
+ case 'window_already_joined':
71
+ return 'One of them already wraps another corner. Unwrap it first.'
72
+ case 'window_not_on_wall':
73
+ return 'A roof or dormer window cannot join another window.'
74
+ case 'windows_not_adjacent':
75
+ return `They are too far apart. Windows merge with at most ${reach} between them.`
76
+ case 'opening_between':
77
+ return `There is ${blockingKind} between them.`
78
+ case 'walls_not_meeting':
79
+ return 'Their walls do not meet at a corner.'
80
+ case 'corner_angle':
81
+ return 'Their walls meet at too sharp or too flat an angle for a corner window.'
82
+ case 'window_not_at_corner':
83
+ return `One of them is too far from the corner. A window joins one within ${reach} of it.`
84
+ case 'height_exceeds_wall':
85
+ return 'The window selected first is taller than the other wall. Select the other one first.'
86
+ case 'opening_overlap':
87
+ return `Sliding them to the corner would overlap ${blockingKind}.`
88
+ default:
89
+ return 'These windows cannot be merged.'
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Merge windows: with exactly two windows selected, one window across both, or one corner window
95
+ * when they sit on two walls that meet. Windows ride their wall, so they have no floating group
96
+ * menu, and this is where the wall merge's twin lives. Two that already wrap their corner get
97
+ * Unwrap instead; two that cannot join, the reason.
98
+ */
99
+ function MergeWindowsAction() {
100
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
101
+ const nodes = useScene((s) => s.nodes)
102
+ const readOnly = useScene((s) => s.readOnly)
103
+ const unit = useViewer((s) => s.unit)
104
+ const metricNotation = useViewer((s) => s.metricNotation)
105
+ const offer = useMemo(() => windowMergeOffer(nodes, selectedIds), [nodes, selectedIds])
106
+ if (!offer) return null
107
+ if (offer.refusal?.code === 'windows_already_joined')
108
+ return (
109
+ <ActionButton
110
+ className="basis-full whitespace-nowrap disabled:cursor-not-allowed disabled:opacity-40"
111
+ disabled={readOnly}
112
+ icon={<Split className="h-4 w-4" />}
113
+ label="Unwrap the corner"
114
+ onClick={() => {
115
+ writeWindowChanges({
116
+ create: null,
117
+ updates: unwrapCorner(useScene.getState().nodes, selectedIds[0]!),
118
+ remove: [],
119
+ })
120
+ triggerSFX('sfx:structure-build')
121
+ }}
122
+ title="Unwrap the corner: both windows stay, each framed at the corner"
123
+ />
124
+ )
125
+ const { merge } = offer
126
+ const note = offer.refusal
127
+ ? mergeRefusalNote(offer.refusal, nodes, (meters) =>
128
+ formatLinearMeasurement(meters, unit, metricNotation),
129
+ )
130
+ : null
131
+ return (
132
+ <>
133
+ <ActionButton
134
+ className="basis-full whitespace-nowrap disabled:cursor-not-allowed disabled:opacity-40"
135
+ disabled={readOnly || !merge}
136
+ icon={<Merge className="h-4 w-4" />}
137
+ label="Merge windows"
138
+ onClick={() => {
139
+ if (!merge) return
140
+ runAsSingleSceneHistoryStep(useScene, () =>
141
+ writeWindowChanges({
142
+ create: null,
143
+ updates: Object.fromEntries(merge.changes.update.map(({ id, data }) => [id, data])),
144
+ remove: merge.changes.delete,
145
+ }),
146
+ )
147
+ triggerSFX('sfx:structure-build')
148
+ useViewer.getState().setSelection({ selectedIds: merge.windowIds as AnyNodeId[] })
149
+ }}
150
+ title={
151
+ note ??
152
+ (merge?.kind === 'corner'
153
+ ? 'Merge windows into a corner window: both take the height, sill and type of the one you selected first'
154
+ : 'Merge windows into one across both: it keeps the type and style of the one you selected first')
155
+ }
156
+ />
157
+ {note ? <p className="basis-full px-1 text-muted-foreground text-xs">{note}</p> : null}
158
+ </>
159
+ )
160
+ }
161
+
46
162
  export function MultiSelectionActions() {
47
163
  const selectedIds = useViewer((s) => s.selection.selectedIds)
48
164
  const sessionGroups = useSessionGroups((s) => s.groups)
@@ -61,6 +177,7 @@ export function MultiSelectionActions() {
61
177
 
62
178
  return (
63
179
  <ActionGroup className="flex-wrap">
180
+ <MergeWindowsAction />
64
181
  {buildingId && (
65
182
  <ActionButton
66
183
  className="basis-full whitespace-nowrap"
@@ -95,8 +212,8 @@ export function MultiSelectionActions() {
95
212
  onClick={() => duplicateSelectionAndPickUp()}
96
213
  />
97
214
  <ActionButton
98
- className="min-w-20 px-2 border-red-500/40 text-red-200 hover:bg-red-500/15"
99
- icon={<Trash2 className="h-4 w-4 text-red-400" />}
215
+ className="min-w-20 px-2 border-red-500/40 text-red-700 dark:text-red-200 hover:bg-red-500/15"
216
+ icon={<Trash2 className="h-4 w-4 text-red-600 dark:text-red-400" />}
100
217
  label="Delete"
101
218
  onClick={() => deleteSelection()}
102
219
  />
@@ -138,7 +138,7 @@ export function PanelWrapper({
138
138
  ) as AnyNodeId | undefined
139
139
  // Subscribe to the selected node's *type* only — a string primitive that
140
140
  // doesn't change as fields are edited (same trick as ParametricInspector).
141
- const selectedType = useScene((s) => (selectedId ? (s.nodes[selectedId]?.type ?? null) : null))
141
+ const selectedType = useScene((s) => (selectedId ? s.nodes[selectedId]?.type ?? null : null))
142
142
  const installedPlugins = useScene((s) => s.installedPlugins)
143
143
  const extensions = useMemo(() => {
144
144
  // re-derive when plugin extensions register after mount (async plugin load)
@@ -155,7 +155,12 @@ export function PanelWrapper({
155
155
  // See `lib/inspector-card-mode.ts` for the transition table.
156
156
  const [activeExtensionId, setActiveExtensionId] = useState<string | null>(null)
157
157
  // Stale ids (kind changed, plugin gated off) fall back to regular mode.
158
- const activeExtension = resolveActiveExtension(activeExtensionId, extensions)
158
+ const primaryExtensionId = useScene((state) => {
159
+ const node = selectedId ? state.nodes[selectedId] : undefined
160
+ return node ? extensions.find((extension) => extension.primaryWhen?.(node))?.id : undefined
161
+ })
162
+ const primaryExtension = extensions.find((extension) => extension.id === primaryExtensionId)
163
+ const activeExtension = primaryExtension ?? resolveActiveExtension(activeExtensionId, extensions)
159
164
 
160
165
  // The panel is collapsed to just its header until the user expands it. The
161
166
  // choice is one editor preference for every inspector (room, wall, item…),
@@ -165,6 +170,10 @@ export function PanelWrapper({
165
170
  useInspectorExpanded.getState().setExpanded(!next)
166
171
  }, [])
167
172
 
173
+ useEffect(() => {
174
+ if (primaryExtension) setCollapsed(false)
175
+ }, [primaryExtension, selectedId, setCollapsed])
176
+
168
177
  const applyMode = useCallback(
169
178
  (next: { collapsed: boolean; activeExtensionId: string | null }) => {
170
179
  setCollapsed(next.collapsed)
@@ -176,8 +185,12 @@ export function PanelWrapper({
176
185
  // Chevron / header press — collapsed → regular, regular → collapsed,
177
186
  // extension mode → regular (exit the extension first, stay expanded).
178
187
  const handleCardToggle = useCallback(() => {
188
+ if (primaryExtension) {
189
+ setCollapsed(!collapsed)
190
+ return
191
+ }
179
192
  applyMode(toggleCard({ collapsed, activeExtensionId }))
180
- }, [applyMode, collapsed, activeExtensionId])
193
+ }, [applyMode, collapsed, activeExtensionId, primaryExtension, setCollapsed])
181
194
 
182
195
  // Folding the card forgets the active extension — extension mode is a
183
196
  // one-shot affordance of the header icon, not sticky panel state.
@@ -282,6 +295,7 @@ export function PanelWrapper({
282
295
 
283
296
  return (
284
297
  <div
298
+ data-editor-inspector
285
299
  className={cn(
286
300
  isMobile
287
301
  ? 'flex h-full w-full flex-col overflow-hidden bg-transparent dark:text-foreground'
@@ -327,14 +341,14 @@ export function PanelWrapper({
327
341
  <div className={cn('flex min-w-0 items-center gap-2', titleContent && 'flex-1 pr-2')}>
328
342
  {onBack && (
329
343
  <button
330
- className="mr-1 flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground"
344
+ className="mr-1 flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-black/8 dark:hover:bg-[#3e3e3e] hover:text-foreground"
331
345
  onClick={onBack}
332
346
  type="button"
333
347
  >
334
348
  <ChevronLeft className="h-4 w-4" />
335
349
  </button>
336
350
  )}
337
- {icon &&
351
+ {primaryExtension ? renderExtensionIcon(primaryExtension.icon) : icon &&
338
352
  (typeof icon === 'string' ? (
339
353
  <NodeIconImage
340
354
  alt=""
@@ -355,13 +369,13 @@ export function PanelWrapper({
355
369
  )}
356
370
  {titleContent ?? (
357
371
  <h2 className="truncate font-semibold text-foreground text-sm tracking-tight">
358
- {title}
372
+ {primaryExtension?.title ?? title}
359
373
  </h2>
360
374
  )}
361
375
  </div>
362
376
  ) : (
363
377
  <h2 className="truncate font-semibold text-foreground text-sm tracking-tight">
364
- {title}
378
+ {primaryExtension?.title ?? title}
365
379
  </h2>
366
380
  )}
367
381
  </div>
@@ -374,7 +388,7 @@ export function PanelWrapper({
374
388
  <div className="flex items-center gap-1">
375
389
  {onReset && (
376
390
  <button
377
- className="flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground"
391
+ className="flex h-7 w-7 items-center justify-center rounded-md bg-black/4 dark:bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-black/8 dark:hover:bg-[#3e3e3e] hover:text-foreground"
378
392
  onClick={onReset}
379
393
  type="button"
380
394
  >
@@ -386,7 +400,7 @@ export function PanelWrapper({
386
400
  ONLY that extension's content (either/or with the regular
387
401
  controls); the active icon (highlighted) or the chevron
388
402
  returns to the regular controls. */}
389
- {extensions.map((extension) => {
403
+ {extensions.filter((extension) => extension !== primaryExtension).map((extension) => {
390
404
  const isActive = !collapsed && activeExtensionId === extension.id
391
405
  return (
392
406
  <button
@@ -395,8 +409,8 @@ export function PanelWrapper({
395
409
  className={cn(
396
410
  'flex h-7 w-7 items-center justify-center rounded-md transition-colors',
397
411
  isActive
398
- ? 'bg-cyan-500/20 text-cyan-400 hover:bg-cyan-500/30'
399
- : 'bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground',
412
+ ? 'bg-cyan-500/20 text-cyan-700 dark:text-cyan-400 hover:bg-cyan-500/30'
413
+ : 'bg-black/4 dark:bg-[#2C2C2E] text-muted-foreground hover:bg-black/8 dark:hover:bg-[#3e3e3e] hover:text-foreground',
400
414
  )}
401
415
  key={extension.id}
402
416
  onClick={() =>
@@ -412,7 +426,7 @@ export function PanelWrapper({
412
426
  <button
413
427
  aria-expanded={!collapsed}
414
428
  aria-label={collapsed ? 'Expand panel' : 'Collapse panel'}
415
- className="flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground"
429
+ className="flex h-7 w-7 items-center justify-center rounded-md bg-black/4 dark:bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-black/8 dark:hover:bg-[#3e3e3e] hover:text-foreground"
416
430
  onClick={handleCardToggle}
417
431
  type="button"
418
432
  >
@@ -422,7 +436,7 @@ export function PanelWrapper({
422
436
  </button>
423
437
  {onClose && (
424
438
  <button
425
- className="flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground"
439
+ className="flex h-7 w-7 items-center justify-center rounded-md bg-black/4 dark:bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-black/8 dark:hover:bg-[#3e3e3e] hover:text-foreground"
426
440
  onClick={onClose}
427
441
  type="button"
428
442
  >
@@ -440,7 +454,9 @@ export function PanelWrapper({
440
454
  via `resolveActiveExtension`. */}
441
455
  {!(collapsed && !isMobile) && (
442
456
  <div className="no-scrollbar flex min-h-0 flex-1 flex-col overflow-y-auto" data-panel-scroll>
443
- {!isMobile && selectedId && activeExtension ? (
457
+ {selectedId && primaryExtension ? (
458
+ <InspectorExtensionContent extension={primaryExtension} nodeId={selectedId} />
459
+ ) : !isMobile && selectedId && activeExtension ? (
444
460
  <InspectorExtensionSection
445
461
  extension={activeExtension}
446
462
  key={activeExtension.id}
@@ -551,23 +567,33 @@ function InspectorExtensionSection({
551
567
  defaultExpanded?: boolean
552
568
  extension: InspectorExtension
553
569
  nodeId: AnyNodeId
570
+ }) {
571
+ return (
572
+ <PanelSection defaultExpanded={defaultExpanded} title={extension.title}>
573
+ <InspectorExtensionContent extension={extension} nodeId={nodeId} />
574
+ </PanelSection>
575
+ )
576
+ }
577
+
578
+ function InspectorExtensionContent({
579
+ extension,
580
+ nodeId,
581
+ }: {
582
+ extension: InspectorExtension
583
+ nodeId: AnyNodeId
554
584
  }) {
555
585
  const node = useScene((s) => s.nodes[nodeId])
556
586
  if (!node) return null
557
587
  const Extension = resolveExtensionComponent(extension)
558
588
  return (
559
- <PanelSection defaultExpanded={defaultExpanded} title={extension.title}>
560
- <ErrorBoundary
561
- fallback={
562
- <p className="p-1 text-muted-foreground text-xs">
563
- “{extension.title}” hit an error and was unloaded for this session.
564
- </p>
565
- }
566
- >
567
- <Suspense fallback={null}>
568
- <Extension node={node} />
569
- </Suspense>
570
- </ErrorBoundary>
571
- </PanelSection>
589
+ <ErrorBoundary fallback={
590
+ <p className="p-1 text-muted-foreground text-xs">
591
+ “{extension.title}” hit an error and was unloaded for this session.
592
+ </p>
593
+ }>
594
+ <Suspense fallback={null}>
595
+ <Extension node={node} />
596
+ </Suspense>
597
+ </ErrorBoundary>
572
598
  )
573
599
  }
@@ -82,7 +82,7 @@ export function ParametricFieldControl({
82
82
  <div className="flex items-center justify-between px-3 py-2">
83
83
  <span className="text-foreground/80 text-xs">{prettifyKey(key)}</span>
84
84
  <select
85
- className="rounded-md border border-border/50 bg-[#2C2C2E] px-2 py-1 text-foreground text-xs focus:outline-none focus:ring-1 focus:ring-foreground/30"
85
+ className="rounded-md border border-border/50 bg-black/4 dark:bg-[#2C2C2E] px-2 py-1 text-foreground text-xs focus:outline-none focus:ring-1 focus:ring-foreground/30"
86
86
  onChange={(e) => apply(e.target.value)}
87
87
  value={mixed ? '' : str}
88
88
  >
@@ -119,7 +119,7 @@ export function ParametricFieldControl({
119
119
  value={str}
120
120
  />
121
121
  <input
122
- className="w-20 rounded-md border border-border/50 bg-[#2C2C2E] px-2 py-1 text-foreground text-xs focus:outline-none focus:ring-1 focus:ring-foreground/30"
122
+ className="w-20 rounded-md border border-border/50 bg-black/4 dark:bg-[#2C2C2E] px-2 py-1 text-foreground text-xs focus:outline-none focus:ring-1 focus:ring-foreground/30"
123
123
  onChange={(e) => apply(e.target.value)}
124
124
  type="text"
125
125
  value={mixed ? 'Mixed' : str}
@@ -180,20 +180,20 @@ export function ParametricInspector({
180
180
  <>
181
181
  <ActionButton
182
182
  className="w-full flex-none"
183
- icon={<Trash2 className="h-4 w-4 text-red-400" />}
183
+ icon={<Trash2 className="h-4 w-4 text-red-600 dark:text-red-400" />}
184
184
  label="Delete"
185
185
  onClick={() => handleDelete(false)}
186
186
  />
187
187
  <ActionButton
188
188
  className="w-full flex-none"
189
- icon={<Trash2 className="h-4 w-4 text-red-400" />}
189
+ icon={<Trash2 className="h-4 w-4 text-red-600 dark:text-red-400" />}
190
190
  label="Delete with contents"
191
191
  onClick={() => handleDelete(true)}
192
192
  />
193
193
  </>
194
194
  ) : (
195
195
  <ActionButton
196
- className="border-red-500/40 text-red-200 hover:bg-red-500/15"
196
+ className="border-red-500/40 text-red-700 dark:text-red-200 hover:bg-red-500/15"
197
197
  icon={<Trash2 className="h-4 w-4" />}
198
198
  label="Delete"
199
199
  onClick={() => handleDelete()}