@volter/editor-sdk 0.5.57

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