kasuti: reel polish, greener cues, custom colours, motif file I/O

UI cleanups:
- MiniPreview fits every motif into a 60×60 box (Snowflake no longer
  overshoots its card).
- Reel replaces the native overflow scrollbar with a scroll container
  plus left/right chevron buttons and edge fades that appear only when
  there is more content in that direction.
- Legal-next vertex markers are green instead of red; the current
  vertex renders green on the active side and muted grey on the
  inactive side (making the side-flip obvious without a jump).

Thread colours:
- Each fabric panel now has an extra swatch opening the native colour
  picker, so the user can dial in any thread colour beyond the five
  presets. The swatch shows a conic rainbow and highlights when the
  current colour isn't one of the presets.

Editor:
- Validation simplified to connectivity only (the alternating Eulerian
  tour on the doubled graph exists for every connected base graph, so
  odd-degree vertices don't actually block solvability — the extra
  warning was misleading).
- Download button exports the current motif as a small JSON file
  ({kind: "kasuti-pattern", version: 1, edges: [...]}); Upload reads
  one back in, validates structure (orthogonal unit segments only,
  fits in the editor grid), and loads it centered for editing.

https://claude.ai/code/session_01KNdXjQczMCRGMK7K3Qderw
This commit is contained in:
Claude 2026-04-22 08:37:19 +00:00
parent 12803052fd
commit d2fa216b8f
No known key found for this signature in database

View file

