@volter/sdk 0.0.0-stage → 0.5.204

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 (523) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +20 -0
  3. package/README.md +38 -3
  4. package/package.json +510 -4
  5. package/src/account.ts +210 -0
  6. package/src/chrome.ts +88 -0
  7. package/src/client.ts +1646 -0
  8. package/src/commands.ts +66 -0
  9. package/src/contributions.ts +619 -0
  10. package/src/css-numeric-style.ts +97 -0
  11. package/src/document-probe.ts +282 -0
  12. package/src/editor-view.ts +225 -0
  13. package/src/extension.ts +40 -0
  14. package/src/generations.ts +178 -0
  15. package/src/host.ts +1157 -0
  16. package/src/http-transport.browser.ts +14 -0
  17. package/src/http-transport.node.ts +19 -0
  18. package/src/index.ts +131 -0
  19. package/src/kit/CapabilityCoverageSection.tsx +185 -0
  20. package/src/kit/account-client.ts +333 -0
  21. package/src/kit/action-registry.ts +317 -0
  22. package/src/kit/active-product.ts +76 -0
  23. package/src/kit/active-project.ts +155 -0
  24. package/src/kit/adapter-editor-config.ts +25 -0
  25. package/src/kit/adapter-module.ts +7 -0
  26. package/src/kit/adapter-observation.ts +49 -0
  27. package/src/kit/animation/animation-clock.ts +479 -0
  28. package/src/kit/animation/stage-transport.ts +385 -0
  29. package/src/kit/api/assets.ts +365 -0
  30. package/src/kit/api/project-open.ts +355 -0
  31. package/src/kit/api/project-source.ts +180 -0
  32. package/src/kit/api/project-state.ts +110 -0
  33. package/src/kit/api/relay.ts +270 -0
  34. package/src/kit/api/themes.ts +45 -0
  35. package/src/kit/api-asset-library-wire.ts +45 -0
  36. package/src/kit/api-base.ts +10 -0
  37. package/src/kit/api-build.ts +99 -0
  38. package/src/kit/api-git-wire.ts +56 -0
  39. package/src/kit/api-logs.ts +92 -0
  40. package/src/kit/api-project-identity.ts +74 -0
  41. package/src/kit/api-settings.ts +36 -0
  42. package/src/kit/api-worktrees.ts +205 -0
  43. package/src/kit/asset-capabilities.ts +344 -0
  44. package/src/kit/asset-compare-core.ts +171 -0
  45. package/src/kit/asset-editor-context.tsx +101 -0
  46. package/src/kit/asset-events.ts +96 -0
  47. package/src/kit/asset-inspector-actions.ts +87 -0
  48. package/src/kit/asset-selection-viewer-registry.ts +113 -0
  49. package/src/kit/asset-selection.ts +146 -0
  50. package/src/kit/asset-thumbnails.ts +25 -0
  51. package/src/kit/asset-viewers.ts +115 -0
  52. package/src/kit/asset-workflow/asset-import-jobs.ts +106 -0
  53. package/src/kit/asset-workflow/asset-ledger-backend.ts +126 -0
  54. package/src/kit/asset-workflow/asset-ledger.ts +156 -0
  55. package/src/kit/asset-workflow/asset-materialization-report.ts +140 -0
  56. package/src/kit/asset-workflow/asset-pack-manifest.ts +320 -0
  57. package/src/kit/asset-workflow/asset-types.ts +142 -0
  58. package/src/kit/asset-workflow/audio-preview-player.ts +193 -0
  59. package/src/kit/asset-workflow/audio-waveform.ts +22 -0
  60. package/src/kit/asset-workflow/cloud-asset-client.ts +263 -0
  61. package/src/kit/asset-workflow/hosted-asset-materialization.ts +236 -0
  62. package/src/kit/asset-workflow/image-view-scale.ts +31 -0
  63. package/src/kit/asset-workflow/import-contract.ts +124 -0
  64. package/src/kit/asset-workflow/ledger-write-lock.ts +244 -0
  65. package/src/kit/asset-workflow/pixi-spritesheet.ts +197 -0
  66. package/src/kit/asset-workflow/preview-resource-lifetime.ts +44 -0
  67. package/src/kit/asset-workflow/project-asset-commands.ts +20 -0
  68. package/src/kit/asset-workflow/project-content.ts +288 -0
  69. package/src/kit/asset-workflow/project-source-index.ts +545 -0
  70. package/src/kit/asset-workflow/thumbnail-system.ts +256 -0
  71. package/src/kit/authoring/active-adapter.ts +200 -0
  72. package/src/kit/authoring/active-systems.ts +422 -0
  73. package/src/kit/authoring/adapter-key.ts +18 -0
  74. package/src/kit/authoring/authoring-asset-url.ts +27 -0
  75. package/src/kit/authoring/bootstrap-state.ts +49 -0
  76. package/src/kit/authoring/boundary-authoring-adapter.ts +189 -0
  77. package/src/kit/authoring/canvas-scene-guides.ts +84 -0
  78. package/src/kit/authoring/composite-authoring-adapter.ts +2109 -0
  79. package/src/kit/authoring/consumer-actions.ts +531 -0
  80. package/src/kit/authoring/design-time-layers.ts +852 -0
  81. package/src/kit/authoring/design-time-mount-registry.ts +244 -0
  82. package/src/kit/authoring/edit-mode-authoring.ts +637 -0
  83. package/src/kit/authoring/empty-project-authoring.ts +22 -0
  84. package/src/kit/authoring/instance-source-menu.ts +135 -0
  85. package/src/kit/authoring/layered-pick.ts +185 -0
  86. package/src/kit/authoring/mounted-root-subjects.ts +146 -0
  87. package/src/kit/authoring/no-authoring-adapter.ts +59 -0
  88. package/src/kit/authoring/object3d-document-persistence.ts +122 -0
  89. package/src/kit/authoring/panel-authoring.ts +121 -0
  90. package/src/kit/authoring/project-authoring-session.ts +105 -0
  91. package/src/kit/authoring/provenance.ts +99 -0
  92. package/src/kit/authoring/react-canvas-navigation.ts +259 -0
  93. package/src/kit/authoring/react-design-canvas-style.ts +20 -0
  94. package/src/kit/authoring/react-story-board.ts +917 -0
  95. package/src/kit/authoring/selection-scope.ts +195 -0
  96. package/src/kit/authoring/shell-document-ops.ts +169 -0
  97. package/src/kit/authoring/story-board-chrome-fit.ts +107 -0
  98. package/src/kit/authoring/story-board-presentation.ts +111 -0
  99. package/src/kit/authoring/three-root.ts +67 -0
  100. package/src/kit/authoring/viewport-tool-context.ts +73 -0
  101. package/src/kit/authoring/viewport-tool-owner.ts +38 -0
  102. package/src/kit/authoring/world-session-state.ts +101 -0
  103. package/src/kit/authoring-seam-evidence.ts +300 -0
  104. package/src/kit/availability-tick.ts +66 -0
  105. package/src/kit/bitmap-label.ts +120 -0
  106. package/src/kit/boot-routing.ts +392 -0
  107. package/src/kit/breakpoint-state.ts +43 -0
  108. package/src/kit/build-identity.ts +16 -0
  109. package/src/kit/bytes-codec.ts +62 -0
  110. package/src/kit/cancellation-reason.ts +58 -0
  111. package/src/kit/canvas-frames.ts +88 -0
  112. package/src/kit/capture-camera-pose.ts +77 -0
  113. package/src/kit/capture-size.ts +88 -0
  114. package/src/kit/chrome-registry.ts +159 -0
  115. package/src/kit/chrome-slot-registry.ts +91 -0
  116. package/src/kit/collaboration-client.ts +264 -0
  117. package/src/kit/collaboration-presence.ts +41 -0
  118. package/src/kit/command-dispatch.ts +19 -0
  119. package/src/kit/command-listener.ts +2182 -0
  120. package/src/kit/command-registry.ts +71 -0
  121. package/src/kit/component-board-registry.ts +205 -0
  122. package/src/kit/component-states-registry.ts +199 -0
  123. package/src/kit/components/AlignToolbar.tsx +204 -0
  124. package/src/kit/components/ApplicationMenus.tsx +372 -0
  125. package/src/kit/components/AssetEditorShell.tsx +216 -0
  126. package/src/kit/components/AssetInspectorToolSection.tsx +124 -0
  127. package/src/kit/components/BoardRulers.tsx +354 -0
  128. package/src/kit/components/CanvasAddNodeDialogs.tsx +529 -0
  129. package/src/kit/components/CanvasSceneViewport.tsx +1195 -0
  130. package/src/kit/components/ChromeSlot.tsx +20 -0
  131. package/src/kit/components/CodeView.tsx +470 -0
  132. package/src/kit/components/CompactInspectorShell.tsx +39 -0
  133. package/src/kit/components/ConsolePanel.tsx +273 -0
  134. package/src/kit/components/GameplaySessionTimeline.tsx +295 -0
  135. package/src/kit/components/InspectionProjection.tsx +932 -0
  136. package/src/kit/components/Inspector.tsx +270 -0
  137. package/src/kit/components/InspectorCanvasPreview.tsx +35 -0
  138. package/src/kit/components/InspectorFieldsSection.tsx +290 -0
  139. package/src/kit/components/InspectorStoriesSection.tsx +92 -0
  140. package/src/kit/components/InspectorToolSection.tsx +96 -0
  141. package/src/kit/components/InspectorTransformSection.tsx +245 -0
  142. package/src/kit/components/LightExplorerPanel.tsx +433 -0
  143. package/src/kit/components/MediaProperties.tsx +145 -0
  144. package/src/kit/components/ProjectHeader.tsx +328 -0
  145. package/src/kit/components/ReactCanvasControls.tsx +358 -0
  146. package/src/kit/components/RootSelectionOverlay.tsx +3688 -0
  147. package/src/kit/components/RootTextEditor.tsx +79 -0
  148. package/src/kit/components/SaveStatus.tsx +70 -0
  149. package/src/kit/components/SurfaceCrashBoundary.tsx +105 -0
  150. package/src/kit/components/SurfaceStateOverlay.tsx +24 -0
  151. package/src/kit/components/ToolContributionSurfaces.tsx +49 -0
  152. package/src/kit/components/ToolHost.tsx +380 -0
  153. package/src/kit/components/Toolbar.tsx +811 -0
  154. package/src/kit/components/TransientHint.tsx +44 -0
  155. package/src/kit/components/VersionControlSection.tsx +470 -0
  156. package/src/kit/components/ViewportOverlaysMenu.tsx +177 -0
  157. package/src/kit/components/VolterLogo.tsx +18 -0
  158. package/src/kit/components/WorktreeSwitcher.tsx +712 -0
  159. package/src/kit/components/account-documents.tsx +1162 -0
  160. package/src/kit/components/asset-documents.tsx +794 -0
  161. package/src/kit/components/asset-editor-persistence.ts +216 -0
  162. package/src/kit/components/asset-selection-section.tsx +545 -0
  163. package/src/kit/components/asset-thumbnails.tsx +307 -0
  164. package/src/kit/components/asset-viewers/AudioViewer.tsx +201 -0
  165. package/src/kit/components/asset-viewers/GenericJsonViewer.tsx +102 -0
  166. package/src/kit/components/asset-viewers/ImageViewer.tsx +300 -0
  167. package/src/kit/components/asset-viewers/JsonAssetDocument.tsx +98 -0
  168. package/src/kit/components/asset-viewers/OnlineAssetDetail.tsx +426 -0
  169. package/src/kit/components/asset-viewers/SourceAssetViewer.tsx +356 -0
  170. package/src/kit/components/asset-viewers/SpritesheetSpriteView.tsx +102 -0
  171. package/src/kit/components/asset-viewers/VideoViewer.tsx +101 -0
  172. package/src/kit/components/asset-viewers/shader-source.ts +144 -0
  173. package/src/kit/components/board-guides.ts +150 -0
  174. package/src/kit/components/canvas-scene-hotkeys.ts +37 -0
  175. package/src/kit/components/canvas-temporary-pivot.ts +34 -0
  176. package/src/kit/components/core-utilities.tsx +94 -0
  177. package/src/kit/components/inspector-preview-section.tsx +223 -0
  178. package/src/kit/components/inspector-revert-label.ts +20 -0
  179. package/src/kit/components/inspector-selection.ts +42 -0
  180. package/src/kit/components/inspector-stories-gating.ts +171 -0
  181. package/src/kit/components/inspector-transform-subject.ts +11 -0
  182. package/src/kit/components/inspector-transform.ts +88 -0
  183. package/src/kit/components/kind-documents.tsx +544 -0
  184. package/src/kit/components/primitives/DraftColorInput.tsx +74 -0
  185. package/src/kit/components/project-tool-documents.tsx +402 -0
  186. package/src/kit/components/scene-documents.tsx +221 -0
  187. package/src/kit/components/status-contributions.tsx +407 -0
  188. package/src/kit/components/tool-documents.tsx +302 -0
  189. package/src/kit/components/tool-schema-form.tsx +262 -0
  190. package/src/kit/components/use-after-paint.ts +41 -0
  191. package/src/kit/components/use-project-image-assets.ts +86 -0
  192. package/src/kit/components/workspace-history.ts +32 -0
  193. package/src/kit/components/world-documents.tsx +570 -0
  194. package/src/kit/components/world-overlay-gestures.ts +1939 -0
  195. package/src/kit/composite-screenshot.ts +2238 -0
  196. package/src/kit/content-entry-source-registry.ts +184 -0
  197. package/src/kit/contribution-surfaces.ts +48 -0
  198. package/src/kit/coverage/canvas-reveal.ts +192 -0
  199. package/src/kit/coverage/design-time-surfaces.ts +101 -0
  200. package/src/kit/coverage/ontology-invariants.ts +466 -0
  201. package/src/kit/coverage/session-vitals.ts +503 -0
  202. package/src/kit/crash-null-boundary.ts +36 -0
  203. package/src/kit/creation-site-edit.ts +1491 -0
  204. package/src/kit/creation-site-registry.ts +160 -0
  205. package/src/kit/delegate-harness-registry.ts +134 -0
  206. package/src/kit/document-areas.ts +70 -0
  207. package/src/kit/document-context-registry.ts +193 -0
  208. package/src/kit/document-open-registry.ts +200 -0
  209. package/src/kit/document-play-extension.ts +221 -0
  210. package/src/kit/document-preview-source.ts +20 -0
  211. package/src/kit/document-renderer-session.ts +138 -0
  212. package/src/kit/document-stage-sessions.ts +26 -0
  213. package/src/kit/document-viewports.ts +120 -0
  214. package/src/kit/editor-api.ts +46 -0
  215. package/src/kit/editor-chrome-capture.ts +136 -0
  216. package/src/kit/editor-commands.ts +176 -0
  217. package/src/kit/editor-console.ts +580 -0
  218. package/src/kit/editor-current-view.ts +56 -0
  219. package/src/kit/editor-document-probe.ts +1168 -0
  220. package/src/kit/editor-git-client.ts +115 -0
  221. package/src/kit/editor-hotkeys.ts +728 -0
  222. package/src/kit/editor-lease-view.ts +39 -0
  223. package/src/kit/editor-lease.ts +415 -0
  224. package/src/kit/editor-mode.ts +19 -0
  225. package/src/kit/editor-notifications.ts +140 -0
  226. package/src/kit/editor-presence.ts +563 -0
  227. package/src/kit/editor-presentation-activity.ts +58 -0
  228. package/src/kit/editor-presentation-notice.ts +42 -0
  229. package/src/kit/editor-runtime.tsx +147 -0
  230. package/src/kit/editor-server-response.ts +86 -0
  231. package/src/kit/editor-session-attribution.ts +85 -0
  232. package/src/kit/editor-session-mode.ts +54 -0
  233. package/src/kit/editor-state-facets.ts +74 -0
  234. package/src/kit/editor-view-presentation.ts +777 -0
  235. package/src/kit/environment-images.ts +58 -0
  236. package/src/kit/eyedropper-session.ts +60 -0
  237. package/src/kit/files/file-provider.ts +62 -0
  238. package/src/kit/files/project-files.ts +270 -0
  239. package/src/kit/finders/index.ts +137 -0
  240. package/src/kit/finders/scenes-from-entrypoint-selection.ts +387 -0
  241. package/src/kit/frame/frame-parts.ts +30 -0
  242. package/src/kit/framed-document-capture.ts +34 -0
  243. package/src/kit/game-globals-prelude.ts +143 -0
  244. package/src/kit/game-surface-defaults.ts +33 -0
  245. package/src/kit/gameplay-dom-recording.ts +318 -0
  246. package/src/kit/gameplay-export-state.ts +14 -0
  247. package/src/kit/gameplay-replay.ts +417 -0
  248. package/src/kit/gameplay-session-time.ts +9 -0
  249. package/src/kit/gameplay-sessions.ts +204 -0
  250. package/src/kit/hierarchy-component-marks.ts +298 -0
  251. package/src/kit/hierarchy-internals.ts +197 -0
  252. package/src/kit/hierarchy-kind-icon.ts +217 -0
  253. package/src/kit/hierarchy-menu-registry.ts +67 -0
  254. package/src/kit/hierarchy-node-rows.ts +307 -0
  255. package/src/kit/hierarchy-panel-view.ts +280 -0
  256. package/src/kit/hierarchy-projection.ts +76 -0
  257. package/src/kit/hierarchy-row-media.ts +45 -0
  258. package/src/kit/hierarchy-row-model.ts +308 -0
  259. package/src/kit/hierarchy-rows.ts +11 -0
  260. package/src/kit/hierarchy-walk.ts +86 -0
  261. package/src/kit/history/editor-session.ts +25 -0
  262. package/src/kit/history/history-commands.ts +147 -0
  263. package/src/kit/history/history-delegate.ts +187 -0
  264. package/src/kit/history/history-limit-notices.ts +43 -0
  265. package/src/kit/history/history-service.ts +1189 -0
  266. package/src/kit/history/persistence-coordinator.ts +35 -0
  267. package/src/kit/history/project-file-history.ts +386 -0
  268. package/src/kit/history/project-root-history-backends.ts +139 -0
  269. package/src/kit/history/resource-registry.ts +209 -0
  270. package/src/kit/history/snapshot-store.ts +103 -0
  271. package/src/kit/history/source-history-backend.ts +546 -0
  272. package/src/kit/history-types.ts +124 -0
  273. package/src/kit/hmr-registration-group.ts +67 -0
  274. package/src/kit/hmr-stable-react-context.ts +23 -0
  275. package/src/kit/hotkeys.ts +190 -0
  276. package/src/kit/inference-diagnostics.ts +69 -0
  277. package/src/kit/initial-project.ts +80 -0
  278. package/src/kit/inspection/active-subject.ts +571 -0
  279. package/src/kit/inspection/active-surface.ts +142 -0
  280. package/src/kit/inspection/compose-subject.ts +1055 -0
  281. package/src/kit/inspection/compose.ts +7 -0
  282. package/src/kit/inspection/display.ts +171 -0
  283. package/src/kit/inspection/document-subject.ts +109 -0
  284. package/src/kit/inspection/game-subject.ts +85 -0
  285. package/src/kit/inspection/null-subject.ts +119 -0
  286. package/src/kit/inspection/serialize.ts +357 -0
  287. package/src/kit/inspection/use-active-inspection.ts +180 -0
  288. package/src/kit/inspection-model.ts +542 -0
  289. package/src/kit/inspection-node-media.ts +58 -0
  290. package/src/kit/inspector-presentation.ts +203 -0
  291. package/src/kit/inspector-property-grouping.ts +64 -0
  292. package/src/kit/inspector-section-registry.ts +221 -0
  293. package/src/kit/instance-source-actions.ts +163 -0
  294. package/src/kit/js-heap.ts +71 -0
  295. package/src/kit/key-actions.ts +91 -0
  296. package/src/kit/keymap-presets.ts +428 -0
  297. package/src/kit/layout-policy.ts +31 -0
  298. package/src/kit/light-explorer-model.ts +134 -0
  299. package/src/kit/live-canvas-frame.ts +55 -0
  300. package/src/kit/live-document.ts +296 -0
  301. package/src/kit/live-gesture-lock.ts +50 -0
  302. package/src/kit/live-seam-evidence.ts +11 -0
  303. package/src/kit/live-session-registry.ts +220 -0
  304. package/src/kit/live-transition.ts +391 -0
  305. package/src/kit/manifest-project.ts +107 -0
  306. package/src/kit/module-fetch-diagnosis.ts +192 -0
  307. package/src/kit/mount-failure-report.ts +154 -0
  308. package/src/kit/native-selection-style.ts +497 -0
  309. package/src/kit/object3d-document-write-policy.ts +137 -0
  310. package/src/kit/packaged-runtime.ts +108 -0
  311. package/src/kit/palettes/maya.palette.json +57 -0
  312. package/src/kit/palettes/substance.palette.json +57 -0
  313. package/src/kit/performance-profiler.ts +367 -0
  314. package/src/kit/performance-sources.ts +69 -0
  315. package/src/kit/photograph-notice.ts +141 -0
  316. package/src/kit/play-boot-phase.ts +166 -0
  317. package/src/kit/play-camera-flight.ts +35 -0
  318. package/src/kit/png-encode.worker.ts +26 -0
  319. package/src/kit/presentation-surface.ts +248 -0
  320. package/src/kit/product-command.ts +90 -0
  321. package/src/kit/project-adapter.ts +1140 -0
  322. package/src/kit/project-asset-refresh.ts +23 -0
  323. package/src/kit/project-asset-roots.ts +68 -0
  324. package/src/kit/project-local-state.ts +151 -0
  325. package/src/kit/project-manager.ts +243 -0
  326. package/src/kit/project-module-changes.ts +201 -0
  327. package/src/kit/project-module-split.ts +270 -0
  328. package/src/kit/project-play-layers.ts +25 -0
  329. package/src/kit/project-provenance.ts +115 -0
  330. package/src/kit/project-ready.ts +42 -0
  331. package/src/kit/project-shape.ts +68 -0
  332. package/src/kit/project-tools.ts +107 -0
  333. package/src/kit/projection-types.ts +44 -0
  334. package/src/kit/readiness.ts +113 -0
  335. package/src/kit/renderer-resource-counts.ts +27 -0
  336. package/src/kit/reported-play-state.ts +90 -0
  337. package/src/kit/resolve-contributed-command.ts +14 -0
  338. package/src/kit/resolve-relative-specifier.ts +33 -0
  339. package/src/kit/retained-document-states.ts +91 -0
  340. package/src/kit/scene-document-plan.ts +320 -0
  341. package/src/kit/scene-live-open.ts +210 -0
  342. package/src/kit/scoped-game-css.ts +152 -0
  343. package/src/kit/served-url.ts +5 -0
  344. package/src/kit/session-close.ts +17 -0
  345. package/src/kit/session-tombstone.ts +127 -0
  346. package/src/kit/settings/settings-provider.ts +82 -0
  347. package/src/kit/settings-store.ts +348 -0
  348. package/src/kit/shell-document-state.ts +27 -0
  349. package/src/kit/shell-store-door.ts +45 -0
  350. package/src/kit/shell-store.ts +722 -0
  351. package/src/kit/source-conflict.ts +122 -0
  352. package/src/kit/stage-context.ts +377 -0
  353. package/src/kit/stage-invalidation.ts +25 -0
  354. package/src/kit/stage-store-registry.ts +69 -0
  355. package/src/kit/startup-failure.ts +80 -0
  356. package/src/kit/state-report-deferral.ts +73 -0
  357. package/src/kit/storage/host-files-storage.ts +97 -0
  358. package/src/kit/storage/http-storage.ts +174 -0
  359. package/src/kit/storage/index.ts +75 -0
  360. package/src/kit/storage/mem-storage.ts +158 -0
  361. package/src/kit/storage/path-lock.ts +44 -0
  362. package/src/kit/storage/paths.ts +26 -0
  363. package/src/kit/storage-types.ts +127 -0
  364. package/src/kit/stories/StoryPreviewMount.tsx +306 -0
  365. package/src/kit/stories/compose-project-stories.ts +255 -0
  366. package/src/kit/stories/prefabs-finder.ts +54 -0
  367. package/src/kit/stories/prefabs-from-stories.ts +182 -0
  368. package/src/kit/stories/project-story-regions.ts +24 -0
  369. package/src/kit/stories/story-capture.ts +579 -0
  370. package/src/kit/stories/story-declared-medium.ts +126 -0
  371. package/src/kit/stories/story-discovery.ts +176 -0
  372. package/src/kit/stories/story-dom-runtime.ts +78 -0
  373. package/src/kit/stories/story-grouping.ts +111 -0
  374. package/src/kit/stories/story-mount-turn.ts +27 -0
  375. package/src/kit/stories/story-presentation.ts +215 -0
  376. package/src/kit/stories/story-preview-component.ts +7 -0
  377. package/src/kit/stories/story-registry.ts +530 -0
  378. package/src/kit/stories-scope.ts +35 -0
  379. package/src/kit/story-document-openers.ts +36 -0
  380. package/src/kit/story-thumbnails.ts +47 -0
  381. package/src/kit/surface-keyboard.ts +101 -0
  382. package/src/kit/surface-state.ts +135 -0
  383. package/src/kit/system-seam-evidence.ts +72 -0
  384. package/src/kit/tab-census.ts +202 -0
  385. package/src/kit/tab-lifecycle-client.ts +227 -0
  386. package/src/kit/theme-library.ts +897 -0
  387. package/src/kit/theme-preference.ts +429 -0
  388. package/src/kit/three-viewport-presentation.ts +23 -0
  389. package/src/kit/tool-contribution-play.ts +74 -0
  390. package/src/kit/tool-loader.ts +1918 -0
  391. package/src/kit/transform-mode-request.ts +66 -0
  392. package/src/kit/transient-hint.ts +78 -0
  393. package/src/kit/transport-strip.tsx +174 -0
  394. package/src/kit/ui-source/adapter-region-includes.ts +238 -0
  395. package/src/kit/ui-source/file-region-resolver.ts +302 -0
  396. package/src/kit/ui-source/inspect.ts +775 -0
  397. package/src/kit/ui-source/source-write-backend.ts +605 -0
  398. package/src/kit/ui-source/tier-source-write-backend.ts +279 -0
  399. package/src/kit/user-local-state.ts +105 -0
  400. package/src/kit/viewport-activation-timings.ts +840 -0
  401. package/src/kit/viewport-editor-controls.ts +22 -0
  402. package/src/kit/viewport-presentation.ts +668 -0
  403. package/src/kit/viewport-surface-status.tsx +55 -0
  404. package/src/kit/wait-until.ts +37 -0
  405. package/src/kit/worker-call-metrics.ts +166 -0
  406. package/src/kit/workspace-areas.ts +191 -0
  407. package/src/kit/workspace-aux-commands.ts +11 -0
  408. package/src/kit/workspace-available-documents.ts +142 -0
  409. package/src/kit/workspace-core-utilities.ts +31 -0
  410. package/src/kit/workspace-document-ids.ts +59 -0
  411. package/src/kit/workspace-document-registry.ts +624 -0
  412. package/src/kit/workspace-document-restore.ts +146 -0
  413. package/src/kit/workspace-host-commands.ts +141 -0
  414. package/src/kit/workspace-persistence-gate.ts +40 -0
  415. package/src/kit/workspace-play-utilities.ts +44 -0
  416. package/src/kit/workspace-presets.ts +446 -0
  417. package/src/kit/workspace-regions.ts +276 -0
  418. package/src/kit/workspace-static-panels.ts +73 -0
  419. package/src/kit/workspace-status-registry.ts +121 -0
  420. package/src/kit/workspace-storage.ts +35 -0
  421. package/src/kit/workspace-style.ts +226 -0
  422. package/src/kit/workspace-utility-commands.ts +74 -0
  423. package/src/kit/workspace-utility-registry.ts +263 -0
  424. package/src/kit/world-adoption-event.ts +23 -0
  425. package/src/kit/world-adoption.ts +115 -0
  426. package/src/kit/world-canvas-viewport-state.ts +35 -0
  427. package/src/kit/world-document-routing.ts +104 -0
  428. package/src/kit/world-pan-state.ts +198 -0
  429. package/src/kit/write-pipe.ts +173 -0
  430. package/src/layout-arrangements.ts +5 -0
  431. package/src/layouts.tsx +108 -0
  432. package/src/looks.ts +16 -0
  433. package/src/project/output-roots.ts +73 -0
  434. package/src/project/tab-census.ts +155 -0
  435. package/src/project-tool-catalog.ts +104 -0
  436. package/src/selection.tsx +107 -0
  437. package/src/services.ts +18 -0
  438. package/src/session/build-report.ts +22 -0
  439. package/src/session/collaboration-types.ts +262 -0
  440. package/src/session/command-table.ts +327 -0
  441. package/src/session/discovery.ts +100 -0
  442. package/src/session/editor-brand.ts +48 -0
  443. package/src/session/editor-compatibility.ts +329 -0
  444. package/src/session/editor-control-lifecycle.ts +68 -0
  445. package/src/session/editor-control-protocol.ts +5 -0
  446. package/src/session/entrypoint-selection-readers.ts +66 -0
  447. package/src/session/entrypoint-selection-source.ts +120 -0
  448. package/src/session/game-css-scope.ts +30 -0
  449. package/src/session/hosted-attachment.ts +225 -0
  450. package/src/session/limited-view.ts +82 -0
  451. package/src/session/product-create.ts +24 -0
  452. package/src/session/product-locator.ts +478 -0
  453. package/src/session/project-module-url.ts +242 -0
  454. package/src/session/project-serving.ts +164 -0
  455. package/src/session/project-upgrade.ts +669 -0
  456. package/src/session/registry-format.ts +210 -0
  457. package/src/session/relative-path-guard.ts +56 -0
  458. package/src/session/scoped-game-css.ts +461 -0
  459. package/src/session/source-glob.ts +15 -0
  460. package/src/session/tool-contribution-convention.ts +123 -0
  461. package/src/session/workbench-locator.ts +712 -0
  462. package/src/session.ts +41 -0
  463. package/src/share.ts +160 -0
  464. package/src/source-analysis.ts +28 -0
  465. package/src/source-authoring.ts +439 -0
  466. package/src/tools/errors.ts +91 -0
  467. package/src/tools/provider-execution.ts +70 -0
  468. package/src/tools/registry.ts +341 -0
  469. package/src/tools/types.ts +159 -0
  470. package/src/transport.ts +100 -0
  471. package/src/types.ts +1693 -0
  472. package/src/views.ts +164 -0
  473. package/src/widgets/design-system.ts +93 -0
  474. package/src/widgets/editor-appearance.ts +151 -0
  475. package/src/widgets/editor-material.ts +83 -0
  476. package/src/widgets/icon-set-registry.ts +105 -0
  477. package/src/widgets/index.ts +71 -0
  478. package/src/widgets/inspector-widgets/AlignmentGrid.tsx +182 -0
  479. package/src/widgets/inspector-widgets/AssetSlotPicker.tsx +123 -0
  480. package/src/widgets/inspector-widgets/BorderEditor.tsx +309 -0
  481. package/src/widgets/inspector-widgets/ColorPicker.tsx +549 -0
  482. package/src/widgets/inspector-widgets/CurveEditor.tsx +359 -0
  483. package/src/widgets/inspector-widgets/FilterEditor.tsx +108 -0
  484. package/src/widgets/inspector-widgets/FontPicker.tsx +191 -0
  485. package/src/widgets/inspector-widgets/GradientEditor.tsx +623 -0
  486. package/src/widgets/inspector-widgets/ScrubbableInput.tsx +180 -0
  487. package/src/widgets/inspector-widgets/ShadowEditor.tsx +319 -0
  488. package/src/widgets/inspector-widgets/color-utils.ts +201 -0
  489. package/src/widgets/inspector-widgets/curve-utils.ts +212 -0
  490. package/src/widgets/inspector-widgets/index.ts +25 -0
  491. package/src/widgets/inspector-widgets/shared.tsx +140 -0
  492. package/src/widgets/interactive-edit-scope.ts +33 -0
  493. package/src/widgets/patterns/Dialog.tsx +140 -0
  494. package/src/widgets/patterns/Fields.tsx +44 -0
  495. package/src/widgets/patterns/List.tsx +25 -0
  496. package/src/widgets/patterns/StateSurface.tsx +40 -0
  497. package/src/widgets/patterns/Surfaces.tsx +122 -0
  498. package/src/widgets/patterns/Tabs.tsx +80 -0
  499. package/src/widgets/patterns/Toolbar.tsx +72 -0
  500. package/src/widgets/patterns/Tree.tsx +72 -0
  501. package/src/widgets/primitives/AnchoredMenu.tsx +260 -0
  502. package/src/widgets/primitives/Button.tsx +62 -0
  503. package/src/widgets/primitives/ColorInput.tsx +78 -0
  504. package/src/widgets/primitives/DraftTextInput.tsx +63 -0
  505. package/src/widgets/primitives/EditorIcon.tsx +157 -0
  506. package/src/widgets/primitives/FormControls.tsx +88 -0
  507. package/src/widgets/primitives/HoverPreview.tsx +96 -0
  508. package/src/widgets/primitives/JsonInput.tsx +113 -0
  509. package/src/widgets/primitives/Layout.tsx +100 -0
  510. package/src/widgets/primitives/Menu.tsx +161 -0
  511. package/src/widgets/primitives/NumberInput.tsx +169 -0
  512. package/src/widgets/primitives/Panel.tsx +80 -0
  513. package/src/widgets/primitives/SectionHeader.tsx +77 -0
  514. package/src/widgets/primitives/Text.tsx +54 -0
  515. package/src/widgets/primitives/ThemeRootPortal.tsx +52 -0
  516. package/src/widgets/primitives/Tooltip.tsx +204 -0
  517. package/src/widgets/primitives/Vec3Input.tsx +70 -0
  518. package/src/widgets/primitives/banner-tones.ts +32 -0
  519. package/src/widgets/primitives/clamp-to-viewport.ts +44 -0
  520. package/src/widgets/primitives/editor-icons.ts +254 -0
  521. package/src/widgets/primitives/panel-header-styles.ts +42 -0
  522. package/src/widgets/theme.ts +2841 -0
  523. package/src/widgets/z-index.ts +25 -0
