merge(r2b): the Desk row, booth dates, and the theme toggle

design-dev's r2b merge 2 (D1 + D1b + D3), merged on the operator's approval
with both heid panels folded (code review and bug hunt, 4/4 each), landed after
merge 1 and its live check so a live regression points at one of the two.

436d234 is the feature. The Desk row gets an always-visible lifetime pill (kept,
held, counting), with zip / keep|release / wipe floating over the preview strip
on hover or focus and taking no room; on touch they are the row's last line.
Booth dates render on the row and the booth header from created_at (statx birth
time) and landed_at: four never-raise date filters in app.py, one `now` per
page, and a date the filesystem cannot give or the calendar cannot hold renders
nothing. The System / Light / Dark toggle is stored per viewer, applied before
first paint, and reaches the ask chrome embed.js mounts inside verbatim pages
(only the fragments it mounted; an author's own .bk-ask is never marked).
_svos_tokens.css is re-vendored at the same SVOS SHA with a scoping-only
transform.

1558a7f folds both panels.
This commit is contained in:
vh
2026-09-23 21:44:14 -07:00
12 changed files with 1234 additions and 189 deletions
+64
View File
@@ -253,6 +253,62 @@ def human_dur(seconds: float) -> str:
return "<1m"
# r2b D1b — a booth's dates as the operator reads them. Created is a DATE
# (the day it began); updated is an AGE (how recently it moved). Each goes in a
# <time> whose `datetime` is `iso` and whose title is the full `stamp`, so the
# short form never hides the exact one. Local time, the box's own zone: the
# Booth has one viewer, on this box.
_MONTHS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
# NEVER RAISE: the Desk renders every row in ONE response, and a clock a fleet
# session can set (content mtimes) or a filesystem can report (birth time) may
# lie outside the calendar's range — which raised through the template and
# 500'd the index for every booth (heid bug-hunt, 4/4). A date that cannot be
# rendered is "" and the macro renders nothing, as for an unknown birth time.
_BAD_DATE = (OverflowError, OSError, ValueError)
def date_iso(epoch: float) -> str:
try:
return datetime.fromtimestamp(epoch, tz=timezone.utc).isoformat(timespec="seconds")
except _BAD_DATE:
return ""
def date_stamp(epoch: float) -> str:
try:
return time.strftime("%Y-%m-%d %H:%M", time.localtime(epoch))
except _BAD_DATE:
return ""
def date_day(epoch: float, now: float | None = None) -> str:
"""'12 Sep', with the year only when it is not this year's; '' when the
calendar cannot hold it."""
try:
t, n = time.localtime(epoch), time.localtime(time.time() if now is None else now)
except _BAD_DATE:
return ""
day = f"{t.tm_mday} {_MONTHS[t.tm_mon - 1]}"
return day if t.tm_year == n.tm_year else f"{day} {t.tm_year}"
def date_ago(seconds: float) -> str:
"""An age in its largest whole unit: 'just now', '12m ago', '5h ago',
'1d ago'. A day is 24h (hours used to run on to 47h, so '1d ago' never
appeared). The caller never passes a clock AHEAD of now: that is said as a
date, not an age."""
s = int(seconds)
if s < 60:
return "just now"
if s < 3600:
return f"{s // 60}m ago"
if s < 86400:
return f"{s // 3600}h ago"
return f"{s // 86400}d ago"
def _newest_mtime(path: Path) -> float:
"""Newest mtime among a folder and everything under it — OUR LOCKS EXCEPT.
@@ -1011,6 +1067,10 @@ def create_app(
auto_reload=False,
)
env.filters["dur"] = human_dur
env.filters["iso"] = date_iso
env.filters["stamp"] = date_stamp
env.filters["day"] = date_day
env.filters["ago"] = date_ago
templates = Jinja2Templates(env=env)
# embed.js IS READ ONCE, HERE, for exactly the reason above. It is the third
@@ -1133,6 +1193,9 @@ def create_app(
"bookmarks": bookmarks[:BOOKMARKS_SHOWN],
"bookmarks_total": len(bookmarks),
"board_url": f"/b/{quote(links_board, safe='')}/",
# r2b D1b: ONE clock for the page, so every row's "updated ...
# ago" is measured from the same instant.
"now": time.time(),
},
)
@@ -1301,6 +1364,7 @@ def create_app(
# handed to the operator lands HERE and not on the index.
"created_at": birth_time(booth),
"landed_at": _content_mtime(booth),
"now": time.time(),
# The lifetime line, same three states as the index card: a
# booth URL handed to the operator lands HERE, not on the index,
# so "why is this not counting down" has to be answerable here.
+39 -3
View File
@@ -27,6 +27,12 @@
if (window.__boothEmbed) return; // declared AND appended: mount once
window.__boothEmbed = true;
/* The ask palette's LIGHT values, written once and used by both light rules
below (OS light, and forced light), so the two can never drift apart. */
var BK_LIGHT = "--bk-accent:#586519;--bk-accent-line:rgba(88,101,25,.55);" +
"--bk-accent-soft:rgba(88,101,25,.11);--bk-on-accent:#fff;--bk-open:#7c5500;--bk-open-text:#7c5500;" +
"--bk-done:#486741;--bk-done-text:#486741;--bk-skip:#52595e;--bk-err:#a42e07";
var CSS = [
/* SVOS values, written as literals: this sheet lands in a page we did not
write, so it can lean on none of base.html's tokens. Hex equivalents of
@@ -55,9 +61,12 @@
".bk-ask{--bk-accent:#b2cd12;--bk-accent-line:rgba(178,205,18,.55);--bk-accent-soft:rgba(178,205,18,.12);",
"--bk-on-accent:#0c1014;--bk-open:#d29a02;--bk-open-text:#fbc10f;--bk-done:#71a166;--bk-done-text:#9bce90;",
"--bk-skip:#868d91;--bk-err:#fea47d}",
"@media (prefers-color-scheme: light){.bk-ask{--bk-accent:#586519;--bk-accent-line:rgba(88,101,25,.55);",
"--bk-accent-soft:rgba(88,101,25,.11);--bk-on-accent:#fff;--bk-open:#7c5500;--bk-open-text:#7c5500;",
"--bk-done:#486741;--bk-done-text:#486741;--bk-skip:#52595e;--bk-err:#a42e07}}",
/* r2b D3 — the operator's theme reaches inside ("theme toggle reaches
inside"): light when the OS asks and dark is not forced, or when light
is forced — the Booth sheet's own rule, carried by `data-bk-theme` on
each fragment (bkTheme, below), never by the host page's <html>. */
"@media (prefers-color-scheme: light){.bk-ask:not([data-bk-theme=dark]){" + BK_LIGHT + "}}",
".bk-ask[data-bk-theme=light]{" + BK_LIGHT + "}",
".bk-ask{margin:1.1rem 0;padding:.9rem 1rem;border:1px solid rgba(128,140,160,.34);",
"border-top:2px solid var(--bk-open);border-radius:8px;background:rgba(128,140,160,.07);",
"font:15px/1.55 'IBM Plex Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif}",
@@ -335,6 +344,30 @@
return mounted;
}
/* r2b D3: mark every fragment WE mounted with the operator's stored theme
choice (the same localStorage key the Booth's toggle writes — same
origin). Absent or unreadable = follow the OS, as before. The host page's
own <html> is the author's and is never touched. */
var ours = []; // every element this script mounted
function bkTheme() {
var t = null;
try { t = localStorage.getItem("booth.theme"); } catch (e) {}
var forced = t === "light" || t === "dark";
/* OUR fragments only (heid bug-hunt): an author's own `.bk-ask` in the
host page is theirs, and is never marked. */
ours.forEach(function (root) {
var els = [root].concat(Array.prototype.slice.call(root.querySelectorAll(".bk-ask")));
els.forEach(function (el) {
if (!el.classList.contains("bk-ask")) return;
if (forced) el.setAttribute("data-bk-theme", t); else el.removeAttribute("data-bk-theme");
});
});
}
/* A choice made in another tab moves an open report live. */
window.addEventListener("storage", function (e) {
if (e.key === "booth.theme" || e.key === null) bkTheme();
});
function start() {
var name = boothName();
if (!name || !document.body) return;
@@ -350,6 +383,9 @@
if (!data) return;
favicon(data.favicon);
var mounted = place(data.marks || []);
ours = [];
Object.keys(mounted).forEach(function (id) { ours = ours.concat(mounted[id]); });
bkTheme();
reassociate();
asksChip(data.open || [], mounted);
document.dispatchEvent(new CustomEvent("booth:mounted", { detail: { booth: name } }));
+21
View File
@@ -0,0 +1,21 @@
{# r2b D1b — a booth's two dates, defined ONCE for the Desk row and the booth
header. Created is the day it began (the filesystem's birth time); updated
is how recently its CONTENT moved (`landed_at`, the clock "new since you
looked" reads). Each is a <time> with its exact stamp as the title.
- A date the filesystem cannot give, or the calendar cannot hold, renders
NOTHING — never a plausible guess, and never a 500 for the whole Desk.
- "Updated" shows whenever it differs from "created" by a minute or more,
EITHER way: copied files keep their mtimes while the folder is born now,
so content can be older than its booth. Within a minute, one date.
- A content clock AHEAD of now (a wrong clock somewhere) is said as its
date, never as an age — "updated just now" would be a lie. #}
{% macro dates(created_at, landed_at, now) -%}
{%- set made = created_at|day(now) if created_at else "" -%}
{%- if made %} · <time class="d-made" datetime="{{ created_at|iso }}" title="created {{ created_at|stamp }}">created {{ made }}</time>{% endif -%}
{%- set moved = landed_at|day(now) if landed_at else "" -%}
{%- if moved and (not made or (landed_at - created_at)|abs >= 60) -%}
{%- set age = now - landed_at -%}
{%- if age < -60 %} · <time class="d-upd" datetime="{{ landed_at|iso }}" title="updated {{ landed_at|stamp }}">updated {{ moved }}</time>
{%- else %} · <time class="d-upd" datetime="{{ landed_at|iso }}" title="updated {{ landed_at|stamp }}">updated {{ age|ago }}</time>{% endif -%}
{%- endif -%}
{%- endmacro %}
+111 -9
View File
@@ -1,10 +1,12 @@
/* SVOS tokens — VENDORED BY COPY from design-systems
palettes/svos/colors.css + svos-theme.css @ ed2f8d8. Do not hand-edit values;
re-vendor from the source. The only transform: SVOS scopes its four themes
by [data-theme]; the Booth has no theme switch and follows the OS, so the
blocks are re-scoped onto prefers-color-scheme / prefers-contrast queries.
Semantic tokens only in the Booth layer below — never a primitive, never
a raw hex. */
re-vendor from the source. The only transform is SCOPING: SVOS selects its
four themes by [data-theme]; the Booth follows the OS unless the viewer
forces a theme (the top-bar toggle sets data-theme on <html>). So light
applies when the OS asks and dark is not forced, or when light is forced;
high contrast follows whichever theme is in effect. No JS, no attribute:
exactly the OS. Semantic tokens only in the Booth layer below — never a
primitive, never a raw hex. */
:root {
--graphite-10: oklch(0.17 0.01 250);
--graphite-15: oklch(0.21 0.01 248);
@@ -42,7 +44,7 @@
--graphite-ink: var(--graphite-15);
}
/* dark — the lair default */
/* dark — the lair default, and data-theme="dark" */
:root {
color-scheme: dark;
--surface-sunken: var(--graphite-10);
@@ -129,8 +131,9 @@
--glow-armed: 0 0 12px color-mix(in oklab, var(--accent) 40%, transparent);
}
/* light: the OS asks and the viewer has not forced dark ... */
@media (prefers-color-scheme: light) {
:root {
:root:not([data-theme="dark"]) {
color-scheme: light;
--surface-sunken: var(--graphite-94);
--surface-base: var(--graphite-96);
@@ -180,6 +183,57 @@
}
}
/* ... or the viewer forced light. Same declarations as above, by construction. */
:root[data-theme="light"] {
color-scheme: light;
--surface-sunken: var(--graphite-94);
--surface-base: var(--graphite-96);
--surface-raised: var(--graphite-98);
--surface-overlay: #ffffff;
--surface-card: var(--graphite-98);
--surface-input: #ffffff;
--surface-scrim: oklch(0.21 0.01 248 / 0.4);
--text-heading: var(--graphite-15);
--text-body: var(--graphite-30);
--text-muted: var(--graphite-40);
--text-faint: var(--graphite-50);
--text-inverse: var(--graphite-90);
--text-link: oklch(0.455 0.097 235);
--text-link-hover: var(--graphite-15);
--border-subtle: oklch(0.89 0.006 218);
--border-default: oklch(0.85 0.008 220);
--border-strong: var(--graphite-75);
--border-focus: var(--green-deep);
--accent: var(--green-deep);
--accent-hover: oklch(0.44 0.1 119);
--accent-active: oklch(0.4 0.09 119);
--accent-text: oklch(0.44 0.1 119);
--accent-contrast: #ffffff;
--accent-soft: color-mix(in oklab, var(--green-deep) 11%, transparent);
--accent-soft-hover: color-mix(in oklab, var(--green-deep) 18%, transparent);
--success: var(--sage-deep);
--success-text: var(--sage-deep);
--success-soft: color-mix(in oklab, var(--sage-deep) 10%, transparent);
--warning: var(--amber-deep);
--warning-text: var(--amber-deep);
--warning-soft: color-mix(in oklab, var(--amber-base) 18%, transparent);
--danger: var(--orange-deep);
--danger-hover: oklch(0.44 0.15 36);
--danger-text: var(--orange-deep);
--danger-contrast: #ffffff;
--danger-soft: color-mix(in oklab, var(--orange-deep) 9%, transparent);
--intel: var(--intel-deep);
--intel-text: var(--intel-deep);
--intel-soft: color-mix(in oklab, var(--intel-deep) 9%, transparent);
--selection-bg: var(--green-deep);
--selection-fg: #ffffff;
--shadow-sm: 0 1px 2px rgb(23 31 38 / 0.07);
--shadow-md: 0 4px 14px rgb(23 31 38 / 0.1);
--shadow-lg: 0 14px 36px rgb(23 31 38 / 0.14);
--glow-armed: 0 0 10px color-mix(in oklab, var(--accent) 30%, transparent);
}
/* dark high contrast: whenever dark is in effect (light, below, outranks it) */
@media (prefers-contrast: more) {
:root {
--surface-sunken: var(--graphite-10);
@@ -226,8 +280,56 @@
}
}
@media (prefers-color-scheme: light) and (prefers-contrast: more) {
:root {
/* light high contrast: the OS light case ... */
@media (prefers-contrast: more) and (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) {
--surface-sunken: var(--graphite-94);
--surface-base: var(--graphite-96);
--surface-raised: var(--graphite-98);
--surface-overlay: #ffffff;
--surface-card: var(--graphite-98);
--surface-input: #ffffff;
--surface-scrim: oklch(0.21 0.01 248 / 0.4);
--text-heading: var(--graphite-15);
--text-body: var(--graphite-30);
--text-muted: oklch(0.4403 0.0102 238);
--text-faint: oklch(0.4403 0.00935 234);
--text-inverse: var(--graphite-90);
--text-link: oklch(0.4371 0.08245 235);
--text-link-hover: var(--graphite-15);
--border-subtle: oklch(0.89 0.006 218);
--border-default: var(--graphite-60);
--border-strong: var(--graphite-40);
--border-focus: var(--green-deep);
--accent: var(--green-deep);
--accent-hover: oklch(0.44 0.1 119);
--accent-active: oklch(0.4 0.09 119);
--accent-text: oklch(0.436 0.085 119);
--accent-contrast: #ffffff;
--accent-soft: color-mix(in oklab, var(--green-deep) 11%, transparent);
--accent-soft-hover: color-mix(in oklab, var(--green-deep) 18%, transparent);
--success: var(--sage-deep);
--success-text: oklch(0.4033 0.0595 140);
--success-soft: color-mix(in oklab, var(--sage-deep) 10%, transparent);
--warning: var(--amber-deep);
--warning-text: oklch(0.4103 0.085 78);
--warning-soft: color-mix(in oklab, var(--amber-base) 18%, transparent);
--danger: var(--orange-deep);
--danger-hover: oklch(0.44 0.15 36);
--danger-text: oklch(0.4225 0.136 36);
--danger-contrast: #ffffff;
--danger-soft: color-mix(in oklab, var(--orange-deep) 9%, transparent);
--intel: var(--intel-deep);
--intel-text: oklch(0.4373 0.0765 235);
--intel-soft: color-mix(in oklab, var(--intel-deep) 9%, transparent);
--selection-bg: var(--green-deep);
--selection-fg: #ffffff;
}
}
/* ... and the forced light case. */
@media (prefers-contrast: more) {
:root[data-theme="light"] {
--surface-sunken: var(--graphite-94);
--surface-base: var(--graphite-96);
--surface-raised: var(--graphite-98);
+122 -25
View File
@@ -12,6 +12,12 @@
<script>
(function () {
var d = document.documentElement, b = d.getAttribute('data-booth');
/* r2b D3: a forced theme, before any stylesheet, so it never flashes the
other one. Absent (or unreadable) = System: no attribute, the OS query. */
try {
var t = localStorage.getItem('booth.theme');
if (t === 'light' || t === 'dark') d.setAttribute('data-theme', t);
} catch (e) {}
try {
if (b !== null && sessionStorage.getItem('booth.reveal:' + b) === '1') d.classList.add('reveal-all');
} catch (e) {}
@@ -108,6 +114,18 @@
.brand .name{font-weight:650;font-size:var(--size-h2);letter-spacing:var(--tracking-h)}
.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
top bar; the pressed choice carries the accent. */
.theme{margin-left:auto;display:inline-flex;border:1px solid var(--border-default);border-radius:var(--radius-md);overflow:hidden}
.theme button{font-family:var(--font-mono);font-size:var(--size-micro);letter-spacing:var(--tracking-caps);
text-transform:uppercase;padding:5px 9px;border:0;border-radius:0;background:none;color:var(--text-muted);
cursor:pointer;-webkit-backdrop-filter:none;backdrop-filter:none}
.theme button + button{border-left:1px solid var(--border-default)}
/* an author `display` beats the UA's [hidden]: without this, the toggle showed
with scripts off and did nothing */
.theme[hidden]{display:none}
.theme button:hover{color:var(--text-body)}
.theme button[aria-pressed="true"]{color:var(--accent-text);background:var(--accent-soft)}
main{flex:1;width:100%;max-width:1240px;margin:0 auto;padding:28px 24px 48px}
@@ -214,35 +232,65 @@
.desk-side{flex:none;display:flex;flex-direction:column;align-items:flex-end;gap:8px}
.badge-new{color:var(--intel-text);background:var(--intel-soft)}
.badge-broken{color:var(--danger-text);background:var(--danger-soft);border-color:color-mix(in oklab,var(--danger) 40%,transparent)}
/* The row's keep / release / wipe, inline on the facts line beside the
state each one changes. Quiet — muted mono like the line they sit in, so
a destructive control does not compete with the thing you came to read —
but always there and always tappable; wipe turns danger only under the
pointer or focus. */
.desk-facts form{display:inline;margin:0}
.desk-facts .dl-link{white-space:nowrap}
.desk-facts form button{display:inline-block;margin-left:4px;padding:1px 6px;height:auto;min-width:0;
font:inherit;line-height:1.4;color:var(--text-muted);background:none;
border:1px solid var(--border-default);border-radius:var(--radius-sm);
-webkit-backdrop-filter:none;backdrop-filter:none;cursor:pointer}
.desk-facts .wipe button{margin-left:0}
.desk-facts form button:hover,.desk-facts form button:focus-visible{color:var(--text-body);
border-color:var(--border-strong);background:var(--surface-overlay)}
.desk-facts .wipe button:hover,.desk-facts .wipe button:focus-visible{background:var(--danger);
/* THE LIFETIME PILL (r2b D1) — state, not a control, so it never hides.
Sage for kept (SVOS: a judgment made), amber for held (a question holds
it), a quiet outline while it counts down. */
.life{display:inline-flex;align-items:center;gap:5px;padding:3px 9px;border-radius:999px;white-space:nowrap;
font-family:var(--font-mono);font-size:var(--size-caption);border:1px solid var(--border-default);color:var(--text-muted)}
.life-kept{color:var(--success-text);background:var(--success-soft);border-color:color-mix(in oklab,var(--success) 45%,transparent)}
.life-kept::before{content:"★"}
.life-held{color:var(--warning-text);background:var(--warning-soft);border-color:color-mix(in oklab,var(--warning) 45%,transparent)}
.life-count::before{content:"◷"}
.life .held,.life .held-broken{color:inherit}
.desk-facts time{white-space:nowrap}
/* THE ROW'S CONTROLS (r2b D1), last in the row's markup. DEFAULT — no real
hover (touch, any coarse pointer, or no hover at all): the cluster is the
row's last line, visible, every control at least the 28px floor (32px).
Hover-only would mean no controls at all on touch; with scripts off no
confirm fires, so wipe stands apart. The strip keeps to the row's top, so
the hover cluster below can be placed over it from the row's own box. */
.desk-row{flex-wrap:wrap;position:relative}
.desk-strip{align-self:flex-start;min-width:0}
.desk-acts{flex:1 0 100%;display:flex;flex-wrap:wrap;align-items:center;gap:6px}
.desk-acts form{margin:0}
.desk-acts .dl-link,.desk-acts button{display:inline-flex;align-items:center;justify-content:center;
min-height:32px;min-width:32px;padding:0 10px;font-family:var(--font-mono);font-size:var(--size-caption);
line-height:1;white-space:nowrap;text-decoration:none;cursor:pointer;color:var(--text-muted);
background:var(--surface-raised);border:1px solid var(--border-default);border-radius:var(--radius-md);
-webkit-backdrop-filter:none;backdrop-filter:none}
.desk-acts .wipe{margin-left:10px}
.desk-acts .dl-link:hover,.desk-acts .dl-link:focus-visible,
.desk-acts button:hover,.desk-acts button:focus-visible{color:var(--text-body);border-color:var(--border-strong);
background:var(--surface-overlay);text-decoration:none}
.desk-acts .wipe button:hover,.desk-acts .wipe button:focus-visible{background:var(--danger);
border-color:var(--danger);color:var(--danger-contrast)}
/* A finger is not a cursor. The compact line is for a fine pointer; on a
coarse one every control gets back the 28px floor it had as a column
(32px here, with room), and wipe stands clear of the zip link — with
scripts off no confirm fires, so a mis-tap on wipe IS the delete. */
@media (pointer:coarse){
.desk-facts{line-height:2.4}
.desk-facts form button{min-height:32px;min-width:32px;padding:0 10px;margin-left:6px;vertical-align:middle}
.desk-facts .wipe button{margin-left:10px}
/* A REAL HOVER — a fine pointer that hovers, and no coarse pointer anywhere
(a touch laptop reports a mouse, but a finger on it cannot hover): the
cluster takes NO room. It floats over the strip's top-right corner,
covering pictures and never information, and appears on row hover or
keyboard focus — opacity AND pointer events together, never visibility,
so hidden controls stay in the tab order and focusing one shows them. */
@media (hover:hover) and (pointer:fine) and (not (any-pointer:coarse)){
.desk-row{flex-wrap:nowrap}
/* over the strip's top-right corner: the strip starts at the row's 12px
padding and is 210px wide */
.desk-acts{position:absolute;top:17px;left:calc(12px + 210px - 5px);transform:translateX(-100%);flex:none;gap:4px;padding:3px;
background:color-mix(in oklab,var(--surface-overlay) 90%,transparent);
border:1px solid var(--border-default);border-radius:var(--radius-lg);box-shadow:var(--shadow-sm);
opacity:0;pointer-events:none;transition:opacity var(--dur-1) var(--ease-out)}
.desk-row:hover .desk-acts,.desk-row:focus-within .desk-acts{opacity:1;pointer-events:auto}
.desk-acts .dl-link,.desk-acts button{min-height:28px;min-width:28px;padding:0 7px}
.desk-acts .wipe{margin-left:5px}
}
@media (hover:hover) and (pointer:fine) and (not (any-pointer:coarse)) and (max-width:700px){
/* the strip spans the row: its top-right is the row's top-right */
.desk-acts{left:auto;right:17px;transform:none}
}
@media (max-width:700px){
.desk-row{flex-wrap:wrap}
.desk-strip{flex-basis:100%}
.desk-side{flex-basis:100%;flex-direction:row;align-items:center}
.desk-side{flex-basis:100%;flex-direction:row;align-items:center;flex-wrap:wrap}
}
/* the side column */
.desk-panel{border:1px solid var(--border-default);border-radius:var(--radius-xl);background:var(--surface-card);
@@ -285,7 +333,7 @@
.prov-broken{color:var(--warning-text);font-style:italic;cursor:help}
/* keep / release / wipe — the controls themselves. Where they sit is the
surface's business: a Desk row's facts line (.desk-facts) or the booth
surface's business: a Desk row's hover cluster (.desk-acts) or the booth
header (.wipe-lg, .keep-lg). The kept/ephemeral CARDS they used to float
over are gone. */
.wipe,.keepit,.release{margin:0}
@@ -880,6 +928,12 @@
<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>
{# r2b D3 — System · Light · Dark. JS-only (with scripts off the page follows
the OS), so it is markup with `hidden` and the script shows it. The top bar
is outside every data-region: no in-place swap replaces it. #}
<div class="theme" role="group" aria-label="colour theme" hidden>
<button type="button" data-theme-choice="system" aria-pressed="false" title="follow the system">System</button><button type="button" data-theme-choice="light" aria-pressed="false">Light</button><button type="button" data-theme-choice="dark" aria-pressed="false">Dark</button>
</div>
</header>
<main>
{# R2 C3: where the in-place script says it could not save in place. Server
@@ -1074,6 +1128,49 @@
if (window.ResizeObserver) new ResizeObserver(set).observe(rail);
})();
</script>
<script>
/* THE THEME TOGGLE (r2b D3). The choice lives in localStorage — it should
outlive the tab, where a reveal must not — and in `data-theme` on <html>.
System REMOVES the attribute: the sheet's prefers-color-scheme query then
answers, and a media query follows the OS live, so no listener is needed.
The click applies to the page first and is remembered second: storage that
throws costs the memory, never the click. */
(function () {
var d = document.documentElement, box = document.querySelector('.theme');
if (!box) return;
function current() {
var t = d.getAttribute('data-theme');
return t === 'light' || t === 'dark' ? t : 'system';
}
function show() {
var c = current();
box.querySelectorAll('[data-theme-choice]').forEach(function (b) {
b.setAttribute('aria-pressed', b.getAttribute('data-theme-choice') === c ? 'true' : 'false');
});
}
box.hidden = false;
show();
/* A choice made in another tab moves this one too (heid bug-hunt): the
embed's ask chrome already followed; the Booth's own pages did not. */
window.addEventListener('storage', function (e) {
if (e.key !== 'booth.theme' && e.key !== null) return;
var t = null;
try { t = localStorage.getItem('booth.theme'); } catch (x) {}
if (t === 'light' || t === 'dark') d.setAttribute('data-theme', t); else d.removeAttribute('data-theme');
show();
});
box.addEventListener('click', function (ev) {
var b = ev.target.closest ? ev.target.closest('[data-theme-choice]') : null;
if (!b) return;
var c = b.getAttribute('data-theme-choice');
if (c === 'system') d.removeAttribute('data-theme'); else d.setAttribute('data-theme', c);
try {
if (c === 'system') localStorage.removeItem('booth.theme'); else localStorage.setItem('booth.theme', c);
} catch (e) {}
show();
});
})();
</script>
<script>
/* REVEAL ALL (r2b D2). Every [data-reveal-all] control on the page shows the
one state of this booth; all of them sit OUTSIDE every data-region, so an
+2 -1
View File
@@ -1,6 +1,7 @@
{% extends "base.html" %}
{% from "_provenance.html" import provenance %}
{% from "_lifetime.html" import lifetime %}
{% from "_dates.html" import dates %}
{# The blur toggle, defined ONCE. There are three item branches in this file
(doc / media / other) and the first cut of this feature patched only one of
them, so docs rendered with no control at all. A macro makes "patched two of
@@ -84,7 +85,7 @@
{% endif %}
{# The open count and the lifetime line depend on marks, so they are a region
(R2 C3): answering the last pick in place must not leave "1 open" behind. #}
<span class="region-wrap" data-region="booth-status"><span class="sub">{% if uploaded %}<span class="badge">⬆ pickup</span> {% endif %}{% if board %}{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if items %} · {{ items|length }} file{{ '' if items|length == 1 else 's' }}{% endif %} · {{ lifetime(kept, hold, expires_in) }}{% else %}{% if marks_open %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · {{ lifetime(kept, hold, expires_in) }}{% endif %}</span></span>
<span class="region-wrap" data-region="booth-status"><span class="sub">{% if uploaded %}<span class="badge">⬆ pickup</span> {% endif %}{% if board %}{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if items %} · {{ items|length }} file{{ '' if items|length == 1 else 's' }}{% endif %}{{ dates(created_at, landed_at, now) }} · {{ lifetime(kept, hold, expires_in) }}{% else %}{% if marks_open %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }}{{ dates(created_at, landed_at, now) }} · {{ lifetime(kept, hold, expires_in) }}{% endif %}</span></span>
{% if items %}<a class="dl-link" href="/b/{{ name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>{% endif %}
{{ provenance(manifest) }}
{# A durable multi-writer board gets no one-click wipe — same rule as the
+32 -30
View File
@@ -1,6 +1,7 @@
{% extends "base.html" %}
{% from "_provenance.html" import provenance %}
{% from "_lifetime.html" import lifetime %}
{% from "_dates.html" import dates %}
{# THE DESK (R2 C4). The index triaged by what needs the operator: needs you,
then new since you looked, then everything else — always in that order, and
the ORDER WITHIN each is decided in app.index, never here. A section with no
@@ -36,39 +37,18 @@
{%- else -%}{{ b.name }}{%- endif -%}
</a>
{{ provenance(b.manifest) }}
{# The row's controls sit ON the facts line, each beside the state it
changes: release after "kept", keep after the countdown, wipe last.
They were a hover-revealed column that reserved its room while
invisible (operator, on the live Desk: "release and x take up space
whether or not they're visible") — and hover never existed on touch.
The confirmation text is DATA-DRIVEN: the booth name travels as a data
attribute and never reaches a JS string (see the script below).
Release is reversible, so it has no prompt beyond the wording.
`·&nbsp;` glues each separator to the item after it, so a wrapped
line never ends on a dangling dot. #}
{# Facts only: counts and dates. The lifetime is state (the pill) and
the controls are actions (the cluster); neither lives here. #}
<div class="desk-facts">
{{ b.count }} item{{ '' if b.count == 1 else 's' }}
{% if b.flags %} · <span class="desk-flags">{{ b.flags }} flagged</span>{% endif %}
· {{ lifetime(b.kept, b.hold, b.expires_in) }}
{%- if b.kept %}
<form class="release" method="post" action="/b/{{ b.name_url }}/unkeep"
data-booth="{{ b.name }}" data-confirm="release"><button title="release this booth so it can be wiped">release</button></form>
{%- else %}
<form class="keepit" method="post" action="/b/{{ b.name_url }}/keep"><button title="keep — exempt from the {{ ttl_hours }}h sweep" aria-label="keep booth">★ keep</button></form>
{%- endif %}
·&nbsp;<a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>
{%- if b.kept %}
·&nbsp;<form class="wipe wipe-kept" method="post" action="/b/{{ b.name_url }}/delete"
data-booth="{{ b.name }}" data-confirm="wipe-kept"><button title="wipe this KEPT booth now" aria-label="wipe kept booth">× wipe</button></form>
{%- else %}
·&nbsp;<form class="wipe" method="post" action="/b/{{ b.name_url }}/delete"
data-booth="{{ b.name }}" data-confirm="wipe"><button title="wipe now" aria-label="wipe booth">× wipe</button></form>
{%- endif %}
{%- if b.flags %} · <span class="desk-flags">{{ b.flags }} flagged</span>{% endif %}
{{- dates(b.created_at, b.landed_at, now) }}
</div>
</div>
{# Badges only: a row with none renders no side column, so it reserves
no room (the row is flex — an absent item costs no gap). #}
{% if b.marks_open or b.hold == "unreadable" or section == 'new' or b.uploaded or b.booth_blurred %}
{# The right column: badges, then the LIFETIME PILL, always visible — state,
not a control, so it stays when the controls hide, and down the Desk it
reads as one column of kept / held / counting (operator: "make it
obvious which are kept and which are ephemeral"). #}
<div class="desk-side">
{% if b.marks_open %}<span class="badge badge-mark">? {{ b.marks_open }} open</span>
{% elif b.hold == "unreadable" %}<span class="badge badge-broken">marks unreadable</span>
@@ -76,8 +56,30 @@
{% if b.uploaded %}<span class="badge">⬆ pickup</span>{% endif %}
{# r2b D2b: a fogged strip says why. Information, not the control. #}
{% if b.booth_blurred %}<span class="badge badge-blur" title="the whole booth is blurred — cosmetic only">◉ blurred</span>{% endif %}
<span class="life {{ 'life-kept' if b.kept else ('life-held' if b.hold in ('open', 'unreadable') else 'life-count') }}">{{ lifetime(b.kept, b.hold, b.expires_in) }}</span>
</div>
{# The row's controls, LAST in the markup so the booth's name comes first
in tab order (heid bug-hunt: wipe used to be reachable before the booth
it acts on). Where a real hover exists they float over the strip's
top-right corner — covering pictures, never information — and appear
only on hover or keyboard focus (operator: "download, keep and release
buttons only appear on mouseover"; x hides too, his answer). Anywhere
else they are the row's last line, visible: hover-only would mean no
controls at all on touch. Order: zip, keep or release, then wipe set
apart — zip out of the middle (operator), release still next to x. #}
<div class="desk-acts">
<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" method="post" action="/b/{{ b.name_url }}/unkeep"
data-booth="{{ b.name }}" data-confirm="release"><button title="release this booth so it can be wiped">release</button></form>
<form class="wipe wipe-kept" method="post" action="/b/{{ b.name_url }}/delete"
data-booth="{{ b.name }}" data-confirm="wipe-kept"><button title="wipe this KEPT booth now" aria-label="wipe kept booth">× wipe</button></form>
{%- else %}
<form class="keepit" method="post" action="/b/{{ b.name_url }}/keep"><button title="keep — exempt from the {{ ttl_hours }}h sweep" aria-label="keep booth">★ keep</button></form>
<form class="wipe" method="post" action="/b/{{ b.name_url }}/delete"
data-booth="{{ b.name }}" data-confirm="wipe"><button title="wipe now" aria-label="wipe booth">× wipe</button></form>
{%- endif %}
</div>
{% endif %}
</article>
{%- endmacro %}
@@ -77,7 +77,18 @@ on mouseover"*.
top-right, which is still picture.
- **Everywhere else (no hover, a coarse primary pointer, or any coarse pointer
present), the cluster is visible and in flow**, on its own line at the
bottom of the row. Hover-only would mean no
bottom of the row.
- **The cluster is LAST in the row's markup**, so the booth's name comes first
in tab order and wipe comes last. Where a real hover exists it is placed over
the strip from the row's own box: the strip keeps to the row's top, 210px
wide from the row's 12px padding.
- Accepted: the hover query uses Media Queries 4 `not (...)`. An engine
without MQ4 drops the whole query, and the failure is the safe
direction: the controls show, in flow.
- Known limit: the touch-LAPTOP branch (a fine primary pointer plus a coarse
one) cannot be emulated, because Chromium's touch emulation makes the
primary pointer coarse. That branch is covered by reading the media query,
not by a test. Hover-only would mean no
controls at all on touch. Every control there is at least 28px square
(r2's Slate T2 floor).
- **× hides with the others** (OPEN-1, answered yes). A visible × on every row
@@ -97,9 +108,25 @@ Merge 2, with the row. booth-dev has put both on the record (thread
None when the filesystem cannot say. None renders as NOTHING, never a guess.
- "Updated" is `landed_at`, the content clock the "new" section already reads.
They render on the Desk row and in the booth header as dated FACTS, a
different kind of thing from the lifetime pill (state) and the controls
(actions). Their exact form is settled in merge 2 against the built row.
They render on the Desk row's facts line and in the booth header's status line
as dated FACTS, a different kind of thing from the lifetime pill (state) and the
controls (actions). One macro, `_dates.html`, serves both:
- **created** is a DATE, "created 12 Sep" (with the year only when it is not
this year's);
- **updated** is an AGE, "updated 5d ago", measured from ONE clock per page
(`now` in the context), so every row is measured from the same instant;
- each is a `<time>` with `datetime` (ISO) and its exact local stamp as the
title;
- **updated shows whenever it differs from created by a minute or more,
EITHER way.** Copied files keep their mtimes while the folder is born now,
so content can be older than its booth. Within a minute, one date;
- a content clock AHEAD of now is said as its DATE, never as an age ("updated
just now" would be false);
- an age is in its largest whole unit, a day being 24h;
- a date the filesystem cannot give, or the calendar cannot hold, renders
NOTHING. The filters never raise: the Desk renders every row in one
response, so one unrenderable clock would otherwise 500 the index for every
booth.
## D2 — Reveal all (blur ruling A)
@@ -208,6 +235,8 @@ reading; this is the control the operator uses, which the blur ruling assumed.
- **Mechanism: `data-theme` on `<html>`.** Absent = the OS preference, exactly
today's sheet. An early `<head>` script sets it before first paint, so a
forced theme never flashes the other one.
- **A choice made in another tab moves every open Booth page** (the `storage`
event), as it moves the ask chrome.
- **System is live-following BY CONSTRUCTION.** Choosing System removes
`data-theme`, and the `prefers-color-scheme` media query takes over. A media
query tracks the OS live, so no `matchMedia` listener is needed: JS never
@@ -234,11 +263,15 @@ reading; this is the control the operator uses, which the blur ruling assumed.
old lacked. The committed test checks each re-scoped copy against the
UNMOVED dark block. SVOS's light and dark declare the same 42 properties,
and its dark-hc and light-hc the same 41, so a declaration the transform
drops fails it.
drops fails it. Light declares every dark-block property PLUS the art
layer's four light-only values (the three shadows and the armed glow).
That extra set is written in the test from SVOS, never derived from the
copies, so dropping it from both copies fails too.
- **No JS: no toggle, and the page follows the OS**, as today.
- **The toggle reaches inside verbatim pages** (operator: "theme toggle reaches
inside"). `embed.js` reads the same `localStorage["booth.theme"]` (the same
origin) and marks each `.bk-ask` it injects with `data-bk-theme`. Its
origin) and marks each `.bk-ask` IT MOUNTED (never an author's own element
of that class) with `data-bk-theme`. Its
colours follow that attribute exactly as the Booth's own sheet follows
`data-theme`: forced when set, OS when absent. It follows a change made in
another tab through the `storage` event. It sets nothing on the host page's
@@ -299,11 +332,25 @@ reading; this is the control the operator uses, which the blur ruling assumed.
pressing Light/Dark changes `--surface-base` and survives a reload (new page,
same context); System plus an emulated OS scheme flip changes it WITHOUT a
reload.
- `a_forced_theme_follows_high_contrast`: forced dark + `prefers-contrast:
more` resolves dark-hc's surface token.
- `a_forced_theme_follows_high_contrast`: under `prefers-contrast: more`,
forced dark resolves exactly what OS dark does, and forced light what OS
light does. The check reads tokens that DIFFER between a theme and its
high-contrast variant (`--text-faint`, `--border-default`; `--surface-card`
is the same in both and would prove nothing), and asserts that high contrast
actually changed them.
- `a_forced_theme_and_the_os_theme_are_the_same_declarations`: each light copy
equals the other and declares exactly the dark block's property set; each
light-hc copy equals the other and declares exactly the dark-hc block's.
equals the other and declares the dark block's property set plus the four
art-light values; each light-hc copy equals the other and declares exactly
the dark-hc block's.
- `created_and_updated_are_dated_facts_and_none_says_nothing`,
`updated_shows_whenever_it_differs_from_created_and_a_future_one_says_its_date`,
`a_date_no_calendar_can_hold_renders_nothing_and_never_500s`,
`an_age_is_said_in_its_largest_whole_unit` (D1b).
- `a_rows_booth_name_comes_before_its_controls_in_tab_order`,
`the_pill_shows_at_rest_and_focus_reveals_the_controls`,
`with_scripts_off_a_rows_controls_still_act` (D1).
- `a_theme_chosen_in_one_tab_moves_the_others`,
`the_theme_marks_only_the_ask_fragments_we_mounted` (D3).
## Assertions that change (declared before the code)
@@ -311,6 +358,8 @@ reading; this is the control the operator uses, which the blur ruling assumed.
|---|---|---|---|
| test_flow_browser `test_a_rows_keep_release_and_wipe_take_no_room_of_their_own` | controls visible at rest; a row with no badge has no side column (gap ≤14px) | replaced by `the_row_controls_take_no_room_where_a_hover_exists` | the operator ruled hover-reveal; the side column now always holds the lifetime pill |
| test_flow_browser `test_on_a_touch_screen_the_row_controls_keep_their_tap_floor` | measures `.desk-facts form button` | the same floor, measured on `.desk-acts` controls | the controls moved; the floor did not |
| test_flow_browser `test_the_wipe_dialog_shows_what_is_being_wiped_and_never_fails_open` | clicks the row's wipe at rest | hovers the row first, then clicks | wipe is hidden until hover (OPEN-1, answered yes) |
| tests/mutations/r2_flow.toml, four rows on the facts-line controls | proved the controls visible at rest on the facts line | retired, with successors in r2b.toml | their tests were replaced, as declared above |
## Out of scope
+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"
+264
View File
@@ -282,3 +282,267 @@ 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:17px;left:calc(12px + 210px - 5px);transform:translateX(-100%);flex:none;gap:4px;padding:3px;'''
new = '''
.desk-acts{position:static;top:17px;left:calc(12px + 210px - 5px);transform:translateX(-100%);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{flex:1 0 100%;display:flex;flex-wrap:wrap;align-items:center;gap:6px}'''
new = '''
.desk-acts{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-held' if b.hold == 'never' else '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 = '''{% macro dates(created_at, landed_at, now) -%}'''
new = '''{% macro dates(created_at, landed_at, now) -%}{%- set created_at = created_at or landed_at -%}'''
[[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 {{ age|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 a stylesheet precedes the stored-theme script (a flash)"
file = "booth/templates/base.html"
test = "tests/test_flow.py::test_a_forced_theme_is_applied_before_first_paint"
old = '''
<script>
(function () {
var d = document.documentElement, b = d.getAttribute('data-booth');'''
new = '''
<style></style>
<script>
(function () {
var d = document.documentElement, b = d.getAttribute('data-booth');'''
[[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();'''
# ---- merge-2 folds: heid bug-hunt ("GORE-7") and code-review ("STAGGER-3")
# Not expressible as one replacement, so pinned by their tests directly: the
# row's DOM tab order (read in order by the test), and a declaration dropped
# from BOTH light copies (the expected set is now written from SVOS, not
# derived from the copies).
[[mutation]]
label = "a date the calendar cannot hold raises through the Desk"
file = "booth/app.py"
test = "tests/test_flow.py::test_a_date_no_calendar_can_hold_renders_nothing_and_never_500s"
old = '''
_BAD_DATE = (OverflowError, OSError, ValueError)'''
new = '''
_BAD_DATE = ()'''
[[mutation]]
label = "updated is dropped for content older than its booth (one-sided gap)"
file = "booth/templates/_dates.html"
test = "tests/test_flow.py::test_updated_shows_whenever_it_differs_from_created_and_a_future_one_says_its_date"
old = '''(landed_at - created_at)|abs >= 60'''
new = '''(landed_at - created_at) >= 60'''
[[mutation]]
label = "a content clock ahead of now reads as an age"
file = "booth/templates/_dates.html"
test = "tests/test_flow.py::test_updated_shows_whenever_it_differs_from_created_and_a_future_one_says_its_date"
old = '''{%- if age < -60 %}'''
new = '''{%- if false %}'''
[[mutation]]
label = "hours run on to 47h (1d ago never appears)"
file = "booth/app.py"
test = "tests/test_flow.py::test_an_age_is_said_in_its_largest_whole_unit"
old = '''
if s < 86400:
return f"{s // 3600}h ago"'''
new = '''
if s < 2 * 86400:
return f"{s // 3600}h ago"'''
[[mutation]]
label = "a theme chosen in one tab does not reach the Booth's other open tabs"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_a_theme_chosen_in_one_tab_moves_the_others"
old = '''
if (e.key !== 'booth.theme' && e.key !== null) return;
var t = null;'''
new = '''
return;
var t = null;'''
[[mutation]]
label = "the theme mark reaches the author's own .bk-ask"
file = "booth/static/embed.js"
test = "tests/test_flow_browser.py::test_the_theme_marks_only_the_ask_fragments_we_mounted"
old = '''
ours.forEach(function (root) {'''
new = '''
[document.body].forEach(function (root) {'''
[[mutation]]
label = "keyboard focus no longer reveals the row's controls"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_pill_shows_at_rest_and_focus_reveals_the_controls"
old = '''
.desk-row:hover .desk-acts,.desk-row:focus-within .desk-acts{opacity:1;pointer-events:auto}'''
new = '''
.desk-row:hover .desk-acts{opacity:1;pointer-events:auto}'''
[[mutation]]
label = "the lifetime pill hides at rest"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_pill_shows_at_rest_and_focus_reveals_the_controls"
old = '''
.life-count::before{content:"◷"}'''
new = '''
.life-count::before{content:"◷"}
.life{opacity:0}'''
[[mutation]]
label = "high contrast never applies (dark-hc block gone)"
file = "booth/templates/_svos_tokens.css"
test = "tests/test_flow_browser.py::test_a_forced_theme_follows_high_contrast"
old = '''
/* dark high contrast: whenever dark is in effect (light, below, outranks it) */
@media (prefers-contrast: more) {'''
new = '''
/* dark high contrast: whenever dark is in effect (light, below, outranks it) */
@media (prefers-contrast: less) {'''
[[mutation]]
label = "Reveal all does not reach a blurred doc's own page"
file = "booth/templates/doc.html"
test = "tests/test_flow_browser.py::test_reveal_all_lifts_the_doc_page_it_reaches"
old = '''
.reveal-all .docbody.is-blurred .markdown-body,.reveal-all .docbody.is-blurred .textview{filter:none}'''
new = '''
.reveal-all-OFF .docbody.is-blurred .markdown-body,.reveal-all .docbody.is-blurred .textview{filter:none}'''
+164
View File
@@ -997,3 +997,167 @@ 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
# Light overrides every dark property plus the art layer's four light-only
# values. That extra set is written here, from SVOS, NOT derived from the
# copies: deriving it from them let a transform that dropped an art-light
# value from BOTH copies pass (heid code-review, 2/4).
art_light = {"--shadow-sm", "--shadow-md", "--shadow-lg", "--glow-armed"}
assert art_light <= props(art)
assert props(forced_light) == props(dark) | art_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})
broken = _booth(tmp_path, "broken", {"a.png": PNG})
(broken / ".marks.json").write_text("{not json")
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)
assert pill("broken").group(1) == "life-held" and "marks unreadable" in pill("broken").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
def test_a_date_no_calendar_can_hold_renders_nothing_and_never_500s(tmp_path, monkeypatch):
"""heid bug-hunt (4/4): the date filters raised on a timestamp outside the
calendar's range, and the Desk renders every row in one response — one
poisoned clock 500'd the index for every booth. A date that cannot be
rendered renders nothing, like a birth time the disk cannot give."""
import booth.app as app_mod
_booth(tmp_path, "g", {"a.png": PNG})
_booth(tmp_path, "ok", {"a.png": PNG})
monkeypatch.setattr(app_mod, "birth_time", lambda p: 1e20 if p.name == "g" else None)
monkeypatch.setattr(app_mod, "_content_mtime", lambda p: -1e20 if p.name == "g" else time.time() - 3600)
c = _client(tmp_path)
r = c.get("/")
assert r.status_code == 200
assert "created" not in re.search(r'class="desk-facts".*?</div>', _row(r.text, "g"), re.S).group(0)
assert c.get("/b/g/").status_code == 200
@pytest.mark.parametrize("age,words", [(30, "just now"), (59 * 60, "59m ago"), (23 * 3600, "23h ago"),
(30 * 3600, "1d ago"), (47 * 3600, "1d ago"), (5 * 86400, "5d ago")])
def test_an_age_is_said_in_its_largest_whole_unit(age, words):
"""heid bug-hunt (groa): hours ran on to 47h, so "1d ago" never appeared,
against the helper's own docstring."""
from booth.app import date_ago
assert date_ago(age) == words
def test_updated_shows_whenever_it_differs_from_created_and_a_future_one_says_its_date(tmp_path, monkeypatch):
"""heid bug-hunt (hulda, groa): "updated" was dropped for content OLDER than
the booth — copied files keep their mtimes while the folder is born now —
and a future content clock read "updated just now". Either direction of a
real gap shows; a clock ahead of now shows its date, never an age."""
import booth.app as app_mod
now = time.time()
_booth(tmp_path, "copied", {"a.png": PNG})
_booth(tmp_path, "ahead", {"a.png": PNG})
monkeypatch.setattr(app_mod, "birth_time", lambda p: now - 3600)
monkeypatch.setattr(app_mod, "_content_mtime",
lambda p: now - 30 * 86400 if p.name == "copied" else now + 5 * 86400)
body = _client(tmp_path).get("/").text
facts = lambda n: re.search(r'class="desk-facts".*?</div>', _row(body, n), re.S).group(0)
assert re.search(r">updated 30d ago</time>", facts("copied"))
ahead = facts("ahead")
assert "just now" not in ahead and re.search(r">updated \d{1,2} [A-Z][a-z]{2}( \d{4})?</time>", ahead)
+351 -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,352 @@ 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}]})
# tokens that DIFFER between a theme and its high-contrast variant
# (--surface-card does not, so reading it saw nothing — heid, 2/4)
got[(os_scheme, forced)] = (page.evaluate(_VAR, "--text-faint"), page.evaluate(_VAR, "--border-default"))
if forced is None and os_scheme == "dark":
cdp.send("Emulation.setEmulatedMedia", {"features": [
{"name": "prefers-contrast", "value": "no-preference"},
{"name": "prefers-color-scheme", "value": os_scheme}]})
got["dark, no contrast"] = (page.evaluate(_VAR, "--text-faint"), page.evaluate(_VAR, "--border-default"))
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)]
assert got[("dark", None)] != got["dark, no contrast"] # high contrast really applied
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
def test_a_rows_booth_name_comes_before_its_controls_in_tab_order(browser, live):
"""heid bug-hunt (groa): the cluster sat before the text in the markup, so
the first tab stop on a row was zip, then keep, then WIPE — before the booth
it acts on — and with scripts off, wipe submits with no confirm. The name
is first; the controls follow, wipe last."""
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")
order = page.evaluate("""() => { const row = document.querySelector('.desk-row[data-booth="loose"]');
return [...row.querySelectorAll('a[href], button')].filter(e => e.tabIndex >= 0)
.map(e => e.classList.contains('desk-title') ? 'title' : e.closest('form') ? e.closest('form').className.split(' ')[0] : e.className.split(' ')[0]); }""")
ctx.close()
assert order == ["title", "dl-link", "keepit", "wipe"], order
def test_a_theme_chosen_in_one_tab_moves_the_others(browser, live):
"""heid bug-hunt (groa): the ask chrome followed a choice made in another
tab, but the Booth's own open pages did not until reloaded."""
base, root = live
_set(root, 1)
ctx = browser.new_context(color_scheme="dark")
a, b = ctx.new_page(), ctx.new_page()
a.goto(f"{base}/", wait_until="networkidle")
b.goto(f"{base}/b/g/", wait_until="networkidle")
a.locator('.theme [data-theme-choice="light"]').click()
b.wait_for_function("document.documentElement.getAttribute('data-theme') === 'light'", timeout=5000)
pressed = b.locator('.theme [aria-pressed="true"]').get_attribute("data-theme-choice")
a.locator('.theme [data-theme-choice="system"]').click()
b.wait_for_function("!document.documentElement.hasAttribute('data-theme')", timeout=5000)
ctx.close()
assert pressed == "light"
def test_the_theme_marks_only_the_ask_fragments_we_mounted(browser, live):
"""heid bug-hunt (regin): the theme mark went on every `.bk-ask` in the
page, the author's own included. Only fragments the embed mounted."""
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><div class="bk-ask" id="authors">mine</div>'
'<script src="/_booth/embed.js" defer></script></body>')
ctx = browser.new_context()
page = ctx.new_page()
page.add_init_script("try { localStorage.setItem('booth.theme', 'light'); } catch (e) {}")
page.goto(f"{base}/b/rep/", wait_until="networkidle")
page.wait_for_selector(".bk-ask:not(#authors)")
got = page.evaluate("""() => [document.getElementById('authors').getAttribute('data-bk-theme'),
[...document.querySelectorAll('.bk-ask:not(#authors)')].map(e => e.getAttribute('data-bk-theme'))]""")
ctx.close()
assert got[0] is None and got[1] and all(t == "light" for t in got[1]), got
def test_the_pill_shows_at_rest_and_focus_reveals_the_controls(browser, live):
"""heid code-review: the pill's "visible with no hover" was never asserted
(an opacity-0 box keeps its geometry), and nothing pinned KEYBOARD focus
revealing the controls — `:focus-within` could go alone."""
base, root = live
_two_rows(root)
page = browser.new_page(viewport={"width": 1400, "height": 900})
page.goto(f"{base}/", wait_until="networkidle")
page.mouse.move(0, 0)
row = page.locator('.desk-row[data-booth="loose"]')
pill = row.locator(".life").evaluate("e => [getComputedStyle(e).opacity, getComputedStyle(e).visibility, e.offsetWidth > 0]")
row.locator(".desk-acts .dl-link").focus()
page.wait_for_timeout(350)
focused = row.locator(".desk-acts").evaluate("a => [getComputedStyle(a).opacity, getComputedStyle(a).pointerEvents]")
page.close()
assert pill == ["1", "visible", True], pill
assert focused == ["1", "auto"], focused
def test_with_scripts_off_a_rows_controls_still_act(browser, live):
"""r2b INV-2, never exercised with JS actually off (heid code-review): the
hover reveal is CSS, and keep is a plain form."""
base, root = live
_two_rows(root)
ctx = browser.new_context(java_script_enabled=False, viewport={"width": 1400, "height": 900})
page = ctx.new_page()
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()
ctx.close()
assert (root / "loose" / ".forever").exists()
def test_reveal_all_lifts_the_doc_page_it_reaches(browser, live):
"""heid code-review (regin, seat-settled): the doc page carries the rules,
but no test turned Reveal all on and looked there."""
from booth.app import set_blurred
base, root = live
b = root / "g"
b.mkdir()
(b / "a.png").write_bytes(PNG)
(b / "n.md").write_text("# secret\n\nbody")
set_blurred(b, "a.png", True)
set_blurred(b, "n.md", True)
page = browser.new_page(viewport={"width": 1200, "height": 800})
page.goto(f"{base}/b/g/", wait_until="networkidle")
page.locator("[data-reveal-all]").click()
page.goto(f"{base}/b/g/view?f=n.md", wait_until="networkidle")
_settle(page)
got = [page.evaluate(_FILTER, "#docbody .markdown-body, #docbody .textview"),
page.evaluate("getComputedStyle(document.getElementById('docreveal')).display")]
page.close()
assert got == ["none", "none"], got