@hyperframes/studio 0.8.59 → 0.8.61

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 (94) hide show
  1. package/dist/assets/{hyperframes-player-BBub3ZdI.js → hyperframes-player-nmVkPQza.js} +1 -1
  2. package/dist/assets/{index-CI7WON-E.js → index-2-HsQB2C.js} +1 -1
  3. package/dist/assets/{index-D1KNPijK.js → index-BK36b0XY.js} +162 -162
  4. package/dist/assets/{index-BxZ5Cw5w.js → index-COG0K-CQ.js} +1 -1
  5. package/dist/assets/{index-DZBHG9TW.css → index-DS0kLbuM.css} +1 -1
  6. package/dist/index.d.ts +5 -6
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +4977 -4273
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +7 -7
  12. package/src/App.tsx +2 -0
  13. package/src/components/StudioHeader.tsx +2 -13
  14. package/src/components/TimelineToolbar.test.tsx +42 -0
  15. package/src/components/TimelineToolbar.tsx +5 -10
  16. package/src/components/editor/propertyPanelFill.tsx +3 -2
  17. package/src/components/icons/AudioMetersIcon.tsx +16 -0
  18. package/src/components/icons/InspectorIcon.tsx +18 -0
  19. package/src/components/icons/ReverseGradientIcon.tsx +23 -0
  20. package/src/components/icons/RippleEditIcon.tsx +19 -0
  21. package/src/components/nle/AudioMeterStrip.test.tsx +278 -7
  22. package/src/components/nle/AudioMeterStrip.tsx +264 -47
  23. package/src/components/ui/Menu.test.tsx +3 -4
  24. package/src/components/ui/Menu.tsx +18 -5
  25. package/src/components/ui/Select.tsx +7 -1
  26. package/src/components/ui/Tooltip.tsx +6 -1
  27. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +56 -0
  28. package/src/hooks/useExternalFileChangeCoordinator.ts +13 -0
  29. package/src/hooks/useRenderClipContent.test.ts +33 -4
  30. package/src/hooks/useRenderClipContent.ts +7 -3
  31. package/src/hooks/useSdkSession.lifecycle.test.tsx +179 -0
  32. package/src/hooks/useSdkSession.ts +84 -14
  33. package/src/hooks/useStudioSdkSessions.ts +8 -1
  34. package/src/player/components/AudioWaveform.test.tsx +53 -1
  35. package/src/player/components/AudioWaveform.tsx +124 -65
  36. package/src/player/components/ImageThumbnail.tsx +3 -5
  37. package/src/player/components/LayerDisclosureRow.tsx +9 -4
  38. package/src/player/components/PlayheadIndicator.tsx +3 -3
  39. package/src/player/components/TimelineAutomationLane.test.tsx +1 -1
  40. package/src/player/components/TimelineAutomationLane.tsx +5 -5
  41. package/src/player/components/TimelineCanvas.tsx +18 -15
  42. package/src/player/components/TimelineClip.test.tsx +41 -4
  43. package/src/player/components/TimelineClip.tsx +11 -4
  44. package/src/player/components/TimelineClipDiamonds.test.tsx +3 -1
  45. package/src/player/components/TimelineClipDiamonds.tsx +2 -2
  46. package/src/player/components/TimelineClipFades.tsx +64 -31
  47. package/src/player/components/TimelineDiamondConnectors.tsx +2 -2
  48. package/src/player/components/TimelineEmptyState.tsx +1 -1
  49. package/src/player/components/TimelineFxButton.tsx +10 -8
  50. package/src/player/components/TimelineGroupHeader.tsx +18 -9
  51. package/src/player/components/TimelineGroupLaneLabels.tsx +12 -5
  52. package/src/player/components/TimelineGroupRow.tsx +1 -1
  53. package/src/player/components/TimelineLanes.test.tsx +14 -0
  54. package/src/player/components/TimelineLanes.tsx +15 -8
  55. package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
  56. package/src/player/components/TimelineRazorInteraction.tsx +1 -1
  57. package/src/player/components/TimelineRuler.tsx +3 -2
  58. package/src/player/components/TimelineShortcutHint.tsx +2 -2
  59. package/src/player/components/TimelineTrackHeader.test.tsx +1 -1
  60. package/src/player/components/TimelineTrackHeader.tsx +4 -6
  61. package/src/player/components/TimelineTrackPlainHeader.tsx +10 -3
  62. package/src/player/components/TimelineTransitionBadge.tsx +59 -0
  63. package/src/player/components/TimelineTransitionOverlays.tsx +30 -0
  64. package/src/player/components/VideoThumbnail.test.tsx +23 -12
  65. package/src/player/components/VideoThumbnail.tsx +86 -32
  66. package/src/player/components/audioClipLink.test.ts +34 -0
  67. package/src/player/components/audioClipLink.ts +33 -0
  68. package/src/player/components/audioWaveformPeaks.test.ts +32 -0
  69. package/src/player/components/audioWaveformPeaks.ts +46 -0
  70. package/src/player/components/thumbnailUtils.test.ts +17 -0
  71. package/src/player/components/thumbnailUtils.ts +7 -0
  72. package/src/player/components/timelineMotionStyles.test.ts +96 -30
  73. package/src/player/components/timelineTheme.test.ts +14 -0
  74. package/src/player/components/timelineTheme.ts +18 -11
  75. package/src/player/components/timelineTransitionSeams.test.ts +48 -0
  76. package/src/player/components/timelineTransitionSeams.ts +36 -0
  77. package/src/player/components/timelineZones.test.ts +18 -0
  78. package/src/player/components/timelineZones.ts +4 -1
  79. package/src/player/lib/playbackTypes.ts +2 -0
  80. package/src/player/lib/timelineDOM.test.ts +28 -0
  81. package/src/player/lib/timelineDOM.ts +24 -2
  82. package/src/player/lib/timelineIframeHelpers.ts +7 -0
  83. package/src/player/lib/timelineTransitionMetadata.ts +52 -0
  84. package/src/player/lib/timelineViewportBudgets.ts +2 -1
  85. package/src/player/store/timelineElement.ts +1 -0
  86. package/src/styles/hex-baseline.json +3 -22
  87. package/src/styles/studio.css +126 -46
  88. package/src/styles/theme.css +113 -1
  89. package/src/styles/theme.test.ts +9 -0
  90. package/src/utils/audioMeterVisibility.ts +2 -2
  91. package/src/utils/sdkCutover.test.ts +102 -1
  92. package/src/utils/sdkCutover.ts +16 -21
  93. package/src/utils/sdkEditTransaction.ts +50 -1
  94. package/src/utils/studioUiPreferences.ts +1 -1
