/** * 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 await page.locator("nav.tabbar").getByRole("button").click(); await dialog.waitFor({ timeout: 6000 }); const sheetBox = await box(dialog); await page.mouse.click(195, Math.max(8, sheetBox.y - 24)); 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(); 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"]) { for (const locale of ["en", "cs"]) { await audit(`${width} ${theme}/${locale} library`, { width, locale, theme }); } } } for (const theme of ["dark", "light"]) { for (const locale of ["en", "cs"]) { await audit(`390 ${theme}/${locale} settings`, { width: 390, locale, theme, openSettingsNow: true }); } } await browser.close(); finish();