diff --git a/app/data-service.js b/app/data-service.js
index b89b1b9..1de2ebc 100644
--- a/app/data-service.js
+++ b/app/data-service.js
@@ -254,6 +254,7 @@
decansJson,
sabianJson,
planetScienceJson,
+ gematriaCiphersJson,
iChingJson,
calendarMonthsJson,
celestialHolidaysJson,
@@ -269,6 +270,7 @@
fetchJson(`${DATA_ROOT}/decans.json`),
fetchJson(`${DATA_ROOT}/sabian-symbols.json`),
fetchJson(`${DATA_ROOT}/planet-science.json`),
+ fetchJson(`${DATA_ROOT}/gematria-ciphers.json`).catch(() => ({})),
fetchJson(`${DATA_ROOT}/i-ching.json`),
fetchJson(`${DATA_ROOT}/calendar-months.json`),
fetchJson(`${DATA_ROOT}/celestial-holidays.json`),
@@ -287,6 +289,9 @@
const planetScience = Array.isArray(planetScienceJson?.planets)
? planetScienceJson.planets
: [];
+ const gematriaCiphers = gematriaCiphersJson && typeof gematriaCiphersJson === "object"
+ ? gematriaCiphersJson
+ : {};
const iChing = {
trigrams: Array.isArray(iChingJson?.trigrams) ? iChingJson.trigrams : [],
hexagrams: Array.isArray(iChingJson?.hexagrams) ? iChingJson.hexagrams : [],
@@ -352,6 +357,7 @@
decansBySign: groupDecansBySign(decans),
sabianSymbols,
planetScience,
+ gematriaCiphers,
iChing,
calendarMonths,
celestialHolidays,
diff --git a/app/quiz-calendars.js b/app/quiz-calendars.js
index 7acb5ab..e3745cf 100644
--- a/app/quiz-calendars.js
+++ b/app/quiz-calendars.js
@@ -3,79 +3,21 @@
(function () {
"use strict";
- // ----- shared utilities (mirrored from ui-quiz.js since they aren't exported) -----
+ const quizPluginHelpers = window.QuizPluginHelpers || {};
+ const {
+ normalizeOption,
+ normalizeKey,
+ toUniqueOptionList,
+ makeTemplate
+ } = quizPluginHelpers;
- function normalizeOption(value) {
- return String(value || "").trim();
- }
-
- function normalizeKey(value) {
- return normalizeOption(value).toLowerCase();
- }
-
- function toUniqueOptionList(values) {
- const seen = new Set();
- const unique = [];
- (values || []).forEach((value) => {
- const formatted = normalizeOption(value);
- if (!formatted) return;
- const key = normalizeKey(formatted);
- if (seen.has(key)) return;
- seen.add(key);
- unique.push(formatted);
- });
- return unique;
- }
-
- function shuffle(list) {
- const clone = list.slice();
- for (let i = clone.length - 1; i > 0; i -= 1) {
- const j = Math.floor(Math.random() * (i + 1));
- [clone[i], clone[j]] = [clone[j], clone[i]];
- }
- return clone;
- }
-
- function buildOptions(correctValue, poolValues) {
- const correct = normalizeOption(correctValue);
- if (!correct) return null;
- const uniquePool = toUniqueOptionList(poolValues || []);
- if (!uniquePool.some((v) => normalizeKey(v) === normalizeKey(correct))) {
- uniquePool.push(correct);
- }
- const distractors = uniquePool.filter((v) => normalizeKey(v) !== normalizeKey(correct));
- if (distractors.length < 3) return null;
- const selected = shuffle(distractors).slice(0, 3);
- const options = shuffle([correct, ...selected]);
- const correctIndex = options.findIndex((v) => normalizeKey(v) === normalizeKey(correct));
- if (correctIndex < 0 || options.length < 4) return null;
- return { options, correctIndex };
- }
-
- /**
- * Build a validated quiz question template.
- * Returns null if there aren't enough distractors for a 4-choice question.
- */
- function makeTemplate(key, categoryId, category, prompt, answer, pool) {
- const correctStr = normalizeOption(answer);
- const promptStr = normalizeOption(prompt);
- if (!key || !categoryId || !promptStr || !correctStr) return null;
-
- const uniquePool = toUniqueOptionList(pool || []);
- if (!uniquePool.some((v) => normalizeKey(v) === normalizeKey(correctStr))) {
- uniquePool.push(correctStr);
- }
- const distractorCount = uniquePool.filter((v) => normalizeKey(v) !== normalizeKey(correctStr)).length;
- if (distractorCount < 3) return null;
-
- return {
- key,
- categoryId,
- category,
- promptByDifficulty: promptStr,
- answerByDifficulty: correctStr,
- poolByDifficulty: uniquePool
- };
+ if (
+ typeof normalizeOption !== "function"
+ || typeof normalizeKey !== "function"
+ || typeof toUniqueOptionList !== "function"
+ || typeof makeTemplate !== "function"
+ ) {
+ throw new Error("QuizPluginHelpers must load before quiz-calendars.js");
}
function ordinal(n) {
diff --git a/app/quiz-connections.js b/app/quiz-connections.js
new file mode 100644
index 0000000..89d65d3
--- /dev/null
+++ b/app/quiz-connections.js
@@ -0,0 +1,873 @@
+/* quiz-connections.js — Dynamic quiz category plugin for dataset relations */
+(function () {
+ "use strict";
+
+ const { getTarotCardDisplayName } = window.TarotCardImages || {};
+ const quizPluginHelpers = window.QuizPluginHelpers || {};
+ const {
+ normalizeOption,
+ normalizeKey,
+ buildTemplatesFromSpec,
+ buildTemplatesFromVariants
+ } = quizPluginHelpers;
+
+ if (
+ typeof normalizeOption !== "function"
+ || typeof normalizeKey !== "function"
+ || typeof buildTemplatesFromSpec !== "function"
+ || typeof buildTemplatesFromVariants !== "function"
+ ) {
+ throw new Error("QuizPluginHelpers must load before quiz-connections.js");
+ }
+
+ const cache = {
+ referenceData: null,
+ magickDataset: null,
+ templates: [],
+ templatesByCategory: new Map()
+ };
+
+ function toTitleCase(value) {
+ const text = normalizeOption(value).toLowerCase();
+ if (!text) {
+ return "";
+ }
+ return text.charAt(0).toUpperCase() + text.slice(1);
+ }
+
+ function labelFromId(value) {
+ const id = normalizeOption(value);
+ if (!id) {
+ return "";
+ }
+
+ return id
+ .replace(/[_-]+/g, " ")
+ .replace(/\s+/g, " ")
+ .trim()
+ .split(" ")
+ .map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : ""))
+ .join(" ");
+ }
+
+ function formatHebrewLetterLabel(entry, fallbackId = "") {
+ if (entry?.name && entry?.char) {
+ return `${entry.name} (${entry.char})`;
+ }
+ if (entry?.name) {
+ return entry.name;
+ }
+ if (entry?.char) {
+ return entry.char;
+ }
+ return labelFromId(fallbackId);
+ }
+
+ function slugifyId(value) {
+ return normalizeKey(value)
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "");
+ }
+
+ function formatHexagramLabel(entry) {
+ const number = Number(entry?.number);
+ const name = normalizeOption(entry?.name);
+ if (Number.isFinite(number) && name) {
+ return `Hexagram ${number}: ${name}`;
+ }
+ if (Number.isFinite(number)) {
+ return `Hexagram ${number}`;
+ }
+ return name || "Hexagram";
+ }
+
+ function formatTrigramLabel(trigram) {
+ const name = normalizeOption(trigram?.name);
+ const element = normalizeOption(trigram?.element);
+ if (name && element) {
+ return `${name} (${element})`;
+ }
+ return name || element;
+ }
+
+ function formatTarotLabel(value) {
+ const raw = normalizeOption(value);
+ if (!raw) {
+ return "";
+ }
+
+ const keyMatch = raw.match(/^key\s*(\d{1,2})\s*:\s*(.+)$/i);
+ if (keyMatch) {
+ const trumpNumber = Number(keyMatch[1]);
+ const fallbackName = normalizeOption(keyMatch[2]);
+ if (typeof getTarotCardDisplayName === "function") {
+ const displayName = normalizeOption(getTarotCardDisplayName(fallbackName, { trumpNumber }));
+ if (displayName) {
+ return displayName;
+ }
+ }
+ return fallbackName;
+ }
+
+ if (typeof getTarotCardDisplayName === "function") {
+ const displayName = normalizeOption(getTarotCardDisplayName(raw));
+ if (displayName) {
+ return displayName;
+ }
+ }
+
+ return raw.replace(/\bPentacles\b/gi, "Disks");
+ }
+
+ function getPlanetLabelById(planetId, planetsById) {
+ const key = normalizeKey(planetId);
+ const label = planetsById?.[key]?.name;
+ if (label) {
+ return normalizeOption(label);
+ }
+ if (key === "primum-mobile") {
+ return "Primum Mobile";
+ }
+ if (key === "olam-yesodot") {
+ return "Earth / Elements";
+ }
+ return normalizeOption(labelFromId(planetId));
+ }
+
+ function formatPathLetter(path) {
+ const transliteration = normalizeOption(path?.hebrewLetter?.transliteration);
+ const glyph = normalizeOption(path?.hebrewLetter?.char);
+ if (transliteration && glyph) {
+ return `${transliteration} (${glyph})`;
+ }
+ return transliteration || glyph;
+ }
+
+ function getSephiraName(numberValue, idValue, sephiraByNumber, sephiraById) {
+ const numberKey = Number(numberValue);
+ if (Number.isFinite(numberKey) && sephiraByNumber.has(Math.trunc(numberKey))) {
+ return sephiraByNumber.get(Math.trunc(numberKey));
+ }
+
+ const idKey = normalizeKey(idValue);
+ if (idKey && sephiraById.has(idKey)) {
+ return sephiraById.get(idKey);
+ }
+
+ if (Number.isFinite(numberKey)) {
+ return `Sephira ${Math.trunc(numberKey)}`;
+ }
+
+ return labelFromId(idValue);
+ }
+
+ function toRomanNumeral(value) {
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric) || numeric <= 0) {
+ return String(value || "");
+ }
+
+ const lookup = [
+ [1000, "M"],
+ [900, "CM"],
+ [500, "D"],
+ [400, "CD"],
+ [100, "C"],
+ [90, "XC"],
+ [50, "L"],
+ [40, "XL"],
+ [10, "X"],
+ [9, "IX"],
+ [5, "V"],
+ [4, "IV"],
+ [1, "I"]
+ ];
+
+ let current = Math.trunc(numeric);
+ let result = "";
+ lookup.forEach(([size, symbol]) => {
+ while (current >= size) {
+ result += symbol;
+ current -= size;
+ }
+ });
+
+ return result || String(Math.trunc(numeric));
+ }
+
+ function formatDecanLabel(decan, signNameById) {
+ const signName = signNameById.get(normalizeKey(decan?.signId)) || labelFromId(decan?.signId);
+ const index = Number(decan?.index);
+ if (!signName || !Number.isFinite(index)) {
+ return "";
+ }
+
+ return `${signName} Decan ${toRomanNumeral(index)}`;
+ }
+
+ function buildEnglishGematriaCipherGroups(englishLetters, gematriaCiphers) {
+ const ciphers = Array.isArray(gematriaCiphers?.ciphers) ? gematriaCiphers.ciphers : [];
+
+ return ciphers
+ .map((cipher) => {
+ const cipherId = normalizeOption(cipher?.id);
+ const cipherName = normalizeOption(cipher?.name || labelFromId(cipherId));
+ const slug = slugifyId(cipherId || cipherName);
+ const values = Array.isArray(cipher?.values) ? cipher.values : [];
+
+ if (!slug || !cipherName || !values.length) {
+ return null;
+ }
+
+ const categoryId = `english-gematria-${slug}`;
+ const category = `English Gematria: ${cipherName}`;
+ const rows = englishLetters
+ .filter((entry) => normalizeOption(entry?.letter) && Number.isFinite(Number(entry?.index)))
+ .map((entry) => {
+ const position = Math.trunc(Number(entry.index));
+ const value = values[position - 1];
+ if (!Number.isFinite(Number(value))) {
+ return null;
+ }
+
+ return {
+ id: normalizeOption(entry.letter),
+ letter: normalizeOption(entry.letter),
+ value: String(Math.trunc(Number(value))),
+ cipherName
+ };
+ })
+ .filter(Boolean);
+
+ if (rows.length < 4) {
+ return null;
+ }
+
+ return {
+ entries: rows,
+ variants: [
+ {
+ keyPrefix: categoryId,
+ categoryId,
+ category,
+ getKey: (entry) => entry.id,
+ getPrompt: (entry) => `In the ${entry.cipherName} cipher, ${entry.letter} has a value of`,
+ getAnswer: (entry) => entry.value
+ }
+ ]
+ };
+ })
+ .filter(Boolean);
+ }
+
+ function buildAutomatedConnectionTemplates(referenceData, magickDataset) {
+ if (cache.referenceData === referenceData && cache.magickDataset === magickDataset) {
+ return cache.templates;
+ }
+
+ const planetsById = referenceData?.planets && typeof referenceData.planets === "object"
+ ? referenceData.planets
+ : {};
+ const planets = Object.values(planetsById).filter(Boolean);
+ const signs = Array.isArray(referenceData?.signs) ? referenceData.signs : [];
+ const gematriaCiphers = referenceData?.gematriaCiphers && typeof referenceData.gematriaCiphers === "object"
+ ? referenceData.gematriaCiphers
+ : {};
+ const decansBySign = referenceData?.decansBySign && typeof referenceData.decansBySign === "object"
+ ? referenceData.decansBySign
+ : {};
+ const signNameById = new Map(
+ signs
+ .filter((entry) => normalizeOption(entry?.id) && normalizeOption(entry?.name))
+ .map((entry) => [normalizeKey(entry.id), normalizeOption(entry.name)])
+ );
+
+ const grouped = magickDataset?.grouped || {};
+ const alphabets = grouped.alphabets || {};
+ const englishLetters = Array.isArray(alphabets?.english) ? alphabets.english : [];
+ const hebrewLetters = Array.isArray(alphabets?.hebrew) ? alphabets.hebrew : [];
+ const hebrewById = new Map(
+ hebrewLetters
+ .filter((entry) => normalizeOption(entry?.hebrewLetterId))
+ .map((entry) => [normalizeKey(entry.hebrewLetterId), entry])
+ );
+
+ const kabbalahTree = grouped?.kabbalah?.["kabbalah-tree"] || {};
+ const treePaths = Array.isArray(kabbalahTree?.paths) ? kabbalahTree.paths : [];
+ const treeSephiroth = Array.isArray(kabbalahTree?.sephiroth) ? kabbalahTree.sephiroth : [];
+ const sephiraByNumber = new Map(
+ treeSephiroth
+ .filter((entry) => Number.isFinite(Number(entry?.number)) && normalizeOption(entry?.name))
+ .map((entry) => [Math.trunc(Number(entry.number)), normalizeOption(entry.name)])
+ );
+ const sephiraByTreeId = new Map(
+ treeSephiroth
+ .filter((entry) => normalizeOption(entry?.sephiraId) && normalizeOption(entry?.name))
+ .map((entry) => [normalizeKey(entry.sephiraId), normalizeOption(entry.name)])
+ );
+ const sephirotById = grouped?.kabbalah?.sephirot && typeof grouped.kabbalah.sephirot === "object"
+ ? grouped.kabbalah.sephirot
+ : {};
+
+ const cube = grouped?.kabbalah?.cube && typeof grouped.kabbalah.cube === "object"
+ ? grouped.kabbalah.cube
+ : {};
+ const cubeWalls = Array.isArray(cube?.walls) ? cube.walls : [];
+ const cubeEdges = Array.isArray(cube?.edges) ? cube.edges : [];
+ const cubeCenter = cube?.center && typeof cube.center === "object" ? cube.center : null;
+
+ const playingCardsData = grouped?.["playing-cards-52"];
+ const playingCards = Array.isArray(playingCardsData)
+ ? playingCardsData
+ : (Array.isArray(playingCardsData?.entries) ? playingCardsData.entries : []);
+ const flattenDecans = Object.values(decansBySign).flatMap((entries) => Array.isArray(entries) ? entries : []);
+
+ const iChing = referenceData?.iChing;
+ const trigrams = Array.isArray(iChing?.trigrams) ? iChing.trigrams : [];
+ const hexagrams = Array.isArray(iChing?.hexagrams) ? iChing.hexagrams : [];
+ const correspondences = Array.isArray(iChing?.correspondences?.tarotToTrigram)
+ ? iChing.correspondences.tarotToTrigram
+ : [];
+
+ const trigramByKey = new Map(
+ trigrams
+ .map((trigram) => [normalizeKey(trigram?.name), trigram])
+ .filter(([key]) => Boolean(key))
+ );
+
+ const hexagramRows = hexagrams
+ .map((entry) => {
+ const upper = trigramByKey.get(normalizeKey(entry?.upperTrigram)) || null;
+ const lower = trigramByKey.get(normalizeKey(entry?.lowerTrigram)) || null;
+
+ return {
+ ...entry,
+ number: Number(entry?.number),
+ hexagramLabel: formatHexagramLabel(entry),
+ upperLabel: formatTrigramLabel(upper || { name: entry?.upperTrigram }),
+ lowerLabel: formatTrigramLabel(lower || { name: entry?.lowerTrigram })
+ };
+ })
+ .filter((entry) => Number.isFinite(entry.number) && entry.hexagramLabel);
+
+ const tarotCorrespondenceRows = correspondences
+ .map((entry, index) => {
+ const trigram = trigramByKey.get(normalizeKey(entry?.trigram)) || null;
+ return {
+ id: `${index}-${normalizeKey(entry?.tarot)}-${normalizeKey(entry?.trigram)}`,
+ tarotLabel: formatTarotLabel(entry?.tarot),
+ trigramLabel: formatTrigramLabel(trigram || { name: entry?.trigram })
+ };
+ })
+ .filter((entry) => entry.tarotLabel && entry.trigramLabel);
+
+ const englishGematriaGroups = buildEnglishGematriaCipherGroups(englishLetters, gematriaCiphers);
+
+ const hebrewNumerologyRows = hebrewLetters
+ .filter((entry) => normalizeOption(entry?.name) && normalizeOption(entry?.char) && Number.isFinite(Number(entry?.numerology)))
+ .map((entry) => ({
+ id: normalizeOption(entry.hebrewLetterId || entry.name),
+ glyphLabel: `${normalizeOption(entry.name)} (${normalizeOption(entry.char)})`,
+ char: normalizeOption(entry.char),
+ value: String(Math.trunc(Number(entry.numerology)))
+ }));
+
+ const englishHebrewRows = englishLetters
+ .map((entry) => {
+ const hebrew = hebrewById.get(normalizeKey(entry?.hebrewLetterId)) || null;
+ if (!normalizeOption(entry?.letter) || !hebrew) {
+ return null;
+ }
+ return {
+ id: normalizeOption(entry.letter),
+ letter: normalizeOption(entry.letter),
+ hebrewLabel: formatHebrewLetterLabel(hebrew, entry?.hebrewLetterId),
+ hebrewChar: normalizeOption(hebrew?.char)
+ };
+ })
+ .filter(Boolean);
+
+ const kabbalahPathRows = treePaths
+ .map((path) => {
+ const pathNumber = Number(path?.pathNumber);
+ if (!Number.isFinite(pathNumber)) {
+ return null;
+ }
+
+ const pathNumberLabel = String(Math.trunc(pathNumber));
+ const fromName = getSephiraName(path?.connects?.from, path?.connectIds?.from, sephiraByNumber, sephiraByTreeId);
+ const toName = getSephiraName(path?.connects?.to, path?.connectIds?.to, sephiraByNumber, sephiraByTreeId);
+ const letterLabel = formatPathLetter(path);
+ const tarotLabel = formatTarotLabel(path?.tarot?.card);
+
+ return {
+ id: pathNumberLabel,
+ pathNumberLabel,
+ pathPairLabel: fromName && toName ? `${fromName} ↔ ${toName}` : "",
+ fromName,
+ toName,
+ letterLabel,
+ tarotLabel
+ };
+ })
+ .filter(Boolean);
+
+ const sephirotRows = Object.values(sephirotById || {})
+ .map((sephira) => {
+ const sephiraName = normalizeOption(sephira?.name?.roman || sephira?.name?.en);
+ const planetLabel = getPlanetLabelById(sephira?.planetId, planetsById);
+ if (!sephiraName || !planetLabel) {
+ return null;
+ }
+ return {
+ id: normalizeOption(sephira?.id || sephiraName),
+ sephiraName,
+ planetLabel
+ };
+ })
+ .filter(Boolean);
+
+ const decanRows = flattenDecans
+ .map((decan) => {
+ const id = normalizeOption(decan?.id);
+ const tarotLabel = formatTarotLabel(decan?.tarotMinorArcana);
+ const decanLabel = formatDecanLabel(decan, signNameById);
+ const rulerLabel = getPlanetLabelById(decan?.rulerPlanetId, planetsById);
+ if (!id || !tarotLabel) {
+ return null;
+ }
+ return {
+ id,
+ tarotLabel,
+ decanLabel,
+ rulerLabel
+ };
+ })
+ .filter(Boolean);
+
+ const cubeTarotRows = [
+ ...cubeWalls.map((wall) => {
+ const wallName = normalizeOption(wall?.name || labelFromId(wall?.id));
+ const locationLabel = wallName ? `${wallName} Wall` : "";
+ const tarotLabel = formatTarotLabel(wall?.associations?.tarotCard);
+ return tarotLabel && locationLabel
+ ? { id: normalizeOption(wall?.id || wallName), tarotLabel, locationLabel }
+ : null;
+ }),
+ ...cubeEdges.map((edge) => {
+ const edgeName = normalizeOption(edge?.name || labelFromId(edge?.id));
+ const locationLabel = edgeName ? `${edgeName} Edge` : "";
+ const hebrew = hebrewById.get(normalizeKey(edge?.hebrewLetterId)) || null;
+ const tarotLabel = formatTarotLabel(hebrew?.tarot?.card);
+ return tarotLabel && locationLabel
+ ? { id: normalizeOption(edge?.id || edgeName), tarotLabel, locationLabel }
+ : null;
+ }),
+ (() => {
+ const tarotLabel = formatTarotLabel(cubeCenter?.associations?.tarotCard || cubeCenter?.tarotCard);
+ return tarotLabel ? { id: "center", tarotLabel, locationLabel: "Center" } : null;
+ })()
+ ].filter(Boolean);
+
+ const cubeHebrewRows = [
+ ...cubeWalls.map((wall) => {
+ const wallName = normalizeOption(wall?.name || labelFromId(wall?.id));
+ const locationLabel = wallName ? `${wallName} Wall` : "";
+ const hebrew = hebrewById.get(normalizeKey(wall?.hebrewLetterId)) || null;
+ const hebrewLabel = formatHebrewLetterLabel(hebrew, wall?.hebrewLetterId);
+ return locationLabel && hebrewLabel
+ ? { id: normalizeOption(wall?.id || wallName), locationLabel, hebrewLabel }
+ : null;
+ }),
+ ...cubeEdges.map((edge) => {
+ const edgeName = normalizeOption(edge?.name || labelFromId(edge?.id));
+ const locationLabel = edgeName ? `${edgeName} Edge` : "";
+ const hebrew = hebrewById.get(normalizeKey(edge?.hebrewLetterId)) || null;
+ const hebrewLabel = formatHebrewLetterLabel(hebrew, edge?.hebrewLetterId);
+ return locationLabel && hebrewLabel
+ ? { id: normalizeOption(edge?.id || edgeName), locationLabel, hebrewLabel }
+ : null;
+ }),
+ (() => {
+ const hebrew = hebrewById.get(normalizeKey(cubeCenter?.hebrewLetterId)) || null;
+ const hebrewLabel = formatHebrewLetterLabel(hebrew, cubeCenter?.hebrewLetterId);
+ return hebrewLabel ? { id: "center", locationLabel: "Center", hebrewLabel } : null;
+ })()
+ ].filter(Boolean);
+
+ const playingCardRows = playingCards
+ .map((entry) => {
+ const cardId = normalizeOption(entry?.id);
+ const rankLabel = normalizeOption(entry?.rankLabel || entry?.rank);
+ const suitLabel = normalizeOption(entry?.suitLabel || labelFromId(entry?.suit));
+ const tarotLabel = formatTarotLabel(entry?.tarotCard);
+ const playingCardLabel = rankLabel && suitLabel ? `${rankLabel} of ${suitLabel}` : "";
+ return cardId && playingCardLabel && tarotLabel
+ ? { id: cardId, playingCardLabel, tarotLabel }
+ : null;
+ })
+ .filter(Boolean);
+
+ const specGroups = [
+ ...englishGematriaGroups,
+ {
+ entries: hebrewNumerologyRows,
+ variants: [
+ {
+ keyPrefix: "hebrew-number",
+ categoryId: "hebrew-numerology",
+ category: "Hebrew Gematria",
+ getKey: (entry) => entry.id,
+ getPrompt: (entry) => `${entry.glyphLabel} has a gematria value of`,
+ getAnswer: (entry) => entry.value
+ }
+ ]
+ },
+ {
+ entries: englishHebrewRows,
+ variants: [
+ {
+ keyPrefix: "english-hebrew",
+ categoryId: "english-hebrew-mapping",
+ category: "Alphabet Mapping",
+ getKey: (entry) => entry.id,
+ getPrompt: (entry) => `${entry.letter} maps to which Hebrew letter`,
+ getAnswer: (entry) => entry.hebrewLabel,
+ inverse: {
+ keyPrefix: "hebrew-english",
+ getUniquenessKey: (entry) => entry.hebrewLabel,
+ getKey: (entry) => entry.hebrewChar || entry.hebrewLabel,
+ getPrompt: (entry) => `${entry.hebrewLabel} maps to which English letter`,
+ getAnswer: (entry) => entry.letter
+ }
+ }
+ ]
+ },
+ {
+ entries: signs.filter((entry) => entry?.name && entry?.rulingPlanetId),
+ variants: [
+ {
+ keyPrefix: "zodiac-ruler",
+ categoryId: "zodiac-rulers",
+ category: "Zodiac Rulers",
+ getKey: (entry) => entry.id || entry.name,
+ getPrompt: (entry) => `${entry.name} is ruled by`,
+ getAnswer: (entry) => getPlanetLabelById(entry.rulingPlanetId, planetsById)
+ }
+ ]
+ },
+ {
+ entries: signs.filter((entry) => entry?.name && entry?.element),
+ variants: [
+ {
+ keyPrefix: "zodiac-element",
+ categoryId: "zodiac-elements",
+ category: "Zodiac Elements",
+ getKey: (entry) => entry.id || entry.name,
+ getPrompt: (entry) => `${entry.name} is`,
+ getAnswer: (entry) => normalizeOption(entry.element).replace(/^./, (char) => char.toUpperCase())
+ }
+ ]
+ },
+ {
+ entries: planets.filter((entry) => entry?.name && entry?.weekday),
+ variants: [
+ {
+ keyPrefix: "planet-weekday",
+ categoryId: "planetary-weekdays",
+ category: "Planetary Weekdays",
+ getKey: (entry) => entry.id || entry.name,
+ getPrompt: (entry) => `${entry.name} corresponds to`,
+ getAnswer: (entry) => normalizeOption(entry.weekday),
+ inverse: {
+ keyPrefix: "weekday-planet",
+ getUniquenessKey: (entry) => normalizeOption(entry.weekday),
+ getKey: (entry) => normalizeOption(entry.weekday),
+ getPrompt: (entry) => `${normalizeOption(entry.weekday)} corresponds to which planet`,
+ getAnswer: (entry) => normalizeOption(entry.name)
+ }
+ }
+ ]
+ },
+ {
+ entries: signs.filter((entry) => entry?.name && (entry?.tarot?.majorArcana || entry?.tarot?.card)),
+ variants: [
+ {
+ keyPrefix: "zodiac-tarot",
+ categoryId: "zodiac-tarot",
+ category: "Zodiac ↔ Tarot",
+ getKey: (entry) => entry.id || entry.name,
+ getPrompt: (entry) => `${entry.name} corresponds to`,
+ getAnswer: (entry) => formatTarotLabel(entry?.tarot?.majorArcana || entry?.tarot?.card),
+ inverse: {
+ keyPrefix: "tarot-zodiac",
+ getUniquenessKey: (entry) => formatTarotLabel(entry?.tarot?.majorArcana || entry?.tarot?.card),
+ getKey: (entry) => formatTarotLabel(entry?.tarot?.majorArcana || entry?.tarot?.card),
+ getPrompt: (entry) => `${formatTarotLabel(entry?.tarot?.majorArcana || entry?.tarot?.card)} corresponds to which zodiac sign`,
+ getAnswer: (entry) => normalizeOption(entry.name)
+ }
+ }
+ ]
+ },
+ {
+ entries: kabbalahPathRows.filter((entry) => entry.fromName && entry.toName),
+ variants: [
+ {
+ keyPrefix: "kabbalah-path-between",
+ categoryId: "kabbalah-path-between-sephirot",
+ category: "Kabbalah Paths",
+ getKey: (entry) => entry.id,
+ getPrompt: (entry) => `What path connects ${entry.fromName} and ${entry.toName}`,
+ getAnswer: (entry) => entry.pathNumberLabel
+ }
+ ]
+ },
+ {
+ entries: kabbalahPathRows.filter((entry) => entry.letterLabel),
+ variants: [
+ {
+ keyPrefix: "kabbalah-path-letter",
+ categoryId: "kabbalah-path-letter",
+ category: "Kabbalah Paths",
+ getKey: (entry) => entry.id,
+ getPrompt: (entry) => `Path ${entry.pathNumberLabel} carries which Hebrew letter`,
+ getAnswer: (entry) => entry.letterLabel,
+ inverse: {
+ keyPrefix: "kabbalah-letter-path-number",
+ getUniquenessKey: (entry) => entry.letterLabel,
+ getKey: (entry) => entry.id,
+ getPrompt: (entry) => `${entry.letterLabel} belongs to which path`,
+ getAnswer: (entry) => entry.pathNumberLabel
+ }
+ }
+ ]
+ },
+ {
+ entries: kabbalahPathRows.filter((entry) => entry.tarotLabel),
+ variants: [
+ {
+ keyPrefix: "kabbalah-path-tarot",
+ categoryId: "kabbalah-path-tarot",
+ category: "Kabbalah ↔ Tarot",
+ getKey: (entry) => entry.id,
+ getPrompt: (entry) => `Path ${entry.pathNumberLabel} corresponds to which Tarot trump`,
+ getAnswer: (entry) => entry.tarotLabel,
+ inverse: {
+ keyPrefix: "tarot-trump-path",
+ getUniquenessKey: (entry) => entry.tarotLabel,
+ getKey: (entry) => entry.id,
+ getPrompt: (entry) => `${entry.tarotLabel} is on which path`,
+ getAnswer: (entry) => entry.pathNumberLabel
+ }
+ }
+ ]
+ },
+ {
+ entries: sephirotRows,
+ variants: [
+ {
+ keyPrefix: "sephirot-planet",
+ categoryId: "sephirot-planets",
+ category: "Sephirot ↔ Planet",
+ getKey: (entry) => entry.id,
+ getPrompt: (entry) => `${entry.sephiraName} corresponds to which planet`,
+ getAnswer: (entry) => entry.planetLabel
+ }
+ ]
+ },
+ {
+ entries: decanRows.filter((entry) => entry.decanLabel),
+ variants: [
+ {
+ keyPrefix: "tarot-decan-sign",
+ categoryId: "tarot-decan-sign",
+ category: "Tarot Decans",
+ getKey: (entry) => entry.id,
+ getPrompt: (entry) => `${entry.tarotLabel} belongs to which decan`,
+ getAnswer: (entry) => entry.decanLabel,
+ inverse: {
+ keyPrefix: "decan-tarot-card",
+ getUniquenessKey: (entry) => entry.decanLabel,
+ getKey: (entry) => entry.id,
+ getPrompt: (entry) => `${entry.decanLabel} corresponds to which Tarot card`,
+ getAnswer: (entry) => entry.tarotLabel
+ }
+ }
+ ]
+ },
+ {
+ entries: decanRows.filter((entry) => entry.rulerLabel),
+ variants: [
+ {
+ keyPrefix: "tarot-decan-ruler",
+ categoryId: "tarot-decan-ruler",
+ category: "Tarot Decans",
+ getKey: (entry) => entry.id,
+ getPrompt: (entry) => `The decan of ${entry.tarotLabel} is ruled by`,
+ getAnswer: (entry) => entry.rulerLabel
+ }
+ ]
+ },
+ {
+ entries: cubeTarotRows,
+ variants: [
+ {
+ keyPrefix: "tarot-cube-location",
+ categoryId: "tarot-cube-location",
+ category: "Tarot ↔ Cube",
+ getKey: (entry) => entry.id,
+ getPrompt: (entry) => `${entry.tarotLabel} is on which Cube location`,
+ getAnswer: (entry) => entry.locationLabel,
+ inverse: {
+ keyPrefix: "cube-location-tarot",
+ getUniquenessKey: (entry) => entry.locationLabel,
+ getKey: (entry) => entry.id,
+ getPrompt: (entry) => `${entry.locationLabel} corresponds to which Tarot card`,
+ getAnswer: (entry) => entry.tarotLabel
+ }
+ }
+ ]
+ },
+ {
+ entries: cubeHebrewRows,
+ variants: [
+ {
+ keyPrefix: "cube-hebrew-letter",
+ categoryId: "cube-hebrew-letter",
+ category: "Cube ↔ Hebrew",
+ getKey: (entry) => entry.id,
+ getPrompt: (entry) => `${entry.locationLabel} corresponds to which Hebrew letter`,
+ getAnswer: (entry) => entry.hebrewLabel,
+ inverse: {
+ keyPrefix: "hebrew-cube-location",
+ getUniquenessKey: (entry) => entry.hebrewLabel,
+ getKey: (entry) => entry.id,
+ getPrompt: (entry) => `${entry.hebrewLabel} is on which Cube location`,
+ getAnswer: (entry) => entry.locationLabel
+ }
+ }
+ ]
+ },
+ {
+ entries: playingCardRows,
+ variants: [
+ {
+ keyPrefix: "playing-card-tarot",
+ categoryId: "playing-card-tarot",
+ category: "Playing Card ↔ Tarot",
+ getKey: (entry) => entry.id,
+ getPrompt: (entry) => `${entry.playingCardLabel} maps to which Tarot card`,
+ getAnswer: (entry) => entry.tarotLabel,
+ inverse: {
+ keyPrefix: "tarot-playing-card",
+ getUniquenessKey: (entry) => entry.tarotLabel,
+ getKey: (entry) => entry.id,
+ getPrompt: (entry) => `${entry.tarotLabel} maps to which playing card`,
+ getAnswer: (entry) => entry.playingCardLabel
+ }
+ }
+ ]
+ },
+ {
+ entries: hexagramRows.filter((entry) => normalizeOption(entry.planetaryInfluence)),
+ variants: [
+ {
+ keyPrefix: "iching-planet",
+ categoryId: "iching-planetary-influence",
+ category: "I Ching ↔ Planet",
+ getKey: (entry) => String(entry.number),
+ getPrompt: (entry) => `${entry.hexagramLabel} corresponds to which planetary influence`,
+ getAnswer: (entry) => normalizeOption(entry.planetaryInfluence)
+ }
+ ]
+ },
+ {
+ entries: hexagramRows,
+ variants: [
+ {
+ keyPrefix: "iching-upper-trigram",
+ categoryId: "iching-trigrams",
+ category: "I Ching Trigrams",
+ getKey: (entry) => `${entry.number}-upper`,
+ getPrompt: (entry) => `What is the upper trigram of ${entry.hexagramLabel}`,
+ getAnswer: (entry) => entry.upperLabel
+ },
+ {
+ keyPrefix: "iching-lower-trigram",
+ categoryId: "iching-trigrams",
+ category: "I Ching Trigrams",
+ getKey: (entry) => `${entry.number}-lower`,
+ getPrompt: (entry) => `What is the lower trigram of ${entry.hexagramLabel}`,
+ getAnswer: (entry) => entry.lowerLabel
+ }
+ ]
+ },
+ {
+ entries: tarotCorrespondenceRows,
+ variants: [
+ {
+ keyPrefix: "iching-tarot-trigram",
+ categoryId: "iching-tarot-correspondence",
+ category: "I Ching ↔ Tarot",
+ getKey: (entry) => entry.id,
+ getPrompt: (entry) => `${entry.tarotLabel} corresponds to which I Ching trigram`,
+ getAnswer: (entry) => entry.trigramLabel
+ }
+ ]
+ }
+ ];
+
+ const templates = specGroups.flatMap((specGroup) => {
+ if (Array.isArray(specGroup.variants) && specGroup.variants.length > 1) {
+ return buildTemplatesFromVariants(specGroup);
+ }
+
+ const [variant] = specGroup.variants || [];
+ return buildTemplatesFromSpec({
+ entries: specGroup.entries,
+ categoryId: variant?.categoryId,
+ category: variant?.category,
+ keyPrefix: variant?.keyPrefix,
+ getKey: variant?.getKey,
+ getPrompt: variant?.getPrompt,
+ getAnswer: variant?.getAnswer
+ });
+ });
+ const templatesByCategory = new Map();
+
+ templates.forEach((template) => {
+ if (!templatesByCategory.has(template.categoryId)) {
+ templatesByCategory.set(template.categoryId, []);
+ }
+ templatesByCategory.get(template.categoryId).push(template);
+ });
+
+ cache.referenceData = referenceData;
+ cache.magickDataset = magickDataset;
+ cache.templates = templates;
+ cache.templatesByCategory = templatesByCategory;
+
+ return templates;
+ }
+
+ function buildConnectionTemplates(referenceData, magickDataset) {
+ return buildAutomatedConnectionTemplates(referenceData, magickDataset).slice();
+ }
+
+ function registerConnectionQuizCategories() {
+ const { registerQuizCategory } = window.QuizSectionUi || {};
+ if (typeof registerQuizCategory !== "function") {
+ return;
+ }
+
+ registerQuizCategory("quiz-connections", "Connection Quizzes", buildConnectionTemplates);
+ }
+
+ registerConnectionQuizCategories();
+
+ window.QuizConnectionsPlugin = {
+ registerConnectionQuizCategories,
+ buildAutomatedConnectionTemplates,
+ buildConnectionTemplates
+ };
+})();
\ No newline at end of file
diff --git a/app/quiz-plugin-helpers.js b/app/quiz-plugin-helpers.js
new file mode 100644
index 0000000..0be8b6e
--- /dev/null
+++ b/app/quiz-plugin-helpers.js
@@ -0,0 +1,168 @@
+/* quiz-plugin-helpers.js — Shared utilities for dynamic quiz plugins */
+(function () {
+ "use strict";
+
+ function normalizeOption(value) {
+ return String(value || "").trim();
+ }
+
+ function normalizeKey(value) {
+ return normalizeOption(value).toLowerCase();
+ }
+
+ function toUniqueOptionList(values) {
+ const seen = new Set();
+ const unique = [];
+
+ (values || []).forEach((value) => {
+ const formatted = normalizeOption(value);
+ if (!formatted) {
+ return;
+ }
+
+ const key = normalizeKey(formatted);
+ if (seen.has(key)) {
+ return;
+ }
+
+ seen.add(key);
+ unique.push(formatted);
+ });
+
+ return unique;
+ }
+
+ function makeTemplate(key, categoryId, category, prompt, answer, pool) {
+ const promptText = normalizeOption(prompt);
+ const answerText = normalizeOption(answer);
+ const optionPool = toUniqueOptionList(pool || []);
+
+ if (!key || !categoryId || !category || !promptText || !answerText) {
+ return null;
+ }
+
+ if (!optionPool.some((value) => normalizeKey(value) === normalizeKey(answerText))) {
+ optionPool.push(answerText);
+ }
+
+ const distractorCount = optionPool.filter((value) => normalizeKey(value) !== normalizeKey(answerText)).length;
+ if (distractorCount < 3) {
+ return null;
+ }
+
+ return {
+ key,
+ categoryId,
+ category,
+ promptByDifficulty: promptText,
+ answerByDifficulty: answerText,
+ poolByDifficulty: optionPool
+ };
+ }
+
+ function buildTemplatesFromSpec(spec) {
+ const rows = Array.isArray(spec?.entries) ? spec.entries : [];
+ const categoryId = normalizeOption(spec?.categoryId);
+ const category = normalizeOption(spec?.category);
+ const keyPrefix = normalizeOption(spec?.keyPrefix);
+ const getPrompt = spec?.getPrompt;
+ const getAnswer = spec?.getAnswer;
+ const getKey = spec?.getKey;
+
+ if (!rows.length || !categoryId || !category || !keyPrefix) {
+ return [];
+ }
+
+ if (typeof getPrompt !== "function" || typeof getAnswer !== "function") {
+ return [];
+ }
+
+ const pool = toUniqueOptionList(rows.map((entry) => getAnswer(entry)).filter(Boolean));
+ if (pool.length < 4) {
+ return [];
+ }
+
+ return rows
+ .map((entry, index) => {
+ const keyValue = typeof getKey === "function" ? getKey(entry, index) : String(index);
+ return makeTemplate(
+ `${keyPrefix}:${keyValue}`,
+ categoryId,
+ category,
+ getPrompt(entry),
+ getAnswer(entry),
+ pool
+ );
+ })
+ .filter(Boolean);
+ }
+
+ function buildTemplatesFromVariants(spec) {
+ const variants = Array.isArray(spec?.variants) ? spec.variants : [];
+ if (!variants.length) {
+ return [];
+ }
+
+ return variants.flatMap((variant) => {
+ const forwardTemplates = buildTemplatesFromSpec({
+ entries: spec.entries,
+ categoryId: variant.categoryId || spec.categoryId,
+ category: variant.category || spec.category,
+ keyPrefix: variant.keyPrefix || spec.keyPrefix,
+ getKey: variant.getKey || spec.getKey,
+ getPrompt: variant.getPrompt,
+ getAnswer: variant.getAnswer
+ });
+
+ const inverse = variant.inverse;
+ if (!inverse || typeof inverse.getPrompt !== "function" || typeof inverse.getAnswer !== "function") {
+ return forwardTemplates;
+ }
+
+ const entries = Array.isArray(spec.entries) ? spec.entries : [];
+ const rows = entries.filter((entry) => {
+ const uniquenessValue = typeof inverse.getUniquenessKey === "function"
+ ? inverse.getUniquenessKey(entry)
+ : variant.getAnswer(entry);
+ return normalizeOption(uniquenessValue) && normalizeOption(inverse.getAnswer(entry));
+ });
+
+ const occurrenceCountByKey = new Map();
+ rows.forEach((entry) => {
+ const uniquenessValue = typeof inverse.getUniquenessKey === "function"
+ ? inverse.getUniquenessKey(entry)
+ : variant.getAnswer(entry);
+ const key = normalizeKey(uniquenessValue);
+ occurrenceCountByKey.set(key, (occurrenceCountByKey.get(key) || 0) + 1);
+ });
+
+ const uniqueRows = rows.filter((entry) => {
+ const uniquenessValue = typeof inverse.getUniquenessKey === "function"
+ ? inverse.getUniquenessKey(entry)
+ : variant.getAnswer(entry);
+ return occurrenceCountByKey.get(normalizeKey(uniquenessValue)) === 1;
+ });
+
+ const inverseTemplates = buildTemplatesFromSpec({
+ entries: uniqueRows,
+ categoryId: inverse.categoryId || variant.categoryId || spec.categoryId,
+ category: inverse.category || variant.category || spec.category,
+ keyPrefix: inverse.keyPrefix || `${variant.keyPrefix || spec.keyPrefix}-reverse`,
+ getKey: inverse.getKey || variant.getKey || spec.getKey,
+ getPrompt: inverse.getPrompt,
+ getAnswer: inverse.getAnswer
+ });
+
+ return [...forwardTemplates, ...inverseTemplates];
+ });
+ }
+
+ window.QuizPluginHelpers = {
+ normalizeOption,
+ normalizeKey,
+ toUniqueOptionList,
+ makeTemplate,
+ buildTemplatesFromSpec,
+ buildTemplatesFromVariants
+ };
+})();
\ No newline at end of file
diff --git a/app/stellarium-now-wrapper.html b/app/stellarium-now-wrapper.html
new file mode 100644
index 0000000..3c65916
--- /dev/null
+++ b/app/stellarium-now-wrapper.html
@@ -0,0 +1,100 @@
+
+
+
+
+
+ Stellarium NOW Wrapper
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/styles.css b/app/styles.css
index 0f5ef0f..9c92ec5 100644
--- a/app/styles.css
+++ b/app/styles.css
@@ -557,14 +557,170 @@
gap: 10px;
}
- .tarot-house-layout {
- display: grid;
+ .tarot-house-card-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
gap: 12px;
+ flex-wrap: wrap;
+ }
+
+ .tarot-house-card-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+ }
+
+ .tarot-house-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ min-height: 36px;
+ padding: 0 10px;
+ border-radius: 8px;
+ border: 1px solid #3f3f46;
+ background: #18181b;
+ color: #f4f4f5;
+ font-size: 13px;
+ line-height: 1.2;
+ cursor: pointer;
+ -webkit-user-select: none;
+ user-select: none;
+ }
+
+ .tarot-house-toggle:hover {
+ background: #27272a;
+ border-color: #52525b;
+ }
+
+ .tarot-house-toggle input {
+ margin: 0;
+ accent-color: #6366f1;
+ }
+
+ .tarot-house-toggle input:disabled {
+ cursor: progress;
+ }
+
+ .tarot-house-toggle:has(input:disabled) {
+ opacity: 0.65;
+ cursor: progress;
+ }
+
+ .tarot-house-filter {
+ display: grid;
+ gap: 4px;
+ color: #d4d4d8;
+ font-size: 11px;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ }
+
+ .tarot-house-filter-select {
+ min-width: 132px;
+ padding: 7px 10px;
+ border-radius: 8px;
+ border: 1px solid #3f3f46;
+ background: #18181b;
+ color: #f4f4f5;
+ font-size: 13px;
+ text-transform: none;
+ letter-spacing: 0;
+ }
+
+ .tarot-house-filter-select:disabled {
+ opacity: 0.65;
+ cursor: progress;
+ }
+
+ .tarot-house-filter-group {
+ margin: 0;
+ padding: 6px 8px;
+ min-height: 36px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+ border: 1px solid #3f3f46;
+ border-radius: 8px;
+ background: #18181b;
+ }
+
+ .tarot-house-filter-group legend {
+ padding: 0 4px;
+ color: #d4d4d8;
+ font-size: 11px;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ }
+
+ .tarot-house-mini-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ min-height: 24px;
+ padding: 2px 6px;
+ border-radius: 999px;
+ background: #111118;
+ color: #f4f4f5;
+ font-size: 12px;
+ cursor: pointer;
+ -webkit-user-select: none;
+ user-select: none;
+ }
+
+ .tarot-house-mini-toggle input {
+ margin: 0;
+ accent-color: #6366f1;
+ }
+
+ .tarot-house-mini-toggle:has(input:disabled) {
+ opacity: 0.65;
+ cursor: progress;
+ }
+
+ .tarot-house-action-btn {
+ padding: 7px 12px;
+ border-radius: 8px;
+ border: 1px solid #3f3f46;
+ background: #18181b;
+ color: #f4f4f5;
+ cursor: pointer;
+ font-size: 13px;
+ line-height: 1.2;
+ }
+
+ .tarot-house-action-btn:hover:not(:disabled) {
+ background: #27272a;
+ border-color: #52525b;
+ }
+
+ .tarot-house-action-btn[aria-pressed="true"] {
+ background: #312e81;
+ border-color: #6366f1;
+ }
+
+ .tarot-house-action-btn:disabled {
+ opacity: 0.65;
+ cursor: progress;
+ }
+
+ .tarot-house-layout {
+ --tarot-house-card-width: 76.8px;
+ --tarot-house-card-height: 115.2px;
+ --tarot-house-card-gap: 6px;
+ --tarot-house-row-gap: 8px;
+ --tarot-house-section-gap: 12px;
+ --tarot-house-major-row-width: calc((var(--tarot-house-card-width) * 11) + (var(--tarot-house-card-gap) * 10));
+ display: grid;
+ gap: var(--tarot-house-section-gap);
+ justify-content: center;
}
.tarot-house-trumps {
display: grid;
- gap: 8px;
+ gap: var(--tarot-house-row-gap);
overflow-x: auto;
padding-bottom: 2px;
}
@@ -572,29 +728,34 @@
.tarot-house-trump-row {
display: flex;
flex-wrap: nowrap;
- gap: 6px;
- width: max-content;
+ gap: var(--tarot-house-card-gap);
+ width: min(100%, var(--tarot-house-major-row-width));
+ max-width: 100%;
+ justify-content: center;
margin: 0 auto;
}
.tarot-house-bottom-grid {
display: grid;
grid-template-columns: repeat(3, max-content);
- column-gap: 6px;
+ width: min(100%, var(--tarot-house-major-row-width));
+ max-width: 100%;
+ column-gap: 0;
row-gap: 0;
- justify-content: center;
+ justify-content: space-between;
+ margin: 0 auto;
}
.tarot-house-column {
display: grid;
- gap: 8px;
+ gap: var(--tarot-house-row-gap);
align-content: start;
}
.tarot-house-row {
display: flex;
flex-wrap: nowrap;
- gap: 6px;
+ gap: var(--tarot-house-card-gap);
}
.tarot-house-card-btn {
@@ -610,11 +771,24 @@
transition: transform 120ms ease, border-color 120ms ease, box-shadow 120ms ease;
}
+ .tarot-house-card-btn.is-text-only {
+ background:
+ radial-gradient(circle at top, rgba(99, 102, 241, 0.16) 0%, rgba(99, 102, 241, 0) 48%),
+ linear-gradient(180deg, #1b1b27 0%, #111118 100%);
+ line-height: 1.2;
+ }
+
.tarot-house-card-btn:hover {
border-color: #7060b0;
background: #27272a;
}
+ .tarot-house-card-btn.is-text-only:hover {
+ background:
+ radial-gradient(circle at top, rgba(129, 140, 248, 0.2) 0%, rgba(129, 140, 248, 0) 50%),
+ linear-gradient(180deg, #212132 0%, #171723 100%);
+ }
+
.tarot-house-card-btn.is-selected {
border-color: #7060b0;
background: #27272a;
@@ -625,15 +799,98 @@
.tarot-house-card-image {
display: block;
- width: 76.8px;
- height: 115.2px;
+ width: var(--tarot-house-card-width);
+ height: var(--tarot-house-card-height);
object-fit: cover;
background: #09090b;
}
+ .tarot-house-card-text-face {
+ width: var(--tarot-house-card-width);
+ height: var(--tarot-house-card-height);
+ display: grid;
+ align-content: center;
+ justify-items: center;
+ gap: 6px;
+ padding: 10px 8px;
+ box-sizing: border-box;
+ color: #fafafa;
+ text-align: center;
+ }
+
+ .tarot-house-card-text-face.is-dense {
+ gap: 4px;
+ padding: 8px 7px;
+ }
+
+ .tarot-house-card-text-face.is-top-hebrew .tarot-house-card-text-primary {
+ font-size: 26px;
+ line-height: 1;
+ font-family: "Noto Sans Hebrew", "Segoe UI Symbol", sans-serif;
+ }
+
+ .tarot-house-card-text-primary {
+ display: block;
+ font-size: 13px;
+ line-height: 1.2;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ overflow-wrap: anywhere;
+ }
+
+ .tarot-house-card-text-secondary {
+ display: block;
+ color: rgba(250, 250, 250, 0.76);
+ font-size: 9px;
+ line-height: 1.25;
+ letter-spacing: 0.02em;
+ overflow-wrap: anywhere;
+ }
+
+ .tarot-house-card-label {
+ position: absolute;
+ left: 4px;
+ right: 4px;
+ bottom: 4px;
+ padding: 4px 5px;
+ border-radius: 5px;
+ background: linear-gradient(180deg, rgba(9, 9, 11, 0.2) 0%, rgba(9, 9, 11, 0.9) 100%);
+ color: #fafafa;
+ font-size: 9px;
+ line-height: 1.2;
+ text-align: center;
+ letter-spacing: 0.02em;
+ pointer-events: none;
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7);
+ box-sizing: border-box;
+ }
+
+ .tarot-house-card-label.is-top-hebrew {
+ padding-top: 5px;
+ padding-bottom: 5px;
+ }
+
+ .tarot-house-card-label.is-dense {
+ font-size: 8px;
+ line-height: 1.15;
+ }
+
+ .tarot-house-card-label-primary {
+ display: block;
+ font-weight: 700;
+ }
+
+ .tarot-house-card-label-secondary {
+ display: block;
+ margin-top: 2px;
+ color: rgba(250, 250, 250, 0.84);
+ font-size: 8px;
+ font-weight: 500;
+ }
+
.tarot-house-card-fallback {
- width: 76.8px;
- height: 115.2px;
+ width: var(--tarot-house-card-width);
+ height: var(--tarot-house-card-height);
display: flex;
align-items: center;
justify-content: center;
@@ -647,6 +904,35 @@
box-sizing: border-box;
}
+ #tarot-browse-view.is-house-focus {
+ grid-template-rows: minmax(0, 1fr);
+ }
+
+ #tarot-browse-view.is-house-focus .tarot-section-house-top {
+ max-height: none;
+ height: 100%;
+ padding: 14px;
+ overflow: auto;
+ }
+
+ #tarot-browse-view.is-house-focus .tarot-layout {
+ display: none;
+ }
+
+ #tarot-browse-view.is-house-focus .tarot-house-layout {
+ --tarot-house-card-gap: clamp(4px, 0.6vw, 8px);
+ --tarot-house-row-gap: clamp(6px, 0.9vw, 10px);
+ --tarot-house-section-gap: clamp(12px, 1.4vw, 16px);
+ --tarot-house-card-width: clamp(48px, calc((100vw - 240px) / 11), 112px);
+ --tarot-house-card-height: calc(var(--tarot-house-card-width) * 1.5);
+ min-height: 100%;
+ align-content: start;
+ }
+
+ #tarot-browse-view.is-house-focus .tarot-house-trumps {
+ overflow-x: hidden;
+ }
+
.planet-layout {
height: 100%;
display: grid;
@@ -2024,6 +2310,7 @@
.alpha-enochian-glyph-img--detail {
width: 72px;
height: 72px;
+ filter:
drop-shadow(0 0 0.7px rgba(255, 255, 255, 0.82))
drop-shadow(0 0 1.6px rgba(255, 255, 255, 0.56));
flex: 1;
@@ -3269,13 +3556,15 @@
#now-panel {
position: relative;
overflow: hidden;
- padding: 16px 24px;
+ padding: 38px 24px 76px;
+ min-height: clamp(740px, 90vh, 1140px);
background: #1e1e24;
border-bottom: 1px solid #27272a;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0;
align-items: start;
+ align-content: start;
}
#now-panel[hidden] {
@@ -3295,6 +3584,13 @@
filter: none;
background-color: #000;
}
+ #now-panel::before {
+ content: "";
+ grid-column: 1 / -1;
+ grid-row: 3;
+ height: clamp(250px, 31vh, 430px);
+ pointer-events: none;
+ }
#now-panel::after {
content: "";
position: absolute;
@@ -3437,6 +3733,7 @@
position: relative;
z-index: 2;
grid-column: 1 / -1;
+ grid-row: 2;
margin-top: 10px;
border-radius: 14px;
padding: 12px 14px;
diff --git a/app/tarot-database-assembly.js b/app/tarot-database-assembly.js
index 80b0877..9052493 100644
--- a/app/tarot-database-assembly.js
+++ b/app/tarot-database-assembly.js
@@ -64,6 +64,7 @@
suit: null,
rank: null,
hebrewLetterId,
+ kabbalahPathNumber: Number.isFinite(Number(card?.number)) ? Number(card.number) + 11 : null,
hebrewLetter: hebrewLetterRelation?.data || null,
summary: card.summary,
meanings: {
@@ -139,6 +140,7 @@
const cards = [];
const signs = Array.isArray(referenceData?.signs) ? referenceData.signs : [];
const signById = Object.fromEntries(signs.map((sign) => [sign.id, sign]));
+ const planets = referenceData?.planets || {};
const decanById = new Map();
const decansBySign = referenceData?.decansBySign || {};
@@ -200,6 +202,24 @@
)
);
+ const ruler = planets?.[meta?.decan?.rulerPlanetId] || null;
+ if (ruler) {
+ dynamicRelations.push(
+ createRelation(
+ "decanRuler",
+ `${meta.signId}-${meta.index}-${ruler.id || meta.decan?.rulerPlanetId || rankKey}-${rankKey}-${suitKey}`,
+ `Decan ruler: ${ruler.symbol || ""} ${ruler.name || meta.decan?.rulerPlanetId || ""}`.trim(),
+ {
+ signId: meta.signId,
+ decanIndex: meta.index,
+ planetId: ruler.id || meta.decan?.rulerPlanetId || null,
+ symbol: ruler.symbol || "",
+ name: ruler.name || meta.decan?.rulerPlanetId || ""
+ }
+ )
+ );
+ }
+
const dateRange = meta.dateRange;
if (dateRange?.start && dateRange?.end) {
const monthNumbers = listMonthNumbersBetween(dateRange.start, dateRange.end);
diff --git a/app/ui-home-calendar.js b/app/ui-home-calendar.js
index b88a05b..877d65d 100644
--- a/app/ui-home-calendar.js
+++ b/app/ui-home-calendar.js
@@ -4,6 +4,8 @@
let config = {};
let lastNowSkyGeoKey = "";
let lastNowSkySourceUrl = "";
+ const NOW_SKY_WRAPPER_PATH = "app/stellarium-now-wrapper.html";
+ const NOW_SKY_FOV_DEGREES = "220";
function getNowSkyLayerEl() {
return config.nowSkyLayerEl || null;
@@ -37,16 +39,16 @@
return "";
}
- const stellariumUrl = new URL("https://stellarium-web.org/");
- stellariumUrl.searchParams.set("lat", String(normalizedGeo.latitude));
- stellariumUrl.searchParams.set("lng", String(normalizedGeo.longitude));
- stellariumUrl.searchParams.set("elev", "0");
- stellariumUrl.searchParams.set("date", new Date().toISOString());
- stellariumUrl.searchParams.set("az", "0");
- stellariumUrl.searchParams.set("alt", "90");
- stellariumUrl.searchParams.set("fov", "180");
+ const wrapperUrl = new URL(NOW_SKY_WRAPPER_PATH, window.location.href);
+ wrapperUrl.searchParams.set("lat", String(normalizedGeo.latitude));
+ wrapperUrl.searchParams.set("lng", String(normalizedGeo.longitude));
+ wrapperUrl.searchParams.set("elev", "0");
+ wrapperUrl.searchParams.set("date", new Date().toISOString());
+ wrapperUrl.searchParams.set("az", "0");
+ wrapperUrl.searchParams.set("alt", "90");
+ wrapperUrl.searchParams.set("fov", NOW_SKY_FOV_DEGREES);
- return stellariumUrl.toString();
+ return wrapperUrl.toString();
}
function syncNowSkyBackground(geo, force = false) {
diff --git a/app/ui-quiz-bank-builtins-domains.js b/app/ui-quiz-bank-builtins-domains.js
index f39aaea..b1b08d1 100644
--- a/app/ui-quiz-bank-builtins-domains.js
+++ b/app/ui-quiz-bank-builtins-domains.js
@@ -1,611 +1,8 @@
-/* ui-quiz-bank-builtins-domains.js — Built-in quiz domain template generation */
+/* ui-quiz-bank-builtins-domains.js — Legacy built-in quiz compatibility layer */
(function () {
"use strict";
- function appendBuiltInQuestionBankDomains(context) {
- const {
- bank,
- helpers,
- englishLetters,
- hebrewLetters,
- hebrewById,
- signs,
- planets,
- planetsById,
- treePaths,
- sephirotById,
- flattenDecans,
- cubeWalls,
- cubeEdges,
- cubeCenter,
- playingCards,
- pools
- } = context || {};
-
- const {
- createQuestionTemplate,
- normalizeId,
- normalizeOption,
- toTitleCase,
- formatHebrewLetterLabel,
- getPlanetLabelById,
- getSephiraName,
- formatPathLetter,
- formatDecanLabel,
- labelFromId
- } = helpers || {};
-
- if (!Array.isArray(bank) || typeof createQuestionTemplate !== "function") {
- return;
- }
-
- const {
- englishGematriaPool,
- hebrewNumerologyPool,
- hebrewNameAndCharPool,
- hebrewCharPool,
- planetNamePool,
- planetWeekdayPool,
- zodiacElementPool,
- zodiacTarotPool,
- pathNumberPool,
- pathLetterPool,
- pathTarotPool,
- sephirotPlanetPool,
- decanLabelPool,
- decanRulerPool,
- cubeLocationPool,
- cubeHebrewLetterPool,
- playingTarotPool
- } = pools || {};
-
- (englishLetters || []).forEach((entry) => {
- if (!entry?.letter || !Number.isFinite(Number(entry?.pythagorean))) {
- return;
- }
-
- const template = createQuestionTemplate(
- {
- key: `english-gematria:${entry.letter}`,
- categoryId: "english-gematria",
- category: "English Gematria",
- promptByDifficulty: `${entry.letter} has a simple gematria value of`,
- answerByDifficulty: String(entry.pythagorean)
- },
- englishGematriaPool
- );
-
- if (template) {
- bank.push(template);
- }
- });
-
- (hebrewLetters || []).forEach((entry) => {
- if (!entry?.name || !entry?.char || !Number.isFinite(Number(entry?.numerology))) {
- return;
- }
-
- const template = createQuestionTemplate(
- {
- key: `hebrew-number:${entry.hebrewLetterId || entry.name}`,
- categoryId: "hebrew-numerology",
- category: "Hebrew Gematria",
- promptByDifficulty: {
- easy: `${entry.name} (${entry.char}) has a gematria value of`,
- normal: `${entry.name} (${entry.char}) has a gematria value of`,
- hard: `${entry.char} has a gematria value of`
- },
- answerByDifficulty: String(entry.numerology)
- },
- hebrewNumerologyPool
- );
-
- if (template) {
- bank.push(template);
- }
- });
-
- (englishLetters || []).forEach((entry) => {
- if (!entry?.letter || !entry?.hebrewLetterId) {
- return;
- }
-
- const mappedHebrew = hebrewById.get(normalizeId(entry.hebrewLetterId));
- if (!mappedHebrew?.name || !mappedHebrew?.char) {
- return;
- }
-
- const template = createQuestionTemplate(
- {
- key: `english-hebrew:${entry.letter}`,
- categoryId: "english-hebrew-mapping",
- category: "Alphabet Mapping",
- promptByDifficulty: {
- easy: `${entry.letter} maps to which Hebrew letter`,
- normal: `${entry.letter} maps to which Hebrew letter`,
- hard: `${entry.letter} maps to which Hebrew glyph`
- },
- answerByDifficulty: {
- easy: `${mappedHebrew.name} (${mappedHebrew.char})`,
- normal: `${mappedHebrew.name} (${mappedHebrew.char})`,
- hard: mappedHebrew.char
- }
- },
- {
- easy: hebrewNameAndCharPool,
- normal: hebrewNameAndCharPool,
- hard: hebrewCharPool
- }
- );
-
- if (template) {
- bank.push(template);
- }
- });
-
- (signs || []).forEach((entry) => {
- if (!entry?.name || !entry?.rulingPlanetId) {
- return;
- }
-
- const rulerName = planetsById[normalizeId(entry.rulingPlanetId)]?.name;
- if (!rulerName) {
- return;
- }
-
- const template = createQuestionTemplate(
- {
- key: `zodiac-ruler:${entry.id || entry.name}`,
- categoryId: "zodiac-rulers",
- category: "Zodiac Rulers",
- promptByDifficulty: `${entry.name} is ruled by`,
- answerByDifficulty: rulerName
- },
- planetNamePool
- );
-
- if (template) {
- bank.push(template);
- }
- });
-
- (signs || []).forEach((entry) => {
- if (!entry?.name || !entry?.element) {
- return;
- }
-
- const template = createQuestionTemplate(
- {
- key: `zodiac-element:${entry.id || entry.name}`,
- categoryId: "zodiac-elements",
- category: "Zodiac Elements",
- promptByDifficulty: `${entry.name} is`,
- answerByDifficulty: toTitleCase(entry.element)
- },
- zodiacElementPool
- );
-
- if (template) {
- bank.push(template);
- }
- });
-
- (planets || []).forEach((entry) => {
- if (!entry?.name || !entry?.weekday) {
- return;
- }
-
- const template = createQuestionTemplate(
- {
- key: `planet-weekday:${entry.id || entry.name}`,
- categoryId: "planetary-weekdays",
- category: "Planetary Weekdays",
- promptByDifficulty: `${entry.name} corresponds to`,
- answerByDifficulty: entry.weekday
- },
- planetWeekdayPool
- );
-
- if (template) {
- bank.push(template);
- }
- });
-
- (signs || []).forEach((entry) => {
- if (!entry?.name || !entry?.tarot?.majorArcana) {
- return;
- }
-
- const template = createQuestionTemplate(
- {
- key: `zodiac-tarot:${entry.id || entry.name}`,
- categoryId: "zodiac-tarot",
- category: "Zodiac ↔ Tarot",
- promptByDifficulty: `${entry.name} corresponds to`,
- answerByDifficulty: entry.tarot.majorArcana
- },
- zodiacTarotPool
- );
-
- if (template) {
- bank.push(template);
- }
- });
-
- (treePaths || []).forEach((path) => {
- const pathNo = Number(path?.pathNumber);
- if (!Number.isFinite(pathNo)) {
- return;
- }
-
- const pathNumberLabel = String(Math.trunc(pathNo));
- const fromNo = Number(path?.connects?.from);
- const toNo = Number(path?.connects?.to);
- const fromName = getSephiraName(fromNo, path?.connectIds?.from);
- const toName = getSephiraName(toNo, path?.connectIds?.to);
- const pathLetter = formatPathLetter(path);
- const tarotCard = normalizeOption(path?.tarot?.card);
-
- if (fromName && toName) {
- const template = createQuestionTemplate(
- {
- key: `kabbalah-path-between:${pathNumberLabel}`,
- categoryId: "kabbalah-path-between-sephirot",
- category: "Kabbalah Paths",
- promptByDifficulty: {
- easy: `Which path is between ${fromName} and ${toName}`,
- normal: `What path connects ${fromName} and ${toName}`,
- hard: `${fromName} ↔ ${toName} is which path`
- },
- answerByDifficulty: pathNumberLabel
- },
- pathNumberPool
- );
-
- if (template) {
- bank.push(template);
- }
- }
-
- if (pathLetter) {
- const numberToLetterTemplate = createQuestionTemplate(
- {
- key: `kabbalah-path-letter:${pathNumberLabel}`,
- categoryId: "kabbalah-path-letter",
- category: "Kabbalah Paths",
- promptByDifficulty: {
- easy: `Which letter is on Path ${pathNumberLabel}`,
- normal: `Path ${pathNumberLabel} carries which Hebrew letter`,
- hard: `Letter on Path ${pathNumberLabel}`
- },
- answerByDifficulty: pathLetter
- },
- pathLetterPool
- );
-
- if (numberToLetterTemplate) {
- bank.push(numberToLetterTemplate);
- }
-
- const letterToNumberTemplate = createQuestionTemplate(
- {
- key: `kabbalah-letter-path-number:${pathNumberLabel}`,
- categoryId: "kabbalah-path-letter",
- category: "Kabbalah Paths",
- promptByDifficulty: {
- easy: `${pathLetter} belongs to which path`,
- normal: `${pathLetter} corresponds to Path`,
- hard: `${pathLetter} is on Path`
- },
- answerByDifficulty: pathNumberLabel
- },
- pathNumberPool
- );
-
- if (letterToNumberTemplate) {
- bank.push(letterToNumberTemplate);
- }
- }
-
- if (tarotCard) {
- const pathToTarotTemplate = createQuestionTemplate(
- {
- key: `kabbalah-path-tarot:${pathNumberLabel}`,
- categoryId: "kabbalah-path-tarot",
- category: "Kabbalah ↔ Tarot",
- promptByDifficulty: {
- easy: `Path ${pathNumberLabel} corresponds to which Tarot trump`,
- normal: `Which Tarot trump is on Path ${pathNumberLabel}`,
- hard: `Tarot trump on Path ${pathNumberLabel}`
- },
- answerByDifficulty: tarotCard
- },
- pathTarotPool
- );
-
- if (pathToTarotTemplate) {
- bank.push(pathToTarotTemplate);
- }
-
- const tarotToPathTemplate = createQuestionTemplate(
- {
- key: `tarot-trump-path:${pathNumberLabel}`,
- categoryId: "kabbalah-path-tarot",
- category: "Tarot ↔ Kabbalah",
- promptByDifficulty: {
- easy: `${tarotCard} is on which path`,
- normal: `Which path corresponds to ${tarotCard}`,
- hard: `${tarotCard} corresponds to Path`
- },
- answerByDifficulty: pathNumberLabel
- },
- pathNumberPool
- );
-
- if (tarotToPathTemplate) {
- bank.push(tarotToPathTemplate);
- }
- }
- });
-
- Object.values(sephirotById || {}).forEach((sephira) => {
- const sephiraName = String(sephira?.name?.roman || sephira?.name?.en || "").trim();
- const planetLabel = getPlanetLabelById(sephira?.planetId);
- if (!sephiraName || !planetLabel) {
- return;
- }
-
- const template = createQuestionTemplate(
- {
- key: `sephirot-planet:${normalizeId(sephira?.id || sephiraName)}`,
- categoryId: "sephirot-planets",
- category: "Sephirot ↔ Planet",
- promptByDifficulty: {
- easy: `${sephiraName} corresponds to which planet`,
- normal: `Planetary correspondence of ${sephiraName}`,
- hard: `${sephiraName} corresponds to`
- },
- answerByDifficulty: planetLabel
- },
- sephirotPlanetPool
- );
-
- if (template) {
- bank.push(template);
- }
- });
-
- (flattenDecans || []).forEach((decan) => {
- const decanId = String(decan?.id || "").trim();
- const card = normalizeOption(decan?.tarotMinorArcana);
- const decanLabel = formatDecanLabel(decan);
- const rulerLabel = getPlanetLabelById(decan?.rulerPlanetId);
-
- if (!decanId || !card) {
- return;
- }
-
- if (decanLabel) {
- const template = createQuestionTemplate(
- {
- key: `tarot-decan-sign:${decanId}`,
- categoryId: "tarot-decan-sign",
- category: "Tarot Decans",
- promptByDifficulty: {
- easy: `${card} belongs to which decan`,
- normal: `Which decan contains ${card}`,
- hard: `${card} is in`
- },
- answerByDifficulty: decanLabel
- },
- decanLabelPool
- );
-
- if (template) {
- bank.push(template);
- }
- }
-
- if (rulerLabel) {
- const template = createQuestionTemplate(
- {
- key: `tarot-decan-ruler:${decanId}`,
- categoryId: "tarot-decan-ruler",
- category: "Tarot Decans",
- promptByDifficulty: {
- easy: `The decan of ${card} is ruled by`,
- normal: `Who rules the decan for ${card}`,
- hard: `${card} decan ruler`
- },
- answerByDifficulty: rulerLabel
- },
- decanRulerPool
- );
-
- if (template) {
- bank.push(template);
- }
- }
- });
-
- (cubeWalls || []).forEach((wall) => {
- const wallName = String(wall?.name || labelFromId(wall?.id)).trim();
- const wallLabel = wallName ? `${wallName} Wall` : "";
- const tarotCard = normalizeOption(wall?.associations?.tarotCard);
- const hebrew = hebrewById.get(normalizeId(wall?.hebrewLetterId));
- const hebrewLabel = formatHebrewLetterLabel(hebrew, wall?.hebrewLetterId);
-
- if (tarotCard && wallLabel) {
- const template = createQuestionTemplate(
- {
- key: `tarot-cube-wall:${normalizeId(wall?.id || wallName)}`,
- categoryId: "tarot-cube-location",
- category: "Tarot ↔ Cube",
- promptByDifficulty: {
- easy: `${tarotCard} is on which Cube wall`,
- normal: `Where is ${tarotCard} on the Cube`,
- hard: `${tarotCard} location on Cube`
- },
- answerByDifficulty: wallLabel
- },
- cubeLocationPool
- );
-
- if (template) {
- bank.push(template);
- }
- }
-
- if (wallLabel && hebrewLabel) {
- const template = createQuestionTemplate(
- {
- key: `cube-wall-letter:${normalizeId(wall?.id || wallName)}`,
- categoryId: "cube-hebrew-letter",
- category: "Cube ↔ Hebrew",
- promptByDifficulty: {
- easy: `${wallLabel} corresponds to which Hebrew letter`,
- normal: `Which Hebrew letter is on ${wallLabel}`,
- hard: `${wallLabel} letter`
- },
- answerByDifficulty: hebrewLabel
- },
- cubeHebrewLetterPool
- );
-
- if (template) {
- bank.push(template);
- }
- }
- });
-
- (cubeEdges || []).forEach((edge) => {
- const edgeName = String(edge?.name || labelFromId(edge?.id)).trim();
- const edgeLabel = edgeName ? `${edgeName} Edge` : "";
- const hebrew = hebrewById.get(normalizeId(edge?.hebrewLetterId));
- const hebrewLabel = formatHebrewLetterLabel(hebrew, edge?.hebrewLetterId);
- const tarotCard = normalizeOption(hebrew?.tarot?.card);
-
- if (tarotCard && edgeLabel) {
- const template = createQuestionTemplate(
- {
- key: `tarot-cube-edge:${normalizeId(edge?.id || edgeName)}`,
- categoryId: "tarot-cube-location",
- category: "Tarot ↔ Cube",
- promptByDifficulty: {
- easy: `${tarotCard} is on which Cube edge`,
- normal: `Where is ${tarotCard} on the Cube edges`,
- hard: `${tarotCard} edge location`
- },
- answerByDifficulty: edgeLabel
- },
- cubeLocationPool
- );
-
- if (template) {
- bank.push(template);
- }
- }
-
- if (edgeLabel && hebrewLabel) {
- const template = createQuestionTemplate(
- {
- key: `cube-edge-letter:${normalizeId(edge?.id || edgeName)}`,
- categoryId: "cube-hebrew-letter",
- category: "Cube ↔ Hebrew",
- promptByDifficulty: {
- easy: `${edgeLabel} corresponds to which Hebrew letter`,
- normal: `Which Hebrew letter is on ${edgeLabel}`,
- hard: `${edgeLabel} letter`
- },
- answerByDifficulty: hebrewLabel
- },
- cubeHebrewLetterPool
- );
-
- if (template) {
- bank.push(template);
- }
- }
- });
-
- if (cubeCenter) {
- const centerTarot = normalizeOption(cubeCenter?.associations?.tarotCard || cubeCenter?.tarotCard);
- const centerHebrew = hebrewById.get(normalizeId(cubeCenter?.hebrewLetterId));
- const centerHebrewLabel = formatHebrewLetterLabel(centerHebrew, cubeCenter?.hebrewLetterId);
-
- if (centerTarot) {
- const template = createQuestionTemplate(
- {
- key: "tarot-cube-center",
- categoryId: "tarot-cube-location",
- category: "Tarot ↔ Cube",
- promptByDifficulty: {
- easy: `${centerTarot} is located at which Cube position`,
- normal: `Where is ${centerTarot} on the Cube`,
- hard: `${centerTarot} Cube location`
- },
- answerByDifficulty: "Center"
- },
- cubeLocationPool
- );
-
- if (template) {
- bank.push(template);
- }
- }
-
- if (centerHebrewLabel) {
- const template = createQuestionTemplate(
- {
- key: "cube-center-letter",
- categoryId: "cube-hebrew-letter",
- category: "Cube ↔ Hebrew",
- promptByDifficulty: {
- easy: "The Cube center corresponds to which Hebrew letter",
- normal: "Which Hebrew letter is at the Cube center",
- hard: "Cube center letter"
- },
- answerByDifficulty: centerHebrewLabel
- },
- cubeHebrewLetterPool
- );
-
- if (template) {
- bank.push(template);
- }
- }
- }
-
- (playingCards || []).forEach((entry) => {
- const cardId = String(entry?.id || "").trim();
- const rankLabel = normalizeOption(entry?.rankLabel || entry?.rank);
- const suitLabel = normalizeOption(entry?.suitLabel || labelFromId(entry?.suit));
- const tarotCard = normalizeOption(entry?.tarotCard);
-
- if (!cardId || !rankLabel || !suitLabel || !tarotCard) {
- return;
- }
-
- const template = createQuestionTemplate(
- {
- key: `playing-card-tarot:${cardId}`,
- categoryId: "playing-card-tarot",
- category: "Playing Card ↔ Tarot",
- promptByDifficulty: {
- easy: `${rankLabel} of ${suitLabel} maps to which Tarot card`,
- normal: `${rankLabel} of ${suitLabel} corresponds to`,
- hard: `${rankLabel} of ${suitLabel} maps to`
- },
- answerByDifficulty: tarotCard
- },
- playingTarotPool
- );
-
- if (template) {
- bank.push(template);
- }
- });
- }
+ function appendBuiltInQuestionBankDomains() {}
window.QuizQuestionBankBuiltInDomains = {
appendBuiltInQuestionBankDomains
diff --git a/app/ui-quiz-bank-builtins.js b/app/ui-quiz-bank-builtins.js
index 35017c5..7c25b74 100644
--- a/app/ui-quiz-bank-builtins.js
+++ b/app/ui-quiz-bank-builtins.js
@@ -1,355 +1,9 @@
-/* ui-quiz-bank-builtins.js — Built-in quiz template generation */
+/* ui-quiz-bank-builtins.js — Legacy built-in quiz compatibility layer */
(function () {
"use strict";
- const quizQuestionBankBuiltInDomains = window.QuizQuestionBankBuiltInDomains || {};
-
- if (typeof quizQuestionBankBuiltInDomains.appendBuiltInQuestionBankDomains !== "function") {
- throw new Error("QuizQuestionBankBuiltInDomains module must load before ui-quiz-bank-builtins.js");
- }
-
- function buildBuiltInQuestionBank(context) {
- const {
- referenceData,
- magickDataset,
- helpers
- } = context || {};
-
- const {
- toTitleCase,
- normalizeOption,
- toUniqueOptionList,
- createQuestionTemplate
- } = helpers || {};
-
- if (
- typeof toTitleCase !== "function"
- || typeof normalizeOption !== "function"
- || typeof toUniqueOptionList !== "function"
- || typeof createQuestionTemplate !== "function"
- ) {
- return [];
- }
-
- const grouped = magickDataset?.grouped || {};
- const alphabets = grouped.alphabets || {};
- const englishLetters = Array.isArray(alphabets?.english) ? alphabets.english : [];
- const hebrewLetters = Array.isArray(alphabets?.hebrew) ? alphabets.hebrew : [];
- const kabbalahTree = grouped?.kabbalah?.["kabbalah-tree"] || {};
- const treePaths = Array.isArray(kabbalahTree?.paths) ? kabbalahTree.paths : [];
- const treeSephiroth = Array.isArray(kabbalahTree?.sephiroth) ? kabbalahTree.sephiroth : [];
- const sephirotById = grouped?.kabbalah?.sephirot && typeof grouped.kabbalah.sephirot === "object"
- ? grouped.kabbalah.sephirot
- : {};
- const cube = grouped?.kabbalah?.cube && typeof grouped.kabbalah.cube === "object"
- ? grouped.kabbalah.cube
- : {};
- const cubeWalls = Array.isArray(cube?.walls) ? cube.walls : [];
- const cubeEdges = Array.isArray(cube?.edges) ? cube.edges : [];
- const cubeCenter = cube?.center && typeof cube.center === "object" ? cube.center : null;
- const playingCardsData = grouped?.["playing-cards-52"];
- const playingCards = Array.isArray(playingCardsData)
- ? playingCardsData
- : (Array.isArray(playingCardsData?.entries) ? playingCardsData.entries : []);
- const signs = Array.isArray(referenceData?.signs) ? referenceData.signs : [];
- const planetsById = referenceData?.planets && typeof referenceData.planets === "object"
- ? referenceData.planets
- : {};
- const planets = Object.values(planetsById);
- const decansBySign = referenceData?.decansBySign && typeof referenceData.decansBySign === "object"
- ? referenceData.decansBySign
- : {};
-
- const normalizeId = (value) => String(value || "").trim().toLowerCase();
-
- const toRomanNumeral = (value) => {
- const numeric = Number(value);
- if (!Number.isFinite(numeric) || numeric <= 0) {
- return String(value || "");
- }
-
- const intValue = Math.trunc(numeric);
- const lookup = [
- [1000, "M"],
- [900, "CM"],
- [500, "D"],
- [400, "CD"],
- [100, "C"],
- [90, "XC"],
- [50, "L"],
- [40, "XL"],
- [10, "X"],
- [9, "IX"],
- [5, "V"],
- [4, "IV"],
- [1, "I"]
- ];
-
- let current = intValue;
- let result = "";
- lookup.forEach(([size, symbol]) => {
- while (current >= size) {
- result += symbol;
- current -= size;
- }
- });
-
- return result || String(intValue);
- };
-
- const labelFromId = (value) => {
- const id = String(value || "").trim();
- if (!id) {
- return "";
- }
- return id
- .replace(/[_-]+/g, " ")
- .replace(/\s+/g, " ")
- .trim()
- .split(" ")
- .map((part) => part ? part.charAt(0).toUpperCase() + part.slice(1) : "")
- .join(" ");
- };
-
- const getPlanetLabelById = (planetId) => {
- const normalized = normalizeId(planetId);
- if (!normalized) {
- return "";
- }
-
- const directPlanet = planetsById[normalized];
- if (directPlanet?.name) {
- return directPlanet.name;
- }
-
- if (normalized === "primum-mobile") {
- return "Primum Mobile";
- }
- if (normalized === "olam-yesodot") {
- return "Earth / Elements";
- }
-
- return labelFromId(normalized);
- };
-
- const hebrewById = new Map(
- hebrewLetters
- .filter((entry) => entry?.hebrewLetterId)
- .map((entry) => [normalizeId(entry.hebrewLetterId), entry])
- );
-
- const formatHebrewLetterLabel = (entry, fallbackId = "") => {
- if (entry?.name && entry?.char) {
- return `${entry.name} (${entry.char})`;
- }
- if (entry?.name) {
- return entry.name;
- }
- if (entry?.char) {
- return entry.char;
- }
- return labelFromId(fallbackId);
- };
-
- const sephiraNameByNumber = new Map(
- treeSephiroth
- .filter((entry) => Number.isFinite(Number(entry?.number)) && entry?.name)
- .map((entry) => [Math.trunc(Number(entry.number)), String(entry.name)])
- );
-
- const sephiraNameById = new Map(
- treeSephiroth
- .filter((entry) => entry?.sephiraId && entry?.name)
- .map((entry) => [normalizeId(entry.sephiraId), String(entry.name)])
- );
-
- const getSephiraName = (numberValue, idValue) => {
- const numberKey = Number(numberValue);
- if (Number.isFinite(numberKey)) {
- const byNumber = sephiraNameByNumber.get(Math.trunc(numberKey));
- if (byNumber) {
- return byNumber;
- }
- }
-
- const byId = sephiraNameById.get(normalizeId(idValue));
- if (byId) {
- return byId;
- }
-
- if (Number.isFinite(numberKey)) {
- return `Sephira ${Math.trunc(numberKey)}`;
- }
-
- return labelFromId(idValue);
- };
-
- const formatPathLetter = (path) => {
- const transliteration = String(path?.hebrewLetter?.transliteration || "").trim();
- const glyph = String(path?.hebrewLetter?.char || "").trim();
-
- if (transliteration && glyph) {
- return `${transliteration} (${glyph})`;
- }
- if (transliteration) {
- return transliteration;
- }
- if (glyph) {
- return glyph;
- }
- return "";
- };
-
- const flattenDecans = Object.values(decansBySign)
- .flatMap((entries) => (Array.isArray(entries) ? entries : []));
-
- const signNameById = new Map(
- signs
- .filter((entry) => entry?.id && entry?.name)
- .map((entry) => [normalizeId(entry.id), String(entry.name)])
- );
-
- const formatDecanLabel = (decan) => {
- const signName = signNameById.get(normalizeId(decan?.signId)) || labelFromId(decan?.signId);
- const index = Number(decan?.index);
- if (!signName || !Number.isFinite(index)) {
- return "";
- }
- return `${signName} Decan ${toRomanNumeral(index)}`;
- };
-
- const bank = [];
-
- const englishGematriaPool = englishLetters
- .map((item) => (Number.isFinite(Number(item?.pythagorean)) ? String(item.pythagorean) : ""))
- .filter(Boolean);
-
- const hebrewNumerologyPool = hebrewLetters
- .map((item) => (Number.isFinite(Number(item?.numerology)) ? String(item.numerology) : ""))
- .filter(Boolean);
-
- const hebrewNameAndCharPool = hebrewLetters
- .filter((item) => item?.name && item?.char)
- .map((item) => `${item.name} (${item.char})`);
-
- const hebrewCharPool = hebrewLetters
- .map((item) => item?.char)
- .filter(Boolean);
-
- const planetNamePool = planets
- .map((planet) => planet?.name)
- .filter(Boolean);
-
- const planetWeekdayPool = planets
- .map((planet) => planet?.weekday)
- .filter(Boolean);
-
- const zodiacElementPool = signs
- .map((sign) => toTitleCase(sign?.element))
- .filter(Boolean);
-
- const zodiacTarotPool = signs
- .map((sign) => sign?.tarot?.majorArcana)
- .filter(Boolean);
-
- const pathNumberPool = toUniqueOptionList(
- treePaths
- .map((path) => {
- const pathNo = Number(path?.pathNumber);
- return Number.isFinite(pathNo) ? String(Math.trunc(pathNo)) : "";
- })
- );
-
- const pathLetterPool = toUniqueOptionList(treePaths.map((path) => formatPathLetter(path)));
- const pathTarotPool = toUniqueOptionList(treePaths.map((path) => normalizeOption(path?.tarot?.card)));
- const sephirotPlanetPool = toUniqueOptionList(
- Object.values(sephirotById).map((entry) => getPlanetLabelById(entry?.planetId))
- );
-
- const decanLabelPool = toUniqueOptionList(flattenDecans.map((decan) => formatDecanLabel(decan)));
- const decanRulerPool = toUniqueOptionList(
- flattenDecans.map((decan) => getPlanetLabelById(decan?.rulerPlanetId))
- );
-
- const cubeWallLabelPool = toUniqueOptionList(
- cubeWalls.map((wall) => `${String(wall?.name || labelFromId(wall?.id)).trim()} Wall`)
- );
-
- const cubeEdgeLabelPool = toUniqueOptionList(
- cubeEdges.map((edge) => `${String(edge?.name || labelFromId(edge?.id)).trim()} Edge`)
- );
-
- const cubeLocationPool = toUniqueOptionList([
- ...cubeWallLabelPool,
- ...cubeEdgeLabelPool,
- "Center"
- ]);
-
- const cubeHebrewLetterPool = toUniqueOptionList([
- ...cubeWalls.map((wall) => {
- const hebrew = hebrewById.get(normalizeId(wall?.hebrewLetterId));
- return formatHebrewLetterLabel(hebrew, wall?.hebrewLetterId);
- }),
- ...cubeEdges.map((edge) => {
- const hebrew = hebrewById.get(normalizeId(edge?.hebrewLetterId));
- return formatHebrewLetterLabel(hebrew, edge?.hebrewLetterId);
- }),
- formatHebrewLetterLabel(hebrewById.get(normalizeId(cubeCenter?.hebrewLetterId)), cubeCenter?.hebrewLetterId)
- ]);
-
- const playingTarotPool = toUniqueOptionList(
- playingCards.map((entry) => normalizeOption(entry?.tarotCard))
- );
-
- quizQuestionBankBuiltInDomains.appendBuiltInQuestionBankDomains({
- bank,
- englishLetters,
- hebrewLetters,
- hebrewById,
- signs,
- planets,
- planetsById,
- treePaths,
- sephirotById,
- flattenDecans,
- cubeWalls,
- cubeEdges,
- cubeCenter,
- playingCards,
- pools: {
- englishGematriaPool,
- hebrewNumerologyPool,
- hebrewNameAndCharPool,
- hebrewCharPool,
- planetNamePool,
- planetWeekdayPool,
- zodiacElementPool,
- zodiacTarotPool,
- pathNumberPool,
- pathLetterPool,
- pathTarotPool,
- sephirotPlanetPool,
- decanLabelPool,
- decanRulerPool,
- cubeLocationPool,
- cubeHebrewLetterPool,
- playingTarotPool
- },
- helpers: {
- createQuestionTemplate,
- normalizeId,
- normalizeOption,
- toTitleCase,
- formatHebrewLetterLabel,
- getPlanetLabelById,
- getSephiraName,
- formatPathLetter,
- formatDecanLabel,
- labelFromId
- }
- });
-
- return bank;
+ function buildBuiltInQuestionBank() {
+ return [];
}
window.QuizQuestionBankBuiltins = {
diff --git a/app/ui-quiz-bank.js b/app/ui-quiz-bank.js
index 6891302..91fd958 100644
--- a/app/ui-quiz-bank.js
+++ b/app/ui-quiz-bank.js
@@ -109,6 +109,19 @@
};
}
+ function dedupeTemplatesByKey(templates) {
+ const deduped = new Map();
+
+ (templates || []).forEach((template) => {
+ if (!template || !template.key) {
+ return;
+ }
+ deduped.set(template.key, template);
+ });
+
+ return [...deduped.values()];
+ }
+
function buildQuestionBank(referenceData, magickDataset, dynamicCategoryRegistry) {
const bank = quizQuestionBankBuiltins.buildBuiltInQuestionBank({
referenceData,
@@ -136,12 +149,13 @@
}
});
- return bank;
+ return dedupeTemplatesByKey(bank);
}
window.QuizQuestionBank = {
buildQuestionBank,
createQuestionTemplate,
+ dedupeTemplatesByKey,
normalizeKey,
normalizeOption,
toTitleCase,
diff --git a/app/ui-quiz.js b/app/ui-quiz.js
index eb8c639..4fa436d 100644
--- a/app/ui-quiz.js
+++ b/app/ui-quiz.js
@@ -310,9 +310,26 @@
function getCategoryOptions() {
const availableCategoryIds = new Set(state.questionBank.map((template) => template.categoryId));
- const dynamic = CATEGORY_META
- .filter((item) => availableCategoryIds.has(item.id))
- .map((item) => ({ value: item.id, label: item.label }));
+ const labelByCategoryId = new Map(CATEGORY_META.map((item) => [item.id, item.label]));
+
+ state.questionBank.forEach((template) => {
+ const categoryId = String(template?.categoryId || "").trim();
+ const category = String(template?.category || "").trim();
+ if (categoryId && category && !labelByCategoryId.has(categoryId)) {
+ labelByCategoryId.set(categoryId, category);
+ }
+ });
+
+ const dynamic = [...availableCategoryIds]
+ .sort((left, right) => {
+ const leftLabel = String(labelByCategoryId.get(left) || left);
+ const rightLabel = String(labelByCategoryId.get(right) || right);
+ return leftLabel.localeCompare(rightLabel);
+ })
+ .map((categoryId) => ({
+ value: categoryId,
+ label: String(labelByCategoryId.get(categoryId) || categoryId)
+ }));
return [...FIXED_CATEGORY_OPTIONS, ...dynamic];
}
@@ -358,7 +375,9 @@
if (mode === "all") {
return "All";
}
- return CATEGORY_META.find((item) => item.id === mode)?.label || "Category";
+ return CATEGORY_META.find((item) => item.id === mode)?.label
+ || state.questionBank.find((template) => template.categoryId === mode)?.category
+ || "Category";
}
function startRun(resetScore = false) {
diff --git a/app/ui-tarot-detail.js b/app/ui-tarot-detail.js
index e3c7690..874a218 100644
--- a/app/ui-tarot-detail.js
+++ b/app/ui-tarot-detail.js
@@ -34,71 +34,7 @@
});
}
- function renderDetail(card, elements) {
- if (!card || !elements) {
- return;
- }
-
- const cardDisplayName = getDisplayCardName(card);
- const imageUrl = typeof resolveTarotCardImage === "function"
- ? resolveTarotCardImage(card.name)
- : null;
-
- if (elements.tarotDetailImageEl) {
- if (imageUrl) {
- elements.tarotDetailImageEl.src = imageUrl;
- elements.tarotDetailImageEl.alt = cardDisplayName || card.name;
- elements.tarotDetailImageEl.style.display = "block";
- elements.tarotDetailImageEl.style.cursor = "zoom-in";
- elements.tarotDetailImageEl.title = "Click to enlarge";
- } else {
- elements.tarotDetailImageEl.removeAttribute("src");
- elements.tarotDetailImageEl.alt = "";
- elements.tarotDetailImageEl.style.display = "none";
- elements.tarotDetailImageEl.style.cursor = "default";
- elements.tarotDetailImageEl.removeAttribute("title");
- }
- }
-
- if (elements.tarotDetailNameEl) {
- elements.tarotDetailNameEl.textContent = cardDisplayName || card.name;
- }
-
- if (elements.tarotDetailTypeEl) {
- elements.tarotDetailTypeEl.textContent = buildTypeLabel(card);
- }
-
- if (elements.tarotDetailSummaryEl) {
- elements.tarotDetailSummaryEl.textContent = card.summary || "--";
- }
-
- if (elements.tarotDetailUprightEl) {
- elements.tarotDetailUprightEl.textContent = card.meanings?.upright || "--";
- }
-
- if (elements.tarotDetailReversedEl) {
- elements.tarotDetailReversedEl.textContent = card.meanings?.reversed || "--";
- }
-
- const meaningText = String(card.meaning || card.meanings?.upright || "").trim();
- if (elements.tarotMetaMeaningCardEl && elements.tarotDetailMeaningEl) {
- if (meaningText) {
- elements.tarotMetaMeaningCardEl.hidden = false;
- elements.tarotDetailMeaningEl.textContent = meaningText;
- } else {
- elements.tarotMetaMeaningCardEl.hidden = true;
- elements.tarotDetailMeaningEl.textContent = "--";
- }
- }
-
- clearChildren(elements.tarotDetailKeywordsEl);
- (card.keywords || []).forEach((keyword) => {
- const chip = document.createElement("span");
- chip.className = "tarot-keyword-chip";
- chip.textContent = keyword;
- elements.tarotDetailKeywordsEl?.appendChild(chip);
- });
-
+ function collectDetailRelations(card) {
const allRelations = (card.relations || [])
.map((relation, index) => normalizeRelationObject(relation, index))
.filter(Boolean);
@@ -224,15 +160,119 @@
return String(left.label || "").localeCompare(String(right.label || ""));
});
- renderStaticRelationGroup(elements.tarotDetailPlanetEl, elements.tarotMetaPlanetCardEl, planetRelations);
- renderStaticRelationGroup(elements.tarotDetailElementEl, elements.tarotMetaElementCardEl, elementRelations);
- renderStaticRelationGroup(elements.tarotDetailTetragrammatonEl, elements.tarotMetaTetragrammatonCardEl, tetragrammatonRelations);
- renderStaticRelationGroup(elements.tarotDetailZodiacEl, elements.tarotMetaZodiacCardEl, zodiacRelationsWithRulership);
- renderStaticRelationGroup(elements.tarotDetailCourtDateEl, elements.tarotMetaCourtDateCardEl, mergedCourtDateRelations);
- renderStaticRelationGroup(elements.tarotDetailHebrewEl, elements.tarotMetaHebrewCardEl, hebrewRelations);
- renderStaticRelationGroup(elements.tarotDetailCubeEl, elements.tarotMetaCubeCardEl, cubeRelations);
- renderStaticRelationGroup(elements.tarotDetailIChingEl, elements.tarotMetaIChingCardEl, iChingRelations);
- renderStaticRelationGroup(elements.tarotDetailCalendarEl, elements.tarotMetaCalendarCardEl, mergedMonthRelations);
+ return {
+ planetRelations,
+ elementRelations,
+ tetragrammatonRelations,
+ zodiacRelationsWithRulership,
+ mergedCourtDateRelations,
+ hebrewRelations,
+ cubeRelations,
+ iChingRelations,
+ mergedMonthRelations
+ };
+ }
+
+ function buildCompareDetails(card) {
+ if (!card) {
+ return [];
+ }
+
+ const detailRelations = collectDetailRelations(card);
+ const groups = [
+ { title: "Letter", relations: detailRelations.hebrewRelations },
+ { title: "Planet / Ruler", relations: detailRelations.planetRelations },
+ { title: "Sign / Decan", relations: detailRelations.zodiacRelationsWithRulership },
+ { title: "Element", relations: detailRelations.elementRelations },
+ { title: "Tetragrammaton", relations: detailRelations.tetragrammatonRelations },
+ { title: "Dates", relations: detailRelations.mergedCourtDateRelations },
+ { title: "Calendar", relations: detailRelations.mergedMonthRelations }
+ ];
+
+ return groups
+ .map((group) => ({
+ title: group.title,
+ items: [...new Set((group.relations || []).map((relation) => String(relation?.label || "").trim()).filter(Boolean))]
+ }))
+ .filter((group) => group.items.length);
+ }
+
+ function renderDetail(card, elements) {
+ if (!card || !elements) {
+ return;
+ }
+
+ const cardDisplayName = getDisplayCardName(card);
+ const imageUrl = typeof resolveTarotCardImage === "function"
+ ? resolveTarotCardImage(card.name)
+ : null;
+
+ if (elements.tarotDetailImageEl) {
+ if (imageUrl) {
+ elements.tarotDetailImageEl.src = imageUrl;
+ elements.tarotDetailImageEl.alt = cardDisplayName || card.name;
+ elements.tarotDetailImageEl.style.display = "block";
+ elements.tarotDetailImageEl.style.cursor = "zoom-in";
+ elements.tarotDetailImageEl.title = "Click to enlarge";
+ } else {
+ elements.tarotDetailImageEl.removeAttribute("src");
+ elements.tarotDetailImageEl.alt = "";
+ elements.tarotDetailImageEl.style.display = "none";
+ elements.tarotDetailImageEl.style.cursor = "default";
+ elements.tarotDetailImageEl.removeAttribute("title");
+ }
+ }
+
+ if (elements.tarotDetailNameEl) {
+ elements.tarotDetailNameEl.textContent = cardDisplayName || card.name;
+ }
+
+ if (elements.tarotDetailTypeEl) {
+ elements.tarotDetailTypeEl.textContent = buildTypeLabel(card);
+ }
+
+ if (elements.tarotDetailSummaryEl) {
+ elements.tarotDetailSummaryEl.textContent = card.summary || "--";
+ }
+
+ if (elements.tarotDetailUprightEl) {
+ elements.tarotDetailUprightEl.textContent = card.meanings?.upright || "--";
+ }
+
+ if (elements.tarotDetailReversedEl) {
+ elements.tarotDetailReversedEl.textContent = card.meanings?.reversed || "--";
+ }
+
+ const meaningText = String(card.meaning || card.meanings?.upright || "").trim();
+ if (elements.tarotMetaMeaningCardEl && elements.tarotDetailMeaningEl) {
+ if (meaningText) {
+ elements.tarotMetaMeaningCardEl.hidden = false;
+ elements.tarotDetailMeaningEl.textContent = meaningText;
+ } else {
+ elements.tarotMetaMeaningCardEl.hidden = true;
+ elements.tarotDetailMeaningEl.textContent = "--";
+ }
+ }
+
+ clearChildren(elements.tarotDetailKeywordsEl);
+ (card.keywords || []).forEach((keyword) => {
+ const chip = document.createElement("span");
+ chip.className = "tarot-keyword-chip";
+ chip.textContent = keyword;
+ elements.tarotDetailKeywordsEl?.appendChild(chip);
+ });
+
+ const detailRelations = collectDetailRelations(card);
+
+ renderStaticRelationGroup(elements.tarotDetailPlanetEl, elements.tarotMetaPlanetCardEl, detailRelations.planetRelations);
+ renderStaticRelationGroup(elements.tarotDetailElementEl, elements.tarotMetaElementCardEl, detailRelations.elementRelations);
+ renderStaticRelationGroup(elements.tarotDetailTetragrammatonEl, elements.tarotMetaTetragrammatonCardEl, detailRelations.tetragrammatonRelations);
+ renderStaticRelationGroup(elements.tarotDetailZodiacEl, elements.tarotMetaZodiacCardEl, detailRelations.zodiacRelationsWithRulership);
+ renderStaticRelationGroup(elements.tarotDetailCourtDateEl, elements.tarotMetaCourtDateCardEl, detailRelations.mergedCourtDateRelations);
+ renderStaticRelationGroup(elements.tarotDetailHebrewEl, elements.tarotMetaHebrewCardEl, detailRelations.hebrewRelations);
+ renderStaticRelationGroup(elements.tarotDetailCubeEl, elements.tarotMetaCubeCardEl, detailRelations.cubeRelations);
+ renderStaticRelationGroup(elements.tarotDetailIChingEl, elements.tarotMetaIChingCardEl, detailRelations.iChingRelations);
+ renderStaticRelationGroup(elements.tarotDetailCalendarEl, elements.tarotMetaCalendarCardEl, detailRelations.mergedMonthRelations);
const kabPathEl = elements.tarotKabPathEl;
if (kabPathEl) {
@@ -304,6 +344,7 @@
}
return {
+ buildCompareDetails,
renderStaticRelationGroup,
renderDetail
};
diff --git a/app/ui-tarot-house.js b/app/ui-tarot-house.js
index 681983e..3de41b8 100644
--- a/app/ui-tarot-house.js
+++ b/app/ui-tarot-house.js
@@ -20,6 +20,28 @@
[18, 17, 15, 14, 13, 9, 8, 7, 6, 5, 4],
[11]
];
+ const EXPORT_CARD_WIDTH = 128;
+ const EXPORT_CARD_HEIGHT = 192;
+ const EXPORT_CARD_GAP = 10;
+ const EXPORT_ROW_GAP = 12;
+ const EXPORT_SECTION_GAP = 18;
+ const EXPORT_PADDING = 28;
+ const EXPORT_BACKGROUND = "#151520";
+ const EXPORT_PANEL = "#18181b";
+ const EXPORT_BORDER = "#3f3f46";
+ const EXPORT_FALLBACK_TEXT = "#f4f4f5";
+ const EXPORT_FORMATS = {
+ png: {
+ mimeType: "image/png",
+ extension: "png",
+ quality: null
+ },
+ webp: {
+ mimeType: "image/webp",
+ extension: "webp",
+ quality: 0.98
+ }
+ };
const config = {
resolveTarotCardImage: null,
@@ -27,8 +49,14 @@
clearChildren: () => {},
normalizeTarotCardLookupName: (value) => String(value || "").trim().toLowerCase(),
selectCardById: () => {},
+ openCardLightbox: () => {},
+ isHouseFocusMode: () => false,
getCards: () => [],
- getSelectedCardId: () => ""
+ getSelectedCardId: () => "",
+ getHouseTopCardsVisible: () => true,
+ getHouseTopInfoModes: () => ({}),
+ getHouseBottomCardsVisible: () => true,
+ getHouseBottomInfoModes: () => ({})
};
function init(nextConfig = {}) {
@@ -81,6 +109,494 @@
return (Array.isArray(cards) ? cards : []).find((card) => card?.arcana === "Major" && Number(card?.number) === target) || null;
}
+ function normalizeLabelText(value) {
+ return String(value || "").replace(/\s+/g, " ").trim();
+ }
+
+ function getCardRelationsByType(card, type) {
+ if (!card || !Array.isArray(card.relations)) {
+ return [];
+ }
+ return card.relations.filter((relation) => relation?.type === type);
+ }
+
+ function getFirstCardRelationByType(card, type) {
+ return getCardRelationsByType(card, type)[0] || null;
+ }
+
+ function toRomanNumeral(value) {
+ const number = Number(value);
+ if (!Number.isFinite(number) || number <= 0) {
+ return "";
+ }
+
+ const numerals = [
+ [10, "X"],
+ [9, "IX"],
+ [5, "V"],
+ [4, "IV"],
+ [1, "I"]
+ ];
+
+ let remaining = number;
+ let result = "";
+ numerals.forEach(([amount, glyph]) => {
+ while (remaining >= amount) {
+ result += glyph;
+ remaining -= amount;
+ }
+ });
+ return result;
+ }
+
+ function buildHebrewLabel(card) {
+ const hebrew = card?.hebrewLetter && typeof card.hebrewLetter === "object"
+ ? card.hebrewLetter
+ : getFirstCardRelationByType(card, "hebrewLetter")?.data;
+
+ const glyph = normalizeLabelText(hebrew?.glyph || hebrew?.char);
+ const transliteration = normalizeLabelText(hebrew?.latin || hebrew?.name || card?.hebrewLetterId);
+ const primary = glyph || transliteration;
+ const secondary = glyph && transliteration ? transliteration : "";
+
+ if (!primary) {
+ return null;
+ }
+
+ return {
+ primary,
+ secondary,
+ className: "is-top-hebrew"
+ };
+ }
+
+ function buildPlanetLabel(card) {
+ const relation = getFirstCardRelationByType(card, "planetCorrespondence")
+ || getFirstCardRelationByType(card, "planet")
+ || getFirstCardRelationByType(card, "decanRuler");
+ const name = normalizeLabelText(relation?.data?.name || relation?.data?.planetId || relation?.id);
+ const symbol = normalizeLabelText(relation?.data?.symbol);
+ const primary = normalizeLabelText(symbol ? `${symbol} ${name}` : name);
+ if (!primary) {
+ return null;
+ }
+ return {
+ primary: relation?.type === "decanRuler" ? `Ruler: ${primary}` : `Planet: ${primary}`,
+ secondary: "",
+ className: ""
+ };
+ }
+
+ function buildMajorZodiacLabel(card) {
+ const relation = getFirstCardRelationByType(card, "zodiacCorrespondence")
+ || getFirstCardRelationByType(card, "zodiac");
+ const name = normalizeLabelText(relation?.data?.name || relation?.data?.signName || relation?.id);
+ const symbol = normalizeLabelText(relation?.data?.symbol);
+ const primary = normalizeLabelText(symbol ? `${symbol} ${name}` : name);
+ if (!primary) {
+ return null;
+ }
+ return {
+ primary: `Zodiac: ${primary}`,
+ secondary: "",
+ className: ""
+ };
+ }
+
+ function buildTrumpNumberLabel(card) {
+ const number = Number(card?.number);
+ if (!Number.isFinite(number)) {
+ return null;
+ }
+
+ const formattedTrumpNumber = number === 0
+ ? "0"
+ : toRomanNumeral(Math.trunc(number));
+
+ return {
+ primary: `Trump: ${formattedTrumpNumber}`,
+ secondary: "",
+ className: ""
+ };
+ }
+
+ function buildPathNumberLabel(card) {
+ const pathNumber = Number(card?.kabbalahPathNumber);
+ if (!Number.isFinite(pathNumber)) {
+ return null;
+ }
+ return {
+ primary: `Path: ${Math.trunc(pathNumber)}`,
+ secondary: "",
+ className: ""
+ };
+ }
+
+ function buildZodiacLabel(card) {
+ const zodiacRelation = getFirstCardRelationByType(card, "zodiac");
+ const decanRelations = getCardRelationsByType(card, "decan");
+ const primary = normalizeLabelText(
+ zodiacRelation?.data?.symbol
+ ? `${zodiacRelation.data.symbol} ${zodiacRelation.data.signName || zodiacRelation.data.name || ""}`
+ : zodiacRelation?.data?.signName || zodiacRelation?.data?.name
+ );
+
+ if (primary) {
+ const dateRange = normalizeLabelText(getFirstCardRelationByType(card, "courtDateWindow")?.data?.dateRange);
+ return {
+ primary,
+ secondary: dateRange || "",
+ className: ""
+ };
+ }
+
+ if (decanRelations.length > 0) {
+ const first = decanRelations[0]?.data || {};
+ const last = decanRelations[decanRelations.length - 1]?.data || {};
+ const firstName = normalizeLabelText(first.signName);
+ const lastName = normalizeLabelText(last.signName);
+ const rangeLabel = firstName && lastName
+ ? (firstName === lastName ? firstName : `${firstName} -> ${lastName}`)
+ : firstName || lastName;
+ const dateRange = normalizeLabelText(getFirstCardRelationByType(card, "courtDateWindow")?.data?.dateRange);
+ if (rangeLabel) {
+ return {
+ primary: rangeLabel,
+ secondary: dateRange || "",
+ className: ""
+ };
+ }
+ }
+
+ return null;
+ }
+
+ function buildDecanLabel(card) {
+ const decanRelations = getCardRelationsByType(card, "decan");
+ if (decanRelations.length === 0) {
+ return null;
+ }
+
+ if (decanRelations.length === 1) {
+ const data = decanRelations[0].data || {};
+ const hasDegrees = Number.isFinite(Number(data.startDegree)) && Number.isFinite(Number(data.endDegree));
+ const degreeLabel = hasDegrees ? `${data.startDegree}°-${data.endDegree}°` : "";
+ const signLabel = normalizeLabelText(data.signName);
+ const primary = degreeLabel || signLabel;
+ const secondary = degreeLabel && signLabel ? signLabel : normalizeLabelText(data.dateRange);
+ if (primary) {
+ return {
+ primary,
+ secondary,
+ className: ""
+ };
+ }
+ }
+
+ const first = decanRelations[0]?.data || {};
+ const last = decanRelations[decanRelations.length - 1]?.data || {};
+ const firstLabel = normalizeLabelText(first.signName) && Number.isFinite(Number(first.index))
+ ? `${first.signName} ${toRomanNumeral(first.index)}`
+ : normalizeLabelText(first.signName);
+ const lastLabel = normalizeLabelText(last.signName) && Number.isFinite(Number(last.index))
+ ? `${last.signName} ${toRomanNumeral(last.index)}`
+ : normalizeLabelText(last.signName);
+ const primary = firstLabel && lastLabel
+ ? (firstLabel === lastLabel ? firstLabel : `${firstLabel} -> ${lastLabel}`)
+ : firstLabel || lastLabel;
+ const secondary = normalizeLabelText(getFirstCardRelationByType(card, "courtDateWindow")?.data?.dateRange);
+
+ if (!primary) {
+ return null;
+ }
+
+ return {
+ primary,
+ secondary,
+ className: ""
+ };
+ }
+
+ function buildDateLabel(card) {
+ const courtWindow = getFirstCardRelationByType(card, "courtDateWindow")?.data || null;
+ const decan = getFirstCardRelationByType(card, "decan")?.data || null;
+ const calendar = getFirstCardRelationByType(card, "calendarMonth")?.data || null;
+
+ const primary = normalizeLabelText(courtWindow?.dateRange || decan?.dateRange || calendar?.dateRange || calendar?.name);
+ const secondary = normalizeLabelText(
+ calendar?.name && primary !== calendar.name
+ ? calendar.name
+ : decan?.signName
+ );
+
+ if (!primary) {
+ return null;
+ }
+
+ return {
+ primary,
+ secondary,
+ className: ""
+ };
+ }
+
+ function buildMonthLabel(card) {
+ const monthRelations = getCardRelationsByType(card, "calendarMonth");
+ const names = [];
+ const seen = new Set();
+
+ monthRelations.forEach((relation) => {
+ const name = normalizeLabelText(relation?.data?.name);
+ const key = name.toLowerCase();
+ if (!name || seen.has(key)) {
+ return;
+ }
+ seen.add(key);
+ names.push(name);
+ });
+
+ if (!names.length) {
+ return null;
+ }
+
+ return {
+ primary: `Month: ${names.join("/")}`,
+ secondary: "",
+ className: ""
+ };
+ }
+
+ function buildRulerLabel(card) {
+ const rulerRelations = getCardRelationsByType(card, "decanRuler");
+ const names = [];
+ const seen = new Set();
+
+ rulerRelations.forEach((relation) => {
+ const name = normalizeLabelText(
+ relation?.data?.symbol
+ ? `${relation.data.symbol} ${relation.data.name || relation.data.planetId || ""}`
+ : relation?.data?.name || relation?.data?.planetId
+ );
+ const key = name.toLowerCase();
+ if (!name || seen.has(key)) {
+ return;
+ }
+ seen.add(key);
+ names.push(name);
+ });
+
+ if (!names.length) {
+ return null;
+ }
+
+ return {
+ primary: `Ruler: ${names.join("/")}`,
+ secondary: "",
+ className: ""
+ };
+ }
+
+ function getTopInfoModeEnabled(mode) {
+ const modes = config.getHouseTopInfoModes?.();
+ return Boolean(modes && modes[mode]);
+ }
+
+ function buildTopInfoLabel(card) {
+ const lineSet = new Set();
+ const lines = [];
+
+ function pushLine(value) {
+ const text = normalizeLabelText(value);
+ const key = text.toLowerCase();
+ if (!text || lineSet.has(key)) {
+ return;
+ }
+ lineSet.add(key);
+ lines.push(text);
+ }
+
+ if (getTopInfoModeEnabled("hebrew")) {
+ const hebrew = buildHebrewLabel(card);
+ pushLine(hebrew?.primary);
+ pushLine(hebrew?.secondary);
+ }
+
+ if (getTopInfoModeEnabled("planet")) {
+ pushLine(buildPlanetLabel(card)?.primary);
+ }
+
+ if (getTopInfoModeEnabled("zodiac")) {
+ pushLine(buildMajorZodiacLabel(card)?.primary);
+ }
+
+ if (getTopInfoModeEnabled("trump")) {
+ pushLine(buildTrumpNumberLabel(card)?.primary);
+ }
+
+ if (getTopInfoModeEnabled("path")) {
+ pushLine(buildPathNumberLabel(card)?.primary);
+ }
+
+ if (!lines.length) {
+ return null;
+ }
+
+ const hasHebrew = getTopInfoModeEnabled("hebrew") && Boolean(buildHebrewLabel(card)?.primary);
+
+ return {
+ primary: lines[0],
+ secondary: lines.slice(1).join(" · "),
+ className: `${lines.length >= 3 ? "is-dense" : ""}${hasHebrew ? " is-top-hebrew" : ""}`.trim()
+ };
+ }
+
+ function getBottomInfoModeEnabled(mode) {
+ const modes = config.getHouseBottomInfoModes?.();
+ return Boolean(modes && modes[mode]);
+ }
+
+ function buildBottomInfoLabel(card) {
+ const lineSet = new Set();
+ const lines = [];
+
+ function pushLine(value) {
+ const text = normalizeLabelText(value);
+ const key = text.toLowerCase();
+ if (!text || lineSet.has(key)) {
+ return;
+ }
+ lineSet.add(key);
+ lines.push(text);
+ }
+
+ if (getBottomInfoModeEnabled("zodiac")) {
+ pushLine(buildZodiacLabel(card)?.primary);
+ }
+
+ if (getBottomInfoModeEnabled("decan")) {
+ const decanLabel = buildDecanLabel(card);
+ pushLine(decanLabel?.primary);
+ if (!getBottomInfoModeEnabled("date")) {
+ pushLine(decanLabel?.secondary);
+ }
+ }
+
+ if (getBottomInfoModeEnabled("month")) {
+ pushLine(buildMonthLabel(card)?.primary);
+ }
+
+ if (getBottomInfoModeEnabled("ruler")) {
+ pushLine(buildRulerLabel(card)?.primary);
+ }
+
+ if (getBottomInfoModeEnabled("date")) {
+ pushLine(buildDateLabel(card)?.primary);
+ }
+
+ if (lines.length === 0) {
+ return null;
+ }
+
+ return {
+ primary: lines[0],
+ secondary: lines.slice(1).join(" · "),
+ className: lines.length >= 3 ? "is-dense" : ""
+ };
+ }
+
+ function buildHouseCardLabel(card) {
+ if (!card) {
+ return null;
+ }
+
+ if (card.arcana === "Major") {
+ return buildTopInfoLabel(card);
+ }
+
+ return buildBottomInfoLabel(card);
+ }
+
+ function isHouseCardImageVisible(card) {
+ if (!card) {
+ return false;
+ }
+
+ if (card.arcana === "Major") {
+ return config.getHouseTopCardsVisible?.() !== false;
+ }
+
+ return config.getHouseBottomCardsVisible?.() !== false;
+ }
+
+ function buildHouseCardTextFaceModel(card, label) {
+ const displayName = normalizeLabelText(config.getDisplayCardName(card) || card?.name || "Tarot");
+
+ if (card?.arcana !== "Major" && label?.primary) {
+ return {
+ primary: displayName || "Tarot",
+ secondary: [label.primary, label.secondary].filter(Boolean).join(" · "),
+ className: label.className || ""
+ };
+ }
+
+ if (label?.primary) {
+ const fallbackSecondary = displayName && label.primary !== displayName ? displayName : "";
+ return {
+ primary: label.primary,
+ secondary: label.secondary || fallbackSecondary,
+ className: label.className || ""
+ };
+ }
+
+ return {
+ primary: displayName || "Tarot",
+ secondary: "",
+ className: ""
+ };
+ }
+
+ function createHouseCardLabelElement(label) {
+ if (!label?.primary) {
+ return null;
+ }
+
+ const labelEl = document.createElement("span");
+ labelEl.className = `tarot-house-card-label${label.className ? ` ${label.className}` : ""}`;
+
+ const primaryEl = document.createElement("span");
+ primaryEl.className = "tarot-house-card-label-primary";
+ primaryEl.textContent = label.primary;
+ labelEl.appendChild(primaryEl);
+
+ if (label.secondary) {
+ const secondaryEl = document.createElement("span");
+ secondaryEl.className = "tarot-house-card-label-secondary";
+ secondaryEl.textContent = label.secondary;
+ labelEl.appendChild(secondaryEl);
+ }
+
+ return labelEl;
+ }
+
+ function createHouseCardTextFaceElement(faceModel) {
+ const faceEl = document.createElement("span");
+ faceEl.className = `tarot-house-card-text-face${faceModel?.className ? ` ${faceModel.className}` : ""}`;
+
+ const primaryEl = document.createElement("span");
+ primaryEl.className = "tarot-house-card-text-primary";
+ primaryEl.textContent = faceModel?.primary || "Tarot";
+ faceEl.appendChild(primaryEl);
+
+ if (faceModel?.secondary) {
+ const secondaryEl = document.createElement("span");
+ secondaryEl.className = "tarot-house-card-text-secondary";
+ secondaryEl.textContent = faceModel.secondary;
+ faceEl.appendChild(secondaryEl);
+ }
+
+ return faceEl;
+ }
+
function createHouseCardButton(card, elements) {
const button = document.createElement("button");
button.type = "button";
@@ -96,28 +612,49 @@
}
const cardDisplayName = config.getDisplayCardName(card);
- button.title = cardDisplayName || card.name;
- button.setAttribute("aria-label", cardDisplayName || card.name);
+ const label = buildHouseCardLabel(card);
+ const showImage = isHouseCardImageVisible(card);
+ const labelText = label?.secondary
+ ? `${label.primary} - ${label.secondary}`
+ : label?.primary || "";
+ button.title = labelText ? `${cardDisplayName || card.name} - ${labelText}` : (cardDisplayName || card.name);
+ button.setAttribute("aria-label", labelText ? `${cardDisplayName || card.name}, ${labelText}` : (cardDisplayName || card.name));
button.dataset.houseCardId = card.id;
const imageUrl = typeof config.resolveTarotCardImage === "function"
? config.resolveTarotCardImage(card.name)
: null;
- if (imageUrl) {
+ if (showImage && imageUrl) {
const image = document.createElement("img");
image.className = "tarot-house-card-image";
image.src = imageUrl;
image.alt = cardDisplayName || card.name;
button.appendChild(image);
- } else {
+ } else if (showImage) {
const fallback = document.createElement("span");
fallback.className = "tarot-house-card-fallback";
fallback.textContent = cardDisplayName || card.name;
button.appendChild(fallback);
+ } else {
+ button.classList.add("is-text-only");
+ button.appendChild(createHouseCardTextFaceElement(buildHouseCardTextFaceModel(card, label)));
+ }
+
+ const labelEl = showImage ? createHouseCardLabelElement(label) : null;
+ if (labelEl) {
+ button.appendChild(labelEl);
}
button.addEventListener("click", () => {
config.selectCardById(card.id, elements);
+ if (config.isHouseFocusMode?.() === true && imageUrl) {
+ config.openCardLightbox?.(
+ imageUrl,
+ cardDisplayName || card.name || "Tarot card enlarged image",
+ { cardId: card.id }
+ );
+ return;
+ }
elements?.tarotCardListEl
?.querySelector(`[data-card-id="${card.id}"]`)
?.scrollIntoView({ block: "nearest" });
@@ -140,6 +677,380 @@
});
}
+ function loadCardImage(url) {
+ return new Promise((resolve) => {
+ if (!url) {
+ resolve(null);
+ return;
+ }
+
+ const image = new Image();
+ image.crossOrigin = "anonymous";
+ image.onload = () => resolve(image);
+ image.onerror = () => resolve(null);
+ image.src = url;
+ });
+ }
+
+ function buildHouseRows(cards, cardLookupMap) {
+ const trumpRows = HOUSE_TRUMP_ROWS.map((trumpNumbers) =>
+ (trumpNumbers || []).map((trumpNumber) => findMajorCardByTrumpNumber(cards, trumpNumber))
+ );
+
+ const leftRows = HOUSE_MINOR_NUMBER_BANDS.map((numbers, rowIndex) =>
+ numbers.map((rankNumber) => findCardByLookupName(cardLookupMap, buildMinorCardName(rankNumber, HOUSE_LEFT_SUITS[rowIndex])))
+ );
+
+ const middleRows = HOUSE_MIDDLE_RANKS.map((rank) =>
+ HOUSE_MIDDLE_SUITS.map((suit) => findCardByLookupName(cardLookupMap, buildCourtCardName(rank, suit)))
+ );
+
+ const rightRows = HOUSE_MINOR_NUMBER_BANDS.map((numbers, rowIndex) =>
+ numbers.map((rankNumber) => findCardByLookupName(cardLookupMap, buildMinorCardName(rankNumber, HOUSE_RIGHT_SUITS[rowIndex])))
+ );
+
+ return {
+ trumpRows,
+ leftRows,
+ middleRows,
+ rightRows
+ };
+ }
+
+ function drawRoundedRectPath(context, x, y, width, height, radius) {
+ const safeRadius = Math.min(radius, width / 2, height / 2);
+ context.beginPath();
+ context.moveTo(x + safeRadius, y);
+ context.arcTo(x + width, y, x + width, y + height, safeRadius);
+ context.arcTo(x + width, y + height, x, y + height, safeRadius);
+ context.arcTo(x, y + height, x, y, safeRadius);
+ context.arcTo(x, y, x + width, y, safeRadius);
+ context.closePath();
+ }
+
+ function fitCanvasLabelText(context, text, maxWidth) {
+ const normalized = normalizeLabelText(text);
+ if (!normalized || context.measureText(normalized).width <= maxWidth) {
+ return normalized;
+ }
+
+ let result = normalized;
+ while (result.length > 1 && context.measureText(`${result}...`).width > maxWidth) {
+ result = result.slice(0, -1).trimEnd();
+ }
+ return `${result}...`;
+ }
+
+ function wrapCanvasText(context, text, maxWidth, maxLines = 4) {
+ const normalized = normalizeLabelText(text);
+ if (!normalized) {
+ return [];
+ }
+
+ const words = normalized.split(/\s+/).filter(Boolean);
+ if (words.length <= 1) {
+ return [fitCanvasLabelText(context, normalized, maxWidth)];
+ }
+
+ const lines = [];
+ let current = "";
+ words.forEach((word) => {
+ const next = current ? `${current} ${word}` : word;
+ if (current && context.measureText(next).width > maxWidth) {
+ lines.push(current);
+ current = word;
+ } else {
+ current = next;
+ }
+ });
+ if (current) {
+ lines.push(current);
+ }
+
+ if (lines.length <= maxLines) {
+ return lines;
+ }
+
+ const clipped = lines.slice(0, Math.max(1, maxLines));
+ clipped[clipped.length - 1] = fitCanvasLabelText(context, `${clipped[clipped.length - 1]}...`, maxWidth);
+ return clipped;
+ }
+
+ function drawTextFaceToCanvas(context, x, y, width, height, faceModel) {
+ const primaryText = normalizeLabelText(faceModel?.primary || "Tarot");
+ const secondaryText = normalizeLabelText(faceModel?.secondary);
+ const maxWidth = width - 20;
+
+ context.save();
+ context.fillStyle = "#f4f4f5";
+ const primaryFontSize = faceModel?.className === "is-top-hebrew" && primaryText.length <= 3 ? 24 : 13;
+ const primaryFontFamily = faceModel?.className === "is-top-hebrew"
+ ? "'Segoe UI Symbol', 'Noto Sans Hebrew', 'Segoe UI', sans-serif"
+ : "'Segoe UI', sans-serif";
+ context.font = `700 ${primaryFontSize}px ${primaryFontFamily}`;
+ const primaryLines = wrapCanvasText(context, primaryText, maxWidth, secondaryText ? 3 : 4);
+
+ context.fillStyle = "rgba(250, 250, 250, 0.84)";
+ context.font = "500 9px 'Segoe UI', sans-serif";
+ const secondaryLines = secondaryText ? wrapCanvasText(context, secondaryText, maxWidth, 2) : [];
+
+ const primaryLineHeight = faceModel?.className === "is-top-hebrew" && primaryText.length <= 3 ? 24 : 16;
+ const secondaryLineHeight = 12;
+ const totalHeight = (primaryLines.length * primaryLineHeight)
+ + (secondaryLines.length ? 8 + (secondaryLines.length * secondaryLineHeight) : 0);
+ let currentY = y + ((height - totalHeight) / 2) + primaryLineHeight;
+
+ context.textAlign = "center";
+ context.textBaseline = "alphabetic";
+ context.fillStyle = "#f4f4f5";
+ context.font = `700 ${primaryFontSize}px ${primaryFontFamily}`;
+ primaryLines.forEach((line) => {
+ context.fillText(line, x + (width / 2), currentY, maxWidth);
+ currentY += primaryLineHeight;
+ });
+
+ if (secondaryLines.length) {
+ currentY += 4;
+ context.fillStyle = "rgba(250, 250, 250, 0.84)";
+ context.font = "500 9px 'Segoe UI', sans-serif";
+ secondaryLines.forEach((line) => {
+ context.fillText(line, x + (width / 2), currentY, maxWidth);
+ currentY += secondaryLineHeight;
+ });
+ }
+ context.restore();
+ }
+
+ function drawCardLabelToCanvas(context, x, y, width, height, label) {
+ if (!label?.primary) {
+ return;
+ }
+
+ const hasSecondary = Boolean(label.secondary);
+ const overlayHeight = hasSecondary ? 34 : 24;
+ const overlayX = x + 4;
+ const overlayY = y + height - overlayHeight - 4;
+ const overlayWidth = width - 8;
+ const gradient = context.createLinearGradient(overlayX, overlayY, overlayX, overlayY + overlayHeight);
+ gradient.addColorStop(0, "rgba(9, 9, 11, 0.18)");
+ gradient.addColorStop(1, "rgba(9, 9, 11, 0.9)");
+
+ context.save();
+ drawRoundedRectPath(context, overlayX, overlayY, overlayWidth, overlayHeight, 6);
+ context.fillStyle = gradient;
+ context.fill();
+ context.textAlign = "center";
+
+ const primaryFontSize = label.className === "is-top-hebrew" && label.primary.length <= 3 ? 14 : 11;
+ context.textBaseline = hasSecondary ? "alphabetic" : "middle";
+ context.fillStyle = "#fafafa";
+ context.font = `700 ${primaryFontSize}px 'Segoe UI Symbol', 'Segoe UI', sans-serif`;
+ const primaryText = fitCanvasLabelText(context, label.primary, overlayWidth - 10);
+ if (hasSecondary) {
+ context.fillText(primaryText, x + width / 2, overlayY + 14, overlayWidth - 10);
+ context.fillStyle = "rgba(250, 250, 250, 0.84)";
+ context.font = "500 9px 'Segoe UI', sans-serif";
+ const secondaryText = fitCanvasLabelText(context, label.secondary, overlayWidth - 10);
+ context.fillText(secondaryText, x + width / 2, overlayY + overlayHeight - 8, overlayWidth - 10);
+ } else {
+ context.fillText(primaryText, x + width / 2, overlayY + (overlayHeight / 2), overlayWidth - 10);
+ }
+ context.restore();
+ }
+
+ function drawCardToCanvas(context, x, y, width, height, card, image) {
+ const label = buildHouseCardLabel(card);
+ const showImage = isHouseCardImageVisible(card);
+ drawRoundedRectPath(context, x, y, width, height, 8);
+ context.fillStyle = EXPORT_PANEL;
+ context.fill();
+
+ if (showImage && image) {
+ context.save();
+ drawRoundedRectPath(context, x, y, width, height, 8);
+ context.clip();
+ context.drawImage(image, x, y, width, height);
+ context.restore();
+ } else if (showImage) {
+ context.fillStyle = "#09090b";
+ context.fillRect(x, y, width, height);
+ context.fillStyle = EXPORT_FALLBACK_TEXT;
+ context.font = "600 12px 'Segoe UI', sans-serif";
+ context.textAlign = "center";
+ context.textBaseline = "middle";
+
+ const label = card ? (config.getDisplayCardName(card) || card.name || "Tarot") : "Missing";
+ const words = String(label).split(/\s+/).filter(Boolean);
+ const lines = [];
+ let current = "";
+ words.forEach((word) => {
+ const next = current ? `${current} ${word}` : word;
+ if (next.length > 14 && current) {
+ lines.push(current);
+ current = word;
+ } else {
+ current = next;
+ }
+ });
+ if (current) {
+ lines.push(current);
+ }
+
+ const lineHeight = 16;
+ const startY = y + (height / 2) - ((lines.length - 1) * lineHeight / 2);
+ lines.slice(0, 4).forEach((line, index) => {
+ context.fillText(line, x + width / 2, startY + (index * lineHeight), width - 16);
+ });
+ } else {
+ drawTextFaceToCanvas(context, x, y, width, height, buildHouseCardTextFaceModel(card, label));
+ }
+
+ if (showImage) {
+ drawCardLabelToCanvas(context, x, y, width, height, label);
+ }
+
+ drawRoundedRectPath(context, x, y, width, height, 8);
+ context.lineWidth = 2;
+ context.strokeStyle = EXPORT_BORDER;
+ context.stroke();
+ }
+
+ function canvasToBlob(canvas) {
+ return new Promise((resolve, reject) => {
+ canvas.toBlob((blob) => {
+ if (blob) {
+ resolve(blob);
+ return;
+ }
+ reject(new Error("Canvas export failed."));
+ }, "image/png");
+ });
+ }
+
+ function isExportFormatSupported(format) {
+ const exportFormat = EXPORT_FORMATS[format];
+ if (!exportFormat) {
+ return false;
+ }
+
+ if (format === "png") {
+ return true;
+ }
+
+ const probeCanvas = document.createElement("canvas");
+ const dataUrl = probeCanvas.toDataURL(exportFormat.mimeType);
+ return dataUrl.startsWith(`data:${exportFormat.mimeType}`);
+ }
+
+ function canvasToBlobByFormat(canvas, format) {
+ const exportFormat = EXPORT_FORMATS[format] || EXPORT_FORMATS.png;
+
+ return new Promise((resolve, reject) => {
+ canvas.toBlob((blob) => {
+ if (blob) {
+ resolve(blob);
+ return;
+ }
+ reject(new Error("Canvas export failed."));
+ }, exportFormat.mimeType, exportFormat.quality);
+ });
+ }
+
+ async function exportImage(format = "png") {
+ const exportFormat = EXPORT_FORMATS[format] || EXPORT_FORMATS.png;
+ const cards = config.getCards();
+ const cardLookupMap = getCardLookupMap(cards);
+ const houseRows = buildHouseRows(cards, cardLookupMap);
+
+ const majorRowWidth = (11 * EXPORT_CARD_WIDTH) + (10 * EXPORT_CARD_GAP);
+ const leftColumnWidth = (3 * EXPORT_CARD_WIDTH) + (2 * EXPORT_CARD_GAP);
+ const middleColumnWidth = (4 * EXPORT_CARD_WIDTH) + (3 * EXPORT_CARD_GAP);
+ const rightColumnWidth = leftColumnWidth;
+ const usedBottomWidth = leftColumnWidth + middleColumnWidth + rightColumnWidth;
+ const betweenColumnGap = Math.max(0, (majorRowWidth - usedBottomWidth) / 2);
+ const contentWidth = majorRowWidth;
+ const trumpHeight = (houseRows.trumpRows.length * EXPORT_CARD_HEIGHT) + ((houseRows.trumpRows.length - 1) * EXPORT_ROW_GAP);
+ const bottomHeight = (houseRows.leftRows.length * EXPORT_CARD_HEIGHT) + ((houseRows.leftRows.length - 1) * EXPORT_ROW_GAP);
+ const contentHeight = trumpHeight + EXPORT_SECTION_GAP + bottomHeight;
+
+ const scale = Math.max(2, Math.min(3, Number(window.devicePixelRatio) || 1));
+ const canvas = document.createElement("canvas");
+ canvas.width = Math.ceil((contentWidth + (EXPORT_PADDING * 2)) * scale);
+ canvas.height = Math.ceil((contentHeight + (EXPORT_PADDING * 2)) * scale);
+ canvas.style.width = `${contentWidth + (EXPORT_PADDING * 2)}px`;
+ canvas.style.height = `${contentHeight + (EXPORT_PADDING * 2)}px`;
+
+ const context = canvas.getContext("2d");
+ if (!context) {
+ throw new Error("Canvas context is unavailable.");
+ }
+
+ context.scale(scale, scale);
+ context.imageSmoothingEnabled = true;
+ context.imageSmoothingQuality = "high";
+ context.fillStyle = EXPORT_BACKGROUND;
+ context.fillRect(0, 0, contentWidth + (EXPORT_PADDING * 2), contentHeight + (EXPORT_PADDING * 2));
+
+ const imageCache = new Map();
+ const imageUrlByCardId = new Map();
+ cards.forEach((card) => {
+ const url = typeof config.resolveTarotCardImage === "function"
+ ? config.resolveTarotCardImage(card.name)
+ : null;
+ imageUrlByCardId.set(card.id, url || "");
+ if (url && !imageCache.has(url)) {
+ imageCache.set(url, loadCardImage(url));
+ }
+ });
+
+ const resolvedImageByCardId = new Map();
+ await Promise.all(cards.map(async (card) => {
+ const url = imageUrlByCardId.get(card.id);
+ const image = url ? await imageCache.get(url) : null;
+ resolvedImageByCardId.set(card.id, image || null);
+ }));
+
+ let currentY = EXPORT_PADDING;
+ houseRows.trumpRows.forEach((row) => {
+ const rowWidth = (row.length * EXPORT_CARD_WIDTH) + ((Math.max(0, row.length - 1)) * EXPORT_CARD_GAP);
+ let currentX = EXPORT_PADDING + ((contentWidth - rowWidth) / 2);
+ row.forEach((card) => {
+ drawCardToCanvas(context, currentX, currentY, EXPORT_CARD_WIDTH, EXPORT_CARD_HEIGHT, card, card ? resolvedImageByCardId.get(card.id) : null);
+ currentX += EXPORT_CARD_WIDTH + EXPORT_CARD_GAP;
+ });
+ currentY += EXPORT_CARD_HEIGHT + EXPORT_ROW_GAP;
+ });
+
+ currentY = EXPORT_PADDING + trumpHeight + EXPORT_SECTION_GAP;
+ const columnXs = [
+ EXPORT_PADDING,
+ EXPORT_PADDING + leftColumnWidth + betweenColumnGap,
+ EXPORT_PADDING + leftColumnWidth + betweenColumnGap + middleColumnWidth + betweenColumnGap
+ ];
+ [houseRows.leftRows, houseRows.middleRows, houseRows.rightRows].forEach((columnRows, columnIndex) => {
+ let columnY = currentY;
+ columnRows.forEach((row) => {
+ let currentX = columnXs[columnIndex];
+ row.forEach((card) => {
+ drawCardToCanvas(context, currentX, columnY, EXPORT_CARD_WIDTH, EXPORT_CARD_HEIGHT, card, card ? resolvedImageByCardId.get(card.id) : null);
+ currentX += EXPORT_CARD_WIDTH + EXPORT_CARD_GAP;
+ });
+ columnY += EXPORT_CARD_HEIGHT + EXPORT_ROW_GAP;
+ });
+ });
+
+ const blob = await canvasToBlobByFormat(canvas, format);
+ const blobUrl = URL.createObjectURL(blob);
+ const downloadLink = document.createElement("a");
+ const stamp = new Date().toISOString().slice(0, 10);
+ downloadLink.href = blobUrl;
+ downloadLink.download = `tarot-house-of-cards-${stamp}.${exportFormat.extension}`;
+ document.body.appendChild(downloadLink);
+ downloadLink.click();
+ downloadLink.remove();
+ setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
+ }
+
function appendHouseMinorRow(columnEl, cardLookupMap, numbers, suit, elements) {
const rowEl = document.createElement("div");
rowEl.className = "tarot-house-row";
@@ -222,6 +1133,8 @@
window.TarotHouseUi = {
init,
render,
- updateSelection
+ updateSelection,
+ exportImage,
+ isExportFormatSupported
};
})();
\ No newline at end of file
diff --git a/app/ui-tarot-lightbox.js b/app/ui-tarot-lightbox.js
index 54855b9..98a19c2 100644
--- a/app/ui-tarot-lightbox.js
+++ b/app/ui-tarot-lightbox.js
@@ -2,20 +2,605 @@
"use strict";
let overlayEl = null;
+ let backdropEl = null;
+ let toolbarEl = null;
+ let helpButtonEl = null;
+ let helpPanelEl = null;
+ let compareButtonEl = null;
+ let zoomControlEl = null;
+ let zoomSliderEl = null;
+ let zoomValueEl = null;
+ let opacityControlEl = null;
+ let opacitySliderEl = null;
+ let opacityValueEl = null;
+ let stageEl = null;
+ let frameEl = null;
+ let baseLayerEl = null;
+ let overlayLayerEl = null;
let imageEl = null;
+ let overlayImageEl = null;
+ let primaryInfoEl = null;
+ let primaryTitleEl = null;
+ let primaryGroupsEl = null;
+ let primaryHintEl = null;
+ let secondaryInfoEl = null;
+ let secondaryTitleEl = null;
+ let secondaryGroupsEl = null;
+ let secondaryHintEl = null;
let zoomed = false;
+ let previousFocusedEl = null;
const LIGHTBOX_ZOOM_SCALE = 6.66;
+ const LIGHTBOX_ZOOM_STEP = 0.1;
+ const LIGHTBOX_PAN_STEP = 4;
+ const LIGHTBOX_COMPARE_DEFAULT_OVERLAY_OPACITY = 0.5;
+ const LIGHTBOX_COMPARE_SEQUENCE_STEP_KEYS = new Set(["ArrowLeft", "ArrowRight"]);
- function resetZoom() {
+ const lightboxState = {
+ isOpen: false,
+ compareMode: false,
+ allowOverlayCompare: false,
+ primaryCard: null,
+ secondaryCard: null,
+ sequenceIds: [],
+ resolveCardById: null,
+ onSelectCardId: null,
+ overlayOpacity: LIGHTBOX_COMPARE_DEFAULT_OVERLAY_OPACITY,
+ zoomScale: LIGHTBOX_ZOOM_SCALE,
+ helpOpen: false,
+ primaryRotated: false,
+ overlayRotated: false,
+ zoomOriginX: 50,
+ zoomOriginY: 50
+ };
+
+ function hasSecondaryCard() {
+ return Boolean(lightboxState.secondaryCard?.src);
+ }
+
+ function clampOverlayOpacity(value) {
+ const numericValue = Number(value);
+ if (!Number.isFinite(numericValue)) {
+ return LIGHTBOX_COMPARE_DEFAULT_OVERLAY_OPACITY;
+ }
+
+ return Math.min(1, Math.max(0.05, numericValue));
+ }
+
+ function clampZoomScale(value) {
+ const numericValue = Number(value);
+ if (!Number.isFinite(numericValue)) {
+ return 1;
+ }
+
+ return Math.min(LIGHTBOX_ZOOM_SCALE, Math.max(1, numericValue));
+ }
+
+ function normalizeCompareDetails(compareDetails) {
+ if (!Array.isArray(compareDetails)) {
+ return [];
+ }
+
+ return compareDetails
+ .map((group) => ({
+ title: String(group?.title || "").trim(),
+ items: Array.isArray(group?.items)
+ ? [...new Set(group.items.map((item) => String(item || "").trim()).filter(Boolean))]
+ : []
+ }))
+ .filter((group) => group.title && group.items.length);
+ }
+
+ function normalizeOpenRequest(srcOrOptions, altText, extraOptions) {
+ if (srcOrOptions && typeof srcOrOptions === "object" && !Array.isArray(srcOrOptions)) {
+ return {
+ ...srcOrOptions
+ };
+ }
+
+ return {
+ ...(extraOptions || {}),
+ src: srcOrOptions,
+ altText
+ };
+ }
+
+ function normalizeCardRequest(request) {
+ const normalized = normalizeOpenRequest(request);
+ const label = String(normalized.label || normalized.altText || "Tarot card enlarged image").trim() || "Tarot card enlarged image";
+ return {
+ src: String(normalized.src || "").trim(),
+ altText: String(normalized.altText || label).trim() || label,
+ label,
+ cardId: String(normalized.cardId || "").trim(),
+ compareDetails: normalizeCompareDetails(normalized.compareDetails)
+ };
+ }
+
+ function resolveCardRequestById(cardId) {
+ if (!cardId || typeof lightboxState.resolveCardById !== "function") {
+ return null;
+ }
+
+ const resolved = lightboxState.resolveCardById(cardId);
+ if (!resolved) {
+ return null;
+ }
+
+ return normalizeCardRequest({
+ ...resolved,
+ cardId
+ });
+ }
+
+ function clearSecondaryCard() {
+ lightboxState.secondaryCard = null;
+ if (overlayImageEl) {
+ overlayImageEl.removeAttribute("src");
+ overlayImageEl.alt = "";
+ overlayImageEl.style.display = "none";
+ }
+
+ syncComparePanels();
+ syncOpacityControl();
+ }
+
+ function setOverlayOpacity(value) {
+ const opacity = clampOverlayOpacity(value);
+ lightboxState.overlayOpacity = opacity;
+
+ if (overlayImageEl) {
+ overlayImageEl.style.opacity = String(opacity);
+ }
+
+ if (opacitySliderEl) {
+ opacitySliderEl.value = String(Math.round(opacity * 100));
+ opacitySliderEl.disabled = !lightboxState.compareMode || !hasSecondaryCard();
+ }
+
+ if (opacityValueEl) {
+ opacityValueEl.textContent = `${Math.round(opacity * 100)}%`;
+ }
+ }
+
+ function updateImageCursor() {
if (!imageEl) {
return;
}
+ imageEl.style.cursor = zoomed ? "zoom-out" : "zoom-in";
+ }
+
+ function buildRotationTransform(rotated) {
+ return rotated ? "rotate(180deg)" : "rotate(0deg)";
+ }
+
+ function isPrimaryRotationActive() {
+ return !lightboxState.compareMode && lightboxState.primaryRotated;
+ }
+
+ function isOverlayRotationActive() {
+ return lightboxState.compareMode && hasSecondaryCard() && lightboxState.overlayRotated;
+ }
+
+ function applyTransformOrigins(originX = lightboxState.zoomOriginX, originY = lightboxState.zoomOriginY) {
+ const nextOrigin = `${originX}% ${originY}%`;
+
+ if (baseLayerEl) {
+ baseLayerEl.style.transformOrigin = nextOrigin;
+ }
+
+ if (overlayLayerEl) {
+ overlayLayerEl.style.transformOrigin = nextOrigin;
+ }
+ }
+
+ function applyZoomTransform() {
+ const activeZoomScale = zoomed ? lightboxState.zoomScale : 1;
+ const showPrimaryRotation = isPrimaryRotationActive();
+ const showOverlayRotation = isOverlayRotationActive();
+
+ if (baseLayerEl) {
+ baseLayerEl.style.transform = `scale(${activeZoomScale})`;
+ }
+
+ if (overlayLayerEl) {
+ overlayLayerEl.style.transform = `scale(${activeZoomScale})`;
+ }
+
+ if (imageEl) {
+ imageEl.style.transform = buildRotationTransform(showPrimaryRotation);
+ }
+
+ if (overlayImageEl) {
+ overlayImageEl.style.transform = buildRotationTransform(showOverlayRotation);
+ }
+
+ applyTransformOrigins();
+
+ updateImageCursor();
+ }
+
+ function setZoomScale(value) {
+ const zoomScale = clampZoomScale(value);
+ lightboxState.zoomScale = zoomScale;
+
+ if (zoomSliderEl) {
+ zoomSliderEl.value = String(Math.round(zoomScale * 100));
+ }
+
+ if (zoomValueEl) {
+ zoomValueEl.textContent = `${Math.round(zoomScale * 100)}%`;
+ }
+
+ if (lightboxState.isOpen) {
+ applyComparePresentation();
+ return;
+ }
+
+ applyZoomTransform();
+ }
+
+ function isZoomInKey(event) {
+ return event.key === "+"
+ || event.key === "="
+ || event.code === "NumpadAdd";
+ }
+
+ function isZoomOutKey(event) {
+ return event.key === "-"
+ || event.key === "_"
+ || event.code === "NumpadSubtract";
+ }
+
+ function isRotateKey(event) {
+ return event.code === "KeyR" || String(event.key || "").toLowerCase() === "r";
+ }
+
+ function stepZoom(direction) {
+ if (!lightboxState.isOpen) {
+ return;
+ }
+
+ const activeScale = zoomed ? lightboxState.zoomScale : 1;
+ const nextScale = clampZoomScale(activeScale + (direction * LIGHTBOX_ZOOM_STEP));
+ zoomed = nextScale > 1;
+ setZoomScale(nextScale);
+
+ if (!zoomed && imageEl) {
+ lightboxState.zoomOriginX = 50;
+ lightboxState.zoomOriginY = 50;
+ }
+
+ if (!zoomed && overlayImageEl) {
+ lightboxState.zoomOriginX = 50;
+ lightboxState.zoomOriginY = 50;
+ }
+ }
+
+ function isPanUpKey(event) {
+ return event.code === "KeyW" || String(event.key || "").toLowerCase() === "w";
+ }
+
+ function isPanLeftKey(event) {
+ return event.code === "KeyA" || String(event.key || "").toLowerCase() === "a";
+ }
+
+ function isPanDownKey(event) {
+ return event.code === "KeyS" || String(event.key || "").toLowerCase() === "s";
+ }
+
+ function isPanRightKey(event) {
+ return event.code === "KeyD" || String(event.key || "").toLowerCase() === "d";
+ }
+
+ function stepPan(deltaX, deltaY) {
+ if (!lightboxState.isOpen || !zoomed || !imageEl) {
+ return;
+ }
+
+ lightboxState.zoomOriginX = Math.min(100, Math.max(0, lightboxState.zoomOriginX + deltaX));
+ lightboxState.zoomOriginY = Math.min(100, Math.max(0, lightboxState.zoomOriginY + deltaY));
+ applyTransformOrigins();
+ }
+
+ function toggleRotation() {
+ if (!lightboxState.isOpen) {
+ return;
+ }
+
+ if (lightboxState.compareMode && hasSecondaryCard()) {
+ lightboxState.overlayRotated = !lightboxState.overlayRotated;
+ } else {
+ lightboxState.primaryRotated = !lightboxState.primaryRotated;
+ }
+
+ applyZoomTransform();
+ }
+
+ function createCompareGroupElement(group) {
+ const sectionEl = document.createElement("section");
+ sectionEl.style.display = "flex";
+ sectionEl.style.flexDirection = "column";
+ sectionEl.style.gap = "5px";
+ sectionEl.style.paddingTop = "8px";
+ sectionEl.style.borderTop = "1px solid rgba(148, 163, 184, 0.14)";
+
+ const titleEl = document.createElement("div");
+ titleEl.textContent = group.title;
+ titleEl.style.font = "600 10px/1.2 sans-serif";
+ titleEl.style.letterSpacing = "0.1em";
+ titleEl.style.textTransform = "uppercase";
+ titleEl.style.color = "rgba(148, 163, 184, 0.92)";
+
+ const valuesEl = document.createElement("div");
+ valuesEl.style.display = "flex";
+ valuesEl.style.flexDirection = "column";
+ valuesEl.style.gap = "3px";
+
+ group.items.forEach((item) => {
+ const itemEl = document.createElement("div");
+ itemEl.textContent = item;
+ itemEl.style.font = "500 12px/1.35 sans-serif";
+ itemEl.style.color = "#f8fafc";
+ valuesEl.appendChild(itemEl);
+ });
+
+ sectionEl.append(titleEl, valuesEl);
+ return sectionEl;
+ }
+
+ function renderComparePanel(panelEl, titleEl, groupsEl, hintEl, cardRequest, roleLabel, hintText, isVisible) {
+ if (!panelEl || !titleEl || !groupsEl || !hintEl) {
+ return;
+ }
+
+ if (!isVisible || !cardRequest?.label) {
+ panelEl.style.display = "none";
+ titleEl.textContent = "";
+ hintEl.textContent = "";
+ groupsEl.replaceChildren();
+ return;
+ }
+
+ panelEl.style.display = "flex";
+ titleEl.textContent = `${roleLabel}: ${cardRequest.label}`;
+ groupsEl.replaceChildren();
+
+ if (Array.isArray(cardRequest.compareDetails) && cardRequest.compareDetails.length) {
+ cardRequest.compareDetails.forEach((group) => {
+ groupsEl.appendChild(createCompareGroupElement(group));
+ });
+ } else {
+ const emptyEl = document.createElement("div");
+ emptyEl.textContent = "No compare metadata available.";
+ emptyEl.style.font = "500 12px/1.35 sans-serif";
+ emptyEl.style.color = "rgba(226, 232, 240, 0.8)";
+ groupsEl.appendChild(emptyEl);
+ }
+
+ hintEl.textContent = hintText;
+ hintEl.style.display = hintText ? "block" : "none";
+ }
+
+ function syncComparePanels() {
+ const isComparing = lightboxState.compareMode;
+ const overlaySelected = hasSecondaryCard();
+ const showPrimaryPanel = Boolean(lightboxState.isOpen && lightboxState.allowOverlayCompare && lightboxState.primaryCard?.label && !zoomed);
+ const showSecondaryPanel = Boolean(isComparing && overlaySelected && lightboxState.secondaryCard?.label && !zoomed);
+
+ renderComparePanel(
+ primaryInfoEl,
+ primaryTitleEl,
+ primaryGroupsEl,
+ primaryHintEl,
+ lightboxState.primaryCard,
+ "Base",
+ isComparing
+ ? (overlaySelected
+ ? "Use Left and Right arrows to move through the overlaid card."
+ : "Click another House card behind the dock, or use Left and Right arrows to pick the first overlay card.")
+ : "Use Left and Right arrows to move through cards. Click Overlay to compare.",
+ showPrimaryPanel
+ );
+
+ renderComparePanel(
+ secondaryInfoEl,
+ secondaryTitleEl,
+ secondaryGroupsEl,
+ secondaryHintEl,
+ lightboxState.secondaryCard,
+ "Overlay",
+ overlaySelected ? "Use Left and Right arrows to swap the overlay card." : "",
+ showSecondaryPanel
+ );
+ }
+
+ function syncOpacityControl() {
+ if (!opacityControlEl) {
+ return;
+ }
+
+ opacityControlEl.style.display = lightboxState.compareMode && hasSecondaryCard() && !zoomed ? "flex" : "none";
+ setOverlayOpacity(lightboxState.overlayOpacity);
+ }
+
+ function syncHelpUi() {
+ if (!helpButtonEl || !helpPanelEl) {
+ return;
+ }
+
+ const canShow = lightboxState.isOpen && !zoomed;
+ helpButtonEl.style.display = canShow ? "inline-flex" : "none";
+ helpPanelEl.style.display = canShow && lightboxState.helpOpen ? "flex" : "none";
+ helpButtonEl.textContent = lightboxState.helpOpen ? "Hide Help" : "Help";
+ }
+
+ function syncZoomControl() {
+ if (!zoomControlEl) {
+ return;
+ }
+
+ zoomControlEl.style.display = lightboxState.isOpen && !zoomed ? "flex" : "none";
+ if (zoomSliderEl) {
+ zoomSliderEl.value = String(Math.round(lightboxState.zoomScale * 100));
+ }
+
+ if (zoomValueEl) {
+ zoomValueEl.textContent = `${Math.round(lightboxState.zoomScale * 100)}%`;
+ }
+ }
+
+ function applyComparePresentation() {
+ if (!overlayEl || !backdropEl || !toolbarEl || !stageEl || !frameEl || !imageEl || !overlayImageEl || !compareButtonEl) {
+ return;
+ }
+
+ compareButtonEl.hidden = zoomed || !lightboxState.allowOverlayCompare || (lightboxState.compareMode && !hasSecondaryCard());
+ compareButtonEl.textContent = lightboxState.compareMode ? "Done Overlay" : "Overlay";
+ syncHelpUi();
+ syncZoomControl();
+ syncOpacityControl();
+
+ if (!lightboxState.compareMode) {
+ overlayEl.style.pointerEvents = "none";
+ backdropEl.style.display = "block";
+ backdropEl.style.pointerEvents = "auto";
+ backdropEl.style.background = "rgba(0, 0, 0, 0.82)";
+ toolbarEl.style.top = "24px";
+ toolbarEl.style.right = "24px";
+ toolbarEl.style.left = "auto";
+ stageEl.style.top = "0";
+ stageEl.style.right = "0";
+ stageEl.style.bottom = "0";
+ stageEl.style.left = "0";
+ stageEl.style.width = "auto";
+ stageEl.style.height = "auto";
+ stageEl.style.transform = "none";
+ stageEl.style.pointerEvents = "auto";
+ frameEl.style.position = "relative";
+ frameEl.style.width = "100%";
+ frameEl.style.height = "100%";
+ frameEl.style.maxWidth = "none";
+ frameEl.style.maxHeight = "none";
+ frameEl.style.borderRadius = "0";
+ frameEl.style.background = "transparent";
+ frameEl.style.boxShadow = "none";
+ frameEl.style.overflow = "hidden";
+ primaryInfoEl.style.left = "auto";
+ primaryInfoEl.style.right = "18px";
+ primaryInfoEl.style.top = "50%";
+ primaryInfoEl.style.bottom = "auto";
+ primaryInfoEl.style.width = "clamp(220px, 20vw, 320px)";
+ primaryInfoEl.style.transform = "translateY(-50%)";
+ imageEl.style.width = "100%";
+ imageEl.style.height = "100%";
+ imageEl.style.maxWidth = "none";
+ imageEl.style.maxHeight = "none";
+ imageEl.style.objectFit = "contain";
+ overlayImageEl.style.display = "none";
+ secondaryInfoEl.style.display = "none";
+ syncComparePanels();
+ applyZoomTransform();
+ return;
+ }
+
+ overlayEl.style.pointerEvents = "none";
+ backdropEl.style.display = "none";
+ backdropEl.style.pointerEvents = "none";
+ toolbarEl.style.top = "18px";
+ toolbarEl.style.right = "18px";
+ toolbarEl.style.left = "auto";
+ stageEl.style.pointerEvents = "auto";
+ frameEl.style.position = "relative";
+ frameEl.style.width = "100%";
+ frameEl.style.height = "100%";
+ frameEl.style.maxWidth = "none";
+ frameEl.style.maxHeight = "none";
+ frameEl.style.overflow = "hidden";
+ imageEl.style.width = "100%";
+ imageEl.style.height = "100%";
+ imageEl.style.maxWidth = "none";
+ imageEl.style.maxHeight = "none";
+ imageEl.style.objectFit = "contain";
+ updateImageCursor();
+
+ if (zoomed && hasSecondaryCard()) {
+ stageEl.style.top = "0";
+ stageEl.style.right = "0";
+ stageEl.style.bottom = "0";
+ stageEl.style.left = "0";
+ stageEl.style.width = "auto";
+ stageEl.style.height = "auto";
+ stageEl.style.transform = "none";
+ frameEl.style.borderRadius = "0";
+ frameEl.style.background = "transparent";
+ frameEl.style.boxShadow = "none";
+ } else if (!hasSecondaryCard()) {
+ stageEl.style.top = "auto";
+ stageEl.style.right = "18px";
+ stageEl.style.bottom = "18px";
+ stageEl.style.left = "auto";
+ stageEl.style.width = "clamp(180px, 18vw, 280px)";
+ stageEl.style.height = "min(44vh, 520px)";
+ stageEl.style.transform = "none";
+ frameEl.style.borderRadius = "22px";
+ frameEl.style.background = "rgba(13, 13, 20, 0.9)";
+ frameEl.style.boxShadow = "0 24px 64px rgba(0, 0, 0, 0.5)";
+ primaryInfoEl.style.left = "auto";
+ primaryInfoEl.style.right = "calc(100% + 16px)";
+ primaryInfoEl.style.top = "50%";
+ primaryInfoEl.style.bottom = "auto";
+ primaryInfoEl.style.width = "clamp(220px, 22vw, 320px)";
+ primaryInfoEl.style.transform = "translateY(-50%)";
+ } else {
+ stageEl.style.top = "50%";
+ stageEl.style.right = "auto";
+ stageEl.style.bottom = "auto";
+ stageEl.style.left = "50%";
+ stageEl.style.width = "min(44vw, 560px)";
+ stageEl.style.height = "min(92vh, 1400px)";
+ stageEl.style.transform = "translate(-50%, -50%)";
+ frameEl.style.borderRadius = "28px";
+ frameEl.style.background = "rgba(11, 15, 26, 0.88)";
+ frameEl.style.boxShadow = "0 30px 90px rgba(0, 0, 0, 0.56)";
+ primaryInfoEl.style.left = "auto";
+ primaryInfoEl.style.right = "calc(100% + 10px)";
+ primaryInfoEl.style.top = "50%";
+ primaryInfoEl.style.bottom = "auto";
+ primaryInfoEl.style.width = "clamp(180px, 15vw, 220px)";
+ primaryInfoEl.style.transform = "translateY(-50%)";
+ secondaryInfoEl.style.left = "calc(100% + 10px)";
+ secondaryInfoEl.style.right = "auto";
+ secondaryInfoEl.style.top = "50%";
+ secondaryInfoEl.style.bottom = "auto";
+ secondaryInfoEl.style.width = "clamp(180px, 15vw, 220px)";
+ secondaryInfoEl.style.transform = "translateY(-50%)";
+ }
+
+ if (hasSecondaryCard()) {
+ overlayImageEl.style.display = "block";
+ } else {
+ overlayImageEl.style.display = "none";
+ secondaryInfoEl.style.display = "none";
+ }
+
+ syncComparePanels();
+ applyZoomTransform();
+ setOverlayOpacity(lightboxState.overlayOpacity);
+ }
+
+ function resetZoom() {
+ if (!imageEl && !overlayImageEl) {
+ return;
+ }
+
+ lightboxState.zoomOriginX = 50;
+ lightboxState.zoomOriginY = 50;
+ applyTransformOrigins();
+
zoomed = false;
- imageEl.style.transform = "scale(1)";
- imageEl.style.transformOrigin = "center center";
- imageEl.style.cursor = "zoom-in";
+ applyZoomTransform();
}
function updateZoomOrigin(clientX, clientY) {
@@ -30,7 +615,9 @@
const x = Math.min(100, Math.max(0, ((clientX - rect.left) / rect.width) * 100));
const y = Math.min(100, Math.max(0, ((clientY - rect.top) / rect.height) * 100));
- imageEl.style.transformOrigin = `${x}% ${y}%`;
+ lightboxState.zoomOriginX = x;
+ lightboxState.zoomOriginY = y;
+ applyTransformOrigins();
}
function isPointOnCard(clientX, clientY) {
@@ -66,71 +653,509 @@
}
function ensure() {
- if (overlayEl && imageEl) {
+ if (overlayEl && imageEl && overlayImageEl) {
return;
}
overlayEl = document.createElement("div");
overlayEl.setAttribute("aria-hidden", "true");
+ overlayEl.setAttribute("role", "dialog");
+ overlayEl.setAttribute("aria-modal", "true");
+ overlayEl.tabIndex = -1;
overlayEl.style.position = "fixed";
overlayEl.style.inset = "0";
- overlayEl.style.background = "rgba(0, 0, 0, 0.82)";
overlayEl.style.display = "none";
- overlayEl.style.alignItems = "center";
- overlayEl.style.justifyContent = "center";
overlayEl.style.zIndex = "9999";
- overlayEl.style.padding = "0";
+ overlayEl.style.pointerEvents = "none";
+
+ helpButtonEl = document.createElement("button");
+ helpButtonEl.type = "button";
+ helpButtonEl.textContent = "Help";
+ helpButtonEl.style.position = "fixed";
+ helpButtonEl.style.top = "24px";
+ helpButtonEl.style.left = "24px";
+ helpButtonEl.style.display = "none";
+ helpButtonEl.style.alignItems = "center";
+ helpButtonEl.style.justifyContent = "center";
+ helpButtonEl.style.border = "1px solid rgba(255, 255, 255, 0.2)";
+ helpButtonEl.style.background = "rgba(15, 23, 42, 0.84)";
+ helpButtonEl.style.color = "#f8fafc";
+ helpButtonEl.style.borderRadius = "999px";
+ helpButtonEl.style.padding = "10px 14px";
+ helpButtonEl.style.font = "600 13px/1.1 sans-serif";
+ helpButtonEl.style.cursor = "pointer";
+ helpButtonEl.style.backdropFilter = "blur(12px)";
+ helpButtonEl.style.pointerEvents = "auto";
+ helpButtonEl.style.zIndex = "2";
+
+ helpPanelEl = document.createElement("div");
+ helpPanelEl.style.position = "fixed";
+ helpPanelEl.style.top = "72px";
+ helpPanelEl.style.left = "24px";
+ helpPanelEl.style.display = "none";
+ helpPanelEl.style.flexDirection = "column";
+ helpPanelEl.style.gap = "8px";
+ helpPanelEl.style.width = "min(320px, calc(100vw - 48px))";
+ helpPanelEl.style.padding = "14px 16px";
+ helpPanelEl.style.borderRadius = "18px";
+ helpPanelEl.style.background = "rgba(2, 6, 23, 0.88)";
+ helpPanelEl.style.border = "1px solid rgba(148, 163, 184, 0.16)";
+ helpPanelEl.style.color = "#f8fafc";
+ helpPanelEl.style.boxShadow = "0 16px 42px rgba(0, 0, 0, 0.34)";
+ helpPanelEl.style.backdropFilter = "blur(12px)";
+ helpPanelEl.style.pointerEvents = "auto";
+ helpPanelEl.style.zIndex = "2";
+
+ const helpTitleEl = document.createElement("div");
+ helpTitleEl.textContent = "Lightbox Shortcuts";
+ helpTitleEl.style.font = "700 13px/1.3 sans-serif";
+
+ const helpListEl = document.createElement("div");
+ helpListEl.style.display = "flex";
+ helpListEl.style.flexDirection = "column";
+ helpListEl.style.gap = "6px";
+ helpListEl.style.font = "500 12px/1.4 sans-serif";
+ helpListEl.style.color = "rgba(226, 232, 240, 0.92)";
+
+ [
+ "Click card: toggle zoom at the clicked point",
+ "Left / Right: move cards, or move overlay card in compare mode",
+ "Overlay: pick a second card to compare",
+ "Space: swap base and overlay cards",
+ "R: rotate base card, or rotate overlay card in compare mode",
+ "+ / -: zoom in or out in steps",
+ "W A S D: pan while zoomed",
+ "Escape or backdrop click: close"
+ ].forEach((line) => {
+ const lineEl = document.createElement("div");
+ lineEl.textContent = line;
+ helpListEl.appendChild(lineEl);
+ });
+
+ helpPanelEl.append(helpTitleEl, helpListEl);
+
+ backdropEl = document.createElement("button");
+ backdropEl.type = "button";
+ backdropEl.setAttribute("aria-label", "Close enlarged tarot card");
+ backdropEl.style.position = "absolute";
+ backdropEl.style.inset = "0";
+ backdropEl.style.border = "none";
+ backdropEl.style.padding = "0";
+ backdropEl.style.margin = "0";
+ backdropEl.style.background = "rgba(0, 0, 0, 0.82)";
+ backdropEl.style.cursor = "pointer";
+
+ toolbarEl = document.createElement("div");
+ toolbarEl.style.position = "fixed";
+ toolbarEl.style.top = "24px";
+ toolbarEl.style.right = "24px";
+ toolbarEl.style.display = "flex";
+ toolbarEl.style.flexDirection = "column";
+ toolbarEl.style.alignItems = "flex-end";
+ toolbarEl.style.gap = "8px";
+ toolbarEl.style.pointerEvents = "auto";
+ toolbarEl.style.zIndex = "2";
+
+ compareButtonEl = document.createElement("button");
+ compareButtonEl.type = "button";
+ compareButtonEl.textContent = "Overlay";
+ compareButtonEl.style.border = "1px solid rgba(255, 255, 255, 0.2)";
+ compareButtonEl.style.background = "rgba(15, 23, 42, 0.84)";
+ compareButtonEl.style.color = "#f8fafc";
+ compareButtonEl.style.borderRadius = "999px";
+ compareButtonEl.style.padding = "10px 14px";
+ compareButtonEl.style.font = "600 13px/1.1 sans-serif";
+ compareButtonEl.style.cursor = "pointer";
+ compareButtonEl.style.backdropFilter = "blur(12px)";
+
+ zoomControlEl = document.createElement("label");
+ zoomControlEl.style.display = "flex";
+ zoomControlEl.style.alignItems = "center";
+ zoomControlEl.style.gap = "8px";
+ zoomControlEl.style.padding = "10px 14px";
+ zoomControlEl.style.border = "1px solid rgba(255, 255, 255, 0.2)";
+ zoomControlEl.style.borderRadius = "999px";
+ zoomControlEl.style.background = "rgba(15, 23, 42, 0.84)";
+ zoomControlEl.style.color = "#f8fafc";
+ zoomControlEl.style.font = "600 12px/1.1 sans-serif";
+ zoomControlEl.style.backdropFilter = "blur(12px)";
+
+ const zoomTextEl = document.createElement("span");
+ zoomTextEl.textContent = "Zoom";
+
+ zoomSliderEl = document.createElement("input");
+ zoomSliderEl.type = "range";
+ zoomSliderEl.min = "100";
+ zoomSliderEl.max = String(Math.round(LIGHTBOX_ZOOM_SCALE * 100));
+ zoomSliderEl.step = "10";
+ zoomSliderEl.value = String(Math.round(LIGHTBOX_ZOOM_SCALE * 100));
+ zoomSliderEl.style.width = "110px";
+ zoomSliderEl.style.cursor = "pointer";
+
+ zoomValueEl = document.createElement("span");
+ zoomValueEl.textContent = `${Math.round(LIGHTBOX_ZOOM_SCALE * 100)}%`;
+ zoomValueEl.style.minWidth = "42px";
+ zoomValueEl.style.textAlign = "right";
+
+ zoomControlEl.append(zoomTextEl, zoomSliderEl, zoomValueEl);
+
+ opacityControlEl = document.createElement("label");
+ opacityControlEl.style.display = "none";
+ opacityControlEl.style.alignItems = "center";
+ opacityControlEl.style.gap = "8px";
+ opacityControlEl.style.padding = "10px 14px";
+ opacityControlEl.style.border = "1px solid rgba(255, 255, 255, 0.2)";
+ opacityControlEl.style.borderRadius = "999px";
+ opacityControlEl.style.background = "rgba(15, 23, 42, 0.84)";
+ opacityControlEl.style.color = "#f8fafc";
+ opacityControlEl.style.font = "600 12px/1.1 sans-serif";
+ opacityControlEl.style.backdropFilter = "blur(12px)";
+
+ const opacityTextEl = document.createElement("span");
+ opacityTextEl.textContent = "Overlay";
+
+ opacitySliderEl = document.createElement("input");
+ opacitySliderEl.type = "range";
+ opacitySliderEl.min = "5";
+ opacitySliderEl.max = "100";
+ opacitySliderEl.step = "5";
+ opacitySliderEl.value = String(Math.round(LIGHTBOX_COMPARE_DEFAULT_OVERLAY_OPACITY * 100));
+ opacitySliderEl.style.width = "110px";
+ opacitySliderEl.style.cursor = "pointer";
+
+ opacityValueEl = document.createElement("span");
+ opacityValueEl.textContent = `${Math.round(LIGHTBOX_COMPARE_DEFAULT_OVERLAY_OPACITY * 100)}%`;
+ opacityValueEl.style.minWidth = "34px";
+ opacityValueEl.style.textAlign = "right";
+
+ opacityControlEl.append(opacityTextEl, opacitySliderEl, opacityValueEl);
+
+ toolbarEl.append(compareButtonEl, zoomControlEl, opacityControlEl);
+
+ stageEl = document.createElement("div");
+ stageEl.style.position = "fixed";
+ stageEl.style.top = "0";
+ stageEl.style.right = "0";
+ stageEl.style.bottom = "0";
+ stageEl.style.left = "0";
+ stageEl.style.pointerEvents = "auto";
+ stageEl.style.overflow = "visible";
+ stageEl.style.transition = "top 220ms ease, right 220ms ease, bottom 220ms ease, left 220ms ease, width 220ms ease, height 220ms ease, transform 220ms ease";
+ stageEl.style.transform = "none";
+ stageEl.style.zIndex = "1";
+
+ frameEl = document.createElement("div");
+ frameEl.style.position = "relative";
+ frameEl.style.width = "100%";
+ frameEl.style.height = "100%";
+ frameEl.style.overflow = "hidden";
+ frameEl.style.transition = "border-radius 220ms ease, background 220ms ease, box-shadow 220ms ease";
+
+ baseLayerEl = document.createElement("div");
+ baseLayerEl.style.position = "absolute";
+ baseLayerEl.style.inset = "0";
+ baseLayerEl.style.transform = "scale(1)";
+ baseLayerEl.style.transformOrigin = "50% 50%";
+ baseLayerEl.style.transition = "transform 120ms ease-out";
+
+ overlayLayerEl = document.createElement("div");
+ overlayLayerEl.style.position = "absolute";
+ overlayLayerEl.style.inset = "0";
+ overlayLayerEl.style.transform = "scale(1)";
+ overlayLayerEl.style.transformOrigin = "50% 50%";
+ overlayLayerEl.style.transition = "transform 120ms ease-out";
+ overlayLayerEl.style.pointerEvents = "none";
imageEl = document.createElement("img");
imageEl.alt = "Tarot card enlarged image";
- imageEl.style.maxWidth = "100vw";
- imageEl.style.maxHeight = "100vh";
- imageEl.style.width = "100vw";
- imageEl.style.height = "100vh";
+ imageEl.style.width = "100%";
+ imageEl.style.height = "100%";
imageEl.style.objectFit = "contain";
- imageEl.style.borderRadius = "0";
- imageEl.style.boxShadow = "none";
- imageEl.style.border = "none";
imageEl.style.cursor = "zoom-in";
- imageEl.style.transform = "scale(1)";
+ imageEl.style.transform = "rotate(0deg)";
imageEl.style.transformOrigin = "center center";
- imageEl.style.transition = "transform 120ms ease-out";
+ imageEl.style.transition = "transform 120ms ease-out, opacity 180ms ease";
imageEl.style.userSelect = "none";
- overlayEl.appendChild(imageEl);
+ overlayImageEl = document.createElement("img");
+ overlayImageEl.alt = "Tarot card overlay image";
+ overlayImageEl.style.width = "100%";
+ overlayImageEl.style.height = "100%";
+ overlayImageEl.style.objectFit = "contain";
+ overlayImageEl.style.opacity = String(LIGHTBOX_COMPARE_DEFAULT_OVERLAY_OPACITY);
+ overlayImageEl.style.pointerEvents = "none";
+ overlayImageEl.style.display = "none";
+ overlayImageEl.style.transform = "rotate(0deg)";
+ overlayImageEl.style.transformOrigin = "center center";
+ overlayImageEl.style.transition = "opacity 180ms ease";
+
+ function createInfoPanel() {
+ const panelEl = document.createElement("div");
+ panelEl.style.position = "absolute";
+ panelEl.style.display = "none";
+ panelEl.style.flexDirection = "column";
+ panelEl.style.gap = "10px";
+ panelEl.style.padding = "14px 16px";
+ panelEl.style.borderRadius = "18px";
+ panelEl.style.background = "rgba(2, 6, 23, 0.8)";
+ panelEl.style.border = "1px solid rgba(148, 163, 184, 0.16)";
+ panelEl.style.color = "#f8fafc";
+ panelEl.style.backdropFilter = "blur(12px)";
+ panelEl.style.boxShadow = "0 16px 42px rgba(0, 0, 0, 0.34)";
+ panelEl.style.transition = "opacity 180ms ease, transform 180ms ease";
+ panelEl.style.transform = "translateY(-50%)";
+ panelEl.style.pointerEvents = "none";
+ panelEl.style.maxHeight = "min(78vh, 760px)";
+ panelEl.style.overflowY = "auto";
+
+ const titleEl = document.createElement("div");
+ titleEl.style.font = "700 13px/1.3 sans-serif";
+ titleEl.style.color = "#f8fafc";
+
+ const groupsEl = document.createElement("div");
+ groupsEl.style.display = "flex";
+ groupsEl.style.flexDirection = "column";
+ groupsEl.style.gap = "0";
+
+ const hintEl = document.createElement("div");
+ hintEl.style.font = "500 11px/1.35 sans-serif";
+ hintEl.style.color = "rgba(226, 232, 240, 0.82)";
+
+ panelEl.append(titleEl, groupsEl, hintEl);
+ return { panelEl, titleEl, groupsEl, hintEl };
+ }
+
+ const primaryPanel = createInfoPanel();
+ primaryInfoEl = primaryPanel.panelEl;
+ primaryTitleEl = primaryPanel.titleEl;
+ primaryGroupsEl = primaryPanel.groupsEl;
+ primaryHintEl = primaryPanel.hintEl;
+
+ const secondaryPanel = createInfoPanel();
+ secondaryInfoEl = secondaryPanel.panelEl;
+ secondaryTitleEl = secondaryPanel.titleEl;
+ secondaryGroupsEl = secondaryPanel.groupsEl;
+ secondaryHintEl = secondaryPanel.hintEl;
+ baseLayerEl.appendChild(imageEl);
+ overlayLayerEl.appendChild(overlayImageEl);
+ frameEl.append(baseLayerEl, overlayLayerEl);
+ stageEl.append(frameEl, primaryInfoEl, secondaryInfoEl);
+ overlayEl.append(backdropEl, stageEl, toolbarEl, helpButtonEl, helpPanelEl);
const close = () => {
- if (!overlayEl || !imageEl) {
+ if (!overlayEl || !imageEl || !overlayImageEl) {
return;
}
+
+ lightboxState.isOpen = false;
+ lightboxState.compareMode = false;
+ lightboxState.allowOverlayCompare = false;
+ lightboxState.primaryCard = null;
+ lightboxState.secondaryCard = null;
+ lightboxState.sequenceIds = [];
+ lightboxState.resolveCardById = null;
+ lightboxState.onSelectCardId = null;
+ lightboxState.overlayOpacity = LIGHTBOX_COMPARE_DEFAULT_OVERLAY_OPACITY;
+ lightboxState.zoomScale = LIGHTBOX_ZOOM_SCALE;
+ lightboxState.helpOpen = false;
overlayEl.style.display = "none";
overlayEl.setAttribute("aria-hidden", "true");
imageEl.removeAttribute("src");
+ imageEl.alt = "Tarot card enlarged image";
+ overlayImageEl.removeAttribute("src");
+ overlayImageEl.alt = "";
+ overlayImageEl.style.display = "none";
resetZoom();
+ syncHelpUi();
+ syncComparePanels();
+ syncOpacityControl();
+
+ if (previousFocusedEl instanceof HTMLElement) {
+ previousFocusedEl.focus({ preventScroll: true });
+ }
+ previousFocusedEl = null;
};
- overlayEl.addEventListener("click", (event) => {
- if (event.target === overlayEl) {
- close();
+ function toggleCompareMode() {
+ if (!lightboxState.allowOverlayCompare || !lightboxState.primaryCard) {
+ return;
}
+
+ lightboxState.compareMode = !lightboxState.compareMode;
+ if (!lightboxState.compareMode) {
+ clearSecondaryCard();
+ }
+ applyComparePresentation();
+ }
+
+ function setSecondaryCard(cardRequest, syncSelection = false) {
+ const normalizedCard = normalizeCardRequest(cardRequest);
+ if (!normalizedCard.src || !normalizedCard.cardId || normalizedCard.cardId === lightboxState.primaryCard?.cardId) {
+ return false;
+ }
+
+ lightboxState.secondaryCard = normalizedCard;
+ overlayImageEl.src = normalizedCard.src;
+ overlayImageEl.alt = normalizedCard.altText;
+ overlayImageEl.style.display = "block";
+ overlayImageEl.style.opacity = String(lightboxState.overlayOpacity);
+ if (syncSelection && typeof lightboxState.onSelectCardId === "function") {
+ lightboxState.onSelectCardId(normalizedCard.cardId);
+ }
+ applyComparePresentation();
+ return true;
+ }
+
+ function stepSecondaryCard(direction) {
+ const sequence = Array.isArray(lightboxState.sequenceIds) ? lightboxState.sequenceIds : [];
+ if (!lightboxState.compareMode || sequence.length < 2 || typeof lightboxState.resolveCardById !== "function") {
+ return;
+ }
+
+ const anchorId = lightboxState.secondaryCard?.cardId || lightboxState.primaryCard?.cardId;
+ const startIndex = sequence.indexOf(anchorId);
+ if (startIndex < 0) {
+ return;
+ }
+
+ for (let offset = 1; offset <= sequence.length; offset += 1) {
+ const nextIndex = (startIndex + direction * offset + sequence.length) % sequence.length;
+ const nextCardId = sequence[nextIndex];
+ if (!nextCardId || nextCardId === lightboxState.primaryCard?.cardId) {
+ continue;
+ }
+
+ const nextCard = resolveCardRequestById(nextCardId);
+ if (nextCard && setSecondaryCard(nextCard, true)) {
+ break;
+ }
+ }
+ }
+
+ function stepPrimaryCard(direction) {
+ const sequence = Array.isArray(lightboxState.sequenceIds) ? lightboxState.sequenceIds : [];
+ if (lightboxState.compareMode || sequence.length < 2 || typeof lightboxState.resolveCardById !== "function") {
+ return;
+ }
+
+ const startIndex = sequence.indexOf(lightboxState.primaryCard?.cardId);
+ if (startIndex < 0) {
+ return;
+ }
+
+ const nextIndex = (startIndex + direction + sequence.length) % sequence.length;
+ const nextCardId = sequence[nextIndex];
+ const nextCard = resolveCardRequestById(nextCardId);
+ if (!nextCard?.src) {
+ return;
+ }
+
+ lightboxState.primaryCard = nextCard;
+ imageEl.src = nextCard.src;
+ imageEl.alt = nextCard.altText;
+ resetZoom();
+ clearSecondaryCard();
+ if (typeof lightboxState.onSelectCardId === "function") {
+ lightboxState.onSelectCardId(nextCard.cardId);
+ }
+ applyComparePresentation();
+ }
+
+ function swapCompareCards() {
+ if (!lightboxState.compareMode || !lightboxState.primaryCard?.src || !lightboxState.secondaryCard?.src) {
+ return;
+ }
+
+ const nextPrimaryCard = lightboxState.secondaryCard;
+ const nextSecondaryCard = lightboxState.primaryCard;
+
+ lightboxState.primaryCard = nextPrimaryCard;
+ lightboxState.secondaryCard = nextSecondaryCard;
+
+ imageEl.src = nextPrimaryCard.src;
+ imageEl.alt = nextPrimaryCard.altText;
+ overlayImageEl.src = nextSecondaryCard.src;
+ overlayImageEl.alt = nextSecondaryCard.altText;
+ overlayImageEl.style.display = "block";
+ overlayImageEl.style.opacity = String(lightboxState.overlayOpacity);
+
+ if (typeof lightboxState.onSelectCardId === "function") {
+ lightboxState.onSelectCardId(nextPrimaryCard.cardId);
+ }
+
+ applyComparePresentation();
+ }
+
+ function shouldIgnoreGlobalKeydown(event) {
+ const target = event.target;
+ if (!(target instanceof HTMLElement)) {
+ return false;
+ }
+
+ if (!overlayEl?.contains(target)) {
+ return false;
+ }
+
+ return target instanceof HTMLInputElement
+ || target instanceof HTMLTextAreaElement
+ || target instanceof HTMLSelectElement
+ || target instanceof HTMLButtonElement;
+ }
+
+ function restoreLightboxFocus() {
+ if (!overlayEl || !lightboxState.isOpen) {
+ return;
+ }
+
+ requestAnimationFrame(() => {
+ if (overlayEl && lightboxState.isOpen) {
+ overlayEl.focus({ preventScroll: true });
+ }
+ });
+ }
+
+ backdropEl.addEventListener("click", close);
+ helpButtonEl.addEventListener("click", () => {
+ lightboxState.helpOpen = !lightboxState.helpOpen;
+ syncHelpUi();
+ restoreLightboxFocus();
});
+ compareButtonEl.addEventListener("click", () => {
+ toggleCompareMode();
+ restoreLightboxFocus();
+ });
+ zoomSliderEl.addEventListener("input", () => {
+ setZoomScale(Number(zoomSliderEl.value) / 100);
+ });
+ zoomSliderEl.addEventListener("change", restoreLightboxFocus);
+ zoomSliderEl.addEventListener("pointerup", restoreLightboxFocus);
+ opacitySliderEl.addEventListener("input", () => {
+ setOverlayOpacity(Number(opacitySliderEl.value) / 100);
+ });
+ opacitySliderEl.addEventListener("change", restoreLightboxFocus);
+ opacitySliderEl.addEventListener("pointerup", restoreLightboxFocus);
imageEl.addEventListener("click", (event) => {
event.stopPropagation();
if (!isPointOnCard(event.clientX, event.clientY)) {
+ if (lightboxState.compareMode) {
+ return;
+ }
+
close();
return;
}
if (!zoomed) {
zoomed = true;
- imageEl.style.transform = `scale(${LIGHTBOX_ZOOM_SCALE})`;
- imageEl.style.cursor = "zoom-out";
+ applyZoomTransform();
updateZoomOrigin(event.clientX, event.clientY);
+ applyComparePresentation();
return;
}
resetZoom();
+ applyComparePresentation();
});
imageEl.addEventListener("mousemove", (event) => {
@@ -139,34 +1164,141 @@
imageEl.addEventListener("mouseleave", () => {
if (zoomed) {
- imageEl.style.transformOrigin = "center center";
+ lightboxState.zoomOriginX = 50;
+ lightboxState.zoomOriginY = 50;
+ applyTransformOrigins();
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
close();
+ return;
}
+
+ if (shouldIgnoreGlobalKeydown(event)) {
+ return;
+ }
+
+ if (lightboxState.isOpen && event.code === "Space" && lightboxState.compareMode && hasSecondaryCard()) {
+ event.preventDefault();
+ swapCompareCards();
+ return;
+ }
+
+ if (lightboxState.isOpen && isRotateKey(event)) {
+ event.preventDefault();
+ toggleRotation();
+ return;
+ }
+
+ if (lightboxState.isOpen && isZoomInKey(event)) {
+ event.preventDefault();
+ stepZoom(1);
+ return;
+ }
+
+ if (lightboxState.isOpen && isZoomOutKey(event)) {
+ event.preventDefault();
+ stepZoom(-1);
+ return;
+ }
+
+ if (lightboxState.isOpen && zoomed && isPanUpKey(event)) {
+ event.preventDefault();
+ stepPan(0, -LIGHTBOX_PAN_STEP);
+ return;
+ }
+
+ if (lightboxState.isOpen && zoomed && isPanLeftKey(event)) {
+ event.preventDefault();
+ stepPan(-LIGHTBOX_PAN_STEP, 0);
+ return;
+ }
+
+ if (lightboxState.isOpen && zoomed && isPanDownKey(event)) {
+ event.preventDefault();
+ stepPan(0, LIGHTBOX_PAN_STEP);
+ return;
+ }
+
+ if (lightboxState.isOpen && zoomed && isPanRightKey(event)) {
+ event.preventDefault();
+ stepPan(LIGHTBOX_PAN_STEP, 0);
+ return;
+ }
+
+ if (!lightboxState.isOpen || !LIGHTBOX_COMPARE_SEQUENCE_STEP_KEYS.has(event.key)) {
+ return;
+ }
+
+ event.preventDefault();
+ if (lightboxState.compareMode) {
+ stepSecondaryCard(event.key === "ArrowRight" ? 1 : -1);
+ return;
+ }
+
+ stepPrimaryCard(event.key === "ArrowRight" ? 1 : -1);
});
document.body.appendChild(overlayEl);
+
+ overlayEl.closeLightbox = close;
+ overlayEl.setSecondaryCard = setSecondaryCard;
+ overlayEl.applyComparePresentation = applyComparePresentation;
}
- function open(src, altText) {
- if (!src) {
+ function open(srcOrOptions, altText, extraOptions) {
+ const request = normalizeOpenRequest(srcOrOptions, altText, extraOptions);
+ const normalizedPrimary = normalizeCardRequest(request);
+
+ if (!normalizedPrimary.src) {
return;
}
ensure();
- if (!overlayEl || !imageEl) {
+ if (!overlayEl || !imageEl || !overlayImageEl) {
return;
}
- imageEl.src = src;
- imageEl.alt = altText || "Tarot card enlarged image";
+ const canCompare = Boolean(
+ request.allowOverlayCompare
+ && normalizedPrimary.cardId
+ && Array.isArray(request.sequenceIds)
+ && request.sequenceIds.length > 1
+ && typeof request.resolveCardById === "function"
+ );
+
+ if (lightboxState.isOpen && lightboxState.compareMode && lightboxState.allowOverlayCompare && canCompare && normalizedPrimary.cardId) {
+ if (normalizedPrimary.cardId === lightboxState.primaryCard?.cardId) {
+ return;
+ }
+ overlayEl.setSecondaryCard?.(normalizedPrimary, false);
+ return;
+ }
+
+ lightboxState.isOpen = true;
+ lightboxState.compareMode = false;
+ lightboxState.allowOverlayCompare = canCompare;
+ lightboxState.primaryCard = normalizedPrimary;
+ lightboxState.sequenceIds = canCompare ? [...request.sequenceIds] : [];
+ lightboxState.resolveCardById = canCompare ? request.resolveCardById : null;
+ lightboxState.onSelectCardId = canCompare && typeof request.onSelectCardId === "function"
+ ? request.onSelectCardId
+ : null;
+ lightboxState.overlayOpacity = LIGHTBOX_COMPARE_DEFAULT_OVERLAY_OPACITY;
+ lightboxState.zoomScale = LIGHTBOX_ZOOM_SCALE;
+ lightboxState.helpOpen = false;
+
+ imageEl.src = normalizedPrimary.src;
+ imageEl.alt = normalizedPrimary.altText;
+ clearSecondaryCard();
resetZoom();
- overlayEl.style.display = "flex";
+ previousFocusedEl = document.activeElement instanceof HTMLElement ? document.activeElement : null;
+ overlayEl.style.display = "block";
overlayEl.setAttribute("aria-hidden", "false");
+ overlayEl.applyComparePresentation?.();
+ overlayEl.focus({ preventScroll: true });
}
window.TarotUiLightbox = {
diff --git a/app/ui-tarot.js b/app/ui-tarot.js
index 0e35411..c924f8c 100644
--- a/app/ui-tarot.js
+++ b/app/ui-tarot.js
@@ -12,6 +12,25 @@
filteredCards: [],
searchQuery: "",
selectedCardId: "",
+ houseFocusMode: false,
+ houseTopCardsVisible: true,
+ houseTopInfoModes: {
+ hebrew: true,
+ planet: true,
+ zodiac: true,
+ trump: true,
+ path: true
+ },
+ houseBottomCardsVisible: true,
+ houseBottomInfoModes: {
+ zodiac: true,
+ decan: true,
+ month: true,
+ ruler: true,
+ date: false
+ },
+ houseExportInProgress: false,
+ houseExportFormat: "png",
magickDataset: null,
referenceData: null,
monthRefsByCardId: new Map(),
@@ -248,10 +267,34 @@
tarotDetailIChingEl: document.getElementById("tarot-detail-iching"),
tarotDetailCalendarEl: document.getElementById("tarot-detail-calendar"),
tarotKabPathEl: document.getElementById("tarot-kab-path"),
- tarotHouseOfCardsEl: document.getElementById("tarot-house-of-cards")
+ tarotHouseOfCardsEl: document.getElementById("tarot-house-of-cards"),
+ tarotBrowseViewEl: document.getElementById("tarot-browse-view"),
+ tarotHouseTopCardsVisibleEl: document.getElementById("tarot-house-top-cards-visible"),
+ tarotHouseTopInfoHebrewEl: document.getElementById("tarot-house-top-info-hebrew"),
+ tarotHouseTopInfoPlanetEl: document.getElementById("tarot-house-top-info-planet"),
+ tarotHouseTopInfoZodiacEl: document.getElementById("tarot-house-top-info-zodiac"),
+ tarotHouseTopInfoTrumpEl: document.getElementById("tarot-house-top-info-trump"),
+ tarotHouseTopInfoPathEl: document.getElementById("tarot-house-top-info-path"),
+ tarotHouseBottomCardsVisibleEl: document.getElementById("tarot-house-bottom-cards-visible"),
+ tarotHouseBottomInfoZodiacEl: document.getElementById("tarot-house-bottom-info-zodiac"),
+ tarotHouseBottomInfoDecanEl: document.getElementById("tarot-house-bottom-info-decan"),
+ tarotHouseBottomInfoMonthEl: document.getElementById("tarot-house-bottom-info-month"),
+ tarotHouseBottomInfoRulerEl: document.getElementById("tarot-house-bottom-info-ruler"),
+ tarotHouseBottomInfoDateEl: document.getElementById("tarot-house-bottom-info-date"),
+ tarotHouseFocusToggleEl: document.getElementById("tarot-house-focus-toggle"),
+ tarotHouseExportEl: document.getElementById("tarot-house-export"),
+ tarotHouseExportWebpEl: document.getElementById("tarot-house-export-webp")
};
}
+ function setHouseBottomInfoCheckboxState(checkbox, enabled) {
+ if (!checkbox) {
+ return;
+ }
+ checkbox.checked = Boolean(enabled);
+ checkbox.disabled = Boolean(state.houseExportInProgress);
+ }
+
function normalizeRelationId(value) {
return String(value || "")
.trim()
@@ -485,6 +528,76 @@
tarotHouseUi.render?.(elements);
}
+ function syncHouseControls(elements) {
+ if (elements?.tarotBrowseViewEl) {
+ elements.tarotBrowseViewEl.classList.toggle("is-house-focus", Boolean(state.houseFocusMode));
+ }
+
+ if (elements?.tarotHouseTopCardsVisibleEl) {
+ elements.tarotHouseTopCardsVisibleEl.checked = Boolean(state.houseTopCardsVisible);
+ elements.tarotHouseTopCardsVisibleEl.disabled = Boolean(state.houseExportInProgress);
+ }
+
+ setHouseBottomInfoCheckboxState(elements?.tarotHouseTopInfoHebrewEl, state.houseTopInfoModes.hebrew);
+ setHouseBottomInfoCheckboxState(elements?.tarotHouseTopInfoPlanetEl, state.houseTopInfoModes.planet);
+ setHouseBottomInfoCheckboxState(elements?.tarotHouseTopInfoZodiacEl, state.houseTopInfoModes.zodiac);
+ setHouseBottomInfoCheckboxState(elements?.tarotHouseTopInfoTrumpEl, state.houseTopInfoModes.trump);
+ setHouseBottomInfoCheckboxState(elements?.tarotHouseTopInfoPathEl, state.houseTopInfoModes.path);
+
+ if (elements?.tarotHouseBottomCardsVisibleEl) {
+ elements.tarotHouseBottomCardsVisibleEl.checked = Boolean(state.houseBottomCardsVisible);
+ elements.tarotHouseBottomCardsVisibleEl.disabled = Boolean(state.houseExportInProgress);
+ }
+
+ setHouseBottomInfoCheckboxState(elements?.tarotHouseBottomInfoZodiacEl, state.houseBottomInfoModes.zodiac);
+ setHouseBottomInfoCheckboxState(elements?.tarotHouseBottomInfoDecanEl, state.houseBottomInfoModes.decan);
+ setHouseBottomInfoCheckboxState(elements?.tarotHouseBottomInfoMonthEl, state.houseBottomInfoModes.month);
+ setHouseBottomInfoCheckboxState(elements?.tarotHouseBottomInfoRulerEl, state.houseBottomInfoModes.ruler);
+ setHouseBottomInfoCheckboxState(elements?.tarotHouseBottomInfoDateEl, state.houseBottomInfoModes.date);
+
+ if (elements?.tarotHouseFocusToggleEl) {
+ elements.tarotHouseFocusToggleEl.setAttribute("aria-pressed", state.houseFocusMode ? "true" : "false");
+ elements.tarotHouseFocusToggleEl.textContent = state.houseFocusMode ? "Show Full Tarot" : "Focus House";
+ }
+
+ if (elements?.tarotHouseExportEl) {
+ elements.tarotHouseExportEl.disabled = Boolean(state.houseExportInProgress);
+ elements.tarotHouseExportEl.textContent = state.houseExportInProgress ? "Exporting..." : "Export PNG";
+ }
+
+ if (elements?.tarotHouseExportWebpEl) {
+ const supportsWebp = tarotHouseUi.isExportFormatSupported?.("webp") === true;
+ elements.tarotHouseExportWebpEl.disabled = Boolean(state.houseExportInProgress) || !supportsWebp;
+ elements.tarotHouseExportWebpEl.hidden = !supportsWebp;
+ elements.tarotHouseExportWebpEl.textContent = state.houseExportInProgress && state.houseExportFormat === "webp"
+ ? "Exporting..."
+ : "Export WebP";
+ if (supportsWebp) {
+ elements.tarotHouseExportWebpEl.title = "Smaller file size, but not guaranteed lossless like PNG.";
+ }
+ }
+ }
+
+ async function exportHouseOfCards(elements, format = "png") {
+ if (state.houseExportInProgress) {
+ return;
+ }
+
+ state.houseExportInProgress = true;
+ state.houseExportFormat = format;
+ syncHouseControls(elements);
+
+ try {
+ await tarotHouseUi.exportImage?.(format);
+ } catch (error) {
+ window.alert(error instanceof Error ? error.message : "Unable to export the House of Cards image.");
+ } finally {
+ state.houseExportInProgress = false;
+ state.houseExportFormat = "png";
+ syncHouseControls(elements);
+ }
+ }
+
function buildTypeLabel(card) {
return tarotCardDerivationsUi.buildTypeLabel(card);
}
@@ -566,6 +679,35 @@
renderDetail(card, elements);
}
+ function scrollCardIntoView(cardIdToReveal, elements) {
+ elements?.tarotCardListEl
+ ?.querySelector(`[data-card-id="${cardIdToReveal}"]`)
+ ?.scrollIntoView({ block: "nearest" });
+ }
+
+ function buildLightboxCardRequestById(cardIdToResolve) {
+ const card = state.cards.find((entry) => entry.id === cardIdToResolve);
+ if (!card) {
+ return null;
+ }
+
+ const src = typeof resolveTarotCardImage === "function"
+ ? resolveTarotCardImage(card.name)
+ : "";
+ if (!src) {
+ return null;
+ }
+
+ const label = getDisplayCardName(card) || card.name || "Tarot card enlarged image";
+ return {
+ src,
+ altText: label,
+ label,
+ cardId: card.id,
+ compareDetails: tarotDetailRenderer.buildCompareDetails?.(card) || []
+ };
+ }
+
function renderList(elements) {
if (!elements?.tarotCardListEl) {
return;
@@ -613,8 +755,32 @@
clearChildren,
normalizeTarotCardLookupName,
selectCardById,
+ openCardLightbox: (src, altText, options = {}) => {
+ const cardId = String(options?.cardId || "").trim();
+ const primaryCardRequest = cardId ? buildLightboxCardRequestById(cardId) : null;
+ window.TarotUiLightbox?.open?.({
+ src: primaryCardRequest?.src || src,
+ altText: primaryCardRequest?.altText || altText || "Tarot card enlarged image",
+ label: primaryCardRequest?.label || altText || "Tarot card enlarged image",
+ cardId: primaryCardRequest?.cardId || cardId,
+ compareDetails: primaryCardRequest?.compareDetails || [],
+ allowOverlayCompare: true,
+ sequenceIds: state.cards.map((card) => card.id),
+ resolveCardById: buildLightboxCardRequestById,
+ onSelectCardId: (nextCardId) => {
+ const latestElements = getElements();
+ selectCardById(nextCardId, latestElements);
+ scrollCardIntoView(nextCardId, latestElements);
+ }
+ });
+ },
+ isHouseFocusMode: () => state.houseFocusMode,
getCards: () => state.cards,
- getSelectedCardId: () => state.selectedCardId
+ getSelectedCardId: () => state.selectedCardId,
+ getHouseTopCardsVisible: () => state.houseTopCardsVisible,
+ getHouseTopInfoModes: () => ({ ...state.houseTopInfoModes }),
+ getHouseBottomCardsVisible: () => state.houseBottomCardsVisible,
+ getHouseBottomInfoModes: () => ({ ...state.houseBottomInfoModes })
});
const elements = getElements();
@@ -623,6 +789,7 @@
state.monthRefsByCardId = buildMonthReferencesByCard(referenceData, state.cards);
state.courtCardByDecanId = buildCourtCardByDecanId(state.cards);
renderHouseOfCards(elements);
+ syncHouseControls(elements);
if (state.selectedCardId) {
const selected = state.cards.find((card) => card.id === state.selectedCardId);
if (selected) {
@@ -652,6 +819,7 @@
state.filteredCards = [...cards];
renderList(elements);
renderHouseOfCards(elements);
+ syncHouseControls(elements);
if (cards.length > 0) {
selectCardById(cards[0].id, elements);
@@ -695,6 +863,75 @@
});
}
+ if (elements.tarotHouseFocusToggleEl) {
+ elements.tarotHouseFocusToggleEl.addEventListener("click", () => {
+ state.houseFocusMode = !state.houseFocusMode;
+ syncHouseControls(elements);
+ });
+ }
+
+ if (elements.tarotHouseTopCardsVisibleEl) {
+ elements.tarotHouseTopCardsVisibleEl.addEventListener("change", () => {
+ state.houseTopCardsVisible = Boolean(elements.tarotHouseTopCardsVisibleEl.checked);
+ renderHouseOfCards(elements);
+ syncHouseControls(elements);
+ });
+ }
+
+ [
+ [elements.tarotHouseTopInfoHebrewEl, "hebrew"],
+ [elements.tarotHouseTopInfoPlanetEl, "planet"],
+ [elements.tarotHouseTopInfoZodiacEl, "zodiac"],
+ [elements.tarotHouseTopInfoTrumpEl, "trump"],
+ [elements.tarotHouseTopInfoPathEl, "path"]
+ ].forEach(([checkbox, key]) => {
+ if (!checkbox) {
+ return;
+ }
+ checkbox.addEventListener("change", () => {
+ state.houseTopInfoModes[key] = Boolean(checkbox.checked);
+ renderHouseOfCards(elements);
+ syncHouseControls(elements);
+ });
+ });
+
+ if (elements.tarotHouseBottomCardsVisibleEl) {
+ elements.tarotHouseBottomCardsVisibleEl.addEventListener("change", () => {
+ state.houseBottomCardsVisible = Boolean(elements.tarotHouseBottomCardsVisibleEl.checked);
+ renderHouseOfCards(elements);
+ syncHouseControls(elements);
+ });
+ }
+
+ [
+ [elements.tarotHouseBottomInfoZodiacEl, "zodiac"],
+ [elements.tarotHouseBottomInfoDecanEl, "decan"],
+ [elements.tarotHouseBottomInfoMonthEl, "month"],
+ [elements.tarotHouseBottomInfoRulerEl, "ruler"],
+ [elements.tarotHouseBottomInfoDateEl, "date"]
+ ].forEach(([checkbox, key]) => {
+ if (!checkbox) {
+ return;
+ }
+ checkbox.addEventListener("change", () => {
+ state.houseBottomInfoModes[key] = Boolean(checkbox.checked);
+ renderHouseOfCards(elements);
+ syncHouseControls(elements);
+ });
+ });
+
+ if (elements.tarotHouseExportEl) {
+ elements.tarotHouseExportEl.addEventListener("click", () => {
+ exportHouseOfCards(elements, "png");
+ });
+ }
+
+ if (elements.tarotHouseExportWebpEl) {
+ elements.tarotHouseExportWebpEl.addEventListener("click", () => {
+ exportHouseOfCards(elements, "webp");
+ });
+ }
+
if (elements.tarotDetailImageEl) {
elements.tarotDetailImageEl.addEventListener("click", () => {
const src = elements.tarotDetailImageEl.getAttribute("src") || "";
@@ -714,8 +951,7 @@
const card = state.cards.find(c => c.arcana === "Major" && c.number === trumpNumber);
if (!card) return;
selectCardById(card.id, el);
- const listItem = el.tarotCardListEl?.querySelector(`[data-card-id="${card.id}"]`);
- listItem?.scrollIntoView({ block: "nearest" });
+ scrollCardIntoView(card.id, el);
}
function selectCardByName(name) {
@@ -725,9 +961,7 @@
const card = state.cards.find((entry) => normalizeTarotCardLookupName(entry.name) === needle);
if (!card) return;
selectCardById(card.id, el);
- el.tarotCardListEl
- ?.querySelector(`[data-card-id="${card.id}"]`)
- ?.scrollIntoView({ block: "nearest" });
+ scrollCardIntoView(card.id, el);
}
window.TarotSectionUi = {
diff --git a/index.html b/index.html
index 4b0c624..bc18103 100644
--- a/index.html
+++ b/index.html
@@ -172,7 +172,68 @@