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 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 rode a pure estimate - and a 130-word section drifts seconds
away from an engine that speaks at its own pace.
- On platforms without boundaries the system engine now speaks one sentence
at a time, and every finished utterance re-anchors the clock: measured
duration vs estimate, smoothed (half old, half new, clamped to 0.25-4x).
Drift cannot accumulate past a sentence.
- The per-word estimate is punctuation-aware (full stop ~260ms, comma ~130ms).
- Once a boundary does arrive (Chrome), the rest of the section goes back to
a single utterance with the exact clock - the accurate path is unchanged.
- When a boundary lands mid-section, the remainder is spoken as one utterance
(it used to skip to the next section).
podcast-test's engine stub finishes utterances now and pins the whole thing:
one sentence first, the next following on its own, the highlight inside the
spoken text, a boundary collapsing the remainder, the highlight advancing.
53 checks. Version 0.7.10, SW v102.
"The podcasts don't even work on my Linux setup on Firefox." Firefox on
Linux speaks through speech-dispatcher; without it speechSynthesis is present
but empty and silent - getVoices() is empty, speak() does nothing, no events
- so the player looked broken.
- pressing play with zero installed voices (or a start that never arrives
within 1.7s) raises a banner naming the cause, with the Linux fix
(sudo apt install speech-dispatcher) and the iOS one where it applies;
- the banner offers "Use the natural voice" when a provider key exists (it
switches the source, keeps playing and remembers it), "Add a provider key"
when not (opens Settings) and a link into the picker;
- natural-voice errors (refused autoplay, provider refusal) show in that same
banner instead of hiding inside the picker.
podcast-test fakes a present-but-empty speechSynthesis and pins the banner,
the Linux line and the one-tap switch. 48 checks. Version 0.7.8, SW v100.
"It only plays once, and when you pause it you can't unpause."
WebKit leaves speechSynthesis paused after a cancel(), and every later
speak() is queued into that paused state - silently. Fixes:
- resume() whenever the synth is paused, before speaking and after our own
cancel;
- a ~90ms beat before a speak() that follows a cancel (several engines drop
it otherwise);
- no cancel when nothing is pending.
The speech engine also no longer rebuilds on rate or voice changes: both are
read live through getters, so a voiceschanged arriving after the first
utterance cannot leave a second engine speaking over the first. A rate or
voice change restarts the current part from the same word instead (the Web
Speech API cannot change either mid-utterance), making the change audible at
once; the natural engine applies speed live and resumes across a voice swap.
The suite's speech stub now models the quirk (cancel -> paused, speak while
paused is dropped and counted) and pins that nothing is ever spoken into a
paused engine. 44 checks. Version 0.7.7, SW v99.
Two reports, one root: the platform's speech engine.
"Loud cracking at the end of every sentence" is the browser/OS TTS popping
between its internal sentence chunks (an idle cancel makes it worse). Hygiene
for that path: never cancel when nothing is pending, normalise the spoken text
(control characters, ellipses, dashes), and a 140ms beat between sections so
speech never runs into the engine's tail.
The real fix is a second voice source that does not use the platform engine:
- lib/providers carries TTS profiles (OpenAI gpt-4o-mini-tts, Groq
playai-tts, any OpenAI-compatible /audio/speech behind the custom provider),
male voices first.
- /api/tts proxies them with the same guards and the key still in headers.
- the picker gains System / Natural, with the provider's voice list.
- lib/podcast-audio plays one clip per section through a hidden <audio>
element, measures the clip's real duration and gives each word a slice of it
proportional to its spoken length - the highlight follows the audio exactly,
and pause/seek/chapters become real audio operations. Clips cache per
session, the next section generates while the current plays, and a voice
change disposes the element and its blobs. Speed applies live (setRate)
without dropping the cache.
podcast-test stubs /api/tts with a real silent WAV and pins the path: the
voice list, the request body, exactly one playing element, and the highlight
riding currentTime. 43 checks. tests/lib.mjs launches Chromium with the
autoplay policy relaxed (a real device has the listener's gesture).
Version 0.7.6, SW v98.
Safari hands web pages one voice per language, whatever the iPhone has
installed, so the picker cannot fix that on iOS. It now carries the way out
instead: Accessibility -> Spoken Content -> Voices -> your language, download
an Enhanced/Premium voice and make it preferred. The speaker button wears an
amber dot while the note is waiting, and the voice-less banner appends the
same guidance. Detection covers iPadOS too (maxTouchPoints, since it reports
as a Mac).
The suite fakes an iPhone user agent, a single Daniel voice and pins the dot
and the note's actual steps (37 checks). Version 0.7.5, SW v97.
"The scrolling works kinda weirdly" + "maybe a less robotic voice, preferably
male".
Scroll: the transcript now 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 rather than animated,
and a wheel/touch/drag on the transcript pauses the following for four seconds
instead of fighting the reader. Chapter jumps and word taps re-centre at once.
Voice: lib/podcast-voices ranks what the platform installed (natural / neural
/ online names up, espeak / SAM / compact down, a male name preferred) and the
player picks the best one automatically; a new picker in the chrome lists
everything in the episode's language, applies it live and remembers the choice
per language. A note points at the system settings when only robotic voices
exist. The engine takes the chosen voice instead of guessing.
podcast-test: 34 checks now (voice ranking, filtered picker, persisted pick,
same-line silence, line following, manual-scroll suspension). Version 0.7.4,
SW v96.
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.
Follow-up to the smart-review breakdown: "I only have 2 decks" turned out to
be three deck cards (one clipped at the window edge) and 15 + 13 + 6 = 34
due. The line now carries each deck's activity total next to its due count -
"Due by deck: Sky deck - 15 of 15 - Humanismus - 13 of 15 - ... - 6 of 13" -
so the arithmetic is checkable inline without opening a deck.
groupByDeck returns {label, count, total}; new i18n key review.dueOfTotal
(en + cs). review-test pins the format. Version 0.6.9, SW v91.
"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 counts were
honest (dueItems/mistakeItems only resolve live deck+activity pairs; a seeded
repro with 37 SRS rows and 30 live shows "30 due"), but nothing in the UI said
where an item came 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.
Both read as a broken list.
- SessionFeed gains showDeck (review + mistakes): a deck chip above every
card, so two identical questions are visibly two decks.
- The Smart review card breaks its numbers down under the buttons:
"Due by deck: Sky deck - 17 - Sky deck (2) - 17", capped at three decks
plus "+n more" (groupByDeck in lib/learning).
- New i18n keys (en + cs, plural forms for "+n more").
review-test pins it: a twin-deck seed renders the breakdown and both deck
chips in the feed. Version 0.6.8, SW v90.
Scrolling through a deck without answering recorded a 0% attempt and listed
every card under "Missed items" with its answer revealed. Only answered cards
count now: the score is correct/answered, the attempt stores the answered
count, an empty run records nothing, and the cards scrolled past wait under
"Not answered (n)" - listed as prompts with "not counted as mistakes" - and
"Answer the rest (n)" starts a catch-up run of just those cards (same restart
path as retry-missed, so history and the SRS still see the answers). "Aced
all" requires nothing left waiting too.
phase2 pins the flow: all-skipped records no attempt and no mistake list;
one-of-two reads "You got 1 of 1 right" + "Not answered (1)" with 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.
Version 0.6.5, SW v87.
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.
1. Bugs fixed:
- Backup schema now accepts theme: "aero" so backups exported under
Frutiger Aero pass parseBackup on import, and applyImport in "replace"
mode replaces SRS schedules, answer history, and chats instead of
merging into existing SRS or skipping empty arrays.
- Typed FillBlank cards bind the input value to chosen ?? value instead
of chosen ?? "", and normalise() preserves Unicode letters (\p{L}\p{N})
when grading non-ASCII answers.
- OpenAI-compatible vision calls (contentFor in lib/openai-compat.ts)
prefix data:image/jpeg;base64, when routes pass stripped base64.
- Streaming calls (ollamaChatStream, providerChatStream) keep their
AbortController active across 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 display the
Checked label instead of Step-by-step guide.
- Desktop dropzones on /create and /homework accept dropped documents as
well as images.
- LibraryScreen filters reported questions out of its due and mistake
counts to match ReviewClient.
- Clearing a provider key resets generation if it pointed at that
provider; chat conversations persist and restore provider; ModelPicker
keys items by provider:name.
- RouteTransition no longer mutates module-level lastPath during render.
- Service worker asset handler falls back to Response.error() when an
uncached asset fetch fails offline.
- Confetti and .ice-orb / .ice-bubble respect the in-app reduce-motion
toggle.
2. Performance & cleanup:
- Deduplicate window storage listeners across all six localStorage
stores (one listener while subscribed instead of N^2 notifications).
- Use todayAnswerCount in LibraryScreen and pass sorted folders to
DeckCard.
- Memoize MessageRow with stable callbacks in ChatScreen and compute
last message IDs in a single pass.
- Close ImageBitmap after drawing in lib/image.ts and destroy pdf.js
documents in finally in lib/documents.ts.
- Avoid re-parsing serialized sync payloads in POST /api/sync.
- Deduplicate copyText (lib/share.ts -> lib/clipboard.ts), chatMessageId,
describeExtraction, ALL_FOLDERS, useIsPhone, and remove unused helpers
and boilerplate public/*.svg files.
Version 0.5.8, SW -> freezmo-v80.
app/not-found.tsx renders a standalone screen: the root layout's ice
scene is the backdrop, the glass card/rim/bubbles come from the shared
recipes (full Aero with that theme on, frosted glass in light/dark),
and the mascot is the scene's glossy ice orb at full strength with a
snowflake and a "404" chip. Two ways out - back to the library and
into the chat - both 44px targets, en + cs.
The regression suite pins the 404 status, both links and the Czech
copy; the pixel-contrast scan covers the screen in all three themes.
The service worker no longer caches non-OK navigations as the offline
shell (a typo'd URL used to write the error page into the shell
cache). Version 0.5.6, SW -> freezmo-v78.
The chat header and composer are full-bleed bands inside the main
pane, which starts right at the sidebar on desktop - so both ended in
a hard vertical edge beside the floating rail card (the 12px gutter
showed the raw scene and the bands' fill + backdrop blur read as a
color step at the seam), with their hairlines starting in mid-air at
that edge. Both bands now reach the card's right edge (the rail's own
12px padding, expanded or collapsed) and their hairlines are inset to
the content box; phones keep the plain full-width borders. The chat
suite pins the dock. The "Chat is set to ignore notes text" banner
went away - the scope chip already says what the chat may read.
Version 0.5.5, SW -> freezmo-v77. Chat 104/104 (2 new), mobile
126/126, wizard 95/95, phase3 31/31, bubbles 34/34, i18n 13/13,
native 33/33, regression 44/44, contrast 0 findings, axe clean.
Full e2e: 14/14 suites.
Buttons: the recipe's thin veils let btn-secondary/btn-ghost/glass-pill
blend into the grass and sky - they now carry a stronger pale-glass veil
(the Ask-pill look) over the blurred scene, ghosts plus a rim and
full-contrast text. Homework make/check results gained the same red "!"
report control as the feeds and the guide (a synthetic short-answer
activity maps into the report flow; pinned in phase3).
Reduced motion: the manual toggle only fed framer's MotionConfig, which
strips transform animations - while components branched on the OS-only
useReducedMotion - so translucent sheets still spring-faded their opacity
in over the page (the "ugly visual glitch with the settings panel"). All
16 components now read useAppReducedMotion() (OS or toggle), and the
settings sheet/scrim and the library's phone sheet go instant. Verified
frame by frame: opacity 1.00 from the first frame.
Contrast 0 findings, axe clean; mobile 126/126, chat 102/102, providers
20/20, wizard 95/95, phase3 31/31, bubbles 34/34, i18n 13/13, native
33/33. Version 0.5.4, SW -> freezmo-v76.
The gate (user-key installs) now headlines Ollama with a "Free" badge and
adds an "or - Use a paid provider instead" option that opens the same
provider rows as Settings, framed as billed by the provider directly.
Saving a provider key there lists its models and switches study
generation to it automatically, so the gate steps aside with no Ollama
key at all.
Gemini joins the registry via its OpenAI-compatible endpoint
(generativelanguage.googleapis.com/v1beta/openai); the privacy policy
names it (en+cs).
Providers suite grows to 20 checks: the gate scenario runs against a
second user-key-mode app instance (free badge, paid option, provider
rows, pass-through, generation switch). All other gates green:
contrast 0 findings, mobile 126/126, chat 102/102, wizard 95/95,
bubbles 34/34, regression 44/44, i18n 13/13, native 33/33.
Version 0.4.9, SW -> freezmo-v73.
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.
- Aero: blue gradients back on the active segment and every primary
(Review due, the card's Study button, the composer's send button); a
frosted backing behind the page heading and the All decks label; a
bottom-weighted rail tint; chat header/composer/welcome panels; calmer
orbs; stronger card veil + a stronger blur bake.
- Dark/Light: a dedicated --track token makes progress bars read (the
first attempt shifted --surface-sunken and the pixel scan caught the
sheet-contrast regression); brighter dark secondary text; darker light
segmented labels and difficulty tints; stronger light card border and
shadow.
- Chat UI: friendly model chip ("Gemma 4 31B") with the raw name in a
tooltip, larger chips, plain centered disclaimer, vertically centered
welcome panel, distinct chat-plus icon for new chat.
- Chat models: every chat-capable model from the server's /api/tags is
selectable (favourites first, search field when long, live-list
validation in the route); default CHAT_MODEL_DEFAULT or gemma4:31b.
- Reduce motion: Settings toggle (default off) that parks the scene,
collapses CSS animations and drives framer-motion's calm mode.
- Folders hint, "New" badge on fresh decks.
Contrast: 0 findings, axe clean, mobile 126/126, chat 102/102, wizard
95/95, regression 44/44, i18n 13/13, native 33/33.
Version 0.4.6, SW -> freezmo-v71.
A user's run failed with the wizard's generic "check your Ollama
settings" card and could not be replicated 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, the app backgrounded by the phone OS) leaked
out of the NDJSON reader as a DOMException, and the wizard can only
show the generic card for unknown errors.
The reader now classifies everything: abort -> "cancelled", stall ->
"the model stopped responding", anything else on the wire -> "the
connection dropped mid-run, try again". Pinned with a fetch stub that
kills the stream mid-run in the wizard suite (95 checks).
Contrast 0 findings, mobile 124/124, chat 101/101, regression 44/44,
i18n 13/13, native 33/33. Version 0.4.1, SW -> freezmo-v70.
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. The caller's abort still maps to the
friendly "cancelled" state.
Verified with a stalled route and Playwright's fake clock in the wizard
suite (93 checks), plus contrast 0 findings, mobile 124/124, chat
101/101, regression 44/44, i18n 13/13, native 33/33.
Version 0.4.0, SW -> freezmo-v69.
The scene is the user's own Frutiger Aero image now: green hills, a river,
an eco-city, bubbles and daisies. Re-encoded to public/aero/background.webp
(152 kB) with a fresh 3 kB pre-blurred variant for the glass, and the
WallpaperSafari credit line, its i18n keys and the README credit are gone
(the image is made by the user with Gemini).
Contrast: 0 below-minimum findings and 0 axe violations, all three themes.
Version 0.2.8, SW -> freezmo-v60.
UI bug from a phone screenshot (short answer, Czech):
- The textarea was flex-shrunk inside the slide's column (58px instead of
~82px), clipping the typed answer mid-line. Every direct child of a
study/review/guide slide is now shrink-0 and the slide scrolls instead.
- Two AI disclaimers were visible at once: 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.
The phase-2 check now asserts the note sits below the graded feedback.
- The mobile suite's backdrop-tap check is deterministic too (the spring
animation made the measured box mid-flight).
Ordering activities: the deck prompt now allows them only when the notes
hold a real sequence (chronological events, process stages, procedure steps,
a lifecycle, a ranked scale) and points at matching / multiple choice /
true-false when they do not - "sort these" was landing on decks with no
order at all.
Version 0.2.4, SW -> freezmo-v57.
The wallpaper is the one from the mockup: "The S Aesthetic That You
Definitely Recognize Frutiger Aero" via WallpaperSafari
(https://wallpapersafari.com/w/IlWuz7) — the URL in the request pointed at a
different (teal abstract) wallpaper, this is the grass/sky/water one that
matches the screenshot. Re-encoded to a 237 kB WebP in public/aero/, used as
the scene in aero only (light/dark never download it), and credited in
Settings next to the theme picker.
The drawn CSS scene (rays, reeds, skyline, caustics) is off over the photo;
the ten rising bubbles stay, and the orbs became extra bubbles. Buttons and
segments are liquid glass now — translucent, glossed top half, bright rim,
backdrop blur — instead of the solid aqua fill. 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.
Also carries: the underwater scene + bubbles work, the floating cookie
notice, the Czech title (Tvůj mrazák) and the review-suite fix that made the
last deploy fail its gate.
Contrast: 0 below-minimum findings, 0 axe violations across all three
themes. Version 0.2.2, SW -> freezmo-v56.
Frutiger Aero, closer to the mock:
- The scene is water now: sun shafts through a bright surface, caustic
patches, reeds on the left, a city silhouette on the right, and ten soap
bubbles that actually rise (own keyframes, varied sizes/speeds/sway; the
spans are display:none outside aero so they cost nothing elsewhere, and
they sit still under reduced motion).
- Pale blue glass at 0.8: still reads as glass, but dark navy text clears
4.5:1 over the water. The phone tab bar mixes the sheet glass instead of
the page — in aero the page *is* the water, which left its labels at 3.9:1.
- The aero page-level text (muted/subtle) is much darker for the same reason.
- The PWA status bar follows the new water blue (#2fa7e6).
The cookie notice is floating glass now (blurred backdrop, rim, shadow): as a
plain card it read as part of the page background instead of a notice.
Czech: "Tvoje mraznička" -> "Tvůj mrazák" (library title), test updated.
Contrast: 0 below-minimum findings and 0 axe violations across every screen,
all three themes. Version 0.2.1, SW -> freezmo-v55.
Frutiger Aero (Settings -> Theme): a third theme, not a dark/light variant.
A sky gradient with a strip of grass, the glacier blobs become clouds, the
orbs soap bubbles, glass gets the classic horizon gloss and the buttons a
glossy aqua. The light/dark toggles (header, rail, chat header) hide while it
is on and the picker says why; the pre-paint script applies the class before
first paint and the PWA status bar follows (#3fa3e0). Its muted/frost text is
darker than Light's because those labels sit directly on the sky.
Contrast, measured on real pixels (tests/contrast.mjs, now covering aero):
- The light settings sheet was translucent over a dark scrim, so its muted
text measured ~2.8:1. Sheets and dialogs now use --glass-sheet-bg (a more
opaque surface), and Light's --text-muted is a touch darker.
- Result: 0 below-minimum findings and 0 axe violations across every screen,
all three themes, phone and desktop.
Also: the suites now seed the cookie notice as already dismissed (they run as
a returning user); the regression suite opts out to test the notice itself —
the previous deploy failed its gate because the notice blocked a chat chip
click. The notice also hides during immersive sessions (overlay-open), so it
can never cover a study feed.
Version 0.1.8, SW -> freezmo-v54.
Three plain-language documents in both app languages (lib/legal.ts, rendered
by components/LegalPage.tsx): /privacy describes the local-first storage, the
key never leaving the browser and the three opt-in server copies (sync,
reminders, reports); /terms covers the GPL-3.0-only software, your own Ollama
key, fair use and liability; /cookies states what is true — no cookies at all
(no Set-Cookie, no document.cookie), strictly-necessary local storage instead,
no tracking.
A cookie notice tells every new browser the same thing once and remembers the
dismissal (freezmo.consent.v1). It sits at z-44: below the immersive study
feeds so it can never cover a session, above the phone tab bar. Settings links
all three documents. The regression suite checks the pages render with real
sections and that dismissing the notice sticks.
Version 0.1.7, SW -> freezmo-v53.
- axe is clean across every screen in both themes, phone and desktop. Fixed:
the hidden file inputs (aria-hidden + tabIndex -1 — they are opened by
labelled buttons, so they should not be in the accessibility tree), the
heading order (deck/homework/smart-review cards are h2 under the page h1;
LearningSection takes a level so it stays h2 on the library and h3 inside
the settings dialog) and landmarks around the study and review feeds.
- tests/contrast.mjs (new diagnostic): samples rendered pixels, because axe
cannot judge text on blurred glass — it reports those nodes as incomplete.
It skips disabled controls (1.4.3 exempts them) and content behind an open
sheet. Result: no below-minimum text on any scanned screen.
- The report control exists in the homework guide too: under each "Try it
yourself" exercise, labelled "Report exercise", without a skip (there is no
queue to skip). lib/question-reports now takes deckId/deckTitle directly.
- The rail's two creation actions are buttons now (.nav-action) rather than
quiet nav rows — once the library is full they were the only way to start
something on desktop and read as just more navigation. "Make my homework"
is "New homework" wherever it is an action (rail, empty state, share
chooser, manifest shortcut); the page title and mode switch keep their
wording.
Version 0.1.6 (three task bumps), SW -> freezmo-v52.
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.
Versioning: package.json carries the app version (0.1.2 here; one bump per
task with `npm run version:bump`, patch 0-9 rolling into the minor at 10),
baked into the build via lib/version.ts, reported by /api/healthz and shown
in Settings next to the running commit. AGENTS.md documents the rule; the
native suite checks both the API and the Settings row.
Fix: on desktop the app header is hidden, so every page owns its top padding —
/create was the only one without `lg:pt-10`, which slammed the Study deck /
Make my homework switch against the top of the window while /homework had a
comfortable gap. Both now sit at 40px; the wizard suite asserts the clearance
and that the two pages stay in step (90 checks).
SW -> freezmo-v50.
The deck wizard had the good one — app icon, rotating stage message as the
headline, progress bar, stage checklist, tip, Cancel — while homework showed
a spinner with a title and route loads a pulsing icon with a smaller line.
All of them now render components/LoadingState:
- the wizard passes its vision/generation stages and keeps its deck tips;
- homework passes the same shape with homework wording (no tips: they are
deck-specific);
- route screens (review/stats/exam loading boundaries + the link overlay)
pass a single message, no stages or progress.
Cancel stays a 44px touch target, the icon animation respects
prefers-reduced-motion, and the unused homework.loading.title string is
gone from both languages.
Suites: freeze-loading 6/6, review 18/18, wizard 88/88, mobile 105/105,
regression 38/38, native 31/31, phase-2 62/62, phase-3 30/30, i18n 13/13.
README: decision 60. SW -> freezmo-v47.
Sync was push-on-demand only: a deck frozen on the laptop stayed in dirty
until someone opened Settings and pressed Sync now. The two stores whose
writes mean "the user just made something" now schedule one debounced
background syncNow("auto") through lib/sync-auto: debounced 2.5s 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), retried on the browser's
online event, and imported dynamically so the sync code stays out of the
first load with no import cycle.
markSyncDirty() now reports whether it actually marked, which keeps pull and
backup-import paths quiet while a real import pushes like any other local
change. Answers and chat keep the existing manual behaviour on purpose —
they change every few seconds while studying.
Tests: phase-1 (a deck write pushes itself, then Sync now is a no-op — 50
checks) and phase-3 (a check run pushes the new homework record — 30 checks).
README: decision 59. SW -> freezmo-v46.
- "Check my work" now takes photos and documents of the attempt: documents
are read in the browser into text, photos go to /api/homework as
solutionImages and are transcribed by the vision model before the check
("Reading your solution..."). The finished attempt comes back in
meta.solutionText and is stored as the record's attempt, so "Redo"
re-checks the same work without keeping the files.
- Deleting a deck now drops its due schedules and mistakes from the library's
smart review immediately (the counts resolve against the live deck list);
Undo brings them back, because the learning data itself is never erased.
- The phone override for the glacier blobs was still at 0.1/0.2: it is now a
quarter of the original value there too (0.025/0.05), matching the desktop.
- License: GPL-3.0-only (LICENSE, package.json, README section).
Tests: phase-3 photos-only API + desktop attachment checks (28/28), review
suite deck-delete checks (18/18), i18n/mobile/phase-2 green. README:
decisions 57-58. SW -> freezmo-v45.
UI batch from feedback:
- The study/review/exam/homework chrome is now a single 58px pill: round
close button, title + position over the progress bar, chips (or the exam
timer) at the far end — everything on the centre line, so nothing crowds
the rounded corners any more. Homework guide gets the same recipe.
- The immersive feeds carry z-[45] so they paint over the app chrome (on
/exam the header used to cover the top of the chrome), and the page chrome
behind an open overlay (.app-header, .segmented, .tabbar) is hidden while
the transparent overlay is up, so it can no longer ghost through the glass.
The guide's disclaimer moved inside the overlay with it.
- Chat: the context and model chips toggle their own popover (pressed state,
aria-expanded), opening one closes the other, a press outside closes it —
no more being forced to pick something to get out.
- Homework: the model's plain-text step explanations/hints/verdicts keep
their line breaks (whitespace-pre-wrap) — a step used to collapse into one
run-on paragraph with the whole solution inside it; the guide prompt now
says the explanation is coaching, 2-4 sentences.
- The four glacier blobs are at 25% of their old opacity (0.045 light,
0.1 dark).
Tests: phase-2 chrome geometry + exam layering checks (62), chat desktop
picker checks (101), the standing suites all green. README: decisions 54-56,
decision 23 updated. SW -> freezmo-v44.
A generated matching card arrived on a phone with three identical
definitions (the generation guards now drop those), but there was no way
to get past a bad card from inside a session. The feed chrome (deck
study, review, mistakes and exam) now has the red "!" control: one tap
records the question, skips to the next card and shows an Undo toast.
- lib/question-reports.ts: the local list (freezmo.reports.v1, zod on
read, capped 200) is the skip state — reported cards leave every
session type, count as "not attempted" in the results instead of
wrong, and Undo takes a report back. A deck where everything is
reported stays studiable so it can be regenerated.
- POST /api/reports appends one JSON line per report to
REPORTS_DIR/reports.jsonl (shared/freezmo/reports in production, the
dev data dir in dev). Deliberately write-only: the question text is
readable on the server (that is the point — prompt tuning) but never
over HTTP, since the app has no auth. Best effort: offline reports stay
local.
- The button: a red outlined rounded square with "!", the chrome's
control size, in both feed headers (hidden on the results slide).
- config: REPORTS_DIR + RATE_LIMIT_REPORTS (20); the deploy creates the
shared reports dir; the dev unit and AGENTS.md note the new server
data location.
- Suites: phase-1 (append + validation + no GET), phase-2 (button, toast,
server POST, local copy, skip counter, hidden next session, Undo, exam
pool). SW -> freezmo-v38.
A generated Czech literature deck reached a phone with three identical
matching definitions — the card could not be solved. The pipeline only
deduped matching terms (and ordering items), so repeated definitions and
options sailed through.
- parseDeck now rejects any card whose options, items, terms or definitions
repeat (case-insensitive), through the same drop-or-repair path as the
ordering giveaway guard: the card is dropped, or a deck left with too few
cards gets one repair round with a clear reason.
- The matching dedupe covers definitions too, and both it and the ordering
one only trim when the card still meets the schema minimum afterwards —
a shorter card would fail validation on the next read and take the whole
deck down with it (latent data-loss bug).
- The generation prompt states the uniqueness rule.
- phase-1 suite: a stub Ollama returns a deck with a repeated-definition
matching card (the reported shape), a repeated-option card and a
salvageable one; asserts the first two are dropped, the third is trimmed
to three pairs and the good cards ship (43 checks).
- README: decision 51.
Server-side only (lib/ai.ts, lib/prompts.ts) — no client bundle change, no
SW bump.
"Freezmo uses AI and can make mistakes. Double-check important info."
(plus a natural informal Czech translation), rendered by a shared
AiDisclaimer so the copy, style and translations live in one place.
Placements — only where the model's words reach the screen:
- Chat: under the composer, below the context/model chips — always (chat
is pure model output, so it also shows in the empty state).
- Make my homework: under the start CTA (desktop) and the mode cards'
helper line (phone), plus once under the generated result for all three
modes (make / check / guide).
- New deck wizard: under the summary helper (phone step 3) and the desktop
CTA, next to Freeze my deck.
- AI grading (short answers): under the graded feedback; study, review and
exam all share it.
Stored, already-generated content (deck page, study cards, explanations)
deliberately carries no note: the disclaimer belongs to the moment of
generation, not to browsing. Style: --text-subtle (documented at 5.6:1
light / 8.6:1 dark), 11.5px, centred, wraps to two lines at 360px, not
clickable, no icon.
Suites: chat (composer, phone + desktop), wizard (deck step 3 across
360/390 x en/cs x dark/light, homework start cards), phase3 (homework
trigger + result, phone + desktop), phase2 (stubbed grading feedback).
SW -> freezmo-v37. README: decision 50 + chat composer note.
Also fixes the deploy script's test instance: it now execs the server so
the cleanup kill reaps the real PID (the wrapper subshell left orphans
squatting on the test port — three from earlier sessions were found while
verifying this task), and the readiness poll checks the port serves the
NEW commit rather than just answering, so a leftover instance can never
be tested silently.
- Desktop: on /chat the main sidebar shows the chat list (search, New chat,
items with rename/delete, active highlight) in place of the deck
navigation; the separate second column is gone and the chat uses the full
width. The expand/collapse chevron sits outside the swappable section and
a collapsed rail stays clean (the highlighted Chat row already opens a
new chat). Active conversation travels via a tiny store (lib/chat-active)
so the sidebar needs no useSearchParams and pages stay static.
- Header (phones): history, theme and New chat are now identical chips -
same 44px hit area, rounded-2xl, border/surface tokens, size-5 icons,
even spacing; the title block truncates cleanly. ThemeToggle gained a
'plain' variant so the header styles it without touching other pages.
- ChatIcon redrawn (clean message-circle, no inner lines) and the empty
state uses the app's icon-chip pattern (size-12 chip, accent-600/200)
instead of the bare oversized glyph; the model chip icon follows.
- Also fixed a pre-existing dev-only breakage found while verifying: the
tab-bar bounce animated three keyframes with a spring, which framer
motion's dev build throws on - breaking client updates in the dev
instance (library tab switching, sheet Escape). It now uses a tween.
- Dev metadata keeps the Apple icons in both branches.
- SW -> freezmo-v35.
- Production now runs from ~/freezmo-prod/current (release symlink) as a
standalone server on :3207; releases are exported by scripts/deploy.sh,
built and e2e-verified on a temp port, switched atomically, health-
checked via the new /api/healthz (commit + build time) and rolled back
automatically on failure. Last 5 releases kept; scripts/rollback.sh for
manual rollback. Shared env + data live in ~/freezmo-prod/shared/ and
are symlinked into every release (existing sync/push data migrated and
verified identical).
- Dev runs from ~/freezmo via freezmo-dev.service: next dev on :3217
(LAN only), its own build folder (.next-dev via NEXT_DIST_DIR), its own
data (~/freezmo/.freezmo-dev), DEV badge, dev favicon/theme colour,
"Freezmo Dev" PWA identity and never the production service worker.
- /api/healthz + the Settings Details version row (short commit + build
time). output: standalone in next.config (next start still works).
- AGENTS.md documents the workflow rules: only dev updates during a task,
temporary dirs/ports for test builds, and deploy-at-the-end as the only
production path. README gains an Environments and deploy section.
- Fix found by the rehearsal: lib/dev-mode.ts must not be a client module
(a server component importing a constant from a "use client" module
gets a truthy proxy, which baked dev metadata into production builds).
- SW -> freezmo-v34.
- Conversations in a versioned IndexedDB store (freezmo-chat v1, zod
validated with the same schema backup/sync use, capped at 100 x 300).
Settled turns persist (question always; answer when produced, partial
stops included); ?c=<id> keeps reloads on the conversation via
history.replaceState (no router round-trip mid-chat).
- History UI: desktop column, phone sheet (spring, backdrop, Escape),
auto titles from the first question, inline rename, delete with an
Undo toast, diacritic-folding search over titles and message text.
Scope and model are remembered per conversation; history is readable
offline.
- Assistant actions: Turn into flashcards (New deck with the answer as
notes, landing on the options step - deck seed gained step) and Save
to notes (appends to the single scoped deck, trimmed at 40k).
- Chats travel in the backup export (preview counts them) and in the
encrypted sync payload; still never the key.
- Motion from lib/motion.ts: message fade-up, sheet springs for the
model/context/history sheets, 150ms composer height transition, all
skipped under reduced motion.
- Tests: chat suite 63 -> 89 checks (history, reload, rename/search/
delete/undo, actions, export, two-device sync round-trip, offline,
desktop panel). Full set: 12/12 suites, 513 checks. SW -> freezmo-v33.
Also: tests/model-compare.mjs benchmark (diagnostic) for deciding the
default text model on numbers; defaults unchanged.
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.
Reported: tapping Review due could sit for many seconds with no loading
screen. The flow itself is local and fast (counts are hydrated on the
library; <1.5s even at 6x CPU throttle with 3,000 answers), so the
visible fault was missing feedback: /review is a dynamic route and
until its payload arrived nothing on screen changed.
- LinkLoadingScreen: the review card links render a full-screen
loading screen from the link's own useLinkStatus pending state, the
instant the tap happens (measured 58 ms with the route request held
open). The links also prefetch their payload.
- Route loading boundaries (loading.tsx) for /review, /stats and
/exam, sharing a RouteLoading screen (app icon + spinner +
localized message); ReviewClient's bare skeleton replaced with it.
- Health probes: per-key 30s cache + in-flight sharing in use-health.ts
(every page mounts its own gate; a session now fires one probe
instead of one per navigation). Manual re-check still forces.
- Czech fix: exam heading 'Zkouškové režim' -> 'Zkouškový režim'.
Verified by the rebuilt suites (the /tmp harness was wiped overnight):
review-loading 15/15 (stalled-request loading screen at 58 ms, session
arrival after release, fast normal path, mistakes path, end-to-end
grading, probe cache) and navigation 78/78 (tab bar, labels 360/390
EN/CS, routing, deep links, wizard loading lock). Lint + build clean.
SW freezmo-v30.
Reported: during a loading screen (freezing a deck) the Study deck /
Make my homework switch stayed live, so the run could be swapped out
mid-flight; a request that finished afterwards could even save a deck
the user never saw and navigate them into it.
- New lib/wizard-busy.ts: a tiny store both wizards publish to while
phase === 'loading' (UploadScreen) or view.kind === 'loading'
(HomeworkScreen); WizardModeSwitch hides itself while busy.
- Both wizards abort the in-flight request on unmount, so leaving via
the header logo or the Back gesture stops the run instead of letting
it finish invisibly and save/navigate later.
- Deck Cancel keeps its long-standing behaviour (wizard + cancelled
notice + Retry), matching the freeze-loading suite.
Verified: wizard suite 92 checks (mode switch hidden during deck and
homework runs, it returns after Cancel, and a delayed reply after
leaving mid-run saves nothing and navigates nowhere) plus mobile
134/134 (also de-flaked the sheet's Escape close by waiting for
detach), regression 74/74, declutter 116/116, animation 8/8, native
35/35, i18n 25/25, phase1 38/38, phase2 43/43, phase3 20/20,
freeze-loading 6/6. Lint + build clean. SW freezmo-v29.
Drawer removed (phones only; desktop rail untouched)
- MobileSidebar, sidebarOpen/useSidebarOpen, MenuIcon and the drawer's
back-gesture layer are gone; the shared nav content now serves only
the desktop rail (dead mobile branches deleted). No drawer-specific
CSS existed; the three drawer strings (header.openMenu,
sidebar.closeMenu, common.menu) and library.importAria are removed.
- The header drops the hamburger: logo + name on the left, the single
theme chip on the right.
Five-tab bottom bar
- Library / Progress / New deck (prominent centre +) / Exam / Settings,
in that order; Progress and Exam reuse the existing /stats and /exam
screens, Settings keeps opening the dialog. Sliding pill, icon bounce,
press feedback, re-tap-to-top and safe-area padding kept; every item
is >=44px (72px wide at 360px).
- New short tab.* keys (EN + CS) so all five labels stay on one line at
360px — 'Zkouškový režim' became 'Zkouška'. No ellipsis anywhere.
- The bar is mounted once in AppHeader so library, progress, exam and
share get it; the wizards keep their own bottom action bar and the
tab bar steps aside there, exactly as before.
Re-homed drawer destinations
- Smart Review card loses the Exam link (it is a tab now); Review due
and Mistakes stay.
- New deck screen (and /homework) get a Study deck / Make my homework
segmented switch; the library's homework empty-state button stays.
- The phone import icon becomes a ... button opening a sheet with
Import JSON and New folder (back-gesture and Escape close it); folder
chips still appear only once at least one folder exists, and folder
creation stays in the New deck options step.
Verified: mobile suite 134 checks (no hamburger/drawer, tab order and
44px targets, label matrix at 360/390 x EN/CS x dark/light x reduced
motion, per-tab routing, deep links, Back from every tab, the ... sheet
with folder creation) plus regression 74/74, wizard 83/83, declutter
116/116, animation 8/8, native 35/35, i18n 25/25, phase1 38/38, phase2
43/43, phase3 20/20, freeze-loading 6/6. Key parity 740/740. Lint +
build clean. SW freezmo-v28.
Check my work (third homework mode)
- One model call with its own system prompt marks the student's own
attempt: verdict, what's right, each issue with why + fix, and the
corrected version that keeps everything the student got right.
Unasked-for extras are never errors. The attempt is typed/pasted
(photos of handwriting are documented as unsupported) and stored in
the record so Redo re-checks the same work.
- Server: mode "check" + solution on POST /api/homework (zod, 400
server.needSolution without one) and the same NDJSON stream; new
checkHomework() pipeline with repair-once validation.
Follow-ups
- New POST /api/homework/refine (plain JSON, RATE_LIMIT_REFINE, zod,
auth before body): shorter / more detail / explain, plain text back,
language preserved.
- Shorter + More detail + Original rewrite the finished answer and the
corrected version in place (FollowUpBar); every guide step has
Explain step N differently; failed calls say so without losing text.
Turn into a study deck
- Any result hands its content (answer, corrected version or assembled
guide) to New deck through a one-shot sessionStorage seed with the
options step pre-filled.
UI
- Phone step 2 gains the third card: tapping it reveals the solution
field + submit; desktop has three cards and the solution field shows
with the mode. Homework history labels and share/card previews cover
the new mode.
Verified: new phase-3 suite 20/20 against a stub Ollama (check payload,
missing-solution 400, refine validation, phone + desktop check flows,
shorter follow-up with restore, guide explain, deck hand-off) plus
regression 74/74, mobile 52/52, declutter 116/116, wizard 80/80,
animation 8/8, native 35/35, phase1 38/38, phase2 43/43, i18n 25/25.
Key parity 734/734. Lint + build clean. SW freezmo-v27.
Answer history and schedules
- Every answer is recorded (deck, activity, topic, correct/wrong, time)
in a versioned IndexedDB log (freezmo-learning v1, stores answers +
srs), mirrored in memory for synchronous React reads and capped at
20k answers (oldest pruned). StudyFeed records as it grades.
- Light SM-2 schedule per activity: 1 day, 3 days, then interval x
ease; wrong answers return in ten minutes with the ease nudged down.
Review, mistakes, stats
- Library 'Smart review' card: due count, mistakes count (latest
answer wrong — clears itself when answered correctly), today's goal
progress. Review sessions freeze their item list at start.
- New /review (due across decks, ?mistakes=1 global or ?deck= per
deck) built on a shared SessionFeed with the study feed's snap/keyboard
mechanics, wake lock and a results slide.
- New /stats: day streak, five-week calendar, accuracy per subject and
per deck, weakest topics (>=3 answers), daily-goal progress.
- Daily goal configurable in Settings (10/20/30/50).
Exam mode
- New /exam: pick decks, question count, optional timer; no hints
(a NoHints context hides hint rows and the self-check escape);
results with score, per-deck and per-topic breakdown; wrong answers
land in the mistakes set through the normal history.
Reminders (conditional)
- VAPID-gated: GET/POST/DELETE /api/push and POST /api/push/test with
the standard route pattern, RATE_LIMIT_PUSH, zod, auth before body.
Subscriptions stored server-side as endpoint + keys only (PUSH_DIR).
UI hidden entirely without keys; iOS install note; service worker
push/notificationclick handlers; scripts/send-reminders.mjs for a
cron/systemd timer; web-push added as the one server dependency.
Verified: new phase-2 suite 42/42 (history/SRS shapes from a real study
run, mistakes clearing, review session, stats math, exam timer and
breakdowns, VAPID-less hidden state plus the configured
subscribe/validate/test/delete path against a second instance) plus
regression 74/74, mobile 52/52, wizard 80/80, declutter 116/116,
animation 8/8, native 35/35, phase1 38/38, i18n 25/25. Key parity
709/709. Lint + build clean. SW freezmo-v26.
Safety net and errors, mobile-first, desktop unchanged in behaviour.
Backup and persistence
- Settings -> Data & backup: 'Export all data' writes one zod-validated
JSON (decks, folders, homework, progress, safe settings; the API key
is not part of the shape and never appears in the file).
- 'Import backup' validates, shows a preview with added/replaced
counts and offers merge or replace; invalid files get a friendly
error. Storage persistence (navigator.storage.persist) is shown and
requestable, with the result reported.
- A dismissible nudge appears after 5 new decks or 14 days without a
backup and stays snoozed for 14 days.
Opt-in encrypted sync (off by default)
- New lib/sync.ts: PBKDF2-SHA256 (310k) -> AES-GCM-256, derived key
kept as a non-extractable CryptoKey in IndexedDB (lib/sync-key.ts);
the passphrase and key never leave the browser.
- New POST/GET/DELETE /api/sync following the route pattern (locale,
cross-origin guard, body cap, rate limit RATE_LIMIT_SYNC, auth
before body parsing, zod validation). One atomic JSON file per
random 128-bit sync ID under SYNC_DIR; server-stamped updatedAt is
the conflict clock; SYNC_MAX_BYTES and SYNC_TOTAL_BYTES enforced
(413 / 507). The server only ever sees an opaque envelope.
- Settings -> Sync: enable with a passphrase, copy the sync ID, sync
now, last-synced time, explicit keep-this-device / use-synced-copy
conflict choice, join from another device (ID + passphrase, wrong
passphrase rejected), unlock after the key is gone, delete the
synced copy (works even on a locked device). Every data store marks
sync dirty on write; pulled payloads are applied without marking
dirty again.
Monthly usage vs short-term throttling
- lib/ollama.ts classifies the real 429 body (monthly/quota/upgrade
vs burst) into new 'usage_limit' and 'throttled' kinds, mapped in
every route and in the health probe.
- The client renders the same localized message in the deck flow,
homework, grading and the Settings banner: the required
upgrade/new-key copy with 'Open Settings' (jumps to and focuses the
key field) and 'Open Ollama'. Input and wizard drafts are kept so
Retry works right after swapping the key. No model fallback.
Verified: new phase-1 suite 38/38 (backup content + no key, import
preview/merge/replace, junk file, persistence row, nudge + snooze,
sync enable/upload/opaque blob/push/join/wrong passphrase/delete,
server validation 400/401, usage-limit copy and key focus, and the
real route mapping against a stub Ollama for both 429 variants) plus
regression 74/74, mobile 52/52, wizard 80/80, declutter 116/116,
animation 8/8, native 35/35, i18n 25/25. Key parity 638/638. Lint +
build clean. SW freezmo-v25.
Mobile only (<=639px / coarse pointers); desktop keeps its behaviour.
System integration
- Manifest: id, display_override, monochrome icon, Make my homework
shortcut and a share target (text/URL via GET, files via POST).
iOS startup images for the common iPhone sizes. Pinch-zoom is allowed
again (double-tap zoom is disabled via touch-action instead).
- Share target: /share chooser with New deck / Make my homework; POSTed
files wait in a 10-minute in-memory store, reach the wizard through
sessionStorage and never touch disk.
- Web Share: Share rows for decks (JSON file where supported) and the
homework result, with a clipboard fallback and a toast.
- Offline: a quiet pill appears and Freeze / mode cards are disabled
with a clear reason; input is never lost (error cards keep Retry).
- State: wizard drafts (text, options, photos up to a size budget)
survive reloads and OS kills and are cleared on success; re-tapping
the active tab scrolls to the top; the deck page shows a skeleton
instead of a spinner.
Navigation and gestures
- System Back closes the topmost layer first: a small history-entry
stack (lib/use-back-close.ts) covers the drawer, Settings sheet,
install sheet, deck actions panel and one wizard step; navigations
are never rewound and stale entries are skipped.
- Destructive-but-reversible actions use Undo toasts: deleting a deck
(no confirm) and removing a file in the wizard. Irreversible ones
keep their confirm.
Input and feedback
- Every phone input got inputmode / enterkeyhint / autocomplete /
autocapitalize / spellcheck; Enter on the last field advances the
wizard; a clipboard-paste button appears where the API exists.
- Haptics behind a Settings toggle (on by default): tab switches,
toggles, correct/wrong, completion; Vibration API + the iOS hidden
switch tick, throttled and silent everywhere else.
- Wake lock during the study feed and homework guide, released on
leave and re-acquired when the tab returns.
Verified: new native suite 35/35 (manifest/head/viewport, offline,
share target GET + POST, back-gesture layers, undo, share fallback,
tab re-tap, haptics persistence, draft restore), regression 74/74,
mobile 52/52, wizard 80/80, declutter 116/116, animation 8/8,
i18n 25/25. Lint + build clean. SW freezmo-v23.
Needs a real device (documented in the README): iOS haptic tick,
system share sheet entry, wake-lock grant, iPhone startup images,
iOS edge-swipe back, install prompts. Pull-to-refresh and the app
badge were skipped on purpose.