@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
@@ -0,0 +1,407 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
3
+ import { Plus, X } from "../../icons/SystemIcons";
4
+ import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
5
+ import type { ImportedFontAsset } from "./fontAssets";
6
+ import { normalizeTextMetricValue } from "./propertyPanelHelpers";
7
+ import { ColorField } from "./propertyPanelColor";
8
+ import { FontFamilyField } from "./propertyPanelFont";
9
+ import { PromotableControl } from "./PromotableControl";
10
+ import { FlatRow, FlatSegmentedRow } from "./propertyPanelFlatPrimitives";
11
+ import {
12
+ resolveValueTier,
13
+ VALUE_TIER_LABEL_CLASS,
14
+ VALUE_TIER_VALUE_CLASS,
15
+ } from "./propertyPanelValueTier";
16
+ import {
17
+ detectAvailableWeights,
18
+ formatTextFieldPreview,
19
+ getTextFieldColor,
20
+ getTextStyleValue,
21
+ TextAreaField,
22
+ WEIGHT_LABELS,
23
+ } from "./propertyPanelSections";
24
+
25
+ /* ------------------------------------------------------------------ */
26
+ /* Flat text section (design_handoff_studio_inspector, #10a) */
27
+ /* ------------------------------------------------------------------ */
28
+
29
+ const ALIGN_OPTIONS = [
30
+ { key: "left", label: "left", node: "L" },
31
+ { key: "center", label: "center", node: "C" },
32
+ { key: "right", label: "right", node: "R" },
33
+ { key: "justify", label: "justify", node: "J" },
34
+ ];
35
+
36
+ const CASE_OPTIONS = [
37
+ { key: "none", label: "none", node: "–" },
38
+ { key: "uppercase", label: "uppercase", node: "AG" },
39
+ { key: "lowercase", label: "lowercase", node: "ag" },
40
+ { key: "capitalize", label: "capitalize", node: "Ag" },
41
+ ];
42
+
43
+ function FlatTextFieldEditor({
44
+ field,
45
+ styles,
46
+ fontAssets,
47
+ onImportFonts,
48
+ onSetText,
49
+ onSetTextFieldStyle,
50
+ autoFocus = false,
51
+ }: {
52
+ field: DomEditSelection["textFields"][number];
53
+ styles: Record<string, string>;
54
+ fontAssets: ImportedFontAsset[];
55
+ onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
56
+ onSetText: (value: string, fieldKey?: string) => void;
57
+ onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
58
+ autoFocus?: boolean;
59
+ }) {
60
+ const track = useTrackDesignInput();
61
+ const weight = getTextStyleValue(field, styles, "font-weight", "400");
62
+ const weightOptions = detectAvailableWeights(
63
+ field.computedStyles["font-family"] || styles["font-family"] || "",
64
+ );
65
+ const align = getTextStyleValue(field, styles, "text-align", "start");
66
+ const textTransform = getTextStyleValue(field, styles, "text-transform", "none");
67
+ const fontStyle = getTextStyleValue(field, styles, "font-style", "normal");
68
+
69
+ return (
70
+ <>
71
+ <PromotableControl channel={{ kind: "text" }} enabled={field.source === "self"}>
72
+ {({ value, onCommit }) => (
73
+ <TextAreaField
74
+ flat
75
+ label="Content"
76
+ value={value ?? field.value}
77
+ autoFocus={autoFocus}
78
+ onCommit={onCommit ?? ((next) => onSetText(next, field.key))}
79
+ />
80
+ )}
81
+ </PromotableControl>
82
+ <PromotableControl
83
+ channel={{ kind: "style", prop: "font-family" }}
84
+ enabled={field.source === "self"}
85
+ >
86
+ {({ value, onCommit }) => (
87
+ <FontFamilyField
88
+ flat
89
+ value={
90
+ value ?? (field.computedStyles["font-family"] || styles["font-family"] || "inherit")
91
+ }
92
+ importedFonts={fontAssets}
93
+ onImportFonts={onImportFonts}
94
+ onCommit={onCommit ?? ((next) => onSetTextFieldStyle(field.key, "font-family", next))}
95
+ />
96
+ )}
97
+ </PromotableControl>
98
+ <FlatRow
99
+ label="Size"
100
+ value={field.computedStyles["font-size"] || styles["font-size"] || "16px"}
101
+ tier={resolveValueTier(field.inlineStyles["font-size"], styles["font-size"] || "16px")}
102
+ liveCommit
103
+ onCommit={(next) => onSetTextFieldStyle(field.key, "font-size", next)}
104
+ />
105
+ <div className="flex min-h-[30px] items-center justify-between">
106
+ <span
107
+ className={
108
+ VALUE_TIER_LABEL_CLASS[resolveValueTier(field.inlineStyles["font-weight"], "400")]
109
+ }
110
+ style={{ fontSize: 11 }}
111
+ >
112
+ Weight
113
+ </span>
114
+ <label className="flex items-center gap-1.5 border-b border-panel-border-input/50 pb-px hover:border-panel-border-input">
115
+ <select
116
+ value={weight}
117
+ onChange={(e) => {
118
+ track("select", "Weight");
119
+ onSetTextFieldStyle(field.key, "font-weight", e.target.value);
120
+ }}
121
+ className={`appearance-none bg-transparent text-right font-mono text-[11px] outline-none ${
122
+ VALUE_TIER_VALUE_CLASS[resolveValueTier(field.inlineStyles["font-weight"], "400")]
123
+ }`}
124
+ >
125
+ {(weightOptions.includes(weight) ? weightOptions : [weight, ...weightOptions]).map(
126
+ (option) => (
127
+ <option key={option} value={option}>
128
+ {WEIGHT_LABELS[option] ?? option}
129
+ </option>
130
+ ),
131
+ )}
132
+ </select>
133
+ <svg
134
+ width="10"
135
+ height="10"
136
+ viewBox="0 0 10 10"
137
+ fill="currentColor"
138
+ className="flex-shrink-0 text-panel-text-5"
139
+ >
140
+ <path d="M2 3l3 4 3-4z" />
141
+ </svg>
142
+ </label>
143
+ </div>
144
+ <FlatRow
145
+ label="Letter spacing"
146
+ value={getTextStyleValue(field, styles, "letter-spacing", "0px")}
147
+ tier={resolveValueTier(field.inlineStyles["letter-spacing"], "0px")}
148
+ onCommit={(next) =>
149
+ onSetTextFieldStyle(
150
+ field.key,
151
+ "letter-spacing",
152
+ normalizeTextMetricValue("letter-spacing", next),
153
+ )
154
+ }
155
+ onReset={() => onSetTextFieldStyle(field.key, "letter-spacing", "")}
156
+ />
157
+ <FlatRow
158
+ label="Line height"
159
+ value={getTextStyleValue(field, styles, "line-height", "normal")}
160
+ tier={resolveValueTier(field.inlineStyles["line-height"], "normal")}
161
+ onCommit={(next) =>
162
+ onSetTextFieldStyle(
163
+ field.key,
164
+ "line-height",
165
+ normalizeTextMetricValue("line-height", next),
166
+ )
167
+ }
168
+ onReset={() => onSetTextFieldStyle(field.key, "line-height", "")}
169
+ />
170
+ <FlatSegmentedRow
171
+ label="Align"
172
+ options={ALIGN_OPTIONS.map((option) => ({
173
+ key: option.key,
174
+ node: option.node,
175
+ label: option.label,
176
+ active:
177
+ align === option.key ||
178
+ (option.key === "left" && align === "start") ||
179
+ (option.key === "right" && align === "end"),
180
+ }))}
181
+ onChange={(next) => {
182
+ // Re-clicking the option that's already visually active for a
183
+ // logical value (authored "start"/"end") must not rewrite it to
184
+ // the physical "left"/"right" — that destroys the logical
185
+ // semantics and is wrong for RTL content. Only write when the
186
+ // user actually picked a different alignment.
187
+ if ((next === "left" && align === "start") || (next === "right" && align === "end")) {
188
+ return;
189
+ }
190
+ onSetTextFieldStyle(field.key, "text-align", next);
191
+ }}
192
+ />
193
+ <FlatSegmentedRow
194
+ label="Case · Style"
195
+ options={[
196
+ ...CASE_OPTIONS.map((option) => ({
197
+ key: option.key,
198
+ node: option.node,
199
+ label: option.label,
200
+ active: textTransform === option.key,
201
+ })),
202
+ { key: "normal", node: "A", label: "upright", active: fontStyle === "normal" },
203
+ { key: "italic", node: "A", label: "italic", active: fontStyle === "italic" },
204
+ ]}
205
+ spacerAfterIndex={2}
206
+ onChange={(next) => {
207
+ if (next === "normal" || next === "italic") {
208
+ onSetTextFieldStyle(field.key, "font-style", next);
209
+ } else {
210
+ onSetTextFieldStyle(field.key, "text-transform", next);
211
+ }
212
+ }}
213
+ />
214
+ <PromotableControl
215
+ channel={{ kind: "style", prop: "color" }}
216
+ enabled={field.source === "self"}
217
+ >
218
+ {({ value, onCommit }) => (
219
+ <ColorField
220
+ flat
221
+ label="Color"
222
+ value={value ?? getTextFieldColor(field, styles)}
223
+ onCommit={onCommit ?? ((next) => onSetTextFieldStyle(field.key, "color", next))}
224
+ />
225
+ )}
226
+ </PromotableControl>
227
+ </>
228
+ );
229
+ }
230
+
231
+ export function FlatTextSection({
232
+ element,
233
+ styles,
234
+ fontAssets,
235
+ onImportFonts,
236
+ onSetText,
237
+ onSetTextFieldStyle,
238
+ onAddTextField,
239
+ onRemoveTextField,
240
+ }: {
241
+ element: DomEditSelection;
242
+ styles: Record<string, string>;
243
+ fontAssets: ImportedFontAsset[];
244
+ onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
245
+ onSetText: (value: string, fieldKey?: string) => void;
246
+ onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
247
+ onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
248
+ onRemoveTextField: (fieldKey: string) => void;
249
+ }) {
250
+ const track = useTrackDesignInput();
251
+ const [activeFieldKey, setActiveFieldKey] = useState<string | null>(
252
+ element.textFields[0]?.key ?? null,
253
+ );
254
+
255
+ useEffect(() => {
256
+ const nextFields = element.textFields;
257
+ setActiveFieldKey((current) => {
258
+ if (current && nextFields.some((field) => field.key === current)) return current;
259
+ return nextFields[0]?.key ?? null;
260
+ });
261
+ }, [element.id, element.selector, element.textFields]);
262
+
263
+ if (!isTextEditableSelection(element)) return null;
264
+ const textFields = element.textFields;
265
+ const activeField = textFields.find((field) => field.key === activeFieldKey) ?? textFields[0];
266
+ if (!activeField) return null;
267
+
268
+ if (textFields.length > 1) {
269
+ return (
270
+ <div className="space-y-1.5">
271
+ <FlatTextLayerList
272
+ fields={textFields}
273
+ activeFieldKey={activeField.key}
274
+ styles={styles}
275
+ onSelect={setActiveFieldKey}
276
+ onAdd={() =>
277
+ void Promise.resolve(onAddTextField(activeField.key)).then((nextKey) => {
278
+ if (nextKey) setActiveFieldKey(nextKey);
279
+ })
280
+ }
281
+ onRemove={onRemoveTextField}
282
+ />
283
+ <FlatTextFieldEditor
284
+ key={activeField.key}
285
+ field={activeField}
286
+ styles={styles}
287
+ fontAssets={fontAssets}
288
+ onImportFonts={onImportFonts}
289
+ onSetText={onSetText}
290
+ onSetTextFieldStyle={onSetTextFieldStyle}
291
+ autoFocus
292
+ />
293
+ </div>
294
+ );
295
+ }
296
+
297
+ return (
298
+ <div className="space-y-1.5">
299
+ <FlatTextFieldEditor
300
+ field={activeField}
301
+ styles={styles}
302
+ fontAssets={fontAssets}
303
+ onImportFonts={onImportFonts}
304
+ onSetText={onSetText}
305
+ onSetTextFieldStyle={onSetTextFieldStyle}
306
+ />
307
+ <button
308
+ type="button"
309
+ onClick={() => {
310
+ track("button", "Add text field");
311
+ void onAddTextField(activeField.key);
312
+ }}
313
+ className="mt-0.5 flex items-center gap-[5px] text-[10px] text-panel-text-4 hover:text-panel-text-2"
314
+ >
315
+ <Plus size={10} />
316
+ Add text field
317
+ </button>
318
+ </div>
319
+ );
320
+ }
321
+
322
+ /* ------------------------------------------------------------------ */
323
+ /* Multi-field layer list (design_handoff_studio_inspector, #10a — */
324
+ /* no mock exists for this row; layout originated by this plan, */
325
+ /* following the "left-rule nested content" convention established */
326
+ /* by Text's own content block, Motion's effect cards, and Media's */
327
+ /* cutout block. Flag for design review.) */
328
+ /* ------------------------------------------------------------------ */
329
+
330
+ export function FlatTextLayerList({
331
+ fields,
332
+ activeFieldKey,
333
+ styles,
334
+ onSelect,
335
+ onAdd,
336
+ onRemove,
337
+ }: {
338
+ fields: DomEditSelection["textFields"];
339
+ activeFieldKey: string;
340
+ styles: Record<string, string>;
341
+ onSelect: (fieldKey: string) => void;
342
+ onAdd: () => void;
343
+ onRemove: (fieldKey: string) => void;
344
+ }) {
345
+ const track = useTrackDesignInput();
346
+ return (
347
+ <div className="mb-2 border-l-2 border-panel-border-input py-0.5 pl-[10px]">
348
+ <div className="mb-1.5 text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
349
+ Text layers
350
+ </div>
351
+ <div className="space-y-1">
352
+ {fields.map((field, index) => {
353
+ const active = field.key === activeFieldKey;
354
+ return (
355
+ <div
356
+ key={field.key}
357
+ data-flat-text-layer-row="true"
358
+ data-active={active}
359
+ onClick={() => onSelect(field.key)}
360
+ className={`flex min-h-[26px] cursor-pointer items-center gap-2 rounded px-1 ${
361
+ active ? "bg-panel-accent/10" : "hover:bg-panel-hover"
362
+ }`}
363
+ >
364
+ <span
365
+ className="h-3 w-3 flex-shrink-0 rounded-sm"
366
+ style={{ backgroundColor: getTextFieldColor(field, styles) }}
367
+ />
368
+ <span className="min-w-0 flex-1 truncate text-[11px] text-panel-text-1">
369
+ {formatTextFieldPreview(field.value) || `Text ${index + 1}`}
370
+ </span>
371
+ <span className="flex-shrink-0 font-mono text-[9px] text-panel-text-4">
372
+ {field.tagName}
373
+ </span>
374
+ {fields.length > 1 && (
375
+ <button
376
+ type="button"
377
+ data-flat-text-layer-remove="true"
378
+ aria-label="Remove text field"
379
+ onClick={(e) => {
380
+ e.stopPropagation();
381
+ track("button", "Remove text field");
382
+ onRemove(field.key);
383
+ }}
384
+ className="flex-shrink-0 text-panel-text-4 hover:text-panel-text-1"
385
+ >
386
+ <X size={10} />
387
+ </button>
388
+ )}
389
+ </div>
390
+ );
391
+ })}
392
+ </div>
393
+ <button
394
+ type="button"
395
+ data-flat-text-layer-add="true"
396
+ onClick={() => {
397
+ track("button", "Add text field");
398
+ onAdd();
399
+ }}
400
+ className="mt-1 flex items-center gap-[5px] text-[10px] text-panel-text-4 hover:text-panel-text-2"
401
+ >
402
+ <Plus size={10} />
403
+ Add text field
404
+ </button>
405
+ </div>
406
+ );
407
+ }
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
3
+ import type { DomEditSelection } from "./domEditingTypes";
4
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
5
+
6
+ function withDataAttributes(
7
+ dataAttributes: Record<string, string>,
8
+ ): Pick<DomEditSelection, "dataAttributes"> {
9
+ return { dataAttributes };
10
+ }
11
+
12
+ describe("deriveElementTiming", () => {
13
+ it("uses the explicit data-start/data-duration attributes when duration is authored", () => {
14
+ const result = deriveElementTiming(withDataAttributes({ start: "8", duration: "4" }));
15
+ expect(result).toEqual({ start: 8, duration: 4, inferred: false });
16
+ });
17
+
18
+ it("infers start/duration from animations when there is no explicit data-duration", () => {
19
+ const animations = [{ position: 2, duration: 3 } as unknown as GsapAnimation];
20
+ const result = deriveElementTiming(
21
+ withDataAttributes({ start: "0", duration: "0" }),
22
+ animations,
23
+ );
24
+ expect(result).toEqual({ start: 2, duration: 3, inferred: true });
25
+ });
26
+
27
+ it("spans the earliest tween start to the latest tween end across multiple animations", () => {
28
+ const animations = [
29
+ { position: 1, duration: 2 } as unknown as GsapAnimation, // 1 -> 3
30
+ { position: 2, duration: 4 } as unknown as GsapAnimation, // 2 -> 6
31
+ ];
32
+ const result = deriveElementTiming(withDataAttributes({}), animations);
33
+ expect(result).toEqual({ start: 1, duration: 5, inferred: true });
34
+ });
35
+
36
+ it("prefers an explicit data-duration over inference even when animations exist", () => {
37
+ const animations = [{ position: 2, duration: 3 } as unknown as GsapAnimation];
38
+ const result = deriveElementTiming(
39
+ withDataAttributes({ start: "0", duration: "10" }),
40
+ animations,
41
+ );
42
+ expect(result).toEqual({ start: 0, duration: 10, inferred: false });
43
+ });
44
+
45
+ it("falls back to hf-authored-duration when data-duration is absent", () => {
46
+ const result = deriveElementTiming(
47
+ withDataAttributes({ start: "1", "hf-authored-duration": "6" }),
48
+ );
49
+ expect(result).toEqual({ start: 1, duration: 6, inferred: false });
50
+ });
51
+
52
+ it("returns a zero-duration, non-inferred result with no attributes and no animations", () => {
53
+ const result = deriveElementTiming(withDataAttributes({}));
54
+ expect(result).toEqual({ start: 0, duration: 0, inferred: false });
55
+ });
56
+
57
+ // This is the exact bug from the whole-plan coherence review: Layout's
58
+ // keyframe-seek basis must land on the same absolute time that Motion's
59
+ // Timing row displays as the element's midpoint.
60
+ it("agrees with a keyframe-percentage seek: 50% lands on the same midpoint the Timing row would show", () => {
61
+ const animations = [{ position: 2, duration: 3 } as unknown as GsapAnimation];
62
+ const timing = deriveElementTiming(
63
+ withDataAttributes({ start: "0", duration: "0" }),
64
+ animations,
65
+ );
66
+ const seekTimeAt50Pct = timing.start + (50 / 100) * timing.duration;
67
+ const timingRowMidpoint = timing.start + timing.duration / 2;
68
+ expect(seekTimeAt50Pct).toBe(timingRowMidpoint);
69
+ expect(seekTimeAt50Pct).toBe(3.5);
70
+ });
71
+ });
@@ -0,0 +1,59 @@
1
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2
+ import type { DomEditSelection } from "./domEditingTypes";
3
+
4
+ /**
5
+ * The single source of truth for an element's clip start/duration in the flat
6
+ * inspector. Both the Motion group's Timing row (`FlatTimingRow`) and the
7
+ * Layout group's keyframe gutter (fed via `elStart`/`elDuration` from
8
+ * `PropertyPanel.tsx` through `PropertyPanelFlat.tsx`) must derive this the
9
+ * same way — otherwise a keyframe-percentage seek in Layout lands on a
10
+ * different absolute time than the range Motion displays for the same
11
+ * element (found by the Plan 3a+3b whole-plan coherence review).
12
+ *
13
+ * Precedence: an explicit `data-duration` (or `data-hf-authored-duration`)
14
+ * wins outright. Only when neither is present do we infer the range from the
15
+ * element's own GSAP tweens (earliest tween start → latest tween end).
16
+ *
17
+ * Scoped to the FLAT inspector only — the legacy (non-flat) panel keeps its
18
+ * own, unrelated `elStart`/`elDuration ?? 1` computation in `PropertyPanel.tsx`
19
+ * untouched.
20
+ */
21
+ export interface ElementTiming {
22
+ start: number;
23
+ duration: number;
24
+ /** True when duration/start came from `deriveTimingFromAnimations`, not an authored attribute. */
25
+ inferred: boolean;
26
+ }
27
+
28
+ function deriveTimingFromAnimations(
29
+ animations: GsapAnimation[],
30
+ ): { start: number; duration: number } | null {
31
+ let lo = Infinity;
32
+ let hi = -Infinity;
33
+ for (const a of animations) {
34
+ const s = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0);
35
+ const d = a.duration ?? 0;
36
+ lo = Math.min(lo, s);
37
+ hi = Math.max(hi, s + d);
38
+ }
39
+ if (!Number.isFinite(lo) || !Number.isFinite(hi) || hi <= lo) return null;
40
+ return { start: lo, duration: hi - lo };
41
+ }
42
+
43
+ export function deriveElementTiming(
44
+ element: Pick<DomEditSelection, "dataAttributes">,
45
+ animations: GsapAnimation[] = [],
46
+ ): ElementTiming {
47
+ const explicitStart = Number.parseFloat(element.dataAttributes.start ?? "0") || 0;
48
+ const explicitDuration =
49
+ Number.parseFloat(
50
+ element.dataAttributes.duration ?? element.dataAttributes["hf-authored-duration"] ?? "0",
51
+ ) || 0;
52
+
53
+ const derived = explicitDuration > 0 ? null : deriveTimingFromAnimations(animations);
54
+ return {
55
+ start: derived ? derived.start : explicitStart,
56
+ duration: derived ? derived.duration : explicitDuration,
57
+ inferred: derived !== null,
58
+ };
59
+ }
@@ -0,0 +1,63 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { FlatToggle } from "./propertyPanelFlatToggle";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ afterEach(() => {
11
+ document.body.innerHTML = "";
12
+ });
13
+
14
+ function renderInto(node: React.ReactElement) {
15
+ const host = document.createElement("div");
16
+ document.body.append(host);
17
+ const root = createRoot(host);
18
+ act(() => {
19
+ root.render(node);
20
+ });
21
+ return { host, root };
22
+ }
23
+
24
+ describe("FlatToggle", () => {
25
+ it("renders the off state with a dim label and dim knob, and fires onChange(true) on click", () => {
26
+ const onChange = vi.fn();
27
+ const { host, root } = renderInto(
28
+ <FlatToggle label="Loop" checked={false} onChange={onChange} />,
29
+ );
30
+ const label = host.querySelector('[data-flat-toggle-label="true"]');
31
+ expect(label?.className).toContain("text-panel-text-3");
32
+ const pill = host.querySelector<HTMLButtonElement>('[data-flat-toggle="true"]');
33
+ expect(pill).not.toBeNull();
34
+ act(() => pill?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
35
+ expect(onChange).toHaveBeenCalledWith(true);
36
+ act(() => root.unmount());
37
+ });
38
+
39
+ it("renders the on state with an emphasized label and mint knob, and fires onChange(false) on click", () => {
40
+ const onChange = vi.fn();
41
+ const { host, root } = renderInto(<FlatToggle label="Loop" checked onChange={onChange} />);
42
+ const label = host.querySelector('[data-flat-toggle-label="true"]');
43
+ expect(label?.className).toContain("text-panel-text-2");
44
+ const knob = host.querySelector('[data-flat-toggle-knob="true"]');
45
+ expect(knob?.className).toContain("bg-panel-accent");
46
+ const pill = host.querySelector<HTMLButtonElement>('[data-flat-toggle="true"]');
47
+ act(() => pill?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
48
+ expect(onChange).toHaveBeenCalledWith(false);
49
+ act(() => root.unmount());
50
+ });
51
+
52
+ it("does not fire onChange when disabled", () => {
53
+ const onChange = vi.fn();
54
+ const { host, root } = renderInto(
55
+ <FlatToggle label="Loop" checked={false} disabled onChange={onChange} />,
56
+ );
57
+ const pill = host.querySelector<HTMLButtonElement>('[data-flat-toggle="true"]');
58
+ expect(pill?.disabled).toBe(true);
59
+ act(() => pill?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
60
+ expect(onChange).not.toHaveBeenCalled();
61
+ act(() => root.unmount());
62
+ });
63
+ });
@@ -0,0 +1,53 @@
1
+ /* ------------------------------------------------------------------ */
2
+ /* FlatToggle — 24×14 pill switch */
3
+ /* (split out of propertyPanelFlatPrimitives.tsx to stay under the */
4
+ /* 600-line file-size gate) */
5
+ /* ------------------------------------------------------------------ */
6
+
7
+ import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
8
+
9
+ export function FlatToggle({
10
+ label,
11
+ checked,
12
+ disabled,
13
+ onChange,
14
+ }: {
15
+ label: string;
16
+ checked: boolean;
17
+ disabled?: boolean;
18
+ onChange: (next: boolean) => void;
19
+ }) {
20
+ const track = useTrackDesignInput();
21
+ return (
22
+ <div className="flex min-h-[30px] items-center justify-between">
23
+ <span
24
+ data-flat-toggle-label="true"
25
+ className={`text-[11px] ${checked ? "text-panel-text-2" : "text-panel-text-3"}`}
26
+ >
27
+ {label}
28
+ </span>
29
+ <button
30
+ type="button"
31
+ data-flat-toggle="true"
32
+ role="switch"
33
+ aria-checked={checked}
34
+ aria-label={label}
35
+ disabled={disabled}
36
+ onClick={() => {
37
+ track("toggle", label);
38
+ onChange(!checked);
39
+ }}
40
+ className={`relative h-[14px] w-6 flex-shrink-0 rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
41
+ checked ? "bg-panel-accent/35" : "bg-panel-hover"
42
+ }`}
43
+ >
44
+ <span
45
+ data-flat-toggle-knob="true"
46
+ className={`absolute top-0.5 h-2.5 w-2.5 rounded-full transition-all ${
47
+ checked ? "right-0.5 bg-panel-accent" : "left-0.5 bg-panel-text-4"
48
+ }`}
49
+ />
50
+ </button>
51
+ </div>
52
+ );
53
+ }
@@ -0,0 +1,30 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { FontFamilyField } from "./propertyPanelFont";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ afterEach(() => {
11
+ document.body.innerHTML = "";
12
+ });
13
+
14
+ describe("FontFamilyField flat trigger", () => {
15
+ it("renders as a label/value row with a trailing dropdown caret, no boxed border", () => {
16
+ const host = document.createElement("div");
17
+ document.body.append(host);
18
+ const root = createRoot(host);
19
+ act(() => {
20
+ root.render(
21
+ <FontFamilyField flat value="JetBrains Mono" importedFonts={[]} onCommit={vi.fn()} />,
22
+ );
23
+ });
24
+ const trigger = host.querySelector<HTMLButtonElement>('[data-flat-font-trigger="true"]');
25
+ expect(trigger).not.toBeNull();
26
+ expect(trigger?.className).not.toContain("border-neutral-800");
27
+ expect(host.textContent).toContain("JetBrains Mono");
28
+ act(() => root.unmount());
29
+ });
30
+ });