@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
@@ -5,14 +5,17 @@ import {
5
5
  type AnyNodeId,
6
6
  area,
7
7
  getLevelDisplayName,
8
+ runAsSingleSceneHistoryStep,
8
9
  useScene,
9
10
  type ZoneNode,
10
11
  } from '@pascal-app/core'
12
+ import { setRoomFloorScript } from '@pascal-app/core/agent-operations'
11
13
  import { useViewer } from '@pascal-app/viewer'
12
14
  import { Check, ChevronDown, Plus } from 'lucide-react'
13
15
  import { type KeyboardEvent, useEffect, useId, useMemo, useRef, useState } from 'react'
14
16
  import { startCeilingEdit } from '../../../lib/ceiling-edit-session'
15
17
  import { resolveOverlayPolicy } from '../../../lib/interaction/overlay-policy'
18
+ import { finishScriptCode } from '../../../lib/geometry-script/finish-scripts'
16
19
  import { formatAreaLabel, formatLinearMeasurement } from '../../../lib/measurements'
17
20
  import { addMezzanineStairs } from '../../../lib/mezzanine-stairs'
18
21
  import { roomFloorBase, roomRelativeFloorHeight } from '../../../lib/room-construction-commands'
@@ -111,6 +114,7 @@ export function RoomPanel({
111
114
  ) : (
112
115
  <RoomConstructionRows key={room.zoneId} room={room} />
113
116
  )}
117
+ <RoomFloorScriptRow key={`floor-script-${room.zoneId}`} zoneId={room.zoneId} />
114
118
  </div>
115
119
  </PanelWrapper>
116
120
  )
@@ -222,6 +226,67 @@ export function RoomUnitRow({ zoneId }: { zoneId: string }) {
222
226
  )
223
227
  }
224
228
 
229
+ /**
230
+ * "Scripted finish": a floor a script builds (it owns the floor's colours, so Paint leaves it),
231
+ * with its code to read and a way to take it off, back to the floor's own finish.
232
+ */
233
+ function RoomFloorScriptRow({ zoneId }: { zoneId: string }) {
234
+ const sha = useScene((scene) => {
235
+ const zone = scene.nodes[zoneId as AnyNodeId]
236
+ return zone?.type === 'zone' ? zone.floor?.script?.script : undefined
237
+ })
238
+ const [code, setCode] = useState<{ sha: string; text: string } | null>(null)
239
+ const [open, setOpen] = useState(false)
240
+ if (!sha) return null
241
+ const toggleCode = () => {
242
+ setOpen((shown) => !shown)
243
+ if (code?.sha === sha) return
244
+ finishScriptCode(sha).then(
245
+ (text) => setCode({ sha, text: text ?? 'The script could not be read.' }),
246
+ () => setCode({ sha, text: 'The script could not be read.' }),
247
+ )
248
+ }
249
+ const remove = () => {
250
+ const { changes } = setRoomFloorScript(
251
+ useScene.getState().nodes,
252
+ { zoneId, remove: true },
253
+ { activeLevelId: null },
254
+ )
255
+ runAsSingleSceneHistoryStep(useScene, () => {
256
+ for (const { id, data } of changes?.update ?? [])
257
+ useScene.getState().updateNode(id as AnyNodeId, data)
258
+ })
259
+ }
260
+ return (
261
+ <div className="mt-3 flex flex-col gap-2" data-room-floor-script={zoneId}>
262
+ <div className="flex min-h-8 items-center justify-between gap-3">
263
+ <span className="text-muted-foreground text-xs">Scripted finish</span>
264
+ <div className="flex items-center gap-1.5">
265
+ <button
266
+ className="rounded-full border border-border/50 px-2.5 py-0.5 text-xs transition-colors hover:bg-accent"
267
+ onClick={toggleCode}
268
+ type="button"
269
+ >
270
+ {open ? 'Hide code' : 'View code'}
271
+ </button>
272
+ <button
273
+ className="rounded-full border border-border/50 px-2.5 py-0.5 text-xs transition-colors hover:bg-accent"
274
+ onClick={remove}
275
+ type="button"
276
+ >
277
+ Remove
278
+ </button>
279
+ </div>
280
+ </div>
281
+ {open && (
282
+ <pre className="max-h-60 overflow-auto whitespace-pre rounded-lg border border-border/50 p-2 font-mono text-[11px] text-foreground">
283
+ {code?.sha === sha ? code.text : 'Loading…'}
284
+ </pre>
285
+ )}
286
+ </div>
287
+ )
288
+ }
289
+
225
290
  const count = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
