# Handoff: ChicoryTV — Auto-Tune (generate channels from library metadata, #69)

## Overview
**Auto-Tune** is the *automatic-first* channel-creation mode — the counterpart to the manual
**Channel Builder** ("New Channel"). Instead of building one channel by hand, it enumerates your
library's metadata along one or more **axes** (TV Shows, TV Genres, Movie Genres), **previews** the
whole set of proposed channels so you can pick which to keep, then **bulk-creates** the selected
ones. It is **additive and non-destructive**: it never edits or deletes an existing channel; name
and number collisions are *skipped*, never overwritten. Each generated channel is backed by a live
**SmartCollection** query, so a "Comedy" channel keeps picking up new comedies as the library grows.

Concept borrowed from **PseudoTV Live's** signature Auto-Tuning, deliberately fixing its two
weaknesses: PseudoTV is all-or-nothing per category with no preview, and it wipes+rebuilds the whole
lineup on every run. Ours adds a preview/select step and is non-destructive.

The **backend already shipped** (PR1, ersatztv#379) — two endpoints under the frozen `/api/v1`. This
handoff is **PR2: the SPA screen** that drives them.

> **Scope note (2026-07-16).** The prototype (`AutoTune.jsx`) was iterated to add a per-channel
> **DetailPanel** slide-over (opened from a "Configure" button on each Preview row — full per-channel
> editor: identity/image, shuffle/always-playing, template + Advanced overrides, weighted content
> sources, exclude/add-untagged, example schedule). That panel needs backend the PR1 endpoints don't
> have, so **it is DEFERRED to a follow-up arc — ersatztv#383** (children #384 enumerate members,
> #385 per-channel overrides + rotation weights in create, #386 the SPA panel). **PR2 (this handoff)
> implements the 3-step wizard only — Configure → Preview → Create — on the existing PR1 endpoints;
> no per-row Configure button.** The DetailPanel sections below the wizard spec are recorded for the
> #383 arc, not PR2.

## About the design files
The files in this bundle are **design references built in HTML/React** (a prototype on the ChicoryTV
UI kit) — **not production code**. Recreate the design in the target codebase (the ChicoryTV React
SPA, `web/`) using its real components (`web/src/components/`) and CSS-custom-property tokens. The
prototype (`AutoTune.jsx`) is the source of truth for **layout, spacing, motion, and interaction**;
its mock data and mock outcome logic are illustrative only — the real screen calls the API.

## Fidelity
**High-fidelity.** Colors, typography, spacing, radii, and interactions are resolved and use tokens
throughout, so it is fully theme-aware (verified in the warm + cool accent themes). All values below
are exact.

---

## The screen — a 3-step wizard

One full-height column: a fixed **toolbar** (`12px 20px` padding, `1px solid var(--border-hairline)`
bottom border) over a scrolling `<main>`. The toolbar is constant across steps; its content and
primary action change per step.

**Toolbar (all steps):**
- Left: a 34×34 accent **icon tile** (`var(--radius-sm)`, `background: var(--ctv-accent-soft)`,
  `color: var(--ctv-accent)`) with the `Sparkles` icon; then title **"Auto-Tune"** (`--text-md`,
  semibold) + subtitle **"Generate channels from your library"** (`--text-xs`, `--text-secondary`).
- Center: a **step rail** — three steps (`Configure · Preview · Create`) joined by 26px hairline
  connectors. Each step is a 20px round chip + label. The **active** step: chip
  `background var(--ctv-accent)` / `color var(--text-on-accent)`, label accent + semibold. A
  **completed** step: chip `var(--ctv-accent-soft)` bg + accent `Check` icon, label
  `--text-secondary`. A **future** step: chip `var(--ctv-surface-2)` + `--text-disabled`.
- Right: the contextual primary action (below).

### Step 1 — Configure
Centered column, `max-width: 860px`, `padding: 26px 24px 40px`.
1. **Intro paragraph** (`--text-sm`, `--text-secondary`, `max-width: 620px`): what Auto-Tune does +
   the non-destructive promise.
2. **"Generate from"** section (eyebrow label) — a **3-column grid** of selectable **axis cards**
   (one per axis). Each card is a `<button>` (`ctv-press` for the tactile scale-on-press):
   - Selected: `1px solid var(--ctv-accent)` border, `var(--ctv-accent-soft)` bg, `var(--shadow-sm)`;
     a filled accent `CheckCircle2` top-right; the icon tile is `var(--ctv-accent)` /
     `var(--text-on-accent)`.
   - Unselected: `1px solid var(--border-control)`, `var(--surface-card)` bg; a `Circle` outline
     top-right (`--text-disabled`); icon tile `var(--ctv-surface-2)` / `--text-secondary`.
   - Content: axis icon (Tv / Clapperboard / Film), **title**, one-line **tagline**, and a bottom
     **order** chip (`ListOrdered` "Episode order" for TV Shows; `Shuffle` "Shuffled" for the genres).
3. **"Defaults"** section — a 2-column grid of four labelled fields, each with a helper line
   (`Field` component: label `--text-xs` medium, hint `--text-2xs` `--text-disabled`):
   - **Minimum items** (`Input type=number`, `Hash` leading icon) — skip channels below this count.
   - **Starting channel number** (`Input type=number`, `Tv` icon) — numbers count up from here.
   - **Channel group** (`Input`, `FolderTree` icon) — the group every generated channel lands in.
   - **Channel template** (`Select`) — the batch's streaming/playout/filler defaults.

**Primary action:** `Preview channels` (primary, `Eye` icon). **Disabled** with a tooltip until ≥1
axis is selected.

### Step 2 — Preview
Centered column, `max-width: 920px`.
- **Summary bar:** `N of M selected` (mono N), an info **Tag** `"{k} already exist — deselected"`
  when any proposal's name collides, a spacer, then ghost **Select all** / **Clear** buttons (they
  only touch selectable — non-existing — rows).
