/* Main app shell — wizard navigation, state, aside summary. */ const { useState: useS, useEffect: useE, useMemo: useM } = React; const STEPS = [ { id: "clade", name: "Clade", key: "cladeId" }, { id: "species", name: "Species", key: "speciesId" }, { id: "class", name: "Calling", key: "classId" }, { id: "background", name: "History", key: "backgroundId" }, { id: "stats", name: "Abilities", key: "stats" }, { id: "skills", name: "Skills", key: "skills" }, { id: "review", name: "Sign", key: "name" }, ]; const App = ({ data, tweaks, setTweaks }) => { const [step, setStep] = useS(0); const [state, setState] = useS(() => ({ data, cladeId: "canidae", speciesId: data.species.find(s => s.clade_id === "canidae")?.id, classId: "fangsworn", backgroundId: "pack_raised", statMethod: "array", statPool: STANDARD_ARRAY.map(v => ({ value: v })), statAssign: {}, statHistory: [], chosenSkills: [], name: "", portraitStyle: "silhouette", })); const set = (patch) => setState(s => ({ ...s, ...patch })); const clade = data.clades.find(c => c.id === state.cladeId); const species = data.species.find(s => s.id === state.speciesId); const cls = data.classes.find(c => c.id === state.classId); const bg = data.backgrounds.find(b => b.id === state.backgroundId); // When class changes, reset skill picks useE(() => { set({ chosenSkills: [] }); }, [state.classId]); // When clade changes, ensure species belongs to it useE(() => { if (!species || species.clade_id !== state.cladeId) { set({ speciesId: data.species.find(s => s.clade_id === state.cladeId)?.id }); } }, [state.cladeId]); // Validation per step const validate = (i) => { if (i === 0) return state.cladeId ? null : "Pick a clade."; if (i === 1) return state.speciesId ? null : "Pick a species."; if (i === 2) return state.classId ? null : "Pick a calling."; if (i === 3) return state.backgroundId ? null : "Pick a background."; if (i === 4) return Object.keys(state.statAssign).length === 6 ? null : `Assign all six abilities (${Object.keys(state.statAssign).length}/6).`; if (i === 5) return state.chosenSkills.length === (cls?.skills_choose || 0) ? null : `Pick exactly ${cls?.skills_choose} skill${cls?.skills_choose>1?"s":""} (${state.chosenSkills.length}/${cls?.skills_choose}).`; if (i === 6) return state.name.trim() ? null : "Enter a name."; return null; }; const stepError = validate(step); const allValid = STEPS.every((_, i) => !validate(i)); const StepComp = [ Steps.StepClade, Steps.StepSpecies, Steps.StepClass, Steps.StepBackground, Steps.StepStats, Steps.StepSkills, Steps.StepReview, ][step]; return (

Theriapolis · Codex of Becoming

Folio {romanize(step+1)} of VII — {STEPS[step].name}
Seed · 0x4F2A · Phase V · M2
{STEPS.map((s, i) => { // A step is locked if any earlier step has unmet requirements. // The current step is always reachable; earlier steps are always // reachable (so the user can go back and edit). const firstIncomplete = STEPS.findIndex((_, j) => validate(j)); const locked = i > step && firstIncomplete !== -1 && firstIncomplete < i; const isComplete = !validate(i) && i !== step; return (
{ if (!locked) setStep(i); }} title={locked ? "Complete earlier folios first" : undefined} >
{locked ? "✕" : romanize(i+1)}
{s.name}
); })}
{stepError || (step < 6 ? "Folio complete" : (allValid ? "Ready to sign" : "Some folios remain"))}
{step+1} / 7
{step < STEPS.length - 1 ? ( ) : ( )}
); }; const Aside = ({ state, set, tweaks, setTweaks }) => { const clade = state.data.clades.find(c => c.id === state.cladeId); const species = state.data.species.find(s => s.id === state.speciesId); const cls = state.data.classes.find(c => c.id === state.classId); const bg = state.data.backgrounds.find(b => b.id === state.backgroundId); return (
The Subject

Name

{state.name || "Unnamed"}

Lineage

{species?.name || "—"} {clade?.name} · {SIZE_LABEL[species?.size] || "—"}
{(clade || species) && (
{(clade?.traits || []).map(t => ( ))} {(species?.traits || []).map(t => ( ))} {tweaks.showDetriments && (clade?.detriments || []).map(t => ( ))} {tweaks.showDetriments && (species?.detriments || []).map(t => ( ))}
)}

Calling & History

{cls?.name || "—"} d{cls?.hit_die} · {bg?.name || "no history"}
{cls && (
{(cls.level_table?.find(l => l.level === 1)?.features || []) .filter(k => !["asi","subclass_select","subclass_feature"].includes(k)) .map(k => { const f = cls.feature_definitions[k]; if (!f) return null; return ; })}
)} {bg && (
)}

Abilities

{ABILITIES.map(ab => { const base = state.statAssign[ab]; const cm = clade?.ability_mods[ab] || 0; const sm = species?.ability_mods[ab] || 0; const f = base != null ? base + cm + sm : null; const m = f != null ? abilityMod(f) : null; return (
{ab}
{f ?? "—"}
{m != null ? signed(m) : ""}
); })}

Skills · {state.chosenSkills.length + (bg?.skill_proficiencies?.length || 0)}

{(bg?.skill_proficiencies || []).map(s => ( ))} {state.chosenSkills.map(s => ( ))} {(state.chosenSkills.length + (bg?.skill_proficiencies?.length || 0)) === 0 && ( none yet )}
); }; function romanize(n) { return ["I","II","III","IV","V","VI","VII","VIII","IX","X"][n-1] || String(n); } window.App = App;