// @ts-nocheck import React, { useMemo, useRef, useState } from "react"; /** * EN↔ZH Syntax Reorderer — Native HTML5 Drag & Drop + Roles & Patterns (v2.2.3) * * The component helps students understand structural and syntactic differences * between English and Chinese by segmenting, labeling roles, and reordering. * (Imported from prototype; minor adjustments for integration only.) */ // ------- Utilities ------- const containsHan = (s = "") => /\p{Script=Han}/u.test(s); const isPunc = (t = "") => /[.,!?;:·…—\-,。?!;:、《》〈〉()“”"'\[\]{}]/.test(t); function hash6(str) { let h = 0; for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) >>> 0; return (h >>> 0).toString(36).slice(0, 6); } function tokenize(text) { if (!text || !String(text).trim()) return []; const rough = String(text).replace(/\s+/g, " ").trim().split(" ").filter(Boolean); const tokens = []; const re = /(\p{Script=Han}+)|([A-Za-z0-9]+(?:[-'][A-Za-z0-9]+)*)|([^\p{Script=Han}A-Za-z0-9\s])/gu; for (const chunk of (rough.length ? rough : [String(text).trim()])) { let m; while ((m = re.exec(chunk))) { const [tok] = m; if (tok.trim() !== "") tokens.push(tok); } } return tokens; } // Expand any Han token into individual characters (keeps punctuation intact) function explodeHanChars(tokens = []) { const out = []; for (const t of tokens) { if (containsHan(t) && !isPunc(t) && t.length > 1) { for (const ch of t) out.push(ch); } else { out.push(t); } } return out; } const boundaryCount = (tokens) => Math.max(0, (tokens?.length || 0) - 1); function groupsFrom(tokens = [], boundaries = []) { if (!Array.isArray(tokens) || tokens.length === 0) return []; const groups = []; let cur = []; tokens.forEach((t, i) => { cur.push(t); const atEnd = i === tokens.length - 1; if (atEnd || !!boundaries[i]) { groups.push(cur.join(" ")); cur = []; } }); return groups; } function boundariesFromPunctuation(tokens = []) { const count = boundaryCount(tokens); const out = new Array(count).fill(false); for (let i = 0; i < count; i++) out[i] = isPunc(tokens[i]); return out; } function splitLiteralChunks(literalText, groups) { if (!literalText || !literalText.trim()) return new Array(Math.max(0, groups.length)).fill(""); let chunks; if (literalText.includes("|")) { chunks = literalText.split("|").map((s) => s.trim()).filter((s) => s !== ""); } else if (literalText.includes("\n")) { chunks = literalText.split(/\n+/).map((s) => s.trim()).filter((s) => s !== ""); } else { const words = literalText.trim().split(/\s+/).filter(Boolean); const sizes = groups.map((g) => g.split(/\s+/).filter(Boolean).length || 1); const total = sizes.reduce((a, b) => a + b, 0) || Math.max(1, groups.length); chunks = []; let cursor = 0; for (let i = 0; i < groups.length; i++) { const share = Math.round((sizes[i] / total) * words.length); const end = i === groups.length - 1 ? words.length : Math.min(words.length, cursor + Math.max(1, share)); chunks.push(words.slice(cursor, end).join(" ")); cursor = end; } chunks = chunks.map((s) => (s && s.trim()) || "⋯"); } return chunks; } // Small helper to move item in an array function moveItem(arr, from, to) { const a = arr.slice(); const [item] = a.splice(from, 1); a.splice(to, 0, item); return a; } // ---------- Roles & Colours ---------- const ROLES = [ "TIME", "PLACE", "TOPIC", "SUBJECT", "AUX", "VERB", "OBJECT", "MANNER", "DEGREE", "CAUSE", "RESULT", "REL", "EXIST", "BA", "BEI", "AGENT", "OTHER" ]; const ROLE_LABEL = { TIME: "Time", PLACE: "Place", TOPIC: "Topic/Stance", SUBJECT: "Subject", AUX: "Auxiliary", VERB: "Verb", OBJECT: "Object", MANNER: "Manner", DEGREE: "Degree", CAUSE: "Cause", RESULT: "Result", REL: "的‑clause", EXIST: "Existential(有)", BA: "把", BEI: "被", AGENT: "Agent", OTHER: "Other" }; const ROLE_COLOUR = { TIME: "bg-blue-50 border-blue-200 text-blue-700", PLACE: "bg-teal-50 border-teal-200 text-teal-700", TOPIC: "bg-amber-50 border-amber-200 text-amber-800", SUBJECT: "bg-sky-50 border-sky-200 text-sky-800", VERB: "bg-indigo-50 border-indigo-200 text-indigo-700", OBJECT: "bg-rose-50 border-rose-200 text-rose-700", MANNER: "bg-violet-50 border-violet-200 text-violet-700", DEGREE: "bg-fuchsia-50 border-fuchsia-200 text-fuchsia-700", CAUSE: "bg-orange-50 border-orange-200 text-orange-700", RESULT: "bg-lime-50 border-lime-200 text-lime-700", REL: "bg-emerald-50 border-emerald-200 text-emerald-700", EXIST: "bg-cyan-50 border-cyan-200 text-cyan-700", BA: "bg-yellow-50 border-yellow-200 text-yellow-700", BEI: "bg-stone-50 border-stone-200 text-stone-700", AGENT: "bg-stone-50 border-stone-200 text-stone-800", OTHER: "bg-zinc-50 border-zinc-200 text-zinc-700", }; // Prevent drag initiation from form controls inside draggable cards const NO_DRAG = { draggable: false, onDragStart: (e) => e.preventDefault(), onMouseDown: (e) => e.stopPropagation(), }; function RolePill({ role }) { const cls = ROLE_COLOUR[role] || ROLE_COLOUR.OTHER; return {ROLE_LABEL[role] || role}; } function RoleSelector({ value, onChange }) { return ( ); } function SortableCard({ id, idx, src, lit, role, onEdit, onDragStart, onDragEnter, onDragOver, onDrop, onDragEnd, onKeyMove, isDragOver, highlightRoles }) { const isHL = highlightRoles?.includes(role); return (
onDragStart(e, id) } onDragEnter={(e) => onDragEnter(e, id)} onDragOver={(e) => onDragOver(e, id)} onDrop={(e) => onDrop(e, id)} onDragEnd={onDragEnd} className={`bg-white rounded-2xl shadow-sm border p-4 mb-3 hover:shadow-md transition outline-none ${isDragOver ? "ring-2 ring-indigo-400" : "border-zinc-200"} ${isHL ? "ring-2 ring-amber-400" : ""} cursor-grab active:cursor-grabbing`} tabIndex={0} onKeyDown={(e) => { if (e.key === "ArrowUp") { e.preventDefault(); onKeyMove(id, -1); } if (e.key === "ArrowDown") { e.preventDefault(); onKeyMove(id, +1); } }} >
Chunk {idx + 1}
onEdit(id, { role: r })} />
{/* Grab bar / affordance */}
⠿ Drag anywhere on the card background • or use ▲▼ keys
); } // ------------------------ // Self-tests (expanded) // ------------------------ function runTokenizerTests() { const cases = [ { name: "empty string", input: "", expect: [] }, { name: "spaces only", input: " \t\n ", expect: [] }, { name: "simple EN", input: "I like apples.", expect: ["I", "like", "apples", "."] }, { name: "simple ZH", input: "我喜欢中文。", expect: ["我", "喜欢", "中文", "。"] }, { name: "ZH+quotes", input: "“你好”,世界!", expect: ["“", "你", "好", "”", ",", "世", "界", "!"] }, { name: "EN hyphen+apostrophe", input: "state-of-the-art don't fail.", expect: ["state-of-the-art", "don't", "fail", "."] }, { name: "Emoji and symbols", input: "Hi🙂!", expect: ["Hi", "🙂", "!"] }, { name: "ZH with spaces", input: "我 爱 你。", expect: ["我", "爱", "你", "。"] }, ]; return cases.map((c) => { const got = tokenize(c.input); const pass = Array.isArray(got) && got.join("|") === c.expect.join("|"); return { name: c.name, pass, got, expect: c.expect }; }); } function runBoundaryTests() { const toks = tokenize("我 喜欢 中文 ."); const expectedCount = Math.max(0, toks.length - 1); const b1 = boundariesFromPunctuation(toks); const safeNewTrue = new Array(Math.max(0, toks.length - 1)).fill(true); const safeNewFalse = new Array(Math.max(0, toks.length - 1)).fill(false); return [ { name: "boundary count safe", pass: b1.length === expectedCount, got: b1.length, expect: expectedCount }, { name: "new true safe", pass: safeNewTrue.length === expectedCount }, { name: "new false safe", pass: safeNewFalse.length === expectedCount }, ]; } function runLiteralSplitTests() { const groups = ["It is", "difficult", "for me", "to learn Chinese"]; const t1 = splitLiteralChunks("很难 | 对我来说 | 学习中文", groups); const t2 = splitLiteralChunks("很难\n对我来说\n学习中文", groups); const t3 = splitLiteralChunks("很 难 对 我 来 说 学 习 中 文", groups); return [ { name: "literal with pipes", pass: Array.isArray(t1) && t1.length === groups.length }, { name: "literal with newlines", pass: Array.isArray(t2) && t2.length === groups.length }, { name: "literal with words", pass: Array.isArray(t3) && t3.length === groups.length }, ]; } function runCharExplodeTests() { const base = ["我喜欢中文", "。"]; const exploded = explodeHanChars(base); const pass = exploded.join("") === "我喜欢中文。" && exploded.length === 6; // 我 喜 欢 中 文 。 return [{ name: "explode Han into characters", pass, got: exploded }]; } function runNormalizeBoundaryTests() { const toks = ["我","喜","欢","中","文","。"]; const b = [true, false, true]; const n = Math.max(0, toks.length - 1); const out = new Array(n).fill(false); for (let i = 0; i < Math.min(n, b.length); i++) out[i] = !!b[i]; return [{ name: "normalize boundaries len", pass: out.length === toks.length - 1 }]; } function SelfTests() { const tokResults = runTokenizerTests(); const bResults = runBoundaryTests(); const lResults = runLiteralSplitTests(); const xResults = runCharExplodeTests(); const nbResults = runNormalizeBoundaryTests(); const all = [...tokResults, ...bResults, ...lResults, ...xResults, ...nbResults]; const passed = all.filter((r) => r.pass).length; const total = all.length; return (
Self-tests: {passed}/{total} passed (click to view)
{all.map((r, i) => (
{r.name} — {r.pass ? "PASS" : "FAIL"}
{r.expect !== undefined && (
expect: {JSON.stringify(r.expect)}
got: {JSON.stringify(r.got)}
)}
))}
); } // ---------- Pattern cards data with examples ---------- const PATTERNS = [ { id: "it_adj", title: "It is ADJ for SB to VP → 对SB来说 + VP + 很ADJ", roles: ["TOPIC", "SUBJECT", "VERB", "OBJECT", "DEGREE"], hint: "Front stance/topic, then VP, then degree+adj (simplified as DEGREE).", exEn: "It is difficult for me to learn Chinese.", exZh: "对我来说,学中文很难。", }, { id: "rel_clause", title: "Relative clause → [REL] 的 + N / EN: N + (that/which …)", roles: ["REL", "OBJECT"], hint: "ZH: 把后置从句前置为 的‑短语;EN: RC follows the noun.", exEn: "the book that I bought yesterday is expensive", exZh: "我昨天买的书很贵。", }, { id: "existential", title: "Existential/there is → (PLACE) + 有 + NP / EN: there is/are + NP (PP)", roles: ["PLACE", "EXIST", "OBJECT"], hint: "ZH: 地点可前置,EXIST 代表 ‘有’;EN: ‘there is/are’ + NP (+ place/time).", exEn: "There is a cat in the room.", exZh: "房间里有一只猫。", }, { id: "ba", title: "Disposal 把 → 把 + OBJ + V + (RESULT)", roles: ["BA", "OBJECT", "VERB", "RESULT"], hint: "突出对宾语的处置;结果补语可选。", exEn: "I put the keys on the table.", exZh: "我把钥匙放在桌子上。", }, { id: "bei", title: "Passive 被 → OBJ + 被 + AGENT + V (+RESULT) / EN: AGENT + V + OBJ (or passive)", roles: ["OBJECT", "BEI", "AGENT", "VERB", "RESULT"], hint: "ZH: AGENT 明示时放在 被 之后;EN: passive optional; active often preferred.", exEn: "The door was opened by the wind.", exZh: "门被风吹开了。", }, ]; const SIMPLE_TITLES = { it_adj: "Topic → VP → 很+ADJ", rel_clause: "的‑relative before noun", existential: "Place + 有 + NP", ba: "把 disposal pattern", bei: "被 passive pattern", } as Record; function ReasoningHints({ direction, cards }) { if (!cards || cards.length === 0) return null; const roles = cards.map((c) => c.role); const hints = []; const has = (r) => roles.includes(r); if (direction.startsWith("EN")) { if (has("TIME") || has("PLACE") || has("TOPIC")) hints.push("Fronted TIME/PLACE/TOPIC reflects Chinese topic/comment tendency."); if (has("REL") && has("OBJECT")) hints.push("Prenominal 的‑relative clause before the noun (ZH)."); if (has("BA")) hints.push("把‑construction focuses disposal/result on the object."); if (has("BEI")) hints.push("被‑passive highlights the patient/topic; agent optional."); if (has("EXIST")) hints.push("Existential 有 introduces new entities: (Place)+有+NP."); } else { if (has("REL") && has("OBJECT")) hints.push("English relative clauses follow the noun: N + (that/which …)."); if (has("EXIST")) hints.push("Translate 存现句 with 'there is/are' when introducing new entities."); if (has("TIME") || has("PLACE")) hints.push("English often places time/place adverbials after the verb phrase or at sentence end."); } if (hints.length === 0) return null; return ( ); } export default function SyntaxReorderer() { const [direction, setDirection] = useState("EN → ZH"); const [sourceText, setSourceText] = useState(""); const [literalText, setLiteralText] = useState(""); const prevLiteralRef = useRef(""); const fileRef = useRef(null); // Character-separator mode (ZH) const [zhCharMode, setZhCharMode] = useState(false); const baseTokens = useMemo(() => tokenize(sourceText), [sourceText]); const hasHanInSource = useMemo(() => /\p{Script=Han}/u.test(sourceText), [sourceText]); const srcTokens = useMemo(() => (zhCharMode ? explodeHanChars(baseTokens) : baseTokens), [baseTokens, zhCharMode]); const [boundaries, setBoundaries] = useState([]); React.useEffect(() => { setBoundaries((prev) => { const count = boundaryCount(srcTokens); const next = new Array(count).fill(false); for (let i = 0; i < Math.min(prev.length, next.length); i++) next[i] = prev[i]; return next; }); }, [srcTokens.length]); const groups = useMemo(() => groupsFrom(srcTokens, boundaries), [srcTokens, boundaries]); const groupsKey = useMemo(() => groups.join("|"), [groups]); const [cards, setCards] = useState([]); // {id, src, lit, role} const [order, setOrder] = useState([]); const [joiner, setJoiner] = useState("auto"); const [autoBuild, setAutoBuild] = useState(true); const [hasDragged, setHasDragged] = useState(false); const [dragId, setDragId] = useState(null); const [dragOverId, setDragOverId] = useState(null); const [isDragging, setIsDragging] = useState(false); // Pattern highlight state const [highlightRoles, setHighlightRoles] = useState([]); function rebuild({ preserveByIndex }) { const litChunks = splitLiteralChunks(literalText, groups); const built = groups.map((g, i) => ({ id: `${i}-${hash6(g)}`, src: g, lit: litChunks[i] ?? "", role: cards[i]?.role || "OTHER" })); if (preserveByIndex && cards.length === built.length) { for (let i = 0; i < built.length; i++) { if (cards[i]) { if (typeof cards[i].lit === "string" && cards[i].lit.trim() !== "") built[i].lit = cards[i].lit; if (cards[i].role) built[i].role = cards[i].role; } } } setCards(built); setOrder((prev) => { if (prev.length === built.length) { const setIds = new Set(built.map((b) => b.id)); const prevFiltered = prev.filter((id) => setIds.has(id)); if (prevFiltered.length === built.length) return prevFiltered; } return built.map((c) => c.id); }); if (joiner === "auto") { const anyHan = built.some((c) => containsHan(c.lit)); setJoiner(anyHan ? "none" : "space"); } } React.useEffect(() => { if (!autoBuild || hasDragged) return; const prevLit = prevLiteralRef.current; const litChanged = prevLit !== literalText; rebuild({ preserveByIndex: !litChanged }); prevLiteralRef.current = literalText; // eslint-disable-next-line react-hooks/exhaustive-deps }, [groupsKey, literalText, autoBuild, hasDragged]); // Helpers for drag guard function startedOnInteractiveTarget(e) { const target = e.target as HTMLElement | null; const tag = (target && target.tagName) || ''; if (["INPUT", "TEXTAREA", "SELECT", "BUTTON"].includes(tag)) return true; if (target && (target as any).isContentEditable) return true; return false; } // HTML5 DnD handlers function handleCardDragStart(e, id) { if (startedOnInteractiveTarget(e)) { e.preventDefault(); return; } setDragId(id); setIsDragging(true); e.dataTransfer.effectAllowed = "move"; try { e.dataTransfer.setData("text/plain", String(id)); } catch {} } function handleCardDragEnter(e, overId) { if (!isDragging) return; e.preventDefault(); setDragOverId(overId); } function handleCardDragOver(e, overId) { if (!isDragging) return; e.preventDefault(); setDragOverId(overId); } function handleCardDrop(e, overId) { if (!isDragging) return; e.preventDefault(); const fromId = dragId || e.dataTransfer.getData("text/plain"); if (!fromId || !overId || fromId === overId) { setDragId(null); setDragOverId(null); setIsDragging(false); return; } setOrder((items) => { const from = items.indexOf(fromId); const to = items.indexOf(overId); if (from === -1 || to === -1) return items; return moveItem(items, from, to); }); setHasDragged(true); setDragId(null); setDragOverId(null); setIsDragging(false); } function handleCardDragEnd() { setIsDragging(false); setDragId(null); setDragOverId(null); } function handleKeyMove(id, delta) { setOrder((items) => { const i = items.indexOf(id); if (i === -1) return items; let j = i + delta; if (j < 0) j = 0; if (j >= items.length) j = items.length - 1; if (i === j) return items; return moveItem(items, i, j); }); setHasDragged(true); } function onEditCard(id, patch) { setCards((prev) => prev.map((c) => (c.id === id ? { ...c, ...patch } : c))); } // Compose outputs const orderedCards = useMemo(() => order.map((id) => cards.find((c) => c.id === id)).filter(Boolean), [order, cards]); const composedTarget = useMemo(() => { const raw = orderedCards.map((c) => c.lit).filter((s) => s != null); if ((joiner === "none") || (joiner === "auto" && raw.some(containsHan))) return raw.join(""); return raw.join(" "); }, [orderedCards, joiner]); // Clear helpers const clearSource = () => { setSourceText(""); setBoundaries([]); setHasDragged(false); }; const clearLiteral = () => { setLiteralText(""); setHasDragged(false); }; const clearCards = () => { setCards([]); setOrder([]); setHasDragged(false); }; function exportJSON() { const payload = { direction, sourceText, literalText, tokens: srcTokens, boundaries, groups, cards, order, joiner, composedTarget, timestamp: new Date().toISOString() }; const json = JSON.stringify(payload, null, 2); try { const blob = new Blob([json], { type: "application/json;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "syntax-reorderer-activity.json"; a.rel = "noopener"; document.body.appendChild(a); a.click(); setTimeout(() => { try { document.body.removeChild(a); } catch {} URL.revokeObjectURL(url); }, 0); } catch (err) { // Fallback: copy to clipboard try { if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(json); alert("Exported by copying JSON to clipboard."); } else { alert("Export failed: " + (err?.message || String(err))); } } catch (e2) { alert("Export failed: " + (err?.message || String(err))); } } } function normalizeBoundaries(b, tokens) { const n = Math.max(0, (tokens?.length || 0) - 1); const out = new Array(n).fill(false); if (Array.isArray(b)) { for (let i = 0; i < Math.min(n, b.length); i++) out[i] = !!b[i]; } return out; } function importJSON(file) { const reader = new FileReader(); reader.onload = (e) => { try { const text = String(e.target?.result || ""); const data = JSON.parse(text); const src = typeof data.sourceText === "string" ? data.sourceText : ""; const base = tokenize(src); const chars = explodeHanChars(base); const wantCharMode = (/\p{Script=Han}/u.test(src)) && Array.isArray(data.tokens) && data.tokens.length === chars.length; setDirection(typeof data.direction === "string" ? data.direction : direction); setZhCharMode(wantCharMode); setSourceText(src); const toks = wantCharMode ? chars : base; setBoundaries(normalizeBoundaries(data.boundaries, toks)); setLiteralText(typeof data.literalText === "string" ? data.literalText : ""); setCards(Array.isArray(data.cards) ? data.cards : []); setOrder(Array.isArray(data.order) ? data.order : []); setJoiner(data.joiner === "space" || data.joiner === "none" ? data.joiner : "auto"); prevLiteralRef.current = typeof data.literalText === "string" ? data.literalText : ""; setHasDragged(false); } catch (err) { alert("Invalid JSON"); } }; reader.readAsText(file); } function loadDemo() { if (direction.startsWith("EN")) { // EN → ZH demo const demoSrc = "It is difficult for me to learn Chinese."; const demoLit = "很难 | 对我来说 | 学习中文"; setDirection("EN → ZH"); setSourceText(demoSrc); setBoundaries(boundariesFromPunctuation(tokenize(demoSrc))); setLiteralText(demoLit); setHasDragged(false); prevLiteralRef.current = demoLit; setZhCharMode(false); } else { // ZH → EN demo const demoSrc = "对我来说,学中文很难。"; const demoLit = "for me | learn Chinese | very difficult"; setDirection("ZH → EN"); setSourceText(demoSrc); // Default to char separators for Chinese demo const base = tokenize(demoSrc); const chars = explodeHanChars(base); setBoundaries(boundariesFromPunctuation(chars)); setZhCharMode(true); setLiteralText(demoLit); setHasDragged(false); prevLiteralRef.current = demoLit; } } const literalChunks = useMemo(() => splitLiteralChunks(literalText, groups), [literalText, groups]); // Pattern helpers function applyPatternOrder(roleSequence) { setOrder((prev) => { const seqIndex = new Map(roleSequence.map((r, i) => [r, i])); const withIdx = prev.map((id, i) => { const card = cards.find((c) => c.id === id); const rank = seqIndex.has(card?.role) ? seqIndex.get(card.role) : 999; return { id, i, rank }; }); withIdx.sort((a, b) => (a.rank - b.rank) || (a.i - b.i)); return withIdx.map((x) => x.id); }); } function toggleHighlightRoles(roleSequence) { setHighlightRoles((prev) => { const same = prev.length === roleSequence.length && prev.every((r, i) => r === roleSequence[i]); return same ? [] : roleSequence.slice(); }); } // ------- Render ------- return (

