feat(cards): recovery mode, undo, FSRS slider, streak header, stats charts, blog
Some checks are pending
CI / validate (push) Waiting to run

Study-View:
- Graceful Backlog Recovery: Banner bei >30 fälligen Karten, Recovery-Queue
  sortiert nach Stability aufsteigend (25er-Batch, ?recovery=true)
- Undo letzte Bewertung: 5s-Toast mit RAF-Fortschrittsbalken, Ctrl/Cmd+Z,
  prevSnapshot-Spalte in reviews (Migration 0001, Prod deployed)
- FSRS-Tooltip nach Reveal: State / Stability / Difficulty als Popover

Deck-Edit:
- Neuer Abschnitt „Lern-Algorithmus" mit request_retention-Slider (50–99 %)

Header:
- Streak-Pill (🔥 N) + fällige-Karten-Pill via GET /api/v1/me/summary

Stats-Page:
- Difficulty-Distribution (5 Buckets, Farb-Bars)
- Deck-Fortschritt (Mastery % = stability>21, max 6 Decks)

API:
- GET /me/summary: streak_days + due_now (leichtgewichtiger Header-Endpoint)
- GET /reviews/due: ?recovery=true → stability-sort, Limit 25
- POST /reviews/:cardId/:subIndex/undo: prevSnapshot-Restore, 409 wenn leer
- /me/stats: difficulty_distribution + deck_mastery

Landing:
- 5 Blog-Artikel (Quizlet-Paywall, FSRS, Datenschutz, Anki, Lernkarten-Tipps)
- BlogTeaser-Komponente auf Startseite, Footer-Spalte „Artikel"

i18n: 11 neue Schlüssel in DE/EN/FR/IT/ES

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-13 13:37:03 +02:00
parent 21ec535173
commit abf493aeec
27 changed files with 2667 additions and 29 deletions

View file

@ -68,8 +68,19 @@ export interface UserStats {
due_forecast: { day: string; n: number }[];
leech_threshold: number;
leech_cards: LeechCard[];
difficulty_distribution: { bucket: 'very_easy' | 'easy' | 'medium' | 'hard' | 'very_hard'; n: number }[];
deck_mastery: { deck_id: string; deck_name: string; total: number; mastered: number; pct: number }[];
}
export function loadStats() {
return api<UserStats>('/api/v1/me/stats');
}
export interface UserSummary {
streak_days: number;
due_now: number;
}
export function loadSummary() {
return api<UserSummary>('/api/v1/me/summary');
}

View file

