Design pass (all themes + chat UI), model picker, reduce-motion toggle

- Aero: blue gradients back on the active segment and every primary
  (Review due, the card's Study button, the composer's send button); a
  frosted backing behind the page heading and the All decks label; a
  bottom-weighted rail tint; chat header/composer/welcome panels; calmer
  orbs; stronger card veil + a stronger blur bake.
- Dark/Light: a dedicated --track token makes progress bars read (the
  first attempt shifted --surface-sunken and the pixel scan caught the
  sheet-contrast regression); brighter dark secondary text; darker light
  segmented labels and difficulty tints; stronger light card border and
  shadow.
- Chat UI: friendly model chip ("Gemma 4 31B") with the raw name in a
  tooltip, larger chips, plain centered disclaimer, vertically centered
  welcome panel, distinct chat-plus icon for new chat.
- Chat models: every chat-capable model from the server's /api/tags is
  selectable (favourites first, search field when long, live-list
  validation in the route); default CHAT_MODEL_DEFAULT or gemma4:31b.
- Reduce motion: Settings toggle (default off) that parks the scene,
  collapses CSS animations and drives framer-motion's calm mode.
- Folders hint, "New" badge on fresh decks.

Contrast: 0 findings, axe clean, mobile 126/126, chat 102/102, wizard
95/95, regression 44/44, i18n 13/13, native 33/33.
Version 0.4.6, SW -> freezmo-v71.
This commit is contained in:
Martin Dvořák 2026-10-14 05:06:02 +00:00
commit 6635b5d405
29 changed files with 468 additions and 90 deletions

View file

