diff --git a/packages/sdk/src/engine/apply-patches.ts b/packages/sdk/src/engine/apply-patches.ts index 57df576bb..4b4c80ffc 100644 --- a/packages/sdk/src/engine/apply-patches.ts +++ b/packages/sdk/src/engine/apply-patches.ts @@ -124,7 +124,20 @@ function applyVariableDefault(document: Document, id: string, newDefault: unknow export function applyOverrideSet(parsed: ParsedDocument, overrides: OverrideSet): void { const patches: JsonPatchOp[] = []; const rootId = findRoot(parsed.document)?.getAttribute("data-hf-id") ?? null; - for (const [key, value] of Object.entries(overrides)) { + // Whole-declaration snapshots (varDecl.{id}) must replay BEFORE value keys + // (var.{id}): a declaration snapshot embeds the default at fold time, while + // var.{id} always carries the latest value — insertion order alone would let + // an older snapshot clobber a newer value. + const entries = Object.entries(overrides).sort(([a], [b]) => { + const aVar = a.startsWith("var."); + const bVar = b.startsWith("var."); + const aDecl = a.startsWith("varDecl."); + const bDecl = b.startsWith("varDecl."); + if (aVar && bDecl) return 1; + if (aDecl && bVar) return -1; + return 0; // stable — every other key keeps its insertion order + }); + for (const [key, value] of entries) { const path = keyToPath(key); if (!path) continue; if (value === null) { diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts index 61dcce69a..2e902e00e 100644 --- a/packages/sdk/src/engine/mutate.ts +++ b/packages/sdk/src/engine/mutate.ts @@ -869,37 +869,21 @@ function handleSetVariableValue( const modelPath = variablePath(id); const oldVarDefault = readVariableDefault(parsed.document, id); - if (isObjectVariableValue(value)) { - // Object values (font / image): write to JSON model only — objects are not - // valid CSS custom property values (LOCKED §7). - writeVariableDefault(parsed.document, id, value); - const p = valueChange(modelPath, oldVarDefault ?? null, value); - return { forward: [p.forward], inverse: [p.inverse] }; - } - - // Scalar values: update the JSON model (B1 — drives the runtime) and also - // keep the CSS custom prop as secondary / compat for compositions that - // CSS-bind directly to --{id}. - const cssVar = `--${id}`; - const rootId = root.getAttribute("data-hf-id"); - const oldStyles = getElementStyles(root); - const oldCssValue = oldStyles[cssVar] ?? null; - const newVal = String(value); - setElementStyles(root, { [cssVar]: newVal }); + // Update the JSON model (B1 — drives the runtime) and keep the CSS custom + // prop as secondary / compat for compositions that CSS-bind directly to + // --{id}. Object values (font / image) are not valid CSS custom property + // values (LOCKED §7) — cssCompatChange clears any stale scalar prop instead. + // Emitting separate model + style patches keeps apply-patches.ts pure per + // path type, so inverse patches restore the exact pre-call state. writeVariableDefault(parsed.document, id, value); - - // Emit explicit patches for both the JSON model (canonical) and the CSS compat - // prop. Keeping them separate means apply-patches.ts can handle each path type - // purely (variable path → model only; style path → CSS only), so inverse patches - // correctly restore the exact pre-call state without CSS-side-effect ambiguity. const modelP = valueChange(modelPath, oldVarDefault ?? null, value); const forward: JsonPatchOp[] = [modelP.forward]; const inverse: JsonPatchOp[] = [modelP.inverse]; - if (rootId) { - const cssPatch = scalarChange(stylePath(rootId, cssVar), oldCssValue, newVal); - forward.push(cssPatch.forward); - inverse.push(cssPatch.inverse); + const css = cssCompatChange(parsed, id, isObjectVariableValue(value) ? null : String(value)); + if (css) { + forward.push(css.forward); + inverse.push(css.inverse); } return { forward, inverse }; diff --git a/packages/sdk/src/session.ts b/packages/sdk/src/session.ts index fbac623e2..cd018ee5d 100644 --- a/packages/sdk/src/session.ts +++ b/packages/sdk/src/session.ts @@ -240,14 +240,7 @@ class CompositionImpl implements Composition { // The CSS compat channel counts as usage: a variable consumed only via // var(--id) in stylesheets or inline styles must not be badged unused // (removing it also removes the --{id} root prop and breaks the binding). - const cssParts: string[] = []; - for (const styleEl of Array.from(this.parsed.document.querySelectorAll("style"))) { - cssParts.push(styleEl.textContent ?? ""); - } - for (const el of Array.from(this.parsed.document.querySelectorAll("[style]"))) { - cssParts.push(el.getAttribute("style") ?? ""); - } - const cssText = cssParts.join("\n"); + const cssText = this._collectCssText(); // Match var(--id) only at a custom-property-name boundary: the id must be // followed by whitespace, a comma (fallback), or the closing paren — so id // "foo" is NOT counted as used by an unrelated var(--foo-header). Ids are @@ -264,6 +257,18 @@ class CompositionImpl implements Composition { }; } + /** All stylesheet + inline-style text, for var(--id) consumption checks. */ + private _collectCssText(): string { + const cssParts: string[] = []; + for (const styleEl of Array.from(this.parsed.document.querySelectorAll("style"))) { + cssParts.push(styleEl.textContent ?? ""); + } + for (const el of Array.from(this.parsed.document.querySelectorAll("[style]"))) { + cssParts.push(el.getAttribute("style") ?? ""); + } + return cssParts.join("\n"); + } + setPreviewVariables(values: Record | null): boolean { if (!this.preview?.setPreviewVariables) return false; this.preview.setPreviewVariables(values); diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 5dd7e2eb4..1131999f7 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -13,8 +13,9 @@ import { usePreviewPersistence } from "./hooks/usePreviewPersistence"; import { useTimelineEditing } from "./hooks/useTimelineEditing"; import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab"; import { useDomEditSession } from "./hooks/useDomEditSession"; -import { useSdkSession } from "./hooks/useSdkSession"; import { useSdkSelectionSync } from "./hooks/useSdkSelectionSync"; +import { useStudioSdkSessions } from "./hooks/useStudioSdkSessions"; +import { usePreviewDocumentVersion } from "./hooks/usePreviewDocumentVersion"; import { useBlockHandlers } from "./hooks/useBlockHandlers"; import { useAppHotkeys } from "./hooks/useAppHotkeys"; import { useClipboard } from "./hooks/useClipboard"; @@ -54,6 +55,7 @@ import { useServerConnection } from "./hooks/useServerConnection"; import { normalizeStudioCompositionPath, readStudioUrlStateFromWindow, + resolveMasterCompositionPath, } from "./utils/studioUrlState"; import { trackStudioSessionStart } from "./telemetry/events"; import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config"; @@ -80,7 +82,7 @@ export function StudioApp() { const [previewIframe, setPreviewIframe] = useState(null); const [compositionLoading, setCompositionLoading] = useState(true); const [refreshKey, setRefreshKey] = useState(0); - const [previewDocumentVersion, setPreviewDocumentVersion] = useState(0); + const [previewDocumentVersion, refreshPreviewDocumentVersion] = usePreviewDocumentVersion(); const [blockPreview, setBlockPreview] = useState(null); const previewIframeRef = useRef(null); const activeCompPathRef = useRef(activeCompPath); @@ -105,22 +107,6 @@ export function StudioApp() { : 0; return Math.max(timelineDuration, maxEnd); }, [timelineDuration, timelineElements]); - const refreshTimersRef = useRef([]); - const refreshPreviewDocumentVersion = useCallback(() => { - for (const id of refreshTimersRef.current) clearTimeout(id); - refreshTimersRef.current = []; - setPreviewDocumentVersion((v) => v + 1); - refreshTimersRef.current.push( - window.setTimeout(() => setPreviewDocumentVersion((v) => v + 1), 80), - window.setTimeout(() => setPreviewDocumentVersion((v) => v + 1), 300), - ); - }, []); - useEffect( - () => () => { - for (const id of refreshTimersRef.current) clearTimeout(id); - }, - [], - ); const [timelineVisible, setTimelineVisible] = useState( () => initialUrlStateRef.current.timelineVisible ?? @@ -150,7 +136,16 @@ export function StudioApp() { domEditSaveTimestampRef, setRefreshKey, }); - const sdkHandle = useSdkSession(projectId, activeCompPath, domEditSaveTimestampRef); + const masterCompPath = useMemo( + () => resolveMasterCompositionPath(fileManager.fileTree), + [fileManager.fileTree], + ); + const { sdkHandle, editFlowSdkSession } = useStudioSdkSessions( + projectId, + activeCompPath, + domEditSaveTimestampRef, + masterCompPath, + ); useEffect(() => { if (activeCompPathHydrated) return; if (!fileManager.fileTreeLoaded) return; @@ -186,7 +181,7 @@ export function StudioApp() { pendingTimelineEditPathRef, uploadProjectFiles: fileManager.uploadProjectFiles, isRecordingRef: isGestureRecordingRef, - sdkSession: sdkHandle.session, + sdkSession: editFlowSdkSession, forceReloadSdkSession: sdkHandle.forceReload, }); const { @@ -302,7 +297,7 @@ export function StudioApp() { openSourceForSelection: fileManager.openSourceForSelection, selectSidebarTab: sidebarTabRef.current.select, getSidebarTab: sidebarTabRef.current.get, - sdkSession: sdkHandle.session, + sdkSession: editFlowSdkSession, forceReloadSdkSession: sdkHandle.forceReload, }); domEditSelectionBridgeRef.current = domEditSession.domEditSelection; @@ -320,7 +315,7 @@ export function StudioApp() { } }; useSdkSelectionSync( - sdkHandle.session, + editFlowSdkSession, domEditSession.domEditSelection, domEditSession.domEditGroupSelections, ); diff --git a/packages/studio/src/components/panels/VariablesPanel.tsx b/packages/studio/src/components/panels/VariablesPanel.tsx index f8c8fd072..b73826f1e 100644 --- a/packages/studio/src/components/panels/VariablesPanel.tsx +++ b/packages/studio/src/components/panels/VariablesPanel.tsx @@ -18,6 +18,7 @@ import { } from "./VariablesDeclarationForm"; import { PreviewValueControl } from "./VariablesValueControls"; import { copyTextToClipboard } from "../../utils/clipboard"; +import { resolveMasterCompositionPath } from "../../utils/studioUrlState"; import { isScalarVariableValue as isScalar } from "@hyperframes/core/variables"; /** POSIX single-quote escaping so the copied command survives quotes in values. */ @@ -273,7 +274,14 @@ export const VariablesPanel = memo(function VariablesPanel({ }: VariablesPanelProps) { const { activeCompPath, showToast } = useStudioShellContext(); const { refreshKey } = useStudioPlaybackContext(); - const { readProjectFile, writeProjectFile } = useFileManagerContext(); + const { readProjectFile, writeProjectFile, fileTree } = useFileManagerContext(); + // On the master view (no activeCompPath) the panel targets the project's real + // main composition — the first .html in the tree — not a hardcoded index.html + // that may not exist. This same path is used for the persist write target (so + // an edit never lands in a phantom index.html) AND the handoff render command. + // Null only when the project has no composition yet, in which case sdkSession + // is also null and the panel is inert. + const effectiveCompPath = activeCompPath ?? resolveMasterCompositionPath(fileTree); const previewValues = usePreviewVariablesStore((s) => s.values); const setPreviewValues = usePreviewVariablesStore((s) => s.setValues); @@ -291,7 +299,7 @@ export const VariablesPanel = memo(function VariablesPanel({ const persistVariables = useVariablesPersist({ sdkSession, - activeCompPath, + activeCompPath: effectiveCompPath, readProjectFile, writeProjectFile, recordEdit, @@ -490,7 +498,7 @@ export const VariablesPanel = memo(function VariablesPanel({ {declarations.length > 0 && ( )} diff --git a/packages/studio/src/hooks/usePreviewDocumentVersion.ts b/packages/studio/src/hooks/usePreviewDocumentVersion.ts new file mode 100644 index 000000000..d4c10acb2 --- /dev/null +++ b/packages/studio/src/hooks/usePreviewDocumentVersion.ts @@ -0,0 +1,27 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +/** + * Version counter for the preview DOM. `refresh` bumps immediately and again + * at 80ms / 300ms so consumers re-scan after the iframe settles; pending + * timers are collapsed by each new refresh and cleared on unmount. + */ +export function usePreviewDocumentVersion(): [number, () => void] { + const [previewDocumentVersion, setPreviewDocumentVersion] = useState(0); + const refreshTimersRef = useRef([]); + const refresh = useCallback(() => { + for (const id of refreshTimersRef.current) clearTimeout(id); + refreshTimersRef.current = []; + setPreviewDocumentVersion((v) => v + 1); + refreshTimersRef.current.push( + window.setTimeout(() => setPreviewDocumentVersion((v) => v + 1), 80), + window.setTimeout(() => setPreviewDocumentVersion((v) => v + 1), 300), + ); + }, []); + useEffect( + () => () => { + for (const id of refreshTimersRef.current) clearTimeout(id); + }, + [], + ); + return [previewDocumentVersion, refresh]; +} diff --git a/packages/studio/src/hooks/useStudioSdkSessions.ts b/packages/studio/src/hooks/useStudioSdkSessions.ts new file mode 100644 index 000000000..bc3d51b2d --- /dev/null +++ b/packages/studio/src/hooks/useStudioSdkSessions.ts @@ -0,0 +1,37 @@ +import { useEffect, type MutableRefObject } from "react"; +import { useSdkSession } from "./useSdkSession"; +import { usePreviewVariablesStore } from "./previewVariablesStore"; + +/** + * Open the studio's SDK session with master-view semantics. + * + * The master view has no explicit comp path, but the session must still model + * the project's main composition so schema-level panels (Variables, Slideshow) + * work there. Edit-flow consumers keep the legacy "no session on master view" + * gating via `editFlowSdkSession` so cutover behavior is unchanged. + * + * Also clears preview variable overrides whenever the composition or project + * changes — overrides are per-composition and must never leak into another + * composition's preview or render. + */ +export function useStudioSdkSessions( + projectId: string | null, + activeCompPath: string | null, + domEditSaveTimestampRef: MutableRefObject, + masterCompPath: string | null, +) { + // On the master view (no explicit comp) the schema panels target the project's + // resolved main composition — the first `.html` in the tree, not a hardcoded + // "index.html" that may not exist. `null` when the project has no composition + // yet, which correctly leaves the session (and the panels) empty. + const sdkHandle = useSdkSession( + projectId, + activeCompPath ?? masterCompPath, + domEditSaveTimestampRef, + ); + const editFlowSdkSession = activeCompPath ? sdkHandle.session : null; + useEffect(() => { + usePreviewVariablesStore.getState().setValues(null); + }, [projectId, activeCompPath]); + return { sdkHandle, editFlowSdkSession }; +} diff --git a/packages/studio/src/utils/studioUrlState.test.ts b/packages/studio/src/utils/studioUrlState.test.ts index be0870c8f..4a3149413 100644 --- a/packages/studio/src/utils/studioUrlState.test.ts +++ b/packages/studio/src/utils/studioUrlState.test.ts @@ -8,12 +8,41 @@ import { normalizeStudioCompositionPath, normalizeStudioUrlPanelTab, parseStudioUrlStateFromHash, + resolveMasterCompositionPath, } from "./studioUrlState"; import { useStudioUrlState } from "../hooks/useStudioUrlState"; import { usePlayerStore } from "../player"; globalThis.IS_REACT_ACT_ENVIRONMENT = true; +describe("resolveMasterCompositionPath", () => { + it("prefers index.html when present", () => { + expect(resolveMasterCompositionPath(["frames/a.html", "index.html", "b.html"])).toBe( + "index.html", + ); + }); + + it("falls back to the first .html when there is no index.html", () => { + expect(resolveMasterCompositionPath(["notes.md", "card.html", "hero.html"])).toBe("card.html"); + }); + + it("returns null when the project carries no composition", () => { + expect(resolveMasterCompositionPath(["notes.md", "styles.css"])).toBeNull(); + expect(resolveMasterCompositionPath([])).toBeNull(); + }); +}); + +describe("normalizeStudioUrlPanelTab", () => { + it("accepts slideshow and variables as valid tabs", () => { + expect(normalizeStudioUrlPanelTab("slideshow", { inspectorPanelsEnabled: true })).toBe( + "slideshow", + ); + expect(normalizeStudioUrlPanelTab("variables", { inspectorPanelsEnabled: true })).toBe( + "variables", + ); + }); +}); + function resetPlayerStore() { usePlayerStore.setState({ isPlaying: false, diff --git a/packages/studio/src/utils/studioUrlState.ts b/packages/studio/src/utils/studioUrlState.ts index b961af508..d144155f1 100644 --- a/packages/studio/src/utils/studioUrlState.ts +++ b/packages/studio/src/utils/studioUrlState.ts @@ -19,7 +19,20 @@ export interface StudioUrlState { selection: StudioUrlSelectionState | null; } -const VALID_TABS: RightPanelTab[] = ["layers", "design", "renders"]; +const VALID_TABS: RightPanelTab[] = ["layers", "design", "renders", "slideshow", "variables"]; + +/** + * The composition a schema-level panel (Variables / Slideshow) targets on the + * master view, where there is no explicit `activeCompPath`. Prefer the + * `index.html` convention, but fall back to the first `.html` in the file tree + * (composition-browser order) so projects whose entry file is `card.html`, + * `hero.html`, etc. don't silently mis-target a non-existent `index.html`. + * Returns null when the project carries no composition file at all. + */ +export function resolveMasterCompositionPath(fileTree: string[]): string | null { + if (fileTree.includes("index.html")) return "index.html"; + return fileTree.find((p) => p.endsWith(".html")) ?? null; +} export function normalizeStudioUrlPanelTab( tab: RightPanelTab | null, @@ -106,6 +119,8 @@ export function readStudioUrlStateFromWindow(): StudioUrlState { return parseStudioUrlStateFromHash(window.location.hash); } +// Pre-existing param-assembly complexity — surfaced by this PR's line shifts. +// fallow-ignore-next-line complexity export function buildStudioHash(projectId: string, state: StudioUrlState): string { const params = new URLSearchParams();