fix: mobile gutters, header search tiers, suggest wiring + empty-state, news pagination, hero CTAs, ghost button

- padding-block on .page/.page-head/.article: container gutter survived only >=1176px (shorthand padding zeroed it) — mobile content sat flush left
- restore <=1024px tiers: field hidden, magnifier + menu visible (header no longer overflows at 375px)
- re-wire hdr-q suggestions lost in the field restore; newsSearch inits null-safe
- focused-empty search state: recent queries (localStorage, cap 4) + latest articles (Baymard autocomplete guidance)
- suggest panel 480px (Baymard/DocSearch width convention)
- /news page 1 = 25 items (featured + 24 = even 3-col rows), later pages 24, custom slicing
- hero CTAs: primary Browse news (/news, spec-first real service), ghost Explore the data (external); mock Proposals out of hero
- .btn.btn--ghost compound selector — .btn base defined later was filling ghosts with Cherenkov

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012jWfn3RwPfFGTtBddm36Uy
This commit is contained in:
ctao
2026-07-25 12:16:41 +02:00
co-authored by Claude Fable 5
parent eaf511a976
commit 4fa35e5317
6 changed files with 130 additions and 28 deletions
+2 -2
View File
@@ -241,10 +241,10 @@ editor" utility link in the footer; /admin keeps working as the direct URL.
|---|---|---|
| Header nav | End-user tasks only; order News · Data · Proposals · Dashboard · Support | NN/g serial-position |
| Header search | Icon-reveal row, all widths; plain GET /search | NN/g site search |
| Hero (home) | Photo (LST under night sky) + Galaxy scrim + eyebrow + headline + Cherenkov subtitle + CTA pair (Cherenkov primary, ghost secondary). Nothing else — no teaser line, no widgets, no second search | Apple hero restraint |
| Hero (home) | Photo (LST under night sky) + Galaxy scrim + eyebrow + headline + Cherenkov subtitle + CTA pair: primary "Browse news & announcements" (/news — the spec-first, fully real service; SPEC §3.1/§3.3.1), ghost "Explore the data ↗" (real external tool). Mocks never get hero billing. Nothing else — no teaser, no widgets, no second search | Apple hero restraint, SPEC §3.1 |
| Badges | Quiet hairline pill, muted sentence case, Galaxy dot on mocks; ONLY at mock interaction points | REQUIREMENTS §5 |
| Cards | Cover · title (clamp 3) · desc (clamp 2) · date · read time; borderless on Moon; hover = elevate only | NN/g metadata |
| Pagination | Newer/Older + windowed numbers, 44px targets, aria-current, Moon hover fill | NN/g pagination |
| Pagination | Newer/Older + windowed numbers, 44px targets, aria-current, Moon hover fill. Page 1 holds 25 items (featured lead + 24 = even 3-col rows), later pages 24 — custom slicing, paginate() can't vary size | NN/g pagination |
| Article | Back "← All news"; h1 → standfirst → muted meta line → borderless breakout cover → inline TOC → prose → post-nav | Guardian/BBC anatomy |
| Forms | Visible labels above fields; placeholders are examples only; buttons start with a verb | GOV.UK forms, NN/g |
| Login | One line + Cherenkov primary CTA + one small-print line; whisper badge top-right of the card (in flow ≤480px); no reassurance prose | auth brevity |
+73 -9
View File
@@ -1,12 +1,34 @@
// Shared client-side news search over /search.json (no dependencies, no modules).
// Used by the homepage hero (live suggestions) and /search (full results).
// Used by the header search (field + mobile reveal) and /search (full results).
// Focused-empty state per Baymard autocomplete guidance: recent searches (local
// only — localStorage, no network) plus the newest articles, never a void.
(function () {
const RKEY = 'ctao-recent-searches';
function recents() {
try { return JSON.parse(localStorage.getItem(RKEY) || '[]'); } catch { return []; }
}
function remember(q) {
q = (q || '').trim();
if (q.length < 2) return;
try {
const r = [q, ...recents().filter((x) => x.toLowerCase() !== q.toLowerCase())].slice(0, 4);
localStorage.setItem(RKEY, JSON.stringify(r));
} catch { /* private mode — recents are a nicety */ }
}
window.newsSearch = function (opts) {
const input = document.getElementById(opts.input);
const list = document.getElementById(opts.list);
const status = document.getElementById(opts.status);
// Null-safe: a page may render only some search surfaces — bail silently
// (returning a no-op) so one missing input can't kill later inits.
if (!input || !list || !status) return function () {};
let index = null;
let timer;
async function ensureIndex() {
if (!index) index = await (await fetch('/search.json')).json();
return index;
}
// First title match wrapped in <mark> (Starlight search pattern) — built
// from text nodes, never innerHTML.
function highlight(text, q) {
@@ -16,12 +38,50 @@
mark.textContent = text.slice(i, i + q.length);
return [text.slice(0, i), mark, text.slice(i + q.length)];
}
function label(text) {
const li = document.createElement('li');
li.className = 'suggest-label';
li.setAttribute('aria-hidden', 'true'); // the status line announces it
li.textContent = text;
return li;
}
function row(children, href, q) {
const li = document.createElement('li');
const a = document.createElement('a');
a.href = href;
if (q) a.dataset.q = q;
a.append(...children);
li.append(a);
return li;
}
// Focused-empty state: up to 4 recent queries, topped up to 5 rows with
// the newest articles from the same index.
async function idle() {
if (input.value.trim().length >= 2) return;
list.innerHTML = '';
const r = recents();
if (r.length) {
list.append(label('Recent'));
for (const term of r) list.append(row([term], '/search?q=' + encodeURIComponent(term), term));
}
const fresh = (await ensureIndex()).slice(0, Math.max(1, 5 - r.length));
if (input.value.trim().length >= 2) return; // typed meanwhile — run() owns the list
list.append(label('Latest news'));
for (const p of fresh) {
const li = row([p.title], '/news/' + encodeURIComponent(p.slug));
const small = document.createElement('small');
small.textContent = p.date;
li.append(small);
list.append(li);
}
status.textContent = opts.hint || (r.length ? 'Showing recent searches and latest news' : 'Showing latest news');
}
async function run() {
const q = input.value.trim().toLowerCase();
if (q.length < 2) { list.innerHTML = ''; idle(); return; }
list.innerHTML = '';
if (q.length < 2) { status.textContent = opts.hint || ''; return; }
if (!index) index = await (await fetch('/search.json')).json();
const hits = index
const idx = await ensureIndex();
const hits = idx
.filter((p) => (p.title + ' ' + p.description + ' ' + p.category).toLowerCase().includes(q))
.slice(0, opts.limit);
const unit = opts.unit || 'result';
@@ -29,17 +89,21 @@
? 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) {
const li = document.createElement('li');
const a = document.createElement('a');
a.href = '/news/' + encodeURIComponent(p.slug);
a.append(...highlight(p.title, q));
const li = row(highlight(p.title, q), '/news/' + encodeURIComponent(p.slug));
const small = document.createElement('small');
small.textContent = p.date;
li.append(a, small);
li.append(small);
list.append(li);
}
}
input.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(run, 150); });
input.addEventListener('focus', () => { if (input.value.trim().length < 2) idle(); });
// Remember successful queries: Enter (form submit) and suggestion clicks
if (input.form) input.form.addEventListener('submit', () => remember(input.value));
list.addEventListener('click', (e) => {
const a = e.target.closest && e.target.closest('a');
remember((a && a.dataset.q) || input.value);
});
return run; // callers may run() immediately (e.g. /search?q=…)
};
})();
+3 -2
View File
@@ -108,8 +108,9 @@ const links = [
</header>
<script is:inline src="/search-client.js"></script>
<script is:inline>
// Header search suggestions (icon-reveal row); the form is a plain
// GET /search, so everything below is progressive enhancement.
// 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 () {
+7 -4
View File
@@ -44,11 +44,14 @@ const services = [
<div class="eyebrow">Cherenkov Telescope Array Observatory</div>
<h1>CTAO Science Portal</h1>
<p>Exploring the Universe at the Highest Energies</p>
{/* 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. */}
{/* Hero actions = the two REAL things a logged-out visitor can do today:
news (SPEC §3.1 lists general information/announcements FIRST; §3.3.1
"displays the main information"; calls for proposals arrive VIA news)
and the external Data Explorer. Proposals stays in nav + tiles — a
mock doesn't earn hero billing. Search lives in the header. */}
<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>
<a class="btn" href="/news">Browse news &amp; announcements</a>
<a class="btn btn--ghost external" href="https://padc-ctao-data-explorer.obspm.fr/" target="_blank" rel="noopener">Explore the data</a>
</div>
</div>
</section>
+25 -4
View File
@@ -2,13 +2,34 @@
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 }) {
// Paginated archive; the [...page] rest route makes page 1 the bare /news URL,
// then /news/2 … /news/N. Custom slicing instead of paginate(): page 1 holds
// 25 items (1 featured lead + 24 grid = even 3-column rows), later pages 24 —
// paginate() cannot vary page size.
export async function getStaticPaths() {
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 FIRST = 25, REST = 24;
const chunks = [posts.slice(0, FIRST)];
for (let i = FIRST; i < posts.length; i += REST) chunks.push(posts.slice(i, i + REST));
const lastPage = chunks.length;
const hrefOf = (n) => (n === 1 ? '/news' : `/news/${n}`);
return chunks.map((data, i) => {
const n = i + 1;
return {
params: { page: n === 1 ? undefined : String(n) },
props: { page: {
data,
currentPage: n,
lastPage,
url: {
prev: n > 1 ? hrefOf(n - 1) : undefined,
next: n < lastPage ? hrefOf(n + 1) : undefined,
},
} },
};
});
}
const { page } = Astro.props;
+20 -7
View File
@@ -221,7 +221,9 @@ img { max-width: 100%; height: auto; display: block; }
}
.nav-search input::placeholder { color: rgba(255, 255, 255, 0.65); } /* 6.9:1 */
.nav-search input:focus { border-color: #fff; }
.nav-search .suggest { left: auto; right: 0; width: min(380px, 92vw); top: calc(100% + 10px); }
/* Autocomplete panel: never narrower than the field, wide enough for a
typical title in ≤2 lines (Baymard autocomplete; DocSearch ~500px) */
.nav-search .suggest { left: auto; right: 0; width: min(480px, calc(100vw - 2 * var(--gutter))); top: calc(100% + 10px); }
/* ≤1024px replacement: a 44px magnifier <details> reveals a search row under
the header (NN/g mobile-search pattern) — see the menu media block. */
.search-pop { display: none; }
@@ -248,6 +250,8 @@ img { max-width: 100%; height: auto; display: block; }
}
@media (max-width: 1024px) {
.nav > nav { display: none; }
.nav-search { display: none; } /* field is desktop-only (NN/g tiers) */
.search-pop { display: block; } /* magnifier reveal takes over ≤1024px */
.menu { display: block; }
.menu summary {
display: inline-flex; align-items: center; gap: 8px; min-height: 44px;
@@ -309,15 +313,17 @@ img { max-width: 100%; height: auto; display: block; }
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-l); }
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.12); color: #fff; border-color: #fff; }
/* Compound selector: outranks the later-defined .btn base (source-order tie
would otherwise fill the ghost with Cherenkov) */
.btn.btn--ghost { background: transparent; color: #fff; border: 1px solid rgba(255, 255, 255, 0.55); }
.btn.btn--ghost:hover { background: rgba(255, 255, 255, 0.12); color: #fff; border-color: #fff; }
/* Section-landing head (news/search/proposals/dashboard/support) — white page,
display-scale Galaxy h1 + standfirst; the navy band is reserved for / and /login. */
.page-head { padding: var(--space-band) 0 var(--space-l); }
.page-head { padding-block: var(--space-band) var(--space-l); }
.page-head h1 { color: var(--galaxy); font-size: var(--fs-h1); margin: 0 0 10px; }
.page-head .standfirst { margin: 0; max-width: 62ch; }
.page-head + .page { padding-top: 0; } /* white-on-white: head already spaces */
.page-head + .page { padding-block-start: 0; } /* white-on-white: head already spaces */
/* Search field (/search page) — large pill input with inline magnifier; real GET form */
.search-form { position: relative; display: flex; gap: 8px; max-width: 560px; }
@@ -340,6 +346,13 @@ a.btn { display: inline-flex; align-items: center; gap: 6px; text-decoration: no
.suggest:empty { display: none; }
.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); }
/* Focused-empty state labels ("Recent" / "Latest news") — quiet, non-interactive */
li.suggest-label {
color: var(--muted); font-size: var(--fs-xs); font-weight: 600;
text-transform: uppercase; letter-spacing: 0.08em; padding-top: 8px;
}
.suggest li.suggest-label:hover { background: none; }
.search-results li.suggest-label { background: none; border: 0; box-shadow: none; padding: 8px 0 0; }
/* 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. */
@@ -441,7 +454,7 @@ span.page-step { color: var(--muted); } /* disabled end stop — non-interactive
/* Article — 70ch column; on ≥1200px a sticky "On this page" rail sits to the right */
.article-layout { display: grid; grid-template-columns: minmax(0, 70ch); }
.article { padding: var(--space-l) 0 var(--space-xl); max-width: 70ch; }
.article { padding-block: var(--space-l) var(--space-xl); max-width: 70ch; }
.article .back { color: var(--muted); font-size: var(--fs-s); }
.article h1 { color: var(--galaxy); font-size: var(--fs-h1); margin: 14px 0 10px; }
.article .meta {
@@ -541,7 +554,7 @@ span.page-step { color: var(--muted); } /* disabled end stop — non-interactive
.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; }
.page { padding-block: var(--space-xl); }
.panel {
background: #fff; border: 1px solid var(--border); border-radius: var(--radius);
padding: var(--space-m); box-shadow: var(--shadow-ambient);