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

@ -1,38 +1,41 @@
{
"name": "@cards/web",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Cards-Web — SvelteKit 2 + Svelte 5 Frontend für cardecky.mana.how.",
"scripts": {
"dev": "vite dev --port 3082 --host",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "vitest run --passWithNoTests",
"lint": "echo 'lint configured later (eslint flat-config)'",
"clean": "rm -rf .svelte-kit build .turbo"
},
"dependencies": {
"@cards/domain": "workspace:*",
"dompurify": "^3.4.2",
"jszip": "^3.10.1",
"marked": "^18.0.3",
"sql.js": "^1.14.1"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.2.0",
"@sveltejs/kit": "^2.8.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@tailwindcss/vite": "^4.2.4",
"@types/dompurify": "^3.2.0",
"@types/jszip": "^3.4.1",
"@types/sql.js": "^1.4.11",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.2.4",
"vite": "^5.4.0",
"vitest": "^2.1.0"
}
"name": "@cards/web",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Cards-Web — SvelteKit 2 + Svelte 5 Frontend für cardecky.mana.how.",
"scripts": {
"dev": "vite dev --port 3082 --host",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "vitest run --passWithNoTests",
"lint": "echo 'lint configured later (eslint flat-config)'",
"clean": "rm -rf .svelte-kit build .turbo"
},
"dependencies": {
"@cards/domain": "workspace:*",
"@mana/shared-icons": "^1.0.0",
"@mana/shared-ui": "^0.1.1",
"dompurify": "^3.4.2",
"jszip": "^3.10.1",
"marked": "^18.0.3",
"sql.js": "^1.14.1",
"@mana/themes": "^0.1.0"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.2.0",
"@sveltejs/kit": "^2.8.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@tailwindcss/vite": "^4.2.4",
"@types/dompurify": "^3.2.0",
"@types/jszip": "^3.4.1",
"@types/sql.js": "^1.4.11",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.2.4",
"vite": "^5.4.0",
"vitest": "^2.1.0"
}
}

View file

@ -1,17 +1,53 @@
@import 'tailwindcss';
@import '@mana/themes/variants/forest.css';
/**
* Cards Theming-Setup.
*
* 12-Token-Vokabular kommt aus `@mana/themes/variants/forest.css`.
* `data-theme="forest"` ist in `app.html` gesetzt; Light/Dark via
* Class `.dark` auf <html>. Konvention: `hsl(var(--color-X))`. Bare
* `var(--color-X)` ist verboten (silent inherit-fallback). Detail in
* mana/docs/THEMING.md.
*
* Diese Datei trägt nur Cards-spezifische Ergänzungen:
* 1. Bridge-Aliase für shared-ui@0.1.x bis shared-ui@2.0 entfällt
* 2. Brand-Literals (--brand-cards-forest)
* 3. Layer-Base-Regeln (html, focus-ring, sr-only, skip-link)
* 4. Reduced-Motion-Override
*/
@theme {
--color-bg: oklch(0.99 0.005 240);
--color-fg: oklch(0.20 0.02 240);
--color-muted: oklch(0.55 0.02 240);
--color-border: oklch(0.92 0.01 240);
--color-card: oklch(1 0 0);
--color-primary: oklch(0.55 0.15 250);
--color-primary-fg: oklch(1 0 0);
--color-success: oklch(0.6 0.15 145);
--color-warning: oklch(0.75 0.15 75);
--color-danger: oklch(0.55 0.18 25);
/* ===== Bridge-Aliase für shared-ui@0.1.x =====
* shared-ui@0.1.x erwartet ein 30-Token-Vokabular aus dem alten
* managarten-System. Damit Komponenten wie PillTabGroup nicht
* silent-fallback brechen, mappen wir die fehlenden Tokens auf
* unser 12er-Set. Mit shared-ui@2.0 entfällt diese Sektion.
*/
--color-card: var(--color-surface);
--color-card-foreground: var(--color-foreground);
--color-popover: var(--color-surface);
--color-popover-foreground: var(--color-foreground);
--color-secondary: var(--color-surface);
--color-secondary-foreground: var(--color-foreground);
--color-accent: var(--color-primary);
--color-accent-foreground: var(--color-primary-foreground);
--color-input: var(--color-border);
--color-ring: var(--color-primary);
--color-border-strong: var(--color-foreground);
--color-surface-elevated: var(--color-surface);
--color-surface-elevated-1: var(--color-surface);
--color-surface-elevated-2: var(--color-surface);
--color-surface-elevated-3: var(--color-surface);
--color-app-accent: var(--color-primary);
--color-mana: var(--color-primary);
--color-destructive: var(--color-error);
--color-destructive-foreground: var(--color-primary-foreground);
/* ===== Brand-Literals (NICHT theme-aware) ===== */
--brand-cards-forest: #16a34a;
/* ===== Fonts ===== */
--font-sans:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
@ -19,19 +55,20 @@
@layer base {
html {
background-color: var(--color-bg);
color: var(--color-fg);
background-color: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-family: var(--font-sans);
}
body {
min-height: 100dvh;
}
/* Sichtbarer Fokus-Ring für Tastatur-Nutzer. Tailwind 4 strippt
die Browser-Defaults; wir setzen einen expliziten Outline.
Nur :focus-visible, damit Maus-Klicks visuell sauber bleiben. */
die Browser-Defaults; expliziter Outline. Nur :focus-visible,
damit Maus-Klicks visuell sauber bleiben. */
:focus-visible {
outline: 2px solid var(--color-primary);
outline: 2px solid hsl(var(--color-primary));
outline-offset: 2px;
border-radius: 2px;
}
@ -56,8 +93,8 @@
top: 0;
left: 0;
padding: 0.5rem 0.75rem;
background: var(--color-primary);
color: var(--color-primary-fg);
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
z-index: 50;
transform: translateY(-200%);
transition: transform 0.15s;
@ -79,15 +116,3 @@
scroll-behavior: auto !important;
}
}
@media (prefers-color-scheme: dark) {
@theme {
--color-bg: oklch(0.18 0.02 240);
--color-fg: oklch(0.95 0.01 240);
--color-muted: oklch(0.65 0.02 240);
--color-border: oklch(0.30 0.02 240);
--color-card: oklch(0.22 0.02 240);
--color-primary: oklch(0.70 0.18 250);
--color-primary-fg: oklch(0.18 0.02 240);
}
}