@ -5,10 +5,11 @@ export type DueReview = Review & {
card?: Pick<Card, 'id' | 'deck_id' | 'type' | 'fields'>;
};
export function listDueReviews(opts: { deckId?: string; limit?: number } = {}) {
export function listDueReviews(opts: { deckId?: string; limit?: number; recovery?: boolean } = {}) {
const params = new URLSearchParams();
if (opts.deckId) params.set('deck_id', opts.deckId);
if (opts.limit) params.set('limit', String(opts.limit));
if (opts.recovery) params.set('recovery', 'true');
const qs = params.toString();
return api<{ reviews: DueReview[]; total: number }>(
`/api/v1/reviews/due${qs ? `?${qs}` : ''}`
@ -21,3 +22,7 @@ export function gradeReview(cardId: string, subIndex: number, rating: Rating) {
body: { rating },
});
}
export function undoReview(cardId: string, subIndex: number) {
return api<Review>(`/api/v1/reviews/${cardId}/${subIndex}/undo`, { method: 'POST' });
}

View file

@ -1,8 +1,10 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import { t } from '$lib/i18n/index.svelte.ts';
import { loadSummary } from '$lib/api/me.ts';
const navItems = $derived([
{ id: 'decks', label: t('nav.decks') },
@ -30,6 +32,20 @@
devUser.user?.email?.charAt(0).toUpperCase() ??
'?'
);
let streakDays = $state(0);
let dueNow = $state(0);
onMount(async () => {
if (!devUser.id) return;
try {
const s = await loadSummary();
streakDays = s.streak_days;
dueNow = s.due_now;
} catch {
// ignorieren — Header ist non-critical
}
});
</script>
<div class="bottom-bar" role="navigation" aria-label={t('common.main_nav')}>
@ -38,6 +54,22 @@
<div class="divider" aria-hidden="true"></div>
{#if devUser.id && (streakDays > 0 || dueNow > 0)}
<div class="header-meta" aria-label="Lernstatus">
{#if streakDays > 0}
<span class="streak-pill" title="{streakDays} Tage Streak">
🔥 {streakDays}
</span>
{/if}
{#if dueNow > 0}
<span class="due-pill" title="{dueNow} Karten fällig">
{dueNow}
</span>
{/if}
</div>
<div class="divider" aria-hidden="true"></div>
{/if}
<!-- Hauptnavigation -->
{#each navItems as item (item.id)}
<button
@ -187,4 +219,32 @@
text-decoration: none;
flex-shrink: 0;
}
.header-meta {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.streak-pill {
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--color-foreground));
padding: 0.125rem 0.375rem;
border-radius: 9999px;
background: hsl(30 100% 55% / 0.12);
white-space: nowrap;
}
.due-pill {
font-size: 0.6875rem;
font-weight: 700;
color: hsl(var(--color-primary-foreground));
background: hsl(var(--color-primary));
padding: 0.125rem 0.4rem;
border-radius: 9999px;
min-width: 1.25rem;
text-align: center;
font-variant-numeric: tabular-nums;
}
</style>

View file

@ -315,6 +315,16 @@ export const de: TranslationNode = {
leeches_none: 'Keine zähen Karten — sauber.',
leeches_lapses: '{n} Ausrutscher',
leeches_lapses_one: '{n} Ausrutscher',
difficulty_title: 'Schwierigkeitsverteilung',
difficulty_desc: 'Wie schwer sind deine Karten laut FSRS.',
difficulty_very_easy: 'Sehr leicht',
difficulty_easy: 'Leicht',
difficulty_medium: 'Mittel',
difficulty_hard: 'Schwer',
difficulty_very_hard: 'Sehr schwer',
mastery_title: 'Deck-Fortschritt',
mastery_desc: 'Anteil gemeisterter Karten (Stabilität > 21 Tage).',
mastery_empty: 'Noch keine Review-Daten.',
loading: 'Lade…',
error: 'Fehler: {msg}',
},

View file

@ -310,6 +310,16 @@ export const en: TranslationNode = {
leeches_none: 'No leeches — clean slate.',
leeches_lapses: '{n} lapses',
leeches_lapses_one: '{n} lapse',
difficulty_title: 'Difficulty distribution',
difficulty_desc: 'How difficult are your cards according to FSRS.',
difficulty_very_easy: 'Very easy',
difficulty_easy: 'Easy',
difficulty_medium: 'Medium',
difficulty_hard: 'Hard',
difficulty_very_hard: 'Very hard',
mastery_title: 'Deck progress',
mastery_desc: 'Share of mastered cards (stability > 21 days).',
mastery_empty: 'No review data yet.',
loading: 'Loading…',
error: 'Error: {msg}',
},

View file

@ -310,6 +310,16 @@ export const es: TranslationNode = {
leeches_none: 'Ninguna carta difícil — todo bien.',
leeches_lapses: '{n} olvidos',
leeches_lapses_one: '{n} olvido',
difficulty_title: 'Difficulty distribution',
difficulty_desc: 'How difficult are your cards according to FSRS.',
difficulty_very_easy: 'Very easy',
difficulty_easy: 'Easy',
difficulty_medium: 'Medium',
difficulty_hard: 'Hard',
difficulty_very_hard: 'Very hard',
mastery_title: 'Deck progress',
mastery_desc: 'Share of mastered cards (stability > 21 days).',
mastery_empty: 'No review data yet.',
loading: 'Cargando…',
error: 'Error: {msg}',
},

View file

@ -310,6 +310,16 @@ export const fr: TranslationNode = {
leeches_none: 'Aucune carte difficile — tout va bien.',
leeches_lapses: '{n} oublis',
leeches_lapses_one: '{n} oubli',
difficulty_title: 'Difficulty distribution',
difficulty_desc: 'How difficult are your cards according to FSRS.',
difficulty_very_easy: 'Very easy',
difficulty_easy: 'Easy',
difficulty_medium: 'Medium',
difficulty_hard: 'Hard',
difficulty_very_hard: 'Very hard',
mastery_title: 'Deck progress',
mastery_desc: 'Share of mastered cards (stability > 21 days).',
mastery_empty: 'No review data yet.',
loading: 'Chargement…',
error: 'Erreur : {msg}',
},

View file

@ -310,6 +310,16 @@ export const it: TranslationNode = {
leeches_none: 'Nessuna carta difficile — pulito.',
leeches_lapses: '{n} ricadute',
leeches_lapses_one: '{n} ricaduta',
difficulty_title: 'Difficulty distribution',
difficulty_desc: 'How difficult are your cards according to FSRS.',
difficulty_very_easy: 'Very easy',
difficulty_easy: 'Easy',
difficulty_medium: 'Medium',
difficulty_hard: 'Hard',
difficulty_very_hard: 'Very hard',
mastery_title: 'Deck progress',
mastery_desc: 'Share of mastered cards (stability > 21 days).',
mastery_empty: 'No review data yet.',
loading: 'Caricamento…',
error: 'Errore: {msg}',
},

View file

@ -27,6 +27,8 @@
let category = $state<DeckCategoryId | null>(null);
let visibility = $state<'private' | 'space' | 'public'>('private');
let fsrsRetention = $state(0.9);
let loading = $state(true);
let saving = $state(false);
let deleting = $state(false);
@ -54,6 +56,7 @@
color = d.color ?? '#6366f1';
category = (d.category as DeckCategoryId | null) ?? null;
visibility = (d.visibility as 'private' | 'space' | 'public') ?? 'private';
fsrsRetention = (d.fsrs_settings as { request_retention?: number } | null)?.request_retention ?? 0.9;
if (d.forked_from_marketplace_deck_id) {
void loadMarketplaceInfo(d);
}
@ -98,6 +101,7 @@
color,
category: category ?? undefined,
visibility,
fsrs_settings: { request_retention: fsrsRetention },
});
toasts.success('Gespeichert.');
goto(`/decks/${deckId}`);
@ -259,7 +263,45 @@
</form>
</section>
<!-- ── 2. Marketplace (nur für Forks) ─────────────────────────── -->
<!-- ── 2. Lern-Algorithmus ───────────────────────────────────────── -->
<section class="settings-section">
<h2 class="section-title">Lern-Algorithmus</h2>
<div class="section-body">
<div class="field">
<div class="retention-header">
<span class="field-label">Ziel-Retention</span>
<span class="retention-value">{Math.round(fsrsRetention * 100)} %</span>
</div>
<input
type="range"
min="50"
max="99"
step="1"
value={Math.round(fsrsRetention * 100)}
oninput={(e) => (fsrsRetention = Number((e.target as HTMLInputElement).value) / 100)}
class="retention-slider"
/>
<div class="retention-ticks" aria-hidden="true">
<span>50 %</span>
<span>70 %</span>
<span>90 %</span>
<span>99 %</span>
</div>
<p class="field-hint">
Wie viele Karten du beim Abfragen richtig beantwortest (im Durchschnitt).
Höher = mehr Wiederholungen, aber bessere Erinnerung.
<strong>90 % ist ein guter Ausgangswert.</strong>
</p>
</div>
<div class="form-actions">
<button type="button" disabled={!canSave} class="btn-primary" onclick={onSave}>
{saving ? 'Speichern…' : 'Speichern'}
</button>
</div>
</div>
</section>
<!-- ── 3. Marketplace (nur für Forks) ─────────────────────────── -->
{#if isForked}
<section class="settings-section">
<h2 class="section-title">Marketplace</h2>
@ -295,7 +337,7 @@
</section>
{/if}
<!-- ── 3. Gefahrenzone ────────────────────────────────────────── -->
<!-- ── 4. Gefahrenzone ────────────────────────────────────────── -->
<section class="settings-section danger-section">
<h2 class="section-title">Weitere Aktionen</h2>
<div class="section-body danger-body">
@ -725,4 +767,41 @@
.btn-ghost:hover {
color: hsl(var(--color-foreground));
}
/* ── Retention slider ──────────────────────────────────────────── */
.retention-header {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.retention-value {
font-size: 1rem;
font-weight: 700;
color: hsl(var(--color-primary));
font-variant-numeric: tabular-nums;
}
.retention-slider {
width: 100%;
accent-color: hsl(var(--color-primary));
cursor: pointer;
margin: 0.25rem 0 0.125rem;
}
.retention-ticks {
display: flex;
justify-content: space-between;
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
padding: 0 0.125rem;
}
.field-hint {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
line-height: 1.5;
margin-top: 0.25rem;
}
</style>

View file

@ -20,6 +20,8 @@
const retentionLayers = stackLayers('stats-retention', 3);
const forecastLayers = stackLayers('stats-forecast', 3);
const leechesLayers = stackLayers('stats-leeches', 3);
const diffLayers = stackLayers('stats-diff', 3);
const masteryLayers = stackLayers('stats-mastery', 3);
const peakDay = $derived.by(() => {
if (!stats) return 1;
@ -57,6 +59,11 @@
return Math.max(1, ...stats.due_forecast.map((d) => d.n));
});
const maxDiffN = $derived.by(() => {
if (!stats) return 1;
return Math.max(1, ...stats.difficulty_distribution.map(d => d.n));
});
function cellLevel(n: number): number {
if (n === 0) return 0;
if (n <= 2) return 1;
@ -347,6 +354,79 @@
</CardSurface>
</li>
<!-- Schwierigkeitsverteilung -->
<li class="stack-wrap">
{#each diffLayers as layer, i (i)}
<div class="layer" style:transform="translate({layer.dx}px,{layer.dy}px) rotate({layer.tilt}deg)" aria-hidden="true"></div>
{/each}
<CardSurface size="md" colorAccent="#EC4899">
<div class="card-inner">
<div class="card-corner">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" aria-hidden="true"><path d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z"/></svg>
</div>
<div class="card-body">
<p class="card-title">{t('stats.difficulty_title')}</p>
<p class="card-desc">{t('stats.difficulty_desc')}</p>
<div class="diff-list">
{#each stats!.difficulty_distribution as item (item.bucket)}
{@const colors = { very_easy: '#10B981', easy: '#84CC16', medium: '#F59E0B', hard: '#F97316', very_hard: '#EF4444' }}
{@const labels = { very_easy: t('stats.difficulty_very_easy'), easy: t('stats.difficulty_easy'), medium: t('stats.difficulty_medium'), hard: t('stats.difficulty_hard'), very_hard: t('stats.difficulty_very_hard') }}
<div class="diff-row">
<span class="diff-label">{labels[item.bucket]}</span>
<div class="diff-bar-wrap">
<div
class="diff-bar-fill"
style:width="{(item.n / maxDiffN) * 100}%"
style:background={colors[item.bucket]}
></div>
</div>
<span class="diff-count">{item.n}</span>
</div>
{/each}
</div>
</div>
</div>
</CardSurface>
</li>
<!-- Deck-Fortschritt -->
<li class="stack-wrap">
{#each masteryLayers as layer, i (i)}
<div class="layer" style:transform="translate({layer.dx}px,{layer.dy}px) rotate({layer.tilt}deg)" aria-hidden="true"></div>
{/each}
<CardSurface size="md" colorAccent="#7C3AED">
<div class="card-inner">
<div class="card-corner">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" aria-hidden="true"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
</div>
<div class="card-body">
<p class="card-title">{t('stats.mastery_title')}</p>
<p class="card-desc">{t('stats.mastery_desc')}</p>
{#if stats!.deck_mastery.length === 0}
<p class="retention-none">{t('stats.mastery_empty')}</p>
{:else}
<div class="mastery-list">
{#each stats!.deck_mastery.slice(0, 6) as deck (deck.deck_id)}
<div class="mastery-row">
<div class="mastery-head">
<span class="mastery-name">{deck.deck_name}</span>
<span class="mastery-count">{deck.mastered} / {deck.total}</span>
</div>
<div class="mastery-bar-wrap">
<div
class="mastery-bar-fill"
style:width="{deck.pct * 100}%"
></div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
</CardSurface>
</li>
</ul>
{/if}
@ -627,4 +707,99 @@
.leech-sep { opacity: 0.5; }
.leech-lapses { color: #DC2626; font-weight: 500; }
/* Difficulty Distribution */
.diff-list {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin-top: auto;
}
.diff-row {
display: grid;
grid-template-columns: 4.5rem 1fr 1.5rem;
align-items: center;
gap: 0.3rem;
}
.diff-label {
font-size: 0.5625rem;
color: hsl(var(--color-muted-foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.diff-bar-wrap {
height: 0.375rem;
background: hsl(var(--color-border));
border-radius: 9999px;
overflow: hidden;
}
.diff-bar-fill {
height: 100%;
border-radius: 9999px;
transition: width 0.4s ease;
}
.diff-count {
font-size: 0.5625rem;
color: hsl(var(--color-muted-foreground));
text-align: right;
font-variant-numeric: tabular-nums;
}
/* Deck Mastery */
.mastery-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-top: auto;
max-height: 11rem;
overflow-y: auto;
}
.mastery-row {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.mastery-head {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.mastery-name {
font-size: 0.625rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 8rem;
}
.mastery-count {
font-size: 0.5625rem;
color: hsl(var(--color-muted-foreground));
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.mastery-bar-wrap {
height: 0.3125rem;
background: hsl(var(--color-border));
border-radius: 9999px;
overflow: hidden;
}
.mastery-bar-fill {
height: 100%;
background: #7C3AED;
border-radius: 9999px;
transition: width 0.4s ease;
}
</style>

View file

@ -11,7 +11,7 @@
} from '@cards/domain';
import { apiErrorMessage } from '$lib/api/error.ts';
import { getDeck } from '$lib/api/decks.ts';
import { listDueReviews, gradeReview, type DueReview } from '$lib/api/reviews.ts';
import { listDueReviews, gradeReview, undoReview, type DueReview } from '$lib/api/reviews.ts';
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import { renderMarkdown } from '$lib/markdown.ts';
import { toasts } from '$lib/stores/toasts.svelte.ts';
@ -32,6 +32,15 @@
let loading = $state(true);
let busy = $state(false);
let stats = $state({ reviewed: 0, again: 0 });
let dueTotal = $state(0);
let recoveryMode = $state(false);
let showRecovery = $state(false);
let fsrsTooltipOpen = $state(false);
let undoCardId = $state<string | null>(null);
let undoSubIndex = $state<number | null>(null);
let undoTimer = $state<ReturnType<typeof setTimeout> | null>(null);
let undoProgress = $state(0); // 0→100 für den Ablauf-Balken
let undoRafId = $state<number | null>(null);
const current = $derived(queue[queueIndex]);
const isDone = $derived(!loading && queueIndex >= queue.length);
@ -131,6 +140,14 @@
};
});
const STATE_LABELS: Record<string, string> = {
new: 'Neu', learning: 'Lernend', review: 'Wiederholen', relearning: 'Nachlernen'
};
$effect(() => {
if (!revealed) fsrsTooltipOpen = false;
});
onMount(async () => {
if (!devUser.id) {
goto('/');
@ -143,7 +160,9 @@
]);
deckName = d.name;
deckColor = d.color ?? null;
dueTotal = due.total;
queue = due.reviews;
if (due.total > 30 && !recoveryMode) showRecovery = true;
} catch (e) {
toasts.error(`Sitzung konnte nicht geladen werden: ${apiErrorMessage(e)}`);
goto('/study');
@ -153,10 +172,27 @@
window.addEventListener('keydown', onKey);
});
async function startRecovery() {
showRecovery = false;
recoveryMode = true;
loading = true;
try {
const due = await listDueReviews({ deckId, recovery: true });
queue = due.reviews;
queueIndex = 0;
revealed = false;
} catch (e) {
toasts.error(`Fehler: ${apiErrorMessage(e)}`);
} finally {
loading = false;
}
}
onDestroy(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('keydown', onKey);
}
clearUndoTimer();
});
function onKey(e: KeyboardEvent) {
@ -166,6 +202,12 @@
if (isTyping) return; // TypingView übernimmt per svelte:window
if (isMultipleChoice) return; // MultipleChoiceView übernimmt per svelte:window
if (e.key === 'z' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
void undo();
return;
}
if (!revealed) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
@ -190,12 +232,59 @@
if (rating === 'again') stats.again += 1;
queueIndex += 1;
revealed = false;
// Undo-Fenster öffnen
clearUndoTimer();
undoCardId = c.card_id;
undoSubIndex = c.sub_index;
undoProgress = 100;
const startTime = Date.now();
const DURATION = 5000;
function tick() {
const elapsed = Date.now() - startTime;
undoProgress = Math.max(0, 100 - (elapsed / DURATION) * 100);
if (elapsed < DURATION) {
undoRafId = requestAnimationFrame(tick);
} else {
clearUndoTimer();
}
}
undoRafId = requestAnimationFrame(tick);
undoTimer = setTimeout(clearUndoTimer, DURATION);
} catch (e) {
toasts.error(t('card_edit.save_failed', { msg: apiErrorMessage(e) }));
} finally {
busy = false;
}
}
function clearUndoTimer() {
if (undoTimer !== null) { clearTimeout(undoTimer); undoTimer = null; }
if (undoRafId !== null) { cancelAnimationFrame(undoRafId); undoRafId = null; }
undoCardId = null;
undoSubIndex = null;
undoProgress = 0;
}
async function undo() {
if (undoCardId === null || undoSubIndex === null || busy) return;
const cid = undoCardId;
const si = undoSubIndex;
clearUndoTimer();
busy = true;
try {
await undoReview(cid, si);
// Einen Schritt zurück — queue-Index dekrementieren, Karte wieder zeigen
queueIndex = Math.max(0, queueIndex - 1);
revealed = false;
stats.reviewed = Math.max(0, stats.reviewed - 1);
// War die Bewertung 'again'? Nicht eindeutig rückwirkend bestimmbar,
// daher konservativ: kein stats.again decrement
} catch (e) {
toasts.error(`Undo fehlgeschlagen: ${apiErrorMessage(e)}`);
} finally {
busy = false;
}
}
</script>
<div class="study-page">
@ -213,6 +302,19 @@
{queueIndex + 1} / {queue.length}
{/if}
</p>
{#if showRecovery}
<div class="recovery-banner">
<p class="recovery-text">
{dueTotal > 50 ? 'Großer Rückstand' : 'Rückstand'}: {queue.length} Karten fällig.
</p>
<button class="recovery-btn" onclick={startRecovery}>
Sanfter Einstieg <span class="recovery-sub">25 schwächste zuerst</span>
</button>
</div>
{/if}
{#if recoveryMode}
<p class="recovery-active">⚡ Sanfter Einstieg · {queue.length} Karten</p>
{/if}
<a class="aside-manage" href="/decks/{deckId}">{t('study_session.manage_link')}</a>
</aside>
@ -290,6 +392,34 @@
<div class="prose answer">{@html answerHtml}</div>
{/if}
{/if}
{#if revealed && current && !isTyping && !isMultipleChoice && !isImageOcclusion}
<div class="fsrs-info-wrap">
<button
class="fsrs-info-btn"
onclick={() => (fsrsTooltipOpen = !fsrsTooltipOpen)}
aria-label="FSRS-Daten anzeigen"
aria-expanded={fsrsTooltipOpen}
>ⓘ</button>
{#if fsrsTooltipOpen}
<div class="fsrs-popover" role="status" aria-live="polite">
<div class="fsrs-row">
<span class="fsrs-label">Zustand</span>
<span class="fsrs-val">{STATE_LABELS[current.state] ?? current.state}</span>
</div>
<div class="fsrs-row">
<span class="fsrs-label">Stabilität</span>
<span class="fsrs-val">
{current.stability > 0 ? current.stability.toFixed(1) + ' Tage' : ''}
</span>
</div>
<div class="fsrs-row">
<span class="fsrs-label">Schwierigkeit</span>
<span class="fsrs-val">{current.difficulty.toFixed(1)} / 10</span>
</div>
</div>
{/if}
</div>
{/if}
</article>
</CardSurface>
</div>
@ -327,6 +457,15 @@
</button>
</div>
</div>
{#if undoCardId !== null}
<div class="undo-toast" role="status" aria-live="polite">
<div class="undo-bar" style:width="{undoProgress}%"></div>
<span class="undo-text">Bewertet</span>
<button class="undo-btn" onclick={undo} disabled={busy}>
Rückgängig <kbd>⌘Z</kbd>
</button>
</div>
{/if}
{/if}
</div>
</div>
@ -437,6 +576,7 @@
Portrait-Format (5:7) an: Inhalt vertikal zentriert, Padding rechts
vom Color-Stripe. */
.study-inner {
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
@ -667,4 +807,169 @@
.grade.grade-easy:hover:not(:disabled) {
background: hsl(var(--color-success) / 0.08);
}
.fsrs-info-wrap {
position: absolute;
bottom: 0.75rem;
right: 0.75rem;
}
.fsrs-info-btn {
width: 1.375rem;
height: 1.375rem;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 1rem;
cursor: pointer;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
opacity: 0.6;
transition: opacity 0.15s;
}
.fsrs-info-btn:hover { opacity: 1; }
.fsrs-popover {
position: absolute;
bottom: 1.875rem;
right: 0;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
padding: 0.625rem 0.875rem;
min-width: 10rem;
z-index: 20;
box-shadow: 0 4px 16px hsl(var(--color-foreground) / 0.1);
}
.fsrs-row {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 0.125rem 0;
}
.fsrs-label {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
}
.fsrs-val {
font-size: 0.6875rem;
font-weight: 500;
color: hsl(var(--color-foreground));
font-variant-numeric: tabular-nums;
}
.recovery-banner {
margin-top: 0.25rem;
padding: 0.5rem 0.75rem;
background: hsl(45 96% 58% / 0.12);
border: 1px solid hsl(45 96% 58% / 0.35);
border-radius: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.recovery-text {
margin: 0;
font-size: 0.6875rem;
color: hsl(var(--color-foreground));
font-weight: 500;
}
.recovery-btn {
display: inline-flex;
align-items: baseline;
gap: 0.375rem;
background: hsl(45 96% 58%);
color: hsl(30 60% 15%);
border: none;
border-radius: 0.375rem;
padding: 0.3125rem 0.625rem;
font-size: 0.6875rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: opacity 0.15s;
}
.recovery-btn:hover { opacity: 0.85; }
.recovery-sub {
font-weight: 400;
opacity: 0.8;
font-size: 0.625rem;
}
.recovery-active {
margin: 0.25rem 0 0;
font-size: 0.6875rem;
color: hsl(45 96% 45%);
font-weight: 500;
}
.undo-toast {
position: relative;
margin-top: 0.75rem;
width: 100%;
max-width: 24rem;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 0.875rem;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: 0.625rem;
box-shadow: 0 2px 8px hsl(var(--color-foreground) / 0.08);
overflow: hidden;
}
.undo-bar {
position: absolute;
bottom: 0;
left: 0;
height: 2px;
background: hsl(var(--color-primary));
transition: width 0.1s linear;
border-radius: 0 0 0 0.625rem;
}
.undo-text {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
flex: 1;
}
.undo-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.3125rem 0.75rem;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
border: 1px solid hsl(var(--color-primary) / 0.25);
border-radius: 0.375rem;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
font-family: inherit;
transition: background 0.15s;
flex-shrink: 0;
}
.undo-btn:hover:not(:disabled) {
background: hsl(var(--color-primary) / 0.18);
}
.undo-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.undo-btn kbd {
font-size: 0.6875rem;
color: hsl(var(--color-primary) / 0.7);
font-family: inherit;
}
</style>