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>
This commit is contained in:
+129
-2
@@ -26,6 +26,7 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from functools import cache
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
@@ -128,8 +129,65 @@ window.__measure = function () {
|
||||
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,
|
||||
@@ -167,6 +225,42 @@ window.__measure = function () {
|
||||
"""
|
||||
|
||||
|
||||
@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
|
||||
|
||||
@@ -198,6 +292,17 @@ def build_client():
|
||||
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
|
||||
|
||||
|
||||
@@ -219,6 +324,20 @@ def rewrite(html: str, client, assets: Path) -> str:
|
||||
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
|
||||
@@ -249,14 +368,15 @@ def rewrite(html: str, client, assets: Path) -> str:
|
||||
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
|
||||
# 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)
|
||||
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:
|
||||
@@ -391,6 +511,13 @@ def main() -> None:
|
||||
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"]:
|
||||
|
||||
Reference in New Issue
Block a user