@hyperframes/studio 0.7.60 → 0.7.62

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 (110) hide show
  1. package/dist/assets/hyperframes-player-CWpmBjzv.js +459 -0
  2. package/dist/assets/{index-D6etaey-.js → index-BJzSiIBj.js} +1 -1
  3. package/dist/assets/index-Ceyz8Qt2.css +1 -0
  4. package/dist/assets/{index-Dh_WhagG.js → index-DJQPB78e.js} +1 -1
  5. package/dist/assets/index-vDRVQtTK.js +426 -0
  6. package/dist/index.d.ts +12 -1
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +4392 -3314
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +15 -15
  12. package/src/components/DesignPanelPromoteProvider.tsx +27 -1
  13. package/src/components/EditorShell.tsx +12 -0
  14. package/src/components/StudioHeader.tsx +2 -3
  15. package/src/components/StudioLeftSidebar.tsx +3 -0
  16. package/src/components/StudioRightPanel.tsx +34 -26
  17. package/src/components/editor/DomEditSelectionChrome.test.tsx +51 -0
  18. package/src/components/editor/PromotableControl.tsx +4 -2
  19. package/src/components/editor/PropertyPanel.tsx +151 -149
  20. package/src/components/editor/PropertyPanelFlat.tsx +50 -42
  21. package/src/components/editor/compositionReliabilityFixture.integration.test.ts +105 -0
  22. package/src/components/editor/domEditing.test.ts +34 -0
  23. package/src/components/editor/manualEditingAvailability.test.ts +6 -6
  24. package/src/components/editor/manualEditingAvailability.ts +5 -3
  25. package/src/components/editor/propertyPanelColor.test.tsx +31 -0
  26. package/src/components/editor/propertyPanelColor.tsx +150 -28
  27. package/src/components/editor/propertyPanelCommitField.tsx +178 -0
  28. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +44 -0
  29. package/src/components/editor/propertyPanelFlatPrimitives.tsx +4 -0
  30. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +0 -13
  31. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +26 -10
  32. package/src/components/editor/propertyPanelFlatStyleSections.tsx +21 -32
  33. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +47 -4
  34. package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -2
  35. package/src/components/editor/propertyPanelInputCoverage.test.tsx +18 -1
  36. package/src/components/editor/propertyPanelPrimitives.tsx +3 -92
  37. package/src/components/editor/propertyPanelSections.tsx +2 -2
  38. package/src/components/editor/propertyPanelTypes.ts +2 -0
  39. package/src/components/editor/useInspectorGestureTransaction.test.tsx +37 -0
  40. package/src/components/editor/useInspectorGestureTransaction.ts +60 -0
  41. package/src/components/nle/TimelinePane.tsx +6 -0
  42. package/src/components/sidebar/CompositionsTab.drag.test.tsx +87 -0
  43. package/src/components/sidebar/CompositionsTab.tsx +45 -2
  44. package/src/components/sidebar/LeftSidebar.tsx +3 -0
  45. package/src/components/storyboard/AgentChatMessageButton.test.tsx +47 -0
  46. package/src/components/storyboard/AgentChatMessageButton.tsx +45 -0
  47. package/src/components/storyboard/StoryboardFrameFocus.tsx +189 -66
  48. package/src/components/storyboard/StoryboardLoaded.tsx +121 -23
  49. package/src/components/storyboard/StoryboardReviewGuide.tsx +300 -0
  50. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +170 -0
  51. package/src/components/storyboard/storyboardReviewStage.test.ts +40 -0
  52. package/src/components/storyboard/storyboardReviewStage.ts +45 -0
  53. package/src/components/storyboard/useFrameComments.ts +22 -6
  54. package/src/contexts/StudioContext.tsx +4 -0
  55. package/src/contexts/ViewModeContext.tsx +60 -7
  56. package/src/hooks/timelineEditingHelpers.ts +10 -4
  57. package/src/hooks/useAddAssetAtPlayhead.test.ts +44 -0
  58. package/src/hooks/useAddAssetAtPlayhead.ts +21 -0
  59. package/src/hooks/usePreviewPersistence.ts +7 -1
  60. package/src/hooks/useRazorSplit.history.test.tsx +21 -28
  61. package/src/hooks/useRazorSplit.test.tsx +20 -9
  62. package/src/hooks/useRazorSplit.testHelpers.ts +34 -22
  63. package/src/hooks/useRazorSplit.ts +78 -350
  64. package/src/hooks/useSlideshowTabState.test.ts +96 -0
  65. package/src/hooks/useSlideshowTabState.ts +61 -0
  66. package/src/hooks/useTimelineAddAtPlayhead.ts +15 -0
  67. package/src/hooks/useTimelineAssetDropOps.ts +52 -2
  68. package/src/hooks/useTimelineEditing.ts +19 -17
  69. package/src/hooks/useTimelineGroupEditing.ts +6 -7
  70. package/src/player/components/Timeline.tsx +12 -1
  71. package/src/player/components/timelineAuthoredMoveTarget.ts +77 -0
  72. package/src/player/components/timelineAuthoredTrack.ts +29 -0
  73. package/src/player/components/timelineCallbacks.ts +4 -0
  74. package/src/player/components/timelineClipDragCommit.test.ts +176 -0
  75. package/src/player/components/timelineClipDragCommit.ts +77 -105
  76. package/src/player/components/timelineClipDragPreview.test.ts +33 -1
  77. package/src/player/components/timelineClipDragPreview.ts +2 -1
  78. package/src/player/components/timelineCollision.test.ts +18 -0
  79. package/src/player/components/timelineCollision.ts +12 -9
  80. package/src/player/components/timelineDragDrop.ts +28 -7
  81. package/src/player/components/timelineEditCapabilities.ts +4 -1
  82. package/src/player/components/timelineGroupEditing.test.ts +10 -0
  83. package/src/player/components/timelineGroupEditing.ts +4 -3
  84. package/src/player/components/timelineLaneMoveRefresh.ts +14 -0
  85. package/src/player/components/timelineLayout.ts +3 -1
  86. package/src/player/components/timelineZMirror.ts +1 -1
  87. package/src/player/components/useTimelineClipDrag.ts +5 -0
  88. package/src/player/components/useTimelineEditPinning.ts +12 -0
  89. package/src/player/hooks/useExpandedTimelineElements.test.ts +42 -0
  90. package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
  91. package/src/player/lib/playbackTypes.ts +9 -0
  92. package/src/player/lib/runtimeProtocol.test.ts +6 -1
  93. package/src/player/lib/timelineDOM.test.ts +58 -0
  94. package/src/player/lib/timelineDOM.ts +26 -12
  95. package/src/player/lib/timelineElementHelpers.ts +21 -11
  96. package/src/player/lib/timelineIframeHelpers.ts +153 -107
  97. package/src/player/store/playerStore.ts +2 -1
  98. package/src/utils/razorSplitTransaction.test.ts +175 -0
  99. package/src/utils/razorSplitTransaction.ts +200 -0
  100. package/src/utils/studioPreviewHelpers.test.ts +27 -0
  101. package/src/utils/timelineCompositionDrop.test.ts +12 -0
  102. package/src/utils/timelineCompositionDrop.ts +16 -0
  103. package/src/utils/timelineCompositionInsert.test.ts +142 -0
  104. package/src/utils/timelineCompositionInsert.ts +90 -0
  105. package/src/utils/timelineElementSplit.test.ts +14 -2
  106. package/src/utils/timelineElementSplit.ts +5 -1
  107. package/dist/assets/hyperframes-player-3XTTaVNf.js +0 -459
  108. package/dist/assets/index-DXbu6IPT.css +0 -1
  109. package/dist/assets/index-cH6NfVV_.js +0 -426
  110. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +0 -23
@@ -98,12 +98,14 @@ export const STUDIO_SDK_RESOLVER_SHADOW_ENABLED = resolveStudioBooleanEnvFlag(
98
98
  );
99
99
 
100
100
  // Studio inspector redesign ("Ledger, flat" — design_handoff_studio_inspector):
101
- // flat identity header/footer/groups behind a flag for incremental review.
102
- // Default false; enable via VITE_STUDIO_FLAT_INSPECTOR_ENABLED=true.
101
+ // flat identity header/footer/groups. Default true as of v0.7.59+ bug-fix pass
102
+ // (right-aligned values, Stroke select-only, promote-badge overlap, Layout/
103
+ // Style section gating); disable via VITE_STUDIO_FLAT_INSPECTOR_ENABLED=false
104
+ // to fall back to the legacy panel.
103
105
  export const STUDIO_FLAT_INSPECTOR_ENABLED = resolveStudioBooleanEnvFlag(
104
106
  env,
105
107
  ["VITE_STUDIO_ENABLE_FLAT_INSPECTOR", "VITE_STUDIO_FLAT_INSPECTOR_ENABLED"],
106
- false,
108
+ true,
107
109
  );
108
110
 
109
111
  export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";
@@ -11,6 +11,15 @@ afterEach(() => {
11
11
  document.body.innerHTML = "";
12
12
  });
13
13
 
