diff --git a/src/components/EntryGrid.astro b/src/components/EntryGrid.astro index 8b86ee5..2be736c 100644 --- a/src/components/EntryGrid.astro +++ b/src/components/EntryGrid.astro @@ -80,10 +80,12 @@ function visualFor(entry: Entry): Visual | undefined { border: 2px solid var(--rule); background: var(--bg); color: inherit; - transition: box-shadow 0.18s ease, border-color 0.18s ease; + transition: box-shadow 0.18s ease, border-color 0.18s ease, + translate 0.3s cubic-bezier(0.22, 1, 0.36, 1); } .tile:hover { text-decoration: none; + translate: 0 -2px; border-color: color-mix(in srgb, var(--accent) 50%, var(--rule)); box-shadow: 0 0 20px -4px color-mix(in srgb, var(--accent) 30%, transparent); } @@ -94,7 +96,9 @@ function visualFor(entry: Entry): Visual | undefined { width: 100%; height: auto; background: var(--rule); + transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1); } + .tile:hover .media { transform: scale(1.03); } .text { padding: 0.85rem 1rem 1rem; diff --git a/src/components/FindCard.astro b/src/components/FindCard.astro index 9c4ee0f..3f48d2d 100644 --- a/src/components/FindCard.astro +++ b/src/components/FindCard.astro @@ -186,10 +186,18 @@ function host(url: string) { border: 1px solid var(--rule); background: var(--bg); overflow: hidden; - transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease; + /* 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; } .find-card:hover { - transform: translateY(-2px); + translate: 0 -2px; border-color: color-mix(in srgb, var(--accent) 40%, var(--rule)); box-shadow: 0 8px 24px -10px rgba(0, 0, 0, 0.18); } @@ -201,7 +209,9 @@ function host(url: string) { height: auto; background: var(--card-fill); border-bottom: 1px solid var(--rule); + transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1); } + .find-card:hover .media { transform: scale(1.03); } .content { padding: 0.95rem 1.05rem 1rem; diff --git a/src/components/Masonry.astro b/src/components/Masonry.astro index f5b8628..f4a3cd7 100644 --- a/src/components/Masonry.astro +++ b/src/components/Masonry.astro @@ -20,6 +20,10 @@ const dataBatch = batch != null ? String(batch) : undefined; const sources = document.querySelectorAll('.masonry-source'); const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + // Per-tile reveal stagger (ms) and its cap, so big batches don't drag on. + 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; @@ -154,6 +158,7 @@ const dataBatch = batch != null ? String(batch) : undefined; 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'; } @@ -179,10 +184,19 @@ const dataBatch = batch != null ? String(batch) : undefined; } void masonry.offsetWidth; // reflow so the inverted start is committed - // Play — release transitions and move to the real layout. + // 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'; @@ -190,10 +204,12 @@ const dataBatch = batch != null ? String(batch) : undefined; } // 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) { - flipUntil = performance.now() + 560; + 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); + settleTimer = window.setTimeout(() => layout({}), 600 + stagger); layout({ flip: true, reveal }); } @@ -234,7 +250,8 @@ const dataBatch = batch != null ? String(batch) : undefined; else layout({}); } - addBatch(Math.min(initialN, stash.length), false); + // The first batch reveals too — a gentle staggered rise on page load. + addBatch(Math.min(initialN, stash.length), true); if (progressive && stash.length > 0) { loadMore.addEventListener('click', () => { @@ -292,9 +309,15 @@ const dataBatch = batch != null ? String(batch) : undefined; 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; + 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. */ @@ -319,10 +342,15 @@ const dataBatch = batch != null ? String(batch) : undefined; border: 1px solid var(--rule); border-radius: 999px; cursor: pointer; - transition: color 0.18s ease, border-color 0.18s ease; + transition: color 0.18s ease, border-color 0.18s ease, translate 0.15s ease, transform 0.15s ease; } .masonry-load-more:hover { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 50%, var(--rule)); + translate: 0 -1px; + } + .masonry-load-more:active { + translate: 0 0; + transform: scale(0.96); } diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 2c03fea..c817c04 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -212,9 +212,35 @@ const year = new Date().getFullYear(); @media (prefers-reduced-motion: reduce) { body { transition: none; } } - a { color: var(--accent); text-decoration: none; } + a { color: var(--accent); text-decoration: none; transition: color 0.15s ease; } a:hover { text-decoration: underline; } + /* Cross-document view transitions — a soft cross-fade between pages in + browsers that support it; everyone else just navigates. */ + @view-transition { navigation: auto; } + + ::selection { background: color-mix(in srgb, var(--accent) 24%, var(--bg)); } + + :focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; } + + html { scroll-behavior: smooth; } + + /* Media fade-in: the script at the foot of tags not-yet-loaded + images/videos with .fx-fade and flips .fx-in when each finishes, so + media eases in instead of popping. Cached media never gets the class + (no flash on repeat views); without JS nothing is ever hidden. */ + .fx-fade { opacity: 0; } + .fx-fade.fx-in { opacity: 1; transition: opacity 0.55s ease; } + + @media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } + @view-transition { navigation: none; } + *, *::before, *::after { + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + } + } + /* Masthead */ .masthead { margin-bottom: 2rem; @@ -420,7 +446,16 @@ const year = new Date().getFullYear(); font-weight: 600; font-size: 0.95rem; } - .external a:hover { text-decoration: none; opacity: 0.9; } + .external a { + transition: opacity 0.18s ease, translate 0.18s ease, box-shadow 0.18s ease; + } + .external a:hover { + text-decoration: none; + opacity: 0.92; + translate: 0 -1px; + box-shadow: 0 6px 16px -8px color-mix(in srgb, var(--accent) 70%, transparent); + } + .external a:active { translate: 0 0; } .external .aff-badge { display: inline-block; margin-left: 0.6rem; @@ -519,6 +554,27 @@ const year = new Date().getFullYear(); .colophon dt:first-child { margin-top: 0; } } + {import.meta.env.DEV && } diff --git a/src/pages/index.astro b/src/pages/index.astro index 7cf7427..1135857 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -184,10 +184,12 @@ const itemList = buildItemList( background: var(--bg); color: inherit; margin-bottom: 2.5rem; - transition: box-shadow 0.18s ease, border-color 0.18s ease; + transition: box-shadow 0.18s ease, border-color 0.18s ease, + translate 0.3s cubic-bezier(0.22, 1, 0.36, 1); } .hero:hover { text-decoration: none; + translate: 0 -2px; border-color: color-mix(in srgb, var(--accent) 50%, var(--rule)); box-shadow: 0 0 32px -4px color-mix(in srgb, var(--accent) 35%, transparent); } @@ -200,7 +202,9 @@ const itemList = buildItemList( max-height: 62vh; object-fit: cover; background: var(--rule); + transition: transform 0.6s cubic-bezier(0.22, 1, 0.36, 1); } + .hero:hover .hero-media { transform: scale(1.015); } .hero-text { padding: 1.6rem 1.75rem 1.75rem; display: flex; @@ -235,10 +239,12 @@ const itemList = buildItemList( border: 2px solid var(--rule); background: var(--bg); color: inherit; - transition: box-shadow 0.18s ease, border-color 0.18s ease; + transition: box-shadow 0.18s ease, border-color 0.18s ease, + translate 0.3s cubic-bezier(0.22, 1, 0.36, 1); } .tile:hover { text-decoration: none; + translate: 0 -2px; border-color: color-mix(in srgb, var(--accent) 50%, var(--rule)); box-shadow: 0 0 20px -4px color-mix(in srgb, var(--accent) 30%, transparent); } @@ -249,7 +255,9 @@ const itemList = buildItemList( width: 100%; height: auto; background: var(--rule); + transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1); } + .tile:hover .media { transform: scale(1.03); } .text { padding: 0.85rem 1rem 1rem;