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:
parent
404ddec62d
commit
19a0036b82
20 changed files with 323 additions and 261 deletions
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
>
|
||||
×
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue