site: back out wide layout — fixed 3/4-column masonry, fixes broken bundle pages
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.
This commit is contained in:
@@ -186,15 +186,9 @@ function host(url: string) {
|
|||||||
border: 1px solid var(--rule);
|
border: 1px solid var(--rule);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
/* The hover lift uses `translate` (not `transform`) so it composes with the
|
/* The hover lift uses `translate` (not `transform`) so it doesn't fight the
|
||||||
inline transform the masonry uses to position tiles instead of losing to it.
|
masonry reveal animation, which animates `transform`. */
|
||||||
The card is itself a masonry tile, so this list must stay the same union of
|
transition: translate 0.25s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
.find-card:hover {
|
.find-card:hover {
|
||||||
translate: 0 -2px;
|
translate: 0 -2px;
|
||||||
|
|||||||
+91
-234
@@ -24,22 +24,16 @@ const dataBatch = batch != null ? String(batch) : undefined;
|
|||||||
const STAGGER_STEP = 36;
|
const STAGGER_STEP = 36;
|
||||||
const STAGGER_CAP = 300;
|
const STAGGER_CAP = 300;
|
||||||
|
|
||||||
// 1rem in px (html font-size = 18px here) — the grid gap, kept robust to changes.
|
// 1–2 columns on small screens; 3 in the default 42rem container; 4 when a
|
||||||
function rootRem(): number {
|
// grid page opted into the 55.5rem container (BaseLayout `wide`, ≥ 69rem).
|
||||||
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; // mobile
|
if (w <= 520) return 1;
|
||||||
if (w <= 880) return 2; // mobile / small
|
if (w <= 880) return 2;
|
||||||
if (w < 1104) return 3; // narrow — 225px cards
|
const wide =
|
||||||
if (w < 1376) return 4; // regular — 225px cards, 4 cols
|
document.body.classList.contains('wide') &&
|
||||||
// wide — full-width grid of ~288px (16rem) cards; round so card width stays
|
window.matchMedia('(min-width: 69rem)').matches;
|
||||||
// near the target as columns flow in.
|
return wide ? 4 : 3;
|
||||||
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) {
|
||||||
@@ -52,9 +46,6 @@ const dataBatch = batch != null ? String(batch) : undefined;
|
|||||||
const initialN = progressive ? Number(initialAttr) : tiles.length;
|
const initialN = progressive ? Number(initialAttr) : tiles.length;
|
||||||
const batchN = 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);
|
||||||
@@ -66,267 +57,133 @@ 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);
|
||||||
|
|
||||||
const stash: HTMLElement[] = tiles.slice();
|
let cols: HTMLDivElement[] = [];
|
||||||
const placed: HTMLElement[] = [];
|
let stash: HTMLElement[] = tiles.slice();
|
||||||
|
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;
|
|
||||||
|
|
||||||
// Predict the masonry's FINAL width rather than measuring it live. At the
|
function buildColumns() {
|
||||||
// two tier boundaries (1104px: body max-width tweens 42rem→55.5rem over
|
masonry.replaceChildren();
|
||||||
// 0.45s; 1376px: .masonry margin/padding tween to full-bleed) the container
|
cols = [];
|
||||||
// is still mid-transition the instant a column-count change fires, so a
|
for (let i = 0; i < currentCols; i++) {
|
||||||
// live clientWidth read targets a transient width and the correction lands
|
const c = document.createElement('div');
|
||||||
// as a visible snap. --max is a custom property, so reading it back returns
|
c.className = 'masonry-col';
|
||||||
// the declared (final) value even while the max-width property it feeds is
|
masonry.appendChild(c);
|
||||||
// still animating — that's the escape hatch. The constants below (1.25rem
|
cols.push(c);
|
||||||
// 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;
|
function shortestColumn(): HTMLDivElement {
|
||||||
return { cols, cardW, gap, padL };
|
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];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Position every placed tile via shortest-column packing. With flip/reveal the
|
function placeOne(tile: HTMLElement, revealIdx: number) {
|
||||||
// move is animated (FLIP): record old rects, jump to the new layout with
|
if (revealIdx >= 0 && !prefersReducedMotion) {
|
||||||
// transitions off, invert back to old, then release so CSS eases to new.
|
tile.style.animationDelay = Math.min(revealIdx * STAGGER_STEP, STAGGER_CAP) + 'ms';
|
||||||
function layout(opts: { flip?: boolean; reveal?: Set<HTMLElement> } = {}) {
|
tile.classList.add('revealing');
|
||||||
if (placed.length === 0) return;
|
tile.addEventListener(
|
||||||
const { cols, cardW, gap, padL } = metrics();
|
'animationend',
|
||||||
if (!(cardW > 0)) return;
|
() => {
|
||||||
const animate = (!!opts.flip || !!opts.reveal) && !prefersReducedMotion;
|
tile.classList.remove('revealing');
|
||||||
|
tile.style.animationDelay = '';
|
||||||
// First — remember where things are.
|
},
|
||||||
const oldPos = new Map<HTMLElement, { x: number; y: number; w: number }>();
|
{ once: true }
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Last — apply new widths, measure heights, pack into shortest column.
|
// A revealed batch ripples in with a small per-tile delay instead of
|
||||||
masonry.classList.add('is-measuring');
|
// blinking on all at once.
|
||||||
for (const t of placed) t.style.width = cardW + 'px';
|
function placeBatch(n: number, reveal: boolean) {
|
||||||
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.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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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<HTMLElement>) {
|
|
||||||
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 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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;
|
for (let i = 0; i < slice.length; i++) {
|
||||||
const revealSet = reveal && !prefersReducedMotion ? new Set(slice) : undefined;
|
placed.push(slice[i]);
|
||||||
for (const t of slice) {
|
placeOne(slice[i], reveal ? i : -1);
|
||||||
if (revealSet) t.style.opacity = '0';
|
|
||||||
masonry.appendChild(t);
|
|
||||||
placed.push(t);
|
|
||||||
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();
|
||||||
// The first batch reveals too — a gentle staggered rise on page load.
|
// 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) {
|
if (progressive && stash.length > 0) {
|
||||||
loadMore.addEventListener('click', () => {
|
loadMore.addEventListener('click', () => {
|
||||||
addBatch(Math.min(batchN, stash.length), true);
|
placeBatch(Math.min(batchN, stash.length), true);
|
||||||
if (stash.length === 0) loadMore.remove();
|
if (stash.length === 0) loadMore.remove();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
loadMore.remove();
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style is:global>
|
<style is:global>
|
||||||
/* No-JS / pre-hydration fallback: column-count masonry. JS replaces the
|
/* No-JS fallback: column-count masonry. JS replaces this with .masonry. */
|
||||||
.masonry-source with an absolute-positioned .masonry (below). */
|
|
||||||
.masonry-source {
|
.masonry-source {
|
||||||
column-count: 4; /* regular */
|
column-count: 3;
|
||||||
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 (min-width: 69rem) {
|
||||||
|
body.wide .masonry-source { column-count: 4; }
|
||||||
|
}
|
||||||
@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 masonry: tiles are absolutely positioned; transform + width animate so a
|
/* JS-built masonry: real columns, never reflow placed tiles. */
|
||||||
column-count change eases each card from its old slot to its new one. */
|
|
||||||
.masonry {
|
.masonry {
|
||||||
position: relative;
|
display: flex;
|
||||||
/* height set by JS; eased below so content beneath the grid glides along
|
gap: 1rem;
|
||||||
with it. Also eases the full-bleed break-out (and its return). */
|
align-items: flex-start;
|
||||||
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 > .masonry-tile {
|
.masonry-col {
|
||||||
position: absolute;
|
flex: 1 1 0;
|
||||||
top: 0;
|
min-width: 0;
|
||||||
left: 0;
|
display: flex;
|
||||||
margin: 0;
|
flex-direction: column;
|
||||||
/* Same union of properties as .find-card (FindCard.astro) — a find card is
|
gap: 1rem;
|
||||||
itself a masonry tile and the two transition shorthands tie on specificity,
|
|
||||||
so they must agree for both the glide and the hover lift to survive. */
|
|
||||||
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,
|
|
||||||
translate 0.25s ease,
|
|
||||||
box-shadow 0.18s ease,
|
|
||||||
border-color 0.18s ease;
|
|
||||||
will-change: transform, width;
|
|
||||||
}
|
}
|
||||||
/* During measurement/inversion we position with transitions off. */
|
.masonry-tile.revealing {
|
||||||
.masonry.is-measuring > .masonry-tile {
|
animation: masonry-tile-in 520ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
transition: none;
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
@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,
|
.masonry-tile.revealing { animation: none; }
|
||||||
.masonry > .masonry-tile { transition: none; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.masonry-load-more {
|
.masonry-load-more {
|
||||||
|
|||||||
@@ -161,32 +161,14 @@ 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
|
/* Content column width. Reading pages keep this tight for a comfortable
|
||||||
comfortable line length; grid/listing pages opt into wider tiers via
|
line length; grid/listing pages opt into the wider container via
|
||||||
<body class="wide"> (BaseLayout `wide` prop). See the tier table below. */
|
<body class="wide"> (BaseLayout `wide` prop), which gives the masonry
|
||||||
|
4 columns instead of 3 (Masonry.astro mirrors the 69rem breakpoint).
|
||||||
|
55.5rem = 4·225px cards + 3·18px gaps + 2·22.5px body padding. */
|
||||||
--max: 42rem;
|
--max: 42rem;
|
||||||
}
|
}
|
||||||
/* ── Layout width tiers ────────────────────────────────────────────────
|
@media (min-width: 69rem) { body.wide { --max: 55.5rem; } }
|
||||||
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;
|
||||||
@@ -198,19 +180,10 @@ 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; transition: color 0.15s ease; }
|
a { color: var(--accent); text-decoration: none; transition: color 0.15s ease; }
|
||||||
a:hover { text-decoration: underline; }
|
a:hover { text-decoration: underline; }
|
||||||
|
|||||||
Reference in New Issue
Block a user