14
+ function renderColorField(onCommit: (value: string) => void): void {
15
+ const host = document.createElement("div");
16
+ document.body.append(host);
17
+ const root = createRoot(host);
18
+ act(() => {
19
+ root.render(<ColorField flat label="Color" value="rgb(255, 176, 32)" onCommit={onCommit} />);
20
+ });
21
+ }
22
+
14
23
  describe("ColorField flat trigger", () => {
15
24
  it("renders label and value inline with a small swatch, no boxed border", () => {
16
25
  const host = document.createElement("div");
@@ -25,4 +34,26 @@ describe("ColorField flat trigger", () => {
25
34
  expect(host.textContent).toContain("Color");
26
35
  act(() => root.unmount());
27
36
  });
37
+
38
+ it("persists one keyboard slider gesture on keyup", () => {
39
+ const onCommit = vi.fn();
40
+ renderColorField(onCommit);
41
+ const trigger = document.querySelector<HTMLButtonElement>('[data-flat-color-trigger="true"]');
42
+ if (!trigger) throw new Error("Color trigger was not rendered");
43
+ act(() => {
44
+ trigger.click();
45
+ });
46
+ const hue = document.querySelector<HTMLElement>('[role="slider"][aria-label="Hue"]');
47
+ if (!hue) throw new Error("Hue slider was not rendered");
48
+
49
+ act(() => {
50
+ hue.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowRight" }));
51
+ });
52
+ expect(onCommit).not.toHaveBeenCalled();
53
+ act(() => {
54
+ hue.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true, key: "ArrowRight" }));
55
+ });
56
+
57
+ expect(onCommit).toHaveBeenCalledOnce();
58
+ });
28
59
  });
