@hyperframes/studio 0.6.95 → 0.6.97

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/assets/hyperframes-player-Daj5djxa.js +418 -0
  2. package/dist/assets/index-B0twsRu0.css +1 -0
  3. package/dist/assets/index-Cfye9xzo.js +251 -0
  4. package/dist/assets/{index-CAANLw9Q.js → index-HveJ0MuV.js} +1 -1
  5. package/dist/index.html +2 -2
  6. package/package.json +4 -4
  7. package/src/App.tsx +10 -5
  8. package/src/components/SaveQueuePausedBanner.tsx +23 -0
  9. package/src/components/StudioPreviewArea.tsx +7 -0
  10. package/src/components/StudioRightPanel.tsx +1 -38
  11. package/src/components/editor/DomEditOverlay.test.ts +169 -29
  12. package/src/components/editor/DomEditOverlay.tsx +13 -23
  13. package/src/components/editor/GestureRecordControl.tsx +98 -0
  14. package/src/components/editor/PropertyPanel.tsx +22 -38
  15. package/src/components/editor/domEditing.test.ts +84 -0
  16. package/src/components/editor/domEditingLayers.ts +19 -0
  17. package/src/components/editor/domEditingRootLayer.ts +64 -0
  18. package/src/components/editor/manualEditingAvailability.test.ts +1 -2
  19. package/src/components/editor/manualEditingAvailability.ts +0 -7
  20. package/src/contexts/DomEditContext.tsx +1 -6
  21. package/src/hooks/gsapScriptCommitHelpers.ts +128 -0
  22. package/src/hooks/useDomEditCommits.ts +97 -123
  23. package/src/hooks/useDomEditPositionPatchCommit.ts +53 -0
  24. package/src/hooks/useDomEditSession.ts +59 -65
  25. package/src/hooks/useFileManager.ts +19 -5
  26. package/src/hooks/useGsapAnimationFetchFallback.ts +19 -0
  27. package/src/hooks/useGsapInteractionFailureTelemetry.ts +25 -0
  28. package/src/hooks/useGsapScriptCommits.ts +152 -140
  29. package/src/hooks/useGsapSelectionHandlers.ts +38 -8
  30. package/src/hooks/usePreviewPersistence.ts +90 -51
  31. package/src/hooks/useSafeGsapCommitMutation.ts +66 -0
  32. package/src/hooks/useStudioContextValue.ts +3 -19
  33. package/src/player/hooks/useTimelinePlayer.ts +25 -28
  34. package/src/player/lib/playbackAdapter.test.ts +86 -1
  35. package/src/player/lib/playbackAdapter.ts +62 -0
  36. package/src/utils/domEditSaveQueue.test.ts +117 -0
  37. package/src/utils/domEditSaveQueue.ts +87 -0
  38. package/src/utils/studioHelpers.ts +1 -1
  39. package/src/utils/studioSaveDiagnostics.test.ts +127 -0
  40. package/src/utils/studioSaveDiagnostics.ts +200 -0
  41. package/src/utils/studioUrlState.test.ts +0 -1
  42. package/src/utils/studioUrlState.ts +2 -8
  43. package/dist/assets/hyperframes-player-0esDKGRk.js +0 -418
  44. package/dist/assets/index-DujOjou6.js +0 -251
  45. package/dist/assets/index-rm9tn9nH.css +0 -1
  46. package/src/components/editor/EaseCurveEditor.tsx +0 -221
  47. package/src/components/editor/MotionPanel.tsx +0 -277
  48. package/src/components/editor/MotionPanelFields.tsx +0 -185
  49. package/src/components/editor/MotionPathOverlay.tsx +0 -146
  50. package/src/components/editor/SpringEaseEditor.tsx +0 -256
