feat: Add template spell picker from spells.yml; polish spell table
All checks were successful
Deploy / deploy (push) Successful in 1m20s

- Add yaml-loader; import data/spells.yml at build time
- Add SpellTemplate/SpellsFile types to globals.d.ts
- Add 'Add Template Spell' button that opens a modal picker pre-filling
  all spell fields from the YAML data
- Move spell data files into data/ directory
- Split spell rows into inputs row + full-width notes row (colspan=6)
- Shrink delete column to fit-content; bold spell name input
- Add class column to spell table; change MP cost to free-text input
- Auto-resize spell notes textarea on load and on input
- Add 10px padding between spells for visual separation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 17:52:02 -04:00
committed by drew
parent d655ad4afc
commit 7f81f85735
11 changed files with 649 additions and 20 deletions

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect, useCallback, useRef } from "react";
import "./fabula-ultima-sheet.css";
import spellsFile from "../data/spells.yml";
const STATUSES = ["Slow", "Enraged", "Dazed", "Weak", "Poisoned", "Shaken"];
const FEELINGS = [
@@ -77,8 +78,9 @@ interface ClassEntry {
interface Spell {
name: string;
spellClass: string;
notes: string;
mp: string | number;
mp: string;
targets: string;
duration: string;
}
@@ -232,6 +234,7 @@ export default function CharacterSheet() {
const [primaryClasses, setPrimaryClasses] = useState<ClassEntry[]>([]);
const [otherClasses, setOtherClasses] = useState<ClassEntry[]>([]);
const [spells, setSpells] = useState<Spell[]>([]);
const [spellPickerOpen, setSpellPickerOpen] = useState(false);
const [statuses, setStatuses] = useState<CheckMap>({});
const [martial, setMartial] = useState<CheckMap>({});
const [disciplines, setDisciplines] = useState<CheckMap>({});
@@ -315,6 +318,7 @@ export default function CharacterSheet() {
oc: otherClasses.map((c) => ({ n: c.name, b: c.benefits, s: c.skills })),
sp: spells.map((s) => ({
n: s.name,
cl: s.spellClass,
nt: s.notes,
mp: s.mp,
tg: s.targets,
@@ -422,6 +426,7 @@ export default function CharacterSheet() {
setSpells(
rawSpells.map((s: SavedData) => ({
name: s.n ?? s.name ?? "",
spellClass: s.cl ?? s.spellClass ?? "",
notes: s.nt ?? s.notes ?? "",
mp: s.mp ?? "",
targets: s.tg ?? s.targets ?? "",
@@ -1327,6 +1332,7 @@ export default function CharacterSheet() {
<thead>
<tr>
<th className="spell-name-col">Name / Notes</th>
<th className="spell-class-col">Class</th>
<th className="spell-mp-col">MP Cost</th>
<th className="spell-targets-col">Targets</th>
<th className="spell-dur-col">Duration</th>
@@ -1351,10 +1357,24 @@ export default function CharacterSheet() {
}
/>
</td>
<td className="spell-class-col">
<input
type="text"
placeholder="Class…"
value={s.spellClass || ""}
onChange={(e) =>
setSpells((prev) =>
prev.map((sp, j) =>
j === i ? { ...sp, spellClass: e.target.value } : sp,
),
)
}
/>
</td>
<td className="spell-mp-col">
<input
type="number"
placeholder="0"
type="text"
placeholder="MP cost…"
value={s.mp || ""}
onChange={(e) =>
setSpells((prev) =>
@@ -1363,7 +1383,6 @@ export default function CharacterSheet() {
),
)
}
style={{ minHeight: 32 }}
/>
</td>
<td className="spell-targets-col">
@@ -1406,7 +1425,7 @@ export default function CharacterSheet() {
</td>
</tr>
<tr className="spell-notes-row">
<td colSpan={5}>
<td colSpan={6}>
<textarea
placeholder="Notes / effect description…"
value={s.notes || ""}
@@ -1426,18 +1445,61 @@ export default function CharacterSheet() {
))}
</tbody>
</table>
<button
className="add-btn"
style={{ marginTop: 10 }}
onClick={() =>
setSpells((prev) => [
...prev,
{ name: "", notes: "", mp: "", targets: "", duration: "" },
])
}
>
+ Add Spell / Arcana
</button>
<div style={{ display: "flex", gap: 8, marginTop: 10 }}>
<button
className="add-btn"
onClick={() =>
setSpells((prev) => [
...prev,
{ name: "", spellClass: "", notes: "", mp: "", targets: "", duration: "" },
])
}
>
+ Add Spell / Arcana
</button>
<button
className="add-btn"
onClick={() => setSpellPickerOpen(true)}
>
+ Add Template Spell
</button>
</div>
{spellPickerOpen && (
<div className="spell-picker-overlay" onClick={() => setSpellPickerOpen(false)}>
<div className="spell-picker-modal" onClick={(e) => e.stopPropagation()}>
<div className="spell-picker-header">
<span>Choose a spell</span>
<button className="spell-picker-close" onClick={() => setSpellPickerOpen(false)}></button>
</div>
<ul className="spell-picker-list">
{(spellsFile as SpellsFile).spells.map((t, i) => (
<li
key={i}
className="spell-picker-item"
onClick={() => {
setSpells((prev) => [
...prev,
{
name: t.name,
spellClass: t.class,
notes: t.description,
mp: t.cost,
targets: t.targets,
duration: t.duration,
},
]);
setSpellPickerOpen(false);
}}
>
<span className="spell-picker-name">{t.name}</span>
<span className="spell-picker-class">{t.class}</span>
</li>
))}
</ul>
</div>
</div>
)}
</div>
<div className="section">

View File

@@ -786,8 +786,16 @@ input[type="number"] {
width: 35%;
}
.spell-name-col input {
font-weight: bold;
}
.spell-class-col {
width: 14%;
}
.spell-mp-col {
width: 12%;
width: 10%;
}
.spell-targets-col {
@@ -839,6 +847,82 @@ input[type="number"] {
color: var(--text-bright);
}
.spell-picker-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
}
.spell-picker-modal {
background: var(--surface2);
border: 1px solid var(--border-bright);
width: min(480px, 90vw);
max-height: 70vh;
display: flex;
flex-direction: column;
}
.spell-picker-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid var(--border-bright);
font-family: var(--font-display);
font-size: 0.6rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--teal);
}
.spell-picker-close {
background: none;
border: none;
cursor: pointer;
color: var(--text-dim);
font-size: 0.9rem;
line-height: 1;
padding: 0;
}
.spell-picker-close:hover {
color: var(--red);
}
.spell-picker-list {
list-style: none;
margin: 0;
padding: 0;
overflow-y: auto;
}
.spell-picker-item {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 8px 14px;
cursor: pointer;
border-bottom: 1px solid var(--border);
}
.spell-picker-item:hover {
background: var(--surface3);
}
.spell-picker-name {
font-size: 0.9rem;
}
.spell-picker-class {
font-size: 0.7rem;
color: var(--text-dim);
text-transform: capitalize;
}
/* ── DISCIPLINES ─────────────────────────────────── */
.disciplines-row {
display: flex;

19
src/globals.d.ts vendored
View File

@@ -1,5 +1,24 @@
declare module "*.css";
declare module "*.yml" {
const data: unknown;
export default data;
}
interface SpellTemplate {
name: string;
cost: string;
targets: string;
duration: string;
description: string;
class: string;
offensive?: boolean;
}
interface SpellsFile {
spells: SpellTemplate[];
}
interface BookPage {
n: number;
content: string;