View file

@ -1,5 +1,5 @@
<!doctype html>
<html lang="de">
<html lang="de" data-theme="forest">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />

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',

View file

@ -31,12 +31,12 @@
<div class="mx-auto max-w-2xl py-12 text-center">
<h1 class="text-3xl font-semibold">{t('app.name')}</h1>
<p class="mt-2 text-[var(--color-muted)]">{t('landing.welcome')}</p>
<p class="mt-1 text-sm text-[var(--color-muted)]">{t('landing.intro')}</p>
<p class="mt-2 text-[hsl(var(--color-muted-foreground))]">{t('landing.welcome')}</p>
<p class="mt-1 text-sm text-[hsl(var(--color-muted-foreground))]">{t('landing.intro')}</p>
{#if !devUser.id}
<form
class="mt-8 rounded-lg border bg-[var(--color-card)] border-[var(--color-border)] p-6 text-left space-y-3"
class="mt-8 rounded-lg border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] p-6 text-left space-y-3"
onsubmit={onSubmit}
>
<h2 class="text-lg font-medium">Anmelden</h2>
@ -47,7 +47,7 @@
bind:value={email}
required
autocomplete="email"
class="mt-1 block w-full rounded border bg-[var(--color-bg)] border-[var(--color-border)] px-3 py-2 text-sm"
class="mt-1 block w-full rounded border bg-[hsl(var(--color-background))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
/>
</label>
<label class="block text-sm">
@ -57,20 +57,20 @@
bind:value={password}
required
autocomplete="current-password"
class="mt-1 block w-full rounded border bg-[var(--color-bg)] border-[var(--color-border)] px-3 py-2 text-sm"
class="mt-1 block w-full rounded border bg-[hsl(var(--color-background))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
/>
</label>
{#if error}
<p class="text-sm text-[var(--color-danger)]" role="alert">{error}</p>
<p class="text-sm text-[hsl(var(--color-error))]" role="alert">{error}</p>
{/if}
<button
type="submit"
disabled={busy || !email || !password}
class="rounded bg-[var(--color-primary)] px-4 py-2 text-sm text-[var(--color-primary-fg)] disabled:opacity-50"
class="rounded bg-[hsl(var(--color-primary))] px-4 py-2 text-sm text-[hsl(var(--color-primary-foreground))] disabled:opacity-50"
>
{busy ? '…' : 'Login'}
</button>
<p class="text-xs text-[var(--color-muted)]">
<p class="text-xs text-[hsl(var(--color-muted-foreground))]">
Mana-Account auf <a
href="https://auth.mana.how"
target="_blank"

View file

@ -68,35 +68,35 @@
<div class="mx-auto max-w-2xl px-4 py-8">
<h1 class="text-2xl font-semibold">{t('account.title')}</h1>
<section class="mt-6 rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-4">
<section class="mt-6 rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4">
{#if devUser.user}
<div class="space-y-2 text-sm">
<div>
<div class="text-[var(--color-muted)]">E-Mail</div>
<div class="text-[hsl(var(--color-muted-foreground))]">E-Mail</div>
<div class="mt-1 font-medium">{devUser.user.email}</div>
</div>
{#if devUser.user.name}
<div>
<div class="text-[var(--color-muted)]">Name</div>
<div class="text-[hsl(var(--color-muted-foreground))]">Name</div>
<div class="mt-1">{devUser.user.name}</div>
</div>
{/if}
<div>
<div class="text-[var(--color-muted)]">Tier</div>
<div class="text-[hsl(var(--color-muted-foreground))]">Tier</div>
<div class="mt-1">
<span
class="inline-flex rounded bg-[var(--color-border)]/40 px-2 py-0.5 text-xs"
class="inline-flex rounded bg-[hsl(var(--color-border))]/40 px-2 py-0.5 text-xs"
>{devUser.user.tier}</span>
</div>
</div>
<div>
<div class="text-[var(--color-muted)]">{t('account.user_id_label')}</div>
<code class="mt-1 block break-all text-xs text-[var(--color-muted)]">{devUser.user.id}</code>
<div class="text-[hsl(var(--color-muted-foreground))]">{t('account.user_id_label')}</div>
<code class="mt-1 block break-all text-xs text-[hsl(var(--color-muted-foreground))]">{devUser.user.id}</code>
</div>
</div>
{:else}
<div class="text-sm">
<div class="text-[var(--color-muted)]">{t('account.user_id_label')} (Stub)</div>
<div class="text-[hsl(var(--color-muted-foreground))]">{t('account.user_id_label')} (Stub)</div>
<code class="mt-1 block break-all text-sm">{devUser.id ?? '—'}</code>
</div>
{/if}
@ -104,34 +104,34 @@
<button
type="button"
onclick={logout}
class="rounded border border-[var(--color-border)] px-3 py-1.5 text-sm hover:bg-[var(--color-border)]/40"
class="rounded border border-[hsl(var(--color-border))] px-3 py-1.5 text-sm hover:bg-[hsl(var(--color-border))]/40"
>
{t('account.logout')}
</button>
</div>
</section>
<section class="mt-6 rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-4">
<section class="mt-6 rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4">
<h2 class="text-lg font-medium">{t('account.export_title')}</h2>
<p class="mt-1 text-sm text-[var(--color-muted)]">{t('account.export_intro')}</p>
<p class="mt-1 text-sm text-[hsl(var(--color-muted-foreground))]">{t('account.export_intro')}</p>
<button
type="button"
onclick={onExport}
disabled={exporting}
class="mt-3 rounded bg-[var(--color-primary)] px-4 py-1.5 text-sm text-[var(--color-primary-fg)] disabled:opacity-50"
class="mt-3 rounded bg-[hsl(var(--color-primary))] px-4 py-1.5 text-sm text-[hsl(var(--color-primary-foreground))] disabled:opacity-50"
>
{exporting ? t('account.export_loading') : t('account.export_button')}
</button>
</section>
<section class="mt-6 rounded-lg border border-[var(--color-danger)]/30 bg-[var(--color-card)] p-4">
<h2 class="text-lg font-medium text-[var(--color-danger)]">{t('account.delete_title')}</h2>
<p class="mt-1 text-sm text-[var(--color-muted)]">{t('account.delete_intro')}</p>
<section class="mt-6 rounded-lg border border-[hsl(var(--color-error))]/30 bg-[hsl(var(--color-card))] p-4">
<h2 class="text-lg font-medium text-[hsl(var(--color-error))]">{t('account.delete_title')}</h2>
<p class="mt-1 text-sm text-[hsl(var(--color-muted-foreground))]">{t('account.delete_intro')}</p>
<button
type="button"
onclick={onDelete}
disabled={deleting}
class="mt-3 rounded border border-[var(--color-danger)] px-4 py-1.5 text-sm text-[var(--color-danger)] hover:bg-[var(--color-danger)]/10 disabled:opacity-50"
class="mt-3 rounded border border-[hsl(var(--color-error))] px-4 py-1.5 text-sm text-[hsl(var(--color-error))] hover:bg-[hsl(var(--color-error))]/10 disabled:opacity-50"
>
{deleting ? t('account.delete_loading') : t('account.delete_button')}
</button>

View file

@ -122,21 +122,21 @@
<div class="mx-auto max-w-3xl px-4 py-6">
{#if loading}
<p class="text-[var(--color-muted)]">{t('decks.loading')}</p>
<p class="text-[hsl(var(--color-muted-foreground))]">{t('decks.loading')}</p>
{:else if error}
<p class="text-[var(--color-danger)]">{t('decks.error', { msg: error })}</p>
<p class="text-[hsl(var(--color-error))]">{t('decks.error', { msg: error })}</p>
{:else if card}
<a
href="/decks/{card.deck_id}"
class="text-sm text-[var(--color-muted)] hover:text-[var(--color-fg)]">{t('card_edit.back')}</a
class="text-sm text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]">{t('card_edit.back')}</a
>
<div class="mt-2 flex items-baseline justify-between gap-3">
<h1 class="text-2xl font-semibold">{t('card_edit.title')}</h1>
<span class="rounded bg-[var(--color-border)] px-2 py-0.5 text-xs text-[var(--color-muted)]">
<span class="rounded bg-[hsl(var(--color-border))] px-2 py-0.5 text-xs text-[hsl(var(--color-muted-foreground))]">
{cardType}
</span>
</div>
<p class="mt-1 text-xs text-[var(--color-muted)]">{t('card_edit.type_locked_help')}</p>
<p class="mt-1 text-xs text-[hsl(var(--color-muted-foreground))]">{t('card_edit.type_locked_help')}</p>
<form class="mt-6 space-y-5" onsubmit={onSubmit}>
{#if cardType === 'image-occlusion'}
@ -150,14 +150,14 @@
required
rows="6"
placeholder={t('card_new.cloze_text_placeholder')}
class="mt-1 block w-full rounded border bg-[var(--color-card)] border-[var(--color-border)] px-3 py-2 font-mono text-sm"
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 font-mono text-sm"
></textarea>
</label>
<p class="mt-1 text-xs text-[var(--color-muted)]">{t('card_new.cloze_help')}</p>
<p class="mt-1 text-xs text-[hsl(var(--color-muted-foreground))]">{t('card_new.cloze_help')}</p>
{#if text.trim() && clusterIds.length === 0}
<p class="mt-1 text-xs text-[var(--color-danger)]">{t('card_new.cloze_no_clusters')}</p>
<p class="mt-1 text-xs text-[hsl(var(--color-error))]">{t('card_new.cloze_no_clusters')}</p>
{:else if clusterIds.length > 0}
<p class="mt-1 text-xs text-[var(--color-muted)]">
<p class="mt-1 text-xs text-[hsl(var(--color-muted-foreground))]">
{t('card_new.cloze_clusters_detected', {
n: clusterIds.length,
ids: clusterIds.join(', c'),
@ -167,8 +167,8 @@
</div>
{#if clozePreviewHtml}
<div class="rounded border border-[var(--color-border)] bg-[var(--color-card)] p-3 text-sm">
<div class="mb-1 text-xs uppercase text-[var(--color-muted)]">
<div class="rounded border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-3 text-sm">
<div class="mb-1 text-xs uppercase text-[hsl(var(--color-muted-foreground))]">
{t('card_new.cloze_preview_label', { first: clusterIds[0] ?? 1 })}
</div>
<div class="prose prose-sm max-w-none">{@html clozePreviewHtml}</div>
@ -181,7 +181,7 @@
bind:value={extra}
rows="3"
placeholder={t('card_new.cloze_extra_placeholder')}
class="mt-1 block w-full rounded border bg-[var(--color-card)] border-[var(--color-border)] px-3 py-2 font-mono text-sm"
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 font-mono text-sm"
></textarea>
</label>
{:else}
@ -193,12 +193,12 @@
bind:value={front}
required
rows="8"
class="mt-1 block w-full rounded border bg-[var(--color-card)] border-[var(--color-border)] px-3 py-2 font-mono text-sm"
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 font-mono text-sm"
></textarea>
</label>
{#if front.trim()}
<div class="mt-2 rounded border border-[var(--color-border)] bg-[var(--color-card)] p-3 text-sm">
<div class="mb-1 text-xs uppercase text-[var(--color-muted)]">{t('card_new.preview_label')}</div>
<div class="mt-2 rounded border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-3 text-sm">
<div class="mb-1 text-xs uppercase text-[hsl(var(--color-muted-foreground))]">{t('card_new.preview_label')}</div>
<div class="prose prose-sm max-w-none">{@html frontHtml}</div>
</div>
{/if}
@ -210,12 +210,12 @@
bind:value={back}
required
rows="8"
class="mt-1 block w-full rounded border bg-[var(--color-card)] border-[var(--color-border)] px-3 py-2 font-mono text-sm"
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 font-mono text-sm"
></textarea>
</label>
{#if back.trim()}
<div class="mt-2 rounded border border-[var(--color-border)] bg-[var(--color-card)] p-3 text-sm">
<div class="mb-1 text-xs uppercase text-[var(--color-muted)]">{t('card_new.preview_label')}</div>
<div class="mt-2 rounded border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-3 text-sm">
<div class="mb-1 text-xs uppercase text-[hsl(var(--color-muted-foreground))]">{t('card_new.preview_label')}</div>
<div class="prose prose-sm max-w-none">{@html backHtml}</div>
</div>
{/if}
@ -228,19 +228,19 @@
<button
type="submit"
disabled={!canSave}
class="rounded bg-[var(--color-primary)] px-4 py-2 text-sm text-[var(--color-primary-fg)] disabled:opacity-50"
class="rounded bg-[hsl(var(--color-primary))] px-4 py-2 text-sm text-[hsl(var(--color-primary-foreground))] disabled:opacity-50"
>
{saving ? t('card_edit.saving') : t('card_edit.save')}
</button>
<a
href="/decks/{card.deck_id}"
class="text-sm text-[var(--color-muted)] hover:text-[var(--color-fg)]">{t('card_edit.cancel')}</a
class="text-sm text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]">{t('card_edit.cancel')}</a
>
</div>
<button
type="button"
onclick={onDelete}
class="text-sm text-[var(--color-muted)] hover:text-[var(--color-danger)]"
class="text-sm text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-error))]"
>
{t('card_edit.delete')}
</button>

View file

@ -107,7 +107,7 @@
}
</script>
<a href={deckId ? `/decks/${deckId}` : '/decks'} class="text-sm text-[var(--color-muted)] hover:text-[var(--color-fg)]"
<a href={deckId ? `/decks/${deckId}` : '/decks'} class="text-sm text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]"
>{t('card_new.back')}</a
>
<h1 class="mt-2 text-2xl font-semibold">{t('card_new.title')}</h1>
@ -119,7 +119,7 @@
<select
bind:value={deckId}
required
class="mt-1 block w-full rounded border bg-[var(--color-card)] border-[var(--color-border)] px-3 py-2 text-sm"
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
>
{#each decks as d}
<option value={d.id}>{d.name}</option>
@ -131,7 +131,7 @@
<span class="text-sm font-medium">{t('card_new.type_label')}</span>
<select
bind:value={cardType}
class="mt-1 block w-full rounded border bg-[var(--color-card)] border-[var(--color-border)] px-3 py-2 text-sm"
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
>
<option value="basic">{t('card_new.type_basic')}</option>
<option value="basic-reverse">{t('card_new.type_basic_reverse')}</option>
@ -152,14 +152,14 @@
required
rows="6"
placeholder={t('card_new.cloze_text_placeholder')}
class="mt-1 block w-full rounded border bg-[var(--color-card)] border-[var(--color-border)] px-3 py-2 font-mono text-sm"
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 font-mono text-sm"
></textarea>
</label>
<p class="mt-1 text-xs text-[var(--color-muted)]">{t('card_new.cloze_help')}</p>
<p class="mt-1 text-xs text-[hsl(var(--color-muted-foreground))]">{t('card_new.cloze_help')}</p>
{#if text.trim() && clusterIds.length === 0}
<p class="mt-1 text-xs text-[var(--color-danger)]">{t('card_new.cloze_no_clusters')}</p>
<p class="mt-1 text-xs text-[hsl(var(--color-error))]">{t('card_new.cloze_no_clusters')}</p>
{:else if clusterIds.length > 0}
<p class="mt-1 text-xs text-[var(--color-muted)]">
<p class="mt-1 text-xs text-[hsl(var(--color-muted-foreground))]">
{t('card_new.cloze_clusters_detected', {
n: clusterIds.length,
ids: clusterIds.join(', c'),
@ -169,8 +169,8 @@
</div>
{#if clozePreviewHtml}
<div class="rounded border border-[var(--color-border)] bg-[var(--color-card)] p-3 text-sm">
<div class="mb-1 text-xs uppercase text-[var(--color-muted)]">
<div class="rounded border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-3 text-sm">
<div class="mb-1 text-xs uppercase text-[hsl(var(--color-muted-foreground))]">
{t('card_new.cloze_preview_label', { first: clusterIds[0] ?? 1 })}
</div>
<div class="prose prose-sm max-w-none">{@html clozePreviewHtml}</div>
@ -183,7 +183,7 @@
bind:value={extra}
rows="3"
placeholder={t('card_new.cloze_extra_placeholder')}
class="mt-1 block w-full rounded border bg-[var(--color-card)] border-[var(--color-border)] px-3 py-2 font-mono text-sm"
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 font-mono text-sm"
></textarea>
</label>
{:else}
@ -196,12 +196,12 @@
required
rows="8"
placeholder={t('card_new.front_placeholder')}
class="mt-1 block w-full rounded border bg-[var(--color-card)] border-[var(--color-border)] px-3 py-2 font-mono text-sm"
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 font-mono text-sm"
></textarea>
</label>
{#if front.trim()}
<div class="mt-2 rounded border border-[var(--color-border)] bg-[var(--color-card)] p-3 text-sm">
<div class="mb-1 text-xs uppercase text-[var(--color-muted)]">{t('card_new.preview_label')}</div>
<div class="mt-2 rounded border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-3 text-sm">
<div class="mb-1 text-xs uppercase text-[hsl(var(--color-muted-foreground))]">{t('card_new.preview_label')}</div>
<div class="prose prose-sm max-w-none">{@html frontHtml}</div>
</div>
{/if}
@ -215,12 +215,12 @@
required
rows="8"
placeholder={t('card_new.back_placeholder')}
class="mt-1 block w-full rounded border bg-[var(--color-card)] border-[var(--color-border)] px-3 py-2 font-mono text-sm"
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 font-mono text-sm"
></textarea>
</label>
{#if back.trim()}
<div class="mt-2 rounded border border-[var(--color-border)] bg-[var(--color-card)] p-3 text-sm">
<div class="mb-1 text-xs uppercase text-[var(--color-muted)]">{t('card_new.preview_label')}</div>
<div class="mt-2 rounded border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-3 text-sm">
<div class="mb-1 text-xs uppercase text-[hsl(var(--color-muted-foreground))]">{t('card_new.preview_label')}</div>
<div class="prose prose-sm max-w-none">{@html backHtml}</div>
</div>
{/if}
@ -232,13 +232,13 @@
<button
type="submit"
disabled={!canSave}
class="rounded bg-[var(--color-primary)] px-4 py-2 text-sm text-[var(--color-primary-fg)] disabled:opacity-50"
class="rounded bg-[hsl(var(--color-primary))] px-4 py-2 text-sm text-[hsl(var(--color-primary-foreground))] disabled:opacity-50"
>
{saving ? t('card_new.creating') : t('card_new.create')}
</button>
<a
href={deckId ? `/decks/${deckId}` : '/decks'}
class="text-sm text-[var(--color-muted)] hover:text-[var(--color-fg)]">{t('card_new.cancel')}</a
class="text-sm text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]">{t('card_new.cancel')}</a
>
</div>
</form>

View file

@ -50,11 +50,11 @@
</svelte:head>
<div class="mx-auto max-w-2xl px-4 py-8">
<a href="/decks" class="text-sm text-[var(--color-muted)] hover:text-[var(--color-fg)]">
<a href="/decks" class="text-sm text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]">
{t('nav.decks')}
</a>
<h1 class="mt-2 text-2xl font-semibold">✨ Deck mit KI erstellen</h1>
<p class="mt-2 text-sm text-[var(--color-muted)]">
<p class="mt-2 text-sm text-[hsl(var(--color-muted-foreground))]">
Beschreibe ein Thema, und mana-llm baut ein Deck mit Karteikarten daraus. Du kannst die Karten
danach jederzeit editieren oder ergänzen.
</p>
@ -68,9 +68,9 @@
rows="4"
maxlength="500"
placeholder="z.B. Deutsche Hunderassen mit ihren wichtigsten Charaktermerkmalen"
class="mt-1 block w-full rounded border bg-[var(--color-card)] border-[var(--color-border)] px-3 py-2 text-sm"
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
></textarea>
<p class="mt-1 text-xs text-[var(--color-muted)]">
<p class="mt-1 text-xs text-[hsl(var(--color-muted-foreground))]">
Klar formulierte, abgegrenzte Themen funktionieren besser als zu breite („alles über
Geschichte" → eher: „französische Revolution: Schlüsselereignisse 1789-1799").
</p>
@ -84,16 +84,16 @@
bind:value={count}
min="3"
max="40"
class="mt-1 block w-full rounded border bg-[var(--color-card)] border-[var(--color-border)] px-3 py-2 text-sm"
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
/>
<p class="mt-1 text-xs text-[var(--color-muted)]">340 (Server-Cap).</p>
<p class="mt-1 text-xs text-[hsl(var(--color-muted-foreground))]">340 (Server-Cap).</p>
</label>
<label class="block">
<span class="text-sm font-medium">Sprache</span>
<select
bind:value={language}
class="mt-1 block w-full rounded border bg-[var(--color-card)] border-[var(--color-border)] px-3 py-2 text-sm"
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
>
<option value="de">Deutsch</option>
<option value="en">English</option>
@ -102,7 +102,7 @@
</div>
{#if error}
<div class="rounded border border-[var(--color-danger)]/40 bg-[var(--color-danger)]/10 p-3 text-sm text-[var(--color-danger)]" role="alert">
<div class="rounded border border-[hsl(var(--color-error))]/40 bg-[hsl(var(--color-error))]/10 p-3 text-sm text-[hsl(var(--color-error))]" role="alert">
{error}
</div>
{/if}
@ -111,30 +111,30 @@
<button
type="submit"
disabled={busy || !prompt.trim()}
class="rounded bg-[var(--color-primary)] px-4 py-2 text-sm text-[var(--color-primary-fg)] disabled:opacity-50"
class="rounded bg-[hsl(var(--color-primary))] px-4 py-2 text-sm text-[hsl(var(--color-primary-foreground))] disabled:opacity-50"
>
{busy ? '✨ Generiere…' : '✨ Deck generieren'}
</button>
<a href="/decks" class="text-sm text-[var(--color-muted)] hover:text-[var(--color-fg)]"
<a href="/decks" class="text-sm text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]"
>{t('deck_new.cancel')}</a
>
</div>
{#if busy}
<p class="text-xs text-[var(--color-muted)]" aria-live="polite">
<p class="text-xs text-[hsl(var(--color-muted-foreground))]" aria-live="polite">
mana-llm denkt nach. Bei {count} Karten typischerweise 1060 Sekunden.
</p>
{/if}
</form>
<aside class="mt-10 rounded-lg border border-dashed border-[var(--color-border)] p-4">
<div class="text-xs font-medium text-[var(--color-fg)]">Beispiele zum Klicken:</div>
<aside class="mt-10 rounded-lg border border-dashed border-[hsl(var(--color-border))] p-4">
<div class="text-xs font-medium text-[hsl(var(--color-foreground))]">Beispiele zum Klicken:</div>
<ul class="mt-2 space-y-1.5">
{#each examples as ex (ex)}
<li>
<button
type="button"
class="text-left text-xs text-[var(--color-muted)] hover:text-[var(--color-primary)]"
class="text-left text-xs text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-primary))]"
onclick={() => (prompt = ex)}
>
{ex}

View file

@ -29,7 +29,7 @@
</script>
<div class="mx-auto max-w-xl">
<a href="/decks" class="text-sm text-[var(--color-muted)] hover:text-[var(--color-fg)]"
<a href="/decks" class="text-sm text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]"
>{t('deck_detail.back')}</a
>
<h1 class="mt-2 text-2xl font-semibold">{t('deck_new.title')}</h1>
@ -41,7 +41,7 @@
bind:value={name}
required
maxlength="200"
class="mt-1 block w-full rounded border bg-[var(--color-card)] border-[var(--color-border)] px-3 py-2 text-sm"
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
/>
</label>
@ -51,7 +51,7 @@
bind:value={description}
maxlength="2000"
rows="3"
class="mt-1 block w-full rounded border bg-[var(--color-card)] border-[var(--color-border)] px-3 py-2 text-sm"
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
></textarea>
</label>
@ -60,7 +60,7 @@
<input
type="color"
bind:value={color}
class="mt-1 h-10 w-20 rounded border border-[var(--color-border)] bg-transparent"
class="mt-1 h-10 w-20 rounded border border-[hsl(var(--color-border))] bg-transparent"
/>
</label>
@ -68,11 +68,11 @@
<button
type="submit"
disabled={saving || !name.trim()}
class="rounded bg-[var(--color-primary)] px-4 py-2 text-sm text-[var(--color-primary-fg)] disabled:opacity-50"
class="rounded bg-[hsl(var(--color-primary))] px-4 py-2 text-sm text-[hsl(var(--color-primary-foreground))] disabled:opacity-50"
>
{saving ? t('deck_new.creating') : t('deck_new.create')}
</button>
<a href="/decks" class="text-sm text-[var(--color-muted)] hover:text-[var(--color-fg)]"
<a href="/decks" class="text-sm text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]"
>{t('deck_new.cancel')}</a
>
</div>

View file

@ -18,21 +18,21 @@
<div class="mx-auto max-w-2xl px-4 py-8">
<h1 class="text-2xl font-semibold">{t('import.title')}</h1>
<p class="mt-2 text-sm text-[var(--color-muted)]">{t('import.intro')}</p>
<p class="mt-2 text-sm text-[hsl(var(--color-muted-foreground))]">{t('import.intro')}</p>
<div class="mt-6">
<AnkiImport />
</div>
<aside class="mt-8 rounded-lg border border-dashed border-[var(--color-border)] p-4 text-xs text-[var(--color-muted)]">
<div class="mb-1 font-medium text-[var(--color-fg)]">{t('import.what_works_title')}</div>
<aside class="mt-8 rounded-lg border border-dashed border-[hsl(var(--color-border))] p-4 text-xs text-[hsl(var(--color-muted-foreground))]">
<div class="mb-1 font-medium text-[hsl(var(--color-foreground))]">{t('import.what_works_title')}</div>
<ul class="list-disc pl-4">
<li>{t('import.what_works_decks')}</li>
<li>{t('import.what_works_basic')}</li>
<li>{t('import.what_works_cloze')}</li>
<li>{t('import.what_works_media')}</li>
</ul>
<div class="mt-2 mb-1 font-medium text-[var(--color-fg)]">{t('import.what_skipped_title')}</div>
<div class="mt-2 mb-1 font-medium text-[hsl(var(--color-foreground))]">{t('import.what_skipped_title')}</div>
<ul class="list-disc pl-4">
<li>{t('import.what_skipped_media')}</li>
<li>{t('import.what_skipped_history')}</li>

View file

@ -54,37 +54,37 @@
<h1 class="text-2xl font-semibold">{t('stats.title')}</h1>
{#if loading}
<p class="mt-6 text-[var(--color-muted)]">{t('stats.loading')}</p>
<p class="mt-6 text-[hsl(var(--color-muted-foreground))]">{t('stats.loading')}</p>
{:else if error}
<p class="mt-6 text-[var(--color-danger)]">{t('stats.error', { msg: error })}</p>
<p class="mt-6 text-[hsl(var(--color-error))]">{t('stats.error', { msg: error })}</p>
{:else if stats}
<p class="mt-1 text-xs text-[var(--color-muted)]">
<p class="mt-1 text-xs text-[hsl(var(--color-muted-foreground))]">
{t('stats.generated_at', { date: fullDate(stats.generated_at) })}
</p>
<section class="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
<div class="rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-4">
<div class="text-xs text-[var(--color-muted)]">{t('stats.decks')}</div>
<div class="rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4">
<div class="text-xs text-[hsl(var(--color-muted-foreground))]">{t('stats.decks')}</div>
<div class="mt-1 text-2xl font-semibold">{stats.total_decks}</div>
</div>
<div class="rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-4">
<div class="text-xs text-[var(--color-muted)]">{t('stats.cards')}</div>
<div class="rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4">
<div class="text-xs text-[hsl(var(--color-muted-foreground))]">{t('stats.cards')}</div>
<div class="mt-1 text-2xl font-semibold">{stats.total_cards}</div>
</div>
<div class="rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-4">
<div class="text-xs text-[var(--color-muted)]">{t('stats.reviews')}</div>
<div class="rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4">
<div class="text-xs text-[hsl(var(--color-muted-foreground))]">{t('stats.reviews')}</div>
<div class="mt-1 text-2xl font-semibold">{stats.total_reviews}</div>
</div>
<div class="rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-4">
<div class="text-xs text-[var(--color-muted)]">{t('stats.due_now')}</div>
<div class="rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4">
<div class="text-xs text-[hsl(var(--color-muted-foreground))]">{t('stats.due_now')}</div>
<div class="mt-1 text-2xl font-semibold">{stats.due_now}</div>
</div>
</section>
<section class="mt-6 rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-4">
<section class="mt-6 rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4">
<div class="flex items-baseline justify-between">
<h2 class="text-lg font-medium">{t('stats.days_title')}</h2>
<span class="text-xs text-[var(--color-muted)]">
<span class="text-xs text-[hsl(var(--color-muted-foreground))]">
{tn('stats.streak', stats.streak_days, { total: reviewedTotal7 })}
</span>
</div>
@ -97,34 +97,34 @@
>
<div class="text-xs tabular-nums">{d.n || ''}</div>
<div
class="w-full rounded-t bg-[var(--color-primary)]/80"
class="w-full rounded-t bg-[hsl(var(--color-primary))]/80"
style="height: {(d.n / peakDay) * 100}%; min-height: {d.n > 0 ? '4px' : '0'};"
aria-hidden="true"
></div>
<div class="text-xs text-[var(--color-muted)]">{dayLabel(d.day)}</div>
<div class="text-xs text-[hsl(var(--color-muted-foreground))]">{dayLabel(d.day)}</div>
</div>
{/each}
</div>
</section>
<section class="mt-6 rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] p-4">
<section class="mt-6 rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4">
<h2 class="text-lg font-medium">{t('stats.fsrs_title')}</h2>
<p class="mt-1 text-xs text-[var(--color-muted)]">{t('stats.fsrs_intro')}</p>
<p class="mt-1 text-xs text-[hsl(var(--color-muted-foreground))]">{t('stats.fsrs_intro')}</p>
<dl class="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-4">
<div>
<dt class="text-xs text-[var(--color-muted)]">{t('stats.fsrs_new')}</dt>
<dt class="text-xs text-[hsl(var(--color-muted-foreground))]">{t('stats.fsrs_new')}</dt>
<dd class="text-xl font-semibold">{stats.state_counts.new}</dd>
</div>
<div>
<dt class="text-xs text-[var(--color-muted)]">{t('stats.fsrs_learning')}</dt>
<dt class="text-xs text-[hsl(var(--color-muted-foreground))]">{t('stats.fsrs_learning')}</dt>
<dd class="text-xl font-semibold">{stats.state_counts.learning}</dd>
</div>
<div>
<dt class="text-xs text-[var(--color-muted)]">{t('stats.fsrs_review')}</dt>
<dt class="text-xs text-[hsl(var(--color-muted-foreground))]">{t('stats.fsrs_review')}</dt>
<dd class="text-xl font-semibold">{stats.state_counts.review}</dd>
</div>
<div>
<dt class="text-xs text-[var(--color-muted)]">{t('stats.fsrs_relearning')}</dt>
<dt class="text-xs text-[hsl(var(--color-muted-foreground))]">{t('stats.fsrs_relearning')}</dt>
<dd class="text-xl font-semibold">{stats.state_counts.relearning}</dd>
</div>
</dl>

View file

@ -44,12 +44,12 @@
</div>
{#if loading}
<p class="mt-8 text-[var(--color-muted)]">{t('study_session.loading')}</p>
<p class="mt-8 text-[hsl(var(--color-muted-foreground))]">{t('study_session.loading')}</p>
{:else}
<ul class="mt-6 space-y-2">
{#each items as it (it.deck.id)}
<li
class="flex items-center justify-between rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] px-4 py-3"
class="flex items-center justify-between rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] px-4 py-3"
>
<div class="flex items-center gap-3 min-w-0">
{#if it.deck.color}
@ -60,19 +60,19 @@
></span>
{/if}
<span class="truncate font-medium">{it.deck.name}</span>
<span class="text-sm text-[var(--color-muted)]">
<span class="text-sm text-[hsl(var(--color-muted-foreground))]">
{t('study.due_count', { n: it.due })}
</span>
</div>
{#if it.due > 0}
<a
href="/study/{it.deck.id}"
class="rounded bg-[var(--color-primary)] px-3 py-1.5 text-sm text-[var(--color-primary-fg)]"
class="rounded bg-[hsl(var(--color-primary))] px-3 py-1.5 text-sm text-[hsl(var(--color-primary-foreground))]"
>
{t('study.study_now')}
</a>
{:else}
<span class="text-sm text-[var(--color-muted)]" aria-label={t('study.none_due')}>—</span>
<span class="text-sm text-[hsl(var(--color-muted-foreground))]" aria-label={t('study.none_due')}>—</span>
{/if}
</li>
{/each}