Commit graph

66 commits

Author SHA1 Message Date
b315e2a1aa Touch devices blur the live backdrop instead of the baked layer
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.
2026-10-13 14:07:24 +00:00
4ba80a9bf2 More shinier bubbles; the chat's top edge fades instead of clipping
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.
2026-10-13 13:19:12 +00:00
5eec310e16 Thinner glass everywhere, chat answers as bubbles in Aero
Two asks. "Slightly more transparent": the veils drop another notch
(recipe 0.15 -> 0.12, tokens ~0.03 less, tab bar / wizard bar / gloss
likewise). "Some buttons aren't themed": the recipe now catches the
flows' hand-rolled frost by attribute selectors - the study activities'
bg-white/xx chips (multiple choice, matching, ordering, fill blank),
the exam rows that used bg-[var(--surface)] (an opaque white in Aero),
and everything on the surface tokens (chat chips, homework chips,
inputs, dropzones). Inside sheets the same shapes stay flat; the
dropzones lost their press-scale (a transform re-anchors the fixed
backdrop) and shade inward instead.

And the chat: the assistant answers are bubble-less by design and the
user bubble's thin tint washed out over the bright wallpaper, so the
longest text in the app sat straight on the photo. In Aero both roles
get a real pane now - chat-answer wraps the markdown in a bubble, the
user turn carries chat-bubble, with a stronger veil than the shared
glass for comfortable reading. Light and dark keep the bubble-less
assistant design (styled only under .aero).

Contrast: 0 below-minimum findings, 0 axe violations. Chat 101/101,
mobile 123/123, regression 44/44, i18n 13/13, native 33/33.
Version 0.3.5, SW -> freezmo-v65.
2026-10-13 12:43:46 +00:00
70ddc5e753 Ask pill joins the glass; Aero hovers light the pane, not move it
The Ask button (and the two phone icon pills sharing its styling) was a
hand-styled bg-[var(--surface-strong)] pill that never picked up the
frosted recipe - it showed the sharp wallpaper through a flat tint while
everything around it showed the blurred one. It carries a glass-pill
class and is part of the same recipe now.

Hover feedback: a transform or filter on a pane with a viewport-fixed
backdrop re-anchors the glass, so the deck card's hover lift made the
card flash a wrong crop of the wallpaper (and filter: brightness() on
the buttons, plus the mobile :active scale foundation, would have done
the same). Aero's feedback no longer moves or filters the glass: the
pane lights up with an inner glow, a white rim and a deeper shadow over
170ms, and the pressed state shades inward.

Contrast: 0 below-minimum findings, 0 axe violations. Version 0.3.3,
SW -> freezmo-v64.
2026-10-13 12:11:23 +00:00
0c5d0ffb11 One Ask-style glass everywhere, cache-busted Aero wallpaper
The background "didn't change" because the service worker's asset cache
kept serving the previous wallpaper bytes (stale-while-revalidate returns
the cached copy first, and an old worker can outlive a deploy). Both aero
images carry a ?v=3 query now, so a stale cache cannot answer for them.

"Everything should look like the Ask button" is literal: the rail, the
cards, the chips, the segments and the buttons share one recipe - the
pre-blurred wallpaper under a ~15% veil with the bright rim. The blur
bake is regenerated at 640x400 with a shadow lift (gamma 0.86) and a
saturation nudge, so the scene's colours stay vivid instead of washing
to a milky film. The wizard's phone action bar loses Aero's heavy
water-blue slab for the same glass, and nested chrome inside sheets
keeps its flat fills (the sheet itself is the real glass there).

Contrast: 0 below-minimum findings on every screen and theme, 0 axe
violations. Version 0.3.2, SW -> freezmo-v63.
2026-10-13 11:02:32 +00:00
c88ad2865a Thin frosted Aero glass, boot-theme fix, latest background revision
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.
2026-10-13 09:38:29 +00:00
857e6445ae Higher-resolution Aero background
The same Gemini scene at 1280x714 (was 1024x572): public/aero/
background.webp is 213 kB, and the pre-blurred glass layer is regenerated
with a proper cover-fit so it still lines up with the sharp wallpaper.

