freezmo/README.md
Martin Dvorak 5ca0de920c Chat mode, phase 2: local history, answer actions, motion polish
- Conversations in a versioned IndexedDB store (freezmo-chat v1, zod
  validated with the same schema backup/sync use, capped at 100 x 300).
  Settled turns persist (question always; answer when produced, partial
  stops included); ?c=<id> keeps reloads on the conversation via
  history.replaceState (no router round-trip mid-chat).
- History UI: desktop column, phone sheet (spring, backdrop, Escape),
  auto titles from the first question, inline rename, delete with an
  Undo toast, diacritic-folding search over titles and message text.
  Scope and model are remembered per conversation; history is readable
  offline.
- Assistant actions: Turn into flashcards (New deck with the answer as
  notes, landing on the options step - deck seed gained step) and Save
  to notes (appends to the single scoped deck, trimmed at 40k).
- Chats travel in the backup export (preview counts them) and in the
  encrypted sync payload; still never the key.
- Motion from lib/motion.ts: message fade-up, sheet springs for the
  model/context/history sheets, 150ms composer height transition, all
  skipped under reduced motion.
- Tests: chat suite 63 -> 89 checks (history, reload, rename/search/
  delete/undo, actions, export, two-device sync round-trip, offline,
  desktop panel). Full set: 12/12 suites, 513 checks. SW -> freezmo-v33.

Also: tests/model-compare.mjs benchmark (diagnostic) for deciding the
default text model on numbers; defaults unchanged.
2026-10-11 19:03:48 +00:00

91 KiB
Raw Blame History

❄️ Freezmo

Turn photos of your study notes into a swipeable feed of mini study games.

Freezmo is a TikTok/Gizmo-style study app. You paste or upload photos of your notes, an AI reads them and builds a deck of 8–15 interactive activities — flashcards, quizzes, matching games and more — that you swipe through vertically like a feed. Every answer gives instant feedback, XP and streaks, and the deck is saved in your browser so you can retry it later.

All AI runs through your own Ollama setup (a local daemon or the Ollama cloud API). No accounts, no database, no data leaving your machine except the requests you send to Ollama.


Table of contents


Features

Capture & generate

  • Drag & drop, file picker, mobile camera capture and clipboard paste (Ctrl/⌘+V)
  • Phones get a 3-step flow — notes → options → summary — with a thin progress bar and “1 / 3”. Step 1 leads with the step name as the headline, a formats/limits hint, one big Add photos or files area plus Camera and Paste text instead, and a “nothing added yet” placeholder that becomes the thumbnail/chip list. Next stays disabled until something is added. Step 2 keeps folder, difficulty and count at their defaults with a helper under each group and tucks the topic hint and pasted notes behind one collapsed row. Step 3 is a real summary — thumbnails/chips, files, difficulty, activities and folder, each with an Edit link back to its step. A flat Back/Cancel + Next/Freeze bar sits at the bottom above the safe area (the tab bar hides inside the wizard), and it is portalled to <body> so the screen entrance animation can never displace it. While a run is generating, the wizard's mode switch hides — a loading screen can't be swapped to the other wizard mid-flight — and leaving the page anyway aborts the request instead of saving a deck in the background. The desktop page is unchanged.
  • Native-feel touches (phones): the Android Back gesture closes the topmost layer first (sheet, actions sheet, then one wizard step); deleting a deck shows an Undo toast instead of a confirm; the deck menu can share a deck as JSON through the system share sheet (clipboard fallback); content shared from other apps opens a chooser and lands pre-filled in either wizard; haptics (Settings → Haptics, on by default) tick on tab switches, toggles, correct/wrong answers and completion; the screen stays awake during study sessions and homework guides; wizard drafts survive a reload or an OS kill; re-tapping the active tab scrolls to the top; an offline pill appears and generation is disabled with a reason until the connection is back.
  • Chat with your notes: an LLM-style conversation grounded in your decks. Ask from the Library, a deck's menu, or a result; pick the scope (all notes, folders/decks, or none) and the model; photos and documents are transcribed first, so any model works. Answers stream with stop, copy, regenerate and edit-and-resend, render safe markdown + math, and hide reasoning behind a Thinking row. See Chat.
  • Learning engine: every answer is recorded (deck, activity, topic, correct/wrong, time) in an IndexedDB log, and each activity gets a light SM-2 schedule — correct answers come back after 1 day, then 3, then interval × ease; wrong ones after ten minutes. The Library shows a Smart review card with the due count, a Mistakes count (activities whose latest answer was wrong — they clear themselves once answered correctly) and today's daily goal progress. Tapping either one shows a loading screen instantly (the link reports its own pending state, the route has a loading boundary, and the payload is prefetched), so even a slow first request never looks like a dead tap. A Progress tab has the day streak, a five-week calendar, accuracy per subject and per deck and the weakest topics. Exam mode (its own tab) takes one or more decks, a question count and an optional timer, hides hints and shows a per-deck/per-topic breakdown at the end. Daily reminders appear in Settings only when the server has VAPID keys configured; subscriptions are stored as endpoint + keys only, and node scripts/send-reminders.mjs (cron/systemd timer) does the sending.
  • Backup, sync and limits: Settings → Data & backup exports everything (decks, folders, homework, progress, safe settings — never the API key) to one JSON file and imports a backup with a preview and a merge/replace choice; a gentle, dismissible nudge appears after 5 new decks or 14 days without a backup, and the storage-persistence state (navigator.storage.persist()) is shown. Settings → Sync is off by default and opt-in: the data is encrypted in the browser (PBKDF2-SHA256 → AES-GCM, key kept as a non-extractable CryptoKey) before it is uploaded, the server stores only an opaque blob under a random 128-bit sync ID, and conflicts are surfaced with an explicit keep this device / use synced copy choice. When Ollama's free monthly usage runs out, every flow that calls the model shows the same clear message with Open Settings (jumps to the key field) and Open Ollama — never a silent fallback to another model.
  • Photos and documents: JPG/PNG/WEBP/HEIC images plus PDF, DOCX, PPTX, ODT/ODP/ODS, TXT, MD, CSV
  • Documents are read in the browser: PDF text layers are extracted, scanned PDF pages are rendered as images for the vision model, DOCX/PPTX text, speaker notes and embedded images are pulled out. The raw file never leaves your device.
  • Up to 8 photos + 6 documents per deck (max 12 images reach the model); photos are resized to max 1600 px and re-encoded as JPEG before upload
  • Optional extra text (pasted notes) and a topic hint
  • Easy / Medium / Hard difficulty and 8 / 10 / 12 / 15 activities per deck
  • Fun staged loading state (Reading your notes… → Finding the key ideas… → Building your activities…)
  • Friendly error cards with a Retry button that point at Settings when a key is needed

Language

  • The notes' language is detected and preserved end to end: transcription, deck title, questions, options, explanations, short-answer grading and later regenerate / add more all stay in that language. Czech notes produce a Czech deck; the app never translates them to English.
  • Language guard: after the vision step, the transcription is checked (script + language markers). If it clearly came back in another language, TEXT_MODEL (which is much stronger multilingually) restores it before any activities are written — the UI shows “Restoring the notes' language…”. On good transcriptions the guard stays silent and costs nothing.
  • The deck stores its language, so short answers are graded and feedback written in the same language.
  • The Freezmo interface has its own language switch: English (default) and a full Czech translation, picked in Settings → Language or from the EN/CS button on the key gate. The chosen locale is remembered per browser and also decides the language of server messages (API errors, health notes), so nothing falls back to English mid-sentence.

Study feed

  • Full-screen vertical scroll-snap feed, one activity per screen
  • Arrow-key / PageUp / PageDown / Home / End navigation, plus swipe on touch devices
  • Progress bar, activity counter, XP counter and answer streak in the chrome
  • Instant feedback with a short explanation for every activity, confetti when you finish
  • Results screen: Retry missed ones (fresh, answerable items), Study the whole deck again, 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
  • Three modes: Just make it returns the finished homework (the answer only, ready to copy), Explain how to make this homework turns it into a guided walkthrough, and Check my work marks the student's own attempt — what's right, what's wrong and why, plus a corrected version (the attempt is typed or pasted; photos of handwriting are not supported yet, and the UI says so)
  • Follow-ups on a result: Shorter, More detail and back to the Original rewrite the finished text in place; inside a guide every step has Explain step N differently
  • Turn into a study deck on any result opens New deck with the content (the answer, the corrected version or the assembled guide) and the options step pre-filled
  • On phones this is a 2-step flow: the assignment first (same simplified input as the deck flow, with its own step headline, formats hint, big add area and empty placeholder), then the mode cards under a short preview — tapping a card starts it immediately (the check card first reveals the solution field), so there is no separate button. Step 1 starts with Cancel and the flat action bar matches the deck wizard. Desktop keeps the select-then-confirm layout.
  • 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 — the homework screen's history and the library's Homework tab — where you can reopen or delete them; Redo re-runs from the stored assignment text (and the stored attempt for checks), and 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
