The conversation column was max-w-3xl and centred, which on a 1280 window left a ~140px empty strip between the rail and the messages - "the chat cuts off weirdly". It now uses the library's container (max-w-6xl, lg:px-10) and the composer's content padding matches, so the chat starts right next to the sidebar and every row shares one left edge. Contrast 0 findings, chat 102/102, mobile 126/126, i18n 13/13, native 33/33. Version 0.5.0 (the 0.4.9 rollover), SW -> freezmo-v74.
1796 lines
129 KiB
Markdown
1796 lines
129 KiB
Markdown
# ❄️ 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](#features)
|
||
- [Quick start](#quick-start)
|
||
- [Supported input formats](#supported-input-formats)
|
||
- [Ollama setup](#ollama-setup)
|
||
- [Rate limits](#rate-limits)
|
||
- [Mode 1 — local daemon (recommended)](#mode-1--local-daemon-proxying-cloud-models-recommended)
|
||
- [Mode 2 — direct Ollama cloud API](#mode-2--direct-ollama-cloud-api-api-key)
|
||
- [Changing models](#changing-models)
|
||
- [Checking the connection](#checking-the-connection)
|
||
- [Running on your network / from your phone](#running-on-your-network--from-your-phone)
|
||
- [Keep it running as a service](#keep-it-running-as-a-service-systemd-user-service)
|
||
- [Environments and deploy](#environments-and-deploy)
|
||
- [Folders](#folders)
|
||
- [Install as an app (PWA)](#install-as-an-app-pwa)
|
||
- [Security notes](#security-notes)
|
||
- [How it works](#how-it-works)
|
||
- [Chat](#chat)
|
||
- [Project structure](#project-structure)
|
||
- [API routes](#api-routes)
|
||
- [Scripts](#scripts)
|
||
- [Data, storage and privacy](#data-storage-and-privacy)
|
||
- [Troubleshooting](#troubleshooting)
|
||
- [Decisions and notes](#decisions-and-notes)
|
||
|
||
---
|
||
|
||
## 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](#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](#install-as-an-app-pwa)
|
||
- 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.
|
||
|
||
```bash
|
||
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):
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
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`.
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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`):
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```bash
|
||
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` |
|
||
|
||
On top of the per-route windows there is one **account-level guard**, so no flood — from one client
|
||
or from many — can get the Ollama key throttled or banned:
|
||
|
||
| Guard | Default | Env var |
|
||
| --- | --- | --- |
|
||
| all model calls together (generate, homework, refine, chat, extract, grade) | 60 per window | `RATE_LIMIT_MODEL_TOTAL` |
|
||
| model calls in flight per client IP | 3 | `RATE_LIMIT_CONCURRENT` |
|
||
| model calls in flight across all clients | 6 | `RATE_LIMIT_CONCURRENT_TOTAL` |
|
||
|
||
A request over the concurrency cap gets a **429 `kind: "busy"`** (“Freezmo is already working on
|
||
something — try again in a moment”), not a punishment: the slot frees as soon as the model call
|
||
finishes, and the client's Retry button works. Slots are taken *after* every cheap rejection, so a
|
||
malformed request can never hold one.
|
||
|
||
Two more disk guards: `REPORTS_MAX_BYTES` (default 8 MB) stops appending to `reports.jsonl` once it
|
||
is full, and `PUSH_MAX_SUBSCRIPTIONS` (default 50) refuses new reminder devices over the cap.
|
||
Reminder endpoints must also be a real push service — `fcm.googleapis.com`,
|
||
`updates.push.services.mozilla.com`, `*.push.apple.com`, `*.notify.windows.com` — or a host you add
|
||
with `PUSH_ALLOWED_HOSTS`, because the server POSTs to whatever is stored.
|
||
|
||
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 the **last** `x-forwarded-for` hop (the one your reverse proxy appended) or
|
||
from `x-real-ip`; taking the first hop would let a client spoof its own identity and dodge every
|
||
per-IP limit. Requests without those headers 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**:
|
||
|
||
```bash
|
||
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 services)
|
||
|
||
Two units keep Freezmo alive — **production** (`deploy/freezmo.service`, the
|
||
release on :3207) and **dev** (`deploy/freezmo-dev.service`, hot reload on
|
||
:3217). Both come back after a reboot and restart if they crash:
|
||
|
||
```bash
|
||
mkdir -p ~/.config/systemd/user
|
||
cp deploy/freezmo.service deploy/freezmo-dev.service ~/.config/systemd/user/
|
||
systemctl --user daemon-reload
|
||
systemctl --user enable --now freezmo.service freezmo-dev.service
|
||
loginctl enable-linger "$USER" # start at boot without logging in
|
||
```
|
||
|
||
| Command | What it does |
|
||
| --- | --- |
|
||
| `systemctl --user status freezmo freezmo-dev` | are they up? |
|
||
| `journalctl --user -u freezmo-dev -f` | live dev logs (hot reload included) |
|
||
| `journalctl --user -u freezmo -f` | live production logs |
|
||
| `systemctl --user restart freezmo-dev` | after changing the dev unit |
|
||
|
||
Production is **never** rebuilt or restarted by hand — `scripts/deploy.sh`
|
||
does that, and only with a pushed, verified commit. The dev instance reads
|
||
`~/freezmo/.env.local`, builds into `.next-dev` and keeps its data in
|
||
`.freezmo-dev/`, so nothing it does can touch production. See
|
||
[Environments and deploy](#environments-and-deploy) and
|
||
[AGENTS.md](AGENTS.md) for the rules.
|
||
|
||
---
|
||
|
||
## Environments and deploy
|
||
|
||
Freezmo runs as two separate instances so that live work never touches what
|
||
you actually use:
|
||
|
||
| | **Dev** | **Production** |
|
||
| --- | --- | --- |
|
||
| Path | `~/freezmo` (the working tree) | `~/freezmo-prod/current` → `releases/<timestamp>-<sha>` |
|
||
| Service | `freezmo-dev.service` | `freezmo.service` |
|
||
| Port | **3217** — LAN only, hot reload | **3207** — the public URL |
|
||
| Build | `.next-dev` (dev server) | standalone server inside the release |
|
||
| Data | `~/freezmo/.freezmo-dev/` | `~/freezmo-prod/shared/freezmo/` (sync, push) |
|
||
| Env | `~/freezmo/.env.local` | `~/freezmo-prod/shared/env.local` |
|
||
| Look | DEV badge, dev favicon/theme, installs as **Freezmo Dev** | normal |
|
||
|
||
On your phone: **dev = `http://<lan-ip>:3217`**, production =
|
||
`http://<lan-ip>:3207` (and the public URL). The dev instance is bound to the
|
||
LAN only — nothing routes port 3217 through the public proxy — so source maps
|
||
and the HMR socket never leave the network. It uses a different origin, which
|
||
means separate storage and a separate service worker: the production worker is
|
||
never registered there and both PWAs can be installed side by side.
|
||
|
||
`NEXT_DIST_DIR` keeps the build folders apart (dev uses `.next-dev`, a plain
|
||
`npm run build` keeps producing `.next` for the test suites), so no build can
|
||
clobber a running server.
|
||
|
||
### Deploying
|
||
|
||
```bash
|
||
scripts/deploy.sh # deploy the latest origin/main
|
||
scripts/deploy.sh <commit> # a specific pushed commit
|
||
scripts/deploy.sh --skip-tests # fast path (lint + build only)
|
||
scripts/rollback.sh # back to the previous release
|
||
```
|
||
|
||
`deploy.sh` refuses a dirty working tree and commits that aren't on
|
||
`origin/main`, exports the commit into a fresh release directory, runs
|
||
`npm ci`, lint and the production build there, starts the new release on a
|
||
temporary port and runs the **full browser suite against it** (the running
|
||
production is untouched), then links the shared env + data, writes
|
||
`release.json`, flips the `current` symlink atomically, restarts the service
|
||
and polls `/api/healthz` until the new commit answers. Any failure after the
|
||
switch rolls back to the previous release automatically. The last 5 releases
|
||
are kept. The running version (short commit + build time) is shown in
|
||
Settings → Details.
|
||
|
||
Data that must survive deploys lives in `~/freezmo-prod/shared/`:
|
||
`env.local`, and `freezmo/{sync,push}` (symlinked into every release as
|
||
`.env.local` and `.freezmo`).
|
||
|
||
The day-to-day rules — only dev updates during a task, production changes only
|
||
through the deploy script at the end — are written down in
|
||
[AGENTS.md](AGENTS.md) so every future session follows them.
|
||
|
||
---
|
||
|
||
## 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-icon`s, 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](#rate-limits). Auth is checked *before* parsing
|
||
request bodies, so unauthenticated traffic costs almost nothing.
|
||
- **An account-level model budget and concurrency caps** sit in front of every route that spends an
|
||
Ollama call (`lib/model-guard.ts`): 60 calls per window across all clients, 3 in flight per IP, 6
|
||
in flight in total. A flood cannot pile up parallel calls or burn the account — the thing that
|
||
gets a key throttled or banned. Slots are released when the call finishes, including a client that
|
||
disconnects mid-stream.
|
||
- **Images must be base64 data URLs** (`dataUrlSchema`). A plain URL in an `images` field would make
|
||
Ollama fetch an arbitrary address on our behalf, so the shape is part of the contract.
|
||
- **Reminder endpoints must be real push services** (an allowlist, `PUSH_ALLOWED_HOSTS`): the server
|
||
POSTs to whatever is stored, so an arbitrary URL there would be an SSRF primitive.
|
||
- **Security headers** on every response: `X-Frame-Options: DENY`, `Content-Security-Policy:
|
||
frame-ancestors 'none'`, `X-Content-Type-Options: nosniff`, a referrer policy and a
|
||
Permissions-Policy that turns off camera/microphone/geolocation/payment (the camera button is a
|
||
file input with `capture`, which is not gated by that policy).
|
||
- **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 keeps counters in memory: single instance only, reset on restart. The client IP is
|
||
read from the **last** `x-forwarded-for` hop, so a client cannot spoof its identity — but behind
|
||
more than one proxy the "last hop" is the proxy closest to Freezmo, which means those clients
|
||
share one bucket. Point `x-real-ip` at the real client if you need per-client buckets there.
|
||
- 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`](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`](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: the list sits in the main sidebar on
|
||
desktop (it takes the deck navigation's place on `/chat`), 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`, respects reduced motion and keeps the shared AI
|
||
disclaimer under the chips.
|
||
- **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 |
|
||
| `POST /api/reports` | `{ deckId, deckTitle?, activityId, type, question, at }` | `{ ok: true }` — appended as one JSON line to `REPORTS_DIR/reports.jsonl` (write-only; see decision 52) |
|
||
| `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 |
|
||
| `npm run version:bump` | bump the app version one step (`0.1.7` → `0.1.8`, `0.1.9` → `0.2.0`); one bump per task |
|
||
| `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](#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).
|
||
- **Question reports** (the red **!** in the study feed) keep the skip state in this browser
|
||
(`freezmo.reports.v1`) and append one JSON line per report to `REPORTS_DIR/reports.jsonl`
|
||
(default `.freezmo/reports`, `shared/freezmo/reports` in production). Question text is part of
|
||
the report on purpose — it is what makes the file useful for tuning the generation prompts — and
|
||
the route is deliberately write-only over HTTP.
|
||
- **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. The collapse/expand arrow keeps one home: its own row directly below the
|
||
logo in both states (labelled when expanded, icon-only and centred when collapsed), instead of
|
||
swapping between the brand row and the middle of the icon column.
|
||
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. The four blobs were then turned down to a quarter of
|
||
their opacity (0.18 → 0.045 light, 0.40 → 0.10 dark): at full strength the huge circles read
|
||
as UI rather than as backdrop.
|
||
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](#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.
|
||
|
||
42. **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).
|
||
|
||
43. **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.
|
||
|
||
44. **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).
|
||
|
||
45. **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.
|
||
|
||
46. **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.
|
||
|
||
47. **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).
|
||
|
||
48. **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 (92
|
||
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).
|
||
|
||
49. **The sidebar rail never lets its content widen it.** The desktop rail is a fixed-width `flex`
|
||
aside and its glass panel is a `flex-1` item, so the panel's automatic minimum size
|
||
(`min-width: auto`) is its content's min-content width. When the chat list moved into the
|
||
sidebar, its search input's intrinsic width (an `<input>` defaults to `size=20`) stretched the
|
||
panel 89 px past the rail, where it painted over the page — the content offset itself was
|
||
correct, which is what made it look like the page had lost its left margin. The panel now
|
||
carries `min-w-0` so wide content truncates instead, and the chat suite asserts that the panel
|
||
stays inside the rail and the main content starts after it, expanded and collapsed.
|
||
|
||
50. **One AI disclaimer, only where the model's words are on screen.** A shared `AiDisclaimer`
|
||
(copy, style and translations in one place, `common.aiDisclaimer`) sits under the chat composer
|
||
— always, since chat is pure model output — next to the generation buttons of the homework and
|
||
deck wizards, under their results, and under AI grading feedback. It also rides along with the
|
||
content itself once it is being consumed: under every card in the study feed and in the review,
|
||
mistakes and exam sessions, and on their results slides. Navigation surfaces (the library, deck
|
||
lists, chat history, settings) deliberately carry no note: the disclaimer belongs to the moment
|
||
of generation and to the card being answered, not to browsing, and the empty states stay clean.
|
||
It is a plain `--text-subtle` paragraph (documented at 5.6:1 light / 8.6:1 dark), centred,
|
||
wrapping to two lines on a 360 px phone — not clickable, not dismissable.
|
||
|
||
36. **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.
|
||
|
||
37. **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.
|
||
|
||
38. **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.
|
||
|
||
39. **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.
|
||
|
||
40. **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 (43 checks, including the real route mapping against a stub Ollama returning a
|
||
monthly-limit body, a two-device sync round-trip, and the generation guards below).
|
||
|
||
66. **Aero's glass is thin, and the OS dark preference can't leak into it.** Two fixes from one
|
||
report ("the white UI should be blurry and transparent; some text is hard to read on first
|
||
load, until the theme is toggled"). The text bug was at boot: the inline script's dark test
|
||
was `s && !a ? s === 'dark' : matchMedia(...)`, which for a stored `aero` fell through to the
|
||
OS preference — so on a dark-preferring machine the page loaded with **both** `dark` and
|
||
`aero` classes and every `.dark` rule (starting with `body`'s colour and the segment labels)
|
||
painted the dark palette over the bright wallpaper, until a theme toggle re-applied the
|
||
classes. The stored value now decides alone and the class is toggled explicitly;
|
||
`tests/mobile-test.mjs` pins it with a dark-`colorScheme` browser context. And the surfaces
|
||
drop their thick white veils (a card top measured ~71% white) for thin veils over the
|
||
pre-blurred wallpaper — the scene is clearly visible through the glass, and the
|
||
pixel-contrast scan still passes.
|
||
|
||
83. **The chat fills the space next to the sidebar.** The conversation column was `max-w-3xl`
|
||
centred, which on a 1280 window left a ~140px dead strip between the rail and the messages —
|
||
"the chat cuts off weirdly". It now uses the library's container (`max-w-6xl`, `lg:px-10`) and
|
||
the composer's content is padded to match, so the conversation starts right next to the sidebar
|
||
(measured: column left = 268 = the rail's edge at 1280) and every row — messages, table, action
|
||
chips, input, chips, note — shares one left edge.
|
||
|
||
82. **First run offers the choice: free Ollama or a paid provider.** The gate (user-key installs)
|
||
now shows Ollama with a "Free" badge as the headline option, and below it an "or · Use a paid
|
||
provider instead" button that opens the same provider rows as Settings — with the framing that
|
||
those are billed by the provider directly (Freezmo never sees a card) and that keys stay in the
|
||
browser. Saving a provider key on that screen lists its models and switches study generation to
|
||
it automatically, so the gate steps aside without an Ollama key at all — pinned end to end in
|
||
the providers suite with a second, user-key-mode app instance.
|
||
|
||
81. **Bring-your-own keys for cloud providers.** Besides the server's Ollama, Settings → Providers takes
|
||
keys for OpenAI, Anthropic (their official OpenAI-compatibility layer), Google Gemini (its
|
||
OpenAI-compatible endpoint), DeepSeek, Groq, OpenRouter and any custom OpenAI-compatible
|
||
endpoint. One adapter (`lib/openai-compat`) covers them all —
|
||
model listing, streaming chat, JSON generation and vision — and every model flow can run on a
|
||
provider: the chat picker lists each enabled provider's models (labelled, remembered across
|
||
visits), and a "Study generation" choice moves decks, homework, grading and attachment
|
||
transcription onto a provider model. Keys stay in this browser exactly like the Ollama one
|
||
(`freezmo.providers.v1`): never stored server-side, never in a backup or the sync payload, only
|
||
forwarded with the request that needs them — the provider test asserts the key reaches the
|
||
provider and that the app keeps the routing header to itself. The suite (`tests/providers-test`,
|
||
13 checks) covers listing, chat, generation and the Settings flow against a two-protocol stub.
|
||
|
||
80. **Chat UI pass: glass chrome, one composer panel, friendlier chips.** The chat header was a flat
|
||
blue strip and the composer area floated on the wallpaper; both are glass surfaces now (the
|
||
composer is one flat frosted panel behind input + chips + note). The disclaimer is plain
|
||
centered text in Aero too (`AiDisclaimer plain`), the welcome block + suggestions sit on a soft
|
||
frosted panel and are vertically centered between header and composer, the model chip shows a
|
||
friendly name ("Gemma 4 31B") with the raw name in a tooltip, the chips are bigger with a
|
||
stronger rim, the send button is the filled primary (blue in every theme, clearly dimmed only
|
||
while empty/offline), and the header's "+" is a chat-plus icon so it no longer reads as the
|
||
rail's "new deck". (The review also asked for the light/dark toggle to come back to the Aero
|
||
header/rail — that stays out: Aero is switched from Settings by the owner's earlier rule.)
|
||
|
||
79. **Any model the server offers is selectable in chat, default `gemma4:31b`.** The picker used
|
||
to be the server's allowlist only. The health check now appends every chat-capable model from
|
||
the server's `/api/tags` (favourites first, embeddings filtered, 120 cap), the picker gained a
|
||
search field when the list is long, and the chat route accepts a model that is in the
|
||
favourites *or* in the live listing (one tags fetch for non-favourites, so nothing selectable
|
||
is rejected). The default is `CHAT_MODEL_DEFAULT` (env) or `gemma4:31b`; when a server doesn't
|
||
have the default, the client falls back to the first available model.
|
||
|
||
78. **Reduce motion: a Settings toggle, default off.** It mirrors the OS preference — an
|
||
`html.reduce-motion` class (applied pre-paint by the boot script) parks the scene and collapses
|
||
CSS animations/transitions, and `MotionPreference` wraps the app in framer-motion's
|
||
`MotionConfig reducedMotion="always"`, so every JS animation calms too. Stored in
|
||
`freezmo.reduce-motion.v1`; pinned in the mobile suite (126 checks).
|
||
|
||
77. **The Aero review pass (tabs, primaries, panels, tints).** The active segment keeps the blue
|
||
gradient in Aero again (it had been glassed), and so does every `btn-primary`/`grad-ice` — the
|
||
"Review due" action, the card's Study button (now the filled primary) and the composer's send
|
||
button included. New Aero surfaces: a frosted backing behind the page heading, a chip behind
|
||
the "All decks" label, a bottom-weighted tint on the rail (its lower rows sit over grass), and
|
||
a dedicated `--track` token so progress bars read in every theme without shifting the shared
|
||
surfaces (the first attempt moved `--surface-sunken` and broke sheet contrast — the pixel scan
|
||
caught it). Difficulty labels got darker, the inactive segmented labels use the full text
|
||
token, the card border/shadow are a touch stronger in light, orbs are calmer in light and
|
||
dark, the folders list nudges "create a folder", and a fresh deck shows a "New" badge instead
|
||
of the gray "Not played yet".
|
||
|
||
76. **A dropped connection says so.** A user's run failed with the wizard's generic "check your
|
||
Ollama settings" card, and it could not be replicated on the owner's machine with the same key
|
||
— because the key and model were never the problem. A raw error (a mid-run socket drop, a
|
||
wifi/LTE switch, an app backgrounded by the phone OS) leaked out of the NDJSON reader as a
|
||
DOMException, and the wizard can only show that generic card for unknown errors. The reader now
|
||
classifies everything: an abort is "cancelled", a stall is "the model stopped responding", and
|
||
anything else on the wire is "the connection dropped mid-run — try again". Pinned with a
|
||
fetch stub that kills the stream mid-run in the wizard suite (95 checks).
|
||
|
||
75. **A quiet run can no longer load forever.** A user got stuck on the wizard's loading screen:
|
||
a hung model meant silence, and silence was indistinguishable from work — the server's own hard
|
||
deadline is 2x `OLLAMA_TIMEOUT_MS` (ten minutes by default), and the client waited on a dead
|
||
stream indefinitely. Both generate routes (deck, homework) now send an NDJSON heartbeat every
|
||
15s, and the client guards the two quiet phases: no response at all for 45s, and no bytes for
|
||
90s mid-stream, both surfacing the regular error state with a Retry — verified with a stalled
|
||
route and Playwright's fake clock in the wizard suite (93 checks). The heartbeat also keeps
|
||
proxies from closing an idle connection.
|
||
|
||
74. **Phones get flat glass, not live blur.** The touch fallback from 73 (a real backdrop blur
|
||
over the live scene) turned out to be the wrong trade: every pane re-blurs on every frame
|
||
while the bubbles animate, and phones choke — input lag, "buttons take ages to respond". Touch
|
||
devices now drop both the bake (which mispaints there) *and* the live blur: the panes keep the
|
||
thin Ask-pill tint over the sharp scene (the hand-rolled activity fills keep their own tints),
|
||
i.e. transparent glass at zero per-frame cost. Desktop keeps the baked blur — the same look as
|
||
the phones had, without the GPU bill. `tests/mobile-test.mjs` pins the flat contract (no baked
|
||
layer, no backdrop-filter on a card) with a touch context.
|
||
|
||
73. **Touch devices blur the live backdrop; desktop keeps the bake.** Mobile engines repaint
|
||
`background-attachment: fixed` late (and iOS treats it as scroll), so the baked glass showed a
|
||
stale crop of the wallpaper until a tap forced a repaint — "some of the UI looks bugged until I
|
||
click it". On touch devices (`max-width: 639px` or `pointer: coarse`) the bake is dropped for a
|
||
real `backdrop-filter` over the live scene (what the floating chrome always did) — the bubbles
|
||
now blur *through* the glass as they pass, and the position is correct by construction. The
|
||
hand-rolled frost fills keep their own tints there; desktop keeps the baked layer (measured
|
||
cheaper there and verified correct). Pinned in `tests/mobile-test.mjs` with a touch context.
|
||
|
||
72. **More, shinier bubbles.** Aero's rising bubbles went from ten to eighteen (the extra eight
|
||
interleaved between the others, 18px to 96px, 10-23s runs) with a tighter specular hotspot, a
|
||
second reflected-light glint low on the sphere, a 1px glass edge and a brighter inner rim on
|
||
both the bubbles and the drifting orbs. The bodies stay translucent and their opacities are
|
||
capped (0.75-0.9), so text sitting on the wallpaper stays readable, and the bubble-perf
|
||
diagnostic shows no added frame cost (18 bubbles, same p95 as ten).
|
||
|
||
71. **The chat's top edge fades instead of hard-clipping.** The conversation scrolls right up to
|
||
the glass header, and over the busy wallpaper a line of text clipped exactly at that edge read
|
||
as a rendering bug ("the chat looks cut off"). The scroller now carries a 14px top mask in
|
||
Aero — shorter than the column's 16px top padding, so a fresh conversation at the top shows no
|
||
dimming at all.
|
||
|
||
70. **Chat answers are bubbles in Aero too.** The assistant answers are deliberately bubble-less
|
||
(full-width text on the flat themes) and the user bubble's thin tint washed out over the bright
|
||
parts of the wallpaper, so the longest text in the app ended up sitting straight on the photo.
|
||
In Aero both get a real pane: `.chat-answer` wraps the markdown in a bubble (plus a
|
||
`chat-bubble` class on the user turn), with a stronger veil than the shared glass so a wall of
|
||
text still reads comfortably over the same blurred scene, and the bright rim. Light and dark
|
||
keep the bubble-less assistant design — these classes are styled only under `.aero`.
|
||
|
||
69. **The glass got thinner and reaches the whole app.** Two asks in one pass: "slightly more
|
||
transparent" and "some buttons aren't themed" while doing decks/homework. The veils drop
|
||
another notch (recipe 0.15 -> 0.12, the tokens ~0.03 less, the tab bar / wizard bar / gloss
|
||
likewise), and the recipe now catches the flows' hand-rolled frost: the study activities'
|
||
`bg-white/xx` chips (multiple-choice options, matching terms, ordering cards, fill-in-blank
|
||
chips), the exam deck rows and pickers that used `bg-[var(--surface)]` — an *opaque* white in
|
||
Aero — and everything using the surface tokens (chat action chips, homework chips, inputs,
|
||
dropzones); attribute selectors match them, so no component needed a new class. The same shapes
|
||
inside sheets stay flat (the exclusion list covers the new selectors — there the sheet itself is
|
||
the glass), and the dropzones lost their press-scale (a transform on a fixed backdrop
|
||
re-anchors it) and shade inward instead. `tests/contrast.mjs`: 0 findings.
|
||
|
||
68. **The Ask pill joins the glass, and Aero's hovers light the pane instead of moving it.** Two
|
||
leftovers from the previous pass. The Ask button (and the two phone icon pills that share its
|
||
styling) was a hand-styled `bg-[var(--surface-strong)]` pill that never picked up the frosted
|
||
recipe, so it showed the sharp wallpaper through a flat tint while everything around it showed
|
||
the blurred one — it carries a `glass-pill` class now and is part of the same recipe. And the
|
||
hover feedback: a `transform` or `filter` on a pane with a viewport-fixed backdrop re-anchors
|
||
the glass, so the deck card's hover lift made the card flash a wrong crop of the wallpaper
|
||
(and the buttons' `filter: brightness()` would have done the same, and the mobile
|
||
`:active { transform: scale(0.96) }` foundation made panes jump while pressed). Aero's
|
||
feedback never moves or filters the glass now: the pane lights up with an inner glow, a white
|
||
rim and a deeper shadow, transitioned over 170ms, and the pressed state shades inward.
|
||
|
||
67. **The Aero wallpaper URL is cache-busted, and every surface is one glass.** Two fixes from
|
||
one report. The background "didn't change" because the service worker's asset cache kept
|
||
serving the previous wallpaper bytes (stale-while-revalidate returns the cached copy first,
|
||
and the old worker can outlive a deploy): both aero images now carry a `?v=3` query, so a
|
||
stale cache cannot answer for them, and future wallpaper swaps get a new query too. And
|
||
"everything should look like the Ask button" is now literal: the rail, the cards, the chips,
|
||
the segments and the buttons all share one recipe — the pre-blurred wallpaper under a ~15%
|
||
veil (the minimum the pixel-contrast scan needs) with the bright rim. The blur bake is
|
||
shadow-lifted (gamma 0.86) and slightly saturated so the scene's colours stay vivid instead
|
||
of washing out to a milky film, the wizard's phone action bar (Aero's `--page` token was the
|
||
saturated water blue, a heavy slab over the photo) joins in, and nested chrome inside sheets
|
||
keeps its flat fills (the sheet itself is the real glass there). `tests/contrast.mjs` is back
|
||
to 0 findings across every screen and theme.
|
||
|
||
65. **Slide content never flex-shrinks, and the AI note appears once.** A long typed answer in a
|
||
short-answer activity shrank the textarea inside the slide's flex column (58px instead of its
|
||
~82px), clipping the user's text mid-line on a phone; every direct child of a study/review/guide
|
||
slide is now `shrink-0` and the slide scrolls instead. The same screenshot showed **two** AI
|
||
disclaimers — the activity's own after the grading box plus the feed's row. The activity-level
|
||
copy is gone: the feed/guide row (which also carries the report control) is the single one, and
|
||
the phase-2 suite asserts the note sits below the graded feedback rather than right after it.
|
||
|
||
64. **Ordering cards only where an order exists.** "Sort these" was landing on decks whose notes
|
||
have no sequence at all (a list of works, authors or terms), which the learner cannot reason
|
||
about. The deck prompt now allows `ordering` only for real sequences — chronological events, the
|
||
stages of a process, steps of a procedure, a lifecycle, a ranked scale — and points at matching,
|
||
multiple choice or true/false when there is none, with one or two ordering cards per deck as the
|
||
usual ceiling.
|
||
|
||
63. **Frutiger Aero is the third theme.** Settings → Theme offers Light, Dark and Frutiger Aero.
|
||
Aero is not a dark/light variant — it replaces the palette and the scene: the wallpaper is a
|
||
Frutiger Aero scene made with Gemini (latest 1280x714 revision re-encoded to WebP in `public/aero/`), with ten drawn
|
||
bubbles rising over it (`.ice-bubble` — `display: none`
|
||
outside aero so they cost nothing elsewhere, still under reduced motion). Buttons and segments
|
||
are **liquid glass** — transparent, glossed, with a bright rim — and every surface shows a
|
||
**pre-blurred copy of the wallpaper** (`public/aero/background-blur.webp`, 3 kB, glued to the
|
||
viewport with `background-attachment: fixed`) instead of blurring the live scene: the bubbles
|
||
move behind every surface, so a real `backdrop-filter` re-runs ~60 times a second (measured
|
||
66ms/frame at 2x throttle vs 17ms with nothing blurring). A thin veil and the gloss ride on top of
|
||
that layer, so the look is identical at zero per-frame cost. The helper lines that sit straight
|
||
on the wallpaper (the keyboard tip, the AI note) get a small glass chip — dark text on grass
|
||
measured 2.5:1. The light/dark
|
||
toggles (header, rail, chat header) hide while it is on and the picker says why; the PWA status
|
||
bar follows the wallpaper's sky (#2fa7e6). Covered by the axe and pixel-contrast scans in all
|
||
three themes.
|
||
|
||
62. **Accessibility: axe is clean everywhere, and contrast is measured on real pixels.** axe cannot
|
||
judge text on blurred glass — it reports those nodes as "incomplete" — so `tests/contrast.mjs`
|
||
samples the rendered pixels (dominant non-glyph colour per element) and compares it with the
|
||
computed text colour using the WCAG formula, skipping disabled controls (1.4.3 exempts them)
|
||
and content behind an open sheet. Fixes from that pass: the hidden file inputs are out of the
|
||
accessibility tree (aria-hidden + tabIndex -1; labelled buttons open them), the heading order is
|
||
right (deck/homework/smart-review cards are h2; LearningSection takes a level so it is h2 on the
|
||
library and h3 inside the dialog), the study and review feeds have landmarks, and the light
|
||
settings sheet — translucent over a dark scrim, where its muted text measured ~2.8:1 — got its
|
||
own more opaque surface (`--glass-sheet-bg`). Result: 0 axe violations and 0 below-minimum
|
||
findings across every screen, all three themes, phone and desktop.
|
||
|
||
61. **Glass that sits still doesn't blur.** The MacBook got hot just browsing: the ice scene animates
|
||
constantly, and every `backdrop-filter` surface re-runs its blur on every frame while the backdrop
|
||
moves — the whole card and rail area was re-blurring the scene ~60 times a second. Measured on
|
||
desktop (2x CPU throttle, software renderer): p50 frame 66ms with everything blurring, 33ms once
|
||
the static surfaces stopped, 17ms with the scene's animation also off — frames over 34ms dropped
|
||
from 128 to 14. The recipe is now split: **floating glass** (sheets, dropdowns, dialogs, the
|
||
sticky header/chrome/tab bar — the layers that can have content scroll underneath) keeps the
|
||
blur, now `backdrop-blur-lg` (which is what phones already used), while **static glass** (cards,
|
||
the rail, the gate, the study prompt card) keeps the exact same tint, rim, highlight and shadow
|
||
without the filter. A deterministic before/after pixel diff (scene motion frozen) shows the
|
||
change is a ~1% colour shift in the card backdrops — invisible in both themes.
|
||
`tests/bubble-perf.mjs` now reports the glass cost too, so a future blurred surface over the
|
||
scene shows up immediately.
|
||
|
||
60. **There is exactly one loading screen.** The deck wizard had the good one — the app icon, the
|
||
rotating stage message as the headline, the progress bar, the stage checklist, a tip and
|
||
Cancel — while the homework flows showed a spinner with a title and the route loads a pulsing
|
||
icon with a smaller line. All three now render `components/LoadingState`: the wizard passes its
|
||
vision/generation stages (and keeps its deck tips), the homework passes the same shape with
|
||
homework wording (no tips — the tips are deck-specific), and the route screens pass a single
|
||
message with no stages or progress. Cancel stays a 44px touch target and the icon animation
|
||
respects `prefers-reduced-motion`. Verified by the freeze-loading, review, wizard and mobile
|
||
suites. The link overlay is portalled to `<body>`: inside a glass card `backdrop-filter` makes
|
||
the card a containing block for `position: fixed`, so the overlay was squeezed into a 44px
|
||
strip of the card instead of covering the viewport (the review suite now measures it).
|
||
|
||
59. **Adding a deck or a homework syncs itself.** Sync used to be push-on-demand only: a deck
|
||
frozen on the laptop sat in `dirty` until someone opened Settings and pressed Sync now. The two
|
||
stores whose writes mean "the user just made something" (`lib/storage`, `lib/homework`) now
|
||
schedule `syncNow("auto")` through `lib/sync-auto` — debounced 2.5 s so a burst of writes is one
|
||
push, one run at a time, silent on failure (the data stays dirty and Settings still offers the
|
||
manual sync), and retried on the browser's `online` event. Deliberately *not* wired to answers
|
||
or chat: those change every few seconds while studying. The module is imported dynamically from
|
||
the stores, so the sync code stays out of the first load and no import cycle forms.
|
||
`markSyncDirty()` now reports whether it actually marked, which also keeps pull/import paths
|
||
quiet while a backup import pushes like any other local change. Verified by the phase-1 suite
|
||
(a deck write pushes itself, then Sync now is a no-op) and the phase-3 suite (a check run pushes
|
||
the new homework record).
|
||
|
||
58. **The smart review follows the deck list.** Deleting a deck used to leave its due schedules
|
||
and mistakes in the library counts: `dueCount` counted raw SRS rows (no deck lookup) and both
|
||
counts were memoised on the learning store alone, so a deck change never recomputed them. The
|
||
counts now resolve through `dueItems`/`mistakeItems` (which skip decks and activities that no
|
||
longer exist) inside a memo keyed on the live deck ids, so deleting a deck drops its numbers at
|
||
once — and Undo brings them straight back, because the learning data itself is never erased.
|
||
Verified by the review suite (two decks due → delete one → counts drop → Undo restores).
|
||
|
||
57. **"Check my work" takes photos and documents, not just text.** The attempt field used to
|
||
apologise that handwritten work was not supported. It now carries its own intake (photos of
|
||
pages, PDF/DOCX/PPTX/ODT/TXT…): documents are read in the browser and their text joins what was
|
||
typed, while photos travel to `/api/homework` as `solutionImages` and are transcribed by the
|
||
vision model before the check runs (the stage reads "Reading your solution…"). The server
|
||
returns the finished attempt in `meta.solutionText`, which the client stores as the record's
|
||
`attempt` — so "Redo" re-checks the same work without the photos ever being stored. Verified by
|
||
the phase-3 suite (API: a photos-only attempt is accepted, transcribed and runs vision first;
|
||
desktop: thumbnail appears, the payload carries `solutionImages`, the result renders).
|
||
|
||
56. **Model text keeps its own line breaks.** Every homework prompt asks for plain text with short
|
||
lines and numbered or dashed lists (`HOMEWORK_BASE_RULES`), but the guide rendered the step
|
||
explanation in a bare `<p>` — JSX collapses newlines, so a step arrived as one run-on paragraph
|
||
with the whole worked solution inside it (the reported "completely broken" step). The
|
||
explanation, the progressive hints and the verdict/wrong entries of a check now render with
|
||
`whitespace-pre-wrap` like the other homework results always did, and the guide prompt says the
|
||
explanation is 2-4 sentences of coaching, with the finished working left to hint 3 and
|
||
`answer`.
|
||
|
||
55. **Popovers close the way they open.** The chat's context ("what may chat read") and model chips
|
||
used to only ever open: the sole way out was picking something, or Escape. The chip now toggles
|
||
its own popover (and reads pressed while open, `aria-expanded`), opening one closes the other,
|
||
and on desktop a pointer press anywhere outside closes it — except on the trigger chips
|
||
themselves, which the outside handler ignores precisely so the toggle cannot re-open what it
|
||
just closed. Phones keep the sheet + backdrop they already had. Verified by the chat suite
|
||
(toggle, pressed state, switch, outside click).
|
||
|
||
54. **The study chrome is one pill, and it sits above the app chrome.** Progress header, close
|
||
button, title, position and streak/xp (or the exam timer) used to be two stacked rows inside a
|
||
`rounded-full` box — on a tall box that is a stadium, so the corner curves cut into the icon
|
||
row and the controls looked like they were falling into the corners. It is now a single 58px
|
||
pill: round close button, title + counter over the progress bar in the middle, chips at the far
|
||
end, everything on the centre line — the same recipe in the study feed, review/exam sessions and
|
||
the homework guide. The immersive feeds also carry `z-[45]`: on `/exam` the app header (`z-40`)
|
||
used to paint over the top of the chrome. Verified by the phase-2 suite (the close button's
|
||
corners stay inside the rounded end, single row, counter present).
|
||
|
||
53. **The flashcard flip never relies on `backface-visibility`.** The card is two faces in a
|
||
`preserve-3d` container (the back pre-rotated 180°) — the classic CSS flip — but each face is a
|
||
glass layer with `backdrop-filter`, and on some GPU/compositing paths the front's backface keeps
|
||
rendering, so the flipped card showed the question mirrored over the answer. The faces are now
|
||
painted by an explicit `visibility` swap driven by the animation's own rotation (the swap lands
|
||
at the 90° edge-on moment, where it cannot be seen), so exactly one face is ever in the render
|
||
tree. Reduced motion skips the spring and the swap still lands via `onAnimationComplete`. The
|
||
phase-2 suite asserts the painted-face invariant at rest, flipped and flipped back.
|
||
|
||
52. **Reporting a question is local-first, with a server copy for the operator.** The red "!" under
|
||
the card (bottom-right of the exercise — it used to sit in the top chrome, where it read as
|
||
chrome and was easy to mis-tap) records the question in this browser — that local list is the
|
||
skip state, so the card leaves study, review, mistakes and exam sessions, and Undo in the toast
|
||
brings it back (the server file is append-only). It also POSTs a copy to `/api/reports`, which
|
||
appends one JSON line to `shared/freezmo/reports/reports.jsonl`. The app has no auth, so the
|
||
route is write-only: the question text is readable on the server (that is the point — prompt
|
||
tuning) but never over HTTP. A reported card counts as "not attempted" in the results rather
|
||
than wrong, and a deck where everything is reported stays studiable so it can be regenerated.
|
||
Verified by the phase-1 suite (append, validation, no GET) and the phase-2 suite (button
|
||
placement under the card with a 44px target, toast, server POST, local copy, skip, hidden next
|
||
session, Undo, exam pool).
|
||
|
||
51. **Cards with repeated wording never ship.** The model sometimes pads a matching card with one
|
||
definition copied across several terms (a Czech literature deck arrived on a phone with three
|
||
identical definitions, so the card could not be solved by knowing the material), or repeats an
|
||
option. The generation pipeline now rejects any card whose options, items, terms or definitions
|
||
repeat (case-insensitive) — the same drop-or-repair path the ordering giveaway guard already
|
||
used, so a deck with too few cards left over gets one repair round instead. The matching and
|
||
ordering dedupe only trims a card when it still meets the schema's minimum afterwards: a
|
||
shorter card would fail validation on the next read and take the whole deck down with it, so
|
||
below the minimum the original is kept and the card is rejected instead. The generation prompt
|
||
states the uniqueness rule too. Verified by the phase-1 suite's stub-Ollama generation: the
|
||
repeated-definition card is dropped, a salvageable card is trimmed to three pairs, the
|
||
repeated-option card is dropped and the good cards still ship.
|
||
|
||
41. **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).
|
||
|
||
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
|
||
model at all, so those decks are produced by `gpt-oss:120b` start to finish.
|
||
13. **No auth, no server storage.** Everything personal lives in `localStorage`; the only server
|
||
state is the request in flight.
|
||
14. **`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.
|
||
15. **Tailwind v4 class-based dark mode** with a pre-paint script so there is no theme flash, and
|
||
`suppressHydrationWarning` on `<html>`.
|
||
16. **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.
|
||
|
||
## Legal pages
|
||
|
||
Freezmo ships its own plain-language documents, in both app languages, rendered by
|
||
`components/LegalPage.tsx` from `lib/legal.ts`:
|
||
|
||
| Page | What it says |
|
||
| --- | --- |
|
||
| `/privacy` | Local-first storage, the key never leaving the browser, the three opt-in server copies (sync, reminders, reports), what the server does *not* store, and the third parties (Ollama, your browser's push service). |
|
||
| `/terms` | Free software under GPL-3.0-only with no warranty, your own Ollama key and its limits, fair use of the instance, your content, availability and liability. |
|
||
| `/cookies` | No cookies at all (verified: no `Set-Cookie`, no `document.cookie`), what the strictly-necessary local storage holds, no tracking, and how to clear it. |
|
||
|
||
A cookie notice (`components/CookieConsent.tsx`) tells every new browser the same thing once and
|
||
remembers the dismissal (`freezmo.consent.v1`). It is floating glass — blurred backdrop, rim and
|
||
shadow — so it reads as a notice above the page rather than part of it. It sits below the immersive
|
||
feeds in the stacking order, so it can never cover a study session, and above the phone tab bar. Settings links all three
|
||
documents. The documents describe what the code actually does — keep them in step with it.
|
||
|
||
## License
|
||
|
||
Freezmo is free software, licensed under the **GNU General Public License, version 3 only**
|
||
(SPDX: `GPL-3.0-only`) — the full text is in [LICENSE](LICENSE), and `package.json` carries the
|
||
same identifier.
|
||
|
||
You may run, study, share and modify it under that license. It comes with **no warranty**, to the
|
||
extent permitted by law. If you redistribute it (or a modified version), the license requires you
|
||
to pass the same freedoms on and to make the corresponding source available.
|