From f57e6b0896207061ef8bf575aa91d0976c0ac28f Mon Sep 17 00:00:00 2001 From: SeokWoo Kang Date: Mon, 20 Apr 2026 15:56:48 +0900 Subject: [PATCH 1/3] feat(demo): add bilingual training-free recommendation pipeline visualization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a static HTML/JS bundle under docs/demo/ that visualizes the four-module online pipeline (Interest Sketch → Semantic Search → Zero-Shot Rerank → MSCP Confidence) on a 26-recipe Food.com seed dataset. The bundle ships as React 18 + Babel standalone (no build step) and is bilingual via the ?lang= URL parameter (en/kr) with navigator.language fallback. URL parameters are preserved across language switches via URLSearchParams so attribution links (utm_source, etc.) survive toggles. Architecture is layered for separation of concerns: data/ holds the master taxonomy plus 26 seed recipes plus the four pipeline functions (buildSketch / semanticSearch / zeroShotRerank / computeConfidenceAndGround + runPipeline wrapper); src/i18n.js owns every user-facing string for both languages; src/components.jsx is locale-neutral and consumes a single L prop; src/app.jsx is the single entry that resolves the locale and mounts the App. JsonView HTML-escapes JSON before regex highlight to neutralize XSS via user query echo. Accessibility patches: form labels via htmlFor, aria-pressed/aria-checked on toggle buttons, role=tab/tablist with aria-current, role=region for the JSON output, hrefLang on lang switch, global :focus-visible ring. Responsive breakpoints at 1200/768/480 px; mobile stacks the sidebar above the main column and the tab bar becomes a horizontal scroller. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/demo/bright.css | 315 ++++++++++++++++++ docs/demo/data/pipeline.js | 313 ++++++++++++++++++ docs/demo/data/recipes.js | 36 +++ docs/demo/data/taxonomy.js | 31 ++ docs/demo/index.html | 29 ++ docs/demo/src/app.jsx | 16 + docs/demo/src/components.jsx | 602 +++++++++++++++++++++++++++++++++++ docs/demo/src/i18n.js | 321 +++++++++++++++++++ 8 files changed, 1663 insertions(+) create mode 100644 docs/demo/bright.css create mode 100644 docs/demo/data/pipeline.js create mode 100644 docs/demo/data/recipes.js create mode 100644 docs/demo/data/taxonomy.js create mode 100644 docs/demo/index.html create mode 100644 docs/demo/src/app.jsx create mode 100644 docs/demo/src/components.jsx create mode 100644 docs/demo/src/i18n.js diff --git a/docs/demo/bright.css b/docs/demo/bright.css new file mode 100644 index 0000000..c5e183c --- /dev/null +++ b/docs/demo/bright.css @@ -0,0 +1,315 @@ +/* ZeroAlign-Rec demo — bright high-contrast theme */ +:root { + --bg: #FFFFFF; + --bg-panel: #FFFFFF; + --bg-raised: #F5F5F4; + --bg-sunken: #FAFAF9; + --line: #E5E5E5; + --line-strong:#0A0A0A; + --ink: #0A0A0A; + --ink-muted: #404040; + --ink-subtle: #737373; + + --cyan: #0052CC; /* strong blue */ + --violet: #6B21A8; /* deep purple */ + --emerald: #047857; /* deep green */ + --amber: #B45309; /* dark amber */ + --rose: #B91C1C; /* strong red */ + + --accent: var(--cyan); + + --sans: "Inter", "Manrope", system-ui, -apple-system, sans-serif; + --mono: "JetBrains Mono", ui-monospace, Menlo, monospace; + --serif: "Newsreader", Georgia, serif; + + --r-sm: 6px; --r-md: 10px; --r-lg: 16px; + --shadow-md: 0 2px 0 #0A0A0A, 0 4px 12px -4px rgba(0,0,0,0.12); + --shadow-lg: 0 4px 0 #0A0A0A, 0 12px 30px -10px rgba(0,0,0,0.15); +} + +* { box-sizing: border-box; } +html, body { margin:0; padding:0; background: var(--bg); color: var(--ink); font-family: var(--sans); font-size: 13px; line-height: 1.5; -webkit-font-smoothing: antialiased; } +body { background-image: none; background-color: #FAFAF9; } +button { font-family: inherit; cursor: pointer; border: none; background: none; color: inherit; text-align: inherit; } +input, select, textarea { font-family: inherit; color: inherit; } + +/* Restore a focus ring for every interactive element. The button reset + * above stripped browser defaults; without this rule keyboard users have + * no idea where focus lives. The brutalism-friendly variant uses a black + * outline with a yellow halo so it sits naturally inside the design. */ +:where(button, a, [role="button"], [role="tab"], [role="radio"], input, textarea, select):focus-visible { + outline: 2px solid #0A0A0A; + outline-offset: 2px; + box-shadow: 0 0 0 4px #FEF3C7; +} + +.app { display: grid; grid-template-columns: 340px 1fr; min-height: 100vh; } + +/* ===== Sidebar ===== */ +.sidebar { background: #FFFFFF; border-right: 2px solid #0A0A0A; padding: 20px 18px; height: 100vh; position: sticky; top: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 22px; } +.sidebar::-webkit-scrollbar { width: 6px; } +.sidebar::-webkit-scrollbar-thumb { background: #0A0A0A; border-radius: 3px; } + +.brand { display: flex; align-items: center; gap: 12px; padding-bottom: 16px; border-bottom: 2px solid #0A0A0A; } +.brand-mark { width: 36px; height: 36px; border-radius: 10px; background: #0A0A0A; display: grid; place-items: center; position: relative; overflow: hidden; flex-shrink: 0; border: 2px solid #0A0A0A; } +.brand-mark::before { content: ""; position: absolute; inset: 0; background: radial-gradient(circle at 30% 30%, rgba(255,255,255,0.25), transparent 60%); } +.brand-mark svg { width: 20px; height: 20px; position: relative; z-index: 1; color: #FFFFFF; } +.brand-name { font-size: 14px; font-weight: 700; letter-spacing: -0.01em; color: #0A0A0A; } +.brand-sub { font-size: 10px; color: var(--ink-subtle); font-family: var(--mono); letter-spacing: 0.08em; text-transform: uppercase; } + +.sb-label { font-size: 10px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; color: #0A0A0A; margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center; } +.sb-section { display: flex; flex-direction: column; } + +/* Preset buttons */ +.preset-list { display: flex; flex-direction: column; gap: 4px; } +.preset-btn { padding: 9px 11px; border-radius: var(--r-md); background: transparent; border: 1.5px solid transparent; text-align: left; font-size: 12px; color: var(--ink-muted); transition: all 0.12s; } +.preset-btn:hover { background: var(--bg-raised); color: var(--ink); } +.preset-btn.active { background: #FEF3C7; border-color: #0A0A0A; color: #0A0A0A; font-weight: 600; } +.preset-btn .kw { font-family: var(--mono); font-size: 10px; color: var(--ink-subtle); margin-top: 2px; display: block; } +.preset-btn.active .kw { color: #92400E; } + +/* Query input */ +textarea.query { width: 100%; min-height: 60px; padding: 10px 12px; background: #FFFFFF; border: 1.5px solid #0A0A0A; border-radius: var(--r-md); color: var(--ink); font-size: 12px; resize: vertical; } +textarea.query:focus { outline: none; border-color: var(--cyan); box-shadow: 0 0 0 3px rgba(0,82,204,0.2); } + +/* Chip selectors */ +.chip-row { display: flex; flex-wrap: wrap; gap: 5px; } +.chip { padding: 4px 9px; font-size: 10px; border-radius: 999px; background: #FFFFFF; border: 1.5px solid #0A0A0A; color: #0A0A0A; font-family: var(--mono); letter-spacing: 0.02em; font-weight: 600; } +.chip:hover { background: #0A0A0A; color: #FFFFFF; } +.chip.pos { border-color: #047857; color: #FFFFFF; background: #047857; } +.chip.neg { border-color: #B91C1C; color: #FFFFFF; background: #B91C1C; } +.chip.filter { border-color: #6B21A8; color: #FFFFFF; background: #6B21A8; } + +/* Item selector */ +.item-list { display: flex; flex-direction: column; gap: 3px; max-height: 140px; overflow-y: auto; padding-right: 4px; } +.item-list::-webkit-scrollbar { width: 4px; } +.item-list::-webkit-scrollbar-thumb { background: #0A0A0A; } +.item-row { display: flex; align-items: center; gap: 8px; padding: 5px 7px; border-radius: 5px; font-size: 11px; cursor: pointer; transition: background 0.1s; } +.item-row:hover { background: var(--bg-raised); } +.item-dot { width: 8px; height: 8px; border-radius: 2px; background: var(--img, #888); flex-shrink: 0; border: 1px solid #0A0A0A; } +.item-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); } +.item-btn { font-size: 10px; padding: 1px 5px; border-radius: 3px; font-family: var(--mono); color: var(--ink-subtle); border: 1px solid transparent; font-weight: 600; } +.item-btn:hover { border-color: #0A0A0A; color: #0A0A0A; } +.item-btn.on-pos { background: #047857; color: #FFFFFF; border-color: #047857; } +.item-btn.on-neg { background: #B91C1C; color: #FFFFFF; border-color: #B91C1C; } + +/* Slider */ +.slider-row { display: flex; flex-direction: column; gap: 6px; } +.slider-head { display: flex; justify-content: space-between; font-size: 11px; } +.slider-value { font-family: var(--mono); color: var(--cyan); font-weight: 700; } +input[type="range"] { -webkit-appearance: none; width: 100%; height: 3px; background: #0A0A0A; border-radius: 2px; } +input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; background: var(--cyan); border: 2px solid #0A0A0A; cursor: pointer; } +input[type="range"]::-moz-range-thumb { width: 14px; height: 14px; border-radius: 50%; background: var(--cyan); border: 2px solid #0A0A0A; } + +/* Run button */ +.run-btn { width: 100%; padding: 12px; border-radius: var(--r-md); background: #0A0A0A; color: #FFFFFF; font-weight: 700; font-size: 13px; letter-spacing: 0.04em; transition: transform 0.1s, box-shadow 0.1s; box-shadow: 0 3px 0 #404040; display: flex; align-items: center; justify-content: center; gap: 6px; text-transform: uppercase; } +.run-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 0 #404040; background: var(--cyan); } +.run-btn:active { transform: translateY(2px); box-shadow: 0 1px 0 #404040; } +.run-btn svg { width: 14px; height: 14px; } +.run-btn.running { background: var(--bg-raised); color: var(--ink-muted); box-shadow: 0 0 0 1.5px #0A0A0A inset; } + +/* ===== Main ===== */ +.main { padding: 28px 36px 48px; min-width: 0; background: #FAFAF9; } + +.topbar { margin-bottom: 22px; display: flex; justify-content: space-between; align-items: flex-end; gap: 20px; flex-wrap: wrap; } +.topbar-title { font-family: var(--sans); font-size: 26px; font-weight: 700; letter-spacing: -0.02em; margin: 0 0 4px; color: #0A0A0A; } +.topbar-title .ac-cyan { color: var(--cyan); } +.topbar-title .ac-violet { color: var(--violet); } +.topbar-sub { font-family: var(--mono); font-size: 11px; color: var(--ink-subtle); letter-spacing: 0.02em; } + +.view-tabs { display: inline-flex; background: #FFFFFF; border: 1.5px solid #0A0A0A; border-radius: var(--r-md); padding: 3px; gap: 2px; } +.view-tab { padding: 7px 13px; font-size: 11px; font-weight: 600; border-radius: 7px; color: var(--ink-muted); letter-spacing: 0.02em; } +.view-tab.active { background: #0A0A0A; color: #FFFFFF; } + +/* ===== Pipeline visualization ===== */ +.pipeline { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 22px; position: relative; } +.pipeline::before { content: ""; position: absolute; top: 50%; left: 5%; right: 5%; height: 2px; background: #0A0A0A; opacity: 0.15; z-index: 0; } +.stage { background: #FFFFFF; border: 1.5px solid #0A0A0A; border-radius: var(--r-md); padding: 14px 14px 12px; position: relative; z-index: 1; transition: transform 0.15s, box-shadow 0.15s; box-shadow: 0 2px 0 #0A0A0A; } +.stage.active { background: #FEF3C7; box-shadow: 0 3px 0 #0A0A0A; transform: translateY(-1px); } +.stage.done { background: #D1FAE5; } +.stage-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } +.stage-num { font-family: var(--mono); font-size: 10px; color: var(--ink-subtle); letter-spacing: 0.08em; font-weight: 600; } +.stage-status { width: 10px; height: 10px; border-radius: 50%; background: #FFFFFF; border: 2px solid #0A0A0A; } +.stage.active .stage-status { background: var(--amber); animation: pulse 1s infinite; } +.stage.done .stage-status { background: var(--emerald); } +@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } } +.stage-title { font-size: 13px; font-weight: 700; color: #0A0A0A; } +.stage-sub { font-size: 10px; color: var(--ink-subtle); font-family: var(--mono); margin-top: 2px; margin-bottom: 10px; letter-spacing: 0.02em; } +.stage-metric { font-family: var(--mono); font-size: 10px; color: var(--ink-muted); } +.stage-metric .m-value { color: #0A0A0A; font-weight: 700; } + +/* Stage body shared */ +.module-panel { background: #FFFFFF; border: 1.5px solid #0A0A0A; border-radius: var(--r-lg); padding: 20px 22px; margin-bottom: 16px; box-shadow: 0 3px 0 #0A0A0A; } +.module-panel-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 18px; padding-bottom: 14px; border-bottom: 1.5px solid #0A0A0A; } +.module-panel-title { font-size: 16px; font-weight: 700; margin: 0; color: #0A0A0A; } +.module-panel-title .num { font-family: var(--mono); font-size: 11px; color: var(--cyan); letter-spacing: 0.08em; margin-right: 10px; font-weight: 700; background: #DBEAFE; padding: 3px 7px; border-radius: 4px; border: 1px solid var(--cyan); } +.module-panel-sub { font-size: 11px; color: var(--ink-subtle); font-family: var(--mono); letter-spacing: 0.02em; } + +.kvp { display: grid; grid-template-columns: 140px 1fr; gap: 10px; padding: 8px 0; border-bottom: 1px dashed #A3A3A3; font-size: 12px; } +.kvp:last-child { border-bottom: none; } +.kvp-key { color: var(--ink-subtle); font-family: var(--mono); font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; } +.kvp-val { color: #0A0A0A; } + +/* Sketch display */ +.sketch-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } +.sketch-block { } +.sketch-block h4 { font-size: 10px; color: #0A0A0A; text-transform: uppercase; letter-spacing: 0.12em; font-weight: 700; margin: 0 0 8px; } +.facet-row { display: flex; gap: 6px; padding: 4px 0; font-size: 11px; align-items: center; } +.facet-name { font-family: var(--mono); color: var(--ink-subtle); min-width: 100px; font-size: 10px; font-weight: 600; } + +/* Retrieval / candidate list */ +.candidate-split { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } +.candidate-col h4 { font-size: 10px; color: #0A0A0A; text-transform: uppercase; letter-spacing: 0.12em; font-weight: 700; margin: 0 0 10px; display: flex; justify-content: space-between; } +.candidate-col h4 .count { color: var(--cyan); font-family: var(--mono); font-weight: 700; } +.candidate-list { display: flex; flex-direction: column; gap: 4px; max-height: 360px; overflow-y: auto; padding-right: 4px; } +.candidate-list::-webkit-scrollbar { width: 5px; } +.candidate-list::-webkit-scrollbar-thumb { background: #0A0A0A; border-radius: 3px; } +.cand-item { display: grid; grid-template-columns: 22px 28px 1fr auto; gap: 10px; padding: 7px 9px; border-radius: var(--r-sm); align-items: center; background: #FAFAF9; border: 1.5px solid #E5E5E5; font-size: 11px; } +.cand-item:hover { border-color: #0A0A0A; background: #FFFFFF; } +.cand-rank { font-family: var(--mono); font-size: 10px; color: var(--ink-subtle); text-align: right; font-weight: 600; } +.cand-thumb { width: 28px; height: 20px; border-radius: 3px; background: var(--img); position: relative; overflow: hidden; border: 1px solid #0A0A0A; } +.cand-thumb::after { content: ""; position: absolute; inset: 0; background: radial-gradient(circle at 30% 20%, rgba(255,255,255,0.3), transparent 60%); } +.cand-title { color: #0A0A0A; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; } +.cand-meta { font-family: var(--mono); color: var(--ink-subtle); font-size: 10px; } +.cand-score { font-family: var(--mono); font-size: 10px; color: var(--cyan); font-weight: 700; } +.cand-item.dropped { opacity: 0.5; background: #FEE2E2; } +.cand-item.dropped .cand-title { text-decoration: line-through; color: var(--ink-muted); } +.cand-item.dropped .cand-score { color: var(--rose); } + +/* Rerank passes */ +.passes-grid { display: flex; flex-direction: column; gap: 10px; } +.pass-row { display: grid; grid-template-columns: 80px 1fr; gap: 12px; align-items: center; font-family: var(--mono); font-size: 11px; } +.pass-label { color: #0A0A0A; font-size: 10px; letter-spacing: 0.05em; text-transform: uppercase; font-weight: 700; } +.pass-tokens { display: flex; gap: 4px; flex-wrap: wrap; } +.pass-tok { padding: 3px 8px; border-radius: 4px; background: #FFFFFF; border: 1.5px solid #0A0A0A; color: #0A0A0A; font-size: 10px; min-width: 24px; text-align: center; font-weight: 600; } +.pass-tok.highlight { background: #DBEAFE; border-color: var(--cyan); color: var(--cyan); } +.pass-tok.top { background: #047857; border-color: #047857; color: #FFFFFF; } + +/* Vote aggregation bars */ +.votes-grid { display: flex; flex-direction: column; gap: 5px; margin-top: 14px; } +.vote-row { display: grid; grid-template-columns: 28px 1fr 60px 50px; gap: 10px; align-items: center; font-size: 11px; font-family: var(--mono); } +.vote-idx { text-align: right; color: var(--ink-subtle); font-size: 10px; font-weight: 600; } +.vote-bar { height: 16px; background: #FFFFFF; border: 1.5px solid #0A0A0A; border-radius: 3px; position: relative; overflow: hidden; } +.vote-bar-fill { height: 100%; background: var(--cyan); border-radius: 0; } +.vote-bar-label { position: absolute; left: 8px; top: 50%; transform: translateY(-50%); font-size: 10px; color: #0A0A0A; white-space: nowrap; font-weight: 600; z-index: 1; mix-blend-mode: difference; filter: invert(1); } +.vote-mscp { color: #0A0A0A; text-align: right; font-weight: 700; } +.vote-band { text-align: right; font-size: 9px; letter-spacing: 0.08em; font-weight: 700; } +.band-HIGH { color: var(--emerald); } +.band-MEDIUM { color: var(--amber); } +.band-LOW { color: var(--rose); } + +/* Final delivery cards */ +.final-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; } +.final-card { background: #FFFFFF; border: 1.5px solid #0A0A0A; border-radius: var(--r-lg); overflow: hidden; position: relative; cursor: pointer; transition: transform 0.15s, box-shadow 0.15s; display: flex; flex-direction: column; box-shadow: 0 3px 0 #0A0A0A; } +.final-card:hover { transform: translateY(-3px); box-shadow: 0 6px 0 #0A0A0A; } +.final-card.rank-1 { background: #FEF3C7; } +.final-img { aspect-ratio: 16 / 9; background: linear-gradient(135deg, var(--img-a), var(--img-b)); position: relative; border-bottom: 1.5px solid #0A0A0A; } +.final-img::after { content: ""; position: absolute; inset: 0; background: radial-gradient(circle at 30% 20%, rgba(255,255,255,0.25), transparent 60%); } +.final-rank { position: absolute; top: 10px; left: 10px; background: #0A0A0A; color: #FFFFFF; padding: 4px 9px; border-radius: 4px; font-family: var(--mono); font-size: 10px; font-weight: 700; letter-spacing: 0.05em; z-index: 2; } +.final-band { position: absolute; top: 10px; right: 10px; padding: 4px 9px; border-radius: 4px; font-family: var(--mono); font-size: 10px; letter-spacing: 0.08em; z-index: 2; font-weight: 700; border: 1.5px solid #0A0A0A; } +.final-band.HIGH { background: #047857; color: #FFFFFF; } +.final-band.MEDIUM { background: var(--amber); color: #FFFFFF; } +.final-band.LOW { background: var(--rose); color: #FFFFFF; } +.final-body { padding: 14px 16px 16px; display: flex; flex-direction: column; gap: 9px; } +.final-title { font-size: 15px; font-weight: 700; margin: 0; line-height: 1.25; color: #0A0A0A; } +.final-meta { font-family: var(--mono); font-size: 10px; color: var(--ink-subtle); letter-spacing: 0.02em; } +.final-rationale { font-size: 11.5px; color: var(--ink); line-height: 1.45; } +.final-matched { display: flex; flex-wrap: wrap; gap: 4px; } +.matched-chip { padding: 2px 7px; border-radius: 999px; background: #EDE9FE; color: var(--violet); font-family: var(--mono); font-size: 9px; letter-spacing: 0.02em; border: 1px solid var(--violet); font-weight: 600; } +.final-bottom { display: flex; justify-content: space-between; align-items: center; padding-top: 10px; border-top: 1.5px dashed #A3A3A3; font-family: var(--mono); font-size: 10px; color: var(--ink-subtle); } +.final-sid { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink-muted); font-size: 9.5px; } + +/* JSON preview — rendered as
 for semantics; reset default 
 margin. */
+.json-block { margin: 0; background: #0A0A0A; border: 1.5px solid #0A0A0A; border-radius: var(--r-md); padding: 16px 18px; font-family: var(--mono); font-size: 11px; color: #FAFAF9; white-space: pre-wrap; word-break: break-word; max-height: 400px; overflow-y: auto; line-height: 1.6; }
+.json-block code { font-family: inherit; }
+.json-key { color: #67E8F9; }
+.json-str { color: #86EFAC; }
+.json-num { color: #FCD34D; }
+
+/* Empty / placeholder */
+.empty { padding: 48px 20px; text-align: center; color: var(--ink-subtle); font-size: 13px; font-style: italic; }
+
+/* Flow connecting lines */
+.flow-arrow { display: grid; place-items: center; color: var(--ink-subtle); font-size: 18px; }
+
+/* Toggle */
+.toggle-row { display: flex; align-items: center; justify-content: space-between; padding: 6px 0; }
+.toggle-row .t-label { font-size: 12px; }
+.toggle { width: 32px; height: 18px; background: #FFFFFF; border: 1.5px solid #0A0A0A; border-radius: 9px; position: relative; transition: background 0.15s; flex-shrink: 0; }
+.toggle::after { content: ""; position: absolute; top: 1px; left: 1px; width: 12px; height: 12px; background: #0A0A0A; border-radius: 50%; transition: transform 0.15s, background 0.15s; }
+.toggle.on { background: var(--cyan); border-color: var(--cyan); }
+.toggle.on::after { transform: translateX(14px); background: #FFFFFF; }
+
+/* Metrics strip */
+.metrics { display: flex; gap: 20px; margin-bottom: 16px; padding: 12px 16px; background: #FFFFFF; border: 1.5px solid #0A0A0A; border-radius: var(--r-md); font-family: var(--mono); font-size: 11px; flex-wrap: wrap; box-shadow: 0 2px 0 #0A0A0A; }
+.metric { display: flex; flex-direction: column; gap: 2px; }
+.metric-label { color: var(--ink-subtle); text-transform: uppercase; letter-spacing: 0.08em; font-size: 9px; font-weight: 700; }
+.metric-val { color: #0A0A0A; font-size: 12px; font-weight: 700; }
+.metric-val.accent { color: var(--cyan); }
+
+.muted { color: var(--ink-subtle); }
+
+
+/* ===== Tab bar ===== */
+.tabbar { display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px; margin-bottom: 20px; padding: 6px; background: #FFFFFF; border: 1.5px solid #0A0A0A; border-radius: var(--r-md); box-shadow: 0 2px 0 #0A0A0A; }
+.tab { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; padding: 10px 12px; border-radius: 8px; text-align: left; transition: background 0.15s, color 0.15s; min-width: 0; border: 1.5px solid transparent; }
+.tab:hover:not(.disabled):not(.active) { background: var(--bg-raised); }
+.tab.active { background: #0A0A0A; border-color: #0A0A0A; }
+.tab.active .tab-num { color: #FCD34D; }
+.tab.active .tab-label { color: #FFFFFF; }
+.tab.active .tab-sub { color: #A3A3A3; }
+.tab.disabled { opacity: 0.4; cursor: not-allowed; }
+.tab-num { font-family: var(--mono); font-size: 9px; letter-spacing: 0.12em; color: var(--ink-subtle); text-transform: uppercase; font-weight: 700; }
+.tab-label { font-size: 12.5px; font-weight: 700; color: #0A0A0A; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
+.tab-sub { font-size: 10px; color: var(--ink-subtle); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
+
+/* ===== Overview grid ===== */
+.overview-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-top: 18px; }
+.overview-card { width: 100%; background: #FFFFFF; border: 1.5px solid #0A0A0A; border-radius: var(--r-md); padding: 18px 18px 14px; cursor: pointer; transition: transform 0.15s, box-shadow 0.15s; display: flex; flex-direction: column; gap: 10px; text-align: left; box-shadow: 0 3px 0 #0A0A0A; }
+.overview-card:hover { transform: translateY(-2px); box-shadow: 0 5px 0 #0A0A0A; background: #FEF3C7; }
+.oc-num { font-family: var(--mono); font-size: 11px; letter-spacing: 0.08em; color: var(--cyan); font-weight: 700; }
+.oc-title { font-size: 15px; font-weight: 700; color: #0A0A0A; }
+.oc-body { display: flex; flex-direction: column; gap: 3px; font-family: var(--mono); font-size: 10.5px; color: var(--ink-muted); letter-spacing: 0.02em; }
+.oc-foot { margin-top: auto; padding-top: 10px; border-top: 1.5px dashed #A3A3A3; font-size: 11px; color: #0A0A0A; font-weight: 700; }
+
+/* ===== Language switch ===== */
+.lang-switch { display: inline-flex; align-items: center; justify-content: center; min-width: 44px; height: 32px; padding: 0 12px; border: 1.5px solid #0A0A0A; border-radius: 999px; color: #0A0A0A; font-family: var(--mono); font-size: 11px; letter-spacing: 0.1em; text-decoration: none; transition: all 0.15s; background: #FFFFFF; font-weight: 700; }
+.lang-switch:hover { color: #FFFFFF; background: #0A0A0A; }
+
+@media (max-width: 1200px) {
+  .tabbar { grid-template-columns: repeat(4, 1fr); }
+  .overview-grid { grid-template-columns: repeat(2, 1fr); }
+}
+
+/* Tablet — stack the sidebar above the main column. */
+@media (max-width: 768px) {
+  .app { grid-template-columns: 1fr; }
+  .sidebar {
+    height: auto; position: relative;
+    border-right: none; border-bottom: 2px solid #0A0A0A;
+  }
+  .pipeline { grid-template-columns: repeat(2, 1fr); }
+  .pipeline::before { display: none; }
+  .candidate-split { grid-template-columns: 1fr; }
+  .sketch-grid { grid-template-columns: 1fr; }
+  .kvp { grid-template-columns: 1fr; gap: 4px; }
+  .main { padding: 20px 18px 32px; }
+  .topbar-title { font-size: 22px; }
+}
+
+/* Mobile — tabbar becomes a horizontal scroller, single-column everywhere. */
+@media (max-width: 480px) {
+  .tabbar {
+    display: flex; overflow-x: auto;
+    scroll-snap-type: x mandatory;
+    -webkit-overflow-scrolling: touch;
+  }
+  .tab { flex: 0 0 140px; scroll-snap-align: start; }
+  .overview-grid { grid-template-columns: 1fr; }
+  .pipeline { grid-template-columns: 1fr; }
+  .final-cards { grid-template-columns: 1fr; }
+  .vote-row { grid-template-columns: 24px 1fr 60px; }
+  .vote-band { display: none; }
+  .topbar { flex-direction: column; align-items: flex-start; }
+  .metrics { gap: 14px; }
+}
diff --git a/docs/demo/data/pipeline.js b/docs/demo/data/pipeline.js
new file mode 100644
index 0000000..4f1dc64
--- /dev/null
+++ b/docs/demo/data/pipeline.js
@@ -0,0 +1,313 @@
+// ZeroAlign-Rec online pipeline — 4 modules.
+// Depends on: window.TAXONOMY (data/taxonomy.js), window.RECIPES (data/recipes.js)
+
+// ---------- Module 2.1: Interest-sketch extraction ----------
+
+// Keyword → taxonomy mapping. Order matters only for readability.
+const KEYWORD_MAP = [
+  { kws: ["spicy","spice","hot","heat"],             facet: "flavor_profile", value: "spicy" },
+  { kws: ["creamy","rich"],                          facet: "flavor_profile", value: "rich" },
+  { kws: ["fresh","light"],                          facet: "flavor_profile", value: "fresh" },
+  { kws: ["umami","savory"],                         facet: "flavor_profile", value: "umami" },
+  { kws: ["sweet","dessert"],                        facet: "flavor_profile", value: "sweet" },
+  { kws: ["herb","herby"],                           facet: "flavor_profile", value: "herbaceous" },
+  { kws: ["hearty","comfort","cozy"],                facet: "flavor_profile", value: "umami" },
+  { kws: ["not too heavy","not heavy","light"],      facet: "effort",         value: "weeknight" },
+  { kws: ["weeknight","weekday","fast","quick"],     facet: "effort",         value: "quick" },
+  { kws: ["weekend","project","impressive"],         facet: "effort",         value: "weekend-project" },
+  { kws: ["vegetarian"],                             facet: "dietary_style",  value: "vegetarian" },
+  { kws: ["vegan"],                                  facet: "dietary_style",  value: "vegan" },
+  { kws: ["no gluten","gluten-free","gluten free"],  facet: "dietary_style",  value: "gluten-free" },
+  { kws: ["lunch"],                                  facet: "meal_type",      value: "lunch" },
+  { kws: ["dinner"],                                 facet: "meal_type",      value: "dinner" },
+  { kws: ["breakfast"],                              facet: "meal_type",      value: "breakfast" },
+  { kws: ["soup"],                                   facet: "dish_type",      value: "soup" },
+  { kws: ["noodle","noodles"],                       facet: "dish_type",      value: "noodles" },
+  { kws: ["bowl"],                                   facet: "dish_type",      value: "bowl" },
+];
+
+const recipeById = id => window.RECIPES.find(r => r.id === id);
+
+const pushUnique = (obj, key, value) => {
+  if (!obj[key]) obj[key] = [];
+  if (!obj[key].includes(value)) obj[key].push(value);
+};
+
+window.buildSketch = function (query, liked = [], disliked = [], hardFilters = {}) {
+  const q = (query || "").toLowerCase();
+  const sketch = {
+    summary: query || "(no free-text query)",
+    positive_facets: {},
+    negative_facets: {},
+    hard_filters: { ...hardFilters },
+    ambiguity_notes: [],
+  };
+
+  // Free-text → facets
+  for (const m of KEYWORD_MAP) {
+    if (m.kws.some(k => q.includes(k))) pushUnique(sketch.positive_facets, m.facet, m.value);
+  }
+
+  // Liked items → positive facets
+  for (const id of liked) {
+    const r = recipeById(id); if (!r) continue;
+    pushUnique(sketch.positive_facets, "cuisine",   r.cuisine);
+    pushUnique(sketch.positive_facets, "dish_type", r.dish);
+    for (const f of r.flavors) pushUnique(sketch.positive_facets, "flavor_profile", f);
+  }
+
+  // Disliked items → negative flavor facets
+  for (const id of disliked) {
+    const r = recipeById(id); if (!r) continue;
+    for (const f of r.flavors) pushUnique(sketch.negative_facets, "flavor_profile", f);
+  }
+
+  // Ambiguity notes
+  if (!query || query.length < 10) {
+    sketch.ambiguity_notes.push("sparse query — relying on liked items");
+  }
+  if (q.includes("not too heavy") && q.includes("hearty")) {
+    sketch.ambiguity_notes.push("'hearty but not heavy' — ambiguous richness target");
+  }
+
+  return sketch;
+};
+
+
+// ---------- Module 2.2: Semantic search + CPU hard filter ----------
+
+// How each facet is matched against a recipe field.
+const FACET_MATCHERS = {
+  cuisine:        (r, v) => r.cuisine === v,
+  dish_type:      (r, v) => r.dish === v,
+  meal_type:      (r, v) => r.meal === v,
+  effort:         (r, v) => r.effort === v,
+  flavor_profile: (r, v) => r.flavors.includes(v),
+  cooking_method: (r, v) => r.method === v,
+  dietary_style:  (r, v) => r.dietary.includes(v),
+};
+
+const FACET_WEIGHTS = {
+  flavor_profile: 0.30,
+  cuisine:        0.25,
+  dish_type:      0.20,
+  effort:         0.15,
+  meal_type:      0.10,
+};
+const DEFAULT_FACET_WEIGHT = 0.08;
+const NEGATIVE_PENALTY = 0.10;
+
+// Deterministic pseudo-random jitter so scores feel vector-y but stay reproducible.
+const noiseFor = id => ((id * 9301 + 49297) % 233280) / 233280 * 0.04;
+
+// Per-recipe score against the sketch.
+function scoreRecipe(recipe, sketch) {
+  let score = 0;
+  const matched = [];
+
+  for (const [facet, values] of Object.entries(sketch.positive_facets)) {
+    const w = FACET_WEIGHTS[facet] ?? DEFAULT_FACET_WEIGHT;
+    const match = FACET_MATCHERS[facet];
+    if (!match) continue;
+    for (const v of values) {
+      if (match(recipe, v)) { score += w; matched.push(`${facet}=${v}`); }
+    }
+  }
+
+  for (const [facet, values] of Object.entries(sketch.negative_facets || {})) {
+    const match = FACET_MATCHERS[facet];
+    if (!match) continue;
+    for (const v of values) if (match(recipe, v)) score -= NEGATIVE_PENALTY;
+  }
+
+  return { ...recipe, score: Math.max(0, score + noiseFor(recipe.id)), matched };
+}
+
+// Each entry returns either null (keep) or a reason string (drop).
+const HARD_FILTERS = {
+  dietary_style: (r, values) => {
+    for (const d of values) if (!r.dietary.includes(d)) return `missing dietary:${d}`;
+    return null;
+  },
+  cuisine:  (r, values) => values.includes(r.cuisine) ? null : `cuisine not in ${values.join(",")}`,
+  max_time: (r, limit)  => r.time > limit ? `time ${r.time}>${limit}` : null,
+};
+
+function applyHardFilters(candidates, hardFilters) {
+  const survivors = [];
+  const dropped   = [];
+  for (const r of candidates) {
+    let reason = null;
+    for (const [key, spec] of Object.entries(hardFilters)) {
+      const fn = HARD_FILTERS[key];
+      if (!fn) continue;
+      reason = fn(r, spec);
+      if (reason) break;
+    }
+    if (reason) dropped.push({ id: r.id, title: r.title, reason });
+    else        survivors.push(r);
+  }
+  return { survivors, dropped };
+}
+
+// Approximate collaborative-filter features derived from liked history.
+function attachCfFeatures(candidates, likedIds) {
+  const likedRecipes = likedIds.map(recipeById).filter(Boolean);
+  for (const r of candidates) {
+    r.popularity  = r.pop;
+    r.cooccurrence = likedRecipes.filter(l => l.cuisine === r.cuisine || l.dish === r.dish).length;
+  }
+}
+
+window.semanticSearch = function (sketch, { topN = 100, topK = 30 } = {}) {
+  const top100 = window.RECIPES
+    .map(r => scoreRecipe(r, sketch))
+    .sort((a, b) => b.score - a.score)
+    .slice(0, topN);
+
+  const { survivors, dropped } = applyHardFilters(top100, sketch.hard_filters || {});
+  const top30 = survivors.slice(0, topK);
+  attachCfFeatures(top30, sketch._liked || []);
+
+  return { top100, top30, dropped };
+};
+
+
+// ---------- Module 2.3: Zero-shot rerank with K order-perturbed passes ----------
+
+const RERANK_COOC_WEIGHT = 0.20;
+const RERANK_POP_WEIGHT  = 0.15;
+
+function buildRationale(candidate) {
+  const keyMatches = candidate.matched.slice(0, 2);
+  const popTag =
+    candidate.popularity > 0.75 ? "Strong popularity signal." :
+    candidate.popularity < 0.60 ? "Niche but aligned." :
+                                  "Balanced fit.";
+  return `Matches ${keyMatches.join(", ") || "taxonomy anchors"}. ${popTag}`;
+}
+
+window.zeroShotRerank = function (candidates, _sketch, { passes = 5 } = {}) {
+  const basis = candidates.map((c, idx) => ({
+    idx, id: c.id,
+    base: c.score * (1 + RERANK_COOC_WEIGHT * c.cooccurrence) * (1 + RERANK_POP_WEIGHT * c.popularity),
+  }));
+
+  // Each pass jitters basis scores with a pass-specific seed.
+  const passResults = [];
+  for (let p = 0; p < passes; p++) {
+    const seed = (p + 1) * 1.37;
+    const perturbed = basis
+      .map(b => ({ ...b, jittered: b.base + Math.sin(b.idx * seed) * 0.04 + Math.cos(b.id * seed) * 0.02 }))
+      .sort((a, b) => b.jittered - a.jittered);
+    passResults.push(perturbed.slice(0, 10).map(x => x.idx + 1)); // 1-indexed
+  }
+
+  // Build rationales once — shared across all passes.
+  const rationales  = {};
+  const matchedPrefs = {};
+  candidates.forEach((c, idx) => {
+    rationales[idx + 1]   = buildRationale(c);
+    matchedPrefs[idx + 1] = c.matched.slice(0, 3);
+  });
+
+  return { passes: passResults, rationales, matchedPrefs };
+};
+
+
+// ---------- Module 2.4: MSCP confidence + grounding ----------
+
+// DCG-style weight: rank 0 gets 1.0, rank 1 gets 0.63, etc.
+const dcgWeight = pos => 1 / Math.log2(pos + 2);
+
+const mscpBand = mscp => mscp >= 0.8 ? "HIGH" : mscp >= 0.5 ? "MEDIUM" : "LOW";
+
+function buildSid(recipe) {
+  const flavor = recipe.flavors?.[0] ?? "default";
+  return `SID::${recipe.cuisine.toLowerCase()}::${recipe.dish}::${flavor}::${recipe.id}`;
+}
+
+window.computeConfidenceAndGround = function (candidates, rerankResult, topK = 3) {
+  // 1. Aggregate DCG-weighted votes across passes.
+  const voteMap = {};
+  for (const pass of rerankResult.passes) {
+    pass.forEach((idx, pos) => {
+      voteMap[idx] = (voteMap[idx] || 0) + dcgWeight(pos);
+    });
+  }
+  const allVotes = Object.entries(voteMap)
+    .map(([idx, votes]) => ({ idx: +idx, votes }))
+    .sort((a, b) => b.votes - a.votes);
+
+  // 2. For the top-k winners, compute MSCP = fraction of passes in which they
+  //    appeared inside the top-k slots.
+  const totalPasses = rerankResult.passes.length;
+  const items = allVotes
+    .slice(0, topK)
+    .map((entry, rank) => {
+      const recipe = candidates[entry.idx - 1];
+      if (!recipe) return null;
+
+      const appearTopK = rerankResult.passes.filter(p => p.slice(0, topK).includes(entry.idx)).length;
+      const bootstrap  = rerankResult.passes.filter(p => p.includes(entry.idx)).length;
+      const mscp       = appearTopK / totalPasses;
+
+      return {
+        rank:               rank + 1,
+        idx:                entry.idx,
+        recipe,
+        rationale:          rerankResult.rationales[entry.idx],
+        matched_preferences: rerankResult.matchedPrefs[entry.idx],
+        mscp,
+        confidence_band:    mscpBand(mscp),
+        bootstrap_support:  bootstrap,
+        mapping_mode:       "direct (id_map.jsonl)",
+        sid:                buildSid(recipe),
+      };
+    })
+    .filter(Boolean);
+
+  return { items, allVotes };
+};
+
+
+// ---------- End-to-end pipeline ----------
+
+// Simulated wall-clock latencies so the UI can animate stage transitions.
+function simulatedTimings() {
+  const rand = n => Math.round(Math.random() * n);
+  return {
+    simSketch:     180 + rand(40),
+    simRetrieval:   35 + rand(10),
+    simRerank:     720 + rand(120),
+    simConfidence:   8 + rand(4),
+  };
+}
+
+window.runPipeline = function ({ query, liked, disliked, hardFilters, topK = 3 }) {
+  const t0 = performance.now();
+  const sketch = window.buildSketch(query, liked, disliked, hardFilters);
+  sketch._liked = liked; // passed through to retrieval for co-occurrence features
+  const t1 = performance.now();
+
+  const search = window.semanticSearch(sketch);
+  const t2 = performance.now();
+
+  const rerank = window.zeroShotRerank(search.top30, sketch, { passes: 5 });
+  const t3 = performance.now();
+
+  const conf = window.computeConfidenceAndGround(search.top30, rerank, topK);
+  const t4 = performance.now();
+
+  return {
+    sketch, search, rerank, conf,
+    timings: {
+      sketch:     t1 - t0,
+      retrieval:  t2 - t1,
+      rerank:     t3 - t2,
+      confidence: t4 - t3,
+      total:      t4 - t0,
+      ...simulatedTimings(),
+    },
+  };
+};
diff --git a/docs/demo/data/recipes.js b/docs/demo/data/recipes.js
new file mode 100644
index 0000000..99470fa
--- /dev/null
+++ b/docs/demo/data/recipes.js
@@ -0,0 +1,36 @@
+// Food.com seed recipes. Each recipe is tagged against window.TAXONOMY.
+// Field shape:
+//   id, title, img, time (min), calories, cuisine,
+//   dietary[], dish, meal, method, flavors[], effort, pop (0..1)
+
+const R = (id, title, img, time, calories, cuisine, dietary, dish, meal, method, flavors, effort, pop) =>
+  ({ id, title, img, time, calories, cuisine, dietary, dish, meal, method, flavors, effort, pop });
+
+window.RECIPES = [
+  R(101, "Miso-Glazed Salmon Rice Bowl",    "#e8b084",  25, 540, "Japanese",       ["pescatarian"],                                "bowl",      "dinner",    "seared",   ["umami","sweet"],             "weeknight",       0.88),
+  R(102, "Tuscan White Bean Stew",          "#b9c39a",  45, 420, "Italian",        ["vegan","vegetarian"],                         "stew",      "dinner",    "stewed",   ["herbaceous","umami"],        "weeknight",       0.62),
+  R(103, "Szechuan Dan Dan Noodles",        "#c25a3a",  30, 610, "Chinese",        ["omnivore"],                                   "noodles",   "dinner",    "simmered", ["spicy","umami"],             "weeknight",       0.81),
+  R(104, "Lemon Ricotta Pancakes",          "#f3d9a4",  20, 480, "American",       ["vegetarian"],                                 "dessert",   "breakfast", "fried",    ["sweet","tangy"],             "quick",           0.74),
+  R(105, "Harissa Roasted Cauliflower",     "#d4744a",  35, 320, "Moroccan",       ["vegan","vegetarian","gluten-free"],           "side",      "side",      "roasted",  ["spicy","smoky"],             "weeknight",       0.58),
+  R(106, "Classic Beef Bourguignon",        "#8b4a2f", 180, 720, "French",         ["omnivore"],                                   "stew",      "dinner",    "stewed",   ["umami","rich"],              "slow-cook",       0.69),
+  R(107, "Thai Green Curry with Tofu",      "#9bb67a",  35, 520, "Thai",           ["vegetarian","vegan"],                         "curry",     "dinner",    "simmered", ["spicy","creamy"],            "weeknight",       0.79),
+  R(108, "Burrata & Heirloom Tomato Plate", "#e8a89a",  10, 380, "Italian",        ["vegetarian","gluten-free"],                   "appetizer", "lunch",     "raw",      ["fresh","creamy"],            "quick",           0.71),
+  R(109, "Korean Kimchi Jjigae",            "#b83d2c",  40, 450, "Korean",         ["omnivore"],                                   "stew",      "dinner",    "stewed",   ["spicy","umami"],             "weeknight",       0.76),
+  R(110, "Wild Mushroom Risotto",           "#c9a678",  40, 580, "Italian",        ["vegetarian","gluten-free"],                   "rice-dish", "dinner",    "simmered", ["umami","creamy"],            "weeknight",       0.84),
+  R(111, "Shakshuka with Feta",             "#d86a3f",  25, 410, "Middle-Eastern", ["vegetarian"],                                 "stew",      "breakfast", "simmered", ["spicy","umami"],             "quick",           0.72),
+  R(112, "Vietnamese Pho Bo",               "#a87c4f", 240, 540, "Vietnamese",     ["omnivore"],                                   "soup",      "dinner",    "simmered", ["umami","herbaceous"],        "slow-cook",       0.77),
+  R(113, "Avocado Farro Grain Bowl",        "#a8b87a",  20, 460, "Californian",    ["vegetarian","vegan"],                         "bowl",      "lunch",     "no-cook",  ["fresh","herbaceous"],        "quick",           0.65),
+  R(114, "Carne Asada Tacos",               "#c25e3a",  30, 580, "Mexican",        ["gluten-free","omnivore"],                     "wrap",      "dinner",    "grilled",  ["smoky","tangy"],             "weeknight",       0.86),
+  R(115, "Dark Chocolate Olive Oil Cake",   "#5a3a2c",  55, 520, "Italian",        ["vegetarian"],                                 "dessert",   "dessert",   "baked",    ["sweet","rich"],              "weekend-project", 0.70),
+  R(116, "Crispy Pork Banh Mi",             "#d89a5a",  45, 620, "Vietnamese",     ["omnivore"],                                   "sandwich",  "lunch",     "fried",    ["tangy","umami"],             "weeknight",       0.73),
+  R(117, "Red Lentil Dal",                  "#e09a3a",  30, 340, "Indian",         ["vegan","vegetarian","gluten-free"],           "stew",      "dinner",    "simmered", ["spicy","creamy"],            "weeknight",       0.60),
+  R(118, "Seared Scallops w/ Brown Butter", "#f0d9b4",  20, 380, "French",         ["pescatarian","gluten-free"],                  "appetizer", "dinner",    "seared",   ["umami","rich"],              "quick",           0.63),
+  R(119, "Smashed Cucumber Salad",          "#7ba87a",  10, 120, "Chinese",        ["vegan","vegetarian","gluten-free"],           "salad",     "side",      "no-cook",  ["spicy","tangy","fresh"],     "quick",           0.55),
+  R(120, "Sourdough Margherita Pizza",      "#d89a6a",  90, 680, "Italian",        ["vegetarian"],                                 "pizza",     "dinner",    "baked",    ["umami","fresh"],             "weekend-project", 0.89),
+  R(121, "Spicy Peanut Soba Noodles",       "#c9834a",  20, 520, "Thai",           ["vegetarian","vegan"],                         "noodles",   "lunch",     "no-cook",  ["spicy","creamy"],            "quick",           0.68),
+  R(122, "Roast Chicken with Lemon",        "#e0b47a",  75, 620, "French",         ["gluten-free","omnivore"],                     "roast",     "dinner",    "roasted",  ["herbaceous","umami"],        "weekend-project", 0.75),
+  R(123, "Matcha Tiramisu",                 "#9bb07a",  60, 460, "Japanese",       ["vegetarian"],                                 "dessert",   "dessert",   "no-cook",  ["sweet","creamy"],            "weekend-project", 0.66),
+  R(124, "Chickpea Shawarma Wrap",          "#d8a74a",  25, 480, "Middle-Eastern", ["vegan","vegetarian"],                         "wrap",      "lunch",     "roasted",  ["spicy","tangy"],             "quick",           0.61),
+  R(125, "Creamy Tomato Pasta",             "#d87a5a",  25, 560, "Italian",        ["vegetarian"],                                 "pasta",     "dinner",    "simmered", ["creamy","umami"],            "weeknight",       0.82),
+  R(126, "Japchae Glass Noodles",           "#b47a3a",  35, 500, "Korean",         ["vegetarian"],                                 "noodles",   "dinner",    "fried",    ["umami","sweet"],             "weeknight",       0.64),
+];
diff --git a/docs/demo/data/taxonomy.js b/docs/demo/data/taxonomy.js
new file mode 100644
index 0000000..be41872
--- /dev/null
+++ b/docs/demo/data/taxonomy.js
@@ -0,0 +1,31 @@
+// Master taxonomy — the vocabulary every downstream stage is guarded by.
+// Values here are the ONLY strings that can appear in sketches / hard filters.
+
+window.TAXONOMY = {
+  cuisine: [
+    "Italian", "Japanese", "Chinese", "Korean", "Thai", "Vietnamese",
+    "Indian", "Mexican", "French", "American", "Middle-Eastern",
+    "Moroccan", "Californian",
+  ],
+  dietary_style: [
+    "vegetarian", "vegan", "gluten-free", "pescatarian", "dairy-free", "omnivore",
+  ],
+  dish_type: [
+    "bowl", "soup", "stew", "noodles", "curry", "roast", "salad",
+    "sandwich", "dessert", "pizza", "pasta", "rice-dish", "wrap", "appetizer",
+  ],
+  meal_type: [
+    "breakfast", "brunch", "lunch", "dinner", "snack", "side", "dessert",
+  ],
+  cooking_method: [
+    "roasted", "grilled", "stewed", "fried", "baked",
+    "seared", "raw", "steamed", "simmered", "no-cook",
+  ],
+  flavor_profile: [
+    "spicy", "sweet", "umami", "tangy", "herbaceous",
+    "smoky", "creamy", "fresh", "rich",
+  ],
+  effort: [
+    "quick", "weeknight", "weekend-project", "slow-cook",
+  ],
+};
diff --git a/docs/demo/index.html b/docs/demo/index.html
new file mode 100644
index 0000000..bb50d61
--- /dev/null
+++ b/docs/demo/index.html
@@ -0,0 +1,29 @@
+
+
+
+  
+  ZeroAlign-Rec
+  
+  
+  
+  
+  
+  
+
+
+  
+ + + + + + + + + + + + + diff --git a/docs/demo/src/app.jsx b/docs/demo/src/app.jsx new file mode 100644 index 0000000..7b2c1dc --- /dev/null +++ b/docs/demo/src/app.jsx @@ -0,0 +1,16 @@ +/* Single entry — picks language from ?lang= URL param, then navigator.language. + * Accepts BCP-47 alias `?lang=ko` for the Korean bundle. */ +const lang = (() => { + let param = new URLSearchParams(location.search).get("lang"); + if (param === "ko") param = "kr"; + if (param === "en" || param === "kr") return param; + return navigator.language?.toLowerCase().startsWith("ko") ? "kr" : "en"; +})(); + +const L = window.I18N[lang]; +document.documentElement.lang = lang === "kr" ? "ko" : "en"; +document.title = L.docTitle; + +ReactDOM.createRoot(document.getElementById("root")).render( + +); diff --git a/docs/demo/src/components.jsx b/docs/demo/src/components.jsx new file mode 100644 index 0000000..1147975 --- /dev/null +++ b/docs/demo/src/components.jsx @@ -0,0 +1,602 @@ +/* Shared React components for ZeroAlign-Rec demo. + * All components accept a locale table L (window.I18N.en or .kr). + * Exported to window at the bottom so thin entry files can use them. */ + +const { useState, useEffect, useRef } = React; + +// ===== Utilities ===== +function shade(hex, amt) { + const h = hex.replace("#", ""); + const r = parseInt(h.slice(0,2),16), g = parseInt(h.slice(2,4),16), b = parseInt(h.slice(4,6),16); + const clamp = v => Math.max(0, Math.min(255, Math.round(v))); + return `rgb(${clamp(r*(1+amt))},${clamp(g*(1+amt))},${clamp(b*(1+amt))})`; +} + +function avgMscp(items) { + return items.length ? (items.reduce((s,i)=>s+i.mscp,0)/items.length).toFixed(2) : "—"; +} + +// Build a URL pointing at the same page but with `lang` overridden, preserving +// every other query parameter (e.g. utm_source) the user arrived with. +function buildLangHref(targetLang) { + const params = new URLSearchParams(location.search); + params.set("lang", targetLang); + return "?" + params.toString(); +} + +// ===== Icons ===== +const BrandMark = () => ( + + + + + + +); + +const PlayIcon = () => ( + +); + +// ===== JSON preview ===== +// Escape HTML BEFORE the regex highlight pass so user-supplied strings +// (e.g. sketch.summary, which echoes the query textarea) cannot inject +// markup via dangerouslySetInnerHTML. The highlight regexes still match +// because JSON.stringify's quote characters are not escaped. +const escapeHtml = s => + s.replace(/&/g, "&").replace(//g, ">"); + +function JsonView({ data, label }) { + const json = escapeHtml(JSON.stringify(data, null, 2)); + const highlighted = json + .replace(/("(?:[^"\\]|\\.)*")(\s*:)/g, '$1$2') + .replace(/:\s*("(?:[^"\\]|\\.)*")/g, ': $1') + .replace(/:\s*(-?\d+(?:\.\d+)?)/g, ': $1'); + //
 gives the right semantics; tabIndex makes the scroll
+  // container reachable via keyboard.
+  return (
+    
+      
+    
+ ); +} + +// ===== Sidebar ===== +function Sidebar({ L, preset, setPreset, query, setQuery, liked, disliked, toggleLiked, toggleDisliked, filters, setFilters, topK, setTopK, onRun, running }) { + return ( +