# Dialogs keywords : synchro site + responsive — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Les dialogs de saisie keywords proposent langues/pays du site actif (au lieu de l'univers) et deviennent responsive.

**Architecture:** Deux helpers purs centralisés dans `sites/options.ts` (`siteLanguageOptions`/`siteCountryOptions`) consommés par 4 dialogs via `useActiveSite()`. Volet responsive : `max-h`+scroll sur `DialogContent` de base + rangée `add-keyword` empilée mobile.

**Tech Stack:** Next.js 16 (client components) + Bun + Tailwind/shadcn + bun:test.

**Spec:** `docs/superpowers/specs/2026-07-24-keyword-dialogs-site-sync-responsive-design.md`

## Global Constraints

- **Strict site** : les dialogs de saisie ne proposent QUE langues (`primaryLanguage` + `languages`) et pays (`targetCountries`) du site actif. Repli sur l'univers (`COUNTRY_OPTIONS`) UNIQUEMENT si `targetCountries` vide/absent (langue : `primaryLanguage` existe toujours, pas de repli). Ne PAS toucher aux filtres facettes (dérivés des données) ni à `coverage-matrix` (déjà branché).
- `useActiveSite()` (`src/lib/keywords/site-context.tsx`) expose `activeSite` avec `primaryLanguage: string`, `languages?: string[]`, `targetCountries?: string[]`. Si `activeSite` est `undefined` (chargement), repli univers, pas de crash.
- `countryLabel(code)` = nom seul (post-chantier i18n) ; le drapeau est rendu par le combobox via `withFlag`. Pas de drapeau sur les langues.
- Tests : `bun run test <path>` (PAS `bun test`) ; `bunx tsc --noEmit` exit 0 ; biome sur les fichiers touchés.
- Conventional Commits ; trailers : `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>` puis `Claude-Session: https://claude.ai/code/session_01TPVZEibkftCoz8dwbRSMkn`.
- Hook local : jamais heredoc + `git commit` ensemble ; AUCUN `git stash`.
- Branche : `feature/keyword-dialogs-site-sync` (depuis `dev`).

---

### Task 1 : Helpers `siteLanguageOptions` / `siteCountryOptions`

**Files:**
- Modify: `src/lib/sites/options.ts`
- Test: `src/lib/sites/options.test.ts` (créer si absent, sinon étendre)

**Interfaces:**
- Consomme : `ComboOption` (`{value, label}`), `languageLabel`, `countryLabel`, `COUNTRY_OPTIONS` (déjà dans `options.ts`).
- Produit : `siteLanguageOptions(site): ComboOption[]`, `siteCountryOptions(site): ComboOption[]` (Task 3 les consomme).

- [ ] **Step 1 : test failing** (`src/lib/sites/options.test.ts`)

```ts
import { expect, test } from "bun:test";
import { siteCountryOptions, siteLanguageOptions } from "./options";

test("siteLanguageOptions : principale + additionnelles, principale d'abord, dédupée", () => {
  const opts = siteLanguageOptions({ primaryLanguage: "en", languages: ["fr", "en", "de"] });
  expect(opts.map((o) => o.value)).toEqual(["en", "fr", "de"]); // en une seule fois, en tête
});

test("siteLanguageOptions : principale seule si pas d'additionnelles", () => {
  expect(siteLanguageOptions({ primaryLanguage: "fr" }).map((o) => o.value)).toEqual(["fr"]);
  expect(siteLanguageOptions({ primaryLanguage: "fr", languages: null }).map((o) => o.value)).toEqual(["fr"]);
});

test("siteCountryOptions : targetCountries mappés", () => {
  const opts = siteCountryOptions({ targetCountries: ["FR", "US"] });
  expect(opts.map((o) => o.value)).toEqual(["FR", "US"]);
});

test("siteCountryOptions : repli univers si targetCountries vide/absent", () => {
  const empty = siteCountryOptions({ targetCountries: [] });
  const absent = siteCountryOptions({});
  // repli = COUNTRY_OPTIONS (250) — on vérifie que c'est long (pas 0) et contient FR
  expect(empty.length).toBeGreaterThan(100);
  expect(absent.length).toBeGreaterThan(100);
  expect(empty.some((o) => o.value === "FR")).toBe(true);
});
```

- [ ] **Step 2 : vérifier l'échec** — `bun run test src/lib/sites/options.test.ts` → FAIL (fonctions absentes).

- [ ] **Step 3 : implémenter dans `src/lib/sites/options.ts`** (après `countryLabel`) :

