From 99b55e4e19bb2a1c5d98eaf6cd732278531b0544 Mon Sep 17 00:00:00 2001 From: rzen Date: Sun, 12 Jul 2026 07:51:14 -0400 Subject: [PATCH] =?UTF-8?q?site:=20back=20out=20wide=20layout=20=E2=80=94?= =?UTF-8?q?=20fixed=203/4-column=20masonry,=20fixes=20broken=20bundle=20pa?= =?UTF-8?q?ges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The width-tier system (full-bleed wide tier, body max-width tweens, absolute-positioned FLIP masonry) is gone. Grid pages are 4 columns in a 55.5rem container (>=69rem viewport), everything else 3, with the existing 1-2 column steps on small screens. Masonry returns to real flex columns. This also fixes bundle pages rendering as a diagonal card cascade: the old .masonry > .masonry-tile { position: absolute } tied with FindCard's scoped position: relative on specificity, and on bundle pages the FindCard rule won the cascade order, leaving tiles in normal flow with masonry translate() offsets stacked on top. Flex columns don't position tiles, so the tie is gone. Kept from the motion pass: staggered tile reveal on initial load and "Load more" (now via animation-delay), card hover lifts, button tactility. --- src/components/FindCard.astro | 12 +- src/components/Masonry.astro | 327 ++++++++++------------------------ src/layouts/BaseLayout.astro | 39 +--- 3 files changed, 101 insertions(+), 277 deletions(-) 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); + }); }