From 824812ea9efd62a7f02eeebe3c2acc2389983706 Mon Sep 17 00:00:00 2001 From: StroepWafel Date: Thu, 4 Jun 2026 14:33:52 +0930 Subject: [PATCH 1/2] super clean popout --- src/css/injected.scss | 345 +++++++- .../utils/Openers/OpenThemeOfTheMonthPopup.ts | 741 ++++++++++++++++-- 2 files changed, 1000 insertions(+), 86 deletions(-) diff --git a/src/css/injected.scss b/src/css/injected.scss index 978cd32b..75b9fd39 100644 --- a/src/css/injected.scss +++ b/src/css/injected.scss @@ -3808,19 +3808,306 @@ div.day-empty { color: var(--text-primary); } +.themeOfTheMonthBackdrop { + position: fixed; + inset: 0; + z-index: 47; + background: color-mix(in srgb, #000 52%, transparent); + backdrop-filter: blur(5px); + -webkit-backdrop-filter: blur(5px); + opacity: 0; + pointer-events: none; + transition: opacity 0.55s cubic-bezier(0.76, 0, 0.24, 1); +} +.themeOfTheMonthBackdropVisible { + opacity: 1; + pointer-events: auto; +} .themeOfTheMonthCard { position: fixed; - right: max(18px, env(safe-area-inset-right)); - bottom: max(18px, env(safe-area-inset-bottom)); + top: 0; + left: 0; + right: auto; + bottom: auto; z-index: 48; + margin: 0; width: min(360px, calc(100vw - 36px)); - overflow: visible; + display: flex; + flex-direction: column; + overflow: hidden; border: 1px solid color-mix(in srgb, var(--text-primary) 12%, transparent); border-radius: 20px; background: var(--background-primary); color: var(--text-primary); box-shadow: 0 22px 70px rgba(0, 0, 0, 0.35); - animation: themeOfTheMonthCardIn 0.24s ease-out; + transform-origin: bottom right; + transition: none; + animation: themeOfTheMonthCardIn 0.28s ease-out; +} +.themeOfTheMonthCardExpanded { + transform-origin: center center; +} +/* translate(x,y) is set inline; transition enabled after mount */ +.themeOfTheMonthCardMorphReady:not(.themeOfTheMonthCardReducedMotion) { + transition: + transform 0.55s cubic-bezier(0.76, 0, 0.24, 1), + width 0.55s cubic-bezier(0.76, 0, 0.24, 1), + height 0.55s cubic-bezier(0.76, 0, 0.24, 1), + max-height 0.55s cubic-bezier(0.76, 0, 0.24, 1), + border-radius 0.55s cubic-bezier(0.76, 0, 0.24, 1); +} +.themeOfTheMonthCardAnchoredBottom, +.themeOfTheMonthCardCollapsing { + transform-origin: 100% 100% !important; +} +/* Expanded: fixed shell; copy scrolls; actions pinned to the bottom. */ +.themeOfTheMonthCardExpanded.themeOfTheMonthCardExpandedShell .themeOfTheMonthCardBody { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + padding: 14px 16px 16px; +} +.themeOfTheMonthCardExpanded.themeOfTheMonthCardExpandedShell .themeOfTheMonthCardDescription { + flex: 1 1 auto; + min-height: 0; + margin: 8px 0 0; + overflow-x: hidden; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} +.themeOfTheMonthCardExpanded.themeOfTheMonthCardExpandedShell .themeOfTheMonthCardActions { + flex-shrink: 0; + margin-top: auto; + padding-top: 14px; +} +.themeOfTheMonthCardReducedMotion { + transition: none !important; +} +.themeOfTheMonthCardMedia { + position: relative; + flex-shrink: 0; +} +#theme-of-the-month-card .themeOfTheMonthCardPopout { + position: absolute; + top: 12px; + left: 12px; + z-index: 6; + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + min-width: 36px; + min-height: 36px; + padding: 0; + appearance: none; + border: none; + border-radius: 50% !important; + aspect-ratio: 1; + cursor: pointer; + color: var(--text-primary); + background: color-mix(in srgb, var(--background-primary) 80%, transparent); + box-shadow: + 0 2px 10px rgba(0, 0, 0, 0.24), + inset 0 0 0 1px color-mix(in srgb, var(--text-primary) 14%, transparent); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + transition: background 0.15s ease, transform 0.15s ease; +} +.themeOfTheMonthCardPopoutIcon { + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 0; + pointer-events: none; +} +.themeOfTheMonthCardPopout:hover { + background: color-mix(in srgb, var(--background-primary) 95%, transparent); + transform: scale(1.05); +} +.themeOfTheMonthCardPopout:active { + transform: scale(0.96); +} +.themeOfTheMonthCardPopout[hidden] { + display: none; +} +.themeOfTheMonthCardCompactMedia { + position: relative; + display: block; + overflow: hidden; + border-radius: 20px 20px 0 0; + line-height: 0; +} +.themeOfTheMonthCardExpanded.themeOfTheMonthCardShowGallery .themeOfTheMonthCardCompactMedia { + display: none; +} +.themeOfTheMonthCardExpandedPanel { + display: none; +} +.themeOfTheMonthCardExpandedPanel[hidden] { + display: none !important; +} +.themeOfTheMonthCardExpanded.themeOfTheMonthCardShowGallery + .themeOfTheMonthCardExpandedPanel:not([hidden]) { + display: block; +} +.themeOfTheMonthCardGallery { + position: relative; +} +.themeOfTheMonthCardHeroEmboss { + display: none; + position: absolute; + left: 0; + right: 0; + bottom: 0; + z-index: 4; + flex-direction: column; + justify-content: flex-end; + pointer-events: none; +} +.themeOfTheMonthCardHeroEmbossScrim { + position: absolute; + inset: 0; + background: linear-gradient( + 180deg, + transparent 0%, + color-mix(in srgb, #000 8%, transparent) 40%, + color-mix(in srgb, #000 55%, transparent) 72%, + color-mix(in srgb, #000 82%, transparent) 100% + ); +} +.themeOfTheMonthCardHeroEmbossContent { + position: relative; + z-index: 1; +} +.themeOfTheMonthCardExpanded .themeOfTheMonthCardHeroEmboss { + display: flex; + padding: 14px 16px 16px; +} +.themeOfTheMonthCardExpanded .themeOfTheMonthCardHeroEmbossTitle { + margin: 0; + font-size: 1.45rem; + font-weight: 800; + line-height: 1.15; + letter-spacing: -0.02em; + color: #fff; + text-shadow: + 0 1px 2px rgba(0, 0, 0, 0.55), + 0 2px 14px rgba(0, 0, 0, 0.35); +} +.themeOfTheMonthCardExpanded .themeOfTheMonthCardHeroEmbossAuthor { + margin: 4px 0 0; + font-size: 0.8rem; + font-weight: 600; + line-height: 1.25; + color: color-mix(in srgb, #fff 88%, transparent); + text-shadow: 0 1px 6px rgba(0, 0, 0, 0.45); +} +.themeOfTheMonthCardExpanded .themeOfTheMonthCardHeroEmbossDescription { + margin: 8px 0 0; + font-size: 0.84rem; + line-height: 1.4; + color: color-mix(in srgb, #fff 92%, transparent); + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + text-shadow: 0 1px 8px rgba(0, 0, 0, 0.5); +} +.themeOfTheMonthCardExpanded .themeOfTheMonthCardHeroEmbossVariants { + margin: 6px 0 0; + font-size: 0.74rem; + font-weight: 600; + line-height: 1.2; + color: color-mix(in srgb, #fff 72%, transparent); + text-shadow: 0 1px 6px rgba(0, 0, 0, 0.4); +} +.themeOfTheMonthCardExpanded.themeOfTheMonthCardShowGallery + .themeOfTheMonthCardGallerySlide + figcaption { + display: none; +} +.themeOfTheMonthCardGalleryTrack { + display: flex; + overflow-x: auto; + scroll-snap-type: x mandatory; + scrollbar-width: none; +} +.themeOfTheMonthCardGalleryTrack::-webkit-scrollbar { + display: none; +} +.themeOfTheMonthCardGallerySlide { + flex: 0 0 100%; + margin: 0; + scroll-snap-align: start; +} +.themeOfTheMonthCardGallerySlide img { + display: block; + width: 100%; + height: min(42vh, 280px); + object-fit: cover; +} +.themeOfTheMonthCardGallerySlide figcaption { + padding: 8px 14px 0; + font-size: 0.78rem; + line-height: 1.3; + color: color-mix(in srgb, var(--text-primary) 68%, transparent); +} +.themeOfTheMonthCardGalleryPrev, +.themeOfTheMonthCardGalleryNext { + position: absolute; + top: 50%; + z-index: 2; + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + margin-top: -17px; + padding: 0; + appearance: none; + border: none; + border-radius: 9999px; + cursor: pointer; + font-size: 1.35rem; + line-height: 1; + color: var(--text-primary); + background: color-mix(in srgb, var(--background-primary) 88%, transparent); + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); +} +.themeOfTheMonthCardGalleryPrev { + left: 10px; +} +.themeOfTheMonthCardGalleryNext { + right: 10px; +} +.themeOfTheMonthCardGalleryDots { + display: flex; + justify-content: center; + gap: 6px; + padding: 8px 14px 0; +} +.themeOfTheMonthCardGalleryDot { + width: 7px; + height: 7px; + padding: 0; + appearance: none; + border: none; + border-radius: 9999px; + cursor: pointer; + background: color-mix(in srgb, var(--text-primary) 28%, transparent); + transition: background 0.15s ease, transform 0.15s ease; +} +.themeOfTheMonthCardGalleryDotActive { + background: var(--better-pri, #6366f1); + transform: scale(1.15); +} +.themeOfTheMonthCardDescriptionTyping { + display: block; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: unset; } .themeOfTheMonthCard::before { content: ""; @@ -3907,16 +4194,25 @@ div.day-empty { .themeOfTheMonthCardConfirmAccept:active { transform: translateY(0); } -.themeOfTheMonthCardImage { +#theme-of-the-month-card .themeOfTheMonthCardImage { display: block; - width: 100%; - height: 150px; + width: 100% !important; + min-width: 100%; + height: 150px !important; + max-width: none !important; + max-height: none !important; margin: 0; - border-radius: 20px 20px 0 0; + padding: 0; + border: 0; + border-radius: 0; object-fit: cover; + object-position: center center; +} +.themeOfTheMonthCardExpanded .themeOfTheMonthCardGallerySlide img { + border-radius: 22px 22px 0 0; } .themeOfTheMonthCardBody { - padding: 14px 16px 16px; + padding: 14px 16px 12px; } .themeOfTheMonthCardEyebrow { margin: 0 0 6px; @@ -3932,14 +4228,26 @@ div.day-empty { line-height: 1.2; } .themeOfTheMonthCardDescription { - display: -webkit-box; - margin: 8px 0 14px; - overflow: hidden; - -webkit-box-orient: vertical; - -webkit-line-clamp: 3; + margin: 8px 0 10px; font-size: 0.92rem; line-height: 1.45; color: color-mix(in srgb, var(--text-primary) 78%, transparent); + overflow-wrap: anywhere; + word-wrap: break-word; +} +.themeOfTheMonthCardDescriptionClipped { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} +.themeOfTheMonthCardDescriptionExpanded { + display: block; + -webkit-line-clamp: unset; +} +.themeOfTheMonthCardExpanding:not(.themeOfTheMonthCardExpandedShell) + .themeOfTheMonthCardDescriptionExpanded { + overflow: hidden; } .themeOfTheMonthCardActions { display: flex; @@ -4027,21 +4335,22 @@ div.day-empty { @keyframes themeOfTheMonthCardIn { from { opacity: 0; - transform: translateY(18px) scale(0.98); } to { opacity: 1; - transform: translateY(0) scale(1); } } @keyframes themeOfTheMonthCardOut { to { opacity: 0; - transform: translateY(12px) scale(0.98); } } +.themeOfTheMonthCardExpanded.themeOfTheMonthCardClosing { + animation: themeOfTheMonthCardOut 0.18s ease-in forwards; +} @media (max-width: 900px) { - .themeOfTheMonthCard { + .themeOfTheMonthCard, + .themeOfTheMonthBackdrop { z-index: 2147483645; } } diff --git a/src/seqta/utils/Openers/OpenThemeOfTheMonthPopup.ts b/src/seqta/utils/Openers/OpenThemeOfTheMonthPopup.ts index c2729db3..6b8b6924 100644 --- a/src/seqta/utils/Openers/OpenThemeOfTheMonthPopup.ts +++ b/src/seqta/utils/Openers/OpenThemeOfTheMonthPopup.ts @@ -5,11 +5,13 @@ import { closePopup } from "./PopupManager"; import { getApiBase } from "../DevApiBase"; import { openThemeStoreWithHighlight } from "../openThemeStoreWithHighlight"; import { cloudAuth } from "../CloudAuth"; +import type { Theme } from "@/interface/types/Theme"; +import { + buildModalHeroSlides, + normalizeStoreTheme, +} from "@/interface/utils/themeStoreFlavours"; +import { attachPopupMediaFullscreen } from "./attachPopupMediaFullscreen"; -/** - * Server response shape from `/api/theme-of-the-month/current`. - * Hero image is resolved client-side via the theme store API when `theme_id` is set. - */ export interface ThemeOfTheMonthEntry { id: string; month: string; @@ -22,12 +24,6 @@ export interface ThemeOfTheMonthEntry { updated_at: number; } -/** - * Fetches the current month's Theme of the Month entry from the API. - * Returns `null` when no entry is configured for this month, or when the - * request fails (we never want a network problem to block other startup - * popups). - */ export async function fetchThemeOfTheMonth(): Promise { try { const res = await fetch(`${getApiBase()}/api/theme-of-the-month/current`, { @@ -45,7 +41,6 @@ export async function fetchThemeOfTheMonth(): Promise { +export async function fetchThemeStoreTheme(themeId: string): Promise { try { const token = await cloudAuth.getStoredToken(); const res = (await browser.runtime.sendMessage({ type: "fetchThemeDetails", themeId, token: token ?? undefined, - })) as { success?: boolean; data?: { theme?: { marqueeImage?: string; coverImage?: string } } }; + })) as { success?: boolean; data?: { theme?: Record } }; if (!res?.success || !res?.data?.theme) return null; - return heroUrlFromStoreTheme(res.data.theme); + return normalizeStoreTheme(res.data.theme); } catch (err) { - console.warn("[ThemeOfTheMonth] Failed to fetch theme store image:", err); + console.warn("[ThemeOfTheMonth] Failed to fetch theme store details:", err); return null; } } -/** Linked theme store image, else optional admin-uploaded cover. */ -async function resolvePopupHeroImageUrl(entry: ThemeOfTheMonthEntry): Promise { - const themeId = entry.theme_id ?? entry.theme?.id; - if (themeId) { - const fromStore = await fetchThemeStoreHeroImage(themeId); - if (fromStore) return fromStore; - } - const fallback = entry.cover_image?.trim(); - return fallback || null; +export async function fetchThemeStoreHeroImage(themeId: string): Promise { + const theme = await fetchThemeStoreTheme(themeId); + return theme ? heroUrlFromStoreTheme(theme) : null; } -function closeThemeOfTheMonthCard(card: HTMLElement, onDismissed?: () => void) { - if (card.classList.contains("themeOfTheMonthCardClosing")) return; +type PopupGallerySlide = { imageUrl: string; caption: string }; +function buildPopupGallerySlides( + entry: ThemeOfTheMonthEntry, + storeTheme: Theme | null, + heroUrl: string | null, +): PopupGallerySlide[] { + if (storeTheme) { + return buildModalHeroSlides(storeTheme).filter((s) => s.imageUrl.trim()); + } + if (heroUrl) { + return [{ imageUrl: heroUrl, caption: entry.title }]; + } + return []; +} + +/** Store theme identity on the hero — not the TOTM notice copy in the body. */ +function renderHeroEmbossHtml(storeTheme: Theme, entry: ThemeOfTheMonthEntry): string { + const name = (storeTheme.name || entry.title).trim(); + const author = storeTheme.author?.trim() ?? ""; + const storeDescription = storeTheme.description?.trim() ?? ""; + const entryDesc = entry.description.trim(); + const showDescription = + storeDescription.length > 0 && storeDescription !== entryDesc; + const flavourCount = storeTheme.flavours?.length ?? 0; + const flavourLine = + flavourCount > 0 + ? `${flavourCount} colour variant${flavourCount === 1 ? "" : "s"}` + : ""; + + if (!name && !author && !showDescription && !flavourLine) return ""; + + return ` +
+ +
+

${escapeHTML(name)}

+ ${author ? `

By ${escapeHTML(author)}

` : ""} + ${ + showDescription + ? `

${escapeHTML(storeDescription).replace(/\n/g, "
")}

` + : "" + } + ${flavourLine ? `

${escapeHTML(flavourLine)}

` : ""} +
+
+ `; +} + +function renderGallerySlidesHtml(slides: PopupGallerySlide[]): string { + if (slides.length === 0) return ""; + const slidesHtml = slides + .map( + (s, i) => ` +
+ ${escapeHTML(s.caption)} +
${escapeHTML(s.caption)}
+
+ `, + ) + .join(""); + const nav = + slides.length > 1 + ? ` + + +
+ ${slides + .map( + (_, i) => + ``, + ) + .join("")} +
+ ` + : ""; + return ` +
+
${slidesHtml}
+ ${nav} +
+ `; +} + +const POPOUT_EXPAND_SVG = /* svg */ ``; +const POPOUT_COLLAPSE_SVG = /* svg */ ``; + +const TOTM_MARGIN_PX = 18; +const TOTM_EXPANDED_SHELL_MAX_PX = 560; +const TOTM_EASE = "cubic-bezier(0.76, 0, 0.24, 1)"; +const TOTM_MORPH_MS = 550; +const TOTM_LAYOUT_SWAP_MS = TOTM_MORPH_MS / 2; + +let themeOfTheMonthAnimGen = 0; + +// --------------------------------------------------------------------------- +// Dimension helpers +// --------------------------------------------------------------------------- + +function themeOfTheMonthCollapsedWidth(): number { + return Math.min(360, window.innerWidth - TOTM_MARGIN_PX * 2); +} + +function themeOfTheMonthExpandedWidth(): number { + return Math.min(520, window.innerWidth - 32); +} + +function themeOfTheMonthMaxCardHeight(): number { + return window.innerHeight - TOTM_MARGIN_PX * 2; +} + +/** Fixed expanded card height — stable morph target; footer pinned inside via CSS. */ +function themeOfTheMonthExpandedShellHeight(): number { + return Math.min(TOTM_EXPANDED_SHELL_MAX_PX, themeOfTheMonthMaxCardHeight()); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + +// --------------------------------------------------------------------------- +// Pure-transform positioning +// +// The card sits at position: fixed; top: 0; left: 0 at all times. +// All movement is expressed as translate(x, y) so CSS transitions drive +// the full path — no top/left changes mid-animation that would cause snapping. +// --------------------------------------------------------------------------- + +/** + * Compute the translate values that place the card at the correct position. + * Collapsed → bottom-right corner. Expanded → viewport centre. + * Both states are expressed purely as transform offsets from (0, 0). + */ +function computeCardTranslate( + cardWidth: number, + cardHeight: number, + expanded: boolean, +): { x: number; y: number } { + if (expanded) { + const x = Math.round( + Math.max( + TOTM_MARGIN_PX, + Math.min( + (window.innerWidth - cardWidth) / 2, + window.innerWidth - TOTM_MARGIN_PX - cardWidth, + ), + ), + ); + const y = Math.round( + Math.max( + TOTM_MARGIN_PX, + Math.min( + (window.innerHeight - cardHeight) / 2, + window.innerHeight - TOTM_MARGIN_PX - cardHeight, + ), + ), + ); + return { x, y }; + } else { + const x = Math.round( + Math.max( + TOTM_MARGIN_PX, + Math.min( + window.innerWidth - cardWidth - TOTM_MARGIN_PX, + window.innerWidth - TOTM_MARGIN_PX - cardWidth, + ), + ), + ); + const y = Math.round( + Math.max( + TOTM_MARGIN_PX, + window.innerHeight - cardHeight - TOTM_MARGIN_PX, + ), + ); + return { x, y }; + } +} + +/** + * Apply card dimensions + border-radius, then set transform so the card + * lands at the right position. + * + * `targetHeight` must be passed explicitly — never read scrollHeight here, + * because content may be hidden/shown mid-animation and scrollHeight would + * give the wrong value, causing the snap-to-full-height bug. + */ +function applyThemeOfTheMonthCardPosition( + card: HTMLElement, + expanded: boolean, + animate: boolean, + targetHeight?: number, +): void { + const width = expanded + ? themeOfTheMonthExpandedWidth() + : themeOfTheMonthCollapsedWidth(); + + card.style.width = `${width}px`; + card.style.maxHeight = expanded ? `${themeOfTheMonthMaxCardHeight()}px` : ""; + card.style.borderRadius = expanded ? "22px" : "20px"; + + const h = targetHeight ?? card.offsetHeight; + const { x, y } = computeCardTranslate(width, h, expanded); + + const canAnimate = + animate && + settingsState.animations && + card.classList.contains("themeOfTheMonthCardMorphReady"); + + if (canAnimate) { + card.style.transition = [ + `transform ${TOTM_MORPH_MS}ms ${TOTM_EASE}`, + `width ${TOTM_MORPH_MS}ms ${TOTM_EASE}`, + `height ${TOTM_MORPH_MS}ms ${TOTM_EASE}`, + `border-radius ${TOTM_MORPH_MS}ms ${TOTM_EASE}`, + ].join(", "); + } else { + card.style.transition = "none"; + } + + // Force a reflow so the browser registers the pre-transition state. + void card.offsetHeight; + + if (targetHeight !== undefined) card.style.height = `${targetHeight}px`; + card.style.transform = `translate(${x}px, ${y}px)`; + + if (!canAnimate) { + requestAnimationFrame(() => { + if (card.isConnected) card.style.transition = ""; + }); + } +} + +// --------------------------------------------------------------------------- +// Height helpers — keep card height explicit during animation so transforms +// can be calculated correctly. +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Expand / collapse animations +// --------------------------------------------------------------------------- + +function applyExpandedCardShell(card: HTMLElement): number { + const h = themeOfTheMonthExpandedShellHeight(); + card.classList.add("themeOfTheMonthCardExpandedShell"); + card.style.height = `${h}px`; + card.style.maxHeight = `${themeOfTheMonthMaxCardHeight()}px`; + card.style.overflow = "hidden"; + return h; +} + +function clearExpandedCardShell(card: HTMLElement): void { + card.classList.remove("themeOfTheMonthCardExpandedShell"); + card.style.height = ""; + card.style.maxHeight = ""; + card.style.overflow = ""; +} + +function applyExpandedLayout(card: HTMLElement, descriptionHtml: string): void { + const desc = card.querySelector(".themeOfTheMonthCardDescription"); + const expandedPanel = card.querySelector(".themeOfTheMonthCardExpandedPanel"); + + card.classList.add("themeOfTheMonthCardExpanded", "themeOfTheMonthCardShowGallery"); + expandedPanel?.removeAttribute("hidden"); + if (expandedPanel) { + expandedPanel.style.opacity = ""; + expandedPanel.style.transition = ""; + } + if (desc) { + desc.innerHTML = descriptionHtml; + desc.classList.add("themeOfTheMonthCardDescriptionExpanded"); + desc.classList.remove("themeOfTheMonthCardDescriptionClipped"); + } +} + +function applyCollapsedLayout(card: HTMLElement, descriptionHtml: string): void { + const desc = card.querySelector(".themeOfTheMonthCardDescription"); + const expandedPanel = card.querySelector(".themeOfTheMonthCardExpandedPanel"); + const body = card.querySelector(".themeOfTheMonthCardBody"); + card.classList.remove( + "themeOfTheMonthCardExpanded", + "themeOfTheMonthCardShowGallery", + "themeOfTheMonthCardExpandedShell", + ); + clearExpandedCardShell(card); + expandedPanel?.setAttribute("hidden", ""); + if (expandedPanel) { + expandedPanel.style.opacity = ""; + expandedPanel.style.transition = ""; + } + if (body) { + body.style.opacity = ""; + body.style.transition = ""; + } + if (desc) { + desc.innerHTML = descriptionHtml; + desc.classList.remove("themeOfTheMonthCardDescriptionExpanded"); + desc.classList.add("themeOfTheMonthCardDescriptionClipped"); + } +} + +function clearCardInlineSizeForMeasure(card: HTMLElement): void { + card.style.height = ""; + card.style.maxHeight = ""; + card.style.overflow = ""; +} + +function measureCollapsedTargetHeight(card: HTMLElement, descriptionHtml: string): number { + applyCollapsedLayout(card, descriptionHtml); + card.style.width = `${themeOfTheMonthCollapsedWidth()}px`; + clearCardInlineSizeForMeasure(card); + void card.offsetHeight; + return Math.min(card.scrollHeight, themeOfTheMonthMaxCardHeight()); +} + +async function runThemeOfTheMonthExpand( + card: HTMLElement, + backdrop: HTMLElement | null, + descriptionHtml: string, +): Promise { + const gen = ++themeOfTheMonthAnimGen; + + const fromH = card.offsetHeight; + const toH = themeOfTheMonthExpandedShellHeight(); + + // Morph starts in mini layout; swap to expanded layout halfway through the move. + applyCollapsedLayout(card, descriptionHtml); + card.style.width = `${themeOfTheMonthCollapsedWidth()}px`; + + card.classList.add("themeOfTheMonthCardExpanding"); + card.style.height = `${fromH}px`; + card.style.overflow = "hidden"; + + if (backdrop) { + backdrop.hidden = false; + backdrop.setAttribute("aria-hidden", "false"); + requestAnimationFrame(() => backdrop.classList.add("themeOfTheMonthBackdropVisible")); + } + + applyThemeOfTheMonthCardPosition(card, true, true, toH); + + await sleep(TOTM_LAYOUT_SWAP_MS); + if (gen !== themeOfTheMonthAnimGen) return; + applyExpandedLayout(card, descriptionHtml); + applyExpandedCardShell(card); + + await sleep(TOTM_LAYOUT_SWAP_MS); + if (gen !== themeOfTheMonthAnimGen) return; + + card.classList.remove("themeOfTheMonthCardExpanding"); + card.style.transition = ""; + const finalH = themeOfTheMonthExpandedShellHeight(); + card.style.height = `${finalH}px`; + applyThemeOfTheMonthCardPosition(card, true, false, finalH); +} + +async function runThemeOfTheMonthCollapse( + card: HTMLElement, + backdrop: HTMLElement | null, + descriptionHtml: string, +): Promise { + const gen = ++themeOfTheMonthAnimGen; + + const fromH = card.offsetHeight; + const toH = measureCollapsedTargetHeight(card, descriptionHtml); + + // Restore expanded visuals, then run one morph (size + position + height together). + applyExpandedLayout(card, descriptionHtml); + card.style.width = `${themeOfTheMonthExpandedWidth()}px`; + + card.classList.add("themeOfTheMonthCardExpanding", "themeOfTheMonthCardCollapsing"); + card.style.height = `${fromH}px`; + card.style.overflow = "hidden"; + + if (backdrop) { + backdrop.classList.remove("themeOfTheMonthBackdropVisible"); + backdrop.setAttribute("aria-hidden", "true"); + } + + applyThemeOfTheMonthCardPosition(card, false, true, toH); + + await sleep(TOTM_LAYOUT_SWAP_MS); + if (gen !== themeOfTheMonthAnimGen) return; + applyCollapsedLayout(card, descriptionHtml); + + await sleep(TOTM_LAYOUT_SWAP_MS); + if (gen !== themeOfTheMonthAnimGen) return; + + card.classList.remove( + "themeOfTheMonthCardExpanding", + "themeOfTheMonthCardCollapsing", + ); + card.style.height = `${toH}px`; + card.style.overflow = ""; + card.style.transition = ""; + + if (backdrop) backdrop.hidden = true; +} + +// --------------------------------------------------------------------------- +// Instant (reduced-motion) state setter +// --------------------------------------------------------------------------- + +function setThemeOfTheMonthExpandedInstant( + card: HTMLElement, + backdrop: HTMLElement | null, + expanded: boolean, + descriptionHtml: string, +): void { + themeOfTheMonthAnimGen++; + + card.classList.toggle("themeOfTheMonthCardExpanded", expanded); + updateThemeOfTheMonthPopoutUi(card, expanded); + + if (expanded) { + applyExpandedLayout(card, descriptionHtml); + if (backdrop) { + backdrop.hidden = false; + backdrop.setAttribute("aria-hidden", "false"); + backdrop.classList.add("themeOfTheMonthBackdropVisible"); + } + applyExpandedCardShell(card); + } else { + applyCollapsedLayout(card, descriptionHtml); + if (backdrop) { + backdrop.classList.remove("themeOfTheMonthBackdropVisible"); + backdrop.setAttribute("aria-hidden", "true"); + backdrop.hidden = true; + } + } + + applyThemeOfTheMonthCardPosition( + card, + expanded, + false, + expanded ? themeOfTheMonthExpandedShellHeight() : undefined, + ); +} + +// --------------------------------------------------------------------------- +// UI helpers +// --------------------------------------------------------------------------- + +function updateThemeOfTheMonthPopoutUi(card: HTMLElement, expanded: boolean): void { + const popout = card.querySelector(".themeOfTheMonthCardPopout"); + const popoutIcon = popout?.querySelector(".themeOfTheMonthCardPopoutIcon"); + if (popoutIcon) { + popoutIcon.innerHTML = expanded ? POPOUT_COLLAPSE_SVG : POPOUT_EXPAND_SVG; + } + if (popout) { + popout.setAttribute("aria-label", expanded ? "Collapse" : "Expand"); + popout.title = expanded ? "Collapse" : "Expand"; + } +} + +function closeThemeOfTheMonthCard(card: HTMLElement, onDismissed?: () => void): void { + if (card.classList.contains("themeOfTheMonthCardClosing")) return; card.classList.add("themeOfTheMonthCardClosing"); window.setTimeout(() => { card.remove(); @@ -118,38 +555,127 @@ function closeThemeOfTheMonthCard(card: HTMLElement, onDismissed?: () => void) { }, 180); } -/** - * Renders the Theme of the Month announcement card. - */ +// --------------------------------------------------------------------------- +// Gallery +// --------------------------------------------------------------------------- + +function initThemeOfTheMonthGallery(card: HTMLElement): void { + const track = card.querySelector(".themeOfTheMonthCardGalleryTrack"); + if (!track) return; + + const slides = [...track.querySelectorAll(".themeOfTheMonthCardGallerySlide")]; + if (slides.length <= 1) return; + + const dots = [...card.querySelectorAll(".themeOfTheMonthCardGalleryDot")]; + let activeIndex = 0; + + const scrollToIndex = (index: number) => { + const clamped = ((index % slides.length) + slides.length) % slides.length; + activeIndex = clamped; + const slide = slides[clamped]; + track.scrollTo({ left: slide.offsetLeft, behavior: "smooth" }); + for (const dot of dots) { + const isActive = Number(dot.dataset.slide) === clamped; + dot.classList.toggle("themeOfTheMonthCardGalleryDotActive", isActive); + dot.setAttribute("aria-selected", isActive ? "true" : "false"); + } + }; + + card.querySelector(".themeOfTheMonthCardGalleryPrev")?.addEventListener("click", (e) => { + e.stopPropagation(); + scrollToIndex(activeIndex - 1); + }); + card.querySelector(".themeOfTheMonthCardGalleryNext")?.addEventListener("click", (e) => { + e.stopPropagation(); + scrollToIndex(activeIndex + 1); + }); + for (const dot of dots) { + dot.addEventListener("click", (e) => { + e.stopPropagation(); + scrollToIndex(Number((e.currentTarget as HTMLButtonElement).dataset.slide)); + }); + } + + const syncDotsFromScroll = () => { + const mid = track.scrollLeft + track.clientWidth / 2; + let nearest = 0; + let nearestDist = Infinity; + slides.forEach((slide, i) => { + const center = slide.offsetLeft + slide.offsetWidth / 2; + const dist = Math.abs(center - mid); + if (dist < nearestDist) { + nearestDist = dist; + nearest = i; + } + }); + if (nearest === activeIndex) return; + activeIndex = nearest; + for (const dot of dots) { + const isActive = Number(dot.dataset.slide) === nearest; + dot.classList.toggle("themeOfTheMonthCardGalleryDotActive", isActive); + dot.setAttribute("aria-selected", isActive ? "true" : "false"); + } + }; + + track.addEventListener("scroll", syncDotsFromScroll, { passive: true }); +} + +function attachPopupImages(root: ParentNode): void { + for (const img of root.querySelectorAll("img")) { + attachPopupMediaFullscreen(img); + } +} + +// --------------------------------------------------------------------------- +// Main entry point +// --------------------------------------------------------------------------- + export async function OpenThemeOfTheMonthPopup( entry: ThemeOfTheMonthEntry, onDismissed?: () => void, -) { +): Promise { document.getElementById("theme-of-the-month-card")?.remove(); + document.getElementById("theme-of-the-month-backdrop")?.remove(); const monthLabel = formatMonthLabel(entry.month); - const heroUrl = await resolvePopupHeroImageUrl(entry); - const description = escapeHTML(entry.description).replace(/\n/g, " "); const linkedThemeId = entry.theme_id ?? entry.theme?.id; + const storeTheme = linkedThemeId ? await fetchThemeStoreTheme(linkedThemeId) : null; + const heroUrl = + (storeTheme ? heroUrlFromStoreTheme(storeTheme) : null) ?? + entry.cover_image?.trim() ?? + null; + const gallerySlides = buildPopupGallerySlides(entry, storeTheme, heroUrl); + const hasExpandableContent = gallerySlides.length > 0 || entry.description.trim().length > 0; + + const descriptionHtml = escapeHTML(entry.description).replace(/\n/g, "
"); + const heroEmbossHtml = + heroUrl && storeTheme ? renderHeroEmbossHtml(storeTheme, entry) : ""; + + const backdrop = stringToHTML(/* html */ ` + + `).firstChild as HTMLElement; const card = stringToHTML(/* html */ ` -