@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
@@ -12,16 +12,3 @@ export const STROKE_STYLE_OPTIONS: string[] = [
12
12
  "inset",
13
13
  "outset",
14
14
  ];
15
-
16
- export function formatStrokeSummary(widthPx: number, style: string): string {
17
- return `${widthPx}px ${style}`;
18
- }
19
-
20
- export function parseStrokeSummary(text: string): { widthPx: number; style: string } | null {
21
- const match = /^\s*(-?\d+(?:\.\d+)?)px\s+(\S+)\s*$/.exec(text);
22
- if (!match) return null;
23
- const widthPx = Number.parseFloat(match[1]);
24
- const style = match[2];
25
- if (!Number.isFinite(widthPx) || !style) return null;
26
- return { widthPx, style };
27
- }
@@ -175,31 +175,43 @@ function setInputValue(input: HTMLInputElement, nextValue: string) {
175
175
  }
176
176
 
177
177
  describe("FlatStyleSection — Stroke and Radius", () => {
178
- it("renders the combined stroke row and commits width+style together on blur", () => {
178
+ it("renders a width-only Stroke width row style is only ever set through the Stroke style select below it", () => {
179
179
  const { host, root } = renderSection(STROKE_STYLES);
180
- expect(host.textContent).toContain("Stroke");
181
- expect(getFlatRowInput(host, "Stroke").value).toBe("1px solid");
180
+ expect(host.textContent).toContain("Stroke width");
181
+ expect(host.textContent).toContain("Stroke style");
182
+ expect(getFlatRowInput(host, "Stroke width").value).toBe("1px");
182
183
  act(() => root.unmount());
183
184
  });
184
185
 
185
- it("commits the stroke row's new width and style together on blur", async () => {
186
+ it("commits a new stroke width without touching the existing style", async () => {
186
187
  const { host, root, onSetStyle } = renderSection(STROKE_STYLES);
187
- await commitFlatRowInput(host, "Stroke", "2px dashed");
188
+ await commitFlatRowInput(host, "Stroke width", "2px");
188
189
  expect(onSetStyle).toHaveBeenCalledWith("border-width", "2px");
189
- expect(onSetStyle).toHaveBeenCalledWith("border-style", "dashed");
190
+ expect(onSetStyle).not.toHaveBeenCalledWith("border-style", expect.anything());
191
+ act(() => root.unmount());
192
+ });
193
+
194
+ it("defaults a from-zero width commit to solid, since a width with no style renders no visible border", async () => {
195
+ const { host, root, onSetStyle } = renderSection({
196
+ "border-width": "0px",
197
+ "border-style": "none",
198
+ "border-color": "rgba(255,255,255,.12)",
199
+ });
200
+ await commitFlatRowInput(host, "Stroke width", "3px");
201
+ expect(onSetStyle).toHaveBeenCalledWith("border-style", "solid");
190
202
  act(() => root.unmount());
191
203
  });
192
204
 
193
205
  it("clamps an out-of-range stroke width commit to 200px (fix 2)", async () => {
194
206
  const { host, root, onSetStyle } = renderSection(STROKE_STYLES);
195
- await commitFlatRowInput(host, "Stroke", "9999px solid");
207
+ await commitFlatRowInput(host, "Stroke width", "9999px");
196
208
  expect(onSetStyle).toHaveBeenCalledWith("border-width", "200px");
197
209
  act(() => root.unmount());
198
210
  });
199
211
 
200
- it("rejects a stroke commit whose style token is not a valid border-style (fix 2)", async () => {
212
+ it("ignores an unparseable stroke width commit", async () => {
201
213
  const { host, root, onSetStyle } = renderSection(STROKE_STYLES);
202
- await commitFlatRowInput(host, "Stroke", "12px bogus");
214
+ await commitFlatRowInput(host, "Stroke width", "bogus");
203
215
  expect(onSetStyle).not.toHaveBeenCalled();
204
216
  act(() => root.unmount());
205
217
  });
@@ -226,7 +238,11 @@ describe("FlatStyleSection — Stroke and Radius", () => {
226
238
  (input) => !host.contains(input),
227
239
  );
228
240
  if (!hexInput) throw new Error("expected the color picker's hex input");
229
- act(() => setInputValue(hexInput, "#112233"));
241
+ act(() => {
242
+ setInputValue(hexInput, "#112233");
243
+ hexInput.focus();
244
+ hexInput.blur();
245
+ });
230
246
  expect(onSetStyle).toHaveBeenCalledWith("border-color", "rgb(17, 34, 51)");
231
247
  act(() => root.unmount());
232
248
  });
@@ -3,11 +3,7 @@ import { useEffect, useState } from "react";
3
3
  import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
4
4
  import { buildDefaultGradientModel, serializeGradient } from "./gradientValue";
5
5
  import { BorderRadiusEditor } from "./BorderRadiusEditor";
6
- import {
7
- formatStrokeSummary,
8
- parseStrokeSummary,
9
- STROKE_STYLE_OPTIONS,
10
- } from "./propertyPanelFlatStyleHelpers";
6
+ import { STROKE_STYLE_OPTIONS } from "./propertyPanelFlatStyleHelpers";
11
7
  import {
12
8
  buildBoxShadowPresetValue,
13
9
  buildClipPathValue,
@@ -47,6 +43,7 @@ function FlatFillFields({
47
43
  styles,
48
44
  assets,
49
45
  onSetStyle,
46
+ onPreviewStyle,
50
47
  onImportAssets,
51
48
  }: {
52
49
  projectId: string;
@@ -54,6 +51,7 @@ function FlatFillFields({
54
51
  styles: Record<string, string>;
55
52
  assets: string[];
56
53
  onSetStyle: (prop: string, value: string) => void | Promise<void>;
54
+ onPreviewStyle?: (prop: string, value: string) => void;
57
55
  onImportAssets?: (files: FileList) => Promise<string[]>;
58
56
  }) {
59
57
  const styleEditingDisabled = !element.capabilities.canEditStyles;
@@ -109,6 +107,7 @@ function FlatFillFields({
109
107
  label="Color"
110
108
  value={styles["background-color"] ?? "transparent"}
111
109
  disabled={styleEditingDisabled}
110
+ onPreview={(next) => onPreviewStyle?.("background-color", next)}
112
111
  onCommit={(next) => onSetStyle("background-color", next)}
113
112
  />
114
113
  ) : preferredFillMode === "Gradient" ? (
@@ -147,10 +146,9 @@ function FlatFillFields({
147
146
  }
148
147
 
149
148
  /* ------------------------------------------------------------------ */
150
- /* Flat Stroke row — combined width+style+color */
149
+ /* Flat Stroke row — width (numeric), style (select), color */
151
150
  /* ------------------------------------------------------------------ */
152
151
 
153
- // fallow-ignore-next-line complexity
154
152
  function FlatStrokeRow({
155
153
  styles,
156
154
  disabled,
@@ -167,52 +165,40 @@ function FlatStrokeRow({
167
165
  const borderStyleValue = styles["border-style"] || styles["border-top-style"] || "none";
168
166
  const borderColorValue =
169
167
  styles["border-color"] || styles["border-top-color"] || "rgba(255, 255, 255, 0.18)";
170
- const summary = formatStrokeSummary(borderWidthValue, borderStyleValue);
171
- const tier = resolveValueTier(
172
- styles["border-width"] != null || styles["border-style"] != null ? summary : undefined,
173
- formatStrokeSummary(0, "none"),
174
- );
168
+ const widthDisplay = formatPxMetricValue(borderWidthValue);
175
169
 
176
170
  return (
177
171
  <>
178
172
  <FlatRow
179
- label="Stroke"
180
- value={summary}
181
- tier={tier}
173
+ label="Stroke width"
174
+ value={widthDisplay}
175
+ tier={resolveValueTier(styles["border-width"], "0px")}
182
176
  disabled={disabled}
183
177
  onCommit={async (next) => {
184
- const parsed = parseStrokeSummary(next);
185
- if (!parsed) return;
186
- if (!STROKE_STYLE_OPTIONS.includes(parsed.style)) return;
187
- const normalizedWidth = normalizePanelPxValue(`${parsed.widthPx}px`, {
178
+ const normalizedWidth = normalizePanelPxValue(next, {
188
179
  min: 0,
189
180
  max: 200,
190
181
  fallback: borderWidthValue,
191
182
  });
192
183
  if (!normalizedWidth) return;
184
+ // buildStrokeWidthStyleUpdates already covers "a width typed in
185
+ // from 0 needs a style to actually render a visible border" —
186
+ // it only defaults to solid when the current style is none/hidden,
187
+ // never clobbering an already-chosen style (dashed, dotted, …).
193
188
  for (const [property, value] of buildStrokeWidthStyleUpdates(
194
189
  normalizedWidth,
195
- parsed.style,
190
+ borderStyleValue,
196
191
  )) {
197
192
  await onSetStyle(property, value);
198
193
  }
199
- for (const [property, value] of buildStrokeStyleUpdates(parsed.style, normalizedWidth)) {
200
- await onSetStyle(property, value);
201
- }
202
194
  }}
203
- suffix={
204
- <>
205
- <span
206
- className="h-4 w-4 flex-shrink-0 rounded border border-panel-border-input"
207
- style={{ backgroundColor: borderColorValue }}
208
- />
209
- <span className="font-mono text-[10px] text-panel-text-3">{borderColorValue}</span>
210
- </>
211
- }
212
195
  />
213
196
  <FlatSelectRow
214
197
  label="Stroke style"
215
198
  value={borderStyleValue}
199
+ // Valid border-style keywords — the ONLY way to set this, since a
200
+ // free-text field here would require typing an exact CSS keyword
201
+ // (e.g. "dashed") with no indication of which ones are valid.
216
202
  options={STROKE_STYLE_OPTIONS}
217
203
  tier={resolveValueTier(styles["border-style"], "none")}
218
204
  disabled={disabled}
@@ -470,6 +456,7 @@ export function FlatStyleSection({
470
456
  styles,
471
457
  assets,
472
458
  onSetStyle,
459
+ onPreviewStyle,
473
460
  onImportAssets,
474
461
  gsapBorderRadius,
475
462
  }: {
@@ -478,6 +465,7 @@ export function FlatStyleSection({
478
465
  styles: Record<string, string>;
479
466
  assets: string[];
480
467
  onSetStyle: (prop: string, value: string) => void | Promise<void>;
468
+ onPreviewStyle?: (prop: string, value: string) => void;
481
469
  onImportAssets?: (files: FileList) => Promise<string[]>;
482
470
  gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
483
471
  }) {
@@ -490,6 +478,7 @@ export function FlatStyleSection({
490
478
  styles={styles}
491
479
  assets={assets}
492
480
  onSetStyle={onSetStyle}
481
+ onPreviewStyle={onPreviewStyle}
493
482
  onImportAssets={onImportAssets}
494
483
  />
495
484
  <FlatStrokeRow styles={styles} disabled={styleEditingDisabled} onSetStyle={onSetStyle} />
@@ -2,6 +2,8 @@
2
2
 
3
3
  import React, { act, useState } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
+ import postcss from "postcss";
6
+ import tailwindcss from "tailwindcss";
5
7
  import { afterEach, describe, expect, it, vi } from "vitest";
6
8
  import { FlatTextLayerList, FlatTextSection } from "./propertyPanelFlatTextSection";
7
9
  import type { DomEditSelection, DomEditTextField } from "./domEditingTypes";
@@ -256,8 +258,9 @@ describe("FlatTextFieldEditor controls", () => {
256
258
  act(() => root.unmount());
257
259
  });
258
260
 
259
- it("live-commits the Size field on input, without requiring blur/Enter", async () => {
261
+ it("previews Size input immediately and persists once on blur", () => {
260
262
  const onSetTextFieldStyle = vi.fn();
263
+ const onPreviewTextFieldStyle = vi.fn();
261
264
  const { host, root } = renderInto(
262
265
  <FlatTextSection
263
266
  element={makeSingleFieldElement()}
@@ -265,6 +268,7 @@ describe("FlatTextFieldEditor controls", () => {
265
268
  fontAssets={[]}
266
269
  onSetText={vi.fn()}
267
270
  onSetTextFieldStyle={onSetTextFieldStyle}
271
+ onPreviewTextFieldStyle={onPreviewTextFieldStyle}
268
272
  onAddTextField={vi.fn()}
269
273
  onRemoveTextField={vi.fn()}
270
274
  />,
@@ -275,6 +279,7 @@ describe("FlatTextFieldEditor controls", () => {
275
279
  const input = sizeLabel?.parentElement?.querySelector<HTMLInputElement>("input");
276
280
  if (!input) throw new Error("expected the Size row's input");
277
281
  act(() => {
282
+ input.focus();
278
283
  const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
279
284
  window.HTMLInputElement.prototype,
280
285
  "value",
@@ -282,9 +287,10 @@ describe("FlatTextFieldEditor controls", () => {
282
287
  nativeInputValueSetter?.call(input, "24px");
283
288
  input.dispatchEvent(new Event("input", { bubbles: true }));
284
289
  });
285
- // liveCommit debounces on a 120ms timer — no blur/Enter dispatched here.
286
- await act(async () => {
287
- await new Promise((resolve) => setTimeout(resolve, 160));
290
+ expect(onPreviewTextFieldStyle).toHaveBeenCalledWith("a", "font-size", "24px");
291
+ expect(onSetTextFieldStyle).not.toHaveBeenCalled();
292
+ act(() => {
293
+ input.blur();
288
294
  });
289
295
  expect(onSetTextFieldStyle).toHaveBeenCalledWith("a", "font-size", "24px");
290
296
  act(() => root.unmount());
@@ -462,4 +468,41 @@ describe("FlatTextSection — multi-field", () => {
462
468
 
463
469
  act(() => root.unmount());
464
470
  });
471
+
472
+ it("keeps Content expandable and grows it with multiline text", async () => {
473
+ const element = makeMultiFieldElement();
474
+ element.textFields[0].value = "First line\nSecond line\nThird line";
475
+
476
+ const host = document.createElement("div");
477
+ document.body.append(host);
478
+ const root = createRoot(host);
479
+ act(() => {
480
+ root.render(
481
+ <FlatTextSection
482
+ element={element}
483
+ styles={{}}
484
+ fontAssets={[]}
485
+ onSetText={vi.fn()}
486
+ onSetTextFieldStyle={vi.fn()}
487
+ onAddTextField={vi.fn()}
488
+ onRemoveTextField={vi.fn()}
489
+ />,
490
+ );
491
+ });
492
+
493
+ const contentTextarea = host.querySelector<HTMLTextAreaElement>("textarea");
494
+ expect(contentTextarea?.value).toBe("First line\nSecond line\nThird line");
495
+ expect(contentTextarea?.classList).toContain("resize-y");
496
+ expect(contentTextarea?.classList).toContain("overflow-y-auto");
497
+
498
+ const compiled = await postcss([
499
+ tailwindcss({
500
+ content: [{ raw: host.innerHTML, extension: "html" }],
501
+ corePlugins: { preflight: false },
502
+ }),
503
+ ]).process("@tailwind utilities;", { from: undefined });
504
+ expect(compiled.css).toContain("field-sizing: content");
505
+
506
+ act(() => root.unmount());
507
+ });
465
508
  });
@@ -47,6 +47,7 @@ function FlatTextFieldEditor({
47
47
  onImportFonts,
48
48
  onSetText,
49
49
  onSetTextFieldStyle,
50
+ onPreviewTextFieldStyle,
50
51
  autoFocus = false,
51
52
  }: {
52
53
  field: DomEditSelection["textFields"][number];
@@ -55,6 +56,7 @@ function FlatTextFieldEditor({
55
56
  onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
56
57
  onSetText: (value: string, fieldKey?: string) => void;
57
58
  onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
59
+ onPreviewTextFieldStyle?: (fieldKey: string, property: string, value: string) => void;
58
60
  autoFocus?: boolean;
59
61
  }) {
60
62
  const track = useTrackDesignInput();
@@ -100,6 +102,7 @@ function FlatTextFieldEditor({
100
102
  value={field.computedStyles["font-size"] || styles["font-size"] || "16px"}
101
103
  tier={resolveValueTier(field.inlineStyles["font-size"], styles["font-size"] || "16px")}
102
104
  liveCommit
105
+ onPreview={(next) => onPreviewTextFieldStyle?.(field.key, "font-size", next)}
103
106
  onCommit={(next) => onSetTextFieldStyle(field.key, "font-size", next)}
104
107
  />
105
108
  <div className="flex min-h-[30px] items-center justify-between">
@@ -220,6 +223,7 @@ function FlatTextFieldEditor({
220
223
  flat
221
224
  label="Color"
222
225
  value={value ?? getTextFieldColor(field, styles)}
226
+ onPreview={(next) => onPreviewTextFieldStyle?.(field.key, "color", next)}
223
227
  onCommit={onCommit ?? ((next) => onSetTextFieldStyle(field.key, "color", next))}
224
228
  />
225
229
  )}
@@ -235,6 +239,7 @@ export function FlatTextSection({
235
239
  onImportFonts,
236
240
  onSetText,
237
241
  onSetTextFieldStyle,
242
+ onPreviewTextFieldStyle,
238
243
  onAddTextField,
239
244
  onRemoveTextField,
240
245
  }: {
@@ -244,6 +249,7 @@ export function FlatTextSection({
244
249
  onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
245
250
  onSetText: (value: string, fieldKey?: string) => void;
246
251
  onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
252
+ onPreviewTextFieldStyle?: (fieldKey: string, property: string, value: string) => void;
247
253
  onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
248
254
  onRemoveTextField: (fieldKey: string) => void;
249
255
  }) {
@@ -267,7 +273,7 @@ export function FlatTextSection({
267
273
 
268
274
  if (textFields.length > 1) {
269
275
  return (
270
- <div className="space-y-1.5">
276
+ <div className="space-y-2.5">
271
277
  <FlatTextLayerList
272
278
  fields={textFields}
273
279
  activeFieldKey={activeField.key}
@@ -288,6 +294,7 @@ export function FlatTextSection({
288
294
  onImportFonts={onImportFonts}
289
295
  onSetText={onSetText}
290
296
  onSetTextFieldStyle={onSetTextFieldStyle}
297
+ onPreviewTextFieldStyle={onPreviewTextFieldStyle}
291
298
  autoFocus
292
299
  />
293
300
  </div>
@@ -295,7 +302,7 @@ export function FlatTextSection({
295
302
  }
296
303
 
297
304
  return (
298
- <div className="space-y-1.5">
305
+ <div className="space-y-2.5">
299
306
  <FlatTextFieldEditor
300
307
  field={activeField}
301
308
  styles={styles}
@@ -303,6 +310,7 @@ export function FlatTextSection({
303
310
  onImportFonts={onImportFonts}
304
311
  onSetText={onSetText}
305
312
  onSetTextFieldStyle={onSetTextFieldStyle}
313
+ onPreviewTextFieldStyle={onPreviewTextFieldStyle}
306
314
  />
307
315
  <button
308
316
  type="button"
@@ -134,6 +134,13 @@ describe("classic property-panel primitive telemetry", () => {
134
134
  const input = host.querySelector("input");
135
135
  if (!input) throw new Error("expected metric input");
136
136
 
137
+ // Regression guard for CommitField's shared `align` default: the classic
138
+ // panel lays out label-then-value inline, where left-aligned reads
139
+ // naturally — this must stay left even though the flat inspector's
140
+ // FlatRow now opts into `align="right"` for its own justify-between rows.
141
+ expect(input.className).toContain("text-left");
142
+ expect(input.className).not.toContain("text-right");
143
+
137
144
  act(() => blurInput(input));
138
145
  expect(trackStudioEvent).not.toHaveBeenCalled();
139
146
 
@@ -349,7 +356,10 @@ describe.each(["classic", "flat"] as const)("shared %s input telemetry", (ui) =>
349
356
  (input) => input.value === "#FF0000",
350
357
  );
351
358
  if (!hex) throw new Error("expected color hex input");
352
- act(() => changeInput(hex, "#00FF00"));
359
+ act(() => {
360
+ changeInput(hex, "#00FF00");
361
+ blurInput(hex);
362
+ });
353
363
 
354
364
  expect(trackStudioEvent).toHaveBeenCalledTimes(1);
355
365
  expect(trackStudioEvent).toHaveBeenLastCalledWith("design_input", {
@@ -448,6 +458,13 @@ function representativeElement() {
448
458
 
449
459
  describe("classic PropertyPanel input coverage", () => {
450
460
  it("emits only named, known-section events across body inputs and header/footer chrome", async () => {
461
+ vi.resetModules();
462
+ vi.doMock("./manualEditingAvailability", async () => {
463
+ const actual = await vi.importActual<typeof import("./manualEditingAvailability")>(
464
+ "./manualEditingAvailability",
465
+ );
466
+ return { ...actual, STUDIO_FLAT_INSPECTOR_ENABLED: false };
467
+ });
451
468
  const { PropertyPanel } = await import("./PropertyPanel");
452
469
  const host = render(
453
470
  <PropertyPanel
@@ -3,99 +3,10 @@ import {
3
3
  DesignPanelInputProvider,
4
4
  useTrackDesignInput,
5
5
  } from "../../contexts/DesignPanelInputContext";
6
- import { adjustNumericToken, FIELD, LABEL, parseNumericToken } from "./propertyPanelHelpers";
6
+ import { FIELD, LABEL } from "./propertyPanelHelpers";
7
+ import { CommitField } from "./propertyPanelCommitField";
7
8
 
8
- export function CommitField({
9
- value,
10
- disabled,
11
- liveCommit,
12
- onCommit,
13
- }: {
14
- value: string;
15
- disabled?: boolean;
16
- liveCommit?: boolean;
17
- onCommit: (nextValue: string) => void;
18
- }) {
19
- const [draft, setDraft] = useState(value);
20
- const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
21
- const valueRef = useRef(value);
22
- const draftRef = useRef(draft);
23
- const inputRef = useRef<HTMLInputElement>(null);
24
-
25
- valueRef.current = value;
26
- draftRef.current = draft;
27
-
28
- useEffect(() => {
29
- setDraft(value);
30
- }, [value]);
31
-
32
- useEffect(() => {
33
- const el = inputRef.current;
34
- if (!el) return;
35
- const handler = (e: WheelEvent) => {
36
- if (disabled || document.activeElement !== el) return;
37
- const delta = e.deltaY === 0 ? e.deltaX : e.deltaY;
38
- if (delta === 0) return;
39
- const nextDraft = adjustNumericToken(draftRef.current, delta < 0 ? 1 : -1, e);
40
- if (!nextDraft) return;
41
- e.preventDefault();
42
- e.stopPropagation();
43
- setDraft(nextDraft);
44
- scheduleCommitRef.current(nextDraft);
45
- };
46
- el.addEventListener("wheel", handler, { passive: false });
47
- return () => el.removeEventListener("wheel", handler);
48
- }, [disabled]);
49
-
50
- useEffect(
51
- () => () => {
52
- if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
53
- },
54
- [],
55
- );
56
-
57
- const commitDraft = (nextDraft: string) => {
58
- if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
59
- if (nextDraft !== valueRef.current) onCommit(nextDraft);
60
- };
61
-
62
- const scheduleCommit = (nextDraft: string) => {
63
- if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
64
- commitTimerRef.current = setTimeout(() => {
65
- if (nextDraft !== valueRef.current) onCommit(nextDraft);
66
- }, 120);
67
- };
68
- const scheduleCommitRef = useRef(scheduleCommit);
69
- scheduleCommitRef.current = scheduleCommit;
70
-
71
- return (
72
- <input
73
- ref={inputRef}
74
- type="text"
75
- value={draft}
76
- disabled={disabled}
77
- onChange={(e) => {
78
- setDraft(e.target.value);
79
- if (liveCommit) scheduleCommit(e.target.value);
80
- }}
81
- onBlur={() => commitDraft(draft)}
82
- onKeyDown={(e) => {
83
- if (e.key === "Enter") {
84
- (e.target as HTMLInputElement).blur();
85
- return;
86
- }
87
- if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
88
- const nextDraft = adjustNumericToken(draft, e.key === "ArrowUp" ? 1 : -1, e);
89
- if (!nextDraft) return;
90
- e.preventDefault();
91
- setDraft(nextDraft);
92
- scheduleCommit(nextDraft);
93
- }}
94
- title={parseNumericToken(value) ? "Scroll or use Arrow keys to adjust" : undefined}
95
- 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"
96
- />
97
- );
98
- }
9
+ export { CommitField } from "./propertyPanelCommitField";
99
10
 
100
11
  /* ------------------------------------------------------------------ */
101
12
  /* MetricField */
@@ -147,7 +147,7 @@ export function TextAreaField({
147
147
  onFocus={handleFocus}
148
148
  onChange={handleChange}
149
149
  onBlur={handleBlur}
150
- className="w-full resize-none bg-transparent font-mono text-[11px] leading-normal text-panel-text-0 outline-none disabled:cursor-not-allowed disabled:text-panel-text-4"
150
+ className="[field-sizing:content] max-h-[40vh] min-h-12 w-full resize-y overflow-x-hidden overflow-y-auto bg-transparent font-mono text-[11px] leading-normal text-panel-text-0 outline-none disabled:cursor-not-allowed disabled:text-panel-text-4"
151
151
  />
152
152
  </div>
153
153
  );
@@ -165,7 +165,7 @@ export function TextAreaField({
165
165
  onFocus={handleFocus}
166
166
  onChange={handleChange}
167
167
  onBlur={handleBlur}
168
- className="w-full resize-none bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600"
168
+ className="[field-sizing:content] max-h-[40vh] min-h-20 w-full resize-y overflow-x-hidden overflow-y-auto bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600"
169
169
  />
170
170
  </div>
171
171
  </label>
@@ -32,6 +32,7 @@ export interface PropertyPanelProps {
32
32
  onClearSelection: () => void;
33
33
  onUngroup?: () => void;
34
34
  onSetStyle: (prop: string, value: string) => void | Promise<void>;
35
+ onPreviewStyle?: (prop: string, value: string) => void;
35
36
  onSetAttribute: (attr: string, value: string) => void | Promise<void>;
36
37
  /** Commits several data-* attributes on the SAME element in ONE atomic
37
38
  * persist call — e.g. a pinned timing range's start+duration together, so
@@ -62,6 +63,7 @@ export interface PropertyPanelProps {
62
63
  onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void;
63
64
  onSetText: (value: string, fieldKey?: string) => void;
64
65
  onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
66
+ onPreviewTextFieldStyle?: (fieldKey: string, property: string, value: string) => void;
65
67
  onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
66
68
  onRemoveTextField: (fieldKey: string) => void;
67
69
  onAskAgent: () => void;
@@ -0,0 +1,37 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { describe, expect, it, vi } from "vitest";
6
+ import { useInspectorGestureTransaction } from "./useInspectorGestureTransaction";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ describe("useInspectorGestureTransaction", () => {
11
+ it("keeps a new gesture active when the prior async commit is acknowledged", () => {
12
+ const host = document.createElement("div");
13
+ const root = createRoot(host);
14
+ const onPreview = vi.fn();
15
+ const onCommit = vi.fn();
16
+ let gesture: ReturnType<typeof useInspectorGestureTransaction<number>> | null = null;
17
+
18
+ function Probe({ sourceValue }: { sourceValue: number }) {
19
+ gesture = useInspectorGestureTransaction({ sourceValue, onPreview, onCommit });
20
+ return null;
21
+ }
22
+
23
+ act(() => root.render(<Probe sourceValue={10} />));
24
+ act(() => {
25
+ gesture?.preview(20);
26
+ gesture?.settle();
27
+ gesture?.preview(30);
28
+ });
29
+ act(() => root.render(<Probe sourceValue={20} />));
30
+
31
+ expect(onPreview).toHaveBeenLastCalledWith(30);
32
+ act(() => gesture?.settle());
33
+ expect(onCommit.mock.calls.map(([value]) => value)).toEqual([20, 30]);
34
+
35
+ act(() => root.unmount());
36
+ });
37
+ });
@@ -0,0 +1,60 @@
1
+ import { useCallback, useEffect, useRef } from "react";
2
+
3
+ /** One owner for continuous inspector edits: preview freely, persist once. */
4
+ export function useInspectorGestureTransaction<T>({
5
+ sourceValue,
6
+ onPreview,
7
+ onCommit,
8
+ }: {
9
+ sourceValue: T;
10
+ onPreview: (value: T) => void;
11
+ onCommit: (value: T) => void;
12
+ }) {
13
+ const sourceRef = useRef(sourceValue);
14
+ const activeRef = useRef<{ before: T; latest: T } | null>(null);
15
+ const previewRef = useRef(onPreview);
16
+ const commitRef = useRef(onCommit);
17
+ if (!activeRef.current) sourceRef.current = sourceValue;
18
+ previewRef.current = onPreview;
19
+ commitRef.current = onCommit;
20
+
21
+ const begin = useCallback(() => {
22
+ if (!activeRef.current) {
23
+ activeRef.current = { before: sourceRef.current, latest: sourceRef.current };
24
+ }
25
+ }, []);
26
+
27
+ const preview = useCallback((value: T) => {
28
+ if (!activeRef.current) {
29
+ activeRef.current = { before: sourceRef.current, latest: sourceRef.current };
30
+ }
31
+ activeRef.current.latest = value;
32
+ previewRef.current(value);
33
+ }, []);
34
+
35
+ const settle = useCallback(() => {
36
+ const active = activeRef.current;
37
+ activeRef.current = null;
38
+ if (active && !Object.is(active.before, active.latest)) {
39
+ sourceRef.current = active.latest;
40
+ // Restore the captured baseline before the persistent commit captures
41
+ // rollback state. The commit reapplies `latest` synchronously, so this
42
+ // is not visible but a failed save can now correctly restore `before`.
43
+ previewRef.current(active.before);
44
+ commitRef.current(active.latest);
45
+ }
46
+ }, []);
47
+
48
+ const cancel = useCallback(() => {
49
+ const active = activeRef.current;
50
+ activeRef.current = null;
51
+ if (active && !Object.is(active.before, active.latest)) {
52
+ sourceRef.current = active.before;
53
+ previewRef.current(active.before);
54
+ }
55
+ }, []);
56
+
57
+ useEffect(() => cancel, [cancel]);
58
+
59
+ return { begin, preview, settle, cancel, activeRef };
60
+ }