site: layout width tiers + masonry glide — column changes animate instead of snapping
Named width tiers (mobile/narrow/regular/wide) with body max-width easing between them; grid pages opt in via BaseLayout `wide`. Masonry is now absolute-positioned with FLIP glides on column-count changes: targets are computed from the *final* container width (reading --max, which flips instantly while max-width tweens), mid-glide column changes retarget instead of being dropped, and container height eases so content below rides along. Claude-Session: https://claude.ai/code/session_01ADR9r5rw5NuvvnCxfYbMuc
This commit is contained in:
@@ -1 +1 @@
|
|||||||
{"sessionId":"87abedef-67c7-43ce-984d-6586f084558f","pid":85715,"procStart":"Sun May 3 13:48:07 2026","acquiredAt":1777883275428}
|
{"sessionId":"f84073a4-0271-4bee-83d1-e6095f6a42e0","pid":80707,"procStart":"Fri Jul 3 18:24:10 2026","acquiredAt":1783106632209}
|
||||||
+217
-77
@@ -20,11 +20,22 @@ const dataBatch = batch != null ? String(batch) : undefined;
|
|||||||
const sources = document.querySelectorAll<HTMLDivElement>('.masonry-source');
|
const sources = document.querySelectorAll<HTMLDivElement>('.masonry-source');
|
||||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
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 {
|
function colCount(): number {
|
||||||
const w = window.innerWidth;
|
const w = window.innerWidth;
|
||||||
if (w <= 520) return 1;
|
if (w <= 520) return 1; // mobile
|
||||||
if (w <= 880) return 2;
|
if (w <= 880) return 2; // mobile / small
|
||||||
return 3;
|
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) {
|
for (const source of sources) {
|
||||||
@@ -34,9 +45,12 @@ const dataBatch = batch != null ? String(batch) : undefined;
|
|||||||
const initialAttr = source.dataset.initial;
|
const initialAttr = source.dataset.initial;
|
||||||
const batchAttr = source.dataset.batch;
|
const batchAttr = source.dataset.batch;
|
||||||
const progressive = initialAttr !== undefined && batchAttr !== undefined;
|
const progressive = initialAttr !== undefined && batchAttr !== undefined;
|
||||||
const initial = progressive ? Number(initialAttr) : tiles.length;
|
const initialN = progressive ? Number(initialAttr) : tiles.length;
|
||||||
const batch = progressive ? Number(batchAttr) : 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');
|
const masonry = document.createElement('div');
|
||||||
masonry.className = 'masonry';
|
masonry.className = 'masonry';
|
||||||
source.parentElement?.insertBefore(masonry, source);
|
source.parentElement?.insertBefore(masonry, source);
|
||||||
@@ -48,124 +62,250 @@ const dataBatch = batch != null ? String(batch) : undefined;
|
|||||||
loadMore.textContent = 'Load more';
|
loadMore.textContent = 'Load more';
|
||||||
masonry.parentElement?.insertBefore(loadMore, masonry.nextSibling);
|
masonry.parentElement?.insertBefore(loadMore, masonry.nextSibling);
|
||||||
|
|
||||||
let cols: HTMLDivElement[] = [];
|
const stash: HTMLElement[] = tiles.slice();
|
||||||
let stash: HTMLElement[] = tiles.slice();
|
const placed: HTMLElement[] = [];
|
||||||
let placed: HTMLElement[] = [];
|
|
||||||
let currentCols = colCount();
|
let currentCols = colCount();
|
||||||
|
let flipUntil = 0; // ignore live retracks while a glide plays
|
||||||
|
let rafId = 0;
|
||||||
|
let settleTimer = 0;
|
||||||
|
|
||||||
function buildColumns() {
|
// Predict the masonry's FINAL width rather than measuring it live. At the
|
||||||
masonry.replaceChildren();
|
// two tier boundaries (1104px: body max-width tweens 42rem→55.5rem over
|
||||||
cols = [];
|
// 0.45s; 1376px: .masonry margin/padding tween to full-bleed) the container
|
||||||
for (let i = 0; i < currentCols; i++) {
|
// is still mid-transition the instant a column-count change fires, so a
|
||||||
const c = document.createElement('div');
|
// live clientWidth read targets a transient width and the correction lands
|
||||||
c.className = 'masonry-col';
|
// as a visible snap. --max is a custom property, so reading it back returns
|
||||||
masonry.appendChild(c);
|
// the declared (final) value even while the max-width property it feeds is
|
||||||
cols.push(c);
|
// 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function shortestColumn(): HTMLDivElement {
|
const cardW = cols > 0 ? (innerW - (cols - 1) * gap) / cols : innerW;
|
||||||
let min = Infinity;
|
return { cols, cardW, gap, padL };
|
||||||
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];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function placeOne(tile: HTMLElement, animate: boolean) {
|
// Position every placed tile via shortest-column packing. With flip/reveal the
|
||||||
if (animate && !prefersReducedMotion) {
|
// move is animated (FLIP): record old rects, jump to the new layout with
|
||||||
tile.classList.add('revealing');
|
// transitions off, invert back to old, then release so CSS eases to new.
|
||||||
tile.addEventListener(
|
function layout(opts: { flip?: boolean; reveal?: Set<HTMLElement> } = {}) {
|
||||||
'animationend',
|
if (placed.length === 0) return;
|
||||||
() => tile.classList.remove('revealing'),
|
const { cols, cardW, gap, padL } = metrics();
|
||||||
{ once: true }
|
if (!(cardW > 0)) return;
|
||||||
);
|
const animate = (!!opts.flip || !!opts.reveal) && !prefersReducedMotion;
|
||||||
|
|
||||||
|
// First — remember where things are.
|
||||||
|
const oldPos = new Map<HTMLElement, { x: number; y: number; w: number }>();
|
||||||
|
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 });
|
||||||
}
|
}
|
||||||
shortestColumn().appendChild(tile);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function placeBatch(n: number, animate: boolean) {
|
// 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<HTMLElement, { x: number; y: number }>();
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run an animated layout and hold off live retracks until the glide settles.
|
||||||
|
function animatedLayout(reveal?: Set<HTMLElement>) {
|
||||||
|
flipUntil = performance.now() + 560;
|
||||||
|
window.clearTimeout(settleTimer);
|
||||||
|
settleTimer = window.setTimeout(() => layout({}), 600);
|
||||||
|
layout({ flip: true, reveal });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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({});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ro = new ResizeObserver(() => schedule());
|
||||||
|
|
||||||
|
function addBatch(n: number, reveal: boolean) {
|
||||||
const slice = stash.splice(0, n);
|
const slice = stash.splice(0, n);
|
||||||
|
if (slice.length === 0) return;
|
||||||
|
const revealSet = reveal && !prefersReducedMotion ? new Set(slice) : undefined;
|
||||||
for (const t of slice) {
|
for (const t of slice) {
|
||||||
|
if (revealSet) t.style.opacity = '0';
|
||||||
|
masonry.appendChild(t);
|
||||||
placed.push(t);
|
placed.push(t);
|
||||||
placeOne(t, animate);
|
ro.observe(t);
|
||||||
}
|
}
|
||||||
|
if (revealSet) animatedLayout(revealSet);
|
||||||
|
else layout({});
|
||||||
}
|
}
|
||||||
|
|
||||||
function rebuild() {
|
addBatch(Math.min(initialN, stash.length), false);
|
||||||
const visibleCount = placed.length;
|
|
||||||
stash = placed.concat(stash);
|
|
||||||
placed = [];
|
|
||||||
buildColumns();
|
|
||||||
placeBatch(visibleCount, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
buildColumns();
|
|
||||||
placeBatch(Math.min(initial, stash.length), false);
|
|
||||||
|
|
||||||
if (progressive && stash.length > 0) {
|
if (progressive && stash.length > 0) {
|
||||||
loadMore.addEventListener('click', () => {
|
loadMore.addEventListener('click', () => {
|
||||||
placeBatch(Math.min(batch, stash.length), true);
|
addBatch(Math.min(batchN, stash.length), true);
|
||||||
if (stash.length === 0) loadMore.remove();
|
if (stash.length === 0) loadMore.remove();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
loadMore.remove();
|
loadMore.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
let resizeTimer: number | undefined;
|
window.addEventListener('resize', schedule);
|
||||||
window.addEventListener('resize', () => {
|
|
||||||
if (resizeTimer) window.clearTimeout(resizeTimer);
|
|
||||||
resizeTimer = window.setTimeout(() => {
|
|
||||||
const next = colCount();
|
|
||||||
if (next !== currentCols) {
|
|
||||||
currentCols = next;
|
|
||||||
rebuild();
|
|
||||||
}
|
|
||||||
}, 150);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style is:global>
|
<style is:global>
|
||||||
/* No-JS fallback: column-count masonry. JS replaces this with .masonry. */
|
/* No-JS / pre-hydration fallback: column-count masonry. JS replaces the
|
||||||
|
.masonry-source with an absolute-positioned .masonry (below). */
|
||||||
.masonry-source {
|
.masonry-source {
|
||||||
column-count: 3;
|
column-count: 4; /* regular */
|
||||||
column-gap: 1rem;
|
column-gap: 1rem;
|
||||||
|
transition: margin 0.45s cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
padding 0.45s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
}
|
}
|
||||||
|
@media (max-width: 1103px) { .masonry-source { column-count: 3; } } /* narrow */
|
||||||
@media (max-width: 880px) { .masonry-source { column-count: 2; } }
|
@media (max-width: 880px) { .masonry-source { column-count: 2; } }
|
||||||
@media (max-width: 520px) { .masonry-source { column-count: 1; } }
|
@media (max-width: 520px) { .masonry-source { column-count: 1; } }
|
||||||
|
/* wide — the grid breaks out to full-bleed while the chrome stays capped
|
||||||
|
at 55.5rem; as many ~288px columns as fit the full browser width. */
|
||||||
|
@media (min-width: 86rem) {
|
||||||
|
body.wide .masonry-source,
|
||||||
|
body.wide .masonry {
|
||||||
|
margin-inline: calc(50% - 50vw);
|
||||||
|
padding-inline: 1.25rem;
|
||||||
|
}
|
||||||
|
.masonry-source { column-count: initial; column-width: 288px; }
|
||||||
|
}
|
||||||
.masonry-source > * {
|
.masonry-source > * {
|
||||||
break-inside: avoid;
|
break-inside: avoid;
|
||||||
display: block;
|
display: block;
|
||||||
margin: 0 0 1rem;
|
margin: 0 0 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* JS-built masonry: real columns, never reflow placed tiles. */
|
/* JS masonry: tiles are absolutely positioned; transform + width animate so a
|
||||||
|
column-count change eases each card from its old slot to its new one. */
|
||||||
.masonry {
|
.masonry {
|
||||||
display: flex;
|
position: relative;
|
||||||
gap: 1rem;
|
/* height set by JS; eased below so content beneath the grid glides along
|
||||||
align-items: flex-start;
|
with it. Also eases the full-bleed break-out (and its return). */
|
||||||
|
transition: margin 0.45s cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
padding 0.45s cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
height 0.5s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
}
|
}
|
||||||
.masonry-col {
|
.masonry > .masonry-tile {
|
||||||
flex: 1 1 0;
|
position: absolute;
|
||||||
min-width: 0;
|
top: 0;
|
||||||
display: flex;
|
left: 0;
|
||||||
flex-direction: column;
|
margin: 0;
|
||||||
gap: 1rem;
|
transition: 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;
|
||||||
|
will-change: transform, width;
|
||||||
}
|
}
|
||||||
.masonry-tile.revealing {
|
/* During measurement/inversion we position with transitions off. */
|
||||||
animation: masonry-tile-in 520ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
.masonry.is-measuring > .masonry-tile {
|
||||||
will-change: transform, opacity;
|
transition: none;
|
||||||
}
|
|
||||||
@keyframes masonry-tile-in {
|
|
||||||
from { opacity: 0; transform: translateY(22px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.masonry-tile.revealing { animation: none; }
|
.masonry,
|
||||||
|
.masonry > .masonry-tile { transition: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.masonry-load-more {
|
.masonry-load-more {
|
||||||
display: block;
|
display: block;
|
||||||
margin: 1.5rem auto 0;
|
margin: 1.5rem auto 0;
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ interface Props {
|
|||||||
modifiedTime?: Date;
|
modifiedTime?: Date;
|
||||||
articleTags?: string[];
|
articleTags?: string[];
|
||||||
jsonLd?: Record<string, unknown> | Record<string, unknown>[];
|
jsonLd?: Record<string, unknown> | Record<string, unknown>[];
|
||||||
|
/** Opt into the wider content tier on roomy viewports (grid/listing pages). */
|
||||||
|
wide?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -38,6 +40,7 @@ const {
|
|||||||
modifiedTime,
|
modifiedTime,
|
||||||
articleTags,
|
articleTags,
|
||||||
jsonLd,
|
jsonLd,
|
||||||
|
wide,
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
const siteName = 'Unique';
|
const siteName = 'Unique';
|
||||||
const SITE_FOUNDED = new Date(2026, 4, 3);
|
const SITE_FOUNDED = new Date(2026, 4, 3);
|
||||||
@@ -77,7 +80,7 @@ const year = new Date().getFullYear();
|
|||||||
/>
|
/>
|
||||||
<script defer src="https://static.cloudflareinsights.com/beacon.min.js" data-cf-beacon='{"token": "fc978456945c49ae90a0b7e0b891ffd2"}'></script>
|
<script defer src="https://static.cloudflareinsights.com/beacon.min.js" data-cf-beacon='{"token": "fc978456945c49ae90a0b7e0b891ffd2"}'></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class:list={[{ wide }]}>
|
||||||
<header class="masthead">
|
<header class="masthead">
|
||||||
<div class="masthead-row">
|
<div class="masthead-row">
|
||||||
<div class="masthead-stats">
|
<div class="masthead-stats">
|
||||||
@@ -158,8 +161,32 @@ const year = new Date().getFullYear();
|
|||||||
--muted: #6b6b6b;
|
--muted: #6b6b6b;
|
||||||
--accent: #b34700;
|
--accent: #b34700;
|
||||||
--rule: #e0dbd2;
|
--rule: #e0dbd2;
|
||||||
|
/* Content column width. Reading pages keep this tight ("narrow") for a
|
||||||
|
comfortable line length; grid/listing pages opt into wider tiers via
|
||||||
|
<body class="wide"> (BaseLayout `wide` prop). See the tier table below. */
|
||||||
--max: 42rem;
|
--max: 42rem;
|
||||||
}
|
}
|
||||||
|
/* ── Layout width tiers ────────────────────────────────────────────────
|
||||||
|
Named steps, shared with the masonry column logic (Masonry.astro).
|
||||||
|
Element rem = 18px (html font-size), so --max in rem renders ×18;
|
||||||
|
media-query breakpoints use the browser default (1rem = 16px).
|
||||||
|
|
||||||
|
tier viewport (mq) chrome (--max) grid card
|
||||||
|
mobile ≤ 880px 42rem / fluid 1–2 cols fluid
|
||||||
|
narrow 880–1104px 42rem (756px) 3 cols contained 225px
|
||||||
|
regular 1104–1376px 55.5rem (999px) 4 cols contained 225px (= narrow)
|
||||||
|
wide ≥ 1376px 55.5rem (999px) fluid, full-bleed ~288px (≈ old 3-col)
|
||||||
|
|
||||||
|
• Chrome (masthead, hero, footer) is capped at the regular 55.5rem in
|
||||||
|
BOTH regular and wide — it never moves between them. Only the card grid
|
||||||
|
breaks out to full width in wide (the full-bleed rule is in Masonry.astro).
|
||||||
|
• regular = 4·225 + 3·gap(18) + 2·pad(22.5) = 999px, so its 4 columns
|
||||||
|
match narrow's 225px card exactly.
|
||||||
|
• wide engages ~10% past a 4-col grid of 288px cards
|
||||||
|
(4·288 + 3·18 + 2·22.5 = 1251px → ×1.10 ≈ 1376px = 86rem), then fills
|
||||||
|
the full browser width with as many ~288px cards as fit.
|
||||||
|
Reading pages omit `wide`, so they stay at narrow (42rem) at every size. */
|
||||||
|
@media (min-width: 69rem) { body.wide { --max: 55.5rem; } } /* regular + wide chrome */
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
html, body {
|
html, body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -171,10 +198,19 @@ const year = new Date().getFullYear();
|
|||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
/* Clip at the viewport (not body — the wide grid breaks out past body) so a
|
||||||
|
card mid-glide from a wider layout never flashes a horizontal scrollbar. */
|
||||||
|
html { overflow-x: clip; }
|
||||||
body {
|
body {
|
||||||
max-width: var(--max);
|
max-width: var(--max);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 1.5rem 1.25rem 2rem;
|
padding: 1.5rem 1.25rem 2rem;
|
||||||
|
/* Glide between width tiers instead of snapping — the hero and contained
|
||||||
|
cards ride inside body, so they gently resize along with the column. */
|
||||||
|
transition: max-width 0.45s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
body { transition: none; }
|
||||||
}
|
}
|
||||||
a { color: var(--accent); text-decoration: none; }
|
a { color: var(--accent); text-decoration: none; }
|
||||||
a:hover { text-decoration: underline; }
|
a:hover { text-decoration: underline; }
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const fmt = new Intl.DateTimeFormat('en-US', {
|
|||||||
subheader={`All ${bundles.length} themed bundles.`}
|
subheader={`All ${bundles.length} themed bundles.`}
|
||||||
lastUpdated={lastUpdated}
|
lastUpdated={lastUpdated}
|
||||||
noindex
|
noindex
|
||||||
|
wide
|
||||||
>
|
>
|
||||||
<Masonry>
|
<Masonry>
|
||||||
{bundles.map((b) => {
|
{bundles.map((b) => {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ const quoteCount = finds.filter(isQuote).length;
|
|||||||
subheader={`All ${finds.length} captured finds.`}
|
subheader={`All ${finds.length} captured finds.`}
|
||||||
lastUpdated={lastUpdated}
|
lastUpdated={lastUpdated}
|
||||||
noindex
|
noindex
|
||||||
|
wide
|
||||||
>
|
>
|
||||||
{import.meta.env.DEV && (
|
{import.meta.env.DEV && (
|
||||||
<div class="find-filter-row">
|
<div class="find-filter-row">
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const lastUpdated = reviews[0]?.data.date;
|
|||||||
subheader="Every review as a tile."
|
subheader="Every review as a tile."
|
||||||
lastUpdated={lastUpdated}
|
lastUpdated={lastUpdated}
|
||||||
noindex
|
noindex
|
||||||
|
wide
|
||||||
>
|
>
|
||||||
<Masonry>
|
<Masonry>
|
||||||
{tiles.map((tile) => (
|
{tiles.map((tile) => (
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ const itemList = buildItemList(
|
|||||||
lastUpdated={lastUpdated}
|
lastUpdated={lastUpdated}
|
||||||
ogImage={ogImage}
|
ogImage={ogImage}
|
||||||
jsonLd={[WEBSITE, ORGANIZATION, itemList]}
|
jsonLd={[WEBSITE, ORGANIZATION, itemList]}
|
||||||
|
wide
|
||||||
>
|
>
|
||||||
{hero && hero.kind === 'review' && (
|
{hero && hero.kind === 'review' && (
|
||||||
<a href={hero.href} class="hero">
|
<a href={hero.href} class="hero">
|
||||||
|
|||||||
Reference in New Issue
Block a user