flashcard Tap to flip, then self-assess I knew it / Not yet
multiple_choice 4 options, keys 1–4, correct answer + explanation
true_false Tap the buttons or swipe the card right/left
fill_blank Inline input (one typo forgiven) or a word bank
matching Tap a term, then its definition; perfect match for full XP
ordering Drag with the mouse/finger, or use ▲▼ buttons, then check
short_answer Typed answer graded by the AI via /api/grade, with a self-check fallback

First run

  • A full-screen API-key gate replaces the app until a key is saved (in user-key mode): one masked input with a show/hide toggle, a single Continue, and a "?" sheet with the four steps to a free key. The key is validated with a real 1-token probe before it is accepted — invalid keys and network trouble get different inline messages. Removing the key in Settings brings the gate back; server and local installs skip it entirely.

Library & folders

  • Two pools, never mixed: a Study Decks tab (the folder + card library) and a Homework tab with saved solutions and step-by-step guides — each with its own empty state and call to action, and the active tab lives in the URL (/?tab=homework)
  • Phone library, decluttered: no stats line, the folder chips and “Unorganized” heading only appear once folders exist, and a ⋯ button next to the pool switcher opens a small sheet with Import JSON and New folder. Deck cards shrink to title + one meta line (activities · difficulty · best score) + a one-line description that expands on tap, with the whole card tappable and a small Study pill; homework cards match.
  • Decks saved locally with title, subject, folder, activity count, date, play count and best score
  • Folders: create named groups ("History", "Biology", …) from the sidebar or the create screen. New decks freeze straight into the selected folder; anything unfiled lives in Unorganized. Rename, delete (decks survive — they return to Unorganized) and move decks between folders
  • Rename, delete (with confirm), regenerate (from the library or the results screen), and add more activities to an existing deck
  • Export a deck as JSON and import it back — the folder name travels with the file and is recreated on import

Design

  • ChatGPT-style shell on desktop, five tabs on phones: desktop keeps the persistent left sidebar (brand, a quiet "New deck" row, a Folders list with per-folder counts, Settings/Dark mode pinned to the bottom), which collapses to a 68px icon rail (remembered in localStorage). Phones have no drawer and no hamburger: a frosted header (logo + theme chip) sits above an iOS-style bottom tab bar — Library · Progress · New deck · Exam · Settings — with the New deck button as the prominent centre action and Settings opening the sheet. The wizards keep their own bottom action bar instead of the tab bar, and the in-page segmented switch moves between the deck and homework wizards.
  • Frosted ice glass, both themes: light page #eef5fd (pale ice) with white glass panels; dark page #0a1120 with rgba(255,255,255,.06) glass. Every large surface shares one recipe — translucent tint, 24px backdrop-blur (16px on phones), a bright 1px rim, an inner top highlight and a blue-tinted shadow — over a fixed ice scene (navy/pale base, drifting glacier blobs, glossy ice orbs and a whisper of grain). Text tiers are documented AA ratios (see below), and the cards fill the row with repeat(auto-fill, minmax(280px, 1fr)) — no stranded cards, no dead space
  • Deck cards: title → muted metadata → neutral tags (difficulty carries the only colour: green/amber/red) → summary → a single Study button, with everything else in the ⋯ menu
  • Frosted UI: the sticky header, tab bar, sheets and panels use real backdrop-filter blur over the fixed ice scene (drifting glacier blobs, glossy orbs, a whisper of grain) — depth without noise
  • Pill language: primary CTAs are near-black in light mode and white in dark mode; secondaries are outlined or ghost. Colour is reserved for meaning — activity tints (sky/lilac/mint/peach/rose), correct/wrong feedback, key status
  • Micro-interactions: 160–200ms colour/transform transitions, active:scale-[0.985], skeleton shimmer, hairline hover washes on every row and card
  • Motion (phones): one shared language (lib/motion.ts) — every button, card, chip, tab and nav row scales to 0.96 on press with a spring release; the tab bar, segmented controls and difficulty/size pickers glide their highlight with a shared layoutId; wizard steps slide forward and backward and page content enters direction-aware; the Settings sheet and the library actions sheet follow the finger and settle on release velocity; library cards fade up in a capped stagger and animate out on delete; score and XP count up; correct answers pulse, wrong ones shake; prefers-reduced-motion collapses everything to short fades. Only transform and opacity animate — never blur.
  • iOS-flavoured PWA: standalone mode, safe-area insets, bottom tab bar, sheet-style dialogs with a grabber, inset grouped lists, segmented controls, "Add to Home Screen" hint — see Install as an app
  • Mobile-first, dark mode, keyboard accessible, focus rings, ARIA labels, prefers-reduced-motion support

Quick start

Requirements: Node.js 20+ (tested on 24) and npm.

cd freezmo
./setup.sh                     # install + .env.local + production build
npm start                      # http://0.0.0.0:3207

Or do it by hand (and use the dev server):

cd freezmo
npm install
cp .env.example .env.local     # first time only
npm run dev                    # http://0.0.0.0:3207

setup.sh flags: --dev installs and prepares .env.local without building, --no-build skips the build too.

Open http://localhost:3207. The app runs without any Ollama configuration — you'll just need a key (or a signed-in daemon) before it can generate a deck. Complete one of the Ollama setups below, then paste the key on the gate (or later in Settings).

npm run dev and npm run build also copy the pdf.js worker into public/ automatically (see scripts/copy-pdf-worker.mjs), which is what lets the browser parse PDFs.

Production:

npm run build
npm start                      # http://0.0.0.0:3207

Production is also where the PWA comes alive: the service worker registers, the app can be installed to the home screen and the /offline fallback works. In npm run dev none of that runs, so you won't fight a stale cache while developing.


Ollama setup

Freezmo talks to POST /api/chat and GET /api/tags of whatever URL is in OLLAMA_BASE_URL. All calls happen in Next.js route handlers on the server — the browser never talks to Ollama directly.

Who provides the API key is controlled by OLLAMA_AUTH:

OLLAMA_AUTH Key comes from Use it when
user-key (default) each browser, pasted on the key gate (or in Settings) several people/devices share the app, or you don't want a key on the server
server OLLAMA_API_KEY in .env.local a single-user install
local nothing — a daemon signed in with ollama signin you run Ollama locally

Mode 1 (default) — bring your own key

OLLAMA_AUTH=user-key
OLLAMA_BASE_URL=https://ollama.com
VISION_MODEL=gemma4:31b-cloud
TEXT_MODEL=gpt-oss:120b-cloud
  1. Open the app — the key gate asks for a key on first visit.
  2. Create one at https://ollama.com/settings/keys, paste it in and press Continue.
  3. The gate validates it with a real 1-token request, then drops you into the icebox. Settings → Test connection re-checks any time and shows the model list.

Where the key lives:

  • In the browser only. It is stored in this browser's localStorage under freezmo.settings.v1 and sent with each API call in the x-ollama-key header.
  • Never on the server: the route handlers read the header, use it for that one request and drop it. It is not written to .env.local, a database, a cookie, a log line or a JS bundle. Restarting the server does not remember any key, and every browser/device needs its own (or the same) key.
  • You can remove it any time with Remove key from this browser.
  • Because the browser hands the key to your own server, use HTTPS if the LAN is not trusted — on a plain-HTTP LAN somebody sniffing traffic could read it. The key never goes to any third party.

Keys are verified with a real (tiny, 1-token, 5-minute-cached) chat request, because model listing on ollama.com is public and would happily accept a bogus key.

Mode 2 — local daemon (no key at all)

Runs cloud models through your local Ollama daemon, signed in once with ollama signin.

ollama                              # start the app/daemon
ollama signin                       # approve the printed link in any browser
ollama pull gemma4:31b-cloud        # vision model, reads the photos of your notes
ollama pull gpt-oss:120b-cloud      # text model, builds the activities and grades

.env.local:

OLLAMA_AUTH=local
OLLAMA_BASE_URL=http://localhost:11434
VISION_MODEL=gemma4:31b-cloud
TEXT_MODEL=gpt-oss:120b-cloud

Restart npm run dev after editing .env.local. Verify with the ⚙️ settings dialog in the app or curl localhost:3207/api/health.

Free tier. gemma4:31b-cloud and gpt-oss:120b-cloud are included in the Ollama Free plan (verified October 2026). Other free models: gpt-oss:20b-cloud, nemotron-3-super:cloud, nemotron-3-nano:30b-cloud. Models such as glm-5.3-flash:cloud, deepseek-v4.1-flash, minimax-m2.7 and mistral-large-3 reply with "this model is not included in your free usage…" — Freezmo detects that (HTTP 402) and says so instead of pretending the key is broken.

Mode 3 — one key for the whole server

OLLAMA_AUTH=server
OLLAMA_BASE_URL=https://ollama.com
OLLAMA_API_KEY=your_key_here

The key stays in .env.local (gitignored) and is never sent to the browser. Requests from any device use it.

Installing without root (user-space)

The official installer needs root. If there is no sudo on the machine (like the box Freezmo was built on), Ollama can be installed entirely under your home directory:

