@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,182 @@
1
+ import { Button } from '../design-system';
2
+ /**
3
+ * AlignmentGrid — a 3×3 flex `justify-content`×`align-items` picker (spec 27
4
+ * §5 C1). Ported from `visual-edit/inspector.tsx`'s `AlignmentGrid`
5
+ * (:304-351). `AnchorPresets` below REUSES the existing `ui-editor/
6
+ * inspector.tsx` `ANCHOR_PRESETS` table's VALUES (now exported there for
7
+ * this reuse) as a data-only copy — not a live import — because
8
+ * `ui-editor/inspector.tsx` pulls in `UIEditStore`/`react-store`/
9
+ * `node-ops`/the inspector-section-registry (a whole editing-mode runtime
10
+ * graph this pure, adapter-free widget kit must not drag in, Rule zero's
11
+ * "talk ONLY to the AuthoringAdapter contract" spirit extended to "don't
12
+ * import a store-coupled module just for one constant"). Keep this table in
13
+ * sync with `ui-editor/inspector.tsx`'s `ANCHOR_PRESETS` if either changes —
14
+ * C2's wiring step is the natural point to collapse them into one shared,
15
+ * dependency-free `anchor-presets.ts` module both sides import.
16
+ */
17
+
18
+ import { type ChangeHandlers, THEME } from './shared';
19
+
20
+ const ANCHOR_PRESETS: Record<
21
+ string,
22
+ { anchor: AnchorRect['anchor']; offset: AnchorRect['offset'] }
23
+ > = {
24
+ full: {
25
+ anchor: { left: 0, top: 0, right: 1, bottom: 1 },
26
+ offset: { left: 0, top: 0, right: 0, bottom: 0 },
27
+ },
28
+ top: {
29
+ anchor: { left: 0, top: 0, right: 1, bottom: 0 },
30
+ offset: { left: 0, top: 0, right: 0, bottom: 0 },
31
+ },
32
+ bottom: {
33
+ anchor: { left: 0, top: 1, right: 1, bottom: 1 },
34
+ offset: { left: 0, top: 0, right: 0, bottom: 0 },
35
+ },
36
+ left: {
37
+ anchor: { left: 0, top: 0, right: 0, bottom: 1 },
38
+ offset: { left: 0, top: 0, right: 0, bottom: 0 },
39
+ },
40
+ right: {
41
+ anchor: { left: 1, top: 0, right: 1, bottom: 1 },
42
+ offset: { left: 0, top: 0, right: 0, bottom: 0 },
43
+ },
44
+ center: {
45
+ anchor: { left: 0.5, top: 0.5, right: 0.5, bottom: 0.5 },
46
+ offset: { left: -60, top: -20, right: 60, bottom: 20 },
47
+ },
48
+ 'top-left': {
49
+ anchor: { left: 0, top: 0, right: 0, bottom: 0 },
50
+ offset: { left: 0, top: 0, right: 120, bottom: 40 },
51
+ },
52
+ 'top-right': {
53
+ anchor: { left: 1, top: 0, right: 1, bottom: 0 },
54
+ offset: { left: -120, top: 0, right: 0, bottom: 40 },
55
+ },
56
+ };
57
+
58
+ const JUSTIFY = ['flex-start', 'center', 'flex-end'] as const;
59
+ const ALIGN = ['flex-start', 'center', 'flex-end'] as const;
60
+
61
+ export interface AlignmentValue {
62
+ justify: (typeof JUSTIFY)[number];
63
+ align: (typeof ALIGN)[number];
64
+ }
65
+
66
+ /** One 3×3 dot — split out of `AlignmentGrid`'s double `.map` purely to keep
67
+ * each function under biome's cognitive-complexity ceiling; same markup. */
68
+ function AlignmentCell({
69
+ row,
70
+ col,
71
+ justify,
72
+ align,
73
+ isActive,
74
+ disabled,
75
+ onSelect,
76
+ }: {
77
+ row: number;
78
+ col: number;
79
+ justify: AlignmentValue['justify'];
80
+ align: AlignmentValue['align'];
81
+ isActive: boolean;
82
+ disabled?: boolean | undefined;
83
+ onSelect: (justify: AlignmentValue['justify'], align: AlignmentValue['align']) => void;
84
+ }): React.ReactElement {
85
+ return (
86
+ <div
87
+ data-testid={`alignment-grid-${row}-${col}`}
88
+ onClick={() => {
89
+ if (!disabled) onSelect(justify, align);
90
+ }}
91
+ style={{
92
+ width: 16,
93
+ height: 16,
94
+ display: 'flex',
95
+ alignItems: 'center',
96
+ justifyContent: 'center',
97
+ cursor: disabled ? 'not-allowed' : 'pointer',
98
+ borderRadius: THEME.radiusSmall,
99
+ background: isActive ? THEME.accent : 'transparent',
100
+ }}
101
+ >
102
+ <div
103
+ style={{
104
+ width: isActive ? 6 : 4,
105
+ height: isActive ? 6 : 4,
106
+ borderRadius: '50%',
107
+ background: isActive ? THEME.onAccent : THEME.textMuted,
108
+ }}
109
+ />
110
+ </div>
111
+ );
112
+ }
113
+
114
+ export function AlignmentGrid({
115
+ value,
116
+ onChange,
117
+ disabled,
118
+ }: ChangeHandlers<AlignmentValue>): React.ReactElement {
119
+ const jIdx = JUSTIFY.indexOf(value.justify);
120
+ const aIdx = ALIGN.indexOf(value.align);
121
+
122
+ return (
123
+ <div
124
+ data-testid="alignment-grid"
125
+ style={{
126
+ display: 'inline-grid',
127
+ gridTemplateColumns: 'repeat(3, 16px)',
128
+ gridTemplateRows: 'repeat(3, 16px)',
129
+ gap: 1,
130
+ background: THEME.inputBg,
131
+ borderRadius: THEME.radiusMedium,
132
+ padding: 4,
133
+ opacity: disabled ? 0.35 : 1,
134
+ }}
135
+ >
136
+ {ALIGN.map((a, row) =>
137
+ JUSTIFY.map((j, col) => (
138
+ <AlignmentCell
139
+ key={`${row}-${col}`}
140
+ row={row}
141
+ col={col}
142
+ justify={j}
143
+ align={a}
144
+ isActive={row === aIdx && col === jIdx}
145
+ disabled={disabled}
146
+ onSelect={(justify, align) => onChange({ justify, align })}
147
+ />
148
+ )),
149
+ )}
150
+ </div>
151
+ );
152
+ }
153
+
154
+ export interface AnchorRect {
155
+ anchor: { left: number; top: number; right: number; bottom: number };
156
+ offset: { left: number; top: number; right: number; bottom: number };
157
+ }
158
+
159
+ export interface AnchorPresetsProps {
160
+ onSelect: (name: string, preset: AnchorRect) => void;
161
+ disabled?: boolean | undefined;
162
+ }
163
+
164
+ export function AnchorPresets({ onSelect, disabled }: AnchorPresetsProps): React.ReactElement {
165
+ return (
166
+ <div data-testid="anchor-presets" style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
167
+ {Object.entries(ANCHOR_PRESETS).map(([name, preset]) => (
168
+ <Button
169
+ key={name}
170
+ type="button"
171
+ variant="secondary"
172
+ size="compact"
173
+ data-testid={`anchor-preset-${name}`}
174
+ disabled={disabled}
175
+ onClick={() => onSelect(name, preset)}
176
+ >
177
+ {name}
178
+ </Button>
179
+ ))}
180
+ </div>
181
+ );
182
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * AssetSlotPicker — the widget for a `PropertyDescriptor.type: 'asset'` row:
3
+ * ONE slot on the inspected thing that either holds a project asset or is
4
+ * empty (a material's `map`, `normalMap`, …).
5
+ *
6
+ * Kit-pure like every other widget here (`index.ts`): it takes the candidate
7
+ * paths and a preview-URL function from its caller and imports no editor
8
+ * state, so the same control serves any lane that can name its own assets.
9
+ *
10
+ * Deliberately NOT a second asset browser. The choices are a flat list of
11
+ * project-relative paths in a `<Select>` — the Asset Browser panel remains the
12
+ * place to look through, tag, import and preview assets; this is the one-click
13
+ * assignment at the point of use, which is the gesture a material author is
14
+ * making. The thumbnail is the assigned file itself, so the row shows WHAT is
15
+ * assigned and not only its name.
16
+ */
17
+
18
+ import { faXmark } from '@fortawesome/free-solid-svg-icons';
19
+ import type React from 'react';
20
+ import { EditorIcon, IconButton, Select, themeVars } from '../design-system';
21
+ import { THEME } from './shared';
22
+
23
+ const THUMB = 22;
24
+
25
+ const thumbStyle: React.CSSProperties = {
26
+ width: THUMB,
27
+ height: THUMB,
28
+ flex: `0 0 ${THUMB}px`,
29
+ borderRadius: THEME.radiusSmall,
30
+ border: `1px solid ${THEME.border}`,
31
+ background: THEME.inputBg,
32
+ objectFit: 'cover',
33
+ // A checker under a transparent PNG, so an alpha map does not read as white.
34
+ backgroundImage:
35
+ 'linear-gradient(45deg,rgba(128,128,128,.35) 25%,transparent 25%,transparent 75%,rgba(128,128,128,.35) 75%),' +
36
+ 'linear-gradient(45deg,rgba(128,128,128,.35) 25%,transparent 25%,transparent 75%,rgba(128,128,128,.35) 75%)',
37
+ backgroundSize: '8px 8px',
38
+ backgroundPosition: '0 0, 4px 4px',
39
+ };
40
+
41
+ export interface AssetSlotPickerProps {
42
+ /** The assigned asset's project path, or `''` when the slot is empty. */
43
+ readonly value: string;
44
+ /** Assign a path, or `''` to CLEAR the slot. */
45
+ readonly onChange: (value: string) => void;
46
+ /** The paths this slot may be filled from, already filtered by the caller to
47
+ * the kinds it can honor. */
48
+ readonly options: readonly string[];
49
+ /** Browser URL for a path's preview image. */
50
+ readonly previewUrlFor: (path: string) => string;
51
+ readonly disabled?: boolean | undefined;
52
+ /**
53
+ * What the last assignment ANSWERED — the write ack's own sentence when it
54
+ * did not persist. Rendered under the control because an edit whose file half
55
+ * silently did not happen is exactly the invisible failure the write pipe
56
+ * exists to make loud.
57
+ */
58
+ readonly note?: string | undefined;
59
+ readonly testId: string;
60
+ }
61
+
62
+ export function AssetSlotPicker({
63
+ value,
64
+ onChange,
65
+ options,
66
+ previewUrlFor,
67
+ disabled,
68
+ note,
69
+ testId,
70
+ }: AssetSlotPickerProps): React.ReactElement {
71
+ // A value assigned outside this list (a texture the game's own code loaded,
72
+ // or a file since deleted) must still be SHOWN — dropping it from the select
73
+ // would silently redisplay the slot as empty while it is not.
74
+ const choices = value && !options.includes(value) ? [value, ...options] : options;
75
+ return (
76
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0, flex: 1 }}>
77
+ <div style={{ display: 'flex', alignItems: 'center', gap: 4, minWidth: 0 }}>
78
+ {value ? (
79
+ <img
80
+ src={previewUrlFor(value)}
81
+ alt=""
82
+ style={thumbStyle}
83
+ data-testid={`${testId}-thumb`}
84
+ />
85
+ ) : (
86
+ <span style={thumbStyle} aria-hidden="true" />
87
+ )}
88
+ <Select
89
+ data-testid={testId}
90
+ disabled={disabled}
91
+ value={value}
92
+ title={value || 'No texture assigned'}
93
+ onChange={(e) => onChange(e.target.value)}
94
+ style={{ flex: 1, minWidth: 0 }}
95
+ >
96
+ <option value="">—</option>
97
+ {choices.map((path) => (
98
+ <option key={path} value={path}>
99
+ {path}
100
+ </option>
101
+ ))}
102
+ </Select>
103
+ <IconButton
104
+ data-testid={`${testId}-clear`}
105
+ aria-label="Clear this texture slot"
106
+ title="Clear this texture slot"
107
+ disabled={disabled || !value}
108
+ onClick={() => onChange('')}
109
+ >
110
+ <EditorIcon icon={faXmark} />
111
+ </IconButton>
112
+ </div>
113
+ {note && (
114
+ <span
115
+ data-testid={`${testId}-note`}
116
+ style={{ fontSize: 'var(--vgai-font-sm)', color: themeVars.content.muted }}
117
+ >
118
+ {note}
119
+ </span>
120
+ )}
121
+ </div>
122
+ );
123
+ }
@@ -0,0 +1,309 @@
1
+ import { Button, Select } from '../design-system';
2
+ /**
3
+ * BorderEditor (per-side width/style/color) + BorderRadiusEditor (per-corner)
4
+ * (spec 27 §5 C1). Ported from `visual-edit/inspector.tsx`'s `BorderEditor`
5
+ * (:1853-1951) and `BorderRadiusEditor` (:1794-1847) — both keep the
6
+ * reference's uniform<->per-side/per-corner expand toggle (a single border/
7
+ * radius is the common case; independent sides are one click away).
8
+ */
9
+
10
+ import { faBorderAll, faBorderTopLeft } from '@fortawesome/free-solid-svg-icons';
11
+ import { useState } from 'react';
12
+ import { EditorIcon } from '../design-system';
13
+ import { ColorSwatch } from './ColorPicker';
14
+ import { ScrubbableInput } from './ScrubbableInput';
15
+ import { type ChangeHandlers, fireChange, fireEnd, THEME } from './shared';
16
+
17
+ const BORDER_STYLES = ['none', 'solid', 'dashed', 'dotted', 'double'];
18
+
19
+ export interface BorderSide {
20
+ width: number;
21
+ style: string;
22
+ color: string;
23
+ }
24
+
25
+ export interface BorderValue {
26
+ top: BorderSide;
27
+ right: BorderSide;
28
+ bottom: BorderSide;
29
+ left: BorderSide;
30
+ }
31
+
32
+ export function uniformBorder(side: BorderSide): BorderValue {
33
+ return { top: side, right: side, bottom: side, left: side };
34
+ }
35
+
36
+ /** `true` when all 4 sides share width/style/color (safe to render collapsed). */
37
+ export function isUniformBorder(v: BorderValue): boolean {
38
+ const key = (s: BorderSide): string => `${s.width}|${s.style}|${s.color}`;
39
+ const k = key(v.top);
40
+ return key(v.right) === k && key(v.bottom) === k && key(v.left) === k;
41
+ }
42
+
43
+ export interface BorderEditorProps extends ChangeHandlers<BorderValue> {
44
+ recentColors?: string[] | undefined;
45
+ onAddRecentColor?: ((color: string) => void) | undefined;
46
+ }
47
+
48
+ export function BorderEditor({
49
+ value,
50
+ onChange,
51
+ onChangeEnd,
52
+ disabled,
53
+ recentColors,
54
+ onAddRecentColor,
55
+ }: BorderEditorProps): React.ReactElement {
56
+ const [expanded, setExpanded] = useState(!isUniformBorder(value));
57
+
58
+ const applyAll = (partial: Partial<BorderSide>, commit: boolean): void => {
59
+ const side = { ...value.top, ...partial };
60
+ const next = uniformBorder(side);
61
+ if (commit) fireEnd({ onChange, onChangeEnd }, next);
62
+ else fireChange({ onChange }, next);
63
+ };
64
+
65
+ const applySide = (
66
+ key: keyof BorderValue,
67
+ partial: Partial<BorderSide>,
68
+ commit: boolean,
69
+ ): void => {
70
+ const next = { ...value, [key]: { ...value[key], ...partial } };
71
+ if (commit) fireEnd({ onChange, onChangeEnd }, next);
72
+ else fireChange({ onChange }, next);
73
+ };
74
+
75
+ if (!expanded) {
76
+ return (
77
+ <div
78
+ data-testid="border-editor-uniform"
79
+ style={{ display: 'flex', gap: 6, alignItems: 'center' }}
80
+ >
81
+ <ColorSwatch
82
+ testId="border-color"
83
+ value={value.top.color}
84
+ disabled={disabled}
85
+ onChange={(c) => applyAll({ color: c }, false)}
86
+ onChangeEnd={(c) => applyAll({ color: c }, true)}
87
+ recentColors={recentColors}
88
+ onAddRecentColor={onAddRecentColor}
89
+ />
90
+ <ScrubbableInput
91
+ testId="border-width"
92
+ label="W"
93
+ value={value.top.width}
94
+ min={0}
95
+ disabled={disabled}
96
+ onChange={(v) => applyAll({ width: v }, false)}
97
+ onChangeEnd={(v) => applyAll({ width: v }, true)}
98
+ style={{ width: 64 }}
99
+ />
100
+ <Select
101
+ className="vgai-select"
102
+ data-testid="border-style"
103
+ value={value.top.style}
104
+ disabled={disabled}
105
+ data-dynamic={disabled || undefined}
106
+ onChange={(e) => applyAll({ style: e.target.value }, true)}
107
+ style={{ flex: 1 }}
108
+ >
109
+ {BORDER_STYLES.map((o) => (
110
+ <option key={o} value={o}>
111
+ {o}
112
+ </option>
113
+ ))}
114
+ </Select>
115
+ <Button
116
+ type="button"
117
+ data-testid="border-expand"
118
+ title="Per-side borders"
119
+ aria-label="Edit borders per side"
120
+ onClick={() => setExpanded(true)}
121
+ variant="ghost"
122
+ size="compact"
123
+ >
124
+ <EditorIcon icon={faBorderAll} />
125
+ </Button>
126
+ </div>
127
+ );
128
+ }
129
+
130
+ const sides: Array<{ key: keyof BorderValue; label: string }> = [
131
+ { key: 'top', label: 'T' },
132
+ { key: 'right', label: 'R' },
133
+ { key: 'bottom', label: 'B' },
134
+ { key: 'left', label: 'L' },
135
+ ];
136
+
137
+ return (
138
+ <div
139
+ data-testid="border-editor-expanded"
140
+ style={{ display: 'flex', flexDirection: 'column', gap: 4 }}
141
+ >
142
+ <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
143
+ <span style={{ fontSize: 10, color: THEME.textMuted }}>Per-side</span>
144
+ <div style={{ flex: 1 }} />
145
+ <Button
146
+ type="button"
147
+ variant="primary"
148
+ size="compact"
149
+ data-testid="border-collapse"
150
+ title="Uniform border"
151
+ aria-label="Use one border for all sides"
152
+ onClick={() => setExpanded(false)}
153
+ >
154
+ <EditorIcon icon={faBorderAll} />
155
+ </Button>
156
+ </div>
157
+ {sides.map(({ key, label }) => (
158
+ <div key={key} style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
159
+ <span style={{ fontSize: 10, color: THEME.textMuted, width: 10, textAlign: 'center' }}>
160
+ {label}
161
+ </span>
162
+ <ColorSwatch
163
+ testId={`border-${key}-color`}
164
+ value={value[key].color}
165
+ disabled={disabled}
166
+ onChange={(c) => applySide(key, { color: c }, false)}
167
+ onChangeEnd={(c) => applySide(key, { color: c }, true)}
168
+ recentColors={recentColors}
169
+ onAddRecentColor={onAddRecentColor}
170
+ />
171
+ <ScrubbableInput
172
+ testId={`border-${key}-width`}
173
+ label=""
174
+ value={value[key].width}
175
+ min={0}
176
+ disabled={disabled}
177
+ onChange={(v) => applySide(key, { width: v }, false)}
178
+ onChangeEnd={(v) => applySide(key, { width: v }, true)}
179
+ style={{ width: 48 }}
180
+ />
181
+ <Select
182
+ className="vgai-select"
183
+ data-testid={`border-${key}-style`}
184
+ value={value[key].style}
185
+ disabled={disabled}
186
+ data-density="compact"
187
+ data-dynamic={disabled || undefined}
188
+ onChange={(e) => applySide(key, { style: e.target.value }, true)}
189
+ style={{ flex: 1 }}
190
+ >
191
+ {BORDER_STYLES.map((o) => (
192
+ <option key={o} value={o}>
193
+ {o}
194
+ </option>
195
+ ))}
196
+ </Select>
197
+ </div>
198
+ ))}
199
+ </div>
200
+ );
201
+ }
202
+
203
+ export interface BorderRadiusValue {
204
+ topLeft: number;
205
+ topRight: number;
206
+ bottomLeft: number;
207
+ bottomRight: number;
208
+ }
209
+
210
+ export function uniformRadius(n: number): BorderRadiusValue {
211
+ return { topLeft: n, topRight: n, bottomLeft: n, bottomRight: n };
212
+ }
213
+
214
+ export function isUniformRadius(v: BorderRadiusValue): boolean {
215
+ return v.topLeft === v.topRight && v.topRight === v.bottomLeft && v.bottomLeft === v.bottomRight;
216
+ }
217
+
218
+ export function BorderRadiusEditor({
219
+ value,
220
+ onChange,
221
+ onChangeEnd,
222
+ disabled,
223
+ }: ChangeHandlers<BorderRadiusValue>): React.ReactElement {
224
+ const [expanded, setExpanded] = useState(!isUniformRadius(value));
225
+
226
+ const applyAll = (n: number, commit: boolean): void => {
227
+ const next = uniformRadius(n);
228
+ if (commit) fireEnd({ onChange, onChangeEnd }, next);
229
+ else fireChange({ onChange }, next);
230
+ };
231
+
232
+ const applyCorner = (key: keyof BorderRadiusValue, n: number, commit: boolean): void => {
233
+ const next = { ...value, [key]: n };
234
+ if (commit) fireEnd({ onChange, onChangeEnd }, next);
235
+ else fireChange({ onChange }, next);
236
+ };
237
+
238
+ if (!expanded) {
239
+ return (
240
+ <div
241
+ data-testid="border-radius-editor-uniform"
242
+ style={{ display: 'flex', gap: 4, alignItems: 'center' }}
243
+ >
244
+ <ScrubbableInput
245
+ testId="border-radius"
246
+ label={<EditorIcon icon={faBorderTopLeft} label="Radius" />}
247
+ value={value.topLeft}
248
+ min={0}
249
+ disabled={disabled}
250
+ onChange={(v) => applyAll(v, false)}
251
+ onChangeEnd={(v) => applyAll(v, true)}
252
+ style={{ flex: 1 }}
253
+ />
254
+ <Button
255
+ type="button"
256
+ data-testid="border-radius-expand"
257
+ title="Independent corners"
258
+ aria-label="Edit radius per corner"
259
+ onClick={() => setExpanded(true)}
260
+ variant="ghost"
261
+ size="compact"
262
+ >
263
+ <EditorIcon icon={faBorderTopLeft} />
264
+ </Button>
265
+ </div>
266
+ );
267
+ }
268
+
269
+ const corners: Array<{ key: keyof BorderRadiusValue; label: string }> = [
270
+ { key: 'topLeft', label: 'TL' },
271
+ { key: 'topRight', label: 'TR' },
272
+ { key: 'bottomLeft', label: 'BL' },
273
+ { key: 'bottomRight', label: 'BR' },
274
+ ];
275
+
276
+ return (
277
+ <div data-testid="border-radius-editor-expanded">
278
+ <div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 4 }}>
279
+ <span style={{ fontSize: 10, color: THEME.textMuted }}>Corners</span>
280
+ <div style={{ flex: 1 }} />
281
+ <Button
282
+ type="button"
283
+ variant="primary"
284
+ size="compact"
285
+ data-testid="border-radius-collapse"
286
+ title="Uniform radius"
287
+ aria-label="Use one radius for all corners"
288
+ onClick={() => setExpanded(false)}
289
+ >
290
+ <EditorIcon icon={faBorderTopLeft} />
291
+ </Button>
292
+ </div>
293
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 3 }}>
294
+ {corners.map(({ key, label }) => (
295
+ <ScrubbableInput
296
+ key={key}
297
+ testId={`border-radius-${key}`}
298
+ label={label}
299
+ value={value[key]}
300
+ min={0}
301
+ disabled={disabled}
302
+ onChange={(v) => applyCorner(key, v, false)}
303
+ onChangeEnd={(v) => applyCorner(key, v, true)}
304
+ />
305
+ ))}
306
+ </div>
307
+ </div>
308
+ );
309
+ }