### 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 `{m.loading()}`, 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.svelte` — `let 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: `
` con `{m.loading()}` 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`. ```svelte
{m.loading()}
``` **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 `
` 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 ` {m.loading()}` (mantener el texto como fallback para SR + tooltip). - [ ] **16.2.2** Splash de auth: `` 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.