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.436d234is 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.1558a7ffolds both panels.
This commit is contained in:
@@ -253,6 +253,62 @@ def human_dur(seconds: float) -> str:
|
|||||||
return "<1m"
|
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:
|
def _newest_mtime(path: Path) -> float:
|
||||||
"""Newest mtime among a folder and everything under it — OUR LOCKS EXCEPT.
|
"""Newest mtime among a folder and everything under it — OUR LOCKS EXCEPT.
|
||||||
|
|
||||||
@@ -1011,6 +1067,10 @@ def create_app(
|
|||||||
auto_reload=False,
|
auto_reload=False,
|
||||||
)
|
)
|
||||||
env.filters["dur"] = human_dur
|
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)
|
templates = Jinja2Templates(env=env)
|
||||||
|
|
||||||
# embed.js IS READ ONCE, HERE, for exactly the reason above. It is the third
|
# 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": bookmarks[:BOOKMARKS_SHOWN],
|
||||||
"bookmarks_total": len(bookmarks),
|
"bookmarks_total": len(bookmarks),
|
||||||
"board_url": f"/b/{quote(links_board, safe='')}/",
|
"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.
|
# handed to the operator lands HERE and not on the index.
|
||||||
"created_at": birth_time(booth),
|
"created_at": birth_time(booth),
|
||||||
"landed_at": _content_mtime(booth),
|
"landed_at": _content_mtime(booth),
|
||||||
|
"now": time.time(),
|
||||||
# The lifetime line, same three states as the index card: a
|
# The lifetime line, same three states as the index card: a
|
||||||
# booth URL handed to the operator lands HERE, not on the index,
|
# booth URL handed to the operator lands HERE, not on the index,
|
||||||
# so "why is this not counting down" has to be answerable here.
|
# so "why is this not counting down" has to be answerable here.
|
||||||
|
|||||||
+39
-3
@@ -27,6 +27,12 @@
|
|||||||
if (window.__boothEmbed) return; // declared AND appended: mount once
|
if (window.__boothEmbed) return; // declared AND appended: mount once
|
||||||
window.__boothEmbed = true;
|
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 = [
|
var CSS = [
|
||||||
/* SVOS values, written as literals: this sheet lands in a page we did not
|
/* 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
|
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-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-on-accent:#0c1014;--bk-open:#d29a02;--bk-open-text:#fbc10f;--bk-done:#71a166;--bk-done-text:#9bce90;",
|
||||||
"--bk-skip:#868d91;--bk-err:#fea47d}",
|
"--bk-skip:#868d91;--bk-err:#fea47d}",
|
||||||
"@media (prefers-color-scheme: light){.bk-ask{--bk-accent:#586519;--bk-accent-line:rgba(88,101,25,.55);",
|
/* r2b D3 — the operator's theme reaches inside ("theme toggle reaches
|
||||||
"--bk-accent-soft:rgba(88,101,25,.11);--bk-on-accent:#fff;--bk-open:#7c5500;--bk-open-text:#7c5500;",
|
inside"): light when the OS asks and dark is not forced, or when light
|
||||||
"--bk-done:#486741;--bk-done-text:#486741;--bk-skip:#52595e;--bk-err:#a42e07}}",
|
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);",
|
".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);",
|
"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}",
|
"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;
|
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() {
|
function start() {
|
||||||
var name = boothName();
|
var name = boothName();
|
||||||
if (!name || !document.body) return;
|
if (!name || !document.body) return;
|
||||||
@@ -350,6 +383,9 @@
|
|||||||
if (!data) return;
|
if (!data) return;
|
||||||
favicon(data.favicon);
|
favicon(data.favicon);
|
||||||
var mounted = place(data.marks || []);
|
var mounted = place(data.marks || []);
|
||||||
|
ours = [];
|
||||||
|
Object.keys(mounted).forEach(function (id) { ours = ours.concat(mounted[id]); });
|
||||||
|
bkTheme();
|
||||||
reassociate();
|
reassociate();
|
||||||
asksChip(data.open || [], mounted);
|
asksChip(data.open || [], mounted);
|
||||||
document.dispatchEvent(new CustomEvent("booth:mounted", { detail: { booth: name } }));
|
document.dispatchEvent(new CustomEvent("booth:mounted", { detail: { booth: name } }));
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
/* SVOS tokens — VENDORED BY COPY from design-systems
|
/* SVOS tokens — VENDORED BY COPY from design-systems
|
||||||
palettes/svos/colors.css + svos-theme.css @ ed2f8d8. Do not hand-edit values;
|
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
|
re-vendor from the source. The only transform is SCOPING: SVOS selects its
|
||||||
by [data-theme]; the Booth has no theme switch and follows the OS, so the
|
four themes by [data-theme]; the Booth follows the OS unless the viewer
|
||||||
blocks are re-scoped onto prefers-color-scheme / prefers-contrast queries.
|
forces a theme (the top-bar toggle sets data-theme on <html>). So light
|
||||||
Semantic tokens only in the Booth layer below — never a primitive, never
|
applies when the OS asks and dark is not forced, or when light is forced;
|
||||||
a raw hex. */
|
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 {
|
:root {
|
||||||
--graphite-10: oklch(0.17 0.01 250);
|
--graphite-10: oklch(0.17 0.01 250);
|
||||||
--graphite-15: oklch(0.21 0.01 248);
|
--graphite-15: oklch(0.21 0.01 248);
|
||||||
@@ -42,7 +44,7 @@
|
|||||||
--graphite-ink: var(--graphite-15);
|
--graphite-ink: var(--graphite-15);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* dark — the lair default */
|
/* dark — the lair default, and data-theme="dark" */
|
||||||
:root {
|
:root {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--surface-sunken: var(--graphite-10);
|
--surface-sunken: var(--graphite-10);
|
||||||
@@ -129,8 +131,9 @@
|
|||||||
--glow-armed: 0 0 12px color-mix(in oklab, var(--accent) 40%, transparent);
|
--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) {
|
@media (prefers-color-scheme: light) {
|
||||||
:root {
|
:root:not([data-theme="dark"]) {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
--surface-sunken: var(--graphite-94);
|
--surface-sunken: var(--graphite-94);
|
||||||
--surface-base: var(--graphite-96);
|
--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) {
|
@media (prefers-contrast: more) {
|
||||||
:root {
|
:root {
|
||||||
--surface-sunken: var(--graphite-10);
|
--surface-sunken: var(--graphite-10);
|
||||||
@@ -226,8 +280,56 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) and (prefers-contrast: more) {
|
/* light high contrast: the OS light case ... */
|
||||||
:root {
|
@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-sunken: var(--graphite-94);
|
||||||
--surface-base: var(--graphite-96);
|
--surface-base: var(--graphite-96);
|
||||||
--surface-raised: var(--graphite-98);
|
--surface-raised: var(--graphite-98);
|
||||||
|
|||||||
+122
-25
@@ -12,6 +12,12 @@
|
|||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
var d = document.documentElement, b = d.getAttribute('data-booth');
|
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 {
|
try {
|
||||||
if (b !== null && sessionStorage.getItem('booth.reveal:' + b) === '1') d.classList.add('reveal-all');
|
if (b !== null && sessionStorage.getItem('booth.reveal:' + b) === '1') d.classList.add('reveal-all');
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
@@ -108,6 +114,18 @@
|
|||||||
.brand .name{font-weight:650;font-size:var(--size-h2);letter-spacing:var(--tracking-h)}
|
.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;
|
.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)}
|
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}
|
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}
|
.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-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)}
|
.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
|
/* THE LIFETIME PILL (r2b D1) — state, not a control, so it never hides.
|
||||||
state each one changes. Quiet — muted mono like the line they sit in, so
|
Sage for kept (SVOS: a judgment made), amber for held (a question holds
|
||||||
a destructive control does not compete with the thing you came to read —
|
it), a quiet outline while it counts down. */
|
||||||
but always there and always tappable; wipe turns danger only under the
|
.life{display:inline-flex;align-items:center;gap:5px;padding:3px 9px;border-radius:999px;white-space:nowrap;
|
||||||
pointer or focus. */
|
font-family:var(--font-mono);font-size:var(--size-caption);border:1px solid var(--border-default);color:var(--text-muted)}
|
||||||
.desk-facts form{display:inline;margin:0}
|
.life-kept{color:var(--success-text);background:var(--success-soft);border-color:color-mix(in oklab,var(--success) 45%,transparent)}
|
||||||
.desk-facts .dl-link{white-space:nowrap}
|
.life-kept::before{content:"★"}
|
||||||
.desk-facts form button{display:inline-block;margin-left:4px;padding:1px 6px;height:auto;min-width:0;
|
.life-held{color:var(--warning-text);background:var(--warning-soft);border-color:color-mix(in oklab,var(--warning) 45%,transparent)}
|
||||||
font:inherit;line-height:1.4;color:var(--text-muted);background:none;
|
.life-count::before{content:"◷"}
|
||||||
border:1px solid var(--border-default);border-radius:var(--radius-sm);
|
.life .held,.life .held-broken{color:inherit}
|
||||||
-webkit-backdrop-filter:none;backdrop-filter:none;cursor:pointer}
|
.desk-facts time{white-space:nowrap}
|
||||||
.desk-facts .wipe button{margin-left:0}
|
|
||||||
.desk-facts form button:hover,.desk-facts form button:focus-visible{color:var(--text-body);
|
/* THE ROW'S CONTROLS (r2b D1), last in the row's markup. DEFAULT — no real
|
||||||
border-color:var(--border-strong);background:var(--surface-overlay)}
|
hover (touch, any coarse pointer, or no hover at all): the cluster is the
|
||||||
.desk-facts .wipe button:hover,.desk-facts .wipe button:focus-visible{background:var(--danger);
|
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)}
|
border-color:var(--danger);color:var(--danger-contrast)}
|
||||||
/* A finger is not a cursor. The compact line is for a fine pointer; on a
|
/* A REAL HOVER — a fine pointer that hovers, and no coarse pointer anywhere
|
||||||
coarse one every control gets back the 28px floor it had as a column
|
(a touch laptop reports a mouse, but a finger on it cannot hover): the
|
||||||
(32px here, with room), and wipe stands clear of the zip link — with
|
cluster takes NO room. It floats over the strip's top-right corner,
|
||||||
scripts off no confirm fires, so a mis-tap on wipe IS the delete. */
|
covering pictures and never information, and appears on row hover or
|
||||||
@media (pointer:coarse){
|
keyboard focus — opacity AND pointer events together, never visibility,
|
||||||
.desk-facts{line-height:2.4}
|
so hidden controls stay in the tab order and focusing one shows them. */
|
||||||
.desk-facts form button{min-height:32px;min-width:32px;padding:0 10px;margin-left:6px;vertical-align:middle}
|
@media (hover:hover) and (pointer:fine) and (not (any-pointer:coarse)){
|
||||||
.desk-facts .wipe button{margin-left:10px}
|
.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){
|
@media (max-width:700px){
|
||||||
.desk-row{flex-wrap:wrap}
|
.desk-row{flex-wrap:wrap}
|
||||||
.desk-strip{flex-basis:100%}
|
.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 */
|
/* the side column */
|
||||||
.desk-panel{border:1px solid var(--border-default);border-radius:var(--radius-xl);background:var(--surface-card);
|
.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}
|
.prov-broken{color:var(--warning-text);font-style:italic;cursor:help}
|
||||||
|
|
||||||
/* keep / release / wipe — the controls themselves. Where they sit is the
|
/* 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
|
header (.wipe-lg, .keep-lg). The kept/ephemeral CARDS they used to float
|
||||||
over are gone. */
|
over are gone. */
|
||||||
.wipe,.keepit,.release{margin:0}
|
.wipe,.keepit,.release{margin:0}
|
||||||
@@ -880,6 +928,12 @@
|
|||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<a class="brand" href="/"><span class="dot"></span><span class="name">The Booth</span></a>
|
<a class="brand" href="/"><span class="dot"></span><span class="name">The Booth</span></a>
|
||||||
<span class="tagline">ephemeral media · auto-wipes {{ ttl_hours }}h · kept boards don't</span>
|
<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>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
{# R2 C3: where the in-place script says it could not save in place. Server
|
{# 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);
|
if (window.ResizeObserver) new ResizeObserver(set).observe(rail);
|
||||||
})();
|
})();
|
||||||
</script>
|
</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>
|
<script>
|
||||||
/* REVEAL ALL (r2b D2). Every [data-reveal-all] control on the page shows the
|
/* 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
|
one state of this booth; all of them sit OUTSIDE every data-region, so an
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% from "_provenance.html" import provenance %}
|
{% from "_provenance.html" import provenance %}
|
||||||
{% from "_lifetime.html" import lifetime %}
|
{% from "_lifetime.html" import lifetime %}
|
||||||
|
{% from "_dates.html" import dates %}
|
||||||
{# The blur toggle, defined ONCE. There are three item branches in this file
|
{# 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
|
(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
|
them, so docs rendered with no control at all. A macro makes "patched two of
|
||||||
@@ -84,7 +85,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{# The open count and the lifetime line depend on marks, so they are a region
|
{# 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. #}
|
(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 %}
|
{% if items %}<a class="dl-link" href="/b/{{ name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>{% endif %}
|
||||||
{{ provenance(manifest) }}
|
{{ provenance(manifest) }}
|
||||||
{# A durable multi-writer board gets no one-click wipe — same rule as the
|
{# A durable multi-writer board gets no one-click wipe — same rule as the
|
||||||
|
|||||||
+32
-30
@@ -1,6 +1,7 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% from "_provenance.html" import provenance %}
|
{% from "_provenance.html" import provenance %}
|
||||||
{% from "_lifetime.html" import lifetime %}
|
{% from "_lifetime.html" import lifetime %}
|
||||||
|
{% from "_dates.html" import dates %}
|
||||||
{# THE DESK (R2 C4). The index triaged by what needs the operator: needs you,
|
{# 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
|
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
|
the ORDER WITHIN each is decided in app.index, never here. A section with no
|
||||||
@@ -36,39 +37,18 @@
|
|||||||
{%- else -%}{{ b.name }}{%- endif -%}
|
{%- else -%}{{ b.name }}{%- endif -%}
|
||||||
</a>
|
</a>
|
||||||
{{ provenance(b.manifest) }}
|
{{ provenance(b.manifest) }}
|
||||||
{# The row's controls sit ON the facts line, each beside the state it
|
{# Facts only: counts and dates. The lifetime is state (the pill) and
|
||||||
changes: release after "kept", keep after the countdown, wipe last.
|
the controls are actions (the cluster); neither lives here. #}
|
||||||
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.
|
|
||||||
`· ` glues each separator to the item after it, so a wrapped
|
|
||||||
line never ends on a dangling dot. #}
|
|
||||||
<div class="desk-facts">
|
<div class="desk-facts">
|
||||||
{{ b.count }} item{{ '' if b.count == 1 else 's' }}
|
{{ b.count }} item{{ '' if b.count == 1 else 's' }}
|
||||||
{% if b.flags %} · <span class="desk-flags">{{ b.flags }} flagged</span>{% endif %}
|
{%- if b.flags %} · <span class="desk-flags">{{ b.flags }} flagged</span>{% endif %}
|
||||||
· {{ lifetime(b.kept, b.hold, b.expires_in) }}
|
{{- dates(b.created_at, b.landed_at, now) }}
|
||||||
{%- 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 %}
|
|
||||||
· <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="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="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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{# Badges only: a row with none renders no side column, so it reserves
|
{# The right column: badges, then the LIFETIME PILL, always visible — state,
|
||||||
no room (the row is flex — an absent item costs no gap). #}
|
not a control, so it stays when the controls hide, and down the Desk it
|
||||||
{% if b.marks_open or b.hold == "unreadable" or section == 'new' or b.uploaded or b.booth_blurred %}
|
reads as one column of kept / held / counting (operator: "make it
|
||||||
|
obvious which are kept and which are ephemeral"). #}
|
||||||
<div class="desk-side">
|
<div class="desk-side">
|
||||||
{% if b.marks_open %}<span class="badge badge-mark">? {{ b.marks_open }} open</span>
|
{% 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>
|
{% 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 %}
|
{% if b.uploaded %}<span class="badge">⬆ pickup</span>{% endif %}
|
||||||
{# r2b D2b: a fogged strip says why. Information, not the control. #}
|
{# 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 %}
|
{% 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>
|
</div>
|
||||||
{% endif %}
|
|
||||||
</article>
|
</article>
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,18 @@ on mouseover"*.
|
|||||||
top-right, which is still picture.
|
top-right, which is still picture.
|
||||||
- **Everywhere else (no hover, a coarse primary pointer, or any coarse pointer
|
- **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
|
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
|
controls at all on touch. Every control there is at least 28px square
|
||||||
(r2's Slate T2 floor).
|
(r2's Slate T2 floor).
|
||||||
- **× hides with the others** (OPEN-1, answered yes). A visible × on every row
|
- **× 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.
|
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.
|
- "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
|
They render on the Desk row's facts line and in the booth header's status line
|
||||||
different kind of thing from the lifetime pill (state) and the controls
|
as dated FACTS, a different kind of thing from the lifetime pill (state) and the
|
||||||
(actions). Their exact form is settled in merge 2 against the built row.
|
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)
|
## 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
|
- **Mechanism: `data-theme` on `<html>`.** Absent = the OS preference, exactly
|
||||||
today's sheet. An early `<head>` script sets it before first paint, so a
|
today's sheet. An early `<head>` script sets it before first paint, so a
|
||||||
forced theme never flashes the other one.
|
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
|
- **System is live-following BY CONSTRUCTION.** Choosing System removes
|
||||||
`data-theme`, and the `prefers-color-scheme` media query takes over. A media
|
`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
|
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
|
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,
|
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
|
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.
|
- **No JS: no toggle, and the page follows the OS**, as today.
|
||||||
- **The toggle reaches inside verbatim pages** (operator: "theme toggle reaches
|
- **The toggle reaches inside verbatim pages** (operator: "theme toggle reaches
|
||||||
inside"). `embed.js` reads the same `localStorage["booth.theme"]` (the same
|
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
|
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
|
`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
|
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,
|
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
|
same context); System plus an emulated OS scheme flip changes it WITHOUT a
|
||||||
reload.
|
reload.
|
||||||
- `a_forced_theme_follows_high_contrast`: forced dark + `prefers-contrast:
|
- `a_forced_theme_follows_high_contrast`: under `prefers-contrast: more`,
|
||||||
more` resolves dark-hc's surface token.
|
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
|
- `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
|
equals the other and declares the dark block's property set plus the four
|
||||||
light-hc copy equals the other and declares exactly the dark-hc block's.
|
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)
|
## 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_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_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
|
## Out of scope
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,11 @@
|
|||||||
# covers them together); and `flagged_targets`' `error is None`, since
|
# covers them together); and `flagged_targets`' `error is None`, since
|
||||||
# hydration already strips the target from a damaged mark.
|
# 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
|
# 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
|
# refresh in the client: localhost alone never lost the race, and the first
|
||||||
# draft of that test stayed green with serialization deleted.
|
# draft of that test stayed green with serialization deleted.
|
||||||
@@ -159,26 +164,6 @@ new = '''
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
continue'''
|
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]]
|
[[mutation]]
|
||||||
label = "the stacked Desk column is a bare 1fr (content sets its minimum)"
|
label = "the stacked Desk column is a bare 1fr (content sets its minimum)"
|
||||||
file = "booth/templates/base.html"
|
file = "booth/templates/base.html"
|
||||||
@@ -206,24 +191,6 @@ old = '''
|
|||||||
new = '''
|
new = '''
|
||||||
.desk-main{flex:1 1 auto}'''
|
.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]]
|
[[mutation]]
|
||||||
label = "the wipe dialog shows the agent-made name raw (bidi, newline)"
|
label = "the wipe dialog shows the agent-made name raw (bidi, newline)"
|
||||||
file = "booth/templates/index.html"
|
file = "booth/templates/index.html"
|
||||||
|
|||||||
@@ -282,3 +282,267 @@ old = '''
|
|||||||
b = pw.chromium.launch(args=OFFLINE)'''
|
b = pw.chromium.launch(args=OFFLINE)'''
|
||||||
new = '''
|
new = '''
|
||||||
b = pw.chromium.launch()'''
|
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}'''
|
||||||
|
|||||||
@@ -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("gone.png") == "/b/g/"
|
||||||
assert loc("n.md") == "/b/g/"
|
assert loc("n.md") == "/b/g/"
|
||||||
assert loc("") == "/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
@@ -410,43 +410,6 @@ def test_the_next_arrow_clears_the_rail_only_beside_it(browser, live):
|
|||||||
assert rights == {1400: "360px", 390: "0px"}
|
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):
|
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
|
"""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
|
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
|
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):
|
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 —
|
"""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
|
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 = []
|
said = []
|
||||||
page.on("dialog", lambda dlg: (said.append(dlg.message), dlg.dismiss()))
|
page.on("dialog", lambda dlg: (said.append(dlg.message), dlg.dismiss()))
|
||||||
page.goto(f"{base}/", wait_until="networkidle")
|
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.locator("form.wipe button").first.click()
|
||||||
page.wait_for_timeout(300)
|
page.wait_for_timeout(300)
|
||||||
page.evaluate("""() => { const f = document.createElement('form');
|
page.evaluate("""() => { const f = document.createElement('form');
|
||||||
@@ -805,3 +734,352 @@ def test_the_test_browser_has_no_internet(browser, live):
|
|||||||
page.close()
|
page.close()
|
||||||
assert "ERR_NAME_NOT_RESOLVED" in str(err.value) and external < 3, (str(err.value)[:80], external)
|
assert "ERR_NAME_NOT_RESOLVED" in str(err.value) and external < 3, (str(err.value)[:80], external)
|
||||||
assert local < 10, local
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user