fix(as-S3): phone layouts — nothing overprints, no word set narrower than itself

From the anti-slop run (design-dev, 2026-09-28). Measured in a real browser
at 390x844 (tests/test_antislop_browser.py), because a layout claim read off
a stylesheet is a guess.

- Bench rows wrap at <=600px (state + name/URL, then who/when/actions); the
  name's column was squeezed to ~53px and overprinted the owner and date.
- The board head and the benches head drop their note under the count, so
  "33 links · 1 pinned" / "3 benches" keep one line.
- An inline doc's bar wraps: the name takes the full width and breaks only
  where it must; it was set one word wide.
- A file tile's download link starts below the ordinal badge.
- The review and compare stages drop the tagline at <=600px (the server
  marks them `page-stage` on <html>), so the header is one line; the Desk
  keeps its tagline (the test's negative control).

Not changed, with reasons in the contract: `.vname` already ellipsises, and
the filmstrip's clipped edge frame is the scroller's "more" cue.
Contract: as_antislop S3. Falsifiers: antislop.toml 41/41 proved (S1-S3);
all 12 tables 321/321 proved on this tree.
This commit is contained in:
vh
2026-09-28 13:30:31 -07:00
parent 54f3531833
commit 44b80e6d9a
6 changed files with 216 additions and 3 deletions
+25 -1
View File
@@ -117,6 +117,10 @@
.brand .dot{width:8px;height:8px;border-radius:var(--radius-pill);background:var(--accent);
box-shadow:var(--glow-armed)}
.brand .name{font-weight:650;font-size:var(--size-h2);letter-spacing:var(--tracking-h)}
/* as S3: on the review and compare stages a phone keeps the header to one
line (brand + theme); the server marks those pages on <body> (<html> carries
data-booth, which the reveal scripts and their tests pin exactly) */
@media (max-width:600px){body.page-stage .topbar .tagline{display:none}}
.tagline{font-family:var(--font-mono);font-size:var(--size-micro);font-weight:500;
letter-spacing:var(--tracking-caps);text-transform:uppercase;color:var(--text-muted)}
/* THE THEME TOGGLE (r2b D3): a quiet three-way segment at the far end of the
@@ -566,6 +570,8 @@
.item img,.item video{width:100%;height:auto;display:block;background:var(--surface-sunken)}
.item audio{width:100%;margin:20px 14px 6px;max-width:calc(100% - 28px)}
.item .dl{padding:22px 14px;font-family:var(--font-mono);font-size:var(--size-sm);word-break:break-all}
/* as S3: the ordinal badge sits in the top-left corner, so the link starts below it */
.item > .ord ~ .dl{padding-top:44px}
/* The caption is the poster's prose (up to CAPTION_MAX), so it is SET TO BE
READ, in the speaking voice, at the tile's full measure; the controls are
records, stay mono, and wrap onto their own row at the right edge — the
@@ -701,6 +707,11 @@
.doc-chevron{color:var(--text-faint);transition:transform var(--dur-1) var(--ease-out);font-size:10px}
.doc-inline[open] > .doc-bar .doc-chevron{transform:rotate(90deg)}
.doc-name{color:var(--text-heading);word-break:break-all}
/* as S3: at phone width the name takes the bar's full width and breaks only
where it must; the actions wrap under it (squeezed, it was one word wide) */
@media (max-width:600px){.doc-inline > .doc-bar{flex-wrap:wrap;row-gap:4px}
.doc-name{flex:1 1 calc(100% - 3rem);word-break:normal;overflow-wrap:anywhere}
.doc-spacer{display:none}}
.doc-spacer{flex:1}
.doc-act{color:var(--text-muted);text-decoration:none;padding:2px 6px;border-radius:var(--radius-sm);
font-size:13px;line-height:1;background:none;border:0;cursor:pointer;font-family:inherit}
@@ -930,6 +941,10 @@
border-bottom:1px solid var(--border-subtle);background:var(--surface-raised)}
.board-selall{display:inline-flex;align-items:center;cursor:pointer;flex:0 0 auto}
.board-title{font-weight:600;font-size:var(--size-sm);color:var(--text-heading)}
/* as S3: at phone width the note drops under the count, which keeps its line */
@media (max-width:600px){.board-head{flex-wrap:wrap}.board-title{white-space:nowrap}
.board-note{order:5;flex:1 1 100%}
.bench-head{flex-wrap:wrap}.bench-title{white-space:nowrap}.bench-note{flex:1 1 100%}}
.board-note{font-family:var(--font-mono);font-size:var(--size-caption);color:var(--text-muted)}
/* Bulk delete: quiet until something is selected. Once rows are ticked it
is ARMED with the hazard stripe — device 2, earned by the selection. */
@@ -1009,6 +1024,15 @@
.bench-rm{font-size:14px;padding:3px 8px;background:none;border-color:transparent;color:var(--text-muted)}
.bench-rm:hover{background:var(--danger);border-color:var(--danger);color:var(--danger-contrast)}
.bench-add{display:flex;gap:8px;padding:10px 14px;border-top:1px solid var(--border-subtle);background:var(--surface-raised)}
/* as S3: at phone width a row WRAPS rather than squeezing. The state word and
the bench (name over URL) take line one; who, when and the actions take line
two, indented under the name. Squeezed, they overprinted each other. */
@media (max-width:600px){
.bench-row{flex-wrap:wrap;row-gap:4px}
.bench-main{flex:1 1 calc(100% - 5.6rem - 10px)}
.bench-meta{flex-direction:row;align-items:baseline;gap:8px;margin-left:calc(5.6rem + 10px)}
.bench-acts{margin-left:auto}
}
.bench-add input[type=url]{flex:2;min-width:0}
.bench-add input[type=text]{flex:1;min-width:0}
@@ -1030,7 +1054,7 @@
}
</style>
</head>
<body>
<body{% block body_attrs %}{% endblock %}>
<header class="topbar">
<a class="brand" href="/"><span class="dot"></span><span class="name">The&nbsp;Booth</span></a>
<span class="tagline">ephemeral media · auto-wipes {{ ttl_hours }}h · kept boards don't</span>
+1
View File
@@ -3,6 +3,7 @@
{# data-booth: without it Reveal all's script and the head script's reveal
restore both bail (r3 C6). #}
{% block html_attrs %} data-booth="{{ name }}"{% endblock %}
{% block body_attrs %} class="page-stage"{% endblock %}
{# COMPARE (R3). Two items of the review ring side by side, one judgment each:
flag the winner. The pair is two rels in the URL, always (INV-1); the active
side and the linked stepping ride the URL as view state. Everything a mark
+1
View File
@@ -1,6 +1,7 @@
{% extends "base.html" %}
{% block title %}{{ file }} · {{ name }} · The Booth{% endblock %}
{% block html_attrs %} data-booth="{{ name }}"{% endblock %}
{% block body_attrs %} class="page-stage"{% endblock %}
{# THE REVIEW (R2 C6). One media item at full size — image, video or audio —
with the judgment on screen beside it, the whole set as a filmstrip below and
the tape above. Docs keep doc.html. Everything a mark can change is a
+21 -2
View File
@@ -13,6 +13,7 @@ touches:
- "booth/app.py (filters)"
- "booth/templates/_marks.html, _ask_inline.html, booth.html (S1)"
- "booth/templates/base.html, view.html; booth/static/embed.js (S2)"
- "booth/templates/base.html, view.html, compare.html; tests/test_antislop_browser.py (S3)"
- "tests/test_antislop.py; tests/mutations/antislop.toml"
assumptions:
- "ONE VIEWER, on this box: local time is the operator's time (US Pacific), as the existing date filters already assume."
@@ -89,10 +90,28 @@ The run found that the Booth is sound on desktop and has a set of problems a vie
- **Folded after the first gate run:** three more labels were below the 11px floor, and they are now `--size-micro` like the rest. They are the flagged tray's number (10px), the tile's "flagged" stamp (10.5px) and compare's A/B badge (9.5px). The report's list had named only the film numbers. *Falsifiable:* the same computed-style test, and three more rows.
- **Folded from the heid bug-hunt.** The embed's ask title no longer fades either (`opacity:.62` on `.bk-ask-title` contradicted "nothing fades", Q9). The claims above are also held on the browser's computed style (`tests/test_antislop_s2_browser.py`): a stylesheet grep cannot see a later rule in the cascade (`font-size:1px`, `color:transparent`, `filter:grayscale`, a placeholder at `opacity:0`), and the browser can.
## S3 to S6
## S3 — phone layouts
**The rule:** at phone width (≤600px), no text overprints other text, and no single word is set in a column narrower than itself. These claims are measured in a real browser at 390×844 (`tests/test_antislop_browser.py`), because a layout claim read off a stylesheet is a guess.
- **Bench rows wrap instead of squeezing.** At ≤600px a row wraps. The state word and the bench (name over URL) take the first line; who, when, the state buttons and × take the second, indented under the name.
- *Falsifiable:* without the wrap, the name, owner and date boxes intersect: `test_bench_rows_do_not_overprint_on_a_phone`.
- **An inline doc's name keeps its line.** At ≤600px the doc bar wraps. The name takes the full width and breaks only where it must (`overflow-wrap:anywhere`, not `word-break:break-all`); the actions wrap under it.
- *Falsifiable:* `test_doc_name_keeps_a_readable_line_on_a_phone`.
- **The link board's headers stay compact.** At ≤600px the note drops under the count, so the count ("33 links · 1 pinned", "3 benches") stays on one line, in both the board head and the benches head.
- *Falsifiable:* `test_board_head_stays_compact_on_a_phone`.
- **A file tile's number clears its download link.** The ordinal badge sits in the tile's top-left corner, so a file tile's link starts below it.
- *Falsifiable:* `test_file_tile_number_clears_the_download_link`.
- **The review and compare pages keep their header to one line on a phone.** At ≤600px they drop the tagline (every other page keeps it), so the header is the brand plus the theme toggle and the stage starts near the top. The class that scopes this is set by the server on `<body>` (`<html>` carries `data-booth`, which the reveal scripts and their tests pin exactly).
- *Falsifiable:* `test_review_header_is_one_line_on_a_phone`, which also checks that the Desk keeps its tagline.
**Measured, not changed** (the detector's rows that are misreads here):
- `.vname` already ellipsises; the detector measures the clipped inner width.
- The filmstrip clips its next frame at the edge on purpose: the clipped frame is the "there is more" cue of a horizontal scroller.
## S4 to S6
These are added as each slice is staged:
- S3, phone layouts;
- S4, reading measure;
- S5, interaction and screen readers;
- S6, the operator's rulings (tagline, needs-you stripe, matte brand dot, the Wipe-now stripe on `::before`).
+51
View File
@@ -262,3 +262,54 @@ file = "booth/templates/base.html"
test = "tests/test_antislop_s2_browser.py::test_labels_and_sentences_render_at_their_floors"
old = ''' font:700 var(--size-micro)/1.2 var(--font-mono);background:var(--accent);color:var(--accent-contrast)}'''
new = ''' font:700 9.5px/1.2 var(--font-mono);background:var(--accent);color:var(--accent-contrast)}'''
# ---- S3: phone layouts (measured in a real browser at 390x844)
[[mutation]]
label = "S3 bench rows squeeze instead of wrapping"
file = "booth/templates/base.html"
test = "tests/test_antislop_browser.py::test_bench_rows_do_not_overprint_on_a_phone"
old = ''' .bench-row{flex-wrap:wrap;row-gap:4px}'''
new = ''' .bench-row{row-gap:4px}'''
[[mutation]]
label = "S3 the board head's phone rule gone (the count wraps)"
file = "booth/templates/base.html"
test = "tests/test_antislop_browser.py::test_board_head_stays_compact_on_a_phone"
old = ''' @media (max-width:600px){.board-head{flex-wrap:wrap}.board-title{white-space:nowrap}'''
new = ''' @media (max-width:1px){.board-head{flex-wrap:wrap}.board-title{white-space:nowrap}'''
[[mutation]]
label = "S3 the doc bar squeezes the name again"
file = "booth/templates/base.html"
test = "tests/test_antislop_browser.py::test_doc_name_keeps_a_readable_line_on_a_phone"
old = ''' @media (max-width:600px){.doc-inline > .doc-bar{flex-wrap:wrap;row-gap:4px}'''
new = ''' @media (max-width:1px){.doc-inline > .doc-bar{flex-wrap:wrap;row-gap:4px}'''
[[mutation]]
label = "S3 a file tile's link starts under the number again"
file = "booth/templates/base.html"
test = "tests/test_antislop_browser.py::test_file_tile_number_clears_the_download_link"
old = ''' .item > .ord ~ .dl{padding-top:44px}'''
new = ''''''
[[mutation]]
label = "S3 the review keeps its tagline on a phone"
file = "booth/templates/view.html"
test = "tests/test_antislop_browser.py::test_review_header_is_one_line_on_a_phone"
old = '''{% block body_attrs %} class="page-stage"{% endblock %}'''
new = ''''''
[[mutation]]
label = "S3 the tagline hidden on every page (the Desk's negative control)"
file = "booth/templates/base.html"
test = "tests/test_antislop_browser.py::test_review_header_is_one_line_on_a_phone"
old = ''' @media (max-width:600px){body.page-stage .topbar .tagline{display:none}}'''
new = ''' @media (max-width:600px){body .topbar .tagline{display:none}}'''
[[mutation]]
label = "S3 the benches head stacks its count again"
file = "booth/templates/base.html"
test = "tests/test_antislop_browser.py::test_board_head_stays_compact_on_a_phone"
old = """ .bench-head{flex-wrap:wrap}.bench-title{white-space:nowrap}.bench-note{flex:1 1 100%}}"""
new = """ }"""
+117
View File
@@ -0,0 +1,117 @@
"""S3 of the anti-slop fix slices, measured in a real browser at phone width.
A layout claim read off a stylesheet is a guess, so these open the pages in the
same real uvicorn + Chromium harness as test_flow_browser and measure boxes. At
390x844 no text may overprint other text, and no word may be set in a column
narrower than itself. Skips, never fails, when no usable Chromium exists.
"""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from test_flow_browser import _png, browser, live # noqa: E402,F401 (fixtures)
PHONE = {"width": 390, "height": 844}
def _boxes_intersect(a, b, slack=1.0):
return (min(a["r"], b["r"]) - max(a["l"], b["l"]) > slack
and min(a["b"], b["b"]) - max(a["t"], b["t"]) > slack)
_RECT = "e => { const b = e.getBoundingClientRect(); return {l: b.left, r: b.right, t: b.top, b: b.bottom, w: b.width, h: b.height}; }"
def _board(root: pathlib.Path):
from booth.benches import set_bench_state, upsert_bench
from booth.links import LINKS_FILE
b = root / "links"
b.mkdir()
(b / LINKS_FILE).write_text(
"- [talk, a fleet voice (HTTPS, trusted cert)](https://talk.example:8092/) <sub>· tts-dev · 2026-09-06 23:35</sub>\n"
"- [a second link](http://x.example/) <sub>· infra-ops · 2026-09-07 09:12</sub>\n")
for url, name, owner, state in [("http://10.100.10.50:5173/", "peedlar desk", "peedlar-dev", "live"),
("http://10.100.10.50:8931/board", "svos board", "highseat-dev", "promoted"),
("http://10.100.10.50:7788/talk", "talk demo", "booth", "retired")]:
bench, _ = upsert_bench(root, url, name, owner)
set_bench_state(root, bench.id, state)
return b
def test_bench_rows_do_not_overprint_on_a_phone(browser, live):
base, root = live
_board(root)
page = browser.new_page(viewport=PHONE)
page.goto(f"{base}/b/links/", wait_until="load")
rows = page.locator(".bench-row")
assert rows.count() == 3, "positive control: the three benches render"
for i in range(rows.count()):
row = rows.nth(i)
parts = {sel: row.locator(sel).first.evaluate(_RECT)
for sel in (".bench-state", ".bench-link", ".bench-url", ".bench-who", ".bench-when", ".bench-acts")}
names = list(parts)
for x in range(len(names)):
for y in range(x + 1, len(names)):
assert not _boxes_intersect(parts[names[x]], parts[names[y]]), \
f"row {i}: {names[x]} overprints {names[y]}: {parts[names[x]]} / {parts[names[y]]}"
main = row.locator(".bench-main").evaluate(_RECT)
assert main["w"] >= 200, f"row {i}: the name's column is squeezed to {main['w']:.0f}px"
page.close()
def test_board_head_stays_compact_on_a_phone(browser, live):
base, root = live
_board(root)
page = browser.new_page(viewport=PHONE)
page.goto(f"{base}/b/links/", wait_until="load")
for sel in (".board-title", ".bench-title"):
title = page.locator(sel).evaluate(
"e => ({h: e.getBoundingClientRect().height, lh: parseFloat(getComputedStyle(e).lineHeight) || parseFloat(getComputedStyle(e).fontSize) * 1.3})")
assert title["h"] <= title["lh"] * 1.5, f"{sel} wraps: {title}"
page.close()
def test_doc_name_keeps_a_readable_line_on_a_phone(browser, live):
base, root = live
d = root / "d"
d.mkdir()
(d / "01-information-architecture.md").write_text("# The Booth\n\nA paragraph of text.\n")
page = browser.new_page(viewport=PHONE)
page.goto(f"{base}/b/d/", wait_until="load")
name = page.locator(".doc-name").first.evaluate(_RECT)
assert name["w"] >= 240, f"the doc name is set {name['w']:.0f}px wide"
page.close()
def test_file_tile_number_clears_the_download_link(browser, live):
base, root = live
f = root / "f"
f.mkdir()
(f / "ldp-demo-kit-2026-09-23-18b8cee.zip").write_bytes(b"PK\x05\x06" + b"\x00" * 18)
for vp in (PHONE, {"width": 1280, "height": 800}):
page = browser.new_page(viewport=vp)
page.goto(f"{base}/b/f/", wait_until="load")
ord_box = page.locator(".item > .ord").first.evaluate(_RECT)
text_box = page.locator(".item .dl").first.evaluate(
"e => { const r = document.createRange(); r.selectNodeContents(e); const b = r.getBoundingClientRect();"
" return {l: b.left, r: b.right, t: b.top, b: b.bottom, w: b.width, h: b.height}; }")
assert not _boxes_intersect(ord_box, text_box), (vp, ord_box, text_box)
page.close()
def test_review_header_is_one_line_on_a_phone(browser, live):
base, root = live
g = root / "g"
g.mkdir()
(g / "a.png").write_bytes(_png(64, 48))
(g / "b.png").write_bytes(_png(64, 48))
page = browser.new_page(viewport=PHONE)
for url in (f"{base}/b/g/view?f=a.png", f"{base}/b/g/compare?a=a.png&b=b.png"):
page.goto(url, wait_until="load")
assert page.locator(".topbar .tagline").evaluate("e => getComputedStyle(e).display") == "none", url
assert page.locator(".topbar").evaluate("e => e.getBoundingClientRect().height") <= 64, url
page.goto(f"{base}/", wait_until="load")
assert page.locator(".topbar .tagline").evaluate("e => getComputedStyle(e).display") != "none", \
"the Desk keeps its tagline (negative control)"
page.close()