Files
collective-lists/plan/fase-9-polish-dark-mode.md
Oier Bravo Urtasun 51e62ecba3 docs(plan): add Fase 16 — loading spinner (MVP2 reopened to 8 fases)
Adds a tiny planned-phase doc for visual feedback during async operations.

- Single SVG-circle Spinner.svelte component, three sizes (sm/md/lg = 16
  / 24 / 40 px), color via currentColor (theme-aware), motion-reduce-aware,
  role="status" + sr-only m.loading() for a11y.
- Integration at 5 existing sites that already render m.loading() text or
  own a `loading` flag: auth splash, /search, /notes/archive,
  CreateCollectiveModal "creating" state, /collective/manage sub-sections.
- 5 unit + 3 e2e tests planned. No DB migration. No new Paraglide strings.

Rebrand: MVP2 reopens from 7/7  to in-progress 7/8 . Headers in fases
9–15 bump "·N/7" + "fases 9 → 15" to "·N/8" + "fases 9 → 16". README +
CLAUDE.md updated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 13:34:58 +02:00

10 KiB

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 (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:
    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.