@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,78 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { useInteractiveEditScope } from '../interactive-edit-scope';
3
+ import { ColorSwatchInput, TextInput } from './FormControls';
4
+
5
+ interface ColorInputProps {
6
+ value: string | null;
7
+ onChange: (value: string) => void;
8
+ /** Read-only: swatch and hex field both refuse input. Optional — existing callers unchanged. */
9
+ disabled?: boolean;
10
+ }
11
+
12
+ export function ColorInput({ value, onChange, disabled = false }: ColorInputProps) {
13
+ // Local draft state for the text input — only commits on blur or Enter.
14
+ const [draft, setDraft] = useState(value ?? '');
15
+ const editScope = useInteractiveEditScope();
16
+
17
+ // Sync draft when the external value changes (e.g. undo, selection change).
18
+ useEffect(() => {
19
+ setDraft(value ?? '');
20
+ }, [value]);
21
+
22
+ const commit = () => {
23
+ const trimmed = draft.trim();
24
+ if (trimmed && trimmed !== value) {
25
+ onChange(trimmed);
26
+ }
27
+ };
28
+
29
+ if (value === null) {
30
+ return (
31
+ <div className="vgai-color-input">
32
+ <div className="vgai-color-input-mixed" />
33
+ <TextInput
34
+ // Mixed-value language unified on the em-dash (I-15), matching
35
+ // NumberInput's mixed display.
36
+ placeholder="—"
37
+ disabled={disabled}
38
+ onChange={(e) => {
39
+ if (e.target.value) onChange(e.target.value);
40
+ }}
41
+ data-mixed="true"
42
+ />
43
+ </div>
44
+ );
45
+ }
46
+
47
+ return (
48
+ <div className="vgai-color-input">
49
+ <ColorSwatchInput
50
+ value={value}
51
+ disabled={disabled}
52
+ // The native picker fires onChange continuously while dragging. Coalesce
53
+ // the whole drag into one undo step (beginInteractiveEdit is idempotent);
54
+ // the gesture closes on blur when the picker is dismissed.
55
+ onChange={(e) => {
56
+ editScope?.begin();
57
+ onChange(e.target.value);
58
+ }}
59
+ onBlur={() => editScope?.end()}
60
+ />
61
+ <TextInput
62
+ value={draft}
63
+ disabled={disabled}
64
+ onChange={(e) => setDraft(e.target.value)}
65
+ onBlur={commit}
66
+ onKeyDown={(e) => {
67
+ if (e.key === 'Enter') {
68
+ commit();
69
+ (e.target as HTMLInputElement).blur();
70
+ } else if (e.key === 'Escape') {
71
+ setDraft(value);
72
+ (e.target as HTMLInputElement).blur();
73
+ }
74
+ }}
75
+ />
76
+ </div>
77
+ );
78
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * A text field whose typing is LOCAL until Enter or blur — the discipline
3
+ * `NumberInput` uses for its typed edits, lifted out for every Inspector row
4
+ * whose `onChange` is a SOURCE WRITE.
5
+ *
6
+ * WHY. A source write remounts the world, and a controlled input that
7
+ * commits per keystroke re-renders from the in-flight value under the
8
+ * typist: the second digit of "50" lands on a field that already reverted to
9
+ * "5" (runhuman pass 49, Grid Size "takes half the value"), a deletion in the
10
+ * name field "would not take" (pass 45). Commit once, on Enter/blur; Escape
11
+ * cancels. While not focused the field tracks the live value, so an external
12
+ * change still shows.
13
+ */
14
+
15
+ import { type InputHTMLAttributes, useRef, useState } from 'react';
16
+ import { TextInput } from './FormControls';
17
+
18
+ export interface DraftTextInputProps
19
+ extends Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'onBlur'> {
20
+ /** The live value, shown whenever the field is not being edited. */
21
+ readonly value: string;
22
+ /** Fires ONCE per committed edit (Enter or blur) with the final text. Not
23
+ * called when the text is unchanged. */
24
+ readonly onCommit: (next: string) => void;
25
+ }
26
+
27
+ export function DraftTextInput({
28
+ value,
29
+ onCommit,
30
+ onKeyDown,
31
+ onFocus,
32
+ ...rest
33
+ }: DraftTextInputProps) {
34
+ const [draft, setDraft] = useState<string | null>(null);
35
+ // Escape blurs, and blur commits — the flag lets the cancel win over the
36
+ // stale draft the blur handler still closes over.
37
+ const cancelled = useRef(false);
38
+ const commit = (): void => {
39
+ if (!cancelled.current && draft !== null && draft !== value) onCommit(draft);
40
+ cancelled.current = false;
41
+ setDraft(null);
42
+ };
43
+ return (
44
+ <TextInput
45
+ {...rest}
46
+ value={draft ?? value}
47
+ onFocus={(event) => {
48
+ setDraft(value);
49
+ onFocus?.(event);
50
+ }}
51
+ onChange={(event) => setDraft(event.target.value)}
52
+ onBlur={commit}
53
+ onKeyDown={(event) => {
54
+ if (event.key === 'Enter') (event.target as HTMLInputElement).blur();
55
+ if (event.key === 'Escape') {
56
+ cancelled.current = true;
57
+ (event.target as HTMLInputElement).blur();
58
+ }
59
+ onKeyDown?.(event);
60
+ }}
61
+ />
62
+ );
63
+ }
@@ -0,0 +1,157 @@
1
+ import type { IconDefinition } from '@fortawesome/fontawesome-svg-core';
2
+ import {
3
+ faCaretDown,
4
+ faCaretRight,
5
+ faChevronDown,
6
+ faChevronRight,
7
+ } from '@fortawesome/free-solid-svg-icons';
8
+ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
9
+ import type React from 'react';
10
+ import { useSyncExternalStore } from 'react';
11
+ import { activeIconGlyph, activeIconSetSnapshot, subscribeIconSets } from '../icon-set-registry';
12
+ import { themeVars } from '../theme';
13
+
14
+ export type EditorIconSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
15
+ export type EditorIconTone = 'current' | 'primary' | 'muted' | 'dim' | 'accent' | 'danger';
16
+
17
+ // A glyph's size is the ICON scale (`theme.ts`'s `iconSize`), not the type
18
+ // scale: these sizes used to be the font's rungs, so a skin that tightened its
19
+ // text shrank every glyph with it — under Blender's density the chrome drew
20
+ // 10–11 px glyphs where Blender draws 14.
21
+ const SIZE: Record<EditorIconSize, string> = {
22
+ xs: 'var(--vgai-icon-xs)',
23
+ sm: 'var(--vgai-icon-sm)',
24
+ md: 'var(--vgai-icon-md)',
25
+ lg: 'var(--vgai-icon-lg)',
26
+ xl: 'var(--vgai-icon-xl)',
27
+ '2xl': 'var(--vgai-icon-2xl)',
28
+ };
29
+
30
+ const TONE: Record<EditorIconTone, string | undefined> = {
31
+ current: undefined,
32
+ primary: themeVars.content.primary,
33
+ muted: themeVars.content.muted,
34
+ dim: themeVars.content.dim,
35
+ accent: themeVars.accent.default,
36
+ danger: themeVars.semantic.danger,
37
+ };
38
+
39
+ export interface EditorIconProps extends Omit<React.HTMLAttributes<SVGSVGElement>, 'color'> {
40
+ icon: IconDefinition;
41
+ size?: EditorIconSize;
42
+ tone?: EditorIconTone;
43
+ /** Icons inside already-labelled controls stay decorative by default. */
44
+ label?: string;
45
+ spin?: boolean;
46
+ }
47
+
48
+ /** Canonical editor-chrome icon metrics and accessibility behavior. */
49
+ export function EditorIcon({
50
+ icon,
51
+ size = 'sm',
52
+ tone = 'current',
53
+ label,
54
+ className,
55
+ style,
56
+ spin = false,
57
+ ...svgProps
58
+ }: EditorIconProps) {
59
+ // The active icon set (`icon-set-registry.ts`): a glyph it carries for this
60
+ // name paints in Font Awesome's box (1.25em fixed width, 1em tall, the
61
+ // -0.125em baseline) so no site notices which set is on.
62
+ useSyncExternalStore(subscribeIconSets, activeIconSetSnapshot, activeIconSetSnapshot);
63
+ const glyph = activeIconGlyph(icon.iconName);
64
+ if (glyph) {
65
+ // THE GLYPH'S OWN COLOUR, and who wins. A site that names a tone has
66
+ // said something about THIS instance — de-emphasis (`dim`/`muted` on a
67
+ // quiet rail), status (`danger`) — and that always wins; `current` is the
68
+ // default, i.e. the site said nothing, so the glyph's category speaks.
69
+ const siteToned = tone !== 'current';
70
+ const categoryInk = glyph.tone ? `var(--vgai-category-${glyph.tone}, currentColor)` : undefined;
71
+ // With a `tonedPath` the category tints only that path and the body stays
72
+ // `currentColor` — Blender's operator marks tint the OPERATED element and
73
+ // leave the cube neutral. Without one, the category is the whole glyph's
74
+ // ink, so it rides `color` and every path inherits it.
75
+ const svgColor = siteToned ? TONE[tone] : glyph.tonedPath ? undefined : categoryInk;
76
+ const tonedFill = siteToned ? 'currentColor' : (categoryInk ?? 'currentColor');
77
+ return (
78
+ <svg
79
+ {...svgProps}
80
+ viewBox={glyph.viewBox ?? '0 0 16 16'}
81
+ // THE SITE'S HALF OF THE INK. A category token is the glyph's colour
82
+ // at FULL opacity (`theme.ts`'s category docblock) and Blender's own
83
+ // alpha belongs to the SITE — 0.80 in the Outliner and on an inactive
84
+ // Properties tab, 1.00 on the open tab and in the tool shelf. A site
85
+ // can only apply that to the glyphs it actually tints, so the tone
86
+ // rides out as a DOM marker: a set with no tone for this name (Font
87
+ // Awesome's, every Classic surface) carries no attribute and no rule
88
+ // keyed on it can reach it.
89
+ data-vgai-tone={siteToned ? undefined : glyph.tone}
90
+ className={className}
91
+ aria-hidden={label ? undefined : (svgProps['aria-hidden'] ?? 'true')}
92
+ aria-label={label}
93
+ role={label ? 'img' : undefined}
94
+ style={{
95
+ width: '1.25em',
96
+ height: '1em',
97
+ overflow: 'visible',
98
+ verticalAlign: '-0.125em',
99
+ fontSize: SIZE[size],
100
+ color: svgColor,
101
+ ...style,
102
+ }}
103
+ >
104
+ <path d={glyph.path} fill="currentColor" />
105
+ {glyph.tonedPath ? <path d={glyph.tonedPath} fill={tonedFill} /> : null}
106
+ </svg>
107
+ );
108
+ }
109
+ return (
110
+ <FontAwesomeIcon
111
+ {...svgProps}
112
+ icon={icon}
113
+ fixedWidth
114
+ spin={spin}
115
+ className={className}
116
+ aria-hidden={label ? undefined : (svgProps['aria-hidden'] ?? 'true')}
117
+ aria-label={label}
118
+ role={label ? 'img' : undefined}
119
+ style={{ fontSize: SIZE[size], color: TONE[tone], ...style }}
120
+ />
121
+ );
122
+ }
123
+
124
+ export function DisclosureIcon({
125
+ direction,
126
+ size = 'xs',
127
+ tone = 'current',
128
+ style,
129
+ }: {
130
+ direction: 'right' | 'down';
131
+ size?: EditorIconSize;
132
+ tone?: EditorIconTone;
133
+ style?: React.CSSProperties;
134
+ }) {
135
+ return (
136
+ <EditorIcon
137
+ icon={direction === 'right' ? faChevronRight : faChevronDown}
138
+ size={size}
139
+ tone={tone}
140
+ {...(style ? { style } : {})}
141
+ />
142
+ );
143
+ }
144
+
145
+ export function TreeDisclosureIcon({
146
+ direction,
147
+ size = 'xs',
148
+ tone = 'current',
149
+ }: {
150
+ direction: 'right' | 'down';
151
+ size?: EditorIconSize;
152
+ tone?: EditorIconTone;
153
+ }) {
154
+ return (
155
+ <EditorIcon icon={direction === 'right' ? faCaretRight : faCaretDown} size={size} tone={tone} />
156
+ );
157
+ }
@@ -0,0 +1,88 @@
1
+ import {
2
+ forwardRef,
3
+ type InputHTMLAttributes,
4
+ type SelectHTMLAttributes,
5
+ type TextareaHTMLAttributes,
6
+ } from 'react';
7
+
8
+ function classes(base: string, className?: string): string {
9
+ return className ? `${base} ${className}` : base;
10
+ }
11
+
12
+ export type TextInputProps = InputHTMLAttributes<HTMLInputElement>;
13
+
14
+ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function TextInput(
15
+ { className, type = 'text', ...props },
16
+ ref,
17
+ ) {
18
+ return <input {...props} ref={ref} type={type} className={classes('vgai-input', className)} />;
19
+ });
20
+
21
+ export type SelectProps = SelectHTMLAttributes<HTMLSelectElement>;
22
+
23
+ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
24
+ { className, ...props },
25
+ ref,
26
+ ) {
27
+ return <select {...props} ref={ref} className={classes('vgai-select', className)} />;
28
+ });
29
+
30
+ export type TextAreaProps = TextareaHTMLAttributes<HTMLTextAreaElement>;
31
+
32
+ /** Canonical multiline editor field. Paint and interaction states live in theme.css. */
33
+ export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(function TextArea(
34
+ { className, ...props },
35
+ ref,
36
+ ) {
37
+ return (
38
+ <textarea {...props} ref={ref} className={classes('vgai-input vgai-textarea', className)} />
39
+ );
40
+ });
41
+
42
+ export type CheckboxProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'type'>;
43
+
44
+ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(function Checkbox(
45
+ { className, ...props },
46
+ ref,
47
+ ) {
48
+ return (
49
+ <input {...props} ref={ref} type="checkbox" className={classes('vgai-checkbox', className)} />
50
+ );
51
+ });
52
+
53
+ export type ColorSwatchInputProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'type'>;
54
+
55
+ /** Native color picker with editor-owned dimensions, border, and focus treatment. */
56
+ export const ColorSwatchInput = forwardRef<HTMLInputElement, ColorSwatchInputProps>(
57
+ function ColorSwatchInput({ className, ...props }, ref) {
58
+ return (
59
+ <input
60
+ {...props}
61
+ ref={ref}
62
+ type="color"
63
+ className={classes('vgai-color-swatch', className)}
64
+ />
65
+ );
66
+ },
67
+ );
68
+
69
+ export type FileInputProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'type'>;
70
+
71
+ /** File input kept native for platform behavior while sharing editor focus/disabled states. */
72
+ export const FileInput = forwardRef<HTMLInputElement, FileInputProps>(function FileInput(
73
+ { className, ...props },
74
+ ref,
75
+ ) {
76
+ return (
77
+ <input {...props} ref={ref} type="file" className={classes('vgai-file-input', className)} />
78
+ );
79
+ });
80
+
81
+ export type RangeInputProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'type'>;
82
+
83
+ export const RangeInput = forwardRef<HTMLInputElement, RangeInputProps>(function RangeInput(
84
+ { className, ...props },
85
+ ref,
86
+ ) {
87
+ return <input {...props} ref={ref} type="range" className={classes('vgai-range', className)} />;
88
+ });
@@ -0,0 +1,96 @@
1
+ /**
2
+ * HoverPreview — a rich, anchored preview that opens on hover or keyboard
3
+ * focus of its single trigger child, through {@link AnchoredMenu} (portaled,
4
+ * no focus trap, dismissed on leave/blur/Escape/outside press).
5
+ *
6
+ * This is the design-system's OWNED home for hover-triggered preview
7
+ * behavior, the same way {@link Tooltip} owns hover-triggered text tips:
8
+ * product surfaces declare WHAT previews (`content`), never wire
9
+ * `onMouseEnter`/`onMouseLeave` themselves — the migration guard forbids
10
+ * exactly those handlers outside `primitives/` because hand-rolled hover
11
+ * wiring is how focus parity (a keyboard user must get the same preview) and
12
+ * Escape semantics quietly fork per surface.
13
+ *
14
+ * Where {@link Tooltip} carries a string and a hotkey, HoverPreview carries
15
+ * an arbitrary body (a thumbnail card, a monospace excerpt). Escape closes
16
+ * the preview and STOPS there — the surface around it never also reads that
17
+ * Escape (the first consumer is the chat composer, where Escape would
18
+ * otherwise close the composer itself).
19
+ */
20
+
21
+ import {
22
+ cloneElement,
23
+ isValidElement,
24
+ type KeyboardEvent,
25
+ type ReactElement,
26
+ type ReactNode,
27
+ useId,
28
+ useRef,
29
+ useState,
30
+ } from 'react';
31
+ import { AnchoredMenu, type AnchoredMenuProps } from './AnchoredMenu';
32
+
33
+ export interface HoverPreviewProps
34
+ extends Omit<
35
+ AnchoredMenuProps,
36
+ 'anchorRef' | 'children' | 'onDismiss' | 'id' | 'role' | 'content'
37
+ > {
38
+ /** The rich preview body, rendered inside the anchored surface. */
39
+ readonly preview: ReactNode;
40
+ /** The single trigger element the preview anchors to. */
41
+ readonly children: ReactElement<{ 'aria-describedby'?: string }>;
42
+ }
43
+
44
+ export function HoverPreview({ preview, children, ...menuProps }: HoverPreviewProps) {
45
+ const [open, setOpen] = useState(false);
46
+ const anchorRef = useRef<HTMLElement | null>(null);
47
+ const id = useId();
48
+
49
+ const close = () => setOpen(false);
50
+ const openAt = (anchor: HTMLElement) => {
51
+ anchorRef.current = anchor;
52
+ setOpen(true);
53
+ };
54
+
55
+ let child = children;
56
+ if (isValidElement<{ 'aria-describedby'?: string }>(children)) {
57
+ const describedBy = [children.props['aria-describedby'], open ? id : null]
58
+ .filter(Boolean)
59
+ .join(' ');
60
+ child = cloneElement(children, describedBy ? { 'aria-describedby': describedBy } : {});
61
+ }
62
+
63
+ return (
64
+ <span
65
+ className="vgai-tooltip-anchor"
66
+ onMouseEnter={(event) => openAt(event.currentTarget)}
67
+ onMouseLeave={close}
68
+ onFocusCapture={(event) => openAt(event.currentTarget)}
69
+ onBlurCapture={(event) => {
70
+ if (!event.currentTarget.contains(event.relatedTarget as Node | null)) close();
71
+ }}
72
+ onKeyDown={(event: KeyboardEvent) => {
73
+ if (event.key !== 'Escape' || !open) return;
74
+ // The preview consumed the key; the surface around it must not also
75
+ // read this Escape (e.g. as "close the composer").
76
+ event.preventDefault();
77
+ event.stopPropagation();
78
+ close();
79
+ }}
80
+ >
81
+ {child}
82
+ {open && anchorRef.current && (
83
+ <AnchoredMenu
84
+ {...menuProps}
85
+ anchorRef={anchorRef}
86
+ id={id}
87
+ role="tooltip"
88
+ autoFocusFirst={false}
89
+ onDismiss={close}
90
+ >
91
+ {preview}
92
+ </AnchoredMenu>
93
+ )}
94
+ </span>
95
+ );
96
+ }
@@ -0,0 +1,113 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { themeVars } from '../theme';
3
+ import { TextInput } from './FormControls';
4
+
5
+ /**
6
+ * Type-preserving JSON input for the component inspector's raw path.
7
+ *
8
+ * Same invariant as DataPanel's `JsonCell` (W2 hardening, fd9f450c), kept as
9
+ * a separate primitive on purpose — the inspector writes the in-memory scene
10
+ * store, DataPanel writes files; the two systems stay uncoupled:
11
+ * **a commit must never widen a typed value into a JSON string.**
12
+ * `commit()` parses the draft and hands `onCommit` the PARSED value; on parse
13
+ * failure it REFUSES — draft kept, error shown, no write. Unlike the sibling
14
+ * keystroke-live inputs, this commits on blur/Enter only (mid-edit JSON is
15
+ * almost always invalid).
16
+ */
17
+ export function JsonInput({
18
+ value,
19
+ onCommit,
20
+ style,
21
+ disabled = false,
22
+ }: {
23
+ value: unknown;
24
+ onCommit: (value: unknown) => void;
25
+ style?: React.CSSProperties;
26
+ /** Read-only: the field refuses input. Optional — existing callers unchanged. */
27
+ disabled?: boolean;
28
+ }) {
29
+ const display = value === undefined ? '' : JSON.stringify(value);
30
+ const [draft, setDraft] = useState(display);
31
+ const [invalid, setInvalid] = useState(false);
32
+ // Escape below calls `.blur()` imperatively to drop focus, which fires the
33
+ // native blur/focusout event SYNCHRONOUSLY, inside the same handler/render —
34
+ // so the resulting `onBlur={commit}` call closes over the PRE-revert
35
+ // `draft`/`display` (React hasn't re-rendered yet) and would re-derive
36
+ // `invalid: true` right after Escape just cleared it. This ref lets Escape
37
+ // tell the nested commit to no-op instead of racing it.
38
+ const revertingRef = useRef(false);
39
+
40
+ // Re-sync when the underlying value changes (undo, another widget, reload).
41
+ useEffect(() => {
42
+ setDraft(display);
43
+ setInvalid(false);
44
+ }, [display]);
45
+
46
+ const commit = () => {
47
+ if (revertingRef.current) {
48
+ revertingRef.current = false;
49
+ return;
50
+ }
51
+ if (draft === display) {
52
+ setInvalid(false);
53
+ return;
54
+ }
55
+ let parsed: unknown;
56
+ try {
57
+ parsed = JSON.parse(draft);
58
+ } catch {
59
+ setInvalid(true); // refuse: keep the draft, no onCommit, no write
60
+ return;
61
+ }
62
+ setInvalid(false);
63
+ onCommit(parsed);
64
+ };
65
+
66
+ return (
67
+ <div>
68
+ <TextInput
69
+ data-testid="json-input"
70
+ // Self-styled baseline (I-28): the shared field class means this
71
+ // primitive is never silently unstyled when a caller passes no
72
+ // `style` — callers now only supply layout (width/flex).
73
+ aria-invalid={invalid || undefined}
74
+ data-tone={invalid ? 'danger' : undefined}
75
+ disabled={disabled}
76
+ value={draft}
77
+ onChange={(e) => {
78
+ setDraft(e.target.value);
79
+ if (invalid) setInvalid(false);
80
+ }}
81
+ onBlur={commit}
82
+ onKeyDown={(e) => {
83
+ if (e.key === 'Enter') {
84
+ commit();
85
+ } else if (e.key === 'Escape') {
86
+ revertingRef.current = true;
87
+ setDraft(display);
88
+ setInvalid(false);
89
+ (e.target as HTMLInputElement).blur();
90
+ }
91
+ }}
92
+ style={style}
93
+ title={
94
+ invalid
95
+ ? 'Not valid JSON — nothing written. Fix the JSON or press Escape to revert.'
96
+ : undefined
97
+ }
98
+ />
99
+ {invalid && (
100
+ <div
101
+ data-testid="json-input-error"
102
+ style={{
103
+ fontSize: 'var(--vgai-font-sm)',
104
+ color: themeVars.semantic.danger,
105
+ marginTop: 2,
106
+ }}
107
+ >
108
+ Not valid JSON — nothing written. Fix the JSON or press Escape to revert.
109
+ </div>
110
+ )}
111
+ </div>
112
+ );
113
+ }
@@ -0,0 +1,100 @@
1
+ import type { CSSProperties, HTMLAttributes, ReactNode } from 'react';
2
+
3
+ export type LayoutGap = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 8 | 10 | 12;
4
+ export type LayoutAlignment = 'start' | 'center' | 'end' | 'stretch' | 'baseline';
5
+ export type LayoutJustification = 'start' | 'center' | 'end' | 'between' | 'around' | 'evenly';
6
+
7
+ interface LayoutProps extends HTMLAttributes<HTMLDivElement> {
8
+ gap?: LayoutGap;
9
+ align?: LayoutAlignment;
10
+ justify?: LayoutJustification;
11
+ padding?: LayoutGap;
12
+ paddingX?: LayoutGap;
13
+ paddingY?: LayoutGap;
14
+ }
15
+
16
+ const alignValue: Record<LayoutAlignment, CSSProperties['alignItems']> = {
17
+ start: 'flex-start',
18
+ center: 'center',
19
+ end: 'flex-end',
20
+ stretch: 'stretch',
21
+ baseline: 'baseline',
22
+ };
23
+
24
+ const justifyValue: Record<LayoutJustification, CSSProperties['justifyContent']> = {
25
+ start: 'flex-start',
26
+ center: 'center',
27
+ end: 'flex-end',
28
+ between: 'space-between',
29
+ around: 'space-around',
30
+ evenly: 'space-evenly',
31
+ };
32
+
33
+ function token(value: LayoutGap | undefined): string | undefined {
34
+ return value === 0 ? '0px' : value === undefined ? undefined : `var(--vgai-space-${value})`;
35
+ }
36
+
37
+ function layoutStyle({
38
+ gap,
39
+ align,
40
+ justify,
41
+ padding,
42
+ paddingX,
43
+ paddingY,
44
+ style,
45
+ }: LayoutProps): CSSProperties {
46
+ return {
47
+ gap: token(gap),
48
+ alignItems: align ? alignValue[align] : undefined,
49
+ justifyContent: justify ? justifyValue[justify] : undefined,
50
+ padding: token(padding),
51
+ paddingInline: token(paddingX),
52
+ paddingBlock: token(paddingY),
53
+ ...style,
54
+ };
55
+ }
56
+
57
+ function classes(base: string, className?: string): string {
58
+ return className ? `${base} ${className}` : base;
59
+ }
60
+
61
+ export function Stack({ className, ...props }: LayoutProps) {
62
+ return <div {...props} className={classes('vgai-stack', className)} style={layoutStyle(props)} />;
63
+ }
64
+
65
+ export function Inline({ className, ...props }: LayoutProps) {
66
+ return (
67
+ <div {...props} className={classes('vgai-inline', className)} style={layoutStyle(props)} />
68
+ );
69
+ }
70
+
71
+ export function Spacer({ className, ...props }: HTMLAttributes<HTMLSpanElement>) {
72
+ return <span {...props} aria-hidden="true" className={classes('vgai-spacer', className)} />;
73
+ }
74
+
75
+ export function Divider({
76
+ orientation = 'horizontal',
77
+ className,
78
+ ...props
79
+ }: HTMLAttributes<HTMLDivElement> & { orientation?: 'horizontal' | 'vertical' }) {
80
+ return (
81
+ <div
82
+ {...props}
83
+ aria-hidden="true"
84
+ data-orientation={orientation}
85
+ className={classes('vgai-divider', className)}
86
+ />
87
+ );
88
+ }
89
+
90
+ export function Actions({
91
+ children,
92
+ className,
93
+ ...props
94
+ }: HTMLAttributes<HTMLDivElement> & { children: ReactNode }) {
95
+ return (
96
+ <Inline {...props} className={classes('vgai-actions', className)} gap={2} align="center">
97
+ {children}
98
+ </Inline>
99
+ );
100
+ }