@ -1411,6 +1411,43 @@ Things I chose while building, with the reasoning:
pre-blurred wallpaper — the scene is clearly visible through the glass, and the pre-blurred wallpaper — the scene is clearly visible through the glass, and the
pixel-contrast scan still passes. pixel-contrast scan still passes.
80. **Chat UI pass: glass chrome, one composer panel, friendlier chips.** The chat header was a flat
blue strip and the composer area floated on the wallpaper; both are glass surfaces now (the
composer is one flat frosted panel behind input + chips + note). The disclaimer is plain
centered text in Aero too (`AiDisclaimer plain`), the welcome block + suggestions sit on a soft
frosted panel and are vertically centered between header and composer, the model chip shows a
friendly name ("Gemma 4 31B") with the raw name in a tooltip, the chips are bigger with a
stronger rim, the send button is the filled primary (blue in every theme, clearly dimmed only
while empty/offline), and the header's "+" is a chat-plus icon so it no longer reads as the
rail's "new deck". (The review also asked for the light/dark toggle to come back to the Aero
header/rail — that stays out: Aero is switched from Settings by the owner's earlier rule.)
79. **Any model the server offers is selectable in chat, default `gemma4:31b`.** The picker used
to be the server's allowlist only. The health check now appends every chat-capable model from
the server's `/api/tags` (favourites first, embeddings filtered, 120 cap), the picker gained a
search field when the list is long, and the chat route accepts a model that is in the
favourites *or* in the live listing (one tags fetch for non-favourites, so nothing selectable
is rejected). The default is `CHAT_MODEL_DEFAULT` (env) or `gemma4:31b`; when a server doesn't
have the default, the client falls back to the first available model.
78. **Reduce motion: a Settings toggle, default off.** It mirrors the OS preference — an
`html.reduce-motion` class (applied pre-paint by the boot script) parks the scene and collapses
CSS animations/transitions, and `MotionPreference` wraps the app in framer-motion's
`MotionConfig reducedMotion="always"`, so every JS animation calms too. Stored in
`freezmo.reduce-motion.v1`; pinned in the mobile suite (126 checks).
77. **The Aero review pass (tabs, primaries, panels, tints).** The active segment keeps the blue
gradient in Aero again (it had been glassed), and so does every `btn-primary`/`grad-ice` — the
"Review due" action, the card's Study button (now the filled primary) and the composer's send
button included. New Aero surfaces: a frosted backing behind the page heading, a chip behind
the "All decks" label, a bottom-weighted tint on the rail (its lower rows sit over grass), and
a dedicated `--track` token so progress bars read in every theme without shifting the shared
surfaces (the first attempt moved `--surface-sunken` and broke sheet contrast — the pixel scan
caught it). Difficulty labels got darker, the inactive segmented labels use the full text
token, the card border/shadow are a touch stronger in light, orbs are calmer in light and
dark, the folders list nudges "create a folder", and a fresh deck shows a "New" badge instead
of the gray "Not played yet".
76. **A dropped connection says so.** A user's run failed with the wizard's generic "check your 76. **A dropped connection says so.** A user's run failed with the wizard's generic "check your
Ollama settings" card, and it could not be replicated on the owner's machine with the same key Ollama settings" card, and it could not be replicated on the owner's machine with the same key
— because the key and model were never the problem. A raw error (a mid-run socket drop, a — because the key and model were never the problem. A raw error (a mid-run socket drop, a

View file

@ -1,7 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth, resolveModelName } from "@/lib/config"; import { authErrorMessage, authErrorKind, canonicalModelName, getServerConfig, resolveAuth, resolveModelName } from "@/lib/config";
import { friendlyErrorMessage, ollamaChatStream, OllamaError } from "@/lib/ollama"; import { friendlyErrorMessage, ollamaChatStream, ollamaListModels, OllamaError } from "@/lib/ollama";
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
import { acquireModelGate } from "@/lib/model-guard"; import { acquireModelGate } from "@/lib/model-guard";
@ -98,14 +98,28 @@ export async function POST(request: Request) {
const { messages, context } = parsed.data; const { messages, context } = parsed.data;
// The model must come from the server's allowlist — never from the client. // The model must be one the server actually offers — the configured
// favourites, or anything the user's Ollama lists. The picker shows exactly
// that list, so nothing selectable is ever rejected; an unknown model still
// gets the 400.
const allowed = config.chatModels.length > 0 ? config.chatModels : [config.textModel]; const allowed = config.chatModels.length > 0 ? config.chatModels : [config.textModel];
const model = parsed.data.model ?? allowed[0]; const model = parsed.data.model ?? allowed[0];
if (!allowed.some((entry) => entry.toLowerCase() === model.toLowerCase())) { const wanted = canonicalModelName(model);
return Response.json( const inFavourites = allowed.some((entry) => canonicalModelName(entry) === wanted);
{ error: translate(locale, "server.chatModelNotAllowed"), kind: "bad_model" }, if (!inFavourites) {
{ status: 400 }, let listed = false;
); try {
const models = await ollamaListModels(auth.auth, config.healthTimeoutMs);
listed = models.some((entry) => canonicalModelName(entry.name) === wanted);
} catch {
listed = false;
}
if (!listed) {
return Response.json(
{ error: translate(locale, "server.chatModelNotAllowed"), kind: "bad_model" },
{ status: 400 },
);
}
} }
const last = messages[messages.length - 1]; const last = messages[messages.length - 1];

View file

@ -143,6 +143,21 @@ export async function GET(request: Request) {
entry.available = names.has(canonicalModelName(entry.name)); entry.available = names.has(canonicalModelName(entry.name));
} }
// Everything the server lists is selectable in chat (the picker shows
// exactly this array), so append the rest of the tags after the
// favourites — trying a free Ollama model needs no .env change.
const known = new Set(health.chatModels.map((entry) => canonicalModelName(entry.name)));
const extras: ChatModelHealth[] = [];
for (const model of models) {
const canonical = canonicalModelName(model.name);
if (known.has(canonical)) continue;
if (/embed|bert|whisper|clip|rerank/i.test(model.name)) continue;
known.add(canonical);
extras.push({ name: model.name, badge: badgeFor(model.name), available: true });
}
extras.sort((a, b) => a.name.localeCompare(b.name));
health.chatModels = [...health.chatModels, ...extras.slice(0, 120)];
health.ok = Boolean(health.vision.available && health.text.available && health.keyAccepted !== false); health.ok = Boolean(health.vision.available && health.text.available && health.keyAccepted !== false);
if (!health.ok && !health.error) { if (!health.ok && !health.error) {
health.error = translate(locale, "server.healthModelsMissing"); health.error = translate(locale, "server.healthModelsMissing");

View file

@ -88,6 +88,7 @@
--surface-strong: rgb(255 255 255 / 0.72); /* nested glass: inputs, chips */ --surface-strong: rgb(255 255 255 / 0.72); /* nested glass: inputs, chips */
--card-hover-bg: rgb(255 255 255 / 0.72); --card-hover-bg: rgb(255 255 255 / 0.72);
--surface-sunken: rgb(12 32 62 / 0.06); --surface-sunken: rgb(12 32 62 / 0.06);
--track: rgb(12 32 62 / 0.14);
--hairline: #d5e4f4; --hairline: #d5e4f4;
--card-border: var(--glass-border); --card-border: var(--glass-border);
--sidebar-divider: rgb(255 255 255 / 0.75); --sidebar-divider: rgb(255 255 255 / 0.75);
@ -103,7 +104,7 @@
--glass-bg: rgb(255 255 255 / 0.55); --glass-bg: rgb(255 255 255 / 0.55);
/* A cool rim instead of near-white: white-on-white made light-mode card /* A cool rim instead of near-white: white-on-white made light-mode card
edges invisible against the pale page. */ edges invisible against the pale page. */
--glass-border: rgb(143 174 212 / 0.55); --glass-border: rgb(143 174 212 / 0.68);
--glass-highlight: rgb(255 255 255 / 0.85); --glass-highlight: rgb(255 255 255 / 0.85);
--glass-shadow: 0 20px 50px -20px rgb(30 64 175 / 0.35); --glass-shadow: 0 20px 50px -20px rgb(30 64 175 / 0.35);
--glass-solid-bg: rgb(255 255 255 / 0.8); /* question card: more opaque */ --glass-solid-bg: rgb(255 255 255 / 0.8); /* question card: more opaque */
@ -126,7 +127,7 @@
--install-chip: rgb(255 255 255 / 0.55); --install-chip: rgb(255 255 255 / 0.55);
--install-border: rgb(255 255 255 / 0.8); --install-border: rgb(255 255 255 / 0.8);
--shadow-card: 0 1px 2px rgb(14 21 40 / 0.06), 0 1px 3px rgb(14 21 40 / 0.04); --shadow-card: 0 1px 2px rgb(14 21 40 / 0.06), 0 1px 3px rgb(14 21 40 / 0.04);
--card-shadow: 0 1px 2px rgb(14 21 40 / 0.06), 0 9px 22px -8px rgb(14 21 40 / 0.16); --card-shadow: 0 1px 2px rgb(14 21 40 / 0.08), 0 12px 26px -10px rgb(14 21 40 / 0.22);
--shadow-card-hover: 0 24px 60px -24px rgb(30 64 175 / 0.45), 0 2px 6px rgb(30 64 175 / 0.12); --shadow-card-hover: 0 24px 60px -24px rgb(30 64 175 / 0.45), 0 2px 6px rgb(30 64 175 / 0.12);
} }
@ -139,14 +140,15 @@
--surface-strong: rgb(255 255 255 / 0.08); --surface-strong: rgb(255 255 255 / 0.08);
--card-hover-bg: rgb(255 255 255 / 0.1); --card-hover-bg: rgb(255 255 255 / 0.1);
--surface-sunken: rgb(255 255 255 / 0.07); --surface-sunken: rgb(255 255 255 / 0.07);
--track: rgb(255 255 255 / 0.18);
--hairline: rgb(255 255 255 / 0.12); --hairline: rgb(255 255 255 / 0.12);
--card-border: var(--glass-border); --card-border: var(--glass-border);
--sidebar-divider: rgb(255 255 255 / 0.12); --sidebar-divider: rgb(255 255 255 / 0.12);
--hairline-soft: rgb(255 255 255 / 0.08); --hairline-soft: rgb(255 255 255 / 0.08);
--glass-tint: rgb(255 255 255 / 0.06); --glass-tint: rgb(255 255 255 / 0.06);
--text: #e8f0ff; /* 16.3:1 on #0a1120 */ --text: #e8f0ff; /* 16.3:1 on #0a1120 */
--text-muted: #b3c4e0; /* 10.1:1 */ --text-muted: #c2d1e8; /* ~12:1 */
--text-subtle: #9db1d0; /* 8.6:1 on page, 7.2:1 on dark glass */ --text-subtle: #adbfd9; /* ~10:1 on page, 8.4:1 on dark glass */
--hover: rgb(255 255 255 / 0.07); --hover: rgb(255 255 255 / 0.07);
--focus: #38bdf8; --focus: #38bdf8;
@ -185,14 +187,14 @@
--ice-base-top: #f4f9ff; --ice-base-top: #f4f9ff;
--ice-base-bottom: #e6f1fb; --ice-base-bottom: #e6f1fb;
--ice-blob-opacity: 0.045; --ice-blob-opacity: 0.045;
--ice-orb-opacity: 0.34; --ice-orb-opacity: 0.22;
--ice-grain-opacity: 0.05; --ice-grain-opacity: 0.05;
} }
.dark { .dark {
--ice-base-top: #070b16; --ice-base-top: #070b16;
--ice-base-bottom: #0c1426; --ice-base-bottom: #0c1426;
--ice-blob-opacity: 0.1; --ice-blob-opacity: 0.1;
--ice-orb-opacity: 0.5; --ice-orb-opacity: 0.38;
--ice-grain-opacity: 0.045; --ice-grain-opacity: 0.045;
} }
@ -414,6 +416,7 @@
--glass-sheet-bg: rgb(232 248 255 / 0.64); --glass-sheet-bg: rgb(232 248 255 / 0.64);
--surface-strong: rgb(255 255 255 / 0.23); --surface-strong: rgb(255 255 255 / 0.23);
--surface-sunken: rgb(255 255 255 / 0.17); --surface-sunken: rgb(255 255 255 / 0.17);
--track: rgb(255 255 255 / 0.38);
--nav-active-bg: rgb(255 255 255 / 0.27); --nav-active-bg: rgb(255 255 255 / 0.27);
} }
@ -433,15 +436,13 @@
.aero .list-group, .aero .list-group,
.aero .nav-action, .aero .nav-action,
.aero .nav-row[aria-current="page"], .aero .nav-row[aria-current="page"],
.aero .nav-row[aria-pressed="true"],
.aero .nav-row[data-active="true"], .aero .nav-row[data-active="true"],
.aero .glass-chip, .aero .glass-chip,
.aero .glass-pill, .aero .glass-pill,
.aero .btn-secondary, .aero .btn-secondary,
.aero .btn-primary, .aero .chat-header,
.aero .grad-ice,
.aero .segmented, .aero .segmented,
.aero .segmented > button[aria-pressed="true"],
.aero .segmented > a[aria-current="page"],
/* Hand-rolled frost fills in the study, exam, chat and homework flows: /* Hand-rolled frost fills in the study, exam, chat and homework flows:
white/xx utilities and the surface tokens — without this they render as white/xx utilities and the surface tokens — without this they render as
flat white chips next to the glass. */ flat white chips next to the glass. */
@ -454,16 +455,17 @@
.aero [class*="bg-white/70"], .aero [class*="bg-white/70"],
.aero [class*="bg-[var(--surface)]"], .aero [class*="bg-[var(--surface)]"],
.aero [class*="bg-[var(--surface-strong)]"], .aero [class*="bg-[var(--surface-strong)]"],
.aero [class*="bg-[var(--surface-sunken)]"],
.aero [class*="bg-frost-50"] { .aero [class*="bg-frost-50"] {
background-image: background-image:
linear-gradient( linear-gradient(
180deg, 180deg,
rgb(255 255 255 / 0.13) 0%, rgb(255 255 255 / 0.15) 0%,
rgb(255 255 255 / 0.04) 46%, rgb(255 255 255 / 0.06) 46%,
rgb(255 255 255 / 0.03) 53%, rgb(255 255 255 / 0.05) 53%,
rgb(255 255 255 / 0.07) 100% rgb(255 255 255 / 0.09) 100%
), ),
linear-gradient(rgb(255 255 255 / 0.12), rgb(255 255 255 / 0.12)), linear-gradient(rgb(255 255 255 / 0.16), rgb(255 255 255 / 0.16)),
url("/aero/background-blur.webp?v=3"); url("/aero/background-blur.webp?v=3");
background-size: 100% 100%, 100% 100%, cover; background-size: 100% 100%, 100% 100%, cover;
background-position: 0 0, 0 0, center 42%; background-position: 0 0, 0 0, center 42%;
@ -613,21 +615,16 @@
.aero .list-group, .aero .list-group,
.aero .nav-action, .aero .nav-action,
.aero .nav-row[aria-current="page"], .aero .nav-row[aria-current="page"],
.aero .nav-row[aria-pressed="true"],
.aero .nav-row[data-active="true"], .aero .nav-row[data-active="true"],
.aero .glass-chip, .aero .glass-chip,
.aero .glass-pill, .aero .glass-pill,
.aero .btn-secondary, .aero .btn-secondary,
.aero .btn-primary, .aero .chat-header,
.aero .grad-ice,
.aero .segmented { .aero .segmented {
background-image: none; background-image: none;
background-color: rgb(255 255 255 / 0.32); background-color: rgb(255 255 255 / 0.32);
} }
.aero .segmented > button[aria-pressed="true"],
.aero .segmented > a[aria-current="page"] {
background-image: none;
background-color: rgb(255 255 255 / 0.45);
}
.aero .chat-bubble, .aero .chat-bubble,
.aero .chat-answer { .aero .chat-answer {
background-image: none; background-image: none;
@ -646,11 +643,65 @@
.aero [class*="bg-white/70"], .aero [class*="bg-white/70"],
.aero [class*="bg-[var(--surface)]"], .aero [class*="bg-[var(--surface)]"],
.aero [class*="bg-[var(--surface-strong)]"], .aero [class*="bg-[var(--surface-strong)]"],
.aero [class*="bg-[var(--surface-sunken)]"],
.aero [class*="bg-frost-50"] { .aero [class*="bg-frost-50"] {
background-image: none; background-image: none;
} }
} }
/* Chat header: the same glass as the cards, a touch more fill — its title and
scope line sit directly over the sky. */
.aero .chat-header {
background-color: rgb(232 248 255 / 0.4);
border-bottom: 1px solid rgb(255 255 255 / 0.6);
}
/* The chat composer: one flat frosted panel for the input, the chips and the
note — instead of each of them floating on the wallpaper. */
.aero .chat-composer {
background-color: rgb(232 248 255 / 0.55);
border-top: 1px solid rgb(255 255 255 / 0.65);
box-shadow: 0 -16px 36px -26px rgb(2 62 110 / 0.5);
}
/* The chat welcome (subtitle + suggestions) gets a soft panel, so clouds and
bubbles never sit right behind the text. */
.aero .chat-empty {
border-radius: 1.5rem;
border: 1px solid rgb(255 255 255 / 0.5);
background-color: rgb(255 255 255 / 0.32);
padding: 1.35rem 1rem 1.1rem;
}
/* Page headings that sit straight on the wallpaper. */
.aero .page-heading {
display: inline-block;
padding: 0.45rem 0.85rem;
border-radius: 1.25rem;
border: 1px solid rgb(255 255 255 / 0.45);
background-color: rgb(255 255 255 / 0.32);
}
/* The rail's lower half sits over the grass; weight the glass a little below
the middle so the bottom rows keep the same contrast as the top. */
.aero .sidebar-surface {
background-image:
linear-gradient(180deg, rgb(255 255 255 / 0) 42%, rgb(255 255 255 / 0.22) 100%),
linear-gradient(
180deg,
rgb(255 255 255 / 0.15) 0%,
rgb(255 255 255 / 0.06) 46%,
rgb(255 255 255 / 0.05) 53%,
rgb(255 255 255 / 0.09) 100%
),
linear-gradient(rgb(255 255 255 / 0.16), rgb(255 255 255 / 0.16)),
url("/aero/background-blur.webp?v=3");
background-size: 100% 100%, 100% 100%, 100% 100%, cover;
background-position: 0 0, 0 0, 0 0, center 42%;
background-attachment: scroll, scroll, scroll, fixed;
background-repeat: no-repeat, no-repeat, no-repeat, no-repeat;
}
/* Inside a sheet or dialog the backdrop is not the raw scene — a scrim and a /* Inside a sheet or dialog the backdrop is not the raw scene — a scrim and a
more opaque sheet glass sit in between — so the viewport-aligned frosted more opaque sheet glass sit in between — so the viewport-aligned frosted
layer would not match what is actually behind the element (it showed the layer would not match what is actually behind the element (it showed the
@ -675,6 +726,7 @@
.aero .glass-sheet [class*="bg-white/70"], .aero .glass-sheet [class*="bg-white/70"],
.aero .glass-sheet [class*="bg-[var(--surface)]"], .aero .glass-sheet [class*="bg-[var(--surface)]"],
.aero .glass-sheet [class*="bg-[var(--surface-strong)]"], .aero .glass-sheet [class*="bg-[var(--surface-strong)]"],
.aero .glass-sheet [class*="bg-[var(--surface-sunken)]"],
.aero .glass-sheet [class*="bg-frost-50"] { .aero .glass-sheet [class*="bg-frost-50"] {
background-image: none; background-image: none;
} }
@ -877,11 +929,11 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) {
@media (max-width: 639px) { @media (max-width: 639px) {
:root { :root {
--ice-blob-opacity: 0.025; --ice-blob-opacity: 0.025;
--ice-orb-opacity: 0.2; --ice-orb-opacity: 0.14;
} }
.dark { .dark {
--ice-blob-opacity: 0.05; --ice-blob-opacity: 0.05;
--ice-orb-opacity: 0.26; --ice-orb-opacity: 0.2;
} }
/* Phones: orbs hug the screen edges so they can never sit under the /* Phones: orbs hug the screen edges so they can never sit under the
content column's cards or primary buttons. The lower-right orb still content column's cards or primary buttons. The lower-right orb still
@ -1041,7 +1093,7 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) {
padding: 0.42rem 0.5rem; padding: 0.42rem 0.5rem;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
color: var(--color-frost-700); color: var(--text);
transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease; transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
} }
.dark .segmented > button { .dark .segmented > button {
@ -1070,7 +1122,7 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) {
padding: 0.42rem 0.5rem; padding: 0.42rem 0.5rem;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
color: var(--color-frost-700); color: var(--text);
transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease; transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
} }
.dark .segmented > a { .dark .segmented > a {
@ -1243,9 +1295,9 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) {
.btn-ghost:hover { background: var(--hover); color: var(--text); } .btn-ghost:hover { background: var(--hover); color: var(--text); }
/* Difficulty: neutral pill, colour lives in the text only. */ /* Difficulty: neutral pill, colour lives in the text only. */
.diff-easy { color: #16714f; } .diff-easy { color: #0f5c3f; }
.diff-medium { color: #7a5300; } .diff-medium { color: #694800; }
.diff-hard { color: #b4342f; } .diff-hard { color: #a3261f; }
.dark .diff-easy { color: #6fd3a8; } .dark .diff-easy { color: #6fd3a8; }
.dark .diff-medium { color: #e3b341; } .dark .diff-medium { color: #e3b341; }
.dark .diff-hard { color: #f08a84; } .dark .diff-hard { color: #f08a84; }
@ -1473,3 +1525,23 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) {
scroll-behavior: auto !important; scroll-behavior: auto !important;
} }
} }
/* ---------------------------------------------------------------------------
Reduced motion (Settings toggle, default off).
The class mirrors `prefers-reduced-motion: reduce`: the scene parks and
every CSS animation/transition collapses to nothing. framer-motion gets
told separately (components/MotionPreference).
--------------------------------------------------------------------------- */
html.reduce-motion .ice-blob,
html.reduce-motion .ice-orb,
html.reduce-motion .ice-bubble {
animation: none !important;
}
html.reduce-motion *,
html.reduce-motion *::before,
html.reduce-motion *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}

View file

@ -1,6 +1,7 @@
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import { IceBackdrop } from "@/components/IceBackdrop"; import { IceBackdrop } from "@/components/IceBackdrop";
import { MotionPreference } from "@/components/MotionPreference";
import { CookieConsent } from "@/components/CookieConsent"; import { CookieConsent } from "@/components/CookieConsent";
import { InstallSheet } from "@/components/InstallSheet"; import { InstallSheet } from "@/components/InstallSheet";
import { OfflineBanner } from "@/components/OfflineBanner"; import { OfflineBanner } from "@/components/OfflineBanner";
@ -86,7 +87,7 @@ export const viewport: Viewport = {
* on the bright wallpaper is what made the first load look washed out until * on the bright wallpaper is what made the first load look washed out until
* a theme toggle re-applied the classes. * a theme toggle re-applied the classes.
*/ */
const themeScript = `(function(){try{var DEV=${devInstanceJs};var s=localStorage.getItem('freezmo.theme');var a=s==='aero';var d=a?false:(s?s==='dark':window.matchMedia('(prefers-color-scheme: dark)').matches);document.documentElement.classList.toggle('dark',!!d);if(a)document.documentElement.classList.add('aero');var l=localStorage.getItem('freezmo.locale.v1');if(l==='cs'||l==='en')document.documentElement.lang=l;var m=document.querySelector('meta[name="theme-color"][data-app]');if(!m){m=document.createElement('meta');m.setAttribute('name','theme-color');m.setAttribute('data-app','1');document.head.insertBefore(m,document.head.firstChild);}m.setAttribute('content',DEV?'#b45309':(a?'#2fa7e6':(d?'#0e1424':'#f4f7fd')));}catch(e){}})();`; const themeScript = `(function(){try{var DEV=${devInstanceJs};var s=localStorage.getItem('freezmo.theme');var a=s==='aero';var d=a?false:(s?s==='dark':window.matchMedia('(prefers-color-scheme: dark)').matches);document.documentElement.classList.toggle('dark',!!d);if(a)document.documentElement.classList.add('aero');var l=localStorage.getItem('freezmo.locale.v1');if(l==='cs'||l==='en')document.documentElement.lang=l;if(localStorage.getItem('freezmo.reduce-motion.v1')==='1')document.documentElement.classList.add('reduce-motion');var m=document.querySelector('meta[name="theme-color"][data-app]');if(!m){m=document.createElement('meta');m.setAttribute('name','theme-color');m.setAttribute('data-app','1');document.head.insertBefore(m,document.head.firstChild);}m.setAttribute('content',DEV?'#b45309':(a?'#2fa7e6':(d?'#0e1424':'#f4f7fd')));}catch(e){}})();`;
/** /**
* Captures Chrome's `beforeinstallprompt` (it can fire before React hydrates) * Captures Chrome's `beforeinstallprompt` (it can fire before React hydrates)
@ -105,13 +106,15 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<script dangerouslySetInnerHTML={{ __html: installScript }} /> <script dangerouslySetInnerHTML={{ __html: installScript }} />
</head> </head>
<body className="min-h-full antialiased app-shell-in"> <body className="min-h-full antialiased app-shell-in">
<IceBackdrop /> <MotionPreference>
{children} <IceBackdrop />
<InstallSheet /> {children}
<OfflineBanner /> <InstallSheet />
<CookieConsent /> <OfflineBanner />
<Toaster /> <CookieConsent />
<ServiceWorkerRegistrar /> <Toaster />
<ServiceWorkerRegistrar />
</MotionPreference>
</body> </body>
</html> </html>
); );

View file

@ -12,11 +12,13 @@ import { cn } from "@/lib/utils";
* --text-subtle is the muted helper token and is documented at 5.6:1 (light) * --text-subtle is the muted helper token and is documented at 5.6:1 (light)
* and 8.6:1 (dark), so the note stays readable at this size. * and 8.6:1 (dark), so the note stays readable at this size.
*/ */
export function AiDisclaimer({ className }: { className?: string }) { export function AiDisclaimer({ className, plain = false }: { className?: string; plain?: boolean }) {
const { t } = useI18n(); const { t } = useI18n();
return ( return (
// hint-line: Aero gives this its own glass chip (it sits on the wallpaper). // hint-line: Aero gives this its own glass chip (it sits on the wallpaper).
<p className={cn("hint-line text-center text-[11.5px] leading-snug text-[var(--text-subtle)]", className)}> // Where the note already sits on a glass panel (the chat composer), `plain`
// keeps it plain centered text like the other themes.
<p className={cn(!plain && "hint-line", "text-center text-[11.5px] leading-snug text-[var(--text-subtle)]", className)}>
{t("common.aiDisclaimer")} {t("common.aiDisclaimer")}
</p> </p>
); );

View file

@ -225,9 +225,17 @@ export function DeckCard({
) : null} ) : null}
</span> </span>
{/* Desktop always shows the same two fields. */} {/* Desktop always shows the same two fields. */}
<span className="hidden lg:inline"> <span className="hidden lg:inline-flex items-center">
<span aria-hidden> · </span> {deck.attempts > 0 ? (
{deck.attempts > 0 ? plural(deck.attempts, "deck.runs") : t("deck.notPlayed")} <>
<span aria-hidden> · </span>
{plural(deck.attempts, "deck.runs")}
</>
) : (
<span className="tint-sky ml-1 rounded-full px-2 py-0.5 text-[10.5px] font-semibold">
{t("deck.new")}
</span>
)}
</span> </span>
</p> </p>
</div> </div>
@ -343,7 +351,7 @@ export function DeckCard({
{t("deck.study")} {t("deck.study")}
</Link> </Link>
<div className="relative z-10 hidden lg:flex lg:justify-end"> <div className="relative z-10 hidden lg:flex lg:justify-end">
<Link href={`/deck/${deck.id}`} className="btn-secondary h-9 px-4 text-[13px]"> <Link href={`/deck/${deck.id}`} className="btn-primary h-9 px-4 text-[13px]">
<PlayIcon className="size-4" /> <PlayIcon className="size-4" />
{t("deck.study")} {t("deck.study")}
</Link> </Link>

View file

@ -192,12 +192,14 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
{/* page header ---------------------------------------------------- */} {/* page header ---------------------------------------------------- */}
<section className="mb-6 space-y-5"> <section className="mb-6 space-y-5">
<div> <div>
<h1 className="text-[24px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)] sm:text-[26px]"> <div className="page-heading">
{t("library.title")} <h1 className="text-[24px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)] sm:text-[26px]">
</h1> {t("library.title")}
<p className="mt-1.5 text-[13.5px] text-[var(--text-subtle)]"> </h1>
{t("library.subtitle")} <p className="mt-1.5 text-[13.5px] text-[var(--text-subtle)]">
</p> {t("library.subtitle")}
</p>
</div>
</div> </div>
{/* Desktop keeps Import above the tabs; phones get a small icon {/* Desktop keeps Import above the tabs; phones get a small icon
@ -486,7 +488,7 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
{/* One quiet label replaces the redundant single-group heading. */} {/* One quiet label replaces the redundant single-group heading. */}
{!hasFolders ? ( {!hasFolders ? (
<p className="mb-4 hidden text-[12px] font-medium text-[var(--text-subtle)] lg:block"> <p className="hint-line mb-4 hidden text-[12px] font-medium text-[var(--text-subtle)] lg:block">
{t("common.allDecks")} {t("common.allDecks")}
</p> </p>
) : null} ) : null}

View file

@ -0,0 +1,16 @@
"use client";
import { MotionConfig } from "framer-motion";
import { useReducedMotionPref } from "@/lib/motion-pref";
/**
* Teaches framer-motion about the in-app "reduce motion" setting: with the
* toggle on, every motion component runs in its calm mode (`always`), not just
* when the OS asks (`user`). Renders nothing itself, so the tree below stays
* exactly as it was.
*/
export function MotionPreference({ children }: { children: React.ReactNode }) {
const { reduced } = useReducedMotionPref();
return <MotionConfig reducedMotion={reduced ? "always" : "user"}>{children}</MotionConfig>;
}

View file

@ -13,6 +13,7 @@ import { ModelErrorActions } from "@/components/ModelErrorActions";
import { DataSection } from "@/components/DataSection"; import { DataSection } from "@/components/DataSection";
import { LearningSection } from "@/components/LearningSection"; import { LearningSection } from "@/components/LearningSection";
import { ThemeSection } from "@/components/ThemeSection"; import { ThemeSection } from "@/components/ThemeSection";
import { useReducedMotionPref } from "@/lib/motion-pref";
import { SyncSection } from "@/components/SyncSection"; import { SyncSection } from "@/components/SyncSection";
import { clearApiKey, saveSettings, settingsStore } from "@/lib/settings"; import { clearApiKey, saveSettings, settingsStore } from "@/lib/settings";
import { useBackClose } from "@/lib/use-back-close"; import { useBackClose } from "@/lib/use-back-close";
@ -54,6 +55,7 @@ export function SettingsDialog({
focusField?: { field: "key"; nonce: number } | null; focusField?: { field: "key"; nonce: number } | null;
}) { }) {
const { health, loading, error, refresh, apiKey, checkedAt } = useHealth(); const { health, loading, error, refresh, apiKey, checkedAt } = useHealth();
const { reduced: reducedMotion, setReduced: setReducedMotion } = useReducedMotionPref();
const installAvailable = useInstallAvailable(); const installAvailable = useInstallAvailable();
const { locale, setLocale, t, plural } = useI18n(); const { locale, setLocale, t, plural } = useI18n();
const version = useVersion(); const version = useVersion();
@ -411,6 +413,19 @@ export function SettingsDialog({
{/* ------------------------------------------- theme */} {/* ------------------------------------------- theme */}
<ThemeSection /> <ThemeSection />
{/* --------------------------------------- motion */}
<section className="mb-4 space-y-2 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
<ToggleRow
label={t("settings.reduceMotion")}
note={t("settings.reduceMotionNote")}
checked={reducedMotion}
onChange={() => {
setReducedMotion(!reducedMotion);
haptic("toggle");
}}
/>
</section>
{/* ----------------------------------------- learning */} {/* ----------------------------------------- learning */}
<LearningSection level={3} /> <LearningSection level={3} />
@ -773,6 +788,19 @@ export function SettingsDialog({
{/* ------------------------------------------- theme */} {/* ------------------------------------------- theme */}
<ThemeSection /> <ThemeSection />
{/* --------------------------------------- motion */}
<section className="mb-4 space-y-2 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
<ToggleRow
label={t("settings.reduceMotion")}
note={t("settings.reduceMotionNote")}
checked={reducedMotion}
onChange={() => {
setReducedMotion(!reducedMotion);
haptic("toggle");
}}
/>
</section>
{/* ----------------------------------------- learning */} {/* ----------------------------------------- learning */}
<LearningSection level={3} /> <LearningSection level={3} />

View file

@ -209,6 +209,12 @@ function NavContent({ collapsed = false }: { collapsed?: boolean }) {
</div> </div>
) : null} ) : null}
{sortedFolders.length === 0 && !collapsed ? (
<p className="px-2.5 pb-1 pt-0.5 text-[11.5px] leading-snug text-[var(--text-subtle)]">
{t("common.foldersHint")}
</p>
) : null}
{sortedFolders.map((folder) => ( {sortedFolders.map((folder) => (
<button <button
key={folder.id} key={folder.id}

View file

@ -689,7 +689,7 @@ export function UploadScreen() {
aria-valuemin={1} aria-valuemin={1}
aria-valuemax={3} aria-valuemax={3}
aria-valuenow={step} aria-valuenow={step}
className="h-1 flex-1 overflow-hidden rounded-full bg-[var(--surface-sunken)]" className="h-1 flex-1 overflow-hidden rounded-full bg-[var(--track)]"
> >
<div <div
className="grad-ice h-full rounded-full transition-[width] duration-300 motion-reduce:transition-none" className="grad-ice h-full rounded-full transition-[width] duration-300 motion-reduce:transition-none"

View file

@ -10,13 +10,14 @@ import { ContextPicker } from "./ContextPicker";
import { HistorySheet } from "./HistoryPanel"; import { HistorySheet } from "./HistoryPanel";
import { MessageRow } from "./MessageRow"; import { MessageRow } from "./MessageRow";
import { ModelPicker } from "./ModelPicker"; import { ModelPicker } from "./ModelPicker";
import { ChatIcon, ChevronDownIcon, ChevronLeftIcon, HistoryIcon, PlusIcon, RefreshIcon } from "@/components/icons"; import { ChatIcon, ChatPlusIcon, ChevronDownIcon, ChevronLeftIcon, HistoryIcon, RefreshIcon } from "@/components/icons";
import { ModelErrorActions } from "@/components/ModelErrorActions"; import { ModelErrorActions } from "@/components/ModelErrorActions";
import { ThemeToggle } from "@/components/ThemeToggle"; import { ThemeToggle } from "@/components/ThemeToggle";
import { Button } from "@/components/ui"; import { Button } from "@/components/ui";
import { ApiError } from "@/lib/client-api"; import { ApiError } from "@/lib/client-api";
import { extractChatAttachment, streamChat } from "@/lib/chat-client"; import { extractChatAttachment, streamChat } from "@/lib/chat-client";
import { buildChatContext, deckHasNotes } from "@/lib/chat-context"; import { buildChatContext, deckHasNotes } from "@/lib/chat-context";
import { cn, prettyModelName } from "@/lib/utils";
import { takeChatSeed, type ChatSeed } from "@/lib/chat-seed"; import { takeChatSeed, type ChatSeed } from "@/lib/chat-seed";
import { chatActive } from "@/lib/chat-active"; import { chatActive } from "@/lib/chat-active";
import { chatHistoryStore, conversationMessages, deleteConversation, ensureChatsHydrated, getConversation, renameConversation, saveConversation } from "@/lib/chat-store"; import { chatHistoryStore, conversationMessages, deleteConversation, ensureChatsHydrated, getConversation, renameConversation, saveConversation } from "@/lib/chat-store";
@ -167,7 +168,10 @@ export function ChatScreen() {
const activeModel = useMemo(() => { const activeModel = useMemo(() => {
if (model && chatModels.some((entry) => entry.name === model)) return model; if (model && chatModels.some((entry) => entry.name === model)) return model;
return chatModels[0]?.name ?? model; // The server puts the default first, but a different server may not have
// it — fall back to the first model that is actually available.
const firstAvailable = chatModels.find((entry) => entry.available !== false);
return (firstAvailable ?? chatModels[0])?.name ?? model;
}, [model, chatModels]); }, [model, chatModels]);
const scopeText = useMemo(() => { const scopeText = useMemo(() => {
@ -524,7 +528,7 @@ export function ChatScreen() {
<div className="flex h-full min-h-0 w-full"> <div className="flex h-full min-h-0 w-full">
{/* Desktop's conversation list lives in the main sidebar (on /chat). */} {/* Desktop's conversation list lives in the main sidebar (on /chat). */}
<div className="relative flex h-full min-h-0 min-w-0 flex-1 flex-col" style={{ paddingBottom: "var(--chat-keyboard, 0px)" }}> <div className="relative flex h-full min-h-0 min-w-0 flex-1 flex-col" style={{ paddingBottom: "var(--chat-keyboard, 0px)" }}>
<header className="glass sticky top-0 z-30 flex h-14 shrink-0 items-center gap-2 px-2 sm:px-4"> <header className="glass chat-header sticky top-0 z-30 flex h-14 shrink-0 items-center gap-2 px-2 sm:px-4">
<button <button
type="button" type="button"
onClick={() => (window.history.length > 1 ? router.back() : router.push("/"))} onClick={() => (window.history.length > 1 ? router.back() : router.push("/"))}
@ -563,16 +567,21 @@ export function ChatScreen() {
title={t("chat.new")} title={t("chat.new")}
className="grid size-11 place-items-center rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]" className="grid size-11 place-items-center rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
> >
<PlusIcon className="size-5" /> <ChatPlusIcon className="size-5" />
</button> </button>
</div> </div>
</header> </header>
<div className="relative min-h-0 flex-1"> <div className="relative min-h-0 flex-1">
<div ref={scrollRef} onScroll={onScroll} className="chat-scroll h-full overflow-y-auto overscroll-contain"> <div ref={scrollRef} onScroll={onScroll} className="chat-scroll h-full overflow-y-auto overscroll-contain">
<div className="mx-auto w-full max-w-3xl space-y-4 px-4 py-4"> <div
className={cn(
"mx-auto w-full max-w-3xl space-y-4 px-4 py-4",
messages.length === 0 && "flex min-h-full flex-col justify-center",
)}
>
{messages.length === 0 ? ( {messages.length === 0 ? (
<div className="space-y-5 py-6"> <div className="chat-empty space-y-5 py-2">
<div className="text-center"> <div className="text-center">
<span className="mx-auto mb-4 grid size-12 place-items-center rounded-2xl border border-[var(--hairline)] bg-[var(--surface-strong)] text-accent-600 dark:text-accent-200"> <span className="mx-auto mb-4 grid size-12 place-items-center rounded-2xl border border-[var(--hairline)] bg-[var(--surface-strong)] text-accent-600 dark:text-accent-200">
<ChatIcon className="size-6" /> <ChatIcon className="size-6" />
@ -671,7 +680,7 @@ export function ChatScreen() {
</div> </div>
) : null} ) : null}
<div className="relative shrink-0"> <div className="chat-composer relative shrink-0">
<Composer <Composer
value={draft} value={draft}
onChange={setDraft} onChange={setDraft}
@ -680,7 +689,8 @@ export function ChatScreen() {
busy={busy} busy={busy}
online={online} online={online}
scopeLabel={scopeText} scopeLabel={scopeText}
modelLabel={activeModel || t("chat.modelTitle")} modelLabel={activeModel ? prettyModelName(activeModel) : t("chat.modelTitle")}
modelTitle={activeModel || undefined}
scopeOpen={scopeOpen} scopeOpen={scopeOpen}
modelOpen={modelOpen} modelOpen={modelOpen}
// The chips toggle their own popover (and open one at a time), so a // The chips toggle their own popover (and open one at a time), so a

View file

@ -26,6 +26,7 @@ export function Composer({
online, online,
scopeLabel, scopeLabel,
modelLabel, modelLabel,
modelTitle,
scopeOpen, scopeOpen,
modelOpen, modelOpen,
onOpenScope, onOpenScope,
@ -43,6 +44,8 @@ export function Composer({
online: boolean; online: boolean;
scopeLabel: string; scopeLabel: string;
modelLabel: string; modelLabel: string;
/** The raw model name, shown as the chip's tooltip. */
modelTitle?: string;
scopeOpen: boolean; scopeOpen: boolean;
modelOpen: boolean; modelOpen: boolean;
onOpenScope: () => void; onOpenScope: () => void;
@ -168,8 +171,8 @@ export function Composer({
onClick={onOpenScope} onClick={onOpenScope}
className={pickerChipClass(scopeOpen)} className={pickerChipClass(scopeOpen)}
> >
<LayersIcon className="size-3.5 shrink-0" /> <LayersIcon className="size-4 shrink-0" />
<span className="max-w-[170px] truncate">{scopeLabel}</span> <span className="max-w-[190px] truncate">{scopeLabel}</span>
</button> </button>
<button <button
type="button" type="button"
@ -177,10 +180,11 @@ export function Composer({
aria-haspopup="dialog" aria-haspopup="dialog"
aria-expanded={modelOpen} aria-expanded={modelOpen}
onClick={onOpenModel} onClick={onOpenModel}
title={modelTitle ?? modelLabel}
className={pickerChipClass(modelOpen)} className={pickerChipClass(modelOpen)}
> >
<ChatIcon className="size-3.5 shrink-0" /> <ChatIcon className="size-4 shrink-0" />
<span className="max-w-[170px] truncate">{modelLabel}</span> <span className="max-w-[190px] truncate">{modelLabel}</span>
</button> </button>
{!online ? ( {!online ? (
<span role="status" className="ml-auto shrink-0 text-[11.5px] text-amber-600 dark:text-amber-300"> <span role="status" className="ml-auto shrink-0 text-[11.5px] text-amber-600 dark:text-amber-300">
@ -189,7 +193,7 @@ export function Composer({
) : null} ) : null}
</div> </div>
<AiDisclaimer className="mt-1.5" /> <AiDisclaimer plain className="mt-1.5" />
</div> </div>
); );
} }
@ -200,9 +204,9 @@ export function Composer({
*/ */
function pickerChipClass(open: boolean): string { function pickerChipClass(open: boolean): string {
return cn( return cn(
"flex min-w-0 items-center gap-1.5 rounded-xl border px-2.5 py-1.5 text-[12px] font-medium transition", "flex min-w-0 items-center gap-1.5 rounded-xl border px-3 py-2 text-[12.5px] font-medium transition",
open open
? "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)] text-[var(--text)]" ? "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)] text-[var(--text)]"
: "border-[var(--hairline-soft)] bg-[var(--surface-sunken)] text-[var(--text-muted)] hover:text-[var(--text)]", : "border-[var(--hairline)] bg-[var(--surface-sunken)] text-[var(--text)] hover:bg-[var(--hover)]",
); );
} }

View file

@ -107,7 +107,7 @@ export function ContextPicker({
"flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left transition", "flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left transition",
scope.kind === value scope.kind === value
? "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)]" ? "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)]"
: "border-[var(--hairline-soft)] bg-[var(--surface-bg)] hover:bg-[var(--hover)]", : "border-[var(--hairline-soft)] bg-[var(--surface-strong)] hover:bg-[var(--hover)]",
)} )}
> >
<span className="shrink-0 text-[var(--text-muted)]">{icon}</span> <span className="shrink-0 text-[var(--text-muted)]">{icon}</span>

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useRef } from "react"; import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { CheckCircleIcon } from "@/components/icons"; import { CheckCircleIcon } from "@/components/icons";
@ -32,6 +32,15 @@ export function ModelPicker({
const isPhone = useIsPhone(); const isPhone = useIsPhone();
const reduceMotion = useReducedMotion(); const reduceMotion = useReducedMotion();
const panel = useRef<HTMLDivElement>(null); const panel = useRef<HTMLDivElement>(null);
const [query, setQuery] = useState("");
/* Every model the server lists is offered, so the list can be long — filter
it. Cleared each time the picker opens. */
useEffect(() => {
if (open) setQuery("");
}, [open]);
const needle = query.trim().toLowerCase();
const visible = needle ? models.filter((model) => model.name.toLowerCase().includes(needle)) : models;
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@ -61,8 +70,23 @@ export function ModelPicker({
model.badge === "vision" ? t("chat.modelVision") : model.badge === "both" ? t("chat.modelBoth") : t("chat.modelText"); model.badge === "vision" ? t("chat.modelVision") : model.badge === "both" ? t("chat.modelBoth") : t("chat.modelText");
const rows = ( const rows = (
<>
{models.length > 8 ? (
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t("chat.modelSearch")}
aria-label={t("chat.modelSearch")}
autoComplete="off"
spellCheck={false}
className="mb-2 w-full rounded-xl border border-[var(--hairline)] bg-[var(--surface-sunken)] px-3 py-2 text-[13px] text-[var(--text)] outline-none placeholder:text-[var(--text-subtle)] focus:border-accent-400"
/>
) : null}
<ul className="max-h-[60dvh] space-y-1 overflow-y-auto overscroll-contain"> <ul className="max-h-[60dvh] space-y-1 overflow-y-auto overscroll-contain">
{models.map((model) => { {visible.length === 0 ? (
<li className="px-3 py-2 text-[12.5px] text-[var(--text-subtle)]">{t("chat.modelSearchEmpty")}</li>
) : null}
{visible.map((model) => {
const selected = model.name === value; const selected = model.name === value;
const unavailable = model.available === false; const unavailable = model.available === false;
return ( return (
@ -79,7 +103,7 @@ export function ModelPicker({
"flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left transition", "flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left transition",
selected selected
? "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)]" ? "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)]"
: "border-[var(--hairline-soft)] bg-[var(--surface-bg)] hover:bg-[var(--hover)]", : "border-[var(--hairline-soft)] bg-[var(--surface-strong)] hover:bg-[var(--hover)]",
unavailable && "opacity-55", unavailable && "opacity-55",
)} )}
> >
@ -100,6 +124,7 @@ export function ModelPicker({
); );
})} })}
</ul> </ul>
</>
); );
if (isPhone) { if (isPhone) {

View file

@ -440,7 +440,7 @@ export function HomeworkInput({
aria-valuemin={1} aria-valuemin={1}
aria-valuemax={2} aria-valuemax={2}
aria-valuenow={step} aria-valuenow={step}
className="h-1 flex-1 overflow-hidden rounded-full bg-[var(--surface-sunken)]" className="h-1 flex-1 overflow-hidden rounded-full bg-[var(--track)]"
> >
<div <div
className="grad-ice h-full rounded-full transition-[width] duration-300 motion-reduce:transition-none" className="grad-ice h-full rounded-full transition-[width] duration-300 motion-reduce:transition-none"

View file

@ -197,6 +197,17 @@ export function PlusIcon({ className }: IconProps) {
); );
} }
/** A chat bubble with a plus: "new chat" — distinct from the plain plus the
* rail uses for "new deck" (two identical + buttons read as the same thing). */
export function ChatPlusIcon({ className }: IconProps) {
return (
<Svg className={className}>
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5Z" />
<path d="M12 8.5v5M9.5 11h5" />
</Svg>
);
}
export function ShareIcon({ className }: IconProps) { export function ShareIcon({ className }: IconProps) {
return ( return (
<Svg className={className}> <Svg className={className}>

View file

@ -113,7 +113,7 @@ export function ProgressBar({
const percent = max <= 0 ? 0 : Math.min(100, Math.max(0, (value / max) * 100)); const percent = max <= 0 ? 0 : Math.min(100, Math.max(0, (value / max) * 100));
return ( return (
<div <div
className={cn("h-1 w-full overflow-hidden rounded-full bg-[var(--surface-sunken)]", className)} className={cn("h-1 w-full overflow-hidden rounded-full bg-[var(--track)]", className)}
role="progressbar" role="progressbar"
aria-valuenow={Math.round(percent)} aria-valuenow={Math.round(percent)}
aria-valuemin={0} aria-valuemin={0}

View file

@ -142,7 +142,7 @@ export async function generateDeck(
body: JSON.stringify(input), body: JSON.stringify(input),
signal: controller.signal, signal: controller.signal,
}); });
} catch (error) { } catch {
if (forwarded?.aborted) throw new ApiError(t("api.cancelled"), "aborted"); if (forwarded?.aborted) throw new ApiError(t("api.cancelled"), "aborted");
if (headersTimedOut) throw new ApiError(t("api.stalled"), "timeout"); if (headersTimedOut) throw new ApiError(t("api.stalled"), "timeout");
throw new ApiError(t("api.unreachable"), "network"); throw new ApiError(t("api.unreachable"), "network");
@ -262,7 +262,7 @@ export async function generateHomework(
body: JSON.stringify(input), body: JSON.stringify(input),
signal: controller.signal, signal: controller.signal,
}); });
} catch (error) { } catch {
if (forwarded?.aborted) throw new ApiError(t("api.cancelled"), "aborted"); if (forwarded?.aborted) throw new ApiError(t("api.cancelled"), "aborted");
if (headersTimedOut) throw new ApiError(t("api.stalled"), "timeout"); if (headersTimedOut) throw new ApiError(t("api.stalled"), "timeout");
throw new ApiError(t("api.unreachable"), "network"); throw new ApiError(t("api.unreachable"), "network");

View file

@ -127,6 +127,11 @@ export function getServerConfig(): ServerConfig {
const visionModel = process.env.VISION_MODEL?.trim() || "gemma4:31b-cloud"; const visionModel = process.env.VISION_MODEL?.trim() || "gemma4:31b-cloud";
const textModel = process.env.TEXT_MODEL?.trim() || "gpt-oss:120b-cloud"; const textModel = process.env.TEXT_MODEL?.trim() || "gpt-oss:120b-cloud";
// The chat picker opens on this one when the server offers it. Every model
// the server lists is selectable anyway (see /api/health), so trying a free
// Ollama model never needs an .env change.
const chatDefault = process.env.CHAT_MODEL_DEFAULT?.trim() || "gemma4:31b";
// Chat model allowlist: CHAT_MODELS wins, otherwise the configured text and // Chat model allowlist: CHAT_MODELS wins, otherwise the configured text and
// vision models. Dedupe on the canonical name so `x` and `x:cloud` collapse. // vision models. Dedupe on the canonical name so `x` and `x:cloud` collapse.
const requestedChatModels = (process.env.CHAT_MODELS ?? "") const requestedChatModels = (process.env.CHAT_MODELS ?? "")
@ -135,7 +140,7 @@ export function getServerConfig(): ServerConfig {
.filter(Boolean); .filter(Boolean);
const chatModels: string[] = []; const chatModels: string[] = [];
const seenModels = new Set<string>(); const seenModels = new Set<string>();
for (const entry of requestedChatModels.length > 0 ? requestedChatModels : [textModel, visionModel]) { for (const entry of [chatDefault, ...(requestedChatModels.length > 0 ? requestedChatModels : [textModel, visionModel])]) {
const canonical = canonicalModelName(entry); const canonical = canonicalModelName(entry);
if (seenModels.has(canonical)) continue; if (seenModels.has(canonical)) continue;
seenModels.add(canonical); seenModels.add(canonical);

View file

@ -76,6 +76,7 @@ const en: Record<string, string> = {
"common.folder": "Folder", "common.folder": "Folder",
"common.folders": "Folders", "common.folders": "Folders",
"common.newFolder": "New folder", "common.newFolder": "New folder",
"common.foldersHint": "Create a folder to organize decks.",
"common.folderName": "Folder name", "common.folderName": "Folder name",
"common.newFolderName": "New folder name", "common.newFolderName": "New folder name",
"common.unorganized": "Unorganized", "common.unorganized": "Unorganized",
@ -386,6 +387,7 @@ const en: Record<string, string> = {
"deck.runs.few": "{n} runs", "deck.runs.few": "{n} runs",
"deck.runs.many": "{n} runs", "deck.runs.many": "{n} runs",
"deck.notPlayed": "Not played yet", "deck.notPlayed": "Not played yet",
"deck.new": "New",
"deck.best": "Best {n}%", "deck.best": "Best {n}%",
"deck.bestLabel": "Best", "deck.bestLabel": "Best",
"deck.study": "Study", "deck.study": "Study",
@ -951,6 +953,8 @@ const en: Record<string, string> = {
"chat.modelText": "Text", "chat.modelText": "Text",
"chat.modelVision": "Vision", "chat.modelVision": "Vision",
"chat.modelBoth": "Text + vision", "chat.modelBoth": "Text + vision",
"chat.modelSearch": "Search models",
"chat.modelSearchEmpty": "No model matches.",
"chat.modelAvailable": "available", "chat.modelAvailable": "available",
"chat.modelUnavailable": "not found", "chat.modelUnavailable": "not found",
"chat.modelNote": "Photos and documents are transcribed first, so any of these works.", "chat.modelNote": "Photos and documents are transcribed first, so any of these works.",
@ -1013,6 +1017,8 @@ const en: Record<string, string> = {
"consent.ok": "Got it", "consent.ok": "Got it",
"settings.version": "Version", "settings.version": "Version",
"settings.chatNotes": "Keep notes text for chat", "settings.chatNotes": "Keep notes text for chat",
"settings.reduceMotion": "Reduce motion",
"settings.reduceMotionNote": "Calms the scene and UI animations. Useful when motion feels distracting.",
"settings.chatNotesNote": "Chat answers from the notes you added (stored with each deck). Turn off to use only the generated study content.", "settings.chatNotesNote": "Chat answers from the notes you added (stored with each deck). Turn off to use only the generated study content.",
"server.chatModelNotAllowed": "That chat model is not on the server's allowlist.", "server.chatModelNotAllowed": "That chat model is not on the server's allowlist.",
"server.chatNeedsQuestion": "The conversation must end with a user message.", "server.chatNeedsQuestion": "The conversation must end with a user message.",
@ -1033,6 +1039,7 @@ const cs: Record<string, string> = {
"common.folder": "Složka", "common.folder": "Složka",
"common.folders": "Složky", "common.folders": "Složky",
"common.newFolder": "Nová složka", "common.newFolder": "Nová složka",
"common.foldersHint": "Vytvořte složku pro uspořádání sad.",
"common.folderName": "Název složky", "common.folderName": "Název složky",
"common.newFolderName": "Název nové složky", "common.newFolderName": "Název nové složky",
"common.unorganized": "Nezařazené", "common.unorganized": "Nezařazené",
@ -1343,6 +1350,7 @@ const cs: Record<string, string> = {
"deck.runs.few": "{n} běhy", "deck.runs.few": "{n} běhy",
"deck.runs.many": "{n} běhů", "deck.runs.many": "{n} běhů",
"deck.notPlayed": "Zatím nehráno", "deck.notPlayed": "Zatím nehráno",
"deck.new": "Nové",
"deck.best": "Nejlepší {n} %", "deck.best": "Nejlepší {n} %",
"deck.bestLabel": "Nejlepší", "deck.bestLabel": "Nejlepší",
"deck.study": "Studovat", "deck.study": "Studovat",
@ -1908,6 +1916,8 @@ const cs: Record<string, string> = {
"chat.modelText": "Text", "chat.modelText": "Text",
"chat.modelVision": "Obrázky", "chat.modelVision": "Obrázky",
"chat.modelBoth": "Text + obrázky", "chat.modelBoth": "Text + obrázky",
"chat.modelSearch": "Hledat modely",
"chat.modelSearchEmpty": "Žádný model neodpovídá.",
"chat.modelAvailable": "dostupný", "chat.modelAvailable": "dostupný",
"chat.modelUnavailable": "nenalezen", "chat.modelUnavailable": "nenalezen",
"chat.modelNote": "Fotky a dokumenty se nejdřív přepíšou na text, takže funguje kterýkoli z nich.", "chat.modelNote": "Fotky a dokumenty se nejdřív přepíšou na text, takže funguje kterýkoli z nich.",
@ -1970,6 +1980,8 @@ const cs: Record<string, string> = {
"consent.ok": "Rozumím", "consent.ok": "Rozumím",
"settings.version": "Verze", "settings.version": "Verze",
"settings.chatNotes": "Uchovávat text poznámek pro chat", "settings.chatNotes": "Uchovávat text poznámek pro chat",
"settings.reduceMotion": "Omezit pohyb",
"settings.reduceMotionNote": "Zklidní scénu i animace rozhraní. Hodí se, když je pohyb rušivý.",
"settings.chatNotesNote": "Chat odpovídá z poznámek, které jsi přidal (jsou uložené u každé sady). Vypnutím bude používat jen vygenerovaný obsah.", "settings.chatNotesNote": "Chat odpovídá z poznámek, které jsi přidal (jsou uložené u každé sady). Vypnutím bude používat jen vygenerovaný obsah.",
"server.chatModelNotAllowed": "Tento model chatu není v povoleném seznamu serveru.", "server.chatModelNotAllowed": "Tento model chatu není v povoleném seznamu serveru.",
"server.chatNeedsQuestion": "Konverzace musí končit zprávou uživatele.", "server.chatNeedsQuestion": "Konverzace musí končit zprávou uživatele.",

51
lib/motion-pref.ts Normal file
View file

@ -0,0 +1,51 @@
"use client";
import { useSyncExternalStore } from "react";
/**
* The Settings "reduce motion" toggle (default off). It mirrors the OS
* preference: the scene stops moving, CSS transitions collapse, and
* framer-motion runs in its calm mode via MotionPreference. One shared store,
* so the toggle and every consumer update together.
*/
const KEY = "freezmo.reduce-motion.v1";
let cached: boolean | null = null;
const listeners = new Set<() => void>();
function read(): boolean {
try {
return window.localStorage.getItem(KEY) === "1";
} catch {
return false;
}
}
function snapshot(): boolean {
cached ??= read();
return cached;
}
export function applyReducedMotion(next: boolean) {
cached = next;
document.documentElement.classList.toggle("reduce-motion", next);
try {
window.localStorage.setItem(KEY, next ? "1" : "0");
} catch {
/* private mode */
}
listeners.forEach((listener) => listener());
}
export function useReducedMotionPref() {
const reduced = useSyncExternalStore(
(listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
snapshot,
() => false,
);
return { reduced, setReduced: applyReducedMotion };
}

View file

@ -57,3 +57,30 @@ export function parseJsonLoose(text: string): unknown {
export function truncate(value: string, max: number): string { export function truncate(value: string, max: number): string {
return value.length > max ? `${value.slice(0, max - 1)}…` : value; return value.length > max ? `${value.slice(0, max - 1)}…` : value;
} }
/**
* A friendlier label for a model name: "gemma4:31b-cloud" → "Gemma 4 31B",
* "gpt-oss:120b" → "GPT OSS 120B". Chips show this; the raw name lives in a
* tooltip (and the picker keeps the technical names).
*/
export function prettyModelName(name: string): string {
const base = name.replace(/:cloud$/i, "").replace(/-cloud$/i, "");
const [family, ...tagParts] = base.split(":");
const words = family
.replace(/[-_.]+/g, " ")
.replace(/([a-z])(\d)/gi, "$1 $2")
.trim()
.split(/\s+/)
.filter(Boolean);
const prettyFamily = words
.map((word) =>
/^[a-z]{1,3}$/i.test(word) && word === word.toLowerCase()
? word.toUpperCase()
: word.charAt(0).toUpperCase() + word.slice(1),
)
.join(" ");
const tag = tagParts
.join(" ")
.replace(/^(\d+)([a-z])$/i, (_match, size: string, unit: string) => `${size}${unit.toUpperCase()}`);
return [prettyFamily, tag].filter(Boolean).join(" ");
}

View file

@ -1,6 +1,6 @@
{ {
"name": "freezmo", "name": "freezmo",
"version": "0.4.1", "version": "0.4.6",
"private": true, "private": true,
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"scripts": { "scripts": {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Before After
Before After

View file

@ -9,7 +9,7 @@
* *
* Bumping CACHE_VERSION is how a new deploy invalidates old assets. * Bumping CACHE_VERSION is how a new deploy invalidates old assets.
*/ */
const CACHE_VERSION = "freezmo-v70"; const CACHE_VERSION = "freezmo-v71";
const SHELL_CACHE = `${CACHE_VERSION}-shell`; const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`; const ASSET_CACHE = `${CACHE_VERSION}-assets`;

View file

@ -46,7 +46,7 @@ const ANSWER = [
const stub = http.createServer((request, response) => { const stub = http.createServer((request, response) => {
if (request.url?.startsWith("/api/tags")) { if (request.url?.startsWith("/api/tags")) {
response.writeHead(200, { "Content-Type": "application/json" }); response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({ models: [{ name: "stub-text" }, { name: "stub-vision" }] })); response.end(JSON.stringify({ models: [{ name: "stub-text" }, { name: "stub-vision" }, { name: "stub-extra" }] }));
return; return;
} }
let raw = ""; let raw = "";
@ -127,6 +127,7 @@ const app = spawnApp(APP_PORT, {
OLLAMA_AUTH: "local", OLLAMA_AUTH: "local",
OLLAMA_VERIFY_KEY: "false", OLLAMA_VERIFY_KEY: "false",
CHAT_MODELS: "stub-text,stub-vision", CHAT_MODELS: "stub-text,stub-vision",
CHAT_MODEL_DEFAULT: "stub-text",
}); });
const DECK_NOTES = { const DECK_NOTES = {
@ -292,7 +293,7 @@ try {
/* 6b. desktop pickers: the chip that opens also closes --------------- /* 6b. desktop pickers: the chip that opens also closes ---------------
(It used to only ever open, so the only way out was a choice.) */ (It used to only ever open, so the only way out was a choice.) */
const modelChip = () => desktop.page.getByRole("button", { name: /stub-(text|vision)|Chat model/ }).first(); const modelChip = () => desktop.page.getByRole("button", { name: /Stub (Text|Vision)|Chat model/ }).first();
const modelDialog = desktop.page.getByRole("dialog", { name: "Chat model" }); const modelDialog = desktop.page.getByRole("dialog", { name: "Chat model" });
const scopeDialog = desktop.page.getByRole("dialog", { name: "What may chat read?" }); const scopeDialog = desktop.page.getByRole("dialog", { name: "What may chat read?" });
const scopeChip = () => desktop.page.getByRole("button", { name: "All notes" }).first(); const scopeChip = () => desktop.page.getByRole("button", { name: "All notes" }).first();
@ -357,20 +358,21 @@ try {
check("chat: context reached the model", String(lastChat?.messages?.[0]?.content ?? "").includes("FOTOSYNTEZA-MARKER")); check("chat: context reached the model", String(lastChat?.messages?.[0]?.content ?? "").includes("FOTOSYNTEZA-MARKER"));
/* 8. UI: model picker + context picker ------------------------------ */ /* 8. UI: model picker + context picker ------------------------------ */
await phone.page.getByRole("button", { name: "stub-text" }).click(); await phone.page.getByRole("button", { name: "Stub Text" }).click();
await phone.page.waitForTimeout(400); await phone.page.waitForTimeout(400);
check("model: picker lists both models with availability", (await phone.page.getByText("stub-vision").count()) >= 1 && (await phone.page.getByText("available").count()) >= 1); check("model: picker lists both models with availability", (await phone.page.getByText("stub-vision").count()) >= 1 && (await phone.page.getByText("available").count()) >= 1);
check("model: a model listed by the server's tags is selectable", (await phone.page.getByText("stub-extra").count()) >= 1);
await phone.page.getByRole("button", { name: /stub-vision/ }).click(); await phone.page.getByRole("button", { name: /stub-vision/ }).click();
await phone.page.getByRole("dialog", { name: "Chat model" }).waitFor({ state: "detached", timeout: 4000 }).catch(() => {}); await phone.page.getByRole("dialog", { name: "Chat model" }).waitFor({ state: "detached", timeout: 4000 }).catch(() => {});
await phone.page.waitForTimeout(300); await phone.page.waitForTimeout(300);
check("model: choice updates the chip", (await phone.page.getByRole("button", { name: "stub-vision" }).count()) === 1); check("model: choice updates the chip", (await phone.page.getByRole("button", { name: "Stub Vision" }).count()) === 1);
check( check(
"model: choice is remembered", "model: choice is remembered",
(await phone.page.evaluate(() => localStorage.getItem("freezmo.chat.model.v1"))) === "stub-vision", (await phone.page.evaluate(() => localStorage.getItem("freezmo.chat.model.v1"))) === "stub-vision",
); );
await phone.page.reload({ waitUntil: "networkidle" }); await phone.page.reload({ waitUntil: "networkidle" });
await phone.page.waitForTimeout(1200); await phone.page.waitForTimeout(1200);
check("model: remembered across reloads", (await phone.page.getByRole("button", { name: "stub-vision" }).count()) === 1); check("model: remembered across reloads", (await phone.page.getByRole("button", { name: "Stub Vision" }).count()) === 1);
await phone.page.getByRole("button", { name: "1 deck" }).click(); await phone.page.getByRole("button", { name: "1 deck" }).click();
await phone.page.waitForTimeout(400); await phone.page.waitForTimeout(400);

View file

@ -287,6 +287,34 @@ const AXE = axeSource();
await context.close(); await context.close();
} }
/* 6e. reduced motion: off by default, toggled in settings -------------- */
{
const { context, page } = await makePage(browser, { width: 390, height: 844 });
await seed(page, { decks: [DECK], theme: "aero" });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(900);
check(
"motion: reduced motion is off by default",
!(await page.evaluate(() => document.documentElement.classList.contains("reduce-motion"))),
);
await page.locator("nav.tabbar").getByRole("button").click();
await page.getByRole("dialog").waitFor({ timeout: 6000 });
await page.waitForTimeout(500);
await page.getByRole("switch", { name: "Reduce motion" }).click();
await page.waitForTimeout(500);
const motion = await page.evaluate(() => ({
cls: document.documentElement.classList.contains("reduce-motion"),
bubble: getComputedStyle(document.querySelector(".ice-bubble")).animationName,
stored: localStorage.getItem("freezmo.reduce-motion.v1"),
}));
check(
"motion: the toggle parks the scene and persists",
motion.cls && motion.bubble === "none" && motion.stored === "1",
JSON.stringify(motion),
);
await context.close();
}
/* 6d. touch devices blur the real backdrop (the baked layer mispaints) - */ /* 6d. touch devices blur the real backdrop (the baked layer mispaints) - */
{ {
const { context, page } = await makePage(browser, { width: 390, height: 844, isMobile: true, hasTouch: true }); const { context, page } = await makePage(browser, { width: 390, height: 844, isMobile: true, hasTouch: true });