feat: astro built-ins — news pagination, prefetch, RSS, OG/SEO meta, TOC scrollspy (Starlight-pattern)

This commit is contained in:
ctao
2026-07-25 06:57:58 +02:00
parent 2de0cf148e
commit 4ace039233
13 changed files with 283 additions and 46 deletions
+10 -1
View File
@@ -78,7 +78,8 @@ ctao.org animates nebula videos under Galaxy vignettes; our translation:
already is its ambient; news/article/utility pages stay calm (reading already is its ambient; news/article/utility pages stay calm (reading
focus). Body text NEVER sits on a moving gradient — cards, panels and focus). Body text NEVER sits on a moving gradient — cards, panels and
bands are solid layers above it. bands are solid layers above it.
3. One-shot hero `rise` stagger; hover lift/transitions. Nothing else. 3. One-shot hero `rise` stagger; hover lift/transitions, incl. the card-cover
micro-zoom (scale 1.03, clipped by the card — AstroWind pattern). Nothing else.
Rules: Rules:
- ALL `animation`/`transition` declarations live inside the single - ALL `animation`/`transition` declarations live inside the single
@@ -114,6 +115,14 @@ displayed): `<details class="toc toc--inline">` collapsed under the title
(< 1200px) and `<nav class="toc toc--rail">` sticky right rail (≥ 1200px, (< 1200px) and `<nav class="toc toc--rail">` sticky right rail (≥ 1200px,
outside the 70ch column via `.article-layout` grid). MDN/Stripe/NN-g pattern. outside the 70ch column via `.article-layout` grid). MDN/Stripe/NN-g pattern.
Scrollspy (Starlight's starlight-toc pattern, minimal vanilla): an
IntersectionObserver band under the sticky header maps the visible prose block
to its governing heading and sets `aria-current="true"` on the TOC link
(Galaxy + 600 weight). Functional state, not motion — the "no JS motion" rule
is untouched; the TOC works fully without JS. Same precedent covers the search
`<mark>` term highlight (Cherenkov tint) and the `.pagination` pill nav
(Galaxy pill = current page, 44px targets).
## Do not ## Do not
- No new hues, tints, or grays — derive via `color-mix` from brand tokens only. - No new hues, tints, or grays — derive via `color-mix` from brand tokens only.
+8
View File
@@ -3,6 +3,14 @@ import { defineConfig } from 'astro/config';
// Static output (zero runtime) — the whole point of the git-based approach. // Static output (zero runtime) — the whole point of the git-based approach.
export default defineConfig({ export default defineConfig({
output: 'static', output: 'static',
// Placeholder domain until the real one exists — only used to build absolute
// URLs (canonical, og:*, RSS, sitemap). Swap once the portal has a home.
site: 'https://portal.ctao.org',
// Built-in prefetch on every internal link (no per-link attributes needed).
// Default 'hover' strategy: near-instant navigation without the bandwidth
// cost of 'viewport' on a 24-card grid; auto-falls back to 'tap' on
// data-saver / slow connections (Astro handles that).
prefetch: { prefetchAll: true },
redirects: { '/telescopes': '/proposals' }, redirects: { '/telescopes': '/proposals' },
server: { port: 4321, host: true }, server: { port: 4321, host: true },
vite: { vite: {
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

+5
View File
@@ -0,0 +1,5 @@
User-agent: *
Allow: /
Disallow: /admin/
Sitemap: https://portal.ctao.org/sitemap.xml
+14 -2
View File
@@ -7,6 +7,15 @@
const status = document.getElementById(opts.status); const status = document.getElementById(opts.status);
let index = null; let index = null;
let timer; let timer;
// First title match wrapped in <mark> (Starlight search pattern) — built
// from text nodes, never innerHTML.
function highlight(text, q) {
const i = text.toLowerCase().indexOf(q);
if (i < 0) return [text];
const mark = document.createElement('mark');
mark.textContent = text.slice(i, i + q.length);
return [text.slice(0, i), mark, text.slice(i + q.length)];
}
async function run() { async function run() {
const q = input.value.trim().toLowerCase(); const q = input.value.trim().toLowerCase();
list.innerHTML = ''; list.innerHTML = '';
@@ -15,12 +24,15 @@
const hits = index const hits = index
.filter((p) => (p.title + ' ' + p.description + ' ' + p.category).toLowerCase().includes(q)) .filter((p) => (p.title + ' ' + p.description + ' ' + p.category).toLowerCase().includes(q))
.slice(0, opts.limit); .slice(0, opts.limit);
status.textContent = hits.length ? hits.length + ' ' + (opts.unit || 'result(s)') : 'No results.'; const unit = opts.unit || 'result';
status.textContent = hits.length
? hits.length + ' ' + unit + (hits.length === 1 ? '' : 's') + ' for “' + input.value.trim() + '”'
: 'No results for “' + input.value.trim() + '” — try a shorter or different term.';
for (const p of hits) { for (const p of hits) {
const li = document.createElement('li'); const li = document.createElement('li');
const a = document.createElement('a'); const a = document.createElement('a');
a.href = '/news/' + encodeURIComponent(p.slug); a.href = '/news/' + encodeURIComponent(p.slug);
a.textContent = p.title; a.append(...highlight(p.title, q));
const small = document.createElement('small'); const small = document.createElement('small');
small.textContent = p.date; small.textContent = p.date;
li.append(a, small); li.append(a, small);
+18 -1
View File
@@ -1,7 +1,13 @@
--- ---
import '../styles/global.css'; import '../styles/global.css';
// `ambient` opts a page into the whole-page drifting brand wash (home only — see DESIGN.md) // `ambient` opts a page into the whole-page drifting brand wash (home only — see DESIGN.md)
const { title = 'CTAO Science Portal', description = 'CTAO Science Portal — demo', ambient = false } = Astro.props; // `type`/`image` feed the social meta: articles pass type="article" + their cover.
const { title = 'CTAO Science Portal', description = 'CTAO Science Portal — demo', ambient = false, type = 'website', image } = Astro.props;
// Absolute URLs for canonical/OG/RSS (head pattern from the official Astro blog
// template). `site` is a placeholder domain until the portal has a real one.
const site = Astro.site ?? new URL('https://portal.ctao.org');
const canonical = new URL(Astro.url.pathname, site);
const ogImage = new URL(image || '/brand/og-card.jpg', site);
const path = Astro.url.pathname; const path = Astro.url.pathname;
const isActive = (href) => (href === '/' ? path === '/' : path.startsWith(href)); const isActive = (href) => (href === '/' ? path === '/' : path.startsWith(href));
// Navigation = services from the Science Portal spec (REQUIREMENTS.md §2/§5). // Navigation = services from the Science Portal spec (REQUIREMENTS.md §2/§5).
@@ -21,6 +27,17 @@ const links = [
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title> <title>{title}</title>
<meta name="description" content={description} /> <meta name="description" content={description} />
<link rel="canonical" href={canonical} />
{/* Social cards (Open Graph + Twitter) — article cover when present, brand card otherwise */}
<meta property="og:site_name" content="CTAO Science Portal" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:type" content={type} />
<meta property="og:url" content={canonical} />
<meta property="og:image" content={ogImage} />
<meta name="twitter:card" content="summary_large_image" />
<link rel="alternate" type="application/rss+xml" title="CTAO Science Portal — News" href={new URL('/rss.xml', site)} />
<link rel="sitemap" href="/sitemap.xml" />
<link rel="icon" href="/brand/CTAO_Logo_positive.svg" type="image/svg+xml" /> <link rel="icon" href="/brand/CTAO_Logo_positive.svg" type="image/svg+xml" />
{/* Brand typography (BRAND D.3): Inter (body) + Space Grotesk (headlines), {/* Brand typography (BRAND D.3): Inter (body) + Space Grotesk (headlines),
self-hosted latin woff2 (variable) — @font-face lives in global.css. */} self-hosted latin woff2 (variable) — @font-face lives in global.css. */}
+1 -1
View File
@@ -38,7 +38,7 @@ const services = [
<script is:inline src="/search-client.js"></script> <script is:inline src="/search-client.js"></script>
<script is:inline> <script is:inline>
newsSearch({ input: 'home-q', list: 'home-suggest', status: 'home-suggest-status', limit: 6, unit: 'suggestion(s)' }); newsSearch({ input: 'home-q', list: 'home-suggest', status: 'home-suggest-status', limit: 6, unit: 'suggestion' });
</script> </script>
{/* Surface rhythm (DESIGN.md): services on Moon Gray, editorial news on white */} {/* Surface rhythm (DESIGN.md): services on Moon Gray, editorial news on white */}
+76
View File
@@ -0,0 +1,76 @@
---
import { getCollection } from 'astro:content';
import Base from '../../layouts/Base.astro';
// Paginated archive via Astro's built-in paginate(): the [...page] rest route
// makes page 1 the bare /news URL, then /news/2 … /news/N.
export async function getStaticPaths({ paginate }) {
const posts = (await getCollection('news', ({ data }) => !data.draft)).sort(
(a, b) => b.data.date.valueOf() - a.data.date.valueOf(),
);
return paginate(posts, { pageSize: 24 });
}
const { page } = Astro.props;
const first = page.currentPage === 1;
const fmt = (d) => new Intl.DateTimeFormat('en-GB', { dateStyle: 'long' }).format(d);
const iso = (d) => d.toISOString().slice(0, 10);
// Windowed page list — 1 … n-1 n n+1 … last (0 marks an ellipsis)
const nums = [];
for (let n = 1; n <= page.lastPage; n++) {
if (n === 1 || n === page.lastPage || Math.abs(n - page.currentPage) <= 1) nums.push(n);
else if (nums.at(-1) !== 0) nums.push(0);
}
const hrefFor = (n) => (n === 1 ? '/news' : `/news/${n}`);
---
<Base
title={first ? 'CTAO Science Portal — News' : `CTAO Science Portal — News, page ${page.currentPage}`}
description={first ? 'News and announcements of the CTAO' : `News and announcements of the CTAO — page ${page.currentPage} of ${page.lastPage}`}
>
<section class="hero-band">
<div class="band-bg" aria-hidden="true"></div>
<div class="container hero">
<div class="eyebrow">Cherenkov Telescope Array Observatory</div>
<h1>News &amp; Announcements</h1>
<p>Exploring the Universe at the Highest Energies</p>
</div>
</section>
<section class="container page">
<h2 class="sr-only">{first ? 'All news' : `All news — page ${page.currentPage}`}</h2>
<div class="grid">
{page.data.map((post, i) => (
<article class={first && i === 0 ? 'card card--featured' : 'card'}>
{post.data.cover && (
<img class="cover" src={encodeURI(post.data.cover)} alt="" loading={first && i === 0 ? 'eager' : 'lazy'} />
)}
<div class="body">
<span class="cat">{post.data.category}</span>
<h3><a href={`/news/${post.id}`}>{post.data.title}</a></h3>
<p class="desc">{post.data.description}</p>
<div class="meta">{post.data.author} · <time datetime={iso(post.data.date)}>{fmt(post.data.date)}</time></div>
</div>
</article>
))}
</div>
{page.lastPage > 1 && (
<nav class="pagination" aria-label="News pages">
{page.url.prev
? <a class="page-link page-step" href={page.url.prev} rel="prev">← Newer</a>
: <span class="page-link page-step" aria-hidden="true">← Newer</span>}
<ol>
{nums.map((n) => (
<li>
{n === 0
? <span class="page-gap" aria-hidden="true">…</span>
: <a class="page-link" href={hrefFor(n)} aria-current={n === page.currentPage ? 'page' : undefined}><span class="sr-only">Page </span>{n}</a>}
</li>
))}
</ol>
{page.url.next
? <a class="page-link page-step" href={page.url.next} rel="next">Older →</a>
: <span class="page-link page-step" aria-hidden="true">Older →</span>}
</nav>
)}
</section>
</Base>
+52 -1
View File
@@ -12,7 +12,7 @@ const { Content, headings } = await render(post);
const toc = headings.filter((h) => h.depth === 2 || h.depth === 3); const toc = headings.filter((h) => h.depth === 2 || h.depth === 3);
const fmt = (d) => new Intl.DateTimeFormat('en-GB', { dateStyle: 'long' }).format(d); const fmt = (d) => new Intl.DateTimeFormat('en-GB', { dateStyle: 'long' }).format(d);
--- ---
<Base title={`${post.data.title} — CTAO`} description={post.data.description}> <Base title={`${post.data.title} — CTAO`} description={post.data.description} type="article" image={post.data.cover && encodeURI(post.data.cover)}>
{/* TOC pattern (MDN/Stripe/NN-g): sticky right rail ≥1200px, collapsed <details> {/* TOC pattern (MDN/Stripe/NN-g): sticky right rail ≥1200px, collapsed <details>
under the title below that. CSS shows exactly one of the two (DESIGN.md). */} under the title below that. CSS shows exactly one of the two (DESIGN.md). */}
<div class="container article-layout"> <div class="container article-layout">
@@ -47,3 +47,54 @@ const fmt = (d) => new Intl.DateTimeFormat('en-GB', { dateStyle: 'long' }).forma
)} )}
</div> </div>
</Base> </Base>
<script>
// TOC scrollspy — minimal vanilla take on Starlight's starlight-toc.ts:
// an IntersectionObserver band near the viewport top; whichever prose block
// enters it is mapped back to its governing h2/h3, whose TOC link(s) get
// aria-current="true" (set in BOTH renderings — inline <details> and rail).
// Progressive enhancement: without JS the TOC is plain working anchors.
const links = [...document.querySelectorAll('.toc a[href^="#"]')];
if (links.length) {
let current = [];
const setCurrent = (id) => {
const next = id ? links.filter((a) => decodeURIComponent(a.hash.slice(1)) === id) : [];
if (next[0] === current[0]) return;
for (const a of current) a.removeAttribute('aria-current');
for (const a of next) a.setAttribute('aria-current', 'true');
current = next;
};
const blocks = [...document.querySelectorAll('.prose > *')];
// Governing heading = the block itself or the nearest h2/h3[id] above it
const headingFor = (el) => {
for (let i = blocks.indexOf(el); i >= 0; i--) {
if (blocks[i].matches('h2[id], h3[id]')) return blocks[i].id;
}
return null; // intro before the first heading — nothing highlighted
};
const onIntersect = (entries) => {
for (const e of entries) {
if (e.isIntersecting) { setCurrent(headingFor(e.target)); break; }
}
};
let observer;
const observe = () => {
observer?.disconnect();
// Narrow band below the sticky header (64px) — Starlight's rootMargin trick
const top = 76, band = 64;
observer = new IntersectionObserver(onIntersect, {
rootMargin: `-${top}px 0px ${top + band - document.documentElement.clientHeight}px`,
});
blocks.forEach((b) => observer.observe(b));
};
observe();
let timer;
addEventListener('resize', () => { clearTimeout(timer); timer = setTimeout(observe, 200); });
// Short final section: at page bottom, the last heading wins
addEventListener('scroll', () => {
if (innerHeight + scrollY >= document.documentElement.scrollHeight - 4) {
setCurrent(headingFor(blocks.at(-1)));
}
}, { passive: true });
}
</script>
-39
View File
@@ -1,39 +0,0 @@
---
import { getCollection } from 'astro:content';
import Base from '../../layouts/Base.astro';
const posts = (await getCollection('news', ({ data }) => !data.draft)).sort(
(a, b) => b.data.date.valueOf() - a.data.date.valueOf(),
);
const fmt = (d) => new Intl.DateTimeFormat('en-GB', { dateStyle: 'long' }).format(d);
const iso = (d) => d.toISOString().slice(0, 10);
---
<Base title="CTAO Science Portal — News" description="News and announcements of the CTAO">
<section class="hero-band">
<div class="band-bg" aria-hidden="true"></div>
<div class="container hero">
<div class="eyebrow">Cherenkov Telescope Array Observatory</div>
<h1>News &amp; Announcements</h1>
<p>Exploring the Universe at the Highest Energies</p>
</div>
</section>
<section class="container page">
<h2 class="sr-only">All news</h2>
<div class="grid">
{posts.map((post, i) => (
<article class={i === 0 ? 'card card--featured' : 'card'}>
{post.data.cover && (
<img class="cover" src={encodeURI(post.data.cover)} alt="" loading={i === 0 ? 'eager' : 'lazy'} />
)}
<div class="body">
<span class="cat">{post.data.category}</span>
<h3><a href={`/news/${post.id}`}>{post.data.title}</a></h3>
<p class="desc">{post.data.description}</p>
<div class="meta">{post.data.author} · <time datetime={iso(post.data.date)}>{fmt(post.data.date)}</time></div>
</div>
</article>
))}
</div>
</section>
</Base>
+43
View File
@@ -0,0 +1,43 @@
// RSS 2.0 feed — hand-rolled static endpoint like search.json.js (no @astrojs/rss
// dependency; the spec is 20 lines of XML). Newest 30 items, absolute URLs from
// the configured `site`.
import { getCollection } from 'astro:content';
const esc = (s = '') =>
s.replace(/[<>&'"]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '"': '&quot;' })[c]);
export async function GET(context) {
const site = context.site ?? new URL('https://portal.ctao.org');
const posts = (await getCollection('news', ({ data }) => !data.draft))
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
.slice(0, 30);
const items = posts
.map((p) => {
const url = new URL(`/news/${p.id}`, site).href;
return ` <item>
<title>${esc(p.data.title)}</title>
<link>${url}</link>
<guid isPermaLink="true">${url}</guid>
<pubDate>${p.data.date.toUTCString()}</pubDate>
<description>${esc(p.data.description)}</description>
<category>${esc(p.data.category)}</category>
</item>`;
})
.join('\n');
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>CTAO Science Portal — News</title>
<link>${new URL('/news', site).href}</link>
<description>News and announcements of the Cherenkov Telescope Array Observatory</description>
<language>en</language>
<lastBuildDate>${(posts[0]?.data.date ?? new Date()).toUTCString()}</lastBuildDate>
<atom:link href="${new URL('/rss.xml', site).href}" rel="self" type="application/rss+xml"/>
${items}
</channel>
</rss>
`;
return new Response(xml, { headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' } });
}
+28
View File
@@ -0,0 +1,28 @@
// Sitemap — hand-rolled static endpoint (no @astrojs/sitemap dependency; our URL
// set is fully known at build time). /admin is intentionally excluded.
import { getCollection } from 'astro:content';
const PAGE_SIZE = 24; // keep in sync with src/pages/news/[...page].astro
export async function GET(context) {
const site = context.site ?? new URL('https://portal.ctao.org');
const news = (await getCollection('news', ({ data }) => !data.draft))
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
const pages = await getCollection('pages');
const urls = [
'/', '/news', '/proposals', '/dashboard', '/support', '/search', '/login',
...Array.from({ length: Math.ceil(news.length / PAGE_SIZE) - 1 }, (_, i) => `/news/${i + 2}`),
...pages.map((p) => `/pages/${p.id}`),
].map((path) => ` <url><loc>${new URL(path, site).href}</loc></url>`);
const articles = news.map(
(p) => ` <url><loc>${new URL(`/news/${p.id}`, site).href}</loc><lastmod>${p.data.date.toISOString().slice(0, 10)}</lastmod></url>`,
);
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${[...urls, ...articles].join('\n')}
</urlset>
`;
return new Response(xml, { headers: { 'Content-Type': 'application/xml; charset=utf-8' } });
}
+28 -1
View File
@@ -236,7 +236,7 @@ img { max-width: 100%; height: auto; display: block; }
} }
.suggest:empty { display: none; } .suggest:empty { display: none; }
.suggest li { display: flex; align-items: center; gap: var(--space-s); padding: 0 12px; border-radius: calc(var(--radius) - 8px); } .suggest li { display: flex; align-items: center; gap: var(--space-s); padding: 0 12px; border-radius: calc(var(--radius) - 8px); }
.suggest li:hover { background: var(--moon); } .suggest li:hover, .suggest li:focus-within { background: var(--moon); }
.suggest a { .suggest a {
flex: 1; display: flex; align-items: center; min-height: 44px; flex: 1; display: flex; align-items: center; min-height: 44px;
color: var(--text); font-weight: 500; font-size: 0.94rem; text-decoration: none; color: var(--text); font-weight: 500; font-size: 0.94rem; text-decoration: none;
@@ -310,6 +310,25 @@ img { max-width: 100%; height: auto; display: block; }
.card--featured .desc { -webkit-line-clamp: 3; line-clamp: 3; } .card--featured .desc { -webkit-line-clamp: 3; line-clamp: 3; }
} }
/* Pagination (news archive) — prev/next + windowed page numbers; ≥44px targets.
Pattern mined from AstroPaper/Astro blog templates, numbers added for 9 pages. */
.pagination {
display: flex; align-items: center; justify-content: center; flex-wrap: wrap;
gap: 4px var(--space-s); margin-top: var(--space-xl);
}
.pagination ol { display: flex; flex-wrap: wrap; justify-content: center; list-style: none; margin: 0; padding: 0; gap: 4px; }
.page-link {
display: inline-flex; align-items: center; justify-content: center;
min-width: 44px; min-height: 44px; padding: 0 12px;
border-radius: var(--radius-pill); text-decoration: none; font-weight: 500; color: var(--text);
}
a.page-link:hover { background: var(--moon); color: var(--indigo); }
.page-link[aria-current="page"] { background: var(--galaxy); color: #fff; }
.page-step { font-weight: 600; }
a.page-step { color: var(--link); }
span.page-step { color: var(--muted); } /* disabled end stop — non-interactive */
.page-gap { display: inline-flex; align-items: center; justify-content: center; min-width: 24px; min-height: 44px; color: var(--muted); }
/* Service tiles — card tokens, text-first */ /* Service tiles — card tokens, text-first */
.tile { padding: var(--space-m); gap: 8px; } .tile { padding: var(--space-m); gap: 8px; }
.tile h3 { margin: 0; } .tile h3 { margin: 0; }
@@ -347,6 +366,8 @@ img { max-width: 100%; height: auto; display: block; }
.toc .d3 { padding-left: var(--space-s); } .toc .d3 { padding-left: var(--space-s); }
.toc a { text-decoration: none; display: inline-flex; align-items: center; min-height: 24px; } .toc a { text-decoration: none; display: inline-flex; align-items: center; min-height: 24px; }
.toc a:hover { text-decoration: underline; } .toc a:hover { text-decoration: underline; }
/* Scrollspy state (set by the [slug].astro script) — functional, not motion */
.toc a[aria-current="true"] { color: var(--galaxy); font-weight: 600; }
.toc--inline { margin-bottom: var(--space-l); } .toc--inline { margin-bottom: var(--space-l); }
.toc--rail { display: none; } .toc--rail { display: none; }
@media (min-width: 1200px) { @media (min-width: 1200px) {
@@ -413,6 +434,9 @@ img { max-width: 100%; height: auto; display: block; }
color: var(--muted); white-space: nowrap; font-size: 0.78rem; color: var(--muted); white-space: nowrap; font-size: 0.78rem;
background: var(--moon); border-radius: var(--radius-pill); padding: 2px 10px; background: var(--moon); border-radius: var(--radius-pill); padding: 2px 10px;
} }
/* Matched-term highlight in results/suggestions (Starlight search pattern) —
brand tint only, no new hues */
mark { background: var(--tint-cherenkov); color: inherit; border-radius: calc(var(--radius) - 12px); padding: 0 2px; }
/* Mock/status badges — every mock must be labelled (REQUIREMENTS.md §5) */ /* Mock/status badges — every mock must be labelled (REQUIREMENTS.md §5) */
.badge-mock, .badge-ext { .badge-mock, .badge-ext {
@@ -513,6 +537,9 @@ img { max-width: 100%; height: auto; display: block; }
/* Hover feedback */ /* Hover feedback */
.card, .search-results li { transition: border-color 0.15s ease, box-shadow 0.2s ease, transform 0.2s ease; } .card, .search-results li { transition: border-color 0.15s ease, box-shadow 0.2s ease, transform 0.2s ease; }
.card:hover { transform: translateY(-2px); } .card:hover { transform: translateY(-2px); }
/* Editorial cover micro-zoom on card hover (AstroWind pattern; card clips it) */
.card .cover { transition: transform 0.3s ease; }
.card:hover .cover, .card:focus-within .cover { transform: scale(1.03); }
.btn, .login-mock, .navlink { transition: background-color 0.15s ease, color 0.15s ease; } .btn, .login-mock, .navlink { transition: background-color 0.15s ease, color 0.15s ease; }
} }
@keyframes nebula { /* inert unless applied inside the block above */ @keyframes nebula { /* inert unless applied inside the block above */