cd ~ && mkdir -p .local/ollama && cd .local/ollama
# grab the tarball for your arch from https://github.com/ollama/ollama/releases/latest
# (ollama-linux-amd64.tar.zst) and extract it here, so you end up with
#   ~/.local/ollama/bin/ollama  and  ~/.local/ollama/lib/ollama/
# No zstd CLI on Debian? Stream it with Python's zstandard module instead of tar --zstd.

export PATH="$HOME/.local/ollama/bin:$PATH"   # add to ~/.profile too
ollama serve &                                 # daemon on 127.0.0.1:11434
ollama signin
ollama pull gemma4:31b-cloud && ollama pull gpt-oss:120b-cloud

On the development machine the daemon runs as a systemd user service (~/.config/systemd/user/ollama.service):

systemctl --user status ollama      # is it up?
systemctl --user restart ollama     # restart it
journalctl --user -u ollama -f      # logs

If the user has lingering enabled (loginctl show-user $USER | grep Linger) the service also starts at boot with nobody logged in; otherwise it starts when you log in. Use the system-wide installer if you want a boot service without a login session.

Changing models

Any model that your Ollama instance exposes works. Edit .env.local:

VISION_MODEL=<model used to transcribe the photos>
TEXT_MODEL=<model used to build activities and grade short answers>

Current cloud models that work well (verified against the Ollama cloud in October 2026):

Purpose Model Free tier? Notes
Vision (default) gemma4:31b-cloud ✅ Native multimodal, 256K context, current generation
Text (default) gpt-oss:120b-cloud ✅ OpenAI open-weights, excellent at strict JSON, quick
Text, light gpt-oss:20b-cloud ✅ Smaller/faster, slightly weaker
Text, cheap nemotron-3-super:cloud ✅ 120B MoE with 12B active, very inexpensive
Text, smallest nemotron-3-nano:30b-cloud ✅ Lowest cost option
Text, faster glm-5.3-flash:cloud 💳 credits 18B active, 1M context, very fast
Text, best quality kimi-k3:cloud 💳 credits Strongest open-weight model, slowest/most expensive
Vision, larger mistral-large-3:675b, deepseek-v4.1-flash 💳 credits Multimodal alternatives

The previously suggested qwen3-vl:235b-cloud has been retired. The current defaults — gemma4:31b-cloud (vision) and gpt-oss:120b-cloud (text) — are both free-tier friendly and were verified end-to-end on a real photo of notes. The full list is one click away in the settings dialog or at https://ollama.com/search?c=cloud.

If your vision model is also good at generation you can point both variables at the same model — the two pipeline steps stay separate either way.

Checking the connection

  • In the app: click the ⚙️ button — it shows mode, base URL, API key status, latency and whether each configured model is available, with a hint for what to fix. On phones the sheet is simplified: a Connection card with one status row (dot + “Connected · 2 models ready”) and the key field, the raw health details live behind a Details expander, and the key can be removed from a small danger button at the very bottom.
  • From the shell: curl http://localhost:3207/api/health
  • Directly against Ollama: curl http://localhost:11434/api/tags

Rate limits

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
POST /api/homework/refine 30 per 5 minutes RATE_LIMIT_REFINE
POST /api/chat, POST /api/chat/extract 60 per 5 minutes RATE_LIMIT_CHAT
GET /api/health 90 per 5 minutes RATE_LIMIT_HEALTH
POST/GET/DELETE /api/sync 60 per 5 minutes RATE_LIMIT_SYNC
GET/POST/DELETE /api/push, POST /api/push/test 30 per 5 minutes RATE_LIMIT_PUSH
window for all of them 5 minutes RATE_LIMIT_WINDOW_MS

When a limit is hit the route answers 429 with a Retry-After header and a friendly message (“Too many requests — try again in 42s”); the UI shows that message and keeps the Retry button. The client IP is taken from x-forwarded-for / x-real-ip when present (reverse proxy), otherwise all local clients share one bucket.

Caveats, stated honestly: counters live in memory, so they reset on restart and are not shared between multiple instances or Node workers. That is fine for a self-hosted single-instance app; if you put Freezmo behind a reverse proxy, keep the proxy's own limits in place too.

Chat has its own allowlist: CHAT_MODELS (comma-separated) lists what the model chip may offer, with the first entry as the default; when unset it falls back to TEXT_MODEL and VISION_MODEL. The server rejects any model name outside that list.

Model names are normalised automatically: when the base URL points at ollama.com, Freezmo strips the local routing suffix (gemma4:31b-cloud → gemma4:31b, gpt-oss:120b-cloud → gpt-oss:120b) because the cloud API expects plain library names, while a local daemon wants the -cloud form. The same .env therefore works in every mode.

Running on your network / from your phone

The dev and start scripts already bind to 0.0.0.0 on port 3207:

npm run dev     # next dev -H 0.0.0.0 -p 3207
npm start       # next start -H 0.0.0.0 -p 3207
  1. Find your machine's LAN IP: hostname -I (Linux) or ipconfig getifaddr en0 (macOS).
  2. On your phone open http://<your-lan-ip>:3207.
  3. Allow port 3207 through the firewall if needed (e.g. sudo ufw allow 3207/tcp, or the equivalent Windows rule).
  4. Ollama only needs to be reachable from the Next.js server, not from the phone.

Notes for phones:

  • The camera button works over plain HTTP because it uses <input type="file" capture="environment">, not getUserMedia (which would require HTTPS). Tapping Camera opens the phone's camera app and the photo comes back as a file.
  • Clipboard paste works best on desktop; on mobile use the camera or photo picker.
  • Add the page to your home screen for a full-screen, app-like experience.

Keep it running as a service (systemd user service)

deploy/freezmo.service runs the production build under systemd (user), so Freezmo comes back by itself after a reboot and restarts if it ever crashes:

npm run build                                 # the service serves the built app
mkdir -p ~/.config/systemd/user
cp deploy/freezmo.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now freezmo.service
loginctl enable-linger "$USER"                # start at boot without logging in
Command What it does
systemctl --user status freezmo is it up?
journalctl --user -u freezmo -f live logs
systemctl --user restart freezmo after a rebuild
systemctl --user stop freezmo stop it

It binds 0.0.0.0:3207 exactly like npm start and reads .env.local from the project directory. The unit refuses to start without .next/BUILD_ID, so run npm run build before enabling it — and again after pulling changes.

Because the build and the running server share .next/, stop the service before a manual rebuild:

systemctl --user stop freezmo && npm run build && systemctl --user start freezmo

Folders

Decks are organised into folders — the same way you'd file paper notes.

  • Unorganized is where every deck starts. It is virtual: it simply means "no folder yet".
  • Create a folder from the library's folder bar (New folder) or from the create screen's Freeze into folder row. Names are unique, case-insensitive, and can be renamed later.
  • Freezing a deck into a folder: pick the folder before generating (create screen) or with the folder row on the create screen; or use a deck's ⋯ menu → Folder chips afterwards. The library shows which folder each deck is in, and groups the list by folder.
  • Filtering: the folder bar filters the library to one folder; All decks shows every folder as its own section, Unorganized last, with per-folder counts on each chip.
  • Deleting a folder never deletes decks — they move back to Unorganized. The confirmation says exactly how many decks will be re-homed.
  • Folders live beside the decks in localStorage (freezmo.folders.v1), so they are per-browser and need no account. Exporting a deck records its folder name, and importing recreates or reuses that folder.

Install as an app (PWA)

Freezmo ships as an installable Progressive Web App with iOS in mind.

Phones and tablets get an "Install the app" bottom sheet about a second after load (never on desktop, never when already installed, and at most once per 7 days after a dismissal). It is a native-feeling sheet with the Freezmo card, numbered steps and chips for the exact buttons to tap, plus an accent confirmation pill. Close it with the X, the backdrop, Escape, or a swipe down — the site keeps working normally. On iOS the steps adapt to the browser (Safari, Chrome, in-app), and on Android Chrome a captured beforeinstallprompt turns into a real Install button. The sheet can be reopened any time from Settings → Install app (mobile only).

It launches full-screen in standalone mode (no browser chrome), respects the notch/home-indicator safe areas, and has an iOS-style bottom tab bar (Library · Progress · New deck · Exam · Settings) with sheet-style dialogs. Android Chrome also offers "Install app" from the ⋮ menu; Freezmo works offline for the parts that don't need a model.

What's included:

