@morphika/andami 0.1.8 → 0.1.9

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.
@@ -0,0 +1,370 @@
1
+ /**
2
+ * useDragReorder — Custom hook encapsulating all drag-to-reorder logic
3
+ * for the ProjectGrid masonry preview.
4
+ *
5
+ * Manages: drag state machine (grabbed → dragging → swap/cancel),
6
+ * hold-to-drag timer, global pointer listeners, cancel fly-back animation,
7
+ * coordinate hit-testing, and project array swap.
8
+ *
9
+ * Session 162: Extracted from LiveProjectGridPreview.tsx (Phase B4).
10
+ */
11
+
12
+ import { useState, useCallback, useRef, useEffect } from "react";
13
+ import { useBuilderStore } from "../../../lib/builder/store";
14
+ import type { MasonryOutput } from "../../../lib/builder/masonry";
15
+ import type { ProjectGridBlock } from "../../../lib/sanity/types";
16
+ import {
17
+ HOLD_DELAY,
18
+ MOVE_THRESHOLD_SQ,
19
+ CANCEL_DURATION,
20
+ type DragState,
21
+ hitTestCards,
22
+ } from "./drag-utils";
23
+
24
+ // ─── Hook Options & Return Types ─────────────────────────────────────
25
+
26
+ export interface UseDragReorderOptions {
27
+ block: ProjectGridBlock;
28
+ containerRef: React.RefObject<HTMLDivElement | null>;
29
+ containerWidth: number;
30
+ masonry: MasonryOutput;
31
+ }
32
+
33
+ export interface UseDragReorderResult {
34
+ dragState: DragState | null;
35
+ handleDragStart: (
36
+ key: string,
37
+ e: React.PointerEvent,
38
+ cardEl: HTMLDivElement,
39
+ fromHandle: boolean,
40
+ ) => void;
41
+ handleSelect: (key: string) => void;
42
+ isAnyDragActive: boolean;
43
+ draggedKey: string | null;
44
+ hoverTargetKey: string | null;
45
+ dragPhase: DragState["phase"] | null;
46
+ draggedItem: NonNullable<ProjectGridBlock["projects"]>[number] | null;
47
+ }
48
+
49
+ // ─── Hook ────────────────────────────────────────────────────────────
50
+
51
+ export function useDragReorder({
52
+ block,
53
+ containerRef,
54
+ containerWidth,
55
+ masonry,
56
+ }: UseDragReorderOptions): UseDragReorderResult {
57
+ const updateBlock = useBuilderStore((s) => s.updateBlock);
58
+ const _pushSnapshot = useBuilderStore((s) => s._pushSnapshot);
59
+ const selectProjectCard = useBuilderStore((s) => s.selectProjectCard);
60
+ const selectedProjectCardKey = useBuilderStore(
61
+ (s) => s.selectedProjectCardKey,
62
+ );
63
+
64
+ const [dragState, setDragState] = useState<DragState | null>(null);
65
+
66
+ // ── Stable refs for event handlers ────────────────────────────
67
+
68
+ const blockRef = useRef(block);
69
+ blockRef.current = block;
70
+ const updateBlockRef = useRef(updateBlock);
71
+ updateBlockRef.current = updateBlock;
72
+ const pushSnapshotRef = useRef(_pushSnapshot);
73
+ pushSnapshotRef.current = _pushSnapshot;
74
+ const containerWidthRef = useRef(containerWidth);
75
+ containerWidthRef.current = containerWidth;
76
+ const dragStateRef = useRef(dragState);
77
+ dragStateRef.current = dragState;
78
+ const masonryRef = useRef(masonry);
79
+ masonryRef.current = masonry;
80
+
81
+ // Timer & cleanup refs
82
+ const holdTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
83
+ const holdCleanupRef = useRef<(() => void) | null>(null);
84
+ const cancelTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
85
+ const cancelRafRef = useRef<number | null>(null);
86
+ const didDragRef = useRef(false);
87
+
88
+ // ── Drag: initiate ────────────────────────────────────────────
89
+
90
+ const initiateDrag = useCallback(
91
+ (
92
+ key: string,
93
+ clientX: number,
94
+ clientY: number,
95
+ cardEl: HTMLDivElement,
96
+ ) => {
97
+ // Clean up any pending hold
98
+ if (holdCleanupRef.current) {
99
+ holdCleanupRef.current();
100
+ holdCleanupRef.current = null;
101
+ }
102
+ // Clean up any cancel animation in progress
103
+ if (cancelTimerRef.current) {
104
+ clearTimeout(cancelTimerRef.current);
105
+ cancelTimerRef.current = null;
106
+ }
107
+ if (cancelRafRef.current) {
108
+ cancelAnimationFrame(cancelRafRef.current);
109
+ cancelRafRef.current = null;
110
+ }
111
+
112
+ const rect = cardEl.getBoundingClientRect();
113
+ didDragRef.current = true;
114
+ setDragState({
115
+ phase: "grabbed",
116
+ draggedKey: key,
117
+ hoverTargetKey: null,
118
+ mouseX: clientX,
119
+ mouseY: clientY,
120
+ startMouseX: clientX,
121
+ startMouseY: clientY,
122
+ offsetX: clientX - rect.left,
123
+ offsetY: clientY - rect.top,
124
+ cardWidth: rect.width,
125
+ cardHeight: rect.height,
126
+ origScreenX: rect.left,
127
+ origScreenY: rect.top,
128
+ });
129
+ },
130
+ [],
131
+ );
132
+
133
+ const handleDragStart = useCallback(
134
+ (
135
+ key: string,
136
+ e: React.PointerEvent,
137
+ cardEl: HTMLDivElement,
138
+ fromHandle: boolean,
139
+ ) => {
140
+ if (fromHandle) {
141
+ e.preventDefault();
142
+ initiateDrag(key, e.clientX, e.clientY, cardEl);
143
+ return;
144
+ }
145
+
146
+ // Card body → hold-to-drag
147
+ const cx = e.clientX;
148
+ const cy = e.clientY;
149
+ didDragRef.current = false;
150
+
151
+ // Clean any previous pending hold
152
+ if (holdCleanupRef.current) holdCleanupRef.current();
153
+
154
+ const cleanup = () => {
155
+ if (holdTimerRef.current) {
156
+ clearTimeout(holdTimerRef.current);
157
+ holdTimerRef.current = null;
158
+ }
159
+ window.removeEventListener("pointerup", onEarlyRelease);
160
+ window.removeEventListener("pointermove", onEarlyMove);
161
+ holdCleanupRef.current = null;
162
+ };
163
+
164
+ const onEarlyRelease = () => cleanup();
165
+
166
+ const onEarlyMove = (ev: PointerEvent) => {
167
+ // If user moved > 10px before hold time, cancel (not a drag intent)
168
+ const dx = ev.clientX - cx;
169
+ const dy = ev.clientY - cy;
170
+ if (dx * dx + dy * dy > 100) cleanup();
171
+ };
172
+
173
+ holdTimerRef.current = setTimeout(() => {
174
+ holdTimerRef.current = null;
175
+ window.removeEventListener("pointerup", onEarlyRelease);
176
+ window.removeEventListener("pointermove", onEarlyMove);
177
+ holdCleanupRef.current = null;
178
+ initiateDrag(key, cx, cy, cardEl);
179
+ }, HOLD_DELAY);
180
+
181
+ window.addEventListener("pointerup", onEarlyRelease);
182
+ window.addEventListener("pointermove", onEarlyMove);
183
+ holdCleanupRef.current = cleanup;
184
+ },
185
+ [initiateDrag],
186
+ );
187
+
188
+ // ── Drag: click handler (selection) ───────────────────────────
189
+
190
+ const handleSelect = useCallback(
191
+ (key: string) => {
192
+ // Suppress click that follows a completed drag
193
+ if (didDragRef.current) {
194
+ didDragRef.current = false;
195
+ return;
196
+ }
197
+ selectProjectCard(selectedProjectCardKey === key ? null : key);
198
+ },
199
+ [selectProjectCard, selectedProjectCardKey],
200
+ );
201
+
202
+ // ── Global pointer handlers during drag ───────────────────────
203
+
204
+ const hasDrag = dragState !== null;
205
+
206
+ useEffect(() => {
207
+ if (!hasDrag) return;
208
+
209
+ const onMove = (e: PointerEvent) => {
210
+ setDragState((prev) => {
211
+ if (!prev || prev.phase === "cancelling") return prev;
212
+
213
+ const next: DragState = {
214
+ ...prev,
215
+ mouseX: e.clientX,
216
+ mouseY: e.clientY,
217
+ };
218
+
219
+ // Transition: grabbed → dragging on sufficient movement
220
+ if (prev.phase === "grabbed") {
221
+ const dx = e.clientX - prev.startMouseX;
222
+ const dy = e.clientY - prev.startMouseY;
223
+ if (dx * dx + dy * dy > MOVE_THRESHOLD_SQ) {
224
+ next.phase = "dragging";
225
+ }
226
+ }
227
+
228
+ // Coordinate-based hit-testing for drop targets
229
+ if (next.phase === "dragging" && containerRef.current) {
230
+ next.hoverTargetKey = hitTestCards(
231
+ e.clientX,
232
+ e.clientY,
233
+ containerRef.current,
234
+ containerWidthRef.current,
235
+ masonryRef.current,
236
+ prev.draggedKey,
237
+ );
238
+ }
239
+
240
+ return next;
241
+ });
242
+ };
243
+
244
+ const onUp = () => {
245
+ const prev = dragStateRef.current;
246
+ if (!prev) {
247
+ setDragState(null);
248
+ return;
249
+ }
250
+
251
+ // Grabbed with no movement → cancel (no visual action, allow click through)
252
+ if (prev.phase === "grabbed") {
253
+ setDragState(null);
254
+ setTimeout(() => {
255
+ didDragRef.current = false;
256
+ }, 0);
257
+ return;
258
+ }
259
+
260
+ // Dragging with a valid drop target → swap
261
+ if (prev.phase === "dragging" && prev.hoverTargetKey) {
262
+ const blk = blockRef.current;
263
+ const arr = [...(blk.projects || [])];
264
+ const fromIdx = arr.findIndex((p) => p._key === prev.draggedKey);
265
+ const toIdx = arr.findIndex((p) => p._key === prev.hoverTargetKey);
266
+ if (fromIdx !== -1 && toIdx !== -1 && fromIdx !== toIdx) {
267
+ pushSnapshotRef.current();
268
+ [arr[fromIdx], arr[toIdx]] = [arr[toIdx], arr[fromIdx]];
269
+ updateBlockRef.current(blk._key, {
270
+ projects: arr,
271
+ } as Partial<ProjectGridBlock>);
272
+ }
273
+ setDragState(null);
274
+ setTimeout(() => {
275
+ didDragRef.current = false;
276
+ }, 0);
277
+ return;
278
+ }
279
+
280
+ // Dragging with no target → cancel with fly-back animation
281
+ if (prev.phase === "dragging") {
282
+ setDragState({ ...prev, phase: "cancelling", hoverTargetKey: null });
283
+ setTimeout(() => {
284
+ didDragRef.current = false;
285
+ }, 0);
286
+ return;
287
+ }
288
+
289
+ // Fallback: clear
290
+ setDragState(null);
291
+ };
292
+
293
+ window.addEventListener("pointermove", onMove);
294
+ window.addEventListener("pointerup", onUp);
295
+ return () => {
296
+ window.removeEventListener("pointermove", onMove);
297
+ window.removeEventListener("pointerup", onUp);
298
+ };
299
+ }, [hasDrag]);
300
+
301
+ // ── Cancel animation: fly ghost back to original position ─────
302
+
303
+ useEffect(() => {
304
+ if (dragState?.phase !== "cancelling") return;
305
+
306
+ // Double rAF ensures the transition CSS is painted before position change
307
+ cancelRafRef.current = requestAnimationFrame(() => {
308
+ cancelRafRef.current = requestAnimationFrame(() => {
309
+ setDragState((prev) => {
310
+ if (prev?.phase !== "cancelling") return prev;
311
+ return {
312
+ ...prev,
313
+ mouseX: prev.origScreenX + prev.offsetX,
314
+ mouseY: prev.origScreenY + prev.offsetY,
315
+ };
316
+ });
317
+ cancelRafRef.current = null;
318
+ });
319
+ });
320
+
321
+ // Clear state after transition completes
322
+ cancelTimerRef.current = setTimeout(() => {
323
+ setDragState(null);
324
+ cancelTimerRef.current = null;
325
+ }, CANCEL_DURATION + 50);
326
+
327
+ return () => {
328
+ if (cancelRafRef.current) cancelAnimationFrame(cancelRafRef.current);
329
+ if (cancelTimerRef.current) {
330
+ clearTimeout(cancelTimerRef.current);
331
+ cancelTimerRef.current = null;
332
+ }
333
+ };
334
+ }, [dragState?.phase]);
335
+
336
+ // ── Cleanup on unmount ────────────────────────────────────────
337
+
338
+ useEffect(
339
+ () => () => {
340
+ if (holdTimerRef.current) clearTimeout(holdTimerRef.current);
341
+ if (holdCleanupRef.current) holdCleanupRef.current();
342
+ if (cancelTimerRef.current) clearTimeout(cancelTimerRef.current);
343
+ if (cancelRafRef.current) cancelAnimationFrame(cancelRafRef.current);
344
+ },
345
+ [],
346
+ );
347
+
348
+ // ── Derived drag values ───────────────────────────────────────
349
+
350
+ const projects = block.projects || [];
351
+ const draggedKey = dragState?.draggedKey ?? null;
352
+ const hoverTargetKey =
353
+ dragState?.phase === "dragging" ? dragState.hoverTargetKey : null;
354
+ const dragPhase = dragState?.phase ?? null;
355
+ const isAnyDragActive = dragState !== null;
356
+ const draggedItem = dragState
357
+ ? projects.find((p) => p._key === dragState.draggedKey) ?? null
358
+ : null;
359
+
360
+ return {
361
+ dragState,
362
+ handleDragStart,
363
+ handleSelect,
364
+ isAnyDragActive,
365
+ draggedKey,
366
+ hoverTargetKey,
367
+ dragPhase,
368
+ draggedItem,
369
+ };
370
+ }
@@ -0,0 +1,152 @@
1
+ "use client";
2
+
3
+ /**
4
+ * AnimationTab — Routes to EnterAnimationPicker / HoverEffectPicker based on selection.
5
+ *
6
+ * Extracted from SettingsPanel.tsx in Session C (refactor split).
7
+ */
8
+
9
+ import { useBuilderStore } from "../../../lib/builder/store";
10
+ import type { ContentBlock, PageSection, ProjectGridBlock } from "../../../lib/sanity/types";
11
+ import type { HoverEffectConfig } from "../../../lib/animation/hover-effect-types";
12
+ import EnterAnimationPicker from "../editors/EnterAnimationPicker";
13
+ import HoverEffectPicker from "../editors/HoverEffectPicker";
14
+ import {
15
+ getBlockAnimationValue,
16
+ hasBlockAnimationOverride,
17
+ setBlockAnimationOverride,
18
+ } from "./responsive-helpers";
19
+ import { CardEntranceSection } from "./CardEntranceSection";
20
+
21
+ /** Safely extract hover_effect (new unified type) from any content block.
22
+ * ProjectGridBlock has a legacy `hover_effect: "3d" | "scale" | "none"` string field
23
+ * that collides — skip it (returns undefined). Other blocks have HoverEffectConfig. */
24
+ export function getBlockHoverEffect(block: ContentBlock): HoverEffectConfig | undefined {
25
+ // ProjectGridBlock hover_effect is the old per-card string — not HoverEffectConfig
26
+ if (block._type === "projectGridBlock") return undefined;
27
+ const val = (block as unknown as Record<string, unknown>).hover_effect;
28
+ if (val === undefined || val === null) return undefined;
29
+ if (typeof val === "object") return val as HoverEffectConfig;
30
+ return undefined;
31
+ }
32
+
33
+ interface AnimationTabProps {
34
+ selectedBlock: { block: ContentBlock; rowKey: string; colKey: string; isSection: boolean } | null;
35
+ selectedSection: PageSection | null;
36
+ }
37
+
38
+ export function AnimationTab({ selectedBlock, selectedSection }: AnimationTabProps) {
39
+ const store = useBuilderStore();
40
+
41
+ // PageSection (V1): enter animation on the section settings level
42
+ if (selectedSection) {
43
+ return (
44
+ <EnterAnimationPicker
45
+ mode={{ level: "section", parentConfig: store.pageSettings.enter_animation }}
46
+ config={selectedSection.settings?.enter_animation}
47
+ onChange={(cfg) => {
48
+ store.updateSectionSettings(selectedSection._key, { enter_animation: cfg });
49
+ }}
50
+ />
51
+ );
52
+ }
53
+
54
+ // Block level: type-specific enter picker + card entrance for projectGrid
55
+ if (selectedBlock) {
56
+ const isProjectGrid = selectedBlock.block._type === "projectGridBlock";
57
+ const pgBlock = isProjectGrid ? (selectedBlock.block as ProjectGridBlock) : null;
58
+ const bvp = store.activeViewport;
59
+ const isBlockResponsive = bvp !== "desktop";
60
+
61
+ const effectiveEnterAnim = getBlockAnimationValue(
62
+ selectedBlock.block, bvp, "enter_animation", undefined
63
+ );
64
+ const effectiveHoverEffect = getBlockAnimationValue(
65
+ selectedBlock.block, bvp, "hover_effect", undefined
66
+ ) as HoverEffectConfig | undefined;
67
+
68
+ const hasEnterOverride = hasBlockAnimationOverride(selectedBlock.block, bvp, "enter_animation");
69
+ const hasHoverOverride = hasBlockAnimationOverride(selectedBlock.block, bvp, "hover_effect");
70
+
71
+ return (
72
+ <>
73
+ {isBlockResponsive && (
74
+ <div className="px-4 pt-3">
75
+ <div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[#076bff]/8 border border-[#076bff]/15">
76
+ <span className="text-[11px] font-medium text-[#076bff]">
77
+ Editing {bvp === "tablet" ? "Tablet" : "Phone"} overrides
78
+ </span>
79
+ </div>
80
+ </div>
81
+ )}
82
+ <div className="relative">
83
+ {hasEnterOverride && (
84
+ <div className="flex items-center justify-between px-4 pt-2">
85
+ <span className="text-[9px] text-[#076bff] font-medium">overridden</span>
86
+ <button
87
+ onClick={() => {
88
+ const updates = setBlockAnimationOverride(selectedBlock.block, bvp, "enter_animation", undefined);
89
+ store.updateBlock(selectedBlock.block._key, updates);
90
+ }}
91
+ className="text-[10px] text-neutral-400 hover:text-[var(--admin-error)] transition-colors"
92
+ >
93
+ Reset
94
+ </button>
95
+ </div>
96
+ )}
97
+ <EnterAnimationPicker
98
+ mode={{ level: "block", blockType: selectedBlock.block._type }}
99
+ config={effectiveEnterAnim}
100
+ onChange={(cfg) => {
101
+ const updates = setBlockAnimationOverride(selectedBlock.block, bvp, "enter_animation", cfg);
102
+ store.updateBlock(selectedBlock.block._key, updates);
103
+ }}
104
+ />
105
+ </div>
106
+ {/* Hover Effect — block-level only, shown if block type has hover presets */}
107
+ <div className="border-t border-neutral-200 my-1" />
108
+ <div className="relative">
109
+ {hasHoverOverride && (
110
+ <div className="flex items-center justify-between px-4 pt-2">
111
+ <span className="text-[9px] text-[#076bff] font-medium">overridden</span>
112
+ <button
113
+ onClick={() => {
114
+ const updates = setBlockAnimationOverride(selectedBlock.block, bvp, "hover_effect", undefined);
115
+ store.updateBlock(selectedBlock.block._key, updates);
116
+ }}
117
+ className="text-[10px] text-neutral-400 hover:text-[var(--admin-error)] transition-colors"
118
+ >
119
+ Reset
120
+ </button>
121
+ </div>
122
+ )}
123
+ <HoverEffectPicker
124
+ blockType={selectedBlock.block._type}
125
+ config={effectiveHoverEffect ?? getBlockHoverEffect(selectedBlock.block)}
126
+ onChange={(cfg) => {
127
+ const updates = setBlockAnimationOverride(selectedBlock.block, bvp, "hover_effect", cfg);
128
+ store.updateBlock(selectedBlock.block._key, updates);
129
+ }}
130
+ />
131
+ </div>
132
+ {isProjectGrid && pgBlock && (
133
+ <>
134
+ <div className="border-t border-neutral-200 my-1" />
135
+ <CardEntranceSection block={pgBlock} />
136
+ </>
137
+ )}
138
+ </>
139
+ );
140
+ }
141
+
142
+ // Page-level: generic enter animation (no hover at page level)
143
+ return (
144
+ <EnterAnimationPicker
145
+ mode={{ level: "page" }}
146
+ config={store.pageSettings.enter_animation}
147
+ onChange={(cfg) => {
148
+ store.updatePageSettings({ enter_animation: cfg });
149
+ }}
150
+ />
151
+ );
152
+ }
@@ -0,0 +1,114 @@
1
+ "use client";
2
+
3
+ /**
4
+ * CardEntranceSection — Toggle + preset/stagger/duration controls for
5
+ * ProjectGrid card entrance animations.
6
+ *
7
+ * Extracted from SettingsPanel.tsx in Session C (refactor split).
8
+ */
9
+
10
+ import { useBuilderStore } from "../../../lib/builder/store";
11
+ import type { ProjectGridBlock, CardEntranceConfig } from "../../../lib/sanity/types";
12
+
13
+ export const ENTRANCE_PRESETS = [
14
+ { value: "fade", label: "Fade" },
15
+ { value: "slide-up", label: "Slide Up" },
16
+ { value: "scale", label: "Scale" },
17
+ ] as const;
18
+
19
+ export const CARD_ENTRANCE_SELECT_CLASS =
20
+ "w-full rounded-lg border border-transparent bg-[#f5f5f5] px-2.5 py-[7px] text-xs text-neutral-900 font-normal outline-none transition-all hover:bg-[#efefef] focus:bg-white focus:border-[#076bff] focus:shadow-[0_0_0_3px_rgba(7,107,255,0.06)]";
21
+
22
+ export const CARD_ENTRANCE_SLIDER_CLASS =
23
+ "w-full h-1.5 rounded-full bg-[#e5e5e5] appearance-none cursor-pointer accent-[#076bff] [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3.5 [&::-webkit-slider-thumb]:h-3.5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-[#076bff] [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-white [&::-webkit-slider-thumb]:shadow-sm";
24
+
25
+ export function CardEntranceSection({ block }: { block: ProjectGridBlock }) {
26
+ const updateBlock = useBuilderStore((s) => s.updateBlock);
27
+ const entrance = block.card_entrance;
28
+ const enabled = entrance?.enabled ?? false;
29
+
30
+ const update = (updates: Partial<CardEntranceConfig>) => {
31
+ updateBlock(block._key, {
32
+ card_entrance: { ...entrance, ...updates },
33
+ } as Partial<ProjectGridBlock>);
34
+ };
35
+
36
+ return (
37
+ <div className="px-4 py-3">
38
+ <div className="flex items-center justify-between mb-2.5">
39
+ <span className="text-xs font-medium text-neutral-700">Card Entrance</span>
40
+ <button
41
+ type="button"
42
+ onClick={() => update({ enabled: !enabled })}
43
+ className={`relative w-8 h-[18px] rounded-full transition-colors ${
44
+ enabled ? "bg-[#076bff]" : "bg-neutral-300"
45
+ }`}
46
+ >
47
+ <span
48
+ className={`absolute top-[2px] w-[14px] h-[14px] rounded-full bg-white shadow transition-transform ${
49
+ enabled ? "translate-x-[16px]" : "translate-x-[2px]"
50
+ }`}
51
+ />
52
+ </button>
53
+ </div>
54
+
55
+ {enabled && (
56
+ <div className="space-y-3">
57
+ {/* Preset — dropdown instead of segmented buttons */}
58
+ <div>
59
+ <label className="text-[11px] text-neutral-500 mb-1 block">Preset</label>
60
+ <select
61
+ value={entrance?.preset || "slide-up"}
62
+ onChange={(e) => update({ preset: e.target.value as "fade" | "slide-up" | "scale" })}
63
+ className={CARD_ENTRANCE_SELECT_CLASS}
64
+ >
65
+ {ENTRANCE_PRESETS.map((opt) => (
66
+ <option key={opt.value} value={opt.value}>
67
+ {opt.label}
68
+ </option>
69
+ ))}
70
+ </select>
71
+ </div>
72
+
73
+ {/* Stagger delay */}
74
+ <div>
75
+ <div className="flex items-center justify-between mb-1">
76
+ <label className="text-[11px] text-neutral-500">Stagger</label>
77
+ <span className="text-[11px] text-neutral-500 tabular-nums">
78
+ {entrance?.stagger_delay ?? 80}ms
79
+ </span>
80
+ </div>
81
+ <input
82
+ type="range"
83
+ min={0}
84
+ max={5000}
85
+ step={10}
86
+ value={entrance?.stagger_delay ?? 80}
87
+ onChange={(e) => update({ stagger_delay: Number(e.target.value) })}
88
+ className={CARD_ENTRANCE_SLIDER_CLASS}
89
+ />
90
+ </div>
91
+
92
+ {/* Duration */}
93
+ <div>
94
+ <div className="flex items-center justify-between mb-1">
95
+ <label className="text-[11px] text-neutral-500">Duration</label>
96
+ <span className="text-[11px] text-neutral-500 tabular-nums">
97
+ {entrance?.duration ?? 500}ms
98
+ </span>
99
+ </div>
100
+ <input
101
+ type="range"
102
+ min={200}
103
+ max={5000}
104
+ step={50}
105
+ value={entrance?.duration ?? 500}
106
+ onChange={(e) => update({ duration: Number(e.target.value) })}
107
+ className={CARD_ENTRANCE_SLIDER_CLASS}
108
+ />
109
+ </div>
110
+ </div>
111
+ )}
112
+ </div>
113
+ );
114
+ }
@@ -0,0 +1,32 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ColumnV2AnimationTab — Enter animation picker for V2 columns,
5
+ * inheriting from parent section config.
6
+ *
7
+ * Extracted from SettingsPanel.tsx in Session C (refactor split).
8
+ */
9
+
10
+ import { useBuilderStore } from "../../../lib/builder/store";
11
+ import type { PageSectionV2, SectionColumn } from "../../../lib/sanity/types";
12
+ import EnterAnimationPicker from "../editors/EnterAnimationPicker";
13
+
14
+ export function ColumnV2AnimationTab({
15
+ section,
16
+ column,
17
+ }: {
18
+ section: PageSectionV2;
19
+ column: SectionColumn;
20
+ }) {
21
+ const store = useBuilderStore();
22
+
23
+ return (
24
+ <EnterAnimationPicker
25
+ mode={{ level: "column", parentConfig: section.settings.enter_animation }}
26
+ config={column.enter_animation}
27
+ onChange={(cfg) => {
28
+ store.updateColumnEnterAnimation(section._key, column._key, cfg);
29
+ }}
30
+ />
31
+ );
32
+ }