Contrast: 0 below-minimum findings, 0 axe violations. Version 0.2.9,
SW -> freezmo-v61.
2026-10-13 08:57:40 +00:00
ff58fdd71d New Aero background (Gemini-made), old credit removed
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.
2026-10-13 08:10:05 +00:00
13b3a80e1c Aero glass is transparent now, blurred without the per-frame cost
The white-ish fills are gone: every surface is a thin translucent glass with
the gloss and a bright rim, and the wallpaper reads through all of it.

The blur is a pre-blurred copy of the wallpaper (public/aero/
background-blur.webp, 3 kB) glued to the viewport with
background-attachment: fixed, layered under the tint and the gloss, instead
of a live backdrop-filter. Blurring the live scene measured 66ms per frame
at 2x CPU throttle (115 frames over 34ms) because the bubbles move behind
every surface; with the pre-blurred layer the same scene runs at 33ms and 8
slow frames — the same as the other themes, so the MacBook stays cool.

Sticky chrome, sheets and dropdowns keep a real backdrop-filter (they sit
over scrolling content, and their area is small).

Contrast: 0 below-minimum findings, 0 axe violations, all three themes.
Version 0.2.5, SW -> freezmo-v58.
2026-10-13 07:45:22 +00:00
9b0354fb11 Fix the clipped answer field + the doubled AI note; ordering only where it fits
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.
2026-10-13 07:27:39 +00:00
f1c4a89adc Aero uses the real wallpaper + liquid glass buttons
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.
2026-10-13 06:21:43 +00:00
67e2cefcfe Aero goes underwater with rising bubbles; floating cookie notice; Czech title
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.
2026-10-13 05:46:53 +00:00
e56653dae1 Frutiger Aero theme, pixel-measured contrast fixes, sheet surfaces
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.
2026-10-12 22:10:16 +00:00
464781a7fd Privacy policy, terms of service, cookie policy + a cookie notice
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.
2026-10-12 21:26:55 +00:00
df34337b4a Accessibility pass, report control for homework exercises, clearer rail actions
- 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.
2026-10-12 21:15:56 +00:00
8822baf4b7 Security: model budget + concurrency caps, SSRF guards, spoof-proof limits
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.
2026-10-12 20:40:57 +00:00
aac6befb0d App versioning + the create page's missing top padding
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.
2026-10-12 20:04:26 +00:00
1f2e702b4e Stop blurring the animated scene on static glass (2x frame time)
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.
2026-10-12 19:47:36 +00:00
9b9e978ef2 Link loading overlay escapes the glass card (portal to body)
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.
2026-10-12 19:04:38 +00:00
2d4d4ee776 One loading screen everywhere (the deck wizard's)
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.
2026-10-12 18:43:53 +00:00
abacfb6018 Deck/homework writes sync themselves when sync is on
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.
2026-10-12 16:40:49 +00:00
d559865a09 Check-my-work attachments, smart review follows deletions, GPL-3.0-only
- "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.
2026-10-12 16:04:53 +00:00
9d204b85be One-pill study chrome, toggling chat pickers, readable homework steps
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.
2026-10-12 14:21:11 +00:00
33c0ad1ed0 Report button moves under the exercise, bottom-right
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.
2026-10-12 12:29:59 +00:00
43dd4e4ec9 Sidebar: arrow sits directly below the logo, in both states
As requested: the collapse/expand control is its own row right under the
brand row — labelled "Collapse sidebar" when expanded, icon-only and
centred when collapsed — so it never moves anywhere else. Measured: the
same y (73px) in both states, directly below the logo.

(The previous attempt anchored it to the rail's top-right corner instead —
not what was asked.)

README: decision 22 updated. SW -> freezmo-v42.
2026-10-12 11:57:17 +00:00
c13b1865c5 Sidebar: give the collapse arrow one home in the rail's top-right
On desktop the collapse/expand control swapped places between states: in
the brand row when expanded, but a nav row in the middle of the icon
column when collapsed — it read as the arrow jumping around.

- One button for both directions, anchored to the rail's top-right corner
  in both states (measured 23px from the rail's right edge either way;
  the padding accounts for the rest).
- Collapsed, the brand mark leads the icon column (it cannot share the
  brand row with the arrow: the 60px panel has ~40px of content width).
- The expanded state is unchanged (logo + name + DEV badge, arrow top
  right).

README: note in decision 22. SW -> freezmo-v41.
2026-10-12 11:45:56 +00:00
b5269d2844 AI disclaimer: also on the cards and the results
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.
2026-10-12 10:59:49 +00:00
396da6528a Flashcard flip: paint one face explicitly, never rely on backface-visibility
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.
2026-10-12 07:34:38 +00:00
9cea13496a Report question: skip a broken card and tell the server
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.
2026-10-12 07:12:55 +00:00
37a84df462 AI disclaimer: one shared component, shown where model output appears
"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.
2026-10-11 22:51:16 +00:00
05e0fdcc86 Fix sidebar rail overflow on the chat page
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).
2026-10-11 21:04:15 +00:00
8a3bef0add Chat layout: history in the main sidebar, consistent header, cleaner icon
- 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.
2026-10-11 20:11:11 +00:00
3f52ab47c2 Infra: split dev and production (releases, services, deploy script)
- 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.
2026-10-11 19:25:29 +00:00
5ca0de920c Chat mode, phase 2: local history, answer actions, motion polish
- 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.
2026-10-11 19:03:48 +00:00
756a6257a2 Chat mode, phase 1: grounded streaming chat over your notes
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.
2026-10-11 18:32:11 +00:00
6b6a6ea158 Ice scene: the glossy orbs become rising bubbles, ~38% more transparent
- 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.
2026-10-11 15:50:17 +00:00
2103f9fc69 Smart Review: instant loading feedback, no more dead-feeling tap
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.
2026-10-11 14:35:16 +00:00
c662fb11f7 Lock the wizard mode switch while a run is generating
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.
2026-10-10 17:47:05 +00:00
8557b04621 Navigation restructure: five-tab bottom bar, no phone drawer
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.
2026-10-10 16:53:55 +00:00
4513364019 Phase 3: Check my work, follow-ups and turn-into-deck
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.
2026-10-10 14:36:04 +00:00
e4a8e0ba01 Phase 2: learning engine — history, SRS review, mistakes, stats, exam, reminders
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.
2026-10-10 14:15:21 +00:00
bb1f4e2018 Phase 1: backup, encrypted sync and monthly-usage handling
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.
2026-10-10 13:43:42 +00:00
1dc2992fb5 Show the generation loading screen again on phones
The wizard-polish restructure put the phone branch before the loading
check, so 'Freeze my deck' left the wizard on screen (looking like
nothing happened) until the router landed on the finished deck. The
loading state is now checked before the phone flow, for both layouts.

