diff --git a/app.js b/app.js
index b468527..dc199f2 100644
--- a/app.js
+++ b/app.js
@@ -50,6 +50,9 @@ const cyclesSectionEl = document.getElementById("cycles-section");
const elementsSectionEl = document.getElementById("elements-section");
const ichingSectionEl = document.getElementById("iching-section");
const kabbalahSectionEl = document.getElementById("kabbalah-section");
+const kabbalahWorldsSectionEl = document.getElementById("kabbalah-worlds-section");
+const kabbalahPathsSectionEl = document.getElementById("kabbalah-paths-section");
+const kabbalahCrossSectionEl = document.getElementById("kabbalah-cross-section");
const kabbalahTreeSectionEl = document.getElementById("kabbalah-tree-section");
const cubeSectionEl = document.getElementById("cube-section");
const alphabetSectionEl = document.getElementById("alphabet-section");
@@ -78,6 +81,10 @@ const openCyclesEl = document.getElementById("open-cycles");
const openElementsEl = document.getElementById("open-elements");
const openIChingEl = document.getElementById("open-iching");
const openKabbalahEl = document.getElementById("open-kabbalah");
+const openKabbalahSephirotEl = document.getElementById("open-kabbalah-sephirot");
+const openKabbalahWorldsEl = document.getElementById("open-kabbalah-worlds");
+const openKabbalahPathsEl = document.getElementById("open-kabbalah-paths");
+const openKabbalahCrossEl = document.getElementById("open-kabbalah-cross");
const openKabbalahTreeEl = document.getElementById("open-kabbalah-tree");
const openKabbalahCubeEl = document.getElementById("open-kabbalah-cube");
const openAlphabetEl = document.getElementById("open-alphabet");
@@ -327,15 +334,22 @@ function getConnectionSettings() {
};
}
-function syncConnectionGateInputs() {
- const connectionSettings = getConnectionSettings();
+function normalizeConnectionSettingsInput(connectionSettings = null) {
+ return {
+ apiBaseUrl: String(connectionSettings?.apiBaseUrl || "").trim().replace(/\/+$/, ""),
+ apiKey: String(connectionSettings?.apiKey || "").trim()
+ };
+}
+
+function syncConnectionGateInputs(connectionSettings = getConnectionSettings()) {
+ const normalizedConnectionSettings = normalizeConnectionSettingsInput(connectionSettings);
if (connectionGateBaseUrlEl) {
- connectionGateBaseUrlEl.value = String(connectionSettings.apiBaseUrl || "");
+ connectionGateBaseUrlEl.value = normalizedConnectionSettings.apiBaseUrl;
}
if (connectionGateApiKeyEl) {
- connectionGateApiKeyEl.value = String(connectionSettings.apiKey || "");
+ connectionGateApiKeyEl.value = normalizedConnectionSettings.apiKey;
}
}
@@ -352,8 +366,8 @@ function setConnectionGateStatus(text, tone = "default") {
}
}
-function showConnectionGate(message, tone = "default") {
- syncConnectionGateInputs();
+function showConnectionGate(message, tone = "default", connectionSettings = null) {
+ syncConnectionGateInputs(connectionSettings || getConnectionSettings());
if (connectionGateEl) {
connectionGateEl.hidden = false;
}
@@ -369,33 +383,49 @@ function hideConnectionGate() {
}
function getConnectionSettingsFromGate() {
- return {
+ return normalizeConnectionSettingsInput({
apiBaseUrl: String(connectionGateBaseUrlEl?.value || "").trim(),
apiKey: String(connectionGateApiKeyEl?.value || "").trim()
- };
+ });
+}
+
+function warmAllDeckImagesInBackground() {
+ const activeDeckId = String(window.TarotCardImages?.getActiveDeck?.() || "").trim();
+
+ window.TarotCardImages?.scheduleAllDeckImagePreload?.({
+ startDeckId: activeDeckId,
+ background: true,
+ includeThumbnails: true
+ });
}
async function ensureConnectedApp(nextConnectionSettings = null) {
- if (nextConnectionSettings) {
- window.TarotAppConfig?.updateConnectionSettings?.(nextConnectionSettings);
+ const configuredConnection = nextConnectionSettings
+ ? normalizeConnectionSettingsInput(nextConnectionSettings)
+ : getConnectionSettings();
+
+ if (!nextConnectionSettings) {
+ syncConnectionGateInputs(configuredConnection);
}
- syncConnectionGateInputs();
-
- const configuredConnection = getConnectionSettings();
if (!configuredConnection.apiBaseUrl) {
- showConnectionGate("Enter an API Base URL to load TaroTime.", "error");
+ showConnectionGate("Enter an API Base URL to load TaroTime.", "error", configuredConnection);
return false;
}
- showConnectionGate("Connecting to the API...", "pending");
+ showConnectionGate("Connecting to the API...", "pending", configuredConnection);
- const probeResult = await window.TarotDataService?.probeConnection?.();
+ const probeResult = await window.TarotDataService?.probeConnection?.(configuredConnection);
if (!probeResult?.ok) {
- showConnectionGate(probeResult?.message || "Unable to reach the API.", "error");
+ showConnectionGate(probeResult?.message || "Unable to reach the API.", "error", configuredConnection);
return false;
}
+ if (nextConnectionSettings) {
+ window.TarotAppConfig?.updateConnectionSettings?.(configuredConnection);
+ syncConnectionGateInputs(configuredConnection);
+ }
+
hideConnectionGate();
if (!hasRenderedConnectedShell) {
sectionStateUi.setActiveSection?.("home");
@@ -405,6 +435,7 @@ async function ensureConnectedApp(nextConnectionSettings = null) {
setConnectionGateStatus("Connected.", "success");
setStatus(`Connected to ${configuredConnection.apiBaseUrl}.`);
await appRuntime.renderWeek?.();
+ warmAllDeckImagesInBackground();
return true;
}
@@ -491,6 +522,9 @@ sectionStateUi.init?.({
elementsSectionEl,
ichingSectionEl,
kabbalahSectionEl,
+ kabbalahWorldsSectionEl,
+ kabbalahPathsSectionEl,
+ kabbalahCrossSectionEl,
kabbalahTreeSectionEl,
cubeSectionEl,
alphabetSectionEl,
@@ -519,6 +553,10 @@ sectionStateUi.init?.({
openElementsEl,
openIChingEl,
openKabbalahEl,
+ openKabbalahSephirotEl,
+ openKabbalahWorldsEl,
+ openKabbalahPathsEl,
+ openKabbalahCrossEl,
openKabbalahTreeEl,
openKabbalahCubeEl,
openAlphabetEl,
@@ -626,6 +664,10 @@ navigationUi.init?.({
openElementsEl,
openIChingEl,
openKabbalahEl,
+ openKabbalahSephirotEl,
+ openKabbalahWorldsEl,
+ openKabbalahPathsEl,
+ openKabbalahCrossEl,
openKabbalahTreeEl,
openKabbalahCubeEl,
openAlphabetEl,
diff --git a/app/card-images.js b/app/card-images.js
index 3ce6c6e..1ac23c8 100644
--- a/app/card-images.js
+++ b/app/card-images.js
@@ -137,7 +137,7 @@
const standardMinorSuits = ["Wands", "Cups", "Swords", "Disks"];
const standardDeckCardNames = buildStandardDeckCardNames();
- let deckManifestSources = buildDeckManifestSources();
+ let deckManifestSources = null;
const manifestCache = new Map();
const cardBackCache = new Map();
@@ -161,6 +161,25 @@
.replace(/\/+$/, "");
}
+ function buildManifestRequestHeaders(path) {
+ const normalizedPath = String(path || "").trim();
+ const apiBaseUrl = getApiBaseUrl();
+ const apiKey = String(
+ window.TarotDataService?.getApiKey?.()
+ || window.TarotAppConfig?.getApiKey?.()
+ || window.TarotAppConfig?.apiKey
+ || ""
+ ).trim();
+
+ if (!normalizedPath || !apiBaseUrl || !apiKey || !normalizedPath.startsWith(apiBaseUrl)) {
+ return {};
+ }
+
+ return {
+ "x-api-key": apiKey
+ };
+ }
+
function rewriteBasePathForApi(basePath) {
const normalizedBasePath = String(basePath || "").trim();
if (!normalizedBasePath) {
@@ -530,6 +549,9 @@
try {
const request = new XMLHttpRequest();
request.open("GET", encodeURI(path), false);
+ Object.entries(buildManifestRequestHeaders(path)).forEach(([headerName, headerValue]) => {
+ request.setRequestHeader(headerName, headerValue);
+ });
request.send(null);
const okStatus = (request.status >= 200 && request.status < 300) || request.status === 0;
@@ -1164,6 +1186,9 @@
})
.then((result) => {
markDeckAsWarmed(normalizedDeckId);
+ if (options.background) {
+ emitDeckPreloadStatus();
+ }
if (!options.background) {
setDeckPreloadStatus({
activeDeckId: normalizedDeckId,
@@ -1211,6 +1236,13 @@
}));
}
+ function scheduleAllDeckImagePreload(options = {}) {
+ return deferPreload(() => preloadAllDeckImages({
+ ...defaultDeckWarmupOptions,
+ ...options
+ }));
+ }
+
function resolveDisplayNameWithDeck(deckId, cardName, trumpNumber) {
const manifest = getDeckManifest(deckId);
const fallbackName = String(cardName || "").trim();
@@ -1333,6 +1365,7 @@
resolveTarotCardBackThumbnail,
preloadDeckImages,
preloadAllDeckImages,
+ scheduleAllDeckImagePreload,
ensureImageLoaded,
isImageLoaded,
getDeckPreloadStatus: () => emitDeckPreloadStatus(),
diff --git a/app/data-service.js b/app/data-service.js
index e00877b..6459b42 100644
--- a/app/data-service.js
+++ b/app/data-service.js
@@ -102,8 +102,22 @@
pluto: "Pluto"
};
- function buildRequestHeaders() {
- const apiKey = getApiKey();
+ function resolveConnectionSettings(connectionSettings = null) {
+ if (!connectionSettings || typeof connectionSettings !== "object") {
+ return {
+ apiBaseUrl: getApiBaseUrl(),
+ apiKey: getApiKey()
+ };
+ }
+
+ return {
+ apiBaseUrl: normalizeApiBaseUrl(connectionSettings.apiBaseUrl),
+ apiKey: String(connectionSettings.apiKey || "").trim()
+ };
+ }
+
+ function buildRequestHeaders(connectionSettings = null) {
+ const { apiKey } = resolveConnectionSettings(connectionSettings);
return apiKey
? {
"x-api-key": apiKey
@@ -172,8 +186,8 @@
.join("/");
}
- function buildApiUrl(path, query = {}) {
- const apiBaseUrl = getApiBaseUrl();
+ function buildApiUrl(path, query = {}, connectionSettings = null) {
+ const { apiBaseUrl } = resolveConnectionSettings(connectionSettings);
if (!apiBaseUrl) {
return "";
}
@@ -569,8 +583,9 @@
}));
}
- async function probeConnection() {
- const apiBaseUrl = getApiBaseUrl();
+ async function probeConnection(connectionSettings = null) {
+ const resolvedConnection = resolveConnectionSettings(connectionSettings);
+ const apiBaseUrl = resolvedConnection.apiBaseUrl;
if (!apiBaseUrl) {
return {
ok: false,
@@ -580,11 +595,11 @@
}
const requestOptions = {
- headers: buildRequestHeaders()
+ headers: buildRequestHeaders(resolvedConnection)
};
try {
- const healthResponse = await fetch(buildApiUrl("/api/v1/health"), requestOptions);
+ const healthResponse = await fetch(buildApiUrl("/api/v1/health", {}, resolvedConnection), requestOptions);
if (!healthResponse.ok) {
return {
ok: false,
@@ -594,7 +609,7 @@
}
const health = await healthResponse.json().catch(() => null);
- const protectedResponse = await fetch(buildApiUrl("/api/v1/decks/options"), requestOptions);
+ const protectedResponse = await fetch(buildApiUrl("/api/v1/decks/options", {}, resolvedConnection), requestOptions);
if (protectedResponse.status === 401 || protectedResponse.status === 403) {
return {
diff --git a/app/styles.css b/app/styles.css
index 3e6436e..8420310 100644
--- a/app/styles.css
+++ b/app/styles.css
@@ -792,7 +792,7 @@
}
.tarot-detail-top {
display: grid;
- grid-template-columns: 150px minmax(0, 1fr);
+ grid-template-columns: minmax(0, 1fr);
gap: 16px;
align-items: start;
}
@@ -866,6 +866,93 @@
text-transform: uppercase;
letter-spacing: 0.04em;
}
+ .tarot-deck-gallery-card {
+ grid-column: 1 / -1;
+ }
+ .tarot-deck-gallery {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
+ gap: 10px;
+ }
+ .tarot-deck-variant {
+ display: grid;
+ gap: 8px;
+ align-content: start;
+ width: 100%;
+ padding: 8px;
+ border: 1px solid #3f3f46;
+ border-radius: 10px;
+ background: #18181b;
+ color: #e4e4e7;
+ cursor: pointer;
+ text-align: left;
+ transition: background 120ms, border-color 120ms, transform 120ms;
+ }
+ .tarot-deck-variant:hover {
+ background: #27272a;
+ border-color: #52525b;
+ transform: translateY(-1px);
+ }
+ .tarot-deck-variant.is-active {
+ border-color: #a5b4fc;
+ box-shadow: inset 0 0 0 1px rgba(165, 180, 252, 0.28);
+ }
+ .tarot-deck-variant-image {
+ width: 100%;
+ aspect-ratio: 2 / 3;
+ object-fit: contain;
+ object-position: center;
+ display: block;
+ padding: 4px;
+ box-sizing: border-box;
+ border-radius: 8px;
+ border: 1px solid #3f3f46;
+ background: #09090b;
+ }
+ .tarot-deck-variant-label {
+ display: grid;
+ gap: 2px;
+ font-size: 12px;
+ line-height: 1.3;
+ }
+ .tarot-deck-variant-deck {
+ font-weight: 600;
+ color: #f4f4f5;
+ }
+ .tarot-deck-variant-name {
+ color: #a1a1aa;
+ }
+ .tarot-deck-variant-active {
+ color: #a5b4fc;
+ font-size: 11px;
+ letter-spacing: 0.03em;
+ text-transform: uppercase;
+ }
+ @media (max-width: 720px) {
+ .tarot-deck-gallery {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+ }
+
+ .tarot-deck-variant {
+ padding: 6px;
+ gap: 6px;
+ }
+
+ .tarot-deck-variant-label {
+ font-size: 11px;
+ }
+
+ .tarot-deck-variant-active {
+ font-size: 10px;
+ }
+ }
+
+ @media (max-width: 420px) {
+ .tarot-deck-gallery {
+ grid-template-columns: minmax(0, 1fr);
+ }
+ }
.tarot-keywords {
display: flex;
flex-wrap: wrap;
@@ -3190,6 +3277,43 @@
line-height: 1.45;
color: #e4e4e7;
}
+ .detail-sequence-nav {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 12px;
+ }
+ .detail-sequence-btn {
+ min-height: 34px;
+ padding: 7px 12px;
+ border-radius: 999px;
+ border: 1px solid #3f3f46;
+ background: #111118;
+ color: #f4f4f5;
+ cursor: pointer;
+ font-size: 12px;
+ line-height: 1;
+ transition: background 120ms, border-color 120ms, color 120ms;
+ }
+ .detail-sequence-btn:hover {
+ background: #27272a;
+ border-color: #52525b;
+ }
+ .detail-sequence-btn:disabled {
+ opacity: 0.45;
+ cursor: default;
+ }
+ .detail-sequence-btn:disabled:hover {
+ background: #111118;
+ border-color: #3f3f46;
+ }
+ .detail-sequence-position {
+ min-width: 78px;
+ color: #a1a1aa;
+ font-size: 12px;
+ line-height: 1.2;
+ }
.planet-meta-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
@@ -3520,6 +3644,34 @@
}
#kabbalah-section[hidden] { display: none; }
+ #kabbalah-worlds-section {
+ height: calc(100vh - 61px);
+ background: #18181b;
+ box-sizing: border-box;
+ overflow: hidden;
+ }
+ #kabbalah-worlds-section[hidden] { display: none; }
+
+ #kabbalah-paths-section {
+ height: calc(100vh - 61px);
+ background: #18181b;
+ box-sizing: border-box;
+ overflow: hidden;
+ }
+ #kabbalah-paths-section[hidden] { display: none; }
+
+ #kabbalah-cross-section {
+ height: calc(100vh - 61px);
+ background: #18181b;
+ box-sizing: border-box;
+ overflow: hidden;
+ }
+ #kabbalah-cross-section[hidden] { display: none; }
+
+ .kab-browser-intro {
+ padding: 0 12px 8px;
+ }
+
#kabbalah-tree-section {
height: calc(100vh - 61px);
background: #18181b;
@@ -5692,6 +5844,14 @@
gap: 10px;
}
+ .detail-sequence-nav {
+ width: 100%;
+ }
+
+ .detail-sequence-position {
+ min-width: 0;
+ }
+
.alpha-text-controls--heading {
grid-template-columns: 1fr;
}
diff --git a/app/ui-calendar.js b/app/ui-calendar.js
index 34bf133..71743ee 100644
--- a/app/ui-calendar.js
+++ b/app/ui-calendar.js
@@ -63,6 +63,7 @@
hebrewById: new Map(),
dayLinksCache: new Map()
};
+ let detailNavigator = null;
const TAROT_TRUMP_NUMBER_BY_NAME = {
"the fool": 0,
@@ -200,6 +201,7 @@
function getElements() {
return {
+ sectionEl: document.getElementById("calendar-section"),
monthListEl: document.getElementById("calendar-month-list"),
monthCountEl: document.getElementById("calendar-month-count"),
listTitleEl: document.getElementById("calendar-list-title"),
@@ -210,6 +212,9 @@
searchClearEl: document.getElementById("calendar-search-clear"),
detailNameEl: document.getElementById("calendar-detail-name"),
detailSubEl: document.getElementById("calendar-detail-sub"),
+ detailPrevEl: document.getElementById("calendar-detail-prev"),
+ detailPositionEl: document.getElementById("calendar-detail-position"),
+ detailNextEl: document.getElementById("calendar-detail-next"),
detailBodyEl: document.getElementById("calendar-detail-body")
};
}
@@ -523,6 +528,66 @@
function renderDetail(elements) {
calendarDetailUi.renderDetail?.(elements);
+ syncDetailNavigation(elements);
+ }
+
+ function getMonthSequenceState() {
+ const total = state.filteredMonths.length;
+ const currentIndex = state.filteredMonths.findIndex((month) => month.id === state.selectedMonthId);
+
+ return {
+ total,
+ currentIndex,
+ previousId: currentIndex > 0 ? state.filteredMonths[currentIndex - 1].id : "",
+ nextId: currentIndex >= 0 && currentIndex < total - 1 ? state.filteredMonths[currentIndex + 1].id : ""
+ };
+ }
+
+ function getDetailNavigator() {
+ if (detailNavigator || typeof window.TarotSequenceNav?.createSequenceNavigator !== "function") {
+ return detailNavigator;
+ }
+
+ detailNavigator = window.TarotSequenceNav.createSequenceNavigator({
+ getElements,
+ isActive: (elements) => Boolean(elements?.sectionEl && elements.sectionEl.hidden === false),
+ getSequenceState: getMonthSequenceState,
+ getPrevButton: (elements) => elements?.detailPrevEl,
+ getNextButton: (elements) => elements?.detailNextEl,
+ getPositionEl: (elements) => elements?.detailPositionEl,
+ formatPositionText: ({ total, currentIndex }) => {
+ if (total > 0 && currentIndex >= 0) {
+ const suffix = state.searchQuery ? " shown" : "";
+ return `${currentIndex + 1} of ${total}${suffix}`;
+ }
+
+ return total > 0 ? `${total} months` : "No months";
+ },
+ selectTarget: (targetId, elements) => selectByMonthId(targetId, elements) !== false,
+ afterSelect: (targetId, elements) => {
+ scrollMonthIntoView(targetId, elements);
+ }
+ });
+
+ return detailNavigator;
+ }
+
+ function syncDetailNavigation(elements = getElements()) {
+ getDetailNavigator()?.sync(elements);
+ }
+
+ function scrollMonthIntoView(monthId, elements = getElements()) {
+ elements?.monthListEl
+ ?.querySelector(`[data-month-id="${monthId}"]`)
+ ?.scrollIntoView({ block: "nearest" });
+ }
+
+ function selectAdjacentMonth(offset, elements = getElements()) {
+ return getDetailNavigator()?.step(offset, elements) === true;
+ }
+
+ function bindKeyboardNavigation(elements) {
+ getDetailNavigator()?.bind(elements);
}
function applySearchFilter(elements) {
@@ -613,6 +678,10 @@
}
}
+ function bindDetailNavigation(elements) {
+ getDetailNavigator()?.bind(elements);
+ }
+
function loadCalendarType(calendarId, elements) {
const months = state.calendarData[calendarId];
if (!Array.isArray(months)) {
@@ -745,6 +814,8 @@
bindYearInput(elements);
bindSearchInput(elements);
bindCalendarTypeSelect(elements);
+ bindDetailNavigation(elements);
+ bindKeyboardNavigation(elements);
}
applySearchFilter(elements);
diff --git a/app/ui-holidays.js b/app/ui-holidays.js
index ac7173a..3fb88d1 100644
--- a/app/ui-holidays.js
+++ b/app/ui-holidays.js
@@ -42,6 +42,7 @@
hebrewById: new Map(),
calendarData: {}
};
+ let detailNavigator = null;
const TAROT_TRUMP_NUMBER_BY_NAME = {
"the fool": 0,
@@ -93,6 +94,7 @@
function getElements() {
return {
+ sectionEl: document.getElementById("holiday-section"),
sourceSelectEl: document.getElementById("holiday-source-select"),
yearInputEl: document.getElementById("holiday-year-input"),
searchInputEl: document.getElementById("holiday-search-input"),
@@ -101,6 +103,9 @@
listEl: document.getElementById("holiday-list"),
detailNameEl: document.getElementById("holiday-detail-name"),
detailSubEl: document.getElementById("holiday-detail-sub"),
+ detailPrevEl: document.getElementById("holiday-detail-prev"),
+ detailPositionEl: document.getElementById("holiday-detail-position"),
+ detailNextEl: document.getElementById("holiday-detail-next"),
detailBodyEl: document.getElementById("holiday-detail-body")
};
}
@@ -225,6 +230,7 @@
detailNameEl.textContent = "--";
detailSubEl.textContent = "Select a holiday to explore";
detailBodyEl.innerHTML = "";
+ syncDetailNavigation(elements);
return;
}
@@ -232,6 +238,66 @@
detailSubEl.textContent = `${holidayDataUi.calendarLabel(holiday?.calendarId)} - ${holidayDataUi.monthLabelForCalendar(state.calendarData, holiday?.calendarId, holiday?.monthId)}`;
detailBodyEl.innerHTML = renderHolidayDetail(holiday);
attachNavHandlers(detailBodyEl);
+ syncDetailNavigation(elements);
+ }
+
+ function getHolidaySequenceState() {
+ const total = state.filteredHolidays.length;
+ const currentIndex = state.filteredHolidays.findIndex((holiday) => holiday.id === state.selectedHolidayId);
+
+ return {
+ total,
+ currentIndex,
+ previousId: currentIndex > 0 ? state.filteredHolidays[currentIndex - 1].id : "",
+ nextId: currentIndex >= 0 && currentIndex < total - 1 ? state.filteredHolidays[currentIndex + 1].id : ""
+ };
+ }
+
+ function getDetailNavigator() {
+ if (detailNavigator || typeof window.TarotSequenceNav?.createSequenceNavigator !== "function") {
+ return detailNavigator;
+ }
+
+ detailNavigator = window.TarotSequenceNav.createSequenceNavigator({
+ getElements,
+ isActive: (elements) => Boolean(elements?.sectionEl && elements.sectionEl.hidden === false),
+ getSequenceState: getHolidaySequenceState,
+ getPrevButton: (elements) => elements?.detailPrevEl,
+ getNextButton: (elements) => elements?.detailNextEl,
+ getPositionEl: (elements) => elements?.detailPositionEl,
+ formatPositionText: ({ total, currentIndex }) => {
+ if (total > 0 && currentIndex >= 0) {
+ const suffix = state.searchQuery ? " shown" : "";
+ return `${currentIndex + 1} of ${total}${suffix}`;
+ }
+
+ return total > 0 ? `${total} holidays` : "No holidays";
+ },
+ selectTarget: (targetId, elements) => selectByHolidayId(targetId, elements) !== false,
+ afterSelect: (targetId, elements) => {
+ scrollHolidayIntoView(targetId, elements);
+ }
+ });
+
+ return detailNavigator;
+ }
+
+ function syncDetailNavigation(elements = getElements()) {
+ getDetailNavigator()?.sync(elements);
+ }
+
+ function scrollHolidayIntoView(holidayId, elements = getElements()) {
+ elements?.listEl
+ ?.querySelector(`[data-holiday-id="${holidayId}"]`)
+ ?.scrollIntoView({ block: "nearest" });
+ }
+
+ function selectAdjacentHoliday(offset, elements = getElements()) {
+ return getDetailNavigator()?.step(offset, elements) === true;
+ }
+
+ function bindKeyboardNavigation(elements) {
+ getDetailNavigator()?.bind(elements);
}
function applyFilters(elements) {
@@ -307,6 +373,8 @@
elements.searchInputEl.focus();
});
}
+
+ bindKeyboardNavigation(elements);
}
function attachNavHandlers(detailBodyEl) {
diff --git a/app/ui-kabbalah-detail.js b/app/ui-kabbalah-detail.js
index 6a62cdb..5a5b2d9 100644
--- a/app/ui-kabbalah-detail.js
+++ b/app/ui-kabbalah-detail.js
@@ -309,6 +309,62 @@
return card;
}
+ function renderWorldLayerDetail(context) {
+ const { worldLayer, tree, elements } = context;
+ if (!worldLayer || !elements?.detailBodyEl) {
+ return;
+ }
+
+ elements.detailNameEl.textContent = String(worldLayer.world || "Qabalistic World");
+ elements.detailSubEl.textContent = [
+ worldLayer.slot ? `${worldLayer.slot}: ${worldLayer.letterChar || ""}`.trim() : "",
+ worldLayer.soulLayer
+ ].filter(Boolean).join(" · ");
+
+ elements.detailBodyEl.innerHTML = "";
+ elements.detailBodyEl.appendChild(metaCard(
+ "World Layer",
+ `${worldLayer.worldLayer || "—"}${worldLayer.worldDescription ? ` · ${worldLayer.worldDescription}` : ""}`,
+ true
+ ));
+ elements.detailBodyEl.appendChild(metaCard(
+ "Soul Layer",
+ `${worldLayer.soulLayer || "—"}${worldLayer.soulTitle ? ` — ${worldLayer.soulTitle}` : ""}${worldLayer.soulDescription ? `: ${worldLayer.soulDescription}` : ""}`,
+ true
+ ));
+
+ const linkedParts = [];
+ const hebrewLetterId = context.resolveHebrewLetterId(worldLayer.hebrewToken);
+ if (hebrewLetterId) {
+ linkedParts.push(createInlineEventLink(
+ `${worldLayer.letterChar || ""} ${worldLayer.hebrewToken || ""}`.replace(/\s+/g, " ").trim(),
+ "nav:alphabet",
+ {
+ alphabet: "hebrew",
+ hebrewLetterId
+ }
+ ));
+ }
+
+ const linkedPath = context.findPathByHebrewToken(tree, worldLayer.hebrewToken);
+ if (linkedPath?.pathNumber != null) {
+ if (linkedParts.length) {
+ linkedParts.push(" · ");
+ }
+ linkedParts.push(createInlineEventLink(
+ `Path ${linkedPath.pathNumber}`,
+ "nav:kabbalah-path",
+ { pathNo: Number(linkedPath.pathNumber) }
+ ));
+ }
+
+ if (linkedParts.length) {
+ elements.detailBodyEl.appendChild(metaCard("Linked Attributions", inlineValue(linkedParts)));
+ }
+
+ elements.detailBodyEl.appendChild(buildFourWorldsCard(tree, worldLayer.hebrewToken, context));
+ }
+
function splitCorrespondenceNames(value) {
return String(value || "")
.split(/,|;|·|\/|\bor\b|\band\b|\+/i)
@@ -419,12 +475,14 @@
function renderSephiraDetail(context) {
const { seph, tree, elements } = context;
- elements.detailNameEl.textContent = `${seph.number} · ${seph.name}`;
+ const displayNumber = String(seph.displayNumber || seph.number || "").trim();
+ elements.detailNameEl.textContent = displayNumber
+ ? `${displayNumber} · ${seph.name}`
+ : `${seph.name}`;
elements.detailSubEl.textContent =
[seph.nameHebrew, seph.translation, seph.planet].filter(Boolean).join(" · ");
elements.detailBodyEl.innerHTML = "";
- elements.detailBodyEl.appendChild(buildFourWorldsCard(tree, "", context));
elements.detailBodyEl.appendChild(buildPlanetLuminaryCard(seph.planet, context));
elements.detailBodyEl.appendChild(metaCard("Intelligence", seph.intelligence));
elements.detailBodyEl.appendChild(buildTarotAttributionCard(seph.tarot));
@@ -484,7 +542,6 @@
elements.detailSubEl.textContent = [path.tarot?.card, astro].filter(Boolean).join(" · ");
elements.detailBodyEl.innerHTML = "";
- elements.detailBodyEl.appendChild(buildFourWorldsCard(tree, context.activeHebrewToken, context));
elements.detailBodyEl.appendChild(buildConnectsCard(path, fromName, toName));
elements.detailBodyEl.appendChild(buildHebrewLetterCard(letter, context));
elements.detailBodyEl.appendChild(buildAstrologyCard(path.astrology, context));
@@ -543,6 +600,7 @@
}
window.KabbalahDetailUi = {
+ renderWorldLayerDetail,
renderSephiraDetail,
renderPathDetail,
renderRoseLandingIntro
diff --git a/app/ui-kabbalah.js b/app/ui-kabbalah.js
index f1d0edb..b2d96d5 100644
--- a/app/ui-kabbalah.js
+++ b/app/ui-kabbalah.js
@@ -57,11 +57,18 @@
showPathLetters: true,
showPathNumbers: true,
showPathTarotCards: false,
+ selectedWorldLayerIndex: 0,
selectedSephiraNumber: null,
selectedPathNumber: null,
+ activeNodeKey: "",
exportInProgress: false,
exportFormat: ""
};
+ let detailNavigator = null;
+ let browserDetailNavigator = null;
+ let worldsDetailNavigator = null;
+ let pathsDetailNavigator = null;
+ let roseDetailNavigator = null;
const TREE_EXPORT_FORMATS = {
webp: {
mimeType: "image/webp",
@@ -70,6 +77,18 @@
}
};
const TREE_EXPORT_BACKGROUND = "#02030a";
+ const DAATH_SEPHIRA = Object.freeze({
+ number: 0,
+ displayNumber: "Daath",
+ sephiraId: "daath",
+ name: "Daath",
+ nameHebrew: "דעת",
+ translation: "Knowledge",
+ planet: "Abyss / Hidden Sephirah",
+ intelligence: "Invisible Sephirah of Knowledge",
+ tarot: "No fixed trump attribution",
+ description: "Daath is the hidden or invisible sephirah placed beneath Kether and between Chokmah and Binah. In Hermetic Qabalah it often marks the threshold of the Abyss rather than a stable emanation like the ten manifest sephiroth."
+ });
const kabbalahDetailUi = window.KabbalahDetailUi || {};
const kabbalahViewsUi = window.KabbalahViewsUi || {};
@@ -263,10 +282,42 @@
// ─── element references ─────────────────────────────────────────────────────
function getElements() {
return {
+ browserSectionEl: document.getElementById("kabbalah-section"),
+ browserListEl: document.getElementById("kab-browser-list"),
+ browserCountEl: document.getElementById("kab-browser-count"),
+ browserDetailNameEl: document.getElementById("kab-browser-detail-name"),
+ browserDetailSubEl: document.getElementById("kab-browser-detail-sub"),
+ browserDetailBodyEl: document.getElementById("kab-browser-detail-body"),
+ browserDetailPrevEl: document.getElementById("kab-browser-detail-prev"),
+ browserDetailPositionEl: document.getElementById("kab-browser-detail-position"),
+ browserDetailNextEl: document.getElementById("kab-browser-detail-next"),
+ worldsSectionEl: document.getElementById("kabbalah-worlds-section"),
+ worldsListEl: document.getElementById("kab-worlds-list"),
+ worldsCountEl: document.getElementById("kab-worlds-count"),
+ worldsDetailNameEl: document.getElementById("kab-worlds-detail-name"),
+ worldsDetailSubEl: document.getElementById("kab-worlds-detail-sub"),
+ worldsDetailBodyEl: document.getElementById("kab-worlds-detail-body"),
+ worldsDetailPrevEl: document.getElementById("kab-worlds-detail-prev"),
+ worldsDetailPositionEl: document.getElementById("kab-worlds-detail-position"),
+ worldsDetailNextEl: document.getElementById("kab-worlds-detail-next"),
+ pathsSectionEl: document.getElementById("kabbalah-paths-section"),
+ pathsListEl: document.getElementById("kab-paths-list"),
+ pathsCountEl: document.getElementById("kab-paths-count"),
+ pathsDetailNameEl: document.getElementById("kab-paths-detail-name"),
+ pathsDetailSubEl: document.getElementById("kab-paths-detail-sub"),
+ pathsDetailBodyEl: document.getElementById("kab-paths-detail-body"),
+ pathsDetailPrevEl: document.getElementById("kab-paths-detail-prev"),
+ pathsDetailPositionEl: document.getElementById("kab-paths-detail-position"),
+ pathsDetailNextEl: document.getElementById("kab-paths-detail-next"),
+ crossSectionEl: document.getElementById("kabbalah-cross-section"),
+ sectionEl: document.getElementById("kabbalah-tree-section"),
treeContainerEl: document.getElementById("kab-tree-container"),
detailNameEl: document.getElementById("kab-detail-name"),
detailSubEl: document.getElementById("kab-detail-sub"),
detailBodyEl: document.getElementById("kab-detail-body"),
+ detailPrevEl: document.getElementById("kab-detail-prev"),
+ detailPositionEl: document.getElementById("kab-detail-position"),
+ detailNextEl: document.getElementById("kab-detail-next"),
pathLetterToggleEl: document.getElementById("kab-path-letter-toggle"),
pathNumberToggleEl: document.getElementById("kab-path-number-toggle"),
pathTarotToggleEl: document.getElementById("kab-path-tarot-toggle"),
@@ -275,6 +326,21 @@
roseDetailNameEl: document.getElementById("kab-rose-detail-name"),
roseDetailSubEl: document.getElementById("kab-rose-detail-sub"),
roseDetailBodyEl: document.getElementById("kab-rose-detail-body"),
+ roseDetailPrevEl: document.getElementById("kab-rose-detail-prev"),
+ roseDetailPositionEl: document.getElementById("kab-rose-detail-position"),
+ roseDetailNextEl: document.getElementById("kab-rose-detail-next"),
+ };
+ }
+
+ function getTreeDetailElements(elements) {
+ if (!elements) {
+ return null;
+ }
+
+ return {
+ detailNameEl: elements.detailNameEl,
+ detailSubEl: elements.detailSubEl,
+ detailBodyEl: elements.detailBodyEl
};
}
@@ -290,6 +356,696 @@
};
}
+ function getPathDetailElements(elements) {
+ if (!elements) {
+ return null;
+ }
+
+ return {
+ detailNameEl: elements.pathsDetailNameEl,
+ detailSubEl: elements.pathsDetailSubEl,
+ detailBodyEl: elements.pathsDetailBodyEl
+ };
+ }
+
+ function getBrowserDetailElements(elements) {
+ if (!elements) {
+ return null;
+ }
+
+ return {
+ detailNameEl: elements.browserDetailNameEl,
+ detailSubEl: elements.browserDetailSubEl,
+ detailBodyEl: elements.browserDetailBodyEl
+ };
+ }
+
+ function getWorldDetailElements(elements) {
+ if (!elements) {
+ return null;
+ }
+
+ return {
+ detailNameEl: elements.worldsDetailNameEl,
+ detailSubEl: elements.worldsDetailSubEl,
+ detailBodyEl: elements.worldsDetailBodyEl
+ };
+ }
+
+ function normalizeDetailElements(elements) {
+ if (!elements) {
+ return null;
+ }
+
+ return {
+ detailNameEl: elements.detailNameEl || null,
+ detailSubEl: elements.detailSubEl || null,
+ detailBodyEl: elements.detailBodyEl || null
+ };
+ }
+
+ function getDetailRenderTargets(primaryElements) {
+ const elements = getElements();
+ const candidates = [
+ normalizeDetailElements(primaryElements),
+ getTreeDetailElements(elements),
+ getBrowserDetailElements(elements)
+ ];
+ const seen = new Set();
+
+ return candidates.filter((target) => {
+ const bodyEl = target?.detailBodyEl;
+ if (!(bodyEl instanceof Element) || seen.has(bodyEl)) {
+ return false;
+ }
+
+ seen.add(bodyEl);
+ return true;
+ });
+ }
+
+ function hasFiniteSelectionNumber(value) {
+ if (value === null || value === undefined || value === "") {
+ return false;
+ }
+
+ return Number.isFinite(Number(value));
+ }
+
+ function isDaathToken(value) {
+ return String(value || "").trim().toLowerCase() === "daath";
+ }
+
+ function buildSephiraKey(value) {
+ if (isDaathToken(value) || Number(value) === 0) {
+ return "sephira:daath";
+ }
+
+ return hasFiniteSelectionNumber(value) ? `sephira:${Number(value)}` : "";
+ }
+
+ function buildPathKey(value) {
+ return hasFiniteSelectionNumber(value) ? `path:${Number(value)}` : "";
+ }
+
+ function buildWorldKey(value) {
+ return hasFiniteSelectionNumber(value) ? `world:${Number(value)}` : "";
+ }
+
+ function getSelectedSephiraKey() {
+ return buildSephiraKey(state.selectedSephiraNumber);
+ }
+
+ function getSelectedPathKey() {
+ return buildPathKey(state.selectedPathNumber);
+ }
+
+ function getSelectedWorldKey() {
+ return buildWorldKey(state.selectedWorldLayerIndex);
+ }
+
+ function getSephiraByNumber(number) {
+ if (isDaathToken(number) || Number(number) === 0) {
+ return DAATH_SEPHIRA;
+ }
+
+ if (!state.tree) {
+ return null;
+ }
+
+ return state.tree.sephiroth.find((entry) => Number(entry?.number) === Number(number)) || null;
+ }
+
+ function getPathByNumber(number) {
+ if (!state.tree) {
+ return null;
+ }
+
+ return state.tree.paths.find((entry) => Number(entry?.pathNumber) === Number(number)) || null;
+ }
+
+ function getWorldLayerByIndex(index) {
+ if (!Array.isArray(state.fourWorldLayers)) {
+ return null;
+ }
+
+ const targetIndex = Number(index);
+ return Number.isInteger(targetIndex) && targetIndex >= 0 && targetIndex < state.fourWorldLayers.length
+ ? state.fourWorldLayers[targetIndex]
+ : null;
+ }
+
+ function getSephirotSequenceEntries() {
+ if (!state.tree) {
+ return [];
+ }
+
+ const entries = Array.isArray(state.tree.sephiroth)
+ ? [...state.tree.sephiroth]
+ .sort((left, right) => Number(left?.number || 0) - Number(right?.number || 0))
+ .map((entry) => ({
+ key: buildSephiraKey(entry?.number),
+ type: "sephira",
+ number: Number(entry?.number)
+ }))
+ : [];
+
+ const daathEntry = {
+ key: buildSephiraKey(0),
+ type: "sephira",
+ number: 0
+ };
+ const insertIndex = entries.findIndex((entry) => entry.number === 3);
+ if (insertIndex >= 0) {
+ entries.splice(insertIndex + 1, 0, daathEntry);
+ } else {
+ entries.push(daathEntry);
+ }
+
+ return entries;
+ }
+
+ function getPathSequenceEntries() {
+ if (!state.tree) {
+ return [];
+ }
+
+ return Array.isArray(state.tree.paths)
+ ? [...state.tree.paths]
+ .sort((left, right) => Number(left?.pathNumber || 0) - Number(right?.pathNumber || 0))
+ .map((entry) => ({
+ key: buildPathKey(entry?.pathNumber),
+ type: "path",
+ number: Number(entry?.pathNumber)
+ }))
+ : [];
+ }
+
+ function getWorldSequenceEntries() {
+ return Array.isArray(state.fourWorldLayers)
+ ? state.fourWorldLayers.map((layer, index) => ({
+ key: buildWorldKey(index),
+ type: "world",
+ index,
+ world: String(layer?.world || "")
+ }))
+ : [];
+ }
+
+ function getNodeSequenceEntries() {
+ if (!state.tree) {
+ return [];
+ }
+
+ const sephiroth = Array.isArray(state.tree.sephiroth)
+ ? [...state.tree.sephiroth]
+ .sort((left, right) => Number(left?.number || 0) - Number(right?.number || 0))
+ .map((entry) => ({
+ key: buildSephiraKey(entry?.number),
+ type: "sephira",
+ number: Number(entry?.number)
+ }))
+ : [];
+
+ const paths = getPathSequenceEntries();
+
+ return [...sephiroth, ...paths];
+ }
+
+ function getSelectedNodeKey() {
+ return String(state.activeNodeKey || "").trim() || getSelectedPathKey() || getSelectedSephiraKey();
+ }
+
+ function buildSequenceState(entries, currentKey) {
+ const currentIndex = entries.findIndex((entry) => entry.key === currentKey);
+
+ return {
+ total: entries.length,
+ currentIndex,
+ previousKey: currentIndex > 0 ? entries[currentIndex - 1].key : "",
+ nextKey: currentIndex >= 0 && currentIndex < entries.length - 1 ? entries[currentIndex + 1].key : ""
+ };
+ }
+
+ function getNodeSequenceState() {
+ return buildSequenceState(getNodeSequenceEntries(), getSelectedNodeKey());
+ }
+
+ function getSephirotSequenceState() {
+ return buildSequenceState(getSephirotSequenceEntries(), getSelectedSephiraKey());
+ }
+
+ function getPathSequenceState() {
+ return buildSequenceState(getPathSequenceEntries(), getSelectedPathKey());
+ }
+
+ function getWorldSequenceState() {
+ return buildSequenceState(getWorldSequenceEntries(), getSelectedWorldKey());
+ }
+
+ function getBrowserListItemMeta(entry) {
+ const seph = getSephiraByNumber(entry.number);
+ const displayNumber = String(seph?.displayNumber || entry.number || "").trim();
+
+ return {
+ title: displayNumber ? `${displayNumber} · ${seph?.name || "Sephirah"}` : `${seph?.name || "Sephirah"}`,
+ meta: [seph?.nameHebrew, seph?.translation, seph?.planet].filter(Boolean).join(" · ") || "Sephirah"
+ };
+ }
+
+ function getWorldListItemMeta(entry) {
+ const layer = getWorldLayerByIndex(entry.index);
+
+ return {
+ title: String(layer?.world || `World ${entry.index + 1}`),
+ meta: [
+ layer?.slot ? `${layer.slot}: ${layer.letterChar || ""}`.trim() : "",
+ layer?.soulLayer
+ ].filter(Boolean).join(" · ") || "Qabalistic World"
+ };
+ }
+
+ function getPathListItemMeta(entry) {
+ const path = getPathByNumber(entry.number);
+ const fromName = getSephiraByNumber(path?.connects?.from)?.name || `Node ${path?.connects?.from || "?"}`;
+ const toName = getSephiraByNumber(path?.connects?.to)?.name || `Node ${path?.connects?.to || "?"}`;
+ const letterLabel = [path?.hebrewLetter?.char, path?.hebrewLetter?.transliteration].filter(Boolean).join(" ").trim();
+
+ return {
+ title: `Path ${entry.number}${letterLabel ? ` · ${letterLabel}` : ""}`,
+ meta: [
+ `${fromName} -> ${toName}`,
+ String(path?.tarot?.card || "").trim()
+ ].filter(Boolean).join(" · ") || "Path"
+ };
+ }
+
+ function syncBrowserListSelection(elements = getElements()) {
+ if (!elements?.browserListEl) {
+ return;
+ }
+
+ const selectedKey = getSelectedSephiraKey();
+ elements.browserListEl.querySelectorAll(".planet-list-item[data-node-key]").forEach((button) => {
+ const isSelected = button.dataset.nodeKey === selectedKey;
+ button.classList.toggle("is-selected", isSelected);
+ button.setAttribute("aria-selected", isSelected ? "true" : "false");
+ });
+ }
+
+ function renderBrowserList(elements = getElements()) {
+ if (!elements?.browserListEl) {
+ return;
+ }
+
+ const entries = getSephirotSequenceEntries();
+ elements.browserListEl.innerHTML = "";
+
+ entries.forEach((entry) => {
+ const button = document.createElement("button");
+ const { title, meta } = getBrowserListItemMeta(entry);
+ button.type = "button";
+ button.className = "planet-list-item";
+ button.setAttribute("role", "option");
+ button.dataset.nodeKey = entry.key;
+ button.innerHTML = `
+
${title}
+ ${meta}
+ `;
+ elements.browserListEl.appendChild(button);
+ });
+
+ if (elements.browserCountEl) {
+ elements.browserCountEl.textContent = `${entries.length} sephiroth`;
+ }
+
+ syncBrowserListSelection(elements);
+ }
+
+ function syncWorldListSelection(elements = getElements()) {
+ if (!elements?.worldsListEl) {
+ return;
+ }
+
+ const selectedKey = getSelectedWorldKey();
+ elements.worldsListEl.querySelectorAll(".planet-list-item[data-world-key]").forEach((button) => {
+ const isSelected = button.dataset.worldKey === selectedKey;
+ button.classList.toggle("is-selected", isSelected);
+ button.setAttribute("aria-selected", isSelected ? "true" : "false");
+ });
+ }
+
+ function syncPathsListSelection(elements = getElements()) {
+ if (!elements?.pathsListEl) {
+ return;
+ }
+
+ const selectedKey = getSelectedPathKey();
+ elements.pathsListEl.querySelectorAll(".planet-list-item[data-path-key]").forEach((button) => {
+ const isSelected = button.dataset.pathKey === selectedKey;
+ button.classList.toggle("is-selected", isSelected);
+ button.setAttribute("aria-selected", isSelected ? "true" : "false");
+ });
+ }
+
+ function renderWorldsList(elements = getElements()) {
+ if (!elements?.worldsListEl) {
+ return;
+ }
+
+ const entries = getWorldSequenceEntries();
+ elements.worldsListEl.innerHTML = "";
+
+ entries.forEach((entry) => {
+ const button = document.createElement("button");
+ const { title, meta } = getWorldListItemMeta(entry);
+ button.type = "button";
+ button.className = "planet-list-item";
+ button.setAttribute("role", "option");
+ button.dataset.worldKey = entry.key;
+ button.innerHTML = `
+ ${title}
+ ${meta}
+ `;
+ elements.worldsListEl.appendChild(button);
+ });
+
+ if (elements.worldsCountEl) {
+ elements.worldsCountEl.textContent = `${entries.length} worlds`;
+ }
+
+ syncWorldListSelection(elements);
+ }
+
+ function renderPathsList(elements = getElements()) {
+ if (!elements?.pathsListEl) {
+ return;
+ }
+
+ const entries = getPathSequenceEntries();
+ elements.pathsListEl.innerHTML = "";
+
+ entries.forEach((entry) => {
+ const button = document.createElement("button");
+ const { title, meta } = getPathListItemMeta(entry);
+ button.type = "button";
+ button.className = "planet-list-item";
+ button.setAttribute("role", "option");
+ button.dataset.pathKey = entry.key;
+ button.innerHTML = `
+ ${title}
+ ${meta}
+ `;
+ elements.pathsListEl.appendChild(button);
+ });
+
+ if (elements.pathsCountEl) {
+ elements.pathsCountEl.textContent = `${entries.length} paths`;
+ }
+
+ syncPathsListSelection(elements);
+ }
+
+ function bindBrowserList(elements = getElements()) {
+ if (!elements?.browserListEl || elements.browserListEl.dataset.bound) {
+ return;
+ }
+
+ elements.browserListEl.addEventListener("click", (event) => {
+ const target = event.target instanceof Element
+ ? event.target.closest(".planet-list-item[data-node-key]")
+ : null;
+
+ if (!(target instanceof HTMLButtonElement)) {
+ return;
+ }
+
+ const targetKey = String(target.dataset.nodeKey || "").trim();
+ if (!targetKey) {
+ return;
+ }
+
+ selectNodeBySequenceKey(targetKey, getBrowserDetailElements(getElements()));
+ });
+
+ elements.browserListEl.dataset.bound = "true";
+ }
+
+ function bindWorldList(elements = getElements()) {
+ if (!elements?.worldsListEl || elements.worldsListEl.dataset.bound) {
+ return;
+ }
+
+ elements.worldsListEl.addEventListener("click", (event) => {
+ const target = event.target instanceof Element
+ ? event.target.closest(".planet-list-item[data-world-key]")
+ : null;
+
+ if (!(target instanceof HTMLButtonElement)) {
+ return;
+ }
+
+ const targetKey = String(target.dataset.worldKey || "").trim();
+ if (!targetKey) {
+ return;
+ }
+
+ selectNodeBySequenceKey(targetKey, getWorldDetailElements(getElements()));
+ });
+
+ elements.worldsListEl.dataset.bound = "true";
+ }
+
+ function bindPathsList(elements = getElements()) {
+ if (!elements?.pathsListEl || elements.pathsListEl.dataset.bound) {
+ return;
+ }
+
+ elements.pathsListEl.addEventListener("click", (event) => {
+ const target = event.target instanceof Element
+ ? event.target.closest(".planet-list-item[data-path-key]")
+ : null;
+
+ if (!(target instanceof HTMLButtonElement)) {
+ return;
+ }
+
+ const targetKey = String(target.dataset.pathKey || "").trim();
+ if (!targetKey) {
+ return;
+ }
+
+ selectNodeBySequenceKey(targetKey, getPathDetailElements(getElements()));
+ });
+
+ elements.pathsListEl.dataset.bound = "true";
+ }
+
+ function selectNodeBySequenceKey(targetKey, elements = getElements()) {
+ if (!state.tree) {
+ return false;
+ }
+
+ const [type, rawToken] = String(targetKey || "").split(":");
+
+ if (type === "sephira") {
+ const seph = getSephiraByNumber(isDaathToken(rawToken) ? 0 : Number(rawToken));
+ if (!seph) {
+ return false;
+ }
+
+ renderSephiraDetail(seph, state.tree, elements);
+ return true;
+ }
+
+ if (type === "path") {
+ const path = getPathByNumber(Number(rawToken));
+ if (!path) {
+ return false;
+ }
+
+ renderPathDetail(path, state.tree, elements);
+ return true;
+ }
+
+ if (type === "world") {
+ const worldLayer = getWorldLayerByIndex(Number(rawToken));
+ if (!worldLayer) {
+ return false;
+ }
+
+ renderWorldLayerDetail(worldLayer, Number(rawToken), state.tree, elements);
+ return true;
+ }
+
+ return false;
+ }
+
+ function getDetailNavigator() {
+ if (detailNavigator || typeof window.TarotSequenceNav?.createSequenceNavigator !== "function") {
+ return detailNavigator;
+ }
+
+ detailNavigator = window.TarotSequenceNav.createSequenceNavigator({
+ getElements,
+ isActive: (elements) => Boolean(elements?.sectionEl && elements.sectionEl.hidden === false),
+ getSequenceState: getNodeSequenceState,
+ getPrevButton: (elements) => elements?.detailPrevEl,
+ getNextButton: (elements) => elements?.detailNextEl,
+ getPositionEl: (elements) => elements?.detailPositionEl,
+ formatPositionText: ({ total, currentIndex }) => {
+ if (total > 0 && currentIndex >= 0) {
+ return `${currentIndex + 1} of ${total} nodes`;
+ }
+
+ return total > 0 ? `${total} nodes` : "No nodes";
+ },
+ selectTarget: (targetKey, elements) => selectNodeBySequenceKey(targetKey, elements)
+ });
+
+ return detailNavigator;
+ }
+
+ function getBrowserDetailNavigator() {
+ if (browserDetailNavigator || typeof window.TarotSequenceNav?.createSequenceNavigator !== "function") {
+ return browserDetailNavigator;
+ }
+
+ browserDetailNavigator = window.TarotSequenceNav.createSequenceNavigator({
+ getElements,
+ isActive: (elements) => Boolean(elements?.browserSectionEl && elements.browserSectionEl.hidden === false),
+ getSequenceState: getSephirotSequenceState,
+ getPrevButton: (elements) => elements?.browserDetailPrevEl,
+ getNextButton: (elements) => elements?.browserDetailNextEl,
+ getPositionEl: (elements) => elements?.browserDetailPositionEl,
+ formatPositionText: ({ total, currentIndex }) => {
+ if (total > 0 && currentIndex >= 0) {
+ return `${currentIndex + 1} of ${total} sephiroth`;
+ }
+
+ return total > 0 ? `${total} sephiroth` : "No sephiroth";
+ },
+ selectTarget: (targetKey) => selectNodeBySequenceKey(targetKey, getBrowserDetailElements(getElements()))
+ });
+
+ return browserDetailNavigator;
+ }
+
+ function getPathsDetailNavigator() {
+ if (pathsDetailNavigator || typeof window.TarotSequenceNav?.createSequenceNavigator !== "function") {
+ return pathsDetailNavigator;
+ }
+
+ pathsDetailNavigator = window.TarotSequenceNav.createSequenceNavigator({
+ getElements,
+ isActive: (elements) => Boolean(elements?.pathsSectionEl && elements.pathsSectionEl.hidden === false),
+ getSequenceState: getPathSequenceState,
+ getPrevButton: (elements) => elements?.pathsDetailPrevEl,
+ getNextButton: (elements) => elements?.pathsDetailNextEl,
+ getPositionEl: (elements) => elements?.pathsDetailPositionEl,
+ formatPositionText: ({ total, currentIndex }) => {
+ if (total > 0 && currentIndex >= 0) {
+ return `${currentIndex + 1} of ${total} paths`;
+ }
+
+ return total > 0 ? `${total} paths` : "No paths";
+ },
+ selectTarget: (targetKey) => selectNodeBySequenceKey(targetKey, getPathDetailElements(getElements()))
+ });
+
+ return pathsDetailNavigator;
+ }
+
+ function getRoseDetailNavigator() {
+ if (roseDetailNavigator || typeof window.TarotSequenceNav?.createSequenceNavigator !== "function") {
+ return roseDetailNavigator;
+ }
+
+ roseDetailNavigator = window.TarotSequenceNav.createSequenceNavigator({
+ getElements,
+ isActive: (elements) => Boolean(elements?.crossSectionEl && elements.crossSectionEl.hidden === false),
+ getSequenceState: getPathSequenceState,
+ getPrevButton: (elements) => elements?.roseDetailPrevEl,
+ getNextButton: (elements) => elements?.roseDetailNextEl,
+ getPositionEl: (elements) => elements?.roseDetailPositionEl,
+ formatPositionText: ({ total, currentIndex }) => {
+ if (total > 0 && currentIndex >= 0) {
+ return `${currentIndex + 1} of ${total} paths`;
+ }
+
+ return total > 0 ? `${total} paths` : "No paths";
+ },
+ selectTarget: (targetKey) => selectNodeBySequenceKey(targetKey, getRoseDetailElements(getElements()))
+ });
+
+ return roseDetailNavigator;
+ }
+
+ function getWorldDetailNavigator() {
+ if (worldsDetailNavigator || typeof window.TarotSequenceNav?.createSequenceNavigator !== "function") {
+ return worldsDetailNavigator;
+ }
+
+ worldsDetailNavigator = window.TarotSequenceNav.createSequenceNavigator({
+ getElements,
+ isActive: (elements) => Boolean(elements?.worldsSectionEl && elements.worldsSectionEl.hidden === false),
+ getSequenceState: getWorldSequenceState,
+ getPrevButton: (elements) => elements?.worldsDetailPrevEl,
+ getNextButton: (elements) => elements?.worldsDetailNextEl,
+ getPositionEl: (elements) => elements?.worldsDetailPositionEl,
+ formatPositionText: ({ total, currentIndex }) => {
+ if (total > 0 && currentIndex >= 0) {
+ return `${currentIndex + 1} of ${total} worlds`;
+ }
+
+ return total > 0 ? `${total} worlds` : "No worlds";
+ },
+ selectTarget: (targetKey) => selectNodeBySequenceKey(targetKey, getWorldDetailElements(getElements()))
+ });
+
+ return worldsDetailNavigator;
+ }
+
+ function syncDetailNavigation(elements = getElements()) {
+ getDetailNavigator()?.sync(elements);
+ }
+
+ function syncBrowserDetailNavigation(elements = getElements()) {
+ getBrowserDetailNavigator()?.sync(elements);
+ }
+
+ function syncPathsDetailNavigation(elements = getElements()) {
+ getPathsDetailNavigator()?.sync(elements);
+ }
+
+ function syncRoseDetailNavigation(elements = getElements()) {
+ getRoseDetailNavigator()?.sync(elements);
+ }
+
+ function syncWorldDetailNavigation(elements = getElements()) {
+ getWorldDetailNavigator()?.sync(elements);
+ }
+
+ function bindDetailNavigation(elements = getElements()) {
+ getDetailNavigator()?.bind(elements);
+ }
+
+ function bindBrowserDetailNavigation(elements = getElements()) {
+ getBrowserDetailNavigator()?.bind(elements);
+ }
+
+ function bindPathsDetailNavigation(elements = getElements()) {
+ getPathsDetailNavigator()?.bind(elements);
+ }
+
+ function bindRoseDetailNavigation(elements = getElements()) {
+ getRoseDetailNavigator()?.bind(elements);
+ }
+
+ function bindWorldDetailNavigation(elements = getElements()) {
+ getWorldDetailNavigator()?.bind(elements);
+ }
+
function normalizeText(value) {
return String(value || "").trim().toLowerCase();
}
@@ -395,30 +1151,18 @@
.forEach(el => el.classList.remove("kab-path-active"));
}
- function renderSephiraDetail(seph, tree, elements) {
- state.selectedSephiraNumber = Number(seph?.number);
- state.selectedPathNumber = null;
-
- clearHighlights();
- document.querySelectorAll(`.kab-node[data-sephira="${seph.number}"], .kab-node-glow[data-sephira="${seph.number}"]`)
- .forEach(el => el.classList.add("kab-node-active"));
-
+ function renderSephiraDetailIntoElements(seph, tree, elements, options = {}) {
if (typeof kabbalahDetailUi.renderSephiraDetail === "function") {
kabbalahDetailUi.renderSephiraDetail(getDetailRenderContext(tree, elements, {
seph,
- onPathSelect: (path) => renderPathDetail(path, tree, elements)
+ onPathSelect: typeof options.onPathSelect === "function"
+ ? options.onPathSelect
+ : null
}));
}
}
- function renderPathDetail(path, tree, elements) {
- state.selectedPathNumber = Number(path?.pathNumber);
- state.selectedSephiraNumber = null;
-
- clearHighlights();
- document.querySelectorAll(`[data-path="${path.pathNumber}"]`)
- .forEach(el => el.classList.add("kab-path-active"));
-
+ function renderPathDetailIntoElements(path, tree, elements) {
if (typeof kabbalahDetailUi.renderPathDetail === "function") {
kabbalahDetailUi.renderPathDetail(getDetailRenderContext(tree, elements, {
path,
@@ -427,6 +1171,94 @@
}
}
+ function renderWorldLayerDetailIntoElements(worldLayer, tree, elements, worldIndex) {
+ if (typeof kabbalahDetailUi.renderWorldLayerDetail === "function") {
+ kabbalahDetailUi.renderWorldLayerDetail(getDetailRenderContext(tree, elements, {
+ worldLayer,
+ worldIndex
+ }));
+ }
+ }
+
+ function renderSephiraDetail(seph, tree, elements) {
+ state.selectedSephiraNumber = Number(seph?.number);
+ if (buildSephiraKey(seph?.number) !== "sephira:daath") {
+ state.activeNodeKey = buildSephiraKey(seph?.number);
+ }
+
+ clearHighlights();
+ document.querySelectorAll(`.kab-node[data-sephira="${seph.number}"], .kab-node-glow[data-sephira="${seph.number}"]`)
+ .forEach(el => el.classList.add("kab-node-active"));
+
+ const allElements = getElements();
+ const treeElements = getTreeDetailElements(allElements);
+ const browserElements = getBrowserDetailElements(allElements);
+
+ if (treeElements?.detailBodyEl) {
+ renderSephiraDetailIntoElements(seph, tree, treeElements, {
+ onPathSelect: (path) => renderPathDetail(path, tree, treeElements)
+ });
+ }
+
+ if (browserElements?.detailBodyEl) {
+ renderSephiraDetailIntoElements(seph, tree, browserElements, {
+ onPathSelect: (path) => {
+ document.dispatchEvent(new CustomEvent("nav:kabbalah-path", {
+ detail: { pathNo: Number(path?.pathNumber) }
+ }));
+ }
+ });
+ }
+
+ syncDetailNavigation();
+ syncBrowserDetailNavigation();
+ syncBrowserListSelection();
+ }
+
+ function renderPathDetail(path, tree, elements) {
+ state.selectedPathNumber = Number(path?.pathNumber);
+ state.activeNodeKey = buildPathKey(path?.pathNumber);
+
+ clearHighlights();
+ document.querySelectorAll(`[data-path="${path.pathNumber}"]`)
+ .forEach(el => el.classList.add("kab-path-active"));
+
+ const allElements = getElements();
+ const treeElements = getTreeDetailElements(allElements);
+ const pathElements = getPathDetailElements(allElements);
+ const roseElements = getRoseDetailElements(allElements);
+
+ if (treeElements?.detailBodyEl) {
+ renderPathDetailIntoElements(path, tree, treeElements);
+ }
+
+ if (pathElements?.detailBodyEl) {
+ renderPathDetailIntoElements(path, tree, pathElements);
+ }
+
+ if (roseElements?.detailBodyEl) {
+ renderPathDetailIntoElements(path, tree, roseElements);
+ }
+
+ syncDetailNavigation();
+ syncPathsDetailNavigation();
+ syncRoseDetailNavigation();
+ syncPathsListSelection();
+ syncBrowserListSelection();
+ }
+
+ function renderWorldLayerDetail(worldLayer, worldIndex, tree, elements) {
+ state.selectedWorldLayerIndex = Number(worldIndex);
+
+ const worldElements = getWorldDetailElements(getElements());
+ if (worldElements?.detailBodyEl) {
+ renderWorldLayerDetailIntoElements(worldLayer, tree, worldElements, worldIndex);
+ }
+
+ syncWorldDetailNavigation();
+ syncWorldListSelection();
+ }
+
function renderRoseLandingIntro(roseElements) {
if (typeof kabbalahDetailUi.renderRoseLandingIntro === "function") {
@@ -434,6 +1266,70 @@
}
}
+ function renderBrowserCurrentSelection(elements) {
+ if (!state.tree) {
+ return;
+ }
+
+ const browserElements = getBrowserDetailElements(elements);
+ if (!browserElements?.detailBodyEl) {
+ return;
+ }
+
+ const selectedSephira = getSephiraByNumber(state.selectedSephiraNumber);
+ if (selectedSephira) {
+ renderSephiraDetail(selectedSephira, state.tree, browserElements);
+ return;
+ }
+
+ const fallbackSephira = getSephiraByNumber(getSephirotSequenceEntries()[0]?.number);
+ if (fallbackSephira) {
+ renderSephiraDetail(fallbackSephira, state.tree, browserElements);
+ }
+ }
+
+ function renderPathsCurrentSelection(elements) {
+ if (!state.tree) {
+ return;
+ }
+
+ const pathElements = getPathDetailElements(elements);
+ if (!pathElements?.detailBodyEl) {
+ return;
+ }
+
+ if (hasFiniteSelectionNumber(state.selectedPathNumber)) {
+ const selectedPath = getPathByNumber(state.selectedPathNumber);
+ if (selectedPath) {
+ renderPathDetail(selectedPath, state.tree, pathElements);
+ return;
+ }
+ }
+
+ const fallbackPath = getPathByNumber(getPathSequenceEntries()[0]?.number);
+ if (fallbackPath) {
+ renderPathDetail(fallbackPath, state.tree, pathElements);
+ }
+ }
+
+ function renderWorldCurrentSelection(elements) {
+ const worldElements = getWorldDetailElements(elements);
+ if (!worldElements?.detailBodyEl) {
+ return;
+ }
+
+ const selectedWorld = getWorldLayerByIndex(state.selectedWorldLayerIndex);
+ if (selectedWorld) {
+ renderWorldLayerDetail(selectedWorld, state.selectedWorldLayerIndex, state.tree, worldElements);
+ return;
+ }
+
+ const fallbackWorld = getWorldLayerByIndex(0);
+ if (fallbackWorld) {
+ renderWorldLayerDetail(fallbackWorld, 0, state.tree, worldElements);
+ }
+ }
+
function getViewRenderContext(elements) {
return {
state,
@@ -469,8 +1365,8 @@
return;
}
- if (Number.isFinite(Number(state.selectedPathNumber))) {
- const selectedPath = state.tree.paths.find((entry) => entry.pathNumber === Number(state.selectedPathNumber));
+ if (hasFiniteSelectionNumber(state.selectedPathNumber)) {
+ const selectedPath = getPathByNumber(state.selectedPathNumber);
if (selectedPath) {
renderPathDetail(selectedPath, state.tree, roseElements);
return;
@@ -478,6 +1374,7 @@
}
renderRoseLandingIntro(roseElements);
+ syncRoseDetailNavigation(elements);
}
function renderRoseCross(elements) {
@@ -720,16 +1617,17 @@
return;
}
- if (Number.isFinite(Number(state.selectedPathNumber))) {
- const selectedPath = state.tree.paths.find((entry) => entry.pathNumber === Number(state.selectedPathNumber));
+ const activeNodeKey = String(state.activeNodeKey || "").trim();
+ if (activeNodeKey.startsWith("path:")) {
+ const selectedPath = getPathByNumber(Number(activeNodeKey.split(":")[1]));
if (selectedPath) {
renderPathDetail(selectedPath, state.tree, elements);
return;
}
}
- if (Number.isFinite(Number(state.selectedSephiraNumber))) {
- const selectedSephira = state.tree.sephiroth.find((entry) => entry.number === Number(state.selectedSephiraNumber));
+ if (activeNodeKey.startsWith("sephira:") && !activeNodeKey.endsWith(":daath")) {
+ const selectedSephira = getSephiraByNumber(Number(activeNodeKey.split(":")[1]));
if (selectedSephira) {
renderSephiraDetail(selectedSephira, state.tree, elements);
return;
@@ -739,6 +1637,47 @@
renderSephiraDetail(state.tree.sephiroth[0], state.tree, elements);
}
+ function renderVisibleKabbalahViews(elements = getElements()) {
+ renderBrowserList(elements);
+ renderWorldsList(elements);
+ renderPathsList(elements);
+
+ if (elements.browserSectionEl && elements.browserSectionEl.hidden === false) {
+ renderBrowserCurrentSelection(elements);
+ } else {
+ syncBrowserListSelection(elements);
+ syncBrowserDetailNavigation(elements);
+ }
+
+ if (elements.worldsSectionEl && elements.worldsSectionEl.hidden === false) {
+ renderWorldCurrentSelection(elements);
+ } else {
+ syncWorldListSelection(elements);
+ syncWorldDetailNavigation(elements);
+ }
+
+ if (elements.pathsSectionEl && elements.pathsSectionEl.hidden === false) {
+ renderPathsCurrentSelection(elements);
+ } else {
+ syncPathsListSelection(elements);
+ syncPathsDetailNavigation(elements);
+ }
+
+ if (elements.crossSectionEl && elements.crossSectionEl.hidden === false) {
+ renderRoseCross(elements);
+ renderRoseCurrentSelection(elements);
+ } else {
+ syncRoseDetailNavigation(elements);
+ }
+
+ if (elements.sectionEl && elements.sectionEl.hidden === false) {
+ renderTree(elements);
+ renderCurrentSelection(elements);
+ } else {
+ syncDetailNavigation(elements);
+ }
+ }
+
// ─── initialise section ──────────────────────────────────────────────────────
function init(magickDataset, elements) {
const tree = magickDataset?.grouped?.kabbalah?.["kabbalah-tree"];
@@ -753,6 +1692,17 @@
state.godsData = magickDataset?.grouped?.["gods"]?.byPath || {};
state.hebrewLetterIdByToken = buildHebrewLetterLookup(magickDataset);
state.fourWorldLayers = buildFourWorldLayersFromDataset(magickDataset);
+ if (!hasFiniteSelectionNumber(state.selectedWorldLayerIndex)
+ || Number(state.selectedWorldLayerIndex) < 0
+ || Number(state.selectedWorldLayerIndex) >= state.fourWorldLayers.length) {
+ state.selectedWorldLayerIndex = 0;
+ }
+ if (!hasFiniteSelectionNumber(state.selectedSephiraNumber)) {
+ state.selectedSephiraNumber = Number(state.tree.sephiroth[0]?.number || 1);
+ }
+ if (!String(state.activeNodeKey || "").trim()) {
+ state.activeNodeKey = buildSephiraKey(state.selectedSephiraNumber);
+ }
const bindPathDisplayToggle = (toggleEl, stateKey) => {
if (!toggleEl) {
@@ -776,6 +1726,14 @@
bindPathDisplayToggle(elements.pathLetterToggleEl, "showPathLetters");
bindPathDisplayToggle(elements.pathNumberToggleEl, "showPathNumbers");
bindPathDisplayToggle(elements.pathTarotToggleEl, "showPathTarotCards");
+ bindDetailNavigation(elements);
+ bindBrowserDetailNavigation(elements);
+ bindPathsDetailNavigation(elements);
+ bindWorldDetailNavigation(elements);
+ bindRoseDetailNavigation(elements);
+ bindBrowserList(elements);
+ bindWorldList(elements);
+ bindPathsList(elements);
syncExportControls(elements);
if (elements.treeExportWebpEl && !elements.treeExportWebpEl.dataset.bound) {
@@ -785,37 +1743,39 @@
elements.treeExportWebpEl.dataset.bound = "true";
}
- renderTree(elements);
- renderCurrentSelection(elements);
- renderRoseCross(elements);
- renderRoseCurrentSelection(elements);
+ renderVisibleKabbalahViews(elements);
}
function selectPathByNumber(pathNumber) {
if (!state.initialized || !state.tree) return;
const el = getElements();
- const path = state.tree.paths.find(p => p.pathNumber === pathNumber);
+ const path = getPathByNumber(pathNumber);
if (path) renderPathDetail(path, state.tree, el);
}
function selectSephiraByNumber(n) {
if (!state.initialized || !state.tree) return;
const el = getElements();
- const seph = state.tree.sephiroth.find(s => s.number === n);
+ const seph = getSephiraByNumber(n);
if (seph) renderSephiraDetail(seph, state.tree, el);
}
// select sephirah (1-10) or path (11+) by a single number
function selectNode(n) {
- if (n >= 1 && n <= 10) selectSephiraByNumber(n);
+ if (isDaathToken(n) || Number(n) === 0) selectSephiraByNumber(0);
+ else if (n >= 1 && n <= 10) selectSephiraByNumber(n);
else selectPathByNumber(n);
}
// ─── public API ────────────────────────────────────────────────────────
function ensureKabbalahSection(magickDataset) {
- if (state.initialized) return;
- state.initialized = true;
const elements = getElements();
+ if (state.initialized) {
+ renderVisibleKabbalahViews(elements);
+ return;
+ }
+
+ state.initialized = true;
init(magickDataset, elements);
}
diff --git a/app/ui-navigation.js b/app/ui-navigation.js
index 049cfe1..1a3ca2d 100644
--- a/app/ui-navigation.js
+++ b/app/ui-navigation.js
@@ -22,6 +22,20 @@
return config.getMagickDataset?.() || null;
}
+ function getKabbalahPathNo(detail) {
+ if (!detail || typeof detail !== "object") {
+ return null;
+ }
+
+ const rawValue = detail.pathNo ?? detail["path-no"] ?? null;
+ if (rawValue === null || rawValue === undefined || rawValue === "") {
+ return null;
+ }
+
+ const numericValue = Number(rawValue);
+ return Number.isFinite(numericValue) ? numericValue : null;
+ }
+
const DETAIL_VIEW_SELECTOR_BY_SECTION = {
tarot: "#tarot-browse-view .tarot-layout",
cube: "#cube-layout",
@@ -31,6 +45,10 @@
iching: "#iching-section .planet-layout",
gods: "#gods-section .planet-layout",
calendar: "#calendar-section .planet-layout",
+ kabbalah: "#kabbalah-section .planet-layout",
+ "kabbalah-worlds": "#kabbalah-worlds-section .planet-layout",
+ "kabbalah-paths": "#kabbalah-paths-section .planet-layout",
+ "kabbalah-cross": "#kabbalah-cross-section .kab-rose-layout",
"kabbalah-tree": "#kabbalah-tree-section .kab-layout",
planets: "#planet-section .planet-layout",
elements: "#elements-section .planet-layout"
@@ -167,12 +185,28 @@
setActiveSection(getActiveSection() === "kabbalah" ? "home" : "kabbalah");
});
+ bindClick(elements.openKabbalahSephirotEl, () => {
+ setActiveSection("kabbalah");
+ });
+
+ bindClick(elements.openKabbalahWorldsEl, () => {
+ setActiveSection("kabbalah-worlds");
+ });
+
+ bindClick(elements.openKabbalahPathsEl, () => {
+ setActiveSection("kabbalah-paths");
+ });
+
+ bindClick(elements.openKabbalahCrossEl, () => {
+ setActiveSection("kabbalah-cross");
+ });
+
bindClick(elements.openKabbalahTreeEl, () => {
- setActiveSection(getActiveSection() === "kabbalah-tree" ? "home" : "kabbalah-tree");
+ setActiveSection("kabbalah-tree");
});
bindClick(elements.openKabbalahCubeEl, () => {
- setActiveSection(getActiveSection() === "cube" ? "home" : "cube");
+ setActiveSection("cube");
});
bindClick(elements.openAlphabetWordEl, () => {
@@ -403,17 +437,21 @@
document.addEventListener("nav:kabbalah-path", (event) => {
const magickDataset = getMagickDataset();
- const pathNo = event?.detail?.pathNo;
+ const pathNo = getKabbalahPathNo(event?.detail);
if (typeof ensure.ensureKabbalahSection === "function" && magickDataset) {
ensure.ensureKabbalahSection(magickDataset);
}
- setActiveSection("kabbalah-tree");
if (pathNo != null) {
+ const targetSection = Number(pathNo) >= 11 ? "kabbalah-paths" : "kabbalah";
+ setActiveSection(targetSection);
requestAnimationFrame(() => {
window.KabbalahSectionUi?.selectNode?.(pathNo);
- scheduleSectionDetailOnly("kabbalah-tree");
+ scheduleSectionDetailOnly(targetSection);
});
+ return;
}
+
+ setActiveSection("kabbalah-paths");
});
document.addEventListener("nav:planet", (event) => {
@@ -473,7 +511,7 @@
});
document.addEventListener("tarot:view-kab-path", (event) => {
- setActiveSection("kabbalah-tree");
+ setActiveSection("kabbalah-paths");
const pathNumber = event?.detail?.pathNumber;
if (pathNumber != null) {
requestAnimationFrame(() => {
@@ -483,7 +521,7 @@
} else {
kabbalahUi?.selectPathByNumber?.(pathNumber);
}
- scheduleSectionDetailOnly("kabbalah-tree");
+ scheduleSectionDetailOnly("kabbalah-paths");
});
}
});
diff --git a/app/ui-planets.js b/app/ui-planets.js
index 783353c..0ace64c 100644
--- a/app/ui-planets.js
+++ b/app/ui-planets.js
@@ -21,6 +21,7 @@
monthRefsByPlanetId: new Map(),
cubePlacementsByPlanetId: new Map()
};
+ let detailNavigator = null;
function normalizePlanetToken(value) {
return String(value || "")
@@ -80,12 +81,16 @@
function getElements() {
return {
+ planetSectionEl: document.getElementById("planet-section"),
planetCardListEl: document.getElementById("planet-card-list"),
planetSearchInputEl: document.getElementById("planet-search-input"),
planetSearchClearEl: document.getElementById("planet-search-clear"),
planetCountEl: document.getElementById("planet-card-count"),
planetDetailNameEl: document.getElementById("planet-detail-name"),
planetDetailTypeEl: document.getElementById("planet-detail-type"),
+ planetDetailPrevEl: document.getElementById("planet-detail-prev"),
+ planetDetailPositionEl: document.getElementById("planet-detail-position"),
+ planetDetailNextEl: document.getElementById("planet-detail-next"),
planetDetailSummaryEl: document.getElementById("planet-detail-summary"),
planetDetailFactsEl: document.getElementById("planet-detail-facts"),
planetDetailAtmosphereEl: document.getElementById("planet-detail-atmosphere"),
@@ -156,11 +161,14 @@
if (!state.filteredEntries.some((entry) => entry.id === state.selectedId)) {
if (state.filteredEntries.length > 0) {
selectById(state.filteredEntries[0].id, elements);
+ } else {
+ syncDetailNavigation(elements);
}
return;
}
updateSelection(elements);
+ syncDetailNavigation(elements);
}
function clearChildren(element) {
@@ -434,6 +442,68 @@
});
}
+ function getSequenceState() {
+ const total = state.filteredEntries.length;
+ const currentIndex = state.filteredEntries.findIndex((entry) => entry.id === state.selectedId);
+
+ return {
+ total,
+ currentIndex,
+ previousId: currentIndex > 0 ? state.filteredEntries[currentIndex - 1].id : "",
+ nextId: currentIndex >= 0 && currentIndex < total - 1 ? state.filteredEntries[currentIndex + 1].id : ""
+ };
+ }
+
+ function getDetailNavigator() {
+ if (detailNavigator || typeof window.TarotSequenceNav?.createSequenceNavigator !== "function") {
+ return detailNavigator;
+ }
+
+ detailNavigator = window.TarotSequenceNav.createSequenceNavigator({
+ getElements,
+ isActive: (elements) => Boolean(elements?.planetSectionEl && elements.planetSectionEl.hidden === false),
+ getSequenceState,
+ getPrevButton: (elements) => elements?.planetDetailPrevEl,
+ getNextButton: (elements) => elements?.planetDetailNextEl,
+ getPositionEl: (elements) => elements?.planetDetailPositionEl,
+ formatPositionText: ({ total, currentIndex }) => {
+ if (total > 0 && currentIndex >= 0) {
+ const suffix = state.searchQuery ? " shown" : "";
+ return `${currentIndex + 1} of ${total}${suffix}`;
+ }
+
+ return total > 0 ? `${total} bodies` : "No bodies";
+ },
+ selectTarget: (targetId, elements) => {
+ selectById(targetId, elements);
+ return true;
+ },
+ afterSelect: (targetId, elements) => {
+ scrollEntryIntoView(targetId, elements);
+ }
+ });
+
+ return detailNavigator;
+ }
+
+ function syncDetailNavigation(elements) {
+ getDetailNavigator()?.sync(elements);
+ }
+
+ function scrollEntryIntoView(id, elements) {
+ elements?.planetCardListEl
+ ?.querySelector(`[data-planet-id="${id}"]`)
+ ?.scrollIntoView({ block: "nearest" });
+ }
+
+ function selectAdjacentEntry(offset, elements) {
+ return getDetailNavigator()?.step(offset, elements) === true;
+ }
+
+ function bindKeyboardNavigation(elements) {
+ getDetailNavigator()?.bind(elements);
+ }
+
function selectById(id, elements) {
const entry = state.entries.find((planet) => planet.id === id);
if (!entry) {
@@ -443,6 +513,7 @@
state.selectedId = entry.id;
updateSelection(elements);
renderDetail(entry, elements);
+ syncDetailNavigation(elements);
}
function renderList(elements) {
@@ -580,6 +651,8 @@
});
}
+ bindKeyboardNavigation(elements);
+
state.initialized = true;
}
@@ -594,9 +667,7 @@
);
if (!entry) return;
selectById(entry.id, el);
- el.planetCardListEl
- ?.querySelector(`[data-planet-id="${entry.id}"]`)
- ?.scrollIntoView({ block: "nearest" });
+ scrollEntryIntoView(entry.id, el);
}
window.PlanetSectionUi = {
diff --git a/app/ui-section-state.js b/app/ui-section-state.js
index bdf7027..9e5a991 100644
--- a/app/ui-section-state.js
+++ b/app/ui-section-state.js
@@ -19,6 +19,9 @@
"elements",
"iching",
"kabbalah",
+ "kabbalah-worlds",
+ "kabbalah-paths",
+ "kabbalah-cross",
"kabbalah-tree",
"cube",
"alphabet",
@@ -109,9 +112,12 @@
const isElementsOpen = activeSection === "elements";
const isIChingOpen = activeSection === "iching";
const isKabbalahOpen = activeSection === "kabbalah";
+ const isKabbalahWorldsOpen = activeSection === "kabbalah-worlds";
+ const isKabbalahPathsOpen = activeSection === "kabbalah-paths";
+ const isKabbalahCrossOpen = activeSection === "kabbalah-cross";
const isKabbalahTreeOpen = activeSection === "kabbalah-tree";
const isCubeOpen = activeSection === "cube";
- const isKabbalahMenuOpen = isKabbalahOpen || isKabbalahTreeOpen || isCubeOpen;
+ const isKabbalahMenuOpen = isKabbalahOpen || isKabbalahWorldsOpen || isKabbalahPathsOpen || isKabbalahCrossOpen || isKabbalahTreeOpen || isCubeOpen;
const isAlphabetOpen = activeSection === "alphabet";
const isAlphabetLettersOpen = activeSection === "alphabet-letters";
const isAlphabetTextOpen = activeSection === "alphabet-text";
@@ -137,6 +143,9 @@
setHidden(elements.elementsSectionEl, !isElementsOpen);
setHidden(elements.ichingSectionEl, !isIChingOpen);
setHidden(elements.kabbalahSectionEl, !isKabbalahOpen);
+ setHidden(elements.kabbalahWorldsSectionEl, !isKabbalahWorldsOpen);
+ setHidden(elements.kabbalahPathsSectionEl, !isKabbalahPathsOpen);
+ setHidden(elements.kabbalahCrossSectionEl, !isKabbalahCrossOpen);
setHidden(elements.kabbalahTreeSectionEl, !isKabbalahTreeOpen);
setHidden(elements.cubeSectionEl, !isCubeOpen);
setHidden(elements.alphabetSectionEl, !isAlphabetOpen);
@@ -168,6 +177,10 @@
setPressed(elements.openElementsEl, isElementsOpen);
setPressed(elements.openIChingEl, isIChingOpen);
setPressed(elements.openKabbalahEl, isKabbalahMenuOpen);
+ toggleActive(elements.openKabbalahSephirotEl, isKabbalahOpen);
+ toggleActive(elements.openKabbalahWorldsEl, isKabbalahWorldsOpen);
+ toggleActive(elements.openKabbalahPathsEl, isKabbalahPathsOpen);
+ toggleActive(elements.openKabbalahCrossEl, isKabbalahCrossOpen);
toggleActive(elements.openKabbalahTreeEl, isKabbalahTreeOpen);
toggleActive(elements.openKabbalahCubeEl, isCubeOpen);
setPressed(elements.openAlphabetEl, isAlphabetMenuOpen);
@@ -249,7 +262,7 @@
return;
}
- if (isKabbalahOpen || isKabbalahTreeOpen) {
+ if (isKabbalahOpen || isKabbalahWorldsOpen || isKabbalahPathsOpen || isKabbalahCrossOpen || isKabbalahTreeOpen) {
ensure.ensureKabbalahSection?.(magickDataset);
return;
}
diff --git a/app/ui-sequence-nav.js b/app/ui-sequence-nav.js
new file mode 100644
index 0000000..2ee466b
--- /dev/null
+++ b/app/ui-sequence-nav.js
@@ -0,0 +1,189 @@
+(function () {
+ "use strict";
+
+ function normalizeSequenceState(sequence) {
+ return {
+ total: Math.max(0, Number(sequence?.total) || 0),
+ currentIndex: Number.isFinite(Number(sequence?.currentIndex)) ? Number(sequence.currentIndex) : -1,
+ previousKey: String(sequence?.previousKey ?? sequence?.previousId ?? "").trim(),
+ nextKey: String(sequence?.nextKey ?? sequence?.nextId ?? "").trim()
+ };
+ }
+
+ function isEditableKeyTarget(target) {
+ if (!(target instanceof HTMLElement)) {
+ return false;
+ }
+
+ return target instanceof HTMLInputElement
+ || target instanceof HTMLTextAreaElement
+ || target instanceof HTMLSelectElement
+ || target.isContentEditable
+ || Boolean(target.closest("[contenteditable='true']"));
+ }
+
+ function hasOpenModalDialog() {
+ return Boolean(document.querySelector("[role='dialog'][aria-modal='true'][aria-hidden='false']"));
+ }
+
+ function createSequenceNavigator(config = {}) {
+ const getElements = typeof config.getElements === "function"
+ ? config.getElements
+ : () => ({});
+
+ let buttonsBound = false;
+ let keyboardBound = false;
+
+ function getSequenceState() {
+ return normalizeSequenceState(
+ typeof config.getSequenceState === "function"
+ ? config.getSequenceState()
+ : null
+ );
+ }
+
+ function getPrevButton(elements) {
+ return typeof config.getPrevButton === "function" ? config.getPrevButton(elements) : null;
+ }
+
+ function getNextButton(elements) {
+ return typeof config.getNextButton === "function" ? config.getNextButton(elements) : null;
+ }
+
+ function getPositionEl(elements) {
+ return typeof config.getPositionEl === "function" ? config.getPositionEl(elements) : null;
+ }
+
+ function isActive(elements) {
+ return typeof config.isActive === "function" ? config.isActive(elements) !== false : true;
+ }
+
+ function getTargetKey(sequence, offset) {
+ return offset < 0 ? sequence.previousKey : sequence.nextKey;
+ }
+
+ function formatPositionText(sequence, elements) {
+ return typeof config.formatPositionText === "function"
+ ? String(config.formatPositionText(sequence, elements) || "")
+ : "";
+ }
+
+ function selectTarget(targetKey, elements, offset) {
+ if (!targetKey || typeof config.selectTarget !== "function") {
+ return false;
+ }
+
+ return config.selectTarget(targetKey, elements, offset) !== false;
+ }
+
+ function afterSelect(targetKey, elements, offset) {
+ if (typeof config.afterSelect === "function") {
+ config.afterSelect(targetKey, elements, offset);
+ }
+ }
+
+ function shouldHandleKeyEvent(event, elements) {
+ if (!isActive(elements)) {
+ return false;
+ }
+
+ if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
+ return false;
+ }
+
+ if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") {
+ return false;
+ }
+
+ if (hasOpenModalDialog()) {
+ return false;
+ }
+
+ return !isEditableKeyTarget(event.target);
+ }
+
+ function sync(elements = getElements()) {
+ const sequence = getSequenceState();
+ const previousKey = getTargetKey(sequence, -1);
+ const nextKey = getTargetKey(sequence, 1);
+ const prevButton = getPrevButton(elements);
+ const nextButton = getNextButton(elements);
+ const positionEl = getPositionEl(elements);
+
+ if (prevButton) {
+ prevButton.disabled = !previousKey;
+ }
+
+ if (nextButton) {
+ nextButton.disabled = !nextKey;
+ }
+
+ if (positionEl) {
+ positionEl.textContent = formatPositionText(sequence, elements);
+ }
+ }
+
+ function step(offset, elements = getElements()) {
+ const sequence = getSequenceState();
+ const targetKey = getTargetKey(sequence, offset);
+ if (!targetKey) {
+ return false;
+ }
+
+ const didSelect = selectTarget(targetKey, elements, offset);
+ if (didSelect) {
+ afterSelect(targetKey, elements, offset);
+ }
+
+ return didSelect;
+ }
+
+ function bind(elements = getElements()) {
+ if (!buttonsBound) {
+ getPrevButton(elements)?.addEventListener("click", () => {
+ step(-1, getElements());
+ });
+
+ getNextButton(elements)?.addEventListener("click", () => {
+ step(1, getElements());
+ });
+
+ buttonsBound = true;
+ }
+
+ if (!keyboardBound) {
+ document.addEventListener("keydown", (event) => {
+ const latestElements = getElements();
+ if (!shouldHandleKeyEvent(event, latestElements)) {
+ return;
+ }
+
+ const offset = event.key === "ArrowRight" ? 1 : -1;
+ const sequence = getSequenceState();
+ if (!getTargetKey(sequence, offset)) {
+ return;
+ }
+
+ event.preventDefault();
+ step(offset, latestElements);
+ });
+
+ keyboardBound = true;
+ }
+
+ sync(elements);
+ }
+
+ return {
+ bind,
+ step,
+ sync,
+ getSequenceState
+ };
+ }
+
+ window.TarotSequenceNav = {
+ ...(window.TarotSequenceNav || {}),
+ createSequenceNavigator
+ };
+})();
\ No newline at end of file
diff --git a/app/ui-settings.js b/app/ui-settings.js
index baa28c9..e3dc99d 100644
--- a/app/ui-settings.js
+++ b/app/ui-settings.js
@@ -209,12 +209,17 @@
const activeDeckId = String(status?.activeDeckId || normalizeTarotDeck(getElements().tarotDeckEl?.value)).trim().toLowerCase();
const loadedCount = Math.max(0, Number(status?.selectedDeckLoadedCount) || 0);
const totalCount = Math.max(0, Number(status?.selectedDeckTotalCount) || 0);
+ const warmedDeckCount = Math.max(0, Number(status?.warmedDeckCount) || 0);
+ const totalDeckCount = Math.max(0, Number(status?.totalDeckCount) || 0);
+ const backgroundProgress = totalDeckCount > 1
+ ? ` (${Math.min(warmedDeckCount, totalDeckCount)}/${totalDeckCount} decks warmed)`
+ : "";
if (status?.selectedDeckPhase === "loading") {
if (totalCount > 0) {
- return `Caching selected deck images to this browser... (${loadedCount}/${totalCount})`;
+ return `Caching selected deck images to this browser... (${loadedCount}/${totalCount})${backgroundProgress}`;
}
- return "Caching selected deck images to this browser...";
+ return `Caching selected deck images to this browser...${backgroundProgress}`;
}
if (status?.selectedDeckPhase === "error") {
@@ -222,9 +227,21 @@
}
if (status?.selectedDeckPhase === "ready") {
+ if (totalDeckCount > 1 && warmedDeckCount < totalDeckCount) {
+ return `Selected deck cached and ready for fullscreen use (${activeDeckId}). Warming the rest of the decks in background${backgroundProgress}.`;
+ }
+
+ if (totalDeckCount > 1) {
+ return `All connected deck images cached and ready (${totalDeckCount}/${totalDeckCount} decks warmed).`;
+ }
+
return `Selected deck cached and ready for fullscreen use (${activeDeckId}).`;
}
+ if (totalDeckCount > 1 && warmedDeckCount > 0) {
+ return `Deck cache idle. Background warmup has ${Math.min(warmedDeckCount, totalDeckCount)}/${totalDeckCount} decks ready.`;
+ }
+
return "Deck cache idle.";
}
@@ -570,6 +587,18 @@
const previousConnectionSettings = getConnectionSettings();
const connectionSettings = getConnectionSettingsFromInputs();
const connectionChanged = hasConnectionChanged(previousConnectionSettings, connectionSettings);
+
+ if (connectionChanged) {
+ setSettingsPageStatus("Validating API connection...", "info", {
+ savedAt: loadLastSavedAt()
+ });
+
+ const probeResult = await window.TarotDataService?.probeConnection?.(connectionSettings);
+ if (!probeResult?.ok) {
+ throw new Error(probeResult?.message || "Unable to validate the API connection.");
+ }
+ }
+
const connectionResult = window.TarotAppConfig?.updateConnectionSettings?.(connectionSettings) || { didPersist: true };
const normalized = applySettingsToInputs(settings);
syncSky(
diff --git a/app/ui-tarot-detail.js b/app/ui-tarot-detail.js
index 0e20719..8212c54 100644
--- a/app/ui-tarot-detail.js
+++ b/app/ui-tarot-detail.js
@@ -5,6 +5,8 @@
getMagickDataset,
resolveTarotCardImage,
resolveTarotCardThumbnail,
+ getDeckVariantsForCard,
+ openDeckVariantLightbox,
getDisplayCardName,
buildTypeLabel,
clearChildren,
@@ -407,6 +409,73 @@
.filter((group) => group.items.length);
}
+ function renderDeckVariants(card, elements, cardDisplayName) {
+ const galleryCardEl = elements?.tarotMetaDeckGalleryCardEl;
+ const galleryEl = elements?.tarotDetailDeckGalleryEl;
+ if (!galleryCardEl || !galleryEl) {
+ return;
+ }
+
+ clearChildren(galleryEl);
+ const variants = typeof getDeckVariantsForCard === "function"
+ ? getDeckVariantsForCard(card)
+ : [];
+
+ if (!Array.isArray(variants) || variants.length < 1) {
+ galleryCardEl.hidden = true;
+ return;
+ }
+
+ variants.forEach((variant) => {
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = `tarot-deck-variant${variant?.isActive ? " is-active" : ""}`;
+ button.dataset.deckId = String(variant?.deckId || "").trim().toLowerCase();
+
+ const imageEl = document.createElement("img");
+ imageEl.className = "tarot-deck-variant-image";
+ imageEl.src = String(variant?.src || "").trim();
+ imageEl.alt = `${String(variant?.label || "Deck").trim()} — ${cardDisplayName || card?.name || "Tarot card"}`;
+ imageEl.loading = "lazy";
+ imageEl.decoding = "async";
+
+ const labelEl = document.createElement("span");
+ labelEl.className = "tarot-deck-variant-label";
+
+ const deckNameEl = document.createElement("span");
+ deckNameEl.className = "tarot-deck-variant-deck";
+ deckNameEl.textContent = String(variant?.label || variant?.deckId || "Deck").trim() || "Deck";
+ labelEl.appendChild(deckNameEl);
+
+ const variantName = String(variant?.displayName || "").trim();
+ if (variantName && variantName !== (cardDisplayName || card?.name || "")) {
+ const variantNameEl = document.createElement("span");
+ variantNameEl.className = "tarot-deck-variant-name";
+ variantNameEl.textContent = variantName;
+ labelEl.appendChild(variantNameEl);
+ }
+
+ if (variant?.isActive) {
+ const activeEl = document.createElement("span");
+ activeEl.className = "tarot-deck-variant-active";
+ activeEl.textContent = "Current deck";
+ labelEl.appendChild(activeEl);
+ }
+
+ button.append(imageEl, labelEl);
+
+ if (typeof openDeckVariantLightbox === "function" && button.dataset.deckId) {
+ button.addEventListener("click", () => {
+ openDeckVariantLightbox(card?.id, button.dataset.deckId);
+ });
+ }
+
+ galleryEl.appendChild(button);
+ });
+
+ galleryCardEl.hidden = false;
+ }
+
function renderDetail(card, elements) {
if (!card || !elements) {
return;
@@ -454,6 +523,8 @@
elements.tarotDetailReversedEl.textContent = card.meanings?.reversed || "--";
}
+ renderDeckVariants(card, elements, cardDisplayName || card.name);
+
const meaningText = String(card.meaning || card.meanings?.upright || "").trim();
if (elements.tarotMetaMeaningCardEl && elements.tarotDetailMeaningEl) {
if (meaningText) {
diff --git a/app/ui-tarot-lightbox.js b/app/ui-tarot-lightbox.js
index 32834c7..05860f9 100644
--- a/app/ui-tarot-lightbox.js
+++ b/app/ui-tarot-lightbox.js
@@ -837,12 +837,14 @@
function normalizeCardRequest(request) {
const normalized = normalizeOpenRequest(request);
const label = String(normalized.label || normalized.altText || "Tarot card enlarged image").trim() || "Tarot card enlarged image";
+ const cardId = String(normalized.cardId || "").trim();
return {
src: String(normalized.src || "").trim(),
previewSrc: String(normalized.previewSrc || "").trim(),
altText: String(normalized.altText || label).trim() || label,
label,
- cardId: String(normalized.cardId || "").trim(),
+ cardId,
+ sequenceId: String(normalized.sequenceId || cardId).trim(),
deckId: String(normalized.deckId || "").trim(),
deckLabel: String(normalized.deckLabel || normalized.deckId || "").trim(),
missingReason: String(normalized.missingReason || "").trim(),
@@ -995,19 +997,20 @@
});
}
- function resolveCardRequestById(cardId) {
- if (!cardId || typeof lightboxState.resolveCardById !== "function") {
+ function resolveCardRequestById(sequenceId) {
+ if (!sequenceId || typeof lightboxState.resolveCardById !== "function") {
return null;
}
- const resolved = lightboxState.resolveCardById(cardId);
+ const resolved = lightboxState.resolveCardById(sequenceId);
if (!resolved) {
return null;
}
return normalizeCardRequest({
...resolved,
- cardId
+ sequenceId: String(resolved.sequenceId || sequenceId).trim() || String(sequenceId),
+ cardId: String(resolved.cardId || sequenceId).trim()
});
}
@@ -3277,7 +3280,10 @@
return;
}
- const anchorId = lightboxState.secondaryCard?.cardId || lightboxState.primaryCard?.cardId;
+ const anchorId = lightboxState.secondaryCard?.sequenceId
+ || lightboxState.secondaryCard?.cardId
+ || lightboxState.primaryCard?.sequenceId
+ || lightboxState.primaryCard?.cardId;
const startIndex = sequence.indexOf(anchorId);
if (startIndex < 0) {
return;
@@ -3285,12 +3291,13 @@
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) {
+ const nextSequenceId = sequence[nextIndex];
+ const primarySequenceId = lightboxState.primaryCard?.sequenceId || lightboxState.primaryCard?.cardId;
+ if (!nextSequenceId || nextSequenceId === primarySequenceId) {
continue;
}
- const nextCard = resolveCardRequestById(nextCardId);
+ const nextCard = resolveCardRequestById(nextSequenceId);
if (nextCard && setSecondaryCard(nextCard, true)) {
break;
}
@@ -3303,14 +3310,15 @@
return;
}
- const startIndex = sequence.indexOf(lightboxState.primaryCard?.cardId);
+ const primarySequenceId = lightboxState.primaryCard?.sequenceId || lightboxState.primaryCard?.cardId;
+ const startIndex = sequence.indexOf(primarySequenceId);
if (startIndex < 0) {
return;
}
const nextIndex = (startIndex + direction + sequence.length) % sequence.length;
- const nextCardId = sequence[nextIndex];
- const nextCard = resolveCardRequestById(nextCardId);
+ const nextSequenceId = sequence[nextIndex];
+ const nextCard = resolveCardRequestById(nextSequenceId);
if (!nextCard?.src) {
return;
}
diff --git a/app/ui-tarot.js b/app/ui-tarot.js
index 527d8ff..66b1e86 100644
--- a/app/ui-tarot.js
+++ b/app/ui-tarot.js
@@ -47,6 +47,7 @@
courtCardByDecanId: new Map(),
loadingPromise: null
};
+ let detailNavigator = null;
const TAROT_TRUMP_NUMBER_BY_NAME = {
"the fool": 0,
@@ -255,9 +256,14 @@
tarotDetailImageEl: document.getElementById("tarot-detail-image"),
tarotDetailNameEl: document.getElementById("tarot-detail-name"),
tarotDetailTypeEl: document.getElementById("tarot-detail-type"),
+ tarotDetailPrevEl: document.getElementById("tarot-detail-prev"),
+ tarotDetailPositionEl: document.getElementById("tarot-detail-position"),
+ tarotDetailNextEl: document.getElementById("tarot-detail-next"),
tarotDetailSummaryEl: document.getElementById("tarot-detail-summary"),
tarotDetailUprightEl: document.getElementById("tarot-detail-upright"),
tarotDetailReversedEl: document.getElementById("tarot-detail-reversed"),
+ tarotMetaDeckGalleryCardEl: document.getElementById("tarot-meta-deck-gallery-card"),
+ tarotDetailDeckGalleryEl: document.getElementById("tarot-detail-deck-gallery"),
tarotMetaMeaningCardEl: document.getElementById("tarot-meta-meaning-card"),
tarotDetailMeaningEl: document.getElementById("tarot-detail-meaning"),
tarotDetailKeywordsEl: document.getElementById("tarot-detail-keywords"),
@@ -355,6 +361,8 @@
getMagickDataset: () => state.magickDataset,
resolveTarotCardImage,
resolveTarotCardThumbnail,
+ getDeckVariantsForCard,
+ openDeckVariantLightbox,
getDisplayCardName,
buildTypeLabel,
clearChildren,
@@ -523,11 +531,14 @@
if (!state.filteredCards.some((card) => card.id === state.selectedCardId)) {
if (state.filteredCards.length > 0) {
selectCardById(state.filteredCards[0].id, elements);
+ } else {
+ syncDetailNavigation(elements);
}
return;
}
updateListSelection(elements);
+ syncDetailNavigation(elements);
}
function clearChildren(element) {
@@ -723,6 +734,62 @@
});
}
+ function getCardSequenceState() {
+ const total = state.filteredCards.length;
+ const currentIndex = state.filteredCards.findIndex((card) => card.id === state.selectedCardId);
+
+ return {
+ total,
+ currentIndex,
+ previousId: currentIndex > 0 ? state.filteredCards[currentIndex - 1].id : "",
+ nextId: currentIndex >= 0 && currentIndex < total - 1 ? state.filteredCards[currentIndex + 1].id : ""
+ };
+ }
+
+ function getDetailNavigator() {
+ if (detailNavigator || typeof window.TarotSequenceNav?.createSequenceNavigator !== "function") {
+ return detailNavigator;
+ }
+
+ detailNavigator = window.TarotSequenceNav.createSequenceNavigator({
+ getElements,
+ isActive: (elements) => Boolean(elements?.tarotSectionEl && elements.tarotSectionEl.hidden === false),
+ getSequenceState: getCardSequenceState,
+ getPrevButton: (elements) => elements?.tarotDetailPrevEl,
+ getNextButton: (elements) => elements?.tarotDetailNextEl,
+ getPositionEl: (elements) => elements?.tarotDetailPositionEl,
+ formatPositionText: ({ total, currentIndex }) => {
+ if (total > 0 && currentIndex >= 0) {
+ const suffix = state.searchQuery ? " shown" : "";
+ return `${currentIndex + 1} of ${total}${suffix}`;
+ }
+
+ return total > 0 ? `${total} cards` : "No cards";
+ },
+ selectTarget: (targetId, elements) => {
+ selectCardById(targetId, elements);
+ return true;
+ },
+ afterSelect: (targetId, elements) => {
+ scrollCardIntoView(targetId, elements);
+ }
+ });
+
+ return detailNavigator;
+ }
+
+ function syncDetailNavigation(elements) {
+ getDetailNavigator()?.sync(elements);
+ }
+
+ function selectAdjacentCard(offset, elements = getElements()) {
+ return getDetailNavigator()?.step(offset, elements) === true;
+ }
+
+ function bindKeyboardNavigation(elements) {
+ getDetailNavigator()?.bind(elements);
+ }
+
function selectCardById(cardIdToSelect, elements) {
const card = state.cards.find((entry) => entry.id === cardIdToSelect);
if (!card) {
@@ -733,6 +800,7 @@
updateListSelection(elements);
updateHouseSelection(elements);
renderDetail(card, elements);
+ syncDetailNavigation(elements);
}
function scrollCardIntoView(cardIdToReveal, elements) {
@@ -758,6 +826,47 @@
return Array.from(getRegisteredDeckOptionMap().values());
}
+ function getDeckVariantsForCard(card) {
+ if (!card) {
+ return [];
+ }
+
+ const trumpNumber = Number.isFinite(Number(card?.number)) ? Number(card.number) : undefined;
+ const activeDeckId = String(getActiveDeck?.() || "").trim().toLowerCase();
+
+ return getRegisteredDeckList()
+ .map((deck) => {
+ const deckId = String(deck?.id || "").trim().toLowerCase();
+ if (!deckId) {
+ return null;
+ }
+
+ const imageOptions = { deckId, trumpNumber };
+ const src = (typeof resolveTarotCardThumbnail === "function"
+ ? resolveTarotCardThumbnail(card.name, imageOptions)
+ : "") || (typeof resolveTarotCardImage === "function"
+ ? resolveTarotCardImage(card.name, imageOptions)
+ : "");
+
+ if (!src) {
+ return null;
+ }
+
+ const displayName = (typeof getTarotCardDisplayName === "function"
+ ? String(getTarotCardDisplayName(card.name, imageOptions) || "").trim()
+ : "") || getDisplayCardName(card) || card.name;
+
+ return {
+ deckId,
+ label: String(deck?.label || deckId).trim() || deckId,
+ src,
+ displayName,
+ isActive: deckId === activeDeckId
+ };
+ })
+ .filter(Boolean);
+ }
+
function buildDeckLightboxCardRequest(cardIdToResolve, deckIdToResolve = "") {
const card = state.cards.find((entry) => entry.id === cardIdToResolve);
if (!card) {
@@ -790,8 +899,8 @@
};
}
- function buildLightboxCardRequestById(cardIdToResolve) {
- const request = buildDeckLightboxCardRequest(cardIdToResolve, getActiveDeck?.() || "");
+ function buildLightboxCardRequestById(cardIdToResolve, deckIdToResolve = "") {
+ const request = buildDeckLightboxCardRequest(cardIdToResolve, deckIdToResolve || getActiveDeck?.() || "");
if (!request?.src) {
return null;
}
@@ -799,19 +908,67 @@
return request;
}
+ function getDefaultLightboxSequenceIds(cardIdToOpen = "") {
+ const normalizedCardId = String(cardIdToOpen || "").trim();
+ const filteredIds = state.filteredCards
+ .map((card) => String(card?.id || "").trim())
+ .filter(Boolean);
+
+ if (normalizedCardId && filteredIds.includes(normalizedCardId)) {
+ return filteredIds;
+ }
+
+ return state.cards
+ .map((card) => String(card?.id || "").trim())
+ .filter(Boolean);
+ }
+
+ function getDeckVariantSequenceEntries(cardIdToResolve) {
+ const card = state.cards.find((entry) => entry.id === cardIdToResolve);
+ if (!card) {
+ return [];
+ }
+
+ return getDeckVariantsForCard(card)
+ .map((variant) => {
+ const deckId = String(variant?.deckId || "").trim().toLowerCase();
+ if (!deckId) {
+ return null;
+ }
+
+ return {
+ sequenceId: deckId,
+ deckId
+ };
+ })
+ .filter(Boolean);
+ }
+
function openCardLightboxById(cardIdToOpen, options = {}) {
const normalizedCardId = String(cardIdToOpen || "").trim();
if (!normalizedCardId) {
return;
}
- const primaryCardRequest = buildLightboxCardRequestById(normalizedCardId);
+ const requestedDeckId = String(options?.deckId || getActiveDeck?.() || "").trim();
+ const primaryCardRequest = buildLightboxCardRequestById(normalizedCardId, requestedDeckId);
if (!primaryCardRequest?.src) {
return;
}
- const activeDeckId = String(getActiveDeck?.() || primaryCardRequest.deckId || "").trim();
+ const activeDeckId = String(primaryCardRequest.deckId || requestedDeckId || getActiveDeck?.() || "").trim();
const availableCompareDecks = getRegisteredDeckList().filter((deck) => deck.id && deck.id !== activeDeckId);
+ const requestedSequenceIds = Array.isArray(options?.sequenceIds)
+ ? options.sequenceIds
+ .map((sequenceId) => String(sequenceId || "").trim())
+ .filter(Boolean)
+ : [];
+ const sequenceIds = requestedSequenceIds.length > 0
+ ? requestedSequenceIds
+ : getDefaultLightboxSequenceIds(normalizedCardId);
+ const resolveCardById = typeof options?.resolveCardById === "function"
+ ? options.resolveCardById
+ : (nextCardId) => buildLightboxCardRequestById(nextCardId, activeDeckId);
const onSelectCardId = typeof options?.onSelectCardId === "function"
? options.onSelectCardId
: (nextCardId) => {
@@ -825,6 +982,7 @@
altText: primaryCardRequest.altText,
label: primaryCardRequest.label,
cardId: primaryCardRequest.cardId,
+ sequenceId: String(options?.sequenceId || primaryCardRequest.sequenceId || normalizedCardId).trim(),
deckId: primaryCardRequest.deckId || activeDeckId,
deckLabel: primaryCardRequest.deckLabel || "",
compareDetails: primaryCardRequest.compareDetails || [],
@@ -834,13 +992,72 @@
activeDeckLabel: primaryCardRequest.deckLabel || "",
availableCompareDecks,
maxCompareDecks: 2,
- sequenceIds: state.cards.map((card) => card.id),
- resolveCardById: buildLightboxCardRequestById,
+ sequenceIds,
+ resolveCardById,
resolveDeckCardById: buildDeckLightboxCardRequest,
onSelectCardId
});
}
+ function openDeckVariantLightbox(cardIdToOpen, deckIdToOpen = "") {
+ const normalizedCardId = String(cardIdToOpen || "").trim();
+ if (!normalizedCardId) {
+ return;
+ }
+
+ const variantEntries = getDeckVariantSequenceEntries(normalizedCardId);
+ if (variantEntries.length < 1) {
+ openCardLightboxById(normalizedCardId, { deckId: deckIdToOpen });
+ return;
+ }
+
+ const requestedDeckId = String(deckIdToOpen || getActiveDeck?.() || "").trim().toLowerCase();
+ const activeVariant = variantEntries.find((variant) => variant.deckId === requestedDeckId) || variantEntries[0];
+ if (!activeVariant?.deckId) {
+ openCardLightboxById(normalizedCardId);
+ return;
+ }
+
+ const primaryCardRequest = buildLightboxCardRequestById(normalizedCardId, activeVariant.deckId);
+ if (!primaryCardRequest?.src) {
+ return;
+ }
+
+ const availableCompareDecks = getRegisteredDeckList().filter((deck) => deck.id && deck.id !== activeVariant.deckId);
+
+ window.TarotUiLightbox?.open?.({
+ ...primaryCardRequest,
+ deckId: activeVariant.deckId,
+ sequenceId: activeVariant.sequenceId,
+ activeDeckId: activeVariant.deckId,
+ activeDeckLabel: primaryCardRequest.deckLabel || "",
+ availableCompareDecks,
+ maxCompareDecks: 2,
+ allowOverlayCompare: true,
+ allowDeckCompare: true,
+ sequenceIds: variantEntries.map((variant) => variant.sequenceId),
+ resolveDeckCardById: buildDeckLightboxCardRequest,
+ resolveCardById: (sequenceId) => {
+ const normalizedSequenceId = String(sequenceId || "").trim().toLowerCase();
+ const matchingVariant = variantEntries.find((variant) => variant.sequenceId === normalizedSequenceId);
+ if (!matchingVariant?.deckId) {
+ return null;
+ }
+
+ const request = buildLightboxCardRequestById(normalizedCardId, matchingVariant.deckId);
+ return request
+ ? {
+ ...request,
+ sequenceId: matchingVariant.sequenceId,
+ deckId: matchingVariant.deckId
+ }
+ : null;
+ },
+ onSelectCardId: () => {
+ }
+ });
+ }
+
function renderList(elements) {
if (!elements?.tarotCardListEl) {
return;
@@ -940,6 +1157,7 @@
const selected = state.cards.find((card) => card.id === state.selectedCardId);
if (selected) {
renderDetail(selected, elements);
+ syncDetailNavigation(elements);
}
}
return;
@@ -1007,6 +1225,8 @@
});
}
+ bindKeyboardNavigation(elements);
+
if (elements.tarotHouseTopCardsVisibleEl) {
elements.tarotHouseTopCardsVisibleEl.addEventListener("change", () => {
state.houseTopCardsVisible = Boolean(elements.tarotHouseTopCardsVisibleEl.checked);
@@ -1104,12 +1324,7 @@
return;
}
- const request = buildLightboxCardRequestById(state.selectedCardId);
- if (!request?.src) {
- return;
- }
-
- window.TarotUiLightbox?.open?.(request);
+ openCardLightboxById(state.selectedCardId);
});
}
diff --git a/index.html b/index.html
index 55622d2..ca536bf 100644
--- a/index.html
+++ b/index.html
@@ -16,7 +16,7 @@
-
+
@@ -63,8 +63,12 @@
@@ -216,6 +220,11 @@
--
Select a month to explore
+
@@ -252,6 +261,11 @@
--
Select a holiday to explore
+
@@ -273,10 +287,14 @@
-
@@ -291,6 +309,10 @@