@pascal-app/editor 1.0.0-beta.4 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (283) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +15 -8
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
  6. package/src/components/editor/bake-exporter.tsx +1 -0
  7. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  8. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  9. package/src/components/editor/capture-camera-rig.tsx +59 -0
  10. package/src/components/editor/custom-camera-controls.tsx +20 -11
  11. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  12. package/src/components/editor/editor-layout-v2.tsx +17 -5
  13. package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
  14. package/src/components/editor/export-manager.tsx +175 -33
  15. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  16. package/src/components/editor/first-person-controls.tsx +182 -57
  17. package/src/components/editor/floating-action-menu.tsx +55 -55
  18. package/src/components/editor/floorplan-background-selection.test.ts +31 -4
  19. package/src/components/editor/floorplan-background-selection.ts +27 -7
  20. package/src/components/editor/floorplan-panel.tsx +81 -57
  21. package/src/components/editor/grid.tsx +12 -7
  22. package/src/components/editor/group-actions.ts +34 -50
  23. package/src/components/editor/group-floating-action-menu.tsx +30 -15
  24. package/src/components/editor/group-move-3d.ts +51 -21
  25. package/src/components/editor/group-transform-shared.test.ts +78 -0
  26. package/src/components/editor/group-transform-shared.ts +74 -7
  27. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  28. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  29. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  30. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  31. package/src/components/editor/handles/resize-snap.ts +10 -1
  32. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  33. package/src/components/editor/index.tsx +252 -46
  34. package/src/components/editor/measurement-pill.tsx +12 -12
  35. package/src/components/editor/node-action-menu.tsx +42 -1
  36. package/src/components/editor/node-arrow-handles.tsx +47 -58
  37. package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
  38. package/src/components/editor/selection-manager.tsx +132 -47
  39. package/src/components/editor/site-edge-labels.tsx +2 -1
  40. package/src/components/editor/snapshot-capture-overlay.tsx +309 -41
  41. package/src/components/editor/snapshot-capture.test.ts +422 -0
  42. package/src/components/editor/snapshot-capture.ts +145 -0
  43. package/src/components/editor/thumbnail-generator.tsx +295 -232
  44. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  45. package/src/components/editor/wall-measurement-label.tsx +3 -2
  46. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  47. package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
  48. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  49. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  50. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  51. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  52. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  53. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  54. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  55. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  56. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +204 -114
  57. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  58. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  59. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  60. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  61. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  62. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  63. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  64. package/src/components/systems/selection-affordance-services.ts +19 -0
  65. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  66. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  67. package/src/components/tools/fence/fence-drafting.ts +6 -25
  68. package/src/components/tools/item/draft-creation.test.ts +12 -0
  69. package/src/components/tools/item/draft-creation.ts +10 -0
  70. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  71. package/src/components/tools/item/face-host-commit.ts +56 -0
  72. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  73. package/src/components/tools/item/face-host-preview.ts +82 -0
  74. package/src/components/tools/item/move-tool.tsx +4 -3
  75. package/src/components/tools/item/placement-math.test.ts +11 -1
  76. package/src/components/tools/item/placement-math.ts +0 -20
  77. package/src/components/tools/item/placement-strategies.test.ts +494 -0
  78. package/src/components/tools/item/placement-strategies.ts +192 -24
  79. package/src/components/tools/item/placement-surface.test.ts +61 -0
  80. package/src/components/tools/item/placement-surface.ts +24 -0
  81. package/src/components/tools/item/placement-types.ts +6 -0
  82. package/src/components/tools/item/test-face-host.ts +121 -0
  83. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  84. package/src/components/tools/item/use-draft-node.ts +26 -4
  85. package/src/components/tools/item/use-placement-coordinator.tsx +447 -180
  86. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  87. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  88. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  89. package/src/components/tools/registry-tool-context.tsx +30 -0
  90. package/src/components/tools/select/box-select-tool.tsx +3 -1
  91. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  92. package/src/components/tools/select/marquee-footprint.ts +29 -0
  93. package/src/components/tools/shared/placement-box.tsx +63 -8
  94. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  95. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  96. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  97. package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
  98. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  99. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  100. package/src/components/tools/stair/stair-tool.tsx +127 -74
  101. package/src/components/tools/tool-manager.tsx +25 -8
  102. package/src/components/tools/wall/wall-drafting.test.ts +527 -4
  103. package/src/components/tools/wall/wall-drafting.ts +55 -462
  104. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  105. package/src/components/ui/action-menu/index.tsx +11 -1
  106. package/src/components/ui/command-palette/editor-commands.tsx +12 -13
  107. package/src/components/ui/command-palette/index.tsx +7 -2
  108. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  109. package/src/components/ui/controls/material-picker.tsx +4 -4
  110. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  111. package/src/components/ui/controls/segmented-control.tsx +8 -1
  112. package/src/components/ui/controls/slider-control.tsx +19 -3
  113. package/src/components/ui/controls/toggle-control.tsx +18 -5
  114. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  115. package/src/components/ui/floating-level-selector.tsx +23 -6
  116. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  117. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  118. package/src/components/ui/helpers/item-helper.tsx +1 -1
  119. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  120. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  121. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  122. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  123. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  124. package/src/components/ui/panels/multi-field-value.ts +163 -0
  125. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  126. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  127. package/src/components/ui/panels/multi-selection-panel.tsx +78 -26
  128. package/src/components/ui/panels/node-display.ts +4 -0
  129. package/src/components/ui/panels/panel-manager.tsx +77 -3
  130. package/src/components/ui/panels/panel-wrapper.tsx +228 -12
  131. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  132. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  133. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  134. package/src/components/ui/panels/reference-panel.tsx +31 -1
  135. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  136. package/src/components/ui/scene-loader.tsx +35 -0
  137. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
  138. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  139. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +31 -23
  140. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  141. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  142. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  143. package/src/components/ui/sidebar/panels/site-panel/index.tsx +128 -46
  144. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  145. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  146. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  147. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +22 -7
  148. package/src/components/ui/sidebar/tab-bar.tsx +9 -1
  149. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  150. package/src/components/viewer/floorplan-compass-button.tsx +50 -0
  151. package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
  152. package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
  153. package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
  154. package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
  155. package/src/components/viewer/floorplan-preview.tsx +1069 -0
  156. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
  157. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  158. package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
  159. package/src/components/viewer/viewer-stage-modes.ts +49 -0
  160. package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
  161. package/src/components/viewer/viewer-stage.test.tsx +47 -0
  162. package/src/components/viewer/viewer-stage.tsx +221 -0
  163. package/src/components/viewer-overlay.tsx +13 -30
  164. package/src/components/viewer-zone-system.tsx +6 -2
  165. package/src/components/walkthrough-hud.tsx +19 -10
  166. package/src/hooks/use-auto-save.test.ts +122 -1
  167. package/src/hooks/use-auto-save.ts +119 -13
  168. package/src/hooks/use-ceiling-events.test.ts +122 -0
  169. package/src/hooks/use-drag-action.ts +17 -3
  170. package/src/hooks/use-grid-events.test.ts +240 -0
  171. package/src/hooks/use-grid-events.ts +190 -11
  172. package/src/hooks/use-keyboard.test.ts +244 -0
  173. package/src/hooks/use-keyboard.ts +164 -63
  174. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  175. package/src/hooks/use-save-shortcut.ts +33 -0
  176. package/src/index.tsx +109 -4
  177. package/src/lib/active-placement-surface.test.ts +16 -0
  178. package/src/lib/active-placement-surface.ts +14 -0
  179. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  180. package/src/lib/ceiling-surface-raycast.ts +12 -0
  181. package/src/lib/contextual-help-extension.ts +17 -0
  182. package/src/lib/contextual-help.test.ts +8 -0
  183. package/src/lib/contextual-help.ts +8 -0
  184. package/src/lib/continuation.test.ts +11 -0
  185. package/src/lib/continuation.ts +8 -1
  186. package/src/lib/direct-manipulation.test.ts +132 -0
  187. package/src/lib/direct-manipulation.ts +38 -1
  188. package/src/lib/editor-api.ts +6 -3
  189. package/src/lib/elevation-guides.test.ts +28 -1
  190. package/src/lib/elevation-guides.ts +22 -7
  191. package/src/lib/floating-menu-scale.test.ts +29 -0
  192. package/src/lib/floating-menu-scale.ts +14 -0
  193. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  194. package/src/lib/floorplan/apply-alignment.ts +10 -1
  195. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  196. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  197. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  198. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  199. package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
  200. package/src/lib/floorplan/floorplan-readonly.ts +85 -0
  201. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  202. package/src/lib/floorplan-grid-event-point.ts +13 -0
  203. package/src/lib/glb-export.test.ts +1169 -21
  204. package/src/lib/glb-export.ts +629 -101
  205. package/src/lib/grid-event-presentation.ts +16 -0
  206. package/src/lib/history.test.ts +523 -77
  207. package/src/lib/history.ts +133 -14
  208. package/src/lib/host-tree-children.test.ts +27 -0
  209. package/src/lib/host-tree-children.ts +79 -0
  210. package/src/lib/inspector-card-mode.test.ts +92 -0
  211. package/src/lib/inspector-card-mode.ts +60 -0
  212. package/src/lib/interaction/hot-set.test.ts +10 -1
  213. package/src/lib/interaction/hot-set.ts +3 -1
  214. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  215. package/src/lib/interaction/overlay-policy.ts +16 -0
  216. package/src/lib/interaction/registered-drafting.ts +36 -0
  217. package/src/lib/interaction/scope.ts +31 -0
  218. package/src/lib/level-print-export.test.ts +484 -0
  219. package/src/lib/level-print-export.ts +526 -0
  220. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  221. package/src/lib/local-project-presentation-persistence.ts +335 -0
  222. package/src/lib/material-paint.ts +2 -0
  223. package/src/lib/measurement-parser.ts +3 -0
  224. package/src/lib/measurements.test.ts +4 -0
  225. package/src/lib/model-export.ts +29 -0
  226. package/src/lib/paint-preview-owner.ts +71 -0
  227. package/src/lib/paint-scope.test.ts +58 -2
  228. package/src/lib/paint-scope.ts +8 -2
  229. package/src/lib/planar-cursor-placement.test.ts +178 -1
  230. package/src/lib/planar-cursor-placement.ts +61 -5
  231. package/src/lib/plugin-panels.test.ts +92 -1
  232. package/src/lib/plugin-panels.ts +48 -0
  233. package/src/lib/portable-export.test.ts +366 -0
  234. package/src/lib/portable-export.ts +969 -0
  235. package/src/lib/print-3mf.test.ts +86 -0
  236. package/src/lib/print-3mf.ts +161 -0
  237. package/src/lib/print-content-scope.test.ts +107 -0
  238. package/src/lib/print-content-scope.ts +90 -0
  239. package/src/lib/print-export.test.ts +294 -0
  240. package/src/lib/print-export.ts +735 -0
  241. package/src/lib/print-feature-thickness.test.ts +147 -0
  242. package/src/lib/print-feature-thickness.ts +180 -0
  243. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  244. package/src/lib/print-golden-house.test.ts +249 -0
  245. package/src/lib/print-roof-solids.test.ts +133 -0
  246. package/src/lib/print-roof-solids.ts +630 -0
  247. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  248. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  249. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  250. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  251. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  252. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  253. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  254. package/src/lib/print-shell-compiler.ts +273 -0
  255. package/src/lib/print-wall-solids.test.ts +194 -0
  256. package/src/lib/print-wall-solids.ts +328 -0
  257. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  258. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  259. package/src/lib/scene.ts +22 -8
  260. package/src/lib/selection-routing.test.ts +125 -6
  261. package/src/lib/selection-routing.ts +34 -1
  262. package/src/lib/session-groups.test.ts +88 -0
  263. package/src/lib/session-groups.ts +201 -0
  264. package/src/lib/sfx-bus.ts +4 -0
  265. package/src/lib/sfx-player.ts +8 -0
  266. package/src/lib/snapping-mode.test.ts +32 -4
  267. package/src/lib/snapping-mode.ts +11 -7
  268. package/src/lib/structured-clone-fallback.test.ts +45 -0
  269. package/src/lib/structured-clone-fallback.ts +24 -0
  270. package/src/lib/usdz-export.ts +38 -0
  271. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  272. package/src/store/live-draft-preview-stores.test.ts +3 -0
  273. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  274. package/src/store/tool-mode.test.ts +87 -0
  275. package/src/store/use-camera-hint-focus.ts +48 -0
  276. package/src/store/use-editor.tsx +299 -188
  277. package/src/store/use-floorplan-draft-preview.ts +9 -0
  278. package/src/store/use-interaction-scope.test.ts +23 -1
  279. package/src/store/use-interaction-scope.ts +63 -2
  280. package/src/store/use-placement-preview.ts +56 -3
  281. package/src/store/use-session-groups.ts +103 -0
  282. package/src/components/tools/roof/roof-tool.tsx +0 -656
  283. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -37,7 +37,7 @@ import {
37
37
  Video,
38
38
  } from 'lucide-react'
