From 38a312d214cbd2c584c12e6b565a6accf2162c0f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 11:31:38 +0000 Subject: [PATCH] =?UTF-8?q?kasuti:=20stage=201=20replay=20=E2=80=94=20cont?= =?UTF-8?q?rols=20+=20scrub=20+=20play/pause?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On completion, the green banner is replaced with a replay panel: skip-to-start / prev / play / next / skip-to-end buttons, a scrubber showing "n / total" stitches, and a 0.5× / 1× / 2× speed toggle. Autoplay kicks off immediately after the confetti so the user sees the tour traced out once by default; pressing play again after reaching the end rewinds and replays. Rendering uses effective stitches/drawn/currentVertex/activeSide derived from replayIdx so both fabrics animate together. The side badges and the running "n / total" counter track the replayed position too. Undo and Reset stay wired to the underlying stitch list and tear down replay state when they fire. https://claude.ai/code/session_01KNdXjQczMCRGMK7K3Qderw --- .../interactives/KasutiEmbroidery.tsx | 246 ++++++++++++++++-- 1 file changed, 224 insertions(+), 22 deletions(-) diff --git a/src/components/interactives/KasutiEmbroidery.tsx b/src/components/interactives/KasutiEmbroidery.tsx index 15bfa25..bcbeb69 100644 --- a/src/components/interactives/KasutiEmbroidery.tsx +++ b/src/components/interactives/KasutiEmbroidery.tsx @@ -1,5 +1,5 @@ import confetti from 'canvas-confetti'; -import { ChevronLeft, ChevronRight, Download, Plus, RotateCcw, Undo2, Upload } from 'lucide-react'; +import { ChevronLeft, ChevronRight, Download, Pause, Play, Plus, RotateCcw, SkipBack, SkipForward, Undo2, Upload } from 'lucide-react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Badge } from '@/components/ui/badge'; @@ -613,6 +613,9 @@ const KasutiEmbroidery: React.FC = () => { const [frontColor, setFrontColor] = useState(FRONT_COLORS[0]); const [backColor, setBackColor] = useState(BACK_COLORS[0]); const [completed, setCompleted] = useState(false); + const [replayIdx, setReplayIdx] = useState(0); + const [replayPlaying, setReplayPlaying] = useState(false); + const [replaySpeed, setReplaySpeed] = useState(1); // 0.5 | 1 | 2 const celebratedRef = useRef(false); const visiblePatterns = useMemo( @@ -733,16 +736,72 @@ const KasutiEmbroidery: React.FC = () => { celebratedRef.current = false; }, [stitches]); + // On first completion: confetti + kick off an autoplay replay from step 0. + // When the tour is un-completed (undo/reset/pattern change), tear down replay. useEffect(() => { if (completed && !celebratedRef.current) { celebratedRef.current = true; confetti({ particleCount: 180, spread: 90, origin: { y: 0.6 } }); + setReplayIdx(0); + setReplayPlaying(true); + } else if (!completed) { + setReplayIdx(0); + setReplayPlaying(false); } }, [completed]); + // Replay autoplay tick. + useEffect(() => { + if (!completed || !replayPlaying) return; + const id = setInterval(() => { + setReplayIdx((prev) => { + if (prev >= stitches.length) { + setReplayPlaying(false); + return prev; + } + return prev + 1; + }); + }, 320 / replaySpeed); + return () => clearInterval(id); + }, [completed, replayPlaying, stitches.length, replaySpeed]); + + // Effective view for rendering: during replay, clip stitches to replayIdx. + const effectiveStitches = useMemo( + () => (completed ? stitches.slice(0, replayIdx) : stitches), + [completed, stitches, replayIdx] + ); + const effectiveDrawnFront = useMemo(() => { + if (!completed) return drawnFront; + const s = new Set(); + for (const st of effectiveStitches) { + if (st.side === 'front') s.add(edgeKey(st.from, st.to)); + } + return s; + }, [completed, effectiveStitches, drawnFront]); + const effectiveDrawnBack = useMemo(() => { + if (!completed) return drawnBack; + const s = new Set(); + for (const st of effectiveStitches) { + if (st.side === 'back') s.add(edgeKey(st.from, st.to)); + } + return s; + }, [completed, effectiveStitches, drawnBack]); + const effectiveCurrentVertex: V | null = completed + ? replayIdx === 0 + ? startVertex + : stitches[replayIdx - 1].to + : currentVertex; + const effectiveActiveSide: Side = completed + ? replayIdx === 0 + ? 'front' + : stitches[replayIdx - 1].side === 'front' + ? 'back' + : 'front' + : activeSide; + const totalEdges = placed.edges.length; - const frontRemaining = totalEdges - drawnFront.size; - const backRemaining = totalEdges - drawnBack.size; + const frontRemaining = totalEdges - effectiveDrawnFront.size; + const backRemaining = totalEdges - effectiveDrawnBack.size; const deadEnd = !completed && currentVertex !== null && legalNextVertices.length === 0; return ( @@ -818,21 +877,21 @@ const KasutiEmbroidery: React.FC = () => { : 'border-foreground/20 text-muted-foreground' } > - Front{activeSide === 'front' ? ' · active' : ''} + Front{effectiveActiveSide === 'front' ? ' · active' : ''} - Back{activeSide === 'back' ? ' · active' : ''} + Back{effectiveActiveSide === 'back' ? ' · active' : ''} - {stitches.length} stitch{stitches.length === 1 ? '' : 'es'} · front{' '} - {drawnFront.size}/{totalEdges} · back {drawnBack.size}/{totalEdges} + {effectiveStitches.length} stitch{effectiveStitches.length === 1 ? '' : 'es'} · front{' '} + {effectiveDrawnFront.size}/{totalEdges} · back {effectiveDrawnBack.size}/{totalEdges}
@@ -861,14 +920,14 @@ const KasutiEmbroidery: React.FC = () => { setColor={setFrontColor} palette={FRONT_COLORS} placed={placed} - currentVertex={currentVertex} + currentVertex={effectiveCurrentVertex} startVertex={startVertex} - drawn={drawnFront} - otherDrawn={drawnBack} - stitches={stitches} + drawn={effectiveDrawnFront} + otherDrawn={effectiveDrawnBack} + stitches={effectiveStitches} frontColor={frontColor} backColor={backColor} - activeSide={activeSide} + activeSide={effectiveActiveSide} onVertexClick={handleVertexClick} legalNext={legalNextVertices} canStartAt={canStartAt} @@ -883,14 +942,14 @@ const KasutiEmbroidery: React.FC = () => { setColor={setBackColor} palette={BACK_COLORS} placed={placed} - currentVertex={currentVertex} + currentVertex={effectiveCurrentVertex} startVertex={startVertex} - drawn={drawnBack} - otherDrawn={drawnFront} - stitches={stitches} + drawn={effectiveDrawnBack} + otherDrawn={effectiveDrawnFront} + stitches={effectiveStitches} frontColor={frontColor} backColor={backColor} - activeSide={activeSide} + activeSide={effectiveActiveSide} onVertexClick={handleVertexClick} legalNext={legalNextVertices} canStartAt={canStartAt} @@ -901,10 +960,15 @@ const KasutiEmbroidery: React.FC = () => {
{completed && ( -
- You ended where you started — both sides match exactly. A complete kasuti tour in{' '} - {stitches.length} stitches. -
+ )} {deadEnd && ( @@ -967,6 +1031,144 @@ const KasutiEmbroidery: React.FC = () => { ); }; +// ─── Replay controls ──────────────────────────────────────────────────────── + +interface ReplayControlsProps { + totalStitches: number; + replayIdx: number; + playing: boolean; + speed: number; // 0.5 | 1 | 2 + onIdxChange: (idx: number) => void; + onPlayingChange: (playing: boolean) => void; + onSpeedChange: (speed: number) => void; +} + +const REPLAY_SPEEDS = [0.5, 1, 2] as const; + +const ReplayControls: React.FC = ({ + totalStitches, + replayIdx, + playing, + speed, + onIdxChange, + onPlayingChange, + onSpeedChange, +}) => { + const atStart = replayIdx <= 0; + const atEnd = replayIdx >= totalStitches; + + const handlePlay = () => { + if (atEnd) { + // If at the end, rewind and start over. + onIdxChange(0); + onPlayingChange(true); + return; + } + onPlayingChange(!playing); + }; + + return ( +
+
+

+ Tour complete in {totalStitches} stitches — replay below. +

+
+ speed + {REPLAY_SPEEDS.map((s) => ( + + ))} +
+
+ +
+ + + + + + { + onPlayingChange(false); + onIdxChange(Number(e.target.value)); + }} + className="flex-1 accent-primary" + aria-label="Replay scrubber" + /> + + {replayIdx} / {totalStitches} + +
+
+ ); +}; + // ─── Fabric panel (one side) ──────────────────────────────────────────────── interface FabricPanelProps {