- {Math.min(progress.reviewed + 1, progress.total)} / {progress.total}
-
+
+
+ {t("flashcards.cardOf", { current: store.currentIndex + 1, total })}
+
+
+ {t("common.newTopic")}
+
+
+
- {store.isFlipped && (
-
- {([1, 3, 5] as const).map((q) => (
- rateAndNext(q)}
- className={`px-4 py-2 rounded-lg text-sm ${
- q === 1 ? "bg-red-100 text-red-700" : q === 3 ? "bg-yellow-100 text-yellow-700" : "bg-green-100 text-green-700"
- }`}
- >
- {q === 1 ? "Hard" : q === 3 ? "Good" : "Easy"}
-
- ))}
-
- )}
-
-
{
- store.setCards([], "");
- setTopicInput("");
- }}
- className="text-sm text-gray-500 underline hover:text-gray-700"
- >
- New topic
+
+ {store.isFlipped && (
+
+
{t("flashcards.rateQuestion")}
+
+ {RATINGS.map((r) => (
+ rateAndNext(r.quality as 1 | 3 | 5)}
+ className={`btn border ${r.className} hover:brightness-95`}
+ >
+ {t(r.label)}
+
+ ))}
+
+
+ )}
+
+
+ )}
+
+ {finished && (
+
+
{t("flashcards.completeTitle")}
+
{t("flashcards.completeDesc", { total, topic: store.topic })}
+
+ {RATINGS.map((r) => (
+
+
{t(r.label)}
+ {tally[r.quality as 1 | 3 | 5]}
+
+ ))}
+
+
+
+
+ {t("flashcards.reviewAgain")}
+
+
+ {t("common.newTopic")}
)}
-
+
);
}
diff --git a/src/app/globals.css b/src/app/globals.css
index ab3d907..fd7cb30 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -1,19 +1,384 @@
-@import 'tailwindcss';
+@import "tailwindcss";
/*
- The default border color has changed to `currentcolor` in Tailwind CSS v4,
- so we've added these compatibility styles to make sure everything still
- looks the same as it did with Tailwind CSS v3.
+ LearnPath design tokens.
- If we ever want to remove these styles, we need to add an explicit border
- color utility to any element that depends on these defaults.
+ Every colour in the UI comes from the variables below; components use the
+ Tailwind utilities generated from them (bg-surface, text-ink-muted, ...).
+ Tailwind's default palette is switched off so a stray `text-gray-400`
+ cannot slip in and break dark mode or contrast.
+
+ Text/background pairs are checked for WCAG AA (4.5:1) in both themes.
+ Light is the default; dark applies when the system prefers it (and the
+ visitor has not picked light), or when the visitor picks dark.
*/
+
+:root {
+ color-scheme: light;
+ --canvas: #f6f6f2;
+ --surface: #ffffff;
+ --surface-2: #eeefe9;
+ --surface-raised: #ffffff;
+ --line: #e1e3dc;
+ --line-strong: #8f978f;
+ --ink: #16211e;
+ --ink-muted: #4a5651;
+ --ink-subtle: #5d6964;
+ --brand: #0c6b60;
+ --brand-hover: #0a564d;
+ --on-brand: #ffffff;
+ --brand-soft: #e1f0ec;
+ --brand-ink: #0a564d;
+ --accent: #b4540a;
+ --accent-soft: #fbeedb;
+ --accent-ink: #8a3f06;
+ --success: #1b7339;
+ --success-soft: #e3f2e8;
+ --success-ink: #165f2f;
+ --danger: #b42318;
+ --danger-soft: #fcebe9;
+ --danger-ink: #9a1d13;
+ --warning-soft: #fdf3dd;
+ --warning-ink: #7a4305;
+ --focus: #0c6b60;
+ --overlay: rgb(14 22 20 / 0.45);
+ --shadow-color: 150 10% 20%;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) {
+ color-scheme: dark;
+ --canvas: #0d1312;
+ --surface: #141c1a;
+ --surface-2: #1b2522;
+ --surface-raised: #2b3834;
+ --line: #26322e;
+ --line-strong: #6b7a74;
+ --ink: #e7edea;
+ --ink-muted: #a8b5b0;
+ --ink-subtle: #93a29c;
+ --brand: #3cc2ad;
+ --brand-hover: #62d3c1;
+ --on-brand: #04201b;
+ --brand-soft: #11302a;
+ --brand-ink: #7fdccd;
+ --accent: #f0a640;
+ --accent-soft: #33240f;
+ --accent-ink: #f5c27a;
+ --success: #4cc27a;
+ --success-soft: #11291a;
+ --success-ink: #84d9a4;
+ --danger: #ef7466;
+ --danger-soft: #341614;
+ --danger-ink: #f6a097;
+ --warning-soft: #2f2410;
+ --warning-ink: #f1c46f;
+ --focus: #62d3c1;
+ --overlay: rgb(0 0 0 / 0.6);
+ --shadow-color: 0 0% 0%;
+ }
+}
+
+:root[data-theme="dark"] {
+ color-scheme: dark;
+ --canvas: #0d1312;
+ --surface: #141c1a;
+ --surface-2: #1b2522;
+ --surface-raised: #2b3834;
+ --line: #26322e;
+ --line-strong: #6b7a74;
+ --ink: #e7edea;
+ --ink-muted: #a8b5b0;
+ --ink-subtle: #93a29c;
+ --brand: #3cc2ad;
+ --brand-hover: #62d3c1;
+ --on-brand: #04201b;
+ --brand-soft: #11302a;
+ --brand-ink: #7fdccd;
+ --accent: #f0a640;
+ --accent-soft: #33240f;
+ --accent-ink: #f5c27a;
+ --success: #4cc27a;
+ --success-soft: #11291a;
+ --success-ink: #84d9a4;
+ --danger: #ef7466;
+ --danger-soft: #341614;
+ --danger-ink: #f6a097;
+ --warning-soft: #2f2410;
+ --warning-ink: #f1c46f;
+ --focus: #62d3c1;
+ --overlay: rgb(0 0 0 / 0.6);
+ --shadow-color: 0 0% 0%;
+}
+
+@theme {
+ --color-*: initial;
+}
+
+@theme inline {
+ --color-white: #ffffff;
+ --color-canvas: var(--canvas);
+ --color-surface: var(--surface);
+ --color-surface-2: var(--surface-2);
+ --color-surface-raised: var(--surface-raised);
+ --color-line: var(--line);
+ --color-line-strong: var(--line-strong);
+ --color-ink: var(--ink);
+ --color-ink-muted: var(--ink-muted);
+ --color-ink-subtle: var(--ink-subtle);
+ --color-brand: var(--brand);
+ --color-brand-hover: var(--brand-hover);
+ --color-on-brand: var(--on-brand);
+ --color-brand-soft: var(--brand-soft);
+ --color-brand-ink: var(--brand-ink);
+ --color-accent: var(--accent);
+ --color-accent-soft: var(--accent-soft);
+ --color-accent-ink: var(--accent-ink);
+ --color-success: var(--success);
+ --color-success-soft: var(--success-soft);
+ --color-success-ink: var(--success-ink);
+ --color-danger: var(--danger);
+ --color-danger-soft: var(--danger-soft);
+ --color-danger-ink: var(--danger-ink);
+ --color-warning-soft: var(--warning-soft);
+ --color-warning-ink: var(--warning-ink);
+ --color-focus: var(--focus);
+
+ --font-sans: var(--font-inter), ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
+ --font-display: var(--font-fraunces), ui-serif, Georgia, "Times New Roman", serif;
+ --font-mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
+
+ --text-display: clamp(2.125rem, 1.55rem + 2.6vw, 3.5rem);
+ --text-display--line-height: 1.08;
+ --text-display--letter-spacing: -0.02em;
+ --text-title: clamp(1.625rem, 1.35rem + 1.2vw, 2.25rem);
+ --text-title--line-height: 1.15;
+ --text-title--letter-spacing: -0.015em;
+
+ --shadow-card: 0 1px 2px hsl(var(--shadow-color) / 0.06), 0 1px 1px hsl(var(--shadow-color) / 0.04);
+ --shadow-raised: 0 10px 30px -12px hsl(var(--shadow-color) / 0.25), 0 2px 6px -2px hsl(var(--shadow-color) / 0.08);
+}
+
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
- border-color: var(--color-gray-200, currentcolor);
+ border-color: var(--line);
+ }
+
+ html {
+ -webkit-text-size-adjust: 100%;
+ text-size-adjust: 100%;
+ overflow-x: clip;
+ }
+
+ body {
+ background: var(--canvas);
+ color: var(--ink);
+ font-family: var(--font-sans);
+ font-feature-settings: "cv11", "ss01";
+ -webkit-font-smoothing: antialiased;
+ overflow-x: clip;
+ }
+
+ :focus-visible {
+ outline: 2px solid var(--focus);
+ outline-offset: 2px;
+ }
+
+ ::selection {
+ background: var(--brand-soft);
+ color: var(--ink);
+ }
+
+ input::placeholder {
+ color: var(--ink-subtle);
+ opacity: 1;
+ }
+}
+
+@layer components {
+ .card {
+ background: var(--surface);
+ border: 1px solid var(--line);
+ border-radius: 1rem;
+ box-shadow: var(--shadow-card);
+ }
+
+ .btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ min-height: 2.75rem;
+ padding: 0.625rem 1.125rem;
+ border-radius: 0.75rem;
+ font-weight: 600;
+ font-size: 0.9375rem;
+ line-height: 1.25rem;
+ border: 1px solid transparent;
+ transition:
+ background-color 150ms ease,
+ border-color 150ms ease,
+ color 150ms ease,
+ box-shadow 150ms ease;
+ -webkit-tap-highlight-color: transparent;
+ cursor: pointer;
+ }
+ .btn:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+ }
+ .btn-primary {
+ background: var(--brand);
+ color: var(--on-brand);
+ box-shadow: var(--shadow-card);
+ }
+ @media (hover: hover) {
+ .btn-primary:not(:disabled):hover {
+ background: var(--brand-hover);
+ }
+ }
+ .btn-secondary {
+ background: var(--surface);
+ color: var(--ink);
+ border-color: var(--line-strong);
}
+ @media (hover: hover) {
+ .btn-secondary:not(:disabled):hover {
+ background: var(--surface-2);
+ }
+ }
+ .btn-ghost {
+ background: transparent;
+ color: var(--ink-muted);
+ }
+ @media (hover: hover) {
+ .btn-ghost:not(:disabled):hover {
+ background: var(--surface-2);
+ color: var(--ink);
+ }
+ }
+ .btn-sm {
+ min-height: 2.25rem;
+ padding: 0.375rem 0.75rem;
+ font-size: 0.875rem;
+ border-radius: 0.625rem;
+ }
+
+ .field {
+ width: 100%;
+ min-height: 2.875rem;
+ padding: 0.625rem 0.875rem;
+ border-radius: 0.75rem;
+ border: 1px solid var(--line-strong);
+ background: var(--surface);
+ color: var(--ink);
+ font-size: 1rem; /* 16px keeps iOS Safari from zooming on focus */
+ transition:
+ border-color 150ms ease,
+ box-shadow 150ms ease;
+ }
+ .field:focus {
+ outline: none;
+ border-color: var(--brand);
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--brand) 25%, transparent);
+ }
+
+ select.field {
+ -webkit-appearance: none;
+ appearance: none;
+ padding-right: 2.5rem;
+ background-image: linear-gradient(45deg, transparent 50%, var(--ink-muted) 50%),
+ linear-gradient(135deg, var(--ink-muted) 50%, transparent 50%);
+ background-position:
+ calc(100% - 1.15rem) 50%,
+ calc(100% - 0.8rem) 50%;
+ background-size: 0.35rem 0.35rem;
+ background-repeat: no-repeat;
+ }
+
+ .label {
+ display: block;
+ margin-bottom: 0.375rem;
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: var(--ink);
+ }
+
+ .eyebrow {
+ font-size: 0.75rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--brand-ink);
+ }
+
+ /* Shown only in the light or only in the dark theme (theme toggle icons). */
+ .only-dark {
+ display: none;
+ }
+ :root[data-theme="dark"] .only-dark {
+ display: inline-block;
+ }
+ :root[data-theme="dark"] .only-light {
+ display: none;
+ }
+ @media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) .only-dark {
+ display: inline-block;
+ }
+ :root:not([data-theme="light"]) .only-light {
+ display: none;
+ }
+ }
+
+ .skeleton {
+ background: linear-gradient(90deg, var(--surface-2) 0%, var(--line) 50%, var(--surface-2) 100%);
+ background-size: 200% 100%;
+ animation: shimmer 1.4s ease-in-out infinite;
+ border-radius: 0.5rem;
+ }
+}
+
+@keyframes shimmer {
+ from {
+ background-position: 100% 0;
+ }
+ to {
+ background-position: -100% 0;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ ::before,
+ ::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
+}
+
+/* Flashcard flip. Prefixed backface-visibility for older WebKit. */
+.flip-scene {
+ perspective: 1200px;
+}
+.flip-card {
+ display: grid;
+ transform-style: preserve-3d;
+ transition: transform 500ms cubic-bezier(0.2, 0.7, 0.2, 1);
+}
+.flip-card[data-flipped="true"] {
+ transform: rotateY(180deg);
+}
+.flip-face {
+ grid-area: 1 / 1;
+ -webkit-backface-visibility: hidden;
+ backface-visibility: hidden;
+}
+.flip-back {
+ transform: rotateY(180deg);
}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index dc44e88..9f55ffa 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,27 +1,56 @@
-import type { Metadata } from "next";
+import type { Metadata, Viewport } from "next";
+import { Fraunces, Inter } from "next/font/google";
import { AppHeader } from "@/components/AppHeader";
+import { MobileNav } from "@/components/MobileNav";
+import { SiteFooter } from "@/components/SiteFooter";
+import { I18nProvider } from "@/i18n/I18nProvider";
+import { translate } from "@/i18n/messages";
+import { getRequestLocale } from "@/i18n/server";
+import { THEME_INIT_SCRIPT } from "@/lib/theme";
import "./globals.css";
-export const metadata: Metadata = {
- title: "LearnPath — Adaptive Learning",
- description: "AI-generated quizzes, flashcards and learning roadmaps with any OpenAI-compatible LLM. Bring your own API key.",
+const inter = Inter({ subsets: ["latin"], variable: "--font-inter", display: "swap" });
+const fraunces = Fraunces({
+ subsets: ["latin"],
+ variable: "--font-fraunces",
+ display: "swap",
+ axes: ["opsz", "SOFT"],
+});
+
+export async function generateMetadata(): Promise
{
+ const locale = await getRequestLocale();
+ return {
+ title: translate(locale, "meta.title"),
+ description: translate(locale, "meta.description"),
+ };
+}
+
+export const viewport: Viewport = {
+ width: "device-width",
+ initialScale: 1,
+ viewportFit: "cover",
+ themeColor: [
+ { media: "(prefers-color-scheme: light)", color: "#f6f6f2" },
+ { media: "(prefers-color-scheme: dark)", color: "#0d1312" },
+ ],
};
-export default function RootLayout({ children }: { children: React.ReactNode }) {
+export default async function RootLayout({ children }: { children: React.ReactNode }) {
+ const locale = await getRequestLocale();
return (
-
-
-
- {children}
-
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
);
diff --git a/src/app/page.tsx b/src/app/page.tsx
index ea83fc9..7d35ad2 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,41 +1,122 @@
"use client";
import Link from "next/link";
+import { ArrowRight, Info, KeyRound } from "lucide-react";
+import { PageShell } from "@/components/ui/PageShell";
+import { useI18n } from "@/i18n/I18nProvider";
+import { openSettings } from "@/lib/api-client";
+import { NAV_ITEMS } from "@/lib/nav";
+import type { MessageKey } from "@/i18n/messages";
-const features = [
- { href: "/quiz", icon: "📝", title: "Adaptive Quiz", desc: "AI-generated quizzes that adapt to your level" },
- { href: "/flashcards", icon: "🃏", title: "Flashcards", desc: "Spaced repetition for long-term retention" },
- { href: "/roadmap", icon: "🗺️", title: "Learning Roadmap", desc: "Personalized learning paths" },
- { href: "/progress", icon: "📊", title: "Progress Tracker", desc: "Sessions from this visit, by topic" },
- { href: "/concepts", icon: "🧠", title: "Concept Map", desc: "Explore knowledge connections (sample data)" },
+const FEATURES: { href: string; title: MessageKey; desc: MessageKey; sample?: boolean }[] = [
+ { href: "/quiz", title: "home.quizTitle", desc: "home.quizDesc" },
+ { href: "/flashcards", title: "home.flashcardsTitle", desc: "home.flashcardsDesc" },
+ { href: "/roadmap", title: "home.roadmapTitle", desc: "home.roadmapDesc" },
+ { href: "/progress", title: "home.progressTitle", desc: "home.progressDesc" },
+ { href: "/concepts", title: "home.conceptsTitle", desc: "home.conceptsDesc", sample: true },
];
+const STEPS: { title: MessageKey; desc: MessageKey }[] = [
+ { title: "home.how1Title", desc: "home.how1Desc" },
+ { title: "home.how2Title", desc: "home.how2Desc" },
+ { title: "home.how3Title", desc: "home.how3Desc" },
+];
+
+function iconFor(href: string) {
+ return NAV_ITEMS.find((item) => item.href === href)!.icon;
+}
+
export default function Home() {
+ const { t } = useI18n();
+
return (
-
-
-
🎓 LearnPath
-
Adaptive learning with any OpenAI-compatible LLM
-
AI-driven quizzes, flashcards, and personalized roadmaps
-
-
-
- {features.map((f) => (
-
-
-
{f.icon}
-
{f.title}
-
{f.desc}
+
+
+
+
{t("home.eyebrow")}
+
{t("home.title")}
+
{t("home.lead")}
+
+
+ {t("home.ctaQuiz")}
+
+
+
+ {t("home.ctaRoadmap")}
+
+
+
+
+
+ {t("home.howTitle")}
+ {STEPS.map((step, i) => (
+
+ {i < STEPS.length - 1 && (
+
+ )}
+
+ {i + 1}
+
+
+
{t(step.title)}
+
{t(step.desc)}
+
+
+ ))}
+
+
+
+
+
+ {t("home.featuresTitle")}
+
+
-
-
-
- Bring your own API key: OpenAI, OpenRouter, Groq, DeepSeek, Together AI, Mistral or Xiaomi MiMo. Self-hosted
- copies can also use Ollama, llama.cpp or any OpenAI-compatible endpoint.
-
-
-
+
+
+
+
+
+
+ {t("home.honestNote")}
+
+
);
}
diff --git a/src/app/progress/page.tsx b/src/app/progress/page.tsx
index 4917984..68b5791 100644
--- a/src/app/progress/page.tsx
+++ b/src/app/progress/page.tsx
@@ -1,80 +1,127 @@
"use client";
+import Link from "next/link";
+import { ArrowRight, ChartColumn, Clock, Coins, Layers } from "lucide-react";
import { ProgressChart } from "@/components/ProgressChart";
-import { useProgressStore } from "@/store/progress-store";
+import { EmptyState } from "@/components/ui/EmptyState";
+import { PageHeader } from "@/components/ui/PageHeader";
+import { PageShell } from "@/components/ui/PageShell";
+import { useProgressStore, type StudySession } from "@/store/progress-store";
+import { useI18n } from "@/i18n/I18nProvider";
+import type { MessageKey } from "@/i18n/messages";
+
+const DAY_KEYS: Record
= {
+ Sun: "days.sun",
+ Mon: "days.mon",
+ Tue: "days.tue",
+ Wed: "days.wed",
+ Thu: "days.thu",
+ Fri: "days.fri",
+ Sat: "days.sat",
+};
+
+const FEATURE_KEYS: Record = {
+ quiz: "progress.featureQuiz",
+ flashcards: "progress.featureFlashcards",
+ roadmap: "progress.featureRoadmap",
+ concepts: "progress.featureConcepts",
+ chat: "progress.featureChat",
+};
export default function ProgressPage() {
+ const { t, intl } = useI18n();
const store = useProgressStore();
const topicProgress = store.getTopicProgress();
const weeklyStats = store.getWeeklyStats();
- const tokensByFeature = store.sessions.reduce>((acc, s) => {
+ const tokensByFeature = store.sessions.reduce>>((acc, s) => {
acc[s.feature] = (acc[s.feature] || 0) + s.tokensUsed;
return acc;
}, {});
+ const maxTokens = Math.max(1, ...Object.values(tokensByFeature).map((n) => n ?? 0));
+ const nf = new Intl.NumberFormat(intl);
+ const hasScore = (topic: string) => store.sessions.some((s) => s.topic === topic && s.score !== undefined);
- return (
-
- 📊 Progress Dashboard
-
- Quizzes, flashcard sets and roadmaps you create during this visit. Nothing is saved after you close the tab.
-
+ const stats = [
+ { icon: Layers, label: t("progress.sessions"), value: nf.format(store.sessions.length) },
+ { icon: Clock, label: t("progress.studyTime"), value: t("progress.studyTimeValue", { minutes: nf.format(Math.round(store.totalStudyTime / 60000)) }) },
+ { icon: Coins, label: t("progress.tokens"), value: nf.format(store.totalTokens) },
+ ];
-
-
-
{store.sessions.length}
-
Total Sessions
-
-
-
- {Math.round(store.totalStudyTime / 60000)}m
-
-
Study Time
-
-
-
- {(store.totalTokens / 1000).toFixed(1)}K
-
-
Tokens Used
-
-
+ return (
+
+
-
-
({ label: s.day, value: s.sessions }))}
- title="Weekly Activity"
+ {store.sessions.length === 0 ? (
+
+ {t("progress.emptyCta")}
+
+
+ }
/>
+ ) : (
+ <>
+
+ {stats.map((s) => (
+
+
+
+
+
+
{s.label}
+ {s.value}
+
+
+ ))}
+
+
+
+
({ label: t(DAY_KEYS[s.day] ?? "days.sun"), value: s.sessions }))}
+ title={t("progress.weekly")}
+ caption={t("progress.weeklyCaption")}
+ />
-
-
Tokens used by feature
- {Object.keys(tokensByFeature).length === 0 ? (
-
Nothing yet. Generate a quiz, flashcards or a roadmap to see usage here.
- ) : (
-
- {Object.entries(tokensByFeature).map(([feature, tokens]) => (
-
- {feature}
- {tokens.toLocaleString("en")}
+
+ {t("progress.byFeature")}
+
+
+
+
+
+
+ {t("progress.topics")}
+
+
+ {Object.entries(topicProgress).map(([topic, data]) => (
+
+ {topic}
+
+ {hasScore(topic)
+ ? t("progress.topicLine", { sessions: data.sessions, score: data.avgScore })
+ : t("progress.topicLineNoScore", { sessions: data.sessions })}
+
))}
- )}
-
-
-
- {Object.keys(topicProgress).length > 0 && (
-
-
Topic Progress
-
- {Object.entries(topicProgress).map(([topic, data]) => (
-
-
{topic}
-
- {data.sessions} sessions • Avg: {data.avgScore}%
-
-
- ))}
-
-
+
+ >
)}
-
+
);
}
diff --git a/src/app/quiz/page.tsx b/src/app/quiz/page.tsx
index c36003c..3504f37 100644
--- a/src/app/quiz/page.tsx
+++ b/src/app/quiz/page.tsx
@@ -1,22 +1,38 @@
"use client";
import { useEffect, useRef, useState } from "react";
+import { clsx } from "clsx";
+import { ArrowRight, Check, ListChecks, RotateCcw, X } from "lucide-react";
import { QuizCard } from "@/components/QuizCard";
-import { LoadingSpinner } from "@/components/LoadingSpinner";
import { ErrorNotice } from "@/components/ErrorNotice";
+import { GeneratingState } from "@/components/GeneratingState";
+import { TopicForm } from "@/components/TopicForm";
+import { PageHeader } from "@/components/ui/PageHeader";
+import { PageShell } from "@/components/ui/PageShell";
+import { ProgressBar } from "@/components/ui/ProgressBar";
+import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { useQuizState } from "@/hooks/useQuizState";
import { recordSession } from "@/lib/record-session";
+import { useI18n } from "@/i18n/I18nProvider";
+
+type Difficulty = "easy" | "medium" | "hard";
export default function QuizPage() {
- const [topicInput, setTopicInput] = useState("");
- const [startedAt, setStartedAt] = useState(0);
+ const { t } = useI18n();
const quiz = useQuizState();
+ const [difficulty, setDifficulty] = useState("medium");
+ const [count, setCount] = useState(5);
+ const [selected, setSelected] = useState();
+ const [startedAt, setStartedAt] = useState(0);
+ const [lastTopic, setLastTopic] = useState("");
const recorded = useRef(false);
- const handleGenerate = async () => {
- if (!topicInput.trim() || quiz.isGenerating) return;
+ const generate = async (topic: string) => {
+ if (quiz.isGenerating) return;
recorded.current = false;
+ setSelected(undefined);
+ setLastTopic(topic);
setStartedAt(Date.now());
- await quiz.generateQuiz(topicInput.trim());
+ await quiz.generateQuiz(topic, count, difficulty);
};
const score = quiz.getScore();
@@ -35,67 +51,217 @@ export default function QuizPage() {
}, [quiz.isComplete, quiz.topic, quiz.tokensUsed, score.percentage, startedAt]);
const currentQ = quiz.questions[quiz.currentQuestionIndex];
+ const answered = selected !== undefined;
+ const isLast = quiz.currentQuestionIndex === quiz.questions.length - 1;
+
+ const answer = (idx: number) => {
+ if (answered) return;
+ setSelected(idx);
+ quiz.answerQuestion(idx);
+ };
+
+ const next = () => {
+ setSelected(undefined);
+ quiz.nextQuestion();
+ };
+
+ const restart = () => {
+ quiz.reset();
+ setSelected(undefined);
+ };
+
+ const showForm = quiz.questions.length === 0 && !quiz.isGenerating;
return (
-
- 📝 Adaptive Quiz
+
+
- {quiz.questions.length === 0 && !quiz.isGenerating && (
+ {showForm && (
<>
-
-
Choose a topic
-
setTopicInput(e.target.value)}
- placeholder="e.g., Machine Learning, React Hooks..."
- className="w-full px-4 py-2 border rounded-lg mb-4 text-base"
- onKeyDown={(e) => e.key === "Enter" && handleGenerate()}
+
+
-
- Generate Quiz
-
+
+
+
+ generate(lastTopic) : undefined} />
-
>
)}
- {quiz.isGenerating && (
-
-
+ {quiz.isGenerating &&
}
+
+ {currentQ && !quiz.isComplete && (
+
+
+
{quiz.topic}
+
+ {t("common.newTopic")}
+
+
+
+
+ {answered && (
+
+
+ {isLast ? t("quiz.seeResults") : t("common.next")}
+
+
+
+ )}
)}
- {currentQ && !quiz.isComplete && (
-
{
+ quiz.setQuestions(quiz.questions, quiz.topic);
+ recorded.current = false;
+ setStartedAt(Date.now());
+ }}
+ onNewTopic={restart}
/>
)}
+
+ );
+}
- {quiz.isComplete && (
-
-
🎉 Quiz Complete!
-
{score.percentage}%
-
- {score.correct} / {score.total} correct
-
-
{
- quiz.reset();
- setTopicInput("");
- }}
- className="mt-4 bg-gray-200 px-4 py-2 rounded-lg hover:bg-gray-300"
- >
- Try Another Topic
+function QuizResults({
+ topic,
+ correct,
+ total,
+ percentage,
+ onRetake,
+ onNewTopic,
+}: {
+ topic: string;
+ correct: number;
+ total: number;
+ percentage: number;
+ onRetake: () => void;
+ onNewTopic: () => void;
+}) {
+ const { t } = useI18n();
+ const quiz = useQuizState();
+ const verdict = percentage >= 80 ? "quiz.scoreGreat" : percentage >= 50 ? "quiz.scoreGood" : "quiz.scoreLow";
+ const radius = 52;
+ const circumference = 2 * Math.PI * radius;
+
+ return (
+
+
+
+
+
+
+
+
+ {percentage}%
+
+
+
+ {t("quiz.completeTitle")}
+
+
+ {topic} · {t("quiz.scoreLine", { correct, total })}
+
+ {t(verdict)}
+
+
+
+ {t("quiz.retake")}
+
+
+ {t("quiz.tryAnother")}
- )}
-
+
+
+
+
+ {t("quiz.reviewTitle")}
+
+
+ {quiz.questions.map((q, i) => {
+ const attempt = quiz.attempts.find((a) => a.questionId === q.id);
+ const ok = !!attempt?.isCorrect;
+ return (
+
+
+ {ok ? : }
+
+
+
+ {i + 1}. {q.question}
+
+
+
+ {q.options[q.correctIndex]}
+
+ {!ok && attempt && (
+
{t("quiz.yourAnswer", { answer: q.options[attempt.selectedIndex] ?? "" })}
+ )}
+
+
+ );
+ })}
+
+
+
);
}
diff --git a/src/app/roadmap/page.tsx b/src/app/roadmap/page.tsx
index f8505f8..4fb0bcf 100644
--- a/src/app/roadmap/page.tsx
+++ b/src/app/roadmap/page.tsx
@@ -1,24 +1,33 @@
"use client";
import { useState } from "react";
-import { RoadmapNode, RoadmapStep } from "@/components/RoadmapNode";
-import { LoadingSpinner } from "@/components/LoadingSpinner";
+import { PartyPopper, Route } from "lucide-react";
+import { RoadmapNode, type RoadmapStep } from "@/components/RoadmapNode";
import { ErrorNotice } from "@/components/ErrorNotice";
+import { GeneratingState } from "@/components/GeneratingState";
+import { TopicForm } from "@/components/TopicForm";
+import { PageHeader } from "@/components/ui/PageHeader";
+import { PageShell } from "@/components/ui/PageShell";
+import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { ApiError, postJson } from "@/lib/api-client";
import { recordSession } from "@/lib/record-session";
+import { useI18n } from "@/i18n/I18nProvider";
+
+type Level = "beginner" | "intermediate" | "advanced";
export default function RoadmapPage() {
- const [topicInput, setTopicInput] = useState("");
- const [level, setLevel] = useState("beginner");
+ const { t } = useI18n();
+ const [level, setLevel] = useState("beginner");
const [steps, setSteps] = useState([]);
const [title, setTitle] = useState("");
+ const [lastTopic, setLastTopic] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
- const handleGenerate = async () => {
- const topic = topicInput.trim();
- if (!topic || isLoading) return;
+ const generate = async (topic: string) => {
+ if (isLoading) return;
setIsLoading(true);
setError(null);
+ setLastTopic(topic);
const started = Date.now();
try {
const data = await postJson<{ roadmap: { title: string; steps: RoadmapStep[] }; tokens_used?: number }>(
@@ -35,59 +44,92 @@ export default function RoadmapPage() {
}
};
+ const completeStep = (index: number) => {
+ setSteps((prev) =>
+ prev.map((s, i) => {
+ if (i === index) return { ...s, status: "completed" };
+ if (i === index + 1 && s.status === "upcoming") return { ...s, status: "current" };
+ return s;
+ }),
+ );
+ };
+
+ const done = steps.filter((s) => s.status === "completed").length;
+ const allDone = steps.length > 0 && done === steps.length;
+
return (
-
- 🗺️ Learning Roadmap
+
+
{steps.length === 0 && !isLoading && (
<>
-
-
setTopicInput(e.target.value)}
- onKeyDown={(e) => e.key === "Enter" && handleGenerate()}
- placeholder="Topic to learn..."
- className="w-full px-4 py-2 border rounded-lg mb-3 text-base"
- />
-
setLevel(e.target.value)}
- className="w-full px-4 py-2 border rounded-lg mb-4 bg-white text-base"
- >
- Beginner
- Intermediate
- Advanced
-
-
- Generate Roadmap
-
+
+
+
+
+
+
+ generate(lastTopic) : undefined} />
-
>
)}
- {isLoading &&
}
+ {isLoading &&
}
{steps.length > 0 && (
-
-
{title}
- {steps.map((step, i) => (
-
- ))}
-
+
+
+
+
+ {t(`roadmap.${level}`)} · {t("roadmap.stepsCount", { count: steps.length })}
+
+
{title}
+
{
setSteps([]);
- setTopicInput("");
}}
- className="text-sm text-gray-500 underline hover:text-gray-700"
+ className="btn btn-ghost btn-sm"
>
- New roadmap
+ {t("roadmap.newRoadmap")}
+ {allDone && (
+
+
+ {t("roadmap.allDone")}
+
+ )}
+
+ {steps.map((step, i) => (
+ completeStep(i)}
+ />
+ ))}
+
)}
-
+
);
}
diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx
index ff41cc2..5b3817d 100644
--- a/src/components/AppHeader.tsx
+++ b/src/components/AppHeader.tsx
@@ -1,15 +1,25 @@
"use client";
import Link from "next/link";
+import { usePathname } from "next/navigation";
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
+import { clsx } from "clsx";
+import { KeyRound } from "lucide-react";
import { OPEN_SETTINGS_EVENT } from "@/lib/api-client";
import { getProvider } from "@/lib/llm/providers";
import { parseSettings, readRawSettings, subscribeSettings } from "@/lib/llm/settings";
+import { NAV_ITEMS, isActive } from "@/lib/nav";
+import { useI18n } from "@/i18n/I18nProvider";
+import { LanguageSwitch } from "./LanguageSwitch";
+import { LogoMark } from "./Logo";
import { SettingsDialog, type ServerInfo } from "./SettingsDialog";
+import { ThemeToggle } from "./ThemeToggle";
/** `undefined` during server render and hydration: settings not read yet. */
const serverSnapshot = () => undefined;
export function AppHeader() {
+ const { t } = useI18n();
+ const pathname = usePathname();
const [open, setOpen] = useState(false);
const [reason, setReason] = useState
();
const [serverInfo, setServerInfo] = useState(null);
@@ -39,29 +49,72 @@ export function AppHeader() {
const preset = getProvider(settings.provider);
const serverMode = serverInfo?.mode === "server";
const ready = serverMode || !!settings.apiKey || (!!preset && !preset.keyRequired);
- const status = !loaded ? "" : serverMode ? "Server key" : ready ? preset?.label ?? "Ready" : "Add API key";
+ const status = !loaded
+ ? ""
+ : serverMode
+ ? t("header.statusServer")
+ : ready
+ ? (preset?.label ?? t("header.statusReady"))
+ : t("header.statusNeedsKey");
+ // No backdrop-filter on the header: it would become the containing block of the fixed settings dialog.
return (
-
-
-
-
🎓 LearnPath
+
diff --git a/src/components/ChatBubble.tsx b/src/components/ChatBubble.tsx
index 428176b..07f8c8e 100644
--- a/src/components/ChatBubble.tsx
+++ b/src/components/ChatBubble.tsx
@@ -1,5 +1,6 @@
"use client";
import { clsx } from "clsx";
+import { useI18n } from "@/i18n/I18nProvider";
interface ChatBubbleProps {
role: "user" | "assistant";
@@ -10,35 +11,37 @@ interface ChatBubbleProps {
}
export function ChatBubble({ role, content, reasoning, timestamp, tokensUsed }: ChatBubbleProps) {
+ const { t } = useI18n();
const isUser = role === "user";
return (
-
+
- {isUser ? "U" : "M"}
+ {isUser ? t("chat.you").slice(0, 1) : t("chat.model")}
-
{content}
+
{content}
{reasoning && (
-
- Reasoning
+
+ {t("chat.reasoning")}
{reasoning}
)}
{(timestamp || tokensUsed) && (
-
+
{timestamp && {timestamp} }
- {tokensUsed && {tokensUsed} tokens }
+ {tokensUsed && {t("common.tokens", { count: tokensUsed })} }
)}
diff --git a/src/components/ConceptMap.tsx b/src/components/ConceptMap.tsx
index fd8914c..ce1db2e 100644
--- a/src/components/ConceptMap.tsx
+++ b/src/components/ConceptMap.tsx
@@ -1,4 +1,7 @@
"use client";
+import { clsx } from "clsx";
+import { CircleCheck, CircleDashed, CircleDot } from "lucide-react";
+import { useI18n } from "@/i18n/I18nProvider";
interface Concept {
id: string;
@@ -12,27 +15,53 @@ interface ConceptMapProps {
title?: string;
}
-export function ConceptMap({ concepts, title = "Concept Map" }: ConceptMapProps) {
- const getMasteryColor = (mastery: number): string => {
- if (mastery >= 80) return "bg-green-100 border-green-400 text-green-800";
- if (mastery >= 50) return "bg-yellow-100 border-yellow-400 text-yellow-800";
- return "bg-red-100 border-red-400 text-red-800";
- };
+function band(mastery: number) {
+ if (mastery >= 80) return { key: "concepts.strong", icon: CircleCheck, chip: "bg-success-soft text-success-ink", bar: "bg-success" } as const;
+ if (mastery >= 50) return { key: "concepts.building", icon: CircleDot, chip: "bg-warning-soft text-warning-ink", bar: "bg-accent" } as const;
+ return { key: "concepts.weak", icon: CircleDashed, chip: "bg-danger-soft text-danger-ink", bar: "bg-danger" } as const;
+}
+
+export function ConceptMap({ concepts, title }: ConceptMapProps) {
+ const { t } = useI18n();
+ const byId = new Map(concepts.map((c) => [c.id, c.label]));
return (
-
-
{title}
-
- {concepts.map((concept) => (
-
- {concept.label}
- {concept.mastery}%
-
- ))}
+
+
+
{title ?? t("concepts.title")}
+
+ {[90, 60, 30].map((m) => {
+ const b = band(m);
+ return (
+
+
+ {t(b.key)}
+
+ );
+ })}
+
-
+
+
);
}
diff --git a/src/components/ErrorNotice.tsx b/src/components/ErrorNotice.tsx
index 34da334..2991c7d 100644
--- a/src/components/ErrorNotice.tsx
+++ b/src/components/ErrorNotice.tsx
@@ -1,20 +1,34 @@
"use client";
+import { CircleAlert, KeyRound } from "lucide-react";
import { ApiError, openSettings } from "@/lib/api-client";
+import { useI18n } from "@/i18n/I18nProvider";
+import { errorMessage } from "@/i18n/errors";
-export function ErrorNotice({ error }: { error: ApiError | null }) {
+export function ErrorNotice({ error, onRetry }: { error: ApiError | null; onRetry?: () => void }) {
+ const { t } = useI18n();
if (!error) return null;
return (
-
-
{error.message}
- {error.fixableInSettings && (
-
openSettings()}
- className="mt-3 rounded-lg bg-white px-3 py-1.5 font-medium text-red-700 shadow-sm ring-1 ring-red-200 hover:bg-red-100"
- >
- Open Settings
-
- )}
+
+
+
+
{t("errors.title")}
+
{errorMessage(t, error.code)}
+ {(error.fixableInSettings || onRetry) && (
+
+ {error.fixableInSettings && (
+ openSettings()} className="btn btn-secondary btn-sm">
+
+ {t("errors.openSettings")}
+
+ )}
+ {onRetry && !error.fixableInSettings && (
+
+ {t("common.tryAgain")}
+
+ )}
+
+ )}
+
);
}
diff --git a/src/components/FlashCard.tsx b/src/components/FlashCard.tsx
index 32d1878..afe766b 100644
--- a/src/components/FlashCard.tsx
+++ b/src/components/FlashCard.tsx
@@ -1,5 +1,6 @@
"use client";
-import { clsx } from "clsx";
+import { RefreshCw } from "lucide-react";
+import { useI18n } from "@/i18n/I18nProvider";
interface FlashCardProps {
front: string;
@@ -9,13 +10,19 @@ interface FlashCardProps {
topic?: string;
}
+/**
+ * Both faces share one grid cell, so the card grows with the longer text
+ * instead of clipping it.
+ */
export function FlashCard({ front, back, isFlipped, onFlip, topic }: FlashCardProps) {
+ const { t } = useI18n();
return (
{
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
@@ -23,26 +30,30 @@ export function FlashCard({ front, back, isFlipped, onFlip, topic }: FlashCardPr
}
}}
>
-
- {/* Front */}
-
+
+
{topic && (
-
+
{topic}
)}
- {front}
- Tap to reveal
+ {front}
+
+
+ {t("flashcards.tapToReveal")}
+
-
- {/* Back */}
-
-
{back}
+
+
+ {t("flashcards.answer")}
+
+
{back}
diff --git a/src/components/GeneratingState.tsx b/src/components/GeneratingState.tsx
new file mode 100644
index 0000000..a992749
--- /dev/null
+++ b/src/components/GeneratingState.tsx
@@ -0,0 +1,47 @@
+"use client";
+import { LoadingSpinner } from "./LoadingSpinner";
+
+/** Loading state shown while the model writes: status text plus a skeleton of the result. */
+export function GeneratingState({ label, hint, shape }: { label: string; hint: string; shape: "quiz" | "cards" | "roadmap" }) {
+ return (
+
+
+
+ {shape === "quiz" && (
+ <>
+
+
+
+ {[0, 1, 2, 3].map((i) => (
+
+ ))}
+ >
+ )}
+ {shape === "cards" && (
+
+ )}
+ {shape === "roadmap" &&
+ [0, 1, 2].map((i) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/LanguageSwitch.tsx b/src/components/LanguageSwitch.tsx
new file mode 100644
index 0000000..4d29e8e
--- /dev/null
+++ b/src/components/LanguageSwitch.tsx
@@ -0,0 +1,32 @@
+"use client";
+import { clsx } from "clsx";
+import { LOCALES } from "@/i18n/config";
+import { useI18n } from "@/i18n/I18nProvider";
+
+/** BM | EN segmented switch. Each option names its language in that language. */
+export function LanguageSwitch() {
+ const { locale, setLocale, t } = useI18n();
+ return (
+
+ {LOCALES.map((l) => {
+ const active = l === locale;
+ return (
+ !active && setLocale(l)}
+ className={clsx(
+ "min-h-8 min-w-9 rounded-md px-2 text-xs font-bold tracking-wide transition-colors",
+ active ? "bg-surface-raised text-ink shadow-card" : "text-ink-muted hover:text-ink",
+ )}
+ >
+ {t(l === "ms" ? "language.msShort" : "language.enShort")}
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/LoadingSpinner.tsx b/src/components/LoadingSpinner.tsx
index d7cef1c..f349c6f 100644
--- a/src/components/LoadingSpinner.tsx
+++ b/src/components/LoadingSpinner.tsx
@@ -7,17 +7,15 @@ interface LoadingSpinnerProps {
}
export function LoadingSpinner({ size = "md", label }: LoadingSpinnerProps) {
- const sizes = { sm: "w-4 h-4", md: "w-8 h-8", lg: "w-12 h-12" };
+ const sizes = { sm: "h-5 w-5", md: "h-8 w-8", lg: "h-12 w-12" };
return (
- {label &&
{label} }
+ {label &&
{label} }
);
}
diff --git a/src/components/Logo.tsx b/src/components/Logo.tsx
new file mode 100644
index 0000000..7bce8a2
--- /dev/null
+++ b/src/components/Logo.tsx
@@ -0,0 +1,19 @@
+/** LearnPath mark: a winding path from a starting point to a goal. */
+export function LogoMark({ className = "h-8 w-8" }: { className?: string }) {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/MobileNav.tsx b/src/components/MobileNav.tsx
new file mode 100644
index 0000000..344af4a
--- /dev/null
+++ b/src/components/MobileNav.tsx
@@ -0,0 +1,47 @@
+"use client";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { clsx } from "clsx";
+import { NAV_ITEMS, isActive } from "@/lib/nav";
+import { useI18n } from "@/i18n/I18nProvider";
+
+/** Bottom tab bar on small screens. */
+export function MobileNav() {
+ const { t } = useI18n();
+ const pathname = usePathname();
+ return (
+
+
+ {NAV_ITEMS.filter((item) => item.mobile).map((item) => {
+ const active = isActive(pathname, item.href);
+ const Icon = item.icon;
+ return (
+
+
+
+
+
+ {t(item.label)}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/components/ProgressChart.tsx b/src/components/ProgressChart.tsx
index 4df4546..8ba0acd 100644
--- a/src/components/ProgressChart.tsx
+++ b/src/components/ProgressChart.tsx
@@ -3,32 +3,60 @@
interface ProgressChartProps {
data: { label: string; value: number; max?: number }[];
title: string;
+ /** Short note under the title, e.g. the unit. */
+ caption?: string;
+ /** Mark colour; defaults to the brand token. */
color?: string;
}
-export function ProgressChart({ data, title, color = "#3b82f6" }: ProgressChartProps) {
+/**
+ * Single-series column chart. One hue, 4px rounded tops on a shared baseline,
+ * a tooltip per column (hover or keyboard focus) and a visually hidden table
+ * with the exact values for screen readers.
+ */
+export function ProgressChart({ data, title, caption, color = "var(--brand)" }: ProgressChartProps) {
const maxVal = Math.max(...data.map((d) => d.max || d.value), 1);
return (
-
-
{title}
-
- {data.map((item) => (
-
-
{item.label}
-
+
+
+ {title}
+ {caption && {caption}
}
+
+
+ {data.map((item) => {
+ const pct = (item.value / maxVal) * 100;
+ return (
+
0 ? `max(${pct}%, 4px)` : "0", backgroundColor: color }}
/>
+
+ {item.label}: {item.value}
+
-
{item.value}
-
+ );
+ })}
+
+
+ {data.map((item) => (
+
+ {item.label}
+
))}
-
+
+ {title}
+
+ {data.map((item) => (
+
+ {item.label}
+ {item.value}
+
+ ))}
+
+
+
);
}
diff --git a/src/components/QuizCard.tsx b/src/components/QuizCard.tsx
index 9073314..35db61e 100644
--- a/src/components/QuizCard.tsx
+++ b/src/components/QuizCard.tsx
@@ -1,6 +1,8 @@
"use client";
import { clsx } from "clsx";
+import { Check, Lightbulb, X } from "lucide-react";
import type { QuizQuestion } from "@/store/quiz-store";
+import { useI18n } from "@/i18n/I18nProvider";
interface QuizCardProps {
question: QuizQuestion;
@@ -11,62 +13,98 @@ interface QuizCardProps {
showResult?: boolean;
}
-export function QuizCard({
- question,
- questionNumber,
- totalQuestions,
- onAnswer,
- selectedAnswer,
- showResult,
-}: QuizCardProps) {
+const DIFFICULTY_STYLE = {
+ easy: "bg-success-soft text-success-ink",
+ medium: "bg-warning-soft text-warning-ink",
+ hard: "bg-danger-soft text-danger-ink",
+} as const;
+
+export function QuizCard({ question, questionNumber, totalQuestions, onAnswer, selectedAnswer, showResult }: QuizCardProps) {
+ const { t } = useI18n();
+ const answeredCorrectly = showResult && selectedAnswer === question.correctIndex;
+
return (
-
-
-
- Question {questionNumber} of {totalQuestions}
+
+
+
+ {t("quiz.questionOf", { current: questionNumber, total: totalQuestions })}
-
- {question.difficulty}
+
+ {t(`quiz.${question.difficulty}`)}
-
{question.question}
+
{question.question}
-
+
{question.options.map((option, idx) => {
const isSelected = selectedAnswer === idx;
const isCorrect = idx === question.correctIndex;
-
+ const state = !showResult ? (isSelected ? "selected" : "idle") : isCorrect ? "correct" : isSelected ? "wrong" : "dim";
return (
- !showResult && onAnswer(idx)}
- disabled={showResult}
- className={clsx(
- "w-full text-left px-4 py-3 rounded-lg border transition-all",
- !showResult && !isSelected && "border-gray-200 hover:border-blue-400 hover:bg-blue-50",
- showResult && isCorrect && "border-green-500 bg-green-50",
- showResult && isSelected && !isCorrect && "border-red-500 bg-red-50",
- isSelected && !showResult && "border-blue-500 bg-blue-50",
- )}
- >
- {String.fromCharCode(65 + idx)}.
- {option}
-
+
+ !showResult && onAnswer(idx)}
+ disabled={showResult}
+ aria-pressed={isSelected}
+ className={clsx(
+ "flex w-full items-start gap-3 rounded-xl border px-4 py-3 text-left text-[0.9375rem] leading-snug transition-colors",
+ state === "idle" && "border-line bg-surface hover:border-brand hover:bg-brand-soft/40",
+ state === "selected" && "border-brand bg-brand-soft",
+ state === "correct" && "border-success bg-success-soft text-success-ink",
+ state === "wrong" && "border-danger bg-danger-soft text-danger-ink",
+ state === "dim" && "border-line bg-surface text-ink-muted",
+ showResult && "cursor-default",
+ )}
+ >
+
+ {state === "correct" ? (
+
+ ) : state === "wrong" ? (
+
+ ) : (
+ String.fromCharCode(65 + idx)
+ )}
+
+ {option}
+
+
);
})}
-
+
- {showResult && question.explanation && (
-
-
Explanation: {question.explanation}
+ {showResult && (
+
+ {selectedAnswer !== undefined && (
+
+ {answeredCorrectly ? t("quiz.correct") : t("quiz.incorrect")}
+ {!answeredCorrectly && (
+
+ {" "}
+ {t("quiz.correctAnswerWas", { answer: String.fromCharCode(65 + question.correctIndex) })}
+
+ )}
+
+ )}
+ {question.explanation && (
+
+
+
+ {t("quiz.explanation")}: {question.explanation}
+
+
+ )}
)}
diff --git a/src/components/RoadmapNode.tsx b/src/components/RoadmapNode.tsx
index 7e70e69..9263250 100644
--- a/src/components/RoadmapNode.tsx
+++ b/src/components/RoadmapNode.tsx
@@ -1,5 +1,7 @@
"use client";
import { clsx } from "clsx";
+import { Check, Clock } from "lucide-react";
+import { useI18n } from "@/i18n/I18nProvider";
export interface RoadmapStep {
id: string;
@@ -14,61 +16,93 @@ interface RoadmapNodeProps {
step: RoadmapStep;
index: number;
isLast: boolean;
+ /** Shown on the current step when given. */
+ onComplete?: () => void;
}
-export function RoadmapNode({ step, index, isLast }: RoadmapNodeProps) {
- const statusColors = {
- completed: "bg-green-500 border-green-500",
- current: "bg-blue-500 border-blue-500",
- upcoming: "bg-gray-200 border-gray-300",
- };
+const STATUS_LABEL = {
+ completed: "roadmap.statusCompleted",
+ current: "roadmap.statusCurrent",
+ upcoming: "roadmap.statusUpcoming",
+} as const;
+
+export function RoadmapNode({ step, index, isLast, onComplete }: RoadmapNodeProps) {
+ const { t } = useI18n();
+ const status = step.status;
return (
-
+
-
- {step.status === "completed" ? "✓" : index + 1}
-
+ {status === "completed" ?
: index + 1}
+
+ {t("roadmap.stepLabel", { number: index + 1 })}, {t(STATUS_LABEL[status])}
+
+
{!isLast && (
-
)}
-
-
+
+
+ {status === "upcoming" ? t("roadmap.stepLabel", { number: index + 1 }) : t(STATUS_LABEL[status])}
+
+ {step.estimatedTime && (
+
+
+ {step.estimatedTime}
+
)}
- >
+
+
{step.title}
- {step.description}
- {step.estimatedTime && (
-
- ⏱ {step.estimatedTime}
-
- )}
+ {step.description &&
{step.description}
}
{step.topics && step.topics.length > 0 && (
-
- {step.topics.map((t) => (
-
- {t}
-
+
+ {step.topics.map((topic) => (
+
+ {topic}
+
))}
-
+
+ )}
+ {status === "current" && onComplete && (
+
+
+ {t("roadmap.markDone")}
+
)}
-
+
);
}
diff --git a/src/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx
index 9a993fb..ce3427d 100644
--- a/src/components/SettingsDialog.tsx
+++ b/src/components/SettingsDialog.tsx
@@ -1,7 +1,11 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import { Eye, EyeOff, ExternalLink, Info, ShieldCheck, X } from "lucide-react";
import { PROVIDERS, getProvider } from "@/lib/llm/providers";
import { clearSettings, defaultSettings, saveSettings, type LlmSettings } from "@/lib/llm/settings";
+import { useI18n } from "@/i18n/I18nProvider";
+import { errorMessage } from "@/i18n/errors";
export interface ServerInfo {
mode: "server" | "byo-key";
@@ -13,23 +17,49 @@ export interface ServerInfo {
}
interface SettingsDialogProps {
+ /** Error code that made the app open this panel (e.g. "missing_key"). */
reason?: string;
initial: LlmSettings;
serverInfo: ServerInfo | null;
onClose: () => void;
}
-/** Rendered only while open, so the form starts from `initial` every time. */
+/**
+ * Rendered only while open, so the form starts from `initial` every time.
+ * Portalled to so no ancestor's stacking context or filter can trap it.
+ */
export function SettingsDialog({ reason, initial, serverInfo, onClose }: SettingsDialogProps) {
+ const { t } = useI18n();
const [draft, setDraft] = useState
(initial);
const [showKey, setShowKey] = useState(false);
- const [notice, setNotice] = useState(null);
+ const [notice, setNotice] = useState<"storageBlocked" | "keyForgotten" | null>(null);
const panelRef = useRef(null);
const ids = useId();
- // Escape closes; lock page scroll on while open.
+ // Escape closes, Tab stays inside the panel, page scroll is locked on ,
+ // and focus returns to whatever opened the panel.
useEffect(() => {
- const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
+ const opener = document.activeElement as HTMLElement | null;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ onClose();
+ return;
+ }
+ if (e.key !== "Tab" || !panelRef.current) return;
+ const focusable = panelRef.current.querySelectorAll(
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
+ );
+ if (focusable.length === 0) return;
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+ if (e.shiftKey && (document.activeElement === first || document.activeElement === panelRef.current)) {
+ e.preventDefault();
+ last.focus();
+ } else if (!e.shiftKey && document.activeElement === last) {
+ e.preventDefault();
+ first.focus();
+ }
+ };
window.addEventListener("keydown", onKey);
const root = document.documentElement;
const previous = root.style.overflow;
@@ -38,6 +68,7 @@ export function SettingsDialog({ reason, initial, serverInfo, onClose }: Setting
return () => {
window.removeEventListener("keydown", onKey);
root.style.overflow = previous;
+ opener?.focus?.();
};
}, [onClose]);
@@ -59,7 +90,7 @@ export function SettingsDialog({ reason, initial, serverInfo, onClose }: Setting
baseUrl: needsBaseUrl ? draft.baseUrl.trim() : "",
};
if (!saveSettings(cleaned)) {
- setNotice("This browser blocked local storage, so the settings could not be saved (private mode?).");
+ setNotice("storageBlocked");
return;
}
onClose();
@@ -68,12 +99,12 @@ export function SettingsDialog({ reason, initial, serverInfo, onClose }: Setting
const forget = () => {
clearSettings();
setDraft({ ...draft, apiKey: "" });
- setNotice("Your key has been removed from this browser.");
+ setNotice("keyForgotten");
};
- return (
+ return createPortal(
e.target === e.currentTarget && onClose()}
>
-
+
-
- AI provider settings
+
+ {t("settings.title")}
- Bring your own API key to use the AI features.
+
{t("settings.subtitle")}
-
-
-
-
+
+
{reason && !serverMode && (
-
{reason}
+
+
+ {errorMessage(t, reason)}
+
)}
{serverMode ? (
-
- This server has its own API key configured
- {serverInfo?.providerLabel ? ` (${serverInfo.providerLabel}${serverInfo.model ? `, ${serverInfo.model}` : ""})` : ""}.
- There is nothing to set up here.
+
+
+
+ {t("settings.serverMode", {
+ detail: serverInfo?.providerLabel
+ ? ` (${serverInfo.providerLabel}${serverInfo.model ? `, ${serverInfo.model}` : ""})`
+ : "",
+ })}
+
-
- Done
+
+ {t("common.done")}
) : (
+
,
+ document.body,
);
}
diff --git a/src/components/SiteFooter.tsx b/src/components/SiteFooter.tsx
new file mode 100644
index 0000000..bc7be50
--- /dev/null
+++ b/src/components/SiteFooter.tsx
@@ -0,0 +1,28 @@
+"use client";
+import { useI18n } from "@/i18n/I18nProvider";
+
+export function SiteFooter() {
+ const { t } = useI18n();
+ const [builtBefore, builtAfter = ""] = t("footer.builtBy").split("{name}");
+ return (
+
+ );
+}
diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx
new file mode 100644
index 0000000..7d96388
--- /dev/null
+++ b/src/components/ThemeToggle.tsx
@@ -0,0 +1,26 @@
+"use client";
+import { useSyncExternalStore } from "react";
+import { Moon, Sun } from "lucide-react";
+import { useI18n } from "@/i18n/I18nProvider";
+import { getEffectiveTheme, setTheme, subscribeTheme, type Theme } from "@/lib/theme";
+
+const serverTheme = (): Theme | null => null;
+
+export function ThemeToggle() {
+ const { t } = useI18n();
+ const theme = useSyncExternalStore(subscribeTheme, getEffectiveTheme, serverTheme);
+ const label = theme === "dark" ? t("header.themeToLight") : t("header.themeToDark");
+ return (
+
setTheme(getEffectiveTheme() === "dark" ? "light" : "dark")}
+ aria-label={label}
+ title={label}
+ className="btn btn-ghost btn-sm h-9 w-9 p-0"
+ >
+ {/* Icons switch with CSS so the first paint is already right. */}
+
+
+
+ );
+}
diff --git a/src/components/TokenUsageBar.tsx b/src/components/TokenUsageBar.tsx
index bd596aa..a79f489 100644
--- a/src/components/TokenUsageBar.tsx
+++ b/src/components/TokenUsageBar.tsx
@@ -11,14 +11,11 @@ export function TokenUsageBar({ feature, current, daily }: TokenUsageBarProps) {
return (
-
{feature}
-
-
+
{feature}
+
-
+
{(current / 1000).toFixed(1)}K / {daily}M
diff --git a/src/components/TopicForm.tsx b/src/components/TopicForm.tsx
new file mode 100644
index 0000000..9ef5701
--- /dev/null
+++ b/src/components/TopicForm.tsx
@@ -0,0 +1,88 @@
+"use client";
+import { useId, useState } from "react";
+import { Sparkles } from "lucide-react";
+import { useI18n } from "@/i18n/I18nProvider";
+
+interface TopicFormProps {
+ title: string;
+ placeholder: string;
+ submitLabel: string;
+ busy?: boolean;
+ /** Pre-fill, e.g. the topic of a request that failed. */
+ defaultTopic?: string;
+ /** Extra options (difficulty, level, ...) shown under the topic field. */
+ children?: React.ReactNode;
+ onSubmit: (topic: string) => void;
+}
+
+const EXAMPLES = ["topicForm.example1", "topicForm.example2", "topicForm.example3", "topicForm.example4"] as const;
+
+/** Shared "choose a topic" card used by the quiz, flashcard and roadmap pages. */
+export function TopicForm({ title, placeholder, submitLabel, busy, defaultTopic = "", children, onSubmit }: TopicFormProps) {
+ const { t } = useI18n();
+ const id = useId();
+ const [topic, setTopic] = useState(defaultTopic);
+ const [touched, setTouched] = useState(false);
+ const empty = !topic.trim();
+
+ return (
+
+ );
+}
diff --git a/src/components/ui/EmptyState.tsx b/src/components/ui/EmptyState.tsx
new file mode 100644
index 0000000..69b4b8b
--- /dev/null
+++ b/src/components/ui/EmptyState.tsx
@@ -0,0 +1,24 @@
+import type { LucideIcon } from "lucide-react";
+
+export function EmptyState({
+ icon: Icon,
+ title,
+ description,
+ action,
+}: {
+ icon: LucideIcon;
+ title: string;
+ description: string;
+ action?: React.ReactNode;
+}) {
+ return (
+
+
+
+
+
{title}
+
{description}
+ {action &&
{action}
}
+
+ );
+}
diff --git a/src/components/ui/PageHeader.tsx b/src/components/ui/PageHeader.tsx
new file mode 100644
index 0000000..2d082a1
--- /dev/null
+++ b/src/components/ui/PageHeader.tsx
@@ -0,0 +1,25 @@
+import type { LucideIcon } from "lucide-react";
+
+interface PageHeaderProps {
+ icon: LucideIcon;
+ title: string;
+ description?: string;
+ children?: React.ReactNode;
+}
+
+export function PageHeader({ icon: Icon, title, description, children }: PageHeaderProps) {
+ return (
+
+
+
+
+
+
+
{title}
+ {description &&
{description}
}
+
+
+ {children}
+
+ );
+}
diff --git a/src/components/ui/PageShell.tsx b/src/components/ui/PageShell.tsx
new file mode 100644
index 0000000..ed49560
--- /dev/null
+++ b/src/components/ui/PageShell.tsx
@@ -0,0 +1,19 @@
+import { clsx } from "clsx";
+
+/** Page container. `id="main"` is the skip-link target. */
+export function PageShell({ children, width = "default" }: { children: React.ReactNode; width?: "default" | "narrow" | "wide" }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/components/ui/ProgressBar.tsx b/src/components/ui/ProgressBar.tsx
new file mode 100644
index 0000000..8d27615
--- /dev/null
+++ b/src/components/ui/ProgressBar.tsx
@@ -0,0 +1,15 @@
+export function ProgressBar({ value, max, label }: { value: number; max: number; label: string }) {
+ const pct = max > 0 ? Math.min(100, Math.max(0, (value / max) * 100)) : 0;
+ return (
+
+ );
+}
diff --git a/src/components/ui/SegmentedControl.tsx b/src/components/ui/SegmentedControl.tsx
new file mode 100644
index 0000000..edb0e0e
--- /dev/null
+++ b/src/components/ui/SegmentedControl.tsx
@@ -0,0 +1,45 @@
+"use client";
+import { useId } from "react";
+import { clsx } from "clsx";
+
+interface SegmentedControlProps
{
+ label: string;
+ value: T;
+ options: readonly { value: T; label: string }[];
+ onChange: (value: T) => void;
+}
+
+/** Radio group styled as a segmented control. Arrow keys work natively. */
+export function SegmentedControl({ label, value, options, onChange }: SegmentedControlProps) {
+ const name = useId();
+ return (
+
+ {label}
+
+ {options.map((opt) => {
+ const checked = opt.value === value;
+ return (
+
+ onChange(opt.value)}
+ className="sr-only"
+ />
+ {opt.label}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/hooks/useChat.ts b/src/hooks/useChat.ts
index ce08cc2..94ab908 100644
--- a/src/hooks/useChat.ts
+++ b/src/hooks/useChat.ts
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useState } from "react";
-import { ApiError, apiHeaders, openSettings, postJson, toApiError } from "@/lib/api-client";
+import { ApiError, apiHeaders, openSettings, postJson, toApiError, withLanguage } from "@/lib/api-client";
import { parseSSEStream } from "@/lib/sse-parser";
interface ChatState {
@@ -52,11 +52,11 @@ export function useChat() {
const res = await fetch("/api/chat", {
method: "POST",
headers: apiHeaders(),
- body: JSON.stringify({ message, systemPrompt, stream: true }),
+ body: JSON.stringify(withLanguage({ message, systemPrompt, stream: true })),
});
if (!res.ok) {
const err = await toApiError(res);
- if (err.code === "missing_key") openSettings(err.message);
+ if (err.code === "missing_key") openSettings(err.code);
throw err;
}
if (!res.body) throw new ApiError("empty_stream", "The server returned an empty stream.", res.status);
diff --git a/src/i18n/I18nProvider.tsx b/src/i18n/I18nProvider.tsx
new file mode 100644
index 0000000..02ac27f
--- /dev/null
+++ b/src/i18n/I18nProvider.tsx
@@ -0,0 +1,53 @@
+"use client";
+import { createContext, useCallback, useContext, useEffect, useMemo, useSyncExternalStore } from "react";
+import { useRouter } from "next/navigation";
+import { intlLocale, type Locale } from "./config";
+import { translate, type MessageKey, type TranslateParams } from "./messages";
+import { getClientLocale, setClientLocale, subscribeLocale, writeLocaleCookie } from "./locale-store";
+
+export interface I18nValue {
+ locale: Locale;
+ /** BCP 47 tag for Intl formatting. */
+ intl: string;
+ setLocale: (locale: Locale) => void;
+ t: (key: MessageKey, params?: TranslateParams) => string;
+}
+
+function makeValue(locale: Locale, setLocale: (l: Locale) => void): I18nValue {
+ return {
+ locale,
+ intl: intlLocale(locale),
+ setLocale,
+ t: (key, params) => translate(locale, key, params),
+ };
+}
+
+// Without a provider (unit tests, isolated components) everything renders in English.
+const I18nContext = createContext(makeValue("en", () => undefined));
+
+export function I18nProvider({ initialLocale, children }: { initialLocale: Locale; children: React.ReactNode }) {
+ const router = useRouter();
+ // Server render and hydration use the locale the server picked; afterwards the browser's choice wins.
+ const locale = useSyncExternalStore(subscribeLocale, getClientLocale, () => initialLocale);
+
+ useEffect(() => {
+ document.documentElement.lang = locale;
+ writeLocaleCookie(locale);
+ }, [locale]);
+
+ const setLocale = useCallback(
+ (next: Locale) => {
+ setClientLocale(next);
+ // Re-render server parts (page title, ) with the new cookie.
+ router.refresh();
+ },
+ [router],
+ );
+
+ const value = useMemo(() => makeValue(locale, setLocale), [locale, setLocale]);
+ return {children} ;
+}
+
+export function useI18n(): I18nValue {
+ return useContext(I18nContext);
+}
diff --git a/src/i18n/config.ts b/src/i18n/config.ts
new file mode 100644
index 0000000..aeac0c3
--- /dev/null
+++ b/src/i18n/config.ts
@@ -0,0 +1,51 @@
+/**
+ * Locale settings shared by the server, the browser and the API routes.
+ * Pure data and pure functions: safe to import anywhere.
+ */
+export const LOCALES = ["en", "ms"] as const;
+export type Locale = (typeof LOCALES)[number];
+
+export const DEFAULT_LOCALE: Locale = "en";
+
+/** localStorage key that holds the visitor's chosen UI language. */
+export const LOCALE_STORAGE_KEY = "learnpath.locale.v1";
+/** Mirror of the choice, read by the server so the first paint is already in the right language. */
+export const LOCALE_COOKIE = "learnpath-locale";
+
+export function isLocale(value: unknown): value is Locale {
+ return typeof value === "string" && (LOCALES as readonly string[]).includes(value);
+}
+
+/**
+ * Pick a locale from a list of browser language tags (navigator.languages or
+ * Accept-Language). Malay (`ms`, `ms-MY`, `zsm`) maps to BM; anything else is English.
+ */
+export function detectLocale(languages: readonly string[] | null | undefined): Locale {
+ for (const tag of languages ?? []) {
+ const base = tag.trim().toLowerCase().split(/[-_;]/)[0];
+ if (base === "ms" || base === "zsm") return "ms";
+ if (base === "en") return "en";
+ }
+ return DEFAULT_LOCALE;
+}
+
+/** Parse an Accept-Language header into tags ordered by q-value. */
+export function parseAcceptLanguage(header: string | null | undefined): string[] {
+ if (!header) return [];
+ return header
+ .split(",")
+ .map((part, index) => {
+ const [tag, ...params] = part.trim().split(";");
+ const q = params.map((p) => p.trim()).find((p) => p.startsWith("q="));
+ const weight = q ? Number.parseFloat(q.slice(2)) : 1;
+ return { tag: tag.trim(), weight: Number.isFinite(weight) ? weight : 0, index };
+ })
+ .filter((x) => x.tag && x.weight > 0)
+ .sort((a, b) => b.weight - a.weight || a.index - b.index)
+ .map((x) => x.tag);
+}
+
+/** BCP 47 tag for Intl number/date formatting. */
+export function intlLocale(locale: Locale): string {
+ return locale === "ms" ? "ms-MY" : "en-MY";
+}
diff --git a/src/i18n/errors.ts b/src/i18n/errors.ts
new file mode 100644
index 0000000..b72974d
--- /dev/null
+++ b/src/i18n/errors.ts
@@ -0,0 +1,12 @@
+import type { MessageKey, TranslateParams } from "./messages";
+import { en } from "./messages/en";
+
+type T = (key: MessageKey, params?: TranslateParams) => string;
+
+/** Localised message for an API error code; unknown codes get the generic message. */
+export function errorMessage(t: T, code: string | undefined): string {
+ if (code && code !== "title" && code !== "openSettings" && Object.prototype.hasOwnProperty.call(en.errors, code)) {
+ return t(`errors.${code}` as MessageKey);
+ }
+ return t("errors.unknown");
+}
diff --git a/src/i18n/locale-store.ts b/src/i18n/locale-store.ts
new file mode 100644
index 0000000..dfadba9
--- /dev/null
+++ b/src/i18n/locale-store.ts
@@ -0,0 +1,96 @@
+/**
+ * Browser-side UI language.
+ *
+ * Source of truth: localStorage (`learnpath.locale.v1`). A cookie mirrors the
+ * choice so the server can render the first paint in the same language.
+ * Every storage access is wrapped in try/catch: storage can be blocked
+ * (private mode, site data disabled), and then the choice lives in memory for
+ * this tab only.
+ */
+import { LOCALE_COOKIE, LOCALE_STORAGE_KEY, detectLocale, isLocale, type Locale } from "./config";
+
+const CHANGED_EVENT = "learnpath:locale-changed";
+const ONE_YEAR = 60 * 60 * 24 * 365;
+
+let memoryLocale: Locale | null = null;
+
+export function readStoredLocale(): Locale | null {
+ try {
+ const value = typeof window === "undefined" ? null : window.localStorage.getItem(LOCALE_STORAGE_KEY);
+ return isLocale(value) ? value : null;
+ } catch {
+ return null;
+ }
+}
+
+export function readCookieLocale(): Locale | null {
+ try {
+ if (typeof document === "undefined") return null;
+ const match = document.cookie.split("; ").find((c) => c.startsWith(`${LOCALE_COOKIE}=`));
+ const value = match?.slice(LOCALE_COOKIE.length + 1);
+ return isLocale(value) ? value : null;
+ } catch {
+ return null;
+ }
+}
+
+/** Write the cookie the server reads. Harmless when cookies are blocked. */
+export function writeLocaleCookie(locale: Locale): void {
+ try {
+ if (typeof document === "undefined") return;
+ document.cookie = `${LOCALE_COOKIE}=${locale}; path=/; max-age=${ONE_YEAR}; samesite=lax`;
+ } catch {
+ // cookies blocked: the server falls back to Accept-Language
+ }
+}
+
+/** The language the UI should show right now in this browser. */
+export function getClientLocale(): Locale {
+ if (memoryLocale) return memoryLocale;
+ const stored = readStoredLocale();
+ if (stored) return stored;
+ const cookie = readCookieLocale();
+ if (cookie) return cookie;
+ if (typeof navigator === "undefined") return detectLocale([]);
+ return detectLocale(navigator.languages?.length ? navigator.languages : [navigator.language]);
+}
+
+/** Persist a choice. Returns false when localStorage refused it (the choice still applies to this tab). */
+export function setClientLocale(locale: Locale): boolean {
+ memoryLocale = locale;
+ let saved = false;
+ try {
+ window.localStorage.setItem(LOCALE_STORAGE_KEY, locale);
+ saved = true;
+ } catch {
+ saved = false;
+ }
+ writeLocaleCookie(locale);
+ try {
+ window.dispatchEvent(new Event(CHANGED_EVENT));
+ } catch {
+ // not in a browser
+ }
+ return saved;
+}
+
+export function subscribeLocale(onChange: () => void): () => void {
+ if (typeof window === "undefined") return () => undefined;
+ const onStorage = (e: StorageEvent) => {
+ if (e.key === LOCALE_STORAGE_KEY) {
+ memoryLocale = null;
+ onChange();
+ }
+ };
+ window.addEventListener(CHANGED_EVENT, onChange);
+ window.addEventListener("storage", onStorage);
+ return () => {
+ window.removeEventListener(CHANGED_EVENT, onChange);
+ window.removeEventListener("storage", onStorage);
+ };
+}
+
+/** Test helper: forget the in-memory choice. */
+export function resetLocaleMemory(): void {
+ memoryLocale = null;
+}
diff --git a/src/i18n/messages/en.ts b/src/i18n/messages/en.ts
new file mode 100644
index 0000000..ed19635
--- /dev/null
+++ b/src/i18n/messages/en.ts
@@ -0,0 +1,269 @@
+/**
+ * English UI text. `ms.ts` must have exactly the same keys (enforced by the
+ * `Messages` type and by tests/i18n/messages.test.ts).
+ *
+ * Placeholders use {name}. Keep both languages' placeholders identical.
+ */
+export const en = {
+ meta: {
+ title: "LearnPath: quizzes, flashcards and learning roadmaps with AI",
+ description:
+ "Generate quizzes, flashcards and learning roadmaps for any topic with an OpenAI-compatible LLM. Bring your own API key. In English and Bahasa Melayu.",
+ },
+ common: {
+ appName: "LearnPath",
+ skipToContent: "Skip to content",
+ tryAgain: "Try again",
+ newTopic: "New topic",
+ close: "Close",
+ cancel: "Cancel",
+ save: "Save",
+ done: "Done",
+ next: "Next",
+ tokens: "{count} tokens",
+ sampleData: "Sample data",
+ },
+ nav: {
+ label: "Main navigation",
+ home: "Home",
+ quiz: "Quiz",
+ flashcards: "Flashcards",
+ roadmap: "Roadmap",
+ progress: "Progress",
+ concepts: "Concepts",
+ },
+ header: {
+ homeLink: "LearnPath home",
+ settings: "Settings",
+ statusServer: "Server key",
+ statusReady: "Ready",
+ statusNeedsKey: "Add API key",
+ language: "Language",
+ themeToDark: "Switch to dark theme",
+ themeToLight: "Switch to light theme",
+ },
+ language: {
+ en: "English",
+ ms: "Bahasa Melayu",
+ enShort: "EN",
+ msShort: "BM",
+ },
+ footer: {
+ source: "Source code on GitHub",
+ builtBy: "Built by {name}",
+ tagline: "Made in Malaysia",
+ },
+ home: {
+ eyebrow: "AI study companion",
+ title: "Turn any topic into a study plan you can practise.",
+ lead: "Type a topic. LearnPath asks the AI model you choose for a quiz, a flashcard deck or a step-by-step roadmap, in English or Bahasa Melayu.",
+ ctaQuiz: "Start a quiz",
+ ctaRoadmap: "Plan a roadmap",
+ featuresTitle: "What you can do",
+ quizTitle: "Quiz",
+ quizDesc: "Multiple-choice questions at the difficulty you pick, each with an explanation.",
+ flashcardsTitle: "Flashcards",
+ flashcardsDesc: "A deck to review, rated with the SM-2 spaced-repetition method.",
+ roadmapTitle: "Learning roadmap",
+ roadmapDesc: "A step-by-step plan from beginner to advanced, with time estimates.",
+ progressTitle: "Progress",
+ progressDesc: "Sessions, scores and token usage from this visit.",
+ conceptsTitle: "Concept map",
+ conceptsDesc: "A sample map of related concepts and mastery levels.",
+ howTitle: "How it works",
+ how1Title: "Pick a topic",
+ how1Desc: "Anything from Photosynthesis to SQL joins.",
+ how2Title: "The AI drafts it",
+ how2Desc: "Your chosen model writes questions, cards or steps in your language.",
+ how3Title: "Practise and review",
+ how3Desc: "Answer, flip and rate. Your progress for this visit appears on the Progress page.",
+ byokTitle: "Bring your own key",
+ byokDesc: "Use a key from OpenAI, OpenRouter, Groq, DeepSeek, Together AI, Mistral or Xiaomi MiMo. It stays in your browser. Self-hosted copies can also use Ollama, llama.cpp or any OpenAI-compatible endpoint.",
+ byokCta: "Open settings",
+ honestNote: "AI output can contain mistakes. Check important facts with your textbook or teacher.",
+ },
+ topicForm: {
+ topicLabel: "Topic",
+ examplesLabel: "Try",
+ example1: "Photosynthesis",
+ example2: "Fractions",
+ example3: "World War II",
+ example4: "SQL joins",
+ emptyTopic: "Enter a topic first.",
+ },
+ quiz: {
+ title: "Quiz",
+ description: "Multiple-choice questions on any topic, with an explanation for every answer.",
+ formTitle: "Create a quiz",
+ placeholder: "e.g. Photosynthesis, Fractions, React hooks",
+ difficulty: "Difficulty",
+ questionCount: "Questions",
+ generate: "Generate quiz",
+ loading: "Writing your quiz…",
+ loadingHint: "This can take up to a minute, depending on the model.",
+ questionOf: "Question {current} of {total}",
+ easy: "Easy",
+ medium: "Medium",
+ hard: "Hard",
+ explanation: "Explanation",
+ correct: "Correct",
+ incorrect: "Not quite",
+ correctAnswerWas: "The answer is {answer}.",
+ seeResults: "See results",
+ completeTitle: "Quiz complete",
+ scoreLine: "{correct} of {total} correct",
+ scoreGreat: "Excellent work. You know this topic well.",
+ scoreGood: "Good progress. Review the questions you missed.",
+ scoreLow: "A good start. Try the flashcards for this topic, then take the quiz again.",
+ reviewTitle: "Review",
+ yourAnswer: "Your answer: {answer}",
+ tryAnother: "Try another topic",
+ retake: "Retake this quiz",
+ },
+ flashcards: {
+ title: "Flashcards",
+ description: "Review a generated deck and rate each card. Ratings feed the SM-2 spaced-repetition schedule.",
+ formTitle: "Create a deck",
+ placeholder: "e.g. Photosynthesis, Cell biology, Data structures",
+ generate: "Generate flashcards",
+ loading: "Writing your flashcards…",
+ loadingHint: "This can take up to a minute, depending on the model.",
+ cardOf: "Card {current} of {total}",
+ tapToReveal: "Tap to show the answer",
+ answer: "Answer",
+ rateQuestion: "How well did you remember it?",
+ rateHard: "Hard",
+ rateGood: "Good",
+ rateEasy: "Easy",
+ completeTitle: "Deck reviewed",
+ completeDesc: "You reviewed {total} cards on {topic}.",
+ reviewAgain: "Review again",
+ },
+ roadmap: {
+ title: "Learning roadmap",
+ description: "A step-by-step plan for a topic at your level, with time estimates and key ideas for each step.",
+ formTitle: "Plan a roadmap",
+ placeholder: "e.g. Photosynthesis, Web development, Statistics",
+ level: "Level",
+ beginner: "Beginner",
+ intermediate: "Intermediate",
+ advanced: "Advanced",
+ generate: "Generate roadmap",
+ loading: "Planning your roadmap…",
+ loadingHint: "This can take up to a minute, depending on the model.",
+ stepsCount: "{count} steps",
+ statusCompleted: "Completed",
+ statusCurrent: "Current step",
+ statusUpcoming: "Upcoming",
+ stepLabel: "Step {number}",
+ markDone: "Mark as done",
+ allDone: "Roadmap complete. Well done.",
+ newRoadmap: "New roadmap",
+ },
+ progress: {
+ title: "Progress",
+ description: "Quizzes, flashcard decks and roadmaps you create during this visit. Nothing is saved after you close the tab.",
+ sessions: "Sessions",
+ studyTime: "Study time",
+ studyTimeValue: "{minutes} min",
+ tokens: "Tokens used",
+ weekly: "Activity in the last 7 days",
+ weeklyCaption: "Sessions per day",
+ byFeature: "Tokens by feature",
+ topics: "Topics",
+ topicLine: "{sessions} sessions · average score {score}%",
+ topicLineNoScore: "{sessions} sessions",
+ emptyTitle: "No sessions yet",
+ emptyDesc: "Generate a quiz, a flashcard deck or a roadmap and it will show up here.",
+ emptyCta: "Start a quiz",
+ featureQuiz: "Quiz",
+ featureFlashcards: "Flashcards",
+ featureRoadmap: "Roadmap",
+ featureConcepts: "Concept map",
+ featureChat: "Chat",
+ },
+ days: {
+ sun: "Sun",
+ mon: "Mon",
+ tue: "Tue",
+ wed: "Wed",
+ thu: "Thu",
+ fri: "Fri",
+ sat: "Sat",
+ },
+ concepts: {
+ title: "Concept map",
+ description: "How the main ideas of a subject connect, coloured by how well you know each one.",
+ mapTitle: "Programming fundamentals",
+ sampleNote: "This page shows fixed sample data. It is not generated from your sessions yet.",
+ legend: "Mastery",
+ strong: "Strong (80% and above)",
+ building: "Building (50–79%)",
+ weak: "Needs work (below 50%)",
+ linksTo: "Links to {list}",
+ variables: "Variables",
+ functions: "Functions",
+ dataTypes: "Data types",
+ controlFlow: "Control flow",
+ collections: "Collections",
+ oop: "Object-oriented programming",
+ },
+ chat: {
+ reasoning: "Reasoning",
+ you: "You",
+ model: "AI",
+ },
+ settings: {
+ title: "AI provider settings",
+ subtitle: "Bring your own API key to use the AI features.",
+ closeLabel: "Close settings",
+ serverMode: "This server has its own API key configured{detail}. There is nothing to set up here.",
+ provider: "Provider",
+ selfHostNote: "Self-hosted options need your own copy of LearnPath.",
+ baseUrl: "Base URL",
+ baseUrlDefault: "Leave empty to use {url}.",
+ baseUrlAny: "Any OpenAI-compatible endpoint, ending in /v1.",
+ model: "Model",
+ modelPlaceholder: "model name",
+ modelDefault: "Default for {provider}:",
+ useDefault: "use default",
+ apiKey: "API key",
+ optional: "(optional)",
+ keyPlaceholder: "Paste your key",
+ show: "Show",
+ hide: "Hide",
+ getKey: "Get an API key from {provider}",
+ privacyTitle: "Where your key goes",
+ privacyBody: "Your key is saved only in this browser. It is sent with each request through this app's API route to the provider you chose, and is never stored or logged on the server. Use a key with a spending limit, and press “Forget key” on a shared device.",
+ storageBlocked: "This browser blocked local storage, so the settings could not be saved (private mode?).",
+ keyForgotten: "Your key has been removed from this browser.",
+ forget: "Forget key",
+ },
+ errors: {
+ title: "That did not work",
+ openSettings: "Open settings",
+ unknown: "Something went wrong. Please try again.",
+ network: "Could not reach LearnPath's server. Check your connection.",
+ http_error: "The request failed. Please try again.",
+ empty_stream: "The server returned an empty stream.",
+ missing_key: "Add your API key in Settings to use this feature.",
+ invalid_provider: "That provider is not supported. Choose another one in Settings.",
+ provider_not_allowed: "This provider is only available when you run LearnPath yourself.",
+ custom_base_url_not_allowed: "This server only talks to the built-in providers. Custom base URLs are disabled.",
+ invalid_base_url: "The base URL is not valid. Check it in Settings.",
+ invalid_model: "The model name contains characters that are not allowed.",
+ invalid_key_format: "That API key does not look valid.",
+ invalid_request: "The request was not valid. Check your input and try again.",
+ server_misconfigured: "This server is not set up correctly. Contact its owner.",
+ provider_rejected_key: "The provider rejected your key. Check the API key and provider in Settings.",
+ provider_payment_required: "The provider says this account has no credit or billing set up.",
+ provider_rate_limited: "The provider is rate limiting this key or its quota is used up. Wait a moment and try again.",
+ provider_model_not_found: "The provider could not find that model. Check the model name in Settings.",
+ provider_bad_request: "The provider refused the request. The model name may be wrong for this provider.",
+ provider_unavailable: "The provider is having trouble right now. Try again later.",
+ provider_unreachable: "Could not reach the provider. Check the provider in Settings.",
+ provider_timeout: "The provider took too long to answer. Try again.",
+ unreadable_output: "The model replied, but not in a format LearnPath could read. Try again or pick a stronger model.",
+ server_error: "Something went wrong on the server. Please try again.",
+ },
+};
diff --git a/src/i18n/messages/index.ts b/src/i18n/messages/index.ts
new file mode 100644
index 0000000..6deb075
--- /dev/null
+++ b/src/i18n/messages/index.ts
@@ -0,0 +1,46 @@
+import type { Locale } from "../config";
+import { en } from "./en";
+import { ms } from "./ms";
+import type { MessageKey, Messages } from "./types";
+
+export type { MessageKey, Messages };
+
+export const MESSAGES: Record = { en, ms };
+
+export type TranslateParams = Record;
+
+/** Look up a dotted key. Returns the key itself when it does not exist (never throws). */
+export function lookup(messages: Messages, key: string): string | undefined {
+ let node: unknown = messages;
+ for (const part of key.split(".")) {
+ if (!node || typeof node !== "object") return undefined;
+ node = (node as Record)[part];
+ }
+ return typeof node === "string" ? node : undefined;
+}
+
+/** Replace {name} placeholders. Unknown placeholders are left as they are. */
+export function interpolate(template: string, params?: TranslateParams): string {
+ if (!params) return template;
+ return template.replace(/\{(\w+)\}/g, (match, name: string) =>
+ Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : match,
+ );
+}
+
+export function translate(locale: Locale, key: MessageKey, params?: TranslateParams): string {
+ const text = lookup(MESSAGES[locale], key) ?? lookup(MESSAGES.en, key) ?? key;
+ return interpolate(text, params);
+}
+
+/** Flatten nested messages into dotted keys (used by tests and tooling). */
+export function flattenMessages(node: unknown, prefix = ""): Record {
+ const out: Record = {};
+ if (node && typeof node === "object") {
+ for (const [k, v] of Object.entries(node as Record)) {
+ const key = prefix ? `${prefix}.${k}` : k;
+ if (typeof v === "string") out[key] = v;
+ else Object.assign(out, flattenMessages(v, key));
+ }
+ }
+ return out;
+}
diff --git a/src/i18n/messages/ms.ts b/src/i18n/messages/ms.ts
new file mode 100644
index 0000000..58a5035
--- /dev/null
+++ b/src/i18n/messages/ms.ts
@@ -0,0 +1,269 @@
+/**
+ * Teks antara muka Bahasa Melayu (ejaan baku Malaysia, bukan Indonesia).
+ * Mesti mempunyai kunci yang sama dengan `en.ts`.
+ */
+import type { Messages } from "./types";
+
+export const ms: Messages = {
+ meta: {
+ title: "LearnPath: kuiz, kad imbas dan peta laluan pembelajaran dengan AI",
+ description:
+ "Jana kuiz, kad imbas dan peta laluan pembelajaran untuk apa-apa topik dengan LLM yang serasi OpenAI. Guna kunci API anda sendiri. Dalam Bahasa Melayu dan bahasa Inggeris.",
+ },
+ common: {
+ appName: "LearnPath",
+ skipToContent: "Langkau ke kandungan",
+ tryAgain: "Cuba lagi",
+ newTopic: "Topik baharu",
+ close: "Tutup",
+ cancel: "Batal",
+ save: "Simpan",
+ done: "Selesai",
+ next: "Seterusnya",
+ tokens: "{count} token",
+ sampleData: "Data contoh",
+ },
+ nav: {
+ label: "Navigasi utama",
+ home: "Utama",
+ quiz: "Kuiz",
+ flashcards: "Kad imbas",
+ roadmap: "Peta laluan",
+ progress: "Kemajuan",
+ concepts: "Konsep",
+ },
+ header: {
+ homeLink: "Laman utama LearnPath",
+ settings: "Tetapan",
+ statusServer: "Kunci pelayan",
+ statusReady: "Sedia",
+ statusNeedsKey: "Masukkan kunci API",
+ language: "Bahasa",
+ themeToDark: "Tukar ke tema gelap",
+ themeToLight: "Tukar ke tema cerah",
+ },
+ language: {
+ en: "English",
+ ms: "Bahasa Melayu",
+ enShort: "EN",
+ msShort: "BM",
+ },
+ footer: {
+ source: "Kod sumber di GitHub",
+ builtBy: "Dibina oleh {name}",
+ tagline: "Buatan Malaysia",
+ },
+ home: {
+ eyebrow: "Teman belajar berkuasa AI",
+ title: "Jadikan apa-apa topik pelan belajar yang boleh anda latih.",
+ lead: "Taip satu topik. LearnPath meminta model AI pilihan anda menyediakan kuiz, set kad imbas atau peta laluan langkah demi langkah, dalam Bahasa Melayu atau bahasa Inggeris.",
+ ctaQuiz: "Mula kuiz",
+ ctaRoadmap: "Rancang peta laluan",
+ featuresTitle: "Apa yang boleh anda buat",
+ quizTitle: "Kuiz",
+ quizDesc: "Soalan aneka pilihan pada tahap kesukaran yang anda pilih, setiap satu dengan penerangan.",
+ flashcardsTitle: "Kad imbas",
+ flashcardsDesc: "Set kad untuk diulang kaji, dinilai dengan kaedah ulangan berjarak SM-2.",
+ roadmapTitle: "Peta laluan pembelajaran",
+ roadmapDesc: "Pelan langkah demi langkah dari peringkat permulaan hingga lanjutan, dengan anggaran masa.",
+ progressTitle: "Kemajuan",
+ progressDesc: "Sesi, markah dan penggunaan token sepanjang lawatan ini.",
+ conceptsTitle: "Peta konsep",
+ conceptsDesc: "Peta contoh konsep yang berkait dan tahap penguasaannya.",
+ howTitle: "Cara ia berfungsi",
+ how1Title: "Pilih topik",
+ how1Desc: "Apa sahaja, daripada Fotosintesis hingga gabungan SQL.",
+ how2Title: "AI menyediakan draf",
+ how2Desc: "Model pilihan anda menulis soalan, kad atau langkah dalam bahasa anda.",
+ how3Title: "Latih dan ulang kaji",
+ how3Desc: "Jawab, balikkan kad dan beri penilaian. Kemajuan lawatan ini dipaparkan di halaman Kemajuan.",
+ byokTitle: "Guna kunci anda sendiri",
+ byokDesc: "Guna kunci daripada OpenAI, OpenRouter, Groq, DeepSeek, Together AI, Mistral atau Xiaomi MiMo. Kunci itu kekal dalam pelayar anda. Salinan yang dihos sendiri juga boleh menggunakan Ollama, llama.cpp atau mana-mana titik akhir yang serasi OpenAI.",
+ byokCta: "Buka tetapan",
+ honestNote: "Hasil AI mungkin mengandungi kesilapan. Semak fakta penting dengan buku teks atau guru anda.",
+ },
+ topicForm: {
+ topicLabel: "Topik",
+ examplesLabel: "Cuba",
+ example1: "Fotosintesis",
+ example2: "Pecahan",
+ example3: "Sejarah Kemerdekaan Malaysia",
+ example4: "Gabungan SQL",
+ emptyTopic: "Masukkan topik dahulu.",
+ },
+ quiz: {
+ title: "Kuiz",
+ description: "Soalan aneka pilihan untuk apa-apa topik, dengan penerangan bagi setiap jawapan.",
+ formTitle: "Sediakan kuiz",
+ placeholder: "cth. Fotosintesis, Pecahan, Sistem suria",
+ difficulty: "Tahap kesukaran",
+ questionCount: "Bilangan soalan",
+ generate: "Jana kuiz",
+ loading: "Sedang menulis kuiz anda…",
+ loadingHint: "Ini boleh mengambil masa sehingga seminit, bergantung pada model.",
+ questionOf: "Soalan {current} daripada {total}",
+ easy: "Mudah",
+ medium: "Sederhana",
+ hard: "Sukar",
+ explanation: "Penerangan",
+ correct: "Betul",
+ incorrect: "Kurang tepat",
+ correctAnswerWas: "Jawapannya ialah {answer}.",
+ seeResults: "Lihat keputusan",
+ completeTitle: "Kuiz selesai",
+ scoreLine: "{correct} daripada {total} betul",
+ scoreGreat: "Syabas. Anda menguasai topik ini dengan baik.",
+ scoreGood: "Kemajuan yang baik. Ulang kaji soalan yang anda tersilap.",
+ scoreLow: "Permulaan yang baik. Cuba kad imbas untuk topik ini, kemudian jawab kuiz sekali lagi.",
+ reviewTitle: "Semakan",
+ yourAnswer: "Jawapan anda: {answer}",
+ tryAnother: "Cuba topik lain",
+ retake: "Ulang kuiz ini",
+ },
+ flashcards: {
+ title: "Kad imbas",
+ description: "Ulang kaji set kad yang dijana dan nilai setiap kad. Penilaian anda digunakan oleh jadual ulangan berjarak SM-2.",
+ formTitle: "Sediakan set kad",
+ placeholder: "cth. Fotosintesis, Biologi sel, Struktur data",
+ generate: "Jana kad imbas",
+ loading: "Sedang menulis kad imbas anda…",
+ loadingHint: "Ini boleh mengambil masa sehingga seminit, bergantung pada model.",
+ cardOf: "Kad {current} daripada {total}",
+ tapToReveal: "Ketik untuk melihat jawapan",
+ answer: "Jawapan",
+ rateQuestion: "Sejauh mana anda ingat?",
+ rateHard: "Sukar",
+ rateGood: "Baik",
+ rateEasy: "Mudah",
+ completeTitle: "Set kad selesai diulang kaji",
+ completeDesc: "Anda telah mengulang kaji {total} kad tentang {topic}.",
+ reviewAgain: "Ulang kaji semula",
+ },
+ roadmap: {
+ title: "Peta laluan pembelajaran",
+ description: "Pelan langkah demi langkah untuk satu topik mengikut tahap anda, dengan anggaran masa dan idea utama bagi setiap langkah.",
+ formTitle: "Rancang peta laluan",
+ placeholder: "cth. Fotosintesis, Pembangunan web, Statistik",
+ level: "Tahap",
+ beginner: "Permulaan",
+ intermediate: "Pertengahan",
+ advanced: "Lanjutan",
+ generate: "Jana peta laluan",
+ loading: "Sedang merancang peta laluan anda…",
+ loadingHint: "Ini boleh mengambil masa sehingga seminit, bergantung pada model.",
+ stepsCount: "{count} langkah",
+ statusCompleted: "Selesai",
+ statusCurrent: "Langkah semasa",
+ statusUpcoming: "Akan datang",
+ stepLabel: "Langkah {number}",
+ markDone: "Tandakan selesai",
+ allDone: "Peta laluan selesai. Syabas.",
+ newRoadmap: "Peta laluan baharu",
+ },
+ progress: {
+ title: "Kemajuan",
+ description: "Kuiz, set kad imbas dan peta laluan yang anda hasilkan sepanjang lawatan ini. Tiada apa-apa disimpan selepas anda menutup tab.",
+ sessions: "Sesi",
+ studyTime: "Masa belajar",
+ studyTimeValue: "{minutes} min",
+ tokens: "Token digunakan",
+ weekly: "Aktiviti 7 hari lepas",
+ weeklyCaption: "Sesi sehari",
+ byFeature: "Token mengikut ciri",
+ topics: "Topik",
+ topicLine: "{sessions} sesi · purata markah {score}%",
+ topicLineNoScore: "{sessions} sesi",
+ emptyTitle: "Belum ada sesi",
+ emptyDesc: "Jana kuiz, set kad imbas atau peta laluan dan ia akan dipaparkan di sini.",
+ emptyCta: "Mula kuiz",
+ featureQuiz: "Kuiz",
+ featureFlashcards: "Kad imbas",
+ featureRoadmap: "Peta laluan",
+ featureConcepts: "Peta konsep",
+ featureChat: "Sembang",
+ },
+ days: {
+ sun: "Ahd",
+ mon: "Isn",
+ tue: "Sel",
+ wed: "Rab",
+ thu: "Kha",
+ fri: "Jum",
+ sat: "Sab",
+ },
+ concepts: {
+ title: "Peta konsep",
+ description: "Bagaimana idea utama sesuatu subjek saling berkait, diwarnakan mengikut tahap penguasaan anda.",
+ mapTitle: "Asas pengaturcaraan",
+ sampleNote: "Halaman ini memaparkan data contoh yang tetap. Ia belum dijana daripada sesi anda.",
+ legend: "Penguasaan",
+ strong: "Kukuh (80% ke atas)",
+ building: "Sedang dibina (50–79%)",
+ weak: "Perlu diperbaiki (bawah 50%)",
+ linksTo: "Berkait dengan {list}",
+ variables: "Pemboleh ubah",
+ functions: "Fungsi",
+ dataTypes: "Jenis data",
+ controlFlow: "Aliran kawalan",
+ collections: "Koleksi",
+ oop: "Pengaturcaraan berorientasikan objek",
+ },
+ chat: {
+ reasoning: "Penaakulan",
+ you: "Anda",
+ model: "AI",
+ },
+ settings: {
+ title: "Tetapan penyedia AI",
+ subtitle: "Guna kunci API anda sendiri untuk menggunakan ciri AI.",
+ closeLabel: "Tutup tetapan",
+ serverMode: "Pelayan ini sudah mempunyai kunci API sendiri{detail}. Tiada apa-apa yang perlu disediakan di sini.",
+ provider: "Penyedia",
+ selfHostNote: "Pilihan hos sendiri memerlukan salinan LearnPath anda sendiri.",
+ baseUrl: "URL asas",
+ baseUrlDefault: "Biarkan kosong untuk menggunakan {url}.",
+ baseUrlAny: "Mana-mana titik akhir yang serasi OpenAI, berakhir dengan /v1.",
+ model: "Model",
+ modelPlaceholder: "nama model",
+ modelDefault: "Lalai untuk {provider}:",
+ useDefault: "guna lalai",
+ apiKey: "Kunci API",
+ optional: "(pilihan)",
+ keyPlaceholder: "Tampal kunci anda",
+ show: "Tunjuk",
+ hide: "Sorok",
+ getKey: "Dapatkan kunci API daripada {provider}",
+ privacyTitle: "Ke mana kunci anda pergi",
+ privacyBody: "Kunci anda disimpan dalam pelayar ini sahaja. Ia dihantar bersama setiap permintaan melalui laluan API aplikasi ini kepada penyedia yang anda pilih, dan tidak pernah disimpan atau dilog pada pelayan. Guna kunci yang mempunyai had perbelanjaan, dan tekan “Lupakan kunci” pada peranti yang dikongsi.",
+ storageBlocked: "Pelayar ini menyekat storan setempat, jadi tetapan tidak dapat disimpan (mod peribadi?).",
+ keyForgotten: "Kunci anda telah dibuang daripada pelayar ini.",
+ forget: "Lupakan kunci",
+ },
+ errors: {
+ title: "Tidak berjaya",
+ openSettings: "Buka tetapan",
+ unknown: "Sesuatu tidak kena. Sila cuba lagi.",
+ network: "Tidak dapat menghubungi pelayan LearnPath. Semak sambungan internet anda.",
+ http_error: "Permintaan gagal. Sila cuba lagi.",
+ empty_stream: "Pelayan memulangkan strim kosong.",
+ missing_key: "Masukkan kunci API anda dalam Tetapan untuk menggunakan ciri ini.",
+ invalid_provider: "Penyedia itu tidak disokong. Pilih penyedia lain dalam Tetapan.",
+ provider_not_allowed: "Penyedia ini hanya tersedia apabila anda menjalankan LearnPath sendiri.",
+ custom_base_url_not_allowed: "Pelayan ini hanya berhubung dengan penyedia terbina dalam. URL asas tersuai dimatikan.",
+ invalid_base_url: "URL asas tidak sah. Semak dalam Tetapan.",
+ invalid_model: "Nama model mengandungi aksara yang tidak dibenarkan.",
+ invalid_key_format: "Kunci API itu nampaknya tidak sah.",
+ invalid_request: "Permintaan tidak sah. Semak input anda dan cuba lagi.",
+ server_misconfigured: "Pelayan ini belum dikonfigurasi dengan betul. Hubungi pemiliknya.",
+ provider_rejected_key: "Penyedia menolak kunci anda. Semak kunci API dan penyedia dalam Tetapan.",
+ provider_payment_required: "Penyedia menyatakan akaun ini tiada kredit atau pengebilan belum disediakan.",
+ provider_rate_limited: "Penyedia mengehadkan kadar permintaan untuk kunci ini atau kuotanya sudah habis. Tunggu sebentar dan cuba lagi.",
+ provider_model_not_found: "Penyedia tidak menjumpai model itu. Semak nama model dalam Tetapan.",
+ provider_bad_request: "Penyedia menolak permintaan ini. Nama model mungkin salah untuk penyedia ini.",
+ provider_unavailable: "Penyedia sedang menghadapi masalah. Cuba lagi sebentar nanti.",
+ provider_unreachable: "Tidak dapat menghubungi penyedia. Semak penyedia dalam Tetapan.",
+ provider_timeout: "Penyedia mengambil masa terlalu lama untuk menjawab. Cuba lagi.",
+ unreadable_output: "Model telah menjawab, tetapi dalam format yang tidak dapat dibaca oleh LearnPath. Cuba lagi atau pilih model yang lebih berkebolehan.",
+ server_error: "Berlaku ralat pada pelayan. Sila cuba lagi.",
+ },
+};
diff --git a/src/i18n/messages/types.ts b/src/i18n/messages/types.ts
new file mode 100644
index 0000000..773aa80
--- /dev/null
+++ b/src/i18n/messages/types.ts
@@ -0,0 +1,13 @@
+import type { en } from "./en";
+
+/** Same shape as the English messages, with every leaf widened to `string`. */
+type Widen = { [K in keyof T]: T[K] extends string ? string : Widen };
+
+export type Messages = Widen;
+
+/** Every dotted key path that ends at a string, e.g. "quiz.generate". */
+export type MessageKey = Paths;
+
+type Paths = {
+ [K in keyof T & string]: T[K] extends string ? `${P}${K}` : Paths;
+}[keyof T & string];
diff --git a/src/i18n/server.ts b/src/i18n/server.ts
new file mode 100644
index 0000000..93f0047
--- /dev/null
+++ b/src/i18n/server.ts
@@ -0,0 +1,9 @@
+import { cookies, headers } from "next/headers";
+import { LOCALE_COOKIE, detectLocale, isLocale, parseAcceptLanguage, type Locale } from "./config";
+
+/** Locale for the first server render: the visitor's saved choice, else their browser language. */
+export async function getRequestLocale(): Promise {
+ const saved = (await cookies()).get(LOCALE_COOKIE)?.value;
+ if (isLocale(saved)) return saved;
+ return detectLocale(parseAcceptLanguage((await headers()).get("accept-language")));
+}
diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts
index 7a1583e..ef3973c 100644
--- a/src/lib/api-client.ts
+++ b/src/lib/api-client.ts
@@ -3,6 +3,8 @@
* provider settings attached as headers.
*/
import { loadSettings, settingsHeaders } from "./llm/settings";
+import { getClientLocale } from "@/i18n/locale-store";
+import type { Locale } from "@/i18n/config";
export const OPEN_SETTINGS_EVENT = "learnpath:open-settings";
@@ -35,6 +37,10 @@ export class ApiError extends Error {
}
}
+/**
+ * Open the settings panel. `reason` is an error code (e.g. "missing_key");
+ * the panel shows the matching message in the visitor's language.
+ */
export function openSettings(reason?: string): void {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent(OPEN_SETTINGS_EVENT, { detail: { reason } }));
@@ -58,16 +64,24 @@ export function apiHeaders(): Record {
return { "Content-Type": "application/json", ...settingsHeaders(loadSettings()) };
}
-export async function postJson(path: string, body: unknown, fetchImpl: typeof fetch = fetch): Promise {
+/**
+ * Request body with the UI language added, so the model answers in the
+ * language the visitor is reading. An explicit `language` in `body` wins.
+ */
+export function withLanguage(body: T, locale: Locale = getClientLocale()): T & { language: Locale } {
+ return { language: locale, ...body };
+}
+
+export async function postJson(path: string, body: object, fetchImpl: typeof fetch = fetch): Promise {
let res: Response;
try {
- res = await fetchImpl(path, { method: "POST", headers: apiHeaders(), body: JSON.stringify(body) });
+ res = await fetchImpl(path, { method: "POST", headers: apiHeaders(), body: JSON.stringify(withLanguage(body)) });
} catch {
throw new ApiError("network", "Could not reach LearnPath's server. Check your connection.", 0);
}
if (!res.ok) {
const err = await toApiError(res);
- if (err.code === "missing_key") openSettings(err.message);
+ if (err.code === "missing_key") openSettings(err.code);
throw err;
}
return (await res.json()) as T;
diff --git a/src/lib/llm/language.ts b/src/lib/llm/language.ts
new file mode 100644
index 0000000..d132a0b
--- /dev/null
+++ b/src/lib/llm/language.ts
@@ -0,0 +1,41 @@
+/**
+ * Output language for the model.
+ *
+ * Every API route reads `language` from the request body ("en" or "ms") and
+ * adds the instruction below to its prompts, so quizzes, flashcards and
+ * roadmaps come back in the language the visitor is reading.
+ *
+ * The Malay instruction asks for standard Malaysian spelling and lists common
+ * Indonesian words to avoid: models trained mostly on Indonesian web text
+ * drift into it otherwise.
+ */
+import { DEFAULT_LOCALE, isLocale, type Locale } from "@/i18n/config";
+
+export function outputLanguage(value: unknown): Locale {
+ return isLocale(value) ? value : DEFAULT_LOCALE;
+}
+
+export const LANGUAGE_NAMES: Record = {
+ en: "English",
+ ms: "Bahasa Melayu (Malaysian Malay)",
+};
+
+const INSTRUCTIONS: Record = {
+ en: "Write every piece of text meant for the learner in English.",
+ ms: [
+ "Write every piece of text meant for the learner in Bahasa Melayu as used in Malaysia, with standard Dewan Bahasa dan Pustaka spelling.",
+ "Do not use Indonesian words or spelling. For example write: kerana (not karena), hantar (not kirim), muat turun (not unduh), semasa or ketika (not saat), mesti or perlu (not harus), wang (not uang), percuma (not gratis), boleh (not bisa), berjaya (not berhasil), nombor (not nomor), cuba (not coba), sila (not silakan).",
+ "Keep standard scientific and technical terms that Malaysian textbooks use.",
+ ].join(" "),
+};
+
+/** Sentence appended to a system prompt. */
+export function languageInstruction(locale: Locale): string {
+ const keepJson = "If the answer is JSON, keep the JSON keys exactly as specified in English; only the values are written in the chosen language.";
+ return `${INSTRUCTIONS[locale]} ${keepJson}`;
+}
+
+/** Short reminder placed in the user message as well, for models that weigh system prompts lightly. */
+export function languageReminder(locale: Locale): string {
+ return `Answer language: ${LANGUAGE_NAMES[locale]}.`;
+}
diff --git a/src/lib/nav.ts b/src/lib/nav.ts
new file mode 100644
index 0000000..654ea09
--- /dev/null
+++ b/src/lib/nav.ts
@@ -0,0 +1,25 @@
+import { ChartColumn, GalleryVerticalEnd, House, ListChecks, Network, Route, type LucideIcon } from "lucide-react";
+import type { MessageKey } from "@/i18n/messages";
+
+export interface NavItem {
+ href: string;
+ label: MessageKey;
+ icon: LucideIcon;
+ /** Shown in the mobile bottom bar (space for five). */
+ mobile: boolean;
+}
+
+/** One list for the desktop header, the mobile bar and the home page. */
+export const NAV_ITEMS: readonly NavItem[] = [
+ { href: "/", label: "nav.home", icon: House, mobile: true },
+ { href: "/quiz", label: "nav.quiz", icon: ListChecks, mobile: true },
+ { href: "/flashcards", label: "nav.flashcards", icon: GalleryVerticalEnd, mobile: true },
+ { href: "/roadmap", label: "nav.roadmap", icon: Route, mobile: true },
+ { href: "/progress", label: "nav.progress", icon: ChartColumn, mobile: true },
+ { href: "/concepts", label: "nav.concepts", icon: Network, mobile: false },
+];
+
+export function isActive(pathname: string | null, href: string): boolean {
+ if (!pathname) return false;
+ return href === "/" ? pathname === "/" : pathname === href || pathname.startsWith(`${href}/`);
+}
diff --git a/src/lib/theme.ts b/src/lib/theme.ts
new file mode 100644
index 0000000..07589e4
--- /dev/null
+++ b/src/lib/theme.ts
@@ -0,0 +1,78 @@
+/**
+ * Light/dark theme.
+ *
+ * With nothing stored the page follows `prefers-color-scheme` (pure CSS).
+ * A stored choice sets `data-theme` on ; the inline script below applies
+ * it before first paint so there is no flash. Choosing the theme the system
+ * already prefers clears the stored value, so the page follows the system again.
+ */
+export type Theme = "light" | "dark";
+
+export const THEME_STORAGE_KEY = "learnpath.theme.v1";
+const CHANGED_EVENT = "learnpath:theme-changed";
+
+/** Runs in before the body renders. Kept tiny and dependency-free. */
+export const THEME_INIT_SCRIPT = `(function(){try{var t=localStorage.getItem(${JSON.stringify(
+ THEME_STORAGE_KEY,
+)});if(t==="light"||t==="dark")document.documentElement.setAttribute("data-theme",t);}catch(e){}})();`;
+
+function systemTheme(): Theme {
+ try {
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
+ } catch {
+ return "light";
+ }
+}
+
+/** The theme currently on screen. */
+export function getEffectiveTheme(): Theme {
+ const attr = document.documentElement.getAttribute("data-theme");
+ return attr === "dark" || attr === "light" ? attr : systemTheme();
+}
+
+/**
+ * Apply a colour change in one frame: without this, elements with a colour
+ * transition (buttons, tabs) fade on their own schedule and the page is
+ * briefly half light, half dark.
+ */
+function withoutTransitions(apply: () => void): void {
+ const style = document.createElement("style");
+ style.textContent = "*,*::before,*::after{transition:none!important}";
+ document.head.appendChild(style);
+ apply();
+ void window.getComputedStyle(document.body).backgroundColor; // flush styles while transitions are off
+ window.requestAnimationFrame(() => window.requestAnimationFrame(() => style.remove()));
+}
+
+export function setTheme(theme: Theme): void {
+ withoutTransitions(() => applyTheme(theme));
+ window.dispatchEvent(new Event(CHANGED_EVENT));
+}
+
+function applyTheme(theme: Theme): void {
+ const root = document.documentElement;
+ const followSystem = theme === systemTheme();
+ try {
+ if (followSystem) window.localStorage.removeItem(THEME_STORAGE_KEY);
+ else window.localStorage.setItem(THEME_STORAGE_KEY, theme);
+ } catch {
+ // storage blocked: the choice still applies to this page
+ }
+ if (followSystem) root.removeAttribute("data-theme");
+ else root.setAttribute("data-theme", theme);
+}
+
+export function subscribeTheme(onChange: () => void): () => void {
+ window.addEventListener(CHANGED_EVENT, onChange);
+ let media: MediaQueryList | null = null;
+ try {
+ media = window.matchMedia("(prefers-color-scheme: dark)");
+ media.addEventListener("change", onChange);
+ } catch {
+ media = null;
+ }
+ return () => {
+ window.removeEventListener(CHANGED_EVENT, onChange);
+ media?.removeEventListener("change", onChange);
+ };
+}
diff --git a/tests/api/routes.test.ts b/tests/api/routes.test.ts
index debc56c..7a97bd6 100644
--- a/tests/api/routes.test.ts
+++ b/tests/api/routes.test.ts
@@ -186,3 +186,65 @@ describe("GET /api/llm-config", () => {
expect(text).not.toContain(ENV_KEY);
});
});
+
+describe("output language reaches the model", () => {
+ const h = { "x-llm-key": HDR_KEY };
+
+ function sentMessages(): { role: string; content: string }[] {
+ return JSON.parse(fetchMock.mock.calls[0][1].body).messages;
+ }
+
+ const cases: [string, (r: Request) => Promise, Record, string][] = [
+ ["quiz", quiz, { topic: "Fotosintesis" }, quizJson],
+ ["flashcards", flashcards, { topic: "Fotosintesis" }, '[{"front":"Q","back":"A"}]'],
+ ["roadmap", roadmap, { topic: "Fotosintesis" }, '{"title":"T","steps":[{"title":"S"}]}'],
+ ["chat", chat, { message: "Apa itu klorofil?" }, "ok"],
+ ["analyze", analyze, { topic: "Fotosintesis", sessions: [] }, "ok"],
+ ];
+
+ for (const [name, route, body, reply] of cases) {
+ it(`${name}: language "ms" asks for Malaysian Malay and warns against Indonesian`, async () => {
+ fetchMock.mockResolvedValue(completion(reply));
+ const res = await route(req({ ...body, language: "ms" }, h));
+ expect(res.status).toBe(200);
+ const messages = sentMessages();
+ const system = messages.find((m) => m.role === "system")?.content ?? "";
+ expect(system).toContain("Bahasa Melayu as used in Malaysia");
+ expect(system).toContain("Do not use Indonesian");
+ expect(system).toContain("kerana (not karena)");
+ expect(system).not.toContain("in English.");
+ });
+
+ it(`${name}: language "en" (and the default) asks for English`, async () => {
+ fetchMock.mockResolvedValue(completion(reply));
+ await route(req({ ...body, language: "en" }, h));
+ const system = sentMessages().find((m) => m.role === "system")?.content ?? "";
+ expect(system).toContain("meant for the learner in English");
+ expect(system).not.toContain("Bahasa Melayu");
+
+ fetchMock.mockClear();
+ fetchMock.mockResolvedValue(completion(reply));
+ await route(req({ ...body, language: "xx-unknown" }, h));
+ expect(sentMessages().find((m) => m.role === "system")?.content).toContain("in English");
+ });
+ }
+
+ it("repeats the language in the user message for generator routes", async () => {
+ await quiz(req({ topic: "Fotosintesis", language: "ms" }, h));
+ expect(sentMessages().find((m) => m.role === "user")?.content).toContain("Answer language: Bahasa Melayu (Malaysian Malay).");
+ });
+
+ it("chat keeps the caller's system prompt after the language instruction", async () => {
+ fetchMock.mockResolvedValue(completion("ok"));
+ await chat(req({ message: "hai", systemPrompt: "You are a tutor.", language: "ms" }, h));
+ const system = sentMessages()[0];
+ expect(system.role).toBe("system");
+ expect(system.content.indexOf("Bahasa Melayu")).toBeLessThan(system.content.indexOf("You are a tutor."));
+ });
+
+ it("roadmap's format example uses Malay time units in BM", async () => {
+ fetchMock.mockResolvedValue(completion('{"title":"T","steps":[{"title":"S"}]}'));
+ await roadmap(req({ topic: "Fotosintesis", language: "ms" }, h));
+ expect(sentMessages().find((m) => m.role === "user")?.content).toContain('"estimatedTime":"2 jam"');
+ });
+});
diff --git a/tests/components/ProgressChart.test.tsx b/tests/components/ProgressChart.test.tsx
index 398fee7..03b2241 100644
--- a/tests/components/ProgressChart.test.tsx
+++ b/tests/components/ProgressChart.test.tsx
@@ -5,13 +5,18 @@ import { ProgressChart } from "@/components/ProgressChart";
describe("ProgressChart", () => {
it("renders title", () => {
render( );
- expect(screen.getByText("Weekly")).toBeInTheDocument();
+ expect(screen.getByRole("heading", { name: "Weekly" })).toBeInTheDocument();
});
it("renders data labels", () => {
render( );
- expect(screen.getByText("Mon")).toBeInTheDocument();
- expect(screen.getByText("Tue")).toBeInTheDocument();
+ expect(screen.getAllByText("Mon").length).toBeGreaterThan(0);
+ expect(screen.getAllByText("Tue").length).toBeGreaterThan(0);
+ });
+
+ it("has an accessible table with the exact values", () => {
+ render( );
+ expect(screen.getByRole("table")).toHaveTextContent("Mon3");
});
it("renders values", () => {
diff --git a/tests/components/QuizCard.test.tsx b/tests/components/QuizCard.test.tsx
index f0b77dc..1b4bab3 100644
--- a/tests/components/QuizCard.test.tsx
+++ b/tests/components/QuizCard.test.tsx
@@ -31,6 +31,12 @@ describe("QuizCard", () => {
expect(onAnswer).toHaveBeenCalledWith(1);
});
+ it("marks a wrong answer and names the correct letter", () => {
+ render( );
+ expect(screen.getByText("Not quite")).toBeInTheDocument();
+ expect(screen.getByText(/The answer is B\./)).toBeInTheDocument();
+ });
+
it("shows explanation when showResult", () => {
render( );
expect(screen.getByText(/Basic math/)).toBeInTheDocument();
@@ -38,6 +44,6 @@ describe("QuizCard", () => {
it("shows difficulty badge", () => {
render( );
- expect(screen.getByText("easy")).toBeInTheDocument();
+ expect(screen.getByText("Easy")).toBeInTheDocument();
});
});
diff --git a/tests/components/RoadmapNode.test.tsx b/tests/components/RoadmapNode.test.tsx
index 7c7629e..9139925 100644
--- a/tests/components/RoadmapNode.test.tsx
+++ b/tests/components/RoadmapNode.test.tsx
@@ -1,5 +1,5 @@
-import { describe, it, expect } from "vitest";
-import { render, screen } from "@testing-library/react";
+import { describe, it, expect, vi } from "vitest";
+import { fireEvent, render, screen } from "@testing-library/react";
import { RoadmapNode } from "@/components/RoadmapNode";
describe("RoadmapNode", () => {
@@ -28,8 +28,25 @@ describe("RoadmapNode", () => {
expect(screen.getByText(/2 hours/)).toBeInTheDocument();
});
- it("shows checkmark for completed", () => {
+ it("announces the completed state instead of the step number", () => {
render( );
- expect(screen.getByText("✓")).toBeInTheDocument();
+ expect(screen.getByText("Step 1, Completed")).toBeInTheDocument();
+ expect(screen.queryByText("1")).not.toBeInTheDocument();
+ });
+
+ it("offers 'Mark as done' only on the current step", () => {
+ const onComplete = vi.fn();
+ const { rerender } = render( );
+ fireEvent.click(screen.getByRole("button", { name: "Mark as done" }));
+ expect(onComplete).toHaveBeenCalled();
+ rerender( );
+ expect(screen.queryByRole("button", { name: "Mark as done" })).not.toBeInTheDocument();
+ });
+
+ it("gives upcoming steps readable (non-white) number text", () => {
+ render( );
+ const badge = screen.getByText("3");
+ expect(badge.className).toContain("text-ink-muted");
+ expect(badge.className).not.toContain("text-white");
});
});
diff --git a/tests/design/contrast.test.ts b/tests/design/contrast.test.ts
new file mode 100644
index 0000000..f9dc1a5
--- /dev/null
+++ b/tests/design/contrast.test.ts
@@ -0,0 +1,65 @@
+// @vitest-environment node
+/**
+ * WCAG AA contrast for the design tokens, computed from globals.css itself so
+ * a colour change that breaks contrast fails CI.
+ */
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import path from "node:path";
+
+const css = readFileSync(path.resolve(__dirname, "../../src/app/globals.css"), "utf8");
+
+function tokens(selector: RegExp): Record {
+ const block = css.match(selector)?.[1] ?? "";
+ return Object.fromEntries([...block.matchAll(/--([\w-]+):\s*(#[0-9a-fA-F]{6})/g)].map((m) => [m[1], m[2].toLowerCase()]));
+}
+
+const light = tokens(/:root\s*\{([^}]*)\}/);
+const darkMedia = tokens(/:root:not\(\[data-theme="light"\]\)\s*\{([^}]*)\}/);
+const darkToggle = tokens(/:root\[data-theme="dark"\]\s*\{([^}]*)\}/);
+
+function luminance(hex: string): number {
+ const [r, g, b] = [1, 3, 5].map((i) => {
+ const c = parseInt(hex.slice(i, i + 2), 16) / 255;
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
+ });
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+}
+
+export function contrast(a: string, b: string): number {
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
+ return (hi + 0.05) / (lo + 0.05);
+}
+
+// [text token, background token, minimum ratio]
+const TEXT_PAIRS: [string, string, number][] = [
+ ["ink", "canvas", 4.5], ["ink", "surface", 4.5], ["ink", "surface-2", 4.5], ["ink", "surface-raised", 4.5],
+ ["ink-muted", "canvas", 4.5], ["ink-muted", "surface", 4.5], ["ink-muted", "surface-2", 4.5],
+ ["ink-subtle", "canvas", 4.5], ["ink-subtle", "surface", 4.5], ["ink-subtle", "surface-2", 4.5],
+ ["on-brand", "brand", 4.5], ["on-brand", "brand-hover", 4.5], ["on-brand", "success", 4.5],
+ ["brand-ink", "brand-soft", 4.5], ["brand-ink", "surface", 4.5], ["brand-ink", "canvas", 4.5],
+ ["accent-ink", "accent-soft", 4.5], ["success-ink", "success-soft", 4.5], ["success-ink", "surface", 4.5],
+ ["danger-ink", "danger-soft", 4.5], ["danger-ink", "surface", 4.5], ["warning-ink", "warning-soft", 4.5],
+ ["canvas", "ink", 4.5],
+];
+// Non-text UI (WCAG 1.4.11): input borders, focus ring.
+const UI_PAIRS: [string, string, number][] = [
+ ["line-strong", "surface", 3], ["focus", "surface", 3], ["focus", "canvas", 3], ["brand", "surface", 3],
+];
+
+describe("design tokens", () => {
+ it("define the dark theme identically for the system setting and the toggle", () => {
+ expect(Object.keys(darkMedia).length).toBeGreaterThan(20);
+ expect(darkMedia).toEqual(darkToggle);
+ });
+
+ for (const [name, t] of [["light", light], ["dark", darkToggle]] as const) {
+ for (const [fg, bg, min] of [...TEXT_PAIRS, ...UI_PAIRS]) {
+ it(`${name}: ${fg} on ${bg} ≥ ${min}:1`, () => {
+ expect(t[fg], fg).toBeDefined();
+ expect(t[bg], bg).toBeDefined();
+ expect(contrast(t[fg], t[bg])).toBeGreaterThanOrEqual(min);
+ });
+ }
+ }
+});
diff --git a/tests/i18n/locale.test.ts b/tests/i18n/locale.test.ts
new file mode 100644
index 0000000..d271ae3
--- /dev/null
+++ b/tests/i18n/locale.test.ts
@@ -0,0 +1,69 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { LOCALE_STORAGE_KEY, detectLocale, parseAcceptLanguage } from "@/i18n/config";
+import { getClientLocale, resetLocaleMemory, setClientLocale } from "@/i18n/locale-store";
+
+function setLanguages(langs: string[]) {
+ vi.spyOn(navigator, "languages", "get").mockReturnValue(langs);
+}
+
+describe("detectLocale", () => {
+ it("maps Malay tags to BM and everything else to English", () => {
+ expect(detectLocale(["ms-MY", "en"])).toBe("ms");
+ expect(detectLocale(["ms"])).toBe("ms");
+ expect(detectLocale(["en-GB", "ms"])).toBe("en");
+ expect(detectLocale(["zh-CN", "ms-MY"])).toBe("ms");
+ expect(detectLocale(["id-ID"])).toBe("en");
+ expect(detectLocale([])).toBe("en");
+ });
+
+ it("reads Accept-Language by q-value", () => {
+ expect(parseAcceptLanguage("en;q=0.5, ms-MY;q=0.9, zh")).toEqual(["zh", "ms-MY", "en"]);
+ expect(detectLocale(parseAcceptLanguage("en-US;q=0.4,ms;q=0.8"))).toBe("ms");
+ expect(parseAcceptLanguage(null)).toEqual([]);
+ });
+});
+
+describe("client locale store", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ resetLocaleMemory();
+ document.cookie = "learnpath-locale=; max-age=0; path=/";
+ });
+ afterEach(() => vi.restoreAllMocks());
+
+ it("defaults to the browser language (ms → BM)", () => {
+ setLanguages(["ms-MY", "en-US"]);
+ expect(getClientLocale()).toBe("ms");
+ });
+
+ it("defaults to English for other browser languages", () => {
+ setLanguages(["fr-FR"]);
+ expect(getClientLocale()).toBe("en");
+ });
+
+ it("persists the choice in localStorage and mirrors it to a cookie", () => {
+ setLanguages(["en-US"]);
+ expect(setClientLocale("ms")).toBe(true);
+ expect(localStorage.getItem(LOCALE_STORAGE_KEY)).toBe("ms");
+ expect(document.cookie).toContain("learnpath-locale=ms");
+ resetLocaleMemory();
+ expect(getClientLocale()).toBe("ms");
+ });
+
+ it("still switches when localStorage throws (private mode)", () => {
+ vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
+ throw new Error("QuotaExceededError");
+ });
+ vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
+ throw new Error("SecurityError");
+ });
+ expect(setClientLocale("ms")).toBe(false);
+ expect(getClientLocale()).toBe("ms");
+ });
+
+ it("ignores garbage in storage", () => {
+ setLanguages(["en"]);
+ localStorage.setItem(LOCALE_STORAGE_KEY, "klingon");
+ expect(getClientLocale()).toBe("en");
+ });
+});
diff --git a/tests/i18n/messages.test.ts b/tests/i18n/messages.test.ts
new file mode 100644
index 0000000..b639ee9
--- /dev/null
+++ b/tests/i18n/messages.test.ts
@@ -0,0 +1,84 @@
+import { describe, it, expect } from "vitest";
+import { MESSAGES, flattenMessages, interpolate, translate } from "@/i18n/messages";
+import { LOCALES } from "@/i18n/config";
+
+const flat = Object.fromEntries(LOCALES.map((l) => [l, flattenMessages(MESSAGES[l])])) as Record<
+ (typeof LOCALES)[number],
+ Record
+>;
+
+const placeholders = (s: string) => [...s.matchAll(/\{(\w+)\}/g)].map((m) => m[1]).sort();
+
+describe("language files", () => {
+ it("contain a meaningful number of keys", () => {
+ expect(Object.keys(flat.en).length).toBeGreaterThan(100);
+ });
+
+ for (const locale of LOCALES) {
+ for (const other of LOCALES) {
+ if (locale === other) continue;
+ it(`every ${locale} key exists in ${other}`, () => {
+ const missing = Object.keys(flat[locale]).filter((k) => !(k in flat[other]));
+ expect(missing).toEqual([]);
+ });
+ }
+
+ it(`${locale} has no empty strings`, () => {
+ const empty = Object.entries(flat[locale]).filter(([, v]) => !v.trim());
+ expect(empty).toEqual([]);
+ });
+ }
+
+ it("uses the same {placeholders} in both languages", () => {
+ const mismatched = Object.keys(flat.en).filter(
+ (k) => JSON.stringify(placeholders(flat.en[k])) !== JSON.stringify(placeholders(flat.ms[k] ?? "")),
+ );
+ expect(mismatched).toEqual([]);
+ });
+
+ it("BM text avoids common Indonesian words", () => {
+ // Malaysian standard alternatives: hantar, muat turun, ketika/semasa, kerana, mesti/perlu,
+ // wang, percuma, sila, berjaya, butang, tetapan, kata laluan, boleh, nombor, cuba.
+ const indonesian = [
+ "kirim", "unduh", "saat", "karena", "harus", "uang", "gratis", "silakan", "berhasil",
+ "tombol", "pengaturan", "sandi", "bisa", "nomor", "coba", "mencoba", "kamu", "aplikasinya",
+ ];
+ const hits: string[] = [];
+ for (const [key, text] of Object.entries(flat.ms)) {
+ for (const word of indonesian) {
+ if (new RegExp(`(^|[^\\p{L}])${word}([^\\p{L}]|$)`, "iu").test(text)) hits.push(`${key}: ${word}`);
+ }
+ }
+ expect(hits).toEqual([]);
+ });
+
+ it("BM strings are actually translated (not copied from English)", () => {
+ // Brand names, abbreviations and pure numbers may be identical.
+ const allowedSame = new Set(["common.appName", "language.en", "language.ms", "language.enShort", "language.msShort", "chat.model", "settings.model", "progress.studyTimeValue"]);
+ const same = Object.keys(flat.en).filter((k) => flat.en[k] === flat.ms[k] && !allowedSame.has(k));
+ expect(same).toEqual([]);
+ });
+});
+
+describe("translate()", () => {
+ it("returns the string for the locale and fills placeholders", () => {
+ expect(translate("en", "quiz.questionOf", { current: 2, total: 5 })).toBe("Question 2 of 5");
+ expect(translate("ms", "quiz.questionOf", { current: 2, total: 5 })).toBe("Soalan 2 daripada 5");
+ });
+
+ it("uses the agreed Malaysian terms", () => {
+ expect(translate("ms", "nav.flashcards")).toBe("Kad imbas");
+ expect(translate("ms", "nav.quiz")).toBe("Kuiz");
+ expect(translate("ms", "home.roadmapTitle")).toBe("Peta laluan pembelajaran");
+ expect(translate("ms", "header.settings")).toBe("Tetapan");
+ expect(translate("ms", "settings.apiKey")).toBe("Kunci API");
+ });
+
+ it("falls back to the key for an unknown key instead of throwing", () => {
+ expect(translate("ms", "nope.missing" as never)).toBe("nope.missing");
+ });
+
+ it("leaves unknown placeholders untouched", () => {
+ expect(interpolate("Hi {name} {other}", { name: "Aina" })).toBe("Hi Aina {other}");
+ });
+});
diff --git a/tests/i18n/no-hardcoded-text.test.ts b/tests/i18n/no-hardcoded-text.test.ts
new file mode 100644
index 0000000..c41a110
--- /dev/null
+++ b/tests/i18n/no-hardcoded-text.test.ts
@@ -0,0 +1,37 @@
+// @vitest-environment node
+/**
+ * Guard against UI text typed straight into components: JSX text nodes in
+ * src/app and src/components must come from the language files.
+ */
+import { describe, it, expect } from "vitest";
+import { readFileSync, readdirSync, statSync } from "node:fs";
+import path from "node:path";
+
+const ROOT = path.resolve(__dirname, "../../src");
+// Proper nouns that are the same in every language.
+const ALLOWED = new Set(["Sasa Teguh", "LearnPath"]);
+
+function files(dir: string): string[] {
+ return readdirSync(dir).flatMap((name) => {
+ const full = path.join(dir, name);
+ if (statSync(full).isDirectory()) return files(full);
+ return full.endsWith(".tsx") ? [full] : [];
+ });
+}
+
+describe("components", () => {
+ it("contain no hard-coded user-visible words in JSX text", () => {
+ const offenders: string[] = [];
+ for (const file of [...files(path.join(ROOT, "app")), ...files(path.join(ROOT, "components"))]) {
+ const source = readFileSync(file, "utf8").replace(/\{\/\*[\s\S]*?\*\/\}/g, "");
+ for (const match of source.matchAll(/>([^<>{}]*[A-Za-z]{2,}[^<>{}]*)/.test(text)) continue;
+ // Skip TypeScript generics and comparisons that look like JSX text.
+ if (/[;=]|&&|\|\|/.test(text)) continue;
+ offenders.push(`${path.relative(ROOT, file)}: "${text}"`);
+ }
+ }
+ expect(offenders).toEqual([]);
+ });
+});
diff --git a/tests/lib/api-client.test.ts b/tests/lib/api-client.test.ts
new file mode 100644
index 0000000..422b812
--- /dev/null
+++ b/tests/lib/api-client.test.ts
@@ -0,0 +1,30 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { postJson, withLanguage } from "@/lib/api-client";
+import { resetLocaleMemory, setClientLocale } from "@/i18n/locale-store";
+
+function okFetch() {
+ return vi.fn().mockImplementation(async () => new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } }));
+}
+
+describe("API client language", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ resetLocaleMemory();
+ });
+ afterEach(() => vi.restoreAllMocks());
+
+ it("sends the selected UI language with every request", async () => {
+ setClientLocale("ms");
+ const fetchImpl = okFetch();
+ await postJson("/api/quiz", { topic: "Fotosintesis" }, fetchImpl);
+ expect(JSON.parse(fetchImpl.mock.calls[0][1].body)).toEqual({ topic: "Fotosintesis", language: "ms" });
+
+ setClientLocale("en");
+ await postJson("/api/roadmap", { topic: "Photosynthesis" }, fetchImpl);
+ expect(JSON.parse(fetchImpl.mock.calls[1][1].body).language).toBe("en");
+ });
+
+ it("lets an explicit language in the body win", () => {
+ expect(withLanguage({ language: "en" }, "ms")).toEqual({ language: "en" });
+ });
+});