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:
+42
-22
@@ -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
@@ -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.
|
||||||
|
`· ` 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 %}
|
||||||
|
· <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>
|
||||||
|
{%- if b.kept %}
|
||||||
|
· <form class="wipe wipe-kept" method="post" action="/b/{{ b.name_url }}/delete"
|
||||||
|
data-booth="{{ b.name }}" data-confirm="wipe-kept"><button title="wipe this KEPT booth now" aria-label="wipe kept booth">× wipe</button></form>
|
||||||
|
{%- else %}
|
||||||
|
· <form class="wipe" method="post" action="/b/{{ b.name_url }}/delete"
|
||||||
|
data-booth="{{ b.name }}" data-confirm="wipe"><button title="wipe now" aria-label="wipe booth">× wipe</button></form>
|
||||||
|
{%- endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{# Badges only: a row with none renders no side column, so it reserves
|
||||||
|
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">
|
|
||||||
{# 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 %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 = "safegnp.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)
|
||||||
|
|||||||
Reference in New Issue
Block a user