From 4fa35e5317b394ee6b455e8eec84950c30c6ffad Mon Sep 17 00:00:00 2001 From: ctao Date: Sat, 25 Jul 2026 12:16:41 +0200 Subject: [PATCH] fix: mobile gutters, header search tiers, suggest wiring + empty-state, news pagination, hero CTAs, ghost button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_012jWfn3RwPfFGTtBddm36Uy --- DESIGN.md | 4 +- public/search-client.js | 82 ++++++++++++++++++++++++++++++---- src/layouts/Base.astro | 5 ++- src/pages/index.astro | 11 +++-- src/pages/news/[...page].astro | 29 ++++++++++-- src/styles/global.css | 27 ++++++++--- 6 files changed, 130 insertions(+), 28 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 3af81cf..1535801 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 | diff --git a/public/search-client.js b/public/search-client.js index 553085b..b2655d1 100644 --- a/public/search-client.js +++ b/public/search-client.js @@ -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 (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=…) }; })(); diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index 7132ba2..ffac499 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -108,8 +108,9 @@ const links = [