Files
LLeMbas/scripts/shoot.py
T
HomerandClaude Opus 5 b7bf7d728b An admin area a phone could reach and not navigate
Administration has a nav of its own rather than the chat sidebar, and 1.1.0
gave every `.sidebar` the drawer behaviour -- starts closed, slides in --
without giving that one any of the drawer's furniture. No id for the toggle to
resolve, no toggle, no close, no scrim: it sat at left:-280 with nothing in the
application able to open it. The close button and the scrim are partials now,
used by both, and the test that guards it *finds* sidebars by scanning the
templates rather than working from a list, which is exactly why this one was
missed.

The chat, measured at 390px, spent forty pixels of side padding and a
forty-four pixel avatar column before drawing a word -- close to a quarter of
the screen on margin, so anything that could not wrap had to be reached
sideways. Padding halved and the avatar moved above the turn; a code block
gained about sixty pixels.

Worse in the same row: `.topbar__actions` asked for 317px of a 390px bar,
because the control that used to give in that row is display:none below a
tablet width, so the group went rigid and the title -- flex: 1 -- was squeezed
to exactly zero. And `.btn--icon` sets a width with no `flex: none`, so the row
shrank the button instead of the text: the sidebar toggle measured eighteen
pixels across. The picker gives now, and shows its avatar rather than its name
on a phone.

Also the instrument, which lied twice more: it could not see horizontal
overflow at all, because `.shell` is overflow:hidden and its "is this
contained" test therefore answered yes for everything on the page; and run from
a copy it resolved `STATIC` to a directory that did not exist, rewrote every
asset URL to a dead file:// path and reported the whole application overflowing
by thirty thousand pixels. It resolves from the imported package now and
asserts that what it rewrote to is really there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 19:27:39 +00:00

403 lines
16 KiB
Python

"""Render LLeMbas pages in a real browser, at a real size.
Run it:
python scripts/shoot.py OUTDIR [/chat,/settings] # measure + capture
python scripts/shoot.py OUTDIR --manifest-screenshots # the two the
# manifest wants
Needs a `chromium` on PATH and the development dependencies installed. It is a
development instrument, like the Node DOM stub the JavaScript is driven under
and like `fetch_vendor.py` -- it is not imported by the application and nothing
in `src/` knows it exists.
Not a test runner: an instrument. It renders a page through TestClient, rewrites
every asset URL to a file:// path, and refuses to continue if even one is left
pointing at `testserver` -- because the last harness that did this silently
measured an unstyled document and reported all five tab panels visible at once.
A dramatic finding that was entirely an artefact of a rewrite matching nothing.
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO / "src"))
# Resolved from the package that actually got imported, not from where this
# file happens to sit. A copy of this script run from somewhere else silently
# pointed STATIC at a directory that did not exist, every asset URL was
# rewritten to a file:// path with nothing behind it, and the run measured an
# unstyled document -- reporting that every page in the application overflowed
# by thirty thousand pixels. The guard below only asked whether the URLs had
# been rewritten, which they had.
import lembas # noqa: E402
SRC = Path(lembas.__file__).resolve().parent.parent
STATIC = Path(lembas.__file__).resolve().parent / "web/static"
CHROMIUM = shutil.which("chromium") or shutil.which("chromium-browser")
# Routes that are served by the app rather than mounted, so the rewrite has to
# fetch them rather than point at a file that does not exist.
ROUTE_ASSETS = {"/branding.css": "branding.css", "/sw.js": "sw.js"}
MEASURE = """
<script>
window.__measure = function () {
var de = document.scrollingElement || document.documentElement;
var small = [];
document.querySelectorAll(
'button, a.btn, a.nav-item, .tabs__tab, input, select, [role=tab]'
).forEach(function (el) {
var r = el.getBoundingClientRect();
if (!r.width || !r.height) return; /* hidden */
if (el.closest('[hidden]')) return;
/* A `.visually-hidden` radio is 1x1 on purpose -- the <label> beside it is
the target, and that one is measured. Counting the input reports five
failures on a settings page whose tabs are all 44px. */
if (el.classList.contains('visually-hidden')) return;
/* Inline text inside a sentence is not a tap target in the sense this is
checking; it is a word you can also click. */
if (getComputedStyle(el).display === 'inline') return;
if (r.height < 40 || r.width < 40) {
small.push({
tag: el.tagName.toLowerCase(),
cls: el.className && el.className.toString().slice(0, 60),
label: (el.getAttribute('aria-label') || el.textContent || '').trim().slice(0, 30),
w: Math.round(r.width), h: Math.round(r.height)
});
}
});
var wide = [];
document.querySelectorAll('body *').forEach(function (el) {
var r = el.getBoundingClientRect();
if (r.right > window.innerWidth + 1 || r.left < -1) {
wide.push({
tag: el.tagName.toLowerCase(),
cls: el.className && el.className.toString().slice(0, 60),
left: Math.round(r.left), right: Math.round(r.right)
});
}
});
/* Which element is actually making the document bigger than the window.
"the page over-scrolls" is not actionable; "`.shell` is 1756px tall in an
844px window" is. Reported for both axes, deepest first, because the
outermost offender is usually just the ancestor of the real one. */
/* Content taller than the window inside something built to scroll is not
overflow, it is the point. So an element counts only when nothing between
it and the root can scroll in that axis -- otherwise every long settings
page reports its own cards as a bug and the signal is lost in them. */
function contained(el, axis) {
var prop = axis === 'y' ? 'overflowY' : 'overflowX';
for (var n = el.parentElement; n && n !== document.documentElement; n = n.parentElement) {
var o = getComputedStyle(n)[prop];
if (o === 'auto' || o === 'scroll' || o === 'hidden') return true;
}
return false;
}
function culprits(axis) {
var found = [];
document.querySelectorAll('body, body *').forEach(function (el) {
if (contained(el, axis)) return;
var r = el.getBoundingClientRect();
var over = axis === 'y'
? r.bottom - window.innerHeight
: r.right - window.innerWidth;
if (over > 1) {
found.push({
tag: el.tagName.toLowerCase(),
cls: (el.className && el.className.toString().slice(0, 50)) || '',
over: Math.round(over),
size: Math.round(axis === 'y' ? r.height : r.width),
pos: getComputedStyle(el).position,
id: el.id || '',
parent: el.parentElement ? (el.parentElement.tagName.toLowerCase() + '.' +
(el.parentElement.className || '').toString().slice(0, 30)) : '',
html: el.outerHTML.slice(0, 120)
});
}
});
return found.sort(function (a, b) { return b.over - a.over; }).slice(0, 8);
}
var shell = document.querySelector('.shell');
return {
docScrollH: de.scrollHeight,
innerH: window.innerHeight,
docScrollW: de.scrollWidth,
innerW: window.innerWidth,
bodyScrollH: document.body.scrollHeight,
shellH: shell ? Math.round(shell.getBoundingClientRect().height) : null,
shellW: shell ? Math.round(shell.getBoundingClientRect().width) : null,
tallCulprits: culprits('y'),
wideCulprits: culprits('x'),
/* The invariant: the application shell fills the window and the DOCUMENT
never scrolls *for the reader*. A document taller than the window is the
/settings bug -- but only when the reader can actually move it. `overflow:
hidden` blocks a wheel and a finger while still permitting an assignment
to scrollTop, so a page whose shell clips a tall descendant reports a
scrollHeight of thousands and scrolls for nobody. /admin/prompts does
exactly that, and reading the raw height called it a bug four times. */
documentScrolls:
de.scrollHeight > window.innerHeight + 1 &&
["visible", "auto", "scroll"].indexOf(
getComputedStyle(document.documentElement).overflowY
) !== -1,
scrollsSideways: de.scrollWidth > window.innerWidth + 1,
smallTargets: small.slice(0, 40),
smallCount: small.length,
overflowing: wide.slice(0, 20),
overflowCount: wide.length
};
};
/* Nothing is appended to the page itself. The first version of this harness
did exactly that, and the div it added was 960px tall -- so the very first
run reported that /chat over-scrolled by 960px on a phone, which was a
finding entirely about the instrument. The frame outside reads __measure()
across the boundary instead, and the page is left exactly as served. */
</script>
"""
def build_client():
import lembas.config as config_mod
tmp = Path(tempfile.mkdtemp(prefix="lembas-shoot-"))
config_mod.settings.data_dir = tmp
config_mod.settings.secret_key = "x" * 43
from fastapi.testclient import TestClient
from lembas.db.session import init_db, session_scope
from lembas.main import create_app
init_db()
app = create_app()
client = TestClient(app)
client.post(
"/auth/register",
data={"name": "Frodo", "email": "f@example.com", "password": "mellonmellon"},
follow_redirects=False,
)
from lembas.db.models import Connection, Model
with session_scope() as db:
connection = Connection(
name="local", base_url="http://127.0.0.1:1", api_key_encrypted=""
)
db.add(connection)
db.flush()
for name in ("gemma4-moe", "qwen3-coder"):
db.add(Model(connection_id=connection.id, model_id=name, display_name=name))
return client
def rewrite(html: str, client, assets: Path) -> str:
"""Point every asset at a file on disk, and prove none was missed."""
for route, name in ROUTE_ASSETS.items():
response = client.get(route)
if response.status_code == 200:
(assets / name).write_text(response.text)
html = re.sub(
r'(?:http://testserver)?/static/([^"\'?\s>]+)(\?[^"\'\s>]*)?',
lambda m: f"file://{STATIC}/{m.group(1)}",
html,
)
html = re.sub(
r'(?:http://testserver)?/branding\.css(\?[^"\'\s>]*)?',
f"file://{assets}/branding.css",
html,
)
# Fail loudly, and only about things that decide how the page LOOKS: every
# `src`, and `href` on a <link>. An `href` on an anchor is a destination,
# not an asset -- flagging those makes the guard cry wolf on every page and
# a guard nobody believes is worse than none.
leftovers = re.findall(r'<link\b[^>]*\bhref="([^"]+)"', html)
leftovers += re.findall(r'\bsrc="([^"]+)"', html)
blocking = [
url
for url in leftovers
if url.startswith(("/", "http://testserver"))
and not url.startswith(("/branding/", "/manifest", "/sw.js"))
]
if blocking:
raise SystemExit(
"UNREWRITTEN ASSET URLS -- this would measure an unstyled document: "
f"{sorted(set(blocking))[:8]}"
)
# And that what they were rewritten *to* is really there. A rewrite that
# matches and produces a dead path is indistinguishable, from inside the
# browser, from no stylesheet at all -- and it is the failure that actually
# happened, twice.
missing = [
url
for url in re.findall(r'(?:href|src)="file://([^"?]+)"', html)
if not Path(url).exists()
]
if missing:
raise SystemExit(f"REWRITTEN TO NOTHING -- still an unstyled document: {missing[:5]}")
# The one-time notifications offer is a modal over the very page we came
# to measure, and it is gated on a localStorage key. Set it in the head, so
# it runs before the deferred script that reads it.
quiet = (
"<script>try{localStorage.setItem('lembas-notifications-asked','1');}"
"catch(e){}</script>"
)
return html.replace("</head>", quiet + MEASURE + "</head>", 1)
def shoot(client, path: str, width: int, height: int, theme: str, outdir: Path) -> dict:
"""One page, at one size, in one theme.
The page is rendered inside an <iframe> of exactly the target size rather
than into a window of it, because headless Chromium refuses to make a window
narrower than about 500px -- ask for 390 and you get 500, and every
measurement is then of a layout no phone will ever produce. A media query
inside an iframe evaluates against the iframe's own viewport, so this is the
real thing: `width: 390px` on the frame is a 390px viewport inside it.
"""
response = client.get(path)
if response.status_code != 200:
raise SystemExit(f"{path} -> HTTP {response.status_code}")
assets = outdir / "assets"
assets.mkdir(parents=True, exist_ok=True)
html = response.text.replace('data-theme="moria"', f'data-theme="{theme}"')
html = rewrite(html, client, assets)
slug = f"{path.strip('/').replace('/', '-') or 'root'}-{theme}-{width}x{height}"
page = outdir / f"{slug}.html"
page.write_text(html)
frame = outdir / f"{slug}-frame.html"
frame.write_text(
"<!doctype html><meta charset=utf-8>"
"<style>html,body{margin:0;background:#888}"
f"iframe{{width:{width}px;height:{height}px;border:0;display:block}}</style>"
f'<iframe id="f" src="{page.name}"></iframe>'
"<div id=\"__measurements\"></div>"
"<script>"
"window.addEventListener('load',function(){setTimeout(function(){"
"var w=document.getElementById('f').contentWindow;"
"document.getElementById('__measurements').textContent="
"JSON.stringify(w.__measure?w.__measure():{error:'no __measure -- the page did not load'});"
"},600);});"
"</script>"
)
shot = outdir / f"{slug}.png"
common = [
CHROMIUM, "--headless", "--no-sandbox", "--disable-gpu",
"--allow-file-access-from-files", "--hide-scrollbars",
"--force-device-scale-factor=1",
f"--window-size={max(width, 520)},{height + 40}",
"--virtual-time-budget=4000",
]
subprocess.run(common + [f"--screenshot={shot}", f"file://{frame}"],
capture_output=True, timeout=120)
dom = subprocess.run(common + ["--dump-dom", f"file://{frame}"],
capture_output=True, text=True, timeout=120).stdout
match = re.search(r'id="__measurements">(.*?)</div>', dom, re.S)
if not match or not match.group(1).strip():
raise SystemExit(f"no measurements for {slug} -- the frame did not report")
data = json.loads(match.group(1))
if "error" in data:
raise SystemExit(f"{slug}: {data['error']}")
data["page"] = slug
if data["innerW"] != width:
raise SystemExit(
f"{slug}: measured a {data['innerW']}px viewport, asked for {width}px"
)
return data
# The two the manifest asks for. Without them Chrome on Android falls back to
# the one-line mini-infobar instead of the install dialog with a name, an icon
# and a picture in it -- which is the difference between an install somebody
# chooses and one they dismiss without reading.
MANIFEST_SHOTS = (
("screenshot-narrow.png", 390, 844, "narrow"),
("screenshot-wide.png", 1280, 800, "wide"),
)
def manifest_screenshots(client, outdir: Path) -> None:
"""Capture the two, straight into static/img/ where the manifest names them.
A browser capture rather than something `build_artwork.py` draws: the point
of a screenshot is that it is what the application actually looks like, and
an illustration of what it looks like is the one thing it must not be.
"""
try:
from PIL import Image
except ImportError: # pragma: no cover - design-time tool
raise SystemExit("pillow is needed to crop the frame off a screenshot") from None
for name, width, height, _form in MANIFEST_SHOTS:
shoot(client, "/chat", width, height, "moria", outdir)
slug = f"chat-moria-{width}x{height}.png"
target = STATIC / "img" / name
# Cropped to the iframe, which sits at the origin of a zero-margin
# wrapper. The capture is of the *outer* document, so without this the
# screenshot carries the harness's own readout along its bottom edge
# and a strip of grey beside it -- and a manifest screenshot is the one
# picture of this application most people will ever see.
with Image.open(outdir / slug) as shot:
shot.crop((0, 0, width, height)).save(target)
print(f"wrote {target.relative_to(REPO)}")
def main() -> None:
if not CHROMIUM:
raise SystemExit("no chromium")
if "--manifest-screenshots" in sys.argv:
outdir = Path(sys.argv[1]) if len(sys.argv) > 2 else Path(tempfile.mkdtemp())
outdir.mkdir(parents=True, exist_ok=True)
manifest_screenshots(build_client(), outdir)
return
outdir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("/tmp/lembas-shoot/out")
outdir.mkdir(parents=True, exist_ok=True)
paths = sys.argv[2].split(",") if len(sys.argv) > 2 else ["/chat", "/settings"]
sizes = [(390, 844), (360, 640), (1280, 800)]
themes = ["moria", "shire"]
client = build_client()
results = []
for path in paths:
for width, height in sizes:
for theme in themes:
results.append(shoot(client, path, width, height, theme, outdir))
(outdir / "results.json").write_text(json.dumps(results, indent=2))
for r in results:
flags = []
if r["documentScrolls"]:
flags.append(f"DOC-SCROLLS({r['docScrollH']}>{r['innerH']})")
if r["scrollsSideways"]:
flags.append(f"SIDEWAYS({r['docScrollW']}>{r['innerW']})")
if r["overflowCount"]:
flags.append(f"overflow:{r['overflowCount']}")
if r["smallCount"]:
flags.append(f"small-targets:{r['smallCount']}")
print(f"{r['page']:44} {' '.join(flags) or 'clean'}")
if __name__ == "__main__":
main()