From d40e8fd4a60881fe16cb345cee8eab30dca7127e Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Wed, 23 Sep 2026 10:54:52 -0700 Subject: [PATCH] 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. --- booth/templates/base.html | 49 ++++++++++++++----------- booth/templates/index.html | 49 ++++++++++++------------- docs/contracts/r2_flow.contract.md | 9 +++++ tests/mutations/r2_flow.toml | 29 +++++++++++++++ tests/test_flow_browser.py | 58 ++++++++++++++++++++++++++++++ 5 files changed, 149 insertions(+), 45 deletions(-) diff --git a/booth/templates/base.html b/booth/templates/base.html index 9d2514c..45b4f5c 100644 --- a/booth/templates/base.html +++ b/booth/templates/base.html @@ -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)} diff --git a/booth/templates/index.html b/booth/templates/index.html index 0e25f60..92c40ce 100644 --- a/booth/templates/index.html +++ b/booth/templates/index.html @@ -36,43 +36,44 @@ {%- else -%}{{ b.name }}{%- endif -%} {{ 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. #}
{{ b.count }} item{{ '' if b.count == 1 else 's' }} {% if b.flags %} · {{ b.flags }} flagged{% endif %} · {{ lifetime(b.kept, b.hold, b.expires_in) }} + {%- if b.kept %} +
+ {%- else %} +
+ {%- endif %} · ⬇ zip + {%- if b.kept %} + ·
+ {%- else %} + ·
+ {%- endif %}
+ {# 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 %}
{% if b.marks_open %}? {{ b.marks_open }} open {% elif b.hold == "unreadable" %}marks unreadable {% elif section == 'new' %}new{% endif %} {% if b.uploaded %}⬆ pickup{% endif %} -
- {# 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 %} -
- -
-
- -
- {% else %} -
- -
-
- -
- {% endif %} -
+ {% endif %} {%- endmacro %} diff --git a/docs/contracts/r2_flow.contract.md b/docs/contracts/r2_flow.contract.md index 5a7d995..476c97f 100644 --- a/docs/contracts/r2_flow.contract.md +++ b/docs/contracts/r2_flow.contract.md @@ -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 diff --git a/tests/mutations/r2_flow.toml b/tests/mutations/r2_flow.toml index c6176f5..44684b0 100644 --- a/tests/mutations/r2_flow.toml +++ b/tests/mutations/r2_flow.toml @@ -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 %} +
''' +new = ''' + {% if true %} +
''' + +[[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}}''' diff --git a/tests/test_flow_browser.py b/tests/test_flow_browser.py index 78daedd..62d7bf1 100644 --- a/tests/test_flow_browser.py +++ b/tests/test_flow_browser.py @@ -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