freezmo/tests/mobile-test.mjs
Martin Dvorak 786d6cf1ae Chat chrome: one gutter, one radius scale, one even glass recipe
The header and composer were full-bleed bands (flush to the top/right/bottom
viewport edges, composer corners off-screen) next to a rail that floats with a
12px margin. The new --layout-gutter (12px) spaces the rail, header, empty-state
card and composer identically on every side; --radius-panel/-control/-chip
(-pill) replace the ad-hoc radii across the shared classes, so every panel is
rounded on all four corners and shared controls match in every theme. The
desktop-only ::after hairlines and the left-corner-only radius are gone.

Aero: the rail and the chat chrome (answers included) share one even recipe -
the wallpaper extra-blurred into a hue-free wash (background-glass.webp) under a
uniform ~50% white veil and a whisper of gloss. Measured within ~10 RGB units
across the rail top->bottom and the card/composer left->right; the blue->green
smear and the teal composer are gone. The composer's field is a clean even tile
with no wallpaper gradient inside it.

Chat fixes: send is a filled primary tile shaped exactly like attach (dimmed
while the input is empty), suggestion/context chips use the shared .chip token
(40/44px target, visible rim, full-contrast text, raw model id in the tooltip),
the AI note reads at 12px --text-muted (.ai-disclaimer), the selected sidebar
row carries the accent border + accent icon, the theme toggle is back in Aero
(the moon flips to Dark; Settings brings Aero back), and the empty state is
centered exactly between header and composer.

Outside CSS: the wallpaper's top-left HUD-circle decoration (it peeked through
the 12px gutters on wide windows) was healed out of background.webp and
background-blur.webp; the orbs/bubbles ride one masked .ice-floats layer that
fades them under the chrome bands and the rail strip, so none is sliced by a
panel edge (still parked under reduced motion).

Version 0.6.0, SW -> freezmo-v82. Tests: chat suite pins the new geometry (12px
gutter, 24px corners, even wash on all four panels, send-vs-attach shape,
chips/input radii); mobile pins the phone chrome and the Aero toggle; bubbles
pins the floats layer + its reduced-motion opt-out.
2026-10-14 21:32:50 +00:00

