Files

503 lines
14 KiB
HTML
Raw Permalink 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.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Oswin Eisenbach — Spell Cards</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/flowbite/2.3.0/flowbite.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400;1,600&display=swap" rel="stylesheet" />
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
cinzel: ['Cinzel', 'serif'],
crimson: ['"Crimson Text"', 'serif'],
},
colors: {
gold: '#c9a84c',
'gold-light': '#e8c96e',
'gold-dim': '#8a6a28',
parchment: '#f0e6c8',
'parchment-dark': '#d8c89a',
ink: '#1c1208',
leather: '#2d1b0e',
'leather-mid': '#4a2e14',
navy: '#1a1a2e',
}
}
}
};
</script>
<style>
/* ─── CARD SIZE: 2.5 × 3.5 inches at 96dpi = 240 × 336px ─── */
:root {
--card-w: 240px;
--card-h: 336px;
}
body {
background: #0e0e1a;
font-family: 'Crimson Text', serif;
}
/* school colour palette */
.school-conjuration { --school-col: #7c3aed; --school-bg: #2e1065; }
.school-transmutation { --school-col: #d97706; --school-bg: #451a03; }
.school-illusion { --school-col: #6366f1; --school-bg: #1e1b4b; }
.school-divination { --school-col: #0ea5e9; --school-bg: #0c2a4a; }
.school-evocation { --school-col: #ef4444; --school-bg: #450a0a; }
.school-abjuration { --school-col: #10b981; --school-bg: #052e16; }
.school-enchantment { --school-col: #ec4899; --school-bg: #500724; }
.school-necromancy { --school-col: #84cc16; --school-bg: #1a2e05; }
.spell-card {
width: var(--card-w);
height: var(--card-h);
border-radius: 8px;
background: #f0e6c8;
position: relative;
overflow: hidden;
box-shadow: 0 4px 24px rgba(0,0,0,0.7), inset 0 0 0 1px rgba(201,168,76,0.4);
display: flex;
flex-direction: column;
flex-shrink: 0;
page-break-inside: avoid;
break-inside: avoid;
}
/* flag: distinguishes cantrips (at-will, grey) from leveled spells (slot, gold) at a glance.
Sits in normal flex flow above the school pip so it never overlaps the card name. */
.level-flag {
font-family: 'Cinzel', serif;
font-size: 6.5px;
font-weight: 700;
letter-spacing: 0.05em;
padding: 1.5px 6px;
border-radius: 3px;
color: #fff;
text-transform: uppercase;
white-space: nowrap;
text-align: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.45);
}
.level-flag-cantrip { background: linear-gradient(135deg, #94a3b8, #475569); }
.level-flag-spell { background: linear-gradient(135deg, #e8c96e, #8a6a28); }
/* outer gold border inset */
.spell-card::before {
content: '';
position: absolute;
inset: 5px;
border: 1.5px solid #c9a84c55;
border-radius: 4px;
pointer-events: none;
z-index: 10;
}
/* corner flourishes */
.spell-card::after {
content: '';
position: absolute;
inset: 5px;
background:
radial-gradient(circle at 0 0, #c9a84c 1.5px, transparent 1.5px) 5px 5px / 1px 1px no-repeat,
radial-gradient(circle at 100% 0, #c9a84c 1.5px, transparent 1.5px) calc(100% - 5px) 5px / 1px 1px no-repeat,
radial-gradient(circle at 0 100%, #c9a84c 1.5px, transparent 1.5px) 5px calc(100% - 5px) / 1px 1px no-repeat,
radial-gradient(circle at 100% 100%, #c9a84c 1.5px, transparent 1.5px) calc(100% - 5px) calc(100% - 5px) / 1px 1px no-repeat;
pointer-events: none;
z-index: 10;
}
.card-header {
background: linear-gradient(135deg, #1c1208 0%, var(--school-bg, #2d1b0e) 100%);
padding: 9px 12px 7px;
position: relative;
}
.card-header::after {
content: '';
position: absolute;
bottom: 0; left: 0; right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--school-col, #c9a84c), transparent);
}
.card-name {
font-family: 'Cinzel', serif;
font-size: 11.5px;
font-weight: 700;
color: #e8c96e;
line-height: 1.2;
letter-spacing: 0.03em;
text-shadow: 0 1px 4px rgba(0,0,0,0.8);
}
.card-level-school {
font-family: 'Cinzel', serif;
font-size: 8.5px;
color: var(--school-col, #c9a84c);
letter-spacing: 0.06em;
margin-top: 1px;
text-transform: uppercase;
}
.school-pip {
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--school-col, #c9a84c);
color: #fff;
font-family: 'Cinzel', serif;
font-size: 9px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 0 8px var(--school-col, #c9a84c), 0 0 0 1.5px rgba(255,255,255,0.15);
}
.stat-row {
display: flex;
gap: 4px;
flex-wrap: wrap;
padding: 5px 10px 4px;
background: #e8d8ae;
border-bottom: 1px solid #c9a84c44;
}
.stat-chip {
font-family: 'Cinzel', serif;
font-size: 7.5px;
font-weight: 600;
color: #1c1208;
background: rgba(201,168,76,0.22);
border: 1px solid #c9a84c55;
border-radius: 3px;
padding: 1.5px 5px;
white-space: nowrap;
letter-spacing: 0.04em;
}
.stat-chip span {
color: #7a4a00;
}
.card-body {
flex: 1;
overflow: hidden;
padding: 7px 10px 6px;
background: #f0e6c8;
position: relative;
}
.card-desc {
font-family: 'Crimson Text', serif;
font-size: 9.5px;
line-height: 1.45;
color: #1c1208;
}
.card-desc p + p { margin-top: 3px; }
.card-tags {
display: flex;
gap: 3px;
flex-wrap: wrap;
margin-top: 5px;
}
.tag {
font-family: 'Cinzel', serif;
font-size: 7px;
color: var(--school-col, #7a4a00);
border: 1px solid var(--school-col, #c9a84c);
border-radius: 2px;
padding: 0 4px;
opacity: 0.8;
letter-spacing: 0.05em;
}
.card-footer {
background: linear-gradient(to right, #1c1208, var(--school-bg, #2d1b0e));
padding: 4px 10px;
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid #c9a84c44;
}
.footer-source {
font-family: 'Crimson Text', serif;
font-style: italic;
font-size: 7.5px;
color: #8a6a28;
}
.footer-uses {
font-family: 'Cinzel', serif;
font-size: 7px;
color: var(--school-col, #c9a84c);
letter-spacing: 0.05em;
}
/* ─── PRINT STYLES ─── */
@media print {
* { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }
body { background: #fff !important; margin: 0; padding: 0; }
.no-print { display: none !important; }
.print-area {
display: grid !important;
grid-template-columns: repeat(3, var(--card-w));
gap: 8px;
padding: 12mm;
justify-content: start;
}
.spell-card {
box-shadow: none !important;
border: 1px solid #c9a84c;
}
.screen-only { display: none !important; }
}
@page {
size: A4 portrait;
margin: 12mm;
}
/* ─── UI chrome ─── */
.ui-panel {
background: linear-gradient(135deg, #12121e, #1e1428);
border-bottom: 1px solid #c9a84c33;
}
.toggle-btn {
font-family: 'Cinzel', serif;
font-size: 11px;
padding: 6px 14px;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
border: 1px solid transparent;
}
.toggle-btn.active {
background: #c9a84c22;
border-color: #c9a84c;
color: #e8c96e;
}
.toggle-btn:not(.active) {
background: transparent;
border-color: #ffffff22;
color: #888;
}
.toggle-btn:hover { opacity: 0.85; }
.print-btn {
font-family: 'Cinzel', serif;
font-size: 11px;
background: linear-gradient(135deg, #c9a84c, #8a6a28);
color: #1c1208;
font-weight: 700;
padding: 7px 18px;
border-radius: 4px;
border: none;
cursor: pointer;
letter-spacing: 0.05em;
transition: opacity 0.15s;
}
.print-btn:hover { opacity: 0.88; }
.section-label {
font-family: 'Cinzel', serif;
font-size: 9px;
color: #c9a84c88;
letter-spacing: 0.1em;
text-transform: uppercase;
margin-bottom: 6px;
}
</style>
</head>
<body>
<!-- ═══════════════════ UI CHROME ═══════════════════ -->
<div class="ui-panel no-print px-6 py-4">
<div class="flex items-center justify-between flex-wrap gap-4">
<div>
<div class="font-cinzel text-gold text-xl font-bold tracking-widest" id="character-name">&nbsp;</div>
<div class="font-crimson text-sm text-yellow-200/50 italic" id="character-subtitle">&nbsp;</div>
</div>
<button class="print-btn" onclick="window.print()">⬡ Print Cards</button>
</div>
<!-- Toggle buttons -->
<div class="mt-4 flex flex-wrap gap-6">
<div>
<div class="section-label">Filter by Type</div>
<div class="flex gap-2 flex-wrap" id="type-filters">
<button class="toggle-btn active" data-type="all" onclick="filterType(this,'all')">All</button>
</div>
</div>
<div>
<div class="section-label">Filter by School</div>
<div class="flex gap-2 flex-wrap" id="school-filters">
<button class="toggle-btn active" data-school="all" onclick="filterSchool(this,'all')">All</button>
</div>
</div>
</div>
</div>
<!-- ═══════════════════ CARD GRID ═══════════════════ -->
<div class="print-area p-8" id="card-grid"
style="display:flex;flex-wrap:wrap;gap:16px;">
</div>
<!-- ═══════════════════ DATA + LOGIC ═══════════════════ -->
<script>
// Spell data lives under characters/<character-folder>/<level-folder>/<spell>.json,
// indexed by a manifest.json so the browser knows which files to fetch.
const CHARACTER_DIR = 'characters/oswin-eisenbach';
let SPELLS = [];
// ─── school abbreviations for pip
const SCHOOL_ABBR = {
Conjuration: 'CO', Transmutation: 'TR', Illusion: 'IL',
Divination: 'DV', Evocation: 'EV', Abjuration: 'AB',
Enchantment: 'EN', Necromancy: 'NE',
};
let activeType = 'all';
let activeSchool = 'all';
async function loadSpells() {
const manifest = await fetch(`${CHARACTER_DIR}/manifest.json`).then(r => r.json());
document.getElementById('character-name').textContent = manifest.character;
document.getElementById('character-subtitle').textContent = manifest.subtitle;
const typeLabels = {};
for (const type of manifest.types) {
typeLabels[type.level] = type.label;
const spells = await Promise.all(
type.files.map(file => fetch(`${CHARACTER_DIR}/${type.level}/${file}`).then(r => r.json()))
);
SPELLS.push(...spells);
}
buildFilters(typeLabels);
renderCards();
}
function schoolClass(school) {
return 'school-' + school.toLowerCase();
}
function buildCard(spell) {
const sc = schoolClass(spell.school);
const abbr = SCHOOL_ABBR[spell.school] || spell.school.slice(0,2).toUpperCase();
const statsHtml = [
`<span class="stat-chip"><span>Cast</span> ${spell.castingTime}</span>`,
`<span class="stat-chip"><span>Range</span> ${spell.range}</span>`,
`<span class="stat-chip"><span>Dur.</span> ${spell.duration}</span>`,
spell.damage ? `<span class="stat-chip"><span>Dmg</span> ${spell.damage}</span>` : '',
spell.saveDC ? `<span class="stat-chip"><span>Save</span> ${spell.saveDC}</span>` : '',
spell.material ? `<span class="stat-chip"><span>M</span> ${spell.material}</span>` : '',
].filter(Boolean).join('');
const descHtml = spell.desc.map(p => `<p>${p}</p>`).join('');
const tagsHtml = (spell.tags||[]).map(t => `<span class="tag">${t}</span>`).join('');
const isCantrip = spell.level === 'cantrip';
const flagLabel = isCantrip ? 'Cantrip' : `Lvl ${spell.level} Spell`;
return `
<div class="spell-card ${sc}"
data-level="${spell.level}"
data-school="${spell.school}">
<div class="card-header flex items-start justify-between gap-2">
<div>
<div class="card-name">${spell.name}</div>
<div class="card-level-school">${spell.levelDisplay} · ${spell.school}</div>
</div>
<div class="flex flex-col items-center gap-1">
<div class="level-flag ${isCantrip ? 'level-flag-cantrip' : 'level-flag-spell'}">${flagLabel}</div>
<div class="school-pip">${abbr}</div>
</div>
</div>
<div class="stat-row">${statsHtml}</div>
<div class="card-body">
<div class="card-desc">${descHtml}</div>
<div class="card-tags">${tagsHtml}</div>
</div>
<div class="card-footer">
<span class="footer-source">${spell.source}</span>
<span class="footer-uses">${spell.uses}</span>
</div>
</div>`;
}
function buildFilters(typeLabels) {
const typeContainer = document.getElementById('type-filters');
for (const level of Object.keys(typeLabels)) {
const btn = document.createElement('button');
btn.className = 'toggle-btn';
btn.dataset.type = level;
btn.textContent = typeLabels[level];
btn.onclick = () => filterType(btn, level);
typeContainer.appendChild(btn);
}
const schoolContainer = document.getElementById('school-filters');
const schools = [...new Set(SPELLS.map(s => s.school))];
for (const school of schools) {
const btn = document.createElement('button');
btn.className = 'toggle-btn';
btn.dataset.school = school;
btn.textContent = school;
btn.onclick = () => filterSchool(btn, school);
schoolContainer.appendChild(btn);
}
}
function renderCards() {
const grid = document.getElementById('card-grid');
const filtered = SPELLS.filter(s => {
const typeOk = activeType === 'all' || s.level === activeType;
const schoolOk = activeSchool === 'all' || s.school === activeSchool;
return typeOk && schoolOk;
});
grid.innerHTML = filtered.map(buildCard).join('');
}
function filterType(btn, val) {
activeType = val;
document.querySelectorAll('#type-filters .toggle-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderCards();
}
function filterSchool(btn, val) {
activeSchool = val;
document.querySelectorAll('#school-filters .toggle-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderCards();
}
loadSpells();
</script>
</body>
</html>