/* ============================================================ SHLOK — The Shlok Deck · Main App Card-deck shell with two-card transition engine + swipe ============================================================ */ const { useState: uS, useEffect: uE, useRef: uR, useCallback: uC } = React; function selectionCount(step, data) { const value = data[step.key]; if (Array.isArray(value)) return value.length; return value ? 1 : 0; } function minRequired(step) { if (typeof step.minSelections === "number") return step.minSelections; if (step.type === "sliders") return 0; return step.type === "welcome" || step.type === "complete" ? 0 : 1; } function maxAllowed(step) { if (typeof step.maxSelections === "number") return step.maxSelections; if (typeof step.max === "number") return step.max; return null; } function constraintStatus(step, data) { const count = selectionCount(step, data); const min = minRequired(step); const max = maxAllowed(step); const valid = count >= min && (max == null || count <= max); return { count, min, max, valid }; } /* ---------- One card's full content (illo + body + foot) ---------- */ function StepCard({ step, data, set, requestAdvance, valid, onNext, isLast, leaving }) { const showFoot = !(step.type === "single" && step.autoAdvance); return ( {step.illo ? (
) : null}
{showFoot ? (
{renderHint(step, data)}
) : (
{renderHint(step, data)} tap to choose
)} ); } function renderHint(step, data) { const t = step.type; const status = constraintStatus(step, data); if (!status.valid && status.min > 0) { return {step.constraintLabel || `Choose at least ${status.min}`}; } if (t === "rank") { return {status.count} / {status.max || step.max} ranked; } if (t === "multi" || t === "loss") { return {status.count ? `${status.count} selected` : "Select all that apply"}; } if (t === "contact" && data[step.key] === "no") { return No problem — we won’t ask; } return ; } /* ---------- App ---------- */ function App() { const STEPS = window.SHLOK.STEPS; const TOTAL_Q = window.SHLOK.QUESTION_STEPS; const [idx, setIdx] = uS(0); const [dir, setDir] = uS(1); const [data, setData] = uS({}); const [debug, setDebug] = uS(false); const [leaving, setLeaving] = uS(null); // { idx, dir } const [drag, setDrag] = uS(0); // current drag offset (px) const [dragging, setDragging] = uS(false); const advTimer = uR(null); const goRef = uR({}); const animating = uR(false); const step = STEPS[idx]; const set = uC((key, val) => { setData((d) => ({ ...d, [key]: typeof val === "function" ? val(d[key]) : val })); }, []); function clearAdv() { if (advTimer.current) { clearTimeout(advTimer.current); advTimer.current = null; } } const go = uC((delta) => { if (animating.current) return; const next = Math.min(Math.max(idx + delta, 0), STEPS.length - 1); if (next === idx) return; clearAdv(); animating.current = true; setDir(delta > 0 ? 1 : -1); setLeaving({ idx, dir: delta > 0 ? 1 : -1 }); setIdx(next); setDrag(0); setDragging(false); setTimeout(() => { setLeaving(null); animating.current = false; }, 600); }, [idx, STEPS.length]); goRef.current = { go }; function requestAdvance() { clearAdv(); advTimer.current = setTimeout(() => goRef.current.go(1), 340); } function restart() { clearAdv(); setData({}); setDir(-1); setLeaving(null); animating.current = false; setIdx(0); } function isValid(s = step) { return constraintStatus(s, data).valid; } const valid = isValid(); const isQuestion = step.type !== "welcome" && step.type !== "complete"; const isLast = idx === STEPS.length - 2; // keyboard uE(() => { function onKey(e) { const tag = (e.target.tagName || "").toLowerCase(); const typing = tag === "input" || tag === "textarea"; if ((e.key === "d" || e.key === "D") && !typing && !e.metaKey && !e.ctrlKey && !e.altKey) { setDebug((v) => !v); return; } if (typing) return; if (e.key === "ArrowRight" || e.key === "Enter") { if (step.type === "welcome") go(1); else if (step.type === "complete") restart(); else if (valid) go(1); } if (e.key === "ArrowLeft" && idx > 0) go(-1); } window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }); // ---- swipe / drag on active card ---- const dragState = uR(null); function onPointerDown(e) { if (!isQuestion || animating.current) return; // ignore drags starting on interactive controls if (e.target.closest("input, textarea, button, .range, .card-body")) { // allow drag from illo / card chrome only if (!e.target.closest(".card-illo")) return; } dragState.current = { x: e.clientX, y: e.clientY, decided: false, horiz: false }; } function onPointerMove(e) { const st = dragState.current; if (!st) return; const dx = e.clientX - st.x, dy = e.clientY - st.y; if (!st.decided) { if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return; st.decided = true; st.horiz = Math.abs(dx) > Math.abs(dy); if (st.horiz) setDragging(true); } if (st.horiz) { e.preventDefault(); setDrag(dx); } } function endDrag() { const st = dragState.current; dragState.current = null; if (!st || !st.horiz) { setDrag(0); setDragging(false); return; } const threshold = 90; if (drag <= -threshold && valid) { go(1); } else if (drag >= threshold && idx > 0) { go(-1); } else { setDragging(false); setDrag(0); } } // dynamic transform while dragging the active card const dragStyle = dragging && drag !== 0 ? { transform: `translateX(${drag}px) rotate(${drag * 0.025}deg)`, transition: "none" } : (dragging ? { transition: "none" } : null); // ---------- RENDER ---------- const enterClass = isQuestion || step.type === "complete" ? (dir === 1 ? "enter-f" : "enter-b") : "enter-f"; // chrome (only on question steps) const progressPct = isQuestion ? Math.round((idx / TOTAL_Q) * 100) : 0; const chrome = isQuestion ? (
SHLOK {idx} / {TOTAL_Q}{progressPct}%
{Array.from({ length: TOTAL_Q }).map((_, i) => ( ))}
{ /* tap = nothing; long-press handled below */ }} onContextMenu={(e) => { e.preventDefault(); setDebug((v) => !v); }} />
) : (
{ e.preventDefault(); setDebug((v) => !v); }} />
); return (
{chrome}
{/* depth ghosts (only mid-deck) */} {isQuestion ?
: null} {isQuestion ?
: null} {/* leaving card */} {leaving ? (
) : null} {/* active card */}
{ if (dragState.current) endDrag(); }} > {step.type === "welcome" ? ( go(1)} /> ) : step.type === "complete" ? ( ) : ( go(1)} isLast={isLast} /> )}
{debug ? setDebug(false)} /> : null} {step.type === "welcome" ?
press “d” for research debug panel
: null}
); } /* leaving card renders a static snapshot (no interaction) */ function LeaveContent({ step, data }) { if (step.type === "welcome") return {}} />; if (step.type === "complete") return {}} />; return ( {step.illo ? (
) : null}
{}} requestAdvance={() => {}} />
); } ReactDOM.createRoot(document.getElementById("root")).render();