@pascal-app/editor 1.0.4 → 1.0.6

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 +40 -38
  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
@@ -0,0 +1,31 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { rankedGroups } from '.'
3
+
4
+ const action = (group: string, label: string, keywords?: string[]) => ({
5
+ id: label,
6
+ group,
7
+ label,
8
+ keywords,
9
+ execute: () => {},
10
+ })
11
+
12
+ // What a host's palette holds: weak fuzzy matches for "theme" registered before the strong one.
13
+ const actions = [
14
+ action('Generate', 'Generate house from template: The Poppy', ['template', 'adu', 'poppy']),
15
+ action('Viewer Controls', 'Switch to Solid', ['solid', 'shading', 'render', 'mode']),
16
+ action('View', 'Use dark theme', ['theme', 'appearance', 'interface']),
17
+ action('Export & Share', 'Take Screenshot', ['screenshot', 'capture', 'image']),
18
+ ]
19
+
20
+ const order = (search: string) => rankedGroups(actions, search).map(([group]) => group)
21
+
22
+ describe('command palette groups', () => {
23
+ test('a search lists the group with the best match first', () => {
24
+ expect(order('theme')[0]).toBe('View')
25
+ expect(order('solid')[0]).toBe('Viewer Controls')
26
+ })
27
+
28
+ test('with no search the groups keep the order they were registered in', () => {
29
+ expect(order('')).toEqual(['Generate', 'Viewer Controls', 'View', 'Export & Share'])
30
+ })
31
+ })
@@ -12,12 +12,15 @@ export function ActionButton({ icon, label, className, ...props }: ActionButtonP
12
12
  <button
13
13
  {...props}
14
14
  className={cn(
15
- 'flex h-9 flex-1 items-center justify-center gap-1.5 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 font-medium text-foreground text-xs transition-colors hover:bg-[#3e3e3e] active:bg-[#3e3e3e]',
15
+ // The fill runs through custom properties so a caller's `bg-*` or
16
+ // `hover:bg-*` replaces it in both themes; tailwind-merge would keep a
17
+ // `dark:bg-*` fill alive under the caller's class.
18
+ 'flex min-h-9 min-w-0 flex-1 shrink-0 items-center justify-center gap-1.5 rounded-lg border border-border/50 bg-(--action-fill) px-3 py-2 font-medium text-foreground text-xs leading-4 transition-colors [&>svg]:shrink-0 [--action-fill-hover:rgb(0_0_0/0.08)] [--action-fill:rgb(0_0_0/0.04)] hover:bg-(--action-fill-hover) active:bg-(--action-fill-hover) dark:[--action-fill-hover:#3e3e3e] dark:[--action-fill:#2C2C2E]',
16
19
  className,
17
20
  )}
18
21
  >
19
22
  {icon}
20
- <span>{label}</span>
23
+ <span className="min-w-0 whitespace-normal text-center">{label}</span>
21
24
  </button>
22
25
  )
23
26
  }
@@ -310,7 +310,7 @@ export function MetricControl({
310
310
  <div
311
311
  className={cn(
312
312
  'group flex h-10 w-full items-center justify-between rounded-lg border border-border/50 px-3 text-sm transition-colors',
313
- isDragging ? 'bg-[#3e3e3e]' : 'bg-[#2C2C2E] hover:bg-[#3e3e3e]',
313
+ isDragging ? 'bg-black/8 dark:bg-[#3e3e3e]' : 'bg-black/4 dark:bg-[#2C2C2E] hover:bg-black/8 dark:hover:bg-[#3e3e3e]',
314
314
  className,
315
315
  )}
316
316
  onMouseEnter={() => setIsHovered(true)}
@@ -5,7 +5,11 @@ import { cn } from '../../../lib/utils'
5
5
  interface SegmentedControlProps<T extends string> {
6
6
  value: T
7
7
  onChange: (value: T) => void
8
- options: { label: React.ReactNode; value: T }[]
8
+ /**
9
+ * `disabled` greys one option and ignores its click; it stays hoverable, so a tooltip in its
10
+ * `label` can say why.
11
+ */
12
+ options: { label: React.ReactNode; value: T; disabled?: boolean }[]
9
13
  className?: string
10
14
  disabled?: boolean
11
15
  mixed?: boolean
@@ -22,28 +26,37 @@ export function SegmentedControl<T extends string>({
22
26
  return (
23
27
  <div
24
28
  className={cn(
25
- 'flex h-9 w-full items-center rounded-lg border border-border/50 bg-[#2C2C2E] p-[3px]',
29
+ 'flex min-h-9 w-full items-stretch rounded-lg border border-border/50 bg-black/4 dark:bg-[#2C2C2E] p-[3px]',
26
30
  disabled && 'opacity-60',
27
31
  className,
28
32
  )}
29
33
  >
30
34
  {options.map((option) => {
31
35
  const isSelected = !mixed && value === option.value
36
+ const isDisabled = disabled || option.disabled === true
32
37
  return (
33
38
  <button
39
+ aria-disabled={option.disabled || undefined}
40
+ aria-pressed={isSelected}
34
41
  className={cn(
35
- 'relative flex h-full flex-1 items-center justify-center rounded-md font-medium text-xs transition-all duration-200',
42
+ 'relative flex min-h-7 min-w-0 flex-1 items-center justify-center rounded-md px-1.5 py-1 font-medium text-xs leading-4 transition-colors duration-200',
36
43
  isSelected
37
- ? 'bg-[#3e3e3e] text-foreground shadow-sm ring-1 ring-border/50'
38
- : 'text-muted-foreground hover:bg-white/5 hover:text-foreground',
39
- disabled && 'cursor-not-allowed hover:bg-transparent hover:text-muted-foreground',
44
+ ? 'bg-white dark:bg-[#3e3e3e] text-foreground shadow-sm ring-1 ring-border/50'
45
+ : 'text-muted-foreground hover:bg-black/4 dark:hover:bg-white/5 hover:text-foreground',
46
+ isDisabled &&
47
+ 'cursor-not-allowed hover:bg-transparent hover:text-muted-foreground dark:hover:bg-transparent',
48
+ option.disabled && 'opacity-40',
40
49
  )}
41
50
  disabled={disabled}
42
51
  key={option.value}
43
- onClick={() => onChange(option.value)}
52
+ onClick={() => {
53
+ if (!option.disabled) onChange(option.value)
54
+ }}
44
55
  type="button"
45
56
  >
46
- <span className="relative z-10 flex items-center gap-1.5">{option.label}</span>
57
+ <span className="relative z-10 flex min-w-0 items-center justify-center gap-1.5 whitespace-normal text-center [&>svg]:shrink-0">
58
+ {option.label}
59
+ </span>
47
60
  </button>
48
61
  )
49
62
  })}
@@ -34,7 +34,7 @@ export function SelectControl<T extends string>({
34
34
  <button
35
35
  aria-label={label}
36
36
  className={cn(
37
- 'group flex h-10 w-full items-center justify-between gap-2 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm transition-colors hover:bg-[#3e3e3e] data-[state=open]:bg-[#3e3e3e]',
37
+ 'group flex h-10 w-full items-center justify-between gap-2 rounded-lg border border-border/50 bg-black/4 dark:bg-[#2C2C2E] px-3 text-sm transition-colors hover:bg-black/8 dark:hover:bg-[#3e3e3e] data-[state=open]:bg-black/8 dark:data-[state=open]:bg-[#3e3e3e]',
38
38
  className,
39
39
  )}
40
40
  type="button"
@@ -329,7 +329,7 @@ export function SliderControl({
329
329
  <div
330
330
  className={cn(
331
331
  'group flex h-7 w-full select-none items-center rounded-lg px-2 transition-colors',
332
- isDragging ? 'bg-white/5' : 'hover:bg-white/5',
332
+ isDragging ? 'bg-black/4 dark:bg-white/5' : 'hover:bg-black/4 dark:hover:bg-white/5',
333
333
  className,
334
334
  )}
335
335
  onMouseEnter={() => setIsHovered(true)}
@@ -21,7 +21,7 @@ export function ToggleControl({
21
21
  return (
22
22
  <div
23
23
  className={cn(
24
- 'group flex h-10 w-full cursor-pointer items-center justify-between rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm transition-colors hover:bg-[#3e3e3e]',
24
+ 'group flex h-10 w-full cursor-pointer items-center justify-between rounded-lg border border-border/50 bg-black/4 dark:bg-[#2C2C2E] px-3 text-sm transition-colors hover:bg-black/8 dark:hover:bg-[#3e3e3e]',
25
25
  className,
26
26
  )}
27
27
  onClick={() => onChange(!checked)}
@@ -34,10 +34,10 @@ export function ToggleControl({
34
34
  className={cn(
35
35
  'flex h-5 w-5 items-center justify-center rounded-[4px] border transition-all duration-200',
36
36
  mixed
37
- ? 'border-muted-foreground/70 bg-black/20 text-muted-foreground'
37
+ ? 'border-muted-foreground/70 bg-black/5 dark:bg-black/20 text-muted-foreground'
38
38
  : checked
39
39
  ? 'border-primary bg-primary text-primary-foreground'
40
- : 'border-border bg-black/20 text-transparent group-hover:border-muted-foreground',
40
+ : 'border-border bg-black/5 dark:bg-black/20 text-transparent group-hover:border-muted-foreground',
41
41
  )}
42
42
  >
43
43
  {mixed ? (
@@ -50,7 +50,7 @@ function ToolOptionRow({
50
50
  'rounded-lg px-2 py-2 text-center font-medium text-xs transition-colors',
51
51
  selected
52
52
  ? 'bg-primary/10 text-primary ring-1 ring-primary/50'
53
- : 'bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground',
53
+ : 'bg-black/4 text-muted-foreground hover:bg-black/8 hover:text-foreground dark:bg-muted/40 dark:hover:bg-muted',
54
54
  )}
55
55
  key={choice.value}
56
56
  onClick={() => {
@@ -24,11 +24,13 @@ import {
24
24
  type BuildingNode,
25
25
  DEFAULT_LEVEL_HEIGHT,
26
26
  getStoredLevelHeight,
27
+ groundSeat,
27
28
  LevelNode,
28
29
  useScene,
29
30
  } from '@pascal-app/core'
30
31
  import { markPerfAction, useViewer } from '@pascal-app/viewer'
31
32
  import {
33
+ ArrowDownToLine,
32
34
  ClipboardPaste,
33
35
  Copy,
34
36
  GripVertical,
@@ -40,6 +42,7 @@ import {
40
42
  import {
41
43
  type ButtonHTMLAttributes,
42
44
  type CSSProperties,
45
+ Fragment,
43
46
  useCallback,
44
47
  useEffect,
45
48
  useRef,
@@ -51,7 +54,13 @@ import {
51
54
  buildLevelDuplicateCreateOps,
52
55
  type LevelDuplicatePreset,
53
56
  } from '@pascal-app/core/building'
54
- import { getDefaultLevelName, getLevelDisplayName } from '@pascal-app/core'
57
+ import {
58
+ getDefaultLevelName,
59
+ getLevelDisplayName,
60
+ roofLevelOrderBreach,
61
+ servesAsRoofLevel,
62
+ } from '@pascal-app/core'
63
+ import { addStoreyBelow, addStoreyOnTop, setGroundFloor } from '../../lib/add-storey'
55
64
  import { deleteLevelWithFallbackSelection } from '../../lib/level-selection'
56
65
  import { unitMemberLevels, leaveUnitFocus } from '../../lib/units'
57
66
  import { useLinearDisplay } from '../../lib/use-linear-display'
@@ -141,6 +150,8 @@ function LevelRow({
141
150
  onDuplicate,
142
151
  onPaste,
143
152
  onRequestDelete,
153
+ onSetGround,
154
+ isGround,
144
155
  unitDotColor,
145
156
  }: {
146
157
  level: LevelNode
@@ -154,6 +165,10 @@ function LevelRow({
154
165
  onDuplicate: (preset?: LevelDuplicatePreset) => void
155
166
  onPaste?: () => void
156
167
  onRequestDelete: () => void
168
+ /** Makes this level the ground floor: the line moves under it. */
169
+ onSetGround: () => void
170
+ /** This level is the ground floor, seated at grade: nothing left to set. */
171
+ isGround: boolean
157
172
  }) {
158
173
  const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false)
159
174
  const [isEditing, setIsEditing] = useState(false)
@@ -196,17 +211,17 @@ function LevelRow({
196
211
  <div
197
212
  className={cn(
198
213
  'flex items-center rounded-lg transition-colors',
199
- isDragging && 'bg-white/10 text-foreground shadow-lg',
214
+ isDragging && 'bg-black/6 dark:bg-white/10 text-foreground shadow-lg',
200
215
  isSelected
201
- ? 'bg-white/10 text-foreground'
202
- : 'text-muted-foreground/70 hover:bg-white/5 hover:text-muted-foreground',
216
+ ? 'bg-black/6 dark:bg-white/10 text-foreground'
217
+ : 'text-muted-foreground/70 hover:bg-black/4 dark:hover:bg-white/5 hover:text-muted-foreground',
203
218
  )}
204
219
  >
205
220
  <button
206
221
  {...dragHandleProps}
207
222
  aria-label={`Reorder ${getLevelDisplayName(level)}`}
208
223
  className={cn(
209
- 'ml-0.5 flex h-6 w-4 shrink-0 cursor-grab touch-none items-center justify-center rounded-md text-muted-foreground/35 opacity-0 transition-colors hover:bg-white/5 hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 group-hover/level:opacity-100',
224
+ 'ml-0.5 flex h-6 w-4 shrink-0 cursor-grab touch-none items-center justify-center rounded-md text-muted-foreground/35 opacity-0 transition-colors hover:bg-black/4 dark:hover:bg-white/5 hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 group-hover/level:opacity-100',
210
225
  isDragging && 'cursor-grabbing opacity-100',
211
226
  )}
212
227
  onClick={(e) => {
@@ -244,7 +259,7 @@ function LevelRow({
244
259
  <Popover>
245
260
  <PopoverTrigger asChild>
246
261
  <button
247
- className="mr-0.5 shrink-0 whitespace-nowrap rounded px-1 py-0.5 font-mono text-[10px] text-muted-foreground/50 tabular-nums transition-colors hover:bg-white/5 hover:text-foreground"
262
+ className="mr-0.5 shrink-0 whitespace-nowrap rounded px-1 py-0.5 font-mono text-[10px] text-muted-foreground tabular-nums transition-colors hover:bg-black/4 hover:text-foreground dark:text-muted-foreground/50 dark:hover:bg-white/5 dark:hover:text-foreground"
248
263
  onClick={(e) => e.stopPropagation()}
249
264
  title="Level height"
250
265
  type="button"
@@ -295,7 +310,7 @@ function LevelRow({
295
310
  </PopoverTrigger>
296
311
  <PopoverContent align="start" className="w-44 p-1" side="right" sideOffset={8}>
297
312
  <button
298
- className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-muted-foreground text-xs transition-colors hover:bg-white/10 hover:text-foreground"
313
+ className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-muted-foreground text-xs transition-colors hover:bg-black/6 dark:hover:bg-white/10 hover:text-foreground"
299
314
  onClick={(e) => {
300
315
  e.stopPropagation()
301
316
  onDuplicate()
@@ -306,7 +321,7 @@ function LevelRow({
306
321
  Duplicate level
307
322
  </button>
308
323
  <button
309
- className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-muted-foreground text-xs transition-colors hover:bg-white/10 hover:text-foreground"
324
+ className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-muted-foreground text-xs transition-colors hover:bg-black/6 dark:hover:bg-white/10 hover:text-foreground"
310
325
  onClick={(e) => {
311
326
  e.stopPropagation()
312
327
  setDuplicateDialogOpen(true)
@@ -316,9 +331,26 @@ function LevelRow({
316
331
  <Copy className="h-3 w-3" />
317
332
  Duplicate with options...
318
333
  </button>
334
+ <button
335
+ className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-muted-foreground text-xs transition-colors enabled:hover:bg-black/6 dark:enabled:hover:bg-white/10 enabled:hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
336
+ disabled={isGround}
337
+ onClick={(e) => {
338
+ e.stopPropagation()
339
+ onSetGround()
340
+ }}
341
+ title={
342
+ isGround
343
+ ? 'This floor stands at grade'
344
+ : 'Seat the building so this floor stands at grade; the floors under it are below grade'
345
+ }
346
+ type="button"
347
+ >
348
+ <ArrowDownToLine className="h-3 w-3" />
349
+ Set as ground floor
350
+ </button>
319
351
  {onPaste && (
320
352
  <button
321
- className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-muted-foreground text-xs transition-colors hover:bg-white/10 hover:text-foreground"
353
+ className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-muted-foreground text-xs transition-colors hover:bg-black/6 dark:hover:bg-white/10 hover:text-foreground"
322
354
  onClick={(e) => {
323
355
  e.stopPropagation()
324
356
  onPaste()
@@ -330,7 +362,7 @@ function LevelRow({
330
362
  </button>
331
363
  )}
332
364
  <button
333
- className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-muted-foreground text-xs transition-colors enabled:hover:bg-white/10 enabled:hover:text-red-400 disabled:cursor-not-allowed disabled:opacity-50"
365
+ className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-muted-foreground text-xs transition-colors enabled:hover:bg-black/6 dark:enabled:hover:bg-white/10 enabled:hover:text-red-600 dark:enabled:hover:text-red-400 disabled:cursor-not-allowed disabled:opacity-50"
334
366
  disabled={!canDeleteLevel}
335
367
  onClick={(e) => {
336
368
  e.stopPropagation()
@@ -366,6 +398,8 @@ function SortableLevelRow({
366
398
  onDuplicate,
367
399
  onPaste,
368
400
  onRequestDelete,
401
+ onSetGround,
402
+ isGround,
369
403
  unitDotColor,
370
404
  }: {
371
405
  level: LevelNode
@@ -375,6 +409,8 @@ function SortableLevelRow({
375
409
  onDuplicate: (preset?: LevelDuplicatePreset) => void
376
410
  onPaste?: () => void
377
411
  onRequestDelete: () => void
412
+ onSetGround: () => void
413
+ isGround: boolean
378
414
  }) {
379
415
  const {
380
416
  attributes,
@@ -404,14 +440,67 @@ function SortableLevelRow({
404
440
  level={level}
405
441
  onDuplicate={onDuplicate}
406
442
  onPaste={onPaste}
443
+ isGround={isGround}
407
444
  onRequestDelete={onRequestDelete}
408
445
  onSelect={onSelect}
446
+ onSetGround={onSetGround}
409
447
  unitDotColor={unitDotColor}
410
448
  />
411
449
  </div>
412
450
  )
413
451
  }
414
452
 
453
+ // ── The ground line: between the level at grade and the ones under it ───────
454
+
455
+ type GroundLineState = {
456
+ levelId: string
457
+ seated: boolean
458
+ atFoundationTop: boolean
459
+ offset: number
460
+ }
461
+
462
+ /**
463
+ * "Ground · +0.20": where the stack meets grade, read as the ground floor's datum over the ground
464
+ * (the top of the foundation, 8 in over it once seated). The levels under it are below grade. When
465
+ * the datum stands outside the seat (a stack that lifted it, or sank it) the line turns amber and
466
+ * a click seats the building.
467
+ */
468
+ function GroundLine({ ground, onSeat }: { ground: GroundLineState; onSeat: () => void }) {
469
+ const { toDisplay, displayUnit, precision } = useLinearDisplay('m', 2)
470
+ const over = toDisplay(ground.offset)
471
+ const label = `Ground · ${over > 0 ? '+' : ''}${over.toFixed(precision)}`
472
+ return (
473
+ <div
474
+ className="relative flex items-center gap-1.5 px-2 py-0.5"
475
+ data-guide-target="level-ground-line"
476
+ >
477
+ {ground.seated ? (
478
+ <span
479
+ className="shrink-0 font-mono text-[10px] text-muted-foreground/70 tabular-nums"
480
+ title="Ground line: the levels under it are below grade"
481
+ >
482
+ {label}
483
+ </span>
484
+ ) : (
485
+ <button
486
+ className="shrink-0 rounded-full px-1.5 font-mono text-[10px] text-amber-400 tabular-nums transition-colors hover:bg-amber-400/10"
487
+ onClick={onSeat}
488
+ title={`The ground floor stands ${Math.abs(toDisplay(ground.offset)).toFixed(precision)} ${displayUnit} ${ground.offset > 0 ? 'over' : 'under'} grade. Seat the ground floor 8 in over grade`}
489
+ type="button"
490
+ >
491
+ {label}
492
+ </button>
493
+ )}
494
+ <span
495
+ className={cn(
496
+ 'h-px flex-1',
497
+ ground.seated ? 'bg-foreground/25' : 'bg-amber-400/60',
498
+ )}
499
+ />
500
+ </div>
501
+ )
502
+ }
503
+
415
504
  // ── Unit focus chip: name + × to end focus, only while a unit is focused ────
416
505
 
417
506
  function UnitFocusChip() {
@@ -435,7 +524,7 @@ function UnitFocusChip() {
435
524
  <span className="truncate">{focusedUnit.name || 'Unit'}</span>
436
525
  <button
437
526
  aria-label="Exit unit focus"
438
- className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground"
527
+ className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-black/6 dark:hover:bg-white/10 hover:text-foreground"
439
528
  onClick={() => leaveUnitFocus()}
440
529
  title="Exit unit focus"
441
530
  type="button"
@@ -500,31 +589,40 @@ export function FloatingLevelSelector() {
500
589
  }),
501
590
  )
502
591
 
592
+ const ground = useScene(
593
+ useShallow((state): GroundLineState | null => {
594
+ const seat = resolvedBuildingId ? groundSeat(state.nodes, resolvedBuildingId) : null
595
+ return seat
596
+ ? {
597
+ levelId: seat.levelId,
598
+ seated: seat.seated,
599
+ atFoundationTop: seat.atFoundationTop,
600
+ offset: seat.offset,
601
+ }
602
+ : null
603
+ }),
604
+ )
605
+
606
+ const handleSetGround = useCallback((levelId: string) => {
607
+ setGroundFloor(levelId as LevelNode['id'])
608
+ }, [])
609
+
610
+ const topIsRoofLevel = useScene((state) => {
611
+ const top = levels.at(-1)
612
+ return top ? servesAsRoofLevel(state.nodes, top) : false
613
+ })
614
+
503
615
  const handleAddAbove = useCallback(() => {
504
616
  if (!resolvedBuildingId) return
505
- const maxLevel = levels.length > 0 ? Math.max(...levels.map((l) => l.level)) : -1
506
- const newLevel = LevelNode.parse({
507
- level: maxLevel + 1,
508
- height: DEFAULT_LEVEL_HEIGHT,
509
- children: [],
510
- parentId: resolvedBuildingId,
511
- })
512
- createNode(newLevel, resolvedBuildingId)
617
+ const newLevel = addStoreyOnTop(resolvedBuildingId)
513
618
  setSelection({ buildingId: resolvedBuildingId, levelId: newLevel.id })
514
- }, [resolvedBuildingId, levels, createNode, setSelection])
619
+ }, [resolvedBuildingId, setSelection])
515
620
 
516
621
  const handleAddBelow = useCallback(() => {
517
622
  if (!resolvedBuildingId) return
518
- const minLevel = levels.length > 0 ? Math.min(...levels.map((l) => l.level)) : 1
519
- const newLevel = LevelNode.parse({
520
- level: minLevel - 1,
521
- height: DEFAULT_LEVEL_HEIGHT,
522
- children: [],
523
- parentId: resolvedBuildingId,
524
- })
525
- createNode(newLevel, resolvedBuildingId)
623
+ const newLevel = addStoreyBelow(resolvedBuildingId)
526
624
  setSelection({ buildingId: resolvedBuildingId, levelId: newLevel.id })
527
- }, [resolvedBuildingId, levels, createNode, setSelection])
625
+ }, [resolvedBuildingId, setSelection])
528
626
 
529
627
  const handleInsertBetween = useCallback(
530
628
  (lowerIndex: number) => {
@@ -533,26 +631,22 @@ export function FloatingLevelSelector() {
533
631
  if (!lower) return
534
632
 
535
633
  const newLevelNumber = lower.level + 1
536
- const toShift = levels.filter((l) => l.level >= newLevelNumber)
537
- if (toShift.length > 0) {
538
- updateNodes(
539
- toShift.map((l) => ({
540
- id: l.id as AnyNodeId,
541
- data: { level: l.level + 1 } as Partial<AnyNode>,
542
- })),
543
- )
544
- }
545
-
546
634
  const newLevel = LevelNode.parse({
547
635
  level: newLevelNumber,
548
636
  height: DEFAULT_LEVEL_HEIGHT,
549
637
  children: [],
550
638
  parentId: resolvedBuildingId,
551
639
  })
552
- createNode(newLevel, resolvedBuildingId)
640
+ // One undo step: undoing the floor also moves the levels above back down.
641
+ applyNodeChanges({
642
+ create: [{ node: newLevel, parentId: resolvedBuildingId }],
643
+ update: levels
644
+ .filter((l) => l.level >= newLevelNumber)
645
+ .map((l) => ({ id: l.id as AnyNodeId, data: { level: l.level + 1 } as Partial<AnyNode> })),
646
+ })
553
647
  setSelection({ buildingId: resolvedBuildingId, levelId: newLevel.id })
554
648
  },
555
- [resolvedBuildingId, levels, createNode, updateNodes, setSelection],
649
+ [resolvedBuildingId, levels, applyNodeChanges, setSelection],
556
650
  )
557
651
 
558
652
  const handleConfirmDelete = useCallback(() => {
@@ -602,6 +696,11 @@ export function FloatingLevelSelector() {
602
696
 
603
697
  const reorderedVisualLevels = arrayMove(visualLevels, oldIndex, newIndex)
604
698
  const levelNumbersDescending = levels.map((level) => level.level).sort((a, b) => b - a)
699
+ // The roof level stays above every storey: a drop putting it under one it stood over is refused.
700
+ const floors = new Map(
701
+ reorderedVisualLevels.map((level, index) => [level.id, levelNumbersDescending[index]!]),
702
+ )
703
+ if (roofLevelOrderBreach(useScene.getState().nodes, floors)) return
605
704
 
606
705
  const updates = reorderedVisualLevels
607
706
  .map((level, index) => ({
@@ -632,13 +731,13 @@ export function FloatingLevelSelector() {
632
731
  const sortableLevelIds = reversedLevels.map((level) => level.id)
633
732
 
634
733
  const addButtonClass =
635
- 'absolute left-1/2 z-10 flex h-4 w-4 -translate-x-1/2 items-center justify-center rounded-full border border-border/80 bg-neutral-800 text-muted-foreground/60 shadow-md transition-colors hover:bg-neutral-700 hover:text-foreground'
734
+ 'absolute left-1/2 z-10 flex h-4 w-4 -translate-x-1/2 items-center justify-center rounded-full border border-border/80 bg-white text-muted-foreground/60 shadow-md transition-colors hover:bg-neutral-100 hover:text-foreground dark:bg-neutral-800 dark:hover:bg-neutral-700'
636
735
 
637
736
  return (
638
737
  <>
639
738
  <div className={cn('pointer-events-auto absolute left-3 z-20', isMobile ? 'top-14' : 'top-3')}>
640
739
  <div className="relative">
641
- {/* Floating + at top edge */}
740
+ {/* Floating + at top edge: a floor on top, under the roof level when one tops the stack */}
642
741
  {!draggingLevelId && (
643
742
  <button
644
743
  className={cn(addButtonClass, 'top-0 -translate-y-1/2')}
@@ -646,7 +745,7 @@ export function FloatingLevelSelector() {
646
745
  // read only from outside: nothing here depends on it.
647
746
  data-guide-target="level-add"
648
747
  onClick={handleAddAbove}
649
- title="Add level above"
748
+ title={topIsRoofLevel ? 'Add a floor under the roof' : 'Add level above'}
650
749
  type="button"
651
750
  >
652
751
  <Plus className="h-2.5 w-2.5" />
@@ -681,6 +780,7 @@ export function FloatingLevelSelector() {
681
780
  const showGapBelow = i < reversedLevels.length - 1
682
781
 
683
782
  return (
783
+ <Fragment key={level.id}>
684
784
  <div
685
785
  className="relative"
686
786
  // A stable hook for host-app onboarding to point at, on
@@ -688,7 +788,6 @@ export function FloatingLevelSelector() {
688
788
  // without knowing the building. Static, and read only
689
789
  // from outside: nothing here depends on it.
690
790
  data-guide-target={level.level === 0 ? 'level-ground' : undefined}
691
- key={level.id}
692
791
  >
693
792
  <SortableLevelRow
694
793
  isSelected={isSelected}
@@ -700,7 +799,9 @@ export function FloatingLevelSelector() {
700
799
  }
701
800
  onDuplicate={(preset) => handleDuplicateLevel(level, preset)}
702
801
  onPaste={() => handlePasteToLevel(level)}
802
+ isGround={ground?.levelId === level.id && ground.atFoundationTop}
703
803
  onRequestDelete={() => setDeletingLevel(level)}
804
+ onSetGround={() => handleSetGround(level.id)}
704
805
  onSelect={() => {
705
806
  if (!isSelected) markPerfAction('level-switch', level.id)
706
807
  setSelection(
@@ -722,6 +823,10 @@ export function FloatingLevelSelector() {
722
823
  </button>
723
824
  )}
724
825
  </div>
826
+ {ground?.levelId === level.id && !draggingLevelId && (
827
+ <GroundLine ground={ground} onSeat={() => handleSetGround(level.id)} />
828
+ )}
829
+ </Fragment>
725
830
  )
726
831
  })}
727
832
  </div>
@@ -1,6 +1,6 @@
1
1
  import { Icon } from '@iconify/react'
2
2
  import type { ToolHint } from '@pascal-app/core'
3
- import { Fragment, useEffect, useSyncExternalStore } from 'react'
3
+ import { Fragment, useEffect, useId, useState, useSyncExternalStore } from 'react'
4
4
  import {
5
5
  CONTINUATION_PROFILES,
6
6
  type ContinuationContext,
@@ -73,14 +73,16 @@ function ShortcutSequence({
73
73
  <span className="text-[9px] text-muted-foreground/70">/</span>
74
74
  ) : null}
75
75
  <ShortcutToken
76
- className={cn(TOKEN_CLASS, active && 'border-white bg-white text-black shadow-sm')}
76
+ className={cn(TOKEN_CLASS, active &&
77
+ 'border-foreground bg-foreground text-background shadow-sm dark:border-white dark:bg-white dark:text-black')}
77
78
  value={alternative}
78
79
  />
79
80
  </Fragment>
80
81
  ))
81
82
  ) : (
82
83
  <ShortcutToken
83
- className={cn(TOKEN_CLASS, active && 'border-white bg-white text-black shadow-sm')}
84
+ className={cn(TOKEN_CLASS, active &&
85
+ 'border-foreground bg-foreground text-background shadow-sm dark:border-white dark:bg-white dark:text-black')}
84
86
  value={entry}
85
87
  />
86
88
  )}
@@ -423,6 +425,10 @@ function HudHeader({ title }: { title: HudTitle }) {
423
425
  const isEscHint = (hint: ContextualShortcutHint) =>
424
426
  hint.keys.length === 1 && hint.keys[0] === 'Esc'
425
427
 
428
+ // A "!" row says why something won't happen here (a wall end a window can't wrap), not a key to
429
+ // press, so it is never folded under More shortcuts.
430
+ const isNoteHint = (hint: ContextualShortcutHint) => hint.keys.length === 1 && hint.keys[0] === '!'
431
+
426
432
  function HintRow({ hint }: { hint: ContextualShortcutHint }) {
427
433
  return (
428
434
  <div className={cn(ROW_CLASS, 'items-start')}>
@@ -431,7 +437,7 @@ function HintRow({ hint }: { hint: ContextualShortcutHint }) {
431
437
  <div
432
438
  className={cn(
433
439
  'text-xs leading-5',
434
- hint.active ? 'font-medium text-white' : 'text-muted-foreground',
440
+ hint.active ? 'font-medium text-foreground dark:text-white' : 'text-muted-foreground',
435
441
  )}
436
442
  >
437
443
  {hint.label}
@@ -470,6 +476,8 @@ export function ContextualHelperPanel({
470
476
  notice?: string | null
471
477
  }) {
472
478
  const inStack = useInRightStack()
479
+ const [showAllHints, setShowAllHints] = useState(false)
480
+ const hintsId = useId()
473
481
  const modeChips = chipHints.filter((hint) => hint.chip)
474
482
  const hasChips = !!snapContext || !!continuationContext || modeChips.length > 0 || showPaintScope
475
483
  const fenceFeature = useEditor((state) =>
@@ -486,6 +494,10 @@ export function ContextualHelperPanel({
486
494
 
487
495
  const actionHints = hints.filter((hint) => !isEscHint(hint))
488
496
  const escHints = hints.filter(isEscHint)
497
+ const collapsedHints = actionHints.filter(
498
+ (hint, index) => index === 0 || hint.active || isNoteHint(hint),
499
+ )
500
+ const visibleHints = showAllHints ? actionHints : collapsedHints
489
501
 
490
502
  return (
491
503
  <div
@@ -493,9 +505,22 @@ export function ContextualHelperPanel({
493
505
  data-hud-card
494
506
  >
495
507
  {title ? <HudHeader title={title} /> : null}
496
- {actionHints.map((hint) => (
497
- <HintRow hint={hint} key={hintKey(hint)} />
498
- ))}
508
+ <div id={hintsId} className="col-span-2 grid grid-cols-subgrid gap-y-1.5">
509
+ {visibleHints.map((hint) => (
510
+ <HintRow hint={hint} key={hintKey(hint)} />
511
+ ))}
512
+ </div>
513
+ {collapsedHints.length < actionHints.length ? (
514
+ <button
515
+ aria-controls={hintsId}
516
+ aria-expanded={showAllHints}
517
+ className="pointer-events-auto col-span-2 min-h-7 rounded-md px-1 text-left text-[11px] text-muted-foreground hover:bg-muted/60 hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"
518
+ onClick={() => setShowAllHints((value) => !value)}
519
+ type="button"
520
+ >
521
+ {showAllHints ? 'Fewer shortcuts' : 'More shortcuts'}
522
+ </button>
523
+ ) : null}
499
524
  {notice ? <HintRow hint={{ keys: ['!'], label: notice, active: true }} /> : null}
500
525
  {actionHints.length > 0 && (hasChips || escHints.length > 0) ? (
501
526
  <div className="col-span-2 my-0.5 h-px bg-border" />