fix(desk): a row's keep, release and wipe take no room of their own

Operator, on the live Desk: "release and x take up space whether or not
they're visible." They sat in a side column at opacity 0, which hides a
control and still reserves its box, and hover-only never worked on
touch.

Each control now sits on the facts line beside the state it changes:
release after "kept", keep after a countdown or hold, wipe last. They
are always visible and quiet, and wipe turns danger only under the
pointer or focus. The side column renders only when the row carries a
badge. The row is flex, so an absent column costs no gap. Forms, POST
targets and data-confirm wording are unchanged.

The flex row exposed a latent sizing bug: the stacked Desk column was a
bare 1fr, whose minimum is its content's, so a long nowrap provenance
line scrolled the page sideways at phone width (1029px at 390). It is
now minmax(0,1fr).

Both behaviours have browser tests, mutation-proved (r2_flow.toml:
21/21). Contract C4 amended.
This commit is contained in:
vh
2026-09-23 10:58:15 -07:00
parent ff35023377
commit d40e8fd4a6
5 changed files with 149 additions and 45 deletions
+28 -21
View File
@@ -162,7 +162,10 @@
"Needs you" rows carry an amber inner edge — the one thing on the page
that asks to be looked at. */
.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-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;
@@ -171,19 +174,19 @@
.desk-head-needs{color:var(--warning-text)}
.desk-head-new{color:var(--intel-text)}
.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);
background:var(--surface-card);box-shadow:var(--shadow-sm);
transition:border-color var(--dur-2) var(--ease-out)}
.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-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)}
.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);
font-size:var(--size-caption);letter-spacing:var(--tracking-caps);text-transform:uppercase;color:var(--text-muted)}
.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);
overflow-wrap:anywhere}
.desk-title:hover{color:var(--text-link);text-decoration:none}
@@ -191,25 +194,28 @@
.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-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-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,
because a destructive control must not compete with the thing you came to
read. Static here: on the old cards they floated over a thumbnail. */
.desk-acts{display:flex;gap:6px;opacity:0;transition:opacity var(--dur-1)}
.desk-row:hover .desk-acts,.desk-acts:focus-within{opacity:1}
.desk-acts form{margin:0}
.desk-acts button{height:28px;min-width:28px;padding:0 8px;font-size:var(--size-sm);border-radius:var(--radius-md);
border:1px solid var(--border-strong);background:var(--surface-raised);color:var(--text-body);
/* The row's keep / release / wipe, inline on the facts line beside the
state each one changes. Quiet — muted mono like the line they sit in, so
a destructive control does not compete with the thing you came to read —
but always there and always tappable; wipe turns danger only under the
pointer or focus. */
.desk-facts form{display:inline;margin:0}
.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}
.desk-acts .release button{font-family:var(--font-mono);font-size:var(--size-caption)}
.desk-acts .wipe button:hover{background:var(--danger);border-color:var(--danger);color:var(--danger-contrast)}
.desk-acts .keepit button:hover,.desk-acts .release button:hover{background:var(--surface-overlay)}
.desk-facts .wipe button{margin-left:0}
.desk-facts form button:hover,.desk-facts form button:focus-visible{color:var(--text-body);
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)}
@media (max-width:700px){
.desk-row{grid-template-columns:1fr}
.desk-side{flex-direction:row;align-items:center;justify-content:space-between}
.desk-acts{opacity:1}
.desk-row{flex-wrap:wrap}
.desk-strip{flex-basis:100%}
.desk-side{flex-basis:100%;flex-direction:row;align-items:center}
}
/* the side column */
.desk-panel{border:1px solid var(--border-default);border-radius:var(--radius-xl);background:var(--surface-card);
@@ -252,8 +258,9 @@
.prov-broken{color:var(--warning-text);font-style:italic;cursor:help}
/* 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,
.keep-lg). The kept/ephemeral CARDS they used to float over are gone. */
surface's business: a Desk row's facts line (.desk-facts) or the booth
header (.wipe-lg, .keep-lg). The kept/ephemeral CARDS they used to float
over are gone. */
.wipe,.keepit,.release{margin:0}
.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)}
+25 -24
View File
@@ -36,43 +36,44 @@
{%- else -%}{{ b.name }}{%- endif -%}
</a>
{{ 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. #}
<div class="desk-facts">
{{ b.count }} item{{ '' if b.count == 1 else 's' }}
{% if b.flags %} · <span class="desk-flags">{{ b.flags }} flagged</span>{% endif %}
· {{ lifetime(b.kept, b.hold, b.expires_in) }}
{%- 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>
{# 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">
{% 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 section == 'new' %}<span class="badge badge-new">new</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>
</div>
{% endif %}
</div>
</div>
</article>
{%- endmacro %}
+9
View File
@@ -328,6 +328,15 @@ 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`,
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.
### C5 — the lightbox (booth.html, booth_view)
- **Layout.** Two panes on a gallery booth: the set on the left, the
+29
View File
@@ -158,3 +158,32 @@ new = '''
continue
except FileNotFoundError:
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_on_a_phone"
old = '''
@media (max-width:1000px){.desk{grid-template-columns:minmax(0,1fr)}}'''
new = '''
@media (max-width:1000px){.desk{grid-template-columns:1fr}}'''
+58
View File
@@ -374,3 +374,61 @@ def test_the_next_arrow_clears_the_rail_only_beside_it(browser, live):
"getComputedStyle(document.querySelector('.vnav.vnext')).right")
page.close()
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_on_a_phone(browser, live):
"""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."""
from booth.manifest import write_manifest
base, root = live
d = root / "longwhy"
d.mkdir()
(d / "a.png").write_bytes(PNG)
write_manifest(d, "design-dev", title="A set with a long reason",
why="a reason long enough to overflow any phone " * 6)
widths = {}
for w in (390, 1400):
page = browser.new_page(viewport={"width": w, "height": 844})
page.goto(f"{base}/", wait_until="networkidle")
widths[w] = page.evaluate("document.documentElement.scrollWidth")
page.close()
assert widths == {390: 390, 1400: 1400}, widths