```ts
/**
 * Options de langue du SITE : langue principale + additionnelles, dédupées,
 * principale en tête. value = code, label = languageLabel(code). Pas de drapeau
 * (une langue n'est pas un pays). `primaryLanguage` existe toujours → au moins
 * une option.
 */
export function siteLanguageOptions(site: {
  primaryLanguage: string;
  languages?: string[] | null;
}): ComboOption[] {
  const codes: string[] = [];
  for (const c of [site.primaryLanguage, ...(site.languages ?? [])]) {
    if (c && !codes.includes(c)) codes.push(c);
  }
  return codes.map((c) => ({ value: c, label: languageLabel(c) }));
}

/**
 * Options de pays du SITE : `targetCountries` mappés (value = alpha-2,
 * label = countryLabel = nom seul, drapeau via le combobox `withFlag`).
 * Repli sur l'univers (COUNTRY_OPTIONS) si `targetCountries` vide/absent, pour
 * ne pas bloquer la saisie sur un site pas encore configuré.
 */
export function siteCountryOptions(site: {
  targetCountries?: string[] | null;
}): ComboOption[] {
  const codes = site.targetCountries ?? [];
  if (codes.length === 0) return COUNTRY_OPTIONS;
  return codes.map((c) => ({ value: c, label: countryLabel(c) }));
}
```

- [ ] **Step 4 : vérifier le pass** — `bun run test src/lib/sites/options.test.ts` (0 fail) ; `bunx tsc --noEmit` (0) ; biome.

- [ ] **Step 5 : commit**

```bash
git add src/lib/sites/options.ts src/lib/sites/options.test.ts
git commit -m "feat(sites): helpers siteLanguageOptions/siteCountryOptions (options du site actif)"
```

---

### Task 2 : Responsive — `DialogContent` base + rangée `add-keyword`

**Files:**
- Modify: `src/components/ui/dialog.tsx` (className de `DialogContent`)
- Modify: `src/components/keywords/add-keyword-dialog.tsx:128` (la rangée `flex gap-2`)

**Interfaces:** aucune (changements CSS/layout).

- [ ] **Step 1 : `DialogContent` base — hauteur bornée + scroll.** Dans `src/components/ui/dialog.tsx`, au `className` de base de `DialogContent` (là où sont `w-full max-w-[calc(100%-2rem)] ... sm:max-w-sm`), ajouter `max-h-[calc(100dvh-2rem)] overflow-y-auto`. Un contenu haut devient scrollable au lieu de déborder ; un contenu court n'est pas affecté (borne, pas hauteur fixe). Lis le fichier d'abord pour insérer au bon endroit sans casser les classes existantes.

- [ ] **Step 2 : rangée `add-keyword` responsive** — `src/components/keywords/add-keyword-dialog.tsx:128-149`. Passer `<div className="flex gap-2">` à `<div className="flex flex-col gap-2 sm:flex-row">`, et les deux wrappers `w-32` à `w-full sm:w-32`. Le `ThemeSelect className="flex-1"` reste. Résultat : empilé sur mobile, en ligne sur `sm+`.

- [ ] **Step 3 : vérifs** — `bunx tsc --noEmit` (0) ; biome sur les 2 fichiers. Vérification visuelle laissée à la fin de branche (d3k, viewport étroit + large).

- [ ] **Step 4 : commit**

```bash
git add src/components/ui/dialog.tsx src/components/keywords/add-keyword-dialog.tsx
git commit -m "fix(ui): DialogContent scrollable (max-h) + rangee add-keyword empilee mobile"
```

---

### Task 3 : Brancher les 4 dialogs sur le site actif

**Files:**
- Modify: `src/components/keywords/add-keyword-dialog.tsx`
- Modify: `src/components/keywords/import-keywords-dialog.tsx`
- Modify: `src/components/keywords/import-brief-dialog.tsx`
- Modify: `src/components/keywords/research/targeting-bar.tsx`

**Interfaces:**
- Consomme : `siteLanguageOptions`, `siteCountryOptions` (Task 1) ; `useActiveSite` (`src/lib/keywords/site-context.tsx`).

