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.
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.
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.
The last visible difference between the two CTAs was the container: the
deck button lived in a full-bleed sticky strip (translucent background,
hairline top border, its own padding), which also tinted the disabled chip
differently. The homework button sits plainly in the page flow.
- The CTA block is now a plain space-y-2 stack at the end of the form:
full-width button + centred helper line, exactly like /homework.
- /create's page padding switches from the sticky-era .pb-cta (9rem, phone
override 4.6rem) to .pb-tabbar, and lg:pb-20 to lg:pb-16 — identical to
the homework page. The now-unused .pb-cta rules are gone.
- Result: same button width (358px @390 / 688px @1280), height, radius,
helper placement and trailing space on both pages; neither has a sticky
ancestor any more.
Service worker: freezmo-v16.
Verified: CTA-flow suite 26/26 (below-the-fold at rest, no sticky ancestor,
full-width, helper centred underneath, width/height/radius/trailing-space
parity at 390px and 1280px), desktop regression 74/74, mobile 51/51,
i18n 27/27. Lint + build clean.
Same sparkles icon, same shape, same states — only the label differs, which
is the one thing that cannot be shared. The snowflake stays the app's brand
mark on the hero, the drop zone and the tab bar.
Service worker: freezmo-v15.
Verified: computed-style diff between the two buttons is empty and the
cropped screenshots differ only in their text; CTA-parity suite 32/32,
desktop regression 74/74, mobile 51/51. Lint + build clean.
Correcting the previous change: instead of stripping the snowflake from
"Freeze my deck", "Make my homework" now carries the homework flow's own
sparkles icon, and the snowflake is back on the deck button.
Both CTAs are now identical in shape — feature icon + label, full-width,
44px, 14px radius, helper line centred underneath, same muted disabled chip
and ice gradient when enabled. The computed-style diff between the two
buttons is empty and their accessible names are unchanged.
Service worker: freezmo-v14.
Verified: CTA-parity suite 32/32, desktop regression 74/74, mobile 51/51.
Lint + build clean.
The two CTAs already shared the same Button size="lg" shape (full-width,
44px, 14px radius, muted disabled chip, helper line centred underneath);
the last visible difference was the snowflake icon on "Freeze my deck".
Dropped it so the button is text-only exactly like "Make my homework" —
the snowflake stays on the hero, the drop zone and the tab bar.
Service worker: freezmo-v13.
Verified: computed-style diff between the two buttons is now empty (same
classes and every measured property), CTA-parity suite 32/32, desktop
regression 74/74, mobile 51/51, i18n 27/27. Lint + build clean.
- "Freeze my deck" is now a full-width (w-full) button in its sticky bar,
like "Make my homework", instead of a compact left-aligned button in a
flex row.
- The status/validation line ("Add a photo or a document (or some text) to
start.") moved from above the button to centred directly underneath it,
with the same 12px muted styling the homework flow uses.
- Both flows now share the Button size="lg" shape (44px tall, 14px radius,
same muted disabled chip, centred text); hover/disabled behaviour comes
from the same .btn-primary/.btn-disabled classes.
Service worker: freezmo-v12.
Verified: CTA-parity suite 32/32 (full-width ratio ~1.0 at 390px and 1280px,
44px height, centred helper under the button on both pages, identical radius
and disabled chip, enabled ice gradient), mobile 51/51, i18n 27/27, desktop
regression 74/74. Lint + build clean.
Header & action bar
- Import JSON no longer disappears on the Homework tab: it sits directly
under the "Your icebox" header/subtitle, above the tab switcher, on every
tab — so switching pools no longer shifts the layout or hides actions. It
is one secondary button at all sizes now, not a quiet link when empty.
- The decks-only "Homework Solver" header action is gone; the homework tab's
empty-state CTA and the drawer cover starting a new solve.
Empty states
- Dropped the "Using your own Ollama models — check the connection in
Settings." line (and its two dictionary keys) everywhere.
- Both tabs share one structure: ice icon -> heading -> short description ->
primary CTA button, starting at the same height:
Study Decks: "No decks yet" -> "Freeze a page of notes into flashcards
and quizzes." -> "Create study deck"
Homework: "No homework yet" -> "Solved assignments and step-by-step
guides show up here." -> "Solve homework"
- CTA labels read as actions on the standardized nouns; removed the now
unused actionStudyDeck/actionHomework keys.
Tabs
- Link segments are 44px tall at every breakpoint (min-height in the base
.segmented > a rule).
Service worker: freezmo-v11.
Verified: library-consistency suite 49/49 (Import visible with a fixed
position on both tabs, no Ollama text, empty states at the same y, primary
ice CTA buttons >=44px, deep links, deletion, redirects, desktop, 360px, axe
en+cs light+dark), mobile 51/51, i18n 27/27, desktop regression 74/74.
Lint + build clean.
Library ("Your icebox")
- Two tabs, never mixed: Study Decks (the folder + card library) and Homework
(saved solutions and step-by-step guides). The active tab lives in the URL
(/?tab=homework), so the sections are linkable and survive a reload.
- Decks tab keeps Import, the folder bar, stats and its own empty state — now
a single decks-only CTA.
- Homework tab lists saved results as cards (mode icon, date, step count,
preview) with Open and delete-with-confirm; its empty state has its own CTA
("Homework Solver") that opens the solver directly. Opening a card
deep-links to /homework?open=<id>, which the screen opens on arrival.
- .segmented now also styles link segments (same control as settings).
/create
- The redundant "Study Deck | Homework Solver" toggle is gone: /create is the
deck uploader only, and the bottom bar's + goes straight to it. Homework has
its own screen, reached from the library tab, the drawer and CTAs.
Copy
- New keys (en + cs): tabDecks, tabHomework, homeworkHeroTitle, plus homework
empty-state/open/step-count strings. The homework hero is now "Solve your
homework" / "Vyřeš svůj úkol" — no study-deck text in the homework flow.
- Removed the now-unused ModeSwitch component and homework.title key.
Service worker: freezmo-v10.
Verified: library-tabs suite 37/37 (tab switching, context empty states,
deep-link opening, deletion, no switch on /create, heroes, redirects, desktop
grid, 360px + 44px targets, axe contrast en+cs light+dark), mobile 49/49,
i18n 27/27, desktop regression 74/74. Lint + build clean.
Terminology (lib/i18n-dict.ts)
- One name per mode everywhere: "Study Deck" / "Studijní balíček" and
"Homework Solver" / "Řešitel úkolů". The library empty-state cards and the
/create switcher now read the same, so the choice no longer changes name
between screens.
Deep links
- Library quick actions carry the mode: /create?mode=deck and
/homework?mode=homework.
- Both pages canonicalize: /create?mode=homework redirects to the homework
flow and /homework?mode=deck redirects to /create, so a mode link always
opens that mode directly.
/create
- The two bulky square cards (106px tall, wrapping/clipping "Study Deck &
Qu…" on phones) are replaced by a compact segmented control (52px) placed
directly above "1. Add your notes": ice pill, snowflake/sparkles icons,
44px segments and one-line labels at 360px. The pill slides to the chosen
segment and the route follows ~190ms later, so toggling reads as one smooth
transition (immediate under prefers-reduced-motion).
Service worker: freezmo-v9.
Verified: mode-switch suite 26/26 (deep links, cross-mode redirects, 52px
control, 44px segments, no clipping/wrapping at 360px, "1. Add your notes" at
y=394 of 740 with the upload controls above the fold, pill slide, axe contrast,
en+cs, phone+desktop), mobile 49/49, i18n 27/27, desktop regression 73/73.
Lint + build clean.
Library empty state ("Your icebox")
- Replace the single "Make your first deck" button with two quick-action
cards: "Create interactive test" (primary, ice-gradient icon + subtle
accent border) and "Help me with my homework" (glass card, sparkles
icon). Stacked on phones, side by side from 640px up; both >=44px tall.
/create
- Replace the "Got homework instead of notes?" banner with a two-card mode
switcher above "1. Add your notes": "Study Deck & Quizzes" (the current
view — aria-current, accent tint, ice icon) and "Homework Solver" (a
shortcut to /homework). Icon-on-top tiles on phones, inline rows from
640px up; glass tokens + backdrop blur in both themes.
Strings
- New keys in both dictionaries: emptyStateCreateDeck, emptyStateHomework,
actionStudyDeck, actionHomework. Drop the now-unused homework.entry and
library.makeFirst keys.
Drive-by AA fix (light mode, /create only)
- The "New folder" dashed button and the difficulty blurbs move from
frost-500 to frost-600 (4.0:1 -> ~6:1). Dark mode is unchanged.
Service worker: freezmo-v8.
Verified: redesign suite 32/32 (stacked/side-by-side layouts, 360/390/1280,
dark+light, en+cs, >=44px targets, both cards navigate, no overflow, axe
contrast), mobile 49/49, i18n 27/27, desktop regression 73/73. Lint + build
clean.
On phones the "Freeze my deck" strip floats one tab-bar height (4.6rem)
above the viewport bottom, but the page reserved env + 9rem of trailing
room — so at the end of the scroll the strip docked ~70px higher than where
it had been floating, leaving a visible cavity above the tab bar.
Phones now get exactly the sticky offset (env + 4.6rem) as trailing space:
the strip settles where it was floating and the bar is never double-counted.
Tablet/desktop (>=640px) keep the roomier 9rem — unchanged.
Verified at 360/390px: gap CTA-to-bar 70.3px -> 0.3px, ~70px less dead
scroll; tablet 800px and desktop 1280px measurements identical to before.
Service worker: freezmo-v7.
Homework (/homework — entry points in the drawer and on the create screen)
- Assignment intake reuses the study pipeline: pasted text, photos
(camera/picker, compressed in the browser) and documents (PDF/DOCX/PPTX/
ODT/text) through lib/image + lib/documents, the same vision step and the
same language guard. Raw files never leave the device — only text and JPEG
data URLs reach the server.
- Two large choice cards: “Just make it” returns the finished homework
(answer only, plain text, copy + make it again); “Explain how to make this
homework” builds an interactive guide.
- The guide is one step per screen in the study-feed snap layout:
explanation, three progressive hints (nudge -> hint -> the answer) and an
optional checkpoint rendered by the existing activity components (graded
through /api/grade). The last screen assembles the solution from the step
answers, with a copy button. Checkpoints carry xp 0 so the guide stays free
of XP/streak chrome.
- Results live in freezmo.homework.v1 with a small history (open/delete);
“Make it again” re-runs from the stored assignment text (the vision
transcription when photos were used) — no original files are stored.
- The answer and the guide are written in the assignment's language, never
translated into the UI language.
Server
- POST /api/homework: NDJSON stage events like /api/generate, the same
http-guard, auth checked before parsing, and its own RATE_LIMIT_HOMEWORK
(documented in .env.example + the README table).
- New prompts in lib/prompts.ts and zod schemas in lib/schemas.ts with the
same single repair retry; TEXT_MODEL for content, VISION_MODEL only for
photos. New shared client pieces: useNotesIntake, lib/homework store,
lib/clipboard, and a shared NDJSON reader in lib/client-api.
Also finishing the mobile polish pass from the previous request
- Phone ice scene: the top-left orb moved behind the glass header (it sat
behind the page heading/subtitle). The settings sheet's scroll cue is now a
bare chevron inside a content fade (mask) instead of a chip over the status
banner, and the API key card's note/disclosure/actions are tightened.
- theme-color now follows the in-app theme (SSR metas still fall back to the
OS preference); apple-mobile-web-app-status-bar-style stays “default”
because iOS fixes the status-bar text colour at launch.
Verified: 78/78 homework checks (text/photo/PDF, both modes, EN+CS, dark+light,
360/390px, copy, history persistence, storage privacy, API language, axe), plus
the existing suites re-run (mobile 47/47, pixel contrast 0 below AA, i18n 27/27,
desktop regression 72/72). Lint + build clean; service worker freezmo-v6.
Phones (<=639px) only for layout/gestures; the theme-level fixes apply
everywhere, as they are token/design changes.
Settings sheet
- Swipe-to-dismiss reusing the InstallSheet gesture: the sheet follows the
finger, closes past ~90px or on a fast flick, snaps back otherwise. Drag
starts only from the handle/header and only while the body is scrolled to
the top. Backdrop tap still closes.
- Fixed header + scrollable body: pb-safe + spacer, so nothing is clipped;
a scroll cue (chevron) fades in while more content is below and out at
the end.
- Show/Save stack under the field at 44px on phones; close, Test connection
and Remove key are 44px too. Remove key is now a rose destructive action
with a confirmation that the full-screen key setup comes back.
- API key note split into a short privacy line plus a collapsible
'Using the local daemon?' tip (OLLAMA_AUTH=local / .env.local / signin).
- Fixed the missing space in the English tip ('ollama signin instead.').
- Health: kept the auto-check and banner; added a loading row and a
'Checked just now' / 'Checked at HH:MM' freshness label (also for errors).
Ice scene / readability
- Verified the orbs were never above content (fixed z-index:-1 layer,
pointer-events:none); they were just shining through the glass. Phones get
lower blob/orb opacity and orbs tucked to the edges; the bottom-right orb
is moved out from under the tab bar and the tab bar paints a more opaque
surface so its labels hold contrast.
Contrast / themes
- Dark --text-subtle raised to #9db1d0 (8.6:1 page, 7.2:1 glass); helper
text now uses the tokens.
- Disabled buttons are a visible muted chip (.btn-disabled) instead of a
40%-opacity ghost.
- One ice primary gradient in both themes; danger/success now actually show
their colour (bg-none strips the primary background-image that used to
paint over !bg-rose-600).
- Light mode: cool card rims instead of white-on-white, stronger shadow.
Redundancy / touch
- Removed only the header gear; the hamburger drawer (folders, Settings,
theme) and the tab-bar Settings stay.
- Theme toggle is a bordered chip with an action aria-label (drawer row too).
- Empty library hides the folder pills, demotes Import to a quiet link and
keeps a single primary CTA. 44px targets for nav rows, segmented control
and folder pills on phones.
- Fixed a pre-existing 16px horizontal overflow (header -mx-4 vs a
padding-less parent) and let info values wrap instead of truncating.
Verified: mobile suite 47/47 (EN+CS, 360/390px), pixel-sampled contrast
audit 0 runs below WCAG AA (8 runs, both themes/locales; axe marks the glass
'incomplete'), i18n 27/27, desktop regression 72/72, lint + build clean.
Service worker cache bumped to freezmo-v5.
English stays the default; Czech is a full translation. Only the app chrome
is translated - notes and generated decks keep their own language, which is a
core feature, not a setting.
i18n
- lib/i18n-dict.ts: flat en/cs dictionaries (441 keys each), translate() and
translatePlural() with real Czech plural rules (1 sada / 2-4 sady / 5+ sad).
English is the default and the fallback for a missing key.
- lib/i18n.ts: browser store (freezmo.locale.v1), useI18n() hook, and the
x-freezmo-locale header. The hook's t/plural are bound to the locale React
is rendering with, so the first client render matches the server HTML and
then re-renders in the stored language; module-level t is only for event
handlers and non-React helpers.
- <html lang> follows the choice (inline script + setLocale).
UI
- Settings -> Language segmented control (English / Cestina), plus an EN/CS
button on the key gate for first run.
- Every screen translated: sidebar, header, tab bar, library, deck cards,
folder bar, create flow, all seven activities, study chrome, results,
settings + health panel, API-key gate and help, install sheet, offline and
the deck-not-found page.
- Plural-aware counts everywhere (decks, folders, activities, runs, photos,
documents, pages, words, wrong attempts).
Server
- API routes and guards (generate, grade, health, http-guard, rate-limit,
ollama error mapping) answer in the request's language, so error cards and
the health panel never fall back to English mid-sentence.
Fixes
- A hydration mismatch when a non-default locale was stored silently dropped
the .dark class after hydration; render-time t() is now bound to the
hook's locale (UploadScreen.describeExtraction, ResultsScreen.answerFor,
DeckCard's folder fallback).
- Bumped the service worker cache to freezmo-v4 for this deploy.
Tests: i18n 27/27 (gate toggle, persistence, html lang, Czech plurals,
settings switch) and a rebuilt regression suite 72/72 (English default,
library, create, all 7 activity types, results, settings, install sheet,
offline, PWA, security, Czech smoke).
After a reboot the app was simply gone because it only ever ran as a
foreground `npm start`. deploy/freezmo.service runs the production build
under systemd (user), restarts on crash and starts at boot:
- ExecStart runs next start directly (no npm wrapper), 0.0.0.0:3207.
- AssertPathExists=.next/BUILD_ID refuses to start without a build.
- After/Wants network-online, and After=ollama.service for local mode.
- README: install steps, useful commands, and the stop-build-start
sequence for manual rebuilds (build and running server share .next/).
Installed and verified locally: enabled in default.target.wants with
linger already on, active after a stop/start cycle, 200 on
127.0.0.1:3207 and the LAN IP, and it self-heals after a kill -9.
Rebuilds the look around the spec: an ice scene, one shared glass
recipe, icy accents and glass on every large surface. Visual only —
layout, behaviour, copy and functionality are untouched.
Background
- .ice-scene: fixed, z-index -1, pointer-events none. Navy base
(#070b16 to #0c1426) in dark, pale (#f4f9ff to #e6f1fb) in light,
four drifting glacier blobs (#38bdf8/#60a5fa/#818cf8, 30-60s
transform-only loops), five glossy ice orbs (40-220px radial
gradients with a highlight at 30% 25% and a blue shadow) placed so the
glass blurs them, and an SVG feTurbulence grain at 4-5%.
- prefers-reduced-motion stops the drift; phones get two blobs and two
orbs fewer.
Glass
- One shared recipe (.glass-panel, .card, .sidebar-surface,
.study-chrome, .tabbar, .glass, .gate-card): rgba(255,255,255,.06)
dark / .55 light, blur(24px) saturate(140%) (16px on phones), a bright
1px rim, an inset top highlight and a blue-tinted outer shadow.
- Nested glass (chips, inputs, secondary buttons) is more opaque and
never adds a second blur layer. @supports fallback for no
backdrop-filter. Radii: 24-28px cards/sheets, 14px inputs/buttons,
pills for chips. Cards hover brighter + cyan rim + 2px lift, 180ms.
Colour and type
- Accent is glacier cyan (#38bdf8) to ice blue (#0ea5e9); progress bars
and selected states use the gradient; the active sidebar item is a
frosted highlight with a cyan rim.
- Primary CTA: icy gradient — white to #bfe3ff with ink text in dark,
deep blue in light (biased so the label sits on the darker half and
keeps 5.6:1).
- Leftover slate/black greys replaced with navy-tinted frost tokens.
- Headings are semibold with tighter tracking.
Screens
- Dashboard: the sidebar is one tall inset rounded glass panel; deck
cards are glass.
- Gate: one centred glass card over the scene, orbs visible behind it.
- Sheets/modals: glass over the dimmed, lightly blurred backdrop.
- Study view: question card more opaque (.glass-solid); top bar and
pager are slim glass pills.
Perf: measured on a phone viewport with 4x CPU throttling. The animated
blobs' filter: blur() was the whole cost (150ms vs 33ms per frame), so
they are soft radial gradients with no filter now; idle 60fps, ~17ms
median while scrolling.
Tests updated for the new tokens: contrast 18/18 AA, deck chrome 15/15,
key gate 49/49, install sheet 64/64, deck fixes 15/15, retry 14/14,
zoom 6/6, final ux 12/12, security 8/8, pwa 16/16.
The colourful blob wash (92fe685) and the flat "straight ice" pass
(5d05592) both missed. Reverting both to get back to the icon-driven
navy palette before rebuilding the look as a proper frosted ice glass
design.
The colourful multi-blob wash was too much and still didn't read as
frosted glass. Replaced with a cleaner material approach:
- Backdrop is now flat with a single faint top highlight (the only
gradient left in it); the animated blobs and the four colour radials
are gone.
- Panels are crisper and more translucent: cards 62%, panels 66%,
sidebar 64% (now flat, no vertical gradient), study chrome 68%, tab
bar 70% — with a bright 1px edge (--glass-edge), a light-from-above
sheen (--glass-sheen) and the inset top highlight.
- Light mode is lighter: page #eef3fa pale ice, sidebar and cards
frosted white; dark mode is a flatter, slightly lighter navy
(#121a2c page, #1a2337 surfaces).
- Key-gate glows toned right down to match.
Contrast re-audited against the effective frosted backgrounds: 21/21 AA.
Suites: deck chrome 15/15, deck fixes 15/15, final ux 12/12, key gate
49/49, install sheet 64/64, retry 14/14, zoom 6/6, security 8/8, pwa
16/16.
The wash was far too faint for backdrop-blur to register, so the glass
read as flat navy. Now:
- The backdrop is a real colour field: four saturated blue/periwinkle/
cyan radial blobs over the navy base, plus two slow animated blobs at
higher opacity and less pre-blur, placed behind the content column.
- Surfaces are properly translucent instead of near-opaque: cards 62%,
glass panels 68%, sidebar 68%, list groups 70%, study chrome 72%,
tab bar 74%, mobile header 62%.
- Glass gets a specular top edge (inset 0 1px 0 --glass-top) so the
surface catches light like real glass.
- Light --text-subtle darkened to #55647f so text still clears AA on the
tinted glass (4.84:1 on the most saturated sidebar area).
Audit updated to the tinted backgrounds: contrast 21/21. Suites: deck
chrome 15/15, deck fixes 15/15, final ux 12/12, key gate 49/49, install
sheet 64/64, retry 14/14, zoom 6/6, security 8/8, pwa 16/16.
The icon's artwork is now the source of truth for the palette:
- frost-* is a navy-tinted neutral scale and accent-* is the icon's
snowflake blue; light page #f4f7fd / sidebar #e9eff9 / border
#dce4f1, dark page #0e1424 / sidebar #0a0f1c / surfaces #171f33.
- Surfaces are frosted glass: .card, .glass-panel, .list-group and
.sidebar-surface are translucent with backdrop-blur/saturate, and the
page wrapper no longer paints an opaque --page, so the fixed navy/blue
backdrop reads through the sidebar, cards, chrome and sheets.
- The primary CTA is the icon's blue (#1d61ab in light, #59a8f7 in dark
with ink text).
- The gate stage, fields and glows are navy/blue; viewport theme-color
and the manifest background/theme colour are #0e1424.
- The old monochrome snowflake tile is gone from the brand: sidebar,
header, key gate and empty state now show the real app icon (the
install sheet already did). Decorative snowflakes stay.
- --surface-strong is finally defined (it was referenced but never
declared, so those inputs had no fill).
- Icon URLs carry ?v=2 so browser favicon caches refetch.
Updated the contrast audit (21 pairs) and the deck-chrome token
assertions to the new theme. Verified: contrast 21/21, deck chrome
15/15, key gate 49/49, install sheet 64/64, final ux 12/12, retry
14/14, zoom 6/6, security 8/8, pwa 16/16, deck fixes 15/15.
Source artwork committed as assets/app-icon-source.jpeg (1024x1024 navy
rounded square) and scripts/make-icons.mjs rewritten to render every icon
from it with headless Chromium:
- The master is masked at full resolution first (a rounded rect 4px inside
the artwork's own radius), so the JPEG's anti-aliased white edge never
survives and small sizes downscale with clean alpha.
- PWA icons (192/512) and favicon-32 keep the rounded square with
transparent corners; apple-touch 180/167/152 are full-bleed (iOS paints
transparency black) with the artwork zoomed 20% to crop its corners;
maskable-512 keeps the artwork inside the 80% safe circle on a matching
navy field.
- iOS splash screens regenerated with the new mark.
- app/favicon.ico regenerated (PNG payload in an ICO container); the old
app/icon.svg removed so the browser tab can't fall back to it.
- Install sheet's app card now shows the real icon, so the home-screen
icon is recognisable.
- Service worker CACHE_VERSION bumped to freezmo-v3 so installed clients
pick up the new assets.
- README updated (source artwork + script description).
Verified: pwa 16/16, install sheet 64/64, final ux 12/12, key gate 49/49,
zoom 6/6; every icon route serves 200 and the manifest/head reference the
new files.
Layout (desktop / lg+ only):
- 40px top padding above the page title.
- Deck grid uses minmax(320px, 1fr), so two cards fill the content width
and share a right edge with the header (same max-width container).
- Group headings only render once real folders exist; with none, a single
quiet "All decks" label replaces "Unorganized".
- Import JSON becomes a plain text button (mobile keeps the pill).
Deck cards (desktop):
- The whole card is a link; Study is a compact secondary button instead
of a full-width solid pill.
- The ... control is a 32px ghost button with a real ellipsis icon,
aligned to the title's centre.
- Meta line always shows the same fields (activities · runs / Not played
yet); the score is a separate quiet "Best 53%" stat.
- 12px rhythm between title/meta/tags/summary, 16px before the actions.
- Hover: lighter surface, stronger border, soft shadow and a 2px lift in
light mode (pointer devices only, so touch has no sticky hover).
Sidebar (desktop rail):
- All decks + Unorganized are views above the FOLDERS label; only
user-created folders sit under it.
- Count only on All decks. The mobile drawer is unchanged.
Theme:
- Dark: sidebar is a step darker (#181818 to #141414 vs #212121 page).
- Light: cards get a slightly stronger border (#e2e2e2) and a soft
shadow; the sidebar divider and edge shadow are stronger too.
- Medium difficulty darkened to #7a5300 (6.2:1 on the light chip).
Mobile is untouched: layout changes are behind lg: (or the desktop prop);
theme tweaks are shared tokens.
Verified: key-gate 49/49, install sheet 64/64, retry 14/14, zoom 6/6,
deck chrome 15/15, deck fixes 15/15, final ux 12/12, contrast 20/20,
security 8/8, pwa 16/16.
On phones the gate card (447px) is taller than the keyboard-reduced
viewport, so the browser panned the page to reveal the field and the user
could keep scrolling/dragging while typing the API key.
- Pin the gate to the visual viewport (height + offsetTop tracked from
visualViewport resize/scroll), so the browser never needs to pan the
page to reveal the focused field.
- When the keyboard is detected (visual viewport >120px shorter than
innerHeight), lock the card: overflow hidden + touch-action none, and
keep the field and Continue button in view by scrolling the wrapper
programmatically. Re-aligns on resize and when the card grows (error
message / busy state).
- Corner controls are now absolute within the pinned gate so they stay in
the visual viewport.
- Don't auto-focus the field on touch devices (no forced keyboard on
load); desktop still focuses.
Verified by shrinking the viewport to 330px: card locked (wheel/swipe
does nothing), field 182-230 and Continue 242-290 both visible, doc
scrollHeight == viewport; error state keeps Continue at 270-318.
key-gate 49/49, install sheet 64/64, final-ux 12/12, zoom 6/6.
- API-key gate is now a fixed, out-of-flow overlay (inset-0, overflow
hidden, overscroll-none) with its own internal scroller, so the document
has no scrollable height on phones. It can't be panned or bounced.
- Home/create mains are flex columns with min-h-dvh and a flex-1 content
area, and the empty state's mobile padding was trimmed, so a short/empty
library is exactly viewport height instead of overflowing by ~76px.
Long pages (the create form, decks) still scroll normally.
- html/body get overscroll-behavior: none to stop pull-to-refresh and
rubber-banding on fitting screens.
Verified with a scroll probe (gate and empty library: scrollHeight ==
viewport; create still scrolls) plus zoom 6/6, install sheet 64/64,
pwa 16/16, final-ux 12/12, retry 14/14, security 8/8, contrast 20/20.
- Viewport: maximum-scale=1 and user-scalable=no (app-like, no pinch or
double-tap zoom on phones).
- Mobile-only rule keeps every input/textarea/select at 16px so iOS
Safari's focus auto-zoom never triggers (it ignores user-scalable=no for
that). Deliberately unlayered so it beats the per-component Tailwind
sizes; desktop keeps the designed 12-14px fields.
New zoom suite (6 checks): viewport meta, gate input, settings key input,
notes textarea and every study-feed field at >= 16px on a phone, desktop
sizes unchanged. Install sheet 64/64, pwa 16/16, final-ux 12/12,
security 8/8, contrast 20/20.
Ordering cards
- Generation prompt now forbids dates, years, times, step numbers and
ordinals in ordering items (spec + hard rules), and the server-side
validation drops any ordering card whose items still contain them
instead of shipping a giveaway.
True/False
- The 'false ✕' swipe label was permanently visible on the card: its
opacity curve was inverted, so the clamp at rest was 1. Flipped the
range (and tightened both ranges for the elastic drag) — each label now
only fades in while swiping in that direction.
Regenerate deck
- Results screen gains a Regenerate deck button: rebuilds the activities
from the deck's stored source notes, replaces the deck in storage
(attempts/best score reset) and starts the fresh run from slide 1.
- StudyFeed tracks the current deck so the header, export and messages use
the regenerated title/activities.
- Fixed a spurious 0% attempt recorded when a regenerated deck swapped in:
scroll anchoring held the old results position, so the feed briefly
reported 5/5 with zero answers. The scroll now resets before the new
slides render, overflow-anchor is disabled on the feed, and a run with
no answers is never recorded.
Tests: new deck-fixes suite 15/15 (TF overlay at rest/drag, regenerate
with loading state + storage reset, ordering guard drop/keep); retry
14/14, deck chrome 15/15, pwa 16/16, final-ux 12/12, security 8/8,
contrast 20/20.
The retry reused the same React keys for the same activity ids, so every
activity component kept its internal state (selected option, feedback,
flip, matched pairs) from the first run — the retried items appeared
pre-answered with the old wrong answer and could not be answered again.
- StudyFeed now keeps a runId that increments on every restart/retry and
includes it in the slide keys, so each run gets fresh component state.
- Scroll snaps back to the first slide instantly on a new run (the old
requestAnimationFrame + smooth goTo could race the queue change).
New retry suite (14 checks): answer wrong → results → retry → items are
unanswered and enabled → answer correctly → 2/2 on the retry run, plus the
full 'study again' reset. Deck-chrome 15/15, pwa 16/16, final-ux 12/12,
security 8/8, contrast 20/20.
- The study feed's progress header and bottom pager no longer use the old
navy glass: new .study-chrome class uses the neutral surface token
(same gray as the cards, #2f2f2f in dark), the neutral border
(8% white in dark, #e5e5e5 in light) and a neutral shadow.
- Close tile, streak pill and XP pill now use --surface-sunken /
--text-muted, matching the app's other buttons and chips; the close
glyph is the shared XIcon.
- Bottom pager: counter in --text, arrows in --text-muted.
- .glass (mobile header) no longer has the hardcoded #101720 navy; it is
now color-mix of the surface token. Removed the unused --color-night-*
tokens and the last night- usage (create screen sticky bar).
- Hidden the 'Swipe up' hint on desktop, where it collided with the
bottom pager.
New deck-chrome suite: 15/15 (computed surfaces/borders/text in both
themes, no navy); pwa 16/16, final-ux 12/12, security 8/8, contrast 20/20.
Steps
- iOS now uses the exact flow: Safari users get 4 steps (⋯ → Share/More →
Add to Home Screen → Add); non-Safari (CriOS/FxiOS/EdgiOS/in-app) get a
first 'Open this page in Safari' step and 5 total. The vague 'in some
browsers it's under…' wording is gone.
Chips and icons
- The ⋯ chip renders a single three-dot icon (icon-only pill) instead of
the icon plus an '⋯' character (which showed two sets of dots).
- The More chip uses a chevron-down icon instead of the rounded-square.
- The accent pill uses real iOS blue: #0A84FF dark / #007AFF light, still
the only coloured element.
- Steps and chips share one 16px type scale; chip labels are medium weight.
Typography and layout
- Numbers are small muted circles in a fixed 24px column, aligned with the
first text line via matching line-height.
- Removed trailing periods after chips; steps tightened.
- Bottom padding is calc(24px + env(safe-area-inset-bottom)) and a
secondary 'Maybe later' button sits below the steps.
- max-height 90dvh with inner scrolling; 36×4px low-contrast handle with
real swipe-down-to-close (momentum off, spring back).
- App card shows the hostname only (no port), truncated.
Themes
- Dark (default): layered surfaces #1c1c1e sheet / #2c2c2e card / #3a3a3c
chips with 8% white borders; subtle logo tile instead of solid white;
backdrop is 50% black with 4px blur.
- Light only when the app theme was explicitly set to light, ignoring the
system preference; borders #e0e0e0, chip fills #f2f2f2.
Also fixes a crash the test pressure exposed: a 429 (rate-limited)
/api/health response crashed Settings at health.vision.available.
fetchHealth now rejects non-OK/malformed responses so the dialog shows its
error banner instead of the app error screen.
Install-sheet suite now 64 checks; pwa/final-ux/security/contrast green.
- New InstallSheet mounted in the root layout: appears ~1s after load on
iOS/Android phones and tablets, over any screen including the key gate,
with a dimmed backdrop. Never on desktop, never in standalone mode, and
at most once per 7 days after a dismissal (localStorage).
- Platform/browser detection (lib/install.ts): iPhone/iPad incl. iPadOS
reporting as Mac with touch, Android, Safari vs Chrome vs other vs
in-app browsers, with step 1 adapted accordingly.
- iOS: numbered steps with chips (Safari, Share, ... , More, Add to Home
Screen) and the accent Add pill. Android: captured beforeinstallprompt
turns into a native Install button; fallback steps use Install app /
Add to Home screen chips and an accent Install pill.
- Close via X, backdrop, Escape or swipe-down on the handle; the site is
untouched afterwards. Anchored to 100dvh so browser toolbars never hide
the last step; pb-safe respects the home indicator.
- Dark sheet by default; light only when the app theme was explicitly set
to light. Settings gains a mobile-only Install app row that reopens it.
- The inline head script captures beforeinstallprompt before hydration and
records appinstalled. Old InstallHint banner removed.
- Tests: new install-sheet suite (45 checks across iPhone Safari/Chrome/
in-app, Android Chrome/Samsung, desktop, prompt path, dismissal, 7-day
expiry, standalone, Settings row, both themes); pwa suite updated.
- Disabled Continue is now the primary style at 45% opacity with
cursor: not-allowed; full opacity while validating and once text is
entered.
- Move "How do I get a key?" below the Continue button (small ? icon,
muted, underline on hover) and tighten input+button spacing.
- Shorten the subtitle to "One step to freeze your first deck." and
balance it.
- Replace the faint gradient with two real blurred blobs (top-right and
bottom-left, 120px blur, pointer-events: none, behind the card).
- Dark: page #0f0f0f, card #1a1a1a, 8% white border.
- Phone: 24px heading, 15px subtitle, wider card padding, 40px corner
buttons with safe-area-aware offsets, robust 100dvh centering.
- Modal: 480px wide with text-pretty steps, stronger tinted free-tier
callout, handle removed from the phone sheet.
- Tests: gate suite now 49 checks incl. disabled opacity, glow pseudo,
card separation and help-link placement; new state screenshots for
invalid/offline/validating in both themes.
- Add a theme toggle to the gate (sun/moon ghost button next to the help
button); it switches instantly and persists to localStorage.
- Strengthen the corner glows so the stage reads atmospheric in both themes
(bigger, slightly stronger radial gradients behind the card).
- Center the logo, heading, subtitle and footnote inside the card.
- Dark mode: replace the solid white logo tile with a subtle raised tile
(thin border, light snowflake); make the card only slightly lighter than
the near-black page with a low-opacity border and soft shadow.
- Disabled Continue keeps the primary fill at ~45% opacity with a crisp
label instead of a faded muddy one; fully solid once text is entered.
- Bigger 36px corner buttons and a visible "How do I get a key?" text link
under the input that opens the same modal.
- Shorten and balance the reassurance line.
- Help modal: step circles aligned to the first text line, emphasised
free-tier callout with a check icon, underlined links with an
external-link icon, muted medium-weight UI labels, 36px close button
aligned to the title.
- Gate test extended to 44 checks (theme toggle, text link, modal polish).
In user-key mode a browser with no stored Ollama key now gets a polished
gate instead of the app shell: one masked input with a show/hide toggle,
a single Continue button, inline validation errors, and a "?" help sheet
with the steps to a free key.
- Validates the draft key through /api/health (real 1-token probe) before
saving; invalid-key and network failures read differently.
- Key still lives only in this browser's localStorage; nothing server-side.
- Gate wraps /, /create and deck study; server-key and local-daemon modes
skip it; removing the key in Settings brings it back.
- Removes the now-redundant dashboard/create API-key banner.
- Monochrome corner glows (dark near-black / light near-white), 24px card,
48px field and button, gentle fade/slide-in, reduced-motion aware.
- Help modal: centred dialog on desktop, bottom sheet on mobile, closes via
X, Escape or backdrop.
- README and browser suites updated (34 gate checks, 15 key-flow checks,
12 shipped-default checks; ui/folders/docs/pwa/security/contrast green).
Next.js 15 app that turns photos, PDFs, DOCX/PPTX and text into a swipeable
feed of AI-generated activities (flashcards, multiple choice, true/false,
fill-in-the-blank, matching, ordering, short answer) using the user's own
Ollama models.
Highlights:
- All AI calls run in server route handlers; the browser never talks to Ollama.
- Bring-your-own-key mode (default): the key lives only in the browser and is
sent per-request; server, local-daemon and shared-key modes also supported.
- Client-side document extraction (pdf.js, mammoth, jszip) with scanned-PDF
page rendering for the vision model; notes stay in their original language.
- ChatGPT-style shell: collapsible sidebar / mobile drawer, neutral token-based
light + dark themes, folder organisation, PWA (manifest, SW, offline page).
- Strict JSON validation with zod + auto-repair, rate limiting, and request
guards (cross-origin, body-size, API-key sanitising).
- setup.sh bootstraps deps, .env.local and a production build.
Gitignore excludes .env*, the generated pdf.js worker and local scratch files.