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:
@@ -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
@@ -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=…)
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user