@volter/editor-game 0.5.65

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 (302) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +23 -0
  3. package/contributions/asset-budget-asset.menu.ts +26 -0
  4. package/contributions/asset-budget.action.ts +18 -0
  5. package/contributions/asset-budget.document.tsx +21 -0
  6. package/contributions/asset-budget.menu.ts +22 -0
  7. package/contributions/audio-unlock.service.ts +17 -0
  8. package/contributions/audio.utility.tsx +15 -0
  9. package/contributions/autoplay.service.ts +48 -0
  10. package/contributions/bridge.command.ts +172 -0
  11. package/contributions/build-profiles.document.tsx +21 -0
  12. package/contributions/build-progress.status.tsx +45 -0
  13. package/contributions/build.action.ts +26 -0
  14. package/contributions/build.command.ts +27 -0
  15. package/contributions/build.header.tsx +37 -0
  16. package/contributions/build.menu.ts +31 -0
  17. package/contributions/build.service.ts +31 -0
  18. package/contributions/connection.status.tsx +43 -0
  19. package/contributions/coverage.service.ts +118 -0
  20. package/contributions/edit-mode-audio.service.ts +23 -0
  21. package/contributions/edit-mode-networking.service.ts +30 -0
  22. package/contributions/game-document.service.ts +25 -0
  23. package/contributions/game-eval.command.ts +210 -0
  24. package/contributions/game.layout.ts +19 -0
  25. package/contributions/gameplay.command.ts +255 -0
  26. package/contributions/generation.service.ts +96 -0
  27. package/contributions/generations.status.tsx +53 -0
  28. package/contributions/ingest.service.ts +80 -0
  29. package/contributions/instances.command.ts +75 -0
  30. package/contributions/navmesh.menu.ts +39 -0
  31. package/contributions/navmesh.service.ts +19 -0
  32. package/contributions/network.utility.tsx +17 -0
  33. package/contributions/play.command.ts +355 -0
  34. package/contributions/profiler.action.ts +16 -0
  35. package/contributions/profiler.menu.ts +22 -0
  36. package/contributions/profiler.utility.tsx +17 -0
  37. package/contributions/react/component-board.service.ts +21 -0
  38. package/contributions/react/design-time-mount.service.ts +60 -0
  39. package/contributions/react/pasteboard.action.ts +41 -0
  40. package/contributions/react/react-inspector.service.ts +85 -0
  41. package/contributions/react/story-documents.service.ts +44 -0
  42. package/contributions/scene-document.service.ts +32 -0
  43. package/contributions/state-watch.action.ts +17 -0
  44. package/contributions/state-watch.menu.ts +23 -0
  45. package/contributions/state-watch.utility.tsx +20 -0
  46. package/contributions/team-playtest.service.ts +124 -0
  47. package/contributions/three/camera-runtime.inspector.tsx +34 -0
  48. package/contributions/three/component-board.service.ts +24 -0
  49. package/contributions/three/component-verbs.command.ts +110 -0
  50. package/contributions/three/component-verbs.service.ts +92 -0
  51. package/contributions/three/constraints.inspector.tsx +34 -0
  52. package/contributions/three/model-asset-sections.service.ts +26 -0
  53. package/contributions/three/reflection-probe-capture.inspector.tsx +32 -0
  54. package/contributions/three/story-documents.service.ts +31 -0
  55. package/contributions/three/three-authoring.service.ts +66 -0
  56. package/contributions/transport.header.tsx +19 -0
  57. package/contributions/xstate-behavior.action.ts +42 -0
  58. package/contributions/xstate-behavior.document.tsx +73 -0
  59. package/contributions/xstate-behavior.inspector.tsx +28 -0
  60. package/contributions/xstate-behavior.menu.ts +23 -0
  61. package/package.json +144 -0
  62. package/src/asset-budget/AssetBudgetPanel.tsx +1172 -0
  63. package/src/asset-budget/asset-budget-model.ts +799 -0
  64. package/src/asset-budget/basis-encoder.ts +165 -0
  65. package/src/asset-budget/gltf-io.ts +154 -0
  66. package/src/asset-budget/gltf-optimize.ts +384 -0
  67. package/src/asset-budget/image-dims.ts +78 -0
  68. package/src/asset-budget/optimize-apply.ts +221 -0
  69. package/src/audio/AudioDebuggerPanel.tsx +457 -0
  70. package/src/audio/audio-debugger-model.ts +87 -0
  71. package/src/bridge/call.ts +60 -0
  72. package/src/bridge/dispatch.ts +459 -0
  73. package/src/bridge/live-frames.ts +25 -0
  74. package/src/bridge/screenshot.ts +316 -0
  75. package/src/build/BuildProfilesPanel.tsx +476 -0
  76. package/src/build/build-session.ts +247 -0
  77. package/src/build/format-bytes.ts +14 -0
  78. package/src/command-results.ts +36 -0
  79. package/src/coverage/live-authoring-surface.ts +30 -0
  80. package/src/coverage/live-project-verbs.ts +162 -0
  81. package/src/coverage/native-system-coverage.ts +143 -0
  82. package/src/coverage/root-coverage.ts +79 -0
  83. package/src/coverage/session-coverage.ts +193 -0
  84. package/src/design-system-stories/ApplicationChrome.stories.tsx +100 -0
  85. package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +406 -0
  86. package/src/edit-mode/edit-mode-audio.ts +56 -0
  87. package/src/edit-mode/edit-mode-networking.ts +112 -0
  88. package/src/game-document/DevicePresetPicker.tsx +84 -0
  89. package/src/game-document/GameCaptureFrameButton.tsx +45 -0
  90. package/src/game-document/GameDocument.tsx +215 -0
  91. package/src/game-document/GamePanel.tsx +662 -0
  92. package/src/game-document/InstanceInspectorPicker.tsx +183 -0
  93. package/src/game-document/crowd-debug.ts +183 -0
  94. package/src/game-document/device-preview.ts +336 -0
  95. package/src/game-document/game-view-store.ts +107 -0
  96. package/src/game-document/physics-debug.ts +187 -0
  97. package/src/generation/GenerationActivity.tsx +421 -0
  98. package/src/generation/GenerationGallery.css +231 -0
  99. package/src/generation/generation-documents.tsx +338 -0
  100. package/src/generation/generation-jobs.ts +128 -0
  101. package/src/generation/generation-presentation.ts +257 -0
  102. package/src/host/adapter-reach.ts +445 -0
  103. package/src/host/adapter-runtime-bindings.ts +303 -0
  104. package/src/host/after-paint.ts +112 -0
  105. package/src/host/api/configurations.ts +66 -0
  106. package/src/host/authoring/babylon-authoring-adapter.ts +703 -0
  107. package/src/host/authoring/canvas-runtime-recognition.ts +50 -0
  108. package/src/host/authoring/contract-hierarchy-authoring.ts +144 -0
  109. package/src/host/authoring/contract-scenes-stories.ts +204 -0
  110. package/src/host/authoring/creation-site-related.ts +55 -0
  111. package/src/host/authoring/ephemeral-persistence.ts +29 -0
  112. package/src/host/authoring/gesture-persist.ts +84 -0
  113. package/src/host/authoring/ingest-data-writer.ts +232 -0
  114. package/src/host/authoring/ingest-source-persistence.ts +826 -0
  115. package/src/host/authoring/mount-isolated-pixi-screen.ts +250 -0
  116. package/src/host/authoring/mounted-authoring.ts +41 -0
  117. package/src/host/authoring/owned-pixi-ticker-listeners.ts +96 -0
  118. package/src/host/authoring/phaser-live-authoring-adapter.ts +265 -0
  119. package/src/host/authoring/pixi-authoring-adapter.ts +1554 -0
  120. package/src/host/authoring/pixi-creation-site-write-target.ts +60 -0
  121. package/src/host/authoring/pixi-isolation-assets.ts +25 -0
  122. package/src/host/authoring/pixi-live-write-target.ts +979 -0
  123. package/src/host/authoring/pixi-source-identity.ts +141 -0
  124. package/src/host/authoring/pixi-still-presentation.ts +71 -0
  125. package/src/host/authoring/pixi-structure-history.ts +237 -0
  126. package/src/host/authoring/pixi-transform-channels.ts +205 -0
  127. package/src/host/authoring/selection-remount-handoff.ts +23 -0
  128. package/src/host/authoring/source-persistence-backend.ts +373 -0
  129. package/src/host/authoring/source-refresh-revisions.ts +81 -0
  130. package/src/host/authoring/struct-write-pipe.ts +143 -0
  131. package/src/host/auto-frame-window.ts +89 -0
  132. package/src/host/binding-resolver.ts +393 -0
  133. package/src/host/browser-transpile.ts +631 -0
  134. package/src/host/canvas-entry-runtime.ts +95 -0
  135. package/src/host/components/CameraAuthoringOverlay.tsx +216 -0
  136. package/src/host/components/HeaderTelemetry.tsx +341 -0
  137. package/src/host/components/PixiIsolationSceneContent.tsx +280 -0
  138. package/src/host/components/ResolutionPicker.tsx +89 -0
  139. package/src/host/components/ThreeIsolationSceneContent.tsx +180 -0
  140. package/src/host/components/frame-debugger-model.ts +579 -0
  141. package/src/host/components/header-telemetry-model.ts +74 -0
  142. package/src/host/components/scene-document.tsx +447 -0
  143. package/src/host/components/utility-view-state.ts +87 -0
  144. package/src/host/components/world-root-stage-binding.tsx +133 -0
  145. package/src/host/components/world-root-stage.ts +1046 -0
  146. package/src/host/coverage/authoring-read-probe.ts +583 -0
  147. package/src/host/coverage/capability-coverage.ts +1587 -0
  148. package/src/host/coverage/coverage-accounting.ts +261 -0
  149. package/src/host/coverage/game-contract-seam-evidence.ts +82 -0
  150. package/src/host/coverage/project-verb-coverage.ts +148 -0
  151. package/src/host/coverage/system-adapter-coverage.ts +373 -0
  152. package/src/host/design-system-stories/StoryLayout.tsx +104 -0
  153. package/src/host/design-system-stories/fixtures/authoring.ts +247 -0
  154. package/src/host/design-system-stories/fixtures/editor-runtime.tsx +115 -0
  155. package/src/host/document-preview-three.ts +110 -0
  156. package/src/host/entry-adjudication.ts +89 -0
  157. package/src/host/game-location-guard.ts +150 -0
  158. package/src/host/game-module-access.ts +196 -0
  159. package/src/host/game-realm-page.ts +358 -0
  160. package/src/host/game-realm-reclaim.ts +55 -0
  161. package/src/host/game-realm-storage.ts +104 -0
  162. package/src/host/gameplay-export.ts +288 -0
  163. package/src/host/gameplay-recording.ts +717 -0
  164. package/src/host/gated-globals.ts +1511 -0
  165. package/src/host/history/json-history-resource.ts +254 -0
  166. package/src/host/ingest/registry.ts +261 -0
  167. package/src/host/instance-extract-actions.ts +120 -0
  168. package/src/host/instance-fork-actions.ts +120 -0
  169. package/src/host/play-control-hook.ts +26 -0
  170. package/src/host/playwright-shim.ts +479 -0
  171. package/src/host/projection/dom.ts +295 -0
  172. package/src/host/projection/pixi.ts +288 -0
  173. package/src/host/r3f-entry-runtime.ts +77 -0
  174. package/src/host/react-mount-runtime.ts +162 -0
  175. package/src/host/realm-services.ts +148 -0
  176. package/src/host/recording-preview.ts +106 -0
  177. package/src/host/roots/module-root.ts +203 -0
  178. package/src/host/roots/react-root.ts +181 -0
  179. package/src/host/same-realm-loop-gate.ts +544 -0
  180. package/src/host/scene-view-drawability.ts +69 -0
  181. package/src/host/sdk/tools.ts +31 -0
  182. package/src/host/served-bundle-runtime-modules.ts +331 -0
  183. package/src/host/server-log-bridge.ts +60 -0
  184. package/src/host/staged-projects.ts +24 -0
  185. package/src/host/stories/mounted-story-viewport-source.ts +64 -0
  186. package/src/host/stories/story-arg-descriptors.ts +50 -0
  187. package/src/host/stories/story-media-presence.ts +152 -0
  188. package/src/host/surface-content.ts +87 -0
  189. package/src/host/take-named-export.ts +23 -0
  190. package/src/host/three-ingest-runtime.ts +76 -0
  191. package/src/host/types-fastnoise-lite.d.ts +7 -0
  192. package/src/host/types-mikktspace.d.ts +20 -0
  193. package/src/host/types-troika-three-text.d.ts +7 -0
  194. package/src/host/use-active-performance-source.ts +53 -0
  195. package/src/host/viewport-pose-memory.ts +48 -0
  196. package/src/host/viewport-root-presentation.ts +40 -0
  197. package/src/ingest/active-ingest.ts +251 -0
  198. package/src/ingest/active-scene-navigation.ts +43 -0
  199. package/src/ingest/authoring/ingest-dom-surface-authoring.ts +191 -0
  200. package/src/ingest/authoring/ingest-root-adapter.ts +897 -0
  201. package/src/ingest/capture-wait-report.ts +122 -0
  202. package/src/ingest/deferred-ingest-play.ts +216 -0
  203. package/src/ingest/deferred-ingest-session.ts +23 -0
  204. package/src/ingest/discovery-public-ingest.ts +242 -0
  205. package/src/ingest/dom-stub-mark.ts +7 -0
  206. package/src/ingest/entry-load.ts +56 -0
  207. package/src/ingest/game-contract-realm.ts +34 -0
  208. package/src/ingest/game-pointer-lock.ts +89 -0
  209. package/src/ingest/held-scene-repaint.ts +73 -0
  210. package/src/ingest/host-surface-box.ts +174 -0
  211. package/src/ingest/ingest-boot-viewport.ts +54 -0
  212. package/src/ingest/ingest-canvas-scene-document.tsx +177 -0
  213. package/src/ingest/ingest-canvas-scene.ts +50 -0
  214. package/src/ingest/ingest-evidence-hook.ts +131 -0
  215. package/src/ingest/ingest-frame-snapshot.ts +183 -0
  216. package/src/ingest/ingest-play-commands.ts +134 -0
  217. package/src/ingest/ingest-play-control.ts +299 -0
  218. package/src/ingest/ingest-render-debug.ts +320 -0
  219. package/src/ingest/ingest-siblings.ts +487 -0
  220. package/src/ingest/ingest-status.ts +62 -0
  221. package/src/ingest/live-ingest-facet.ts +46 -0
  222. package/src/ingest/module-mode.ts +229 -0
  223. package/src/ingest/mount-canvas-ingest-root.ts +899 -0
  224. package/src/ingest/mount-coverage.ts +296 -0
  225. package/src/ingest/mount-dom-ingest-root.ts +282 -0
  226. package/src/ingest/mount-ingest-root.ts +744 -0
  227. package/src/ingest/mount-three-ingest-root.ts +366 -0
  228. package/src/ingest/resolve-canvas.ts +47 -0
  229. package/src/ingest/resolve-three.ts +123 -0
  230. package/src/ingest/served-bundle.ts +109 -0
  231. package/src/ingest/served-html-boot.ts +292 -0
  232. package/src/ingest/surface-canvas.ts +84 -0
  233. package/src/ingest/surface-dom.ts +84 -0
  234. package/src/ingest/surface-three.ts +128 -0
  235. package/src/ingest/types.ts +76 -0
  236. package/src/ingest/unmount-ingest-root.ts +224 -0
  237. package/src/navmesh/navmesh-actions.ts +27 -0
  238. package/src/navmesh/navmesh-handler.ts +237 -0
  239. package/src/navmesh/navmesh-workflow-store.ts +78 -0
  240. package/src/network/NetworkInspectorPanel.tsx +644 -0
  241. package/src/network/network-inspector-model.ts +225 -0
  242. package/src/play/play-boot-stall.ts +118 -0
  243. package/src/play/play-log-events.ts +26 -0
  244. package/src/play/play-mode.ts +2553 -0
  245. package/src/play/play-recording.ts +335 -0
  246. package/src/play/react-play-live-authoring.ts +165 -0
  247. package/src/play-bar/PlayBar.tsx +488 -0
  248. package/src/play-bar/PlayerCountPicker.tsx +100 -0
  249. package/src/profiler/FrameDebuggerPanel.tsx +458 -0
  250. package/src/profiler/PerformancePanel.tsx +1068 -0
  251. package/src/profiler/ProfilerPanel.tsx +53 -0
  252. package/src/profiler/frame-debugger-store.ts +97 -0
  253. package/src/profiler/main-thread-busy.ts +90 -0
  254. package/src/react/design-time-react-mount.ts +782 -0
  255. package/src/react/dom-authoring-adapter.ts +764 -0
  256. package/src/react/pasteboard-materialize.ts +154 -0
  257. package/src/react/react-inspector-section.tsx +2496 -0
  258. package/src/react/react-world-authoring-adapter.ts +3795 -0
  259. package/src/react/story-documents/story-args-section.ts +19 -0
  260. package/src/react/story-documents/story-documents.tsx +1380 -0
  261. package/src/react/story-paint-bounds.ts +94 -0
  262. package/src/react/ui-board-document.tsx +101 -0
  263. package/src/react/ui-board-title.ts +9 -0
  264. package/src/react/ui-component-board.ts +72 -0
  265. package/src/services/audio-pose-guard.ts +81 -0
  266. package/src/services/game-audio-unlock.ts +48 -0
  267. package/src/state-watch/StateWatchPanel.tsx +535 -0
  268. package/src/three/authoring/camera-runtime-inspector-section.tsx +135 -0
  269. package/src/three/authoring/constraint-inspector-section.tsx +189 -0
  270. package/src/three/authoring/design-time-renderer.ts +189 -0
  271. package/src/three/authoring/model-asset-inspector-section.css +41 -0
  272. package/src/three/authoring/model-asset-inspector-section.tsx +869 -0
  273. package/src/three/authoring/oid-source-persistence.ts +557 -0
  274. package/src/three/authoring/r3f-design-session.ts +1174 -0
  275. package/src/three/authoring/r3f-source-authoring-adapter.ts +5949 -0
  276. package/src/three/authoring/reflection-probe-inspector-section.tsx +76 -0
  277. package/src/three/authoring/spatial-audio-handles.ts +215 -0
  278. package/src/three/authoring/spatial-collider-handles.ts +229 -0
  279. package/src/three/authoring/spatial-joint-handles.ts +114 -0
  280. package/src/three/authoring/spatial-light-handles.ts +178 -0
  281. package/src/three/authoring/spatial-lod-handles.ts +93 -0
  282. package/src/three/authoring/spatial-particle-handles.ts +368 -0
  283. package/src/three/authoring/three-authoring-adapter.ts +1953 -0
  284. package/src/three/authoring/three-scene-identity.ts +19 -0
  285. package/src/three/authoring/three-spatial-handles.ts +161 -0
  286. package/src/three/authoring/typed-three-inspector.ts +528 -0
  287. package/src/three/component-verbs/extract-menu.ts +71 -0
  288. package/src/three/component-verbs/fork-menu.ts +78 -0
  289. package/src/three/component-verbs/internals-menu.ts +91 -0
  290. package/src/three/story-documents/three-story-documents.tsx +607 -0
  291. package/src/three/three-board/ThreeBoardDocument.tsx +888 -0
  292. package/src/three/three-board/board-framing.ts +412 -0
  293. package/src/three/three-board/board-layout.ts +401 -0
  294. package/src/three/three-board/board-scene.ts +901 -0
  295. package/src/three/three-board/three-component-board.ts +62 -0
  296. package/src/xstate/XStateBehaviorSection.tsx +130 -0
  297. package/src/xstate/XStateMachineInspector.tsx +566 -0
  298. package/src/xstate/character-animation-machine.fixture.ts +74 -0
  299. package/src/xstate/live-behaviors.ts +106 -0
  300. package/src/xstate/use-live-actor-state.ts +44 -0
  301. package/src/xstate/xstate-graph.ts +235 -0
  302. package/src/xstate/xstate-layout.ts +76 -0