226
291
 
227
292
  /** "2 doors · 3 windows" (the level is in the kicker). */
@@ -0,0 +1,8 @@
1
+ /** A small mark beside a name: the plan a plugin needs ("Pro"), as the plugin manager shows it. */
2
+ export function Badge({ label }: { label: string }) {
3
+ return (
4
+ <span className="rounded-[5px] border border-border px-1.5 py-px font-mono font-medium text-[10px] text-muted-foreground uppercase tracking-wide">
5
+ {label}
6
+ </span>
7
+ )
8
+ }
@@ -48,7 +48,7 @@ type SidebarStore = {
48
48
  export const useSidebarStore = create<SidebarStore>()(
49
49
  persist(
50
50
  (set) => ({
51
- width: 288, // 18rem = 288px
51
+ width: 360,
52
52
  setWidth: (width) => {
53
53
  if (width < SIDEBAR_COLLAPSE_THRESHOLD) {
54
54
  set({ isCollapsed: true })
@@ -2,6 +2,8 @@
2
2
 
3
3
  import { createContext, type ReactNode, useContext, useEffect, useRef } from 'react'
4
4
  import { useIsMobile } from '../../hooks/use-mobile'
5
+ import { cn } from '../../lib/utils'
6
+ import { TOP_MENU_CLEARANCE } from './action-menu/placement'
5
7
 
6
8
  const RightStackContext = createContext(false)
7
9
 
@@ -56,7 +58,19 @@ function inspectorHeights(slot: HTMLElement): { natural: number; header: number
56
58
  * and the card scrolls instead. Neither hides the other. On a phone the two
57
59
  * keep their own layouts (sheet / no card).
58
60
  */
59
- export function RightStack({ inspector, helper }: { inspector: ReactNode; helper: ReactNode }) {
61
+ export function RightStack({
62
+ inspector,
63
+ helper,
64
+ reserveBottomMenu = true,
65
+ belowTopMenu = false,
66
+ }: {
67
+ inspector: ReactNode
68
+ helper: ReactNode
69
+ // Keep clear of an action menu docked at the bottom; off when it sits at the top.
70
+ reserveBottomMenu?: boolean
71
+ // Start below an action menu docked at the top of the stage.
72
+ belowTopMenu?: boolean
73
+ }) {
60
74
  const isMobile = useIsMobile()
61
75
  const stackRef = useRef<HTMLDivElement>(null)
62
76
  const inspectorRef = useRef<HTMLDivElement>(null)
@@ -125,7 +139,12 @@ export function RightStack({ inspector, helper }: { inspector: ReactNode; helper
125
139
  return (
126
140
  <RightStackContext.Provider value={true}>
127
141
  <div
128
- className="pointer-events-none fixed top-20 right-4 bottom-[74px] z-40 flex flex-col items-end gap-2"
142
+ className={cn(
143
+ 'pointer-events-none fixed right-4 z-40 flex flex-col items-end gap-2',
144
+ reserveBottomMenu ? 'bottom-[74px]' : 'bottom-4',
145
+ !belowTopMenu && 'top-20',
146
+ )}
147
+ style={belowTopMenu ? { top: TOP_MENU_CLEARANCE } : undefined}
129
148
  data-right-stack
130
149
  ref={stackRef}
131
150
  >
@@ -78,7 +78,7 @@ export function AppSidebar({
78
78
 
79
79
  return (
80
80
  <>
81
- <div className={cn('dark flex h-full w-full bg-sidebar text-sidebar-foreground')}>
81
+ <div className="flex h-full w-full bg-sidebar text-sidebar-foreground">
82
82
  {/* Icon Rail */}
83
83
  <IconRail
84
84
  activePanel={activePanel}
@@ -65,7 +65,7 @@ export function IconRail({
65
65
  <button
66
66
  className={cn(
67
67
  'flex h-9 w-9 items-center justify-center rounded-lg transition-all',
68
- isActive ? 'bg-accent' : 'hover:bg-accent',
68
+ isActive ? 'bg-sidebar-accent' : 'hover:bg-sidebar-accent',
69
69
  )}
70
70
  onClick={() => onPanelChange(panel.id)}
71
71
  type="button"
@@ -107,7 +107,7 @@ export function IconRail({
107
107
  <button
108
108
  className={cn(
109
109
  'flex h-9 w-9 items-center justify-center rounded-lg transition-all',
110
- isActive ? 'bg-accent' : 'hover:bg-accent',
110
+ isActive ? 'bg-sidebar-accent' : 'hover:bg-sidebar-accent',
111
111
  )}
112
112
  onClick={() => onPanelChange(panel.id)}
113
113
  type="button"
@@ -138,7 +138,7 @@ export function IconRail({
138
138
  <button
139
139
  className={cn(
140
140
  'flex h-9 w-9 items-center justify-center rounded-lg transition-all',
141
- isActive ? 'bg-accent' : 'hover:bg-accent',
141
+ isActive ? 'bg-sidebar-accent' : 'hover:bg-sidebar-accent',
142
142
  )}
143
143
  onClick={() => onPanelChange(panel.id)}
144
144
  type="button"
@@ -136,7 +136,7 @@ export function FunctionTreePanel({
136
136
  'relative flex aspect-square items-center justify-center rounded-xl transition-all duration-200',
137
137
  isActive
138
138
  ? 'bg-primary/10 ring-1 ring-primary/50'
139
- : 'bg-muted/40 opacity-70 grayscale hover:bg-muted hover:opacity-100 hover:grayscale-0',
139
+ : 'bg-black/4 opacity-70 grayscale hover:bg-black/8 hover:opacity-100 hover:grayscale-0 dark:bg-muted/40 dark:hover:bg-muted',
140
140
  )}
141
141
  onClick={() => {
142
142
  triggerSFX('sfx:menu-click')
@@ -279,7 +279,7 @@ function LegacyItemsPanel({
279
279
  'cursor-pointer rounded-md px-2 py-0.5 font-medium text-xs transition-colors',
280
280
  activePlacementTag === null
281
281
  ? 'bg-blue-500 text-white'
282
- : 'bg-blue-950/50 text-blue-300 hover:bg-blue-900/60 hover:text-blue-200',
282
+ : 'bg-blue-50 dark:bg-blue-950/50 text-blue-700 dark:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-900/60 hover:text-blue-800 dark:hover:text-blue-200',
283
283
  )}
284
284
  onClick={() => setActivePlacementTag(null)}
285
285
  type="button"
@@ -297,8 +297,8 @@ function LegacyItemsPanel({
297
297
  isActive
298
298
  ? 'bg-blue-500 text-white'
299
299
  : isEmpty
300
- ? 'cursor-not-allowed bg-zinc-800 text-zinc-500'
301
- : 'bg-blue-950/50 text-blue-300 hover:bg-blue-900/60 hover:text-blue-200',
300
+ ? 'cursor-not-allowed bg-zinc-100 dark:bg-zinc-800 text-zinc-400 dark:text-zinc-500'
301
+ : 'bg-blue-50 dark:bg-blue-950/50 text-blue-700 dark:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-900/60 hover:text-blue-800 dark:hover:text-blue-200',
302
302
  )}
303
303
  disabled={isEmpty}
304
304
  key={tag}
@@ -312,7 +312,7 @@ function LegacyItemsPanel({
312
312
  isActive
313
313
  ? 'text-blue-200'
314
314
  : isEmpty
315
- ? 'text-zinc-600'
315
+ ? 'text-zinc-400 dark:text-zinc-600'
316
316
  : 'text-blue-500/70',
317
317
  )}
318
318
  >
@@ -337,7 +337,7 @@ function LegacyItemsPanel({
337
337
  isActive
338
338
  ? 'bg-violet-500 text-white'
339
339
  : isEmpty
340
- ? 'cursor-not-allowed bg-zinc-800 text-zinc-500'
340
+ ? 'cursor-not-allowed bg-zinc-100 dark:bg-zinc-800 text-zinc-400 dark:text-zinc-500'
341
341
  : 'bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground',
342
342
  )}
343
343
  disabled={isEmpty}
@@ -352,7 +352,7 @@ function LegacyItemsPanel({
352
352
  isActive
353
353
  ? 'text-violet-200'
354
354
  : isEmpty
355
- ? 'text-zinc-600'
355
+ ? 'text-zinc-400 dark:text-zinc-600'
356
356
  : 'text-zinc-500/70',
357
357
  )}
358
358
  >
@@ -0,0 +1,40 @@
1
+ 'use client'
2
+
3
+ import { ANIMATION_SPEEDS, type AnimationSpeed, normalizeAnimationSpeed } from '@pascal-app/viewer'
4
+ import useConstructionReveal from '../../../../../store/use-construction-reveal'
5
+ import { SegmentedControl } from '../../../controls/segmented-control'
6
+
7
+ const label = (speed: AnimationSpeed) => `×${speed}`
8
+
9
+ // The control's values are strings; a step is its number as text.
10
+ const OPTIONS = ANIMATION_SPEEDS.map((speed) => ({
11
+ label: label(speed),
12
+ value: String(speed),
13
+ title: speed === 1 ? 'The pace builds were made at.' : `Builds play ×${speed} as fast.`,
14
+ }))
15
+
16
+ /** How fast what the AI builds plays: ×0.5 to ×2, for the Full and Framing animations and their undo. */
17
+ export function AnimationSpeedSetting() {
18
+ const level = useConstructionReveal((state) => state.level)
19
+ const speed = useConstructionReveal((state) => state.speed)
20
+ const setSpeed = useConstructionReveal((state) => state.setSpeed)
21
+ const off = level === 'off'
22
+ return (
23
+ <div className="space-y-2 py-1">
24
+ <div className="space-y-0.5">
25
+ <p className="font-medium text-sm">Animation speed</p>
26
+ <p className="text-muted-foreground text-xs">
27
+ {off
28
+ ? 'Turn the construction animation on to use this.'
29
+ : 'Higher plays a build faster; it applies to the next build.'}
30
+ </p>
31
+ </div>
32
+ <SegmentedControl
33
+ aria-label="Animation speed"
34
+ onChange={(value) => setSpeed(normalizeAnimationSpeed(Number(value)))}
35
+ options={OPTIONS}
36
+ value={String(speed)}
37
+ />
38
+ </div>
39
+ )
40
+ }
@@ -34,17 +34,18 @@ export function AudioSettingsDialog() {
34
34
  <DialogContent className="sm:max-w-[425px]">
35
35
  <DialogHeader>
36
36
  <DialogTitle>Audio settings</DialogTitle>
37
- <DialogDescription>Adjust volume levels and mute settings</DialogDescription>
37
+ <DialogDescription>
38
+ Volume levels apply to every tab. Muting applies to this tab only.
39
+ </DialogDescription>
38
40
  </DialogHeader>
39
41
  <div className="space-y-6 py-4">
40
42
  {/* Master Volume */}
41
43
  <div className="space-y-2">
42
44
  <div className="flex items-center justify-between">
43
- <label className="font-medium text-sm">Master Volume</label>
45
+ <label className="font-medium text-sm">Master volume</label>
44
46
  <span className="text-muted-foreground text-sm">{masterVolume}%</span>
45
47
  </div>
46
48
  <Slider
47
- disabled={muted}
48
49
  max={100}
49
50
  onValueChange={(value) => value[0] !== undefined && setMasterVolume(value[0])}
50
51
  step={1}
@@ -55,11 +56,10 @@ export function AudioSettingsDialog() {
55
56
  {/* Radio Volume */}
56
57
  <div className="space-y-2">
57
58
  <div className="flex items-center justify-between">
58
- <label className="font-medium text-sm">Radio Volume</label>
59
+ <label className="font-medium text-sm">Radio volume</label>
59
60
  <span className="text-muted-foreground text-sm">{radioVolume}%</span>
60
61
  </div>
61
62
  <Slider
62
- disabled={muted}
63
63
  max={100}
64
64
  onValueChange={(value) => value[0] !== undefined && setRadioVolume(value[0])}
65
65
  step={1}
@@ -70,11 +70,10 @@ export function AudioSettingsDialog() {
70
70
  {/* SFX Volume */}
71
71
  <div className="space-y-2">
72
72
  <div className="flex items-center justify-between">
73
- <label className="font-medium text-sm">Sound Effects</label>
73
+ <label className="font-medium text-sm">Sound effects</label>
74
74
  <span className="text-muted-foreground text-sm">{sfxVolume}%</span>
75
75
  </div>
76
76
  <Slider
77
- disabled={muted}
78
77
  max={100}
79
78
  onValueChange={(value) => value[0] !== undefined && setSfxVolume(value[0])}
80
79
  step={1}
@@ -90,7 +89,7 @@ export function AudioSettingsDialog() {
90
89
  variant={muted ? 'default' : 'outline'}
91
90
  >
92
91
  {muted ? <VolumeX className="size-4" /> : <Volume2 className="size-4" />}
93
- {muted ? 'Unmute All Sounds' : 'Mute All Sounds'}
92
+ {muted ? 'Unmute this tab' : 'Mute this tab'}
94
93
  </Button>
95
94
  </div>
96
95
  </div>
@@ -0,0 +1,170 @@
1
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNodeDefinition,
4
+ BaseNode,
5
+ loadPlugin,
6
+ nodeRegistry,
7
+ nodeType,
8
+ objectId,
9
+ useScene,
10
+ } from '@pascal-app/core'
11
+ import type { ConstructionRevealLevel } from '@pascal-app/viewer'
12
+ import { renderToStaticMarkup } from 'react-dom/server'
13
+ import useConstructionReveal, {
14
+ chooseConstructionLevel,
15
+ FRAMING_KIND,
16
+ resolveConstructionLevel,
17
+ } from '../../../../../store/use-construction-reveal'
18
+ import { AnimationSpeedSetting } from './animation-speed-setting'
19
+ import {
20
+ ConstructionAnimationSetting,
21
+ constructionLevelOptions,
22
+ FRAMING_NEEDS_ARCHITECT,
23
+ } from './construction-animation-setting'
24
+
25
+ // The framing plugin carries the Framing level: without it in the project the choice is greyed out with the
26
+ // reason as its tip, and the level that plays is Full.
27
+
28
+ const framing = {
29
+ kind: FRAMING_KIND,
30
+ schemaVersion: 1,
31
+ schema: BaseNode.extend({ id: objectId('framing'), type: nodeType(FRAMING_KIND) }),
32
+ category: 'structure',
33
+ defaults: () => ({}),
34
+ capabilities: {},
35
+ geometry: () => null,
36
+ } as unknown as AnyNodeDefinition
37
+
38
+ let restore: () => void = () => {}
39
+ beforeAll(async () => {
40
+ restore = nodeRegistry._snapshot()
41
+ nodeRegistry._reset()
42
+ await loadPlugin({ id: 'fixture:framing', apiVersion: 1, nodes: [framing] })
43
+ })
44
+ afterAll(() => restore())
45
+
46
+ // A server render reads a store's initial state, so the state to draw is put there.
47
+ const sceneInitial = useScene.getInitialState() as { installedPlugins: string[] }
48
+ const revealInitial = useConstructionReveal.getInitialState() as {
49
+ level: string
50
+ chosen: boolean
51
+ speed: number
52
+ }
53
+ const was = { plugins: sceneInitial.installedPlugins, ...revealInitial }
54
+
55
+ function project(options: { installed: boolean; level?: string; chosen?: boolean; speed?: number }) {
56
+ sceneInitial.installedPlugins = options.installed ? ['fixture:framing'] : []
57
+ revealInitial.level = options.level ?? 'full'
58
+ revealInitial.chosen = options.chosen ?? false
59
+ revealInitial.speed = options.speed ?? 1
60
+ }
61
+
62
+ beforeEach(() => project({ installed: false }))
63
+ afterEach(() => {
64
+ sceneInitial.installedPlugins = was.plugins
65
+ Object.assign(revealInitial, { level: was.level, chosen: was.chosen, speed: was.speed })
66
+ })
67
+
68
+ const optionOf = (markup: string, label: string) => {
69
+ const match = new RegExp(`<button[^>]*>(?:(?!</button>).)*${label}(?:(?!</button>).)*</button>`).exec(
70
+ markup,
71
+ )
72
+ return match?.[0] ?? ''
73
+ }
74
+
75
+ describe('the levels the control offers', () => {
76
+ test('without the plugin, Framing is disabled and its tip says it needs the Architect plugin', () => {
77
+ const framingOption = constructionLevelOptions(false).find((o) => o.value === 'framing')!
78
+ expect(framingOption.disabled).toBe(true)
79
+ expect(framingOption.title).toBe(FRAMING_NEEDS_ARCHITECT)
80
+ expect(FRAMING_NEEDS_ARCHITECT).toBe('Framing needs the Architect plugin.')
81
+ for (const option of constructionLevelOptions(false).filter((o) => o.value !== 'framing'))
82
+ expect(option.disabled).toBe(false)
83
+ })
84
+
85
+ test('with the plugin, every level is on offer', () => {
86
+ expect(constructionLevelOptions(true).every((option) => option.disabled === false)).toBe(true)
87
+ })
88
+ })
89
+
90
+ describe('the construction animation control, drawn', () => {
91
+ test('without the plugin: Framing is greyed out and Full is the level that plays', () => {
92
+ project({ installed: false })
93
+ const markup = renderToStaticMarkup(<ConstructionAnimationSetting />)
94
+ expect(optionOf(markup, 'Framing')).toContain('aria-disabled="true"')
95
+ expect(optionOf(markup, 'Full')).toContain('aria-pressed="true"')
96
+ expect(optionOf(markup, 'Full')).not.toContain('aria-disabled')
97
+ })
98
+
99
+ test('with the plugin and no choice of the person’s own: Framing plays, nothing is greyed', () => {
100
+ project({ installed: true })
101
+ const markup = renderToStaticMarkup(<ConstructionAnimationSetting />)
102
+ expect(optionOf(markup, 'Framing')).toContain('aria-pressed="true"')
103
+ expect(markup).not.toContain('aria-disabled')
104
+ })
105
+
106
+ test('with the plugin and a choice of Full: Full is kept', () => {
107
+ project({ installed: true, level: 'full', chosen: true })
108
+ const markup = renderToStaticMarkup(<ConstructionAnimationSetting />)
109
+ expect(optionOf(markup, 'Full')).toContain('aria-pressed="true"')
110
+ expect(optionOf(markup, 'Framing')).toContain('aria-pressed="false"')
111
+ })
112
+
113
+ test('a saved choice of Framing falls back to Full while the plugin is gone, greyed out', () => {
114
+ project({ installed: false, level: 'framing', chosen: true })
115
+ const markup = renderToStaticMarkup(<ConstructionAnimationSetting />)
116
+ expect(optionOf(markup, 'Full')).toContain('aria-pressed="true"')
117
+ expect(optionOf(markup, 'Framing')).toContain('aria-disabled="true"')
118
+ expect(optionOf(markup, 'Framing')).toContain('aria-pressed="false"')
119
+ })
120
+ })
121
+
122
+ describe('clicking the level that plays', () => {
123
+ // A saved Framing plays as Full while the plugin is gone; the control shows Full pressed, and a click on
124
+ // the pressed option is still a click. It must not turn the kept Framing into a chosen Full.
125
+ const click = (level: ConstructionRevealLevel, installed: boolean) =>
126
+ chooseConstructionLevel(level, installed)
127
+
128
+ beforeEach(() => useConstructionReveal.setState({ level: 'framing', chosen: true }))
129
+ afterEach(() => useConstructionReveal.setState({ level: 'full', chosen: false }))
130
+
131
+ test('the Full shown for a saved Framing keeps the Framing when it is clicked, and it plays again with the plugin', () => {
132
+ click('full', false)
133
+ expect(useConstructionReveal.getState()).toMatchObject({ level: 'framing', chosen: true })
134
+ expect(resolveConstructionLevel(useConstructionReveal.getState(), true)).toBe('framing')
135
+ })
136
+
137
+ test('another level is a choice and replaces it', () => {
138
+ click('simple', false)
139
+ expect(useConstructionReveal.getState()).toMatchObject({ level: 'simple', chosen: true })
140
+ })
141
+
142
+ test('with no choice of the person’s own, a click on the level that plays is a choice of it', () => {
143
+ useConstructionReveal.setState({ level: 'full', chosen: false })
144
+ click('full', false)
145
+ expect(useConstructionReveal.getState()).toMatchObject({ level: 'full', chosen: true })
146
+ })
147
+ })
148
+
149
+ describe('the Pro badge', () => {
150
+ // The plugin that draws the framing is a Pro one: the option carries the plugin manager's mark,
151
+ // greyed out or not, and no other level does.
152
+ test('Framing carries it, greyed out or on offer; the other levels do not', () => {
153
+ for (const installed of [false, true]) {
154
+ project({ installed })
155
+ const markup = renderToStaticMarkup(<ConstructionAnimationSetting />)
156
+ expect(optionOf(markup, 'Framing')).toMatch(/>Pro</)
157
+ for (const label of ['Off', 'Simple', 'Full']) expect(optionOf(markup, label)).not.toMatch(/>Pro</)
158
+ }
159
+ })
160
+ })
161
+
162
+ describe('the animation speed control, drawn', () => {
163
+ test('offers the five steps and shows the saved one', () => {
164
+ project({ installed: true, speed: 1.5 })
165
+ const markup = renderToStaticMarkup(<AnimationSpeedSetting />)
166
+ for (const label of ['×0.5', '×0.75', '×1', '×1.5', '×2']) expect(markup).toContain(label)
167
+ expect(optionOf(markup, '×1.5')).toContain('aria-pressed="true"')
168
+ expect(optionOf(markup, '×0.5')).toContain('aria-pressed="false"')
169
+ })
170
+ })
@@ -0,0 +1,92 @@
1
+ 'use client'
2
+
3
+ import type { ConstructionRevealLevel } from '@pascal-app/viewer'
4
+ import {
5
+ chooseConstructionLevel,
6
+ useConstructionRevealLevel,
7
+ useFramingAvailable,
8
+ } from '../../../../../store/use-construction-reveal'
9
+ import { SegmentedControl } from '../../../controls/segmented-control'
10
+ import { Badge } from '../../../primitives/badge'
11
+ import { Tooltip, TooltipContent, TooltipTrigger } from '../../../primitives/tooltip'
12
+
13
+ /** Why Framing is greyed out. */
14
+ export const FRAMING_NEEDS_ARCHITECT = 'Framing needs the Architect plugin.'
15
+
16
+ const OPTIONS: { label: string; value: ConstructionRevealLevel; title: string; badge?: string }[] = [
17
+ { label: 'Off', value: 'off', title: 'What the AI builds appears at once.' },
18
+ {
19
+ label: 'Simple',
20
+ value: 'simple',
21
+ title: 'Walls rise and the rest grows in, piece by piece. No falls, no dust, no sound.',
22
+ },
23
+ {
24
+ label: 'Full',
25
+ value: 'full',
26
+ title: 'Pieces drop into place with dust, the roof assembles, one sound per phase.',
27
+ },
28
+ {
29
+ label: 'Framing',
30
+ value: 'framing',
31
+ title: 'Full, plus the framing members dropping in first, where the framing plugin draws them.',
32
+ // The plugin that draws the framing is a Pro one.
33
+ badge: 'Pro',
34
+ },
35
+ ]
36
+
37
+ /**
38
+ * The four levels as the control offers them: Framing is greyed out, with the reason as its tip,
39
+ * where the project has no plugin that draws the framing.
40
+ */
41
+ export function constructionLevelOptions(framingAvailable: boolean) {
42
+ return OPTIONS.map((option) =>
43
+ option.value === 'framing' && !framingAvailable
44
+ ? { ...option, title: FRAMING_NEEDS_ARCHITECT, disabled: true }
45
+ : { ...option, disabled: false },
46
+ )
47
+ }
48
+
49
+ /** How much of an AI build plays as a construction. */
50
+ export function ConstructionAnimationSetting() {
51
+ // What plays: the person's choice, or Framing where the framing plugin is installed and Full where it is not.
52
+ const level = useConstructionRevealLevel()
53
+ const framingAvailable = useFramingAvailable()
54
+ const current = OPTIONS.find((option) => option.value === level)
55
+ const options = constructionLevelOptions(framingAvailable).map((option) => {
56
+ const label = option.badge ? (
57
+ <span className="inline-flex items-center gap-1.5">
58
+ {option.label}
59
+ <Badge label={option.badge} />
60
+ </span>
61
+ ) : (
62
+ option.label
63
+ )
64
+ if (!option.disabled) return { ...option, label }
65
+ return {
66
+ ...option,
67
+ title: undefined,
68
+ label: (
69
+ <Tooltip>
70
+ <TooltipTrigger asChild>
71
+ <span>{label}</span>
72
+ </TooltipTrigger>
73
+ <TooltipContent>{option.title}</TooltipContent>
74
+ </Tooltip>
75
+ ),
76
+ }
77
+ })
78
+ return (
79
+ <div className="space-y-2 py-1">
80
+ <div className="space-y-0.5">
81
+ <p className="font-medium text-sm">Construction animation</p>
82
+ <p className="text-muted-foreground text-xs">{current?.title}</p>
83
+ </div>
84
+ <SegmentedControl
85
+ aria-label="Construction animation"
86
+ onChange={(next) => chooseConstructionLevel(next, framingAvailable)}
87
+ options={options}
88
+ value={level}
89
+ />
90
+ </div>
91
+ )
92
+ }