whaleprep/scripts/shots.py
dev 14542f169a Make the exam screen work on a phone
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.
2026-10-11 21:54:17 +05:30

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))))