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
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
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

View file

@ -1,7 +1,7 @@
import { z } from "zod";
import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth, resolveModelName } from "@/lib/config";
import { friendlyErrorMessage, ollamaChatStream, OllamaError } from "@/lib/ollama";
import { authErrorMessage, authErrorKind, canonicalModelName, getServerConfig, resolveAuth, resolveModelName } from "@/lib/config";
import { friendlyErrorMessage, ollamaChatStream, ollamaListModels, OllamaError } from "@/lib/ollama";
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
import { acquireModelGate } from "@/lib/model-guard";
@ -98,14 +98,28 @@ export async function POST(request: Request) {
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 model = parsed.data.model ?? allowed[0];
if (!allowed.some((entry) => entry.toLowerCase() === model.toLowerCase())) {
return Response.json(
{ error: translate(locale, "server.chatModelNotAllowed"), kind: "bad_model" },
{ status: 400 },
);
const wanted = canonicalModelName(model);
const inFavourites = allowed.some((entry) => canonicalModelName(entry) === wanted);
if (!inFavourites) {
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];

View file

@ -143,6 +143,21 @@ export async function GET(request: Request) {
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);
if (!health.ok && !health.error) {
health.error = translate(locale, "server.healthModelsMissing");

View file

@ -88,6 +88,7 @@
--surface-strong: rgb(255 255 255 / 0.72); /* nested glass: inputs, chips */
--card-hover-bg: rgb(255 255 255 / 0.72);
--surface-sunken: rgb(12 32 62 / 0.06);
--track: rgb(12 32 62 / 0.14);
--hairline: #d5e4f4;
--card-border: var(--glass-border);
--sidebar-divider: rgb(255 255 255 / 0.75);
@ -103,7 +104,7 @@
--glass-bg: rgb(255 255 255 / 0.55);
/* A cool rim instead of near-white: white-on-white made light-mode card
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-shadow: 0 20px 50px -20px rgb(30 64 175 / 0.35);
--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-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);
--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);
}
@ -139,14 +140,15 @@
--surface-strong: rgb(255 255 255 / 0.08);
--card-hover-bg: rgb(255 255 255 / 0.1);
--surface-sunken: rgb(255 255 255 / 0.07);
--track: rgb(255 255 255 / 0.18);
--hairline: rgb(255 255 255 / 0.12);
--card-border: var(--glass-border);
--sidebar-divider: rgb(255 255 255 / 0.12);
--hairline-soft: rgb(255 255 255 / 0.08);
--glass-tint: rgb(255 255 255 / 0.06);
--text: #e8f0ff; /* 16.3:1 on #0a1120 */
--text-muted: #b3c4e0; /* 10.1:1 */
--text-subtle: #9db1d0; /* 8.6:1 on page, 7.2:1 on dark glass */
--text-muted: #c2d1e8; /* ~12:1 */
--text-subtle: #adbfd9; /* ~10:1 on page, 8.4:1 on dark glass */
--hover: rgb(255 255 255 / 0.07);
--focus: #38bdf8;
@ -185,14 +187,14 @@
--ice-base-top: #f4f9ff;
--ice-base-bottom: #e6f1fb;
--ice-blob-opacity: 0.045;
--ice-orb-opacity: 0.34;
--ice-orb-opacity: 0.22;
--ice-grain-opacity: 0.05;
}
.dark {
--ice-base-top: #070b16;
--ice-base-bottom: #0c1426;
--ice-blob-opacity: 0.1;
--ice-orb-opacity: 0.5;
--ice-orb-opacity: 0.38;
--ice-grain-opacity: 0.045;
}
@ -414,6 +416,7 @@
--glass-sheet-bg: rgb(232 248 255 / 0.64);
--surface-strong: rgb(255 255 255 / 0.23);
--surface-sunken: rgb(255 255 255 / 0.17);
--track: rgb(255 255 255 / 0.38);
--nav-active-bg: rgb(255 255 255 / 0.27);
}
@ -433,15 +436,13 @@
.aero .list-group,
.aero .nav-action,
.aero .nav-row[aria-current="page"],
.aero .nav-row[aria-pressed="true"],
.aero .nav-row[data-active="true"],
.aero .glass-chip,
.aero .glass-pill,
.aero .btn-secondary,
.aero .btn-primary,
.aero .grad-ice,
.aero .chat-header,
.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:
white/xx utilities and the surface tokens — without this they render as
flat white chips next to the glass. */
@ -454,16 +455,17 @@
.aero [class*="bg-white/70"],
.aero [class*="bg-[var(--surface)]"],
.aero [class*="bg-[var(--surface-strong)]"],
.aero [class*="bg-[var(--surface-sunken)]"],
.aero [class*="bg-frost-50"] {
background-image:
linear-gradient(
180deg,
rgb(255 255 255 / 0.13) 0%,
rgb(255 255 255 / 0.04) 46%,
rgb(255 255 255 / 0.03) 53%,
rgb(255 255 255 / 0.07) 100%
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.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");
background-size: 100% 100%, 100% 100%, cover;
background-position: 0 0, 0 0, center 42%;
@ -613,21 +615,16 @@
.aero .list-group,
.aero .nav-action,
.aero .nav-row[aria-current="page"],
.aero .nav-row[aria-pressed="true"],
.aero .nav-row[data-active="true"],
.aero .glass-chip,
.aero .glass-pill,
.aero .btn-secondary,
.aero .btn-primary,
.aero .grad-ice,
.aero .chat-header,
.aero .segmented {
background-image: none;
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-answer {
background-image: none;
@ -646,11 +643,65 @@
.aero [class*="bg-white/70"],
.aero [class*="bg-[var(--surface)]"],
.aero [class*="bg-[var(--surface-strong)]"],
.aero [class*="bg-[var(--surface-sunken)]"],
.aero [class*="bg-frost-50"] {
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
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
@ -675,6 +726,7 @@
.aero .glass-sheet [class*="bg-white/70"],
.aero .glass-sheet [class*="bg-[var(--surface)]"],
.aero .glass-sheet [class*="bg-[var(--surface-strong)]"],
.aero .glass-sheet [class*="bg-[var(--surface-sunken)]"],
.aero .glass-sheet [class*="bg-frost-50"] {
background-image: none;
}
@ -877,11 +929,11 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) {
@media (max-width: 639px) {
:root {
--ice-blob-opacity: 0.025;
--ice-orb-opacity: 0.2;
--ice-orb-opacity: 0.14;
}
.dark {
--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
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;
font-size: 0.875rem;
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;
}
.dark .segmented > button {
@ -1070,7 +1122,7 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) {
padding: 0.42rem 0.5rem;
font-size: 0.875rem;
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;
}
.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); }
/* Difficulty: neutral pill, colour lives in the text only. */
.diff-easy { color: #16714f; }
.diff-medium { color: #7a5300; }
.diff-hard { color: #b4342f; }
.diff-easy { color: #0f5c3f; }
.diff-medium { color: #694800; }
.diff-hard { color: #a3261f; }
.dark .diff-easy { color: #6fd3a8; }
.dark .diff-medium { color: #e3b341; }
.dark .diff-hard { color: #f08a84; }
@ -1473,3 +1525,23 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) {
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 { IceBackdrop } from "@/components/IceBackdrop";
import { MotionPreference } from "@/components/MotionPreference";
import { CookieConsent } from "@/components/CookieConsent";
import { InstallSheet } from "@/components/InstallSheet";
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
* 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)
@ -105,13 +106,15 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<script dangerouslySetInnerHTML={{ __html: installScript }} />
</head>
<body className="min-h-full antialiased app-shell-in">
<IceBackdrop />
{children}
<InstallSheet />
<OfflineBanner />
<CookieConsent />
<Toaster />
<ServiceWorkerRegistrar />
<MotionPreference>
<IceBackdrop />
{children}
<InstallSheet />
<OfflineBanner />
<CookieConsent />
<Toaster />
<ServiceWorkerRegistrar />
</MotionPreference>
</body>
</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)
* 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();
return (
// 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")}
</p>
);

View file

@ -225,9 +225,17 @@ export function DeckCard({
) : null}
</span>
{/* Desktop always shows the same two fields. */}
<span className="hidden lg:inline">
<span aria-hidden> · </span>
{deck.attempts > 0 ? plural(deck.attempts, "deck.runs") : t("deck.notPlayed")}
<span className="hidden lg:inline-flex items-center">
{deck.attempts > 0 ? (
<>
<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>
</p>
</div>
@ -343,7 +351,7 @@ export function DeckCard({
{t("deck.study")}
</Link>
<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" />
{t("deck.study")}
</Link>

View file

@ -192,12 +192,14 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
{/* page header ---------------------------------------------------- */}
<section className="mb-6 space-y-5">
<div>
<h1 className="text-[24px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)] sm:text-[26px]">
{t("library.title")}
</h1>
<p className="mt-1.5 text-[13.5px] text-[var(--text-subtle)]">
{t("library.subtitle")}
</p>
<div className="page-heading">
<h1 className="text-[24px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)] sm:text-[26px]">
{t("library.title")}
</h1>
<p className="mt-1.5 text-[13.5px] text-[var(--text-subtle)]">
{t("library.subtitle")}
</p>
</div>
</div>
{/* 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. */}
{!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")}
</p>
) : 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 { LearningSection } from "@/components/LearningSection";
import { ThemeSection } from "@/components/ThemeSection";
import { useReducedMotionPref } from "@/lib/motion-pref";
import { SyncSection } from "@/components/SyncSection";
import { clearApiKey, saveSettings, settingsStore } from "@/lib/settings";
import { useBackClose } from "@/lib/use-back-close";
@ -54,6 +55,7 @@ export function SettingsDialog({
focusField?: { field: "key"; nonce: number } | null;
}) {
const { health, loading, error, refresh, apiKey, checkedAt } = useHealth();
const { reduced: reducedMotion, setReduced: setReducedMotion } = useReducedMotionPref();
const installAvailable = useInstallAvailable();
const { locale, setLocale, t, plural } = useI18n();
const version = useVersion();
@ -411,6 +413,19 @@ export function SettingsDialog({
{/* ------------------------------------------- theme */}
<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 */}
<LearningSection level={3} />
@ -773,6 +788,19 @@ export function SettingsDialog({
{/* ------------------------------------------- theme */}
<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 */}
<LearningSection level={3} />

View file

@ -209,6 +209,12 @@ function NavContent({ collapsed = false }: { collapsed?: boolean }) {
</div>
) : 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) => (
<button
key={folder.id}

View file

@ -689,7 +689,7 @@ export function UploadScreen() {
aria-valuemin={1}
aria-valuemax={3}
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
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 { MessageRow } from "./MessageRow";
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 { ThemeToggle } from "@/components/ThemeToggle";
import { Button } from "@/components/ui";
import { ApiError } from "@/lib/client-api";
import { extractChatAttachment, streamChat } from "@/lib/chat-client";
import { buildChatContext, deckHasNotes } from "@/lib/chat-context";
import { cn, prettyModelName } from "@/lib/utils";
import { takeChatSeed, type ChatSeed } from "@/lib/chat-seed";
import { chatActive } from "@/lib/chat-active";
import { chatHistoryStore, conversationMessages, deleteConversation, ensureChatsHydrated, getConversation, renameConversation, saveConversation } from "@/lib/chat-store";
@ -167,7 +168,10 @@ export function ChatScreen() {
const activeModel = useMemo(() => {
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]);
const scopeText = useMemo(() => {
@ -524,7 +528,7 @@ export function ChatScreen() {
<div className="flex h-full min-h-0 w-full">
{/* 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)" }}>
<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
type="button"
onClick={() => (window.history.length > 1 ? router.back() : router.push("/"))}
@ -563,16 +567,21 @@ export function ChatScreen() {
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)]"
>
<PlusIcon className="size-5" />
<ChatPlusIcon className="size-5" />
</button>
</div>
</header>
<div className="relative min-h-0 flex-1">
<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 ? (
<div className="space-y-5 py-6">
<div className="chat-empty space-y-5 py-2">
<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">
<ChatIcon className="size-6" />
@ -671,7 +680,7 @@ export function ChatScreen() {
</div>
) : null}
<div className="relative shrink-0">
<div className="chat-composer relative shrink-0">
<Composer
value={draft}
onChange={setDraft}
@ -680,7 +689,8 @@ export function ChatScreen() {
busy={busy}
online={online}
scopeLabel={scopeText}
modelLabel={activeModel || t("chat.modelTitle")}
modelLabel={activeModel ? prettyModelName(activeModel) : t("chat.modelTitle")}
modelTitle={activeModel || undefined}
scopeOpen={scopeOpen}
modelOpen={modelOpen}
// The chips toggle their own popover (and open one at a time), so a

View file

@ -26,6 +26,7 @@ export function Composer({
online,
scopeLabel,
modelLabel,
modelTitle,
scopeOpen,
modelOpen,
onOpenScope,
@ -43,6 +44,8 @@ export function Composer({
online: boolean;
scopeLabel: string;
modelLabel: string;
/** The raw model name, shown as the chip's tooltip. */
modelTitle?: string;
scopeOpen: boolean;
modelOpen: boolean;
onOpenScope: () => void;
@ -168,8 +171,8 @@ export function Composer({
onClick={onOpenScope}
className={pickerChipClass(scopeOpen)}
>
<LayersIcon className="size-3.5 shrink-0" />
<span className="max-w-[170px] truncate">{scopeLabel}</span>
<LayersIcon className="size-4 shrink-0" />
<span className="max-w-[190px] truncate">{scopeLabel}</span>
</button>
<button
type="button"
@ -177,10 +180,11 @@ export function Composer({
aria-haspopup="dialog"
aria-expanded={modelOpen}
onClick={onOpenModel}
title={modelTitle ?? modelLabel}
className={pickerChipClass(modelOpen)}
>
<ChatIcon className="size-3.5 shrink-0" />
<span className="max-w-[170px] truncate">{modelLabel}</span>
<ChatIcon className="size-4 shrink-0" />
<span className="max-w-[190px] truncate">{modelLabel}</span>
</button>
{!online ? (
<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}
</div>
<AiDisclaimer className="mt-1.5" />
<AiDisclaimer plain className="mt-1.5" />
</div>
);
}
@ -200,9 +204,9 @@ export function Composer({
*/
function pickerChipClass(open: boolean): string {
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
? "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",
scope.kind === value
? "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>

View file

@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { CheckCircleIcon } from "@/components/icons";
@ -32,6 +32,15 @@ export function ModelPicker({
const isPhone = useIsPhone();
const reduceMotion = useReducedMotion();
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(() => {
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");
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">
{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 unavailable = model.available === false;
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",
selected
? "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",
)}
>
@ -100,6 +124,7 @@ export function ModelPicker({
);
})}
</ul>
</>
);
if (isPhone) {

View file

@ -440,7 +440,7 @@ export function HomeworkInput({
aria-valuemin={1}
aria-valuemax={2}
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
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) {
return (
<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));
return (
<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"
aria-valuenow={Math.round(percent)}
aria-valuemin={0}

View file

@ -142,7 +142,7 @@ export async function generateDeck(
body: JSON.stringify(input),
signal: controller.signal,
});
} catch (error) {
} catch {
if (forwarded?.aborted) throw new ApiError(t("api.cancelled"), "aborted");
if (headersTimedOut) throw new ApiError(t("api.stalled"), "timeout");
throw new ApiError(t("api.unreachable"), "network");
@ -262,7 +262,7 @@ export async function generateHomework(
body: JSON.stringify(input),
signal: controller.signal,
});
} catch (error) {
} catch {
if (forwarded?.aborted) throw new ApiError(t("api.cancelled"), "aborted");
if (headersTimedOut) throw new ApiError(t("api.stalled"), "timeout");
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 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
// vision models. Dedupe on the canonical name so `x` and `x:cloud` collapse.
const requestedChatModels = (process.env.CHAT_MODELS ?? "")
@ -135,7 +140,7 @@ export function getServerConfig(): ServerConfig {
.filter(Boolean);
const chatModels: 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);
if (seenModels.has(canonical)) continue;
seenModels.add(canonical);

View file

@ -76,6 +76,7 @@ const en: Record<string, string> = {
"common.folder": "Folder",
"common.folders": "Folders",
"common.newFolder": "New folder",
"common.foldersHint": "Create a folder to organize decks.",
"common.folderName": "Folder name",
"common.newFolderName": "New folder name",
"common.unorganized": "Unorganized",
@ -386,6 +387,7 @@ const en: Record<string, string> = {
"deck.runs.few": "{n} runs",
"deck.runs.many": "{n} runs",
"deck.notPlayed": "Not played yet",
"deck.new": "New",
"deck.best": "Best {n}%",
"deck.bestLabel": "Best",
"deck.study": "Study",
@ -951,6 +953,8 @@ const en: Record<string, string> = {
"chat.modelText": "Text",
"chat.modelVision": "Vision",
"chat.modelBoth": "Text + vision",
"chat.modelSearch": "Search models",
"chat.modelSearchEmpty": "No model matches.",
"chat.modelAvailable": "available",
"chat.modelUnavailable": "not found",
"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",
"settings.version": "Version",
"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.",
"server.chatModelNotAllowed": "That chat model is not on the server's allowlist.",
"server.chatNeedsQuestion": "The conversation must end with a user message.",
@ -1033,6 +1039,7 @@ const cs: Record<string, string> = {
"common.folder": "Složka",
"common.folders": "Složky",
"common.newFolder": "Nová složka",
"common.foldersHint": "Vytvořte složku pro uspořádání sad.",
"common.folderName": "Název složky",
"common.newFolderName": "Název nové složky",
"common.unorganized": "Nezařazené",
@ -1343,6 +1350,7 @@ const cs: Record<string, string> = {
"deck.runs.few": "{n} běhy",
"deck.runs.many": "{n} běhů",
"deck.notPlayed": "Zatím nehráno",
"deck.new": "Nové",
"deck.best": "Nejlepší {n} %",
"deck.bestLabel": "Nejlepší",
"deck.study": "Studovat",
@ -1908,6 +1916,8 @@ const cs: Record<string, string> = {
"chat.modelText": "Text",
"chat.modelVision": "Obrázky",
"chat.modelBoth": "Text + obrázky",
"chat.modelSearch": "Hledat modely",
"chat.modelSearchEmpty": "Žádný model neodpovídá.",
"chat.modelAvailable": "dostupný",
"chat.modelUnavailable": "nenalezen",
"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",
"settings.version": "Verze",
"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.",
"server.chatModelNotAllowed": "Tento model chatu není v povoleném seznamu serveru.",
"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 {
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",
"version": "0.4.1",
"version": "0.4.6",
"private": true,
"license": "GPL-3.0-only",
"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.
*/
const CACHE_VERSION = "freezmo-v70";
const CACHE_VERSION = "freezmo-v71";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`;

View file

@ -46,7 +46,7 @@ const ANSWER = [
const stub = http.createServer((request, response) => {
if (request.url?.startsWith("/api/tags")) {
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;
}
let raw = "";
@ -127,6 +127,7 @@ const app = spawnApp(APP_PORT, {
OLLAMA_AUTH: "local",
OLLAMA_VERIFY_KEY: "false",
CHAT_MODELS: "stub-text,stub-vision",
CHAT_MODEL_DEFAULT: "stub-text",
});
const DECK_NOTES = {
@ -292,7 +293,7 @@ try {
/* 6b. desktop pickers: the chip that opens also closes ---------------
(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 scopeDialog = desktop.page.getByRole("dialog", { name: "What may chat read?" });
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"));
/* 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);
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("dialog", { name: "Chat model" }).waitFor({ state: "detached", timeout: 4000 }).catch(() => {});
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(
"model: choice is remembered",
(await phone.page.evaluate(() => localStorage.getItem("freezmo.chat.model.v1"))) === "stub-vision",
);
await phone.page.reload({ waitUntil: "networkidle" });
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.waitForTimeout(400);

View file

@ -287,6 +287,34 @@ const AXE = axeSource();
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) - */
{
const { context, page } = await makePage(browser, { width: 390, height: 844, isMobile: true, hasTouch: true });