@@ -0,0 +1,2841 @@
1
+ /**
2
+ * Canonical design-token module for the Volter editor (editor-style-polish U0).
3
+ * Every color/spacing/radius/type/shadow/z-index value the editor's ~98 React
4
+ * components currently hand-roll as inline hex literals gets one home here. This module installs the variables consumed by `theme.css` and
5
+ * supplies plain TS/TSX call sites that still build inline `style`
6
+ * objects (e.g. `inspector-widgets/shared.tsx`, `primitives/Panel.tsx`) — so
7
+ * it must stay import-safe from any context: no DOM access, no side effects,
8
+ * just typed `const` data.
9
+ *
10
+ * The original Classic Graphite palette remains byte-for-byte compatible;
11
+ * the neutral Glass default is a separate palette rather than a mutation of
12
+ * Classic's historical action/focus color.
13
+ * selection material is derived separately so glass never turns every
14
+ * selected row, tab, and segment blue. The mono stack is the
15
+ * `ui-monospace` spelling with
16
+ * 2 independent votes, not `FONT_MONO`'s 1; the sans stack drops `'Inter'`
17
+ * (never loaded — zero `@font-face` hits repo-wide) in favor of the
18
+ * `-apple-system` chain already live in root `index.html`.
19
+ */
20
+
21
+ import { GLASS_MATERIAL } from './editor-material';
22
+ import { zIndex } from './z-index';
23
+
24
+ const GRAPHITE_BG = {
25
+ 0: '#1a1a1a',
26
+ 1: '#242424',
27
+ 2: '#2c2c2c',
28
+ 3: '#333333',
29
+ inset: '#1e1e1e',
30
+ } as const;
31
+ const GRAPHITE_BORDER = { 1: '#333333', 2: '#444444' } as const;
32
+ // Compatibility contract: these are the original Classic values.
33
+ const GRAPHITE_TEXT = { 1: '#c5c8ce', 2: '#9aa0a6', 3: '#90959c' } as const;
34
+ const GRAPHITE_ACCENT = '#579EFF';
35
+ const GRAPHITE_ACCENT_MUTED = 'rgba(87,158,255,0.15)';
36
+ // Glass defaults to this separate palette. A material never introduces hue.
37
+ const GRAPHITE_NEUTRAL_TEXT = { 1: '#f2f6fb', 2: '#d3d9e0', 3: '#aab0b7' } as const;
38
+ const GRAPHITE_NEUTRAL_ACCENT = '#d8dee9';
39
+ const GRAPHITE_NEUTRAL_ACCENT_MUTED = 'rgba(216,222,233,0.18)';
40
+ const GRAPHITE_DANGER = '#FF6B6B';
41
+ const GRAPHITE_WARN = '#e0a030';
42
+ const GRAPHITE_SUCCESS = '#4caf50';
43
+ const GRAPHITE_SUCCESS_MUTED = 'rgba(76,175,80,0.15)';
44
+ const GRAPHITE_DANGER_MUTED = 'rgba(255,107,107,0.15)';
45
+ const GRAPHITE_WARN_MUTED = 'rgba(224,160,48,0.12)';
46
+ const GRAPHITE_DANGER_FAINT = 'rgba(255,107,107,0.06)';
47
+ const GRAPHITE_SCRIM = 'rgba(0,0,0,0.5)';
48
+ const GRAPHITE_DYNAMIC = '#ff79c6';
49
+ const GRAPHITE_DYNAMIC_BG = 'rgba(255,121,198,0.08)';
50
+ // Component-instance identity (H1) — our translation of Unity's blue-prefab
51
+ // label. Cyan, NOT the accent role: `accent` means "action/focus" and
52
+ // several palettes deliberately keep it neutral (`GRAPHITE_NEUTRAL_ACCENT`
53
+ // is near-white), which made an instance row indistinguishable from a plain
54
+ // one. Cyan is also the widest free hue gap in this palette — clear of
55
+ // danger (red), warning (amber), success (green ~122°), dynamic (pink
56
+ // ~326°) and the graphite accent (blue ~215°). Depth is tuned so the tint
57
+ // also survives a BRIGHT backdrop under Glass (2.36:1 on white — the same
58
+ // band as dynamic/warning) while still reading at 6.65:1 on the opaque
59
+ // graphite row.
60
+ const GRAPHITE_INSTANCE = '#22b8d6';
61
+ const GRAPHITE_FONT_SANS = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
62
+ const GRAPHITE_FONT_MONO = 'ui-monospace, "SF Mono", Monaco, "Cascadia Code", monospace';
63
+ const GRAPHITE_SHADOW = {
64
+ sm: '0 2px 8px rgba(0,0,0,0.6)',
65
+ md: '0 4px 12px rgba(0,0,0,0.5)',
66
+ lg: '0 8px 32px rgba(0,0,0,0.6)',
67
+ } as const;
68
+
69
+ /**
70
+ * Compatibility references used by existing DOM call sites. They resolve at
71
+ * paint time, so the whole existing editor participates in runtime themes.
72
+ * New code should prefer the semantic {@link themeVars} vocabulary.
73
+ */
74
+ export const bg = {
75
+ /** App shell / loading screen (`AppRoot.tsx`, root `index.html` body). */
76
+ 0: 'var(--volter-surface-shell)',
77
+ /** Panel body (`Panel.tsx`'s content area, THEME.bg). */
78
+ 1: 'var(--volter-surface-panel)',
79
+ /** Surface: header / menu / dropdown / popover (THEME.surface). */
80
+ 2: 'var(--volter-surface-chrome)',
81
+ /** Raised / hover surface (THEME.surfaceHover). */
82
+ 3: 'var(--volter-surface-raised)',
83
+ /** Input well / recessed field background (THEME.inputBg). */
84
+ inset: 'var(--volter-surface-inset)',
85
+ } as const;
86
+
87
+ /** Border ramp — same literal as `bg[3]` for `border[1]` by design (distinguished by CSS property, not value). */
88
+ export const border = {
89
+ /** Default border. */
90
+ 1: 'var(--volter-boundary-default)',
91
+ /** Hover / emphasis border. */
92
+ 2: 'var(--volter-boundary-strong)',
93
+ } as const;
94
+
95
+ /** Text ramp — primary → tertiary/dim. */
96
+ export const text = {
97
+ /** Primary text (THEME.text). */
98
+ 1: 'var(--volter-content-primary)',
99
+ /** Secondary / muted text (THEME.textMuted). */
100
+ 2: 'var(--volter-content-muted)',
101
+ /** Tertiary / dim text. */
102
+ 3: 'var(--volter-content-dim)',
103
+ } as const;
104
+
105
+ /** Canonical action/focus accent. Selection chrome uses `selection` below. */
106
+ export const accent = 'var(--volter-accent)';
107
+ /** Semantic tint behind accent-colored actions, status, and information. */
108
+ export const accentMuted = 'var(--volter-accent-muted)';
109
+ /** Material selection roles: accent in Classic, adaptive neutral in Glass. */
110
+ export const selection = {
111
+ background: 'var(--volter-selection-bg)',
112
+ border: 'var(--volter-selection-border)',
113
+ indicator: 'var(--volter-selection-indicator)',
114
+ } as const;
115
+
116
+ /** Semantic tones. */
117
+ export const danger = 'var(--volter-danger)';
118
+ export const warn = 'var(--volter-warn)';
119
+ export const success = 'var(--volter-success)';
120
+
121
+ /**
122
+ * Semantic background tints (U6, punch-list #1) — one family alpha (0.15,
123
+ * matching `accentMuted`) for chip/band/state backgrounds behind
124
+ * semantic-colored content. Converges the drifted hand-rolled alphas
125
+ * (0.12/0.15) that U3-U5 left behind in EnvironmentSection (stale-bake),
126
+ * ConsolePanel (entity chip), AudioSection
127
+ * (stop state), and the unsaved-document treatment.
128
+ */
129
+ export const successMuted = 'var(--volter-success-muted)';
130
+ export const dangerMuted = 'var(--volter-danger-muted)';
131
+ /** Resting warning surface used for unsaved/attention states. */
132
+ export const warnMuted = 'var(--volter-warn-muted)';
133
+ /**
134
+ * Deliberately fainter danger tint for RESTING full-row backgrounds
135
+ * (ConsolePanel error rows): a run of consecutive error
136
+ * rows must read as a list, not a solid red wall, so rows sit at 0.06 while
137
+ * point-emphasis chips/bands use `dangerMuted`. Two files independently
138
+ * converged on this exact value — tokenized so they can't drift apart.
139
+ */
140
+ export const dangerFaint = 'var(--volter-danger-faint)';
141
+
142
+ /**
143
+ * The one modal/backdrop dimming scrim. The canonical Dialog pattern and
144
+ * non-dialog modal surfaces share this semantic value.
145
+ */
146
+ export const scrim = 'var(--volter-scrim)';
147
+
148
+ /** Kit-specific "reactive/bound value" indicator (inspector-widgets only — not otherwise contested). */
149
+ export const dynamic = 'var(--volter-dynamic)';
150
+ export const dynamicBg = 'var(--volter-dynamic-muted)';
151
+
152
+ /** 4/8-based spacing scale — compose padding/margin/gap from these instead of ad hoc shorthand strings. */
153
+ export const space = {
154
+ 1: 2,
155
+ 2: 4,
156
+ 3: 6,
157
+ 4: 8,
158
+ 5: 10,
159
+ 6: 12,
160
+ 8: 16,
161
+ 10: 20,
162
+ 12: 24,
163
+ } as const;
164
+
165
+ /**
166
+ * Semantic editor-chrome dimensions. Different roles intentionally use
167
+ * different heights, but every instance of a role must consume the same
168
+ * token instead of retyping a nearby number.
169
+ */
170
+ export const chromeSize = {
171
+ commandBar: 36,
172
+ panelHeader: 30,
173
+ localToolbar: 28,
174
+ treeRow: 24,
175
+ // How far one level of a tree steps right. The editor's own is 14, which is
176
+ // what `GameHierarchy`'s `INDENT` always was. Blender's Outliner steps by
177
+ // its ROW HEIGHT — a square grid, the shape every DCC tree uses: measured on
178
+ // `outliner.png` (2x), the type glyphs of Scene Collection, Collection and
179
+ // Camera centre at x 30.25, 50.25 and 70.25 CSS and their chevrons one cell
180
+ // left of each, so the step is exactly 20.
181
+ treeIndent: 14,
182
+ // 22 was always too cramped for the bottom row (owner design review,
183
+ // 2026-07-19 — "the wrong height even during the regular page"; 28 was
184
+ // still too tight on second look). 36 mirrors the command bar, so the
185
+ // top and bottom chrome rows are symmetric.
186
+ statusBar: 36,
187
+ // The tool rail (Blender's toolbar): a tool's box, its width and the gap
188
+ // between groups — the EDITOR'S OWN, which is what Classic paints. These
189
+ // are not Blender's (a comment here used to claim they were): the Blender
190
+ // style declares its own `chrome.toolSize/toolWidth/toolGap`
191
+ // (`blender.style.ts`), those are the measured ones, and they are what the
192
+ // rail actually renders at under that look. Changing a number here moves
193
+ // Classic and nothing else.
194
+ toolSize: 34,
195
+ toolWidth: 38,
196
+ toolGap: 7,
197
+ // How wide the seam between two docked AREAS is cut. The editor's own is
198
+ // the resting stroke (spelled out rather than read from `strokeWidth`,
199
+ // which is declared below this object), so a skin that declares nothing
200
+ // keeps today's hairline; Blender's groove is 3.5 — see
201
+ // `color.boundary.area` for the frame and coordinates it was read at.
202
+ areaSeam: 1,
203
+ // The EMBOSS beside that groove, in percent of white mixed into the area's
204
+ // own fill. Zero paints nothing at all (`areaEmbossValue` returns
205
+ // `transparent`), which is the editor's own look and every skin but
206
+ // Blender's; Blender's is 8.2. See `areaEmbossValue`.
207
+ areaEmboss: 0,
208
+ } as const;
209
+
210
+ /** What a material may retune of the chrome's density, all in px. */
211
+ export interface EditorDensity {
212
+ readonly control?: Partial<Record<keyof typeof controlSize, number>>;
213
+ readonly font?: Partial<Record<keyof typeof fontSize, number>>;
214
+ /** Glyph sizes, independent of the type scale ({@link iconSize}). */
215
+ readonly icon?: Partial<Record<keyof typeof iconSize, number>>;
216
+ readonly chrome?: Partial<Record<keyof typeof chromeSize, number>>;
217
+ }
218
+
219
+ /** WHAT THE LOOK STATES ABOUT THE 3D STAGE, as opposed to about the chrome (the material's
220
+ * `stage`, `@volter/sdk/looks`' `StageContribution` for every member's derivation).
221
+ * Every member is independently optional and there is no default table: an absent member
222
+ * emits an EMPTY token and its reader keeps the editor's own behaviour. */
223
+ export interface EditorStage {
224
+ readonly gizmoSize?: number;
225
+ readonly gizmoArrowLength?: number;
226
+ readonly gizmoArrowHead?: number;
227
+ readonly gizmoRingWidth?: number;
228
+ readonly navigationGizmo?: 'balls' | 'godot' | 'cones' | 'triad';
229
+ readonly navigationCorner?: 'top-right' | 'bottom-left';
230
+ readonly navigationSize?: number;
231
+ readonly gizmoOpacity?: number;
232
+ readonly gizmoHighlightSaturation?: number;
233
+ readonly gizmoHighlightValue?: number;
234
+ readonly gridLineWidth?: number;
235
+ readonly gridMajorWidth?: number;
236
+ readonly gridMajorContrast?: number;
237
+ readonly axisLineWidth?: number;
238
+ readonly selectionBox?: 'corners' | 'edges';
239
+ readonly selectionBoxFrame?: 'world' | 'object';
240
+ readonly outlineStyle?: 'soft' | 'crisp';
241
+ readonly outlineWidth?: number;
242
+ readonly outlineHidden?: boolean;
243
+ readonly wireOpacity?: number;
244
+ readonly selectionBoxWidth?: number;
245
+ /** `StageContribution.marqueeDash`: the box-select outline's dash length in device px. */
246
+ readonly marqueeDash?: number;
247
+ /** `StageContribution.chrome`: which overlay controls the stage carries and where. */
248
+ readonly chrome?: {
249
+ readonly bar?: 'strip' | 'pills';
250
+ readonly viewName?: 'text' | 'menu' | 'gizmo' | 'bar';
251
+ readonly tools?: 'shelf' | 'bar-start' | 'bar-end';
252
+ readonly display?: 'corner' | 'bar-start' | 'bar-end';
253
+ readonly transformControls?: 'header' | 'bar';
254
+ };
255
+ /** `StageContribution.words`: the stage's controls in the target's own words. */
256
+ readonly words?: {
257
+ readonly shading?: Readonly<Partial<Record<string, string>>>;
258
+ readonly helpers?: string;
259
+ };
260
+ }
261
+ /** The shading modes a look may name (`StageContribution.words.shading`). */
262
+ export const STAGE_WORD_MODES = [
263
+ 'solid',
264
+ 'clay',
265
+ 'unlit',
266
+ 'wireframe',
267
+ 'matcap',
268
+ 'normals',
269
+ 'overdraw',
270
+ 'preview',
271
+ 'rendered',
272
+ ] as const;
273
+ function numberToken(value: number | undefined): string {
274
+ return value === undefined ? '' : `${value}`;
275
+ }
276
+ function density(theme: Pick<EditorTheme, 'density'>) {
277
+ return {
278
+ control: { ...controlSize, ...theme.density?.control },
279
+ font: { ...fontSize, ...theme.density?.font },
280
+ icon: { ...iconSize, ...theme.density?.icon },
281
+ chrome: { ...chromeSize, ...theme.density?.chrome },
282
+ };
283
+ }
284
+
285
+ /** Reusable interactive-control heights. */
286
+ export const controlSize = {
287
+ compact: 20,
288
+ default: 24,
289
+ comfortable: 28,
290
+ } as const;
291
+
292
+ /** Resting boundaries stay quiet; interaction affordances may strengthen. */
293
+ export const strokeWidth = {
294
+ resting: 1,
295
+ active: 2,
296
+ } as const;
297
+
298
+ /** Named type scale (px). Covers the overwhelming majority of real fontSize call sites. */
299
+ /**
300
+ * ICON SIZE — its OWN axis, not a rung of the type scale.
301
+ *
302
+ * Every glyph used to take its size from `fontSize`, so a skin that tightened
303
+ * its text tightened its icons with it: under Blender's density the chrome's
304
+ * glyphs rendered at 10–11 px where Blender draws 14 (measured against
305
+ * `properties-data-edit.png` and `outliner.png` at matched scale), which is
306
+ * what "the sizes are totally wrong" names. Blender sizes icons
307
+ * independently of text and so does every application whose icons are a
308
+ * pictorial language rather than a typographic one.
309
+ *
310
+ * The defaults below are the type scale's values, so a skin that declares no
311
+ * `density.icon` looks exactly as it did.
312
+ */
313
+ export const iconSize = {
314
+ xs: 9,
315
+ sm: 10,
316
+ md: 12,
317
+ lg: 13,
318
+ xl: 14,
319
+ '2xl': 16,
320
+ } as const;
321
+
322
+ export const fontSize = {
323
+ xs: 9,
324
+ sm: 10,
325
+ base: 11,
326
+ md: 12,
327
+ lg: 13,
328
+ xl: 14,
329
+ '2xl': 16,
330
+ heading: 24,
331
+ } as const;
332
+
333
+ export const fontWeight = {
334
+ regular: 400,
335
+ semibold: 600,
336
+ bold: 700,
337
+ } as const;
338
+
339
+ /**
340
+ * Line-height scale (unitless, so it scales with each element's own size).
341
+ * Minted from the values the estate already used by hand: 1 for single-line
342
+ * chrome, 1.2 for headings and labels, 1.4 for control copy, 1.5 for prose.
343
+ */
344
+ export const lineHeight = {
345
+ tight: 1,
346
+ snug: 1.2,
347
+ normal: 1.4,
348
+ relaxed: 1.5,
349
+ } as const;
350
+
351
+ /**
352
+ * Motion scale. Durations in ms; one standard easing. Minted from the values
353
+ * the estate already used by hand (140/180/300ms and one cubic-bezier).
354
+ * `prefers-reduced-motion` handling stays with each animation's own rule.
355
+ */
356
+ export const motion = {
357
+ duration: { fast: 140, base: 180, slow: 300 },
358
+ easing: {
359
+ standard: 'cubic-bezier(0.32, 0.72, 0.28, 1)',
360
+ out: 'ease-out',
361
+ },
362
+ } as const;
363
+
364
+ /**
365
+ * Sans stack — the fallback chain already live everywhere else (originally
366
+ * declared in root `index.html`). `'Inter'` is intentionally NOT included:
367
+ * no `@font-face`/`<link>` for it exists anywhere in `packages/editor`, so it
368
+ * only ever silently fell back to this same chain.
369
+ */
370
+ export const fontSans = 'var(--volter-font-sans)';
371
+
372
+ /**
373
+ * Canonical mono stack — replaces `FONT_MONO`/`MONO`/18 bare `'monospace'`
374
+ * sites. This spelling had 2 independent votes in the codebase vs. 1 each
375
+ * for the others.
376
+ */
377
+ export const fontMono = 'var(--volter-font-mono)';
378
+
379
+ const DEFAULT_RADIUS = {
380
+ small: '3px',
381
+ medium: '6px',
382
+ large: '8px',
383
+ full: '9999px',
384
+ } as const;
385
+
386
+ /**
387
+ * Runtime border-radius references. Like the color compatibility exports,
388
+ * these resolve at paint time so existing product surfaces participate in a
389
+ * custom theme instead of freezing the Graphite defaults into inline styles.
390
+ */
391
+ export const radius = {
392
+ sm: 'var(--volter-radius-sm)',
393
+ md: 'var(--volter-radius-md)',
394
+ lg: 'var(--volter-radius-lg)',
395
+ full: 'var(--volter-radius-full)',
396
+ } as const;
397
+
398
+ /** Drop-shadow tiers. */
399
+ export const shadow = {
400
+ /** Tooltip tier. */
401
+ sm: 'var(--volter-shadow-sm)',
402
+ /** Dropdown / menu tier — already the dominant value pre-token. */
403
+ md: 'var(--volter-shadow-md)',
404
+ /** Modal tier. */
405
+ lg: 'var(--volter-shadow-lg)',
406
+ } as const;
407
+
408
+ /**
409
+ * Typed `var()` handles for the numeric scales, for components that style
410
+ * inline (`style={{ fontSize: fontSizeVar.sm }}`). An inline literal
411
+ * (`fontSize: 11`) sits above every stylesheet in the cascade and is the one
412
+ * place a theme cannot reach; these keep inline styles on the token.
413
+ */
414
+ export const fontSizeVar = {
415
+ xs: 'var(--volter-font-xs)',
416
+ sm: 'var(--volter-font-sm)',
417
+ base: 'var(--volter-font-base)',
418
+ md: 'var(--volter-font-md)',
419
+ lg: 'var(--volter-font-lg)',
420
+ xl: 'var(--volter-font-xl)',
421
+ '2xl': 'var(--volter-font-2xl)',
422
+ heading: 'var(--volter-font-heading)',
423
+ } as const;
424
+
425
+ export const spaceVar = {
426
+ 1: 'var(--volter-space-1)',
427
+ 2: 'var(--volter-space-2)',
428
+ 3: 'var(--volter-space-3)',
429
+ 4: 'var(--volter-space-4)',
430
+ 5: 'var(--volter-space-5)',
431
+ 6: 'var(--volter-space-6)',
432
+ 8: 'var(--volter-space-8)',
433
+ 10: 'var(--volter-space-10)',
434
+ 12: 'var(--volter-space-12)',
435
+ } as const;
436
+
437
+ export const lineHeightVar = {
438
+ tight: 'var(--volter-leading-tight)',
439
+ snug: 'var(--volter-leading-snug)',
440
+ normal: 'var(--volter-leading-normal)',
441
+ relaxed: 'var(--volter-leading-relaxed)',
442
+ } as const;
443
+
444
+ export const motionVar = {
445
+ duration: {
446
+ fast: 'var(--volter-duration-fast)',
447
+ base: 'var(--volter-duration-base)',
448
+ slow: 'var(--volter-duration-slow)',
449
+ },
450
+ easing: {
451
+ standard: 'var(--volter-ease-standard)',
452
+ out: 'var(--volter-ease-out)',
453
+ },
454
+ } as const;
455
+
456
+ /**
457
+ * `<active stroke>px solid <accent>` — apply via a global `:focus-visible` rule for
458
+ * `className`-based controls (see `theme.css`), and via a shared
459
+ * `onFocus`/`onBlur` handler for inline-`style` components that can't
460
+ * express the pseudo-class directly (out of scope for U0 — later units).
461
+ */
462
+ export const focusRing = 'var(--volter-focus-ring)';
463
+
464
+ /**
465
+ * The EDITOR AREAS a palette may paint separately (`EditorTheme.color.region`,
466
+ * whose docblock carries the measurement). The vocabulary is deliberately tiny
467
+ * and named after the reference's own editors, not after our panels: a name
468
+ * earns its place when a frame shows that area painted differently from
469
+ * `surface.panel`. `workspace-static-panels.ts` is where a panel claims one.
470
+ */
471
+ export const EDITOR_REGION_NAMES = ['outliner', 'properties'] as const;
472
+ export type EditorRegionName = (typeof EDITOR_REGION_NAMES)[number];
473
+
474
+ /** The glyph inks a palette may name (`EditorTheme.color.category`). */
475
+ export type EditorCategoryName = keyof NonNullable<EditorTheme['color']['category']>;
476
+ /**
477
+ * Every member of `EditorTheme.color.category`, for code that walks the group. Spelled as a
478
+ * record so the compiler refuses a list that misses a member: a palette arriving as a document
479
+ * is rebuilt member by member, and a name left off the walk is dropped without a sound.
480
+ */
481
+ export const EDITOR_CATEGORY_NAMES = Object.keys({
482
+ object: true,
483
+ modifier: true,
484
+ material: true,
485
+ tool: true,
486
+ operator: true,
487
+ data: true,
488
+ scene: true,
489
+ collection: true,
490
+ select: true,
491
+ } satisfies Record<EditorCategoryName, true>) as readonly EditorCategoryName[];
492
+
493
+ /**
494
+ * Runtime editor-theme contract. The compatibility exports above are CSS
495
+ * references so existing DOM chrome themes at paint time; concrete defaults
496
+ * live in {@link graphiteDarkEditorTheme}. New DOM code should consume
497
+ * {@link themeVars}' semantic names.
498
+ */
499
+ export interface EditorTheme {
500
+ readonly id: string;
501
+ readonly color: {
502
+ readonly surface: {
503
+ readonly shell: string;
504
+ readonly panel: string;
505
+ readonly chrome: string;
506
+ readonly raised: string;
507
+ readonly inset: string;
508
+ /** Translucent editor HUD chrome drawn over a game/scene viewport. */
509
+ readonly overlay: string;
510
+ };
511
+ readonly boundary: {
512
+ readonly default: string;
513
+ readonly strong: string;
514
+ /**
515
+ * THE AREA SEAM — the line between two docked AREAS, which is a
516
+ * different subject from `default`, the outline around a WIDGET.
517
+ * Optional, like `widget` and `category`: a palette that names none
518
+ * paints every seam `boundary.default`, exactly as before this member
519
+ * existed, so no other skin moves by a pixel.
520
+ *
521
+ * Blender 5.2 factory startup, measured on the native 2x reference frames
522
+ * (halved here):
523
+ *
524
+ * - `modeling.png`, the seam between the Outliner and the Properties
525
+ * editor, median over x 2900..3400: rows 419..425 are (21,21,21) —
526
+ * 3.5 CSS px of #151515 — with a one-pixel lighter emboss on each
527
+ * side (417..418 = 59/60, 426..427 = 54/64).
528
+ * - the same frame's viewport↔Properties seam, median over rows
529
+ * 600..1900: x 2836..2842 is the identical (21,21,21) run, same
530
+ * 3.5 px.
531
+ *
532
+ * So the seam is DARKER than either neighbour, where our
533
+ * `boundary.default` #3c3c3c is lighter than both — that inversion is
534
+ * what made our window read as tiles with bright grout.
535
+ *
536
+ * The two SHELL seams measure narrower (topbar↔header y 48..52 = 2.5
537
+ * px, viewport↔status bar y 2057..2060 = 2 px) for a reason that is
538
+ * not a second width: the top and status bars are #171717 themselves,
539
+ * two levels off the groove, so there is nothing there to see. They
540
+ * take the colour and keep their hairline width; only an interior
541
+ * area↔area seam is cut to `chrome.areaSeam` px.
542
+ */
543
+ readonly area?: string;
544
+ /**
545
+ * THE TREE'S INDENT GUIDE — the vertical rule an OPEN parent draws down
546
+ * its own disclosure column, through the rows of its subtree. Optional,
547
+ * per member, exactly like `area`, `widget` and `category`: a palette
548
+ * that names none paints NO GUIDE AT ALL, which is what every skin but
549
+ * Blender's did before this member existed, so nothing else moves.
550
+ *
551
+ * Measured on `modeling-object-none.png` and
552
+ * `modeling-object-selected.png` (Blender 5.2, native 2x, OBJECT mode;
553
+ * the Outliner is `modeling.png[y 53..419, x 2843..3452]`, which is
554
+ * `outliner.png`'s own box, so the coordinates below are that crop's):
555
+ *
556
+ * - the rule is ONE device pixel at x 59 — 0.5 CSS px, at CSS 29.5..30.0
557
+ * against the Collection row's chevron centre of 30.25;
558
+ * - it runs y 143..242 CONTINUOUSLY, i.e. from 5 CSS px into the first
559
+ * child's row to 5 CSS px above the last child's row bottom (rows are
560
+ * 40 device px: Camera 133..172, Cube 173..212, Light 213..252);
561
+ * - every one of those 100 pixels is exactly (101,101,101) — over the
562
+ * panel's #272727/#2a2a2a stripe, over the #1c304c selected bar AND
563
+ * over the #324c7f active bar — so the ink is OPAQUE #656565 and is
564
+ * painted OVER the row's fill, never mixed into it.
565
+ *
566
+ * Where the evidence stops: the frame nests exactly ONE level
567
+ * (Scene Collection → Collection → three objects), and the root row
568
+ * carries no chevron and no guide beneath it — measured, x 0..48 is
569
+ * clear for the whole Collection row. What a row THREE levels deep
570
+ * carries is therefore not photographed; `theme.css`'s
571
+ * `.volter-tree-indent-guide` records the reading taken and why.
572
+ */
573
+ readonly indent?: string;
574
+ /**
575
+ * A DIVIDER'S RULE — the line a chrome bar draws between two GROUPS of
576
+ * its own controls, as against `default`, which outlines a widget.
577
+ * Blender keeps them apart and we had one value for both.
578
+ *
579
+ * Optional, transcribing {@link indent}'s shape rather than inventing
580
+ * one: a palette naming none is bit-identical to before this member
581
+ * existed. The fallback differs only because the situations do — no
582
+ * skin ever drew an indent guide, so absent there means `transparent`
583
+ * and no guide; every skin draws this rule, so absent here means
584
+ * `boundary.default`, which is what it drew.
585
+ *
586
+ * MEASURED, `topbar.png` at native 2x: the rule closing the menu words
587
+ * before the workspace tabs is at x 488..489, y 6..45 — 2 device px
588
+ * wide, 40 tall, a FLAT #2e2e2e (46) with no AA ramp on either side,
589
+ * between the bar's own 23 and the tab strip's 28. Ours painted
590
+ * `boundary.default` #3c3c3c (60), lighter than the strip it borders.
591
+ *
592
+ * ONE SITE, and the scope is the measurement's: `.volter-project-menu-rule`
593
+ * is the only divider in either frame I measured. The token is read
594
+ * there and nowhere else; widening it to every `.volter-divider` is a
595
+ * later unit's, with its own frames.
596
+ */
597
+ readonly divider?: string;
598
+ };
599
+ readonly content: {
600
+ readonly primary: string;
601
+ readonly muted: string;
602
+ readonly dim: string;
603
+ readonly onAccent: string;
604
+ /**
605
+ * A MENU WORD — the ink of an unenclosed clickable word in a menu bar or
606
+ * an area header. Its own role because a bar of words is neither a
607
+ * widget's content nor a tree row's name: it is the loudest thing a
608
+ * chrome band says, and Blender inks it one step under its panel text.
609
+ *
610
+ * Optional, and falls back to {@link primary} in the emitter, which is
611
+ * what the top bar already paints (`ProjectHeader.css`, PR #7317) — a
612
+ * palette naming no menu ink keeps that bar bit-identical.
613
+ *
614
+ * MEASURED, Blender 5.2 at native 2x, and the reason this is ONE member
615
+ * and not two: the same #d8d8d8 (216) inks `File`/`Edit`/`Render` in the
616
+ * top bar over its #171717 (23) band (`topbar.png`, eroded stroke
617
+ * interiors peak at 216) AND `View`/`Select`/`Add`/`Mesh`/`Vertex`/
618
+ * `Edge`/`Face`/`UV` in the 3D View's area header over its #343434 (52)
619
+ * band (`modeling-edit-none.png`, x 420..1100, y 56..104 — the histogram
620
+ * there tops out at exactly 216 with nothing above it). Two backdrops
621
+ * 29 levels apart reading one value is what says the ink is OPAQUE
622
+ * rather than an alpha at the site.
623
+ *
624
+ * IT REMOVES A SPLIT. Ours drew the two sites at two different members —
625
+ * 229 `content.primary` in the top bar and 194 `content.muted` in the
626
+ * area header (measured live before this member existed) — because the
627
+ * area header's words inherit `.volter-menu-trigger`'s base ink and only
628
+ * the top bar carried an override. One role, one member, one rule.
629
+ *
630
+ * WHERE THE EVIDENCE STOPS: no reference frame photographs a HOVERED or
631
+ * a DISABLED menu word, so the hover state keeps reading `content.primary`
632
+ * as it did.
633
+ */
634
+ readonly menu?: string;
635
+ /**
636
+ * THE STATUS BAND'S OWN INK — the quietest text in the window, and its
637
+ * own role because the band is one voice: every glyph, rule and digit in
638
+ * it reads at one level regardless of what put them there.
639
+ *
640
+ * Optional, falling back to {@link dim} in the emitter, which is what
641
+ * `.volter-editor-bottom-bar` already inherits — a palette naming none
642
+ * paints exactly what it painted before.
643
+ *
644
+ * MEASURED, Blender 5.2 at native 2x: the whole status band of
645
+ * `modeling-edit-none.png` (the bottom 46 device px, full width)
646
+ * histograms to a ceiling of #878787 (135) — 135 and its AA neighbour
647
+ * 134 and nothing above either, against the 216 its menu words ink and
648
+ * the 229 its panels do. `EditorBottomBar.css` recorded the 15 levels
649
+ * between it and `content.dim` as residue rather than hardcode the hex;
650
+ * this is the member that closes it.
651
+ */
652
+ readonly status?: string;
653
+ /**
654
+ * A FIELD'S PLACEHOLDER. Its own role because nothing else in the editor
655
+ * is text that is not content — and because until this member existed a
656
+ * BROWSER DEFAULT decided an ink in our product: no `::placeholder` rule
657
+ * existed anywhere in the editor's CSS.
658
+ *
659
+ * MEASURED LIVE, and it corrects the reading that named this gap: Chrome
660
+ * resolves its UA `::placeholder` to a FIXED rgb(117,117,117) here — the
661
+ * same value for a field inked 229, 100, rgb(200,0,0) or black, four
662
+ * samples in one page — so it is not composited from the field's own ink
663
+ * at all. A fixed browser constant is exactly the thing a palette cannot
664
+ * re-skin, which is the defect.
665
+ *
666
+ * MEASURED, Blender 5.2 at native 2x: the Outliner's `Search`
667
+ * placeholder in `modeling-object-none.png` (x 3100..3260, y 60..96)
668
+ * peaks at #5e5e5e (94) and stops there — below its own `content.dim`,
669
+ * which is what a placeholder should be.
670
+ *
671
+ * Optional, falling back to {@link dim} — the palette's own quiet ink
672
+ * and the only measured member on the right side of the field's text. A
673
+ * palette naming none therefore moves off Chrome's constant; that is the
674
+ * point, and it is stated rather than hidden, because reproducing a UA
675
+ * constant in our own stylesheet would nail one browser's opinion into
676
+ * the product under a different name.
677
+ */
678
+ readonly placeholder?: string;
679
+ /**
680
+ * THE ACTIVE SUBJECT'S OWN INK — the name of the one thing edits target,
681
+ * where a palette wants that said in COLOUR rather than in fill.
682
+ *
683
+ * Optional, and emitted EMPTY when a palette names none (the `viewport`
684
+ * group's answer, not `category`'s): the one reader is the hierarchy
685
+ * row's label, which falls back to inheriting the row's ink, so a
686
+ * palette without it paints exactly what it painted before.
687
+ *
688
+ * Blender is why it exists. Measured on `outliner.png` (native 2x): the
689
+ * Cube — the active object — inks its NAME at #ffae28, an 82-pixel
690
+ * plateau, while Camera and Light ink at the row's ordinary #c2c2c2.
691
+ *
692
+ * CORRECTED 2026-09-18, and the correction matters because this note was
693
+ * the evidence behind "Blender marks the active row with the name and
694
+ * nothing else": it used to add that the Cube's row FILL is the plain 42
695
+ * stripe. That is true of `outliner.png` and says nothing about the state,
696
+ * because `outliner.png` is cut from `modeling.png`, whose Outliner is
697
+ * BYTE-IDENTICAL to `modeling-edit-none.png` — the Modeling workspace
698
+ * opens in EDIT mode, where no object-mode selection exists to paint. In
699
+ * `modeling-object-selected.png` the same Cube row carries a #324c7f band
700
+ * inside a #5a74a7 hairline. The name is one of three marks, not the only
701
+ * one; the other two are on `--volter-tree-row-selected-bg` below.
702
+ *
703
+ * It is NOT `viewport.active`, and that is Blender's own distinction
704
+ * rather than ours: its 3D View paints the active object's outline
705
+ * #ffa028 where its Outliner inks the name #ffae28 — two theme entries,
706
+ * fourteen levels of green apart, and this palette already carries the
707
+ * first.
708
+ */
709
+ readonly active?: string;
710
+ /**
711
+ * THE SELECTED-BUT-NOT-ACTIVE SUBJECT'S INK — {@link active}'s other
712
+ * half, for a palette whose reference distinguishes the two.
713
+ *
714
+ * Optional and emitted EMPTY the same way, and its one reader (the
715
+ * hierarchy row's label) falls back to {@link active}, so a palette that
716
+ * names only an active ink paints every selected row exactly what it
717
+ * painted before.
718
+ *
719
+ * Measured on `modeling-object-selected.png` (object mode, every object
720
+ * selected): Camera and Light — selected, not active — ink their names
721
+ * #e86900 as solid plateaus, while the Cube inks #ffae28. It is not any
722
+ * scaling or mix of the active ink (232/255, 105/174 and 0/40 are three
723
+ * different ratios), which is why it is a second entry rather than a
724
+ * derivation.
725
+ */
726
+ readonly selected?: string;
727
+ /** Palette-owned ink ramp used only when Glass measures a bright backdrop. */
728
+ readonly onBright: {
729
+ readonly primary: string;
730
+ readonly muted: string;
731
+ readonly dim: string;
732
+ };
733
+ };
734
+ readonly accent: { readonly default: string; readonly muted: string };
735
+ readonly semantic: {
736
+ readonly danger: string;
737
+ readonly dangerMuted: string;
738
+ readonly dangerFaint: string;
739
+ readonly warning: string;
740
+ readonly warningMuted: string;
741
+ readonly success: string;
742
+ readonly successMuted: string;
743
+ readonly dynamic: string;
744
+ readonly dynamicMuted: string;
745
+ /**
746
+ * Component-instance identity (H1) — since 2026-07-31 the color of the
747
+ * dotted RULE under an instance row's name, not the name's own color
748
+ * (owner: "perhaps underline instead of color"). Still MUST be
749
+ * hue-bearing in every palette: a near-neutral value makes the rule
750
+ * read as an artifact rather than a mark, which is the same defect this
751
+ * role was split out of `accent` to fix. It carries less weight than it
752
+ * did as text color, though — the underline is the signal and the hue
753
+ * is the hint, so contrast here buys legibility, not the whole
754
+ * distinction.
755
+ */
756
+ readonly instance: string;
757
+ };
758
+ readonly neutralOverlay: { readonly hover: string; readonly active: string };
759
+ readonly scrim: string;
760
+ /**
761
+ * The 3D VIEWPORT's own colours — Blender's "3D Viewport" theme section
762
+ * transcribed: window background, grid, the X and Y axis lines, the
763
+ * selected and active object. Optional: a palette without it keeps the
764
+ * editor's own (the accent selects, the dressing paints the background).
765
+ * Read by `native-selection-style.ts` off the emitted tokens.
766
+ */
767
+ readonly viewport?: {
768
+ readonly background: string;
769
+ readonly grid: string;
770
+ readonly axisX: string;
771
+ readonly axisY: string;
772
+ /** The Z axis line, where a view draws one (optional: the group's X and Y are the floor's
773
+ * pair in a Z-up world; without it the line takes the gizmo's Z). The axis colours are
774
+ * named by the WORLD's axes, whatever the world's up axis. */
775
+ readonly axisZ?: string;
776
+ /** The selection's WIRE (optional; the selection colour otherwise): Unity draws it blue
777
+ * under an orange outline. Its opacity is `stage.wireOpacity`. */
778
+ readonly wire?: string;
779
+ readonly selection: string;
780
+ readonly active: string;
781
+ /**
782
+ * THE BOX-SELECT RECTANGLE (the rubber band a left-drag over the stage draws): its outline
783
+ * and its fill. Each optional beside the all-or-nothing group, and each emitted as the
784
+ * colour it painted before it existed when absent — the outline the palette's
785
+ * `accent.default`, the fill its `accent.muted` — so every palette that names neither
786
+ * draws exactly what it drew.
787
+ *
788
+ * Blender's is not its accent. `wm_gesture_draw_rect` (`windowmanager/intern/
789
+ * wm_gesture.cc`) fills the box with WHITE AT 5% and outlines it with a one-pixel line
790
+ * dashed in two colours, 0.4 grey and white. Its accent, `.tui.wcol_regular.inner_sel`,
791
+ * is an opaque blue, and as the fill it drew the band as a solid blue square.
792
+ */
793
+ readonly marqueeLine?: string;
794
+ readonly marqueeFill?: string;
795
+ /** The colour BETWEEN the outline's dashes (Blender's second dash colour, white), where
796
+ * the look dashes it (`stage.marqueeDash`). Absent, the gaps are clear. */
797
+ readonly marqueeGap?: string;
798
+ };
799
+ /**
800
+ * THE GIZMOS' COLOURS — the transform gizmo's handles and the navigation gizmo's axes, named
801
+ * by the WORLD's axes (the stage's `world.upAxis` decides which one points up). `hover` and
802
+ * `drag` are a fixed highlight (Unity's preselection and selected-axis colours, Unreal's
803
+ * yellow); without them a handle highlights in its own axis colour, carried by
804
+ * `stage.gizmoHighlightSaturation`/`gizmoHighlightValue` (Blender's own colour;
805
+ * Godot's at a quarter of its saturation, full value). `navigationX`/`Y`/`Z` colour the
806
+ * navigation gizmo where it is drawn differently from the axes (Blender's balls), and
807
+ * default to `x`/`y`/`z`. Each trio comes together or not at all; every member is otherwise
808
+ * optional, and one left out keeps the editor's own. See `docs/VIEWPORT-STAGE.md`.
809
+ */
810
+ readonly gizmo?: {
811
+ readonly x?: string;
812
+ readonly y?: string;
813
+ readonly z?: string;
814
+ readonly navigationX?: string;
815
+ readonly navigationY?: string;
816
+ readonly navigationZ?: string;
817
+ readonly hover?: string;
818
+ readonly drag?: string;
819
+ };
820
+ /**
821
+ * WIDGET COLOUR CLASSES — one fill per KIND of widget, the way Blender's
822
+ * theme carries `wcol_regular`/`wcol_menu`/`wcol_text` rather than one
823
+ * "raised surface". Optional, and every member is independently optional:
824
+ * a palette that names none is byte-identical to before this group
825
+ * existed, because each token falls back to the surface it used to read
826
+ * (`regular`/`menu` → `surface.raised`, `field` → `surface.inset`).
827
+ *
828
+ * MEASURED, Blender 5.2 factory startup, reference frames at 2x (halved
829
+ * here). The three classes really are three colours, which is the whole
830
+ * reason the group exists — our single `surface.raised` painted a
831
+ * dropdown trigger pushbutton-grey where Blender paints a dark well:
832
+ *
833
+ * - `regular` — a pushbutton. `Add Modifier`, `properties-modifier.png`
834
+ * (src x=200, y 142–177): inner #535353.
835
+ * - `menu` — a dropdown/menu BUTTON. `Object Mode v` and `Global v`,
836
+ * `modeling-object-none.png` (src y=78, x 97–… and x 1172–…), and the
837
+ * Scene/ViewLayer ID selectors' icon half in `topbar.png` (src y=26,
838
+ * x 2791–2850): inner #272727. NOT the menu POPUP's background
839
+ * (Blender's `wcol_pulldown`/`wcol_menu_back`) — the shot runner
840
+ * cannot photograph an open menu (`scripts/blender-reference/README.md`
841
+ * records the two measurements that proved it), so no popup value is
842
+ * declared anywhere and the popup keeps `surface.overlay`.
843
+ * - `field` — a text field. The Search fields in `properties-modifier.png`
844
+ * (src x=380, y 7–42) and `outliner.png` (src x=400, y 7–42), and the
845
+ * Scene name half in `topbar.png` (src y=26, x 2853–…): inner #1c1c1c.
846
+ *
847
+ * NO `edge` member, and that is a measurement, not an omission: all three
848
+ * classes outline in the SAME #3c3c3c, which the palette already carries
849
+ * as `boundary.default` — the colour every one of these call sites
850
+ * already borders with.
851
+ *
852
+ * Blender's `wcol_option` (a checkbox — `Add Rest Position`,
853
+ * `properties-data-edit.png`, src y=758, x 92–115) measures #535353,
854
+ * identical to `regular`, and our own checkbox is a native
855
+ * `<input type="checkbox">` whose unchecked box no colour token can reach
856
+ * without `appearance: none`. There is therefore nothing to declare and
857
+ * nothing to read; it belongs to whichever unit redraws that control.
858
+ *
859
+ * `emboss` is the fourth member and the odd one out: it is not a FILL but
860
+ * the 1 CSS px line Blender lays UNDER every widget, below that widget's
861
+ * own `#3c3c3c` outline (Blender's `ThemeUserInterface.widget_emboss`).
862
+ * Measured on five widget KINDS at native 2x, which is what says it is
863
+ * one rule and not five decorations:
864
+ *
865
+ * - pushbutton — `Add Modifier`, `properties-modifier.png` x=300:
866
+ * fill 142–177, outline 178–179, emboss 180–181.
867
+ * - text field — the datablock name, `properties-object.png` x=470:
868
+ * fill #1c1c1c 142–177, outline 178–179, emboss 180–181.
869
+ * - dropdown well — the datablock kind well, `properties-data-edit.png`
870
+ * x=103: fill #272727, outline 178–179, emboss 180–181.
871
+ * - checkbox — `Add Rest Position`, same frame x=103: fill #535353
872
+ * 746–769, outline 770–771, emboss 772–773.
873
+ * - list box — Vertex Groups, same frame x=103: interior #2c2c2c,
874
+ * outline 424–425, emboss 426–427.
875
+ *
876
+ * THE VALUE IS TRANSLUCENT BLACK, NOT A COLOUR — that is the whole reason
877
+ * it is declared as one token and not per surface. Read off four
878
+ * different backdrops (median over 160–500 px each, single-valued in
879
+ * every band):
880
+ *
881
+ * backdrop emboss
882
+ * #272727 39 Outliner header #212121 33
883
+ * #2f2f2f 47 Properties body #282828 40
884
+ * #343434 52 3D View header #2d2d2d 45
885
+ * #3c3c3c 60 a panel band #333333 51
886
+ *
887
+ * No 8-bit alpha reproduces all four, and the browser leaves no finer
888
+ * dial: Chrome QUANTIZES a `box-shadow` alpha to a byte (a declared
889
+ * 0.142 reads back as `rgba(0, 0, 0, 0.14)` and composites as 36/255).
890
+ * 36/255 renders 33/40/45/52 and 37/255 renders 33/40/44/51 — each is
891
+ * three of four, and the one they miss is the OTHER one. 0.142 is
892
+ * declared because the byte it lands on (36) is exact on the two
893
+ * surfaces this look actually paints widgets over, the Properties body
894
+ * (47 → 40, confirmed in our own capture) and the Outliner header
895
+ * (39 → 33); the residue is +1 under a widget sitting on a panel BAND.
896
+ * Only stacked widgets in one group escape the rule entirely: Blender's
897
+ * Location X/Y/Z share outlines and the emboss lands under the GROUP.
898
+ *
899
+ * A palette that names no `emboss` emits `--volter-widget-emboss-shadow:
900
+ * none` and every widget paints exactly what it painted before this
901
+ * member existed.
902
+ */
903
+ readonly widget?: {
904
+ readonly regular?: string;
905
+ readonly menu?: string;
906
+ readonly field?: string;
907
+ readonly emboss?: string;
908
+ };
909
+ /**
910
+ * CATEGORY INKS — colour as a GLYPH's own channel, the way Blender's
911
+ * Properties-tab rail carries it. That rail groups by HUE, and the hue is
912
+ * the discriminator: at 14 px the tab shapes in `properties-object.png`
913
+ * are near-indistinguishable, so what says "this is the Modifier group"
914
+ * is that it is blue. The outliner says the same thing about a row's type
915
+ * glyph, and an edit-mode operator mark says it about the element it
916
+ * operates on.
917
+ *
918
+ * Optional, and every member is independently optional: each token
919
+ * emits with a `currentColor` fallback, so a palette naming none paints
920
+ * every glyph monochrome exactly as before this group existed. A glyph
921
+ * takes a tone through the icon SET (`IconSetContribution`'s per-glyph
922
+ * `tone`/`tonedPath`); the palette only says what each tone's ink is.
923
+ *
924
+ * EVERY VALUE HERE IS INK — the glyph's own colour at FULL opacity, never
925
+ * the composite a particular chrome produces. The SITE applies Blender's
926
+ * alpha. Corrected 2026-09-18 after the group was found in two colour
927
+ * spaces: `object` had been sampled from the Object tab in
928
+ * `properties-object.png`, which is the ACTIVE tab and paints its ink
929
+ * FULL, while `modifier`/`material`/`tool`/`data` were sampled from
930
+ * INACTIVE tabs in the same frame, which paint at α — so the green
931
+ * rendered 31 levels low wherever the ink was wanted, and `modifier`
932
+ * looked impossible to α-undo (its blue solved to 257).
933
+ *
934
+ * THE ALPHA IS 0.80, EXACTLY, and it is now PROVED rather than fitted,
935
+ * because three inks are photographed at full in one frame each and the
936
+ * same ink is photographed dimmed in another:
937
+ *
938
+ * site α
939
+ * Properties rail, ACTIVE tab 1.00
940
+ * Properties rail, inactive tab 0.80
941
+ * Outliner row glyph (active object or not) 0.80
942
+ * Edit-mode tool shelf 1.00
943
+ *
944
+ * Object at 0.80 renders every channel of every sample exactly:
945
+ * #e09557 over #1c1c1c → 28+0.8·(224−28, 149−28, 87−28) = (185,125,75) =
946
+ * #b97d4b, which is the inactive Object tab in `properties-modifier.png`
947
+ * and `properties-data-edit.png` (src x 10-56, y 522-549); over the
948
+ * outliner's #272727 → (187,127,77) = #bb7f4d (`outliner.png`, the Camera
949
+ * and Light rows, src x 126-155 / 129-152); over the selected row's
950
+ * #525252 → (196,136,86) = #c48856 (the Cube row's own glyph plate,
951
+ * src x 121-160). The earlier 0.791 came from the two-background form
952
+ * (196−187)/(82−39), where 195.6 had rounded UP to 196 — one quantisation
953
+ * step in the numerator, 1% in the answer, and enough to put `modifier`
954
+ * out of gamut.
955
+ *
956
+ * - `object` — #e09557, PHOTOGRAPHED at full: `properties-object.png`,
957
+ * the ACTIVE Object tab (src x 10-56, y 522-549), over its #2f2f2f
958
+ * open-tab plate. Unchanged by the correction; it was always ink.
959
+ * - `modifier` — #73a1ff, PHOTOGRAPHED at full: `properties-modifier.png`,
960
+ * the ACTIVE Modifier tab (src y 578-605) = (115,161,255). Its dimmed
961
+ * form #6286d1 (this member's former value) is that ink at 0.80 over
962
+ * #1c1c1c, and Particles/Physics/Constraints (y 634-774) measure the
963
+ * identical blue, which is the grouping.
964
+ * - `data` — #00d3a2, PHOTOGRAPHED at full: `properties-data-edit.png`,
965
+ * the ACTIVE Object Data tab (src y 802-829) = (0,211,162). Its red is
966
+ * CLAMPED AT ZERO, which is why α-undoing it from a composite was
967
+ * unstable: the outliner's three data glyphs paint (7,176,137) on the
968
+ * row's 39, (15,184,145) on a #4f4f4f icon plate and (5,182,141) on the
969
+ * Cube's tinted plate (`outliner.png` src x 266-327, y 135-247), and
970
+ * all three resolve to this one ink at 0.80 only once the red is read
971
+ * as 0.2·backdrop rather than as ink.
972
+ * - `material` — #cb646e, DERIVED, not photographed: no reference frame
973
+ * opens the Material tab, so this is the inactive #a8565e
974
+ * (`properties-object.png` src y 858-885) α-undone at 0.80. It
975
+ * re-renders that composite byte-for-byte; if a frame ever shows the
976
+ * tab open, read it there instead.
977
+ * - `tool` — #cbcbcb, DERIVED the same way from the inactive #a8a8a8
978
+ * (`properties-object.png`, the Tool tab, src y 82-109; Render, Output,
979
+ * View Layer and Scene, y 155-350, measure the identical grey). The
980
+ * scene-level tabs are deliberately the quiet, hue-less group and read
981
+ * DIMMER than the row text (#c2c2c2) only because they are dimmed —
982
+ * the ink itself is lighter than the text.
983
+ * - `operator` — #95dab2. `modeling-edit-none.png`, the edit-mode tool
984
+ * column: Add Cube (src x 36-86, y 690-740), Extrude (y 990-1030),
985
+ * Loop Cut / Knife / Poly Build (y 1080-1280). Already ink and
986
+ * unchanged: that shelf is a 1.00 site (its neutral bodies measure
987
+ * #e4e4e4 over #272727, which no α below 1 can produce). Only the
988
+ * OPERATED element takes the tint; the cube stays #e3e3e3.
989
+ *
990
+ * MEASURED BUT NOT DECLARED. Every ink below was read off a frame; none
991
+ * is declared, because a member no glyph can paint is a name with no
992
+ * caller. The coordinates are here so the unit that earns one
993
+ * transcribes rather than re-measures:
994
+ *
995
+ * - `world` — #cb646e at ink (the World tab, `properties-object.png`
996
+ * src x 10-56, y 378-405, measures #a6555d inactive), within a level
997
+ * of `material` because Blender puts World and Material in one red
998
+ * group. Nothing in this editor draws a globe to MEAN a World
999
+ * datablock: `faGlobe`'s sites are the transform-SPACE toggle and a
1000
+ * build profile, where red reads as an error state.
1001
+ * - no `camera`/`light` — in `outliner.png` the camera and light OBJECT
1002
+ * glyphs measure #bb7f4d, byte-identical to the mesh object's, and
1003
+ * their DATA glyphs measure the same green as the mesh's. Blender's
1004
+ * outliner discriminates object-vs-data, not camera-vs-light.
1005
+ * - no `accent` — no glyph in any frame paints in the palette's accent.
1006
+ * - `deform` — #d6c1e4. Blender's edit-mode column carries a SECOND
1007
+ * operator tint, a lilac on the deforming tools (Smooth src x 36-88
1008
+ * y 1330-1380, Shear y 1500-1550, Edge Slide y 1420-1470) against the
1009
+ * creating tools' green. Our glyph set ships none of those operators.
1010
+ */
1011
+ readonly category?: {
1012
+ readonly object?: string;
1013
+ readonly modifier?: string;
1014
+ readonly material?: string;
1015
+ readonly tool?: string;
1016
+ readonly operator?: string;
1017
+ /** Mesh DATA — Blender's green Object Data tab and its outliner data
1018
+ * glyphs: `.tui.icon_object_data` #00d4a3
1019
+ * (`userdef_default_theme.c:275`), which every `DEF_ICON_OBJECT_DATA`
1020
+ * mark paints in. */
1021
+ readonly data?: string;
1022
+ /** Blender's SCENE group — the Render, Output, View Layer and Scene
1023
+ * tabs, every one of them a `DEF_ICON_SCENE` in `UI_icons.hh`:
1024
+ * `.tui.icon_scene` #cccccc (`userdef_default_theme.c:272`). Note the
1025
+ * World tab is NOT in it — `UI_icons.hh:193` declares it
1026
+ * `DEF_ICON_SHADING(WORLD)`, so it takes `material` with Material and
1027
+ * Texture. That correction is what reading the source bought over
1028
+ * reading the rail. */
1029
+ readonly scene?: string;
1030
+ /** Blender's COLLECTION group — one tab, `DEF_ICON_COLLECTION(GROUP)`
1031
+ * (`UI_icons.hh:248`): `.tui.icon_collection` #ffffff
1032
+ * (`userdef_default_theme.c:273`). White rather than the scene grey,
1033
+ * which is why it is its own name. */
1034
+ readonly collection?: string;
1035
+ /** The selection tools' marquee (Blender's Select Box icon: an orange dashed box, baked
1036
+ * into the icon's geometry, not a theme member). */
1037
+ readonly select?: string;
1038
+ };
1039
+ /**
1040
+ * REGION FILLS — one colour per EDITOR AREA, the way Blender's theme
1041
+ * carries `theme.outliner.back` and `theme.properties.back` rather than
1042
+ * one "panel". Our dock paints every group from a single
1043
+ * `--dv-group-view-background-color`, so before this member a panel could
1044
+ * not have its own fill and the Outliner sat eight levels too light.
1045
+ *
1046
+ * A region names ONE colour and it paints the area's BODY AND ITS HEADER,
1047
+ * because that is what the frames measure — see below. Optional, and every
1048
+ * member is independently optional: an undeclared region falls back to
1049
+ * what its call sites already read (`surface.panel` for the body,
1050
+ * `surface.chrome` for the header), so a palette naming none is
1051
+ * byte-identical to before this group existed.
1052
+ *
1053
+ * A panel claims a region by name in `workspace-static-panels.ts`
1054
+ * ({@link EditorRegionName}); the workspace host applies the ACTIVE
1055
+ * panel's claim to the region it sits in. A panel that claims none is
1056
+ * unchanged.
1057
+ *
1058
+ * MEASURED, Blender 5.2 factory startup, `modeling.png` at 2x (source
1059
+ * coordinates in the 2x frame):
1060
+ *
1061
+ * - `outliner` — #272727, BODY AND HEADER ALIKE. Body: row y=300 over
1062
+ * x 2845..3448 is #272727 with the row alternation stepping to
1063
+ * #2a2a2a. Header: row y=75 between the search field and the chevron
1064
+ * (x 3260..3345) is the identical #272727. Our `surface.chrome`
1065
+ * #1c1c1c painted that header eleven levels DARKER than its own body,
1066
+ * where Blender's is the same colour — the header does not read as a
1067
+ * slab there at all.
1068
+ * - `properties` — #2f2f2f, body and header alike. Body: column x=3400,
1069
+ * y 608..2054 is #2f2f2f. Header: row y=450 over x 2924..3030 and
1070
+ * x 3261..3395 is #2f2f2f. The body already matched `surface.panel`;
1071
+ * the member exists for the HEADER, which was #1c1c1c.
1072
+ *
1073
+ * NOT DECLARABLE HERE, and it is the one place Blender's header differs
1074
+ * from its body: the 3D viewport's header is #343434 over a #3f3f3f back
1075
+ * (`modeling.png` y=108 is 52, y=109 is 63). That area is not a dock
1076
+ * panel — it is the document surface, whose fill is `viewport.background`
1077
+ * and whose header is `--volter-surface-header` — so it takes no region
1078
+ * claim and the "one colour, header and body" rule above stands for
1079
+ * everything this member can reach.
1080
+ */
1081
+ readonly region?: { readonly [name in EditorRegionName]?: string };
1082
+ };
1083
+ readonly typography: { readonly sans: string; readonly mono: string };
1084
+ readonly shape: {
1085
+ readonly small: string;
1086
+ readonly medium: string;
1087
+ readonly large: string;
1088
+ readonly full: string;
1089
+ };
1090
+ readonly elevation: { readonly small: string; readonly medium: string; readonly large: string };
1091
+ /** A material's own chrome density (`editor-appearance.ts`); absent means
1092
+ * the editor's own `controlSize`/`fontSize`/`chromeSize`. */
1093
+ readonly density?: EditorDensity;
1094
+ /** A material's own 3D stage furniture ({@link EditorStage}); absent means the editor's own. */
1095
+ readonly stage?: EditorStage;
1096
+ /**
1097
+ * Surface-treatment vocabulary (Glass-UI spike, W3 — work item 3).
1098
+ * Optional: absent means no backdrop treatment, matching every existing
1099
+ * theme's opaque panels. Extensible record for future treatment axes;
1100
+ * only backdrop blur/ saturation are defined for now. A "glass" look is
1101
+ * entirely DATA here — no `[data-volter-theme=…]` CSS special-casing
1102
+ * exists or should be added; `editorThemeVariables` is the only place
1103
+ * this is consumed.
1104
+ */
1105
+ readonly treatment?: {
1106
+ /** Backdrop blur radius in px (0–64). */
1107
+ readonly backdropBlurPx?: number;
1108
+ /** Backdrop saturation multiplier (0–3; 1 = unchanged). */
1109
+ readonly backdropSaturation?: number;
1110
+ /**
1111
+ * Backdrop brightness multiplier (0–2; 1 = unchanged). The luminance
1112
+ * clamp that keeps dark-glass text legible over arbitrarily bright scene
1113
+ * content (glass-UI W7, report §2.22): a value below 1 darkens whatever
1114
+ * the card floats over BEFORE the translucent panel color composites, so
1115
+ * a pure-white backdrop can never wash out card chrome.
1116
+ */
1117
+ readonly backdropBrightness?: number;
1118
+ /**
1119
+ * Liquid Glass Tier-1 edge-specular intensity (0–1; report §2.24). Drives
1120
+ * the alphas of the pure-CSS "light catching the edge" trio — the 1.5px
1121
+ * gradient border ring, the 1px inset rim catches, and the diagonal
1122
+ * sheen — all emitted as `--volter-card-specular-*` variables and painted
1123
+ * by `workspace-dock.css`'s card-chrome pseudo-elements. 0/absent means
1124
+ * none of the three paint. Calibrated so 0.6 reproduces the committed
1125
+ * prototype's variant-B alphas exactly
1126
+ * (`docs/assets/glass-ui-feasibility/liquid-glass-proto/index.html`).
1127
+ */
1128
+ readonly edgeSpecular?: number;
1129
+ /**
1130
+ * Direction the specular "light" arrives from, in degrees (0–360;
1131
+ * default 120 — upper-left key light, matching the prototype). Rotates
1132
+ * both the ring gradient and the sheen; only meaningful when
1133
+ * `edgeSpecular` > 0.
1134
+ */
1135
+ readonly specularAngleDeg?: number;
1136
+ /**
1137
+ * Liquid Glass Tier-1 thickness cue (0–1; report §2.24): scales the
1138
+ * geometry of the inset-shadow depth pair (upper glass glow + lower
1139
+ * inner shadow) and the card's drop shadow. 0/absent keeps the theme's
1140
+ * ordinary `elevation.large` card shadow. Calibrated so 0.5 reproduces
1141
+ * the prototype's variant-B depths exactly.
1142
+ */
1143
+ readonly thickness?: number;
1144
+ /**
1145
+ * Liquid Glass refraction: width of the refracting bezel band in px
1146
+ * (4–80; the owner-reopened visual default is 56). Only meaningful when
1147
+ * `refractionThickness` > 0. Displacement follows the convex-squircle
1148
+ * height profile from full bend at the rim to zero at the bezel's inner
1149
+ * boundary; the interior is strictly neutral — the backdrop passes
1150
+ * through untouched (`components/glass-refraction.ts`).
1151
+ */
1152
+ readonly refractionBezelPx?: number;
1153
+ /**
1154
+ * Liquid Glass refraction: slab-thickness factor (0–2; ×40px of glass;
1155
+ * the owner-reopened visual default is 1.25). This is THE refraction knob — the
1156
+ * `feDisplacementMap` scale is physically DERIVED from the generated
1157
+ * field's max magnitude, never authored directly. 0/absent means no
1158
+ * refraction. Capability-gated at the surface host
1159
+ * (`components/glass-refraction.ts`): it only ever paints when Chromium
1160
+ * supports `backdrop-filter: url(#…)` AND the GPU is not a software
1161
+ * rasterizer; everywhere else the plain
1162
+ * `--volter-surface-backdrop-filter` list paints instead, automatically.
1163
+ * Distinct from `thickness` (the Tier-1 inset-shadow depth cue), which
1164
+ * survives as an independent axis.
1165
+ */
1166
+ readonly refractionThickness?: number;
1167
+ /**
1168
+ * Content-legibility text shadow opacity (0–1): emits
1169
+ * `--volter-content-text-shadow: 0 1px 2px rgba(0,0,0,<v>)` inherited by
1170
+ * all editor chrome. The §2.32 legibility finding: near-white text +
1171
+ * concentrated text-shadow is the correct default over a predominantly dark
1172
+ * viewport when the surface itself is a ≤15% white whisper (a full
1173
+ * adaptive palette is a later unit). 0/absent emits `none` — every
1174
+ * pre-existing theme is byte-identical.
1175
+ */
1176
+ readonly textShadowOpacity?: number;
1177
+ /**
1178
+ * Maximum body/content frost blur in px (0–24; §2.31 P2 owner
1179
+ * amendment). On the SVG path the lens blends from its clear rim toward
1180
+ * this blur with optical depth. `.volter-content-frost` also applies the
1181
+ * same value locally behind text-dense zones, using a colorless/light
1182
+ * material lift rather than black paint. 0/absent emits
1183
+ * `none`/`transparent`, so treatment-less themes stay byte-identical.
1184
+ */
1185
+ readonly contentFrostBlurPx?: number;
1186
+ /**
1187
+ * Faint ambient lift (P6-U6 owner taste decision 2, approved
1188
+ * 2026-07-19): peak white alpha (0–1, faint — ≤0.1 territory) of a
1189
+ * fixed top-lit luminance wash layered with card/island fills so
1190
+ * clear glass still reads as a surface over a pure-black void —
1191
+ * physically a glass sheet over black IS invisible, and the owner
1192
+ * chose a subtle ambient light response over accepting the physics.
1193
+ * Emitted as the `--volter-ambient-lift` background-image layer
1194
+ * (gradient geometry is a design constant here; this axis is only the
1195
+ * intensity). 0/absent emits `none` — every pre-existing theme and
1196
+ * every lite/reduced-transparency fallback paints byte-identically.
1197
+ */
1198
+ readonly ambientLiftOpacity?: number;
1199
+ /** Enable measured per-surface use of the palette's bright-backdrop ink ramp. */
1200
+ readonly adaptiveContent?: boolean;
1201
+ /** Bright-side text-shadow opacity; material physics, never palette chroma. */
1202
+ readonly brightTextShadowOpacity?: number;
1203
+ /** Bright-side content frost tint; material physics, never content ink. */
1204
+ readonly brightFrostBg?: string;
1205
+ };
1206
+ /** Resolved axis identity. Legacy standalone themes are inferred when absent. */
1207
+ readonly appearance?: {
1208
+ readonly material: 'classic' | 'glass';
1209
+ readonly transparency: 'standard' | 'reduced';
1210
+ };
1211
+ }
1212
+
1213
+ export const graphiteDarkEditorTheme: EditorTheme = {
1214
+ id: 'graphite-dark',
1215
+ color: {
1216
+ surface: {
1217
+ shell: GRAPHITE_BG[0],
1218
+ panel: GRAPHITE_BG[1],
1219
+ chrome: GRAPHITE_BG[2],
1220
+ raised: GRAPHITE_BG[3],
1221
+ inset: GRAPHITE_BG.inset,
1222
+ overlay: 'rgba(36,36,36,0.94)',
1223
+ },
1224
+ boundary: { default: GRAPHITE_BORDER[1], strong: GRAPHITE_BORDER[2] },
1225
+ content: {
1226
+ primary: GRAPHITE_TEXT[1],
1227
+ muted: GRAPHITE_TEXT[2],
1228
+ dim: GRAPHITE_TEXT[3],
1229
+ onAccent: '#101820',
1230
+ onBright: {
1231
+ primary: '#1a2330',
1232
+ muted: 'rgba(26,35,48,0.82)',
1233
+ dim: 'rgba(26,35,48,0.74)',
1234
+ },
1235
+ },
1236
+ accent: { default: GRAPHITE_ACCENT, muted: GRAPHITE_ACCENT_MUTED },
1237
+ semantic: {
1238
+ danger: GRAPHITE_DANGER,
1239
+ dangerMuted: GRAPHITE_DANGER_MUTED,
1240
+ dangerFaint: GRAPHITE_DANGER_FAINT,
1241
+ warning: GRAPHITE_WARN,
1242
+ warningMuted: GRAPHITE_WARN_MUTED,
1243
+ success: GRAPHITE_SUCCESS,
1244
+ successMuted: GRAPHITE_SUCCESS_MUTED,
1245
+ dynamic: GRAPHITE_DYNAMIC,
1246
+ dynamicMuted: GRAPHITE_DYNAMIC_BG,
1247
+ instance: GRAPHITE_INSTANCE,
1248
+ },
1249
+ neutralOverlay: {
1250
+ hover: 'rgba(255,255,255,0.08)',
1251
+ active: 'rgba(255,255,255,0.14)',
1252
+ },
1253
+ scrim: GRAPHITE_SCRIM,
1254
+ },
1255
+ typography: { sans: GRAPHITE_FONT_SANS, mono: GRAPHITE_FONT_MONO },
1256
+ shape: {
1257
+ small: DEFAULT_RADIUS.small,
1258
+ medium: DEFAULT_RADIUS.medium,
1259
+ large: DEFAULT_RADIUS.large,
1260
+ full: DEFAULT_RADIUS.full,
1261
+ },
1262
+ elevation: {
1263
+ small: GRAPHITE_SHADOW.sm,
1264
+ medium: GRAPHITE_SHADOW.md,
1265
+ large: GRAPHITE_SHADOW.lg,
1266
+ },
1267
+ };
1268
+
1269
+ /** Neutral palette used by the default Glass style without rewriting Classic. */
1270
+ export const graphiteNeutralEditorTheme: EditorTheme = {
1271
+ ...graphiteDarkEditorTheme,
1272
+ id: 'graphite-neutral',
1273
+ color: {
1274
+ ...graphiteDarkEditorTheme.color,
1275
+ content: {
1276
+ primary: GRAPHITE_NEUTRAL_TEXT[1],
1277
+ muted: GRAPHITE_NEUTRAL_TEXT[2],
1278
+ dim: GRAPHITE_NEUTRAL_TEXT[3],
1279
+ onAccent: '#101820',
1280
+ onBright: {
1281
+ primary: '#1a2330',
1282
+ muted: 'rgba(26,35,48,0.82)',
1283
+ dim: 'rgba(26,35,48,0.74)',
1284
+ },
1285
+ },
1286
+ accent: {
1287
+ default: GRAPHITE_NEUTRAL_ACCENT,
1288
+ muted: GRAPHITE_NEUTRAL_ACCENT_MUTED,
1289
+ },
1290
+ },
1291
+ };
1292
+
1293
+ /**
1294
+ * Production high-contrast theme. It deliberately preserves the editor's
1295
+ * dark, low-glare character while increasing text/boundary separation and
1296
+ * shifting the accent away from the default graphite palette.
1297
+ */
1298
+ export const midnightHighContrastEditorTheme: EditorTheme = {
1299
+ id: 'midnight-high-contrast',
1300
+ color: {
1301
+ surface: {
1302
+ shell: '#0b0f14',
1303
+ panel: '#111821',
1304
+ chrome: '#17212c',
1305
+ raised: '#223040',
1306
+ inset: '#080c11',
1307
+ overlay: 'rgba(17,24,33,0.96)',
1308
+ },
1309
+ boundary: { default: '#34465a', strong: '#71869d' },
1310
+ content: {
1311
+ primary: '#f2f5f8',
1312
+ muted: '#bdc8d5',
1313
+ dim: '#8493a5',
1314
+ onAccent: '#071019',
1315
+ onBright: {
1316
+ primary: '#071019',
1317
+ muted: 'rgba(7,16,25,0.82)',
1318
+ dim: 'rgba(7,16,25,0.72)',
1319
+ },
1320
+ },
1321
+ accent: { default: '#65d1ff', muted: 'rgba(101,209,255,0.18)' },
1322
+ semantic: {
1323
+ danger: '#ff7b86',
1324
+ dangerMuted: 'rgba(255,123,134,0.18)',
1325
+ dangerFaint: 'rgba(255,123,134,0.08)',
1326
+ warning: '#f3c969',
1327
+ warningMuted: 'rgba(243,201,105,0.16)',
1328
+ success: '#62d394',
1329
+ successMuted: 'rgba(98,211,148,0.18)',
1330
+ dynamic: '#e889ff',
1331
+ dynamicMuted: 'rgba(232,137,255,0.12)',
1332
+ // This is the ONE palette whose accent already owns cyan (#65d1ff), so
1333
+ // instance identity takes the next-widest free gap instead — the
1334
+ // indigo/periwinkle band between the accent (~198°) and dynamic
1335
+ // (~288°), ~45° clear of both.
1336
+ instance: '#a49bff',
1337
+ },
1338
+ neutralOverlay: {
1339
+ hover: 'rgba(255,255,255,0.10)',
1340
+ active: 'rgba(255,255,255,0.18)',
1341
+ },
1342
+ scrim: 'rgba(0,0,0,0.68)',
1343
+ },
1344
+ typography: { sans: GRAPHITE_FONT_SANS, mono: GRAPHITE_FONT_MONO },
1345
+ shape: { small: '3px', medium: '6px', large: '8px', full: '9999px' },
1346
+ elevation: {
1347
+ small: '0 2px 8px rgba(0,0,0,0.72)',
1348
+ medium: '0 6px 18px rgba(0,0,0,0.68)',
1349
+ large: '0 12px 40px rgba(0,0,0,0.76)',
1350
+ },
1351
+ };
1352
+
1353
+ /**
1354
+ * Glass-UI spike (W3, work item 3): graphite-derived, translucent built-in
1355
+ * theme. Surface alphas target panel alpha >= 0.55
1356
+ * COMPOSITED (i.e. after `backdropSaturation`'s perceived contrast boost)
1357
+ * to keep text-on-surface contrast workable over arbitrary live 3D content
1358
+ * — the spike's proven `color-mix(... 62%, transparent)` card recipe,
1359
+ * expressed here as theme data instead of injected CSS. Elevation is
1360
+ * strengthened slightly over graphite-dark: a translucent card needs a more
1361
+ * assertive shadow to read as "above" the scene it is blurring.
1362
+ */
1363
+ export const glassDarkEditorTheme: EditorTheme = {
1364
+ id: 'glass-dark',
1365
+ appearance: { material: 'glass', transparency: 'standard' },
1366
+ color: {
1367
+ surface: {
1368
+ shell: 'rgba(26,26,26,0.88)',
1369
+ panel: 'rgba(36,36,36,0.62)',
1370
+ chrome: 'rgba(44,44,44,0.68)',
1371
+ raised: 'rgba(51,51,51,0.74)',
1372
+ inset: 'rgba(30,30,30,0.7)',
1373
+ overlay: 'rgba(36,36,36,0.55)',
1374
+ },
1375
+ boundary: { default: 'rgba(255,255,255,0.14)', strong: 'rgba(255,255,255,0.24)' },
1376
+ content: {
1377
+ primary: GRAPHITE_TEXT[1],
1378
+ muted: GRAPHITE_TEXT[2],
1379
+ dim: GRAPHITE_TEXT[3],
1380
+ onAccent: '#101820',
1381
+ onBright: {
1382
+ primary: '#1a2330',
1383
+ muted: 'rgba(26,35,48,0.82)',
1384
+ dim: 'rgba(26,35,48,0.74)',
1385
+ },
1386
+ },
1387
+ accent: {
1388
+ default: GRAPHITE_NEUTRAL_ACCENT,
1389
+ muted: GRAPHITE_NEUTRAL_ACCENT_MUTED,
1390
+ },
1391
+ semantic: {
1392
+ danger: GRAPHITE_DANGER,
1393
+ dangerMuted: GRAPHITE_DANGER_MUTED,
1394
+ dangerFaint: GRAPHITE_DANGER_FAINT,
1395
+ warning: GRAPHITE_WARN,
1396
+ warningMuted: GRAPHITE_WARN_MUTED,
1397
+ success: GRAPHITE_SUCCESS,
1398
+ successMuted: GRAPHITE_SUCCESS_MUTED,
1399
+ dynamic: GRAPHITE_DYNAMIC,
1400
+ dynamicMuted: GRAPHITE_DYNAMIC_BG,
1401
+ instance: GRAPHITE_INSTANCE,
1402
+ },
1403
+ neutralOverlay: {
1404
+ hover: 'color-mix(in srgb, currentColor 9%, transparent)',
1405
+ active: 'color-mix(in srgb, currentColor 14%, transparent)',
1406
+ },
1407
+ // Lighter dim than the opaque themes — the modal scrim's blur
1408
+ // (`--volter-scrim-backdrop-filter`, emitted for every treatment theme)
1409
+ // does the separation work, the macOS read (P6-U6 owner taste
1410
+ // decision 3). The lite fallback pins the original heavy dim.
1411
+ scrim: 'rgba(0,0,0,0.35)',
1412
+ },
1413
+ typography: { sans: GRAPHITE_FONT_SANS, mono: GRAPHITE_FONT_MONO },
1414
+ shape: {
1415
+ small: DEFAULT_RADIUS.small,
1416
+ medium: DEFAULT_RADIUS.medium,
1417
+ large: DEFAULT_RADIUS.large,
1418
+ full: DEFAULT_RADIUS.full,
1419
+ },
1420
+ elevation: {
1421
+ small: '0 4px 16px rgba(0,0,0,0.55)',
1422
+ medium: '0 8px 28px rgba(0,0,0,0.5)',
1423
+ large: '0 16px 48px rgba(0,0,0,0.55)',
1424
+ },
1425
+ // brightness 0.5 tuned live over a pure-white scene (W7 contrast session,
1426
+ // report §2.22): 0.65 was invisible over white, 0.2 killed the backdrop
1427
+ // bleed entirely; 0.5 holds crisp text over white while the scene still
1428
+ // reads through every card.
1429
+ // Liquid Glass Tier-1 axes (report §2.24) tuned live over the dark
1430
+ // template scene AND a forced-white scene (0/0.4/0.55/0.6/0.8 specular ×
1431
+ // 0.3/0.5/0.7 thickness close-up matrix): edgeSpecular 0.6 — the exact
1432
+ // prototype-B reference the owner reviewed; 0.8 over-brightens the lit
1433
+ // corner until it merges into a white backdrop, 0.4 is invisible at
1434
+ // editor-card sizes, and the feared ring-plus-1px-boundary "doubled edge"
1435
+ // does not materialize at 0.6. Angle 120 (upper-left key light).
1436
+ // Thickness 0.5 — the prototype-B depth pair unchanged; 0.7 pushed the
1437
+ // lower inner shadow into a visible dark band across the card bottom.
1438
+ treatment: {
1439
+ backdropBlurPx: 14,
1440
+ backdropSaturation: 1.3,
1441
+ backdropBrightness: 0.5,
1442
+ edgeSpecular: 0.6,
1443
+ specularAngleDeg: 120,
1444
+ thickness: 0.5,
1445
+ },
1446
+ };
1447
+
1448
+ /**
1449
+ * Measured-fallback built-in (Glass-UI feasibility report §2.5, "Measured
1450
+ * frame-throughput impact"): the report's conclusion is that translucency
1451
+ * itself is frame-free but the blur-bearing `backdrop-filter` is not, so a
1452
+ * no-filter treatment must ship as a first-class fallback — not just a
1453
+ * theoretical escape hatch — for hardware where the backdrop filter is
1454
+ * unaffordable. Derived from `glassDarkEditorTheme` with NO `treatment` key
1455
+ * (so `editorThemeVariables` emits `--volter-surface-backdrop-filter: none`)
1456
+ * and surface alphas raised to compensate for the legibility that blur would
1457
+ * otherwise have provided: panel 0.62->0.78, chrome 0.68->0.82, raised
1458
+ * 0.74->0.86, inset 0.70->0.82; shell/overlay are unchanged since they were
1459
+ * already high-alpha. Values are decided by the report; do not retune here.
1460
+ */
1461
+ export const glassDarkLiteEditorTheme: EditorTheme = (() => {
1462
+ // Destructure `treatment` out rather than setting it to `undefined` —
1463
+ // `exactOptionalPropertyTypes` treats those as different, and the whole
1464
+ // point of this theme is that the key is ABSENT (matching every other
1465
+ // no-blur theme), not present-with-undefined.
1466
+ const { treatment: _treatment, ...glassDarkWithoutTreatment } = glassDarkEditorTheme;
1467
+ return {
1468
+ ...glassDarkWithoutTreatment,
1469
+ id: 'glass-dark-lite',
1470
+ appearance: { material: 'glass', transparency: 'reduced' },
1471
+ color: {
1472
+ ...glassDarkEditorTheme.color,
1473
+ // U6.5 F8: the reduced-transparency tier is SOLID, not merely
1474
+ // higher-alpha — the audit (shot 25) caught the scene reading through
1475
+ // the former translucent fills, defeating the accessibility request
1476
+ // outright ("the ladder says lite → solid"). Values are the previous
1477
+ // translucent tints composited over the shell, so the hue family is
1478
+ // unchanged; only the see-through is gone.
1479
+ surface: {
1480
+ shell: '#1a1a1a',
1481
+ panel: '#222222',
1482
+ chrome: '#292929',
1483
+ raised: '#303030',
1484
+ inset: '#1d1d1d',
1485
+ overlay: '#202020',
1486
+ },
1487
+ // No treatment ⇒ no scrim blur; the dim must carry modal separation
1488
+ // alone, so this fallback keeps the pre-P6-U6 heavy scrim. (The scrim
1489
+ // stays translucent by function — dimming IS its job.)
1490
+ scrim: GRAPHITE_SCRIM,
1491
+ },
1492
+ };
1493
+ })();
1494
+
1495
+ /**
1496
+ * REAL liquid glass (owner direction reset, report §2.31; material proven in
1497
+ * §2.32's demo — `docs/assets/glass-ui-feasibility/liquid-glass-real/`).
1498
+ * The surface has NO background fill of its own: panel is a 6% white
1499
+ * "material presence" whisper (never a dark fill — the owner rejected the
1500
+ * tinted-frost model outright: "Real glass morphism has no background color
1501
+ * and instead morphs the colors behind it"). The material reads as glass
1502
+ * because the backdrop's colors BEND at the bezel and keep displacing as
1503
+ * optical depth increases through the body (`refractionBezelPx` /
1504
+ * `refractionThickness`, painted by `components/glass-refraction.ts`). Frost
1505
+ * 1px + saturate 1.15 are carried by `backdropBlurPx` /
1506
+ * `backdropSaturation` so the non-Chromium plain-list fallback and the SVG
1507
+ * chain cannot drift. Treatment numbers were strengthened after the owner
1508
+ * visual review: bezel 56, thickness 1.25, frost 1px, saturation 1.15, tint
1509
+ * 6%. Content is near-white with a subtle
1510
+ * text shadow (the §2.32 legibility finding for a predominantly dark
1511
+ * viewport; adaptive palette is a later unit). No `backdropBrightness`:
1512
+ * darkening the backdrop was part of the rejected frost model.
1513
+ */
1514
+ export const liquidGlassEditorTheme: EditorTheme = {
1515
+ id: 'liquid-glass',
1516
+ appearance: { material: 'glass', transparency: 'standard' },
1517
+ color: {
1518
+ surface: {
1519
+ shell: GLASS_MATERIAL.color.surface.shell,
1520
+ // THE glass surface — 6% white, the §2.32 "material presence" whisper.
1521
+ panel: GLASS_MATERIAL.color.surface.panel,
1522
+ chrome: GLASS_MATERIAL.color.surface.chrome,
1523
+ raised: GLASS_MATERIAL.color.surface.raised,
1524
+ // Input wells recess by shading, not by an opaque slab: a control
1525
+ // fill, not a panel surface (the §2.31 ban is on surface fills).
1526
+ inset: GLASS_MATERIAL.color.surface.inset,
1527
+ overlay: GLASS_MATERIAL.color.surface.overlay,
1528
+ },
1529
+ boundary: GLASS_MATERIAL.color.boundary,
1530
+ content: {
1531
+ primary: '#f2f6fb',
1532
+ // Dark-side ramp polish (P3, owner directive #3): muted/dim tuned FOR
1533
+ // the clear glass surface rather than inherited from the opaque-panel
1534
+ // ramp — over live scene content the old 0.72/0.55 alphas dropped
1535
+ // secondary text below comfortable legibility the moment the backdrop
1536
+ // carried any detail. Raised presence, same near-white family
1537
+ // (measured on the rts mid-green worst case: muted 0.8 read 3.6:1;
1538
+ // 0.85 is the highest alpha that still reads as a distinct tier).
1539
+ muted: 'rgba(242,246,251,0.85)',
1540
+ dim: 'rgba(242,246,251,0.68)',
1541
+ onAccent: '#0b1526',
1542
+ onBright: {
1543
+ primary: '#1a2330',
1544
+ muted: 'rgba(26,35,48,0.82)',
1545
+ dim: 'rgba(26,35,48,0.74)',
1546
+ },
1547
+ },
1548
+ // Legacy resolved-theme export kept neutral too. The canonical Glass
1549
+ // material itself cannot carry an accent; hue comes from a palette.
1550
+ accent: {
1551
+ default: GRAPHITE_NEUTRAL_ACCENT,
1552
+ muted: GRAPHITE_NEUTRAL_ACCENT_MUTED,
1553
+ },
1554
+ semantic: {
1555
+ danger: '#ff7b86',
1556
+ dangerMuted: 'rgba(255,123,134,0.22)',
1557
+ dangerFaint: 'rgba(255,123,134,0.10)',
1558
+ warning: '#ffd35f',
1559
+ warningMuted: 'rgba(255,211,95,0.18)',
1560
+ success: '#58e078',
1561
+ successMuted: 'rgba(88,224,120,0.20)',
1562
+ dynamic: '#ff8fd8',
1563
+ dynamicMuted: 'rgba(255,143,216,0.14)',
1564
+ // Brighter sibling of the graphite cyan, matching this theme's lifted
1565
+ // semantic family (its danger/warning/success are all brighter too).
1566
+ instance: '#3ad2ea',
1567
+ },
1568
+ neutralOverlay: GLASS_MATERIAL.color.neutralOverlay,
1569
+ // Lighter dim + scrim blur (`--volter-scrim-backdrop-filter`) — the macOS
1570
+ // modal read, P6-U6 owner taste decision 3; lite pins the heavier dim.
1571
+ scrim: GLASS_MATERIAL.color.scrim,
1572
+ },
1573
+ typography: { sans: GRAPHITE_FONT_SANS, mono: GRAPHITE_FONT_MONO },
1574
+ // Rounder shapes than graphite: the lens corner IS the material — a
1575
+ // larger card radius gives the bezel band a visible curve to bend around
1576
+ // (the demo's card is 24px; 14px keeps editor density workable).
1577
+ // `full` must be a px value, never 50%: restyle2 U0 killed the 50% oval
1578
+ // token (50% renders an ellipse on any non-square element) and pill
1579
+ // consumers are specified against true half-circle end caps.
1580
+ shape: GLASS_MATERIAL.shape,
1581
+ // Soft, deep drop shadows (the demo's `.glass` stack): a clear surface
1582
+ // needs shadow — not fill — to read as "above" the scene.
1583
+ elevation: GLASS_MATERIAL.elevation,
1584
+ treatment: GLASS_MATERIAL.treatment,
1585
+ };
1586
+
1587
+ /**
1588
+ * Liquid-glass degrade tier (reduced transparency / no-treatment
1589
+ * environments), following the `glass-dark-lite` precedent: the SAME
1590
+ * palette family with NO `treatment` key (so
1591
+ * `--volter-surface-backdrop-filter` emits `none` and
1592
+ * `--volter-card-refraction-thickness` emits `0`) and — U6.5 F8 — SOLID
1593
+ * fills: the audit (shot 25) caught the scene reading through the former
1594
+ * `rgba(255,255,255,0.14)` card fills with no backdrop-filter to earn the
1595
+ * translucency, defeating the reduced-transparency request outright ("the
1596
+ * ladder says lite → solid"). Each value is the previous light tint
1597
+ * composited over the #12161f shell (panel 0.14, chrome 0.18, raised 0.24,
1598
+ * overlay 0.16; inset's dark wash over the solid panel), so the lifted
1599
+ * blue-gray family reads the same — opaque.
1600
+ */
1601
+ export const liquidGlassLiteEditorTheme: EditorTheme = (() => {
1602
+ // Destructure `treatment` out rather than setting it to `undefined` —
1603
+ // `exactOptionalPropertyTypes` treats those as different, and the whole
1604
+ // point of this theme is that the key is ABSENT (same idiom as
1605
+ // `glassDarkLiteEditorTheme`).
1606
+ const { treatment: _treatment, ...liquidGlassWithoutTreatment } = liquidGlassEditorTheme;
1607
+ return {
1608
+ ...liquidGlassWithoutTreatment,
1609
+ id: 'liquid-glass-lite',
1610
+ appearance: { material: 'glass', transparency: 'reduced' },
1611
+ color: {
1612
+ ...liquidGlassEditorTheme.color,
1613
+ surface: GLASS_MATERIAL.reducedTransparency.surface,
1614
+ // No treatment ⇒ no scrim blur; keep the pre-P6-U6 heavier dim so
1615
+ // modal separation survives the degrade. (Scrims stay translucent by
1616
+ // function — dimming IS their job.)
1617
+ scrim: GLASS_MATERIAL.reducedTransparency.scrim,
1618
+ },
1619
+ };
1620
+ })();
1621
+
1622
+ export const editorThemes = {
1623
+ 'graphite-dark': graphiteDarkEditorTheme,
1624
+ 'graphite-neutral': graphiteNeutralEditorTheme,
1625
+ 'midnight-high-contrast': midnightHighContrastEditorTheme,
1626
+ 'glass-dark': glassDarkEditorTheme,
1627
+ 'glass-dark-lite': glassDarkLiteEditorTheme,
1628
+ 'liquid-glass': liquidGlassEditorTheme,
1629
+ 'liquid-glass-lite': liquidGlassLiteEditorTheme,
1630
+ } as const satisfies Record<string, EditorTheme>;
1631
+
1632
+ export type EditorThemeId = keyof typeof editorThemes;
1633
+
1634
+ export function isEditorThemeId(value: unknown): value is EditorThemeId {
1635
+ return typeof value === 'string' && Object.hasOwn(editorThemes, value);
1636
+ }
1637
+
1638
+ /** Semantic CSS-variable references for rendered editor chrome. */
1639
+ export const themeVars = {
1640
+ surface: {
1641
+ shell: 'var(--volter-surface-shell)',
1642
+ panel: 'var(--volter-surface-panel)',
1643
+ chrome: 'var(--volter-surface-chrome)',
1644
+ raised: 'var(--volter-surface-raised)',
1645
+ inset: 'var(--volter-surface-inset)',
1646
+ overlay: 'var(--volter-surface-overlay)',
1647
+ },
1648
+ boundary: {
1649
+ default: 'var(--volter-boundary-default)',
1650
+ strong: 'var(--volter-boundary-strong)',
1651
+ },
1652
+ content: {
1653
+ primary: 'var(--volter-content-primary)',
1654
+ muted: 'var(--volter-content-muted)',
1655
+ dim: 'var(--volter-content-dim)',
1656
+ onAccent: 'var(--volter-content-on-accent)',
1657
+ /** The ACTIVE subject's own ink; EMPTY under a palette that names none,
1658
+ * so a site reading it must write its own fallback. */
1659
+ active: 'var(--volter-content-active)',
1660
+ /** The SELECTED-but-not-active subject's ink; EMPTY the same way, and the
1661
+ * emitter already falls it back to {@link active}, so a palette naming
1662
+ * only an active ink resolves this to that. (The fallback is in the
1663
+ * EMITTER and not in a `var(…, …)` default, because every variable is
1664
+ * always emitted — as `''` when absent — so a CSS fallback would never
1665
+ * fire.) */
1666
+ selected: 'var(--volter-content-selected)',
1667
+ },
1668
+ accent: {
1669
+ default: 'var(--volter-accent)',
1670
+ muted: 'var(--volter-accent-muted)',
1671
+ },
1672
+ selection: {
1673
+ background: 'var(--volter-selection-bg)',
1674
+ border: 'var(--volter-selection-border)',
1675
+ indicator: 'var(--volter-selection-indicator)',
1676
+ },
1677
+ semantic: {
1678
+ danger: 'var(--volter-danger)',
1679
+ dangerMuted: 'var(--volter-danger-muted)',
1680
+ dangerFaint: 'var(--volter-danger-faint)',
1681
+ warning: 'var(--volter-warn)',
1682
+ warningMuted: 'var(--volter-warn-muted)',
1683
+ success: 'var(--volter-success)',
1684
+ successMuted: 'var(--volter-success-muted)',
1685
+ dynamic: 'var(--volter-dynamic)',
1686
+ dynamicMuted: 'var(--volter-dynamic-muted)',
1687
+ instance: 'var(--volter-instance)',
1688
+ },
1689
+ neutralOverlay: {
1690
+ hover: 'var(--volter-neutral-hover)',
1691
+ active: 'var(--volter-neutral-active)',
1692
+ },
1693
+ scrim: 'var(--volter-scrim)',
1694
+ /** The 3D viewport's DOM-drawn furniture. Its WebGL-drawn colours are read off the same
1695
+ * emitted tokens by `native-selection-style.ts`, since a canvas cannot consume a variable. */
1696
+ viewport: {
1697
+ /** The box-select rectangle: never empty, the accent where the palette names none. */
1698
+ marqueeLine: 'var(--volter-viewport-marquee-line)',
1699
+ marqueeFill: 'var(--volter-viewport-marquee-fill)',
1700
+ /** Between the outline's dashes; `transparent` where the palette names none. */
1701
+ marqueeGap: 'var(--volter-viewport-marquee-gap)',
1702
+ },
1703
+ typography: {
1704
+ sans: 'var(--volter-font-sans)',
1705
+ mono: 'var(--volter-font-mono)',
1706
+ },
1707
+ shape: {
1708
+ small: 'var(--volter-radius-sm)',
1709
+ medium: 'var(--volter-radius-md)',
1710
+ large: 'var(--volter-radius-lg)',
1711
+ full: 'var(--volter-radius-full)',
1712
+ },
1713
+ elevation: {
1714
+ small: 'var(--volter-shadow-sm)',
1715
+ medium: 'var(--volter-shadow-md)',
1716
+ large: 'var(--volter-shadow-lg)',
1717
+ },
1718
+ focusRing: 'var(--volter-focus-ring)',
1719
+ } as const;
1720
+
1721
+ export type EditorThemeVariable = `--volter-${string}`;
1722
+
1723
+ /** Theme-derived native-select artwork; data URIs cannot inherit CSS `color`. */
1724
+ function chevronDataUri(color: string): string {
1725
+ const svg =
1726
+ `<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'>` +
1727
+ `<path d='M1 1L5 5L9 1' stroke='${color}' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/>` +
1728
+ `</svg>`;
1729
+ return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
1730
+ }
1731
+
1732
+ /**
1733
+ * Compose the treatment vocabulary's `backdrop-filter` value: `blur(..px)
1734
+ * saturate(..)` when either axis is set, else `'none'`. Treatment-less
1735
+ * themes (graphite, classic, and the `-lite` reduced-transparency tiers)
1736
+ * emit `'none'` — no `[data-volter-theme=…]` CSS special-casing anywhere;
1737
+ * this function is the single place a theme's treatment becomes a real
1738
+ * filter string.
1739
+ */
1740
+ function backdropFilterValue(treatment: EditorTheme['treatment']): string {
1741
+ if (!treatment) return 'none';
1742
+ const parts: string[] = [];
1743
+ if (treatment.backdropBlurPx) parts.push(`blur(${treatment.backdropBlurPx}px)`);
1744
+ if (treatment.backdropSaturation !== undefined && treatment.backdropSaturation !== 1) {
1745
+ parts.push(`saturate(${treatment.backdropSaturation})`);
1746
+ }
1747
+ if (treatment.backdropBrightness !== undefined && treatment.backdropBrightness !== 1) {
1748
+ parts.push(`brightness(${treatment.backdropBrightness})`);
1749
+ }
1750
+ return parts.length > 0 ? parts.join(' ') : 'none';
1751
+ }
1752
+
1753
+ /**
1754
+ * Structural header surfaces (`--volter-surface-sticky`/`-panel`). Glass
1755
+ * themes leave title/table rails transparent: the owner-reopened acceptance
1756
+ * explicitly reserves frost for significant text floors, not headers.
1757
+ * Classic themes retain the old occluding layered stack. Reduced Glass keeps
1758
+ * Glass morphology even though it intentionally has no optical treatment.
1759
+ */
1760
+ function stickySurfaceValue(
1761
+ token: '--volter-surface-chrome' | '--volter-surface-panel',
1762
+ glassMaterial: boolean,
1763
+ ): string {
1764
+ if (glassMaterial) return 'transparent';
1765
+ return (
1766
+ `linear-gradient(var(${token}), var(${token})), ` +
1767
+ `linear-gradient(var(--volter-surface-shell), var(--volter-surface-shell))`
1768
+ );
1769
+ }
1770
+
1771
+ /**
1772
+ * Content-zone frost (`--volter-content-frost-filter`/`-bg`, §2.31 P2 owner
1773
+ * amendment): the local frosted-blur layer behind text-dense interior zones.
1774
+ * Filter reuses the theme's own saturation term so the frost and the plain
1775
+ * surface treatment cannot drift; bg is a fixed light tint — NEVER a dark
1776
+ * fill (the rejected tinted-frost model). `none`/`transparent` when the
1777
+ * axis is 0/absent, so non-frost themes paint byte-identically.
1778
+ */
1779
+ function contentFrostFilterValue(treatment: EditorTheme['treatment']): string {
1780
+ const blurPx = treatment?.contentFrostBlurPx ?? 0;
1781
+ if (blurPx <= 0) return 'none';
1782
+ const parts = [`blur(${blurPx}px)`];
1783
+ if (treatment?.backdropSaturation !== undefined && treatment.backdropSaturation !== 1) {
1784
+ parts.push(`saturate(${treatment.backdropSaturation})`);
1785
+ }
1786
+ return parts.join(' ');
1787
+ }
1788
+
1789
+ function contentFrostBgValue(treatment: EditorTheme['treatment']): string {
1790
+ const blurPx = treatment?.contentFrostBlurPx ?? 0;
1791
+ return blurPx > 0 ? 'rgba(255,255,255,0.10)' : 'transparent';
1792
+ }
1793
+
1794
+ /** Text-entry wells are thin overlays within a glass surface, never nested
1795
+ * lenses. `currentColor` makes the translucent wash and rim follow the
1796
+ * adaptive light/dark ink flip; Classic retains the established opaque inset
1797
+ * recipe. Reduced Glass is safe because its parent surfaces are solid. */
1798
+ function inputSurfaceValue(glassMaterial: boolean): string {
1799
+ // A text field is its own WIDGET CLASS (Blender's `wcol_text`, measured
1800
+ // #1c1c1c), which is why this reads `--volter-widget-field` rather than
1801
+ // `--volter-surface-inset` directly — that token resolves to `surface.inset`
1802
+ // for every palette that does not name the class, so this is the same value
1803
+ // it has always been.
1804
+ return glassMaterial
1805
+ ? 'color-mix(in srgb, currentColor 7%, transparent)'
1806
+ : 'var(--volter-widget-field)';
1807
+ }
1808
+
1809
+ function inputBorderValue(glassMaterial: boolean): string {
1810
+ return glassMaterial
1811
+ ? 'color-mix(in srgb, currentColor 26%, transparent)'
1812
+ : 'var(--volter-boundary-default)';
1813
+ }
1814
+
1815
+ function inputShadowValue(glassMaterial: boolean, embossShadow: string): string {
1816
+ // A text field is a WIDGET, so it takes the widget emboss
1817
+ // (`EditorTheme.color.widget.emboss`) like every other one. It composes
1818
+ // here rather than in the four stylesheet rules that read this token
1819
+ // because `none` is not a legal member of a `box-shadow` LIST: a rule
1820
+ // written `var(--volter-widget-emboss-shadow), var(--volter-input-shadow)`
1821
+ // would be invalid for every palette that names neither.
1822
+ const emboss = embossShadow === 'none' ? '' : embossShadow;
1823
+ const glass = glassMaterial
1824
+ ? 'inset 0 1px 0 color-mix(in srgb, currentColor 14%, transparent), 0 5px 14px -12px color-mix(in srgb, currentColor 55%, transparent)'
1825
+ : '';
1826
+ const layers = [emboss, glass].filter(Boolean);
1827
+ return layers.length === 0 ? 'none' : layers.join(', ');
1828
+ }
1829
+
1830
+ /**
1831
+ * Liquid Glass Tier-1 calibration anchors (report §2.24): the rgba/px
1832
+ * constants inside the two functions below ARE the committed prototype's
1833
+ * variant-B recipe (`docs/assets/glass-ui-feasibility/liquid-glass-proto/
1834
+ * index.html`, `.card.specular`), and the axes are normalized so
1835
+ * `edgeSpecular` 0.6 / `thickness` 0.5 reproduce that look exactly — the
1836
+ * values the owner reviewed. Everything here is inert data → CSS custom
1837
+ * properties; the pixels are painted by `workspace-dock.css`'s card-chrome
1838
+ * pseudo-elements, with NO added backdrop-filter and NO runtime JS.
1839
+ */
1840
+ const SPECULAR_REFERENCE = 0.6;
1841
+ const THICKNESS_REFERENCE = 0.5;
1842
+
1843
+ /** White at a prototype-B-anchored alpha: `base` × (edgeSpecular ÷ 0.6), clamped to 1. */
1844
+ function specularWhite(base: number, edgeSpecular: number): string {
1845
+ const alpha = Math.min(1, Number(((base * edgeSpecular) / SPECULAR_REFERENCE).toFixed(3)));
1846
+ return `rgba(255,255,255,${alpha})`;
1847
+ }
1848
+
1849
+ /**
1850
+ * The 1.5px edge ring's gradient (`--volter-card-specular-ring`): brightest at
1851
+ * the light-facing corner, falling to a faint trace on the far side, so the
1852
+ * rim reads as light catching a polished edge rather than a drawn border.
1853
+ * `'none'` when the treatment carries no specular — the ring pseudo-element
1854
+ * then paints nothing, which is the whole degrade story.
1855
+ */
1856
+ function specularRingValue(treatment: EditorTheme['treatment']): string {
1857
+ const intensity = treatment?.edgeSpecular ?? 0;
1858
+ if (intensity <= 0) return 'none';
1859
+ const angle = treatment?.specularAngleDeg ?? 120;
1860
+ const white = (base: number) => specularWhite(base, intensity);
1861
+ return (
1862
+ `linear-gradient(${angle}deg, ${white(0.95)} 0%, ${white(0.38)} 16%, ` +
1863
+ `${white(0.16)} 40%, ${white(0.14)} 60%, ${white(0.45)} 84%, ${white(0.85)} 100%)`
1864
+ );
1865
+ }
1866
+
1867
+ /**
1868
+ * The interior sheen wash (`--volter-card-specular-sheen`): a soft diagonal
1869
+ * gradient from the lit corner across ~40% of the card, painted BELOW card
1870
+ * content (negative-z pseudo) so text never sits on a brightened band edge.
1871
+ */
1872
+ function specularSheenValue(treatment: EditorTheme['treatment']): string {
1873
+ const intensity = treatment?.edgeSpecular ?? 0;
1874
+ if (intensity <= 0) return 'none';
1875
+ const angle = treatment?.specularAngleDeg ?? 120;
1876
+ const white = (base: number) => specularWhite(base, intensity);
1877
+ return `linear-gradient(${angle}deg, ${white(0.16)} 0%, ${white(0.05)} 24%, transparent 40%)`;
1878
+ }
1879
+
1880
+ /**
1881
+ * The card's full box-shadow stack (`--volter-card-specular-shadow`):
1882
+ * `edgeSpecular` contributes the 1px inset rim catches; `thickness` scales
1883
+ * the glass-depth pair (upper inner glow + lower inner shadow) plus the
1884
+ * matching drop shadow, replacing the theme's stock elevation. Either axis
1885
+ * absent → its terms drop out; both absent → exactly the pre-existing
1886
+ * `var(--volter-shadow-lg)` card shadow, so treatment-less themes and the
1887
+ * reduced-transparency fallback paint byte-identical cards.
1888
+ */
1889
+ function specularShadowValue(treatment: EditorTheme['treatment']): string {
1890
+ const intensity = treatment?.edgeSpecular ?? 0;
1891
+ const thickness = treatment?.thickness ?? 0;
1892
+ const parts: string[] = [];
1893
+ if (intensity > 0) {
1894
+ parts.push(`inset 0 1px 1px ${specularWhite(0.28, intensity)}`);
1895
+ parts.push(`inset 0 -1px 1px ${specularWhite(0.1, intensity)}`);
1896
+ }
1897
+ if (thickness > 0) {
1898
+ const depth = (px: number) =>
1899
+ `${Number(((px * thickness) / THICKNESS_REFERENCE).toFixed(1))}px`;
1900
+ parts.push(`inset 0 ${depth(12)} ${depth(24)} ${depth(-14)} rgba(255,255,255,0.35)`);
1901
+ parts.push(`inset 0 ${depth(-14)} ${depth(28)} ${depth(-18)} rgba(0,0,0,0.55)`);
1902
+ parts.push(`0 ${depth(18)} ${depth(40)} ${depth(-18)} rgba(0,0,0,0.55)`);
1903
+ } else {
1904
+ parts.push('var(--volter-shadow-lg)');
1905
+ }
1906
+ return parts.join(', ');
1907
+ }
1908
+
1909
+ /**
1910
+ * Floating glass-island chrome (`--volter-island-*`, P6 glass-native chrome
1911
+ * U1/U2): free-standing chrome that floats over the canvas —
1912
+ * `.volter-floating-toolbar` (the viewport toolstrip and its siblings), and
1913
+ * the header/footer clusters in later P6 units. A theme WITH a glass
1914
+ * treatment paints islands as REAL glass, the same material as cards: the
1915
+ * panel-whisper fill (never an opaque or color-mix slab), the theme's own
1916
+ * plain backdrop list (upgraded per-island to the url() refraction chain by
1917
+ * `components/glass-refraction.ts` where capable — the var here is the
1918
+ * degrade fallback that paints when refraction can't), the P6 demo
1919
+ * toolbar's 17px radius, the card border (the specular ring supplies the
1920
+ * rim), and the deep drop shadow a clear surface needs to read as "above"
1921
+ * the scene. A Classic theme emits EXACTLY the pre-P6
1922
+ * floating-toolbar recipe — 90% panel color-mix over transparent, blur(8px),
1923
+ * strong boundary, small shadow, the medium radius token — so
1924
+ * graphite/classic paint byte-identically to the pre-island editor. Reduced
1925
+ * Glass keeps the Glass shape/boundary/shadow while its filter becomes none.
1926
+ */
1927
+ /** Resolve material identity without making treatment presence do two jobs. */
1928
+ export function usesGlassMaterial(theme: EditorTheme): boolean {
1929
+ if (theme.appearance) return theme.appearance.material === 'glass';
1930
+ // Compatibility for standalone pre-axis themes, which declare no
1931
+ // `appearance` axis at all.
1932
+ return theme.treatment !== undefined;
1933
+ }
1934
+
1935
+ function islandSurfaceValue(theme: EditorTheme): string {
1936
+ return usesGlassMaterial(theme)
1937
+ ? 'var(--volter-surface-panel)'
1938
+ : 'color-mix(in srgb, var(--volter-surface-panel) 90%, transparent)';
1939
+ }
1940
+
1941
+ function islandBackdropFilterValue(theme: EditorTheme): string {
1942
+ // Referencing the surface var (not re-deriving from treatment data) means
1943
+ // the island fallback and the card plain list cannot drift — and the
1944
+ // refraction manager parses that same var for its in-chain frost/sat.
1945
+ if (usesGlassMaterial(theme)) {
1946
+ return theme.treatment ? 'var(--volter-surface-backdrop-filter)' : 'none';
1947
+ }
1948
+ return 'blur(8px)';
1949
+ }
1950
+
1951
+ function islandRadiusValue(glassMaterial: boolean): string {
1952
+ // 17px is the P6 reference demo toolbar's committed radius — a design
1953
+ // constant of the glass chrome, not a shape token.
1954
+ return glassMaterial ? '17px' : 'var(--volter-radius-md)';
1955
+ }
1956
+
1957
+ function islandBorderColorValue(glassMaterial: boolean): string {
1958
+ return glassMaterial ? 'var(--volter-boundary-default)' : 'var(--volter-boundary-strong)';
1959
+ }
1960
+
1961
+ function islandShadowValue(glassMaterial: boolean): string {
1962
+ return glassMaterial ? 'var(--volter-shadow-lg)' : 'var(--volter-shadow-sm)';
1963
+ }
1964
+
1965
+ function accentAlphaValue(theme: EditorTheme, alpha: number): string | null {
1966
+ const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(theme.color.accent.default);
1967
+ if (!m) return null;
1968
+ const [r, g, b] = [m[1], m[2], m[3]].map((c) => Number.parseInt(c as string, 16));
1969
+ return `rgba(${r},${g},${b},${alpha})`;
1970
+ }
1971
+
1972
+ /**
1973
+ * Selection is material state, not brand/action state. Glass and its
1974
+ * reduced-transparency descendants use neutral currentColor mixes so the
1975
+ * selection adapts with Liquid Glass's bright/dark content ramp. Opaque
1976
+ * Classic themes retain their established accent selection treatment.
1977
+ */
1978
+ function usesNeutralSelection(theme: EditorTheme): boolean {
1979
+ return usesGlassMaterial(theme);
1980
+ }
1981
+
1982
+ function selectionBackgroundValue(theme: EditorTheme): string {
1983
+ return usesNeutralSelection(theme) ? 'var(--volter-neutral-active)' : 'var(--volter-accent-muted)';
1984
+ }
1985
+
1986
+ /**
1987
+ * Whether a palette colour PAINTS ON ITS OWN, or is a wash that lets the
1988
+ * surface beneath show through.
1989
+ *
1990
+ * The pressed ink depends on nothing else: "white on the accent" only reads
1991
+ * when the accent is actually there.
1992
+ */
1993
+ function isOpaqueColor(value: string): boolean {
1994
+ const v = value.trim().toLowerCase();
1995
+ if (v.startsWith('#')) {
1996
+ // #rgba and #rrggbbaa carry the alpha in the last 1 or 2 digits.
1997
+ if (v.length === 5) return v.endsWith('f');
1998
+ if (v.length === 9) return v.endsWith('ff');
1999
+ return true;
2000
+ }
2001
+ const fn = /^rgba?\(([^)]*)\)$/.exec(v) ?? /^hsla?\(([^)]*)\)$/.exec(v);
2002
+ if (fn) {
2003
+ const alpha = (fn[1] ?? '').split(/[,/]/)[3];
2004
+ return alpha === undefined || Number.parseFloat(alpha) >= 1;
2005
+ }
2006
+ // `transparent`, and anything that mixes toward it, is not a paint.
2007
+ return v !== 'transparent' && !v.includes('transparent');
2008
+ }
2009
+
2010
+ /**
2011
+ * Whether a surface colour is an opaque LIGHT paint (relative luminance above
2012
+ * one half) — the fact a derivation that sinks toward black has to know before
2013
+ * it can be right. A translucent surface (every Glass material's panel is a
2014
+ * low-alpha white over whatever lies beneath) is not a paint and answers
2015
+ * `false`, as does any form this does not read: the dark case most palettes
2016
+ * are.
2017
+ */
2018
+ function isBrightSurface(value: string): boolean {
2019
+ if (!isOpaqueColor(value)) return false;
2020
+ const v = value.trim().toLowerCase();
2021
+ let rgb: number[] | null = null;
2022
+ const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})(?:f|ff)?$/.exec(v);
2023
+ if (hex) {
2024
+ const h = hex[1]!.length === 3 ? [...hex[1]!].map((c) => c + c).join('') : hex[1]!;
2025
+ rgb = [0, 2, 4].map((i) => Number.parseInt(h.slice(i, i + 2), 16));
2026
+ } else {
2027
+ const fn = /^rgba?\(([^)]*)\)$/.exec(v);
2028
+ if (fn) rgb = (fn[1] ?? '').split(/[,\s/]+/).filter(Boolean).slice(0, 3).map((n) => (n.endsWith('%') ? Number.parseFloat(n) * 2.55 : Number.parseFloat(n)));
2029
+ }
2030
+ if (!rgb || rgb.some((n) => !Number.isFinite(n))) return false;
2031
+ const [r, g, b] = rgb as [number, number, number];
2032
+ return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255 > 0.5;
2033
+ }
2034
+
2035
+ /**
2036
+ * The ink that reads ON `--volter-selection-bg` — and it is DERIVED, because
2037
+ * the fill it has to read on is different per skin.
2038
+ *
2039
+ * Blender's pressed fill is the solid accent (`accent.muted` = `#4772b3`),
2040
+ * so its ink is the palette's on-accent white. Every other skin's pressed
2041
+ * fill is a WASH (`rgba(87,158,255,0.15)` and siblings) over the ordinary
2042
+ * panel, so the ordinary ink is what reads there — and the on-accent ink,
2043
+ * which is near-BLACK in those palettes because their accent is light, is
2044
+ * invisible on it. Measured live through `editor.document.query`'s resolved
2045
+ * styles: Classic painted `rgb(16,24,32)` on `rgba(87,158,255,0.15)`, which
2046
+ * is how a pressed eye toggle became an unreadable black smudge while the
2047
+ * same control read white in Blender.
2048
+ */
2049
+
2050
+ function selectionInkValue(theme: EditorTheme): string {
2051
+ if (usesNeutralSelection(theme)) return 'var(--volter-content-primary)';
2052
+ return isOpaqueColor(theme.color.accent.muted)
2053
+ ? 'var(--volter-content-on-accent)'
2054
+ : 'var(--volter-content-primary)';
2055
+ }
2056
+
2057
+ function selectionBorderValue(theme: EditorTheme): string {
2058
+ if (usesNeutralSelection(theme)) return 'color-mix(in srgb, currentColor 30%, transparent)';
2059
+ // On a LIGHT panel the accent is the ink, and an ink ring round every pressed
2060
+ // control is a hard black box; the selection there is the fill alone, so the
2061
+ // border is the fill's own colour.
2062
+ if (isBrightSurface(theme.color.surface.panel)) return `rgb(from ${theme.color.accent.muted} r g b / 1)`;
2063
+ return 'var(--volter-accent)';
2064
+ }
2065
+
2066
+ function selectionIndicatorValue(theme: EditorTheme): string {
2067
+ return usesNeutralSelection(theme)
2068
+ ? 'color-mix(in srgb, currentColor 76%, transparent)'
2069
+ : 'var(--volter-accent)';
2070
+ }
2071
+
2072
+ /**
2073
+ * Controls INSIDE glass chrome are concentric capsules (the HIG
2074
+ * concentricity rule: nested radii follow the container's pill), and the
2075
+ * ONE prominent action per toolbar is a TINTED-GLASS capsule — accent as a
2076
+ * translucent tint in the material, never an opaque painted slab. Opaque
2077
+ * themes keep the flat design's small radius and solid accent.
2078
+ */
2079
+ function islandControlRadiusValue(glassMaterial: boolean): string {
2080
+ return glassMaterial ? 'var(--volter-radius-full)' : 'var(--volter-radius-sm)';
2081
+ }
2082
+
2083
+ function islandPrimaryBgValue(theme: EditorTheme): string {
2084
+ if (!usesGlassMaterial(theme) || !theme.treatment) return 'var(--volter-accent)';
2085
+ return accentAlphaValue(theme, 0.82) ?? 'var(--volter-accent)';
2086
+ }
2087
+
2088
+ /**
2089
+ * Islands wear a DIMMER specular ring than cards (owner design review,
2090
+ * spec "Design-review round" item 1): chrome pills sit close to content
2091
+ * and at card intensity the rim reads as a drawn border, not caught light.
2092
+ * 0.4× the card's edgeSpecular, same gradient profile. 'none' without a
2093
+ * specular treatment — the ring pseudo then paints nothing (the degrade).
2094
+ */
2095
+ function islandSpecularRingValue(treatment: EditorTheme['treatment']): string {
2096
+ const intensity = treatment?.edgeSpecular ?? 0;
2097
+ if (intensity <= 0) return 'none';
2098
+ return specularRingValue({
2099
+ ...(treatment as NonNullable<EditorTheme['treatment']>),
2100
+ edgeSpecular: intensity * 0.4,
2101
+ });
2102
+ }
2103
+
2104
+ /**
2105
+ * Faint ambient lift (`--volter-ambient-lift`, P6-U6 taste decision 2): a
2106
+ * top-lit white wash — full alpha at the top edge, ~a third at the
2107
+ * midline, gone by the bottom — layered over the translucent color fill
2108
+ * (an extra background-image layer) on cards and islands, so clear glass
2109
+ * reads as a lit surface over a black void. The
2110
+ * 165° angle matches the specular key-light family (upper-left-ish);
2111
+ * geometry is a design constant, the treatment axis is intensity only.
2112
+ * 'none' without the axis — the extra background layer then paints
2113
+ * nothing and treatment-less themes stay byte-identical.
2114
+ */
2115
+ function ambientLiftValue(treatment: EditorTheme['treatment']): string {
2116
+ const alpha = treatment?.ambientLiftOpacity ?? 0;
2117
+ if (alpha <= 0) return 'none';
2118
+ const top = Number(alpha.toFixed(3));
2119
+ const mid = Number((alpha * 0.35).toFixed(3));
2120
+ return `linear-gradient(165deg, rgba(255,255,255,${top}) 0%, rgba(255,255,255,${mid}) 55%, rgba(255,255,255,0) 100%)`;
2121
+ }
2122
+
2123
+ /**
2124
+ * Content-legibility text shadow (`--volter-content-text-shadow`, §2.32):
2125
+ * `none` for every theme without the axis, so pre-existing themes paint
2126
+ * byte-identically; the liquid-glass value is the demo's proven
2127
+ * `0 1px 2px rgba(0,0,0,0.72)`.
2128
+ */
2129
+ function textShadowFromOpacity(opacity: number): string {
2130
+ if (opacity <= 0) return 'none';
2131
+ return `0 1px 2px rgba(0,0,0,${Number(opacity.toFixed(3))})`;
2132
+ }
2133
+
2134
+ function contentTextShadowValue(treatment: EditorTheme['treatment']): string {
2135
+ return textShadowFromOpacity(treatment?.textShadowOpacity ?? 0);
2136
+ }
2137
+
2138
+ /**
2139
+ * Adaptive bright-backdrop content ramp (P3): the `--volter-content-*-on-bright`
2140
+ * values `theme.css` flips to under a measured `data-volter-backdrop="bright"`
2141
+ * classification. Themes without adaptive material physics emit their normal
2142
+ * content values here — the values must be LITERALS (mirroring, not
2143
+ * `var(--volter-content-…)` references), or the CSS flip
2144
+ * `--volter-content-primary: var(--volter-content-primary-on-bright)` would be a
2145
+ * self-referential cycle; mirroring makes the flip inert instead. The
2146
+ * bright-side text shadow defaults to `none` only when the axis exists
2147
+ * (dark ink needs no shadow); without the axis it mirrors the normal shadow.
2148
+ */
2149
+ function contentOnBrightValues(theme: EditorTheme): {
2150
+ primary: string;
2151
+ muted: string;
2152
+ dim: string;
2153
+ textShadow: string;
2154
+ frostBg: string;
2155
+ } {
2156
+ if (!theme.treatment?.adaptiveContent) {
2157
+ return {
2158
+ primary: theme.color.content.primary,
2159
+ muted: theme.color.content.muted,
2160
+ dim: theme.color.content.dim,
2161
+ textShadow: contentTextShadowValue(theme.treatment),
2162
+ frostBg: contentFrostBgValue(theme.treatment),
2163
+ };
2164
+ }
2165
+ return {
2166
+ primary: theme.color.content.onBright.primary,
2167
+ muted: theme.color.content.onBright.muted,
2168
+ dim: theme.color.content.onBright.dim,
2169
+ textShadow: textShadowFromOpacity(theme.treatment.brightTextShadowOpacity ?? 0),
2170
+ frostBg: theme.treatment.brightFrostBg ?? contentFrostBgValue(theme.treatment),
2171
+ };
2172
+ }
2173
+
2174
+ /**
2175
+ * REGULAR Liquid Glass (menus / popovers / dialogs / palette / tooltips —
2176
+ * `.volter-menu` / `.volter-popover` / `.volter-dialog` in theme.css; P3 overlay
2177
+ * chrome formalized as `--volter-glass-regular-*` in P6-U6 per the owner's
2178
+ * taste decision): Apple's REGULAR variant is the "more solid" material —
2179
+ * adaptive, self-legible, used for transient chrome that must obscure
2180
+ * whatever it covers; Clear is the refractive card/island family, and the
2181
+ * two are never mixed within an element class. Transient surfaces are NOT
2182
+ * backdrop-classified (sampling them is overkill — they live for a click),
2183
+ * so the material is SELF-SUFFICIENT on both luminance sides:
2184
+ * - adaptive themes (liquid-glass): a raised light tint over a
2185
+ * strong frost, statically paired (in theme.css) with the `-on-bright`
2186
+ * dark ink ramp — legible over ANY backdrop, Apple's own light-material
2187
+ * menu answer (the P3 recipe, unchanged).
2188
+ * - treatment themes WITHOUT the axis (glass-dark): the SAME solidity on
2189
+ * the dark side — the theme's own chrome tone raised to high opacity
2190
+ * over the same strong frost, not the previous thin translucency.
2191
+ * - treatment-less themes mirror the plain chrome surface / `none`, so the
2192
+ * theme.css consumption is inert and they paint byte-identically.
2193
+ */
2194
+ function glassRegularBgValue(theme: EditorTheme): string {
2195
+ if (!theme.treatment) return theme.color.surface.chrome;
2196
+ // Raised LIGHT tint (never a dark fill — §2.31): strong enough that the
2197
+ // dark ink ramp holds ≥4.5:1 even over a black backdrop after compositing.
2198
+ // U6.5 F7 — floored 0.62 → 0.9 (the dark-side floor, symmetric): Regular
2199
+ // menus must OBSCURE what they cover, and the 0.62 tint let underlying
2200
+ // chrome read through at near-full contrast (audit shot 03 — hierarchy
2201
+ // rows through the View menu; menus opened inside a header island are
2202
+ // backdrop-root-captured, so their frost blur cannot reach the page
2203
+ // behind and the fill alone must carry the occlusion).
2204
+ if (theme.treatment.adaptiveContent) return 'rgba(255,255,255,0.9)';
2205
+ // Dark-side Regular: the theme's chrome tone, alpha floored at 0.9 so the
2206
+ // material reads solid (colors it covers may glow through the frost, never
2207
+ // read through the fill).
2208
+ return withMinimumAlpha(theme.color.surface.chrome, 0.9);
2209
+ }
2210
+
2211
+ /**
2212
+ * REGULAR Liquid Glass for persistent, text-heavy PANELS. This is the same
2213
+ * functional material as transient Regular glass, but it must retain enough
2214
+ * scene continuity to work as a permanent inspector/hierarchy surface rather
2215
+ * than an opaque menu. The base is the theme's faint panel presence; sampled
2216
+ * backdrop luminance then supplies a complementary, low-alpha correction.
2217
+ * Dark scenes are lifted and bright scenes are dimmed without replacing
2218
+ * either with a white card. Strong body frost removes high-frequency detail.
2219
+ *
2220
+ * Themes without the adaptive bright-side ramp retain their authored panel
2221
+ * surface unchanged. Plain themes also receive no filter, so opting a panel
2222
+ * into the class is inert for Classic and reduced-transparency modes.
2223
+ */
2224
+ function glassRegularPanelBgValue(theme: EditorTheme): string {
2225
+ return theme.color.surface.panel;
2226
+ }
2227
+
2228
+ /** Raise an `rgba(r,g,b,a)` color's alpha to at least `minAlpha`; any other
2229
+ * color syntax is already effectively solid and passes through unchanged. */
2230
+ function withMinimumAlpha(color: string, minAlpha: number): string {
2231
+ const m = /^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([0-9.]+)\s*\)$/.exec(color);
2232
+ if (!m) return color;
2233
+ const alpha = Math.max(Number(m[4]), minAlpha);
2234
+ return `rgba(${m[1]},${m[2]},${m[3]},${alpha})`;
2235
+ }
2236
+
2237
+ function glassRegularBackdropFilterValue(treatment: EditorTheme['treatment']): string {
2238
+ if (!treatment) return 'none';
2239
+ // Double the content frost (floor 16px): a menu floats over UNFROSTED
2240
+ // scene regions, so it needs more blur than an in-panel text zone to
2241
+ // average high-frequency backdrops into a stable field.
2242
+ // Regular overlays are text-heavy surfaces, so they use the same authored
2243
+ // frost tier rather than inventing a doubled second tier.
2244
+ const blurPx = Math.max(16, treatment.contentFrostBlurPx ?? 0);
2245
+ const parts = [`blur(${blurPx}px)`];
2246
+ if (treatment.backdropSaturation !== undefined && treatment.backdropSaturation !== 1) {
2247
+ parts.push(`saturate(${treatment.backdropSaturation})`);
2248
+ }
2249
+ return parts.join(' ');
2250
+ }
2251
+
2252
+ /**
2253
+ * The AREA seam's colour. A palette that names none paints the boundary its
2254
+ * call sites already read, so every skin but Blender is pixel-identical.
2255
+ */
2256
+ function areaBoundaryValue(theme: EditorTheme): string {
2257
+ return theme.color.boundary.area ?? theme.color.boundary.default;
2258
+ }
2259
+
2260
+ /**
2261
+ * A tree row's ALTERNATE band — a step off whatever AREA FILL the tree sits on,
2262
+ * stated once here because two places must produce it and a duplicated
2263
+ * percentage drifts. `editorThemeVariables` emits it against `surface.panel`
2264
+ * (the fill every unclaimed group has); the workspace host re-emits
2265
+ * it on a group whose panel CLAIMS a region, against that region's fill.
2266
+ *
2267
+ * It cannot simply read an inherited `--volter-surface-region`: a custom
2268
+ * property's `var()` references are substituted at computed-value time ON THE
2269
+ * ELEMENT THAT DECLARES IT, so a root-declared expression would have baked in
2270
+ * the root's panel fill before any descendant could override it — measured, and
2271
+ * it is why the Outliner's rows stepped ELEVEN levels after `color.region`
2272
+ * landed instead of Blender's three (#323232 on #272727 where the frame shows
2273
+ * #2a2a2a).
2274
+ *
2275
+ * The solve, against `modeling.png` at 2x: A = the area fill, B = `content.primary`
2276
+ * 0xe6=230, target = A + 3. For the Outliner's 0x27=39 that is 42, and
2277
+ * p = (230-42)/(230-39) = 188/191 = 98.43% — the same 98.4% the panel-fill solve
2278
+ * gives, because the step is three levels either way.
2279
+ */
2280
+ export function regionRowAltValue(areaFill: string, ink: string): string {
2281
+ return `color-mix(in srgb, ${areaFill} 98.4%, ${ink})`;
2282
+ }
2283
+
2284
+ /**
2285
+ * THE AREA EMBOSS — the one CSS px of light Blender puts on each side of the
2286
+ * dark area groove. It is not a colour a palette names: it is the area's OWN
2287
+ * FILL lightened, which is why a bright viewport gets a bright line and a dark
2288
+ * Outliner a dim one, and why one number (`chrome.areaEmboss`, percent of
2289
+ * white) covers every area. `0` returns `transparent` — nothing painted, which
2290
+ * is byte-identical to having no rule at all.
2291
+ *
2292
+ * SOLVED, not chosen, against `modeling.png` at its native 2x. Each pair is
2293
+ * the area's flat fill and the light band beside the groove, read as whole
2294
+ * pixel runs (the emboss is 2 device px = 1 CSS px):
2295
+ *
2296
+ * fill emboss coordinates
2297
+ * Properties #2f2f2f = 47 → 64 x=3200, y 426..427 (below the
2298
+ * Outliner↔Properties groove)
2299
+ * Outliner #272727 = 39 → 57/56 y=300, x 2843..2844 (right of the
2300
+ * viewport↔right-column groove)
2301
+ * tab rail #171717 = 23 → 43/42 y=1500, x 2843..2844 (same groove,
2302
+ * lower down, where the Properties
2303
+ * tab rail is the neighbour)
2304
+ * row-alt #2a2a2a = 42 → 59/60 x=3200, y 417..418
2305
+ *
2306
+ * `p = (out - fill) / (255 - fill)` for those four is 0.0817, 0.0810/0.0841,
2307
+ * 0.0862/0.0822 — mean 8.14%, and 8.2% is the value that renders all four
2308
+ * measured integers (42.0, 56.7, 59.5, 64.1 → 42, 57, 59/60, 64).
2309
+ *
2310
+ * THE PIXELS CONTRADICTED THE BRIEF ON THE FIFTH PAIR, and this is the
2311
+ * correction: the viewport's band measures 63 → 93/94 (y 2055..2056 at the
2312
+ * status bar, x 4..5 at the window's left edge, x 2834 at the sash) and the
2313
+ * 3D View's own header 52 → 86 (y 53..54 under the top bar) — both nearly
2314
+ * DOUBLE the lift the other four share, and a single mix cannot produce that
2315
+ * (a fixed colour at a fixed alpha must compress the fills' spread, and these
2316
+ * expand it: 34 levels of output across 11 levels of input). Applying the SAME
2317
+ * 8.2% TWICE does: 63 → 78.7 → 93.2 (measured 93/94) and 52 → 68.6 → 83.9
2318
+ * (measured 86). Both doubled areas are the 3D View's — its main region
2319
+ * overlaps its area, so the region's emboss lands on the area's — and the
2320
+ * single-pass value shows through where the two passes do not align
2321
+ * (x=2835 reads 78 beside x=2834's 94, against a predicted 78.7). So this
2322
+ * is ONE number applied once by an ordinary area and twice by the stage,
2323
+ * which is Blender's own structure, not a second constant.
2324
+ */
2325
+ export function areaEmbossValue(areaFill: string): string {
2326
+ return `color-mix(in srgb, var(--volter-area-emboss-mix), ${areaFill})`;
2327
+ }
2328
+
2329
+ /**
2330
+ * The light and its weight, as the one token every {@link areaEmbossValue}
2331
+ * expression mixes in. A skin that declares no `chrome.areaEmboss` gets
2332
+ * `transparent 100%`, so every emboss expression — root, group, nested —
2333
+ * computes to a fully transparent colour and paints nothing whatsoever.
2334
+ * Emitted as a PAIR because the whole point of the indirection is that a
2335
+ * group's own expression (the workspace host's) can be written
2336
+ * without reading the theme object.
2337
+ */
2338
+ function areaEmbossMixValue(theme: Pick<EditorTheme, 'density'>): string {
2339
+ const amount = density(theme).chrome.areaEmboss;
2340
+ return amount > 0 ? `#ffffff ${amount}%` : 'transparent 100%';
2341
+ }
2342
+
2343
+ /** Map one typed theme to the semantic variables consumed by editor chrome. */
2344
+ export function editorThemeVariables(theme: EditorTheme): Record<EditorThemeVariable, string> {
2345
+ const onBright = contentOnBrightValues(theme);
2346
+ const glassMaterial = usesGlassMaterial(theme);
2347
+ // The WIDGET classes, resolved once: a palette that names none reads the
2348
+ // surface each call site read before the group existed, so every token below
2349
+ // is the same string it used to be and no skin moves. See
2350
+ // `EditorTheme.color.widget` for the measurements.
2351
+ const widgetRegular = theme.color.widget?.regular ?? theme.color.surface.raised;
2352
+ const widgetMenu = theme.color.widget?.menu ?? theme.color.surface.raised;
2353
+ const widgetField = theme.color.widget?.field ?? theme.color.surface.inset;
2354
+ // The emboss is stated once, as a whole `box-shadow` value rather than as a
2355
+ // colour, so the 1 px offset lives in ONE place and a palette that names no
2356
+ // emboss resolves to the literal `none` — which is what keeps every other
2357
+ // look byte-identical, and what lets a rule COMPOSE it into an existing
2358
+ // shadow list (`none` cannot appear inside one).
2359
+ const widgetEmbossShadow = theme.color.widget?.emboss
2360
+ ? `0 1px 0 ${theme.color.widget.emboss}`
2361
+ : 'none';
2362
+ // Hover LIFTS a widget by a step from ITS OWN fill, recomputed per class so
2363
+ // a palette that moves a class's fill gets a hover that still belongs to
2364
+ // it. THIS IS THE ONLY STATEMENT OF THE HOVER RELATIONSHIP — keep it that
2365
+ // way: a hover surface stated twice is a number that gets corrected in one
2366
+ // place and left wrong in the one that paints.
2367
+ //
2368
+ // The percentage is SOLVED, not chosen. `color-mix(in srgb, A p%, B)` on
2369
+ // opaque colours is `A*p + B*(1-p)`, so `p = (B - target)/(B - A)`; with
2370
+ // Blender's `widget.regular` 0x53=83 as A, `content.primary` 0xe6=230 as B
2371
+ // and the hover target 0x65=101, p = 129/147 = 87.755%. At the 86% this
2372
+ // was, `--volter-widget-regular-hover` rendered #686868.
2373
+ //
2374
+ // HONEST LABEL: #656565 is the only number here that is NOT measurable
2375
+ // from the reference frames — none of the eighteen captures
2376
+ // holds a widget under the
2377
+ // pointer, and a search of every frame for a #656565 REGION finds only
2378
+ // scattered viewport-gradient pixels. It is the target this file's own
2379
+ // docblock has carried; the solve against it is exact, the target is
2380
+ // inherited.
2381
+ const lift = (fill: string) =>
2382
+ `color-mix(in srgb, ${fill} 87.8%, ${theme.color.content.primary})`;
2383
+ // The AREA HEADER's fill, stated once here because two variables need it —
2384
+ // `--volter-surface-header` paints it and `--volter-area-emboss-stage-header`
2385
+ // lightens it. The solve is documented with the other derived surfaces below.
2386
+ const surfaceHeader = `color-mix(in srgb, ${theme.color.surface.panel} 86.5%, ${theme.color.surface.raised})`;
2387
+ return {
2388
+ '--volter-surface-shell': theme.color.surface.shell,
2389
+ '--volter-surface-panel': theme.color.surface.panel,
2390
+ '--volter-surface-chrome': theme.color.surface.chrome,
2391
+ '--volter-surface-raised': theme.color.surface.raised,
2392
+ '--volter-surface-inset': theme.color.surface.inset,
2393
+ '--volter-surface-overlay': theme.color.surface.overlay,
2394
+ '--volter-surface-backdrop-filter': backdropFilterValue(theme.treatment),
2395
+ '--volter-surface-sticky': stickySurfaceValue('--volter-surface-chrome', glassMaterial),
2396
+ '--volter-surface-sticky-panel': stickySurfaceValue('--volter-surface-panel', glassMaterial),
2397
+ '--volter-surface-sticky-backdrop-filter': 'none',
2398
+ // Optical body frost is independent from nested content-floor paint.
2399
+ // The refraction host reads this stable token even when a Regular panel
2400
+ // locally disables `--volter-content-frost-filter` to enforce one filter.
2401
+ '--volter-surface-body-frost-filter': contentFrostFilterValue(theme.treatment),
2402
+ '--volter-content-frost-filter': contentFrostFilterValue(theme.treatment),
2403
+ '--volter-content-frost-bg': contentFrostBgValue(theme.treatment),
2404
+ '--volter-input-surface': inputSurfaceValue(glassMaterial),
2405
+ '--volter-input-border': inputBorderValue(glassMaterial),
2406
+ '--volter-input-shadow': inputShadowValue(glassMaterial, widgetEmbossShadow),
2407
+ '--volter-structural-divider': glassMaterial ? 'transparent' : theme.color.boundary.default,
2408
+ '--volter-card-specular-ring': specularRingValue(theme.treatment),
2409
+ '--volter-card-specular-sheen': specularSheenValue(theme.treatment),
2410
+ '--volter-card-specular-shadow': specularShadowValue(theme.treatment),
2411
+ // Liquid Glass refraction (report §2.31/§2.32): unitless numbers,
2412
+ // consumed by the surface host's refraction manager
2413
+ // (`components/glass-refraction.ts`) via getComputedStyle — never by
2414
+ // any CSS rule. Thickness '0' (no refraction) for treatment-less themes
2415
+ // keeps the degrade ladder data-driven: the reduced-transparency paint
2416
+ // path strips `treatment`, which zeroes this var, which removes every
2417
+ // per-surface url() filter automatically.
2418
+ '--volter-card-refraction-thickness': String(theme.treatment?.refractionThickness ?? 0),
2419
+ '--volter-card-refraction-bezel': String(theme.treatment?.refractionBezelPx ?? 28),
2420
+ '--volter-card-refraction-angle': String(theme.treatment?.specularAngleDeg ?? 120),
2421
+ // Floating glass-island chrome (P6 U1/U2): real-glass material for
2422
+ // treatment themes, the exact pre-P6 floating-toolbar recipe otherwise
2423
+ // (see the island*Value functions above for the ladder rationale).
2424
+ '--volter-ambient-lift': ambientLiftValue(theme.treatment),
2425
+ '--volter-island-surface': islandSurfaceValue(theme),
2426
+ '--volter-island-backdrop-filter': islandBackdropFilterValue(theme),
2427
+ '--volter-island-radius': islandRadiusValue(glassMaterial),
2428
+ '--volter-island-border-color': islandBorderColorValue(glassMaterial),
2429
+ '--volter-island-shadow': islandShadowValue(glassMaterial),
2430
+ '--volter-island-specular-ring': islandSpecularRingValue(theme.treatment),
2431
+ '--volter-selection-bg': selectionBackgroundValue(theme),
2432
+ '--volter-content-on-selection': selectionInkValue(theme),
2433
+ '--volter-selection-border': selectionBorderValue(theme),
2434
+ '--volter-selection-indicator': selectionIndicatorValue(theme),
2435
+ '--volter-island-control-radius': islandControlRadiusValue(glassMaterial),
2436
+ '--volter-island-primary-bg': islandPrimaryBgValue(theme),
2437
+ '--volter-content-text-shadow': contentTextShadowValue(theme.treatment),
2438
+ // Adaptive bright-backdrop ramp (P3): consumed only by theme.css's
2439
+ // `[data-volter-backdrop="bright"]` / Regular-glass scopes. Themes without
2440
+ // adaptive material physics mirror their normal values here, so those
2441
+ // scopes are inert for them. `--volter-content-adaptive` is the unitless
2442
+ // classifier gate (components/backdrop-luminance.ts reads it via
2443
+ // getComputedStyle, same idiom as `--volter-card-refraction-thickness`):
2444
+ // '0' means no surface is ever classified — today's dark default.
2445
+ '--volter-content-primary-on-bright': onBright.primary,
2446
+ '--volter-content-muted-on-bright': onBright.muted,
2447
+ '--volter-content-dim-on-bright': onBright.dim,
2448
+ '--volter-content-text-shadow-on-bright': onBright.textShadow,
2449
+ '--volter-content-frost-bg-on-bright': onBright.frostBg,
2450
+ '--volter-content-adaptive': theme.treatment?.adaptiveContent ? '1' : '0',
2451
+ '--volter-glass-regular-bg': glassRegularBgValue(theme),
2452
+ '--volter-glass-regular-backdrop-filter': glassRegularBackdropFilterValue(theme.treatment),
2453
+ '--volter-glass-regular-panel-bg': glassRegularPanelBgValue(theme),
2454
+ '--volter-glass-regular-panel-backdrop-filter': glassRegularBackdropFilterValue(theme.treatment),
2455
+ '--volter-glass-dark-frost-panel-bg': 'rgba(5,8,13,0.74)',
2456
+ '--volter-glass-dark-frost-content-bg': 'rgba(5,8,13,0.84)',
2457
+ // Modal scrim (P6-U6 owner taste decision 3): glass themes read like
2458
+ // macOS — a LIGHTER dim plus a blur doing the separation work; opaque
2459
+ // themes keep their heavy dim and `none`, byte-identically.
2460
+ '--volter-scrim-backdrop-filter': theme.treatment ? 'blur(8px)' : 'none',
2461
+ '--volter-boundary-default': theme.color.boundary.default,
2462
+ '--volter-boundary-strong': theme.color.boundary.strong,
2463
+ // The AREA seam. Never emitted empty — a seam that stops being painted
2464
+ // fuses two areas into one field — so an absent member resolves to the
2465
+ // boundary its call sites already read (`EditorTheme.color.boundary.area`).
2466
+ '--volter-boundary-area': areaBoundaryValue(theme),
2467
+ // THE TREE'S INDENT GUIDE (`color.boundary.indent`). Emitted EMPTY —
2468
+ // `transparent` — when the palette names none, because no skin but
2469
+ // Blender's drew one and a fallback to any other boundary would put a rule
2470
+ // into every tree in the editor. `.volter-tree-indent-guide` paints this and
2471
+ // nothing else, so absent means the guide's spans are there and invisible.
2472
+ '--volter-tree-indent-guide': theme.color.boundary.indent ?? 'transparent',
2473
+ // THE DIVIDER RULE (`color.boundary.divider`). Same optional shape, a
2474
+ // different fallback for a stated reason: no skin drew an indent guide, so
2475
+ // absent means `transparent` above; every skin draws this rule, so absent
2476
+ // means the boundary it already drew.
2477
+ '--volter-boundary-divider': theme.color.boundary.divider ?? theme.color.boundary.default,
2478
+ // THE EMBOSS beside that groove (`areaEmbossValue`). Three values because
2479
+ // three fills meet a groove: an ordinary docked area (a group with no
2480
+ // region claim takes `surface.panel`; one that claims a region re-emits
2481
+ // this against its own fill in the workspace host, the same
2482
+ // way `--volter-surface-row-alt` is re-emitted there and for the same
2483
+ // substitution reason), and the STAGE area, whose sides are the viewport's
2484
+ // fill and whose top edge is its own header band — both of them lifted
2485
+ // TWICE, which is what the 3D View's overlapping region does in Blender.
2486
+ '--volter-area-emboss-mix': areaEmbossMixValue(theme),
2487
+ '--volter-area-emboss': areaEmbossValue(theme.color.surface.panel),
2488
+ '--volter-area-emboss-stage': areaEmbossValue(
2489
+ areaEmbossValue(theme.color.viewport?.background ?? theme.color.surface.panel),
2490
+ ),
2491
+ '--volter-area-emboss-stage-header': areaEmbossValue(areaEmbossValue(surfaceHeader)),
2492
+ '--volter-content-primary': theme.color.content.primary,
2493
+ '--volter-content-muted': theme.color.content.muted,
2494
+ '--volter-content-dim': theme.color.content.dim,
2495
+ '--volter-content-on-accent': theme.color.content.onAccent,
2496
+ // THE MENU WORD (`color.content.menu`). Falls back in the EMITTER — not in
2497
+ // a `var(…, …)` default, for the reason `content.selected` states: every
2498
+ // variable here is always emitted, so a CSS fallback would never fire.
2499
+ // The fallback is `content.primary` because that is what the top bar's
2500
+ // menu words already paint, so a palette naming no menu ink leaves that
2501
+ // bar bit-identical; the area header's words, which read `content.muted`
2502
+ // only because nothing had overridden the base rule, converge onto it.
2503
+ '--volter-content-menu': theme.color.content.menu ?? theme.color.content.primary,
2504
+ // THE STATUS BAND'S INK and THE PLACEHOLDER'S, both falling back to
2505
+ // `content.dim` in the EMITTER for the reason above. The band already
2506
+ // inherited dim, so its fallback is bit-identical; the placeholder had no
2507
+ // rule at all and inherited CHROME's fixed rgb(117,117,117), so a palette
2508
+ // naming none moves onto the palette's own quiet ink — which is the whole
2509
+ // point of the member (see its docblock).
2510
+ '--volter-content-status': theme.color.content.status ?? theme.color.content.dim,
2511
+ '--volter-content-placeholder': theme.color.content.placeholder ?? theme.color.content.dim,
2512
+ // Emitted EMPTY when the palette names none — the `viewport` group's
2513
+ // answer. Its one reader inherits the row's ink through the fallback.
2514
+ '--volter-content-active': theme.color.content.active ?? '',
2515
+ '--volter-content-selected': theme.color.content.selected ?? theme.color.content.active ?? '',
2516
+ '--volter-accent': theme.color.accent.default,
2517
+ '--volter-accent-muted': theme.color.accent.muted,
2518
+ '--volter-danger': theme.color.semantic.danger,
2519
+ '--volter-danger-muted': theme.color.semantic.dangerMuted,
2520
+ '--volter-danger-faint': theme.color.semantic.dangerFaint,
2521
+ '--volter-warn': theme.color.semantic.warning,
2522
+ '--volter-warn-muted': theme.color.semantic.warningMuted,
2523
+ '--volter-success': theme.color.semantic.success,
2524
+ '--volter-success-muted': theme.color.semantic.successMuted,
2525
+ '--volter-dynamic': theme.color.semantic.dynamic,
2526
+ '--volter-dynamic-muted': theme.color.semantic.dynamicMuted,
2527
+ '--volter-instance': theme.color.semantic.instance,
2528
+ '--volter-neutral-hover': theme.color.neutralOverlay.hover,
2529
+ '--volter-neutral-active': theme.color.neutralOverlay.active,
2530
+ '--volter-scrim': theme.color.scrim,
2531
+ // DERIVED surfaces — relationships every skin keeps, computed from its own
2532
+ // palette (Blender's measured steps: a panel header is a slab #3c3c3c
2533
+ // over #2f2f2f, an area header #343434, outliner rows alternate by three
2534
+ // levels). The hover step is stated ONCE, by `lift` above, because that
2535
+ // is what paints a hovered widget.
2536
+ //
2537
+ // Each percentage below is SOLVED from that measured target against the
2538
+ // two inputs the expression already mixes, never chosen for roundness:
2539
+ // `color-mix(in srgb, A p%, B)` on opaque colours is the plain sRGB
2540
+ // average `A*p + B*(1-p)`, so `p = (B - target) / (B - A)`. The three
2541
+ // solves, with the Blender palette's own channel values (grey, so one
2542
+ // channel states all three) and the coordinate the target was read at in
2543
+ // the reference frames (1728x1052 factory startup captured at 2x):
2544
+ //
2545
+ // header A=panel 0x2f=47, B=raised 0x54=84, target 0x34=52
2546
+ // p = (84-52)/(84-47) = 32/37 = 86.49% → 86.5%
2547
+ // (modeling.png, the 3D viewport's area header, y 60..108 over
2548
+ // x 300..2700, median 52 — it was 78%, which renders 55.)
2549
+ // section A=panel 0x2f=47, B=raised 0x54=84, target 0x3c=60
2550
+ // p = (84-60)/(84-47) = 24/37 = 64.86% → 64.9%
2551
+ // (properties-object.png at 2x, the Transform panel's card:
2552
+ // row y=380 over x 76..277 and x 495..590 is 60, and the card
2553
+ // header at y=230 is the same 60 — one continuous fill, header
2554
+ // and body. It was 62%, which renders 61, one level off the
2555
+ // value THIS BLOCK'S OWN COMMENT already named.)
2556
+ // s-edge A=panel 0x2f=47, B=raised 0x54=84, target 0x49=73
2557
+ // p = (84-73)/(84-47) = 11/37 = 29.73% → 29.7%
2558
+ // (the same card's 1 CSS px outline: properties-object.png
2559
+ // x 74..75 and x 591..592 over its whole height, y 200..201 and
2560
+ // y 1030 at its ends, all 73. Blender outlines a panel card
2561
+ // LIGHTER than the card; `boundary.default` #3c3c3c is the
2562
+ // card's own fill there and would be invisible.)
2563
+ // rowAlt A=panel 0x2f=47, B=primary 0xe6=230, target = panel + 3 = 50
2564
+ // p = (230-50)/(230-47) = 180/183 = 98.36% → 98.4%
2565
+ // (modeling.png, Outliner rows over x 3300..3420: y 186..225 is
2566
+ // 39 = #272727 and y 226..265 is 42 = #2a2a2a — a THREE-level
2567
+ // alternation on a 40 px (20 CSS px) pitch. It was 96%, which
2568
+ // steps seven.)
2569
+ //
2570
+ // Each is carried to one decimal because the integer next to it misses:
2571
+ // 98% renders 0x33, and `lift`'s 88% renders 0x66.
2572
+ '--volter-surface-section': `color-mix(in srgb, ${theme.color.surface.panel} 64.9%, ${theme.color.surface.raised})`,
2573
+ '--volter-surface-section-edge': `color-mix(in srgb, ${theme.color.surface.panel} 29.7%, ${theme.color.surface.raised})`,
2574
+ '--volter-surface-header': surfaceHeader,
2575
+ '--volter-surface-row-alt': regionRowAltValue(
2576
+ theme.color.surface.panel,
2577
+ theme.color.content.primary,
2578
+ ),
2579
+ // A SELECTED TREE ROW is not an active widget, and there are TWO of them.
2580
+ //
2581
+ // Measured in Blender 5.2, `modeling-object-selected.png` (object mode,
2582
+ // every object selected), Outliner rows at device y 133-252, sampled over a
2583
+ // clean x band (crop-local 440-500 against the `outliner.png` origin
2584
+ // 2843,53). Three row states, three fills:
2585
+ //
2586
+ // unselected #272727 / #2a2a2a — the ordinary stripe
2587
+ // selected, not active #1c304c — (28,48,76)
2588
+ // selected AND active #324c7f — (50,76,127), inside a
2589
+ // 1 px #5a74a7 border
2590
+ //
2591
+ // Both bands are the same 18-of-20 px box: device rows 135-170 and 177-208
2592
+ // inside a 40 px pitch, so ONE ordinary stripe row shows above and below
2593
+ // each. `theme.css`'s `.volter-tree-row` rules paint that inset.
2594
+ //
2595
+ // The derivations, and their residual against the measurement. The palette
2596
+ // carries the accent (#4772b3, Blender's own widget blue) and both bands
2597
+ // are it sunk toward black — the same relation the single band already
2598
+ // used, so a palette that declares nothing new gets a coherent pair:
2599
+ // active 0.70 x #4772b3 = #32507d against #324c7f — green +4, blue -2
2600
+ // selected 0.42 x #4772b3 = #1e304b against #1c304c — red +2, blue -1
2601
+ // Neither band is EXACTLY a scaling of the accent (Blender's are their own
2602
+ // theme entries), and the residual is under five levels on one channel of
2603
+ // each, so no palette token is minted for four levels of green.
2604
+ //
2605
+ // The BORDER is exact and additive: #5a74a7 is the active fill plus 40 on
2606
+ // every channel (50+40, 76+40, 127+40). No `color-mix` expresses a uniform
2607
+ // lift — mixing toward white moves the channels by different amounts — so
2608
+ // this is the one place the stylesheet uses relative colour syntax, which
2609
+ // states the measurement literally and derives for every palette.
2610
+ //
2611
+ // Both derivations sink the accent toward BLACK, which is a statement about
2612
+ // a DARK palette: the band has to sit darker than the stripe it replaces.
2613
+ // On a LIGHT panel (a paper palette) the same arithmetic paints a
2614
+ // near-black band under dark ink. There the selected band is the palette's
2615
+ // own selection wash (`accent.muted`, what a pressed control already wears);
2616
+ // the active row is that colour at full strength, with no ink ring. Every dark palette
2617
+ // computes exactly what it did before.
2618
+ ...(isBrightSurface(theme.color.surface.panel)
2619
+ ? {
2620
+ '--volter-tree-row-selected-bg': theme.color.accent.muted,
2621
+ // Opaque: the active band is painted OVER its hairline layer, so a
2622
+ // wash would let the hairline show through the whole row.
2623
+ '--volter-tree-row-active-bg': `rgb(from ${theme.color.accent.muted} r g b / 1)`,
2624
+ '--volter-tree-row-active-border': `rgb(from ${theme.color.accent.muted} r g b / 1)`,
2625
+ '--volter-tree-datablock-fill': 'color-mix(in srgb, var(--volter-category-data, currentColor) 14%, transparent)',
2626
+ '--volter-tree-datablock-border': 'color-mix(in srgb, var(--volter-content-primary) 14%, transparent)',
2627
+ }
2628
+ : {
2629
+ '--volter-tree-row-selected-bg': `color-mix(in srgb, ${theme.color.accent.default} 42%, #000)`,
2630
+ '--volter-tree-row-active-bg': `color-mix(in srgb, ${theme.color.accent.default} 70%, #000)`,
2631
+ '--volter-tree-row-active-border':
2632
+ 'rgb(from var(--volter-tree-row-active-bg) calc(r + 40) calc(g + 40) calc(b + 40))',
2633
+ // The datablock plate's transcription (theme.css `.volter-tree-datablock`), unchanged.
2634
+ '--volter-tree-datablock-fill': 'color-mix(in srgb, color-mix(in srgb, var(--volter-category-data, currentColor) 60%, black) 26%, transparent)',
2635
+ '--volter-tree-datablock-border': 'color-mix(in srgb, var(--volter-content-primary) 24%, transparent)',
2636
+ }),
2637
+ // WHO GETS THE ROW'S ONE TEXT MARK when a palette declares an ACTIVE ink.
2638
+ // The instance rule (`semantic.instance`, owner 2026-07-31) and the active
2639
+ // object's orange name both want the name, and on the active row only one
2640
+ // can be read: a dotted rule in one hue under a name in another is two
2641
+ // marks arguing. So the rule YIELDS there, and only there — this is `none`
2642
+ // exactly when `content.active` exists and `underline` otherwise, so a
2643
+ // palette that names no active ink re-states what the row already drew and
2644
+ // its instance rows are untouched. Derived rather than declared: the
2645
+ // emitter is the one place that knows what the palette said.
2646
+ '--volter-tree-active-name-underline': theme.color.content.active ? 'none' : 'underline',
2647
+ // The viewport group, empty when the palette carries none (readers treat
2648
+ // an empty token as "the editor's own").
2649
+ '--volter-viewport-background': theme.color.viewport?.background ?? '',
2650
+ '--volter-viewport-grid': theme.color.viewport?.grid ?? '',
2651
+ '--volter-viewport-axis-x': theme.color.viewport?.axisX ?? '',
2652
+ '--volter-viewport-axis-y': theme.color.viewport?.axisY ?? '',
2653
+ '--volter-viewport-axis-z': theme.color.viewport?.axisZ ?? '',
2654
+ '--volter-viewport-wire': theme.color.viewport?.wire ?? '',
2655
+ '--volter-viewport-wire-opacity': numberToken(theme.stage?.wireOpacity),
2656
+ '--volter-viewport-axis-line-width': numberToken(theme.stage?.axisLineWidth),
2657
+ '--volter-viewport-selection': theme.color.viewport?.selection ?? '',
2658
+ '--volter-viewport-active': theme.color.viewport?.active ?? '',
2659
+ // THE BOX-SELECT RECTANGLE. Never empty: a palette that names no marquee draws the band in
2660
+ // its accent, as the band always drew, so the default is derived here (the one place that
2661
+ // knows what the palette said) rather than in a `var(…, …)` fallback, which an emitted
2662
+ // empty token would never reach. Its dash is the look's `stage.marqueeDash`.
2663
+ '--volter-viewport-marquee-line': theme.color.viewport?.marqueeLine ?? theme.color.accent.default,
2664
+ '--volter-viewport-marquee-fill': theme.color.viewport?.marqueeFill ?? theme.color.accent.muted,
2665
+ '--volter-viewport-marquee-gap': theme.color.viewport?.marqueeGap ?? 'transparent',
2666
+ '--volter-viewport-marquee-dash': numberToken(theme.stage?.marqueeDash),
2667
+ '--volter-gizmo-x': theme.color.gizmo?.x ?? '',
2668
+ '--volter-gizmo-y': theme.color.gizmo?.y ?? '',
2669
+ '--volter-gizmo-z': theme.color.gizmo?.z ?? '',
2670
+ '--volter-gizmo-navigation-x': theme.color.gizmo?.navigationX ?? '',
2671
+ '--volter-gizmo-navigation-y': theme.color.gizmo?.navigationY ?? '',
2672
+ '--volter-gizmo-navigation-z': theme.color.gizmo?.navigationZ ?? '',
2673
+ '--volter-gizmo-hover': theme.color.gizmo?.hover ?? '',
2674
+ '--volter-gizmo-drag': theme.color.gizmo?.drag ?? '',
2675
+ '--volter-viewport-gizmo-opacity': numberToken(theme.stage?.gizmoOpacity),
2676
+ '--volter-viewport-gizmo-arrow-length': numberToken(theme.stage?.gizmoArrowLength),
2677
+ '--volter-viewport-gizmo-arrow-head': numberToken(theme.stage?.gizmoArrowHead),
2678
+ '--volter-viewport-gizmo-ring-width': numberToken(theme.stage?.gizmoRingWidth),
2679
+ '--volter-viewport-navigation-gizmo': theme.stage?.navigationGizmo ?? '',
2680
+ '--volter-viewport-navigation-corner': theme.stage?.navigationCorner ?? '',
2681
+ '--volter-viewport-navigation-size': numberToken(theme.stage?.navigationSize),
2682
+ '--volter-viewport-gizmo-highlight-saturation': numberToken(
2683
+ theme.stage?.gizmoHighlightSaturation,
2684
+ ),
2685
+ '--volter-viewport-gizmo-highlight-value': numberToken(theme.stage?.gizmoHighlightValue),
2686
+ // THE TRANSFORM GIZMO'S SCREEN SIZE, in px per gizmo unit, emitted the
2687
+ // same way and read the same way (`native-selection-style.ts`): a look
2688
+ // that names none emits empty, and the viewport keeps three's own
2689
+ // viewport-relative handle. Blender's is `U.gizmo_size` — see
2690
+ // `StageContribution.gizmoSize` for the derivation.
2691
+ '--volter-viewport-gizmo-size':
2692
+ theme.stage?.gizmoSize === undefined ? '' : `${theme.stage.gizmoSize}`,
2693
+ // THE FLOOR GRID'S LINE WIDTHS AND MAJOR CONTRAST, emitted empty when the look states none,
2694
+ // so the stage keeps its own hairline floor (`StageContribution.gridLineWidth`).
2695
+ '--volter-viewport-grid-line-width': numberToken(theme.stage?.gridLineWidth),
2696
+ '--volter-viewport-grid-major-width': numberToken(theme.stage?.gridMajorWidth),
2697
+ '--volter-viewport-grid-major-contrast': numberToken(theme.stage?.gridMajorContrast),
2698
+ '--volter-viewport-selection-box': theme.stage?.selectionBox ?? '',
2699
+ '--volter-viewport-selection-box-frame': theme.stage?.selectionBoxFrame ?? '',
2700
+ '--volter-viewport-outline-style': theme.stage?.outlineStyle ?? '',
2701
+ '--volter-viewport-outline-width': numberToken(theme.stage?.outlineWidth),
2702
+ '--volter-viewport-outline-hidden':
2703
+ theme.stage?.outlineHidden === undefined ? '' : `${theme.stage.outlineHidden}`,
2704
+ '--volter-viewport-selection-box-width': numberToken(theme.stage?.selectionBoxWidth),
2705
+ // THE STAGE'S OWN CHROME (`StageContribution.chrome`), empty when the look places nothing:
2706
+ // the stage keeps the editor's own arrangement then (`nativeViewportChrome`).
2707
+ '--volter-viewport-chrome-bar': theme.stage?.chrome?.bar ?? '',
2708
+ '--volter-viewport-chrome-view-name': theme.stage?.chrome?.viewName ?? '',
2709
+ '--volter-viewport-chrome-tools': theme.stage?.chrome?.tools ?? '',
2710
+ '--volter-viewport-chrome-display': theme.stage?.chrome?.display ?? '',
2711
+ '--volter-viewport-chrome-transform-controls': theme.stage?.chrome?.transformControls ?? '',
2712
+ // THE STAGE'S WORDS (`StageContribution.words`), each empty where the look names none.
2713
+ ...Object.fromEntries(
2714
+ STAGE_WORD_MODES.map((mode) => [`--volter-viewport-word-${mode}`, theme.stage?.words?.shading?.[mode] ?? '']),
2715
+ ),
2716
+ '--volter-viewport-word-helpers': theme.stage?.words?.helpers ?? '',
2717
+ // The widget classes. Unlike `viewport`, these are never emitted empty:
2718
+ // every one paints a control that must stay painted, so an absent group
2719
+ // resolves to the surface that call site already read.
2720
+ '--volter-widget-regular': widgetRegular,
2721
+ '--volter-widget-regular-hover': lift(widgetRegular),
2722
+ '--volter-widget-menu': widgetMenu,
2723
+ '--volter-widget-menu-hover': lift(widgetMenu),
2724
+ '--volter-widget-field': widgetField,
2725
+ '--volter-widget-emboss-shadow': widgetEmbossShadow,
2726
+ // The CATEGORY inks. Emitted as `currentColor` when the palette names
2727
+ // none, which is the whole compatibility story: a toned glyph paints
2728
+ // `var(--volter-category-object, currentColor)`, so under a palette
2729
+ // without the group it paints exactly what a monochrome glyph paints.
2730
+ // The literal is used rather than an empty string (the `viewport` group's
2731
+ // answer) because these tokens are read by a `fill`, where empty is not
2732
+ // a colour and the fallback must therefore be a real one.
2733
+ ...Object.fromEntries(
2734
+ EDITOR_CATEGORY_NAMES.map((name) => [`--volter-category-${name}`, theme.color.category?.[name] ?? 'currentColor']),
2735
+ ),
2736
+ // The REGION fills, a pair per area. Never emitted empty (the `widget`
2737
+ // group's answer, not the `viewport` group's): each falls back to the
2738
+ // surface its call site already reads, so the dock can point a group at
2739
+ // `var(--volter-region-<name>)` unconditionally and a palette that names no
2740
+ // region paints exactly what it painted before.
2741
+ '--volter-region-outliner': theme.color.region?.outliner ?? theme.color.surface.panel,
2742
+ '--volter-region-outliner-header': theme.color.region?.outliner ?? theme.color.surface.chrome,
2743
+ '--volter-region-properties': theme.color.region?.properties ?? theme.color.surface.panel,
2744
+ '--volter-region-properties-header': theme.color.region?.properties ?? theme.color.surface.chrome,
2745
+ '--volter-font-sans': theme.typography.sans,
2746
+ '--volter-font-mono': theme.typography.mono,
2747
+ '--volter-radius-sm': theme.shape.small,
2748
+ '--volter-radius-md': theme.shape.medium,
2749
+ '--volter-radius-lg': theme.shape.large,
2750
+ '--volter-radius-full': theme.shape.full,
2751
+ '--volter-shadow-sm': theme.elevation.small,
2752
+ '--volter-shadow-md': theme.elevation.medium,
2753
+ '--volter-shadow-lg': theme.elevation.large,
2754
+ '--volter-focus-ring': `${strokeWidth.active}px solid ${theme.color.accent.default}`,
2755
+ '--volter-select-chevron': chevronDataUri(theme.color.content.muted),
2756
+ '--volter-space-1': `${space[1]}px`,
2757
+ '--volter-space-2': `${space[2]}px`,
2758
+ '--volter-space-3': `${space[3]}px`,
2759
+ '--volter-space-4': `${space[4]}px`,
2760
+ '--volter-space-5': `${space[5]}px`,
2761
+ '--volter-space-6': `${space[6]}px`,
2762
+ '--volter-space-8': `${space[8]}px`,
2763
+ '--volter-space-10': `${space[10]}px`,
2764
+ '--volter-space-12': `${space[12]}px`,
2765
+ '--volter-command-bar-height': `${density(theme).chrome.commandBar}px`,
2766
+ '--volter-panel-header-height': `${density(theme).chrome.panelHeader}px`,
2767
+ '--volter-local-toolbar-height': `${density(theme).chrome.localToolbar}px`,
2768
+ '--volter-tree-row-height': `${density(theme).chrome.treeRow}px`,
2769
+ '--volter-tree-indent': `${density(theme).chrome.treeIndent}px`,
2770
+ '--volter-status-bar-height': `${density(theme).chrome.statusBar}px`,
2771
+ '--volter-tool-size': `${density(theme).chrome.toolSize}px`,
2772
+ '--volter-tool-width': `${density(theme).chrome.toolWidth}px`,
2773
+ '--volter-tool-gap': `${density(theme).chrome.toolGap}px`,
2774
+ '--volter-area-seam-width': `${density(theme).chrome.areaSeam}px`,
2775
+ '--volter-control-compact-height': `${density(theme).control.compact}px`,
2776
+ '--volter-control-default-height': `${density(theme).control.default}px`,
2777
+ '--volter-control-comfortable-height': `${density(theme).control.comfortable}px`,
2778
+ '--volter-stroke-resting': `${strokeWidth.resting}px`,
2779
+ '--volter-stroke-active': `${strokeWidth.active}px`,
2780
+ '--volter-font-xs': `${density(theme).font.xs}px`,
2781
+ '--volter-font-sm': `${density(theme).font.sm}px`,
2782
+ '--volter-font-base': `${density(theme).font.base}px`,
2783
+ '--volter-font-md': `${density(theme).font.md}px`,
2784
+ '--volter-font-lg': `${density(theme).font.lg}px`,
2785
+ '--volter-font-xl': `${density(theme).font.xl}px`,
2786
+ '--volter-font-2xl': `${density(theme).font['2xl']}px`,
2787
+ '--volter-icon-xs': `${density(theme).icon.xs}px`,
2788
+ '--volter-icon-sm': `${density(theme).icon.sm}px`,
2789
+ '--volter-icon-md': `${density(theme).icon.md}px`,
2790
+ '--volter-icon-lg': `${density(theme).icon.lg}px`,
2791
+ '--volter-icon-xl': `${density(theme).icon.xl}px`,
2792
+ '--volter-icon-2xl': `${density(theme).icon['2xl']}px`,
2793
+ '--volter-font-heading': `${density(theme).font.heading}px`,
2794
+ '--volter-font-weight-regular': String(fontWeight.regular),
2795
+ '--volter-font-weight-semibold': String(fontWeight.semibold),
2796
+ '--volter-font-weight-bold': String(fontWeight.bold),
2797
+ '--volter-leading-tight': String(lineHeight.tight),
2798
+ '--volter-leading-snug': String(lineHeight.snug),
2799
+ '--volter-leading-normal': String(lineHeight.normal),
2800
+ '--volter-leading-relaxed': String(lineHeight.relaxed),
2801
+ '--volter-duration-fast': `${motion.duration.fast}ms`,
2802
+ '--volter-duration-base': `${motion.duration.base}ms`,
2803
+ '--volter-duration-slow': `${motion.duration.slow}ms`,
2804
+ '--volter-ease-standard': motion.easing.standard,
2805
+ '--volter-ease-out': motion.easing.out,
2806
+ '--volter-z-base': String(zIndex.base),
2807
+ '--volter-z-overlay-low': String(zIndex.overlayLow),
2808
+ '--volter-z-sticky': String(zIndex.sticky),
2809
+ '--volter-z-dropdown': String(zIndex.dropdown),
2810
+ '--volter-z-toast': String(zIndex.toast),
2811
+ '--volter-z-modal': String(zIndex.modal),
2812
+ };
2813
+ }
2814
+
2815
+ export const EDITOR_THEME_CLASS = 'volter-editor-theme';
2816
+
2817
+ /** Install or switch a theme on one editor-chrome root. */
2818
+ export function applyEditorTheme(
2819
+ root: HTMLElement,
2820
+ theme: EditorTheme = graphiteDarkEditorTheme,
2821
+ ): void {
2822
+ root.classList.add(EDITOR_THEME_CLASS);
2823
+ root.dataset['volterTheme'] = theme.id;
2824
+ // Chrome mode (P6 glass-native chrome, U3/U4): under the Glass material
2825
+ // there are no header/footer BARS — those surfaces dissolve into floating
2826
+ // glass clusters ("islands", the macOS liquid-glass model). Classic themes
2827
+ // keep bars. Reduced-transparency Glass strips optical treatment but
2828
+ // remains islands. Derived from explicit material identity, never from
2829
+ // theme identity (the theme-id ban holds: CSS scopes on this attribute,
2830
+ // not on [data-volter-theme]).
2831
+ root.dataset['volterChrome'] = usesGlassMaterial(theme) ? 'islands' : 'bars';
2832
+ for (const [name, value] of Object.entries(editorThemeVariables(theme))) {
2833
+ root.style.setProperty(name, value);
2834
+ }
2835
+ }
2836
+
2837
+ /**
2838
+ * Re-exported from `z-index.ts` so callers can import either module for the
2839
+ * full token set. See that file's own doc comment for the scale rationale.
2840
+ */
2841
+ export { zIndex };