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:
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 Booth</span></a>
|
<a class="brand" href="/"><span class="dot"></span><span class="name">The 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
@@ -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">×</button>
|
title="remove this bench" aria-label="remove the bench {{ b.name or b.url }}">×</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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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. #}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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{}}"""
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user