@@ -12,6 +12,7 @@ import {
12
12
  import { resolveFloatingPanelPosition, type FloatingPosition } from "./floatingPanel";
13
13
  import { colorFromCss, FIELD, LABEL } from "./propertyPanelHelpers";
14
14
  import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
15
+ import { useInspectorGestureTransaction } from "./useInspectorGestureTransaction";
15
16
 
16
17
  const COLOR_PICKER_SIZE = { width: 292, height: 386 };
17
18
 
@@ -29,7 +30,10 @@ function ColorSlider({
29
30
  background,
30
31
  thumbColor,
31
32
  disabled,
32
- onCommit,
33
+ onPreview,
34
+ onInteractionStart,
35
+ onInteractionEnd,
36
+ onInteractionCancel,
33
37
  }: {
34
38
  label: string;
35
39
  value: number;
@@ -40,21 +44,24 @@ function ColorSlider({
40
44
  background: string;
41
45
  thumbColor: string;
42
46
  disabled?: boolean;
43
- onCommit: (nextValue: number) => void;
47
+ onPreview: (nextValue: number) => void;
48
+ onInteractionStart: () => void;
49
+ onInteractionEnd: () => void;
50
+ onInteractionCancel: () => void;
44
51
  }) {
45
52
  const trackRef = useRef<HTMLDivElement | null>(null);
46
53
  const percent = ((value - min) / (max - min)) * 100;
47
54
 
48
- const commitFromClientX = (clientX: number) => {
55
+ const previewFromClientX = (clientX: number) => {
49
56
  const rect = trackRef.current?.getBoundingClientRect();
50
57
  if (!rect || rect.width <= 0) return;
51
58
  const rawValue = min + ((clientX - rect.left) / rect.width) * (max - min);
52
59
  const stepped = Math.round(rawValue / step) * step;
53
- onCommit(Math.max(min, Math.min(max, stepped)));
60
+ onPreview(Math.max(min, Math.min(max, stepped)));
54
61
  };
55
62
 
56
- const commitKeyboardValue = (nextValue: number) => {
57
- onCommit(Math.max(min, Math.min(max, nextValue)));
63
+ const previewKeyboardValue = (nextValue: number) => {
64
+ onPreview(Math.max(min, Math.min(max, nextValue)));
58
65
  };
59
66
 
60
67
  return (
@@ -78,32 +85,52 @@ function ColorSlider({
78
85
  style={{ background }}
79
86
  onPointerDown={(event) => {
80
87
  if (disabled) return;
88
+ onInteractionStart();
81
89
  event.currentTarget.setPointerCapture(event.pointerId);
82
- commitFromClientX(event.clientX);
90
+ previewFromClientX(event.clientX);
83
91
  }}
84
92
  onPointerUp={(event) => {
93
+ onInteractionEnd();
85
94
  event.currentTarget.blur();
86
95
  }}
96
+ onPointerCancel={onInteractionCancel}
87
97
  onPointerMove={(event) => {
88
98
  if (disabled || event.buttons !== 1) return;
89
- commitFromClientX(event.clientX);
99
+ previewFromClientX(event.clientX);
90
100
  }}
91
101
  onKeyDown={(event) => {
92
102
  if (disabled) return;
103
+ if (event.key === "Escape") {
104
+ event.preventDefault();
105
+ onInteractionCancel();
106
+ return;
107
+ }
93
108
  if (event.key === "ArrowRight" || event.key === "ArrowUp") {
94
109
  event.preventDefault();
95
- commitKeyboardValue(value + step);
110
+ onInteractionStart();
111
+ previewKeyboardValue(value + step);
96
112
  } else if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
97
113
  event.preventDefault();
98
- commitKeyboardValue(value - step);
114
+ onInteractionStart();
115
+ previewKeyboardValue(value - step);
99
116
  } else if (event.key === "Home") {
100
117
  event.preventDefault();
101
- commitKeyboardValue(min);
118
+ onInteractionStart();
119
+ previewKeyboardValue(min);
102
120
  } else if (event.key === "End") {
103
121
  event.preventDefault();
104
- commitKeyboardValue(max);
122
+ onInteractionStart();
123
+ previewKeyboardValue(max);
124
+ }
125
+ }}
126
+ onKeyUp={(event) => {
127
+ if (
128
+ ["ArrowRight", "ArrowUp", "ArrowLeft", "ArrowDown", "Home", "End"].includes(event.key)
129
+ ) {
130
+ onInteractionEnd();
105
131
  }
106
132
  }}
133
+ onBlur={onInteractionEnd}
107
134
  >
108
135
  <div
109
136
  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)]"
@@ -122,13 +149,19 @@ export function ColorField({
122
149
  label,
123
150
  value,
124
151
  disabled,
152
+ onReset,
125
153
  flat,
154
+ mixed,
155
+ onPreview,
126
156
  onCommit,
127
157
  }: {
128
158
  label: string;
129
159
  value: string;
130
160
  disabled?: boolean;
161
+ onReset?: () => void;
131
162
  flat?: boolean;
163
+ mixed?: boolean;
164
+ onPreview?: (nextValue: string) => void;
132
165
  onCommit: (nextValue: string) => void;
133
166
  }) {
134
167
  const track = useTrackDesignInput();
@@ -137,6 +170,8 @@ export function ColorField({
137
170
  const [open, setOpen] = useState(false);
138
171
  const [panelPosition, setPanelPosition] = useState<FloatingPosition | null>(null);
139
172
  const [draftColor, setDraftColor] = useState<ParsedColor>(() => colorFromCss(value));
173
+ const draftColorRef = useRef(draftColor);
174
+ draftColorRef.current = draftColor;
140
175
  const [hexDraft, setHexDraft] = useState(() => toHexColor(colorFromCss(value)).toUpperCase());
141
176
  const hsv = rgbToHsv(draftColor);
142
177
  const hueColor = formatCssColor({
@@ -149,6 +184,33 @@ export function ColorField({
149
184
  const brightnessPercent = Math.round(hsv.value * 100);
150
185
  const alphaPercent = Math.round(draftColor.alpha * 100);
151
186
 
187
+ const updateColorDraft = useCallback((nextValue: string) => {
188
+ const nextColor = parseCssColor(nextValue);
189
+ if (!nextColor) return;
190
+ setDraftColor(nextColor);
191
+ setHexDraft(toHexColor(nextColor).toUpperCase());
192
+ }, []);
193
+ const persistColorValue = useCallback(
194
+ (nextValue: string) => {
195
+ if (nextValue !== value) track("color", label);
196
+ onCommit(nextValue);
197
+ },
198
+ [label, onCommit, track, value],
199
+ );
200
+ const {
201
+ begin: beginColorGesture,
202
+ preview: previewColorGesture,
203
+ settle: settleColorGesture,
204
+ cancel: cancelColorGesture,
205
+ } = useInspectorGestureTransaction({
206
+ sourceValue: value,
207
+ onPreview: (nextValue) => {
208
+ updateColorDraft(nextValue);
209
+ onPreview?.(nextValue);
210
+ },
211
+ onCommit: persistColorValue,
212
+ });
213
+
152
214
  useEffect(() => {
153
215
  const nextColor = colorFromCss(value);
154
216
  setDraftColor(nextColor);
@@ -189,10 +251,14 @@ export function ColorField({
189
251
  const target = event.target as Node | null;
190
252
  if (!target) return;
191
253
  if (panelRef.current?.contains(target) || buttonRef.current?.contains(target)) return;
254
+ settleColorGesture();
192
255
  setOpen(false);
193
256
  };
194
257
  const handleKeyDown = (event: KeyboardEvent) => {
195
- if (event.key === "Escape") setOpen(false);
258
+ if (event.key === "Escape") {
259
+ cancelColorGesture();
260
+ setOpen(false);
261
+ }
196
262
  };
197
263
  document.addEventListener("pointerdown", handlePointerDown);
198
264
  document.addEventListener("keydown", handleKeyDown);
@@ -200,14 +266,10 @@ export function ColorField({
200
266
  document.removeEventListener("pointerdown", handlePointerDown);
201
267
  document.removeEventListener("keydown", handleKeyDown);
202
268
  };
203
- }, [open]);
269
+ }, [cancelColorGesture, open, settleColorGesture]);
204
270
 
205
- const commitColor = (nextColor: ParsedColor) => {
206
- setDraftColor(nextColor);
207
- setHexDraft(toHexColor(nextColor).toUpperCase());
208
- const nextValue = formatCssColor(nextColor);
209
- if (nextValue !== value) track("color", label);
210
- onCommit(nextValue);
271
+ const previewColor = (nextColor: ParsedColor) => {
272
+ previewColorGesture(formatCssColor(nextColor));
211
273
  };
212
274
 
213
275
  const commitHsv = (nextHsv: { hue?: number; saturation?: number; value?: number }) => {
@@ -216,7 +278,7 @@ export function ColorField({
216
278
  saturation: nextHsv.saturation ?? hsv.saturation,
217
279
  value: nextHsv.value ?? hsv.value,
218
280
  });
219
- commitColor({ ...rgb, alpha: draftColor.alpha });
281
+ previewColor({ ...rgb, alpha: draftColorRef.current.alpha });
220
282
  };
221
283
 
222
284
  const updateSaturationValue = (clientX: number, clientY: number, target: HTMLDivElement) => {
@@ -231,7 +293,8 @@ export function ColorField({
231
293
  const normalized = nextHex.trim().startsWith("#") ? nextHex.trim() : `#${nextHex.trim()}`;
232
294
  const parsed = parseCssColor(normalized);
233
295
  if (!parsed) return;
234
- commitColor({ ...parsed, alpha: draftColor.alpha });
296
+ const nextValue = formatCssColor({ ...parsed, alpha: draftColorRef.current.alpha });
297
+ updateColorDraft(nextValue);
235
298
  };
236
299
 
237
300
  const picker = open
@@ -251,7 +314,10 @@ export function ColorField({
251
314
  </div>
252
315
  <button
253
316
  type="button"
254
- onClick={() => setOpen(false)}
317
+ onClick={() => {
318
+ settleColorGesture();
319
+ setOpen(false);
320
+ }}
255
321
  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"
256
322
  aria-label="Close color picker"
257
323
  >
@@ -263,6 +329,7 @@ export function ColorField({
263
329
  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)]"
264
330
  style={{ backgroundColor: hueColor }}
265
331
  onPointerDown={(event) => {
332
+ beginColorGesture();
266
333
  event.currentTarget.setPointerCapture(event.pointerId);
267
334
  updateSaturationValue(event.clientX, event.clientY, event.currentTarget);
268
335
  }}
@@ -270,6 +337,8 @@ export function ColorField({
270
337
  if (event.buttons !== 1) return;
271
338
  updateSaturationValue(event.clientX, event.clientY, event.currentTarget);
272
339
  }}
340
+ onPointerUp={settleColorGesture}
341
+ onPointerCancel={cancelColorGesture}
273
342
  >
274
343
  <div className="absolute inset-0 bg-gradient-to-r from-white to-transparent" />
275
344
  <div className="absolute inset-0 bg-gradient-to-t from-black to-transparent" />
@@ -316,7 +385,10 @@ export function ColorField({
316
385
  background="linear-gradient(90deg, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)"
317
386
  thumbColor={hueColor}
318
387
  disabled={disabled}
319
- onCommit={(nextHue) => commitHsv({ hue: nextHue })}
388
+ onInteractionStart={beginColorGesture}
389
+ onPreview={(nextHue) => commitHsv({ hue: nextHue })}
390
+ onInteractionEnd={settleColorGesture}
391
+ onInteractionCancel={cancelColorGesture}
320
392
  />
321
393
 
322
394
  <ColorSlider
@@ -329,7 +401,12 @@ export function ColorField({
329
401
  background={`linear-gradient(90deg, transparent, ${opaqueColor})`}
330
402
  thumbColor={currentColor}
331
403
  disabled={disabled}
332
- onCommit={(nextAlpha) => commitColor({ ...draftColor, alpha: nextAlpha })}
404
+ onInteractionStart={beginColorGesture}
405
+ onPreview={(nextAlpha) =>
406
+ previewColor({ ...draftColorRef.current, alpha: nextAlpha })
407
+ }
408
+ onInteractionEnd={settleColorGesture}
409
+ onInteractionCancel={cancelColorGesture}
333
410
  />
334
411
 
335
412
  <label className="grid gap-1.5">
@@ -337,6 +414,20 @@ export function ColorField({
337
414
  <input
338
415
  value={hexDraft}
339
416
  onChange={(event) => handleHexCommit(event.target.value)}
417
+ onBlur={() => {
418
+ const normalized = hexDraft.trim().startsWith("#")
419
+ ? hexDraft.trim()
420
+ : `#${hexDraft.trim()}`;
421
+ const parsed = parseCssColor(normalized);
422
+ if (parsed) {
423
+ const nextValue = formatCssColor({
424
+ ...parsed,
425
+ alpha: draftColorRef.current.alpha,
426
+ });
427
+ persistColorValue(nextValue);
428
+ }
429
+ setHexDraft(toHexColor(draftColorRef.current).toUpperCase());
430
+ }}
340
431
  className={`${FIELD} h-10 w-full text-[11px] font-medium outline-none`}
341
432
  spellCheck={false}
342
433
  />
@@ -349,6 +440,7 @@ export function ColorField({
349
440
 
350
441
  const openPicker = () => {
351
442
  if (disabled) return;
443
+ if (open) settleColorGesture();
352
444
  setOpen((current) => !current);
353
445
  if (!open) {
354
446
  requestAnimationFrame(updatePanelPosition);
@@ -370,9 +462,19 @@ export function ColorField({
370
462
  >
371
463
  <span
372
464
  className="h-4 w-4 flex-shrink-0 rounded-[4px]"
373
- style={{ backgroundColor: value || "transparent" }}
465
+ style={{ backgroundColor: open ? currentColor : value || "transparent" }}
374
466
  />
375
- <span className="font-mono text-[11px] text-panel-text-0">{value}</span>
467
+ <span className="font-mono text-[11px] text-panel-text-0">
468
+ {open ? currentColor : value}
469
+ </span>
470
+ {mixed && (
471
+ <span
472
+ data-color-mixed-indicator="true"
473
+ className="rounded bg-panel-hover px-1.5 py-0.5 text-[9px] font-medium text-panel-text-4"
474
+ >
475
+ Mixed
476
+ </span>
477
+ )}
376
478
  </button>
377
479
  {picker}
378
480
  </div>
@@ -381,7 +483,19 @@ export function ColorField({
381
483
 
382
484
  return (
383
485
  <div className="grid min-w-0 gap-1.5">
384
- <span className={LABEL}>{label}</span>
486
+ <div className="flex items-center justify-between gap-2">
487
+ <span className={LABEL}>{label}</span>
488
+ {onReset && (
489
+ <button
490
+ type="button"
491
+ disabled={disabled}
492
+ onClick={onReset}
493
+ className="rounded bg-panel-hover px-1.5 py-0.5 text-[9px] font-medium text-panel-text-4 transition-colors hover:text-panel-text-0 disabled:cursor-not-allowed disabled:opacity-40"
494
+ >
495
+ Reset
496
+ </button>
497
+ )}
498
+ </div>
385
499
  <button
386
500
  type="button"
387
501
  disabled={disabled}
@@ -397,6 +511,14 @@ export function ColorField({
397
511
  <span className="min-w-0 flex-1 truncate text-[11px] font-medium text-neutral-100">
398
512
  {value}
399
513
  </span>
514
+ {mixed && (
515
+ <span
516
+ data-color-mixed-indicator="true"
517
+ className="rounded bg-panel-hover px-1.5 py-0.5 text-[9px] font-medium text-panel-text-4"
518
+ >
519
+ Mixed
520
+ </span>
521
+ )}
400
522
  </button>
401
523
  {picker}
402
524
  </div>
@@ -0,0 +1,178 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { adjustNumericToken, parseNumericToken } from "./propertyPanelHelpers";
3
+ import { useInspectorGestureTransaction } from "./useInspectorGestureTransaction";
4
+
5
+ function arrowDirection(key: string): 1 | -1 | null {
6
+ if (key === "ArrowUp") return 1;
7
+ if (key === "ArrowDown") return -1;
8
+ return null;
9
+ }
10
+
11
+ export function CommitField({
12
+ value,
13
+ disabled,
14
+ liveCommit,
15
+ align = "left",
16
+ onPreview,
17
+ onCommit,
18
+ }: {
19
+ value: string;
20
+ disabled?: boolean;
21
+ liveCommit?: boolean;
22
+ align?: "left" | "right";
23
+ onPreview?: (nextValue: string) => void;
24
+ onCommit: (nextValue: string) => void;
25
+ }) {
26
+ const [draft, setDraft] = useState(value);
27
+ const valueRef = useRef(value);
28
+ const draftRef = useRef(draft);
29
+ const inputRef = useRef<HTMLInputElement>(null);
30
+ const focusedRef = useRef(false);
31
+ const dirtyRef = useRef(false);
32
+ valueRef.current = value;
33
+ draftRef.current = draft;
34
+
35
+ const gestureActiveRef = useRef(false);
36
+ const gestureSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
37
+ const gestureTransaction = useInspectorGestureTransaction({
38
+ sourceValue: value,
39
+ onPreview: (nextValue) => {
40
+ setDraft(nextValue);
41
+ onPreview?.(nextValue);
42
+ },
43
+ onCommit,
44
+ });
45
+ const gestureTransactionRef = useRef(gestureTransaction);
46
+ gestureTransactionRef.current = gestureTransaction;
47
+
48
+ const clearGestureSettleTimer = () => {
49
+ if (!gestureSettleTimerRef.current) return;
50
+ clearTimeout(gestureSettleTimerRef.current);
51
+ gestureSettleTimerRef.current = null;
52
+ };
53
+ const settleGesture = () => {
54
+ clearGestureSettleTimer();
55
+ if (!gestureActiveRef.current) return false;
56
+ gestureActiveRef.current = false;
57
+ gestureTransaction.settle();
58
+ return true;
59
+ };
60
+ const scheduleGestureSettle = () => {
61
+ clearGestureSettleTimer();
62
+ gestureSettleTimerRef.current = setTimeout(() => {
63
+ gestureSettleTimerRef.current = null;
64
+ if (!gestureActiveRef.current) return;
65
+ gestureActiveRef.current = false;
66
+ gestureTransactionRef.current.settle();
67
+ }, 250);
68
+ };
69
+ const cancelGesture = () => {
70
+ clearGestureSettleTimer();
71
+ gestureActiveRef.current = false;
72
+ gestureTransaction.cancel();
73
+ };
74
+ const commitDraft = (nextValue: string) => {
75
+ setDraft(nextValue);
76
+ onPreview?.(nextValue);
77
+ if (nextValue !== valueRef.current) onCommit(nextValue);
78
+ };
79
+ const cancelGestureFromKeyEvent = (event: React.KeyboardEvent<HTMLInputElement>) => {
80
+ if (!gestureActiveRef.current) return false;
81
+ event.preventDefault();
82
+ event.stopPropagation();
83
+ cancelGesture();
84
+ return true;
85
+ };
86
+ const previewNumericKeyStep = (event: React.KeyboardEvent<HTMLInputElement>) => {
87
+ const direction = arrowDirection(event.key);
88
+ if (direction === null) return;
89
+ const nextDraft = adjustNumericToken(draftRef.current, direction, event);
90
+ if (!nextDraft) return;
91
+ event.preventDefault();
92
+ dirtyRef.current = false;
93
+ gestureActiveRef.current = true;
94
+ gestureTransaction.preview(nextDraft);
95
+ scheduleGestureSettle();
96
+ };
97
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
98
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z") {
99
+ cancelGestureFromKeyEvent(event);
100
+ return;
101
+ }
102
+ if (event.key === "Escape") {
103
+ cancelGestureFromKeyEvent(event);
104
+ return;
105
+ }
106
+ if (event.key === "Enter") {
107
+ event.currentTarget.blur();
108
+ return;
109
+ }
110
+ previewNumericKeyStep(event);
111
+ };
112
+
113
+ useEffect(() => {
114
+ if (focusedRef.current && dirtyRef.current) return;
115
+ setDraft(value);
116
+ }, [value]);
117
+
118
+ useEffect(() => {
119
+ const el = inputRef.current;
120
+ if (!el) return;
121
+ const handler = (event: WheelEvent) => {
122
+ if (disabled || document.activeElement !== el) return;
123
+ const delta = event.deltaY === 0 ? event.deltaX : event.deltaY;
124
+ if (delta === 0) return;
125
+ const nextDraft = adjustNumericToken(draftRef.current, delta < 0 ? 1 : -1, event);
126
+ if (!nextDraft) return;
127
+ event.preventDefault();
128
+ event.stopPropagation();
129
+ dirtyRef.current = false;
130
+ gestureActiveRef.current = true;
131
+ gestureTransactionRef.current.preview(nextDraft);
132
+ scheduleGestureSettle();
133
+ };
134
+ el.addEventListener("wheel", handler, { passive: false });
135
+ return () => {
136
+ el.removeEventListener("wheel", handler);
137
+ clearGestureSettleTimer();
138
+ };
139
+ }, [disabled]);
140
+
141
+ return (
142
+ <input
143
+ ref={inputRef}
144
+ type="text"
145
+ value={draft}
146
+ disabled={disabled}
147
+ onFocus={() => {
148
+ focusedRef.current = true;
149
+ }}
150
+ onChange={(event) => {
151
+ settleGesture();
152
+ dirtyRef.current = true;
153
+ setDraft(event.target.value);
154
+ if (liveCommit) onPreview?.(event.target.value);
155
+ }}
156
+ onBlur={() => {
157
+ if (settleGesture()) {
158
+ focusedRef.current = false;
159
+ return;
160
+ }
161
+ const wasDirty = dirtyRef.current;
162
+ focusedRef.current = false;
163
+ dirtyRef.current = false;
164
+ if (wasDirty && (!liveCommit || parseNumericToken(draft))) {
165
+ commitDraft(draft);
166
+ } else {
167
+ setDraft(valueRef.current);
168
+ if (wasDirty && liveCommit) onPreview?.(valueRef.current);
169
+ }
170
+ }}
171
+ onKeyDown={handleKeyDown}
172
+ title={parseNumericToken(value) ? "Scroll or use Arrow keys to adjust" : undefined}
173
+ className={`min-w-0 w-full bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600 ${
174
+ align === "right" ? "text-right" : "text-left"
175
+ }`}
176
+ />
177
+ );
178
+ }
@@ -38,6 +38,16 @@ describe("FlatRow", () => {
38
38
  act(() => root.unmount());
39
39
  });
40
40
 
41
+ it("right-aligns the value input — the flat inspector's justify-between row layout leaves a left-aligned value looking stranded at the edge of its own box", () => {
42
+ const { host, root } = renderInto(
43
+ <FlatRow label="Size" value="72px" tier="explicitCustom" onCommit={vi.fn()} />,
44
+ );
45
+ const input = host.querySelector("input");
46
+ expect(input?.className).toContain("text-right");
47
+ expect(input?.className).not.toContain("text-left");
48
+ act(() => root.unmount());
49
+ });
50
+
41
51
  it("renders the explicitCustom tier with a mint value and a reset button", () => {
42
52
  const onReset = vi.fn();
43
53
  const { host, root } = renderInto(
@@ -79,6 +89,40 @@ describe("FlatRow", () => {
79
89
  expect(onCommit).toHaveBeenCalledWith("24px");
80
90
  act(() => root.unmount());
81
91
  });
92
+
93
+ it("persists a rapid numeric arrow-key burst as one commit", () => {
94
+ vi.useFakeTimers();
95
+ const onCommit = vi.fn();
96
+ const onPreview = vi.fn();
97
+ const { host, root } = renderInto(
98
+ <FlatRow
99
+ label="Size"
100
+ value="22px"
101
+ tier="explicitDefault"
102
+ liveCommit
103
+ onPreview={onPreview}
104
+ onCommit={onCommit}
105
+ />,
106
+ );
107
+ const input = host.querySelector<HTMLInputElement>("input");
108
+ if (!input) throw new Error("expected an input");
109
+
110
+ for (let step = 0; step < 8; step += 1) {
111
+ act(() => {
112
+ input.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowUp" }));
113
+ });
114
+ }
115
+
116
+ expect(input.value).toBe("30px");
117
+ expect(onPreview).toHaveBeenLastCalledWith("30px");
118
+ expect(onCommit).not.toHaveBeenCalled();
119
+ act(() => vi.advanceTimersByTime(250));
120
+ expect(onCommit).toHaveBeenCalledOnce();
121
+ expect(onCommit).toHaveBeenCalledWith("30px");
122
+
123
+ act(() => root.unmount());
124
+ vi.useRealTimers();
125
+ });
82
126
  });
83
127
 
84
128
  describe("FlatSegmentedRow", () => {
@@ -20,6 +20,7 @@ export function FlatRow({
20
20
  liveCommit,
21
21
  suffix,
22
22
  dropdown,
23
+ onPreview,
23
24
  onCommit,
24
25
  onReset,
25
26
  }: {
@@ -31,6 +32,7 @@ export function FlatRow({
31
32
  suffix?: ReactNode;
32
33
  /** Renders a trailing 10px caret-down, for select-backed rows. */
33
34
  dropdown?: boolean;
35
+ onPreview?: (nextValue: string) => void;
34
36
  onCommit: (nextValue: string) => void;
35
37
  onReset?: () => void;
36
38
  }) {
@@ -51,6 +53,8 @@ export function FlatRow({
51
53
  value={value}
52
54
  disabled={disabled}
53
55
  liveCommit={liveCommit}
56
+ align="right"
57
+ onPreview={onPreview}
54
58
  onCommit={(nextValue) => {
55
59
  track("metric", label);
56
60
  onCommit(nextValue);