39
39
  import { useEffect } from 'react'
40
- import { runRedo, runUndo } from '../../../lib/history'
40
+ import { getHistoryCommandState, runRedo, runUndo } from '../../../lib/history'
41
41
  import { deleteLevelWithFallbackSelection } from '../../../lib/level-selection'
42
42
  import { useCommandRegistry } from '../../../store/use-command-registry'
43
43
  import type { StructureTool } from '../../../store/use-editor'
@@ -49,10 +49,9 @@ export function EditorCommands() {
49
49
  const { navigateTo, setInputValue, setOpen } = useCommandPalette()
50
50
 
51
51
  const setPhase = useEditor((s) => s.setPhase)
52
- const setMode = useEditor((s) => s.setMode)
53
- const setTool = useEditor((s) => s.setTool)
52
+ const armToolMode = useEditor((s) => s.armToolMode)
53
+ const armMaterialPaint = useEditor((s) => s.armMaterialPaint)
54
54
  const setStructureLayer = useEditor((s) => s.setStructureLayer)
55
- const primeMaterialPaintFromSelection = useEditor((s) => s.primeMaterialPaintFromSelection)
56
55
  const isPreviewMode = useEditor((s) => s.isPreviewMode)
57
56
  const setPreviewMode = useEditor((s) => s.setPreviewMode)
58
57
 
@@ -68,9 +67,9 @@ export function EditorCommands() {
68
67
  const activateTool = (tool: StructureTool) => {
69
68
  run(() => {
70
69
  setPhase('structure')
71
- setMode('build')
72
70
  if (tool === 'zone') setStructureLayer('zones')
73
- setTool(tool)
71
+ else setStructureLayer('elements')
72
+ armToolMode({ mode: 'build', tool })
74
73
  })
75
74
  }
76
75
 
@@ -163,10 +162,9 @@ export function EditorCommands() {
163
162
  shortcut: ['P'],
164
163
  execute: () =>
165
164
  run(() => {
166
- primeMaterialPaintFromSelection()
167
165
  setPhase('structure')
168
166
  setStructureLayer('elements')
169
- setMode('material-paint')
167
+ armMaterialPaint()
170
168
  }),
171
169
  },
172
170
  {
@@ -176,9 +174,8 @@ export function EditorCommands() {
176
174
  icon: <Mountain className="h-4 w-4" />,
177
175
  keywords: ['terrain', 'ground', 'elevation', 'sculpt', 'hill', 'slope', 'grade', 'dig'],
178
176
  shortcut: ['G'],
179
- // No `setPhase`: `setMode` moves to the site phase itself, and doing it
180
- // here would set the phase twice with a mode reset in between.
181
- execute: () => run(() => setMode('terrain-sculpt')),
177
+ // The ToolMode transition moves to the site phase itself.
178
+ execute: () => run(() => armToolMode({ mode: 'terrain-sculpt' })),
182
179
  },
183
180
 
184
181
  // ── Levels ───────────────────────────────────────────────────────────
@@ -351,6 +348,7 @@ export function EditorCommands() {
351
348
  group: 'History',
352
349
  icon: <Undo2 className="h-4 w-4" />,
353
350
  keywords: ['undo', 'revert', 'back'],
351
+ when: () => getHistoryCommandState().canUndo,
354
352
  execute: () => run(() => runUndo()),
355
353
  },
356
354
  {
@@ -359,6 +357,7 @@ export function EditorCommands() {
359
357
  group: 'History',
360
358
  icon: <Redo2 className="h-4 w-4" />,
361
359
  keywords: ['redo', 'forward', 'repeat'],
360
+ when: () => getHistoryCommandState().canRedo,
362
361
  execute: () => run(() => runRedo()),
363
362
  },
364
363
 
@@ -426,8 +425,8 @@ export function EditorCommands() {
426
425
  setInputValue,
427
426
  setOpen,
428
427
  setPhase,
429
- setMode,
430
- setTool,
428
+ armToolMode,
429
+ armMaterialPaint,
431
430
  setStructureLayer,
432
431
  isPreviewMode,
433
432
  setPreviewMode,
@@ -2,7 +2,7 @@
2
2
 
3
3
  import type { AnyNodeId, LevelNode } from '@pascal-app/core'
4
4
  import { useScene } from '@pascal-app/core'
5
- import { useViewer } from '@pascal-app/viewer'
5
+ import { markPerfAction, useViewer } from '@pascal-app/viewer'
6
6
  import { Command, useCommandState } from 'cmdk'
7
7
  import { ChevronRight, Search } from 'lucide-react'
8
8
  import type { ReactNode } from 'react'
@@ -408,7 +408,12 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
408
408
  key={level.id}
409
409
  label={getLevelDisplayName(level)}
410
410
  onSelect={() =>
411
- run(() => useViewer.getState().setSelection({ levelId: level.id }))
411
+ run(() => {
412
+ if (level.id !== useViewer.getState().selection.levelId) {
413
+ markPerfAction('level-switch', level.id)
414
+ }
415
+ useViewer.getState().setSelection({ levelId: level.id })
416
+ })
412
417
  }
413
418
  />
414
419
  ))}
@@ -35,7 +35,7 @@ export type MaterialPaintPanelProps = {
35
35
  export function MaterialPaintPanel({ onCreateMaterialRequest }: MaterialPaintPanelProps) {
36
36
  const activePaintMaterial = useEditor((state) => state.activePaintMaterial)
37
37
  const activePaintTarget = useEditor((state) => state.activePaintTarget)
38
- const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial)
38
+ const armMaterialPaint = useEditor((state) => state.armMaterialPaint)
39
39
  const setActivePaintTarget = useEditor((state) => state.setActivePaintTarget)
40
40
  const paintEraser = useEditor((state) => state.paintEraser)
41
41
  const setPaintEraser = useEditor((state) => state.setPaintEraser)
@@ -81,7 +81,7 @@ export function MaterialPaintPanel({ onCreateMaterialRequest }: MaterialPaintPan
81
81
  },
82
82
  },
83
83
  })
