### Fase 9 — Polish + Dark Mode

**MVP2 · 1/6.** Primera fase del ciclo MVP2 (rama `mvp2`, fases 9 → 14).

**Estado: ✅ Completada (2026-05-18). Ver `docs/history/fase-9-polish-dark-mode.md` para detalles + paleta definitiva. 41 tests nuevos: 8 user_theme pgTAP, 12 sync_conflicts pgTAP, 4 sync-conflicts integración, 3 SC unit, 8 theme unit, 3 theme E2E, 3 reading-width E2E. WebKit specs (2 nuevos) gated en `RUN_WEBKIT=1`.**

**Objetivo: estabilizar la experiencia visual con un tema oscuro de primera clase + cerrar cuatro pulidos que se quedaron fuera del MVP sin añadir deuda técnica nueva. No se tocan features de dominio — eso queda para Fase 10.**

**Presencia de avatares en modo-compra (polish #5 del brainstorm): CONFIRMADO PARKED.** Auditoría upstream del 2026-04-23:
- Última versión estable: `supabase/realtime v2.86.3` (2026-04-21).
- Bug [#1617](https://github.com/supabase/realtime/issues/1617) (`handle_out/3 is undefined` + Presence crashes) sigue abierto desde 2025-11-14; ninguna release entre v2.76.5 y v2.86.3 lo arregla.
- Los 2 tests de presencia siguen `.skip`. Revisitar cuando #1617 se cierre.

Se documenta la auditoría en `docs/history/fase-9-polish-dark-mode.md` al cierre para que la próxima persona que encuentre los tests saltados no repita la investigación.

---

#### 9.1 Dark mode — feature flagship

**Fichero principal:** `apps/web/src/app.css` + `apps/web/tailwind.config.cjs` + nuevo toggle en `/settings`.

**Decisiones de diseño:**
- Tres modos: `light` | `dark` | `system`. Default en usuarios nuevos: `system`.
- Persistencia por usuario: nueva columna `users.theme text not null default 'system' check (theme in ('light','dark','system'))` (migración 015).
- Aplicación: `<html data-theme="...">` gestionado en `(app)/+layout.svelte` a partir de `currentUser.theme` + `window.matchMedia('(prefers-color-scheme: dark)')` cuando `system`.
- Paleta: triplets RGB en `app.css` bajo `:root[data-theme=light]` y `:root[data-theme=dark]` — **respetar la gotcha existente**: tokens como triplets espaciados (`51 65 85`) y consumidos con `rgb(var(--token) / <alpha>)`, nunca `hsl()`.
- Transición: sin animación en el cambio (evita flash/jank); el `data-theme` se escribe síncronamente antes del primer paint en `app.html` (inline script de ~5 líneas leyendo `localStorage.getItem('theme')` para evitar FOUC en refresh).

**Tareas:**

- [ ] **9.1.1** Migración `supabase/migrations/015_user_theme.sql` — añade columna `users.theme` con default `'system'` + check constraint.
- [ ] **9.1.2** Regenerar `packages/types/src/database.ts` vía `just db-types`.
- [ ] **9.1.3** Definir paleta oscura en `apps/web/src/app.css`:
  - Tokens base: `--color-bg`, `--color-surface`, `--color-border`, `--color-text`, `--color-text-muted`, `--color-accent`, `--color-danger`, `--color-success`.
  - Contraste mínimo WCAG AA (4.5:1 para texto normal, 3:1 para UI chrome).
  - Revisión componente a componente en `$lib/components/` — todo lo que hardcodea un `slate-*` específico pasa a token.
- [ ] **9.1.4** Script anti-FOUC en `apps/web/src/app.html` — lee `localStorage.theme` (fallback `'system'`) y `matchMedia` antes del primer render.
- [ ] **9.1.5** `ThemeToggle.svelte` en `$lib/components` — tres opciones (`light` / `dark` / `system`); onChange escribe localStorage inmediatamente + dispara UPDATE a `users.theme` en background.
- [ ] **9.1.6** Añadir sección "Apariencia" en `/settings/+page.svelte` con el toggle.
- [ ] **9.1.7** Reactividad: `(app)/+layout.svelte` observa `$currentUser.theme` y `prefers-color-scheme` (listener `change`) y actualiza `data-theme` sin reload.
- [ ] **9.1.8** Tests:
  - Unit: `ThemeToggle.test.ts` — las tres opciones escriben el valor correcto en localStorage + `data-theme`.
  - Integración: `theme.spec.ts` (Vitest con `@testing-library`) — cambio de `users.theme` se refleja en el DOM sin reload.
  - Playwright: `tests/e2e/theme.test.ts` — 3 tests: T-01 toggle persiste tras reload; T-02 `system` respeta `prefers-color-scheme` emulado; T-03 refresh en modo `dark` no produce flash blanco (screenshot comparativo del primer frame).

**Criterio de aceptación:** los 3 modos funcionan sin reload, persisten entre sesiones, respetan `prefers-color-scheme` cuando `system`, sin FOUC en refresh. Paleta oscura pasa WCAG AA en `/lists`, `/list/[id]` (modo compra incluido), `/tasks`, `/notes`, `/settings`, `/collective/manage`, `/onboarding`, `/invitation/[token]`, `/logged-out`.

---

#### 9.2 Ancho de lectura en desktop — `max-w-2xl`

**Fichero:** `apps/web/src/routes/(app)/+layout.svelte` (o wrapper equivalente).

**Tareas:**

- [ ] **9.2.1** Aplicar `max-w-2xl mx-auto` al contenedor principal de las vistas de contenido (`/lists/[id]`, `/tasks/[id]`, `/notes/[id]`). Breakpoint: `md:` en adelante; móvil sigue full-width.
- [ ] **9.2.2** Verificar que el modo compra sigue ocupando todo el viewport en desktop (shopping session es excepción — inmersiva).
- [ ] **9.2.3** Manual check: resize 320px → 1920px sin regresiones de layout en las 5 rutas tocadas.

**Criterio de aceptación:** desktop ≥ `md` muestra contenido en columna central; móvil sin cambios; modo compra sigue full-width.

---

#### 9.3 Wiring de `sync_conflicts`

**Fichero:** `apps/web/src/lib/sync/queue.ts` + nueva migración + panel debug en `/settings`.

**Estado actual:** la tabla está referenciada en el header del fichero (comentario de diseño) pero nunca se creó ni se escribe. Queda como "advisory only" sin nada que aconsejar.

**Tareas:**

- [ ] **9.3.1** Migración `supabase/migrations/016_sync_conflicts.sql` — tabla:
  ```sql
  create table public.sync_conflicts (
    id uuid primary key default gen_random_uuid(),
    user_id uuid not null references auth.users(id) on delete cascade,
    collective_id uuid references public.collectives(id) on delete cascade,
    entity_type text not null,         -- 'shopping_item' | 'task' | 'note' | ...
    entity_id uuid not null,
    local_version jsonb not null,
    remote_version jsonb not null,
    resolution text not null,          -- 'remote_won' | 'local_won'
    created_at timestamptz not null default now()
  );
  ```
  RLS: `select` sólo por `auth.uid() = user_id`; `insert` idem. Sin update/delete (append-only).
- [ ] **9.3.2** En `queue.ts`, cuando el merge last-write-wins descarta una versión local, insertar fila en `sync_conflicts` con ambos payloads (best-effort; fallo de inserción no bloquea el sync).
- [ ] **9.3.3** Panel debug en `/settings` (sólo visible en dev o tras toggle secreto): tabla con los últimos 20 conflictos del usuario, botón "Exportar JSON".
- [ ] **9.3.4** Tests pgTAP (`009_sync_conflicts_rls.sql`): 4 asserts — insert propio OK, select propio OK, select ajeno bloqueado, update/delete bloqueado.
- [ ] **9.3.5** Test integración: simular conflicto (dos clientes escriben al mismo item offline, mergear) → verifica fila en `sync_conflicts`.

**Criterio de aceptación:** los conflictos reales (no los simulados en dev) se loguean sin afectar el flujo; panel debug visible con los últimos 20 del usuario actual.

---

#### 9.4 Swipe-delete E2E en WebKit

**Estado actual:** 2 tests `.skip` en `apps/web/tests/e2e/mobile.test.ts` porque Chromium emula mal el touch; lógica de swipe ya funciona en iOS real (validada manualmente en Fase 5).

**Tareas:**

- [ ] **9.4.1** Añadir `npx playwright install webkit` al `just playwright-install` (Justfile) y al CI.
- [ ] **9.4.2** Anotar los tests para correr **sólo en WebKit** (`test.describe.parallel` con project filter) — no reactivar en Chromium.
- [ ] **9.4.3** Desactivar `.skip` de los 2 tests afectados.
- [ ] **9.4.4** Validar en local (`just test-e2e --project=webkit`) y en CI.

**Criterio de aceptación:** 2 tests previamente saltados pasan en WebKit; no se añaden nuevos saltos.

---

#### 9.Z Verificación + cierre

- [ ] `just test-all` → 259 + deltas estimados (~8 nuevos: 3 dark-mode Playwright + 1 integración + 1 unit + 2 swipe-delete desaltados + 1 sync_conflicts integración) = **267 verdes**, 3 skip (2 presencia upstream + 1 rate-limit gated).
- [ ] `just test-rate-limit` sigue pasando.
- [ ] Lighthouse (`just lighthouse`) no cae más de 2 puntos en modo oscuro vs claro.
- [ ] CLAUDE.md actualizado: total tests + línea "Fase 9 complete: dark mode + polish".
- [ ] `docs/history/fase-9-polish-dark-mode.md` escrito con:
  - Paleta oscura definitiva (hex + motivación).
  - Auditoría upstream de `#1617` (para que no se repita la investigación).
  - Nota de migración: los usuarios existentes obtienen `theme='system'` por defecto.

---

### Riesgos / notas

1. **Paleta oscura es trabajo manual tedioso — riesgo de olvidar componentes.** Mitigación: grep exhaustivo de `slate-|gray-|zinc-|white|black` en `apps/web/src/**/*.svelte` antes del PR; cada hit o migra a token o se justifica en comentario.

2. **FOUC en `/logged-out`.** Esa ruta vive fuera de `(app)` y no tiene acceso a `$currentUser`. El inline script de `app.html` la cubre igualmente porque lee `localStorage` directamente — verificar en el test T-03 con esta ruta también.

3. **`users.theme` añadida mientras hay sesiones activas en prod.** El JWT cachea la fila de `auth.users`, no `public.users`; el UPDATE a `public.users.theme` se lee por query, no del token → no requiere re-login. Documentar de todas formas.

4. **`sync_conflicts` tiene `collective_id` nullable.** Se deja nullable porque algunos conflictos (p.ej. cambio de `users.theme` — no aplica aquí pero como principio general) no son scoped a colectivo. Si siempre va a ser non-null en la práctica, se puede apretar más adelante; peor apretarlo demasiado pronto.

5. **Dark mode en PNG/SVG del onboarding/empty states.** Si hay ilustraciones con fondo blanco fijo, se verán raras en oscuro. Revisar `apps/web/static/` + cualquier `<img>` inline durante 9.1.3.

---

### Scope explícito fuera

- **No** se tocan features de dominio (leave/rename/dissolve collective, account deletion, trash restore, reset-from-detail, Accept-Language) — todo eso es Fase 10.
- **No** se añade presencia de avatares (bloqueado upstream; documentado arriba).
- **No** se reintroduce campo estructurado de cantidad/unidad en items — eso es trabajo de producto, no polish.
- **No** se cambia la tipografía ni el spacing base — sólo color y ancho de lectura.
- **No** se persigue un dark mode "perfecto" en screenshots de la página `/onboarding` ilustrada si hay arte estático: documentar deuda y seguir.
