super clean popout

This commit is contained in:
2026-06-04 14:33:52 +09:30
parent ce18412405
commit 824812ea9e
2 changed files with 1000 additions and 86 deletions
+327 -18
View File
@@ -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;
}
}
@@ -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<ThemeOfTheMonthEntry | null> {
try {
const res = await fetch(`${getApiBase()}/api/theme-of-the-month/current`, {
@@ -45,7 +41,6 @@ export async function fetchThemeOfTheMonth(): Promise<ThemeOfTheMonthEntry | nul
}
}
/** True when the current month's entry should appear in the startup queue. */
export function shouldShowThemeOfTheMonth(entry: ThemeOfTheMonthEntry | null): boolean {
if (!entry || settingsState.themeOfTheMonthDisabled) return false;
return settingsState.themeOfTheMonthDismissedMonth !== entry.month;
@@ -67,7 +62,6 @@ function formatMonthLabel(month: string): string {
return date.toLocaleDateString("en-US", { year: "numeric", month: "long" });
}
/** Same priority as the theme store: marquee, then cover/banner. */
function heroUrlFromStoreTheme(theme: {
marqueeImage?: string | null;
coverImage?: string | null;
@@ -76,41 +70,484 @@ function heroUrlFromStoreTheme(theme: {
return url || null;
}
/**
* Loads hero image for a store theme via the background script (same path as
* {@link ThemeSelector} / theme store detail fetches).
*/
export async function fetchThemeStoreHeroImage(themeId: string): Promise<string | null> {
export async function fetchThemeStoreTheme(themeId: string): Promise<Theme | null> {
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<string, unknown> } };
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<string | null> {
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<string | null> {
const theme = await fetchThemeStoreTheme(themeId);
return theme ? heroUrlFromStoreTheme(theme) : null;
}
function closeThemeOfTheMonthCard(card: HTMLElement, onDismissed?: () => void) {
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 `
<div class="themeOfTheMonthCardHeroEmboss">
<div class="themeOfTheMonthCardHeroEmbossScrim" aria-hidden="true"></div>
<div class="themeOfTheMonthCardHeroEmbossContent">
<h3 class="themeOfTheMonthCardHeroEmbossTitle">${escapeHTML(name)}</h3>
${author ? `<p class="themeOfTheMonthCardHeroEmbossAuthor">By ${escapeHTML(author)}</p>` : ""}
${
showDescription
? `<p class="themeOfTheMonthCardHeroEmbossDescription">${escapeHTML(storeDescription).replace(/\n/g, "<br />")}</p>`
: ""
}
${flavourLine ? `<p class="themeOfTheMonthCardHeroEmbossVariants">${escapeHTML(flavourLine)}</p>` : ""}
</div>
</div>
`;
}
function renderGallerySlidesHtml(slides: PopupGallerySlide[]): string {
if (slides.length === 0) return "";
const slidesHtml = slides
.map(
(s, i) => `
<figure class="themeOfTheMonthCardGallerySlide" data-slide="${i}">
<img src="${escapeHTML(s.imageUrl)}" alt="${escapeHTML(s.caption)}" loading="lazy" />
<figcaption>${escapeHTML(s.caption)}</figcaption>
</figure>
`,
)
.join("");
const nav =
slides.length > 1
? `
<button type="button" class="themeOfTheMonthCardGalleryPrev" aria-label="Previous image"></button>
<button type="button" class="themeOfTheMonthCardGalleryNext" aria-label="Next image"></button>
<div class="themeOfTheMonthCardGalleryDots" role="tablist" aria-label="Theme previews">
${slides
.map(
(_, i) =>
`<button type="button" class="themeOfTheMonthCardGalleryDot${i === 0 ? " themeOfTheMonthCardGalleryDotActive" : ""}" data-slide="${i}" role="tab" aria-label="Image ${i + 1} of ${slides.length}" aria-selected="${i === 0 ? "true" : "false"}"></button>`,
)
.join("")}
</div>
`
: "";
return `
<div class="themeOfTheMonthCardGallery">
<div class="themeOfTheMonthCardGalleryTrack">${slidesHtml}</div>
${nav}
</div>
`;
}
const POPOUT_EXPAND_SVG = /* svg */ `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 3h6v6"/><path d="M9 21H3v-6"/><path d="M21 3l-7 7"/><path d="M3 21l7-7"/></svg>`;
const POPOUT_COLLAPSE_SVG = /* svg */ `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 14h6v6"/><path d="M20 10h-6V4"/><path d="M14 10l7-7"/><path d="M3 21l7-7"/></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<void> {
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<HTMLElement>(".themeOfTheMonthCardDescription");
const expandedPanel = card.querySelector<HTMLElement>(".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<HTMLElement>(".themeOfTheMonthCardDescription");
const expandedPanel = card.querySelector<HTMLElement>(".themeOfTheMonthCardExpandedPanel");
const body = card.querySelector<HTMLElement>(".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<void> {
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<void> {
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<HTMLButtonElement>(".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<HTMLElement>(".themeOfTheMonthCardGalleryTrack");
if (!track) return;
const slides = [...track.querySelectorAll<HTMLElement>(".themeOfTheMonthCardGallerySlide")];
if (slides.length <= 1) return;
const dots = [...card.querySelectorAll<HTMLButtonElement>(".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<HTMLImageElement>("img")) {
attachPopupMediaFullscreen(img);
}
}
// ---------------------------------------------------------------------------
// Main entry point
// ---------------------------------------------------------------------------
export async function OpenThemeOfTheMonthPopup(
entry: ThemeOfTheMonthEntry,
onDismissed?: () => void,
) {
): Promise<void> {
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, "<br />");
const heroEmbossHtml =
heroUrl && storeTheme ? renderHeroEmbossHtml(storeTheme, entry) : "";
const backdrop = stringToHTML(/* html */ `
<div id="theme-of-the-month-backdrop" class="themeOfTheMonthBackdrop" hidden aria-hidden="true"></div>
`).firstChild as HTMLElement;
const card = stringToHTML(/* html */ `
<aside id="theme-of-the-month-card" class="themeOfTheMonthCard" role="dialog" aria-label="Theme of the Month">
${
heroUrl
? `<img class="themeOfTheMonthCardImage" src="${escapeHTML(heroUrl)}" alt="${escapeHTML(entry.title)}" />`
: ""
}
<aside id="theme-of-the-month-card" class="themeOfTheMonthCard${settingsState.animations ? "" : " themeOfTheMonthCardReducedMotion"}" role="dialog" aria-label="Theme of the Month">
<div class="themeOfTheMonthCardMedia">
<button type="button" class="themeOfTheMonthCardPopout" aria-label="Expand" title="Expand"${hasExpandableContent ? "" : " hidden"}>
<span class="themeOfTheMonthCardPopoutIcon">${POPOUT_EXPAND_SVG}</span>
</button>
<div class="themeOfTheMonthCardCompactMedia"${heroUrl ? "" : " hidden"}>
${heroUrl ? `<img class="themeOfTheMonthCardImage" src="${escapeHTML(heroUrl)}" alt="${escapeHTML(entry.title)}" />` : ""}
</div>
<div class="themeOfTheMonthCardExpandedPanel" hidden>
${renderGallerySlidesHtml(gallerySlides)}
</div>
${heroEmbossHtml}
</div>
<div class="themeOfTheMonthCardBody">
<p class="themeOfTheMonthCardEyebrow">Theme of the Month · ${escapeHTML(monthLabel)}</p>
<h2>${escapeHTML(entry.title)}</h2>
<p class="themeOfTheMonthCardDescription">${description}</p>
<p class="themeOfTheMonthCardDescription themeOfTheMonthCardDescriptionClipped">${descriptionHtml}</p>
<div class="themeOfTheMonthCardActions">
<div class="themeOfTheMonthCardActionsStart">
${
linkedThemeId
? `<button type="button" class="themeOfTheMonthCardPrimary">Open Store</button>`
: ""
}
${linkedThemeId ? `<button type="button" class="themeOfTheMonthCardPrimary">Open Store</button>` : ""}
</div>
<div class="themeOfTheMonthCardActionsEnd">
<button type="button" class="themeOfTheMonthCardSecondary">Close</button>
@@ -170,32 +696,97 @@ export async function OpenThemeOfTheMonthPopup(
</aside>
`).firstChild as HTMLElement;
const autoCloseTimeout = window.setTimeout(() => {
closeThemeOfTheMonthCard(card, onDismissed);
}, 30_000);
let isExpanded = false;
let expandAnimating = false;
const dismiss = () => {
window.clearTimeout(autoCloseTimeout);
const applyExpandedState = async (expanded: boolean): Promise<void> => {
updateThemeOfTheMonthPopoutUi(card, expanded);
if (!settingsState.animations) {
setThemeOfTheMonthExpandedInstant(card, backdrop, expanded, descriptionHtml);
return;
}
expandAnimating = true;
try {
if (expanded) {
await runThemeOfTheMonthExpand(card, backdrop, descriptionHtml);
} else {
await runThemeOfTheMonthCollapse(card, backdrop, descriptionHtml);
}
} finally {
expandAnimating = false;
updateThemeOfTheMonthPopoutUi(card, expanded);
}
};
const onDocKey = (ev: KeyboardEvent) => {
if (ev.key !== "Escape") return;
if (!isExpanded || expandAnimating) return;
ev.stopPropagation();
isExpanded = false;
void applyExpandedState(false);
};
let autoCloseTimeout = 0;
const pauseAutoClose = () => window.clearTimeout(autoCloseTimeout);
const onResize = () => {
if (isExpanded) applyExpandedCardShell(card);
applyThemeOfTheMonthCardPosition(
card,
isExpanded,
false,
isExpanded ? themeOfTheMonthExpandedShellHeight() : undefined,
);
};
const dismissWithCleanup = () => {
pauseAutoClose();
window.removeEventListener("resize", onResize);
backdrop.remove();
document.removeEventListener("keydown", onDocKey, true);
closeThemeOfTheMonthCard(card, onDismissed);
};
card.addEventListener("mouseenter", () => window.clearTimeout(autoCloseTimeout), { once: true });
autoCloseTimeout = window.setTimeout(dismissWithCleanup, 30_000);
card.addEventListener("mouseenter", pauseAutoClose, { once: true });
initThemeOfTheMonthGallery(card);
attachPopupImages(card);
const confirmEl = card.querySelector<HTMLElement>(".themeOfTheMonthCardConfirm");
const toggleExpanded = () => {
if (expandAnimating) return;
isExpanded = !isExpanded;
pauseAutoClose();
void applyExpandedState(isExpanded);
};
card.querySelector(".themeOfTheMonthCardPopout")?.addEventListener("click", (e) => {
e.stopPropagation();
toggleExpanded();
});
backdrop.addEventListener("click", () => {
if (!isExpanded || expandAnimating) return;
isExpanded = false;
void applyExpandedState(false);
});
document.addEventListener("keydown", onDocKey, true);
card.querySelector(".themeOfTheMonthCardSecondary")?.addEventListener("click", () => {
settingsState.themeOfTheMonthDismissedMonth = entry.month;
dismiss();
dismissWithCleanup();
});
card.querySelector(".themeOfTheMonthCardPrimary")?.addEventListener("click", () => {
settingsState.themeOfTheMonthDismissedMonth = entry.month;
dismiss();
dismissWithCleanup();
openThemeStoreWithHighlight(linkedThemeId!);
});
const openDontShowConfirm = () => {
window.clearTimeout(autoCloseTimeout);
pauseAutoClose();
if (!confirmEl) return;
confirmEl.hidden = false;
requestAnimationFrame(() => confirmEl.classList.add("themeOfTheMonthCardConfirmVisible"));
@@ -206,23 +797,37 @@ export async function OpenThemeOfTheMonthPopup(
card.querySelector(".themeOfTheMonthCardConfirmCancel")?.addEventListener("click", () => {
if (!confirmEl) return;
confirmEl.classList.remove("themeOfTheMonthCardConfirmVisible");
window.setTimeout(() => {
confirmEl.hidden = true;
}, 160);
window.setTimeout(() => { confirmEl.hidden = true; }, 160);
});
card.querySelector(".themeOfTheMonthCardConfirmAccept")?.addEventListener("click", () => {
settingsState.themeOfTheMonthDisabled = true;
dismiss();
dismissWithCleanup();
});
document.body.appendChild(card);
// Mount — card at top:0; left:0, all positioning via transform.
card.style.position = "fixed";
card.style.top = "0";
card.style.left = "0";
document.body.append(backdrop, card);
// Set initial collapsed position instantly (no transition).
applyThemeOfTheMonthCardPosition(card, false, false);
window.addEventListener("resize", onResize);
// Enable morph-ready class after two frames so the initial snap doesn't
// accidentally play a transition.
if (settingsState.animations) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
card.classList.add("themeOfTheMonthCardMorphReady");
});
});
}
}
/**
* Dev helper: fetch the current month's entry and show the popup immediately,
* even if the user dismissed it for this calendar month.
*/
export async function showThemeOfTheMonthPopupNow(): Promise<void> {
const entry = await fetchThemeOfTheMonth();
if (!entry) {