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

0
data/spells.json Normal file
View File

50
data/spells.schema.json Normal file
View File

@@ -0,0 +1,50 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "data/spells.schema.json",
"title": "Spell Sheet",
"description": "A collection of Fabula Ultima spells and arcana entries.",
"type": "object",
"properties": {
"spells": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Name of the spell or arcana."
},
"cost": {
"type": "string",
"minimum": 0,
"description": "MP cost to cast the spell."
},
"targets": {
"type": "string",
"description": "Who or what the spell affects (e.g. \"One creature\", \"All enemies\")."
},
"duration": {
"type": "string",
"description": "How long the spell's effect lasts (e.g. \"Instantaneous\", \"Scene\")."
},
"description": {
"type": "string",
"description": "Full effect description and any special rules for the spell."
},
"class": {
"type": "string",
"enum": ["arcanist", "chimerist", "elementalist", "entropist", "spiritist"],
"description": "The magic-using class this spell belongs to."
},
"offensive": {
"type": "boolean",
"description": "Whether the spell is offensive in nature."
}
},
"required": ["name", "cost", "targets", "duration", "description", "class"],
"additionalProperties": false
}
}
},
"required": ["spells"]
}

287
data/spells.yml Normal file
View File

@@ -0,0 +1,287 @@
spells:
- name: Elemental Shroud
cost: 5 x T
targets: Up to three creatures
duration: scene
description: "You weave magical energy and protect the targets from the fury of the elements. Choose a damage type: air, bolt, earth, fire or ice. Until this spell ends, each target gains Resistance against the chosen damage type."
class: elementalist
- name: Elemental Weapon
cost: "10"
targets: One weapon
duration: Scene
description: "You imbue a weapon with elemental energy. Choose a damage type: air, bolt, earth, fire, or ice. Until this spell ends, all damage dealt by the weapon becomes of the chosen damage type. If you have that weapon equipped while you cast this spell, you may perform a free attack with it as part of the same action. This spell can only be cast on a weapon equipped by a willing creature."
class: elementalist
- name: Flare
cost: "20"
targets: One creature
duration: Instantaneous
description: "You channel a single ray of fire towards your foe, its temperature so high that it will pierce through most defenses. The target suffers【 HR + 25】 fire damage. Damage dealt by this spell ignores Resistances."
class: elementalist
offensive: true
- name: Fulgur
cost: "10 x T"
targets: Up to three creatures
duration: Instantaneous
description: "You weave electricity into a wave of crackling bolts. Each target hit by this spell suffers 【HR + 15】 bolt damage. Opportunity: Each target hit by this spell suffers dazed."
class: elementalist
offensive: true
- name: Glacies
cost: 10 x T
targets: Up to three creatures
duration: Instantaneous
description: "You coat your foes under a thick layer of frost. Each target hit by this spell suffers【 HR + 15】 ice damage. Opportunity: Each target hit by this spell suffers slow."
class: elementalist
offensive: true
- name: Iceberg
cost: "20"
targets: One creature
duration: Instantaneous
description: "A pillar of ice magic envelops your foe, suddenly dropping their body temperature to a critical level. The target suffers【 HR + 25】 ice damage. Damage dealt by this spell ignores Resistances."
class: elementalist
offensive: true
- name: Ignis
cost: 10 x T
targets: Up to three creatures
duration: Instantaneous
description: "You unleash a searing barrage against your foes, conjuring flames out of thin air. Each target hit by this spell suffers【 HR + 15】 fire damage. Opportunity: Each target hit by this spell suffers shaken."
class: elementalist
offensive: true
- name: Soaring Strike
cost: "10"
targets: Self
duration: Instantaneous
description: "The wind carries your strikes across the battlefield. You may immediately perform a free attack with a melee weapon you have equipped. This attack may target creatures that can only be targeted by ranged attacks. If you used a weapon belonging to the brawling or spear Category for this attack, it deals 5 extra damage. If you hit a flying target with this attack, you may force them to land immediately."
class: elementalist
- name: Terra
cost: 10 x T
targets: Up to three creatures
duration: Instantaneous
description: "Spires of jagged rock erupt from the ground beneath your foes, closing around them. Each target hit by this spell suffers【 HR + 15】 earth damage. This spell cannot target creatures who are flying, floating, falling, or otherwise in mid-air. Opportunity: Each target hit by this spell performs one fewer action on their next turn (to a minimum of 0 actions)."
class: elementalist
offensive: true
- name: Thunderbolt
cost: "20"
targets: One creature
duration: Instantaneous
description: "You send lightning striking at your foe. The target suffers【 HR + 25】 bolt damage. Damage dealt by this spell ignores Resistances."
class: elementalist
offensive: true
- name: Ventus
cost: 10 x T
targets: One creature
duration: Instantaneosu
description: "You summon the power of winds against your enemy. Each target hit by this spell suffers【 HR + 15】 air damage. Opportunity: Each flying target hit by this spell is forced to land immediately."
class: elementalist
offensive: true
- name: Acceleration
cost: "20"
targets: One creature
duration: Scene
description: "You bend the fabric of time. Until this spell ends, the target gains the ability to perform a single additional action during each of their turns. Once the target has performed a total of two additional actions granted by this spell, this spell ends."
class: entropist
offensive: false
- name: Anomaly
cost: "20"
targets: One creature
duration: Scene
description: "You alter the very nature of your target. Until this spell ends, if the target would suffer damage of a type they Absorb or are Immune to, they are instead treated as if they were Vulnerable to that damage type. Once that happens, this spell ends."
class: entropist
offensive: true
- name: Dark Weapon
cost: "10"
targets: One equipped weapon
duration: Scene
description: "You imbue a weapon with dark energy. Until this spell ends, all damage dealt by the weapon becomes of the dark type. If you have that weapon equipped while you cast this spell, you may perform a free attack with it as part of the same action. This spell can only be cast on a weapon equipped by a willing creature."
class: entropist
offensive: false
- name: Dispel
cost: "10"
targets: One creature
duration: Instantaneous
description: "You release a wave of negative energy and cleanse all magic from a creature. If the target is affected by one or more spells with a duration of Scene, they are no longer affected by any of those spells instead."
class: entropist
offensive: false
- name: Divination
cost: "10"
targets: Self
duration: Scene
description: "You glimpse briefly into the future. Until this spell ends, after a creature you can see performs a Check, if it was not a fumble nor a critical success, you may force that creature to reroll both dice. Once you have forced two rerolls this way, this spell ends."
class: entropist
offensive: false
- name: Drain Spirit
cost: "5"
targets: One creature
duration: Instantaneous
description: "You consume a creature's psyche. The target loses【 HR + 15】 Mind Points. Then, you recover an amount of Mind Points equal to half the Mind Points loss they suffered (if the loss was reduced to 0 in some way, you recover none)."
class: entropist
offensive: true
- name: Drain Vigor
cost: "10"
targets: One creature
duration: Instantaneous
description: "You steal another creature's life force. The target suffers【 HR + 15】 dark damage. Then, you recover an amount of Hit Points equal to half the Hit Points loss they suffered (if the loss was reduced to 0 in some way, you recover none)."
class: entropist
offensive: true
- name: Gamble
cost: Up to 20
targets: Special
duration: Scene
description: |
You summon a vortex of chaotic energy. Roll your current Willpower die once for every 10 Mind Points spent while casting this spell, then keep the single die you prefer: the number on that die determines the effects of this spell.
1) You lose half of your current Hit Points and half of your current Mind Points.
2-3) Each creature present on the scene, including yourself, suffers poisoned.
4-6) Each creature present on the scene, including yourself, suffers slow.
7-8) Choose up to three creatures you can see: each of them recovers 50 Hit Points and also recovers from all status effects.
9+) Choose any number of creatures you can see: each of them suffers 30 damage. The damage type is determined randomly by rolling a d6: 1. air 2. bolt 3. dark 4. earth 5. fire 6. poison
class: entropist
offensive: false
- name: Mirror
cost: "10"
targets: One creature
duration: Scene
description: "You twist the laws of magic. Until this spell ends, if an offensive (r) spell is cast on the target, the creature who cast that offensive spell will be targeted in their stead (any other targets of the offensive spell will be targeted as normal). Once that happens, this spell ends."
class: entropist
offensive: false
- name: Omega
cost: "20"
targets: One creature
duration: Instantaneous
description: "You invoke doom on your foe, turning strength into frailty. The target loses an amount of Hit Points equal to【 20 + half the target's level】"
class: entropist
offensive: true
- name: Stop
cost: "10"
targets: One creature
duration: Instantaneous
description: "You trap a foe inside a circle of altered time and space. The target will perform one fewer action on their next turn (to a minimum of 0 actions)."
class: entropist
offensive: true
- name: Umbra
cost: 10 x T
targets: Up to three creatures
duration: Scene
description: "A storm of dark energy turns matter into ash. Each target hit by this spell suffers 【HR + 15】 dark damage. Opportunity: Each target hit by this spell suffers weak."
class: entropist
offensive: true
- name: Vortex
cost: "10"
targets: Self
duration: Scene
description: "A roaring gale surrounds you, blowing away arrows and bullets. Until this spell ends, you gain a +2 bonus to your Defense against ranged attacks."
class: elementalist
- name: Aura
cost: 5 x T
targets: Up to three creatures
duration: scene
description: You project your soul outside your body and direct it to surround the targets, shielding them from dangerous magic. Until this spell ends, each target may treat their Magic Defense as being equal to 12 against any effects that target it (they are still free to use their normal Defense score if higher than 12).
class: spiritist
- name: Awaken
cost: "20"
targets: One creature
duration: scene
description: "You allow a creature to focus their vital energy into accomplishing what they previously could not. Choose one Attribute: Dexterity, Insight, Might, or Willpower. Until this spell ends, the target treats the chosen Attribute as if it were one die size higher (up to a maximum of d12)."
class: spiritist
- name: Barrier
cost: 5 x T
targets: Up to three creatures
duration: scene
description: You project your soul outside your body and weave it into a barrier to protect the targets from attacks. Until this spell ends, each target may treat their Defense as being equal to 12 against any effects that target it (they are still free to use their normal Defense score if higher than 12).
class: spiritist
- name: Cleanse
cost: 5 x T
targets: Up to three creatures
duration: Instantaneous
description: You strengthen and purify the soul energy coursing through your companions. Each target recovers from all status effects.
class: spiritist
- name: Enrage
cost: rr 10
targets: One creature
duration: Instantaneous
description: You cause a creature to lose any semblance of temper and act brazenly. The target suffers enraged and cannot perform the Guard or Spell actions during their next turn.
class: spiritist
offensive: true
- name: Hallucination
cost: rr 5 x T
targets: Up to three creatures
duration: Instantaneous
description: "You alter the senses of your enemies, causing them to experience bizarre or frightening hallucinations. Choose dazed or shaken: you inflict the chosen status effect on each target hit by this spell."
class: spiritist
offensive: true
- name: Heal
cost: 10 x T
targets: Up to three creatures
duration: Instantaneous
description: "You invigorate your companions, soothing their pain and healing their fatigue. Each target recovers 40 Hit Points. This amount increases to 50 Hit Points if you are level 20 or higher, or to 60 Hit Points if you are level 40 or higher."
class: spiritist
offensive: false
- name: Lux
cost: 10 x T
targets: Up to three creatures
duration: Instantaneous
description: "You focus your inner energy into a barrage of blinding soul rays. Each target hit by this spell suffers【 HR + 15】 light damage. Opportunity: Each target hit by this spell suffers dazed."
class: spiritist
offensive: true
- name: Mercy
cost: "20"
targets: One creature
duration: Scene
description: "You strengthen the heart of a creature against suffering and despair. Until this spell ends, if the target would be reduced to 0 Hit Points, they are instead left standing with exactly 1 Hit Point. Once that happens, this spell ends."
class: spiritist
offensive: false
- name: Reinforce
cost: 5 x T
targets: Up to three creatures
duration: Scene
description: "You protect the targets from attacks that would corrupt their body and spirit. Choose dazed, enraged, poisoned, shaken, slow, or weak. Until this spell ends, each target becomes immune to the chosen status effect."
class: spiritist
offensive: false
- name: Soul Weapon
cost: "10"
targets: One equipped weapon
duration: Scene
description: "You imbue a weapon with the cleansing energy of your spirit. Until this spell ends, all damage dealt by the weapon becomes of the light type. If you have that weapon equipped while you cast this spell, you may perform a free attack with it as part of the same action. This spell can only be cast on a weapon equipped by a willing creature."
class: spiritist
offensive: false
- name: Torpor
cost: 5 x T
targets: Up to three creatures
duration: Instantaneous
description: "You smother the soul energy coursing through the bodies of your foes, hindering their movements. Choose slow or weak: you inflict the chosen status effect on each target hit by this spell."
class: spiritist
offensive: true

