Files
LLeMbas/scripts/shoot.py
T
HomerandClaude Opus 5 ab32c68a8f A crowd you can find, and a phone 65px too narrow
Two reports against 1.6.0 and 1.7.0, both correct.

The crowd worked end to end and was, in practice, not there: the picker was
behind the ⋯ menu of a chat that already existed, and the switch was a card on
the Agents page, which made it read as an agent-chat feature. The picker is now
a button in the composer toolbar on both screens that include it, and on the
new-chat screen the choice rides along with the first message, so a chat can
start as a crowd instead of having to be converted into one. The instance
switch has its own page.

The width bug was the suggestion cards, exactly as reported. `.suggestions`
rendered 455px inside a 366px column, and the tree's standing rule applied on
its own made it worse -- 428px to 455px. A grid item carries `min-width: auto`,
which is a min-content floor, and a floor beats `width: 100%`; the floor is
measured while the percentage is indefinite, so `min(100%, …)` alone sends the
track to a card's max-content. Both halves now go on all four auto-fit grids,
and a test refuses either alone.

It survived four releases of narrow-width checking because the harness never
rendered that screen: `TestClient(app)` runs no lifespan outside a `with` block,
so the startup-seeded cards were missing from every shot ever taken of it. And
its overflow check skipped anything inside a scroller -- right for a table in
its own scroller, blind to the scroller itself, which `overflow-y: auto` makes
scroll sideways too. Both fixed; it now names the box and the child to blame.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 20:08:10 +00:00

530 lines
23 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 functools import cache
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);
}
/* --- A box that scrolls sideways when nobody asked it to -----------------
The blind spot that hid the suggestions bug through forty measurements.
`.suggestions` rendered 455px wide inside a 390px `.thread-scroll`, and
every check above looked straight past it: `culprits('x')` skips anything
with a scrollable ancestor -- correct for a table inside its own scroller,
wrong for the scroller itself -- and `scrollsSideways` stayed false because
`.thread-scroll` absorbed the overflow instead of the document.
"Authored" is the distinction that makes this reportable rather than noise.
The tree's rule is that anything wide gets its OWN scroller, so a wrapper
carrying `overflow-x: auto` in a stylesheet is right. A box given only
`overflow-y: auto` scrolls sideways as well, because the other axis then
computes to `auto` -- and that is always a bug. Computed style cannot tell
those apart, both being `auto`, so the rules that say it are read off the
stylesheets -- in Python, by `authored_sideways()` below, and not from the
CSSOM here: a stylesheet loaded over `file://` is a foreign origin for
`cssRules` even with `--allow-file-access-from-files`, and every sheet
throws. That silently found *nothing authored*, which turns this check into
"every vertical scroller is a bug" -- so the list arriving empty is a hard
error rather than a clean run. */
var sidewaysAuthors = __SIDEWAYS_AUTHORS__;
function authoredSideways(el) {
if (el.style.overflowX || el.style.overflow) return true;
for (var i = 0; i < sidewaysAuthors.length; i++) {
try { if (el.matches(sidewaysAuthors[i])) return true; } catch (e) { /* :has() etc */ }
}
return false;
}
var sideways = [];
document.querySelectorAll('body, body *').forEach(function (el) {
var ox = getComputedStyle(el).overflowX;
if (ox !== 'auto' && ox !== 'scroll') return;
if (el.scrollWidth <= el.clientWidth + 1) return;
if (authoredSideways(el)) return;
/* Which child is doing it. "`.thread-scroll` scrolls sideways" is not
actionable; "`.suggestions` is 455px inside its 390px" is. */
var worst = null;
el.querySelectorAll('*').forEach(function (kid) {
var over = kid.getBoundingClientRect().width - el.clientWidth;
if (over > 1 && (!worst || over > worst.over)) {
worst = {tag: kid.tagName.toLowerCase(),
cls: (kid.className && kid.className.toString().slice(0, 50)) || '',
w: Math.round(kid.getBoundingClientRect().width),
over: Math.round(over)};
}
});
sideways.push({tag: el.tagName.toLowerCase(),
cls: (el.className && el.className.toString().slice(0, 50)) || '',
scrollW: el.scrollWidth, clientW: el.clientWidth,
widest: worst});
});
var shell = document.querySelector('.shell');
return {
sidewaysScrollers: sideways.slice(0, 8),
sidewaysCount: sideways.length,
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>
"""
@cache
def authored_sideways() -> tuple[str, ...]:
"""Selectors whose rules really do ask for horizontal scrolling.
The tree's rule is that anything wide gets its own scroller, so these are
the correct ones: a table wrapper, a code block, the tab bar. Everything
else that scrolls sideways is `overflow-y: auto` dragging the other axis
along with it, which is always a bug and is what `.suggestions` did.
"""
selectors: list[str] = []
for path in sorted((STATIC / "css").glob("*.css")):
text = re.sub(r"/\*.*?\*/", "", path.read_text(), flags=re.S)
# Innermost blocks only: `[^{}]*` cannot cross a brace, so an `@media`
# prelude never matches and the rules inside it do.
for prelude, body in re.findall(r"([^{}]*)\{([^{}]*)\}", text):
wants = False
for declaration in body.split(";"):
name, _, value = declaration.partition(":")
name, value = name.strip().lower(), value.strip().lower()
if name not in ("overflow", "overflow-x") or not value:
continue
# `overflow: hidden auto` is x then y, so the first word is ours;
# `overflow: auto` is both.
wants = wants or value.split()[0] in ("auto", "scroll")
if not wants:
continue
selectors += [
part.strip()
for part in prelude.split(",")
if part.strip() and not part.strip().startswith("@")
]
if not selectors:
raise SystemExit("read no horizontal-overflow rules -- the sideways check would cry wolf")
return tuple(selectors)
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))
# 🚨 The suggestion cards are seeded by the startup hook, and `TestClient(app)`
# runs a lifespan only inside a `with` block -- so every shot of the new-chat
# screen ever taken by this script was of a page with its cards missing. That
# is how a grid 65px wider than a phone survived forty measurements. Seeded
# here rather than by entering the lifespan, which would also start the
# schedule ticker and rehydrate background jobs inside a screenshot run.
from lembas.services.suggestions import seed_defaults as seed_suggestions
with session_scope() as db:
seed_suggestions(db)
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,
)
# Anything else the *application* serves rather than mounts. Model avatars live
# under `/uploads/models/…`, which is a route behind auth -- so they cannot be
# pointed at a file on disk and have to be fetched through the client like
# `/branding.css` above. A real instance has them and a fixture does not, which
# is exactly the difference that makes a page measured here unlike the page
# somebody is looking at.
for url in sorted({*re.findall(r'\bsrc="(/(?:uploads|branding)/[^"?]+)"', html)}):
response = client.get(url)
if response.status_code != 200:
continue
name = "fetched-" + url.strip("/").replace("/", "-")
(assets / name).write_bytes(response.content)
html = html.replace(f'src="{url}"', f'src="file://{assets}/{name}"')
# 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>"
)
measure = MEASURE.replace("__SIDEWAYS_AUTHORS__", json.dumps(list(authored_sideways())))
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']})")
for s in r.get("sidewaysScrollers", []):
widest = s["widest"]
blame = f"<{widest['tag']}.{widest['cls']} {widest['w']}px" if widest else ""
flags.append(
f"SCROLLER-SIDEWAYS({s['tag']}.{s['cls']} "
f"{s['scrollW']}>{s['clientW']}{blame})"
)
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()