From d2fa216b8fee6dc711d5913b419d8fa337052515 Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 22 Apr 2026 08:37:19 +0000
Subject: [PATCH] kasuti: reel polish, greener cues, custom colours, motif file
I/O
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
.../interactives/KasutiEmbroidery.tsx | 408 +++++++++++++-----
1 file changed, 310 insertions(+), 98 deletions(-)
diff --git a/src/components/interactives/KasutiEmbroidery.tsx b/src/components/interactives/KasutiEmbroidery.tsx
index d265df7..6aa0a99 100644
--- a/src/components/interactives/KasutiEmbroidery.tsx
+++ b/src/components/interactives/KasutiEmbroidery.tsx
@@ -1,5 +1,5 @@
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 { Badge } from '@/components/ui/badge';
@@ -446,22 +446,27 @@ function toXY([r, c]: V): { x: number; y: number } {
// ─── 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 [rows, cols] = pattern.bbox;
- const pad = 4;
- const cell = 14;
- const w = cols * cell + pad * 2;
- const h = rows * cell + pad * 2;
+ // Scale to fit the fixed MINI_SIZE square so big motifs (Snowflake, Rose) don't overshoot.
+ const span = Math.max(rows, cols, 1);
+ const inner = MINI_SIZE - MINI_PAD * 2;
+ const cell = inner / span;
+ const offsetR = (inner - rows * cell) / 2;
+ const offsetC = (inner - cols * cell) / 2;
return (
-
+
{pattern.edges.map(([a, b], i) => (
= ({ pattern,
);
};
+// ─── Motif reel (custom horizontal scroller) ────────────────────────────────
+
+interface MotifReelProps {
+ patterns: Pattern[];
+ activeId: string;
+ onSelect: (id: string) => void;
+ onOpenEditor: () => void;
+}
+
+const MotifReel: React.FC = ({ patterns, activeId, onSelect, onOpenEditor }) => {
+ const trackRef = useRef(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 (
+
+
+ {patterns.map((p) => {
+ const active = p.id === activeId;
+ return (
+
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'
+ }`}
+ >
+
+
+
+ {p.title}
+ {p.description}
+
+ );
+ })}
+
+
+
+ Make your own
+ draw a motif
+
+
+
+ {/* Edge fades + arrow buttons */}
+ {!atStart && (
+
+ )}
+ {!atEnd && (
+
+ )}
+
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"
+ >
+
+
+
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"
+ >
+
+
+
+ );
+};
+
// ─── Main component ────────────────────────────────────────────────────────
const KasutiEmbroidery: React.FC = () => {
@@ -652,49 +776,12 @@ const KasutiEmbroidery: React.FC = () => {
-
- {visiblePatterns.map((p) => {
- const active = p.id === patternId;
- return (
-
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'
- }`}
- >
-
-
-
- {p.title}
- {p.description}
-
- );
- })}
-
-
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"
- >
-
- Make your own
- draw a motif
-
-
+ setEditorOpen(true)}
+ />
@@ -896,7 +983,7 @@ const FabricPanel: React.FC = ({
@@ -979,7 +1083,7 @@ const FabricPanel: React.FC = ({
);
})}
- {/* Legal-next affordances */}
+ {/* Legal-next affordances (green = go) */}
{isActive &&
currentVertex &&
legalNext.map((v, i) => {
@@ -990,7 +1094,7 @@ const FabricPanel: React.FC = ({
cx={x}
cy={y}
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}
/>
);
@@ -1007,7 +1111,7 @@ const FabricPanel: React.FC = ({
cx={x}
cy={y}
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}
/>
);
@@ -1025,13 +1129,17 @@ const FabricPanel: React.FC = ({
/>
)}
- {/* Current vertex marker */}
+ {/* Current vertex marker — full colour on the active side, grey on the other */}
{currentVertex && (
)}
@@ -1108,17 +1216,6 @@ function isConnected(edges: Array): boolean {
return seen.size === adj.size;
}
-function oddDegreeVertices(edges: Array): number {
- const deg = new Map();
- 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): Pattern | null {
if (edges.length === 0) return null;
let minR = Infinity,
@@ -1153,27 +1250,74 @@ interface PatternEditorProps {
onSave: (p: Pattern) => void;
}
+interface KasutiPatternFile {
+ kind: 'kasuti-pattern';
+ version: 1;
+ title: string;
+ edges: Array<[[number, number], [number, number]]>;
+}
+
+function centeredEdgeSetFromPattern(p: Pattern): Set {
+ 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();
+ 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 = [];
+ 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 = ({ open, onOpenChange, initial, onSave }) => {
const [edges, setEdges] = useState>(new Set());
+ const [loadError, setLoadError] = useState(null);
+ const fileInputRef = useRef(null);
// 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.
useEffect(() => {
if (!open) return;
+ setLoadError(null);
if (!initial) {
setEdges(new Set());
return;
}
- const [br, bc] = initial.bbox;
- 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();
- 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);
+ setEdges(centeredEdgeSetFromPattern(initial));
}, [open, initial]);
const toggleEdge = useCallback((a: V, b: V) => {
@@ -1188,7 +1332,6 @@ const PatternEditor: React.FC = ({ open, onOpenChange, initi
const edgeList = useMemo(() => edgeListFromKeys(edges), [edges]);
const connected = useMemo(() => isConnected(edgeList), [edgeList]);
- const oddCount = useMemo(() => oddDegreeVertices(edgeList), [edgeList]);
const count = edges.size;
const allEdges: Array<[V, V]> = useMemo(() => {
@@ -1218,15 +1361,67 @@ const PatternEditor: React.FC = ({ open, onOpenChange, initi
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) => {
+ 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 (
Design your own motif
- Click between two dots to add or remove a stitch line. The motif must be{' '}
- connected for the puzzle to start. For a complete alternating tour, every
- vertex should have an even number of incident lines.
+ Click between two dots to add or remove a stitch line. As long as your motif is{' '}
+ connected , an alternating kasuti tour exists on it.
@@ -1303,22 +1498,39 @@ const PatternEditor: React.FC = ({ open, onOpenChange, initi
Not connected — every drawn segment must reach every other segment.
)}
- {count > 0 && connected && oddCount === 0 && (
-
- Connected and every vertex has even degree — a complete alternating tour exists.
-
- )}
- {count > 0 && connected && oddCount > 0 && (
-
- Connected, but {oddCount} vertex{oddCount === 1 ? '' : 'es'} {oddCount === 1 ? 'has' : 'have'} odd degree — you may not be able to close the tour.
-
+ {count > 0 && connected && (
+ Connected — ready to stitch.
)}
+ {loadError && {loadError}
}
-
-
- Clear
-
+
+
+
+
+
+
+ Upload
+
+
+
+ Download
+
+
+ Clear
+
+
Use this motif