# Spec: Context-scoped search with filter chips and configurable group order

> ⚠️ **Superseded in part by
> [scoped-search-boundary.md](scoped-search-boundary.md).** The "frontend only,
> no Rust changes" decision below (§Background 2, §Design "Scope model" and
> "Threading scope through the store") left Jellyfin's item-type taxonomy in the
> presentation layer, which violates the backend/frontend boundary. The taxonomy
> is being moved into Rust. The **user-facing behaviour and UX in this spec are
> unchanged**; only where the scope→item-type mapping and result bucketing live
> changes. Read the boundary spec before touching search code.
>
> **Progress:** the scope→item-type mapping now lives in Rust
> (`SearchScope::item_types()`); the frontend sends an opaque scope. Result-side
> bucketing (`GROUP_ITEM_TYPES`) is still frontend-side — see
> [scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md)
> §Stage 2.

**Status:** Implemented (boundary revision: query side done, result side pending)
**Scope:** Frontend only. No Rust changes required. *(Revised — see banner.)*
**Requirements:** UR-049 → DR-063, DR-064, DR-065; UR-050 → DR-066, DR-067
(see [requirements.md](../requirements.md)).
**UX spec:** [ux-flows.md §6](../ux-flows.md) — §6.1 scope, §6.2 layout,
§6.3 group order, §6.4 current deviations.

## Summary

Two related changes to search:

1. **Scope** — a search started inside a library searches *that* library.
   Started from Home, `/library`, or the search tab, it searches everything.
   The active scope shows as a chip row under the search bar, preselected from
   context and freely changeable without retyping.
2. **Group order** — the order result groups appear in (Songs, Albums, Artists,
   Movies, TV Shows) becomes a drag-and-drop setting instead of being hardcoded.

## Motivation

Searching "office" while browsing TV currently returns music albums, because
both search entry points call the same unscoped query. The user has already
told us what they're looking at; ignoring that makes search feel indiscriminate
and pushes the relevant result below unrelated media.

## Background: what already exists

Verified in code — **most of the plumbing is already there.** This is
substantially a wiring task, not new infrastructure.

1. **`SearchOptions` already carries the filter.**
   [bindings.ts](../../src/lib/api/bindings.ts) —
   `SearchOptions = { limit?, includeItemTypes?, searchTerm? }`.

2. **Rust already honours `include_item_types` on both paths** — online
   ([online.rs](../../src-tauri/src/repository/online.rs), in the `get_items`
   options mapping) and offline
   ([offline.rs](../../src-tauri/src/repository/offline.rs), which builds a SQL
   type filter from it). **Do not add Rust code for filtering.**

3. **Per-page list search already does this correctly.**
   [GenericMediaListPage.svelte](../../src/lib/components/library/GenericMediaListPage.svelte)
   passes `includeItemTypes: [config.itemType]` to `repo.search(...)`. Use it as
   the reference for the call shape, including the `requestId` handling.

4. **The gap is exactly one function.**
   [library.ts](../../src/lib/stores/library.ts) — `search(query)` takes only a
   query and calls `repo.search(query, { limit: 10000 }, requestId)`, dropping
   any scope. Both callers
   ([search/+page.svelte](../../src/routes/search/+page.svelte) and
   [library/+layout.svelte](../../src/routes/library/+layout.svelte)) go through
   it.

5. **Group order is hardcoded in markup.**
   [SearchResults.svelte](../../src/lib/components/search/SearchResults.svelte)
   categorizes into `music{tracks,albums,artists} / movies / tvShows` and
   renders three fixed sections in source order.

6. **Frontend preferences persist via `localStorage`**, per the existing
   `viewMode` precedent in [library.ts](../../src/lib/stores/library.ts)
   (`jellytau-view-mode`). Follow that pattern — **do not** add a Rust settings
   command for this.

## Design

### Scope model

One `SearchScope` type, defined once and shared:

| Scope | `includeItemTypes` | Chip label |
|-------|--------------------|------------|
| `all` | *unset* | All |
| `music` | `MusicAlbum`, `MusicArtist`, `Audio`, `Playlist` | Music |
| `movies` | `Movie` | Movies |
| `tv` | `Series`, `Episode` | TV |

`all` must send **no** `includeItemTypes` key rather than a list of every type —
the two are not equivalent for item types not enumerated here (Person, folders).

### Route → scope resolution (DR-063)

A pure function, unit-testable without a DOM:

