@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,140 @@
1
+ import {
2
+ type ButtonHTMLAttributes,
3
+ forwardRef,
4
+ type HTMLAttributes,
5
+ type RefObject,
6
+ useEffect,
7
+ useImperativeHandle,
8
+ useRef,
9
+ } from 'react';
10
+
11
+ function classes(base: string, className?: string): string {
12
+ return className ? `${base} ${className}` : base;
13
+ }
14
+
15
+ export interface MenuProps extends HTMLAttributes<HTMLDivElement> {
16
+ autoFocusFirst?: boolean;
17
+ /** Optional trigger/anchor that counts as part of the menu's dismissal boundary.
18
+ * Portaled menus need this so pressing their trigger closes them once instead
19
+ * of the document-level outside handler closing on pointerdown and the
20
+ * trigger's click immediately reopening them. */
21
+ dismissBoundaryRef?: RefObject<HTMLElement | null>;
22
+ onDismiss?: (reason: MenuDismissReason) => void;
23
+ }
24
+
25
+ export type MenuDismissReason = 'escape' | 'outside';
26
+
27
+ function enabledItems(menu: HTMLDivElement): HTMLElement[] {
28
+ return Array.from(
29
+ menu.querySelectorAll<HTMLElement>(
30
+ '[role="menuitem"]:not(:disabled), [role="menuitemradio"]:not(:disabled), [role="menuitemcheckbox"]:not(:disabled)',
31
+ ),
32
+ );
33
+ }
34
+
35
+ /** Focus a menu's first enabled item. Exported so a portaled owner (AnchoredMenu)
36
+ * can drive autofocus AFTER it unhides: browsers refuse `focus()` inside a
37
+ * `visibility:hidden` subtree and never restore it on unhide, so the passive
38
+ * mount-time effect below fires too early for a measure-then-show menu. */
39
+ export function focusFirstMenuItem(menu: HTMLDivElement): void {
40
+ enabledItems(menu)[0]?.focus();
41
+ }
42
+
43
+ export const Menu = forwardRef<HTMLDivElement, MenuProps>(function Menu(
44
+ {
45
+ className,
46
+ role = 'menu',
47
+ autoFocusFirst = false,
48
+ dismissBoundaryRef,
49
+ onDismiss,
50
+ onKeyDown,
51
+ ...props
52
+ },
53
+ ref,
54
+ ) {
55
+ const localRef = useRef<HTMLDivElement>(null);
56
+ useImperativeHandle(ref, () => localRef.current as HTMLDivElement);
57
+ useEffect(() => {
58
+ if (autoFocusFirst) enabledItems(localRef.current as HTMLDivElement)[0]?.focus();
59
+ }, [autoFocusFirst]);
60
+ useEffect(() => {
61
+ if (!onDismiss) return;
62
+ const dismissOutside = (event: PointerEvent) => {
63
+ const target = event.target as Node;
64
+ if (!localRef.current?.contains(target) && !dismissBoundaryRef?.current?.contains(target)) {
65
+ onDismiss('outside');
66
+ }
67
+ };
68
+ document.addEventListener('pointerdown', dismissOutside);
69
+ return () => document.removeEventListener('pointerdown', dismissOutside);
70
+ }, [dismissBoundaryRef, onDismiss]);
71
+
72
+ return (
73
+ <div
74
+ {...props}
75
+ ref={localRef}
76
+ role={role}
77
+ className={classes('vgai-menu', className)}
78
+ onKeyDown={(event) => {
79
+ onKeyDown?.(event);
80
+ if (event.defaultPrevented) return;
81
+ if (event.key === 'Escape') {
82
+ event.preventDefault();
83
+ onDismiss?.('escape');
84
+ return;
85
+ }
86
+ if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
87
+ event.preventDefault();
88
+ const items = enabledItems(event.currentTarget);
89
+ if (items.length === 0) return;
90
+ const current = items.indexOf(document.activeElement as HTMLElement);
91
+ if (event.key === 'Home') items[0]?.focus();
92
+ else if (event.key === 'End') items.at(-1)?.focus();
93
+ else if (event.key === 'ArrowDown')
94
+ items[(current + 1 + items.length) % items.length]?.focus();
95
+ else items[(current - 1 + items.length) % items.length]?.focus();
96
+ }}
97
+ />
98
+ );
99
+ });
100
+
101
+ export type MenuItemProps = ButtonHTMLAttributes<HTMLButtonElement> & {
102
+ onSelect?: (() => void) | undefined;
103
+ };
104
+
105
+ export const MenuItem = forwardRef<HTMLButtonElement, MenuItemProps>(function MenuItem(
106
+ { className, type = 'button', role = 'menuitem', onClick, onSelect, ...props },
107
+ ref,
108
+ ) {
109
+ return (
110
+ <button
111
+ {...props}
112
+ ref={ref}
113
+ type={type}
114
+ role={role}
115
+ className={classes('vgai-menu-item', className)}
116
+ onClick={(event) => {
117
+ onClick?.(event);
118
+ if (!event.defaultPrevented) onSelect?.();
119
+ }}
120
+ />
121
+ );
122
+ });
123
+
124
+ export function MenuSeparator(props: HTMLAttributes<HTMLHRElement>) {
125
+ return <hr {...props} className={classes('vgai-menu-separator', props.className)} />;
126
+ }
127
+
128
+ export const MenuTrigger = forwardRef<HTMLButtonElement, ButtonHTMLAttributes<HTMLButtonElement>>(
129
+ function MenuTrigger({ className, type = 'button', ...props }, ref) {
130
+ return (
131
+ <button
132
+ {...props}
133
+ ref={ref}
134
+ type={type}
135
+ aria-haspopup={props['aria-haspopup'] ?? 'menu'}
136
+ className={classes('vgai-menu-trigger', className)}
137
+ />
138
+ );
139
+ },
140
+ );
@@ -0,0 +1,169 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { useInteractiveEditScope } from '../interactive-edit-scope';
3
+ import { TextInput } from './FormControls';
4
+
5
+ interface NumberInputProps {
6
+ value: number | null; // null = mixed (multi-edit)
7
+ onChange: (value: number) => void;
8
+ /**
9
+ * Fires once per discrete edit gesture — a drag's pointerup (with the
10
+ * drag's FINAL value) or a typed edit's blur/Enter — instead of on every
11
+ * intermediate `onChange` call (which still fires on every pointermove
12
+ * during a drag, for callers that want live feedback as the value moves).
13
+ * Optional: existing callers that only wired `onChange` are unaffected. A
14
+ * caller whose `onChange` triggers something expensive per call (a file
15
+ * write, a network POST — see DataPanel's DATA-tab number cells, #3)
16
+ * should commit through THIS instead, so a 2-second scrub fires exactly
17
+ * one commit, not dozens.
18
+ */
19
+ onChangeEnd?: (value: number) => void;
20
+ step?: number;
21
+ /**
22
+ * Decimal places in the resting display. Defaults to 3 — the right read for
23
+ * a transform axis, and a nonsense one for a whole-number tuning field
24
+ * (`ROUNDS 3.000`). A caller that knows its field's granularity passes it;
25
+ * `TuningPanel` derives it from the schema's step.
26
+ */
27
+ precision?: number;
28
+ /** Read-only: no scrub, no typed edit. Optional — existing callers unchanged. */
29
+ disabled?: boolean;
30
+ label?: string;
31
+ labelColor?: string | undefined;
32
+ style?: React.CSSProperties;
33
+ /** Stable test hook (set on the wrapper; the edit <input> is its descendant). */
34
+ testId?: string | undefined;
35
+ /** Hover text for the whole field (a channel's read-only reason, say). */
36
+ title?: string | undefined;
37
+ }
38
+
39
+ export function NumberInput({
40
+ value,
41
+ onChange,
42
+ onChangeEnd,
43
+ step = 0.1,
44
+ precision = 3,
45
+ disabled = false,
46
+ label,
47
+ labelColor,
48
+ style,
49
+ testId,
50
+ title,
51
+ }: NumberInputProps) {
52
+ const [editing, setEditing] = useState(false);
53
+ const [text, setText] = useState('');
54
+ const dragRef = useRef({ startX: 0, startVal: 0, dragging: false, gesture: false, lastVal: 0 });
55
+ const inputRef = useRef<HTMLInputElement>(null);
56
+ const editScope = useInteractiveEditScope();
57
+
58
+ useEffect(() => {
59
+ if (!editing) return;
60
+ const input = inputRef.current;
61
+ if (!input) return;
62
+ input.focus();
63
+ // THE WHOLE VALUE IS SELECTED WHEN THE EDITOR OPENS, so typing REPLACES
64
+ // it — every number field in every DCC tool. Focused without a
65
+ // selection, a typed number was spliced into the old text and committed
66
+ // as garbage: click the field showing -5, type -7, Enter → -47;
67
+ // double-click, type -9 → -459 (measured on the GPU instrument,
68
+ // 2026-09-03). That is the "everything's there, just not where I wanted
69
+ // it" and "it jumped" of runhuman passes 122 and 127 — the author typed
70
+ // one number and the field committed another.
71
+ input.select();
72
+ }, [editing]);
73
+
74
+ const isMixed = value === null;
75
+ const numValue = value ?? 0;
76
+ const display = isMixed
77
+ ? '—'
78
+ : Number.isFinite(numValue)
79
+ ? numValue.toFixed(precision)
80
+ : (0).toFixed(precision);
81
+
82
+ const handlePointerDown = (e: React.PointerEvent) => {
83
+ if (disabled) return;
84
+ dragRef.current = {
85
+ startX: e.clientX,
86
+ startVal: numValue,
87
+ dragging: false,
88
+ gesture: false,
89
+ lastVal: numValue,
90
+ };
91
+ const onMove = (me: PointerEvent) => {
92
+ const dx = me.clientX - dragRef.current.startX;
93
+ if (Math.abs(dx) > 3) dragRef.current.dragging = true;
94
+ if (dragRef.current.dragging) {
95
+ // Coalesce the whole scrub into a single undo step: open the gesture
96
+ // just before the first value change (so the snapshot is pre-drag).
97
+ if (!dragRef.current.gesture) {
98
+ dragRef.current.gesture = true;
99
+ editScope?.begin();
100
+ }
101
+ const newVal = dragRef.current.startVal + dx * step;
102
+ const rounded = Math.round(newVal / step) * step;
103
+ dragRef.current.lastVal = rounded;
104
+ onChange(rounded);
105
+ }
106
+ };
107
+ const onUp = () => {
108
+ window.removeEventListener('pointermove', onMove);
109
+ window.removeEventListener('pointerup', onUp);
110
+ if (dragRef.current.gesture) editScope?.end();
111
+ if (dragRef.current.dragging) {
112
+ // One commit for the whole gesture (#3) — `onChange` above already
113
+ // fired per pointermove for callers that want live feedback; this is
114
+ // the single "the user is done" signal, with the drag's final value.
115
+ onChangeEnd?.(dragRef.current.lastVal);
116
+ } else {
117
+ setEditing(true);
118
+ setText(isMixed ? '' : display);
119
+ }
120
+ };
121
+ window.addEventListener('pointermove', onMove);
122
+ window.addEventListener('pointerup', onUp);
123
+ };
124
+
125
+ const commitEdit = () => {
126
+ setEditing(false);
127
+ const n = Number.parseFloat(text);
128
+ if (Number.isFinite(n)) {
129
+ onChange(n);
130
+ onChangeEnd?.(n);
131
+ }
132
+ };
133
+
134
+ return (
135
+ <div className="vgai-number-input" data-testid={testId} title={title} style={style}>
136
+ {label && (
137
+ <span
138
+ className="vgai-number-input-label"
139
+ style={labelColor ? { color: labelColor } : undefined}
140
+ >
141
+ {label}
142
+ </span>
143
+ )}
144
+ {editing ? (
145
+ <TextInput
146
+ ref={inputRef}
147
+ value={text}
148
+ onChange={(e) => setText(e.target.value)}
149
+ onBlur={commitEdit}
150
+ onKeyDown={(e) => {
151
+ if (e.key === 'Enter') commitEdit();
152
+ if (e.key === 'Escape') setEditing(false);
153
+ }}
154
+ placeholder={isMixed ? '—' : undefined}
155
+ className="vgai-number-input-editor"
156
+ />
157
+ ) : (
158
+ <div
159
+ onPointerDown={handlePointerDown}
160
+ className="vgai-number-input-scrub"
161
+ data-mixed={isMixed || undefined}
162
+ data-disabled={disabled || undefined}
163
+ >
164
+ {display}
165
+ </div>
166
+ )}
167
+ </div>
168
+ );
169
+ }
@@ -0,0 +1,80 @@
1
+ import type React from 'react';
2
+ import { space } from '../theme';
3
+ import { panelHeaderStyle } from './panel-header-styles';
4
+
5
+ interface PanelOwnProps {
6
+ name: React.ReactNode;
7
+ actions?: React.ReactNode;
8
+ onHeaderClick?: () => void;
9
+ /** The dock host already names permanent panels in its tab strip. Hide the
10
+ * legacy embedded header there to avoid two consecutive titles for one
11
+ * surface while retaining Panel's content/layout behavior. */
12
+ hideHeader?: boolean;
13
+ }
14
+
15
+ type PanelProps = PanelOwnProps &
16
+ Omit<React.HTMLAttributes<HTMLDivElement>, keyof PanelOwnProps> & {
17
+ /** React 19 ref-as-prop, forwarded onto the panel's own scroll/root div. */
18
+ ref?: React.Ref<HTMLDivElement>;
19
+ };
20
+
21
+ export function Panel({
22
+ name,
23
+ actions,
24
+ onHeaderClick,
25
+ hideHeader = false,
26
+ style,
27
+ children,
28
+ ...rest
29
+ }: PanelProps) {
30
+ return (
31
+ <div
32
+ {...rest}
33
+ style={{
34
+ // No surface fill of its own (§2.31 transparent-interiors wave): the
35
+ // hosting dock group / floating card layer carries the themed panel
36
+ // surface. A Panel that floats free of any painted surface opts into
37
+ // one locally via `style` (see the design-system stories).
38
+ display: 'flex',
39
+ flexDirection: 'column',
40
+ pointerEvents: 'auto',
41
+ ...style,
42
+ }}
43
+ >
44
+ {!hideHeader && (
45
+ <div
46
+ role={onHeaderClick ? 'button' : undefined}
47
+ tabIndex={onHeaderClick ? 0 : undefined}
48
+ onClick={onHeaderClick}
49
+ onKeyDown={
50
+ onHeaderClick
51
+ ? (e) => {
52
+ if (e.key === 'Enter') onHeaderClick();
53
+ }
54
+ : undefined
55
+ }
56
+ style={{
57
+ ...panelHeaderStyle,
58
+ cursor: onHeaderClick ? 'pointer' : undefined,
59
+ pointerEvents: 'auto',
60
+ }}
61
+ >
62
+ <span style={{ flexShrink: 0 }}>{name}</span>
63
+ {actions && (
64
+ <div
65
+ style={{
66
+ marginLeft: 'auto',
67
+ display: 'flex',
68
+ alignItems: 'center',
69
+ gap: space[3],
70
+ }}
71
+ >
72
+ {actions}
73
+ </div>
74
+ )}
75
+ </div>
76
+ )}
77
+ {children}
78
+ </div>
79
+ );
80
+ }
@@ -0,0 +1,77 @@
1
+ import type { IconDefinition } from '@fortawesome/free-solid-svg-icons';
2
+ import { type ReactNode, useState } from 'react';
3
+ import { DisclosureIcon, EditorIcon } from './EditorIcon';
4
+
5
+ interface SectionHeaderProps {
6
+ label: string;
7
+ /** The section's glyph, beside the disclosure arrow. Every inspection
8
+ * section carries one (`inspection/model.ts` makes `icon` required), so a
9
+ * section reads the same in the stacked column as it does on the compact
10
+ * card's icon strip. */
11
+ icon?: IconDefinition;
12
+ description?: ReactNode;
13
+ actions?: ReactNode;
14
+ /**
15
+ * `false` heads the section without a disclosure control and keeps it open.
16
+ * Collapsing is what a STACK is for — hiding one block to reach the next.
17
+ * Where a single section is showing because you pressed its tab, a triangle
18
+ * offers to hide the only thing in the box (owner, 2026-08-07: "in small
19
+ * mode there's no reason for a collapsible triangle on the section name").
20
+ */
21
+ collapsible?: boolean;
22
+ defaultOpen?: boolean;
23
+ /* The disclosure's size is `--vgai-section-disclosure-size` where a surface
24
+ * sets one, and `--vgai-icon-xs` — `DisclosureIcon`'s own default rung —
25
+ * where none does, so a host that declares nothing renders exactly what it
26
+ * rendered before. The one declarer is the PROPERTIES presentation
27
+ * (`compact-inspector.css`), where Blender's band mark measures 5.5x9.5 CSS
28
+ * against the 4x6 this rung draws. */
29
+ children: ReactNode;
30
+ }
31
+
32
+ export function SectionHeader({
33
+ label,
34
+ icon,
35
+ description,
36
+ actions,
37
+ collapsible = true,
38
+ defaultOpen = true,
39
+ children,
40
+ }: SectionHeaderProps) {
41
+ const [open, setOpen] = useState(defaultOpen);
42
+ const title = (
43
+ <span className="vgai-section-title">
44
+ <span>{label}</span>
45
+ {description ? <small>{description}</small> : null}
46
+ </span>
47
+ );
48
+
49
+ return (
50
+ <section className="vgai-section">
51
+ <div className="vgai-section-header">
52
+ {collapsible ? (
53
+ <button
54
+ type="button"
55
+ className="vgai-section-trigger"
56
+ aria-expanded={open}
57
+ onClick={() => setOpen(!open)}
58
+ >
59
+ <DisclosureIcon
60
+ direction={open ? 'down' : 'right'}
61
+ style={{ fontSize: 'var(--vgai-section-disclosure-size, var(--vgai-icon-xs))' }}
62
+ />
63
+ {icon && <EditorIcon icon={icon} tone="dim" className="vgai-section-glyph" />}
64
+ {title}
65
+ </button>
66
+ ) : (
67
+ <div className="vgai-section-trigger" data-vgai-static>
68
+ {icon && <EditorIcon icon={icon} tone="dim" className="vgai-section-glyph" />}
69
+ {title}
70
+ </div>
71
+ )}
72
+ {actions ? <div className="vgai-section-actions">{actions}</div> : null}
73
+ </div>
74
+ {(open || !collapsible) && <div className="vgai-section-body">{children}</div>}
75
+ </section>
76
+ );
77
+ }
@@ -0,0 +1,54 @@
1
+ import { type ComponentType, type ElementType, forwardRef, type HTMLAttributes } from 'react';
2
+
3
+ export type TextVariant = 'caption' | 'body' | 'label' | 'title' | 'heading' | 'display' | 'code';
4
+ export type TextTone = 'primary' | 'muted' | 'dim' | 'accent' | 'danger' | 'warning' | 'success';
5
+
6
+ export interface TextProps extends HTMLAttributes<HTMLElement> {
7
+ as?: ElementType;
8
+ variant?: TextVariant;
9
+ tone?: TextTone;
10
+ truncate?: boolean;
11
+ selectable?: boolean;
12
+ }
13
+
14
+ function classes(base: string, className?: string): string {
15
+ return className ? `${base} ${className}` : base;
16
+ }
17
+
18
+ /** Semantic editor typography. Product surfaces choose a role, never a px size. */
19
+ export const Text = forwardRef<HTMLElement, TextProps>(function Text(
20
+ {
21
+ as: Component = 'span',
22
+ variant = 'body',
23
+ tone = 'primary',
24
+ truncate = false,
25
+ selectable = false,
26
+ className,
27
+ ...props
28
+ },
29
+ ref,
30
+ ) {
31
+ // `as` is polymorphic, so TS intersects props across EVERY member of
32
+ // `ElementType`. That intersection collapses to `never` the moment the JSX
33
+ // namespace grows — which it now does: the R3F design session pulls
34
+ // `@react-three/fiber`'s `IntrinsicElements` augmentation into the editor's
35
+ // program, and `ref`/`className` stopped being assignable here with no
36
+ // change to this file. Narrow the callee to the prop shape this component
37
+ // actually documents in `TextProps`.
38
+ const Element = Component as ComponentType<Record<string, unknown>>;
39
+ return (
40
+ <Element
41
+ {...props}
42
+ ref={ref}
43
+ data-variant={variant}
44
+ data-tone={tone}
45
+ data-truncate={truncate || undefined}
46
+ data-selectable={selectable || undefined}
47
+ className={classes('vgai-text', className)}
48
+ />
49
+ );
50
+ });
51
+
52
+ export function Keycap({ className, ...props }: HTMLAttributes<HTMLElement>) {
53
+ return <kbd {...props} className={classes('vgai-keycap', className)} />;
54
+ }
@@ -0,0 +1,52 @@
1
+ import { type ReactNode, useId, useLayoutEffect, useRef, useState } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+
4
+ /**
5
+ * Portal children to the nearest `.vgai-editor-theme` root — the same target
6
+ * resolution `Tooltip` uses (`Tooltip.tsx`), extracted for `position: fixed`
7
+ * overlays that open from inside a panel (context menus).
8
+ *
9
+ * Why this exists (Glass-UI spike, W6 — work item 6): `backdrop-filter` (like
10
+ * `filter`/`transform`) makes an element the containing block for ALL
11
+ * positioned descendants, including `position: fixed`. An inline
12
+ * fixed-position menu inside a treated floating card would position relative
13
+ * to the card — at the wrong place — and be clipped by the card's `overflow:
14
+ * hidden`. The theme root carries no filter, so a menu portaled here keeps
15
+ * viewport-correct client coordinates under every workspace layout and
16
+ * theme treatment.
17
+ *
18
+ * THE PORTAL RECORDS WHERE IT CAME FROM, and that is what makes a portaled
19
+ * overlay reachable through the surface that opened it. The anchor span below
20
+ * already sits IN PLACE — it is how the theme root is found — so the two ends
21
+ * carry the same generated id: `data-vgai-portal` where the portal was
22
+ * written, `data-vgai-portal-content` on what it wrote. `editor-document-probe`
23
+ * walks that pair, so `editor.document.click` can drive a menu a DOCUMENT
24
+ * opened without being able to reach one the editor's own chrome opened. Its
25
+ * own header used to say the DOM records no ownership and that any heuristic
26
+ * wide enough would over-reach; this is the ownership, stated rather than
27
+ * guessed. A `display: contents` wrapper generates no box, so nothing about
28
+ * where a `position: fixed` child lands changes.
29
+ */
30
+ export function ThemeRootPortal({ children }: { children: ReactNode }) {
31
+ const anchorRef = useRef<HTMLSpanElement>(null);
32
+ const [portalRoot, setPortalRoot] = useState<Element | null>(null);
33
+ const id = useId();
34
+
35
+ useLayoutEffect(() => {
36
+ setPortalRoot(anchorRef.current?.closest('.vgai-editor-theme') ?? null);
37
+ }, []);
38
+
39
+ return (
40
+ <>
41
+ <span ref={anchorRef} data-vgai-portal={id} style={{ display: 'none' }} />
42
+ {portalRoot
43
+ ? createPortal(
44
+ <div data-vgai-portal-content={id} style={{ display: 'contents' }}>
45
+ {children}
46
+ </div>,
47
+ portalRoot,
48
+ )
49
+ : null}
50
+ </>
51
+ );
52
+ }