@hyperframes/studio 0.6.0-alpha.9 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/assets/{hyperframes-player-DjsVzYFP.js → hyperframes-player-DOFETgjy.js} +1 -1
  2. package/dist/assets/index-D1JDq7Gg.css +1 -0
  3. package/dist/assets/index-DUqUmaoH.js +117 -0
  4. package/dist/favicon.svg +14 -0
  5. package/dist/index.html +3 -2
  6. package/package.json +9 -9
  7. package/src/App.tsx +428 -4299
  8. package/src/components/AskAgentModal.tsx +120 -0
  9. package/src/components/StudioHeader.tsx +133 -0
  10. package/src/components/StudioLeftSidebar.tsx +125 -0
  11. package/src/components/StudioPreviewArea.tsx +163 -0
  12. package/src/components/StudioRightPanel.tsx +198 -0
  13. package/src/components/TimelineToolbar.tsx +89 -0
  14. package/src/components/editor/DomEditOverlay.tsx +15 -1
  15. package/src/components/editor/PropertyPanel.test.ts +0 -49
  16. package/src/components/editor/PropertyPanel.tsx +132 -2763
  17. package/src/components/editor/domEditing.ts +38 -5
  18. package/src/components/editor/manualEditingAvailability.test.ts +2 -2
  19. package/src/components/editor/manualEditingAvailability.ts +1 -1
  20. package/src/components/editor/manualEdits.ts +32 -0
  21. package/src/components/editor/propertyPanelColor.tsx +371 -0
  22. package/src/components/editor/propertyPanelFill.tsx +421 -0
  23. package/src/components/editor/propertyPanelFont.tsx +455 -0
  24. package/src/components/editor/propertyPanelHelpers.ts +401 -0
  25. package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
  26. package/src/components/editor/propertyPanelSections.tsx +453 -0
  27. package/src/components/editor/propertyPanelStyleSections.tsx +411 -0
  28. package/src/components/nle/NLELayout.tsx +8 -11
  29. package/src/components/nle/NLEPreview.tsx +3 -0
  30. package/src/components/renders/RenderQueue.tsx +102 -31
  31. package/src/components/renders/useRenderQueue.ts +8 -2
  32. package/src/components/sidebar/LeftSidebar.tsx +186 -186
  33. package/src/contexts/DomEditContext.tsx +137 -0
  34. package/src/contexts/FileManagerContext.tsx +110 -0
  35. package/src/contexts/PanelLayoutContext.tsx +68 -0
  36. package/src/contexts/StudioContext.tsx +135 -0
  37. package/src/hooks/useAppHotkeys.ts +326 -0
  38. package/src/hooks/useAskAgentModal.ts +162 -0
  39. package/src/hooks/useCaptionDetection.ts +132 -0
  40. package/src/hooks/useCompositionDimensions.ts +25 -0
  41. package/src/hooks/useConsoleErrorCapture.ts +60 -0
  42. package/src/hooks/useDomEditCommits.ts +437 -0
  43. package/src/hooks/useDomEditSession.ts +342 -0
  44. package/src/hooks/useDomEditTextCommits.ts +330 -0
  45. package/src/hooks/useDomSelection.ts +398 -0
  46. package/src/hooks/useFileManager.ts +431 -0
  47. package/src/hooks/useFrameCapture.ts +77 -0
  48. package/src/hooks/useLintModal.ts +35 -0
  49. package/src/hooks/useManifestPersistence.ts +492 -0
  50. package/src/hooks/usePanelLayout.ts +68 -0
  51. package/src/hooks/usePreviewInteraction.ts +153 -0
  52. package/src/hooks/useRenderClipContent.ts +124 -0
  53. package/src/hooks/useTimelineEditing.ts +472 -0
  54. package/src/player/components/Player.tsx +33 -2
  55. package/src/player/components/Timeline.test.ts +0 -8
  56. package/src/player/components/Timeline.tsx +10 -103
  57. package/src/player/components/TimelineClip.tsx +9 -244
  58. package/src/player/hooks/useTimelinePlayer.ts +140 -103
  59. package/src/utils/domEditHelpers.ts +50 -0
  60. package/src/utils/studioFontHelpers.ts +83 -0
  61. package/src/utils/studioHelpers.ts +214 -0
  62. package/src/utils/studioPreviewHelpers.ts +185 -0
  63. package/src/utils/timelineDiscovery.ts +1 -1
  64. package/dist/assets/index-14zH9lqh.css +0 -1
  65. package/dist/assets/index-DYCiFGWQ.js +0 -108
  66. package/src/player/components/TimelineClip.test.ts +0 -92
