diff --git a/apps/web/src/lib/components/Spinner.svelte b/apps/web/src/lib/components/Spinner.svelte
new file mode 100644
index 0000000..cee78eb
--- /dev/null
+++ b/apps/web/src/lib/components/Spinner.svelte
@@ -0,0 +1,34 @@
+
+
+
diff --git a/apps/web/src/lib/components/Spinner.test.ts b/apps/web/src/lib/components/Spinner.test.ts
new file mode 100644
index 0000000..3e101f9
--- /dev/null
+++ b/apps/web/src/lib/components/Spinner.test.ts
@@ -0,0 +1,79 @@
+/**
+ * Fase 16 — Spinner component unit tests.
+ *
+ * Vitest + jsdom + Svelte 5's `mount()` API. There is no @testing-library/svelte
+ * in the repo (yet), but Svelte 5 exposes `mount`/`unmount` directly so we can
+ * inspect the rendered DOM without an extra dependency.
+ */
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { mount, unmount } from 'svelte';
+import Spinner from './Spinner.svelte';
+import * as m from '$lib/paraglide/messages';
+
+let host: HTMLElement;
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+let instance: any;
+
+function render(props: Record = {}) {
+ host = document.createElement('div');
+ document.body.appendChild(host);
+ instance = mount(Spinner, { target: host, props });
+ return host;
+}
+
+beforeEach(() => {
+ host = document.createElement('div');
+});
+
+afterEach(() => {
+ if (instance) unmount(instance);
+ if (host?.parentNode) host.parentNode.removeChild(host);
+});
+
+describe('Spinner component', () => {
+ it('SP-U-01: default size renders a 24x24 SVG', () => {
+ const root = render();
+ const svg = root.querySelector('svg[data-testid="spinner"]');
+ expect(svg).not.toBeNull();
+ expect(svg?.getAttribute('width')).toBe('24');
+ expect(svg?.getAttribute('height')).toBe('24');
+ });
+
+ it("SP-U-02: size='sm' renders 16x16 and size='lg' renders 40x40", () => {
+ const smRoot = render({ size: 'sm' });
+ const smSvg = smRoot.querySelector('svg[data-testid="spinner"]');
+ expect(smSvg?.getAttribute('width')).toBe('16');
+ expect(smSvg?.getAttribute('height')).toBe('16');
+ unmount(instance);
+ instance = null;
+ smRoot.remove();
+
+ const lgRoot = render({ size: 'lg' });
+ const lgSvg = lgRoot.querySelector('svg[data-testid="spinner"]');
+ expect(lgSvg?.getAttribute('width')).toBe('40');
+ expect(lgSvg?.getAttribute('height')).toBe('40');
+ });
+
+ it('SP-U-03: outer wrapper exposes role="status" and aria-live="polite"', () => {
+ const root = render();
+ const wrapper = root.querySelector('[role="status"]');
+ expect(wrapper).not.toBeNull();
+ expect(wrapper?.getAttribute('aria-live')).toBe('polite');
+ });
+
+ it("SP-U-04: includes an sr-only label sourced from Paraglide m.loading()", () => {
+ const root = render();
+ const srOnly = root.querySelector('.sr-only');
+ expect(srOnly).not.toBeNull();
+ // Paraglide's English default is "Loading…" — we don't hard-code it here,
+ // we compare against the message itself so an i18n switch doesn't break.
+ expect(srOnly?.textContent).toBe(m.loading());
+ });
+
+ it('SP-U-05: forwards the `class` prop onto the wrapper element', () => {
+ const root = render({ class: 'text-amber-500 ml-2' });
+ const wrapper = root.querySelector('[role="status"]');
+ expect(wrapper?.className).toContain('text-amber-500');
+ expect(wrapper?.className).toContain('ml-2');
+ });
+});
diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts
index a6c1733..9760ef3 100644
--- a/apps/web/vitest.config.ts
+++ b/apps/web/vitest.config.ts
@@ -16,9 +16,20 @@ export default defineConfig({
globals: true,
include: ['src/**/*.test.ts'],
exclude: ['tests/**', 'node_modules/**', '.svelte-kit/**'],
- setupFiles: ['./vitest.setup.ts']
+ setupFiles: ['./vitest.setup.ts'],
+ // Force Svelte to load its browser entry (index-client.js) instead of
+ // the server entry — `mount()` only exists on the client. Without this
+ // any test that mounts a Svelte 5 component throws
+ // `lifecycle_function_unavailable`. jsdom + the `browser` condition is
+ // the canonical Svelte 5 unit-test setup.
+ server: {
+ deps: {
+ inline: [/^svelte/]
+ }
+ }
},
resolve: {
+ conditions: ['browser'],
alias: {
$lib: new URL('./src/lib', import.meta.url).pathname
}