From 386c2be5d4a736911523af4f93516370053b0635 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 14 Jul 2026 23:59:26 -0400 Subject: [PATCH 1/4] feat(studio): add design-panel input usage tracking primitive --- .../contexts/DesignPanelInputContext.test.tsx | 81 ++++++++++++++++ .../src/contexts/DesignPanelInputContext.tsx | 48 +++++++++ .../src/utils/designInputTracking.test.ts | 97 +++++++++++++++++++ .../studio/src/utils/designInputTracking.ts | 70 +++++++++++++ 4 files changed, 296 insertions(+) create mode 100644 packages/studio/src/contexts/DesignPanelInputContext.test.tsx create mode 100644 packages/studio/src/contexts/DesignPanelInputContext.tsx create mode 100644 packages/studio/src/utils/designInputTracking.test.ts create mode 100644 packages/studio/src/utils/designInputTracking.ts diff --git a/packages/studio/src/contexts/DesignPanelInputContext.test.tsx b/packages/studio/src/contexts/DesignPanelInputContext.test.tsx new file mode 100644 index 0000000000..b9330603ef --- /dev/null +++ b/packages/studio/src/contexts/DesignPanelInputContext.test.tsx @@ -0,0 +1,81 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const trackDesignInput = vi.fn(); +vi.mock("../utils/designInputTracking", () => ({ + trackDesignInput: (...args: unknown[]) => trackDesignInput(...args), +})); + +import { DesignPanelInputProvider, useTrackDesignInput } from "./DesignPanelInputContext"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +beforeEach(() => trackDesignInput.mockReset()); +afterEach(() => { + document.body.innerHTML = ""; +}); + +function FireButton({ control, name }: { control: string; name: string }) { + const track = useTrackDesignInput(); + return ( + + ); +} + +function renderAndClick(tree: React.ReactElement) { + const host = document.createElement("div"); + document.body.appendChild(host); + const root = createRoot(host); + act(() => root.render(tree)); + act(() => { + host.querySelector("button")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + act(() => root.unmount()); +} + +describe("DesignPanelInputContext", () => { + it("binds the tracker to the enclosing ui + section", () => { + renderAndClick( + + + , + ); + expect(trackDesignInput).toHaveBeenCalledWith({ + ui: "flat", + section: "style", + control: "metric", + name: "Opacity", + }); + }); + + it("nested provider overrides section but inherits ui from parent", () => { + renderAndClick( + + + + + , + ); + expect(trackDesignInput).toHaveBeenCalledWith({ + ui: "flat", + section: "color-grading", + control: "slider", + name: "Exposure", + }); + }); + + it("defaults to classic/unknown with no provider", () => { + renderAndClick(); + expect(trackDesignInput).toHaveBeenCalledWith({ + ui: "classic", + section: "unknown", + control: "button", + name: "Reset", + }); + }); +}); diff --git a/packages/studio/src/contexts/DesignPanelInputContext.tsx b/packages/studio/src/contexts/DesignPanelInputContext.tsx new file mode 100644 index 0000000000..a4b4223c04 --- /dev/null +++ b/packages/studio/src/contexts/DesignPanelInputContext.tsx @@ -0,0 +1,48 @@ +import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react"; +import { trackDesignInput, type DesignInputUi } from "../utils/designInputTracking"; + +// Carries which inspector UI and which section the currently-rendered design-panel +// inputs belong to, so commit sites only pass { control, name } and never thread +// section/ui through every call. Providers nest: PropertyPanel sets `ui` once at the +// top; each Section sets `section` and inherits `ui` from the parent. + +interface DesignPanelInputContextValue { + ui: DesignInputUi; + section: string; +} + +const DesignPanelInputContext = createContext({ + ui: "classic", + section: "unknown", +}); + +export function DesignPanelInputProvider({ + ui, + section, + children, +}: { + ui?: DesignInputUi; + section?: string; + children: ReactNode; +}) { + const parent = useContext(DesignPanelInputContext); + const value = useMemo( + () => ({ ui: ui ?? parent.ui, section: section ?? parent.section }), + [ui, section, parent.ui, parent.section], + ); + return ( + {children} + ); +} + +/** + * Returns a stable `track(control, name)` fn bound to the current UI + section. + * Call it from any input's commit handler. + */ +export function useTrackDesignInput(): (control: string, name: string) => void { + const { ui, section } = useContext(DesignPanelInputContext); + return useCallback( + (control: string, name: string) => trackDesignInput({ ui, section, control, name }), + [ui, section], + ); +} diff --git a/packages/studio/src/utils/designInputTracking.test.ts b/packages/studio/src/utils/designInputTracking.test.ts new file mode 100644 index 0000000000..24582d66bb --- /dev/null +++ b/packages/studio/src/utils/designInputTracking.test.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const trackStudioEvent = vi.fn(); +vi.mock("./studioTelemetry", () => ({ + trackStudioEvent: (...args: unknown[]) => trackStudioEvent(...args), +})); + +import { + __resetDesignInputThrottle, + slugifyDesignInput, + trackDesignInput, +} from "./designInputTracking"; + +beforeEach(() => { + trackStudioEvent.mockReset(); + __resetDesignInputThrottle(); + vi.restoreAllMocks(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("trackDesignInput", () => { + it("emits one design_input event with ui/section/control/name", () => { + trackDesignInput({ ui: "flat", section: "Style", control: "metric", name: "Opacity" }); + expect(trackStudioEvent).toHaveBeenCalledTimes(1); + expect(trackStudioEvent).toHaveBeenCalledWith("design_input", { + ui: "flat", + section: "style", + control: "metric", + name: "opacity", + }); + }); + + it("slugifies compound names and sections", () => { + trackDesignInput({ + ui: "classic", + section: "Color Grading", + control: "slider", + name: "Font Size", + }); + expect(trackStudioEvent).toHaveBeenCalledWith("design_input", { + ui: "classic", + section: "color-grading", + control: "slider", + name: "font-size", + }); + }); + + it("marks an unresolved name as 'unnamed' (R3 coverage signal)", () => { + trackDesignInput({ ui: "classic", section: "style", control: "button", name: "" }); + expect(trackStudioEvent).toHaveBeenCalledWith( + "design_input", + expect.objectContaining({ name: "unnamed" }), + ); + }); + + it("coalesces repeated fires of the same input within the window (R4)", () => { + const nowSpy = vi.spyOn(performance, "now"); + // Same key, three quick fires -> 1 event. + nowSpy.mockReturnValue(1000); + trackDesignInput({ ui: "flat", section: "style", control: "slider", name: "opacity" }); + nowSpy.mockReturnValue(1100); + trackDesignInput({ ui: "flat", section: "style", control: "slider", name: "opacity" }); + nowSpy.mockReturnValue(1500); + trackDesignInput({ ui: "flat", section: "style", control: "slider", name: "opacity" }); + expect(trackStudioEvent).toHaveBeenCalledTimes(1); + + // A different input within the window is NOT collapsed. + nowSpy.mockReturnValue(1550); + trackDesignInput({ ui: "flat", section: "style", control: "slider", name: "scale" }); + expect(trackStudioEvent).toHaveBeenCalledTimes(2); + + // Same input after the window fires again. + nowSpy.mockReturnValue(2200); + trackDesignInput({ ui: "flat", section: "style", control: "slider", name: "opacity" }); + expect(trackStudioEvent).toHaveBeenCalledTimes(3); + }); + + it("never throws even if the underlying tracker throws", () => { + trackStudioEvent.mockImplementation(() => { + throw new Error("ingest down"); + }); + expect(() => + trackDesignInput({ ui: "flat", section: "style", control: "metric", name: "opacity" }), + ).not.toThrow(); + }); +}); + +describe("slugifyDesignInput", () => { + it("lowercases, collapses non-alphanumerics, and trims dashes", () => { + expect(slugifyDesignInput(" Border Radius (px) ")).toBe("border-radius-px"); + expect(slugifyDesignInput("X")).toBe("x"); + expect(slugifyDesignInput("---")).toBe(""); + }); +}); diff --git a/packages/studio/src/utils/designInputTracking.ts b/packages/studio/src/utils/designInputTracking.ts new file mode 100644 index 0000000000..e10a08c39a --- /dev/null +++ b/packages/studio/src/utils/designInputTracking.ts @@ -0,0 +1,70 @@ +import { trackStudioEvent } from "./studioTelemetry"; + +// Per-input usage telemetry for the design (inspector) panel. Both inspector UIs +// (classic PropertyPanel, flat PropertyPanelFlat) funnel their inputs through this +// one helper so usage can be ranked by input to find removal candidates. Emits the +// batched `studio:design_input` event via trackStudioEvent (opt-out-aware, never-throw). + +export type DesignInputUi = "flat" | "classic"; + +export interface DesignInputDescriptor { + ui: DesignInputUi; + /** Section slug the input lives under (e.g. "style", "color-grading"). */ + section: string; + /** Control kind: "metric" | "slider" | "select" | "segmented" | "toggle" | "color" | "text" | "button" | … */ + control: string; + /** Input identity — the field label or CSS/GSAP property. Slugified for stable ranking. */ + name: string; +} + +// Continuous controls (sliders, scrub, wheel-nudge, live-commit text) fire many +// commits per interaction. Collapse repeated fires of the same input within this +// window into one event so a single drag counts once (R4). +const COALESCE_WINDOW_MS = 600; + +const lastFiredByKey = new Map(); + +function now(): number { + // performance.now() is monotonic and available in the studio runtime and jsdom. + return typeof performance !== "undefined" && typeof performance.now === "function" + ? performance.now() + : 0; +} + +export function slugifyDesignInput(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, ""); +} + +/** Test seam: clear the coalescing state between cases. */ +export function __resetDesignInputThrottle(): void { + lastFiredByKey.clear(); +} + +export function trackDesignInput(descriptor: DesignInputDescriptor): void { + try { + const section = slugifyDesignInput(descriptor.section) || "unknown"; + const name = slugifyDesignInput(descriptor.name); + // An input with no resolvable name is useless for the removal analysis (R3). + // Emit it anyway (so a coverage test can catch it) but under an explicit marker. + const control = descriptor.control || "unknown"; + const key = `${descriptor.ui}:${section}:${control}:${name || "unnamed"}`; + + const t = now(); + const last = lastFiredByKey.get(key); + if (last !== undefined && t - last < COALESCE_WINDOW_MS) return; + lastFiredByKey.set(key, t); + + trackStudioEvent("design_input", { + ui: descriptor.ui, + section, + control, + name: name || "unnamed", + }); + } catch { + // Telemetry must never break the edit path. + } +} From e8a73629d5ae39c2b9afea13be82acf852c5af9b Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 15 Jul 2026 00:18:15 -0400 Subject: [PATCH 2/4] feat(studio): track input usage in classic inspector panel --- .../src/components/editor/ArcPathControls.tsx | 1 + .../editor/GsapAnimationSection.tsx | 120 +++++++- .../src/components/editor/PropertyPanel.tsx | 4 +- .../editor/propertyPanel3dTransform.tsx | 5 + .../components/editor/propertyPanelColor.tsx | 6 +- .../propertyPanelColorGradingControls.tsx | 13 +- .../propertyPanelColorGradingSection.tsx | 16 +- .../propertyPanelColorGradingSlider.tsx | 24 +- .../components/editor/propertyPanelFill.tsx | 27 +- .../components/editor/propertyPanelFont.tsx | 12 +- .../propertyPanelInputCoverage.test.tsx | 263 ++++++++++++++++++ .../editor/propertyPanelMediaSection.tsx | 10 + .../editor/propertyPanelPrimitives.tsx | 87 ++++-- .../editor/propertyPanelSections.tsx | 30 +- .../editor/propertyPanelStyleSections.tsx | 6 + 15 files changed, 561 insertions(+), 63 deletions(-) create mode 100644 packages/studio/src/components/editor/propertyPanelInputCoverage.test.tsx diff --git a/packages/studio/src/components/editor/ArcPathControls.tsx b/packages/studio/src/components/editor/ArcPathControls.tsx index e9f46607de..481efc9323 100644 --- a/packages/studio/src/components/editor/ArcPathControls.tsx +++ b/packages/studio/src/components/editor/ArcPathControls.tsx @@ -113,6 +113,7 @@ export const ArcPathControls = memo(function ArcPathControls({ )} { + const control = + property === "visibility" + ? "toggle" + : property === "filter" || property === "clipPath" + ? "text" + : "metric"; + track(control, property); + }; + const updateMeta = ( + animationId: string, + updates: { duration?: number; ease?: string; position?: number }, + ) => { + if (updates.duration !== undefined) track("metric", "Length"); + else if (updates.position !== undefined) track("metric", "Starts at"); + else track("select", "Speed"); + onUpdateMeta(animationId, updates); + }; return (
}> @@ -58,21 +78,94 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({ key={anim.id} animation={anim} defaultExpanded={index === 0} - onUpdateProperty={onUpdateProperty} - onUpdateMeta={onUpdateMeta} - onDeleteAnimation={onDeleteAnimation} - onAddProperty={onAddProperty} - onRemoveProperty={onRemoveProperty} - onUpdateFromProperty={onUpdateFromProperty} - onAddFromProperty={onAddFromProperty} - onRemoveFromProperty={onRemoveFromProperty} + onUpdateProperty={(animationId, property, value) => { + trackProperty(property); + onUpdateProperty(animationId, property, value); + }} + onUpdateMeta={updateMeta} + onDeleteAnimation={(animationId) => { + track("button", "Remove animation"); + onDeleteAnimation(animationId); + }} + onAddProperty={(animationId, property) => { + track("select", "Add effect property"); + onAddProperty(animationId, property); + }} + onRemoveProperty={(animationId, property) => { + track("button", `Remove ${property}`); + onRemoveProperty(animationId, property); + }} + onUpdateFromProperty={ + onUpdateFromProperty + ? (animationId, property, value) => { + trackProperty(property); + onUpdateFromProperty(animationId, property, value); + } + : undefined + } + onAddFromProperty={ + onAddFromProperty + ? (animationId, property) => { + track("select", "Add from property"); + onAddFromProperty(animationId, property); + } + : undefined + } + onRemoveFromProperty={ + onRemoveFromProperty + ? (animationId, property) => { + track("button", `Remove from ${property}`); + onRemoveFromProperty(animationId, property); + } + : undefined + } onLivePreview={onLivePreview} onLivePreviewEnd={onLivePreviewEnd} - onSetArcPath={onSetArcPath} - onUpdateArcSegment={onUpdateArcSegment} - onUpdateKeyframeEase={onUpdateKeyframeEase} - onSetAllKeyframeEases={onSetAllKeyframeEases} - onUnroll={onUnroll} + onSetArcPath={ + onSetArcPath + ? (animationId, config) => { + track( + "toggle", + config.autoRotate !== undefined ? "Auto rotate" : "Arc motion", + ); + onSetArcPath(animationId, config); + } + : undefined + } + onUpdateArcSegment={ + onUpdateArcSegment + ? (animationId, segmentIndex, update) => { + if (update.curviness === undefined) { + track("button", `Reset arc segment ${segmentIndex + 1}`); + } + onUpdateArcSegment(animationId, segmentIndex, update); + } + : undefined + } + onUpdateKeyframeEase={ + onUpdateKeyframeEase + ? (animationId, percentage, ease) => { + track("select", "Keyframe ease"); + onUpdateKeyframeEase(animationId, percentage, ease); + } + : undefined + } + onSetAllKeyframeEases={ + onSetAllKeyframeEases + ? (animationId, ease) => { + track("select", "All keyframe eases"); + onSetAllKeyframeEases(animationId, ease); + } + : undefined + } + onUnroll={ + onUnroll + ? (animationId) => { + track("button", "Unroll animation"); + onUnroll(animationId); + } + : undefined + } /> ))} @@ -85,6 +178,7 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({ type="button" title={METHOD_TOOLTIPS[method]} onClick={() => { + track("button", `Add ${method} animation`); onAddAnimation(method); setAddMenuOpen(false); }} diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index 9a29d4abe2..ab2b35d953 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -38,6 +38,7 @@ import { TimingSection } from "./propertyPanelTimingSection"; import { type PropertyPanelProps } from "./propertyPanelHelpers"; import { GestureRecordPanelButton } from "./GestureRecordControl"; import { PropertyPanelEmptyState } from "./PropertyPanelEmptyState"; +import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext"; // Re-export helpers that external consumers import from this module export { @@ -303,7 +304,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro ); } - return ( + const classicPanel = (
@@ -593,4 +594,5 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
); + return {classicPanel}; }); diff --git a/packages/studio/src/components/editor/propertyPanel3dTransform.tsx b/packages/studio/src/components/editor/propertyPanel3dTransform.tsx index ad29d80d29..a74ebc9bcc 100644 --- a/packages/studio/src/components/editor/propertyPanel3dTransform.tsx +++ b/packages/studio/src/components/editor/propertyPanel3dTransform.tsx @@ -5,6 +5,7 @@ import { MetricField } from "./propertyPanelPrimitives"; import { KeyframeNavigation } from "./KeyframeNavigation"; import { formatPxMetricValue, parsePxMetricValue, RESPONSIVE_GRID } from "./propertyPanelHelpers"; import { Transform3DCube, type CubePose } from "./Transform3DCube"; +import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext"; // translateZ only foreshortens under a perspective lens. Rather than hardcode one // (an arbitrary px value reads wrong at different canvas sizes), derive it from the @@ -71,6 +72,7 @@ function Cube3dControl({ onKeyframe?: () => void; keyframed?: boolean; }) { + const track = useTrackDesignInput(); const pose: CubePose = { rotationX: gsapRuntimeValues.rotationX ?? 0, rotationY: gsapRuntimeValues.rotationY ?? 0, @@ -96,6 +98,7 @@ function Cube3dControl({ } const axes = Object.keys(changedProps); if (axes.length === 0) return; + track("slider", "3D rotation pose"); // ONE keyframe for the whole pose change — avoids per-axis commits racing into // adjacent duplicate keyframes. void onCommitAnimatedProperties(element, changedProps); @@ -111,6 +114,7 @@ function Cube3dControl({ scale: 1, transformPerspective: 0, }; + track("button", "Reset 3D transform"); void onCommitAnimatedProperties(element, identity); }; // Immediate element feedback while dragging — set the live transform without a @@ -168,6 +172,7 @@ function Cube3dControl({ } // One commit for all props so the writes can't race read-modify-write on // the same script (which dropped a prop and reverted after a seek). + track("slider", "3D depth"); void onCommitAnimatedProperties(element, props); }} onRecenter={recenter} diff --git a/packages/studio/src/components/editor/propertyPanelColor.tsx b/packages/studio/src/components/editor/propertyPanelColor.tsx index ea6dbb6008..74dc2917b5 100644 --- a/packages/studio/src/components/editor/propertyPanelColor.tsx +++ b/packages/studio/src/components/editor/propertyPanelColor.tsx @@ -11,6 +11,7 @@ import { } from "./colorValue"; import { resolveFloatingPanelPosition, type FloatingPosition } from "./floatingPanel"; import { colorFromCss, FIELD, LABEL } from "./propertyPanelHelpers"; +import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext"; const COLOR_PICKER_SIZE = { width: 292, height: 386 }; @@ -130,6 +131,7 @@ export function ColorField({ flat?: boolean; onCommit: (nextValue: string) => void; }) { + const track = useTrackDesignInput(); const buttonRef = useRef(null); const panelRef = useRef(null); const [open, setOpen] = useState(false); @@ -203,7 +205,9 @@ export function ColorField({ const commitColor = (nextColor: ParsedColor) => { setDraftColor(nextColor); setHexDraft(toHexColor(nextColor).toUpperCase()); - onCommit(formatCssColor(nextColor)); + const nextValue = formatCssColor(nextColor); + if (!flat && nextValue !== value) track("color", label); + onCommit(nextValue); }; const commitHsv = (nextHsv: { hue?: number; saturation?: number; value?: number }) => { diff --git a/packages/studio/src/components/editor/propertyPanelColorGradingControls.tsx b/packages/studio/src/components/editor/propertyPanelColorGradingControls.tsx index 138130a103..080dd3b137 100644 --- a/packages/studio/src/components/editor/propertyPanelColorGradingControls.tsx +++ b/packages/studio/src/components/editor/propertyPanelColorGradingControls.tsx @@ -11,6 +11,7 @@ import { ChevronDown, ChevronRight, Plus, X } from "../../icons/SystemIcons"; import { LUT_EXT } from "../../utils/mediaTypes"; import { LABEL } from "./propertyPanelHelpers"; import { ColorGradingSliderControl } from "./propertyPanelColorGradingSlider"; +import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext"; const LUT_UPLOAD_DIR = "assets/luts"; @@ -175,6 +176,7 @@ export function ColorGradingControls({ onImportAssets?: (files: FileList, dir?: string) => Promise; onCommitColorGrading: (nextGrading: NormalizedHfColorGrading) => void; }) { + const track = useTrackDesignInput(); const lutInputRef = useRef(null); const [lutOpen, setLutOpen] = useState(false); const [detailSettings, setDetailSettings] = useState<"vignette" | "grain" | null>(null); @@ -195,7 +197,10 @@ export function ColorGradingControls({ const applyPreset = (preset: string) => { const next = normalizeHfColorGrading({ preset, intensity: 1, lut: grading.lut }); - if (next) onCommitColorGrading(next); + if (next) { + track("select", "Preset"); + onCommitColorGrading(next); + } }; const updateFilterIntensity = (value: number) => { onCommitColorGrading({ @@ -218,7 +223,10 @@ export function ColorGradingControls({ if (!files?.length || !onImportAssets) return; const uploaded = await onImportAssets(files, LUT_UPLOAD_DIR); const firstLut = uploaded.find((asset) => LUT_EXT.test(asset)); - if (firstLut) applyLut(firstLut, 1); + if (firstLut) { + track("button", "Import LUT"); + applyLut(firstLut, 1); + } }; const commitDetailSlider = (slider: DetailSlider, next: number) => { onCommitColorGrading({ @@ -313,6 +321,7 @@ export function ColorGradingControls({ value={selectedLut} onChange={(event) => { const nextSrc = event.target.value; + track("select", "Custom LUT"); applyLut( nextSrc || null, nextSrc && grading.lut?.src === nextSrc ? grading.lut.intensity : 1, diff --git a/packages/studio/src/components/editor/propertyPanelColorGradingSection.tsx b/packages/studio/src/components/editor/propertyPanelColorGradingSection.tsx index 7331cba4ef..a0a49e4e68 100644 --- a/packages/studio/src/components/editor/propertyPanelColorGradingSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelColorGradingSection.tsx @@ -9,6 +9,7 @@ import { type MediaMetadata, type RuntimeColorGradingStatus, } from "./useColorGradingController"; +import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext"; function StatusPill({ status }: { status: RuntimeColorGradingStatus }) { const dotClass = @@ -68,10 +69,12 @@ function HoldBeforeButton({ disabled: boolean; onHoldChange: (holding: boolean) => void; }) { + const track = useTrackDesignInput(); const startHold = (event: ReactPointerEvent) => { if (disabled) return; event.preventDefault(); event.stopPropagation(); + track("toggle", "Compare original"); onHoldChange(true); const release = () => { onHoldChange(false); @@ -105,7 +108,10 @@ function HoldBeforeButton({ onKeyDown={(event) => { if (disabled || (event.key !== " " && event.key !== "Enter")) return; event.preventDefault(); - if (!active) onHoldChange(true); + if (!active) { + track("toggle", "Compare original"); + onHoldChange(true); + } }} onKeyUp={(event) => { if (disabled || (event.key !== " " && event.key !== "Enter")) return; @@ -148,6 +154,7 @@ export function ColorGradingSection({ value: string | null, ) => Promise<{ changedFiles: number; changedElements: number }>; }) { + const track = useTrackDesignInput(); const { grading, compareEnabled, @@ -184,6 +191,7 @@ export function ColorGradingSection({ type="button" onClick={(event) => { event.stopPropagation(); + track("button", "Reset color grading"); resetGrading(); }} className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded text-panel-text-4 transition-colors hover:bg-panel-hover hover:text-panel-text-1" @@ -205,7 +213,10 @@ export function ColorGradingSection({
onChange(e.target.value)} + onChange={(e) => { + track("select", label); + onChange(e.target.value); + }} className="min-w-0 w-full appearance-none bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600" > {renderedOptions.map((option) => ( @@ -370,24 +401,24 @@ export function Section({ ); + const section = slugifyPanelSectionTitle(title); return ( -
-
- - {accessory &&
{accessory}
} -
- {!collapsed &&
{children}
} -
+ +
+
+ + {accessory &&
{accessory}
} +
+ {!collapsed &&
{children}
} +
+
); } diff --git a/packages/studio/src/components/editor/propertyPanelSections.tsx b/packages/studio/src/components/editor/propertyPanelSections.tsx index e1b0b8923a..c20b59eb98 100644 --- a/packages/studio/src/components/editor/propertyPanelSections.tsx +++ b/packages/studio/src/components/editor/propertyPanelSections.tsx @@ -7,6 +7,7 @@ import { MetricField, Section, SelectField } from "./propertyPanelPrimitives"; import { ColorField } from "./propertyPanelColor"; import { FontFamilyField } from "./propertyPanelFont"; import { PromotableControl } from "./PromotableControl"; +import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext"; /* ------------------------------------------------------------------ */ /* Text helpers (used only by text section components) */ @@ -74,9 +75,11 @@ export function TextAreaField({ flat?: boolean; onCommit: (nextValue: string) => void; }) { + const track = useTrackDesignInput(); const [draft, setDraft] = useState(value); const textareaRef = useRef(null); const commitTimerRef = useRef | null>(null); + const interactionChangedRef = useRef(false); const focusedRef = useRef(false); const valueRef = useRef(value); valueRef.current = value; @@ -98,12 +101,22 @@ export function TextAreaField({ const commitDraft = (d: string) => { if (commitTimerRef.current) clearTimeout(commitTimerRef.current); + if (!flat && interactionChangedRef.current) { + interactionChangedRef.current = false; + track("text", label); + } if (d !== valueRef.current) onCommit(d); }; const scheduleCommit = (d: string) => { if (commitTimerRef.current) clearTimeout(commitTimerRef.current); commitTimerRef.current = setTimeout(() => { - if (d !== valueRef.current) onCommit(d); + if (d !== valueRef.current) { + if (!flat && interactionChangedRef.current) { + interactionChangedRef.current = false; + track("text", label); + } + onCommit(d); + } }, 120); }; @@ -112,6 +125,7 @@ export function TextAreaField({ }; const handleChange = (e: ChangeEvent) => { setDraft(e.target.value); + interactionChangedRef.current = true; scheduleCommit(e.target.value); }; const handleBlur = () => { @@ -169,6 +183,7 @@ function FontWeightField({ fontFamily?: string; onCommit: (nextValue: string) => void; }) { + const track = useTrackDesignInput(); const options = fontFamily ? detectAvailableWeights(fontFamily) : ALL_WEIGHTS; const displayOptions = value && !options.includes(value) ? [value, ...options] : options; return ( @@ -178,7 +193,10 @@ function FontWeightField({ onSetApplyScope(e.target.value as "source-file" | "project")} + onChange={(e) => { + track("select", "Copy grade scope"); + onSetApplyScope(e.target.value as "source-file" | "project"); + }} disabled={applyBusy} className="bg-transparent font-mono text-[11px] text-panel-text-0 outline-none disabled:opacity-50" > @@ -540,7 +555,10 @@ export function FlatColorGradingSection({ type="button" data-flat-grade-apply="true" disabled={applyBusy} - onClick={onApplyToScope} + onClick={() => { + track("button", "Apply grade to scope"); + onApplyToScope(); + }} className="text-[11px] font-medium text-panel-accent hover:text-panel-accent/80 disabled:cursor-not-allowed disabled:opacity-50" > {applyBusy ? "Applying" : "Apply"} diff --git a/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx index 60988350e4..da217814f5 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx @@ -1,3 +1,4 @@ +import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext"; import { FlatRow, FlatSegmentedRow, FlatSelectRow } from "./propertyPanelFlatPrimitives"; import { KeyframeNavigation } from "./KeyframeNavigation"; import { formatPxMetricValue } from "./propertyPanelHelpers"; @@ -67,6 +68,7 @@ function KeyframeGutter({ | "onRemoveKeyframe" | "onConvertToKeyframes" >) { + const track = useTrackDesignInput(); if (!STUDIO_KEYFRAMES_ENABLED || !gsapAnimId) return null; const hasKeyframesOnProp = Boolean(navKeyframes?.some((kf) => property in kf.properties)); return ( @@ -76,11 +78,21 @@ function KeyframeGutter({ keyframes={navKeyframes} currentPercentage={currentPct} onSeek={seekFromKfPct} - onAddKeyframe={() => - onCommitAnimatedProperty && void onCommitAnimatedProperty(element, property, displayValue) - } - onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp(property), pct)} - onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp(property))} + onAddKeyframe={() => { + if (!onCommitAnimatedProperty) return; + track("button", `Add ${property} keyframe`); + void onCommitAnimatedProperty(element, property, displayValue); + }} + onRemoveKeyframe={(pct) => { + if (!onRemoveKeyframe) return; + track("button", `Remove ${property} keyframe`); + onRemoveKeyframe(animIdForProp(property), pct); + }} + onConvertToKeyframes={() => { + if (!onConvertToKeyframes) return; + track("button", `Convert ${property} to keyframes`); + onConvertToKeyframes(animIdForProp(property)); + }} /> ); diff --git a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx index c63ef12019..1850a48905 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext"; import { Check, ClipboardList } from "../../icons/SystemIcons"; import type { DomEditSelection } from "./domEditing"; import { @@ -37,6 +38,7 @@ export function FlatMediaSection({ }, ) => Promise; }) { + const track = useTrackDesignInput(); const isVideo = element.tagName === "video"; const isAudio = element.tagName === "audio"; const isImage = element.tagName === "img"; @@ -91,6 +93,7 @@ export function FlatMediaSection({ const runBackgroundRemoval = async () => { if (!onRemoveBackground || !projectSrc || removeBusy) return; + track("button", "Remove background"); setRemoveBusy(true); setRemoveProgress({ status: "processing", progress: 0, stage: "Preparing" }); try { @@ -126,6 +129,7 @@ export function FlatMediaSection({ type="button" data-flat-media-copy="true" onClick={() => { + track("button", "Copy media path"); void navigator.clipboard.writeText(absoluteSrc).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500); diff --git a/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx index f457979c68..b7ee4b1df2 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext"; import type { DomEditSelection } from "./domEditing"; import { formatTimingValue, RESPONSIVE_GRID } from "./propertyPanelHelpers"; import { parseTimingValue } from "./propertyPanelTimingSection"; @@ -25,6 +26,7 @@ export function FlatTimingRow({ * documented below) when the caller doesn't wire it up. */ onSetAttributes?: (selection: DomEditSelection, attrs: Record) => Promise; }) { + const track = useTrackDesignInput(); const { start, duration, inferred: derived } = deriveElementTiming(element, animations); const end = start + duration; @@ -81,7 +83,13 @@ export function FlatTimingRow({
{label} - + { + track("metric", label); + onCommit(next); + }} + />
); @@ -122,7 +130,17 @@ export function FlatMotionSection({ onSetAttributes?: (selection: DomEditSelection, attrs: Record) => Promise; onAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void; } & GsapAnimationEditCallbacks) { + const track = useTrackDesignInput(); const [addMenuOpen, setAddMenuOpen] = useState(false); + const trackProperty = (property: string) => { + const control = + property === "visibility" + ? "toggle" + : property === "filter" || property === "clipPath" + ? "text" + : "metric"; + track(control, property); + }; return (
@@ -155,7 +173,99 @@ export function FlatMotionSection({ animation={anim} defaultExpanded={index === 0} flat - {...callbacks} + onUpdateProperty={(animationId, property, value) => { + trackProperty(property); + callbacks.onUpdateProperty(animationId, property, value); + }} + onUpdateMeta={(animationId, updates) => { + if (updates.duration !== undefined) track("metric", "Length"); + else if (updates.position !== undefined) track("metric", "Starts at"); + else track("select", "Speed"); + callbacks.onUpdateMeta(animationId, updates); + }} + onDeleteAnimation={(animationId) => { + track("button", "Remove animation"); + callbacks.onDeleteAnimation(animationId); + }} + onAddProperty={(animationId, property) => { + track("select", "Add effect property"); + callbacks.onAddProperty(animationId, property); + }} + onRemoveProperty={(animationId, property) => { + track("button", `Remove ${property}`); + callbacks.onRemoveProperty(animationId, property); + }} + onUpdateFromProperty={ + callbacks.onUpdateFromProperty + ? (animationId, property, value) => { + trackProperty(property); + callbacks.onUpdateFromProperty?.(animationId, property, value); + } + : undefined + } + onAddFromProperty={ + callbacks.onAddFromProperty + ? (animationId, property) => { + track("select", "Add from property"); + callbacks.onAddFromProperty?.(animationId, property); + } + : undefined + } + onRemoveFromProperty={ + callbacks.onRemoveFromProperty + ? (animationId, property) => { + track("button", `Remove from ${property}`); + callbacks.onRemoveFromProperty?.(animationId, property); + } + : undefined + } + onLivePreview={callbacks.onLivePreview} + onLivePreviewEnd={callbacks.onLivePreviewEnd} + onSetArcPath={ + callbacks.onSetArcPath + ? (animationId, config) => { + track( + "toggle", + config.autoRotate !== undefined ? "Auto rotate" : "Arc motion", + ); + callbacks.onSetArcPath?.(animationId, config); + } + : undefined + } + onUpdateArcSegment={ + callbacks.onUpdateArcSegment + ? (animationId, segmentIndex, update) => { + if (update.curviness === undefined) { + track("button", `Reset arc segment ${segmentIndex + 1}`); + } + callbacks.onUpdateArcSegment?.(animationId, segmentIndex, update); + } + : undefined + } + onUpdateKeyframeEase={ + callbacks.onUpdateKeyframeEase + ? (animationId, percentage, ease) => { + track("select", "Keyframe ease"); + callbacks.onUpdateKeyframeEase?.(animationId, percentage, ease); + } + : undefined + } + onSetAllKeyframeEases={ + callbacks.onSetAllKeyframeEases + ? (animationId, ease) => { + track("select", "All keyframe eases"); + callbacks.onSetAllKeyframeEases?.(animationId, ease); + } + : undefined + } + onUnroll={ + callbacks.onUnroll + ? (animationId) => { + track("button", "Unroll animation"); + callbacks.onUnroll?.(animationId); + } + : undefined + } /> ))}
@@ -167,6 +277,7 @@ export function FlatMotionSection({ type="button" title={METHOD_TOOLTIPS[method]} onClick={() => { + track("button", `Add ${method} animation`); onAddAnimation(method); setAddMenuOpen(false); }} diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx index d8b1752d7c..9ac716f45b 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; +import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext"; import { RotateCcw } from "../../icons/SystemIcons"; import { CommitField } from "./propertyPanelPrimitives"; import { @@ -33,6 +34,7 @@ export function FlatRow({ onCommit: (nextValue: string) => void; onReset?: () => void; }) { + const track = useTrackDesignInput(); return (
{label} @@ -49,7 +51,10 @@ export function FlatRow({ value={value} disabled={disabled} liveCommit={liveCommit} - onCommit={onCommit} + onCommit={(nextValue) => { + track("metric", label); + onCommit(nextValue); + }} /> {suffix} @@ -58,7 +63,10 @@ export function FlatRow({ type="button" data-flat-row-reset="true" title="Remove — fall back to default" - onClick={onReset} + onClick={() => { + track("button", `Reset ${label}`); + onReset(); + }} className="flex-shrink-0 text-panel-text-3 opacity-0 transition-opacity hover:text-panel-text-1 group-hover:opacity-100" > @@ -109,6 +117,7 @@ export function FlatSegmentedRow({ spacerAfterIndex?: number; onChange: (nextKey: string) => void; }) { + const track = useTrackDesignInput(); return (
{label} @@ -121,7 +130,10 @@ export function FlatSegmentedRow({ aria-label={option.label} aria-pressed={option.active} disabled={disabled} - onClick={() => onChange(option.key)} + onClick={() => { + if (!option.active) track("segmented", label); + onChange(option.key); + }} className={`px-1.5 py-1 text-[11px] transition-colors disabled:cursor-not-allowed ${ option.active ? "border-b-2 border-panel-accent text-panel-text-0" @@ -270,6 +282,7 @@ export function FlatSlider({ onReset?: () => void; onCommit: (nextValue: number) => void; }) { + const track = useTrackDesignInput(); // `draft` gives the knob instant, drag-local visual feedback. `onCommit` is // throttled (not debounced) to at most once per 40ms: a real drag fires // pointermove faster than that, and a pure debounce (reset the timer on @@ -439,6 +452,7 @@ export function FlatSlider({ const stepped = stepFromClientX(e.clientX, e.currentTarget.getBoundingClientRect()); setDraft(stepped); commitDraft(stepped); + if (stepped !== dragStartValueRef.current) track("slider", label); }} onPointerCancel={(e) => { // A native pointercancel means the platform aborted the gesture (a @@ -481,6 +495,7 @@ export function FlatSlider({ e.preventDefault(); setDraft(next); commitDraft(next); + if (next !== draft) track("slider", label); }} onContextMenu={(e) => { // Right-click during a drag must cancel it (revert to the pre-drag @@ -530,7 +545,10 @@ export function FlatSlider({ data-flat-slider-reset="true" title="Remove — fall back to default" disabled={disabled} - onClick={onReset} + onClick={() => { + track("button", `Reset ${label}`); + onReset(); + }} className="text-panel-text-3 hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40" > diff --git a/packages/studio/src/components/editor/propertyPanelFlatSelectRow.tsx b/packages/studio/src/components/editor/propertyPanelFlatSelectRow.tsx index f3ef5cc6d8..f081c507d9 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatSelectRow.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatSelectRow.tsx @@ -1,4 +1,5 @@ import { RotateCcw } from "../../icons/SystemIcons"; +import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext"; import { VALUE_TIER_LABEL_CLASS, VALUE_TIER_VALUE_CLASS, @@ -32,6 +33,8 @@ export function FlatSelectRow({ onChange: (nextValue: string) => void; onReset?: () => void; }) { + const track = useTrackDesignInput(); + const trackName = ariaLabel || label; const normalizedOptions = options.map((option) => typeof option === "string" ? { value: option, label: option } : option, ); @@ -55,7 +58,10 @@ export function FlatSelectRow({ value={value} disabled={disabled} aria-label={ariaLabel || label || undefined} - onChange={(e) => onChange(e.target.value)} + onChange={(e) => { + track("select", trackName); + onChange(e.target.value); + }} className={`appearance-none bg-transparent text-right font-mono text-[11px] outline-none disabled:cursor-not-allowed ${VALUE_TIER_VALUE_CLASS[tier]}`} > {renderedOptions.map((option) => ( @@ -80,7 +86,10 @@ export function FlatSelectRow({ data-flat-select-reset="true" title="Remove — fall back to default" disabled={disabled} - onClick={onReset} + onClick={() => { + track("button", `Reset ${trackName}`); + onReset(); + }} 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" > diff --git a/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx index 8f8d046d46..7a05c488dd 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext"; import { Plus, X } from "../../icons/SystemIcons"; import { isTextEditableSelection, type DomEditSelection } from "./domEditing"; import type { ImportedFontAsset } from "./fontAssets"; @@ -56,6 +57,7 @@ function FlatTextFieldEditor({ onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void; autoFocus?: boolean; }) { + const track = useTrackDesignInput(); const weight = getTextStyleValue(field, styles, "font-weight", "400"); const weightOptions = detectAvailableWeights( field.computedStyles["font-family"] || styles["font-family"] || "", @@ -112,7 +114,10 @@ function FlatTextFieldEditor({