A phone, and how much of this could not be used on one
The sidebar was a 280px panel laid over the page below the phone breakpoint, opened from first paint, with the only control that closed it underneath it -- and that control existed on /chat and on none of the seven other pages carrying a sidebar, Settings included. It starts closed at that width now, slides, dims the page behind it, and closes by tapping beside it, by Escape, or by its own button, which is inside the drawer where it can be reached. Everything a finger has to hit was 36px, or 28 for renaming a chat, every action on a message and every panel's close button. Raising --control-h under a coarse pointer is the only fix that reaches all forty of them, which is what that token is for. The row and message actions were also hover-only, so on a phone they did not exist at all. Installing: the splash and the browser chrome follow the instance's theme rather than always being Moria's near-black; there are screenshots, so the install offer is a dialog rather than a one-line bar; a new release no longer takes over a page somebody is reading; the notification badge is a silhouette rather than a grey square; and a browser rotating its own subscription no longer ends notifications for good. Every request now says it is happening -- nothing did before, so anything slower than a few milliseconds looked like a click that had not registered. A chat can be archived. The column has been filtered on in four places since folders arrived and written by nothing, which is what made it look built. chat.css may contain media queries. The ban protected the composer toolbar from being "fixed" with a breakpoint; that guarantee is asserted directly now, and the old test would have passed a version of the file that wrapped the toolbar without one. scripts/shoot.py is the instrument all of this was found with: it renders a page through TestClient into a real headless browser at a real size and refuses to run if an asset URL was left pointing at testserver. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
"""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
|
||||
SRC = REPO / "src"
|
||||
sys.path.insert(0, str(SRC))
|
||||
|
||||
STATIC = SRC / "lembas/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. A document taller than the window is the /settings bug. */
|
||||
documentScrolls: de.scrollHeight > window.innerHeight + 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]}"
|
||||
)
|
||||
|
||||
# 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()
|
||||
Reference in New Issue
Block a user