feat(desk): the Desk row, booth dates, and the theme toggle (r2b merge 2: D1 + D1b + D3)

Operator rulings, 2026-09-23.

D1, the Desk row:
- Kept vs ephemeral reads at a glance: an always-visible lifetime pill in
  the right column (sage ★ kept, amber held, ◷ counting down).
- The facts line is facts only.
- zip / keep|release / wipe are one cluster, with zip out of the middle.
  Where a real hover exists it floats over the preview strip (covering
  pictures, never information), appears on hover or keyboard focus, and
  takes no room. Anywhere else (touch, any coarse pointer) it is the
  row's last line, visible, with 32px controls. × hides too (the operator
  answered yes).
D1b: "created 12 Sep" (filesystem birth time; nothing when unknown) and
  "updated 5d ago" (the content clock), as <time> facts on the row and in
  the booth header, from one macro and one clock per page.
D3, the theme toggle: System · Light · Dark in the top bar.
- Stored in localStorage and applied in <head> before any stylesheet.
- System removes data-theme, so the OS query follows the OS live, with
  no listener.
- The token sheet is re-vendored at the same SVOS SHA with a scoping-only
  transform (155 declarations, the same set, both directions), so forced
  themes win over the OS and high contrast follows the theme in effect.
- The ask chrome inside verbatim pages follows the choice through
  data-bk-theme on our own fragments, live across tabs. The host page's
  <html> is never touched.

