diff --git a/src/components/FindCard.astro b/src/components/FindCard.astro index 3f48d2d..91afb1b 100644 --- a/src/components/FindCard.astro +++ b/src/components/FindCard.astro @@ -186,15 +186,9 @@ function host(url: string) { border: 1px solid var(--rule); background: var(--bg); overflow: hidden; - /* The hover lift uses `translate` (not `transform`) so it composes with the - inline transform the masonry uses to position tiles instead of losing to it. - The card is itself a masonry tile, so this list must stay the same union of - properties as .masonry > .masonry-tile (Masonry.astro) — the two rules tie - on specificity and either may win the cascade. */ - transition: translate 0.25s ease, box-shadow 0.18s ease, border-color 0.18s ease, - transform 0.5s cubic-bezier(0.22, 1, 0.36, 1), - width 0.5s cubic-bezier(0.22, 1, 0.36, 1), - opacity 0.42s ease; + /* The hover lift uses `translate` (not `transform`) so it doesn't fight the + masonry reveal animation, which animates `transform`. */ + transition: translate 0.25s ease, box-shadow 0.18s ease, border-color 0.18s ease; } .find-card:hover { translate: 0 -2px; diff --git a/src/components/Masonry.astro b/src/components/Masonry.astro index f4a3cd7..9afcabb 100644 --- a/src/components/Masonry.astro +++ b/src/components/Masonry.astro @@ -24,22 +24,16 @@ const dataBatch = batch != null ? String(batch) : undefined; const STAGGER_STEP = 36; const STAGGER_CAP = 300; - // 1rem in px (html font-size = 18px here) — the grid gap, kept robust to changes. - function rootRem(): number { - return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; - } - - // Column count per width tier — see the tier table in BaseLayout.astro. + // 1–2 columns on small screens; 3 in the default 42rem container; 4 when a + // grid page opted into the 55.5rem container (BaseLayout `wide`, ≥ 69rem). function colCount(): number { const w = window.innerWidth; - if (w <= 520) return 1; // mobile - if (w <= 880) return 2; // mobile / small - if (w < 1104) return 3; // narrow — 225px cards - if (w < 1376) return 4; // regular — 225px cards, 4 cols - // wide — full-width grid of ~288px (16rem) cards; round so card width stays - // near the target as columns flow in. - const gap = rootRem(); - return Math.max(4, Math.round((w - 1.5 * gap) / (17 * gap))); + if (w <= 520) return 1; + if (w <= 880) return 2; + const wide = + document.body.classList.contains('wide') && + window.matchMedia('(min-width: 69rem)').matches; + return wide ? 4 : 3; } for (const source of sources) { @@ -52,9 +46,6 @@ const dataBatch = batch != null ? String(batch) : undefined; const initialN = progressive ? Number(initialAttr) : tiles.length; const batchN = progressive ? Number(batchAttr) : tiles.length; - // Absolute-positioned masonry: tiles are placed with transform + width so a - // column-count change animates (FLIP) instead of snapping. .masonry-source is - // only the no-JS / pre-hydration fallback. const masonry = document.createElement('div'); masonry.className = 'masonry'; source.parentElement?.insertBefore(masonry, source); @@ -66,267 +57,133 @@ const dataBatch = batch != null ? String(batch) : undefined; loadMore.textContent = 'Load more'; masonry.parentElement?.insertBefore(loadMore, masonry.nextSibling); - const stash: HTMLElement[] = tiles.slice(); - const placed: HTMLElement[] = []; + let cols: HTMLDivElement[] = []; + let stash: HTMLElement[] = tiles.slice(); + let placed: HTMLElement[] = []; let currentCols = colCount(); - let flipUntil = 0; // ignore live retracks while a glide plays - let rafId = 0; - let settleTimer = 0; - // Predict the masonry's FINAL width rather than measuring it live. At the - // two tier boundaries (1104px: body max-width tweens 42rem→55.5rem over - // 0.45s; 1376px: .masonry margin/padding tween to full-bleed) the container - // is still mid-transition the instant a column-count change fires, so a - // live clientWidth read targets a transient width and the correction lands - // as a visible snap. --max is a custom property, so reading it back returns - // the declared (final) value even while the max-width property it feeds is - // still animating — that's the escape hatch. The constants below (1.25rem - // full-bleed padding, box-sizing: border-box) mirror the tier table in - // BaseLayout.astro ("Layout width tiers"); keep them in sync with it. - function metrics() { - const gap = rootRem(); - const cols = colCount(); - const fullBleed = - document.body.classList.contains('wide') && - window.matchMedia('(min-width: 86rem)').matches; - - let innerW: number; - let padL: number; - - if (fullBleed) { - // .masonry { padding-inline: 1.25rem } in the wide media query below. - padL = 1.25 * gap; - innerW = document.documentElement.clientWidth - 2 * padL; - } else { - const bodyCs = getComputedStyle(document.body); - const maxStr = bodyCs.getPropertyValue('--max').trim(); - const maxPx = maxStr.endsWith('rem') ? parseFloat(maxStr) * gap : parseFloat(maxStr); - if (Number.isNaN(maxPx)) { - // Unparseable --max — fall back to a live (possibly mid-transition) read. - const cs = getComputedStyle(masonry); - padL = parseFloat(cs.paddingLeft) || 0; - const padR = parseFloat(cs.paddingRight) || 0; - innerW = masonry.clientWidth - padL - padR; - } else { - // Body is box-sizing: border-box; its own padding never animates. - const bodyPadL = parseFloat(bodyCs.paddingLeft) || 0; - const bodyPadR = parseFloat(bodyCs.paddingRight) || 0; - const containerW = Math.min(document.documentElement.clientWidth, maxPx); - innerW = containerW - bodyPadL - bodyPadR; - padL = 0; - } - } - - const cardW = cols > 0 ? (innerW - (cols - 1) * gap) / cols : innerW; - return { cols, cardW, gap, padL }; - } - - // Position every placed tile via shortest-column packing. With flip/reveal the - // move is animated (FLIP): record old rects, jump to the new layout with - // transitions off, invert back to old, then release so CSS eases to new. - function layout(opts: { flip?: boolean; reveal?: Set } = {}) { - if (placed.length === 0) return; - const { cols, cardW, gap, padL } = metrics(); - if (!(cardW > 0)) return; - const animate = (!!opts.flip || !!opts.reveal) && !prefersReducedMotion; - - // First — remember where things are. - const oldPos = new Map(); - if (animate) { - const base = masonry.getBoundingClientRect(); - for (const t of placed) { - if (opts.reveal?.has(t)) continue; - const r = t.getBoundingClientRect(); - oldPos.set(t, { x: r.left - base.left, y: r.top - base.top, w: r.width }); - } - } - - // Last — apply new widths, measure heights, pack into shortest column. - masonry.classList.add('is-measuring'); - for (const t of placed) t.style.width = cardW + 'px'; - const colH = new Array(cols).fill(0); - const newPos = new Map(); - for (const t of placed) { - const h = t.offsetHeight; - let c = 0; - for (let i = 1; i < cols; i++) if (colH[i] < colH[c]) c = i; - newPos.set(t, { x: padL + c * (cardW + gap), y: colH[c] }); - colH[c] += h + gap; - } - masonry.style.height = Math.max(0, Math.max(...colH) - gap) + 'px'; - - if (!animate) { - for (const t of placed) { - const p = newPos.get(t)!; - t.style.transitionDelay = ''; - t.style.transform = `translate(${p.x}px, ${p.y}px)`; - t.style.opacity = '1'; - } - masonry.classList.remove('is-measuring'); - return; - } - - // Invert — snap each tile back to where it just was (or below, for reveals). - for (const t of placed) { - const p = newPos.get(t)!; - if (opts.reveal?.has(t)) { - t.style.width = cardW + 'px'; - t.style.transform = `translate(${p.x}px, ${p.y + 26}px)`; - t.style.opacity = '0'; - } else if (oldPos.has(t)) { - const o = oldPos.get(t)!; - t.style.width = o.w + 'px'; - t.style.transform = `translate(${o.x}px, ${o.y}px)`; - } else { - t.style.transform = `translate(${p.x}px, ${p.y}px)`; - t.style.opacity = '1'; - } - } - void masonry.offsetWidth; // reflow so the inverted start is committed - - // Play — release transitions and move to the real layout. Revealed tiles - // cascade with a small per-tile delay so a batch ripples in instead of - // blinking on all at once; everything else moves immediately. - masonry.classList.remove('is-measuring'); - let revealIdx = 0; - for (const t of placed) { - const p = newPos.get(t)!; - if (opts.reveal?.has(t)) { - t.style.transitionDelay = Math.min(revealIdx * STAGGER_STEP, STAGGER_CAP) + 'ms'; - revealIdx += 1; - } else { - t.style.transitionDelay = ''; - } - t.style.width = cardW + 'px'; - t.style.transform = `translate(${p.x}px, ${p.y}px)`; - t.style.opacity = '1'; + function buildColumns() { + masonry.replaceChildren(); + cols = []; + for (let i = 0; i < currentCols; i++) { + const c = document.createElement('div'); + c.className = 'masonry-col'; + masonry.appendChild(c); + cols.push(c); } } - // Run an animated layout and hold off live retracks until the glide settles. - // Reveal staggering stretches the glide, so the settle window stretches too. - function animatedLayout(reveal?: Set) { - const stagger = reveal ? Math.min(reveal.size * STAGGER_STEP, STAGGER_CAP) : 0; - flipUntil = performance.now() + 560 + stagger; - window.clearTimeout(settleTimer); - settleTimer = window.setTimeout(() => layout({}), 600 + stagger); - layout({ flip: true, reveal }); + function shortestColumn(): HTMLDivElement { + let min = Infinity; + let idx = 0; + for (let i = 0; i < cols.length; i++) { + const h = cols[i].offsetHeight; + if (h < min) { min = h; idx = i; } + } + return cols[idx]; } - // Coalesced retrack from resize / content-size changes. A column-count change - // glides — even one that lands mid-glide, so it retargets seamlessly instead - // of being dropped (the FLIP's old positions come from live - // getBoundingClientRect(), not from `currentCols`). A same-tier width change - // during a glide is self-inflicted by the animating tile widths, so it's - // suppressed until the glide settles. - function schedule() { - if (rafId) return; - rafId = window.requestAnimationFrame(() => { - rafId = 0; - const next = colCount(); - if (next !== currentCols) { - currentCols = next; - animatedLayout(); - return; - } - if (performance.now() < flipUntil) return; // self-inflicted RO ticks while a glide plays - layout({}); - }); + function placeOne(tile: HTMLElement, revealIdx: number) { + if (revealIdx >= 0 && !prefersReducedMotion) { + tile.style.animationDelay = Math.min(revealIdx * STAGGER_STEP, STAGGER_CAP) + 'ms'; + tile.classList.add('revealing'); + tile.addEventListener( + 'animationend', + () => { + tile.classList.remove('revealing'); + tile.style.animationDelay = ''; + }, + { once: true } + ); + } + shortestColumn().appendChild(tile); } - const ro = new ResizeObserver(() => schedule()); - - function addBatch(n: number, reveal: boolean) { + // A revealed batch ripples in with a small per-tile delay instead of + // blinking on all at once. + function placeBatch(n: number, reveal: boolean) { const slice = stash.splice(0, n); - if (slice.length === 0) return; - const revealSet = reveal && !prefersReducedMotion ? new Set(slice) : undefined; - for (const t of slice) { - if (revealSet) t.style.opacity = '0'; - masonry.appendChild(t); - placed.push(t); - ro.observe(t); + for (let i = 0; i < slice.length; i++) { + placed.push(slice[i]); + placeOne(slice[i], reveal ? i : -1); } - if (revealSet) animatedLayout(revealSet); - else layout({}); } + function rebuild() { + const visibleCount = placed.length; + stash = placed.concat(stash); + placed = []; + buildColumns(); + placeBatch(visibleCount, false); + } + + buildColumns(); // The first batch reveals too — a gentle staggered rise on page load. - addBatch(Math.min(initialN, stash.length), true); + placeBatch(Math.min(initialN, stash.length), true); if (progressive && stash.length > 0) { loadMore.addEventListener('click', () => { - addBatch(Math.min(batchN, stash.length), true); + placeBatch(Math.min(batchN, stash.length), true); if (stash.length === 0) loadMore.remove(); }); } else { loadMore.remove(); } - window.addEventListener('resize', schedule); + let resizeTimer: number | undefined; + window.addEventListener('resize', () => { + if (resizeTimer) window.clearTimeout(resizeTimer); + resizeTimer = window.setTimeout(() => { + const next = colCount(); + if (next !== currentCols) { + currentCols = next; + rebuild(); + } + }, 150); + }); }