@hyperframes/studio 0.5.0-alpha.8 → 0.5.0

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