# ❄️ 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 `
` so the screen entrance animation can never displace it. While a run is generating, the wizard's **mode switch hides** — a loading screen can't be swapped to the other wizard mid-flight — and leaving the page anyway aborts the request instead of saving a deck in the background. The desktop page is unchanged. - **Native-feel touches (phones)**: the Android Back gesture closes the topmost layer first (sheet, actions sheet, then one wizard step); deleting a deck shows an *Undo* toast instead of a confirm; the deck menu can share a deck as JSON through the system share sheet (clipboard fallback); content shared from other apps opens a chooser and lands pre-filled in either wizard; haptics (Settings → Haptics, on by default) tick on tab switches, toggles, correct/wrong answers and completion; the screen stays awake during study sessions and homework guides; wizard drafts survive a reload or an OS kill; re-tapping the active tab scrolls to the top; an offline pill appears and generation is disabled with a reason until the connection is back. - **Chat with your notes**: an LLM-style conversation grounded in your decks. Ask from the Library, a deck's menu, or a result; pick the scope (all notes, folders/decks, or none) and the model; photos and documents are transcribed first, so any model works. Answers stream with stop, copy, regenerate and edit-and-resend, render safe markdown + math, and hide reasoning behind a **Thinking** row. See [Chat](#chat). - **Learning engine**: every answer is recorded (deck, activity, topic, correct/wrong, time) in an IndexedDB log, and each activity gets a light **SM-2 schedule** — correct answers come back after 1 day, then 3, then interval × ease; wrong ones after ten minutes. The Library shows a **Smart review** card with the due count, a **Mistakes** count (activities whose latest answer was wrong — they clear themselves once answered correctly) and today's **daily goal** progress. Tapping either one shows a **loading screen instantly** (the link reports its own pending state, the route has a loading boundary, and the payload is prefetched), so even a slow first request never looks like a dead tap. A **Progress** tab has the day streak, a five-week calendar, accuracy per subject and per deck and the weakest topics. **Exam mode** (its own tab) takes one or more decks, a question count and an optional timer, hides hints and shows a per-deck/per-topic breakdown at the end. **Daily reminders** appear in Settings only when the server has VAPID keys configured; subscriptions are stored as endpoint + keys only, and `node scripts/send-reminders.mjs` (cron/systemd timer) does the sending. - **Backup, sync and limits**: *Settings → Data & backup* exports **everything** (decks, folders, homework, progress, safe settings — never the API key) to one JSON file and imports a backup with a preview and a merge/replace choice; a gentle, dismissible nudge appears after 5 new decks or 14 days without a backup, and the storage-persistence state (`navigator.storage.persist()`) is shown. *Settings → Sync* is **off by default** and opt-in: the data is encrypted in the browser (PBKDF2-SHA256 → AES-GCM, key kept as a non-extractable `CryptoKey`) before it is uploaded, the server stores only an opaque blob under a random 128-bit sync ID, and conflicts are surfaced with an explicit *keep this device / use synced copy* choice. When Ollama's **free monthly usage** runs out, every flow that calls the model shows the same clear message with **Open Settings** (jumps to the key field) and **Open Ollama** — never a silent fallback to another model. - **Photos and documents**: JPG/PNG/WEBP/HEIC images plus **PDF, DOCX, PPTX, ODT/ODP/ODS, TXT, MD, CSV** - Documents are read **in the browser**: PDF text layers are extracted, scanned PDF pages are rendered as images for the vision model, DOCX/PPTX text, speaker notes and embedded images are pulled out. The raw file never leaves your device. - Up to 8 photos + 6 documents per deck (max 12 images reach the model); photos are resized to max 1600 px and re-encoded as JPEG before upload - Optional extra text (pasted notes) and a topic hint - Easy / Medium / Hard difficulty and 8 / 10 / 12 / 15 activities per deck - Fun staged loading state (`Reading your notes… → Finding the key ideas… → Building your activities…`) - Friendly error cards with a **Retry** button that point at Settings when a key is needed **Language** - The notes' language is detected and **preserved end to end**: transcription, deck title, questions, options, explanations, short-answer grading and later *regenerate* / *add more* all stay in that language. Czech notes produce a Czech deck; the app never translates them to English. - **Language guard:** after the vision step, the transcription is checked (script + language markers). If it clearly came back in another language, `TEXT_MODEL` (which is much stronger multilingually) restores it before any activities are written — the UI shows *“Restoring the notes' language…”*. On good transcriptions the guard stays silent and costs nothing. - The deck stores its language, so short answers are graded and feedback written in the same language. - The **Freezmo interface** has its own language switch: **English (default)** and a full **Czech** translation, picked in **Settings → Language** or from the `EN`/`CS` button on the key gate. The chosen locale is remembered per browser and also decides the language of server messages (API errors, health notes), so nothing falls back to English mid-sentence. **Study feed** - Full-screen vertical scroll-snap feed, one activity per screen - Arrow-key / PageUp / PageDown / Home / End navigation, plus swipe on touch devices - Progress bar, activity counter, XP counter and answer streak in the chrome - Instant feedback with a short explanation for every activity, confetti when you finish - Results screen: **Retry missed ones** (fresh, answerable items), **Study the whole deck again**, and **Regenerate deck** — rebuilds the activities from the stored notes so you can keep drilling until you're ready **Make my homework** - Same intake as a deck — paste the assignment, photograph it, or add a PDF/DOCX/PPTX/ODT/text file; photos go through the same vision model and language guard that notes do - Three modes: **Just make it** returns the finished homework (the answer only, ready to copy), **Explain how to make this homework** turns it into a guided walkthrough, and **Check my work** marks the student's own attempt — what's right, what's wrong and why, plus a corrected version (the attempt is typed or pasted; photos of handwriting are not supported yet, and the UI says so) - **Follow-ups on a result**: *Shorter*, *More detail* and back to the *Original* rewrite the finished text in place; inside a guide every step has *Explain step N differently* - **Turn into a study deck** on any result opens New deck with the content (the answer, the corrected version or the assembled guide) and the options step pre-filled - On phones this is a **2-step flow**: the assignment first (same simplified input as the deck flow, with its own step headline, formats hint, big add area and empty placeholder), then the mode cards under a short preview — tapping a card starts it immediately (the check card first reveals the solution field), so there is no separate button. Step 1 starts with *Cancel* and the flat action bar matches the deck wizard. Desktop keeps the select-then-confirm layout. - The guide is one step per screen in the same snap-feed feel as the study feed: what to do and why, **three progressive hints** per step (nudge → hint → the full answer), and a **checkpoint** built from the normal activity components and graded through `/api/grade` - The last screen assembles the solution from the step answers, with a copy button - Results live in `freezmo.homework.v1` — the homework screen's history and the library's **Homework** tab — where you can reopen or delete them; **Redo** re-runs from the stored assignment text (and the stored attempt for checks), and original files are never saved - The answer and the guide are always written in the **language of the assignment**, never translated into the interface language **Activity types** (each one is its own React component, rendered from JSON by a `type` field) | Type | Interaction | | --- | --- | | `flashcard` | Tap to flip, then self-assess *I knew it / Not yet* | | `multiple_choice` | 4 options, keys 1–4, correct answer + explanation | | `true_false` | Tap the buttons **or** swipe the card right/left | | `fill_blank` | Inline input (one typo forgiven) or a word bank | | `matching` | Tap a term, then its definition; perfect match for full XP | | `ordering` | Drag with the mouse/finger, or use ▲▼ buttons, then check | | `short_answer` | Typed answer graded by the AI via `/api/grade`, with a self-check fallback | **First run** - A full-screen **API-key gate** replaces the app until a key is saved (in `user-key` mode): one masked input with a show/hide toggle, a single **Continue**, and a "?" sheet with the four steps to a free key. The key is validated with a real 1-token probe before it is accepted — invalid keys and network trouble get different inline messages. Removing the key in Settings brings the gate back; `server` and `local` installs skip it entirely. **Library & folders** - **Two pools, never mixed**: a **Study Decks** tab (the folder + card library) and a **Homework** tab with saved solutions and step-by-step guides — each with its own empty state and call to action, and the active tab lives in the URL (`/?tab=homework`) - **Phone library, decluttered**: no stats line, the folder chips and “Unorganized” heading only appear once folders exist, and a **⋯** button next to the pool switcher opens a small sheet with **Import JSON** and **New folder**. Deck cards shrink to title + one meta line (activities · difficulty · best score) + a one-line description that expands on tap, with the whole card tappable and a small **Study** pill; homework cards match. - Decks saved locally with title, subject, folder, activity count, date, play count and best score - **Folders**: create named groups ("History", "Biology", …) from the sidebar or the create screen. New decks freeze straight into the selected folder; anything unfiled lives in **Unorganized**. Rename, delete (decks survive — they return to Unorganized) and move decks between folders - Rename, delete (with confirm), regenerate (from the library or the results screen), and **add more activities** to an existing deck - Export a deck as JSON and import it back — the folder name travels with the file and is recreated on import **Design** - **ChatGPT-style shell on desktop, five tabs on phones**: desktop keeps the persistent left **sidebar** (brand, a quiet "New deck" row, a *Folders* list with per-folder counts, Settings/Dark mode pinned to the bottom), which **collapses** to a 68px icon rail (remembered in `localStorage`). Phones have no drawer and no hamburger: a frosted header (logo + theme chip) sits above an iOS-style bottom tab bar — **Library · Progress · New deck · Exam · Settings** — with the New deck button as the prominent centre action and Settings opening the sheet. The wizards keep their own bottom action bar instead of the tab bar, and the in-page segmented switch moves between the deck and homework wizards. - **Frosted ice glass, both themes**: light page `#eef5fd` (pale ice) with white glass panels; dark page `#0a1120` with `rgba(255,255,255,.06)` glass. Every large surface shares one recipe — translucent tint, 24px `backdrop-blur` (16px on phones), a bright 1px rim, an inner top highlight and a blue-tinted shadow — over a fixed ice scene (navy/pale base, drifting glacier blobs, glossy ice orbs and a whisper of grain). Text tiers are documented AA ratios (see below), and the cards fill the row with `repeat(auto-fill, minmax(280px, 1fr))` — no stranded cards, no dead space - Deck cards: title → muted metadata → neutral tags (difficulty carries the only colour: green/amber/red) → summary → a single **Study** button, with everything else in the `⋯` menu - **Frosted UI**: the sticky header, tab bar, sheets and panels use real `backdrop-filter` blur over the fixed ice scene (drifting glacier blobs, glossy orbs, a whisper of grain) — depth without noise - **Pill language**: primary CTAs are near-black in light mode and white in dark mode; secondaries are outlined or ghost. Colour is reserved for meaning — activity tints (sky/lilac/mint/peach/rose), correct/wrong feedback, key status - Micro-interactions: 160–200ms colour/transform transitions, `active:scale-[0.985]`, skeleton shimmer, hairline hover washes on every row and card - **Motion (phones)**: one shared language (`lib/motion.ts`) — every button, card, chip, tab and nav row scales to 0.96 on press with a spring release; the tab bar, segmented controls and difficulty/size pickers glide their highlight with a shared `layoutId`; wizard steps slide forward and backward and page content enters direction-aware; the Settings sheet and the library actions sheet follow the finger and settle on release velocity; library cards fade up in a capped stagger and animate out on delete; score and XP count up; correct answers pulse, wrong ones shake; `prefers-reduced-motion` collapses everything to short fades. Only transform and opacity animate — never blur. - **iOS-flavoured PWA**: standalone mode, safe-area insets, bottom tab bar, sheet-style dialogs with a grabber, inset grouped lists, segmented controls, "Add to Home Screen" hint — see [Install as an app](#install-as-an-app-pwa) - Mobile-first, dark mode, keyboard accessible, focus rings, ARIA labels, `prefers-reduced-motion` support --- ## Quick start Requirements: Node.js 20+ (tested on 24) and npm. ```bash cd freezmo ./setup.sh # install + .env.local + production build npm start # http://0.0.0.0:3207 ``` Or do it by hand (and use the dev server): ```bash cd freezmo npm install cp .env.example .env.local # first time only npm run dev # http://0.0.0.0:3207 ``` `setup.sh` flags: `--dev` installs and prepares `.env.local` without building, `--no-build` skips the build too. Open` — JSX collapses newlines, so a step arrived as one run-on paragraph with the whole worked solution inside it (the reported "completely broken" step). The explanation, the progressive hints and the verdict/wrong entries of a check now render with `whitespace-pre-wrap` like the other homework results always did, and the guide prompt says the explanation is 2-4 sentences of coaching, with the finished working left to hint 3 and `answer`. 55. **Popovers close the way they open.** The chat's context ("what may chat read") and model chips used to only ever open: the sole way out was picking something, or Escape. The chip now toggles its own popover (and reads pressed while open, `aria-expanded`), opening one closes the other, and on desktop a pointer press anywhere outside closes it — except on the trigger chips themselves, which the outside handler ignores precisely so the toggle cannot re-open what it just closed. Phones keep the sheet + backdrop they already had. Verified by the chat suite (toggle, pressed state, switch, outside click). 54. **The study chrome is one pill, and it sits above the app chrome.** Progress header, close button, title, position and streak/xp (or the exam timer) used to be two stacked rows inside a `rounded-full` box — on a tall box that is a stadium, so the corner curves cut into the icon row and the controls looked like they were falling into the corners. It is now a single 58px pill: round close button, title + counter over the progress bar in the middle, chips at the far end, everything on the centre line — the same recipe in the study feed, review/exam sessions and the homework guide. The immersive feeds also carry `z-[45]`: on `/exam` the app header (`z-40`) used to paint over the top of the chrome. Verified by the phase-2 suite (the close button's corners stay inside the rounded end, single row, counter present). 53. **The flashcard flip never relies on `backface-visibility`.** The card is two faces in a `preserve-3d` container (the back pre-rotated 180°) — the classic CSS flip — but each face is a glass layer with `backdrop-filter`, and on some GPU/compositing paths the front's backface keeps rendering, so the flipped card showed the question mirrored over the answer. The faces are now painted by an explicit `visibility` swap driven by the animation's own rotation (the swap lands at the 90° edge-on moment, where it cannot be seen), so exactly one face is ever in the render tree. Reduced motion skips the spring and the swap still lands via `onAnimationComplete`. The phase-2 suite asserts the painted-face invariant at rest, flipped and flipped back. 52. **Reporting a question is local-first, with a server copy for the operator.** The red "!" under the card (bottom-right of the exercise — it used to sit in the top chrome, where it read as chrome and was easy to mis-tap) records the question in this browser — that local list is the skip state, so the card leaves study, review, mistakes and exam sessions, and Undo in the toast brings it back (the server file is append-only). It also POSTs a copy to `/api/reports`, which appends one JSON line to `shared/freezmo/reports/reports.jsonl`. The app has no auth, so the route is write-only: the question text is readable on the server (that is the point — prompt tuning) but never over HTTP. A reported card counts as "not attempted" in the results rather than wrong, and a deck where everything is reported stays studiable so it can be regenerated. Verified by the phase-1 suite (append, validation, no GET) and the phase-2 suite (button placement under the card with a 44px target, toast, server POST, local copy, skip, hidden next session, Undo, exam pool). 51. **Cards with repeated wording never ship.** The model sometimes pads a matching card with one definition copied across several terms (a Czech literature deck arrived on a phone with three identical definitions, so the card could not be solved by knowing the material), or repeats an option. The generation pipeline now rejects any card whose options, items, terms or definitions repeat (case-insensitive) — the same drop-or-repair path the ordering giveaway guard already used, so a deck with too few cards left over gets one repair round instead. The matching and ordering dedupe only trims a card when it still meets the schema's minimum afterwards: a shorter card would fail validation on the next read and take the whole deck down with it, so below the minimum the original is kept and the card is rejected instead. The generation prompt states the uniqueness rule too. Verified by the phase-1 suite's stub-Ollama generation: the repeated-definition card is dropped, a salvageable card is trimmed to three pairs, the repeated-option card is dropped and the good cards still ship. 41. **The learning log is an event log, so it lives in IndexedDB.** Decks and homework stay in `localStorage` (small, always loaded), but answers + SRS schedules go to a versioned `freezmo-learning` database with an in-memory mirror so React can read them synchronously via `useSyncExternalStore`; writes update the mirror first and persist asynchronously, and the log is capped at 20 000 answers. The schedule is deliberately simple (1 day → 3 days → interval × ease; wrong = ten minutes, ease −0.2): predictable beats clever, and the mistakes set needs no storage at all — it is *the latest answer per activity being wrong*, so it clears itself the moment the activity is answered correctly. Review and exam sessions **freeze their item list at start** (a bug the phase-2 suite caught: answering a mistake removed it from the live list and collapsed the session mid-run). Push reminders are all-or-nothing: no VAPID keys → no UI, no endpoints, nothing to misconfigure; `web-push` is the one added server dependency and the daily send is an explicit cron/timer script. Verified by the phase-2 suite (43 checks: history and SRS shapes, the review/mistakes sessions, stats math, exam with timer and breakdowns, the VAPID-less hidden state and the configured subscribe/validate/test/delete path; the suite runs its own server instance on a separate port and kills the process group on exit). 14. **`gpt-oss:120b` does all content work — but it cannot read images** (`this model does not support image input`), so `VISION_MODEL` has to stay a multimodal model (`gemma4:31b-cloud`). Text-only inputs — pasted text, PDFs with a text layer, DOCX/PPTX/ODT — never touch the vision model at all, so those decks are produced by `gpt-oss:120b` start to finish. 13. **No auth, no server storage.** Everything personal lives in `localStorage`; the only server state is the request in flight. 14. **`canvas-confetti` and `framer-motion` are the only UI dependencies.** Document parsing adds `pdfjs-dist`, `mammoth` and `jszip`, all dynamically imported (the `/create` page grew ~3 kB; the parsers load only when a document is added). Animations are skipped when `prefers-reduced-motion` is set. 15. **Tailwind v4 class-based dark mode** with a pre-paint script so there is no theme flash, and `suppressHydrationWarning` on ``. 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.