diff --git a/app.js b/app.js index 38d2605..5bbfe29 100644 --- a/app.js +++ b/app.js @@ -40,6 +40,7 @@ const holidaySectionEl = document.getElementById("holiday-section"); const audioCircleSectionEl = document.getElementById("audio-circle-section"); const audioNotesSectionEl = document.getElementById("audio-notes-section"); const tarotSectionEl = document.getElementById("tarot-section"); +const tarotFrameSectionEl = document.getElementById("tarot-frame-section"); const tarotHouseSectionEl = document.getElementById("tarot-house-section"); const astronomySectionEl = document.getElementById("astronomy-section"); const natalSectionEl = document.getElementById("natal-section"); @@ -67,6 +68,7 @@ const openAudioEl = document.getElementById("open-audio"); const openAudioCircleEl = document.getElementById("open-audio-circle"); const openAudioNotesEl = document.getElementById("open-audio-notes"); const openTarotEl = document.getElementById("open-tarot"); +const openTarotFrameEl = document.getElementById("open-tarot-frame"); const openTarotHouseEl = document.getElementById("open-tarot-house"); const openAstronomyEl = document.getElementById("open-astronomy"); const openPlanetsEl = document.getElementById("open-planets"); @@ -393,6 +395,11 @@ window.TarotSpreadUi?.init?.({ setActiveSection: (section) => sectionStateUi.setActiveSection?.(section) }); +window.TarotFrameUi?.init?.({ + ensureTarotSection, + getCards: () => window.TarotSectionUi?.getCards?.() || [] +}); + sectionStateUi.init?.({ calendar, tarotSpreadUi, @@ -411,6 +418,7 @@ sectionStateUi.init?.({ audioCircleSectionEl, audioNotesSectionEl, tarotSectionEl, + tarotFrameSectionEl, tarotHouseSectionEl, astronomySectionEl, natalSectionEl, @@ -438,6 +446,7 @@ sectionStateUi.init?.({ openAudioCircleEl, openAudioNotesEl, openTarotEl, + openTarotFrameEl, openTarotHouseEl, openAstronomyEl, openPlanetsEl, @@ -460,6 +469,7 @@ sectionStateUi.init?.({ }, ensure: { ensureTarotSection, + ensureTarotFrameSection: window.TarotFrameUi?.ensureTarotFrameSection, ensurePlanetSection, ensureCyclesSection, ensureElementsSection, @@ -540,6 +550,7 @@ navigationUi.init?.({ openAudioCircleEl, openAudioNotesEl, openTarotEl, + openTarotFrameEl, openTarotHouseEl, openAstronomyEl, openPlanetsEl, @@ -562,6 +573,7 @@ navigationUi.init?.({ }, ensure: { ensureTarotSection, + ensureTarotFrameSection: window.TarotFrameUi?.ensureTarotFrameSection, ensurePlanetSection, ensureCyclesSection, ensureElementsSection, diff --git a/app/navigation-detail-test-harness.js b/app/navigation-detail-test-harness.js new file mode 100644 index 0000000..acd40dd --- /dev/null +++ b/app/navigation-detail-test-harness.js @@ -0,0 +1,247 @@ +(function () { + "use strict"; + + const DEFAULT_TIMEOUT = 10000; + const DEFAULT_INTERVAL = 50; + + function sleep(ms) { + return new Promise((resolve) => { + window.setTimeout(resolve, ms); + }); + } + + async function waitFor(predicate, options = {}) { + const timeout = Number.isFinite(Number(options.timeout)) ? Number(options.timeout) : DEFAULT_TIMEOUT; + const interval = Number.isFinite(Number(options.interval)) ? Number(options.interval) : DEFAULT_INTERVAL; + const deadline = Date.now() + timeout; + + while (Date.now() < deadline) { + try { + if (await predicate()) { + return true; + } + } catch { + } + + await sleep(interval); + } + + return false; + } + + function textById(id) { + const element = document.getElementById(id); + return element instanceof HTMLElement ? String(element.textContent || "").trim() : ""; + } + + function getSectionElement(selector) { + const element = document.querySelector(selector); + return element instanceof HTMLElement ? element : null; + } + + function getLayoutState(selector) { + const layout = getSectionElement(selector); + const classes = layout ? Array.from(layout.classList) : []; + return { + classes, + detailOnly: classes.includes("layout-sidebar-collapsed") && !classes.includes("layout-detail-collapsed") + }; + } + + function getSelectedText(sectionSelector) { + const section = getSectionElement(sectionSelector); + if (!section) { + return ""; + } + + const selected = section.querySelector('[aria-selected="true"]'); + return selected instanceof HTMLElement ? String(selected.textContent || "").trim() : ""; + } + + function dispatchNavigationEvent(eventName, detail) { + document.dispatchEvent(new CustomEvent(eventName, { + detail, + bubbles: true + })); + } + + function createSnapshot(test) { + const section = getSectionElement(test.sectionSelector); + const layoutState = getLayoutState(test.layoutSelector); + + return { + sectionHidden: section ? section.hidden : true, + layoutClasses: layoutState.classes, + detailOnly: layoutState.detailOnly, + detailName: textById(test.detailNameId), + detailSub: test.detailSubId ? textById(test.detailSubId) : "", + detailBody: test.detailBodyId ? textById(test.detailBodyId) : "", + selectedText: getSelectedText(test.sectionSelector) + }; + } + + async function waitForAppReady() { + return waitFor(() => { + if (document.readyState !== "complete") { + return false; + } + + const referenceData = window.TarotAppRuntime?.getReferenceData?.(); + const magickDataset = window.TarotAppRuntime?.getMagickDataset?.(); + + return Boolean( + typeof window.TarotChromeUi?.showDetailOnly === "function" + && referenceData + && magickDataset + ); + }); + } + + const TESTS = { + tarotTrumpNav: { + eventName: "nav:tarot-trump", + detail: { trumpNumber: 0 }, + sectionSelector: "#tarot-section", + layoutSelector: "#tarot-browse-view .tarot-layout", + detailNameId: "tarot-detail-name", + matches: (state) => state.detailName === "The Fool" + }, + kabViewTrump: { + eventName: "kab:view-trump", + detail: { trumpNumber: 0 }, + sectionSelector: "#tarot-section", + layoutSelector: "#tarot-browse-view .tarot-layout", + detailNameId: "tarot-detail-name", + matches: (state) => state.detailName === "The Fool" + }, + ichingHexagramNav: { + eventName: "nav:iching", + detail: { hexagramNumber: 1 }, + sectionSelector: "#iching-section", + layoutSelector: "#iching-section .planet-layout", + detailNameId: "iching-detail-name", + matches: (state) => /Creative Force/i.test(state.detailName) && /#1/i.test(state.selectedText) + }, + zodiacSignNav: { + eventName: "nav:zodiac", + detail: { signId: "aries" }, + sectionSelector: "#zodiac-section", + layoutSelector: "#zodiac-section .planet-layout", + detailNameId: "zodiac-detail-name", + detailSubId: "zodiac-detail-sub", + matches: (state) => /Aries/i.test(state.detailSub) && /aries/i.test(state.selectedText) + }, + planetNav: { + eventName: "nav:planet", + detail: { planetId: "mars" }, + sectionSelector: "#planet-section", + layoutSelector: "#planet-section .planet-layout", + detailNameId: "planet-detail-name", + matches: (state) => state.detailName !== "--" && /mars/i.test(state.selectedText) + }, + numberNav: { + eventName: "nav:number", + detail: { value: 1 }, + sectionSelector: "#numbers-section", + layoutSelector: "#numbers-section .numbers-main-layout", + detailNameId: "numbers-detail-name", + matches: (state) => /Number 1|One/i.test(state.detailName) && /One/i.test(state.selectedText) + }, + elementNav: { + eventName: "nav:elements", + detail: { elementId: "fire" }, + sectionSelector: "#elements-section", + layoutSelector: "#elements-section .planet-layout", + detailNameId: "elements-detail-name", + matches: (state) => state.detailName !== "--" && /fire/i.test(state.selectedText) + }, + calendarMonthNav: { + eventName: "nav:calendar-month", + detail: { calendarId: "gregorian", monthId: "january" }, + sectionSelector: "#calendar-section", + layoutSelector: "#calendar-section .planet-layout", + detailNameId: "calendar-detail-name", + matches: (state) => /january/i.test(state.detailName) && /january/i.test(state.selectedText) + }, + tarotViewKabPath: { + eventName: "tarot:view-kab-path", + detail: { pathNumber: 11 }, + sectionSelector: "#kabbalah-tree-section", + layoutSelector: "#kabbalah-tree-section .kab-layout", + detailNameId: "kab-detail-name", + detailSubId: "kab-detail-sub", + detailBodyId: "kab-detail-body", + matches: (state) => /Path 11/i.test(state.detailName) && /The Fool/i.test(state.detailSub) + } + }; + + async function runTest(testId, options = {}) { + const test = TESTS[testId]; + if (!test) { + throw new Error(`Unknown navigation detail test: ${testId}`); + } + + const ready = await waitForAppReady(); + if (!ready) { + return { + id: testId, + pass: false, + error: "App data did not finish loading in time." + }; + } + + dispatchNavigationEvent(test.eventName, test.detail); + + const timeout = Number.isFinite(Number(options.timeout)) ? Number(options.timeout) : DEFAULT_TIMEOUT; + const pass = await waitFor(() => { + const state = createSnapshot(test); + return !state.sectionHidden && state.detailOnly && test.matches(state); + }, { timeout }); + + const state = createSnapshot(test); + return { + id: testId, + eventName: test.eventName, + detail: test.detail, + pass, + ...state, + error: pass ? "" : "Destination detail pane did not settle into detail-only mode." + }; + } + + async function runAll(testIds = Object.keys(TESTS), options = {}) { + const results = []; + for (const testId of testIds) { + results.push(await runTest(testId, options)); + } + + const summary = { + total: results.length, + passed: results.filter((result) => result.pass).length, + failed: results.filter((result) => !result.pass).length + }; + + const payload = { summary, results }; + window.__TAROT_NAVIGATION_DETAIL_TEST_RESULTS__ = payload; + return payload; + } + + window.TarotNavigationDetailTestHarness = { + runAll, + runTest, + listTests: () => Object.keys(TESTS) + }; + + if (new URLSearchParams(window.location.search).has("navtest")) { + waitForAppReady().then((ready) => { + if (!ready) { + return; + } + + runAll().then((result) => { + window.__TAROT_NAVIGATION_DETAIL_TEST_LAST_RESULT__ = result; + console.info("Navigation detail test harness results", result); + }); + }); + } +})(); \ No newline at end of file diff --git a/app/styles.css b/app/styles.css index 449a642..b948488 100644 --- a/app/styles.css +++ b/app/styles.css @@ -89,6 +89,9 @@ .topbar-menu-toggle:hover { background: #3f3f46; } + .topbar-settings-toggle { + margin-left: 0; + } .topbar-actions { display: none; flex: 1 0 100%; @@ -187,6 +190,9 @@ .topbar-menu-toggle { min-height: 38px; } + .topbar-settings-toggle { + min-height: 38px; + } .topbar-panel-toggle { min-height: 38px; } @@ -389,12 +395,21 @@ box-sizing: border-box; overflow: hidden; } + #tarot-frame-section { + height: calc(100vh - 61px); + background: #18181b; + box-sizing: border-box; + overflow: auto; + } #tarot-section[hidden] { display: none; } #tarot-house-section[hidden] { display: none; } + #tarot-frame-section[hidden] { + display: none; + } #planet-section[hidden] { display: none; } @@ -844,12 +859,430 @@ gap: 10px; } + .tarot-frame-view { + min-height: 100%; + padding: 18px; + box-sizing: border-box; + } + + .tarot-frame-shell { + width: min(1480px, 100%); + margin: 0 auto; + display: grid; + gap: 16px; + } + + .tarot-frame-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + } + + .tarot-frame-title { + margin: 0; + font-size: clamp(28px, 4vw, 38px); + line-height: 1.05; + letter-spacing: 0.01em; + } + + .tarot-frame-copy { + margin: 8px 0 0; + max-width: 820px; + color: #cbd5e1; + font-size: 14px; + line-height: 1.55; + } + + .tarot-frame-actions { + position: relative; + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + } + + .tarot-frame-action-btn { + padding: 10px 14px; + border: 1px solid #4c1d95; + border-radius: 999px; + background: linear-gradient(180deg, #312e81, #1d1b4b); + color: #eef2ff; + cursor: pointer; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.02em; + } + + .tarot-frame-action-btn:hover { + border-color: #6d28d9; + background: linear-gradient(180deg, #4338ca, #312e81); + } + + .tarot-frame-settings-panel { + position: absolute; + top: calc(100% + 10px); + right: 0; + z-index: 25; + min-width: 220px; + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid #312e81; + border-radius: 16px; + background: + radial-gradient(circle at top, rgba(99, 102, 241, 0.14), transparent 40%), + linear-gradient(180deg, rgba(22, 22, 34, 0.98), rgba(10, 10, 18, 0.98)); + box-shadow: 0 18px 38px rgba(0, 0, 0, 0.3); + } + + .tarot-frame-settings-panel[hidden] { + display: none !important; + } + + .tarot-frame-toggle { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 9px 10px; + border: 1px solid rgba(99, 102, 241, 0.34); + border-radius: 12px; + background: rgba(15, 23, 42, 0.5); + color: #e2e8f0; + font-size: 13px; + font-weight: 600; + cursor: pointer; + -webkit-user-select: none; + user-select: none; + } + + .tarot-frame-toggle input { + margin: 0; + accent-color: #818cf8; + } + + .tarot-frame-toggle input:disabled, + .tarot-frame-export-btn:disabled, + .tarot-frame-settings-toggle:disabled { + cursor: wait; + } + + .tarot-frame-export-btn { + width: 100%; + justify-content: center; + } + + .tarot-frame-status { + padding: 10px 14px; + border: 1px solid #27272a; + border-radius: 14px; + background: linear-gradient(180deg, rgba(20, 20, 32, 0.95), rgba(10, 10, 18, 0.95)); + color: #d4d4d8; + font-size: 13px; + line-height: 1.4; + } + + .tarot-frame-board-grid { + display: block; + } + + .tarot-frame-panel { + --frame-cell-size: clamp(34px, 3.1vw, 52px); + --frame-gap: clamp(2px, 0.3vw, 6px); + display: grid; + gap: 14px; + padding: 18px; + border: 1px solid #27272a; + border-radius: 22px; + background: + radial-gradient(circle at top, rgba(59, 130, 246, 0.08), transparent 34%), + linear-gradient(180deg, #161622 0%, #0f0f17 100%); + box-shadow: 0 22px 54px rgba(0, 0, 0, 0.24); + overflow: auto; + } + + .tarot-frame-panel-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + min-width: max-content; + } + + .tarot-frame-panel-title { + margin: 0; + font-size: 17px; + line-height: 1.2; + color: #f8fafc; + } + + .tarot-frame-panel-subtitle { + margin: 5px 0 0; + color: #94a3b8; + font-size: 13px; + line-height: 1.45; + } + + .tarot-frame-panel-count { + align-self: center; + padding: 6px 10px; + border-radius: 999px; + background: rgba(15, 23, 42, 0.78); + color: #cbd5e1; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + white-space: nowrap; + } + + .tarot-frame-legend { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + } + + .tarot-frame-legend-item { + display: grid; + gap: 4px; + padding: 10px 12px; + border: 1px solid rgba(71, 85, 105, 0.56); + border-radius: 14px; + background: rgba(15, 23, 42, 0.46); + color: #cbd5e1; + font-size: 12px; + line-height: 1.4; + } + + .tarot-frame-legend-item strong { + color: #f8fafc; + font-size: 12px; + letter-spacing: 0.04em; + text-transform: uppercase; + } + + .tarot-frame-grid { + display: grid; + grid-template-columns: repeat(var(--frame-grid-size), var(--frame-cell-size)); + grid-template-rows: repeat(var(--frame-grid-size), var(--frame-cell-size)); + gap: var(--frame-gap); + justify-content: center; + align-content: center; + min-width: max-content; + } + + .tarot-frame-slot { + position: relative; + width: var(--frame-cell-size); + height: var(--frame-cell-size); + border-radius: 8px; + transition: transform 120ms ease, box-shadow 120ms ease, border-color 120ms ease; + } + + .tarot-frame-slot.is-empty-slot { + border: 1px dashed rgba(148, 163, 184, 0.4); + } + + .tarot-frame-slot.is-drop-target { + box-shadow: 0 0 0 2px #f59e0b, 0 0 0 6px rgba(245, 158, 11, 0.18); + border-radius: 8px; + } + + .tarot-frame-slot.is-drag-source { + opacity: 0.42; + } + + .tarot-frame-slot-empty { + width: 100%; + height: 100%; + display: block; + border: 0; + border-radius: 0; + background: transparent; + color: transparent; + font-size: 0; + } + + .tarot-frame-slot-empty::before { + content: none; + } + + .tarot-frame-card { + position: absolute; + inset: 0; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + overflow: visible; + cursor: grab; + box-shadow: none; + transition: transform 120ms ease, filter 120ms ease; + -webkit-user-select: none; + user-select: none; + } + + .tarot-frame-card.is-empty { + cursor: default; + box-shadow: none; + background: transparent; + border-color: transparent; + } + + .tarot-frame-card:hover { + transform: translateY(-2px); + filter: drop-shadow(0 10px 18px rgba(15, 23, 42, 0.38)); + } + + .tarot-frame-card.is-empty:hover { + border-color: transparent; + box-shadow: none; + transform: none; + } + + .tarot-frame-card-image, + .tarot-frame-card-fallback { + display: block; + width: 100%; + height: 100%; + pointer-events: none; + -webkit-user-select: none; + user-select: none; + } + + .tarot-frame-card-image { + object-fit: contain; + background: transparent; + border-radius: 0; + } + + .tarot-frame-card-fallback { + display: grid; + place-items: center; + padding: 8px; + box-sizing: border-box; + color: #f8fafc; + font-size: 12px; + line-height: 1.2; + text-align: center; + background: + radial-gradient(circle at top, rgba(99, 102, 241, 0.18), transparent 50%), + linear-gradient(180deg, #1e1b4b 0%, #0f172a 100%); + } + + .tarot-frame-card-badge { + position: absolute; + left: 4px; + right: 4px; + bottom: 4px; + padding: 4px 5px; + border-radius: 8px; + background: rgba(2, 6, 23, 0.84); + color: #f8fafc; + font-size: clamp(8px, 0.72vw, 10px); + font-weight: 700; + line-height: 1.15; + letter-spacing: 0.02em; + text-align: center; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7); + white-space: normal; + box-sizing: border-box; + pointer-events: none; + -webkit-user-select: none; + user-select: none; + } + + .tarot-frame-grid.is-info-hidden .tarot-frame-card-badge { + display: none; + } + + .tarot-frame-drag-ghost { + position: fixed; + z-index: 120; + width: 86px; + height: 129px; + pointer-events: none; + border-radius: 14px; + overflow: hidden; + border: 1px solid #818cf8; + background: linear-gradient(180deg, #18181b 0%, #09090b 100%); + box-shadow: 0 20px 42px rgba(0, 0, 0, 0.38); + transform: translate(-50%, -50%); + } + + .tarot-frame-drag-ghost img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } + + .tarot-frame-drag-ghost-label { + position: absolute; + left: 6px; + right: 6px; + bottom: 6px; + padding: 4px 5px; + border-radius: 999px; + background: rgba(2, 6, 23, 0.88); + color: #f8fafc; + font-size: 10px; + font-weight: 700; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + box-sizing: border-box; + } + + body.is-tarot-frame-dragging { + cursor: grabbing; + -webkit-user-select: none; + user-select: none; + } + + @media (max-width: 1180px) { + .tarot-frame-legend { + grid-template-columns: minmax(0, 1fr); + } + } + + @media (max-width: 820px) { + .tarot-frame-view { + padding: 12px; + } + + .tarot-frame-actions { + width: 100%; + } + + .tarot-frame-settings-panel { + left: 0; + right: auto; + } + + .tarot-frame-panel { + padding: 14px; + } + + .tarot-frame-panel { + --frame-cell-size: 28px; + } + + .tarot-frame-card-badge { + font-size: 7px; + padding: 3px 4px; + } + } + .tarot-house-card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; + position: relative; } .tarot-house-card-actions { @@ -857,6 +1290,28 @@ align-items: center; gap: 8px; flex-wrap: wrap; + margin-left: auto; + } + + .tarot-house-settings-panel { + position: absolute; + top: calc(100% + 10px); + right: 0; + z-index: 3; + width: min(560px, 100%); + display: grid; + gap: 10px; + padding: 14px 16px; + border: 1px solid rgba(82, 82, 91, 0.9); + border-radius: 14px; + background: rgba(9, 9, 11, 0.96); + box-shadow: 0 18px 44px rgba(0, 0, 0, 0.34); + -webkit-backdrop-filter: blur(14px); + backdrop-filter: blur(14px); + } + + .tarot-house-settings-panel[hidden] { + display: none; } .tarot-house-toggle { @@ -2897,12 +3352,13 @@ } .tarot-house-card-actions { - display: grid; - grid-template-columns: minmax(0, 1fr); + display: flex; align-items: stretch; gap: 8px; + margin-left: 0; } + .tarot-house-settings-toggle, .tarot-house-toggle, .tarot-house-filter-group, .tarot-house-action-btn { @@ -2910,6 +3366,12 @@ box-sizing: border-box; } + .tarot-house-settings-panel { + position: static; + width: 100%; + max-width: 100%; + } + .tarot-house-filter-group { justify-content: flex-start; } diff --git a/app/ui-chrome.js b/app/ui-chrome.js index d063044..b6af9cc 100644 --- a/app/ui-chrome.js +++ b/app/ui-chrome.js @@ -455,11 +455,13 @@ const topbarEl = document.querySelector(".topbar"); const actionsEl = document.getElementById("topbar-actions"); const menuToggleEl = document.getElementById("topbar-menu-toggle"); + const settingsToggleEl = document.getElementById("open-settings"); return { topbarEl: topbarEl instanceof HTMLElement ? topbarEl : null, actionsEl: actionsEl instanceof HTMLElement ? actionsEl : null, - menuToggleEl: menuToggleEl instanceof HTMLButtonElement ? menuToggleEl : null + menuToggleEl: menuToggleEl instanceof HTMLButtonElement ? menuToggleEl : null, + settingsToggleEl: settingsToggleEl instanceof HTMLButtonElement ? settingsToggleEl : null }; } @@ -482,7 +484,7 @@ } function bindTopbarMobileMenu() { - const { topbarEl, actionsEl, menuToggleEl } = getTopbarElements(); + const { topbarEl, actionsEl, menuToggleEl, settingsToggleEl } = getTopbarElements(); if (!(topbarEl instanceof HTMLElement) || !(actionsEl instanceof HTMLElement) || !(menuToggleEl instanceof HTMLButtonElement)) { return; } @@ -500,6 +502,13 @@ setTopbarMenuOpen(nextOpen); }); + if (settingsToggleEl instanceof HTMLButtonElement && settingsToggleEl.dataset.topbarSettingsReady !== "1") { + settingsToggleEl.dataset.topbarSettingsReady = "1"; + settingsToggleEl.addEventListener("click", () => { + setTopbarMenuOpen(false); + }); + } + actionsEl.addEventListener("click", (event) => { const button = event.target instanceof Element ? event.target.closest("button") diff --git a/app/ui-navigation.js b/app/ui-navigation.js index da4ea32..049cfe1 100644 --- a/app/ui-navigation.js +++ b/app/ui-navigation.js @@ -22,6 +22,81 @@ return config.getMagickDataset?.() || null; } + const DETAIL_VIEW_SELECTOR_BY_SECTION = { + tarot: "#tarot-browse-view .tarot-layout", + cube: "#cube-layout", + zodiac: "#zodiac-section .planet-layout", + "alphabet-letters": "#alphabet-letters-section .planet-layout", + numbers: "#numbers-section .numbers-main-layout", + iching: "#iching-section .planet-layout", + gods: "#gods-section .planet-layout", + calendar: "#calendar-section .planet-layout", + "kabbalah-tree": "#kabbalah-tree-section .kab-layout", + planets: "#planet-section .planet-layout", + elements: "#elements-section .planet-layout" + }; + + function showSectionDetailOnly(sectionKey, persist = false) { + const selector = DETAIL_VIEW_SELECTOR_BY_SECTION[sectionKey]; + if (!selector) { + return false; + } + + const target = document.querySelector(selector); + if (!(target instanceof HTMLElement)) { + return false; + } + + return Boolean(window.TarotChromeUi?.showDetailOnly?.(target, persist)); + } + + function isSectionDetailOnly(sectionKey) { + const selector = DETAIL_VIEW_SELECTOR_BY_SECTION[sectionKey]; + if (!selector) { + return false; + } + + const target = document.querySelector(selector); + if (!(target instanceof HTMLElement)) { + return false; + } + + return target.classList.contains("layout-sidebar-collapsed") + && !target.classList.contains("layout-detail-collapsed"); + } + + function scheduleSectionDetailOnly(sectionKey, persist = false, attempts = 4) { + requestAnimationFrame(() => { + showSectionDetailOnly(sectionKey, persist); + if (!isSectionDetailOnly(sectionKey) && attempts > 1) { + scheduleSectionDetailOnly(sectionKey, persist, attempts - 1); + } + }); + } + + async function prepareTarotBrowseDetailView() { + const ensure = config.ensure || {}; + const referenceData = getReferenceData(); + const magickDataset = getMagickDataset(); + + setActiveSection("tarot"); + config.tarotSpreadUi?.showCardsView?.(); + + if (typeof ensure.ensureTarotSection === "function" && referenceData) { + await ensure.ensureTarotSection(referenceData, magickDataset); + } + + await new Promise((resolve) => { + requestAnimationFrame(resolve); + }); + + await new Promise((resolve) => { + requestAnimationFrame(resolve); + }); + + showSectionDetailOnly("tarot"); + } + function bindClick(element, handler) { if (!element) { return; @@ -60,6 +135,10 @@ } }); + bindClick(elements.openTarotFrameEl, () => { + setActiveSection(getActiveSection() === "tarot-frame" ? "home" : "tarot-frame"); + }); + bindClick(elements.openTarotHouseEl, () => { setActiveSection(getActiveSection() === "tarot-house" ? "home" : "tarot-house"); }); @@ -170,6 +249,7 @@ if (!selected && detail?.wallId) { ui?.selectWallById?.(detail.wallId); } + scheduleSectionDetailOnly("cube"); }); }); @@ -184,6 +264,7 @@ if (signId) { requestAnimationFrame(() => { window.ZodiacSectionUi?.selectBySignId?.(signId); + scheduleSectionDetailOnly("zodiac"); }); } }); @@ -207,22 +288,27 @@ const ui = window.AlphabetSectionUi; if ((alphabet === "hebrew" || (!alphabet && hebrewLetterId)) && hebrewLetterId) { ui?.selectLetterByHebrewId?.(hebrewLetterId); + scheduleSectionDetailOnly("alphabet-letters"); return; } if (alphabet === "greek" && greekName) { ui?.selectGreekLetterByName?.(greekName); + scheduleSectionDetailOnly("alphabet-letters"); return; } if (alphabet === "english" && englishLetter) { ui?.selectEnglishLetter?.(englishLetter); + scheduleSectionDetailOnly("alphabet-letters"); return; } if (alphabet === "arabic" && arabicName) { ui?.selectArabicLetter?.(arabicName); + scheduleSectionDetailOnly("alphabet-letters"); return; } if (alphabet === "enochian" && enochianId) { ui?.selectEnochianLetter?.(enochianId); + scheduleSectionDetailOnly("alphabet-letters"); } }); }); @@ -241,6 +327,7 @@ if (typeof config.selectNumberEntry === "function") { config.selectNumberEntry(normalizedValue); } + scheduleSectionDetailOnly("numbers"); }); }); @@ -259,10 +346,12 @@ const ui = window.IChingSectionUi; if (hexagramNumber != null) { ui?.selectByHexagramNumber?.(hexagramNumber); + scheduleSectionDetailOnly("iching"); return; } if (planetaryInfluence) { ui?.selectByPlanetaryInfluence?.(planetaryInfluence); + scheduleSectionDetailOnly("iching"); } }); }); @@ -284,6 +373,7 @@ if (!viaId && !viaName && pathNo != null) { ui?.selectByPathNo?.(pathNo); } + scheduleSectionDetailOnly("gods"); }); }); @@ -307,6 +397,7 @@ window.CalendarSectionUi?.selectCalendarType?.(calendarId); } window.CalendarSectionUi?.selectByMonthId?.(monthId); + scheduleSectionDetailOnly("calendar"); }); }); @@ -320,6 +411,7 @@ if (pathNo != null) { requestAnimationFrame(() => { window.KabbalahSectionUi?.selectNode?.(pathNo); + scheduleSectionDetailOnly("kabbalah-tree"); }); } }); @@ -337,6 +429,7 @@ setActiveSection("planets"); requestAnimationFrame(() => { window.PlanetSectionUi?.selectByPlanetId?.(planetId); + scheduleSectionDetailOnly("planets"); }); }); @@ -355,38 +448,27 @@ requestAnimationFrame(() => { window.ElementsSectionUi?.selectByElementId?.(elementId); + scheduleSectionDetailOnly("elements"); }); }); - document.addEventListener("nav:tarot-trump", (event) => { - const referenceData = getReferenceData(); - const magickDataset = getMagickDataset(); - if (typeof ensure.ensureTarotSection === "function" && referenceData) { - ensure.ensureTarotSection(referenceData, magickDataset); - } - setActiveSection("tarot"); + document.addEventListener("nav:tarot-trump", async (event) => { + await prepareTarotBrowseDetailView(); const { trumpNumber, cardName } = event?.detail || {}; - requestAnimationFrame(() => { - if (trumpNumber != null) { - window.TarotSectionUi?.selectCardByTrump?.(trumpNumber); - } else if (cardName) { - window.TarotSectionUi?.selectCardByName?.(cardName); - } - }); + + if (trumpNumber != null) { + window.TarotSectionUi?.selectCardByTrump?.(trumpNumber); + } else if (cardName) { + window.TarotSectionUi?.selectCardByName?.(cardName); + } }); - document.addEventListener("kab:view-trump", (event) => { - const referenceData = getReferenceData(); - const magickDataset = getMagickDataset(); - setActiveSection("tarot"); + document.addEventListener("kab:view-trump", async (event) => { + await prepareTarotBrowseDetailView(); const trumpNumber = event?.detail?.trumpNumber; + if (trumpNumber != null) { - if (typeof ensure.ensureTarotSection === "function" && referenceData) { - ensure.ensureTarotSection(referenceData, magickDataset); - } - requestAnimationFrame(() => { - window.TarotSectionUi?.selectCardByTrump?.(trumpNumber); - }); + window.TarotSectionUi?.selectCardByTrump?.(trumpNumber); } }); @@ -401,6 +483,7 @@ } else { kabbalahUi?.selectPathByNumber?.(pathNumber); } + scheduleSectionDetailOnly("kabbalah-tree"); }); } }); diff --git a/app/ui-section-state.js b/app/ui-section-state.js index b8deb7b..48ceb04 100644 --- a/app/ui-section-state.js +++ b/app/ui-section-state.js @@ -9,6 +9,7 @@ "audio-circle", "audio-notes", "tarot", + "tarot-frame", "tarot-house", "astronomy", "planets", @@ -94,8 +95,9 @@ const isAudioCircleOpen = activeSection === "audio-circle"; const isAudioMenuOpen = isAudioNotesOpen || isAudioCircleOpen; const isTarotOpen = activeSection === "tarot"; + const isTarotFrameOpen = activeSection === "tarot-frame"; const isTarotHouseOpen = activeSection === "tarot-house"; - const isTarotMenuOpen = isTarotOpen || isTarotHouseOpen; + const isTarotMenuOpen = isTarotOpen || isTarotFrameOpen || isTarotHouseOpen; const isAstronomyOpen = activeSection === "astronomy"; const isPlanetOpen = activeSection === "planets"; const isCyclesOpen = activeSection === "cycles"; @@ -123,6 +125,7 @@ setHidden(elements.audioCircleSectionEl, !isAudioCircleOpen); setHidden(elements.audioNotesSectionEl, !isAudioNotesOpen); setHidden(elements.tarotSectionEl, !isTarotOpen); + setHidden(elements.tarotFrameSectionEl, !isTarotFrameOpen); setHidden(elements.tarotHouseSectionEl, !isTarotHouseOpen); setHidden(elements.astronomySectionEl, !isAstronomyOpen); setHidden(elements.planetSectionEl, !isPlanetOpen); @@ -152,6 +155,7 @@ toggleActive(elements.openAudioCircleEl, isAudioCircleOpen); toggleActive(elements.openAudioNotesEl, isAudioNotesOpen); setPressed(elements.openTarotEl, isTarotMenuOpen); + toggleActive(elements.openTarotFrameEl, isTarotFrameOpen); toggleActive(elements.openTarotHouseEl, isTarotHouseOpen); config.tarotSpreadUi?.applyViewState?.(); setPressed(elements.openAstronomyEl, isAstronomyMenuOpen); @@ -211,6 +215,11 @@ return; } + if (isTarotFrameOpen) { + ensure.ensureTarotFrameSection?.(referenceData, magickDataset); + return; + } + if (isTarotHouseOpen) { ensure.ensureTarotSection?.(referenceData, magickDataset); return; diff --git a/app/ui-tarot-frame.js b/app/ui-tarot-frame.js new file mode 100644 index 0000000..4404865 --- /dev/null +++ b/app/ui-tarot-frame.js @@ -0,0 +1,1223 @@ +(function () { + "use strict"; + + const tarotCardImages = window.TarotCardImages || {}; + const MONTH_LENGTHS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + const MONTH_ABBR = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + const MINOR_RANKS = new Set(["Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten"]); + const COURT_RANKS = new Set(["Knight", "Queen", "Prince"]); + const EXTRA_SUIT_ORDER = ["wands", "cups", "swords", "disks"]; + const ZODIAC_START_TOKEN_BY_SIGN_ID = { + aries: "03-21", + taurus: "04-20", + gemini: "05-21", + cancer: "06-21", + leo: "07-23", + virgo: "08-23", + libra: "09-23", + scorpio: "10-23", + sagittarius: "11-22", + capricorn: "12-22", + aquarius: "01-20", + pisces: "02-19" + }; + const MASTER_GRID_SIZE = 18; + const EXPORT_SLOT_SIZE = 120; + const EXPORT_CARD_INSET = 0; + const EXPORT_GRID_GAP = 10; + const EXPORT_PADDING = 28; + const EXPORT_BACKGROUND = "#0f0f17"; + const EXPORT_PANEL = "#18181b"; + const EXPORT_CARD_BORDER = "#475569"; + const EXPORT_BADGE_BACKGROUND = "rgba(2, 6, 23, 0.9)"; + const EXPORT_BADGE_TEXT = "#f8fafc"; + const EXPORT_FORMATS = { + webp: { + mimeType: "image/webp", + extension: "webp", + quality: 0.98 + } + }; + const BOARD_LAYOUTS = [ + { + id: "extra-cards", + title: "Extra Row", + description: "Top row for aces, princesses, and the non-zodiac majors.", + positions: Array.from({ length: MASTER_GRID_SIZE }, (_, index) => ({ row: 1, column: index + 1 })), + getOrderedCards(cards) { + return cards + .filter((card) => isExtraTopRowCard(card)) + .sort(compareExtraTopRowCards); + } + }, + { + id: "small-cards", + title: "Small Cards", + description: "Outer perimeter in chronological decan order.", + positions: buildPerimeterPath(10, 5, 5), + getOrderedCards(cards) { + return cards + .filter((card) => isSmallCard(card)) + .sort((left, right) => compareDateTokens(getRelation(left, "decan")?.data?.dateStart, getRelation(right, "decan")?.data?.dateStart, "03-21")); + } + }, + { + id: "court-dates", + title: "Court Dates", + description: "Inner left frame in chronological court-date order.", + positions: buildPerimeterPath(4, 8, 6), + getOrderedCards(cards) { + return cards + .filter((card) => isCourtDateCard(card)) + .sort((left, right) => compareDateTokens(getRelation(left, "courtDateWindow")?.data?.dateStart, getRelation(right, "courtDateWindow")?.data?.dateStart, "11-12")); + } + }, + { + id: "zodiac-trumps", + title: "Zodiac Trumps", + description: "Inner right frame in chronological zodiac order.", + positions: buildPerimeterPath(4, 8, 10), + getOrderedCards(cards) { + return cards + .filter((card) => isZodiacTrump(card)) + .sort((left, right) => { + const leftSignId = normalizeKey(getRelation(left, "zodiacCorrespondence")?.data?.signId); + const rightSignId = normalizeKey(getRelation(right, "zodiacCorrespondence")?.data?.signId); + return compareDateTokens(ZODIAC_START_TOKEN_BY_SIGN_ID[leftSignId], ZODIAC_START_TOKEN_BY_SIGN_ID[rightSignId], "03-21"); + }); + } + } + ]; + + const state = { + initialized: false, + layoutReady: false, + cardSignature: "", + slotAssignments: new Map(), + statusMessage: "Loading tarot cards...", + drag: null, + suppressClick: false, + showInfo: true, + settingsOpen: false, + exportInProgress: false, + exportFormat: "webp" + }; + + let config = { + ensureTarotSection: null, + getCards: () => [] + }; + + function buildPerimeterPath(size, rowOffset = 1, columnOffset = 1) { + const path = []; + for (let column = 0; column < size; column += 1) { + path.push({ row: rowOffset, column: columnOffset + column }); + } + for (let row = 1; row < size - 1; row += 1) { + path.push({ row: rowOffset + row, column: columnOffset + size - 1 }); + } + for (let column = size - 1; column >= 0; column -= 1) { + path.push({ row: rowOffset + size - 1, column: columnOffset + column }); + } + for (let row = size - 2; row >= 1; row -= 1) { + path.push({ row: rowOffset + row, column: columnOffset }); + } + return path; + } + + function getElements() { + return { + tarotFrameBoardEl: document.getElementById("tarot-frame-board"), + tarotFrameStatusEl: document.getElementById("tarot-frame-status"), + tarotFrameResetEl: document.getElementById("tarot-frame-reset"), + tarotFrameSettingsToggleEl: document.getElementById("tarot-frame-settings-toggle"), + tarotFrameSettingsPanelEl: document.getElementById("tarot-frame-settings-panel"), + tarotFrameShowInfoEl: document.getElementById("tarot-frame-show-info"), + tarotFrameExportWebpEl: document.getElementById("tarot-frame-export-webp") + }; + } + + function normalizeLabelText(value) { + return String(value || "").replace(/\s+/g, " ").trim(); + } + + function isSmallCard(card) { + return card?.arcana === "Minor" + && MINOR_RANKS.has(String(card?.rank || "")) + && Boolean(getRelation(card, "decan")); + } + + function isCourtDateCard(card) { + return COURT_RANKS.has(String(card?.rank || "")) + && Boolean(getRelation(card, "courtDateWindow")); + } + + function isZodiacTrump(card) { + return card?.arcana === "Major" + && Boolean(getRelation(card, "zodiacCorrespondence")); + } + + function getExtraTopRowCategory(card) { + const rank = String(card?.rank || "").trim(); + if (rank === "Ace") { + return 0; + } + if (card?.arcana === "Major") { + return 1; + } + if (rank === "Princess") { + return 2; + } + return 3; + } + + function compareSuitOrder(leftSuit, rightSuit) { + const leftIndex = EXTRA_SUIT_ORDER.indexOf(normalizeKey(leftSuit)); + const rightIndex = EXTRA_SUIT_ORDER.indexOf(normalizeKey(rightSuit)); + const safeLeft = leftIndex === -1 ? EXTRA_SUIT_ORDER.length : leftIndex; + const safeRight = rightIndex === -1 ? EXTRA_SUIT_ORDER.length : rightIndex; + return safeLeft - safeRight; + } + + function compareExtraTopRowCards(left, right) { + const categoryDiff = getExtraTopRowCategory(left) - getExtraTopRowCategory(right); + if (categoryDiff !== 0) { + return categoryDiff; + } + + const category = getExtraTopRowCategory(left); + if (category === 0 || category === 2) { + return compareSuitOrder(left?.suit, right?.suit); + } + + if (category === 1) { + return Number(left?.number) - Number(right?.number); + } + + return String(left?.name || "").localeCompare(String(right?.name || "")); + } + + function isExtraTopRowCard(card) { + return Boolean(card) && !isSmallCard(card) && !isCourtDateCard(card) && !isZodiacTrump(card); + } + + function buildReadyStatus(cards) { + return `${Array.isArray(cards) ? cards.length : 0} cards ready. Drag any card to any grid square and it will snap into that spot.`; + } + + function normalizeKey(value) { + return String(value || "").trim().toLowerCase(); + } + + function getCards() { + const cards = config.getCards?.(); + return Array.isArray(cards) ? cards : []; + } + + function getCardId(card) { + return String(card?.id || "").trim(); + } + + function getCardMap(cards) { + return new Map(cards.map((card) => [getCardId(card), card])); + } + + function getRelation(card, type) { + return Array.isArray(card?.relations) + ? card.relations.find((relation) => relation?.type === type) || null + : null; + } + + function parseMonthDayToken(token) { + const match = String(token || "").trim().match(/^(\d{2})-(\d{2})$/); + if (!match) { + return null; + } + + const month = Number(match[1]); + const day = Number(match[2]); + if (!Number.isInteger(month) || !Number.isInteger(day) || month < 1 || month > 12) { + return null; + } + + return { month, day }; + } + + function formatMonthDay(token) { + const parsed = parseMonthDayToken(token); + if (!parsed) { + return ""; + } + return `${MONTH_ABBR[parsed.month - 1]} ${parsed.day}`; + } + + function decrementToken(token) { + const parsed = parseMonthDayToken(token); + if (!parsed) { + return null; + } + + if (parsed.day > 1) { + return `${String(parsed.month).padStart(2, "0")}-${String(parsed.day - 1).padStart(2, "0")}`; + } + + const previousMonth = parsed.month === 1 ? 12 : parsed.month - 1; + const previousDay = MONTH_LENGTHS[previousMonth - 1]; + return `${String(previousMonth).padStart(2, "0")}-${String(previousDay).padStart(2, "0")}`; + } + + function formatDateRange(startToken, endToken) { + const start = parseMonthDayToken(startToken); + const end = parseMonthDayToken(endToken); + if (!start || !end) { + return ""; + } + + const startMonth = MONTH_ABBR[start.month - 1]; + const endMonth = MONTH_ABBR[end.month - 1]; + if (start.month === end.month) { + return `${startMonth} ${start.day}-${end.day}`; + } + return `${startMonth} ${start.day}-${endMonth} ${end.day}`; + } + + function toOrdinalDay(token) { + const parsed = parseMonthDayToken(token); + if (!parsed) { + return Number.POSITIVE_INFINITY; + } + + const daysBeforeMonth = MONTH_LENGTHS.slice(0, parsed.month - 1).reduce((total, length) => total + length, 0); + return daysBeforeMonth + parsed.day; + } + + function getCyclicDayValue(token, cycleStartToken) { + const value = toOrdinalDay(token); + const cycleStart = toOrdinalDay(cycleStartToken); + if (!Number.isFinite(value) || !Number.isFinite(cycleStart)) { + return Number.POSITIVE_INFINITY; + } + + return (value - cycleStart + 365) % 365; + } + + function compareDateTokens(leftToken, rightToken, cycleStartToken) { + return getCyclicDayValue(leftToken, cycleStartToken) - getCyclicDayValue(rightToken, cycleStartToken); + } + + function buildCardSignature(cards) { + return cards.map((card) => getCardId(card)).filter(Boolean).sort().join("|"); + } + + function resolveDeckOptions(card) { + const deckId = String(tarotCardImages.getActiveDeck?.() || "").trim(); + const trumpNumber = card?.arcana === "Major" && Number.isFinite(Number(card?.number)) + ? Number(card.number) + : undefined; + + if (!deckId && !Number.isFinite(trumpNumber)) { + return null; + } + + return { + ...(deckId ? { deckId } : {}), + ...(Number.isFinite(trumpNumber) ? { trumpNumber } : {}) + }; + } + + function resolveCardThumbnail(card) { + if (!card) { + return ""; + } + + const deckOptions = resolveDeckOptions(card) || undefined; + return String( + tarotCardImages.resolveTarotCardThumbnail?.(card.name, deckOptions) + || tarotCardImages.resolveTarotCardImage?.(card.name, deckOptions) + || "" + ).trim(); + } + + function getDisplayCardName(card) { + const label = tarotCardImages.getTarotCardDisplayName?.(card?.name, resolveDeckOptions(card) || undefined); + return String(label || card?.name || "Tarot").trim() || "Tarot"; + } + + function getCardOverlayDate(card) { + const decan = getRelation(card, "decan")?.data || null; + if (decan?.dateStart && decan?.dateEnd) { + return formatDateRange(decan.dateStart, decan.dateEnd); + } + + const court = getRelation(card, "courtDateWindow")?.data || null; + if (court?.dateStart && court?.dateEnd) { + return formatDateRange(court.dateStart, court.dateEnd); + } + + const zodiac = getRelation(card, "zodiacCorrespondence")?.data || null; + const signId = normalizeKey(zodiac?.signId); + const signStart = ZODIAC_START_TOKEN_BY_SIGN_ID[signId]; + if (signStart) { + const signIds = Object.keys(ZODIAC_START_TOKEN_BY_SIGN_ID); + const index = signIds.indexOf(signId); + const nextSignId = signIds[(index + 1) % signIds.length]; + const nextStart = ZODIAC_START_TOKEN_BY_SIGN_ID[nextSignId]; + const endToken = decrementToken(nextStart); + return formatDateRange(signStart, endToken); + } + + return ""; + } + + function getSlotId(row, column) { + return `${row}:${column}`; + } + + function setStatus(message) { + state.statusMessage = String(message || "").trim(); + const { tarotFrameStatusEl } = getElements(); + if (tarotFrameStatusEl) { + tarotFrameStatusEl.textContent = state.statusMessage; + } + } + + function resetLayout(cards = getCards(), nextStatusMessage = "") { + state.slotAssignments.clear(); + + BOARD_LAYOUTS.forEach((layout) => { + const orderedCards = layout.getOrderedCards(cards); + layout.positions.forEach((position, index) => { + state.slotAssignments.set(getSlotId(position.row, position.column), getCardId(orderedCards[index] || null)); + }); + }); + + state.layoutReady = true; + setStatus(nextStatusMessage || buildReadyStatus(cards)); + } + + function getAssignedCard(slotId, cardMap) { + const cardId = String(state.slotAssignments.get(slotId) || "").trim(); + return cardMap.get(cardId) || null; + } + + function getCardOverlayLabel(card) { + return getCardOverlayDate(card) || formatMonthDay(getRelation(card, "decan")?.data?.dateStart) || getDisplayCardName(card); + } + + function createSlot(row, column, card) { + const slotId = getSlotId(row, column); + const slotEl = document.createElement("div"); + slotEl.className = "tarot-frame-slot"; + slotEl.dataset.slotId = slotId; + slotEl.style.gridRow = String(row); + slotEl.style.gridColumn = String(column); + + if (state.drag?.sourceSlotId === slotId) { + slotEl.classList.add("is-drag-source"); + } + + if (state.drag?.hoverSlotId === slotId && state.drag?.started) { + slotEl.classList.add("is-drop-target"); + } + + const button = document.createElement("button"); + button.type = "button"; + button.className = "tarot-frame-card"; + button.dataset.slotId = slotId; + button.draggable = false; + + if (!card) { + slotEl.classList.add("is-empty-slot"); + button.classList.add("is-empty"); + button.tabIndex = -1; + const emptyEl = document.createElement("span"); + emptyEl.className = "tarot-frame-slot-empty"; + button.appendChild(emptyEl); + slotEl.appendChild(button); + return slotEl; + } + + button.dataset.cardId = getCardId(card); + button.setAttribute("aria-label", `${getDisplayCardName(card)} in row ${row}, column ${column}`); + button.title = getDisplayCardName(card); + + const imageSrc = resolveCardThumbnail(card); + if (imageSrc) { + const image = document.createElement("img"); + image.className = "tarot-frame-card-image"; + image.src = imageSrc; + image.alt = getDisplayCardName(card); + image.loading = "lazy"; + image.decoding = "async"; + image.draggable = false; + button.appendChild(image); + } else { + const fallback = document.createElement("span"); + fallback.className = "tarot-frame-card-fallback"; + fallback.textContent = getDisplayCardName(card); + button.appendChild(fallback); + } + + if (state.showInfo) { + const overlay = document.createElement("span"); + overlay.className = "tarot-frame-card-badge"; + overlay.textContent = getCardOverlayLabel(card); + button.appendChild(overlay); + } + + slotEl.appendChild(button); + return slotEl; + } + + function createLegend() { + const legendEl = document.createElement("div"); + legendEl.className = "tarot-frame-legend"; + BOARD_LAYOUTS.forEach((layout) => { + const itemEl = document.createElement("div"); + itemEl.className = "tarot-frame-legend-item"; + + const titleEl = document.createElement("strong"); + titleEl.textContent = layout.title; + const textEl = document.createElement("span"); + textEl.textContent = layout.description; + itemEl.append(titleEl, textEl); + legendEl.appendChild(itemEl); + }); + return legendEl; + } + + function render() { + const { tarotFrameBoardEl } = getElements(); + if (!tarotFrameBoardEl) { + return; + } + + const cards = getCards(); + const cardMap = getCardMap(cards); + tarotFrameBoardEl.replaceChildren(); + + const panelEl = document.createElement("section"); + panelEl.className = "tarot-frame-panel tarot-frame-panel--master"; + + const headEl = document.createElement("div"); + headEl.className = "tarot-frame-panel-head"; + + const titleWrapEl = document.createElement("div"); + const titleEl = document.createElement("h3"); + titleEl.className = "tarot-frame-panel-title"; + titleEl.textContent = "Master 18x18 Frame Grid"; + const subtitleEl = document.createElement("p"); + subtitleEl.className = "tarot-frame-panel-subtitle"; + subtitleEl.textContent = "Top row holds the remaining 18 cards, while the centered frame keeps the small cards, court dates, and zodiac trumps grouped together. Every square on the grid is a snap target for custom layouts."; + titleWrapEl.append(titleEl, subtitleEl); + + const countEl = document.createElement("span"); + countEl.className = "tarot-frame-panel-count"; + countEl.textContent = `${cards.length} cards / ${MASTER_GRID_SIZE * MASTER_GRID_SIZE} cells`; + headEl.append(titleWrapEl, countEl); + + panelEl.append(headEl, createLegend()); + + const gridEl = document.createElement("div"); + gridEl.className = "tarot-frame-grid tarot-frame-grid--master"; + gridEl.classList.toggle("is-info-hidden", !state.showInfo); + gridEl.style.setProperty("--frame-grid-size", String(MASTER_GRID_SIZE)); + + for (let row = 1; row <= MASTER_GRID_SIZE; row += 1) { + for (let column = 1; column <= MASTER_GRID_SIZE; column += 1) { + gridEl.appendChild(createSlot(row, column, getAssignedCard(getSlotId(row, column), cardMap))); + } + } + + panelEl.appendChild(gridEl); + tarotFrameBoardEl.appendChild(panelEl); + } + + function syncControls() { + const { + tarotFrameSettingsToggleEl, + tarotFrameSettingsPanelEl, + tarotFrameShowInfoEl, + tarotFrameExportWebpEl, + tarotFrameResetEl + } = getElements(); + + if (tarotFrameSettingsToggleEl) { + tarotFrameSettingsToggleEl.setAttribute("aria-expanded", state.settingsOpen ? "true" : "false"); + tarotFrameSettingsToggleEl.textContent = state.settingsOpen ? "Hide Settings" : "Settings"; + tarotFrameSettingsToggleEl.disabled = Boolean(state.exportInProgress); + } + + if (tarotFrameSettingsPanelEl) { + tarotFrameSettingsPanelEl.hidden = !state.settingsOpen; + } + + if (tarotFrameShowInfoEl) { + tarotFrameShowInfoEl.checked = Boolean(state.showInfo); + tarotFrameShowInfoEl.disabled = Boolean(state.exportInProgress); + } + + if (tarotFrameExportWebpEl) { + const supportsWebp = isExportFormatSupported("webp"); + tarotFrameExportWebpEl.hidden = !supportsWebp; + tarotFrameExportWebpEl.disabled = Boolean(state.exportInProgress) || !supportsWebp; + tarotFrameExportWebpEl.textContent = state.exportInProgress ? "Exporting..." : "Export WebP"; + if (supportsWebp) { + tarotFrameExportWebpEl.title = "Download the current frame grid arrangement as a WebP image."; + } + } + + if (tarotFrameResetEl) { + tarotFrameResetEl.disabled = Boolean(state.exportInProgress); + } + } + + function getSlotElement(slotId) { + return document.querySelector(`.tarot-frame-slot[data-slot-id="${slotId}"]`); + } + + function setHoverSlot(slotId) { + const previous = state.drag?.hoverSlotId; + if (previous && previous !== slotId) { + getSlotElement(previous)?.classList.remove("is-drop-target"); + } + + if (state.drag) { + state.drag.hoverSlotId = slotId || ""; + } + + if (slotId) { + getSlotElement(slotId)?.classList.add("is-drop-target"); + } + } + + function createDragGhost(card) { + const ghost = document.createElement("div"); + ghost.className = "tarot-frame-drag-ghost"; + + const imageSrc = resolveCardThumbnail(card); + if (imageSrc) { + const image = document.createElement("img"); + image.src = imageSrc; + image.alt = ""; + ghost.appendChild(image); + } + + if (state.showInfo) { + const label = document.createElement("span"); + label.className = "tarot-frame-drag-ghost-label"; + label.textContent = getCardOverlayLabel(card); + ghost.appendChild(label); + } + + document.body.appendChild(ghost); + return ghost; + } + + function moveGhost(ghostEl, clientX, clientY) { + if (!(ghostEl instanceof HTMLElement)) { + return; + } + + ghostEl.style.left = `${clientX}px`; + ghostEl.style.top = `${clientY}px`; + } + + function updateHoverSlotFromPoint(clientX, clientY, sourceSlotId) { + const target = document.elementFromPoint(clientX, clientY); + const slot = target instanceof Element ? target.closest(".tarot-frame-slot[data-slot-id]") : null; + const nextSlotId = slot instanceof HTMLElement ? String(slot.dataset.slotId || "") : ""; + setHoverSlot(nextSlotId && nextSlotId !== sourceSlotId ? nextSlotId : ""); + } + + function detachPointerListeners() { + document.removeEventListener("pointermove", handlePointerMove); + document.removeEventListener("pointerup", handlePointerUp); + document.removeEventListener("pointercancel", handlePointerCancel); + } + + function cleanupDrag() { + if (!state.drag) { + return; + } + + setHoverSlot(""); + getSlotElement(state.drag.sourceSlotId)?.classList.remove("is-drag-source"); + if (state.drag.ghostEl instanceof HTMLElement) { + state.drag.ghostEl.remove(); + } + + state.drag = null; + document.body.classList.remove("is-tarot-frame-dragging"); + detachPointerListeners(); + } + + function swapOrMoveSlots(sourceSlotId, targetSlotId) { + const sourceCardId = String(state.slotAssignments.get(sourceSlotId) || ""); + const targetCardId = String(state.slotAssignments.get(targetSlotId) || ""); + state.slotAssignments.set(targetSlotId, sourceCardId); + if (targetCardId) { + state.slotAssignments.set(sourceSlotId, targetCardId); + } else { + state.slotAssignments.delete(sourceSlotId); + } + } + + function describeSlot(slotId) { + const [rowText, columnText] = String(slotId || "").split(":"); + return `row ${rowText || "?"}, column ${columnText || "?"}`; + } + + function openCardLightbox(cardId) { + const card = getCardMap(getCards()).get(String(cardId || "").trim()) || null; + if (!card) { + return; + } + + const deckOptions = resolveDeckOptions(card); + const src = String( + tarotCardImages.resolveTarotCardImage?.(card.name, deckOptions) + || tarotCardImages.resolveTarotCardThumbnail?.(card.name, deckOptions) + || "" + ).trim(); + + if (!src) { + return; + } + + const label = getDisplayCardName(card); + window.TarotUiLightbox?.open?.({ + src, + altText: label, + label, + cardId: getCardId(card), + deckId: String(tarotCardImages.getActiveDeck?.() || "").trim() + }); + } + + function handlePointerDown(event) { + const target = event.target; + if (!(target instanceof Element) || event.button !== 0) { + return; + } + + const cardButton = target.closest(".tarot-frame-card[data-slot-id][data-card-id]"); + if (!(cardButton instanceof HTMLButtonElement)) { + return; + } + + state.drag = { + pointerId: event.pointerId, + sourceSlotId: String(cardButton.dataset.slotId || ""), + cardId: String(cardButton.dataset.cardId || ""), + startX: event.clientX, + startY: event.clientY, + started: false, + hoverSlotId: "", + ghostEl: null + }; + + detachPointerListeners(); + document.addEventListener("pointermove", handlePointerMove); + document.addEventListener("pointerup", handlePointerUp); + document.addEventListener("pointercancel", handlePointerCancel); + } + + function handlePointerMove(event) { + if (!state.drag || event.pointerId !== state.drag.pointerId) { + return; + } + + const movedEnough = Math.hypot(event.clientX - state.drag.startX, event.clientY - state.drag.startY) >= 6; + if (!state.drag.started && movedEnough) { + const card = getCardMap(getCards()).get(state.drag.cardId) || null; + if (!card) { + cleanupDrag(); + return; + } + + state.drag.started = true; + state.drag.ghostEl = createDragGhost(card); + getSlotElement(state.drag.sourceSlotId)?.classList.add("is-drag-source"); + document.body.classList.add("is-tarot-frame-dragging"); + state.suppressClick = true; + } + + if (!state.drag.started) { + return; + } + + moveGhost(state.drag.ghostEl, event.clientX, event.clientY); + updateHoverSlotFromPoint(event.clientX, event.clientY, state.drag.sourceSlotId); + event.preventDefault(); + } + + function finishDrop() { + if (!state.drag) { + return; + } + + const sourceSlotId = state.drag.sourceSlotId; + const targetSlotId = state.drag.hoverSlotId; + const draggedCard = getCardMap(getCards()).get(state.drag.cardId) || null; + const moved = Boolean(targetSlotId && targetSlotId !== sourceSlotId); + + if (moved) { + swapOrMoveSlots(sourceSlotId, targetSlotId); + render(); + setStatus(`${getDisplayCardName(draggedCard)} snapped to ${describeSlot(targetSlotId)}.`); + } + + cleanupDrag(); + if (!moved) { + state.suppressClick = false; + } + } + + function handlePointerUp(event) { + if (!state.drag || event.pointerId !== state.drag.pointerId) { + return; + } + + if (!state.drag.started) { + cleanupDrag(); + return; + } + + finishDrop(); + } + + function handlePointerCancel(event) { + if (!state.drag || event.pointerId !== state.drag.pointerId) { + return; + } + + cleanupDrag(); + state.suppressClick = false; + } + + function handleBoardClick(event) { + const target = event.target; + if (!(target instanceof Element)) { + return; + } + + const cardButton = target.closest(".tarot-frame-card[data-card-id]"); + if (!(cardButton instanceof HTMLButtonElement)) { + return; + } + + if (state.suppressClick) { + state.suppressClick = false; + return; + } + + openCardLightbox(cardButton.dataset.cardId); + } + + function handleNativeDragStart(event) { + const target = event.target; + if (!(target instanceof Element)) { + return; + } + + if (target.closest(".tarot-frame-card")) { + event.preventDefault(); + } + } + + function handleDocumentClick(event) { + if (!state.settingsOpen) { + return; + } + + const target = event.target; + if (!(target instanceof Node)) { + return; + } + + const { tarotFrameSettingsPanelEl, tarotFrameSettingsToggleEl } = getElements(); + if (tarotFrameSettingsPanelEl?.contains(target) || tarotFrameSettingsToggleEl?.contains(target)) { + return; + } + + state.settingsOpen = false; + syncControls(); + } + + function handleDocumentKeydown(event) { + if (!state.settingsOpen || event.key !== "Escape") { + return; + } + + state.settingsOpen = false; + syncControls(); + } + + function drawRoundedRectPath(context, x, y, width, height, radius) { + const nextRadius = Math.max(0, Math.min(radius, width / 2, height / 2)); + context.beginPath(); + context.moveTo(x + nextRadius, y); + context.lineTo(x + width - nextRadius, y); + context.quadraticCurveTo(x + width, y, x + width, y + nextRadius); + context.lineTo(x + width, y + height - nextRadius); + context.quadraticCurveTo(x + width, y + height, x + width - nextRadius, y + height); + context.lineTo(x + nextRadius, y + height); + context.quadraticCurveTo(x, y + height, x, y + height - nextRadius); + context.lineTo(x, y + nextRadius); + context.quadraticCurveTo(x, y, x + nextRadius, y); + context.closePath(); + } + + function fitCanvasLabelText(context, text, maxWidth) { + const normalized = normalizeLabelText(text); + if (!normalized || context.measureText(normalized).width <= maxWidth) { + return normalized; + } + + let result = normalized; + while (result.length > 1 && context.measureText(`${result}...`).width > maxWidth) { + result = result.slice(0, -1).trimEnd(); + } + return `${result}...`; + } + + function wrapCanvasText(context, text, maxWidth, maxLines = 2) { + const normalized = normalizeLabelText(text); + if (!normalized) { + return []; + } + + const words = normalized.split(/\s+/).filter(Boolean); + const lines = []; + let current = ""; + words.forEach((word) => { + const next = current ? `${current} ${word}` : word; + if (current && context.measureText(next).width > maxWidth) { + lines.push(current); + current = word; + } else { + current = next; + } + }); + if (current) { + lines.push(current); + } + + if (lines.length <= maxLines) { + return lines; + } + + const clipped = lines.slice(0, Math.max(1, maxLines)); + clipped[clipped.length - 1] = fitCanvasLabelText(context, clipped[clipped.length - 1], maxWidth); + return clipped; + } + + function drawImageContain(context, image, x, y, width, height) { + if (!(image instanceof HTMLImageElement) && !(image instanceof ImageBitmap)) { + return; + } + + const sourceWidth = Number(image.width || image.naturalWidth || 0); + const sourceHeight = Number(image.height || image.naturalHeight || 0); + if (!(sourceWidth > 0 && sourceHeight > 0)) { + return; + } + + const scale = Math.min(width / sourceWidth, height / sourceHeight); + const drawWidth = sourceWidth * scale; + const drawHeight = sourceHeight * scale; + const drawX = x + ((width - drawWidth) / 2); + const drawY = y + ((height - drawHeight) / 2); + context.drawImage(image, drawX, drawY, drawWidth, drawHeight); + } + + function drawSlotToCanvas(context, x, y, size, card, image) { + if (!card) { + context.save(); + context.setLineDash([6, 6]); + context.lineWidth = 1.5; + context.strokeStyle = "rgba(148, 163, 184, 0.42)"; + drawRoundedRectPath(context, x + 1, y + 1, size - 2, size - 2, 10); + context.stroke(); + context.restore(); + return; + } + + const cardX = x + EXPORT_CARD_INSET; + const cardY = y + EXPORT_CARD_INSET; + const cardSize = size - (EXPORT_CARD_INSET * 2); + + context.save(); + drawRoundedRectPath(context, cardX, cardY, cardSize, cardSize, 0); + context.clip(); + if (image) { + drawImageContain(context, image, cardX, cardY, cardSize, cardSize); + } else { + context.fillStyle = EXPORT_PANEL; + context.fillRect(cardX, cardY, cardSize, cardSize); + context.fillStyle = "#f8fafc"; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.font = "700 14px 'Segoe UI', sans-serif"; + const lines = wrapCanvasText(context, getDisplayCardName(card), cardSize - 18, 4); + const lineHeight = 18; + let currentY = cardY + (cardSize / 2) - (((Math.max(1, lines.length) - 1) * lineHeight) / 2); + lines.forEach((line) => { + context.fillText(line, cardX + (cardSize / 2), currentY, cardSize - 18); + currentY += lineHeight; + }); + } + context.restore(); + + if (state.showInfo) { + const overlayText = getCardOverlayLabel(card); + if (overlayText) { + const overlayHeight = 30; + const overlayX = cardX + 4; + const overlayY = cardY + cardSize - overlayHeight - 4; + const overlayWidth = cardSize - 8; + drawRoundedRectPath(context, overlayX, overlayY, overlayWidth, overlayHeight, 8); + context.fillStyle = EXPORT_BADGE_BACKGROUND; + context.fill(); + context.fillStyle = EXPORT_BADGE_TEXT; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.font = "700 11px 'Segoe UI', sans-serif"; + const lines = wrapCanvasText(context, overlayText, overlayWidth - 10, 2); + const lineHeight = 12; + let currentY = overlayY + (overlayHeight / 2) - (((Math.max(1, lines.length) - 1) * lineHeight) / 2); + lines.forEach((line) => { + context.fillText(line, overlayX + (overlayWidth / 2), currentY, overlayWidth - 10); + currentY += lineHeight; + }); + } + } + } + + function loadCardImage(src) { + return new Promise((resolve) => { + const image = new Image(); + image.crossOrigin = "anonymous"; + image.decoding = "async"; + image.onload = () => resolve(image); + image.onerror = () => resolve(null); + image.src = src; + }); + } + + function isExportFormatSupported(format) { + const exportFormat = EXPORT_FORMATS[format]; + if (!exportFormat) { + return false; + } + + const probeCanvas = document.createElement("canvas"); + const dataUrl = probeCanvas.toDataURL(exportFormat.mimeType); + return dataUrl.startsWith(`data:${exportFormat.mimeType}`); + } + + function canvasToBlobByFormat(canvas, format) { + const exportFormat = EXPORT_FORMATS[format] || EXPORT_FORMATS.webp; + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) { + resolve(blob); + return; + } + reject(new Error("Canvas export failed.")); + }, exportFormat.mimeType, exportFormat.quality); + }); + } + + async function exportImage(format = "webp") { + const cards = getCards(); + const cardMap = getCardMap(cards); + const exportFormat = EXPORT_FORMATS[format] || EXPORT_FORMATS.webp; + const contentSize = (MASTER_GRID_SIZE * EXPORT_SLOT_SIZE) + ((MASTER_GRID_SIZE - 1) * EXPORT_GRID_GAP); + const canvasSize = contentSize + (EXPORT_PADDING * 2); + const scale = Math.max(1.5, Math.min(2, Number(window.devicePixelRatio) || 1)); + const canvas = document.createElement("canvas"); + canvas.width = Math.ceil(canvasSize * scale); + canvas.height = Math.ceil(canvasSize * scale); + canvas.style.width = `${canvasSize}px`; + canvas.style.height = `${canvasSize}px`; + + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("Canvas context is unavailable."); + } + + context.scale(scale, scale); + context.imageSmoothingEnabled = true; + context.imageSmoothingQuality = "high"; + context.fillStyle = EXPORT_BACKGROUND; + context.fillRect(0, 0, canvasSize, canvasSize); + + const imageCache = new Map(); + cards.forEach((card) => { + const src = resolveCardThumbnail(card); + if (src && !imageCache.has(src)) { + imageCache.set(src, loadCardImage(src)); + } + }); + + const resolvedImages = new Map(); + await Promise.all(cards.map(async (card) => { + const src = resolveCardThumbnail(card); + const image = src ? await imageCache.get(src) : null; + resolvedImages.set(getCardId(card), image || null); + })); + + for (let row = 1; row <= MASTER_GRID_SIZE; row += 1) { + for (let column = 1; column <= MASTER_GRID_SIZE; column += 1) { + const slotId = getSlotId(row, column); + const card = getAssignedCard(slotId, cardMap); + const x = EXPORT_PADDING + ((column - 1) * (EXPORT_SLOT_SIZE + EXPORT_GRID_GAP)); + const y = EXPORT_PADDING + ((row - 1) * (EXPORT_SLOT_SIZE + EXPORT_GRID_GAP)); + drawSlotToCanvas(context, x, y, EXPORT_SLOT_SIZE, card, card ? resolvedImages.get(getCardId(card)) : null); + } + } + + const blob = await canvasToBlobByFormat(canvas, format); + const blobUrl = URL.createObjectURL(blob); + const downloadLink = document.createElement("a"); + const stamp = new Date().toISOString().slice(0, 10); + downloadLink.href = blobUrl; + downloadLink.download = `tarot-frame-grid-${stamp}.${exportFormat.extension}`; + document.body.appendChild(downloadLink); + downloadLink.click(); + downloadLink.remove(); + setTimeout(() => URL.revokeObjectURL(blobUrl), 1000); + } + + async function exportFrame(format = "webp") { + if (state.exportInProgress) { + return; + } + + state.exportInProgress = true; + state.exportFormat = format; + syncControls(); + + try { + await exportImage(format); + setStatus(`Downloaded a ${String(format || "webp").toUpperCase()} export of the current frame grid.`); + } catch (error) { + window.alert(error instanceof Error ? error.message : "Unable to export the Tarot Frame image."); + } finally { + state.exportInProgress = false; + state.exportFormat = "webp"; + syncControls(); + } + } + + function bindEvents() { + const { + tarotFrameBoardEl, + tarotFrameResetEl, + tarotFrameSettingsToggleEl, + tarotFrameSettingsPanelEl, + tarotFrameShowInfoEl, + tarotFrameExportWebpEl + } = getElements(); + if (tarotFrameBoardEl) { + tarotFrameBoardEl.addEventListener("pointerdown", handlePointerDown); + tarotFrameBoardEl.addEventListener("click", handleBoardClick); + tarotFrameBoardEl.addEventListener("dragstart", handleNativeDragStart); + } + + if (tarotFrameResetEl) { + tarotFrameResetEl.addEventListener("click", () => { + const cards = getCards(); + if (!cards.length) { + return; + } + resetLayout(cards, "Master grid reset to the default chronological frame arrangement."); + render(); + }); + } + + if (tarotFrameSettingsToggleEl) { + tarotFrameSettingsToggleEl.addEventListener("click", (event) => { + event.stopPropagation(); + if (state.exportInProgress) { + return; + } + state.settingsOpen = !state.settingsOpen; + syncControls(); + }); + } + + if (tarotFrameSettingsPanelEl) { + tarotFrameSettingsPanelEl.addEventListener("click", (event) => { + event.stopPropagation(); + }); + } + + if (tarotFrameShowInfoEl) { + tarotFrameShowInfoEl.addEventListener("change", () => { + state.showInfo = Boolean(tarotFrameShowInfoEl.checked); + render(); + syncControls(); + }); + } + + if (tarotFrameExportWebpEl) { + tarotFrameExportWebpEl.addEventListener("click", () => { + exportFrame("webp"); + }); + } + + document.addEventListener("click", handleDocumentClick); + document.addEventListener("keydown", handleDocumentKeydown); + } + + async function ensureTarotFrameSection(referenceData, magickDataset) { + if (typeof config.ensureTarotSection === "function") { + await config.ensureTarotSection(referenceData, magickDataset); + } + + const cards = getCards(); + if (!cards.length) { + setStatus("Tarot cards are still loading..."); + return; + } + + const signature = buildCardSignature(cards); + if (!state.layoutReady || state.cardSignature !== signature) { + state.cardSignature = signature; + resetLayout(cards); + } else { + setStatus(state.statusMessage || buildReadyStatus(cards)); + } + + render(); + syncControls(); + } + + function init(nextConfig = {}) { + config = { + ...config, + ...nextConfig + }; + + if (state.initialized) { + return; + } + + bindEvents(); + syncControls(); + state.initialized = true; + } + + window.TarotFrameUi = { + ...(window.TarotFrameUi || {}), + init, + ensureTarotFrameSection, + render, + resetLayout, + exportImage, + isExportFormatSupported + }; +})(); \ No newline at end of file diff --git a/app/ui-tarot-house.js b/app/ui-tarot-house.js index 3bd4988..65cf15d 100644 --- a/app/ui-tarot-house.js +++ b/app/ui-tarot-house.js @@ -58,6 +58,7 @@ getSelectedCardId: () => "", getHouseTopCardsVisible: () => true, getHouseTopInfoModes: () => ({}), + getMagickDataset: () => null, getHouseBottomCardsVisible: () => true, getHouseBottomInfoModes: () => ({}) }; @@ -326,12 +327,33 @@ const courtWindow = getFirstCardRelationByType(card, "courtDateWindow")?.data || null; const decan = getFirstCardRelationByType(card, "decan")?.data || null; const calendar = getFirstCardRelationByType(card, "calendarMonth")?.data || null; + const zodiac = getFirstCardRelationByType(card, "zodiacCorrespondence")?.data + || getFirstCardRelationByType(card, "zodiac")?.data + || null; - const primary = normalizeLabelText(courtWindow?.dateRange || decan?.dateRange || calendar?.dateRange || calendar?.name); + let zodiacDateRange = ""; + if (zodiac?.signId || zodiac?.id || zodiac?.name) { + const zodiacId = normalizeLabelText(zodiac.signId || zodiac.id || zodiac.name).toLowerCase(); + const sign = config.getMagickDataset?.()?.grouped?.astrology?.zodiac?.[zodiacId] || null; + const rulesFrom = Array.isArray(sign?.rulesFrom) ? sign.rulesFrom : []; + if (rulesFrom.length === 2) { + const [[startMonth, startDay], [endMonth, endDay]] = rulesFrom; + const monthLabels = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + const startLabel = Number.isFinite(Number(startMonth)) && Number.isFinite(Number(startDay)) + ? `${monthLabels[Math.max(0, Math.min(11, Number(startMonth) - 1))]} ${Number(startDay)}` + : ""; + const endLabel = Number.isFinite(Number(endMonth)) && Number.isFinite(Number(endDay)) + ? `${monthLabels[Math.max(0, Math.min(11, Number(endMonth) - 1))]} ${Number(endDay)}` + : ""; + zodiacDateRange = normalizeLabelText(startLabel && endLabel ? `${startLabel} - ${endLabel}` : startLabel || endLabel); + } + } + + const primary = normalizeLabelText(courtWindow?.dateRange || decan?.dateRange || calendar?.dateRange || zodiacDateRange || calendar?.name); const secondary = normalizeLabelText( calendar?.name && primary !== calendar.name ? calendar.name - : decan?.signName + : decan?.signName || zodiac?.name ); if (!primary) { @@ -442,6 +464,10 @@ pushLine(buildPathNumberLabel(card)?.primary); } + if (getTopInfoModeEnabled("date")) { + pushLine(buildDateLabel(card)?.primary); + } + if (!lines.length) { return null; } diff --git a/app/ui-tarot-lightbox.js b/app/ui-tarot-lightbox.js index 1059ff5..f32a5d7 100644 --- a/app/ui-tarot-lightbox.js +++ b/app/ui-tarot-lightbox.js @@ -4,6 +4,8 @@ let overlayEl = null; let backdropEl = null; let toolbarEl = null; + let settingsButtonEl = null; + let settingsPanelEl = null; let helpButtonEl = null; let helpPanelEl = null; let compareButtonEl = null; @@ -79,6 +81,7 @@ onSelectCardId: null, overlayOpacity: LIGHTBOX_COMPARE_DEFAULT_OVERLAY_OPACITY, zoomScale: LIGHTBOX_ZOOM_SCALE, + settingsMenuOpen: false, helpOpen: false, primaryRotated: false, overlayRotated: false, @@ -350,6 +353,27 @@ } } + function closeSettingsMenu() { + lightboxState.settingsMenuOpen = false; + if (settingsPanelEl) { + settingsPanelEl.style.display = "none"; + } + } + + function toggleSettingsMenu() { + if (!lightboxState.isOpen || zoomed) { + return; + } + + const nextOpen = !lightboxState.settingsMenuOpen; + lightboxState.settingsMenuOpen = nextOpen; + if (nextOpen) { + lightboxState.helpOpen = false; + closeDeckComparePanel(); + } + applyComparePresentation(); + } + function suppressDeckCompareToggle(durationMs = 400) { suppressDeckCompareToggleUntil = Date.now() + Math.max(0, Number(durationMs) || 0); } @@ -417,6 +441,7 @@ function toggleDeckComparePanel() { if (!lightboxState.allowDeckCompare) { + closeSettingsMenu(); lightboxState.deckComparePickerOpen = true; lightboxState.deckCompareMessage = "Add another registered deck to use deck compare."; applyComparePresentation(); @@ -426,6 +451,7 @@ if (lightboxState.deckComparePickerOpen) { closeDeckComparePanel(); } else { + closeSettingsMenu(); lightboxState.deckComparePickerOpen = true; } lightboxState.deckCompareMessage = lightboxState.availableCompareDecks.length @@ -798,13 +824,33 @@ && mobileInfoPanelEl && mobileInfoPanelEl.style.display !== "none" ); + const settingsPanelVisible = Boolean( + lightboxState.settingsMenuOpen + && settingsPanelEl + && settingsPanelEl.style.display !== "none" + ); + const helpPanelVisible = Boolean( + lightboxState.helpOpen + && helpPanelEl + && helpPanelEl.style.display !== "none" + ); + const deckPickerVisible = Boolean( + lightboxState.deckComparePickerOpen + && deckComparePanelEl + && deckComparePanelEl.style.display !== "none" + ); const toolbarHeight = toolbarEl instanceof HTMLElement && toolbarEl.style.display !== "none" ? toolbarEl.offsetHeight : 0; const infoPanelHeight = mobileInfoPanelVisible && mobileInfoPanelEl instanceof HTMLElement ? mobileInfoPanelEl.offsetHeight : 0; - const bottomOffset = toolbarHeight + (mobileInfoPanelVisible ? infoPanelHeight + 32 : 24); + const floatingPanelHeight = Math.max( + settingsPanelVisible && settingsPanelEl instanceof HTMLElement ? settingsPanelEl.offsetHeight + 12 : 0, + helpPanelVisible && helpPanelEl instanceof HTMLElement ? helpPanelEl.offsetHeight + 12 : 0, + deckPickerVisible && deckComparePanelEl instanceof HTMLElement ? deckComparePanelEl.offsetHeight + 12 : 0 + ); + const bottomOffset = toolbarHeight + floatingPanelHeight + (mobileInfoPanelVisible ? infoPanelHeight + 32 : 24); mobilePrevButtonEl.style.top = "auto"; mobileNextButtonEl.style.top = "auto"; @@ -902,6 +948,19 @@ setOverlayOpacity(lightboxState.overlayOpacity); } + function syncSettingsUi() { + if (!settingsButtonEl || !settingsPanelEl) { + return; + } + + const canShow = lightboxState.isOpen && !zoomed; + settingsButtonEl.style.display = canShow ? "inline-flex" : "none"; + settingsButtonEl.textContent = lightboxState.settingsMenuOpen ? "Hide Settings" : "Settings"; + settingsButtonEl.setAttribute("aria-expanded", canShow && lightboxState.settingsMenuOpen ? "true" : "false"); + settingsPanelEl.style.display = canShow && lightboxState.settingsMenuOpen ? "flex" : "none"; + settingsPanelEl.style.pointerEvents = canShow && lightboxState.settingsMenuOpen ? "auto" : "none"; + } + function syncDeckComparePicker() { if (!deckCompareButtonEl || !deckComparePanelEl || !deckCompareMessageEl || !deckCompareDeckListEl) { return; @@ -1077,21 +1136,6 @@ return; } - const isCompact = isCompactLightboxLayout(); - if (isCompact) { - if (helpButtonEl.parentElement !== toolbarEl) { - toolbarEl.insertBefore(helpButtonEl, zoomControlEl || null); - } - helpButtonEl.style.position = "static"; - helpButtonEl.style.zIndex = "auto"; - } else { - if (helpButtonEl.parentElement !== overlayEl) { - overlayEl.appendChild(helpButtonEl); - } - helpButtonEl.style.position = "fixed"; - helpButtonEl.style.zIndex = "2"; - } - const canShow = lightboxState.isOpen && !zoomed; helpButtonEl.style.display = canShow ? "inline-flex" : "none"; helpPanelEl.style.display = canShow && lightboxState.helpOpen ? "flex" : "none"; @@ -1121,18 +1165,22 @@ const isCompact = isCompactLightboxLayout(); if (!isCompact) { - helpButtonEl.style.right = "auto"; - helpButtonEl.style.top = "24px"; - helpButtonEl.style.left = "24px"; + settingsPanelEl.style.top = "72px"; + settingsPanelEl.style.right = "24px"; + settingsPanelEl.style.bottom = "auto"; + settingsPanelEl.style.left = "auto"; + settingsPanelEl.style.width = "min(320px, calc(100vw - 48px))"; + settingsPanelEl.style.maxHeight = "none"; + settingsPanelEl.style.overflowY = "visible"; helpPanelEl.style.top = "72px"; - helpPanelEl.style.right = "auto"; + helpPanelEl.style.right = "24px"; helpPanelEl.style.bottom = "auto"; - helpPanelEl.style.left = "24px"; + helpPanelEl.style.left = "auto"; helpPanelEl.style.width = "min(320px, calc(100vw - 48px))"; helpPanelEl.style.maxHeight = "none"; helpPanelEl.style.overflowY = "visible"; - deckComparePanelEl.style.top = "24px"; - deckComparePanelEl.style.right = "176px"; + deckComparePanelEl.style.top = "72px"; + deckComparePanelEl.style.right = "24px"; deckComparePanelEl.style.bottom = "auto"; deckComparePanelEl.style.left = "auto"; deckComparePanelEl.style.width = "min(280px, calc(100vw - 48px))"; @@ -1145,6 +1193,7 @@ || !lightboxState.allowOverlayCompare || (!isCompact && lightboxState.compareMode && !hasSecondaryCard()); compareButtonEl.textContent = lightboxState.compareMode ? "Done Overlay" : "Overlay"; + syncSettingsUi(); syncHelpUi(); syncZoomControl(); syncOpacityControl(); @@ -1205,10 +1254,13 @@ toolbarEl.style.flexWrap = "wrap"; toolbarEl.style.alignItems = "center"; toolbarEl.style.justifyContent = "center"; - helpButtonEl.style.top = "auto"; - helpButtonEl.style.right = "auto"; - helpButtonEl.style.bottom = "auto"; - helpButtonEl.style.left = "auto"; + settingsPanelEl.style.top = "auto"; + settingsPanelEl.style.right = "12px"; + settingsPanelEl.style.bottom = "calc(72px + env(safe-area-inset-bottom, 0px))"; + settingsPanelEl.style.left = "12px"; + settingsPanelEl.style.width = "auto"; + settingsPanelEl.style.maxHeight = "min(56svh, 440px)"; + settingsPanelEl.style.overflowY = "auto"; helpPanelEl.style.top = "auto"; helpPanelEl.style.right = "12px"; helpPanelEl.style.bottom = "calc(72px + env(safe-area-inset-bottom, 0px))"; @@ -1540,15 +1592,28 @@ overlayEl.style.pointerEvents = "none"; overlayEl.style.overscrollBehavior = "contain"; + settingsButtonEl = document.createElement("button"); + settingsButtonEl.type = "button"; + settingsButtonEl.textContent = "Settings"; + settingsButtonEl.style.display = "none"; + settingsButtonEl.style.alignItems = "center"; + settingsButtonEl.style.justifyContent = "center"; + settingsButtonEl.style.border = "1px solid rgba(255, 255, 255, 0.2)"; + settingsButtonEl.style.background = "rgba(15, 23, 42, 0.84)"; + settingsButtonEl.style.color = "#f8fafc"; + settingsButtonEl.style.borderRadius = "999px"; + settingsButtonEl.style.padding = "10px 14px"; + settingsButtonEl.style.font = "600 13px/1.1 sans-serif"; + settingsButtonEl.style.cursor = "pointer"; + settingsButtonEl.style.backdropFilter = "blur(12px)"; + helpButtonEl = document.createElement("button"); helpButtonEl.type = "button"; helpButtonEl.textContent = "Help"; - helpButtonEl.style.position = "fixed"; - helpButtonEl.style.top = "24px"; - helpButtonEl.style.left = "24px"; helpButtonEl.style.display = "none"; helpButtonEl.style.alignItems = "center"; helpButtonEl.style.justifyContent = "center"; + helpButtonEl.style.width = "100%"; helpButtonEl.style.border = "1px solid rgba(255, 255, 255, 0.2)"; helpButtonEl.style.background = "rgba(15, 23, 42, 0.84)"; helpButtonEl.style.color = "#f8fafc"; @@ -1557,8 +1622,28 @@ helpButtonEl.style.font = "600 13px/1.1 sans-serif"; helpButtonEl.style.cursor = "pointer"; helpButtonEl.style.backdropFilter = "blur(12px)"; - helpButtonEl.style.pointerEvents = "auto"; - helpButtonEl.style.zIndex = "2"; + + settingsPanelEl = document.createElement("div"); + settingsPanelEl.style.position = "fixed"; + settingsPanelEl.style.top = "72px"; + settingsPanelEl.style.right = "24px"; + settingsPanelEl.style.display = "none"; + settingsPanelEl.style.flexDirection = "column"; + settingsPanelEl.style.gap = "10px"; + settingsPanelEl.style.width = "min(320px, calc(100vw - 48px))"; + settingsPanelEl.style.padding = "14px 16px"; + settingsPanelEl.style.borderRadius = "18px"; + settingsPanelEl.style.background = "rgba(2, 6, 23, 0.88)"; + settingsPanelEl.style.border = "1px solid rgba(148, 163, 184, 0.16)"; + settingsPanelEl.style.color = "#f8fafc"; + settingsPanelEl.style.boxShadow = "0 16px 42px rgba(0, 0, 0, 0.34)"; + settingsPanelEl.style.backdropFilter = "blur(12px)"; + settingsPanelEl.style.pointerEvents = "auto"; + settingsPanelEl.style.zIndex = "3"; + + const settingsTitleEl = document.createElement("div"); + settingsTitleEl.textContent = "Lightbox Settings"; + settingsTitleEl.style.font = "700 13px/1.3 sans-serif"; helpPanelEl = document.createElement("div"); helpPanelEl.style.position = "fixed"; @@ -1640,6 +1725,10 @@ compareButtonEl.style.font = "600 13px/1.1 sans-serif"; compareButtonEl.style.cursor = "pointer"; compareButtonEl.style.backdropFilter = "blur(12px)"; + compareButtonEl.style.display = "inline-flex"; + compareButtonEl.style.alignItems = "center"; + compareButtonEl.style.justifyContent = "center"; + compareButtonEl.style.width = "100%"; deckCompareButtonEl = document.createElement("button"); deckCompareButtonEl.type = "button"; @@ -1652,10 +1741,15 @@ deckCompareButtonEl.style.font = "600 13px/1.1 sans-serif"; deckCompareButtonEl.style.cursor = "pointer"; deckCompareButtonEl.style.backdropFilter = "blur(12px)"; + deckCompareButtonEl.style.alignItems = "center"; + deckCompareButtonEl.style.justifyContent = "center"; + deckCompareButtonEl.style.width = "100%"; zoomControlEl = document.createElement("label"); zoomControlEl.style.display = "flex"; zoomControlEl.style.alignItems = "center"; + zoomControlEl.style.justifyContent = "space-between"; + zoomControlEl.style.width = "100%"; zoomControlEl.style.gap = "8px"; zoomControlEl.style.padding = "10px 14px"; zoomControlEl.style.border = "1px solid rgba(255, 255, 255, 0.2)"; @@ -1687,6 +1781,8 @@ opacityControlEl = document.createElement("label"); opacityControlEl.style.display = "none"; opacityControlEl.style.alignItems = "center"; + opacityControlEl.style.justifyContent = "space-between"; + opacityControlEl.style.width = "100%"; opacityControlEl.style.gap = "8px"; opacityControlEl.style.padding = "10px 14px"; opacityControlEl.style.border = "1px solid rgba(255, 255, 255, 0.2)"; @@ -1780,6 +1876,9 @@ mobileInfoButtonEl.style.font = "600 13px/1.1 sans-serif"; mobileInfoButtonEl.style.cursor = "pointer"; mobileInfoButtonEl.style.backdropFilter = "blur(12px)"; + mobileInfoButtonEl.style.alignItems = "center"; + mobileInfoButtonEl.style.justifyContent = "center"; + mobileInfoButtonEl.style.width = "100%"; mobileInfoPrimaryTabEl = document.createElement("button"); mobileInfoPrimaryTabEl.type = "button"; @@ -1793,6 +1892,9 @@ mobileInfoPrimaryTabEl.style.font = "600 13px/1.1 sans-serif"; mobileInfoPrimaryTabEl.style.cursor = "pointer"; mobileInfoPrimaryTabEl.style.backdropFilter = "blur(12px)"; + mobileInfoPrimaryTabEl.style.alignItems = "center"; + mobileInfoPrimaryTabEl.style.justifyContent = "center"; + mobileInfoPrimaryTabEl.style.width = "100%"; mobileInfoSecondaryTabEl = document.createElement("button"); mobileInfoSecondaryTabEl.type = "button"; @@ -1806,16 +1908,22 @@ mobileInfoSecondaryTabEl.style.font = "600 13px/1.1 sans-serif"; mobileInfoSecondaryTabEl.style.cursor = "pointer"; mobileInfoSecondaryTabEl.style.backdropFilter = "blur(12px)"; + mobileInfoSecondaryTabEl.style.alignItems = "center"; + mobileInfoSecondaryTabEl.style.justifyContent = "center"; + mobileInfoSecondaryTabEl.style.width = "100%"; - toolbarEl.append( + settingsPanelEl.append( + settingsTitleEl, compareButtonEl, deckCompareButtonEl, mobileInfoButtonEl, mobileInfoPrimaryTabEl, mobileInfoSecondaryTabEl, + helpButtonEl, zoomControlEl, opacityControlEl ); + toolbarEl.append(settingsButtonEl); stageEl = document.createElement("div"); stageEl.style.position = "fixed"; @@ -2106,7 +2214,7 @@ overlayLayerEl.appendChild(overlayImageEl); frameEl.append(baseLayerEl, overlayLayerEl, mobileInfoPanelEl); stageEl.append(frameEl, compareGridEl, primaryInfoEl, secondaryInfoEl); - overlayEl.append(backdropEl, stageEl, toolbarEl, deckComparePanelEl, helpButtonEl, helpPanelEl, mobilePrevButtonEl, mobileNextButtonEl); + overlayEl.append(backdropEl, stageEl, toolbarEl, settingsPanelEl, deckComparePanelEl, helpPanelEl, mobilePrevButtonEl, mobileNextButtonEl); const close = () => { if (!overlayEl || !imageEl || !overlayImageEl) { @@ -2134,6 +2242,7 @@ lightboxState.onSelectCardId = null; lightboxState.overlayOpacity = LIGHTBOX_COMPARE_DEFAULT_OVERLAY_OPACITY; lightboxState.zoomScale = LIGHTBOX_ZOOM_SCALE; + lightboxState.settingsMenuOpen = false; lightboxState.helpOpen = false; lightboxState.primaryRotated = false; lightboxState.overlayRotated = false; @@ -2169,8 +2278,6 @@ lightboxState.compareMode = !lightboxState.compareMode; if (!lightboxState.compareMode) { clearSecondaryCard(); - } else if (isCompactLightboxLayout()) { - lightboxState.mobileInfoOpen = true; } applyComparePresentation(); } @@ -2311,9 +2418,24 @@ backdropEl.addEventListener("click", close); helpButtonEl.addEventListener("click", () => { lightboxState.helpOpen = !lightboxState.helpOpen; + if (lightboxState.helpOpen) { + closeSettingsMenu(); + } syncHelpUi(); restoreLightboxFocus(); }); + settingsButtonEl.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + toggleSettingsMenu(); + restoreLightboxFocus(); + }); + settingsPanelEl.addEventListener("pointerdown", (event) => { + event.stopPropagation(); + }); + settingsPanelEl.addEventListener("click", (event) => { + event.stopPropagation(); + }); compareButtonEl.addEventListener("click", () => { toggleCompareMode(); restoreLightboxFocus(); @@ -2646,10 +2768,11 @@ : null; lightboxState.overlayOpacity = LIGHTBOX_COMPARE_DEFAULT_OVERLAY_OPACITY; lightboxState.zoomScale = LIGHTBOX_ZOOM_SCALE; + lightboxState.settingsMenuOpen = false; lightboxState.helpOpen = false; lightboxState.primaryRotated = false; lightboxState.overlayRotated = false; - lightboxState.mobileInfoOpen = isCompactLightboxLayout(); + lightboxState.mobileInfoOpen = false; lightboxState.mobileInfoView = "primary"; imageEl.src = normalizedPrimary.src; diff --git a/app/ui-tarot.js b/app/ui-tarot.js index bb67bb2..0797429 100644 --- a/app/ui-tarot.js +++ b/app/ui-tarot.js @@ -27,7 +27,8 @@ planet: true, zodiac: true, trump: true, - path: true + path: true, + date: false }, houseBottomCardsVisible: true, houseBottomInfoModes: { @@ -39,6 +40,7 @@ }, houseExportInProgress: false, houseExportFormat: "png", + houseSettingsOpen: false, magickDataset: null, referenceData: null, monthRefsByCardId: new Map(), @@ -281,12 +283,15 @@ tarotHouseOfCardsEl: document.getElementById("tarot-house-of-cards"), tarotBrowseViewEl: document.getElementById("tarot-browse-view"), tarotHouseViewEl: document.getElementById("tarot-house-view"), + tarotHouseSettingsToggleEl: document.getElementById("tarot-house-settings-toggle"), + tarotHouseSettingsPanelEl: document.getElementById("tarot-house-settings-panel"), tarotHouseTopCardsVisibleEl: document.getElementById("tarot-house-top-cards-visible"), tarotHouseTopInfoHebrewEl: document.getElementById("tarot-house-top-info-hebrew"), tarotHouseTopInfoPlanetEl: document.getElementById("tarot-house-top-info-planet"), tarotHouseTopInfoZodiacEl: document.getElementById("tarot-house-top-info-zodiac"), tarotHouseTopInfoTrumpEl: document.getElementById("tarot-house-top-info-trump"), tarotHouseTopInfoPathEl: document.getElementById("tarot-house-top-info-path"), + tarotHouseTopInfoDateEl: document.getElementById("tarot-house-top-info-date"), tarotHouseBottomCardsVisibleEl: document.getElementById("tarot-house-bottom-cards-visible"), tarotHouseBottomInfoZodiacEl: document.getElementById("tarot-house-bottom-info-zodiac"), tarotHouseBottomInfoDecanEl: document.getElementById("tarot-house-bottom-info-decan"), @@ -544,6 +549,15 @@ elements.tarotHouseViewEl.classList.toggle("is-house-focus", Boolean(state.houseFocusMode)); } + if (elements?.tarotHouseSettingsToggleEl) { + elements.tarotHouseSettingsToggleEl.setAttribute("aria-expanded", state.houseSettingsOpen ? "true" : "false"); + elements.tarotHouseSettingsToggleEl.textContent = state.houseSettingsOpen ? "Hide Settings" : "Settings"; + } + + if (elements?.tarotHouseSettingsPanelEl) { + elements.tarotHouseSettingsPanelEl.hidden = !state.houseSettingsOpen; + } + if (elements?.tarotHouseTopCardsVisibleEl) { elements.tarotHouseTopCardsVisibleEl.checked = Boolean(state.houseTopCardsVisible); elements.tarotHouseTopCardsVisibleEl.disabled = Boolean(state.houseExportInProgress); @@ -554,6 +568,7 @@ setHouseBottomInfoCheckboxState(elements?.tarotHouseTopInfoZodiacEl, state.houseTopInfoModes.zodiac); setHouseBottomInfoCheckboxState(elements?.tarotHouseTopInfoTrumpEl, state.houseTopInfoModes.trump); setHouseBottomInfoCheckboxState(elements?.tarotHouseTopInfoPathEl, state.houseTopInfoModes.path); + setHouseBottomInfoCheckboxState(elements?.tarotHouseTopInfoDateEl, state.houseTopInfoModes.date); if (elements?.tarotHouseBottomCardsVisibleEl) { elements.tarotHouseBottomCardsVisibleEl.checked = Boolean(state.houseBottomCardsVisible); @@ -839,6 +854,7 @@ getSelectedCardId: () => state.selectedCardId, getHouseTopCardsVisible: () => state.houseTopCardsVisible, getHouseTopInfoModes: () => ({ ...state.houseTopInfoModes }), + getMagickDataset: () => state.magickDataset, getHouseBottomCardsVisible: () => state.houseBottomCardsVisible, getHouseBottomInfoModes: () => ({ ...state.houseBottomInfoModes }) }); @@ -929,12 +945,47 @@ }); } + if (elements.tarotHouseSettingsToggleEl) { + elements.tarotHouseSettingsToggleEl.addEventListener("click", (event) => { + event.stopPropagation(); + state.houseSettingsOpen = !state.houseSettingsOpen; + syncHouseControls(elements); + }); + } + + if (elements.tarotHouseSettingsPanelEl) { + elements.tarotHouseSettingsPanelEl.addEventListener("click", (event) => { + event.stopPropagation(); + }); + } + + document.addEventListener("click", (event) => { + if (!state.houseSettingsOpen) { + return; + } + + const target = event.target; + if (!(target instanceof Node)) { + return; + } + + const settingsPanelEl = elements.tarotHouseSettingsPanelEl; + const settingsToggleEl = elements.tarotHouseSettingsToggleEl; + if (settingsPanelEl?.contains(target) || settingsToggleEl?.contains(target)) { + return; + } + + state.houseSettingsOpen = false; + syncHouseControls(elements); + }); + [ [elements.tarotHouseTopInfoHebrewEl, "hebrew"], [elements.tarotHouseTopInfoPlanetEl, "planet"], [elements.tarotHouseTopInfoZodiacEl, "zodiac"], [elements.tarotHouseTopInfoTrumpEl, "trump"], - [elements.tarotHouseTopInfoPathEl, "path"] + [elements.tarotHouseTopInfoPathEl, "path"], + [elements.tarotHouseTopInfoDateEl, "date"] ].forEach(([checkbox, key]) => { if (!checkbox) { return; diff --git a/index.html b/index.html index 164c3fd..1ad1b43 100644 --- a/index.html +++ b/index.html @@ -16,12 +16,13 @@ - +
+
@@ -68,12 +69,12 @@
-
@@ -307,6 +308,31 @@
+