@hyperframes/studio 0.7.57 → 0.7.59

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 (247) hide show
  1. package/dist/assets/{hyperframes-player--Z69cEkE.js → hyperframes-player-Bm07FLkl.js} +1 -1
  2. package/dist/assets/{index-KsfE1bUu.js → index-B995FG46.js} +1 -1
  3. package/dist/assets/index-CrkAdJkb.js +426 -0
  4. package/dist/assets/index-Dj5p8U_A.css +1 -0
  5. package/dist/assets/{index-Bf1x1y8H.js → index-FvzmPhfG.js} +1 -1
  6. package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
  7. package/dist/chunk-OBAG3GWK.js.map +1 -0
  8. package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
  9. package/dist/index.d.ts +27 -7
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +18029 -11563
  12. package/dist/index.js.map +1 -1
  13. package/dist/styles/tailwind-preset.d.ts +5 -0
  14. package/dist/styles/tailwind-preset.js +8 -1
  15. package/dist/styles/tailwind-preset.js.map +1 -1
  16. package/package.json +7 -7
  17. package/src/App.tsx +6 -6
  18. package/src/components/DesignPanelPromoteProvider.tsx +15 -2
  19. package/src/components/StudioFeedbackBar.tsx +2 -2
  20. package/src/components/StudioRightPanel.tsx +29 -51
  21. package/src/components/editor/AnimationCard.test.tsx +134 -0
  22. package/src/components/editor/AnimationCard.tsx +19 -4
  23. package/src/components/editor/ArcPathControls.tsx +1 -0
  24. package/src/components/editor/CanvasContextMenu.test.tsx +27 -14
  25. package/src/components/editor/CanvasContextMenu.tsx +71 -6
  26. package/src/components/editor/DomEditOverlay.tsx +5 -2
  27. package/src/components/editor/GestureRecordControl.tsx +7 -1
  28. package/src/components/editor/GsapAnimationSection.tsx +109 -14
  29. package/src/components/editor/InspectorHeaderActions.tsx +42 -4
  30. package/src/components/editor/LayersPanel.tsx +110 -99
  31. package/src/components/editor/PropertyPanel.test.tsx +948 -0
  32. package/src/components/editor/PropertyPanel.tsx +144 -148
  33. package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
  34. package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
  35. package/src/components/editor/PropertyPanelFlat.tsx +580 -0
  36. package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
  37. package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
  38. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
  39. package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
  40. package/src/components/editor/canvasContextMenuZOrder.test.ts +57 -0
  41. package/src/components/editor/canvasContextMenuZOrder.ts +40 -1
  42. package/src/components/editor/domEditingDom.ts +5 -6
  43. package/src/components/editor/domEditingElement.ts +15 -32
  44. package/src/components/editor/gsapAnimationCallbacks.ts +27 -0
  45. package/src/components/editor/gsapLivePreview.ts +34 -0
  46. package/src/components/editor/layersPanelSort.ts +80 -0
  47. package/src/components/editor/manualEditingAvailability.test.ts +10 -0
  48. package/src/components/editor/manualEditingAvailability.ts +9 -0
  49. package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
  50. package/src/components/editor/propertyPanelColor.test.tsx +28 -0
  51. package/src/components/editor/propertyPanelColor.tsx +31 -1
  52. package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
  53. package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
  54. package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
  55. package/src/components/editor/propertyPanelFill.tsx +22 -5
  56. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
  57. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
  58. package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
  59. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
  60. package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
  61. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
  62. package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
  63. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
  64. package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
  65. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
  66. package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
  67. package/src/components/editor/propertyPanelFlatSelectRow.tsx +101 -0
  68. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
  69. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
  70. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
  71. package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
  72. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
  73. package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
  74. package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
  75. package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
  76. package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
  77. package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
  78. package/src/components/editor/propertyPanelFont.test.tsx +30 -0
  79. package/src/components/editor/propertyPanelFont.tsx +136 -95
  80. package/src/components/editor/propertyPanelHelpers.ts +73 -0
  81. package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
  82. package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
  83. package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
  84. package/src/components/editor/propertyPanelSections.test.tsx +161 -0
  85. package/src/components/editor/propertyPanelSections.tsx +154 -90
  86. package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
  87. package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
  88. package/src/components/editor/propertyPanelTypes.ts +14 -1
  89. package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
  90. package/src/components/editor/propertyPanelValueTier.ts +28 -0
  91. package/src/components/editor/useColorGradingController.test.ts +418 -0
  92. package/src/components/editor/useColorGradingController.ts +596 -0
  93. package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
  94. package/src/components/editor/useLayerRevealOverride.ts +389 -0
  95. package/src/components/nle/PreviewOverlays.tsx +27 -2
  96. package/src/components/nle/TimelinePane.test.ts +3 -0
  97. package/src/components/nle/TimelinePane.tsx +11 -1
  98. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
  99. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
  100. package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
  101. package/src/components/nle/zLaneGesture.test.ts +176 -0
  102. package/src/components/nle/zLaneGesture.ts +77 -0
  103. package/src/components/panels/VariablesPanel.tsx +4 -0
  104. package/src/components/sidebar/CompositionsTab.test.ts +28 -2
  105. package/src/components/sidebar/CompositionsTab.tsx +3 -1
  106. package/src/components/storyboard/FramePoster.tsx +24 -4
  107. package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
  108. package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
  109. package/src/components/storyboard/StoryboardGrid.tsx +23 -1
  110. package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
  111. package/src/components/storyboard/StoryboardView.tsx +9 -2
  112. package/src/components/storyboard/frameComments.test.ts +95 -0
  113. package/src/components/storyboard/frameComments.ts +115 -0
  114. package/src/components/storyboard/useFrameComments.ts +82 -0
  115. package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
  116. package/src/contexts/DesignPanelInputContext.tsx +48 -0
  117. package/src/contexts/DomEditContext.tsx +4 -0
  118. package/src/contexts/VariablePromoteContext.test.tsx +67 -0
  119. package/src/contexts/VariablePromoteContext.tsx +10 -5
  120. package/src/hooks/domEditCommitRunner.ts +11 -0
  121. package/src/hooks/domEditCommitTypes.ts +17 -1
  122. package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
  123. package/src/hooks/fetchStubTestUtils.ts +16 -0
  124. package/src/hooks/gsapScriptCommitTypes.ts +6 -4
  125. package/src/hooks/timelineEditingHelpers.test.ts +92 -1
  126. package/src/hooks/timelineEditingHelpers.ts +32 -15
  127. package/src/hooks/timelineMoveAdapter.ts +2 -1
  128. package/src/hooks/timelineTimingSync.test.ts +703 -33
  129. package/src/hooks/timelineTimingSync.ts +282 -97
  130. package/src/hooks/timelineTrackVisibility.test.ts +55 -1
  131. package/src/hooks/timelineTrackVisibility.ts +31 -13
  132. package/src/hooks/useAppHotkeys.ts +9 -0
  133. package/src/hooks/useClipboard.ts +19 -17
  134. package/src/hooks/useDomEditAttributeCommits.ts +123 -1
  135. package/src/hooks/useDomEditCommits.test.tsx +316 -38
  136. package/src/hooks/useDomEditCommits.ts +81 -162
  137. package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
  138. package/src/hooks/useDomEditSession.test.tsx +3 -1
  139. package/src/hooks/useDomEditSession.ts +18 -6
  140. package/src/hooks/useDomEditTextCommits.ts +14 -9
  141. package/src/hooks/useDomEditWiring.ts +10 -9
  142. package/src/hooks/useElementLifecycleOps.test.tsx +426 -60
  143. package/src/hooks/useElementLifecycleOps.ts +182 -84
  144. package/src/hooks/useFileManager.projectOwnership.test.tsx +98 -0
  145. package/src/hooks/useFileManager.ts +82 -60
  146. package/src/hooks/useGsapAnimationOps.ts +7 -6
  147. package/src/hooks/useGsapKeyframeOps.ts +6 -5
  148. package/src/hooks/useGsapPropertyDebounce.ts +43 -28
  149. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
  150. package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
  151. package/src/hooks/useGsapScriptCommits.ts +150 -50
  152. package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
  153. package/src/hooks/useGsapSelectionHandlers.ts +94 -31
  154. package/src/hooks/useInspectorSplitResize.ts +51 -0
  155. package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
  156. package/src/hooks/usePersistentEditHistory.test.ts +49 -1
  157. package/src/hooks/usePersistentEditHistory.ts +71 -31
  158. package/src/hooks/usePreviewPersistence.ts +2 -2
  159. package/src/hooks/useProjectCompositionVariables.ts +10 -10
  160. package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
  161. package/src/hooks/useProjectSignaturePoll.ts +68 -0
  162. package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
  163. package/src/hooks/useSdkSession.ts +155 -14
  164. package/src/hooks/useSlideshowPersist.ts +7 -1
  165. package/src/hooks/useStoryboard.ts +23 -4
  166. package/src/hooks/useTimelineEditing.test.tsx +306 -155
  167. package/src/hooks/useTimelineEditing.ts +111 -101
  168. package/src/hooks/useTimelineEditingTypes.ts +4 -1
  169. package/src/hooks/useTimelineGroupEditing.ts +51 -5
  170. package/src/hooks/useVariablesPersist.ts +9 -7
  171. package/src/icons/SystemIcons.tsx +2 -0
  172. package/src/player/components/Timeline.test.ts +31 -21
  173. package/src/player/components/Timeline.tsx +62 -91
  174. package/src/player/components/TimelineCanvas.tsx +36 -5
  175. package/src/player/components/TimelineLanes.tsx +32 -10
  176. package/src/player/components/TimelineOverlays.tsx +36 -0
  177. package/src/player/components/TimelineRuler.tsx +18 -5
  178. package/src/player/components/TrackGapContextMenu.tsx +116 -0
  179. package/src/player/components/timelineCallbacks.ts +4 -1
  180. package/src/player/components/timelineClipDragCommit.test.ts +279 -3
  181. package/src/player/components/timelineClipDragCommit.ts +173 -71
  182. package/src/player/components/timelineGapCommit.test.ts +163 -0
  183. package/src/player/components/timelineGapCommit.ts +103 -0
  184. package/src/player/components/timelineGaps.test.ts +221 -0
  185. package/src/player/components/timelineGaps.ts +182 -0
  186. package/src/player/components/timelineLayout.test.ts +2 -1
  187. package/src/player/components/timelineLayout.ts +22 -6
  188. package/src/player/components/timelineMarquee.test.ts +20 -10
  189. package/src/player/components/timelineMarquee.ts +9 -2
  190. package/src/player/components/timelineStackingSync.test.ts +42 -1
  191. package/src/player/components/timelineStackingSync.ts +31 -16
  192. package/src/player/components/timelineZMirror.test.ts +667 -0
  193. package/src/player/components/timelineZMirror.ts +393 -0
  194. package/src/player/components/timelineZoom.test.ts +2 -2
  195. package/src/player/components/timelineZoom.ts +7 -3
  196. package/src/player/components/useTimelineClipDrag.ts +1 -1
  197. package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
  198. package/src/player/components/useTimelineGapHighlights.ts +110 -0
  199. package/src/player/components/useTimelinePlayhead.ts +4 -3
  200. package/src/player/components/useTimelineRangeSelection.ts +6 -4
  201. package/src/player/components/useTimelineScrollViewport.ts +73 -0
  202. package/src/player/components/useTimelineStackingSync.test.tsx +0 -1
  203. package/src/player/components/useTimelineStackingSync.ts +5 -3
  204. package/src/player/components/useTimelineTrackDerivations.ts +38 -0
  205. package/src/player/components/useTrackGapMenu.ts +155 -0
  206. package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
  207. package/src/player/hooks/useExpandedTimelineElements.ts +12 -1
  208. package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
  209. package/src/player/lib/layerOrdering.test.ts +1 -15
  210. package/src/player/lib/layerOrdering.ts +5 -28
  211. package/src/player/lib/timelineDOM.test.ts +72 -0
  212. package/src/player/lib/timelineDOM.ts +3 -0
  213. package/src/player/lib/timelineElementHelpers.ts +35 -11
  214. package/src/player/store/playerStore.ts +12 -1
  215. package/src/styles/studio.css +22 -0
  216. package/src/styles/tailwind-preset.shared.js +7 -0
  217. package/src/telemetry/events.test.ts +10 -0
  218. package/src/telemetry/events.ts +1 -0
  219. package/src/utils/designInputTracking.test.ts +97 -0
  220. package/src/utils/designInputTracking.ts +80 -0
  221. package/src/utils/domEditSaveQueue.ts +4 -3
  222. package/src/utils/editHistory.test.ts +82 -0
  223. package/src/utils/editHistory.ts +14 -2
  224. package/src/utils/gsapSoftReload.test.ts +54 -115
  225. package/src/utils/gsapSoftReload.ts +42 -152
  226. package/src/utils/gsapUndoRestore.test.ts +269 -0
  227. package/src/utils/gsapUndoRestore.ts +269 -0
  228. package/src/utils/sdkCutover.gate.test.ts +7 -7
  229. package/src/utils/sdkCutover.test.ts +490 -57
  230. package/src/utils/sdkCutover.ts +181 -219
  231. package/src/utils/sdkEditTransaction.ts +292 -0
  232. package/src/utils/sdkResolverAttempts.ts +94 -0
  233. package/src/utils/sdkResolverShadow.test.ts +148 -0
  234. package/src/utils/sdkResolverShadow.ts +139 -106
  235. package/src/utils/setSlideshowManifest.test.ts +0 -9
  236. package/src/utils/setSlideshowManifest.ts +20 -27
  237. package/src/utils/sourceScopedSelectorIndex.ts +29 -0
  238. package/src/utils/studioFileHistory.test.ts +41 -0
  239. package/src/utils/studioFileHistory.ts +30 -27
  240. package/src/utils/studioFileMutationCoordinator.ts +45 -0
  241. package/src/utils/studioHelpers.test.ts +45 -0
  242. package/src/utils/studioHelpers.ts +45 -14
  243. package/src/utils/studioSaveDiagnostics.ts +17 -0
  244. package/dist/assets/index-DfmYkU44.js +0 -423
  245. package/dist/assets/index-_pqzyxB1.css +0 -1
  246. package/dist/chunk-5QSIMBEJ.js.map +0 -1
  247. /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
