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:
2026-09-26 20:08:10 +00:00
co-authored by Claude Opus 5
parent a16510aba8
commit ab32c68a8f
18 changed files with 714 additions and 160 deletions
+129 -2
View File
@@ -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"]: