Add an English/Czech interface language switch

English stays the default; Czech is a full translation. Only the app chrome
is translated - notes and generated decks keep their own language, which is a
core feature, not a setting.

i18n
- lib/i18n-dict.ts: flat en/cs dictionaries (441 keys each), translate() and
  translatePlural() with real Czech plural rules (1 sada / 2-4 sady / 5+ sad).
  English is the default and the fallback for a missing key.
- lib/i18n.ts: browser store (freezmo.locale.v1), useI18n() hook, and the
  x-freezmo-locale header. The hook's t/plural are bound to the locale React
  is rendering with, so the first client render matches the server HTML and
  then re-renders in the stored language; module-level t is only for event
  handlers and non-React helpers.
- <html lang> follows the choice (inline script + setLocale).

UI
- Settings -> Language segmented control (English / Cestina), plus an EN/CS
  button on the key gate for first run.
- Every screen translated: sidebar, header, tab bar, library, deck cards,
  folder bar, create flow, all seven activities, study chrome, results,
  settings + health panel, API-key gate and help, install sheet, offline and
  the deck-not-found page.
- Plural-aware counts everywhere (decks, folders, activities, runs, photos,
  documents, pages, words, wrong attempts).

Server
- API routes and guards (generate, grade, health, http-guard, rate-limit,
  ollama error mapping) answer in the request's language, so error cards and
  the health panel never fall back to English mid-sentence.

Fixes
- A hydration mismatch when a non-default locale was stored silently dropped
  the .dark class after hydration; render-time t() is now bound to the
  hook's locale (UploadScreen.describeExtraction, ResultsScreen.answerFor,
  DeckCard's folder fallback).
- Bumped the service worker cache to freezmo-v4 for this deploy.

Tests: i18n 27/27 (gate toggle, persistence, html lang, Czech plurals,
settings switch) and a rebuilt regression suite 72/72 (English default,
library, create, all 7 activity types, results, settings, install sheet,
offline, PWA, security, Czech smoke).
This commit is contained in:
Martin Dvořák 2026-10-08 13:56:32 +00:00
commit 618ad5fb9c
42 changed files with 1892 additions and 509 deletions

View file

@ -2,6 +2,7 @@
import { useEffect, useRef, useState } from "react";
import { useI18n } from "@/lib/i18n";
import type { Activity } from "@/lib/schemas";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui";
@ -67,6 +68,7 @@ export function isFillBlankCorrect(userValue: string, answer: string): boolean {
}
export function FillBlank({ activity, active, onAnswered, onNext, isLast }: ActivityComponentProps<FillBlankActivity>) {
const { t } = useI18n();
const [value, setValue] = useState("");
const [chosen, setChosen] = useState<string | null>(null);
const [result, setResult] = useState<boolean | null>(null);
@ -121,8 +123,8 @@ export function FillBlank({ activity, active, onAnswered, onNext, isLast }: Acti
event.stopPropagation();
}}
disabled={result !== null}
aria-label="Type the missing word"
placeholder="type it"
aria-label={t("fill.typeMissing")}
placeholder={t("fill.placeholder")}
autoComplete="off"
autoCapitalize="off"
spellCheck={false}
@ -142,7 +144,7 @@ export function FillBlank({ activity, active, onAnswered, onNext, isLast }: Acti
</PromptCard>
{activity.options ? (
<div className="grid grid-cols-2 gap-2.5" role="group" aria-label="Word bank">
<div className="grid grid-cols-2 gap-2.5" role="group" aria-label={t("activity.wordBank")}>
{activity.options.map((option) => {
const state =
result === null
@ -168,13 +170,13 @@ export function FillBlank({ activity, active, onAnswered, onNext, isLast }: Acti
) : result === null ? (
<div className="flex gap-2">
<Button size="lg" onClick={() => submit(value)} disabled={!value.trim()} className="flex-1">
Check answer
{t("fill.check")}
</Button>
</div>
) : null}
{result === null && !activity.options ? (
<Hint>Press Enter to check. Small typos are forgiven.</Hint>
<Hint>{t("fill.hint")}</Hint>
) : null}
{result !== null ? (
@ -186,11 +188,11 @@ export function FillBlank({ activity, active, onAnswered, onNext, isLast }: Acti
onNext={onNext}
detail={
<span>
Expected: <span className="font-bold">{activity.answer}</span>
{t("fill.expected")} <span className="font-bold">{activity.answer}</span>
{!result && chosen ? (
<>
{" "}
— you wrote <span className="italic">{chosen}</span>
{t("fill.youWrote")} <span className="italic">{chosen}</span>
</>
) : null}
</span>

View file

@ -3,6 +3,7 @@
import { motion } from "framer-motion";
import { useState } from "react";
import { useI18n } from "@/lib/i18n";
import type { Activity } from "@/lib/schemas";
import { Button } from "@/components/ui";
@ -18,6 +19,7 @@ import {
type FlashcardActivity = Extract<Activity, { type: "flashcard" }>;
export function Flashcard({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<FlashcardActivity>) {
const { t } = useI18n();
const [flipped, setFlipped] = useState(false);
const [result, setResult] = useState<boolean | null>(null);
@ -30,13 +32,13 @@ export function Flashcard({ activity, onAnswered, onNext, isLast }: ActivityComp
return (
<>
<ActivityHeader type="flashcard" topic={activity.topic} />
<Hint>{flipped ? "Did you know it before flipping?" : "Tap the card to flip it."}</Hint>
<Hint>{flipped ? t("flashcard.didYouKnow") : t("flashcard.tapToFlip")}</Hint>
<button
type="button"
onClick={() => setFlipped((value) => !value)}
className="group relative block w-full text-left [perspective:1400px]"
aria-label={flipped ? "Flip back to the question" : "Reveal the answer"}
aria-label={flipped ? t("flashcard.flipBack") : t("flashcard.reveal")}
>
<motion.div
animate={{ rotateX: flipped ? 180 : 0 }}
@ -45,18 +47,18 @@ export function Flashcard({ activity, onAnswered, onNext, isLast }: ActivityComp
>
<PromptCard className="absolute inset-0 flex flex-col justify-center gap-3 group-hover:border-frost-300 [backface-visibility:hidden]">
<span className="text-xs font-bold uppercase tracking-widest text-frost-600 dark:text-frost-200">
Question
{t("flashcard.question")}
</span>
<QuestionText>{activity.front}</QuestionText>
<span className="mt-2 text-xs font-semibold text-frost-400 dark:text-frost-400">
tap to flip ↻
{t("flashcard.tapFlip")}
</span>
</PromptCard>
<div className="[transform:rotateX(180deg)] [backface-visibility:hidden]">
<div className="card flex min-h-56 flex-col justify-center gap-3 px-5 py-6 sm:px-6">
<span className="text-xs font-bold uppercase tracking-widest text-frost-600 dark:text-frost-200">
Answer
{t("flashcard.answer")}
</span>
<p className="text-lg font-semibold leading-relaxed text-frost-950 dark:text-frost-50">
{activity.back}
@ -69,10 +71,10 @@ export function Flashcard({ activity, onAnswered, onNext, isLast }: ActivityComp
{flipped && result === null ? (
<div className="grid grid-cols-2 gap-3">
<Button variant="secondary" size="lg" onClick={() => settle(false)}>
😅 Not yet
{t("flashcard.notYet")}
</Button>
<Button variant="success" size="lg" onClick={() => settle(true)}>
🧊 I knew it
{t("flashcard.knewIt")}
</Button>
</div>
) : null}
@ -83,7 +85,7 @@ export function Flashcard({ activity, onAnswered, onNext, isLast }: ActivityComp
explanation={activity.explanation}
xp={activity.xp}
isLast={isLast}
headline={result ? "Frozen solid!" : "Keep it on the pile"}
headline={result ? t("flashcard.frozenSolid") : t("flashcard.keepPile")}
onNext={onNext}
/>
) : null}

View file

@ -2,6 +2,7 @@
import { useMemo, useState } from "react";
import { useI18n } from "@/lib/i18n";
import type { Activity } from "@/lib/schemas";
import { cn, shuffle } from "@/lib/utils";
@ -16,6 +17,7 @@ import {
type MatchingActivity = Extract<Activity, { type: "matching" }>;
export function Matching({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<MatchingActivity>) {
const { t, plural } = useI18n();
const terms = useMemo(
() => shuffle(activity.pairs.map((pair, index) => ({ index, text: pair.term }))),
[activity],
@ -60,7 +62,7 @@ export function Matching({ activity, onAnswered, onNext, isLast }: ActivityCompo
topic={activity.topic}
extra={
<span className="rounded-full bg-[var(--surface-strong)] px-3 py-1 text-xs font-bold text-frost-600 dark:text-frost-200">
{matched.length}/{total} matched
{t("matching.matched", { n: matched.length, total })}
</span>
}
/>
@ -68,10 +70,10 @@ export function Matching({ activity, onAnswered, onNext, isLast }: ActivityCompo
<p className="text-lg font-bold text-frost-950 dark:text-frost-50">{activity.instruction}</p>
<Hint>
{done
? "All matched!"
? t("matching.allMatched")
: selectedTerm === null
? "Tap a term on the left, then its match on the right."
: "Now tap the matching definition →"}
? t("matching.tapTerm")
: t("matching.tapDef")}
</Hint>
</PromptCard>
@ -136,12 +138,10 @@ export function Matching({ activity, onAnswered, onNext, isLast }: ActivityCompo
xp={activity.xp}
isLast={isLast}
onNext={onNext}
headline={misses === 0 ? "Perfect match!" : "Matched — with a few slips"}
headline={misses === 0 ? t("matching.perfect") : t("matching.slips")}
detail={
<span>
{misses === 0
? "You matched everything first try."
: `You made ${misses} wrong ${misses === 1 ? "attempt" : "attempts"}.`}
{misses === 0 ? t("matching.firstTry") : plural(misses, "matching.wrongAttempts")}
</span>
}
/>

View file

@ -2,6 +2,7 @@
import { useEffect, useState } from "react";
import { useI18n } from "@/lib/i18n";
import type { Activity } from "@/lib/schemas";
import {
@ -23,6 +24,7 @@ export function MultipleChoice({
onNext,
isLast,
}: ActivityComponentProps<MultipleChoiceActivity>) {
const { t } = useI18n();
const [selected, setSelected] = useState<number | null>(null);
const answered = selected !== null;
@ -62,7 +64,7 @@ export function MultipleChoice({
<QuestionText>{activity.question}</QuestionText>
</PromptCard>
<div className="flex flex-col gap-2.5" role="group" aria-label="Answer options">
<div className="flex flex-col gap-2.5" role="group" aria-label={t("activity.answerOptions")}>
{activity.options.map((option, index) => (
<OptionTile
key={option + index}
@ -76,7 +78,7 @@ export function MultipleChoice({
))}
</div>
{!answered ? <Hint>Tip: press 1–4 on a keyboard.</Hint> : null}
{!answered ? <Hint>{t("mc.tipKeys")}</Hint> : null}
{answered ? (
<FeedbackPanel
@ -88,7 +90,7 @@ export function MultipleChoice({
detail={
selected === activity.correctIndex ? null : (
<>
Correct answer:{" "}
{t("activity.correctAnswer")}{" "}
<span className="font-bold">{activity.options[activity.correctIndex]}</span>
</>
)

View file

@ -3,6 +3,7 @@
import { Reorder } from "framer-motion";
import { useMemo, useState } from "react";
import { useI18n } from "@/lib/i18n";
import type { Activity } from "@/lib/schemas";
import { cn, shuffle } from "@/lib/utils";
import { Button } from "@/components/ui";
@ -18,6 +19,7 @@ import {
type OrderingActivity = Extract<Activity, { type: "ordering" }>;
export function Ordering({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<OrderingActivity>) {
const { t } = useI18n();
const initial = useMemo(() => shuffle(activity.items), [activity]);
const [items, setItems] = useState<string[]>(initial);
const [checked, setChecked] = useState(false);
@ -48,7 +50,7 @@ export function Ordering({ activity, onAnswered, onNext, isLast }: ActivityCompo
<PromptCard className="py-4">
<p className="text-lg font-bold text-frost-950 dark:text-frost-50">{activity.instruction}</p>
<Hint>
{checked ? "Here is the correct order:" : "Drag the cards, or use ▲▼, then check your order."}
{checked ? t("ordering.correctOrder") : t("ordering.hint")}
</Hint>
</PromptCard>
@ -89,7 +91,7 @@ export function Ordering({ activity, onAnswered, onNext, isLast }: ActivityCompo
<span className="flex flex-col">
<button
type="button"
aria-label={`Move "${item}" up`}
aria-label={t("ordering.moveUp", { item })}
onClick={() => move(index, -1)}
disabled={index === 0}
className="rounded px-1.5 text-xs text-frost-700 transition hover:bg-frost-100 disabled:opacity-30 dark:text-frost-100 dark:hover:bg-white/10"
@ -98,7 +100,7 @@ export function Ordering({ activity, onAnswered, onNext, isLast }: ActivityCompo
</button>
<button
type="button"
aria-label={`Move "${item}" down`}
aria-label={t("ordering.moveDown", { item })}
onClick={() => move(index, 1)}
disabled={index === items.length - 1}
className="rounded px-1.5 text-xs text-frost-700 transition hover:bg-frost-100 disabled:opacity-30 dark:text-frost-100 dark:hover:bg-white/10"
@ -116,7 +118,7 @@ export function Ordering({ activity, onAnswered, onNext, isLast }: ActivityCompo
{!checked ? (
<Button size="lg" onClick={check} disabled={items.length < 2}>
Check order
{t("ordering.check")}
</Button>
) : (
<FeedbackPanel

View file

@ -3,6 +3,7 @@
import { useEffect, useRef, useState } from "react";
import { ApiError, gradeAnswer } from "@/lib/client-api";
import { useI18n } from "@/lib/i18n";
import type { Activity } from "@/lib/schemas";
import { cn } from "@/lib/utils";
import { Button, Spinner } from "@/components/ui";
@ -26,6 +27,7 @@ type GradeState =
| { status: "self-check" };
export function ShortAnswer({ activity, active, language, onAnswered, onNext, isLast }: ActivityComponentProps<ShortAnswerActivity>) {
const { t } = useI18n();
const [value, setValue] = useState("");
const [state, setState] = useState<GradeState>({ status: "idle" });
const textareaRef = useRef<HTMLTextAreaElement>(null);
@ -61,13 +63,13 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
message:
error instanceof ApiError
? error.message
: "Grading failed. Check that Ollama is reachable and try again.",
: t("short.gradeFailed"),
});
}
};
const selfCheck = (correct: boolean) => {
setState({ status: "graded", correct, feedback: "Self-checked — you judged this one yourself.", missing: [] });
setState({ status: "graded", correct, feedback: t("short.selfChecked"), missing: [] });
onAnswered(correct, { userAnswer: value.trim() });
};
@ -79,7 +81,7 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
</PromptCard>
<label className="sr-only" htmlFor={`answer-${activity.id}`}>
Your answer
{t("short.yourAnswer")}
</label>
<textarea
id={`answer-${activity.id}`}
@ -95,15 +97,15 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
}}
disabled={state.status === "grading" || state.status === "graded"}
rows={3}
placeholder="Type your answer in your own words…"
placeholder={t("short.placeholder")}
className="w-full resize-none rounded-2xl border-2 border-[var(--glass-border)] bg-[var(--surface-strong)] px-4 py-3 text-[15px] font-medium text-frost-950 shadow-sm outline-none transition placeholder:text-frost-400 focus:border-[var(--focus)] disabled:opacity-70 dark:border-[var(--glass-border)] dark:bg-[var(--surface-strong)] dark:text-frost-50 dark:placeholder:text-frost-400"
/>
{state.status === "idle" ? (
<div className="flex items-center justify-between gap-3">
<Hint>Ctrl/⌘ + Enter to submit.</Hint>
<Hint>{t("short.ctrlEnter")}</Hint>
<Button size="lg" onClick={() => void grade()} disabled={!value.trim()}>
Submit answer
{t("short.submit")}
</Button>
</div>
) : null}
@ -111,20 +113,20 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
{state.status === "grading" ? (
<div className="flex items-center gap-3 rounded-2xl border border-white/60 bg-white/50 backdrop-blur-xl px-4 py-3 text-sm font-semibold text-frost-800 dark:border-white/15 dark:bg-white/10 dark:text-frost-100">
<Spinner className="size-5" />
Grading your answer…
{t("short.grading")}
</div>
) : null}
{state.status === "error" ? (
<div className="rounded-2xl border-2 border-amber-300 bg-amber-50 p-4 text-sm text-amber-900 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-100">
<p className="font-bold">Couldn&apos;t grade that answer</p>
<p className="font-bold">{t("short.couldNotGrade")}</p>
<p className="mt-1">{state.message}</p>
<div className="mt-3 flex flex-wrap gap-2">
<Button size="sm" onClick={() => void grade()}>
Retry
{t("common.retry")}
</Button>
<Button size="sm" variant="secondary" onClick={() => setState({ status: "self-check" })}>
Show answer &amp; self-check
{t("short.showAnswer")}
</Button>
</div>
</div>
@ -132,15 +134,15 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
{state.status === "self-check" ? (
<div className="card space-y-3 p-4">
<p className="text-sm font-bold text-frost-900 dark:text-frost-100">Model answer</p>
<p className="text-sm font-bold text-frost-900 dark:text-frost-100">{t("short.modelAnswer")}</p>
<p className="text-sm leading-relaxed text-frost-700 dark:text-frost-200">{activity.referenceAnswer}</p>
<p className="text-xs text-frost-500 dark:text-frost-400">Key points: {activity.keyPoints.join(" · ")}</p>
<p className="text-xs text-frost-500 dark:text-frost-400">{t("short.keyPoints")} {activity.keyPoints.join(" · ")}</p>
<div className="grid grid-cols-2 gap-3">
<Button variant="secondary" onClick={() => selfCheck(false)}>
😅 I missed it
{t("short.missedIt")}
</Button>
<Button variant="success" onClick={() => selfCheck(true)}>
🧊 I had it right
{t("short.hadRight")}
</Button>
</div>
</div>
@ -157,7 +159,7 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
)}
>
<p className="font-extrabold uppercase tracking-wide">
{state.correct ? "Accepted" : "Needs work"}
{state.correct ? t("short.accepted") : t("short.needsWork")}
</p>
<p className="mt-1 font-normal">{state.feedback}</p>
{state.missing.length ? (
@ -169,7 +171,7 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
) : null}
{!state.correct ? (
<p className="mt-2 font-normal">
<span className="font-bold">Model answer: </span>
<span className="font-bold">{t("short.modelAnswer")}: </span>
{activity.referenceAnswer}
</p>
) : null}

View file

@ -3,6 +3,7 @@
import { motion, useMotionValue, useTransform } from "framer-motion";
import { useState } from "react";
import { useI18n } from "@/lib/i18n";
import type { Activity } from "@/lib/schemas";
import { Button } from "@/components/ui";
@ -17,6 +18,7 @@ import {
type TrueFalseActivity = Extract<Activity, { type: "true_false" }>;
export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<TrueFalseActivity>) {
const { t } = useI18n();
const [result, setResult] = useState<boolean | null>(null);
const x = useMotionValue(0);
// The drag is elastic, so the card moves less than the pointer — these
@ -37,7 +39,7 @@ export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComp
<>
<ActivityHeader type="true_false" topic={activity.topic} />
<Hint>
{result === null ? "Swipe the card right for true, left for false — or tap a button." : "Nice swipe!"}
{result === null ? t("tf.hint") : t("tf.niceSwipe")}
</Hint>
<div className="relative">
@ -55,10 +57,10 @@ export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComp
>
<PromptCard className="relative min-h-44 select-none">
<motion.span style={{ opacity: trueOpacity }} className="absolute left-4 top-3 text-sm font-extrabold uppercase tracking-widest text-emerald-500">
✓ true
{t("tf.trueLabel")}
</motion.span>
<motion.span style={{ opacity: falseOpacity }} className="absolute right-4 top-3 text-sm font-extrabold uppercase tracking-widest text-rose-500">
false ✕
{t("tf.falseLabel")}
</motion.span>
<p className="pt-6 text-center text-xl font-bold leading-snug text-frost-950 sm:text-2xl dark:text-frost-50">
“{activity.statement}”
@ -70,10 +72,10 @@ export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComp
{result === null ? (
<div className="grid grid-cols-2 gap-3">
<Button variant="secondary" size="lg" onClick={() => answer(false)} className="border-rose-200 text-rose-700 dark:text-rose-200">
✕ False
{t("tf.falseBtn")}
</Button>
<Button variant="success" size="lg" onClick={() => answer(true)}>
✓ True
{t("tf.trueBtn")}
</Button>
</div>
) : (
@ -85,7 +87,8 @@ export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComp
onNext={onNext}
detail={
<span>
This statement is <span className="font-bold">{activity.answer ? "true" : "false"}</span>.
{t("tf.statementIs.before")}{" "}
<span className="font-bold">{activity.answer ? t("tf.true") : t("tf.false")}</span>.
</span>
}
/>

View file

@ -7,6 +7,7 @@ import { MultipleChoice } from "./MultipleChoice";
import { Ordering } from "./Ordering";
import { ShortAnswer } from "./ShortAnswer";
import { TrueFalse } from "./TrueFalse";
import { t } from "@/lib/i18n";
import { type ActivityComponentProps } from "./shared";
export type { ActivityComponentProps } from "./shared";
@ -38,7 +39,7 @@ export function ActivityRenderer(props: ActivityComponentProps) {
// Exhaustiveness guard: adding a new activity type without a component
// becomes a TypeScript error.
const never: never = activity;
return <p className="text-sm">Unsupported activity: {JSON.stringify(never)}</p>;
return <p className="text-sm">{t("activity.unsupported", { json: JSON.stringify(never) })}</p>;
}
}
}

View file

@ -2,6 +2,7 @@
import { motion } from "framer-motion";
import { useI18n } from "@/lib/i18n";
import type { Activity } from "@/lib/schemas";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui";
@ -19,14 +20,14 @@ export type ActivityComponentProps<A extends Activity = Activity> = {
isLast: boolean;
};
export const TYPE_META: Record<Activity["type"], { label: string; icon: string; tint: string }> = {
flashcard: { label: "Flashcard", icon: "🃏", tint: "tint-sky" },
multiple_choice: { label: "Multiple choice", icon: "🔘", tint: "tint-lilac" },
true_false: { label: "True or false", icon: "⚖️", tint: "tint-mint" },
fill_blank: { label: "Fill the blank", icon: "✏️", tint: "tint-peach" },
matching: { label: "Match up", icon: "🧲", tint: "tint-sky" },
ordering: { label: "Put in order", icon: "🔢", tint: "tint-lilac" },
short_answer: { label: "Short answer", icon: "💬", tint: "tint-rose" },
export const TYPE_META: Record<Activity["type"], { labelKey: string; icon: string; tint: string }> = {
flashcard: { labelKey: "type.flashcard", icon: "🃏", tint: "tint-sky" },
multiple_choice: { labelKey: "type.multipleChoice", icon: "🔘", tint: "tint-lilac" },
true_false: { labelKey: "type.trueFalse", icon: "⚖️", tint: "tint-mint" },
fill_blank: { labelKey: "type.fillBlank", icon: "✏️", tint: "tint-peach" },
matching: { labelKey: "type.matching", icon: "🧲", tint: "tint-sky" },
ordering: { labelKey: "type.ordering", icon: "🔢", tint: "tint-lilac" },
short_answer: { labelKey: "type.shortAnswer", icon: "💬", tint: "tint-rose" },
};
export function ActivityHeader({
type,
@ -37,6 +38,7 @@ export function ActivityHeader({
topic?: string;
extra?: React.ReactNode;
}) {
const { t } = useI18n();
const meta = TYPE_META[type];
return (
<div className="flex flex-wrap items-center gap-2">
@ -47,7 +49,7 @@ export function ActivityHeader({
)}
>
<span aria-hidden>{meta.icon}</span>
{meta.label}
{t(meta.labelKey)}
</span>
{topic ? (
<span className="glass-chip rounded-full px-3 py-1 text-xs font-medium text-frost-600 dark:text-frost-300">
@ -144,6 +146,7 @@ export function FeedbackPanel({
detail?: React.ReactNode;
children?: React.ReactNode;
}) {
const { t } = useI18n();
return (
<motion.div
initial={{ opacity: 0, y: 18 }}
@ -166,7 +169,7 @@ export function FeedbackPanel({
)}
>
<span aria-hidden>{correct ? "✅" : "💧"}</span>
{headline ?? (correct ? "Nice one!" : "Not quite")}
{headline ?? (correct ? t("activity.niceOne") : t("activity.notQuite"))}
</p>
{correct && xp ? (
<motion.span
@ -182,7 +185,7 @@ export function FeedbackPanel({
{detail ? <div className="mt-2 text-sm text-frost-700 dark:text-frost-200">{detail}</div> : null}
<p className="mt-2 text-sm leading-relaxed text-frost-700 dark:text-frost-200">
<span className="font-bold">Why: </span>
<span className="font-bold">{t("activity.why")} </span>
{explanation}
</p>
@ -190,7 +193,7 @@ export function FeedbackPanel({
<div className="mt-4 flex items-center justify-end">
<Button onClick={onNext} size="md" className="min-w-28">
{isLast ? "See results" : "Next"}
{isLast ? t("activity.seeResults") : t("activity.next")}
<span aria-hidden>→</span>
</Button>
</div>