@@ -453,16 +453,49 @@ function getElementDepth(el: HTMLElement): number {
453
453
  return depth;
454
454
  }
455
455
 
456
+ const VISUAL_LEAF_TAGS = new Set(["img", "video", "canvas", "svg", "audio"]);
457
+
458
+ function isElementComputedVisible(el: HTMLElement): boolean {
459
+ const win = el.ownerDocument.defaultView;
460
+ if (!win) return true;
461
+ let current: HTMLElement | null = el;
462
+ while (current) {
463
+ const computed = win.getComputedStyle(current);
464
+ if (computed.display === "none" || computed.visibility === "hidden") return false;
465
+ const opacity = Number.parseFloat(computed.opacity);
466
+ if (Number.isFinite(opacity) && opacity <= 0.01) return false;
467
+ current = current.parentElement;
468
+ }
469
+ return true;
470
+ }
471
+
472
+ function isEmptyVisualContainer(el: HTMLElement): boolean {
473
+ const tag = el.tagName.toLowerCase();
474
+ if (VISUAL_LEAF_TAGS.has(tag)) return false;
475
+
476
+ const children = el.children;
477
+ if (children.length === 0) {
478
+ const text = (el.textContent ?? "").trim();
479
+ return text.length === 0;
480
+ }
481
+
482
+ for (let i = 0; i < children.length; i += 1) {
483
+ const child = children[i];
484
+ if (!isHtmlElement(child)) continue;
485
+ if (VISUAL_LEAF_TAGS.has(child.tagName.toLowerCase())) return false;
486
+ if (isElementComputedVisible(child)) return false;
487
+ }
488
+
489
+ return true;
490
+ }
491
+
456
492
  function hasRenderedBox(el: HTMLElement): boolean {
457
493
  const rect = el.getBoundingClientRect();
458
494
  if (rect.width <= 1 || rect.height <= 1) return false;
459
495
 
460
- const computed = el.ownerDocument.defaultView?.getComputedStyle(el);
461
- if (!computed) return true;
462
- if (computed.display === "none" || computed.visibility === "hidden") return false;
496
+ if (!isElementComputedVisible(el)) return false;
463
497
 
464
- const opacity = Number.parseFloat(computed.opacity);
465
- if (Number.isFinite(opacity) && opacity <= 0.01) return false;
498
+ if (isEmptyVisualContainer(el)) return false;
466
499
 
467
500
  return true;
468
501
  }
@@ -16,10 +16,10 @@ describe("manual editing availability", () => {
16
16
  vi.resetModules();
17
17
  });
18
18
 
