/** * 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();