The screen had no mobile state at all: the clock scrolled away mid-paper, the only navigation was save-and-next so a candidate could not go back, the palette sat below the question where it pushed submit off-screen, and the nav row lost three of its four links under 700px. Sticky header carries the clock and a progress bar; previous/next are real buttons; the palette is a drawer below 1024px opened by a floating button that sits above the tab bar. Primary destinations move to a bottom tab bar on phones, data tables scroll inside their own card, and every control meets the 44px touch minimum. Verified with a headless browser across five viewports rather than by reading the stylesheet: scripts/audit_mobile.py fails on horizontal overflow, tap targets under 44px, and content hidden behind a fixed bar; scripts/shots.py captures each page at both widths. Both caught bugs I would otherwise have shipped, including a palette toggle that lived inside the drawer it opened and so could never be pressed on a phone. fmt_dur drops a leading zero hour, so a 24 minute paper reads 24m 00s.
70 lines
No EOL
2.5 KiB
Python
Executable file
70 lines
No EOL
2.5 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Screenshots each page at phone and desktop width.
|
|
scripts/shots.py <base-url> <out-dir>
|
|
"""
|
|
import os
|
|
import sys
|
|
import urllib.request
|
|
from playwright.sync_api import sync_playwright
|
|
|
|
BASE = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8180"
|
|
OUT = sys.argv[2] if len(sys.argv) > 2 else "/tmp/shots"
|
|
os.makedirs(OUT, exist_ok=True)
|
|
|
|
SIZES = [("phone", 390, 844, True), ("desktop", 1440, 900, False)]
|
|
|
|
|
|
def sign_in(page):
|
|
page.goto(BASE + "/student", wait_until="networkidle")
|
|
page.fill("#name", "Shot")
|
|
page.fill("#email", "shot@example.com")
|
|
page.click("button[type=submit]")
|
|
page.wait_for_load_state("networkidle")
|
|
|
|
|
|
with sync_playwright() as pw:
|
|
browser = pw.chromium.launch()
|
|
for label, w, h, touch in SIZES:
|
|
ctx = browser.new_context(viewport={"width": w, "height": h},
|
|
has_touch=touch, is_mobile=touch, device_scale_factor=2)
|
|
page = ctx.new_page()
|
|
sign_in(page)
|
|
|
|
page.goto(BASE + "/", wait_until="networkidle")
|
|
page.screenshot(path=f"{OUT}/{label}-landing.png")
|
|
|
|
page.goto(BASE + "/build", wait_until="networkidle")
|
|
page.evaluate("document.querySelectorAll('.chap').forEach(c => { if (!c.checked) c.click(); })")
|
|
page.screenshot(path=f"{OUT}/{label}-build.png", full_page=label == "desktop")
|
|
|
|
page.click("button[type=submit]")
|
|
page.wait_for_load_state("networkidle")
|
|
exam = page.url
|
|
page.goto(exam, wait_until="networkidle")
|
|
page.screenshot(path=f"{OUT}/{label}-exam.png")
|
|
|
|
# Answer one so the palette shows the answered state too.
|
|
# On a phone the palette is a drawer, so it has to be opened before use.
|
|
if touch:
|
|
page.click("#pal-toggle")
|
|
page.wait_for_timeout(350)
|
|
page.screenshot(path=f"{OUT}/{label}-palette-open.png")
|
|
page.click("[data-close-palette]")
|
|
page.wait_for_timeout(350)
|
|
|
|
# Step forward with the on-screen control until a multiple-choice
|
|
# question shows, then pick an option.
|
|
for _ in range(12):
|
|
opt = page.locator(".qpane:not([hidden]) .opt").first
|
|
if opt.count() > 0:
|
|
opt.click()
|
|
break
|
|
page.click(".qpane:not([hidden]) [data-act=save]")
|
|
page.wait_for_timeout(150)
|
|
page.wait_for_timeout(400)
|
|
page.screenshot(path=f"{OUT}/{label}-exam-answered.png")
|
|
|
|
ctx.close()
|
|
browser.close()
|
|
|
|
print("\n".join(sorted(os.listdir(OUT)))) |