EN↔ZH Syntax Reorderer

Label chunks, apply patterns, and drag to restructure. Inputs won’t start drags; use the card background.

{/* Controls */}
{hasDragged && autoBuild && ( )}
e.target.files?.[0] && importJSON(e.target.files[0])} />
{/* Recipe strip — switches with direction */} {direction.startsWith("EN") ? (
Chinese “light recipe” (typical order):
{["TIME","PLACE","TOPIC","SUBJECT","MANNER","DEGREE","VERB","OBJECT","RESULT"].map((r) => ( {ROLE_LABEL[r]} ))}
) : (
English “light recipe” (typical clause order):
{["TIME","SUBJECT","AUX","DEGREE","MANNER","VERB","OBJECT","PLACE","RESULT"].map((r) => ( {r === "AUX" ? "Auxiliary" : (ROLE_LABEL[r] || r)} ))}
Notes: English RCs are post‑nominal ("the book [that I bought]"); many adverbials follow the verb phrase; existential uses “there is/are”.
)}
{/* What are roles? */}
What are “roles” and what do these buttons mean?
Roles are the function of a chunk (Time, Place, Topic/Stance, Subject, Verb, Object, etc.). Assigning roles lets patterns hint or reorder cards intelligently.
  • Auto‑build: when you change separators or the literal list, the cards below update automatically. After you drag manually, it pauses to preserve your custom order.
  • Rebuild now: forces an immediate rebuild (useful if Auto‑build is off or paused).
  • Apply order (on a pattern): reorders cards so roles that belong to the pattern come first, in the pattern’s typical order. It’s disabled until at least one card has one of those roles.
{/* Pattern cards (collapsed by default) */}
Pattern cards
{PATTERNS.map((p) => { const matchCount = cards.filter((c) => p.roles.includes(c.role)).length; return (
{p.title}
{SIMPLE_TITLES[p.id] || ''}
matches: {matchCount}
{p.hint}
Example: {p.exEn} → {p.exZh}
{p.roles.map((r) => ( {ROLE_LABEL[r]} ))}
); })}
{highlightRoles.length > 0 && (
Highlighting: {highlightRoles.map((r) => ROLE_LABEL[r]).join(" → ")}
)}
{/* Sentence + literal inputs */}

1) Source sentence