40
package-lock.json generated
View File

@@ -24,7 +24,8 @@
"typescript": "^6.0.3",
"webpack": "^5.107.2",
"webpack-cli": "^7.0.3",
"webpack-dev-server": "^5.2.4"
"webpack-dev-server": "^5.2.4",
"yaml-loader": "^0.9.0"
}
},
"node_modules/@babel/code-frame": {
@@ -4243,6 +4244,13 @@
"node": ">=0.10.0"
}
},
"node_modules/javascript-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/javascript-stringify/-/javascript-stringify-2.1.0.tgz",
"integrity": "sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg==",
"dev": true,
"license": "MIT"
},
"node_modules/jest-regex-util": {
"version": "30.4.0",
"resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz",
@@ -7233,6 +7241,36 @@
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"dev": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yaml-loader": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/yaml-loader/-/yaml-loader-0.9.0.tgz",
"integrity": "sha512-N0bPfDR42qCruELfmpF6Q8XnDu1elTULGjvVn7u6rsfDa8WhlmJbjXQRh+x5TGmpOqw8R9qfiUgc3aKP+8Hd4w==",
"dev": true,
"license": "MIT",
"dependencies": {
"javascript-stringify": "^2.0.1",
"yaml": "^2.0.0"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@@ -25,6 +25,7 @@
"typescript": "^6.0.3",
"webpack": "^5.107.2",
"webpack-cli": "^7.0.3",
"webpack-dev-server": "^5.2.4"
"webpack-dev-server": "^5.2.4",
"yaml-loader": "^0.9.0"
}
}

