Compare commits

...

11 commits

3 changed files with 167 additions and 76 deletions

View file

@ -32,6 +32,7 @@
<input <input
id="toc-search" id="toc-search"
type="search" type="search"
autofocus
placeholder="Search the entries..." placeholder="Search the entries..."
/> />
<div id="toc-letters" class="toc-letters"></div> <div id="toc-letters" class="toc-letters"></div>

144
script.js
View file

@ -53,39 +53,66 @@ Array.from(firstByLetter.entries())
}); });
const searchInput = document.getElementById("toc-search"); const searchInput = document.getElementById("toc-search");
let searchTimeout;
searchInput?.addEventListener("input", (event) => { searchInput?.addEventListener("input", (event) => {
const query = event.target.value.trim().toLowerCase(); clearTimeout(searchTimeout);
Array.from(tocList?.querySelectorAll("li") ?? []).forEach((item) => { searchTimeout = setTimeout(() => {
const text = item.textContent.toLowerCase(); const query = event.target.value.trim().toLowerCase();
item.style.display = text.includes(query) ? "" : "none"; Array.from(tocList?.querySelectorAll("li") ?? []).forEach((item) => {
}); const text = item.textContent.toLowerCase();
item.style.display = text.includes(query) ? "" : "none";
});
}, 50);
}); });
const specialCaseMap = { // OCR/formatting errors from epub conversion
const typoFixes = {
"GAY MA G E": "GAY MAGE",
"MORE ON RIVERS RIVERS": "RIVERS",
"REEK OF WRONG NESS": "REEK OF WRONGNESS",
"WITCH LIGHT": "WITCHLIGHT",
};
// truncated possessives and hyphenated terms
const partialMatches = {
"A KING'S": "KINGS",
"KING'": "KINGS",
"MERCHANT'": "MERCHANTS",
"POWER-": "POWER",
"WIZARD'": "WIZARDS",
"WIZARD'S": "WIZARDS",
"WIZARD'S BREEDING PROGRAMME": "BREEDING PROGRAMMES",
"WIZARD'S STAFF": "STAFFS",
};
// British/American spelling and verb forms
const spellingVariants = {
AMBUSHED: "AMBUSHES",
BACKLASHES: "BACKLASH OF MAGIC",
CURSED: "CURSES",
"FELLOW TRAVELLER": "FELLOW TRAVELERS",
"FELLOW TRAVELLERS": "FELLOW TRAVELERS",
};
// different terms mapping to canonical entries
const synonyms = {
"A BARD": "BARDS", "A BARD": "BARDS",
"A COURTIER": "COURTIERS", "A COURTIER": "COURTIERS",
"A DEMON": "DEMONS", "A DEMON": "DEMONS",
"A GODDESS OR GOD": "GODDESSES AND GODS", "A GODDESS OR GOD": "GODDESSES AND GODS",
"A HERBWOMAN": "HERBWOMAN", "A HERBWOMAN": "HERBWOMAN",
"A KING'S": "KINGS",
"A MAGIC USER": "MAGIC USERS", "A MAGIC USER": "MAGIC USERS",
"A SEER": "SEER", "A SEER": "SEER",
"A WIZARD": "WIZARDS", "A WIZARD": "WIZARDS",
AMBUSHED: "AMBUSHES",
ASSASSIN: "ASSASSINS, GUILD OF", ASSASSIN: "ASSASSINS, GUILD OF",
ASSASSINS: "ASSASSINS, GUILD OF", ASSASSINS: "ASSASSINS, GUILD OF",
BACKLASHES: "BACKLASH OF MAGIC",
BALLAD: "BALLAD, How to Compose a", BALLAD: "BALLAD, How to Compose a",
BALLADS: "BALLAD, How to Compose a", BALLADS: "BALLAD, How to Compose a",
BLOCKED: "MOUNTAIN PASS, BLOCKED", BLOCKED: "MOUNTAIN PASS, BLOCKED",
CHILD: "CHILDREN", CHILD: "CHILDREN",
CURSED: "CURSES",
ETERNAL: "ETERNAL QUEST", ETERNAL: "ETERNAL QUEST",
"EVIL SPELLS": "SPELLS", "EVIL SPELLS": "SPELLS",
FEMALE: "SLAVES, FEMALE,", FEMALE: "SLAVES, FEMALE,",
"FELLOW TRAVELLER": "FELLOW TRAVELERS",
"FELLOW TRAVELLERS": "FELLOW TRAVELERS",
"GAY MA G E": "GAY MAGE",
"GIFT OF SIGHT": "SIGHT", "GIFT OF SIGHT": "SIGHT",
GOD: "GODDESSES AND GODS", GOD: "GODDESSES AND GODS",
GODDESS: "GODDESSES AND GODS", GODDESS: "GODDESSES AND GODS",
@ -102,91 +129,90 @@ const specialCaseMap = {
"HOW TO INTERACT WITH WIZARDS": "WIZARDS", "HOW TO INTERACT WITH WIZARDS": "WIZARDS",
IMPORT: "IMPORT/EXPORT", IMPORT: "IMPORT/EXPORT",
"JOURNEY CAKE": "WAYBREAD OR JOURNEY CAKE", "JOURNEY CAKE": "WAYBREAD OR JOURNEY CAKE",
"KING'": "KINGS",
"LOST LAND": "LOST LANDS,", "LOST LAND": "LOST LANDS,",
"LOST LANDS": "LOST LANDS,", "LOST LANDS": "LOST LANDS,",
MAGELIGHT: "MAGELIGHT OR MAGEFIRE", MAGELIGHT: "MAGELIGHT OR MAGEFIRE",
MALE: "SLAVES, MALE,", MALE: "SLAVES, MALE,",
"MERCHANT'": "MERCHANTS",
MINION: "MINIONS OF THE DARK LORD", MINION: "MINIONS OF THE DARK LORD",
"MINION OF THE DARK LORD": "MINIONS OF THE DARK LORD", "MINION OF THE DARK LORD": "MINIONS OF THE DARK LORD",
MINIONS: "MINIONS OF THE DARK LORD", MINIONS: "MINIONS OF THE DARK LORD",
"MORE ON RIVERS": "RIVERS",
"MORE ON RIVERS RIVERS": "RIVERS",
MOON: "MOON(S)", MOON: "MOON(S)",
"MORE ON RIVERS": "RIVERS",
"MOUNTAIN PASS": "MOUNTAIN PASS, BLOCKED", "MOUNTAIN PASS": "MOUNTAIN PASS, BLOCKED",
"NORTHERN BARBARIANS DWELL": "NORTHERN BARBARIANS", "NORTHERN BARBARIANS DWELL": "NORTHERN BARBARIANS",
PANCELTIC: "PANCELTIC TOURS", PANCELTIC: "PANCELTIC TOURS",
"PANCELTIC TOUR": "PANCELTIC TOURS", "PANCELTIC TOUR": "PANCELTIC TOURS",
PENTAGRAM: "PENTAGRAM OR PENTACLE", PENTAGRAM: "PENTAGRAM OR PENTACLE",
PENTAGRAMS: "PENTAGRAM OR PENTACLE", PENTAGRAMS: "PENTAGRAM OR PENTACLE",
"POWER-": "POWER",
"PRACTICE RING": "PRACTICE RING OR COMBAT RING", "PRACTICE RING": "PRACTICE RING OR COMBAT RING",
PRIESTESSES: "HIGH PRIESTESSES", PRIESTESSES: "HIGH PRIESTESSES",
"SECRET VALLEY": "VALLEYS",
RIVERBOAT: "RIVERS", RIVERBOAT: "RIVERS",
"REEK OF WRONG NESS": "REEK OF WRONGNESS", "SECRET VALLEY": "VALLEYS",
SLAVE: "GALLEY SLAVE", SLAVE: "GALLEY SLAVE",
SLAVES: "SLAVES, MALE,", SLAVES: "SLAVES, MALE,",
"STANDING STONES": "STONE CIRCLES", "STANDING STONES": "STONE CIRCLES",
THIEVES: "THIEVES GUILD", THIEVES: "THIEVES' GUILD",
"TOWN COUNCIL": "COUNCIL",
"TOUR COMPANIONS": "COMPANIONS", "TOUR COMPANIONS": "COMPANIONS",
"TOWN COUNCIL": "COUNCIL",
TROTS: "TROTS, THE", TROTS: "TROTS, THE",
WAYBREAD: "WAYBREAD OR JOURNEY CAKE", WAYBREAD: "WAYBREAD OR JOURNEY CAKE",
"WITCH LIGHT": "WITCHLIGHT", };
"WIZARD'": "WIZARDS",
"WIZARD'S": "WIZARDS", const specialCaseMap = {
"WIZARD'S BREEDING PROGRAMME": "BREEDING PROGRAMMES", ...typoFixes,
"WIZARD'S STAFF": "STAFFS", ...partialMatches,
...spellingVariants,
...synonyms,
}; };
const resolveTerm = (term) => { const resolveTerm = (term) => {
const normalized = term.replace(//g, "'"); const normalized = term.replace(/'/g, "'");
const special = specialCaseMap[normalized]; const special = specialCaseMap[normalized];
if (special && entryMap.has(special)) { if (special && entryMap.has(special)) {
return special; return special;
} }
if (entryMap.has(term)) { if (entryMap.has(normalized)) {
return term; return normalized;
} }
const lower = term.toLowerCase(); const lower = normalized.toLowerCase();
const candidates = []; const candidates = [];
// Singularize // Singularize
if (lower.endsWith("ies")) { if (lower.endsWith("ies")) {
candidates.push(term.slice(0, -3) + "Y"); candidates.push(normalized.slice(0, -3) + "Y");
} }
if (lower.endsWith("ves")) { if (lower.endsWith("ves")) {
candidates.push(term.slice(0, -3) + "F"); candidates.push(normalized.slice(0, -3) + "F");
candidates.push(term.slice(0, -3) + "FE"); candidates.push(normalized.slice(0, -3) + "FE");
} }
if (lower.endsWith("es")) { if (lower.endsWith("es")) {
candidates.push(term.slice(0, -2)); candidates.push(normalized.slice(0, -2));
} }
if (lower.endsWith("s")) { if (lower.endsWith("s")) {
candidates.push(term.slice(0, -1)); candidates.push(normalized.slice(0, -1));
} }
// Pluralize // Pluralize
if (lower.endsWith("y") && !/[aeiou]y$/.test(lower)) { if (lower.endsWith("y") && !/[aeiou]y$/.test(lower)) {
candidates.push(term.slice(0, -1) + "IES"); candidates.push(normalized.slice(0, -1) + "IES");
} }
if (lower.endsWith("f")) { if (lower.endsWith("f")) {
candidates.push(term.slice(0, -1) + "VES"); candidates.push(normalized.slice(0, -1) + "VES");
} }
if (lower.endsWith("fe")) { if (lower.endsWith("fe")) {
candidates.push(term.slice(0, -2) + "VES"); candidates.push(normalized.slice(0, -2) + "VES");
} }
candidates.push(`${term}S`); candidates.push(`${normalized}S`);
if (/(s|x|z|ch|sh)$/i.test(lower)) { if (/(s|x|z|ch|sh)$/i.test(lower)) {
candidates.push(`${term}ES`); candidates.push(`${normalized}ES`);
} }
return candidates.find((candidate) => entryMap.has(candidate)) || null; return candidates.find((candidate) => entryMap.has(candidate)) || null;
}; };
const CAPS_TERM_REGEX = /\b[A-Z][A-Z0-9''\-]*(?:\s+[A-Z][A-Z0-9''\-]*)*\b/g;
const linkifyTextNode = (node) => { const linkifyTextNode = (node) => {
const text = node.nodeValue; const text = node.nodeValue;
if (!text || !/[A-Z]{2}/.test(text)) { if (!text || !/[A-Z]{2}/.test(text)) {
@ -194,9 +220,9 @@ const linkifyTextNode = (node) => {
} }
const matches = []; const matches = [];
const regex = /\b[A-Z][A-Z0-9'\-]*(?:\s+[A-Z][A-Z0-9'\-]*)*\b/g; CAPS_TERM_REGEX.lastIndex = 0;
let match; let match;
while ((match = regex.exec(text)) !== null) { while ((match = CAPS_TERM_REGEX.exec(text)) !== null) {
const raw = match[0]; const raw = match[0];
const cleaned = cleanTerm(raw); const cleaned = cleanTerm(raw);
const resolved = resolveTerm(cleaned); const resolved = resolveTerm(cleaned);
@ -290,8 +316,38 @@ document.body.appendChild(tocToggle);
const toc = document.querySelector(".toc"); const toc = document.querySelector(".toc");
const layout = document.querySelector(".layout"); const layout = document.querySelector(".layout");
const isMobile = () => window.matchMedia("(max-width: 700px)").matches;
tocToggle.addEventListener("click", () => { tocToggle.addEventListener("click", () => {
toc?.classList.toggle("collapsed"); toc?.classList.toggle("collapsed");
layout?.classList.toggle("toc-hidden"); layout?.classList.toggle("toc-hidden");
document.body.classList.toggle("toc-collapsed"); if (isMobile()) {
document.body.classList.toggle("toc-open");
} else {
document.body.classList.toggle("toc-collapsed");
}
});
// auto-collapse on mobile (CSS defaults to collapsed, just ensure classes match)
if (isMobile()) {
toc?.classList.add("collapsed");
layout?.classList.add("toc-hidden");
document.body.classList.remove("toc-open");
}
// close drawer when clicking a toc link on mobile
tocList?.addEventListener("click", (e) => {
if (e.target.tagName === "A" && isMobile()) {
toc?.classList.add("collapsed");
layout?.classList.add("toc-hidden");
document.body.classList.remove("toc-open");
}
});
// scroll TOC item into view when navigating via hash
window.addEventListener("hashchange", () => {
const id = location.hash.slice(1);
if (id) {
tocList?.querySelector(`a[href="#${id}"]`)?.scrollIntoView({ block: "nearest" });
}
}); });

View file

@ -14,8 +14,13 @@
box-sizing: border-box; box-sizing: border-box;
} }
html {
max-width: 100%;
}
body { body {
margin: 0; margin: 0;
max-width: 100%;
color: var(--ink); color: var(--ink);
background: background:
radial-gradient( radial-gradient(
@ -40,7 +45,7 @@ body {
} }
.hero { .hero {
padding: 48px 6vw 32px; padding: clamp(24px, 6vw, 48px) clamp(16px, 6vw, 6vw) clamp(16px, 4vw, 32px);
background: background:
linear-gradient(120deg, rgba(182, 82, 47, 0.12), transparent 55%), linear-gradient(120deg, rgba(182, 82, 47, 0.12), transparent 55%),
linear-gradient(220deg, rgba(122, 46, 26, 0.18), transparent 45%); linear-gradient(220deg, rgba(122, 46, 26, 0.18), transparent 45%);
@ -51,7 +56,7 @@ body {
.hero h1 { .hero h1 {
font-family: font-family:
"Iowan Old Style", "Palatino Linotype", "Book Antiqua", Garamond, serif; "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Garamond, serif;
font-size: clamp(2.4rem, 4vw, 3.6rem); font-size: clamp(1.5rem, 6vw, 3.6rem);
letter-spacing: 0.02em; letter-spacing: 0.02em;
margin: 0 0 8px; margin: 0 0 8px;
} }
@ -65,15 +70,15 @@ body {
.layout { .layout {
display: grid; display: grid;
grid-template-columns: minmax(240px, 320px) minmax(0, 1fr); grid-template-columns: minmax(200px, 280px) minmax(0, 1fr);
gap: 28px; gap: clamp(16px, 4vw, 28px);
padding: 32px 6vw 64px; padding: clamp(16px, 4vw, 32px) clamp(12px, 6vw, 6vw) clamp(32px, 8vw, 64px);
} }
.toc { .toc {
background: var(--nav-bg); background: var(--nav-bg);
border-radius: 18px; border-radius: 18px;
padding: 24px; padding: clamp(16px, 4vw, 24px);
box-shadow: 0 18px 40px var(--shadow); box-shadow: 0 18px 40px var(--shadow);
position: sticky; position: sticky;
top: 24px; top: 24px;
@ -101,8 +106,8 @@ body {
} }
.toc-letters { .toc-letters {
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: repeat(auto-fill, minmax(28px, 1fr));
gap: 6px; gap: 6px;
margin-bottom: 16px; margin-bottom: 16px;
} }
@ -110,6 +115,7 @@ body {
.toc-letters a { .toc-letters a {
font-size: 0.75rem; font-size: 0.75rem;
text-decoration: none; text-decoration: none;
text-align: center;
color: var(--accent-deep); color: var(--accent-deep);
padding: 4px 6px; padding: 4px 6px;
border-radius: 6px; border-radius: 6px;
@ -147,10 +153,13 @@ body {
.content { .content {
background: rgba(255, 255, 255, 0.6); background: rgba(255, 255, 255, 0.6);
border-radius: 24px; border-radius: clamp(12px, 3vw, 24px);
padding: 32px 36px; padding: clamp(16px, 4vw, 32px) clamp(16px, 5vw, 36px);
box-shadow: 0 20px 55px var(--shadow); box-shadow: 0 20px 55px var(--shadow);
animation: fade-up 0.8s ease 0.2s both; animation: fade-up 0.8s ease 0.2s both;
overflow-wrap: break-word;
word-wrap: break-word;
min-width: 0;
} }
.content h2, .content h2,
@ -167,6 +176,7 @@ body {
letter-spacing: 0.04em; letter-spacing: 0.04em;
animation: fade-up 0.6s ease both; animation: fade-up 0.6s ease both;
animation-delay: var(--entry-delay, 0s); animation-delay: var(--entry-delay, 0s);
scroll-margin-top: 1rem;
} }
.entry-title:target { .entry-title:target {
@ -257,41 +267,65 @@ body.toc-collapsed .toc-toggle::before {
grid-template-columns: 0 minmax(0, 1fr); grid-template-columns: 0 minmax(0, 1fr);
} }
@media (max-width: 980px) { @media (max-width: 700px) {
.layout { .page,
grid-template-columns: 1fr; .hero,
.content {
max-width: 100%;
} }
.toc { .toc {
position: relative; position: fixed;
max-height: none; top: 0;
} left: 0;
bottom: 0;
.toc.collapsed { width: min(85vw, 320px);
max-height: 100vh;
border-radius: 0 18px 18px 0;
z-index: 200;
margin-left: 0; margin-left: 0;
margin-top: -100%; transform: translateX(-100%);
height: 0; transition: transform 0.3s ease, opacity 0.3s ease;
padding: 0; animation: none;
overflow: hidden;
} }
.layout.toc-hidden { body.toc-open .toc {
grid-template-columns: 1fr; transform: translateX(0);
opacity: 1;
pointer-events: auto;
} }
.toc-toggle { .toc-toggle {
top: auto; top: 50%;
bottom: 20px; bottom: auto;
left: 20px; transform: translateY(-50%);
transform: none; z-index: 201;
border-radius: 8px; transition: left 0.3s ease;
padding: 10px 6px;
font-size: 1rem;
left: 0;
}
body.toc-open .toc-toggle {
left: min(85vw, 320px);
} }
.toc-toggle::before { .toc-toggle::before {
content: "▲"; content: "";
} }
body.toc-collapsed .toc-toggle::before { body.toc-open .toc-toggle::before {
content: "▼"; content: "◀";
}
.layout,
.layout.toc-hidden {
display: block;
grid-template-columns: none;
max-width: 100%;
}
.content {
border-radius: clamp(8px, 2vw, 16px);
} }
} }