@pascal-app/editor 1.0.6 → 1.1.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 (382) hide show
  1. package/package.json +18 -15
  2. package/src/components/editor/action-menu-button.tsx +4 -1
  3. package/src/components/editor/alignment-3d-guide-layer.tsx +102 -11
  4. package/src/components/editor/bake-thumbnail.tsx +3 -2
  5. package/src/components/editor/custom-camera-controls.tsx +110 -42
  6. package/src/components/editor/duplicate-node.ts +2 -1
  7. package/src/components/editor/elevation-3d-guide-layer.tsx +1 -1
  8. package/src/components/editor/first-person-controls.tsx +77 -68
  9. package/src/components/editor/fit-scene-framing.test.ts +318 -0
  10. package/src/components/editor/fit-scene-framing.ts +142 -0
  11. package/src/components/editor/floating-action-menu.tsx +31 -11
  12. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  13. package/src/components/editor/floor-openings-overlay.tsx +1 -1
  14. package/src/components/editor/floor-region-controls.tsx +2 -2
  15. package/src/components/editor/floorplan-panel.test.ts +220 -0
  16. package/src/components/editor/floorplan-panel.tsx +458 -140
  17. package/src/components/editor/floorplan-wall-typing-keys.test.tsx +152 -0
  18. package/src/components/editor/floorplan-wall-typing-keys.ts +79 -0
  19. package/src/components/editor/footprint-height-handle.tsx +2 -2
  20. package/src/components/editor/grid.tsx +24 -15
  21. package/src/components/editor/group-actions.ts +19 -1
  22. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  23. package/src/components/editor/group-move-3d.ts +27 -2
  24. package/src/components/editor/group-selection-box-3d.tsx +4 -1
  25. package/src/components/editor/group-transform-shared.ts +23 -1
  26. package/src/components/editor/index.tsx +150 -20
  27. package/src/components/editor/inspector-extension-actions.tsx +36 -0
  28. package/src/components/editor/localized-html.tsx +22 -0
  29. package/src/components/editor/measurement-pill.tsx +1 -1
  30. package/src/components/editor/node-action-menu.tsx +46 -12
  31. package/src/components/editor/node-arrow-handles.tsx +2 -3
  32. package/src/components/editor/open-wall-ends-3d-layer.tsx +2 -3
  33. package/src/components/editor/open-wall-ends-hint.tsx +1 -1
  34. package/src/components/editor/opening-guides-3d-layer.tsx +1 -1
  35. package/src/components/editor/quick-measurement-card.tsx +1 -1
  36. package/src/components/editor/registry-action-contributions.test.tsx +67 -6
  37. package/src/components/editor/registry-action-contributions.tsx +115 -15
  38. package/src/components/editor/riser-diagram-panel.tsx +59 -9
  39. package/src/components/editor/room-controls.tsx +4 -3
  40. package/src/components/editor/room-handles-2d.tsx +158 -7
  41. package/src/components/editor/room-handles.tsx +244 -54
  42. package/src/components/editor/room-highlight.tsx +2 -0
  43. package/src/components/editor/room-labels.tsx +33 -17
  44. package/src/components/editor/selection-manager.tsx +15 -102
  45. package/src/components/editor/separator-end-grip.tsx +74 -0
  46. package/src/components/editor/site-edge-labels.tsx +7 -3
  47. package/src/components/editor/thumbnail-generator.test.ts +15 -6
  48. package/src/components/editor/thumbnail-generator.tsx +26 -1
  49. package/src/components/editor/use-floorplan-background-placement.ts +55 -52
  50. package/src/components/editor/views/view-bar.tsx +111 -26
  51. package/src/components/editor/wall-measurement-label.tsx +31 -15
  52. package/src/components/editor/wall-move-side-handles.tsx +15 -7
  53. package/src/components/editor/wall-pull-grip.tsx +193 -0
  54. package/src/components/editor/wall-region-handles.tsx +2 -2
  55. package/src/components/editor/wall-snap-beacon-layer.tsx +134 -33
  56. package/src/components/editor-2d/floorplan-adjacent-walls-layer.test.ts +76 -0
  57. package/src/components/editor-2d/floorplan-adjacent-walls-layer.tsx +186 -0
  58. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +63 -3
  59. package/src/components/editor-2d/floorplan-group-move.tsx +32 -4
  60. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +6 -0
  61. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +33 -24
  62. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +12 -5
  63. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +2 -1
  64. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +29 -5
  65. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +96 -4
  66. package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +52 -12
  67. package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +7 -1
  68. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +13 -1
  69. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.test.tsx +35 -0
  70. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.tsx +65 -0
  71. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +34 -1
  72. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +211 -39
  73. package/src/components/procedural-items/inspector.tsx +59 -12
  74. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +2 -0
  75. package/src/components/systems/ceiling/ceiling-system.test.ts +141 -0
  76. package/src/components/systems/ceiling/ceiling-system.tsx +53 -18
  77. package/src/components/systems/roof/roof-edit-system.test.tsx +15 -1
  78. package/src/components/systems/roof/roof-edit-system.tsx +30 -19
  79. package/src/components/tools/distribution/distribution-run-tool.test.tsx +371 -3
  80. package/src/components/tools/distribution/distribution-run-tool.tsx +200 -58
  81. package/src/components/tools/item/move-tool.tsx +1 -6
  82. package/src/components/tools/item/placement-history.test.tsx +1 -1
  83. package/src/components/tools/item/placement-strategies.test.ts +64 -1
  84. package/src/components/tools/item/placement-strategies.ts +5 -9
  85. package/src/components/tools/item/use-placement-coordinator.tsx +31 -35
  86. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +1 -1
  87. package/src/components/tools/registry/item-surface-move.test.ts +11 -0
  88. package/src/components/tools/registry/move-registry-node-tool.test.ts +42 -1
  89. package/src/components/tools/registry/move-registry-node-tool.tsx +38 -14
  90. package/src/components/tools/select/select-candidates.test.ts +45 -0
  91. package/src/components/tools/select/select-candidates.ts +13 -2
  92. package/src/components/tools/shared/cursor-sphere.tsx +10 -3
  93. package/src/components/tools/shared/draft-measurement-label.tsx +1 -1
  94. package/src/components/tools/shared/placement-box.tsx +2 -6
  95. package/src/components/tools/shared/placement-coordinate-input.tsx +94 -0
  96. package/src/components/tools/shared/placement-dimension-guides.tsx +1 -1
  97. package/src/components/tools/shared/segment-angle.ts +0 -4
  98. package/src/components/tools/site/terrain-brush-cursor.tsx +1 -1
  99. package/src/components/tools/tool-manager.tsx +1 -24
  100. package/src/components/tools/wall/wall-drafting.test.ts +299 -23
  101. package/src/components/tools/wall/wall-drafting.ts +132 -24
  102. package/src/components/tools/wall/wall-pull-snap.test.ts +274 -0
  103. package/src/components/tools/wall/wall-pull-snap.ts +563 -0
  104. package/src/components/tools/wall/wall-snap-geometry.test.ts +113 -1
  105. package/src/components/tools/wall/wall-snap-geometry.ts +137 -0
  106. package/src/components/tools/wall/wall-typed-entry.test.ts +99 -0
  107. package/src/components/tools/wall/wall-typed-entry.ts +70 -0
  108. package/src/components/tools/zone/zone-tool.tsx +3 -3
  109. package/src/components/ui/action-menu/action-button.tsx +2 -0
  110. package/src/components/ui/action-menu/camera-actions.tsx +8 -7
  111. package/src/components/ui/action-menu/control-modes.tsx +9 -5
  112. package/src/components/ui/action-menu/follow-pascal-pill.tsx +3 -1
  113. package/src/components/ui/action-menu/follow-pascal-toggle.tsx +7 -1
  114. package/src/components/ui/action-menu/furnish-tools.tsx +7 -6
  115. package/src/components/ui/action-menu/index.tsx +1 -0
  116. package/src/components/ui/action-menu/measurement-control.tsx +35 -29
  117. package/src/components/ui/action-menu/point-ask-button.tsx +4 -3
  118. package/src/components/ui/action-menu/structure-tools.tsx +25 -23
  119. package/src/components/ui/action-menu/view-toggles.test.ts +18 -0
  120. package/src/components/ui/action-menu/view-toggles.tsx +256 -51
  121. package/src/components/ui/build-panel/build-panel.tsx +163 -25
  122. package/src/components/ui/command-palette/editor-commands.tsx +38 -11
  123. package/src/components/ui/controls/finish-swatch.tsx +45 -19
  124. package/src/components/ui/controls/material-picker.tsx +24 -17
  125. package/src/components/ui/controls/metric-control.test.ts +52 -0
  126. package/src/components/ui/controls/metric-control.tsx +62 -24
  127. package/src/components/ui/controls/segmented-control.tsx +5 -2
  128. package/src/components/ui/controls/slider-control.test.tsx +23 -0
  129. package/src/components/ui/controls/slider-control.tsx +47 -19
  130. package/src/components/ui/controls/tool-options-panel.tsx +74 -44
  131. package/src/components/ui/floating-level-selector.tsx +100 -48
  132. package/src/components/ui/helpers/building-helper.tsx +5 -3
  133. package/src/components/ui/helpers/contextual-helper-panel.tsx +97 -37
  134. package/src/components/ui/helpers/helper-manager.test.tsx +106 -3
  135. package/src/components/ui/helpers/helper-manager.tsx +136 -57
  136. package/src/components/ui/helpers/item-helper.tsx +6 -4
  137. package/src/components/ui/helpers/registered-tool-helper.tsx +3 -1
  138. package/src/components/ui/level-duplicate-dialog.tsx +32 -23
  139. package/src/components/ui/level-wall-copy-prompt.tsx +124 -0
  140. package/src/components/ui/panels/collections/collections-popover.tsx +11 -11
  141. package/src/components/ui/panels/deck-boards-fields.tsx +136 -0
  142. package/src/components/ui/panels/floor-foundation-panel.tsx +114 -39
  143. package/src/components/ui/panels/floor-opening-panel.tsx +74 -26
  144. package/src/components/ui/panels/inspector-extensions.tsx +66 -0
  145. package/src/components/ui/panels/mobile-panel-sheet.tsx +3 -1
  146. package/src/components/ui/panels/mobile-selection-bar.tsx +34 -28
  147. package/src/components/ui/panels/multi-field-value.test.ts +27 -0
  148. package/src/components/ui/panels/multi-field-value.ts +20 -1
  149. package/src/components/ui/panels/multi-height-mode.tsx +11 -10
  150. package/src/components/ui/panels/multi-parametric-inspector.tsx +122 -34
  151. package/src/components/ui/panels/multi-selection-panel.tsx +76 -36
  152. package/src/components/ui/panels/node-display.ts +35 -24
  153. package/src/components/ui/panels/panel-manager.tsx +9 -5
  154. package/src/components/ui/panels/panel-wrapper.tsx +63 -127
  155. package/src/components/ui/panels/parametric-field-control.tsx +13 -10
  156. package/src/components/ui/panels/parametric-inspector.tsx +18 -12
  157. package/src/components/ui/panels/reference-panel.tsx +51 -35
  158. package/src/components/ui/panels/room-construction-rows.tsx +465 -66
  159. package/src/components/ui/panels/room-name-options.test.ts +36 -3
  160. package/src/components/ui/panels/room-name-options.ts +110 -11
  161. package/src/components/ui/panels/room-openings-field.tsx +22 -13
  162. package/src/components/ui/panels/room-panel.tsx +207 -100
  163. package/src/components/ui/panels/shape-choice.tsx +7 -5
  164. package/src/components/ui/panels/wall-region-list.tsx +7 -7
  165. package/src/components/ui/primitives/shortcut-token.tsx +10 -6
  166. package/src/components/ui/right-stack.test.ts +13 -6
  167. package/src/components/ui/right-stack.tsx +21 -10
  168. package/src/components/ui/sidebar/icon-rail.tsx +11 -9
  169. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +10 -10
  170. package/src/components/ui/sidebar/panels/items-panel/index.tsx +10 -9
  171. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +3 -0
  172. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +102 -32
  173. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +1 -5
  174. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +4 -3
  175. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +5 -3
  176. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +4 -2
  177. package/src/components/ui/sidebar/panels/site-panel/collection-tree-node.tsx +7 -5
  178. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +4 -2
  179. package/src/components/ui/sidebar/panels/site-panel/default-names.ts +75 -0
  180. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +4 -2
  181. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +4 -2
  182. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +4 -2
  183. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +4 -2
  184. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +4 -2
  185. package/src/components/ui/sidebar/panels/site-panel/index.tsx +118 -72
  186. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +36 -3
  187. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +4 -2
  188. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +7 -4
  189. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +8 -2
  190. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +8 -5
  191. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +4 -2
  192. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +9 -4
  193. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +4 -2
  194. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +4 -2
  195. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +8 -7
  196. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +7 -6
  197. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +16 -1
  198. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +15 -8
  199. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +4 -2
  200. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +4 -2
  201. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +3 -1
  202. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +19 -9
  203. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +25 -14
  204. package/src/components/ui/sidebar/tab-bar.tsx +4 -1
  205. package/src/components/ui/sidebar/use-plugin-panels.tsx +19 -10
  206. package/src/components/ui/snap-target-badge.tsx +7 -6
  207. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +721 -0
  208. package/src/components/ui/wall-type/wall-type-menu.tsx +167 -0
  209. package/src/components/viewer/floorplan-compass-button.tsx +12 -3
  210. package/src/components/viewer/floorplan-preview.tsx +27 -19
  211. package/src/components/viewer/render-settings-menu.tsx +294 -0
  212. package/src/components/viewer/sky-control.tsx +710 -0
  213. package/src/components/viewer/use-viewer-ui-sounds.test.ts +15 -0
  214. package/src/components/viewer/use-viewer-ui-sounds.ts +46 -0
  215. package/src/components/viewer/viewer-controls-bar.tsx +278 -260
  216. package/src/components/viewer/viewer-hover-label.tsx +12 -4
  217. package/src/components/viewer/viewer-rooms.tsx +62 -5
  218. package/src/components/viewer/viewer-scene-header.tsx +28 -17
  219. package/src/components/viewer/viewer-stage-switcher.tsx +4 -2
  220. package/src/components/viewer-overlay.tsx +8 -0
  221. package/src/components/walkthrough-hud.tsx +75 -2
  222. package/src/hooks/use-keyboard.test.ts +85 -4
  223. package/src/hooks/use-keyboard.ts +65 -7
  224. package/src/hooks/use-selected-room.ts +12 -1
  225. package/src/i18n/floor-footprint-name.ts +30 -0
  226. package/src/i18n/level-name.ts +39 -0
  227. package/src/i18n/locales/en/messages.po +15849 -0
  228. package/src/i18n/locales/en/messages.ts +1 -0
  229. package/src/i18n/locales/en-XA/messages.ts +1 -0
  230. package/src/i18n/locales/es/messages.po +39331 -0
  231. package/src/i18n/locales/es/messages.ts +1 -0
  232. package/src/i18n/locales/fr/messages.po +39331 -0
  233. package/src/i18n/locales/fr/messages.ts +1 -0
  234. package/src/i18n/locales/ja/messages.po +39331 -0
  235. package/src/i18n/locales/ja/messages.ts +1 -0
  236. package/src/i18n/locales/zh-CN/messages.po +39331 -0
  237. package/src/i18n/locales/zh-CN/messages.ts +1 -0
  238. package/src/i18n/locales.test.ts +63 -0
  239. package/src/i18n/locales.ts +32 -0
  240. package/src/i18n/message-slot.tsx +25 -0
  241. package/src/i18n/node-messages.ts +169 -0
  242. package/src/i18n/pseudo-locale.ts +17 -0
  243. package/src/i18n/published-entry.test.ts +17 -0
  244. package/src/i18n/scene-theme-name.ts +16 -0
  245. package/src/index.tsx +224 -45
  246. package/src/lib/add-storey.test.ts +3 -3
  247. package/src/lib/contextual-help.test.ts +20 -1
  248. package/src/lib/contextual-help.ts +101 -19
  249. package/src/lib/continuation.ts +38 -11
  250. package/src/lib/elevation-guides.ts +21 -6
  251. package/src/lib/elevator-support.ts +1 -72
  252. package/src/lib/floor-footprints.test.ts +3 -2
  253. package/src/lib/floor-footprints.ts +1 -9
  254. package/src/lib/floor-foundation-support.test.ts +15 -6
  255. package/src/lib/floor-opening-draft.ts +1 -1
  256. package/src/lib/floor-region-session.ts +1 -1
  257. package/src/lib/floorplan/dimension-drive.test.ts +36 -2
  258. package/src/lib/floorplan/dimension-drive.ts +68 -3
  259. package/src/lib/floorplan/floorplan-export.test.ts +16 -5
  260. package/src/lib/floorplan/floorplan-export.tsx +13 -16
  261. package/src/lib/floorplan/floorplan-extension.test.ts +11 -12
  262. package/src/lib/floorplan/floorplan-extension.ts +37 -18
  263. package/src/lib/floorplan/floorplan-mode.test.ts +13 -6
  264. package/src/lib/floorplan/floorplan-mode.ts +14 -12
  265. package/src/lib/floorplan/floorplan-plugin-install.test.tsx +1 -1
  266. package/src/lib/floorplan/floorplan-readonly.ts +3 -3
  267. package/src/lib/floorplan/open-wall-ends.ts +2 -1
  268. package/src/lib/floorplan/schedules.ts +10 -6
  269. package/src/lib/floorplan/site-plan/site-annotations.test.ts +46 -1
  270. package/src/lib/floorplan/site-plan/site-annotations.ts +3 -0
  271. package/src/lib/floorplan/site-plan/site-parts.ts +6 -3
  272. package/src/lib/geometry-script/author.test.ts +114 -0
  273. package/src/lib/geometry-script/author.ts +55 -23
  274. package/src/lib/geometry-script/client.ts +3 -0
  275. package/src/lib/geometry-script/finish-scripts.ts +7 -7
  276. package/src/lib/gesture-lifecycle.ts +3 -1
  277. package/src/lib/glb-export.test.ts +327 -0
  278. package/src/lib/glb-export.ts +70 -2
  279. package/src/lib/grid-frame.test.ts +56 -3
  280. package/src/lib/grid-frame.ts +28 -1
  281. package/src/lib/history.test.ts +2 -1
  282. package/src/lib/hud-title.ts +48 -24
  283. package/src/lib/ifc-export.test.ts +41 -1
  284. package/src/lib/inspector-card-mode.test.ts +3 -31
  285. package/src/lib/inspector-card-mode.ts +5 -10
  286. package/src/lib/inspector-expanded.test.ts +13 -0
  287. package/src/lib/inspector-expanded.ts +13 -1
  288. package/src/lib/interaction/scope.ts +1 -0
  289. package/src/lib/keyboard-pan.test.ts +7 -0
  290. package/src/lib/keyboard-pan.ts +9 -2
  291. package/src/lib/layer-thickness.test.ts +22 -0
  292. package/src/lib/layer-thickness.ts +79 -0
  293. package/src/lib/level-selection.test.ts +18 -2
  294. package/src/lib/level-selection.ts +31 -8
  295. package/src/lib/linear-display.ts +1 -1
  296. package/src/lib/live-gesture-round-trip.test.ts +1 -1
  297. package/src/lib/lot/drop-in.test.ts +59 -1
  298. package/src/lib/lot/drop-in.ts +42 -50
  299. package/src/lib/material-swatch-style.ts +24 -0
  300. package/src/lib/measurement-label.test.ts +1 -1
  301. package/src/lib/measurement-parser.ts +7 -3
  302. package/src/lib/measurements.test.ts +24 -6
  303. package/src/lib/measurements.ts +1 -203
  304. package/src/lib/mezzanine-selection.test.ts +3 -1
  305. package/src/lib/node-action-movement.test.tsx +115 -0
  306. package/src/lib/node-action-movement.ts +2 -0
  307. package/src/lib/orbit-near-plane.test.ts +12 -0
  308. package/src/lib/orbit-near-plane.ts +13 -0
  309. package/src/lib/paint-regions.test.ts +8 -5
  310. package/src/lib/paint-scope.test.ts +53 -0
  311. package/src/lib/paint-scope.ts +18 -4
  312. package/src/lib/panel-rows.ts +17 -8
  313. package/src/lib/placement-metadata.ts +4 -0
  314. package/src/lib/point-ask/describe-target.ts +6 -8
  315. package/src/lib/point-ask/region.ts +1 -1
  316. package/src/lib/print-shell-compiler.ts +12 -1
  317. package/src/lib/print-wall-solids.test.ts +11 -0
  318. package/src/lib/print-wall-solids.ts +9 -0
  319. package/src/lib/reference-scale.ts +83 -0
  320. package/src/lib/room-assembly-overlay.ts +1 -1
  321. package/src/lib/room-built-on.test.ts +28 -17
  322. package/src/lib/room-built-on.ts +21 -17
  323. package/src/lib/room-construction-commands.ts +3 -2
  324. package/src/lib/room-construction.test.ts +28 -1
  325. package/src/lib/room-construction.ts +14 -0
  326. package/src/lib/room-controls-phase6.test.ts +141 -1
  327. package/src/lib/room-handle-drag.ts +554 -58
  328. package/src/lib/room-name-catalog.test.ts +8 -0
  329. package/src/lib/room-name-catalog.ts +8 -1
  330. package/src/lib/room-name-messages.test.ts +132 -0
  331. package/src/lib/room-name-messages.ts +147 -0
  332. package/src/lib/room-name-storage.test.ts +334 -0
  333. package/src/lib/room-push-dimensions.test.ts +38 -1
  334. package/src/lib/room-push-dimensions.ts +226 -2
  335. package/src/lib/room-selection.test.ts +23 -6
  336. package/src/lib/scene-bounds.test.ts +144 -0
  337. package/src/lib/scene-bounds.ts +79 -15
  338. package/src/lib/scene-clipboard.test.ts +70 -49
  339. package/src/lib/scene-clipboard.ts +11 -5
  340. package/src/lib/selection-cycle.ts +63 -0
  341. package/src/lib/separator-edit.test.ts +431 -0
  342. package/src/lib/separator-edit.ts +179 -0
  343. package/src/lib/separator-lines.ts +38 -0
  344. package/src/lib/sfx-bus.ts +27 -1
  345. package/src/lib/sfx-player.ts +16 -0
  346. package/src/lib/snapping-mode.test.ts +23 -1
  347. package/src/lib/tool-cursor-icon.ts +5 -1
  348. package/src/lib/tool-shortcuts.ts +130 -0
  349. package/src/lib/viewer-selection.test.ts +28 -24
  350. package/src/lib/viewer-selection.ts +21 -6
  351. package/src/lib/wall-draw-variant.test.ts +7 -6
  352. package/src/lib/wall-draw-variant.ts +107 -6
  353. package/src/lib/wall-nudge.test.ts +44 -0
  354. package/src/lib/wall-nudge.ts +131 -0
  355. package/src/lib/wall-parallel-snap.test.ts +83 -0
  356. package/src/lib/wall-parallel-snap.ts +176 -0
  357. package/src/lib/wall-placement-pointer.test.ts +23 -0
  358. package/src/lib/wall-placement-pointer.ts +18 -0
  359. package/src/lib/wall-pull.test.ts +245 -0
  360. package/src/lib/wall-pull.ts +209 -0
  361. package/src/lib/wall-push-line-up.test.ts +132 -0
  362. package/src/lib/wall-region-list.ts +17 -6
  363. package/src/lib/wall-run-snap.test.ts +188 -0
  364. package/src/lib/wall-run-snap.ts +83 -0
  365. package/src/lib/wall-type.test.ts +125 -0
  366. package/src/lib/wall-type.ts +119 -0
  367. package/src/lib/window-interaction.test.ts +44 -0
  368. package/src/lib/world-grid-snap.test.ts +139 -2
  369. package/src/lib/world-grid-snap.ts +102 -105
  370. package/src/store/use-alignment-guides.ts +27 -1
  371. package/src/store/use-editor.tsx +53 -0
  372. package/src/store/use-floorplan-annotation-visibility.test.ts +64 -0
  373. package/src/store/use-floorplan-annotation-visibility.ts +47 -22
  374. package/src/store/use-placement-typing.test.ts +165 -0
  375. package/src/store/use-placement-typing.ts +186 -0
  376. package/src/store/use-wall-snap-indicator.ts +15 -2
  377. package/src/components/tools/elevator/elevator-defaults.ts +0 -8
  378. package/src/components/tools/elevator/elevator-tool.tsx +0 -372
  379. package/src/components/tools/elevator/move-elevator-tool.tsx +0 -262
  380. package/src/lib/door-interaction.ts +0 -102
  381. package/src/lib/measurement-label.ts +0 -68
  382. package/src/lib/window-interaction.ts +0 -100
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { useScene } from '@pascal-app/core'
3
+ import { formatNumber, parseLocalizedNumber, useLocale, useScene } from '@pascal-app/core'
4
4
  import { useCallback, useEffect, useRef, useState } from 'react'