84
- setActivePaintMaterial({ materialPreset: toSceneMaterialRef(id), sourceTarget: activePaintTarget })
84
+ armMaterialPaint({ materialPreset: toSceneMaterialRef(id), sourceTarget: activePaintTarget })
85
85
  setAutoEditMaterialId(id)
86
86
  }
87
87
 
@@ -112,11 +112,13 @@ export function MaterialPaintPanel({ onCreateMaterialRequest }: MaterialPaintPan
112
112
  </div>
113
113
 
114
114
  {/* Scrolls: category tabs (fixed inside) + catalog grid (the scroll). */}
115
- <div className="min-h-0 flex-1">
115
+ {/* A stable hook for host-app onboarding to point at. Static, and read
116
+ only from outside: nothing here depends on it. */}
117
+ <div className="min-h-0 flex-1" data-guide-target="paint-material">
116
118
  <MaterialPicker
117
119
  onCreateMaterialRequest={onCreateMaterialRequest}
118
120
  onSelectMaterialPreset={(materialPreset) => {
119
- setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget })
121
+ armMaterialPaint({ materialPreset, sourceTarget: activePaintTarget })
120
122
  }}
121
123
  selectedMaterialPreset={activePaintMaterial?.materialPreset}
122
124
  />
@@ -17,7 +17,7 @@ import { Plus } from 'lucide-react'
17
17
  import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
