add language learner project
Some checks failed
Test and deploy staging / staging (push) Has been cancelled

Dutch and Korean (explained in English) and English (explained in Dutch)
from A1 to C1, with a Hangul level for Korean. Lessons with notes, words
and sentences generate multiple-choice, typing, sentence building, cloze,
listening and matching exercises. Endless challenges: a seeded daily
challenge, sprint, survival, mixed practice, level tests, spaced-repetition
reviews and generated drills (numbers, clock times, de/het, plurals, Dutch
and Korean conjugation, particles, Hangul reading, irregular verbs).
Progress is stored in localStorage with export/import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 17:34:56 +00:00
parent afb1f191df
commit 438623d857
69 changed files with 12669 additions and 4 deletions

View File

@@ -0,0 +1,13 @@
<script lang="ts">
import './language-learner.css';
import { onMount } from 'svelte';
import { progress } from '$lib/language-learner/progress.svelte';
let { children } = $props();
onMount(() => progress.load());
</script>
<div class="ll mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
{@render children()}
</div>

View File

@@ -0,0 +1,20 @@
import { allEntries, allLessons } from '$lib/language-learner/course';
import { COURSES, loadCourse } from '$lib/language-learner/courses';
import type { PageServerLoad } from './$types';
// Counted on the server so the overview doesn't download three full courses.
export const load: PageServerLoad = async () => {
const courses = await Promise.all(COURSES.map((c) => loadCourse(c.code)));
return {
counts: Object.fromEntries(
courses.map((c) => [
c.code,
{
lessons: allLessons(c).length,
words: allEntries(c).filter((e) => e.kind === 'word').length,
levels: c.levels.map((lv) => lv.band)
}
])
) as Record<string, { lessons: number; words: number; levels: string[] }>
};
};

View File

