@hyperframes/studio 0.7.58 → 0.7.60

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 (173) hide show
  1. package/dist/assets/{hyperframes-player-CtTDO63S.js → hyperframes-player-3XTTaVNf.js} +1 -1
  2. package/dist/assets/{index-C47jAC3Q.js → index-D6etaey-.js} +1 -1
  3. package/dist/assets/index-DXbu6IPT.css +1 -0
  4. package/dist/assets/{index-DeQPzqwH.js → index-Dh_WhagG.js} +1 -1
  5. package/dist/assets/index-cH6NfVV_.js +426 -0
  6. package/dist/index.d.ts +12 -3
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +13755 -8627
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles/tailwind-preset.d.ts +5 -0
  11. package/dist/styles/tailwind-preset.js +8 -1
  12. package/dist/styles/tailwind-preset.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +4 -4
  15. package/src/components/DesignPanelPromoteProvider.tsx +15 -2
  16. package/src/components/StudioRightPanel.tsx +42 -53
  17. package/src/components/editor/AnimationCard.test.tsx +134 -0
  18. package/src/components/editor/AnimationCard.tsx +19 -4
  19. package/src/components/editor/ArcPathControls.tsx +1 -0
  20. package/src/components/editor/GestureRecordControl.tsx +7 -1
  21. package/src/components/editor/GsapAnimationSection.tsx +109 -14
  22. package/src/components/editor/InspectorHeaderActions.tsx +42 -4
  23. package/src/components/editor/PropertyPanel.test.tsx +948 -0
  24. package/src/components/editor/PropertyPanel.tsx +144 -148
  25. package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
  26. package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
  27. package/src/components/editor/PropertyPanelFlat.tsx +580 -0
  28. package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
  29. package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
  30. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
  31. package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
  32. package/src/components/editor/gsapAnimationCallbacks.ts +27 -0
  33. package/src/components/editor/gsapLivePreview.ts +34 -0
  34. package/src/components/editor/manualEditingAvailability.test.ts +10 -0
  35. package/src/components/editor/manualEditingAvailability.ts +9 -0
  36. package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
  37. package/src/components/editor/propertyPanelColor.test.tsx +28 -0
  38. package/src/components/editor/propertyPanelColor.tsx +31 -1
  39. package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
  40. package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
  41. package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
  42. package/src/components/editor/propertyPanelFill.tsx +22 -5
  43. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
  44. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
  45. package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
  46. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
  47. package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
  48. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
  49. package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
  50. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
  51. package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
  52. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
  53. package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
  54. package/src/components/editor/propertyPanelFlatSelectRow.tsx +107 -0
  55. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
  56. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
  57. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
  58. package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
  59. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
  60. package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
  61. package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
  62. package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
  63. package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
  64. package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
  65. package/src/components/editor/propertyPanelFont.test.tsx +30 -0
  66. package/src/components/editor/propertyPanelFont.tsx +136 -95
  67. package/src/components/editor/propertyPanelHelpers.ts +73 -0
  68. package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
  69. package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
  70. package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
  71. package/src/components/editor/propertyPanelSections.test.tsx +161 -0
  72. package/src/components/editor/propertyPanelSections.tsx +154 -90
  73. package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
  74. package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
  75. package/src/components/editor/propertyPanelTypes.ts +14 -1
  76. package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
  77. package/src/components/editor/propertyPanelValueTier.ts +28 -0
  78. package/src/components/editor/useColorGradingController.test.ts +418 -0
  79. package/src/components/editor/useColorGradingController.ts +596 -0
  80. package/src/components/panels/VariablesPanel.tsx +4 -0
  81. package/src/components/sidebar/CompositionsTab.test.ts +28 -2
  82. package/src/components/sidebar/CompositionsTab.tsx +3 -1
  83. package/src/components/storyboard/FramePoster.tsx +24 -4
  84. package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
  85. package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
  86. package/src/components/storyboard/StoryboardGrid.tsx +23 -1
  87. package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
  88. package/src/components/storyboard/StoryboardView.tsx +9 -2
  89. package/src/components/storyboard/frameComments.test.ts +95 -0
  90. package/src/components/storyboard/frameComments.ts +115 -0
  91. package/src/components/storyboard/useFrameComments.ts +82 -0
  92. package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
  93. package/src/contexts/DesignPanelInputContext.tsx +48 -0
  94. package/src/contexts/DomEditContext.tsx +4 -0
  95. package/src/contexts/FileManagerContext.tsx +3 -0
  96. package/src/contexts/PanelLayoutContext.tsx +3 -0
  97. package/src/contexts/VariablePromoteContext.test.tsx +67 -0
  98. package/src/contexts/VariablePromoteContext.tsx +10 -5
  99. package/src/hooks/domEditCommitRunner.ts +11 -0
  100. package/src/hooks/gsapScriptCommitTypes.ts +6 -4
  101. package/src/hooks/timelineEditingHelpers.ts +2 -2
  102. package/src/hooks/timelineTrackVisibility.test.ts +52 -0
  103. package/src/hooks/timelineTrackVisibility.ts +27 -10
  104. package/src/hooks/useAppHotkeys.ts +9 -0
  105. package/src/hooks/useDomEditAttributeCommits.ts +123 -1
  106. package/src/hooks/useDomEditCommits.test.tsx +41 -1
  107. package/src/hooks/useDomEditCommits.ts +31 -177
  108. package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
  109. package/src/hooks/useDomEditSession.ts +18 -6
  110. package/src/hooks/useDomEditTextCommits.ts +14 -9
  111. package/src/hooks/useDomEditWiring.ts +10 -9
  112. package/src/hooks/useEditorSave.ts +1 -1
  113. package/src/hooks/useElementLifecycleOps.ts +8 -3
  114. package/src/hooks/useFileManager.projectOwnership.test.tsx +109 -0
  115. package/src/hooks/useFileManager.ts +151 -65
  116. package/src/hooks/useGsapAnimationOps.ts +7 -6
  117. package/src/hooks/useGsapKeyframeOps.ts +6 -5
  118. package/src/hooks/useGsapPropertyDebounce.ts +43 -28
  119. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
  120. package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
  121. package/src/hooks/useGsapScriptCommits.ts +150 -50
  122. package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
  123. package/src/hooks/useGsapSelectionHandlers.ts +94 -31
  124. package/src/hooks/useInspectorSplitResize.ts +51 -0
  125. package/src/hooks/usePanelLayout.test.ts +118 -0
  126. package/src/hooks/usePanelLayout.ts +24 -1
  127. package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
  128. package/src/hooks/usePersistentEditHistory.test.ts +49 -1
  129. package/src/hooks/usePersistentEditHistory.ts +71 -31
  130. package/src/hooks/usePreviewPersistence.ts +4 -1
  131. package/src/hooks/useProjectCompositionVariables.ts +10 -10
  132. package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
  133. package/src/hooks/useProjectSignaturePoll.ts +68 -0
  134. package/src/hooks/useRazorSplit.history.test.tsx +12 -4
  135. package/src/hooks/useRazorSplit.test.tsx +88 -0
  136. package/src/hooks/useRazorSplit.testHelpers.ts +3 -2
  137. package/src/hooks/useRazorSplit.ts +50 -16
  138. package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
  139. package/src/hooks/useSdkSession.ts +155 -14
  140. package/src/hooks/useSlideshowPersist.ts +7 -1
  141. package/src/hooks/useStoryboard.ts +23 -4
  142. package/src/hooks/useTimelineEditing.test.tsx +4 -1
  143. package/src/hooks/useTimelineEditing.ts +18 -10
  144. package/src/hooks/useTimelineEditingTypes.ts +5 -1
  145. package/src/hooks/useTimelineGroupEditing.ts +17 -3
  146. package/src/hooks/useVariablesPersist.ts +9 -7
  147. package/src/icons/SystemIcons.tsx +2 -0
  148. package/src/styles/studio.css +22 -0
  149. package/src/styles/tailwind-preset.shared.js +7 -0
  150. package/src/utils/designInputTracking.test.ts +97 -0
  151. package/src/utils/designInputTracking.ts +80 -0
  152. package/src/utils/domEditSaveQueue.test.ts +19 -0
  153. package/src/utils/domEditSaveQueue.ts +2 -1
  154. package/src/utils/sdkCutover.gate.test.ts +7 -7
  155. package/src/utils/sdkCutover.test.ts +511 -62
  156. package/src/utils/sdkCutover.ts +181 -219
  157. package/src/utils/sdkEditTransaction.ts +293 -0
  158. package/src/utils/sdkResolverAttempts.ts +94 -0
  159. package/src/utils/sdkResolverShadow.test.ts +148 -0
  160. package/src/utils/sdkResolverShadow.ts +139 -106
  161. package/src/utils/setSlideshowManifest.test.ts +0 -9
  162. package/src/utils/setSlideshowManifest.ts +20 -27
  163. package/src/utils/studioFileHistory.test.ts +41 -0
  164. package/src/utils/studioFileHistory.ts +34 -29
  165. package/src/utils/studioFileMutationCoordinator.ts +45 -0
  166. package/src/utils/studioFileVersion.test.ts +36 -0
  167. package/src/utils/studioFileVersion.ts +23 -0
  168. package/src/utils/studioHelpers.test.ts +45 -0
  169. package/src/utils/studioHelpers.ts +45 -14
  170. package/src/utils/studioSaveDiagnostics.test.ts +18 -0
  171. package/src/utils/studioSaveDiagnostics.ts +38 -0
  172. package/dist/assets/index-B_UvTX3E.js +0 -423
  173. package/dist/assets/index-uahwWkgw.css +0 -1
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef, useState } from "react";
1
+ import { useEffect, useRef, useState, type ChangeEvent } from "react";
2
2
  import { Plus, Type } from "../../icons/SystemIcons";
