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:
parent
b70b0e83cf
commit
6635b5d405
29 changed files with 468 additions and 90 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
51
lib/motion-pref.ts
Normal 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 };
|
||||
}
|
||||
27
lib/utils.ts
27
lib/utils.ts
|
|
@ -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(" ");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue