/* ============================================================
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();