3
3
  import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
4
4
  import type { ImportedFontAsset } from "./fontAssets";
@@ -7,25 +7,26 @@ import { MetricField, Section, SelectField } from "./propertyPanelPrimitives";
7
7
  import { ColorField } from "./propertyPanelColor";
8
8
  import { FontFamilyField } from "./propertyPanelFont";
9
9
  import { PromotableControl } from "./PromotableControl";
10
+ import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
10
11
 
11
12
  /* ------------------------------------------------------------------ */
12
13
  /* Text helpers (used only by text section components) */
13
14
  /* ------------------------------------------------------------------ */
14
15
 
15
- function formatTextFieldPreview(value: string): string {
16
+ export function formatTextFieldPreview(value: string): string {
16
17
  const collapsed = value.trim().replace(/\s+/g, " ");
17
18
  if (collapsed.length <= 56) return collapsed;
18
19
  return `${collapsed.slice(0, 55)}…`;
19
20
  }
20
21
 
21
- function getTextFieldColor(
22
+ export function getTextFieldColor(
22
23
  field: { computedStyles: Record<string, string> },
23
24
  inheritedStyles: Record<string, string>,
24
25
  ): string {
25
26
  return field.computedStyles.color || inheritedStyles.color || "rgb(0, 0, 0)";
26
27
  }
27
28
 
28
- function getTextStyleValue(
29
+ export function getTextStyleValue(
29
30
  field: { computedStyles: Record<string, string> },
30
31
  inheritedStyles: Record<string, string>,
31
32
  property: string,
@@ -35,7 +36,7 @@ function getTextStyleValue(
35
36
  }
36
37
 
37
38
  const ALL_WEIGHTS = ["100", "200", "300", "400", "500", "600", "700", "800", "900"];
38
- const WEIGHT_LABELS: Record<string, string> = {
39
+ export const WEIGHT_LABELS: Record<string, string> = {
39
40
  "100": "100 · Thin",
40
41
  "200": "200 · Extra Light",
41
42
  "300": "300 · Light",
@@ -47,7 +48,7 @@ const WEIGHT_LABELS: Record<string, string> = {
47
48
  "900": "900 · Black",
48
49
  };
49
50
 
50
- function detectAvailableWeights(fontFamily: string): string[] {
51
+ export function detectAvailableWeights(fontFamily: string): string[] {
51
52
  const fonts = document.fonts;
52
53
  if (!fonts) return ALL_WEIGHTS;
53
54
  const family = fontFamily.split(",")[0]?.trim().replace(/['"]/g, "");
@@ -59,22 +60,26 @@ function detectAvailableWeights(fontFamily: string): string[] {
59
60
  return available.length > 0 ? available : ALL_WEIGHTS;
60
61
  }
61
62
 
62
- function TextAreaField({
63
+ export function TextAreaField({
63
64
  label,
64
65
  value,
65
66
  disabled,
66
67
  autoFocus,
68
+ flat,
67
69
  onCommit,
68
70
  }: {
69
71
  label: string;
70
72
  value: string;
71
73
  disabled?: boolean;
72
74
  autoFocus?: boolean;
75
+ flat?: boolean;
73
76
  onCommit: (nextValue: string) => void;
74
77
  }) {
78
+ const track = useTrackDesignInput();
75
79
  const [draft, setDraft] = useState(value);
76
80
  const textareaRef = useRef<HTMLTextAreaElement | null>(null);
77
81
  const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
82
+ const interactionChangedRef = useRef(false);
78
83
  const focusedRef = useRef(false);
79
84
  const valueRef = useRef(value);
80
85
  valueRef.current = value;
@@ -96,15 +101,58 @@ function TextAreaField({
96
101
 
97
102
  const commitDraft = (d: string) => {
98
103
  if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
104
+ if (interactionChangedRef.current) {
105
+ interactionChangedRef.current = false;
106
+ track("text", label);
107
+ }
99
108
  if (d !== valueRef.current) onCommit(d);
100
109
  };
101
110
  const scheduleCommit = (d: string) => {
102
111
  if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
103
112
  commitTimerRef.current = setTimeout(() => {
104
- if (d !== valueRef.current) onCommit(d);
113
+ if (d !== valueRef.current) {
114
+ if (interactionChangedRef.current) {
115
+ interactionChangedRef.current = false;
116
+ track("text", label);
117
+ }
118
+ onCommit(d);
119
+ }
105
120
  }, 120);
106
121
  };
107
122
 
123
+ const handleFocus = () => {
124
+ focusedRef.current = true;
125
+ };
126
+ const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
127
+ setDraft(e.target.value);
128
+ interactionChangedRef.current = true;
129
+ scheduleCommit(e.target.value);
130
+ };
131
+ const handleBlur = () => {
132
+ focusedRef.current = false;
133
+ commitDraft(draft);
134
+ };
135
+
136
+ if (flat) {
137
+ return (
138
+ <div className="border-l-2 border-panel-border-input py-0.5 pl-[10px]">
139
+ <div className="mb-[3px] text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
140
+ {label}
141
+ </div>
142
+ <textarea
143
+ ref={textareaRef}
144
+ value={draft}
145
+ disabled={disabled}
146
+ rows={2}
147
+ onFocus={handleFocus}
148
+ onChange={handleChange}
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"
151
+ />
152
+ </div>
153
+ );
154
+ }
155
+
108
156
  return (
109
157
  <label className="grid min-w-0 gap-1.5">
110
158
  <span className={LABEL}>{label}</span>
@@ -114,17 +162,9 @@ function TextAreaField({
114
162
  value={draft}
115
163
  disabled={disabled}
116
164
  rows={4}
117
- onFocus={() => {
118
- focusedRef.current = true;
119
- }}
120
- onChange={(e) => {
121
- setDraft(e.target.value);
122
- scheduleCommit(e.target.value);
123
- }}
124
- onBlur={() => {
125
- focusedRef.current = false;
126
- commitDraft(draft);
127
- }}
165
+ onFocus={handleFocus}
166
+ onChange={handleChange}
167
+ onBlur={handleBlur}
128
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"
129
169
  />
130
170
  </div>
@@ -143,6 +183,7 @@ function FontWeightField({
143
183
  fontFamily?: string;
144
184
  onCommit: (nextValue: string) => void;
145
185
  }) {
186
+ const track = useTrackDesignInput();
146
187
  const options = fontFamily ? detectAvailableWeights(fontFamily) : ALL_WEIGHTS;
147
188
  const displayOptions = value && !options.includes(value) ? [value, ...options] : options;
148
189
  return (
@@ -152,7 +193,10 @@ function FontWeightField({
152
193
  <select
153
194
  value={value}
154
195
  disabled={disabled}
155
- onChange={(e) => onCommit(e.target.value)}
196
+ onChange={(e) => {
197
+ track("select", "Weight");
198
+ onCommit(e.target.value);
199
+ }}
156
200
  className="min-w-0 w-full appearance-none bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600"
157
201
  >
158
202
  {displayOptions.map((o) => (
@@ -262,6 +306,7 @@ function TextFieldEditor({
262
306
  onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
263
307
  onRemoveTextField: (fieldKey: string) => void;
264
308
  }) {
309
+ const track = useTrackDesignInput();
265
310
  return (
266
311
  <div className="space-y-3">
267
312
  <div className={showRemove ? "flex min-w-0 items-center justify-between gap-2" : "min-w-0"}>
@@ -274,7 +319,10 @@ function TextFieldEditor({
274
319
  {showRemove && (
275
320
  <button
276
321
  type="button"
277
- onClick={() => onRemoveTextField(field.key)}
322
+ onClick={() => {
323
+ track("button", "Remove text field");
324
+ onRemoveTextField(field.key);
325
+ }}
278
326
  className="inline-flex h-7 flex-shrink-0 items-center rounded-lg border border-neutral-700 bg-neutral-950 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white"
279
327
  >
280
328
  Remove
@@ -356,6 +404,7 @@ export function TextSection({
356
404
  onSetTextFieldStyle,
357
405
  onAddTextField,
358
406
  onRemoveTextField,
407
+ hideOwnHeading = false,
359
408
  }: {
360
409
  element: DomEditSelection;
361
410
  styles: Record<string, string>;
@@ -365,7 +414,13 @@ export function TextSection({
365
414
  onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
366
415
  onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
367
416
  onRemoveTextField: (fieldKey: string) => void;
417
+ /** Skip TextSection's own "Text" Section heading/wrapper — for callers (the
418
+ * flat inspector's multi-field fallback) that already render their own
419
+ * "Text" heading one level up, to avoid a doubled heading. Defaults to
420
+ * false so the legacy (non-flat) call site is unaffected. */
421
+ hideOwnHeading?: boolean;
368
422
  }) {
423
+ const track = useTrackDesignInput();
369
424
  const hasTextControls = isTextEditableSelection(element);
370
425
  const [activeTextFieldKey, setActiveTextFieldKey] = useState<string | null>(
371
426
  element.textFields[0]?.key ?? null,
@@ -386,85 +441,94 @@ export function TextSection({
386
441
  if (!activeField) return null;
387
442
 
388
443
  if (textFields.length === 1) {
444
+ const content = (
445
+ <TextFieldEditor
446
+ field={activeField}
447
+ styles={styles}
448
+ fontAssets={fontAssets}
449
+ onImportFonts={onImportFonts}
450
+ showRemove={false}
451
+ onSetText={onSetText}
452
+ onSetTextFieldStyle={onSetTextFieldStyle}
453
+ onRemoveTextField={onRemoveTextField}
454
+ />
455
+ );
456
+ if (hideOwnHeading) return content;
389
457
  return (
390
458
  <Section title="Text" icon={<Type size={15} />} defaultCollapsed>
391
- <TextFieldEditor
392
- field={activeField}
393
- styles={styles}
394
- fontAssets={fontAssets}
395
- onImportFonts={onImportFonts}
396
- showRemove={false}
397
- onSetText={onSetText}
398
- onSetTextFieldStyle={onSetTextFieldStyle}
399
- onRemoveTextField={onRemoveTextField}
400
- />
459
+ {content}
401
460
  </Section>
402
461
  );
403
462
  }
404
463
 
405
- return (
406
- <Section title="Text" icon={<Type size={15} />}>
407
- <div className="space-y-4">
408
- <div className="grid gap-1.5">
409
- <div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
410
- <span className={LABEL}>Text layers</span>
411
- <button
412
- type="button"
413
- onClick={() => {
414
- void Promise.resolve(onAddTextField(activeField.key)).then((nextKey) => {
415
- if (nextKey) setActiveTextFieldKey(nextKey);
416
- });
417
- }}
418
- className="inline-flex h-7 max-w-full items-center gap-1.5 rounded-lg border border-neutral-700 bg-neutral-950 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white"
419
- >
420
- <Plus size={12} className="flex-shrink-0" />
421
- <span className="truncate">Add text</span>
422
- </button>
423
- </div>
424
- <div className="grid gap-2">
425
- {textFields.map((field, index) => {
426
- const active = field.key === activeField.key;
427
- return (
428
- <button
429
- key={field.key}
430
- type="button"
431
- onClick={() => setActiveTextFieldKey(field.key)}
432
- className={`min-w-0 w-full rounded-xl border px-3 py-2 text-left transition-colors ${
433
- active
434
- ? "border-studio-accent/50 bg-studio-accent/10"
435
- : "border-neutral-800 bg-neutral-900/80 hover:border-neutral-700 hover:bg-neutral-900"
436
- }`}
437
- >
438
- <div className="flex min-w-0 items-center justify-between gap-2">
439
- <div className="flex min-w-0 items-center gap-2">
440
- <span
441
- className="h-4 w-4 flex-shrink-0 rounded border border-neutral-700 bg-neutral-950"
442
- style={{ backgroundColor: getTextFieldColor(field, styles) }}
443
- />
444
- <span className="min-w-0 truncate text-[11px] font-medium text-neutral-100">
445
- {formatTextFieldPreview(field.value) || `Text ${index + 1}`}
446
- </span>
447
- </div>
448
- <span className="flex-shrink-0 rounded-md border border-neutral-700 bg-neutral-950 px-1.5 py-0.5 text-[10px] text-neutral-500">
449
- {field.tagName}
464
+ const content = (
465
+ <div className="space-y-4">
466
+ <div className="grid gap-1.5">
467
+ <div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
468
+ <span className={LABEL}>Text layers</span>
469
+ <button
470
+ type="button"
471
+ onClick={() => {
472
+ track("button", "Add text field");
473
+ void Promise.resolve(onAddTextField(activeField.key)).then((nextKey) => {
474
+ if (nextKey) setActiveTextFieldKey(nextKey);
475
+ });
476
+ }}
477
+ className="inline-flex h-7 max-w-full items-center gap-1.5 rounded-lg border border-neutral-700 bg-neutral-950 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white"
478
+ >
479
+ <Plus size={12} className="flex-shrink-0" />
480
+ <span className="truncate">Add text</span>
481
+ </button>
482
+ </div>
483
+ <div className="grid gap-2">
484
+ {textFields.map((field, index) => {
485
+ const active = field.key === activeField.key;
486
+ return (
487
+ <button
488
+ key={field.key}
489
+ type="button"
490
+ onClick={() => setActiveTextFieldKey(field.key)}
491
+ className={`min-w-0 w-full rounded-xl border px-3 py-2 text-left transition-colors ${
492
+ active
493
+ ? "border-studio-accent/50 bg-studio-accent/10"
494
+ : "border-neutral-800 bg-neutral-900/80 hover:border-neutral-700 hover:bg-neutral-900"
495
+ }`}
496
+ >
497
+ <div className="flex min-w-0 items-center justify-between gap-2">
498
+ <div className="flex min-w-0 items-center gap-2">
499
+ <span
500
+ className="h-4 w-4 flex-shrink-0 rounded border border-neutral-700 bg-neutral-950"
501
+ style={{ backgroundColor: getTextFieldColor(field, styles) }}
502
+ />
503
+ <span className="min-w-0 truncate text-[11px] font-medium text-neutral-100">
504
+ {formatTextFieldPreview(field.value) || `Text ${index + 1}`}
450
505
  </span>
451
506
  </div>
452
- </button>
453
- );
454
- })}
455
- </div>
507
+ <span className="flex-shrink-0 rounded-md border border-neutral-700 bg-neutral-950 px-1.5 py-0.5 text-[10px] text-neutral-500">
508
+ {field.tagName}
509
+ </span>
510
+ </div>
511
+ </button>
512
+ );
513
+ })}
456
514
  </div>
457
- <TextFieldEditor
458
- field={activeField}
459
- styles={styles}
460
- fontAssets={fontAssets}
461
- onImportFonts={onImportFonts}
462
- showRemove={true}
463
- onSetText={onSetText}
464
- onSetTextFieldStyle={onSetTextFieldStyle}
465
- onRemoveTextField={onRemoveTextField}
466
- />
467
515
  </div>
516
+ <TextFieldEditor
517
+ field={activeField}
518
+ styles={styles}
519
+ fontAssets={fontAssets}
520
+ onImportFonts={onImportFonts}
521
+ showRemove={true}
522
+ onSetText={onSetText}
523
+ onSetTextFieldStyle={onSetTextFieldStyle}
524
+ onRemoveTextField={onRemoveTextField}
525
+ />
526
+ </div>
527
+ );
528
+ if (hideOwnHeading) return content;
529
+ return (
530
+ <Section title="Text" icon={<Type size={15} />}>
531
+ {content}
468
532
  </Section>
469
533
  );
470
534
  }
@@ -47,6 +47,7 @@ export function StyleSections({
47
47
  onSetStyle,
48
48
  onImportAssets,
49
49
  gsapBorderRadius,
50
+ hideFlex = false,
50
51
  }: {
51
52
  projectId: string;
52
53
  element: DomEditSelection;
@@ -55,6 +56,10 @@ export function StyleSections({
55
56
  onSetStyle: (prop: string, value: string) => void | Promise<void>;
56
57
  onImportAssets?: (files: FileList) => Promise<string[]>;
57
58
  gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
59
+ // When true, the Flex `Section` is suppressed. The flat inspector renders
60
+ // its own Flex controls inside the Layout group (LayoutFlexBlock), so the
61
+ // flat path passes this to avoid a double-render. Non-flat callers omit it.
62
+ hideFlex?: boolean;
58
63
  }) {
59
64
  const styleEditingDisabled = !element.capabilities.canEditStyles;
60
65
  const isFlex = styles.display === "flex" || styles.display === "inline-flex";
@@ -145,10 +150,11 @@ export function StyleSections({
145
150
 
146
151
  return (
147
152
  <>
148
- {isFlex && (
153
+ {isFlex && !hideFlex && (
149
154
  <Section title="Flex" icon={<Layers size={15} />} defaultCollapsed>
150
155
  <div className="space-y-4">
151
156
  <SegmentedControl
157
+ trackName="Flex direction"
152
158
  disabled={styleEditingDisabled}
153
159
  value={styles["flex-direction"] || "row"}
154
160
  onChange={(next) => onSetStyle("flex-direction", next)}
@@ -293,6 +299,7 @@ export function StyleSections({
293
299
  <div className="grid min-w-0 gap-1.5">
294
300
  <span className={LABEL}>Layer blur</span>
295
301
  <SliderControl
302
+ trackName="Layer blur"
296
303
  value={filterBlurValue}
297
304
  min={0}
298
305
  max={Math.max(40, Math.ceil(filterBlurValue))}
@@ -308,6 +315,7 @@ export function StyleSections({
308
315
  <div className="grid min-w-0 gap-1.5">
309
316
  <span className={LABEL}>Backdrop</span>
310
317
  <SliderControl
318
+ trackName="Backdrop blur"
311
319
  value={backdropBlurValue}
312
320
  min={0}
313
321
  max={Math.max(60, Math.ceil(backdropBlurValue))}
@@ -358,6 +366,7 @@ export function StyleSections({
358
366
  <div className="grid min-w-0 gap-1.5">
359
367
  <span className={LABEL}>Mask inset</span>
360
368
  <SliderControl
369
+ trackName="Mask inset"
361
370
  value={clipInsetValue}
362
371
  min={0}
363
372
  max={Math.max(120, Math.ceil(clipInsetValue))}
@@ -406,6 +415,7 @@ export function StyleSections({
406
415
  <Section title="Transparency" icon={<Eye size={15} />} defaultCollapsed>
407
416
  <div className="space-y-4">
408
417
  <SliderControl
418
+ trackName="Opacity"
409
419
  value={opacityValue}
410
420
  min={0}
411
421
  max={100}
@@ -428,6 +438,7 @@ export function StyleSections({
428
438
  <Section title="Fill" icon={<Palette size={15} />}>
429
439
  <div className="space-y-4">
430
440
  <SegmentedControl
441
+ trackName="Fill type"
431
442
  disabled={styleEditingDisabled}
432
443
  value={preferredFillMode}
433
444
  onChange={handleFillModeChange}
@@ -4,7 +4,7 @@ import type { DomEditSelection } from "./domEditing";
4
4
  import { formatTimingValue, RESPONSIVE_GRID } from "./propertyPanelHelpers";
5
5
  import { MetricField, Section } from "./propertyPanelPrimitives";
6
6
 
7
- function parseTimingValue(input: string): number | null {
7
+ export function parseTimingValue(input: string): number | null {
8
8
  const cleaned = input.replace(/s$/i, "").trim();
9
9
  const parsed = Number.parseFloat(cleaned);
10
10
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
@@ -25,12 +25,25 @@ export interface PropertyPanelProps {
25
25
  assets: string[];
26
26
  element: DomEditSelection | null;
27
27
  multiSelectCount?: number;
28
+ multiSelectedElements?: DomEditSelection[];
29
+ onGroupSelection?: () => void;
30
+ onHideAllSelected?: () => void;
28
31
  copiedAgentPrompt: boolean;
29
32
  onClearSelection: () => void;
30
33
  onUngroup?: () => void;
31
34
  onSetStyle: (prop: string, value: string) => void | Promise<void>;
32
35
  onSetAttribute: (attr: string, value: string) => void | Promise<void>;
33
- onSetAttributeLive: (attr: string, value: string | null) => void | Promise<void>;
36
+ /** Commits several data-* attributes on the SAME element in ONE atomic
37
+ * persist call — e.g. a pinned timing range's start+duration together, so
38
+ * a selection change or a partial failure mid-commit can't misdirect one
39
+ * of the two writes or leave them half-applied. Falls back to sequential
40
+ * `onSetAttribute` calls where omitted. */
41
+ onSetAttributes?: (selection: DomEditSelection, attrs: Record<string, string>) => Promise<void>;
42
+ onSetAttributeLive: (
43
+ attr: string,
44
+ value: string | null,
45
+ onSettled?: (ok: boolean) => void,
46
+ ) => void | Promise<void>;
34
47
  onApplyColorGradingScope?: (
35
48
  scope: "source-file" | "project",
36
49
  value: string | null,
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ resolveValueTier,
4
+ VALUE_TIER_LABEL_CLASS,
5
+ VALUE_TIER_VALUE_CLASS,
6
+ } from "./propertyPanelValueTier";
7
+
8
+ describe("resolveValueTier", () => {
9
+ it("is 'default' when there is no explicit declaration", () => {
10
+ expect(resolveValueTier(undefined, "400")).toBe("default");
11
+ expect(resolveValueTier("", "400")).toBe("default");
12
+ });
13
+
14
+ it("is 'explicitDefault' when the explicit value equals the default", () => {
15
+ expect(resolveValueTier("400", "400")).toBe("explicitDefault");
16
+ expect(resolveValueTier(" normal ", "normal")).toBe("explicitDefault");
17
+ });
18
+
19
+ it("is 'explicitCustom' when the explicit value differs from the default", () => {
20
+ expect(resolveValueTier("3.96px", "0px")).toBe("explicitCustom");
21
+ });
22
+ });
23
+
24
+ describe("value tier class maps", () => {
25
+ it("covers all three tiers for both label and value", () => {
26
+ for (const tier of ["default", "explicitDefault", "explicitCustom"] as const) {
27
+ expect(VALUE_TIER_LABEL_CLASS[tier]).toBeTruthy();
28
+ expect(VALUE_TIER_VALUE_CLASS[tier]).toBeTruthy();
29
+ }
30
+ expect(VALUE_TIER_VALUE_CLASS.explicitCustom).toBe("text-panel-accent");
31
+ });
32
+ });
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The flat inspector's 3-state value coloring (design_handoff_studio_inspector,
3
+ * verified against Studio Panel Redesign.dc.html #10a): a property row is either
4
+ * unset (no explicit declaration), explicitly declared but equal to its default
5
+ * (no visual "set" signal), or explicitly declared and different from its default
6
+ * (mint value + emphasized label + reset affordance).
7
+ */
8
+ export type PropertyValueTier = "default" | "explicitDefault" | "explicitCustom";
9
+
10
+ export function resolveValueTier(
11
+ explicitValue: string | undefined,
12
+ defaultValue: string,
13
+ ): PropertyValueTier {
14
+ if (explicitValue == null || explicitValue.trim() === "") return "default";
15
+ return explicitValue.trim() === defaultValue.trim() ? "explicitDefault" : "explicitCustom";
16
+ }
17
+
18
+ export const VALUE_TIER_LABEL_CLASS: Record<PropertyValueTier, string> = {
19
+ default: "text-panel-text-3",
20
+ explicitDefault: "text-panel-text-2",
21
+ explicitCustom: "text-panel-text-0",
22
+ };
23
+
24
+ export const VALUE_TIER_VALUE_CLASS: Record<PropertyValueTier, string> = {
25
+ default: "text-panel-text-3",
26
+ explicitDefault: "text-panel-text-0",
27
+ explicitCustom: "text-panel-accent",
28
+ };