442 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Phone suite: five-tab bottom bar (labels at 360/390 in EN/CS, dark/light,
* reduced motion), the library ⋯ sheet, the settings sheet (drag, touch
* targets, key flow), and an axe-core contrast audit.
*/
import { BASE, DECK, axeSource, launchBrowser, makeChecker, makePage, seed } from "./lib.mjs";
const { check, finish } = makeChecker();
const browser = await launchBrowser();
const box = (locator) => locator.boundingBox();
const dragDown = async (page, locator, dy = 170, steps = 9) => {
const bounds = await locator.boundingBox();
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
await page.mouse.down();
for (let index = 1; index <= steps; index++) {
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2 + (dy * index) / steps);
await page.waitForTimeout(18);
}
await page.mouse.up();
};
/* 1. empty library: header, tab bar, ⋯ sheet ------------------------- */
{
const { context, page } = await makePage(browser);
await seed(page, { decks: [] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(1100);
check("no hamburger on phones", (await page.getByRole("button", { name: "Open menu" }).count()) === 0);
check("no drawer on phones", (await page.getByRole("complementary", { name: "Menu" }).count()) === 0);
const themeBtn = page.getByRole("button", { name: "Switch to light mode" });
check("theme toggle present", await themeBtn.isVisible());
const themeBox = await box(themeBtn);
check("theme toggle >= 44px", themeBox.height >= 43.5 && themeBox.width >= 43.5, `${Math.round(themeBox.width)}x${Math.round(themeBox.height)}`);
check("exactly one theme toggle on phones", (await page.getByRole("button", { name: /Switch to (light|dark) mode/ }).count()) === 1);
const tabs = page.locator("nav.tabbar");
check("tab bar visible", await tabs.isVisible());
const tabTexts = (await tabs.locator("a, button").allInnerTexts()).map((text) => text.trim());
check(
"five tabs in order",
JSON.stringify(tabTexts) === JSON.stringify(["Library", "Progress", "New deck", "Exam", "Settings"]),
JSON.stringify(tabTexts),
);
const tabBoxes = await tabs.locator("a, button").evaluateAll((els) => els.map((el) => el.getBoundingClientRect().height));
check("every tab >= 44px tall", tabBoxes.every((height) => height >= 43.5), JSON.stringify(tabBoxes.map(Math.round)));
check("empty state hides the folder pills", (await page.locator(".folder-pill").count()) === 0);
const moreBtn = page.getByRole("button", { name: "More actions" });
check("⋯ button replaces the import icon", await moreBtn.isVisible());
const moreBox = await box(moreBtn);
check("⋯ >= 44px", moreBox.height >= 43.5 && moreBox.width >= 43.5, `${Math.round(moreBox.width)}x${Math.round(moreBox.height)}`);
await moreBtn.click();
await page.waitForTimeout(500);
const sheet = page.getByRole("dialog", { name: "Library actions" });
check(
"⋯ sheet lists Import JSON + New folder",
(await sheet.isVisible()) && (await sheet.getByText("Import JSON").isVisible()) && (await sheet.getByText("New folder").isVisible()),
);
await page.keyboard.press("Escape");
let sheetClosed = true;
try {
await sheet.waitFor({ state: "detached", timeout: 2500 });
} catch {
sheetClosed = false;
}
check("⋯ sheet closes with Escape", sheetClosed);
// Empty state checks before any folder exists.
const cta = page.getByRole("link", { name: "Create study deck", exact: true });
check("dedicated primary CTA", await cta.isVisible());
const ctaBox = await box(cta);
check("CTA >= 44px tall", ctaBox.height >= 43.5, String(Math.round(ctaBox.height)));
await page.getByRole("link", { name: "Homework", exact: true }).click();
await page.waitForTimeout(700);
const hwCta = page.getByRole("link", { name: "New homework", exact: true });
check("homework quick action", await hwCta.isVisible());
await page.getByRole("link", { name: "Study Decks", exact: true }).click();
await page.waitForTimeout(700);
// Creating a folder from the ⋯ sheet brings the chips row in.
await page.getByRole("button", { name: "More actions" }).click();
await page.waitForTimeout(500);
await page.getByRole("dialog").getByText("New folder").click();
await page.waitForTimeout(400);
await page.getByRole("dialog").locator("input").fill("Biology");
await page.getByRole("dialog").getByRole("button", { name: "Add" }).click();
await page.waitForTimeout(700);
check(
"⋯ sheet creates a folder",
(await page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.folders.v1")).map((f) => f.name))).includes("Biology"),
);
check("folder chips appear once folders exist", (await page.locator(".folder-pill").count()) >= 1);
await context.close();
}
/* 2. tab-bar matrix: 360/390 × EN/CS × dark/light × reduced motion --- */
for (const locale of ["en", "cs"]) {
for (const width of [360, 390]) {
for (const theme of ["dark", "light"]) {
for (const reducedMotion of ["no-preference", "reduce"]) {
const label = `${locale}/${width}/${theme}/${reducedMotion === "reduce" ? "reduced" : "motion"}`;
const { context, page } = await makePage(browser, { width, height: 800, reducedMotion });
await seed(page, { locale, theme, decks: [] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(900);
const tabs = page.locator("nav.tabbar");
const visible = await tabs.isVisible();
const overflowX = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
const labels = await tabs.locator("a > span:last-child, button > span:last-child").evaluateAll((els) =>
els.map((el) => ({ text: el.innerText, clipped: el.scrollWidth > el.clientWidth + 1 })),
);
check(`${label}: tab bar visible, no overflow`, visible && overflowX <= 1, String(overflowX));
check(`${label}: five labels, none clipped`, labels.length === 5 && labels.every((entry) => !entry.clipped), JSON.stringify(labels.map((entry) => entry.text)));
check(`${label}: active indicator present`, (await tabs.locator('[aria-current="page"]').count()) === 1);
await context.close();
}
}
}
}
/* 3. Czech settings sheet: touch targets, key flow, drag -------------- */
{
const { context, page } = await makePage(browser);
await seed(page, { locale: "cs", decks: [DECK], folders: [{ id: "f1", name: "Škola", createdAt: 1 }] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(1100);
check("cs folder pills are 44px tall", (await box(page.locator(".folder-pill").first())).height >= 43.5);
await page.locator("nav.tabbar").getByRole("button").click();
const dialog = page.getByRole("dialog");
await dialog.waitFor({ timeout: 6000 });
await page.waitForTimeout(700);
check("cs sheet title", await dialog.getByRole("heading", { name: "Nastavení" }).isVisible());
const targets = [
["close", dialog.getByRole("button", { name: "Zavřít nastavení" })],
["Show", dialog.getByRole("button", { name: "Zobrazit" })],
["Re-check", dialog.getByRole("button", { name: "Zkontrolovat připojení" })],
["Remove key", dialog.getByRole("button", { name: "Odstranit klíč z prohlížeče" })],
["segmented", dialog.locator(".segmented > button").first()],
["privacy summary", dialog.locator("summary").first()],
];
for (const [name, locator] of targets) {
const bounds = await box(locator);
check(`${name} >= 44px`, bounds.width >= 43.5 && bounds.height >= 43.5, `${Math.round(bounds.width)}x${Math.round(bounds.height)}`);
}
check("Save hidden until the key is edited", (await dialog.getByRole("button", { name: "Uložit", exact: true }).count()) === 0);
const keyInput = dialog.locator("#ollama-api-key-phone");
await keyInput.fill("test-key2");
await page.waitForTimeout(200);
const save = dialog.getByRole("button", { name: "Uložit", exact: true });
check("Save appears after editing, >= 44px", (await save.isVisible()) && (await box(save)).height >= 43.5);
await keyInput.fill("test-key");
await page.waitForTimeout(200);
check("Save hides again when unchanged", (await dialog.getByRole("button", { name: "Uložit", exact: true }).count()) === 0);
const tip = dialog.locator("details").first();
check("privacy note starts collapsed", !(await tip.evaluate((el) => el.open)));
await tip.locator("summary").click();
await page.waitForTimeout(250);
check("privacy expander opens", await tip.evaluate((el) => el.open));
check("privacy expander mentions .env.local", await tip.getByText(".env.local").isVisible());
await tip.locator("summary").click();
await page.waitForTimeout(200);
// drag while scrolled won't dismiss, at the top it will
await page.locator(".sheet-scroll").evaluate((el) => (el.scrollTop = 120));
await page.waitForTimeout(250);
await dragDown(page, page.locator(".sheet-scroll"));
await page.waitForTimeout(700);
check("scrolled: drag snaps back, sheet stays", (await dialog.count()) === 1);
await page.locator(".sheet-scroll").evaluate((el) => (el.scrollTop = 0));
await page.waitForTimeout(250);
await dragDown(page, dialog.locator("#settings-title"));
await page.waitForTimeout(800);
check("handle+header swipe closes the sheet", (await dialog.count()) === 0);
// backdrop tap — at a fixed point near the top: the sheet is max-h-[90dvh],
// so its top edge can never reach it, while a point measured mid-animation
// can end up inside the sheet once the spring settles.
await page.locator("nav.tabbar").getByRole("button").click();
await dialog.waitFor({ timeout: 6000 });
await page.waitForTimeout(700);
await page.mouse.click(195, 12);
await page.waitForTimeout(800);
check("backdrop tap closes", (await dialog.count()) === 0);
// remove-key confirmation
await page.locator("nav.tabbar").getByRole("button").click();
await dialog.waitFor({ timeout: 6000 });
await dialog.getByRole("button", { name: "Odstranit klíč z prohlížeče" }).click();
await page.waitForTimeout(300);
check("confirm step appears", await dialog.getByText(/vrátí se celoobrazovková/).isVisible());
await dialog.getByRole("button", { name: "Zrušit" }).click();
await page.waitForTimeout(300);
check("cancel keeps the key", await dialog.getByRole("button", { name: "Odstranit klíč z prohlížeče" }).isVisible());
await dialog.getByRole("button", { name: "Odstranit klíč z prohlížeče" }).click();
await dialog.getByRole("button", { name: "Ano, odstranit" }).click();
await page.waitForTimeout(1400);
check("confirming brings the key gate back", await page.getByRole("heading", { name: "Připoj svůj klíč Ollama" }).isVisible());
await context.close();
}
/* 4. Czech at 360px: no overflow, key field fits --------------------- */
{
const { context, page } = await makePage(browser, { width: 360, height: 740 });
await seed(page, { locale: "cs", decks: [DECK] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(1100);
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
check("360px: library has no horizontal overflow", overflow <= 1, String(overflow));
await page.locator("nav.tabbar").getByRole("button").click();
await page.getByRole("dialog").waitFor({ timeout: 6000 });
await page.waitForTimeout(600);
const overflow2 = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
check("360px: settings has no horizontal overflow", overflow2 <= 1, String(overflow2));
const inputBox = await box(page.getByRole("dialog").locator("#ollama-api-key-phone"));
const panel = await box(page.getByRole("dialog"));
check("key field fits the sheet", inputBox.x >= panel.x && inputBox.x + inputBox.width <= panel.x + panel.width + 1);
await context.close();
}
/* 5. axe-core contrast audit (360/390/desktop × dark/light × en/cs) -- */
const AXE = axeSource();
/* 6. frutiger aero: a third theme, no light/dark switch --------------- */
{
const { context, page } = await makePage(browser);
await seed(page, { decks: [DECK], theme: "aero" });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(900);
const state = await page.evaluate(() => ({
aero: document.documentElement.classList.contains("aero"),
dark: document.documentElement.classList.contains("dark"),
sky: getComputedStyle(document.documentElement).getPropertyValue("--ice-base-top").trim(),
themeColor: document.querySelector('meta[name="theme-color"][data-app]')?.getAttribute("content"),
}));
check("aero: applied without the dark class", state.aero && !state.dark, JSON.stringify(state));
check(
"aero: the scene becomes water",
state.sky.length > 0 && state.sky !== "#f4f9ff" && state.sky !== "#070b16",
state.sky,
);
check(
"aero: bubbles rise",
(await page.locator(".ice-bubble").count()) === 18 &&
(await page.locator(".ice-bubble").first().evaluate((el) => getComputedStyle(el).animationName)) ===
"aero-bubble-rise",
);
check("aero: the status bar follows the water", state.themeColor === "#2fa7e6", String(state.themeColor));
check(
"aero: the theme toggle stays (it offers dark)",
(await page.getByRole("button", { name: "Switch to dark mode" }).count()) >= 1,
);
await page.locator("nav.tabbar").getByRole("button").click();
await page.getByRole("dialog").waitFor({ timeout: 6000 });
await page.waitForTimeout(600);
const picker = page.getByRole("group", { name: "Theme" });
check(
"aero: settings offers light, dark and aero",
(await picker.count()) === 1 &&
(await picker.getByRole("button", { name: "Frutiger Aero" }).getAttribute("aria-pressed")) === "true",
);
/* Phones float the chat chrome with the same gutter and radii as the
desktop rail — the header and composer used to be flush to the viewport,
with the composer's bottom corners cropped off-screen. */
await page.goto(`${BASE}/chat`, { waitUntil: "networkidle" });
await page.waitForTimeout(1200);
const phoneChrome = await page.evaluate(() => {
const header = document.querySelector(".chat-header");
const band = document.querySelector(".chat-composer-band");
const empty = document.querySelector(".chat-empty");
if (!header || !band || !empty) return null;
const box = (el) => {
const rect = el.getBoundingClientRect();
const style = getComputedStyle(el);
return {
left: Math.round(rect.left),
top: Math.round(rect.top),
right: Math.round(rect.right),
bottom: Math.round(rect.bottom),
radius: style.borderTopLeftRadius,
bottomRadius: style.borderBottomRightRadius,
};
};
return { header: box(header), band: box(band), empty: box(empty), viewport: { width: window.innerWidth, height: window.innerHeight } };
});
check(
"aero phone: chat panels float with one gutter and rounded corners",
phoneChrome !== null &&
phoneChrome.header.top === 12 &&
phoneChrome.header.left === 12 &&
phoneChrome.viewport.width - phoneChrome.header.right === 12 &&
phoneChrome.viewport.height - phoneChrome.band.bottom === 12 &&
phoneChrome.band.left === phoneChrome.header.left &&
phoneChrome.empty.left === phoneChrome.header.left &&
[phoneChrome.header, phoneChrome.band, phoneChrome.empty].every((panel) => panel.radius === "24px" && panel.bottomRadius === "24px"),
JSON.stringify(phoneChrome),
);
await context.close();
}
/* 6b. picking Aero keeps a light/dark way out, without a reload --------- */
{
const { context, page } = await makePage(browser, { width: 1280, height: 900 });
await seed(page, { decks: [DECK], theme: "dark" });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(1000);
const railRow = () =>
page.evaluate(() => {
const button = [...document.querySelectorAll("aside button")].find((b) => /Light mode|Dark mode/.test(b.textContent ?? ""));
return button ? (button.textContent ?? "").trim() : null;
});
check("theme: the rail has a light/dark row while dark", (await railRow())?.includes("Light mode") === true, String(await railRow()));
await page.locator("aside button").filter({ hasText: /Settings|Nastavení/ }).first().click();
await page.getByRole("dialog").waitFor({ timeout: 6000 });
await page.waitForTimeout(500);
await page.getByRole("group", { name: "Theme" }).getByRole("button", { name: "Frutiger Aero" }).click();
await page.waitForTimeout(600);
check(
"theme: picking Aero flips the row to Dark mode without a reload",
(await page.evaluate(() => document.documentElement.classList.contains("aero"))) && (await railRow())?.includes("Dark mode") === true,
String(await railRow()),
);
await page.keyboard.press("Escape");
await page.waitForTimeout(500);
await page.locator("aside button").filter({ hasText: /Dark mode/ }).first().click();
await page.waitForTimeout(500);
check(
"theme: the Aero row is a real way out (switches to dark)",
await page.evaluate(() => document.documentElement.classList.contains("dark") && !document.documentElement.classList.contains("aero")),
);
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 });
await seed(page, { decks: [DECK], theme: "aero" });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(1000);
const glass = await page.evaluate(() => {
const coarsish = matchMedia("(pointer: coarse)").matches || matchMedia("(max-width: 639px)").matches;
const el = document.querySelector(".card");
const cs = getComputedStyle(el);
return { coarsish, backdrop: cs.backdropFilter, image: cs.backgroundImage };
});
check(
"theme: touch glass is flat (no bake, no per-frame blur)",
glass.coarsish && glass.backdrop === "none" && glass.image === "none",
JSON.stringify(glass),
);
await context.close();
}
/* 6c. a dark OS preference must not leak the dark palette into Aero ------ */
{
const { context, page } = await makePage(browser, { width: 390, height: 844, colorScheme: "dark" });
await seed(page, { decks: [DECK], theme: "aero" });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(1000);
const state = await page.evaluate(() => ({
aero: document.documentElement.classList.contains("aero"),
dark: document.documentElement.classList.contains("dark"),
bodyColor: getComputedStyle(document.body).color,
}));
check("theme: aero on a dark OS keeps the dark class off", state.aero && !state.dark);
check("theme: boot paints the aero body text", state.bodyColor === "rgb(5, 32, 58)", state.bodyColor);
await context.close();
}
async function audit(label, { width = 390, locale, theme, openSettingsNow }) {
const { context, page } = await makePage(browser, { width, height: width >= 1280 ? 900 : 800, deviceScaleFactor: width >= 1280 ? 1 : 2 });
await seed(page, { locale, theme, decks: [DECK] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(1200);
if (openSettingsNow) {
await page.locator("nav.tabbar").getByRole("button").click();
await page.getByRole("dialog").waitFor({ timeout: 6000 });
await page.waitForTimeout(600);
}
await page.addScriptTag({ content: AXE });
const report = await page.evaluate(async () => {
const result = await window.axe.run(document, { runOnly: ["color-contrast"] });
return {
violations: result.violations.map((violation) => ({
id: violation.id,
nodes: violation.nodes.map((node) => ({ target: node.target.join(" "), summary: node.failureSummary?.split("\n")[0] })),
})),
};
});
check(`axe ${label}: no contrast violations`, report.violations.length === 0, JSON.stringify(report.violations).slice(0, 300));
await context.close();
}
for (const width of [360, 390, 1280]) {
for (const theme of ["dark", "light", "aero"]) {
for (const locale of ["en", "cs"]) {
await audit(`${width} ${theme}/${locale} library`, { width, locale, theme });
}
}
}
for (const theme of ["dark", "light", "aero"]) {
for (const locale of ["en", "cs"]) {
await audit(`390 ${theme}/${locale} settings`, { width: 390, locale, theme, openSettingsNow: true });
}
}
await browser.close();
finish();