New freeze-loading suite (6 checks): the staged loading screen appears
immediately after Freeze, shows the stage message and Cancel, unmounts
the wizard bar, and Cancel returns to the wizard with the cancelled
notice instead of getting stuck.

Regression 74/74, mobile 52/52, wizard 80/80, declutter 116/116,
freeze-loading 6/6, native 35/35, i18n 25/25. Lint + build clean.
SW freezmo-v24.
2026-10-10 12:35:14 +00:00
4369ba1a17 Native-feel pass: PWA integration, system back, offline, haptics, undo
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.
2026-10-10 12:16:03 +00:00
e66141e9a0 Wizard polish: step headlines, gated Next, real summary, portal action bar
Mobile only (<=639px); desktop markup untouched.

Bug fix: the Back/Next bar was fixed-positioned inside the screen
entrance animation's transformed wrapper, so it painted under the content
and jumped to the viewport bottom about a second in. It is now portalled
to <body> and only fades in, pinned to the bottom from the first frame.
The other fixed/sticky elements (tab bar, drawer, sheets, install sheet)
were checked and live outside the animated wrappers.

- The step name is now the large headline with a one-line description;
  the repeated page title is dropped on phones. Thin progress bar + 1/3.
- Step 1: formats/limits hint, one big dashed Add photos or files area
  (plus icon), Camera and Paste text instead as secondaries, and a
  'nothing added yet' placeholder that becomes the thumbnail/chip list.
- Next is disabled until a photo, file or pasted text exists; step 3 can
  never be reached empty.
