diff --git a/frontend/src/lib/shared/primitives/Card.svelte b/frontend/src/lib/shared/primitives/Card.svelte new file mode 100644 index 00000000..7ff0fe39 --- /dev/null +++ b/frontend/src/lib/shared/primitives/Card.svelte @@ -0,0 +1,84 @@ + + +
+ {#if caption} + +

+ {caption} +

+ {/if} + + {#if children} + {@render children()} + {/if} +
diff --git a/frontend/src/lib/shared/primitives/Card.svelte.spec.ts b/frontend/src/lib/shared/primitives/Card.svelte.spec.ts new file mode 100644 index 00000000..d1ca81c1 --- /dev/null +++ b/frontend/src/lib/shared/primitives/Card.svelte.spec.ts @@ -0,0 +1,199 @@ +/** + * Card.svelte.spec.ts + * + * RED-first: written before Card.svelte exists. + * Tests all three accent variants, padding values, radius, section-caption helper, + * fallback for invalid accent props, and dark-mode token correctness. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { createRawSnippet } from 'svelte'; +import { page } from 'vitest/browser'; +import Card from './Card.svelte'; + +afterEach(() => cleanup()); + +describe('Card', () => { + // ── Rendering ────────────────────────────────────────────────────────────── + + it('renders children via snippet slot', async () => { + const children = createRawSnippet(() => ({ + render: () => `Archival content`, + setup: () => {} + })); + render(Card, { props: { children } }); + await expect.element(page.getByText('Archival content')).toBeInTheDocument(); + }); + + it('has data-testid="card" on the root element', async () => { + render(Card); + await expect.element(page.getByTestId('card')).toBeInTheDocument(); + }); + + // ── Base classes ─────────────────────────────────────────────────────────── + + it('has bg-surface token class', async () => { + render(Card); + const el = document.querySelector('[data-testid="card"]'); + expect(el?.className).toContain('bg-surface'); + }); + + it('has border-line token class', async () => { + render(Card); + const el = document.querySelector('[data-testid="card"]'); + expect(el?.className).toContain('border-line'); + }); + + it('has shadow-sm class', async () => { + render(Card); + const el = document.querySelector('[data-testid="card"]'); + expect(el?.className).toContain('shadow-sm'); + }); + + it('has rounded-sm class (2px radius)', async () => { + render(Card); + const el = document.querySelector('[data-testid="card"]'); + expect(el?.className).toContain('rounded-sm'); + }); + + // ── Accent: top (default) ───────────────────────────────────────────────── + + it('renders "top" accent variant by default', async () => { + render(Card); + const el = document.querySelector('[data-testid="card"]') as HTMLElement; + expect(el?.dataset.accent).toBe('top'); + }); + + it('applies top accent border-top style via var(--c-accent)', async () => { + render(Card, { props: { accent: 'top' } }); + const el = document.querySelector('[data-testid="card"]') as HTMLElement; + const style = el?.getAttribute('style') ?? ''; + // The top accent is delivered as an inline style using var(--c-accent) + expect(style).toContain('var(--c-accent)'); + expect(style).toContain('border-top'); + }); + + // ── Accent: left ────────────────────────────────────────────────────────── + + it('renders "left" accent variant correctly', async () => { + render(Card, { props: { accent: 'left' } }); + const el = document.querySelector('[data-testid="card"]') as HTMLElement; + expect(el?.dataset.accent).toBe('left'); + }); + + it('applies left accent border-left style via var(--c-accent)', async () => { + render(Card, { props: { accent: 'left' } }); + const el = document.querySelector('[data-testid="card"]') as HTMLElement; + const style = el?.getAttribute('style') ?? ''; + expect(style).toContain('var(--c-accent)'); + expect(style).toContain('border-left'); + }); + + // ── Accent: none ────────────────────────────────────────────────────────── + + it('renders "none" accent variant correctly', async () => { + render(Card, { props: { accent: 'none' } }); + const el = document.querySelector('[data-testid="card"]') as HTMLElement; + expect(el?.dataset.accent).toBe('none'); + }); + + it('does NOT apply accent inline style when accent="none"', async () => { + render(Card, { props: { accent: 'none' } }); + const el = document.querySelector('[data-testid="card"]') as HTMLElement; + const style = el?.getAttribute('style') ?? ''; + // No border-top or border-left with var(--c-accent) when accent is none + expect(style).not.toContain('var(--c-accent)'); + }); + + // ── Fallback for invalid accent ──────────────────────────────────────────── + + it('falls back to "top" for an unknown accent value', async () => { + // @ts-expect-error — intentionally passing invalid prop to test runtime fallback + render(Card, { props: { accent: 'invalid-value' } }); + const el = document.querySelector('[data-testid="card"]') as HTMLElement; + expect(el?.dataset.accent).toBe('top'); + }); + + // ── Padding ─────────────────────────────────────────────────────────────── + + it('defaults to padding="md" (24px)', async () => { + render(Card); + const el = document.querySelector('[data-testid="card"]') as HTMLElement; + expect(el?.dataset.padding).toBe('md'); + }); + + it('applies p-6 (24px) class for padding="md"', async () => { + render(Card, { props: { padding: 'md' } }); + const el = document.querySelector('[data-testid="card"]') as HTMLElement; + expect(el?.className).toContain('p-6'); + }); + + it('applies p-5 (20px) class for padding="sm"', async () => { + render(Card, { props: { padding: 'sm' } }); + const el = document.querySelector('[data-testid="card"]') as HTMLElement; + expect(el?.className).toContain('p-5'); + }); + + // ── Section-caption helper ───────────────────────────────────────────────── + + it('does NOT render a caption element when caption prop is absent', async () => { + render(Card); + const caption = document.querySelector('[data-testid="card-caption"]'); + expect(caption).toBeNull(); + }); + + it('renders the section-caption helper when caption text is provided', async () => { + render(Card, { props: { caption: 'Briefkorrespondenz' } }); + await expect.element(page.getByTestId('card-caption')).toBeInTheDocument(); + }); + + it('caption has font-sans Montserrat token class', async () => { + render(Card, { props: { caption: 'Dokumente' } }); + const el = document.querySelector('[data-testid="card-caption"]') as HTMLElement; + expect(el?.className).toContain('font-sans'); + }); + + it('caption has text-ink-3 token class', async () => { + render(Card, { props: { caption: 'Personen' } }); + const el = document.querySelector('[data-testid="card-caption"]') as HTMLElement; + expect(el?.className).toContain('text-ink-3'); + }); + + it('caption has uppercase class', async () => { + render(Card, { props: { caption: 'Übersicht' } }); + const el = document.querySelector('[data-testid="card-caption"]') as HTMLElement; + expect(el?.className).toContain('uppercase'); + }); + + it('caption has font-bold class (700 weight)', async () => { + render(Card, { props: { caption: 'Briefwechsel' } }); + const el = document.querySelector('[data-testid="card-caption"]') as HTMLElement; + expect(el?.className).toContain('font-bold'); + }); + + it('renders caption text content', async () => { + render(Card, { props: { caption: 'Zeitstrahl' } }); + await expect.element(page.getByText('Zeitstrahl')).toBeInTheDocument(); + }); + + // ── Dark-mode token contract ─────────────────────────────────────────────── + + it('accent uses var(--c-accent) token — never raw hex — for dark-mode compatibility', async () => { + render(Card, { props: { accent: 'top' } }); + const el = document.querySelector('[data-testid="card"]') as HTMLElement; + const style = el?.getAttribute('style') ?? ''; + // Must use the CSS variable, not any hardcoded hex color + expect(style).toContain('var(--c-accent)'); + expect(style).not.toMatch(/#[0-9a-fA-F]{3,6}/); + }); + + it('no raw Tailwind color class (e.g. green-*, blue-*) on card element', async () => { + render(Card, { props: { accent: 'top' } }); + const el = document.querySelector('[data-testid="card"]') as HTMLElement; + const cls = el?.className ?? ''; + // Check for raw Tailwind palette colors (bg-green-*, border-blue-*, etc.) + expect(cls).not.toMatch( + /\b(bg|border|text)-(red|green|blue|yellow|purple|pink|indigo|gray|slate|zinc|stone|orange|amber|lime|emerald|teal|cyan|sky|violet|fuchsia|rose)-\d+/ + ); + }); +});