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>
403 lines
16 KiB
Python
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()
|