From d47e63df6dc6ce27e528929ba14b06b89ae6cd5d Mon Sep 17 00:00:00 2001 From: Nose Date: Sat, 28 Mar 2026 17:31:45 -0700 Subject: [PATCH] made tarot section more mobile friendly --- app/styles.css | 97 +++++ app/ui-tarot-house.js | 52 ++- app/ui-tarot-lightbox.js | 798 +++++++++++++++++++++++++++++++++++++-- app/ui-tarot.js | 26 +- index.html | 10 +- 5 files changed, 918 insertions(+), 65 deletions(-) diff --git a/app/styles.css b/app/styles.css index 2b3fe56..449a642 100644 --- a/app/styles.css +++ b/app/styles.css @@ -2882,6 +2882,103 @@ gap: 14px; } + @media (max-width: 900px) { + #tarot-house-view { + padding: 10px; + } + + #tarot-house-view .tarot-house-card { + gap: 10px; + } + + .tarot-house-card-head { + flex-direction: column; + align-items: stretch; + } + + .tarot-house-card-actions { + display: grid; + grid-template-columns: minmax(0, 1fr); + align-items: stretch; + gap: 8px; + } + + .tarot-house-toggle, + .tarot-house-filter-group, + .tarot-house-action-btn { + width: 100%; + box-sizing: border-box; + } + + .tarot-house-filter-group { + justify-content: flex-start; + } + + .tarot-house-layout { + --tarot-house-card-gap: 2px; + --tarot-house-row-gap: 4px; + --tarot-house-section-gap: 8px; + --tarot-house-card-width: clamp(28px, calc((100vw - 56px) / 11), 42px); + --tarot-house-card-height: calc(var(--tarot-house-card-width) * 1.5); + width: 100%; + max-width: 100%; + } + + .tarot-house-trumps { + overflow-x: visible; + } + + .tarot-house-trump-row, + .tarot-house-bottom-grid { + width: 100%; + } + + .tarot-house-bottom-grid { + justify-content: center; + } + + .tarot-house-card-btn.is-selected { + transform: scale(1.08); + } + + .tarot-house-card-label { + left: 2px; + right: 2px; + bottom: 2px; + padding: 2px 3px; + font-size: 6.5px; + line-height: 1.1; + } + + .tarot-house-card-label-secondary { + margin-top: 1px; + font-size: 6px; + } + + .tarot-house-card-text-face { + gap: 3px; + padding: 5px 4px; + } + + .tarot-house-card-text-face.is-dense { + gap: 2px; + padding: 4px 3px; + } + + .tarot-house-card-text-face.is-top-hebrew .tarot-house-card-text-primary { + font-size: 14px; + } + + .tarot-house-card-text-primary { + font-size: 8px; + } + + .tarot-house-card-text-secondary, + .tarot-house-card-fallback { + font-size: 6px; + } + } + /* ── Tarot Spread View ─────────────────────────────── */ #tarot-spread-view { display: flex; diff --git a/app/ui-tarot-house.js b/app/ui-tarot-house.js index b826b3d..3bd4988 100644 --- a/app/ui-tarot-house.js +++ b/app/ui-tarot-house.js @@ -46,6 +46,7 @@ const config = { resolveTarotCardImage: null, resolveTarotCardThumbnail: null, + getActiveDeck: () => "", getDisplayCardName: (card) => card?.name || "", clearChildren: () => {}, normalizeTarotCardLookupName: (value) => String(value || "").trim().toLowerCase(), @@ -610,6 +611,34 @@ houseImageObserver = null; } + function isCompactHouseLayout() { + if (typeof window === "undefined") { + return false; + } + + if (typeof window.matchMedia === "function") { + return window.matchMedia("(max-width: 900px)").matches; + } + + return Number(window.innerWidth) <= 900; + } + + function buildHouseCardDeckOptions(card) { + const deckId = String(config.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 hydrateHouseCardImage(image) { if (!(image instanceof HTMLImageElement)) { return; @@ -626,7 +655,7 @@ } function getHouseImageObserver(elements) { - const root = elements?.tarotHouseOfCardsEl?.closest(".tarot-section-house-top") || null; + const root = elements?.tarotHouseViewEl || elements?.tarotHouseOfCardsEl?.closest(".tarot-section-house-top") || null; if (!root || typeof IntersectionObserver !== "function") { return null; } @@ -659,6 +688,11 @@ return; } + if (isCompactHouseLayout()) { + hydrateHouseCardImage(image); + return; + } + const observer = getHouseImageObserver(elements); if (!observer) { hydrateHouseCardImage(image); @@ -685,6 +719,7 @@ const cardDisplayName = config.getDisplayCardName(card); const label = buildHouseCardLabel(card); const showImage = isHouseCardImageVisible(card); + const deckOptions = buildHouseCardDeckOptions(card); const labelText = label?.secondary ? `${label.primary} - ${label.secondary}` : label?.primary || ""; @@ -692,8 +727,11 @@ button.setAttribute("aria-label", labelText ? `${cardDisplayName || card.name}, ${labelText}` : (cardDisplayName || card.name)); button.dataset.houseCardId = card.id; const imageUrl = typeof config.resolveTarotCardThumbnail === "function" - ? config.resolveTarotCardThumbnail(card.name) - : (typeof config.resolveTarotCardImage === "function" ? config.resolveTarotCardImage(card.name) : null); + ? config.resolveTarotCardThumbnail(card.name, deckOptions || undefined) + : (typeof config.resolveTarotCardImage === "function" ? config.resolveTarotCardImage(card.name, deckOptions || undefined) : null); + const fullImageUrl = typeof config.resolveTarotCardImage === "function" + ? config.resolveTarotCardImage(card.name, deckOptions || undefined) + : imageUrl; if (showImage && imageUrl) { const image = document.createElement("img"); @@ -704,11 +742,19 @@ image.decoding = "async"; image.fetchPriority = config.isHouseFocusMode?.() === true ? "auto" : "low"; image.dataset.src = imageUrl; + image.dataset.fullSrc = fullImageUrl || imageUrl; image.addEventListener("load", () => { image.classList.remove("is-loading"); image.classList.add("is-loaded"); }, { once: true }); image.addEventListener("error", () => { + const fallbackSrc = String(image.dataset.fullSrc || "").trim(); + if (fallbackSrc && fallbackSrc !== image.currentSrc && image.dataset.fullImageTried !== "true") { + image.dataset.fullImageTried = "true"; + image.dataset.imageHydrated = "true"; + image.src = fallbackSrc; + return; + } image.classList.remove("is-loading"); image.classList.remove("is-loaded"); image.dataset.imageHydrated = "false"; diff --git a/app/ui-tarot-lightbox.js b/app/ui-tarot-lightbox.js index 13e9ec0..1059ff5 100644 --- a/app/ui-tarot-lightbox.js +++ b/app/ui-tarot-lightbox.js @@ -8,6 +8,9 @@ let helpPanelEl = null; let compareButtonEl = null; let deckCompareButtonEl = null; + let mobileInfoButtonEl = null; + let mobileInfoPrimaryTabEl = null; + let mobileInfoSecondaryTabEl = null; let deckComparePanelEl = null; let deckCompareMessageEl = null; let deckCompareDeckListEl = null; @@ -32,9 +35,21 @@ let secondaryTitleEl = null; let secondaryGroupsEl = null; let secondaryHintEl = null; + let mobileInfoPanelEl = null; + let mobileInfoTitleEl = null; + let mobileInfoGroupsEl = null; + let mobileInfoHintEl = null; + let mobilePrevButtonEl = null; + let mobileNextButtonEl = null; let compareGridSlots = []; let zoomed = false; let previousFocusedEl = null; + let activePointerId = null; + let activePointerStartX = 0; + let activePointerStartY = 0; + let activePointerMoved = false; + let suppressNextCardClick = false; + let suppressDeckCompareToggleUntil = 0; const LIGHTBOX_ZOOM_SCALE = 6.66; const LIGHTBOX_ZOOM_STEP = 0.1; @@ -67,6 +82,8 @@ helpOpen: false, primaryRotated: false, overlayRotated: false, + mobileInfoOpen: false, + mobileInfoView: "primary", zoomOriginX: 50, zoomOriginY: 50 }; @@ -75,6 +92,70 @@ return Boolean(lightboxState.secondaryCard?.src); } + function isCompactLightboxLayout() { + if (typeof window === "undefined") { + return false; + } + + if (typeof window.matchMedia === "function") { + return window.matchMedia("(max-width: 900px)").matches; + } + + return Number(window.innerWidth) <= 900; + } + + function hasSequenceNavigation() { + return Array.isArray(lightboxState.sequenceIds) + && lightboxState.sequenceIds.length > 1 + && typeof lightboxState.resolveCardById === "function"; + } + + function getActiveMobileInfoView() { + return lightboxState.compareMode && hasSecondaryCard() && lightboxState.mobileInfoView === "overlay" + ? "overlay" + : "primary"; + } + + function getEffectiveMaxCompareDecks() { + return isCompactLightboxLayout() + ? Math.min(1, lightboxState.maxCompareDecks) + : lightboxState.maxCompareDecks; + } + + function getCompareDeckLimitMessage() { + const compareDeckLimit = getEffectiveMaxCompareDecks(); + if (compareDeckLimit === 1 && isCompactLightboxLayout()) { + return "Choose 1 extra deck on mobile."; + } + + return `Choose up to ${compareDeckLimit} extra decks.`; + } + + function shouldHandleCompactPointerGesture(event) { + return Boolean( + lightboxState.isOpen + && isCompactLightboxLayout() + && event?.pointerType + && event.pointerType !== "mouse" + ); + } + + function clearActivePointerGesture() { + activePointerId = null; + activePointerStartX = 0; + activePointerStartY = 0; + activePointerMoved = false; + } + + function consumeSuppressedCardClick() { + if (!suppressNextCardClick) { + return false; + } + + suppressNextCardClick = false; + return true; + } + function clampOverlayOpacity(value) { const numericValue = Number(value); if (!Number.isFinite(numericValue)) { @@ -195,10 +276,15 @@ return; } + const compareDeckLimit = getEffectiveMaxCompareDecks(); lightboxState.deckCompareCards = lightboxState.selectedCompareDeckIds - .slice(0, lightboxState.maxCompareDecks) + .slice(0, compareDeckLimit) .map((deckId) => resolveDeckCardRequest(lightboxState.primaryCard.cardId, deckId)) .filter(Boolean); + + if (lightboxState.selectedCompareDeckIds.length > compareDeckLimit) { + lightboxState.selectedCompareDeckIds = lightboxState.selectedCompareDeckIds.slice(0, compareDeckLimit); + } } function hasDeckCompareCards() { @@ -235,6 +321,7 @@ function clearSecondaryCard() { lightboxState.secondaryCard = null; + lightboxState.mobileInfoView = "primary"; if (overlayImageEl) { overlayImageEl.removeAttribute("src"); overlayImageEl.alt = ""; @@ -249,14 +336,33 @@ lightboxState.deckCompareMode = false; lightboxState.selectedCompareDeckIds = []; lightboxState.deckCompareCards = []; - lightboxState.deckComparePickerOpen = false; + closeDeckComparePanel(); lightboxState.deckCompareMessage = ""; } + function closeDeckComparePanel() { + lightboxState.deckComparePickerOpen = false; + if (deckComparePanelEl) { + deckComparePanelEl.style.display = "none"; + } + if (deckCompareDeckListEl) { + deckCompareDeckListEl.replaceChildren(); + } + } + + function suppressDeckCompareToggle(durationMs = 400) { + suppressDeckCompareToggleUntil = Date.now() + Math.max(0, Number(durationMs) || 0); + } + + function shouldSuppressDeckCompareToggle() { + return Date.now() < suppressDeckCompareToggleUntil; + } + function updateDeckCompareMode(deckIds, preservePanel = true) { + const compareDeckLimit = getEffectiveMaxCompareDecks(); const uniqueDeckIds = [...new Set((Array.isArray(deckIds) ? deckIds : []).map((deckId) => String(deckId || "").trim()).filter(Boolean))] .filter((deckId) => deckId !== lightboxState.activeDeckId) - .slice(0, lightboxState.maxCompareDecks); + .slice(0, compareDeckLimit); lightboxState.selectedCompareDeckIds = uniqueDeckIds; lightboxState.deckCompareMode = uniqueDeckIds.length > 0; @@ -265,7 +371,7 @@ if (!lightboxState.deckCompareMode) { lightboxState.deckCompareCards = []; if (!preservePanel) { - lightboxState.deckComparePickerOpen = false; + closeDeckComparePanel(); } return; } @@ -281,23 +387,31 @@ return; } + const compareDeckLimit = getEffectiveMaxCompareDecks(); + const nextSelection = [...lightboxState.selectedCompareDeckIds]; const existingIndex = nextSelection.indexOf(normalizedDeckId); if (existingIndex >= 0) { nextSelection.splice(existingIndex, 1); updateDeckCompareMode(nextSelection); + suppressDeckCompareToggle(); + closeDeckComparePanel(); applyComparePresentation(); return; } - if (nextSelection.length >= lightboxState.maxCompareDecks) { - lightboxState.deckCompareMessage = `You can compare up to ${lightboxState.maxCompareDecks} decks at once.`; + if (nextSelection.length >= compareDeckLimit) { + lightboxState.deckCompareMessage = compareDeckLimit === 1 && isCompactLightboxLayout() + ? "Mobile compare supports one extra deck at a time." + : `You can compare up to ${compareDeckLimit} decks at once.`; applyComparePresentation(); return; } nextSelection.push(normalizedDeckId); updateDeckCompareMode(nextSelection); + suppressDeckCompareToggle(); + closeDeckComparePanel(); applyComparePresentation(); } @@ -309,7 +423,11 @@ return; } - lightboxState.deckComparePickerOpen = !lightboxState.deckComparePickerOpen; + if (lightboxState.deckComparePickerOpen) { + closeDeckComparePanel(); + } else { + lightboxState.deckComparePickerOpen = true; + } lightboxState.deckCompareMessage = lightboxState.availableCompareDecks.length ? "" : "Add another registered deck to use deck compare."; @@ -575,7 +693,7 @@ } panelEl.style.display = "flex"; - titleEl.textContent = `${roleLabel}: ${cardRequest.label}`; + titleEl.textContent = roleLabel ? `${roleLabel}: ${cardRequest.label}` : cardRequest.label; groupsEl.replaceChildren(); if (Array.isArray(cardRequest.compareDetails) && cardRequest.compareDetails.length) { @@ -594,17 +712,139 @@ hintEl.style.display = hintText ? "block" : "none"; } + function renderMobileInfoPanel(cardRequest, roleLabel, hintText, isVisible) { + renderComparePanel( + mobileInfoPanelEl, + mobileInfoTitleEl, + mobileInfoGroupsEl, + mobileInfoHintEl, + cardRequest, + roleLabel, + hintText, + isVisible + ); + } + + function syncMobileInfoControls() { + if (!mobileInfoButtonEl || !mobileInfoPrimaryTabEl || !mobileInfoSecondaryTabEl || !mobileInfoPanelEl) { + return; + } + + const isCompact = isCompactLightboxLayout(); + const canShowInfo = Boolean( + lightboxState.isOpen + && isCompact + && !zoomed + && !lightboxState.deckCompareMode + && lightboxState.allowOverlayCompare + && lightboxState.primaryCard?.label + ); + const hasOverlayInfo = Boolean(lightboxState.compareMode && hasSecondaryCard() && lightboxState.secondaryCard?.label); + const activeView = getActiveMobileInfoView(); + + mobileInfoButtonEl.style.display = canShowInfo ? "inline-flex" : "none"; + mobileInfoButtonEl.textContent = lightboxState.mobileInfoOpen ? "Hide Info" : "Info"; + mobileInfoButtonEl.setAttribute("aria-pressed", lightboxState.mobileInfoOpen ? "true" : "false"); + + mobileInfoPrimaryTabEl.style.display = canShowInfo && lightboxState.mobileInfoOpen && hasOverlayInfo ? "inline-flex" : "none"; + mobileInfoSecondaryTabEl.style.display = canShowInfo && lightboxState.mobileInfoOpen && hasOverlayInfo ? "inline-flex" : "none"; + + mobileInfoPrimaryTabEl.setAttribute("aria-pressed", activeView === "primary" ? "true" : "false"); + mobileInfoSecondaryTabEl.setAttribute("aria-pressed", activeView === "overlay" ? "true" : "false"); + + mobileInfoPrimaryTabEl.style.background = activeView === "primary" ? "rgba(51, 65, 85, 0.96)" : "rgba(15, 23, 42, 0.84)"; + mobileInfoSecondaryTabEl.style.background = activeView === "overlay" ? "rgba(51, 65, 85, 0.96)" : "rgba(15, 23, 42, 0.84)"; + mobileInfoPanelEl.style.pointerEvents = canShowInfo && lightboxState.mobileInfoOpen ? "auto" : "none"; + } + + function syncMobileNavigationControls() { + if (!mobilePrevButtonEl || !mobileNextButtonEl) { + return; + } + + if (mobilePrevButtonEl.parentElement !== overlayEl) { + overlayEl.appendChild(mobilePrevButtonEl); + } + + if (mobileNextButtonEl.parentElement !== overlayEl) { + overlayEl.appendChild(mobileNextButtonEl); + } + + const canNavigate = Boolean( + lightboxState.isOpen + && isCompactLightboxLayout() + && !zoomed + && !lightboxState.deckComparePickerOpen + && hasSequenceNavigation() + ); + const previousLabel = lightboxState.compareMode + ? "Previous overlay card" + : (lightboxState.deckCompareMode ? "Previous compared card" : "Previous card"); + const nextLabel = lightboxState.compareMode + ? "Next overlay card" + : (lightboxState.deckCompareMode ? "Next compared card" : "Next card"); + + mobilePrevButtonEl.style.display = canNavigate ? "inline-flex" : "none"; + mobileNextButtonEl.style.display = canNavigate ? "inline-flex" : "none"; + mobilePrevButtonEl.setAttribute("aria-label", previousLabel); + mobileNextButtonEl.setAttribute("aria-label", nextLabel); + + if (!canNavigate) { + return; + } + + const mobileInfoPanelVisible = Boolean( + lightboxState.mobileInfoOpen + && mobileInfoPanelEl + && mobileInfoPanelEl.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); + + mobilePrevButtonEl.style.top = "auto"; + mobileNextButtonEl.style.top = "auto"; + mobilePrevButtonEl.style.bottom = `${bottomOffset}px`; + mobileNextButtonEl.style.bottom = `${bottomOffset}px`; + mobilePrevButtonEl.style.transform = "none"; + mobileNextButtonEl.style.transform = "none"; + } + function syncComparePanels() { if (lightboxState.deckCompareMode) { renderComparePanel(primaryInfoEl, primaryTitleEl, primaryGroupsEl, primaryHintEl, null, "", "", false); renderComparePanel(secondaryInfoEl, secondaryTitleEl, secondaryGroupsEl, secondaryHintEl, null, "", "", false); + renderMobileInfoPanel(null, "", "", false); return; } + const isCompact = isCompactLightboxLayout(); const isComparing = lightboxState.compareMode; const overlaySelected = hasSecondaryCard(); - const showPrimaryPanel = Boolean(lightboxState.isOpen && lightboxState.allowOverlayCompare && lightboxState.primaryCard?.label && !zoomed); - const showSecondaryPanel = Boolean(isComparing && overlaySelected && lightboxState.secondaryCard?.label && !zoomed); + const primaryHint = isComparing + ? (overlaySelected + ? "Use the side arrows to move through the overlaid card." + : "Use the side arrows to pick the first overlay card.") + : "Use the side arrows to move through cards. Tap Overlay to compare."; + const secondaryHint = overlaySelected ? "Use the side arrows to swap the overlay card." : ""; + const showPrimaryPanel = Boolean( + !isCompact + && lightboxState.isOpen + && lightboxState.allowOverlayCompare + && lightboxState.primaryCard?.label + && !zoomed + ); + const showSecondaryPanel = Boolean( + !isCompact + && isComparing + && overlaySelected + && lightboxState.secondaryCard?.label + && !zoomed + ); renderComparePanel( primaryInfoEl, @@ -613,11 +853,7 @@ primaryHintEl, lightboxState.primaryCard, "Base", - isComparing - ? (overlaySelected - ? "Use Left and Right arrows to move through the overlaid card." - : "Click another House card behind the dock, or use Left and Right arrows to pick the first overlay card.") - : "Use Left and Right arrows to move through cards. Click Overlay to compare.", + primaryHint, showPrimaryPanel ); @@ -628,9 +864,28 @@ secondaryHintEl, lightboxState.secondaryCard, "Overlay", - overlaySelected ? "Use Left and Right arrows to swap the overlay card." : "", + secondaryHint, showSecondaryPanel ); + + if (!isCompact) { + renderMobileInfoPanel(null, "", "", false); + return; + } + + const activeView = getActiveMobileInfoView(); + const mobileCard = activeView === "overlay" ? lightboxState.secondaryCard : lightboxState.primaryCard; + const mobileRole = activeView === "overlay" ? "Overlay" : (isComparing ? "Base" : "Card"); + const mobileHint = activeView === "overlay" ? secondaryHint : primaryHint; + const showMobileInfo = Boolean( + lightboxState.isOpen + && lightboxState.mobileInfoOpen + && !zoomed + && lightboxState.allowOverlayCompare + && mobileCard?.label + ); + + renderMobileInfoPanel(mobileCard, mobileRole, mobileHint, showMobileInfo); } function syncOpacityControl() { @@ -670,7 +925,7 @@ deckComparePanelEl.style.display = "flex"; deckCompareMessageEl.textContent = lightboxState.deckCompareMessage || (lightboxState.availableCompareDecks.length - ? `Choose up to ${lightboxState.maxCompareDecks} extra decks.` + ? getCompareDeckLimitMessage() : "Add another registered deck to use deck compare."); deckCompareDeckListEl.replaceChildren(); @@ -681,7 +936,7 @@ lightboxState.availableCompareDecks.forEach((deck) => { const isSelected = lightboxState.selectedCompareDeckIds.includes(deck.id); - const isDisabled = !isSelected && lightboxState.selectedCompareDeckIds.length >= lightboxState.maxCompareDecks; + const isDisabled = !isSelected && lightboxState.selectedCompareDeckIds.length >= getEffectiveMaxCompareDecks(); const deckButtonEl = document.createElement("button"); deckButtonEl.type = "button"; deckButtonEl.textContent = deck.label; @@ -696,7 +951,9 @@ deckButtonEl.style.color = isDisabled ? "rgba(148, 163, 184, 0.52)" : "#f8fafc"; deckButtonEl.style.cursor = isDisabled ? "not-allowed" : "pointer"; deckButtonEl.style.font = "600 12px/1.3 sans-serif"; - deckButtonEl.addEventListener("click", () => { + deckButtonEl.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); toggleDeckCompareSelection(deck.id); restoreLightboxFocus(); }); @@ -715,8 +972,12 @@ clearButtonEl.style.color = "rgba(248, 250, 252, 0.92)"; clearButtonEl.style.cursor = "pointer"; clearButtonEl.style.font = "600 12px/1.3 sans-serif"; - clearButtonEl.addEventListener("click", () => { + clearButtonEl.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); updateDeckCompareMode([]); + suppressDeckCompareToggle(); + closeDeckComparePanel(); applyComparePresentation(); restoreLightboxFocus(); }); @@ -729,6 +990,8 @@ return; } + const isCompact = isCompactLightboxLayout(); + if (!lightboxState.deckCompareMode) { compareGridEl.style.display = "none"; compareGridSlots.forEach((slot) => { @@ -746,7 +1009,11 @@ const visibleCards = [lightboxState.primaryCard, ...lightboxState.deckCompareCards].filter(Boolean); compareGridEl.style.display = "grid"; + compareGridEl.style.gap = isCompact ? "6px" : "14px"; + compareGridEl.style.padding = isCompact ? "8px 6px 88px" : "76px 24px 24px"; compareGridEl.style.gridTemplateColumns = `repeat(${Math.max(1, visibleCards.length)}, minmax(0, 1fr))`; + compareGridEl.style.alignItems = isCompact ? "center" : "stretch"; + compareGridEl.style.alignContent = isCompact ? "center" : "stretch"; compareGridSlots.forEach((slot, index) => { const cardRequest = visibleCards[index] || null; @@ -762,6 +1029,23 @@ } slot.slotEl.style.display = "flex"; + slot.slotEl.style.width = "100%"; + slot.slotEl.style.height = isCompact ? "auto" : "100%"; + slot.slotEl.style.maxWidth = isCompact ? "220px" : "none"; + slot.slotEl.style.justifySelf = isCompact ? "center" : "stretch"; + slot.slotEl.style.alignSelf = isCompact ? "center" : "stretch"; + slot.slotEl.style.borderRadius = isCompact ? "12px" : "22px"; + slot.slotEl.style.boxShadow = isCompact ? "0 8px 18px rgba(0, 0, 0, 0.22)" : "0 24px 64px rgba(0, 0, 0, 0.36)"; + slot.headerEl.style.padding = isCompact ? "6px 8px" : "10px 12px"; + slot.headerEl.style.gap = isCompact ? "4px" : "10px"; + slot.badgeEl.style.font = isCompact ? "700 9px/1.15 sans-serif" : "700 11px/1.2 sans-serif"; + slot.cardLabelEl.style.font = isCompact ? "500 9px/1.2 sans-serif" : "500 11px/1.3 sans-serif"; + slot.mediaEl.style.flex = isCompact ? "0 0 auto" : "1 1 auto"; + slot.mediaEl.style.aspectRatio = isCompact ? "2 / 3" : "auto"; + slot.mediaEl.style.padding = isCompact ? "4px" : "16px"; + slot.zoomLayerEl.style.inset = isCompact ? "4px" : "16px"; + slot.fallbackEl.style.maxWidth = isCompact ? "100%" : "260px"; + slot.fallbackEl.style.padding = isCompact ? "12px 8px" : "16px"; slot.badgeEl.textContent = cardRequest.deckLabel || (index === 0 ? "Active Deck" : "Compare Deck"); slot.cardLabelEl.textContent = cardRequest.label || "Tarot card"; @@ -793,6 +1077,21 @@ 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"; @@ -819,35 +1118,73 @@ return; } + const isCompact = isCompactLightboxLayout(); + + if (!isCompact) { + helpButtonEl.style.right = "auto"; + helpButtonEl.style.top = "24px"; + helpButtonEl.style.left = "24px"; + helpPanelEl.style.top = "72px"; + helpPanelEl.style.right = "auto"; + helpPanelEl.style.bottom = "auto"; + helpPanelEl.style.left = "24px"; + 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.bottom = "auto"; + deckComparePanelEl.style.left = "auto"; + deckComparePanelEl.style.width = "min(280px, calc(100vw - 48px))"; + deckComparePanelEl.style.maxHeight = "none"; + deckComparePanelEl.style.overflowY = "visible"; + } + compareButtonEl.hidden = zoomed || lightboxState.deckCompareMode || !lightboxState.allowOverlayCompare - || (lightboxState.compareMode && !hasSecondaryCard()); + || (!isCompact && lightboxState.compareMode && !hasSecondaryCard()); compareButtonEl.textContent = lightboxState.compareMode ? "Done Overlay" : "Overlay"; syncHelpUi(); syncZoomControl(); syncOpacityControl(); syncDeckComparePicker(); + syncComparePanels(); + syncMobileInfoControls(); + syncMobileNavigationControls(); if (lightboxState.deckCompareMode) { overlayEl.style.pointerEvents = "none"; backdropEl.style.display = "block"; backdropEl.style.pointerEvents = "auto"; backdropEl.style.background = "rgba(0, 0, 0, 0.88)"; - toolbarEl.style.top = "24px"; - toolbarEl.style.right = "24px"; - toolbarEl.style.left = "auto"; + toolbarEl.style.top = isCompact ? "auto" : "24px"; + toolbarEl.style.right = isCompact ? "12px" : "24px"; + toolbarEl.style.bottom = isCompact ? "calc(12px + env(safe-area-inset-bottom, 0px))" : "auto"; + toolbarEl.style.left = isCompact ? "12px" : "auto"; + toolbarEl.style.flexDirection = isCompact ? "row" : "column"; + toolbarEl.style.flexWrap = isCompact ? "wrap" : "nowrap"; + toolbarEl.style.alignItems = isCompact ? "center" : "flex-end"; + toolbarEl.style.justifyContent = isCompact ? "center" : "flex-start"; stageEl.style.top = "0"; stageEl.style.right = "0"; stageEl.style.bottom = "0"; stageEl.style.left = "0"; + stageEl.style.display = "block"; + stageEl.style.alignItems = "stretch"; + stageEl.style.justifyContent = "stretch"; stageEl.style.width = "auto"; stageEl.style.height = "auto"; stageEl.style.transform = "none"; stageEl.style.pointerEvents = "auto"; + compareGridEl.style.padding = isCompact ? "18px 12px 84px" : "76px 24px 24px"; frameEl.style.display = "none"; primaryInfoEl.style.display = "none"; secondaryInfoEl.style.display = "none"; + if (mobileInfoPanelEl) { + mobileInfoPanelEl.style.display = "none"; + } + syncMobileNavigationControls(); renderDeckCompareGrid(); return; } @@ -855,6 +1192,70 @@ frameEl.style.display = "block"; compareGridEl.style.display = "none"; + if (isCompact) { + overlayEl.style.pointerEvents = "none"; + backdropEl.style.display = "block"; + backdropEl.style.pointerEvents = "auto"; + backdropEl.style.background = lightboxState.compareMode ? "rgba(0, 0, 0, 0.88)" : "rgba(0, 0, 0, 0.82)"; + toolbarEl.style.top = "auto"; + toolbarEl.style.right = "12px"; + toolbarEl.style.bottom = "calc(12px + env(safe-area-inset-bottom, 0px))"; + toolbarEl.style.left = "12px"; + toolbarEl.style.flexDirection = "row"; + 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"; + helpPanelEl.style.top = "auto"; + helpPanelEl.style.right = "12px"; + helpPanelEl.style.bottom = "calc(72px + env(safe-area-inset-bottom, 0px))"; + helpPanelEl.style.left = "12px"; + helpPanelEl.style.width = "auto"; + helpPanelEl.style.maxHeight = "min(42svh, 360px)"; + helpPanelEl.style.overflowY = "auto"; + deckComparePanelEl.style.top = "auto"; + deckComparePanelEl.style.right = "12px"; + deckComparePanelEl.style.bottom = "calc(72px + env(safe-area-inset-bottom, 0px))"; + deckComparePanelEl.style.left = "12px"; + deckComparePanelEl.style.width = "auto"; + deckComparePanelEl.style.maxHeight = "min(42svh, 360px)"; + deckComparePanelEl.style.overflowY = "auto"; + stageEl.style.top = "calc(12px + env(safe-area-inset-top, 0px))"; + stageEl.style.right = "12px"; + stageEl.style.bottom = "calc(84px + env(safe-area-inset-bottom, 0px))"; + stageEl.style.left = "12px"; + stageEl.style.display = "flex"; + stageEl.style.alignItems = "center"; + stageEl.style.justifyContent = "center"; + stageEl.style.width = "auto"; + stageEl.style.height = "auto"; + stageEl.style.transform = "none"; + stageEl.style.pointerEvents = "auto"; + frameEl.style.position = "relative"; + frameEl.style.width = "min(100%, 520px)"; + frameEl.style.height = "100%"; + frameEl.style.maxWidth = "520px"; + frameEl.style.maxHeight = "100%"; + frameEl.style.borderRadius = zoomed && hasSecondaryCard() ? "0" : "24px"; + frameEl.style.background = zoomed && hasSecondaryCard() ? "transparent" : "rgba(11, 15, 26, 0.92)"; + frameEl.style.boxShadow = zoomed && hasSecondaryCard() ? "none" : "0 24px 64px rgba(0, 0, 0, 0.44)"; + frameEl.style.overflow = "hidden"; + imageEl.style.width = "100%"; + imageEl.style.height = "100%"; + imageEl.style.maxWidth = "none"; + imageEl.style.maxHeight = "none"; + imageEl.style.objectFit = "contain"; + overlayImageEl.style.display = hasSecondaryCard() ? "block" : "none"; + primaryInfoEl.style.display = "none"; + secondaryInfoEl.style.display = "none"; + applyZoomTransform(); + setOverlayOpacity(lightboxState.overlayOpacity); + return; + } + if (!lightboxState.compareMode) { overlayEl.style.pointerEvents = "none"; backdropEl.style.display = "block"; @@ -862,11 +1263,19 @@ backdropEl.style.background = "rgba(0, 0, 0, 0.82)"; toolbarEl.style.top = "24px"; toolbarEl.style.right = "24px"; + toolbarEl.style.bottom = "auto"; toolbarEl.style.left = "auto"; + toolbarEl.style.flexDirection = "column"; + toolbarEl.style.flexWrap = "nowrap"; + toolbarEl.style.alignItems = "flex-end"; + toolbarEl.style.justifyContent = "flex-start"; stageEl.style.top = "0"; stageEl.style.right = "0"; stageEl.style.bottom = "0"; stageEl.style.left = "0"; + stageEl.style.display = "block"; + stageEl.style.alignItems = "stretch"; + stageEl.style.justifyContent = "stretch"; stageEl.style.width = "auto"; stageEl.style.height = "auto"; stageEl.style.transform = "none"; @@ -893,7 +1302,6 @@ imageEl.style.objectFit = "contain"; overlayImageEl.style.display = "none"; secondaryInfoEl.style.display = "none"; - syncComparePanels(); applyZoomTransform(); return; } @@ -903,7 +1311,15 @@ backdropEl.style.pointerEvents = "none"; toolbarEl.style.top = "18px"; toolbarEl.style.right = "18px"; + toolbarEl.style.bottom = "auto"; toolbarEl.style.left = "auto"; + toolbarEl.style.flexDirection = "column"; + toolbarEl.style.flexWrap = "nowrap"; + toolbarEl.style.alignItems = "flex-end"; + toolbarEl.style.justifyContent = "flex-start"; + stageEl.style.display = "block"; + stageEl.style.alignItems = "stretch"; + stageEl.style.justifyContent = "stretch"; stageEl.style.pointerEvents = "auto"; frameEl.style.position = "relative"; frameEl.style.width = "100%"; @@ -978,7 +1394,6 @@ secondaryInfoEl.style.display = "none"; } - syncComparePanels(); applyZoomTransform(); setOverlayOpacity(lightboxState.overlayOpacity); } @@ -988,6 +1403,8 @@ return; } + clearActivePointerGesture(); + suppressNextCardClick = false; lightboxState.zoomOriginX = 50; lightboxState.zoomOriginY = 50; applyTransformOrigins(); @@ -1014,6 +1431,65 @@ applyTransformOrigins(); } + function handleCompactPointerDown(event) { + if (!shouldHandleCompactPointerGesture(event)) { + return false; + } + + activePointerId = event.pointerId; + activePointerStartX = Number(event.clientX) || 0; + activePointerStartY = Number(event.clientY) || 0; + activePointerMoved = false; + + if (zoomed) { + event.preventDefault(); + } + + return true; + } + + function handleCompactPointerMove(event, targetImage = imageEl, targetFrame = null) { + if (!shouldHandleCompactPointerGesture(event) || event.pointerId !== activePointerId || !zoomed) { + return; + } + + const deltaX = Math.abs((Number(event.clientX) || 0) - activePointerStartX); + const deltaY = Math.abs((Number(event.clientY) || 0) - activePointerStartY); + if (!activePointerMoved && Math.max(deltaX, deltaY) < 6) { + event.preventDefault(); + return; + } + + activePointerMoved = true; + suppressNextCardClick = true; + event.preventDefault(); + updateZoomOrigin(event.clientX, event.clientY, targetImage, targetFrame); + } + + function handleCompactPointerEnd(event, targetImage = imageEl) { + if (!shouldHandleCompactPointerGesture(event) || event.pointerId !== activePointerId) { + return; + } + + if (activePointerMoved) { + suppressNextCardClick = true; + } + + if (typeof targetImage?.releasePointerCapture === "function" && targetImage.hasPointerCapture?.(event.pointerId)) { + targetImage.releasePointerCapture(event.pointerId); + } + + clearActivePointerGesture(); + } + + function preventCompactTouchScroll(event) { + if (!lightboxState.isOpen || !isCompactLightboxLayout() || !zoomed) { + return; + } + + event.preventDefault(); + } + function isPointOnCard(clientX, clientY, targetImage = imageEl, targetFrame = null) { const frameElForHitTest = targetFrame || targetImage; if (!targetImage || !frameElForHitTest) { @@ -1062,6 +1538,7 @@ overlayEl.style.display = "none"; overlayEl.style.zIndex = "9999"; overlayEl.style.pointerEvents = "none"; + overlayEl.style.overscrollBehavior = "contain"; helpButtonEl = document.createElement("button"); helpButtonEl.type = "button"; @@ -1116,7 +1593,7 @@ "Click card: toggle zoom at the clicked point", "Left / Right: move cards, or move overlay card in compare mode", "Overlay: pick a second card to compare", - "Compare: show the same card from up to two other registered decks", + "Compare: show the same card from other registered decks", "Space: swap base and overlay cards", "R: rotate base card, or rotate overlay card in compare mode", "+ / -: zoom in or out in steps", @@ -1254,12 +1731,32 @@ deckComparePanelEl.style.boxShadow = "0 16px 42px rgba(0, 0, 0, 0.34)"; deckComparePanelEl.style.backdropFilter = "blur(12px)"; deckComparePanelEl.style.pointerEvents = "auto"; + deckComparePanelEl.style.touchAction = "manipulation"; deckComparePanelEl.style.zIndex = "2"; + const deckCompareHeaderEl = document.createElement("div"); + deckCompareHeaderEl.style.display = "flex"; + deckCompareHeaderEl.style.alignItems = "center"; + deckCompareHeaderEl.style.justifyContent = "space-between"; + deckCompareHeaderEl.style.gap = "10px"; + const deckCompareTitleEl = document.createElement("div"); deckCompareTitleEl.textContent = "Compare Registered Decks"; deckCompareTitleEl.style.font = "700 13px/1.3 sans-serif"; + const deckCompareCloseButtonEl = document.createElement("button"); + deckCompareCloseButtonEl.type = "button"; + deckCompareCloseButtonEl.textContent = "Close"; + deckCompareCloseButtonEl.style.padding = "6px 10px"; + deckCompareCloseButtonEl.style.borderRadius = "999px"; + deckCompareCloseButtonEl.style.border = "1px solid rgba(248, 250, 252, 0.16)"; + deckCompareCloseButtonEl.style.background = "rgba(15, 23, 42, 0.44)"; + deckCompareCloseButtonEl.style.color = "rgba(248, 250, 252, 0.92)"; + deckCompareCloseButtonEl.style.cursor = "pointer"; + deckCompareCloseButtonEl.style.font = "600 11px/1.2 sans-serif"; + + deckCompareHeaderEl.append(deckCompareTitleEl, deckCompareCloseButtonEl); + deckCompareMessageEl = document.createElement("div"); deckCompareMessageEl.style.font = "500 12px/1.4 sans-serif"; deckCompareMessageEl.style.color = "rgba(226, 232, 240, 0.84)"; @@ -1269,9 +1766,56 @@ deckCompareDeckListEl.style.flexDirection = "column"; deckCompareDeckListEl.style.gap = "8px"; - deckComparePanelEl.append(deckCompareTitleEl, deckCompareMessageEl, deckCompareDeckListEl); + deckComparePanelEl.append(deckCompareHeaderEl, deckCompareMessageEl, deckCompareDeckListEl); - toolbarEl.append(compareButtonEl, deckCompareButtonEl, zoomControlEl, opacityControlEl); + mobileInfoButtonEl = document.createElement("button"); + mobileInfoButtonEl.type = "button"; + mobileInfoButtonEl.textContent = "Info"; + mobileInfoButtonEl.style.display = "none"; + mobileInfoButtonEl.style.border = "1px solid rgba(255, 255, 255, 0.2)"; + mobileInfoButtonEl.style.background = "rgba(15, 23, 42, 0.84)"; + mobileInfoButtonEl.style.color = "#f8fafc"; + mobileInfoButtonEl.style.borderRadius = "999px"; + mobileInfoButtonEl.style.padding = "10px 14px"; + mobileInfoButtonEl.style.font = "600 13px/1.1 sans-serif"; + mobileInfoButtonEl.style.cursor = "pointer"; + mobileInfoButtonEl.style.backdropFilter = "blur(12px)"; + + mobileInfoPrimaryTabEl = document.createElement("button"); + mobileInfoPrimaryTabEl.type = "button"; + mobileInfoPrimaryTabEl.textContent = "Base"; + mobileInfoPrimaryTabEl.style.display = "none"; + mobileInfoPrimaryTabEl.style.border = "1px solid rgba(255, 255, 255, 0.2)"; + mobileInfoPrimaryTabEl.style.background = "rgba(15, 23, 42, 0.84)"; + mobileInfoPrimaryTabEl.style.color = "#f8fafc"; + mobileInfoPrimaryTabEl.style.borderRadius = "999px"; + mobileInfoPrimaryTabEl.style.padding = "10px 14px"; + mobileInfoPrimaryTabEl.style.font = "600 13px/1.1 sans-serif"; + mobileInfoPrimaryTabEl.style.cursor = "pointer"; + mobileInfoPrimaryTabEl.style.backdropFilter = "blur(12px)"; + + mobileInfoSecondaryTabEl = document.createElement("button"); + mobileInfoSecondaryTabEl.type = "button"; + mobileInfoSecondaryTabEl.textContent = "Overlay"; + mobileInfoSecondaryTabEl.style.display = "none"; + mobileInfoSecondaryTabEl.style.border = "1px solid rgba(255, 255, 255, 0.2)"; + mobileInfoSecondaryTabEl.style.background = "rgba(15, 23, 42, 0.84)"; + mobileInfoSecondaryTabEl.style.color = "#f8fafc"; + mobileInfoSecondaryTabEl.style.borderRadius = "999px"; + mobileInfoSecondaryTabEl.style.padding = "10px 14px"; + mobileInfoSecondaryTabEl.style.font = "600 13px/1.1 sans-serif"; + mobileInfoSecondaryTabEl.style.cursor = "pointer"; + mobileInfoSecondaryTabEl.style.backdropFilter = "blur(12px)"; + + toolbarEl.append( + compareButtonEl, + deckCompareButtonEl, + mobileInfoButtonEl, + mobileInfoPrimaryTabEl, + mobileInfoSecondaryTabEl, + zoomControlEl, + opacityControlEl + ); stageEl = document.createElement("div"); stageEl.style.position = "fixed"; @@ -1281,6 +1825,7 @@ stageEl.style.left = "0"; stageEl.style.pointerEvents = "auto"; stageEl.style.overflow = "visible"; + stageEl.style.overscrollBehavior = "contain"; stageEl.style.transition = "top 220ms ease, right 220ms ease, bottom 220ms ease, left 220ms ease, width 220ms ease, height 220ms ease, transform 220ms ease"; stageEl.style.transform = "none"; stageEl.style.zIndex = "1"; @@ -1290,6 +1835,8 @@ frameEl.style.width = "100%"; frameEl.style.height = "100%"; frameEl.style.overflow = "hidden"; + frameEl.style.touchAction = "none"; + frameEl.style.overscrollBehavior = "contain"; frameEl.style.transition = "border-radius 220ms ease, background 220ms ease, box-shadow 220ms ease"; baseLayerEl = document.createElement("div"); @@ -1364,6 +1911,8 @@ mediaEl.style.padding = "16px"; mediaEl.style.background = "rgba(2, 6, 23, 0.4)"; mediaEl.style.overflow = "hidden"; + mediaEl.style.touchAction = "none"; + mediaEl.style.overscrollBehavior = "contain"; const zoomLayerEl = document.createElement("div"); zoomLayerEl.style.position = "absolute"; @@ -1384,6 +1933,7 @@ compareImageEl.style.transform = "rotate(0deg)"; compareImageEl.style.transformOrigin = "center center"; compareImageEl.style.transition = "transform 120ms ease-out"; + compareImageEl.style.touchAction = "none"; compareImageEl.style.userSelect = "none"; const fallbackEl = document.createElement("div"); @@ -1400,6 +1950,7 @@ return { slotEl, + headerEl, badgeEl, cardLabelEl, mediaEl, @@ -1423,6 +1974,7 @@ imageEl.style.transform = "rotate(0deg)"; imageEl.style.transformOrigin = "center center"; imageEl.style.transition = "transform 120ms ease-out, opacity 180ms ease"; + imageEl.style.touchAction = "none"; imageEl.style.userSelect = "none"; overlayImageEl = document.createElement("img"); @@ -1484,11 +2036,77 @@ secondaryTitleEl = secondaryPanel.titleEl; secondaryGroupsEl = secondaryPanel.groupsEl; secondaryHintEl = secondaryPanel.hintEl; + + mobileInfoPanelEl = document.createElement("div"); + mobileInfoPanelEl.style.position = "absolute"; + mobileInfoPanelEl.style.left = "12px"; + mobileInfoPanelEl.style.right = "12px"; + mobileInfoPanelEl.style.bottom = "12px"; + mobileInfoPanelEl.style.display = "none"; + mobileInfoPanelEl.style.flexDirection = "column"; + mobileInfoPanelEl.style.gap = "10px"; + mobileInfoPanelEl.style.padding = "14px 16px"; + mobileInfoPanelEl.style.borderRadius = "18px"; + mobileInfoPanelEl.style.background = "rgba(2, 6, 23, 0.86)"; + mobileInfoPanelEl.style.border = "1px solid rgba(148, 163, 184, 0.16)"; + mobileInfoPanelEl.style.color = "#f8fafc"; + mobileInfoPanelEl.style.backdropFilter = "blur(12px)"; + mobileInfoPanelEl.style.boxShadow = "0 16px 42px rgba(0, 0, 0, 0.34)"; + mobileInfoPanelEl.style.maxHeight = "min(46%, 320px)"; + mobileInfoPanelEl.style.overflowY = "auto"; + mobileInfoPanelEl.style.pointerEvents = "auto"; + mobileInfoPanelEl.style.zIndex = "3"; + + mobileInfoTitleEl = document.createElement("div"); + mobileInfoTitleEl.style.font = "700 13px/1.3 sans-serif"; + mobileInfoTitleEl.style.color = "#f8fafc"; + + mobileInfoGroupsEl = document.createElement("div"); + mobileInfoGroupsEl.style.display = "flex"; + mobileInfoGroupsEl.style.flexDirection = "column"; + mobileInfoGroupsEl.style.gap = "0"; + + mobileInfoHintEl = document.createElement("div"); + mobileInfoHintEl.style.font = "500 11px/1.35 sans-serif"; + mobileInfoHintEl.style.color = "rgba(226, 232, 240, 0.82)"; + + mobileInfoPanelEl.append(mobileInfoTitleEl, mobileInfoGroupsEl, mobileInfoHintEl); + + function createMobileNavButton(label, ariaLabel) { + const buttonEl = document.createElement("button"); + buttonEl.type = "button"; + buttonEl.textContent = label; + buttonEl.setAttribute("aria-label", ariaLabel); + buttonEl.style.position = "fixed"; + buttonEl.style.top = "auto"; + buttonEl.style.display = "none"; + buttonEl.style.alignItems = "center"; + buttonEl.style.justifyContent = "center"; + buttonEl.style.width = "44px"; + buttonEl.style.height = "44px"; + buttonEl.style.border = "1px solid rgba(255, 255, 255, 0.2)"; + buttonEl.style.borderRadius = "999px"; + buttonEl.style.background = "rgba(15, 23, 42, 0.84)"; + buttonEl.style.color = "#f8fafc"; + buttonEl.style.font = "700 20px/1 sans-serif"; + buttonEl.style.cursor = "pointer"; + buttonEl.style.backdropFilter = "blur(12px)"; + buttonEl.style.transform = "none"; + buttonEl.style.pointerEvents = "auto"; + buttonEl.style.zIndex = "6"; + return buttonEl; + } + + mobilePrevButtonEl = createMobileNavButton("<", "Previous card"); + mobilePrevButtonEl.style.left = "12px"; + mobileNextButtonEl = createMobileNavButton(">", "Next card"); + mobileNextButtonEl.style.right = "12px"; + baseLayerEl.appendChild(imageEl); overlayLayerEl.appendChild(overlayImageEl); - frameEl.append(baseLayerEl, overlayLayerEl); + frameEl.append(baseLayerEl, overlayLayerEl, mobileInfoPanelEl); stageEl.append(frameEl, compareGridEl, primaryInfoEl, secondaryInfoEl); - overlayEl.append(backdropEl, stageEl, toolbarEl, deckComparePanelEl, helpButtonEl, helpPanelEl); + overlayEl.append(backdropEl, stageEl, toolbarEl, deckComparePanelEl, helpButtonEl, helpPanelEl, mobilePrevButtonEl, mobileNextButtonEl); const close = () => { if (!overlayEl || !imageEl || !overlayImageEl) { @@ -1519,6 +2137,10 @@ lightboxState.helpOpen = false; lightboxState.primaryRotated = false; lightboxState.overlayRotated = false; + lightboxState.mobileInfoOpen = false; + lightboxState.mobileInfoView = "primary"; + clearActivePointerGesture(); + suppressNextCardClick = false; overlayEl.style.display = "none"; overlayEl.setAttribute("aria-hidden", "true"); imageEl.removeAttribute("src"); @@ -1547,6 +2169,8 @@ lightboxState.compareMode = !lightboxState.compareMode; if (!lightboxState.compareMode) { clearSecondaryCard(); + } else if (isCompactLightboxLayout()) { + lightboxState.mobileInfoOpen = true; } applyComparePresentation(); } @@ -1558,6 +2182,9 @@ } lightboxState.secondaryCard = normalizedCard; + if (isCompactLightboxLayout()) { + lightboxState.mobileInfoView = "overlay"; + } overlayImageEl.src = normalizedCard.src; overlayImageEl.alt = normalizedCard.altText; overlayImageEl.style.display = "block"; @@ -1692,9 +2319,42 @@ restoreLightboxFocus(); }); deckCompareButtonEl.addEventListener("click", () => { + if (shouldSuppressDeckCompareToggle()) { + restoreLightboxFocus(); + return; + } toggleDeckComparePanel(); restoreLightboxFocus(); }); + deckComparePanelEl.addEventListener("pointerdown", (event) => { + event.stopPropagation(); + }); + deckComparePanelEl.addEventListener("click", (event) => { + event.stopPropagation(); + }); + deckCompareCloseButtonEl.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + suppressDeckCompareToggle(); + closeDeckComparePanel(); + applyComparePresentation(); + restoreLightboxFocus(); + }); + mobileInfoButtonEl.addEventListener("click", () => { + lightboxState.mobileInfoOpen = !lightboxState.mobileInfoOpen; + applyComparePresentation(); + restoreLightboxFocus(); + }); + mobileInfoPrimaryTabEl.addEventListener("click", () => { + lightboxState.mobileInfoView = "primary"; + applyComparePresentation(); + restoreLightboxFocus(); + }); + mobileInfoSecondaryTabEl.addEventListener("click", () => { + lightboxState.mobileInfoView = "overlay"; + applyComparePresentation(); + restoreLightboxFocus(); + }); zoomSliderEl.addEventListener("input", () => { setZoomScale(Number(zoomSliderEl.value) / 100); }); @@ -1705,9 +2365,31 @@ }); opacitySliderEl.addEventListener("change", restoreLightboxFocus); opacitySliderEl.addEventListener("pointerup", restoreLightboxFocus); + mobilePrevButtonEl.addEventListener("click", (event) => { + event.stopPropagation(); + if (lightboxState.compareMode) { + stepSecondaryCard(-1); + } else { + stepPrimaryCard(-1); + } + restoreLightboxFocus(); + }); + mobileNextButtonEl.addEventListener("click", (event) => { + event.stopPropagation(); + if (lightboxState.compareMode) { + stepSecondaryCard(1); + } else { + stepPrimaryCard(1); + } + restoreLightboxFocus(); + }); imageEl.addEventListener("click", (event) => { event.stopPropagation(); + if (consumeSuppressedCardClick()) { + return; + } + if (!isPointOnCard(event.clientX, event.clientY)) { if (lightboxState.compareMode) { return; @@ -1733,6 +2415,26 @@ updateZoomOrigin(event.clientX, event.clientY); }); + imageEl.addEventListener("pointerdown", (event) => { + if (handleCompactPointerDown(event)) { + imageEl.setPointerCapture?.(event.pointerId); + } + }); + + imageEl.addEventListener("pointermove", (event) => { + handleCompactPointerMove(event, imageEl, null); + }); + + imageEl.addEventListener("pointerup", (event) => { + handleCompactPointerEnd(event, imageEl); + }); + + imageEl.addEventListener("pointercancel", (event) => { + handleCompactPointerEnd(event, imageEl); + }); + + imageEl.addEventListener("touchmove", preventCompactTouchScroll, { passive: false }); + imageEl.addEventListener("mouseleave", () => { if (zoomed) { lightboxState.zoomOriginX = 50; @@ -1744,6 +2446,10 @@ compareGridSlots.forEach((slot) => { slot.imageEl.addEventListener("click", (event) => { event.stopPropagation(); + if (consumeSuppressedCardClick()) { + return; + } + if (!isPointOnCard(event.clientX, event.clientY, slot.imageEl, slot.mediaEl)) { close(); return; @@ -1765,6 +2471,26 @@ updateZoomOrigin(event.clientX, event.clientY, slot.imageEl, slot.mediaEl); }); + slot.imageEl.addEventListener("pointerdown", (event) => { + if (handleCompactPointerDown(event)) { + slot.imageEl.setPointerCapture?.(event.pointerId); + } + }); + + slot.imageEl.addEventListener("pointermove", (event) => { + handleCompactPointerMove(event, slot.imageEl, slot.mediaEl); + }); + + slot.imageEl.addEventListener("pointerup", (event) => { + handleCompactPointerEnd(event, slot.imageEl); + }); + + slot.imageEl.addEventListener("pointercancel", (event) => { + handleCompactPointerEnd(event, slot.imageEl); + }); + + slot.imageEl.addEventListener("touchmove", preventCompactTouchScroll, { passive: false }); + slot.imageEl.addEventListener("mouseleave", () => { if (zoomed) { lightboxState.zoomOriginX = 50; @@ -1845,6 +2571,14 @@ stepPrimaryCard(event.key === "ArrowRight" ? 1 : -1); }); + window.addEventListener("resize", () => { + if (!lightboxState.isOpen) { + return; + } + + applyComparePresentation(); + }); + document.body.appendChild(overlayEl); overlayEl.closeLightbox = close; @@ -1915,6 +2649,8 @@ lightboxState.helpOpen = false; lightboxState.primaryRotated = false; lightboxState.overlayRotated = false; + lightboxState.mobileInfoOpen = isCompactLightboxLayout(); + lightboxState.mobileInfoView = "primary"; imageEl.src = normalizedPrimary.src; imageEl.alt = normalizedPrimary.altText; diff --git a/app/ui-tarot.js b/app/ui-tarot.js index f381f67..bb67bb2 100644 --- a/app/ui-tarot.js +++ b/app/ui-tarot.js @@ -293,8 +293,6 @@ tarotHouseBottomInfoMonthEl: document.getElementById("tarot-house-bottom-info-month"), tarotHouseBottomInfoRulerEl: document.getElementById("tarot-house-bottom-info-ruler"), tarotHouseBottomInfoDateEl: document.getElementById("tarot-house-bottom-info-date"), - tarotHouseFocusToggleEl: document.getElementById("tarot-house-focus-toggle"), - tarotHouseExportEl: document.getElementById("tarot-house-export"), tarotHouseExportWebpEl: document.getElementById("tarot-house-export-webp") }; } @@ -568,16 +566,6 @@ setHouseBottomInfoCheckboxState(elements?.tarotHouseBottomInfoRulerEl, state.houseBottomInfoModes.ruler); setHouseBottomInfoCheckboxState(elements?.tarotHouseBottomInfoDateEl, state.houseBottomInfoModes.date); - if (elements?.tarotHouseFocusToggleEl) { - elements.tarotHouseFocusToggleEl.setAttribute("aria-pressed", state.houseFocusMode ? "true" : "false"); - elements.tarotHouseFocusToggleEl.textContent = state.houseFocusMode ? "Show Full Tarot" : "Focus House"; - } - - if (elements?.tarotHouseExportEl) { - elements.tarotHouseExportEl.disabled = Boolean(state.houseExportInProgress); - elements.tarotHouseExportEl.textContent = state.houseExportInProgress ? "Exporting..." : "Export PNG"; - } - if (elements?.tarotHouseExportWebpEl) { const supportsWebp = tarotHouseUi.isExportFormatSupported?.("webp") === true; elements.tarotHouseExportWebpEl.disabled = Boolean(state.houseExportInProgress) || !supportsWebp; @@ -808,6 +796,7 @@ tarotHouseUi.init?.({ resolveTarotCardImage, resolveTarotCardThumbnail, + getActiveDeck, getDisplayCardName, clearChildren, normalizeTarotCardLookupName, @@ -932,13 +921,6 @@ }); } - if (elements.tarotHouseFocusToggleEl) { - elements.tarotHouseFocusToggleEl.addEventListener("click", () => { - state.houseFocusMode = !state.houseFocusMode; - syncHouseControls(elements); - }); - } - if (elements.tarotHouseTopCardsVisibleEl) { elements.tarotHouseTopCardsVisibleEl.addEventListener("change", () => { state.houseTopCardsVisible = Boolean(elements.tarotHouseTopCardsVisibleEl.checked); @@ -989,12 +971,6 @@ }); }); - if (elements.tarotHouseExportEl) { - elements.tarotHouseExportEl.addEventListener("click", () => { - exportHouseOfCards(elements, "png"); - }); - } - if (elements.tarotHouseExportWebpEl) { elements.tarotHouseExportWebpEl.addEventListener("click", () => { exportHouseOfCards(elements, "webp"); diff --git a/index.html b/index.html index d04e950..164c3fd 100644 --- a/index.html +++ b/index.html @@ -16,7 +16,7 @@ - +
@@ -368,8 +368,6 @@ Date - -
@@ -1039,8 +1037,8 @@ - - + + @@ -1059,7 +1057,7 @@ - +