69
scripts/anchors.py Normal file
View File

@@ -0,0 +1,69 @@
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "bs4",
# ]
# ///
from bs4 import BeautifulSoup
import re
from pathlib import Path
# Dictionary to track how many times we've seen each header ID
header_seen = {}
def add_anchors_to_headers(html_content):
# Parse the HTML content
soup = BeautifulSoup(html_content, 'html.parser')
# Find all header tags (h1 through h6)
header_tags = soup.find_all(['h1', 'h2', 'h3', 'h4', 'h5', 'h6'])
# Add a count-based ID to each header tag
for header in header_tags:
# Extract header text (fallback to tag name if empty)
header_text = header.get_text(strip=True) or header.name
# Normalize header
header_text = header_text.lower().replace(" ", "-")
# Create base ID: hN-<text>
base_id = f"{header.name}-{header_text}"
# Check if we've seen this base ID before
if base_id in header_seen:
# Append next count: -1, -2, -3...
count = header_seen[base_id]
header_seen[base_id] = count + 1
header_id = f"{base_id}-{count + 1}"
else:
# First time seeing this text → count starts at 1
header_seen[base_id] = 1
header_id = base_id
# Add the ID to the header tag
header['id'] = header_id
# Wrap the header in an anchor link (clickable permalink)
# The link points to itself via the id attribute
# anchor_tag = soup.new_tag("a", href=f"#{header_id}", class_="anchor-link")
# anchor_tag.string = f"🔗"
# header.wrap(anchor_tag)
# Return the modified HTML content
return str(soup)
for book in ('./books/core', './books/natural-fantasy-atlas'):
for root, dirs, files in Path(book).walk():
for fn in files:
path = root / fn
if path.suffix != ".html":
continue
with path.open('r') as fh:
raw_html = fh.read()
new_html = add_anchors_to_headers(raw_html)
with path.open('w') as fh:
fh.write(new_html)

15
shell.nix Normal file
View File

@@ -0,0 +1,15 @@
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShellNoCC {
packages = with pkgs; [
go
gopls
just
nodejs
starship
typescript-language-server
];
shellHook = ''
eval "$(starship init bash)"
'';
}

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>
<div style={{ display: "flex", gap: 8, marginTop: 10 }}>
<button
className="add-btn"
style={{ marginTop: 10 }}
onClick={() =>
setSpells((prev) => [
...prev,
{ name: "", notes: "", mp: "", targets: "", duration: "" },
{ 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;

View File

@@ -65,6 +65,10 @@ module.exports = (env, argv) => {
},
},
},
{
test: /\.ya?ml$/,
use: "yaml-loader",
},
{
test: /\.css$/,
use: [