refactor(studio): split ease mode controls out of EaseCurveSection

EaseCurveSection.tsx was 635 lines against CI's 600-line cap, which has
been failing the File size check on main for four consecutive runs and
blocks release cuts.

Moves the two self-contained presentational pieces into a sibling
EaseModeControls.tsx, following the pattern the directory already uses
(easeCurveSvg, easePresetLibrary, EaseParamFields): the mode radio group
(EaseModeToggle) and the preset grid (EasePresetGrid), plus the mode
vocabulary they own — EASE_MODES, the EaseMode type, MODE_LABELS,
DEFAULT_EASE_BY_MODE, and the DEFAULT_CURVE/Pts pair those defaults are
built from. Both components are stateless: they take the current
selection and emit a committed ease string, so nothing had to be
rewired. Only the symbols the parent still references are exported —
EASE_MODES and DEFAULT_EASE_BY_MODE became file-internal, since the
components that consume them moved too.

No behaviour change. EaseCurveSection is now 556 lines, the new file 110.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-07-29 18:37:10 -07:00
co-authored by Claude Opus 5
parent 941167dd3c
commit e0b1909475
2 changed files with 119 additions and 90 deletions
@@ -5,7 +5,15 @@ import {
parseWiggleEase,
type WiggleEaseConfig,
} from "@hyperframes/core/wiggle-ease";
import { EASE_PRESETS, easePresetLabel } from "./easePresetLibrary";
import { easePresetLabel } from "./easePresetLibrary";
import {
DEFAULT_CURVE,
MODE_LABELS,
EaseModeToggle,
EasePresetGrid,
type EaseMode,
type Pts,
} from "./EaseModeControls";
import { holdCurvePath, MiniCurveSvg, sampledPath } from "./easeCurveSvg";
import { EaseBezierField, SpringBounceField, WiggleField } from "./EaseParamFields";
import { EASE_CURVES, EASE_LABELS, resolveEaseCurveTuple } from "./gsapAnimationConstants";
@@ -14,51 +22,6 @@ import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
export { MiniCurveSvg } from "./easeCurveSvg";
const EASE_MODES = ["curve", "spring", "wiggle"] as const;
type EaseMode = (typeof EASE_MODES)[number];
const EasePresetGrid = function EasePresetGrid({
kind,
currentEase,
onSelect,
}: {
kind: EaseMode;
currentEase: string;
onSelect: (ease: string) => void;
}) {
return (
<div className="mb-2 grid max-h-56 grid-cols-4 gap-1 overflow-y-auto pr-0.5">
{EASE_PRESETS.filter((preset) => preset.kind === kind).map((preset) => {
const isActive = currentEase === preset.ease;
return (
<button
key={preset.id}
type="button"
data-ease-preset-id={preset.id}
role="menuitemradio"
aria-checked={isActive}
tabIndex={isActive ? 0 : -1}
onClick={() => onSelect(preset.ease)}
className={`flex flex-col items-center gap-0.5 rounded-md p-1 transition-colors ${
isActive ? "bg-panel-accent/10 ring-1 ring-panel-accent/30" : "hover:bg-neutral-800"
}`}
title={preset.label}
>
<MiniCurveSvg ease={preset.ease} active={isActive} />
<span
className={`text-center text-[8px] leading-none ${
isActive ? "text-panel-accent" : "text-neutral-500"
}`}
>
{preset.label}
</span>
</button>
);
})}
</div>
);
};
const round2 = roundToCenti;
// ── Graph geometry (Figma-style easing box) ─────────────────────────────────
@@ -81,50 +44,6 @@ const DRAG_VMAX = 2;
const DRAG_VMIN = -1;
const ACCENT = "#3CE6AC";
type Pts = [number, number, number, number];
const DEFAULT_CURVE: Pts = EASE_CURVES["power2.out"];
const MODE_LABELS = { curve: "Curve", spring: "Spring", wiggle: "Wiggle" } satisfies Record<
EaseMode,
string
>;
const DEFAULT_EASE_BY_MODE = {
curve: `custom(M0,0 C${DEFAULT_CURVE[0]},${DEFAULT_CURVE[1]} ${DEFAULT_CURVE[2]},${DEFAULT_CURVE[3]} 1,1)`,
spring: "spring(0.42)",
wiggle: "wiggle(3,easeInOut,0.12)",
} satisfies Record<EaseMode, string>;
function EaseModeToggle({ mode, onCommit }: { mode: EaseMode; onCommit: (ease: string) => void }) {
return (
<div
className="mb-2 grid grid-cols-3 rounded-md bg-black/20 p-0.5"
role="radiogroup"
aria-label="Ease editor mode"
>
{EASE_MODES.map((candidateMode) => {
const active = candidateMode === mode;
return (
<button
key={candidateMode}
type="button"
data-ease-mode={candidateMode}
role="radio"
aria-checked={active}
onClick={() => {
if (active) return;
onCommit(DEFAULT_EASE_BY_MODE[candidateMode]);
}}
className={`rounded px-2 py-1 text-[10px] font-medium transition-colors ${
active ? "bg-neutral-700 text-neutral-100" : "text-neutral-500 hover:text-neutral-300"
}`}
>
{MODE_LABELS[candidateMode]}
</button>
);
})}
</div>
);
}
// Figma-style ease-type dropdown: the current ease (glyph + name) as a button
// that opens the preset grid in a popover. This is where a preset is selected —
// the grid is no longer shown inline.
@@ -0,0 +1,110 @@
/**
* Ease editor mode primitives: the mode radio group and the preset grid.
*
* Split out of `EaseCurveSection.tsx` to keep it under the 600-line cap
* (CI's file size check). Both components are presentational and stateless —
* they take the current selection and emit a committed ease string — so they
* carry the mode vocabulary (`EASE_MODES`, labels, per-mode defaults) with
* them rather than importing it back from the parent.
*/
import { EASE_PRESETS } from "./easePresetLibrary";
import { MiniCurveSvg } from "./easeCurveSvg";
import { EASE_CURVES } from "./gsapAnimationConstants";
const EASE_MODES = ["curve", "spring", "wiggle"] as const;
export type EaseMode = (typeof EASE_MODES)[number];
export type Pts = [number, number, number, number];
export const DEFAULT_CURVE: Pts = EASE_CURVES["power2.out"];
export const MODE_LABELS = { curve: "Curve", spring: "Spring", wiggle: "Wiggle" } satisfies Record<
EaseMode,
string
>;
const DEFAULT_EASE_BY_MODE = {
curve: `custom(M0,0 C${DEFAULT_CURVE[0]},${DEFAULT_CURVE[1]} ${DEFAULT_CURVE[2]},${DEFAULT_CURVE[3]} 1,1)`,
spring: "spring(0.42)",
wiggle: "wiggle(3,easeInOut,0.12)",
} satisfies Record<EaseMode, string>;
export const EasePresetGrid = function EasePresetGrid({
kind,
currentEase,
onSelect,
}: {
kind: EaseMode;
currentEase: string;
onSelect: (ease: string) => void;
}) {
return (
<div className="mb-2 grid max-h-56 grid-cols-4 gap-1 overflow-y-auto pr-0.5">
{EASE_PRESETS.filter((preset) => preset.kind === kind).map((preset) => {
const isActive = currentEase === preset.ease;
return (
<button
key={preset.id}
type="button"
data-ease-preset-id={preset.id}
role="menuitemradio"
aria-checked={isActive}
tabIndex={isActive ? 0 : -1}
onClick={() => onSelect(preset.ease)}
className={`flex flex-col items-center gap-0.5 rounded-md p-1 transition-colors ${
isActive ? "bg-panel-accent/10 ring-1 ring-panel-accent/30" : "hover:bg-neutral-800"
}`}
title={preset.label}
>
<MiniCurveSvg ease={preset.ease} active={isActive} />
<span
className={`text-center text-[8px] leading-none ${
isActive ? "text-panel-accent" : "text-neutral-500"
}`}
>
{preset.label}
</span>
</button>
);
})}
</div>
);
};
export function EaseModeToggle({
mode,
onCommit,
}: {
mode: EaseMode;
onCommit: (ease: string) => void;
}) {
return (
<div
className="mb-2 grid grid-cols-3 rounded-md bg-black/20 p-0.5"
role="radiogroup"
aria-label="Ease editor mode"
>
{EASE_MODES.map((candidateMode) => {
const active = candidateMode === mode;
return (
<button
key={candidateMode}
type="button"
data-ease-mode={candidateMode}
role="radio"
aria-checked={active}
onClick={() => {
if (active) return;
onCommit(DEFAULT_EASE_BY_MODE[candidateMode]);
}}
className={`rounded px-2 py-1 text-[10px] font-medium transition-colors ${
active ? "bg-neutral-700 text-neutral-100" : "text-neutral-500 hover:text-neutral-300"
}`}
>
{MODE_LABELS[candidateMode]}
</button>
);
})}
</div>
);
}