feat(studio): track design-panel input usage across both inspector UIs (#2467)

* feat(studio): add design-panel input usage tracking primitive

* feat(studio): track input usage in classic inspector panel

* feat(studio): track input usage in flat inspector panel

* fix(studio): attribute animation meta by field, track classic chrome, widen coverage guard
This commit is contained in:
Miguel Ángel
2026-07-15 01:33:27 -04:00
committed by GitHub
parent e3ca89e9ac
commit 7cc10a9922
33 changed files with 1613 additions and 188 deletions
@@ -113,6 +113,7 @@ export const ArcPathControls = memo(function ArcPathControls({
)}
</div>
<SliderControl
trackName={segmentCount === 1 ? "Curviness" : `Segment ${i + 1} curviness`}
value={seg.curviness}
min={0}
max={3}
@@ -1,3 +1,5 @@
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
export type GestureRecordingState = "idle" | "recording" | "preview";
interface GestureRecordIconProps {
@@ -28,13 +30,17 @@ export function GestureRecordPanelButton({
onToggleRecording,
}: GestureRecordPanelButtonProps) {
const recording = recordingState === "recording";
const track = useTrackDesignInput();
return (
<div className="px-4 pb-3">
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={onToggleRecording}
onClick={() => {
track("button", "Gesture recording");
onToggleRecording();
}}
className={`w-full flex items-center justify-center gap-2 rounded-lg py-2 text-[11px] font-medium transition-colors ${
recording
? "bg-red-500/15 text-red-400 border border-red-500/30 animate-pulse"
@@ -4,7 +4,11 @@ import { Film } from "../../icons/SystemIcons";
import { Section } from "./propertyPanelPrimitives";
import { ADD_METHODS, ADD_METHOD_LABELS, METHOD_TOOLTIPS } from "./gsapAnimationConstants";
import { AnimationCard } from "./AnimationCard";
import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks";
import {
trackAnimationMetaUpdate,
type GsapAnimationEditCallbacks,
} from "./gsapAnimationCallbacks";
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
interface GsapAnimationSectionProps extends GsapAnimationEditCallbacks {
animations: GsapAnimation[];
@@ -34,7 +38,24 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
onSetAllKeyframeEases,
onUnroll,
}: GsapAnimationSectionProps) {
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);
};
const updateMeta = (
animationId: string,
updates: { duration?: number; ease?: string; position?: number },
) => {
trackAnimationMetaUpdate(track, updates);
onUpdateMeta(animationId, updates);
};
return (
<Section title="Animation" icon={<Film size={15} />}>
@@ -58,21 +79,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 +179,7 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
type="button"
title={METHOD_TOOLTIPS[method]}
onClick={() => {
track("button", `Add ${method} animation`);
onAddAnimation(method);
setAddMenuOpen(false);
}}
@@ -1,26 +1,58 @@
import { Eye, EyeSlash } from "@phosphor-icons/react";
import { X } from "../../icons/SystemIcons";
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
import type { DomEditSelection } from "./domEditingTypes";
/** The action buttons in the inspector header: Ungroup (groups only), copy, clear. */
/** The action buttons in the inspector header: visibility, Ungroup (groups only), copy, clear. */
export function InspectorHeaderActions({
element,
copied,
onCopy,
onClear,
onUngroup,
selectedElementId,
selectedElementHidden,
visibilityLabel,
onToggleHidden,
}: {
element: DomEditSelection;
copied: boolean;
onCopy: () => void;
onClear: () => void;
onUngroup?: () => void;
selectedElementId?: string | null;
selectedElementHidden?: boolean;
visibilityLabel?: string;
onToggleHidden?: (id: string, hidden: boolean) => void;
}) {
const track = useTrackDesignInput();
return (
<div className="flex items-center gap-1">
{selectedElementId && onToggleHidden && (
<button
type="button"
aria-label={visibilityLabel}
title={visibilityLabel}
onClick={() => {
track("toggle", "Element visibility");
void onToggleHidden(selectedElementId, !selectedElementHidden);
}}
className="flex h-6 w-6 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-neutral-300"
>
{selectedElementHidden ? (
<EyeSlash size={13} weight="bold" aria-hidden="true" />
) : (
<Eye size={13} weight="bold" aria-hidden="true" />
)}
</button>
)}
{onUngroup && element.dataAttributes["hf-group"] != null && (
<button
type="button"
onClick={onUngroup}
onClick={() => {
track("button", "Ungroup");
onUngroup();
}}
title="Ungroup (⌘⇧G)"
className="flex h-6 items-center rounded px-2 text-[11px] font-medium text-neutral-400 transition-colors hover:bg-neutral-800 hover:text-neutral-200"
>
@@ -29,7 +61,10 @@ export function InspectorHeaderActions({
)}
<button
type="button"
onClick={onCopy}
onClick={() => {
track("button", "Copy element info");
onCopy();
}}
className={`flex h-6 w-6 items-center justify-center rounded transition-colors ${
copied
? "text-studio-accent"
@@ -52,7 +87,10 @@ export function InspectorHeaderActions({
<button
type="button"
aria-label="Clear selection"
onClick={onClear}
onClick={() => {
track("button", "Clear selection");
onClear();
}}
className="flex h-6 w-6 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-neutral-300"
>
<X size={13} />
@@ -1,6 +1,5 @@
import { memo, useEffect, useMemo, useRef, useState } from "react";
import { Move } from "../../icons/SystemIcons";
import { Eye, EyeSlash } from "@phosphor-icons/react";
import { InspectorHeaderActions } from "./InspectorHeaderActions";
import { useStudioShellContext } from "../../contexts/StudioContext";
import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
@@ -38,6 +37,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,51 +303,40 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
);
}
return (
const classicPanel = (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg text-panel-text-1">
<div className="px-4 py-3">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<div className="truncate text-[13px] font-semibold text-neutral-100">
{element.label}
<DesignPanelInputProvider section="header">
<div className="px-4 py-3">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<div className="truncate text-[13px] font-semibold text-neutral-100">
{element.label}
</div>
<div className="mt-0.5 truncate text-[11px] text-neutral-500">{sourceLabel}</div>
</div>
<div className="mt-0.5 truncate text-[11px] text-neutral-500">{sourceLabel}</div>
</div>
<div className="flex items-center gap-1">
{selectedElementId && onToggleElementHidden && (
<button
type="button"
aria-label={visibilityToggleLabel}
title={visibilityToggleLabel}
onClick={() => {
void onToggleElementHidden(selectedElementId, !selectedElementHidden);
}}
className="flex h-6 w-6 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-neutral-300"
>
{selectedElementHidden ? (
<EyeSlash size={13} weight="bold" aria-hidden="true" />
) : (
<Eye size={13} weight="bold" aria-hidden="true" />
)}
</button>
)}
<InspectorHeaderActions
element={element}
copied={clipboardCopied}
onCopy={handleCopyElementInfo}
onClear={onClearSelection}
onUngroup={onUngroup}
selectedElementId={selectedElementId}
selectedElementHidden={selectedElementHidden}
visibilityLabel={visibilityToggleLabel}
onToggleHidden={onToggleElementHidden}
/>
</div>
</div>
</div>
</DesignPanelInputProvider>
<div className="flex-1 overflow-y-auto">
{onToggleRecording && (
<GestureRecordPanelButton
recordingState={recordingState}
recordingDuration={recordingDuration}
onToggleRecording={onToggleRecording}
/>
<DesignPanelInputProvider section="footer">
<GestureRecordPanelButton
recordingState={recordingState}
recordingDuration={recordingDuration}
onToggleRecording={onToggleRecording}
/>
</DesignPanelInputProvider>
)}
<TextSection
@@ -593,4 +582,5 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
</div>
</div>
);
return <DesignPanelInputProvider ui="classic">{classicPanel}</DesignPanelInputProvider>;
});
@@ -1,5 +1,7 @@
import { type ReactNode, useEffect, useRef, useState } from "react";
import { resolveEditingSections } from "@hyperframes/core/editing";
import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
import { slugifyDesignInput } from "../../utils/designInputTracking";
import type { DomEditSelection } from "./domEditing";
import { isTextEditableSelection } from "./domEditing";
import type { PropertyPanelProps } from "./propertyPanelHelpers";
@@ -502,67 +504,77 @@ export function PropertyPanelFlat({
const afterOpen = openIndex === -1 ? [] : groups.slice(openIndex + 1);
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg text-panel-text-1">
<PropertyPanelFlatHeader
name={element.label}
meta={`${sourceLabel} · ${element.tagName}`}
elementKind={elementKind}
hidden={selectedElementHidden}
onToggleHidden={
selectedElementId && onToggleElementHidden
? () => void onToggleElementHidden(selectedElementId, !selectedElementHidden)
: undefined
}
copied={clipboardCopied}
onCopy={onCopyElementInfo}
onClear={onClearSelection}
onUngroup={onUngroup}
showUngroup={Boolean(onUngroup && element.dataAttributes["hf-group"] != null)}
/>
<div data-flat-panel-body="true" className="flex min-h-0 flex-1 flex-col overflow-y-auto">
{beforeOpen.map((g) => (
<FlatGroupHeader
key={g.id}
title={g.title}
isOpen={false}
onToggleOpen={() => toggleOpen(g.id)}
summary={g.summary}
animateEntrance={justToggledIds.includes(g.id)}
<DesignPanelInputProvider ui="flat">
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg text-panel-text-1">
<DesignPanelInputProvider section="header">
<PropertyPanelFlatHeader
name={element.label}
meta={`${sourceLabel} · ${element.tagName}`}
elementKind={elementKind}
hidden={selectedElementHidden}
onToggleHidden={
selectedElementId && onToggleElementHidden
? () => void onToggleElementHidden(selectedElementId, !selectedElementHidden)
: undefined
}
copied={clipboardCopied}
onCopy={onCopyElementInfo}
onClear={onClearSelection}
onUngroup={onUngroup}
showUngroup={Boolean(onUngroup && element.dataAttributes["hf-group"] != null)}
/>
))}
{openGroup && (
<div data-flat-group-open="true" className="flex min-h-0 flex-1 flex-col">
<FlatGroupHeader
title={openGroup.title}
isOpen
onToggleOpen={() => toggleOpen(openGroup.id)}
accessory={openGroup.accessory}
animateEntrance={justToggledIds.includes(openGroup.id)}
/>
<div
className={`${justToggledIds.includes(openGroup.id) ? "hf-flat-group-enter " : ""}min-h-0 flex-1 overflow-y-auto border-b border-panel-hairline bg-panel-bg-inset px-4 py-3 shadow-[inset_0_2px_4px_-1px_rgba(0,0,0,0.5)]`}
>
{openGroup.content}
</div>
</div>
)}
{afterOpen.map((g) => (
<FlatGroupHeader
key={g.id}
title={g.title}
isOpen={false}
onToggleOpen={() => toggleOpen(g.id)}
summary={g.summary}
animateEntrance={justToggledIds.includes(g.id)}
</DesignPanelInputProvider>
<div data-flat-panel-body="true" className="flex min-h-0 flex-1 flex-col overflow-y-auto">
{beforeOpen.map((g) => (
<DesignPanelInputProvider key={g.id} section={slugifyDesignInput(g.title)}>
<FlatGroupHeader
title={g.title}
isOpen={false}
onToggleOpen={() => toggleOpen(g.id)}
summary={g.summary}
animateEntrance={justToggledIds.includes(g.id)}
/>
</DesignPanelInputProvider>
))}
{openGroup && (
<DesignPanelInputProvider section={slugifyDesignInput(openGroup.title)}>
<div data-flat-group-open="true" className="flex min-h-0 flex-1 flex-col">
<FlatGroupHeader
title={openGroup.title}
isOpen
onToggleOpen={() => toggleOpen(openGroup.id)}
accessory={openGroup.accessory}
animateEntrance={justToggledIds.includes(openGroup.id)}
/>
<div
className={`${justToggledIds.includes(openGroup.id) ? "hf-flat-group-enter " : ""}min-h-0 flex-1 overflow-y-auto border-b border-panel-hairline bg-panel-bg-inset px-4 py-3 shadow-[inset_0_2px_4px_-1px_rgba(0,0,0,0.5)]`}
>
{openGroup.content}
</div>
</div>
</DesignPanelInputProvider>
)}
{afterOpen.map((g) => (
<DesignPanelInputProvider key={g.id} section={slugifyDesignInput(g.title)}>
<FlatGroupHeader
title={g.title}
isOpen={false}
onToggleOpen={() => toggleOpen(g.id)}
summary={g.summary}
animateEntrance={justToggledIds.includes(g.id)}
/>
</DesignPanelInputProvider>
))}
</div>
<DesignPanelInputProvider section="footer">
<PropertyPanelFlatFooter
onAskAgent={onAskAgent}
recordingState={recordingState}
recordingDuration={recordingDuration}
onToggleRecording={onToggleRecording}
/>
))}
</DesignPanelInputProvider>
</div>
<PropertyPanelFlatFooter
onAskAgent={onAskAgent}
recordingState={recordingState}
recordingDuration={recordingDuration}
onToggleRecording={onToggleRecording}
/>
</div>
</DesignPanelInputProvider>
);
}
@@ -1,3 +1,5 @@
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
export function PropertyPanelFlatFooter({
onAskAgent,
recordingState,
@@ -9,6 +11,7 @@ export function PropertyPanelFlatFooter({
recordingDuration?: number;
onToggleRecording?: () => void;
}) {
const track = useTrackDesignInput();
const recording = recordingState === "recording";
const recordTitle = recording
? `Stop recording ${(recordingDuration ?? 0).toFixed(1)}s`
@@ -25,7 +28,10 @@ export function PropertyPanelFlatFooter({
<button
type="button"
data-flat-footer-ask="true"
onClick={onAskAgent}
onClick={() => {
track("button", "Ask agent");
onAskAgent?.();
}}
disabled={!onAskAgent}
className="flex items-center gap-[7px] text-[11px] font-medium text-panel-text-2 disabled:cursor-not-allowed"
>
@@ -47,7 +53,10 @@ export function PropertyPanelFlatFooter({
aria-label={recordTitle}
title={recordTitle}
onMouseDown={(e) => e.preventDefault()}
onClick={onToggleRecording}
onClick={() => {
track("button", "Gesture recording");
onToggleRecording();
}}
className={recording ? "text-panel-danger animate-pulse" : "text-panel-danger"}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
@@ -1,4 +1,5 @@
import { Eye, EyeSlash } from "@phosphor-icons/react";
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
import { ClipboardList, Film, Square, Type, X } from "../../icons/SystemIcons";
const ICON_BY_KIND = { text: Type, media: Film, other: Square } as const;
@@ -31,6 +32,7 @@ export function PropertyPanelFlatHeader({
onUngroup?: () => void;
showUngroup: boolean;
}) {
const track = useTrackDesignInput();
const Icon = ICON_BY_KIND[elementKind];
const visibilityLabel = hidden ? "Show element" : "Hide element";
@@ -47,7 +49,15 @@ export function PropertyPanelFlatHeader({
</div>
<div className="flex flex-shrink-0 items-center gap-2.5 text-panel-text-3">
{showUngroup && (
<button type="button" aria-label="Ungroup" title="Ungroup (⌘⇧G)" onClick={onUngroup}>
<button
type="button"
aria-label="Ungroup"
title="Ungroup (⌘⇧G)"
onClick={() => {
track("button", "Ungroup");
onUngroup?.();
}}
>
<svg
width="13"
height="13"
@@ -66,7 +76,10 @@ export function PropertyPanelFlatHeader({
type="button"
aria-label={visibilityLabel}
title={visibilityLabel}
onClick={onToggleHidden}
onClick={() => {
track("toggle", "Element visibility");
onToggleHidden();
}}
>
{hidden ? <EyeSlash size={13} weight="bold" /> : <Eye size={13} weight="bold" />}
</button>
@@ -75,12 +88,22 @@ export function PropertyPanelFlatHeader({
type="button"
aria-label="Copy element info to clipboard"
title={copied ? "Copied!" : "Copy element info for any AI agent"}
onClick={onCopy}
onClick={() => {
track("button", "Copy element info");
onCopy();
}}
className={copied ? "text-panel-accent" : undefined}
>
<ClipboardList size={13} />
</button>
<button type="button" aria-label="Clear selection" onClick={onClear}>
<button
type="button"
aria-label="Clear selection"
onClick={() => {
track("button", "Clear selection");
onClear();
}}
>
<X size={13} />
</button>
</div>
@@ -34,3 +34,30 @@ export interface GsapAnimationEditCallbacks {
/** Unroll a computed (helper/loop) tween into literal tweens so it edits directly. */
onUnroll?: (animationId: string) => void;
}
// User-facing control label for each animation-meta field. The ease control is
// labelled "Speed" in the card UI, so ease/easeEach map there.
const ANIMATION_META_LABELS: Record<string, { control: string; name: string }> = {
duration: { control: "metric", name: "Length" },
position: { control: "metric", name: "Starts at" },
ease: { control: "select", name: "Speed" },
easeEach: { control: "select", name: "Speed" },
};
/**
* Emit design-input telemetry for an `onUpdateMeta` payload, attributing each
* changed field to the control the user actually touched. Iterates the real keys
* present rather than falling through to a single placeholder — so a meta field
* added later is attributed honestly by its own key instead of poisoning another
* control's usage count.
*/
export function trackAnimationMetaUpdate(
track: (control: string, name: string) => void,
updates: Record<string, unknown>,
): void {
for (const key of Object.keys(updates)) {
const mapped = ANIMATION_META_LABELS[key];
if (mapped) track(mapped.control, mapped.name);
else track("select", key);
}
}
@@ -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}
@@ -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<HTMLButtonElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(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 (nextValue !== value) track("color", label);
onCommit(nextValue);
};
const commitHsv = (nextHsv: { hue?: number; saturation?: number; value?: number }) => {
@@ -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<string[]>;
onCommitColorGrading: (nextGrading: NormalizedHfColorGrading) => void;
}) {
const track = useTrackDesignInput();
const lutInputRef = useRef<HTMLInputElement>(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,
@@ -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<HTMLButtonElement>) => {
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({
<div className="mt-4 grid min-w-0 grid-cols-[minmax(0,1fr)_auto] gap-2">
<select
value={applyScope}
onChange={(event) => setApplyScope(event.currentTarget.value as typeof applyScope)}
onChange={(event) => {
track("select", "Apply scope");
setApplyScope(event.currentTarget.value as typeof applyScope);
}}
disabled={applyBusy}
className="w-full min-w-0 rounded-md bg-panel-input px-3 py-2 text-[11px] font-medium text-panel-text-1 outline-none disabled:cursor-not-allowed disabled:opacity-50"
title="Choose where to copy these color grading settings"
@@ -218,6 +229,7 @@ export function ColorGradingSection({
disabled={applyBusy}
onClick={(event) => {
event.stopPropagation();
track("button", "Apply color grading scope");
void applyToScope();
}}
className="h-8 rounded-md bg-panel-input px-3 text-[11px] font-medium text-panel-text-2 transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-50"
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Minus, Plus, RotateCcw, Settings } from "../../icons/SystemIcons";
import { LABEL } from "./propertyPanelHelpers";
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
const SLIDER_THUMB_SIZE = 10;
const SLIDER_THUMB_RADIUS = SLIDER_THUMB_SIZE / 2;
@@ -59,9 +60,11 @@ export function ColorGradingSliderControl({
onClick: () => void;
};
}) {
const track = useTrackDesignInput();
const [draftState, setDraftState] = useState<{ value: number; source: number } | null>(null);
const [inputDraft, setInputDraft] = useState<{ value: string; source: number } | null>(null);
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const interactionChangedRef = useRef(false);
const valueRef = useRef(value);
valueRef.current = value;
@@ -92,9 +95,13 @@ export function ColorGradingSliderControl({
(nextValue: number) => {
const clamped = setLocalDraft(nextValue);
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
if (interactionChangedRef.current) {
interactionChangedRef.current = false;
track("slider", label);
}
if (clamped !== valueRef.current) onCommit(clamped);
},
[onCommit, setLocalDraft],
[label, onCommit, setLocalDraft, track],
);
const scheduleCommit = useCallback(
@@ -115,6 +122,7 @@ export function ColorGradingSliderControl({
const commitInputDraft = useCallback(() => {
const parsed = parseNumericInput(inputValue, scale);
if (parsed === null) {
interactionChangedRef.current = false;
setInputDraft(null);
return;
}
@@ -123,6 +131,7 @@ export function ColorGradingSliderControl({
const nudge = useCallback(
(direction: -1 | 1) => {
interactionChangedRef.current = true;
commitDraft(draft + step * direction);
},
[commitDraft, draft, step],
@@ -166,6 +175,7 @@ export function ColorGradingSliderControl({
aria-label={`Reset ${label}`}
onClick={(event) => {
event.stopPropagation();
track("button", `Reset ${label}`);
onReset();
}}
className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-panel-text-5 transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
@@ -205,7 +215,10 @@ export function ColorGradingSliderControl({
value={draft}
disabled={disabled}
aria-label={label}
onChange={(event) => scheduleCommit(Number(event.currentTarget.value))}
onChange={(event) => {
interactionChangedRef.current = true;
scheduleCommit(Number(event.currentTarget.value));
}}
onMouseUp={() => commitDraft(draft)}
onTouchEnd={() => commitDraft(draft)}
onBlur={() => commitDraft(draft)}
@@ -223,9 +236,10 @@ export function ColorGradingSliderControl({
max={max / scale}
step={step / scale}
disabled={disabled}
onChange={(event) =>
setInputDraft({ value: event.currentTarget.value, source: valueRef.current })
}
onChange={(event) => {
interactionChangedRef.current = true;
setInputDraft({ value: event.currentTarget.value, source: valueRef.current });
}}
onBlur={commitInputDraft}
onKeyDown={(event) => {
if (event.key === "Enter") {
@@ -16,6 +16,7 @@ import {
SliderControl,
} from "./propertyPanelPrimitives";
import { ColorField } from "./propertyPanelColor";
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
/* ------------------------------------------------------------------ */
/* Asset path helpers */
@@ -87,6 +88,7 @@ export function ImageFillField({
onCommit: (nextValue: string) => void;
onImportAssets?: (files: FileList) => Promise<string[]>;
}) {
const track = useTrackDesignInput();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [uploading, setUploading] = useState(false);
const imageAssets = useMemo(() => assets.filter((a) => IMAGE_EXT.test(a)), [assets]);
@@ -102,7 +104,10 @@ export function ImageFillField({
try {
const uploaded = await onImportAssets(files);
const nextImage = uploaded.find((a) => IMAGE_EXT.test(a));
if (nextImage) onCommit(`url("${toProjectRootAssetPath(nextImage)}")`);
if (nextImage) {
track("button", "Upload image");
onCommit(`url("${toProjectRootAssetPath(nextImage)}")`);
}
} finally {
setUploading(false);
}
@@ -156,6 +161,7 @@ export function ImageFillField({
disabled={disabled}
onChange={(e) => {
const next = e.target.value;
track("select", "Project asset");
if (!next) {
onCommit("none");
return;
@@ -205,6 +211,7 @@ export function GradientField({
disabled?: boolean;
onCommit: (nextValue: string) => void;
}) {
const track = useTrackDesignInput();
const previewRef = useRef<HTMLDivElement | null>(null);
const parsed = parseGradient(value) ?? buildDefaultGradientModel(fallbackColor);
@@ -226,11 +233,13 @@ export function GradientField({
? Math.min(100, (parsed.stops.at(-1)?.position ?? 90) + 10)
: 100,
);
track("button", "Add gradient stop");
commit(nextGradient);
};
const removeStop = (index: number) => {
if (parsed.stops.length <= 2) return;
track("button", `Remove gradient stop ${index + 1}`);
commit({ ...parsed, stops: parsed.stops.filter((_, i) => i !== index) });
};
@@ -263,6 +272,7 @@ export function GradientField({
</div>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<SegmentedControl
trackName="Gradient type"
disabled={disabled}
value={parsed.kind}
onChange={(next) => patch({ kind: next as GradientModel["kind"] })}
@@ -277,7 +287,10 @@ export function GradientField({
type="checkbox"
checked={parsed.repeating}
disabled={disabled}
onChange={(e) => patch({ repeating: e.target.checked })}
onChange={(e) => {
track("toggle", "Repeat gradient");
patch({ repeating: e.target.checked });
}}
className="h-4 w-4 rounded border-neutral-700 bg-neutral-950 text-panel-accent focus:ring-panel-accent"
/>
Repeat
@@ -285,15 +298,16 @@ export function GradientField({
<button
type="button"
disabled={disabled}
onClick={() =>
onClick={() => {
track("button", "Reverse gradient");
commit({
...parsed,
stops: [...parsed.stops].reverse().map((stop) => ({
...stop,
position: 100 - stop.position,
})),
})
}
});
}}
className="inline-flex h-7 items-center gap-1.5 rounded-lg border border-neutral-700 bg-neutral-950 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white disabled:cursor-not-allowed disabled:text-neutral-600"
>
<RotateCcw size={12} />
@@ -306,6 +320,7 @@ export function GradientField({
<div className="grid gap-1.5">
<span className={LABEL}>{parsed.kind === "linear" ? "Angle" : "Start angle"}</span>
<SliderControl
trackName={parsed.kind === "linear" ? "Angle" : "Start angle"}
value={parsed.angle}
min={0}
max={360}
@@ -342,6 +357,7 @@ export function GradientField({
<div className="grid min-w-0 gap-1.5">
<span className={LABEL}>Center X</span>
<SliderControl
trackName="Center X"
value={parsed.centerX}
min={0}
max={100}
@@ -355,6 +371,7 @@ export function GradientField({
<div className="grid min-w-0 gap-1.5">
<span className={LABEL}>Center Y</span>
<SliderControl
trackName="Center Y"
value={parsed.centerY}
min={0}
max={100}
@@ -9,6 +9,7 @@ import {
type NormalizedHfColorGrading,
} from "@hyperframes/core/color-grading";
import { Compare, Plus, RotateCcw, Settings } from "../../icons/SystemIcons";
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
import { LUT_EXT } from "../../utils/mediaTypes";
import { FlatSelectRow, FlatSlider } from "./propertyPanelFlatPrimitives";
import { resolveValueTier } from "./propertyPanelValueTier";
@@ -30,6 +31,7 @@ export function FlatColorGradingAccessory({
"grading" | "compareEnabled" | "runtimeStatus" | "commitCompare" | "resetGrading"
>;
}) {
const track = useTrackDesignInput();
const { grading, compareEnabled, runtimeStatus, commitCompare, resetGrading } = state;
const gradingActive = isHfColorGradingActive(grading);
// Tracks the active hold's cleanup so it can be torn down on unmount too —
@@ -56,6 +58,7 @@ export function FlatColorGradingAccessory({
if (!gradingActive) return;
e.preventDefault();
e.stopPropagation();
track("button", "Compare original");
commitCompare(true);
const release = () => {
commitCompare(false);
@@ -75,7 +78,10 @@ export function FlatColorGradingAccessory({
onKeyDown={(e) => {
if (!gradingActive || (e.key !== " " && e.key !== "Enter")) return;
e.preventDefault();
if (!compareEnabled) commitCompare(true);
if (!compareEnabled) {
track("button", "Compare original");
commitCompare(true);
}
}}
onKeyUp={(e) => {
if (!gradingActive || (e.key !== " " && e.key !== "Enter")) return;
@@ -106,6 +112,7 @@ export function FlatColorGradingAccessory({
title="Reset color grading"
onClick={(e) => {
e.stopPropagation();
track("button", "Reset color grading");
resetGrading();
}}
className="flex-shrink-0 text-panel-text-3 hover:text-panel-text-1"
@@ -242,6 +249,7 @@ export function FlatColorGradingSection({
onApplyScopeAvailable: boolean;
mediaMetadata: MediaMetadata | null;
}) {
const track = useTrackDesignInput();
const lutInputRef = useRef<HTMLInputElement>(null);
const [lutOpen, setLutOpen] = useState(false);
const [detailSettingsOpen, setDetailSettingsOpen] = useState<"vignette" | "grain" | null>(null);
@@ -270,7 +278,10 @@ export function FlatColorGradingSection({
if (!files?.length || !onImportAssets) return;
const uploaded = await onImportAssets(files, "assets/luts");
const firstLut = uploaded.find((asset) => LUT_EXT.test(asset));
if (firstLut) applyLut(firstLut, 1);
if (firstLut) {
track("button", "Import LUT");
applyLut(firstLut, 1);
}
};
const renderDetailSlider = (key: HfColorGradingDetailKey) => {
@@ -363,6 +374,7 @@ export function FlatColorGradingSection({
value={lut?.src ?? ""}
onChange={(e) => {
const src = e.target.value;
track("select", "Custom LUT");
applyLut(src || null, src && lut?.src === src ? lut.intensity : 1);
}}
className="bg-transparent font-mono text-[10px] text-panel-text-3 outline-none"
@@ -528,7 +540,10 @@ export function FlatColorGradingSection({
<select
aria-label="Copy grade to"
value={applyScope}
onChange={(e) => 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"}
@@ -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));
}}
/>
</span>
);
@@ -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<BackgroundRemovalResult>;
}) {
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);
@@ -1,12 +1,16 @@
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";
import { CommitField } from "./propertyPanelPrimitives";
import { AnimationCard } from "./AnimationCard";
import { ADD_METHODS, ADD_METHOD_LABELS, METHOD_TOOLTIPS } from "./gsapAnimationConstants";
import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks";
import {
trackAnimationMetaUpdate,
type GsapAnimationEditCallbacks,
} from "./gsapAnimationCallbacks";
import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
export function FlatTimingRow({
@@ -25,6 +29,7 @@ export function FlatTimingRow({
* documented below) when the caller doesn't wire it up. */
onSetAttributes?: (selection: DomEditSelection, attrs: Record<string, string>) => Promise<void>;
}) {
const track = useTrackDesignInput();
const { start, duration, inferred: derived } = deriveElementTiming(element, animations);
const end = start + duration;
@@ -81,7 +86,13 @@ export function FlatTimingRow({
<div className="grid gap-px">
<span className="text-[9px] text-panel-text-4">{label}</span>
<span className="border-b border-transparent font-mono text-[11px] text-panel-text-0 hover:border-panel-border-input">
<CommitField value={value} onCommit={onCommit} />
<CommitField
value={value}
onCommit={(next) => {
track("metric", label);
onCommit(next);
}}
/>
</span>
</div>
);
@@ -122,7 +133,17 @@ export function FlatMotionSection({
onSetAttributes?: (selection: DomEditSelection, attrs: Record<string, string>) => Promise<void>;
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 (
<div className="space-y-3">
@@ -155,7 +176,97 @@ export function FlatMotionSection({
animation={anim}
defaultExpanded={index === 0}
flat
{...callbacks}
onUpdateProperty={(animationId, property, value) => {
trackProperty(property);
callbacks.onUpdateProperty(animationId, property, value);
}}
onUpdateMeta={(animationId, updates) => {
trackAnimationMetaUpdate(track, updates);
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
}
/>
))}
<div className="relative pt-1">
@@ -167,6 +278,7 @@ export function FlatMotionSection({
type="button"
title={METHOD_TOOLTIPS[method]}
onClick={() => {
track("button", `Add ${method} animation`);
onAddAnimation(method);
setAddMenuOpen(false);
}}
@@ -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 (
<div className="group flex min-h-[30px] items-center justify-between gap-3">
<span className={`text-[11px] ${VALUE_TIER_LABEL_CLASS[tier]}`}>{label}</span>
@@ -49,7 +51,10 @@ export function FlatRow({
value={value}
disabled={disabled}
liveCommit={liveCommit}
onCommit={onCommit}
onCommit={(nextValue) => {
track("metric", label);
onCommit(nextValue);
}}
/>
</span>
{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"
>
<RotateCcw size={11} />
@@ -109,6 +117,7 @@ export function FlatSegmentedRow({
spacerAfterIndex?: number;
onChange: (nextKey: string) => void;
}) {
const track = useTrackDesignInput();
return (
<div className="flex min-h-[32px] items-center justify-between">
<span className="text-[11px] text-panel-text-3">{label}</span>
@@ -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"
>
<RotateCcw size={11} />
@@ -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"
>
<RotateCcw size={11} />
@@ -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({
<label className="flex items-center gap-1.5">
<select
value={weight}
onChange={(e) => onSetTextFieldStyle(field.key, "font-weight", e.target.value)}
onChange={(e) => {
track("select", "Weight");
onSetTextFieldStyle(field.key, "font-weight", e.target.value);
}}
className={`appearance-none bg-transparent text-right font-mono text-[11px] outline-none ${
VALUE_TIER_VALUE_CLASS[resolveValueTier(field.inlineStyles["font-weight"], "400")]
}`}
@@ -242,6 +247,7 @@ export function FlatTextSection({
onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
onRemoveTextField: (fieldKey: string) => void;
}) {
const track = useTrackDesignInput();
const [activeFieldKey, setActiveFieldKey] = useState<string | null>(
element.textFields[0]?.key ?? null,
);
@@ -300,7 +306,10 @@ export function FlatTextSection({
/>
<button
type="button"
onClick={() => void onAddTextField(activeField.key)}
onClick={() => {
track("button", "Add text field");
void onAddTextField(activeField.key);
}}
className="mt-0.5 flex items-center gap-[5px] text-[10px] text-panel-text-4 hover:text-panel-text-2"
>
<Plus size={10} />
@@ -333,6 +342,7 @@ export function FlatTextLayerList({
onAdd: () => void;
onRemove: (fieldKey: string) => void;
}) {
const track = useTrackDesignInput();
return (
<div className="mb-2 border-l-2 border-panel-border-input py-0.5 pl-[10px]">
<div className="mb-1.5 text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
@@ -368,6 +378,7 @@ export function FlatTextLayerList({
aria-label="Remove text field"
onClick={(e) => {
e.stopPropagation();
track("button", "Remove text field");
onRemove(field.key);
}}
className="flex-shrink-0 text-panel-text-4 hover:text-panel-text-1"
@@ -382,7 +393,10 @@ export function FlatTextLayerList({
<button
type="button"
data-flat-text-layer-add="true"
onClick={onAdd}
onClick={() => {
track("button", "Add text field");
onAdd();
}}
className="mt-1 flex items-center gap-[5px] text-[10px] text-panel-text-4 hover:text-panel-text-2"
>
<Plus size={10} />
@@ -4,6 +4,8 @@
/* 600-line file-size gate) */
/* ------------------------------------------------------------------ */
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
export function FlatToggle({
label,
checked,
@@ -15,6 +17,7 @@ export function FlatToggle({
disabled?: boolean;
onChange: (next: boolean) => void;
}) {
const track = useTrackDesignInput();
return (
<div className="flex min-h-[30px] items-center justify-between">
<span
@@ -30,7 +33,10 @@ export function FlatToggle({
aria-checked={checked}
aria-label={label}
disabled={disabled}
onClick={() => onChange(!checked)}
onClick={() => {
track("toggle", label);
onChange(!checked);
}}
className={`relative h-[14px] w-6 flex-shrink-0 rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
checked ? "bg-panel-accent/35" : "bg-panel-hover"
}`}
@@ -18,6 +18,7 @@ import {
type FontOption,
type LocalFontData,
} from "./propertyPanelHelpers";
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
/* ------------------------------------------------------------------ */
/* Font helper functions */
@@ -135,6 +136,7 @@ export function FontFamilyField({
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
onCommit: (nextValue: string) => void;
}) {
const track = useTrackDesignInput();
const currentFamily = primaryFontFamily(value);
const containerRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
@@ -150,6 +152,10 @@ export function FontFamilyField({
const [fontNotice, setFontNotice] = useState<string | null>(null);
const canQueryLocalFonts =
typeof window !== "undefined" && typeof window.queryLocalFonts === "function";
const commitFontFamily = (nextValue: string) => {
if (nextValue !== value) track("select", "Font family");
onCommit(nextValue);
};
useEffect(() => {
if (!open) return;
@@ -247,7 +253,7 @@ export function FontFamilyField({
for (const font of imported) loadImportedFontStylesheet(font);
const first = imported[0];
if (first) {
onCommit(buildFontFamilyValue(first.family));
commitFontFamily(buildFontFamilyValue(first.family));
setQuery("");
setOpen(false);
} else {
@@ -349,7 +355,7 @@ export function FontFamilyField({
: await importSystemFont(option.family);
if (imported) {
loadImportedFontStylesheet(imported);
onCommit(buildFontFamilyValue(imported.family));
commitFontFamily(buildFontFamilyValue(imported.family));
setQuery("");
setOpen(false);
return;
@@ -363,7 +369,7 @@ export function FontFamilyField({
(f) => f.family.toLowerCase() === option.family.toLowerCase(),
);
if (imported) loadImportedFontStylesheet(imported);
onCommit(buildFontFamilyValue(option.family));
commitFontFamily(buildFontFamilyValue(option.family));
setQuery("");
setOpen(false);
};
@@ -0,0 +1,587 @@
// @vitest-environment happy-dom
import React, { act, type ReactElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
import { __resetDesignInputThrottle } from "../../utils/designInputTracking";
import type { PropertyPanelProps } from "./propertyPanelHelpers";
import { ColorField } from "./propertyPanelColor";
import { FontFamilyField } from "./propertyPanelFont";
import {
FlatRow,
FlatSegmentedRow,
FlatSelectRow,
FlatSlider,
} from "./propertyPanelFlatPrimitives";
import { FlatToggle } from "./propertyPanelFlatToggle";
import {
DetailField,
MetricField,
Section,
SegmentedControl,
SelectField,
SliderControl,
} from "./propertyPanelPrimitives";
import { TextAreaField } from "./propertyPanelSections";
const trackStudioEvent = vi.hoisted(() => vi.fn());
vi.mock("../../utils/studioTelemetry", () => ({
trackStudioEvent: (...args: unknown[]) => trackStudioEvent(...args),
}));
vi.mock("../../contexts/StudioContext", async () => {
const actual = await vi.importActual<typeof import("../../contexts/StudioContext")>(
"../../contexts/StudioContext",
);
return { ...actual, useStudioShellContext: () => ({ showToast: vi.fn() }) };
});
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let roots: Root[] = [];
beforeEach(() => {
trackStudioEvent.mockReset();
__resetDesignInputThrottle();
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(JSON.stringify([]), { status: 200 })),
);
});
afterEach(() => {
for (const root of roots) act(() => root.unmount());
roots = [];
document.body.innerHTML = "";
vi.useRealTimers();
vi.doUnmock("./manualEditingAvailability");
vi.resetModules();
vi.unstubAllGlobals();
});
function render(ui: ReactElement): HTMLElement {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
roots.push(root);
act(() => root.render(ui));
return host;
}
function changeInput(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
if (!setter) throw new Error("expected native input value setter");
setter.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
function changeTextarea(textarea: HTMLTextAreaElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
if (!setter) throw new Error("expected native textarea value setter");
setter.call(textarea, value);
textarea.dispatchEvent(new Event("input", { bubbles: true }));
}
function blurInput(input: HTMLInputElement) {
input.focus();
input.blur();
}
function expectTracked(control: string, name: string, section = "style") {
expect(trackStudioEvent).toHaveBeenLastCalledWith("design_input", {
ui: "classic",
section,
control,
name,
});
}
function expectFlatTracked(control: string, name: string, section = "layout") {
expect(trackStudioEvent).toHaveBeenLastCalledWith("design_input", {
ui: "flat",
section,
control,
name,
});
}
function flatSection(children: ReactElement) {
return (
<DesignPanelInputProvider ui="flat" section="layout">
{children}
</DesignPanelInputProvider>
);
}
function classicSection(children: ReactElement) {
return (
<DesignPanelInputProvider ui="classic">
<Section title="Style" icon={null}>
{children}
</Section>
</DesignPanelInputProvider>
);
}
describe("classic property-panel primitive telemetry", () => {
it("tracks MetricField only when a changed value commits", () => {
const onCommit = vi.fn();
const host = render(
classicSection(<MetricField label="Opacity" value="20" onCommit={onCommit} />),
);
const input = host.querySelector("input");
if (!input) throw new Error("expected metric input");
act(() => blurInput(input));
expect(trackStudioEvent).not.toHaveBeenCalled();
act(() => {
changeInput(input, "40");
});
act(() => blurInput(input));
expect(onCommit).toHaveBeenCalledWith("40");
expectTracked("metric", "opacity");
});
it("tracks SliderControl on settle, not on its scheduled commit tick", () => {
vi.useFakeTimers();
const onCommit = vi.fn();
const host = render(
classicSection(
<SliderControl
trackName="Opacity"
value={20}
min={0}
max={100}
step={1}
displayValue="20%"
onCommit={onCommit}
/>,
),
);
const input = host.querySelector<HTMLInputElement>('input[type="range"]');
if (!input) throw new Error("expected slider input");
act(() => {
changeInput(input, "40");
});
act(() => vi.advanceTimersByTime(40));
expect(trackStudioEvent).not.toHaveBeenCalled();
act(() => input.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })));
expectTracked("slider", "opacity");
});
it("tracks SelectField with its label", () => {
const host = render(
classicSection(
<SelectField
label="Blend mode"
value="normal"
options={["normal", "multiply"]}
onChange={vi.fn()}
/>,
),
);
const select = host.querySelector("select");
if (!select) throw new Error("expected select");
act(() => {
select.value = "multiply";
select.dispatchEvent(new Event("change", { bubbles: true }));
});
expectTracked("select", "blend-mode");
});
it("tracks DetailField with its label", () => {
const host = render(
classicSection(<DetailField label="External URL" value="old.png" onCommit={vi.fn()} />),
);
const input = host.querySelector("input");
if (!input) throw new Error("expected detail input");
act(() => changeInput(input, "new.png"));
act(() => blurInput(input));
expectTracked("text", "external-url");
});
it("tracks SegmentedControl with its explicit name", () => {
const host = render(
classicSection(
<SegmentedControl
trackName="Fill type"
value="solid"
options={[
{ label: "Solid", value: "solid" },
{ label: "Gradient", value: "gradient" },
]}
onChange={vi.fn()}
/>,
),
);
const gradient = Array.from(host.querySelectorAll("button")).find(
(button) => button.textContent === "Gradient",
);
if (!gradient) throw new Error("expected Gradient segment");
act(() => gradient.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expectTracked("segmented", "fill-type");
});
});
describe("flat property-panel primitive telemetry", () => {
it("tracks FlatRow commits with its label", () => {
const host = render(
flatSection(<FlatRow label="Z-index" value="1" tier="default" onCommit={vi.fn()} />),
);
const input = host.querySelector("input");
if (!input) throw new Error("expected flat row input");
act(() => changeInput(input, "2"));
act(() => blurInput(input));
expectFlatTracked("metric", "z-index");
});
it("tracks FlatSlider once on pointer settle, not during drag commits", () => {
const host = render(
flatSection(
<FlatSlider
label="Opacity"
value={10}
min={0}
max={100}
tier="explicitCustom"
displayValue="10%"
onCommit={vi.fn()}
/>,
),
);
const slider = host.querySelector<HTMLElement>('[data-flat-slider-track="true"]');
if (!slider) throw new Error("expected flat slider");
Object.defineProperty(slider, "getBoundingClientRect", {
value: () => ({ left: 0, width: 100, top: 0, height: 20, right: 100, bottom: 20 }),
});
act(() => {
slider.dispatchEvent(
new PointerEvent("pointerdown", { bubbles: true, clientX: 20, pointerId: 1 }),
);
slider.dispatchEvent(
new PointerEvent("pointermove", { bubbles: true, clientX: 80, pointerId: 1 }),
);
});
expect(trackStudioEvent).not.toHaveBeenCalled();
act(() => {
slider.dispatchEvent(
new PointerEvent("pointerup", { bubbles: true, clientX: 80, pointerId: 1 }),
);
});
expect(trackStudioEvent).toHaveBeenCalledTimes(1);
expectFlatTracked("slider", "opacity");
});
it("tracks FlatSegmentedRow changes with its label", () => {
const host = render(
flatSection(
<FlatSegmentedRow
label="Direction"
options={[
{ key: "row", node: "Row", label: "Row", active: true },
{ key: "column", node: "Column", label: "Column", active: false },
]}
onChange={vi.fn()}
/>,
),
);
const column = host.querySelector<HTMLButtonElement>('[aria-label="Column"]');
act(() => column?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expectFlatTracked("segmented", "direction");
});
it("tracks FlatToggle changes with its label", () => {
const host = render(
flatSection(<FlatToggle label="Loop" checked={false} onChange={vi.fn()} />),
);
const toggle = host.querySelector<HTMLButtonElement>('[data-flat-toggle="true"]');
act(() => toggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expectFlatTracked("toggle", "loop");
});
it("tracks FlatSelectRow changes with its accessible label", () => {
const host = render(
flatSection(
<FlatSelectRow
label=""
ariaLabel="Preset"
value="neutral"
options={["neutral", "warm"]}
tier="default"
onChange={vi.fn()}
/>,
),
);
const select = host.querySelector("select");
if (!select) throw new Error("expected flat select");
act(() => {
select.value = "warm";
select.dispatchEvent(new Event("change", { bubbles: true }));
});
expectFlatTracked("select", "preset");
});
});
describe.each(["classic", "flat"] as const)("shared %s input telemetry", (ui) => {
const section = (children: ReactElement) => (
<DesignPanelInputProvider ui={ui} section="text">
{children}
</DesignPanelInputProvider>
);
it("tracks ColorField exactly once for a real color change", () => {
const host = render(
section(<ColorField flat={ui === "flat"} label="Color" value="#FF0000" onCommit={vi.fn()} />),
);
const trigger = host.querySelector<HTMLButtonElement>('[aria-label="Pick color color"]');
act(() => trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
const hex = Array.from(document.body.querySelectorAll<HTMLInputElement>("input")).find(
(input) => input.value === "#FF0000",
);
if (!hex) throw new Error("expected color hex input");
act(() => changeInput(hex, "#00FF00"));
expect(trackStudioEvent).toHaveBeenCalledTimes(1);
expect(trackStudioEvent).toHaveBeenLastCalledWith("design_input", {
ui,
section: "text",
control: "color",
name: "color",
});
});
it("tracks FontFamilyField exactly once for a real selection", () => {
const host = render(
section(
<FontFamilyField
flat={ui === "flat"}
value="Arial"
importedFonts={[]}
onCommit={vi.fn()}
/>,
),
);
const trigger = host.querySelector<HTMLButtonElement>(
ui === "flat" ? '[data-flat-font-trigger="true"]' : "button",
);
act(() => trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
const option = Array.from(host.querySelectorAll<HTMLButtonElement>("button")).find((button) =>
button.textContent?.includes("sans-serif"),
);
if (!option) throw new Error("expected sans-serif font option");
act(() => option.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(trackStudioEvent).toHaveBeenCalledTimes(1);
expect(trackStudioEvent).toHaveBeenLastCalledWith("design_input", {
ui,
section: "text",
control: "select",
name: "font-family",
});
});
it("tracks TextAreaField exactly once across scheduled commit and blur", () => {
vi.useFakeTimers();
const host = render(
section(
<TextAreaField flat={ui === "flat"} label="Content" value="Before" onCommit={vi.fn()} />,
),
);
const textarea = host.querySelector("textarea");
if (!textarea) throw new Error("expected text area");
act(() => changeTextarea(textarea, "After"));
act(() => vi.advanceTimersByTime(120));
act(() => {
textarea.focus();
textarea.blur();
});
expect(trackStudioEvent).toHaveBeenCalledTimes(1);
expect(trackStudioEvent).toHaveBeenLastCalledWith("design_input", {
ui,
section: "text",
control: "text",
name: "content",
});
});
});
function representativeElement() {
return {
element: document.createElement("div"),
id: "panel-target",
selector: "#panel-target",
label: "Panel Target",
tagName: "div",
sourceFile: "index.html",
compositionPath: "index.html",
isCompositionHost: false,
isInsideLockedComposition: false,
boundingBox: { x: 0, y: 0, width: 320, height: 180 },
textContent: "",
dataAttributes: {},
inlineStyles: {},
computedStyles: {},
textFields: [],
capabilities: {
canSelect: true,
canEditStyles: false,
canCrop: true,
canMove: true,
canResize: true,
canApplyManualOffset: true,
canApplyManualSize: true,
canApplyManualRotation: true,
},
};
}
describe("classic PropertyPanel input coverage", () => {
it("emits only named, known-section events across body inputs and header/footer chrome", async () => {
const { PropertyPanel } = await import("./PropertyPanel");
const host = render(
<PropertyPanel
{...({
element: representativeElement(),
assets: [],
onSetStyle: vi.fn(),
onSetText: vi.fn(),
onSetAttributeLive: vi.fn(),
onSetManualOffset: vi.fn(),
onSetManualSize: vi.fn(),
onSetManualRotation: vi.fn(),
onClearSelection: vi.fn(),
onAskAgent: vi.fn(),
onToggleElementHidden: vi.fn(),
recordingState: "idle",
onToggleRecording: vi.fn(),
} as unknown as PropertyPanelProps)}
/>,
);
// Fire every body text input across the WHOLE panel (not just the layout
// section): a section rendered without a <DesignPanelInputProvider section="X">
// would surface here as section "unknown" and fail the invariant below.
const bodyInputs = Array.from(host.querySelectorAll<HTMLInputElement>('input[type="text"]'));
expect(bodyInputs.length).toBeGreaterThan(0);
for (const [index, input] of bodyInputs.entries()) {
act(() => changeInput(input, String(100 + index)));
act(() => blurInput(input));
}
// Header + footer chrome — the classic siblings of the flat header/footer.
// (Copy is skipped: its handler reaches for the clipboard, unavailable here.
// The visibility toggle is store-gated on a live selection this unit mock does
// not model; Clear selection already exercises the header section.)
const clear = host.querySelector<HTMLButtonElement>('[aria-label="Clear selection"]');
if (!clear) throw new Error("expected classic Clear selection control");
act(() => clear.dispatchEvent(new MouseEvent("click", { bubbles: true })));
const recordButton = Array.from(host.querySelectorAll("button")).find((b) =>
b.textContent?.includes("Record gesture"),
);
if (!recordButton) throw new Error("expected classic gesture record button");
act(() => recordButton.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(trackStudioEvent).toHaveBeenCalled();
const sections = new Set<string>();
for (const [, payload] of trackStudioEvent.mock.calls) {
expect(payload.ui).toBe("classic");
expect(payload.name).not.toBe("");
expect(payload.name).not.toBe("unnamed");
expect(payload.section).not.toBe("");
expect(payload.section).not.toBe("unknown");
sections.add(payload.section as string);
}
// A body section plus both chrome regions: proves coverage beyond one section
// and that classic chrome is wired in parallel with the flat header/footer.
expect(sections.has("header")).toBe(true);
expect(sections.has("footer")).toBe(true);
expect(sections.size).toBeGreaterThan(2);
});
});
describe("flat PropertyPanel input coverage", () => {
it("emits only named flat events from known sections for every visible layout input", async () => {
vi.resetModules();
vi.doMock("./manualEditingAvailability", async () => {
const actual = await vi.importActual<typeof import("./manualEditingAvailability")>(
"./manualEditingAvailability",
);
return { ...actual, STUDIO_FLAT_INSPECTOR_ENABLED: true };
});
const { PropertyPanel } = await import("./PropertyPanel");
const host = render(
<PropertyPanel
{...({
element: representativeElement(),
assets: [],
onSetStyle: vi.fn(),
onSetText: vi.fn(),
onSetAttributeLive: vi.fn(),
onSetManualOffset: vi.fn(),
onSetManualSize: vi.fn(),
onSetManualRotation: vi.fn(),
// Header/footer controls render only when their callbacks are wired —
// supply them so the coverage guard exercises the header + footer sections.
selectedElementId: "el-1",
selectedElementHidden: false,
onToggleElementHidden: vi.fn(),
onCopyElementInfo: vi.fn(),
onClearSelection: vi.fn(),
onAskAgent: vi.fn(),
onToggleRecording: vi.fn(),
} as unknown as PropertyPanelProps)}
/>,
);
const layout = host.querySelector('[data-flat-group-open="true"]');
if (!layout || !layout.textContent?.includes("Layout")) {
throw new Error("expected open flat Layout group");
}
const inputs = Array.from(layout.querySelectorAll<HTMLInputElement>('input[type="text"]'));
expect(inputs.length).toBeGreaterThan(0);
for (const [index, input] of inputs.entries()) {
act(() => changeInput(input, String(200 + index)));
act(() => blurInput(input));
}
// Header (Clear selection) and footer (ask + record) controls — exercises the
// "header" and "footer" sections. The visibility toggle is intentionally omitted:
// it renders only when the dispatcher forwards a live selection handle, which this
// unit-level mock does not model. Clear selection already covers the header section.
for (const selector of [
'[aria-label="Clear selection"]',
'[data-flat-footer-ask="true"]',
'[data-flat-footer-record="true"]',
]) {
const button = host.querySelector<HTMLButtonElement>(selector);
if (!button) throw new Error(`expected flat panel control ${selector}`);
act(() => button.dispatchEvent(new MouseEvent("click", { bubbles: true })));
}
expect(trackStudioEvent).toHaveBeenCalled();
expect(new Set(trackStudioEvent.mock.calls.map(([, payload]) => payload.section))).toEqual(
new Set(["layout", "header", "footer"]),
);
for (const [, payload] of trackStudioEvent.mock.calls) {
expect(payload).toEqual(
expect.objectContaining({
ui: "flat",
}),
);
expect(payload.name).not.toBe("");
expect(payload.name).not.toBe("unnamed");
expect(payload.section).not.toBe("");
expect(payload.section).not.toBe("unknown");
}
});
});
@@ -12,6 +12,7 @@ import {
stripQueryAndHash,
} from "./propertyPanelHelpers";
import { Section, SegmentedControl, SelectField, SliderControl } from "./propertyPanelPrimitives";
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
// fallow-ignore-next-line complexity
export function MediaSection({
@@ -38,6 +39,7 @@ export function MediaSection({
},
) => Promise<BackgroundRemovalResult>;
}) {
const track = useTrackDesignInput();
const isVideo = element.tagName === "video";
const isAudio = element.tagName === "audio";
const isImage = element.tagName === "img";
@@ -98,6 +100,7 @@ export function MediaSection({
const runBackgroundRemoval = async () => {
if (!onRemoveBackground || !projectSrc || removeBusy) return;
track("button", "Remove background");
setRemoveBusy(true);
setRemoveProgress({ status: "processing", progress: 0, stage: "Preparing" });
try {
@@ -194,6 +197,7 @@ export function MediaSection({
<div className="grid min-w-0 gap-1.5">
<span className={LABEL}>BG plate</span>
<SegmentedControl
trackName="BG plate"
value={createPlate ? "on" : "off"}
onChange={(next) => setCreatePlate(next === "on")}
options={[
@@ -245,6 +249,7 @@ export function MediaSection({
<div className="grid min-w-0 gap-1.5">
<span className={LABEL}>Volume</span>
<SliderControl
trackName="Volume"
value={volumePercent}
min={0}
max={100}
@@ -260,6 +265,7 @@ export function MediaSection({
<div className="grid min-w-0 gap-1.5">
<span className={LABEL}>Playback rate</span>
<SliderControl
trackName="Playback rate"
value={playbackRate * 100}
min={25}
max={300}
@@ -275,6 +281,7 @@ export function MediaSection({
<div className="grid min-w-0 gap-1.5">
<span className={LABEL}>Media start</span>
<SliderControl
trackName="Media start"
value={Math.round(mediaStart * 100)}
min={0}
max={mediaStartMax * 100}
@@ -291,6 +298,7 @@ export function MediaSection({
<div className="grid min-w-0 gap-1.5">
<span className={LABEL}>Loop</span>
<SegmentedControl
trackName="Loop"
value={hasLoop ? "on" : "off"}
onChange={(next) => {
void onSetHtmlAttribute("loop", next === "on" ? "true" : null);
@@ -304,6 +312,7 @@ export function MediaSection({
<div className="grid min-w-0 gap-1.5">
<span className={LABEL}>Muted</span>
<SegmentedControl
trackName="Muted"
value={hasMuted ? "on" : "off"}
onChange={(next) => {
void onSetHtmlAttribute("muted", next === "on" ? "true" : null);
@@ -320,6 +329,7 @@ export function MediaSection({
<div className="grid min-w-0 gap-1.5">
<span className={LABEL}>Has audio track</span>
<SegmentedControl
trackName="Has audio track"
value={hasAudio ? "yes" : "no"}
onChange={(next) => {
if (next === "yes") {
@@ -1,4 +1,8 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import {
DesignPanelInputProvider,
useTrackDesignInput,
} from "../../contexts/DesignPanelInputContext";
import { adjustNumericToken, FIELD, LABEL, parseNumericToken } from "./propertyPanelHelpers";
export function CommitField({
@@ -116,7 +120,15 @@ export function MetricField({
tooltip?: string;
onCommit: (nextValue: string) => void;
}) {
const track = useTrackDesignInput();
const scrubRef = useRef<{ startX: number; startValue: number; pointerId: number } | null>(null);
const commit = useCallback(
(nextValue: string) => {
if (nextValue !== value) track("metric", label);
onCommit(nextValue);
},
[label, onCommit, track, value],
);
const handleScrubPointerDown = useCallback(
(e: React.PointerEvent<HTMLSpanElement>) => {
@@ -134,9 +146,9 @@ export function MetricField({
const state = scrubRef.current;
if (!state) return;
const delta = e.clientX - state.startX;
onCommit(String(Math.round(state.startValue + delta)));
commit(String(Math.round(state.startValue + delta)));
},
[onCommit],
[commit],
);
const handleScrubPointerUp = useCallback(() => {
@@ -158,12 +170,7 @@ export function MetricField({
<div className={FIELD} title={tooltip}>
<div className="flex min-w-0 items-center gap-3">
<span {...scrubProps}>{label}</span>
<CommitField
value={value}
disabled={disabled}
liveCommit={liveCommit}
onCommit={onCommit}
/>
<CommitField value={value} disabled={disabled} liveCommit={liveCommit} onCommit={commit} />
{suffix && <span className="flex-shrink-0 text-[10px] text-neutral-600">{suffix}</span>}
</div>
</div>
@@ -185,17 +192,23 @@ export function DetailField({
disabled?: boolean;
onCommit: (nextValue: string) => void;
}) {
const track = useTrackDesignInput();
const commit = (nextValue: string) => {
if (nextValue !== value) track("text", label);
onCommit(nextValue);
};
return (
<label className="grid min-w-0 gap-1.5">
<span className={LABEL}>{label}</span>
<div className={FIELD}>
<CommitField value={value} disabled={disabled} onCommit={onCommit} />
<CommitField value={value} disabled={disabled} onCommit={commit} />
</div>
</label>
);
}
export function SliderControl({
trackName,
value,
min,
max,
@@ -205,6 +218,7 @@ export function SliderControl({
disabled,
onCommit,
}: {
trackName: string;
value: number;
min: number;
max: number;
@@ -214,8 +228,10 @@ export function SliderControl({
disabled?: boolean;
onCommit: (nextValue: number) => void;
}) {
const track = useTrackDesignInput();
const [draft, setDraft] = useState(value);
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const interactionChangedRef = useRef(false);
const valueRef = useRef(value);
valueRef.current = value;
@@ -231,6 +247,10 @@ export function SliderControl({
const commitDraft = (nextDraft: number) => {
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
if (interactionChangedRef.current) {
interactionChangedRef.current = false;
track("slider", trackName);
}
if (nextDraft !== valueRef.current) onCommit(nextDraft);
};
const scheduleCommit = (nextDraft: number) => {
@@ -252,6 +272,7 @@ export function SliderControl({
onChange={(e) => {
const n = Number(e.target.value);
setDraft(n);
interactionChangedRef.current = true;
scheduleCommit(n);
}}
onMouseUp={() => commitDraft(draft)}
@@ -267,16 +288,19 @@ export function SliderControl({
}
export function SegmentedControl({
trackName,
options,
value,
disabled,
onChange,
}: {
trackName: string;
options: Array<{ label: string; value: string }>;
value: string;
disabled?: boolean;
onChange: (nextValue: string) => void;
}) {
const track = useTrackDesignInput();
return (
<div
className="grid min-w-0 gap-[2px] rounded-md bg-panel-input p-[2px]"
@@ -287,7 +311,10 @@ export function SegmentedControl({
key={option.value}
type="button"
disabled={disabled}
onClick={() => onChange(option.value)}
onClick={() => {
if (option.value !== value) track("segmented", trackName);
onChange(option.value);
}}
className={`min-w-0 truncate rounded px-2 py-[5px] text-[11px] font-medium transition-colors disabled:cursor-not-allowed ${
option.value === value
? "bg-panel-hover text-white"
@@ -314,6 +341,7 @@ export function SelectField({
options: string[];
onChange: (nextValue: string) => void;
}) {
const track = useTrackDesignInput();
const renderedOptions = value && !options.includes(value) ? [value, ...options] : options;
return (
<label className={`${FIELD} flex items-center gap-3`}>
@@ -321,7 +349,10 @@ export function SelectField({
<select
value={value}
disabled={disabled}
onChange={(e) => 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({
</svg>
);
const section = slugifyPanelSectionTitle(title);
return (
<section
className="min-w-0 border-t border-panel-border"
data-panel-section={slugifyPanelSectionTitle(title)}
>
<div className="flex w-full items-center gap-2 px-4 py-2.5">
<button
type="button"
onClick={() => setCollapsed((v) => !v)}
className="flex min-w-0 flex-1 items-center justify-between gap-2 text-left"
>
<h3 className="text-[12px] font-semibold text-panel-text-1">{title}</h3>
{collapseIcon}
</button>
{accessory && <div className="flex flex-shrink-0 items-center">{accessory}</div>}
</div>
{!collapsed && <div className="px-4 pb-3">{children}</div>}
</section>
<DesignPanelInputProvider section={section}>
<section className="min-w-0 border-t border-panel-border" data-panel-section={section}>
<div className="flex w-full items-center gap-2 px-4 py-2.5">
<button
type="button"
onClick={() => setCollapsed((v) => !v)}
className="flex min-w-0 flex-1 items-center justify-between gap-2 text-left"
>
<h3 className="text-[12px] font-semibold text-panel-text-1">{title}</h3>
{collapseIcon}
</button>
{accessory && <div className="flex flex-shrink-0 items-center">{accessory}</div>}
</div>
{!collapsed && <div className="px-4 pb-3">{children}</div>}
</section>
</DesignPanelInputProvider>
);
}
@@ -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<HTMLTextAreaElement | null>(null);
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | 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 (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 (interactionChangedRef.current) {
interactionChangedRef.current = false;
track("text", label);
}
onCommit(d);
}
}, 120);
};
@@ -112,6 +125,7 @@ export function TextAreaField({
};
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
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({
<select
value={value}
disabled={disabled}
onChange={(e) => onCommit(e.target.value)}
onChange={(e) => {
track("select", "Weight");
onCommit(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"
>
{displayOptions.map((o) => (
@@ -288,6 +306,7 @@ function TextFieldEditor({
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
onRemoveTextField: (fieldKey: string) => void;
}) {
const track = useTrackDesignInput();
return (
<div className="space-y-3">
<div className={showRemove ? "flex min-w-0 items-center justify-between gap-2" : "min-w-0"}>
@@ -300,7 +319,10 @@ function TextFieldEditor({
{showRemove && (
<button
type="button"
onClick={() => onRemoveTextField(field.key)}
onClick={() => {
track("button", "Remove text field");
onRemoveTextField(field.key);
}}
className="inline-flex h-7 flex-shrink-0 items-center rounded-lg border border-neutral-700 bg-neutral-950 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white"
>
Remove
@@ -398,6 +420,7 @@ export function TextSection({
* false so the legacy (non-flat) call site is unaffected. */
hideOwnHeading?: boolean;
}) {
const track = useTrackDesignInput();
const hasTextControls = isTextEditableSelection(element);
const [activeTextFieldKey, setActiveTextFieldKey] = useState<string | null>(
element.textFields[0]?.key ?? null,
@@ -446,6 +469,7 @@ export function TextSection({
<button
type="button"
onClick={() => {
track("button", "Add text field");
void Promise.resolve(onAddTextField(activeField.key)).then((nextKey) => {
if (nextKey) setActiveTextFieldKey(nextKey);
});
@@ -154,6 +154,7 @@ export function StyleSections({
<Section title="Flex" icon={<Layers size={15} />} defaultCollapsed>
<div className="space-y-4">
<SegmentedControl
trackName="Flex direction"
disabled={styleEditingDisabled}
value={styles["flex-direction"] || "row"}
onChange={(next) => onSetStyle("flex-direction", next)}
@@ -298,6 +299,7 @@ export function StyleSections({
<div className="grid min-w-0 gap-1.5">
<span className={LABEL}>Layer blur</span>
<SliderControl
trackName="Layer blur"
value={filterBlurValue}
min={0}
max={Math.max(40, Math.ceil(filterBlurValue))}
@@ -313,6 +315,7 @@ export function StyleSections({
<div className="grid min-w-0 gap-1.5">
<span className={LABEL}>Backdrop</span>
<SliderControl
trackName="Backdrop blur"
value={backdropBlurValue}
min={0}
max={Math.max(60, Math.ceil(backdropBlurValue))}
@@ -363,6 +366,7 @@ export function StyleSections({
<div className="grid min-w-0 gap-1.5">
<span className={LABEL}>Mask inset</span>
<SliderControl
trackName="Mask inset"
value={clipInsetValue}
min={0}
max={Math.max(120, Math.ceil(clipInsetValue))}
@@ -411,6 +415,7 @@ export function StyleSections({
<Section title="Transparency" icon={<Eye size={15} />} defaultCollapsed>
<div className="space-y-4">
<SliderControl
trackName="Opacity"
value={opacityValue}
min={0}
max={100}
@@ -433,6 +438,7 @@ export function StyleSections({
<Section title="Fill" icon={<Palette size={15} />}>
<div className="space-y-4">
<SegmentedControl
trackName="Fill type"
disabled={styleEditingDisabled}
value={preferredFillMode}
onChange={handleFillModeChange}
@@ -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 (
<button type="button" onClick={() => track(control, name)}>
fire
</button>
);
}
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(
<DesignPanelInputProvider ui="flat" section="style">
<FireButton control="metric" name="Opacity" />
</DesignPanelInputProvider>,
);
expect(trackDesignInput).toHaveBeenCalledWith({
ui: "flat",
section: "style",
control: "metric",
name: "Opacity",
});
});
it("nested provider overrides section but inherits ui from parent", () => {
renderAndClick(
<DesignPanelInputProvider ui="flat" section="outer">
<DesignPanelInputProvider section="color-grading">
<FireButton control="slider" name="Exposure" />
</DesignPanelInputProvider>
</DesignPanelInputProvider>,
);
expect(trackDesignInput).toHaveBeenCalledWith({
ui: "flat",
section: "color-grading",
control: "slider",
name: "Exposure",
});
});
it("defaults to classic/unknown with no provider", () => {
renderAndClick(<FireButton control="button" name="Reset" />);
expect(trackDesignInput).toHaveBeenCalledWith({
ui: "classic",
section: "unknown",
control: "button",
name: "Reset",
});
});
});
@@ -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<DesignPanelInputContextValue>({
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 (
<DesignPanelInputContext.Provider value={value}>{children}</DesignPanelInputContext.Provider>
);
}
/**
* 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],
);
}
@@ -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("");
});
});
@@ -0,0 +1,80 @@
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).
//
// Fire convention (kept consistent across both UIs):
// - Discrete controls (metric/text/select/segmented/toggle/color/button) fire only
// when a real committed value change happens (the commit site guards on
// next !== current).
// - Continuous controls (sliders, scrub) fire once per user interaction that
// produced commits, even if the net value ends unchanged — net-change-at-settle
// is unreliable for them because mid-drag commits have already advanced the value,
// so "did the user work this control" is the honest signal. The coalescing window
// below collapses the many mid-drag commits into that single event.
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<string, number>();
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.
}
}