RFC-027 — List-route search on /missions + /fleet (v0.7)
Status · Draft Date · 2026-06-13 Target · v0.7 Tracked by · GH issue #338
Why this is an RFC. /missions (98 entries) and /fleet (245 entries) both carry a rich filter strip, but the filter chips alone leave the user scrolling once a category is broad (e.g. "FLOWN crewed NASA" is still 30+ Apollo + Skylab + Shuttle / Crew-Dragon entries). The audit-blessed addition is a single text input that narrows the card set across name + agency + description, and bidirectionally re-counts the filter chips so the filter UI keeps telling the truth about what the user is looking at. This RFC locks the matching contract, the search↔filter interaction model, the URL state, and the slice plan before either route's
+page.svelteis touched.
Gating sentence: Introduces a per-route text-search input on /missions + /fleet, with single-corpus matching across name + agency + description, AND-semantics interaction with the existing filter chips, and deep-linkable ?q= URL state — tracked by issue #338.
Goal
Let a user narrow the card set on /missions + /fleet by typing a search term that matches any of the three text fields the cards already render (name, agency-short, description), while keeping the existing filter chips fully bidirectional with the matched set.
Scope (v0.7)
- One text input per route —
/missionsand/fleetonly — positioned at the same break point as the FILTERS button (above the card grid). - Match against three resolved-locale fields per card:
name,agency_short(oragencyif no short form), anddescription. - AND-semantics with filters: the visible set is
(matches(q) AND matches(filters)). Order of application is irrelevant. - Filter chip counts re-compute on every keystroke against the searched subset so the chips never overstate.
- URL state via
?q=<term>on top of existing filter params. Emptyqdrops from the URL. - i18n: input placeholder, "Showing N of M", and "No matches" copy each get one message-bundle key, matched across all 14 locales.
- Empty state: when no card matches, show "No matches for
<q>" + an inline "Clear search" button. Filter chips remain visible (filters can independently widen the result if the user clears the query). - Mobile: the input collapses into the filter strip's sticky header so it never costs vertical pixels on a phone.
Non-goals (v0.7)
- Pagination — the existing
content-visibility: autopattern (TA.md §"Long-list routes") already keeps off-screen cards out of paint. 98 missions + 245 fleet entries × scroll is not a real perf cost; the filter + search combination is the cognitive-load tool. Re-visit if a real complaint lands. - Fuzzy / typo-tolerant matching — substring-case-insensitive is enough for the corpus size. A future RFC can add fzy/fuse.js if the empty-state hit rate is non-trivial.
- Autocomplete dropdown — the matched cards are the autocomplete.
- Search history / "recent" suggestions — privacy-coherent with the "no tracking" line in the footer; not worth the local-storage footprint.
- Cross-route search — typing "perseverance" on /missions doesn't surface the /fleet entry. A future v0.8 "global search" RFC can pick that up if the routes start needing it; for now the route-scoped pattern matches the user's already-route-scoped intent.
- Search against gallery alt-text, learn-link titles, science cross-refs — the corpus is bounded to what the card itself renders so the empty state never confuses ("I see no Apollo card but it matched??").
Matching contract
function matchesQuery(card: MissionIndex | FleetEntry, q: string): boolean {
if (!q) return true;
const needle = q.trim().toLowerCase();
if (!needle) return true;
const haystack = [
card.name,
card.agency_short ?? card.agency,
card.description ?? '',
].join(' ').toLowerCase();
return haystack.includes(needle);
}- Case-insensitive substring; no token splitting, no Unicode normalization in v1 (Latin-script corpus across all 14 locales currently — the few CJK locales fall back to en-US for these fields, so substring still hits).
- Empty / whitespace-only
qis a no-op (every card matches). - Pure function — same input always produces the same output, no module state.
Search ↔ filter interaction
The two are commutative. The route's filtered derivation becomes:
let q = $state(''); // bound to <input> via useUrlParam('q')
let filtered = $derived(
allCards
.filter(c => matchesQuery(c, q))
.filter(c => matchesFilters(c, activeFilters))
);Filter chip counts come from a second pass against the searched-but-not-yet-filter-narrowed set:
let searched = $derived(allCards.filter(c => matchesQuery(c, q)));
let agencyCounts = $derived(countBy(searched, c => c.agency));
// chips render `${label} ${count}` from agencyCountsSelecting an agency filter when a query is active keeps the query intact; clearing the query keeps the filter chips intact. The two state slices are independent.
URL state
?q=<term>— written by the newuseUrlParam('q')instance (RFC-024 rune), debounced 200 ms,replaceState: trueso back-button doesn't fill with keystrokes.- Empty/whitespace-only
qdrops from the URL on the next debounce tick. - Coexists with every existing filter param. Order of params is unstable but URL.searchParams round-trips losslessly.
- Deep-linkable:
/missions?dest=MARS&q=2020opens /missions filtered to Mars + searched for "2020" — both apply on first paint.
i18n
Three new message keys per route family (six total across /missions + /fleet):
search_input_placeholder = "Search missions…" / "Search the fleet…"
search_results_count = "Showing {n} of {total}"
search_empty_state = "No matches for "{query}""The matching corpus uses the active locale's resolved overlay (static/data/i18n/<locale>/missions/<id>.json shallow-merged onto base per ADR-017) — the same object the card already renders. So typing "Mond" on the German /missions route matches Moon missions because the German overlay translates the destination label inside the description. No fallback to en-US for matching (would be confusing — user can't see en-US strings).
Slice plan
- Slice A — extract matching contract + tests. New
src/lib/list-search.tswithmatchesQuery()+ unit tests. No UI changes. Lands asfeat(list-search): matcher contract. - Slice B — wire /missions. Add
<input>,useUrlParam('q'), updatefilteredderivation, re-bind chip counts to thesearchedset. e2e: deep-link?q=apolloshows ≥6 Apollo missions; chip counts reflect. - Slice C — wire /fleet. Same pattern. e2e parity.
- Slice D — i18n strings. Add 3 message keys × 14 locales = 42 strings (de/es/fr/it/pt-BR/nl/sr-Cyrl/zh-CN/ja/ko/hi/ar/ru). Run through translation pipeline.
Each slice is one commit. Adoption sweep stops at /missions + /fleet — the other long lists (/credits, /library) re-use the helper if/when a user complaint lands.
Risks
- Filter-chip flicker on every keystroke — every input change re-derives chip counts. Mitigation: $derived is reactive, no manual setState; 200 ms input debounce on the URL write avoids the back-button history flood but the visual chip update is intentionally instant (matches the user's mental model: "I see what I'm searching").
?q=collision with other route params — none of the existing params on /missions or /fleet useq; verified viagrep -nE "searchParams.get\('q'" src/routes/{missions,fleet}/+page.svelte(zero hits).- Substring false positives on short queries —
?q=esmatches "ESA", "es" inside "esa-jaxa joint", "tess", "messages", etc. Acceptable; the visible cards are the disambiguator. Two-char minimum was considered + rejected (excludes useful queries like "Q1" or "MA").
Acceptance
/missions?q=apolloand/fleet?q=dragonpaint the matched subset on first frame, chip counts reflect.- Deep-link
/missions?dest=MARS&q=2020opens with both filter + query applied. - Clearing the query restores the full filter-chip-counted set without a route reload.
- e2e covers: typing narrows; clearing widens; filter-while-searching narrows further; URL round-trips.
- preflight green; line coverage hold (the new helper is pure + tested).
- 42 new i18n strings land across all 14 locales.
Open questions (carried into the issue)
- Does
agency_shortexist on every fleet entry? If not, fall back toagency. - Should the search input keep focus after URL hydration on first paint (so deep-linked
?q=lands the user already typing more)? Default: yes. - Keyboard shortcut to focus the input (
/,Cmd-K)? Defer to a follow-up — not all routes have it and it's a separate UX decision.