feat(theming): forest variant from @mana/themes (sprint 9m)

Cards is the first app on the new 12-token mana-vereinsweite
theming system (mana/docs/THEMING.md). Forest-Variant aus
@mana/themes/variants/forest.css konsumiert via app.css-Import,
data-theme="forest" in app.html.

Token-Welt umgestellt — 158 renames + 304 hsl-wraps in 17 Files
(Python-Refactor, BSD-sed war zu unzuverlässig):
- --color-bg          → --color-background
- --color-fg          → --color-foreground
- --color-muted       → --color-muted-foreground
- --color-primary-fg  → --color-primary-foreground
- --color-danger      → --color-error
- bare var(--color-X) → hsl(var(--color-X)) durchgängig

Bridge-Aliase in app.css mappen die shared-ui@0.1.x-Erwartungen
(card, accent, surface-elevated-*, …) auf das 12er-Set. Mit
shared-ui@2.0-Refactor entfällt diese Sektion. --brand-cards-forest
als App-Identitäts-Hex separiert von Theme-Tokens.

Header konsumiert PillTabGroup aus @mana/shared-ui@0.1.1 für die
Routen-Navigation (Decks/Lernen/Library/Import/Stats) und den
DE/EN-Sprach-Switcher — visuell konsistent mit Vereins-Standard.

Cards' primary-Grün wurde dabei von 142 76% 36% (alter Live-Stand)
auf 142 76% 28% verdunkelt, damit primary-foreground/primary-
Kontrast WCAG-AA-konform (≥4.5) ist. Der alte Live-Stand hatte
Ratio 3.35.

i18n: deck_stack.aria_label, deck_detail.fan_aria, deck_detail.
card_open, decks.card_count_more, study_session.manage_link.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-09 18:01:37 +02:00
parent 404ddec62d
commit 19a0036b82
20 changed files with 323 additions and 261 deletions

View file

