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

@ -5,7 +5,8 @@ import { useState } from "react";
import { ApiError, generateDeck } from "@/lib/client-api";
import { activityTypes, materialiseActivities } from "@/lib/deck-ops";
import { createFolder as makeFolder, folderName, listFolders } from "@/lib/folders";
import { createFolder as makeFolder, listFolders } from "@/lib/folders";
import { useI18n } from "@/lib/i18n";
import { UNORGANIZED_ID, type Deck } from "@/lib/schemas";
import { appendActivities, deleteDeck, downloadDeck, renameDeck, upsertDeck } from "@/lib/storage";
import { cn, formatDate, truncate } from "@/lib/utils";
@ -21,6 +22,7 @@ const ADD_OPTIONS = [3, 5, 8];
* prominent Study CTA with quiet secondary actions.
*/
export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: string | undefined) => void }) {
const { t, plural } = useI18n();
const folders = listFolders();
const [busy, setBusy] = useState<Busy>(null);
const [menuOpen, setMenuOpen] = useState(false);
@ -44,7 +46,7 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
const addMore = async () => {
if (!hasSource) {
setError("This deck has no stored source notes, so it can't be extended.");
setError(t("deck.noSourceExtend"));
return;
}
setBusy("more");
@ -59,11 +61,11 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
existing: { title: deck.title, types: activityTypes(deck.activities), count: deck.activities.length },
});
appendActivities(deck.id, materialiseActivities(generated.activities, deck.difficulty));
setNotice(`Added ${generated.activities.length} activities`);
setNotice(plural(generated.activities.length, "deck.added"));
setMenuOpen(false);
window.setTimeout(() => setNotice(null), 3000);
} catch (addError) {
setError(addError instanceof ApiError ? addError.message : "Could not add more activities.");
setError(addError instanceof ApiError ? addError.message : t("deck.couldNotAdd"));
} finally {
setBusy(null);
}
@ -71,7 +73,7 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
const regenerate = async () => {
if (!hasSource) {
setError("This deck has no stored source notes, so it can't be regenerated.");
setError(t("deck.noSourceRegenerate"));
return;
}
setBusy("regenerate");
@ -94,11 +96,11 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
bestScore: undefined,
updatedAt: Date.now(),
});
setNotice("Deck regenerated");
setNotice(t("deck.regenerated"));
setMenuOpen(false);
window.setTimeout(() => setNotice(null), 3000);
} catch (regenerateError) {
setError(regenerateError instanceof ApiError ? regenerateError.message : "Could not regenerate the deck.");
setError(regenerateError instanceof ApiError ? regenerateError.message : t("deck.couldNotRegenerate"));
} finally {
setBusy(null);
}
@ -123,7 +125,7 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
{renaming ? (
<div className="relative z-10 flex items-center gap-2">
<label className="sr-only" htmlFor={`rename-${deck.id}`}>
Deck title
{t("deck.titleLabel")}
</label>
<input
id={`rename-${deck.id}`}
@ -141,7 +143,7 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
className="min-w-0 flex-1 rounded-lg border border-[var(--hairline)] bg-[var(--surface-strong)] px-2.5 py-1.5 text-[15px] font-semibold text-[var(--text)] outline-none focus:border-accent-400"
/>
<Button size="sm" onClick={saveTitle}>
Save
{t("common.save")}
</Button>
</div>
) : (
@ -158,28 +160,26 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
{formatDate(deck.updatedAt)}
<span aria-hidden> · </span>
</span>
{deck.activities.length} activities
{plural(deck.activities.length, "deck.activities")}
<span className="lg:hidden">
{deck.attempts > 0 ? (
<>
<span aria-hidden> · </span>
{deck.attempts} {deck.attempts === 1 ? "run" : "runs"}
{plural(deck.attempts, "deck.runs")}
</>
) : null}
</span>
{/* Desktop always shows the same two fields. */}
<span className="hidden lg:inline">
<span aria-hidden> · </span>
{deck.attempts > 0
? `${deck.attempts} ${deck.attempts === 1 ? "run" : "runs"}`
: "Not played yet"}
{deck.attempts > 0 ? plural(deck.attempts, "deck.runs") : t("deck.notPlayed")}
</span>
</p>
</div>
<button
type="button"
aria-label="Deck actions"
aria-label={t("deck.actions")}
aria-expanded={menuOpen}
onClick={() => setMenuOpen((value) => !value)}
className={cn(
@ -196,21 +196,28 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{deck.subject ? <Tag>{deck.subject}</Tag> : null}
<Tag>
<span className={difficultyClass(deck.difficulty)}>{deck.difficulty}</span>
<span className={difficultyClass(deck.difficulty)}>
{deck.difficulty === "easy"
? t("upload.easy")
: deck.difficulty === "hard"
? t("upload.hard")
: t("upload.medium")}
</span>
</Tag>
{deck.folderId ? (
<Tag>
<FolderIcon className="size-3.5 opacity-70" />
{folderName(deck.folderId)}
{folders.find((entry) => entry.id === deck.folderId)?.name ?? t("common.unorganized")}
</Tag>
) : null}
{deck.bestScore !== undefined ? (
<>
<Tag className="lg:hidden">
Best <span className="text-emerald-700 dark:text-emerald-300">{deck.bestScore}%</span>
{t("deck.bestLabel")}{" "}
<span className="text-emerald-700 dark:text-emerald-300">{deck.bestScore}%</span>
</Tag>
<span className="hidden text-[12px] font-medium tabular-nums text-[var(--text-muted)] lg:ml-1 lg:inline">
Best {deck.bestScore}%
{t("deck.bestLabel")} {deck.bestScore}%
</span>
</>
) : null}
@ -226,9 +233,13 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
{/* status ------------------------------------------------------- */}
{busy ? (
<div className="mt-4 space-y-2">
<ProgressBar value={1} max={2} label={busy === "more" ? "Adding activities" : "Regenerating deck"} />
<ProgressBar
value={1}
max={2}
label={busy === "more" ? t("deck.addingActivities") : t("deck.regeneratingDeck")}
/>
<p className="text-[12.5px] font-medium text-[var(--text-muted)]">
{busy === "more" ? "Adding new activities…" : "Regenerating the whole deck…"}
{busy === "more" ? t("deck.addingNew") : t("deck.regeneratingWhole")}
</p>
</div>
) : null}
@ -248,12 +259,12 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
<div className="mt-5 lg:mt-4">
<Link href={`/deck/${deck.id}`} className="btn-primary h-10 w-full px-4 text-sm lg:hidden">
<PlayIcon className="size-4" />
Study
{t("deck.study")}
</Link>
<div className="relative z-10 hidden lg:flex lg:justify-end">
<Link href={`/deck/${deck.id}`} className="btn-secondary h-9 px-4 text-[13px]">
<PlayIcon className="size-4" />
Study
{t("deck.study")}
</Link>
</div>
</div>
@ -268,11 +279,11 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
<span aria-hidden className="w-4 text-center text-[var(--text-subtle)]">
✎
</span>
Rename
{t("common.rename")}
</button>
<button type="button" className={menuRow} onClick={() => downloadDeck(deck)} disabled={Boolean(busy)}>
<DownloadIcon className="size-4 text-[var(--text-subtle)]" />
Export JSON
{t("deck.export")}
</button>
<button
type="button"
@ -281,16 +292,16 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
disabled={Boolean(busy) || !hasSource}
>
<RefreshIcon className="size-4 text-[var(--text-subtle)]" />
Regenerate deck
{t("deck.regenerate")}
</button>
{confirmDelete ? (
<div className="flex items-center gap-2 px-3 py-2">
<Button size="sm" variant="danger" onClick={() => deleteDeck(deck.id)}>
Really delete
{t("deck.reallyDelete")}
</Button>
<Button size="sm" variant="ghost" onClick={() => setConfirmDelete(false)}>
Cancel
{t("common.cancel")}
</Button>
</div>
) : (
@ -298,17 +309,17 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
<span aria-hidden className="w-4 text-center">
⌫
</span>
Delete deck
{t("deck.delete")}
</button>
)}
{/* folder ---------------------------------------------------- */}
<div className="mt-2 rounded-xl bg-[var(--surface-sunken)] p-2.5">
<p className="px-1 pb-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
Folder
{t("common.folder")}
</p>
<div className="flex flex-wrap items-center gap-1.5">
{[{ id: UNORGANIZED_ID, name: "Unorganized" }, ...folders].map((folder) => {
{[{ id: UNORGANIZED_ID, name: t("common.unorganized") }, ...folders].map((folder) => {
const current = (deck.folderId ?? UNORGANIZED_ID) === folder.id;
return (
<button
@ -336,7 +347,7 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
onClick={() => setNewFolder("")}
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
+ New folder
{t("deck.newFolder")}
</button>
) : (
<span className="inline-flex items-center gap-1 rounded-full border border-[var(--hairline)] bg-[var(--surface-strong)] py-0.5 pl-2.5 pr-1">
@ -358,8 +369,8 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
}
}
}}
aria-label="New folder name"
placeholder="Folder name"
aria-label={t("common.newFolderName")}
placeholder={t("common.folderName")}
maxLength={60}
className="w-24 bg-transparent text-[12px] text-[var(--text)] outline-none placeholder:text-[var(--text-subtle)]"
/>
@ -377,7 +388,7 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
}
}}
>
Add
{t("common.add")}
</button>
</span>
)}
@ -387,7 +398,7 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
{/* add more -------------------------------------------------- */}
<div className="mt-1 rounded-xl bg-[var(--surface-sunken)] p-2.5">
<p className="px-1 pb-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
Add more activities
{t("deck.addMore")}
</p>
<div className="flex flex-wrap items-center gap-1.5">
{ADD_OPTIONS.map((option) => (
@ -407,12 +418,12 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
</button>
))}
<Button size="sm" onClick={() => void addMore()} disabled={Boolean(busy) || !hasSource} loading={busy === "more"}>
Generate
{t("deck.generate")}
</Button>
</div>
{!hasSource ? (
<p className="px-1 pt-2 text-[11px] text-amber-700 dark:text-amber-300">
No stored source text for this deck (imported decks don&apos;t carry it).
{t("deck.noSource")}
</p>
) : null}
</div>