Skip to content

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.svelte is 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 — /missions and /fleet only — 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 (or agency if no short form), and description.
  • 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. Empty q drops 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: auto pattern (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

ts
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 q is 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:

ts
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:

ts
let searched = $derived(allCards.filter(c => matchesQuery(c, q)));
let agencyCounts = $derived(countBy(searched, c => c.agency));
// chips render `${label} ${count}` from agencyCounts

Selecting 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 new useUrlParam('q') instance (RFC-024 rune), debounced 200 ms, replaceState: true so back-button doesn't fill with keystrokes.
  • Empty/whitespace-only q drops 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=2020 opens /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

  1. Slice A — extract matching contract + tests. New src/lib/list-search.ts with matchesQuery() + unit tests. No UI changes. Lands as feat(list-search): matcher contract.
  2. Slice B — wire /missions. Add <input>, useUrlParam('q'), update filtered derivation, re-bind chip counts to the searched set. e2e: deep-link ?q=apollo shows ≥6 Apollo missions; chip counts reflect.
  3. Slice C — wire /fleet. Same pattern. e2e parity.
  4. 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 use q; verified via grep -nE "searchParams.get\('q'" src/routes/{missions,fleet}/+page.svelte (zero hits).
  • Substring false positives on short queries?q=es matches "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=apollo and /fleet?q=dragon paint the matched subset on first frame, chip counts reflect.
  • Deep-link /missions?dest=MARS&q=2020 opens 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_short exist on every fleet entry? If not, fall back to agency.
  • 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.

Orrery — architecture documentation · MIT · No tracking