diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock index 6b2571d..e86c6a6 100644 --- a/.claude/scheduled_tasks.lock +++ b/.claude/scheduled_tasks.lock @@ -1 +1 @@ -{"sessionId":"87abedef-67c7-43ce-984d-6586f084558f","pid":85715,"procStart":"Sun May 3 13:48:07 2026","acquiredAt":1777883275428} \ No newline at end of file +{"sessionId":"f84073a4-0271-4bee-83d1-e6095f6a42e0","pid":80707,"procStart":"Fri Jul 3 18:24:10 2026","acquiredAt":1783106632209} \ No newline at end of file diff --git a/src/components/Masonry.astro b/src/components/Masonry.astro index 5ab9ba0..f5b8628 100644 --- a/src/components/Masonry.astro +++ b/src/components/Masonry.astro @@ -20,11 +20,22 @@ const dataBatch = batch != null ? String(batch) : undefined; const sources = document.querySelectorAll('.masonry-source'); const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + // 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. function colCount(): number { const w = window.innerWidth; - if (w <= 520) return 1; - if (w <= 880) return 2; - return 3; + 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))); } for (const source of sources) { @@ -34,9 +45,12 @@ const dataBatch = batch != null ? String(batch) : undefined; const initialAttr = source.dataset.initial; const batchAttr = source.dataset.batch; const progressive = initialAttr !== undefined && batchAttr !== undefined; - const initial = progressive ? Number(initialAttr) : tiles.length; - const batch = progressive ? Number(batchAttr) : tiles.length; + 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); @@ -48,124 +62,250 @@ const dataBatch = batch != null ? String(batch) : undefined; loadMore.textContent = 'Load more'; masonry.parentElement?.insertBefore(loadMore, masonry.nextSibling); - let cols: HTMLDivElement[] = []; - let stash: HTMLElement[] = tiles.slice(); - let placed: HTMLElement[] = []; + const stash: HTMLElement[] = tiles.slice(); + const placed: HTMLElement[] = []; let currentCols = colCount(); + let flipUntil = 0; // ignore live retracks while a glide plays + let rafId = 0; + let settleTimer = 0; - 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); + // 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.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. + masonry.classList.remove('is-measuring'); + for (const t of placed) { + const p = newPos.get(t)!; + t.style.width = cardW + 'px'; + t.style.transform = `translate(${p.x}px, ${p.y}px)`; + t.style.opacity = '1'; } } - 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]; + // Run an animated layout and hold off live retracks until the glide settles. + function animatedLayout(reveal?: Set) { + flipUntil = performance.now() + 560; + window.clearTimeout(settleTimer); + settleTimer = window.setTimeout(() => layout({}), 600); + layout({ flip: true, reveal }); } - function placeOne(tile: HTMLElement, animate: boolean) { - if (animate && !prefersReducedMotion) { - tile.classList.add('revealing'); - tile.addEventListener( - 'animationend', - () => tile.classList.remove('revealing'), - { once: true } - ); - } - shortestColumn().appendChild(tile); + // 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 placeBatch(n: number, animate: boolean) { + const ro = new ResizeObserver(() => schedule()); + + function addBatch(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); - placeOne(t, animate); + ro.observe(t); } + if (revealSet) animatedLayout(revealSet); + else layout({}); } - function rebuild() { - const visibleCount = placed.length; - stash = placed.concat(stash); - placed = []; - buildColumns(); - placeBatch(visibleCount, false); - } - - buildColumns(); - placeBatch(Math.min(initial, stash.length), false); + addBatch(Math.min(initialN, stash.length), false); if (progressive && stash.length > 0) { loadMore.addEventListener('click', () => { - placeBatch(Math.min(batch, stash.length), true); + addBatch(Math.min(batchN, stash.length), true); if (stash.length === 0) loadMore.remove(); }); } else { loadMore.remove(); } - 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); - }); + window.addEventListener('resize', schedule); }