Declared test changes:
- two row tests replaced;
- the wipe-dialog test hovers first;
- four r2_flow rows retired, with successors in r2b.toml (45/45).
785 passed.
This commit is contained in:
vh
2026-09-23 19:25:32 -07:00
parent ca0641f55b
commit 436d234ca0
12 changed files with 868 additions and 185 deletions
+5 -38
View File
@@ -11,6 +11,11 @@
# covers them together); and `flagged_targets`' `error is None`, since
# hydration already strips the target from a damaged mark.
#
# RETIRED (r2b D1, 2026-09-23): four rows proving the facts-line row controls
# (visible at rest, compact, on the facts line) — the operator ruled those
# controls hover-revealed over the preview strip, and their tests were replaced
# as declared in r2b's contract. Their successors are in r2b.toml.
#
# The serialization row is only a falsifier because its test HOLDS the first
# refresh in the client: localhost alone never lost the race, and the first
# draft of that test stayed green with serialization deleted.
@@ -159,26 +164,6 @@ new = '''
except FileNotFoundError:
continue'''
[[mutation]]
label = "Desk row controls hidden until hover again (opacity 0 keeps their box)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_a_rows_keep_release_and_wipe_take_no_room_of_their_own"
old = '''
.desk-facts form{display:inline;margin:0}'''
new = '''
.desk-facts form{display:inline;margin:0;opacity:0}'''
[[mutation]]
label = "Desk row renders its side column with no badge in it (a reserved gap)"
file = "booth/templates/index.html"
test = "tests/test_flow_browser.py::test_a_rows_keep_release_and_wipe_take_no_room_of_their_own"
old = '''
{% if b.marks_open or b.hold == "unreadable" or section == 'new' or b.uploaded %}
<div class="desk-side">'''
new = '''
{% if true %}
<div class="desk-side">'''
[[mutation]]
label = "the stacked Desk column is a bare 1fr (content sets its minimum)"
file = "booth/templates/base.html"
@@ -206,24 +191,6 @@ old = '''
new = '''
.desk-main{flex:1 1 auto}'''
[[mutation]]
label = "a coarse pointer gets the compact ~21px controls"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_on_a_touch_screen_the_row_controls_keep_their_tap_floor"
old = '''
.desk-facts form button{min-height:32px;min-width:32px;padding:0 10px;margin-left:6px;vertical-align:middle}'''
new = '''
.desk-facts form button{padding:0 10px;margin-left:6px;vertical-align:middle}'''
[[mutation]]
label = "the zip link may break between its glyph and its word"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_on_a_touch_screen_the_row_controls_keep_their_tap_floor"
old = '''
.desk-facts .dl-link{white-space:nowrap}'''
new = '''
.desk-facts .dl-link{}'''
[[mutation]]
label = "the wipe dialog shows the agent-made name raw (bidi, newline)"
file = "booth/templates/index.html"
+162
View File
@@ -282,3 +282,165 @@ old = '''
b = pw.chromium.launch(args=OFFLINE)'''
new = '''
b = pw.chromium.launch()'''
# ---- merge 2: D1 the Desk row, D1b dates, D3 the theme toggle
# Successors to the four r2_flow rows retired for D1. One branch is proved by
# reading, not here: a true touch LAPTOP (fine pointer + a coarse one) cannot be
# emulated — Chromium's touch emulation makes the primary pointer coarse.
[[mutation]]
label = "D1 the cluster is visible at rest"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_row_controls_take_no_room_where_a_hover_exists"
old = '''
opacity:0;pointer-events:none;transition:opacity var(--dur-1) var(--ease-out)}'''
new = '''
opacity:1;pointer-events:none;transition:opacity var(--dur-1) var(--ease-out)}'''
[[mutation]]
label = "D1 hover shows the cluster but leaves it unclickable"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_row_controls_take_no_room_where_a_hover_exists"
old = '''
.desk-row:hover .desk-acts,.desk-row:focus-within .desk-acts{opacity:1;pointer-events:auto}'''
new = '''
.desk-row:hover .desk-acts,.desk-row:focus-within .desk-acts{opacity:1}'''
[[mutation]]
label = "D1 the cluster stays in flow where a hover exists (it takes room)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_row_controls_take_no_room_where_a_hover_exists"
old = '''
.desk-acts{position:absolute;top:5px;right:5px;flex:none;gap:4px;padding:3px;'''
new = '''
.desk-acts{position:static;top:5px;right:5px;flex:none;gap:4px;padding:3px;'''
[[mutation]]
label = "D1 hover-only everywhere (no controls at all on touch)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_on_touch_the_row_controls_are_visible_in_flow_and_at_least_28px"
old = '''
.desk-acts{order:99;flex:1 0 100%;display:flex;flex-wrap:wrap;align-items:center;gap:6px}'''
new = '''
.desk-acts{order:99;flex:1 0 100%;display:flex;flex-wrap:wrap;align-items:center;gap:6px;opacity:0}'''
[[mutation]]
label = "D1 touch controls fall below the 28px floor"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_on_touch_the_row_controls_are_visible_in_flow_and_at_least_28px"
old = '''
min-height:32px;min-width:32px;padding:0 10px;font-family:var(--font-mono);font-size:var(--size-caption);'''
new = '''
min-height:18px;min-width:18px;padding:0 10px;font-family:var(--font-mono);font-size:var(--size-caption);'''
[[mutation]]
label = "D1 zip back in the middle"
file = "booth/templates/index.html"
test = "tests/test_flow_browser.py::test_the_row_controls_run_zip_keep_or_release_then_wipe"
old = '''
<a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>
{%- if b.kept %}
<form class="release"'''
new = '''
{%- if b.kept %}
<a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>
<form class="release"'''
[[mutation]]
label = "D1 a held booth's pill reads as counting down"
file = "booth/templates/index.html"
test = "tests/test_flow.py::test_the_lifetime_pill_class_is_kept_held_or_counting"
old = '''('life-held' if b.hold in ('open', 'unreadable') else 'life-count')'''
new = ''''life-count\''''
[[mutation]]
label = "D1b an unknown birth time renders a guess"
file = "booth/templates/_dates.html"
test = "tests/test_flow.py::test_created_and_updated_are_dated_facts_and_none_says_nothing"
old = '''
{%- if created_at %} · <time class="d-made" datetime="{{ created_at|iso }}" title="created {{ created_at|stamp }}">created {{ created_at|day(now) }}</time>{% endif -%}'''
new = '''
{%- set created_at = created_at or landed_at %}{%- if created_at %} · <time class="d-made" datetime="{{ created_at|iso }}" title="created {{ created_at|stamp }}">created {{ created_at|day(now) }}</time>{% endif -%}'''
[[mutation]]
label = "D1b updated is measured from the wrong clock"
file = "booth/templates/_dates.html"
test = "tests/test_flow.py::test_created_and_updated_are_dated_facts_and_none_says_nothing"
old = '''updated {{ (now - landed_at)|ago }}'''
new = '''updated {{ (now - created_at)|ago }}'''
[[mutation]]
label = "D3 a stored theme is not applied at load (a reload forgets it)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_theme_toggle_forces_light_and_dark_and_system_follows_the_os_live"
old = '''
if (t === 'light' || t === 'dark') d.setAttribute('data-theme', t);'''
new = '''
if (false) d.setAttribute('data-theme', t);'''
[[mutation]]
label = "D3 System snapshots the OS instead of following it live"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_theme_toggle_forces_light_and_dark_and_system_follows_the_os_live"
old = '''
if (c === 'system') d.removeAttribute('data-theme'); else d.setAttribute('data-theme', c);'''
new = '''
if (c === 'system') d.setAttribute('data-theme', matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'); else d.setAttribute('data-theme', c);'''
[[mutation]]
label = "D3 forced light is not in the sheet"
file = "booth/templates/_svos_tokens.css"
test = "tests/test_flow_browser.py::test_the_theme_toggle_forces_light_and_dark_and_system_follows_the_os_live"
old = '''
:root[data-theme="light"] {'''
new = '''
:root[data-theme="light-OFF"] {'''
[[mutation]]
label = "D3 the toggle shows without JS (display beats [hidden])"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_theme_toggle_never_shows_without_js_and_a_storage_failure_still_applies"
old = '''
.theme[hidden]{display:none}'''
new = '''
'''
[[mutation]]
label = "D3 a storage write that throws swallows the choice"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_theme_toggle_never_shows_without_js_and_a_storage_failure_still_applies"
old = '''
var c = b.getAttribute('data-theme-choice');
if (c === 'system')'''
new = '''
var c = b.getAttribute('data-theme-choice');
localStorage.setItem('booth.theme', c);
if (c === 'system')'''
[[mutation]]
label = "D3 the stored theme is read after the stylesheet (a flash)"
file = "booth/templates/base.html"
test = "tests/test_flow.py::test_a_forced_theme_is_applied_before_first_paint"
old = '''
var t = localStorage.getItem('booth.theme');'''
new = '''
var t = localStorage.getItem('booth.' + 'theme');'''
[[mutation]]
label = "D3 the ask chrome in a verbatim page ignores the choice"
file = "booth/static/embed.js"
test = "tests/test_flow_browser.py::test_a_forced_theme_reaches_the_ask_chrome_inside_a_verbatim_page"
old = '''
bkTheme();
reassociate();'''
new = '''
reassociate();'''
[[mutation]]
label = "D3 the ask chrome does not follow a choice made in another tab"
file = "booth/static/embed.js"
test = "tests/test_flow_browser.py::test_a_forced_theme_reaches_the_ask_chrome_inside_a_verbatim_page"
old = '''
if (e.key === "booth.theme" || e.key === null) bkTheme();'''
new = '''
if (false) bkTheme();'''
+111
View File
@@ -997,3 +997,114 @@ def test_the_fog_landing_is_built_from_the_ring_never_echoed(tmp_path):
assert loc("gone.png") == "/b/g/"
assert loc("n.md") == "/b/g/"
assert loc("") == "/b/g/"
def _css_blocks(css: str) -> list[tuple[str, list[str]]]:
"""[(media prelude | selector, declarations)] in source order. Enough of a
parser for the vendored sheet: blocks are one level deep, or one inside a
single @media."""
out, media = [], ""
for m in re.finditer(r'(@media[^{]+)\{|([^{}@]+?)\s*\{([^{}]*)\}|\}', css):
if m.group(1):
media = " ".join(m.group(1).split())
elif m.group(2) is not None:
key = (media + " | " + " ".join(m.group(2).split()).split("*/")[-1].strip()).strip()
body = re.sub(r"/\*.*?\*/", "", m.group(3), flags=re.S) # a comment hides the declaration after it
decls = [d.strip() for d in body.split(";") if d.strip()]
out.append((key, decls))
else:
media = ""
return out
def test_a_forced_theme_and_the_os_theme_are_the_same_declarations():
"""r2b D3: light is written twice — under the OS query for
`:root:not([data-theme="dark"])`, and bare for `:root[data-theme="light"]`
— and light-hc twice likewise. The two copies of each must be the same
declarations, value for value, or forcing a theme would give a different
theme than the OS asking for it.
And each must declare exactly the property set of the block it overrides,
read from the UNMOVED dark block (SVOS's light and dark declare the same
properties, as do its two high-contrast blocks). A check that only compared
the re-scoped copies with each other would pass a transform that dropped
the same declaration from both (heid contract panel, 4/4)."""
css = (pathlib.Path(__file__).parent.parent / "booth/templates/_svos_tokens.css").read_text()
blocks = _css_blocks(css)
by = {}
for key, decls in blocks:
by.setdefault(key, []).append(decls)
props = lambda decls: {d.split(":", 1)[0].strip() for d in decls}
dark = by["| :root"][1] # primitives, DARK, art layer
art = by["| :root"][2]
[os_light] = by['@media (prefers-color-scheme: light) | :root:not([data-theme="dark"])']
[forced_light] = by['| :root[data-theme="light"]']
[dhc] = by["@media (prefers-contrast: more) | :root"]
[os_lhc] = by['@media (prefers-contrast: more) and (prefers-color-scheme: light) | :root:not([data-theme="dark"])']
[forced_lhc] = by['@media (prefers-contrast: more) | :root[data-theme="light"]']
assert "color-scheme: dark" in dark and len(dark) > 30 # the parser found the real dark block
assert os_light == forced_light
assert os_lhc == forced_lhc
assert props(forced_light) == props(dark) | (props(forced_light) & props(art))
assert props(dark) <= props(forced_light)
assert props(forced_lhc) == props(dhc)
def _row(body: str, name: str) -> str:
return re.search(r'<article class="desk-row[^"]*" data-booth="%s".*?</article>' % re.escape(name), body, re.S).group(0)
def test_the_lifetime_pill_class_is_kept_held_or_counting(tmp_path):
"""r2b D1 (operator: 'make it obvious which are kept and which are
ephemeral'): the lifetime is a pill in the right column, its CLASS chosen
by state and its words exactly the lifetime macro's."""
from booth.marks import declare_pick
k = _booth(tmp_path, "kept1", {"a.png": PNG})
(k / ".forever").write_bytes(b"")
h = _booth(tmp_path, "held", {"a.png": PNG})
declare_pick(h, "q", {"prompt": "Which?", "options": ["x", "y"]})
_booth(tmp_path, "loose", {"a.png": PNG})
body = _client(tmp_path).get("/").text
pill = lambda n: re.search(r'<span class="life (life-\w+)"[^>]*>(.*?)</span>\s*</div>', _row(body, n), re.S)
assert pill("kept1").group(1) == "life-kept" and "kept" in pill("kept1").group(2)
assert pill("held").group(1) == "life-held" and "held until answered" in pill("held").group(2)
assert pill("loose").group(1) == "life-count" and "expires in" in pill("loose").group(2)
# and the lifetime left the facts line, which is facts only
assert "expires in" not in re.search(r'class="desk-facts".*?</div>', _row(body, "loose"), re.S).group(0)
def test_created_and_updated_are_dated_facts_and_none_says_nothing(tmp_path, monkeypatch):
"""r2b D1b (operator: 'creation and update dates on the booths'): created is
a date, updated an age, each a <time> with its full stamp as the title, on
the Desk row's facts line and in the booth header. A birth time the
filesystem cannot give renders NOTHING — never a guess (booth-dev)."""
import os
import booth.app as app_mod
t0 = time.time() - 5 * 86400
b = _booth(tmp_path, "g", {"a.png": PNG})
os.utime(b / "a.png", (t0, t0))
_booth(tmp_path, "nobirth", {"a.png": PNG})
real = app_mod.birth_time
monkeypatch.setattr(app_mod, "birth_time", lambda p: None if p.name == "nobirth" else t0 - 86400)
c = _client(tmp_path)
facts = lambda n: re.search(r'class="desk-facts".*?</div>', _row(c.get("/").text, n), re.S).group(0)
g = facts("g")
assert re.search(r'<time[^>]*datetime="\d{4}-\d\d-\d\dT[^"]+"[^>]*title="created [^"]+"[^>]*>created [^<]+</time>', g)
assert re.search(r'<time[^>]*title="updated [^"]+"[^>]*>updated 5d ago</time>', g)
assert "created" not in facts("nobirth")
head = c.get("/b/g/").text
assert re.search(r'<time[^>]*>created [^<]+</time>', head) and re.search(r'<time[^>]*>updated 5d ago</time>', head)
monkeypatch.setattr(app_mod, "birth_time", real)
def test_a_forced_theme_is_applied_before_first_paint(tmp_path):
"""r2b D3 / INV-6: the script that applies a stored theme runs in <head>
BEFORE any stylesheet, so a forced theme never flashes the other one. The
toggle is markup on every page, always `hidden` until the script shows it."""
_booth(tmp_path, "g", {"a.png": PNG})
c = _client(tmp_path)
for url in ("/", "/b/g/"):
page = c.get(url).text
head = page[:page.index("</head>")]
assert head.index("booth.theme") < head.index("<style>"), url
assert re.search(r'<div class="theme"[^>]*hidden', page), url
+229 -73
View File
@@ -410,43 +410,6 @@ def test_the_next_arrow_clears_the_rail_only_beside_it(browser, live):
assert rights == {1400: "360px", 390: "0px"}
def test_a_rows_keep_release_and_wipe_take_no_room_of_their_own(browser, live):
"""Operator, on the live Desk: 'release and x take up space whether or not
they're visible.' They were `opacity:0` in their own side column, which
hides a control and still reserves its box. Each now sits on the facts
line beside the state it changes, visible without hover (a touch screen
never had hover), so a row with no badge has no side column at all."""
import os
base, root = live
past = time.time() - 10_000
for name, kept in (("kept1", True), ("loose", False)):
d = root / name
d.mkdir()
(d / "a.png").write_bytes(PNG)
os.utime(d / "a.png", (past, past))
(d / ".viewed").write_bytes(b"") # looked at since: no 'new' badge
if kept:
(d / ".forever").write_bytes(b"")
page = browser.new_page(viewport={"width": 1400, "height": 900})
page.goto(f"{base}/", wait_until="networkidle")
page.mouse.move(0, 0) # nothing hovered
out = {}
for name, form in (("kept1", "form.release"), ("kept1", "form.wipe-kept"),
("loose", "form.keepit"), ("loose", "form.wipe")):
btn = page.locator(f'.desk-row[data-booth="{name}"] {form} button')
out[(name, form)] = btn.is_visible() and btn.evaluate(
"b => { for (let e = b; e; e = e.parentElement)"
" if (getComputedStyle(e).opacity === '0') return false;"
" return true; }")
gaps = page.evaluate("""() => [...document.querySelectorAll('.desk-row')].map(r => {
const row = r.getBoundingClientRect(), main = r.querySelector('.desk-main').getBoundingClientRect();
return Math.round(row.right - main.right); })""")
page.close()
assert all(out.values()), out
# row padding (12) + border (1) + nothing else: no side column is reserved
assert gaps and max(gaps) <= 14, gaps
def test_the_desk_never_scrolls_sideways_at_any_width(browser, tmp_path):
"""The row-controls change made a Desk row a flex container, and a long
provenance line (nowrap, ellipsised) then set the Desk column's MINIMUM
@@ -492,42 +455,6 @@ def test_the_desk_never_scrolls_sideways_at_any_width(browser, tmp_path):
assert all(v <= 0 for v in over.values()), over
def test_on_a_touch_screen_the_row_controls_keep_their_tap_floor(browser, live):
"""Slate T2 (kimi, groa): moving keep/release/wipe onto the facts line
dropped the deliberate 28px tap target (`height:28px;min-width:28px`) to
about 19px, 4-6px from the zip link. With scripts off no confirm fires, so
a mis-tap on wipe POSTs the delete. On a coarse pointer every row control
is at least 28px square again and wipe stands clear of the zip link; a
fine pointer keeps the compact line."""
import os
base, root = live
past = time.time() - 10_000
for name, kept in (("kept1", True), ("loose", False)):
d = root / name
d.mkdir()
(d / "a.png").write_bytes(PNG)
os.utime(d / "a.png", (past, past))
(d / ".viewed").write_bytes(b"")
if kept:
(d / ".forever").write_bytes(b"")
ctx = browser.new_context(viewport={"width": 390, "height": 844}, has_touch=True, is_mobile=True)
page = ctx.new_page()
page.goto(f"{base}/", wait_until="networkidle")
coarse = page.evaluate("matchMedia('(pointer: coarse)').matches")
boxes = page.evaluate("""() => [...document.querySelectorAll('.desk-facts form button')].map(b => {
const r = b.getBoundingClientRect(); return [Math.round(r.width), Math.round(r.height)]; })""")
gaps = page.evaluate("""() => [...document.querySelectorAll('.desk-row')].map(row => {
const z = row.querySelector('.dl-link').getBoundingClientRect(),
w = row.querySelector('form.wipe button').getBoundingClientRect();
// the clearance between the two boxes on whichever axis separates them:
// beside each other on one line, or wipe wrapped onto the next
return Math.round(Math.max(w.left - z.right, z.left - w.right, w.top - z.bottom, z.top - w.bottom)); })""")
ctx.close()
assert coarse, "the emulation must present a coarse pointer, or this test measures nothing"
assert len(boxes) == 4 and all(w >= 28 and h >= 28 for w, h in boxes), boxes
assert all(g >= 8 for g in gaps), gaps
def test_the_wipe_dialog_shows_what_is_being_wiped_and_never_fails_open(browser, live):
"""Slate T4 (groa): the booth name travels as data, never into a script —
but the confirm TEXT showed it raw, so a name carrying a bidi override
@@ -548,6 +475,8 @@ def test_the_wipe_dialog_shows_what_is_being_wiped_and_never_fails_open(browser,
said = []
page.on("dialog", lambda dlg: (said.append(dlg.message), dlg.dismiss()))
page.goto(f"{base}/", wait_until="networkidle")
# r2b D1: the row's controls appear on hover (operator ruling), so hover first
page.locator(".desk-row").first.hover()
page.locator("form.wipe button").first.click()
page.wait_for_timeout(300)
page.evaluate("""() => { const f = document.createElement('form');
@@ -805,3 +734,230 @@ def test_the_test_browser_has_no_internet(browser, live):
page.close()
assert "ERR_NAME_NOT_RESOLVED" in str(err.value) and external < 3, (str(err.value)[:80], external)
assert local < 10, local
def _two_rows(root: pathlib.Path) -> None:
"""A kept and an ephemeral booth, both looked at since they landed (so no
'new' badge): the plainest rows the Desk draws."""
import os
past = time.time() - 10_000
for name, kept in (("kept1", True), ("loose", False)):
d = root / name
d.mkdir()
(d / "a.png").write_bytes(PNG)
os.utime(d / "a.png", (past, past))
(d / ".viewed").write_bytes(b"")
if kept:
(d / ".forever").write_bytes(b"")
_ROW_BOXES = """row => {
const box = e => { const r = e.getBoundingClientRect(); return [r.x, r.y, r.width, r.height].map(Math.round); };
const parts = [...row.querySelectorAll('.desk-strip, .desk-strip img, .desk-main, .desk-side, .life')];
return parts.map(box);
}"""
def test_the_row_controls_take_no_room_where_a_hover_exists(browser, live):
"""r2b D1 (operator: 'download, keep and release buttons only appear on
mouseover'; earlier: 'release and x take up space whether or not they're
visible'). Where a real hover exists the cluster floats over the preview
strip: at rest invisible AND unclickable; on hover visible AND clickable;
and every other box in the row is the same with the cluster removed. It
never covers the text or the side column, at any width."""
base, root = live
_two_rows(root)
over = {}
for w in (390, 720, 1000, 1400):
page = browser.new_page(viewport={"width": w, "height": 900})
page.goto(f"{base}/", wait_until="networkidle")
assert page.evaluate("matchMedia('(hover: hover) and (pointer: fine)').matches")
page.mouse.move(0, 0)
row = page.locator('.desk-row[data-booth="loose"]')
acts = row.locator(".desk-acts")
rest = acts.evaluate("a => [getComputedStyle(a).opacity, getComputedStyle(a).pointerEvents]")
with_acts = row.evaluate(_ROW_BOXES)
row.hover()
page.wait_for_timeout(350)
shown = acts.evaluate("a => [getComputedStyle(a).opacity, getComputedStyle(a).pointerEvents]")
over[w] = row.evaluate("""row => {
const a = row.querySelector('.desk-acts').getBoundingClientRect();
return [...row.querySelectorAll('.desk-main, .desk-side')].some(e => {
const r = e.getBoundingClientRect();
return a.left < r.right && r.left < a.right && a.top < r.bottom && r.top < a.bottom; }); }""")
acts.evaluate("a => a.remove()")
without = row.evaluate(_ROW_BOXES)
page.close()
assert rest == ["0", "none"], (w, rest)
assert shown == ["1", "auto"], (w, shown)
assert with_acts == without, (w, with_acts, without)
assert not any(over.values()), over
# and on hover a control is really pressable: keep reaches the server
page = browser.new_page(viewport={"width": 1400, "height": 900})
page.goto(f"{base}/", wait_until="networkidle")
row = page.locator('.desk-row[data-booth="loose"]')
row.hover()
page.wait_for_timeout(350)
with page.expect_navigation():
row.locator(".desk-acts form.keepit button").click()
page.close()
assert (root / "loose" / ".forever").exists()
def test_on_touch_the_row_controls_are_visible_in_flow_and_at_least_28px(browser, live):
"""r2b D1: hover-only would mean no controls at all on touch. Without a
real hover (here: a coarse, touch primary pointer) the cluster is visible,
in flow on its own line, and every control is at least the 28px floor
(Slate T2) — wipe clear of its neighbour."""
base, root = live
_two_rows(root)
ctx = browser.new_context(viewport={"width": 390, "height": 844}, has_touch=True, is_mobile=True)
page = ctx.new_page()
page.goto(f"{base}/", wait_until="networkidle")
coarse = page.evaluate("matchMedia('(pointer: coarse)').matches")
got = page.evaluate("""() => [...document.querySelectorAll('.desk-row')].map(row => {
const a = row.querySelector('.desk-acts'), cs = getComputedStyle(a);
const ctl = [...a.querySelectorAll('button, a')].map(b => b.getBoundingClientRect());
const main = row.querySelector('.desk-main').getBoundingClientRect();
const w = a.querySelector('form.wipe button').getBoundingClientRect();
const prev = ctl[ctl.length - 2];
return {opacity: cs.opacity, position: cs.position,
below: a.getBoundingClientRect().top >= main.bottom - 1,
small: ctl.filter(r => r.width < 28 || r.height < 28).length,
gap: Math.round(Math.max(w.left - prev.right, w.top - prev.bottom))}; })""")
ctx.close()
assert coarse, "the emulation must present a coarse pointer, or this test measures nothing"
for g in got:
assert g["opacity"] == "1" and g["position"] == "static" and g["below"], got
assert g["small"] == 0 and g["gap"] >= 8, got
def test_the_row_controls_run_zip_keep_or_release_then_wipe(browser, live):
"""r2b D1 (operator: 'the zip download button is in between keep/release and
wipe, and looks awkward'). Zip leads; release stays next to x."""
base, root = live
_two_rows(root)
page = browser.new_page(viewport={"width": 1400, "height": 900})
page.goto(f"{base}/", wait_until="networkidle")
order = page.evaluate("""() => Object.fromEntries([...document.querySelectorAll('.desk-row')].map(row =>
[row.dataset.booth, [...row.querySelectorAll('.desk-acts > *')].map(e => e.className.split(' ')[0])]))""")
page.close()
assert order == {"kept1": ["dl-link", "release", "wipe"], "loose": ["dl-link", "keepit", "wipe"]}, order
_VAR = "n => getComputedStyle(document.documentElement).getPropertyValue(n).trim()"
def test_the_theme_toggle_forces_light_and_dark_and_system_follows_the_os_live(browser, live):
"""r2b D3 (operator: 'light mode toggle at the top (dark, light, system)').
Light and Dark force the theme and survive a reload in the same browser
(localStorage). System hands the question back to the OS, and follows it
LIVE — an OS flip moves the page with no reload and no listener, because a
media query tracks the OS by construction."""
base, root = live
_set(root, 1)
ctx = browser.new_context(color_scheme="dark", viewport={"width": 1200, "height": 800})
page = ctx.new_page()
page.goto(f"{base}/b/g/", wait_until="networkidle")
dark = page.evaluate(_VAR, "--surface-base")
page.locator('.theme [data-theme-choice="light"]').click()
light = page.evaluate(_VAR, "--surface-base")
page.reload(wait_until="networkidle")
after_reload = page.evaluate(_VAR, "--surface-base")
pressed = page.locator('.theme [aria-pressed="true"]').get_attribute("data-theme-choice")
page.locator('.theme [data-theme-choice="system"]').click()
system_dark = page.evaluate(_VAR, "--surface-base")
page.emulate_media(color_scheme="light")
system_light = page.evaluate(_VAR, "--surface-base")
page.locator('.theme [data-theme-choice="dark"]').click()
forced_dark_on_light_os = page.evaluate(_VAR, "--surface-base")
ctx.close()
assert dark != light
assert after_reload == light and pressed == "light"
assert system_dark == dark and system_light == light, (system_dark, system_light)
assert forced_dark_on_light_os == dark
def test_a_forced_theme_follows_high_contrast(browser, live):
"""r2b D3: under prefers-contrast: more, a FORCED theme gets its own
high-contrast variant — forced dark resolves exactly what an OS-dark page
resolves, forced light exactly what an OS-light page does, whatever the OS
says about the scheme."""
base, root = live
_set(root, 1)
got = {}
for os_scheme in ("dark", "light"):
for forced in (None, "dark", "light"):
ctx = browser.new_context(color_scheme=os_scheme, reduced_motion="no-preference")
page = ctx.new_page()
page.emulate_media(color_scheme=os_scheme)
page.goto(f"{base}/b/g/", wait_until="networkidle")
page.evaluate("f => f ? document.documentElement.setAttribute('data-theme', f) : null", forced)
# contrast: more is emulated through CDP; Playwright has no option for it
cdp = ctx.new_cdp_session(page)
cdp.send("Emulation.setEmulatedMedia", {"features": [
{"name": "prefers-contrast", "value": "more"},
{"name": "prefers-color-scheme", "value": os_scheme}]})
got[(os_scheme, forced)] = page.evaluate(_VAR, "--surface-card")
ctx.close()
assert got[("light", "dark")] == got[("dark", None)] == got[("dark", "dark")]
assert got[("dark", "light")] == got[("light", None)] == got[("light", "light")]
assert got[("dark", None)] != got[("light", None)]
def test_the_theme_toggle_never_shows_without_js_and_a_storage_failure_still_applies(browser, live):
"""r2b D3: in the markup with `hidden`, never shown without JS (the page
follows the OS). With localStorage throwing, a click still applies to the
page; only the memory is lost."""
base, root = live
_set(root, 1)
ctx = browser.new_context(java_script_enabled=False)
page = ctx.new_page()
page.goto(f"{base}/", wait_until="networkidle")
nojs = page.locator(".theme").is_visible()
ctx.close()
ctx = browser.new_context(color_scheme="dark")
page = ctx.new_page()
errors = []
page.on("pageerror", lambda e: errors.append(str(e)))
page.add_init_script("""Storage.prototype.setItem = function () { throw new Error('quota'); };
Storage.prototype.getItem = function () { throw new Error('denied'); };""")
page.goto(f"{base}/", wait_until="networkidle")
before = page.evaluate(_VAR, "--surface-base")
page.locator('.theme [data-theme-choice="light"]').click()
after = page.evaluate(_VAR, "--surface-base")
ctx.close()
assert not nojs
assert before != after and errors == [], (before, after, errors)
def test_a_forced_theme_reaches_the_ask_chrome_inside_a_verbatim_page(browser, live):
"""r2b D3 (operator: 'theme toggle reaches inside'): the `.bk-ask` chrome
embed.js mounts in an author's page follows the stored choice, and a change
in another tab (the `storage` event) moves it live. The host page's own
<html> is never touched."""
from booth.marks import declare_pick
base, root = live
b = root / "rep"
b.mkdir()
declare_pick(b, "winner", {"prompt": "Which?", "options": ["A", "B"]})
(b / "index.html").write_text('<!doctype html><title>r</title><body><h1>R</h1>'
'<script src="/_booth/embed.js" defer></script></body>')
ctx = browser.new_context(color_scheme="dark")
page = ctx.new_page()
page.goto(f"{base}/b/rep/", wait_until="networkidle")
page.wait_for_selector(".bk-ask")
accent = lambda: page.evaluate("getComputedStyle(document.querySelector('.bk-ask')).getPropertyValue('--bk-accent').trim()")
os_dark = accent()
other = ctx.new_page() # the toggle, pressed in another tab
other.goto(f"{base}/", wait_until="networkidle")
other.locator('.theme [data-theme-choice="light"]').click()
page.wait_for_timeout(300)
forced_light = accent()
host_html = page.evaluate("document.documentElement.getAttribute('data-theme')")
page.reload(wait_until="networkidle")
page.wait_for_selector(".bk-ask")
after_reload = accent()
ctx.close()
assert os_dark == "#b2cd12" and forced_light == "#586519" and after_reload == "#586519", (os_dark, forced_light, after_reload)
assert host_html is None