kasuti: multiple custom motifs, rename, click-to-edit

State now carries an array of custom patterns instead of one slot.
Each gets a unique id ("custom-<n>") and a user-editable title; they
render as full tiles in the reel between the presets and the
"+ Make your own" button.

Reel behaviour:
- Clicking a preset tile still selects it for play.
- Clicking a custom tile opens the editor pre-seeded with that motif
  (rename, tweak geometry, re-download, or just press "Use this
  motif" to re-select for play).
- "+ Make your own" always opens an empty editor so a second motif
  can be drawn without overwriting the first.

Editor gains a Name input at the top. Saving writes the trimmed title
back onto the motif; downloads use it in the filename; parsed uploads
restore whatever title was baked into the JSON. Shared replay URLs
for custom motifs hydrate into a new "Shared motif" tile on the
viewer's reel rather than clobbering an existing custom.

Intro blurb split: the "editor only supports connected designs" and
"browser will not save your designs — download them separately"
sentences are now a single paragraph in foreground colour so the two
caveats read as a highlighted note.

https://claude.ai/code/session_01KNdXjQczMCRGMK7K3Qderw
This commit is contained in:
Claude 2026-04-22 11:57:46 +00:00
parent 39d173ab75
commit 2ea9de67c1
No known key found for this signature in database

View file

@ -14,6 +14,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Toaster as SonnerToaster } from '@/components/ui/sonner'; import { Toaster as SonnerToaster } from '@/components/ui/sonner';
@ -220,19 +221,19 @@ function b64urlDecode(s: string): string {
} }
function encodeReplay( function encodeReplay(
patternId: string, pattern: Pattern,
customPattern: Pattern | null,
startVertex: V, startVertex: V,
stitches: Stitch[] stitches: Stitch[]
): string { ): string {
const isCustom = !PATTERNS.some((p) => p.id === pattern.id);
const payload: ReplayPayload = { const payload: ReplayPayload = {
v: 1, v: 1,
p: patternId, p: isCustom ? 'custom' : pattern.id,
s: [startVertex[0], startVertex[1]], s: [startVertex[0], startVertex[1]],
t: stitches.map((st) => [st.to[0], st.to[1]]), t: stitches.map((st) => [st.to[0], st.to[1]]),
}; };
if (patternId === 'custom' && customPattern) { if (isCustom) {
payload.e = customPattern.edges.map(([a, b]) => [ payload.e = pattern.edges.map(([a, b]) => [
[a[0], a[1]], [a[0], a[1]],
[b[0], b[1]], [b[0], b[1]],
]); ]);
@ -677,12 +678,22 @@ const MiniPreview: React.FC<{ pattern: Pattern; active: boolean }> = ({ pattern,
interface MotifReelProps { interface MotifReelProps {
patterns: Pattern[]; patterns: Pattern[];
customPatternIds: string[];
activeId: string; activeId: string;
onSelect: (id: string) => void; onSelect: (id: string) => void;
onEditCustom: (id: string) => void;
onOpenEditor: () => void; onOpenEditor: () => void;
} }
const MotifReel: React.FC<MotifReelProps> = ({ patterns, activeId, onSelect, onOpenEditor }) => { const MotifReel: React.FC<MotifReelProps> = ({
patterns,
customPatternIds,
activeId,
onSelect,
onEditCustom,
onOpenEditor,
}) => {
const customIdSet = useMemo(() => new Set(customPatternIds), [customPatternIds]);
const trackRef = useRef<HTMLDivElement | null>(null); const trackRef = useRef<HTMLDivElement | null>(null);
const [atStart, setAtStart] = useState(true); const [atStart, setAtStart] = useState(true);
const [atEnd, setAtEnd] = useState(false); const [atEnd, setAtEnd] = useState(false);
@ -726,12 +737,14 @@ const MotifReel: React.FC<MotifReelProps> = ({ patterns, activeId, onSelect, onO
> >
{patterns.map((p) => { {patterns.map((p) => {
const active = p.id === activeId; const active = p.id === activeId;
const isCustom = customIdSet.has(p.id);
return ( return (
<button <button
key={p.id} key={p.id}
role="radio" role="radio"
aria-checked={active} aria-checked={active}
onClick={() => onSelect(p.id)} onClick={() => (isCustom ? onEditCustom(p.id) : onSelect(p.id))}
title={isCustom ? 'Click to edit or rename this motif' : p.title}
className={`shrink-0 flex flex-col items-center gap-1 rounded-lg border p-2 transition-colors min-w-[92px] ${ className={`shrink-0 flex flex-col items-center gap-1 rounded-lg border p-2 transition-colors min-w-[92px] ${
active active
? 'border-primary bg-primary/5 text-foreground' ? 'border-primary bg-primary/5 text-foreground'
@ -742,7 +755,9 @@ const MotifReel: React.FC<MotifReelProps> = ({ patterns, activeId, onSelect, onO
<MiniPreview pattern={p} active={active} /> <MiniPreview pattern={p} active={active} />
</div> </div>
<span className="text-xs font-medium">{p.title}</span> <span className="text-xs font-medium">{p.title}</span>
<span className="text-[10px] text-muted-foreground">{p.description}</span> <span className="text-[10px] text-muted-foreground">
{isCustom ? 'click to edit' : p.description}
</span>
</button> </button>
); );
})} })}
@ -796,8 +811,10 @@ const MotifReel: React.FC<MotifReelProps> = ({ patterns, activeId, onSelect, onO
const KasutiEmbroidery: React.FC = () => { const KasutiEmbroidery: React.FC = () => {
const [patternId, setPatternId] = useState<string>(PATTERNS[0].id); const [patternId, setPatternId] = useState<string>(PATTERNS[0].id);
const [customPattern, setCustomPattern] = useState<Pattern | null>(null); const [customPatterns, setCustomPatterns] = useState<Pattern[]>([]);
const [editorOpen, setEditorOpen] = useState(false); const [editorOpen, setEditorOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null); // null = new motif
const customCounterRef = useRef(0);
const [currentVertex, setCurrentVertex] = useState<V | null>(null); const [currentVertex, setCurrentVertex] = useState<V | null>(null);
const [startVertex, setStartVertex] = useState<V | null>(null); const [startVertex, setStartVertex] = useState<V | null>(null);
const [activeSide, setActiveSide] = useState<Side>('front'); const [activeSide, setActiveSide] = useState<Side>('front');
@ -811,10 +828,20 @@ const KasutiEmbroidery: React.FC = () => {
const celebratedRef = useRef(false); const celebratedRef = useRef(false);
const visiblePatterns = useMemo( const visiblePatterns = useMemo(
() => (customPattern ? [...PATTERNS, customPattern] : PATTERNS), () => [...PATTERNS, ...customPatterns],
[customPattern] [customPatterns]
); );
const editingPattern = useMemo(
() => (editingId ? customPatterns.find((p) => p.id === editingId) ?? null : null),
[editingId, customPatterns]
);
const nextCustomId = useCallback(() => {
customCounterRef.current += 1;
return `custom-${customCounterRef.current}`;
}, []);
const placed = useMemo(() => { const placed = useMemo(() => {
const p = visiblePatterns.find((x) => x.id === patternId) ?? PATTERNS[0]; const p = visiblePatterns.find((x) => x.id === patternId) ?? PATTERNS[0];
return placePattern(p); return placePattern(p);
@ -931,12 +958,14 @@ const KasutiEmbroidery: React.FC = () => {
// Build a shareable URL that hydrates to this tour on arrival. // Build a shareable URL that hydrates to this tour on arrival.
const buildReplayUrl = useCallback((): string | null => { const buildReplayUrl = useCallback((): string | null => {
if (!completed || !startVertex || typeof window === 'undefined') return null; if (!completed || !startVertex || typeof window === 'undefined') return null;
const encoded = encodeReplay(patternId, customPattern, startVertex, stitches); const pattern = visiblePatterns.find((p) => p.id === patternId);
if (!pattern) return null;
const encoded = encodeReplay(pattern, startVertex, stitches);
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.search = ''; url.search = '';
url.searchParams.set('replay', encoded); url.searchParams.set('replay', encoded);
return url.toString(); return url.toString();
}, [completed, startVertex, patternId, customPattern, stitches]); }, [completed, startVertex, patternId, visiblePatterns, stitches]);
const copyReplayLink = useCallback(async () => { const copyReplayLink = useCallback(async () => {
const url = buildReplayUrl(); const url = buildReplayUrl();
@ -1054,6 +1083,7 @@ const KasutiEmbroidery: React.FC = () => {
if (!payload) return; if (!payload) return;
let targetPattern: Pattern | null = null; let targetPattern: Pattern | null = null;
let targetPatternId = payload.p;
if (payload.p === 'custom' && payload.e) { if (payload.p === 'custom' && payload.e) {
const customEdges: Array<readonly [V, V]> = payload.e.map(([a, b]) => [ const customEdges: Array<readonly [V, V]> = payload.e.map(([a, b]) => [
[a[0], a[1]] as V, [a[0], a[1]] as V,
@ -1066,20 +1096,22 @@ const KasutiEmbroidery: React.FC = () => {
maxR = Math.max(maxR, a[0], b[0]); maxR = Math.max(maxR, a[0], b[0]);
maxC = Math.max(maxC, a[1], b[1]); maxC = Math.max(maxC, a[1], b[1]);
} }
const hydratedId = nextCustomId();
targetPattern = { targetPattern = {
id: 'custom', id: hydratedId,
title: 'Custom', title: 'Shared motif',
description: `${customEdges.length} stitches`, description: `${customEdges.length} stitches`,
bbox: [maxR, maxC], bbox: [maxR, maxC],
edges: customEdges, edges: customEdges,
}; };
setCustomPattern(targetPattern); targetPatternId = hydratedId;
setCustomPatterns((prev) => [...prev, targetPattern!]);
} else { } else {
targetPattern = PATTERNS.find((p) => p.id === payload.p) ?? null; targetPattern = PATTERNS.find((p) => p.id === payload.p) ?? null;
} }
if (!targetPattern) return; if (!targetPattern) return;
setPatternId(payload.p); setPatternId(targetPatternId);
// Reconstruct stitches: start at payload.s, alternate sides, each entry's // Reconstruct stitches: start at payload.s, alternate sides, each entry's
// `to` is the corresponding tour point. // `to` is the corresponding tour point.
@ -1181,8 +1213,11 @@ const KasutiEmbroidery: React.FC = () => {
with making some designs or just get a feel for the pathways the thread will take with making some designs or just get a feel for the pathways the thread will take
when you don't have your materials handy, you can try emulating kasuti embroidery when you don't have your materials handy, you can try emulating kasuti embroidery
below. Pick one of the premade examples or make, save, and share your own designs! below. Pick one of the premade examples or make, save, and share your own designs!
</p>
<p className="text-sm font-medium text-foreground max-w-3xl mx-auto">
The editor does not currently support disjoint designs so make sure your patterns The editor does not currently support disjoint designs so make sure your patterns
are connected. are connected. The browser will not save your designs, so be sure to download them
separately if you want to come back to them later!
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@ -1200,21 +1235,48 @@ const KasutiEmbroidery: React.FC = () => {
<CardContent> <CardContent>
<MotifReel <MotifReel
patterns={visiblePatterns} patterns={visiblePatterns}
customPatternIds={customPatterns.map((p) => p.id)}
activeId={patternId} activeId={patternId}
onSelect={selectPattern} onSelect={selectPattern}
onOpenEditor={() => setEditorOpen(true)} onEditCustom={(id) => {
setEditingId(id);
setEditorOpen(true);
}}
onOpenEditor={() => {
setEditingId(null);
setEditorOpen(true);
}}
/> />
</CardContent> </CardContent>
</Card> </Card>
<PatternEditor <PatternEditor
open={editorOpen} open={editorOpen}
onOpenChange={setEditorOpen} onOpenChange={(open) => {
initial={customPattern} setEditorOpen(open);
if (!open) setEditingId(null);
}}
initial={editingPattern}
onSave={(p) => { onSave={(p) => {
setCustomPattern(p); const title = p.title?.trim() || (editingPattern?.title ?? 'Custom');
setPatternId(p.id); if (editingId) {
// Update existing motif in place.
const updated: Pattern = { ...p, id: editingId, title };
setCustomPatterns((prev) =>
prev.map((m) => (m.id === editingId ? updated : m))
);
setPatternId(editingId);
} else {
// Brand new motif.
const id = nextCustomId();
const defaultTitle =
title && title !== 'Custom' ? title : `Custom ${customCounterRef.current}`;
const created: Pattern = { ...p, id, title: defaultTitle };
setCustomPatterns((prev) => [...prev, created]);
setPatternId(id);
}
resetTrace(); resetTrace();
setEditingId(null);
setEditorOpen(false); setEditorOpen(false);
}} }}
/> />
@ -1985,6 +2047,7 @@ function parsePatternFile(raw: string): Pattern | null {
const PatternEditor: React.FC<PatternEditorProps> = ({ open, onOpenChange, initial, onSave }) => { const PatternEditor: React.FC<PatternEditorProps> = ({ open, onOpenChange, initial, onSave }) => {
const [edges, setEdges] = useState<Set<EdgeKey>>(new Set()); const [edges, setEdges] = useState<Set<EdgeKey>>(new Set());
const [title, setTitle] = useState<string>('');
const [loadError, setLoadError] = useState<string | null>(null); const [loadError, setLoadError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
@ -1995,9 +2058,11 @@ const PatternEditor: React.FC<PatternEditorProps> = ({ open, onOpenChange, initi
setLoadError(null); setLoadError(null);
if (!initial) { if (!initial) {
setEdges(new Set()); setEdges(new Set());
setTitle('');
return; return;
} }
setEdges(centeredEdgeSetFromPattern(initial)); setEdges(centeredEdgeSetFromPattern(initial));
setTitle(initial.title ?? '');
}, [open, initial]); }, [open, initial]);
const toggleEdge = useCallback((a: V, b: V) => { const toggleEdge = useCallback((a: V, b: V) => {
@ -2050,18 +2115,20 @@ const PatternEditor: React.FC<PatternEditorProps> = ({ open, onOpenChange, initi
// Main grid has GRID_SIZE cells; patterns larger than that will not fit. // Main grid has GRID_SIZE cells; patterns larger than that will not fit.
const [br, bc] = pattern.bbox; const [br, bc] = pattern.bbox;
if (br > GRID_SIZE || bc > GRID_SIZE) return; if (br > GRID_SIZE || bc > GRID_SIZE) return;
onSave(pattern); const trimmed = title.trim();
}, [connected, edgeList, onSave]); onSave({ ...pattern, title: trimmed || 'Custom' });
}, [connected, edgeList, title, onSave]);
const handleClear = useCallback(() => setEdges(new Set()), []); const handleClear = useCallback(() => setEdges(new Set()), []);
const handleDownload = useCallback(() => { const handleDownload = useCallback(() => {
const pattern = normalizePattern(edgeList); const pattern = normalizePattern(edgeList);
if (!pattern) return; if (!pattern) return;
const trimmed = title.trim() || 'Custom';
const payload: KasutiPatternFile = { const payload: KasutiPatternFile = {
kind: 'kasuti-pattern', kind: 'kasuti-pattern',
version: 1, version: 1,
title: 'Custom', title: trimmed,
edges: pattern.edges.map(([a, b]) => [ edges: pattern.edges.map(([a, b]) => [
[a[0], a[1]], [a[0], a[1]],
[b[0], b[1]], [b[0], b[1]],
@ -2070,13 +2137,14 @@ const PatternEditor: React.FC<PatternEditorProps> = ({ open, onOpenChange, initi
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const link = document.createElement('a'); const link = document.createElement('a');
const safeName = trimmed.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'motif';
link.href = url; link.href = url;
link.download = `kasuti-motif-${pattern.edges.length}.json`; link.download = `kasuti-${safeName}-${pattern.edges.length}.json`;
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
link.remove(); link.remove();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}, [edgeList]); }, [edgeList, title]);
const handleUpload = useCallback(() => { const handleUpload = useCallback(() => {
fileInputRef.current?.click(); fileInputRef.current?.click();
@ -2103,6 +2171,7 @@ const PatternEditor: React.FC<PatternEditorProps> = ({ open, onOpenChange, initi
} }
setLoadError(null); setLoadError(null);
setEdges(centeredEdgeSetFromPattern(parsed)); setEdges(centeredEdgeSetFromPattern(parsed));
setTitle(parsed.title ?? '');
}; };
reader.onerror = () => setLoadError('Could not read that file.'); reader.onerror = () => setLoadError('Could not read that file.');
reader.readAsText(file); reader.readAsText(file);
@ -2112,13 +2181,27 @@ const PatternEditor: React.FC<PatternEditorProps> = ({ open, onOpenChange, initi
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg md:max-w-2xl"> <DialogContent className="sm:max-w-lg md:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Design your own motif</DialogTitle> <DialogTitle>{initial ? 'Edit motif' : 'Design your own motif'}</DialogTitle>
<DialogDescription> <DialogDescription>
Click between two dots to add or remove a stitch line. As long as your motif is{' '} Click between two dots to add or remove a stitch line. As long as your motif is{' '}
<em>connected</em>, an alternating kasuti tour exists on it. <em>connected</em>, an alternating kasuti tour exists on it.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex items-center gap-2">
<Label htmlFor="kasuti-motif-title" className="text-xs text-muted-foreground shrink-0">
Name
</Label>
<Input
id="kasuti-motif-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Custom"
maxLength={40}
className="h-8 text-sm"
/>
</div>
<div className="flex justify-center overflow-auto"> <div className="flex justify-center overflow-auto">
<svg <svg
width={EDITOR_DIM} width={EDITOR_DIM}