@hyperframes/studio 0.6.0-alpha.9 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/dist/assets/hyperframes-player-CzwFysqv.js +418 -0
  2. package/dist/assets/index-D1JDq7Gg.css +1 -0
  3. package/dist/assets/index-hYc4aP7M.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 +421 -4303
  8. package/src/captions/components/CaptionOverlay.tsx +13 -246
  9. package/src/captions/components/CaptionOverlayUtils.ts +221 -0
  10. package/src/components/AskAgentModal.tsx +120 -0
  11. package/src/components/StudioHeader.tsx +133 -0
  12. package/src/components/StudioLeftSidebar.tsx +125 -0
  13. package/src/components/StudioPreviewArea.tsx +167 -0
  14. package/src/components/StudioRightPanel.tsx +198 -0
  15. package/src/components/TimelineToolbar.tsx +89 -0
  16. package/src/components/editor/DomEditOverlay.tsx +88 -993
  17. package/src/components/editor/EaseCurveEditor.tsx +221 -0
  18. package/src/components/editor/FileTree.tsx +13 -621
  19. package/src/components/editor/FileTreeIcons.tsx +128 -0
  20. package/src/components/editor/FileTreeNodes.tsx +496 -0
  21. package/src/components/editor/MotionPanel.tsx +16 -390
  22. package/src/components/editor/MotionPanelFields.tsx +185 -0
  23. package/src/components/editor/PropertyPanel.test.ts +0 -49
  24. package/src/components/editor/PropertyPanel.tsx +132 -2763
  25. package/src/components/editor/domEditOverlayGeometry.ts +211 -0
  26. package/src/components/editor/domEditOverlayGestures.ts +138 -0
  27. package/src/components/editor/domEditOverlayStartGesture.ts +155 -0
  28. package/src/components/editor/domEditing.ts +44 -1117
  29. package/src/components/editor/domEditingAgentPrompt.ts +97 -0
  30. package/src/components/editor/domEditingDom.ts +266 -0
  31. package/src/components/editor/domEditingElement.ts +329 -0
  32. package/src/components/editor/domEditingLayers.ts +460 -0
  33. package/src/components/editor/domEditingTypes.ts +125 -0
  34. package/src/components/editor/manualEditingAvailability.test.ts +2 -2
  35. package/src/components/editor/manualEditingAvailability.ts +1 -1
  36. package/src/components/editor/manualEdits.ts +84 -1049
  37. package/src/components/editor/manualEditsDom.ts +436 -0
  38. package/src/components/editor/manualEditsParsing.ts +280 -0
  39. package/src/components/editor/manualEditsSnapshot.ts +333 -0
  40. package/src/components/editor/manualEditsTypes.ts +141 -0
  41. package/src/components/editor/propertyPanelColor.tsx +371 -0
  42. package/src/components/editor/propertyPanelFill.tsx +421 -0
  43. package/src/components/editor/propertyPanelFont.tsx +455 -0
  44. package/src/components/editor/propertyPanelHelpers.ts +401 -0
  45. package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
  46. package/src/components/editor/propertyPanelSections.tsx +453 -0
  47. package/src/components/editor/propertyPanelStyleSections.tsx +411 -0
  48. package/src/components/editor/studioMotion.ts +47 -434
  49. package/src/components/editor/studioMotionOps.ts +299 -0
  50. package/src/components/editor/studioMotionTypes.ts +168 -0
  51. package/src/components/editor/useDomEditOverlayGestures.ts +393 -0
  52. package/src/components/editor/useDomEditOverlayRects.ts +207 -0
  53. package/src/components/nle/NLELayout.tsx +68 -155
  54. package/src/components/nle/NLEPreview.tsx +3 -0
  55. package/src/components/nle/useCompositionStack.ts +126 -0
  56. package/src/components/renders/RenderQueue.tsx +102 -31
  57. package/src/components/renders/useRenderQueue.ts +8 -2
  58. package/src/components/sidebar/LeftSidebar.tsx +186 -186
  59. package/src/contexts/DomEditContext.tsx +137 -0
  60. package/src/contexts/FileManagerContext.tsx +110 -0
  61. package/src/contexts/PanelLayoutContext.tsx +68 -0
  62. package/src/contexts/StudioContext.tsx +135 -0
  63. package/src/hooks/useAppHotkeys.ts +326 -0
  64. package/src/hooks/useAskAgentModal.ts +162 -0
  65. package/src/hooks/useCaptionDetection.ts +132 -0
  66. package/src/hooks/useCompositionDimensions.ts +25 -0
  67. package/src/hooks/useConsoleErrorCapture.ts +60 -0
  68. package/src/hooks/useDomEditCommits.ts +437 -0
  69. package/src/hooks/useDomEditSession.ts +342 -0
  70. package/src/hooks/useDomEditTextCommits.ts +330 -0
  71. package/src/hooks/useDomSelection.ts +398 -0
  72. package/src/hooks/useFileManager.ts +431 -0
  73. package/src/hooks/useFrameCapture.ts +77 -0
  74. package/src/hooks/useLintModal.ts +35 -0
  75. package/src/hooks/useManifestPersistence.ts +492 -0
  76. package/src/hooks/usePanelLayout.ts +68 -0
  77. package/src/hooks/usePreviewInteraction.ts +153 -0
  78. package/src/hooks/useRenderClipContent.ts +124 -0
  79. package/src/hooks/useTimelineEditing.ts +472 -0
  80. package/src/hooks/useToast.ts +20 -0
  81. package/src/player/components/Player.tsx +33 -2
  82. package/src/player/components/Timeline.test.ts +0 -8
  83. package/src/player/components/Timeline.tsx +196 -1518
  84. package/src/player/components/TimelineCanvas.tsx +434 -0
  85. package/src/player/components/TimelineClip.tsx +9 -244
  86. package/src/player/components/TimelineEmptyState.tsx +102 -0
  87. package/src/player/components/TimelineRuler.tsx +90 -0
  88. package/src/player/components/timelineIcons.tsx +49 -0
  89. package/src/player/components/timelineLayout.ts +215 -0
  90. package/src/player/components/timelineUtils.ts +211 -0
  91. package/src/player/components/useTimelineClipDrag.ts +388 -0
  92. package/src/player/components/useTimelinePlayhead.ts +200 -0
  93. package/src/player/components/useTimelineRangeSelection.ts +135 -0
  94. package/src/player/hooks/usePlaybackKeyboard.ts +171 -0
  95. package/src/player/hooks/useTimelinePlayer.ts +105 -1371
  96. package/src/player/hooks/useTimelineSyncCallbacks.ts +288 -0
  97. package/src/player/lib/playbackAdapter.ts +145 -0
  98. package/src/player/lib/playbackShortcuts.ts +68 -0
  99. package/src/player/lib/playbackTypes.ts +60 -0
  100. package/src/player/lib/timelineDOM.ts +373 -0
  101. package/src/player/lib/timelineElementHelpers.ts +303 -0
  102. package/src/player/lib/timelineIframeHelpers.ts +269 -0
  103. package/src/utils/domEditHelpers.ts +50 -0
  104. package/src/utils/studioFontHelpers.ts +83 -0
  105. package/src/utils/studioHelpers.ts +214 -0
  106. package/src/utils/studioPreviewHelpers.ts +185 -0
  107. package/src/utils/timelineDiscovery.ts +1 -1
  108. package/dist/assets/hyperframes-player-DjsVzYFP.js +0 -418
  109. package/dist/assets/index-14zH9lqh.css +0 -1
  110. package/dist/assets/index-DYCiFGWQ.js +0 -108
  111. package/src/player/components/TimelineClip.test.ts +0 -92
