fix(as-S5a): every control named, one h1 and a skip link, rings and hit areas

The markup and CSS half of the anti-slop interaction work. The in-place
client is untouched (that is S5b).
- Glyph-only controls carry a name: the withdraw ×s, downloads, open full
  page, the viewers' ✕, the board's pin, copy and remove, the bench's
  remove, the 1:1 toggle ("1:1, natural pixels"). Film-strip and tray
  frames carry the file's name as sr-only text instead of reading "01".
  A Desk row's wipe names its booth.
- Fields are named by aria-label, not by their placeholder.
- The inline ask's options are a radiogroup labelled by the prompt; a
  single-question fieldset gets an sr-only legend; a titled ask's title
  takes bk-ask-<id>-title (it duplicated the question's id).
- One h1 per page (sr-only on the Desk, review and compare), a skip link
  to <main id="main">, theme-color for light and dark.
- The review tape is one picture (role=img); its segments leave the tab
  order (the film strip holds the same links, named).
- Wipe now uses the Desk's delegated prompt, moved to base.html: it names
  the booth and asks the kept-booth question for a kept booth.
- Embed focus rings of its own; rings drawn inside clipping containers;
  the withdraw × at least 24px, 44px under a coarse pointer;
  touch-action:manipulation; strips contain their overscroll; a long
  slug wraps on a phone.
- A truncated why carries its full text in title; a countdown of 48h or
  more reads in days.
Two r2_flow.toml rows for the confirm helper now name base.html, where
the helper moved (anchors unchanged; the gate found them drifted).

Contract: as_antislop S5a. Falsifiers: antislop.toml 86/86 proved (S1-S6, S5a);
all 12 tables 366/366 proved on this tree.
This commit is contained in:
vh
2026-09-28 13:30:31 -07:00
parent ec807fe41b
commit d4f64fd7ec
16 changed files with 675 additions and 82 deletions
+5
View File
@@ -231,6 +231,11 @@ def human_dur(seconds: float) -> str:
return "expired" return "expired"
h, rem = divmod(s, 3600) h, rem = divmod(s, 3600)
m, _ = divmod(rem, 60) m, _ = divmod(rem, 60)
# as S5a: past two days the hours roll up ("6d 23h", not "167h 12m"); the
# minutes are noise at that range. Under 48h the hours stay, as before.
if h >= 48:
d, h = divmod(h, 24)
return f"{d}d {h}h" if h else f"{d}d"
if h and m: if h and m:
return f"{h}h {m}m" return f"{h}h {m}m"
if h: if h:
+5
View File
@@ -47,6 +47,9 @@
"color:#dce3e5;background:rgba(12,16,20,.86);border:1px solid rgba(255,255,255,.16);", "color:#dce3e5;background:rgba(12,16,20,.86);border:1px solid rgba(255,255,255,.16);",
"-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);transition:background .12s,border-color .12s}", "-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);transition:background .12s,border-color .12s}",
".booth-nav-home:hover{background:rgba(31,35,40,.96);border-color:rgba(255,255,255,.34)}", ".booth-nav-home:hover{background:rgba(31,35,40,.96);border-color:rgba(255,255,255,.34)}",
/* as S5a: our own focus rings, so a host page's `outline:none` cannot take
them. The chips sit on any host: a white ring inside a dark halo reads on both. */
".booth-nav-home:focus-visible,.booth-nav-asks:focus-visible{outline:2px solid #fff;outline-offset:1px;box-shadow:0 0 0 4px #15191d}",
/* amber = needs you: the one chip that asks to be clicked */ /* amber = needs you: the one chip that asks to be clicked */
".booth-nav-asks{right:7.4rem;font:600 13px/1.25 'IBM Plex Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;", ".booth-nav-asks{right:7.4rem;font:600 13px/1.25 'IBM Plex Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;",
"color:#15191d;background:#fbc10f;border:1px solid #fbc10f;transition:filter .12s}", "color:#15191d;background:#fbc10f;border:1px solid #fbc10f;transition:filter .12s}",
@@ -102,6 +105,8 @@
"padding:.7rem 1.1rem;border-radius:8px;border:1px solid var(--bk-accent);", "padding:.7rem 1.1rem;border-radius:8px;border:1px solid var(--bk-accent);",
"background:var(--bk-accent);color:var(--bk-on-accent)}", "background:var(--bk-accent);color:var(--bk-on-accent)}",
".bk-ask-go:hover{filter:brightness(1.06)}", ".bk-ask-go:hover{filter:brightness(1.06)}",
".bk-ask-go:focus-visible{outline:2px solid var(--bk-accent);outline-offset:2px}",
".bk-ask-opt:has(input:focus-visible){outline:2px solid var(--bk-accent);outline-offset:1px}",
".bk-ask-was{margin:.15rem 0 .55rem;font-size:.86rem}", ".bk-ask-was{margin:.15rem 0 .55rem;font-size:.86rem}",
".bk-ask-err{color:var(--bk-err);font-size:.86rem}", ".bk-ask-err{color:var(--bk-err);font-size:.86rem}",
/* [hidden] restated at class specificity: a host rule as plain as /* [hidden] restated at class specificity: a host rule as plain as
+5 -5
View File
@@ -25,10 +25,10 @@
{% set skipped = a.answer and not picked %} {% set skipped = a.answer and not picked %}
<div class="bk-ask{% if picked %} bk-done{% elif skipped %} bk-skip{% endif %}" id="bk-ask-{{ a.id }}{% if q.key %}-{{ q.key }}{% endif %}"> <div class="bk-ask{% if picked %} bk-done{% elif skipped %} bk-skip{% endif %}" id="bk-ask-{{ a.id }}{% if q.key %}-{{ q.key }}{% endif %}">
<span class="bk-ask-tag">{% if picked %}✓ answered{% elif skipped %}— skipped{% else %}? your pick{% endif %}</span> <span class="bk-ask-tag">{% if picked %}✓ answered{% elif skipped %}— skipped{% else %}? your pick{% endif %}</span>
<p class="bk-ask-prompt">{{ q.prompt }}</p> <p class="bk-ask-prompt" id="bk-ask-{{ a.id }}{% if q.key %}-{{ q.key }}{% endif %}-prompt">{{ q.prompt }}</p>
{% if picked %}<p class="bk-ask-was">recorded: <b>{{ qa.label }}</b>{% if qa.notes %} — {{ qa.notes }}{% endif %}</p> {% if picked %}<p class="bk-ask-was">recorded: <b>{{ qa.label }}</b>{% if qa.notes %} — {{ qa.notes }}{% endif %}</p>
{% elif skipped %}<p class="bk-ask-was">left blank — pick one any time, or leave it{% if qa and qa.notes %}; note: {{ qa.notes }}{% endif %}</p>{% endif %} {% elif skipped %}<p class="bk-ask-was">left blank — pick one any time, or leave it{% if qa and qa.notes %}; note: {{ qa.notes }}{% endif %}</p>{% endif %}
<div class="bk-ask-opts"> <div class="bk-ask-opts" role="radiogroup" aria-labelledby="bk-ask-{{ a.id }}{% if q.key %}-{{ q.key }}{% endif %}-prompt">
{% for o in q.options %} {% for o in q.options %}
<label class="bk-ask-opt"> <label class="bk-ask-opt">
<input type="radio" name="{{ field }}" value="{{ o.id }}" <input type="radio" name="{{ field }}" value="{{ o.id }}"
@@ -42,7 +42,7 @@
{% if q.notes %} {% if q.notes %}
<textarea class="bk-ask-notes" name="notes.{{ q.key }}" rows="2" <textarea class="bk-ask-notes" name="notes.{{ q.key }}" rows="2"
{% if not standalone %}form="{{ form_id }}"{% endif %} {% if not standalone %}form="{{ form_id }}"{% endif %}
placeholder="notes on this one (optional)">{{ qa.notes if qa else '' }}</textarea> aria-label="notes on this one" placeholder="notes on this one (optional)">{{ qa.notes if qa else '' }}</textarea>
{% endif %} {% endif %}
</div> </div>
{% endmacro %} {% endmacro %}
@@ -59,7 +59,7 @@
{% if not a.answer %}<p class="bk-ask-was">Answer what you can — blanks are fine, and you can come back.</p>{% endif %} {% if not a.answer %}<p class="bk-ask-was">Answer what you can — blanks are fine, and you can come back.</p>{% endif %}
{% if a.notes_enabled %} {% if a.notes_enabled %}
<textarea class="bk-ask-notes" name="notes" rows="3" form="{{ form_id }}" <textarea class="bk-ask-notes" name="notes" rows="3" form="{{ form_id }}"
placeholder="{{ a.notes_label }} (optional)">{{ a.answer.notes if a.answer else '' }}</textarea> aria-label="{{ a.notes_label }}" placeholder="{{ a.notes_label }} (optional)">{{ a.answer.notes if a.answer else '' }}</textarea>
{% endif %} {% endif %}
<button type="submit" class="bk-ask-go" form="{{ form_id }}">{% if a.answer %}Update answer{% else %}Submit answer{% endif %}</button> <button type="submit" class="bk-ask-go" form="{{ form_id }}">{% if a.answer %}Update answer{% else %}Submit answer{% endif %}</button>
{# Empty and hidden: where embed.js says which asks a several-at-once submit {# Empty and hidden: where embed.js says which asks a several-at-once submit
@@ -74,7 +74,7 @@
<div class="bk-ask"><span class="bk-ask-tag">⚠ broken ask</span> <div class="bk-ask"><span class="bk-ask-tag">⚠ broken ask</span>
<p class="bk-ask-err">this question could not be read: {{ a.error }}</p></div> <p class="bk-ask-err">this question could not be read: {{ a.error }}</p></div>
{% else %} {% else %}
{% if a.title %}<p class="bk-ask-title" id="bk-ask-{{ a.id }}">{{ a.title }}</p>{% endif %} {% if a.title %}<p class="bk-ask-title" id="bk-ask-{{ a.id }}-title">{{ a.title }}</p>{% endif %}
{% for q in a.questions %}{{ question(a, q, form_id, name_url) }}{% endfor %} {% for q in a.questions %}{{ question(a, q, form_id, name_url) }}{% endfor %}
{{ submit(a, form_id, name_url) }} {{ submit(a, form_id, name_url) }}
{% endif %} {% endif %}
+8 -8
View File
@@ -40,7 +40,7 @@
<form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark" data-inplace> <form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark" data-inplace>
<input type="hidden" name="mark" value="{{ a.id }}"> <input type="hidden" name="mark" value="{{ a.id }}">
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %} {% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
<button type="submit" class="mark-x" title="withdraw this mark">×</button> <button type="submit" class="mark-x" title="withdraw this mark" aria-label="withdraw this mark">×</button>
</form> </form>
</header> </header>
<p class="mark-error">This mark could not be read: {{ a.error }}</p> <p class="mark-error">This mark could not be read: {{ a.error }}</p>
@@ -94,7 +94,7 @@
{% set field = 'choice.' ~ q.key if a.multi else 'choice' %} {% set field = 'choice.' ~ q.key if a.multi else 'choice' %}
{% set qa = a.answer.answers.get(q.key) if (a.answer and a.multi) else a.answer %} {% set qa = a.answer.answers.get(q.key) if (a.answer and a.multi) else a.answer %}
<fieldset class="mark-q"> <fieldset class="mark-q">
{% if a.multi %}<legend class="mark-q-prompt">{{ loop.index }}. {{ q.prompt }}</legend>{% endif %} {% if a.multi %}<legend class="mark-q-prompt">{{ loop.index }}. {{ q.prompt }}</legend>{% else %}<legend class="sr-only">{{ q.prompt }}</legend>{% endif %}
<div class="mark-options"> <div class="mark-options">
{% for o in q.options %} {% for o in q.options %}
<label class="mark-opt{% if qa and qa.choice == o.id %} is-current{% endif %}"> <label class="mark-opt{% if qa and qa.choice == o.id %} is-current{% endif %}">
@@ -108,12 +108,12 @@
{% endfor %} {% endfor %}
</div> </div>
{% if q.notes %} {% if q.notes %}
<textarea class="mark-notes mark-qnotes" name="notes.{{ q.key }}" rows="2" placeholder="notes on this one (optional)">{{ qa.notes if qa else '' }}</textarea> <textarea class="mark-notes mark-qnotes" name="notes.{{ q.key }}" rows="2" aria-label="notes on this one" placeholder="notes on this one (optional)">{{ qa.notes if qa else '' }}</textarea>
{% endif %} {% endif %}
</fieldset> </fieldset>
{% endfor %} {% endfor %}
{% if a.notes_enabled %} {% if a.notes_enabled %}
<textarea class="mark-notes" name="notes" rows="3" placeholder="{{ a.notes_label }} (optional)">{{ a.answer.notes if a.answer else '' }}</textarea> <textarea class="mark-notes" name="notes" rows="3" aria-label="{{ a.notes_label }}" placeholder="{{ a.notes_label }} (optional)">{{ a.answer.notes if a.answer else '' }}</textarea>
{% endif %} {% endif %}
<div class="mark-actions"> <div class="mark-actions">
<button type="submit" class="mark-submit">{% if a.answer %}Update answer{% else %}Submit answer{% endif %}</button> <button type="submit" class="mark-submit">{% if a.answer %}Update answer{% else %}Submit answer{% endif %}</button>
@@ -145,7 +145,7 @@
<div class="tray"> <div class="tray">
{% for it in tray %} {% for it in tray %}
<a class="tray-item{% if it.blurred %} is-blurred{% endif %}" href="view?f={{ it.url }}" title="{{ it.name }}"> <a class="tray-item{% if it.blurred %} is-blurred{% endif %}" href="view?f={{ it.url }}" title="{{ it.name }}">
{%- if it.kind == 'image' %}<img loading="lazy" decoding="async" src="{{ it.thumb or it.url }}" alt="">{% else %}<span class="tray-kind">{{ it.kind }}</span>{% endif -%} <span class="sr-only">{{ it.name }}</span>{%- if it.kind == 'image' %}<img loading="lazy" decoding="async" src="{{ it.thumb or it.url }}" alt="">{% else %}<span class="tray-kind">{{ it.kind }}</span>{% endif -%}
<span class="tray-ord">#{{ "%0*d"|format(ord_width, it.ordinal) }}</span></a> <span class="tray-ord">#{{ "%0*d"|format(ord_width, it.ordinal) }}</span></a>
{% endfor %} {% endfor %}
</div> </div>
@@ -163,7 +163,7 @@
<li><span class="mono">{{ m.target }}</span> <li><span class="mono">{{ m.target }}</span>
<form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark" data-inplace> <form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark" data-inplace>
<input type="hidden" name="mark" value="{{ m.id }}"> <input type="hidden" name="mark" value="{{ m.id }}">
<button type="submit" class="mark-x" title="withdraw this flag">×</button> <button type="submit" class="mark-x" title="withdraw this flag" aria-label="withdraw this flag">×</button>
</form></li> </form></li>
{% endfor %} {% endfor %}
</ul> </ul>
@@ -196,7 +196,7 @@
<form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark" data-inplace> <form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark" data-inplace>
<input type="hidden" name="mark" value="{{ a.id }}"> <input type="hidden" name="mark" value="{{ a.id }}">
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %} {% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
<button type="submit" class="mark-x" title="withdraw this note">×</button> <button type="submit" class="mark-x" title="withdraw this note" aria-label="withdraw this note">×</button>
</form> </form>
</header> </header>
<pre class="mark-text">{{ a.text }}</pre> <pre class="mark-text">{{ a.text }}</pre>
@@ -209,7 +209,7 @@
all — this is the direction that was running through chat. #} all — this is the direction that was running through chat. #}
<form class="mark-add" method="post" action="/b/{{ name_url }}/note" data-inplace> <form class="mark-add" method="post" action="/b/{{ name_url }}/note" data-inplace>
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %} {% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
<textarea name="text" rows="2" placeholder="a note on this booth, for the session that posted it"></textarea> <textarea name="text" rows="2" aria-label="a note on this booth, for the session that posted it" placeholder="a note on this booth, for the session that posted it"></textarea>
<button type="submit">Add note</button> <button type="submit">Add note</button>
</form> </form>
{% endif %} {% endif %}
+1 -1
View File
@@ -12,6 +12,6 @@
{% elif m.error %} {% elif m.error %}
<div class="prov prov-broken" title="{{ m.error }}">unreadable</div> <div class="prov prov-broken" title="{{ m.error }}">unreadable</div>
{% else %} {% else %}
<div class="prov"><span class="prov-who">{{ m.handle }}</span>{% if m.why %} · <span class="prov-why">{{ m.why }}</span>{% endif %}</div> <div class="prov"><span class="prov-who">{{ m.handle }}</span>{% if m.why %} · <span class="prov-why" title="{{ m.why }}">{{ m.why }}</span>{% endif %}</div>
{% endif %} {% endif %}
{%- endmacro %} {%- endmacro %}
+62 -1
View File
@@ -3,6 +3,8 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f0f4f5">
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#15191d">
{# r2b: per-browser state applied BEFORE FIRST PAINT, so a revealed booth does {# r2b: per-browser state applied BEFORE FIRST PAINT, so a revealed booth does
not flash blurred on the next page of the reel. Every change of page is a not flash blurred on the next page of the reel. Every change of page is a
full load (the in-place client re-fetches the current URL and never full load (the in-place client re-fetches the current URL and never
@@ -1059,9 +1061,28 @@
@media (prefers-reduced-motion: reduce){ @media (prefers-reduced-motion: reduce){
*{transition:none !important} *{transition:none !important}
} }
/* ---- as S5a: accessibility plumbing ---------------------------------- */
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;
clip:rect(0 0 0 0);white-space:nowrap;border:0}
.skip-link{position:absolute;left:12px;top:8px;z-index:2147483647;transform:translateY(-200%);
padding:8px 12px;border-radius:var(--radius-md);background:var(--surface-raised);
color:var(--text-heading);border:1px solid var(--border-strong);font-weight:600}
.skip-link:focus-visible{transform:none}
/* a ring drawn OUTSIDE is clipped by these overflow:hidden containers: draw it inside */
.theme button:focus-visible,.vtoggle button:focus-visible,.item a:focus-visible,
.desk-panel a:focus-visible{outline-offset:-2px}
/* hit areas: at least 24px anywhere, 44px under a finger */
.mark-x{min-width:24px;min-height:24px}
@media (pointer:coarse){.mark-x,.flagtoggle button,.blurtoggle button,.board-check,
.board-selall input{min-width:44px;min-height:44px}}
a,button,summary,label,input,select,textarea{touch-action:manipulation}
.film,.vcap,.doc-body,.cmp-strip{overscroll-behavior:contain}
@media (max-width:600px){.h1-slug{white-space:normal;overflow-wrap:anywhere}}
</style> </style>
</head> </head>
<body{% block body_attrs %}{% endblock %}> <body{% block body_attrs %}{% endblock %}>
<a class="skip-link" href="#main">Skip to content</a>
<header class="topbar"> <header class="topbar">
<a class="brand" href="/"><span class="dot"></span><span class="name">The&nbsp;Booth</span></a> <a class="brand" href="/"><span class="dot"></span><span class="name">The&nbsp;Booth</span></a>
<span class="tagline">held for review · wipes in {{ ttl_hours }}h unless kept</span> <span class="tagline">held for review · wipes in {{ ttl_hours }}h unless kept</span>
@@ -1072,7 +1093,7 @@
<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> <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> </div>
</header> </header>
<main> <main id="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
rendered and empty, so the script only ever sets its text — it builds no rendered and empty, so the script only ever sets its text — it builds no
markup (INV-6). #} markup (INV-6). #}
@@ -1475,5 +1496,45 @@
<footer class="foot"> <footer class="foot">
drop a folder into <code>{{ data_dir }}</code>{% if host %} · {{ host }}{% endif %} drop a folder into <code>{{ data_dir }}</code>{% if host %} · {{ host }}{% endif %}
</footer> </footer>
<script>
/* as S5a: moved here from index.html so every page's destructive forms
(the Desk's wipe and release, a booth's own wipe button) share ONE prompt that names
the booth and fails closed on an unknown word. */
/* Destructive-action confirmation, delegated and DATA-DRIVEN. The booth name
travels as a data attribute, where escaping is escaping, and never reaches
a JS string literal: a booth name is agent-authored, and an inline handler
carrying one was a live injection path. With JS off the form submits
without a prompt. */
(function () {
var WORDS = {
release: function (n) {
return 'Release “' + n + '”?\n\nIt rejoins the sweep: it will be wiped '
+ '{{ ttl_hours|int }}h after its last activity. Nothing is deleted by this step.';
},
'wipe-kept': function (n) {
return 'WIPE the KEPT booth “' + n + '”?\n\nThis deletes it and its files '
+ 'immediately. Kept booths are the ones nothing else will clean up, so nobody '
+ 'else is going to do this for you — and nothing brings it back.';
},
wipe: function (n) { return 'Wipe booth “' + n + '”?'; }
};
/* A word the page does not know still ASKS: fail closed. Keying the
prompt on `word &&` meant a typo'd data-confirm submitted unguarded. */
var ASK = function (n) { return 'Really do this to “' + n + '”?'; };
/* The name as the operator should READ it. Escaping keeps it out of any
script, but a bidi override (U+202E) or a newline in an agent-made name
still rewrote the dialog's text — what is read before approving a wipe.
Controls and bidi formatting show as U+FFFD, visibly, never silently. */
function shown(n) {
return n.replace(/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, '\ufffd');
}
document.addEventListener('submit', function (ev) {
var form = ev.target.closest ? ev.target.closest('form[data-confirm]') : null;
if (!form) return;
var word = WORDS[form.getAttribute('data-confirm')] || ASK;
if (!confirm(word(shown(form.getAttribute('data-booth') || '')))) ev.preventDefault();
}, true);
})();
</script>
</body> </body>
</html> </html>
+12 -12
View File
@@ -48,7 +48,7 @@
<pre>{{ m.text }}</pre> <pre>{{ m.text }}</pre>
<form method="post" action="/b/{{ name_url }}/unmark" data-inplace> <form method="post" action="/b/{{ name_url }}/unmark" data-inplace>
<input type="hidden" name="mark" value="{{ m.id }}"> <input type="hidden" name="mark" value="{{ m.id }}">
<button class="mark-x" title="withdraw this note">×</button> <button class="mark-x" title="withdraw this note" aria-label="withdraw this note">×</button>
</form> </form>
</div> </div>
{% endfor %} {% endfor %}
@@ -56,7 +56,7 @@
<summary>+ note</summary> <summary>+ note</summary>
<form method="post" action="/b/{{ name_url }}/note" data-inplace> <form method="post" action="/b/{{ name_url }}/note" data-inplace>
<input type="hidden" name="target" value="{{ it.name }}"> <input type="hidden" name="target" value="{{ it.name }}">
<textarea name="text" rows="2" placeholder="a note on this item"></textarea> <textarea name="text" rows="2" aria-label="a note on this item" placeholder="a note on this item"></textarea>
<button type="submit">Add</button> <button type="submit">Add</button>
</form> </form>
</details> </details>
@@ -123,7 +123,7 @@
{% endif %} {% endif %}
{% if not board %} {% if not board %}
<form class="wipe wipe-lg" method="post" action="/b/{{ name_url }}/delete" <form class="wipe wipe-lg" method="post" action="/b/{{ name_url }}/delete"
onsubmit="return confirm('Wipe this booth now?')"> data-booth="{{ name }}" data-confirm="{{ 'wipe-kept' if kept else 'wipe' }}">
<button>Wipe now</button> <button>Wipe now</button>
</form> </form>
{% endif %} {% endif %}
@@ -215,13 +215,13 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
<button type="submit" class="bench-rm" formaction="/b/{{ name_url }}/bench-remove" <button type="submit" class="bench-rm" formaction="/b/{{ name_url }}/bench-remove"
title="remove this bench">&times;</button> title="remove this bench" aria-label="remove the bench {{ b.name or b.url }}">&times;</button>
</form> </form>
</div> </div>
{% endfor %} {% endfor %}
<form class="bench-add" method="post" action="/b/{{ name_url }}/bench-add"> <form class="bench-add" method="post" action="/b/{{ name_url }}/bench-add">
<input type="url" name="url" placeholder="https://host:port/" required> <input type="url" name="url" aria-label="https://host:port/" placeholder="https://host:port/" required>
<input type="text" name="name" placeholder="what it is"> <input type="text" name="name" aria-label="what it is" placeholder="what it is">
<button type="submit">register</button> <button type="submit">register</button>
</form> </form>
</div> </div>
@@ -263,7 +263,7 @@
<div class="board-row{% if e.pinned %} is-pinned{% endif %}{% if e.dead %} board-dead{% endif %}"> <div class="board-row{% if e.pinned %} is-pinned{% endif %}{% if e.dead %} board-dead{% endif %}">
<input class="board-check" type="checkbox" name="sel" value="{{ e.id }}" aria-label="select {{ e.desc }}"> <input class="board-check" type="checkbox" name="sel" value="{{ e.id }}" aria-label="select {{ e.desc }}">
<button type="submit" class="board-pin{% if e.pinned %} on{% endif %}" formaction="/b/{{ name_url }}/pin" <button type="submit" class="board-pin{% if e.pinned %} on{% endif %}" formaction="/b/{{ name_url }}/pin"
name="entry" value="{{ e.id }}" aria-pressed="{{ 'true' if e.pinned else 'false' }}" name="entry" value="{{ e.id }}" aria-pressed="{{ 'true' if e.pinned else 'false' }}" aria-label="pin {{ e.desc }}"
title="{{ 'unpin' if e.pinned else 'pin to top' }}">{{ '★' if e.pinned else '☆' }}</button> title="{{ 'unpin' if e.pinned else 'pin to top' }}">{{ '★' if e.pinned else '☆' }}</button>
<div class="board-main"> <div class="board-main">
{% if e.safe %}<a class="board-link" href="{{ e.url }}" target="_blank" rel="noopener">{{ e.desc }}</a> {% if e.safe %}<a class="board-link" href="{{ e.url }}" target="_blank" rel="noopener">{{ e.desc }}</a>
@@ -279,9 +279,9 @@
{% if e.who|byline %}<span class="board-who">{{ e.who|byline }}</span>{% endif %} {% if e.who|byline %}<span class="board-who">{{ e.who|byline }}</span>{% endif %}
{% if e.when %}<span class="board-when"><time datetime="{{ e.when }}">{{ e.when|clock }}</time></span>{% endif %} {% if e.when %}<span class="board-when"><time datetime="{{ e.when }}">{{ e.when|clock }}</time></span>{% endif %}
</div> </div>
<button type="button" class="copy-btn board-copy" data-copy="{{ e.url }}" title="copy URL">⧉</button> <button type="button" class="copy-btn board-copy" data-copy="{{ e.url }}" title="copy URL" aria-label="copy the URL of {{ e.desc }}">⧉</button>
<button type="submit" class="board-rm-btn" formaction="/b/{{ name_url }}/unlink" <button type="submit" class="board-rm-btn" formaction="/b/{{ name_url }}/unlink"
name="entry" value="{{ e.id }}" title="remove this link" name="entry" value="{{ e.id }}" title="remove this link" aria-label="remove the link {{ e.desc }}"
data-desc="{{ e.desc }}" data-url="{{ e.url }}">×</button> data-desc="{{ e.desc }}" data-url="{{ e.url }}">×</button>
</div> </div>
{% endfor %} {% endfor %}
@@ -383,8 +383,8 @@
{{ ordinal(it) }} {{ ordinal(it) }}
<span class="doc-name">{{ it.name }}</span> <span class="doc-name">{{ it.name }}</span>
<span class="doc-spacer"></span> <span class="doc-spacer"></span>
<a class="doc-act" href="view?f={{ it.url }}" title="open full page">⤢</a> <a class="doc-act" href="view?f={{ it.url }}" title="open full page" aria-label="open {{ it.name }} full page">⤢</a>
<a class="doc-act" href="{{ it.url }}" download title="download {{ it.name }}">⬇</a> <a class="doc-act" href="{{ it.url }}" download title="download {{ it.name }}" aria-label="download {{ it.name }}">⬇</a>
{{ blurtoggle(name_url, it, 'doc-act') }} {{ blurtoggle(name_url, it, 'doc-act') }}
{{ markcontrols(name_url, it, item_marks.get(it.name, []), 'doc-act') }} {{ markcontrols(name_url, it, item_marks.get(it.name, []), 'doc-act') }}
<button type="button" class="doc-act doc-close" title="close (hide for now)" aria-label="close">✕</button> <button type="button" class="doc-act doc-close" title="close (hide for now)" aria-label="close">✕</button>
@@ -437,7 +437,7 @@
{{ marknotes(name_url, it, item_marks.get(it.name, [])) }} {{ marknotes(name_url, it, item_marks.get(it.name, [])) }}
{% else %} {% else %}
<figcaption> <figcaption>
<a class="dl-link" href="{{ it.url }}" download title="download {{ it.name }}">⬇</a> <a class="dl-link" href="{{ it.url }}" download title="download {{ it.name }}" aria-label="download {{ it.name }}">⬇</a>
<span class="cap-text">{{ it.caption or it.name }}</span> <span class="cap-text">{{ it.caption or it.name }}</span>
{# R2: every MEDIA tile links into the review — a picture through its {# R2: every MEDIA tile links into the review — a picture through its
image, sound and video through this. Enter on the grid cursor image, sound and video through this. Enter on the grid cursor
+4 -3
View File
@@ -14,9 +14,10 @@
apply unchanged; `.compare` overrides its grid. #} apply unchanged; `.compare` overrides its grid. #}
{% macro num(n) -%}#{{ "%0*d"|format(ord_width, n) }}{%- endmacro %} {% macro num(n) -%}#{{ "%0*d"|format(ord_width, n) }}{%- endmacro %}
{% block content %} {% block content %}
<h1 class="sr-only">compare {{ sides.a.rel }} and {{ sides.b.rel }}</h1>
<div class="viewer review compare" data-linked="{{ '1' if linked else '0' }}"> <div class="viewer review compare" data-linked="{{ '1' if linked else '0' }}">
<div class="vbar"> <div class="vbar">
<a class="vbtn vx" href="{{ back_url }}" title="back to the review of A (Esc)">✕</a> <a class="vbtn vx" href="{{ back_url }}" title="back to the review of A (Esc)" aria-label="back to the review of A">✕</a>
<span class="vname">compare <span class="cmp-vs"><span class="ord">{{ num(sides.a.ordinal) }}</span> · <span class="ord">{{ num(sides.b.ordinal) }}</span></span></span> <span class="vname">compare <span class="cmp-vs"><span class="ord">{{ num(sides.a.ordinal) }}</span> · <span class="ord">{{ num(sides.b.ordinal) }}</span></span></span>
<span class="vspacer"></span> <span class="vspacer"></span>
<a class="vbtn cmp-step" data-step="both-prev" href="{{ steps.both_prev }}" title="both back (←)" aria-label="both back">‹‹</a> <a class="vbtn cmp-step" data-step="both-prev" href="{{ steps.both_prev }}" title="both back (←)" aria-label="both back">‹‹</a>
@@ -27,7 +28,7 @@
title="linked: ← and → move both sides (L)">{{ '⛓ linked' if linked else '⛓ unlinked' }}</button> title="linked: ← and → move both sides (L)">{{ '⛓ linked' if linked else '⛓ unlinked' }}</button>
{% if any_image %} {% if any_image %}
<span class="vtoggle" id="vtoggle" hidden> <span class="vtoggle" id="vtoggle" hidden>
<button type="button" class="vseg on" id="btn-fit" aria-pressed="true" title="the whole picture, as large as the stage allows (Z)">Fit</button><button type="button" class="vseg" id="btn-one" aria-pressed="false" title="natural pixels — drag to pan; both stages pan together (Z)">1:1</button> <button type="button" class="vseg on" id="btn-fit" aria-pressed="true" title="the whole picture, as large as the stage allows (Z)">Fit</button><button type="button" class="vseg" id="btn-one" aria-pressed="false" aria-label="1:1, natural pixels" title="natural pixels — drag to pan; both stages pan together (Z)">1:1</button>
</span> </span>
{% endif %} {% endif %}
{% if film | selectattr('blurred') | list %}<button type="button" class="reveal-all-btn" data-reveal-all hidden title="blur is cosmetic — the files are still served"><span class="ra-label">👁 reveal all</span><span class="ra-note"> — blur is cosmetic</span></button>{% endif %} {% if film | selectattr('blurred') | list %}<button type="button" class="reveal-all-btn" data-reveal-all hidden title="blur is cosmetic — the files are still served"><span class="ra-label">👁 reveal all</span><span class="ra-note"> — blur is cosmetic</span></button>{% endif %}
@@ -82,7 +83,7 @@
{% for x in film %} {% for x in film %}
<a class="film-f{% if x.flagged %} is-flagged{% endif %}{% if x.blurred %} is-blurred{% endif %}{% if x.is_a %} is-a{% endif %}{% if x.is_b %} is-b{% endif %}{% if (active == 'a' and x.is_a) or (active == 'b' and x.is_b) %} is-active{% endif %}" <a class="film-f{% if x.flagged %} is-flagged{% endif %}{% if x.blurred %} is-blurred{% endif %}{% if x.is_a %} is-a{% endif %}{% if x.is_b %} is-b{% endif %}{% if (active == 'a' and x.is_a) or (active == 'b' and x.is_b) %} is-active{% endif %}"
href="{{ x.pick }}" data-rel="{{ x.name }}" data-pick-a="{{ x.pick_a }}" data-pick-b="{{ x.pick_b }}" title="{{ x.name }}"> href="{{ x.pick }}" data-rel="{{ x.name }}" data-pick-a="{{ x.pick_a }}" data-pick-b="{{ x.pick_b }}" title="{{ x.name }}">
{%- if x.kind == 'image' %}<img loading="lazy" decoding="async" src="{{ x.thumb or x.url }}" alt="">{% else %}<span class="film-kind">{{ '♪' if x.kind == 'audio' else '▶' }}</span>{% endif -%} <span class="sr-only">{{ x.name }}</span>{%- if x.kind == 'image' %}<img loading="lazy" decoding="async" src="{{ x.thumb or x.url }}" alt="">{% else %}<span class="film-kind">{{ '♪' if x.kind == 'audio' else '▶' }}</span>{% endif -%}
<span class="film-ord">{{ num(x.ordinal) }}</span> <span class="film-ord">{{ num(x.ordinal) }}</span>
{%- if x.is_a or x.is_b %}<span class="film-ab">{{ 'A' if x.is_a }}{{ 'B' if x.is_b }}</span>{% endif -%} {%- if x.is_a or x.is_b %}<span class="film-ab">{{ 'A' if x.is_a }}{{ 'B' if x.is_b }}</span>{% endif -%}
</a> </a>
+2 -2
View File
@@ -4,13 +4,13 @@
{% block content %} {% block content %}
<div class="docview"> <div class="docview">
<div class="vbar"> <div class="vbar">
<a class="vbtn vx" href="/b/{{ name_url }}/" title="back to gallery (Esc)">✕</a> <a class="vbtn vx" href="/b/{{ name_url }}/" title="back to gallery (Esc)" aria-label="back to the gallery">✕</a>
<span class="vname">{{ file }}</span> <span class="vname">{{ file }}</span>
<span class="vspacer"></span> <span class="vspacer"></span>
{# Reveal all can lift this page's blur, so this page must be able to put it {# Reveal all can lift this page's blur, so this page must be able to put it
back (r2b, heid bug-hunt). #} back (r2b, heid bug-hunt). #}
{% if blurred %}<button type="button" class="reveal-all-btn" data-reveal-all hidden title="blur is cosmetic — the files are still served"><span class="ra-label">👁 reveal all</span><span class="ra-note"> — blur is cosmetic</span></button>{% endif %} {% if blurred %}<button type="button" class="reveal-all-btn" data-reveal-all hidden title="blur is cosmetic — the files are still served"><span class="ra-label">👁 reveal all</span><span class="ra-note"> — blur is cosmetic</span></button>{% endif %}
<a class="vbtn" href="{{ file_url }}?dl=1" title="download {{ file }}">⬇</a> <a class="vbtn" href="{{ file_url }}?dl=1" title="download {{ file }}" aria-label="download {{ file }}">⬇</a>
</div> </div>
{# Same record, same reason as the image viewer: the sidecar that says what {# Same record, same reason as the image viewer: the sidecar that says what
this doc IS travels with it to full-page view. #} this doc IS travels with it to full-page view. #}
+3 -37
View File
@@ -73,17 +73,18 @@
<form class="release" method="post" action="/b/{{ b.name_url }}/unkeep" <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> 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" <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> data-booth="{{ b.name }}" data-confirm="wipe-kept"><button title="wipe this KEPT booth now" aria-label="wipe the kept booth {{ b.name }}">× wipe</button></form>
{%- else %} {%- 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="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" <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> data-booth="{{ b.name }}" data-confirm="wipe"><button title="wipe now" aria-label="wipe the booth {{ b.name }}">× wipe</button></form>
{%- endif %} {%- endif %}
</div> </div>
</article> </article>
{%- endmacro %} {%- endmacro %}
{% block content %} {% block content %}
<h1 class="sr-only">Desk</h1>
<div class="desk"> <div class="desk">
<div class="desk-list"> <div class="desk-list">
{% if needs %} {% if needs %}
@@ -192,40 +193,5 @@
}); });
})(); })();
/* Destructive-action confirmation, delegated and DATA-DRIVEN. The booth name
travels as a data attribute, where escaping is escaping, and never reaches
a JS string literal: a booth name is agent-authored, and an inline handler
carrying one was a live injection path. With JS off the form submits
without a prompt. */
(function () {
var WORDS = {
release: function (n) {
return 'Release “' + n + '”?\n\nIt rejoins the sweep: it will be wiped '
+ '{{ ttl_hours|int }}h after its last activity. Nothing is deleted by this step.';
},
'wipe-kept': function (n) {
return 'WIPE the KEPT booth “' + n + '”?\n\nThis deletes it and its files '
+ 'immediately. Kept booths are the ones nothing else will clean up, so nobody '
+ 'else is going to do this for you — and nothing brings it back.';
},
wipe: function (n) { return 'Wipe booth “' + n + '”?'; }
};
/* A word the page does not know still ASKS: fail closed. Keying the
prompt on `word &&` meant a typo'd data-confirm submitted unguarded. */
var ASK = function (n) { return 'Really do this to “' + n + '”?'; };
/* The name as the operator should READ it. Escaping keeps it out of any
script, but a bidi override (U+202E) or a newline in an agent-made name
still rewrote the dialog's text — what is read before approving a wipe.
Controls and bidi formatting show as U+FFFD, visibly, never silently. */
function shown(n) {
return n.replace(/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, '\ufffd');
}
document.addEventListener('submit', function (ev) {
var form = ev.target.closest ? ev.target.closest('form[data-confirm]') : null;
if (!form) return;
var word = WORDS[form.getAttribute('data-confirm')] || ASK;
if (!confirm(word(shown(form.getAttribute('data-booth') || '')))) ev.preventDefault();
}, true);
})();
</script> </script>
{% endblock %} {% endblock %}
+10 -9
View File
@@ -9,9 +9,10 @@
THE STAGE NEVER IS — swapping it would restart a playing track. #} THE STAGE NEVER IS — swapping it would restart a playing track. #}
{% macro num(n) -%}#{{ "%0*d"|format(ord_width, n) }}{%- endmacro %} {% macro num(n) -%}#{{ "%0*d"|format(ord_width, n) }}{%- endmacro %}
{% block content %} {% block content %}
<h1 class="sr-only">{{ file }}</h1>
<div class="viewer review"> <div class="viewer review">
<div class="vbar"> <div class="vbar">
<a class="vbtn vx" href="{{ back_url }}" title="back to the grid (Esc)">✕</a> <a class="vbtn vx" href="{{ back_url }}" title="back to the grid (Esc)" aria-label="back to the grid">✕</a>
<span class="vname"><span class="ord">{{ num(ordinal) }}</span> {{ file }}</span> <span class="vname"><span class="ord">{{ num(ordinal) }}</span> {{ file }}</span>
<span class="vspacer"></span> <span class="vspacer"></span>
{# R3: this item against the next one in the ring, side by side (C). #} {# R3: this item against the next one in the ring, side by side (C). #}
@@ -21,7 +22,7 @@
and only ever rendered for a picture. With scripts off the image shows at and only ever rendered for a picture. With scripts off the image shows at
fit size and no judgment depends on this. #} fit size and no judgment depends on this. #}
<span class="vtoggle" id="vtoggle" hidden> <span class="vtoggle" id="vtoggle" hidden>
<button type="button" class="vseg on" id="btn-fit" aria-pressed="true" title="the whole picture, as large as the stage allows">Fit</button><button type="button" class="vseg" id="btn-one" aria-pressed="false" title="natural pixels — drag to pan a large picture">1:1</button> <button type="button" class="vseg on" id="btn-fit" aria-pressed="true" title="the whole picture, as large as the stage allows">Fit</button><button type="button" class="vseg" id="btn-one" aria-pressed="false" aria-label="1:1, natural pixels" title="natural pixels — drag to pan a large picture">1:1</button>
</span> </span>
{% endif %} {% endif %}
{# r2b D2b + D2, in the top bar: outside every data-region, so no swap {# r2b D2b + D2, in the top bar: outside every data-region, so no swap
@@ -32,16 +33,16 @@
<button title="{{ 'un-blur the whole booth' if booth_blurred else 'blur every image and video in this booth — cosmetic only' }}">{{ '◉ booth blurred' if booth_blurred else '◌ blur booth' }}</button> <button title="{{ 'un-blur the whole booth' if booth_blurred else 'blur every image and video in this booth — cosmetic only' }}">{{ '◉ booth blurred' if booth_blurred else '◌ blur booth' }}</button>
</form></span> </form></span>
{% if film | selectattr('blurred') | list %}<button type="button" class="reveal-all-btn" data-reveal-all hidden title="blur is cosmetic — the files are still served"><span class="ra-label">👁 reveal all</span><span class="ra-note"> — blur is cosmetic</span></button>{% endif %} {% if film | selectattr('blurred') | list %}<button type="button" class="reveal-all-btn" data-reveal-all hidden title="blur is cosmetic — the files are still served"><span class="ra-label">👁 reveal all</span><span class="ra-note"> — blur is cosmetic</span></button>{% endif %}
<a class="vbtn" href="{{ file_url }}" download title="download {{ file }}">⬇</a> <a class="vbtn" href="{{ file_url }}" download title="download {{ file }}" aria-label="download {{ file }}">⬇</a>
</div> </div>
{# THE TAPE (B's device): one segment per item in the review ring — seen, {# THE TAPE (B's device): one segment per item in the review ring — seen,
flagged, current — so how far through the set you are is always in view. #} flagged, current — so how far through the set you are is always in view. #}
<div class="tape" data-region="tape" aria-label="{{ seen_n }} of {{ ring_m }} seen"> <div class="tape" data-region="tape" role="img" aria-label="{{ seen_n }} of {{ ring_m }} seen">
<div class="tape-segs"> <div class="tape-segs">
{% for x in film %} {% for x in film %}
<a class="tape-s{% if x.current %} is-current{% elif x.flagged %} is-flagged{% elif x.seen %} is-seen{% endif %}" <a class="tape-s{% if x.current %} is-current{% elif x.flagged %} is-flagged{% elif x.seen %} is-seen{% endif %}"
href="?f={{ x.url }}" title="{{ num(x.ordinal) }} {{ x.name }}"></a> href="?f={{ x.url }}" title="{{ num(x.ordinal) }} {{ x.name }}" tabindex="-1" aria-hidden="true"></a>
{% endfor %} {% endfor %}
</div> </div>
<span class="tape-count">{{ seen_n }} of {{ ring_m }} seen</span> <span class="tape-count">{{ seen_n }} of {{ ring_m }} seen</span>
@@ -87,7 +88,7 @@
<input type="hidden" name="mark" value="{{ m.id }}"> <input type="hidden" name="mark" value="{{ m.id }}">
<input type="hidden" name="back" value="view"> <input type="hidden" name="back" value="view">
<input type="hidden" name="f" value="{{ file }}"> <input type="hidden" name="f" value="{{ file }}">
<button class="mark-x" title="withdraw this note">×</button> <button class="mark-x" title="withdraw this note" aria-label="withdraw this note">×</button>
</form> </form>
</div> </div>
{% endfor %} {% endfor %}
@@ -95,7 +96,7 @@
<input type="hidden" name="target" value="{{ file }}"> <input type="hidden" name="target" value="{{ file }}">
<input type="hidden" name="back" value="view"> <input type="hidden" name="back" value="view">
<input type="hidden" name="f" value="{{ file }}"> <input type="hidden" name="f" value="{{ file }}">
<textarea name="text" id="vnote-text" rows="2" placeholder="a note on this item (N)"></textarea> <textarea name="text" id="vnote-text" rows="2" aria-label="a note on this item" placeholder="a note on this item (N)"></textarea>
<button type="submit">Add note</button> <button type="submit">Add note</button>
</form> </form>
</div> </div>
@@ -116,7 +117,7 @@
<div class="tray"> <div class="tray">
{% for x in tray %} {% for x in tray %}
<a class="tray-item{% if x.blurred %} is-blurred{% endif %}" href="?f={{ x.url }}" title="{{ x.name }}"> <a class="tray-item{% if x.blurred %} is-blurred{% endif %}" href="?f={{ x.url }}" title="{{ x.name }}">
{%- if x.kind == 'image' %}<img loading="lazy" decoding="async" src="{{ x.thumb or x.url }}" alt="">{% else %}<span class="tray-kind">{{ x.kind }}</span>{% endif -%} <span class="sr-only">{{ x.name }}</span>{%- if x.kind == 'image' %}<img loading="lazy" decoding="async" src="{{ x.thumb or x.url }}" alt="">{% else %}<span class="tray-kind">{{ x.kind }}</span>{% endif -%}
<span class="tray-ord">{{ num(x.ordinal) }}</span></a> <span class="tray-ord">{{ num(x.ordinal) }}</span></a>
{% endfor %} {% endfor %}
</div> </div>
@@ -141,7 +142,7 @@
{% for x in film %} {% for x in film %}
<a class="film-f{% if x.flagged %} is-flagged{% endif %}{% if x.current %} is-current{% endif %}{% if x.blurred %} is-blurred{% endif %}" <a class="film-f{% if x.flagged %} is-flagged{% endif %}{% if x.current %} is-current{% endif %}{% if x.blurred %} is-blurred{% endif %}"
href="?f={{ x.url }}" title="{{ x.name }}"{% if x.current %} aria-current="true"{% endif %}> href="?f={{ x.url }}" title="{{ x.name }}"{% if x.current %} aria-current="true"{% endif %}>
{%- if x.kind == 'image' %}<img loading="lazy" decoding="async" src="{{ x.thumb or x.url }}" alt="">{% else %}<span class="film-kind">{{ '♪' if x.kind == 'audio' else '▶' }}</span>{% endif -%} <span class="sr-only">{{ x.name }}</span>{%- if x.kind == 'image' %}<img loading="lazy" decoding="async" src="{{ x.thumb or x.url }}" alt="">{% else %}<span class="film-kind">{{ '♪' if x.kind == 'audio' else '▶' }}</span>{% endif -%}
<span class="film-ord">{{ num(x.ordinal) }}</span></a> <span class="film-ord">{{ num(x.ordinal) }}</span></a>
{% endfor %} {% endfor %}
</nav> </nav>
+38 -2
View File
@@ -14,6 +14,8 @@ touches:
- "booth/templates/_marks.html, _ask_inline.html, booth.html (S1)" - "booth/templates/_marks.html, _ask_inline.html, booth.html (S1)"
- "booth/templates/base.html, view.html; booth/static/embed.js (S2)" - "booth/templates/base.html, view.html; booth/static/embed.js (S2)"
- "booth/templates/base.html, view.html, compare.html; tests/test_antislop_browser.py (S3)" - "booth/templates/base.html, view.html, compare.html; tests/test_antislop_browser.py (S3)"
- "booth/templates/base.html (S4, S6)"
- "booth/app.py (`human_dur`); every page template; booth/static/embed.js (S5a)"
- "tests/test_antislop.py; tests/mutations/antislop.toml" - "tests/test_antislop.py; tests/mutations/antislop.toml"
assumptions: assumptions:
- "ONE VIEWER, on this box: local time is the operator's time (US Pacific), as the existing date filters already assume." - "ONE VIEWER, on this box: local time is the operator's time (US Pacific), as the existing date filters already assume."
@@ -129,6 +131,40 @@ The run found that the Booth is sound on desktop and has a set of problems a vie
- **The hazard stripe sits on a `::before`,** not on the button's background, for Wipe now and the armed bulk delete. The button's own background is honestly transparent (a detector read the 3px background band as the whole background, 1.0:1), and the stripe renders exactly as before. - **The hazard stripe sits on a `::before`,** not on the button's background, for Wipe now and the armed bulk delete. The button's own background is honestly transparent (a detector read the 3px background band as the whole background, 1.0:1), and the stripe renders exactly as before.
- *Falsifiable:* `test_the_hazard_stripe_is_a_pseudo_element`, in a real browser: no gradient on the button, a 3px striped `::before`. - *Falsifiable:* `test_the_hazard_stripe_is_a_pseudo_element`, in a real browser: no gradient on the button, a 3px striped `::before`.
## S5 ## S5a — names, landmarks, focus rings, hit areas
Interaction and screen readers: focus after a swap, a status slot the viewer cannot cover, live-region timing, names, keys, focus rings, hit areas, and the unsaved-draft guard. It rewires the in-place client, so it gets its own contract section and review before any code. The markup and CSS half of the interaction work. It changes no script behaviour except where Wipe now's prompt comes from. The in-place client is untouched; that half is S5b.
- **Every link and button has a word for a name.** A control a screen reader would announce as "×", "⬇", "⤢", "☆", "1:1" or "01" carries an `aria-label`, or the file's name as `.sr-only` text. The visible label stays inside the name (`1:1, natural pixels`).
- Covered: the withdraw ×s, downloads, open-full-page, the viewers' close ✕, the board's pin, copy and remove, the bench's remove, the zoom toggle, and the film-strip and flagged-tray frames.
- A Desk row's wipe names its booth (`wipe the booth alpha`), so a list of rows is not a list of identical "wipe booth"s.
- *Falsifiable:* `test_every_control_has_a_word_for_a_name` (every page, the link board included; the name is computed from `aria-label`, else the text plus each image's `alt`), and `test_desk_row_controls_name_their_booth`.
- **Every field has a name that is not its placeholder.** Every note field, the bench's two inputs and the inline ask's notes carry an `aria-label`.
- *Falsifiable:* `test_fields_are_named`, on every page and on both embed placements.
- **An ask's options are a named group, and its ids are unique.** The inline ask's options are `role="radiogroup"`, labelled by the question's prompt. The marks page's single-question fieldset gets a visually hidden `<legend>`. A titled ask's title takes `bk-ask-<id>-title`: it used to reuse `bk-ask-<id>`, which the question already holds.
- *Falsifiable:* `test_radio_groups_are_named_and_ids_are_unique`, checked on each placement the embed makes: either `whole`, or the questions plus `submit`.
- **Every page has one h1, a skip link and a named main.** The Desk, the review and compare get a visually hidden h1. A doc's own h1 is content and is not counted.
- *Falsifiable:* `test_every_page_has_one_h1_and_a_skip_link`.
- **The browser chrome matches the theme:** `theme-color` for light (`#f0f4f5`) and for dark (`#15191d`).
- *Falsifiable:* `test_theme_color_for_both_schemes`.
- **The review's progress tape is one picture** (`role="img"`, named "N of M seen"). Its segments leave the tab order: the film strip below it holds the same links, named.
- *Falsifiable:* `test_the_tape_is_one_picture`.
- **Wipe now asks by name.** The Desk's delegated prompt moves to `base.html`, and a booth page's Wipe now uses it. It names the booth, and asks the kept-booth question for a kept booth. This replaces an inline `confirm('Wipe this booth now?')`. With JS off the form still submits, as before.
- *Falsifiable:* `test_wipe_now_asks_by_name` (markup), and `test_wipe_now_asks_by_name_in_the_browser`: the dialog's text, and dismissing it wipes nothing.
- **Focus rings and hit areas.**
- The embed draws its own focus rings, so a host's `outline:none` cannot remove them.
- Rings inside `overflow:hidden` containers are drawn inside (`outline-offset:-2px`), where they cannot be clipped.
- The withdraw × is at least 24px, and 44px under a coarse pointer.
- Controls take `touch-action:manipulation`, and the scrolling strips contain their overscroll.
- A long booth slug wraps on a phone.
- *Falsifiable:* `test_embed_chrome_draws_its_own_focus_rings`, `test_focus_rings_are_drawn_inside_clipping_containers`, `test_withdraw_buttons_are_big_enough_to_hit` (measured at 1280, and at 390 with touch), and `test_touch_and_scroll_behaviour` (computed style).
- **Small truths.**
- A why truncated with an ellipsis carries its full text in `title`.
- A countdown of 48h or more rolls up to days (`6d 23h`, not `167h 12m`).
- *Falsifiable:* `test_a_truncated_why_carries_its_full_text`, `test_human_dur_rolls_up_to_days`.
- **Existing rows this slice edits** (booth-dev's): two `r2_flow.toml` rows for the confirm helper now name `base.html`, where the helper moved. Their anchors are unchanged.
- **Reported, not changed:** mark ids repeat across a tile and its aside (`mark-note-1`). The CLI prints `#mark-<id>` links to them, so the fix is booth-dev's call.
## S5b
The in-place client half: focus restored after a swap, a status slot the viewer cannot cover (and `inert` behind it), live-region timing and "Saving…", key handling, and the unsaved-draft guard. It rewires the in-place client, so it gets its own contract section and review before any code.
+268
View File
@@ -375,3 +375,271 @@ file = "booth/templates/base.html"
test = "tests/test_antislop_browser.py::test_the_hazard_stripe_is_a_pseudo_element" test = "tests/test_antislop_browser.py::test_the_hazard_stripe_is_a_pseudo_element"
old = """ .wipe-lg button::before,.board-del-sel:not(:disabled)::before{content:"";position:absolute;""" old = """ .wipe-lg button::before,.board-del-sel:not(:disabled)::before{content:"";position:absolute;"""
new = """ .wipe-lg button::after,.board-del-sel:not(:disabled)::after{content:"";position:absolute;""" new = """ .wipe-lg button::after,.board-del-sel:not(:disabled)::after{content:"";position:absolute;"""
# ---- S5a: accessibility plumbing (names, landmarks, focus, hit areas)
[[mutation]]
label = "S5a the withdraw-note × unnamed again"
file = "booth/templates/_marks.html"
test = "tests/test_antislop.py::test_every_control_has_a_word_for_a_name"
old = """<button type="submit" class="mark-x" title="withdraw this note" aria-label="withdraw this note">×</button>"""
new = """<button type="submit" class="mark-x" title="withdraw this note">×</button>"""
[[mutation]]
label = "S5a a film frame named only by its number"
file = "booth/templates/view.html"
test = "tests/test_antislop.py::test_every_control_has_a_word_for_a_name"
old = """ <span class="sr-only">{{ x.name }}</span>{%- if x.kind == 'image' %}<img loading="lazy" decoding="async" src="{{ x.thumb or x.url }}" alt="">{% else %}<span class="film-kind">"""
new = """ {%- if x.kind == 'image' %}<img loading="lazy" decoding="async" src="{{ x.thumb or x.url }}" alt="">{% else %}<span class="film-kind">"""
[[mutation]]
label = "S5a a flagged-tray item named only by its number"
file = "booth/templates/_marks.html"
test = "tests/test_antislop.py::test_every_control_has_a_word_for_a_name"
old = """<span class="sr-only">{{ it.name }}</span>{%- if it.kind"""
new = """{%- if it.kind"""
[[mutation]]
label = "S5a the 1:1 button read as digits"
file = "booth/templates/view.html"
test = "tests/test_antislop.py::test_every_control_has_a_word_for_a_name"
old = """ aria-label="1:1, natural pixels" title="natural pixels — drag to pan a large picture">"""
new = """ title="natural pixels — drag to pan a large picture">"""
[[mutation]]
label = "S5a the doc's open-full-page glyph unnamed"
file = "booth/templates/booth.html"
test = "tests/test_antislop.py::test_every_control_has_a_word_for_a_name"
old = """ title="open full page" aria-label="open {{ it.name }} full page">⤢</a>"""
new = """ title="open full page">⤢</a>"""
[[mutation]]
label = "S5a compare's close glyph unnamed"
file = "booth/templates/compare.html"
test = "tests/test_antislop.py::test_every_control_has_a_word_for_a_name"
old = """ title="back to the review of A (Esc)" aria-label="back to the review of A">✕</a>"""
new = """ title="back to the review of A (Esc)">✕</a>"""
[[mutation]]
label = "S5a the board's pin star unnamed"
file = "booth/templates/booth.html"
test = "tests/test_antislop.py::test_every_control_has_a_word_for_a_name"
old = """ aria-pressed="{{ 'true' if e.pinned else 'false' }}" aria-label="pin {{ e.desc }}""""
new = """ aria-pressed="{{ 'true' if e.pinned else 'false' }}""""
[[mutation]]
label = "S5a the board's remove × unnamed"
file = "booth/templates/booth.html"
test = "tests/test_antislop.py::test_every_control_has_a_word_for_a_name"
old = """ title="remove this link" aria-label="remove the link {{ e.desc }}""""
new = """ title="remove this link""""
[[mutation]]
label = "S5a a Desk wipe that does not say which booth"
file = "booth/templates/index.html"
test = "tests/test_antislop.py::test_desk_row_controls_name_their_booth"
old = """aria-label="wipe the booth {{ b.name }}""""
new = """aria-label="wipe booth""""
[[mutation]]
label = "S5a the note field named only by its placeholder"
file = "booth/templates/_marks.html"
test = "tests/test_antislop.py::test_fields_are_named"
old = """<textarea name="text" rows="2" aria-label="a note on this booth, for the session that posted it" placeholder"""
new = """<textarea name="text" rows="2" placeholder"""
[[mutation]]
label = "S5a the bench URL field unnamed"
file = "booth/templates/booth.html"
test = "tests/test_antislop.py::test_fields_are_named"
old = """<input type="url" name="url" aria-label="https://host:port/" placeholder"""
new = """<input type="url" name="url" placeholder"""
[[mutation]]
label = "S5a the embed's notes field unnamed"
file = "booth/templates/_ask_inline.html"
test = "tests/test_antislop.py::test_fields_are_named"
old = """ aria-label="{{ a.notes_label }}" placeholder="{{ a.notes_label }} (optional)">"""
new = """ placeholder="{{ a.notes_label }} (optional)">"""
[[mutation]]
label = "S5a the inline ask's options not a named group"
file = "booth/templates/_ask_inline.html"
test = "tests/test_antislop.py::test_radio_groups_are_named_and_ids_are_unique"
old = """<div class="bk-ask-opts" role="radiogroup" aria-labelledby="""
new = """<div class="bk-ask-opts" aria-labelledby="""
[[mutation]]
label = "S5a the group names a prompt id that is not there"
file = "booth/templates/_ask_inline.html"
test = "tests/test_antislop.py::test_radio_groups_are_named_and_ids_are_unique"
old = """<p class="bk-ask-prompt" id="bk-ask-{{ a.id }}{% if q.key %}-{{ q.key }}{% endif %}-prompt">"""
new = """<p class="bk-ask-prompt">"""
[[mutation]]
label = "S5a a titled ask's title reuses the question's id"
file = "booth/templates/_ask_inline.html"
test = "tests/test_antislop.py::test_radio_groups_are_named_and_ids_are_unique"
old = """<p class="bk-ask-title" id="bk-ask-{{ a.id }}-title">"""
new = """<p class="bk-ask-title" id="bk-ask-{{ a.id }}">"""
[[mutation]]
label = "S5a a single-question fieldset without a legend"
file = "booth/templates/_marks.html"
test = "tests/test_antislop.py::test_radio_groups_are_named_and_ids_are_unique"
old = """{% else %}<legend class="sr-only">{{ q.prompt }}</legend>{% endif %}"""
new = """{% endif %}"""
[[mutation]]
label = "S5a the Desk without an h1"
file = "booth/templates/index.html"
test = "tests/test_antislop.py::test_every_page_has_one_h1_and_a_skip_link"
old = """<h1 class="sr-only">Desk</h1>
"""
new = """"""
[[mutation]]
label = "S5a the review without an h1"
file = "booth/templates/view.html"
test = "tests/test_antislop.py::test_every_page_has_one_h1_and_a_skip_link"
old = """<h1 class="sr-only">{{ file }}</h1>
"""
new = """"""
[[mutation]]
label = "S5a compare without an h1"
file = "booth/templates/compare.html"
test = "tests/test_antislop.py::test_every_page_has_one_h1_and_a_skip_link"
old = """<h1 class="sr-only">compare {{ sides.a.rel }} and {{ sides.b.rel }}</h1>
"""
new = """"""
[[mutation]]
label = "S5a no skip link"
file = "booth/templates/base.html"
test = "tests/test_antislop.py::test_every_page_has_one_h1_and_a_skip_link"
old = """<a class="skip-link" href="#main">Skip to content</a>
"""
new = """"""
[[mutation]]
label = "S5a the skip link points at nothing"
file = "booth/templates/base.html"
test = "tests/test_antislop.py::test_every_page_has_one_h1_and_a_skip_link"
old = """<main id="main">"""
new = """<main>"""
[[mutation]]
label = "S5a no dark theme-color"
file = "booth/templates/base.html"
test = "tests/test_antislop.py::test_theme_color_for_both_schemes"
old = """<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#15191d">
"""
new = """"""
[[mutation]]
label = "S5a the tape is a list of links again"
file = "booth/templates/view.html"
test = "tests/test_antislop.py::test_the_tape_is_one_picture"
old = """<div class="tape" data-region="tape" role="img" aria-label="""
new = """<div class="tape" data-region="tape" aria-label="""
[[mutation]]
label = "S5a the tape's segments back in the tab order"
file = "booth/templates/view.html"
test = "tests/test_antislop.py::test_the_tape_is_one_picture"
old = """ title="{{ num(x.ordinal) }} {{ x.name }}" tabindex="-1" aria-hidden="true"></a>"""
new = """ title="{{ num(x.ordinal) }} {{ x.name }}"></a>"""
[[mutation]]
label = "S5a Wipe now back on an inline anonymous prompt"
file = "booth/templates/booth.html"
test = "tests/test_antislop.py::test_wipe_now_asks_by_name"
old = """ data-booth="{{ name }}" data-confirm="{{ 'wipe-kept' if kept else 'wipe' }}">"""
new = """ onsubmit="return confirm('Wipe this booth now?')">"""
[[mutation]]
label = "S5a a kept booth's Wipe now asks the ordinary question"
file = "booth/templates/booth.html"
test = "tests/test_antislop_browser.py::test_wipe_now_asks_by_name_in_the_browser"
old = """data-confirm="{{ 'wipe-kept' if kept else 'wipe' }}""""
new = """data-confirm="wipe""""
[[mutation]]
label = "S5a the shared prompt does not reach a booth page"
file = "booth/templates/base.html"
test = "tests/test_antislop_browser.py::test_wipe_now_asks_by_name_in_the_browser"
old = """ var form = ev.target.closest ? ev.target.closest('form[data-confirm]') : null;"""
new = """ var form = ev.target.closest ? ev.target.closest('.desk form[data-confirm]') : null;"""
[[mutation]]
label = "S5a hours never roll up to days"
file = "booth/app.py"
test = "tests/test_antislop.py::test_human_dur_rolls_up_to_days"
old = """ if h >= 48:"""
new = """ if h >= 4800:"""
[[mutation]]
label = "S5a the embed's submit button has no ring of its own"
file = "booth/static/embed.js"
test = "tests/test_antislop.py::test_embed_chrome_draws_its_own_focus_rings"
old = """ ".bk-ask-go:focus-visible{outline:2px solid var(--bk-accent);outline-offset:2px}",
"""
new = """"""
[[mutation]]
label = "S5a an embed option has no ring"
file = "booth/static/embed.js"
test = "tests/test_antislop.py::test_embed_chrome_draws_its_own_focus_rings"
old = """ ".bk-ask-opt:has(input:focus-visible){outline:2px solid var(--bk-accent);outline-offset:1px}",
"""
new = """"""
[[mutation]]
label = "S5a rings drawn outside a clipping container"
file = "booth/templates/base.html"
test = "tests/test_antislop.py::test_focus_rings_are_drawn_inside_clipping_containers"
old = """ .desk-panel a:focus-visible{outline-offset:-2px}"""
new = """ .desk-panel a:focus-visible{outline-offset:2px}"""
[[mutation]]
label = "S5a a truncated why without its full text"
file = "booth/templates/_provenance.html"
test = "tests/test_antislop.py::test_a_truncated_why_carries_its_full_text"
old = """<span class="prov-why" title="{{ m.why }}">"""
new = """<span class="prov-why">"""
[[mutation]]
label = "S5a the withdraw × at its old size on a desktop"
file = "booth/templates/base.html"
test = "tests/test_antislop_browser.py::test_withdraw_buttons_are_big_enough_to_hit"
old = """ .mark-x{min-width:24px;min-height:24px}"""
new = """ .mark-x{}"""
[[mutation]]
label = "S5a the withdraw × not enlarged under a finger"
file = "booth/templates/base.html"
test = "tests/test_antislop_browser.py::test_withdraw_buttons_are_big_enough_to_hit"
old = """ @media (pointer:coarse){.mark-x,.flagtoggle button"""
new = """ @media (pointer:coarse){.flagtoggle button"""
[[mutation]]
label = "S5a double-tap zoom delay back on controls"
file = "booth/templates/base.html"
test = "tests/test_antislop_browser.py::test_touch_and_scroll_behaviour"
old = """ a,button,summary,label,input,select,textarea{touch-action:manipulation}"""
new = """ summary,label,input,select,textarea{touch-action:manipulation}"""
[[mutation]]
label = "S5a the film strip hands its scroll to the page"
file = "booth/templates/base.html"
test = "tests/test_antislop_browser.py::test_touch_and_scroll_behaviour"
old = """ .film,.vcap,.doc-body,.cmp-strip{overscroll-behavior:contain}"""
new = """ .vcap,.doc-body,.cmp-strip{overscroll-behavior:contain}"""
[[mutation]]
label = "S5a a long slug runs off a phone"
file = "booth/templates/base.html"
test = "tests/test_antislop_browser.py::test_touch_and_scroll_behaviour"
old = """ @media (max-width:600px){.h1-slug{white-space:normal;overflow-wrap:anywhere}}"""
new = """ @media (max-width:600px){.h1-slug{}}"""
+2 -2
View File
@@ -187,7 +187,7 @@ new = '''
[[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/base.html" # as S5a: the confirm helper moved here from index.html
test = "tests/test_flow_browser.py::test_the_wipe_dialog_shows_what_is_being_wiped_and_never_fails_open" test = "tests/test_flow_browser.py::test_the_wipe_dialog_shows_what_is_being_wiped_and_never_fails_open"
old = ''' old = '''
if (!confirm(word(shown(form.getAttribute('data-booth') || '')))) ev.preventDefault();''' if (!confirm(word(shown(form.getAttribute('data-booth') || '')))) ev.preventDefault();'''
@@ -196,7 +196,7 @@ new = '''
[[mutation]] [[mutation]]
label = "an unknown data-confirm word submits with no prompt (fail open)" label = "an unknown data-confirm word submits with no prompt (fail open)"
file = "booth/templates/index.html" file = "booth/templates/base.html" # as S5a: the confirm helper moved here from index.html
test = "tests/test_flow_browser.py::test_the_wipe_dialog_shows_what_is_being_wiped_and_never_fails_open" test = "tests/test_flow_browser.py::test_the_wipe_dialog_shows_what_is_being_wiped_and_never_fails_open"
old = ''' old = '''
var word = WORDS[form.getAttribute('data-confirm')] || ASK; var word = WORDS[form.getAttribute('data-confirm')] || ASK;
+191
View File
@@ -306,3 +306,194 @@ def test_needs_you_rows_carry_no_side_stripe():
def test_the_brand_dot_is_matte(): def test_the_brand_dot_is_matte():
decl = _rule((_T / "base.html").read_text(), ".brand .dot") decl = _rule((_T / "base.html").read_text(), ".brand .dot")
assert "box-shadow" not in decl, decl assert "box-shadow" not in decl, decl
# ---- S5a: accessibility plumbing (markup and CSS) --------------------------------
# Written after the code: each test is proved by its row in antislop.toml, which
# runs the change it forbids and must see it fail.
import html as _html
def _s5_booth(data):
"""A booth that renders every control the S5a claims cover: a doc, a file, an
image with a note and a flag, a titled single-question ask, and a board."""
from booth.marks import set_flag
b = data / "b"
b.mkdir()
(b / "notes.md").write_text("# Notes\n\ntext\n")
(b / "kit.zip").write_bytes(b"PK\x05\x06" + b"\x00" * 18)
(b / "a.png").write_bytes(_PNG)
(b / "c.png").write_bytes(_PNG)
set_flag(b, "a.png", True)
write_note(b, "a.png", "soft edges")
write_note(b, None, "about the booth")
declare_pick(b, "p1", {"title": "Round one", "prompt": "Which?", "options": ["North", "South"]})
_s5_board(data)
return b
def _s5_board(data):
"""The link board, with a row and a bench: its controls are named too."""
from booth.benches import upsert_bench
from booth.links import LINKS_FILE
(data / "links").mkdir()
(data / "links" / LINKS_FILE).write_text("- [Booth](http://x.example/) <sub>· infra-ops · 2026-09-06 23:35</sub>\n")
upsert_bench(data, "http://10.100.10.50:5173/", "peedlar desk", "peedlar-dev")
_PNG = (b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde"
b"\x00\x00\x00\x0cIDATx\x9cc\xf8\xcf\xc0\x00\x00\x03\x01\x01\x00\xc9\xfe\x92\xef\x00\x00\x00\x00IEND\xaeB`\x82")
def _pages(c):
return {u: c.get(u).text for u in ("/", "/b/b/", "/b/b/view?f=a.png", "/b/b/compare?a=a.png&b=c.png",
"/b/b/marks", "/b/b/view?f=notes.md", "/b/links/")}
def _markup(page):
"""The page's own markup: script and style bodies are strings and comments
are prose (the stylesheet's comments name `<textarea>`), not elements."""
page = re.sub(r"<(script|style)\b.*?</\1>", " ", page, flags=re.S)
return re.sub(r"<!--.*?-->", " ", page, flags=re.S)
def _name(attrs, inner):
"""The accessible name, as far as markup decides it: aria-label wins, else
the text, counting each image's alt (an <img alt="a.png"> names its link)."""
m = re.search(r'aria-label="([^"]*)"', attrs)
if m:
return _html.unescape(m.group(1))
inner = re.sub(r'<img\b[^>]*\balt="([^"]*)"[^>]*>', r" \1 ", inner)
return _html.unescape(_text(inner))
def _controls(page):
for m in re.finditer(r"<(a|button)\b([^>]*)>(.*?)</\1>", _markup(page), flags=re.S):
if 'aria-hidden="true"' in m.group(2):
continue # a deliberate duplicate (the Desk's thumbnail link) is not a control
yield m.group(2), _name(m.group(2), m.group(3))
def test_every_control_has_a_word_for_a_name(client):
"""A control a screen reader announces as "×", "↗" or "01" has no name. Every
link and button's name, as the markup decides it, carries a word."""
c, data = client
_s5_booth(data)
for url, page in _pages(c).items():
seen = 0
for attrs, name in _controls(page):
seen += 1
assert re.search(r"[A-Za-z]", name), f"{url}: a control named {name!r}: {attrs[:120]}"
assert seen, f"{url}: no control found (positive control)"
def test_desk_row_controls_name_their_booth(client):
c, data = client
for n in ("alpha", "beta"):
(data / n).mkdir()
(data / n / "x.txt").write_text("x")
labels = re.findall(r'aria-label="(wipe the (?:kept )?booth [^"]+)"', c.get("/").text)
assert len(labels) == 2 and len(set(labels)) == 2 and any("alpha" in s for s in labels), labels
def test_fields_are_named(client):
c, data = client
_s5_booth(data)
pages = _pages(c)
pages["embed"] = " ".join(m["whole"] + m["submit"] for m in c.get("/b/b/embed.json").json()["marks"])
for url, page in pages.items():
for m in re.finditer(r"<(textarea|input)\b([^>]*)>", _markup(page), flags=re.S):
attrs = m.group(2)
if re.search(r'type="(hidden|radio|checkbox|submit)"', attrs):
continue
ok = "aria-label=" in attrs or re.search(r'id="([^"]+)"', attrs) and \
re.search(r'<label[^>]*for="%s"' % re.escape(re.search(r'id="([^"]+)"', attrs).group(1)), page)
assert ok, f"{url}: an unnamed field: {attrs[:120]}"
def test_radio_groups_are_named_and_ids_are_unique(client):
c, data = client
_s5_booth(data)
(m,) = c.get("/b/b/embed.json").json()["marks"]
# The embed places an ask one of two ways: `whole` (title, questions and
# submit in one piece), or its questions one by one plus `submit`. Never both.
placements = {"whole": m["whole"], "parts": "".join(q["html"] for q in m["questions"]) + m["submit"]}
for how, frag in placements.items():
groups = re.findall(r'role="radiogroup" aria-labelledby="([^"]+)"', frag)
assert groups and all(f'id="{g}"' in frag for g in groups), (how, groups)
# the ASK ids are this slice's (a titled single ask emitted `bk-ask-<id>`
# twice). Mark ids (`mark-note-1`) also repeat across the tile and the
# aside; the CLI prints `#mark-<id>` links to them, so that one is
# reported, not changed here.
ids = re.findall(r'\bid="(bk-ask-[^"]+)"', frag)
dupes = {i for i in ids if ids.count(i) > 1}
assert ids and not dupes, (how, f"duplicate ask ids {sorted(dupes)[:5]}")
marks = c.get("/b/b/marks").text
for fs in re.findall(r"<fieldset\b.*?</fieldset>", marks, flags=re.S):
if 'type="radio"' in fs:
assert "<legend" in fs, fs[:160]
def test_every_page_has_one_h1_and_a_skip_link(client):
c, data = client
_s5_booth(data)
for url in ("/", "/b/b/", "/b/b/view?f=a.png", "/b/b/compare?a=a.png&b=c.png", "/b/b/marks"):
page = re.sub(r'<article class="markdown-body.*?</article>', " ", c.get(url).text, flags=re.S) # a doc's own h1 is content
assert len(re.findall(r"<h1\b", page)) == 1, (url, len(re.findall(r"<h1\b", page)))
assert re.search(r'<a class="skip-link" href="#main">', page) and 'id="main"' in page, url
def test_theme_color_for_both_schemes(client):
c, _ = client
page = c.get("/").text
for scheme in ("light", "dark"):
assert re.search(r'<meta name="theme-color" media="\(prefers-color-scheme: %s\)" content="#[0-9a-f]{6}">' % scheme, page), scheme
def test_the_tape_is_one_picture(client):
c, data = client
_s5_booth(data)
page = c.get("/b/b/view?f=a.png").text
assert re.search(r'<div class="tape" data-region="tape" role="img" aria-label="', page)
segs = re.findall(r'<a class="tape-s[^>]*>', page, flags=re.S)
assert segs and all('tabindex="-1"' in s and 'aria-hidden="true"' in s for s in segs), segs[:2]
def test_wipe_now_asks_by_name(client):
c, data = client
_s5_booth(data)
page = c.get("/b/b/").text
form = re.search(r'<form class="wipe wipe-lg"[^>]*>', page, flags=re.S).group(0)
assert 'data-booth="b"' in form and 'data-confirm="wipe"' in form and "onsubmit" not in form, form
assert "var WORDS = {" in page, "the shared confirm helper is on the booth page"
def test_human_dur_rolls_up_to_days():
from booth.app import human_dur
assert human_dur(47 * 3600) == "47h"
assert human_dur(48 * 3600) == "2d"
assert human_dur(167 * 3600 + 12 * 60) == "6d 23h"
def test_embed_chrome_draws_its_own_focus_rings():
js = _EMBED.read_text()
assert re.search(r'"\.booth-nav-home:focus-visible,\.booth-nav-asks:focus-visible\{outline:2px solid', js)
assert re.search(r'"\.bk-ask-go:focus-visible\{outline:2px solid', js)
assert re.search(r'"\.bk-ask-opt:has\(input:focus-visible\)\{outline:2px solid', js)
def test_focus_rings_are_drawn_inside_clipping_containers():
css = (_T / "base.html").read_text()
m = re.search(r"\.theme button:focus-visible,\.vtoggle button:focus-visible,\.item a:focus-visible,\s*\.desk-panel a:focus-visible\{outline-offset:-2px\}", css)
assert m
def test_a_truncated_why_carries_its_full_text(client):
c, data = client
b = data / "w"
b.mkdir()
(b / "x.txt").write_text("x")
(b / ".booth.json").write_text('{"handle": "design-dev", "why": "a long reason that the Desk truncates with an ellipsis"}')
page = c.get("/").text
assert re.search(r'<span class="prov-why" title="a long reason that the Desk truncates with an ellipsis">', page)
+59
View File
@@ -172,3 +172,62 @@ def test_the_hazard_stripe_is_a_pseudo_element(browser, live):
assert got["own"] == "none", f"the button paints its own stripe: {got['own'][:80]}" assert got["own"] == "none", f"the button paints its own stripe: {got['own'][:80]}"
assert "repeating-linear-gradient" in got["pre"] and got["h"] == "3px" and got["content"] != "none", got assert "repeating-linear-gradient" in got["pre"] and got["h"] == "3px" and got["content"] != "none", got
page.close() page.close()
# ---- S5a: hit areas, and Wipe now asks by name -------------------------------------
def test_withdraw_buttons_are_big_enough_to_hit(browser, live):
from booth.marks import write_note
base, root = live
g = root / "g"
g.mkdir()
(g / "a.png").write_bytes(_png(64, 48))
write_note(g, "a.png", "soft")
for ctx_args, floor in (({"viewport": {"width": 1280, "height": 800}}, 24),
({"viewport": PHONE, "is_mobile": True, "has_touch": True}, 44)):
ctx = browser.new_context(**ctx_args)
page = ctx.new_page()
page.goto(f"{base}/b/g/marks", wait_until="load") # visible at both widths
box = page.locator(".mark-x").first.evaluate("e => e.getBoundingClientRect().height")
assert box >= floor, (ctx_args, box)
ctx.close()
def test_wipe_now_asks_by_name_in_the_browser(browser, live):
base, root = live
for name, kept in (("alpha", False), ("beta", True)):
(root / name).mkdir()
(root / name / "x.txt").write_text("x")
if kept:
(root / name / ".forever").write_text("")
page = browser.new_page(viewport={"width": 1280, "height": 800})
page.goto(f"{base}/b/{name}/", wait_until="load")
said = []
page.on("dialog", lambda d: (said.append(d.message), d.dismiss()))
page.locator(".wipe-lg button").click()
page.wait_for_timeout(300)
assert said and f"“{name}”" in said[0], said
assert ("KEPT" in said[0]) == kept, said[0]
assert (root / name).exists(), "dismissing the prompt must not wipe"
page.close()
def test_touch_and_scroll_behaviour(browser, live):
"""No double-tap zoom delay on a control; the film strip does not hand its
scroll to the page at its end; a long booth slug wraps on a phone instead of
running off the edge (measured on COMPUTED style, not the stylesheet)."""
base, root = live
g = root / "a-very-long-booth-name-that-runs-off-a-phone-screen-2026-09-28"
g.mkdir()
(g / "a.png").write_bytes(_png(64, 48))
(g / "b.png").write_bytes(_png(64, 48))
(g / ".booth.json").write_text('{"handle": "design-dev", "title": "Review"}') # the slug shows beside a title
page = browser.new_page(viewport=PHONE)
page.goto(f"{base}/b/{g.name}/view?f=a.png", wait_until="load")
assert page.locator(".vbar a.vx").evaluate("e => getComputedStyle(e).touchAction") == "manipulation"
assert page.locator(".film").evaluate("e => getComputedStyle(e).overscrollBehaviorX") == "contain"
page.goto(f"{base}/b/{g.name}/", wait_until="load")
slug = page.locator(".h1-slug").evaluate(
"e => ({ws: getComputedStyle(e).whiteSpace, r: e.getBoundingClientRect().right, vw: innerWidth})")
assert slug["ws"] == "normal" and slug["r"] <= slug["vw"], slug
page.close()