Files
5et-desktop/src/renderer/App.tsx
T
2026-02-26 01:13:22 +00:00

6027 lines
234 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { FormEvent, JSX, useCallback, useEffect, useMemo, useRef, useState } from "react";
const DEFAULT_PAGE = "/5etools.html";
const CARD_PAGE_SIZE = 8;
const APP_CHARACTERS_MENU_ID = "__characters";
const CHARACTER_STEPS = [
"Class",
"Species",
"Background",
"Ability Scores",
"Feats",
"Skills",
"Spells",
"Equipment",
"About",
"Finish"
] as const;
const ABILITY_KEYS = ["str", "dex", "con", "int", "wis", "cha"] as const;
type AbilityKey = (typeof ABILITY_KEYS)[number];
const ABILITY_LABELS: Record<AbilityKey, string> = {
str: "Strength",
dex: "Dexterity",
con: "Constitution",
int: "Intelligence",
wis: "Wisdom",
cha: "Charisma"
};
const STANDARD_ARRAY: [number, number, number, number, number, number] = [15, 14, 13, 12, 10, 8];
const SKILL_OPTIONS = [
"Acrobatics",
"Animal Handling",
"Arcana",
"Athletics",
"Deception",
"History",
"Insight",
"Intimidation",
"Investigation",
"Medicine",
"Nature",
"Perception",
"Performance",
"Persuasion",
"Religion",
"Sleight of Hand",
"Stealth",
"Survival"
] as const;
const SOURCE_BUCKET_OPTIONS = [
{ key: "homebrew", label: "Homebrew (non-WotC)" },
{ key: "core2024", label: "Core 2024" },
{ key: "expanded2024", label: "Expanded 2024" },
{ key: "core2014", label: "Core 2014" },
{ key: "expanded2014", label: "Expanded 2014" }
] as const;
const FIVEETOOLS_RENDER_CLASSIC_SCRIPT_PATHS = [
"lib/jquery.js",
"js/parser.js",
"js/utils.js",
"js/utils-ui.js",
"lib/localforage.js",
"js/utils-dataloader.js",
"js/render.js",
"js/render-dice.js",
"js/hist.js"
] as const;
const FIVEETOOLS_RENDER_MODULE_SCRIPT_PATHS = [
"js/utils-brew.js",
"js/utils-config.js",
"js/shim-esmodules.js"
] as const;
const FIVEETOOLS_RENDER_STYLESHEET_PATHS = ["css/bootstrap.css", "css/main.css"] as const;
type CharacterModalMode = "closed" | "list" | "editor";
type EntityPopoverKind = "class" | "species" | "background";
type RendererHandle = {
setBaseUrl: (value: string) => RendererHandle;
setAddHandlers: (value: boolean) => RendererHandle;
setFirstSection: (value: boolean) => RendererHandle;
render: (entry: unknown) => string;
};
type RendererGlobal = {
get: () => RendererHandle;
hover?: {
_doInit?: () => void;
pHandleLinkMouseOver?: (evt: MouseEvent, ele: HTMLElement, opts?: unknown) => Promise<void>;
handleLinkMouseLeave?: (evt: MouseEvent, ele: HTMLElement) => void;
handleLinkMouseMove?: (evt: MouseEvent, ele: HTMLElement) => void;
handleLinkClick?: (evt: MouseEvent, ele: HTMLElement, opts?: { isForceOpenPermanent?: boolean }) => void;
handleLinkWheel?: (evt: WheelEvent, ele: HTMLElement) => void;
handleLinkDragStart?: (evt: DragEvent, ele: HTMLElement) => void;
};
};
type TopbarMenuEntry = {
id: string;
type: "link" | "action" | "divider" | "label" | "submenu";
label?: string;
datePrefix?: string;
sourceColor?: string;
href?: string;
isExternal?: boolean;
actionId?: string;
children?: TopbarMenuEntry[];
};
type TopbarMenuGroup = {
id: string;
kind: "dropdown" | "link" | "action";
label: string;
href?: string;
isExternal?: boolean;
actionId?: string;
items?: TopbarMenuEntry[];
};
declare global {
interface Window {
Renderer?: RendererGlobal;
JqueryUtil?: {
initEnhancements?: () => void;
};
TemplateUtil?: {
initVanilla?: () => void;
};
}
}
let renderRuntimePromise: Promise<void> | null = null;
let renderRuntimeBaseUrl = "";
function normalizeWikiBaseUrl(value: string): string {
return value.replace(/\/+$/, "");
}
function cleanTopbarLabel(value: string): string {
return value
.replace(/\s+/g, " ")
.replace(/[▾▼►]+/g, "")
.trim();
}
function getElementPrimaryText(ele: Element): string {
for (const node of Array.from(ele.childNodes)) {
if (node.nodeType === Node.TEXT_NODE) {
const text = cleanTopbarLabel(node.textContent || "");
if (text) {
return text;
}
}
}
return cleanTopbarLabel(ele.textContent || "");
}
function getElementMenuMeta(ele: Element | null): Pick<TopbarMenuEntry, "datePrefix" | "sourceColor"> {
if (!ele) {
return {};
}
const out: Pick<TopbarMenuEntry, "datePrefix" | "sourceColor"> = {};
const dateEle = ele.querySelector(":scope > .page__nav-date");
if (dateEle) {
const datePrefix = cleanTopbarLabel(dateEle.textContent || "");
if (datePrefix) {
out.datePrefix = datePrefix;
}
}
const sourceEle = ele.querySelector(":scope > .nav2-list__disp-source");
if (sourceEle instanceof HTMLElement) {
const computed = getComputedStyle(sourceEle);
const sourceColor = (computed.backgroundColor || computed.borderLeftColor || "").trim();
if (
sourceColor
&& sourceColor !== "transparent"
&& !/^rgba?\(\s*0\s*,\s*0\s*,\s*0(?:\s*,\s*0)?\s*\)$/i.test(sourceColor)
) {
out.sourceColor = sourceColor;
}
}
return out;
}
function getPathnameFromHrefLoose(hrefRaw: string): string {
const href = hrefRaw.trim();
if (!href) {
return "";
}
try {
return new URL(href, "http://localhost").pathname.toLowerCase();
} catch {
return href.split(/[?#]/)[0].toLowerCase();
}
}
function areTopbarGroupsEquivalent(a: TopbarMenuGroup[], b: TopbarMenuGroup[]): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
function parseNativeTopbarMenuList(
list: Element,
registerAction: (ele: HTMLElement) => string,
idPrefix: string
): TopbarMenuEntry[] {
const entries: TopbarMenuEntry[] = [];
const liElements = Array.from(list.children).filter((it): it is HTMLLIElement => it instanceof HTMLLIElement);
liElements.forEach((li, index) => {
const entryId = `${idPrefix}-${index}`;
const submenuList = (
li.querySelector(":scope > ul")
|| (li.classList.contains("nav2-accord__wrp") ? li.querySelector(":scope > .nav2-accord__body") : null)
) as Element | null;
const submenuChildren = submenuList ? parseNativeTopbarMenuList(submenuList, registerAction, `${entryId}-submenu`) : [];
if (li.classList.contains("ve-dropdown-divider") || li.classList.contains("divider")) {
entries.push({ id: `${entryId}-divider`, type: "divider" });
return;
}
if (li.classList.contains("nav2-list__label")) {
const label = cleanTopbarLabel(li.textContent || "");
if (label) {
entries.push({ id: `${entryId}-label`, type: "label", label });
}
return;
}
if (li.classList.contains("dropdown--navbar")) {
const toggle = li.querySelector(":scope > a") as HTMLAnchorElement | null;
const label = toggle ? getElementPrimaryText(toggle) : "";
if (!label) {
return;
}
entries.push({
id: `${entryId}-submenu`,
type: "submenu",
label,
...getElementMenuMeta(toggle),
children: submenuChildren
});
return;
}
if (li.classList.contains("nav2-accord__wrp")) {
const headPrimary = li.querySelector(":scope > .nav2-accord__head > :first-child");
const label = headPrimary ? getElementPrimaryText(headPrimary) : "";
if (!label || !submenuChildren.length) {
return;
}
entries.push({
id: `${entryId}-accordion`,
type: "submenu",
label,
...getElementMenuMeta(headPrimary),
children: submenuChildren
});
return;
}
const link = li.querySelector(":scope > a") as HTMLAnchorElement | null;
if (link) {
const label = getElementPrimaryText(link);
const href = (link.getAttribute("href") || "").trim();
const isActionLike = href === "#" || href.toLowerCase().startsWith("javascript:");
if (submenuChildren.length) {
entries.push({
id: `${entryId}-submenu-link`,
type: "submenu",
label: label || "Section",
...getElementMenuMeta(link),
href: !isActionLike ? href : undefined,
actionId: isActionLike ? registerAction(link) : undefined,
isExternal: !isActionLike ? link.target === "_blank" || /^https?:\/\//i.test(href) : false,
children: submenuChildren
});
return;
}
if (isActionLike) {
entries.push({
id: `${entryId}-action`,
type: "action",
label: label || "Action",
...getElementMenuMeta(link),
actionId: registerAction(link)
});
return;
}
entries.push({
id: `${entryId}-link`,
type: "link",
label: label || href,
...getElementMenuMeta(link),
href,
isExternal: link.target === "_blank" || /^https?:\/\//i.test(href)
});
return;
}
const spans = Array.from(li.querySelectorAll(":scope > span")).filter((it): it is HTMLSpanElement => it instanceof HTMLSpanElement);
if (submenuChildren.length && spans.length) {
const first = spans[0];
const label = cleanTopbarLabel(first.textContent || first.title || "");
entries.push({
id: `${entryId}-submenu-span`,
type: "submenu",
label: label || "Section",
...getElementMenuMeta(first),
actionId: registerAction(first),
children: submenuChildren
});
return;
}
spans.forEach((span, spanIndex) => {
const label = cleanTopbarLabel(span.textContent || span.title || "");
entries.push({
id: `${entryId}-span-${spanIndex}`,
type: "action",
label: label || "Action",
...getElementMenuMeta(span),
actionId: registerAction(span)
});
});
});
return entries;
}
function parseNativeTopbarGroups(navbar: HTMLElement): {
groups: TopbarMenuGroup[];
actionHandlers: Map<string, () => void>;
} {
let actionIx = 0;
const actionHandlers = new Map<string, () => void>();
const registerAction = (ele: HTMLElement): string => {
const id = `native-action-${actionIx++}`;
actionHandlers.set(id, () => {
ele.click();
});
return id;
};
const groups: TopbarMenuGroup[] = [];
const rootItems = Array.from(navbar.children).filter((it): it is HTMLLIElement => it instanceof HTMLLIElement);
rootItems.forEach((li, index) => {
if (li.classList.contains("omni__wrp-input") || li.classList.contains("desktop-nav-characters")) {
return;
}
if (li.classList.contains("dropdown--navbar")) {
const toggle = li.querySelector(":scope > a") as HTMLAnchorElement | null;
const menuList = li.querySelector(":scope > ul") as HTMLUListElement | null;
const label = toggle ? getElementPrimaryText(toggle) : "";
if (!label) {
return;
}
groups.push({
id: `root-${index}-dropdown`,
kind: "dropdown",
label,
items: menuList ? parseNativeTopbarMenuList(menuList, registerAction, `root-${index}-list`) : []
});
return;
}
const link = li.querySelector(":scope > a") as HTMLAnchorElement | null;
if (!link) {
return;
}
const href = (link.getAttribute("href") || "").trim();
const label = getElementPrimaryText(link);
if (!label) {
return;
}
const isActionLike = href === "#" || href.toLowerCase().startsWith("javascript:");
if (isActionLike) {
groups.push({
id: `root-${index}-action`,
kind: "action",
label,
actionId: registerAction(link)
});
return;
}
groups.push({
id: `root-${index}-link`,
kind: "link",
label,
href,
isExternal: link.target === "_blank" || /^https?:\/\//i.test(href)
});
});
return { groups, actionHandlers };
}
function loadExternalScript(src: string, scriptType: "classic" | "module" = "classic"): Promise<void> {
return new Promise((resolve, reject) => {
const selector = `script[data-5etd-render-runtime="true"][src="${src}"][data-5etd-script-type="${scriptType}"]`;
const existing = document.querySelector(selector) as HTMLScriptElement | null;
if (existing) {
if (existing.dataset.loaded === "true") {
resolve();
return;
}
existing.addEventListener("load", () => resolve(), { once: true });
existing.addEventListener("error", () => reject(new Error(`Failed to load ${src}`)), { once: true });
return;
}
const script = document.createElement("script");
script.src = src;
script.type = scriptType === "module" ? "module" : "text/javascript";
script.async = false;
script.dataset.loaded = "false";
script.dataset["5etdRenderRuntime"] = "true";
script.dataset["5etdScriptType"] = scriptType;
script.addEventListener(
"load",
() => {
script.dataset.loaded = "true";
resolve();
},
{ once: true }
);
script.addEventListener(
"error",
() => {
reject(new Error(`Failed to load ${src}`));
},
{ once: true }
);
document.head.append(script);
});
}
function loadExternalStylesheet(href: string): Promise<void> {
return new Promise((resolve, reject) => {
const selector = `link[data-5etd-render-runtime-style="true"][href="${href}"]`;
const existing = document.querySelector(selector) as HTMLLinkElement | null;
if (existing) {
if (existing.dataset.loaded === "true") {
resolve();
return;
}
existing.addEventListener("load", () => resolve(), { once: true });
existing.addEventListener("error", () => reject(new Error(`Failed to load stylesheet ${href}`)), { once: true });
return;
}
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.dataset.loaded = "false";
link.dataset["5etdRenderRuntimeStyle"] = "true";
link.addEventListener(
"load",
() => {
link.dataset.loaded = "true";
resolve();
},
{ once: true }
);
link.addEventListener(
"error",
() => {
reject(new Error(`Failed to load stylesheet ${href}`));
},
{ once: true }
);
// Insert before Vite-injected app styles where possible, so app-specific CSS keeps precedence.
const firstStyleOrLink = document.head.querySelector("style, link[rel='stylesheet']");
if (firstStyleOrLink) {
document.head.insertBefore(link, firstStyleOrLink);
} else {
document.head.append(link);
}
});
}
async function ensure5etoolsRenderRuntime(wikiUrl: string): Promise<void> {
const baseUrl = normalizeWikiBaseUrl(wikiUrl);
if (!baseUrl) {
return;
}
if (window.Renderer?.get && renderRuntimeBaseUrl === baseUrl) {
return;
}
if (renderRuntimePromise && renderRuntimeBaseUrl === baseUrl) {
await renderRuntimePromise;
return;
}
renderRuntimeBaseUrl = baseUrl;
renderRuntimePromise = (async () => {
(window as unknown as { RENDERER_BASE_URL?: string }).RENDERER_BASE_URL = `${baseUrl}/`;
for (const stylePath of FIVEETOOLS_RENDER_STYLESHEET_PATHS) {
await loadExternalStylesheet(new URL(stylePath, `${baseUrl}/`).toString());
}
for (const scriptPath of FIVEETOOLS_RENDER_CLASSIC_SCRIPT_PATHS) {
await loadExternalScript(new URL(scriptPath, `${baseUrl}/`).toString(), "classic");
}
for (const scriptPath of FIVEETOOLS_RENDER_MODULE_SCRIPT_PATHS) {
await loadExternalScript(new URL(scriptPath, `${baseUrl}/`).toString(), "module");
}
if (!window.Renderer?.get) {
throw new Error("5etools Renderer runtime did not initialize.");
}
// `Renderer.hover` internally uses `ee` template helpers from utils.js.
// These are initialized by `JqueryUtil.initEnhancements()` in normal 5etools page bootstraps.
// We must do this manually in the character view runtime.
if (window.JqueryUtil?.initEnhancements) {
window.JqueryUtil.initEnhancements();
} else {
window.TemplateUtil?.initVanilla?.();
}
window.Renderer.get().setBaseUrl(`${baseUrl}/`).setAddHandlers(true).setFirstSection(true);
window.Renderer.hover?._doInit?.();
})();
await renderRuntimePromise;
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function stripTagsFallback(value: string): string {
return value
.replace(/\{@([^}]+)\}/g, (_match: string, innerRaw: string) => {
const inner = innerRaw.trim();
if (!inner) {
return "";
}
const firstSpace = inner.indexOf(" ");
const payload = firstSpace >= 0 ? inner.slice(firstSpace + 1) : inner;
const parts = payload.split("|").map((it) => it.trim());
return parts[2] || parts[3] || parts[0] || parts[1] || "";
})
.replace(/\s+/g, " ")
.trim();
}
function renderEntryText(text: string, isParserReady: boolean): string {
const value = text.trim();
if (!value) {
return "";
}
if (isParserReady && window.Renderer?.get) {
try {
const renderer = window.Renderer.get();
renderer.setFirstSection(true);
return renderer.render(value);
} catch {
// Fall through to basic escaped text below.
}
}
return escapeHtml(stripTagsFallback(value));
}
function ParsedEntryText(props: {
text: string;
isParserReady: boolean;
className?: string;
}): JSX.Element | null {
const { text, isParserReady, className } = props;
const html = useMemo(() => renderEntryText(text, isParserReady), [text, isParserReady]);
if (!html) {
return null;
}
return <div className={className} dangerouslySetInnerHTML={{ __html: html }} />;
}
function createNewCharacterDraft(): CharacterDocument {
const now = new Date().toISOString();
return {
schema: "5etd.character",
version: 1,
id: crypto.randomUUID(),
createdAt: now,
updatedAt: now,
profile: {
name: "Unnamed Character",
level: 1,
alignment: "",
faith: "",
lifestyle: "",
age: "",
gender: "",
height: "",
weight: "",
eyes: "",
skin: "",
hair: ""
},
selections: {
class: null,
subclass: null,
background: null,
species: null
},
steps: {
preferences: {
sourceBuckets: ["core2024"],
useOptionalClassFeatures: true,
customizeOrigin: true,
advancementType: "milestone",
hpMode: "average",
usePrerequisitesFeats: true,
usePrerequisitesMulticlass: true
},
abilityScores: {
method: "standardArray",
scores: {
str: 15,
dex: 14,
con: 13,
int: 12,
wis: 10,
cha: 8
},
rolledValues: [...STANDARD_ARRAY],
assignments: {
str: 0,
dex: 1,
con: 2,
int: 3,
wis: 4,
cha: 5
},
backgroundBonusModeIndex: 0,
backgroundBonusSelections: []
},
feats: {
selected: [],
notes: ""
},
skills: {
selected: [],
notes: ""
},
spells: {
selected: [],
notes: ""
},
equipment: {
startingEquipmentText: "",
inventoryText: "",
possessionsText: "",
currency: {
cp: 0,
sp: 0,
ep: 0,
gp: 0,
pp: 0
}
},
about: {
personality: "",
ideals: "",
bonds: "",
flaws: "",
backstory: "",
notes: ""
}
},
notes: ""
};
}
function abilityModifier(score: number): string {
const mod = Math.floor((score - 10) / 2);
return mod >= 0 ? `+${mod}` : `${mod}`;
}
function formatIso(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString();
}
function parseLineList(raw: string): string[] {
return Array.from(
new Set(
raw
.split(/\r?\n/g)
.map((it) => it.trim())
.filter((it) => Boolean(it))
)
);
}
function toLineList(items: string[]): string {
return items.join("\n");
}
function filterOptions(options: CharacterSelectionOption[], query: string): CharacterSelectionOption[] {
const needle = query.trim().toLowerCase();
if (!needle) {
return options;
}
return options.filter((option) => {
const haystack = `${option.name} ${option.source} ${option.sourceName} ${option.details} ${option.summary}`.toLowerCase();
return haystack.includes(needle);
});
}
function filterBySourceBuckets(options: CharacterSelectionOption[], sourceBuckets: string[]): CharacterSelectionOption[] {
if (!sourceBuckets.length) {
return [];
}
const selected = new Set(sourceBuckets);
return options.filter((option) => selected.has(option.sourceBucket));
}
function clampPage(page: number, totalItems: number): number {
const totalPages = Math.max(1, Math.ceil(totalItems / CARD_PAGE_SIZE));
return Math.max(1, Math.min(totalPages, page));
}
function paginateOptions(options: CharacterSelectionOption[], page: number): CharacterSelectionOption[] {
const safePage = clampPage(page, options.length);
const start = (safePage - 1) * CARD_PAGE_SIZE;
return options.slice(start, start + CARD_PAGE_SIZE);
}
function toEntityRef(option: CharacterSelectionOption): CharacterEntityRef {
return {
entityType: option.entityType,
name: option.name,
source: option.source,
hash: option.hash,
entityRef: option.entityRef
};
}
function toSubclassEntityRef(option: CharacterFeatureOption): CharacterEntityRef {
return {
entityType: "subclass",
name: option.name,
source: option.source,
hash: option.hash,
entityRef: option.entityRef
};
}
function buildAssetUrl(baseUrl: string, relativePath: string | null): string {
if (!baseUrl || !relativePath) {
return "";
}
return new URL(relativePath.replace(/^\/+/, ""), `${baseUrl}/`).toString();
}
function roll4d6DropLowest(): number {
const rolls = [
Math.floor(Math.random() * 6) + 1,
Math.floor(Math.random() * 6) + 1,
Math.floor(Math.random() * 6) + 1,
Math.floor(Math.random() * 6) + 1
].sort((a, b) => a - b);
return rolls[1] + rolls[2] + rolls[3];
}
function getScoresFromAssignments(
pool: [number, number, number, number, number, number],
assignments: Record<AbilityKey, number>
): Record<AbilityKey, number> {
return {
str: pool[assignments.str] ?? 8,
dex: pool[assignments.dex] ?? 8,
con: pool[assignments.con] ?? 8,
int: pool[assignments.int] ?? 8,
wis: pool[assignments.wis] ?? 8,
cha: pool[assignments.cha] ?? 8
};
}
function getPointBuyCost(score: number): number {
const map: Record<number, number> = {
8: 0,
9: 1,
10: 2,
11: 3,
12: 4,
13: 5,
14: 7,
15: 9
};
return map[score] ?? 99;
}
function getPointBuySpent(scores: Record<AbilityKey, number>): number {
return ABILITY_KEYS.reduce((total, key) => total + getPointBuyCost(scores[key]), 0);
}
interface BackgroundAbilityMode {
key: string;
from: AbilityKey[];
weights: number[];
label: string;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isAbilityKey(value: string): value is AbilityKey {
return ABILITY_KEYS.includes(value as AbilityKey);
}
function getBackgroundAbilityModes(detail: CharacterSelectionDetail | null): BackgroundAbilityMode[] {
if (!detail || detail.entityType !== "background") {
return [];
}
try {
const parsed = JSON.parse(detail.rawJson) as unknown;
if (!isRecord(parsed) || !Array.isArray(parsed.ability)) {
return [];
}
const out: BackgroundAbilityMode[] = [];
parsed.ability.forEach((partRaw, index) => {
if (!isRecord(partRaw)) {
return;
}
const chooseRaw = partRaw.choose;
if (!isRecord(chooseRaw)) {
return;
}
const weightedRaw = chooseRaw.weighted;
if (!isRecord(weightedRaw)) {
return;
}
if (!Array.isArray(weightedRaw.from) || !Array.isArray(weightedRaw.weights)) {
return;
}
const from = Array.from(
new Set(
weightedRaw.from
.map((it) => (typeof it === "string" ? it.trim().toLowerCase() : ""))
.filter((it) => Boolean(it) && isAbilityKey(it))
)
) as AbilityKey[];
const weights = weightedRaw.weights
.map((it) => (typeof it === "number" && Number.isFinite(it) ? Math.max(1, Math.trunc(it)) : 0))
.filter((it) => it > 0);
if (!from.length || !weights.length || weights.length > from.length) {
return;
}
const label = weights.map((it) => `+${it}`).join(" / ");
out.push({
key: `bg-ability-mode-${index}`,
from,
weights,
label
});
});
return out;
} catch {
return [];
}
}
function areAbilityArraysEqual(a: AbilityKey[], b: AbilityKey[]): boolean {
if (a.length !== b.length) {
return false;
}
return a.every((value, index) => value === b[index]);
}
function normalizeBackgroundBonusSelections(mode: BackgroundAbilityMode | null, selectedRaw: AbilityKey[]): AbilityKey[] {
if (!mode) {
return [];
}
const selected: AbilityKey[] = [];
const used = new Set<AbilityKey>();
for (const value of selectedRaw) {
if (!mode.from.includes(value) || used.has(value)) {
continue;
}
selected.push(value);
used.add(value);
if (selected.length >= mode.weights.length) {
break;
}
}
for (const candidate of mode.from) {
if (selected.length >= mode.weights.length) {
break;
}
if (used.has(candidate)) {
continue;
}
selected.push(candidate);
used.add(candidate);
}
return selected;
}
function getBackgroundBonusTotals(mode: BackgroundAbilityMode | null, selected: AbilityKey[]): Record<AbilityKey, number> {
const totals: Record<AbilityKey, number> = {
str: 0,
dex: 0,
con: 0,
int: 0,
wis: 0,
cha: 0
};
if (!mode) {
return totals;
}
mode.weights.forEach((weight, index) => {
const ability = selected[index];
if (!ability) {
return;
}
totals[ability] += weight;
});
return totals;
}
function getScoresWithBonuses(
baseScores: Record<AbilityKey, number>,
bonuses: Record<AbilityKey, number>
): Record<AbilityKey, number> {
return {
str: baseScores.str + bonuses.str,
dex: baseScores.dex + bonuses.dex,
con: baseScores.con + bonuses.con,
int: baseScores.int + bonuses.int,
wis: baseScores.wis + bonuses.wis,
cha: baseScores.cha + bonuses.cha
};
}
const CORE_SOURCE_CODES = new Set(["PHB", "DMG", "XPHB", "XDMG"]);
const SKILL_TO_ABILITY: Record<(typeof SKILL_OPTIONS)[number], AbilityKey> = {
Acrobatics: "dex",
"Animal Handling": "wis",
Arcana: "int",
Athletics: "str",
Deception: "cha",
History: "int",
Insight: "wis",
Intimidation: "cha",
Investigation: "int",
Medicine: "wis",
Nature: "int",
Perception: "wis",
Performance: "cha",
Persuasion: "cha",
Religion: "int",
"Sleight of Hand": "dex",
Stealth: "dex",
Survival: "wis"
};
const ABILITY_SCORE_COST_TABLE: Record<number, number> = {
8: 0,
9: 1,
10: 2,
11: 3,
12: 4,
13: 5,
14: 7,
15: 9
};
interface SkillChoiceRequirement {
id: string;
sourceLabel: string;
title: string;
count: number;
options: string[];
}
interface ParsedSkillData {
granted: string[];
choices: SkillChoiceRequirement[];
}
interface FeatCatalogItem {
key: string;
name: string;
source: string;
sourceBucket: CharacterSourceBucket;
sourceEdition: "2014" | "2024" | null;
category: string | null;
summary: string;
}
interface SpellCatalogItem {
key: string;
name: string;
source: string;
level: number;
school: string;
sourceBucket: CharacterSourceBucket;
sourceEdition: "2014" | "2024" | null;
classes: Array<{ name: string; source: string }>;
}
type SpellSourceLookup = Record<
string,
Record<
string,
{
class?: Record<string, Record<string, unknown>>;
classVariant?: Record<string, Record<string, unknown>>;
}
>
>;
interface EquipmentGrant {
id: string;
label: string;
cpValue: number;
}
interface EquipmentChoiceOption {
id: string;
key: string;
label: string;
grants: EquipmentGrant[];
}
interface EquipmentChoiceGroup {
id: string;
label: string;
options: EquipmentChoiceOption[];
}
interface EquipmentSelectionState {
selections: Record<string, string>;
addedItems: string[];
}
interface SkillsNotesState {
text: string;
picksByChoiceId: Record<string, string[]>;
extraSkills: string[];
}
function safeJsonParse<T>(value: string): T | null {
try {
return JSON.parse(value) as T;
} catch {
return null;
}
}
function getSourceBadgeText(source: string, sourceEdition: "2014" | "2024" | null): string {
const src = source.trim().toUpperCase();
if (src === "XPHB") {
return "2024";
}
if (src === "PHB") {
return "2014";
}
return sourceEdition ?? "";
}
function classifySourceBucket(sourceRaw: string): {
sourceBucket: CharacterSourceBucket;
sourceEdition: "2014" | "2024" | null;
} {
const source = sourceRaw.trim().toUpperCase();
if (!source) {
return {
sourceBucket: "expanded2014",
sourceEdition: null
};
}
const is2024 = source.startsWith("X");
const isCore = CORE_SOURCE_CODES.has(source);
return {
sourceBucket: is2024 ? (isCore ? "core2024" : "expanded2024") : isCore ? "core2014" : "expanded2014",
sourceEdition: is2024 ? "2024" : "2014"
};
}
function normalizeSkillName(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
const SKILL_NAME_LOOKUP = (() => {
const out = new Map<string, string>();
for (const skill of SKILL_OPTIONS) {
out.set(normalizeSkillName(skill), skill);
}
return out;
})();
function toSkillName(value: string): string | null {
return SKILL_NAME_LOOKUP.get(normalizeSkillName(value)) || null;
}
function parseSkillProficiencyValue(
value: unknown,
sourceLabel: string,
idPrefix: string
): ParsedSkillData {
const granted = new Set<string>();
const choices: SkillChoiceRequirement[] = [];
if (!Array.isArray(value)) {
return { granted: [], choices: [] };
}
value.forEach((entryRaw, entryIndex) => {
if (!isRecord(entryRaw)) {
return;
}
const chooseRaw = entryRaw.choose;
if (isRecord(chooseRaw)) {
const from = Array.isArray(chooseRaw.from)
? chooseRaw.from.map((it) => (typeof it === "string" ? toSkillName(it) : null)).filter((it): it is string => Boolean(it))
: [...SKILL_OPTIONS];
const count = typeof chooseRaw.count === "number" && Number.isFinite(chooseRaw.count) ? Math.max(1, Math.trunc(chooseRaw.count)) : 1;
if (from.length) {
choices.push({
id: `${idPrefix}-choose-${entryIndex}`,
sourceLabel,
title: `${sourceLabel} Skill Choice`,
count: Math.min(count, from.length),
options: Array.from(new Set(from))
});
}
return;
}
Object.entries(entryRaw).forEach(([key, val]) => {
if (key === "any" && typeof val === "number" && val > 0) {
choices.push({
id: `${idPrefix}-any-${entryIndex}`,
sourceLabel,
title: `${sourceLabel} Skill Choice`,
count: Math.min(Math.trunc(val), SKILL_OPTIONS.length),
options: [...SKILL_OPTIONS]
});
return;
}
if (val !== true) {
return;
}
const skill = toSkillName(key);
if (skill) {
granted.add(skill);
}
});
});
return {
granted: [...granted],
choices
};
}
function parseClassSkills(detail: CharacterSelectionDetail | null): ParsedSkillData {
if (!detail) {
return { granted: [], choices: [] };
}
const raw = safeJsonParse<Record<string, unknown>>(detail.rawJson);
if (!raw || !isRecord(raw.startingProficiencies)) {
return { granted: [], choices: [] };
}
return parseSkillProficiencyValue(raw.startingProficiencies.skills, "Class", "class");
}
function parseBackgroundSkills(detail: CharacterSelectionDetail | null): ParsedSkillData {
if (!detail) {
return { granted: [], choices: [] };
}
const raw = safeJsonParse<Record<string, unknown>>(detail.rawJson);
return parseSkillProficiencyValue(raw?.skillProficiencies, "Background", "background");
}
function parseSpeciesSkills(detail: CharacterSelectionDetail | null): ParsedSkillData {
if (!detail) {
return { granted: [], choices: [] };
}
const raw = safeJsonParse<Record<string, unknown>>(detail.rawJson);
return parseSkillProficiencyValue(raw?.skillProficiencies, "Species", "species");
}
function getProficiencyBonus(level: number): number {
return Math.max(2, Math.min(6, 2 + Math.floor((Math.max(1, level) - 1) / 4)));
}
function sanitizeFeatKey(name: string, source: string): string {
return `${name.trim().toLowerCase()}|${source.trim().toUpperCase()}`;
}
function parseFeatUid(uidRaw: string): { name: string; source: string } | null {
const uid = uidRaw.trim();
if (!uid) {
return null;
}
const [nameRaw, sourceRaw] = uid.split("|");
const name = (nameRaw || "").trim();
const source = (sourceRaw || "PHB").trim().toUpperCase();
if (!name) {
return null;
}
return { name, source };
}
function getClassFeatSlotCount(detail: CharacterSelectionDetail | null, level: number): number {
if (!detail) {
return 0;
}
return detail.featureCards.filter((card) => {
if (card.level != null && card.level > level) {
return false;
}
const title = card.title.toLowerCase();
return /ability score improvement|fighting style feat|epic boon|\bfeat\b/i.test(title);
}).length;
}
function getFixedFeatUids(detail: CharacterSelectionDetail | null): string[] {
if (!detail || detail.entityType === "class") {
return [];
}
const raw = safeJsonParse<Record<string, unknown>>(detail.rawJson);
if (!raw || !Array.isArray(raw.feats)) {
return [];
}
const out = new Set<string>();
raw.feats.forEach((entryRaw) => {
if (!isRecord(entryRaw)) {
return;
}
Object.entries(entryRaw).forEach(([key, value]) => {
if (value !== true) {
return;
}
const parsed = parseFeatUid(key);
if (!parsed) {
return;
}
out.add(`${parsed.name}|${parsed.source}`);
});
});
return [...out];
}
function parseClassSpellcasting(detail: CharacterSelectionDetail | null, level: number): {
cantrips: number;
prepared: number;
known: number;
slots: Array<{ level: number; slots: number; label: string }>;
hasPactMagic: boolean;
} {
const out = {
cantrips: 0,
prepared: 0,
known: 0,
slots: [] as Array<{ level: number; slots: number; label: string }>,
hasPactMagic: false
};
if (!detail) {
return out;
}
const raw = safeJsonParse<Record<string, unknown>>(detail.rawJson);
if (!raw) {
return out;
}
const levelIndex = Math.max(0, Math.min(19, level - 1));
const cantripProgression = Array.isArray(raw.cantripProgression) ? raw.cantripProgression : [];
const preparedSpellsProgression = Array.isArray(raw.preparedSpellsProgression) ? raw.preparedSpellsProgression : [];
const spellsKnownProgression = Array.isArray(raw.spellsKnownProgression) ? raw.spellsKnownProgression : [];
if (typeof cantripProgression[levelIndex] === "number") {
out.cantrips = Math.max(0, Math.trunc(cantripProgression[levelIndex]));
}
if (typeof preparedSpellsProgression[levelIndex] === "number") {
out.prepared = Math.max(0, Math.trunc(preparedSpellsProgression[levelIndex]));
}
if (typeof spellsKnownProgression[levelIndex] === "number") {
out.known = Math.max(0, Math.trunc(spellsKnownProgression[levelIndex]));
} else {
out.known = out.prepared;
}
const groups = Array.isArray(raw.classTableGroups) ? raw.classTableGroups : [];
groups.forEach((groupRaw) => {
if (!isRecord(groupRaw)) {
return;
}
const labels = Array.isArray(groupRaw.colLabels) ? groupRaw.colLabels : [];
const rows = Array.isArray(groupRaw.rowsSpellProgression) ? groupRaw.rowsSpellProgression : Array.isArray(groupRaw.rows) ? groupRaw.rows : [];
const levelRow = Array.isArray(rows[levelIndex]) ? rows[levelIndex] : null;
if (!levelRow) {
return;
}
labels.forEach((labelRaw, labelIndex) => {
const label = typeof labelRaw === "string" ? labelRaw : "";
const lower = label.toLowerCase();
if (!lower.includes("slot")) {
return;
}
const value = levelRow[labelIndex];
const slots = typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
if (!slots) {
return;
}
const levelMatch = lower.match(/(\d+)(?:st|nd|rd|th)/);
const spellLevel = levelMatch ? Number.parseInt(levelMatch[1], 10) : 1;
out.slots.push({
level: Number.isFinite(spellLevel) ? spellLevel : 1,
slots,
label
});
if (lower.includes("pact")) {
out.hasPactMagic = true;
}
});
});
out.slots.sort((a, b) => a.level - b.level || a.label.localeCompare(b.label));
return out;
}
function readEquipmentTypeLabel(value: string): string {
const map: Record<string, string> = {
instrumentMusical: "Musical Instrument",
toolArtisan: "Artisan's Tools"
};
return map[value] || value;
}
function parseEquipmentGrant(raw: unknown, idPrefix: string, index: number): EquipmentGrant[] {
if (!isRecord(raw)) {
return [];
}
const grants: EquipmentGrant[] = [];
if (typeof raw.item === "string") {
const parsed = parseFeatUid(raw.item.replace(/^([^|]+)\|/, "$1|"));
const [nameRaw] = raw.item.split("|");
const quantity = typeof raw.quantity === "number" ? Math.max(1, Math.trunc(raw.quantity)) : 1;
grants.push({
id: `${idPrefix}-item-${index}`,
label: `${nameRaw.trim()}${quantity > 1 ? ` x${quantity}` : ""}`,
cpValue: 0
});
if (parsed) {
void parsed;
}
}
if (typeof raw.value === "number" && Number.isFinite(raw.value)) {
const cp = Math.max(0, Math.trunc(raw.value));
const gp = cp / 100;
grants.push({
id: `${idPrefix}-gp-${index}`,
label: `${gp} GP`,
cpValue: cp
});
}
if (typeof raw.equipmentType === "string") {
grants.push({
id: `${idPrefix}-type-${index}`,
label: `Choose ${readEquipmentTypeLabel(raw.equipmentType)}`,
cpValue: 0
});
}
if (Array.isArray(raw.equipmentTypes) && raw.equipmentTypes.length) {
const labels = raw.equipmentTypes.filter((it): it is string => typeof it === "string").map((it) => readEquipmentTypeLabel(it));
if (labels.length) {
grants.push({
id: `${idPrefix}-types-${index}`,
label: `Choose ${labels.join(" or ")}`,
cpValue: 0
});
}
}
if (typeof raw.special === "string" && raw.special.trim()) {
grants.push({
id: `${idPrefix}-special-${index}`,
label: raw.special.trim(),
cpValue: 0
});
}
return grants;
}
function parseStartingEquipmentChoices(
detail: CharacterSelectionDetail | null,
sourceLabel: "Class" | "Background"
): EquipmentChoiceGroup[] {
if (!detail) {
return [];
}
const raw = safeJsonParse<Record<string, unknown>>(detail.rawJson);
if (!raw) {
return [];
}
const startingEquipment = raw.startingEquipment;
if (!Array.isArray(startingEquipment)) {
return [];
}
const groups: EquipmentChoiceGroup[] = [];
startingEquipment.forEach((groupRaw, groupIndex) => {
if (!isRecord(groupRaw)) {
return;
}
const options: EquipmentChoiceOption[] = [];
Object.entries(groupRaw)
.filter(([_k, value]) => Array.isArray(value))
.sort(([a], [b]) => a.localeCompare(b))
.forEach(([key, value]) => {
const grants = (value as unknown[]).flatMap((it, itemIndex) => parseEquipmentGrant(it, `${sourceLabel}-${groupIndex}-${key}`, itemIndex));
options.push({
id: `${sourceLabel}-${groupIndex}-${key}`,
key,
label: `${sourceLabel} Option ${key}`,
grants
});
});
if (!options.length) {
return;
}
groups.push({
id: `${sourceLabel}-${groupIndex}`,
label: `${sourceLabel} Starting Equipment ${groupIndex + 1}`,
options
});
});
return groups;
}
function parseEquipmentSelectionState(raw: string, fallbackInventoryRaw: string): EquipmentSelectionState {
const parsed = safeJsonParse<{ selections?: Record<string, string>; addedItems?: unknown[] }>(raw);
if (parsed && isRecord(parsed.selections) && Array.isArray(parsed.addedItems)) {
const selections: Record<string, string> = {};
Object.entries(parsed.selections).forEach(([k, v]) => {
if (typeof v === "string" && v.trim()) {
selections[k] = v;
}
});
return {
selections,
addedItems: parsed.addedItems
.filter((it): it is string => typeof it === "string")
.map((it) => it.trim())
.filter((it) => Boolean(it))
};
}
return {
selections: {},
addedItems: parseLineList(fallbackInventoryRaw)
};
}
function stringifyEquipmentSelectionState(state: EquipmentSelectionState): string {
return JSON.stringify(
{
selections: state.selections,
addedItems: state.addedItems
},
null,
2
);
}
function parseSkillsNotes(raw: string): SkillsNotesState {
const parsed = safeJsonParse<{ text?: string; picksByChoiceId?: Record<string, unknown>; extraSkills?: unknown[] }>(raw);
if (!parsed) {
return { text: raw, picksByChoiceId: {}, extraSkills: [] };
}
const hasKnownShape = typeof parsed.text === "string" || isRecord(parsed.picksByChoiceId) || Array.isArray(parsed.extraSkills);
if (!hasKnownShape) {
return { text: raw, picksByChoiceId: {}, extraSkills: [] };
}
const picksByChoiceId: Record<string, string[]> = {};
if (isRecord(parsed.picksByChoiceId)) {
Object.entries(parsed.picksByChoiceId).forEach(([choiceId, picksRaw]) => {
if (!Array.isArray(picksRaw)) {
return;
}
picksByChoiceId[choiceId] = picksRaw
.filter((it): it is string => typeof it === "string")
.map((it) => toSkillName(it))
.filter((it): it is string => Boolean(it));
});
}
const extraSkills = Array.isArray(parsed.extraSkills)
? uniqueStrings(
parsed.extraSkills
.filter((it): it is string => typeof it === "string")
.map((it) => toSkillName(it))
.filter((it): it is string => Boolean(it))
)
: [];
return {
text: typeof parsed.text === "string" ? parsed.text : "",
picksByChoiceId,
extraSkills
};
}
function stringifySkillsNotes(state: SkillsNotesState): string {
return JSON.stringify(
{
text: state.text,
picksByChoiceId: state.picksByChoiceId,
extraSkills: state.extraSkills
},
null,
2
);
}
function getAbilityModifierNumber(score: number): number {
return Math.floor((score - 10) / 2);
}
function areStringArraysEqual(a: string[], b: string[]): boolean {
if (a.length !== b.length) {
return false;
}
return a.every((it, ix) => it === b[ix]);
}
function uniqueStrings(values: string[]): string[] {
return Array.from(new Set(values.filter((it) => Boolean(it))));
}
function toNameSourceToken(name: string, source: string): string {
return `${name.trim()}|${source.trim().toUpperCase()}`;
}
function parseNameSourceToken(value: string): { name: string; source: string } | null {
const [nameRaw, sourceRaw] = value.split("|");
const name = (nameRaw || "").trim();
const source = (sourceRaw || "").trim().toUpperCase();
if (!name || !source) {
return null;
}
return { name, source };
}
function cpToCurrency(cpRaw: number): Record<"cp" | "sp" | "ep" | "gp" | "pp", number> {
let cp = Math.max(0, Math.trunc(cpRaw));
const pp = Math.floor(cp / 1000);
cp -= pp * 1000;
const gp = Math.floor(cp / 100);
cp -= gp * 100;
const ep = Math.floor(cp / 50);
cp -= ep * 50;
const sp = Math.floor(cp / 10);
cp -= sp * 10;
return { cp, sp, ep, gp, pp };
}
function parseInventoryLabel(value: string): { label: string; count: number } {
const match = value.trim().match(/^(.*?)\s*x\s*(\d+)$/i);
if (!match) {
return { label: value.trim(), count: 1 };
}
const label = (match[1] || "").trim();
const count = Number.parseInt((match[2] || "1").trim(), 10);
return {
label: label || value.trim(),
count: Number.isFinite(count) && count > 0 ? count : 1
};
}
function collectEntryText(value: unknown, out: string[], limit = 5): void {
if (out.length >= limit || value == null) {
return;
}
if (typeof value === "string") {
const cleaned = stripTagsFallback(value);
if (cleaned) {
out.push(cleaned);
}
return;
}
if (Array.isArray(value)) {
value.forEach((it) => collectEntryText(it, out, limit));
return;
}
if (!isRecord(value)) {
return;
}
if (typeof value.entry === "string") {
collectEntryText(value.entry, out, limit);
}
if (typeof value.text === "string") {
collectEntryText(value.text, out, limit);
}
if (Array.isArray(value.entries)) {
collectEntryText(value.entries, out, limit);
}
if (Array.isArray(value.items)) {
collectEntryText(value.items, out, limit);
}
}
function getEntrySummary(value: unknown): string {
const chunks: string[] = [];
collectEntryText(value, chunks, 3);
return chunks.join(" ").trim();
}
function normalizeSpellClasses(classesRaw: Array<{ name: string; source: string }>): Array<{ name: string; source: string }> {
const seen = new Set<string>();
const out: Array<{ name: string; source: string }> = [];
classesRaw.forEach((entry) => {
const name = entry.name.trim();
const source = entry.source.trim().toUpperCase();
if (!name) {
return;
}
const key = `${name.toLowerCase()}|${source}`;
if (seen.has(key)) {
return;
}
seen.add(key);
out.push({ name, source });
});
return out;
}
function getSpellClassesFromRaw(spellRaw: Record<string, unknown>): Array<{ name: string; source: string }> {
const out: Array<{ name: string; source: string }> = [];
const classesRaw = spellRaw.classes;
if (!isRecord(classesRaw)) {
return out;
}
const lists = ["fromClassList", "fromClassListVariant", "fromClassListLegacy"] as const;
lists.forEach((listKey) => {
const list = classesRaw[listKey];
if (!Array.isArray(list)) {
return;
}
list.forEach((entry) => {
if (!isRecord(entry)) {
return;
}
const name = typeof entry.name === "string" ? entry.name.trim() : "";
const source = typeof entry.source === "string" ? entry.source.trim().toUpperCase() : "";
if (!name) {
return;
}
out.push({ name, source });
});
});
return out;
}
function getSpellClassesFromLookup(
spellRaw: Record<string, unknown>,
sourceLookup: SpellSourceLookup | null
): Array<{ name: string; source: string }> {
if (!sourceLookup) {
return [];
}
const source = typeof spellRaw.source === "string" ? spellRaw.source.trim().toLowerCase() : "";
const name = typeof spellRaw.name === "string" ? spellRaw.name.trim().toLowerCase() : "";
if (!source || !name) {
return [];
}
const record = sourceLookup[source]?.[name];
if (!record) {
return [];
}
const out: Array<{ name: string; source: string }> = [];
const collect = (mapRaw: Record<string, Record<string, unknown>> | undefined) => {
if (!mapRaw) {
return;
}
Object.entries(mapRaw).forEach(([classSource, classMap]) => {
if (!isRecord(classMap)) {
return;
}
Object.keys(classMap).forEach((className) => {
if (!className.trim()) {
return;
}
out.push({ name: className.trim(), source: classSource.trim().toUpperCase() });
});
});
};
collect(record.class);
collect(record.classVariant);
return out;
}
function SetupView() {
const [setupState, setSetupState] = useState<SetupState | null>(null);
const [sourceMode, setSourceMode] = useState<"url" | "zip">("url");
const [sourceRepoUrl, setSourceRepoUrl] = useState("");
const [sourceZipPath, setSourceZipPath] = useState("");
const [imageRepoUrl, setImageRepoUrl] = useState("");
const [imageBaseUrl, setImageBaseUrl] = useState("");
const [downloadImages, setDownloadImages] = useState(true);
const [error, setError] = useState("");
const [status, setStatus] = useState("Checking local 5etools source...");
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
window.desktopApi
.getSetupState()
.then((state) => {
setSetupState(state);
setSourceRepoUrl(state.sourceRepoUrl);
setImageRepoUrl(state.imageRepoUrl);
setImageBaseUrl(state.imageBaseUrl);
setStatus(state.needsSetup ? "Local 5etools source not found. Configure first-run setup below." : "Setup already complete.");
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : "Unknown setup-state error";
setError(message);
setStatus("Failed to load setup state.");
});
}, []);
const onSubmit = async (event: FormEvent) => {
event.preventDefault();
setError("");
setIsSubmitting(true);
setStatus(
sourceMode === "zip"
? "Extracting local source ZIP and preparing files. This can take a few minutes..."
: "Downloading and preparing local 5etools source. This can take a few minutes..."
);
try {
const result = await window.desktopApi.runSetup({
mode: sourceMode,
sourceRepoUrl,
sourceZipPath,
imageRepoUrl,
imageBaseUrl,
downloadImages
});
if (!result.ok) {
setError(result.error || "Setup failed.");
setStatus("Setup failed.");
setIsSubmitting(false);
return;
}
setStatus("Setup complete. Launching wiki...");
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown setup error";
setError(message);
setStatus("Setup failed.");
setIsSubmitting(false);
}
};
const onClickBrowseZip = async () => {
const selected = await window.desktopApi.pickSourceZip();
if (selected) {
setSourceZipPath(selected);
}
};
return (
<main className="status-wrap setup-wrap">
<section className="status-panel setup-panel">
<h1>First Run Setup</h1>
<p>{status}</p>
{setupState?.sourceDir ? <p className="setup-meta">Detected local source: {setupState.sourceDir}</p> : null}
{error ? <p className="setup-error">{error}</p> : null}
<form className="setup-form" onSubmit={onSubmit}>
<div className="setup-mode-row" role="radiogroup" aria-label="Source input mode">
<button
type="button"
className={sourceMode === "url" ? "setup-mode-btn is-active" : "setup-mode-btn"}
onClick={() => setSourceMode("url")}
disabled={isSubmitting}
>
Source URL
</button>
<button
type="button"
className={sourceMode === "zip" ? "setup-mode-btn is-active" : "setup-mode-btn"}
onClick={() => setSourceMode("zip")}
disabled={isSubmitting}
>
Source ZIP
</button>
</div>
{sourceMode === "url" ? (
<label className="setup-field">
<span>5etools source repository URL</span>
<input
type="url"
value={sourceRepoUrl}
onChange={(event) => setSourceRepoUrl(event.target.value)}
required={sourceMode === "url"}
disabled={isSubmitting}
placeholder="https://github.com/5etools-mirror-2/5etools-mirror-2.github.io.git"
/>
</label>
) : (
<label className="setup-field">
<span>5etools source ZIP file</span>
<div className="setup-zip-row">
<input
type="text"
value={sourceZipPath}
onChange={(event) => setSourceZipPath(event.target.value)}
required={sourceMode === "zip"}
disabled={isSubmitting}
placeholder="/path/to/5etools-source.zip"
/>
<button type="button" className="setup-zip-pick" onClick={onClickBrowseZip} disabled={isSubmitting}>
Browse
</button>
</div>
</label>
)}
<label className="setup-field">
<span>5etools image repository URL</span>
<input
type="url"
value={imageRepoUrl}
onChange={(event) => setImageRepoUrl(event.target.value)}
required
disabled={isSubmitting}
placeholder="https://github.com/5etools-mirror-2/5etools-img"
/>
</label>
<label className="setup-field">
<span>External image base URL fallback</span>
<input
type="url"
value={imageBaseUrl}
onChange={(event) => setImageBaseUrl(event.target.value)}
required
disabled={isSubmitting}
placeholder="https://5e.tools/img/"
/>
</label>
<label className="setup-checkbox">
<input
type="checkbox"
checked={downloadImages}
onChange={(event) => setDownloadImages(event.target.checked)}
disabled={isSubmitting}
/>
<span>Download local image repository (recommended)</span>
</label>
<button className="setup-submit" type="submit" disabled={isSubmitting}>
{isSubmitting ? "Setting up..." : "Run Setup"}
</button>
</form>
</section>
</main>
);
}
function WikiView() {
const [wikiUrl, setWikiUrl] = useState<string>("");
const [error, setError] = useState<string>("");
const [currentPath, setCurrentPath] = useState<string>(DEFAULT_PAGE);
const [isRenderRuntimeReady, setIsRenderRuntimeReady] = useState(false);
const [openTopMenuId, setOpenTopMenuId] = useState<string | null>(null);
const [openTopMenuPath, setOpenTopMenuPath] = useState<string[]>([]);
const [topbarSearch, setTopbarSearch] = useState("");
const [topbarGroups, setTopbarGroups] = useState<TopbarMenuGroup[]>([]);
const frameRef = useRef<HTMLIFrameElement | null>(null);
const topbarRef = useRef<HTMLElement | null>(null);
const [characterModalMode, setCharacterModalMode] = useState<CharacterModalMode>("closed");
const [characterStep, setCharacterStep] = useState<number>(0);
const [characterDraft, setCharacterDraft] = useState<CharacterDocument | null>(null);
const [characterSummaries, setCharacterSummaries] = useState<CharacterSummary[]>([]);
const [characterOptions, setCharacterOptions] = useState<CharacterSelectionOptionSets | null>(null);
const [detailCache, setDetailCache] = useState<Record<string, CharacterSelectionDetail>>({});
const [characterError, setCharacterError] = useState<string>("");
const [characterNotice, setCharacterNotice] = useState<string>("");
const [isCharacterBusy, setIsCharacterBusy] = useState(false);
const [classSearch, setClassSearch] = useState("");
const [speciesSearch, setSpeciesSearch] = useState("");
const [backgroundSearch, setBackgroundSearch] = useState("");
const [featSearch, setFeatSearch] = useState("");
const [spellSearch, setSpellSearch] = useState("");
const [equipmentItemInput, setEquipmentItemInput] = useState("");
const [classPage, setClassPage] = useState(1);
const [speciesPage, setSpeciesPage] = useState(1);
const [backgroundPage, setBackgroundPage] = useState(1);
const [entityPopover, setEntityPopover] = useState<{ kind: EntityPopoverKind; entityRef: string } | null>(null);
const [featCatalog, setFeatCatalog] = useState<FeatCatalogItem[] | null>(null);
const [spellCatalog, setSpellCatalog] = useState<SpellCatalogItem[] | null>(null);
const [itemCatalog, setItemCatalog] = useState<string[]>([]);
const updateDraft = useCallback((updater: (current: CharacterDocument) => CharacterDocument) => {
setCharacterDraft((current) => {
if (!current) {
return current;
}
return updater(current);
});
}, []);
const loadCharacterOptions = useCallback(async () => {
if (characterOptions) {
return characterOptions;
}
const options = await window.desktopApi.getCharacterOptions();
setCharacterOptions(options);
return options;
}, [characterOptions]);
const ensureOptionDetail = useCallback(
async (entityRef: string) => {
const trimmed = entityRef.trim();
if (!trimmed) {
return null;
}
if (detailCache[trimmed]) {
return detailCache[trimmed];
}
const detail = await window.desktopApi.getCharacterOptionDetail(trimmed);
if (!detail) {
return null;
}
setDetailCache((current) => ({ ...current, [trimmed]: detail }));
return detail;
},
[detailCache]
);
const openEntityPopover = useCallback(
(kind: EntityPopoverKind, entityRef: string) => {
const trimmed = entityRef.trim();
if (!trimmed) {
return;
}
void ensureOptionDetail(trimmed);
setEntityPopover({ kind, entityRef: trimmed });
},
[ensureOptionDetail]
);
const refreshCharacterSummaries = useCallback(async () => {
const list = await window.desktopApi.listCharacters();
setCharacterSummaries(list);
}, []);
const resetEditorUiState = useCallback(() => {
setClassSearch("");
setSpeciesSearch("");
setBackgroundSearch("");
setFeatSearch("");
setSpellSearch("");
setEquipmentItemInput("");
setClassPage(1);
setSpeciesPage(1);
setBackgroundPage(1);
setCharacterStep(0);
}, []);
const openCharacterList = useCallback(async () => {
setCharacterError("");
setCharacterNotice("");
setIsCharacterBusy(true);
try {
setCharacterModalMode("list");
await refreshCharacterSummaries();
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to load character list.";
setCharacterError(message);
setCharacterModalMode("list");
} finally {
setIsCharacterBusy(false);
}
}, [refreshCharacterSummaries]);
const openNewCharacter = useCallback(async () => {
setCharacterError("");
setCharacterNotice("");
setIsCharacterBusy(true);
try {
await loadCharacterOptions();
setCharacterDraft(createNewCharacterDraft());
resetEditorUiState();
setCharacterModalMode("editor");
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to start a new character.";
setCharacterError(message);
setCharacterModalMode("list");
} finally {
setIsCharacterBusy(false);
}
}, [loadCharacterOptions, resetEditorUiState]);
const openCharacterById = useCallback(
async (id: string) => {
setCharacterError("");
setCharacterNotice("");
setIsCharacterBusy(true);
try {
await loadCharacterOptions();
const record = await window.desktopApi.getCharacter(id);
if (!record) {
setCharacterError("Character file was not found.");
setCharacterModalMode("list");
return;
}
setCharacterDraft(record);
resetEditorUiState();
setCharacterModalMode("editor");
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to open character.";
setCharacterError(message);
setCharacterModalMode("list");
} finally {
setIsCharacterBusy(false);
}
},
[loadCharacterOptions, resetEditorUiState]
);
const loadCharacterFromJson = useCallback(async () => {
setCharacterError("");
setCharacterNotice("");
setIsCharacterBusy(true);
try {
const selectedPath = await window.desktopApi.pickCharacterJson();
if (!selectedPath) {
return;
}
await loadCharacterOptions();
const imported = await window.desktopApi.importCharacterJson(selectedPath);
await refreshCharacterSummaries();
setCharacterDraft(imported);
resetEditorUiState();
setCharacterNotice(`Imported character: ${imported.profile.name}`);
setCharacterModalMode("editor");
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to import character JSON.";
setCharacterError(message);
setCharacterModalMode("list");
} finally {
setIsCharacterBusy(false);
}
}, [loadCharacterOptions, refreshCharacterSummaries, resetEditorUiState]);
const deleteCharacterById = useCallback(
async (id: string) => {
if (!window.confirm("Delete this character file? This action cannot be undone.")) {
return;
}
setCharacterError("");
setCharacterNotice("");
setIsCharacterBusy(true);
try {
const deleted = await window.desktopApi.deleteCharacter(id);
if (!deleted) {
setCharacterError("Character file could not be deleted.");
return;
}
setCharacterNotice("Character deleted.");
await refreshCharacterSummaries();
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to delete character.";
setCharacterError(message);
} finally {
setIsCharacterBusy(false);
}
},
[refreshCharacterSummaries]
);
const saveCharacterDraft = useCallback(async () => {
if (!characterDraft) {
return;
}
setCharacterError("");
setCharacterNotice("");
setIsCharacterBusy(true);
try {
const saved = await window.desktopApi.saveCharacter({
id: characterDraft.id,
createdAt: characterDraft.createdAt,
profile: characterDraft.profile,
selections: characterDraft.selections,
steps: characterDraft.steps,
notes: characterDraft.notes
});
setCharacterDraft(saved);
setCharacterNotice(`Saved ${saved.profile.name}.`);
await refreshCharacterSummaries();
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to save character.";
setCharacterError(message);
} finally {
setIsCharacterBusy(false);
}
}, [characterDraft, refreshCharacterSummaries]);
useEffect(() => {
window.desktopApi
.getWikiUrl()
.then((url) => setWikiUrl(url))
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : "Unknown error";
setError(message);
});
}, []);
useEffect(() => {
if (!wikiUrl) {
setIsRenderRuntimeReady(false);
return;
}
let isCancelled = false;
void ensure5etoolsRenderRuntime(wikiUrl)
.then(() => {
if (!isCancelled) {
setIsRenderRuntimeReady(true);
}
})
.catch((runtimeError: unknown) => {
console.error("[5ET-D] Failed to initialize 5etools render runtime", runtimeError);
if (!isCancelled) {
setIsRenderRuntimeReady(false);
}
});
return () => {
isCancelled = true;
};
}, [wikiUrl]);
useEffect(() => {
if (characterModalMode !== "editor" || !wikiUrl || !isRenderRuntimeReady) {
return;
}
let isCancelled = false;
const baseUrl = `${normalizeWikiBaseUrl(wikiUrl)}/`;
const loadFeats = async () => {
if (featCatalog) {
return;
}
const response = await fetch(new URL("data/feats.json", baseUrl).toString());
if (!response.ok) {
throw new Error(`Failed to load feats (${response.status})`);
}
const payload = (await response.json()) as { feat?: unknown[] };
const featsRaw = Array.isArray(payload.feat) ? payload.feat : [];
const next = featsRaw
.map((featRaw): FeatCatalogItem | null => {
if (!isRecord(featRaw)) {
return null;
}
const name = typeof featRaw.name === "string" ? featRaw.name.trim() : "";
const source = typeof featRaw.source === "string" ? featRaw.source.trim().toUpperCase() : "";
if (!name || !source) {
return null;
}
const sourceMeta = classifySourceBucket(source);
const summary = getEntrySummary(featRaw.entries);
return {
key: sanitizeFeatKey(name, source),
name,
source,
sourceBucket: sourceMeta.sourceBucket,
sourceEdition: sourceMeta.sourceEdition,
category: typeof featRaw.category === "string" ? featRaw.category : null,
summary
};
})
.filter((it): it is FeatCatalogItem => Boolean(it))
.sort((a, b) => a.name.localeCompare(b.name) || a.source.localeCompare(b.source));
if (!isCancelled) {
setFeatCatalog(next);
}
};
const loadSpells = async () => {
if (spellCatalog) {
return;
}
const runtime = window as unknown as {
DataUtil?: {
spell?: {
pLoadAll?: () => Promise<unknown[]>;
};
};
Renderer?: {
spell?: {
getCombinedClasses?: (spell: unknown, prop: string) => Array<{ name: string; source: string }>;
};
};
};
const pLoadAll = runtime.DataUtil?.spell?.pLoadAll;
const getCombinedClasses = runtime.Renderer?.spell?.getCombinedClasses;
if (!pLoadAll) {
if (!isCancelled) {
setSpellCatalog([]);
}
return;
}
let sourceLookup: SpellSourceLookup | null = null;
try {
const lookupResp = await fetch(new URL("data/generated/gendata-spell-source-lookup.json", baseUrl).toString());
if (lookupResp.ok) {
sourceLookup = (await lookupResp.json()) as SpellSourceLookup;
}
} catch {
sourceLookup = null;
}
const spellsRaw = await pLoadAll();
const next = (Array.isArray(spellsRaw) ? spellsRaw : [])
.map((spellRaw): SpellCatalogItem | null => {
if (!isRecord(spellRaw)) {
return null;
}
const name = typeof spellRaw.name === "string" ? spellRaw.name.trim() : "";
const source = typeof spellRaw.source === "string" ? spellRaw.source.trim().toUpperCase() : "";
if (!name || !source) {
return null;
}
const classesFromRenderer = getCombinedClasses
? (getCombinedClasses(spellRaw, "fromClassList") || [])
.map((it) => ({
name: typeof it.name === "string" ? it.name.trim() : "",
source: typeof it.source === "string" ? it.source.trim().toUpperCase() : ""
}))
.filter((it) => Boolean(it.name))
: [];
const classesFromRaw = getSpellClassesFromRaw(spellRaw);
const classesFromLookup = getSpellClassesFromLookup(spellRaw, sourceLookup);
const classes = normalizeSpellClasses([...classesFromRenderer, ...classesFromRaw, ...classesFromLookup]);
const sourceMeta = classifySourceBucket(source);
const level = typeof spellRaw.level === "number" ? Math.max(0, Math.trunc(spellRaw.level)) : 0;
const school = typeof spellRaw.school === "string" ? spellRaw.school : "";
return {
key: sanitizeFeatKey(name, source),
name,
source,
level,
school,
sourceBucket: sourceMeta.sourceBucket,
sourceEdition: sourceMeta.sourceEdition,
classes
};
})
.filter((it): it is SpellCatalogItem => Boolean(it))
.sort((a, b) => a.level - b.level || a.name.localeCompare(b.name) || a.source.localeCompare(b.source));
if (!isCancelled) {
setSpellCatalog(next);
}
};
const loadItems = async () => {
if (itemCatalog.length) {
return;
}
const response = await fetch(new URL("data/items-base.json", baseUrl).toString());
if (!response.ok) {
throw new Error(`Failed to load item catalog (${response.status})`);
}
const payload = (await response.json()) as { baseitem?: unknown[] };
const itemsRaw = Array.isArray(payload.baseitem) ? payload.baseitem : [];
const next = Array.from(
new Set(
itemsRaw
.map((it) => (isRecord(it) && typeof it.name === "string" ? it.name.trim() : ""))
.filter((it) => Boolean(it))
)
).sort((a, b) => a.localeCompare(b));
if (!isCancelled) {
setItemCatalog(next);
}
};
void (async () => {
try {
await Promise.all([loadFeats(), loadSpells(), loadItems()]);
} catch (catalogError) {
console.error("[5ET-D] Failed to load character builder catalogs", catalogError);
}
})();
return () => {
isCancelled = true;
};
}, [characterModalMode, wikiUrl, isRenderRuntimeReady, featCatalog, spellCatalog, itemCatalog.length]);
useEffect(() => {
const onMessage = (event: MessageEvent<unknown>) => {
const frameWindow = frameRef.current?.contentWindow;
if (!frameWindow || event.source !== frameWindow) {
return;
}
if (!event.data || typeof event.data !== "object") {
return;
}
const data = event.data as { type?: string; groups?: TopbarMenuGroup[] };
if (data.type === "5etd:native-navbar-data") {
if (!Array.isArray(data.groups)) {
return;
}
setTopbarGroups((current) => (areTopbarGroupsEquivalent(current, data.groups as TopbarMenuGroup[]) ? current : (data.groups as TopbarMenuGroup[])));
return;
}
if (data.type === "5etd:reset-onboarding") {
void (async () => {
const result = await window.desktopApi.resetOnboarding();
if (result.ok) {
return;
}
frameWindow.postMessage(
{
type: "5etd:reset-onboarding-result",
ok: false,
error: result.error || "Failed to reset onboarding."
},
"*"
);
})();
return;
}
if (data.type === "5etd:characters:new") {
void openNewCharacter();
return;
}
if (data.type === "5etd:characters:load-json") {
void loadCharacterFromJson();
return;
}
if (data.type === "5etd:characters:list") {
void openCharacterList();
}
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [loadCharacterFromJson, openCharacterList, openNewCharacter]);
useEffect(() => {
if (!openTopMenuId) {
return;
}
const onDocumentMouseDown = (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof Node)) {
return;
}
if (!topbarRef.current?.contains(target)) {
setOpenTopMenuId(null);
}
};
const onWindowKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setOpenTopMenuId(null);
}
};
document.addEventListener("mousedown", onDocumentMouseDown);
window.addEventListener("keydown", onWindowKeyDown);
return () => {
document.removeEventListener("mousedown", onDocumentMouseDown);
window.removeEventListener("keydown", onWindowKeyDown);
};
}, [openTopMenuId]);
useEffect(() => {
const frame = frameRef.current;
if (!frame) {
return;
}
const requestNativeNavbarModel = () => {
frame.contentWindow?.postMessage({ type: "5etd:native-navbar-request" }, "*");
};
frame.addEventListener("load", requestNativeNavbarModel);
requestNativeNavbarModel();
return () => frame.removeEventListener("load", requestNativeNavbarModel);
}, [currentPath, characterModalMode]);
const frameSrc = useMemo(() => {
if (!wikiUrl) {
return "";
}
return new URL(currentPath, `${wikiUrl}/`).toString();
}, [currentPath, wikiUrl]);
const currentPathname = useMemo(() => {
try {
return new URL(currentPath, "http://localhost").pathname.toLowerCase();
} catch {
return currentPath.split(/[?#]/)[0]?.toLowerCase() || DEFAULT_PAGE;
}
}, [currentPath]);
const navigateWikiPath = useCallback((path: string) => {
setCurrentPath(path);
setCharacterModalMode("closed");
setOpenTopMenuId(null);
setOpenTopMenuPath([]);
}, []);
const onSubmitTopbarSearch = useCallback((event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const query = topbarSearch.trim();
navigateWikiPath(query ? `/search.html?q=${encodeURIComponent(query)}` : "/search.html");
}, [navigateWikiPath, topbarSearch]);
const isSameAppPath = useCallback((leftRaw: string, rightRaw: string): boolean => {
const left = getPathnameFromHrefLoose(leftRaw);
const right = getPathnameFromHrefLoose(rightRaw);
if (!left || !right) {
return false;
}
if (left === right) {
return true;
}
const homeAliases = new Set(["/index.html", "/5etools.html"]);
return homeAliases.has(left) && homeAliases.has(right);
}, []);
const resolveWikiPathFromHref = useCallback(
(hrefRaw: string): string | null => {
const href = hrefRaw.trim();
if (!href || href.startsWith("#")) {
return null;
}
const hrefLower = href.toLowerCase();
if (hrefLower.startsWith("javascript:") || hrefLower.startsWith("mailto:") || hrefLower.startsWith("tel:")) {
return null;
}
if (!wikiUrl) {
return null;
}
try {
const base = new URL(`${normalizeWikiBaseUrl(wikiUrl)}/`);
const resolved = new URL(href, base);
if (resolved.origin !== base.origin) {
return null;
}
if (!resolved.pathname.toLowerCase().endsWith(".html")) {
return null;
}
const path = resolved.pathname.startsWith("/") ? resolved.pathname : `/${resolved.pathname}`;
return `${path}${resolved.search}${resolved.hash}`;
} catch {
return null;
}
},
[wikiUrl]
);
const runTopbarAction = useCallback((actionId: string | undefined) => {
if (!actionId) {
return;
}
frameRef.current?.contentWindow?.postMessage({ type: "5etd:native-navbar-run-action", actionId }, "*");
setOpenTopMenuPath([]);
}, []);
const navigateTopbarHref = useCallback((hrefRaw: string, isExternal?: boolean) => {
const href = hrefRaw.trim();
if (!href) {
return;
}
if (isExternal || /^https?:\/\//i.test(href)) {
window.open(href, "_blank", "noopener");
return;
}
const resolved = resolveWikiPathFromHref(href);
if (resolved) {
navigateWikiPath(resolved);
return;
}
if (href.startsWith("/")) {
navigateWikiPath(href);
return;
}
navigateWikiPath(`/${href.replace(/^\.?\//, "")}`);
}, [navigateWikiPath, resolveWikiPathFromHref]);
const activateTopbarEntry = useCallback((entry: TopbarMenuEntry) => {
if (entry.actionId) {
runTopbarAction(entry.actionId);
setOpenTopMenuId(null);
return;
}
if (entry.href) {
navigateTopbarHref(entry.href, entry.isExternal);
return;
}
}, [navigateTopbarHref, runTopbarAction]);
const activateTopbarGroup = useCallback((group: TopbarMenuGroup) => {
if (group.kind === "dropdown") {
setOpenTopMenuId((current) => (current === group.id ? null : group.id));
setOpenTopMenuPath([]);
return;
}
if (group.kind === "action") {
runTopbarAction(group.actionId);
setOpenTopMenuId(null);
return;
}
if (group.kind === "link" && group.href) {
navigateTopbarHref(group.href, group.isExternal);
}
}, [navigateTopbarHref, runTopbarAction]);
useEffect(() => {
if (!openTopMenuId) {
setOpenTopMenuPath([]);
}
}, [openTopMenuId]);
const openTopbarSubmenuAtDepth = useCallback((depth: number, entryId: string) => {
setOpenTopMenuPath((current) => {
if (current[depth] === entryId && current.length === depth + 1) {
return current;
}
return [...current.slice(0, depth), entryId];
});
}, []);
const renderTopbarDropdown = useCallback((entries: TopbarMenuEntry[]): JSX.Element => {
const renderEntryLabel = (entry: TopbarMenuEntry): JSX.Element => (
<span className="topbar-item-content">
{entry.datePrefix ? <span className="topbar-item-date">{entry.datePrefix}</span> : null}
{entry.sourceColor ? <span className="topbar-item-source" style={{ backgroundColor: entry.sourceColor }} aria-hidden="true" /> : null}
<span className="topbar-item-text">{entry.label}</span>
</span>
);
const columns: TopbarMenuEntry[][] = [entries];
let currentEntries = entries;
for (let depth = 0; depth < openTopMenuPath.length; depth += 1) {
const activeSubmenuId = openTopMenuPath[depth];
if (!activeSubmenuId) {
break;
}
const submenuEntry = currentEntries.find((entry) => entry.type === "submenu" && entry.id === activeSubmenuId);
if (!submenuEntry?.children?.length) {
break;
}
columns.push(submenuEntry.children);
currentEntries = submenuEntry.children;
}
return (
<div className="topbar-dropdown-columns">
{columns.map((columnEntries, depth) => (
<div key={`col-${depth}`} className="topbar-dropdown-column">
{columnEntries.map((entry) => {
if (entry.type === "divider") {
return <div key={entry.id} className="topbar-dropdown-divider" />;
}
if (entry.type === "label") {
return <div key={entry.id} className="topbar-dropdown-label">{entry.label}</div>;
}
if (entry.type === "submenu") {
const isSubmenuActive = openTopMenuPath[depth] === entry.id;
const hasChildren = Boolean(entry.children?.length);
const className = [
"topbar-dropdown-item",
"topbar-dropdown-item--submenu",
isSubmenuActive ? "is-active" : ""
]
.filter(Boolean)
.join(" ");
return (
<button
type="button"
key={entry.id}
className={className}
onMouseEnter={() => {
if (!hasChildren) {
return;
}
openTopbarSubmenuAtDepth(depth, entry.id);
}}
onFocus={() => {
if (!hasChildren) {
return;
}
openTopbarSubmenuAtDepth(depth, entry.id);
}}
onClick={() => {
if (hasChildren) {
openTopbarSubmenuAtDepth(depth, entry.id);
return;
}
activateTopbarEntry(entry);
}}
>
{renderEntryLabel(entry)}
{hasChildren ? <span className="topbar-submenu-caret" aria-hidden="true">▸</span> : null}
</button>
);
}
const isActive = entry.type === "link" && entry.href ? isSameAppPath(entry.href, currentPathname) : false;
return (
<button
type="button"
key={entry.id}
className={isActive ? "topbar-dropdown-item is-active" : "topbar-dropdown-item"}
onClick={() => activateTopbarEntry(entry)}
>
{renderEntryLabel(entry)}
</button>
);
})}
</div>
))}
</div>
);
}, [activateTopbarEntry, currentPathname, isSameAppPath, openTopMenuPath, openTopbarSubmenuAtDepth]);
const sourceBuckets = characterDraft?.steps.preferences.sourceBuckets ?? [];
const filteredClassOptions = useMemo(
() => filterOptions(filterBySourceBuckets(characterOptions?.classes ?? [], sourceBuckets), classSearch),
[characterOptions?.classes, sourceBuckets, classSearch]
);
const filteredSpeciesOptions = useMemo(
() => filterOptions(filterBySourceBuckets(characterOptions?.species ?? [], sourceBuckets), speciesSearch),
[characterOptions?.species, sourceBuckets, speciesSearch]
);
const filteredBackgroundOptions = useMemo(
() => filterOptions(filterBySourceBuckets(characterOptions?.backgrounds ?? [], sourceBuckets), backgroundSearch),
[characterOptions?.backgrounds, sourceBuckets, backgroundSearch]
);
const classTotalPages = Math.max(1, Math.ceil(filteredClassOptions.length / CARD_PAGE_SIZE));
const speciesTotalPages = Math.max(1, Math.ceil(filteredSpeciesOptions.length / CARD_PAGE_SIZE));
const backgroundTotalPages = Math.max(1, Math.ceil(filteredBackgroundOptions.length / CARD_PAGE_SIZE));
const classPageSafe = clampPage(classPage, filteredClassOptions.length);
const speciesPageSafe = clampPage(speciesPage, filteredSpeciesOptions.length);
const backgroundPageSafe = clampPage(backgroundPage, filteredBackgroundOptions.length);
const classPageOptions = useMemo(
() => paginateOptions(filteredClassOptions, classPageSafe),
[filteredClassOptions, classPageSafe]
);
const speciesPageOptions = useMemo(
() => paginateOptions(filteredSpeciesOptions, speciesPageSafe),
[filteredSpeciesOptions, speciesPageSafe]
);
const backgroundPageOptions = useMemo(
() => paginateOptions(filteredBackgroundOptions, backgroundPageSafe),
[filteredBackgroundOptions, backgroundPageSafe]
);
useEffect(() => {
setClassPage((current) => clampPage(current, filteredClassOptions.length));
}, [filteredClassOptions.length]);
useEffect(() => {
setSpeciesPage((current) => clampPage(current, filteredSpeciesOptions.length));
}, [filteredSpeciesOptions.length]);
useEffect(() => {
setBackgroundPage((current) => clampPage(current, filteredBackgroundOptions.length));
}, [filteredBackgroundOptions.length]);
useEffect(() => {
const selectedRefs = [
characterDraft?.selections.class?.entityRef,
characterDraft?.selections.background?.entityRef,
characterDraft?.selections.species?.entityRef
]
.filter((it): it is string => Boolean(it))
.slice(0, 3);
for (const ref of selectedRefs) {
void ensureOptionDetail(ref);
}
}, [characterDraft?.selections.background?.entityRef, characterDraft?.selections.class?.entityRef, characterDraft?.selections.species?.entityRef, ensureOptionDetail]);
const selectedClassDetail = characterDraft?.selections.class ? detailCache[characterDraft.selections.class.entityRef] ?? null : null;
const selectedSpeciesDetail = characterDraft?.selections.species ? detailCache[characterDraft.selections.species.entityRef] ?? null : null;
const selectedBackgroundDetail = characterDraft?.selections.background
? detailCache[characterDraft.selections.background.entityRef] ?? null
: null;
const entityPopoverDetail = entityPopover ? detailCache[entityPopover.entityRef] ?? null : null;
useEffect(() => {
if (characterModalMode !== "editor" || !entityPopover) {
return;
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setEntityPopover(null);
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [characterModalMode, entityPopover]);
useEffect(() => {
if (characterModalMode !== "editor" || !entityPopover) {
return;
}
if (characterStep < 0 || characterStep > 2) {
setEntityPopover(null);
}
}, [characterModalMode, characterStep, entityPopover]);
const classSkillData = useMemo(
() => parseClassSkills(selectedClassDetail),
[selectedClassDetail?.entityRef, selectedClassDetail?.rawJson]
);
const backgroundSkillData = useMemo(
() => parseBackgroundSkills(selectedBackgroundDetail),
[selectedBackgroundDetail?.entityRef, selectedBackgroundDetail?.rawJson]
);
const speciesSkillData = useMemo(
() => parseSpeciesSkills(selectedSpeciesDetail),
[selectedSpeciesDetail?.entityRef, selectedSpeciesDetail?.rawJson]
);
const skillChoiceRequirements = useMemo(
() => [...classSkillData.choices, ...backgroundSkillData.choices, ...speciesSkillData.choices],
[classSkillData, backgroundSkillData, speciesSkillData]
);
const grantedSkills = useMemo(
() => uniqueStrings([...classSkillData.granted, ...backgroundSkillData.granted, ...speciesSkillData.granted]).sort((a, b) => a.localeCompare(b)),
[classSkillData, backgroundSkillData, speciesSkillData]
);
const skillNotesState = useMemo(
() => parseSkillsNotes(characterDraft?.steps.skills.notes ?? ""),
[characterDraft?.steps.skills.notes]
);
const normalizedSkillChoicePicks = useMemo(() => {
const out: Record<string, string[]> = {};
for (const choice of skillChoiceRequirements) {
const picksRaw = skillNotesState.picksByChoiceId[choice.id] || [];
const picks: string[] = [];
for (const skill of picksRaw) {
if (!choice.options.includes(skill) || picks.includes(skill)) {
continue;
}
picks.push(skill);
if (picks.length >= choice.count) {
break;
}
}
out[choice.id] = picks;
}
return out;
}, [skillChoiceRequirements, skillNotesState.picksByChoiceId]);
const manualSkillSelections = useMemo(
() =>
uniqueStrings(skillNotesState.extraSkills.map((it) => toSkillName(it)).filter((it): it is string => Boolean(it))).sort((a, b) =>
a.localeCompare(b)
),
[skillNotesState.extraSkills]
);
const allProficientSkills = useMemo(
() =>
uniqueStrings([
...grantedSkills,
...Object.values(normalizedSkillChoicePicks).flatMap((picks) => picks),
...manualSkillSelections
]).sort((a, b) => a.localeCompare(b)),
[grantedSkills, manualSkillSelections, normalizedSkillChoicePicks]
);
const fixedFeatTokens = useMemo(
() => uniqueStrings([...getFixedFeatUids(selectedBackgroundDetail), ...getFixedFeatUids(selectedSpeciesDetail)]).sort((a, b) => a.localeCompare(b)),
[selectedBackgroundDetail?.rawJson, selectedSpeciesDetail?.rawJson]
);
const classFeatSlotCount = useMemo(
() => getClassFeatSlotCount(selectedClassDetail, characterDraft?.profile.level ?? 1),
[selectedClassDetail?.entityRef, selectedClassDetail?.featureCards, characterDraft?.profile.level]
);
const normalizedFeatSelections = useMemo(() => {
const raw = characterDraft?.steps.feats.selected ?? [];
return uniqueStrings(
raw
.map((token) => {
const parsed = parseNameSourceToken(token) || parseFeatUid(token);
return parsed ? toNameSourceToken(parsed.name, parsed.source) : "";
})
.filter((it) => Boolean(it))
);
}, [characterDraft?.steps.feats.selected]);
const variableFeatSelections = useMemo(() => {
const fixed = new Set(
fixedFeatTokens
.map((token) => parseNameSourceToken(token))
.filter((it): it is { name: string; source: string } => Boolean(it))
.map((it) => sanitizeFeatKey(it.name, it.source))
);
const filtered = normalizedFeatSelections.filter((token) => {
const parsed = parseNameSourceToken(token);
if (!parsed) {
return false;
}
return !fixed.has(sanitizeFeatKey(parsed.name, parsed.source));
});
return filtered.slice(0, Math.max(0, classFeatSlotCount));
}, [normalizedFeatSelections, fixedFeatTokens, classFeatSlotCount]);
const filteredFeatCatalog = useMemo(() => {
const needle = featSearch.trim().toLowerCase();
return (featCatalog ?? [])
.filter((feat) => sourceBuckets.includes(feat.sourceBucket))
.filter((feat) => {
if (!needle) {
return true;
}
return `${feat.name} ${feat.source} ${feat.summary} ${feat.category || ""}`.toLowerCase().includes(needle);
});
}, [featCatalog, sourceBuckets, featSearch]);
const featByKey = useMemo(() => {
const out = new Map<string, FeatCatalogItem>();
(featCatalog ?? []).forEach((feat) => out.set(feat.key, feat));
return out;
}, [featCatalog]);
const fixedFeatKeySet = useMemo(() => {
const out = new Set<string>();
fixedFeatTokens.forEach((token) => {
const parsed = parseNameSourceToken(token);
if (!parsed) {
return;
}
out.add(sanitizeFeatKey(parsed.name, parsed.source));
});
return out;
}, [fixedFeatTokens]);
const selectedFeatItems = useMemo(
() =>
normalizedFeatSelections.map((token) => {
const parsed = parseNameSourceToken(token);
if (!parsed) {
return {
key: token,
name: token,
source: "",
sourceBucket: "expanded2014" as CharacterSourceBucket,
sourceEdition: null,
category: null,
summary: ""
};
}
return (
featByKey.get(sanitizeFeatKey(parsed.name, parsed.source)) || {
key: sanitizeFeatKey(parsed.name, parsed.source),
name: parsed.name,
source: parsed.source,
sourceBucket: classifySourceBucket(parsed.source).sourceBucket,
sourceEdition: classifySourceBucket(parsed.source).sourceEdition,
category: null,
summary: ""
}
);
}),
[featByKey, normalizedFeatSelections]
);
const classSpellcasting = useMemo(
() => parseClassSpellcasting(selectedClassDetail, characterDraft?.profile.level ?? 1),
[selectedClassDetail?.entityRef, selectedClassDetail?.rawJson, characterDraft?.profile.level]
);
const selectedClassSpellPool = useMemo(() => {
if (!spellCatalog || !characterDraft?.selections.class) {
return [] as SpellCatalogItem[];
}
const className = characterDraft.selections.class.name.trim().toLowerCase();
const classSource = characterDraft.selections.class.source.trim().toUpperCase();
const sourceAliases = new Set([classSource]);
if (classSource === "XPHB") {
sourceAliases.add("PHB");
}
if (classSource === "PHB") {
sourceAliases.add("XPHB");
}
const needle = spellSearch.trim().toLowerCase();
return spellCatalog
.filter((spell) => sourceBuckets.includes(spell.sourceBucket))
.filter((spell) => {
const matchingByName = spell.classes.filter((cls) => cls.name.trim().toLowerCase() === className);
if (!matchingByName.length) {
return false;
}
const hasSourceData = matchingByName.some((cls) => Boolean(cls.source?.trim()));
if (!hasSourceData) {
return true;
}
return matchingByName.some((cls) => sourceAliases.has(cls.source.trim().toUpperCase()));
})
.filter((spell) => {
if (!needle) {
return true;
}
return `${spell.name} ${spell.source} ${spell.level}`.toLowerCase().includes(needle);
});
}, [spellCatalog, characterDraft?.selections.class, sourceBuckets, spellSearch]);
const selectedCantripPool = useMemo(
() => selectedClassSpellPool.filter((spell) => spell.level === 0).sort((a, b) => a.name.localeCompare(b.name) || a.source.localeCompare(b.source)),
[selectedClassSpellPool]
);
const selectedLeveledSpellPoolByLevel = useMemo(() => {
const groups = new Map<number, SpellCatalogItem[]>();
selectedClassSpellPool
.filter((spell) => spell.level > 0)
.forEach((spell) => {
const list = groups.get(spell.level) || [];
list.push(spell);
groups.set(spell.level, list);
});
return Array.from(groups.entries())
.sort(([a], [b]) => a - b)
.map(([level, spells]) => ({
level,
spells: [...spells].sort((a, b) => a.name.localeCompare(b.name) || a.source.localeCompare(b.source))
}));
}, [selectedClassSpellPool]);
const selectedSpellTokens = useMemo(
() =>
uniqueStrings(
(characterDraft?.steps.spells.selected ?? [])
.map((token) => {
const parsed = parseNameSourceToken(token) || parseFeatUid(token);
return parsed ? toNameSourceToken(parsed.name, parsed.source) : "";
})
.filter((it) => Boolean(it))
),
[characterDraft?.steps.spells.selected]
);
const selectedSpellItems = useMemo(() => {
const map = new Map<string, SpellCatalogItem>();
(spellCatalog ?? []).forEach((spell) => map.set(spell.key, spell));
return selectedSpellTokens
.map((token) => {
const parsed = parseNameSourceToken(token);
if (!parsed) {
return null;
}
return map.get(sanitizeFeatKey(parsed.name, parsed.source)) || null;
})
.filter((it): it is SpellCatalogItem => Boolean(it));
}, [spellCatalog, selectedSpellTokens]);
const selectedCantripCount = selectedSpellItems.filter((it) => it.level === 0).length;
const selectedPreparedCount = selectedSpellItems.filter((it) => it.level > 0).length;
const spellSlotMatrix = useMemo(() => {
const byLevel = new Map<number, number>();
classSpellcasting.slots.forEach((slot) => {
byLevel.set(slot.level, (byLevel.get(slot.level) || 0) + slot.slots);
});
return Array.from(byLevel.entries())
.sort(([a], [b]) => a - b)
.map(([level, slots]) => ({ level, slots }));
}, [classSpellcasting.slots]);
const classEquipmentGroups = useMemo(
() => parseStartingEquipmentChoices(selectedClassDetail, "Class"),
[selectedClassDetail?.entityRef, selectedClassDetail?.rawJson]
);
const backgroundEquipmentGroups = useMemo(
() => parseStartingEquipmentChoices(selectedBackgroundDetail, "Background"),
[selectedBackgroundDetail?.entityRef, selectedBackgroundDetail?.rawJson]
);
const equipmentChoiceGroups = useMemo(
() => [...classEquipmentGroups, ...backgroundEquipmentGroups],
[classEquipmentGroups, backgroundEquipmentGroups]
);
const equipmentSelectionState = useMemo(
() =>
parseEquipmentSelectionState(
characterDraft?.steps.equipment.startingEquipmentText ?? "",
characterDraft?.steps.equipment.inventoryText ?? ""
),
[characterDraft?.steps.equipment.startingEquipmentText, characterDraft?.steps.equipment.inventoryText]
);
const normalizedEquipmentSelections = useMemo(() => {
const out: Record<string, string> = {};
equipmentChoiceGroups.forEach((group) => {
const selectedOptionId = equipmentSelectionState.selections[group.id];
const hasSelected = group.options.some((option) => option.id === selectedOptionId);
out[group.id] = hasSelected ? selectedOptionId : group.options[0]?.id || "";
});
return out;
}, [equipmentChoiceGroups, equipmentSelectionState.selections]);
const resolvedEquipmentGrants = useMemo(() => {
const grants: EquipmentGrant[] = [];
equipmentChoiceGroups.forEach((group) => {
const selectedId = normalizedEquipmentSelections[group.id];
const option = group.options.find((it) => it.id === selectedId);
if (!option) {
return;
}
grants.push(...option.grants);
});
return grants;
}, [equipmentChoiceGroups, normalizedEquipmentSelections]);
const resolvedEquipmentRows = useMemo(() => {
const counts = new Map<string, number>();
const sourceLabels = [
...resolvedEquipmentGrants.map((grant) => grant.label).filter((label) => !/gp$/i.test(label)),
...equipmentSelectionState.addedItems
];
sourceLabels.forEach((rawLabel) => {
const parsed = parseInventoryLabel(rawLabel);
if (!parsed.label) {
return;
}
counts.set(parsed.label, (counts.get(parsed.label) || 0) + parsed.count);
});
return Array.from(counts.entries())
.map(([label, count]) => ({ label, count }))
.sort((a, b) => a.label.localeCompare(b.label));
}, [resolvedEquipmentGrants, equipmentSelectionState.addedItems]);
const resolvedEquipmentCurrency = useMemo(
() => cpToCurrency(resolvedEquipmentGrants.reduce((total, grant) => total + grant.cpValue, 0)),
[resolvedEquipmentGrants]
);
const backgroundAbilityModes = useMemo(
() => getBackgroundAbilityModes(selectedBackgroundDetail),
[selectedBackgroundDetail?.rawJson, selectedBackgroundDetail?.entityRef]
);
const activeBackgroundAbilityMode = useMemo(() => {
if (!characterDraft || !backgroundAbilityModes.length) {
return null;
}
const safeIndex = Math.max(0, Math.min(backgroundAbilityModes.length - 1, characterDraft.steps.abilityScores.backgroundBonusModeIndex));
return backgroundAbilityModes[safeIndex] ?? null;
}, [backgroundAbilityModes, characterDraft?.steps.abilityScores.backgroundBonusModeIndex, characterDraft]);
const normalizedBackgroundBonusSelections = useMemo(() => {
if (!characterDraft) {
return [];
}
return normalizeBackgroundBonusSelections(activeBackgroundAbilityMode, characterDraft.steps.abilityScores.backgroundBonusSelections);
}, [activeBackgroundAbilityMode, characterDraft?.steps.abilityScores.backgroundBonusSelections, characterDraft]);
const backgroundBonusTotals = useMemo(
() => getBackgroundBonusTotals(activeBackgroundAbilityMode, normalizedBackgroundBonusSelections),
[activeBackgroundAbilityMode, normalizedBackgroundBonusSelections]
);
const abilityTotals = useMemo(() => {
if (!characterDraft) {
return null;
}
return getScoresWithBonuses(characterDraft.steps.abilityScores.scores, backgroundBonusTotals);
}, [characterDraft?.steps.abilityScores.scores, backgroundBonusTotals, characterDraft]);
useEffect(() => {
if (!characterDraft) {
return;
}
const current = characterDraft.steps.abilityScores;
if (!backgroundAbilityModes.length) {
if (current.backgroundBonusModeIndex === 0 && current.backgroundBonusSelections.length === 0) {
return;
}
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
abilityScores: {
...draft.steps.abilityScores,
backgroundBonusModeIndex: 0,
backgroundBonusSelections: []
}
}
}));
return;
}
const clampedModeIndex = Math.max(0, Math.min(backgroundAbilityModes.length - 1, current.backgroundBonusModeIndex));
const mode = backgroundAbilityModes[clampedModeIndex] ?? null;
const normalizedSelections = normalizeBackgroundBonusSelections(mode, current.backgroundBonusSelections);
const hasModeChanged = clampedModeIndex !== current.backgroundBonusModeIndex;
const hasSelectionsChanged = !areAbilityArraysEqual(normalizedSelections, current.backgroundBonusSelections);
if (!hasModeChanged && !hasSelectionsChanged) {
return;
}
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
abilityScores: {
...draft.steps.abilityScores,
backgroundBonusModeIndex: clampedModeIndex,
backgroundBonusSelections: normalizedSelections
}
}
}));
}, [
backgroundAbilityModes,
characterDraft?.id,
characterDraft?.steps.abilityScores.backgroundBonusModeIndex,
characterDraft?.steps.abilityScores.backgroundBonusSelections,
updateDraft
]);
useEffect(() => {
if (!characterDraft) {
return;
}
const desiredNotes =
skillChoiceRequirements.length > 0 || manualSkillSelections.length > 0
? stringifySkillsNotes({
text: skillNotesState.text,
picksByChoiceId: normalizedSkillChoicePicks,
extraSkills: manualSkillSelections
})
: skillNotesState.text;
const currentSelected = [...characterDraft.steps.skills.selected].sort((a, b) => a.localeCompare(b));
const desiredSelected = [...allProficientSkills].sort((a, b) => a.localeCompare(b));
const isSelectedSame = areStringArraysEqual(currentSelected, desiredSelected);
const isNotesSame = characterDraft.steps.skills.notes === desiredNotes;
if (isSelectedSame && isNotesSame) {
return;
}
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
skills: {
...draft.steps.skills,
selected: desiredSelected,
notes: desiredNotes
}
}
}));
}, [
allProficientSkills,
characterDraft?.id,
characterDraft?.steps.skills.notes,
characterDraft?.steps.skills.selected,
normalizedSkillChoicePicks,
manualSkillSelections,
skillChoiceRequirements.length,
skillNotesState.text,
updateDraft
]);
useEffect(() => {
if (!characterDraft) {
return;
}
const desired = uniqueStrings([...fixedFeatTokens, ...variableFeatSelections]).sort((a, b) => a.localeCompare(b));
const current = [...normalizedFeatSelections].sort((a, b) => a.localeCompare(b));
if (areStringArraysEqual(current, desired)) {
return;
}
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
feats: {
...draft.steps.feats,
selected: desired
}
}
}));
}, [characterDraft?.id, fixedFeatTokens, normalizedFeatSelections, updateDraft, variableFeatSelections]);
useEffect(() => {
if (!characterDraft || !equipmentChoiceGroups.length) {
return;
}
const desiredState: EquipmentSelectionState = {
selections: normalizedEquipmentSelections,
addedItems: equipmentSelectionState.addedItems
};
const desiredStartingEquipment = stringifyEquipmentSelectionState(desiredState);
const desiredInventory = desiredState.addedItems.join("\n");
const currentCurrency = characterDraft.steps.equipment.currency;
const desiredCurrency = resolvedEquipmentCurrency;
const isCurrencySame =
currentCurrency.cp === desiredCurrency.cp &&
currentCurrency.sp === desiredCurrency.sp &&
currentCurrency.ep === desiredCurrency.ep &&
currentCurrency.gp === desiredCurrency.gp &&
currentCurrency.pp === desiredCurrency.pp;
if (
characterDraft.steps.equipment.startingEquipmentText === desiredStartingEquipment &&
characterDraft.steps.equipment.inventoryText === desiredInventory &&
isCurrencySame
) {
return;
}
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
equipment: {
...draft.steps.equipment,
startingEquipmentText: desiredStartingEquipment,
inventoryText: desiredInventory,
currency: desiredCurrency
}
}
}));
}, [
characterDraft?.id,
characterDraft?.steps.equipment.currency,
characterDraft?.steps.equipment.inventoryText,
characterDraft?.steps.equipment.startingEquipmentText,
equipmentChoiceGroups.length,
equipmentSelectionState.addedItems,
normalizedEquipmentSelections,
resolvedEquipmentCurrency,
updateDraft
]);
const isStepComplete = useCallback((stepIndex: number, draft: CharacterDocument | null): boolean => {
if (!draft) {
return false;
}
if (stepIndex === 0) {
return Boolean(draft.selections.class) && draft.steps.preferences.sourceBuckets.length > 0;
}
if (stepIndex === 1) {
return Boolean(draft.selections.species);
}
if (stepIndex === 2) {
return Boolean(draft.selections.background);
}
if (stepIndex === 3) {
const { method, scores, assignments } = draft.steps.abilityScores;
let isBaseValid = true;
if (method === "pointBuy") {
const valuesOk = ABILITY_KEYS.every((key) => scores[key] >= 8 && scores[key] <= 15);
isBaseValid = valuesOk && getPointBuySpent(scores) <= 27;
}
if (method === "manual" && isBaseValid) {
isBaseValid = ABILITY_KEYS.every((key) => Number.isInteger(scores[key]) && scores[key] >= 1 && scores[key] <= 30);
}
if ((method === "standardArray" || method === "rolled") && isBaseValid) {
const assignmentValues = ABILITY_KEYS.map((key) => assignments[key]);
const unique = new Set(assignmentValues);
isBaseValid = assignmentValues.every((it) => Number.isInteger(it) && it >= 0 && it <= 5) && unique.size === 6;
}
if (!isBaseValid) {
return false;
}
const selectedBackgroundRef = draft.selections.background?.entityRef;
if (!selectedBackgroundRef) {
return true;
}
const backgroundDetail = detailCache[selectedBackgroundRef] ?? null;
const modes = getBackgroundAbilityModes(backgroundDetail);
if (!modes.length) {
return true;
}
const safeModeIndex = Math.max(0, Math.min(modes.length - 1, draft.steps.abilityScores.backgroundBonusModeIndex));
const mode = modes[safeModeIndex] ?? null;
const selections = normalizeBackgroundBonusSelections(mode, draft.steps.abilityScores.backgroundBonusSelections);
return mode ? selections.length === mode.weights.length : true;
}
if (stepIndex === 4) {
const classDetail = draft.selections.class ? detailCache[draft.selections.class.entityRef] ?? null : null;
const backgroundDetail = draft.selections.background ? detailCache[draft.selections.background.entityRef] ?? null : null;
const speciesDetail = draft.selections.species ? detailCache[draft.selections.species.entityRef] ?? null : null;
const slotCount = getClassFeatSlotCount(classDetail, draft.profile.level);
if (slotCount <= 0) {
return true;
}
const fixedFeatKeySet = new Set(
uniqueStrings([...getFixedFeatUids(backgroundDetail), ...getFixedFeatUids(speciesDetail)])
.map((token) => parseNameSourceToken(token))
.filter((it): it is { name: string; source: string } => Boolean(it))
.map((it) => sanitizeFeatKey(it.name, it.source))
);
const variableCount = uniqueStrings(
draft.steps.feats.selected
.map((token) => parseNameSourceToken(token) || parseFeatUid(token))
.filter((it): it is { name: string; source: string } => Boolean(it))
.map((it) => sanitizeFeatKey(it.name, it.source))
).filter((key) => !fixedFeatKeySet.has(key)).length;
return variableCount >= slotCount;
}
if (stepIndex === 5) {
const classDetail = draft.selections.class ? detailCache[draft.selections.class.entityRef] ?? null : null;
const backgroundDetail = draft.selections.background ? detailCache[draft.selections.background.entityRef] ?? null : null;
const speciesDetail = draft.selections.species ? detailCache[draft.selections.species.entityRef] ?? null : null;
const requirements = [
...parseClassSkills(classDetail).choices,
...parseBackgroundSkills(backgroundDetail).choices,
...parseSpeciesSkills(speciesDetail).choices
];
if (!requirements.length) {
return true;
}
const notesState = parseSkillsNotes(draft.steps.skills.notes);
return requirements.every((choice) => {
const picks = uniqueStrings((notesState.picksByChoiceId[choice.id] || []).filter((it) => choice.options.includes(it)));
return picks.length >= choice.count;
});
}
if (stepIndex === 6) {
const classDetail = draft.selections.class ? detailCache[draft.selections.class.entityRef] ?? null : null;
const spellcasting = parseClassSpellcasting(classDetail, draft.profile.level);
const requiredCantrips = spellcasting.cantrips;
const requiredPrepared = spellcasting.known || spellcasting.prepared || 0;
if (requiredCantrips <= 0 && requiredPrepared <= 0) {
return true;
}
if (!spellCatalog?.length) {
return draft.steps.spells.selected.length > 0;
}
const spellLevelByToken = new Map<string, number>();
spellCatalog.forEach((spell) => {
spellLevelByToken.set(toNameSourceToken(spell.name, spell.source), spell.level);
});
let cantripCount = 0;
let preparedCount = 0;
uniqueStrings(draft.steps.spells.selected).forEach((token) => {
const parsed = parseNameSourceToken(token) || parseFeatUid(token);
if (!parsed) {
return;
}
const level = spellLevelByToken.get(toNameSourceToken(parsed.name, parsed.source));
if (level == null) {
return;
}
if (level === 0) {
cantripCount += 1;
} else {
preparedCount += 1;
}
});
return cantripCount >= requiredCantrips && preparedCount >= requiredPrepared;
}
if (stepIndex === 7) {
const classDetail = draft.selections.class ? detailCache[draft.selections.class.entityRef] ?? null : null;
const backgroundDetail = draft.selections.background ? detailCache[draft.selections.background.entityRef] ?? null : null;
const groups = [...parseStartingEquipmentChoices(classDetail, "Class"), ...parseStartingEquipmentChoices(backgroundDetail, "Background")];
if (!groups.length) {
return true;
}
const selections = parseEquipmentSelectionState(draft.steps.equipment.startingEquipmentText, draft.steps.equipment.inventoryText).selections;
return groups.every((group) => {
const selectedId = selections[group.id];
return group.options.some((option) => option.id === selectedId);
});
}
if (stepIndex === 8) {
return Boolean(draft.profile.name.trim()) && Boolean(draft.steps.about.backstory.trim() || draft.steps.about.notes.trim());
}
if (stepIndex === CHARACTER_STEPS.length - 1) {
return true;
}
return false;
}, [detailCache, spellCatalog]);
const stepCompletion = useMemo(
() => CHARACTER_STEPS.map((_, index) => isStepComplete(index, characterDraft)),
[characterDraft, isStepComplete]
);
const currentStepLabel = CHARACTER_STEPS[characterStep] ?? CHARACTER_STEPS[0];
const progressionSteps = CHARACTER_STEPS.slice(0, CHARACTER_STEPS.length - 1);
const canMoveNext = useMemo(() => {
if (!characterDraft) {
return false;
}
if (characterStep >= CHARACTER_STEPS.length - 1) {
return false;
}
if (characterStep === CHARACTER_STEPS.length - 2) {
return true;
}
return isStepComplete(characterStep, characterDraft);
}, [characterDraft, characterStep, isStepComplete]);
const nextStep = useCallback(() => {
setCharacterStep((current) => Math.min(CHARACTER_STEPS.length - 1, current + 1));
}, []);
const prevStep = useCallback(() => {
setCharacterStep((current) => Math.max(0, current - 1));
}, []);
const renderEntityDetailContent = useCallback(
(detail: CharacterSelectionDetail | null, kind: EntityPopoverKind) => {
if (!detail) {
return <p className="character-empty">Loading details...</p>;
}
return (
<div className="entity-detail entity-detail--popover">
<h4>{detail.name}</h4>
<p className="entity-detail-meta">
{detail.sourceName} ({detail.source}) • {detail.sourceBucket}
</p>
{detail.summary ? (
<ParsedEntryText text={detail.summary} isParserReady={isRenderRuntimeReady} className="entity-detail-text" />
) : null}
{detail.featureCards.length ? (
<div className="entity-feature-list">
{detail.featureCards.map((feature) => (
<article key={feature.id} className="entity-feature-card">
<header>
<strong>{feature.title}</strong>
{feature.level != null ? <span>Level {feature.level}</span> : null}
</header>
{feature.summary ? (
<ParsedEntryText text={feature.summary} isParserReady={isRenderRuntimeReady} className="entity-feature-text" />
) : null}
{kind === "class" && feature.options?.length ? (
<label className="character-field">
<span>Choose an option</span>
<select
value={characterDraft?.selections.subclass?.entityRef ?? ""}
onChange={(event) => {
const next = feature.options?.find((opt) => opt.entityRef === event.target.value) || null;
updateDraft((draft) => ({
...draft,
selections: {
...draft.selections,
subclass: next ? toSubclassEntityRef(next) : null
}
}));
}}
>
<option value="">Select...</option>
{feature.options.map((opt) => (
<option key={opt.entityRef} value={opt.entityRef}>
{opt.name} ({opt.source})
</option>
))}
</select>
</label>
) : null}
</article>
))}
</div>
) : null}
<details className="entity-json">
<summary>View JSON</summary>
<pre>{detail.rawJson}</pre>
</details>
</div>
);
},
[characterDraft?.selections.subclass?.entityRef, isRenderRuntimeReady, updateDraft]
);
useEffect(() => {
if (characterModalMode === "closed") {
return;
}
const onDocumentClickCapture = (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof Element)) {
return;
}
const anchor = target.closest("a[href]") as HTMLAnchorElement | null;
if (!anchor) {
return;
}
const isVetLink = anchor.hasAttribute("data-vet-page") && anchor.hasAttribute("data-vet-hash");
if (isVetLink) {
// Let native 5etools link handlers drive hover/popover behavior.
return;
}
const href = anchor.getAttribute("href");
if (!href) {
return;
}
const wikiPath = resolveWikiPathFromHref(href);
if (!wikiPath) {
return;
}
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
setCurrentPath(wikiPath);
setCharacterModalMode("closed");
};
document.addEventListener("click", onDocumentClickCapture, true);
return () => document.removeEventListener("click", onDocumentClickCapture, true);
}, [characterModalMode, resolveWikiPathFromHref]);
if (error) {
return (
<main className="status-wrap">
<section className="status-panel">
<h1>5etools Desktop failed to start</h1>
<p>{error}</p>
</section>
</main>
);
}
if (!wikiUrl) {
return (
<main className="status-wrap">
<section className="status-panel">
<h1>Starting 5etools Desktop</h1>
<p>Loading local 5etools wiki...</p>
</section>
</main>
);
}
return (
<main className="shell" aria-label="5etools Desktop Wiki Shell">
<header className="app-topbar" ref={topbarRef}>
<div className="app-topbar-brand">
<p className="app-topbar-kicker">5etools Desktop</p>
<h1 className="app-topbar-title">Compendium</h1>
</div>
<nav className="app-topbar-nav" aria-label="5etools Top Navigation">
{topbarGroups.map((group) => {
const isOpen = openTopMenuId === group.id;
const isActive = group.kind === "link" && group.href ? isSameAppPath(group.href, currentPathname) : false;
return (
<div key={group.id} className={isOpen ? "topbar-menu is-open" : "topbar-menu"}>
<button
type="button"
className={isActive ? "topbar-menu-toggle is-active" : "topbar-menu-toggle"}
onClick={() => activateTopbarGroup(group)}
aria-expanded={group.kind === "dropdown" ? isOpen : undefined}
>
<span>{group.label}</span>
{group.kind === "dropdown" ? <span className="topbar-caret" aria-hidden="true">▾</span> : null}
</button>
{group.kind === "dropdown" && isOpen ? (
<div className="topbar-dropdown-menu" role="menu">
{renderTopbarDropdown(group.items ?? [])}
</div>
) : null}
</div>
);
})}
<div className={openTopMenuId === APP_CHARACTERS_MENU_ID ? "topbar-menu is-open" : "topbar-menu"}>
<button
type="button"
className={characterModalMode !== "closed" ? "topbar-menu-toggle is-active" : "topbar-menu-toggle"}
onClick={() => {
setOpenTopMenuId((current) => (current === APP_CHARACTERS_MENU_ID ? null : APP_CHARACTERS_MENU_ID));
setOpenTopMenuPath([]);
}}
aria-expanded={openTopMenuId === APP_CHARACTERS_MENU_ID}
>
<span>Characters</span>
<span className="topbar-caret" aria-hidden="true">▾</span>
</button>
{openTopMenuId === APP_CHARACTERS_MENU_ID ? (
<div className="topbar-dropdown-menu" role="menu">
<button type="button" className="topbar-dropdown-item" onClick={() => { setOpenTopMenuId(null); void openNewCharacter(); }}>
New
</button>
<button type="button" className="topbar-dropdown-item" onClick={() => { setOpenTopMenuId(null); void loadCharacterFromJson(); }}>
Load from JSON
</button>
<button type="button" className="topbar-dropdown-item" onClick={() => { setOpenTopMenuId(null); void openCharacterList(); }}>
My Characters
</button>
</div>
) : null}
</div>
</nav>
<form className="app-topbar-search" onSubmit={onSubmitTopbarSearch}>
<input
type="search"
value={topbarSearch}
onChange={(event) => setTopbarSearch(event.target.value)}
placeholder="Search everywhere..."
/>
</form>
</header>
<section className="workspace">
{characterModalMode === "closed" ? (
<section className="frame-shell frame-shell--full">
<iframe ref={frameRef} className="wiki-frame" src={frameSrc} title="5etools Wiki" />
</section>
) : (
<section className="frame-shell frame-shell--full character-shell" role="region" aria-label="Character Creator">
<div className={characterModalMode === "editor" ? "character-modal is-editor" : "character-modal"}>
<header className="character-modal-header">
<h2>Character Creator</h2>
<div className="character-modal-actions">
<button type="button" onClick={() => void openCharacterList()} disabled={isCharacterBusy}>
My Characters
</button>
<button type="button" onClick={() => void openNewCharacter()} disabled={isCharacterBusy}>
New
</button>
<button type="button" onClick={() => void loadCharacterFromJson()} disabled={isCharacterBusy}>
Load JSON
</button>
<button type="button" className="is-subtle" onClick={() => setCharacterModalMode("closed")} disabled={isCharacterBusy}>
Back to Wiki
</button>
</div>
</header>
{characterError ? <p className="character-alert character-alert--error">{characterError}</p> : null}
{characterNotice ? <p className="character-alert character-alert--info">{characterNotice}</p> : null}
{characterModalMode === "list" ? (
<div className="character-list-wrap">
<div className="character-list-header">
<h3>Saved Characters</h3>
<button type="button" onClick={() => void refreshCharacterSummaries()} disabled={isCharacterBusy}>
Refresh
</button>
</div>
{characterSummaries.length === 0 ? (
<p className="character-empty">No characters saved yet.</p>
) : (
<ul className="character-list">
{characterSummaries.map((summary) => (
<li key={summary.id} className="character-list-item">
<div>
<p className="character-list-title">{summary.name}</p>
<p className="character-list-meta">
Level {summary.level} • {summary.className || "No class"} • {summary.backgroundName || "No background"} •{" "}
{summary.speciesName || "No species"}
</p>
<p className="character-list-time">Updated {formatIso(summary.updatedAt)}</p>
</div>
<div className="character-list-actions">
<button type="button" onClick={() => void openCharacterById(summary.id)} disabled={isCharacterBusy}>
Open
</button>
<button type="button" className="is-danger" onClick={() => void deleteCharacterById(summary.id)} disabled={isCharacterBusy}>
Delete
</button>
</div>
</li>
))}
</ul>
)}
</div>
) : (
<div className="character-editor-wrap">
<header className="character-builder-header">
<div className="character-builder-step">
<h3>{currentStepLabel}</h3>
<span className={stepCompletion[characterStep] ? "character-builder-step-state is-complete" : "character-builder-step-state is-pending"}>
{stepCompletion[characterStep] ? "Complete" : "Needs Choices"}
</span>
</div>
<div className="character-builder-actions">
<button
type="button"
className="is-subtle"
onClick={() => setCharacterStep(CHARACTER_STEPS.length - 1)}
disabled={!characterDraft || isCharacterBusy}
>
Review Character
</button>
<button type="button" className="is-subtle" onClick={() => setCharacterModalMode("closed")} disabled={isCharacterBusy}>
Compendium
</button>
<button type="button" className="is-danger" onClick={() => setCharacterModalMode("closed")} disabled={isCharacterBusy}>
Exit
</button>
</div>
</header>
{characterDraft ? (
<div className="character-step-content">
{characterStep === 0 ? (
<section className="character-section">
<h3>Step 1: Class</h3>
<div className="character-grid">
<label className="character-field">
<span>Name</span>
<input
type="text"
value={characterDraft.profile.name}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
profile: { ...draft.profile, name: event.target.value }
}))
}
/>
</label>
<label className="character-field">
<span>Level</span>
<input
type="number"
min={1}
max={20}
value={characterDraft.profile.level}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
profile: {
...draft.profile,
level: Math.max(1, Math.min(20, Number.parseInt(event.target.value || "1", 10) || 1))
}
}))
}
/>
</label>
<label className="character-field">
<span>Advancement Type</span>
<select
value={characterDraft.steps.preferences.advancementType}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
preferences: {
...draft.steps.preferences,
advancementType: event.target.value === "xp" ? "xp" : "milestone"
}
}
}))
}
>
<option value="milestone">Milestone</option>
<option value="xp">XP</option>
</select>
</label>
<label className="character-field">
<span>Hit Point Type</span>
<select
value={characterDraft.steps.preferences.hpMode}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
preferences: {
...draft.steps.preferences,
hpMode:
event.target.value === "maximum" || event.target.value === "rolled"
? event.target.value
: "average"
}
}
}))
}
>
<option value="average">Average</option>
<option value="maximum">Maximum</option>
<option value="rolled">Rolled</option>
</select>
</label>
</div>
<div className="character-source-grid">
{SOURCE_BUCKET_OPTIONS.map((bucket) => {
const isChecked = characterDraft.steps.preferences.sourceBuckets.includes(bucket.key);
return (
<label key={bucket.key} className="character-checkbox">
<input
type="checkbox"
checked={isChecked}
onChange={(event) =>
updateDraft((draft) => {
const current = draft.steps.preferences.sourceBuckets;
const next = event.target.checked
? Array.from(new Set([...current, bucket.key]))
: current.filter((it) => it !== bucket.key);
return {
...draft,
steps: {
...draft.steps,
preferences: {
...draft.steps.preferences,
sourceBuckets: next
}
}
};
})
}
/>
<span>{bucket.label}</span>
</label>
);
})}
</div>
<div className="character-toggle-grid">
<label className="character-checkbox">
<input
type="checkbox"
checked={characterDraft.steps.preferences.useOptionalClassFeatures}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
preferences: {
...draft.steps.preferences,
useOptionalClassFeatures: event.target.checked
}
}
}))
}
/>
<span>Optional Class Features</span>
</label>
<label className="character-checkbox">
<input
type="checkbox"
checked={characterDraft.steps.preferences.customizeOrigin}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
preferences: {
...draft.steps.preferences,
customizeOrigin: event.target.checked
}
}
}))
}
/>
<span>Customize Origin</span>
</label>
<label className="character-checkbox">
<input
type="checkbox"
checked={characterDraft.steps.preferences.usePrerequisitesFeats}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
preferences: {
...draft.steps.preferences,
usePrerequisitesFeats: event.target.checked
}
}
}))
}
/>
<span>Use Feat Prerequisites</span>
</label>
<label className="character-checkbox">
<input
type="checkbox"
checked={characterDraft.steps.preferences.usePrerequisitesMulticlass}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
preferences: {
...draft.steps.preferences,
usePrerequisitesMulticlass: event.target.checked
}
}
}))
}
/>
<span>Use Multiclass Prerequisites</span>
</label>
</div>
<label className="character-field">
<span>Search classes</span>
<input type="search" value={classSearch} onChange={(event) => setClassSearch(event.target.value)} />
</label>
<div className="entity-layout">
<div className="entity-list-panel entity-list-panel--solo">
<div className="entity-card-grid">
{classPageOptions.map((option) => {
const isSelected = characterDraft.selections.class?.entityRef === option.entityRef;
const imageUrl = buildAssetUrl(wikiUrl, option.imagePath);
const sourceBadge = getSourceBadgeText(option.source, option.sourceEdition);
return (
<button
type="button"
key={option.entityRef}
className={isSelected ? "entity-tile is-selected" : "entity-tile"}
onClick={() => {
updateDraft((draft) => ({
...draft,
selections: {
...draft.selections,
class: toEntityRef(option),
subclass: null
}
}));
openEntityPopover("class", option.entityRef);
}}
>
<span className="entity-tile-media">
{imageUrl ? <img src={imageUrl} alt={option.name} /> : <span className="entity-tile-fallback">{option.name.slice(0, 1)}</span>}
{sourceBadge ? <span className="entity-tile-source">{sourceBadge}</span> : null}
<span className="entity-tile-titlebar">
<span className="entity-tile-label">{option.name}</span>
<span className="entity-tile-meta">{option.source}</span>
</span>
</span>
</button>
);
})}
</div>
{filteredClassOptions.length === 0 ? <p className="character-empty">No classes match the selected source buckets.</p> : null}
<div className="entity-pagination">
<button type="button" onClick={() => setClassPage((it) => Math.max(1, it - 1))} disabled={classPageSafe <= 1}>
Previous
</button>
<span>
Page {classPageSafe}/{classTotalPages} ({filteredClassOptions.length} total)
</span>
<button
type="button"
onClick={() => setClassPage((it) => Math.min(classTotalPages, it + 1))}
disabled={classPageSafe >= classTotalPages}
>
Next
</button>
</div>
<div className="entity-list-actions">
<button
type="button"
className="is-subtle"
onClick={() => {
if (!characterDraft.selections.class) {
return;
}
openEntityPopover("class", characterDraft.selections.class.entityRef);
}}
disabled={!characterDraft.selections.class}
>
{characterDraft.selections.class ? `View ${characterDraft.selections.class.name} details` : "Select a class"}
</button>
</div>
</div>
</div>
</section>
) : null}
{characterStep === 1 ? (
<section className="character-section">
<h3>Step 2: Species</h3>
<label className="character-field">
<span>Search species</span>
<input type="search" value={speciesSearch} onChange={(event) => setSpeciesSearch(event.target.value)} />
</label>
<div className="entity-layout">
<div className="entity-list-panel entity-list-panel--solo">
<div className="entity-card-grid">
{speciesPageOptions.map((option) => {
const isSelected = characterDraft.selections.species?.entityRef === option.entityRef;
const imageUrl = buildAssetUrl(wikiUrl, option.imagePath);
const sourceBadge = getSourceBadgeText(option.source, option.sourceEdition);
return (
<button
type="button"
key={option.entityRef}
className={isSelected ? "entity-tile is-selected" : "entity-tile"}
onClick={() => {
updateDraft((draft) => ({
...draft,
selections: {
...draft.selections,
species: toEntityRef(option)
}
}));
openEntityPopover("species", option.entityRef);
}}
>
<span className="entity-tile-media">
{imageUrl ? <img src={imageUrl} alt={option.name} /> : <span className="entity-tile-fallback">{option.name.slice(0, 1)}</span>}
{sourceBadge ? <span className="entity-tile-source">{sourceBadge}</span> : null}
<span className="entity-tile-titlebar">
<span className="entity-tile-label">{option.name}</span>
<span className="entity-tile-meta">{option.source}</span>
</span>
</span>
</button>
);
})}
</div>
{filteredSpeciesOptions.length === 0 ? <p className="character-empty">No species match the selected source buckets.</p> : null}
<div className="entity-pagination">
<button type="button" onClick={() => setSpeciesPage((it) => Math.max(1, it - 1))} disabled={speciesPageSafe <= 1}>
Previous
</button>
<span>
Page {speciesPageSafe}/{speciesTotalPages} ({filteredSpeciesOptions.length} total)
</span>
<button
type="button"
onClick={() => setSpeciesPage((it) => Math.min(speciesTotalPages, it + 1))}
disabled={speciesPageSafe >= speciesTotalPages}
>
Next
</button>
</div>
<div className="entity-list-actions">
<button
type="button"
className="is-subtle"
onClick={() => {
if (!characterDraft.selections.species) {
return;
}
openEntityPopover("species", characterDraft.selections.species.entityRef);
}}
disabled={!characterDraft.selections.species}
>
{characterDraft.selections.species
? `View ${characterDraft.selections.species.name} details`
: "Select a species"}
</button>
</div>
</div>
</div>
</section>
) : null}
{characterStep === 2 ? (
<section className="character-section">
<h3>Step 3: Background</h3>
<label className="character-field">
<span>Search backgrounds</span>
<input type="search" value={backgroundSearch} onChange={(event) => setBackgroundSearch(event.target.value)} />
</label>
<div className="entity-layout">
<div className="entity-list-panel entity-list-panel--solo">
<div className="entity-card-grid">
{backgroundPageOptions.map((option) => {
const isSelected = characterDraft.selections.background?.entityRef === option.entityRef;
const imageUrl = buildAssetUrl(wikiUrl, option.imagePath);
const sourceBadge = getSourceBadgeText(option.source, option.sourceEdition);
return (
<button
type="button"
key={option.entityRef}
className={isSelected ? "entity-tile is-selected" : "entity-tile"}
onClick={() => {
updateDraft((draft) => ({
...draft,
selections: {
...draft.selections,
background: toEntityRef(option)
},
steps: {
...draft.steps,
abilityScores: {
...draft.steps.abilityScores,
backgroundBonusModeIndex: 0,
backgroundBonusSelections: []
}
}
}));
openEntityPopover("background", option.entityRef);
}}
>
<span className="entity-tile-media">
{imageUrl ? <img src={imageUrl} alt={option.name} /> : <span className="entity-tile-fallback">{option.name.slice(0, 1)}</span>}
{sourceBadge ? <span className="entity-tile-source">{sourceBadge}</span> : null}
<span className="entity-tile-titlebar">
<span className="entity-tile-label">{option.name}</span>
<span className="entity-tile-meta">{option.source}</span>
</span>
</span>
</button>
);
})}
</div>
{filteredBackgroundOptions.length === 0 ? (
<p className="character-empty">No backgrounds match the selected source buckets.</p>
) : null}
<div className="entity-pagination">
<button
type="button"
onClick={() => setBackgroundPage((it) => Math.max(1, it - 1))}
disabled={backgroundPageSafe <= 1}
>
Previous
</button>
<span>
Page {backgroundPageSafe}/{backgroundTotalPages} ({filteredBackgroundOptions.length} total)
</span>
<button
type="button"
onClick={() => setBackgroundPage((it) => Math.min(backgroundTotalPages, it + 1))}
disabled={backgroundPageSafe >= backgroundTotalPages}
>
Next
</button>
</div>
<div className="entity-list-actions">
<button
type="button"
className="is-subtle"
onClick={() => {
if (!characterDraft.selections.background) {
return;
}
openEntityPopover("background", characterDraft.selections.background.entityRef);
}}
disabled={!characterDraft.selections.background}
>
{characterDraft.selections.background
? `View ${characterDraft.selections.background.name} details`
: "Select a background"}
</button>
</div>
</div>
</div>
</section>
) : null}
{characterStep === 3 ? (
<section className="character-section character-section--ability">
<h3>Step 4: Ability Scores</h3>
<div className="ability-method-row">
{([
["standardArray", "Standard"],
["pointBuy", "Point Buy"],
["rolled", "Roll"],
["manual", "Manual"]
] as const).map(([method, label]) => (
<button
key={method}
type="button"
className={characterDraft.steps.abilityScores.method === method ? "is-active" : ""}
onClick={() =>
updateDraft((draft) => {
if (method === "standardArray") {
const pool = [...STANDARD_ARRAY] as [number, number, number, number, number, number];
return {
...draft,
steps: {
...draft.steps,
abilityScores: {
...draft.steps.abilityScores,
method,
rolledValues: pool,
assignments: {
str: 0,
dex: 1,
con: 2,
int: 3,
wis: 4,
cha: 5
},
scores: {
str: 15,
dex: 14,
con: 13,
int: 12,
wis: 10,
cha: 8
}
}
}
};
}
if (method === "rolled") {
const pool = draft.steps.abilityScores.rolledValues;
const assignments = draft.steps.abilityScores.assignments;
return {
...draft,
steps: {
...draft.steps,
abilityScores: {
...draft.steps.abilityScores,
method,
rolledValues: pool,
assignments,
scores: getScoresFromAssignments(pool, assignments)
}
}
};
}
if (method === "pointBuy") {
const normalized: Record<AbilityKey, number> = {
str: Math.max(8, Math.min(15, draft.steps.abilityScores.scores.str)),
dex: Math.max(8, Math.min(15, draft.steps.abilityScores.scores.dex)),
con: Math.max(8, Math.min(15, draft.steps.abilityScores.scores.con)),
int: Math.max(8, Math.min(15, draft.steps.abilityScores.scores.int)),
wis: Math.max(8, Math.min(15, draft.steps.abilityScores.scores.wis)),
cha: Math.max(8, Math.min(15, draft.steps.abilityScores.scores.cha))
};
return {
...draft,
steps: {
...draft.steps,
abilityScores: {
...draft.steps.abilityScores,
method,
scores: normalized
}
}
};
}
return {
...draft,
steps: {
...draft.steps,
abilityScores: {
...draft.steps.abilityScores,
method
}
}
};
})
}
>
{label}
</button>
))}
</div>
<div className="ability-layout">
<aside className="ability-sidebar">
{characterDraft.steps.abilityScores.method === "rolled" ? (
<article className="ability-panel">
<div className="ability-panel-headline">
<h4>Roll Stats</h4>
<button
type="button"
onClick={() =>
updateDraft((draft) => {
const rolled = [
roll4d6DropLowest(),
roll4d6DropLowest(),
roll4d6DropLowest(),
roll4d6DropLowest(),
roll4d6DropLowest(),
roll4d6DropLowest()
] as [number, number, number, number, number, number];
return {
...draft,
steps: {
...draft.steps,
abilityScores: {
...draft.steps.abilityScores,
method: "rolled",
rolledValues: rolled,
scores: getScoresFromAssignments(rolled, draft.steps.abilityScores.assignments)
}
}
};
})
}
>
Roll All
</button>
</div>
<div className="ability-roll-list">
{characterDraft.steps.abilityScores.rolledValues.map((value, idx) => (
<div key={`roll-${idx}`} className="ability-roll-row">
<span>Roll #{idx + 1}</span>
<strong>{value}</strong>
<button
type="button"
className="is-subtle"
onClick={() =>
updateDraft((draft) => {
const nextValues = [...draft.steps.abilityScores.rolledValues] as [
number,
number,
number,
number,
number,
number
];
nextValues[idx] = roll4d6DropLowest();
return {
...draft,
steps: {
...draft.steps,
abilityScores: {
...draft.steps.abilityScores,
rolledValues: nextValues,
scores: getScoresFromAssignments(nextValues, draft.steps.abilityScores.assignments)
}
}
};
})
}
>
Roll
</button>
</div>
))}
</div>
<button
type="button"
className="is-subtle"
onClick={() =>
updateDraft((draft) => {
const rankOrder = draft.steps.abilityScores.rolledValues
.map((value, index) => ({ value, index }))
.sort((a, b) => b.value - a.value)
.map((it) => it.index);
const nextAssignments: Record<AbilityKey, number> = {
str: rankOrder[0] ?? 0,
dex: rankOrder[1] ?? 1,
con: rankOrder[2] ?? 2,
int: rankOrder[3] ?? 3,
wis: rankOrder[4] ?? 4,
cha: rankOrder[5] ?? 5
};
return {
...draft,
steps: {
...draft.steps,
abilityScores: {
...draft.steps.abilityScores,
assignments: nextAssignments,
scores: getScoresFromAssignments(draft.steps.abilityScores.rolledValues, nextAssignments)
}
}
};
})
}
>
Assign All
</button>
</article>
) : null}
{characterDraft.steps.abilityScores.method === "standardArray" ? (
<article className="ability-panel">
<h4>Standard Array</h4>
<p className="ability-inline-meta">Assign each slot once: 15, 14, 13, 12, 10, 8.</p>
</article>
) : null}
{characterDraft.steps.abilityScores.method === "pointBuy" ? (
<article className="ability-panel">
<h4>Point Buy</h4>
<p className="ability-inline-meta">
Budget 27 • Spent {getPointBuySpent(characterDraft.steps.abilityScores.scores)} • Remaining{" "}
{27 - getPointBuySpent(characterDraft.steps.abilityScores.scores)}
</p>
</article>
) : null}
{characterDraft.steps.abilityScores.method === "manual" ? (
<article className="ability-panel">
<h4>Manual Scores</h4>
<p className="ability-inline-meta">Set each score directly. Allowed range: 1 to 30.</p>
</article>
) : null}
{selectedBackgroundDetail ? (
<article className="ability-panel">
<div className="ability-panel-headline">
<h4>{selectedBackgroundDetail.name}</h4>
<span className="ability-panel-meta">
{selectedBackgroundDetail.sourceName} ({selectedBackgroundDetail.source})
</span>
</div>
{selectedBackgroundDetail.summary ? (
<ParsedEntryText
text={selectedBackgroundDetail.summary}
isParserReady={isRenderRuntimeReady}
className="ability-background-summary"
/>
) : null}
{backgroundAbilityModes.length > 1 ? (
<label className="character-field">
<span>Ability Increase Pattern</span>
<select
value={characterDraft.steps.abilityScores.backgroundBonusModeIndex}
onChange={(event) => {
const nextModeIndex = Math.max(
0,
Math.min(backgroundAbilityModes.length - 1, Number.parseInt(event.target.value, 10) || 0)
);
const mode = backgroundAbilityModes[nextModeIndex] ?? null;
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
abilityScores: {
...draft.steps.abilityScores,
backgroundBonusModeIndex: nextModeIndex,
backgroundBonusSelections: normalizeBackgroundBonusSelections(
mode,
draft.steps.abilityScores.backgroundBonusSelections
)
}
}
}));
}}
>
{backgroundAbilityModes.map((mode, index) => (
<option key={mode.key} value={index}>
{mode.label}
</option>
))}
</select>
</label>
) : null}
{activeBackgroundAbilityMode ? (
<div className="ability-background-selectors">
{activeBackgroundAbilityMode.weights.map((weight, index) => (
<label key={`ability-bonus-${index}`} className="character-field">
<span>Select ability for +{weight}</span>
<select
value={normalizedBackgroundBonusSelections[index] ?? activeBackgroundAbilityMode.from[0]}
onChange={(event) => {
const selectedRaw = event.target.value.trim().toLowerCase();
if (!isAbilityKey(selectedRaw)) {
return;
}
updateDraft((draft) => {
const modeIndex = Math.max(
0,
Math.min(
backgroundAbilityModes.length - 1,
draft.steps.abilityScores.backgroundBonusModeIndex
)
);
const mode = backgroundAbilityModes[modeIndex] ?? null;
if (!mode) {
return draft;
}
const nextSelections = normalizeBackgroundBonusSelections(
mode,
draft.steps.abilityScores.backgroundBonusSelections
);
nextSelections[index] = selectedRaw;
const normalized = normalizeBackgroundBonusSelections(mode, nextSelections);
return {
...draft,
steps: {
...draft.steps,
abilityScores: {
...draft.steps.abilityScores,
backgroundBonusSelections: normalized
}
}
};
});
}}
>
{activeBackgroundAbilityMode.from.map((abilityKey) => {
const usedByOther = normalizedBackgroundBonusSelections.some(
(selectedAbility, selectedIndex) =>
selectedIndex !== index && selectedAbility === abilityKey
);
return (
<option key={`ability-choice-${index}-${abilityKey}`} value={abilityKey} disabled={usedByOther}>
{ABILITY_LABELS[abilityKey]}
</option>
);
})}
</select>
</label>
))}
</div>
) : null}
</article>
) : null}
</aside>
<div className="ability-main">
<div className="ability-assignment-grid ability-assignment-grid--ability-step">
{ABILITY_KEYS.map((key) => {
const baseScore = characterDraft.steps.abilityScores.scores[key];
const bonusScore = backgroundBonusTotals[key] ?? 0;
const totalScore = abilityTotals?.[key] ?? baseScore + bonusScore;
const method = characterDraft.steps.abilityScores.method;
return (
<article key={key} className="ability-assignment-card ability-assignment-card--detailed">
<h4>{ABILITY_LABELS[key]}</h4>
{method === "standardArray" || method === "rolled" ? (
<label className="character-field">
<span>Assign Value</span>
<select
value={characterDraft.steps.abilityScores.assignments[key]}
onChange={(event) => {
const nextIdx = Math.max(0, Math.min(5, Number.parseInt(event.target.value, 10) || 0));
updateDraft((draft) => {
const nextAssignments = {
...draft.steps.abilityScores.assignments,
[key]: nextIdx
};
const nextScores = getScoresFromAssignments(draft.steps.abilityScores.rolledValues, nextAssignments);
return {
...draft,
steps: {
...draft.steps,
abilityScores: {
...draft.steps.abilityScores,
assignments: nextAssignments,
scores: nextScores
}
}
};
});
}}
>
{characterDraft.steps.abilityScores.rolledValues.map((_value, idx) => {
const usedByOther = ABILITY_KEYS.some(
(other) => other !== key && characterDraft.steps.abilityScores.assignments[other] === idx
);
return (
<option key={`opt-${key}-${idx}`} value={idx} disabled={usedByOther}>
Slot {idx + 1}: {characterDraft.steps.abilityScores.rolledValues[idx]}
</option>
);
})}
</select>
</label>
) : null}
{method === "pointBuy" ? (
<label className="character-field">
<span>Score</span>
<input
type="number"
min={8}
max={15}
value={baseScore}
onChange={(event) => {
const next = Math.max(8, Math.min(15, Number.parseInt(event.target.value || "8", 10) || 8));
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
abilityScores: {
...draft.steps.abilityScores,
scores: {
...draft.steps.abilityScores.scores,
[key]: next
}
}
}
}));
}}
/>
</label>
) : null}
{method === "manual" ? (
<label className="character-field">
<span>Score</span>
<input
type="number"
min={1}
max={30}
value={baseScore}
onChange={(event) => {
const next = Math.max(1, Math.min(30, Number.parseInt(event.target.value || "1", 10) || 1));
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
abilityScores: {
...draft.steps.abilityScores,
scores: {
...draft.steps.abilityScores.scores,
[key]: next
}
}
}
}));
}}
/>
</label>
) : null}
<p className="ability-inline-meta">
Base {baseScore} • Bonus {bonusScore >= 0 ? `+${bonusScore}` : `${bonusScore}`} • Total {totalScore} • Modifier{" "}
{abilityModifier(totalScore)}
{method === "pointBuy" ? ` • Cost ${getPointBuyCost(baseScore)}` : ""}
</p>
</article>
);
})}
</div>
</div>
</div>
</section>
) : null}
{characterStep === 4 ? (
<section className="character-section">
<h3>Step 5: Feats</h3>
<div className="chooser-header">
<p>
Available picks: <strong>{classFeatSlotCount}</strong> • Chosen: <strong>{variableFeatSelections.length}</strong>
</p>
</div>
{fixedFeatTokens.length ? (
<article className="chooser-panel">
<h4>Granted Feats</h4>
<div className="chip-row">
{fixedFeatTokens.map((token) => {
const parsed = parseNameSourceToken(token);
if (!parsed) {
return null;
}
return (
<span key={`fixed-feat-${token}`} className="chip chip--locked">
{parsed.name} ({parsed.source})
</span>
);
})}
</div>
</article>
) : null}
<label className="character-field">
<span>Search feats</span>
<input type="search" value={featSearch} onChange={(event) => setFeatSearch(event.target.value)} />
</label>
<div className="chooser-layout">
<div className="chooser-panel chooser-panel--scroll">
<div className="feat-list">
{filteredFeatCatalog.map((feat) => {
const token = toNameSourceToken(feat.name, feat.source);
const key = sanitizeFeatKey(feat.name, feat.source);
const isSelected = normalizedFeatSelections.includes(token);
const isFixed = fixedFeatKeySet.has(key);
const isAtCap = !isSelected && !isFixed && variableFeatSelections.length >= classFeatSlotCount;
return (
<button
type="button"
key={feat.key}
className={isSelected ? "feat-card is-selected" : "feat-card"}
onClick={() => {
if (isFixed) {
return;
}
updateDraft((draft) => {
const current = uniqueStrings(
draft.steps.feats.selected
.map((it) => parseNameSourceToken(it) || parseFeatUid(it))
.filter((it): it is { name: string; source: string } => Boolean(it))
.map((it) => toNameSourceToken(it.name, it.source))
);
const has = current.includes(token);
let next = current;
if (has) {
next = current.filter((it) => it !== token);
} else {
const fixedKeys = new Set(
fixedFeatTokens
.map((it) => parseNameSourceToken(it))
.filter((it): it is { name: string; source: string } => Boolean(it))
.map((it) => sanitizeFeatKey(it.name, it.source))
);
const variableCount = current.filter((it) => {
const parsed = parseNameSourceToken(it);
return parsed ? !fixedKeys.has(sanitizeFeatKey(parsed.name, parsed.source)) : false;
}).length;
if (variableCount >= classFeatSlotCount) {
return draft;
}
next = [...current, token];
}
return {
...draft,
steps: {
...draft.steps,
feats: {
...draft.steps.feats,
selected: next
}
}
};
});
}}
disabled={isAtCap}
>
<span className="feat-card-title">{feat.name}</span>
<span className="feat-card-meta">
{feat.source} {feat.category ? `• ${feat.category}` : ""}
</span>
{feat.summary ? <span className="feat-card-summary">{feat.summary}</span> : null}
</button>
);
})}
</div>
{filteredFeatCatalog.length === 0 ? <p className="character-empty">No feats match current sources and search.</p> : null}
</div>
<aside className="chooser-panel chooser-panel--scroll">
<h4>Selected Feats</h4>
<div className="feat-list">
{selectedFeatItems.map((feat) => {
const isFixed = fixedFeatKeySet.has(sanitizeFeatKey(feat.name, feat.source));
return (
<article key={`selected-${feat.key}`} className="feat-card is-selected">
<span className="feat-card-title">{feat.name}</span>
<span className="feat-card-meta">
{feat.source || "Unknown source"}
{isFixed ? " • granted" : ""}
</span>
{feat.summary ? <span className="feat-card-summary">{feat.summary}</span> : null}
</article>
);
})}
</div>
{!selectedFeatItems.length ? <p className="character-empty">No feats selected yet.</p> : null}
<label className="character-field">
<span>Feat Notes</span>
<textarea
rows={4}
value={characterDraft.steps.feats.notes}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
feats: {
...draft.steps.feats,
notes: event.target.value
}
}
}))
}
/>
</label>
</aside>
</div>
</section>
) : null}
{characterStep === 5 ? (
<section className="character-section">
<h3>Step 6: Skills</h3>
<div className="chooser-layout">
<div className="chooser-panel chooser-panel--scroll">
<article className="feature-choice-card">
<h4>Granted Proficiencies</h4>
{grantedSkills.length ? (
<div className="chip-row">
{grantedSkills.map((skill) => (
<span key={`granted-${skill}`} className="chip chip--locked">
{skill}
</span>
))}
</div>
) : (
<p className="character-empty">No fixed skill proficiencies from current class/species/background.</p>
)}
</article>
{skillChoiceRequirements.map((choice) => {
const picks = normalizedSkillChoicePicks[choice.id] || [];
return (
<article key={choice.id} className="feature-choice-card">
<h4>
{choice.title} <span className="feature-choice-count">{picks.length}/{choice.count}</span>
</h4>
<div className="feature-choice-grid">
{Array.from({ length: choice.count }).map((_, ix) => (
<label key={`${choice.id}-pick-${ix}`} className="character-field">
<span>{choice.sourceLabel} pick {ix + 1}</span>
<select
value={picks[ix] || ""}
onChange={(event) => {
const picked = toSkillName(event.target.value);
updateDraft((draft) => {
const parsedNotes = parseSkillsNotes(draft.steps.skills.notes);
const nextByChoice = { ...parsedNotes.picksByChoiceId };
const nextPicks = [...(nextByChoice[choice.id] || [])];
nextPicks[ix] = picked || "";
nextByChoice[choice.id] = uniqueStrings(nextPicks.map((it) => toSkillName(it) || "").filter((it) => Boolean(it)));
return {
...draft,
steps: {
...draft.steps,
skills: {
...draft.steps.skills,
notes: stringifySkillsNotes({
text: parsedNotes.text,
picksByChoiceId: nextByChoice,
extraSkills: parsedNotes.extraSkills
})
}
}
};
});
}}
>
<option value="">Select a skill</option>
{choice.options.map((option) => {
const usedElsewhere = picks.some((it, pIx) => pIx !== ix && it === option);
return (
<option key={`${choice.id}-${option}`} value={option} disabled={usedElsewhere}>
{option}
</option>
);
})}
</select>
</label>
))}
</div>
</article>
);
})}
<article className="feature-choice-card">
<h4>Additional Skill Proficiencies</h4>
<p className="feature-choice-hint">
Add selections from class/background/species features which are not automatically parsed yet.
</p>
<div className="chip-toggle-grid">
{SKILL_OPTIONS.map((skill) => {
const selected = manualSkillSelections.includes(skill);
return (
<button
key={`manual-skill-${skill}`}
type="button"
className={selected ? "chip-toggle is-active" : "chip-toggle"}
onClick={() =>
updateDraft((draft) => {
const parsedNotes = parseSkillsNotes(draft.steps.skills.notes);
const next = new Set(
parsedNotes.extraSkills
.map((it) => toSkillName(it))
.filter((it): it is string => Boolean(it))
);
if (next.has(skill)) {
next.delete(skill);
} else {
next.add(skill);
}
return {
...draft,
steps: {
...draft.steps,
skills: {
...draft.steps.skills,
notes: stringifySkillsNotes({
text: parsedNotes.text,
picksByChoiceId: parsedNotes.picksByChoiceId,
extraSkills: Array.from(next).sort((a, b) => a.localeCompare(b))
})
}
}
};
})
}
>
{skill}
</button>
);
})}
</div>
</article>
<label className="character-field">
<span>Skill Notes</span>
<textarea
rows={4}
value={skillNotesState.text}
onChange={(event) =>
updateDraft((draft) => {
const parsedNotes = parseSkillsNotes(draft.steps.skills.notes);
return {
...draft,
steps: {
...draft.steps,
skills: {
...draft.steps.skills,
notes: stringifySkillsNotes({
text: event.target.value,
picksByChoiceId: parsedNotes.picksByChoiceId,
extraSkills: parsedNotes.extraSkills
})
}
}
};
})
}
/>
</label>
</div>
<aside className="chooser-panel chooser-panel--scroll">
<h4>
Skills Matrix • Proficiency Bonus +{getProficiencyBonus(characterDraft.profile.level)}
</h4>
<div className="skill-matrix">
<table>
<thead>
<tr>
<th>Skill</th>
<th>Ability</th>
<th>Mod</th>
<th>Prof</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{SKILL_OPTIONS.map((skill) => {
const ability = SKILL_TO_ABILITY[skill];
const score = abilityTotals?.[ability] ?? characterDraft.steps.abilityScores.scores[ability];
const mod = getAbilityModifierNumber(score);
const isProficient = allProficientSkills.includes(skill);
const profBonus = getProficiencyBonus(characterDraft.profile.level);
const total = mod + (isProficient ? profBonus : 0);
return (
<tr key={`skill-row-${skill}`}>
<td>{skill}</td>
<td>{ABILITY_LABELS[ability]}</td>
<td>{mod >= 0 ? `+${mod}` : mod}</td>
<td>{isProficient ? `+${profBonus}` : "—"}</td>
<td>{total >= 0 ? `+${total}` : total}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</aside>
</div>
</section>
) : null}
{characterStep === 6 ? (
<section className="character-section">
<h3>Step 7: Spells</h3>
{!characterDraft.selections.class ? (
<p className="character-empty">Choose a class first to load its spell list.</p>
) : (
<div className="chooser-layout">
<div className="chooser-panel chooser-panel--scroll">
<h4>Spell Selection</h4>
<label className="character-field">
<span>Search class spells</span>
<input type="search" value={spellSearch} onChange={(event) => setSpellSearch(event.target.value)} />
</label>
<div className="spell-cap-row">
<span>Cantrips: {selectedCantripCount}/{classSpellcasting.cantrips || 0}</span>
<span>Prepared/Known: {selectedPreparedCount}/{classSpellcasting.known || classSpellcasting.prepared || 0}</span>
</div>
<div className="spell-card-list">
<div className="spell-level-group">
<h5>Cantrips</h5>
{selectedCantripPool.map((spell) => {
const token = toNameSourceToken(spell.name, spell.source);
const isSelected = selectedSpellTokens.includes(token);
const canAdd =
isSelected ||
((classSpellcasting.cantrips || 0) > 0 && selectedCantripCount < (classSpellcasting.cantrips || 0));
return (
<button
type="button"
key={spell.key}
className={isSelected ? "spell-pick-card is-selected" : "spell-pick-card"}
onClick={() =>
updateDraft((draft) => {
const current = uniqueStrings(
draft.steps.spells.selected
.map((it) => parseNameSourceToken(it) || parseFeatUid(it))
.filter((it): it is { name: string; source: string } => Boolean(it))
.map((it) => toNameSourceToken(it.name, it.source))
);
const has = current.includes(token);
const next = has ? current.filter((it) => it !== token) : canAdd ? [...current, token] : current;
return {
...draft,
steps: {
...draft.steps,
spells: {
...draft.steps.spells,
selected: next
}
}
};
})
}
disabled={!canAdd}
>
<span className="spell-pick-title">{spell.name}</span>
<span className="spell-pick-meta">Cantrip • {spell.source}</span>
</button>
);
})}
{!selectedCantripPool.length ? <p className="character-empty">No cantrips matched your filters.</p> : null}
</div>
{selectedLeveledSpellPoolByLevel.map((group) => (
<div key={`spell-level-group-${group.level}`} className="spell-level-group">
<h5>Level {group.level}</h5>
{group.spells.map((spell) => {
const token = toNameSourceToken(spell.name, spell.source);
const isSelected = selectedSpellTokens.includes(token);
const preparedCap = classSpellcasting.known || classSpellcasting.prepared || 0;
const canAdd = isSelected || (preparedCap > 0 && selectedPreparedCount < preparedCap);
return (
<button
type="button"
key={spell.key}
className={isSelected ? "spell-pick-card is-selected" : "spell-pick-card"}
onClick={() =>
updateDraft((draft) => {
const current = uniqueStrings(
draft.steps.spells.selected
.map((it) => parseNameSourceToken(it) || parseFeatUid(it))
.filter((it): it is { name: string; source: string } => Boolean(it))
.map((it) => toNameSourceToken(it.name, it.source))
);
const has = current.includes(token);
const next = has ? current.filter((it) => it !== token) : canAdd ? [...current, token] : current;
return {
...draft,
steps: {
...draft.steps,
spells: {
...draft.steps.spells,
selected: next
}
}
};
})
}
disabled={!canAdd}
>
<span className="spell-pick-title">{spell.name}</span>
<span className="spell-pick-meta">Level {group.level} • {spell.source}</span>
</button>
);
})}
</div>
))}
</div>
{selectedCantripPool.length === 0 && selectedLeveledSpellPoolByLevel.length === 0 ? (
<p className="character-empty">No class spells matched your filters.</p>
) : null}
</div>
<aside className="chooser-panel chooser-panel--scroll">
<h4>Selected Spells</h4>
{spellSlotMatrix.length ? (
<>
<div className="slot-matrix">
{spellSlotMatrix.map((slot) => (
<span key={`slot-level-${slot.level}`} className="slot-chip">
L{slot.level}
<strong>{slot.slots}</strong>
</span>
))}
</div>
<div className="slot-detail-list">
{classSpellcasting.slots.map((slot, index) => (
<span key={`slot-detail-${slot.label}-${index}`}>{slot.label}</span>
))}
</div>
</>
) : (
<p className="character-empty">No spell slots at this level.</p>
)}
<div className="spell-selected-list">
{selectedSpellItems.map((spell) => (
<article key={`sel-${spell.key}`} className="spell-selected-card">
<strong>{spell.name}</strong>
<span>
{spell.level === 0 ? "Cantrip" : `Level ${spell.level}`} • {spell.source}
</span>
</article>
))}
</div>
{!selectedSpellItems.length ? <p className="character-empty">No spells selected.</p> : null}
<label className="character-field">
<span>Spell Notes</span>
<textarea
rows={4}
value={characterDraft.steps.spells.notes}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
spells: {
...draft.steps.spells,
notes: event.target.value
}
}
}))
}
/>
</label>
</aside>
</div>
)}
</section>
) : null}
{characterStep === 7 ? (
<section className="character-section">
<h3>Step 8: Equipment</h3>
<div className="chooser-layout">
<div className="chooser-panel chooser-panel--scroll">
<h4>Starting Equipment Choices</h4>
{equipmentChoiceGroups.map((group) => (
<article key={group.id} className="feature-choice-card">
<h4>{group.label}</h4>
<label className="character-field">
<span>Choose one</span>
<select
value={normalizedEquipmentSelections[group.id] || ""}
onChange={(event) =>
updateDraft((draft) => {
const currentState = parseEquipmentSelectionState(
draft.steps.equipment.startingEquipmentText,
draft.steps.equipment.inventoryText
);
currentState.selections[group.id] = event.target.value;
return {
...draft,
steps: {
...draft.steps,
equipment: {
...draft.steps.equipment,
startingEquipmentText: stringifyEquipmentSelectionState(currentState)
}
}
};
})
}
>
{group.options.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
</label>
</article>
))}
{equipmentChoiceGroups.length === 0 ? <p className="character-empty">No starting equipment choices for current selections.</p> : null}
<article className="feature-choice-card">
<h4>Add Inventory Item</h4>
<div className="add-item-row">
<input
type="text"
list="equipment-item-catalog"
value={equipmentItemInput}
onChange={(event) => setEquipmentItemInput(event.target.value)}
placeholder="Add custom or catalog item"
/>
<datalist id="equipment-item-catalog">
{itemCatalog.slice(0, 500).map((item) => (
<option key={`item-opt-${item}`} value={item} />
))}
</datalist>
<button
type="button"
onClick={() => {
const trimmed = equipmentItemInput.trim();
if (!trimmed) {
return;
}
updateDraft((draft) => {
const currentState = parseEquipmentSelectionState(
draft.steps.equipment.startingEquipmentText,
draft.steps.equipment.inventoryText
);
if (!currentState.addedItems.includes(trimmed)) {
currentState.addedItems = [...currentState.addedItems, trimmed];
}
return {
...draft,
steps: {
...draft.steps,
equipment: {
...draft.steps.equipment,
startingEquipmentText: stringifyEquipmentSelectionState(currentState),
inventoryText: currentState.addedItems.join("\n")
}
}
};
});
setEquipmentItemInput("");
}}
>
Add
</button>
</div>
<div className="chip-row">
{equipmentSelectionState.addedItems.map((item) => (
<button
key={`added-item-${item}`}
type="button"
className="chip chip--removable"
onClick={() =>
updateDraft((draft) => {
const currentState = parseEquipmentSelectionState(
draft.steps.equipment.startingEquipmentText,
draft.steps.equipment.inventoryText
);
currentState.addedItems = currentState.addedItems.filter((it) => it !== item);
return {
...draft,
steps: {
...draft.steps,
equipment: {
...draft.steps.equipment,
startingEquipmentText: stringifyEquipmentSelectionState(currentState),
inventoryText: currentState.addedItems.join("\n")
}
}
};
})
}
>
{item} ×
</button>
))}
</div>
</article>
<label className="character-field">
<span>Personal Possessions</span>
<textarea
rows={3}
value={characterDraft.steps.equipment.possessionsText}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
equipment: {
...draft.steps.equipment,
possessionsText: event.target.value
}
}
}))
}
/>
</label>
</div>
<aside className="chooser-panel chooser-panel--scroll">
<h4>Starting Inventory</h4>
<div className="inventory-list">
{resolvedEquipmentRows.map((row) => (
<div key={`inventory-item-${row.label}`} className="inventory-item">
<span>{row.label}</span>
<strong>{row.count}</strong>
</div>
))}
</div>
{!resolvedEquipmentRows.length ? <p className="character-empty">No equipment selected yet.</p> : null}
<div className="currency-grid">
{(["cp", "sp", "ep", "gp", "pp"] as const).map((coin) => (
<label key={coin} className="character-field">
<span>{coin.toUpperCase()}</span>
<input type="number" min={0} value={characterDraft.steps.equipment.currency[coin]} readOnly />
</label>
))}
</div>
</aside>
</div>
</section>
) : null}
{characterStep === 8 ? (
<section className="character-section">
<h3>Step 9: About</h3>
<div className="character-grid">
<label className="character-field">
<span>Alignment</span>
<input
type="text"
value={characterDraft.profile.alignment}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
profile: { ...draft.profile, alignment: event.target.value }
}))
}
/>
</label>
<label className="character-field">
<span>Faith</span>
<input
type="text"
value={characterDraft.profile.faith}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
profile: { ...draft.profile, faith: event.target.value }
}))
}
/>
</label>
<label className="character-field">
<span>Lifestyle</span>
<input
type="text"
value={characterDraft.profile.lifestyle}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
profile: { ...draft.profile, lifestyle: event.target.value }
}))
}
/>
</label>
<label className="character-field">
<span>Age</span>
<input
type="text"
value={characterDraft.profile.age}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
profile: { ...draft.profile, age: event.target.value }
}))
}
/>
</label>
<label className="character-field">
<span>Gender</span>
<input
type="text"
value={characterDraft.profile.gender}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
profile: { ...draft.profile, gender: event.target.value }
}))
}
/>
</label>
<label className="character-field">
<span>Height</span>
<input
type="text"
value={characterDraft.profile.height}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
profile: { ...draft.profile, height: event.target.value }
}))
}
/>
</label>
<label className="character-field">
<span>Weight</span>
<input
type="text"
value={characterDraft.profile.weight}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
profile: { ...draft.profile, weight: event.target.value }
}))
}
/>
</label>
<label className="character-field">
<span>Eyes</span>
<input
type="text"
value={characterDraft.profile.eyes}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
profile: { ...draft.profile, eyes: event.target.value }
}))
}
/>
</label>
<label className="character-field">
<span>Skin</span>
<input
type="text"
value={characterDraft.profile.skin}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
profile: { ...draft.profile, skin: event.target.value }
}))
}
/>
</label>
<label className="character-field">
<span>Hair</span>
<input
type="text"
value={characterDraft.profile.hair}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
profile: { ...draft.profile, hair: event.target.value }
}))
}
/>
</label>
</div>
<label className="character-field">
<span>Personality Traits</span>
<textarea
rows={3}
value={characterDraft.steps.about.personality}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
about: {
...draft.steps.about,
personality: event.target.value
}
}
}))
}
/>
</label>
<label className="character-field">
<span>Ideals</span>
<textarea
rows={2}
value={characterDraft.steps.about.ideals}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
about: {
...draft.steps.about,
ideals: event.target.value
}
}
}))
}
/>
</label>
<label className="character-field">
<span>Bonds</span>
<textarea
rows={2}
value={characterDraft.steps.about.bonds}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
about: {
...draft.steps.about,
bonds: event.target.value
}
}
}))
}
/>
</label>
<label className="character-field">
<span>Flaws</span>
<textarea
rows={2}
value={characterDraft.steps.about.flaws}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
about: {
...draft.steps.about,
flaws: event.target.value
}
}
}))
}
/>
</label>
<label className="character-field">
<span>Backstory</span>
<textarea
rows={4}
value={characterDraft.steps.about.backstory}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
about: {
...draft.steps.about,
backstory: event.target.value
}
}
}))
}
/>
</label>
<label className="character-field">
<span>About Notes</span>
<textarea
rows={3}
value={characterDraft.steps.about.notes}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
steps: {
...draft.steps,
about: {
...draft.steps.about,
notes: event.target.value
}
}
}))
}
/>
</label>
</section>
) : null}
{characterStep === 9 ? (
<section className="character-section">
<h3>Finish</h3>
<div className="character-summary">
<p>
<strong>Name:</strong> {characterDraft.profile.name}
</p>
<p>
<strong>Level:</strong> {characterDraft.profile.level}
</p>
<p>
<strong>Class:</strong> {characterDraft.selections.class?.name || "Not set"}
</p>
<p>
<strong>Subclass:</strong> {characterDraft.selections.subclass?.name || "Not set"}
</p>
<p>
<strong>Species:</strong> {characterDraft.selections.species?.name || "Not set"}
</p>
<p>
<strong>Background:</strong> {characterDraft.selections.background?.name || "Not set"}
</p>
<p>
<strong>Source Buckets:</strong> {characterDraft.steps.preferences.sourceBuckets.join(", ") || "None"}
</p>
</div>
<label className="character-field">
<span>General Notes</span>
<textarea
rows={5}
value={characterDraft.notes}
onChange={(event) =>
updateDraft((draft) => ({
...draft,
notes: event.target.value
}))
}
/>
</label>
<div className="character-finish-actions">
<button type="button" onClick={() => void saveCharacterDraft()} disabled={isCharacterBusy}>
Save Character
</button>
<button type="button" className="is-subtle" onClick={() => void openCharacterList()} disabled={isCharacterBusy}>
Back to My Characters
</button>
</div>
</section>
) : null}
<footer className="character-flow-footer">
<button type="button" className="character-flow-nav" onClick={prevStep} disabled={characterStep === 0 || isCharacterBusy}>
Previous
</button>
<div className="character-flow-rail" role="tablist" aria-label="Character creator steps">
{progressionSteps.map((stepLabel, index) => {
const isActive = characterStep === index;
const isComplete = stepCompletion[index];
const className = [
"character-flow-step",
isActive ? "is-active" : "",
isComplete ? "is-complete" : "is-pending"
]
.filter(Boolean)
.join(" ");
return (
<button
key={stepLabel}
type="button"
className={className}
onClick={() => setCharacterStep(index)}
disabled={!characterDraft}
role="tab"
aria-selected={isActive}
title={stepLabel}
>
<span className="character-flow-step-index">{isComplete ? "✓" : index + 1}</span>
{isActive ? <span className="character-flow-step-label">{stepLabel}</span> : null}
</button>
);
})}
<button
type="button"
className={characterStep === CHARACTER_STEPS.length - 1 ? "character-flow-step is-active is-review" : "character-flow-step is-review"}
onClick={() => setCharacterStep(CHARACTER_STEPS.length - 1)}
disabled={!characterDraft}
role="tab"
aria-selected={characterStep === CHARACTER_STEPS.length - 1}
title="Review Character"
>
<span className="character-flow-step-label">Review</span>
</button>
</div>
<button
type="button"
className="character-flow-nav"
onClick={nextStep}
disabled={characterStep >= CHARACTER_STEPS.length - 1 || !canMoveNext || isCharacterBusy}
>
{characterStep >= CHARACTER_STEPS.length - 2 ? "Review" : "Next"}
</button>
</footer>
{entityPopover ? (
<div
className="entity-popover-backdrop"
role="presentation"
onClick={() => setEntityPopover(null)}
>
<section
className="entity-popover"
role="dialog"
aria-modal="true"
aria-label={`${entityPopover.kind} details`}
onClick={(event) => event.stopPropagation()}
>
<header className="entity-popover-header">
<h4>
{entityPopover.kind === "class"
? "Class details"
: entityPopover.kind === "species"
? "Species details"
: "Background details"}
</h4>
<button type="button" className="is-subtle" onClick={() => setEntityPopover(null)}>
Close
</button>
</header>
{renderEntityDetailContent(entityPopoverDetail, entityPopover.kind)}
</section>
</div>
) : null}
</div>
) : (
<p className="character-empty">No character loaded.</p>
)}
</div>
)}
</div>
</section>
)}
</section>
</main>
);
}
export default function App() {
const isSetupMode = useMemo(() => {
const params = new URLSearchParams(window.location.search);
return params.get("setup") === "1";
}, []);
if (isSetupMode) {
return <SetupView />;
}
return <WikiView />;
}