fix: search scroll jump (atomic list swap, no clear-before-await); services grid back to 6 tiles (2 subdued planned) + planned line for the rest

This commit is contained in:
ctao
2026-07-25 18:33:06 +02:00
parent 1ef2cab89e
commit affa3262ef
3 changed files with 29 additions and 16 deletions
+13 -8
View File
@@ -69,29 +69,31 @@
// the newest articles from the same index. // the newest articles from the same index.
async function idle() { async function idle() {
if (input.value.trim().length >= 2) return; if (input.value.trim().length >= 2) return;
list.innerHTML = ''; // Build off-DOM, swap atomically — clearing before the await shrinks the
// page mid-flight and the browser clamps the scroll position ("jumps up").
const frag = document.createDocumentFragment();
const r = recents(); const r = recents();
if (r.length) { if (r.length) {
list.append(label('Recent')); frag.append(label('Recent'));
for (const term of r) list.append(row([term], '/search?q=' + encodeURIComponent(term), term)); for (const term of r) frag.append(row([term], '/search?q=' + encodeURIComponent(term), term));
} }
const fresh = (await ensureIndex()).slice(0, Math.max(1, 5 - r.length)); 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 if (input.value.trim().length >= 2) return; // typed meanwhile — run() owns the list
list.append(label('Latest news')); frag.append(label('Latest news'));
for (const p of fresh) { for (const p of fresh) {
const li = row([p.title], '/news/' + encodeURIComponent(p.slug)); const li = row([p.title], '/news/' + encodeURIComponent(p.slug));
const small = document.createElement('small'); const small = document.createElement('small');
small.textContent = p.date; small.textContent = p.date;
li.append(small); li.append(small);
list.append(li); frag.append(li);
} }
list.replaceChildren(frag);
hint(); hint();
status.textContent = opts.hint || (r.length ? 'Showing recent searches and latest news' : 'Showing latest news'); status.textContent = opts.hint || (r.length ? 'Showing recent searches and latest news' : 'Showing latest news');
} }
async function run() { async function run() {
const q = input.value.trim().toLowerCase(); const q = input.value.trim().toLowerCase();
if (q.length < 2) { list.innerHTML = ''; idle(); return; } if (q.length < 2) { list.replaceChildren(); idle(); return; }
list.innerHTML = '';
const idx = await ensureIndex(); const idx = await ensureIndex();
const hits = idx const hits = idx
.filter((p) => (p.title + ' ' + p.description + ' ' + p.category).toLowerCase().includes(q)) .filter((p) => (p.title + ' ' + p.description + ' ' + p.category).toLowerCase().includes(q))
@@ -100,13 +102,16 @@
status.textContent = hits.length status.textContent = hits.length
? hits.length + ' ' + unit + (hits.length === 1 ? '' : 's') + ' for “' + input.value.trim() + '”' ? hits.length + ' ' + unit + (hits.length === 1 ? '' : 's') + ' for “' + input.value.trim() + '”'
: 'No results for “' + input.value.trim() + '” — try a shorter or different term.'; : 'No results for “' + input.value.trim() + '” — try a shorter or different term.';
// Off-DOM build + atomic swap (no transient page-height collapse)
const frag = document.createDocumentFragment();
for (const p of hits) { for (const p of hits) {
const li = row(highlight(p.title, q), '/news/' + encodeURIComponent(p.slug)); const li = row(highlight(p.title, q), '/news/' + encodeURIComponent(p.slug));
const small = document.createElement('small'); const small = document.createElement('small');
small.textContent = p.date; small.textContent = p.date;
li.append(small); li.append(small);
list.append(li); frag.append(li);
} }
list.replaceChildren(frag);
hint(); hint();
} }
input.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(run, 150); }); input.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(run, 150); });
+12 -6
View File
@@ -27,10 +27,14 @@ const services = [
icon: '<rect x="4" y="4" width="7" height="7" rx="1.5"/><rect x="13" y="4" width="7" height="7" rx="1.5"/><rect x="4" y="13" width="7" height="7" rx="1.5"/><rect x="13" y="13" width="7" height="7" rx="1.5"/>' }, icon: '<rect x="4" y="4" width="7" height="7" rx="1.5"/><rect x="13" y="4" width="7" height="7" rx="1.5"/><rect x="4" y="13" width="7" height="7" rx="1.5"/><rect x="13" y="13" width="7" height="7" rx="1.5"/>' },
{ title: 'User Support', href: '/support', desc: 'Help desk, FAQ, user forum and mailing lists.', { title: 'User Support', href: '/support', desc: 'Help desk, FAQ, user forum and mailing lists.',
icon: '<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="4"/><path d="m5.7 5.7 3.5 3.5m5.6 0 3.5-3.5m0 12.6-3.5-3.5m-5.6 0-3.5 3.5"/>' }, icon: '<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="4"/><path d="m5.7 5.7 3.5 3.5m5.6 0 3.5-3.5m0 12.6-3.5-3.5m-5.6 0-3.5 3.5"/>' },
// Two subdued planned tiles complete the 2×3 grid (an orphan 4th tile in a
// 3-col grid reads broken); the remaining TBD services stay on the quiet line.
{ title: 'Software', planned: true, desc: 'Science analysis tools and pipelines — planned.',
icon: '<rect x="3" y="4.5" width="18" height="15" rx="2"/><path d="m7 9.5 3 2.5-3 2.5M12.5 15H17"/>' },
{ title: 'Documentation', planned: true, desc: 'User guides and technical documentation — planned.',
icon: '<path d="M12 6.7C10.6 5.2 8.6 4.5 5.7 4.5H4v13.6h1.7c2.9 0 4.9.7 6.3 2.2 1.4-1.5 3.4-2.2 6.3-2.2H20V4.5h-1.7c-2.9 0-4.9.7-6.3 2.2z"/><path d="M12 6.7v13.6"/>' },
]; ];
// Planned services (SPEC §3.1/§3.3.2, all TBD): one quiet line instead of dead const planned = ['Scheduling', 'Science alerts'];
// tiles — a grid should not ship tiles that do nothing (Apple-lens audit).
const planned = ['Software', 'Documentation', 'Scheduling', 'Science alerts'];
--- ---
<Base title="CTAO Science Portal" description="News, observation proposals, data and user services of the Cherenkov Telescope Array Observatory" ambient> <Base title="CTAO Science Portal" description="News, observation proposals, data and user services of the Cherenkov Telescope Array Observatory" ambient>
{/* Photographic hero: LST-1 under the La Palma night sky (copied from the {/* Photographic hero: LST-1 under the La Palma night sky (copied from the
@@ -66,11 +70,13 @@ const planned = ['Software', 'Documentation', 'Scheduling', 'Science alerts'];
<div class="section-head"><h2>Services</h2></div> <div class="section-head"><h2>Services</h2></div>
<div class="grid"> <div class="grid">
{services.map((s) => ( {services.map((s) => (
<article class="card tile"> <article class={s.planned ? 'card tile tile--planned' : 'card tile'}>
<svg class="ico" aria-hidden="true" viewBox="0 0 24 24" set:html={s.icon} /> <svg class="ico" aria-hidden="true" viewBox="0 0 24 24" set:html={s.icon} />
<h3>{s.ext <h3>{s.planned
? s.title
: (s.ext
? <a class="external" href={s.href} target="_blank" rel="noopener">{s.title}</a> ? <a class="external" href={s.href} target="_blank" rel="noopener">{s.title}</a>
: <a href={s.href}>{s.title}</a>}</h3> : <a href={s.href}>{s.title}</a>)}</h3>
<p class="desc">{s.desc}</p> <p class="desc">{s.desc}</p>
</article> </article>
))} ))}
+3 -1
View File
@@ -468,7 +468,9 @@ span.page-step { color: var(--muted); } /* disabled end stop — non-interactive
.tile { padding: var(--space-m); gap: 8px; } .tile { padding: var(--space-m); gap: 8px; }
.tile .ico { width: 26px; height: 26px; stroke-width: 1.75; stroke-linejoin: round; color: var(--galaxy); } .tile .ico { width: 26px; height: 26px; stroke-width: 1.75; stroke-linejoin: round; color: var(--galaxy); }
.tile h3 { margin: 0; } .tile h3 { margin: 0; }
/* Planned services — one quiet line under the grid, never dead tiles */ /* Planned tiles complete the grid rows but stay subdued (no link, muted) */
.tile--planned :is(h3, .ico) { color: var(--muted); }
/* Remaining planned services — one quiet line under the grid */
.planned-note { color: var(--muted); font-size: var(--fs-s); margin: var(--space-m) 0 0; } .planned-note { color: var(--muted); font-size: var(--fs-s); margin: var(--space-m) 0 0; }
/* Article — 70ch column; on ≥1200px a sticky "On this page" rail sits to the right */ /* Article — 70ch column; on ≥1200px a sticky "On this page" rail sits to the right */