merge(desk): release and wipe move onto the facts line

The operator: "release and x take up space whether or not they're visible."
Confirmed — opacity:0 hid them while still reserving about 100px of side column
and a 36px row. Each control now sits beside the fact it changes ("kept ·
release", "expires in 22h · keep"), always visible, taking no room of its own,
and nothing hides behind a hover that touch screens never had.

d40e8fd is the change; 704e8cd is its heid bug-hunt fold (round Slate):
coarse-pointer touch targets at 28px with wipe clear of zip, control and bidi
characters shown as U+FFFD in the wipe dialog, an unknown data-confirm word
prompting rather than submitting unguarded, and the CSS "code" rule wrapping
anywhere so a long unbreakable install path in the footer stops widening every
page, the Desk included.

A surgical change that still went through a bug-hunt, which is the discipline
paying for itself: the last item was a latent overflow already on main that only
became visible once the row was a flex container.
This commit is contained in:
vh
2026-09-23 11:28:53 -07:00
5 changed files with 355 additions and 53 deletions
+42 -22
View File
@@ -51,8 +51,11 @@
:focus-visible{outline:2px solid var(--border-focus);outline-offset:2px} :focus-visible{outline:2px solid var(--border-focus);outline-offset:2px}
a{color:var(--text-link);text-decoration:none} a{color:var(--text-link);text-decoration:none}
a:hover{color:var(--text-link-hover);text-decoration:underline} a:hover{color:var(--text-link-hover);text-decoration:underline}
/* overflow-wrap: a path is one unbreakable run to the line breaker, and the
footer and the empty Desk print the install path — a long one scrolled
every page sideways. Inside <pre> nothing wraps anyway (white-space). */
code{font-family:var(--font-mono);font-size:.9em;background:var(--surface-sunken);color:var(--text-body); code{font-family:var(--font-mono);font-size:.9em;background:var(--surface-sunken);color:var(--text-body);
padding:1px 6px;border-radius:var(--radius-sm);border:1px solid var(--border-subtle)} padding:1px 6px;border-radius:var(--radius-sm);border:1px solid var(--border-subtle);overflow-wrap:anywhere}
/* ---- base controls ---------------------------------------------------- /* ---- base controls ----------------------------------------------------
A bare <button>, <textarea> or text <input> in the chrome is a quiet SVOS A bare <button>, <textarea> or text <input> in the chrome is a quiet SVOS
@@ -162,7 +165,10 @@
"Needs you" rows carry an amber inner edge — the one thing on the page "Needs you" rows carry an amber inner edge — the one thing on the page
that asks to be looked at. */ that asks to be looked at. */
.desk{display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:var(--space-6);align-items:start} .desk{display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:var(--space-6);align-items:start}
@media (max-width:1000px){.desk{grid-template-columns:1fr}} /* minmax(0,…), never a bare 1fr: 1fr's minimum is the content's, and a
Desk row's nowrap provenance line would then set the column's width —
the page scrolled sideways on a phone. */
@media (max-width:1000px){.desk{grid-template-columns:minmax(0,1fr)}}
.desk-sec{margin:0 0 var(--space-6)} .desk-sec{margin:0 0 var(--space-6)}
.desk-head{display:flex;align-items:center;gap:10px;margin:0 0 10px;font-family:var(--font-mono); .desk-head{display:flex;align-items:center;gap:10px;margin:0 0 10px;font-family:var(--font-mono);
font-size:var(--size-micro);font-weight:500;letter-spacing:var(--tracking-caps);text-transform:uppercase; font-size:var(--size-micro);font-weight:500;letter-spacing:var(--tracking-caps);text-transform:uppercase;
@@ -171,19 +177,19 @@
.desk-head-needs{color:var(--warning-text)} .desk-head-needs{color:var(--warning-text)}
.desk-head-new{color:var(--intel-text)} .desk-head-new{color:var(--intel-text)}
.desk-rule{font-weight:400;letter-spacing:0;text-transform:none;color:var(--text-muted)} .desk-rule{font-weight:400;letter-spacing:0;text-transform:none;color:var(--text-muted)}
.desk-row{display:grid;grid-template-columns:210px minmax(0,1fr) auto;gap:var(--space-4);align-items:center; .desk-row{display:flex;gap:var(--space-4);align-items:center;
padding:12px;margin-bottom:8px;border:1px solid var(--border-default);border-radius:var(--radius-xl); padding:12px;margin-bottom:8px;border:1px solid var(--border-default);border-radius:var(--radius-xl);
background:var(--surface-card);box-shadow:var(--shadow-sm); background:var(--surface-card);box-shadow:var(--shadow-sm);
transition:border-color var(--dur-2) var(--ease-out)} transition:border-color var(--dur-2) var(--ease-out)}
.desk-row:hover,.desk-row:focus-within{border-color:var(--border-strong)} .desk-row:hover,.desk-row:focus-within{border-color:var(--border-strong)}
.desk-row.is-needs{box-shadow:inset 3px 0 0 var(--warning),var(--shadow-sm)} .desk-row.is-needs{box-shadow:inset 3px 0 0 var(--warning),var(--shadow-sm)}
.desk-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:3px;height:58px;border-radius:var(--radius-md); .desk-strip{flex:0 0 210px;display:grid;grid-template-columns:repeat(4,1fr);gap:3px;height:58px;border-radius:var(--radius-md);
overflow:hidden;background:var(--surface-sunken)} overflow:hidden;background:var(--surface-sunken)}
.desk-strip img{width:100%;height:100%;object-fit:cover;display:block} .desk-strip img{width:100%;height:100%;object-fit:cover;display:block}
.desk-strip .ph{grid-column:1/-1;display:flex;align-items:center;justify-content:center;font-family:var(--font-mono); .desk-strip .ph{grid-column:1/-1;display:flex;align-items:center;justify-content:center;font-family:var(--font-mono);
font-size:var(--size-caption);letter-spacing:var(--tracking-caps);text-transform:uppercase;color:var(--text-muted)} font-size:var(--size-caption);letter-spacing:var(--tracking-caps);text-transform:uppercase;color:var(--text-muted)}
.desk-strip:hover{text-decoration:none} .desk-strip:hover{text-decoration:none}
.desk-main{min-width:0} .desk-main{flex:1 1 auto;min-width:0}
.desk-title{display:block;font-weight:600;font-size:var(--size-h3);line-height:1.3;color:var(--text-heading); .desk-title{display:block;font-weight:600;font-size:var(--size-h3);line-height:1.3;color:var(--text-heading);
overflow-wrap:anywhere} overflow-wrap:anywhere}
.desk-title:hover{color:var(--text-link);text-decoration:none} .desk-title:hover{color:var(--text-link);text-decoration:none}
@@ -191,25 +197,38 @@
.desk-main .prov{margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .desk-main .prov{margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.desk-facts{margin-top:5px;font-family:var(--font-mono);font-size:var(--size-caption);color:var(--text-muted)} .desk-facts{margin-top:5px;font-family:var(--font-mono);font-size:var(--size-caption);color:var(--text-muted)}
.desk-flags{color:var(--success-text)} .desk-flags{color:var(--success-text)}
.desk-side{display:flex;flex-direction:column;align-items:flex-end;gap:8px} .desk-side{flex:none;display:flex;flex-direction:column;align-items:flex-end;gap:8px}
.badge-new{color:var(--intel-text);background:var(--intel-soft)} .badge-new{color:var(--intel-text);background:var(--intel-soft)}
.badge-broken{color:var(--danger-text);background:var(--danger-soft);border-color:color-mix(in oklab,var(--danger) 40%,transparent)} .badge-broken{color:var(--danger-text);background:var(--danger-soft);border-color:color-mix(in oklab,var(--danger) 40%,transparent)}
/* The row's keep / release / × — quiet until the row is hovered or focused, /* The row's keep / release / wipe, inline on the facts line beside the
because a destructive control must not compete with the thing you came to state each one changes. Quiet — muted mono like the line they sit in, so
read. Static here: on the old cards they floated over a thumbnail. */ a destructive control does not compete with the thing you came to read —
.desk-acts{display:flex;gap:6px;opacity:0;transition:opacity var(--dur-1)} but always there and always tappable; wipe turns danger only under the
.desk-row:hover .desk-acts,.desk-acts:focus-within{opacity:1} pointer or focus. */
.desk-acts form{margin:0} .desk-facts form{display:inline;margin:0}
.desk-acts button{height:28px;min-width:28px;padding:0 8px;font-size:var(--size-sm);border-radius:var(--radius-md); .desk-facts .dl-link{white-space:nowrap}
border:1px solid var(--border-strong);background:var(--surface-raised);color:var(--text-body); .desk-facts form button{display:inline-block;margin-left:4px;padding:1px 6px;height:auto;min-width:0;
font:inherit;line-height:1.4;color:var(--text-muted);background:none;
border:1px solid var(--border-default);border-radius:var(--radius-sm);
-webkit-backdrop-filter:none;backdrop-filter:none;cursor:pointer} -webkit-backdrop-filter:none;backdrop-filter:none;cursor:pointer}
.desk-acts .release button{font-family:var(--font-mono);font-size:var(--size-caption)} .desk-facts .wipe button{margin-left:0}
.desk-acts .wipe button:hover{background:var(--danger);border-color:var(--danger);color:var(--danger-contrast)} .desk-facts form button:hover,.desk-facts form button:focus-visible{color:var(--text-body);
.desk-acts .keepit button:hover,.desk-acts .release button:hover{background:var(--surface-overlay)} border-color:var(--border-strong);background:var(--surface-overlay)}
.desk-facts .wipe button:hover,.desk-facts .wipe button:focus-visible{background:var(--danger);
border-color:var(--danger);color:var(--danger-contrast)}
/* A finger is not a cursor. The compact line is for a fine pointer; on a
coarse one every control gets back the 28px floor it had as a column
(32px here, with room), and wipe stands clear of the zip link — with
scripts off no confirm fires, so a mis-tap on wipe IS the delete. */
@media (pointer:coarse){
.desk-facts{line-height:2.4}
.desk-facts form button{min-height:32px;min-width:32px;padding:0 10px;margin-left:6px;vertical-align:middle}
.desk-facts .wipe button{margin-left:10px}
}
@media (max-width:700px){ @media (max-width:700px){
.desk-row{grid-template-columns:1fr} .desk-row{flex-wrap:wrap}
.desk-side{flex-direction:row;align-items:center;justify-content:space-between} .desk-strip{flex-basis:100%}
.desk-acts{opacity:1} .desk-side{flex-basis:100%;flex-direction:row;align-items:center}
} }
/* the side column */ /* the side column */
.desk-panel{border:1px solid var(--border-default);border-radius:var(--radius-xl);background:var(--surface-card); .desk-panel{border:1px solid var(--border-default);border-radius:var(--radius-xl);background:var(--surface-card);
@@ -252,8 +271,9 @@
.prov-broken{color:var(--warning-text);font-style:italic;cursor:help} .prov-broken{color:var(--warning-text);font-style:italic;cursor:help}
/* keep / release / wipe — the controls themselves. Where they sit is the /* keep / release / wipe — the controls themselves. Where they sit is the
surface's business: a Desk row (.desk-acts) or the booth header (.wipe-lg, surface's business: a Desk row's facts line (.desk-facts) or the booth
.keep-lg). The kept/ephemeral CARDS they used to float over are gone. */ header (.wipe-lg, .keep-lg). The kept/ephemeral CARDS they used to float
over are gone. */
.wipe,.keepit,.release{margin:0} .wipe,.keepit,.release{margin:0}
.wipe button,.keepit button,.release button{cursor:pointer;font:inherit;line-height:1; .wipe button,.keepit button,.release button{cursor:pointer;font:inherit;line-height:1;
transition:background var(--dur-1) var(--ease-out),border-color var(--dur-1) var(--ease-out),color var(--dur-1) var(--ease-out)} transition:background var(--dur-1) var(--ease-out),border-color var(--dur-1) var(--ease-out),color var(--dur-1) var(--ease-out)}
+40 -27
View File
@@ -36,43 +36,46 @@
{%- else -%}{{ b.name }}{%- endif -%} {%- else -%}{{ b.name }}{%- endif -%}
</a> </a>
{{ provenance(b.manifest) }} {{ provenance(b.manifest) }}
{# The row's controls sit ON the facts line, each beside the state it
changes: release after "kept", keep after the countdown, wipe last.
They were a hover-revealed column that reserved its room while
invisible (operator, on the live Desk: "release and x take up space
whether or not they're visible") — and hover never existed on touch.
The confirmation text is DATA-DRIVEN: the booth name travels as a data
attribute and never reaches a JS string (see the script below).
Release is reversible, so it has no prompt beyond the wording.
`·&nbsp;` glues each separator to the item after it, so a wrapped
line never ends on a dangling dot. #}
<div class="desk-facts"> <div class="desk-facts">
{{ b.count }} item{{ '' if b.count == 1 else 's' }} {{ b.count }} item{{ '' if b.count == 1 else 's' }}
{% if b.flags %} · <span class="desk-flags">{{ b.flags }} flagged</span>{% endif %} {% if b.flags %} · <span class="desk-flags">{{ b.flags }} flagged</span>{% endif %}
· {{ lifetime(b.kept, b.hold, b.expires_in) }} · {{ lifetime(b.kept, b.hold, b.expires_in) }}
· <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a> {%- if b.kept %}
<form class="release" method="post" action="/b/{{ b.name_url }}/unkeep"
data-booth="{{ b.name }}" data-confirm="release"><button title="release this booth so it can be wiped">release</button></form>
{%- else %}
<form class="keepit" method="post" action="/b/{{ b.name_url }}/keep"><button title="keep — exempt from the {{ ttl_hours }}h sweep" aria-label="keep booth">★ keep</button></form>
{%- endif %}
·&nbsp;<a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>
{%- if b.kept %}
·&nbsp;<form class="wipe wipe-kept" method="post" action="/b/{{ b.name_url }}/delete"
data-booth="{{ b.name }}" data-confirm="wipe-kept"><button title="wipe this KEPT booth now" aria-label="wipe kept booth">× wipe</button></form>
{%- else %}
·&nbsp;<form class="wipe" method="post" action="/b/{{ b.name_url }}/delete"
data-booth="{{ b.name }}" data-confirm="wipe"><button title="wipe now" aria-label="wipe booth">× wipe</button></form>
{%- endif %}
</div> </div>
</div> </div>
{# Badges only: a row with none renders no side column, so it reserves
no room (the row is flex — an absent item costs no gap). #}
{% if b.marks_open or b.hold == "unreadable" or section == 'new' or b.uploaded %}
<div class="desk-side"> <div class="desk-side">
{% if b.marks_open %}<span class="badge badge-mark">? {{ b.marks_open }} open</span> {% if b.marks_open %}<span class="badge badge-mark">? {{ b.marks_open }} open</span>
{% elif b.hold == "unreadable" %}<span class="badge badge-broken">marks unreadable</span> {% elif b.hold == "unreadable" %}<span class="badge badge-broken">marks unreadable</span>
{% elif section == 'new' %}<span class="badge badge-new">new</span>{% endif %} {% elif section == 'new' %}<span class="badge badge-new">new</span>{% endif %}
{% if b.uploaded %}<span class="badge">⬆ pickup</span>{% endif %} {% if b.uploaded %}<span class="badge">⬆ pickup</span>{% endif %}
<div class="desk-acts"> </div>
{# Keep / release and the ×. The confirmation text is DATA-DRIVEN: the
booth name travels as a data attribute and never reaches a JS string
(see the script below). Release is reversible, so it has no prompt of
its own beyond the wording. #}
{% if b.kept %}
<form class="release" method="post" action="/b/{{ b.name_url }}/unkeep"
data-booth="{{ b.name }}" data-confirm="release">
<button title="release this booth so it can be wiped">release</button>
</form>
<form class="wipe wipe-kept" method="post" action="/b/{{ b.name_url }}/delete"
data-booth="{{ b.name }}" data-confirm="wipe-kept">
<button title="wipe this KEPT booth now" aria-label="wipe kept booth">×</button>
</form>
{% else %}
<form class="keepit" method="post" action="/b/{{ b.name_url }}/keep">
<button title="keep — exempt from the {{ ttl_hours }}h sweep" aria-label="keep booth">★</button>
</form>
<form class="wipe" method="post" action="/b/{{ b.name_url }}/delete"
data-booth="{{ b.name }}" data-confirm="wipe">
<button title="wipe now" aria-label="wipe booth">×</button>
</form>
{% endif %} {% endif %}
</div>
</div>
</article> </article>
{%- endmacro %} {%- endmacro %}
@@ -203,11 +206,21 @@
}, },
wipe: function (n) { return 'Wipe booth “' + n + '”?'; } 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) { document.addEventListener('submit', function (ev) {
var form = ev.target.closest ? ev.target.closest('form[data-confirm]') : null; var form = ev.target.closest ? ev.target.closest('form[data-confirm]') : null;
if (!form) return; if (!form) return;
var word = WORDS[form.getAttribute('data-confirm')]; var word = WORDS[form.getAttribute('data-confirm')] || ASK;
if (word && !confirm(word(form.getAttribute('data-booth') || ''))) ev.preventDefault(); if (!confirm(word(shown(form.getAttribute('data-booth') || '')))) ev.preventDefault();
}, true); }, true);
})(); })();
</script> </script>
+21
View File
@@ -328,6 +328,27 @@ The kept/ephemeral lanes are **removed**: 23 of 24 live booths are kept, so the
lanes sort nothing. Kept status and the lifetime line (`_lifetime.html`, lanes sort nothing. Kept status and the lifetime line (`_lifetime.html`,
unchanged) remain on every row. unchanged) remain on every row.
**A row's controls sit on its facts line**, each beside the state it changes:
`release` after "kept", `★ keep` after a countdown or a hold, `× wipe` last.
They are always visible, with no hover-only reveal: that was a column that
reserved its room while invisible (operator, 2026-09-23, on the live Desk:
"release and x take up space whether or not they're visible"), and touch has
no hover. The side column renders only when the row has a badge, so a row
without one reserves nothing. The forms, POST targets and `data-confirm`
wording are unchanged.
- **On a coarse pointer every row control is at least 28px square**, the floor
it had as a column, and wipe stands clear of the zip link. With scripts off
no confirm fires, so a mis-tap on wipe is the delete. A fine pointer keeps
the compact line.
- **The confirm dialog shows the name as it should be READ.** Control and bidi
formatting characters in an agent-made name show as U+FFFD, so a U+202E or a
newline cannot rewrite what the operator approves. A `data-confirm` word the
page does not know still asks, generically: the prompt fails closed.
- **No page scrolls sideways at any width**, including an install path with no
break opportunity in the footer or the empty Desk (`code` wraps anywhere).
Tested at 390, 720, 850, 1000 and 1400px with the heaviest row the Desk draws.
### C5 — the lightbox (booth.html, booth_view) ### C5 — the lightbox (booth.html, booth_view)
- **Layout.** Two panes on a gallery booth: the set on the left, the - **Layout.** Two panes on a gallery booth: the set on the left, the
+85
View File
@@ -158,3 +158,88 @@ new = '''
continue continue
except FileNotFoundError: except FileNotFoundError:
continue''' continue'''
[[mutation]]
label = "Desk row controls hidden until hover again (opacity 0 keeps their box)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_a_rows_keep_release_and_wipe_take_no_room_of_their_own"
old = '''
.desk-facts form{display:inline;margin:0}'''
new = '''
.desk-facts form{display:inline;margin:0;opacity:0}'''
[[mutation]]
label = "Desk row renders its side column with no badge in it (a reserved gap)"
file = "booth/templates/index.html"
test = "tests/test_flow_browser.py::test_a_rows_keep_release_and_wipe_take_no_room_of_their_own"
old = '''
{% if b.marks_open or b.hold == "unreadable" or section == 'new' or b.uploaded %}
<div class="desk-side">'''
new = '''
{% if true %}
<div class="desk-side">'''
[[mutation]]
label = "the stacked Desk column is a bare 1fr (content sets its minimum)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_desk_never_scrolls_sideways_at_any_width"
old = '''
@media (max-width:1000px){.desk{grid-template-columns:minmax(0,1fr)}}'''
new = '''
@media (max-width:1000px){.desk{grid-template-columns:1fr}}'''
[[mutation]]
label = "a long unbreakable install path in <code> scrolls the page sideways"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_desk_never_scrolls_sideways_at_any_width"
old = '''
padding:1px 6px;border-radius:var(--radius-sm);border:1px solid var(--border-subtle);overflow-wrap:anywhere}'''
new = '''
padding:1px 6px;border-radius:var(--radius-sm);border:1px solid var(--border-subtle)}'''
[[mutation]]
label = "the row's text column cannot shrink (the 700-1000px window overflows)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_desk_never_scrolls_sideways_at_any_width"
old = '''
.desk-main{flex:1 1 auto;min-width:0}'''
new = '''
.desk-main{flex:1 1 auto}'''
[[mutation]]
label = "a coarse pointer gets the compact ~21px controls"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_on_a_touch_screen_the_row_controls_keep_their_tap_floor"
old = '''
.desk-facts form button{min-height:32px;min-width:32px;padding:0 10px;margin-left:6px;vertical-align:middle}'''
new = '''
.desk-facts form button{padding:0 10px;margin-left:6px;vertical-align:middle}'''
[[mutation]]
label = "the zip link may break between its glyph and its word"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_on_a_touch_screen_the_row_controls_keep_their_tap_floor"
old = '''
.desk-facts .dl-link{white-space:nowrap}'''
new = '''
.desk-facts .dl-link{}'''
[[mutation]]
label = "the wipe dialog shows the agent-made name raw (bidi, newline)"
file = "booth/templates/index.html"
test = "tests/test_flow_browser.py::test_the_wipe_dialog_shows_what_is_being_wiped_and_never_fails_open"
old = '''
if (!confirm(word(shown(form.getAttribute('data-booth') || '')))) ev.preventDefault();'''
new = '''
if (!confirm(word(form.getAttribute('data-booth') || ''))) ev.preventDefault();'''
[[mutation]]
label = "an unknown data-confirm word submits with no prompt (fail open)"
file = "booth/templates/index.html"
test = "tests/test_flow_browser.py::test_the_wipe_dialog_shows_what_is_being_wiped_and_never_fails_open"
old = '''
var word = WORDS[form.getAttribute('data-confirm')] || ASK;
if (!confirm('''
new = '''
var word = WORDS[form.getAttribute('data-confirm')];
if (word && !confirm('''
+167 -4
View File
@@ -6,6 +6,7 @@ that a failure never re-POSTs. So: a real uvicorn, a real Chromium — the same
harness as test_embed_browser.py, and like it this SKIPS, never fails, when no harness as test_embed_browser.py, and like it this SKIPS, never fails, when no
browser is available. browser is available.
""" """
import contextlib
import pathlib import pathlib
import socket import socket
import sys import sys
@@ -34,15 +35,18 @@ def browser():
b.close() b.close()
@pytest.fixture @contextlib.contextmanager
def live(tmp_path): def _serving(data_dir: pathlib.Path):
"""A real uvicorn on a free port over `data_dir`. A context manager rather
than only a fixture, so a test can serve from a data dir of its own shape
(a long install path, say)."""
import uvicorn import uvicorn
sock = socket.socket() sock = socket.socket()
sock.bind(("127.0.0.1", 0)) sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1] port = sock.getsockname()[1]
sock.close() sock.close()
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False) app = create_app(data_dir, ttl_hours=24, start_sweeper=False)
server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error")) server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error"))
thread = threading.Thread(target=server.run, daemon=True) thread = threading.Thread(target=server.run, daemon=True)
thread.start() thread.start()
@@ -52,12 +56,18 @@ def live(tmp_path):
if not server.started: if not server.started:
pytest.skip("uvicorn did not come up") pytest.skip("uvicorn did not come up")
try: try:
yield f"http://127.0.0.1:{port}", tmp_path yield f"http://127.0.0.1:{port}"
finally: finally:
server.should_exit = True server.should_exit = True
thread.join(timeout=10) thread.join(timeout=10)
@pytest.fixture
def live(tmp_path):
with _serving(tmp_path) as base:
yield base, tmp_path
def _set(root: pathlib.Path, n: int = 30) -> pathlib.Path: def _set(root: pathlib.Path, n: int = 30) -> pathlib.Path:
b = root / "g" b = root / "g"
b.mkdir() b.mkdir()
@@ -374,3 +384,156 @@ def test_the_next_arrow_clears_the_rail_only_beside_it(browser, live):
"getComputedStyle(document.querySelector('.vnav.vnext')).right") "getComputedStyle(document.querySelector('.vnav.vnext')).right")
page.close() page.close()
assert rights == {1400: "360px", 390: "0px"} assert rights == {1400: "360px", 390: "0px"}
def test_a_rows_keep_release_and_wipe_take_no_room_of_their_own(browser, live):
"""Operator, on the live Desk: 'release and x take up space whether or not
they're visible.' They were `opacity:0` in their own side column, which
hides a control and still reserves its box. Each now sits on the facts
line beside the state it changes, visible without hover (a touch screen
never had hover), so a row with no badge has no side column at all."""
import os
base, root = live
past = time.time() - 10_000
for name, kept in (("kept1", True), ("loose", False)):
d = root / name
d.mkdir()
(d / "a.png").write_bytes(PNG)
os.utime(d / "a.png", (past, past))
(d / ".viewed").write_bytes(b"") # looked at since: no 'new' badge
if kept:
(d / ".forever").write_bytes(b"")
page = browser.new_page(viewport={"width": 1400, "height": 900})
page.goto(f"{base}/", wait_until="networkidle")
page.mouse.move(0, 0) # nothing hovered
out = {}
for name, form in (("kept1", "form.release"), ("kept1", "form.wipe-kept"),
("loose", "form.keepit"), ("loose", "form.wipe")):
btn = page.locator(f'.desk-row[data-booth="{name}"] {form} button')
out[(name, form)] = btn.is_visible() and btn.evaluate(
"b => { for (let e = b; e; e = e.parentElement)"
" if (getComputedStyle(e).opacity === '0') return false;"
" return true; }")
gaps = page.evaluate("""() => [...document.querySelectorAll('.desk-row')].map(r => {
const row = r.getBoundingClientRect(), main = r.querySelector('.desk-main').getBoundingClientRect();
return Math.round(row.right - main.right); })""")
page.close()
assert all(out.values()), out
# row padding (12) + border (1) + nothing else: no side column is reserved
assert gaps and max(gaps) <= 14, gaps
def test_the_desk_never_scrolls_sideways_at_any_width(browser, tmp_path):
"""The row-controls change made a Desk row a flex container, and a long
provenance line (nowrap, ellipsised) then set the Desk column's MINIMUM
width: at 390px the page scrolled sideways to 1029px. The column is capped
at the space it has; the ellipsis does the rest.
Slate (heid bug-hunt) widened it three ways: the invariant is PAGE-level,
so a long install path — printed in the footer and the empty state — must
wrap too (groa); the widths between the two first tested (700-1000px, a
non-wrapping flex line) are swept with the heaviest row the Desk draws
(regin); and the page is compared with its OWN client width, never the
viewport's, which a vertical scrollbar would make a false failure (kimi)."""
from booth.manifest import write_manifest
from booth.marks import declare_pick, set_flag
# ONE unbreakable run: a hyphen or a slash is a line-break opportunity, and
# the first draft of this fixture ("a-very-long-install-path-" * 9) wrapped
# all by itself — the test passed with the bug present.
root = tmp_path / ("averylonginstallpath" * 11)
root.mkdir()
over = {}
with _serving(root) as base:
for w in (390, 1400): # the empty Desk first
page = browser.new_page(viewport={"width": w, "height": 844})
page.goto(f"{base}/", wait_until="networkidle")
over[("empty", w)] = page.evaluate(
"document.documentElement.scrollWidth - document.documentElement.clientWidth")
page.close()
d = root / ("heavy-" + "x" * 60)
d.mkdir()
for i in range(12):
(d / f"{i:02d}.png").write_bytes(PNG)
set_flag(d, f"{i:02d}.png", True)
declare_pick(d, "q", {"prompt": "Which?", "options": ["x", "y"]})
(d / ".uploaded").write_bytes(b"")
write_manifest(d, "design-dev", title="A set with a long title " * 4,
why="a reason long enough to overflow any phone " * 6)
for w in (390, 720, 850, 1000, 1400):
page = browser.new_page(viewport={"width": w, "height": 844})
page.goto(f"{base}/", wait_until="networkidle")
over[("heavy", w)] = page.evaluate(
"document.documentElement.scrollWidth - document.documentElement.clientWidth")
page.close()
assert all(v <= 0 for v in over.values()), over
def test_on_a_touch_screen_the_row_controls_keep_their_tap_floor(browser, live):
"""Slate T2 (kimi, groa): moving keep/release/wipe onto the facts line
dropped the deliberate 28px tap target (`height:28px;min-width:28px`) to
about 19px, 4-6px from the zip link. With scripts off no confirm fires, so
a mis-tap on wipe POSTs the delete. On a coarse pointer every row control
is at least 28px square again and wipe stands clear of the zip link; a
fine pointer keeps the compact line."""
import os
base, root = live
past = time.time() - 10_000
for name, kept in (("kept1", True), ("loose", False)):
d = root / name
d.mkdir()
(d / "a.png").write_bytes(PNG)
os.utime(d / "a.png", (past, past))
(d / ".viewed").write_bytes(b"")
if kept:
(d / ".forever").write_bytes(b"")
ctx = browser.new_context(viewport={"width": 390, "height": 844}, has_touch=True, is_mobile=True)
page = ctx.new_page()
page.goto(f"{base}/", wait_until="networkidle")
coarse = page.evaluate("matchMedia('(pointer: coarse)').matches")
boxes = page.evaluate("""() => [...document.querySelectorAll('.desk-facts form button')].map(b => {
const r = b.getBoundingClientRect(); return [Math.round(r.width), Math.round(r.height)]; })""")
gaps = page.evaluate("""() => [...document.querySelectorAll('.desk-row')].map(row => {
const z = row.querySelector('.dl-link').getBoundingClientRect(),
w = row.querySelector('form.wipe button').getBoundingClientRect();
// the clearance between the two boxes on whichever axis separates them:
// beside each other on one line, or wipe wrapped onto the next
return Math.round(Math.max(w.left - z.right, z.left - w.right, w.top - z.bottom, z.top - w.bottom)); })""")
ctx.close()
assert coarse, "the emulation must present a coarse pointer, or this test measures nothing"
assert len(boxes) == 4 and all(w >= 28 and h >= 28 for w, h in boxes), boxes
assert all(g >= 8 for g in gaps), gaps
def test_the_wipe_dialog_shows_what_is_being_wiped_and_never_fails_open(browser, live):
"""Slate T4 (groa): the booth name travels as data, never into a script —
but the confirm TEXT showed it raw, so a name carrying a bidi override
(U+202E) or a newline rewrote what the operator reads before approving a
wipe. Controls and bidi formatting show as U+FFFD instead. And (seat S1)
a `data-confirm` word the page does not know submitted with NO prompt; an
unknown word now asks generically — fail closed, never open."""
import os
base, root = live
name = "safe‮gnp.xe\nline2"
d = root / name
d.mkdir()
(d / "a.png").write_bytes(PNG)
past = time.time() - 10_000
os.utime(d / "a.png", (past, past))
(d / ".viewed").write_bytes(b"")
page = browser.new_page(viewport={"width": 1400, "height": 900})
said = []
page.on("dialog", lambda dlg: (said.append(dlg.message), dlg.dismiss()))
page.goto(f"{base}/", wait_until="networkidle")
page.locator("form.wipe button").first.click()
page.wait_for_timeout(300)
page.evaluate("""() => { const f = document.createElement('form');
f.method = 'post'; f.action = '/nowhere'; f.setAttribute('data-confirm', 'typo');
f.setAttribute('data-booth', 'x'); f.innerHTML = '<button>go</button>';
document.body.appendChild(f); f.querySelector('button').click(); }""")
page.wait_for_timeout(300)
page.close()
assert d.exists(), "a dismissed confirm must not wipe"
assert len(said) == 2, said
shown = said[0].split("\n\n")[0]
assert not any(c in shown for c in "‮\n"), repr(shown)
assert "safe�gnp.xe�line2" in shown, repr(shown)