5
5
  import { lingoUnitSpec, measurementHint, parseMeasurement } from '../../../lib/measurement-parser'
6
6
  import { useLinearDisplay } from '../../../lib/use-linear-display'
@@ -20,6 +20,45 @@ interface MetricControlProps {
20
20
  restoreOnCommit?: boolean
21
21
  }
22
22
 
23
+ export function parseMetricInput(
24
+ text: string,
25
+ unit: string,
26
+ options: { isImperial: boolean; locale: string; parseUnit?: string },
27
+ ): number | null {
28
+ const spec = lingoUnitSpec(unit)
29
+ return spec
30
+ ? parseMeasurement(text, spec, {
31
+ bareUnit: options.parseUnit ?? spec.unitId,
32
+ locale: options.locale,
33
+ system: options.isImperial ? 'us' : 'metric',
34
+ })
35
+ : parseLocalizedNumber(text, options.locale)
36
+ }
37
+
38
+ export function formatMetricInputValue(
39
+ value: number,
40
+ precision: number,
41
+ locale: string,
42
+ ): string {
43
+ return formatNumber(roundMetricValueForDisplay(value, precision), {
44
+ locale,
45
+ min: precision,
46
+ max: precision,
47
+ })
48
+ }
49
+
50
+ export function roundMetricValueForDisplay(value: number, precision: number): number {
51
+ const rounded = Number(
52
+ formatNumber(value, {
53
+ locale: 'en',
54
+ min: precision,
55
+ max: precision,
56
+ useGrouping: false,
57
+ }),
58
+ )
59
+ return Number.isFinite(rounded) ? rounded : value
60
+ }
61
+
23
62
  export function MetricControl({
24
63
  label,
25
64
  value,
@@ -33,6 +72,7 @@ export function MetricControl({
33
72
  unit = '',
34
73
  restoreOnCommit = true,
35
74
  }: MetricControlProps) {
75
+ const locale = useLocale()
36
76
  const {
37
77
  isImperial,
38
78
  displayUnit,
@@ -51,16 +91,20 @@ export function MetricControl({
51
91
  )
52
92
  const roundStoredValueForDisplayPrecision = useCallback(
53
93
  (storedValue: number) =>
54
- clamp(toStoredValue(Number.parseFloat(toDisplayValue(storedValue).toFixed(precision)))),
94
+ clamp(toStoredValue(roundMetricValueForDisplay(toDisplayValue(storedValue), precision))),
55
95
  [clamp, precision, toDisplayValue, toStoredValue],
56
96
  )
57
97
 
58
98
  const displayValue = toDisplayValue(value)
99
+ const formatDisplayValue = useCallback(
100
+ (nextValue: number) => formatMetricInputValue(nextValue, precision, locale),
101
+ [locale, precision],
102
+ )
59
103
 
60
104
  const [isEditing, setIsEditing] = useState(false)
61
105
  const [isDragging, setIsDragging] = useState(false)
62
106
  const [isHovered, setIsHovered] = useState(false)
63
- const [inputValue, setInputValue] = useState(displayValue.toFixed(precision))
107
+ const [inputValue, setInputValue] = useState(() => formatDisplayValue(displayValue))
64
108
  const startXRef = useRef(0)
65
109
  const startValueRef = useRef(0)
66
110
  const containerRef = useRef<HTMLDivElement>(null)
@@ -81,9 +125,9 @@ export function MetricControl({
81
125
 
82
126
  useEffect(() => {
83
127
  if (!isEditing) {
84
- setInputValue(displayValue.toFixed(precision))
128
+ setInputValue(formatDisplayValue(displayValue))
85
129
  }
86
- }, [displayValue, precision, isEditing])
130
+ }, [displayValue, formatDisplayValue, isEditing])
87
131
 
88
132
  useEffect(() => {
89
133
  const container = containerRef.current
@@ -221,8 +265,8 @@ export function MetricControl({
221
265
 
222
266
  const handleValueClick = useCallback(() => {
223
267
  setIsEditing(true)
224
- setInputValue(toDisplayValue(value).toFixed(precision))
225
- }, [value, toDisplayValue, precision])
268
+ setInputValue(formatDisplayValue(toDisplayValue(value)))
269
+ }, [formatDisplayValue, value, toDisplayValue])
226
270
 
227
271
  const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
228
272
  setInputValue(e.target.value)
@@ -230,18 +274,10 @@ export function MetricControl({
230
274
 
231
275
  const submitValue = useCallback(() => {
232
276
  const spec = lingoUnitSpec(unit)
233
- let stored = spec
234
- ? parseMeasurement(inputValue, spec, {
235
- bareUnit: parseUnit ?? spec.unitId,
236
- system: isImperial ? 'us' : 'metric',
237
- })
238
- : null
239
- if (stored === null) {
240
- const numValue = Number.parseFloat(inputValue)
241
- stored = Number.isFinite(numValue) ? toStoredValue(numValue) : null
242
- }
277
+ const parsed = parseMetricInput(inputValue, unit, { isImperial, locale, parseUnit })
278
+ const stored = parsed === null ? null : spec ? parsed : toStoredValue(parsed)
243
279
  if (stored === null) {
244
- setInputValue(toDisplayValue(value).toFixed(precision))
280
+ setInputValue(formatDisplayValue(toDisplayValue(value)))
245
281
  } else {
246
282
  applyCommittedValue(clamp(stored))
247
283
  }
@@ -249,13 +285,14 @@ export function MetricControl({
249
285
  }, [
250
286
  inputValue,
251
287
  unit,
288
+ locale,
252
289
  isImperial,
253
290
  parseUnit,
254
291
  applyCommittedValue,
255
292
  clamp,
256
293
  toStoredValue,
257
294
  value,
258
- precision,
295
+ formatDisplayValue,
259
296
  toDisplayValue,
260
297
  ])
261
298
 
@@ -265,6 +302,7 @@ export function MetricControl({
265
302
  ? measurementHint(inputValue, spec, {
266
303
  bareUnit: parseUnit ?? spec.unitId,
267
304
  system: isImperial ? 'us' : 'metric',
305
+ locale,
268
306
  displayUnit: parseUnit ?? spec.unitId,
269
307
  precision,
270
308
  clamp,
@@ -280,25 +318,25 @@ export function MetricControl({
280
318
  if (e.key === 'Enter') {
281
319
  submitValue()
282
320
  } else if (e.key === 'Escape') {
283
- setInputValue(toDisplayValue(value).toFixed(precision))
321
+ setInputValue(formatDisplayValue(toDisplayValue(value)))
284
322
  setIsEditing(false)
285
323
  } else if (e.key === 'ArrowUp') {
286
324
  e.preventDefault()
287
325
  const newV = clamp(value + toStoredValue(step))
288
326
  applyCommittedValue(newV)
289
- setInputValue(toDisplayValue(newV).toFixed(precision))
327
+ setInputValue(formatDisplayValue(toDisplayValue(newV)))
290
328
  } else if (e.key === 'ArrowDown') {
291
329
  e.preventDefault()
292
330
  const newV = clamp(value - toStoredValue(step))
293
331
  applyCommittedValue(newV)
294
- setInputValue(toDisplayValue(newV).toFixed(precision))
332
+ setInputValue(formatDisplayValue(toDisplayValue(newV)))
295
333
  }
296
334
  },
297
335
  [
298
336
  submitValue,
299
337
  value,
300
338
  toDisplayValue,
301
- precision,
339
+ formatDisplayValue,
302
340
  step,
303
341
  clamp,
304
342
  applyCommittedValue,
@@ -363,7 +401,7 @@ export function MetricControl({
363
401
  onClick={handleValueClick}
364
402
  >
365
403
  <span className="font-mono tabular-nums tracking-tight">
366
- {Number(displayValue.toFixed(precision)).toFixed(precision)}
404
+ {formatDisplayValue(displayValue)}
367
405
  </span>
368
406
  {displayUnit && <span className="ml-[1px] text-muted-foreground">{displayUnit}</span>}
369
407
  </div>
@@ -39,7 +39,10 @@ export function SegmentedControl<T extends string>({
39
39
  aria-disabled={option.disabled || undefined}
40
40
  aria-pressed={isSelected}
41
41
  className={cn(
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',
42
+ // Every chip stretches to the row's height (the row is `items-stretch`), so a label that wraps
43
+ // to two lines grows the whole row evenly instead of one tall chip. The tighter line-height
44
+ // keeps a wrapped label reading as one unit and leaves single-line rows at min-h-7.
45
+ '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-3.5 transition-colors duration-200',
43
46
  isSelected
44
47
  ? 'bg-white dark:bg-[#3e3e3e] text-foreground shadow-sm ring-1 ring-border/50'
45
48
  : 'text-muted-foreground hover:bg-black/4 dark:hover:bg-white/5 hover:text-foreground',
@@ -54,7 +57,7 @@ export function SegmentedControl<T extends string>({
54
57
  }}
55
58
  type="button"
56
59
  >
57
- <span className="relative z-10 flex min-w-0 items-center justify-center gap-1.5 whitespace-normal text-center [&>svg]:shrink-0">
60
+ <span className="relative z-10 flex min-w-0 items-center justify-center gap-1.5 whitespace-normal text-balance text-center [overflow-wrap:anywhere] [&>svg]:shrink-0">
58
61
  {option.label}
59
62
  </span>
60
63
  </button>
@@ -0,0 +1,23 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { createI18n, getLocale, PascalI18nProvider, setLocale } from '@pascal-app/core'
3
+ import { renderToString } from 'react-dom/server'
4
+ import { parseSliderBareValue, SliderControl } from './slider-control'
5
+
6
+ test('slider numbers use the scoped locale for display and parsing while the global locale is English', () => {
7
+ setLocale('en')
8
+ const i18n = createI18n({ locale: 'fr' })
9
+ try {
10
+ const html = renderToString(
11
+ <PascalI18nProvider i18n={i18n}>
12
+ <SliderControl label="Length" onChange={() => {}} precision={2} value={2.75} />
13
+ </PascalI18nProvider>,
14
+ )
15
+
16
+ expect(getLocale()).toBe('en')
17
+ expect(html).toContain('2,75')
18
+ expect(parseSliderBareValue('2,75', i18n.getLocale())).toBe(2.75)
19
+ expect(parseSliderBareValue('1e2', i18n.getLocale())).toBe(100)
20
+ } finally {
21
+ i18n.destroy()
22
+ }
23
+ })
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { useScene } from '@pascal-app/core'
3
+ import { formatNumber, parseLocalizedNumber, useLocale, useScene, usePascalI18n } from '@pascal-app/core'
4
4
  import { useCallback, useEffect, useRef, useState } from 'react'
5
5
  import { lingoUnitSpec, measurementHint, parseMeasurement } from '../../../lib/measurement-parser'
6
6
  import { useLinearDisplay } from '../../../lib/use-linear-display'
@@ -23,6 +23,10 @@ interface SliderControlProps {
23
23
  mixed?: boolean
24
24
  }
25
25
 
26
+ export function parseSliderBareValue(text: string, locale: string): number | null {
27
+ return parseLocalizedNumber(text, locale)
28
+ }
29
+
26
30
  function stepPrecision(s: number): number {
27
31
  if (s <= 0) return 0
28
32
  return Math.max(0, Math.ceil(-Math.log10(s)))
@@ -67,14 +71,25 @@ export function SliderControl({
67
71
  restoreOnCommit = true,
68
72
  mixed = false,
69
73
  }: SliderControlProps) {
74
+ const i18n = usePascalI18n()
75
+ const locale = useLocale()
70
76
  // Values and bounds stay in meters; gestures and input use the displayed unit.
71
77
  const { isImperial, displayUnit, parseUnit, precision, step, toDisplay, toStored } =
72
78
  useLinearDisplay(unit, storedPrecision, storedStep)
79
+ const formatDisplay = useCallback(
80
+ (displayValue: number) =>
81
+ formatNumber(displayValue, {
82
+ locale,
83
+ min: precision,
84
+ max: precision,
85
+ }),
86
+ [locale, precision],
87
+ )
73
88
 
74
89
  const [isEditing, setIsEditing] = useState(false)
75
90
  const [isDragging, setIsDragging] = useState(false)
76
91
  const [isHovered, setIsHovered] = useState(false)
77
- const [inputValue, setInputValue] = useState(toDisplay(value).toFixed(precision))
92
+ const [inputValue, setInputValue] = useState(() => formatDisplay(toDisplay(value)))
78
93
  // Live readout while dragging. Multi-edit previews write live overrides
79
94
  // instead of the scene, so `value` would otherwise stay frozen even though
80
95
  // the meshes are moving.
@@ -116,9 +131,9 @@ export function SliderControl({
116
131
 
117
132
  useEffect(() => {
118
133
  if (!isEditing) {
119
- setInputValue(toDisplay(value).toFixed(precision))
134
+ setInputValue(formatDisplay(toDisplay(value)))
120
135
  }
121
- }, [value, precision, isEditing, toDisplay])
136
+ }, [value, isEditing, toDisplay, formatDisplay])
122
137
 
123
138
  // Wheel support on the label
124
139
  useEffect(() => {
@@ -246,6 +261,12 @@ export function SliderControl({
246
261
  [],
247
262
  )
248
263
 
264
+ useEffect(() => {
265
+ const cancelOnBlur = () => { if (dragRef.current || editingRef.current) cancelEdit() }
266
+ window.addEventListener('blur', cancelOnBlur)
267
+ return () => window.removeEventListener('blur', cancelOnBlur)
268
+ }, [cancelEdit])
269
+
249
270
  useEffect(() => {
250
271
  if (!isDragging) return
251
272
  const escape = (event: KeyboardEvent) => {
@@ -258,8 +279,8 @@ export function SliderControl({
258
279
  const handleValueClick = useCallback(() => {
259
280
  editingRef.current = true
260
281
  setIsEditing(true)
261
- setInputValue(toDisplay(value).toFixed(precision))
262
- }, [value, precision, toDisplay])
282
+ setInputValue(formatDisplay(toDisplay(value)))
283
+ }, [value, toDisplay, formatDisplay])
263
284
 
264
285
  const parseInputValue = useCallback(
265
286
  (text: string) => {
@@ -267,16 +288,17 @@ export function SliderControl({
267
288
  const parsed = spec
268
289
  ? parseMeasurement(text, spec, {
269
290
  bareUnit: parseUnit ?? spec.unitId,
291
+ locale,
270
292
  system: isImperial ? 'us' : 'metric',
271
293
  })
272
294
  : null
273
- const bare = Number(text)
274
- const stored = parsed ?? (text.trim() && Number.isFinite(bare) ? toStored(bare) : null)
295
+ const bare = parseSliderBareValue(text, locale)
296
+ const stored = parsed ?? (bare === null ? null : toStored(bare))
275
297
  return stored === null
276
298
  ? null
277
299
  : clamp(toStored(Number.parseFloat(toDisplay(stored).toFixed(precision))))
278
300
  },
279
- [unit, parseUnit, isImperial, toStored, clamp, toDisplay, precision],
301
+ [unit, parseUnit, locale, isImperial, toStored, clamp, toDisplay, precision],
280
302
  )
281
303
 
282
304
  const submitValue = useCallback(() => {
@@ -285,13 +307,13 @@ export function SliderControl({
285
307
  const nextValue = parseInputValue(inputValue)
286
308
  if (nextValue === null) {
287
309
  onCancel?.()
288
- setInputValue(toDisplay(value).toFixed(precision))
310
+ setInputValue(formatDisplay(toDisplay(value)))
289
311
  } else {
290
312
  onChange(nextValue)
291
313
  onCommit?.(nextValue)
292
314
  }
293
315
  setIsEditing(false)
294
- }, [inputValue, parseInputValue, onCancel, onChange, onCommit, toDisplay, value, precision])
316
+ }, [inputValue, parseInputValue, onCancel, onChange, onCommit, toDisplay, value, formatDisplay])
295
317
 
296
318
  const spec = lingoUnitSpec(unit)
297
319
  const hint =
@@ -299,6 +321,7 @@ export function SliderControl({
299
321
  ? measurementHint(inputValue, spec, {
300
322
  bareUnit: parseUnit ?? spec.unitId,
301
323
  system: isImperial ? 'us' : 'metric',
324
+ locale,
302
325
  displayUnit: parseUnit ?? spec.unitId,
303
326
  precision,
304
327
  clamp,
@@ -317,10 +340,10 @@ export function SliderControl({
317
340
  const adjustedStep = getAdjustedStep(step, e)
318
341
  const newV = applyDisplayDelta(value, direction * adjustedStep, adjustedStep)
319
342
  onChange(newV)
320
- setInputValue(toDisplay(newV).toFixed(precision))
343
+ setInputValue(formatDisplay(toDisplay(newV)))
321
344
  }
322
345
  },
323
- [submitValue, cancelEdit, value, precision, step, applyDisplayDelta, onChange, toDisplay],
346
+ [submitValue, cancelEdit, value, step, applyDisplayDelta, onChange, toDisplay, formatDisplay],
324
347
  )
325
348
 
326
349
  const displayValue = toDisplay(shown)
@@ -376,6 +399,7 @@ export function SliderControl({
376
399
  </span>
377
400
  )}
378
401
  <input
402
+ aria-label={typeof label === 'string' ? label : undefined}
379
403
  autoFocus
380
404
  className="w-14 bg-transparent p-0 text-right font-mono text-foreground outline-none selection:bg-primary/30"
381
405
  onBlur={submitValue}
@@ -402,22 +426,26 @@ export function SliderControl({
402
426
  )}
403
427
  </>
404
428
  ) : mixed && !isDragging ? (
405
- <div
429
+ <button
430
+ type="button"
431
+ aria-label={typeof label === 'string' ? i18n._({ id: 'editor.controls.sliderControl.editLabel', message: 'Edit {label}', values: { label } }) : undefined}
406
432
  className="flex cursor-text items-center text-muted-foreground transition-colors hover:text-foreground"
407
433
  onClick={handleValueClick}
408
434
  >
409
- <span className="font-mono tracking-tight">Mixed</span>
410
- </div>
435
+ <span className="font-mono tracking-tight">{i18n._({ id: 'editor.controls.sliderControl.mixed', message: 'Mixed' })}</span>
436
+ </button>
411
437
  ) : (
412
- <div
438
+ <button
439
+ type="button"
440
+ aria-label={typeof label === 'string' ? i18n._({ id: 'editor.controls.sliderControl.editLabel', message: 'Edit {label}', values: { label } }) : undefined}
413
441
  className="flex cursor-text items-center text-foreground/60 transition-colors hover:text-foreground"
414
442
  onClick={handleValueClick}
415
443
  >
416
444
  <span className="font-mono tabular-nums tracking-tight" suppressHydrationWarning>
417
- {Number(displayValue.toFixed(precision)).toFixed(precision)}
445
+ {formatDisplay(displayValue)}
418
446
  </span>
419
447
  {displayUnit && <span className="ml-[1px] text-muted-foreground">{displayUnit}</span>}
420
- </div>
448
+ </button>
421
449
  )}
422
450
  </div>
423
451
  </div>
@@ -34,50 +34,80 @@ function ToolOptionRow({
34
34
  return (
35
35
  <div className="flex flex-col gap-2">
36
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-black/4 text-muted-foreground hover:bg-black/8 hover:text-foreground dark:bg-muted/40 dark:hover:bg-muted',
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>
37
+ {option.input ? (
38
+ <label className="flex items-center gap-2 rounded-lg border border-border px-2 py-1.5">
39
+ <input
40
+ key={value}
41
+ aria-label={option.label}
42
+ className="min-w-0 flex-1 bg-transparent text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
43
+ type="number"
44
+ defaultValue={value}
45
+ min={option.input.min}
46
+ max={option.input.max}
47
+ step={option.input.step ?? 'any'}
48
+ onBlur={(event) => {
49
+ const next = event.target.valueAsNumber
50
+ if (
51
+ Number.isFinite(next) &&
52
+ !event.target.validity.rangeUnderflow &&
53
+ !event.target.validity.rangeOverflow
54
+ ) {
55
+ option.set(String(next))
56
+ onSelect?.(option, String(next))
57
+ } else event.target.value = value
58
+ }}
59
+ onKeyDown={(event) => {
60
+ if (event.key === 'Enter') event.currentTarget.blur()
61
+ }}
62
+ />
63
+ <span className="text-xs text-muted-foreground">{option.input.unit}</span>
64
+ </label>
65
+ ) : (
66
+ <div
67
+ className="grid gap-1.5"
68
+ style={{
69
+ gridTemplateColumns: `repeat(${Math.min(option.choices.length, 3)}, minmax(0, 1fr))`,
70
+ }}
71
+ >
72
+ {option.choices.map((choice) => {
73
+ const selected = active && choice.value === value
74
+ const thumbnail = getChoiceThumbnail?.(option, choice.value)
75
+ return (
76
+ <button
77
+ aria-pressed={selected}
78
+ className={cn(
79
+ 'rounded-lg px-2 py-2 text-center font-medium text-xs transition-colors',
80
+ selected
81
+ ? 'bg-primary/10 text-primary ring-1 ring-primary/50'
82
+ : 'bg-black/4 text-muted-foreground hover:bg-black/8 hover:text-foreground dark:bg-muted/40 dark:hover:bg-muted',
83
+ )}
84
+ key={choice.value}
85
+ onClick={() => {
86
+ triggerSFX('sfx:menu-click')
87
+ option.set(choice.value)
88
+ onSelect?.(option, choice.value)
89
+ }}
90
+ onMouseEnter={() => triggerSFX('sfx:menu-hover')}
91
+ type="button"
92
+ >
93
+ {thumbnail && (
94
+ <Image
95
+ alt=""
96
+ className={cn(
97
+ 'mx-auto mb-1 size-14 object-contain',
98
+ !selected && 'opacity-70 grayscale',
99
+ )}
100
+ height={56}
101
+ src={thumbnail}
102
+ width={56}
103
+ />
104
+ )}
105
+ {choice.label}
106
+ </button>
107
+ )
108
+ })}
109
+ </div>
110
+ )}
81
111
  {activeChoice && activeChoice.description ? (
82
112
  <p className="px-0.5 text-[11px] text-muted-foreground leading-relaxed">
83
113
  {activeChoice.description}