@@ -202,7 +202,76 @@ body {
202
202
  .timeline-clip {
203
203
  background-color: var(--clip-bg);
204
204
  border: 1px solid var(--clip-border);
205
+ border-radius: var(--timeline-clip-radius);
206
+ box-shadow:
207
+ inset 0 1px 0 var(--timeline-clip-highlight),
208
+ var(--timeline-clip-shadow);
209
+ }
210
+
211
+ .timeline-transition {
212
+ position: absolute;
213
+ width: 0;
214
+ height: 0;
215
+ pointer-events: none;
216
+ z-index: 30;
217
+ }
218
+
219
+ .timeline-transition__zone {
220
+ position: absolute;
221
+ left: calc(var(--timeline-transition-zone-width) / -2);
222
+ top: -21px;
223
+ width: var(--timeline-transition-zone-width);
224
+ height: 42px;
205
225
  border-radius: 8px;
226
+ overflow: hidden;
227
+ background: var(--timeline-transition-zone);
228
+ filter: blur(2.5px);
229
+ opacity: 0.95;
230
+ }
231
+
232
+ .timeline-transition__image {
233
+ position: absolute;
234
+ inset: -4px;
235
+ background-position: center;
236
+ background-size: cover;
237
+ }
238
+
239
+ .timeline-transition__image--out {
240
+ mask-image: linear-gradient(90deg, black 0%, transparent 100%);
241
+ }
242
+
243
+ .timeline-transition__image--in {
244
+ mask-image: linear-gradient(90deg, transparent 0%, black 100%);
245
+ }
246
+
247
+ .timeline-transition__badge {
248
+ position: absolute;
249
+ left: -14px;
250
+ top: -14px;
251
+ display: grid;
252
+ width: 28px;
253
+ height: 28px;
254
+ place-items: center;
255
+ border: 1px solid color-mix(in srgb, var(--timeline-transition-badge-ink) 22%, transparent);
256
+ border-radius: 999px;
257
+ background: var(--timeline-transition-badge);
258
+ box-shadow: 0 2px 5px var(--timeline-playhead-shadow);
259
+ }
260
+
261
+ .timeline-transition__badge svg {
262
+ width: 17px;
263
+ height: 17px;
264
+ }
265
+ .timeline-transition__frame,
266
+ .timeline-transition__frame--out,
267
+ .timeline-transition__frame--in {
268
+ fill: none;
269
+ stroke: var(--timeline-transition-badge-ink);
270
+ stroke-width: 1.5;
271
+ }
272
+ .timeline-transition__intersection {
273
+ fill: var(--timeline-transition-badge-ink);
274
+ opacity: 0.7;
206
275
  }
207
276
 
208
277
  .timeline-clip::before {
@@ -227,16 +296,37 @@ body {
227
296
  accented visual clips — so the audio zone at the bottom is visually separate
228
297
  at a glance (the timeline's kinds: visual on top, audio below). */
229
298
  .timeline-clip.is-audio {
230
- background-color: rgba(167, 139, 250, 0.16);
231
- border-color: rgba(167, 139, 250, 0.4);
299
+ background-color: var(--timeline-clip-audio-bg);
300
+ border-color: var(--timeline-clip-audio-border);
301
+ border-radius: var(--timeline-clip-audio-radius);
232
302
  }
233
303
 
234
304
  .timeline-clip.is-audio.is-hovered {
235
- background-color: rgba(167, 139, 250, 0.24);
305
+ background-color: var(--timeline-clip-audio-bg-hover);
236
306
  }
237
307
 
238
308
  .timeline-clip.is-audio.is-dragging {
239
- background-color: rgba(60, 52, 84, 0.96);
309
+ box-shadow:
310
+ inset 0 0 0 999px var(--timeline-clip-audio-bg-dragging),
311
+ 0 8px 24px var(--timeline-clip-shadow-dragging);
312
+ }
313
+
314
+ /* Mute greys the pill in place. Hot path: no transition. */
315
+ .timeline-clip.is-audio[data-clip-hidden="true"],
316
+ .timeline-clip.is-audio[data-audio-muted="true"] {
317
+ background-color: var(--timeline-clip-audio-muted-bg);
318
+ transition: none;
319
+ }
320
+
321
+ .timeline-audio-link {
322
+ position: absolute;
323
+ z-index: 4;
324
+ top: -9px;
325
+ left: 50%;
326
+ width: 1px;
327
+ height: 10px;
328
+ background: var(--timeline-audio-link);
329
+ pointer-events: none;
240
330
  }
241
331
 
242
332
  .timeline-clip.is-hovered {
@@ -248,53 +338,56 @@ body {
248
338
  }
249
339
 
250
340
  .timeline-clip.is-selected {
251
- box-shadow: 0 0 0 1.5px rgba(255, 255, 255, 0.85);
341
+ outline: 1.5px solid var(--timeline-clip-selection);
342
+ outline-offset: -1.5px;
252
343
  }
253
344
 
254
- .timeline-clip[data-active].is-selected {
255
- box-shadow: 0 0 0 1.5px rgba(255, 255, 255, 0.85);
256
- }
257
-
258
- .timeline-clip.is-dragging {
259
- /* Solid background so the picked-up clip reads clearly while dragging — audio
260
- clips (waveform drawn on a separate layer) would otherwise look transparent. */
261
- background-color: var(--clip-bg-dragging);
262
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
345
+ .timeline-clip.is-selected::after {
346
+ content: "";
347
+ position: absolute;
348
+ inset: 0;
349
+ z-index: 4;
350
+ border-radius: inherit;
351
+ box-shadow: inset 0 0 0 1px var(--timeline-clip-selection-highlight);
352
+ pointer-events: none;
263
353
  }
264
354
 
265
- /* Keep the white selection outline while dragging (a plain box-shadow alone gets
266
- overridden by .is-dragging's drop shadow, so re-state both together). */
267
- .timeline-clip.is-selected.is-dragging {
268
- box-shadow:
269
- 0 0 0 1.5px rgba(255, 255, 255, 0.85),
270
- 0 8px 24px rgba(0, 0, 0, 0.4);
355
+ .timeline-clip[data-active].is-selected {
356
+ outline: 1.5px solid var(--timeline-clip-selection);
357
+ outline-offset: -1.5px;
271
358
  }
272
359
 
273
- .timeline-clip[data-active].is-selected.is-dragging {
360
+ .timeline-clip.is-dragging {
361
+ /* Veil is a shadow, not background-color, so drop does not fade the hover fill. */
274
362
  box-shadow:
275
- 0 0 0 1.5px rgba(255, 255, 255, 0.85),
276
- 0 8px 24px rgba(0, 0, 0, 0.4);
363
+ inset 0 0 0 999px var(--clip-bg-dragging),
364
+ 0 8px 24px var(--timeline-clip-shadow-dragging);
277
365
  }
278
366
 
279
367
  .timeline-clip__label {
280
368
  position: absolute;
281
- top: 6px;
282
- left: 12px;
283
- right: 10px;
369
+ top: 5px;
370
+ left: 6px;
284
371
  z-index: 6;
372
+ width: fit-content;
373
+ max-width: calc(100% - 22px);
285
374
  overflow: hidden;
286
- color: rgba(255, 255, 255, 0.5);
375
+ padding: 1px 5px;
376
+ border-radius: 4px;
377
+ background: var(--timeline-clip-chip-bg);
378
+ color: var(--timeline-clip-chip-text);
287
379
  font-size: 10px;
288
380
  font-weight: 500;
289
- line-height: 1;
381
+ line-height: 16px;
290
382
  pointer-events: none;
291
- text-shadow: 0 1px 2px rgba(0, 0, 0, 0.85);
292
383
  text-overflow: ellipsis;
293
384
  white-space: nowrap;
294
385
  }
295
386
 
296
- .timeline-clip[data-active] .timeline-clip__label {
297
- color: rgba(232, 255, 247, 0.95);
387
+ .timeline-clip.is-audio .timeline-clip__label {
388
+ top: 11px;
389
+ left: 9px;
390
+ background: var(--timeline-clip-chip-audio-bg);
298
391
  }
299
392
 
300
393
  .timeline-clip__timecode {
@@ -302,7 +395,7 @@ body {
302
395
  bottom: 6px;
303
396
  left: 12px;
304
397
  overflow: hidden;
305
- color: rgba(255, 255, 255, 0.34);
398
+ color: var(--timeline-tick-text);
306
399
  font-family: var(--font-mono);
307
400
  font-size: 9px;
308
401
  font-variant-numeric: tabular-nums;
@@ -313,20 +406,7 @@ body {
313
406
 
314
407
  @media (prefers-reduced-motion: no-preference) {
315
408
  .timeline-clip {
316
- transition:
317
- background-color 200ms cubic-bezier(0.22, 1, 0.36, 1),
318
- border-color 200ms cubic-bezier(0.22, 1, 0.36, 1),
319
- box-shadow 200ms cubic-bezier(0.22, 1, 0.36, 1),
320
- color 200ms cubic-bezier(0.22, 1, 0.36, 1),
321
- opacity 200ms cubic-bezier(0.22, 1, 0.36, 1);
322
- }
323
-
324
- .timeline-clip::before {
325
- transition: opacity 200ms cubic-bezier(0.22, 1, 0.36, 1);
326
- }
327
-
328
- .timeline-clip__label {
329
- transition: color 200ms cubic-bezier(0.22, 1, 0.36, 1);
409
+ transition: background-color 120ms ease-out;
330
410
  }
331
411
 
332
412
  .timeline-clip__handle-bar {
@@ -62,6 +62,46 @@
62
62
  --timeline-tick-major: rgba(255, 255, 255, 0.1);
63
63
  --timeline-tick-minor: rgba(255, 255, 255, 0.06);
64
64
  --timeline-clip-bg: rgba(255, 255, 255, 0.12);
65
+ --timeline-clip-label-active: rgba(232, 255, 247, 0.95);
66
+ --timeline-clip-label-shadow: 0 1px 2px rgba(0, 0, 0, 0.85);
67
+ --timeline-clip-audio-bg: rgba(167, 139, 250, 0.16);
68
+ --timeline-clip-audio-border: rgba(167, 139, 250, 0.4);
69
+ --timeline-clip-audio-bg-hover: rgba(167, 139, 250, 0.24);
70
+ --timeline-clip-audio-bg-dragging: rgba(60, 52, 84, 0.96);
71
+ --timeline-clip-audio-muted-bg: rgb(72, 74, 78);
72
+ --timeline-audio-link: #a0aaa4;
73
+ --timeline-clip-selection: rgba(255, 255, 255, 0.85);
74
+ --timeline-clip-selection-highlight: rgb(255 255 255 / 0.55);
75
+ --timeline-clip-radius: 8px;
76
+ --timeline-clip-audio-radius: 21px;
77
+ --timeline-clip-highlight: rgb(255 255 255 / 0.08);
78
+ --timeline-clip-shadow: 0 2px 6px rgb(0 0 0 / 0.3);
79
+ --timeline-clip-chip-bg: rgba(15, 22, 18, 0.73);
80
+ --timeline-clip-chip-audio-bg: rgba(25, 16, 38, 0.7);
81
+ --timeline-clip-chip-text: #ffffff;
82
+ --timeline-shortcut-bg: rgba(17, 23, 35, 0.84);
83
+ --timeline-thumbnail-shimmer: linear-gradient(
84
+ 90deg,
85
+ rgba(255, 255, 255, 0.02) 0%,
86
+ rgba(255, 255, 255, 0.05) 50%,
87
+ rgba(255, 255, 255, 0.02) 100%
88
+ );
89
+ --timeline-thumbnail-label-gradient: linear-gradient(
90
+ to top,
91
+ rgba(0, 0, 0, 0.85) 0%,
92
+ rgba(0, 0, 0, 0.4) 60%,
93
+ transparent 100%
94
+ );
95
+ --timeline-thumbnail-label-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
96
+ --timeline-waveform-bar-rgb: 245, 237, 255;
97
+ --timeline-waveform-muted-rgb: 236, 238, 242;
98
+ --timeline-waveform-baseline-rgb: 226, 216, 238;
99
+ --timeline-waveform-error: repeating-linear-gradient(
100
+ 90deg,
101
+ rgba(245, 237, 255, 0.35) 0 2px,
102
+ transparent 2px 5px
103
+ );
104
+ --timeline-waveform-label-shadow: 0 1px 3px rgba(0, 0, 0, 0.9);
65
105
  --timeline-clip-bg-active: rgba(60, 230, 172, 0.2);
66
106
  --timeline-clip-bg-hover: rgba(255, 255, 255, 0.16);
67
107
  --timeline-clip-bg-dragging: rgba(38, 42, 52, 0.96);
@@ -78,6 +118,43 @@
78
118
  --timeline-track-clip-fill: rgba(255, 255, 255, 0.055);
79
119
  --timeline-track-clip-active: rgba(60, 230, 172, 0.16);
80
120
  --timeline-track-label: rgba(255, 255, 255, 0.5);
121
+ --timeline-border-strong: rgba(255, 255, 255, 0.2);
122
+ --timeline-group-member-tint: rgba(255, 255, 255, 0.035);
123
+ --timeline-overlay-text: rgba(255, 255, 255, 0.8);
124
+ --timeline-accent: #3ce6ac;
125
+ --timeline-accent-soft: rgba(60, 230, 172, 0.18);
126
+ --timeline-accent-faint: rgba(60, 230, 172, 0.055);
127
+ --timeline-accent-fill: rgba(60, 230, 172, 0.1);
128
+ --timeline-accent-glow: rgba(60, 230, 172, 0.5);
129
+ --timeline-accent-border: rgba(60, 230, 172, 0.7);
130
+ --timeline-accent-rail: rgba(60, 230, 172, 0.35);
131
+ --timeline-accent-hover: rgba(60, 230, 172, 0.25);
132
+ --timeline-accent-button-bg: rgba(60, 230, 172, 0.15);
133
+ --timeline-danger: rgba(239, 68, 68, 0.7);
134
+ --timeline-info-bg: rgba(59, 130, 246, 0.12);
135
+ --timeline-info-border: rgba(59, 130, 246, 0.4);
136
+ --timeline-dialog-bg: #1b1b1f;
137
+ --timeline-diamond-bg: #171717;
138
+ --timeline-diamond-border: rgba(255, 255, 255, 0.14);
139
+ --timeline-text-solid: #ffffff;
140
+ --timeline-text-faint: rgba(255, 255, 255, 0.35);
141
+ --timeline-text-dim: rgba(255, 255, 255, 0.4);
142
+ --timeline-text-soft: rgba(255, 255, 255, 0.75);
143
+ --timeline-text-bright: rgba(255, 255, 255, 0.7);
144
+ --timeline-snap-guide: rgba(255, 255, 255, 0.6);
145
+ --timeline-overlay-bg: rgba(0, 0, 0, 0.8);
146
+ --timeline-overlay-bg-strong: rgba(0, 0, 0, 0.85);
147
+ --timeline-input-bg: rgba(0, 0, 0, 0.3);
148
+ --timeline-automation-rail: rgba(255, 255, 255, 0.08);
149
+ --timeline-automation-point-muted: rgba(0, 0, 0, 0.5);
150
+ --timeline-diamond-muted: #a3a3a3;
151
+ --timeline-beat: rgb(34, 197, 94);
152
+ --timeline-clip-shadow-dragging: rgba(0, 0, 0, 0.4);
153
+ --timeline-playhead-glow: rgba(60, 230, 172, 0.14);
154
+ --timeline-playhead-shadow: rgba(0, 0, 0, 0.55);
155
+ --timeline-transition-zone: rgba(255, 255, 255, 0.2);
156
+ --timeline-transition-badge: rgba(237, 244, 239, 0.98);
157
+ --timeline-transition-badge-ink: rgba(36, 56, 45, 0.96);
81
158
 
82
159
  /* -------------------------------------------------------------- geometry */
83
160
 
@@ -146,7 +223,14 @@
146
223
  --duration-press: 120ms;
147
224
  --duration-hover: 150ms;
148
225
  --duration-expand: 160ms;
149
- --duration-open: 180ms;
226
+ /* Menus, popovers, and dropdowns. Open is ease-out; close is shorter and ease-in. */
227
+ --duration-open: 120ms;
228
+ --duration-close: 90ms;
229
+ /* Tooltips use the same enter shape on a shorter open. */
230
+ --duration-tooltip: 100ms;
231
+ /* Visible on the first frame. Never enter from scale 0 or opacity 0. */
232
+ --popup-enter-opacity: 0.8;
233
+ --popup-enter-scale: 0.97;
150
234
 
151
235
  /* ------------------------------------------------------------------ icon */
152
236
 
@@ -264,6 +348,34 @@
264
348
  --color-white: #fff;
265
349
  }
266
350
 
351
+ /* One paper block. A host sets data-theme="paper" on the document element. */
352
+ :root[data-theme="paper"] {
353
+ --timeline-shell-bg: #f4f6f3;
354
+ --timeline-row-bg: #edf0ed;
355
+ --timeline-clip-bg: #c9d1cb;
356
+ --timeline-clip-shadow: 0 2px 6px rgb(0 0 0 / 0.12);
357
+ --timeline-clip-highlight: rgb(255 255 255 / 0.08);
358
+ --timeline-clip-selection: #0f766e;
359
+ --timeline-clip-selection-highlight: rgb(255 255 255 / 0.55);
360
+ --timeline-clip-chip-bg: rgba(15, 22, 18, 0.73);
361
+ --timeline-clip-chip-audio-bg: rgba(25, 16, 38, 0.7);
362
+ --timeline-clip-chip-text: #ffffff;
363
+ --timeline-clip-audio-bg: rgb(150, 128, 180);
364
+ --timeline-clip-audio-bg-hover: rgb(135, 109, 170);
365
+ --timeline-clip-audio-bg-dragging: rgb(232, 224, 242);
366
+ --timeline-clip-audio-border: rgba(113, 80, 155, 0.45);
367
+ --timeline-clip-audio-muted-bg: rgb(156, 161, 157);
368
+ --timeline-audio-link: #626d66;
369
+ --timeline-waveform-bar-rgb: 20, 12, 32;
370
+ --timeline-waveform-muted-rgb: 24, 28, 26;
371
+ --timeline-waveform-baseline-rgb: 76, 62, 88;
372
+ --timeline-waveform-error: repeating-linear-gradient(
373
+ 90deg,
374
+ rgba(20, 12, 32, 0.35) 0 2px,
375
+ transparent 2px 5px
376
+ );
377
+ }
378
+
267
379
  /* Motion durations as utilities, since Tailwind has no `--duration-*`
268
380
  * namespace. Each one zeroes itself under `prefers-reduced-motion`, so a
269
381
  * caller cannot forget the reduced-motion case. */
@@ -109,6 +109,15 @@ describe("studio theme", () => {
109
109
  expect(vars.get("--text-lg")).toBe("1.125rem");
110
110
  });
111
111
 
112
+ it("opens menus from a visible shape and closes them faster", () => {
113
+ expect(declaredValue("--duration-open")).toBe("120ms");
114
+ expect(declaredValue("--duration-close")).toBe("90ms");
115
+ expect(declaredValue("--duration-tooltip")).toBe("100ms");
116
+ expect(declaredValue("--popup-enter-opacity")).toBe("0.8");
117
+ expect(declaredValue("--popup-enter-scale")).toBe("0.97");
118
+ expect(declaredValue("--duration-hover")).toBe("150ms");
119
+ });
120
+
112
121
  it("compiles a motion-duration utility with a reduced-motion variant", async () => {
113
122
  const css = await build("studio.css", ["duration-press", "duration-open"]);
114
123
 
@@ -1,12 +1,12 @@
1
1
  import { create } from "zustand";
2
2
  import { readStudioUiPreferences, writeStudioUiPreferences } from "./studioUiPreferences";
3
3
 
4
- /** Whether the audio meter strip is shown; persisted, on unless the user hid it. */
4
+ /** Whether the audio meter strip is shown; persisted, off unless the user showed it. */
5
5
  export const useAudioMetersVisible = create<{
6
6
  visible: boolean;
7
7
  setVisible: (visible: boolean) => void;
8
8
  }>((set) => ({
9
- visible: readStudioUiPreferences().audioMetersVisible ?? true,
9
+ visible: readStudioUiPreferences().audioMetersVisible ?? false,
10
10
  setVisible: (visible) => {
11
11
  writeStudioUiPreferences({ audioMetersVisible: visible });
12
12
  set({ visible });
@@ -1,4 +1,4 @@
1
- import { describe, expect, it, vi } from "vitest";
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
2
  import {
3
3
  shouldUseSdkCutover,
4
4
  sdkCutoverPersist,
@@ -14,6 +14,7 @@ import {
14
14
  import { openComposition } from "@hyperframes/sdk";
15
15
  import { createMemoryAdapter } from "@hyperframes/sdk/adapters/memory";
16
16
  import type { PatchOperation } from "./sourcePatcher";
17
+ import { StudioSaveNetworkError } from "./studioSaveDiagnostics";
17
18
 
18
19
  vi.mock("../components/editor/manualEditingAvailability", () => ({
19
20
  STUDIO_SDK_CUTOVER_ENABLED: true,
@@ -1404,3 +1405,103 @@ gsap.timeline().to('[data-hf-id="hf-layer"]', { duration: 1, x: 100 });
1404
1405
  expect(written).toContain("gsap.timeline()");
1405
1406
  });
1406
1407
  });
1408
+
1409
+ describe("sdk_cutover_failed classification (error_kind)", () => {
1410
+ const makeDeps = (overrides: Partial<Parameters<typeof sdkCutoverPersist>[5]> = {}) => ({
1411
+ editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
1412
+ writeProjectFile: vi.fn().mockResolvedValue(undefined),
1413
+ reloadPreview: vi.fn(),
1414
+ ...candidateTestDeps(),
1415
+ ...overrides,
1416
+ });
1417
+
1418
+ const makeSession = () =>
1419
+ ({
1420
+ getElement: vi.fn().mockReturnValue({ inlineStyles: {} }),
1421
+ dispatch: vi.fn(),
1422
+ serialize: vi
1423
+ .fn()
1424
+ .mockReturnValueOnce("<html>before</html>")
1425
+ .mockReturnValue("<html></html>"),
1426
+ batch: vi.fn((fn: () => void) => fn()),
1427
+ }) as unknown as Parameters<typeof sdkCutoverPersist>[4];
1428
+
1429
+ beforeEach(() => {
1430
+ vi.mocked(trackStudioEvent).mockClear();
1431
+ });
1432
+
1433
+ // The production gate event this fix exists for: a bare `TypeError` from an
1434
+ // UNWRAPPED raw fetch (`readProjectFile`'s implementation in
1435
+ // useProjectFileWriter.ts, or writeProjectFile's own preflight — neither
1436
+ // wraps as StudioSaveNetworkError). Without the message-based fallback in
1437
+ // `cutoverErrorKind`, this files as `error_kind: "sdk"` and trips the
1438
+ // cutover-failure rollback gate on a network blip the SDK never owned.
1439
+ it("classifies a bare fetch TypeError from readProjectFile as network", async () => {
1440
+ const deps = makeDeps({
1441
+ readProjectFile: vi.fn().mockRejectedValue(new TypeError("Failed to fetch")),
1442
+ });
1443
+ const session = makeSession();
1444
+ const sel = { hfId: "hf-abc" } as never;
1445
+
1446
+ const result = await sdkCutoverPersist(
1447
+ sel,
1448
+ [styleOp("color", "red")],
1449
+ "before",
1450
+ "/path.html",
1451
+ session,
1452
+ deps,
1453
+ );
1454
+
1455
+ expect(result.status).toBe("failed");
1456
+ expect(trackStudioEvent).toHaveBeenCalledWith(
1457
+ "sdk_cutover_failed",
1458
+ expect.objectContaining({ family: "dom", error_kind: "network" }),
1459
+ );
1460
+ });
1461
+
1462
+ it("classifies StudioSaveNetworkError from writeProjectFile as network", async () => {
1463
+ const deps = makeDeps({
1464
+ writeProjectFile: vi.fn().mockRejectedValue(new StudioSaveNetworkError("Failed to save")),
1465
+ });
1466
+ const session = makeSession();
1467
+ const sel = { hfId: "hf-abc" } as never;
1468
+
1469
+ const result = await sdkCutoverPersist(
1470
+ sel,
1471
+ [styleOp("color", "red")],
1472
+ "before",
1473
+ "/path.html",
1474
+ session,
1475
+ deps,
1476
+ );
1477
+
1478
+ expect(result.status).toBe("failed");
1479
+ expect(trackStudioEvent).toHaveBeenCalledWith(
1480
+ "sdk_cutover_failed",
1481
+ expect.objectContaining({ family: "dom", error_kind: "network" }),
1482
+ );
1483
+ });
1484
+
1485
+ it("classifies a plain error as an sdk defect, not network", async () => {
1486
+ const deps = makeDeps({
1487
+ writeProjectFile: vi.fn().mockRejectedValue(new Error("disk full")),
1488
+ });
1489
+ const session = makeSession();
1490
+ const sel = { hfId: "hf-abc" } as never;
1491
+
1492
+ const result = await sdkCutoverPersist(
1493
+ sel,
1494
+ [styleOp("color", "red")],
1495
+ "before",
1496
+ "/path.html",
1497
+ session,
1498
+ deps,
1499
+ );
1500
+
1501
+ expect(result.status).toBe("failed");
1502
+ expect(trackStudioEvent).toHaveBeenCalledWith(
1503
+ "sdk_cutover_failed",
1504
+ expect.objectContaining({ family: "dom", error_kind: "sdk" }),
1505
+ );
1506
+ });
1507
+ });
@@ -11,8 +11,8 @@ import {
11
11
  shouldDeclineTextCutoverForTarget,
12
12
  } from "./sdkCutoverEligibility";
13
13
  import {
14
- asCutoverError,
15
14
  declinedCutover,
15
+ failedCutover,
16
16
  persistSdkCandidateMutation,
17
17
  type CutoverDeps,
18
18
  type CutoverOptions,
@@ -45,12 +45,13 @@ function sdkFamilyEnabled(family: StudioSdkOperationFamily): boolean {
45
45
 
46
46
  function trackCutoverResult(
47
47
  result: CutoverResult,
48
+ family: StudioSdkOperationFamily,
48
49
  context: { hfId?: string | null; opCount: number },
49
50
  ): void {
50
51
  if (result.status === "committed") {
51
- trackStudioEvent("sdk_cutover_success", context);
52
+ trackStudioEvent("sdk_cutover_success", { ...context, family });
52
53
  } else if (result.status === "failed") {
53
- trackStudioEvent("sdk_cutover_failed", { ...context, error: result.error.message });
54
+ failedCutover(result.error, family, context);
54
55
  }
55
56
  }
56
57
 
@@ -106,7 +107,7 @@ export async function sdkCutoverPersist(
106
107
  },
107
108
  options,
108
109
  );
109
- trackCutoverResult(result, { hfId, opCount: ops.length });
110
+ trackCutoverResult(result, "dom", { hfId, opCount: ops.length });
110
111
  return result;
111
112
  }
112
113
 
@@ -148,12 +149,10 @@ export async function sdkTimingPersist(
148
149
  options,
149
150
  serializedBefore,
150
151
  );
151
- trackCutoverResult(result, { hfId, opCount: 1 });
152
+ trackCutoverResult(result, "timing", { hfId, opCount: 1 });
152
153
  return result;
153
154
  } catch (error) {
154
- const failed = { status: "failed", error: asCutoverError(error) } as const;
155
- trackStudioEvent("sdk_cutover_failed", { hfId, error: failed.error.message });
156
- return failed;
155
+ return failedCutover(error, "timing", { hfId, opCount: 1 });
157
156
  }
158
157
  }
159
158
 
@@ -202,24 +201,22 @@ export async function sdkTimingBatchPersist(
202
201
  serializedBefore,
203
202
  );
204
203
  if (result.status === "failed") {
205
- trackStudioEvent("sdk_cutover_failed", {
204
+ return failedCutover(result.error, "timing", {
206
205
  hfId: changes[0]?.hfId ?? null,
207
- error: result.error.message,
206
+ opCount: changes.length,
208
207
  });
209
- return result;
210
208
  }
211
209
  trackStudioEvent("sdk_cutover_success", {
212
210
  hfId: changes[0]?.hfId ?? null,
213
211
  opCount: changes.length,
212
+ family: "timing",
214
213
  });
215
214
  return result;
216
215
  } catch (error) {
217
- const failed = { status: "failed", error: asCutoverError(error) } as const;
218
- trackStudioEvent("sdk_cutover_failed", {
216
+ return failedCutover(error, "timing", {
219
217
  hfId: changes[0]?.hfId ?? null,
220
- error: failed.error.message,
218
+ opCount: changes.length,
221
219
  });
222
- return failed;
223
220
  }
224
221
  }
225
222
 
@@ -321,15 +318,13 @@ async function dispatchGsapOpAndPersist(
321
318
  serializedBefore,
322
319
  );
323
320
  if (result.status === "committed") {
324
- trackStudioEvent("sdk_cutover_success", { opCount: 1 });
321
+ trackStudioEvent("sdk_cutover_success", { opCount: 1, family });
325
322
  } else if (result.status === "failed") {
326
- trackStudioEvent("sdk_cutover_failed", { error: result.error.message });
323
+ return failedCutover(result.error, family, { opCount: 1 });
327
324
  }
328
325
  return result;
329
326
  } catch (error) {
330
- const failed = { status: "failed", error: asCutoverError(error) } as const;
331
- trackStudioEvent("sdk_cutover_failed", { error: failed.error.message });
332
- return failed;
327
+ return failedCutover(error, family, { opCount: 1 });
333
328
  }
334
329
  }
335
330
 
@@ -595,6 +590,6 @@ export async function sdkDeletePersist(
595
590
  (session) => session.removeElement(hfId),
596
591
  { label: "Delete element" },
597
592
  );
598
- trackCutoverResult(result, { hfId, opCount: 1 });
593
+ trackCutoverResult(result, "lifecycle", { hfId, opCount: 1 });
599
594
  return result;
600
595
  }