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

@ -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>
);