@hyperframes/studio 0.5.5 → 0.6.0-alpha.1

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 (74) hide show
  1. package/dist/assets/hyperframes-player-Cd8vYWxP.js +198 -0
  2. package/dist/assets/index-D04_ZoMm.js +107 -0
  3. package/dist/assets/index-UWFaHilT.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +2621 -170
  7. package/src/components/LintModal.tsx +3 -4
  8. package/src/components/editor/DomEditOverlay.test.ts +241 -0
  9. package/src/components/editor/DomEditOverlay.tsx +1300 -0
  10. package/src/components/editor/MotionPanel.tsx +651 -0
  11. package/src/components/editor/PropertyPanel.test.ts +67 -0
  12. package/src/components/editor/PropertyPanel.tsx +2891 -207
  13. package/src/components/editor/TimelineLayerPanel.test.ts +42 -0
  14. package/src/components/editor/TimelineLayerPanel.tsx +113 -0
  15. package/src/components/editor/colorValue.test.ts +82 -0
  16. package/src/components/editor/colorValue.ts +175 -0
  17. package/src/components/editor/domEditing.test.ts +872 -0
  18. package/src/components/editor/domEditing.ts +993 -0
  19. package/src/components/editor/floatingPanel.test.ts +34 -0
  20. package/src/components/editor/floatingPanel.ts +54 -0
  21. package/src/components/editor/fontAssets.ts +32 -0
  22. package/src/components/editor/fontCatalog.ts +126 -0
  23. package/src/components/editor/gradientValue.test.ts +89 -0
  24. package/src/components/editor/gradientValue.ts +445 -0
  25. package/src/components/editor/manualEditingAvailability.test.ts +120 -0
  26. package/src/components/editor/manualEditingAvailability.ts +60 -0
  27. package/src/components/editor/manualEdits.test.ts +945 -0
  28. package/src/components/editor/manualEdits.ts +1397 -0
  29. package/src/components/editor/manualOffsetDrag.test.ts +140 -0
  30. package/src/components/editor/manualOffsetDrag.ts +307 -0
  31. package/src/components/editor/studioMotion.test.ts +355 -0
  32. package/src/components/editor/studioMotion.ts +632 -0
  33. package/src/components/nle/NLELayout.tsx +27 -4
  34. package/src/components/nle/NLEPreview.tsx +50 -5
  35. package/src/components/renders/RenderQueue.tsx +13 -62
  36. package/src/components/renders/useRenderQueue.ts +6 -30
  37. package/src/components/sidebar/AssetsTab.tsx +3 -4
  38. package/src/components/sidebar/CompositionsTab.test.ts +16 -1
  39. package/src/components/sidebar/CompositionsTab.tsx +117 -45
  40. package/src/components/sidebar/LeftSidebar.tsx +140 -125
  41. package/src/hooks/usePersistentEditHistory.test.ts +256 -0
  42. package/src/hooks/usePersistentEditHistory.ts +337 -0
  43. package/src/icons/SystemIcons.tsx +2 -0
  44. package/src/player/components/CompositionThumbnail.test.ts +19 -0
  45. package/src/player/components/CompositionThumbnail.tsx +50 -13
  46. package/src/player/components/EditModal.tsx +5 -20
  47. package/src/player/components/Player.tsx +18 -2
  48. package/src/player/components/Timeline.test.ts +20 -0
  49. package/src/player/components/Timeline.tsx +103 -21
  50. package/src/player/components/TimelineClip.test.ts +92 -0
  51. package/src/player/components/TimelineClip.tsx +241 -7
  52. package/src/player/components/timelineEditing.test.ts +16 -3
  53. package/src/player/components/timelineEditing.ts +10 -3
  54. package/src/player/hooks/useTimelinePlayer.test.ts +148 -19
  55. package/src/player/hooks/useTimelinePlayer.ts +287 -16
  56. package/src/player/store/playerStore.ts +2 -0
  57. package/src/utils/clipboard.test.ts +89 -0
  58. package/src/utils/clipboard.ts +57 -0
  59. package/src/utils/editHistory.test.ts +244 -0
  60. package/src/utils/editHistory.ts +218 -0
  61. package/src/utils/editHistoryStorage.test.ts +37 -0
  62. package/src/utils/editHistoryStorage.ts +99 -0
  63. package/src/utils/mediaTypes.ts +1 -1
  64. package/src/utils/sourcePatcher.test.ts +128 -1
  65. package/src/utils/sourcePatcher.ts +130 -18
  66. package/src/utils/studioFileHistory.test.ts +156 -0
  67. package/src/utils/studioFileHistory.ts +61 -0
  68. package/src/utils/timelineAssetDrop.test.ts +31 -11
  69. package/src/utils/timelineAssetDrop.ts +22 -2
  70. package/src/utils/timelineInspector.test.ts +79 -0
  71. package/src/utils/timelineInspector.ts +116 -0
  72. package/dist/assets/hyperframes-player-CEnWY28J.js +0 -417
  73. package/dist/assets/index-04Mp2wOn.css +0 -1
  74. package/dist/assets/index-960mgQMI.js +0 -93
@@ -1,272 +1,2956 @@
1
- import { memo } from "react";
2
- import { X, MousePointer, Move, Type, Palette, Clock, Eye } from "../../icons/SystemIcons";
3
- import { Button, IconButton } from "../ui";
4
- import type { PickedElement } from "../../hooks/useElementPicker";
1
+ import {
2
+ memo,
3
+ useCallback,
4
+ useEffect,
5
+ useLayoutEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type ReactNode,
10
+ } from "react";
11
+ import { createPortal } from "react-dom";
12
+ import {
13
+ Eye,
14
+ Layers,
15
+ MessageSquare,
16
+ Move,
17
+ Palette,
18
+ Plus,
19
+ RotateCcw,
20
+ Settings,
21
+ Square,
22
+ Type,
23
+ X,
24
+ Zap,
25
+ } from "../../icons/SystemIcons";
26
+ import {
27
+ formatCssColor,
28
+ hsvToRgb,
29
+ parseCssColor,
30
+ rgbToHsv,
31
+ toColorPickerValue,
32
+ toHexColor,
33
+ type ParsedColor,
34
+ } from "./colorValue";
35
+ import {
36
+ buildDefaultGradientModel,
37
+ insertGradientStop,
38
+ parseGradient,
39
+ serializeGradient,
40
+ type GradientModel,
41
+ } from "./gradientValue";
42
+ import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
43
+ import { readStudioBoxSize, readStudioPathOffset } from "./manualEdits";
44
+ import {
45
+ COMMON_LOCAL_FONT_FAMILIES,
46
+ googleFontStylesheetUrl,
47
+ POPULAR_GOOGLE_FONT_FAMILIES,
48
+ } from "./fontCatalog";
49
+ import { fontFamilyFromAssetPath, importedFontFaceCss, type ImportedFontAsset } from "./fontAssets";
50
+ import { resolveFloatingPanelPosition, type FloatingPosition } from "./floatingPanel";
51
+ import { IMAGE_EXT } from "../../utils/mediaTypes";
5
52
 
6
53
  interface PropertyPanelProps {
7
- element: PickedElement | null;
8
- isPickMode: boolean;
9
- onEnablePick: () => void;
10
- onDisablePick: () => void;
11
- onClearPick: () => void;
12
- onSetStyle: (prop: string, value: string) => void;
13
- onSetDataAttr: (attr: string, value: string) => void;
14
- onSetText?: (text: string) => void;
54
+ projectId: string;
55
+ assets: string[];
56
+ element: DomEditSelection | null;
57
+ copiedAgentPrompt: boolean;
58
+ onClearSelection: () => void;
59
+ onSetStyle: (prop: string, value: string) => void | Promise<void>;
60
+ onSetManualOffset: (element: DomEditSelection, next: { x: number; y: number }) => void;
61
+ onSetManualSize: (element: DomEditSelection, next: { width: number; height: number }) => void;
62
+ onSetText: (value: string, fieldKey?: string) => void;
63
+ onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
64
+ onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
65
+ onRemoveTextField: (fieldKey: string) => void;
66
+ onResetManualEdits: (element: DomEditSelection) => void;
67
+ onAskAgent: () => void;
68
+ onImportAssets?: (files: FileList) => Promise<string[]>;
69
+ fontAssets?: ImportedFontAsset[];
70
+ onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
15
71
  }
16
72
 
