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

@ -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(" ");
}