diff --git a/README.md b/README.md index f9b49cf..9f9b892 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Adaptive learning with any OpenAI-compatible LLM** -> AI-generated quizzes, flashcards and learning roadmaps, with spaced repetition and a simple progress view. +> AI-generated quizzes, flashcards and learning roadmaps, with spaced repetition and a simple progress view. In English and Bahasa Melayu. [![CI](https://github.com/aimanmalib/learnpath/actions/workflows/ci.yml/badge.svg)](https://github.com/aimanmalib/learnpath/actions/workflows/ci.yml) [![Next.js 16](https://img.shields.io/badge/Next.js-16-black.svg)](https://nextjs.org/) @@ -24,14 +24,22 @@ Every AI feature goes through one provider layer (`src/lib/llm/`) that speaks th | **Learning Roadmap** | Builds a step-by-step plan for a topic at beginner / intermediate / advanced level | | **Progress Tracker** | Shows the sessions, scores and token usage from your current visit (kept in memory, not saved) | | **Concept Map** | A static sample map of connected concepts (not generated by the LLM yet) | +| **Bahasa Melayu + English** | Every screen in BM or English (switch in the top bar). The AI answers in the language you are reading | +| **Light and dark themes** | Follows your device setting; the moon/sun button overrides it | -There are also two API-only routes with no page yet: `POST /api/chat` (single message, optional streaming) and `POST /api/analyze` (study-session analysis). +### Languages + +- All UI text lives in `src/i18n/messages/en.ts` and `ms.ts`. A test fails if a key exists in one language but not the other, if placeholders differ, or if the BM file contains common Indonesian words (`karena`, `harus`, `kirim`, ...). +- The first visit uses your browser language (`ms`/`ms-MY` → BM, anything else → English). Your choice is saved in `localStorage` and mirrored to a cookie so the server renders the page in the same language. +- Every API call sends `language: "en" | "ms"`. The server adds an instruction to the prompt to answer in that language; for BM it asks for standard Malaysian spelling (Dewan Bahasa dan Pustaka) and names Indonesian words to avoid. How well the model follows it depends on the model you choose. + +There are also two API-only routes with no page yet: `POST /api/chat` (single message, optional streaming) and `POST /api/analyze` (study-session analysis). Both accept the same optional `language` field. ## Bring your own key (how the demo works) The public demo has no server-side key. Each visitor uses their own: -1. Click **Settings** in the top bar. +1. Click **Settings** (**Tetapan** in BM, the key icon on phones) in the top bar. 2. Pick a provider, keep the suggested model or type another, and paste your API key. 3. Press **Save**. **Forget key** removes it again. @@ -114,14 +122,15 @@ npm run build # production build ## Architecture ``` -Browser (Next.js pages) +Browser (Next.js pages, UI text from src/i18n/messages/{en,ms}.ts) │ Settings panel → localStorage (provider, model, key) - │ fetch /api/* with x-llm-provider / x-llm-model / x-llm-key + │ fetch /api/* with x-llm-provider / x-llm-model / x-llm-key, body includes language: "en" | "ms" ▼ API routes /api/quiz · /api/flashcards · /api/roadmap · /api/chat · /api/analyze - │ src/lib/llm/server.ts resolve: server env key → else request headers → else 400 - │ allowlist of provider base URLs, max_tokens cap - │ src/lib/llm/client.ts POST {baseUrl}/chat/completions, friendly upstream errors + │ src/lib/llm/server.ts resolve: server env key → else request headers → else 400 + │ allowlist of provider base URLs, max_tokens cap + │ src/lib/llm/language.ts "answer in English / Bahasa Melayu (Malaysia)" prompt instruction + │ src/lib/llm/client.ts POST {baseUrl}/chat/completions, friendly upstream errors ▼ OpenAI-compatible provider (OpenAI, OpenRouter, Groq, DeepSeek, Together, Mistral, MiMo, Ollama, …) ``` @@ -129,7 +138,8 @@ OpenAI-compatible provider (OpenAI, OpenRouter, Groq, DeepSeek, Together, Mistra ## Tech stack - Next.js 16 (App Router), React 19, TypeScript -- Tailwind CSS 4 +- Tailwind CSS 4 with colour tokens (light + dark) in `src/app/globals.css`; a test checks WCAG AA contrast for every text/background pair +- lucide-react icons, Inter + Fraunces (self-hosted by `next/font`) - Zustand (state) - SM-2 spaced repetition - Vitest + Testing Library diff --git a/package-lock.json b/package-lock.json index 2892026..e847d49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.1.0", "dependencies": { "clsx": "^2.1.1", + "lucide-react": "1.52.0", "next": "^16.4.2", "react": "^19.3.1", "react-dom": "^19.3.1", @@ -6269,6 +6270,21 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.52.0.tgz", + "integrity": "sha512-TUgPtl5ZI9WgxOcbu4ckaC5B3l1qxPQrCMgb6OfUB9owx/OvSJfcf93SMgUWAxRw5/1XDfh9uI5F5iNGXCcGDQ==", + "license": "ISC", + "peerDependencies": { + "@types/react": "*", + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", diff --git a/package.json b/package.json index 88a1b65..fd84816 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "clsx": "^2.1.1", + "lucide-react": "1.52.0", "next": "^16.4.2", "react": "^19.3.1", "react-dom": "^19.3.1", diff --git a/src/app/api/analyze/route.ts b/src/app/api/analyze/route.ts index 6dbd623..005119f 100644 --- a/src/app/api/analyze/route.ts +++ b/src/app/api/analyze/route.ts @@ -1,15 +1,16 @@ import { NextResponse } from "next/server"; import { chatCompletion } from "@/lib/llm/client"; import { handleLlmRoute, textField } from "@/lib/llm/server"; +import { languageInstruction, languageReminder, outputLanguage } from "@/lib/llm/language"; export async function POST(request: Request) { return handleLlmRoute(request, async ({ llm, body }) => { const topic = textField(body.topic, 200, "general"); const sessions = Array.isArray(body.sessions) ? body.sessions.slice(-20) : []; const sessionSummary = JSON.stringify(sessions).slice(0, 8000); + const language = outputLanguage(body.language); - const systemPrompt = - "You are a learning analytics expert. Analyze study session data and provide insights and recommendations for improvement. Be specific and actionable."; + const systemPrompt = `You are a learning analytics expert. Analyze study session data and provide insights and recommendations for improvement. Be specific and actionable. ${languageInstruction(language)}`; const result = await chatCompletion( llm, @@ -17,7 +18,7 @@ export async function POST(request: Request) { { role: "system", content: systemPrompt }, { role: "user", - content: `Analyze these study sessions for topic ${JSON.stringify(topic)}: ${sessionSummary}. Provide: 1) Performance summary 2) Weak areas 3) Recommended next steps`, + content: `Analyze these study sessions for topic ${JSON.stringify(topic)}: ${sessionSummary}. Provide: 1) Performance summary 2) Weak areas 3) Recommended next steps. ${languageReminder(language)}`, }, ], { temperature: 0.5, maxTokens: 2048 }, diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 946537b..5eb1f49 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -2,16 +2,21 @@ import { NextResponse } from "next/server"; import { chatCompletion, chatCompletionStream, type ChatMessage } from "@/lib/llm/client"; import { handleLlmRoute, textField } from "@/lib/llm/server"; import { LlmError } from "@/lib/llm/errors"; +import { languageInstruction, outputLanguage } from "@/lib/llm/language"; export async function POST(request: Request) { return handleLlmRoute(request, async ({ llm, body }) => { const message = textField(body.message, 8000); if (!message) throw new LlmError("invalid_request", "Message is empty.", 400); const systemPrompt = textField(body.systemPrompt, 4000); + const language = outputLanguage(body.language); - const messages: ChatMessage[] = []; - if (systemPrompt) messages.push({ role: "system", content: systemPrompt }); - messages.push({ role: "user", content: message }); + // The language instruction always goes first; a caller's system prompt follows it. + const system = [languageInstruction(language), systemPrompt].filter(Boolean).join("\n\n"); + const messages: ChatMessage[] = [ + { role: "system", content: system }, + { role: "user", content: message }, + ]; if (body.stream === true) { const stream = await chatCompletionStream(llm, messages, { temperature: 0.7, maxTokens: 2048, timeoutMs: 120_000 }); diff --git a/src/app/api/flashcards/route.ts b/src/app/api/flashcards/route.ts index 850361c..54e1518 100644 --- a/src/app/api/flashcards/route.ts +++ b/src/app/api/flashcards/route.ts @@ -2,15 +2,17 @@ import { NextResponse } from "next/server"; import { chatCompletion } from "@/lib/llm/client"; import { extractJson, handleLlmRoute, intField, textField, unreadable } from "@/lib/llm/server"; import { LlmError } from "@/lib/llm/errors"; +import { languageInstruction, languageReminder, outputLanguage } from "@/lib/llm/language"; export async function POST(request: Request) { return handleLlmRoute(request, async ({ llm, body }) => { const topic = textField(body.topic, 200); if (!topic) throw new LlmError("invalid_request", "Enter a topic first.", 400); const count = intField(body.count, 1, 20, 10); + const language = outputLanguage(body.language); - const systemPrompt = `Generate ${count} study flashcards about the user's topic. Return ONLY a valid JSON array, no prose.`; - const userPrompt = `Topic: ${JSON.stringify(topic)}. Format: [{"id":"fc1","front":"question or concept","back":"answer or definition"}]`; + const systemPrompt = `Generate ${count} study flashcards about the user's topic. Return ONLY a valid JSON array, no prose. ${languageInstruction(language)}`; + const userPrompt = `Topic: ${JSON.stringify(topic)}. Format: [{"id":"fc1","front":"question or concept","back":"answer or definition"}]. ${languageReminder(language)}`; const result = await chatCompletion( llm, diff --git a/src/app/api/quiz/route.ts b/src/app/api/quiz/route.ts index 996d6ce..5e1a103 100644 --- a/src/app/api/quiz/route.ts +++ b/src/app/api/quiz/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { chatCompletion } from "@/lib/llm/client"; import { extractJson, handleLlmRoute, intField, oneOf, textField, unreadable } from "@/lib/llm/server"; import { LlmError } from "@/lib/llm/errors"; +import { languageInstruction, languageReminder, outputLanguage } from "@/lib/llm/language"; const DIFFICULTIES = ["easy", "medium", "hard"] as const; @@ -21,9 +22,10 @@ export async function POST(request: Request) { if (!topic) throw new LlmError("invalid_request", "Enter a topic first.", 400); const count = intField(body.count, 1, 10, 5); const difficulty = oneOf(body.difficulty, DIFFICULTIES, "medium"); + const language = outputLanguage(body.language); - const systemPrompt = `You are LearnPath's quiz generator. Create ${count} multiple-choice questions about the user's topic at ${difficulty} difficulty. Each question must have exactly 4 options with one correct answer. Return ONLY a valid JSON array, no prose.`; - const userPrompt = `Topic: ${JSON.stringify(topic)}. Format: [{"id":"q1","question":"...","options":["A","B","C","D"],"correctIndex":0,"explanation":"..."}]`; + const systemPrompt = `You are LearnPath's quiz generator. Create ${count} multiple-choice questions about the user's topic at ${difficulty} difficulty. Each question must have exactly 4 options with one correct answer. Return ONLY a valid JSON array, no prose. ${languageInstruction(language)}`; + const userPrompt = `Topic: ${JSON.stringify(topic)}. Format: [{"id":"q1","question":"...","options":["A","B","C","D"],"correctIndex":0,"explanation":"..."}]. ${languageReminder(language)}`; const result = await chatCompletion( llm, diff --git a/src/app/api/roadmap/route.ts b/src/app/api/roadmap/route.ts index fe71ba9..30f1d36 100644 --- a/src/app/api/roadmap/route.ts +++ b/src/app/api/roadmap/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { chatCompletion } from "@/lib/llm/client"; import { extractJson, handleLlmRoute, oneOf, textField, unreadable } from "@/lib/llm/server"; import { LlmError } from "@/lib/llm/errors"; +import { languageInstruction, languageReminder, outputLanguage } from "@/lib/llm/language"; const LEVELS = ["beginner", "intermediate", "advanced"] as const; @@ -10,9 +11,11 @@ export async function POST(request: Request) { const topic = textField(body.topic, 200); if (!topic) throw new LlmError("invalid_request", "Enter a topic first.", 400); const level = oneOf(body.level, LEVELS, "beginner"); + const language = outputLanguage(body.language); + const exampleTime = language === "ms" ? "2 jam" : "2 hours"; - const systemPrompt = `You are a learning path designer. Create a structured ${level}-level roadmap for the user's topic. Break it into clear steps with estimated times. Return ONLY valid JSON, no prose.`; - const userPrompt = `Topic: ${JSON.stringify(topic)}. Format: {"title":"...","steps":[{"id":"s1","title":"...","description":"...","estimatedTime":"2 hours","topics":["a","b"]}]}`; + const systemPrompt = `You are a learning path designer. Create a structured ${level}-level roadmap for the user's topic. Break it into clear steps with estimated times. Return ONLY valid JSON, no prose. ${languageInstruction(language)}`; + const userPrompt = `Topic: ${JSON.stringify(topic)}. Format: {"title":"...","steps":[{"id":"s1","title":"...","description":"...","estimatedTime":"${exampleTime}","topics":["a","b"]}]}. ${languageReminder(language)}`; const result = await chatCompletion( llm, diff --git a/src/app/concepts/page.tsx b/src/app/concepts/page.tsx index 9cc81e8..4af8d55 100644 --- a/src/app/concepts/page.tsx +++ b/src/app/concepts/page.tsx @@ -1,23 +1,31 @@ "use client"; +import { Info, Network } from "lucide-react"; import { ConceptMap } from "@/components/ConceptMap"; +import { PageHeader } from "@/components/ui/PageHeader"; +import { PageShell } from "@/components/ui/PageShell"; +import { useI18n } from "@/i18n/I18nProvider"; +import type { MessageKey } from "@/i18n/messages"; -const sampleConcepts = [ - { id: "1", label: "Variables", connections: ["2", "3"], mastery: 90 }, - { id: "2", label: "Functions", connections: ["1", "4"], mastery: 75 }, - { id: "3", label: "Data Types", connections: ["1", "5"], mastery: 85 }, - { id: "4", label: "Control Flow", connections: ["2", "6"], mastery: 60 }, - { id: "5", label: "Collections", connections: ["3", "6"], mastery: 45 }, - { id: "6", label: "OOP", connections: ["4", "5"], mastery: 30 }, +/** Fixed sample data (labels come from the language files). */ +const SAMPLE: { id: string; label: MessageKey; connections: string[]; mastery: number }[] = [ + { id: "1", label: "concepts.variables", connections: ["2", "3"], mastery: 90 }, + { id: "2", label: "concepts.functions", connections: ["1", "4"], mastery: 75 }, + { id: "3", label: "concepts.dataTypes", connections: ["1", "5"], mastery: 85 }, + { id: "4", label: "concepts.controlFlow", connections: ["2", "6"], mastery: 60 }, + { id: "5", label: "concepts.collections", connections: ["3", "6"], mastery: 45 }, + { id: "6", label: "concepts.oop", connections: ["4", "5"], mastery: 30 }, ]; export default function ConceptsPage() { + const { t } = useI18n(); return ( -
-

🧠 Concept Map

- -

- Sample data. The concept map is a static demo and is not generated from your sessions yet. + + +

+

-
+ ({ ...c, label: t(c.label) }))} title={t("concepts.mapTitle")} /> + ); } diff --git a/src/app/flashcards/page.tsx b/src/app/flashcards/page.tsx index 39ad1df..97ca381 100644 --- a/src/app/flashcards/page.tsx +++ b/src/app/flashcards/page.tsx @@ -1,24 +1,45 @@ "use client"; -import { useCallback, useState } from "react"; +import { useState } from "react"; +import { GalleryVerticalEnd, RotateCcw } from "lucide-react"; import { FlashCard } from "@/components/FlashCard"; -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 { useFlashcardStore, type Flashcard } from "@/store/flashcard-store"; import type { QualityRating } from "@/lib/spaced-repetition"; import { ApiError, postJson } from "@/lib/api-client"; import { recordSession } from "@/lib/record-session"; +import { useI18n } from "@/i18n/I18nProvider"; +import type { MessageKey } from "@/i18n/messages"; + +const RATINGS: { quality: QualityRating; label: MessageKey; className: string }[] = [ + { quality: 1, label: "flashcards.rateHard", className: "border-danger/40 bg-danger-soft text-danger-ink" }, + { quality: 3, label: "flashcards.rateGood", className: "border-accent/40 bg-warning-soft text-warning-ink" }, + { quality: 5, label: "flashcards.rateEasy", className: "border-success/40 bg-success-soft text-success-ink" }, +]; + +type Tally = Record<1 | 3 | 5, number>; +const EMPTY_TALLY: Tally = { 1: 0, 3: 0, 5: 0 }; export default function FlashcardsPage() { - const [topicInput, setTopicInput] = useState(""); + const { t } = useI18n(); + const store = useFlashcardStore(); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - const store = useFlashcardStore(); + const [lastTopic, setLastTopic] = useState(""); + const [finished, setFinished] = useState(false); + const [tally, setTally] = useState(EMPTY_TALLY); - const handleGenerate = useCallback(async () => { - const topic = topicInput.trim(); - if (!topic || isLoading) return; + const generate = async (topic: string) => { + if (isLoading) return; setIsLoading(true); setError(null); + setLastTopic(topic); + setFinished(false); + setTally(EMPTY_TALLY); const started = Date.now(); try { const data = await postJson<{ cards: Flashcard[]; tokens_used?: number }>("/api/flashcards", { topic, count: 10 }); @@ -29,82 +50,117 @@ export default function FlashcardsPage() { } finally { setIsLoading(false); } - }, [topicInput, isLoading, store]); + }; const currentCard = store.cards[store.currentIndex]; - const progress = store.getProgress(); + const total = store.cards.length; - const rateAndNext = (quality: QualityRating) => { + const rateAndNext = (quality: 1 | 3 | 5) => { store.rate(quality); - store.next(); + setTally((prev) => ({ ...prev, [quality]: prev[quality] + 1 })); + if (store.currentIndex >= total - 1) setFinished(true); + else store.next(); + }; + + const newTopic = () => { + store.setCards([], ""); + setFinished(false); + }; + + const reviewAgain = () => { + store.reset(); + setTally(EMPTY_TALLY); + setFinished(false); }; return ( -
-

🃏 Flashcards

+ + - {store.cards.length === 0 && !isLoading && ( + {total === 0 && !isLoading && ( <> -
-

Choose a topic

- setTopicInput(e.target.value)} - placeholder="e.g., Data Structures, Biology..." - className="w-full px-4 py-2 border rounded-lg mb-4 text-base" - onKeyDown={(e) => e.key === "Enter" && handleGenerate()} - /> - + +
+ generate(lastTopic) : undefined} />
- )} - {isLoading && } + {isLoading && } - {currentCard && ( -
-

- {Math.min(progress.reviewed + 1, progress.total)} / {progress.total} -

+ {currentCard && !finished && ( +
+
+

+ {t("flashcards.cardOf", { current: store.currentIndex + 1, total })} +

+ +
+
+ +
- {store.isFlipped && ( -
- {([1, 3, 5] as const).map((q) => ( - - ))} -
- )} -
- + ))} +
+
+ )} +
+
+ )} + + {finished && ( +
+

{t("flashcards.completeTitle")}

+

{t("flashcards.completeDesc", { total, topic: store.topic })}

+
+ {RATINGS.map((r) => ( +
+
{t(r.label)}
+
{tally[r.quality as 1 | 3 | 5]}
+
+ ))} +
+
+ +
)} -
+ ); } 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} - + + +