Files
collective-lists/plan/fase-16-spinner.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

5.9 KiB

Fase 16 — Loading spinner (visual feedback)

MVP2 · 8/8. Añadida después del cierre de Fase 15 para cubrir la falta de feedback visual durante operaciones asíncronas.

Estado: 📋 Planificada. Atiende una petición concreta: cuando algo carga, hoy se muestra solo texto Loading…. Reemplazar por un círculo animado simple y reutilizable.

Objetivo: un componente Spinner minimal (SVG circle + animación CSS), integrado en los 5 sitios actuales que ya muestran m.loading() o un estado loading. Nada de skeletons, nada de transiciones complejas. La UX es: si tarda >100ms, hay un círculo girando. Punto.


Estado actual (auditado 2026-05-18):

  • apps/web/src/routes/(app)/+layout.svelte:151 — splash de auth con <span>{m.loading()}</span>, sin indicador visual.
  • apps/web/src/routes/(app)/search/+page.svelte:122 — texto plano m.loading() durante búsqueda.
  • apps/web/src/routes/(app)/notes/archive/+page.sveltelet loading = $state(true), sin spinner.
  • apps/web/src/lib/components/CreateCollectiveModal.svelte:109 — botón muestra m.loading() cuando creating=true, sin spinner.
  • Stores con flag dedicado: authLoading, notesLoading, tagsLoading, commonItemsLoading. Buena base para integración futura.

Decisiones clave:

  • SVG + CSS keyframes, no dependencias. Stroke-dasharray con animación rotate(360deg) infinita.
  • Props: size = 'sm' | 'md' | 'lg' (16 / 24 / 40 px), class = '' para Tailwind overrides. Color hereda currentColor para integrar con el theme system (Fase 9).
  • Render bloque: <div role="status" aria-live="polite"> con <span class="sr-only">{m.loading()}</span> para a11y.
  • Sin prefers-reduced-motion: la animación queda pausada (animation: none) para usuarios que lo prefieran.
  • Sin delay de aparición ("solo si >100ms"); el componente aparece cuando lo monta el llamador. Si UX se queja, añadir prop delay después.

16.1 Componente

Fichero: nuevo apps/web/src/lib/components/Spinner.svelte.

<script lang="ts">
  import * as m from '$lib/paraglide/messages';

  interface Props {
    size?: 'sm' | 'md' | 'lg';
    class?: string;
  }
  let { size = 'md', class: extra = '' }: Props = $props();

  const px = size === 'sm' ? 16 : size === 'lg' ? 40 : 24;
</script>

<div role="status" aria-live="polite" class={extra}>
  <svg
    data-testid="spinner"
    width={px}
    height={px}
    viewBox="0 0 24 24"
    fill="none"
    class="animate-spin motion-reduce:animate-none"
  >
    <circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2.5"
            stroke-linecap="round" stroke-dasharray="42 14" opacity="0.75" />
  </svg>
  <span class="sr-only">{m.loading()}</span>
</div>

Tareas:

  • 16.1.1 Crear apps/web/src/lib/components/Spinner.svelte.
  • 16.1.2 Test unit apps/web/src/lib/components/Spinner.test.ts:
    • SP-U-01 default size renders 24x24 SVG.
    • SP-U-02 size='sm' → 16x16; size='lg' → 40x40.
    • SP-U-03 role="status" + aria-live="polite" presentes.
    • SP-U-04 sr-only message reads from Paraglide m.loading().
    • SP-U-05 extra class prop forwarded a <div> raíz.

16.2 Integraciones

Ficheros:

  • apps/web/src/routes/(app)/+layout.svelte (splash de auth)
  • apps/web/src/routes/(app)/search/+page.svelte (búsqueda)
  • apps/web/src/routes/(app)/notes/archive/+page.svelte (carga inicial)
  • apps/web/src/lib/components/CreateCollectiveModal.svelte (botón creating)
  • apps/web/src/routes/(app)/collective/manage/+page.svelte (sub-secciones "Items frecuentes" + "Etiquetas" cuando commonItemsLoading o tagsLoading)

Tareas:

  • 16.2.1 En cada uno, reemplazar el texto m.loading() por <Spinner size="sm" /> <span>{m.loading()}</span> (mantener el texto como fallback para SR + tooltip).
  • 16.2.2 Splash de auth: <Spinner size="lg" /> centrado, con label opcional.
  • 16.2.3 Botón modal creating: spinner inline (size="sm") antes del texto.
  • 16.2.4 No tocar otros sitios sin estado de loading (no añadir loading donde antes no había).

16.3 Tests E2E

Fichero: nuevo apps/web/tests/e2e/spinner.test.ts.

  • 16.3.1 SP-E-01 En /search, escribir consulta lenta (throttle red en route.fulfill con delay 500ms) → [data-testid=spinner] visible mientras carga.
  • 16.3.2 SP-E-02 En /collective/manage (admin), abrir modal "Add common item", clickar Save con red throttleada → spinner visible en botón hasta resolución.
  • 16.3.3 SP-E-03 Auth splash: navegar a /lists sin sesión → spinner visible mientras Supabase resuelve la sesión.

16.Z Verificación + cierre

  • 16.Z.1 just test-all verde. Suma esperada: +5 unit + 3 e2e = +8.
  • 16.Z.2 A11y manual: VoiceOver / NVDA leen el sr-only m.loading(). Motion reduce respetada.
  • 16.Z.3 Smoke visual en dark mode (Fase 9) — currentColor hereda bien.
  • 16.Z.4 Documentar en docs/history/fase-16-spinner.md.
  • 16.Z.5 No nuevas strings Paraglide (reutiliza m.loading() existente).

Riesgos / notas

  • Animación a 60fps en mobile bajo carga — un transform: rotate con will-change: transform es barato; no debería ser un problema. Si lo es, bajar a 30fps con animation-duration: 2s en lugar de 1s (Tailwind default animate-spin es 1s).
  • CLS (Cumulative Layout Shift) — al reemplazar texto por spinner+texto, el ancho cambia. Si afecta a Lighthouse, fijar min-width del contenedor.
  • motion-reduce:animate-none requiere Tailwind v3+. Repo ya lo usa.

Scope explícito fuera

  • Skeletons (placeholder con shape del contenido). Solo spinner.
  • Progress bars determinísticas. Solo indeterminado.
  • Toasts/notifications de "loading". Solo inline.
  • Spinner overlay full-screen. Solo inline en el sitio del trigger.
  • Integración en stores que aún no tienen flag dedicado (lists, tasks). Fuera de scope hasta que se pida.