The pushed copy pass renames the library heading to "Your freezer"
("Tvůj mrazák" in Czech) and reworks the gate subtitle plus several
Czech strings. Five suites pinned the old heading — regression, i18n,
native, wizard and providers — and now assert the new one; the README
prose and project tree follow. Key parity is unchanged.
Version 0.5.9, SW -> freezmo-v81.
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.
Docking the two chat bands into the rail card removed the floating cut
but the edge stayed a straight vertical line - still a seam ("this
still looks bad"). Both bands now keep the rail's own 12px gutter and
end in the rail card's 1.5rem radius on their left side, so the ending
reads as deliberate; the header divider spans the flat part of the
bottom edge, from the corner curve inward. Phones keep the plain
full-width borders.
The chat suite now pins the geometry: 12px gutter at the seam, 24px
left corners on both bands, zeroed band borders, inset ::after
hairlines. Chat 104/104, mobile 126/126, regression 50/50.
Version 0.5.7, SW -> freezmo-v79.
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 touch fallback from 0.3.8 (a real backdrop-filter over the live
scene) was the wrong trade: every pane re-blurs on every frame while
the bubbles animate, and phones choke - input lag, "buttons take ages
to respond". Touch devices now drop both the bake (which mispaints
there) and the live blur: the panes keep the thin Ask-pill tint over
the sharp scene, and the hand-rolled activity fills keep their own
tints - transparent glass at zero per-frame cost. Desktop keeps the
baked blur.
tests/mobile-test.mjs pins the flat contract (no baked layer, no
backdrop-filter on a card) with a touch context: 124/124.
Contrast 0 findings, axe clean, chat 101/101, regression 44/44, i18n
13/13, native 33/33. Version 0.3.9, SW -> freezmo-v68.
Mobile engines repaint background-attachment: fixed late (iOS treats it
as scroll), so the baked glass showed a stale crop of the wallpaper
until a tap forced a repaint - "some of the UI looks bugged until I
click it". On touch devices (max-width 639px or pointer: coarse) the
bake is dropped for a real backdrop-filter over the live scene, so the
bubbles blur through the glass as they pass and the position is correct
by construction. Hand-rolled frost fills keep their own tints there;
desktop keeps the baked layer (measured cheaper and verified correct).
Pinned with a touch-context check in the mobile suite (124 checks).
Contrast 0 findings, axe clean, chat 101/101, regression 44/44, i18n
13/13, native 33/33. Version 0.3.8, SW -> freezmo-v67.
Bubbles: ten -> eighteen (interleaved, 18px to 96px, 10-23s runs), with a
tighter specular hotspot, a reflected-light glint low on the sphere, a 1px
glass edge and a brighter inner rim on both the bubbles and the drifting
orbs. Bodies stay translucent with capped opacity, so wallpaper text stays
readable; bubble-perf shows no added frame cost.
Chat: the conversation scrolls up to the glass header, and a line clipped
exactly at that edge read as a rendering bug over the busy wallpaper - the
scroller now carries a 14px top mask in Aero, shorter than the column's
16px top padding so a fresh conversation shows no dimming.
Contrast 0 findings, axe clean, chat 101/101, mobile 123/123 (bubble count
pinned to 18), regression 44/44, i18n 13/13, native 33/33.
Version 0.3.7, SW -> freezmo-v66.
The white surfaces were reading as a milky white panel (a card top was
~71% white). The frosted recipes now use a 14% base veil plus the gloss
over the pre-blurred wallpaper, small chrome (rows, chips, segments,
buttons) keeps a touch more veil, and nothing nested inside a sheet or
dialog uses the viewport-aligned layer (it showed the raw dark water
under the settings theme labels at 1.5:1 — nested chrome falls back to
the flat fills over the genuinely blurred sheet).
The first-load text bug was the boot script: its dark test was
`s && !a ? s === 'dark' : matchMedia(...)`, which for a stored aero fell
through to the OS preference, so a dark-preferring machine loaded with
both `dark` and `aero` classes and every `.dark` rule painted over the
wallpaper until a theme toggle re-applied the classes. The stored value
now decides alone; a dark-colorScheme check pins it in the mobile suite
(123 checks).
Background: the user's latest Gemini scene (1280x714) re-encoded to
public/aero/background.webp (206 kB) with a fresh pre-blurred sibling.
Contrast: 0 below-minimum findings, 0 axe violations, all three themes.
Version 0.3.1, SW -> freezmo-v62.
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 sheet's spring animation meant the box measured right after the dialog
appeared could be mid-flight; tapping 24px above it sometimes landed inside
the sheet once it settled (the deploy gate caught it twice — production was
never touched). Tap a fixed point near the top instead: the sheet is
max-h-[90dvh], so its top edge cannot reach y=12.
Three consecutive mobile runs: 119/119 each.
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 MacBook ran hot browsing the app: the ice scene animates constantly, so
every backdrop-filter surface re-ran its blur on every frame. Measured on
desktop (2x CPU throttle, software renderer): p50 frame 66.6ms baseline,
33.3ms with the static surfaces not filtering, 16.7ms with the scene's
animation also off; frames over 34ms: 128 -> 14.
The glass recipe is split: floating layers (sheets, dropdowns, dialogs, the
sticky header/chrome/tab bar) keep the blur (backdrop-blur-lg, what phones
already used); static layers (cards, the sidebar rail, the gate, the study
prompt card) keep the identical tint, rim, highlight and shadow without the
filter. The study prompt card moves from glass-panel to card + glass-solid,
which is the same surface recipe.
Quality: a deterministic pixel diff (scene motion frozen, both themes,
desktop + phone) shows a ~1% colour shift in the card backdrops — invisible.
The mobile axe contrast audit and the bubbles suite stay green.
tests/bubble-perf.mjs now also reports the glass cost. README: decision 61.
SW -> freezmo-v49.
The full-screen loading screen shown the instant a Link starts navigating
was rendered inside the Smart Review card. That card has backdrop-filter,
which makes it a containing block for position: fixed — so the overlay was
confined to a 44px strip of the card and the loading block spilled below the
fold instead of covering the viewport. It is now portalled to <body> with a
soft page scrim behind it; the review suite measures that it covers the
viewport (390x844).
SW -> freezmo-v48.
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.
The red "!" left the top chrome (it read as chrome and was easy to
mis-tap) and now sits in a row under the exercise: the AI disclaimer stays
centred, the report control is right-aligned with the card edge and is a
44px target. Applies to study, Smart Review and exam sessions; the results
slides keep just the disclaimer.
phase-2 suite now asserts the placement (below the card, right-aligned,
not in the header, >=44px) — 60 checks.
README: decision 52 updated. SW -> freezmo-v43.
The note now rides along with the content itself, not only with the
generation screens and the chat composer:
- Under every card in the study feed and in the review, mistakes and exam
sessions (the activity slide's last element — below the card, and below
the feedback panel once answered).
- On the results slides: the study results screen and the shared session
results (review, mistakes, exam).
Navigation surfaces (library, deck lists, chat history, settings) still
carry no note — it belongs to the moment of generation and to the card
being answered, not to browsing (README decision 50 updated).
The phase-2 grading check now scopes its placement (the disclaimer right
under the graded box) since the same text legitimately appears several
times on a session page; three new checks cover the feed, the results
slide and the exam (59 checks). SW -> freezmo-v40.
On a GPU-composited desktop the flipped card showed the question mirrored
over the answer. Both faces are glass layers with backdrop-filter, and on
some compositing paths the front's backface keeps rendering, so the
pre-rotated back face and the mirrored front face both painted.
- The faces are now painted by an explicit visibility swap driven by the
animation's own rotation (onUpdate): the swap lands at the 90° edge-on
moment, where it cannot be seen, so exactly one face is ever in the
render tree — independent of backface-visibility and of the browser's
3D flattening rules. onAnimationComplete lands the swap for instant
flips (reduced motion now skips the spring, matching the app's motion
rules).
- PromptCard takes an optional style so the face can carry the visibility.
- phase-2 suite: the painted-face invariant at rest, flipped and flipped
back, plus the answer reading inside the card (56 checks).
- README: decision 53. SW -> freezmo-v39.
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.
The content offset was fine — the rail's glass panel was the problem. As a
flex-1 item in the row-flex aside, the panel's automatic minimum size
(min-width: auto) is its content's min-content width. The chat list's
search input (an <input> defaults to size=20) has a ~192px intrinsic
width, which with the New chat button stretched the panel to 345px — 89px
past the 268px rail — where it painted over the chat page: the header
title, the composer placeholder ("sk about your notes...") and the
context chip ("ts").
- min-w-0 on the panel so the rail width always wins and wide content
truncates instead (the input and list rows already shrink/truncate).
- Chat suite: assert the panel stays inside the rail and the main content
starts after it, expanded and collapsed (the old checks counted
elements, never their boxes — this is what slipped through).
- README: history bullet + decision 49. SW -> freezmo-v36.
Verified on the dev instance and a production build: panel 244px inside
the 268px rail (60px in the 84px collapsed rail) at 1024-1920, content
starts after the rail, all sidebar pages unchanged, dark + light, and
the full e2e (12/12 suites, 516 checks).
- 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.
- 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.