18
18
  import { triggerSFX } from '../../../lib/sfx-bus'
19
19
 
20
- export type MaterialSourceFilter = 'all' | MaterialSource
20
+ export type MaterialSourceFilter = MaterialSource
21
21
 
22
22
  export type MaterialPickerProps = {
23
23
  selectedMaterialPreset?: string
@@ -28,8 +28,9 @@ export type MaterialPickerProps = {
28
28
  onCreateMaterialRequest?: () => void
29
29
  }
30
30
 
31
+ // No 'All': the browse surfaces (Items / Rooms / Build) dropped it and default
32
+ // to the Pascal library — the combined list buried the curated set.
31
33
  const SOURCE_FILTERS: { id: MaterialSourceFilter; label: string }[] = [
32
- { id: 'all', label: 'All' },
33
34
  { id: 'pascal', label: 'Pascal' },
34
35
  { id: 'mine', label: 'Mine' },
35
36
  { id: 'workspace', label: 'Workspace' },
@@ -41,7 +42,6 @@ function getCategoryLabel(category: (typeof MATERIAL_CATEGORIES)[number]) {
41
42
  }
42
43
 
43
44
  function filterBySource(items: MaterialCatalogItem[], filter: MaterialSourceFilter) {
44
- if (filter === 'all') return items
45
45
  return items.filter((item) => (item.source ?? 'pascal') === filter)
46
46
  }
47
47
 
@@ -60,7 +60,7 @@ export function MaterialPicker({
60
60
  const [selectedCategory, setSelectedCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>(
61
61
  MATERIAL_CATEGORIES[0],
62
62
  )
63
- const [sourceFilter, setSourceFilter] = useState<MaterialSourceFilter>('all')
63
+ const [sourceFilter, setSourceFilter] = useState<MaterialSourceFilter>('pascal')
64
64
  // Version counter so host registrations/unregistrations re-render the picker.
65
65
  const libraryVersion = useSyncExternalStore(
66
66
  subscribeLibraryMaterials,
@@ -33,7 +33,7 @@ export function SceneMaterialList({ autoEditId }: { autoEditId?: SceneMaterialId
33
33
  const removeSceneMaterial = useScene((state) => state.removeSceneMaterial)
34
34
  const activePaintTarget = useEditor((state) => state.activePaintTarget)
35
35
  const activePaintRef = useEditor((state) => state.activePaintMaterial?.materialPreset)
36
- const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial)
36
+ const armMaterialPaint = useEditor((state) => state.armMaterialPaint)
37
37
 
38
38
  const materialEntries = useMemo(
39
39
  () => Object.entries(materials) as [SceneMaterialId, SceneMaterial][],
@@ -76,7 +76,7 @@ export function SceneMaterialList({ autoEditId }: { autoEditId?: SceneMaterialId
76
76
  key={id}
77
77
  removeSceneMaterial={removeSceneMaterial}
78
78
  sceneMaterial={sceneMaterial}
79
- setActivePaintMaterial={setActivePaintMaterial}
79
+ armMaterialPaint={armMaterialPaint}
80
80
  updateSceneMaterial={updateSceneMaterial}
81
81
  usageCount={usageCounts.get(id) ?? 0}
82
82
  />
@@ -95,7 +95,7 @@ function SceneMaterialRow({
95
95
  addSceneMaterial,
96
96
  updateSceneMaterial,
97
97
  removeSceneMaterial,
98
- setActivePaintMaterial,
98
+ armMaterialPaint,
99
99
  }: {
100
100
  id: SceneMaterialId
101
101
  sceneMaterial: SceneMaterial
@@ -106,7 +106,7 @@ function SceneMaterialRow({
106
106
  addSceneMaterial: ReturnType<typeof useScene.getState>['addSceneMaterial']
107
107
  updateSceneMaterial: ReturnType<typeof useScene.getState>['updateSceneMaterial']
108
108
  removeSceneMaterial: ReturnType<typeof useScene.getState>['removeSceneMaterial']
109
- setActivePaintMaterial: ReturnType<typeof useEditor.getState>['setActivePaintMaterial']
109
+ armMaterialPaint: ReturnType<typeof useEditor.getState>['armMaterialPaint']
110
110
  }) {
111
111
  // A freshly-created material (via "+ Custom") mounts with its editor open.
112
112
  const [isEditingMaterial, setIsEditingMaterial] = useState(autoEdit)
@@ -141,6 +141,7 @@ function SceneMaterialRow({
141
141
  className={`rounded-md border border-border/60 bg-background/40 p-2 ${
142
142
  isActive ? 'ring-1 ring-primary ring-inset' : ''
143
143
  }`}
144
+ data-testid={`scene-material-row-${id}`}
144
145
  >
145
146
  <div className="flex items-center gap-2">
146
147
  <span
@@ -174,7 +175,7 @@ function SceneMaterialRow({
174
175
  <Button
175
176
  aria-label="Paint with"
176
177
  onClick={() =>
177
- setActivePaintMaterial({
178
+ armMaterialPaint({
178
179
  materialPreset: toSceneMaterialRef(id),
179
180
  sourceTarget: activePaintTarget,
180
181
  })
@@ -7,6 +7,8 @@ interface SegmentedControlProps<T extends string> {
7
7
  onChange: (value: T) => void
8
8
  options: { label: React.ReactNode; value: T }[]
9
9
  className?: string
10
+ disabled?: boolean
11
+ mixed?: boolean
10
12
  }
11
13
 
12
14
  export function SegmentedControl<T extends string>({
@@ -14,16 +16,19 @@ export function SegmentedControl<T extends string>({
14
16
  onChange,
15
17
  options,
16
18
  className,
19
+ disabled = false,
20
+ mixed = false,
17
21
  }: SegmentedControlProps<T>) {
18
22
  return (
19
23
  <div
20
24
  className={cn(
21
25
  'flex h-9 w-full items-center rounded-lg border border-border/50 bg-[#2C2C2E] p-[3px]',
26
+ disabled && 'opacity-60',
22
27
  className,
23
28
  )}
24
29
  >
25
30
  {options.map((option) => {
26
- const isSelected = value === option.value
31
+ const isSelected = !mixed && value === option.value
27
32
  return (
28
33
  <button
29
34
  className={cn(
@@ -31,7 +36,9 @@ export function SegmentedControl<T extends string>({
31
36
  isSelected
32
37
  ? 'bg-[#3e3e3e] text-foreground shadow-sm ring-1 ring-border/50'
33
38
  : 'text-muted-foreground hover:bg-white/5 hover:text-foreground',
39
+ disabled && 'cursor-not-allowed hover:bg-transparent hover:text-muted-foreground',
34
40
  )}
41
+ disabled={disabled}
35
42
  key={option.value}
36
43
  onClick={() => onChange(option.value)}
37
44
  type="button"
@@ -22,6 +22,7 @@ interface SliderControlProps {
22
22
  className?: string
23
23
  unit?: string
24
24
  restoreOnCommit?: boolean
25
+ mixed?: boolean
25
26
  }
26
27
 
27
28
  function stepPrecision(s: number): number {
@@ -64,6 +65,7 @@ export function SliderControl({
64
65
  className,
65
66
  unit = '',
66
67
  restoreOnCommit = true,
68
+ mixed = false,
67
69
  }: SliderControlProps) {
68
70
  // Display/storage conversion so the value honors the metric/imperial toggle.
69
71
  // `value`, `onChange`, `onCommit`, `min`/`max`/`clamp` are always in the
@@ -76,6 +78,10 @@ export function SliderControl({
76
78
  const [isDragging, setIsDragging] = useState(false)
77
79
  const [isHovered, setIsHovered] = useState(false)
78
80
  const [inputValue, setInputValue] = useState(toDisplay(value).toFixed(precision))
81
+ // Live readout while dragging. Multi-edit previews write live overrides
82
+ // instead of the scene, so `value` would otherwise stay frozen even though
83
+ // the meshes are moving.
84
+ const [dragDisplay, setDragDisplay] = useState<number | null>(null)
79
85
 
80
86
  const dragRef = useRef<{
81
87
  // Original value at drag start — preserved across modifier re-anchors so
@@ -89,8 +95,9 @@ export function SliderControl({
89
95
  stepMultiplier: number
90
96
  } | null>(null)
91
97
  const labelRef = useRef<HTMLDivElement>(null)
92
- const valueRef = useRef(value)
93
- valueRef.current = value
98
+ const shown = dragDisplay ?? value
99
+ const valueRef = useRef(shown)
100
+ valueRef.current = shown
94
101
 
95
102
  const clamp = useCallback((val: number) => Math.min(Math.max(val, min), max), [min, max])
96
103
  // Apply a signed display-unit delta to a stored value, rounding in the
@@ -185,6 +192,7 @@ export function SliderControl({
185
192
  const newValue = applyDisplayDelta(anchorValue, (dx / 4) * s, s)
186
193
  if (newValue !== valueRef.current) {
187
194
  valueRef.current = newValue
195
+ setDragDisplay(newValue)
188
196
  onChange(newValue)
189
197
  }
190
198
  },
@@ -209,6 +217,7 @@ export function SliderControl({
209
217
  useScene.temporal.getState().resume()
210
218
  onCommit?.(finalVal)
211
219
  }
220
+ setDragDisplay(null)
212
221
  },
213
222
  [onChange, onCommit, restoreOnCommit],
214
223
  )
@@ -272,7 +281,7 @@ export function SliderControl({
272
281
  [submitValue, value, precision, step, applyDisplayDelta, onChange, toDisplay],
273
282
  )
274
283
 
275
- const displayValue = toDisplay(value)
284
+ const displayValue = toDisplay(shown)
276
285
 
277
286
  return (
278
287
  <div
@@ -340,6 +349,13 @@ export function SliderControl({
340
349
  </span>
341
350
  )}
342
351
  </>
352
+ ) : mixed && !isDragging ? (
353
+ <div
354
+ className="flex cursor-text items-center text-muted-foreground transition-colors hover:text-foreground"
355
+ onClick={handleValueClick}
356
+ >
357
+ <span className="font-mono tracking-tight">Mixed</span>
358
+ </div>
343
359
  ) : (
344
360
  <div
345
361
  className="flex cursor-text items-center text-foreground/60 transition-colors hover:text-foreground"
@@ -8,9 +8,16 @@ interface ToggleControlProps {
8
8
  checked: boolean
9
9
  onChange: (checked: boolean) => void
10
10
  className?: string
11
+ mixed?: boolean
11
12
  }
12
13
 
13
- export function ToggleControl({ label, checked, onChange, className }: ToggleControlProps) {
14
+ export function ToggleControl({
15
+ label,
16
+ checked,
17
+ onChange,
18
+ className,
19
+ mixed = false,
20
+ }: ToggleControlProps) {
14
21
  return (
15
22
  <div
16
23
  className={cn(
@@ -26,12 +33,18 @@ export function ToggleControl({ label, checked, onChange, className }: ToggleCon
26
33
  <div
27
34
  className={cn(
28
35
  'flex h-5 w-5 items-center justify-center rounded-[4px] border transition-all duration-200',
29
- checked
30
- ? 'border-primary bg-primary text-primary-foreground'
31
- : 'border-border bg-black/20 text-transparent group-hover:border-muted-foreground',
36
+ mixed
37
+ ? 'border-muted-foreground/70 bg-black/20 text-muted-foreground'
38
+ : checked
39
+ ? 'border-primary bg-primary text-primary-foreground'
40
+ : 'border-border bg-black/20 text-transparent group-hover:border-muted-foreground',
32
41
  )}
33
42
  >
34
- <Check className="h-3.5 w-3.5" strokeWidth={3} />
43
+ {mixed ? (
44
+ <div className="h-0.5 w-2.5 rounded-full bg-current" />
45
+ ) : (
46
+ <Check className="h-3.5 w-3.5" strokeWidth={3} />
47
+ )}
35
48
  </div>
36
49
  </div>
37
50
  )
@@ -0,0 +1,126 @@
1
+ 'use client'
2
+
3
+ import { nodeRegistry, type ToolOption } from '@pascal-app/core'
4
+ import Image from 'next/image'
5
+ import { useSyncExternalStore } from 'react'
6
+ import { cn } from '../../../lib/utils'
7
+ import { triggerSFX } from '../../../lib/sfx-bus'
8
+
9
+ const ALWAYS_VISIBLE = {
10
+ subscribe: () => () => {},
11
+ value: () => true,
12
+ }
13
+
14
+ function ToolOptionRow({
15
+ option,
16
+ onSelect,
17
+ getChoiceThumbnail,
18
+ active = true,
19
+ }: {
20
+ option: ToolOption
21
+ onSelect?: (option: ToolOption, value: string) => void
22
+ getChoiceThumbnail?: (option: ToolOption, value: string) => string | undefined
23
+ active?: boolean
24
+ }) {
25
+ const visibility = option.visible ?? ALWAYS_VISIBLE
26
+ const visible = useSyncExternalStore(visibility.subscribe, visibility.value, visibility.value)
27
+ const value = useSyncExternalStore(option.subscribe, option.value, option.value)
28
+ if (
29
+ !visible ||
30
+ (!active && !option.choices.some((choice) => getChoiceThumbnail?.(option, choice.value)))
31
+ ) return null
32
+
33
+ const activeChoice = active && option.choices.find((choice) => choice.value === value)
34
+ return (
35
+ <div className="flex flex-col gap-2">
36
+ <div className="px-0.5 font-medium text-muted-foreground text-xs">{option.label}</div>
37
+ <div
38
+ className="grid gap-1.5"
39
+ style={{
40
+ gridTemplateColumns: `repeat(${Math.min(option.choices.length, 3)}, minmax(0, 1fr))`,
41
+ }}
42
+ >
43
+ {option.choices.map((choice) => {
44
+ const selected = active && choice.value === value
45
+ const thumbnail = getChoiceThumbnail?.(option, choice.value)
46
+ return (
47
+ <button
48
+ aria-pressed={selected}
49
+ className={cn(
50
+ 'rounded-lg px-2 py-2 text-center font-medium text-xs transition-colors',
51
+ selected
52
+ ? 'bg-primary/10 text-primary ring-1 ring-primary/50'
53
+ : 'bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground',
54
+ )}
55
+ key={choice.value}
56
+ onClick={() => {
57
+ triggerSFX('sfx:menu-click')
58
+ option.set(choice.value)
59
+ onSelect?.(option, choice.value)
60
+ }}
61
+ onMouseEnter={() => triggerSFX('sfx:menu-hover')}
62
+ type="button"
63
+ >
64
+ {thumbnail && (
65
+ <Image
66
+ alt=""
67
+ className={cn(
68
+ 'mx-auto mb-1 size-14 object-contain',
69
+ !selected && 'opacity-70 grayscale',
70
+ )}
71
+ height={56}
72
+ src={thumbnail}
73
+ width={56}
74
+ />
75
+ )}
76
+ {choice.label}
77
+ </button>
78
+ )
79
+ })}
80
+ </div>
81
+ {activeChoice && activeChoice.description ? (
82
+ <p className="px-0.5 text-[11px] text-muted-foreground leading-relaxed">
83
+ {activeChoice.description}
84
+ </p>
85
+ ) : null}
86
+ </div>
87
+ )
88
+ }
89
+
90
+ /**
91
+ * The pick-one option rows a kind declares via `def.toolOptions` (e.g. the
92
+ * roof's 'Create from: Draw / Room'), for whichever sidebar the host mounts
93
+ * it in — the standalone Build tab and the community Build sidebar both get
94
+ * every kind's options with no per-kind wiring. Renders nothing for kinds
95
+ * without options. Selecting a choice only writes the kind's own state;
96
+ * hosts that want selection to also arm the tool pass `onSelect`.
97
+ */
98
+ export function ToolOptionsPanel({
99
+ kind,
100
+ className,
101
+ onSelect,
102
+ getChoiceThumbnail,
103
+ active = true,
104
+ }: {
105
+ kind: string | null | undefined
106
+ className?: string
107
+ onSelect?: (option: ToolOption, value: string) => void
108
+ getChoiceThumbnail?: (option: ToolOption, value: string) => string | undefined
109
+ active?: boolean
110
+ }) {
111
+ const options = (kind ? nodeRegistry.get(kind)?.toolOptions : undefined) ?? []
112
+ if (options.length === 0) return null
113
+ return (
114
+ <div className={cn('flex flex-col gap-3', className)}>
115
+ {options.map((option) => (
116
+ <ToolOptionRow
117
+ active={active}
118
+ getChoiceThumbnail={getChoiceThumbnail}
119
+ key={option.id}
120
+ onSelect={onSelect}
121
+ option={option}
122
+ />
123
+ ))}
124
+ </div>
125
+ )
126
+ }
@@ -27,7 +27,7 @@ import {
27
27
  LevelNode,
28
28
  useScene,
29
29
  } from '@pascal-app/core'
30
- import { useViewer } from '@pascal-app/viewer'
30
+ import { markPerfAction, useViewer } from '@pascal-app/viewer'
31
31
  import { ClipboardPaste, Copy, GripVertical, MoreVertical, Plus, Trash2 } from 'lucide-react'
32
32
  import {
33
33
  type ButtonHTMLAttributes,
@@ -150,6 +150,9 @@ function LevelRow({
150
150
  const storeyHeight = getStoredLevelHeight(level)
151
151
  // toFixed(2) + strip one trailing zero: "2.50" → "2.5", "2.75" stays.
152
152
  const storeyHeightLabel = `${toDisplay(storeyHeight).toFixed(2).replace(/0$/, '')} ${displayUnit}`
153
+ // Same rule as the site panel and command palette: the ordinal-0 ground
154
+ // floor is the vertical model's zero anchor and must never be deletable.
155
+ const canDeleteLevel = level.level !== 0
153
156
 
154
157
  // Clean preset values per display system; imperial stores exact meters
155
158
  // for whole-foot storey heights.
@@ -235,7 +238,7 @@ function LevelRow({
235
238
  >
236
239
  <SliderControl
237
240
  label="Level height"
238
- max={6}
241
+ max={20}
239
242
  min={1}
240
243
  onChange={(v) => updateNode(level.id, { height: v })}
241
244
  precision={3}
@@ -304,11 +307,13 @@ function LevelRow({
304
307
  </button>
305
308
  )}
306
309
  <button
307
- 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-red-400"
310
+ 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"
311
+ disabled={!canDeleteLevel}
308
312
  onClick={(e) => {
309
313
  e.stopPropagation()
310
314
  onRequestDelete()
311
315
  }}
316
+ title={canDeleteLevel ? 'Delete level' : 'The ground level cannot be deleted'}
312
317
  type="button"
313
318
  >
314
319
  <Trash2 className="h-3 w-3" />
@@ -572,6 +577,9 @@ export function FloatingLevelSelector() {
572
577
  {!draggingLevelId && (
573
578
  <button
574
579
  className={cn(addButtonClass, 'top-0 -translate-y-1/2')}
580
+ // A stable hook for host-app onboarding to point at. Static, and
581
+ // read only from outside: nothing here depends on it.
582
+ data-guide-target="level-add"
575
583
  onClick={handleAddAbove}
576
584
  title="Add level above"
577
585
  type="button"
@@ -608,20 +616,29 @@ export function FloatingLevelSelector() {
608
616
  const showGapBelow = i < reversedLevels.length - 1
609
617
 
610
618
  return (
611
- <div className="relative" key={level.id}>
619
+ <div
620
+ className="relative"
621
+ // A stable hook for host-app onboarding to point at, on
622
+ // the ground floor only — the one level a guide can name
623
+ // without knowing the building. Static, and read only
624
+ // from outside: nothing here depends on it.
625
+ data-guide-target={level.level === 0 ? 'level-ground' : undefined}
626
+ key={level.id}
627
+ >
612
628
  <SortableLevelRow
613
629
  isSelected={isSelected}
614
630
  level={level}
615
631
  onDuplicate={(preset) => handleDuplicateLevel(level, preset)}
616
632
  onPaste={() => handlePasteToLevel(level)}
617
633
  onRequestDelete={() => setDeletingLevel(level)}
618
- onSelect={() =>
634
+ onSelect={() => {
635
+ if (!isSelected) markPerfAction('level-switch', level.id)
619
636
  setSelection(
620
637
  resolvedBuildingId
621
638
  ? { buildingId: resolvedBuildingId, levelId: level.id }
622
639
  : { levelId: level.id },
623
640
  )
624
- }
641
+ }}
625
642
  />
626
643
 
627
644
  {showGapBelow && !draggingLevelId && (
@@ -90,6 +90,7 @@ function ShortcutSequence({
90
90
  function ChipRow({
91
91
  ariaLabel,
92
92
  disabled = false,
93
+ guideTarget,
93
94
  icon,
94
95
  label,
95
96
  onClick,
@@ -98,6 +99,11 @@ function ChipRow({
98
99
  }: {
99
100
  ariaLabel?: string
100
101
  disabled?: boolean
102
+ /**
103
+ * A static hook for a host app's first-run tour to point at, written to
104
+ * `data-guide-target`. Nothing here reads it.
105
+ */
106
+ guideTarget?: string
101
107
  icon?: string
102
108
  label: string
103
109
  onClick?: () => void
@@ -130,6 +136,7 @@ function ChipRow({
130
136
  'pointer-events-auto cursor-pointer items-center rounded-md text-left transition-colors hover:bg-muted/60',
131
137
  disabled && 'opacity-45 saturate-0',
132
138
  )}
139
+ data-guide-target={guideTarget}
133
140
  onClick={onClick}
134
141
  type="button"
135
142
  >
@@ -181,6 +188,7 @@ function SnappingChips({ context }: { context: SnapContext }) {
181
188
  <>
182
189
  <ChipRow
183
190
  ariaLabel={`Snapping: ${SNAPPING_MODE_LABELS[snappingMode]}`}
191
+ guideTarget="snap-mode"
184
192
  icon={SNAPPING_MODE_ICONS[snappingMode]}
185
193
  label={`Snapping: ${SNAPPING_MODE_LABELS[snappingMode]}`}
186
194
  onClick={() => {
@@ -193,6 +201,7 @@ function SnappingChips({ context }: { context: SnapContext }) {
193
201
  {gridActive ? (
194
202
  <ChipRow
195
203
  ariaLabel={`Grid step: ${gridSnapStep.toFixed(2)} m`}
204
+ guideTarget="snap-grid-step"
196
205
  label={`Grid: ${gridSnapStep.toFixed(2)} m`}
197
206
  onClick={() => {
198
207
  setGridSnapStep(nextGridSnapStep(gridSnapStep))