@@ -0,0 +1,141 @@
1
+ /* ── Public constants ──────────────────────────────────────────────── */
2
+ export const STUDIO_MANUAL_EDITS_PATH = ".hyperframes/studio-manual-edits.json";
3
+ export const STUDIO_OFFSET_X_PROP = "--hf-studio-offset-x";
4
+ export const STUDIO_OFFSET_Y_PROP = "--hf-studio-offset-y";
5
+ export const STUDIO_WIDTH_PROP = "--hf-studio-width";
6
+ export const STUDIO_HEIGHT_PROP = "--hf-studio-height";
7
+ export const STUDIO_ROTATION_PROP = "--hf-studio-rotation";
8
+
9
+ /* ── Internal DOM attribute names ─────────────────────────────────── */
10
+ export const STUDIO_PATH_OFFSET_ATTR = "data-hf-studio-path-offset";
11
+ export const STUDIO_MANUAL_EDIT_GESTURE_ATTR = "data-hf-studio-manual-edit-gesture";
12
+ export const STUDIO_BOX_SIZE_ATTR = "data-hf-studio-box-size";
13
+ export const STUDIO_ROTATION_ATTR = "data-hf-studio-rotation";
14
+ export const STUDIO_ORIGINAL_TRANSLATE_ATTR = "data-hf-studio-original-translate";
15
+ export const STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR = "data-hf-studio-original-inline-translate";
16
+ export const STUDIO_ORIGINAL_WIDTH_ATTR = "data-hf-studio-original-width";
17
+ export const STUDIO_ORIGINAL_HEIGHT_ATTR = "data-hf-studio-original-height";
18
+ export const STUDIO_ORIGINAL_MIN_WIDTH_ATTR = "data-hf-studio-original-min-width";
19
+ export const STUDIO_ORIGINAL_MIN_HEIGHT_ATTR = "data-hf-studio-original-min-height";
20
+ export const STUDIO_ORIGINAL_MAX_WIDTH_ATTR = "data-hf-studio-original-max-width";
21
+ export const STUDIO_ORIGINAL_MAX_HEIGHT_ATTR = "data-hf-studio-original-max-height";
22
+ export const STUDIO_ORIGINAL_FLEX_BASIS_ATTR = "data-hf-studio-original-flex-basis";
23
+ export const STUDIO_ORIGINAL_FLEX_GROW_ATTR = "data-hf-studio-original-flex-grow";
24
+ export const STUDIO_ORIGINAL_FLEX_SHRINK_ATTR = "data-hf-studio-original-flex-shrink";
25
+ export const STUDIO_ORIGINAL_BOX_SIZING_ATTR = "data-hf-studio-original-box-sizing";
26
+ export const STUDIO_ORIGINAL_SCALE_ATTR = "data-hf-studio-original-scale";
27
+ export const STUDIO_ORIGINAL_TRANSFORM_ORIGIN_ATTR = "data-hf-studio-original-transform-origin";
28
+ export const STUDIO_ORIGINAL_DISPLAY_ATTR = "data-hf-studio-original-display";
29
+ export const STUDIO_ORIGINAL_ROTATE_ATTR = "data-hf-studio-original-rotate";
30
+ export const STUDIO_ORIGINAL_INLINE_ROTATE_ATTR = "data-hf-studio-original-inline-rotate";
31
+ export const STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR =
32
+ "data-hf-studio-original-rotation-transform-origin";
33
+ export const STUDIO_ROTATION_DRAFT_ATTR = "data-hf-studio-rotation-draft";
34
+ export const STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR = "data-hf-studio-original-transform-display";
35
+
36
+ /* ── Internal window property names ──────────────────────────────── */
37
+ export const STUDIO_MANUAL_EDITS_APPLY_PROP = "__hfStudioManualEditsApply";
38
+ export const STUDIO_MANUAL_EDITS_WRAPPED_PROP = "__hfStudioManualEditsWrapped";
39
+ export const STUDIO_MANUAL_EDITS_PLAYBACK_FRAME_PROP = "__hfStudioManualEditsPlaybackFrame";
40
+
41
+ export const STUDIO_ROTATION_TRANSFORM_ORIGIN = "center center";
42
+
43
+ /* ── Edit types ───────────────────────────────────────────────────── */
44
+ export interface StudioManualEditTarget {
45
+ sourceFile: string;
46
+ selector?: string;
47
+ selectorIndex?: number;
48
+ id?: string;
49
+ }
50
+
51
+ export interface StudioPathOffsetEdit {
52
+ kind: "path-offset";
53
+ target: StudioManualEditTarget;
54
+ x: number;
55
+ y: number;
56
+ updatedAt?: string;
57
+ }
58
+
59
+ export interface StudioBoxSizeEdit {
60
+ kind: "box-size";
61
+ target: StudioManualEditTarget;
62
+ width: number;
63
+ height: number;
64
+ updatedAt?: string;
65
+ }
66
+
67
+ export interface StudioRotationEdit {
68
+ kind: "rotation";
69
+ target: StudioManualEditTarget;
70
+ angle: number;
71
+ updatedAt?: string;
72
+ }
73
+
74
+ export type StudioManualEdit = StudioPathOffsetEdit | StudioBoxSizeEdit | StudioRotationEdit;
75
+
76
+ export interface StudioManualEditManifest {
77
+ version: 1;
78
+ edits: StudioManualEdit[];
79
+ }
80
+
81
+ export type StudioManualEditSeekWindow = Window & {
82
+ __hf?: Record<string, unknown>;
83
+ __player?: Record<string, unknown>;
84
+ __timeline?: Record<string, unknown>;
85
+ __timelines?: Record<string, Record<string, unknown>>;
86
+ __hfStudioManualEditsApply?: () => void;
87
+ __hfStudioManualEditsPlaybackFrame?: number | null;
88
+ };
89
+
90
+ /* ── Snapshot types ───────────────────────────────────────────────── */
91
+ export interface StudioBoxSizeSnapshot {
92
+ width: string;
93
+ height: string;
94
+ minWidth: string;
95
+ minHeight: string;
96
+ maxWidth: string;
97
+ maxHeight: string;
98
+ flexBasis: string;
99
+ flexGrow: string;
100
+ flexShrink: string;
101
+ boxSizing: string;
102
+ scale: string;
103
+ transformOrigin: string;
104
+ display: string;
105
+ studioWidth: string;
106
+ studioHeight: string;
107
+ marker: string | null;
108
+ originalWidth: string | null;
109
+ originalHeight: string | null;
110
+ originalMinWidth: string | null;
111
+ originalMinHeight: string | null;
112
+ originalMaxWidth: string | null;
113
+ originalMaxHeight: string | null;
114
+ originalFlexBasis: string | null;
115
+ originalFlexGrow: string | null;
116
+ originalFlexShrink: string | null;
117
+ originalBoxSizing: string | null;
118
+ originalScale: string | null;
119
+ originalTransformOrigin: string | null;
120
+ originalDisplay: string | null;
121
+ }
122
+
123
+ export interface StudioRotationSnapshot {
124
+ rotate: string;
125
+ transformOrigin: string;
126
+ studioRotation: string;
127
+ marker: string | null;
128
+ draftMarker: string | null;
129
+ originalRotate: string | null;
130
+ originalInlineRotate: string | null;
131
+ originalTransformOrigin: string | null;
132
+ }
133
+
134
+ export interface StudioPathOffsetSnapshot {
135
+ translate: string;
136
+ x: string;
137
+ y: string;
138
+ marker: string | null;
139
+ originalTranslate: string | null;
140
+ originalInlineTranslate: string | null;
141
+ }
@@ -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
+ }