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);
|
||||
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;
|
||||
|
||||
+92
-235
@@ -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<HTMLElement> } = {}) {
|
||||
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<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 });
|
||||
}
|
||||
}
|
||||
|
||||
// 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.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<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 });
|
||||
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);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style is:global>
|
||||
/* No-JS / pre-hydration fallback: column-count masonry. JS replaces the
|
||||
.masonry-source with an absolute-positioned .masonry (below). */
|
||||
/* No-JS fallback: column-count masonry. JS replaces this with .masonry. */
|
||||
.masonry-source {
|
||||
column-count: 4; /* regular */
|
||||
column-count: 3;
|
||||
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: 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 > * {
|
||||
break-inside: avoid;
|
||||
display: block;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
/* JS-built masonry: real columns, never reflow placed tiles. */
|
||||
.masonry {
|
||||
position: relative;
|
||||
/* height set by JS; eased below so content beneath the grid glides along
|
||||
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);
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.masonry > .masonry-tile {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin: 0;
|
||||
/* Same union of properties as .find-card (FindCard.astro) — a find card is
|
||||
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;
|
||||
.masonry-col {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
/* During measurement/inversion we position with transitions off. */
|
||||
.masonry.is-measuring > .masonry-tile {
|
||||
transition: none;
|
||||
.masonry-tile.revealing {
|
||||
animation: masonry-tile-in 520ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
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) {
|
||||
.masonry,
|
||||
.masonry > .masonry-tile { transition: none; }
|
||||
.masonry-tile.revealing { animation: none; }
|
||||
}
|
||||
|
||||
.masonry-load-more {
|
||||
|
||||
@@ -161,32 +161,14 @@ const year = new Date().getFullYear();
|
||||
--muted: #6b6b6b;
|
||||
--accent: #b34700;
|
||||
--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. */
|
||||
/* Content column width. Reading pages keep this tight for a comfortable
|
||||
line length; grid/listing pages opt into the wider container via
|
||||
<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;
|
||||
}
|
||||
/* ── 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 */
|
||||
@media (min-width: 69rem) { body.wide { --max: 55.5rem; } }
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0;
|
||||
@@ -198,19 +180,10 @@ const year = new Date().getFullYear();
|
||||
line-height: 1.55;
|
||||
-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 {
|
||||
max-width: var(--max);
|
||||
margin: 0 auto;
|
||||
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:hover { text-decoration: underline; }
|
||||
|
||||
Reference in New Issue
Block a user