- [ ] **Step 1 : `add-keyword-dialog.tsx`.**
  - Ajouter `import { useActiveSite } from "@/lib/keywords/site-context";` et `import { siteLanguageOptions, siteCountryOptions } from "@/lib/sites/options";` (retirer l'import `COUNTRY_OPTIONS, LANGUAGE_OPTIONS`).
  - Dans le composant : `const { activeSite } = useActiveSite();`
  - Dériver les options (repli univers si pas de site) :

```tsx
const langOptions = activeSite ? siteLanguageOptions(activeSite) : LANGUAGE_OPTIONS;
const countryOptions = activeSite ? siteCountryOptions(activeSite) : COUNTRY_OPTIONS;
```

  (garder alors un import minimal `LANGUAGE_OPTIONS`/`COUNTRY_OPTIONS` pour le repli `!activeSite` — ou repli sur `[]`/premier ; le plus sûr = garder les 2 imports pour le fallback undefined.)
  - Défauts alignés sur le site : `const [lang, setLang] = useState(activeSite?.primaryLanguage ?? "en");` et `const [country, setCountry] = useState(activeSite?.targetCountries?.[0] ?? "");`. **Attention** : `activeSite` peut être `undefined` au premier render puis défini → utiliser un `useEffect` qui (re)pose lang/country quand `activeSite` arrive ET que le champ n'a pas été touché, OU initialiser via `useState(() => ...)` + resync dans `reset()`. Le plus simple et robuste : initialiser `lang`/`country` vides et, dans un `useEffect([activeSite, open])`, si `open` et non modifié, poser `primaryLanguage`/`targetCountries[0]`. Choisis l'approche la plus propre et documente.
  - `reset()` : `setLang(activeSite?.primaryLanguage ?? "en")`, `setCountry(activeSite?.targetCountries?.[0] ?? "")`.
  - Le fallback submit `lang.trim() || "fr"` / `country.trim() || "FR"` : remplacer les littéraux par `activeSite?.primaryLanguage ?? "en"` / `activeSite?.targetCountries?.[0] ?? "FR"`.
  - Les `<SingleCombobox options={...}>` utilisent `langOptions` / `countryOptions`.

- [ ] **Step 2 : `import-keywords-dialog.tsx`** (`:31` import, `:268` `options={LANGUAGE_OPTIONS}`). Ajouter `useActiveSite` + `siteLanguageOptions`. La LISTE langue passe à `activeSite ? siteLanguageOptions(activeSite) : LANGUAGE_OPTIONS`. Le `defaultLang` reçu en prop reste (déjà branché sur `primaryLanguage` par les pages) — vérifier qu'il est présent dans la liste restreinte (il l'est : c'est la langue principale).

- [ ] **Step 3 : `import-brief-dialog.tsx`** (`:17,23-26` `BRIEF_COUNTRY_OPTIONS`, `:154`). Ajouter `useActiveSite` + `siteCountryOptions`. Construire la liste dynamiquement dans le composant : `const briefCountryOptions = [AUTO_OPTION, ...(activeSite ? siteCountryOptions(activeSite) : COUNTRY_OPTIONS)];` (garder le sentinel `"auto"` de détection automatique en tête). Remplacer `BRIEF_COUNTRY_OPTIONS` (constante module) par cette dérivation locale. Lis le fichier pour le nom exact du sentinel/constante.

- [ ] **Step 4 : `targeting-bar.tsx`** (`:18` import, `:93-97`). Ajouter `useActiveSite` + `siteCountryOptions`. `options={activeSite ? siteCountryOptions(activeSite) : COUNTRY_OPTIONS}`. Retirer/mettre à jour le commentaire « diaspora, tous pays » (décision : restreint au site).

- [ ] **Step 5 : vérifier le montage** — les 4 dialogs sont montés dans l'arbre keywords sous `SiteProvider` (reservoir/recherche/planning containers l'incluent). Confirme par grep que `SiteProvider`/`useActiveSite` est bien disponible pour chacun (les toolbars et containers l'utilisent déjà). Si un dialog était monté hors provider, `useActiveSite` throw — vérifier.

- [ ] **Step 6 : vérifs** — `bunx tsc --noEmit` (0) ; biome sur les 4 fichiers ; `bun run test src/lib/sites/ src/components/keywords/` — noter les fails PRÉ-EXISTANTS (postgres/tenancy, mock global order-dependent), 0 nouvel échec.

- [ ] **Step 7 : commit**

```bash
git add src/components/keywords/add-keyword-dialog.tsx src/components/keywords/import-keywords-dialog.tsx src/components/keywords/import-brief-dialog.tsx src/components/keywords/research/targeting-bar.tsx
git commit -m "feat(keywords): dialogs de saisie synchronises sur langues/pays du site actif"
```

---

## Fin de branche

Review finale (diff `dev..HEAD`), puis merge ff-only dans `dev`. **Vérification visuelle d3k obligatoire** (le cœur de la demande) : ouvrir `/keywords/reservoir` → Importer → Ajouter un mot-clé, confirmer que le dropdown langue = langues du site (pas les 51), pays = targetCountries, défauts = principale/premier pays ; tester en viewport étroit (rangée empilée, dialog scrollable) et large.
