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