# Keyword Reservoir → Backlinks Parity (per-site)

**Date:** 2026-06-05
**Scope:** Bring `/keywords/reservoir` to feature + design parity with `/backlink/reservoir`, keeping the **per-site** architecture (per `conventions.md`: keywords stay site-isolated; only `backlink/reservoir` is global).

## Context / current state

The keyword reservoir is already DB-backed (`keywords` table, `siteId` FK) and already has the CSV import flow (auto-detect, preview, commit, Semrush columns, `refresh()`). The gaps vs the backlinks reservoir are:

1. **Bulk actions don't persist** — `reservoir.tsx:73-85` (`setStatusForSelected`, `setThemeForSelected`) mutate local React state only; nothing reaches the DB.
2. **No add / delete / edit actions** — the detail `KeywordSheet` is read-only; there is no manual single-keyword add and no delete.
3. **Visual inconsistency** — backlinks badges use a shared `PILL` + `ACCENT` "dark-substrate" pill system (`backlink/source-badges.tsx`) and a bucketed `DaBadge`. Keyword `StatusBadge`/`IntentBadges` use plain shadcn `<Badge>` variants; the `Source` column is bare `capitalize` text.

Confirmed decisions (user):
- Keep **per-site** architecture (no global pool restructure).
- Include all four: persist bulk actions, design polish, delete + editable sheet, manual single-keyword add.
- **KD/CPC keep their continuous HSL gradient** (not bucketized) — only categorical badges adopt the pill language.
- Manual add: **add a `"manual"` value to the `KeywordSource` enum** (text column, no migration).

Data-flow seam stays as-is: the reservoir loads via the client action `fetchKeywordsForSiteAction` + a `refresh()` callback. Mutations follow the same pattern (`await action` → `refresh()`), **not** SSR `revalidatePath`.

## Components / units of work

Delivered as small increments (page-par-page), each independently testable.

### 1. Shared pill primitive
- **New** `src/components/ui/pill.tsx` exporting `PILL` (string), `ACCENT` (record), and a small `Pill` component (`{ accent, children, title? }`).
- Move the `PILL`/`ACCENT` definitions out of `backlink/source-badges.tsx` and re-import them there (no behavior change for backlinks).
- Interface: `ACCENT` keys = `emerald | amber | rose | primary | neutral` (unchanged from backlinks).

### 2. Keyword badge polish (adopt dark-substrate pills)
- `status-badge.tsx` → pill. Accent map: `idea`=neutral, `watch`=amber, `active`=emerald, `archived`=rose. FR labels unchanged.
- `intent-badge.tsx` → pills. Accent map: `informational`=primary, `commercial`=amber, `transactional`=emerald, `navigational`=neutral.
- **New** `SourceBadge` (in `keywords/`) → neutral pill; used by the `Source` column (replaces bare `capitalize`). Renders `manual` like any other source.
- `KdBadge`/`CpcBadge` **unchanged** (continuous HSL gradient kept).

### 3. Persist bulk actions
- **New** server actions in `src/lib/keywords/actions.ts`:
  - `setKeywordsStatusAction(siteId, ids: string[], status: KeywordStatus): Promise<void>`
  - `setKeywordsThemeAction(siteId, ids: string[], theme: KeywordTheme): Promise<void>`
  - Both `UPDATE keywords SET … WHERE siteId = $siteId AND id IN ($ids)` (site scope = defense-in-depth).
- `reservoir.tsx`: `setStatusForSelected` / `setThemeForSelected` call the action, then `refresh()` and clear selection. Remove the local-state mutation.

### 4. Delete
- **New** `deleteKeywordsAction(siteId, ids: string[]): Promise<void>` → `DELETE … WHERE siteId AND id IN (…)`.
- `BulkActionsBar`: add a **Delete** button (destructive variant) with a confirm step (AlertDialog), wired through `reservoir.tsx` → `refresh()`.

### 5. Editable detail sheet
- **New** `updateKeywordAction(siteId, id, patch: { status?, theme?, intent? }): Promise<void>` → single-row `UPDATE` (site-scoped).
- `KeywordSheet`: make **status / theme / intent** editable (inline selects / multi-select) → `updateKeywordAction` → `refresh()`. Metrics, trend, SERP features stay read-only (import-owned). The sheet receives the `siteId` and a refresh callback.

### 6. Manual single-keyword add
- Add `"manual"` to `KeywordSource` enum + `KEYWORD_SOURCES` in `src/lib/keywords/types.ts`.
- **New** `addKeywordAction(siteId, input: { term, lang, country, theme?, intent[] }): Promise<{ ok: true } | { ok: false; error: string }>`:
  - slug via existing helper; `id = keywordId(siteId, slug)`.
  - Insert with `status="idea"`, `primarySource="manual"`, `sources=["manual"]`, `metrics={}`, `trend=[]`.
  - Reject empty term / duplicate id with a friendly error.
- **New** `AddKeywordDialog` (mirrors `backlink/add-source-dialog.tsx`): fields term, lang, country, theme (select, optional), intent (multi). Added to `KeywordsToolbar` beside Import; on success → `refresh()`.
- Column/sheet rendering already falls back to `0` / `N/A` when `metrics[primarySource]` is absent — verify, no change expected.

## Data flow

```
user action (bulk / sheet edit / add / delete)
  → server action (site-scoped UPDATE/DELETE/INSERT on `keywords`)
  → refresh()  (re-runs fetchKeywordsForSiteAction)
  → table + sheet reflect new state
```

## Error handling

- All write actions are `"use server"`, site-scoped in the `WHERE`/insert payload.
- `addKeywordAction` returns a result object (`ok`/`error`) for inline dialog display, matching `addSourceAction`.
- Bulk/update/delete return `void`; client clears selection and refreshes. Failures are caught and surfaced minimally (toast/console per existing convention).
- Delete is confirmed via AlertDialog before firing.

## Testing

- Per existing project convention (DB-backed actions): targeted tests for the new actions against the **test** DB (`assertTestDb()` guard; force `DATABASE_URL=…seo_insight_test`). Cover: bulk status/theme update only touches given ids within the site; delete is site-scoped; `addKeywordAction` rejects duplicates; `updateKeywordAction` patches only provided fields.
- Manual UI verification of badge polish, dialog, editable sheet against the dev server.

## Out of scope

- Global keyword pool / per-site targeting restructure (explicitly rejected — keeps `conventions.md`).
- Persisting theme **label renames** to the DB (stays client-side via `theme-context`).
- Persisting column-visibility / filter / search preferences.
- Real keyword API, dedup/merge beyond existing on-conflict-do-update, Google re-check.

## Sequencing

1 → 2 → 3 → 4 → 5 → 6. Step 1 (shared pill) unblocks 2; 3/4/5/6 are largely independent and can be parallelized across sub-agents once actions are stubbed.
