From d40e8fd4a60881fe16cb345cee8eab30dca7127e Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Wed, 23 Sep 2026 10:54:52 -0700 Subject: [PATCH 1/2] 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 From 704e8cd809beaa5118808419286997b1fb726c09 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Wed, 23 Sep 2026 11:27:34 -0700 Subject: [PATCH 2/2] fix(desk): the heid bug-hunt panel on the row controls (round "Slate", 4/4) - Touch: on a coarse pointer every row control is at least 28px square again (32px), and wipe stands clear of the zip link. The move onto the facts line had dropped the deliberate 28px floor to ~21px, 4-6px from zip; with scripts off no confirm fires, so a mis-tap on wipe is the delete. The zip link no longer breaks between its glyph and its word, and each separator is glued to the item after it. - The wipe dialog shows the name as it should be read: control and bidi formatting characters in an agent-made name show as U+FFFD, so U+202E or a newline cannot rewrite what the operator approves. An unknown data-confirm word now prompts generically instead of submitting unguarded (fail closed). - No page scrolls sideways: `code` wraps anywhere, so a long unbreakable install path in the footer or the empty Desk no longer widens every page. The overflow test now sweeps 390/720/850/1000/1400 with the heaviest row the Desk draws, and compares scrollWidth with the page's own clientWidth. Its first fixture used a hyphenated path, which wrapped by itself; the test passed with the bug present until the path became one unbreakable run. r2_flow.toml: 27/27 proved. 749 passed. --- booth/templates/base.html | 15 +++- booth/templates/index.html | 24 +++-- docs/contracts/r2_flow.contract.md | 12 +++ tests/mutations/r2_flow.toml | 58 +++++++++++- tests/test_flow_browser.py | 137 +++++++++++++++++++++++++---- 5 files changed, 222 insertions(+), 24 deletions(-) diff --git a/booth/templates/base.html b/booth/templates/base.html index 45b4f5c..bd2f110 100644 --- a/booth/templates/base.html +++ b/booth/templates/base.html @@ -51,8 +51,11 @@ :focus-visible{outline:2px solid var(--border-focus);outline-offset:2px} a{color:var(--text-link);text-decoration:none} 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
 nothing wraps anyway (white-space). */
   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 ----------------------------------------------------
      A bare