@@ -0,0 +1,245 @@
<script lang="ts">
import { resolve } from '$app/paths';
import ArrowRight from 'lucide-svelte/icons/arrow-right';
import Flame from 'lucide-svelte/icons/flame';
import ProgressBar from '$lib/components/ProgressBar.svelte';
import { COURSES } from '$lib/language-learner/courses';
import { progress } from '$lib/language-learner/progress.svelte';
import type { LangCode } from '$lib/language-learner/types';
let { data } = $props();
const goals = [20, 50, 100, 200];
let importError = $state('');
let fileInput = $state<HTMLInputElement>();
const completed = (code: LangCode) =>
Object.keys(progress.data.langs[code]?.lessons ?? {}).length;
function exportProgress() {
const blob = new Blob([progress.export()], { type: 'application/json' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `language-learner-${new Date().toISOString().slice(0, 10)}.json`;
link.click();
URL.revokeObjectURL(link.href);
}
async function importProgress(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0];
if (!file) return;
try {
progress.import(await file.text());
importError = '';
} catch {
importError = 'That file is not a language learner backup.';
}
}
function reset(code: LangCode, name: string) {
if (confirm(`Delete all ${name} progress? This can't be undone.`)) progress.reset(code);
}
</script>
<header class="mb-10">
<h1 class="mb-3 text-4xl font-semibold tracking-tight sm:text-5xl">language learner</h1>
<p class="max-w-2xl text-lg ll-muted">
Dutch, Korean and English from the very first word to advanced. Structured lessons from A1 to
C1, spaced repetition, and endless daily challenges to grind.
</p>
</header>
{#if progress.loaded}
<section class="mb-10 grid gap-3 sm:grid-cols-3" aria-label="Today">
<div class="ll-card">
<p class="text-sm ll-muted">streak</p>
<p class="flex items-center gap-2 text-3xl font-semibold">
<Flame
size={26}
aria-hidden="true"
class={progress.streak() ? 'text-[var(--ll-gold)]' : 'll-muted'}
/>
{progress.streak()}
{progress.streak() === 1 ? 'day' : 'days'}
</p>
</div>
<div class="ll-card">
<p class="text-sm ll-muted">today</p>
<p class="mb-2 text-3xl font-semibold">
{progress.todayXp()}
<span class="text-base font-normal ll-muted">/ {progress.settings.goal} XP</span>
</p>
<ProgressBar
value={Math.min(progress.todayXp(), progress.settings.goal)}
max={progress.settings.goal}
label="Daily goal"
small
/>
</div>
<div class="ll-card">
<p class="text-sm ll-muted">total</p>
<p class="text-3xl font-semibold">
{Object.values(progress.data.langs).reduce((n, p) => n + (p?.xp ?? 0), 0)} XP
</p>
</div>
</section>
{/if}
<section aria-label="Languages" class="mb-14 grid gap-4 md:grid-cols-3">
{#each COURSES as c (c.code)}
{@const count = data.counts[c.code]}
<a
href={resolve('/projects/language-learner/[lang]', { lang: c.code })}
class="ll-card flex flex-col gap-3"
>
<div class="flex items-center justify-between">
<span class="ll-badge">{c.code.toUpperCase()}</span>
{#if progress.loaded && progress.streak(c.code)}
<span class="ll-pill"
><Flame size={14} aria-hidden="true" /> {progress.streak(c.code)}</span
>
{/if}
</div>
<div>
<h2 class="text-2xl font-semibold">{c.name}</h2>
<p class="ll-muted" lang={c.code}>{c.nativeName}</p>
</div>
<p class="text-sm ll-muted" lang={c.glossLang}>{c.tagline}</p>
<p class="text-sm ll-muted">
{count.lessons} lessons · {count.words} words · {count.levels[0]}–{count.levels.at(-1)}
</p>
<div class="mt-auto">
{#if progress.loaded}
<div class="mb-1 flex justify-between text-sm">
<span>{completed(c.code)} / {count.lessons} lessons</span>
{#if progress.due(c.code).length}
<span class="ll-muted">{progress.due(c.code).length} to review</span>
{/if}
</div>
<ProgressBar
value={completed(c.code)}
max={count.lessons}
label="{c.name} lessons done"
small
/>
{/if}
<span class="mt-4 inline-flex items-center gap-1 font-semibold"
>{completed(c.code) ? 'Continue' : 'Start'}
<ArrowRight size={16} aria-hidden="true" /></span
>
</div>
</a>
{/each}
</section>
<section class="mb-14 grid gap-8 md:grid-cols-2">
<div>
<h2 class="mb-4 text-2xl font-semibold">how it works</h2>
<ul class="space-y-3 ll-muted">
<li>
<strong class="text-[var(--site-text)]">Lessons</strong> explain one topic, then drill its words,
sentences and grammar. Wrong answers come back until you get them right.
</li>
<li>
<strong class="text-[var(--site-text)]">Reviews</strong> use spaced repetition: every word you
learn returns just before you'd forget it.
</li>
<li>
<strong class="text-[var(--site-text)]">Challenges</strong> are endless: a daily challenge, a
60-second sprint, survival with three lives, and generated drills for numbers, clock times, conjugation,
articles and particles.
</li>
<li>
<strong class="text-[var(--site-text)]">Start anywhere.</strong> Lessons are never locked, and
a level test lets you skip what you already know.
</li>
</ul>
</div>
<div>
<h2 class="mb-4 text-2xl font-semibold">settings</h2>
{#if progress.loaded}
<div class="ll-card space-y-4">
<label class="flex items-center justify-between gap-4">
<span
>Read words aloud <span class="block text-sm ll-muted">Uses your browser's voices</span
></span
>
<input
type="checkbox"
bind:checked={progress.settings.audio}
onchange={() => progress.save()}
/>
</label>
<label class="flex items-center justify-between gap-4">
<span>Show romanization for Korean</span>
<input
type="checkbox"
bind:checked={progress.settings.romanization}
onchange={() => progress.save()}
/>
</label>
{#each COURSES as c (c.code)}
<label class="flex items-center justify-between gap-4">
<span>Typing exercises in {c.name}</span>
<input
type="checkbox"
checked={progress.typing(c.code)}
onchange={(event) => {
progress.settings.typing[c.code] = event.currentTarget.checked;
progress.save();
}}
/>
</label>
{/each}
<div class="flex flex-wrap items-center justify-between gap-3">
<span>Daily goal</span>
<div class="flex gap-2">
{#each goals as goal (goal)}
<button
type="button"
class="ll-chip"
aria-pressed={progress.settings.goal === goal}
onclick={() => {
progress.settings.goal = goal;
progress.save();
}}>{goal} XP</button
>
{/each}
</div>
</div>
<div class="border-t border-[var(--site-border)] pt-4">
<p class="mb-3 text-sm ll-muted">
Progress is saved in this browser. Export it to move it to another device.
</p>
<div class="flex flex-wrap gap-2">
<button type="button" class="ll-button ll-button-quiet" onclick={exportProgress}
>Export</button
>
<button
type="button"
class="ll-button ll-button-quiet"
onclick={() => fileInput?.click()}>Import</button
>
<input
bind:this={fileInput}
type="file"
accept="application/json"
class="hidden"
onchange={importProgress}
/>
{#each COURSES as c (c.code)}
{#if progress.data.langs[c.code]}
<button
type="button"
class="ll-button ll-button-quiet"
onclick={() => reset(c.code, c.name)}>Reset {c.name}</button
>
{/if}
{/each}
</div>
{#if importError}<p class="mt-2 text-sm text-[var(--ll-wrong)]">{importError}</p>{/if}
</div>
</div>
{/if}
</div>
</section>

View File

@@ -0,0 +1,10 @@
import { error } from '@sveltejs/kit';
import { isLang, loadCourse } from '$lib/language-learner/courses';
import { loadDrills } from '$lib/language-learner/drills';
import type { LayoutLoad } from './$types';
export const load: LayoutLoad = async ({ params }) => {
if (!isLang(params.lang)) error(404, 'Unknown language');
const [course, drills] = await Promise.all([loadCourse(params.lang), loadDrills(params.lang)]);
return { course, drills };
};

View File

@@ -0,0 +1,211 @@
<script lang="ts">
import { resolve } from '$app/paths';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import Check from 'lucide-svelte/icons/check';
import Flame from 'lucide-svelte/icons/flame';
import ProgressBar from '$lib/components/ProgressBar.svelte';
import { allLessons } from '$lib/language-learner/course';
import { progress } from '$lib/language-learner/progress.svelte';
import { dayKey } from '$lib/language-learner/srs';
import { MODES } from '$lib/language-learner/modes';
let { data } = $props();
const course = $derived(data.course);
const code = $derived(course.code);
const p = $derived(progress.loaded ? progress.data.langs[code] : undefined);
const lessons = $derived(allLessons(course));
const next = $derived(lessons.find((l) => !p?.lessons[l.id]));
const due = $derived(progress.loaded ? progress.due(code).length : 0);
const dailyScore = $derived(p?.daily[dayKey()]);
const challenges = $derived(MODES.filter((m) => m.group === 'challenge'));
const bestOf = (mode: string) => p?.bests[mode];
</script>
<a
href={resolve('/projects/language-learner')}
class="mb-6 inline-flex items-center gap-1 text-sm ll-muted hover:text-[var(--site-text)]"
><ArrowLeft size={14} aria-hidden="true" /> language learner</a
>
<header class="mb-8 flex flex-wrap items-end justify-between gap-4">
<div>
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">{course.name}</h1>
<p class="mt-2 text-lg ll-muted">
<span lang={code}>{course.nativeName}</span> ·
<span lang={course.glossLang}>{course.tagline}</span>
</p>
</div>
{#if progress.loaded}
<div class="flex flex-wrap gap-2">
<span class="ll-pill"
><Flame size={14} aria-hidden="true" /> {progress.streak(code)} day streak</span
>
<span class="ll-pill">{progress.todayXp(code)} XP today</span>
<span class="ll-pill">{p?.xp ?? 0} XP total</span>
</div>
{/if}
</header>
<section class="mb-12 grid gap-4 md:grid-cols-2" aria-label="Up next">
<div class="ll-card flex flex-col justify-between gap-4">
{#if next}
<div>
<p class="text-sm ll-muted">
{p && Object.keys(p.lessons).length ? 'next lesson' : 'start here'}
</p>
<h2 class="text-2xl font-semibold" lang={code}>{next.title}</h2>
<p class="ll-muted">{next.summary}</p>
</div>
<a
href={resolve('/projects/language-learner/[lang]/lesson/[lesson]', {
lang: code,
lesson: next.id
})}
class="ll-button self-start">Start lesson</a
>
{:else}
<div>
<p class="text-sm ll-muted">course complete</p>
<h2 class="text-2xl font-semibold">Every lesson done</h2>
<p class="ll-muted">Keep your words fresh with reviews and challenges.</p>
</div>
{/if}
</div>
<div class="ll-card flex flex-col justify-between gap-4">
<div>
<p class="text-sm ll-muted">reviews</p>
<h2 class="text-2xl font-semibold">{due} due</h2>
<p class="ll-muted">
{#if !p || !Object.keys(p.cards).length}
Words from finished lessons show up here when it's time to repeat them.
{:else if due}
Spaced repetition: review now to keep them.
{:else}
Nothing due. You can still practise your weakest words.
{/if}
</p>
</div>
<a
href={resolve('/projects/language-learner/[lang]/practice/[mode]', {
lang: code,
mode: 'review'
})}
class="ll-button self-start {due ? '' : 'll-button-quiet'}">Review</a
>
</div>
</section>
<section class="mb-12" aria-labelledby="challenges">
<div class="mb-4 flex flex-wrap items-end justify-between gap-3">
<h2 id="challenges" class="text-2xl font-semibold">challenges</h2>
<a
href={resolve('/projects/language-learner/[lang]/words', { lang: code })}
class="text-sm ll-muted underline underline-offset-2 hover:text-[var(--site-text)]"
>all words</a
>
</div>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{#each challenges as m (m.id)}
<a
href={resolve('/projects/language-learner/[lang]/practice/[mode]', {
lang: code,
mode: m.id
})}
class="ll-card"
>
<h3 class="font-semibold">{m.title}</h3>
<p class="text-sm ll-muted">{m.description}</p>
<p class="mt-2 text-sm">
{#if m.id === 'daily'}
{#if dailyScore !== undefined}<span class="ll-done">done today · {dailyScore}%</span
>{:else}not done yet{/if}
{:else if bestOf(m.id) !== undefined}
best: {bestOf(m.id)}
{/if}
</p>
</a>
{/each}
</div>
<h3 class="mt-8 mb-3 text-lg font-semibold">endless drills</h3>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each data.drills as d (d.id)}
{@const skill = Object.entries(p?.skills ?? {}).filter(
([k]) => k === d.id || k.startsWith(d.id + '-')
)}
{@const seen = skill.reduce((n, [, s]) => n + s.seen, 0)}
{@const right = skill.reduce((n, [, s]) => n + s.right, 0)}
<a
href={resolve('/projects/language-learner/[lang]/practice/[mode]', {
lang: code,
mode: `drill-${d.id}`
})}
class="ll-card"
>
<h4 class="font-semibold">{d.title}</h4>
<p class="text-sm ll-muted">{d.description}</p>
{#if seen}<p class="mt-2 text-sm">
{seen} answered · {Math.round((right / seen) * 100)}% right
</p>{/if}
</a>
{/each}
</div>
</section>
<section aria-labelledby="levels">
<h2 id="levels" class="mb-4 text-2xl font-semibold">lessons</h2>
<div class="space-y-10">
{#each course.levels as lv (lv.id)}
{@const done = lv.lessons.filter((l) => p?.lessons[l.id]).length}
<div>
<div class="mb-3 flex flex-wrap items-end justify-between gap-3">
<div>
<h3 class="text-xl font-semibold">
<span class="ll-badge mr-2">{lv.band}</span>{lv.title}
</h3>
<p class="text-sm ll-muted">{lv.description}</p>
</div>
<div class="flex items-center gap-3">
<span class="text-sm ll-muted">{done}/{lv.lessons.length}</span>
<a
href={resolve('/projects/language-learner/[lang]/practice/[mode]', {
lang: code,
mode: `test-${lv.id}`
})}
class="ll-chip">{done === lv.lessons.length ? 'Level test' : 'Test out'}</a
>
</div>
</div>
<div class="mb-3">
<ProgressBar value={done} max={lv.lessons.length} label="{lv.band} progress" small />
</div>
<ol class="grid gap-2 md:grid-cols-2">
{#each lv.lessons as lesson, i (lesson.id)}
{@const record = p?.lessons[lesson.id]}
<li>
<a
href={resolve('/projects/language-learner/[lang]/lesson/[lesson]', {
lang: code,
lesson: lesson.id
})}
class="ll-card flex items-center gap-3 !p-3"
>
<span
class="ll-lesson-dot"
class:is-done={record}
class:is-next={next?.id === lesson.id}
>
{#if record}<Check size={16} aria-label="done" />{:else}{i + 1}{/if}
</span>
<span class="min-w-0 flex-1">
<span class="block truncate font-medium" lang={code}>{lesson.title}</span>
<span class="block truncate text-sm ll-muted">{lesson.summary}</span>
</span>
{#if record}<span class="text-sm ll-muted">{record.best}%</span>{/if}
</a>
</li>
{/each}
</ol>
</div>
{/each}
</div>
</section>

View File

@@ -0,0 +1,11 @@
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ parent }) => {
const { course } = await parent();
return {
seo: {
title: `Learn ${course.name} · Language Learner`,
description: `${course.name} lessons from ${course.levels[0].band} to ${course.levels.at(-1)!.band}, with spaced repetition and endless daily challenges.`
}
};
};

View File

@@ -0,0 +1,173 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { onMount } from 'svelte';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import ArrowRight from 'lucide-svelte/icons/arrow-right';
import { lessonSession } from '$lib/language-learner/exercises';
import { romanize } from '$lib/language-learner/hangul';
import { progress } from '$lib/language-learner/progress.svelte';
import { seeded } from '$lib/language-learner/random';
import { hasVoice } from '$lib/language-learner/speech';
import type { Exercise, SessionResult } from '$lib/language-learner/types';
import Results from '$lib/language-learner/ui/Results.svelte';
import Session from '$lib/language-learner/ui/Session.svelte';
import Speak from '$lib/language-learner/ui/Speak.svelte';
let { data } = $props();
const course = $derived(data.course);
const lesson = $derived(data.lesson);
const code = $derived(course.code);
let voice = $state(false);
let queue = $state<Exercise[] | null>(null);
let result = $state<SessionResult | null>(null);
let run = $state(0);
const audio = $derived(voice && progress.settings.audio);
const record = $derived(
progress.loaded ? progress.data.langs[code]?.lessons[lesson.id] : undefined
);
const showRoman = $derived(code === 'ko' && progress.settings.romanization);
onMount(async () => {
voice = await hasVoice(course.voice);
});
// Reset when navigating between lessons.
$effect(() => {
void lesson.id;
queue = null;
result = null;
});
function start() {
queue = lessonSession(course, lesson, seeded(`${lesson.id}:${Date.now()}`), {
typing: progress.typing(code),
audio
});
result = null;
run++;
scrollTo({ top: 0 });
}
function finish(r: SessionResult) {
queue = null;
if (r.reason !== 'done') return;
const score = Math.round((r.firstTry / Math.max(1, r.total)) * 100);
const bonus = 10 + (score === 100 ? 5 : 0);
progress.completeLesson(code, lesson.id, score, [...lesson.words, ...lesson.sentences]);
progress.addXp(code, bonus);
result = { ...r, xp: r.xp + bonus };
scrollTo({ top: 0 });
}
</script>
{#if queue}
{#key run}
<Session {course} {queue} requeue {audio} onfinish={finish} />
{/key}
{:else}
<a
href={resolve('/projects/language-learner/[lang]', { lang: code })}
class="mb-6 inline-flex items-center gap-1 text-sm ll-muted hover:text-[var(--site-text)]"
><ArrowLeft size={14} aria-hidden="true" /> {course.name}</a
>
{#if result}
<Results
{result}
{code}
glossLang={course.glossLang}
title="Lesson complete"
subtitle={lesson.title}
score={Math.round((result.firstTry / Math.max(1, result.total)) * 100)}
percent
>
<button type="button" class="ll-button ll-button-quiet" onclick={start}>Practise again</button
>
{#if data.next}
<a
href={resolve('/projects/language-learner/[lang]/lesson/[lesson]', {
lang: code,
lesson: data.next.id
})}
class="ll-button">Next: {data.next.title} <ArrowRight size={16} aria-hidden="true" /></a
>
{:else}
<a href={resolve('/projects/language-learner/[lang]', { lang: code })} class="ll-button"
>Back to {course.name}</a
>
{/if}
</Results>
{/if}
<header class="mb-8 flex flex-wrap items-end justify-between gap-4">
<div>
<p class="mb-1 text-sm ll-muted">
<span class="ll-badge mr-2">{data.level.band}</span>{data.level.title}
</p>
<h1 class="text-3xl font-semibold tracking-tight sm:text-4xl" lang={code}>{lesson.title}</h1>
<p class="mt-1 text-lg ll-muted">{lesson.summary}</p>
</div>
<div class="flex items-center gap-3">
{#if record}<span class="text-sm ll-muted">best {record.best}% · {record.runs}×</span>{/if}
<button type="button" class="ll-button" onclick={start}
>{record ? 'Practise' : 'Start lesson'}</button
>
</div>
</header>
<div class="grid gap-10 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
<article class="ll-notes prose" lang={course.glossLang}>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- trusted course content -->
{@html data.notesHtml}
</article>
<aside class="space-y-8">
<section>
<h2 class="mb-3 text-lg font-semibold">
{lesson.words.some((w) => w.kind === 'script') ? 'letters and words' : 'words'}
</h2>
<ul class="divide-y divide-[var(--site-border)] border-y border-[var(--site-border)]">
{#each lesson.words as w (w.id)}
<li class="flex items-center gap-3 py-2">
<span class="min-w-0 flex-1">
<span class="font-medium" lang={code}>{w.target.join(' / ')}</span>
{#if showRoman && w.kind === 'word'}<span class="ml-1 text-sm ll-muted"
>{romanize(w.target[0])}</span
>{/if}
<span
class="block text-sm ll-muted"
lang={w.kind === 'script' ? undefined : course.glossLang}
>{w.gloss.join(', ')}{w.note ? ` · ${w.note}` : ''}</span
>
</span>
{#if audio && (w.kind !== 'script' || /[가-힣]/.test(w.target[0]))}<Speak
text={w.target[0]}
voice={course.voice}
/>{/if}
</li>
{/each}
</ul>
</section>
{#if lesson.sentences.length}
<section>
<h2 class="mb-3 text-lg font-semibold">sentences</h2>
<ul class="divide-y divide-[var(--site-border)] border-y border-[var(--site-border)]">
{#each lesson.sentences as s (s.id)}
<li class="flex items-center gap-3 py-2">
<span class="min-w-0 flex-1">
<span lang={code}>{s.target[0]}</span>
<span class="block text-sm ll-muted" lang={course.glossLang}>{s.gloss[0]}</span>
</span>
{#if audio}<Speak text={s.target[0]} voice={course.voice} />{/if}
</li>
{/each}
</ul>
</section>
{/if}
<button type="button" class="ll-button w-full" onclick={start}
>{record ? 'Practise' : 'Start lesson'}</button
>
</aside>
</div>
{/if}

View File

@@ -0,0 +1,24 @@
import { error } from '@sveltejs/kit';
import { marked } from 'marked';
import { allLessons } from '$lib/language-learner/course';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ parent, params }) => {
const { course } = await parent();
const lessons = allLessons(course);
const index = lessons.findIndex((l) => l.id === params.lesson);
if (index < 0) error(404, 'Unknown lesson');
const lesson = lessons[index];
const level = course.levels.find((lv) => lv.id === lesson.level)!;
return {
lesson,
level,
next: lessons[index + 1] ?? null,
// Notes are course content written in this repository, not user input.
notesHtml: marked.parse(lesson.notes, { async: false }),
seo: {
title: `${lesson.title} · ${course.name} ${level.band}`,
description: `${course.name} lesson: ${lesson.summary}. ${lesson.words.length} words with exercises.`
}
};
};

View File

@@ -0,0 +1,346 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { onMount } from 'svelte';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import { mixed, reviews, scopeOf } from '$lib/language-learner/challenges';
import { progress } from '$lib/language-learner/progress.svelte';
import { seeded, type Rng } from '$lib/language-learner/random';
import { hasVoice } from '$lib/language-learner/speech';
import { dayKey } from '$lib/language-learner/srs';
import type { Exercise, SessionResult } from '$lib/language-learner/types';
import Results from '$lib/language-learner/ui/Results.svelte';
import Session from '$lib/language-learner/ui/Session.svelte';
let { data } = $props();
const course = $derived(data.course);
const code = $derived(course.code);
const mode = $derived(data.mode);
type Setup = {
queue: Exercise[];
more?: () => Exercise | null;
lives?: number;
seconds?: number;
requeue?: boolean;
xpPer?: number;
};
let voice = $state(false);
let setup = $state<Setup | null>(null);
let result = $state<SessionResult | null>(null);
type Headline = {
title: string;
score?: number;
percent?: boolean;
record: boolean;
note?: string;
};
let headline = $state<Headline | null>(null);
let run = $state(0);
let drillLevel = $state(1);
const audio = $derived(voice && progress.settings.audio);
const p = $derived(progress.loaded ? progress.data.langs[code] : undefined);
const scopeId = $derived(p?.scope ?? '');
const scope = $derived(scopeOf(course, p, scopeId));
const mixedMode = $derived(['daily', 'sprint', 'survival', 'endless'].includes(mode.kind));
const bestKey = $derived(mode.kind === 'drill' ? `drill-${mode.id}` : mode.kind);
const best = $derived(p?.bests[bestKey]);
const dailyDone = $derived(mode.kind === 'daily' ? p?.daily[dayKey()] : undefined);
const reviewInfo = $derived(
mode.kind === 'review' && p
? reviews(course, p, progress.due(code), seeded(0), { typing: false, audio: false })
: null
);
onMount(async () => {
voice = await hasVoice(course.voice);
});
// A sensible default drill level: the highest level you've started.
$effect(() => {
if (progress.loaded && mode.kind === 'drill')
drillLevel = scopeOf(course, progress.data.langs[code]).level;
});
$effect(() => {
void data.mode;
setup = null;
result = null;
});
function start() {
const opts = { typing: progress.typing(code), audio };
const rng: Rng = mode.kind === 'daily' ? seeded(`${dayKey()}:${code}`) : seeded(Date.now());
const lang = progress.lang(code);
const drills = data.drills;
switch (mode.kind) {
case 'daily': {
const next = mixed({ course, scope, drills, rng, opts, p: lang });
setup = { queue: Array.from({ length: 20 }, next), xpPer: 3 };
break;
}
case 'sprint':
setup = {
queue: [],
more: mixed({ course, scope, drills, rng, opts, p: lang }),
seconds: 60,
xpPer: 1
};
break;
case 'survival':
setup = {
queue: [],
more: mixed({ course, scope, drills, rng, opts, p: lang, ramp: true }),
lives: 3
};
break;
case 'endless':
setup = { queue: [], more: mixed({ course, scope, drills, rng, opts, p: lang }) };
break;
case 'review': {
const r = reviews(course, lang, progress.due(code), rng, opts);
setup = { queue: r.queue, more: r.more, requeue: true };
break;
}
case 'drill': {
const drill = drills.find((d) => d.id === mode.id)!;
const level = drillLevel;
setup = { queue: [], more: () => drill.make({ rng, level, opts, course }) };
break;
}
case 'test': {
const lv = course.levels.find((l) => l.id === mode.level)!;
const next = mixed({
course,
scope: {
lessons: lv.lessons,
level: scopeOf(course, undefined, lv.id).level,
label: lv.band
},
drills: [],
rng,
opts
});
setup = { queue: Array.from({ length: 20 }, next), xpPer: 2 };
break;
}
}
result = null;
headline = null;
run++;
}
function finish(r: SessionResult) {
const s = setup;
setup = null;
if (!s) return;
const accuracy = Math.round((r.correct / Math.max(1, r.answered)) * 100);
switch (mode.kind) {
case 'daily': {
if (r.answered < 20) {
headline = {
title: 'Daily challenge stopped',
record: false,
note: 'Finish all 20 to record your score.'
};
break;
}
const first = p?.daily[dayKey()] === undefined;
progress.recordDaily(code, accuracy);
if (first) {
progress.addXp(code, 20);
r = { ...r, xp: r.xp + 20 };
}
headline = {
title: 'Daily challenge done',
score: accuracy,
percent: true,
record: false,
note: first
? 'Your score for today is saved. +20 XP bonus.'
: 'Only your first run of the day counts.'
};
break;
}
case 'sprint':
case 'survival':
case 'endless':
case 'drill':
headline = {
title:
mode.kind === 'survival'
? 'Out of lives'
: mode.kind === 'sprint'
? "Time's up"
: 'Session over',
score: r.correct,
record: r.correct > 0 && progress.recordBest(code, bestKey, r.correct)
};
break;
case 'review':
headline = { title: 'Review done', record: false };
break;
case 'test': {
const passed = r.answered >= 20 && accuracy >= 80;
const lv = course.levels.find((l) => l.id === mode.level)!;
if (passed)
for (const lesson of lv.lessons)
progress.completeLesson(code, lesson.id, accuracy, [
...lesson.words,
...lesson.sentences
]);
headline = {
title: passed ? `${lv.band} passed` : r.answered < 20 ? 'Test stopped' : 'Not yet',
score: accuracy,
percent: true,
record: false,
note: passed
? `Every ${lv.band} lesson is now marked as done and its words are in your reviews.`
: 'You need 80% on all 20 questions. The lessons will get you there.'
};
break;
}
}
result = r;
}
</script>
{#if setup}
{#key run}
<Session
{course}
queue={setup.queue}
more={setup.more}
lives={setup.lives}
seconds={setup.seconds}
requeue={setup.requeue}
xpPer={setup.xpPer}
{audio}
onfinish={finish}
/>
{/key}
{:else}
<a
href={resolve('/projects/language-learner/[lang]', { lang: code })}
class="mb-6 inline-flex items-center gap-1 text-sm ll-muted hover:text-[var(--site-text)]"
><ArrowLeft size={14} aria-hidden="true" /> {course.name}</a
>
{#if result && headline}
<Results
{result}
{code}
glossLang={course.glossLang}
title={headline.title}
subtitle={mode.title}
score={headline.score}
percent={headline.percent}
record={headline.record}
>
{#if headline.note}<p class="mb-2 w-full ll-muted">{headline.note}</p>{/if}
<button type="button" class="ll-button" onclick={start}>Again</button>
<a
href={resolve('/projects/language-learner/[lang]', { lang: code })}
class="ll-button ll-button-quiet">Back to {course.name}</a
>
</Results>
{/if}
<header class="mb-6">
<h1 class="text-3xl font-semibold tracking-tight sm:text-4xl">{mode.title}</h1>
<p class="mt-1 text-lg ll-muted">{mode.description}</p>
</header>
{#if progress.loaded}
<div class="ll-card max-w-2xl space-y-5">
{#if mixedMode}
<div>
<p class="mb-2 font-semibold">Questions from</p>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="ll-chip"
aria-pressed={scopeId === ''}
onclick={() => {
progress.lang(code).scope = '';
progress.save();
}}>What I have learned</button
>
{#each course.levels as lv (lv.id)}
<button
type="button"
class="ll-chip"
aria-pressed={scopeId === lv.id}
onclick={() => {
progress.lang(code).scope = lv.id;
progress.save();
}}>{lv.band}</button
>
{/each}
<button
type="button"
class="ll-chip"
aria-pressed={scopeId === 'all'}
onclick={() => {
progress.lang(code).scope = 'all';
progress.save();
}}>Everything</button
>
</div>
<p class="mt-2 text-sm ll-muted">
{scope.lessons.length} lessons: {scope.lessons
.slice(0, 4)
.map((l) => l.title)
.join(', ')}{scope.lessons.length > 4 ? ', …' : ''}
</p>
</div>
{/if}
{#if mode.kind === 'drill'}
<div>
<p class="mb-2 font-semibold">Difficulty</p>
<div class="flex flex-wrap gap-2">
{#each [1, 2, 3, 4, 5] as n (n)}
<button
type="button"
class="ll-chip"
aria-pressed={drillLevel === n}
onclick={() => (drillLevel = n)}>{['A1', 'A2', 'B1', 'B2', 'C1'][n - 1]}</button
>
{/each}
</div>
</div>
{/if}
{#if mode.kind === 'review' && reviewInfo}
<p>
{#if !reviewInfo.known}
No words to review yet. Finish a lesson first.
{:else}
<strong>{reviewInfo.due}</strong> due now, {reviewInfo.known} words learned.
{reviewInfo.due
? 'Due words come first; after that you keep practising your weakest words.'
: 'Nothing is due, so this practises your weakest words.'}
{/if}
</p>
{/if}
<div class="flex flex-wrap items-center gap-4">
<button
type="button"
class="ll-button"
disabled={mode.kind === 'review' && !reviewInfo?.known}
onclick={start}>Start</button
>
{#if dailyDone !== undefined}
<span class="ll-done">Done today: {dailyDone}%</span>
{:else if best !== undefined && mode.kind !== 'daily'}
<span class="ll-muted">Best: {best}</span>
{/if}
{#if mode.kind === 'survival' || mode.kind === 'endless' || mode.kind === 'drill'}
<span class="text-sm ll-muted">Endless: stop any time with ✕.</span>
{/if}
</div>
</div>
{/if}
{/if}

View File

@@ -0,0 +1,37 @@
import { error } from '@sveltejs/kit';
import { MODES } from '$lib/language-learner/modes';
import type { PageLoad } from './$types';
export type Mode =
| {
kind: 'daily' | 'sprint' | 'survival' | 'endless' | 'review';
title: string;
description: string;
}
| { kind: 'drill'; id: string; title: string; description: string }
| { kind: 'test'; level: string; title: string; description: string };
export const load: PageLoad = async ({ parent, params }) => {
const { course, drills } = await parent();
let mode: Mode | undefined;
const known = MODES.find((m) => m.id === params.mode);
if (known) mode = { kind: known.id, title: known.title, description: known.description };
const drill =
params.mode.startsWith('drill-') && drills.find((d) => d.id === params.mode.slice(6));
if (drill)
mode = { kind: 'drill', id: drill.id, title: drill.title, description: drill.description };
const level =
params.mode.startsWith('test-') && course.levels.find((lv) => lv.id === params.mode.slice(5));
if (level)
mode = {
kind: 'test',
level: level.id,
title: `${level.band} level test`,
description: `20 questions from ${level.band} (${level.title}). Score 80% or more to mark every ${level.band} lesson as done.`
};
if (!mode) error(404, 'Unknown practice mode');
return {
mode,
seo: { title: `${mode.title} · ${course.name}`, description: mode.description, index: false }
};
};

View File

@@ -0,0 +1,139 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { onMount } from 'svelte';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import { allLessons } from '$lib/language-learner/course';
import { romanize } from '$lib/language-learner/hangul';
import { progress } from '$lib/language-learner/progress.svelte';
import { hasVoice } from '$lib/language-learner/speech';
import { mastery, type Mastery } from '$lib/language-learner/srs';
import { normalize } from '$lib/language-learner/text';
import Speak from '$lib/language-learner/ui/Speak.svelte';
let { data } = $props();
const course = $derived(data.course);
const code = $derived(course.code);
type Filter = 'all' | Mastery | 'sentences';
let filter = $state<Filter>('all');
let query = $state('');
let voice = $state(false);
onMount(async () => {
voice = await hasVoice(course.voice);
});
const lessonTitle = $derived(new Map(allLessons(course).map((l) => [l.id, l.title])));
// A word taught in several lessons is listed once, under the lesson that introduces it.
const rows = $derived(
course.levels
.flatMap((lv) =>
lv.lessons.flatMap((l) =>
[...l.words, ...l.sentences].map((entry) => ({ entry, band: lv.band }))
)
)
.filter((r, i, all) => all.findIndex((x) => x.entry.id === r.entry.id) === i)
.map((r) => ({
...r,
mastery: mastery(progress.loaded ? progress.data.langs[code]?.cards[r.entry.id] : undefined)
}))
);
const counts = $derived(
rows.reduce(
(acc, r) => {
if (r.entry.kind !== 'sentence') acc[r.mastery]++;
return acc;
},
{ new: 0, learning: 0, familiar: 0, known: 0, mastered: 0 } as Record<Mastery, number>
)
);
const visible = $derived.by(() => {
const q = normalize(query);
return rows.filter(
(r) =>
(filter === 'all'
? r.entry.kind !== 'sentence'
: filter === 'sentences'
? r.entry.kind === 'sentence'
: r.entry.kind !== 'sentence' && r.mastery === filter) &&
(!q || [...r.entry.target, ...r.entry.gloss].some((t) => normalize(t).includes(q)))
);
});
const filters: { id: Filter; label: string }[] = [
{ id: 'all', label: 'All words' },
{ id: 'new', label: 'New' },
{ id: 'learning', label: 'Learning' },
{ id: 'familiar', label: 'Familiar' },
{ id: 'known', label: 'Known' },
{ id: 'mastered', label: 'Mastered' },
{ id: 'sentences', label: 'Sentences' }
];
</script>
<a
href={resolve('/projects/language-learner/[lang]', { lang: code })}
class="mb-6 inline-flex items-center gap-1 text-sm ll-muted hover:text-[var(--site-text)]"
><ArrowLeft size={14} aria-hidden="true" /> {course.name}</a
>
<header class="mb-6">
<h1 class="text-3xl font-semibold tracking-tight sm:text-4xl">{course.name} words</h1>
<p class="mt-1 ll-muted">
{#each ['learning', 'familiar', 'known', 'mastered'] as const as m (m)}
<span class="mr-4 inline-flex items-center gap-1.5"
><span class="ll-mastery" data-level={m}></span>{counts[m]} {m}</span
>
{/each}
<span class="inline-flex items-center gap-1.5"
><span class="ll-mastery"></span>{counts.new} new</span
>
</p>
</header>
<div class="mb-4 flex flex-wrap items-center gap-2">
<input
class="ll-input max-w-xs"
type="search"
placeholder="Search"
aria-label="Search words"
bind:value={query}
/>
{#each filters as f (f.id)}
<button
type="button"
class="ll-chip"
aria-pressed={filter === f.id}
onclick={() => (filter = f.id)}>{f.label}</button
>
{/each}
</div>
<p class="mb-2 text-sm ll-muted">{visible.length} shown</p>
<ul class="divide-y divide-[var(--site-border)] border-y border-[var(--site-border)]">
{#each visible.slice(0, 400) as r (r.entry.id)}
<li class="flex items-center gap-3 py-2">
<span class="ll-mastery" data-level={r.mastery} title={r.mastery}></span>
<span class="min-w-0 flex-1">
<span class="font-medium" lang={code}>{r.entry.target.join(' / ')}</span>
{#if code === 'ko' && progress.settings.romanization && r.entry.kind !== 'script'}
<span class="ml-1 text-sm ll-muted">{romanize(r.entry.target[0])}</span>
{/if}
<span
class="block text-sm ll-muted"
lang={r.entry.kind === 'script' ? undefined : course.glossLang}
>{r.entry.gloss.join(', ')}</span
>
</span>
<span class="hidden text-right text-xs ll-muted sm:block"
>{r.band} · {lessonTitle.get(r.entry.lesson)}</span
>
{#if voice && progress.settings.audio}<Speak
text={r.entry.target[0]}
voice={course.voice}
/>{/if}
</li>
{/each}
</ul>
{#if visible.length > 400}<p class="mt-2 text-sm ll-muted">
Showing the first 400. Search to narrow it down.
</p>{/if}

View File

@@ -0,0 +1,11 @@
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ parent }) => {
const { course } = await parent();
return {
seo: {
title: `${course.name} vocabulary · Language Learner`,
description: `Every word and sentence in the ${course.name} course, with translations.`
}
};
};

View File

@@ -0,0 +1,450 @@
.ll {
--ll-right: #3fb872;
--ll-right-bg: rgba(63, 184, 114, 0.12);
--ll-wrong: #e5645c;
--ll-wrong-bg: rgba(229, 100, 92, 0.12);
--ll-gold: #e8b64c;
}
:root[data-theme='light'] .ll {
--ll-right: #16804a;
--ll-right-bg: rgba(22, 128, 74, 0.08);
--ll-wrong: #c23a33;
--ll-wrong-bg: rgba(194, 58, 51, 0.08);
--ll-gold: #a86f00;
}
.ll :lang(ko) {
font-family: 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', var(--font-body), sans-serif;
word-break: keep-all;
}
.ll-card {
border: 1px solid var(--site-border);
border-radius: 0.75rem;
background: var(--site-surface);
padding: 1.25rem;
}
a.ll-card:hover,
button.ll-card:hover:not(:disabled) {
background: var(--site-hover);
}
.ll-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
border-radius: 0.5rem;
background: var(--site-text);
color: var(--site-bg);
padding: 0.55rem 1.1rem;
font-weight: 600;
white-space: nowrap;
}
.ll-button:hover:not(:disabled) {
opacity: 0.85;
}
.ll-button:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.ll-button-quiet {
background: transparent;
color: var(--site-text);
border: 1px solid var(--site-border);
}
.ll-button-quiet:hover:not(:disabled) {
background: var(--site-hover);
opacity: 1;
}
.ll-icon-button,
.ll-speak {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--site-muted);
border-radius: 999px;
}
.ll-icon-button {
width: 2.25rem;
height: 2.25rem;
}
.ll-speak {
width: 1.9rem;
height: 1.9rem;
vertical-align: middle;
}
.ll-icon-button:hover,
.ll-speak:hover {
color: var(--site-text);
background: var(--site-hover);
}
.ll-pill {
display: inline-flex;
align-items: center;
gap: 0.3rem;
border: 1px solid var(--site-border);
border-radius: 999px;
padding: 0.15rem 0.6rem;
font-size: 0.8125rem;
color: var(--site-muted);
white-space: nowrap;
}
.ll-combo {
color: var(--ll-gold);
border-color: var(--ll-gold);
}
.ll-heart {
color: var(--ll-wrong);
fill: var(--ll-wrong);
}
.ll-heart-lost {
opacity: 0.3;
}
.ll-chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
border: 1px solid var(--site-border);
border-radius: 999px;
padding: 0.3rem 0.8rem;
font-size: 0.875rem;
color: var(--site-muted);
}
.ll-chip:hover:not(:disabled) {
background: var(--site-hover);
color: var(--site-text);
}
.ll-chip[aria-pressed='true'] {
border-color: var(--site-text);
color: var(--site-text);
}
.ll-muted {
color: var(--site-muted);
}
.ll-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.25rem;
border-radius: 0.4rem;
border: 1px solid var(--site-border);
padding: 0.1rem 0.4rem;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--site-muted);
}
.ll-done {
color: var(--ll-right);
}
/* session */
.ll-session {
max-width: 42rem;
margin: 0 auto;
}
.ll-session-top {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 2rem;
}
.ll-stat-line {
font-size: 0.875rem;
color: var(--site-muted);
}
.ll-exercise {
min-height: 18rem;
}
.ll-instruction {
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--site-muted);
margin-bottom: 1rem;
}
.ll-prompt {
margin-bottom: 1.5rem;
}
.ll-prompt-text {
font-size: 1.75rem;
font-weight: 600;
line-height: 1.3;
}
.ll-roman {
display: block;
font-size: 0.875rem;
font-weight: 400;
color: var(--site-muted);
}
.ll-context {
margin-top: 0.5rem;
color: var(--site-muted);
}
.ll-hint {
font-size: 0.8125rem;
color: var(--site-muted);
}
.ll-listen {
display: inline-flex;
align-items: center;
justify-content: center;
width: 5rem;
height: 5rem;
border-radius: 1rem;
background: var(--site-text);
color: var(--site-bg);
}
.ll-slow {
margin-left: 0.75rem;
font-size: 0.875rem;
color: var(--site-muted);
text-decoration: underline;
text-underline-offset: 3px;
}
.ll-options {
display: grid;
gap: 0.6rem;
}
@media (min-width: 640px) {
.ll-options {
grid-template-columns: 1fr 1fr;
}
.ll-options.ll-options-wide {
grid-template-columns: 1fr;
}
}
.ll-option {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
border: 1px solid var(--site-border);
border-radius: 0.6rem;
padding: 0.75rem 1rem;
text-align: left;
font-size: 1.0625rem;
background: var(--site-bg);
transition: background 0.1s;
}
.ll-option:hover:not(:disabled) {
background: var(--site-hover);
}
.ll-option kbd {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--site-muted);
border: 1px solid var(--site-border);
border-radius: 0.3rem;
padding: 0 0.35rem;
}
.ll-option.is-glyph span {
font-size: 1.75rem;
line-height: 1.2;
}
.ll-option.is-right {
border-color: var(--ll-right);
background: var(--ll-right-bg);
}
.ll-option.is-wrong {
border-color: var(--ll-wrong);
background: var(--ll-wrong-bg);
}
.ll-option.is-dim {
opacity: 0.5;
}
.ll-option.is-selected {
border-color: var(--site-text);
}
.ll-option.is-matched {
border-color: var(--ll-right);
background: var(--ll-right-bg);
opacity: 0.5;
}
.ll-type {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.ll-type input,
.ll-input {
flex: 1;
min-width: 0;
border: 1px solid var(--site-border);
border-radius: 0.5rem;
background: var(--site-bg);
padding: 0.65rem 0.9rem;
font-size: 1.125rem;
}
.ll-input {
font-size: 1rem;
padding: 0.45rem 0.75rem;
}
.ll-build-line {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
min-height: 3.5rem;
border-bottom: 2px solid var(--site-border);
padding: 0.5rem 0;
margin-bottom: 1rem;
}
.ll-bank {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1.25rem;
}
.ll-tile {
border: 1px solid var(--site-border);
border-bottom-width: 3px;
border-radius: 0.5rem;
background: var(--site-bg);
padding: 0.35rem 0.8rem;
font-size: 1.0625rem;
}
.ll-tile:hover:not(:disabled) {
background: var(--site-hover);
}
.ll-tile.is-used {
visibility: hidden;
}
.ll-actions {
display: flex;
justify-content: flex-end;
}
.ll-match {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.6rem;
}
.ll-match > div {
display: grid;
gap: 0.6rem;
align-content: start;
}
.ll-match.is-shaking {
animation: ll-shake 0.3s;
}
@keyframes ll-shake {
25% {
transform: translateX(-4px);
}
75% {
transform: translateX(4px);
}
}
.ll-feedback {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-top: 1.5rem;
border-radius: 0.75rem;
padding: 1rem 1.25rem;
border: 1px solid;
}
.ll-feedback.is-right {
border-color: var(--ll-right);
background: var(--ll-right-bg);
color: var(--ll-right);
}
.ll-feedback.is-wrong {
border-color: var(--ll-wrong);
background: var(--ll-wrong-bg);
color: var(--ll-wrong);
}
.ll-feedback p {
overflow-wrap: anywhere;
}
.ll-explain {
margin-top: 0.25rem;
color: var(--site-text);
font-size: 0.9375rem;
}
.ll-keys {
margin-top: 2rem;
text-align: center;
font-size: 0.75rem;
color: var(--site-muted);
}
@media (hover: none) {
.ll-keys,
.ll-option kbd {
display: none;
}
}
/* lesson notes */
.ll-notes {
--tw-prose-body: var(--site-text);
--tw-prose-headings: var(--site-text);
--tw-prose-bold: var(--site-text);
--tw-prose-bullets: var(--site-muted);
--tw-prose-th-borders: var(--site-border);
--tw-prose-td-borders: var(--site-border);
--tw-prose-code: var(--site-text);
max-width: none;
}
.ll-notes table {
display: block;
overflow-x: auto;
}
/* level path */
.ll-lesson-dot {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2rem;
height: 2rem;
border-radius: 999px;
border: 1px solid var(--site-border);
font-size: 0.8125rem;
color: var(--site-muted);
}
.ll-lesson-dot.is-done {
border-color: var(--ll-right);
background: var(--ll-right-bg);
color: var(--ll-right);
}
.ll-lesson-dot.is-next {
border-color: var(--site-text);
color: var(--site-text);
}
.ll-mastery {
display: inline-block;
width: 0.6rem;
height: 0.6rem;
border-radius: 999px;
background: var(--site-border);
}
.ll-mastery[data-level='learning'] {
background: var(--ll-wrong);
}
.ll-mastery[data-level='familiar'] {
background: var(--ll-gold);
}
.ll-mastery[data-level='known'] {
background: #5aa0e0;
}
.ll-mastery[data-level='mastered'] {
background: var(--ll-right);
}