diff --git a/plan/fase-10-spec-completion.md b/plan/fase-10-spec-completion.md new file mode 100644 index 0000000..43c37fc --- /dev/null +++ b/plan/fase-10-spec-completion.md @@ -0,0 +1,281 @@ +### Fase 10 — Spec completion ("cerrar la laguna del `analisis-funcional`") + +**Estado: 📋 Planificada. Cubre las 8 piezas de "completion debt" detectadas en el audit del 2026-04-22: 3 CU de gestión de colectivo (H06/H07/H08), 3 afordances de contenido ya construido (trash restore, reset-from-detail, sidebar create-collective), eliminación de cuenta, y detección automática de idioma.** + +**Objetivo: llevar la paridad con `analysis/analisis-funcional.md` al 100% en el eje de gestión de colectivo y ciclo de vida de usuario/contenido — todo lo que los usuarios empiezan a pedir desde el primer día de uso real.** + +--- + +#### 10.1 CU-H06 — Abandonar colectivo + +**Fichero:** nueva RPC + botón en `/settings` → sección "Colectivos". + +**Regla clave (spec §2.3):** el usuario sale, el contenido permanece. Si es el único admin y hay otros miembros, disparar `promote_oldest_admin` antes de eliminar la fila de `collective_members`. Si es el único miembro, equivale a dissolve (bloquear y redirigir al usuario a H08). + +**Tareas:** + +- [ ] **10.1.1** Migración `supabase/migrations/017_leave_collective_rpc.sql`: + ```sql + create or replace function public.leave_collective(p_collective_id uuid) + returns void + language plpgsql + security definer + set search_path = public, auth + as $$ + declare + v_user uuid := auth.uid(); + v_is_last_member boolean; + begin + if v_user is null then raise exception 'not authenticated'; end if; + select count(*) = 1 into v_is_last_member + from collective_members where collective_id = p_collective_id; + if v_is_last_member then + raise exception 'cannot leave as sole member; dissolve instead' using errcode = 'P0001'; + end if; + delete from collective_members + where collective_id = p_collective_id and user_id = v_user; + -- el trigger de auto-promote cubre el caso "solo admin sale" + end; $$; + grant execute on function public.leave_collective(uuid) to authenticated; + ``` +- [ ] **10.1.2** UI: en `/settings` listar colectivos del usuario con botón "Abandonar" por fila. Modal de confirmación simple (un click). Si la RPC devuelve error `P0001`, mostrar "Eres el único miembro — usa 'Disolver' en lugar de abandonar" con enlace a H08. +- [ ] **10.1.3** Después del éxito: si el colectivo abandonado era el `$activeCollective`, hacer switch al más antiguo restante o redirect a `/onboarding` si queda sin colectivos. +- [ ] **10.1.4** Tests pgTAP (`010_leave_collective.sql`): abandonar como member normal OK; abandonar como único admin con otros miembros OK + promoción triggereada; abandonar como único miembro rechazado con errcode P0001. +- [ ] **10.1.5** Playwright `tests/e2e/leave-collective.test.ts`: L-01 Borja abandona el colectivo semilla → desaparece de `/collective/manage` desde la vista de Ana + Borja aterriza en `/onboarding`. + +--- + +#### 10.2 CU-H07 — Renombrar colectivo / cambiar emoji + +**Fichero:** `apps/web/src/routes/(app)/collective/manage/+page.svelte`. + +**Regla clave:** sólo `admin` puede editar. Las policies UPDATE ya lo permiten — sólo falta UI. + +**Tareas:** + +- [ ] **10.2.1** Input inline editable para `name` (click-to-edit, Enter para guardar, Esc para cancelar) — visible sólo si `currentRole === 'admin'`. +- [ ] **10.2.2** Picker de emoji al lado del avatar (reusar el emoji picker del `/onboarding`). +- [ ] **10.2.3** UPDATE directo por PostgREST (`supabase.from('collectives').update({ name, avatar_emoji }).eq('id', ...)`); error toasteado si RLS falla. +- [ ] **10.2.4** Reactividad: `$currentCollective` debe actualizarse en el store sin reload (subscribir a la respuesta del update). +- [ ] **10.2.5** Playwright: reactivar el test MC-01 dropped en Fase 7 — Ana renombra el colectivo, reload, nombre persiste; Borja (member) no ve el input editable. + +**Criterio de aceptación:** admin edita nombre+emoji sin reload; member/guest ven valores de sólo lectura. + +--- + +#### 10.3 CU-H08 — Disolver colectivo + +**Fichero:** nueva RPC con CASCADE explícito + modal con confirmación por nombre + botón en `/collective/manage`. + +**Regla clave (spec §2.3):** elimina el colectivo y **todo** su contenido permanentemente. Sólo admin. + +**Decisión (confirmada con el usuario):** **confirmación tipeando el nombre del colectivo** (no checkbox) — patrón GitHub/Vercel para operaciones destructivas. + +**Tareas:** + +- [ ] **10.3.1** Migración `supabase/migrations/018_dissolve_collective_rpc.sql`: + ```sql + create or replace function public.dissolve_collective(p_collective_id uuid) + returns void + language plpgsql + security definer + set search_path = public, auth + as $$ + declare + v_user uuid := auth.uid(); + v_role text; + begin + if v_user is null then raise exception 'not authenticated'; end if; + select role into v_role from collective_members + where collective_id = p_collective_id and user_id = v_user; + if v_role is distinct from 'admin' then + raise exception 'only admins can dissolve' using errcode = 'P0002'; + end if; + -- CASCADE: todas las FKs de contenido apuntan a collective_id con on delete cascade + delete from collectives where id = p_collective_id; + end; $$; + grant execute on function public.dissolve_collective(uuid) to authenticated; + ``` + **Pre-check obligatorio:** auditar que **todas** las tablas con `collective_id` tienen `on delete cascade`. En particular: `shopping_lists`, `shopping_items`, `task_lists`, `tasks`, `notes`, `collective_invitations`, `collective_members`, `trashed_lists` (si existe tabla separada), `sync_conflicts` (recién creada en Fase 9). Si alguna no lo tiene, migración adicional 019 para añadirlo antes de exponer la RPC. +- [ ] **10.3.2** UI: botón "Disolver colectivo" al final de `/collective/manage`, en zona "Peligro" con separador visual y color danger. Sólo visible para admin. +- [ ] **10.3.3** Modal de confirmación: + - Título: "¿Disolver [nombre]?". + - Texto: advertencia explícita ("Se borrarán N listas, M tareas, K notas. Esta acción es irreversible.") con counts reales leídos antes de abrir el modal. + - Input: "Escribe **``** para confirmar". + - Botón "Disolver permanentemente" disabled hasta que el input iguale exactamente el nombre (case-sensitive). +- [ ] **10.3.4** Post-éxito: limpiar `$currentCollective` y cualquier caché local, redirigir a `/onboarding` si era el único colectivo del usuario, o al siguiente más antiguo. +- [ ] **10.3.5** Tests pgTAP (`011_dissolve_collective.sql`): admin disuelve OK + contenido desaparece en cascada; member rechazado con P0002; guest rechazado. +- [ ] **10.3.6** Playwright `tests/e2e/dissolve-collective.test.ts`: D-01 Ana disuelve un colectivo auxiliar (creado en `beforeAll`, no el semilla!) tipeando el nombre → redirect + colectivo desaparece de la sidebar; D-02 botón "Disolver" permanece disabled mientras el input no matchee exactamente; D-03 Borja (member) no ve el botón "Disolver". + +**⚠️ Crítico:** el test semilla (`seed.sql`) NO debe ser disoluble por accidente en tests — crear colectivos auxiliares con fixture dedicado. Si se disuelve la semilla, toda la suite aguas abajo rompe. + +--- + +#### 10.4 Trash drawer — restore + hard delete + +**Fichero:** `apps/web/src/routes/(app)/lists/+page.svelte` (o el componente del drawer). + +**Estado actual:** el drawer ya muestra listas tachadas; sin botones. Triggers ya borran permanentemente a los 7 días. + +**Tareas:** + +- [ ] **10.4.1** Migración `supabase/migrations/020_restore_list_rpc.sql` — dos funciones: + ```sql + -- restore + create or replace function public.restore_list(p_list_id uuid) returns void + ... + update shopping_lists set deleted_at = null where id = p_list_id and ...rls check...; + + -- hard delete (antes del ttl de 7 días) + create or replace function public.hard_delete_list(p_list_id uuid) returns void + ... + delete from shopping_lists where id = p_list_id and deleted_at is not null and ...rls check...; + ``` + Ambas con `security definer` y guarda de membresía (member o admin del colectivo). +- [ ] **10.4.2** En cada fila del drawer: dos botones (icon-only con tooltip) — "Restaurar" y "Eliminar para siempre". +- [ ] **10.4.3** "Eliminar para siempre" abre un confirm simple (no hace falta el patrón de nombre — ya está soft-deleted, el daño potencial es menor). +- [ ] **10.4.4** Tests pgTAP (`012_trash_rpcs.sql`): restore de lista propia OK + `deleted_at` queda null; restore de lista ajena falla; hard delete de lista ya soft-deleted OK; hard delete de lista no soft-deleted falla (guardia). +- [ ] **10.4.5** Integración Vitest: simular borrado + restore, verificar que la lista reaparece en el listado activo. + +--- + +#### 10.5 Eliminación de cuenta (hard delete) + +**Decisión (confirmada con el usuario):** **hard delete, con nota informativa clara al usuario antes de confirmar** explicando qué se borra y qué se queda. + +**Regla clave (spec §6.3):** borrar una cuenta NO borra el contenido del colectivo; el contenido queda huérfano con `created_by` apuntando a una fila inexistente (protegido por `on delete set null` o equivalente). + +**Auditoría previa (no-trivial):** confirmar que `created_by`, `checked_by`, `completed_by` en las tablas relevantes tienen `on delete set null` y no `cascade`. Si alguna usa `cascade`, migración previa para cambiarlo. + +**Tareas:** + +- [ ] **10.5.1** Auditoría de FKs: grep de `references auth.users` y `references public.users` en todas las migraciones → clasificar comportamiento on-delete → migración `019_user_deletion_fks.sql` para normalizar a `set null` donde no lo esté. +- [ ] **10.5.2** Migración `021_delete_account_rpc.sql`: + ```sql + create or replace function public.delete_account() returns void + language plpgsql + security definer + set search_path = public, auth + as $$ + declare + v_user uuid := auth.uid(); + v_is_sole_admin_with_members boolean; + begin + if v_user is null then raise exception 'not authenticated'; end if; + -- bloquear si el usuario es único admin de un colectivo con otros miembros + -- y no hay member elegible para auto-promoción (ya hay trigger, pero blindar) + select exists( + select 1 from collective_members cm + where cm.user_id = v_user and cm.role = 'admin' + and (select count(*) from collective_members c2 where c2.collective_id = cm.collective_id) > 1 + and not exists ( + select 1 from collective_members c3 + where c3.collective_id = cm.collective_id + and c3.user_id <> v_user + and c3.role <> 'guest' + ) + ) into v_is_sole_admin_with_members; + if v_is_sole_admin_with_members then + raise exception 'sole admin with non-eligible members' using errcode = 'P0003'; + end if; + -- el trigger de promoción auto-asciende al member más antiguo cuando el admin sale + delete from auth.users where id = v_user; + -- CASCADE borra collective_members del usuario; created_by etc. van a null por la migración 019 + end; $$; + grant execute on function public.delete_account() to authenticated; + ``` +- [ ] **10.5.3** UI en `/settings` → zona "Peligro": + - Botón "Eliminar mi cuenta". + - Modal con **nota informativa explícita**: "Se eliminará permanentemente: tu cuenta, tus membresías en N colectivos, tu idioma/tema guardado. **NO** se eliminará: el contenido que hayas creado (listas, tareas, notas) — queda en los colectivos, atribuido a 'Usuario eliminado'. Si eres el único admin de algún colectivo, el miembro más antiguo será promovido automáticamente." + - Confirmación: escribir la palabra `ELIMINAR` para habilitar el botón. +- [ ] **10.5.4** Post-éxito: llamar a `logout()` (que dispara el flow RP-initiated a Keycloak) — la cuenta de GoTrue ya no existe, pero Keycloak sí. El `/logged-out` absorbe la redirección. + - **⚠️ decisión de scope:** NO borramos el usuario en Keycloak (requeriría admin API token y es siguiente nivel de trabajo). El usuario sigue pudiendo registrarse de nuevo con el mismo email y obtendrá una cuenta nueva (nuevo UUID). Documentar en la nota informativa como comportamiento conocido. +- [ ] **10.5.5** Tests pgTAP (`013_delete_account.sql`): 4 asserts — delete propio OK + fila de `auth.users` desaparece; sole-admin-con-members-no-elegibles rechazado con P0003; sole-admin-con-promotable OK + promoción triggereada; `created_by` en items del usuario borrado queda null. +- [ ] **10.5.6** Playwright `tests/e2e/account-deletion.test.ts`: DEL-01 crear usuario efímero, delete account, intento de login falla; DEL-02 contenido creado por el usuario borrado sigue visible en la UI para otros miembros, con autor "Usuario eliminado". + +--- + +#### 10.6 "Crear nuevo colectivo" desde la sidebar + +**Fichero:** dropdown de selector de colectivo en la sidebar + reuso de `create_collective()` RPC de migración 012. + +**Estado actual:** el switcher existe (cambio entre colectivos) pero no tiene entrada "+ Nuevo". Eva puede crear UNO en `/onboarding`, después está atascada. + +**Tareas:** + +- [ ] **10.6.1** Añadir entrada "+ Nuevo colectivo" al final del dropdown del sidebar. +- [ ] **10.6.2** Abre un modal con el mismo formulario de `/onboarding` (nombre + emoji); al éxito, switch automático al nuevo colectivo + redirect a `/lists`. +- [ ] **10.6.3** Recupera (por fin) el test O-04 que se dropeó en Fase 7: "Eva con colectivo activo crea un segundo → ambos aparecen en el switcher". + +**Criterio de aceptación:** usuarios con ≥ 1 colectivo pueden crear otros desde la sidebar sin salir a `/onboarding`. + +--- + +#### 10.7 Botón "Reiniciar lista" en vista de detalle + +**Fichero:** `apps/web/src/routes/(app)/lists/[id]/+page.svelte`. + +**Estado actual:** `resetList()` RPC existe; botón sólo aparece en `/lists` (overview). Desde la vista detalle no se puede resetear — UX roto: el usuario acaba de completar la lista, ve la pantalla de "lista completada", y tiene que volver al overview para reiniciarla. + +**Tareas:** + +- [ ] **10.7.1** Añadir botón "Reiniciar lista" en el estado "lista completada" de la vista detalle. +- [ ] **10.7.2** Llamar a la misma RPC `reset_list()`; al éxito, la vista vuelve al modo activo en el mismo renderer sin redirect. +- [ ] **10.7.3** Playwright: extender el test existente de reset para cubrir también el botón de la detalle view. + +--- + +#### 10.8 Detección automática de idioma + +**Fichero:** hook de servidor (`apps/web/src/hooks.server.ts` si existe) o `(app)/+layout.ts`. + +**Estado actual:** un usuario nuevo obtiene `users.language = 'en'` por default en el seed; no se inspecciona `Accept-Language` del navegador. + +**Tareas:** + +- [ ] **10.8.1** Al primer login (cuando `users.language IS NULL` o aún no existe la fila), leer `Accept-Language` del request. Si la cabecera contiene `es` (cualquier variante) con q-score ≥ 0.5, establecer `users.language = 'es'`; en cualquier otro caso, `'en'`. +- [ ] **10.8.2** Persistir UPDATE en `public.users` junto con el resto de la inicialización del usuario (ya existe ese path en post-login). +- [ ] **10.8.3** Fallback duro: si por cualquier motivo el parseo falla, default `'en'`. +- [ ] **10.8.4** Test unit: parser de `Accept-Language` con 6 casos (es, en, es-ES, es;q=0.9,en;q=0.8, en-US,es;q=0.3, vacío). +- [ ] **10.8.5** Test integración: simular login con header `Accept-Language: es-ES,es;q=0.9,en;q=0.5` para un usuario nuevo → `users.language` acaba en `'es'`. + +**Criterio de aceptación:** un hispanohablante aterriza en la UI en español sin tocar nada; `/settings` sigue permitiendo override manual. + +--- + +#### 10.Z Verificación + cierre + +- [ ] `just test-all` → 267 (baseline Fase 9) + deltas estimados (~18 nuevos: pgTAP ~14, Playwright ~8, unit/integration ~6) = **~285 verdes**, 3 skip (2 presencia + 1 rate-limit). +- [ ] `just rls-audit` limpio — las 4 nuevas RPCs + las modificaciones de FK no introducen holes. +- [ ] `just test-rate-limit` sigue pasando. +- [ ] CLAUDE.md actualizado: total tests + línea "Fase 10 complete: spec-completion (H06/H07/H08 + trash restore + account deletion + sidebar-create + reset-from-detail + Accept-Language)". +- [ ] `docs/history/fase-10-spec-completion.md` con: + - Diagrama de FKs on-delete por tabla (resultado de la auditoría 10.5.1) — referencia valiosa para futuros cambios. + - Decisión documentada: Keycloak NO se limpia al delete-account (y por qué). + - Nueva tabla total de cobertura del spec (objetivo: 100% de las CU-H*). + +--- + +### Riesgos / notas + +1. **Auditoría de on-delete FKs (10.5.1) puede destapar más migraciones de las previstas.** Si la mayoría de tablas usan `cascade` en lugar de `set null`, el blast radius de 10.5 se multiplica. Mitigación: hacer la auditoría como primera tarea de la fase (antes de escribir ningún código) y recalibrar scope si hace falta. + +2. **Verificación de CASCADE completo en dissolve (10.3.1).** Un colectivo sin borrado en cascada completo dejaría filas huérfanas que romperían RLS a los otros usuarios del colectivo. Mitigación: el pre-check listado es obligatorio; si falta alguna, bloquear el merge hasta añadirlo. + +3. **Race entre delete-account y sesión activa en otro dispositivo.** El usuario A borra su cuenta en el móvil; su pestaña de escritorio sigue abierta con JWT válido hasta que expire. Mientras, todas sus queries devolverán resultados "zombi" (PostgREST no revoca JWTs). Mitigación: documentar como comportamiento esperado — el JWT caduca en pocos minutos, el usuario en el otro dispositivo verá errores 401 al siguiente refresh. No es bloqueante para la fase. + +4. **Dissolve vs leave para último miembro.** 10.1.1 bloquea "leave" si el usuario es el único miembro, forzando a usar dissolve. Alternativa (más amigable): auto-convertir leave-as-sole-member en dissolve con confirmación extra. Decidido el primero por explicitud; reconsiderar si UX testing lo demanda. + +5. **Accept-Language + usuarios existentes.** La detección sólo aplica a filas nuevas; los usuarios ya registrados conservan su `users.language` actual (correcto — respeta su elección previa). + +6. **Test O-04 (rescatado en 10.6.3)** depende de que `resetEva()` borre también cualquier colectivo que Eva haya creado en el test anterior, no sólo las membresías. Revisar el helper `resetEva()` de Fase 7 (fichero `apps/web/tests/fixtures/db.ts`) antes de escribir O-04. + +--- + +### Scope explícito fuera + +- **No** se tocan features completamente nuevas de dominio (recurring lists, barcode scan, push notifications, recipes, budget). Eso es Fase 11+. +- **No** se re-introduce el campo estructurado de cantidad/unidad en items (regresión de migración 011). Esa decisión es producto, no completion debt. +- **No** se borra el usuario en Keycloak durante delete-account. Queda como deuda conocida documentada. +- **No** se migra el idioma detectado a `Accept-Language` para usuarios existentes — sólo altas nuevas. +- **No** se cambia el TTL de 7 días del trash — 10.4 sólo expone las afordances ya implícitas en los triggers existentes. diff --git a/plan/fase-9-polish-dark-mode.md b/plan/fase-9-polish-dark-mode.md new file mode 100644 index 0000000..d84b0a2 --- /dev/null +++ b/plan/fase-9-polish-dark-mode.md @@ -0,0 +1,142 @@ +### Fase 9 — Polish + Dark Mode + +**Estado: 📋 Planificada. Feature flagship: dark mode. Además de los tres pulidos pendientes heredados de Fase 5/6 y el wiring de `sync_conflicts` nunca conectado.** + +**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.