```ts
resolveSearchScope(pathname: string): SearchScope
```

- `/library/music*` → `music`
- `/library/movies*` → `movies`
- `/library/tv*` → `tv`
- `/`, `/library`, `/search`, anything else → `all`

Note `/library/shows/genres` exists as a route; treat `shows` as `tv`. Check the
current route list before finalising — do not assume this table is exhaustive.

### Scope is a starting point, not a lock (DR-064)

The resolved scope sets the **initial** chip only. Once the user taps a chip,
their choice governs until they leave the search surface. Concretely: derive the
initial value from the route, hold it in component state, and do not re-derive
it on every navigation — otherwise a user who widens to All snaps back to TV.

Changing a chip re-runs the current query at the new scope. Changing the query
keeps the current scope.

### Threading scope through the store (DR-065)

Extend the store's search signature to accept an optional scope and pass
`includeItemTypes` down to `repo.search`. Preserve the existing behaviour
exactly: the `requestId` bump, the stale-response guard, the `search-event`
listener merge, the 10s timeout, and the empty-query clear path. This is an
additive parameter — no caller should break.

### Group order (DR-066, DR-067)

Persist an ordered array of group ids:

```
["songs", "albums", "artists", "movies", "tvShows"]   // shipped default
```

Rendering composes scope and order as **two independent axes**, in this order:

1. drop groups outside the active scope,
2. sort the remainder by the user's saved order,
3. omit groups that came back empty.

Scope never rewrites the saved order — narrowing to Music and back to All must
restore the user's full arrangement. See [ux-flows.md §6.3](../ux-flows.md) for
the worked example.

Settings gets a reorderable list. **Dragging alone is not sufficient**: provide
keyboard-operable move up/down controls with proper labels, or the setting is
unusable with a screen reader and on any pointerless input.

Unknown or missing ids in the stored array must not crash rendering — treat the
stored order as a hint, append any group it doesn't mention, and ignore ids that
no longer exist. A user upgrading from a build with fewer groups must not lose
the new ones.

## Out of scope

- Ranking *within* a group. Order is presentation-only.
- Server-side search ranking or the Jellyfin query itself.
- Scope chips on the per-page list search in `GenericMediaListPage` — that page
  is already implicitly scoped by its own `itemType`.
- Any Rust change.

## Acceptance criteria

- [ ] Searching from inside Music returns no movies or TV; from inside TV, no music.
- [ ] Searching from Home, `/library`, or the search tab returns all types.
- [ ] The chip row renders under the search bar on both the search page and the
      in-library header search, with the context-derived chip preselected.
- [ ] Tapping a chip re-runs the search with the query preserved; editing the
      query preserves the selected chip.
- [ ] Tapping "All" from a context-scoped search widens results without retyping.
- [ ] Result groups render in the user's configured order, with out-of-scope and
      empty groups omitted and relative order preserved.
- [ ] Group order is reorderable by drag **and** by keyboard, persists across
      restarts, and ships with the documented default.
- [ ] Offline search respects scope (the offline path already filters — verify,
      don't reimplement).
- [ ] `bun run check` and `bun run test` pass.

## Testing

Follow the existing frontend test conventions (vitest, `src/lib/**/*.test.ts`).

- `resolveSearchScope` — pure unit tests over the route table, including the
  `/library/shows/genres` case and unknown routes falling back to `all`.
- Scope → `includeItemTypes` mapping, asserting `all` omits the key entirely.
- The compose step: scope filter + user order + empty-group omission, including
  the "narrow then widen restores order" case and a stored order containing an
  unknown id.
- Store-level: scoped search forwards `includeItemTypes` to the repository, and
  the existing stale-`requestId` guard still discards superseded responses.

New requirement-implementing code needs `TRACES:` comments — see
[CLAUDE.md](../../CLAUDE.md). Suggested tags: the scope resolver and chip row
`UR-049 | DR-063, DR-064`, the store change `UR-049 | DR-065`, the settings list
and ordered rendering `UR-050 | DR-066, DR-067`.

## Notes for the implementer

- Read [ux-flows.md §6](../ux-flows.md) first — it is the behavioural spec; this
  document is the implementation plan.
- The IPC camelCase rule applies to anything new that crosses the boundary
  ([CLAUDE.md](../../CLAUDE.md)) — though this change should not add commands.
- Another session may be active in this repo. Check `git diff` before
  "repairing" unexpected changes.
