From 2ea9de67c1ba84226a05749279efbd73d4e25441 Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 22 Apr 2026 11:57:46 +0000
Subject: [PATCH] kasuti: multiple custom motifs, rename, click-to-edit
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
State now carries an array of custom patterns instead of one slot.
Each gets a unique id ("custom-") 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
---
.../interactives/KasutiEmbroidery.tsx | 141 ++++++++++++++----
1 file changed, 112 insertions(+), 29 deletions(-)
diff --git a/src/components/interactives/KasutiEmbroidery.tsx b/src/components/interactives/KasutiEmbroidery.tsx
index 8a7ff00..f1b3437 100644
--- a/src/components/interactives/KasutiEmbroidery.tsx
+++ b/src/components/interactives/KasutiEmbroidery.tsx
@@ -14,6 +14,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
+import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Toaster as SonnerToaster } from '@/components/ui/sonner';
@@ -220,19 +221,19 @@ function b64urlDecode(s: string): string {
}
function encodeReplay(
- patternId: string,
- customPattern: Pattern | null,
+ pattern: Pattern,
startVertex: V,
stitches: Stitch[]
): string {
+ const isCustom = !PATTERNS.some((p) => p.id === pattern.id);
const payload: ReplayPayload = {
v: 1,
- p: patternId,
+ p: isCustom ? 'custom' : pattern.id,
s: [startVertex[0], startVertex[1]],
t: stitches.map((st) => [st.to[0], st.to[1]]),
};
- if (patternId === 'custom' && customPattern) {
- payload.e = customPattern.edges.map(([a, b]) => [
+ if (isCustom) {
+ payload.e = pattern.edges.map(([a, b]) => [
[a[0], a[1]],
[b[0], b[1]],
]);
@@ -677,12 +678,22 @@ const MiniPreview: React.FC<{ pattern: Pattern; active: boolean }> = ({ pattern,
interface MotifReelProps {
patterns: Pattern[];
+ customPatternIds: string[];
activeId: string;
onSelect: (id: string) => void;
+ onEditCustom: (id: string) => void;
onOpenEditor: () => void;
}
-const MotifReel: React.FC = ({ patterns, activeId, onSelect, onOpenEditor }) => {
+const MotifReel: React.FC = ({
+ patterns,
+ customPatternIds,
+ activeId,
+ onSelect,
+ onEditCustom,
+ onOpenEditor,
+}) => {
+ const customIdSet = useMemo(() => new Set(customPatternIds), [customPatternIds]);
const trackRef = useRef(null);
const [atStart, setAtStart] = useState(true);
const [atEnd, setAtEnd] = useState(false);
@@ -726,12 +737,14 @@ const MotifReel: React.FC = ({ patterns, activeId, onSelect, onO
>
{patterns.map((p) => {
const active = p.id === activeId;
+ const isCustom = customIdSet.has(p.id);
return (
);
})}
@@ -796,8 +811,10 @@ const MotifReel: React.FC = ({ patterns, activeId, onSelect, onO
const KasutiEmbroidery: React.FC = () => {
const [patternId, setPatternId] = useState(PATTERNS[0].id);
- const [customPattern, setCustomPattern] = useState(null);
+ const [customPatterns, setCustomPatterns] = useState([]);
const [editorOpen, setEditorOpen] = useState(false);
+ const [editingId, setEditingId] = useState(null); // null = new motif
+ const customCounterRef = useRef(0);
const [currentVertex, setCurrentVertex] = useState(null);
const [startVertex, setStartVertex] = useState(null);
const [activeSide, setActiveSide] = useState('front');
@@ -811,10 +828,20 @@ const KasutiEmbroidery: React.FC = () => {
const celebratedRef = useRef(false);
const visiblePatterns = useMemo(
- () => (customPattern ? [...PATTERNS, customPattern] : PATTERNS),
- [customPattern]
+ () => [...PATTERNS, ...customPatterns],
+ [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 p = visiblePatterns.find((x) => x.id === patternId) ?? PATTERNS[0];
return placePattern(p);
@@ -931,12 +958,14 @@ const KasutiEmbroidery: React.FC = () => {
// Build a shareable URL that hydrates to this tour on arrival.
const buildReplayUrl = useCallback((): string | 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);
url.search = '';
url.searchParams.set('replay', encoded);
return url.toString();
- }, [completed, startVertex, patternId, customPattern, stitches]);
+ }, [completed, startVertex, patternId, visiblePatterns, stitches]);
const copyReplayLink = useCallback(async () => {
const url = buildReplayUrl();
@@ -1054,6 +1083,7 @@ const KasutiEmbroidery: React.FC = () => {
if (!payload) return;
let targetPattern: Pattern | null = null;
+ let targetPatternId = payload.p;
if (payload.p === 'custom' && payload.e) {
const customEdges: Array = payload.e.map(([a, b]) => [
[a[0], a[1]] as V,
@@ -1066,20 +1096,22 @@ const KasutiEmbroidery: React.FC = () => {
maxR = Math.max(maxR, a[0], b[0]);
maxC = Math.max(maxC, a[1], b[1]);
}
+ const hydratedId = nextCustomId();
targetPattern = {
- id: 'custom',
- title: 'Custom',
+ id: hydratedId,
+ title: 'Shared motif',
description: `${customEdges.length} stitches`,
bbox: [maxR, maxC],
edges: customEdges,
};
- setCustomPattern(targetPattern);
+ targetPatternId = hydratedId;
+ setCustomPatterns((prev) => [...prev, targetPattern!]);
} else {
targetPattern = PATTERNS.find((p) => p.id === payload.p) ?? null;
}
if (!targetPattern) return;
- setPatternId(payload.p);
+ setPatternId(targetPatternId);
// Reconstruct stitches: start at payload.s, alternate sides, each entry's
// `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
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!
+
+
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!