- Neue Utility `apiErrorMessage()` in `$lib/api/error.ts`: liest `body.detail` / `body.error` aus ApiError-Responses statt generischer "(err as Error).message" — 22 Dateien auf die Utility umgestellt, keine rohen Type-Casts mehr - MultipleChoiceView: Fallback-UI wenn < 1 Distractor verfügbar — zeigt Antwort direkt + Nochmal/Gewusst-Buttons statt kaputter 1-Option-Auswahl Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
666 lines
18 KiB
Svelte
666 lines
18 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import { page } from '$app/state';
|
|
import { goto } from '$app/navigation';
|
|
import {
|
|
import { apiErrorMessage } from '$lib/api/error.ts';
|
|
clusterIdForSubIndex,
|
|
maskForSubIndex,
|
|
renderClozePrompt,
|
|
renderClozeAnswer,
|
|
type Rating,
|
|
} from '@cards/domain';
|
|
import { getDeck } from '$lib/api/decks.ts';
|
|
import { listDueReviews, gradeReview, 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';
|
|
import { t } from '$lib/i18n/index.svelte.ts';
|
|
import ImageOcclusionView from '$lib/components/ImageOcclusionView.svelte';
|
|
import AudioFrontView from '$lib/components/AudioFrontView.svelte';
|
|
import TypingView from '$lib/components/TypingView.svelte';
|
|
import MultipleChoiceView from '$lib/components/MultipleChoiceView.svelte';
|
|
import CardSurface from '$lib/components/CardSurface.svelte';
|
|
|
|
const deckId = $derived(page.params.deckId ?? '');
|
|
|
|
let deckName = $state('');
|
|
let deckColor = $state<string | null>(null);
|
|
let queue = $state<DueReview[]>([]);
|
|
let queueIndex = $state(0);
|
|
let revealed = $state(false);
|
|
let loading = $state(true);
|
|
let busy = $state(false);
|
|
let stats = $state({ reviewed: 0, again: 0 });
|
|
|
|
const current = $derived(queue[queueIndex]);
|
|
const isDone = $derived(!loading && queueIndex >= queue.length);
|
|
const cardColor = $derived(
|
|
(current?.card?.fields as Record<string, string>)?.color ?? deckColor,
|
|
);
|
|
|
|
const promptMarkdown = $derived.by(() => {
|
|
const c = current;
|
|
if (!c?.card) return '';
|
|
const subIndex = c.sub_index;
|
|
const fields = c.card.fields as Record<string, string>;
|
|
switch (c.card.type) {
|
|
case 'basic':
|
|
return fields.front ?? '';
|
|
case 'basic-reverse':
|
|
return subIndex === 0 ? (fields.front ?? '') : (fields.back ?? '');
|
|
case 'cloze': {
|
|
const text = fields.text ?? '';
|
|
const clusterId = clusterIdForSubIndex(text, subIndex);
|
|
return clusterId !== null ? renderClozePrompt(text, clusterId) : text;
|
|
}
|
|
default:
|
|
return fields.front ?? '';
|
|
}
|
|
});
|
|
|
|
const answerMarkdown = $derived.by(() => {
|
|
const c = current;
|
|
if (!c?.card) return '';
|
|
const subIndex = c.sub_index;
|
|
const fields = c.card.fields as Record<string, string>;
|
|
switch (c.card.type) {
|
|
case 'basic':
|
|
return fields.back ?? '';
|
|
case 'basic-reverse':
|
|
return subIndex === 0 ? (fields.back ?? '') : (fields.front ?? '');
|
|
case 'cloze': {
|
|
const text = fields.text ?? '';
|
|
const clusterId = clusterIdForSubIndex(text, subIndex);
|
|
const body = clusterId !== null ? renderClozeAnswer(text, clusterId) : text;
|
|
const extra = fields.extra ? `\n\n${fields.extra}` : '';
|
|
return body + extra;
|
|
}
|
|
default:
|
|
return fields.back ?? '';
|
|
}
|
|
});
|
|
|
|
const promptHtml = $derived(renderMarkdown(promptMarkdown));
|
|
const answerHtml = $derived(renderMarkdown(answerMarkdown));
|
|
|
|
const isImageOcclusion = $derived(current?.card?.type === 'image-occlusion');
|
|
const imageOcclusionData = $derived.by(() => {
|
|
const c = current;
|
|
if (!c?.card || c.card.type !== 'image-occlusion') return null;
|
|
const fields = c.card.fields as Record<string, string>;
|
|
const mask = maskForSubIndex(fields.mask_regions ?? '', c.sub_index);
|
|
return {
|
|
imageRef: fields.image_ref ?? '',
|
|
maskRegionsJson: fields.mask_regions ?? '[]',
|
|
activeMaskId: mask?.id ?? null,
|
|
};
|
|
});
|
|
|
|
const isMultipleChoice = $derived(current?.card?.type === 'multiple-choice');
|
|
const multipleChoiceData = $derived.by(() => {
|
|
const c = current;
|
|
if (!c?.card || c.card.type !== 'multiple-choice') return null;
|
|
const fields = c.card.fields as Record<string, string>;
|
|
return {
|
|
answer: fields.answer ?? '',
|
|
distractorPool: fields.distractor_pool || undefined,
|
|
};
|
|
});
|
|
|
|
const isTyping = $derived(current?.card?.type === 'typing');
|
|
const typingData = $derived.by(() => {
|
|
const c = current;
|
|
if (!c?.card || c.card.type !== 'typing') return null;
|
|
const fields = c.card.fields as Record<string, string>;
|
|
return {
|
|
answer: fields.answer ?? '',
|
|
aliases: fields.aliases || undefined,
|
|
};
|
|
});
|
|
|
|
const isAudioFront = $derived(current?.card?.type === 'audio-front');
|
|
const audioFrontData = $derived.by(() => {
|
|
const c = current;
|
|
if (!c?.card || c.card.type !== 'audio-front') return null;
|
|
const fields = c.card.fields as Record<string, string>;
|
|
return {
|
|
audioRef: fields.audio_ref ?? '',
|
|
frontText: fields.front || undefined,
|
|
};
|
|
});
|
|
|
|
onMount(async () => {
|
|
if (!devUser.id) {
|
|
goto('/');
|
|
return;
|
|
}
|
|
try {
|
|
const [d, due] = await Promise.all([
|
|
getDeck(deckId),
|
|
listDueReviews({ deckId, limit: 200 }),
|
|
]);
|
|
deckName = d.name;
|
|
deckColor = d.color ?? null;
|
|
queue = due.reviews;
|
|
} catch (e) {
|
|
toasts.error(`Sitzung konnte nicht geladen werden: ${apiErrorMessage(e)}`);
|
|
goto('/study');
|
|
return;
|
|
}
|
|
loading = false;
|
|
window.addEventListener('keydown', onKey);
|
|
});
|
|
|
|
onDestroy(() => {
|
|
if (typeof window !== 'undefined') {
|
|
window.removeEventListener('keydown', onKey);
|
|
}
|
|
});
|
|
|
|
function onKey(e: KeyboardEvent) {
|
|
const target = e.target as HTMLElement | null;
|
|
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
|
|
if (busy || isDone) return;
|
|
if (isTyping) return; // TypingView übernimmt per svelte:window
|
|
if (isMultipleChoice) return; // MultipleChoiceView übernimmt per svelte:window
|
|
|
|
if (!revealed) {
|
|
if (e.key === ' ' || e.key === 'Enter') {
|
|
e.preventDefault();
|
|
revealed = true;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (e.key === '1') return grade('again');
|
|
if (e.key === '2') return grade('hard');
|
|
if (e.key === '3' || e.key === ' ' || e.key === 'Enter') return grade('good');
|
|
if (e.key === '4') return grade('easy');
|
|
}
|
|
|
|
async function grade(rating: Rating) {
|
|
const c = current;
|
|
if (!c || busy) return;
|
|
busy = true;
|
|
try {
|
|
await gradeReview(c.card_id, c.sub_index, rating);
|
|
stats.reviewed += 1;
|
|
if (rating === 'again') stats.again += 1;
|
|
queueIndex += 1;
|
|
revealed = false;
|
|
} catch (e) {
|
|
toasts.error(t('card_edit.save_failed', { msg: apiErrorMessage(e) }));
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="study-page">
|
|
<!-- Sidebar links neben der Karte: Zurück-Pfeil, Deck-Name,
|
|
Fortschritt, Karten-Verwalten-Link — alles als kleiner UI-Block
|
|
gepackt, damit der Karten-Bereich frei vom Chrome bleibt. -->
|
|
<aside class="study-aside" aria-label={t('study_session.back')}>
|
|
<a class="aside-back" href="/decks" aria-label={t('study_session.back')}>
|
|
<span aria-hidden="true">←</span>
|
|
<span>{t('nav.decks')}</span>
|
|
</a>
|
|
<h1 class="aside-name">{deckName}</h1>
|
|
<p class="aside-progress" aria-live="polite">
|
|
{#if !loading && !isDone}
|
|
{queueIndex + 1} / {queue.length}
|
|
{/if}
|
|
</p>
|
|
<a class="aside-manage" href="/decks/{deckId}">{t('study_session.manage_link')}</a>
|
|
</aside>
|
|
|
|
<div class="study-content">
|
|
{#if loading}
|
|
<p class="loading">{t('study_session.loading')}</p>
|
|
{:else if queue.length === 0}
|
|
<div class="study-stage">
|
|
<CardSurface size="hero" raised colorAccent={deckColor} class="study-card empty">
|
|
<div class="study-inner centered">
|
|
<p class="big-emoji" aria-hidden="true">🎉</p>
|
|
<p class="big-line">{t('study.none_due')}</p>
|
|
<a class="action-link" href="/decks/{deckId}">{t('card_edit.back')}</a>
|
|
</div>
|
|
</CardSurface>
|
|
</div>
|
|
{:else if isDone}
|
|
<div class="study-stage">
|
|
<CardSurface size="hero" raised colorAccent={deckColor} class="study-card done">
|
|
<div class="study-inner centered">
|
|
<h2 class="big-line">{t('study_session.all_done')}</h2>
|
|
<p class="muted">{t('study_session.stats', { reviewed: stats.reviewed, again: stats.again })}</p>
|
|
<div class="done-actions">
|
|
<a class="btn-secondary" href="/decks/{deckId}">{t('card_edit.back')}</a>
|
|
<a class="btn-primary" href="/study/{deckId}">{t('study.study_now')}</a>
|
|
</div>
|
|
</div>
|
|
</CardSurface>
|
|
</div>
|
|
{:else}
|
|
<div class="study-stage">
|
|
<CardSurface size="hero" raised colorAccent={cardColor} class="study-card">
|
|
<article class="study-inner" aria-labelledby="study-prompt-heading">
|
|
<h2 id="study-prompt-heading" class="sr-only">
|
|
{revealed ? t('card_new.preview_label') : t('study_session.reveal')}
|
|
</h2>
|
|
{#if isImageOcclusion && imageOcclusionData}
|
|
<ImageOcclusionView
|
|
imageRef={imageOcclusionData.imageRef}
|
|
maskRegionsJson={imageOcclusionData.maskRegionsJson}
|
|
activeMaskId={imageOcclusionData.activeMaskId}
|
|
{revealed}
|
|
/>
|
|
{:else if isMultipleChoice && multipleChoiceData}
|
|
<MultipleChoiceView
|
|
promptHtml={promptHtml}
|
|
answer={multipleChoiceData.answer}
|
|
distractorPool={multipleChoiceData.distractorPool}
|
|
deckId={deckId}
|
|
cardId={current?.card_id ?? ''}
|
|
ongrade={grade}
|
|
/>
|
|
{:else if isTyping && typingData}
|
|
<TypingView
|
|
promptHtml={promptHtml}
|
|
answer={typingData.answer}
|
|
aliases={typingData.aliases}
|
|
answerHtml={answerHtml}
|
|
ongrade={grade}
|
|
/>
|
|
{:else}
|
|
{#if isAudioFront && audioFrontData}
|
|
<AudioFrontView
|
|
audioRef={audioFrontData.audioRef}
|
|
frontText={audioFrontData.frontText}
|
|
/>
|
|
{:else}
|
|
<div class="prose">{@html promptHtml}</div>
|
|
{/if}
|
|
{#if revealed}
|
|
<hr class="divider" />
|
|
<div class="prose answer">{@html answerHtml}</div>
|
|
{/if}
|
|
{/if}
|
|
</article>
|
|
</CardSurface>
|
|
</div>
|
|
|
|
<div class="action-bar" class:invisible={isTyping || isMultipleChoice}>
|
|
<!-- grade-row immer im Flow → setzt die Höhe der action-bar -->
|
|
<div
|
|
class="grade-row"
|
|
class:invisible={!revealed}
|
|
role="group"
|
|
aria-label={t('study_session.grade_hint')}
|
|
aria-hidden={!revealed}
|
|
>
|
|
<button class="grade grade-again" onclick={() => grade('again')} disabled={busy} tabindex={revealed ? 0 : -1}>
|
|
<span>{t('study_session.grade_again')}</span>
|
|
<kbd>1</kbd>
|
|
</button>
|
|
<button class="grade grade-hard" onclick={() => grade('hard')} disabled={busy} tabindex={revealed ? 0 : -1}>
|
|
<span>{t('study_session.grade_hard')}</span>
|
|
<kbd>2</kbd>
|
|
</button>
|
|
<button class="grade grade-good" onclick={() => grade('good')} disabled={busy} tabindex={revealed ? 0 : -1}>
|
|
<span>{t('study_session.grade_good')}</span>
|
|
<kbd>3</kbd>
|
|
</button>
|
|
<button class="grade grade-easy" onclick={() => grade('easy')} disabled={busy} tabindex={revealed ? 0 : -1}>
|
|
<span>{t('study_session.grade_easy')}</span>
|
|
<kbd>4</kbd>
|
|
</button>
|
|
</div>
|
|
<!-- reveal-row liegt absolut über der grade-row -->
|
|
<div class="reveal-row" class:invisible={revealed} aria-hidden={revealed}>
|
|
<button class="btn-primary reveal" onclick={() => (revealed = true)} tabindex={revealed ? -1 : 0}>
|
|
{t('study_session.reveal')} <kbd>Space</kbd>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.study-page {
|
|
position: relative;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100vh;
|
|
overflow: hidden;
|
|
padding: 2rem 1.5rem;
|
|
}
|
|
|
|
/* Sidebar oben links — Deck-Info als kompakter Block, KEIN Einfluss
|
|
auf die Karten-Zentrierung (absolut positioniert). */
|
|
.study-aside {
|
|
position: absolute;
|
|
top: 1.5rem;
|
|
left: 1.5rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
max-width: 16rem;
|
|
}
|
|
|
|
.aside-back {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.375rem;
|
|
font-size: 0.8125rem;
|
|
color: hsl(var(--color-muted-foreground));
|
|
text-decoration: none;
|
|
width: fit-content;
|
|
}
|
|
.aside-back:hover {
|
|
color: hsl(var(--color-foreground));
|
|
}
|
|
|
|
.aside-name {
|
|
margin: 0;
|
|
font-size: 1.25rem;
|
|
font-weight: 600;
|
|
color: hsl(var(--color-foreground));
|
|
line-height: 1.25;
|
|
}
|
|
|
|
.aside-progress {
|
|
margin: 0;
|
|
font-size: 0.8125rem;
|
|
color: hsl(var(--color-muted-foreground));
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.aside-manage {
|
|
margin-top: 0.5rem;
|
|
font-size: 0.8125rem;
|
|
color: hsl(var(--color-muted-foreground));
|
|
text-decoration: none;
|
|
width: fit-content;
|
|
}
|
|
.aside-manage:hover {
|
|
color: hsl(var(--color-primary));
|
|
}
|
|
|
|
/* Content — Karte horizontal + vertikal zentriert auf dem Viewport,
|
|
unabhängig von der Sidebar (die ist absolut platziert). */
|
|
.study-content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 100%;
|
|
}
|
|
|
|
.loading {
|
|
text-align: center;
|
|
color: hsl(var(--color-muted-foreground));
|
|
}
|
|
|
|
.study-stage {
|
|
display: flex;
|
|
justify-content: center;
|
|
width: 100%;
|
|
}
|
|
|
|
/* Mobile: Sidebar bleibt oben links, aber Karte rückt unter die
|
|
Sidebar — Top-Padding größer, damit nichts überlappt. */
|
|
@media (max-width: 720px) {
|
|
.study-page {
|
|
padding: 6.5rem 1rem 2rem;
|
|
}
|
|
.study-aside {
|
|
top: 1rem;
|
|
left: 1rem;
|
|
right: 1rem;
|
|
max-width: none;
|
|
}
|
|
.aside-name {
|
|
font-size: 1.0625rem;
|
|
}
|
|
}
|
|
|
|
/* Die Karte selbst — minimal, fokussiert. CardSurface trägt Border,
|
|
Radius, Shadow, Color-Stripe links. Inneres Layout passt sich an
|
|
Portrait-Format (5:7) an: Inhalt vertikal zentriert, Padding rechts
|
|
vom Color-Stripe. */
|
|
.study-inner {
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
gap: 1.25rem;
|
|
padding: 2rem 1.5rem 2rem 1.875rem;
|
|
height: 100%;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.study-inner.centered {
|
|
justify-content: center;
|
|
align-items: center;
|
|
text-align: center;
|
|
}
|
|
|
|
.prose {
|
|
font-size: 1.125rem;
|
|
line-height: 1.55;
|
|
color: hsl(var(--color-foreground));
|
|
}
|
|
|
|
.prose :global(p) {
|
|
margin: 0 0 0.875em;
|
|
}
|
|
.prose :global(p:last-child) {
|
|
margin-bottom: 0;
|
|
}
|
|
.prose :global(strong) {
|
|
font-weight: 600;
|
|
}
|
|
.prose :global(code) {
|
|
background: hsl(var(--color-muted));
|
|
padding: 0.125rem 0.375rem;
|
|
border-radius: 0.25rem;
|
|
font-size: 0.875em;
|
|
}
|
|
|
|
/* Großes Symbol-Heading — z.B. "# H" auf Periodensystem-Karten */
|
|
.prose :global(h1) {
|
|
font-size: 4rem;
|
|
font-weight: 700;
|
|
text-align: center;
|
|
margin: 0;
|
|
line-height: 1;
|
|
letter-spacing: -0.02em;
|
|
color: hsl(var(--color-foreground));
|
|
}
|
|
|
|
/* Eigenschaften-Tabelle auf der Rückseite */
|
|
.prose :global(table) {
|
|
border-collapse: collapse;
|
|
width: 100%;
|
|
margin-top: 0.625rem;
|
|
font-size: 0.875rem;
|
|
}
|
|
.prose :global(thead tr) {
|
|
display: none;
|
|
}
|
|
.prose :global(td) {
|
|
padding: 0.3rem 0;
|
|
border-bottom: 1px solid hsl(var(--color-border));
|
|
vertical-align: top;
|
|
}
|
|
.prose :global(td:first-child) {
|
|
color: hsl(var(--color-muted-foreground));
|
|
padding-right: 1rem;
|
|
white-space: nowrap;
|
|
width: 1%;
|
|
}
|
|
.prose :global(tr:last-child td) {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.prose.answer {
|
|
color: hsl(var(--color-foreground));
|
|
}
|
|
|
|
.divider {
|
|
border: none;
|
|
border-top: 1px solid hsl(var(--color-border));
|
|
margin: 0.25rem 0;
|
|
}
|
|
|
|
.big-emoji {
|
|
font-size: 2.5rem;
|
|
margin: 0;
|
|
}
|
|
|
|
.big-line {
|
|
margin: 0;
|
|
font-size: 1.25rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.muted {
|
|
margin: 0;
|
|
color: hsl(var(--color-muted-foreground));
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.action-link {
|
|
color: hsl(var(--color-primary));
|
|
font-size: 0.875rem;
|
|
text-decoration: none;
|
|
}
|
|
.action-link:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.done-actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
margin-top: 0.5rem;
|
|
}
|
|
|
|
.btn-primary,
|
|
.btn-secondary {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 0.5rem 1rem;
|
|
border-radius: 0.5rem;
|
|
font-size: 0.875rem;
|
|
text-decoration: none;
|
|
border: 1px solid transparent;
|
|
font-family: inherit;
|
|
cursor: pointer;
|
|
}
|
|
.btn-primary {
|
|
background: hsl(var(--color-primary));
|
|
color: hsl(var(--color-primary-foreground));
|
|
}
|
|
.btn-secondary {
|
|
background: hsl(var(--color-surface));
|
|
color: hsl(var(--color-foreground));
|
|
border-color: hsl(var(--color-border));
|
|
}
|
|
|
|
/* Reveal-Button und Grade-Row sitzen UNTER der Karte — keine
|
|
Sub-Karten, sondern Aktions-Leiste. Bewahrt das "eine Karte ist
|
|
eine Karte"-Gefühl. */
|
|
.action-bar {
|
|
position: relative;
|
|
margin-top: 1.5rem;
|
|
width: 100%;
|
|
max-width: 24rem;
|
|
}
|
|
|
|
.invisible {
|
|
visibility: hidden;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.reveal-row {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.btn-primary.reveal {
|
|
padding: 0.75rem 1.5rem;
|
|
font-size: 0.9375rem;
|
|
}
|
|
.btn-primary.reveal kbd {
|
|
margin-left: 0.5rem;
|
|
padding: 0.0625rem 0.375rem;
|
|
background: hsl(var(--color-primary-foreground) / 0.15);
|
|
border-radius: 0.25rem;
|
|
font-size: 0.75rem;
|
|
font-family: inherit;
|
|
}
|
|
|
|
.grade-row {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.grade {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 0.25rem;
|
|
padding: 0.875rem 0.5rem;
|
|
border: 1px solid hsl(var(--color-border));
|
|
background: hsl(var(--color-surface));
|
|
color: hsl(var(--color-foreground));
|
|
border-radius: 0.5rem;
|
|
font: inherit;
|
|
font-size: 0.875rem;
|
|
cursor: pointer;
|
|
transition: background-color 0.15s ease, border-color 0.15s ease;
|
|
}
|
|
.grade:hover:not(:disabled) {
|
|
background: hsl(var(--color-surface-hover));
|
|
}
|
|
.grade:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
.grade kbd {
|
|
font-size: 0.6875rem;
|
|
color: hsl(var(--color-muted-foreground));
|
|
font-family: inherit;
|
|
}
|
|
|
|
.grade.grade-again {
|
|
border-color: hsl(var(--color-error) / 0.4);
|
|
}
|
|
.grade.grade-again:hover:not(:disabled) {
|
|
background: hsl(var(--color-error) / 0.08);
|
|
}
|
|
.grade.grade-good {
|
|
background: hsl(var(--color-primary));
|
|
color: hsl(var(--color-primary-foreground));
|
|
border-color: hsl(var(--color-primary));
|
|
}
|
|
.grade.grade-good:hover:not(:disabled) {
|
|
background: hsl(var(--color-primary) / 0.9);
|
|
}
|
|
.grade.grade-good kbd {
|
|
color: hsl(var(--color-primary-foreground) / 0.7);
|
|
}
|
|
.grade.grade-easy {
|
|
border-color: hsl(var(--color-success) / 0.4);
|
|
}
|
|
.grade.grade-easy:hover:not(:disabled) {
|
|
background: hsl(var(--color-success) / 0.08);
|
|
}
|
|
</style>
|