- Step 1 shows Cancel (back to the Library) instead of a dead Back.
- Step 3 is a real summary: thumbnail/chip strip plus Files, Difficulty,
  Activities and Folder rows, each with an Edit link to its step; Freeze
  is the single primary action.
- Step 2 got a visible Difficulty label and a helper line under every
  group, defaults still preselected.
- Bottom chrome: flat full-width Back/Next bar above the safe area, and
  the tab bar hides inside both wizards. The lower-right orb is hidden on
  phones so it can never glow behind the bar.
- Make my homework matches: headline, hint, big add area, placeholder,
  Cancel, gated Next, flat bar.

Verified: new wizard suite 80/80 (bar position sampled at 5 frames during
the entrance from tab bar/drawer/empty state; 360 + 390, dark + light,
EN + CS, reduced motion on/off; gating, summary Edit links, hidden tab
bar and orb), declutter 116/116, regression 74/74, mobile 52/52,
animation 8/8, i18n 25/25. Lint + build clean. SW freezmo-v22.
2026-10-10 11:35:45 +00:00
374752c59e Animation pass: one shared motion language for phones
Mobile only (<=639px / coarse pointers); desktop keeps its behaviour.

Foundation
- lib/motion.ts owns the spring presets (snappy/gentle/sheet), durations,
  easings and variants (screens, tabs, wizard steps, list stagger).
- Native-feel CSS: press feedback scales every button, card, chip, tab,
  row and summary to 0.96 with a spring-like cubic-bezier on transform
  (scoped to phones/coarse pointers), tap-highlight off, touch-action
  manipulation, chrome text non-selectable, reduced motion drops it.
- App shell fades in once on launch; the study feed fades in on phones.

Applied
- Tab bar: sliding active pill (shared layoutId) + icon bounce.
- Study Decks/Homework, language toggle, difficulty and size pickers:
  the same gliding highlight, gated so desktop keeps the old styling.
- Route content enters direction-aware (deeper = from the right, back =
  from the left, same-depth = crossfade) via RouteTransition on the
  library/create/homework content columns; library tab switches
  crossfade; wizard steps slide forward/backward (reduced motion safe).
- Library: cards fade up in a capped stagger on first load, glide with
  layout animations and shrink away on delete (AnimatePresence).
- Study feed: correct feedback pulses, wrong shakes, the progress bar
  settles with a spring curve, score and XP count up on the results.
- Settings sheet uses the sheet spring; the drawer now follows the
  finger and closes on release velocity, like the sheet.
- Generation progress: the stage message fades/slides between stages;
  the icon keeps its gentle wobble; the feed fades in on hand-off.

Skipped, on purpose: LazyMotion (the sliding highlights need layout
animations = domMax, same features the current import already has);
route exit animations (the App Router unmounts the outgoing page —
exits are handled by in-page AnimatePresence); replacing the study
feed's native scroll-snap with finger-follow drag (would fight the
platform scroller); finger-follow height animation for native <details>
expanders.

Verified: animation suite 8/8 (reduced motion + 4x-CPU-throttled trace:
no long tasks, CLS 0.0000), regression 74/74, mobile 52/52, declutter
116/116, i18n 25/25. Lint + build clean. SW freezmo-v21.
2026-10-10 11:01:29 +00:00
6564e88138 Mobile declutter pass: step flows, compact cards, simplified settings
Phones only (<=639px); desktop markup is untouched.

- New deck is a 3-step flow: progress (1/3 + bar) and a fixed Back/Next
  bar above the tab bar. Step 1 = Add photos or files / Camera / Paste
  text instead (no drop zone or paste copy on touch), step 2 = folder,
  difficulty and count with defaults plus one collapsed focus/notes row,
  step 3 = one summary line (photos/docs/text · difficulty · count ·
  folder) with Freeze. Going Back keeps every input; step transitions
  respect prefers-reduced-motion.
- Make my homework is a 2-step flow sharing the same pattern; tapping a
  mode card starts it (no separate CTA on phones), with a short preview
  of the assignment on step 2. The hero is now 'Make my homework' /
  'Udělej mi úkol' everywhere ('Solve your homework' is gone) and the
  intro is one line.
