The daily reminder said one generic line to everyone, because the server
never sees a deck. Now the notification text is written in the browser
(lib/reminder.ts) and travels with the subscription:
- Personal: "3 cards are due in Sky deck", in the app's language, tapping
through to /review. Without notes (or with nothing due) it is the plain
nudge, translated in both languages — push.title / push.due.* / push.generic.
- POST /api/push accepts an optional message {title, body, url}; url must be
a path on this app, so a stolen subscription cannot make the notification
link elsewhere. The store keeps endpoint, keys, the message and createdAt
(a refresh no longer resets the timestamp).
- components/PushReminderSync.tsx refreshes the stored text when the app
opens and whenever the tab becomes visible (throttled to twice a day), so a
count is only ever sent while it is true.
- scripts/send-reminders.mjs sends each device its own text and falls back to
PUSH_REMINDER_TITLE/_BODY for a subscription that has none
(scripts/reminder-payload.mjs, unit-checked).
- /api/push/test previews the real reminder with a "Test:" prefix.
- deploy/freezmo-reminders.{service,timer}: the daily send as a systemd user
timer (18:00 local, Persistent, randomised), documented in the README.
- Settings copy, privacy page (en+cs) and .env.example say exactly what the
server now holds.
- phase2-test grew the whole path with a faked browser subscription: personal
text in en and cs, the generic fallback in both, the URL guard, and the
sender's preference order (81 checks).
Version 0.8.2, SW v104.
2339 lines
175 KiB
Markdown
2339 lines
175 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. Each device
|
||
writes its own notification — how many of *its* cards are due, in which deck, in the app's
|
||
language, or the plain nudge when there are no notes at all — and stores it with its
|
||
subscription; `node scripts/send-reminders.mjs` is the daily sender (the shipped
|
||
`deploy/freezmo-reminders.timer`, or your own cron).
|
||
- **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 |
|
||
|
||
Every card carries the extras that make it studyable: a **speaker** control (Web Speech API,
|
||
the deck's language, on the text you are meant to read — flashcard faces, questions, statements,
|
||
instructions), a **“From your notes”** passage after you answer (the generator stores a verbatim
|
||
quote with each activity; decks made before that get a local, IDF-weighted sentence match over
|
||
the saved notes) with the matching words highlighted green, and the feed chrome's **⏱ timed run**
|
||
(off by default — 30 seconds a card, running out counts as a miss and the feed moves on).
|
||
|
||
**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 surface shares one recipe —
|
||
translucent tint, a bright 1px rim, an inner top highlight and a blue-tinted shadow, plus a real
|
||
16px `backdrop-blur` (12px on phones) only on floating glass that content scrolls under — over a
|
||
fixed ice scene (navy/pale base, soft glacier blobs and glossy ice orbs rising through the open
|
||
band between the chrome).
|
||
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, study chrome and sheets use real `backdrop-filter`
|
||
blur over the fixed ice scene — depth without noise. Glass that sits still never blurs, and the
|
||
scene parks whenever nobody is watching it (hidden tab, unfocused window, an open dialog)
|
||
- **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 freezer.
|
||
**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 |
|
||
|
||
The **daily reminder** is its own pair — `deploy/freezmo-reminders.service`
|
||
(one shot: send whatever each device asked for) and
|
||
`deploy/freezmo-reminders.timer` (18:00 local, `Persistent=true` so a laptop
|
||
that was asleep still catches up):
|
||
|
||
```bash
|
||
cp deploy/freezmo-reminders.service deploy/freezmo-reminders.timer ~/.config/systemd/user/
|
||
systemctl --user daemon-reload
|
||
systemctl --user enable --now freezmo-reminders.timer
|
||
systemctl --user list-timers freezmo-reminders.timer # next run
|
||
journalctl --user -u freezmo-reminders -n 20 # what it sent
|
||
```
|
||
|
||
It needs `VAPID_PUBLIC_KEY`/`VAPID_PRIVATE_KEY` in the shared `env.local`
|
||
(the settings UI is hidden without them) and reads `PUSH_DIR` relative to the
|
||
release's `server/` directory, which is where the app writes subscriptions —
|
||
run it from there, or set `PUSH_DIR` yourself.
|
||
|
||
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 freezer")
|
||
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 context.tsx
|
||
NotesSource.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
|
||
SpeakButton.tsx (read-aloud control for study cards)
|
||
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
|
||
notes-source.ts "from your notes": quote lookup + local sentence matching
|
||
speech.ts Web Speech API wrapper (one utterance, deck language)
|
||
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, message? }` | `{ ok, count }` (stored server-side: endpoint, keys, and the reminder text the browser composed — decision 110) |
|
||
| `DELETE /api/push?endpoint=` | – | `{ ok, count }` |
|
||
| `POST /api/push/test` | `{ endpoint }` | `{ ok: true }` or 502 when the push service refuses; sends the reminder this device stored, prefixed “Test:” |
|
||
|
||
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 and the
|
||
notification text the browser composed — nothing else) 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. |
|
||
| No reminder arrived at the usual time | The sender is a timer you install (or your own cron): `systemctl --user list-timers freezmo-reminders.timer` shows the next run, `journalctl --user -u freezmo-reminders -n 20` shows what it sent. It reads `PUSH_DIR` relative to the release's `server/` directory, so run it from there (the shipped unit does). |
|
||
| “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). The deploy
|
||
gate runs every suite against the release before switching `current`.
|
||
|
||
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.
|
||
|
||
In Aero the welcome panel is the same frosted glass as the cards (blurred, not a flat tint) and
|
||
stretches to the content column's edges — flush under the header and above the composer
|
||
(measured: 268..1280 at a 1280 window) — a flat pane floating 40px inside the scene read as
|
||
"cut off next to the sidebar".
|
||
|
||
98. **The MacBook ran hot on the scene, so the scene's per-frame bill came down.** "It has
|
||
terribly high CPU usage and the MacBook gets really hot when I'm on it" — a fanless MacBook Air
|
||
driving a 144Hz display. Measured on that machine with real headed engines (process CPU plus
|
||
the per-process GPU time macOS keeps — `tests/heat-probe.mjs`), not frame times in a throttled
|
||
software renderer: an idle Aero library cost LibreWolf (Gecko) ~110% CPU and ~230 ms of GPU
|
||
per second, the study feed ~130% / ~320 ms, Chromium ~57% CPU and up to ~350 ms/s of GPU, and
|
||
Safari ~220-250 ms/s of WindowServer GPU above the desktop. Each suspect was switched off on its
|
||
own to price it:
|
||
- **The gradient mask on `.ice-floats`** (from 91) re-rendered the whole animated layer
|
||
offscreen on every frame — ~75 points of Gecko CPU by itself. The floats now rise through a
|
||
plain rectangular band (68px below the top, 151px above the bottom — 172px on phones — and
|
||
right of the 268px rail strip on desktop), and every orb and bubble starts at the band's
|
||
bottom and ends at its top at zero opacity: the fades live in the keyframes (in by 18%, out
|
||
from 90%; `--ice-rise` is the band height minus the float's own) and the 1.06 end scale is
|
||
taken back by a 3% nudge, so no visible float ever touches the clip. The bubbles suite sweeps
|
||
every float through 41 phases at 1280 and 390 and fails on any visible frame outside the band.
|
||
- **The overlay grain** (`mix-blend-mode` over the animated layer) forced a backdrop readback
|
||
per frame for a texture that measured a max 2-6/255 and mean ≤0.11/255 difference —
|
||
invisible. Gone.
|
||
- **The blob drift**: four viewport-sized layers moving a few vw over 44-60s at 0.045-0.1
|
||
opacity turned every frame into a full-screen redraw (Chromium GPU 128 → 80 ms/s with them
|
||
still). They are painted once now.
|
||
- **Backdrop blurs on glass that sits still**: answer tiles, the feedback panel, chips, inputs,
|
||
dropzones, grouped lists, segmented controls and Aero's hint chips re-blurred the moving
|
||
scene every frame (the study feed's tiles alone: ~20% Chromium CPU, ~270 ms/s of GPU). Only
|
||
floating glass that content scrolls under blurs now; the hint chips use the shared bake, and
|
||
the baked Aero chat header/composer drop a live blur that never showed under the opaque bake
|
||
(touch has no bake there and keeps it). Their bake is the glass on desktop, so 97's flat
|
||
nested tiles still sit on the right backdrop.
|
||
- **Nobody watching**: besides the hidden tab, the scene now parks while the window has no
|
||
focus (another app in front, the browser on the other screen) and while any dialog or sheet
|
||
is open — its scrim carries `.scene-pause`, so the blurred scrim and sheet render once.
|
||
- The loading screen's rocking icon was a framer-motion loop writing the transform from the
|
||
main thread every frame for the whole generation; it is a compositor-run CSS animation on
|
||
`rotate`/`scale` now.
|
||
|
||
Same machine, focused and animating (before → after): LibreWolf library Aero 109% → 33% CPU
|
||
(GPU 227 → 101 ms/s), study 134% → 34% (316 → 141), chat 130% → 42%, dark/light library ~94% →
|
||
24-26%; Chromium's GPU time 2.5-4.4× lower (study Aero 354 → 80 ms/s) with 7-22 points less CPU;
|
||
Safari's WindowServer GPU above the desktop −20-26% in Aero and about half in dark. Unfocused,
|
||
or with a sheet open, the scene costs nothing. What remains is the price of the ambient motion
|
||
itself — the orbs (23 floats in Aero) composited at the display's refresh rate, on the
|
||
compositor only (Chromium's main thread: ~9ms busy per 4s); Settings → Reduce motion parks it
|
||
entirely. The visuals are unchanged apart from where a bubble happens to be at a given moment,
|
||
and the pixel-contrast scan lost a finding (the Aero study chip, 4.49:1). The suites also find
|
||
Playwright's Chromium on macOS now (`~/Library/Caches/ms-playwright`).
|
||
|
||
98. **The chat composer's textarea has no scrollbar.** With enough text the auto-growing field
|
||
capped at 180px revealed the browser scrollbar inside the frosted tile — over the Aero glass it
|
||
read as a broken widget, not an affordance ("that ugly scroll bar in the box where you put in
|
||
the prompts"). The field carries the shared `.no-scrollbar` style now: bar gone in all three
|
||
themes (verified against production: `scrollbar-width: none`, webkit `display: none`), while
|
||
`overflow` stays `auto`, so long messages still wheel-scroll and the caret line is scrolled
|
||
into view as you type. No strings, so no i18n; the additive change sits on top of the merged
|
||
scene-heat rework unchanged.
|
||
|
||
110. **The reminder is written by the browser, because the notes never leave it.** Push reminders
|
||
used to say one generic line to everyone, and the server had no way to do better: it only
|
||
knows push endpoints, never a deck. So the notification text is now composed on the device
|
||
(`lib/reminder.ts`): how many cards are due, in the deck that has the most of them, in the
|
||
app's own language — or the plain nudge when there are no notes at all, translated like every
|
||
other string. It travels with the subscription (`POST /api/push` takes an optional
|
||
`message: { title, body, url }`, `url` forced to a path on this app so a stolen subscription
|
||
cannot turn the notification into a link elsewhere), and the daily sender
|
||
(`scripts/send-reminders.mjs`, `scripts/reminder-payload.mjs`) sends each device exactly what
|
||
it asked for, falling back to the operator's `PUSH_REMINDER_TITLE/_BODY` for a subscription
|
||
that has none. Tapping the notification opens `/review` — the queue the count talks about. The
|
||
text cannot go stale either: `components/PushReminderSync.tsx` refreshes it when the app opens
|
||
and whenever the tab becomes visible, throttled to twice a day, so "3 cards are due" is only
|
||
ever sent while three really are. The settings copy says what the server now holds, the
|
||
privacy page and `.env.example` say it too, and the phase-2 suite pins the whole path with a
|
||
faked browser subscription: personal text in both languages, the generic fallback in both,
|
||
and the sender's preference order (81 checks).
|
||
|
||
109. **Teach it back: the AI stops being the tutor and becomes the student.** The mode where the user
|
||
explains a topic and an AI classmate probes the gaps — never a grader, never lecturing.
|
||
`/teach` (a sidebar row next to Exam mode, plus *Teach it back* in every deck's actions)
|
||
starts from a deck — which prefills the topic and checks against that deck's own cards — or
|
||
from a typed topic with the same context and model chips Chat uses. The student is a prompt
|
||
variant of the chat route (`mode: "teach"`), so model resolution, allowlists, rate limits and
|
||
the streaming client are literally the same code; the personality lives in `lib/prompts.ts`
|
||
(`TEACH_*`): friendly or skeptical, one short reaction plus exactly one follow-up question,
|
||
never "correct"/"wrong", never its own facts, and it says so instead of guessing when the
|
||
notes cover nothing. It receives the deck's cards as a numbered checklist ("[3] Why …? → …")
|
||
and is told to ask about the ideas that were skipped. After `TEACH_MAX_EXCHANGES` (10, one
|
||
constant) replies — or whenever Finish is pressed — a second `teach_summary` call turns the
|
||
transcript into the recap JSON (`well` / `gaps` with card references / `next`), with one
|
||
repair retry, and each gap links back to the exact card it refers to. The recap's two
|
||
follow-ups: *Add missed cards to review* puts those activities on the SRS schedule due now,
|
||
and *Turn gaps into cards* generates one flashcard per gap from the chosen deck's own notes
|
||
and shows them in an editable review list (checkbox, front, back) before anything is saved.
|
||
Sessions are chat conversations with a `mode: "teach"` marker plus the session record, so they
|
||
inherit the IndexedDB history, backup and encrypted sync for free while staying out of the
|
||
Chat screens; the recent list on the start screen reopens a recap or continues an unfinished
|
||
session. The screen reuses MessageRow, the Composer (now able to drop its attach/scope chips
|
||
and take a mic), Markdown + KaTeX, AiDisclaimer and the pickers; dictation uses the Web Speech
|
||
API's recognition side, and the mic simply does not render where the browser cannot listen.
|
||
Verified by the teach suite (44 checks).
|
||
|
||
108. **The clock that learned the engine's pace.** "The tts dispatcher thing and the text highlight is
|
||
completely out of sync on desktop" — Firefox + speech-dispatcher (like iOS) reports **no
|
||
`onboundary` events**, so the highlight rides the estimated clock, and that estimate drifts
|
||
seconds behind or ahead over a 130-word section; engines also speak at their own pace (espeak
|
||
is nothing like 150 wpm). The system engine now speaks **one sentence at a time** on those
|
||
platforms and re-anchors at every finish: each utterance's *measured* duration is compared
|
||
with its estimate, and a smoothed factor (half old, half new, clamped to 0.25–4×) scales the
|
||
next stretch — so the clock follows the engine instead of guessing, and the drift can never
|
||
accumulate past a sentence. The estimate itself got punctuation-aware too (a full stop costs
|
||
~260 ms, a comma ~130 ms: that is where these engines actually spend their time). Where
|
||
boundaries *do* arrive (Chrome), the first one switches the rest of the section back to a
|
||
single utterance with the exact clock, so the accurate path stays exactly as it was. Pause,
|
||
seek and resume are unchanged: they re-speak from a word, and the sentence lookups are derived
|
||
from the same token list. The suite's engine stub now *finishes* utterances and pins all of it:
|
||
one sentence first, the next sentence following on its own, the highlight staying inside the
|
||
spoken text, a boundary collapsing the rest of the section into one utterance, and the
|
||
highlight advancing over time (53 checks).
|
||
|
||
107. **The Aero soundtrack button rides above the podcast player.** "The music thing button with
|
||
the Frutiger Aero doesn't work on the podcasts": the podcast page is a full-screen overlay at
|
||
`z-45` and the music control sat at `z-40` — visible through the transparent page, but every
|
||
tap landed on the overlay. The control now takes a `data-raised="player"` position on
|
||
`/podcast/[id]`: `z-index: 46`, docked to the header's corner (`top` under the header pill,
|
||
`right` mirroring the `max-w-3xl` geometry) where neither the transport bar nor the voice panel
|
||
lives, and the transcript gained `pt-32` so the episode card clears it. The study/review/exam
|
||
feeds keep the "steps aside" behaviour — their bottom corners are spoken for. aero-music-test
|
||
grew a podcast section that asserts the control is there, that `elementFromPoint` at its centre
|
||
hits the button (nothing covers it) and that a tap expands the credit bar (31 checks).
|
||
|
||
106. **When the browser has no speech engine at all.** "The podcasts don't even work on my Linux
|
||
setup on Firefox": Firefox on Linux speaks through speech-dispatcher, and without it
|
||
`speechSynthesis` is *present* (so `speechAvailable()` says yes) but empty and silent —
|
||
`getVoices()` returns nothing and `speak()` does nothing, with no events. The player used to
|
||
sit there looking broken. Now the failure is named and has a way out: pressing play with zero
|
||
installed voices (or a start that never arrives within 1.7 s) raises a banner that says what is
|
||
missing — *"On Firefox/Linux install speech-dispatcher (sudo apt install speech-dispatcher) and
|
||
reopen this page"* — with **Use the natural voice** right there when a provider key exists (it
|
||
switches the source, starts playing and remembers the choice), **Add a provider key** when not
|
||
(it opens Settings), and a link into the voice picker. Natural-voice errors (a refused
|
||
autoplay, a provider refusal) are surfaced in the same place instead of hiding inside the
|
||
picker. The suite fakes a present-but-empty `speechSynthesis` and pins the banner, the Linux
|
||
line and the one-tap switch (48 checks).
|
||
|
||
105. **The play button that only worked once.** "It only plays once, and when you pause it you can't
|
||
unpause." WebKit's quirk, faithfully: `speechSynthesis.cancel()` leaves the synthesis **paused**,
|
||
and every later `speak()` is queued into that paused state — silently. Three fixes in the
|
||
system engine: `resume()` whenever the synth is paused before speaking (and after our own
|
||
cancel), a ~90 ms beat before a `speak()` that follows a cancel (several engines, Android
|
||
Chrome included, drop it otherwise), and no more unnecessary cancels. The engine also stopped
|
||
being rebuilt by speed or voice changes — both are read live through getters now, so a
|
||
`voiceschanged` that arrives after the first utterance can no longer leave a second engine
|
||
speaking over the first; a rate or voice change instead restarts the current part from the
|
||
same word (the Web Speech API cannot change either mid-utterance), which is what makes the
|
||
change audible at once. The suite's speech stub now *models* the quirk — a cancel pauses it
|
||
and a speak while paused is dropped and counted — and pins the fix: nothing may be spoken into
|
||
a paused engine (44 checks).
|
||
|
||
104. **Natural voices — and the crack at the end of every sentence.** Two reports, one root:
|
||
the platform's speech engine. "There's a loud cracking sound at the end of every sentence" is
|
||
the browser/OS TTS (iOS and several Linux engines pop between their internal sentence chunks,
|
||
and an unnecessary `cancel()` makes it worse) — nothing in our code touches audio there. Both
|
||
halves are answered: the system path got hygiene (never cancel while idle, strip control
|
||
characters and normalise ellipses/dashes before speaking, and a 140 ms beat between sections
|
||
so speech never runs into the engine's tail), and the real fix is a second voice source that
|
||
does not use the platform engine at all. `lib/providers` now carries TTS profiles (OpenAI
|
||
`gpt-4o-mini-tts`, Groq `playai-tts`, and any OpenAI-compatible `/audio/speech` the custom
|
||
provider points at; male voices first), `/api/tts` proxies them so the key still never leaves
|
||
the browser, and the player's picker gains a **System / Natural** switch with the provider's
|
||
voice list. The natural engine (`lib/podcast-audio`) plays one clip per section through an
|
||
`<audio>` element: the clip's real duration is measured and each word gets a slice of it
|
||
proportional to its spoken length, so the highlight follows the audio exactly instead of
|
||
estimating — and pause, seek and chapter jumps become real audio operations. Clips are cached
|
||
per session, the next section is generated while the current one plays, and switching voice
|
||
disposes the old element and its blobs. The suite stubs the TTS endpoint with a real silent
|
||
WAV and pins the whole path (voice list, request body, one playing element, the highlight
|
||
riding `currentTime`) — 43 checks.
|
||
|
||
103. **Podcast mode: your notes, read aloud, highlighted word by word.** The third segment of
|
||
the note-powered wizards (Study deck ⇄ Homework ⇄ Podcast) takes the same intake — photos,
|
||
documents, pasted text, the share target — and turns it into a spoken episode instead of
|
||
activities: `PODCAST_SYSTEM_PROMPT` asks for plain prose written for the ear (no markdown, no
|
||
lists, no "as you can see"), chaptered into 3-8 sections of 60-140 words around a
|
||
length target, and the new `/api/podcast` route mirrors `/api/generate`'s plumbing exactly
|
||
(rate limit, model gate, provider keys, heartbeats, the 2x deadline, the optional vision pass).
|
||
Episodes are stored like homework results (`freezmo.podcasts.v1`, zod-validated, riding the
|
||
backup and the encrypted sync payload) and listed in a new library tab with listen / rename /
|
||
delete-with-undo.
|
||
The player is the Spotify-lyrics part: the transcript is word-wrapped spans, and the word
|
||
under the voice rides a highlight that keeps itself centred as the episode plays. Two clocks
|
||
drive it — `onboundary` where the browser reports it (the character index maps onto the word
|
||
list) and a per-word length timer where it does not (iOS Safari, several Android engines);
|
||
the first boundary seen retires the timer for the session. Pause is a cancel plus a
|
||
re-speak-from-word, which makes "resume", "jump to a chapter" and "tap any word to seek" the
|
||
same operation, and the position is saved on pause, page-hide and unmount, so reopening an
|
||
episode resumes where you stopped. A speed button rebuilds the engine and keeps playing, the
|
||
Aero soundtrack pauses itself when the voice starts (`freezmo:foreground-audio`), and a
|
||
browser with no installed voice gets an honest note with the transcript still readable. The
|
||
script sits on one glass pane — in Aero, plain paragraphs over grass and sun failed
|
||
contrast, and the scan now covers this screen too. `tests/podcast-test.mjs` (17th suite)
|
||
stubs both the model and the speech engine: it drives a boundary event, checks the
|
||
pause/resume word, the chapter jump, the speed change, the stored position and the plugin's
|
||
400/stream guards. **Follow-up (0.7.4):** the scroll follows by *line*, not by word — words on
|
||
the same line never move the page (that was the jitter), a line only scrolls once it leaves a
|
||
comfortable band, small corrections are instant instead of animated, and a manual wheel,
|
||
touch or drag pauses the following for four seconds instead of fighting the reader; a seek
|
||
(chapter, word tap) re-centres at once. And the voice is now a choice: `lib/podcast-voices`
|
||
ranks what the platform has installed (natural/neural/online names up, espeak/SAM/compact
|
||
down, a male name preferred — "preferably male" was the ask), the player picks the best one
|
||
automatically and offers a picker for everything in the episode's language, and the choice is
|
||
remembered per language. The suite grew to 34 checks (voice ranking, the filtered picker,
|
||
persisting a pick, same-line silence, line following, manual-scroll suspension). **Follow-up
|
||
2 (0.7.5):** iOS is the special case the picker cannot fix — Safari hands web pages one voice
|
||
per language, so on an iPhone the panel now carries the settings path instead (Accessibility →
|
||
Spoken Content → Voices → language → Enhanced/Premium, set as preferred) and the speaker
|
||
button wears an amber dot while that note is waiting; the voice-less banner appends the same
|
||
guidance. Detected via the user agent (iPadOS included through `maxTouchPoints`); the suite
|
||
fakes an iPhone and pins the note, its steps and the dot (37 checks).
|
||
|
||
102. **An API key is not a login.** "When trying to use a different provider on the web app it
|
||
glitches out completely" — a photo of the first-run gate with iOS's saved-password sheet laid
|
||
over it ("password for proxmox…"). Two causes, both fixed. (1) The key fields were
|
||
`type="password"` with `autocomplete="off"`, which iOS/Safari (and 1Password/LastPass) read as
|
||
a login and answer with their own overlay; the gate, Settings and the provider rows now carry
|
||
`autoComplete="new-password"`, `data-form-type="other"`, `data-lpignore="true"`,
|
||
`autoCorrect`/`autoCapitalize="off"` and `inputMode="text"` — the mask stays, the password UI
|
||
goes. (2) The gate's keyboard handling scrolled to the *Continue* button rather than the field
|
||
being edited, so with the provider rows open the keyboard covered the input and the locked
|
||
wrapper gave no way to reach it; the wrapper stays user-scrollable, alignment now anchors on
|
||
the focused element (Continue is only the fallback), and the corner controls hide while the
|
||
keyboard is up instead of landing on the card. The providers suite drives a fake
|
||
`visualViewport` (shrunk to 340px, installed before the app mounts) and pins all of it: the
|
||
field stays in view, the card stays scrollable, the corners step aside — plus the
|
||
autofill-hardening attributes on the field itself.
|
||
|
||
101. **Frutiger Aero has its soundtrack.** Picking the theme starts a random track from the
|
||
Frutiger Aero playlist (Bigblamboii's set on SoundCloud) and draws every following track at
|
||
random too, so the set shuffles itself. The player is the credit bar from the user's mock: a
|
||
44px glass circle with a note by default, expanding into pause + the track's own title +
|
||
"SoundCloud · artist" (the widget reports both; the fallbacks are the playlist's own title and
|
||
author). Details that matter: the SoundCloud embed and its widget API are only ever created
|
||
while Aero is on and the listener has not turned it off — light/dark request nothing from
|
||
soundcloud.com, and a suite check pins that; the off choice persists (`freezmo.aero-music.v1`)
|
||
while re-selecting Aero in Settings is fresh consent (`applyTheme` clears the flag); volume
|
||
starts at 45; the iframe is 1px and inert, only the audio engine; the control clears the phone tab bar, lifts above the chat composer and the
|
||
wizard bar, steps aside under immersive overlays and while the cookie notice shares the
|
||
corner, and the credit area collapses back to the circle without stopping the music. The
|
||
cookie policy gained the one third-party-embed paragraph it was owed (soundcloud.com may set
|
||
its own cookies; Freezmo still sets none). `tests/aero-music-test.mjs` stubs the widget API
|
||
and pins all of it (28 checks). **Follow-up:** it must not need a tap on the player at all. The
|
||
embed URL now carries `auto_play=true` as a second try, and when the browser still refuses
|
||
(a reload has no gesture) the retry rides the next `pointerdown`/`keydown` anywhere *outside*
|
||
the player — so the first tap on anything starts the music, with a 700ms throttle so a flurry
|
||
of taps cannot skip through the set. An explicit pause or the × is respected: no later gesture
|
||
undoes it. The suite simulates the refusal (`__scAllowPlay`) and pins all three paths.
|
||
|
||
100. **The review breakdown carries each deck's own total.** The follow-up to 99: "bro its not
|
||
possible, I only have 2 decks" — the screenshot showed three deck cards (one clipped at the
|
||
window edge) and a breakdown of 15 + 13 + 6 = 34, so the count was right and the *number of
|
||
decks* was the misread. To make that checkable inline, each entry now carries the deck's
|
||
activity total next to its due count — "Due by deck: Renesanční… · 15 of 15 · Humanismus… ·
|
||
13 of 15 · Renesance… · 6 of 13" — so the arithmetic (34 due out of 43 activities) is
|
||
verifiable without opening a single deck. review-test pins the format.
|
||
|
||
99. **The smart review names its decks.** "It contains mistakes I either did more than once or
|
||
from decks that I've removed — there's no way there's 34 to repeat with 2 decks." The count
|
||
itself was honest: `dueItems`/`mistakeItems` only resolve live (deck, activity) pairs, so a
|
||
removed deck can never contribute — verified with a seeded repro (37 SRS rows, 30 live → "30
|
||
due"). What was missing was *where* each item comes from: a deck created twice from the same
|
||
notes shows every question twice, and a deck that survived a delete (or a sync) looks like a
|
||
ghost — with no deck name anywhere in the session, both read as a broken list. Now the session
|
||
carries a deck chip above every card (review and mistakes; per-deck mistakes sessions keep
|
||
their titled header instead), and the Smart review card breaks the counts down under the
|
||
buttons — "Due by deck: Sky deck · 17 · Sky deck (2) · 17", capped at three decks plus
|
||
"+n more" — so 34 over two visible decks is self-explanatory at a glance. The review suite
|
||
pins both: a twin-deck seed renders the breakdown and both deck chips in the feed.
|
||
|
||
97. **Nested tiles inside the real-blur chrome drop the baked backdrop.** "Frutiger aero theme —
|
||
the attachment button looks completely different to the other ones." The composer band, the
|
||
chat header and the study pager keep a real `backdrop-filter` (they float over scrolling
|
||
content), but Chrome cannot anchor a `background-attachment: fixed` layer *inside* a blurred
|
||
ancestor: the bake is laid out against the element itself, so the attach button sampled a tiny
|
||
(often dark, sometimes oddly green) crop of the wallpaper instead of the glass behind it — a
|
||
solid, mismatched tile next to the pale field. Fix in the same spirit as the sheet carve-out:
|
||
inside `.glass-panel` / `.study-chrome` / `.glass` / `.tabbar` the nesting chrome (`surface`
|
||
tokens, white/xx fills, chips, cards) drops the bake and keeps its flat token fill — the
|
||
panel's own blur *is* the glass — while the composer's textarea keeps its brighter tile
|
||
recipe (exempted by `:not()`), and `.aero .btn-ghost` gains the same carve-out so the study
|
||
pager's arrows sit flat in the pill. Probes pin it: the attach tile now samples the band
|
||
(115,137,118) instead of a dark mis-crop (73,102,77), all suites green, contrast scan 0.
|
||
|
||
96. **Skipping a card is not a mistake.** "You can just scroll all the way down and it counts
|
||
everything as incorrect even if you technically just skip it to do it later." The study feed's
|
||
results treated *every* card without a result as missed: swiping to the end recorded a 0%
|
||
attempt against the deck and listed all twelve cards under "Missed items" with their answers.
|
||
Now only answered cards count: the score line is `correct / answered`, the attempt records the
|
||
answered count, an empty run records nothing at all, and the cards you scrolled past appear
|
||
under **Not answered (n)** — listed as prompts, labelled "not counted as mistakes", with
|
||
**Answer the rest (n)** starting a catch-up run of exactly those cards (the same `restart`
|
||
path as retry-missed, so history and the SRS see the answers as usual). "Aced all" now also
|
||
requires that nothing was left waiting. Phase 2 pins the whole flow: all-skipped → no attempt,
|
||
no mistake list, no score line; one-of-two → "You got 1 of 1 right", "Not answered (1)",
|
||
`attempts: 1, bestScore: 100`; the catch-up run records the remaining answer. Exam mode keeps
|
||
its own rule — in a timed exam an unanswered question *is* a miss.
|
||
|
||
95. **Aero, second pass: a heavier shared blur, and a field that reads as a field.** Two follow-ups
|
||
on 94. The composer's input was still a flat 60% white bar (the "clean, even tile" from 91) on
|
||
a panel that had — rightly — become translucent, so it read as a white slab: "especially the
|
||
part where you enter the prompt". It is now the app's standard frosted tile plus one light
|
||
veil; measured against the same backdrop, the field sits at `178,212,157` where the panel is
|
||
`145,183,79`, so it is clearly a brighter *field* while the scene shows through. And because
|
||
translucent glass needs softness rather than opacity, the shared backdrop
|
||
(`background-blur.webp`, the pre-blurred copy every Aero surface sits on) was re-baked much
|
||
softer — a 64×36 upscale instead of the old half-res copy — so panels, cards, chips and
|
||
buttons all show the scene as an even frosted wash: nothing to read as a muddy patch, nothing
|
||
turned white. `?v=5` busts the asset. Library, chat, study and settings screens all scan at 0
|
||
below-minimum contrast findings in all three themes; phone touch still strips the bake
|
||
entirely (flat 32% glass, as in 93).
|
||
|
||
94. **Aero glass goes back to glass (the "even wash" read as a white pane).** The uniform wash
|
||
(`background-glass.webp`) from decisions 91/93 made the rail and the chat chrome nearly
|
||
opaque: "why is the sidebar so white and non-translucent on the frutiger theme now? and the
|
||
chat mode looks even worse... its supposed to be translucent and blurry!". The rail, the chat
|
||
header, the welcome card and the composer now use the *same* recipe as every other surface in
|
||
the app — `background-blur.webp` under the shared gloss + veil + `--glass-bg`, exactly what
|
||
the deck cards do — and the dedicated wash asset is deleted. Measured on the pixels: the rail
|
||
runs from `181,229,241` over the sky to `133,174,75` over the grass (Δ166, unmistakably
|
||
translucent), while the chat and library screens still scan at 0 below-minimum text-contrast
|
||
findings in all three themes (Aero's near-black text tokens carry the thin glass). The chat
|
||
suite pins both halves of that: the shared recipe on all four panels *and* a translucency
|
||
check that the rail's tint follows the scene, so a future "make it uniform" pass cannot
|
||
quietly reintroduce an opaque panel. Phone touch keeps the flat 32% Ask-pill glass from 93.
|
||
|
||
93. **Phone Aero glass, and a timed run you can actually see.** Two follow-ups from a phone.
|
||
- *The chat read as a white pane.* On touch devices the chat header, welcome card and composer
|
||
carried a 55% flat white veil while every other surface in the app used the thin 32%
|
||
Ask-pill tint — over a bright sky that is "fully white with no transparency", nothing like
|
||
the rest of the theme. They now use the same flat glass as the cards (the baked wallpaper
|
||
layer stays desktop-only, exactly as on every other surface), the answer and user bubbles
|
||
keep a touch more body for long reading (42%), and the composer's field is a lighter 42%
|
||
tile of the same family. Ordering matters and is now pinned by a comment: the shared wash
|
||
block sits *before* the touch media query, otherwise its later `background-image` wins and
|
||
the bake (and its late repaint) comes back on phones. The chat screen's pixel-contrast scan
|
||
is clean in all three themes at 390px — the one earlier finding was the Next.js dev-tools
|
||
badge sitting over the disclaimer, not the panel.
|
||
- *The timer looked dead.* It worked (30 → 0, marked as a miss, toast, next card) but the only
|
||
cue was a small chip among the streak/XP counters, so turning it on seemed to do nothing.
|
||
The chrome pill now draws the countdown along its lower edge (`[data-timed-bar]`), the chip
|
||
has a border so it reads as a control, it turns rose together with the bar in the last ten
|
||
seconds, and a soft haptic tick fires at ten seconds. The activities suite pins the bar as
|
||
well as the countdown.
|
||
|
||
92. **Study cards: where the answer came from, read aloud, and timed runs.** Three additions to
|
||
the study feed, all local and dependency-free.
|
||
- **From your notes.** The generator now returns `source` with every activity: the verbatim
|
||
words from the notes that prove the answer (max 25 words; `clampQuote` in `lib/ai.ts` trims
|
||
anything longer to a clean cut, so a chatty model cannot drop a card). After an answer the
|
||
feedback panel shows the sentence that carries it with the quote highlighted green
|
||
(`mark`). Decks made before the change — and quotes the model trimmed — fall back to a
|
||
local, IDF-weighted match over the stored notes text (`lib/notes-source.ts`): a query from
|
||
the activity's own wording, sentence scoring where rare words weigh more, word-boundary
|
||
highlighting, and nothing shown unless at least two distinct terms land (a random sentence
|
||
would be worse than silence). It disappears in exam mode with the other reveals.
|
||
- **Read aloud.** A speaker control on the text a learner is meant to read (both flashcard
|
||
faces, the visible one only; questions, statements, sentences, instructions) on the Web
|
||
Speech API (`lib/speech.ts`, `components/SpeakButton.tsx`): one utterance at a time
|
||
app-wide, a voice matched to the deck's language (BCP-47 table, interface locale as the
|
||
fallback, 0.95 rate), and no button at all where the browser has no speech synthesis.
|
||
Tapping the speaker used to flip the flashcard — it sat inside the flip button (nested
|
||
buttons) — so it now lives next to the hint row, outside the flip target.
|
||
- **Timed runs.** The feed chrome carries a timer chip (off by default): on, each card gets
|
||
30 seconds (`TIMED_CARD_SECONDS`), the chip counts down, and a card that runs out is
|
||
recorded as a miss through the same path as a wrong answer (history + SRS agree), shows a
|
||
toast, and the feed moves straight on to the next card with a fresh budget. The auto-advance
|
||
is deliberately not deferred: recording the miss re-renders the feed and would cancel a
|
||
queued scroll with its effect cleanup. `handleAnswered` now ignores a second result for the
|
||
same card, so a timeout followed by a manual answer cannot write two history entries.
|
||
- The generator's type spec now names what each type suits (flashcards, matching and fill in
|
||
the blank for language decks; ordering for real sequences; short answer for reasoning), so
|
||
the deck-aware guidance covers all seven types, not just ordering.
|
||
New suite `activities-test` (24 checks: quote path, local path, the green mark, the speech
|
||
stub — real `SpeechSynthesisUtterance`, recording `speak`/`cancel` — and the timed run with
|
||
Playwright's fake clock), registered in run-all; the exam suite pins that the notes passage
|
||
stays hidden with the other reveals; en + cs keys for every new string; no new dependencies.
|
||
|
||
91. **The chat chrome floats: one gutter, one radius scale, one glass recipe.** The header and
|
||
the composer were full-bleed bands inside the main pane — flush to the viewport's top, right
|
||
and bottom edges, with the composer's bottom corners cropped off-screen — while the rail
|
||
floated with a 12px margin. `--layout-gutter` (12px, the rail's own inset) now spaces the
|
||
rail, the header, the empty-state card and the composer identically on all four sides, and
|
||
the radius scale (`--radius-panel` 24px, `--radius-control` 14px, `--radius-chip` 12px,
|
||
`--radius-pill`) replaced the ad-hoc values across the shared classes (`.card`, `.glass-panel`,
|
||
`.sidebar-surface`, buttons, nav rows, `.segmented`, chips), so every panel is rounded on all
|
||
four corners and shared controls match in every theme. The desktop-only `::after` hairlines
|
||
and the left-corner-only radius of decision 87 are gone.
|
||
|
||
In Aero the rail and the chat chrome (answers included) share **one even recipe**: the
|
||
wallpaper, extra-blurred into a hue-free wash (`background-glass.webp`), under a uniform
|
||
~50% white veil and a whisper of gloss. Measured: the rail top→bottom and the card/composer
|
||
left→right stay within ~10 RGB units (the old rail ran clear blue into a muddy green smear,
|
||
and the composer picked up the river's teal). Chat-specific fixes: **send** is a filled
|
||
primary tile shaped exactly like attach (dimmed while the input is empty — it used to be a
|
||
pale outline that read as disabled), the suggestion/context **chips** use the shared `.chip`
|
||
token (40px target, 44px on phones, visible rim, full-contrast text, the raw model id in the
|
||
tooltip), the AI note reads at 12px `--text-muted` (`.ai-disclaimer`), the sidebar's selected
|
||
row carries the accent border + accent icon (it looked like one more tinted action button),
|
||
the **theme toggle** is back in Aero — the moon flips to Dark, the Settings picker brings Aero
|
||
back — and the empty state is centered exactly between the header and the composer (the
|
||
trailing spacer only exists under a conversation now). Three fixes live outside CSS: the
|
||
wallpaper's **HUD-circle decoration** in the top-left corner (it peeked through the 12px
|
||
gutters on wide windows and read as a glitch) was healed out of `background.webp` and
|
||
`background-blur.webp`; the orbs and bubbles ride one masked `.ice-floats` layer that fades
|
||
them out under the chrome bands and the rail's strip, so none is sliced by a panel edge (still
|
||
parked under reduced motion); and the panel shadows/highlights come from the shared glass
|
||
tokens. The chat suite pins the geometry (12px gutter on all sides, 24px corners, the even
|
||
wash on all four panels, send-vs-attach shape, chips/input radii) and mobile pins the phone
|
||
chrome plus the Aero toggle.
|
||
|
||
90. **Copy pass on the shell (`lib/i18n-dict.ts`).** A user-authored wording pass: the library heading
|
||
is now "Your freezer" (matching the Czech "Tvůj mrazák"), the key gate's subtitle speaks about
|
||
grades ("První krok k dobrým známkám"), and several Czech strings read more naturally ("Schovat /
|
||
Prodloužit panel", "Vibrace na podporovaných zařízení", "Všechno je uloženo v tomto prohlížeči",
|
||
"Vizuální model (překládá fotky na text)", "radši ověř"). The five suites that pinned the old
|
||
library heading were updated with it; key parity is unchanged.
|
||
|
||
89. **Codebase-wide audit (bugs, performance, dead code, edge cases).**
|
||
- *Bugs fixed:* Backup schema now accepts `theme: "aero"` (backups exported under Frutiger Aero
|
||
used to fail `parseBackup` on import), and `applyImport` in `"replace"` mode now replaces SRS
|
||
schedules, answer history and chats instead of merging into existing SRS or skipping empty
|
||
arrays; typed `FillBlank` cards (without word-bank options) now bind the `<input>` to the live
|
||
typed state (`chosen ?? value` instead of `chosen ?? ""`) and preserve non-ASCII Unicode
|
||
letters (`\p{L}\p{N}`) when checking answers; OpenAI-compatible vision calls (`contentFor` in
|
||
`lib/openai-compat.ts`) now prefix `data:image/jpeg;base64,` when routes pass stripped base64;
|
||
streaming calls (`ollamaChatStream`, `providerChatStream`) keep their `AbortController`
|
||
attached for the entire body stream and API routes forward `request.signal` so stopping a chat
|
||
or cancelling a run aborts the upstream request immediately; `HomeworkCard` uses
|
||
`homeworkModeLabelKey` so `"check"` records show "Checked" instead of "Step-by-step guide";
|
||
desktop dropzones on `/create` and `/homework` accept dropped documents as well as images;
|
||
`LibraryScreen` excludes reported questions from its due and mistake counts (matching
|
||
`ReviewClient`); clearing a provider key resets `generation` if it pointed at that provider,
|
||
chat conversations persist and restore `provider`, and `ModelPicker` keys items by
|
||
`provider:name`; `RouteTransition` no longer mutates module-level `lastPath` during render;
|
||
`public/sw.js` falls back to `Response.error()` when an uncached asset fetch fails offline;
|
||
confetti and `.ice-orb`/`.ice-bubble` respect the in-app `reduce-motion` class.
|
||
- *Performance & cleanup:* The six `localStorage` stores now attach a single `storage` listener
|
||
while subscribed instead of one per subscriber (avoiding $N^2$ notifications and $N\times$ JSON
|
||
parses); `LibraryScreen` uses `todayAnswerCount` instead of building the full 35-day stats
|
||
object on every render and passes sorted `folders` down to `DeckCard`; `MessageRow` is wrapped
|
||
in `React.memo` with stable callbacks so streaming tokens only re-render the active message;
|
||
`prepareImageFromBlob` closes its `ImageBitmap` and `extractPdf` destroys the `pdf.js`
|
||
document proxy in `finally`; `POST /api/sync` no longer re-parses the serialized sync blob;
|
||
`lib/share.ts` reuses `copyText` from `lib/clipboard.ts` (including its HTTP LAN fallback);
|
||
and unused helpers and `create-next-app` SVG placeholders in `public/` were removed.
|
||
|
||
88. **A custom 404, Frutiger Aero style.** Unknown URLs used to fall through to Next's default
|
||
"This page could not be found". `app/not-found.tsx` now renders a standalone screen — the root
|
||
layout's ice scene is the backdrop, and the glass card, the rim and the rising bubbles come from
|
||
the shared recipes, so it is full Frutiger Aero while that theme is on and stays in the
|
||
frosted-glass language in light and dark. It wears the scene's glossy ice orb
|
||
(`.not-found-orb`, at full strength so the snowflake reads) with a "404" chip, "This page melted
|
||
away", and two ways out: back to the library and into the chat (44px targets, en + cs). The
|
||
regression suite pins the 404 status, both links and the Czech copy; the pixel-contrast scan now
|
||
covers the screen in all three themes. The service worker also stopped caching non-OK
|
||
navigations as the offline shell — a typo'd URL used to write the error page into the shell
|
||
cache.
|
||
|
||
87. **Chat's chrome ends at the rail with a rounded corner (the sidebar seam).** The chat header
|
||
and composer are full-bleed bands inside the main pane; at desktop widths that pane starts right
|
||
at the sidebar, so both bands ended in a hard vertical edge beside the floating rail card — the
|
||
12px gutter showed the raw scene and the bands' fill + blur made it read as a color step at the
|
||
seam. Docking them into the card did not fix it either ("this seam still looks bad" — a straight
|
||
cut is still a straight cut): both bands now keep the rail's own 12px gutter and end in the
|
||
card's 1.5rem radius on their left side, so the ending is legible as deliberate, with their
|
||
hairlines inset instead of touching the seam (the header divider spans the flat part of the
|
||
bottom edge, from the corner curve inward). Phones keep the plain full-width borders. The chat
|
||
suite pins the geometry (12px gutter, 24px left corners, zeroed band borders, inset `::after`
|
||
hairlines). The "Chat is set to ignore notes text" banner went away with it — the scope chip
|
||
already says what the chat may read.
|
||
|
||
86. **Reduced motion no longer ghosts the sheets.** The in-app toggle fed framer through
|
||
`MotionConfig reducedMotion="always"`, which only strips *transform* animations — while every
|
||
component branched on the OS-only `useReducedMotion`. So a translucent sheet still spring-faded
|
||
its opacity in over the page, showing the page through it for ~300 ms ("ugly visual glitch with
|
||
the settings panel when reduced motion is on"). All 16 components now read
|
||
`useAppReducedMotion()` (the OS preference *or* the toggle), and the settings sheet + scrim and
|
||
the library's phone sheet go instant under reduced motion. Verified frame by frame: opacity
|
||
1.00 from the very first frame.
|
||
|
||
85. **Aero's buttons read as buttons, and homework results can be reported.** Two reports from one
|
||
screenshot. The recipe's thin veils let secondary/ghost buttons blend into the grass and sky
|
||
("why are there buttons which dont have any background"): `btn-secondary`, `btn-ghost` and
|
||
`glass-pill` now carry a stronger pale-glass veil (the "Ask pill" look) over the blurred scene,
|
||
with ghosts also getting a rim and full-contrast text. And the homework make/check results had
|
||
no report control — both now carry the same red "!" as the study feeds and the guide, mapping
|
||
the result into the existing report flow through a synthetic short-answer activity (the report
|
||
keeps only the flattened question/type/id), pinned in phase3's result-flow checks.
|
||
|
||
84. **"Available" means proven, not just listed.** With a cloud Ollama key the picker marked every
|
||
model from /api/tags as *available* — but a free plan can list models it cannot run, so the
|
||
badge promised more than the account could keep. A tag listing now proves availability only on
|
||
a local daemon (there the listing *is* the installed set); on the cloud, and for provider seeds
|
||
before their first live /models test, the badge stays off until the model is actually used. The
|
||
configured favourites keep their verified state — that is what the health probe really checks.
|
||
|
||
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 — the shipped `deploy/freezmo-reminders.{service,timer}`
|
||
pair. Verified by the phase-2 suite (81 checks: history and SRS shapes, the review/mistakes
|
||
sessions, stats math, exam with timer and breakdowns, the VAPID-less hidden state, the
|
||
configured subscribe/validate/test/delete path, and the reminder each device composes from its
|
||
own notes in both languages; 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.
|