@@ -0,0 +1,2496 @@
1
+ import { setAuthoringSelection } from '@volter/editor-core/authoring/consumer-actions';
2
+ import { faBookBookmark, faCode, faPalette } from '@fortawesome/free-solid-svg-icons';
3
+ import {
4
+ Button,
5
+ Checkbox,
6
+ DraftTextInput,
7
+ Select,
8
+ TextArea,
9
+ TextInput,
10
+ } from '@volter/editor-sdk/widgets';
11
+
12
+ /**
13
+ * React-world inspector section (C2, spec 27 §5 C2) — the "wire the widgets"
14
+ * half of the widget story. `components/inspector-widgets/` is a PURE widget kit
15
+ * with no wiring; this module is the adapter-module contribution that plugs it
16
+ * into the ONE inspector shell (`components/Inspector.tsx`) via
17
+ * `inspector-section-registry.ts`, the SAME seam
18
+ * `authoring/first-party-inspector/register.tsx` (three) and
19
+ * `authoring/ui-inspector-sections.tsx` (scene-UI) already use — see those two
20
+ * files for the sanctioned pattern this one follows.
21
+ *
22
+ * Matches any node owned by a react/DOM adapter — `ReactRootAuthoringAdapter`
23
+ * (Lane B, `data-oid` component-source writes, T6.2) or
24
+ * `DomAuthoringAdapter` (the ingested/overlay DOM adapter) —
25
+ * whether the active adapter IS one of those directly (a bare single-world
26
+ * session) or is a `CompositeAuthoringAdapter` routing to one as `id`'s
27
+ * owner (multi-world edit mode). Renders every `PropertyDescriptor` the
28
+ * adapter's `InspectorProvider.properties(id)` reports, GROUPED by
29
+ * `PropertyDescriptor.group` (falling back to one flat "Style" group for an
30
+ * adapter, like `DomAuthoringAdapter`, whose descriptors declare none),
31
+ * mapping each descriptor to the right C1 widget instead of a generic
32
+ * `<TextInput>` — see `widgetKindFor`'s doc comment for the exact mapping table.
33
+ * It CLAIMS `PROPERTIES_SECTION_ID`, so it stands in for the shell's generic
34
+ * property grid on a matched node (Rule zero: this section still talks to the
35
+ * node ONLY through `adapter.inspector`/`adapter.hierarchy`, never the
36
+ * DOM/fiber the adapter itself holds).
37
+ *
38
+ * `onChange` (every interaction tick) and `onChangeEnd` (the commit) both
39
+ * route through the SAME `adapter.inspector.set` call — unlike
40
+ * `BoxEditProvider`'s begin/apply/end split (a spatial-gesture concern),
41
+ * `ReactRootAuthoringAdapter.inspector.set` has exactly ONE write
42
+ * path (T0 §2/A4): it echoes the value into its optimistic cache
43
+ * synchronously (so the field never snaps back) and fires the async
44
+ * source-write itself. There is no separate "preview without writing" mode
45
+ * to route `onChange` through, so both handlers call `commit`, mirroring the
46
+ * shell's own pre-existing `setProp` helper (`components/Inspector.tsx`)
47
+ * which already does the same on every keystroke of the generic grid this
48
+ * section replaces.
49
+ */
50
+
51
+ import {
52
+ activeBreakpoint,
53
+ setActiveBreakpoint,
54
+ subscribeBreakpoint,
55
+ } from '@volter/editor-core/authoring/breakpoint-state';
56
+ import { CompositeAuthoringAdapter } from '@volter/editor-core/authoring/composite-authoring-adapter';
57
+ import { numericStyleValue, UNITLESS_NUMBER_PROPS } from '@volter/editor-core/authoring/css-numeric-style';
58
+ import { beginEyedropperSession } from '@volter/editor-core/authoring/eyedropper-session';
59
+ import { groupProperties } from '@volter/editor-core/components/inspector-property-grouping';
60
+ import { useEditorStore } from '@volter/editor-core/editor-runtime';
61
+ import { createHmrRegistrationGroup } from '@volter/editor-core/hmr-registration-group';
62
+ import { groupIcon } from '@volter/editor-core/inspection/compose';
63
+ import {
64
+ GROUP_SECTION_ORDER,
65
+ groupSectionId,
66
+ type InspectionSection,
67
+ PROPERTIES_SECTION_ID,
68
+ PROPERTIES_SECTION_ORDER,
69
+ } from '@volter/editor-core/inspection/model';
70
+ import { registerInspectorSections } from '@volter/editor-core/inspector-section-registry';
71
+ import { effectiveColorFromChain, getAvailableFonts } from '@volter/editor-core/ui-source/inspect';
72
+ import type { OidEntry } from '@volter/editor-core/ui-source/oid-transform';
73
+ import type { AuthoringAdapter, EditorNode, PropertyDescriptor } from '@volter/editor-project/adapter';
74
+ import {
75
+ AlignmentGrid,
76
+ type AlignmentValue,
77
+ BorderEditor,
78
+ BorderRadiusEditor,
79
+ type BorderRadiusValue,
80
+ type BorderSide,
81
+ type BorderValue,
82
+ ColorSwatch,
83
+ composeFilter,
84
+ composeGradient,
85
+ composeShadows,
86
+ FilterEditor,
87
+ type FilterFunc,
88
+ FONT_MONO,
89
+ FontPicker,
90
+ fontSizeVar,
91
+ GradientEditor,
92
+ type GradientValue,
93
+ isUniformBorder,
94
+ isUniformRadius,
95
+ LABEL_COL_WIDTH,
96
+ lineHeightVar,
97
+ parseFilterFunctions,
98
+ parseGradient,
99
+ parseShadows,
100
+ radius,
101
+ rowStyle,
102
+ ScrubbableInput,
103
+ ShadowEditor,
104
+ spaceVar,
105
+ TextShadowEditor,
106
+ THEME,
107
+ } from '@volter/editor-sdk/widgets';
108
+ import { createElement, useMemo, useState, useSyncExternalStore } from 'react';
109
+ import { DomAuthoringAdapter } from './dom-authoring-adapter';
110
+ import { cssColorToHex, ReactRootAuthoringAdapter } from './react-world-authoring-adapter';
111
+
112
+ /**
113
+ * The child adapter that actually owns `node.id` — a bare react/DOM adapter
114
+ * itself, or (via a `CompositeAuthoringAdapter`) whichever child's
115
+ * `ownerOf`/`childAdapters()` claims the id (the SAME public T0 query
116
+ * `components/Inspector.tsx`'s `worldGroupChildAdapter` uses — never decode a
117
+ * reserved id here). `null` for anything not owned by a
118
+ * react/DOM adapter (a three/scene-UI node, or an unresolved id).
119
+ */
120
+ export function resolveReactOwner(
121
+ node: EditorNode | null,
122
+ adapter: AuthoringAdapter,
123
+ ): ReactRootAuthoringAdapter | DomAuthoringAdapter | null {
124
+ if (!node) return null;
125
+ const target: AuthoringAdapter | null = (() => {
126
+ if (!(adapter instanceof CompositeAuthoringAdapter)) return adapter;
127
+ const owner = adapter.ownerOf(node.id);
128
+ if (owner === null) return null;
129
+ return adapter.childAdapters().find((c) => c.worldId === owner)?.adapter ?? null;
130
+ })();
131
+ if (target instanceof ReactRootAuthoringAdapter) return target;
132
+ if (target instanceof DomAuthoringAdapter) return target;
133
+ return null;
134
+ }
135
+
136
+ /** The registry `match`: any real selection owned by a react/DOM adapter. */
137
+ export const isDomSelection = (node: EditorNode | null, adapter: AuthoringAdapter): boolean =>
138
+ resolveReactOwner(node, adapter) !== null;
139
+
140
+ /** The FACETS this adapter contributes, on the model's shared id/order scales
141
+ * (`inspection/model.ts`). A section id names a facet of the inspected thing
142
+ * — never the lane or adapter that happens to render it; what the object IS
143
+ * belongs to the subject's identity row. They lead: what the element IS comes
144
+ * before what it looks like, in the column and in the card's tab strip alike. */
145
+ export const REACT_ELEMENT_SECTION_ID = 'element';
146
+ const REACT_ELEMENT_SECTION_ORDER = 1500;
147
+ export const REACT_STORY_SECTION_ID = 'story';
148
+ const REACT_STORY_SECTION_ORDER = 1600;
149
+
150
+ // --- widget mapping (the C2 "map PropertyDescriptor.type/group -> widget" table) ---
151
+
152
+ export type ReactPropertyWidgetKind =
153
+ | 'color'
154
+ | 'font'
155
+ | 'gradient'
156
+ | 'shadow'
157
+ | 'text-shadow'
158
+ | 'filter'
159
+ | 'backdrop-filter'
160
+ | 'radius'
161
+ | 'transform'
162
+ | 'scrub'
163
+ | 'select'
164
+ | 'checkbox'
165
+ | 'json'
166
+ | 'text';
167
+
168
+ function propNameFromPath(path: string): string {
169
+ const idx = path.indexOf('.');
170
+ return idx === -1 ? path : path.slice(idx + 1);
171
+ }
172
+
173
+ /**
174
+ * A single `PropertyDescriptor` -> the C1 widget that renders it. Structured
175
+ * CSS values (gradient/shadow/filter/radius) are matched by their well-known
176
+ * style-prop NAME (the react adapters' `STYLE_PROPERTIES` tables are the
177
+ * only source of these paths today — `PropertyDescriptor.type` alone can't
178
+ * distinguish a plain string from a composable CSS function list); every
179
+ * other descriptor maps off its plain `type`.
180
+ */
181
+ export function widgetKindFor(prop: PropertyDescriptor): ReactPropertyWidgetKind {
182
+ const name = propNameFromPath(prop.path);
183
+ if (prop.type === 'color') return 'color';
184
+ if (/^fontFamily$/i.test(name)) return 'font';
185
+ if (name === 'background' || name === 'backgroundImage') return 'gradient';
186
+ if (name === 'boxShadow') return 'shadow';
187
+ if (name === 'textShadow') return 'text-shadow';
188
+ if (name === 'filter') return 'filter';
189
+ if (name === 'backdropFilter') return 'backdrop-filter';
190
+ if (name === 'borderRadius') return 'radius';
191
+ // Scoped to `type: 'string'` (the react-world `style.transform` CSS-string
192
+ // descriptor) so an unrelated `transform` name of a DIFFERENT type — e.g. a
193
+ // three `vec3` position/transform prop — still falls through to its own
194
+ // generic widget instead of being misread as a CSS transform string.
195
+ if (prop.type === 'string' && name === 'transform') return 'transform';
196
+ if (prop.type === 'boolean') return 'checkbox';
197
+ if (prop.type === 'enum') return 'select';
198
+ if (prop.type === 'json') return 'json';
199
+ if (prop.type === 'number') return 'scrub';
200
+ // 'string' / 'vec3' / 'asset' — no dedicated widget in this task's mapping;
201
+ // falls through to a plain text input (see the report's "no widget" list).
202
+ return 'text';
203
+ }
204
+
205
+ /** A composed row: either one property, or a "combo" of several sibling
206
+ * properties this section renders as ONE richer widget (per-side border,
207
+ * 3x3 flex alignment) instead of N separate fields. */
208
+ export type PropertyRow =
209
+ | {
210
+ kind: 'border';
211
+ width: PropertyDescriptor;
212
+ style: PropertyDescriptor;
213
+ color: PropertyDescriptor;
214
+ }
215
+ | { kind: 'align'; justify: PropertyDescriptor; align: PropertyDescriptor }
216
+ | { kind: 'single'; prop: PropertyDescriptor };
217
+
218
+ /**
219
+ * Groups the react-world adapter's separate `borderWidth`/`borderStyle`/
220
+ * `borderColor` scalars into one `BorderEditor` row, and `justifyContent`/
221
+ * `alignItems` into one `AlignmentGrid` row — order-preserving single pass
222
+ * (a combo is emitted at the position of its FIRST member; already-consumed
223
+ * members are skipped later in the pass). Any property list missing one or
224
+ * more combo members (e.g. `DomAuthoringAdapter`'s flatter list) simply
225
+ * never forms that combo — every member renders as its own `single` row.
226
+ */
227
+ /** Detects the `borderWidth`/`borderStyle`/`borderColor` trio starting at
228
+ * `p` and, if all three are present, pushes ONE `border` combo row. Split
229
+ * out of {@link buildPropertyRows} to keep its own cognitive complexity
230
+ * under biome's ceiling (same discipline as `GradientEditor.tsx`'s helper
231
+ * extraction). Returns `true` only when a combo row was actually formed (so
232
+ * the caller can `continue` past all three consumed members); `false` when
233
+ * `p` isn't a border-trio member, or is one but its siblings aren't present
234
+ * (falls through to an ordinary `single` row instead). */
235
+ function tryBorderCombo(
236
+ p: PropertyDescriptor,
237
+ byPath: ReadonlyMap<string, PropertyDescriptor>,
238
+ consumed: Set<string>,
239
+ rows: PropertyRow[],
240
+ ): boolean {
241
+ const isBorderProp =
242
+ p.path === 'style.borderWidth' ||
243
+ p.path === 'style.borderStyle' ||
244
+ p.path === 'style.borderColor';
245
+ if (!isBorderProp) return false;
246
+ const width = byPath.get('style.borderWidth');
247
+ const style = byPath.get('style.borderStyle');
248
+ const color = byPath.get('style.borderColor');
249
+ if (!width || !style || !color) return false;
250
+ rows.push({ kind: 'border', width, style, color });
251
+ consumed.add(width.path);
252
+ consumed.add(style.path);
253
+ consumed.add(color.path);
254
+ return true;
255
+ }
256
+
257
+ /** Detects the `justifyContent`/`alignItems` pair starting at `p` and, if
258
+ * both are present, pushes ONE `align` combo row. See {@link tryBorderCombo}'s
259
+ * doc comment for why this is its own function. */
260
+ function tryAlignCombo(
261
+ p: PropertyDescriptor,
262
+ byPath: ReadonlyMap<string, PropertyDescriptor>,
263
+ consumed: Set<string>,
264
+ rows: PropertyRow[],
265
+ ): boolean {
266
+ const isAlignProp = p.path === 'style.justifyContent' || p.path === 'style.alignItems';
267
+ if (!isAlignProp) return false;
268
+ const justify = byPath.get('style.justifyContent');
269
+ const align = byPath.get('style.alignItems');
270
+ if (!justify || !align) return false;
271
+ rows.push({ kind: 'align', justify, align });
272
+ consumed.add(justify.path);
273
+ consumed.add(align.path);
274
+ return true;
275
+ }
276
+
277
+ export function buildPropertyRows(props: readonly PropertyDescriptor[]): PropertyRow[] {
278
+ const byPath = new Map(props.map((p) => [p.path, p] as const));
279
+ const consumed = new Set<string>();
280
+ const rows: PropertyRow[] = [];
281
+ for (const p of props) {
282
+ if (consumed.has(p.path)) continue;
283
+ if (tryBorderCombo(p, byPath, consumed, rows)) continue;
284
+ if (tryAlignCombo(p, byPath, consumed, rows)) continue;
285
+ rows.push({ kind: 'single', prop: p });
286
+ }
287
+ return rows;
288
+ }
289
+
290
+ function numericValue(v: unknown): number {
291
+ return typeof v === 'number' && Number.isFinite(v) ? v : 0;
292
+ }
293
+
294
+ function testIdFor(path: string): string {
295
+ return `react-prop-${path.replace(/\./g, '-')}`;
296
+ }
297
+
298
+ const DEFAULT_GRADIENT: GradientValue = {
299
+ type: 'linear',
300
+ angle: 180,
301
+ stops: [
302
+ { color: '#ffffff', position: 0 },
303
+ { color: '#000000', position: 1 },
304
+ ],
305
+ };
306
+
307
+ /** A labeled row shell, matching `components/Inspector.tsx`'s own generic
308
+ * property row layout (`NUM_STYLE`'s spacing/label conventions, ported to
309
+ * this kit's `THEME`/`rowStyle`) so a react section slots in visually next
310
+ * to the sections it replaces. The wrapper's own test id is the row's
311
+ * `${testId}-row` — DISTINCT from the interactive control inside it (which
312
+ * carries the bare `testId`), so a select/text/json control and its row
313
+ * wrapper never collide (spec 27 §5 C2, S1). */
314
+ function Field({
315
+ label,
316
+ testId,
317
+ children,
318
+ }: {
319
+ label: string;
320
+ testId?: string | undefined;
321
+ children: React.ReactNode;
322
+ }): React.ReactElement {
323
+ return (
324
+ <div
325
+ data-testid={testId ? `${testId}-row` : undefined}
326
+ style={{ ...rowStyle, padding: `${spaceVar[2]} ${spaceVar[4]}` }}
327
+ >
328
+ <span
329
+ style={{
330
+ width: LABEL_COL_WIDTH,
331
+ flexShrink: 0,
332
+ fontSize: fontSizeVar.base,
333
+ color: THEME.textMuted,
334
+ }}
335
+ >
336
+ {label}
337
+ </span>
338
+ <div style={{ flex: 1, minWidth: 0 }}>{children}</div>
339
+ </div>
340
+ );
341
+ }
342
+
343
+ /** {@link PropertyRow}'s two combo variants — excludes `single` so
344
+ * {@link ComboRow} (only ever called for a combo row, see `GroupBlock`)
345
+ * narrows on `row.kind` without TS widening back to the full union. */
346
+ type ComboPropertyRow = Extract<PropertyRow, { kind: 'border' } | { kind: 'align' }>;
347
+
348
+ interface ComboRowProps {
349
+ adapter: AuthoringAdapter;
350
+ id: string;
351
+ row: ComboPropertyRow;
352
+ }
353
+
354
+ /** Every per-kind row renderer's shared inputs — `raw` is the CURRENT value
355
+ * read off `adapter.inspector.get`, `commit` writes a (possibly different)
356
+ * path through the SAME `adapter.inspector.set` + `notifyIngestEdit` pair
357
+ * (see the file doc comment) so a combo write-back (border/align, above)
358
+ * and a plain single-prop write share one code path. */
359
+ interface KindRowProps {
360
+ prop: PropertyDescriptor;
361
+ raw: unknown;
362
+ disabled: boolean | undefined;
363
+ testId: string;
364
+ commit: (path: string, value: unknown) => void;
365
+ fonts: readonly string[];
366
+ }
367
+
368
+ function ColorRow({ prop, raw, disabled, testId, commit }: KindRowProps): React.ReactElement {
369
+ return (
370
+ <Field label={prop.label} testId={testId}>
371
+ <ColorSwatch
372
+ testId={testId}
373
+ value={String(raw ?? '#ffffff')}
374
+ disabled={disabled}
375
+ onChange={(c) => commit(prop.path, c)}
376
+ onChangeEnd={(c) => commit(prop.path, c)}
377
+ eyedropperFallback={() => effectiveColorFromChain([String(raw ?? '')])}
378
+ // D3.d (spec 27 §6) — no native EyeDropper: begin an interactive
379
+ // canvas-pick session (`RootSelectionOverlay` renders the
380
+ // cursor-following swatch and resolves it) instead of the static
381
+ // synchronous fallback above, which this session-based path replaces
382
+ // whenever the host (this section, mounted inside the real editor)
383
+ // is present to wire it.
384
+ onEyedropperPick={(apply) => beginEyedropperSession(apply)}
385
+ />
386
+ </Field>
387
+ );
388
+ }
389
+
390
+ function FontRow({ prop, raw, disabled, fonts, testId, commit }: KindRowProps): React.ReactElement {
391
+ return (
392
+ <Field label={prop.label} testId={testId}>
393
+ <FontPicker
394
+ testId={testId}
395
+ value={String(raw ?? '')}
396
+ fonts={fonts}
397
+ disabled={disabled}
398
+ onChange={(f) => commit(prop.path, f)}
399
+ />
400
+ </Field>
401
+ );
402
+ }
403
+
404
+ function GradientRow({ prop, raw, disabled, testId, commit }: KindRowProps): React.ReactElement {
405
+ const parsed = parseGradient(String(raw ?? 'none'));
406
+ // S2 (anti-shim, CLAUDE.md "adapters never fabricate first-party data"): when
407
+ // the element has NO real gradient (`parseGradient` -> null), render an honest
408
+ // empty state — NOT a fabricated white→black `DEFAULT_GRADIENT` preview that
409
+ // misrepresents the source as already having a gradient. `DEFAULT_GRADIENT` is
410
+ // still the SEED for an explicit user "add" (a real, source-writing action),
411
+ // never a passive display value.
412
+ if (!parsed) {
413
+ return (
414
+ <Field label={prop.label} testId={testId}>
415
+ <Button
416
+ type="button"
417
+ variant="secondary"
418
+ className="vgai-field-trigger"
419
+ data-testid={testId}
420
+ disabled={disabled}
421
+ onClick={() => commit(prop.path, composeGradient(DEFAULT_GRADIENT))}
422
+ >
423
+ No gradient — click to add
424
+ </Button>
425
+ </Field>
426
+ );
427
+ }
428
+ return (
429
+ <Field label={prop.label} testId={testId}>
430
+ <GradientEditor
431
+ testId={testId}
432
+ value={parsed}
433
+ disabled={disabled}
434
+ onChange={(g) => commit(prop.path, composeGradient(g))}
435
+ onChangeEnd={(g) => commit(prop.path, composeGradient(g))}
436
+ />
437
+ </Field>
438
+ );
439
+ }
440
+
441
+ function ShadowRow({
442
+ prop,
443
+ raw,
444
+ disabled,
445
+ testId,
446
+ commit,
447
+ withSpreadInset,
448
+ }: KindRowProps & { withSpreadInset: boolean }): React.ReactElement {
449
+ const parsed = parseShadows(String(raw ?? 'none'), withSpreadInset);
450
+ const Widget = withSpreadInset ? ShadowEditor : TextShadowEditor;
451
+ return (
452
+ <Field label={prop.label} testId={testId}>
453
+ <Widget
454
+ value={parsed}
455
+ disabled={disabled}
456
+ onChange={(list) => commit(prop.path, composeShadows(list, withSpreadInset))}
457
+ onChangeEnd={(list) => commit(prop.path, composeShadows(list, withSpreadInset))}
458
+ />
459
+ </Field>
460
+ );
461
+ }
462
+
463
+ function FilterRow({
464
+ prop,
465
+ raw,
466
+ disabled,
467
+ testId,
468
+ commit,
469
+ label,
470
+ }: KindRowProps & { label: string }): React.ReactElement {
471
+ const parsed = parseFilterFunctions(String(raw ?? 'none'));
472
+ return (
473
+ <Field label={prop.label} testId={testId}>
474
+ <FilterEditor
475
+ testId={testId}
476
+ label={label}
477
+ value={parsed}
478
+ disabled={disabled}
479
+ onChange={(f: FilterFunc[]) => commit(prop.path, composeFilter(f))}
480
+ onChangeEnd={(f: FilterFunc[]) => commit(prop.path, composeFilter(f))}
481
+ />
482
+ </Field>
483
+ );
484
+ }
485
+
486
+ /** Corner suffixes (`borderTopLeftRadius`, …) shared by {@link RadiusRow}'s
487
+ * read + write-back — the per-corner longhand CSS the shorthand `borderRadius`
488
+ * descriptor (`prop`/`raw`/`commit` above) never had a slot for. */
489
+ const RADIUS_CORNERS: ReadonlyArray<{ key: keyof BorderRadiusValue; suffix: string }> = [
490
+ { key: 'topLeft', suffix: 'TopLeft' },
491
+ { key: 'topRight', suffix: 'TopRight' },
492
+ { key: 'bottomLeft', suffix: 'BottomLeft' },
493
+ { key: 'bottomRight', suffix: 'BottomRight' },
494
+ ];
495
+
496
+ /** U2 (spec 27 §5 "Effects: radius per-corner") — true per-corner write-back.
497
+ * Reads each corner's own `style.border<Corner>Radius` longhand through
498
+ * `adapter.inspector.get` (falling back to the shorthand `raw` when a
499
+ * corner's own computed value is unset/unparseable — the vitest `node` env
500
+ * resolver falls back to inline `.style`, which has no implicit per-corner
501
+ * values the way a REAL computed style always does) so the widget reflects
502
+ * a PRIOR non-uniform edit instead of collapsing back to uniform on every
503
+ * re-render. Write-back keeps the uniform shorthand fast-path (`commit`
504
+ * the same `borderRadius` path this row already used) but writes the four
505
+ * longhand corners when the widget reports a non-uniform value — removing
506
+ * the documented `:397-409` limitation (per-corner UI, uniform-only write). */
507
+ function RadiusRow({
508
+ adapter,
509
+ id,
510
+ prop,
511
+ raw,
512
+ disabled,
513
+ testId,
514
+ commit,
515
+ removeStyle,
516
+ }: KindRowProps & {
517
+ adapter: AuthoringAdapter;
518
+ id: string;
519
+ removeStyle: (path: string) => void;
520
+ }): React.ReactElement {
521
+ const fallback = numericValue(raw);
522
+ const value: BorderRadiusValue = RADIUS_CORNERS.reduce((acc, { key, suffix }) => {
523
+ const cornerRaw = adapter.inspector?.get(id, `style.border${suffix}Radius`);
524
+ acc[key] = numericStyleValue(cornerRaw) ?? fallback;
525
+ return acc;
526
+ }, {} as BorderRadiusValue);
527
+ const writeBack = (v: BorderRadiusValue): void => {
528
+ if (isUniformRadius(v)) {
529
+ commit(prop.path, v.topLeft);
530
+ // D1 — a uniform edit must also CLEAR any per-corner longhands a prior
531
+ // non-uniform edit wrote, or the stale longhand silently overrides the
532
+ // shorthand on reload. `removeStyle` no-ops when the longhand isn't
533
+ // authored, so this is safe on a clean element (never pollutes source).
534
+ for (const { suffix } of RADIUS_CORNERS) {
535
+ removeStyle(`style.border${suffix}Radius`);
536
+ }
537
+ return;
538
+ }
539
+ for (const { key, suffix } of RADIUS_CORNERS) {
540
+ commit(`style.border${suffix}Radius`, v[key]);
541
+ }
542
+ };
543
+ return (
544
+ <Field label={prop.label} testId={testId}>
545
+ <BorderRadiusEditor
546
+ value={value}
547
+ disabled={disabled}
548
+ onChange={writeBack}
549
+ onChangeEnd={writeBack}
550
+ />
551
+ </Field>
552
+ );
553
+ }
554
+
555
+ function ScrubRow({ prop, raw, disabled, testId, commit }: KindRowProps): React.ReactElement {
556
+ const unit = UNITLESS_NUMBER_PROPS.has(propNameFromPath(prop.path)) ? '' : 'px';
557
+ return (
558
+ <ScrubbableInput
559
+ testId={testId}
560
+ label={prop.label}
561
+ value={numericValue(raw)}
562
+ unit={unit}
563
+ disabled={disabled}
564
+ onChange={(v) => commit(prop.path, v)}
565
+ onChangeEnd={(v) => commit(prop.path, v)}
566
+ style={{ padding: `${spaceVar[2]} ${spaceVar[4]}` }}
567
+ />
568
+ );
569
+ }
570
+
571
+ function SelectRow({ prop, raw, disabled, testId, commit }: KindRowProps): React.ReactElement {
572
+ return (
573
+ <Field label={prop.label} testId={testId}>
574
+ <Select
575
+ data-testid={testId}
576
+ value={String(raw ?? '')}
577
+ disabled={disabled}
578
+ data-dynamic={disabled || undefined}
579
+ onChange={(e) => commit(prop.path, e.target.value)}
580
+ style={{ width: '100%' }}
581
+ >
582
+ {(prop.options ?? []).map((o) => (
583
+ <option key={String(o)} value={String(o)}>
584
+ {String(o)}
585
+ </option>
586
+ ))}
587
+ </Select>
588
+ </Field>
589
+ );
590
+ }
591
+
592
+ function CheckboxRow({ prop, raw, disabled, testId, commit }: KindRowProps): React.ReactElement {
593
+ return (
594
+ <label
595
+ style={{
596
+ display: 'flex',
597
+ gap: spaceVar[3],
598
+ alignItems: 'center',
599
+ padding: `${spaceVar[3]} ${spaceVar[4]}`,
600
+ fontSize: fontSizeVar.md,
601
+ }}
602
+ >
603
+ <Checkbox
604
+ className="vgai-checkbox"
605
+ data-testid={testId}
606
+ checked={raw !== false}
607
+ disabled={disabled}
608
+ onChange={(e) => commit(prop.path, e.target.checked)}
609
+ />
610
+ {prop.label}
611
+ </label>
612
+ );
613
+ }
614
+
615
+ function JsonRow({ prop, raw, disabled, testId, commit }: KindRowProps): React.ReactElement {
616
+ return (
617
+ <Field label={prop.label} testId={testId}>
618
+ <TextArea
619
+ data-testid={testId}
620
+ defaultValue={JSON.stringify(raw ?? null)}
621
+ disabled={disabled}
622
+ data-dynamic={disabled || undefined}
623
+ data-variant="code"
624
+ style={{ width: '100%', minHeight: 40 }}
625
+ onBlur={(e) => {
626
+ try {
627
+ commit(prop.path, JSON.parse(e.target.value));
628
+ } catch {
629
+ // Invalid JSON — leave the field as typed, don't write garbage.
630
+ }
631
+ }}
632
+ />
633
+ </Field>
634
+ );
635
+ }
636
+
637
+ function TextRow({ prop, raw, disabled, testId, commit }: KindRowProps): React.ReactElement {
638
+ // Commit once, on Enter/blur — a per-keystroke write re-projects the board
639
+ // under the typist (see `ScrubbableInput`'s draft for the measurement).
640
+ return (
641
+ <Field label={prop.label} testId={testId}>
642
+ <DraftTextInput
643
+ type="text"
644
+ data-testid={testId}
645
+ readOnly={disabled}
646
+ data-dynamic={disabled || undefined}
647
+ value={String(raw ?? '')}
648
+ onCommit={(next) => commit(prop.path, next)}
649
+ style={{ width: '100%' }}
650
+ />
651
+ </Field>
652
+ );
653
+ }
654
+
655
+ /** U3 (spec 27 §5 "Transform: rotate/scale") — a structured `{rotateDeg, scale}`
656
+ * pair, when `style.transform` is losslessly representable that way. */
657
+ export interface TransformValue {
658
+ rotateDeg: number;
659
+ scale: number;
660
+ }
661
+
662
+ const TRANSFORM_ROTATE_SCALE_RE = /^rotate\(\s*(-?[\d.]+)deg\s*\)\s*scale\(\s*(-?[\d.]+)\s*\)$/i;
663
+ const TRANSFORM_SCALE_ROTATE_RE = /^scale\(\s*(-?[\d.]+)\s*\)\s*rotate\(\s*(-?[\d.]+)deg\s*\)$/i;
664
+ // S3 — single-function literals (a JSX-authored `rotate(45deg)` or `scale(1.5)`
665
+ // alone, missing axis defaulted): computed style is always a matrix in the real
666
+ // browser, but these are exactly what an author hand-writes, so give them the
667
+ // structured row too rather than the raw TextRow.
668
+ const TRANSFORM_ROTATE_ONLY_RE = /^rotate\(\s*(-?[\d.]+)deg\s*\)$/i;
669
+ const TRANSFORM_SCALE_ONLY_RE = /^scale\(\s*(-?[\d.]+)\s*\)$/i;
670
+ const TRANSFORM_MATRIX_RE =
671
+ /^matrix\(\s*(-?[\d.eE+-]+)\s*,\s*(-?[\d.eE+-]+)\s*,\s*(-?[\d.eE+-]+)\s*,\s*(-?[\d.eE+-]+)\s*,\s*(-?[\d.eE+-]+)\s*,\s*(-?[\d.eE+-]+)\s*\)$/;
672
+ const TRANSFORM_EPS = 1e-4;
673
+
674
+ /**
675
+ * Parse a `style.transform` value (either the author's literal
676
+ * `rotate(<deg>deg) scale(<n>)` — the exact form this row writes — or a
677
+ * REAL browser's computed 2x2 `matrix(a, b, c, d, e, f)`, since Chromium
678
+ * always normalizes an authored transform to matrix form, unlike this
679
+ * repo's headless/vitest fallback which echoes the literal string straight
680
+ * from inline style) into a structured `{rotateDeg, scale}` pair, or `null`
681
+ * when the value can't be represented that way LOSSLESSLY — a nonzero
682
+ * translate (`e`/`f`), a skew/non-uniform scale (`c !== -b` or `d !== a`),
683
+ * `matrix3d(...)`, or any other transform function list. `null` is the
684
+ * signal {@link TransformRow} uses to fall back to the plain `TextRow` —
685
+ * every value this doesn't understand stays editable as raw text, so no
686
+ * expressiveness is ever silently dropped.
687
+ */
688
+ export function parseTransform(raw: string): TransformValue | null {
689
+ const v = raw.trim();
690
+ if (v === '' || v === 'none') return { rotateDeg: 0, scale: 1 };
691
+ const lit = TRANSFORM_ROTATE_SCALE_RE.exec(v);
692
+ if (lit) return { rotateDeg: Number(lit[1]), scale: Number(lit[2]) };
693
+ const swapped = TRANSFORM_SCALE_ROTATE_RE.exec(v);
694
+ if (swapped) return { rotateDeg: Number(swapped[2]), scale: Number(swapped[1]) };
695
+ const rotOnly = TRANSFORM_ROTATE_ONLY_RE.exec(v);
696
+ if (rotOnly) return { rotateDeg: Number(rotOnly[1]), scale: 1 }; // S3 — missing scale defaults 1
697
+ const scaleOnly = TRANSFORM_SCALE_ONLY_RE.exec(v);
698
+ if (scaleOnly) return { rotateDeg: 0, scale: Number(scaleOnly[1]) }; // S3 — missing rotate defaults 0
699
+ const m = TRANSFORM_MATRIX_RE.exec(v);
700
+ if (!m) return null; // matrix3d / translate / skew / any other function list
701
+ const [a, b, c, d, e, f] = m.slice(1, 7).map(Number) as [
702
+ number,
703
+ number,
704
+ number,
705
+ number,
706
+ number,
707
+ number,
708
+ ];
709
+ if (Math.abs(e) > TRANSFORM_EPS || Math.abs(f) > TRANSFORM_EPS) return null; // has a translate
710
+ if (Math.abs(c + b) > TRANSFORM_EPS || Math.abs(d - a) > TRANSFORM_EPS) return null; // skew / non-uniform scale
711
+ const scale = Math.hypot(a, b);
712
+ if (scale < TRANSFORM_EPS) return null; // degenerate (zero scale)
713
+ const rotateDeg = (Math.atan2(b, a) * 180) / Math.PI;
714
+ return {
715
+ rotateDeg: Math.round(rotateDeg * 100) / 100,
716
+ scale: Math.round(scale * 1000) / 1000,
717
+ };
718
+ }
719
+
720
+ /** The inverse of {@link parseTransform}'s literal fast-path — always
721
+ * composes the readable `rotate()/scale()` form, never a matrix, so the
722
+ * written source stays human-editable. */
723
+ export function composeTransform(v: TransformValue): string {
724
+ return `rotate(${v.rotateDeg}deg) scale(${v.scale})`;
725
+ }
726
+
727
+ function TransformRow(props: KindRowProps): React.ReactElement {
728
+ const { prop, raw, disabled, testId, commit } = props;
729
+ const parsed = parseTransform(String(raw ?? 'none'));
730
+ if (!parsed) return <TextRow {...props} />; // safety net — no expressiveness lost
731
+ const writeBack = (next: TransformValue): void => commit(prop.path, composeTransform(next));
732
+ return (
733
+ <Field label={prop.label} testId={testId}>
734
+ <div style={{ display: 'flex', gap: spaceVar[3] }}>
735
+ <ScrubbableInput
736
+ testId={`${testId}-rotate`}
737
+ label="Rotate"
738
+ value={parsed.rotateDeg}
739
+ unit="deg"
740
+ disabled={disabled}
741
+ onChange={(v) => writeBack({ ...parsed, rotateDeg: v })}
742
+ onChangeEnd={(v) => writeBack({ ...parsed, rotateDeg: v })}
743
+ style={{ flex: 1 }}
744
+ />
745
+ <ScrubbableInput
746
+ testId={`${testId}-scale`}
747
+ label="Scale"
748
+ value={parsed.scale}
749
+ unit="x"
750
+ step={0.1}
751
+ disabled={disabled}
752
+ onChange={(v) => writeBack({ ...parsed, scale: v })}
753
+ onChangeEnd={(v) => writeBack({ ...parsed, scale: v })}
754
+ style={{ flex: 1 }}
755
+ />
756
+ </div>
757
+ </Field>
758
+ );
759
+ }
760
+
761
+ /** One `single`-kind row: reads the current value, picks the widget via
762
+ * {@link widgetKindFor}, and delegates to the matching `*Row` component
763
+ * above — each extracted so this dispatcher's own branching stays well
764
+ * under biome's complexity ceiling (same discipline `GradientEditor.tsx`/
765
+ * `ShadowEditor.tsx` document for their own helper splits). Both
766
+ * `onChange`/`onChangeEnd` route through the SAME `commit` (file doc
767
+ * comment: this adapter has one write path, not a separate preview mode). */
768
+ function SingleRow({
769
+ adapter,
770
+ id,
771
+ prop,
772
+ fonts,
773
+ }: {
774
+ adapter: AuthoringAdapter;
775
+ id: string;
776
+ prop: PropertyDescriptor;
777
+ fonts: readonly string[];
778
+ }): React.ReactElement {
779
+ const store = useEditorStore();
780
+ const commit = (path: string, value: unknown): void => {
781
+ adapter.inspector?.set(id, path, value);
782
+ store.notifyIngestEdit();
783
+ };
784
+ // D1 — remove a stale CSS longhand override (no-op when unauthored); optional
785
+ // on the inspector, so a non-react adapter simply skips the cleanup.
786
+ const removeStyle = (path: string): void => {
787
+ adapter.inspector?.remove?.(id, path);
788
+ store.notifyIngestEdit();
789
+ };
790
+ const rowProps: KindRowProps = {
791
+ prop,
792
+ raw: adapter.inspector?.get(id, prop.path),
793
+ disabled: prop.readonly,
794
+ testId: testIdFor(prop.path),
795
+ commit,
796
+ fonts,
797
+ };
798
+ switch (widgetKindFor(prop)) {
799
+ case 'color':
800
+ return <ColorRow {...rowProps} />;
801
+ case 'font':
802
+ return <FontRow {...rowProps} />;
803
+ case 'gradient':
804
+ return <GradientRow {...rowProps} />;
805
+ case 'shadow':
806
+ return <ShadowRow {...rowProps} withSpreadInset />;
807
+ case 'text-shadow':
808
+ return <ShadowRow {...rowProps} withSpreadInset={false} />;
809
+ case 'filter':
810
+ return <FilterRow {...rowProps} label="Filter" />;
811
+ case 'backdrop-filter':
812
+ return <FilterRow {...rowProps} label="Backdrop Filter" />;
813
+ case 'radius':
814
+ return <RadiusRow {...rowProps} adapter={adapter} id={id} removeStyle={removeStyle} />;
815
+ case 'transform':
816
+ return <TransformRow {...rowProps} />;
817
+ case 'scrub':
818
+ return <ScrubRow {...rowProps} />;
819
+ case 'select':
820
+ return <SelectRow {...rowProps} />;
821
+ case 'checkbox':
822
+ return <CheckboxRow {...rowProps} />;
823
+ case 'json':
824
+ return <JsonRow {...rowProps} />;
825
+ default:
826
+ return <TextRow {...rowProps} />;
827
+ }
828
+ }
829
+
830
+ /** A `border`/`align` combo row — reads all its member props, composes the
831
+ * widget's structured value, and writes every member back on change. */
832
+ function ComboRow({ adapter, id, row }: ComboRowProps): React.ReactElement | null {
833
+ const store = useEditorStore();
834
+ const commit = (path: string, value: unknown): void => {
835
+ adapter.inspector?.set(id, path, value);
836
+ store.notifyIngestEdit();
837
+ };
838
+ const removeStyle = (path: string): void => {
839
+ adapter.inspector?.remove?.(id, path);
840
+ store.notifyIngestEdit();
841
+ };
842
+ if (row.kind === 'border') {
843
+ // U2 (spec 27 §5 "Stroke: per-side border") — the shorthand descriptors
844
+ // (`row.width`/`row.style`/`row.color`) are the uniform fallback; each
845
+ // side's OWN longhand (`borderTopWidth`, …) is read through the same
846
+ // `adapter.inspector.get` and wins when present, so a prior per-side
847
+ // edit survives the next re-render instead of collapsing back to
848
+ // uniform (mirrors `RadiusRow`'s corner read, same fallback rationale:
849
+ // the vitest `node` env resolver has no implicit per-side computed
850
+ // values the way a real `CSSStyleDeclaration` always does).
851
+ const fallback: BorderSide = {
852
+ width: numericValue(adapter.inspector?.get(id, row.width.path)),
853
+ style: String(adapter.inspector?.get(id, row.style.path) ?? 'solid'),
854
+ color: String(adapter.inspector?.get(id, row.color.path) ?? '#000000'),
855
+ };
856
+ const readSide = (suffix: 'Top' | 'Right' | 'Bottom' | 'Left'): BorderSide => {
857
+ const w = numericStyleValue(adapter.inspector?.get(id, `style.border${suffix}Width`));
858
+ const st = adapter.inspector?.get(id, `style.border${suffix}Style`);
859
+ const c = adapter.inspector?.get(id, `style.border${suffix}Color`);
860
+ // D1 — normalize the per-side color longhand through `cssColorToHex`: it's
861
+ // not in `STYLE_PROPERTY_TYPE`, so `inspector.get` hands back the RAW
862
+ // computed value (`rgb(…)`), and letting that flow into a later uniform
863
+ // commit would rewrite the author's hex literal as an `rgb()` string.
864
+ const cHex = typeof c === 'string' ? cssColorToHex(c) : undefined;
865
+ return {
866
+ width: w ?? fallback.width,
867
+ style: typeof st === 'string' && st ? st : fallback.style,
868
+ color: cHex ?? fallback.color,
869
+ };
870
+ };
871
+ const value: BorderValue = {
872
+ top: readSide('Top'),
873
+ right: readSide('Right'),
874
+ bottom: readSide('Bottom'),
875
+ left: readSide('Left'),
876
+ };
877
+ // Uniform edits keep the pre-existing shorthand fast-path (3 commits to
878
+ // the `borderWidth`/`borderStyle`/`borderColor` descriptors this row
879
+ // always had); a non-uniform widget value now writes the 4 per-side
880
+ // CSS longhands instead of collapsing to `.top` — removing the
881
+ // documented limitation this file used to note here.
882
+ const sideSuffixes = ['Top', 'Right', 'Bottom', 'Left'] as const;
883
+ const writeBack = (v: BorderValue): void => {
884
+ if (isUniformBorder(v)) {
885
+ commit(row.width.path, v.top.width);
886
+ commit(row.style.path, v.top.style);
887
+ commit(row.color.path, v.top.color);
888
+ // D1 — clear any per-side longhands a prior non-uniform edit wrote, so
889
+ // the uniform shorthand actually wins on reload (removeStyle no-ops
890
+ // when a longhand isn't authored — safe on a clean element).
891
+ for (const suffix of sideSuffixes) {
892
+ removeStyle(`style.border${suffix}Width`);
893
+ removeStyle(`style.border${suffix}Style`);
894
+ removeStyle(`style.border${suffix}Color`);
895
+ }
896
+ return;
897
+ }
898
+ const sides: ReadonlyArray<{
899
+ suffix: 'Top' | 'Right' | 'Bottom' | 'Left';
900
+ side: BorderSide;
901
+ }> = sideSuffixes.map((suffix) => ({
902
+ suffix,
903
+ side: v[suffix.toLowerCase() as 'top' | 'right' | 'bottom' | 'left'],
904
+ }));
905
+ for (const { suffix, side } of sides) {
906
+ commit(`style.border${suffix}Width`, side.width);
907
+ commit(`style.border${suffix}Style`, side.style);
908
+ commit(`style.border${suffix}Color`, side.color);
909
+ }
910
+ };
911
+ return (
912
+ <Field label="Border" testId="react-prop-border">
913
+ <BorderEditor value={value} onChange={writeBack} onChangeEnd={writeBack} />
914
+ </Field>
915
+ );
916
+ }
917
+ const value: AlignmentValue = {
918
+ justify:
919
+ (adapter.inspector?.get(id, row.justify.path) as AlignmentValue['justify']) ?? 'flex-start',
920
+ align: (adapter.inspector?.get(id, row.align.path) as AlignmentValue['align']) ?? 'flex-start',
921
+ };
922
+ return (
923
+ <Field label="Align" testId="react-prop-align">
924
+ <AlignmentGrid
925
+ value={value}
926
+ onChange={(v) => {
927
+ commit(row.justify.path, v.justify);
928
+ commit(row.align.path, v.align);
929
+ }}
930
+ />
931
+ </Field>
932
+ );
933
+ }
934
+
935
+ function rowKey(row: PropertyRow): string {
936
+ if (row.kind === 'single') return row.prop.path;
937
+ if (row.kind === 'border') return `border:${row.width.path}`;
938
+ return `align:${row.justify.path}`;
939
+ }
940
+
941
+ function compactScrub(
942
+ adapter: AuthoringAdapter,
943
+ id: string,
944
+ prop: PropertyDescriptor | undefined,
945
+ label: string,
946
+ commit: (path: string, value: unknown) => void,
947
+ ): React.ReactElement | null {
948
+ if (!prop) return null;
949
+ const unit = UNITLESS_NUMBER_PROPS.has(propNameFromPath(prop.path)) ? '' : 'px';
950
+ const testId = testIdFor(prop.path);
951
+ return (
952
+ <ScrubbableInput
953
+ key={prop.path}
954
+ testId={testId}
955
+ label={label}
956
+ value={numericValue(adapter.inspector?.get(id, prop.path))}
957
+ unit={unit}
958
+ disabled={prop.readonly}
959
+ onChange={(value) => commit(prop.path, value)}
960
+ onChangeEnd={(value) => commit(prop.path, value)}
961
+ style={{ flex: 1, minWidth: 0 }}
962
+ />
963
+ );
964
+ }
965
+
966
+ function SemanticLayout({
967
+ adapter,
968
+ id,
969
+ props,
970
+ fonts,
971
+ showConstraints,
972
+ onToggleConstraints,
973
+ }: {
974
+ adapter: AuthoringAdapter;
975
+ id: string;
976
+ props: readonly PropertyDescriptor[];
977
+ fonts: readonly string[];
978
+ showConstraints: boolean;
979
+ onToggleConstraints: () => void;
980
+ }): React.ReactElement {
981
+ const store = useEditorStore();
982
+ const byName = new Map(props.map((prop) => [propNameFromPath(prop.path), prop] as const));
983
+ const commit = (path: string, value: unknown): void => {
984
+ adapter.inspector?.set(id, path, value);
985
+ store.notifyIngestEdit();
986
+ };
987
+ const display = String(
988
+ adapter.inspector?.get(id, byName.get('display')?.path ?? 'style.display') ?? '',
989
+ );
990
+ const isFlex = display === 'flex' || display === 'inline-flex';
991
+ const isGrid = display === 'grid' || display === 'inline-grid';
992
+ const displayProp = byName.get('display');
993
+ const gapProp = byName.get('gap');
994
+ const alignRows = buildPropertyRows(
995
+ props.filter((prop) => ['justifyContent', 'alignItems'].includes(propNameFromPath(prop.path))),
996
+ );
997
+ const ordinary = ['flexDirection', 'flexWrap', 'overflow']
998
+ .map((name) => byName.get(name))
999
+ .filter((prop): prop is PropertyDescriptor => !!prop);
1000
+ return (
1001
+ <div style={{ padding: '2px 8px 7px' }}>
1002
+ <div style={{ display: 'flex', gap: spaceVar[3] }}>
1003
+ {compactScrub(adapter, id, byName.get('width'), 'W', commit)}
1004
+ {compactScrub(adapter, id, byName.get('height'), 'H', commit)}
1005
+ </div>
1006
+ {showConstraints && (
1007
+ <div data-testid="react-layout-constraints" style={{ marginTop: 5 }}>
1008
+ <div style={{ display: 'flex', gap: spaceVar[3] }}>
1009
+ {compactScrub(adapter, id, byName.get('minWidth'), 'Min W', commit)}
1010
+ {compactScrub(adapter, id, byName.get('minHeight'), 'Min H', commit)}
1011
+ </div>
1012
+ <div style={{ display: 'flex', gap: spaceVar[3], marginTop: spaceVar[2] }}>
1013
+ {compactScrub(adapter, id, byName.get('maxWidth'), 'Max W', commit)}
1014
+ {compactScrub(adapter, id, byName.get('maxHeight'), 'Max H', commit)}
1015
+ </div>
1016
+ </div>
1017
+ )}
1018
+ <Button
1019
+ type="button"
1020
+ variant="ghost"
1021
+ size="compact"
1022
+ data-testid="react-layout-toggle-constraints"
1023
+ onClick={onToggleConstraints}
1024
+ style={{ display: 'flex', margin: '2px auto 5px' }}
1025
+ >
1026
+ {showConstraints ? '− constraints' : '··· constraints'}
1027
+ </Button>
1028
+ <div
1029
+ style={{
1030
+ display: 'grid',
1031
+ gridTemplateColumns: 'minmax(0, 1.35fr) minmax(0, 1fr)',
1032
+ gap: spaceVar[3],
1033
+ }}
1034
+ >
1035
+ {displayProp && (
1036
+ <Select
1037
+ data-testid={testIdFor(displayProp.path)}
1038
+ value={display}
1039
+ disabled={displayProp.readonly}
1040
+ data-dynamic={displayProp.readonly || undefined}
1041
+ onChange={(event) => commit(displayProp.path, event.target.value)}
1042
+ style={{ width: '100%' }}
1043
+ >
1044
+ {(displayProp.options ?? []).map((option) => (
1045
+ <option key={String(option)} value={String(option)}>
1046
+ {String(option)}
1047
+ </option>
1048
+ ))}
1049
+ </Select>
1050
+ )}
1051
+ {compactScrub(adapter, id, gapProp, 'Gap', commit)}
1052
+ </div>
1053
+ {isFlex && (
1054
+ <div data-testid="react-layout-flex-controls" style={{ marginTop: 5 }}>
1055
+ {ordinary
1056
+ .filter((prop) => propNameFromPath(prop.path) !== 'overflow')
1057
+ .map((prop) => (
1058
+ <SingleRow key={prop.path} adapter={adapter} id={id} prop={prop} fonts={fonts} />
1059
+ ))}
1060
+ {alignRows.map((row) =>
1061
+ row.kind === 'single' ? (
1062
+ <SingleRow
1063
+ key={rowKey(row)}
1064
+ adapter={adapter}
1065
+ id={id}
1066
+ prop={row.prop}
1067
+ fonts={fonts}
1068
+ />
1069
+ ) : (
1070
+ <ComboRow key={rowKey(row)} adapter={adapter} id={id} row={row} />
1071
+ ),
1072
+ )}
1073
+ </div>
1074
+ )}
1075
+ {isGrid && (
1076
+ <div data-testid="react-layout-grid-controls" style={{ marginTop: 5 }}>
1077
+ <div style={{ display: 'flex', gap: spaceVar[3] }}>
1078
+ {gridCountScrub(adapter, id, byName.get('gridTemplateColumns'), 'Cols', commit)}
1079
+ {gridCountScrub(adapter, id, byName.get('gridTemplateRows'), 'Rows', commit)}
1080
+ </div>
1081
+ {[byName.get('gridTemplateColumns'), byName.get('gridTemplateRows')]
1082
+ .filter((prop): prop is PropertyDescriptor => !!prop)
1083
+ .map((prop) => (
1084
+ <SingleRow key={prop.path} adapter={adapter} id={id} prop={prop} fonts={fonts} />
1085
+ ))}
1086
+ <div style={{ display: 'flex', gap: spaceVar[3], marginTop: spaceVar[1] }}>
1087
+ {compactScrub(adapter, id, byName.get('rowGap'), 'Row Gap', commit)}
1088
+ {compactScrub(adapter, id, byName.get('columnGap'), 'Col Gap', commit)}
1089
+ </div>
1090
+ {byName.get('gridAutoFlow') && (
1091
+ <SingleRow
1092
+ adapter={adapter}
1093
+ id={id}
1094
+ prop={byName.get('gridAutoFlow') as PropertyDescriptor}
1095
+ fonts={fonts}
1096
+ />
1097
+ )}
1098
+ </div>
1099
+ )}
1100
+ {ordinary
1101
+ .filter((prop) => propNameFromPath(prop.path) === 'overflow')
1102
+ .map((prop) => (
1103
+ <SingleRow key={prop.path} adapter={adapter} id={id} prop={prop} fonts={fonts} />
1104
+ ))}
1105
+ <GridItemDisclosure adapter={adapter} id={id} byName={byName} fonts={fonts} />
1106
+ </div>
1107
+ );
1108
+ }
1109
+
1110
+ /** The number of tracks in a `grid-template-*` value (`none` ⇒ 0). A COMPUTED
1111
+ * template is a resolved list, but the optimistic echo hands back the
1112
+ * AUTHORED string, so integer `repeat(N, tracks)` must expand (measured:
1113
+ * without it, the echoed `repeat(3, 1fr)` counted as ONE track and the next
1114
+ * commit collapsed the template back to `repeat(1, 1fr)`). `auto-fit`/
1115
+ * `auto-fill` repeats count as one segment — their track count is genuinely
1116
+ * not knowable from the text. */
1117
+ export function gridTrackCount(template: string): number {
1118
+ const t = template.trim();
1119
+ if (!t || t === 'none') return 0;
1120
+ const segments: string[] = [];
1121
+ let depth = 0;
1122
+ let start = 0;
1123
+ for (let i = 0; i < t.length; i++) {
1124
+ const ch = t[i];
1125
+ if (ch === '(' || ch === '[') depth++;
1126
+ else if (ch === ')' || ch === ']') depth--;
1127
+ else if (ch === ' ' && depth === 0) {
1128
+ if (i > start) segments.push(t.slice(start, i));
1129
+ start = i + 1;
1130
+ }
1131
+ }
1132
+ if (start < t.length) segments.push(t.slice(start));
1133
+ let count = 0;
1134
+ for (const segment of segments) {
1135
+ const rep = /^repeat\(\s*(\d+)\s*,([\s\S]*)\)$/.exec(segment);
1136
+ count += rep?.[1] && rep[2] !== undefined ? Number(rep[1]) * gridTrackCount(rep[2]) : 1;
1137
+ }
1138
+ return count;
1139
+ }
1140
+
1141
+ /** Figma's grid COUNT control on a CSS template: scrub/type the track count
1142
+ * and the template becomes `repeat(N, 1fr)` — exactly what Figma's
1143
+ * rows/columns numbers mean. A custom template (minmax, named lines) stays
1144
+ * authorable through the template text row below; bumping the count
1145
+ * deliberately rewrites to N equal tracks, same as Figma. */
1146
+ function gridCountScrub(
1147
+ adapter: AuthoringAdapter,
1148
+ id: string,
1149
+ prop: PropertyDescriptor | undefined,
1150
+ label: string,
1151
+ commit: (path: string, value: unknown) => void,
1152
+ ): React.ReactElement | null {
1153
+ if (!prop) return null;
1154
+ // Dedupe instead of end-only (measured by the blind walk): with a noop
1155
+ // onChange the CONTROLLED input never echoed a keystroke — typing was dead
1156
+ // and only the raw template field worked. onChange must reach commit for
1157
+ // the echo to render; the duplicate-write warn the end-only shape avoided
1158
+ // is handled by skipping writes of the template just written.
1159
+ let lastWritten: string | null = null;
1160
+ const write = (value: number): void => {
1161
+ const n = Math.max(1, Math.round(value));
1162
+ const template = `repeat(${n}, 1fr)`;
1163
+ if (template === lastWritten) return;
1164
+ lastWritten = template;
1165
+ commit(prop.path, template);
1166
+ };
1167
+ return (
1168
+ <ScrubbableInput
1169
+ key={`${prop.path}-count`}
1170
+ testId={`${testIdFor(prop.path)}-count`}
1171
+ label={label}
1172
+ value={gridTrackCount(String(adapter.inspector?.get(id, prop.path) ?? ''))}
1173
+ min={1}
1174
+ disabled={prop.readonly}
1175
+ onChange={write}
1176
+ onChangeEnd={write}
1177
+ style={{ flex: 1, minWidth: 0 }}
1178
+ />
1179
+ );
1180
+ }
1181
+
1182
+ /** The grid-ITEM half (placement lives on the child, not the container):
1183
+ * `grid-column` / `grid-row` / `justify-self` behind a ghost disclosure —
1184
+ * same idiom as the constraints toggle, because for most elements these
1185
+ * are noise, and the inspector cannot cheaply know whether the PARENT is
1186
+ * a grid (its reads are node-scoped). */
1187
+ function GridItemDisclosure({
1188
+ adapter,
1189
+ id,
1190
+ byName,
1191
+ fonts,
1192
+ }: {
1193
+ adapter: AuthoringAdapter;
1194
+ id: string;
1195
+ byName: ReadonlyMap<string, PropertyDescriptor>;
1196
+ fonts: readonly string[];
1197
+ }): React.ReactElement | null {
1198
+ const [open, setOpen] = useState(false);
1199
+ const props = ['gridColumn', 'gridRow', 'justifySelf']
1200
+ .map((name) => byName.get(name))
1201
+ .filter((prop): prop is PropertyDescriptor => !!prop);
1202
+ if (props.length === 0) return null;
1203
+ return (
1204
+ <>
1205
+ <Button
1206
+ type="button"
1207
+ variant="ghost"
1208
+ size="compact"
1209
+ data-testid="react-layout-toggle-grid-item"
1210
+ onClick={() => setOpen((value) => !value)}
1211
+ style={{ display: 'flex', margin: '2px auto 0' }}
1212
+ >
1213
+ {open ? '− grid item' : '··· grid item'}
1214
+ </Button>
1215
+ {open && (
1216
+ <div data-testid="react-layout-grid-item">
1217
+ {props.map((prop) => (
1218
+ <SingleRow key={prop.path} adapter={adapter} id={id} prop={prop} fonts={fonts} />
1219
+ ))}
1220
+ </div>
1221
+ )}
1222
+ </>
1223
+ );
1224
+ }
1225
+
1226
+ /** Split a `background-image` value into its top-level comma layers (CSS
1227
+ * paints the FIRST layer on top). `none`/empty ⇒ []. */
1228
+ export function splitFillLayers(value: string): string[] {
1229
+ const t = value.trim();
1230
+ if (!t || t === 'none') return [];
1231
+ const layers: string[] = [];
1232
+ let depth = 0;
1233
+ let start = 0;
1234
+ for (let i = 0; i < t.length; i++) {
1235
+ const ch = t[i];
1236
+ if (ch === '(') depth++;
1237
+ else if (ch === ')') depth--;
1238
+ else if (ch === ',' && depth === 0) {
1239
+ layers.push(t.slice(start, i).trim());
1240
+ start = i + 1;
1241
+ }
1242
+ }
1243
+ layers.push(t.slice(start).trim());
1244
+ return layers.filter(Boolean);
1245
+ }
1246
+
1247
+ /** One fill layer's URL, when the layer is an image paint. */
1248
+ function fillLayerUrl(layer: string): string | null {
1249
+ const m = /^url\((['"]?)([\s\S]*)\1\)$/.exec(layer.trim());
1250
+ return m?.[2] ?? null;
1251
+ }
1252
+
1253
+ /**
1254
+ * FILLS AS A LIST (design ledger: fill-as-list; the scoreboard's measured
1255
+ * gap was "exactly ONE colour and ONE gradient per element"). Figma's fill
1256
+ * list maps onto the web's own layer model: `background-image` is a
1257
+ * comma-separated stack of paints (first = topmost), and `background-color`
1258
+ * is the one solid underneath them all. Each layer row previews itself and
1259
+ * expands to its own editor (gradient editor / image url); add, remove and
1260
+ * reorder rewrite the list. Honest deltas, stated: CSS has no per-layer
1261
+ * visibility toggle (Figma's eye) — hiding is removing — and no per-layer
1262
+ * opacity knob for image paints (a gradient layer carries opacity in its
1263
+ * own color stops).
1264
+ */
1265
+ function SemanticFill({
1266
+ adapter,
1267
+ id,
1268
+ props,
1269
+ fonts,
1270
+ }: {
1271
+ adapter: AuthoringAdapter;
1272
+ id: string;
1273
+ props: readonly PropertyDescriptor[];
1274
+ fonts: readonly string[];
1275
+ }): React.ReactElement {
1276
+ const store = useEditorStore();
1277
+ const byName = new Map(props.map((prop) => [propNameFromPath(prop.path), prop] as const));
1278
+ const commit = (path: string, value: unknown): void => {
1279
+ adapter.inspector?.set(id, path, value);
1280
+ store.notifyIngestEdit();
1281
+ };
1282
+ const imageProp = byName.get('backgroundImage');
1283
+ const colorProp = byName.get('backgroundColor');
1284
+ const [expanded, setExpanded] = useState<number | null>(null);
1285
+ const [imageDraft, setImageDraft] = useState<string | null>(null);
1286
+ const raw = String(imageProp ? (adapter.inspector?.get(id, imageProp.path) ?? '') : '');
1287
+ const layers = splitFillLayers(raw);
1288
+ const write = (next: string[]): void => {
1289
+ if (!imageProp) return;
1290
+ if (next.length > 0) {
1291
+ commit(imageProp.path, next.join(', '));
1292
+ return;
1293
+ }
1294
+ // An emptied list REMOVES the member — `backgroundImage: 'none'` parked
1295
+ // in source is authored noise the element never had.
1296
+ adapter.inspector?.remove?.(id, imageProp.path);
1297
+ store.notifyIngestEdit();
1298
+ };
1299
+ const replaceLayer = (index: number, layer: string): void =>
1300
+ write(layers.map((l, j) => (j === index ? layer : l)));
1301
+ const removeLayer = (index: number): void => {
1302
+ setExpanded(null);
1303
+ write(layers.filter((_, j) => j !== index));
1304
+ };
1305
+ const moveLayer = (index: number, delta: number): void => {
1306
+ const next = [...layers];
1307
+ const swap = next[index + delta];
1308
+ if (swap === undefined || next[index] === undefined) return;
1309
+ next[index + delta] = next[index] as string;
1310
+ next[index] = swap;
1311
+ setExpanded(null);
1312
+ write(next);
1313
+ };
1314
+ // Image layers compose with DOUBLE-quoted urls: the style writer stores
1315
+ // members single-quoted, and an escaped inner quote fails its literal
1316
+ // grammar (LITERAL_RE) — the member becomes uneditable AND unremovable
1317
+ // (measured: a url('…') layer could not be removed through this list).
1318
+ const layerButton: React.CSSProperties = {
1319
+ background: 'none',
1320
+ border: 'none',
1321
+ color: THEME.textMuted,
1322
+ cursor: 'pointer',
1323
+ fontSize: fontSizeVar.base,
1324
+ // Real hit targets (blind-walk friction: the reorder/remove buttons were
1325
+ // a couple of pixels wide).
1326
+ padding: `${spaceVar[2]} ${spaceVar[3]}`,
1327
+ lineHeight: lineHeightVar.tight,
1328
+ };
1329
+ return (
1330
+ <div data-testid="react-fill-list" style={{ padding: '2px 8px 7px' }}>
1331
+ {layers.map((layer, index) => {
1332
+ const gradient = parseGradient(layer);
1333
+ const url = fillLayerUrl(layer);
1334
+ const label = gradient
1335
+ ? `${gradient.type} gradient`
1336
+ : url !== null
1337
+ ? (url.split('/').pop() ?? 'image')
1338
+ : layer.slice(0, 22);
1339
+ return (
1340
+ <div key={`${index}-${layer.slice(0, 24)}`} style={{ marginBottom: 3 }}>
1341
+ <div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
1342
+ <button
1343
+ type="button"
1344
+ data-testid={`react-fill-layer-${index}`}
1345
+ title={layer}
1346
+ onClick={() => setExpanded(expanded === index ? null : index)}
1347
+ style={{
1348
+ display: 'flex',
1349
+ alignItems: 'center',
1350
+ gap: spaceVar[3],
1351
+ flex: 1,
1352
+ minWidth: 0,
1353
+ background: 'none',
1354
+ border: 'none',
1355
+ cursor: 'pointer',
1356
+ padding: `${spaceVar[1]} 0`,
1357
+ color: THEME.text,
1358
+ fontSize: fontSizeVar.base,
1359
+ textAlign: 'left',
1360
+ }}
1361
+ >
1362
+ <span
1363
+ style={{
1364
+ width: 16,
1365
+ height: 16,
1366
+ flexShrink: 0,
1367
+ borderRadius: radius.sm,
1368
+ border: `1px solid ${THEME.border}`,
1369
+ background: layer,
1370
+ backgroundSize: 'cover',
1371
+ }}
1372
+ />
1373
+ <span
1374
+ style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
1375
+ >
1376
+ {label}
1377
+ </span>
1378
+ </button>
1379
+ {index > 0 && (
1380
+ <button
1381
+ type="button"
1382
+ data-testid={`react-fill-up-${index}`}
1383
+ title="Raise this fill"
1384
+ onClick={() => moveLayer(index, -1)}
1385
+ style={layerButton}
1386
+ >
1387
+ ↑
1388
+ </button>
1389
+ )}
1390
+ {index < layers.length - 1 && (
1391
+ <button
1392
+ type="button"
1393
+ data-testid={`react-fill-down-${index}`}
1394
+ title="Lower this fill"
1395
+ onClick={() => moveLayer(index, 1)}
1396
+ style={layerButton}
1397
+ >
1398
+ ↓
1399
+ </button>
1400
+ )}
1401
+ <button
1402
+ type="button"
1403
+ data-testid={`react-fill-remove-${index}`}
1404
+ title="Remove this fill (CSS has no hidden-layer state — removing is Figma's eye)"
1405
+ onClick={() => removeLayer(index)}
1406
+ style={layerButton}
1407
+ >
1408
+ ✕
1409
+ </button>
1410
+ </div>
1411
+ {expanded === index && gradient && (
1412
+ <GradientEditor
1413
+ testId={`react-fill-gradient-${index}`}
1414
+ value={gradient}
1415
+ onChange={(g) => replaceLayer(index, composeGradient(g))}
1416
+ onChangeEnd={(g) => replaceLayer(index, composeGradient(g))}
1417
+ />
1418
+ )}
1419
+ {expanded === index && url !== null && (
1420
+ <TextInput
1421
+ data-testid={`react-fill-url-${index}`}
1422
+ defaultValue={url}
1423
+ placeholder="image url"
1424
+ onBlur={(event) => replaceLayer(index, `url("${event.target.value.trim()}")`)}
1425
+ onKeyDown={(event) => {
1426
+ if (event.key === 'Enter')
1427
+ replaceLayer(index, `url("${event.currentTarget.value.trim()}")`);
1428
+ }}
1429
+ style={{ width: '100%', marginTop: spaceVar[1] }}
1430
+ />
1431
+ )}
1432
+ </div>
1433
+ );
1434
+ })}
1435
+ {imageDraft !== null && (
1436
+ <TextInput
1437
+ data-testid="react-fill-image-draft"
1438
+ value={imageDraft}
1439
+ placeholder="image url — Enter to add"
1440
+ autoFocus
1441
+ onChange={(event) => setImageDraft(event.target.value)}
1442
+ onKeyDown={(event) => {
1443
+ if (event.key === 'Enter' && imageDraft.trim()) {
1444
+ write([`url("${imageDraft.trim()}")`, ...layers]);
1445
+ setImageDraft(null);
1446
+ }
1447
+ if (event.key === 'Escape') setImageDraft(null);
1448
+ }}
1449
+ style={{ width: '100%', marginBottom: 3 }}
1450
+ />
1451
+ )}
1452
+ <div style={{ display: 'flex', gap: spaceVar[2] }}>
1453
+ <Button
1454
+ type="button"
1455
+ variant="ghost"
1456
+ size="compact"
1457
+ data-testid="react-fill-add-gradient"
1458
+ onClick={() => {
1459
+ write([composeGradient(DEFAULT_GRADIENT), ...layers]);
1460
+ setExpanded(0);
1461
+ }}
1462
+ >
1463
+ + gradient
1464
+ </Button>
1465
+ <Button
1466
+ type="button"
1467
+ variant="ghost"
1468
+ size="compact"
1469
+ data-testid="react-fill-add-image"
1470
+ onClick={() => setImageDraft('')}
1471
+ >
1472
+ + image
1473
+ </Button>
1474
+ </div>
1475
+ {layers.some((layer) => fillLayerUrl(layer) !== null) &&
1476
+ ['backgroundSize', 'backgroundPosition', 'backgroundRepeat']
1477
+ .map((name) => byName.get(name))
1478
+ .filter((prop): prop is PropertyDescriptor => !!prop)
1479
+ .map((prop) => (
1480
+ <SingleRow key={prop.path} adapter={adapter} id={id} prop={prop} fonts={fonts} />
1481
+ ))}
1482
+ {colorProp && <SingleRow adapter={adapter} id={id} prop={colorProp} fonts={fonts} />}
1483
+ </div>
1484
+ );
1485
+ }
1486
+
1487
+ function SemanticPosition({
1488
+ adapter,
1489
+ id,
1490
+ props,
1491
+ fonts,
1492
+ }: {
1493
+ adapter: AuthoringAdapter;
1494
+ id: string;
1495
+ props: readonly PropertyDescriptor[];
1496
+ fonts: readonly string[];
1497
+ }): React.ReactElement {
1498
+ const store = useEditorStore();
1499
+ const byName = new Map(props.map((prop) => [propNameFromPath(prop.path), prop] as const));
1500
+ const position = byName.get('position');
1501
+ const positionValue = String(
1502
+ position ? (adapter.inspector?.get(id, position.path) ?? 'static') : 'static',
1503
+ );
1504
+ const commit = (path: string, value: unknown): void => {
1505
+ adapter.inspector?.set(id, path, value);
1506
+ store.notifyIngestEdit();
1507
+ };
1508
+ const offsets = ['top', 'right', 'bottom', 'left'];
1509
+ const itemProps = ['zIndex', 'flexGrow', 'flexShrink', 'flexBasis', 'alignSelf']
1510
+ .map((name) => byName.get(name))
1511
+ .filter((prop): prop is PropertyDescriptor => !!prop);
1512
+ return (
1513
+ <div style={{ padding: '2px 8px 7px' }}>
1514
+ {position && (
1515
+ <div
1516
+ data-testid={testIdFor(position.path)}
1517
+ style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: spaceVar[1] }}
1518
+ >
1519
+ {(position.options ?? []).map((option) => {
1520
+ const value = String(option);
1521
+ const active = value === positionValue;
1522
+ return (
1523
+ <Button
1524
+ key={value}
1525
+ type="button"
1526
+ variant={active ? 'primary' : 'secondary'}
1527
+ size="compact"
1528
+ aria-pressed={active}
1529
+ title={value}
1530
+ disabled={position.readonly}
1531
+ onClick={() => commit(position.path, value)}
1532
+ >
1533
+ {value.slice(0, 3)}
1534
+ </Button>
1535
+ );
1536
+ })}
1537
+ </div>
1538
+ )}
1539
+ {positionValue !== 'static' && (
1540
+ <div
1541
+ data-testid="react-position-offsets"
1542
+ style={{
1543
+ display: 'grid',
1544
+ gridTemplateColumns: '1fr 1fr',
1545
+ gap: spaceVar[2],
1546
+ marginTop: spaceVar[3],
1547
+ }}
1548
+ >
1549
+ {offsets.map((name) =>
1550
+ compactScrub(adapter, id, byName.get(name), name[0]!.toUpperCase(), commit),
1551
+ )}
1552
+ </div>
1553
+ )}
1554
+ {itemProps.length > 0 && (
1555
+ <div style={{ borderTop: `1px solid ${THEME.border}`, marginTop: 7, paddingTop: 3 }}>
1556
+ <div style={{ color: THEME.textMuted, fontSize: fontSizeVar.xs, padding: '2px 0 1px' }}>
1557
+ STACKING &amp; FLEX ITEM
1558
+ </div>
1559
+ {itemProps.map((prop) => (
1560
+ <SingleRow key={prop.path} adapter={adapter} id={id} prop={prop} fonts={fonts} />
1561
+ ))}
1562
+ </div>
1563
+ )}
1564
+ </div>
1565
+ );
1566
+ }
1567
+
1568
+ function SemanticSpacing({
1569
+ adapter,
1570
+ id,
1571
+ props,
1572
+ }: {
1573
+ adapter: AuthoringAdapter;
1574
+ id: string;
1575
+ props: readonly PropertyDescriptor[];
1576
+ }): React.ReactElement {
1577
+ const store = useEditorStore();
1578
+ const byName = new Map(props.map((prop) => [propNameFromPath(prop.path), prop] as const));
1579
+ const commit = (path: string, value: unknown): void => {
1580
+ adapter.inspector?.set(id, path, value);
1581
+ store.notifyIngestEdit();
1582
+ };
1583
+ // The BOX-MODEL diagram (owner feedback, 2026-08-31: the labeled M T/M R
1584
+ // grid "is not how figma works"). The value's PLACE on the nested box says
1585
+ // which side it is — margin the outer ring, padding the inner — the way
1586
+ // design tools draw CSS spacing (Webflow's spacing control, devtools'
1587
+ // box model; Figma's own H/V padding pair exists only because Figma has
1588
+ // no margin, so it cannot be transcribed onto CSS whole).
1589
+ const slot = (name: string): React.ReactElement => {
1590
+ const prop = byName.get(name);
1591
+ if (!prop) return <span key={name} />;
1592
+ return (
1593
+ <ScrubbableInput
1594
+ key={prop.path}
1595
+ testId={testIdFor(prop.path)}
1596
+ label=""
1597
+ value={numericValue(adapter.inspector?.get(id, prop.path))}
1598
+ unit="px"
1599
+ disabled={prop.readonly}
1600
+ onChange={(value) => commit(prop.path, value)}
1601
+ onChangeEnd={(value) => commit(prop.path, value)}
1602
+ style={{ width: 46, minWidth: 0, flex: 'none' }}
1603
+ />
1604
+ );
1605
+ };
1606
+ const ringLabel = (text: string): React.ReactElement => (
1607
+ <span
1608
+ style={{
1609
+ position: 'absolute',
1610
+ top: 2,
1611
+ left: 6,
1612
+ fontSize: 8,
1613
+ letterSpacing: '.08em',
1614
+ color: THEME.textMuted,
1615
+ pointerEvents: 'none',
1616
+ }}
1617
+ >
1618
+ {text}
1619
+ </span>
1620
+ );
1621
+ const centered: React.CSSProperties = {
1622
+ display: 'flex',
1623
+ alignItems: 'center',
1624
+ justifyContent: 'center',
1625
+ };
1626
+ return (
1627
+ <div
1628
+ style={{ padding: `${spaceVar[1]} ${spaceVar[4]} ${spaceVar[4]}` }}
1629
+ data-testid="react-spacing-box"
1630
+ >
1631
+ <div
1632
+ style={{
1633
+ position: 'relative',
1634
+ border: `1px solid ${THEME.border}`,
1635
+ borderRadius: 4,
1636
+ padding: '11px 4px 3px',
1637
+ }}
1638
+ >
1639
+ {ringLabel('MARGIN')}
1640
+ <div style={centered}>{slot('marginTop')}</div>
1641
+ <div style={{ display: 'flex', alignItems: 'center', gap: spaceVar[1] }}>
1642
+ {slot('marginLeft')}
1643
+ <div
1644
+ style={{
1645
+ position: 'relative',
1646
+ flex: 1,
1647
+ minWidth: 0,
1648
+ border: `1px dashed ${THEME.border}`,
1649
+ borderRadius: 4,
1650
+ padding: '11px 4px 3px',
1651
+ }}
1652
+ >
1653
+ {ringLabel('PADDING')}
1654
+ <div style={centered}>{slot('paddingTop')}</div>
1655
+ <div
1656
+ style={{
1657
+ display: 'flex',
1658
+ alignItems: 'center',
1659
+ justifyContent: 'space-between',
1660
+ gap: spaceVar[1],
1661
+ }}
1662
+ >
1663
+ {slot('paddingLeft')}
1664
+ {slot('paddingRight')}
1665
+ </div>
1666
+ <div style={centered}>{slot('paddingBottom')}</div>
1667
+ </div>
1668
+ {slot('marginRight')}
1669
+ </div>
1670
+ <div style={centered}>{slot('marginBottom')}</div>
1671
+ </div>
1672
+ </div>
1673
+ );
1674
+ }
1675
+
1676
+ /**
1677
+ * ONE group's body — the widget-mapped rows for one descriptor group, with no
1678
+ * chrome of its own: the group IS an identified `InspectionSection` now
1679
+ * (see `reactInspectorSections`), so its title, icon, order and collapse
1680
+ * affordance belong to the projection rendering it, and repeating them here
1681
+ * would title the same block twice.
1682
+ */
1683
+ function GroupBody({
1684
+ name,
1685
+ groupId,
1686
+ props,
1687
+ adapter,
1688
+ id,
1689
+ fonts,
1690
+ }: {
1691
+ name: string;
1692
+ groupId: string;
1693
+ props: readonly PropertyDescriptor[];
1694
+ adapter: AuthoringAdapter;
1695
+ id: string;
1696
+ fonts: readonly string[];
1697
+ }): React.ReactElement {
1698
+ const [showConstraints, setShowConstraints] = useState(false);
1699
+ const rows = buildPropertyRows(props);
1700
+ return (
1701
+ <div data-testid={`react-inspector-group-${groupId}`}>
1702
+ {name === 'Layout' && (
1703
+ <SemanticLayout
1704
+ adapter={adapter}
1705
+ id={id}
1706
+ props={props}
1707
+ fonts={fonts}
1708
+ showConstraints={showConstraints}
1709
+ onToggleConstraints={() => setShowConstraints((value) => !value)}
1710
+ />
1711
+ )}
1712
+ {name === 'Position' && (
1713
+ <SemanticPosition adapter={adapter} id={id} props={props} fonts={fonts} />
1714
+ )}
1715
+ {name === 'Spacing' && <SemanticSpacing adapter={adapter} id={id} props={props} />}
1716
+ {name === 'Fill' && <SemanticFill adapter={adapter} id={id} props={props} fonts={fonts} />}
1717
+ {name !== 'Layout' &&
1718
+ name !== 'Position' &&
1719
+ name !== 'Spacing' &&
1720
+ name !== 'Fill' &&
1721
+ rows.map((row) =>
1722
+ row.kind === 'single' ? (
1723
+ <SingleRow key={rowKey(row)} adapter={adapter} id={id} prop={row.prop} fonts={fonts} />
1724
+ ) : (
1725
+ <ComboRow key={rowKey(row)} adapter={adapter} id={id} row={row} />
1726
+ ),
1727
+ )}
1728
+ </div>
1729
+ );
1730
+ }
1731
+
1732
+ function BreadcrumbBar({
1733
+ adapter,
1734
+ node,
1735
+ }: {
1736
+ adapter: AuthoringAdapter;
1737
+ node: EditorNode;
1738
+ }): React.ReactElement {
1739
+ const chain: EditorNode[] = [];
1740
+ let current: EditorNode | null = node;
1741
+ let guard = 0;
1742
+ while (current && guard++ < 100) {
1743
+ chain.unshift(current);
1744
+ current = current.parentId ? adapter.hierarchy.node(current.parentId) : null;
1745
+ }
1746
+ return (
1747
+ <nav
1748
+ aria-label="Selection ancestry"
1749
+ data-testid="react-inspector-breadcrumbs"
1750
+ style={{
1751
+ display: 'flex',
1752
+ alignItems: 'center',
1753
+ gap: 3,
1754
+ padding: `${spaceVar[3]} ${spaceVar[4]}`,
1755
+ overflowX: 'auto',
1756
+ borderBottom: `1px solid ${THEME.border}`,
1757
+ whiteSpace: 'nowrap',
1758
+ }}
1759
+ >
1760
+ {chain.map((entry, index) => (
1761
+ <span key={entry.id} style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}>
1762
+ {index > 0 && <span style={{ color: THEME.textMuted }}>/</span>}
1763
+ <Button
1764
+ type="button"
1765
+ variant="ghost"
1766
+ size="compact"
1767
+ aria-current={entry.id === node.id ? 'page' : undefined}
1768
+ title={entry.secondaryLabel ?? entry.label}
1769
+ onClick={() => setAuthoringSelection(adapter, [entry.id])}
1770
+ >
1771
+ {entry.label}
1772
+ </Button>
1773
+ </span>
1774
+ ))}
1775
+ </nav>
1776
+ );
1777
+ }
1778
+
1779
+ function StoryActions({
1780
+ owner,
1781
+ node,
1782
+ }: {
1783
+ owner: ReactRootAuthoringAdapter;
1784
+ node: EditorNode;
1785
+ }): React.ReactElement {
1786
+ const args = owner.portableArgs(node.id);
1787
+ // The CSF WRITE half: save-the-current-args as a new story, rename, delete
1788
+ // — inline inputs, never window.prompt (banned), refusals rendered where
1789
+ // the gesture happened.
1790
+ const [mode, setMode] = useState<'idle' | 'save-as' | 'rename' | 'confirm-delete'>('idle');
1791
+ const [draftName, setDraftName] = useState('');
1792
+ const [notice, setNotice] = useState<string | null>(null);
1793
+ const run = async (op: Parameters<ReactRootAuthoringAdapter['writeStory']>[1]): Promise<void> => {
1794
+ const result = await owner.writeStory(node.id, op);
1795
+ setNotice(result.changed ? null : (result.error ?? 'refused'));
1796
+ if (result.changed) {
1797
+ setMode('idle');
1798
+ setDraftName('');
1799
+ }
1800
+ };
1801
+ return (
1802
+ <div
1803
+ data-testid="react-story-controls-header"
1804
+ style={{ borderBottom: `1px solid ${THEME.border}` }}
1805
+ >
1806
+ <div
1807
+ style={{
1808
+ display: 'flex',
1809
+ alignItems: 'center',
1810
+ justifyContent: 'space-between',
1811
+ gap: spaceVar[4],
1812
+ padding: '7px 8px',
1813
+ }}
1814
+ >
1815
+ <div style={{ minWidth: 0 }}>
1816
+ <div style={{ color: THEME.text, fontSize: fontSizeVar.md, fontWeight: 600 }}>
1817
+ {node.label}
1818
+ </div>
1819
+ <div style={{ color: THEME.textMuted, fontSize: fontSizeVar.sm }}>
1820
+ {args ? `${Object.keys(args).length} args · session controls` : 'Portable CSF story'}
1821
+ </div>
1822
+ </div>
1823
+ <Button
1824
+ type="button"
1825
+ variant="secondary"
1826
+ size="compact"
1827
+ data-testid="react-story-reset-args"
1828
+ onClick={() => owner.resetPortableArgs(node.id)}
1829
+ >
1830
+ Reset
1831
+ </Button>
1832
+ </div>
1833
+ <div
1834
+ style={{
1835
+ display: 'flex',
1836
+ gap: spaceVar[2],
1837
+ padding: `0 ${spaceVar[4]} ${spaceVar[3]}`,
1838
+ flexWrap: 'wrap',
1839
+ }}
1840
+ >
1841
+ <Button
1842
+ type="button"
1843
+ variant="ghost"
1844
+ size="compact"
1845
+ data-testid="react-story-save-as"
1846
+ title="Write the CURRENT session args as a new story export in this file"
1847
+ onClick={() => {
1848
+ setNotice(null);
1849
+ setDraftName('');
1850
+ setMode(mode === 'save-as' ? 'idle' : 'save-as');
1851
+ }}
1852
+ >
1853
+ Save as…
1854
+ </Button>
1855
+ <Button
1856
+ type="button"
1857
+ variant="ghost"
1858
+ size="compact"
1859
+ data-testid="react-story-rename"
1860
+ onClick={() => {
1861
+ setNotice(null);
1862
+ setDraftName('');
1863
+ setMode(mode === 'rename' ? 'idle' : 'rename');
1864
+ }}
1865
+ >
1866
+ Rename…
1867
+ </Button>
1868
+ {mode === 'confirm-delete' ? (
1869
+ <Button
1870
+ type="button"
1871
+ variant="secondary"
1872
+ size="compact"
1873
+ data-testid="react-story-delete-confirm"
1874
+ onClick={() => void run({ kind: 'delete' })}
1875
+ >
1876
+ Delete story?
1877
+ </Button>
1878
+ ) : (
1879
+ <Button
1880
+ type="button"
1881
+ variant="ghost"
1882
+ size="compact"
1883
+ data-testid="react-story-delete"
1884
+ onClick={() => {
1885
+ setNotice(null);
1886
+ setMode('confirm-delete');
1887
+ }}
1888
+ >
1889
+ Delete
1890
+ </Button>
1891
+ )}
1892
+ </div>
1893
+ {(mode === 'save-as' || mode === 'rename') && (
1894
+ <div
1895
+ style={{
1896
+ display: 'flex',
1897
+ gap: spaceVar[2],
1898
+ padding: `0 ${spaceVar[4]} ${spaceVar[4]}`,
1899
+ alignItems: 'center',
1900
+ }}
1901
+ >
1902
+ <input
1903
+ data-testid="react-story-name-input"
1904
+ // Focus follows the gesture (blind re-walk nit: typing right
1905
+ // after "Save as…" went nowhere until a second click).
1906
+ // biome-ignore lint/a11y/noAutofocus: the input exists only because the user just asked for it.
1907
+ autoFocus
1908
+ value={draftName}
1909
+ placeholder={mode === 'save-as' ? 'NewStoryName' : `Rename ${node.label}`}
1910
+ onChange={(event) => setDraftName(event.target.value)}
1911
+ onKeyDown={(event) => {
1912
+ if (event.key === 'Enter' && draftName.trim()) {
1913
+ void run(
1914
+ mode === 'save-as'
1915
+ ? { kind: 'save-as', name: draftName.trim() }
1916
+ : { kind: 'rename', newName: draftName.trim() },
1917
+ );
1918
+ }
1919
+ if (event.key === 'Escape') setMode('idle');
1920
+ }}
1921
+ style={{
1922
+ flex: 1,
1923
+ minWidth: 0,
1924
+ background: THEME.neutralHover,
1925
+ border: `1px solid ${THEME.border}`,
1926
+ borderRadius: 4,
1927
+ color: THEME.text,
1928
+ fontSize: fontSizeVar.base,
1929
+ padding: '3px 6px',
1930
+ }}
1931
+ />
1932
+ <Button
1933
+ type="button"
1934
+ variant="secondary"
1935
+ size="compact"
1936
+ data-testid="react-story-name-commit"
1937
+ onClick={() =>
1938
+ void run(
1939
+ mode === 'save-as'
1940
+ ? { kind: 'save-as', name: draftName.trim() }
1941
+ : { kind: 'rename', newName: draftName.trim() },
1942
+ )
1943
+ }
1944
+ >
1945
+ {mode === 'save-as' ? 'Save' : 'Rename'}
1946
+ </Button>
1947
+ </div>
1948
+ )}
1949
+ {notice && (
1950
+ <div
1951
+ data-testid="react-story-write-notice"
1952
+ style={{
1953
+ padding: `0 ${spaceVar[4]} ${spaceVar[4]}`,
1954
+ color: themeDanger(),
1955
+ fontSize: fontSizeVar.sm,
1956
+ lineHeight: lineHeightVar.normal,
1957
+ }}
1958
+ >
1959
+ {notice}
1960
+ </div>
1961
+ )}
1962
+ </div>
1963
+ );
1964
+ }
1965
+
1966
+ /**
1967
+ * NAMED STYLES on the element (design ledger; Webflow prior art): the class
1968
+ * tokens the element wears, each with Webflow's "N elements share this class"
1969
+ * count and a remove affordance, plus one input that APPLIES an existing
1970
+ * first-party class or CREATES a new one from the element's literal inline
1971
+ * styles (the adapter decides which — `writeNamedStyle`). A chip whose class
1972
+ * has a first-party single-class rule is an editable named style; one without
1973
+ * (a utility/generated class) renders muted, because there is no rule of ours
1974
+ * behind it to edit.
1975
+ */
1976
+ function ClassesBlock({
1977
+ owner,
1978
+ nodeId,
1979
+ }: {
1980
+ owner: ReactRootAuthoringAdapter;
1981
+ nodeId: string;
1982
+ }): React.ReactElement {
1983
+ const classes = owner.elementClasses(nodeId);
1984
+ const [draft, setDraft] = useState('');
1985
+ const [notice, setNotice] = useState<string | null>(null);
1986
+ const commit = async (): Promise<void> => {
1987
+ const className = draft.trim().replace(/^\./, '');
1988
+ if (!className) return;
1989
+ const result = await owner.writeNamedStyle(nodeId, { kind: 'set', className });
1990
+ setNotice(result.changed ? null : (result.error ?? 'refused'));
1991
+ if (result.changed) setDraft('');
1992
+ };
1993
+ return (
1994
+ <div
1995
+ data-testid="react-element-classes"
1996
+ style={{
1997
+ padding: `${spaceVar[3]} ${spaceVar[4]}`,
1998
+ borderBottom: `1px solid ${THEME.border}`,
1999
+ }}
2000
+ >
2001
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: spaceVar[2], alignItems: 'center' }}>
2002
+ <span
2003
+ style={{ fontSize: fontSizeVar.sm, color: THEME.textMuted, marginRight: spaceVar[1] }}
2004
+ >
2005
+ Style class
2006
+ </span>
2007
+ {classes.map((cls) => (
2008
+ <span
2009
+ key={cls.name}
2010
+ data-testid={`react-class-chip-${cls.name}`}
2011
+ title={
2012
+ cls.styledIn
2013
+ ? `.${cls.name} — ${cls.count} element${cls.count === 1 ? '' : 's'} in this game ` +
2014
+ `share this style (${cls.styledIn.split('/').pop() ?? cls.styledIn})`
2015
+ : `.${cls.name} — utility/generated class (no first-party rule to edit)`
2016
+ }
2017
+ style={{
2018
+ display: 'inline-flex',
2019
+ alignItems: 'center',
2020
+ gap: 3,
2021
+ fontFamily: FONT_MONO,
2022
+ fontSize: fontSizeVar.sm,
2023
+ color: cls.styledIn ? THEME.text : THEME.textMuted,
2024
+ background: THEME.neutralHover,
2025
+ border: `1px solid ${THEME.border}`,
2026
+ borderRadius: 4,
2027
+ padding: '1px 4px',
2028
+ }}
2029
+ >
2030
+ .{cls.name}
2031
+ {cls.count > 1 && (
2032
+ <span style={{ color: THEME.textMuted, fontSize: fontSizeVar.xs }}>×{cls.count}</span>
2033
+ )}
2034
+ <button
2035
+ type="button"
2036
+ data-testid={`react-class-remove-${cls.name}`}
2037
+ title={`Remove .${cls.name} from this element`}
2038
+ onClick={() =>
2039
+ void owner
2040
+ .writeNamedStyle(nodeId, { kind: 'remove', className: cls.name })
2041
+ .then((result) => setNotice(result.changed ? null : (result.error ?? 'refused')))
2042
+ }
2043
+ style={{
2044
+ background: 'none',
2045
+ border: 'none',
2046
+ color: THEME.textMuted,
2047
+ cursor: 'pointer',
2048
+ fontSize: fontSizeVar.base,
2049
+ // A real hit target (blind-walk friction: the ✕ was ~6px).
2050
+ padding: '3px 5px',
2051
+ margin: '-3px -4px -3px -1px',
2052
+ lineHeight: lineHeightVar.tight,
2053
+ }}
2054
+ >
2055
+ ✕
2056
+ </button>
2057
+ </span>
2058
+ ))}
2059
+ <input
2060
+ data-testid="react-class-input"
2061
+ value={draft}
2062
+ placeholder={classes.length === 0 ? 'create style…' : 'add class…'}
2063
+ title="Type a class name — an existing first-party class is applied; a new name creates a style from this element's inline declarations"
2064
+ onChange={(event) => {
2065
+ setDraft(event.target.value);
2066
+ if (notice) setNotice(null);
2067
+ }}
2068
+ onKeyDown={(event) => {
2069
+ if (event.key === 'Enter') void commit();
2070
+ if (event.key === 'Escape') setDraft('');
2071
+ }}
2072
+ style={{
2073
+ flex: 1,
2074
+ minWidth: 72,
2075
+ background: 'transparent',
2076
+ border: `1px dashed ${THEME.border}`,
2077
+ borderRadius: 4,
2078
+ color: THEME.text,
2079
+ fontSize: fontSizeVar.sm,
2080
+ fontFamily: FONT_MONO,
2081
+ padding: '2px 5px',
2082
+ }}
2083
+ />
2084
+ </div>
2085
+ {notice && (
2086
+ <div
2087
+ data-testid="react-class-notice"
2088
+ style={{
2089
+ paddingTop: spaceVar[2],
2090
+ color: themeDanger(),
2091
+ fontSize: fontSizeVar.sm,
2092
+ lineHeight: lineHeightVar.normal,
2093
+ }}
2094
+ >
2095
+ {notice}
2096
+ </div>
2097
+ )}
2098
+ <BreakpointRow />
2099
+ </div>
2100
+ );
2101
+ }
2102
+
2103
+ /** The breakpoint selector — module session state (`breakpoint-state.ts`),
2104
+ * because the authoring adapters are recreated across design-time re-renders
2105
+ * and instance state silently reset to base between the select gesture and
2106
+ * the next edit (measured). */
2107
+ function BreakpointRow(): React.ReactElement {
2108
+ const bp = useSyncExternalStore(subscribeBreakpoint, activeBreakpoint);
2109
+ return (
2110
+ <>
2111
+ <div style={{ display: 'flex', alignItems: 'center', gap: 5, paddingTop: 5 }}>
2112
+ <span style={{ fontSize: fontSizeVar.sm, color: THEME.textMuted }}>Breakpoint</span>
2113
+ <Select
2114
+ data-testid="react-breakpoint-select"
2115
+ value={bp ?? ''}
2116
+ onChange={(event) => setActiveBreakpoint(event.target.value || null)}
2117
+ style={{
2118
+ fontSize: fontSizeVar.sm,
2119
+ flex: 1,
2120
+ ...(bp ? { color: THEME.accent, borderColor: THEME.accent } : {}),
2121
+ }}
2122
+ >
2123
+ {BREAKPOINT_OPTIONS.map((option) => (
2124
+ <option key={option.label} value={option.media ?? ''}>
2125
+ {option.label}
2126
+ </option>
2127
+ ))}
2128
+ </Select>
2129
+ </div>
2130
+ {bp && (
2131
+ <div
2132
+ data-testid="react-breakpoint-note"
2133
+ style={{
2134
+ paddingTop: 3,
2135
+ color: THEME.textMuted,
2136
+ fontSize: fontSizeVar.xs,
2137
+ lineHeight: lineHeightVar.normal,
2138
+ }}
2139
+ >
2140
+ Style edits write @media {bp} — previewed live by frame width.
2141
+ </div>
2142
+ )}
2143
+ </>
2144
+ );
2145
+ }
2146
+
2147
+ /** Webflow's cascade tiers as `@media` conditions. Base is the unscoped rule;
2148
+ * each tier is max-width, so narrower inherits from wider — the same
2149
+ * downward cascade Webflow teaches. A project's own additional conditions
2150
+ * are honored wherever they already exist in its css; this list is only
2151
+ * what the SELECT offers. */
2152
+ const BREAKPOINT_OPTIONS: ReadonlyArray<{ label: string; media: string | null }> = [
2153
+ { label: 'Base', media: null },
2154
+ { label: 'Tablet ≤991', media: '(max-width: 991px)' },
2155
+ { label: 'Phone L ≤767', media: '(max-width: 767px)' },
2156
+ { label: 'Phone ≤479', media: '(max-width: 479px)' },
2157
+ ];
2158
+
2159
+ /** The danger tone for a story-write refusal, resolved lazily so this module
2160
+ * keeps a single THEME import surface. */
2161
+ function themeDanger(): string {
2162
+ return '#e06c75';
2163
+ }
2164
+
2165
+ /** The Lane-B ("edits component source -> every instance") disclosure —
2166
+ * ports `ui-editor/source-edit-panel.tsx`'s header COPY, not the component
2167
+ * itself (that one is bound to `UIEditStore`/an ad hoc `/__ui-source/index`
2168
+ * fetch — Rule zero bans importing `UIEditStore` into this shell-registered
2169
+ * section). Shown only for a `ReactRootAuthoringAdapter` (real Lane B,
2170
+ * `data-oid` component-source writes); a `DomAuthoringAdapter`
2171
+ * (ingested, overlay-only — never touches source) gets its own honest note
2172
+ * instead, so neither lane's write model is ever misrepresented. */
2173
+ /**
2174
+ * C4 (spec §9): "the editor offers open/copy-path/go-to-line operations" for a DOM
2175
+ * element's mapped source location. Read-only — this ONLY surfaces where the source is
2176
+ * and copies a `file:line` locator; it has no write path (that stays SourceWriteBackend's
2177
+ * bounded style/className/text/struct ops, wired elsewhere). `open` degrades honestly to
2178
+ * copy-path when no editor-launch protocol is configured (documented fallback, not a
2179
+ * silent no-op): most terminals/OS shells don't register a handler for arbitrary custom
2180
+ * URI schemes, so we copy a `vscode://file/<abs>:<line>` link users can paste, rather than
2181
+ * assume a scheme is registered and fail silently if it isn't.
2182
+ */
2183
+ function SourceLocationRow({ entry }: { entry: OidEntry }): React.ReactElement {
2184
+ const [copied, setCopied] = useState(false);
2185
+ const filePath = `${entry.file}:${entry.line}`;
2186
+ const short = `${entry.file.split('/').pop() ?? entry.file}:${entry.line}`;
2187
+ const copy = async (): Promise<void> => {
2188
+ try {
2189
+ await navigator.clipboard.writeText(filePath);
2190
+ setCopied(true);
2191
+ setTimeout(() => setCopied(false), 1500);
2192
+ } catch {
2193
+ // Clipboard API unavailable — the row still shows the path as text.
2194
+ }
2195
+ };
2196
+ return (
2197
+ <button
2198
+ type="button"
2199
+ data-testid="react-source-location"
2200
+ title={`${filePath} — click to copy the path`}
2201
+ onClick={() => void copy()}
2202
+ style={{
2203
+ fontFamily: FONT_MONO,
2204
+ fontSize: 'inherit',
2205
+ color: THEME.textMuted,
2206
+ cursor: 'pointer',
2207
+ overflow: 'hidden',
2208
+ textOverflow: 'ellipsis',
2209
+ whiteSpace: 'nowrap',
2210
+ minWidth: 0,
2211
+ background: 'none',
2212
+ border: 'none',
2213
+ padding: 0,
2214
+ textAlign: 'left',
2215
+ }}
2216
+ >
2217
+ {copied ? 'Copied' : short}
2218
+ </button>
2219
+ );
2220
+ }
2221
+
2222
+ function LaneDisclosure({
2223
+ owner,
2224
+ node,
2225
+ nodeId,
2226
+ }: {
2227
+ owner: ReactRootAuthoringAdapter | DomAuthoringAdapter;
2228
+ node: EditorNode | null;
2229
+ nodeId: string;
2230
+ }): React.ReactElement {
2231
+ const isLaneB = owner instanceof ReactRootAuthoringAdapter;
2232
+ // C4: only Lane B (the OID/JSX source-writer path) has a source-location mapping —
2233
+ // Lane A (`DomAuthoringAdapter`, an ingested/overlay session) never stamps
2234
+ // `data-oid` or has an OID index (see its file doc comment), so it has nothing to
2235
+ // resolve here; that's the intended DOM-only degradation, not a bug.
2236
+ const sourceEntry = isLaneB ? owner.sourceLocation(nodeId) : undefined;
2237
+ // ONE quiet line, Figma-shaped (owner feedback, 2026-08-30): the inspector
2238
+ // leads with design controls, so provenance is a small muted row — the
2239
+ // short label + the file:line — with the full explanation living in the
2240
+ // tooltip instead of a callout paragraph.
2241
+ const short =
2242
+ node?.role === 'story'
2243
+ ? `Story · ${node.label}`
2244
+ : node?.role === 'document'
2245
+ ? `CSF · ${node.secondaryLabel ?? node.label}`
2246
+ : isLaneB
2247
+ ? 'Source'
2248
+ : 'Session overlay — not written to source';
2249
+ const explain =
2250
+ node?.role === 'story'
2251
+ ? `${node.label} is the active portable CSF design state.`
2252
+ : node?.role === 'document'
2253
+ ? `${node.secondaryLabel ?? node.label} owns this root's design states.`
2254
+ : isLaneB
2255
+ ? `Edits change ${node?.label ?? 'this component'}'s source — every rendered ` +
2256
+ 'instance updates.'
2257
+ : 'This is an ingested game: edits persist as a per-project overlay only, never ' +
2258
+ 'written to source.';
2259
+ return (
2260
+ <div
2261
+ data-testid={isLaneB ? 'react-lane-b-disclosure' : 'react-overlay-disclosure'}
2262
+ title={explain}
2263
+ style={{
2264
+ display: 'flex',
2265
+ alignItems: 'center',
2266
+ gap: spaceVar[3],
2267
+ padding: `${spaceVar[1]} ${spaceVar[4]} ${spaceVar[3]}`,
2268
+ borderBottom: `1px solid ${THEME.border}`,
2269
+ color: THEME.textMuted,
2270
+ fontSize: fontSizeVar.sm,
2271
+ lineHeight: lineHeightVar.normal,
2272
+ minWidth: 0,
2273
+ }}
2274
+ >
2275
+ <span style={{ flexShrink: 0, ...(isLaneB ? {} : { color: THEME.accent }) }}>{short}</span>
2276
+ {sourceEntry && <SourceLocationRow entry={sourceEntry} />}
2277
+ </div>
2278
+ );
2279
+ }
2280
+
2281
+ /** The `element` facet's body: where it sits (ancestry), which lane owns it,
2282
+ * and where its source is. The subject's own name and type live on the
2283
+ * shell's identity row, not here. */
2284
+ function ElementSection({
2285
+ adapter,
2286
+ node,
2287
+ nodeId,
2288
+ owner,
2289
+ }: {
2290
+ adapter: AuthoringAdapter;
2291
+ node: EditorNode;
2292
+ nodeId: string;
2293
+ owner: ReactRootAuthoringAdapter | DomAuthoringAdapter;
2294
+ }): React.ReactElement {
2295
+ return (
2296
+ <div data-testid="react-inspector-section">
2297
+ <BreadcrumbBar adapter={adapter} node={node} />
2298
+ {owner instanceof ReactRootAuthoringAdapter && <ClassesBlock owner={owner} nodeId={nodeId} />}
2299
+ <LaneDisclosure owner={owner} node={node} nodeId={nodeId} />
2300
+ </div>
2301
+ );
2302
+ }
2303
+
2304
+ /** The Style/Layout/Position/Spacing/component-prop group bodies re-render on
2305
+ * the shell's own subscription: every `SingleRow`/`ComboRow` calls
2306
+ * `store.notifyIngestEdit()` on commit (its own `useEditorStore()`), which
2307
+ * re-renders the inspector tree including these sections. */
2308
+ function GroupSectionBody({
2309
+ adapter,
2310
+ id,
2311
+ name,
2312
+ groupId,
2313
+ props,
2314
+ }: {
2315
+ adapter: AuthoringAdapter;
2316
+ id: string;
2317
+ name: string;
2318
+ groupId: string;
2319
+ props: readonly PropertyDescriptor[];
2320
+ }): React.ReactElement {
2321
+ const fonts = useMemo(() => getAvailableFonts(), []);
2322
+ return (
2323
+ <GroupBody
2324
+ name={name}
2325
+ groupId={groupId}
2326
+ props={props}
2327
+ adapter={adapter}
2328
+ id={id}
2329
+ fonts={fonts}
2330
+ />
2331
+ );
2332
+ }
2333
+
2334
+ /**
2335
+ * The react/DOM adapter as a PRODUCER of subject data (owner, 2026-08-06 —
2336
+ * "every section its own identified tab; no lumps").
2337
+ *
2338
+ * This adapter's inspector is not one thing: it is the element's identity,
2339
+ * its loose style declarations, and one block per descriptor GROUP (Layout,
2340
+ * Position, Spacing, a component's own props, the document's tokens…). Which
2341
+ * groups exist is a property of the NODE, so they can only be produced from
2342
+ * it — which is exactly what a producer registration is for. Each block comes
2343
+ * back as its own identified `InspectionSection`, so the compact card gives
2344
+ * it its own icon tab and the column its own titled block, with no projection
2345
+ * learning anything about React.
2346
+ *
2347
+ * The ids are the model's own (`properties` for the loose declarations,
2348
+ * `group:<id>` per group), so each block REPLACES the generic built-in of the
2349
+ * same id rather than appearing beside it.
2350
+ */
2351
+ export function reactInspectorSections(
2352
+ node: EditorNode | null,
2353
+ adapter: AuthoringAdapter,
2354
+ ): readonly InspectionSection[] {
2355
+ const owner = resolveReactOwner(node, adapter);
2356
+ if (!owner || !node) return [];
2357
+ const nodeId = node.id;
2358
+ const props = (adapter.inspector?.properties(nodeId) ?? []).filter((p) => p.path !== 'name');
2359
+ const { ungrouped, groups } = groupProperties(props);
2360
+ const sections: InspectionSection[] = [
2361
+ {
2362
+ id: REACT_ELEMENT_SECTION_ID,
2363
+ // The facet's name, not the object's: what KIND of element this is
2364
+ // surfaces on the subject's identity row. This adapter sets no
2365
+ // `typeLabel`, so the row's kind label falls back to `node.kind` (the tag
2366
+ // name) — the `typeLabel ?? kind` chain in `compose.ts`'s `kindLabel`.
2367
+ title: 'Element',
2368
+ icon: faCode,
2369
+ order: REACT_ELEMENT_SECTION_ORDER,
2370
+ body: {
2371
+ kind: 'custom',
2372
+ render: () => createElement(ElementSection, { adapter, node, nodeId, owner }),
2373
+ // The AGENT half of the Element section (P18 + the Transform-section
2374
+ // editable precedent): the class chips and active breakpoint the
2375
+ // rendered chrome shows, published as data — and the breakpoint as a
2376
+ // writable field through the SAME `setActiveBreakpoint` the select
2377
+ // calls, so `editor.inspect()` can read the named-styles surface and
2378
+ // `editor.setField('element.breakpoint', …)` can arm it. Class
2379
+ // add/remove stays a structural gesture (writeNamedStyle), not a
2380
+ // field — a class list is not a value.
2381
+ ...(owner instanceof ReactRootAuthoringAdapter
2382
+ ? {
2383
+ data: {
2384
+ classes: owner.elementClasses(nodeId),
2385
+ breakpoint: activeBreakpoint(),
2386
+ },
2387
+ editable: {
2388
+ fields: [
2389
+ {
2390
+ path: 'element.breakpoint',
2391
+ label: 'Breakpoint',
2392
+ type: 'enum' as const,
2393
+ options: BREAKPOINT_OPTIONS.map((option) => option.media ?? ''),
2394
+ },
2395
+ ],
2396
+ io: {
2397
+ get: (path: string) =>
2398
+ path === 'element.breakpoint' ? (activeBreakpoint() ?? '') : undefined,
2399
+ set: (path: string, value: unknown) => {
2400
+ if (path === 'element.breakpoint') {
2401
+ setActiveBreakpoint(typeof value === 'string' && value ? value : null);
2402
+ }
2403
+ },
2404
+ },
2405
+ },
2406
+ }
2407
+ : {}),
2408
+ },
2409
+ },
2410
+ ];
2411
+ if (node.role === 'story' && owner instanceof ReactRootAuthoringAdapter) {
2412
+ sections.push({
2413
+ id: REACT_STORY_SECTION_ID,
2414
+ title: 'Story',
2415
+ // The single selected story's own controls — a DIFFERENT facet from the
2416
+ // built-in `stories` collection/switcher (`faBookOpen`), and both can
2417
+ // compose on a story node. The compact card tells tabs apart by glyph
2418
+ // alone, so this one wears a distinct single-story glyph.
2419
+ icon: faBookBookmark,
2420
+ order: REACT_STORY_SECTION_ORDER,
2421
+ body: { kind: 'custom', render: () => createElement(StoryActions, { owner, node }) },
2422
+ });
2423
+ }
2424
+ if (ungrouped.length > 0) {
2425
+ sections.push({
2426
+ id: PROPERTIES_SECTION_ID,
2427
+ title: 'Style',
2428
+ icon: faPalette,
2429
+ order: PROPERTIES_SECTION_ORDER,
2430
+ defaultOpen: true,
2431
+ body: {
2432
+ kind: 'custom',
2433
+ render: () =>
2434
+ createElement(GroupSectionBody, {
2435
+ adapter,
2436
+ id: nodeId,
2437
+ name: 'Style',
2438
+ groupId: 'style',
2439
+ props: ungrouped,
2440
+ }),
2441
+ },
2442
+ });
2443
+ }
2444
+ groups.forEach((group, index) => {
2445
+ sections.push({
2446
+ id: groupSectionId(group.groupId),
2447
+ title: group.name,
2448
+ icon: groupIcon(group.name),
2449
+ order: GROUP_SECTION_ORDER + index,
2450
+ defaultOpen: true,
2451
+ body: {
2452
+ kind: 'custom',
2453
+ render: () =>
2454
+ createElement(GroupSectionBody, {
2455
+ adapter,
2456
+ id: nodeId,
2457
+ name: group.name,
2458
+ groupId: group.groupId,
2459
+ props: group.properties,
2460
+ }),
2461
+ },
2462
+ });
2463
+ });
2464
+ return sections;
2465
+ }
2466
+
2467
+ const registrationGroup = createHmrRegistrationGroup(import.meta.hot, 'react-inspector-section');
2468
+
2469
+ /**
2470
+ * Idempotent one-time registration of this surface's Inspector section, called
2471
+ * by `@volter/editor-game/contributions/react/react-inspector.service.ts`.
2472
+ *
2473
+ * It used to run at module scope, reached by a bare `import
2474
+ * './authoring/react-inspector-section';` on line 33 of the host's `main.tsx`
2475
+ * — which is how the whole React/DOM design-time surface, 28 files, entered
2476
+ * every editor boot including a `models` build that authors no DOM. The
2477
+ * `ensure`/`track` shape is `@volter/editor-game`'s story contributions', for the same
2478
+ * reason recorded there: importing this module (in a test, or to reach
2479
+ * {@link resolveReactOwner}) must not mutate a registry.
2480
+ */
2481
+ export function ensureReactInspectorSectionRegistered(): void {
2482
+ registrationGroup.ensure((track) => {
2483
+ track(
2484
+ registerInspectorSections({
2485
+ match: isDomSelection,
2486
+ sections: reactInspectorSections,
2487
+ // The descriptor channel is this adapter's own: its blocks ARE the node's
2488
+ // properties, mapped to the C1 widgets instead of the generic grid. Claimed
2489
+ // even for a node with no loose declarations, so the thin generic grid
2490
+ // never appears beside the rich blocks. (A react/DOM node has no transform
2491
+ // provider at all, so no Transform block is ever composed for one.)
2492
+ claims: [PROPERTIES_SECTION_ID],
2493
+ }),
2494
+ );
2495
+ });
2496
+ }