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

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