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>
5.9 KiB
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 planom.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 muestram.loading()cuandocreating=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 heredacurrentColorpara 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
delaydespué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" cuandocommonItemsLoadingotagsLoading)
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 enroute.fulfillcon delay 500ms) →[data-testid=spinner]visible mientras carga. - 16.3.2 SP-E-02 En
/collective/manage(admin), abrir modal "Add common item", clickarSavecon red throttleada → spinner visible en botón hasta resolución. - 16.3.3 SP-E-03 Auth splash: navegar a
/listssin sesión → spinner visible mientras Supabase resuelve la sesión.
16.Z Verificación + cierre
- 16.Z.1
just test-allverde. 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) —
currentColorhereda 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: rotateconwill-change: transformes barato; no debería ser un problema. Si lo es, bajar a 30fps conanimation-duration: 2sen lugar de1s(Tailwind defaultanimate-spines 1s). - CLS (Cumulative Layout Shift) — al reemplazar texto por spinner+texto, el ancho cambia. Si afecta a Lighthouse, fijar
min-widthdel contenedor. motion-reduce:animate-nonerequiere 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.