Commit graph

15 commits

Author SHA1 Message Date
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
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
f202653017 Generation guards: cards with repeated wording never ship
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.
2026-10-12 05:37:03 +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
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
dbd5dfbc58 Move the e2e suites into the repo (tests/), out of /tmp
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.
2026-10-11 15:11:06 +00:00