19
- it("enables inspector selection by default while motion and manual dragging stay opt-in", async () => {
19
+ it("enables inspector selection and manual dragging by default while motion stays opt-in", async () => {
20
20
  const availability = await loadAvailabilityWithEnv({});
21
21
 
22
- expect(availability.STUDIO_PREVIEW_MANUAL_EDITING_ENABLED).toBe(false);
22
+ expect(availability.STUDIO_PREVIEW_MANUAL_EDITING_ENABLED).toBe(true);
23
23
  expect(availability.STUDIO_PREVIEW_SELECTION_ENABLED).toBe(true);
24
24
  expect(availability.STUDIO_INSPECTOR_PANELS_ENABLED).toBe(true);
25
25
  expect(availability.STUDIO_MOTION_PANEL_ENABLED).toBe(false);
@@ -32,7 +32,7 @@ const env = import.meta.env as StudioFeatureFlagEnv;
32
32
  export const STUDIO_PREVIEW_MANUAL_EDITING_ENABLED = resolveStudioBooleanEnvFlag(
33
33
  env,
34
34
  [STUDIO_PREVIEW_MANUAL_DRAGGING_ENV, "VITE_STUDIO_PREVIEW_MANUAL_EDITING_ENABLED"],
35
- false,
35
+ true,
36
36
  );
37
37
 
38
38
  export const STUDIO_INSPECTOR_PANELS_ENABLED = resolveStudioBooleanEnvFlag(
@@ -653,10 +653,33 @@ function styleMatchesStudioRotationDraft(element: HTMLElement, value: string): b
653
653
  );
654
654
  }
655
655
 
656
+ const STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR = "data-hf-studio-original-transform-display";
657
+
658
+ function promoteInlineForTransform(element: HTMLElement): void {
659
+ const computedDisplay = safeComputedStyleProperty(element, "display");
660
+ if (computedDisplay !== "inline") return;
661
+ if (!element.hasAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR)) {
662
+ element.setAttribute(
663
+ STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR,
664
+ element.style.getPropertyValue("display"),
665
+ );
666
+ }
667
+ element.style.setProperty("display", "inline-block");
668
+ }
669
+
670
+ function restoreInlineDisplay(element: HTMLElement): void {
671
+ const original = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR);
672
+ if (original == null) return;
673
+ if (original === "") element.style.removeProperty("display");
674
+ else element.style.setProperty("display", original);
675
+ element.removeAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR);
676
+ }
677
+
656
678
  export function applyStudioPathOffset(
657
679
  element: HTMLElement,
658
680
  offset: { x: number; y: number },
659
681
  ): void {
682
+ promoteInlineForTransform(element);
660
683
  writeStudioPathOffsetVars(element, offset);
661
684
  element.style.setProperty(
662
685
  "translate",
@@ -672,6 +695,7 @@ export function applyStudioPathOffsetDraft(
672
695
  element: HTMLElement,
673
696
  offset: { x: number; y: number },
674
697
  ): void {
698
+ promoteInlineForTransform(element);
675
699
  writeStudioPathOffsetVars(element, offset, { updateBase: false });
676
700
  element.style.setProperty(
677
701
  "translate",
@@ -683,6 +707,7 @@ export function applyStudioBoxSize(
683
707
  element: HTMLElement,
684
708
  size: { width: number; height: number },
685
709
  ): void {
710
+ promoteInlineForTransform(element);
686
711
  applyStudioBoxSizeDimensions(element, size);
687
712
  }
688
713
 
@@ -690,10 +715,12 @@ export function applyStudioBoxSizeDraft(
690
715
  element: HTMLElement,
691
716
  size: { width: number; height: number },
692
717
  ): void {
718
+ promoteInlineForTransform(element);
693
719
  applyStudioBoxSizeDimensions(element, size);
694
720
  }
695
721
 
696
722
  export function applyStudioRotation(element: HTMLElement, rotation: { angle: number }): void {
723
+ promoteInlineForTransform(element);
697
724
  writeStudioRotationVars(element, rotation);
698
725
  element.removeAttribute(STUDIO_ROTATION_DRAFT_ATTR);
699
726
  element.style.setProperty(
@@ -703,6 +730,7 @@ export function applyStudioRotation(element: HTMLElement, rotation: { angle: num
703
730
  }
704
731
 
705
732
  export function applyStudioRotationDraft(element: HTMLElement, rotation: { angle: number }): void {
733
+ promoteInlineForTransform(element);
706
734
  writeStudioRotationVars(element, rotation, { updateBase: false });
707
735
  element.setAttribute(STUDIO_ROTATION_DRAFT_ATTR, "true");
708
736
  element.style.setProperty(
@@ -718,6 +746,7 @@ function clearStudioPathOffset(element: HTMLElement): void {
718
746
  ) {
719
747
  restoreOriginalTranslateProperty(element);
720
748
  }
749
+ restoreInlineDisplay(element);
721
750
  element.style.removeProperty(STUDIO_OFFSET_X_PROP);
722
751
  element.style.removeProperty(STUDIO_OFFSET_Y_PROP);
723
752
  element.removeAttribute(STUDIO_PATH_OFFSET_ATTR);
@@ -732,6 +761,7 @@ function clearStudioRotation(element: HTMLElement): void {
732
761
  ) {
733
762
  restoreOriginalRotationProperty(element);
734
763
  }
764
+ restoreInlineDisplay(element);
735
765
 
736
766
  element.style.removeProperty(STUDIO_ROTATION_PROP);
737
767
  element.removeAttribute(STUDIO_ROTATION_ATTR);
@@ -816,6 +846,7 @@ function clearStudioBoxSize(element: HTMLElement): void {
816
846
  restoreOriginalBoxSizeProperty(element, "display", STUDIO_ORIGINAL_DISPLAY_ATTR);
817
847
  }
818
848
 
849
+ restoreInlineDisplay(element);
819
850
  element.style.removeProperty(STUDIO_WIDTH_PROP);
820
851
  element.style.removeProperty(STUDIO_HEIGHT_PROP);
821
852
  element.removeAttribute(STUDIO_BOX_SIZE_ATTR);
@@ -1346,6 +1377,7 @@ function collectStudioManualEditElements(doc: Document): HTMLElement[] {
1346
1377
  element.hasAttribute(STUDIO_ROTATION_DRAFT_ATTR) ||
1347
1378
  element.hasAttribute(STUDIO_ORIGINAL_TRANSLATE_ATTR) ||
1348
1379
  element.hasAttribute(STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR) ||
1380
+ element.hasAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR) ||
1349
1381
  element.hasAttribute(STUDIO_ORIGINAL_MIN_WIDTH_ATTR) ||
1350
1382
  element.hasAttribute(STUDIO_ORIGINAL_FLEX_BASIS_ATTR) ||
1351
1383
  element.hasAttribute(STUDIO_ORIGINAL_SCALE_ATTR) ||
@@ -0,0 +1,371 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { X } from "../../icons/SystemIcons";
4
+ import {
5
+ formatCssColor,
6
+ hsvToRgb,
7
+ parseCssColor,
8
+ rgbToHsv,
9
+ toHexColor,
10
+ type ParsedColor,
11
+ } from "./colorValue";
12
+ import { resolveFloatingPanelPosition, type FloatingPosition } from "./floatingPanel";
13
+ import { colorFromCss, FIELD, LABEL } from "./propertyPanelHelpers";
14
+
15
+ const COLOR_PICKER_SIZE = { width: 292, height: 386 };
16
+
17
+ /* ------------------------------------------------------------------ */
18
+ /* ColorSlider */
19
+ /* ------------------------------------------------------------------ */
20
+
21
+ export function ColorSlider({
22
+ label,
23
+ value,
24
+ min,
25
+ max,
26
+ step,
27
+ displayValue,
28
+ background,
29
+ thumbColor,
30
+ disabled,
31
+ onCommit,
32
+ }: {
33
+ label: string;
34
+ value: number;
35
+ min: number;
36
+ max: number;
37
+ step: number;
38
+ displayValue: string;
39
+ background: string;
40
+ thumbColor: string;
41
+ disabled?: boolean;
42
+ onCommit: (nextValue: number) => void;
43
+ }) {
44
+ const trackRef = useRef<HTMLDivElement | null>(null);
45
+ const percent = ((value - min) / (max - min)) * 100;
46
+
47
+ const commitFromClientX = (clientX: number) => {
48
+ const rect = trackRef.current?.getBoundingClientRect();
49
+ if (!rect || rect.width <= 0) return;
50
+ const rawValue = min + ((clientX - rect.left) / rect.width) * (max - min);
51
+ const stepped = Math.round(rawValue / step) * step;
52
+ onCommit(Math.max(min, Math.min(max, stepped)));
53
+ };
54
+
55
+ const commitKeyboardValue = (nextValue: number) => {
56
+ onCommit(Math.max(min, Math.min(max, nextValue)));
57
+ };
58
+
59
+ return (
60
+ <div className="grid gap-1.5">
61
+ <div className="flex items-center justify-between">
62
+ <span className={LABEL}>{label}</span>
63
+ <span className="text-[10px] font-medium text-neutral-400">{displayValue}</span>
64
+ </div>
65
+ <div
66
+ ref={trackRef}
67
+ role="slider"
68
+ tabIndex={disabled ? -1 : 0}
69
+ aria-label={label}
70
+ aria-valuemin={min}
71
+ aria-valuemax={max}
72
+ aria-valuenow={value}
73
+ aria-disabled={disabled}
74
+ className={`relative h-4 rounded-full border border-neutral-700 shadow-[inset_0_1px_2px_rgba(0,0,0,0.55)] outline-none focus:border-[#f5a400] focus:ring-2 focus:ring-[#f5a400]/40 ${
75
+ disabled ? "cursor-not-allowed opacity-50" : "cursor-ew-resize"
76
+ }`}
77
+ style={{ background }}
78
+ onPointerDown={(event) => {
79
+ if (disabled) return;
80
+ event.currentTarget.setPointerCapture(event.pointerId);
81
+ commitFromClientX(event.clientX);
82
+ }}
83
+ onPointerMove={(event) => {
84
+ if (disabled || event.buttons !== 1) return;
85
+ commitFromClientX(event.clientX);
86
+ }}
87
+ onKeyDown={(event) => {
88
+ if (disabled) return;
89
+ if (event.key === "ArrowRight" || event.key === "ArrowUp") {
90
+ event.preventDefault();
91
+ commitKeyboardValue(value + step);
92
+ } else if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
93
+ event.preventDefault();
94
+ commitKeyboardValue(value - step);
95
+ } else if (event.key === "Home") {
96
+ event.preventDefault();
97
+ commitKeyboardValue(min);
98
+ } else if (event.key === "End") {
99
+ event.preventDefault();
100
+ commitKeyboardValue(max);
101
+ }
102
+ }}
103
+ >
104
+ <div
105
+ className="pointer-events-none absolute top-1/2 h-6 w-6 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.85),0_6px_14px_rgba(0,0,0,0.5)]"
106
+ style={{ left: `${Math.max(0, Math.min(100, percent))}%`, backgroundColor: thumbColor }}
107
+ />
108
+ </div>
109
+ </div>
110
+ );
111
+ }
112
+
113
+ /* ------------------------------------------------------------------ */
114
+ /* ColorField */
115
+ /* ------------------------------------------------------------------ */
116
+
117
+ export function ColorField({
118
+ label,
119
+ value,
120
+ disabled,
121
+ onCommit,
122
+ }: {
123
+ label: string;
124
+ value: string;
125
+ disabled?: boolean;
126
+ onCommit: (nextValue: string) => void;
127
+ }) {
128
+ const buttonRef = useRef<HTMLButtonElement | null>(null);
129
+ const panelRef = useRef<HTMLDivElement | null>(null);
130
+ const [open, setOpen] = useState(false);
131
+ const [panelPosition, setPanelPosition] = useState<FloatingPosition | null>(null);
132
+ const [draftColor, setDraftColor] = useState<ParsedColor>(() => colorFromCss(value));
133
+ const [hexDraft, setHexDraft] = useState(() => toHexColor(colorFromCss(value)).toUpperCase());
134
+ const hsv = rgbToHsv(draftColor);
135
+ const hueColor = formatCssColor({
136
+ ...hsvToRgb({ hue: hsv.hue, saturation: 1, value: 1 }),
137
+ alpha: 1,
138
+ });
139
+ const opaqueColor = formatCssColor({ ...draftColor, alpha: 1 });
140
+ const currentColor = formatCssColor(draftColor);
141
+ const saturationPercent = Math.round(hsv.saturation * 100);
142
+ const brightnessPercent = Math.round(hsv.value * 100);
143
+ const alphaPercent = Math.round(draftColor.alpha * 100);
144
+
145
+ useEffect(() => {
146
+ const nextColor = colorFromCss(value);
147
+ setDraftColor(nextColor);
148
+ setHexDraft(toHexColor(nextColor).toUpperCase());
149
+ }, [value]);
150
+
151
+ const updatePanelPosition = useCallback(() => {
152
+ const anchor = buttonRef.current?.getBoundingClientRect();
153
+ if (!anchor) return;
154
+ const measured = panelRef.current?.getBoundingClientRect();
155
+ setPanelPosition(
156
+ resolveFloatingPanelPosition(
157
+ anchor,
158
+ { width: window.innerWidth, height: window.innerHeight },
159
+ {
160
+ width: measured?.width || COLOR_PICKER_SIZE.width,
161
+ height: measured?.height || COLOR_PICKER_SIZE.height,
162
+ },
163
+ ),
164
+ );
165
+ }, []);
166
+
167
+ useLayoutEffect(() => {
168
+ if (!open) return;
169
+ updatePanelPosition();
170
+ const handlePositionInvalidated = () => updatePanelPosition();
171
+ window.addEventListener("resize", handlePositionInvalidated);
172
+ window.addEventListener("scroll", handlePositionInvalidated, true);
173
+ return () => {
174
+ window.removeEventListener("resize", handlePositionInvalidated);
175
+ window.removeEventListener("scroll", handlePositionInvalidated, true);
176
+ };
177
+ }, [open, updatePanelPosition]);
178
+
179
+ useEffect(() => {
180
+ if (!open) return;
181
+ const handlePointerDown = (event: PointerEvent) => {
182
+ const target = event.target as Node | null;
183
+ if (!target) return;
184
+ if (panelRef.current?.contains(target) || buttonRef.current?.contains(target)) return;
185
+ setOpen(false);
186
+ };
187
+ const handleKeyDown = (event: KeyboardEvent) => {
188
+ if (event.key === "Escape") setOpen(false);
189
+ };
190
+ document.addEventListener("pointerdown", handlePointerDown);
191
+ document.addEventListener("keydown", handleKeyDown);
192
+ return () => {
193
+ document.removeEventListener("pointerdown", handlePointerDown);
194
+ document.removeEventListener("keydown", handleKeyDown);
195
+ };
196
+ }, [open]);
197
+
198
+ const commitColor = (nextColor: ParsedColor) => {
199
+ setDraftColor(nextColor);
200
+ setHexDraft(toHexColor(nextColor).toUpperCase());
201
+ onCommit(formatCssColor(nextColor));
202
+ };
203
+
204
+ const commitHsv = (nextHsv: { hue?: number; saturation?: number; value?: number }) => {
205
+ const rgb = hsvToRgb({
206
+ hue: nextHsv.hue ?? hsv.hue,
207
+ saturation: nextHsv.saturation ?? hsv.saturation,
208
+ value: nextHsv.value ?? hsv.value,
209
+ });
210
+ commitColor({ ...rgb, alpha: draftColor.alpha });
211
+ };
212
+
213
+ const updateSaturationValue = (clientX: number, clientY: number, target: HTMLDivElement) => {
214
+ const rect = target.getBoundingClientRect();
215
+ const saturation = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
216
+ const nextValue = Math.max(0, Math.min(1, 1 - (clientY - rect.top) / rect.height));
217
+ commitHsv({ saturation, value: nextValue });
218
+ };
219
+
220
+ const handleHexCommit = (nextHex: string) => {
221
+ setHexDraft(nextHex);
222
+ const normalized = nextHex.trim().startsWith("#") ? nextHex.trim() : `#${nextHex.trim()}`;
223
+ const parsed = parseCssColor(normalized);
224
+ if (!parsed) return;
225
+ commitColor({ ...parsed, alpha: draftColor.alpha });
226
+ };
227
+
228
+ const picker = open
229
+ ? createPortal(
230
+ <div
231
+ ref={panelRef}
232
+ className="fixed z-[9999] w-[292px] overflow-hidden rounded-2xl border border-neutral-700 bg-neutral-950 shadow-2xl shadow-black/50"
233
+ style={{
234
+ left: panelPosition?.left ?? -9999,
235
+ top: panelPosition?.top ?? -9999,
236
+ }}
237
+ >
238
+ <div className="flex items-center justify-between border-b border-neutral-800 px-3 py-2">
239
+ <div className="min-w-0">
240
+ <div className="truncate text-[11px] font-medium text-neutral-100">{label}</div>
241
+ <div className="text-[9px] uppercase tracking-[0.16em] text-neutral-600">Color</div>
242
+ </div>
243
+ <button
244
+ type="button"
245
+ onClick={() => setOpen(false)}
246
+ className="flex h-7 w-7 items-center justify-center rounded-lg text-neutral-500 transition-colors hover:bg-neutral-900 hover:text-neutral-200"
247
+ aria-label="Close color picker"
248
+ >
249
+ <X size={13} />
250
+ </button>
251
+ </div>
252
+ <div className="space-y-3 p-3">
253
+ <div
254
+ className="relative h-36 cursor-crosshair overflow-hidden rounded-xl border border-neutral-700 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.06)]"
255
+ style={{ backgroundColor: hueColor }}
256
+ onPointerDown={(event) => {
257
+ event.currentTarget.setPointerCapture(event.pointerId);
258
+ updateSaturationValue(event.clientX, event.clientY, event.currentTarget);
259
+ }}
260
+ onPointerMove={(event) => {
261
+ if (event.buttons !== 1) return;
262
+ updateSaturationValue(event.clientX, event.clientY, event.currentTarget);
263
+ }}
264
+ >
265
+ <div className="absolute inset-0 bg-gradient-to-r from-white to-transparent" />
266
+ <div className="absolute inset-0 bg-gradient-to-t from-black to-transparent" />
267
+ <div
268
+ className="pointer-events-none absolute top-0 h-full w-px -translate-x-1/2 bg-white/70 shadow-[0_0_0_1px_rgba(0,0,0,0.45)] mix-blend-difference"
269
+ style={{ left: `${hsv.saturation * 100}%` }}
270
+ />
271
+ <div
272
+ className="pointer-events-none absolute left-0 h-px w-full -translate-y-1/2 bg-white/70 shadow-[0_0_0_1px_rgba(0,0,0,0.45)] mix-blend-difference"
273
+ style={{ top: `${(1 - hsv.value) * 100}%` }}
274
+ />
275
+ <div
276
+ className="pointer-events-none absolute h-6 w-6 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.85),0_8px_18px_rgba(0,0,0,0.45)]"
277
+ style={{
278
+ left: `${hsv.saturation * 100}%`,
279
+ top: `${(1 - hsv.value) * 100}%`,
280
+ backgroundColor: opaqueColor,
281
+ }}
282
+ />
283
+ </div>
284
+
285
+ <div className="flex min-w-0 items-center gap-3">
286
+ <div
287
+ className="h-9 w-9 flex-shrink-0 rounded-xl border border-neutral-600 shadow-[inset_0_1px_0_rgba(255,255,255,0.08)]"
288
+ style={{ backgroundColor: currentColor }}
289
+ />
290
+ <div className="min-w-0 flex-1">
291
+ <div className="truncate text-[11px] font-medium text-neutral-100">
292
+ {currentColor}
293
+ </div>
294
+ <div className="mt-0.5 text-[9px] uppercase tracking-[0.12em] text-neutral-600">
295
+ S {saturationPercent}% · B {brightnessPercent}% · A {alphaPercent}%
296
+ </div>
297
+ </div>
298
+ </div>
299
+
300
+ <ColorSlider
301
+ label="Hue"
302
+ value={hsv.hue}
303
+ min={0}
304
+ max={360}
305
+ step={1}
306
+ displayValue={`${Math.round(hsv.hue)}°`}
307
+ background="linear-gradient(90deg, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)"
308
+ thumbColor={hueColor}
309
+ disabled={disabled}
310
+ onCommit={(nextHue) => commitHsv({ hue: nextHue })}
311
+ />
312
+
313
+ <ColorSlider
314
+ label="Alpha"
315
+ value={draftColor.alpha}
316
+ min={0}
317
+ max={1}
318
+ step={0.01}
319
+ displayValue={`${alphaPercent}%`}
320
+ background={`linear-gradient(90deg, transparent, ${opaqueColor})`}
321
+ thumbColor={currentColor}
322
+ disabled={disabled}
323
+ onCommit={(nextAlpha) => commitColor({ ...draftColor, alpha: nextAlpha })}
324
+ />
325
+
326
+ <label className="grid gap-1.5">
327
+ <span className={LABEL}>Hex</span>
328
+ <input
329
+ value={hexDraft}
330
+ onChange={(event) => handleHexCommit(event.target.value)}
331
+ className={`${FIELD} h-10 w-full text-[11px] font-medium outline-none`}
332
+ spellCheck={false}
333
+ />
334
+ </label>
335
+ </div>
336
+ </div>,
337
+ document.body,
338
+ )
339
+ : null;
340
+
341
+ const openPicker = () => {
342
+ if (disabled) return;
343
+ setOpen((current) => !current);
344
+ if (!open) {
345
+ requestAnimationFrame(updatePanelPosition);
346
+ }
347
+ };
348
+
349
+ return (
350
+ <div className="grid min-w-0 gap-1.5">
351
+ <span className={LABEL}>{label}</span>
352
+ <button
353
+ type="button"
354
+ disabled={disabled}
355
+ aria-label={`Pick ${label.toLowerCase()} color`}
356
+ ref={buttonRef}
357
+ onClick={openPicker}
358
+ className={`${FIELD} flex items-center gap-3 text-left hover:border-neutral-700 disabled:cursor-not-allowed ${open ? "border-neutral-600" : ""}`}
359
+ >
360
+ <div
361
+ className="relative h-7 w-7 flex-shrink-0 overflow-hidden rounded-lg border border-neutral-700 bg-neutral-950 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]"
362
+ style={{ backgroundColor: value || "transparent" }}
363
+ />
364
+ <span className="min-w-0 flex-1 truncate text-[11px] font-medium text-neutral-100">
365
+ {value}
366
+ </span>
367
+ </button>
368
+ {picker}
369
+ </div>
370
+ );
371
+ }