- **One section per axis** that produced rows (`var(--surface-card)`, hairline border,
  `var(--radius-md)`), in axis order (TV Shows → TV Genres → Movie Genres). Section header
  (`var(--ctv-bg-sunken)`): a **group checkbox** (tri-state: checked / indeterminate when partial),
  the axis icon + title, a neutral **Badge** with the row count, and a right-aligned order eyebrow.
- **Rows** (hairline-separated): per-row **Checkbox**, the allocated **number** (mono,
  `min-width 42`), the channel **name** (semibold, ellipsis) over a `from "{value}"` sub-line, a
  neutral **Badge** `{itemCount} items`, and — for an already-existing name — an `Exists` **Tag**.
  Existing rows render at `opacity: 0.55` with a **disabled, unchecked** checkbox (dedup: you can't
  re-create a channel that already exists by that name).
- **Empty state** (no proposals matched): centered `SearchX` + "try lowering the minimum items".

**Primary action:** `Create {N} channels` (primary, `Check` icon; label pluralizes; disabled until
≥1 selected) preceded by a ghost `Back` (`ArrowLeft`) that returns to Configure.

### Step 3 — Create (results)
Centered column, `max-width: 860px`.
- **Three `Stat` tiles** across the top: **Created / Skipped / Failed** counts (icons
  `CheckCircle2` / `MinusCircle` / `XCircle`).
- A **"Added to group {group}"** line (`FolderTree` icon).
- A **results list** (card, hairline rows): per channel a status **glyph** in the status color
  (`CheckCircle2` live-green / `MinusCircle` secondary / `XCircle` danger), the **name**, an optional
  **reason** sub-line (skip/fail explanation), the new **`#{channelId}`** (mono) when created, and a
  status **Badge** (`positive` / `neutral` / `danger`).

**Primary action:** `Start over` (primary, `RotateCcw`) — resets the wizard to Configure.

---

## API mapping (the real screen)