Piece Detail
app/manifest.ts id, display: standalone, portrait, theme/background colours, any + maskable + monochrome icons, three app shortcuts (New deck, Make my homework, Library) and a share target (text/URL via GET, files via POST)
Icons 192/512 px, a maskable 512 px icon, a monochrome SVG and 180/167/152 px apple-touch-icons, all rendered from assets/app-icon-source.jpeg
iOS meta apple-mobile-web-app-capable, -title, -status-bar-style, viewport-fit=cover, plus startup images for the common iPhone sizes so launch has no white flash
Service worker public/sw.js, production only: cached app shell, stale-while-revalidate for static assets, network-first navigations with an /offline fallback. /api/* is never cached
Offline page /offline — "your decks are still here, reconnect to generate"
Safe areas pb-safe / pt-safe utilities plus pb-tabbar so nothing hides behind the tab bar
Share target /share chooser (New deck / Make my homework); POSTed files wait in a short-lived in-memory store, are handed to the wizard through sessionStorage, and are never written to disk

The icons are generated by scripts/make-icons.mjs from assets/app-icon-source.jpeg (headless Chromium); it is a one-off tool and is not part of the app's dependencies. It also writes app/favicon.ico, the Next.js file convention for the browser tab.

Security notes

An honest review of what Freezmo does and does not defend against. It is a single-user, self-hosted study app with no accounts, no database and no server-side state.

What is hardened

  • No secrets on the client. The API key lives only in the browser's localStorage and travels in the x-ollama-key header per request. Route handlers use it for that one call and discard it — no disk, no log, no cookie, no environment write. Verified by grepping the whole project and the production .next output for a test key, and by restarting the server (which remembers nothing).
  • No server-side injection surface. The Ollama endpoint and model names come only from the server's .env; the client cannot point the server at another host. Model output is validated with zod and rendered as text — there is no dangerouslySetInnerHTML except two static inline scripts (the pre-paint theme choice and the beforeinstallprompt capture), so a deck cannot inject markup or script (React escapes everything).
  • Key-header sanitisation. Keys from the client are stripped of control characters/whitespace and capped at 512 chars before they are used in an outbound header, so a crafted key cannot inject headers. Verified with a raw-socket CRLF attempt.
  • Cross-origin requests are refused on /api/generate and /api/grade (Origin must match the host), so a random website in your browser cannot make your Freezmo spend your rate limit or, in server auth mode, your credits. Origin-less clients (curl) still work.
  • Body-size limits (32 MB for generation, 64 KB for grading) are rejected with 413 before any parsing, on top of the zod limits on image count, text length and field sizes.
  • Rate limiting per client IP; see Rate limits. Auth is checked before parsing request bodies, so unauthenticated traffic costs almost nothing.
  • No CSRF surface: no cookies, no sessions, no server-side mutations.
  • Prompt injection is low impact by design. Your notes could contain text that tries to manipulate the model, but the model can only return a JSON deck (validated per activity, dropped if invalid) — it has no tools, no file access and no network access beyond the model call.

Known limitations (by design)

  • The key travels from your browser to your own Freezmo server, so on an untrusted network it needs HTTPS to stay private. On a plain-HTTP LAN someone sniffing traffic could read it, along with your notes.
  • Anything stored in localStorage is readable by JavaScript on the same origin. Freezmo has no XSS vector today, but that is the standard risk of browser-stored credentials.
  • Rate limiting trusts x-forwarded-for when present (correct behind a proxy, spoofable when exposed directly) and keeps counters in memory: single instance only, reset on restart.
  • Ollama cloud usage is billed to whoever's key is used, so treat a shared Freezmo URL as shared key access in server mode.
  • No accounts means no per-user isolation: anyone who can reach the URL can use the app (and, in user-key mode, only with their own key).

Supported input formats

Everything is parsed client-side, so documents are never uploaded — only the extracted text and page images are sent to /api/generate.

Format What Freezmo reads Sent to the model as
JPG / PNG / WEBP / HEIC / GIF the photo itself, resized to 1600 px JPEG images (vision model)
PDF, text layer text of every page (up to 80 pages) text
PDF, scanned / image-only pages with little or no text are rendered to images images (vision model)
DOCX document text + embedded images text + images
PPTX slide text + speaker notes + slide images text + images
ODT / ODP / ODS text from the OpenDocument content.xml text
TXT / MD / CSV / TSV / LOG the file's text text

Limits and behaviour worth knowing:

  • up to 6 documents and 8 photos per deck, 40 MB per document, 20 MB per photo
  • at most 12 images are sent (photos, rendered scanned pages and images embedded in documents)
  • each document is capped at 60 000 characters and the merged text at 100 000; longer documents show a “truncated” note in the UI
  • the activity generator reads up to 48 000 characters of notes per deck — plenty for lecture notes, but a whole book will only be sampled
  • password-protected or corrupted files report a clear error on their card and can be removed

How it works

 photos + text                                   /api/generate
 ─────────────┐
              ▼
   ┌──────────────────────────┐   step 1 (if images)   ┌───────────────────────────┐
   │ browser: resize/compress │ ─────────────────────► │ VISION_MODEL              │
   │ max 1600px JPEG data URL │                        │ transcribe + extract      │
   └──────────────────────────┘                        │ notes/concepts/formulas   │
              │                                        └────────────┬──────────────┘
              │ NDJSON progress stream                              │ clean structured text
              ▼                                                     ▼
   ┌──────────────────────────┐                        ┌───────────────────────────┐
   │ browser: study feed      │ ◄───────────────────── │ TEXT_MODEL                │
   │ JSON validated by zod    │   deck JSON            │ design 8–15 activities    │
   └──────────────────────────┘                        └───────────────────────────┘
  • The prompts live in lib/prompts.ts. They instruct the model to use only the student's notes, never invent facts, vary the activity types, write a short explanation for each activity and answer with JSON only.
  • Every response is parsed (markdown ``` fences stripped), validated with zod (lib/schemas.ts) and — if validation fails — automatically retried once with the validation error fed back to the model (lib/ai.ts).
  • Individual broken activities are dropped rather than failing the whole deck; if fewer than half the requested activities survive, the step is retried too.
  • Requests use stream: false with per-request timeouts and a hard overall deadline (two sequential model calls). Errors surface as friendly messages ("Ollama isn't reachable, check your settings") with a retry button.
  • POST /api/generate answers with newline-delimited JSON (stage / deck / error events) so the UI can show real progress between the vision and generation steps.

Chat

Chat mode is an LLM-style conversation that knows your notes — ask about a deck, get quizzed, or have a step explained without leaving the app.

  • Entry points. The Library header has Ask (a labelled button on desktop, an icon on phones), every deck's menu has Ask about this deck (opens scoped to that deck), the desktop sidebar has a Chat row, and study/homework results have Ask a question (the compact result travels along as extra context). On phones the chat is a full-screen route with a back button and New chat — the tab bar steps aside so the composer gets the space. Desktop keeps the sidebar.
  • Grounded answers, retrieved in the browser. Chat reads each deck's stored notes text (sourceText — the same text Regenerate uses, already kept and size-capped per deck). The client chunks the scoped decks (~800 tokens per chunk), ranks the chunks against your question and the recent turns with a small BM25 scorer, and sends only the top chunks (~7k tokens, each labelled with its deck and section). The server stays stateless: the chosen context and the conversation window travel with every request, exactly like the API key. Decks without notes text fall back to their generated study content, and the chat says so.
  • Scope. The context chip picks All notes, selected folders/decks or No notes (general chat); opening from a deck defaults to that deck. A Settings toggle (Keep notes text for chat) makes chat ignore the notes text, and each deck's menu has Delete notes text to remove it from that deck entirely.
  • Models. The model chip lists the server's allowlist (CHAT_MODELS, defaulting to the configured text and vision models) with text/vision badges and availability from the health check; the last choice is remembered. The server validates every request against the same allowlist and never accepts a model name from the client.
  • Attachments. Photos, PDFs and documents go through the existing vision/extraction step first (plain text), so any chat model works — text-only included.
  • History. Conversations live in a local IndexedDB store: a list on the left on desktop, a sheet on phones, with auto titles from your first question, rename, delete (with Undo) and a search across titles and message text. Conversations remember their scope and model, reopen from ?c=<id> (so a reload continues where you left off), stay readable offline, and are included in the backup export and the encrypted sync — still never the key.
  • From an answer. Every answer can become a deck (Turn into flashcards opens New deck with the answer as notes, on the options step) or be appended to the scoped deck's notes (Save to notes, only offered when exactly one deck is in scope).
  • Streaming and controls. Tokens stream with a caret and a stop button; auto-scroll pauses when you scroll up and a jump-to-bottom button appears; answers render safe markdown (lists, tables, code blocks with copy — no HTML injection) and KaTeX loads dynamically only when a message contains math; reasoning models' thinking is hidden behind a collapsible Thinking row and never mixed into the answer. Every answer offers copy / regenerate, the last question edit and resend. The composer auto-grows at 16px, sends on Enter on desktop (newline on phones), tracks the on-screen keyboard through visualViewport and respects reduced motion.
  • Privacy. Notes text and conversations stay in this browser; nothing is stored server-side. The API key still only ever travels in the x-ollama-key header to Freezmo's own route for that single request.

Project structure

app/
  layout.tsx                 root layout, iOS meta tags + theme bootstrap script
  manifest.ts                web app manifest (installable PWA)
  offline/page.tsx           offline fallback served by the service worker
  globals.css                Tailwind v4 theme, frosty palette, animations, reduced-motion
  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)
  chat/page.tsx              chat mode (full-screen on phones, sidebar on desktop)
  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/chat/route.ts          grounded chat turns → NDJSON token/thinking stream
  api/chat/extract/route.ts  chat attachments → plain text (vision step)
  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)
  chat/                    chat screen, composer, message rows, markdown + math, pickers
  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, 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
  folders.ts       folder library (create/rename/delete) in localStorage
  install.ts       install-prompt platform/browser detection + 7-day dismissal
  settings.ts      browser-side settings (the user's API key) + auth headers
  use-health.ts    client hook for /api/health, re-runs when the key changes
  rate-limit.ts    in-memory sliding-window limiter for the API routes
  http-guard.ts    same-origin check, body-size cap, API-key sanitiser
  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)
  chat.ts          chat types + last-model memory
  chat-context.ts  client-side chunking + BM25 retrieval over the scoped decks
  chat-client.ts   streaming chat client + attachment extraction
  chat-seed.ts     one-shot hand-off from results into the chat
  chat-trim.ts     token budgeting + the sliding history window (server + client)
  xp.ts utils.ts deck-ops.ts confetti.ts
public/
  sw.js                  service worker (production only)
  icons/                 PWA + iOS icons and splash screens
assets/
  app-icon-source.jpeg   master artwork the icons are rendered from
deploy/
  freezmo.service       systemd user unit (auto-start at boot)
scripts/
  copy-pdf-worker.mjs   copies the pdf.js worker to /public (runs on predev/prebuild)
  make-icons.mjs        regenerates the PWA/iOS icons from the master (one-off tool)

API routes

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" | "check", images?: string[], text?, solution?, language? } NDJSON stream: {type:"stage"}, {type:"homework",homework,meta}, {type:"error"}
POST /api/homework/refine { instruction: "shorter" | "more" | "explain", text, context?, language? } { text } — one rewritten piece, plain text
POST /api/grade { question, referenceAnswer, keyPoints, userAnswer, language? } { correct, score, feedback, missing }
POST /api/chat { model?, messages: [{role,content}], context?: [{deckId,deckTitle,section,text}] } NDJSON stream: {type:"token"}, {type:"thinking"}, {type:"done",model}, {type:"error"}
POST /api/chat/extract { images?: string[], text? } { text } — attachments reduced to plain text via the vision step
GET /api/health – { ok, reachable, mode, baseUrl, hasApiKey, vision, text, chatModels, latencyMs, modelCount, error }
POST /api/sync { id, envelope } (opaque AES-GCM blob) { updatedAt } — 413 over SYNC_MAX_BYTES, 507 over SYNC_TOTAL_BYTES
GET /api/sync?id= – { updatedAt, envelope } or 404
DELETE /api/sync?id= – { ok: true } (revokes the synced copy)
GET /api/push – { enabled, publicKey? } — reminders are hidden when enabled is false
POST /api/push { endpoint, keys } { ok, count } (stored server-side, endpoint + keys only)
DELETE /api/push?endpoint= – { ok, count }
POST /api/push/test { endpoint } { ok: true } or 502 when the push service refuses

The model 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.

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 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).


Scripts

Script What it does
npm run dev next dev -H 0.0.0.0 -p 3207
npm run build production build (type-check + lint)
npm start next start -H 0.0.0.0 -p 3207
npm run lint ESLint
node scripts/make-icons.mjs regenerate the PWA/iOS icons (needs a Playwright Chromium)
node scripts/copy-pdf-worker.mjs copy the pdf.js worker (also runs on predev/prebuild)

Data, storage and privacy

  • Decks live in localStorage under freezmo.decks.v1, folders under freezmo.folders.v1 (no accounts, no server database). Stored per deck: title, subject, summary, language, difficulty, the extracted source text, activities, play count and best score.
  • Original files are never stored or uploaded — photos and documents are parsed in the browser, and only the extracted text (plus page images for the vision model) is sent to your Ollama endpoint. That is why regenerate and add more activities work from the source text rather than the original files. Imported decks that contain no source text can't be regenerated; the UI says so.
  • Documents are parsed locally by pdfjs-dist (PDF), mammoth (DOCX), jszip (PPTX/OpenDocument) and the browser's own File.text() — nothing is uploaded for parsing.
  • Images and rendered PDF pages are sent to whatever Ollama endpoint you configure. In local-daemon mode they go to your machine first; in direct-cloud mode they are processed by Ollama's cloud.
  • In the default user-key mode your Ollama API key is also kept in localStorage (key freezmo.settings.v1) and sent with each API call so the server can forward it to Ollama. It is never written to the server, a log, a cookie or the environment — see Ollama setup for the full story.
  • Clearing browser data deletes the library and the saved key. Use Export all data (Settings → Data & backup) for anything you want to keep; the file never contains the API key.
  • Opt-in sync (freezmo.sync.v1 + a non-extractable CryptoKey in IndexedDB) uploads an AES-GCM envelope to POST /api/sync. The server writes one small JSON file per sync ID under SYNC_DIR (default .freezmo/sync), can't decrypt it, and never receives the passphrase or the key. Deleting the synced copy is one button in Settings (and works even on a device that can't unlock the data).
  • Answer history lives in IndexedDB (freezmo-learning, versioned stores answers + srs), because an event log would eventually fill localStorage; the log is capped at 20 000 answers (oldest pruned). Backups and sync include it, so progress survives a device move. Push subscriptions are the only reminder state on the server (PUSH_DIR, endpoint + keys only) and are removed when you turn reminders off — or automatically when the push service reports them gone.

Troubleshooting

Symptom Fix
“Ollama isn't reachable at http://localhost:11434” Start the daemon (ollama serve, or systemctl --user start ollama for a user-space install), or fix OLLAMA_BASE_URL. Check curl http://localhost:11434/api/tags.
A document card is stuck on “Reading page …” The pdf.js worker may be missing: run npm run dev/npm run build (they copy it to public/pdf.worker.min.mjs) or node scripts/copy-pdf-worker.mjs manually.
PDF uploaded but no activities about its content The PDF is probably image-only (a scan). Freezmo renders such pages and sends them to the vision model — check the card note (“N scanned pages were turned into images”); if it says “No readable text”, the scan may be too low quality.
Deck came back in English even though the notes are not Make sure both model calls succeed (a failed vision step falls back to raw text). The prompts force the notes' language; if you still see translation, switch TEXT_MODEL to a stronger instruction-follower such as gpt-oss:120b-cloud.
“You've run out of your free monthly Ollama usage” The account's free monthly usage is used up — this is not a short-term rate limit. Upgrade the account at ollama.com/settings, or create a new account and paste its key in Settings (the button jumps straight to the field). “Ollama is busy” is the short-term variant: just press Retry.
Reminders are missing from Settings They only appear when VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY are set in .env.local (generate with npx web-push generate-vapid-keys). On iPhone, add Freezmo to the Home Screen first — Safari tabs can't receive push.
“Add your Ollama API key first” Bring-your-own-key mode is active. Paste a key from https://ollama.com/settings/keys on the key gate (or in the ⚙️ dialog) — it is stored only in your browser.
“Ollama rejected this API key” The key is invalid or revoked. Create a new one and paste it again; the check uses a real 1-token request, so a green tick means it truly works.
“this model is not included in your free usage” The key works, but the model is outside your plan. Use one of the ✅ models in the table above, or add credits at https://ollama.com/settings.
“Too many requests — try again in Ns” You hit a rate limit (12 decks / 40 grades per 5 min by default). Wait, or raise RATE_LIMIT_* in .env.local.
“set to OLLAMA_AUTH=local but points at https://ollama.com” Fix the combination: use OLLAMA_AUTH=user-key/server for the cloud URL, or point OLLAMA_BASE_URL at http://localhost:11434.
Settings dialog says a model is “not found” Local daemon: pull it once — ollama pull gemma4:31b-cloud (cloud pulls are just manifests). Direct cloud: check the model name and that your key is valid.
401/403 from https://ollama.com Create a key at https://ollama.com/settings/keys and put it in OLLAMA_API_KEY (no quotes), then restart.
Daemon logs / it keeps dying User-space install: journalctl --user -u ollama -f. Manual start: ~/.local/ollama/bin/ollama serve.
Generation times out Use fewer/smaller photos, choose a faster TEXT_MODEL (e.g. gpt-oss:20b-cloud), or raise OLLAMA_TIMEOUT_MS.
Responses are slow Thinking models spend tokens on reasoning. Set OLLAMA_THINK=false to send think: false, and/or use a smaller model.
“did not match the expected format” Freezmo retried once automatically. If it still fails, the model is poor at JSON — switch to a stronger TEXT_MODEL (e.g. gpt-oss:120b-cloud).
Blank/boxed icons on Linux The app chrome uses inline SVG, but activity badges use emoji. Install an emoji font (fonts-noto-color-emoji).
Port 3207 already in use lsof -i :3207 and stop the other process, or change the port in both scripts in package.json.
Camera button missing on mobile It's in the Add your notes card; some in-app browsers hide file inputs — open the page in Chrome/Safari directly.
Hydration warning in dev Usually a stale dev artifact: hard-reload. Decks load in useEffect/useSyncExternalStore, so no library content is server-rendered.

Decisions and notes

Things I chose while building, with the reasoning:

  1. Defaults follow the current Ollama catalogue and the free tier. The suggested qwen3-vl:235b-cloud has been retired, so the defaults are gemma4:31b-cloud (vision) and gpt-oss:120b-cloud (text/JSON/grading) — both verified as included in the free plan on a real photo of notes. Paid/credit models such as glm-5.3-flash:cloud and kimi-k3:cloud are documented as alternatives.

  2. One .env for both modes. Direct-cloud names differ from daemon names (gemma4:31b vs gemma4:31b-cloud), so lib/config.ts strips the -cloud / :cloud suffix automatically when OLLAMA_BASE_URL points at ollama.com.

  3. Two separable AI steps. Photos always go through VISION_MODEL for transcription/extraction; the structured text then goes to TEXT_MODEL. Pointing both variables at one model works too. Text-only requests skip the vision step entirely.

  4. NDJSON progress stream instead of a single long request. The two model calls are sequential and slow, so /api/generate streams stage events; the UI shows real stages rather than a fake timer. stream: false is still used for each Ollama call for simplicity and reliability.

  5. zod + one repair retry, plus per-activity salvage. A single bad activity shouldn't cost you the whole deck: valid activities are kept, and the repair round is only used when too few survive.

  6. Flashcards count as "correct" via self-assessment (I knew it / Not yet) so that scoring, XP and "retry missed ones" apply to every activity type.

  7. Fill-in-the-blank is graded locally (case/punctuation-insensitive, one typo forgiven, / alternatives in the answer) — no round trip needed. Only short answers call /api/grade.

  8. Matching requires a perfect match for full XP, otherwise it's marked as missed with an explanation of how many wrong attempts you made.

  9. Source text is stored with each deck so regenerate and add more work without keeping the original photos (storage/privacy win). Imported decks have no source text, so those two actions are disabled for them.

  10. XP is assigned server-side (easy 10 / medium 15 / hard 20, +5 for matching, ordering and short answer) so the model can't invent inconsistent rewards.

  11. Documents are parsed in the browser, not on the server. pdf.js, mammoth and jszip run client-side, so the raw file never leaves the device and the API stays a thin text/image pipe. Scanned PDF pages are rendered to JPEG for the vision model, while PDFs with a text layer skip the vision step entirely (faster and exact).

  12. The notes' language is detected once and then carried everywhere. The vision step reports a language field, the deck stores it, and generation, grading, regenerate and add-more all receive it explicitly — plus “never translate” rules in every prompt. Verified with Czech notes across all three input paths (text PDF, scanned PDF, DOCX + PPTX).

  13. Keys are per browser and never stored server-side. Freezmo defaults to OLLAMA_AUTH=user-key: the key is typed into the UI, kept in localStorage and sent in the x-ollama-key header, and the route handlers use it for that request only — nothing is written to disk, logs, cookies or the environment (verified by grepping the whole project for a test key and by restarting the server). server and local modes remain available by env var.

  14. Key verification uses a 1-token chat probe, cached for 5 minutes. Model listing on ollama.com is public, so a bogus key would look valid; only a real authenticated request can tell. HTTP 402 (“not included in your free usage”) is reported as a plan problem rather than a broken key.

  15. Rate limits are per client IP, in memory, and generous by default (12 decks / 5 min, 40 grades / 5 min). They exist to stop runaway loops and accidental abuse, not to be a security boundary — the README says so, and the counters are intentionally not persisted.

  16. A misconfiguration is caught early: OLLAMA_AUTH=local combined with https://ollama.com returns an explanatory error instead of a confusing 401.

  17. Folders are a first-class, non-destructive idea. UNORGANIZED_ID is virtual (decks simply have no folderId), deleting a folder re-homes its decks instead of deleting them, and folder names travel in exports so an imported deck lands where it came from. 13 browser checks cover create → freeze → move → rename → delete → reload → import.

  18. The visual language is frosted ice glass. Glacier cyan to ice blue (#38bdf8 → #0ea5e9) is the accent for focus rings, progress bars and selected states; the primary CTA is an icy gradient (deep blue in light, white-to-#bfe3ff in dark). The background is a fixed ice scene; the sidebar, cards, chrome, sheets and gate are translucent glass with a bright rim and a soft blue shadow. Colour appears in activity tints and semantic feedback only — pretty, but still readable and quiet.

  19. Custom CSS lives in @layer components, and glass uses Tailwind's backdrop utilities. Two build-level traps, both found by looking at real screenshots: unlayered rules beat Tailwind utilities (which silently broke fixed on the feed), and a hand-written backdrop-filter gets minified down to -webkit- only — which painted no blur at all. @apply backdrop-blur-* emits both properties correctly.

  20. There is no demo/sample deck. Every activity comes from the user's own notes; the empty state points at the create flow and Settings instead of shipping fake content. Browser tests seed a fixture deck into localStorage rather than relying on app code.

  21. Dark mode is token-driven, and contrast is audited on glass. Text colours come from --text / --text-muted / --text-subtle (never hardcoded), and a contrast.mjs audit checks every pairing at WCAG AA against the effective frosted background — currently 18/18 pairs pass (e.g. dark secondary #b3c4e0 on glass #1c2535 = 10:1, white on the light CTA mid-gradient #1a5fd0 = 5.6:1). Difficulty colours were darkened until they cleared 4.5:1 on their pills. This was a real bug: an earlier edit silently failed, so .dark never overrode --text, leaving near-black text on a dark page.

  22. The dashboard is one inset glass rail plus glass cards. The sidebar is a single tall rounded glass panel inset from the viewport edges, so the ice scene shows around it; deck cards are 24px-radius glass panels with a secondary Study CTA and quiet metadata. Headings are semibold with tighter tracking. Nested glass (chips, inputs) is more opaque but never adds a second blur layer.

  23. One fixed ice scene instead of per-page blobs. The decorative background used to be rendered inside the content column, so on wide screens it was clipped into a visible hard edge. It now lives in a single position: fixed layer (.ice-scene, z-index: -1, pointer-events none): base gradient, four drifting glacier blobs, five glossy ice orbs and an SVG-noise grain. prefers-reduced-motion stops the drift.

  24. The UI was deliberately made quieter. One accent colour (accent-*), a desaturated frost-* scale, hairline borders and flat surfaces replaced the saturated gradients and the seven-colour activity badges. Semantic colours (green/amber/red) survive only where they carry meaning: correct/wrong feedback, key status, rate limits.

  25. PWA instead of a native wrapper. A manifest, iOS meta tags, generated icons, a production-only service worker, an offline page and a standalone-aware layout give the install-to-home-screen experience without adding a runtime dependency — and the app still works as a plain website.

  26. The API is defended like a LAN service. Cross-origin POSTs are refused, bodies are capped before parsing, keys are sanitised before they reach a header, and auth is checked before any body work. See Security notes for the full list and the known limitations.

  27. A language guard repairs accidentally translated transcriptions. TEXT_MODEL (gpt-oss:120b) is the strongest multilingual model in the free tier, so when the transcription check (lib/language.ts: expected script vs. actual script, distinctive diacritics, English stopword density) flags a mismatch, the text model rewrites the JSON back into the original language before generation. Verified both ways: it fires on a synthetic English-but-declared-Czech transcription and never fires on real Czech notes (no extra calls, no added latency).

  28. The first screen is the API-key gate. In user-key mode a browser without a key gets a full-screen, monochrome gate instead of the dashboard: one masked input, one Continue button, and a "?" sheet with the steps to a free key. The gate reuses the cached 1-token probe through /api/health, so an invalid key and an unreachable Ollama are worded differently. The old dashboard banner is gone; key management stays in Settings, and removing the key brings the gate back. The gate covers /, /create and deck study, but never /offline.

  29. The install prompt is a teaching sheet, not a nag. It appears once, phones/tablets only, a second after load, and never in standalone mode; dismissing it silences it for 7 days. It detects iOS vs Android and Safari vs Chrome vs an in-app browser so step 1 always matches the device, uses Chrome's deferred beforeinstallprompt for a one-tap install where available, and stays reachable from Settings. It is mounted in the root layout, so it can appear over any screen — including the key gate — without blocking the page underneath.

  30. Phones use touch-action, not a zoom lock. The viewport allows pinch-zoom (accessibility), and double-tap zoom is disabled by touch-action: manipulation on the UI chrome instead. A mobile-only rule keeps every input and textarea at 16px so iOS Safari's focus auto-zoom never fires (iOS ignores user-scalable=no for that anyway). Desktop keeps the designed 12–14px field sizes.

  31. The app icon drives the palette. The navy rounded-square artwork in assets/app-icon-source.jpeg is the source of truth: frost-* is navy-tinted, the accent is glacier cyan, the ice scene uses the artwork's blues, the primary CTA is an icy gradient, and every icon/splash/favicon is rendered from it by scripts/make-icons.mjs. The sidebar, header, key gate, empty state and install sheet all show the real artwork instead of the old monochrome snowflake tile.

  32. The ice scene was profiled, and the blur moved off the animated layer. A mid-range phone viewport with 4x CPU throttling showed the drifting blobs were the only expensive part: a filter: blur(100px) on a huge element that is also being transformed forces a re-rasterisation every frame (150ms vs 33ms per frame in a software renderer). The blobs are now soft radial gradients with no filter — visually the same, 4-5x cheaper — phones get two blobs instead of four and a 16px (instead of 24px) glass blur, and nested glass never blurs. Result: 60fps idle and ~17ms median frames while scrolling under 4x throttle.

  33. Interface copy lives in two flat dictionaries, not in a framework. lib/i18n-dict.ts holds the en and cs tables plus translate() / translatePlural(); lib/i18n.ts adds the browser store (freezmo.locale.v1), the useI18n() hook and the x-freezmo-locale header that API routes read to answer in the same language. English is the default and the fallback for any missing key. Czech uses the real plural rules (1 sada / 2–4 sady / 5+ sad). Crucially, the hook's t is bound to the locale React is rendering with — the first client render matches the server HTML, then re-renders in the stored language; using the module-level t during render instead caused a hydration mismatch that silently dropped the dark class. Module-level t is only for event handlers and non-React helpers (documents, folders, API errors). The notes' own language is untouched by this — decks still come back in the language of your notes.

  34. The mobile pass was a layering perception fix plus a real contrast debt. The ice orbs were never above the content — the scene is a fixed pointer-events:none layer at z-index:-1 (verified with elementFromPoint), but it shines through the translucent page and glass. Phones now get a thinner scene (lower blob/orb opacity, orbs tucked to the edges, the bottom-right orb moved out from under the tab bar) and the tab bar paints color-mix(page 84%) so its labels keep their contrast. Helper text moved onto the tokens (--text-subtle is #9db1d0 in dark: 8.6:1 on the page, 7.2:1 on dark glass) and disabled buttons are a visible muted chip (.btn-disabled) instead of opacity-40. Buttons share one ice gradient in both themes — which also exposed that the danger/success variants never showed their colour, because the primary background-image painted over !bg-rose-600 (bg-none now strips it). Light mode got a cool card rim instead of white-on-white. The settings sheet swipes away like the install sheet (drag only while the body is scrolled to the top), its body scrolls to the true bottom with a fading scroll cue, and the API-key note is split into a short privacy line plus a collapsible local-daemon tip. axe-core reports every glass surface as “incomplete”, so contrast is verified by a pixel-sampled script (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.

  36. Check my work is a checker, not a second writer. The third homework mode sends the assignment and the student's attempt to one model call with its own system prompt (HOMEWORK_CHECK_SYSTEM_PROMPT): the output is a verdict, a list of what is right, a list of issues with why and fix, and the corrected version. Unasked-for extras are explicitly never errors, and the corrected version keeps everything the student got right as they wrote it. The attempt is typed or pasted — supporting photos of handwriting would need a second vision pass and a way to separate assignment from attempt in the same image, so the UI says plainly that it isn't supported yet. Follow-ups are a separate, cheaper route (POST /api/homework/refine, plain JSON, RATE_LIMIT_REFINE) shared by the result, the check and each guide step; they rewrite one piece and never touch the stored record. “Turn into a study deck” hands the content to the New deck flow through sessionStorage (one-shot, like the share target), with the options step pre-filled. Verified by the phase-3 suite (20 checks against a stub Ollama: the check payload, the missing-solution 400, refine validation, the phone and desktop check flows, the shorter follow-up with restore, the guide's explain action, and the deck hand-off).

  37. Phones navigate with five tabs, not a drawer. The drawer, its hamburger, its drag-to-close gesture and its back-gesture layer are gone (MobileSidebar, sidebarOpen, MenuIcon and the three drawer strings deleted); the desktop rail is untouched and is now the only consumer of the shared nav content. The bottom bar is Library · Progress · New deck (the prominent centre +) · Exam · Settings, keeping the sliding pill, press feedback, re-tap-to-top and safe-area padding. Tab labels use dedicated short tab.* keys because the old names (“Zkouškový režim”) can't fit five-up at 360px; the matrix in the mobile suite checks every label against clipping at 360/390, EN/CS, dark/light and with reduced motion on and off. The wizards keep their own bottom action bar, so the tab bar steps aside there, exactly as before. Drawer-only destinations were re-homed: Progress and Exam became tabs, the New deck screen got a Study deck ⇄ Make my homework segmented switch (also on /homework), and the library's phone row gained a ⋯ sheet with Import JSON + New folder (folder chips still appear only once a folder exists). The mode switch is hidden while either wizard is generating (lib/wizard-busy.ts), and leaving a wizard mid-run aborts the request so a stray generation can never save a deck or yank the user into the study feed. Verified by the mobile suite (134 checks: no hamburger/drawer, tab order, the label matrix, per-tab routing, deep links, Back from every tab and the ⋯ sheet) plus the wizard suite (92 checks, including the loading-lock and abort-on-leave cases) and the other standing suites.

  38. Smart Review must never look like a dead tap. The reported symptom was a tap on Review due sitting for many seconds with no feedback. The flow itself is local and fast (the counts are already hydrated on the library; measurements show <1.5s even at 6× CPU throttle with 3,000 answers), so the visible fault was the missing feedback: /review is a dynamic route (search params), and until its payload arrived the screen simply didn't change. Three layers now cover it: the card links prefetch their payload; the link's own useLinkStatus pending state renders a full-screen loading screen (LinkLoadingScreen) the instant the tap happens — measured at 58 ms even with the route request held open; and loading.tsx boundaries cover the render/stream phase (review, progress and exam). The client screen's own bare skeleton was replaced with the same loading screen. While in there, health probes got a per-key 30-second cache plus in-flight sharing (use-health.ts): every page mounts its own gate and each probe can sit for seconds while Ollama answers, so a session now fires one probe instead of one per navigation (verified). Also fixed a Czech typo on the exam heading (“Zkouškové režim” → “Zkouškový režim”). Verified by the review suite (15 checks: stalled-request loading screen at 58 ms, session arrival after release, the normal fast path, the mistakes path, end-to-end grading, and the probe cache).

  39. The browser suites live in the repo, not in /tmp. The harness was built over several sessions in a scratch directory that a reboot wiped; it now lives in tests/ and runs with npm run test:e2e against the running production build (Playwright + axe-core as dev dependencies, Chromium auto-detected or via CHROME). Ten suites cover the core app (regression), the phone tab bar and sheets (mobile), the wizards (wizard), PWA/offline/share (native), i18n, the freeze loading screen, Smart Review, and phases 1–3 (backup/sync/usage limits, the learning engine, homework check/follow-ups). Suites that need a stub Ollama or VAPID keys start their own next start instance on a spare port, verify the port is free and kill the process group on exit; test scratch stays in tests/.tmp/ (gitignored). Twelve suites, 513 checks.

  40. The ice orbs are rising bubbles now, composited and cheaper to read through. The glossy orbs (the glacier blobs stay exactly as they were) rise from below the bottom edge to above the top edge, each with its own 36–68 s pass (slower on phones), a negative delay so the scene is already populated on load, its own horizontal position, a gentle sway and a slight scale drift; opacity fades in/out near the edges so the loop never pops. Only transform and opacity animate, each orb owns a composited layer (will-change: transform), there is no filter/backdrop-filter on them, and nothing animates size, offsets or shadow. Opacity dropped ~38% in both themes (0.55→0.34 light, 0.8→0.5 dark; phone overrides 0.32→0.2 / 0.42→0.26) so the background reads through while the gloss stays soft. Under prefers-reduced-motion the rise is removed and the orbs return to their old static placements — a paused-from-the-start CSS animation is not applied at all in Chrome (getAnimations() is empty), which would have parked them below the fold; while the tab is hidden a visibility listener parks the whole scene (.ice-paused), which also freezes the blob drift. Measured with a CDP performance trace on a 4× throttled phone profile (390×844 @2x): p95 33.4 ms, zero frames over 34 ms and no long tasks — identical to the pre-change baseline — for ~0.6% extra main-thread CPU; the desktop scene stays compositor-bound from the pre-existing blob drift, exactly as before. Verified by the bubbles suite (34 checks: scene stacking/layout contract, keyframes only transform+opacity, rise/sway/scale/fade phases, per-breakpoint counts 5/3/2, phone pacing, reduced motion, tab-hidden parking) plus the 16-run axe contrast matrix (360/390/1280 × dark/light × EN/CS). tests/bubble-perf.mjs keeps the measurement repeatable.

  41. Chat grounds on the notes that were already there, and the server stays stateless. The reconnaissance before this feature found the extracted notes text already kept per deck (sourceText, size-capped, used by Regenerate), so the "store it in IndexedDB" branch of the brief didn't apply — chat reads the deck record instead, with the generated study content as the documented fallback for old or imported decks. Retrieval is client-side and dependency-free: ~800-token chunks, BM25 (k1 = 1.2, b = 0.75, stopwords, diacritics folded) scored against the question plus the last turns, top chunks under a ~7k-token budget, each labelled with deck and section. The request carries the chosen context and the conversation window (like the key does); the server trims the window to ~6k tokens, caps the context at ~9k, validates the model against CHAT_MODELS and streams token / thinking / done / error NDJSON. Reasoning is a separate event, so it can be hidden behind the Thinking row and never mixed into the answer. Attachments reuse the vision step (/api/chat/extract) so text-only chat models work. Markdown is a small React-node parser (no HTML injection, no dependency); KaTeX is dynamically imported only when a message contains math and writes into its container imperatively — there is still no dangerouslySetInnerHTML anywhere in the app. Stopping mid-stream aborts the upstream call; the abort arrives as a reader rejection and is mapped back to a clean "stopped" state instead of an error card. Usage-limit failures keep the draft and show the shared message with Settings/Ollama actions in an inline banner. The Settings toggle "Keep notes text for chat" makes chat ignore notes text, and a deck's menu can delete the notes text outright. Verified by the chat suite (63 checks against a stub Ollama: validation and the allowlist, context travel, the history window, thinking separation, usage-limit mapping, attachment extraction, entry points, streaming, stop, markdown + KaTeX, the model and scope pickers, edit/regenerate, offline, attachments and reduced motion).

  42. Chat history is local, bounded and part of the backup/sync payload. Conversations live in their own versioned IndexedDB (freezmo-chat v1, store chats), validated with the same zod schema the backup and sync use (chatConversationSchema), capped at 100 conversations × 300 messages, with the in-memory mirror the other stores use. Every settled turn is persisted (the question always, the answer when one was produced — partial answers from a stop included); the URL carries ?c=<id> so a reload continues the conversation, and that URL tweak uses history.replaceState so it never triggers a router round-trip mid-chat. The list (desktop column, phone sheet) has auto titles from the first question, inline rename, delete with an Undo toast, and diacritic-folding search over titles and message text. Chats ride along in the backup file (the import preview counts them) and in the encrypted sync payload — still never the key. Assistant answers also offer Turn into flashcards (opens New deck with the answer as notes, landing on the options step) and Save to notes (appends to the single scoped deck, trimming at 40k characters). Motion comes from lib/motion.ts: message fade-up, sheet springs, a 150 ms composer height transition — all skipped under reduced motion. Verified by the chat suite (89 checks: everything from phase 1 plus history persistence, reload, rename/search/delete/undo, both actions, the export payload, a two-device sync round-trip of a chat, offline readability and the desktop panel).

  43. The mobile declutter pass reshapes, it doesn't fork. Phones (≤639px) get a 3-step deck flow and a 2-step homework flow that share the exact state and handlers with the desktop cards (one UploadScreen / one HomeworkInput, two layouts switched by useIsPhone()); desktop markup is untouched. Library cards collapse to title + one meta line + one description line (expand on tap) with the whole card tappable and a small Study pill, the stats line and folder row only appear once folders exist, and the settings sheet trades the health cards for a Connection status row plus a collapsed Details list. Decorative orbs were pushed further off the phone edges so they never glow under CTAs or cards.

  44. The animation pass is one shared language, and it stays off the desktop. lib/motion.ts owns the spring presets (snappy / gentle / sheet), durations and variants; components import them instead of ad-hoc numbers. Press feedback is CSS (a spring-like cubic-bezier on transform) scoped to ≤639px / pointer: coarse, so every control responds without per-component wiring. Sliding highlights use one layoutId per control. Route content animates on entrance only — the App Router unmounts the outgoing page, so exits are left to in-page AnimatePresence (list items, sheets). Only transform and opacity animate (plus the explicit expander heights); prefers-reduced-motion collapses motion to short fades. LazyMotion was skipped on purpose: the sliding highlights need layout animations (domMax), the same feature set the current motion import already pulls in. Verified with a 4x-CPU-throttled mobile trace (no long tasks, CLS 0) and a reduced-motion run.

  45. Wizard chrome lives outside the animated tree. The step name is the headline, the action bar is a flat full-width bar at the bottom (the tab bar hides inside the wizard), and the bar is rendered through a React portal to <body>. That last part is a real bug fix: the screen entrance animation puts a transform on the content wrapper, and a transform on an ancestor becomes the containing block for position: fixed, so the bar used to paint inside the content and jump to the viewport bottom a second later. It now only fades in, pinned to the bottom from its first frame — verified by sampling its position across the entrance (5 frames, 60–450ms) from the tab bar, the mode switch and the empty state, at 360/390, both themes, both languages and with reduced motion.

  46. Native feel is layered onto the platform, never around it. The Back gesture closes the topmost layer through a small history-entry stack (lib/use-back-close.ts): each layer pushes a same-URL entry, only the top of the stack reacts, and a layer closed by a navigation leaves its entry for the permanent listener to skip. Haptics are best-effort behind a Settings toggle (Vibration API; the hidden <input switch> tick on iOS, throttled); the wake lock is requested only while a study feed or guide is open and released on leave. Destructive-but-reversible actions (delete deck, remove file) became Undo toasts; irreversible ones keep their confirm. Wizard drafts and the last URL are per-browser; the share target hands POSTed files to the client through a ten-minute in-memory store so nothing touches disk. Verified by the native suite (35 checks: manifest/head/viewport, offline banner + disabled generate, share target GET and POST, back-gesture closing for sheet/wizard, undo toast, share fallback, tab re-tap, haptics persistence, draft restore) plus the six standing suites.

    Needs a real device to verify (emulation can't do these honestly): the iOS hidden-switch haptic tick, the Android/iOS share target entry in the system share sheet, the actual wake lock grant, the startup images on an installed iPhone, the iOS edge-swipe back gesture in standalone, and installability/rich-install prompts (Lighthouse covers the manifest side). Pull-to-refresh and the app badge were skipped on purpose — the Settings refresh icon already re-checks the connection, and there is no meaningful badge number.

  47. Backups are plain, sync is encrypted, and the key never leaves either. The backup file is the same zod-validated shape the sync payload uses (lib/backup.ts), which is why the import preview can count added/replaced items without touching the key — the key is simply not part of the shape. Sync adds PBKDF2-SHA256 (310k iterations) → AES-GCM on top, keeps the derived key as a non-extractable CryptoKey in IndexedDB, and lets the server be a dumb blob store: it stamps updatedAt (the conflict clock), enforces SYNC_MAX_BYTES / SYNC_TOTAL_BYTES and writes atomically (temp file + rename). A device that lost its stored key can still unlock (passphrase re-derivation) or revoke (DELETE) the synced copy. Monthly-usage exhaustion and short-term throttling are told apart by inspecting the real 429 body (classifyOllamaFailure) and mapped to usage_limit / throttled kinds — the client renders the same localized message in every model flow, with a jump to the key field and a link to Ollama, and keeps the user's input so Retry works right after swapping the key. Verified by the phase-1 suite (38 checks, including the real route mapping against a stub Ollama returning a monthly-limit body, and a two-device sync round-trip).

  48. The learning log is an event log, so it lives in IndexedDB. Decks and homework stay in localStorage (small, always loaded), but answers + SRS schedules go to a versioned freezmo-learning database with an in-memory mirror so React can read them synchronously via useSyncExternalStore; writes update the mirror first and persist asynchronously, and the log is capped at 20 000 answers. The schedule is deliberately simple (1 day → 3 days → interval × ease; wrong = ten minutes, ease −0.2): predictable beats clever, and the mistakes set needs no storage at all — it is the latest answer per activity being wrong, so it clears itself the moment the activity is answered correctly. Review and exam sessions freeze their item list at start (a bug the phase-2 suite caught: answering a mistake removed it from the live list and collapsed the session mid-run). Push reminders are all-or-nothing: no VAPID keys → no UI, no endpoints, nothing to misconfigure; web-push is the one added server dependency and the daily send is an explicit cron/timer script. Verified by the phase-2 suite (43 checks: history and SRS shapes, the review/mistakes sessions, stats math, exam with timer and breakdowns, the VAPID-less hidden state and the configured subscribe/validate/test/delete path; the suite runs its own server instance on a separate port and kills the process group on exit).

  49. 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 model at all, so those decks are produced by gpt-oss:120b start to finish.

  50. No auth, no server storage. Everything personal lives in localStorage; the only server state is the request in flight.

  51. canvas-confetti and framer-motion are the only UI dependencies. Document parsing adds pdfjs-dist, mammoth and jszip, all dynamically imported (the /create page grew ~3 kB; the parsers load only when a document is added). Animations are skipped when prefers-reduced-motion is set.

  52. Tailwind v4 class-based dark mode with a pre-paint script so there is no theme flash, and suppressHydrationWarning on <html>.

  53. Testing. The pipeline was first tested against a mock Ollama HTTP server (including a deliberately invalid first response to exercise the repair path) with the UI driven by headless Chromium, then re-run against the real Ollama cloud. Current suites: 13/13 document extraction (one fixture per format, including a text PDF and a scanned PDF), 15/15 language preservation, 24/24 activity/scoring/feed checks and 11/11 real-model browser checks.