@ -84,14 +84,14 @@
}
</script>
<div class="rounded-xl border border-[var(--color-border)] bg-[var(--color-card)] p-4">
<div class="rounded-xl border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4">
<div class="mb-2 text-sm font-medium">{t('import.anki_label')}</div>
{#if stage === 'idle'}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="cursor-pointer rounded-lg border-2 border-dashed border-[var(--color-border)] px-4 py-6 text-center text-sm text-[var(--color-muted)] transition-colors hover:border-[var(--color-primary)] hover:text-[var(--color-fg)]"
class="cursor-pointer rounded-lg border-2 border-dashed border-[hsl(var(--color-border))] px-4 py-6 text-center text-sm text-[hsl(var(--color-muted-foreground))] transition-colors hover:border-[hsl(var(--color-primary))] hover:text-[hsl(var(--color-foreground))]"
ondragover={(e) => e.preventDefault()}
ondrop={onDrop}
onclick={() => fileInput?.click()}
@ -115,21 +115,21 @@
onchange={onPick}
/>
{:else if stage === 'parsing'}
<div class="py-6 text-center text-sm text-[var(--color-muted)]" aria-live="polite">
<div class="py-6 text-center text-sm text-[hsl(var(--color-muted-foreground))]" aria-live="polite">
{t('import.parsing', { file: fileName })}
</div>
{:else if stage === 'preview' && parsed}
<div class="space-y-2 text-sm">
<div>
<span class="text-[var(--color-muted)]">{t('import.preview_found')}</span>
<code class="rounded bg-[var(--color-border)]/40 px-1 text-xs">{fileName}</code>:
<span class="text-[hsl(var(--color-muted-foreground))]">{t('import.preview_found')}</span>
<code class="rounded bg-[hsl(var(--color-border))]/40 px-1 text-xs">{fileName}</code>:
</div>
<ul class="ml-4 list-disc">
<li>{tn('import.preview_decks', parsed.decks.length)}</li>
<li>
{tn('import.preview_cards', parsed.cards.length)}
{#if parsed.cards.length > 0}
<span class="text-[var(--color-muted)]">
<span class="text-[hsl(var(--color-muted-foreground))]">
{t('import.preview_breakdown', {
basic: typeBreakdown.basic,
basic_reverse: typeBreakdown.basicReverse,
@ -139,7 +139,7 @@
{/if}
</li>
{#if parsed.mediaByFilename.size > 0}
<li class="text-[var(--color-muted)]">
<li class="text-[hsl(var(--color-muted-foreground))]">
{t('import.preview_media', { n: parsed.mediaByFilename.size })}
</li>
{/if}
@ -148,7 +148,7 @@
{/if}
</ul>
{#if parsed.warnings.length > 0}
<details class="text-xs text-[var(--color-muted)]">
<details class="text-xs text-[hsl(var(--color-muted-foreground))]">
<summary class="cursor-pointer">{t('import.preview_warnings', { n: parsed.warnings.length })}</summary>
<ul class="mt-1 list-disc pl-4">
{#each parsed.warnings.slice(0, 10) as w (w)}<li>{w}</li>{/each}
@ -157,13 +157,13 @@
{/if}
<div class="flex justify-end gap-2 pt-2">
<button
class="rounded px-3 py-1.5 text-sm text-[var(--color-muted)] hover:text-[var(--color-fg)]"
class="rounded px-3 py-1.5 text-sm text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]"
onclick={reset}
>
{t('import.cancel')}
</button>
<button
class="rounded bg-[var(--color-primary)] px-4 py-1.5 text-sm text-[var(--color-primary-fg)] hover:opacity-90"
class="rounded bg-[hsl(var(--color-primary))] px-4 py-1.5 text-sm text-[hsl(var(--color-primary-foreground))] hover:opacity-90"
onclick={confirmImport}
>
{t('import.import_now')}
@ -171,7 +171,7 @@
</div>
</div>
{:else if stage === 'importing'}
<div class="py-6 text-center text-sm text-[var(--color-muted)]" aria-live="polite">
<div class="py-6 text-center text-sm text-[hsl(var(--color-muted-foreground))]" aria-live="polite">
{#if progress.stage === 'media'}
{t('import.stage_media', { current: progress.current, total: progress.total })}
{:else if progress.stage === 'decks'}
@ -182,32 +182,32 @@
{t('import.stage_done')}
{/if}
<div
class="mx-auto mt-3 h-1 w-48 overflow-hidden rounded-full bg-[var(--color-border)]/40"
class="mx-auto mt-3 h-1 w-48 overflow-hidden rounded-full bg-[hsl(var(--color-border))]/40"
role="progressbar"
aria-valuemin="0"
aria-valuemax={progress.total}
aria-valuenow={progress.current}
>
<div
class="h-full bg-[var(--color-primary)] transition-all"
class="h-full bg-[hsl(var(--color-primary))] transition-all"
style="width: {progress.total === 0 ? 0 : (progress.current / progress.total) * 100}%"
></div>
</div>
</div>
{:else if stage === 'done' && result}
<div class="space-y-2 text-sm">
<div class="text-[var(--color-success,#16a34a)]">
<div class="text-[hsl(var(--color-success))]">
{result.decksCreated === 1
? t('import.done_summary_one', { cards: result.cardsCreated })
: t('import.done_summary', { cards: result.cardsCreated, decks: result.decksCreated })}
</div>
{#if result.cardsSkippedDuplicate > 0}
<div class="text-[var(--color-muted)]">
<div class="text-[hsl(var(--color-muted-foreground))]">
{t('import.done_dupes', { n: result.cardsSkippedDuplicate })}
</div>
{/if}
{#if result.mediaUploaded > 0 || result.mediaFailed > 0}
<div class="text-[var(--color-muted)]">
<div class="text-[hsl(var(--color-muted-foreground))]">
{t('import.done_media', {
uploaded: result.mediaUploaded,
failed: result.mediaFailed,
@ -215,7 +215,7 @@
</div>
{/if}
{#if result.failed > 0}
<details class="text-[var(--color-danger)]">
<details class="text-[hsl(var(--color-error))]">
<summary class="cursor-pointer">{t('import.done_failures', { n: result.failed })}</summary>
<ul class="mt-1 list-disc pl-4 text-xs">
{#each result.failures.slice(0, 20) as msg (msg)}<li>{msg}</li>{/each}
@ -223,7 +223,7 @@
</details>
{/if}
<button
class="rounded px-3 py-1.5 text-sm text-[var(--color-muted)] hover:text-[var(--color-fg)]"
class="rounded px-3 py-1.5 text-sm text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]"
onclick={reset}
>
{t('import.done_more')}
@ -231,9 +231,9 @@
</div>
{:else if stage === 'error'}
<div class="space-y-2 text-sm" role="alert">
<div class="text-[var(--color-danger)]">{t('import.error_label', { msg: error ?? '?' })}</div>
<div class="text-[hsl(var(--color-error))]">{t('import.error_label', { msg: error ?? '?' })}</div>
<button
class="rounded px-3 py-1.5 text-sm text-[var(--color-muted)] hover:text-[var(--color-fg)]"
class="rounded px-3 py-1.5 text-sm text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]"
onclick={reset}
>
{t('import.retry')}

View file

@ -1,16 +1,54 @@
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import { i18n, t } from '$lib/i18n/index.svelte.ts';
import { PillTabGroup } from '@mana/shared-ui';
const langOptions = [
{ id: 'de', label: 'DE', title: 'Deutsch' },
{ id: 'en', label: 'EN', title: 'English' },
];
const navOptions = $derived([
{ id: 'decks', label: t('nav.decks') },
{ id: 'study', label: t('nav.study') },
{ id: 'explore', label: t('nav.explore') },
{ id: 'import', label: t('nav.import') },
{ id: 'stats', label: t('nav.stats') },
]);
const activeNav = $derived.by(() => {
const path = page.url.pathname;
if (path.startsWith('/decks')) return 'decks';
if (path.startsWith('/study')) return 'study';
// /explore zeigt aktiv für Marketplace-Surfaces:
// /explore selbst, /d/<slug>, /u/<slug>, /me/{published,subscribed,forks}.
if (
path.startsWith('/explore') ||
path.startsWith('/d/') ||
path.startsWith('/u/') ||
path.startsWith('/me/')
) {
return 'explore';
}
if (path.startsWith('/import')) return 'import';
if (path.startsWith('/stats')) return 'stats';
return '';
});
function navTo(id: string) {
goto('/' + id);
}
</script>
<header
class="sticky top-0 z-40 w-full border-b bg-[var(--color-card)] border-[var(--color-border)]"
class="sticky top-0 z-40 w-full border-b bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))]"
>
<div class="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
<a href="/" class="flex items-center gap-2 font-semibold">
<span
class="inline-flex h-7 w-7 items-center justify-center rounded bg-[var(--color-primary)] text-[var(--color-primary-fg)] text-sm"
class="inline-flex h-7 w-7 items-center justify-center rounded bg-[hsl(var(--color-primary))] text-[hsl(var(--color-primary-foreground))] text-sm"
aria-hidden="true"
>
C
@ -18,57 +56,27 @@
<span>{t('app.name')}</span>
</a>
<nav class="flex items-center gap-6 text-sm" aria-label={t('common.main_nav')}>
<a
href="/decks"
class="hover:text-[var(--color-primary)]"
class:font-medium={page.url.pathname.startsWith('/decks')}>{t('nav.decks')}</a
>
<a
href="/study"
class="hover:text-[var(--color-primary)]"
class:font-medium={page.url.pathname.startsWith('/study')}>{t('nav.study')}</a
>
<a
href="/import"
class="hover:text-[var(--color-primary)]"
class:font-medium={page.url.pathname.startsWith('/import')}>{t('nav.import')}</a
>
<a
href="/stats"
class="hover:text-[var(--color-primary)]"
class:font-medium={page.url.pathname.startsWith('/stats')}>{t('nav.stats')}</a
>
<nav aria-label={t('common.main_nav')}>
<PillTabGroup
options={navOptions}
value={activeNav}
onChange={navTo}
primaryColor="hsl(var(--color-primary))"
/>
</nav>
<div class="flex items-center gap-3 text-sm">
<div
class="flex overflow-hidden rounded border border-[var(--color-border)] text-xs"
role="group"
aria-label={t('common.language_switcher')}
>
<button
type="button"
class="px-2 py-1 transition-colors"
class:bg-primary-active={i18n.current === 'de'}
style:background-color={i18n.current === 'de' ? 'var(--color-primary)' : 'transparent'}
style:color={i18n.current === 'de' ? 'var(--color-primary-fg)' : 'var(--color-muted)'}
aria-pressed={i18n.current === 'de'}
onclick={() => i18n.set('de')}>DE</button
>
<button
type="button"
class="px-2 py-1 transition-colors"
style:background-color={i18n.current === 'en' ? 'var(--color-primary)' : 'transparent'}
style:color={i18n.current === 'en' ? 'var(--color-primary-fg)' : 'var(--color-muted)'}
aria-pressed={i18n.current === 'en'}
onclick={() => i18n.set('en')}>EN</button
>
</div>
<PillTabGroup
options={langOptions}
value={i18n.current}
onChange={(id: string) => i18n.set(id as 'de' | 'en')}
sectionLabel={t('common.language_switcher')}
primaryColor="hsl(var(--color-primary))"
/>
{#if devUser.id}
<a
href="/account"
class="truncate max-w-[200px] text-[var(--color-muted)] hover:text-[var(--color-fg)]"
class="truncate max-w-[200px] text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]"
title={devUser.user?.email ?? devUser.id}
>
{devUser.user?.email ?? devUser.user?.name ?? devUser.id}
@ -76,7 +84,7 @@
{:else}
<a
href="/"
class="rounded bg-[var(--color-primary)] px-3 py-1 text-[var(--color-primary-fg)]"
class="rounded bg-[hsl(var(--color-primary))] px-3 py-1 text-[hsl(var(--color-primary-foreground))]"
>
Login
</a>

View file

@ -134,7 +134,7 @@
/>
</label>
{#if uploading}
<p class="text-xs text-[var(--color-muted)]">{t('image_occlusion.uploading')}</p>
<p class="text-xs text-[hsl(var(--color-muted-foreground))]">{t('image_occlusion.uploading')}</p>
{/if}
{#if imageRef}
@ -186,24 +186,24 @@
</svg>
</div>
<p class="text-xs text-[var(--color-muted)]">{t('image_occlusion.draw_hint')}</p>
<p class="text-xs text-[hsl(var(--color-muted-foreground))]">{t('image_occlusion.draw_hint')}</p>
{#if masks.length > 0}
<ul class="space-y-2 text-sm">
{#each masks as m, i (m.id)}
<li class="flex items-center gap-3 rounded border border-[var(--color-border)] px-3 py-2">
<span class="text-xs text-[var(--color-muted)] tabular-nums">{i + 1}</span>
<li class="flex items-center gap-3 rounded border border-[hsl(var(--color-border))] px-3 py-2">
<span class="text-xs text-[hsl(var(--color-muted-foreground))] tabular-nums">{i + 1}</span>
<input
type="text"
placeholder={t('image_occlusion.label_placeholder')}
value={m.label ?? ''}
oninput={(e) => setLabel(m.id, (e.currentTarget as HTMLInputElement).value)}
class="flex-1 rounded border bg-[var(--color-card)] border-[var(--color-border)] px-2 py-1 text-sm"
class="flex-1 rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-2 py-1 text-sm"
/>
<button
type="button"
onclick={() => deleteMask(m.id)}
class="text-xs text-[var(--color-muted)] hover:text-[var(--color-danger)]"
class="text-xs text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-error))]"
aria-label={t('image_occlusion.delete_mask')}
>
×

View file

@ -18,11 +18,11 @@
{#if stats && stats.deck && stats.cardCount > 0}
<a
href="/decks/{stats.deck.id}"
class="block rounded-lg border border-[var(--color-primary)]/40 bg-[var(--color-primary)]/10 px-4 py-3 text-sm hover:bg-[var(--color-primary)]/15"
class="block rounded-lg border border-[hsl(var(--color-primary))]/40 bg-[hsl(var(--color-primary))]/10 px-4 py-3 text-sm hover:bg-[hsl(var(--color-primary))]/15"
>
<span class="font-medium">{t('inbox_banner.label')}</span>
<span class="text-[var(--color-muted)]" aria-hidden="true">·</span>
<span class="text-[hsl(var(--color-muted-foreground))]" aria-hidden="true">·</span>
<span>{tn('inbox_banner.count', stats.cardCount)}</span>
<span class="ml-1 text-[var(--color-muted)]">{t('inbox_banner.cta')}</span>
<span class="ml-1 text-[hsl(var(--color-muted-foreground))]">{t('inbox_banner.cta')}</span>
</a>
{/if}

View file

@ -14,15 +14,15 @@
>
{#each toasts.items as t (t.id)}
<div
class="flex items-start gap-3 rounded-lg px-4 py-3 shadow-lg max-w-sm border bg-[var(--color-card)] border-[var(--color-border)]"
class:text-[var(--color-success)]={t.kind === 'success'}
class:text-[var(--color-warning)]={t.kind === 'warning'}
class:text-[var(--color-danger)]={t.kind === 'error'}
class="flex items-start gap-3 rounded-lg px-4 py-3 shadow-lg max-w-sm border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))]"
class:text-[hsl(var(--color-success))]={t.kind === 'success'}
class:text-[hsl(var(--color-warning))]={t.kind === 'warning'}
class:text-[hsl(var(--color-error))]={t.kind === 'error'}
role={t.kind === 'error' ? 'alert' : 'status'}
>
<span class="flex-1 text-sm">{t.message}</span>
<button
class="text-[var(--color-muted)] hover:text-[var(--color-fg)] text-lg leading-none"
class="text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))] text-lg leading-none"
onclick={() => toasts.dismiss(t.id)}
aria-label={closeLabel}
>

View file

@ -11,6 +11,7 @@ export const de: TranslationNode = {
nav: {
decks: 'Decks',
study: 'Lernen',
explore: 'Library',
import: 'Import',
stats: 'Statistik',
login_dev: 'Login (dev)',
@ -32,6 +33,8 @@ export const de: TranslationNode = {
error: 'Fehler: {msg}',
card_count: '{n} Karten',
card_count_one: '1 Karte',
card_count_more: '{n} weitere Karten im Stapel',
card_count_more_one: '1 weitere Karte im Stapel',
due_count: '{n} fällig',
delete_confirm:
'Deck "{name}" wirklich löschen? Alle Karten + Review-Daten gehen verloren.',
@ -48,6 +51,11 @@ export const de: TranslationNode = {
card_delete_aria: 'Karte löschen',
card_delete_label: 'Löschen',
card_delete_confirm: 'Karte wirklich löschen? Reviews werden mit gelöscht.',
fan_aria: 'Aufgefächerte Karten von Stapel "{name}"',
card_open: 'Karte öffnen — {type}',
},
deck_stack: {
aria_label: 'Stapel "{name}" — {cards} Karten, {due} fällig',
},
deck_new: {
title: 'Neues Deck',
@ -126,6 +134,7 @@ export const de: TranslationNode = {
grade_hint: '1=Wieder · 2=Schwer · 3=Gut · 4=Leicht',
loading: 'Lade…',
error: 'Fehler: {msg}',
manage_link: 'Karten verwalten →',
},
import: {
title: 'Importieren',

View file

@ -8,6 +8,7 @@ export const en: TranslationNode = {
nav: {
decks: 'Decks',
study: 'Study',
explore: 'Library',
import: 'Import',
stats: 'Stats',
login_dev: 'Login (dev)',
@ -29,6 +30,8 @@ export const en: TranslationNode = {
error: 'Error: {msg}',
card_count: '{n} cards',
card_count_one: '1 card',
card_count_more: '{n} more cards in the stack',
card_count_more_one: '1 more card in the stack',
due_count: '{n} due',
delete_confirm:
'Really delete deck "{name}"? All cards + review data will be lost.',
@ -45,6 +48,11 @@ export const en: TranslationNode = {
card_delete_aria: 'Delete card',
card_delete_label: 'Delete',
card_delete_confirm: 'Really delete card? Reviews will be deleted with it.',
fan_aria: 'Fanned cards from stack "{name}"',
card_open: 'Open card — {type}',
},
deck_stack: {
aria_label: 'Stack "{name}" — {cards} cards, {due} due',
},
deck_new: {
title: 'New deck',
@ -123,6 +131,7 @@ export const en: TranslationNode = {
grade_hint: '1=Again · 2=Hard · 3=Good · 4=Easy',
loading: 'Loading…',
error: 'Error: {msg}',
manage_link: 'Manage cards →',
},
import: {
title: 'Import',