@@ -0,0 +1,563 @@
1
+ import { useEffect, useRef, useState, type ReactNode } from "react";
2
+ import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
3
+ import { RotateCcw } from "../../icons/SystemIcons";
4
+ import { CommitField } from "./propertyPanelPrimitives";
5
+ import {
6
+ VALUE_TIER_LABEL_CLASS,
7
+ VALUE_TIER_VALUE_CLASS,
8
+ type PropertyValueTier,
9
+ } from "./propertyPanelValueTier";
10
+
11
+ /* ------------------------------------------------------------------ */
12
+ /* FlatRow — single-column label/value property row */
13
+ /* ------------------------------------------------------------------ */
14
+
15
+ export function FlatRow({
16
+ label,
17
+ value,
18
+ tier,
19
+ disabled,
20
+ liveCommit,
21
+ suffix,
22
+ dropdown,
23
+ onCommit,
24
+ onReset,
25
+ }: {
26
+ label: string;
27
+ value: string;
28
+ tier: PropertyValueTier;
29
+ disabled?: boolean;
30
+ liveCommit?: boolean;
31
+ suffix?: ReactNode;
32
+ /** Renders a trailing 10px caret-down, for select-backed rows. */
33
+ dropdown?: boolean;
34
+ onCommit: (nextValue: string) => void;
35
+ onReset?: () => void;
36
+ }) {
37
+ const track = useTrackDesignInput();
38
+ return (
39
+ <div className="group flex min-h-[30px] items-center justify-between gap-3">
40
+ <span className={`text-[11px] ${VALUE_TIER_LABEL_CLASS[tier]}`}>{label}</span>
41
+ <span className="flex min-w-0 flex-shrink-0 items-center gap-1.5">
42
+ <span
43
+ data-flat-row-value="true"
44
+ className={`min-w-0 border-b pb-px font-mono text-[11px] ${VALUE_TIER_VALUE_CLASS[tier]} ${
45
+ tier === "explicitCustom"
46
+ ? "border-transparent group-hover:border-panel-accent/35"
47
+ : "border-transparent group-hover:border-panel-border-input"
48
+ }`}
49
+ >
50
+ <CommitField
51
+ value={value}
52
+ disabled={disabled}
53
+ liveCommit={liveCommit}
54
+ onCommit={(nextValue) => {
55
+ track("metric", label);
56
+ onCommit(nextValue);
57
+ }}
58
+ />
59
+ </span>
60
+ {suffix}
61
+ {tier === "explicitCustom" && onReset && (
62
+ <button
63
+ type="button"
64
+ data-flat-row-reset="true"
65
+ title="Remove — fall back to default"
66
+ onClick={() => {
67
+ track("button", `Reset ${label}`);
68
+ onReset();
69
+ }}
70
+ className="flex-shrink-0 text-panel-text-3 opacity-0 transition-opacity hover:text-panel-text-1 group-hover:opacity-100"
71
+ >
72
+ <RotateCcw size={11} />
73
+ </button>
74
+ )}
75
+ {dropdown && (
76
+ <svg
77
+ width="10"
78
+ height="10"
79
+ viewBox="0 0 10 10"
80
+ fill="currentColor"
81
+ className="flex-shrink-0 text-panel-text-5"
82
+ >
83
+ <path d="M2 3l3 4 3-4z" />
84
+ </svg>
85
+ )}
86
+ </span>
87
+ </div>
88
+ );
89
+ }
90
+
91
+ /* ------------------------------------------------------------------ */
92
+ /* FlatSegmentedRow — inline glyph runs, no container background */
93
+ /* ------------------------------------------------------------------ */
94
+
95
+ export interface FlatSegmentOption {
96
+ key: string;
97
+ node: ReactNode;
98
+ /** Accessible name — the glyph alone (e.g. two indistinguishable "A"
99
+ * buttons for upright vs. italic) isn't a valid accessible name on its
100
+ * own. */
101
+ label: string;
102
+ active: boolean;
103
+ }
104
+
105
+ export function FlatSegmentedRow({
106
+ label,
107
+ options,
108
+ disabled,
109
+ /** Index (0-based) after which to render a 12px spacer — for combined rows
110
+ * like Text's "Case · Style", which pack two independent option groups. */
111
+ spacerAfterIndex,
112
+ onChange,
113
+ }: {
114
+ label: string;
115
+ options: FlatSegmentOption[];
116
+ disabled?: boolean;
117
+ spacerAfterIndex?: number;
118
+ onChange: (nextKey: string) => void;
119
+ }) {
120
+ const track = useTrackDesignInput();
121
+ return (
122
+ <div className="flex min-h-[32px] items-center justify-between">
123
+ <span className="text-[11px] text-panel-text-3">{label}</span>
124
+ <span className="flex items-center gap-0.5">
125
+ {options.map((option, index) => (
126
+ <span key={option.key} className="flex items-center">
127
+ <button
128
+ type="button"
129
+ data-flat-segment="true"
130
+ aria-label={option.label}
131
+ aria-pressed={option.active}
132
+ disabled={disabled}
133
+ onClick={() => {
134
+ if (!option.active) track("segmented", label);
135
+ onChange(option.key);
136
+ }}
137
+ className={`px-1.5 py-1 text-[11px] transition-colors disabled:cursor-not-allowed ${
138
+ option.active
139
+ ? "border-b-2 border-panel-accent text-panel-text-0"
140
+ : "border-b-2 border-transparent text-panel-text-4 hover:text-panel-text-2"
141
+ }`}
142
+ >
143
+ {option.node}
144
+ </button>
145
+ {spacerAfterIndex === index && <span className="w-3" aria-hidden="true" />}
146
+ </span>
147
+ ))}
148
+ </span>
149
+ </div>
150
+ );
151
+ }
152
+
153
+ /* ------------------------------------------------------------------ */
154
+ /* FlatGroupHeader — one-open-at-a-time accordion group header */
155
+ /* (fixed-headers + scrollable-open-section layout, design_handoff */
156
+ /* scrollable-open-section): renders ONLY the header bar — collapsed */
157
+ /* button, or open-state title bar with the collapse control. Never */
158
+ /* positioned (no sticky, no stacking offsets) — it always sits in */
159
+ /* normal document flow. The open group's body content is rendered by */
160
+ /* PropertyPanelFlat.tsx directly, in a dedicated scrollable region, */
161
+ /* not as children here. */
162
+ /* ------------------------------------------------------------------ */
163
+
164
+ export function FlatGroupHeader({
165
+ title,
166
+ isOpen,
167
+ onToggleOpen,
168
+ accessory,
169
+ summary,
170
+ animateEntrance,
171
+ }: {
172
+ title: string;
173
+ isOpen: boolean;
174
+ onToggleOpen: () => void;
175
+ accessory?: ReactNode;
176
+ summary?: string;
177
+ /** Play the fast entrance animation on this render — set only for the one
178
+ * group(s) actually transitioning (see PropertyPanelFlat's justToggledIds).
179
+ * Not derived from `isOpen`/remounting alone: React's key-based diffing
180
+ * can still shift an unrelated collapsed sibling's position in the
181
+ * before/after-open arrays (e.g. when the newly opened group isn't
182
+ * adjacent to the previously open one), and Chromium restarts a CSS
183
+ * entrance animation on such a position change even though nothing about
184
+ * that sibling actually changed — gating explicitly avoids that replay. */
185
+ animateEntrance?: boolean;
186
+ }) {
187
+ if (!isOpen) {
188
+ return (
189
+ <button
190
+ type="button"
191
+ data-flat-group-collapsed="true"
192
+ onClick={onToggleOpen}
193
+ className={`${animateEntrance ? "hf-flat-group-enter " : ""}flex min-h-10 w-full flex-shrink-0 items-center justify-between gap-2 border-b border-panel-hairline bg-panel-bg px-4 text-left`}
194
+ >
195
+ <span className="flex min-w-0 items-center gap-2">
196
+ <span className="text-[12px] font-medium text-panel-text-2">{title}</span>
197
+ {summary && (
198
+ <span className="min-w-0 truncate font-mono text-[9px] text-panel-text-4">
199
+ {summary}
200
+ </span>
201
+ )}
202
+ </span>
203
+ <svg
204
+ width="12"
205
+ height="12"
206
+ viewBox="0 0 12 12"
207
+ fill="currentColor"
208
+ className="flex-shrink-0 text-panel-text-5"
209
+ >
210
+ <path d="M4 2l4 4-4 4z" />
211
+ </svg>
212
+ </button>
213
+ );
214
+ }
215
+
216
+ return (
217
+ <div
218
+ className={`${animateEntrance ? "hf-flat-group-enter " : ""}flex min-h-10 flex-shrink-0 items-center justify-between bg-panel-bg px-4`}
219
+ >
220
+ <span className="text-[12px] font-semibold text-panel-text-0">{title}</span>
221
+ <span className="flex items-center gap-2.5 text-panel-text-5">
222
+ {accessory}
223
+ <button type="button" onClick={onToggleOpen} title="Collapse" className="text-panel-text-3">
224
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
225
+ <path d="M2 4l4 4 4-4z" />
226
+ </svg>
227
+ </button>
228
+ </span>
229
+ </div>
230
+ );
231
+ }
232
+
233
+ /* ------------------------------------------------------------------ */
234
+ /* FlatSlider — full-width label/track/value row */
235
+ /* ------------------------------------------------------------------ */
236
+
237
+ /** Keyboard target for a slider keydown, or null for keys we don't handle. */
238
+ function sliderKeyTarget(
239
+ key: string,
240
+ current: number,
241
+ min: number,
242
+ max: number,
243
+ step: number,
244
+ ): number | null {
245
+ if (key === "Home") return min;
246
+ if (key === "End") return max;
247
+ const deltas: Record<string, number> = {
248
+ ArrowLeft: -step,
249
+ ArrowDown: -step,
250
+ ArrowRight: step,
251
+ ArrowUp: step,
252
+ PageDown: -step * 10,
253
+ PageUp: step * 10,
254
+ };
255
+ const delta = deltas[key];
256
+ if (delta === undefined) return null;
257
+ return Math.max(min, Math.min(max, current + delta));
258
+ }
259
+
260
+ export function FlatSlider({
261
+ label,
262
+ value,
263
+ min,
264
+ max,
265
+ step = 1,
266
+ tier,
267
+ displayValue,
268
+ disabled,
269
+ centerTick,
270
+ onReset,
271
+ onCommit,
272
+ }: {
273
+ label: string;
274
+ value: number;
275
+ min: number;
276
+ max: number;
277
+ step?: number;
278
+ tier: "default" | "explicitCustom";
279
+ displayValue: string;
280
+ disabled?: boolean;
281
+ centerTick?: boolean;
282
+ onReset?: () => void;
283
+ onCommit: (nextValue: number) => void;
284
+ }) {
285
+ const track = useTrackDesignInput();
286
+ // `draft` gives the knob instant, drag-local visual feedback. `onCommit` is
287
+ // throttled (not debounced) to at most once per 40ms: a real drag fires
288
+ // pointermove faster than that, and a pure debounce (reset the timer on
289
+ // every move) never commits until the pointer pauses or lifts — which kills
290
+ // live preview updates during a continuous drag. Throttling still fires on
291
+ // the leading edge and on a trailing timer, so the preview keeps updating
292
+ // while dragging, with an immediate flush on release for the final value.
293
+ const [draft, setDraft] = useState(value);
294
+ const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
295
+ const lastCommitAtRef = useRef(0);
296
+ const pendingRef = useRef<number | null>(null);
297
+ // True from pointerdown to pointerup/cancel. While dragging, the committed
298
+ // prop echoing back through the parent must NOT reset `draft` — the echo is
299
+ // up to 40ms stale (throttled commit), and syncing it mid-drag snaps the
300
+ // knob backwards under the user's pointer.
301
+ const draggingRef = useRef(false);
302
+ // Tracks the last value actually sent to onCommit — separate from `value`
303
+ // (the committed prop) because in a single pointerdown+pointerup click the
304
+ // leading-edge commit fires before the parent has re-rendered with the new
305
+ // prop, so the release flush must dedupe against what we just sent, not
306
+ // against the stale prop, or the same value commits twice.
307
+ const lastCommittedRef = useRef(value);
308
+ // Always the current render's onCommit — read inside the throttle timer
309
+ // instead of closing over the callback at schedule time. A caller whose
310
+ // onCommit spreads other current state (e.g. Grade's "...grading, details:
311
+ // {...}") would otherwise let a queued trailing commit fire ~40ms later
312
+ // with a stale snapshot and silently revert whatever the user changed on a
313
+ // different control in between.
314
+ const onCommitRef = useRef(onCommit);
315
+ onCommitRef.current = onCommit;
316
+ // Always this render's committed value — read directly (not via the
317
+ // effect below) by onLostPointerCapture, so the resync there doesn't
318
+ // depend on ordering between the native event and the [value] effect.
319
+ const latestValueRef = useRef(value);
320
+ latestValueRef.current = value;
321
+ // releasePointerCapture() (called explicitly below in onPointerUp/
322
+ // onPointerCancel) fires lostpointercapture SYNCHRONOUSLY in real
323
+ // browsers — i.e. onLostPointerCapture runs mid-onPointerUp, BEFORE
324
+ // onPointerUp's own draggingRef check and final commitDraft. Without this
325
+ // flag, a NORMAL release would have onLostPointerCapture reset
326
+ // draggingRef/draft to the stale value first, making onPointerUp's own
327
+ // "if (!draggingRef.current) return" bail out and silently drop the
328
+ // real final-position commit. Set right before each explicit release
329
+ // call so onLostPointerCapture can tell "our own release, the caller's
330
+ // own logic already handles it" apart from a genuine EXTERNAL capture
331
+ // loss (another element steals it, or the browser reclaims it for a
332
+ // scroll/touch gesture) where no other handler is about to run.
333
+ const explicitReleaseRef = useRef(false);
334
+ // The committed value when the current drag began — Escape and right-click
335
+ // both cancel an in-progress drag by reverting to this, not by leaving
336
+ // whatever position the pointer last reached committed.
337
+ const dragStartValueRef = useRef(value);
338
+ const activePointerIdRef = useRef<number | null>(null);
339
+
340
+ useEffect(() => {
341
+ if (draggingRef.current) return;
342
+ setDraft(value);
343
+ lastCommittedRef.current = value;
344
+ }, [value]);
345
+ useEffect(
346
+ () => () => {
347
+ if (commitTimerRef.current) {
348
+ clearTimeout(commitTimerRef.current);
349
+ // Flush rather than drop a still-queued edit — this only fires if the
350
+ // component unmounts mid-drag (e.g. selection changes away), and
351
+ // silently discarding the user's last dragged position would look
352
+ // like data loss.
353
+ if (pendingRef.current !== null) onCommitRef.current(pendingRef.current);
354
+ }
355
+ },
356
+ [],
357
+ );
358
+
359
+ const clampedPct = Math.max(0, Math.min(100, ((draft - min) / Math.max(max - min, 1e-6)) * 100));
360
+
361
+ const stepFromClientX = (clientX: number, rect: DOMRect) => {
362
+ const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / Math.max(rect.width, 1)));
363
+ const raw = min + ratio * (max - min);
364
+ const stepped = Math.round(raw / step) * step;
365
+ return Math.max(min, Math.min(max, stepped));
366
+ };
367
+ const commitDraft = (nextDraft: number) => {
368
+ if (commitTimerRef.current) {
369
+ clearTimeout(commitTimerRef.current);
370
+ commitTimerRef.current = null;
371
+ }
372
+ pendingRef.current = null;
373
+ lastCommitAtRef.current = Date.now();
374
+ if (nextDraft !== lastCommittedRef.current) {
375
+ lastCommittedRef.current = nextDraft;
376
+ onCommitRef.current(nextDraft);
377
+ }
378
+ };
379
+ const scheduleCommit = (nextDraft: number) => {
380
+ const elapsed = Date.now() - lastCommitAtRef.current;
381
+ if (elapsed >= 40) {
382
+ commitDraft(nextDraft);
383
+ return;
384
+ }
385
+ pendingRef.current = nextDraft;
386
+ if (!commitTimerRef.current) {
387
+ commitTimerRef.current = setTimeout(() => {
388
+ commitTimerRef.current = null;
389
+ if (pendingRef.current !== null) commitDraft(pendingRef.current);
390
+ }, 40 - elapsed);
391
+ }
392
+ };
393
+ // Reverts to the pre-drag value instead of leaving whatever position the
394
+ // pointer last reached committed — the drag's own leading-edge commit (in
395
+ // onPointerDown) may already have applied an intermediate value, so this
396
+ // must go through commitDraft (not just a visual setDraft) to actually
397
+ // undo it.
398
+ const cancelDrag = (target: HTMLDivElement) => {
399
+ if (!draggingRef.current) return;
400
+ draggingRef.current = false;
401
+ const pointerId = activePointerIdRef.current;
402
+ if (pointerId !== null && target.hasPointerCapture(pointerId)) {
403
+ explicitReleaseRef.current = true;
404
+ target.releasePointerCapture(pointerId);
405
+ }
406
+ setDraft(dragStartValueRef.current);
407
+ commitDraft(dragStartValueRef.current);
408
+ };
409
+
410
+ return (
411
+ <div className="flex min-h-[28px] items-center gap-2.5">
412
+ <span className="w-[86px] flex-shrink-0 text-[11px] text-panel-text-3">{label}</span>
413
+ <div
414
+ data-flat-slider-track="true"
415
+ role="slider"
416
+ aria-label={label}
417
+ aria-valuenow={draft}
418
+ aria-valuemin={min}
419
+ aria-valuemax={max}
420
+ aria-disabled={disabled}
421
+ tabIndex={disabled ? -1 : 0}
422
+ style={{ touchAction: "none" }}
423
+ className={`relative h-5 flex-1 ${disabled ? "cursor-not-allowed" : "cursor-pointer"}`}
424
+ onPointerDown={(e) => {
425
+ if (disabled) return;
426
+ draggingRef.current = true;
427
+ dragStartValueRef.current = latestValueRef.current;
428
+ activePointerIdRef.current = e.pointerId;
429
+ e.currentTarget.setPointerCapture(e.pointerId);
430
+ const stepped = stepFromClientX(e.clientX, e.currentTarget.getBoundingClientRect());
431
+ setDraft(stepped);
432
+ scheduleCommit(stepped);
433
+ }}
434
+ onPointerMove={(e) => {
435
+ if (disabled || !e.currentTarget.hasPointerCapture(e.pointerId)) return;
436
+ const stepped = stepFromClientX(e.clientX, e.currentTarget.getBoundingClientRect());
437
+ setDraft(stepped);
438
+ scheduleCommit(stepped);
439
+ }}
440
+ onPointerUp={(e) => {
441
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) {
442
+ explicitReleaseRef.current = true;
443
+ e.currentTarget.releasePointerCapture(e.pointerId);
444
+ }
445
+ if (disabled) return;
446
+ if (!draggingRef.current) return;
447
+ draggingRef.current = false;
448
+ // Recompute from the event itself rather than reading the `draft`
449
+ // closure — if pointerdown+pointerup land in the same React batch
450
+ // (e.g. a very fast click), the onPointerUp handler can still be
451
+ // bound to the pre-drag render, making `draft` stale.
452
+ const stepped = stepFromClientX(e.clientX, e.currentTarget.getBoundingClientRect());
453
+ setDraft(stepped);
454
+ commitDraft(stepped);
455
+ if (stepped !== dragStartValueRef.current) track("slider", label);
456
+ }}
457
+ onPointerCancel={(e) => {
458
+ // A native pointercancel means the platform aborted the gesture (a
459
+ // scroll/touch takeover, pen leaving range, etc.) — that must cancel
460
+ // the drag the same way Escape/right-click do (revert to the
461
+ // pre-drag value), not just stop dragging and leave whatever
462
+ // intermediate position the pointer last reached committed.
463
+ cancelDrag(e.currentTarget);
464
+ }}
465
+ onLostPointerCapture={() => {
466
+ if (explicitReleaseRef.current) {
467
+ // Our own onPointerUp/onPointerCancel just released capture —
468
+ // their own logic already handles (or intentionally leaves)
469
+ // draggingRef/draft correctly. Resyncing here too would race
470
+ // onPointerUp's still-pending final commitDraft(stepped) below
471
+ // this call, since draggingRef flipping false would make its
472
+ // own "if (!draggingRef.current) return" bail out first.
473
+ explicitReleaseRef.current = false;
474
+ return;
475
+ }
476
+ // A genuine EXTERNAL capture loss (another element steals it, or
477
+ // the browser reclaims it for a scroll/touch gesture) — no other
478
+ // handler is about to run, so resync immediately and directly
479
+ // from latestValueRef rather than only clearing draggingRef and
480
+ // waiting for the [value] effect to notice (that effect depends
481
+ // on `value` actually changing again to re-run).
482
+ draggingRef.current = false;
483
+ setDraft(latestValueRef.current);
484
+ lastCommittedRef.current = latestValueRef.current;
485
+ }}
486
+ onKeyDown={(e) => {
487
+ if (disabled) return;
488
+ if (e.key === "Escape" && draggingRef.current) {
489
+ e.preventDefault();
490
+ cancelDrag(e.currentTarget);
491
+ return;
492
+ }
493
+ const next = sliderKeyTarget(e.key, draft, min, max, step);
494
+ if (next === null) return;
495
+ e.preventDefault();
496
+ setDraft(next);
497
+ commitDraft(next);
498
+ if (next !== draft) track("slider", label);
499
+ }}
500
+ onContextMenu={(e) => {
501
+ // Right-click during a drag must cancel it (revert to the pre-drag
502
+ // value), not leave the last dragged-to position committed while
503
+ // the native context menu opens on top of the slider.
504
+ if (!draggingRef.current) return;
505
+ e.preventDefault();
506
+ cancelDrag(e.currentTarget);
507
+ }}
508
+ >
509
+ <div className="absolute inset-x-0 top-1/2 h-0.5 -translate-y-1/2 rounded-full bg-panel-hover">
510
+ {centerTick && (
511
+ <div
512
+ data-flat-slider-center-tick="true"
513
+ className="absolute left-1/2 top-[-1px] h-1 w-px -translate-x-1/2 bg-panel-text-5"
514
+ />
515
+ )}
516
+ {tier === "explicitCustom" && (
517
+ <div
518
+ data-flat-slider-fill="true"
519
+ className="absolute inset-y-0 left-0 rounded-full bg-panel-text-5"
520
+ style={{ width: `${clampedPct}%` }}
521
+ />
522
+ )}
523
+ </div>
524
+ <div
525
+ data-flat-slider-knob="true"
526
+ className={`absolute top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full ${
527
+ tier === "explicitCustom" ? "h-2 w-2 bg-white" : "h-[7px] w-[7px] bg-panel-text-4"
528
+ }`}
529
+ style={{ left: `${clampedPct}%` }}
530
+ />
531
+ </div>
532
+ <span
533
+ data-flat-slider-value="true"
534
+ className={`w-11 flex-shrink-0 text-right font-mono text-[10px] ${
535
+ tier === "explicitCustom" ? "text-panel-text-0" : "text-panel-text-3"
536
+ }`}
537
+ >
538
+ {displayValue}
539
+ </span>
540
+ {(centerTick || onReset) && (
541
+ <span data-flat-slider-reset-slot="true" className="w-3.5 flex-shrink-0">
542
+ {tier === "explicitCustom" && onReset && (
543
+ <button
544
+ type="button"
545
+ data-flat-slider-reset="true"
546
+ title="Remove — fall back to default"
547
+ disabled={disabled}
548
+ onClick={() => {
549
+ track("button", `Reset ${label}`);
550
+ onReset();
551
+ }}
552
+ className="text-panel-text-3 hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
553
+ >
554
+ <RotateCcw size={11} />
555
+ </button>
556
+ )}
557
+ </span>
558
+ )}
559
+ </div>
560
+ );
561
+ }
562
+
563
+ export { FlatSelectRow } from "./propertyPanelFlatSelectRow";
@@ -0,0 +1,101 @@
1
+ import { RotateCcw } from "../../icons/SystemIcons";
2
+ import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
3
+ import {
4
+ VALUE_TIER_LABEL_CLASS,
5
+ VALUE_TIER_VALUE_CLASS,
6
+ type PropertyValueTier,
7
+ } from "./propertyPanelValueTier";
8
+
9
+ /* ------------------------------------------------------------------ */
10
+ /* FlatSelectRow — label/value row backed by a native <select> */
11
+ /* ------------------------------------------------------------------ */
12
+
13
+ export function FlatSelectRow({
14
+ label,
15
+ ariaLabel,
16
+ value,
17
+ options,
18
+ tier,
19
+ disabled,
20
+ onChange,
21
+ onReset,
22
+ }: {
23
+ label: string;
24
+ /** Accessible name when a caller renders the visible label OUTSIDE this
25
+ * row (label="" to avoid a duplicate) — e.g. Grade's "Preset" row, which
26
+ * shows its own label span and would otherwise leave the <select>
27
+ * unnamed. Falls back to `label` when omitted. */
28
+ ariaLabel?: string;
29
+ value: string;
30
+ options: Array<string | { value: string; label: string }>;
31
+ tier: PropertyValueTier;
32
+ disabled?: boolean;
33
+ onChange: (nextValue: string) => void;
34
+ onReset?: () => void;
35
+ }) {
36
+ const track = useTrackDesignInput();
37
+ const trackName = ariaLabel || label;
38
+ const normalizedOptions = options.map((option) =>
39
+ typeof option === "string" ? { value: option, label: option } : option,
40
+ );
41
+ // A valid authored value outside the preset list (e.g. a `mix-blend-mode`
42
+ // or `object-position` this row doesn't offer as a preset) must not be
43
+ // silently misrepresented as the first option — the native <select> falls
44
+ // back to selectedIndex 0 when `value` matches no <option>, and reselecting
45
+ // that visible-but-wrong preset overwrites the real persisted value. Prepend
46
+ // the current value so it's always representable, matching legacy
47
+ // `SelectField`'s same guard.
48
+ const renderedOptions =
49
+ value && !normalizedOptions.some((option) => option.value === value)
50
+ ? [{ value, label: value }, ...normalizedOptions]
51
+ : normalizedOptions;
52
+ return (
53
+ <div className="group flex min-h-[30px] items-center justify-between">
54
+ <span className={`text-[11px] ${VALUE_TIER_LABEL_CLASS[tier]}`}>{label}</span>
55
+ <span className="flex items-center gap-2">
56
+ <label className="flex items-center gap-1.5">
57
+ <select
58
+ value={value}
59
+ disabled={disabled}
60
+ aria-label={ariaLabel || label || undefined}
61
+ onChange={(e) => {
62
+ track("select", trackName);
63
+ onChange(e.target.value);
64
+ }}
65
+ className={`appearance-none bg-transparent text-right font-mono text-[11px] outline-none disabled:cursor-not-allowed ${VALUE_TIER_VALUE_CLASS[tier]}`}
66
+ >
67
+ {renderedOptions.map((option) => (
68
+ <option key={option.value} value={option.value}>
69
+ {option.label}
70
+ </option>
71
+ ))}
72
+ </select>
73
+ <svg
74
+ width="10"
75
+ height="10"
76
+ viewBox="0 0 10 10"
77
+ fill="currentColor"
78
+ className="flex-shrink-0 text-panel-text-5"
79
+ >
80
+ <path d="M2 3l3 4 3-4z" />
81
+ </svg>
82
+ </label>
83
+ {tier === "explicitCustom" && onReset && (
84
+ <button
85
+ type="button"
86
+ data-flat-select-reset="true"
87
+ title="Remove — fall back to default"
88
+ disabled={disabled}
89
+ onClick={() => {
90
+ track("button", `Reset ${trackName}`);
91
+ onReset();
92
+ }}
93
+ className="flex-shrink-0 text-panel-text-3 opacity-0 transition-opacity hover:text-panel-text-1 group-hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-40"
94
+ >
95
+ <RotateCcw size={11} />
96
+ </button>
97
+ )}
98
+ </span>
99
+ </div>
100
+ );
101
+ }
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { formatStrokeSummary, parseStrokeSummary } from "./propertyPanelFlatStyleHelpers";
3
+
4
+ describe("formatStrokeSummary", () => {
5
+ it("formats width and style into one string", () => {
6
+ expect(formatStrokeSummary(1, "solid")).toBe("1px solid");
7
+ expect(formatStrokeSummary(2.5, "dashed")).toBe("2.5px dashed");
8
+ expect(formatStrokeSummary(0, "none")).toBe("0px none");
9
+ });
10
+ });
11
+
12
+ describe("parseStrokeSummary", () => {
13
+ it("parses a well-formed summary back into width and style", () => {
14
+ expect(parseStrokeSummary("1px solid")).toEqual({ widthPx: 1, style: "solid" });
15
+ expect(parseStrokeSummary(" 2.5px dashed ")).toEqual({ widthPx: 2.5, style: "dashed" });
16
+ });
17
+
18
+ it("returns null for unparseable input", () => {
19
+ expect(parseStrokeSummary("garbage")).toBeNull();
20
+ expect(parseStrokeSummary("")).toBeNull();
21
+ expect(parseStrokeSummary("1px")).toBeNull();
22
+ });
23
+ });