17
- function PropertyRow({
73
+ const FIELD =
74
+ "min-w-0 rounded-xl border border-neutral-800 bg-neutral-900/95 px-3 py-2 text-neutral-100 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)] transition-colors focus-within:border-neutral-600";
75
+ const LABEL = "text-[11px] font-medium uppercase tracking-[0.18em] text-neutral-500";
76
+ const RESPONSIVE_GRID = "grid grid-cols-[repeat(auto-fit,minmax(118px,1fr))] gap-3";
77
+ const EMPTY_STYLES: Record<string, string> = {};
78
+ const GENERIC_FONT_FAMILIES = new Set([
79
+ "inherit",
80
+ "initial",
81
+ "revert",
82
+ "revert-layer",
83
+ "serif",
84
+ "sans-serif",
85
+ "monospace",
86
+ "cursive",
87
+ "fantasy",
88
+ "system-ui",
89
+ "ui-sans-serif",
90
+ "ui-serif",
91
+ "ui-monospace",
92
+ "ui-rounded",
93
+ "emoji",
94
+ "math",
95
+ "fangsong",
96
+ ]);
97
+ const DEFAULT_FONT_FAMILIES = [
98
+ ...COMMON_LOCAL_FONT_FAMILIES,
99
+ "Inter",
100
+ "system-ui",
101
+ "sans-serif",
102
+ "serif",
103
+ "monospace",
104
+ ];
105
+ interface LocalFontData {
106
+ family: string;
107
+ fullName?: string;
108
+ postscriptName?: string;
109
+ style?: string;
110
+ blob?: () => Promise<Blob>;
111
+ }
112
+
113
+ type FontSource = "Current" | "Document" | "Imported" | "Local" | "Google" | "System";
114
+
115
+ interface FontOption {
116
+ family: string;
117
+ source: FontSource;
118
+ }
119
+
120
+ const COLOR_PICKER_SIZE = { width: 292, height: 386 };
121
+ const EMPTY_FILTER_VALUE = "none";
122
+ const BOX_SHADOW_PRESETS = {
123
+ none: "none",
124
+ soft: "0 12px 36px rgba(0, 0, 0, 0.28)",
125
+ lift: "0 18px 54px rgba(0, 0, 0, 0.38)",
126
+ glow: "0 0 0 1px rgba(60, 230, 172, 0.34), 0 18px 56px rgba(60, 230, 172, 0.2)",
127
+ } as const;
128
+
129
+ type BoxShadowPreset = keyof typeof BOX_SHADOW_PRESETS | "custom";
130
+
131
+ function colorFromCss(value: string): ParsedColor {
132
+ return parseCssColor(value) ?? { red: 0, green: 0, blue: 0, alpha: 1 };
133
+ }
134
+
135
+ declare global {
136
+ interface Window {
137
+ queryLocalFonts?: () => Promise<LocalFontData[]>;
138
+ }
139
+ }
140
+
141
+ function sanitizeFontFilePart(value: string): string {
142
+ return value
143
+ .replace(/[^\w .-]+/g, " ")
144
+ .replace(/\s+/g, " ")
145
+ .trim();
146
+ }
147
+
148
+ function localFontSortScore(font: LocalFontData): number {
149
+ const style = font.style?.toLowerCase() ?? "";
150
+ const fullName = font.fullName?.toLowerCase() ?? "";
151
+ if (style === "regular" || fullName.endsWith(" regular")) return 0;
152
+ if (style === "normal" || fullName.endsWith(" normal")) return 1;
153
+ if (style === "medium" || fullName.endsWith(" medium")) return 2;
154
+ return 3;
155
+ }
156
+
157
+ function parseNumericValue(value: string | undefined): number | null {
158
+ if (!value) return null;
159
+ const parsed = Number.parseFloat(value);
160
+ return Number.isFinite(parsed) ? parsed : null;
161
+ }
162
+
163
+ function formatNumericValue(value: number): string {
164
+ const rounded = Math.round(value * 100) / 100;
165
+ return Number.isInteger(rounded)
166
+ ? `${rounded}`
167
+ : rounded.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
168
+ }
169
+
170
+ interface ParsedNumericToken {
171
+ value: number;
172
+ unit: string;
173
+ }
174
+
175
+ function parseNumericToken(value: string | undefined): ParsedNumericToken | null {
176
+ if (!value) return null;
177
+ const match = value.trim().match(/^(-?\d+(?:\.\d+)?)([a-z%]*)$/i);
178
+ if (!match) return null;
179
+ const parsed = Number.parseFloat(match[1]);
180
+ if (!Number.isFinite(parsed)) return null;
181
+ return {
182
+ value: parsed,
183
+ unit: match[2] ?? "",
184
+ };
185
+ }
186
+
187
+ function parsePxMetricValue(value: string): number | null {
188
+ const token = parseNumericToken(value);
189
+ if (!token) return null;
190
+ if (token.unit && token.unit.toLowerCase() !== "px") return null;
191
+ return token.value;
192
+ }
193
+
194
+ export function clampPanelNumber(
195
+ value: number,
196
+ min: number,
197
+ max: number,
198
+ fallback: number,
199
+ ): number {
200
+ if (!Number.isFinite(value)) return fallback;
201
+ return Math.max(min, Math.min(max, value));
202
+ }
203
+
204
+ export function normalizePanelPxValue(
205
+ value: string,
206
+ options: { min?: number; max?: number; fallback?: number } = {},
207
+ ): string | null {
208
+ const token = parseNumericToken(value.trim());
209
+ if (!token) return null;
210
+ if (token.unit && token.unit.toLowerCase() !== "px") return null;
211
+ const next = clampPanelNumber(
212
+ token.value,
213
+ options.min ?? Number.NEGATIVE_INFINITY,
214
+ options.max ?? Number.POSITIVE_INFINITY,
215
+ options.fallback ?? 0,
216
+ );
217
+ return `${formatNumericValue(next)}px`;
218
+ }
219
+
220
+ function formatPxMetricValue(value: number): string {
221
+ return `${formatNumericValue(value)}px`;
222
+ }
223
+
224
+ function normalizeTextMetricValue(property: "letter-spacing" | "line-height", value: string) {
225
+ const trimmed = value.trim();
226
+ if (!trimmed || trimmed === "normal") return trimmed || "normal";
227
+ const token = parseNumericToken(trimmed);
228
+ if (!token) return trimmed;
229
+ if (property === "letter-spacing") {
230
+ return token.unit ? trimmed : `${formatNumericValue(token.value)}px`;
231
+ }
232
+ if (token.unit) return trimmed;
233
+ return token.value > 4 ? `${formatNumericValue(token.value)}px` : formatNumericValue(token.value);
234
+ }
235
+
236
+ function splitCssFunctions(value: string): string[] {
237
+ const functions: string[] = [];
238
+ let current = "";
239
+ let depth = 0;
240
+
241
+ for (const char of value.trim()) {
242
+ if (char === "(") depth += 1;
243
+ if (char === ")") depth = Math.max(0, depth - 1);
244
+ if (/\s/.test(char) && depth === 0) {
245
+ if (current.trim()) functions.push(current.trim());
246
+ current = "";
247
+ continue;
248
+ }
249
+ current += char;
250
+ }
251
+
252
+ if (current.trim()) functions.push(current.trim());
253
+ return functions;
254
+ }
255
+
256
+ export function getCssFilterFunctionPx(value: string | undefined, name: string): number {
257
+ const normalized = value?.trim();
258
+ if (!normalized || normalized === EMPTY_FILTER_VALUE) return 0;
259
+ const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
260
+ const match = new RegExp(`(?:^|\\s)${escapedName}\\((-?\\d+(?:\\.\\d+)?)px\\)`, "i").exec(
261
+ normalized,
262
+ );
263
+ if (!match) return 0;
264
+ const parsed = Number.parseFloat(match[1]);
265
+ return Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
266
+ }
267
+
268
+ export function setCssFilterFunctionPx(
269
+ value: string | undefined,
270
+ name: string,
271
+ nextPx: number,
272
+ ): string {
273
+ const nextValue = clampPanelNumber(nextPx, 0, 200, 0);
274
+ const functions = splitCssFunctions(value && value.trim() !== EMPTY_FILTER_VALUE ? value : "");
275
+ const lowerName = name.toLowerCase();
276
+ const filtered = functions.filter((entry) => !entry.toLowerCase().startsWith(`${lowerName}(`));
277
+ if (nextValue > 0) filtered.push(`${name}(${formatNumericValue(nextValue)}px)`);
278
+ return filtered.length > 0 ? filtered.join(" ") : EMPTY_FILTER_VALUE;
279
+ }
280
+
281
+ export function inferBoxShadowPreset(value: string | undefined): BoxShadowPreset {
282
+ const normalized = value?.trim() || "none";
283
+ for (const [preset, shadow] of Object.entries(BOX_SHADOW_PRESETS)) {
284
+ if (normalized === shadow) return preset as BoxShadowPreset;
285
+ }
286
+ return normalized === "none" ? "none" : "custom";
287
+ }
288
+
289
+ function buildBoxShadowPresetValue(preset: BoxShadowPreset, fallback: string | undefined): string {
290
+ if (preset === "custom") return fallback?.trim() || "none";
291
+ return BOX_SHADOW_PRESETS[preset];
292
+ }
293
+
294
+ export function inferClipPathPreset(
295
+ value: string | undefined,
296
+ ): "none" | "inset" | "circle" | "custom" {
297
+ const normalized = value?.trim();
298
+ if (!normalized || normalized === "none") return "none";
299
+ if (/^inset\(/i.test(normalized)) return "inset";
300
+ if (/^circle\(/i.test(normalized)) return "circle";
301
+ return "custom";
302
+ }
303
+
304
+ export function getClipPathInsetPx(value: string | undefined): number {
305
+ const match = /^inset\(\s*(-?\d+(?:\.\d+)?)px\b/i.exec(value?.trim() ?? "");
306
+ if (!match) return 0;
307
+ const parsed = Number.parseFloat(match[1]);
308
+ return Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
309
+ }
310
+
311
+ export function buildStrokeWidthStyleUpdates(
312
+ nextWidth: string,
313
+ currentBorderStyle: string | undefined,
314
+ ): Array<[property: string, value: string]> {
315
+ const updates: Array<[property: string, value: string]> = [["border-width", nextWidth]];
316
+ const token = parseNumericToken(nextWidth);
317
+ const style = currentBorderStyle?.trim().toLowerCase() || "none";
318
+ if (token && token.value > 0 && (style === "none" || style === "hidden")) {
319
+ updates.push(["border-style", "solid"]);
320
+ }
321
+ return updates;
322
+ }
323
+
324
+ export function buildStrokeStyleUpdates(
325
+ nextStyle: string,
326
+ currentBorderWidth: string | undefined,
327
+ ): Array<[property: string, value: string]> {
328
+ const updates: Array<[property: string, value: string]> = [["border-style", nextStyle]];
329
+ const style = nextStyle.trim().toLowerCase();
330
+ if (!style || style === "none" || style === "hidden") return updates;
331
+
332
+ const token = parseNumericToken(currentBorderWidth?.trim() || "0");
333
+ if (!token || token.value <= 0) {
334
+ updates.push(["border-width", "1px"]);
335
+ }
336
+ return updates;
337
+ }
338
+
339
+ function buildClipPathValue(
340
+ preset: "none" | "inset" | "circle" | "custom",
341
+ radiusValue: number,
342
+ fallback: string | undefined,
343
+ ) {
344
+ if (preset === "custom") return fallback?.trim() || "none";
345
+ if (preset === "circle") return "circle(50% at 50% 50%)";
346
+ if (preset === "inset") {
347
+ return `inset(0 round ${formatNumericValue(Math.max(0, radiusValue))}px)`;
348
+ }
349
+ return "none";
350
+ }
351
+
352
+ function buildInsetClipPathValue(insetPx: number, radiusValue: number): string {
353
+ return `inset(${formatNumericValue(Math.max(0, insetPx))}px round ${formatNumericValue(Math.max(0, radiusValue))}px)`;
354
+ }
355
+
356
+ function adjustNumericToken(
357
+ value: string,
358
+ direction: 1 | -1,
359
+ modifiers?: { shiftKey?: boolean; altKey?: boolean },
360
+ ): string | null {
361
+ const token = parseNumericToken(value);
362
+ if (!token) return null;
363
+
364
+ const baseStep = modifiers?.altKey ? 0.1 : modifiers?.shiftKey ? 10 : 1;
365
+ const nextValue = token.value + baseStep * direction;
366
+ return `${formatNumericValue(nextValue)}${token.unit}`;
367
+ }
368
+
369
+ function formatColorToken(value: string): string {
370
+ const parsed = parseCssColor(value);
371
+ if (!parsed) return value;
372
+ const hex = toColorPickerValue(value).replace(/^#/, "").toUpperCase();
373
+ return `${hex} / ${Math.round(parsed.alpha * 100)}%`;
374
+ }
375
+
376
+ function extractBackgroundImageUrl(value: string | undefined): string {
377
+ if (!value) return "";
378
+ const lowerValue = value.toLowerCase();
379
+ const urlStart = lowerValue.indexOf("url(");
380
+ if (urlStart < 0) return "";
381
+
382
+ let index = urlStart + 4;
383
+ while (
384
+ index < value.length &&
385
+ (value[index] === " " ||
386
+ value[index] === "\n" ||
387
+ value[index] === "\r" ||
388
+ value[index] === "\t" ||
389
+ value[index] === "\f")
390
+ ) {
391
+ index += 1;
392
+ }
393
+
394
+ const quote = value[index] === '"' || value[index] === "'" ? value[index] : null;
395
+ if (quote) {
396
+ index += 1;
397
+ const endQuote = value.indexOf(quote, index);
398
+ return endQuote >= index ? value.slice(index, endQuote) : "";
399
+ }
400
+
401
+ const endParen = value.indexOf(")", index);
402
+ if (endParen < index) return "";
403
+ return value.slice(index, endParen).trim();
404
+ }
405
+
406
+ function normalizeProjectPath(value: string): string {
407
+ const trimmed = value.trim();
408
+ const maybeUrl = /^[a-z]+:\/\//i.test(trimmed) ? new URL(trimmed).pathname : trimmed;
409
+ return decodeURIComponent(maybeUrl)
410
+ .replace(/\\/g, "/")
411
+ .replace(/^\.?\//, "");
412
+ }
413
+
414
+ function toRelativeProjectAssetPath(sourceFile: string, assetPath: string): string {
415
+ const fromParts = normalizeProjectPath(sourceFile).split("/").filter(Boolean);
416
+ const targetParts = normalizeProjectPath(assetPath).split("/").filter(Boolean);
417
+
418
+ fromParts.pop();
419
+
420
+ while (fromParts.length > 0 && targetParts.length > 0 && fromParts[0] === targetParts[0]) {
421
+ fromParts.shift();
422
+ targetParts.shift();
423
+ }
424
+
425
+ return [...fromParts.map(() => ".."), ...targetParts].join("/") || assetPath;
426
+ }
427
+
428
+ function toProjectRootAssetPath(assetPath: string): string {
429
+ return normalizeProjectPath(assetPath);
430
+ }
431
+
432
+ function resolveSelectedAsset(
433
+ imageUrl: string,
434
+ sourceFile: string,
435
+ assets: string[],
436
+ ): string | null {
437
+ const normalizedUrl = normalizeProjectPath(imageUrl);
438
+ if (!normalizedUrl) return null;
439
+
440
+ for (const asset of assets) {
441
+ const normalizedAsset = normalizeProjectPath(asset);
442
+ const relativeAsset = toRelativeProjectAssetPath(sourceFile, asset);
443
+ if (
444
+ normalizedUrl === normalizedAsset ||
445
+ normalizedUrl === relativeAsset ||
446
+ normalizedUrl.endsWith(`/${normalizedAsset}`) ||
447
+ normalizedUrl.endsWith(`/${relativeAsset}`)
448
+ ) {
449
+ return asset;
450
+ }
451
+ }
452
+
453
+ return null;
454
+ }
455
+
456
+ function collectSelectionColors(styles: Record<string, string>) {
457
+ const candidates = [
458
+ { source: "Fill", value: styles["background-color"] },
459
+ { source: "Text", value: styles.color },
460
+ ];
461
+
462
+ const deduped = new Map<string, { swatch: string; token: string; sources: string[] }>();
463
+
464
+ for (const candidate of candidates) {
465
+ if (!candidate.value) continue;
466
+ const parsed = parseCssColor(candidate.value);
467
+ if (!parsed || parsed.alpha <= 0) continue;
468
+
469
+ const key = `${toColorPickerValue(candidate.value)}-${Math.round(parsed.alpha * 100)}`;
470
+ const existing = deduped.get(key);
471
+ if (existing) {
472
+ existing.sources.push(candidate.source);
473
+ continue;
474
+ }
475
+
476
+ deduped.set(key, {
477
+ swatch: toColorPickerValue(candidate.value),
478
+ token: formatColorToken(candidate.value),
479
+ sources: [candidate.source],
480
+ });
481
+ }
482
+
483
+ return Array.from(deduped.values());
484
+ }
485
+
486
+ function CommitField({
487
+ value,
488
+ disabled,
489
+ liveCommit,
490
+ onCommit,
491
+ }: {
492
+ value: string;
493
+ disabled?: boolean;
494
+ liveCommit?: boolean;
495
+ onCommit: (nextValue: string) => void;
496
+ }) {
497
+ const [draft, setDraft] = useState(value);
498
+ const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
499
+ const valueRef = useRef(value);
500
+
501
+ valueRef.current = value;
502
+
503
+ useEffect(() => {
504
+ setDraft(value);
505
+ }, [value]);
506
+
507
+ useEffect(
508
+ () => () => {
509
+ if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
510
+ },
511
+ [],
512
+ );
513
+
514
+ const commitDraft = (nextDraft: string) => {
515
+ if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
516
+ if (nextDraft !== valueRef.current) {
517
+ onCommit(nextDraft);
518
+ }
519
+ };
520
+
521
+ const scheduleCommit = (nextDraft: string) => {
522
+ if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
523
+ commitTimerRef.current = setTimeout(() => {
524
+ if (nextDraft !== valueRef.current) {
525
+ onCommit(nextDraft);
526
+ }
527
+ }, 120);
528
+ };
529
+
530
+ return (
531
+ <input
532
+ type="text"
533
+ value={draft}
534
+ disabled={disabled}
535
+ onChange={(e) => {
536
+ setDraft(e.target.value);
537
+ if (liveCommit) scheduleCommit(e.target.value);
538
+ }}
539
+ onBlur={() => commitDraft(draft)}
540
+ onWheel={(e) => {
541
+ if (disabled) return;
542
+ const delta = e.deltaY === 0 ? e.deltaX : e.deltaY;
543
+ if (delta === 0) return;
544
+ const nextDraft = adjustNumericToken(draft, delta < 0 ? 1 : -1, e);
545
+ if (!nextDraft) return;
546
+ e.preventDefault();
547
+ setDraft(nextDraft);
548
+ scheduleCommit(nextDraft);
549
+ }}
550
+ onKeyDown={(e) => {
551
+ if (e.key === "Enter") {
552
+ (e.target as HTMLInputElement).blur();
553
+ return;
554
+ }
555
+ if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
556
+ const nextDraft = adjustNumericToken(draft, e.key === "ArrowUp" ? 1 : -1, e);
557
+ if (!nextDraft) return;
558
+ e.preventDefault();
559
+ setDraft(nextDraft);
560
+ scheduleCommit(nextDraft);
561
+ }}
562
+ title={parseNumericToken(value) ? "Scroll or use Arrow keys to adjust" : undefined}
563
+ className="min-w-0 w-full bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600"
564
+ />
565
+ );
566
+ }
567
+
568
+ function MetricField({
18
569
  label,
19
570
  value,
20
- onChange,
571
+ disabled,
572
+ liveCommit,
573
+ onCommit,
21
574
  }: {
22
575
  label: string;
23
576
  value: string;
24
- onChange: (v: string) => void;
577
+ disabled?: boolean;
578
+ liveCommit?: boolean;
579
+ onCommit: (nextValue: string) => void;
25
580
  }) {
26
581
  return (
27
- <div className="flex items-center gap-2">
28
- <span className="text-2xs text-neutral-600 w-16 flex-shrink-0 text-right">{label}</span>
29
- <input
30
- type="text"
31
- value={value}
32
- onChange={(e) => onChange(e.target.value)}
33
- className="flex-1 bg-neutral-900 border border-neutral-800 rounded px-1.5 py-0.5 text-2xs text-neutral-200 font-mono outline-none focus:border-neutral-600 min-w-0"
582
+ <div className={FIELD}>
583
+ <div className="flex min-w-0 items-center gap-3">
584
+ <span className="flex-shrink-0 text-[11px] font-medium text-neutral-500">{label}</span>
585
+ <CommitField
586
+ value={value}
587
+ disabled={disabled}
588
+ liveCommit={liveCommit}
589
+ onCommit={onCommit}
590
+ />
591
+ </div>
592
+ </div>
593
+ );
594
+ }
595
+
596
+ function DetailField({
597
+ label,
598
+ value,
599
+ disabled,
600
+ onCommit,
601
+ }: {
602
+ label: string;
603
+ value: string;
604
+ disabled?: boolean;
605
+ onCommit: (nextValue: string) => void;
606
+ }) {
607
+ return (
608
+ <label className="grid min-w-0 gap-1.5">
609
+ <span className={LABEL}>{label}</span>
610
+ <div className={FIELD}>
611
+ <CommitField value={value} disabled={disabled} onCommit={onCommit} />
612
+ </div>
613
+ </label>
614
+ );
615
+ }
616
+
617
+ function TextAreaField({
618
+ label,
619
+ value,
620
+ disabled,
621
+ autoFocus,
622
+ onCommit,
623
+ }: {
624
+ label: string;
625
+ value: string;
626
+ disabled?: boolean;
627
+ autoFocus?: boolean;
628
+ onCommit: (nextValue: string) => void;
629
+ }) {
630
+ const [draft, setDraft] = useState(value);
631
+ const textareaRef = useRef<HTMLTextAreaElement | null>(null);
632
+ const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
633
+ const focusedRef = useRef(false);
634
+ const valueRef = useRef(value);
635
+
636
+ valueRef.current = value;
637
+
638
+ useEffect(() => {
639
+ if (focusedRef.current) return;
640
+ setDraft(value);
641
+ }, [value]);
642
+
643
+ useEffect(
644
+ () => () => {
645
+ if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
646
+ },
647
+ [],
648
+ );
649
+
650
+ useEffect(() => {
651
+ if (!autoFocus) return;
652
+ textareaRef.current?.focus();
653
+ }, [autoFocus]);
654
+
655
+ const commitDraft = (nextDraft: string) => {
656
+ if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
657
+ if (nextDraft !== valueRef.current) {
658
+ onCommit(nextDraft);
659
+ }
660
+ };
661
+
662
+ const scheduleCommit = (nextDraft: string) => {
663
+ if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
664
+ commitTimerRef.current = setTimeout(() => {
665
+ if (nextDraft !== valueRef.current) {
666
+ onCommit(nextDraft);
667
+ }
668
+ }, 120);
669
+ };
670
+
671
+ return (
672
+ <label className="grid min-w-0 gap-1.5">
673
+ <span className={LABEL}>{label}</span>
674
+ <div className={FIELD}>
675
+ <textarea
676
+ ref={textareaRef}
677
+ value={draft}
678
+ disabled={disabled}
679
+ rows={4}
680
+ onFocus={() => {
681
+ focusedRef.current = true;
682
+ }}
683
+ onChange={(e) => {
684
+ setDraft(e.target.value);
685
+ scheduleCommit(e.target.value);
686
+ }}
687
+ onBlur={() => {
688
+ focusedRef.current = false;
689
+ commitDraft(draft);
690
+ }}
691
+ className="w-full resize-none bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600"
692
+ />
693
+ </div>
694
+ </label>
695
+ );
696
+ }
697
+
698
+ function formatTextFieldPreview(value: string): string {
699
+ const collapsed = value.trim().replace(/\s+/g, " ");
700
+ if (collapsed.length <= 56) return collapsed;
701
+ return `${collapsed.slice(0, 55)}…`;
702
+ }
703
+
704
+ function getTextFieldColor(
705
+ field: { computedStyles: Record<string, string> },
706
+ inheritedStyles: Record<string, string>,
707
+ ): string {
708
+ return field.computedStyles.color || inheritedStyles.color || "rgb(0, 0, 0)";
709
+ }
710
+
711
+ function splitFontFamilies(value: string): string[] {
712
+ const families: string[] = [];
713
+ let current = "";
714
+ let quote: '"' | "'" | null = null;
715
+
716
+ for (const char of value) {
717
+ if ((char === '"' || char === "'") && !quote) {
718
+ quote = char;
719
+ continue;
720
+ }
721
+ if (char === quote) {
722
+ quote = null;
723
+ continue;
724
+ }
725
+ if (char === "," && !quote) {
726
+ if (current.trim()) families.push(current.trim());
727
+ current = "";
728
+ continue;
729
+ }
730
+ current += char;
731
+ }
732
+
733
+ if (current.trim()) families.push(current.trim());
734
+ return families.map((family) => family.replace(/^["']|["']$/g, "").trim()).filter(Boolean);
735
+ }
736
+
737
+ function primaryFontFamily(value: string): string {
738
+ return splitFontFamilies(value)[0] ?? "inherit";
739
+ }
740
+
741
+ function quoteFontFamily(family: string): string {
742
+ const trimmed = family.trim();
743
+ if (GENERIC_FONT_FAMILIES.has(trimmed.toLowerCase())) return trimmed;
744
+ return `"${trimmed.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
745
+ }
746
+
747
+ function buildFontFamilyValue(family: string): string {
748
+ const trimmed = family.trim();
749
+ if (!trimmed) return "inherit";
750
+ if (GENERIC_FONT_FAMILIES.has(trimmed.toLowerCase())) return trimmed;
751
+ return `${quoteFontFamily(trimmed)}, ui-sans-serif, system-ui, sans-serif`;
752
+ }
753
+
754
+ function collectDocumentFontFamilies(): string[] {
755
+ if (typeof document === "undefined") return [];
756
+ const fontSet = document.fonts;
757
+ if (!fontSet) return [];
758
+ return Array.from(fontSet, (fontFace) => fontFace.family.replace(/^["']|["']$/g, "").trim())
759
+ .filter(Boolean)
760
+ .sort((a, b) => a.localeCompare(b));
761
+ }
762
+
763
+ function uniqueFontFamilies(values: string[]): string[] {
764
+ const seen = new Set<string>();
765
+ const result: string[] = [];
766
+ for (const value of values) {
767
+ const family = value.trim();
768
+ if (!family) continue;
769
+ const key = family.toLowerCase();
770
+ if (seen.has(key)) continue;
771
+ seen.add(key);
772
+ result.push(family);
773
+ }
774
+ return result;
775
+ }
776
+
777
+ function uniqueFontOptions(values: FontOption[]): FontOption[] {
778
+ const seen = new Set<string>();
779
+ const result: FontOption[] = [];
780
+ for (const value of values) {
781
+ const family = value.family.trim();
782
+ if (!family) continue;
783
+ const key = family.toLowerCase();
784
+ if (seen.has(key)) continue;
785
+ seen.add(key);
786
+ result.push({ family, source: value.source });
787
+ }
788
+ return result;
789
+ }
790
+
791
+ function fontSourceRank(source: FontSource): number {
792
+ if (source === "Current") return 0;
793
+ if (source === "Document") return 1;
794
+ if (source === "Imported") return 2;
795
+ if (source === "Local") return 3;
796
+ if (source === "Google") return 4;
797
+ return 5;
798
+ }
799
+
800
+ function sortFontOptions(options: FontOption[]): FontOption[] {
801
+ return [...options].sort((a, b) => {
802
+ const rankDelta = fontSourceRank(a.source) - fontSourceRank(b.source);
803
+ if (rankDelta !== 0) return rankDelta;
804
+
805
+ const commonA = COMMON_LOCAL_FONT_FAMILIES.findIndex(
806
+ (family) => family.toLowerCase() === a.family.toLowerCase(),
807
+ );
808
+ const commonB = COMMON_LOCAL_FONT_FAMILIES.findIndex(
809
+ (family) => family.toLowerCase() === b.family.toLowerCase(),
810
+ );
811
+ const commonDelta =
812
+ (commonA === -1 ? Number.MAX_SAFE_INTEGER : commonA) -
813
+ (commonB === -1 ? Number.MAX_SAFE_INTEGER : commonB);
814
+
815
+ return commonDelta === 0 ? a.family.localeCompare(b.family) : commonDelta;
816
+ });
817
+ }
818
+
819
+ function fontSearchKey(value: string): string {
820
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
821
+ }
822
+
823
+ function fontMatchesQuery(family: string, query: string): boolean {
824
+ const normalizedQuery = query.trim().toLowerCase();
825
+ if (!normalizedQuery) return true;
826
+ const normalizedFamily = family.toLowerCase();
827
+ if (normalizedFamily.includes(normalizedQuery)) return true;
828
+ return fontSearchKey(family).includes(fontSearchKey(normalizedQuery));
829
+ }
830
+
831
+ function loadGoogleFontStylesheet(family: string): void {
832
+ if (typeof document === "undefined") return;
833
+ const trimmed = family.trim();
834
+ if (!trimmed) return;
835
+
836
+ const id = `studio-google-font-${trimmed.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
837
+ if (document.getElementById(id)) return;
838
+
839
+ const preconnect = document.querySelector('link[data-studio-google-font-preconnect="true"]');
840
+ if (!preconnect) {
841
+ const preconnectEl = document.createElement("link");
842
+ preconnectEl.setAttribute("data-studio-google-font-preconnect", "true");
843
+ preconnectEl.rel = "preconnect";
844
+ preconnectEl.href = "https://fonts.gstatic.com";
845
+ preconnectEl.crossOrigin = "anonymous";
846
+ document.head.appendChild(preconnectEl);
847
+ }
848
+
849
+ const link = document.createElement("link");
850
+ link.id = id;
851
+ link.rel = "stylesheet";
852
+ link.href = googleFontStylesheetUrl(trimmed);
853
+ document.head.appendChild(link);
854
+ }
855
+
856
+ function loadImportedFontStylesheet(asset: ImportedFontAsset): void {
857
+ if (typeof document === "undefined") return;
858
+ const id = `studio-imported-font-${asset.family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
859
+ if (document.getElementById(id)) return;
860
+
861
+ const style = document.createElement("style");
862
+ style.id = id;
863
+ style.textContent = importedFontFaceCss(asset);
864
+ document.head.appendChild(style);
865
+ }
866
+
867
+ function FontWeightField({
868
+ value,
869
+ disabled,
870
+ onCommit,
871
+ }: {
872
+ value: string;
873
+ disabled?: boolean;
874
+ onCommit: (nextValue: string) => void;
875
+ }) {
876
+ const options = ["300", "400", "500", "600", "700", "800"];
877
+ return (
878
+ <div className={FIELD}>
879
+ <div className="flex min-w-0 items-center gap-3">
880
+ <span className="flex-shrink-0 text-[11px] font-medium text-neutral-500">Weight</span>
881
+ <select
882
+ value={value}
883
+ disabled={disabled}
884
+ onChange={(e) => onCommit(e.target.value)}
885
+ className="min-w-0 w-full appearance-none bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600"
886
+ >
887
+ {options.map((option) => (
888
+ <option key={option} value={option}>
889
+ {option}
890
+ </option>
891
+ ))}
892
+ </select>
893
+ </div>
894
+ </div>
895
+ );
896
+ }
897
+
898
+ function FontFamilyField({
899
+ value,
900
+ disabled,
901
+ importedFonts,
902
+ onImportFonts,
903
+ onCommit,
904
+ }: {
905
+ value: string;
906
+ disabled?: boolean;
907
+ importedFonts: ImportedFontAsset[];
908
+ onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
909
+ onCommit: (nextValue: string) => void;
910
+ }) {
911
+ const currentFamily = primaryFontFamily(value);
912
+ const containerRef = useRef<HTMLDivElement | null>(null);
913
+ const inputRef = useRef<HTMLInputElement | null>(null);
914
+ const fontInputRef = useRef<HTMLInputElement | null>(null);
915
+ const [open, setOpen] = useState(false);
916
+ const [query, setQuery] = useState("");
917
+ const [localFonts, setLocalFonts] = useState<string[]>([]);
918
+ const [localFontData, setLocalFontData] = useState<LocalFontData[]>([]);
919
+ const [googleFonts, setGoogleFonts] = useState<string[]>(() => [...POPULAR_GOOGLE_FONT_FAMILIES]);
920
+ const [loadingLocalFonts, setLoadingLocalFonts] = useState(false);
921
+ const [loadingGoogleFonts, setLoadingGoogleFonts] = useState(false);
922
+ const [importingFonts, setImportingFonts] = useState(false);
923
+ const [fontNotice, setFontNotice] = useState<string | null>(null);
924
+ const canQueryLocalFonts =
925
+ typeof window !== "undefined" && typeof window.queryLocalFonts === "function";
926
+
927
+ useEffect(() => {
928
+ if (!open) return;
929
+ const handlePointerDown = (event: PointerEvent) => {
930
+ const target = event.target;
931
+ if (!(target instanceof Node)) return;
932
+ if (!containerRef.current?.contains(target)) setOpen(false);
933
+ };
934
+ document.addEventListener("pointerdown", handlePointerDown);
935
+ return () => {
936
+ document.removeEventListener("pointerdown", handlePointerDown);
937
+ };
938
+ }, [open]);
939
+
940
+ useEffect(() => {
941
+ if (!open) return;
942
+ requestAnimationFrame(() => inputRef.current?.focus());
943
+ }, [open]);
944
+
945
+ useEffect(() => {
946
+ let cancelled = false;
947
+ void fetch("/api/fonts")
948
+ .then((response) => (response.ok ? response.json() : null))
949
+ .then((data: { fonts?: string[] } | null) => {
950
+ const fonts = data?.fonts;
951
+ if (cancelled || !Array.isArray(fonts)) return;
952
+ setLocalFonts((current) => uniqueFontFamilies([...current, ...fonts]));
953
+ })
954
+ .catch(() => undefined);
955
+ return () => {
956
+ cancelled = true;
957
+ };
958
+ }, []);
959
+
960
+ useEffect(() => {
961
+ let cancelled = false;
962
+ setLoadingGoogleFonts(true);
963
+ void fetch("/api/fonts/google")
964
+ .then((response) => (response.ok ? response.json() : null))
965
+ .then((data: { fonts?: string[] } | null) => {
966
+ const fonts = data?.fonts;
967
+ if (cancelled || !Array.isArray(fonts)) return;
968
+ setGoogleFonts(uniqueFontFamilies([...fonts, ...POPULAR_GOOGLE_FONT_FAMILIES]));
969
+ })
970
+ .catch(() => undefined)
971
+ .finally(() => {
972
+ if (!cancelled) setLoadingGoogleFonts(false);
973
+ });
974
+ return () => {
975
+ cancelled = true;
976
+ };
977
+ }, []);
978
+
979
+ useEffect(() => {
980
+ if (googleFonts.some((family) => family.toLowerCase() === currentFamily.toLowerCase())) {
981
+ loadGoogleFontStylesheet(currentFamily);
982
+ }
983
+ const imported = importedFonts.find(
984
+ (font) => font.family.toLowerCase() === currentFamily.toLowerCase(),
985
+ );
986
+ if (imported) loadImportedFontStylesheet(imported);
987
+ }, [currentFamily, googleFonts, importedFonts]);
988
+
989
+ const loadBrowserLocalFonts = async () => {
990
+ if (!canQueryLocalFonts || !window.queryLocalFonts) {
991
+ setFontNotice("This browser does not expose installed fonts. Import a font file instead.");
992
+ return;
993
+ }
994
+ setLoadingLocalFonts(true);
995
+ setFontNotice(null);
996
+ try {
997
+ const fonts = await window.queryLocalFonts();
998
+ const sortedFonts = [...fonts].sort((a, b) => localFontSortScore(a) - localFontSortScore(b));
999
+ const families = sortedFonts
1000
+ .map((font) => font.family)
1001
+ .filter((name): name is string => Boolean(name))
1002
+ .map((name) => fontFamilyFromAssetPath(`${name}.ttf`));
1003
+ setLocalFontData(sortedFonts);
1004
+ setLocalFonts((current) => uniqueFontFamilies([...current, ...families]));
1005
+ setFontNotice(fonts.length === 0 ? "No browser-local fonts were returned." : null);
1006
+ } catch (error) {
1007
+ const name = error instanceof Error ? error.name : "";
1008
+ setFontNotice(
1009
+ name === "NotAllowedError"
1010
+ ? "Local font access was denied. Import a font file instead."
1011
+ : "Local font access is unavailable. Import a font file instead.",
1012
+ );
1013
+ } finally {
1014
+ setLoadingLocalFonts(false);
1015
+ }
1016
+ };
1017
+
1018
+ const handleImportFonts = async (files: FileList | File[] | null) => {
1019
+ if (!files?.length || !onImportFonts) return;
1020
+ setImportingFonts(true);
1021
+ setFontNotice(null);
1022
+ try {
1023
+ const imported = await onImportFonts(files);
1024
+ for (const font of imported) loadImportedFontStylesheet(font);
1025
+ const first = imported[0];
1026
+ if (first) {
1027
+ onCommit(buildFontFamilyValue(first.family));
1028
+ setQuery("");
1029
+ setOpen(false);
1030
+ } else {
1031
+ setFontNotice("No supported font files were imported.");
1032
+ }
1033
+ } finally {
1034
+ setImportingFonts(false);
1035
+ }
1036
+ };
1037
+
1038
+ const projectFontAssets = useMemo(
1039
+ () =>
1040
+ uniqueFontOptions(
1041
+ importedFonts.map((font): FontOption => ({ family: font.family, source: "Imported" })),
1042
+ ),
1043
+ [importedFonts],
1044
+ );
1045
+
1046
+ const options = useMemo(() => {
1047
+ const documentFonts = collectDocumentFontFamilies();
1048
+ return sortFontOptions(
1049
+ uniqueFontOptions([
1050
+ { family: currentFamily, source: "Current" },
1051
+ ...documentFonts.map((family): FontOption => ({ family, source: "Document" })),
1052
+ ...projectFontAssets,
1053
+ ...localFonts.map((family): FontOption => ({ family, source: "Local" })),
1054
+ ...googleFonts.map((family): FontOption => ({ family, source: "Google" })),
1055
+ ...DEFAULT_FONT_FAMILIES.map((family): FontOption => ({ family, source: "System" })),
1056
+ ]),
1057
+ );
1058
+ }, [currentFamily, googleFonts, localFonts, projectFontAssets]);
1059
+
1060
+ const filteredOptions = useMemo(() => {
1061
+ const matches = options.filter((option) => fontMatchesQuery(option.family, query));
1062
+ return matches.slice(0, query.trim() ? 120 : 160);
1063
+ }, [options, query]);
1064
+
1065
+ const importLocalFont = async (family: string): Promise<ImportedFontAsset | null> => {
1066
+ if (!onImportFonts) return null;
1067
+ const candidates = localFontData
1068
+ .filter((font) => fontFamilyFromAssetPath(`${font.family}.ttf`) === family)
1069
+ .sort((a, b) => localFontSortScore(a) - localFontSortScore(b));
1070
+ const font = candidates.find((entry) => typeof entry.blob === "function");
1071
+ if (!font?.blob) return null;
1072
+
1073
+ const blob = await font.blob();
1074
+ const style = sanitizeFontFilePart(font.style ?? "Regular") || "Regular";
1075
+ const name = sanitizeFontFilePart(`${family} ${style}`) || family;
1076
+ const file = new File([blob], `${name}.ttf`, {
1077
+ type: blob.type || "font/ttf",
1078
+ });
1079
+ const imported = await onImportFonts([file]);
1080
+ return (
1081
+ imported.find((asset) => asset.family.toLowerCase() === family.toLowerCase()) ??
1082
+ imported[0] ??
1083
+ null
1084
+ );
1085
+ };
1086
+
1087
+ const commitFamily = async (option: FontOption) => {
1088
+ if (option.source === "Local") {
1089
+ setImportingFonts(true);
1090
+ setFontNotice(null);
1091
+ try {
1092
+ const imported = await importLocalFont(option.family);
1093
+ if (imported) {
1094
+ loadImportedFontStylesheet(imported);
1095
+ onCommit(buildFontFamilyValue(imported.family));
1096
+ setQuery("");
1097
+ setOpen(false);
1098
+ return;
1099
+ }
1100
+ onCommit(buildFontFamilyValue(option.family));
1101
+ setQuery("");
1102
+ setOpen(false);
1103
+ } finally {
1104
+ setImportingFonts(false);
1105
+ }
1106
+ return;
1107
+ }
1108
+
1109
+ if (option.source === "Google") {
1110
+ loadGoogleFontStylesheet(option.family);
1111
+ }
1112
+ const imported = importedFonts.find(
1113
+ (font) => font.family.toLowerCase() === option.family.toLowerCase(),
1114
+ );
1115
+ if (imported) loadImportedFontStylesheet(imported);
1116
+ onCommit(buildFontFamilyValue(option.family));
1117
+ setQuery("");
1118
+ setOpen(false);
1119
+ };
1120
+
1121
+ return (
1122
+ <div ref={containerRef} className="relative grid min-w-0 gap-1.5">
1123
+ <span className={LABEL}>Font family</span>
1124
+ <button
1125
+ type="button"
1126
+ disabled={disabled}
1127
+ onClick={() => setOpen((next) => !next)}
1128
+ className={`${FIELD} flex h-10 items-center justify-between gap-3 text-left hover:border-neutral-700 disabled:cursor-not-allowed`}
1129
+ >
1130
+ <span
1131
+ className="min-w-0 flex-1 truncate text-[11px] font-medium text-neutral-100"
1132
+ style={{ fontFamily: value }}
1133
+ >
1134
+ {currentFamily}
1135
+ </span>
1136
+ <span className="flex-shrink-0 text-[10px] uppercase tracking-[0.14em] text-neutral-600">
1137
+ Font
1138
+ </span>
1139
+ </button>
1140
+
1141
+ {open && (
1142
+ <div className="absolute left-0 right-0 top-[calc(100%+6px)] z-50 overflow-hidden rounded-xl border border-neutral-700 bg-neutral-950 shadow-2xl">
1143
+ <div className="grid grid-cols-[minmax(0,1fr)_auto_auto] gap-2 border-b border-neutral-800 p-2">
1144
+ <input
1145
+ ref={inputRef}
1146
+ type="text"
1147
+ value={query}
1148
+ disabled={disabled}
1149
+ placeholder={loadingGoogleFonts ? "Loading Google Fonts..." : "Search fonts"}
1150
+ onChange={(e) => setQuery(e.target.value)}
1151
+ onKeyDown={(e) => {
1152
+ if (e.key === "Escape") {
1153
+ e.preventDefault();
1154
+ setOpen(false);
1155
+ }
1156
+ if (e.key === "Enter" && filteredOptions[0]) {
1157
+ e.preventDefault();
1158
+ commitFamily(filteredOptions[0]);
1159
+ }
1160
+ }}
1161
+ className="min-w-0 rounded-lg border border-neutral-800 bg-neutral-900 px-2.5 py-2 text-[11px] font-medium text-neutral-100 outline-none placeholder:text-neutral-600 focus:border-neutral-600"
1162
+ />
1163
+ {canQueryLocalFonts && (
1164
+ <button
1165
+ type="button"
1166
+ disabled={disabled || loadingLocalFonts}
1167
+ onClick={loadBrowserLocalFonts}
1168
+ className="rounded-lg border border-neutral-700 bg-neutral-900 px-2.5 text-[10px] font-medium text-neutral-400 transition-colors hover:border-neutral-600 hover:text-neutral-100 disabled:cursor-not-allowed disabled:text-neutral-700"
1169
+ >
1170
+ {loadingLocalFonts ? "..." : "Local"}
1171
+ </button>
1172
+ )}
1173
+ <button
1174
+ type="button"
1175
+ disabled={disabled || importingFonts || !onImportFonts}
1176
+ onClick={() => fontInputRef.current?.click()}
1177
+ className="rounded-lg border border-neutral-700 bg-neutral-900 px-2.5 text-[10px] font-medium text-neutral-400 transition-colors hover:border-neutral-600 hover:text-neutral-100 disabled:cursor-not-allowed disabled:text-neutral-700"
1178
+ >
1179
+ {importingFonts ? "..." : "Import"}
1180
+ </button>
1181
+ <input
1182
+ ref={fontInputRef}
1183
+ type="file"
1184
+ accept=".ttf,.otf,.ttc,.woff,.woff2,.eot,font/*"
1185
+ multiple
1186
+ aria-label="Import local font files"
1187
+ disabled={disabled || importingFonts || !onImportFonts}
1188
+ className="hidden"
1189
+ onChange={async (event) => {
1190
+ await handleImportFonts(event.target.files);
1191
+ event.target.value = "";
1192
+ }}
1193
+ />
1194
+ </div>
1195
+ {fontNotice && (
1196
+ <div className="border-b border-neutral-800 px-3 py-2 text-[10px] leading-4 text-neutral-500">
1197
+ {fontNotice}
1198
+ </div>
1199
+ )}
1200
+ <div className="max-h-64 overflow-y-auto p-1">
1201
+ {filteredOptions.length === 0 ? (
1202
+ <div className="px-2 py-3 text-[11px] text-neutral-500">No fonts found.</div>
1203
+ ) : (
1204
+ filteredOptions.map((option) => (
1205
+ <button
1206
+ key={`${option.source}-${option.family}`}
1207
+ type="button"
1208
+ onClick={() => commitFamily(option)}
1209
+ className={`flex w-full min-w-0 items-center justify-between gap-3 rounded-lg px-2 py-2 text-left text-[11px] transition-colors ${
1210
+ option.family === currentFamily
1211
+ ? "bg-studio-accent/15 text-neutral-50"
1212
+ : "text-neutral-300 hover:bg-neutral-900 hover:text-neutral-100"
1213
+ }`}
1214
+ >
1215
+ <span className="min-w-0 truncate font-medium">{option.family}</span>
1216
+ <span className="flex-shrink-0 text-[9px] uppercase tracking-[0.14em] text-neutral-600">
1217
+ {option.source}
1218
+ </span>
1219
+ </button>
1220
+ ))
1221
+ )}
1222
+ </div>
1223
+ </div>
1224
+ )}
1225
+ </div>
1226
+ );
1227
+ }
1228
+
1229
+ function getTextStyleValue(
1230
+ field: DomEditSelection["textFields"][number],
1231
+ inheritedStyles: Record<string, string>,
1232
+ property: string,
1233
+ fallback: string,
1234
+ ): string {
1235
+ return field.computedStyles[property] || inheritedStyles[property] || fallback;
1236
+ }
1237
+
1238
+ function AdvancedTextControls({
1239
+ field,
1240
+ inheritedStyles,
1241
+ disabled,
1242
+ onCommit,
1243
+ }: {
1244
+ field: DomEditSelection["textFields"][number];
1245
+ inheritedStyles: Record<string, string>;
1246
+ disabled?: boolean;
1247
+ onCommit: (property: string, value: string) => void;
1248
+ }) {
1249
+ return (
1250
+ <div className="space-y-4">
1251
+ <div className={RESPONSIVE_GRID}>
1252
+ <MetricField
1253
+ label="Line"
1254
+ value={getTextStyleValue(field, inheritedStyles, "line-height", "normal")}
1255
+ disabled={disabled}
1256
+ liveCommit
1257
+ onCommit={(next) =>
1258
+ onCommit("line-height", normalizeTextMetricValue("line-height", next))
1259
+ }
1260
+ />
1261
+ <MetricField
1262
+ label="Track"
1263
+ value={getTextStyleValue(field, inheritedStyles, "letter-spacing", "0px")}
1264
+ disabled={disabled}
1265
+ liveCommit
1266
+ onCommit={(next) =>
1267
+ onCommit("letter-spacing", normalizeTextMetricValue("letter-spacing", next))
1268
+ }
1269
+ />
1270
+ </div>
1271
+ <div className={RESPONSIVE_GRID}>
1272
+ <SelectField
1273
+ label="Align"
1274
+ value={getTextStyleValue(field, inheritedStyles, "text-align", "start")}
1275
+ disabled={disabled}
1276
+ onChange={(next) => onCommit("text-align", next)}
1277
+ options={["start", "left", "center", "right", "justify", "end"]}
1278
+ />
1279
+ <SelectField
1280
+ label="Case"
1281
+ value={getTextStyleValue(field, inheritedStyles, "text-transform", "none")}
1282
+ disabled={disabled}
1283
+ onChange={(next) => onCommit("text-transform", next)}
1284
+ options={["none", "uppercase", "lowercase", "capitalize"]}
1285
+ />
1286
+ </div>
1287
+ <SelectField
1288
+ label="Style"
1289
+ value={getTextStyleValue(field, inheritedStyles, "font-style", "normal")}
1290
+ disabled={disabled}
1291
+ onChange={(next) => onCommit("font-style", next)}
1292
+ options={["normal", "italic", "oblique"]}
34
1293
  />
35
1294
  </div>
36
1295
  );
37
1296
  }
38
1297
 
39
- function ColorRow({
1298
+ function ColorField({
40
1299
  label,
41
1300
  value,
42
- onChange,
1301
+ disabled,
1302
+ onCommit,
43
1303
  }: {
44
1304
  label: string;
45
1305
  value: string;
46
- onChange: (v: string) => void;
1306
+ disabled?: boolean;
1307
+ onCommit: (nextValue: string) => void;
47
1308
  }) {
1309
+ const buttonRef = useRef<HTMLButtonElement | null>(null);
1310
+ const panelRef = useRef<HTMLDivElement | null>(null);
1311
+ const [open, setOpen] = useState(false);
1312
+ const [panelPosition, setPanelPosition] = useState<FloatingPosition | null>(null);
1313
+ const [draftColor, setDraftColor] = useState<ParsedColor>(() => colorFromCss(value));
1314
+ const [hexDraft, setHexDraft] = useState(() => toHexColor(colorFromCss(value)).toUpperCase());
1315
+ const hsv = rgbToHsv(draftColor);
1316
+ const hueColor = formatCssColor({
1317
+ ...hsvToRgb({ hue: hsv.hue, saturation: 1, value: 1 }),
1318
+ alpha: 1,
1319
+ });
1320
+ const opaqueColor = formatCssColor({ ...draftColor, alpha: 1 });
1321
+ const currentColor = formatCssColor(draftColor);
1322
+ const saturationPercent = Math.round(hsv.saturation * 100);
1323
+ const brightnessPercent = Math.round(hsv.value * 100);
1324
+ const alphaPercent = Math.round(draftColor.alpha * 100);
1325
+
1326
+ useEffect(() => {
1327
+ const nextColor = colorFromCss(value);
1328
+ setDraftColor(nextColor);
1329
+ setHexDraft(toHexColor(nextColor).toUpperCase());
1330
+ }, [value]);
1331
+
1332
+ const updatePanelPosition = useCallback(() => {
1333
+ const anchor = buttonRef.current?.getBoundingClientRect();
1334
+ if (!anchor) return;
1335
+ const measured = panelRef.current?.getBoundingClientRect();
1336
+ setPanelPosition(
1337
+ resolveFloatingPanelPosition(
1338
+ anchor,
1339
+ { width: window.innerWidth, height: window.innerHeight },
1340
+ {
1341
+ width: measured?.width || COLOR_PICKER_SIZE.width,
1342
+ height: measured?.height || COLOR_PICKER_SIZE.height,
1343
+ },
1344
+ ),
1345
+ );
1346
+ }, []);
1347
+
1348
+ useLayoutEffect(() => {
1349
+ if (!open) return;
1350
+ updatePanelPosition();
1351
+
1352
+ const handlePositionInvalidated = () => updatePanelPosition();
1353
+ window.addEventListener("resize", handlePositionInvalidated);
1354
+ window.addEventListener("scroll", handlePositionInvalidated, true);
1355
+ return () => {
1356
+ window.removeEventListener("resize", handlePositionInvalidated);
1357
+ window.removeEventListener("scroll", handlePositionInvalidated, true);
1358
+ };
1359
+ }, [open, updatePanelPosition]);
1360
+
1361
+ useEffect(() => {
1362
+ if (!open) return;
1363
+
1364
+ const handlePointerDown = (event: PointerEvent) => {
1365
+ const target = event.target as Node | null;
1366
+ if (!target) return;
1367
+ if (panelRef.current?.contains(target) || buttonRef.current?.contains(target)) return;
1368
+ setOpen(false);
1369
+ };
1370
+ const handleKeyDown = (event: KeyboardEvent) => {
1371
+ if (event.key === "Escape") setOpen(false);
1372
+ };
1373
+
1374
+ document.addEventListener("pointerdown", handlePointerDown);
1375
+ document.addEventListener("keydown", handleKeyDown);
1376
+ return () => {
1377
+ document.removeEventListener("pointerdown", handlePointerDown);
1378
+ document.removeEventListener("keydown", handleKeyDown);
1379
+ };
1380
+ }, [open]);
1381
+
1382
+ const commitColor = (nextColor: ParsedColor) => {
1383
+ setDraftColor(nextColor);
1384
+ setHexDraft(toHexColor(nextColor).toUpperCase());
1385
+ onCommit(formatCssColor(nextColor));
1386
+ };
1387
+
1388
+ const commitHsv = (nextHsv: { hue?: number; saturation?: number; value?: number }) => {
1389
+ const rgb = hsvToRgb({
1390
+ hue: nextHsv.hue ?? hsv.hue,
1391
+ saturation: nextHsv.saturation ?? hsv.saturation,
1392
+ value: nextHsv.value ?? hsv.value,
1393
+ });
1394
+ commitColor({ ...rgb, alpha: draftColor.alpha });
1395
+ };
1396
+
1397
+ const updateSaturationValue = (clientX: number, clientY: number, target: HTMLDivElement) => {
1398
+ const rect = target.getBoundingClientRect();
1399
+ const saturation = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
1400
+ const nextValue = Math.max(0, Math.min(1, 1 - (clientY - rect.top) / rect.height));
1401
+ commitHsv({ saturation, value: nextValue });
1402
+ };
1403
+
1404
+ const handleHexCommit = (nextHex: string) => {
1405
+ setHexDraft(nextHex);
1406
+ const normalized = nextHex.trim().startsWith("#") ? nextHex.trim() : `#${nextHex.trim()}`;
1407
+ const parsed = parseCssColor(normalized);
1408
+ if (!parsed) return;
1409
+ commitColor({ ...parsed, alpha: draftColor.alpha });
1410
+ };
1411
+
1412
+ const picker = open
1413
+ ? createPortal(
1414
+ <div
1415
+ ref={panelRef}
1416
+ className="fixed z-[9999] w-[292px] overflow-hidden rounded-2xl border border-neutral-700 bg-neutral-950 shadow-2xl shadow-black/50"
1417
+ style={{
1418
+ left: panelPosition?.left ?? -9999,
1419
+ top: panelPosition?.top ?? -9999,
1420
+ }}
1421
+ >
1422
+ <div className="flex items-center justify-between border-b border-neutral-800 px-3 py-2">
1423
+ <div className="min-w-0">
1424
+ <div className="truncate text-[11px] font-medium text-neutral-100">{label}</div>
1425
+ <div className="text-[9px] uppercase tracking-[0.16em] text-neutral-600">Color</div>
1426
+ </div>
1427
+ <button
1428
+ type="button"
1429
+ onClick={() => setOpen(false)}
1430
+ className="flex h-7 w-7 items-center justify-center rounded-lg text-neutral-500 transition-colors hover:bg-neutral-900 hover:text-neutral-200"
1431
+ aria-label="Close color picker"
1432
+ >
1433
+ <X size={13} />
1434
+ </button>
1435
+ </div>
1436
+ <div className="space-y-3 p-3">
1437
+ <div
1438
+ className="relative h-36 cursor-crosshair overflow-hidden rounded-xl border border-neutral-700 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.06)]"
1439
+ style={{
1440
+ backgroundColor: hueColor,
1441
+ }}
1442
+ onPointerDown={(event) => {
1443
+ event.currentTarget.setPointerCapture(event.pointerId);
1444
+ updateSaturationValue(event.clientX, event.clientY, event.currentTarget);
1445
+ }}
1446
+ onPointerMove={(event) => {
1447
+ if (event.buttons !== 1) return;
1448
+ updateSaturationValue(event.clientX, event.clientY, event.currentTarget);
1449
+ }}
1450
+ >
1451
+ <div className="absolute inset-0 bg-gradient-to-r from-white to-transparent" />
1452
+ <div className="absolute inset-0 bg-gradient-to-t from-black to-transparent" />
1453
+ <div
1454
+ className="pointer-events-none absolute top-0 h-full w-px -translate-x-1/2 bg-white/70 shadow-[0_0_0_1px_rgba(0,0,0,0.45)] mix-blend-difference"
1455
+ style={{ left: `${hsv.saturation * 100}%` }}
1456
+ />
1457
+ <div
1458
+ className="pointer-events-none absolute left-0 h-px w-full -translate-y-1/2 bg-white/70 shadow-[0_0_0_1px_rgba(0,0,0,0.45)] mix-blend-difference"
1459
+ style={{ top: `${(1 - hsv.value) * 100}%` }}
1460
+ />
1461
+ <div
1462
+ className="pointer-events-none absolute h-6 w-6 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.85),0_8px_18px_rgba(0,0,0,0.45)]"
1463
+ style={{
1464
+ left: `${hsv.saturation * 100}%`,
1465
+ top: `${(1 - hsv.value) * 100}%`,
1466
+ backgroundColor: opaqueColor,
1467
+ }}
1468
+ />
1469
+ </div>
1470
+
1471
+ <div className="flex min-w-0 items-center gap-3">
1472
+ <div
1473
+ className="h-9 w-9 flex-shrink-0 rounded-xl border border-neutral-600 shadow-[inset_0_1px_0_rgba(255,255,255,0.08)]"
1474
+ style={{ backgroundColor: currentColor }}
1475
+ />
1476
+ <div className="min-w-0 flex-1">
1477
+ <div className="truncate text-[11px] font-medium text-neutral-100">
1478
+ {currentColor}
1479
+ </div>
1480
+ <div className="mt-0.5 text-[9px] uppercase tracking-[0.12em] text-neutral-600">
1481
+ S {saturationPercent}% · B {brightnessPercent}% · A {alphaPercent}%
1482
+ </div>
1483
+ </div>
1484
+ </div>
1485
+
1486
+ <ColorSlider
1487
+ label="Hue"
1488
+ value={hsv.hue}
1489
+ min={0}
1490
+ max={360}
1491
+ step={1}
1492
+ displayValue={`${Math.round(hsv.hue)}°`}
1493
+ background="linear-gradient(90deg, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)"
1494
+ thumbColor={hueColor}
1495
+ disabled={disabled}
1496
+ onCommit={(nextHue) => commitHsv({ hue: nextHue })}
1497
+ />
1498
+
1499
+ <ColorSlider
1500
+ label="Alpha"
1501
+ value={draftColor.alpha}
1502
+ min={0}
1503
+ max={1}
1504
+ step={0.01}
1505
+ displayValue={`${alphaPercent}%`}
1506
+ background={`linear-gradient(90deg, transparent, ${opaqueColor})`}
1507
+ thumbColor={currentColor}
1508
+ disabled={disabled}
1509
+ onCommit={(nextAlpha) => commitColor({ ...draftColor, alpha: nextAlpha })}
1510
+ />
1511
+
1512
+ <label className="grid gap-1.5">
1513
+ <span className={LABEL}>Hex</span>
1514
+ <input
1515
+ value={hexDraft}
1516
+ onChange={(event) => handleHexCommit(event.target.value)}
1517
+ className={`${FIELD} h-10 w-full text-[11px] font-medium outline-none`}
1518
+ spellCheck={false}
1519
+ />
1520
+ </label>
1521
+ </div>
1522
+ </div>,
1523
+ document.body,
1524
+ )
1525
+ : null;
1526
+
1527
+ const openPicker = () => {
1528
+ if (disabled) return;
1529
+ setOpen((current) => !current);
1530
+ if (!open) {
1531
+ requestAnimationFrame(updatePanelPosition);
1532
+ }
1533
+ };
1534
+
48
1535
  return (
49
- <div className="flex items-center gap-2">
50
- <span className="text-2xs text-neutral-600 w-16 flex-shrink-0 text-right">{label}</span>
51
- <div className="flex items-center gap-1 flex-1">
1536
+ <div className="grid min-w-0 gap-1.5">
1537
+ <span className={LABEL}>{label}</span>
1538
+ <button
1539
+ type="button"
1540
+ disabled={disabled}
1541
+ aria-label={`Pick ${label.toLowerCase()} color`}
1542
+ ref={buttonRef}
1543
+ onClick={openPicker}
1544
+ className={`${FIELD} flex items-center gap-3 text-left hover:border-neutral-700 disabled:cursor-not-allowed ${open ? "border-neutral-600" : ""}`}
1545
+ >
52
1546
  <div
53
- className="w-5 h-5 rounded border border-neutral-700 flex-shrink-0"
54
- style={{ backgroundColor: value }}
1547
+ className="relative h-7 w-7 flex-shrink-0 overflow-hidden rounded-lg border border-neutral-700 bg-neutral-950 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]"
1548
+ style={{ backgroundColor: value || "transparent" }}
55
1549
  />
56
- <input
57
- type="text"
58
- value={value}
59
- onChange={(e) => onChange(e.target.value)}
60
- className="flex-1 bg-neutral-900 border border-neutral-800 rounded px-1.5 py-0.5 text-2xs text-neutral-200 font-mono outline-none focus:border-neutral-600 min-w-0"
1550
+ <span className="min-w-0 flex-1 truncate text-[11px] font-medium text-neutral-100">
1551
+ {value}
1552
+ </span>
1553
+ </button>
1554
+ {picker}
1555
+ </div>
1556
+ );
1557
+ }
1558
+
1559
+ function ColorSlider({
1560
+ label,
1561
+ value,
1562
+ min,
1563
+ max,
1564
+ step,
1565
+ displayValue,
1566
+ background,
1567
+ thumbColor,
1568
+ disabled,
1569
+ onCommit,
1570
+ }: {
1571
+ label: string;
1572
+ value: number;
1573
+ min: number;
1574
+ max: number;
1575
+ step: number;
1576
+ displayValue: string;
1577
+ background: string;
1578
+ thumbColor: string;
1579
+ disabled?: boolean;
1580
+ onCommit: (nextValue: number) => void;
1581
+ }) {
1582
+ const trackRef = useRef<HTMLDivElement | null>(null);
1583
+ const percent = ((value - min) / (max - min)) * 100;
1584
+
1585
+ const commitFromClientX = (clientX: number) => {
1586
+ const rect = trackRef.current?.getBoundingClientRect();
1587
+ if (!rect || rect.width <= 0) return;
1588
+ const rawValue = min + ((clientX - rect.left) / rect.width) * (max - min);
1589
+ const stepped = Math.round(rawValue / step) * step;
1590
+ onCommit(Math.max(min, Math.min(max, stepped)));
1591
+ };
1592
+
1593
+ const commitKeyboardValue = (nextValue: number) => {
1594
+ onCommit(Math.max(min, Math.min(max, nextValue)));
1595
+ };
1596
+
1597
+ return (
1598
+ <div className="grid gap-1.5">
1599
+ <div className="flex items-center justify-between">
1600
+ <span className={LABEL}>{label}</span>
1601
+ <span className="text-[10px] font-medium text-neutral-400">{displayValue}</span>
1602
+ </div>
1603
+ <div
1604
+ ref={trackRef}
1605
+ role="slider"
1606
+ tabIndex={disabled ? -1 : 0}
1607
+ aria-label={label}
1608
+ aria-valuemin={min}
1609
+ aria-valuemax={max}
1610
+ aria-valuenow={value}
1611
+ aria-disabled={disabled}
1612
+ className={`relative h-4 rounded-full border border-neutral-700 shadow-[inset_0_1px_2px_rgba(0,0,0,0.55)] outline-none focus:border-[#f5a400] focus:ring-2 focus:ring-[#f5a400]/40 ${
1613
+ disabled ? "cursor-not-allowed opacity-50" : "cursor-ew-resize"
1614
+ }`}
1615
+ style={{ background }}
1616
+ onPointerDown={(event) => {
1617
+ if (disabled) return;
1618
+ event.currentTarget.setPointerCapture(event.pointerId);
1619
+ commitFromClientX(event.clientX);
1620
+ }}
1621
+ onPointerMove={(event) => {
1622
+ if (disabled || event.buttons !== 1) return;
1623
+ commitFromClientX(event.clientX);
1624
+ }}
1625
+ onKeyDown={(event) => {
1626
+ if (disabled) return;
1627
+ if (event.key === "ArrowRight" || event.key === "ArrowUp") {
1628
+ event.preventDefault();
1629
+ commitKeyboardValue(value + step);
1630
+ } else if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
1631
+ event.preventDefault();
1632
+ commitKeyboardValue(value - step);
1633
+ } else if (event.key === "Home") {
1634
+ event.preventDefault();
1635
+ commitKeyboardValue(min);
1636
+ } else if (event.key === "End") {
1637
+ event.preventDefault();
1638
+ commitKeyboardValue(max);
1639
+ }
1640
+ }}
1641
+ >
1642
+ <div
1643
+ className="pointer-events-none absolute top-1/2 h-6 w-6 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.85),0_6px_14px_rgba(0,0,0,0.5)]"
1644
+ style={{ left: `${Math.max(0, Math.min(100, percent))}%`, backgroundColor: thumbColor }}
61
1645
  />
62
1646
  </div>
63
1647
  </div>
64
1648
  );
65
1649
  }
66
1650
 
67
- function SectionHeader({ icon: Icon, label }: { icon: typeof Move; label: string }) {
1651
+ function ImageFillField({
1652
+ projectId,
1653
+ sourceFile,
1654
+ value,
1655
+ assets,
1656
+ disabled,
1657
+ onCommit,
1658
+ onImportAssets,
1659
+ }: {
1660
+ projectId: string;
1661
+ sourceFile: string;
1662
+ value: string;
1663
+ assets: string[];
1664
+ disabled?: boolean;
1665
+ onCommit: (nextValue: string) => void;
1666
+ onImportAssets?: (files: FileList) => Promise<string[]>;
1667
+ }) {
1668
+ const fileInputRef = useRef<HTMLInputElement | null>(null);
1669
+ const [uploading, setUploading] = useState(false);
1670
+ const imageAssets = useMemo(() => assets.filter((asset) => IMAGE_EXT.test(asset)), [assets]);
1671
+ const selectedAsset = useMemo(
1672
+ () => resolveSelectedAsset(value, sourceFile, imageAssets),
1673
+ [imageAssets, sourceFile, value],
1674
+ );
1675
+ const externalUrlValue = selectedAsset ? "" : value;
1676
+
1677
+ const handleUpload = async (files: FileList | null) => {
1678
+ if (!files?.length || !onImportAssets) return;
1679
+ setUploading(true);
1680
+ try {
1681
+ const uploaded = await onImportAssets(files);
1682
+ const nextImage = uploaded.find((asset) => IMAGE_EXT.test(asset));
1683
+ if (nextImage) {
1684
+ onCommit(`url("${toProjectRootAssetPath(nextImage)}")`);
1685
+ }
1686
+ } finally {
1687
+ setUploading(false);
1688
+ }
1689
+ };
1690
+
1691
+ return (
1692
+ <div className="space-y-4">
1693
+ <div className="grid min-w-0 gap-1.5">
1694
+ <div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
1695
+ <span className={LABEL}>Project asset</span>
1696
+ <button
1697
+ type="button"
1698
+ disabled={disabled || uploading}
1699
+ onClick={() => fileInputRef.current?.click()}
1700
+ className={`inline-flex h-7 max-w-full items-center gap-1.5 rounded-lg border border-neutral-700 bg-neutral-950 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors ${
1701
+ disabled || uploading
1702
+ ? "cursor-not-allowed text-neutral-600"
1703
+ : "cursor-pointer hover:border-neutral-600 hover:text-white"
1704
+ }`}
1705
+ >
1706
+ <Plus size={12} className="flex-shrink-0" />
1707
+ <span className="truncate">{uploading ? "Uploading…" : "Upload image"}</span>
1708
+ </button>
1709
+ <input
1710
+ ref={fileInputRef}
1711
+ type="file"
1712
+ accept="image/*"
1713
+ aria-label="Upload image asset"
1714
+ disabled={disabled || uploading}
1715
+ className="hidden"
1716
+ onChange={async (event) => {
1717
+ await handleUpload(event.target.files);
1718
+ event.target.value = "";
1719
+ }}
1720
+ />
1721
+ </div>
1722
+ {imageAssets.length > 0 ? (
1723
+ <div className="space-y-3">
1724
+ {selectedAsset && (
1725
+ <div className="overflow-hidden rounded-xl border border-neutral-800 bg-neutral-900/80">
1726
+ <img
1727
+ src={`/api/projects/${projectId}/preview/${selectedAsset}`}
1728
+ alt={selectedAsset.split("/").pop() ?? selectedAsset}
1729
+ className="h-28 w-full object-contain bg-neutral-950/80"
1730
+ />
1731
+ </div>
1732
+ )}
1733
+ <div className={FIELD}>
1734
+ <select
1735
+ value={selectedAsset ?? ""}
1736
+ disabled={disabled}
1737
+ onChange={(e) => {
1738
+ const nextAsset = e.target.value;
1739
+ if (!nextAsset) {
1740
+ onCommit("none");
1741
+ return;
1742
+ }
1743
+ onCommit(`url("${toProjectRootAssetPath(nextAsset)}")`);
1744
+ }}
1745
+ className="min-w-0 w-full appearance-none bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600"
1746
+ >
1747
+ <option value="">None</option>
1748
+ {imageAssets.map((asset) => (
1749
+ <option key={asset} value={asset}>
1750
+ {asset}
1751
+ </option>
1752
+ ))}
1753
+ </select>
1754
+ </div>
1755
+ </div>
1756
+ ) : (
1757
+ <div className="rounded-xl border border-dashed border-neutral-800 bg-neutral-900/50 px-3 py-3 text-[11px] leading-5 text-neutral-500">
1758
+ No image assets yet. Upload one here and Studio will also add it to the Assets tab.
1759
+ </div>
1760
+ )}
1761
+ </div>
1762
+
1763
+ <DetailField
1764
+ label="External URL"
1765
+ value={externalUrlValue}
1766
+ disabled={disabled}
1767
+ onCommit={(next) => onCommit(next.trim() ? `url("${next.trim()}")` : "none")}
1768
+ />
1769
+ </div>
1770
+ );
1771
+ }
1772
+
1773
+ function GradientField({
1774
+ value,
1775
+ fallbackColor,
1776
+ disabled,
1777
+ onCommit,
1778
+ }: {
1779
+ value: string;
1780
+ fallbackColor: string | undefined;
1781
+ disabled?: boolean;
1782
+ onCommit: (nextValue: string) => void;
1783
+ }) {
1784
+ const previewRef = useRef<HTMLDivElement | null>(null);
1785
+ const parsed = parseGradient(value) ?? buildDefaultGradientModel(fallbackColor);
1786
+
1787
+ const commit = (next: GradientModel) => onCommit(serializeGradient(next));
1788
+
1789
+ const patch = (partial: Partial<GradientModel>) => commit({ ...parsed, ...partial });
1790
+
1791
+ const updateStop = (index: number, partial: Partial<GradientModel["stops"][number]>) => {
1792
+ const stops = parsed.stops.map((stop, stopIndex) =>
1793
+ stopIndex === index ? { ...stop, ...partial } : stop,
1794
+ );
1795
+ commit({ ...parsed, stops });
1796
+ };
1797
+
1798
+ const addStop = (position?: number) => {
1799
+ const nextGradient =
1800
+ position != null
1801
+ ? insertGradientStop(parsed, position)
1802
+ : insertGradientStop(
1803
+ parsed,
1804
+ parsed.stops.at(-1)?.position != null
1805
+ ? Math.min(100, (parsed.stops.at(-1)?.position ?? 90) + 10)
1806
+ : 100,
1807
+ );
1808
+ commit(nextGradient);
1809
+ };
1810
+
1811
+ const removeStop = (index: number) => {
1812
+ if (parsed.stops.length <= 2) return;
1813
+ commit({ ...parsed, stops: parsed.stops.filter((_, stopIndex) => stopIndex !== index) });
1814
+ };
1815
+
1816
+ const previewStyle = {
1817
+ backgroundImage: serializeGradient(parsed),
1818
+ };
1819
+
1820
+ return (
1821
+ <div className="space-y-4">
1822
+ <div className={`${FIELD} space-y-3 p-3`}>
1823
+ <div
1824
+ ref={previewRef}
1825
+ className="relative h-11 overflow-hidden rounded-lg border border-neutral-700"
1826
+ style={previewStyle}
1827
+ onClick={(event) => {
1828
+ if (disabled) return;
1829
+ const rect = previewRef.current?.getBoundingClientRect();
1830
+ if (!rect || rect.width <= 0) return;
1831
+ const position = ((event.clientX - rect.left) / rect.width) * 100;
1832
+ addStop(position);
1833
+ }}
1834
+ >
1835
+ {parsed.stops.map((stop, index) => (
1836
+ <div
1837
+ key={`stop-preview-${index}`}
1838
+ className="absolute top-1/2 h-4 w-4 -translate-y-1/2 rounded-full border-2 border-white/90 shadow-[0_0_0_1px_rgba(0,0,0,0.35)]"
1839
+ style={{
1840
+ left: `calc(${stop.position}% - 8px)`,
1841
+ backgroundColor: stop.color,
1842
+ }}
1843
+ />
1844
+ ))}
1845
+ </div>
1846
+ <div className="flex min-w-0 flex-wrap items-center gap-2">
1847
+ <SegmentedControl
1848
+ disabled={disabled}
1849
+ value={parsed.kind}
1850
+ onChange={(next) => patch({ kind: next as GradientModel["kind"] })}
1851
+ options={[
1852
+ { label: "Linear", value: "linear" },
1853
+ { label: "Radial", value: "radial" },
1854
+ { label: "Conic", value: "conic" },
1855
+ ]}
1856
+ />
1857
+ <label className="flex items-center gap-2 text-[11px] font-medium text-neutral-400">
1858
+ <input
1859
+ type="checkbox"
1860
+ checked={parsed.repeating}
1861
+ disabled={disabled}
1862
+ onChange={(e) => patch({ repeating: e.target.checked })}
1863
+ className="h-4 w-4 rounded border-neutral-700 bg-neutral-950 text-[#3ce6ac] focus:ring-[#3ce6ac]"
1864
+ />
1865
+ Repeat
1866
+ </label>
1867
+ <button
1868
+ type="button"
1869
+ disabled={disabled}
1870
+ onClick={() =>
1871
+ commit({
1872
+ ...parsed,
1873
+ stops: [...parsed.stops].reverse().map((stop) => ({
1874
+ ...stop,
1875
+ position: 100 - stop.position,
1876
+ })),
1877
+ })
1878
+ }
1879
+ className="inline-flex h-7 items-center gap-1.5 rounded-lg border border-neutral-700 bg-neutral-950 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white disabled:cursor-not-allowed disabled:text-neutral-600"
1880
+ >
1881
+ <RotateCcw size={12} />
1882
+ Reverse
1883
+ </button>
1884
+ </div>
1885
+ </div>
1886
+
1887
+ {(parsed.kind === "linear" || parsed.kind === "conic") && (
1888
+ <div className="grid gap-1.5">
1889
+ <span className={LABEL}>{parsed.kind === "linear" ? "Angle" : "Start angle"}</span>
1890
+ <SliderControl
1891
+ value={parsed.angle}
1892
+ min={0}
1893
+ max={360}
1894
+ step={1}
1895
+ disabled={disabled}
1896
+ displayValue={`${Math.round(parsed.angle)}°`}
1897
+ formatDisplayValue={(next) => `${Math.round(next)}°`}
1898
+ onCommit={(next) => patch({ angle: next })}
1899
+ />
1900
+ </div>
1901
+ )}
1902
+
1903
+ {parsed.kind === "radial" && (
1904
+ <div className={RESPONSIVE_GRID}>
1905
+ <SelectField
1906
+ label="Shape"
1907
+ value={parsed.shape}
1908
+ disabled={disabled}
1909
+ onChange={(next) => patch({ shape: next as GradientModel["shape"] })}
1910
+ options={["ellipse", "circle"]}
1911
+ />
1912
+ <SelectField
1913
+ label="Size"
1914
+ value={parsed.radialSize}
1915
+ disabled={disabled}
1916
+ onChange={(next) => patch({ radialSize: next as GradientModel["radialSize"] })}
1917
+ options={["closest-side", "closest-corner", "farthest-side", "farthest-corner"]}
1918
+ />
1919
+ </div>
1920
+ )}
1921
+
1922
+ {(parsed.kind === "radial" || parsed.kind === "conic") && (
1923
+ <div className={RESPONSIVE_GRID}>
1924
+ <div className="grid min-w-0 gap-1.5">
1925
+ <span className={LABEL}>Center X</span>
1926
+ <SliderControl
1927
+ value={parsed.centerX}
1928
+ min={0}
1929
+ max={100}
1930
+ step={1}
1931
+ disabled={disabled}
1932
+ displayValue={`${Math.round(parsed.centerX)}%`}
1933
+ formatDisplayValue={(next) => `${Math.round(next)}%`}
1934
+ onCommit={(next) => patch({ centerX: next })}
1935
+ />
1936
+ </div>
1937
+ <div className="grid min-w-0 gap-1.5">
1938
+ <span className={LABEL}>Center Y</span>
1939
+ <SliderControl
1940
+ value={parsed.centerY}
1941
+ min={0}
1942
+ max={100}
1943
+ step={1}
1944
+ disabled={disabled}
1945
+ displayValue={`${Math.round(parsed.centerY)}%`}
1946
+ formatDisplayValue={(next) => `${Math.round(next)}%`}
1947
+ onCommit={(next) => patch({ centerY: next })}
1948
+ />
1949
+ </div>
1950
+ </div>
1951
+ )}
1952
+
1953
+ <div className="space-y-3">
1954
+ <div className="flex items-center justify-between">
1955
+ <span className={LABEL}>Stops</span>
1956
+ <button
1957
+ type="button"
1958
+ disabled={disabled || parsed.stops.length >= 6}
1959
+ onClick={() => addStop()}
1960
+ className="inline-flex h-7 items-center gap-1.5 rounded-lg border border-neutral-700 bg-neutral-950 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white disabled:cursor-not-allowed disabled:text-neutral-600"
1961
+ >
1962
+ <Plus size={12} />
1963
+ Add stop
1964
+ </button>
1965
+ </div>
1966
+ <div className="space-y-3">
1967
+ {parsed.stops.map((stop, index) => (
1968
+ <div
1969
+ key={`stop-editor-${index}`}
1970
+ className="grid min-w-0 grid-cols-[minmax(0,1fr)_68px_28px] gap-2"
1971
+ >
1972
+ <ColorField
1973
+ label={`Stop ${index + 1}`}
1974
+ value={stop.color}
1975
+ disabled={disabled}
1976
+ onCommit={(next) => updateStop(index, { color: next })}
1977
+ />
1978
+ <DetailField
1979
+ label="Pos"
1980
+ value={`${Math.round(stop.position)}%`}
1981
+ disabled={disabled}
1982
+ onCommit={(next) =>
1983
+ updateStop(index, {
1984
+ position: Number.parseFloat(next.replace("%", "")) || 0,
1985
+ })
1986
+ }
1987
+ />
1988
+ <button
1989
+ type="button"
1990
+ disabled={disabled || parsed.stops.length <= 2}
1991
+ onClick={() => removeStop(index)}
1992
+ className="mt-[22px] flex h-10 items-center justify-center rounded-lg border border-neutral-700 bg-neutral-950 text-neutral-400 transition-colors hover:border-neutral-600 hover:text-white disabled:cursor-not-allowed disabled:text-neutral-700"
1993
+ aria-label={`Remove stop ${index + 1}`}
1994
+ >
1995
+ <X size={12} />
1996
+ </button>
1997
+ </div>
1998
+ ))}
1999
+ </div>
2000
+ </div>
2001
+ </div>
2002
+ );
2003
+ }
2004
+
2005
+ function SliderControl({
2006
+ value,
2007
+ min,
2008
+ max,
2009
+ step,
2010
+ displayValue,
2011
+ formatDisplayValue,
2012
+ disabled,
2013
+ onCommit,
2014
+ }: {
2015
+ value: number;
2016
+ min: number;
2017
+ max: number;
2018
+ step: number;
2019
+ displayValue: string;
2020
+ formatDisplayValue?: (nextValue: number) => string;
2021
+ disabled?: boolean;
2022
+ onCommit: (nextValue: number) => void;
2023
+ }) {
2024
+ const [draft, setDraft] = useState(value);
2025
+ const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
2026
+ const valueRef = useRef(value);
2027
+
2028
+ valueRef.current = value;
2029
+
2030
+ useEffect(() => {
2031
+ setDraft(value);
2032
+ }, [value]);
2033
+
2034
+ useEffect(
2035
+ () => () => {
2036
+ if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
2037
+ },
2038
+ [],
2039
+ );
2040
+
2041
+ const commitDraft = (nextDraft: number) => {
2042
+ if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
2043
+ if (nextDraft !== valueRef.current) {
2044
+ onCommit(nextDraft);
2045
+ }
2046
+ };
2047
+
2048
+ const scheduleCommit = (nextDraft: number) => {
2049
+ if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
2050
+ commitTimerRef.current = setTimeout(() => {
2051
+ if (nextDraft !== valueRef.current) {
2052
+ onCommit(nextDraft);
2053
+ }
2054
+ }, 40);
2055
+ };
2056
+
2057
+ const renderedDisplayValue = formatDisplayValue?.(draft) ?? displayValue;
2058
+
2059
+ return (
2060
+ <div className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-2">
2061
+ <input
2062
+ type="range"
2063
+ min={min}
2064
+ max={max}
2065
+ step={step}
2066
+ value={draft}
2067
+ disabled={disabled}
2068
+ onChange={(e) => {
2069
+ const nextDraft = Number(e.target.value);
2070
+ setDraft(nextDraft);
2071
+ scheduleCommit(nextDraft);
2072
+ }}
2073
+ onMouseUp={() => commitDraft(draft)}
2074
+ onTouchEnd={() => commitDraft(draft)}
2075
+ onBlur={() => commitDraft(draft)}
2076
+ className="h-2 min-w-0 w-full cursor-pointer appearance-none rounded-full bg-neutral-800 accent-[#3ce6ac] disabled:cursor-not-allowed"
2077
+ />
2078
+ <div className="min-w-[52px] rounded-xl border border-neutral-800 bg-neutral-900 px-2 py-2 text-right text-[11px] font-medium text-neutral-100 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]">
2079
+ {renderedDisplayValue}
2080
+ </div>
2081
+ </div>
2082
+ );
2083
+ }
2084
+
2085
+ function SegmentedControl({
2086
+ options,
2087
+ value,
2088
+ disabled,
2089
+ onChange,
2090
+ }: {
2091
+ options: Array<{ label: string; value: string }>;
2092
+ value: string;
2093
+ disabled?: boolean;
2094
+ onChange: (nextValue: string) => void;
2095
+ }) {
68
2096
  return (
69
- <div className="flex items-center gap-1.5 mt-2 mb-1">
70
- <Icon size={10} className="text-neutral-600" />
71
- <span className="text-2xs font-medium text-neutral-500 uppercase tracking-wider">
72
- {label}
73
- </span>
2097
+ <div
2098
+ className="grid min-w-0 gap-1 rounded-xl bg-neutral-900 p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
2099
+ style={{ gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))` }}
2100
+ >
2101
+ {options.map((option) => {
2102
+ const selected = option.value === value;
2103
+ return (
2104
+ <button
2105
+ key={option.value}
2106
+ type="button"
2107
+ disabled={disabled}
2108
+ onClick={() => onChange(option.value)}
2109
+ className={`min-w-0 truncate rounded-lg px-2 py-1.5 text-[11px] font-medium transition-colors disabled:cursor-not-allowed ${
2110
+ selected
2111
+ ? "bg-neutral-800 text-white shadow-[0_1px_3px_rgba(0,0,0,0.28)]"
2112
+ : "text-neutral-500 hover:text-neutral-200"
2113
+ }`}
2114
+ >
2115
+ {option.label}
2116
+ </button>
2117
+ );
2118
+ })}
2119
+ </div>
2120
+ );
2121
+ }
2122
+
2123
+ function SelectField({
2124
+ label,
2125
+ value,
2126
+ disabled,
2127
+ options,
2128
+ onChange,
2129
+ }: {
2130
+ label: string;
2131
+ value: string;
2132
+ disabled?: boolean;
2133
+ options: string[];
2134
+ onChange: (nextValue: string) => void;
2135
+ }) {
2136
+ const renderedOptions = value && !options.includes(value) ? [value, ...options] : options;
2137
+
2138
+ return (
2139
+ <label className="grid min-w-0 gap-1.5">
2140
+ <span className={LABEL}>{label}</span>
2141
+ <div className={FIELD}>
2142
+ <select
2143
+ value={value}
2144
+ disabled={disabled}
2145
+ onChange={(e) => onChange(e.target.value)}
2146
+ className="min-w-0 w-full appearance-none bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600"
2147
+ >
2148
+ {renderedOptions.map((option) => (
2149
+ <option key={option} value={option}>
2150
+ {option}
2151
+ </option>
2152
+ ))}
2153
+ </select>
2154
+ </div>
2155
+ </label>
2156
+ );
2157
+ }
2158
+
2159
+ function Section({
2160
+ title,
2161
+ icon,
2162
+ children,
2163
+ accessory,
2164
+ }: {
2165
+ title: string;
2166
+ icon: ReactNode;
2167
+ children: ReactNode;
2168
+ accessory?: ReactNode;
2169
+ }) {
2170
+ return (
2171
+ <section className="min-w-0 border-t border-neutral-800/80 px-4 py-4">
2172
+ <div className="mb-3 flex min-w-0 flex-wrap items-center justify-between gap-2">
2173
+ <div className="flex min-w-0 items-center gap-2.5">
2174
+ <span className="flex-shrink-0 text-neutral-500">{icon}</span>
2175
+ <h3 className="text-[11px] font-semibold uppercase tracking-[0.12em] text-neutral-300">
2176
+ {title}
2177
+ </h3>
2178
+ </div>
2179
+ {accessory}
2180
+ </div>
2181
+ {children}
2182
+ </section>
2183
+ );
2184
+ }
2185
+
2186
+ function SelectionColorRow({
2187
+ swatch,
2188
+ token,
2189
+ sources,
2190
+ }: {
2191
+ swatch: string;
2192
+ token: string;
2193
+ sources: string[];
2194
+ }) {
2195
+ return (
2196
+ <div className={`${FIELD} flex min-w-0 items-center gap-3`}>
2197
+ <div
2198
+ className="h-7 w-7 flex-shrink-0 rounded-lg border border-neutral-700"
2199
+ style={{ backgroundColor: swatch }}
2200
+ />
2201
+ <div className="min-w-0 flex-1">
2202
+ <div className="truncate text-[11px] font-medium text-neutral-100">{token}</div>
2203
+ <div className="truncate text-[11px] uppercase tracking-[0.18em] text-neutral-500">
2204
+ {sources.join(" · ")}
2205
+ </div>
2206
+ </div>
74
2207
  </div>
75
2208
  );
76
2209
  }
77
2210
 
78
2211
  export const PropertyPanel = memo(function PropertyPanel({
2212
+ projectId,
2213
+ assets,
79
2214
  element,
80
- isPickMode,
81
- onEnablePick,
82
- onDisablePick,
83
- onClearPick,
2215
+ copiedAgentPrompt,
2216
+ onClearSelection,
84
2217
  onSetStyle,
85
- onSetDataAttr,
2218
+ onSetManualOffset,
2219
+ onSetManualSize,
86
2220
  onSetText,
2221
+ onSetTextFieldStyle,
2222
+ onAddTextField,
2223
+ onRemoveTextField,
2224
+ onResetManualEdits,
2225
+ onAskAgent,
2226
+ onImportAssets,
2227
+ fontAssets = [],
2228
+ onImportFonts,
87
2229
  }: PropertyPanelProps) {
2230
+ const styles = element?.computedStyles ?? EMPTY_STYLES;
2231
+ const selectionColors = useMemo(() => collectSelectionColors(styles), [styles]);
2232
+ const backgroundImage = styles["background-image"] ?? "none";
2233
+ const fillMode =
2234
+ backgroundImage && backgroundImage !== "none"
2235
+ ? backgroundImage.includes("gradient")
2236
+ ? "Gradient"
2237
+ : "Image"
2238
+ : "Solid";
2239
+ const [preferredFillMode, setPreferredFillMode] = useState(fillMode);
2240
+ const imageUrl = extractBackgroundImageUrl(backgroundImage);
2241
+ const [activeTextFieldKey, setActiveTextFieldKey] = useState<string | null>(
2242
+ element?.textFields[0]?.key ?? null,
2243
+ );
2244
+ const hasTextControls = element != null && isTextEditableSelection(element);
2245
+
2246
+ useEffect(() => {
2247
+ setPreferredFillMode(fillMode);
2248
+ }, [fillMode, element?.id, element?.selector, backgroundImage]);
2249
+
2250
+ useEffect(() => {
2251
+ const nextFields = element?.textFields ?? [];
2252
+ setActiveTextFieldKey((current) => {
2253
+ if (current && nextFields.some((field) => field.key === current)) return current;
2254
+ return nextFields[0]?.key ?? null;
2255
+ });
2256
+ }, [element?.id, element?.selector, element?.textFields]);
2257
+
88
2258
  if (!element) {
89
2259
  return (
90
- <div className="flex flex-col items-center justify-center h-full px-4 text-center">
91
- <MousePointer size={20} className="text-neutral-700 mb-2" />
92
- <p className="text-xs text-neutral-500">Click an element in the preview to inspect it</p>
93
- <Button
94
- variant="secondary"
95
- size="sm"
96
- onClick={isPickMode ? onDisablePick : onEnablePick}
97
- className={`mt-3 ${isPickMode ? "bg-studio-accent/20 text-studio-accent border-studio-accent/30" : ""}`}
98
- >
99
- {isPickMode ? "Pick mode active..." : "Enable Pick Mode"}
100
- </Button>
2260
+ <div className="flex h-full flex-col items-center justify-center bg-neutral-900 px-6 text-center">
2261
+ <Eye size={18} className="mb-3 text-neutral-600" />
2262
+ <p className="text-sm font-medium text-neutral-200">Select an element in the preview.</p>
2263
+ <p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
2264
+ The inspector is tuned for element edits with safer geometry controls, color picking, and
2265
+ cleaner grouped layer controls.
2266
+ </p>
101
2267
  </div>
102
2268
  );
103
2269
  }
104
2270
 
105
- const s = element.computedStyles;
2271
+ const styleEditingDisabled = !element.capabilities.canEditStyles;
2272
+ const manualOffsetEditingDisabled = !element.capabilities.canApplyManualOffset;
2273
+ const manualSizeEditingDisabled = !element.capabilities.canApplyManualSize;
2274
+ const isFlex = styles.display === "flex" || styles.display === "inline-flex";
2275
+ const radiusValue = parseNumericValue(styles["border-radius"]) ?? 0;
2276
+ const opacityValue = Math.round((parseNumericValue(styles.opacity) ?? 1) * 100);
2277
+ const borderWidthValue =
2278
+ parsePxMetricValue(styles["border-width"] ?? "") ??
2279
+ parsePxMetricValue(styles["border-top-width"] ?? "") ??
2280
+ 0;
2281
+ const borderStyleValue = styles["border-style"] || styles["border-top-style"] || "none";
2282
+ const borderColorValue =
2283
+ styles["border-color"] || styles["border-top-color"] || "rgba(255, 255, 255, 0.18)";
2284
+ const boxShadowPreset = inferBoxShadowPreset(styles["box-shadow"]);
2285
+ const filterBlurValue = getCssFilterFunctionPx(styles.filter, "blur");
2286
+ const backdropBlurValue = getCssFilterFunctionPx(styles["backdrop-filter"], "blur");
2287
+ const clipPathValue = styles["clip-path"] || "none";
2288
+ const clipPathPreset = inferClipPathPreset(clipPathValue);
2289
+ const clipInsetValue = getClipPathInsetPx(clipPathValue);
2290
+ const sourceLabel = element.id ? `#${element.id}` : element.selector;
2291
+ const showEditableSections = element.capabilities.canEditStyles;
2292
+ const manualOffset = readStudioPathOffset(element.element);
2293
+ const manualSize = readStudioBoxSize(element.element);
2294
+ const resolvedWidth =
2295
+ manualSize.width > 0
2296
+ ? manualSize.width
2297
+ : (parsePxMetricValue(styles.width ?? "") ?? element.boundingBox.width);
2298
+ const resolvedHeight =
2299
+ manualSize.height > 0
2300
+ ? manualSize.height
2301
+ : (parsePxMetricValue(styles.height ?? "") ?? element.boundingBox.height);
2302
+
2303
+ const commitManualOffset = (axis: "x" | "y", nextValue: string) => {
2304
+ const parsed = parsePxMetricValue(nextValue);
2305
+ if (parsed == null) return;
2306
+ const current = readStudioPathOffset(element.element);
2307
+ onSetManualOffset(element, {
2308
+ x: axis === "x" ? parsed : current.x,
2309
+ y: axis === "y" ? parsed : current.y,
2310
+ });
2311
+ };
2312
+
2313
+ const commitManualSize = (axis: "width" | "height", nextValue: string) => {
2314
+ const parsed = parsePxMetricValue(nextValue);
2315
+ if (parsed == null || parsed <= 0) return;
2316
+ const current = readStudioBoxSize(element.element);
2317
+ const width =
2318
+ current.width > 0
2319
+ ? current.width
2320
+ : (parsePxMetricValue(styles.width ?? "") ?? element.boundingBox.width);
2321
+ const height =
2322
+ current.height > 0
2323
+ ? current.height
2324
+ : (parsePxMetricValue(styles.height ?? "") ?? element.boundingBox.height);
2325
+ onSetManualSize(element, {
2326
+ width: axis === "width" ? parsed : width,
2327
+ height: axis === "height" ? parsed : height,
2328
+ });
2329
+ };
2330
+
2331
+ const handleFillModeChange = (nextMode: string) => {
2332
+ setPreferredFillMode(nextMode);
2333
+ if (nextMode === "Solid") {
2334
+ onSetStyle("background-image", "none");
2335
+ return;
2336
+ }
2337
+ if (nextMode === "Gradient" && !backgroundImage.includes("gradient")) {
2338
+ onSetStyle(
2339
+ "background-image",
2340
+ serializeGradient(buildDefaultGradientModel(styles["background-color"])),
2341
+ );
2342
+ }
2343
+ };
106
2344
 
107
2345
  return (
108
- <div className="flex flex-col h-full min-h-0">
109
- {/* Header */}
110
- <div className="flex items-center justify-between px-3 py-2 border-b border-neutral-800 flex-shrink-0">
111
- <div className="flex items-center gap-1.5 min-w-0">
112
- <span className="text-2xs font-mono text-studio-accent truncate">{element.selector}</span>
113
- </div>
114
- <div className="flex items-center gap-1">
115
- <IconButton
116
- icon={<MousePointer size={11} />}
117
- aria-label={isPickMode ? "Disable pick mode" : "Enable pick mode"}
118
- size="sm"
119
- onClick={isPickMode ? onDisablePick : onEnablePick}
120
- className={isPickMode ? "text-studio-accent bg-studio-accent/10" : ""}
121
- />
122
- <IconButton
123
- icon={<X size={11} />}
2346
+ <div className="flex h-full min-h-0 flex-col overflow-hidden bg-neutral-900 text-neutral-100">
2347
+ <div className="border-b border-neutral-800 px-4 py-5">
2348
+ <div className="flex items-start justify-between gap-4">
2349
+ <div className="min-w-0">
2350
+ <div className={LABEL}>Document</div>
2351
+ <div className="mt-3 truncate text-[12px] font-semibold text-neutral-100">
2352
+ {element.label}
2353
+ </div>
2354
+ <div className="mt-1 truncate text-[11px] text-neutral-500">{sourceLabel}</div>
2355
+ </div>
2356
+ <button
2357
+ type="button"
124
2358
  aria-label="Clear selection"
125
- size="sm"
126
- onClick={onClearPick}
127
- />
2359
+ onClick={onClearSelection}
2360
+ className="flex h-9 w-9 items-center justify-center rounded-full border border-neutral-700 bg-neutral-950 text-neutral-500 shadow-[0_1px_2px_rgba(0,0,0,0.2)] transition-colors hover:border-neutral-600 hover:text-neutral-200"
2361
+ >
2362
+ <X size={13} />
2363
+ </button>
128
2364
  </div>
129
- </div>
130
-
131
- {/* Properties */}
132
- <div className="flex-1 overflow-y-auto px-3 py-2 space-y-1">
133
- {/* Element info */}
134
- <div className="flex items-center gap-2 mb-2">
135
- <span className="text-2xs text-neutral-300 font-medium">{element.label}</span>
136
- <span className="text-2xs text-neutral-600 font-mono">&lt;{element.tagName}&gt;</span>
137
- </div>
138
-
139
- {/* Position & Size */}
140
- <SectionHeader icon={Move} label="Position & Size" />
141
- <div className="grid grid-cols-2 gap-1">
142
- <PropertyRow
143
- label="X"
144
- value={s["left"] ?? "auto"}
145
- onChange={(v) => onSetStyle("left", v)}
146
- />
147
- <PropertyRow
148
- label="Y"
149
- value={s["top"] ?? "auto"}
150
- onChange={(v) => onSetStyle("top", v)}
151
- />
152
- <PropertyRow
153
- label="W"
154
- value={s["width"] ?? "auto"}
155
- onChange={(v) => onSetStyle("width", v)}
156
- />
157
- <PropertyRow
158
- label="H"
159
- value={s["height"] ?? "auto"}
160
- onChange={(v) => onSetStyle("height", v)}
161
- />
2365
+ <div className="mt-4 flex min-w-0 flex-wrap items-center gap-2">
2366
+ <button
2367
+ type="button"
2368
+ onClick={onAskAgent}
2369
+ className="inline-flex h-8 items-center justify-center gap-2 rounded-xl border border-neutral-700 bg-neutral-950 px-3.5 text-[11px] font-medium text-neutral-100 transition-colors hover:border-studio-accent/40 hover:text-studio-accent"
2370
+ >
2371
+ <MessageSquare size={15} />
2372
+ <span>{copiedAgentPrompt ? "Prompt copied" : "Ask agent"}</span>
2373
+ </button>
2374
+ <button
2375
+ type="button"
2376
+ onClick={() => onResetManualEdits(element)}
2377
+ title="Reset move, size, and rotation edits"
2378
+ className="inline-flex h-8 items-center justify-center gap-2 rounded-xl border border-neutral-700 bg-neutral-950 px-3.5 text-[11px] font-medium text-neutral-100 transition-colors hover:border-neutral-500 hover:text-white"
2379
+ >
2380
+ <RotateCcw size={14} />
2381
+ <span>Reset edits</span>
2382
+ </button>
162
2383
  </div>
2384
+ </div>
163
2385
 
164
- {/* Typography */}
165
- {(element.tagName === "div" ||
166
- element.tagName === "span" ||
167
- element.tagName === "p" ||
168
- element.tagName === "h1" ||
169
- element.tagName === "h2") && (
170
- <>
171
- <SectionHeader icon={Type} label="Typography" />
172
- <PropertyRow
173
- label="Size"
174
- value={s["font-size"] ?? ""}
175
- onChange={(v) => onSetStyle("font-size", v)}
2386
+ <div className="flex-1 overflow-y-auto">
2387
+ <Section title="Layout" icon={<Move size={15} />}>
2388
+ <div className={RESPONSIVE_GRID}>
2389
+ <MetricField
2390
+ label="X"
2391
+ value={formatPxMetricValue(manualOffset.x)}
2392
+ disabled={manualOffsetEditingDisabled}
2393
+ onCommit={(next) => commitManualOffset("x", next)}
176
2394
  />
177
- <PropertyRow
178
- label="Weight"
179
- value={s["font-weight"] ?? ""}
180
- onChange={(v) => onSetStyle("font-weight", v)}
2395
+ <MetricField
2396
+ label="Y"
2397
+ value={formatPxMetricValue(manualOffset.y)}
2398
+ disabled={manualOffsetEditingDisabled}
2399
+ onCommit={(next) => commitManualOffset("y", next)}
181
2400
  />
182
- <PropertyRow
183
- label="Family"
184
- value={s["font-family"]?.split(",")[0] ?? ""}
185
- onChange={(v) => onSetStyle("font-family", v)}
2401
+ <MetricField
2402
+ label="W"
2403
+ value={formatPxMetricValue(resolvedWidth)}
2404
+ disabled={manualSizeEditingDisabled}
2405
+ onCommit={(next) => commitManualSize("width", next)}
186
2406
  />
187
- </>
188
- )}
189
-
190
- {/* Colors */}
191
- <SectionHeader icon={Palette} label="Colors" />
192
- <ColorRow
193
- label="Color"
194
- value={s["color"] ?? "#fff"}
195
- onChange={(v) => onSetStyle("color", v)}
196
- />
197
- <ColorRow
198
- label="Background"
199
- value={s["background-color"] ?? "transparent"}
200
- onChange={(v) => onSetStyle("background-color", v)}
201
- />
202
-
203
- {/* Appearance */}
204
- <SectionHeader icon={Eye} label="Appearance" />
205
- <PropertyRow
206
- label="Opacity"
207
- value={s["opacity"] ?? "1"}
208
- onChange={(v) => onSetStyle("opacity", v)}
209
- />
210
- <PropertyRow
211
- label="Radius"
212
- value={s["border-radius"] ?? "0"}
213
- onChange={(v) => onSetStyle("border-radius", v)}
214
- />
215
- <PropertyRow
216
- label="Z-index"
217
- value={s["z-index"] ?? "auto"}
218
- onChange={(v) => onSetStyle("z-index", v)}
219
- />
220
- <PropertyRow
221
- label="Transform"
222
- value={s["transform"] ?? "none"}
223
- onChange={(v) => onSetStyle("transform", v)}
224
- />
2407
+ <MetricField
2408
+ label="H"
2409
+ value={formatPxMetricValue(resolvedHeight)}
2410
+ disabled={manualSizeEditingDisabled}
2411
+ onCommit={(next) => commitManualSize("height", next)}
2412
+ />
2413
+ </div>
2414
+ </Section>
225
2415
 
226
- {/* Timing */}
227
- {(element.dataAttributes["start"] || element.dataAttributes["duration"]) && (
228
- <>
229
- <SectionHeader icon={Clock} label="Timing" />
230
- {element.dataAttributes["start"] != null && (
231
- <PropertyRow
232
- label="Start"
233
- value={element.dataAttributes["start"]}
234
- onChange={(v) => onSetDataAttr("start", v)}
235
- />
236
- )}
237
- {element.dataAttributes["duration"] != null && (
238
- <PropertyRow
239
- label="Duration"
240
- value={element.dataAttributes["duration"]}
241
- onChange={(v) => onSetDataAttr("duration", v)}
2416
+ {showEditableSections && isFlex && (
2417
+ <Section title="Flex" icon={<Layers size={15} />}>
2418
+ <div className="space-y-4">
2419
+ <SegmentedControl
2420
+ disabled={styleEditingDisabled}
2421
+ value={styles["flex-direction"] || "row"}
2422
+ onChange={(next) => onSetStyle("flex-direction", next)}
2423
+ options={[
2424
+ { label: "→ Row", value: "row" },
2425
+ { label: "↓ Column", value: "column" },
2426
+ ]}
242
2427
  />
243
- )}
244
- {element.dataAttributes["track-index"] != null && (
245
- <PropertyRow
246
- label="Track"
247
- value={element.dataAttributes["track-index"]}
248
- onChange={(v) => onSetDataAttr("track-index", v)}
2428
+ <div className={RESPONSIVE_GRID}>
2429
+ <SelectField
2430
+ label="Justify"
2431
+ value={styles["justify-content"] || "flex-start"}
2432
+ disabled={styleEditingDisabled}
2433
+ onChange={(next) => onSetStyle("justify-content", next)}
2434
+ options={[
2435
+ "flex-start",
2436
+ "center",
2437
+ "space-between",
2438
+ "space-around",
2439
+ "space-evenly",
2440
+ "flex-end",
2441
+ ]}
2442
+ />
2443
+ <SelectField
2444
+ label="Align"
2445
+ value={styles["align-items"] || "stretch"}
2446
+ disabled={styleEditingDisabled}
2447
+ onChange={(next) => onSetStyle("align-items", next)}
2448
+ options={["stretch", "flex-start", "center", "flex-end", "baseline"]}
2449
+ />
2450
+ </div>
2451
+ <DetailField
2452
+ label="Gap"
2453
+ value={styles.gap ?? "0px"}
2454
+ disabled={styleEditingDisabled}
2455
+ onCommit={(next) => onSetStyle("gap", next.endsWith("px") ? next : `${next}px`)}
249
2456
  />
250
- )}
251
- </>
2457
+ </div>
2458
+ </Section>
252
2459
  )}
253
2460
 
254
- {/* Editable text content */}
255
- {element.textContent && (
2461
+ {showEditableSections && (
256
2462
  <>
257
- <SectionHeader icon={Type} label="Text Content" />
258
- <textarea
259
- defaultValue={element.textContent}
260
- onBlur={(e) => onSetText?.(e.target.value)}
261
- onKeyDown={(e) => {
262
- if (e.key === "Enter" && e.metaKey) {
263
- (e.target as HTMLTextAreaElement).blur();
264
- }
265
- }}
266
- rows={3}
267
- className="w-full bg-neutral-900 border border-neutral-800 rounded px-2 py-1.5 text-xs text-neutral-200 outline-none focus:border-neutral-600 resize-y leading-relaxed"
268
- placeholder="Edit text..."
269
- />
2463
+ <Section title="Radius" icon={<Settings size={15} />}>
2464
+ <SliderControl
2465
+ value={radiusValue}
2466
+ min={0}
2467
+ max={Math.max(240, Math.ceil(radiusValue))}
2468
+ step={1}
2469
+ disabled={styleEditingDisabled}
2470
+ displayValue={`${formatNumericValue(radiusValue)}px`}
2471
+ formatDisplayValue={(next) => `${formatNumericValue(next)}px`}
2472
+ onCommit={(next) => onSetStyle("border-radius", `${formatNumericValue(next)}px`)}
2473
+ />
2474
+ </Section>
2475
+
2476
+ <Section title="Stroke" icon={<Square size={15} />}>
2477
+ <div className="space-y-4">
2478
+ <div className={RESPONSIVE_GRID}>
2479
+ <MetricField
2480
+ label="Width"
2481
+ value={formatPxMetricValue(borderWidthValue)}
2482
+ disabled={styleEditingDisabled}
2483
+ liveCommit
2484
+ onCommit={async (next) => {
2485
+ const normalized = normalizePanelPxValue(next, {
2486
+ min: 0,
2487
+ max: 200,
2488
+ fallback: borderWidthValue,
2489
+ });
2490
+ if (!normalized) return;
2491
+ for (const [property, value] of buildStrokeWidthStyleUpdates(
2492
+ normalized,
2493
+ borderStyleValue,
2494
+ )) {
2495
+ await onSetStyle(property, value);
2496
+ }
2497
+ }}
2498
+ />
2499
+ <SelectField
2500
+ label="Style"
2501
+ value={borderStyleValue}
2502
+ disabled={styleEditingDisabled}
2503
+ onChange={async (next) => {
2504
+ for (const [property, value] of buildStrokeStyleUpdates(
2505
+ next,
2506
+ formatPxMetricValue(borderWidthValue),
2507
+ )) {
2508
+ await onSetStyle(property, value);
2509
+ }
2510
+ }}
2511
+ options={[
2512
+ "none",
2513
+ "solid",
2514
+ "dashed",
2515
+ "dotted",
2516
+ "double",
2517
+ "hidden",
2518
+ "groove",
2519
+ "ridge",
2520
+ "inset",
2521
+ "outset",
2522
+ ]}
2523
+ />
2524
+ </div>
2525
+ <ColorField
2526
+ label="Stroke color"
2527
+ value={borderColorValue}
2528
+ disabled={styleEditingDisabled}
2529
+ onCommit={(next) => onSetStyle("border-color", next)}
2530
+ />
2531
+ </div>
2532
+ </Section>
2533
+
2534
+ <Section title="Effects" icon={<Zap size={15} />}>
2535
+ <div className="space-y-4">
2536
+ <SelectField
2537
+ label="Shadow"
2538
+ value={boxShadowPreset}
2539
+ disabled={styleEditingDisabled}
2540
+ onChange={(next) => {
2541
+ if (next === "custom") return;
2542
+ onSetStyle(
2543
+ "box-shadow",
2544
+ buildBoxShadowPresetValue(next as BoxShadowPreset, styles["box-shadow"]),
2545
+ );
2546
+ }}
2547
+ options={["custom", "none", "soft", "lift", "glow"]}
2548
+ />
2549
+ <div className={RESPONSIVE_GRID}>
2550
+ <div className="grid min-w-0 gap-1.5">
2551
+ <span className={LABEL}>Layer blur</span>
2552
+ <SliderControl
2553
+ value={filterBlurValue}
2554
+ min={0}
2555
+ max={Math.max(40, Math.ceil(filterBlurValue))}
2556
+ step={1}
2557
+ disabled={styleEditingDisabled}
2558
+ displayValue={`${formatNumericValue(filterBlurValue)}px`}
2559
+ formatDisplayValue={(next) => `${formatNumericValue(next)}px`}
2560
+ onCommit={(next) =>
2561
+ onSetStyle("filter", setCssFilterFunctionPx(styles.filter, "blur", next))
2562
+ }
2563
+ />
2564
+ </div>
2565
+ <div className="grid min-w-0 gap-1.5">
2566
+ <span className={LABEL}>Backdrop</span>
2567
+ <SliderControl
2568
+ value={backdropBlurValue}
2569
+ min={0}
2570
+ max={Math.max(60, Math.ceil(backdropBlurValue))}
2571
+ step={1}
2572
+ disabled={styleEditingDisabled}
2573
+ displayValue={`${formatNumericValue(backdropBlurValue)}px`}
2574
+ formatDisplayValue={(next) => `${formatNumericValue(next)}px`}
2575
+ onCommit={(next) =>
2576
+ onSetStyle(
2577
+ "backdrop-filter",
2578
+ setCssFilterFunctionPx(styles["backdrop-filter"], "blur", next),
2579
+ )
2580
+ }
2581
+ />
2582
+ </div>
2583
+ </div>
2584
+ </div>
2585
+ </Section>
2586
+
2587
+ <Section title="Clip" icon={<Layers size={15} />}>
2588
+ <div className="space-y-4">
2589
+ <div className={RESPONSIVE_GRID}>
2590
+ <SelectField
2591
+ label="Overflow"
2592
+ value={styles.overflow || "visible"}
2593
+ disabled={styleEditingDisabled}
2594
+ onChange={(next) => onSetStyle("overflow", next)}
2595
+ options={["visible", "hidden", "clip", "auto", "scroll"]}
2596
+ />
2597
+ <SelectField
2598
+ label="Mask"
2599
+ value={clipPathPreset}
2600
+ disabled={styleEditingDisabled}
2601
+ onChange={(next) => {
2602
+ if (next === "custom") return;
2603
+ onSetStyle(
2604
+ "clip-path",
2605
+ buildClipPathValue(
2606
+ next as "none" | "inset" | "circle",
2607
+ radiusValue,
2608
+ clipPathValue,
2609
+ ),
2610
+ );
2611
+ }}
2612
+ options={["custom", "none", "inset", "circle"]}
2613
+ />
2614
+ </div>
2615
+ <div className="grid min-w-0 gap-1.5">
2616
+ <span className={LABEL}>Mask inset</span>
2617
+ <SliderControl
2618
+ value={clipInsetValue}
2619
+ min={0}
2620
+ max={Math.max(120, Math.ceil(clipInsetValue))}
2621
+ step={1}
2622
+ disabled={styleEditingDisabled}
2623
+ displayValue={`${formatNumericValue(clipInsetValue)}px`}
2624
+ formatDisplayValue={(next) => `${formatNumericValue(next)}px`}
2625
+ onCommit={(next) =>
2626
+ onSetStyle("clip-path", buildInsetClipPathValue(next, radiusValue))
2627
+ }
2628
+ />
2629
+ </div>
2630
+ </div>
2631
+ </Section>
2632
+
2633
+ <Section title="Blending" icon={<Eye size={15} />}>
2634
+ <div className="space-y-4">
2635
+ <SliderControl
2636
+ value={opacityValue}
2637
+ min={0}
2638
+ max={100}
2639
+ step={1}
2640
+ disabled={styleEditingDisabled}
2641
+ displayValue={`${opacityValue}%`}
2642
+ formatDisplayValue={(next) => `${Math.round(next)}%`}
2643
+ onCommit={(next) => onSetStyle("opacity", formatNumericValue(next / 100))}
2644
+ />
2645
+ <SelectField
2646
+ label="Mode"
2647
+ value={styles["mix-blend-mode"] || "normal"}
2648
+ disabled={styleEditingDisabled}
2649
+ onChange={(next) => onSetStyle("mix-blend-mode", next)}
2650
+ options={["normal", "multiply", "screen", "overlay", "darken", "lighten"]}
2651
+ />
2652
+ </div>
2653
+ </Section>
2654
+
2655
+ <Section
2656
+ title="Fill"
2657
+ icon={<Palette size={15} />}
2658
+ accessory={
2659
+ <div className="rounded-full border border-neutral-700 bg-neutral-900 px-2.5 py-1 text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-400">
2660
+ {preferredFillMode}
2661
+ </div>
2662
+ }
2663
+ >
2664
+ <div className="space-y-4">
2665
+ <SegmentedControl
2666
+ disabled={styleEditingDisabled}
2667
+ value={preferredFillMode}
2668
+ onChange={handleFillModeChange}
2669
+ options={[
2670
+ { label: "Solid", value: "Solid" },
2671
+ { label: "Gradient", value: "Gradient" },
2672
+ { label: "Image", value: "Image" },
2673
+ ]}
2674
+ />
2675
+ {preferredFillMode === "Solid" ? (
2676
+ <ColorField
2677
+ label="Fill color"
2678
+ value={styles["background-color"] ?? "transparent"}
2679
+ disabled={styleEditingDisabled}
2680
+ onCommit={(next) => onSetStyle("background-color", next)}
2681
+ />
2682
+ ) : preferredFillMode === "Gradient" ? (
2683
+ <GradientField
2684
+ value={
2685
+ backgroundImage !== "none"
2686
+ ? backgroundImage
2687
+ : serializeGradient(buildDefaultGradientModel(styles["background-color"]))
2688
+ }
2689
+ fallbackColor={styles["background-color"]}
2690
+ disabled={styleEditingDisabled}
2691
+ onCommit={(next) => onSetStyle("background-image", next)}
2692
+ />
2693
+ ) : (
2694
+ <ImageFillField
2695
+ projectId={projectId}
2696
+ sourceFile={element.sourceFile}
2697
+ value={imageUrl}
2698
+ assets={assets}
2699
+ disabled={styleEditingDisabled}
2700
+ onCommit={(next) => onSetStyle("background-image", next)}
2701
+ onImportAssets={onImportAssets}
2702
+ />
2703
+ )}
2704
+ {!hasTextControls && (
2705
+ <ColorField
2706
+ label="Text color"
2707
+ value={styles.color ?? "rgb(0, 0, 0)"}
2708
+ disabled={styleEditingDisabled}
2709
+ onCommit={(next) => onSetStyle("color", next)}
2710
+ />
2711
+ )}
2712
+ </div>
2713
+ </Section>
2714
+
2715
+ {hasTextControls && (
2716
+ <Section title="Text" icon={<Type size={15} />}>
2717
+ {(() => {
2718
+ const textFields = element.textFields;
2719
+ const activeField =
2720
+ textFields.find((field) => field.key === activeTextFieldKey) ?? textFields[0];
2721
+ if (!activeField) return null;
2722
+
2723
+ if (textFields.length === 1) {
2724
+ return (
2725
+ <div className="space-y-4 rounded-xl border border-neutral-800 bg-neutral-900/60 p-3">
2726
+ <div className="min-w-0">
2727
+ <div className="truncate text-[11px] font-medium text-neutral-100">
2728
+ {formatTextFieldPreview(activeField.value) || "Text"}
2729
+ </div>
2730
+ <div className="text-[10px] uppercase tracking-[0.12em] text-neutral-500">
2731
+ {activeField.tagName}
2732
+ </div>
2733
+ </div>
2734
+
2735
+ <TextAreaField
2736
+ key={activeField.key}
2737
+ label="Content"
2738
+ value={activeField.value}
2739
+ disabled={false}
2740
+ onCommit={(next) => onSetText(next, activeField.key)}
2741
+ />
2742
+
2743
+ <ColorField
2744
+ label="Text color"
2745
+ value={getTextFieldColor(activeField, styles)}
2746
+ disabled={false}
2747
+ onCommit={(next) => onSetTextFieldStyle(activeField.key, "color", next)}
2748
+ />
2749
+
2750
+ <div className={RESPONSIVE_GRID}>
2751
+ <MetricField
2752
+ label="Size"
2753
+ value={
2754
+ activeField.computedStyles["font-size"] ||
2755
+ styles["font-size"] ||
2756
+ "16px"
2757
+ }
2758
+ disabled={false}
2759
+ liveCommit
2760
+ onCommit={(next) =>
2761
+ onSetTextFieldStyle(activeField.key, "font-size", next)
2762
+ }
2763
+ />
2764
+ <FontWeightField
2765
+ value={
2766
+ activeField.computedStyles["font-weight"] ||
2767
+ styles["font-weight"] ||
2768
+ "400"
2769
+ }
2770
+ disabled={false}
2771
+ onCommit={(next) =>
2772
+ onSetTextFieldStyle(activeField.key, "font-weight", next)
2773
+ }
2774
+ />
2775
+ </div>
2776
+
2777
+ <FontFamilyField
2778
+ value={
2779
+ activeField.computedStyles["font-family"] ||
2780
+ styles["font-family"] ||
2781
+ "inherit"
2782
+ }
2783
+ disabled={false}
2784
+ importedFonts={fontAssets}
2785
+ onImportFonts={onImportFonts}
2786
+ onCommit={(next) =>
2787
+ onSetTextFieldStyle(activeField.key, "font-family", next)
2788
+ }
2789
+ />
2790
+
2791
+ <AdvancedTextControls
2792
+ field={activeField}
2793
+ inheritedStyles={styles}
2794
+ disabled={false}
2795
+ onCommit={(property, value) =>
2796
+ onSetTextFieldStyle(activeField.key, property, value)
2797
+ }
2798
+ />
2799
+ </div>
2800
+ );
2801
+ }
2802
+
2803
+ return (
2804
+ <div className="space-y-4">
2805
+ <div className="grid gap-1.5">
2806
+ <div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
2807
+ <span className={LABEL}>Text layers</span>
2808
+ <button
2809
+ type="button"
2810
+ onClick={() => {
2811
+ void Promise.resolve(onAddTextField(activeField.key)).then(
2812
+ (nextKey) => {
2813
+ if (nextKey) setActiveTextFieldKey(nextKey);
2814
+ },
2815
+ );
2816
+ }}
2817
+ className="inline-flex h-7 max-w-full items-center gap-1.5 rounded-lg border border-neutral-700 bg-neutral-950 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white"
2818
+ >
2819
+ <Plus size={12} className="flex-shrink-0" />
2820
+ <span className="truncate">Add text</span>
2821
+ </button>
2822
+ </div>
2823
+ <div className="grid gap-2">
2824
+ {textFields.map((field, index) => {
2825
+ const active = field.key === activeField.key;
2826
+ return (
2827
+ <button
2828
+ key={field.key}
2829
+ type="button"
2830
+ onClick={() => setActiveTextFieldKey(field.key)}
2831
+ className={`min-w-0 w-full rounded-xl border px-3 py-2 text-left transition-colors ${
2832
+ active
2833
+ ? "border-studio-accent/50 bg-studio-accent/10"
2834
+ : "border-neutral-800 bg-neutral-900/80 hover:border-neutral-700 hover:bg-neutral-900"
2835
+ }`}
2836
+ >
2837
+ <div className="flex min-w-0 items-center justify-between gap-2">
2838
+ <div className="flex min-w-0 items-center gap-2">
2839
+ <span
2840
+ className="h-4 w-4 flex-shrink-0 rounded border border-neutral-700 bg-neutral-950"
2841
+ style={{ backgroundColor: getTextFieldColor(field, styles) }}
2842
+ />
2843
+ <span className="min-w-0 truncate text-[11px] font-medium text-neutral-100">
2844
+ {formatTextFieldPreview(field.value) || `Text ${index + 1}`}
2845
+ </span>
2846
+ </div>
2847
+ <span className="flex-shrink-0 rounded-md border border-neutral-700 bg-neutral-950 px-1.5 py-0.5 text-[10px] uppercase tracking-[0.12em] text-neutral-500">
2848
+ {field.tagName}
2849
+ </span>
2850
+ </div>
2851
+ </button>
2852
+ );
2853
+ })}
2854
+ </div>
2855
+ </div>
2856
+
2857
+ <div className="space-y-4 rounded-xl border border-neutral-800 bg-neutral-900/60 p-3">
2858
+ <div className="flex min-w-0 items-center justify-between gap-2">
2859
+ <div className="min-w-0">
2860
+ <div className="truncate text-[11px] font-medium text-neutral-100">
2861
+ {formatTextFieldPreview(activeField.value) || "Text"}
2862
+ </div>
2863
+ <div className="text-[10px] uppercase tracking-[0.12em] text-neutral-500">
2864
+ {activeField.tagName}
2865
+ </div>
2866
+ </div>
2867
+ <button
2868
+ type="button"
2869
+ onClick={() => onRemoveTextField(activeField.key)}
2870
+ className="inline-flex h-7 flex-shrink-0 items-center rounded-lg border border-neutral-700 bg-neutral-950 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white"
2871
+ >
2872
+ Remove
2873
+ </button>
2874
+ </div>
2875
+
2876
+ <TextAreaField
2877
+ key={activeField.key}
2878
+ label="Content"
2879
+ value={activeField.value}
2880
+ disabled={false}
2881
+ autoFocus
2882
+ onCommit={(next) => onSetText(next, activeField.key)}
2883
+ />
2884
+
2885
+ <ColorField
2886
+ label="Text color"
2887
+ value={getTextFieldColor(activeField, styles)}
2888
+ disabled={false}
2889
+ onCommit={(next) => onSetTextFieldStyle(activeField.key, "color", next)}
2890
+ />
2891
+
2892
+ <div className={RESPONSIVE_GRID}>
2893
+ <MetricField
2894
+ label="Size"
2895
+ value={activeField.computedStyles["font-size"] || "16px"}
2896
+ disabled={false}
2897
+ liveCommit
2898
+ onCommit={(next) =>
2899
+ onSetTextFieldStyle(activeField.key, "font-size", next)
2900
+ }
2901
+ />
2902
+ <FontWeightField
2903
+ value={activeField.computedStyles["font-weight"] || "400"}
2904
+ disabled={false}
2905
+ onCommit={(next) =>
2906
+ onSetTextFieldStyle(activeField.key, "font-weight", next)
2907
+ }
2908
+ />
2909
+ </div>
2910
+
2911
+ <FontFamilyField
2912
+ value={
2913
+ activeField.computedStyles["font-family"] ||
2914
+ styles["font-family"] ||
2915
+ "inherit"
2916
+ }
2917
+ disabled={false}
2918
+ importedFonts={fontAssets}
2919
+ onImportFonts={onImportFonts}
2920
+ onCommit={(next) =>
2921
+ onSetTextFieldStyle(activeField.key, "font-family", next)
2922
+ }
2923
+ />
2924
+
2925
+ <AdvancedTextControls
2926
+ field={activeField}
2927
+ inheritedStyles={styles}
2928
+ disabled={false}
2929
+ onCommit={(property, value) =>
2930
+ onSetTextFieldStyle(activeField.key, property, value)
2931
+ }
2932
+ />
2933
+ </div>
2934
+ </div>
2935
+ );
2936
+ })()}
2937
+ </Section>
2938
+ )}
2939
+
2940
+ {selectionColors.length > 0 && (
2941
+ <Section title="Selection colors" icon={<Palette size={15} />}>
2942
+ <div className="space-y-3">
2943
+ {selectionColors.map((entry) => (
2944
+ <SelectionColorRow
2945
+ key={`${entry.swatch}-${entry.token}`}
2946
+ swatch={entry.swatch}
2947
+ token={entry.token}
2948
+ sources={entry.sources}
2949
+ />
2950
+ ))}
2951
+ </div>
2952
+ </Section>
2953
+ )}
270
2954
  </>
271
2955
  )}
272
2956
  </div>