Both endpoints already exist (PR1). The client sends **only `axis` + `value`** back — never a Lucene
query; the server regenerates it (query authorship is server-side only).

| UI element | Endpoint / field |
|---|---|
| `Preview channels` | `POST /api/v1/channels/auto-tune/preview` |
| — axis cards | request `axes: AutoTuneAxis[]` (`"TvShow" \| "TvGenre" \| "MovieGenre"`) |
| — Minimum items | request `minItems: number` |
| — Starting channel number | request `startingNumber: number` |
| Preview rows | response `AutoTuneProposalResponseModel[]`: `{ axis, value, name, number (string), itemCount, alreadyExists }` |
| — number chip | `number` (string — channel numbers can be `"500.1"`; render as-is) |
| — `{itemCount} items` badge | `itemCount` |
| — greyed + `Exists` | `alreadyExists === true` (deselect + disable) |
| `Create {N} channels` | `POST /api/v1/channels/auto-tune` |
| — Channel template field | request `templateId: number` (from `GET /api/v1/channel-templates` / `…/default`) |
| — Channel group field | request `group: string` |
| — selected rows | request `channels: { axis, value, name, number }[]` (echo the selected proposals) |
| Results tiles + list | response `AutoTuneResultResponseModel`: `{ results: { name, status ("Created"\|"Skipped"\|"Failed"), channelId, reason }[], createdCount, skippedCount, failedCount }` |

Notes:
- **Numbers are advisory.** The preview allocates them (skipping taken numbers); the create handler
  **re-validates** at create time — a number taken in between yields a per-channel `Skipped`, not a
  batch failure. So a `Skipped` outcome with "number … already taken" is normal, not an error.
- **Default selection = every proposal whose `alreadyExists` is false.**
- **Grouping/ordering** is fixed: axis order (TvShow, TvGenre, MovieGenre) then by value; mirror the
  server's ordering rather than re-sorting client-side.
- **Template default:** preselect `GET /api/v1/channel-templates/default` (fall back to the first
  template) so the field is never empty; the batch requires a `templateId`.

## Real-SPA implementation notes (target = `web/`)
- New screen `web/src/screens/AutoTuneScreen.tsx`; new route id `autoTune`, path `/app/auto-tune`,
  label **"Auto-Tune"**, icon `Sparkles`, placed **right after `builder`** in the first sidebar nav
  group (the two channel-creation modes sit together). No `primaryAction` label — the wizard's
  action lives in-body (per `spa-conventions.md` §10: the "+" banner is for single unambiguous
  *create* list screens; a multi-step wizard drives its own buttons), so the shell shows no banner
  button for this screen.
- New API module `web/src/api/autoTune.ts` (re-export the generated DTOs; `previewAutoTune(body)` +
  `createAutoTunedChannels(body)` over the shared `request` helper; a `messageFrom…Error` narrower),
  re-exported from `web/src/api/index.ts`.
- Reuse `getChannelTemplates` / `getDefaultChannelTemplate` from `web/src/api/channelTemplates.ts`
  for the template `Select`.
- Follow `spa-conventions.md` §3 for the two async calls (discriminated-union state, `seqRef` +
  `activeRef`, no synchronous set-state in an effect body). The wizard is transient (a create flow,
  not an editor of persisted data), so it does **not** register the §8 unsaved-changes guard.
- Map prototype primitives → real components: `Button`, `Input`, `Select`, `Checkbox`, `Badge`,
  `Tag`, `Stat`, `Tooltip`, `Spinner` from `web/src/components/`. The axis cards and step rail are
  small screen-local components built from `ctv-*` utility classes + tokens (no new shared primitive).
- Docs to update in the same PR: `docs/domain-model.md` (add `/app/auto-tune` to the channel routes),
  `docs/blazor-route-parity.md` (net-new SPA screen, no Blazor ancestor), `docs/spa-conventions.md`
  (if the wizard/step-rail pattern is worth recording), and `design-system/` committed alongside.
