feat: content-UX — header search, metadata de-noise, icon/microcopy coherence, article composition

This commit is contained in:
ctao
2026-07-25 07:32:11 +02:00
parent a8833ce736
commit 419e9a2dd1
12 changed files with 361 additions and 119 deletions
+62 -19
View File
@@ -11,12 +11,15 @@ const ogImage = new URL(image || '/brand/og-card.jpg', site);
const path = Astro.url.pathname;
const isActive = (href) => (href === '/' ? path === '/' : path.startsWith(href));
// Navigation = services from the Science Portal spec (REQUIREMENTS.md §2/§5).
// External systems built by other teams are links out, clearly marked.
// Order: serial-position effect (NN/g) — News (universal entry) first, Support
// (help convention) last; Data/Proposals by task frequency; Dashboard is the
// personal area, placed late next to the Sign-in cluster. External systems
// built by other teams are links out, clearly marked.
const links = [
{ href: '/news', label: 'News' },
{ href: 'https://padc-ctao-data-explorer.obspm.fr/', label: 'Data', ext: true },
{ href: '/proposals', label: 'Proposals' },
{ href: '/dashboard', label: 'Dashboard' },
{ href: 'https://padc-ctao-data-explorer.obspm.fr/', label: 'Data', ext: true },
{ href: '/support', label: 'Support' },
];
---
@@ -45,6 +48,15 @@ const links = [
</head>
<body class={ambient ? 'has-ambient' : undefined}>
<a class="skip" href="#main">Skip to content</a>
{/* Demo disclosure — GOV.UK phase-banner pattern: one slim, honest banner
instead of implementation talk scattered through the UI. Not sticky;
it scrolls away and the header sticks below it. */}
<div class="phase-banner">
<div class="container">
<strong class="phase-tag">Demo</strong>
<span>This is a demonstration of the CTAO Science Portal — content is sample data.</span>
</div>
</div>
<header class="site-header">
<div class="container nav">
<a class="brand" href="/" aria-label="CTAO Science Portal — home">
@@ -59,17 +71,37 @@ const links = [
))}
</nav>
<span class="spacer"></span>
<a class="navlink" href="/search">Search</a>
<a class="navlink navlink--edit" href="/admin" title="Content editor (demo CMS)">
<svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><path d="M17 3l4 4L8 20l-5 1 1-5L17 3z" /></svg>
Editor
</a>
{/* Site search lives in the header (desktop: compact field; ≤1024px: the
magnifier <details> below). Plain GET form — works without JS; with JS
the shared suggest dropdown appears (search-client.js). */}
<form class="nav-search" role="search" aria-label="Site search" action="/search" method="get">
<label class="sr-only" for="hdr-q">Search news</label>
<svg class="ico search-ico" aria-hidden="true" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7" /><path d="m20 20-4.3-4.3" /></svg>
<input id="hdr-q" name="q" type="search" placeholder="Search…" autocomplete="off" />
<ul class="suggest" id="hdr-suggest"></ul>
<p class="sr-only" id="hdr-suggest-status" role="status" aria-live="polite"></p>
</form>
<a class="login-mock" href="/login">Sign in</a>
{/* ≤1024px: magnifier button reveals a full-width search row. name="header-panel"
pairs it with the menu as a native exclusive group — opening one closes
the other, no JS (exclusive-accordion behavior of <details name>). */}
<details class="search-pop" name="header-panel">
<summary aria-label="Search">
<svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7" /><path d="m20 20-4.3-4.3" /></svg>
</summary>
<form role="search" aria-label="Site search" action="/search" method="get">
<label class="sr-only" for="pop-q">Search news</label>
<input id="pop-q" name="q" type="search" placeholder="Search news…" autocomplete="off" />
<button class="btn" type="submit">Search</button>
<ul class="suggest" id="pop-suggest"></ul>
<p class="sr-only" id="pop-suggest-status" role="status" aria-live="polite"></p>
</form>
</details>
{/* Mobile disclosure menu — native <details>/<summary>, no JS; desktop hides it via CSS */}
<details class="menu">
<summary>
<details class="menu" name="header-panel">
<summary aria-label="Menu">
<svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><path class="i-menu" d="M3 6h18M3 12h18M3 18h18" /><path class="i-close" d="M5 5l14 14M19 5 5 19" /></svg>
Menu
<span class="menu-label">Menu</span>
</summary>
<nav aria-label="Main navigation">
{links.map((l) => l.ext ? (
@@ -77,12 +109,21 @@ const links = [
) : (
<a href={l.href} aria-current={isActive(l.href) ? 'page' : undefined}>{l.label}</a>
))}
<a href="/search">Search</a>
<a href="/admin">Editor</a>
</nav>
</details>
</div>
</header>
<script is:inline src="/search-client.js"></script>
<script is:inline>
// Header search suggestions (desktop field + mobile reveal); the forms are
// plain GET /search, so everything below is progressive enhancement.
newsSearch({ input: 'hdr-q', list: 'hdr-suggest', status: 'hdr-suggest-status', limit: 6, unit: 'suggestion' });
newsSearch({ input: 'pop-q', list: 'pop-suggest', status: 'pop-suggest-status', limit: 6, unit: 'suggestion' });
var pop = document.querySelector('.search-pop');
pop.addEventListener('toggle', function () {
if (pop.open) document.getElementById('pop-q').focus();
});
</script>
<main id="main">
<slot />
</main>
@@ -92,23 +133,25 @@ const links = [
<div class="container">
<div class="foot-top">
<img src="/brand/AAFF_CTAO_Logo_RGB_Monochrome-negative.svg" alt="CTAO" />
<p>Science Portal — news, observation proposals, data and user services of the
Cherenkov Telescope Array Observatory.</p>
<p>CTAO Science Portal — demo by Cyfronet (SUSS-PORT team).</p>
</div>
{/* Order: action/utility first, legal middle, meta last (GOV.UK/NN-g
footer anatomy). Spec §3.3.1 requires the five items, not an order. */}
<nav class="foot-links" aria-label="Footer">
<a href="/pages/contact">Contact</a>
<a href="/search">Search</a>
<a href="/pages/disclaimer">Disclaimer</a>
<a href="/pages/privacy">Privacy</a>
<a href="/pages/contact">Contact</a>
<a>Site settings</a>
<a href="/search">Search</a>
{/* Internal tooling stays out of the main nav (NN/g: navigation reflects
user tasks); the editor is a discreet utility link for the demo. */}
<a href="/admin">Content editor</a>
</nav>
<div class="foot-note">
{/* The real brand flash — path taken from the official logo SVG (BRAND D.4).
Sole decorative accent in this view; static by design. */}
<svg class="flash" viewBox="85.3 15 15 15" aria-hidden="true"><path fill="currentColor" d="M94.7,23.8c1-1.4,3.3-2.1,5.2-2.7c-2,0.1-4.4,0.3-5.8-0.6c-1.4-1-2.1-3.3-2.7-5.2c0.1,2,0.3,4.4-0.6,5.8c-1,1.4-3.3,2.1-5.2,2.7c2-0.1,4.4-0.3,5.8,0.6c1.4,1,2.1,3.3,2.7,5.2C93.9,27.6,93.7,25.2,94.7,23.8z"/></svg>
<span>Science Portal Core demo (Cyfronet / SUSS-PORT team). Sample content
imported from ctao.org; the Site settings link is a placeholder. Statically
generated from Markdown (git-based CMS).</span>
<span>© 2026 CTAO — demonstration, not an official service.</span>
</div>
</div>
</footer>
+1 -1
View File
@@ -58,7 +58,7 @@ const tickets = [
<p class="notice">Search and download in the <a href="https://padc-ctao-data-explorer.obspm.fr/" target="_blank" rel="noopener" class="external">Data Explorer</a> (external, LUX team).</p>
</div>
<div class="panel">
<h2 class="panel-title">Help-desk tickets</h2>
<h2 class="panel-title">Support tickets</h2>
<ul class="rows">
{tickets.map((t) => (
<li>
+14 -17
View File
@@ -7,6 +7,9 @@ const posts = (await getCollection('news', ({ data }) => !data.draft))
.slice(0, 3);
const fmt = (d) => new Intl.DateTimeFormat('en-GB', { dateStyle: 'long' }).format(d);
const iso = (d) => d.toISOString().slice(0, 10);
// Reading time from the Markdown body (~220 wpm) — differentiating card metadata;
// category/author are identical across all articles, so cards omit them (NN/g).
const readMin = (p) => Math.max(1, Math.round((p.body ?? '').split(/\s+/).length / 220));
// Services per the Science Portal spec; unbuilt ones are labelled, never faked.
const services = [
{ title: 'Proposals', href: '/proposals', desc: 'Submit observation proposals, including ToO / MWL requests.', badge: 'mock' },
@@ -24,23 +27,18 @@ const services = [
<div class="eyebrow">Cherenkov Telescope Array Observatory</div>
<h1>CTAO Science Portal</h1>
<p>Exploring the Universe at the Highest Energies</p>
<form class="hero-search" role="search" action="/search" method="get">
<label class="sr-only" for="home-q">Search news</label>
<svg class="ico search-ico" aria-hidden="true" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7" /><path d="m20 20-4.3-4.3" /></svg>
<input id="home-q" name="q" type="search" placeholder="Search news and announcements…" autocomplete="off" />
<button class="btn" type="submit">Search</button>
{/* Live suggestions (plain links, Tab-accessible); Enter still submits to /search */}
<ul class="suggest" id="home-suggest"></ul>
<p class="sr-only" id="home-suggest-status" role="status" aria-live="polite"></p>
</form>
{/* Search lives in the header (one search location); the hero carries the
two core scientist tasks per SPEC §3.1 instead — classic hero-CTA pair. */}
<div class="hero-cta">
<a class="btn external" href="https://padc-ctao-data-explorer.obspm.fr/" target="_blank" rel="noopener">Explore the data</a>
<a class="btn btn--ghost" href="/proposals">Submit a proposal</a>
</div>
{posts[0] && (
<p class="hero-teaser">Latest: <a href={`/news/${posts[0].id}`}>{posts[0].data.title}</a></p>
)}
</div>
</section>
<script is:inline src="/search-client.js"></script>
<script is:inline>
newsSearch({ input: 'home-q', list: 'home-suggest', status: 'home-suggest-status', limit: 6, unit: 'suggestion' });
</script>
{/* Surface rhythm (DESIGN.md): services on Moon Gray, editorial news on white */}
<section class="band--moon">
<div class="container page">
@@ -56,7 +54,7 @@ const services = [
<p class="desc">{s.desc}</p>
{s.badge === 'mock' && <span class="badge-mock">Mock</span>}
{s.badge === 'ext' && <span class="badge-ext">External — LUX team</span>}
{s.badge === 'tbd' && <span class="badge-ext">Planned (TBD)</span>}
{s.badge === 'tbd' && <span class="badge-ext">Planned — not yet available</span>}
</article>
))}
</div>
@@ -73,10 +71,9 @@ const services = [
<article class="card">
{post.data.cover && <img class="cover" src={encodeURI(post.data.cover)} alt="" loading="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 class="meta"><time datetime={iso(post.data.date)}>{fmt(post.data.date)}</time> · {readMin(post)} min read</div>
</div>
</article>
))}
+3 -4
View File
@@ -8,11 +8,10 @@ import Base from '../layouts/Base.astro';
<img class="auth-logo" src="/brand/CTAO_Logo_positive.svg" alt="CTAO" />
<span class="badge-mock">Mock — AAI integration pending</span>
<h1>Sign in</h1>
<p class="muted">One account for all CTAO services. Authentication is handled by the
central <strong>CTAO AAI</strong> (single sign-on) — the portal never sees your password.</p>
{/* Auth pages are scanned, not read — one line, no reassurance prose */}
<p class="muted">Use your CTAO account — single sign-on via CTAO AAI.</p>
<button class="btn btn-wide" type="button">Continue with your CTAO account</button>
<p class="notice">No account yet? <a>Register via CTAO AAI</a> · Access level follows
your CTAO role and permissions.</p>
<p class="notice">No account? <a>Register via CTAO AAI</a></p>
</div>
</section>
</Base>
+5 -3
View File
@@ -15,6 +15,9 @@ 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);
// Reading time from the Markdown body (~220 wpm) — differentiating card metadata;
// category/author are identical across all articles, so cards omit them (NN/g).
const readMin = (p) => Math.max(1, Math.round((p.body ?? '').split(/\s+/).length / 220));
// Windowed page list — 1 … n-1 n n+1 … last (0 marks an ellipsis)
const nums = [];
for (let n = 1; n <= page.lastPage; n++) {
@@ -32,7 +35,7 @@ const hrefFor = (n) => (n === 1 ? '/news' : `/news/${n}`);
<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>
<p>Science highlights and updates from the observatory and its partners</p>
</div>
</section>
@@ -45,10 +48,9 @@ const hrefFor = (n) => (n === 1 ? '/news' : `/news/${n}`);
<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 class="meta"><time datetime={iso(post.data.date)}>{fmt(post.data.date)}</time> · {readMin(post)} min read</div>
</div>
</article>
))}
+29 -4
View File
@@ -3,14 +3,22 @@ import { getCollection, render } from 'astro:content';
import Base from '../../layouts/Base.astro';
export async function getStaticPaths() {
const posts = await getCollection('news', ({ data }) => !data.draft);
return posts.map((post) => ({ params: { slug: post.id }, props: { post } }));
// Date-sorted so each article knows its sequential neighbours (prev/next
// pattern of every editorial site — keeps readers in the content flow).
const posts = (await getCollection('news', ({ data }) => !data.draft)).sort(
(a, b) => b.data.date.valueOf() - a.data.date.valueOf(),
);
return posts.map((post, i) => ({
params: { slug: post.id },
props: { post, newer: posts[i - 1] ?? null, older: posts[i + 1] ?? null },
}));
}
const { post } = Astro.props;
const { post, newer, older } = Astro.props;
const { Content, headings } = await render(post);
const toc = headings.filter((h) => h.depth === 2 || h.depth === 3);
const fmt = (d) => new Intl.DateTimeFormat('en-GB', { dateStyle: 'long' }).format(d);
const readMin = Math.max(1, Math.round((post.body ?? '').split(/\s+/).length / 220));
---
<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>
@@ -19,7 +27,8 @@ const fmt = (d) => new Intl.DateTimeFormat('en-GB', { dateStyle: 'long' }).forma
<article class="article">
<a class="back" href="/news">← All news</a>
<h1>{post.data.title}</h1>
<div class="meta"><span class="cat">{post.data.category}</span><span>{post.data.author}</span>·<time datetime={post.data.date.toISOString().slice(0, 10)}>{fmt(post.data.date)}</time></div>
{/* Meta order: category · author · date · reading time */}
<div class="meta"><span class="cat">{post.data.category}</span><span>{post.data.author}</span>·<time datetime={post.data.date.toISOString().slice(0, 10)}>{fmt(post.data.date)}</time>·<span>{readMin} min read</span></div>
{toc.length >= 3 && (
<details class="toc toc--inline">
<summary>On this page</summary>
@@ -34,6 +43,22 @@ const fmt = (d) => new Intl.DateTimeFormat('en-GB', { dateStyle: 'long' }).forma
<div class="prose">
<Content />
</div>
{(newer || older) && (
<nav class="post-nav" aria-label="More news">
{newer && (
<a class="post-nav-prev" rel="prev" href={`/news/${newer.id}`}>
<small>← Newer</small>
<span class="post-nav-title">{newer.data.title}</span>
</a>
)}
{older && (
<a class="post-nav-next" rel="next" href={`/news/${older.id}`}>
<small>Older →</small>
<span class="post-nav-title">{older.data.title}</span>
</a>
)}
</nav>
)}
</article>
{toc.length >= 3 && (
<nav class="toc toc--rail" aria-label="On this page">
+3 -3
View File
@@ -7,14 +7,14 @@ import Base from '../layouts/Base.astro';
<div class="container hero">
<div class="eyebrow">Proposal Submission</div>
<h1>Observation proposals</h1>
<p>Submitting observation proposals, including ToO / MWL requests</p>
<p>Submit observation proposals, including ToO and MWL requests</p>
</div>
</section>
<section class="band--moon">
<div class="container page">
<span class="badge-mock">Mock — no submission logic</span>
<span class="badge-ext">Target: Proposal Handling System (APC team) integrated into the portal</span>
<span class="badge-ext">Planned integration: Proposal Handling System (APC team)</span>
<div class="panel panel--narrow">
<div class="form-row">
<label for="f-title">Proposal title</label>
@@ -42,7 +42,7 @@ import Base from '../layouts/Base.astro';
</div>
<div class="form-row">
<label for="f-just">Scientific justification</label>
<textarea id="f-just" rows="4" placeholder="Brief description of the observation goal..."></textarea>
<textarea id="f-just" rows="4" placeholder="Brief description of the observation goal…"></textarea>
</div>
<button class="btn" type="button">Submit proposal</button>
<p class="notice">Demonstration mock — the form does not store any data.</p>
+1 -1
View File
@@ -26,7 +26,7 @@ import Base from '../layouts/Base.astro';
</section>
</Base>
<script is:inline src="/search-client.js"></script>
{/* search-client.js is loaded once by the Base layout (header search) */}
<script is:inline>
const run = newsSearch({ input: 'q', list: 'search-results', status: 'search-status', limit: 20, hint: 'Type at least 2 characters.' });
// Support /search?q=… deep links (hero search form, footer, bookmarks)
+1 -1
View File
@@ -14,7 +14,7 @@ const channels = [
<div class="container hero">
<div class="eyebrow">User services</div>
<h1>User Support</h1>
<p>Help desk, FAQ / troubleshooting guide, user forum and mailing lists</p>
<p>Help desk, FAQ, user forum and mailing lists</p>
</div>
</section>
+159 -56
View File
@@ -74,6 +74,11 @@
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; scroll-padding-top: 84px; /* sticky header + anchor jumps */ }
/* Guard, not a solution (DESIGN.md): every wide element is contained at its own
level (tables scroll in their box, long words wrap); clip only catches
regressions so one offender can never remove the page gutters. `clip`, unlike
`hidden`, creates no scroll container — sticky header keeps working. */
html, body { overflow-x: clip; }
body {
margin: 0;
font-family: var(--font-body);
@@ -85,6 +90,8 @@ body {
h1, h2 { font-family: var(--font-display); letter-spacing: -0.01em; }
h1, h2, h3, .hero p { text-wrap: balance; }
/* Overlong unbroken words (slugs, codes) may break rather than overflow the page */
h1, h2, h3 { overflow-wrap: break-word; }
.prose p, .desc { text-wrap: pretty; }
/* Inline SVG icons — stroke inherits currentColor */
@@ -120,24 +127,72 @@ img { max-width: 100%; height: auto; display: block; }
official logo, never a CSS approximation. Budget: one accent per view; static. */
.flash { width: 12px; height: 12px; color: var(--cherenkov); flex: none; }
/* Demo disclosure — GOV.UK phase-banner pattern: slim Moon bar above the
header (scrolls away; the header sticks below it). Galaxy-on-tint pairs
computed ≥16:1 (same pairing as .cat chips). */
.phase-banner { background: var(--moon); border-bottom: 1px solid var(--border); font-size: 0.8rem; }
.phase-banner .container { display: flex; align-items: center; gap: 10px; padding-top: 6px; padding-bottom: 6px; }
.phase-tag {
flex: none; font-size: 0.68rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em;
color: var(--galaxy); background: var(--tint-cherenkov); border-radius: var(--radius-pill); padding: 2px 10px;
}
/* Header — Galaxy Blue band, reverse (white) logo per BRAND B.1.2 */
.site-header { background: var(--galaxy); position: sticky; top: 0; z-index: 10; }
.nav { display: flex; align-items: center; gap: 4px; min-height: 64px; flex-wrap: nowrap; }
.nav > nav { display: flex; align-items: center; gap: 4px; }
/* Header tiers (one row, always): full nav >1024px; sub-brand only >1100px;
disclosure menu (native <details>/<summary>, CSS-only) at ≤1024px */
@media (max-width: 1100px) { .brand .brand-sub { display: none; } }
@media (max-width: 360px) {
.nav { padding: 0 14px; }
.brand img { height: 20px; }
.nav .login-mock { padding: 8px 14px; }
}
/* One-row guarantee: only the search field and the spacer flex — everything
else keeps its natural size (links are single words, they can never wrap) */
.nav > * { flex: none; }
/* Header tiers (one row, always — width math in DESIGN.md):
≥1100px full nav + inline search + sub-brand · 1025–1099px sub-brand hidden ·
≤1024px disclosure menu + magnifier reveal (both native <details>, CSS-only) ·
≤480px icon-only menu button, smaller logo · ≤360px compact paddings */
@media (max-width: 1099px) { .brand .brand-sub { display: none; } }
.menu { display: none; }
.menu .i-close { display: none; }
.menu[open] .i-menu { display: none; }
.menu[open] .i-close { display: block; }
.brand { display: flex; align-items: center; gap: 10px; margin-right: 10px; }
.brand img { height: 26px; width: auto; }
.brand .brand-sub {
color: var(--cherenkov); font-size: 0.78rem; font-weight: 500;
border-left: 1px solid rgba(255,255,255,0.25); padding-left: 10px;
}
.nav .spacer { flex: 1; }
.nav a.navlink {
color: var(--on-galaxy-muted); font-weight: 500; font-size: 0.95rem;
padding: 10px 10px; text-decoration: none; border-bottom: 2px solid transparent;
}
.nav a.navlink:hover { color: #fff; }
.nav a.navlink[aria-current="page"] { color: #fff; border-bottom-color: var(--cherenkov); }
/* AAI sign-in entry — leads to the /login mock page */
.nav .login-mock {
color: var(--galaxy); background: var(--cherenkov); font: inherit; font-weight: 600;
border: 0; border-radius: var(--radius-pill); padding: 8px 18px; margin-left: 6px;
text-decoration: none; white-space: nowrap;
}
.nav .login-mock:hover { background: var(--cherenkov-hover); color: var(--galaxy); }
/* Tap targets ≥44px (WCAG 2.5.5); inline-flex keeps the active bottom border */
.nav a.navlink, .nav .login-mock {
display: inline-flex; align-items: center; gap: 6px; min-height: 44px;
}
/* Header search (desktop) — compact pill in the utility cluster; a plain GET
form to /search (works without JS), suggestions reuse the shared .suggest
dropdown. Flexes 130–180px so the row can never overflow (width math: DESIGN.md). */
.nav-search { position: relative; display: flex; align-items: center; flex: 0 1 180px; min-width: 130px; margin-left: 6px; }
.nav-search .search-ico { left: 12px; width: 15px; height: 15px; }
.nav-search input {
width: 100%; min-width: 0; font: inherit; font-size: 0.9rem; color: var(--text);
background: #fff; border: 1px solid transparent; border-radius: var(--radius-pill);
padding: 8px 14px 8px 34px;
}
.nav-search input:focus { border-color: var(--azure); }
.nav-search .suggest { left: auto; right: 0; width: min(380px, 92vw); top: calc(100% + 10px); }
/* ≤1024px replacement: magnifier <details> reveal — see the menu media block */
.search-pop { display: none; }
@media (max-width: 1024px) {
.nav > nav, .nav > a.navlink { display: none; }
.nav > nav { display: none; }
.menu { display: block; }
.menu summary {
display: inline-flex; align-items: center; gap: 8px; min-height: 44px;
@@ -158,37 +213,42 @@ img { max-width: 100%; height: auto; display: block; }
}
.menu nav a:last-child { border-bottom: 0; }
.menu nav a[aria-current="page"] { color: var(--cherenkov); }
/* Magnifier icon-button reveals a full-width search row under the header
(visible search beats search buried in the menu — NN/g mobile-search).
Same native-<details> panel pattern as the menu. */
.nav-search { display: none; }
.search-pop { display: block; }
.search-pop summary {
display: inline-flex; align-items: center; justify-content: center;
min-width: 44px; min-height: 44px; color: #fff; cursor: pointer; list-style: none;
}
.search-pop summary::-webkit-details-marker { display: none; }
.search-pop form {
position: absolute; left: 0; right: 0; top: 100%;
display: flex; gap: 8px; padding: 10px var(--gutter) 14px;
background: var(--galaxy); border-top: 1px solid rgba(255, 255, 255, 0.15);
box-shadow: var(--shadow-lifted);
}
.search-pop input {
flex: 1; min-width: 0; font: inherit; color: var(--text);
background: #fff; border: 1px solid var(--border); border-radius: var(--radius-pill);
padding: 10px 16px;
}
.search-pop input:focus { border-color: var(--azure); }
.search-pop .btn { padding: 10px 18px; }
.search-pop .suggest { left: 10px; right: 10px; top: calc(100% + 4px); }
}
.brand { display: flex; align-items: center; gap: 10px; margin-right: 10px; }
.brand img { height: 26px; width: auto; }
.brand .brand-sub {
color: var(--cherenkov); font-size: 0.78rem; font-weight: 500;
border-left: 1px solid rgba(255,255,255,0.25); padding-left: 10px;
@media (max-width: 480px) {
.brand img { height: 22px; }
.menu summary .menu-label { display: none; }
.menu summary { min-width: 44px; justify-content: center; padding: 0; margin-left: 2px; }
}
.nav .spacer { flex: 1; }
.nav a.navlink {
color: var(--on-galaxy-muted); font-weight: 500; font-size: 0.95rem;
padding: 10px 10px; text-decoration: none; border-bottom: 2px solid transparent;
@media (max-width: 360px) {
.nav { padding: 0 14px; gap: 2px; }
.brand { margin-right: 4px; }
.brand img { height: 20px; }
.nav .login-mock { padding: 8px 12px; }
}
.nav a.navlink:hover { color: #fff; }
.nav a.navlink[aria-current="page"] { color: #fff; border-bottom-color: var(--cherenkov); }
.nav a.navlink--edit {
color: #fff; border: 1px solid rgba(255,255,255,0.35); border-radius: var(--radius-pill);
padding: 7px 16px; margin-left: 6px;
}
.nav a.navlink--edit:hover { border-color: var(--cherenkov); color: var(--cherenkov); }
/* AAI sign-in entry — leads to the /login mock page */
.nav .login-mock {
color: var(--galaxy); background: var(--cherenkov); font: inherit; font-weight: 600;
border: 0; border-radius: var(--radius-pill); padding: 8px 18px; margin-left: 6px;
text-decoration: none; white-space: nowrap;
}
.nav .login-mock:hover { background: var(--cherenkov-hover); color: var(--galaxy); }
/* Tap targets ≥44px (WCAG 2.5.5); inline-flex keeps the active bottom border */
.nav a.navlink, .nav .login-mock, .nav a.navlink--edit {
display: inline-flex; align-items: center; gap: 6px; min-height: 44px;
}
.navlink--edit .ico { width: 15px; height: 15px; }
/* External-link marker — SVG arrow via mask, inherits currentColor */
.external::after {
content: ""; display: inline-block; width: 0.72em; height: 0.72em; margin-left: 3px;
@@ -215,16 +275,26 @@ img { max-width: 100%; height: auto; display: block; }
letter-spacing: 0.14em; font-size: 0.72rem; margin-bottom: 8px;
}
/* Search fields — large pill input with inline magnifier; real GET form to /search */
.hero-search, .search-form { position: relative; display: flex; gap: 8px; max-width: 560px; }
.hero-search { margin-top: var(--space-m); }
/* Hero CTAs — the two core scientist tasks (SPEC §3.1): one Cherenkov primary,
one quiet ghost. Links styled as buttons because they navigate. */
.hero-cta { display: flex; flex-wrap: wrap; gap: var(--space-s); margin-top: var(--space-m); }
a.btn { display: inline-flex; align-items: center; gap: 6px; text-decoration: none; }
.btn--ghost { background: transparent; color: #fff; border: 1px solid rgba(255, 255, 255, 0.55); }
.btn--ghost:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
/* One-line latest-news teaser under the CTAs (editorial touch, no widgets) */
.hero .hero-teaser { margin-top: var(--space-m); font-size: 0.9rem; color: var(--on-galaxy-muted); max-width: none; }
.hero .hero-teaser a { color: #fff; text-underline-offset: 3px; }
.hero .hero-teaser a:hover { color: var(--cherenkov); }
/* Search field (/search page) — large pill input with inline magnifier; real GET form */
.search-form { position: relative; display: flex; gap: 8px; max-width: 560px; }
.search-ico { position: absolute; left: 16px; top: 50%; translate: 0 -50%; color: var(--muted); pointer-events: none; }
.hero-search input, .search-form input {
.search-form input {
flex: 1; min-width: 0; font: inherit; color: var(--text);
background: #fff; border: 1px solid var(--border);
border-radius: var(--radius-pill); padding: 13px 18px 13px 44px;
}
.hero-search input:focus, .search-form input:focus { border-color: var(--azure); }
.search-form input:focus { border-color: var(--azure); }
/* Live suggestions under the hero search — plain links, Tab-accessible */
.suggest {
position: absolute; top: calc(100% + 8px); left: 0; right: 0;
@@ -235,14 +305,19 @@ img { max-width: 100%; height: auto; display: block; }
max-height: min(60vh, 480px); overflow: auto; /* long lists scroll on mobile */
}
.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: flex-start; gap: var(--space-s); padding: 0 12px; border-radius: calc(var(--radius) - 8px); }
.suggest li:hover, .suggest li:focus-within { background: var(--moon); }
/* The link holds <mark>-split text nodes — it must lay out as flowing text,
never as a flex row (flex would turn every fragment into its own item and
stop titles from wrapping). ≥44px target comes from the block padding. */
.suggest a {
flex: 1; display: flex; align-items: center; min-height: 44px;
flex: 1; min-width: 0; display: block; padding-block: 10px;
color: var(--text); font-weight: 500; font-size: 0.94rem; text-decoration: none;
overflow-wrap: break-word;
}
.suggest small { margin-top: 12px; } /* date chip tracks the first title line */
.suggest::after {
content: "↵ Enter — all results"; display: block; margin-top: 4px;
content: "Press Enter for all results"; display: block; margin-top: 4px;
padding: 8px 12px 2px; border-top: 1px solid var(--border);
color: var(--muted); font-size: 0.78rem;
}
@@ -261,10 +336,10 @@ img { max-width: 100%; height: auto; display: block; }
.grid { display: grid; gap: var(--space-m); grid-template-columns: 1fr; container-type: inline-size; }
@media (min-width: 640px) { .grid { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 940px) { .grid { grid-template-columns: repeat(3, 1fr); } }
/* Subgrid: cover/cat/title/desc/meta rows align across each card row */
/* Subgrid: cover/title/desc/meta rows align across each card row */
@supports (grid-template-rows: subgrid) {
.grid > .card:not(.card--featured, .tile) { display: grid; grid-template-rows: subgrid; grid-row: span 5; gap: 0; }
.grid > .card:not(.card--featured, .tile) .body { display: grid; grid-template-rows: subgrid; grid-row: span 4; gap: 10px; }
.grid > .card:not(.card--featured, .tile) { display: grid; grid-template-rows: subgrid; grid-row: span 4; gap: 0; }
.grid > .card:not(.card--featured, .tile) .body { display: grid; grid-template-rows: subgrid; grid-row: span 3; gap: 10px; }
}
/* News cards — image + category + clamped title/description + <time> meta;
@@ -380,6 +455,9 @@ span.page-step { color: var(--muted); } /* disabled end stop — non-interactive
}
}
/* Long unbroken strings (URLs, identifiers) wrap instead of forcing page overflow */
.prose { overflow-wrap: break-word; }
.prose a { overflow-wrap: anywhere; }
.prose h2 { color: var(--galaxy); margin: 34px 0 10px; font-size: clamp(1.25rem, 2.6vw, 1.45rem); }
.prose h3 { color: var(--galaxy); margin: 26px 0 8px; font-size: 1.15rem; }
.prose p { margin: 0 0 16px; }
@@ -392,11 +470,30 @@ span.page-step { color: var(--muted); } /* disabled end stop — non-interactive
.prose img { border: 1px solid var(--border); border-radius: var(--radius); }
.prose img + em, .prose p > em:only-child { color: var(--muted); font-size: 0.85rem; }
.prose hr { border: 0; border-top: 1px solid var(--border); margin: 32px 0; }
.prose table { width: 100%; border-collapse: collapse; margin: 0 0 16px; font-size: 0.94rem; }
/* Wide tables scroll inside their own box, never the page (mobile guard) */
.prose table { display: block; width: 100%; overflow-x: auto; border-collapse: collapse; margin: 0 0 16px; font-size: 0.94rem; }
.prose th { text-align: left; font-weight: 600; color: var(--galaxy); }
.prose th, .prose td { padding: 10px 12px; border-bottom: 1px solid var(--border); }
.prose thead th { border-bottom: 2px solid var(--cherenkov); }
/* Prev/next article links — sequential nav from the date-sorted collection */
.post-nav {
display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-m);
border-top: 1px solid var(--border); margin-top: var(--space-xl); padding-top: var(--space-m);
}
.post-nav a { display: block; min-width: 0; text-decoration: none; }
.post-nav .post-nav-prev { grid-column: 1; }
.post-nav .post-nav-next { grid-column: 2; text-align: right; }
.post-nav small {
display: block; color: var(--muted); font-size: 0.72rem; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 4px;
}
.post-nav .post-nav-title {
color: var(--galaxy); font-weight: 500;
display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.post-nav a:hover .post-nav-title { color: var(--link); }
/* Generic page section — one rhythm token for all pages */
.page { padding: var(--space-xl) 0; }
.panel {
@@ -422,29 +519,34 @@ span.page-step { color: var(--muted); } /* disabled end stop — non-interactive
/* Search results (list built by the inline script on /search) — card-like rows */
.search-results { list-style: none; margin: var(--space-m) 0 0; padding: 0; max-width: 720px; display: grid; gap: var(--space-s); }
.search-results li {
display: flex; justify-content: space-between; align-items: center; gap: var(--space-s);
display: flex; align-items: flex-start; gap: var(--space-s);
background: #fff; border: 1px solid var(--border); border-radius: var(--radius);
padding: 12px 18px; box-shadow: var(--shadow-ambient);
}
.search-results li:hover { border-color: var(--azure); box-shadow: var(--shadow-lifted); }
.search-results a { text-decoration: none; font-weight: 500; }
/* Title flows as inline text (never display:flex — see .suggest a note) */
.search-results a { flex: 1; min-width: 0; overflow-wrap: break-word; text-decoration: none; font-weight: 500; }
.search-results a:hover { text-decoration: underline; }
/* Date chips on results and suggestions */
.search-results small, .suggest small {
color: var(--muted); white-space: nowrap; font-size: 0.78rem;
background: var(--moon); border-radius: var(--radius-pill); padding: 2px 10px;
}
.search-results small { margin-top: 3px; } /* optical align with the first title line */
/* 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 stays labelled (REQUIREMENTS.md §5), but the
Demo phase banner is loud once, so per-element badges only whisper: quiet
hairline pill, muted text (7.1:1 on Moon), sentence case, Galaxy dot on mocks. */
.badge-mock, .badge-ext {
display: inline-block; font-size: 0.72rem; font-weight: 600; letter-spacing: 0.06em;
text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-pill); margin-bottom: 14px;
display: inline-flex; align-items: center; gap: 6px; max-width: 100%;
font-size: 0.75rem; font-weight: 500; color: var(--muted);
background: var(--moon); border: 1px solid var(--border);
border-radius: var(--radius-pill); padding: 2px 10px; margin-bottom: 14px;
}
.badge-mock { color: #7a3d00; background: #fff3e0; border: 1px solid #f0cf9b; }
.badge-ext { color: var(--galaxy); background: var(--moon); border: 1px solid var(--border); text-transform: none; letter-spacing: 0; }
.badge-mock::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--galaxy); flex: none; }
/* Dashboard mock — aggregation panels with status pills */
.dash-grid { display: grid; gap: var(--space-m); grid-template-columns: 1fr; margin-top: var(--space-s); }
@@ -534,6 +636,7 @@ mark { background: var(--tint-cherenkov); color: inherit; border-radius: calc(va
.hero > :nth-child(2) { animation-delay: 0.08s; }
.hero > :nth-child(3) { animation-delay: 0.16s; }
.hero > :nth-child(4) { animation-delay: 0.24s; }
.hero > :nth-child(5) { animation-delay: 0.32s; }
/* Hover feedback */
.card, .search-results li { transition: border-color 0.15s ease, box-shadow 0.2s ease, transform 0.2s ease; }
.card:hover { transform: translateY(-2px); }