Mobile engines repaint background-attachment: fixed late (iOS treats it as scroll), so the baked glass showed a stale crop of the wallpaper until a tap forced a repaint - "some of the UI looks bugged until I click it". On touch devices (max-width 639px or pointer: coarse) the bake is dropped for a real backdrop-filter over the live scene, so the bubbles blur through the glass as they pass and the position is correct by construction. Hand-rolled frost fills keep their own tints there; desktop keeps the baked layer (measured cheaper and verified correct). Pinned with a touch-context check in the mobile suite (124 checks). Contrast 0 findings, axe clean, chat 101/101, regression 44/44, i18n 13/13, native 33/33. Version 0.3.8, SW -> freezmo-v67.
363 lines
18 KiB
JavaScript
363 lines
18 KiB
JavaScript
/**
|
||
* 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(".segmented");
|
||
const cs = getComputedStyle(el);
|
||
return { coarsish, backdrop: cs.backdropFilter, image: cs.backgroundImage };
|
||
});
|
||
check(
|
||
"theme: touch glass blurs the live backdrop (no baked layer)",
|
||
glass.coarsish && glass.backdrop.includes("blur") && 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();
|