@hyperframes/studio 0.7.21 → 0.7.23

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 (35) hide show
  1. package/dist/assets/hyperframes-player-BGuumSiM.js +459 -0
  2. package/dist/assets/{index-jbPe1Dih.js → index-B8XMgbWF.js} +157 -157
  3. package/dist/assets/{index-pAaVqALC.js → index-BRZTrsSs.js} +1 -1
  4. package/dist/assets/{index-gFA786gK.js → index-DPO5Gw_v.js} +1 -1
  5. package/dist/{chunk-SBGXX7WY.js → chunk-AN2EWWK3.js} +59 -108
  6. package/dist/chunk-AN2EWWK3.js.map +1 -0
  7. package/dist/{domEditingLayers-VZMLL4AP.js → domEditingLayers-EK7R7R4G.js} +4 -2
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +163 -99
  10. package/dist/index.js.map +1 -1
  11. package/package.json +7 -7
  12. package/src/App.tsx +2 -5
  13. package/src/components/StudioHeader.tsx +2 -3
  14. package/src/components/editor/PropertyPanel.tsx +11 -8
  15. package/src/components/editor/domEditingDom.ts +4 -33
  16. package/src/components/editor/domEditingLayers.ts +88 -108
  17. package/src/components/editor/manualEditingAvailability.ts +0 -10
  18. package/src/components/editor/propertyPanelColorGradingSection.tsx +0 -4
  19. package/src/components/editor/propertyPanelMediaSection.tsx +0 -6
  20. package/src/contexts/ViewModeContext.tsx +10 -19
  21. package/src/hooks/useDomEditSession.test.tsx +277 -0
  22. package/src/hooks/useDomEditSession.ts +7 -4
  23. package/src/telemetry/config.ts +10 -33
  24. package/src/telemetry/distinctId.test.ts +103 -0
  25. package/src/telemetry/distinctId.ts +125 -0
  26. package/src/utils/safeStorage.ts +20 -0
  27. package/src/utils/sdkCutover.ts +21 -112
  28. package/src/utils/sdkCutoverEligibility.ts +116 -0
  29. package/src/utils/sdkResolverShadow.test.ts +101 -5
  30. package/src/utils/sdkResolverShadow.ts +67 -4
  31. package/src/utils/studioTelemetry.ts +5 -19
  32. package/dist/assets/hyperframes-player-DNLS_l47.js +0 -459
  33. package/dist/chunk-SBGXX7WY.js.map +0 -1
  34. package/src/hooks/useDomEditSession.test.ts +0 -41
  35. /package/dist/{domEditingLayers-VZMLL4AP.js.map → domEditingLayers-EK7R7R4G.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.21",
3
+ "version": "0.7.23",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,11 +46,11 @@
46
46
  "gsap": "^3.13.0",
47
47
  "marked": "^14.1.4",
48
48
  "mediabunny": "^1.45.3",
49
- "@hyperframes/parsers": "0.7.21",
50
- "@hyperframes/player": "0.7.21",
51
- "@hyperframes/studio-server": "0.7.21",
52
- "@hyperframes/sdk": "0.7.21",
53
- "@hyperframes/core": "0.7.21"
49
+ "@hyperframes/core": "0.7.23",
50
+ "@hyperframes/player": "0.7.23",
51
+ "@hyperframes/sdk": "0.7.23",
52
+ "@hyperframes/studio-server": "0.7.23",
53
+ "@hyperframes/parsers": "0.7.23"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/react": "19",
@@ -65,7 +65,7 @@
65
65
  "vite": "^6.4.2",
66
66
  "vitest": "^3.2.4",
67
67
  "zustand": "^5.0.0",
68
- "@hyperframes/producer": "0.7.21"
68
+ "@hyperframes/producer": "0.7.23"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "react": "19",
package/src/App.tsx CHANGED
@@ -36,10 +36,7 @@ import {
36
36
  import type { DomEditSelection } from "./components/editor/domEditing";
37
37
  import { StudioHeader } from "./components/StudioHeader";
38
38
  import { useGestureCommit } from "./hooks/useGestureCommit";
39
- import {
40
- STUDIO_KEYFRAMES_ENABLED,
41
- STUDIO_STORYBOARD_ENABLED,
42
- } from "./components/editor/manualEditingAvailability";
39
+ import { STUDIO_KEYFRAMES_ENABLED } from "./components/editor/manualEditingAvailability";
43
40
  import { GestureTrailOverlay } from "./components/editor/GestureTrailOverlay";
44
41
  import { StudioLeftSidebar } from "./components/StudioLeftSidebar";
45
42
  import { StudioPreviewArea } from "./components/StudioPreviewArea";
@@ -65,7 +62,7 @@ type CanvasRect = { left: number; top: number; width: number; height: number };
65
62
  export function StudioApp() {
66
63
  const { projectId, resolving, waitingForServer } = useServerConnection();
67
64
  const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
68
- const viewModeValue = useViewModeState(STUDIO_STORYBOARD_ENABLED);
65
+ const viewModeValue = useViewModeState();
69
66
 
70
67
  // sessionStorage-backed: fires once per tab, survives HMR remounts
71
68
  useEffect(() => {
@@ -3,7 +3,6 @@ import { RotateCcw, RotateCw, Camera } from "../icons/SystemIcons";
3
3
  import {
4
4
  STUDIO_INSPECTOR_PANELS_ENABLED,
5
5
  STUDIO_MANUAL_EDITING_DISABLED_TITLE,
6
- STUDIO_STORYBOARD_ENABLED,
7
6
  } from "./editor/manualEditingAvailability";
8
7
  import { getHistoryShortcutLabel } from "../utils/studioHelpers";
9
8
  import { useStudioShellContext } from "../contexts/StudioContext";
@@ -204,8 +203,8 @@ export function StudioHeader({
204
203
  </span>
205
204
  <span className="text-[11px] font-medium text-neutral-300">{projectId}</span>
206
205
  </div>
207
- {/* Center: storyboard / preview toggle (flag-gated) */}
208
- {STUDIO_STORYBOARD_ENABLED && <ViewModeToggle />}
206
+ {/* Center: storyboard / preview toggle */}
207
+ <ViewModeToggle />
209
208
  {/* Right: toolbar buttons */}
210
209
  <div className="flex items-center gap-1.5">
211
210
  <button
@@ -14,11 +14,10 @@ import {
14
14
  import { MetricField, Section } from "./propertyPanelPrimitives";
15
15
  import { createTransformCommitHandlers } from "./propertyPanelTransformCommit";
16
16
  import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
17
- import { isMediaElement, MediaSection } from "./propertyPanelMediaSection";
18
- import {
19
- ColorGradingSection,
20
- isColorGradingCapableElement,
21
- } from "./propertyPanelColorGradingSection";
17
+ import { resolveEditingSections } from "@hyperframes/core/editing";
18
+ import { MediaSection } from "./propertyPanelMediaSection";
19
+ import { ColorGradingSection } from "./propertyPanelColorGradingSection";
20
+ import { domEditSelectionToFacts } from "./domEditingLayers";
22
21
  import { TextSection, StyleSections } from "./propertyPanelSections";
23
22
  import { GsapAnimationSection } from "./GsapAnimationSection";
24
23
  import { PropertyPanel3dTransform } from "./propertyPanel3dTransform";
@@ -199,6 +198,10 @@ export const PropertyPanel = memo(function PropertyPanel({
199
198
  const manualRotationEditingDisabled = !element.capabilities.canApplyManualRotation;
200
199
  const sourceLabel = element.id ? `#${element.id}` : element.selector;
201
200
  const showEditableSections = element.capabilities.canEditStyles;
201
+ // Capabilities are already resolved on the selection; recompute only sections,
202
+ // feeding the live GSAP tween count (arrives on the gsapAnimations prop, not the
203
+ // selection) so the Timing section shows for pure-GSAP elements with no data-start.
204
+ const sections = resolveEditingSections(domEditSelectionToFacts(element, gsapAnimations.length));
202
205
  const manualOffset = readStudioPathOffset(element.element);
203
206
  const manualSize = readStudioBoxSize(element.element);
204
207
  const resolvedWidth =
@@ -339,7 +342,7 @@ export const PropertyPanel = memo(function PropertyPanel({
339
342
  onRemoveTextField={onRemoveTextField}
340
343
  />
341
344
 
342
- {(element.dataAttributes.start != null || gsapAnimations.length > 0) && (
345
+ {sections.timing && (
343
346
  // Render whenever there's an authored clip range OR animations to infer
344
347
  // one from — a pure-GSAP element with no data-start still gets a Timing
345
348
  // range (TimingSection derives it from its tweens).
@@ -349,7 +352,7 @@ export const PropertyPanel = memo(function PropertyPanel({
349
352
  onSetAttribute={onSetAttribute}
350
353
  />
351
354
  )}
352
- {isMediaElement(element) && (
355
+ {sections.media && (
353
356
  <MediaSection
354
357
  projectDir={projectDir}
355
358
  element={element}
@@ -360,7 +363,7 @@ export const PropertyPanel = memo(function PropertyPanel({
360
363
  />
361
364
  )}
362
365
 
363
- {STUDIO_COLOR_GRADING_ENABLED && isColorGradingCapableElement(element) && (
366
+ {STUDIO_COLOR_GRADING_ENABLED && sections.colorGrading && (
364
367
  <ColorGradingSection
365
368
  key={[
366
369
  element.id ?? "",
@@ -19,39 +19,10 @@ export function isHtmlElement(value: unknown): value is HTMLElement {
19
19
 
20
20
  // ─── Style parsing ────────────────────────────────────────────────────────────
21
21
 
22
- export function parsePx(value: string | undefined): number | null {
23
- if (!value) return null;
24
- const trimmed = value.trim();
25
- if (!trimmed.endsWith("px")) return null;
26
- const parsed = parseFloat(trimmed);
27
- return Number.isFinite(parsed) ? parsed : null;
28
- }
29
-
30
- export function isIdentityTransform(value: string | undefined): boolean {
31
- const transform = (value ?? "none").trim();
32
- if (!transform || transform === "none") return true;
33
-
34
- const matrix = transform.match(/^matrix\(([^)]+)\)$/i);
35
- if (matrix) {
36
- const values = matrix[1].split(",").map((part) => Number.parseFloat(part.trim()));
37
- if (values.length !== 6 || values.some((part) => !Number.isFinite(part))) return false;
38
- return (
39
- Math.abs(values[0] - 1) < 0.0001 &&
40
- Math.abs(values[1]) < 0.0001 &&
41
- Math.abs(values[2]) < 0.0001 &&
42
- Math.abs(values[3] - 1) < 0.0001 &&
43
- Math.abs(values[4]) < 0.0001 &&
44
- Math.abs(values[5]) < 0.0001
45
- );
46
- }
47
-
48
- const matrix3d = transform.match(/^matrix3d\(([^)]+)\)$/i);
49
- if (!matrix3d) return false;
50
- const values = matrix3d[1].split(",").map((part) => Number.parseFloat(part.trim()));
51
- if (values.length !== 16 || values.some((part) => !Number.isFinite(part))) return false;
52
- const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
53
- return values.every((part, index) => Math.abs(part - identity[index]) < 0.0001);
54
- }
22
+ // Single source of truth lives in @hyperframes/core/editing so the studio
23
+ // callers and the core resolver can't drift. Re-exported here to keep this
24
+ // module's public surface (6 studio callers import parsePx from it).
25
+ export { parsePx } from "@hyperframes/core/editing";
55
26
 
56
27
  export function isTextBearingTag(tagName: string): boolean {
57
28
  return ["div", "span", "p", "strong", "h1", "h2", "h3", "h4", "h5", "h6"].includes(tagName);
@@ -3,6 +3,11 @@
3
3
  * for dom editing.
4
4
  */
5
5
  import type { PatchOperation } from "../../utils/sourcePatcher";
6
+ import {
7
+ resolveEditingAffordances,
8
+ resolveEditingSections,
9
+ type EditableElementFacts,
10
+ } from "@hyperframes/core/editing";
6
11
  import { groupScopedLayerRoots, resolveGroupCapture } from "./domEditingGroups";
7
12
  import type {
8
13
  DomEditCapabilities,
@@ -21,9 +26,7 @@ import {
21
26
  getSelectorIndex,
22
27
  getSourceFileForElement,
23
28
  isHtmlElement,
24
- isIdentityTransform,
25
29
  isTextBearingTag,
26
- parsePx,
27
30
  } from "./domEditingDom";
28
31
  import {
29
32
  findElementForSelection,
@@ -171,12 +174,66 @@ export function buildDefaultDomEditTextField(base?: Partial<DomEditTextField>):
171
174
 
172
175
  // ─── Capabilities ────────────────────────────────────────────────────────────
173
176
 
174
- // fallow-ignore-next-line complexity
177
+ /**
178
+ * Build the geometry/capability half of EditableElementFacts. Section inputs
179
+ * (text/timing/animation) are irrelevant to capability resolution, so they are
180
+ * zeroed here. Shared by the wrapper and the live-selection path so the two
181
+ * fact-construction sites can't disagree.
182
+ */
183
+ function capabilityFacts(geometry: {
184
+ hasStableTarget: boolean;
185
+ tag: string;
186
+ inlineStyles: Record<string, string>;
187
+ computedStyles: Record<string, string>;
188
+ isCompositionHost: boolean;
189
+ isCompositionRoot: boolean;
190
+ isInsideLockedComposition: boolean;
191
+ isMasterView: boolean;
192
+ existsInSource: boolean;
193
+ }): EditableElementFacts {
194
+ return {
195
+ ...geometry,
196
+ hasEditableText: false,
197
+ hasTimingStart: false,
198
+ animationCount: 0,
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Build core EditableElementFacts from a fully-resolved DomEditSelection.
204
+ * `animationCount` is supplied by the caller because live GSAP tweens arrive on
205
+ * a separate channel (the PropertyPanel `gsapAnimations` prop), not on the
206
+ * selection — `selection.gsapAnimations` is never populated.
207
+ */
208
+ export function domEditSelectionToFacts(
209
+ selection: DomEditSelection,
210
+ animationCount = selection.gsapAnimations?.length ?? 0,
211
+ ): EditableElementFacts {
212
+ return {
213
+ hasStableTarget: Boolean(selection.selector || selection.hfId),
214
+ tag: selection.tagName,
215
+ inlineStyles: selection.inlineStyles,
216
+ computedStyles: selection.computedStyles,
217
+ isCompositionHost: selection.isCompositionHost,
218
+ isCompositionRoot: false,
219
+ isInsideLockedComposition: selection.isInsideLockedComposition,
220
+ isMasterView: false,
221
+ existsInSource: true,
222
+ hasEditableText: selection.textFields.length > 0,
223
+ hasTimingStart: selection.dataAttributes.start != null,
224
+ animationCount,
225
+ };
226
+ }
227
+
228
+ /**
229
+ * Resolve DOM edit capabilities for a given element.
230
+ * Thin wrapper over core resolveEditingAffordances — kept for backward
231
+ * compatibility (tests and the barrel import this signature directly).
232
+ */
175
233
  export function resolveDomEditCapabilities(args: {
176
234
  selector?: string;
177
235
  hfId?: string;
178
236
  tagName?: string;
179
- className?: string;
180
237
  inlineStyles: Record<string, string>;
181
238
  computedStyles: Record<string, string>;
182
239
  isCompositionHost: boolean;
@@ -185,92 +242,19 @@ export function resolveDomEditCapabilities(args: {
185
242
  isMasterView: boolean;
186
243
  existsInSource?: boolean;
187
244
  }): DomEditCapabilities {
188
- if ((!args.selector && !args.hfId) || args.isInsideLockedComposition) {
189
- return {
190
- canSelect: !args.isInsideLockedComposition,
191
- canEditStyles: false,
192
- canMove: false,
193
- canResize: false,
194
- canApplyManualOffset: false,
195
- canApplyManualSize: false,
196
- canApplyManualRotation: false,
197
- reasonIfDisabled: args.isInsideLockedComposition
198
- ? "This element belongs to a locked composition."
199
- : "Studio could not resolve a stable patch target for this element.",
200
- };
201
- }
202
-
203
- if (args.existsInSource === false) {
204
- return {
205
- canSelect: true,
206
- canEditStyles: false,
207
- canMove: false,
208
- canResize: false,
209
- canApplyManualOffset: false,
210
- canApplyManualSize: false,
211
- canApplyManualRotation: false,
212
- reasonIfDisabled: "This element is generated by a script and cannot be edited visually.",
213
- };
214
- }
215
-
216
- if (args.isCompositionRoot) {
217
- return {
218
- canSelect: true,
219
- canEditStyles: true,
220
- canMove: false,
221
- canResize: false,
222
- canApplyManualOffset: false,
223
- canApplyManualSize: false,
224
- canApplyManualRotation: false,
225
- reasonIfDisabled: "The root composition defines the preview bounds.",
226
- };
227
- }
228
-
229
- const position = args.computedStyles.position;
230
- const left = parsePx(args.inlineStyles.left) ?? parsePx(args.computedStyles.left);
231
- const top = parsePx(args.inlineStyles.top) ?? parsePx(args.computedStyles.top);
232
- const width = parsePx(args.inlineStyles.width) ?? parsePx(args.computedStyles.width);
233
- const height = parsePx(args.inlineStyles.height) ?? parsePx(args.computedStyles.height);
234
- const hasTransformDrivenGeometry = !isIdentityTransform(args.computedStyles.transform);
235
-
236
- const canMove =
237
- (position === "absolute" || position === "fixed") &&
238
- left != null &&
239
- top != null &&
240
- !hasTransformDrivenGeometry;
241
-
242
- const canResize = canMove && (width != null || height != null);
243
- const canApplyManualGeometry = !args.isCompositionHost;
244
- const canApplyManualOffset = canApplyManualGeometry;
245
- const canApplyManualSize = canApplyManualGeometry;
246
- const canApplyManualRotation = canApplyManualGeometry;
247
- const reasonIfDisabled = canApplyManualGeometry
248
- ? undefined
249
- : "Select an internal layer to transform it.";
250
-
251
- if (args.isCompositionHost && args.isMasterView) {
252
- return {
253
- canSelect: true,
254
- canEditStyles: false,
255
- canMove,
256
- canResize,
257
- canApplyManualOffset,
258
- canApplyManualSize,
259
- canApplyManualRotation,
260
- reasonIfDisabled,
261
- };
262
- }
263
-
264
- return {
265
- canSelect: true,
266
- canEditStyles: true,
267
- canMove,
268
- canResize,
269
- canApplyManualOffset,
270
- canApplyManualSize,
271
- canApplyManualRotation,
272
- reasonIfDisabled,
273
- };
245
+ return resolveEditingAffordances(
246
+ capabilityFacts({
247
+ hasStableTarget: Boolean(args.selector || args.hfId),
248
+ tag: (args.tagName ?? "div").toLowerCase(),
249
+ inlineStyles: args.inlineStyles,
250
+ computedStyles: args.computedStyles,
251
+ isCompositionHost: args.isCompositionHost,
252
+ isCompositionRoot: args.isCompositionRoot ?? false,
253
+ isInsideLockedComposition: args.isInsideLockedComposition ?? false,
254
+ isMasterView: args.isMasterView,
255
+ existsInSource: args.existsInSource ?? true,
256
+ }),
257
+ ).capabilities;
274
258
  }
275
259
 
276
260
  // ─── Element label ────────────────────────────────────────────────────────────
@@ -354,19 +338,19 @@ export async function resolveDomEditSelection(
354
338
  if (selectorIndex != null) probeTarget.selectorIndex = selectorIndex;
355
339
  existsInSource = await probeSourceElement(options.projectId, sourceFile, probeTarget);
356
340
  }
357
- const capabilities = resolveDomEditCapabilities({
358
- selector,
359
- hfId,
360
- tagName: current.tagName.toLowerCase(),
361
- className: current.className,
362
- inlineStyles,
363
- computedStyles,
364
- isCompositionHost: Boolean(compositionSrc),
365
- isCompositionRoot,
366
- isInsideLockedComposition: isInsideLocked,
367
- isMasterView: options.isMasterView,
368
- existsInSource,
369
- });
341
+ const capabilities = resolveEditingAffordances(
342
+ capabilityFacts({
343
+ hasStableTarget: Boolean(selector || hfId),
344
+ tag: current.tagName.toLowerCase(),
345
+ inlineStyles,
346
+ computedStyles,
347
+ isCompositionHost: Boolean(compositionSrc),
348
+ isCompositionRoot,
349
+ isInsideLockedComposition: isInsideLocked,
350
+ isMasterView: options.isMasterView,
351
+ existsInSource: existsInSource ?? true,
352
+ }),
353
+ ).capabilities;
370
354
  const rect = current.getBoundingClientRect();
371
355
 
372
356
  return {
@@ -554,11 +538,7 @@ export function getDomEditTargetKey(
554
538
  }
555
539
 
556
540
  export function isTextEditableSelection(selection: DomEditSelection): boolean {
557
- return (
558
- selection.textFields.length > 0 &&
559
- !selection.isCompositionHost &&
560
- !selection.isInsideLockedComposition
561
- );
541
+ return resolveEditingSections(domEditSelectionToFacts(selection)).text;
562
542
  }
563
543
 
564
544
  // buildElementAgentPrompt is in domEditingAgentPrompt.ts
@@ -82,16 +82,6 @@ export const STUDIO_RAZOR_TOOL_ENABLED = resolveStudioBooleanEnvFlag(
82
82
  true,
83
83
  );
84
84
 
85
- // Storyboard view: a top-level, toggleable view that renders STORYBOARD.md as a
86
- // contact sheet of live HTML frame tiles, replacing the timeline/preview stage.
87
- // Opt-in / off by default until the experience is ready for broad exposure.
88
- // VITE_STUDIO_ENABLE_STORYBOARD=1 npx hyperframes preview
89
- export const STUDIO_STORYBOARD_ENABLED = resolveStudioBooleanEnvFlag(
90
- env,
91
- ["VITE_STUDIO_ENABLE_STORYBOARD", "VITE_STUDIO_STORYBOARD_ENABLED"],
92
- false,
93
- );
94
-
95
85
  export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
96
86
 
97
87
  // Stage 7 Step 3c: SDK cutover — routes inline-style ops through SDK dispatch
@@ -50,10 +50,6 @@ interface RuntimeColorGradingStatus {
50
50
  message: string;
51
51
  }
52
52
 
53
- export function isColorGradingCapableElement(element: DomEditSelection): boolean {
54
- return element.tagName === "video" || element.tagName === "img";
55
- }
56
-
57
53
  function readColorGradingFromElement(element: DomEditSelection): NormalizedHfColorGrading {
58
54
  const grading =
59
55
  normalizeHfColorGrading(element.dataAttributes[COLOR_GRADING_DATA_KEY]) ??
@@ -10,12 +10,6 @@ import {
10
10
  } from "./propertyPanelHelpers";
11
11
  import { Section, SegmentedControl, SelectField, SliderControl } from "./propertyPanelPrimitives";
12
12
 
13
- const MEDIA_TAGS = new Set(["video", "audio"]);
14
-
15
- export function isMediaElement(element: DomEditSelection): boolean {
16
- return MEDIA_TAGS.has(element.tagName);
17
- }
18
-
19
13
  export function MediaSection({
20
14
  projectDir,
21
15
  element,
@@ -44,14 +44,11 @@ export interface ViewModeValue {
44
44
  }
45
45
 
46
46
  /**
47
- * Owns the view-mode state. When `enabled` is false (storyboard flag off) the
48
- * mode is pinned to `timeline` and the URL is left untouched, so the feature is
49
- * fully inert until the flag is on.
47
+ * Owns the view-mode state initial read from `?view=`, toggling, popstate sync.
48
+ * Storyboard mode is always available; no flag gating.
50
49
  */
51
- export function useViewModeState(enabled: boolean): ViewModeValue {
52
- const [viewMode, setMode] = useState<StudioViewMode>(() =>
53
- enabled ? readViewModeFromUrl() : "timeline",
54
- );
50
+ export function useViewModeState(): ViewModeValue {
51
+ const [viewMode, setMode] = useState<StudioViewMode>(() => readViewModeFromUrl());
55
52
 
56
53
  // Reflect genuine browser back/forward between history entries with a different
57
54
  // `?view=`. Note: our own writes use `replaceState` (below), which does NOT fire
@@ -60,23 +57,17 @@ export function useViewModeState(enabled: boolean): ViewModeValue {
60
57
  // the mount-time read); a scripted `pushState`/`replaceState` to `?view=` would not be
61
58
  // reflected here, by design.
62
59
  useEffect(() => {
63
- if (!enabled) return;
64
60
  const onPopState = () => setMode(readViewModeFromUrl());
65
61
  window.addEventListener("popstate", onPopState);
66
62
  return () => window.removeEventListener("popstate", onPopState);
67
- }, [enabled]);
63
+ }, []);
68
64
 
69
- const setViewMode = useCallback(
70
- (mode: StudioViewMode) => {
71
- if (!enabled) return;
72
- setMode(mode);
73
- writeViewModeToUrl(mode);
74
- },
75
- [enabled],
76
- );
65
+ const setViewMode = useCallback((mode: StudioViewMode) => {
66
+ setMode(mode);
67
+ writeViewModeToUrl(mode);
68
+ }, []);
77
69
 
78
- const effectiveMode = enabled ? viewMode : "timeline";
79
- return useMemo(() => ({ viewMode: effectiveMode, setViewMode }), [effectiveMode, setViewMode]);
70
+ return useMemo(() => ({ viewMode, setViewMode }), [viewMode, setViewMode]);
80
71
  }
81
72
 
82
73
  const ViewModeContext = createContext<ViewModeValue | null>(null);