@@ -1,185 +0,0 @@
1
- import { useState, useRef, useEffect, type ReactNode } from "react";
2
- import { Zap } from "../../icons/SystemIcons";
3
-
4
- const FIELD =
5
- "min-w-0 rounded-xl border border-neutral-800 bg-neutral-900/95 px-3 py-2 text-neutral-100 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)] transition-colors focus-within:border-neutral-600";
6
- export const LABEL = "text-[11px] font-medium uppercase tracking-[0.18em] text-neutral-500";
7
- export const RESPONSIVE_GRID = "grid grid-cols-[repeat(auto-fit,minmax(118px,1fr))] gap-3";
8
-
9
- export function formatNumericValue(value: number): string {
10
- const rounded = Math.round(value * 100) / 100;
11
- return Number.isInteger(rounded)
12
- ? `${rounded}`
13
- : rounded.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
14
- }
15
-
16
- export function clampMotionNumber(
17
- value: number | null,
18
- min: number,
19
- max: number,
20
- fallback: number,
21
- ): number {
22
- if (value == null || !Number.isFinite(value)) return fallback;
23
- return Math.min(max, Math.max(min, value));
24
- }
25
-
26
- export function parsePlainNumber(value: string): number | null {
27
- const parsed = Number.parseFloat(value.trim());
28
- return Number.isFinite(parsed) ? parsed : null;
29
- }
30
-
31
- // ── CommitField ──
32
-
33
- function CommitField({
34
- value,
35
- disabled,
36
- onCommit,
37
- }: {
38
- value: string;
39
- disabled?: boolean;
40
- onCommit: (nextValue: string) => void;
41
- }) {
42
- const [draft, setDraft] = useState(value);
43
- const focusedRef = useRef(false);
44
-
45
- useEffect(() => {
46
- if (!focusedRef.current) setDraft(value);
47
- }, [value]);
48
-
49
- const commitDraft = () => {
50
- focusedRef.current = false;
51
- const next = draft.trim();
52
- if (next !== value) onCommit(next);
53
- };
54
-
55
- return (
56
- <input
57
- type="text"
58
- value={draft}
59
- disabled={disabled}
60
- onFocus={() => {
61
- focusedRef.current = true;
62
- }}
63
- onChange={(event) => setDraft(event.target.value)}
64
- onBlur={commitDraft}
65
- onKeyDown={(event) => {
66
- if (event.key === "Enter") (event.target as HTMLInputElement).blur();
67
- }}
68
- className="w-full min-w-0 bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600"
69
- />
70
- );
71
- }
72
-
73
- // ── DetailField ──
74
-
75
- export function DetailField({
76
- label,
77
- value,
78
- disabled,
79
- onCommit,
80
- }: {
81
- label: string;
82
- value: string;
83
- disabled?: boolean;
84
- onCommit: (nextValue: string) => void;
85
- }) {
86
- return (
87
- <label className="grid min-w-0 gap-1.5">
88
- <span className={LABEL}>{label}</span>
89
- <div className={FIELD}>
90
- <CommitField value={value} disabled={disabled} onCommit={onCommit} />
91
- </div>
92
- </label>
93
- );
94
- }
95
-
96
- // ── SegmentedControl ──
97
-
98
- export function SegmentedControl({
99
- value,
100
- options,
101
- onChange,
102
- }: {
103
- value: string;
104
- options: Array<{ label: string; value: string }>;
105
- onChange: (value: string) => void;
106
- }) {
107
- return (
108
- <div className="grid grid-cols-3 gap-1 rounded-2xl border border-neutral-800 bg-neutral-950 p-1">
109
- {options.map((option) => (
110
- <button
111
- key={option.value}
112
- type="button"
113
- onClick={() => onChange(option.value)}
114
- className={`h-9 rounded-xl text-[11px] font-semibold transition-colors ${
115
- option.value === value
116
- ? "bg-neutral-800 text-white shadow-sm"
117
- : "text-neutral-500 hover:bg-neutral-900 hover:text-neutral-200"
118
- }`}
119
- >
120
- {option.label}
121
- </button>
122
- ))}
123
- </div>
124
- );
125
- }
126
-
127
- // ── SelectField ──
128
-
129
- export function SelectField({
130
- label,
131
- value,
132
- options,
133
- onChange,
134
- }: {
135
- label: string;
136
- value: string;
137
- options: readonly string[];
138
- onChange: (value: string) => void;
139
- }) {
140
- return (
141
- <label className="grid min-w-0 gap-1.5">
142
- <span className={LABEL}>{label}</span>
143
- <div className={FIELD}>
144
- <select
145
- value={value}
146
- onChange={(event) => onChange(event.target.value)}
147
- className="w-full min-w-0 appearance-none bg-transparent text-[11px] font-medium text-neutral-100 outline-none"
148
- >
149
- {options.map((option) => (
150
- <option key={option} value={option}>
151
- {option}
152
- </option>
153
- ))}
154
- </select>
155
- </div>
156
- </label>
157
- );
158
- }
159
-
160
- // ── MotionSection ──
161
-
162
- export function MotionSection({
163
- title,
164
- children,
165
- accessory,
166
- }: {
167
- title: string;
168
- children: ReactNode;
169
- accessory?: ReactNode;
170
- }) {
171
- return (
172
- <section className="border-b border-neutral-800 px-4 py-5">
173
- <div className="mb-4 flex items-center justify-between gap-3">
174
- <div className="flex items-center gap-3">
175
- <Zap size={15} className="text-neutral-500" />
176
- <h3 className="text-[11px] font-semibold uppercase tracking-[0.22em] text-neutral-300">
177
- {title}
178
- </h3>
179
- </div>
180
- {accessory}
181
- </div>
182
- {children}
183
- </section>
184
- );
185
- }
@@ -1,146 +0,0 @@
1
- import { memo, useMemo, type RefObject } from "react";
2
- import type { ArcPathConfig } from "@hyperframes/core/gsap-parser";
3
-
4
- interface MotionPathOverlayProps {
5
- iframeRef: RefObject<HTMLIFrameElement | null>;
6
- arcPath: ArcPathConfig | null;
7
- waypoints: Array<{ x: number; y: number }> | null;
8
- elementBaseRect: { left: number; top: number; scaleX: number; scaleY: number } | null;
9
- }
10
-
11
- function buildSvgPath(
12
- waypoints: Array<{ x: number; y: number }>,
13
- segments: ArcPathConfig["segments"],
14
- base: { left: number; top: number; scaleX: number; scaleY: number },
15
- ): string {
16
- if (waypoints.length < 2) return "";
17
-
18
- const toPixel = (wp: { x: number; y: number }) => ({
19
- x: base.left + wp.x * base.scaleX,
20
- y: base.top + wp.y * base.scaleY,
21
- });
22
-
23
- const first = toPixel(waypoints[0]!);
24
- const parts = [`M ${first.x} ${first.y}`];
25
-
26
- for (let i = 0; i < segments.length && i < waypoints.length - 1; i++) {
27
- const seg = segments[i]!;
28
- const end = toPixel(waypoints[i + 1]!);
29
-
30
- if (seg.cp1 && seg.cp2) {
31
- const c1 = toPixel(seg.cp1);
32
- const c2 = toPixel(seg.cp2);
33
- parts.push(`C ${c1.x} ${c1.y} ${c2.x} ${c2.y} ${end.x} ${end.y}`);
34
- } else {
35
- const start = toPixel(waypoints[i]!);
36
- const dx = end.x - start.x;
37
- const dy = end.y - start.y;
38
- const c = seg.curviness ?? 1;
39
- const offset = c * Math.abs(dx) * 0.25;
40
- const c1x = start.x + dx * 0.33;
41
- const c1y = start.y + dy * 0.33 - offset;
42
- const c2x = start.x + dx * 0.66;
43
- const c2y = start.y + dy * 0.66 - offset;
44
- parts.push(`C ${c1x} ${c1y} ${c2x} ${c2y} ${end.x} ${end.y}`);
45
- }
46
- }
47
-
48
- return parts.join(" ");
49
- }
50
-
51
- export const MotionPathOverlay = memo(function MotionPathOverlay({
52
- arcPath,
53
- waypoints,
54
- elementBaseRect,
55
- }: MotionPathOverlayProps) {
56
- const pathD = useMemo(() => {
57
- if (!arcPath?.enabled || !waypoints || waypoints.length < 2 || !elementBaseRect) return "";
58
- return buildSvgPath(waypoints, arcPath.segments, elementBaseRect);
59
- }, [arcPath, waypoints, elementBaseRect]);
60
-
61
- const anchorPoints = useMemo(() => {
62
- if (!waypoints || !elementBaseRect) return [];
63
- return waypoints.map((wp) => ({
64
- x: elementBaseRect.left + wp.x * elementBaseRect.scaleX,
65
- y: elementBaseRect.top + wp.y * elementBaseRect.scaleY,
66
- }));
67
- }, [waypoints, elementBaseRect]);
68
-
69
- const controlPoints = useMemo(() => {
70
- if (!arcPath?.enabled || !elementBaseRect) return [];
71
- const points: Array<{
72
- segIndex: number;
73
- type: "cp1" | "cp2";
74
- x: number;
75
- y: number;
76
- anchorX: number;
77
- anchorY: number;
78
- }> = [];
79
- for (let i = 0; i < arcPath.segments.length; i++) {
80
- const seg = arcPath.segments[i]!;
81
- if (seg.cp1 && seg.cp2 && waypoints) {
82
- const anchor1 = waypoints[i]!;
83
- const anchor2 = waypoints[i + 1]!;
84
- points.push({
85
- segIndex: i,
86
- type: "cp1",
87
- x: elementBaseRect.left + seg.cp1.x * elementBaseRect.scaleX,
88
- y: elementBaseRect.top + seg.cp1.y * elementBaseRect.scaleY,
89
- anchorX: elementBaseRect.left + anchor1.x * elementBaseRect.scaleX,
90
- anchorY: elementBaseRect.top + anchor1.y * elementBaseRect.scaleY,
91
- });
92
- points.push({
93
- segIndex: i,
94
- type: "cp2",
95
- x: elementBaseRect.left + seg.cp2.x * elementBaseRect.scaleX,
96
- y: elementBaseRect.top + seg.cp2.y * elementBaseRect.scaleY,
97
- anchorX: elementBaseRect.left + anchor2.x * elementBaseRect.scaleX,
98
- anchorY: elementBaseRect.top + anchor2.y * elementBaseRect.scaleY,
99
- });
100
- }
101
- }
102
- return points;
103
- }, [arcPath, waypoints, elementBaseRect]);
104
-
105
- if (!pathD) return null;
106
-
107
- return (
108
- <svg className="absolute inset-0 pointer-events-none z-20 overflow-visible">
109
- <path d={pathD} fill="none" stroke="rgba(45, 212, 191, 0.4)" strokeWidth={2} />
110
-
111
- {controlPoints.map((cp) => (
112
- <g key={`${cp.segIndex}-${cp.type}`}>
113
- <line
114
- x1={cp.anchorX}
115
- y1={cp.anchorY}
116
- x2={cp.x}
117
- y2={cp.y}
118
- stroke="rgba(167, 139, 250, 0.3)"
119
- strokeWidth={1}
120
- strokeDasharray="3 2"
121
- />
122
- <circle
123
- cx={cp.x}
124
- cy={cp.y}
125
- r={4}
126
- fill="#a78bfa"
127
- className="pointer-events-auto cursor-grab"
128
- />
129
- </g>
130
- ))}
131
-
132
- {anchorPoints.map((pt, i) => (
133
- <circle
134
- key={i}
135
- cx={pt.x}
136
- cy={pt.y}
137
- r={5}
138
- fill="#3CE6AC"
139
- stroke="rgba(255,255,255,0.5)"
140
- strokeWidth={1}
141
- className="pointer-events-auto cursor-pointer"
142
- />
143
- ))}
144
- </svg>
145
- );
146
- });
@@ -1,256 +0,0 @@
1
- import { useState, useRef, useEffect, useCallback } from "react";
2
- import { generateSpringEaseData, SPRING_PRESETS } from "@hyperframes/core/spring-ease";
3
- import { LABEL } from "./MotionPanelFields";
4
- import { RotateCcw } from "../../icons/SystemIcons";
5
-
6
- interface SpringParams {
7
- mass: number;
8
- stiffness: number;
9
- damping: number;
10
- }
11
-
12
- const DEFAULT_SPRING: SpringParams = { mass: 1, stiffness: 180, damping: 12 };
13
-
14
- const SLIDERS: {
15
- key: keyof SpringParams;
16
- label: string;
17
- min: number;
18
- max: number;
19
- step: number;
20
- }[] = [
21
- { key: "mass", label: "Mass", min: 0.1, max: 5, step: 0.1 },
22
- { key: "stiffness", label: "Stiffness", min: 10, max: 500, step: 10 },
23
- { key: "damping", label: "Damping", min: 1, max: 50, step: 1 },
24
- ];
25
-
26
- function springValue(mass: number, stiffness: number, damping: number, t: number): number {
27
- const w0 = Math.sqrt(stiffness / mass);
28
- const zeta = damping / (2 * Math.sqrt(stiffness * mass));
29
- if (zeta < 1) {
30
- const wd = w0 * Math.sqrt(1 - zeta * zeta);
31
- return (
32
- 1 - Math.exp(-zeta * w0 * t) * (Math.cos(wd * t) + ((zeta * w0) / wd) * Math.sin(wd * t))
33
- );
34
- }
35
- if (zeta === 1) {
36
- return 1 - (1 + w0 * t) * Math.exp(-w0 * t);
37
- }
38
- const s1 = -w0 * (zeta - Math.sqrt(zeta * zeta - 1));
39
- const s2 = -w0 * (zeta + Math.sqrt(zeta * zeta - 1));
40
- return 1 + (s1 * Math.exp(s2 * t) - s2 * Math.exp(s1 * t)) / (s2 - s1);
41
- }
42
-
43
- function springSimDuration(mass: number, stiffness: number, damping: number): number {
44
- const w0 = Math.sqrt(stiffness / mass);
45
- const zeta = damping / (2 * Math.sqrt(stiffness * mass));
46
- if (zeta < 1) return Math.min(5 / (zeta * w0), 10);
47
- const decayRate = zeta * w0 - w0 * Math.sqrt(zeta * zeta - 1);
48
- return Math.min(4 / Math.max(decayRate, 0.01), 10);
49
- }
50
-
51
- function buildSpringPath(
52
- params: SpringParams,
53
- mapFn: (point: { x: number; y: number }) => { x: number; y: number },
54
- ): string {
55
- const steps = 64;
56
- const simDur = springSimDuration(params.mass, params.stiffness, params.damping);
57
- const commands: string[] = [];
58
- for (let i = 0; i <= steps; i++) {
59
- const t = i / steps;
60
- const simT = t * simDur;
61
- const y = springValue(params.mass, params.stiffness, params.damping, simT);
62
- const mapped = mapFn({ x: t, y });
63
- commands.push(`${i === 0 ? "M" : "L"}${mapped.x.toFixed(2)},${mapped.y.toFixed(2)}`);
64
- }
65
- return commands.join(" ");
66
- }
67
-
68
- export function SpringEaseEditor({
69
- onCommit,
70
- }: {
71
- onCommit: (easeId: string, easeData: string) => void;
72
- }) {
73
- const [params, setParams] = useState<SpringParams>(DEFAULT_SPRING);
74
- const commitTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
75
-
76
- const scheduleCommit = useCallback(
77
- (next: SpringParams) => {
78
- if (commitTimeoutRef.current) clearTimeout(commitTimeoutRef.current);
79
- commitTimeoutRef.current = setTimeout(() => {
80
- const data = generateSpringEaseData(next.mass, next.stiffness, next.damping);
81
- const id = `spring-m${next.mass}-k${next.stiffness}-d${next.damping}`;
82
- onCommit(id, data);
83
- }, 120);
84
- },
85
- [onCommit],
86
- );
87
-
88
- useEffect(() => {
89
- return () => {
90
- if (commitTimeoutRef.current) clearTimeout(commitTimeoutRef.current);
91
- };
92
- }, []);
93
-
94
- const updateParam = (key: keyof SpringParams, value: number) => {
95
- const next = { ...params, [key]: value };
96
- setParams(next);
97
- scheduleCommit(next);
98
- };
99
-
100
- const applyPreset = (preset: (typeof SPRING_PRESETS)[number]) => {
101
- const next: SpringParams = {
102
- mass: preset.mass,
103
- stiffness: preset.stiffness,
104
- damping: preset.damping,
105
- };
106
- setParams(next);
107
- const data = generateSpringEaseData(next.mass, next.stiffness, next.damping);
108
- onCommit(preset.name, data);
109
- };
110
-
111
- const reset = () => {
112
- setParams(DEFAULT_SPRING);
113
- const data = generateSpringEaseData(
114
- DEFAULT_SPRING.mass,
115
- DEFAULT_SPRING.stiffness,
116
- DEFAULT_SPRING.damping,
117
- );
118
- onCommit("spring-bouncy", data);
119
- };
120
-
121
- // SVG layout matching EaseCurveEditor proportions
122
- const width = 324;
123
- const height = 214;
124
- const plot = { left: 46, top: 24, width: 242, height: 146 };
125
- const yMin = -0.2;
126
- const yMax = 1.3;
127
-
128
- const mapPoint = (point: { x: number; y: number }) => ({
129
- x: plot.left + point.x * plot.width,
130
- y: plot.top + ((yMax - point.y) / (yMax - yMin)) * plot.height,
131
- });
132
-
133
- const curvePath = buildSpringPath(params, mapPoint);
134
- const start = mapPoint({ x: 0, y: 0 });
135
- const end = mapPoint({ x: 1, y: 1 });
136
-
137
- const activePreset = SPRING_PRESETS.find(
138
- (p) =>
139
- p.mass === params.mass && p.stiffness === params.stiffness && p.damping === params.damping,
140
- );
141
-
142
- return (
143
- <div className="overflow-hidden rounded-2xl border border-neutral-800 bg-black/40">
144
- <div className="flex items-center justify-between gap-3 border-b border-neutral-800 px-3 py-2">
145
- <div>
146
- <div className={LABEL}>Spring Ease</div>
147
- <div className="mt-1 font-mono text-[10px] text-neutral-500">
148
- {activePreset?.label ?? `m${params.mass} k${params.stiffness} d${params.damping}`}
149
- </div>
150
- </div>
151
- <button
152
- type="button"
153
- onClick={reset}
154
- className="inline-flex h-8 items-center justify-center gap-2 rounded-xl border border-neutral-800 bg-neutral-950 px-3 text-[10px] font-semibold uppercase tracking-[0.14em] text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-100"
155
- >
156
- <RotateCcw size={13} />
157
- Reset
158
- </button>
159
- </div>
160
-
161
- {/* Curve preview */}
162
- <svg viewBox={`0 0 ${width} ${height}`} className="block w-full select-none touch-none">
163
- <rect x="0" y="0" width={width} height={height} fill="transparent" />
164
- {[0, 0.5, 1].map((value) => {
165
- const mapped = mapPoint({ x: 0, y: value });
166
- return (
167
- <g key={value}>
168
- <line
169
- x1={plot.left}
170
- x2={plot.left + plot.width}
171
- y1={mapped.y}
172
- y2={mapped.y}
173
- stroke="rgba(255,255,255,0.12)"
174
- strokeDasharray="5 8"
175
- />
176
- <text
177
- x={plot.left - 12}
178
- y={mapped.y + 4}
179
- textAnchor="end"
180
- className="fill-neutral-500 text-[10px] font-semibold"
181
- >
182
- {value}
183
- </text>
184
- </g>
185
- );
186
- })}
187
- <line
188
- x1={plot.left}
189
- x2={plot.left + plot.width}
190
- y1={plot.top + plot.height}
191
- y2={plot.top + plot.height}
192
- stroke="rgba(255,255,255,0.18)"
193
- />
194
- <line
195
- x1={plot.left}
196
- x2={plot.left}
197
- y1={plot.top}
198
- y2={plot.top + plot.height}
199
- stroke="rgba(255,255,255,0.18)"
200
- />
201
- <path d={curvePath} fill="none" stroke="#ffdd57" strokeWidth="4" strokeLinecap="round" />
202
- <circle cx={start.x} cy={start.y} r="5" fill="#ffdd57" />
203
- <circle cx={end.x} cy={end.y} r="5" fill="#ffdd57" />
204
- </svg>
205
-
206
- {/* Presets */}
207
- <div className="flex gap-1.5 border-t border-neutral-800 px-3 py-2">
208
- {SPRING_PRESETS.map((preset) => {
209
- const isActive =
210
- preset.mass === params.mass &&
211
- preset.stiffness === params.stiffness &&
212
- preset.damping === params.damping;
213
- return (
214
- <button
215
- key={preset.name}
216
- type="button"
217
- onClick={() => applyPreset(preset)}
218
- className={`flex-1 rounded-lg px-1.5 py-1.5 text-[10px] font-semibold transition-colors ${
219
- isActive
220
- ? "border border-yellow-400/40 bg-yellow-400/10 text-yellow-300"
221
- : "border border-neutral-800 bg-neutral-950 text-neutral-500 hover:border-neutral-700 hover:text-neutral-300"
222
- }`}
223
- >
224
- {preset.label}
225
- </button>
226
- );
227
- })}
228
- </div>
229
-
230
- {/* Sliders */}
231
- <div className="space-y-3 border-t border-neutral-800 px-3 py-3">
232
- {SLIDERS.map((slider) => (
233
- <div key={slider.key}>
234
- <div className="mb-1 flex items-center justify-between">
235
- <span className="text-[10px] font-medium uppercase tracking-[0.14em] text-neutral-500">
236
- {slider.label}
237
- </span>
238
- <span className="min-w-[36px] text-right font-mono text-[10px] text-neutral-400">
239
- {params[slider.key]}
240
- </span>
241
- </div>
242
- <input
243
- type="range"
244
- min={slider.min}
245
- max={slider.max}
246
- step={slider.step}
247
- value={params[slider.key]}
248
- onChange={(e) => updateParam(slider.key, Number(e.target.value))}
249
- className="h-1 w-full cursor-pointer appearance-none rounded-full bg-neutral-800 accent-yellow-400 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-yellow-400"
250
- />
251
- </div>
252
- ))}
253
- </div>
254
- </div>
255
- );
256
- }