freezmo/tests/mobile-test.mjs
Martin Dvorak 2b78dcf0d1 Phones get flat glass: no live blur, no baked layer
The touch fallback from 0.3.8 (a real backdrop-filter over the live
scene) was the wrong trade: every pane re-blurs on every frame while
the bubbles animate, and phones choke - input lag, "buttons take ages
to respond". Touch devices now drop both the bake (which mispaints
there) and the live blur: the panes keep the thin Ask-pill tint over
the sharp scene, and the hand-rolled activity fills keep their own
tints - transparent glass at zero per-frame cost. Desktop keeps the
baked blur.

tests/mobile-test.mjs pins the flat contract (no baked layer, no
backdrop-filter on a card) with a touch context: 124/124.
Contrast 0 findings, axe clean, chat 101/101, regression 44/44, i18n
13/13, native 33/33. Version 0.3.9, SW -> freezmo-v68.
2026-10-13 15:57:46 +00:00

363 lines
18 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" }).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" });
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" }).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 light/dark toggle is gone",
(await page.getByRole("button", { name: /Switch to (light|dark) mode/ }).count()) === 0,
);
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",
);
await context.close();
}
/* 6b. picking Aero hides the light/dark switches immediately ---------- */
{
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 toggleInRail = () =>
page.evaluate(() => [...document.querySelectorAll("aside button")].some((b) => /Light mode|Dark mode/.test(b.textContent ?? "")));
check("theme: the rail has a light/dark row while dark", await toggleInRail());
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 hides it without a reload",
(await page.evaluate(() => document.documentElement.classList.contains("aero"))) && !(await toggleInRail()),
);
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();