docs(plan): fase-9 (polish + dark mode) and fase-10 (spec completion)
Fase 9 bundles dark mode as the flagship feature plus the four polish items parked from Fase 5/6 (sync_conflicts wiring, presence avatars still blocked on supabase/realtime #1617). Fase 10 closes the completion debt found in the 2026-04-22 audit: CU-H06/H07/H08 (leave/dissolve/transfer admin), trash restore, reset-from-detail, sidebar create-collective, account deletion, and automatic language detection. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
281
plan/fase-10-spec-completion.md
Normal file
281
plan/fase-10-spec-completion.md
Normal file
@@ -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 **`<nombre del colectivo>`** 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.
|
||||||
142
plan/fase-9-polish-dark-mode.md
Normal file
142
plan/fase-9-polish-dark-mode.md
Normal file
@@ -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: `<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.
|
||||||
Reference in New Issue
Block a user