From ef2a6a3589c8abbda41e52a70d2b1f72598b1faf Mon Sep 17 00:00:00 2001
From: Martin Dvorak
Date: Thu, 8 Oct 2026 18:30:33 +0000
Subject: [PATCH] =?UTF-8?q?Add=20=E2=80=9CMake=20my=20homework=E2=80=9D,?=
=?UTF-8?q?=20and=20finish=20the=20mobile=20polish=20pass?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Homework (/homework — entry points in the drawer and on the create screen)
- Assignment intake reuses the study pipeline: pasted text, photos
(camera/picker, compressed in the browser) and documents (PDF/DOCX/PPTX/
ODT/text) through lib/image + lib/documents, the same vision step and the
same language guard. Raw files never leave the device — only text and JPEG
data URLs reach the server.
- Two large choice cards: “Just make it” returns the finished homework
(answer only, plain text, copy + make it again); “Explain how to make this
homework” builds an interactive guide.
- The guide is one step per screen in the study-feed snap layout:
explanation, three progressive hints (nudge -> hint -> the answer) and an
optional checkpoint rendered by the existing activity components (graded
through /api/grade). The last screen assembles the solution from the step
answers, with a copy button. Checkpoints carry xp 0 so the guide stays free
of XP/streak chrome.
- Results live in freezmo.homework.v1 with a small history (open/delete);
“Make it again” re-runs from the stored assignment text (the vision
transcription when photos were used) — no original files are stored.
- The answer and the guide are written in the assignment's language, never
translated into the UI language.
Server
- POST /api/homework: NDJSON stage events like /api/generate, the same
http-guard, auth checked before parsing, and its own RATE_LIMIT_HOMEWORK
(documented in .env.example + the README table).
- New prompts in lib/prompts.ts and zod schemas in lib/schemas.ts with the
same single repair retry; TEXT_MODEL for content, VISION_MODEL only for
photos. New shared client pieces: useNotesIntake, lib/homework store,
lib/clipboard, and a shared NDJSON reader in lib/client-api.
Also finishing the mobile polish pass from the previous request
- Phone ice scene: the top-left orb moved behind the glass header (it sat
behind the page heading/subtitle). The settings sheet's scroll cue is now a
bare chevron inside a content fade (mask) instead of a chip over the status
banner, and the API key card's note/disclosure/actions are tightened.
- theme-color now follows the in-app theme (SSR metas still fall back to the
OS preference); apple-mobile-web-app-status-bar-style stays “default”
because iOS fixes the status-bar text colour at launch.
Verified: 78/78 homework checks (text/photo/PDF, both modes, EN+CS, dark+light,
360/390px, copy, history persistence, storage privacy, API language, axe), plus
the existing suites re-run (mobile 47/47, pixel contrast 0 below AA, i18n 27/27,
desktop regression 72/72). Lint + build clean; service worker freezmo-v6.
---
.env.example | 1 +
README.md | 50 +++-
app/api/homework/route.ts | 225 +++++++++++++++
app/globals.css | 11 +-
app/homework/page.tsx | 28 ++
app/layout.tsx | 8 +-
components/SettingsDialog.tsx | 95 +++----
components/Sidebar.tsx | 12 +
components/ThemeToggle.tsx | 24 ++
components/UploadScreen.tsx | 21 +-
components/activities/FillBlank.tsx | 3 +-
components/activities/Flashcard.tsx | 3 +-
components/activities/Matching.tsx | 3 +-
components/activities/MultipleChoice.tsx | 2 +
components/activities/Ordering.tsx | 3 +-
components/activities/ShortAnswer.tsx | 3 +-
components/activities/TrueFalse.tsx | 3 +-
components/activities/shared.tsx | 7 +-
components/homework/CopyButton.tsx | 33 +++
components/homework/HomeworkGuide.tsx | 253 +++++++++++++++++
components/homework/HomeworkHistory.tsx | 89 ++++++
components/homework/HomeworkInput.tsx | 339 +++++++++++++++++++++++
components/homework/HomeworkLoading.tsx | 51 ++++
components/homework/HomeworkResult.tsx | 51 ++++
components/homework/HomeworkScreen.tsx | 188 +++++++++++++
lib/ai.ts | 109 ++++++++
lib/client-api.ts | 170 +++++++++---
lib/clipboard.ts | 37 +++
lib/config.ts | 3 +
lib/homework.ts | 160 +++++++++++
lib/i18n-dict.ts | 118 ++++++++
lib/prompts.ts | 88 ++++++
lib/schemas.ts | 89 ++++++
lib/use-notes-intake.ts | 152 ++++++++++
public/sw.js | 2 +-
35 files changed, 2334 insertions(+), 100 deletions(-)
create mode 100644 app/api/homework/route.ts
create mode 100644 app/homework/page.tsx
create mode 100644 components/homework/CopyButton.tsx
create mode 100644 components/homework/HomeworkGuide.tsx
create mode 100644 components/homework/HomeworkHistory.tsx
create mode 100644 components/homework/HomeworkInput.tsx
create mode 100644 components/homework/HomeworkLoading.tsx
create mode 100644 components/homework/HomeworkResult.tsx
create mode 100644 components/homework/HomeworkScreen.tsx
create mode 100644 lib/clipboard.ts
create mode 100644 lib/homework.ts
create mode 100644 lib/use-notes-intake.ts
diff --git a/.env.example b/.env.example
index 6d9a4bc..cea1779 100644
--- a/.env.example
+++ b/.env.example
@@ -41,6 +41,7 @@ TEXT_MODEL=gpt-oss:120b-cloud
# RATE_LIMIT_WINDOW_MS=300000
# RATE_LIMIT_GENERATE=12
# RATE_LIMIT_GRADE=40
+# RATE_LIMIT_HOMEWORK=12
# RATE_LIMIT_HEALTH=90
# Optional tuning ------------------------------------------------------------
diff --git a/README.md b/README.md
index 77d66af..e212f77 100644
--- a/README.md
+++ b/README.md
@@ -79,6 +79,21 @@ no database, no data leaving your machine except the requests you send to Ollama
and **Regenerate deck** — rebuilds the activities from the stored notes so you can keep drilling
until you're ready
+**Make my homework**
+
+- Same intake as a deck — paste the assignment, photograph it, or add a PDF/DOCX/PPTX/ODT/text
+ file; photos go through the same vision model and language guard that notes do
+- Two modes: **Just make it** returns the finished homework (the answer only, ready to copy), and
+ **Explain how to make this homework** turns it into a guided walkthrough
+- The guide is one step per screen in the same snap-feed feel as the study feed: what to do and
+ why, **three progressive hints** per step (nudge → hint → the full answer), and a **checkpoint**
+ built from the normal activity components and graded through `/api/grade`
+- The last screen assembles the solution from the step answers, with a copy button
+- Results live in `freezmo.homework.v1` with a small history you can reopen or delete; **Make it
+ again** re-runs from the stored assignment text — original files are never saved
+- The answer and the guide are always written in the **language of the assignment**, never
+ translated into the interface language
+
**Activity types** (each one is its own React component, rendered from JSON by a `type` field)
| Type | Interaction |
@@ -335,12 +350,13 @@ the two pipeline steps stay separate either way.
## Rate limits
-Generation is expensive (two model calls per deck), so the API routes are limited per client IP with
-a sliding window. Everything is configurable in `.env.local`:
+Generation is expensive (one to two model calls per request), so the API routes are limited per
+client IP with a sliding window. Everything is configurable in `.env.local`:
| Route | Default | Env var |
| --- | --- | --- |
| `POST /api/generate` | 12 per 5 minutes | `RATE_LIMIT_GENERATE` |
+| `POST /api/homework` | 12 per 5 minutes | `RATE_LIMIT_HOMEWORK` |
| `POST /api/grade` | 40 per 5 minutes | `RATE_LIMIT_GRADE` |
| `GET /api/health` | 90 per 5 minutes | `RATE_LIMIT_HEALTH` |
| window for all of them | 5 minutes | `RATE_LIMIT_WINDOW_MS` |
@@ -581,20 +597,23 @@ app/
page.tsx library ("Your icebox")
create/page.tsx upload screen
deck/[id]/page.tsx study feed for a saved deck
+ homework/page.tsx “Make my homework” (assignment in, homework or guide out)
api/generate/route.ts images/text in → NDJSON progress → deck JSON out
+ api/homework/route.ts assignment in → NDJSON progress → homework/guide JSON out
api/grade/route.ts short-answer grading
api/health/route.ts Ollama reachability + model availability
components/
activities/ one component per activity type + shared shell
Flashcard.tsx MultipleChoice.tsx TrueFalse.tsx FillBlank.tsx
Matching.tsx Ordering.tsx ShortAnswer.tsx shared.tsx index.tsx
+ homework/ make-my-homework screens (input, result, guide feed, history)
UploadScreen.tsx StudyFeed.tsx ResultsScreen.tsx DeckCard.tsx ApiKeyGate.tsx
InstallSheet.tsx TabBar.tsx ServiceWorkerRegistrar.tsx FolderBar.tsx
LibraryScreen.tsx SettingsDialog.tsx AppHeader.tsx ThemeToggle.tsx icons.tsx ui.tsx
lib/
ollama.ts server-only HTTP client (timeouts, auth, error mapping)
- prompts.ts the vision, generation, grading and repair prompts
- ai.ts the three pipeline steps with zod validation + retry
+ prompts.ts the vision, generation, grading, homework and repair prompts
+ ai.ts every pipeline step with zod validation + retry
schemas.ts zod schemas and shared types
config.ts env config, cloud/direct model-name normalisation
documents.ts client-side PDF/DOCX/PPTX/ODT/text extraction
@@ -607,6 +626,9 @@ lib/
language.ts script/language checks that catch translated transcriptions
ui-events.ts tiny event bus (any screen can open the settings dialog)
storage.ts localStorage deck library, import/export
+ homework.ts localStorage homework results + solution assembly
+ use-notes-intake.ts shared client-side photo/document intake hook
+ clipboard.ts copy helper with a non-secure-context fallback
image.ts client-side validation, resize and compression
client-api.ts browser client for the API routes (NDJSON reader)
xp.ts utils.ts deck-ops.ts confetti.ts
@@ -629,10 +651,11 @@ scripts/
| Route | Body | Returns |
| --- | --- | --- |
| `POST /api/generate` | `{ images?: string[], text?, difficulty, count?, focus?, language?, mode?, existing? }` | NDJSON stream: `{type:"stage"}`, `{type:"deck",deck,meta}`, `{type:"error"}` |
+| `POST /api/homework` | `{ mode: "make" \| "guide", images?: string[], text?, language? }` | NDJSON stream: `{type:"stage"}`, `{type:"homework",homework,meta}`, `{type:"error"}` |
| `POST /api/grade` | `{ question, referenceAnswer, keyPoints, userAnswer, language? }` | `{ correct, score, feedback, missing }` |
| `GET /api/health` | – | `{ ok, reachable, mode, baseUrl, hasApiKey, vision, text, latencyMs, modelCount, error }` |
-All three routes accept an optional **`x-ollama-key`** header (the browser's own key; required in
+All four routes accept an optional **`x-ollama-key`** header (the browser's own key; required in
`user-key` auth mode). Missing keys answer **401** with `kind: "needs_key"`, a rejected key answers
`kind: "auth"` (or `kind: "no_credits"` for HTTP 402), and exceeding a rate limit answers **429**
with `Retry-After`. Keys are used for the single request and discarded.
@@ -640,9 +663,9 @@ with `Retry-After`. Keys are used for the single request and discarded.
Images are sent as base64 data URLs; the server strips the prefix before handing them to Ollama.
Uploads are validated for type (photos and the document formats above), count (max 8 photos,
6 documents, 12 images) and size (max ~8 MB per image after compression, 40 MB per document) and the
-client compresses aggressively, so a typical upload is 1–3 MB. `language` is optional on both routes:
-the vision step fills it in automatically, and the UI passes the stored deck language for grading,
-regeneration and “add more”.
+client compresses aggressively, so a typical upload is 1–3 MB. `language` is optional wherever it
+appears: the vision step fills it in automatically, and the UI passes the stored deck language for
+grading, regeneration and “add more” (and the assignment language for homework).
---
@@ -870,6 +893,17 @@ Things I chose while building, with the reasoning:
(hide the text → screenshot → worst-case background luminance per glyph rect): 0 runs below AA
across library and settings, both themes, both languages.
+35. **“Make my homework” reuses the study pipeline instead of growing its own.** The intake is the
+ same client-side parsing as `/create` (lib/documents + lib/image, wrapped in `useNotesIntake`),
+ the photos go through the same `extractNotes` vision step and language guard, and the guide's
+ checkpoints are ordinary activity JSON rendered by the existing components — just a
+ `checkpoint` field on a step. The route streams NDJSON like `/api/generate` and has its own
+ rate limit. Two things are deliberately different: the finished answer and the guide are
+ written in the *assignment's* language (the interface language never leaks in), and the final
+ solution is assembled on the client from the step answers instead of asking the model for a
+ second copy. Result records store the assignment text — the vision transcription when photos
+ were used — so “Make it again” works without storing a single original file.
+
14. **`gpt-oss:120b` does all content work — but it cannot read images** (`this model does not
support image input`), so `VISION_MODEL` has to stay a multimodal model (`gemma4:31b-cloud`).
Text-only inputs — pasted text, PDFs with a text layer, DOCX/PPTX/ODT — never touch the vision
diff --git a/app/api/homework/route.ts b/app/api/homework/route.ts
new file mode 100644
index 0000000..6cb63ea
--- /dev/null
+++ b/app/api/homework/route.ts
@@ -0,0 +1,225 @@
+import { z } from "zod";
+
+import { buildHomeworkGuide, extractNotes, makeHomework, notesToText, type PipelineStage } from "@/lib/ai";
+import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth, resolveModelName } from "@/lib/config";
+import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
+import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
+import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
+import { translate } from "@/lib/i18n-dict";
+import { homeworkModeSchema, type HomeworkStep } from "@/lib/schemas";
+import { uid } from "@/lib/utils";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+export const maxDuration = 300;
+
+/** ~8 MB per image after client-side resizing; up to 16 images (photos + PDF pages). */
+const MAX_IMAGE_CHARS = 8_000_000;
+/** Generous but finite: ~16 compressed images plus JSON overhead. */
+const MAX_BODY_BYTES = 33554432;
+
+const requestSchema = z.object({
+ mode: homeworkModeSchema,
+ images: z.array(z.string().min(32).max(MAX_IMAGE_CHARS)).max(16).optional(),
+ text: z.string().max(120_000).optional(),
+ /** Language of the assignment (usually detected by the vision step). */
+ language: z.string().max(60).optional(),
+});
+
+type StreamEvent =
+ | { type: "stage"; stage: PipelineStage; message: string }
+ | { type: "homework"; homework: unknown; meta: Record }
+ | { type: "error"; error: string; kind?: string };
+
+function encoder() {
+ const enc = new TextEncoder();
+ return (event: StreamEvent) => enc.encode(`${JSON.stringify(event)}\n`);
+}
+
+/** Same pipeline shape as /api/generate: vision (photos only) then one model call. */
+export async function POST(request: Request) {
+ const locale = requestLocale(request);
+ const crossOrigin = rejectCrossOrigin(request);
+ if (crossOrigin) return crossOrigin;
+ const oversized = rejectOversizedBody(request, MAX_BODY_BYTES);
+ if (oversized) return oversized;
+
+ const config = getServerConfig();
+
+ // Cheap requests are rejected before any work happens.
+ const limit = rateLimit(`homework:${clientKey(request)}`, config.rateLimits.homework, config.rateLimits.windowMs);
+ if (!limit.allowed) return rateLimitResponse(limit, locale);
+
+ const auth = resolveAuth(config, request);
+ if ("error" in auth) {
+ return Response.json(
+ { error: authErrorMessage(locale, auth.error), kind: authErrorKind(auth.error) },
+ { status: 401 },
+ );
+ }
+
+ let payload: unknown;
+ try {
+ payload = await request.json();
+ } catch {
+ return Response.json({ error: translate(locale, "server.bodyNotJson") }, { status: 400 });
+ }
+
+ const parsed = requestSchema.safeParse(payload);
+ if (!parsed.success) {
+ return Response.json(
+ {
+ error: translate(locale, "server.invalidRequest", {
+ detail: parsed.error.issues
+ .slice(0, 3)
+ .map((issue) => `${issue.path.join(".") || "body"} ${issue.message}`)
+ .join("; "),
+ }),
+ },
+ { status: 400 },
+ );
+ }
+
+ const { mode, images, text, language } = parsed.data;
+ const imageList = (images ?? []).map(stripDataUrlPrefix).filter(Boolean);
+
+ if (imageList.length === 0 && !text?.trim()) {
+ return Response.json({ error: translate(locale, "server.needAssignment") }, { status: 400 });
+ }
+
+ const encode = encoder();
+ const abort = new AbortController();
+ // A single hard deadline for the whole pipeline (two sequential model calls).
+ const globalTimer = setTimeout(() => abort.abort(), config.timeoutMs * 2);
+
+ const stream = new ReadableStream({
+ async start(controller) {
+ const send = (event: StreamEvent) => {
+ try {
+ controller.enqueue(encode(event));
+ } catch {
+ /* client went away */
+ }
+ };
+
+ try {
+ let assignmentText: string;
+ let detectedLanguage: string | undefined = language;
+ let repaired = false;
+
+ if (imageList.length > 0) {
+ send({ type: "stage", stage: "vision", message: translate(locale, "homework.loading.reading") });
+ const extraction = await extractNotes({
+ model: config.visionModel,
+ repairModel: config.textModel,
+ auth: auth.auth,
+ images: imageList,
+ text,
+ signal: abort.signal,
+ onProgress: (message) => send({ type: "stage", stage: "vision", message }),
+ });
+ assignmentText = notesToText(extraction.notes);
+ detectedLanguage = extraction.notes.language || language;
+ repaired = extraction.repaired || extraction.languageRepaired;
+ } else {
+ assignmentText = text!.trim();
+ }
+
+ send({
+ type: "stage",
+ stage: "generation",
+ message: translate(locale, mode === "make" ? "homework.loading.writing" : "homework.loading.planning"),
+ });
+
+ const meta = {
+ repaired,
+ language: detectedLanguage || null,
+ visionModel: imageList.length ? resolveModelName(config.visionModel, auth.auth.directCloud) : null,
+ textModel: resolveModelName(config.textModel, auth.auth.directCloud),
+ };
+
+ if (mode === "make") {
+ const result = await makeHomework({
+ model: config.textModel,
+ auth: auth.auth,
+ assignment: assignmentText,
+ language: detectedLanguage,
+ signal: abort.signal,
+ });
+ send({
+ type: "homework",
+ homework: {
+ mode: "make",
+ title: result.title,
+ language: result.language || detectedLanguage,
+ answer: result.answer,
+ // The assignment as text (the vision transcription when photos
+ // were used) so the client can store it for “Make it again”.
+ sourceText: assignmentText,
+ },
+ meta,
+ });
+ } else {
+ const guide = await buildHomeworkGuide({
+ model: config.textModel,
+ auth: auth.auth,
+ assignment: assignmentText,
+ language: detectedLanguage,
+ signal: abort.signal,
+ });
+ const steps: HomeworkStep[] = guide.steps.map((step) => ({
+ ...step,
+ // xp: 0 keeps the shared activity components from rendering their
+ // "+XP" badge — the guide deliberately has no XP or streak.
+ checkpoint: step.checkpoint ? { ...step.checkpoint, id: uid("act"), xp: 0 } : undefined,
+ }));
+ send({
+ type: "homework",
+ homework: {
+ mode: "guide",
+ title: guide.title,
+ language: guide.language || detectedLanguage,
+ steps,
+ sourceText: assignmentText,
+ },
+ meta,
+ });
+ }
+ } catch (error) {
+ const message = friendlyErrorMessage(error, auth.auth.baseUrl, locale);
+ // Never log the request body: it may contain the student's assignment.
+ console.error("[api/homework]", error instanceof OllamaError ? `${error.kind}: ${error.message}` : error);
+ send({
+ type: "error",
+ error: message,
+ kind: error instanceof OllamaError ? error.kind : "unknown",
+ });
+ } finally {
+ clearTimeout(globalTimer);
+ try {
+ controller.close();
+ } catch {
+ /* already closed */
+ }
+ }
+ },
+ cancel() {
+ clearTimeout(globalTimer);
+ abort.abort();
+ },
+ });
+
+ return new Response(stream, {
+ headers: {
+ "Content-Type": "application/x-ndjson; charset=utf-8",
+ "Cache-Control": "no-store, no-transform",
+ Connection: "keep-alive",
+ },
+ });
+}
+
+function stripDataUrlPrefix(value: string): string {
+ const commaIndex = value.indexOf(",");
+ if (value.startsWith("data:") && commaIndex !== -1) return value.slice(commaIndex + 1);
+ return value;
+}
diff --git a/app/globals.css b/app/globals.css
index 4c09538..42aa009 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -338,7 +338,9 @@ body {
--ice-blob-opacity: 0.2;
--ice-orb-opacity: 0.42;
}
- .ice-orb-1 { width: 96px; height: 96px; top: 8%; left: -26px; }
+ /* Tucked up behind the glass header: at top 8% it sat right behind the page
+ heading and subtitle. */
+ .ice-orb-1 { width: 88px; height: 88px; top: -30px; left: -38px; }
.ice-orb-2 { width: 72px; height: 72px; left: -32px; }
/* Was sitting right behind the bottom-nav labels. */
.ice-orb-3 { width: 116px; height: 116px; bottom: 15vh; right: -46px; }
@@ -698,10 +700,15 @@ body {
}
/* Settings sheet: contained momentum scrolling; the safe-area padding comes
- from the pb-safe utility plus a trailing spacer. */
+ from the pb-safe utility plus a trailing spacer. The mask fades the last
+ 36px of *content* (not a coloured overlay, so it blends with the glass in
+ both themes) — that is the scroll cue; the trailing spacer keeps real
+ content out of the faded strip once you reach the bottom. */
.sheet-scroll {
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
+ -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 36px), transparent);
+ mask-image: linear-gradient(to bottom, #000 calc(100% - 36px), transparent);
}
}
diff --git a/app/homework/page.tsx b/app/homework/page.tsx
new file mode 100644
index 0000000..24be85e
--- /dev/null
+++ b/app/homework/page.tsx
@@ -0,0 +1,28 @@
+import { ApiKeyGate } from "@/components/ApiKeyGate";
+import { AppHeader } from "@/components/AppHeader";
+import { HomeworkScreen } from "@/components/homework/HomeworkScreen";
+import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar";
+import { TabBar } from "@/components/TabBar";
+
+export const metadata = {
+ title: "Make my homework",
+};
+
+export default function HomeworkPage() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/layout.tsx b/app/layout.tsx
index 7f4c904..d18df27 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -55,8 +55,12 @@ export const viewport: Viewport = {
],
};
-/** Applies the saved theme before paint to avoid a flash. */
-const themeScript = `(function(){try{var s=localStorage.getItem('freezmo.theme');var d=s?s==='dark':window.matchMedia('(prefers-color-scheme: dark)').matches;if(d)document.documentElement.classList.add('dark');var l=localStorage.getItem('freezmo.locale.v1');if(l==='cs'||l==='en')document.documentElement.lang=l;}catch(e){}})();`;
+/**
+ * Applies the saved theme before paint to avoid a flash, and keeps the
+ * `theme-color` meta in step with the *app* theme (the SSR metas follow the
+ * OS preference, which is wrong once the user has picked a theme here).
+ */
+const themeScript = `(function(){try{var s=localStorage.getItem('freezmo.theme');var d=s?s==='dark':window.matchMedia('(prefers-color-scheme: dark)').matches;if(d)document.documentElement.classList.add('dark');var l=localStorage.getItem('freezmo.locale.v1');if(l==='cs'||l==='en')document.documentElement.lang=l;var m=document.querySelector('meta[name="theme-color"][data-app]');if(!m){m=document.createElement('meta');m.setAttribute('name','theme-color');m.setAttribute('data-app','1');document.head.insertBefore(m,document.head.firstChild);}m.setAttribute('content',d?'#0e1424':'#f4f7fd');}catch(e){}})();`;
/**
* Captures Chrome's `beforeinstallprompt` (it can fire before React hydrates)
diff --git a/components/SettingsDialog.tsx b/components/SettingsDialog.tsx
index 730ae5d..5e61da5 100644
--- a/components/SettingsDialog.tsx
+++ b/components/SettingsDialog.tsx
@@ -257,23 +257,26 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
-
) : null}
@@ -371,22 +373,21 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
{health ? : null}
- {/* breathing room for the scroll cue + safe area */}
-
+ {/* keeps real content out of the 36px fade strip at the bottom */}
+
- {/* visible scroll cue */}
+ {/* scroll cue: a bare chevron inside the fade strip, so it never
+ covers text (the .sheet-scroll mask fades what is underneath) */}
-
-
-
+
diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx
index 6dd5a86..2759c3a 100644
--- a/components/Sidebar.tsx
+++ b/components/Sidebar.tsx
@@ -12,6 +12,7 @@ import {
LayersIcon,
MoonIcon,
PlusIcon,
+ SparklesIcon,
SunIcon,
} from "@/components/icons";
import { useTheme } from "@/components/ThemeToggle";
@@ -146,6 +147,17 @@ function NavContent({
{!collapsed ? {t("common.newDeck")} : null}
+ {/* make my homework: same quiet row --------------------------------- */}
+
+
+ {!collapsed ? {t("homework.nav")} : null}
+
+
{/* folders --------------------------------------------------------- */}
{/* Mobile keeps the original layout: one Folders label above the list. */}
{!desktop && !collapsed ? (
diff --git a/components/ThemeToggle.tsx b/components/ThemeToggle.tsx
index 69bab3e..815ae33 100644
--- a/components/ThemeToggle.tsx
+++ b/components/ThemeToggle.tsx
@@ -8,6 +8,28 @@ import { MoonIcon, SunIcon } from "@/components/icons";
const STORAGE_KEY = "freezmo.theme";
+const THEME_COLORS = { light: "#f4f7fd", dark: "#0e1424" } as const;
+
+/**
+ * Keeps the first `theme-color` meta (the one the app owns) in step with the
+ * in-app theme. The SSR metas follow the OS preference; this one always wins
+ * because it is media-less and first in the head.
+ */
+export function syncThemeColor(theme: "light" | "dark") {
+ try {
+ let meta = document.querySelector('meta[name="theme-color"][data-app]');
+ if (!meta) {
+ meta = document.createElement("meta");
+ meta.setAttribute("name", "theme-color");
+ meta.setAttribute("data-app", "1");
+ document.head.insertBefore(meta, document.head.firstChild);
+ }
+ meta.setAttribute("content", THEME_COLORS[theme]);
+ } catch {
+ /* private mode / odd embedded browsers */
+ }
+}
+
export function useTheme() {
const [theme, setTheme] = useState<"light" | "dark">("light");
@@ -17,11 +39,13 @@ export function useTheme() {
? stored === "dark"
: window.matchMedia("(prefers-color-scheme: dark)").matches;
setTheme(isDark ? "dark" : "light");
+ syncThemeColor(isDark ? "dark" : "light");
}, []);
const apply = (next: "light" | "dark") => {
setTheme(next);
document.documentElement.classList.toggle("dark", next === "dark");
+ syncThemeColor(next);
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch {
diff --git a/components/UploadScreen.tsx b/components/UploadScreen.tsx
index ed448b6..8cbac2c 100644
--- a/components/UploadScreen.tsx
+++ b/components/UploadScreen.tsx
@@ -1,6 +1,7 @@
"use client";
import { motion } from "framer-motion";
+import Link from "next/link";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
@@ -32,7 +33,7 @@ import { UNORGANIZED_ID, type Difficulty } from "@/lib/schemas";
import { upsertDeck } from "@/lib/storage";
import { openSettings } from "@/lib/ui-events";
import { cn, uid } from "@/lib/utils";
-import { CameraIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SunIcon } from "@/components/icons";
+import { CameraIcon, ChevronRightIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SparklesIcon, SunIcon } from "@/components/icons";
import { Button, ProgressBar } from "@/components/ui";
type Phase = "idle" | "preparing" | "loading" | "error";
@@ -320,6 +321,24 @@ export function UploadScreen() {
+ {/* The other thing you can bring here: an assignment. */}
+
+
+
+
+
+ {t("homework.entry")}
+ {t("homework.nav")} →
+
+
+
+
{error ? (