A new mode where the user explains a topic in their own words and the AI
plays a curious classmate: short reactions, exactly one follow-up question
at a time, never "correct"/"wrong", never lecturing, and everything it knows
comes from the user's own decks and notes.
- Entry points: a sidebar row next to Exam mode and a "Teach it back" action
in every deck's menu (which prefills the topic and scope).
- Start screen: deck or typed topic, friendly/skeptical student, the same
context and model chips as Chat (/teach?deck=<id> pre-selects the deck).
- Session: the student prompt is a mode of the existing /api/chat route, so
model resolution, allowlists, rate limits and the streaming client are the
same code; the personality lives in lib/prompts.ts (TEACH_*). The deck's
cards travel as a numbered checklist and the student is told to ask about
the ideas that were skipped; it must say so rather than guess when the
notes cover nothing. One constant (TEACH_MAX_EXCHANGES = 10) ends the
session, or the Finish button does.
- Recap: a teach_summary call (JSON, one repair retry) yields well / gaps
with card references / next steps; each gap links to the card it means.
"Add missed cards to review" schedules those activities due now; "Turn
gaps into cards" generates one flashcard per gap from the deck's notes and
shows an editable review list before saving.
- Sessions are chat conversations with a mode:"teach" marker + session
record: same IndexedDB history, backup and sync, filtered out of the Chat
screens; the recent list reopens a recap or continues a session.
- Reused pieces: MessageRow, Composer (now optional attach/scope chips and a
mic), Markdown/KaTeX, AiDisclaimer, ContextPicker/ModelPicker, plus a
shared useChatModels hook and useKeyboardInset extracted from Chat.
- Dictation: Web Speech API recognition (lib/speech-input.ts); the mic only
renders where the browser supports it, and dictated phrases land in the
draft.
- All strings in en + cs. teach-test: 44 checks (API prompts and guards,
both entry points, the session, recap actions, history, exchange limit,
360/390px layouts, mic on/off). Version 0.8.1, SW v103.
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.
- prompts: PODCAST_SYSTEM_PROMPT writes for the ear (plain prose, no markdown,
no visual references), chaptered into 3-8 sections around a length target.
- /api/podcast mirrors /api/generate's plumbing (rate limit, model gate,
provider keys, heartbeats, the 2x deadline, the optional vision pass).
- storage: frezmo.podcasts.v1, zod-validated, included in the backup and the
encrypted sync payload; a new library tab lists episodes with listen /
rename / delete-with-undo, and the wizard switch deep-links in.
- player: the transcript is word-wrapped spans and the word under the voice
rides a lyric-style highlight that keeps itself centred. Two clocks: exact
onboundary events where the browser reports them, a per-word length timer
where it does not (iOS Safari); the first boundary retires the timer.
Pause = cancel + re-speak from the word, which makes resume, chapter jumps
and tap-any-word-to-seek the same operation; the position saves on pause,
page-hide and unmount, so reopening resumes. Speed rebuilds the engine and
keeps playing; the Aero soundtrack pauses itself when the voice starts; a
browser with no voice gets an honest note and a readable transcript.
- the script sits on a glass pane (plain paragraphs over Aero's grass failed
contrast; the scan now covers the player too).
tests/podcast-test.mjs (17th suite) stubs the model and the speech engine and
drives the highlight by hand: 26 checks. All suites green, contrast clean.
Version 0.7.3, SW v95.
Selecting Aero starts a random track from the Frutiger Aero playlist
(Bigblamboii's set on SoundCloud); every following track is drawn at random
too, so the set shuffles itself. The player is the credit bar from the mock:
a 44px glass circle with a note by default, expanding into pause + the
track's title + "SoundCloud - artist" (both reported by the widget).
- Only Aero ever touches SoundCloud: the embed and the widget API are created
while the theme is on and the listener has not turned it off; light/dark
request nothing (pinned by a suite check).
- The off choice persists (freezmo.aero-music.v1); re-selecting Aero in
Settings is fresh consent (applyTheme clears it).
- Autoplay rides the theme-selection gesture; after a reload the circle comes
up paused (browsers require a gesture) and one tap starts it.
- Volume 45; the iframe is 1px and inert, only the audio engine; the credit
area collapses back to the circle without stopping the music.
- Geometry: 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. 44px targets on phones.
- Cookie policy gained the third-party-embed paragraph it was owed.
tests/aero-music-test.mjs (16th suite, stubbed widget API, 21 checks). All
suites green, contrast scan clean. Version 0.7.0, SW v92.
From your notes: the generator now stores a verbatim source quote with every
activity (schema + prompt + a server-side clamp so a chatty model cannot drop a
card), and the feedback panel shows the sentence that carries it with the quote
highlighted green. Decks made before the change, and quotes the model trimmed,
fall back to a local IDF-weighted match over the saved notes text
(lib/notes-source.ts): nothing is shown unless at least two distinct terms
land. The passage hides in exam mode with the other reveals.
Read aloud: a Web Speech API speaker control on the text a learner reads
(flashcard faces, questions, statements, sentences, instructions) via
lib/speech.ts and components/SpeakButton.tsx — one utterance app-wide, a voice
matched to the deck's language, and no button at all where the browser has no
speech synthesis. It used to sit inside the flashcard's flip button (nested
buttons, tapping it flipped the card); it now lives next to the hint row.
Timed runs: the feed chrome carries a timer chip (off by default) that gives
each card 30 seconds; 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 with a fresh budget. handleAnswered ignores a second result
per card, so a timeout plus a manual answer cannot record twice.
The generator's type spec now names what each type suits (language decks lean
on flashcards/matching/fill blank, ordering needs a real sequence, short answer
reasoning), so the deck-aware guidance covers all seven types.
New activities-test suite (24 checks: quote path, local path, the green mark,
the speech stub, the timed run on Playwright's fake clock), registered in
run-all; phase2 pins that the notes passage stays hidden in exam mode; en+cs
keys for every new string; no new dependencies. Version 0.6.1, SW v83.
Settings -> Providers takes keys for OpenAI, Anthropic (their official
OpenAI-compatibility layer), DeepSeek, Groq, OpenRouter and any custom
OpenAI-compatible endpoint. One adapter (lib/openai-compat) covers
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), 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, forwarded only with the request that needs them. The
provider test asserts the key reaches the provider while the routing
header stays on the app side.
New suite tests/providers-test.mjs (13 checks) stubs both protocols:
model listing, chat streaming, study generation and the Settings flow.
Privacy policy updated (en+cs). Contrast 0 findings, axe clean, mobile
126/126, chat 102/102, wizard 95/95, bubbles 34/34, regression 44/44,
i18n 13/13, native 33/33. Version 0.4.7, SW -> freezmo-v72.
Audit pass with the threats that actually apply to a self-hosted, account-less
app: getting the Ollama key throttled or banned, and someone grinding the app
down. Per-route windows already existed; these are the missing layers.
- lib/model-guard.ts: every route that spends a model call now takes a slot —
60 calls per window across all clients (RATE_LIMIT_MODEL_TOTAL), 3 in flight
per IP, 6 in total — and the slot is released when the call finishes, on
errors and on client disconnects. Over the concurrency cap answers 429
kind=busy, not a punishment. Slots are taken after every cheap rejection so
a malformed request cannot hold one (found by the new suite: early 400s
were leaking slots).
- Images must be base64 data URLs (dataUrlSchema in lib/schemas): a plain URL
in an images field made Ollama fetch an arbitrary address on our behalf.
- Push endpoints must be a real push service (PUSH_ALLOWED_HOSTS, defaulting
to FCM/Mozilla/Apple/WNS): the server POSTs to whatever is stored.
- The rate-limit identity is the LAST x-forwarded-for hop. Reading the first
let a client spoof its own IP and dodge every per-IP limit.
- Disk guards: reports.jsonl stops at REPORTS_MAX_BYTES (8 MB), reminder
devices cap at PUSH_MAX_SUBSCRIPTIONS (50).
- Security headers: frame-ancestors 'none' + X-Frame-Options DENY, nosniff, a
referrer policy and a Permissions-Policy without camera/mic/geo/payment.
New tests/security-test.mjs (13 checks, registered in run-all) proves each
guard against a slow stub Ollama with deliberately tiny limits. README:
security notes + rate-limit tables updated. Version 0.1.3, SW -> v51.
Entry points (no sixth tab): Ask in the Library header (desktop button,
phone icon), Ask about this deck in the deck menu, Ask a question on
study/homework results (compact result travels as context), and a Chat
row in the desktop sidebar. Phones get a full-screen route with back +
New chat; the tab bar steps aside.
- Client-side retrieval: chunk scoped decks (~800 tokens), BM25 against
the question + recent turns, top chunks under ~7k tokens, each
labelled with deck + section. Notes come from the deck's stored
sourceText (already kept for Regenerate); decks without it fall back
to generated content and the UI says so.
- Stateless server: /api/chat validates against the CHAT_MODELS
allowlist, trims the history to ~6k tokens, caps context at ~9k and
streams token/thinking/done/error NDJSON. /api/chat/extract reduces
attachments to text through the existing vision step.
- UI: user bubbles + full-width assistant text, streaming with stop,
auto-scroll that pauses with a jump-to-bottom button, safe markdown
(React nodes, no HTML injection; KaTeX dynamically imported only when
a message has math), Thinking row for reasoning, copy/regenerate/
edit-and-resend, model + context pickers (last model remembered),
composer with visualViewport keyboard handling, safe-area, 16px
textarea, Enter-to-send on desktop.
- Usage-limit failures keep the draft and show the shared message with
Settings/Ollama actions; stopping maps the reader abort to a clean
stopped state; offline disables sending with a reason.
- Settings: Keep notes text for chat toggle; deck menu: Delete notes
text. RATE_LIMIT_CHAT + CHAT_MODELS in .env.example and README.
- Tests: chat suite (63 checks, stub Ollama) wired into run-all.
Full set: 12/12 suites, 487 checks. SW -> freezmo-v32.
Also: tests/model-compare.mjs (diagnostic) benchmarks two models on the
same notes (latency, repair rounds, activity types) so the gpt-oss vs
gemma default can be decided on numbers; defaults unchanged.
- Orbs rise from below the bottom edge to above the top edge, each with
its own 36-68s pass (slower on phones), a negative delay (scene already
populated on load), own horizontal position, a gentle sway and a slight
scale drift; opacity fades in/out at the edges so the loop never pops.
- Only transform + opacity animate, each orb on its own composited layer
(will-change: transform); no filter/backdrop-filter, nothing animates
size/offsets/shadow. The glacier blobs are untouched.
- Opacity lowered ~38% in both themes: 0.55->0.34 light, 0.8->0.5 dark,
phone overrides 0.32->0.2 / 0.42->0.26.
- prefers-reduced-motion: rise removed, orbs return to the old static
placements (a paused-from-start CSS animation is not applied in Chrome
and would park them below the fold); tab hidden: a visibility listener
parks the whole scene (.ice-paused), blob drift included.
- Measured with a CDP perf trace on a 4x throttled phone profile: p95
33.4ms, 0 frames over 34ms, no long tasks - identical to the pre-change
baseline; ~0.6% extra main-thread CPU. Desktop stays compositor-bound
from the pre-existing blob drift, as before. tests/bubble-perf.mjs
keeps it repeatable.
- New bubbles suite (34 checks) in the repo harness; the axe contrast
matrix is now 16 runs (360/390/1280 x dark/light x EN/CS) and passes.
- SW cache -> freezmo-v31; README decision 46.
Full suite: 11/11 suites, 424 checks.
The browser harness lived in /tmp/opencode and an overnight reboot
wiped it. It now lives in tests/ and survives reboots, is versioned
and runs with one command.
- tests/lib.mjs: shared helpers (Chromium auto-detect, seeding,
IndexedDB fixtures, stub/extra instance spawn with process-group
cleanup, check/finish).
- tests/run-all.mjs + npm run test:e2e [filter]: runs the ten suites
in order with a summary; suites that need a stub Ollama or VAPID
keys start their own next start instance on 3208/3209, verify the
port is free and kill the group on exit. Test scratch in
tests/.tmp/ (gitignored).
- Suites rebuilt and green against the current build: regression 38,
mobile 94 (tab matrix, ... sheet, settings sheet, axe), wizard 83,
native 31 (PWA/offline/share/back/undo/haptics/draft), i18n 13,
freeze-loading 6, review 15, phase1 36 (backup/sync/usage limits),
phase2 42 (learning engine/reminders), phase3 21 (homework check).
379 checks, 10/10 suites in one full run.
- playwright-core + axe-core added as devDependencies; CHROME env or
the newest ~/.cache/ms-playwright chromium is used.
- README: new decision 45 documenting the harness; decision 44's
verification note updated.
The older suites' exact sources were lost with the wipe, so the
rebuilt versions reconstruct the same feature areas rather than
being byte-identical.