- Library: stats line removed, folder row and 'Unorganized' heading
  hidden until folders exist (New folder lives in the drawer), Import is
  a small icon button beside the tabs. Deck and homework cards are
  compact on phones: title, one meta line (activities · difficulty ·
  best), a one-line description that expands on tap, the whole card
  tappable and a small Study/Open pill instead of the full-width button.
- Settings sheet on phones: no .env.local subtitle; Language stays first;
  one Connection card (status dot + 'Connected · 2 models ready', key
  field with the eye inside, Save only when edited, auto re-check on
  save, refresh icon instead of Test connection); privacy one-liner with
  a More expander (daemon tip inside); raw health rows behind one
  collapsed Details list; Remove key as a small danger button at the very
  bottom (same confirmation).
- Decorative orbs on phones moved further off the screen edges so they
  never glow under cards or primary buttons.
- Shared markup for folder/difficulty/count and mode cards keeps one
  source of state; new lib/use-phone.ts gates the layouts.

Verified: new declutter suite 116/116 (360 + 390, EN + CS, photo + PDF +
pasted text, Back keeps input, summaries, settings flow), regression
74/74, mobile 52/52, i18n 25/25. Lint + build clean. SW freezmo-v20.
2026-10-10 10:43:32 +00:00
0ee9e930f0 Retry runs get a fresh scroller so the feed can never start blank
Retrying the missed items could leave the feed on a stale position: the
retry swaps the queue (8 slides -> 3) and resets the scroll on the OLD
element, while the browser was still finishing a smooth scroll or snap.
Depending on the engine that could strand the viewport past the new
content (blank feed with the header still showing the old counter), and
the results slide kept its inner scroll because it has no key.

- The scroller now carries key={runId}, so every run mounts a brand-new
  element: scroll position 0, fresh snap state, no inherited inner
  scrolls in any slide.
- The scroll listener re-attaches to the new element (deps [runId]).

New retry suite 12/12: plain retry (fresh element, slide 1, header
Retry 1/N, missed item answerable again), retry fired mid smooth-scroll,
double-tap retry, and retry -> answer -> results -> retry again — all
land on slide 0 with content visible. Regression 74/74, mobile 51/51,
i18n 27/27. Lint + build clean. SW freezmo-v19.
2026-10-09 20:22:28 +00:00
6fb11fa405 Grade against the question: stop requiring details the question never asked for
A short-answer case: "Why was Kryštof Harant executed?" with the student
answering "because he took part in the anti-Habsburg uprising" was failed
for not naming the year 1621 — a detail only present in the reference
answer, never asked by the question. The generator had put the date into
keyPoints, and the grader treated every key point as mandatory.

- Grading prompt: the QUESTION defines what is required. A key point
  counts only when the question asks for it or the answer is wrong or
  incomplete without it; extra detail in the reference answer (dates,
  places, names, numbers) is a bonus — never listed as missing, never a
  reason to fail. An accepted answer must return an empty "missing"
  array.
- Generation spec: short_answer keyPoints must contain only the ideas
  the question asks for (a "why" question requires the reason, not the
  year).
- gradeShortAnswer drops any stray "missing" entries when correct is
  true, so the UI can never show a gap on an accepted answer.

Verified: mock-Ollama integration test 9/9 through the real /api/grade
route (new rules sent upstream, incorrect answers keep their missing
point, accepted answers normalise to missing: []), regression 74/74,
mobile 51/51, i18n 27/27. Lint + build clean. SW freezmo-v18.
2026-10-09 18:13:29 +00:00
829a71f4ff Show the app icon while a deck is generating
The staged loading screen still rendered the pre-redesign logo — a white
snowflake in a blue rounded square — while every other surface (header,
sidebar, empty states, key gate, install sheet) uses the app icon.

- LoadingState now shows /icons/icon-512.png at size-28 with the same
  gentle wobble animation; 512px keeps the 112px render crisp on hi-DPI
  phones.
- Service worker: freezmo-v17.

Verified: loading-logo suite 12/12 (dark + light: icon visible at
112x112, no old snowflake svg left, heading/stages/cancel intact),
regression 74/74, mobile 51/51, i18n 27/27. Lint + build clean.
2026-10-09 17:03:59 +00:00