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

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