@ -1,5 +1,5 @@
import confetti from 'canvas-confetti'; import confetti from 'canvas-confetti';
import { Plus, RotateCcw, Undo2 } from 'lucide-react'; import { ChevronLeft, ChevronRight, Download, Plus, RotateCcw, Undo2, Upload } from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@ -446,22 +446,27 @@ function toXY([r, c]: V): { x: number; y: number } {
// ─── Mini pattern preview for the reel ────────────────────────────────────── // ─── Mini pattern preview for the reel ──────────────────────────────────────
const MINI_SIZE = 60;
const MINI_PAD = 4;
const MiniPreview: React.FC<{ pattern: Pattern; active: boolean }> = ({ pattern, active }) => { const MiniPreview: React.FC<{ pattern: Pattern; active: boolean }> = ({ pattern, active }) => {
const [rows, cols] = pattern.bbox; const [rows, cols] = pattern.bbox;
const pad = 4; // Scale to fit the fixed MINI_SIZE square so big motifs (Snowflake, Rose) don't overshoot.
const cell = 14; const span = Math.max(rows, cols, 1);
const w = cols * cell + pad * 2; const inner = MINI_SIZE - MINI_PAD * 2;
const h = rows * cell + pad * 2; const cell = inner / span;
const offsetR = (inner - rows * cell) / 2;
const offsetC = (inner - cols * cell) / 2;
return ( return (
<svg width={w} height={h} className="shrink-0"> <svg width={MINI_SIZE} height={MINI_SIZE} className="shrink-0">
{pattern.edges.map(([a, b], i) => ( {pattern.edges.map(([a, b], i) => (
<line <line
key={i} key={i}
x1={pad + a[1] * cell} x1={MINI_PAD + offsetC + a[1] * cell}
y1={pad + a[0] * cell} y1={MINI_PAD + offsetR + a[0] * cell}
x2={pad + b[1] * cell} x2={MINI_PAD + offsetC + b[1] * cell}
y2={pad + b[0] * cell} y2={MINI_PAD + offsetR + b[0] * cell}
stroke={active ? 'currentColor' : 'currentColor'} stroke="currentColor"
strokeWidth={2} strokeWidth={2}
strokeLinecap="round" strokeLinecap="round"
className={active ? 'text-primary' : 'text-muted-foreground'} className={active ? 'text-primary' : 'text-muted-foreground'}
@ -471,6 +476,125 @@ const MiniPreview: React.FC<{ pattern: Pattern; active: boolean }> = ({ pattern,
); );
}; };
// ─── Motif reel (custom horizontal scroller) ────────────────────────────────
interface MotifReelProps {
patterns: Pattern[];
activeId: string;
onSelect: (id: string) => void;
onOpenEditor: () => void;
}
const MotifReel: React.FC<MotifReelProps> = ({ patterns, activeId, onSelect, onOpenEditor }) => {
const trackRef = useRef<HTMLDivElement | null>(null);
const [atStart, setAtStart] = useState(true);
const [atEnd, setAtEnd] = useState(false);
const updateEdgeState = useCallback(() => {
const el = trackRef.current;
if (!el) return;
setAtStart(el.scrollLeft <= 1);
setAtEnd(el.scrollLeft + el.clientWidth >= el.scrollWidth - 1);
}, []);
useEffect(() => {
updateEdgeState();
const el = trackRef.current;
if (!el) return;
const handler = () => updateEdgeState();
el.addEventListener('scroll', handler, { passive: true });
const ro = new ResizeObserver(handler);
ro.observe(el);
return () => {
el.removeEventListener('scroll', handler);
ro.disconnect();
};
}, [updateEdgeState, patterns.length]);
const scrollBy = useCallback((dir: 1 | -1) => {
const el = trackRef.current;
if (!el) return;
const step = Math.max(el.clientWidth * 0.8, 180);
el.scrollBy({ left: dir * step, behavior: 'smooth' });
}, []);
return (
<div className="relative">
<div
ref={trackRef}
className="flex gap-2 overflow-x-auto scrollbar-none pb-1"
style={{ scrollbarWidth: 'none' }}
role="radiogroup"
aria-label="Motif picker"
>
{patterns.map((p) => {
const active = p.id === activeId;
return (
<button
key={p.id}
role="radio"
aria-checked={active}
onClick={() => onSelect(p.id)}
className={`shrink-0 flex flex-col items-center gap-1 rounded-lg border p-2 transition-colors min-w-[92px] ${
active
? 'border-primary bg-primary/5 text-foreground'
: 'border-border bg-card hover:border-primary/40'
}`}
>
<div className="h-[60px] w-[60px] flex items-center justify-center">
<MiniPreview pattern={p} active={active} />
</div>
<span className="text-xs font-medium">{p.title}</span>
<span className="text-[10px] text-muted-foreground">{p.description}</span>
</button>
);
})}
<button
type="button"
onClick={onOpenEditor}
aria-label="Design your own motif"
className="shrink-0 flex flex-col items-center justify-center gap-1 rounded-lg border border-dashed border-border p-2 transition-colors min-w-[92px] hover:border-primary/60 hover:bg-primary/5"
>
<div className="h-[60px] w-[60px] flex items-center justify-center">
<div className="w-9 h-9 rounded-full border-2 border-primary/60 flex items-center justify-center">
<Plus className="w-5 h-5 text-primary" />
</div>
</div>
<span className="text-xs font-medium">Make your own</span>
<span className="text-[10px] text-muted-foreground">draw a motif</span>
</button>
</div>
{/* Edge fades + arrow buttons */}
{!atStart && (
<div className="pointer-events-none absolute inset-y-0 left-0 w-12 bg-gradient-to-r from-card to-transparent" />
)}
{!atEnd && (
<div className="pointer-events-none absolute inset-y-0 right-0 w-12 bg-gradient-to-l from-card to-transparent" />
)}
<button
type="button"
onClick={() => scrollBy(-1)}
disabled={atStart}
aria-label="Scroll motifs left"
className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-1 w-8 h-8 rounded-full bg-card border border-border shadow-sm flex items-center justify-center transition-opacity disabled:opacity-0 disabled:pointer-events-none hover:border-primary/60"
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => scrollBy(1)}
disabled={atEnd}
aria-label="Scroll motifs right"
className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-1 w-8 h-8 rounded-full bg-card border border-border shadow-sm flex items-center justify-center transition-opacity disabled:opacity-0 disabled:pointer-events-none hover:border-primary/60"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
);
};
// ─── Main component ──────────────────────────────────────────────────────── // ─── Main component ────────────────────────────────────────────────────────
const KasutiEmbroidery: React.FC = () => { const KasutiEmbroidery: React.FC = () => {
@ -652,49 +776,12 @@ const KasutiEmbroidery: React.FC = () => {
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div <MotifReel
className="flex gap-2 overflow-x-auto pb-2" patterns={visiblePatterns}
role="radiogroup" activeId={patternId}
aria-label="Motif picker" onSelect={selectPattern}
> onOpenEditor={() => setEditorOpen(true)}
{visiblePatterns.map((p) => { />
const active = p.id === patternId;
return (
<button
key={p.id}
role="radio"
aria-checked={active}
onClick={() => selectPattern(p.id)}
className={`shrink-0 flex flex-col items-center gap-1 rounded-lg border p-2 transition-colors min-w-[88px] ${
active
? 'border-primary bg-primary/5 text-foreground'
: 'border-border bg-card hover:border-primary/40'
}`}
>
<div className="h-[60px] flex items-center justify-center">
<MiniPreview pattern={p} active={active} />
</div>
<span className="text-xs font-medium">{p.title}</span>
<span className="text-[10px] text-muted-foreground">{p.description}</span>
</button>
);
})}
<button
type="button"
onClick={() => setEditorOpen(true)}
aria-label="Design your own motif"
className="shrink-0 flex flex-col items-center justify-center gap-1 rounded-lg border border-dashed border-border p-2 transition-colors min-w-[88px] hover:border-primary/60 hover:bg-primary/5"
>
<div className="h-[60px] flex items-center justify-center">
<div className="w-9 h-9 rounded-full border-2 border-primary/60 flex items-center justify-center">
<Plus className="w-5 h-5 text-primary" />
</div>
</div>
<span className="text-xs font-medium">Make your own</span>
<span className="text-[10px] text-muted-foreground">draw a motif</span>
</button>
</div>
</CardContent> </CardContent>
</Card> </Card>
@ -896,7 +983,7 @@ const FabricPanel: React.FC<FabricPanelProps> = ({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label className="text-xs text-muted-foreground shrink-0">thread</Label> <Label className="text-xs text-muted-foreground shrink-0">thread</Label>
<div className="flex gap-1"> <div className="flex items-center gap-1">
{palette.map((c) => ( {palette.map((c) => (
<button <button
key={c} key={c}
@ -909,6 +996,23 @@ const FabricPanel: React.FC<FabricPanelProps> = ({
style={{ backgroundColor: c }} style={{ backgroundColor: c }}
/> />
))} ))}
<label
aria-label={`Pick a custom ${side} thread color`}
className={`relative w-5 h-5 rounded-full border-2 transition-transform cursor-pointer overflow-hidden ${
palette.includes(color) ? 'border-border' : 'border-foreground scale-110'
}`}
style={{
background:
'conic-gradient(from 0deg, #ef4444, #f59e0b, #facc15, #22c55e, #06b6d4, #3b82f6, #a855f7, #ec4899, #ef4444)',
}}
>
<input
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="absolute inset-0 opacity-0 cursor-pointer"
/>
</label>
</div> </div>
</div> </div>
</div> </div>
@ -979,7 +1083,7 @@ const FabricPanel: React.FC<FabricPanelProps> = ({
); );
})} })}
{/* Legal-next affordances */} {/* Legal-next affordances (green = go) */}
{isActive && {isActive &&
currentVertex && currentVertex &&
legalNext.map((v, i) => { legalNext.map((v, i) => {
@ -990,7 +1094,7 @@ const FabricPanel: React.FC<FabricPanelProps> = ({
cx={x} cx={x}
cy={y} cy={y}
r={6} r={6}
className="fill-primary/30 stroke-primary" className="fill-green-500/30 stroke-green-600 dark:fill-green-400/25 dark:stroke-green-400"
strokeWidth={1.5} strokeWidth={1.5}
/> />
); );
@ -1007,7 +1111,7 @@ const FabricPanel: React.FC<FabricPanelProps> = ({
cx={x} cx={x}
cy={y} cy={y}
r={5} r={5}
className="fill-primary/20 stroke-primary/70" className="fill-green-500/20 stroke-green-600/70 dark:fill-green-400/15 dark:stroke-green-400/70"
strokeWidth={1} strokeWidth={1}
/> />
); );
@ -1025,13 +1129,17 @@ const FabricPanel: React.FC<FabricPanelProps> = ({
/> />
)} )}
{/* Current vertex marker */} {/* Current vertex marker — full colour on the active side, grey on the other */}
{currentVertex && ( {currentVertex && (
<circle <circle
cx={toXY(currentVertex).x} cx={toXY(currentVertex).x}
cy={toXY(currentVertex).y} cy={toXY(currentVertex).y}
r={4} r={4}
className="fill-primary" className={
isActive
? 'fill-green-600 dark:fill-green-400'
: 'fill-muted-foreground/60'
}
/> />
)} )}
@ -1108,17 +1216,6 @@ function isConnected(edges: Array<readonly [V, V]>): boolean {
return seen.size === adj.size; return seen.size === adj.size;
} }
function oddDegreeVertices(edges: Array<readonly [V, V]>): number {
const deg = new Map<string, number>();
for (const [a, b] of edges) {
deg.set(vKey(a), (deg.get(vKey(a)) ?? 0) + 1);
deg.set(vKey(b), (deg.get(vKey(b)) ?? 0) + 1);
}
let odd = 0;
for (const d of deg.values()) if (d % 2 !== 0) odd++;
return odd;
}
function normalizePattern(edges: Array<readonly [V, V]>): Pattern | null { function normalizePattern(edges: Array<readonly [V, V]>): Pattern | null {
if (edges.length === 0) return null; if (edges.length === 0) return null;
let minR = Infinity, let minR = Infinity,
@ -1153,27 +1250,74 @@ interface PatternEditorProps {
onSave: (p: Pattern) => void; onSave: (p: Pattern) => void;
} }
interface KasutiPatternFile {
kind: 'kasuti-pattern';
version: 1;
title: string;
edges: Array<[[number, number], [number, number]]>;
}
function centeredEdgeSetFromPattern(p: Pattern): Set<EdgeKey> {
const [br, bc] = p.bbox;
const offR = Math.max(0, Math.floor((EDITOR_SIZE - br) / 2));
const offC = Math.max(0, Math.floor((EDITOR_SIZE - bc) / 2));
const out = new Set<EdgeKey>();
for (const [a, b] of p.edges) {
const sa: V = [a[0] + offR, a[1] + offC];
const sb: V = [b[0] + offR, b[1] + offC];
out.add(edgeKey(sa, sb));
}
return out;
}
function parsePatternFile(raw: string): Pattern | null {
try {
const data = JSON.parse(raw) as KasutiPatternFile;
if (!data || data.kind !== 'kasuti-pattern' || !Array.isArray(data.edges)) return null;
const edges: Array<readonly [V, V]> = [];
for (const e of data.edges) {
if (!Array.isArray(e) || e.length !== 2) return null;
const [a, b] = e;
if (!Array.isArray(a) || !Array.isArray(b)) return null;
if (a.length !== 2 || b.length !== 2) return null;
const [r1, c1] = a;
const [r2, c2] = b;
if (
typeof r1 !== 'number' ||
typeof c1 !== 'number' ||
typeof r2 !== 'number' ||
typeof c2 !== 'number'
)
return null;
// Only orthogonal unit-length segments are valid.
const dr = Math.abs(r1 - r2);
const dc = Math.abs(c1 - c2);
if (!((dr === 1 && dc === 0) || (dr === 0 && dc === 1))) return null;
edges.push([[r1, c1] as V, [r2, c2] as V]);
}
const normalized = normalizePattern(edges);
if (!normalized) return null;
return { ...normalized, title: data.title || 'Custom' };
} catch {
return 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 [loadError, setLoadError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
// When the modal opens, seed from the initial custom pattern (if any), centred in the // When the modal opens, seed from the initial custom pattern (if any), centred in the
// editor grid. Resetting here avoids stale state between open/close cycles. // editor grid. Resetting here avoids stale state between open/close cycles.
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
setLoadError(null);
if (!initial) { if (!initial) {
setEdges(new Set()); setEdges(new Set());
return; return;
} }
const [br, bc] = initial.bbox; setEdges(centeredEdgeSetFromPattern(initial));
const offR = Math.max(0, Math.floor((EDITOR_SIZE - br) / 2));
const offC = Math.max(0, Math.floor((EDITOR_SIZE - bc) / 2));
const seed = new Set<EdgeKey>();
for (const [a, b] of initial.edges) {
const sa: V = [a[0] + offR, a[1] + offC];
const sb: V = [b[0] + offR, b[1] + offC];
seed.add(edgeKey(sa, sb));
}
setEdges(seed);
}, [open, initial]); }, [open, initial]);
const toggleEdge = useCallback((a: V, b: V) => { const toggleEdge = useCallback((a: V, b: V) => {
@ -1188,7 +1332,6 @@ const PatternEditor: React.FC<PatternEditorProps> = ({ open, onOpenChange, initi
const edgeList = useMemo(() => edgeListFromKeys(edges), [edges]); const edgeList = useMemo(() => edgeListFromKeys(edges), [edges]);
const connected = useMemo(() => isConnected(edgeList), [edgeList]); const connected = useMemo(() => isConnected(edgeList), [edgeList]);
const oddCount = useMemo(() => oddDegreeVertices(edgeList), [edgeList]);
const count = edges.size; const count = edges.size;
const allEdges: Array<[V, V]> = useMemo(() => { const allEdges: Array<[V, V]> = useMemo(() => {
@ -1218,15 +1361,67 @@ const PatternEditor: React.FC<PatternEditorProps> = ({ open, onOpenChange, initi
const handleClear = useCallback(() => setEdges(new Set()), []); const handleClear = useCallback(() => setEdges(new Set()), []);
const handleDownload = useCallback(() => {
const pattern = normalizePattern(edgeList);
if (!pattern) return;
const payload: KasutiPatternFile = {
kind: 'kasuti-pattern',
version: 1,
title: 'Custom',
edges: pattern.edges.map(([a, b]) => [
[a[0], a[1]],
[b[0], b[1]],
]),
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `kasuti-motif-${pattern.edges.length}.json`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}, [edgeList]);
const handleUpload = useCallback(() => {
fileInputRef.current?.click();
}, []);
const handleFilePicked = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const result = typeof reader.result === 'string' ? reader.result : '';
const parsed = parsePatternFile(result);
if (!parsed) {
setLoadError('That file does not look like a saved Kasuti motif.');
return;
}
const [br, bc] = parsed.bbox;
if (br > EDITOR_SIZE || bc > EDITOR_SIZE) {
setLoadError(
`That motif is too big for the editor grid (${br + 1}×${bc + 1} > ${EDITOR_SIZE + 1}×${EDITOR_SIZE + 1}).`
);
return;
}
setLoadError(null);
setEdges(centeredEdgeSetFromPattern(parsed));
};
reader.onerror = () => setLoadError('Could not read that file.');
reader.readAsText(file);
}, []);
return ( return (
<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>Design your own motif</DialogTitle>
<DialogDescription> <DialogDescription>
Click between two dots to add or remove a stitch line. The motif must be{' '} Click between two dots to add or remove a stitch line. As long as your motif is{' '}
<em>connected</em> for the puzzle to start. For a complete alternating tour, every <em>connected</em>, an alternating kasuti tour exists on it.
vertex should have an even number of incident lines.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -1303,22 +1498,39 @@ const PatternEditor: React.FC<PatternEditorProps> = ({ open, onOpenChange, initi
Not connected every drawn segment must reach every other segment. Not connected every drawn segment must reach every other segment.
</p> </p>
)} )}
{count > 0 && connected && oddCount === 0 && ( {count > 0 && connected && (
<p className="text-success"> <p className="text-success">Connected ready to stitch.</p>
Connected and every vertex has even degree a complete alternating tour exists.
</p>
)}
{count > 0 && connected && oddCount > 0 && (
<p className="text-amber-600 dark:text-amber-400">
Connected, but {oddCount} vertex{oddCount === 1 ? '' : 'es'} {oddCount === 1 ? 'has' : 'have'} odd degree you may not be able to close the tour.
</p>
)} )}
{loadError && <p className="text-destructive">{loadError}</p>}
</div> </div>
<DialogFooter className="gap-2 sm:gap-2"> <input
<Button variant="outline" onClick={handleClear} disabled={count === 0}> ref={fileInputRef}
Clear type="file"
</Button> accept="application/json,.json"
className="hidden"
onChange={handleFilePicked}
/>
<DialogFooter className="flex-col-reverse sm:flex-row gap-2 sm:justify-between">
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleUpload}>
<Upload className="w-4 h-4 mr-1" />
Upload
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDownload}
disabled={count === 0}
>
<Download className="w-4 h-4 mr-1" />
Download
</Button>
<Button variant="outline" size="sm" onClick={handleClear} disabled={count === 0}>
Clear
</Button>
</div>
<Button onClick={handleSave} disabled={!connected}> <Button onClick={handleSave} disabled={!connected}>
Use this motif Use this motif
</Button> </Button>