@hyperframes/studio 0.6.0-alpha.4 → 0.6.0-alpha.6

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.
@@ -15,21 +15,32 @@ import {
15
15
  MessageSquare,
16
16
  Move,
17
17
  Palette,
18
+ Plus,
18
19
  RotateCcw,
19
20
  Settings,
21
+ Square,
20
22
  Type,
21
23
  X,
24
+ Zap,
22
25
  } from "../../icons/SystemIcons";
23
26
  import {
24
27
  formatCssColor,
25
28
  hsvToRgb,
26
29
  parseCssColor,
27
30
  rgbToHsv,
31
+ toColorPickerValue,
28
32
  toHexColor,
29
33
  type ParsedColor,
30
34
  } from "./colorValue";
35
+ import {
36
+ buildDefaultGradientModel,
37
+ insertGradientStop,
38
+ parseGradient,
39
+ serializeGradient,
40
+ type GradientModel,
41
+ } from "./gradientValue";
31
42
  import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
32
- import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
43
+ import { readStudioBoxSize, readStudioPathOffset } from "./manualEdits";
33
44
  import {
34
45
  COMMON_LOCAL_FONT_FAMILIES,
35
46
  googleFontStylesheetUrl,
@@ -37,8 +48,11 @@ import {
37
48
  } from "./fontCatalog";
38
49
  import { fontFamilyFromAssetPath, importedFontFaceCss, type ImportedFontAsset } from "./fontAssets";
39
50
  import { resolveFloatingPanelPosition, type FloatingPosition } from "./floatingPanel";
51
+ import { IMAGE_EXT } from "../../utils/mediaTypes";
40
52
 
41
53
  interface PropertyPanelProps {
54
+ projectId: string;
55
+ assets: string[];
42
56
  element: DomEditSelection | null;
43
57
  multiSelectCount?: number;
44
58
  copiedAgentPrompt: boolean;
@@ -46,11 +60,13 @@ interface PropertyPanelProps {
46
60
  onSetStyle: (prop: string, value: string) => void | Promise<void>;
47
61
  onSetManualOffset: (element: DomEditSelection, next: { x: number; y: number }) => void;
48
62
  onSetManualSize: (element: DomEditSelection, next: { width: number; height: number }) => void;
49
- onSetRotation: (element: DomEditSelection, next: { angle: number }) => void;
50
63
  onSetText: (value: string, fieldKey?: string) => void;
51
64
  onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
65
+ onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
66
+ onRemoveTextField: (fieldKey: string) => void;
52
67
  onResetManualEdits: (element: DomEditSelection) => void;
53
68
  onAskAgent: () => void;
69
+ onImportAssets?: (files: FileList) => Promise<string[]>;
54
70
  fontAssets?: ImportedFontAsset[];
55
71
  onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
56
72
  }
@@ -206,6 +222,18 @@ function formatPxMetricValue(value: number): string {
206
222
  return `${formatNumericValue(value)}px`;
207
223
  }
208
224
 
225
+ function normalizeTextMetricValue(property: "letter-spacing" | "line-height", value: string) {
226
+ const trimmed = value.trim();
227
+ if (!trimmed || trimmed === "normal") return trimmed || "normal";
228
+ const token = parseNumericToken(trimmed);
229
+ if (!token) return trimmed;
230
+ if (property === "letter-spacing") {
231
+ return token.unit ? trimmed : `${formatNumericValue(token.value)}px`;
232
+ }
233
+ if (token.unit) return trimmed;
234
+ return token.value > 4 ? `${formatNumericValue(token.value)}px` : formatNumericValue(token.value);
235
+ }
236
+
209
237
  function splitCssFunctions(value: string): string[] {
210
238
  const functions: string[] = [];
211
239
  let current = "";
@@ -309,6 +337,23 @@ export function buildStrokeStyleUpdates(
309
337
  return updates;
310
338
  }
311
339
 
340
+ function buildClipPathValue(
341
+ preset: "none" | "inset" | "circle" | "custom",
342
+ radiusValue: number,
343
+ fallback: string | undefined,
344
+ ) {
345
+ if (preset === "custom") return fallback?.trim() || "none";
346
+ if (preset === "circle") return "circle(50% at 50% 50%)";
347
+ if (preset === "inset") {
348
+ return `inset(0 round ${formatNumericValue(Math.max(0, radiusValue))}px)`;
349
+ }
350
+ return "none";
351
+ }
352
+
353
+ function buildInsetClipPathValue(insetPx: number, radiusValue: number): string {
354
+ return `inset(${formatNumericValue(Math.max(0, insetPx))}px round ${formatNumericValue(Math.max(0, radiusValue))}px)`;
355
+ }
356
+
312
357
  function adjustNumericToken(
313
358
  value: string,
314
359
  direction: 1 | -1,
@@ -322,6 +367,123 @@ function adjustNumericToken(
322
367
  return `${formatNumericValue(nextValue)}${token.unit}`;
323
368
  }
324
369
 
370
+ function formatColorToken(value: string): string {
371
+ const parsed = parseCssColor(value);
372
+ if (!parsed) return value;
373
+ const hex = toColorPickerValue(value).replace(/^#/, "").toUpperCase();
374
+ return `${hex} / ${Math.round(parsed.alpha * 100)}%`;
375
+ }
376
+
377
+ function extractBackgroundImageUrl(value: string | undefined): string {
378
+ if (!value) return "";
379
+ const lowerValue = value.toLowerCase();
380
+ const urlStart = lowerValue.indexOf("url(");
381
+ if (urlStart < 0) return "";
382
+
383
+ let index = urlStart + 4;
384
+ while (
385
+ index < value.length &&
386
+ (value[index] === " " ||
387
+ value[index] === "\n" ||
388
+ value[index] === "\r" ||
389
+ value[index] === "\t" ||
390
+ value[index] === "\f")
391
+ ) {
392
+ index += 1;
393
+ }
394
+
395
+ const quote = value[index] === '"' || value[index] === "'" ? value[index] : null;
396
+ if (quote) {
397
+ index += 1;
398
+ const endQuote = value.indexOf(quote, index);
399
+ return endQuote >= index ? value.slice(index, endQuote) : "";
400
+ }
401
+
402
+ const endParen = value.indexOf(")", index);
403
+ if (endParen < index) return "";
404
+ return value.slice(index, endParen).trim();
405
+ }
406
+
407
+ function normalizeProjectPath(value: string): string {
408
+ const trimmed = value.trim();
409
+ const maybeUrl = /^[a-z]+:\/\//i.test(trimmed) ? new URL(trimmed).pathname : trimmed;
410
+ return decodeURIComponent(maybeUrl)
411
+ .replace(/\\/g, "/")
412
+ .replace(/^\.?\//, "");
413
+ }
414
+
415
+ function toRelativeProjectAssetPath(sourceFile: string, assetPath: string): string {
416
+ const fromParts = normalizeProjectPath(sourceFile).split("/").filter(Boolean);
417
+ const targetParts = normalizeProjectPath(assetPath).split("/").filter(Boolean);
418
+
419
+ fromParts.pop();
420
+
421
+ while (fromParts.length > 0 && targetParts.length > 0 && fromParts[0] === targetParts[0]) {
422
+ fromParts.shift();
423
+ targetParts.shift();
424
+ }
425
+
426
+ return [...fromParts.map(() => ".."), ...targetParts].join("/") || assetPath;
427
+ }
428
+
429
+ function toProjectRootAssetPath(assetPath: string): string {
430
+ return normalizeProjectPath(assetPath);
431
+ }
432
+
433
+ function resolveSelectedAsset(
434
+ imageUrl: string,
435
+ sourceFile: string,
436
+ assets: string[],
437
+ ): string | null {
438
+ const normalizedUrl = normalizeProjectPath(imageUrl);
439
+ if (!normalizedUrl) return null;
440
+
441
+ for (const asset of assets) {
442
+ const normalizedAsset = normalizeProjectPath(asset);
443
+ const relativeAsset = toRelativeProjectAssetPath(sourceFile, asset);
444
+ if (
445
+ normalizedUrl === normalizedAsset ||
446
+ normalizedUrl === relativeAsset ||
447
+ normalizedUrl.endsWith(`/${normalizedAsset}`) ||
448
+ normalizedUrl.endsWith(`/${relativeAsset}`)
449
+ ) {
450
+ return asset;
451
+ }
452
+ }
453
+
454
+ return null;
455
+ }
456
+
457
+ function collectSelectionColors(styles: Record<string, string>) {
458
+ const candidates = [
459
+ { source: "Fill", value: styles["background-color"] },
460
+ { source: "Text", value: styles.color },
461
+ ];
462
+
463
+ const deduped = new Map<string, { swatch: string; token: string; sources: string[] }>();
464
+
465
+ for (const candidate of candidates) {
466
+ if (!candidate.value) continue;
467
+ const parsed = parseCssColor(candidate.value);
468
+ if (!parsed || parsed.alpha <= 0) continue;
469
+
470
+ const key = `${toColorPickerValue(candidate.value)}-${Math.round(parsed.alpha * 100)}`;
471
+ const existing = deduped.get(key);
472
+ if (existing) {
473
+ existing.sources.push(candidate.source);
474
+ continue;
475
+ }
476
+
477
+ deduped.set(key, {
478
+ swatch: toColorPickerValue(candidate.value),
479
+ token: formatColorToken(candidate.value),
480
+ sources: [candidate.source],
481
+ });
482
+ }
483
+
484
+ return Array.from(deduped.values());
485
+ }
486
+
325
487
  function CommitField({
326
488
  value,
327
489
  disabled,
@@ -432,6 +594,27 @@ function MetricField({
432
594
  );
433
595
  }
434
596
 
597
+ function DetailField({
598
+ label,
599
+ value,
600
+ disabled,
601
+ onCommit,
602
+ }: {
603
+ label: string;
604
+ value: string;
605
+ disabled?: boolean;
606
+ onCommit: (nextValue: string) => void;
607
+ }) {
608
+ return (
609
+ <label className="grid min-w-0 gap-1.5">
610
+ <span className={LABEL}>{label}</span>
611
+ <div className={FIELD}>
612
+ <CommitField value={value} disabled={disabled} onCommit={onCommit} />
613
+ </div>
614
+ </label>
615
+ );
616
+ }
617
+
435
618
  function TextAreaField({
436
619
  label,
437
620
  value,
@@ -1044,6 +1227,75 @@ function FontFamilyField({
1044
1227
  );
1045
1228
  }
1046
1229
 
1230
+ function getTextStyleValue(
1231
+ field: DomEditSelection["textFields"][number],
1232
+ inheritedStyles: Record<string, string>,
1233
+ property: string,
1234
+ fallback: string,
1235
+ ): string {
1236
+ return field.computedStyles[property] || inheritedStyles[property] || fallback;
1237
+ }
1238
+
1239
+ function AdvancedTextControls({
1240
+ field,
1241
+ inheritedStyles,
1242
+ disabled,
1243
+ onCommit,
1244
+ }: {
1245
+ field: DomEditSelection["textFields"][number];
1246
+ inheritedStyles: Record<string, string>;
1247
+ disabled?: boolean;
1248
+ onCommit: (property: string, value: string) => void;
1249
+ }) {
1250
+ return (
1251
+ <div className="space-y-4">
1252
+ <div className={RESPONSIVE_GRID}>
1253
+ <MetricField
1254
+ label="Line"
1255
+ value={getTextStyleValue(field, inheritedStyles, "line-height", "normal")}
1256
+ disabled={disabled}
1257
+ liveCommit
1258
+ onCommit={(next) =>
1259
+ onCommit("line-height", normalizeTextMetricValue("line-height", next))
1260
+ }
1261
+ />
1262
+ <MetricField
1263
+ label="Track"
1264
+ value={getTextStyleValue(field, inheritedStyles, "letter-spacing", "0px")}
1265
+ disabled={disabled}
1266
+ liveCommit
1267
+ onCommit={(next) =>
1268
+ onCommit("letter-spacing", normalizeTextMetricValue("letter-spacing", next))
1269
+ }
1270
+ />
1271
+ </div>
1272
+ <div className={RESPONSIVE_GRID}>
1273
+ <SelectField
1274
+ label="Align"
1275
+ value={getTextStyleValue(field, inheritedStyles, "text-align", "start")}
1276
+ disabled={disabled}
1277
+ onChange={(next) => onCommit("text-align", next)}
1278
+ options={["start", "left", "center", "right", "justify", "end"]}
1279
+ />
1280
+ <SelectField
1281
+ label="Case"
1282
+ value={getTextStyleValue(field, inheritedStyles, "text-transform", "none")}
1283
+ disabled={disabled}
1284
+ onChange={(next) => onCommit("text-transform", next)}
1285
+ options={["none", "uppercase", "lowercase", "capitalize"]}
1286
+ />
1287
+ </div>
1288
+ <SelectField
1289
+ label="Style"
1290
+ value={getTextStyleValue(field, inheritedStyles, "font-style", "normal")}
1291
+ disabled={disabled}
1292
+ onChange={(next) => onCommit("font-style", next)}
1293
+ options={["normal", "italic", "oblique"]}
1294
+ />
1295
+ </div>
1296
+ );
1297
+ }
1298
+
1047
1299
  function ColorField({
1048
1300
  label,
1049
1301
  value,
@@ -1397,6 +1649,360 @@ function ColorSlider({
1397
1649
  );
1398
1650
  }
1399
1651
 
1652
+ function ImageFillField({
1653
+ projectId,
1654
+ sourceFile,
1655
+ value,
1656
+ assets,
1657
+ disabled,
1658
+ onCommit,
1659
+ onImportAssets,
1660
+ }: {
1661
+ projectId: string;
1662
+ sourceFile: string;
1663
+ value: string;
1664
+ assets: string[];
1665
+ disabled?: boolean;
1666
+ onCommit: (nextValue: string) => void;
1667
+ onImportAssets?: (files: FileList) => Promise<string[]>;
1668
+ }) {
1669
+ const fileInputRef = useRef<HTMLInputElement | null>(null);
1670
+ const [uploading, setUploading] = useState(false);
1671
+ const imageAssets = useMemo(() => assets.filter((asset) => IMAGE_EXT.test(asset)), [assets]);
1672
+ const selectedAsset = useMemo(
1673
+ () => resolveSelectedAsset(value, sourceFile, imageAssets),
1674
+ [imageAssets, sourceFile, value],
1675
+ );
1676
+ const externalUrlValue = selectedAsset ? "" : value;
1677
+
1678
+ const handleUpload = async (files: FileList | null) => {
1679
+ if (!files?.length || !onImportAssets) return;
1680
+ setUploading(true);
1681
+ try {
1682
+ const uploaded = await onImportAssets(files);
1683
+ const nextImage = uploaded.find((asset) => IMAGE_EXT.test(asset));
1684
+ if (nextImage) {
1685
+ onCommit(`url("${toProjectRootAssetPath(nextImage)}")`);
1686
+ }
1687
+ } finally {
1688
+ setUploading(false);
1689
+ }
1690
+ };
1691
+
1692
+ return (
1693
+ <div className="space-y-4">
1694
+ <div className="grid min-w-0 gap-1.5">
1695
+ <div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
1696
+ <span className={LABEL}>Project asset</span>
1697
+ <button
1698
+ type="button"
1699
+ disabled={disabled || uploading}
1700
+ onClick={() => fileInputRef.current?.click()}
1701
+ 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 ${
1702
+ disabled || uploading
1703
+ ? "cursor-not-allowed text-neutral-600"
1704
+ : "cursor-pointer hover:border-neutral-600 hover:text-white"
1705
+ }`}
1706
+ >
1707
+ <Plus size={12} className="flex-shrink-0" />
1708
+ <span className="truncate">{uploading ? "Uploading…" : "Upload image"}</span>
1709
+ </button>
1710
+ <input
1711
+ ref={fileInputRef}
1712
+ type="file"
1713
+ accept="image/*"
1714
+ aria-label="Upload image asset"
1715
+ disabled={disabled || uploading}
1716
+ className="hidden"
1717
+ onChange={async (event) => {
1718
+ await handleUpload(event.target.files);
1719
+ event.target.value = "";
1720
+ }}
1721
+ />
1722
+ </div>
1723
+ {imageAssets.length > 0 ? (
1724
+ <div className="space-y-3">
1725
+ {selectedAsset && (
1726
+ <div className="overflow-hidden rounded-xl border border-neutral-800 bg-neutral-900/80">
1727
+ <img
1728
+ src={`/api/projects/${projectId}/preview/${selectedAsset}`}
1729
+ alt={selectedAsset.split("/").pop() ?? selectedAsset}
1730
+ className="h-28 w-full object-contain bg-neutral-950/80"
1731
+ />
1732
+ </div>
1733
+ )}
1734
+ <div className={FIELD}>
1735
+ <select
1736
+ value={selectedAsset ?? ""}
1737
+ disabled={disabled}
1738
+ onChange={(e) => {
1739
+ const nextAsset = e.target.value;
1740
+ if (!nextAsset) {
1741
+ onCommit("none");
1742
+ return;
1743
+ }
1744
+ onCommit(`url("${toProjectRootAssetPath(nextAsset)}")`);
1745
+ }}
1746
+ 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"
1747
+ >
1748
+ <option value="">None</option>
1749
+ {imageAssets.map((asset) => (
1750
+ <option key={asset} value={asset}>
1751
+ {asset}
1752
+ </option>
1753
+ ))}
1754
+ </select>
1755
+ </div>
1756
+ </div>
1757
+ ) : (
1758
+ <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">
1759
+ No image assets yet. Upload one here and Studio will also add it to the Assets tab.
1760
+ </div>
1761
+ )}
1762
+ </div>
1763
+
1764
+ <DetailField
1765
+ label="External URL"
1766
+ value={externalUrlValue}
1767
+ disabled={disabled}
1768
+ onCommit={(next) => onCommit(next.trim() ? `url("${next.trim()}")` : "none")}
1769
+ />
1770
+ </div>
1771
+ );
1772
+ }
1773
+
1774
+ function GradientField({
1775
+ value,
1776
+ fallbackColor,
1777
+ disabled,
1778
+ onCommit,
1779
+ }: {
1780
+ value: string;
1781
+ fallbackColor: string | undefined;
1782
+ disabled?: boolean;
1783
+ onCommit: (nextValue: string) => void;
1784
+ }) {
1785
+ const previewRef = useRef<HTMLDivElement | null>(null);
1786
+ const parsed = parseGradient(value) ?? buildDefaultGradientModel(fallbackColor);
1787
+
1788
+ const commit = (next: GradientModel) => onCommit(serializeGradient(next));
1789
+
1790
+ const patch = (partial: Partial<GradientModel>) => commit({ ...parsed, ...partial });
1791
+
1792
+ const updateStop = (index: number, partial: Partial<GradientModel["stops"][number]>) => {
1793
+ const stops = parsed.stops.map((stop, stopIndex) =>
1794
+ stopIndex === index ? { ...stop, ...partial } : stop,
1795
+ );
1796
+ commit({ ...parsed, stops });
1797
+ };
1798
+
1799
+ const addStop = (position?: number) => {
1800
+ const nextGradient =
1801
+ position != null
1802
+ ? insertGradientStop(parsed, position)
1803
+ : insertGradientStop(
1804
+ parsed,
1805
+ parsed.stops.at(-1)?.position != null
1806
+ ? Math.min(100, (parsed.stops.at(-1)?.position ?? 90) + 10)
1807
+ : 100,
1808
+ );
1809
+ commit(nextGradient);
1810
+ };
1811
+
1812
+ const removeStop = (index: number) => {
1813
+ if (parsed.stops.length <= 2) return;
1814
+ commit({ ...parsed, stops: parsed.stops.filter((_, stopIndex) => stopIndex !== index) });
1815
+ };
1816
+
1817
+ const previewStyle = {
1818
+ backgroundImage: serializeGradient(parsed),
1819
+ };
1820
+
1821
+ return (
1822
+ <div className="space-y-4">
1823
+ <div className={`${FIELD} space-y-3 p-3`}>
1824
+ <div
1825
+ ref={previewRef}
1826
+ className="relative h-11 overflow-hidden rounded-lg border border-neutral-700"
1827
+ style={previewStyle}
1828
+ onClick={(event) => {
1829
+ if (disabled) return;
1830
+ const rect = previewRef.current?.getBoundingClientRect();
1831
+ if (!rect || rect.width <= 0) return;
1832
+ const position = ((event.clientX - rect.left) / rect.width) * 100;
1833
+ addStop(position);
1834
+ }}
1835
+ >
1836
+ {parsed.stops.map((stop, index) => (
1837
+ <div
1838
+ key={`stop-preview-${index}`}
1839
+ 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)]"
1840
+ style={{
1841
+ left: `calc(${stop.position}% - 8px)`,
1842
+ backgroundColor: stop.color,
1843
+ }}
1844
+ />
1845
+ ))}
1846
+ </div>
1847
+ <div className="flex min-w-0 flex-wrap items-center gap-2">
1848
+ <SegmentedControl
1849
+ disabled={disabled}
1850
+ value={parsed.kind}
1851
+ onChange={(next) => patch({ kind: next as GradientModel["kind"] })}
1852
+ options={[
1853
+ { label: "Linear", value: "linear" },
1854
+ { label: "Radial", value: "radial" },
1855
+ { label: "Conic", value: "conic" },
1856
+ ]}
1857
+ />
1858
+ <label className="flex items-center gap-2 text-[11px] font-medium text-neutral-400">
1859
+ <input
1860
+ type="checkbox"
1861
+ checked={parsed.repeating}
1862
+ disabled={disabled}
1863
+ onChange={(e) => patch({ repeating: e.target.checked })}
1864
+ className="h-4 w-4 rounded border-neutral-700 bg-neutral-950 text-[#3ce6ac] focus:ring-[#3ce6ac]"
1865
+ />
1866
+ Repeat
1867
+ </label>
1868
+ <button
1869
+ type="button"
1870
+ disabled={disabled}
1871
+ onClick={() =>
1872
+ commit({
1873
+ ...parsed,
1874
+ stops: [...parsed.stops].reverse().map((stop) => ({
1875
+ ...stop,
1876
+ position: 100 - stop.position,
1877
+ })),
1878
+ })
1879
+ }
1880
+ 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"
1881
+ >
1882
+ <RotateCcw size={12} />
1883
+ Reverse
1884
+ </button>
1885
+ </div>
1886
+ </div>
1887
+
1888
+ {(parsed.kind === "linear" || parsed.kind === "conic") && (
1889
+ <div className="grid gap-1.5">
1890
+ <span className={LABEL}>{parsed.kind === "linear" ? "Angle" : "Start angle"}</span>
1891
+ <SliderControl
1892
+ value={parsed.angle}
1893
+ min={0}
1894
+ max={360}
1895
+ step={1}
1896
+ disabled={disabled}
1897
+ displayValue={`${Math.round(parsed.angle)}°`}
1898
+ formatDisplayValue={(next) => `${Math.round(next)}°`}
1899
+ onCommit={(next) => patch({ angle: next })}
1900
+ />
1901
+ </div>
1902
+ )}
1903
+
1904
+ {parsed.kind === "radial" && (
1905
+ <div className={RESPONSIVE_GRID}>
1906
+ <SelectField
1907
+ label="Shape"
1908
+ value={parsed.shape}
1909
+ disabled={disabled}
1910
+ onChange={(next) => patch({ shape: next as GradientModel["shape"] })}
1911
+ options={["ellipse", "circle"]}
1912
+ />
1913
+ <SelectField
1914
+ label="Size"
1915
+ value={parsed.radialSize}
1916
+ disabled={disabled}
1917
+ onChange={(next) => patch({ radialSize: next as GradientModel["radialSize"] })}
1918
+ options={["closest-side", "closest-corner", "farthest-side", "farthest-corner"]}
1919
+ />
1920
+ </div>
1921
+ )}
1922
+
1923
+ {(parsed.kind === "radial" || parsed.kind === "conic") && (
1924
+ <div className={RESPONSIVE_GRID}>
1925
+ <div className="grid min-w-0 gap-1.5">
1926
+ <span className={LABEL}>Center X</span>
1927
+ <SliderControl
1928
+ value={parsed.centerX}
1929
+ min={0}
1930
+ max={100}
1931
+ step={1}
1932
+ disabled={disabled}
1933
+ displayValue={`${Math.round(parsed.centerX)}%`}
1934
+ formatDisplayValue={(next) => `${Math.round(next)}%`}
1935
+ onCommit={(next) => patch({ centerX: next })}
1936
+ />
1937
+ </div>
1938
+ <div className="grid min-w-0 gap-1.5">
1939
+ <span className={LABEL}>Center Y</span>
1940
+ <SliderControl
1941
+ value={parsed.centerY}
1942
+ min={0}
1943
+ max={100}
1944
+ step={1}
1945
+ disabled={disabled}
1946
+ displayValue={`${Math.round(parsed.centerY)}%`}
1947
+ formatDisplayValue={(next) => `${Math.round(next)}%`}
1948
+ onCommit={(next) => patch({ centerY: next })}
1949
+ />
1950
+ </div>
1951
+ </div>
1952
+ )}
1953
+
1954
+ <div className="space-y-3">
1955
+ <div className="flex items-center justify-between">
1956
+ <span className={LABEL}>Stops</span>
1957
+ <button
1958
+ type="button"
1959
+ disabled={disabled || parsed.stops.length >= 6}
1960
+ onClick={() => addStop()}
1961
+ 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"
1962
+ >
1963
+ <Plus size={12} />
1964
+ Add stop
1965
+ </button>
1966
+ </div>
1967
+ <div className="space-y-3">
1968
+ {parsed.stops.map((stop, index) => (
1969
+ <div
1970
+ key={`stop-editor-${index}`}
1971
+ className="grid min-w-0 grid-cols-[minmax(0,1fr)_68px_28px] gap-2"
1972
+ >
1973
+ <ColorField
1974
+ label={`Stop ${index + 1}`}
1975
+ value={stop.color}
1976
+ disabled={disabled}
1977
+ onCommit={(next) => updateStop(index, { color: next })}
1978
+ />
1979
+ <DetailField
1980
+ label="Pos"
1981
+ value={`${Math.round(stop.position)}%`}
1982
+ disabled={disabled}
1983
+ onCommit={(next) =>
1984
+ updateStop(index, {
1985
+ position: Number.parseFloat(next.replace("%", "")) || 0,
1986
+ })
1987
+ }
1988
+ />
1989
+ <button
1990
+ type="button"
1991
+ disabled={disabled || parsed.stops.length <= 2}
1992
+ onClick={() => removeStop(index)}
1993
+ 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"
1994
+ aria-label={`Remove stop ${index + 1}`}
1995
+ >
1996
+ <X size={12} />
1997
+ </button>
1998
+ </div>
1999
+ ))}
2000
+ </div>
2001
+ </div>
2002
+ </div>
2003
+ );
2004
+ }
2005
+
1400
2006
  function SliderControl({
1401
2007
  value,
1402
2008
  min,
@@ -1477,6 +2083,44 @@ function SliderControl({
1477
2083
  );
1478
2084
  }
1479
2085
 
2086
+ function SegmentedControl({
2087
+ options,
2088
+ value,
2089
+ disabled,
2090
+ onChange,
2091
+ }: {
2092
+ options: Array<{ label: string; value: string }>;
2093
+ value: string;
2094
+ disabled?: boolean;
2095
+ onChange: (nextValue: string) => void;
2096
+ }) {
2097
+ return (
2098
+ <div
2099
+ 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)]"
2100
+ style={{ gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))` }}
2101
+ >
2102
+ {options.map((option) => {
2103
+ const selected = option.value === value;
2104
+ return (
2105
+ <button
2106
+ key={option.value}
2107
+ type="button"
2108
+ disabled={disabled}
2109
+ onClick={() => onChange(option.value)}
2110
+ className={`min-w-0 truncate rounded-lg px-2 py-1.5 text-[11px] font-medium transition-colors disabled:cursor-not-allowed ${
2111
+ selected
2112
+ ? "bg-neutral-800 text-white shadow-[0_1px_3px_rgba(0,0,0,0.28)]"
2113
+ : "text-neutral-500 hover:text-neutral-200"
2114
+ }`}
2115
+ >
2116
+ {option.label}
2117
+ </button>
2118
+ );
2119
+ })}
2120
+ </div>
2121
+ );
2122
+ }
2123
+
1480
2124
  function SelectField({
1481
2125
  label,
1482
2126
  value,
@@ -1540,36 +2184,34 @@ function Section({
1540
2184
  );
1541
2185
  }
1542
2186
 
1543
- export type PropertyPanelSectionId = "Text" | "Layout" | "Colors" | "Radius" | "Shadow";
1544
-
1545
- export function getPropertyPanelVisibleSections(input: {
1546
- hasSelection: boolean;
1547
- canEditStyles: boolean;
1548
- hasTextControls: boolean;
1549
- hasColorControls: boolean;
1550
- }): PropertyPanelSectionId[] {
1551
- if (!input.hasSelection) return [];
1552
-
1553
- const sections: PropertyPanelSectionId[] = [];
1554
- if (input.hasTextControls) sections.push("Text");
1555
- sections.push("Layout");
1556
- if (input.canEditStyles) {
1557
- if (input.hasColorControls) sections.push("Colors");
1558
- sections.push("Radius", "Shadow");
1559
- }
1560
- return sections;
1561
- }
1562
-
1563
- export function isPropertyPanelMediaLikeSelection(input: {
1564
- tagName: string;
1565
- styles: Record<string, string>;
1566
- }): boolean {
1567
- if (["img", "video", "audio", "canvas"].includes(input.tagName)) return true;
1568
- const backgroundImage = input.styles["background-image"]?.trim();
1569
- return Boolean(backgroundImage && backgroundImage !== "none");
2187
+ function SelectionColorRow({
2188
+ swatch,
2189
+ token,
2190
+ sources,
2191
+ }: {
2192
+ swatch: string;
2193
+ token: string;
2194
+ sources: string[];
2195
+ }) {
2196
+ return (
2197
+ <div className={`${FIELD} flex min-w-0 items-center gap-3`}>
2198
+ <div
2199
+ className="h-7 w-7 flex-shrink-0 rounded-lg border border-neutral-700"
2200
+ style={{ backgroundColor: swatch }}
2201
+ />
2202
+ <div className="min-w-0 flex-1">
2203
+ <div className="truncate text-[11px] font-medium text-neutral-100">{token}</div>
2204
+ <div className="truncate text-[11px] uppercase tracking-[0.18em] text-neutral-500">
2205
+ {sources.join(" · ")}
2206
+ </div>
2207
+ </div>
2208
+ </div>
2209
+ );
1570
2210
  }
1571
2211
 
1572
2212
  export const PropertyPanel = memo(function PropertyPanel({
2213
+ projectId,
2214
+ assets,
1573
2215
  element,
1574
2216
  multiSelectCount = 0,
1575
2217
  copiedAgentPrompt,
@@ -1577,20 +2219,36 @@ export const PropertyPanel = memo(function PropertyPanel({
1577
2219
  onSetStyle,
1578
2220
  onSetManualOffset,
1579
2221
  onSetManualSize,
1580
- onSetRotation,
1581
2222
  onSetText,
1582
2223
  onSetTextFieldStyle,
2224
+ onAddTextField,
2225
+ onRemoveTextField,
1583
2226
  onResetManualEdits,
1584
2227
  onAskAgent,
2228
+ onImportAssets,
1585
2229
  fontAssets = [],
1586
2230
  onImportFonts,
1587
2231
  }: PropertyPanelProps) {
1588
2232
  const styles = element?.computedStyles ?? EMPTY_STYLES;
2233
+ const selectionColors = useMemo(() => collectSelectionColors(styles), [styles]);
2234
+ const backgroundImage = styles["background-image"] ?? "none";
2235
+ const fillMode =
2236
+ backgroundImage && backgroundImage !== "none"
2237
+ ? backgroundImage.includes("gradient")
2238
+ ? "Gradient"
2239
+ : "Image"
2240
+ : "Solid";
2241
+ const [preferredFillMode, setPreferredFillMode] = useState(fillMode);
2242
+ const imageUrl = extractBackgroundImageUrl(backgroundImage);
1589
2243
  const [activeTextFieldKey, setActiveTextFieldKey] = useState<string | null>(
1590
2244
  element?.textFields[0]?.key ?? null,
1591
2245
  );
1592
2246
  const hasTextControls = element != null && isTextEditableSelection(element);
1593
2247
 
2248
+ useEffect(() => {
2249
+ setPreferredFillMode(fillMode);
2250
+ }, [fillMode, element?.id, element?.selector, backgroundImage]);
2251
+
1594
2252
  useEffect(() => {
1595
2253
  const nextFields = element?.textFields ?? [];
1596
2254
  setActiveTextFieldKey((current) => {
@@ -1620,7 +2278,8 @@ export const PropertyPanel = memo(function PropertyPanel({
1620
2278
  Select an element in the preview.
1621
2279
  </p>
1622
2280
  <p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
1623
- Click any element to inspect and edit it. Click deeper to select child elements.
2281
+ The inspector is tuned for element edits with safer geometry controls, color picking,
2282
+ and cleaner grouped layer controls.
1624
2283
  </p>
1625
2284
  </>
1626
2285
  )}
@@ -1631,31 +2290,26 @@ export const PropertyPanel = memo(function PropertyPanel({
1631
2290
  const styleEditingDisabled = !element.capabilities.canEditStyles;
1632
2291
  const manualOffsetEditingDisabled = !element.capabilities.canApplyManualOffset;
1633
2292
  const manualSizeEditingDisabled = !element.capabilities.canApplyManualSize;
2293
+ const isFlex = styles.display === "flex" || styles.display === "inline-flex";
1634
2294
  const radiusValue = parseNumericValue(styles["border-radius"]) ?? 0;
2295
+ const opacityValue = Math.round((parseNumericValue(styles.opacity) ?? 1) * 100);
2296
+ const borderWidthValue =
2297
+ parsePxMetricValue(styles["border-width"] ?? "") ??
2298
+ parsePxMetricValue(styles["border-top-width"] ?? "") ??
2299
+ 0;
2300
+ const borderStyleValue = styles["border-style"] || styles["border-top-style"] || "none";
2301
+ const borderColorValue =
2302
+ styles["border-color"] || styles["border-top-color"] || "rgba(255, 255, 255, 0.18)";
1635
2303
  const boxShadowPreset = inferBoxShadowPreset(styles["box-shadow"]);
2304
+ const filterBlurValue = getCssFilterFunctionPx(styles.filter, "blur");
2305
+ const backdropBlurValue = getCssFilterFunctionPx(styles["backdrop-filter"], "blur");
2306
+ const clipPathValue = styles["clip-path"] || "none";
2307
+ const clipPathPreset = inferClipPathPreset(clipPathValue);
2308
+ const clipInsetValue = getClipPathInsetPx(clipPathValue);
1636
2309
  const sourceLabel = element.id ? `#${element.id}` : element.selector;
1637
2310
  const showEditableSections = element.capabilities.canEditStyles;
1638
- const mediaLikeSelection = isPropertyPanelMediaLikeSelection({
1639
- tagName: element.tagName,
1640
- styles,
1641
- });
1642
- const bgColor = styles["background-color"]?.trim();
1643
- const hasExplicitFillColor =
1644
- !mediaLikeSelection &&
1645
- Boolean(bgColor) &&
1646
- bgColor !== "rgba(0, 0, 0, 0)" &&
1647
- bgColor !== "transparent";
1648
- const canShowFillColor = hasExplicitFillColor;
1649
- const canShowTextColor = !mediaLikeSelection && Boolean(styles.color?.trim());
1650
- const visibleSections = getPropertyPanelVisibleSections({
1651
- hasSelection: true,
1652
- canEditStyles: showEditableSections,
1653
- hasTextControls,
1654
- hasColorControls: canShowFillColor || canShowTextColor,
1655
- });
1656
2311
  const manualOffset = readStudioPathOffset(element.element);
1657
2312
  const manualSize = readStudioBoxSize(element.element);
1658
- const manualRotation = readStudioRotation(element.element);
1659
2313
  const resolvedWidth =
1660
2314
  manualSize.width > 0
1661
2315
  ? manualSize.width
@@ -1693,6 +2347,20 @@ export const PropertyPanel = memo(function PropertyPanel({
1693
2347
  });
1694
2348
  };
1695
2349
 
2350
+ const handleFillModeChange = (nextMode: string) => {
2351
+ setPreferredFillMode(nextMode);
2352
+ if (nextMode === "Solid") {
2353
+ onSetStyle("background-image", "none");
2354
+ return;
2355
+ }
2356
+ if (nextMode === "Gradient" && !backgroundImage.includes("gradient")) {
2357
+ onSetStyle(
2358
+ "background-image",
2359
+ serializeGradient(buildDefaultGradientModel(styles["background-color"])),
2360
+ );
2361
+ }
2362
+ };
2363
+
1696
2364
  return (
1697
2365
  <div className="flex h-full min-h-0 flex-col overflow-hidden bg-neutral-900 text-neutral-100">
1698
2366
  <div className="border-b border-neutral-800 px-4 py-5">
@@ -1735,205 +2403,574 @@ export const PropertyPanel = memo(function PropertyPanel({
1735
2403
  </div>
1736
2404
 
1737
2405
  <div className="flex-1 overflow-y-auto">
1738
- {visibleSections.includes("Text") && (
1739
- <Section title="Text" icon={<Type size={15} />}>
1740
- {(() => {
1741
- const textFields = element.textFields;
1742
- const activeField =
1743
- textFields.find((field) => field.key === activeTextFieldKey) ?? textFields[0];
1744
- if (!activeField) return null;
1745
-
1746
- return (
1747
- <div className="space-y-4">
1748
- {textFields.length > 1 && (
1749
- <div className="grid gap-2">
1750
- <span className={LABEL}>Text layers</span>
1751
- {textFields.map((field, index) => {
1752
- const active = field.key === activeField.key;
1753
- return (
1754
- <button
1755
- key={field.key}
1756
- type="button"
1757
- onClick={() => setActiveTextFieldKey(field.key)}
1758
- className={`min-w-0 w-full rounded-xl border px-3 py-2 text-left transition-colors ${
1759
- active
1760
- ? "border-studio-accent/50 bg-studio-accent/10"
1761
- : "border-neutral-800 bg-neutral-900/80 hover:border-neutral-700 hover:bg-neutral-900"
1762
- }`}
1763
- >
1764
- <div className="flex min-w-0 items-center justify-between gap-2">
1765
- <div className="flex min-w-0 items-center gap-2">
1766
- <span
1767
- className="h-4 w-4 flex-shrink-0 rounded border border-neutral-700 bg-neutral-950"
1768
- style={{ backgroundColor: getTextFieldColor(field, styles) }}
1769
- />
1770
- <span className="min-w-0 truncate text-[11px] font-medium text-neutral-100">
1771
- {formatTextFieldPreview(field.value) || `Text ${index + 1}`}
1772
- </span>
1773
- </div>
1774
- <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">
1775
- {field.tagName}
1776
- </span>
1777
- </div>
1778
- </button>
1779
- );
1780
- })}
1781
- </div>
1782
- )}
1783
-
1784
- <div className="space-y-4 rounded-xl border border-neutral-800 bg-neutral-900/60 p-3">
1785
- <TextAreaField
1786
- key={activeField.key}
1787
- label="Content"
1788
- value={activeField.value}
1789
- disabled={false}
1790
- onCommit={(next) => onSetText(next, activeField.key)}
1791
- />
1792
-
1793
- <ColorField
1794
- label="Text color"
1795
- value={getTextFieldColor(activeField, styles)}
1796
- disabled={false}
1797
- onCommit={(next) => onSetTextFieldStyle(activeField.key, "color", next)}
1798
- />
1799
-
1800
- <FontFamilyField
1801
- value={
1802
- activeField.computedStyles["font-family"] ||
1803
- styles["font-family"] ||
1804
- "inherit"
1805
- }
1806
- disabled={false}
1807
- importedFonts={fontAssets}
1808
- onImportFonts={onImportFonts}
1809
- onCommit={(next) => onSetTextFieldStyle(activeField.key, "font-family", next)}
1810
- />
1811
-
1812
- <div className={RESPONSIVE_GRID}>
1813
- <MetricField
1814
- label="Size"
1815
- value={
1816
- activeField.computedStyles["font-size"] || styles["font-size"] || "16px"
1817
- }
1818
- disabled={false}
1819
- liveCommit
1820
- onCommit={(next) => onSetTextFieldStyle(activeField.key, "font-size", next)}
1821
- />
1822
- <FontWeightField
1823
- value={
1824
- activeField.computedStyles["font-weight"] ||
1825
- styles["font-weight"] ||
1826
- "400"
1827
- }
1828
- disabled={false}
1829
- onCommit={(next) =>
1830
- onSetTextFieldStyle(activeField.key, "font-weight", next)
1831
- }
1832
- />
1833
- </div>
1834
- </div>
1835
- </div>
1836
- );
1837
- })()}
1838
- </Section>
1839
- )}
2406
+ <Section title="Layout" icon={<Move size={15} />}>
2407
+ <div className={RESPONSIVE_GRID}>
2408
+ <MetricField
2409
+ label="X"
2410
+ value={formatPxMetricValue(manualOffset.x)}
2411
+ disabled={manualOffsetEditingDisabled}
2412
+ onCommit={(next) => commitManualOffset("x", next)}
2413
+ />
2414
+ <MetricField
2415
+ label="Y"
2416
+ value={formatPxMetricValue(manualOffset.y)}
2417
+ disabled={manualOffsetEditingDisabled}
2418
+ onCommit={(next) => commitManualOffset("y", next)}
2419
+ />
2420
+ <MetricField
2421
+ label="W"
2422
+ value={formatPxMetricValue(resolvedWidth)}
2423
+ disabled={manualSizeEditingDisabled}
2424
+ onCommit={(next) => commitManualSize("width", next)}
2425
+ />
2426
+ <MetricField
2427
+ label="H"
2428
+ value={formatPxMetricValue(resolvedHeight)}
2429
+ disabled={manualSizeEditingDisabled}
2430
+ onCommit={(next) => commitManualSize("height", next)}
2431
+ />
2432
+ </div>
2433
+ </Section>
1840
2434
 
1841
- {visibleSections.includes("Layout") && (
1842
- <Section title="Layout" icon={<Move size={15} />}>
1843
- <div className={RESPONSIVE_GRID}>
1844
- <MetricField
1845
- label="X"
1846
- value={formatPxMetricValue(manualOffset.x)}
1847
- disabled={manualOffsetEditingDisabled}
1848
- onCommit={(next) => commitManualOffset("x", next)}
1849
- />
1850
- <MetricField
1851
- label="Y"
1852
- value={formatPxMetricValue(manualOffset.y)}
1853
- disabled={manualOffsetEditingDisabled}
1854
- onCommit={(next) => commitManualOffset("y", next)}
1855
- />
1856
- <MetricField
1857
- label="W"
1858
- value={formatPxMetricValue(resolvedWidth)}
1859
- disabled={manualSizeEditingDisabled}
1860
- onCommit={(next) => commitManualSize("width", next)}
1861
- />
1862
- <MetricField
1863
- label="H"
1864
- value={formatPxMetricValue(resolvedHeight)}
1865
- disabled={manualSizeEditingDisabled}
1866
- onCommit={(next) => commitManualSize("height", next)}
2435
+ {showEditableSections && isFlex && (
2436
+ <Section title="Flex" icon={<Layers size={15} />}>
2437
+ <div className="space-y-4">
2438
+ <SegmentedControl
2439
+ disabled={styleEditingDisabled}
2440
+ value={styles["flex-direction"] || "row"}
2441
+ onChange={(next) => onSetStyle("flex-direction", next)}
2442
+ options={[
2443
+ { label: "→ Row", value: "row" },
2444
+ { label: "↓ Column", value: "column" },
2445
+ ]}
1867
2446
  />
1868
- <MetricField
1869
- label="Rotate"
1870
- value={`${formatNumericValue(manualRotation.angle)}°`}
1871
- disabled={manualOffsetEditingDisabled}
1872
- onCommit={(next) => {
1873
- const parsed = parseFloat(next.replace("°", "").trim());
1874
- if (Number.isFinite(parsed)) {
1875
- onSetRotation(element, { angle: parsed % 360 });
1876
- }
1877
- }}
2447
+ <div className={RESPONSIVE_GRID}>
2448
+ <SelectField
2449
+ label="Justify"
2450
+ value={styles["justify-content"] || "flex-start"}
2451
+ disabled={styleEditingDisabled}
2452
+ onChange={(next) => onSetStyle("justify-content", next)}
2453
+ options={[
2454
+ "flex-start",
2455
+ "center",
2456
+ "space-between",
2457
+ "space-around",
2458
+ "space-evenly",
2459
+ "flex-end",
2460
+ ]}
2461
+ />
2462
+ <SelectField
2463
+ label="Align"
2464
+ value={styles["align-items"] || "stretch"}
2465
+ disabled={styleEditingDisabled}
2466
+ onChange={(next) => onSetStyle("align-items", next)}
2467
+ options={["stretch", "flex-start", "center", "flex-end", "baseline"]}
2468
+ />
2469
+ </div>
2470
+ <DetailField
2471
+ label="Gap"
2472
+ value={styles.gap ?? "0px"}
2473
+ disabled={styleEditingDisabled}
2474
+ onCommit={(next) => onSetStyle("gap", next.endsWith("px") ? next : `${next}px`)}
1878
2475
  />
1879
2476
  </div>
1880
2477
  </Section>
1881
2478
  )}
1882
2479
 
1883
- {visibleSections.includes("Colors") && (
1884
- <Section title="Colors" icon={<Palette size={15} />}>
1885
- <div className="space-y-4">
1886
- {canShowFillColor && (
2480
+ {showEditableSections && (
2481
+ <>
2482
+ <Section title="Radius" icon={<Settings size={15} />}>
2483
+ <SliderControl
2484
+ value={radiusValue}
2485
+ min={0}
2486
+ max={Math.max(240, Math.ceil(radiusValue))}
2487
+ step={1}
2488
+ disabled={styleEditingDisabled}
2489
+ displayValue={`${formatNumericValue(radiusValue)}px`}
2490
+ formatDisplayValue={(next) => `${formatNumericValue(next)}px`}
2491
+ onCommit={(next) => onSetStyle("border-radius", `${formatNumericValue(next)}px`)}
2492
+ />
2493
+ </Section>
2494
+
2495
+ <Section title="Stroke" icon={<Square size={15} />}>
2496
+ <div className="space-y-4">
2497
+ <div className={RESPONSIVE_GRID}>
2498
+ <MetricField
2499
+ label="Width"
2500
+ value={formatPxMetricValue(borderWidthValue)}
2501
+ disabled={styleEditingDisabled}
2502
+ liveCommit
2503
+ onCommit={async (next) => {
2504
+ const normalized = normalizePanelPxValue(next, {
2505
+ min: 0,
2506
+ max: 200,
2507
+ fallback: borderWidthValue,
2508
+ });
2509
+ if (!normalized) return;
2510
+ for (const [property, value] of buildStrokeWidthStyleUpdates(
2511
+ normalized,
2512
+ borderStyleValue,
2513
+ )) {
2514
+ await onSetStyle(property, value);
2515
+ }
2516
+ }}
2517
+ />
2518
+ <SelectField
2519
+ label="Style"
2520
+ value={borderStyleValue}
2521
+ disabled={styleEditingDisabled}
2522
+ onChange={async (next) => {
2523
+ for (const [property, value] of buildStrokeStyleUpdates(
2524
+ next,
2525
+ formatPxMetricValue(borderWidthValue),
2526
+ )) {
2527
+ await onSetStyle(property, value);
2528
+ }
2529
+ }}
2530
+ options={[
2531
+ "none",
2532
+ "solid",
2533
+ "dashed",
2534
+ "dotted",
2535
+ "double",
2536
+ "hidden",
2537
+ "groove",
2538
+ "ridge",
2539
+ "inset",
2540
+ "outset",
2541
+ ]}
2542
+ />
2543
+ </div>
1887
2544
  <ColorField
1888
- label="Fill color"
1889
- value={styles["background-color"] ?? "transparent"}
2545
+ label="Stroke color"
2546
+ value={borderColorValue}
1890
2547
  disabled={styleEditingDisabled}
1891
- onCommit={(next) => onSetStyle("background-color", next)}
2548
+ onCommit={(next) => onSetStyle("border-color", next)}
1892
2549
  />
1893
- )}
1894
- {canShowTextColor && (
1895
- <ColorField
1896
- label="Text color"
1897
- value={styles.color ?? "rgb(0, 0, 0)"}
2550
+ </div>
2551
+ </Section>
2552
+
2553
+ <Section title="Effects" icon={<Zap size={15} />}>
2554
+ <div className="space-y-4">
2555
+ <SelectField
2556
+ label="Shadow"
2557
+ value={boxShadowPreset}
1898
2558
  disabled={styleEditingDisabled}
1899
- onCommit={(next) => onSetStyle("color", next)}
2559
+ onChange={(next) => {
2560
+ if (next === "custom") return;
2561
+ onSetStyle(
2562
+ "box-shadow",
2563
+ buildBoxShadowPresetValue(next as BoxShadowPreset, styles["box-shadow"]),
2564
+ );
2565
+ }}
2566
+ options={["custom", "none", "soft", "lift", "glow"]}
1900
2567
  />
1901
- )}
1902
- </div>
1903
- </Section>
1904
- )}
2568
+ <div className={RESPONSIVE_GRID}>
2569
+ <div className="grid min-w-0 gap-1.5">
2570
+ <span className={LABEL}>Layer blur</span>
2571
+ <SliderControl
2572
+ value={filterBlurValue}
2573
+ min={0}
2574
+ max={Math.max(40, Math.ceil(filterBlurValue))}
2575
+ step={1}
2576
+ disabled={styleEditingDisabled}
2577
+ displayValue={`${formatNumericValue(filterBlurValue)}px`}
2578
+ formatDisplayValue={(next) => `${formatNumericValue(next)}px`}
2579
+ onCommit={(next) =>
2580
+ onSetStyle("filter", setCssFilterFunctionPx(styles.filter, "blur", next))
2581
+ }
2582
+ />
2583
+ </div>
2584
+ <div className="grid min-w-0 gap-1.5">
2585
+ <span className={LABEL}>Backdrop</span>
2586
+ <SliderControl
2587
+ value={backdropBlurValue}
2588
+ min={0}
2589
+ max={Math.max(60, Math.ceil(backdropBlurValue))}
2590
+ step={1}
2591
+ disabled={styleEditingDisabled}
2592
+ displayValue={`${formatNumericValue(backdropBlurValue)}px`}
2593
+ formatDisplayValue={(next) => `${formatNumericValue(next)}px`}
2594
+ onCommit={(next) =>
2595
+ onSetStyle(
2596
+ "backdrop-filter",
2597
+ setCssFilterFunctionPx(styles["backdrop-filter"], "blur", next),
2598
+ )
2599
+ }
2600
+ />
2601
+ </div>
2602
+ </div>
2603
+ </div>
2604
+ </Section>
2605
+
2606
+ <Section title="Clip" icon={<Layers size={15} />}>
2607
+ <div className="space-y-4">
2608
+ <div className={RESPONSIVE_GRID}>
2609
+ <SelectField
2610
+ label="Overflow"
2611
+ value={styles.overflow || "visible"}
2612
+ disabled={styleEditingDisabled}
2613
+ onChange={(next) => onSetStyle("overflow", next)}
2614
+ options={["visible", "hidden", "clip", "auto", "scroll"]}
2615
+ />
2616
+ <SelectField
2617
+ label="Mask"
2618
+ value={clipPathPreset}
2619
+ disabled={styleEditingDisabled}
2620
+ onChange={(next) => {
2621
+ if (next === "custom") return;
2622
+ onSetStyle(
2623
+ "clip-path",
2624
+ buildClipPathValue(
2625
+ next as "none" | "inset" | "circle",
2626
+ radiusValue,
2627
+ clipPathValue,
2628
+ ),
2629
+ );
2630
+ }}
2631
+ options={["custom", "none", "inset", "circle"]}
2632
+ />
2633
+ </div>
2634
+ <div className="grid min-w-0 gap-1.5">
2635
+ <span className={LABEL}>Mask inset</span>
2636
+ <SliderControl
2637
+ value={clipInsetValue}
2638
+ min={0}
2639
+ max={Math.max(120, Math.ceil(clipInsetValue))}
2640
+ step={1}
2641
+ disabled={styleEditingDisabled}
2642
+ displayValue={`${formatNumericValue(clipInsetValue)}px`}
2643
+ formatDisplayValue={(next) => `${formatNumericValue(next)}px`}
2644
+ onCommit={(next) =>
2645
+ onSetStyle("clip-path", buildInsetClipPathValue(next, radiusValue))
2646
+ }
2647
+ />
2648
+ </div>
2649
+ </div>
2650
+ </Section>
2651
+
2652
+ <Section title="Blending" icon={<Eye size={15} />}>
2653
+ <div className="space-y-4">
2654
+ <SliderControl
2655
+ value={opacityValue}
2656
+ min={0}
2657
+ max={100}
2658
+ step={1}
2659
+ disabled={styleEditingDisabled}
2660
+ displayValue={`${opacityValue}%`}
2661
+ formatDisplayValue={(next) => `${Math.round(next)}%`}
2662
+ onCommit={(next) => onSetStyle("opacity", formatNumericValue(next / 100))}
2663
+ />
2664
+ <SelectField
2665
+ label="Mode"
2666
+ value={styles["mix-blend-mode"] || "normal"}
2667
+ disabled={styleEditingDisabled}
2668
+ onChange={(next) => onSetStyle("mix-blend-mode", next)}
2669
+ options={["normal", "multiply", "screen", "overlay", "darken", "lighten"]}
2670
+ />
2671
+ </div>
2672
+ </Section>
2673
+
2674
+ <Section
2675
+ title="Fill"
2676
+ icon={<Palette size={15} />}
2677
+ accessory={
2678
+ <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">
2679
+ {preferredFillMode}
2680
+ </div>
2681
+ }
2682
+ >
2683
+ <div className="space-y-4">
2684
+ <SegmentedControl
2685
+ disabled={styleEditingDisabled}
2686
+ value={preferredFillMode}
2687
+ onChange={handleFillModeChange}
2688
+ options={[
2689
+ { label: "Solid", value: "Solid" },
2690
+ { label: "Gradient", value: "Gradient" },
2691
+ { label: "Image", value: "Image" },
2692
+ ]}
2693
+ />
2694
+ {preferredFillMode === "Solid" ? (
2695
+ <ColorField
2696
+ label="Fill color"
2697
+ value={styles["background-color"] ?? "transparent"}
2698
+ disabled={styleEditingDisabled}
2699
+ onCommit={(next) => onSetStyle("background-color", next)}
2700
+ />
2701
+ ) : preferredFillMode === "Gradient" ? (
2702
+ <GradientField
2703
+ value={
2704
+ backgroundImage !== "none"
2705
+ ? backgroundImage
2706
+ : serializeGradient(buildDefaultGradientModel(styles["background-color"]))
2707
+ }
2708
+ fallbackColor={styles["background-color"]}
2709
+ disabled={styleEditingDisabled}
2710
+ onCommit={(next) => onSetStyle("background-image", next)}
2711
+ />
2712
+ ) : (
2713
+ <ImageFillField
2714
+ projectId={projectId}
2715
+ sourceFile={element.sourceFile}
2716
+ value={imageUrl}
2717
+ assets={assets}
2718
+ disabled={styleEditingDisabled}
2719
+ onCommit={(next) => onSetStyle("background-image", next)}
2720
+ onImportAssets={onImportAssets}
2721
+ />
2722
+ )}
2723
+ {!hasTextControls && (
2724
+ <ColorField
2725
+ label="Text color"
2726
+ value={styles.color ?? "rgb(0, 0, 0)"}
2727
+ disabled={styleEditingDisabled}
2728
+ onCommit={(next) => onSetStyle("color", next)}
2729
+ />
2730
+ )}
2731
+ </div>
2732
+ </Section>
2733
+
2734
+ {hasTextControls && (
2735
+ <Section title="Text" icon={<Type size={15} />}>
2736
+ {(() => {
2737
+ const textFields = element.textFields;
2738
+ const activeField =
2739
+ textFields.find((field) => field.key === activeTextFieldKey) ?? textFields[0];
2740
+ if (!activeField) return null;
2741
+
2742
+ if (textFields.length === 1) {
2743
+ return (
2744
+ <div className="space-y-4 rounded-xl border border-neutral-800 bg-neutral-900/60 p-3">
2745
+ <div className="min-w-0">
2746
+ <div className="truncate text-[11px] font-medium text-neutral-100">
2747
+ {formatTextFieldPreview(activeField.value) || "Text"}
2748
+ </div>
2749
+ <div className="text-[10px] uppercase tracking-[0.12em] text-neutral-500">
2750
+ {activeField.tagName}
2751
+ </div>
2752
+ </div>
2753
+
2754
+ <TextAreaField
2755
+ key={activeField.key}
2756
+ label="Content"
2757
+ value={activeField.value}
2758
+ disabled={false}
2759
+ onCommit={(next) => onSetText(next, activeField.key)}
2760
+ />
2761
+
2762
+ <ColorField
2763
+ label="Text color"
2764
+ value={getTextFieldColor(activeField, styles)}
2765
+ disabled={false}
2766
+ onCommit={(next) => onSetTextFieldStyle(activeField.key, "color", next)}
2767
+ />
2768
+
2769
+ <div className={RESPONSIVE_GRID}>
2770
+ <MetricField
2771
+ label="Size"
2772
+ value={
2773
+ activeField.computedStyles["font-size"] ||
2774
+ styles["font-size"] ||
2775
+ "16px"
2776
+ }
2777
+ disabled={false}
2778
+ liveCommit
2779
+ onCommit={(next) =>
2780
+ onSetTextFieldStyle(activeField.key, "font-size", next)
2781
+ }
2782
+ />
2783
+ <FontWeightField
2784
+ value={
2785
+ activeField.computedStyles["font-weight"] ||
2786
+ styles["font-weight"] ||
2787
+ "400"
2788
+ }
2789
+ disabled={false}
2790
+ onCommit={(next) =>
2791
+ onSetTextFieldStyle(activeField.key, "font-weight", next)
2792
+ }
2793
+ />
2794
+ </div>
2795
+
2796
+ <FontFamilyField
2797
+ value={
2798
+ activeField.computedStyles["font-family"] ||
2799
+ styles["font-family"] ||
2800
+ "inherit"
2801
+ }
2802
+ disabled={false}
2803
+ importedFonts={fontAssets}
2804
+ onImportFonts={onImportFonts}
2805
+ onCommit={(next) =>
2806
+ onSetTextFieldStyle(activeField.key, "font-family", next)
2807
+ }
2808
+ />
2809
+
2810
+ <AdvancedTextControls
2811
+ field={activeField}
2812
+ inheritedStyles={styles}
2813
+ disabled={false}
2814
+ onCommit={(property, value) =>
2815
+ onSetTextFieldStyle(activeField.key, property, value)
2816
+ }
2817
+ />
2818
+ </div>
2819
+ );
2820
+ }
1905
2821
 
1906
- {visibleSections.includes("Radius") && (
1907
- <Section title="Radius" icon={<Settings size={15} />}>
1908
- <SliderControl
1909
- value={radiusValue}
1910
- min={0}
1911
- max={Math.max(240, Math.ceil(radiusValue))}
1912
- step={1}
1913
- disabled={styleEditingDisabled}
1914
- displayValue={`${formatNumericValue(radiusValue)}px`}
1915
- formatDisplayValue={(next) => `${formatNumericValue(next)}px`}
1916
- onCommit={(next) => onSetStyle("border-radius", `${formatNumericValue(next)}px`)}
1917
- />
1918
- </Section>
1919
- )}
2822
+ return (
2823
+ <div className="space-y-4">
2824
+ <div className="grid gap-1.5">
2825
+ <div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
2826
+ <span className={LABEL}>Text layers</span>
2827
+ <button
2828
+ type="button"
2829
+ onClick={() => {
2830
+ void Promise.resolve(onAddTextField(activeField.key)).then(
2831
+ (nextKey) => {
2832
+ if (nextKey) setActiveTextFieldKey(nextKey);
2833
+ },
2834
+ );
2835
+ }}
2836
+ 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"
2837
+ >
2838
+ <Plus size={12} className="flex-shrink-0" />
2839
+ <span className="truncate">Add text</span>
2840
+ </button>
2841
+ </div>
2842
+ <div className="grid gap-2">
2843
+ {textFields.map((field, index) => {
2844
+ const active = field.key === activeField.key;
2845
+ return (
2846
+ <button
2847
+ key={field.key}
2848
+ type="button"
2849
+ onClick={() => setActiveTextFieldKey(field.key)}
2850
+ className={`min-w-0 w-full rounded-xl border px-3 py-2 text-left transition-colors ${
2851
+ active
2852
+ ? "border-studio-accent/50 bg-studio-accent/10"
2853
+ : "border-neutral-800 bg-neutral-900/80 hover:border-neutral-700 hover:bg-neutral-900"
2854
+ }`}
2855
+ >
2856
+ <div className="flex min-w-0 items-center justify-between gap-2">
2857
+ <div className="flex min-w-0 items-center gap-2">
2858
+ <span
2859
+ className="h-4 w-4 flex-shrink-0 rounded border border-neutral-700 bg-neutral-950"
2860
+ style={{ backgroundColor: getTextFieldColor(field, styles) }}
2861
+ />
2862
+ <span className="min-w-0 truncate text-[11px] font-medium text-neutral-100">
2863
+ {formatTextFieldPreview(field.value) || `Text ${index + 1}`}
2864
+ </span>
2865
+ </div>
2866
+ <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">
2867
+ {field.tagName}
2868
+ </span>
2869
+ </div>
2870
+ </button>
2871
+ );
2872
+ })}
2873
+ </div>
2874
+ </div>
2875
+
2876
+ <div className="space-y-4 rounded-xl border border-neutral-800 bg-neutral-900/60 p-3">
2877
+ <div className="flex min-w-0 items-center justify-between gap-2">
2878
+ <div className="min-w-0">
2879
+ <div className="truncate text-[11px] font-medium text-neutral-100">
2880
+ {formatTextFieldPreview(activeField.value) || "Text"}
2881
+ </div>
2882
+ <div className="text-[10px] uppercase tracking-[0.12em] text-neutral-500">
2883
+ {activeField.tagName}
2884
+ </div>
2885
+ </div>
2886
+ <button
2887
+ type="button"
2888
+ onClick={() => onRemoveTextField(activeField.key)}
2889
+ 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"
2890
+ >
2891
+ Remove
2892
+ </button>
2893
+ </div>
2894
+
2895
+ <TextAreaField
2896
+ key={activeField.key}
2897
+ label="Content"
2898
+ value={activeField.value}
2899
+ disabled={false}
2900
+ autoFocus
2901
+ onCommit={(next) => onSetText(next, activeField.key)}
2902
+ />
2903
+
2904
+ <ColorField
2905
+ label="Text color"
2906
+ value={getTextFieldColor(activeField, styles)}
2907
+ disabled={false}
2908
+ onCommit={(next) => onSetTextFieldStyle(activeField.key, "color", next)}
2909
+ />
2910
+
2911
+ <div className={RESPONSIVE_GRID}>
2912
+ <MetricField
2913
+ label="Size"
2914
+ value={activeField.computedStyles["font-size"] || "16px"}
2915
+ disabled={false}
2916
+ liveCommit
2917
+ onCommit={(next) =>
2918
+ onSetTextFieldStyle(activeField.key, "font-size", next)
2919
+ }
2920
+ />
2921
+ <FontWeightField
2922
+ value={activeField.computedStyles["font-weight"] || "400"}
2923
+ disabled={false}
2924
+ onCommit={(next) =>
2925
+ onSetTextFieldStyle(activeField.key, "font-weight", next)
2926
+ }
2927
+ />
2928
+ </div>
2929
+
2930
+ <FontFamilyField
2931
+ value={
2932
+ activeField.computedStyles["font-family"] ||
2933
+ styles["font-family"] ||
2934
+ "inherit"
2935
+ }
2936
+ disabled={false}
2937
+ importedFonts={fontAssets}
2938
+ onImportFonts={onImportFonts}
2939
+ onCommit={(next) =>
2940
+ onSetTextFieldStyle(activeField.key, "font-family", next)
2941
+ }
2942
+ />
2943
+
2944
+ <AdvancedTextControls
2945
+ field={activeField}
2946
+ inheritedStyles={styles}
2947
+ disabled={false}
2948
+ onCommit={(property, value) =>
2949
+ onSetTextFieldStyle(activeField.key, property, value)
2950
+ }
2951
+ />
2952
+ </div>
2953
+ </div>
2954
+ );
2955
+ })()}
2956
+ </Section>
2957
+ )}
1920
2958
 
1921
- {visibleSections.includes("Shadow") && (
1922
- <Section title="Shadow" icon={<Eye size={15} />}>
1923
- <SelectField
1924
- label="Box shadow"
1925
- value={boxShadowPreset}
1926
- disabled={styleEditingDisabled}
1927
- onChange={(next) => {
1928
- if (next === "custom") return;
1929
- onSetStyle(
1930
- "box-shadow",
1931
- buildBoxShadowPresetValue(next as BoxShadowPreset, styles["box-shadow"]),
1932
- );
1933
- }}
1934
- options={["custom", "none", "soft", "lift", "glow"]}
1935
- />
1936
- </Section>
2959
+ {selectionColors.length > 0 && (
2960
+ <Section title="Selection colors" icon={<Palette size={15} />}>
2961
+ <div className="space-y-3">
2962
+ {selectionColors.map((entry) => (
2963
+ <SelectionColorRow
2964
+ key={`${entry.swatch}-${entry.token}`}
2965
+ swatch={entry.swatch}
2966
+ token={entry.token}
2967
+ sources={entry.sources}
2968
+ />
2969
+ ))}
2970
+ </div>
2971
+ </Section>
2972
+ )}
2973
+ </>
1937
2974
  )}
1938
2975
  </div>
1939
2976
  </div>