### Fase 9 — Polish + Dark Mode **MVP2 · 1/8.** Primera fase del ciclo MVP2 (rama `mvp2`, fases 9 → 16). **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: `` 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) / )`, 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 `` 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.