From 72d6c6162973ced860204e2943deb8e726593b8f Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Tue, 29 Sep 2026 00:26:41 -0700 Subject: [PATCH] fix(as-S5c): whose key it is, the doc bar, tile sizes, the rail's shadow, reveal names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last of the anti-slop interaction work (guidelines G6, G7, G14, G15, G17), plus booth-dev's note from S5b's gate. Every S5b promise holds: no re-POST, serialized saves, a batch never reloads, focus survives a swap. - Keys (G6): one rule in base.html's , BoothKeys.theirs(e), called first by the grid, the review and compare. A field or a player owns every key but Escape (Esc still goes back from a focused player); a control owns Space; a focused 1:1 stage that can pan owns the arrows and Space (Chromium puts it in the Tab order); Ctrl/Meta/Alt are the browser's. The field check lives on as BoothKeys.isEditable. Before: an arrow on a focused video left the review, and Enter on any control also opened the grid cursor's tile. - The grid cursor is real focus: the tile it moves to gets tabindex=-1 (script-set, one tile at a time) and focus, without a scroll; the cursor is an item (its data-item), and a doc closed with its ✕ is skipped; focus that lands on a tile (S5b's fallback) makes it the cursor; Enter opens the review only from the body, the grid or the tile, by its view?f= link; n opens a closed doc's fold; Escape clears the cursor and releases the tile's focus. The reticle is its focus mark (no second ring). - The doc bar (G7): the controls leave the . div.doc-bar holds details.doc-fold (its summary is the label only) and div.doc-tools beside it; the body and notes follow in div.doc-inline, hidden with a closed fold by :has(), scripts on or off. A closed doc keeps its tools. Renders pixel-identical to today at 1280 and 390, light and dark. - Tile sizes (G14): a gallery tile's carries width/height, the picture as the browser draws it (EXIF 5-8 swap), read from the header only (no decode; PNG getexif is skipped unless the header carried it), opened O_NOFOLLOW|O_NONBLOCK, cached by the file's identity (ctime included, so cp -p over a file is seen), in a separate step (items.image_dims over thumbs.drawn_size) so the Desk never pays it. Measured before: a link to tile 30 of 40 landed 44px low (3/3); after, on its mark. content-visibility:auto, which the report proposed too, is NOT added: a swapped-in tile has no remembered size, and a flag far down moved the page 2929px (3/3; 0px without it). - The rail (G15): html:has(.rail){scroll-padding-top} replaces .item's scroll-margin-top (the two add), so a control reached by Tab stops below the sticky rail too. Measured before: a Tab-focused flag button at 19.6px, under the rail's bottom at 47.6px. The scripts-off fallbacks are the old rules' numbers (132px, 217px at <=480), now pinned by a test. The height script follows the live rail after every in-place save (it watched the replaced node, and read 0px after one flag), and the rail's own controls cancel the padding (a Tab between stuck group links scrolled 357px). - Reveal names (G17): no aria-label on any reveal control; the name is the words on it, the glyph in an aria-hidden span, the item's name as .sr-only text ("reveal a.png" / "hide a.png"). Reveal all drops aria-pressed (its words already say the state; r2b rules them) and its "on" look reads the .reveal-all class on . No pixel changes. - booth-dev's note: a refused batch's forms enter `unsent` with the refusal's words, and a later save says every standing failure's words (each once, in order) instead of "Saved.", and every warning says the other standing failures first, so no failure buries another. Test first: test_a_batch_refusal_outlives_an_unrelated_save. - Rows re-anchored to the same failure: r2b "Space on a focused review button", r3 "C3 a held modifier" and both "C3 Space on a focused ..." (now in BoothKeys), r2c "the stage reveal shows with scripts off", and this contract's S3 doc-bar row and five S5b status-line rows. Folded from the heid contract review (BEINKA, panel 4/4, thread 01M3NZJNX8D3BEYD48M9K3MV3Q): 24 flags, all prose the tests left open; the contract states the tile/focus/cursor seam with S5b, the helper's union and scope, the size's source and every path to none, Reveal all's name, the refusal sentence's lifetime, and the fallback arithmetic (one test added). Folded from the heid bug-hunt (HRÖSKVA, panel 4/4, thread 01M3P0ZPRSASFSE5K3PR4NTQP6): R1 closed docs and the cursor as an item, R2 the rail's height after a save, R3 no warning buries another, R5 the view?f= link, R7 ctime in the size cache, R8 Escape from a player, R9 the rail's own controls, R10 n on a closed doc. Refuted with reasons: R4 (unreachable: refused picks re-send together), R6 (Chrome takes the same header's size with or without the attributes; measured), R11 (by design). From this slice's own falsifier runs: a "one row wide" row that mutated a flex basis a non-wrapping bar just shrinks (re-aimed at the bar's flex), and a Reveal-all "on look" read under the clicking pointer, where :hover draws the same border (the pointer now leaves first; 3/3 proved). Contract: as_antislop S5c. Falsifiers: antislop.toml S5c section. --- booth/app.py | 4 + booth/items.py | 13 +- booth/templates/base.html | 170 +++++-- booth/templates/booth.html | 152 ++++-- booth/templates/compare.html | 28 +- booth/templates/doc.html | 9 +- booth/templates/view.html | 36 +- booth/thumbs.py | 67 +++ docs/contracts/as_antislop.contract.md | 197 +++++++- tests/mutations/antislop.toml | 368 ++++++++++++++- tests/mutations/r2b.toml | 10 +- tests/mutations/r2c.toml | 6 +- tests/mutations/r3.toml | 26 +- tests/test_antislop.py | 170 +++++++ tests/test_antislop_s5c_browser.py | 610 +++++++++++++++++++++++++ 15 files changed, 1730 insertions(+), 136 deletions(-) create mode 100644 tests/test_antislop_s5c_browser.py diff --git a/booth/app.py b/booth/app.py index 8f37bea..cff67be 100644 --- a/booth/app.py +++ b/booth/app.py @@ -103,6 +103,7 @@ from booth.items import ( # noqa: E402,F401 read_blurred, render_doc, render_doc_body, + image_dims, ) # The per-item blur writer lives with its reader in booth/blur.py, stdlib-only so # `scripts/booth blur` shares both. Re-exported: tests import it from here. @@ -916,6 +917,9 @@ def build_gallery(child: Path) -> list[dict]: # SILENT fallback to the full image — the adapter is exactly # where a new Item field gets dropped without anything failing. "thumb": it.thumb, + # as S5c (G14): the tile's picture size, a separate step like + # the doc body below, so the Desk's `booth_items` never pays it. + "dims": image_dims(child, it), # R2 C1. Same rule: the resolver numbers, this carries. "ordinal": it.ordinal, "caption": it.caption, diff --git a/booth/items.py b/booth/items.py index 5c11fe6..dcba229 100644 --- a/booth/items.py +++ b/booth/items.py @@ -33,7 +33,7 @@ except ImportError: # pragma: no cover from booth.asks import is_answer_file, is_ask_file from booth.blur import BLUR_FILE, read_blurred # noqa: F401 (re-exported) from booth.links import is_safe_href -from booth.thumbs import wants_thumb +from booth.thumbs import drawn_size, wants_thumb # Browser-playable media buckets. Anything else renders as a download link. IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg", ".bmp"} @@ -500,6 +500,17 @@ def find_item(items: Sequence[Item], rel: str) -> Item | None: return None +def image_dims(booth: Path, item: Item) -> tuple[int, int] | None: + """(width, height) of an image item as the browser draws it, or None. + + A SEPARATE STEP, like `render_doc_body` below and for its reason: the Desk + calls `booth_items` for every booth, and reading a header per picture there + would be the cost of a fact only the gallery's tiles use (as S5c, G14).""" + if item.kind != "image": + return None + return drawn_size(booth / item.rel) + + def render_doc_body(booth: Path, item: Item) -> tuple[str, bool] | None: """(body, is_html) for a doc item under DOC_MAX_BYTES, else None. diff --git a/booth/templates/base.html b/booth/templates/base.html index df86ea0..8407bb5 100644 --- a/booth/templates/base.html +++ b/booth/templates/base.html @@ -76,6 +76,38 @@ }, true); })(); + {% block title %}The Booth{% endblock %} {# The two SVOS voices. display=swap and the system stacks in --font-sans / @@ -577,7 +609,7 @@ font:600 11px/1.2 var(--font-mono);background:oklch(0.17 0.01 250 / .82);color:oklch(0.91 0.008 216); pointer-events:none} .item.blurred > .ord{top:44px} - .doc-bar .ord{font:600 11px/1 var(--font-mono);color:var(--text-muted)} + .doc-sum .ord{font:600 11px/1 var(--font-mono);color:var(--text-muted)} /* the inline group header: spans the grid, is never a tile */ .grp-head{grid-column:1 / -1;display:flex;align-items:center;gap:8px;margin:8px 0 -4px; font-family:var(--font-mono);font-size:var(--size-micro);letter-spacing:var(--tracking-caps);text-transform:uppercase} @@ -631,8 +663,16 @@ untouched: this sizes boxes, it moves none. */ .gallery{display:grid;gap:var(--space-5);grid-template-columns:repeat(auto-fill,minmax(320px,1fr));align-items:start} .item{position:relative;margin:0;background:var(--surface-card);border:1px solid var(--border-default); - border-radius:var(--radius-xl);overflow:hidden;display:flex;flex-direction:column;box-shadow:var(--shadow-sm); - scroll-margin-top:calc(var(--rail-h, 120px) + 12px)} + border-radius:var(--radius-xl);overflow:hidden;display:flex;flex-direction:column;box-shadow:var(--shadow-sm)} + /* as S5c (G15): the PAGE keeps the rail's height clear, not the tile, so a + control reached by Tab stops below the rail too, as a jump and the cursor + do. It replaces .item's scroll-margin-top: the two add, and a tile would + land two rails down. Only a page with a rail. */ + html:has(.rail){--rail-pad:calc(var(--rail-h, 120px) + 12px);scroll-padding-top:var(--rail-pad)} + /* ...and a control IN the stuck rail is not under it: its own negative + margin cancels the padding, or every Tab between group links scrolled the + page up by the rail (357px measured; heid bug-hunt R9). */ + .rail :is(a,button,input,summary){scroll-margin-top:calc(-1 * var(--rail-pad, 0px))} .item img,.item video{width:100%;height:auto;display:block;background:var(--surface-sunken)} .item audio{width:100%;margin:20px 14px 6px;max-width:calc(100% - 28px)} .item .dl{padding:22px 14px;font-family:var(--font-mono);font-size:var(--size-sm);word-break:break-all} @@ -706,6 +746,9 @@ .mark-opt:has(input:checked)::after{inset:3px;--rl:10px;--rt:1.5px;filter:none} .film-f.is-current::after,.film-f.is-active::after{inset:2px;--rl:9px;--rt:1.5px} .item.is-cursor{border-color:color-mix(in oklab,var(--accent) 60%,transparent)} + /* as S5c (G6): the cursor is real focus, and the reticle and the border + above already mark it, so the focused cursor tile draws no second ring */ + .item.is-cursor:focus-visible{outline:none} /* ⚠ Blur is COSMETIC. The file is still served at its own URL and still in the zip. This hides an item from a glance, nothing more — and nothing here @@ -745,7 +788,9 @@ background:none;color:var(--text-muted);cursor:pointer;-webkit-backdrop-filter:none;backdrop-filter:none} .blur-all button:hover,.reveal-all-btn:hover{color:var(--text-body);border-color:var(--border-strong)} .blur-all.is-on button{color:var(--text-body);border-color:var(--border-strong)} - .reveal-all-btn[aria-pressed="true"]{color:var(--text-body);border-color:var(--border-strong)} + /* as S5c (G17): its "on" look reads the one class on that IS the + state; the words say it to a screen reader, so no aria-pressed */ + .reveal-all .reveal-all-btn{color:var(--text-body);border-color:var(--border-strong)} /* One line each, always: a top bar at phone width squeezed these into four-line stacks. Below 600px the reveal drops its "— blur is cosmetic" tail; the title still says it, as does every per-item reveal. */ @@ -765,26 +810,38 @@ full grid width so prose has a readable measure. */ .item-doc{grid-column:1 / -1} .item-doc.is-closed{display:none} + /* as S5c (G7): the bar is a row holding the fold's summary (its label only) + and the tools beside it, outside the summary, which a screen reader reads + as one button. The fold holds only its summary, so a closed fold keeps the + tools; closing it hides the body and notes by CSS, scripts on or off. */ + /* The tools' padding sets the bar's height, as the old summary's did around + them; the fold stretches to it and its summary fills it, so the whole bar + left of the tools is still the hit area. */ + .doc-bar{display:flex;align-items:stretch;font-family:var(--font-mono);font-size:var(--size-caption); + color:var(--text-muted);background:var(--surface-raised);border-bottom:1px solid var(--border-subtle)} + .doc-fold{flex:1;min-width:0;display:flex} + .doc-sum{flex:1;min-width:0;list-style:none;cursor:pointer;display:flex;align-items:center;gap:8px; + padding:9px 14px;user-select:none} + .doc-sum::-webkit-details-marker{display:none} + .doc-tools{display:flex;align-items:center;gap:8px;padding:9px 14px 9px 0} .doc-inline{display:block} - .doc-inline > .doc-bar{list-style:none;cursor:pointer;display:flex;align-items:center;gap:8px; - padding:9px 14px;font-family:var(--font-mono);font-size:var(--size-caption);color:var(--text-muted); - background:var(--surface-raised);border-bottom:1px solid var(--border-subtle);user-select:none} - .doc-inline > .doc-bar::-webkit-details-marker{display:none} + .item-doc:has(.doc-fold:not([open])) > .doc-inline{display:none} .doc-chevron{color:var(--text-faint);transition:transform var(--dur-1) var(--ease-out);font-size:10px} - .doc-inline[open] > .doc-bar .doc-chevron{transform:rotate(90deg)} + .doc-fold[open] .doc-chevron{transform:rotate(90deg)} .doc-name{color:var(--text-heading);word-break:break-all} /* as S3: at phone width the name takes the bar's full width and breaks only where it must; the actions wrap under it (squeezed, it was one word wide) */ - @media (max-width:600px){.doc-inline > .doc-bar{flex-wrap:wrap;row-gap:4px} - .doc-name{flex:1 1 calc(100% - 3rem);word-break:normal;overflow-wrap:anywhere} - .doc-spacer{display:none}} - .doc-spacer{flex:1} + @media (max-width:600px){.doc-bar{flex-wrap:wrap;row-gap:4px} + .doc-fold{flex:1 1 100%} + .doc-sum{padding-bottom:0} + .doc-tools{padding:0 14px 9px} + .doc-name{flex:1 1 calc(100% - 3rem);word-break:normal;overflow-wrap:anywhere}} .doc-act{color:var(--text-muted);text-decoration:none;padding:2px 6px;border-radius:var(--radius-sm); font-size:13px;line-height:1;background:none;border:0;cursor:pointer;font-family:inherit} .doc-act:hover{color:var(--text-heading);background:var(--surface-sunken);text-decoration:none} .doc-close:hover{color:var(--text-heading)} - .doc-bar .blurtoggle,.doc-bar .flagtoggle{padding:0;background:none} - .doc-bar .blurtoggle{margin-left:4px} + .doc-tools .blurtoggle,.doc-tools .flagtoggle{padding:0;background:none} + .doc-tools .blurtoggle{margin-left:4px} .doc-body{margin:0;border:0;border-radius:0;max-height:32rem;overflow:auto;padding:18px 20px} .doc-body.textview{background:var(--surface-card);border:0;border-radius:0} @@ -1111,7 +1168,7 @@ leaves the tile visible; undershooting hides it. JS on, --rail-h is exact and this changes nothing. */ @media (max-width:480px){ - .item{scroll-margin-top:calc(var(--rail-h, 205px) + 12px)} + html:has(.rail){--rail-pad:calc(var(--rail-h, 205px) + 12px)} } @media (max-width:600px){ main{padding:20px 16px 40px} @@ -1197,18 +1254,36 @@ the line goes back to that one's "Saving…". Otherwise "Saved.", the one message that clears itself, and only if the line still holds that write. */ var CHANGED = 'Saved. You changed it while it was saving, and that change is not saved yet: press again to save it.'; + /* as S5c: every failure that stays, by form, with the words said when it + failed (a one-form failure's STAY, or a batch's refusal), so a later + save says them again instead of burying them under "Saved.". */ var unsent = {}; + function stayed(keys) { + var words = []; + keys.forEach(function (k) { if (words.indexOf(unsent[k]) < 0) words.push(unsent[k]); }); + return words.join(' '); + } + /* as S5c (heid bug-hunt R3): a warning says the failures still standing + first, except those of the forms it is about, so a later failure never + buries an earlier one. (`liveForms` is below.) */ + function also(text, keys) { + var others = Object.keys(unsent).filter(function (k) { + return keys.indexOf(k) < 0 && liveForms(k).length; + }); + var before = stayed(others); + return before ? before + ' ' + text : text; + } function liveForms(key) { return [].filter.call(document.querySelectorAll('form[data-inplace]'), function (f) { return flightKey(f) === key; }); } function landed(keys) { keys.forEach(function (k) { delete unsent[k]; }); - if (keys.some(function (k) { return liveForms(k).some(dirty); })) { warn(CHANGED); return; } + if (keys.some(function (k) { return liveForms(k).some(dirty); })) { warn(also(CHANGED, keys)); return; } var others = Object.keys(pending).some(function (k) { return keys.indexOf(k) < 0; }); if (others) { say('Saving…'); return; } var failed = Object.keys(unsent).filter(function (k) { return liveForms(k).length; }); - if (failed.length) { warn(STAY); return; } + if (failed.length) { warn(stayed(failed)); return; } var n = say('Saved.'); setTimeout(function () { if (said === n) say(''); }, 2000); } @@ -1478,14 +1553,14 @@ var STAY = 'Could not save in place. Reload to see what was saved; your other entries are still here.'; var beat = null; function fail(form, key, snap) { - unsent[key] = true; - if (!mayReload(key, snap)) { warn(STAY); return; } + unsent[key] = STAY; + if (!mayReload(key, snap)) { warn(also(STAY, [key])); return; } /* Said, then reloaded after a beat, so the words are readable rather than a flash before the page goes; asked again at the beat, since a draft typed in the meantime must not go with it. A press inside the beat cancels it: that new save owns the page (heid bug-hunt R2). */ warn('Could not save in place — reloading to show what was saved.'); - beat = setTimeout(function () { beat = null; if (mayReload(key, snap)) reload(); else warn(STAY); }, 900); + beat = setTimeout(function () { beat = null; if (mayReload(key, snap)) reload(); else warn(also(STAY, [key])); }, 900); } /* The POST and the page GET fail differently. A POST that failed did not save. A POST that answered 204 DID save, even if the GET then failed: @@ -1497,10 +1572,10 @@ return refresh([{key: key, snap: snap}]).then(function (placed) { if (placed) { landed([key]); return; } if (mayReload(key, snap)) { reload(); return; } - warn('Saved. The page changed meanwhile; reload to see it.'); + warn(also('Saved. The page changed meanwhile; reload to see it.', [key])); }, function () { if (mayReload(key, snap)) reload(); - else warn('Saved. Could not refresh the page; reload to see it.'); + else warn(also('Saved. Could not refresh the page; reload to see it.', [key])); }); }, function () { fail(form, key, snap); }); } @@ -1573,14 +1648,23 @@ ? 'Saved ' + saved.length + ' of ' + forms.length + '. Not saved: ' + refused.join('; ') + '. Nothing else you entered was cleared.' : ''; + /* as S5c (booth-dev's note): a refused form stays unsent with the + refusal's words, like a one-form failure, so an unrelated save's + "Saved." cannot replace them while it is still on the page. The + refused forms are the ones the server did not take. */ + forms.forEach(function (f) { + var k = flightKey(f); + if (!saved.some(function (x) { return x.key === k; })) unsent[k] = told; + }); var shown = saved.length ? refresh(saved, true) : Promise.resolve(true); + var mine = forms.map(flightKey); return shown.then(function (placed) { - if (!placed) warn((told || 'Saved.') + ' The page changed meanwhile; reload to see it.'); - else if (told) warn(told); - else landed(forms.map(flightKey)); + if (!placed) warn(also((told || 'Saved.') + ' The page changed meanwhile; reload to see it.', mine)); + else if (told) warn(also(told, mine)); + else landed(mine); }, function () { - warn((told || 'Saved ' + saved.length + ' of ' + forms.length + '.') + - ' Could not refresh the page; reload to see what was saved.'); + warn(also((told || 'Saved ' + saved.length + ' of ' + forms.length + '.') + + ' Could not refresh the page; reload to see what was saved.', mine)); }); }); } @@ -1620,18 +1704,31 @@ })(); @@ -591,11 +666,11 @@ to the node it replaced goes with that node. `__bound` keeps a node from being bound twice. - Inline-doc ✕ closes (hides) a rendered doc for the session. The button sits - inside , so without this its click would just toggle the
- open/closed — stopPropagation + preventDefault make ✕ mean "close", not - "collapse". Collapse stays available via the rest of the summary bar. With - JS off the button is inert and collapse via
still works. + Inline-doc ✕ closes (hides) a rendered doc for the session. It sits in the + bar's tools, beside the fold's summary (as S5c, G7), so its click toggles + nothing; stopPropagation + preventDefault are kept as belt and braces. + Collapse is the summary's. With JS off the button is inert and collapse via +
still works. Blur reveal. WARNING: this handler previously sat after the content block's closing tag, which in a child template Jinja DISCARDS — the button rendered @@ -604,10 +679,6 @@ block. Per-viewer and never persisted: a reload re-hides. */ function bindTiles() { function once(el) { if (el.__bound) return false; el.__bound = true; return true; } - /* A form inside would otherwise collapse the doc on submit. */ - document.querySelectorAll('.doc-bar .blurtoggle, .doc-bar .flagtoggle').forEach(function (f) { - if (once(f)) f.addEventListener('click', function (ev) { ev.stopPropagation(); }); - }); document.querySelectorAll('.doc-close').forEach(function (btn) { if (!once(btn)) return; btn.addEventListener('click', function (ev) { @@ -617,16 +688,21 @@ if (item) item.classList.add('is-closed'); }); }); + /* as S5c (G17): the glyph and the word flip in their own spans, so the + name stays the words on screen and the item's .sr-only name stays put */ + function said(btn, on) { + btn.querySelector('.rv-glyph').textContent = on ? '🙈' : '👁'; + btn.querySelector('.rv-word').textContent = on ? 'hide' : 'reveal'; + } document.querySelectorAll('.item.blurred .reveal').forEach(function (btn) { var fig = btn.closest('.item'); - /* a swap carries `revealed` across (base.html); the label follows it */ - btn.textContent = fig.classList.contains('revealed') ? '🙈 hide' : '👁 reveal'; + /* a swap carries `revealed` across (base.html); the words follow it */ + said(btn, fig.classList.contains('revealed')); if (!once(btn)) return; btn.addEventListener('click', function (ev) { ev.preventDefault(); ev.stopPropagation(); - var on = fig.classList.toggle('revealed'); - btn.textContent = on ? '🙈 hide' : '👁 reveal'; + said(btn, fig.classList.toggle('revealed')); }); }); } diff --git a/booth/templates/compare.html b/booth/templates/compare.html index c6f975b..8078b37 100644 --- a/booth/templates/compare.html +++ b/booth/templates/compare.html @@ -31,7 +31,7 @@ {% endif %} - {% if film | selectattr('blurred') | list %}{% endif %} + {% if film | selectattr('blurred') | list %}{% endif %}
@@ -52,7 +52,9 @@
{# Over the stage, never inside its scrolled content; JS-only, so `hidden` until bound (the review's pattern). #} - {% if s.blurred %}{% endif %} + {# as S5c (G17): the name is the words on it, the side's letter as + .sr-only text ("reveal A — blur is cosmetic", then "hide A") #} + {% if s.blurred %}{% endif %}
@@ -216,7 +218,9 @@ btn.hidden = false; btn.addEventListener('click', function () { var on = stage.classList.toggle('revealed'); - btn.textContent = on ? '🙈 hide' : '👁 reveal — blur is cosmetic'; + btn.querySelector('.rv-glyph').textContent = on ? '🙈' : '👁'; + btn.querySelector('.rv-word').textContent = on ? 'hide' : 'reveal'; + btn.querySelector('.rv-note').hidden = on; }); }); @@ -235,29 +239,25 @@ }); document.addEventListener('booth:swapped', restate); - /* THE KEYS (C3). EVERY key here is ignored while focus is in something - editable and whenever Ctrl, Meta or Alt is held (the review's rule, - applied to all of them). */ - function isEditable(el) { - return !!(el && (el.isContentEditable || - /^(input|textarea|select)$/i.test(el.tagName || ''))); - } + /* THE KEYS (C3). EVERY key here is left to the focused element when it + uses it, and to the browser whenever Ctrl, Meta or Alt is held: the + review's rule, now one rule for both (as S5c, G6: base.html's + BoothKeys). A focused player keeps every key; a focused 1:1 stage pans. */ function step(dir) { var which = (linked ? 'both' : active) + (dir < 0 ? '-prev' : '-next'); var a = document.querySelector('a[data-step="' + which + '"]'); if (a) go(withState(a.getAttribute('href'))); } document.addEventListener('keydown', function (e) { - if (isEditable(e.target)) return; - if (e.metaKey || e.ctrlKey || e.altKey) return; + if (BoothKeys.theirs(e)) return; var k = e.key; if (k === 'Escape' || k === 'c' || k === 'C') go(BACK); else if (k === 'ArrowLeft') step(-1); else if (k === 'ArrowRight') step(1); /* Space steps only from nowhere in particular: never from a focused - control (Space presses it) and never from a player on either stage. */ + control (Space presses it) and never from a player on either stage; + BoothKeys has already left it to them. */ else if (k === ' ') { - if (e.target.closest && e.target.closest('button, a, summary, video, audio')) return; e.preventDefault(); step(e.shiftKey ? -1 : 1); } diff --git a/booth/templates/doc.html b/booth/templates/doc.html index f7374c9..8605fb8 100644 --- a/booth/templates/doc.html +++ b/booth/templates/doc.html @@ -9,7 +9,7 @@ {# Reveal all can lift this page's blur, so this page must be able to put it back (r2b, heid bug-hunt). #} - {% if blurred %}{% endif %} + {% if blurred %}{% endif %} ⬇
{# Same record, same reason as the image viewer: the sidecar that says what @@ -24,7 +24,8 @@ doc's own page rendered clear. Its reveal is per-page and JS-only, like the review stage's; Reveal all lifts it by the same class. #}
- {% if blurred %}{% endif %} + {# as S5c (G17): the name is the words on it, the glyph hidden #} + {% if blurred %}{% endif %} {% if is_html %}
{{ body|safe }}
{% else %} @@ -64,7 +65,9 @@ rv.hidden = false; rv.addEventListener('click', function () { var on = document.getElementById('docbody').classList.toggle('revealed'); - rv.textContent = on ? '🙈 hide' : '👁 reveal — blur is cosmetic'; + rv.querySelector('.rv-glyph').textContent = on ? '🙈' : '👁'; + rv.querySelector('.rv-word').textContent = on ? 'hide' : 'reveal'; + rv.querySelector('.rv-note').hidden = on; }); } document.addEventListener('keydown', function (e) { diff --git a/booth/templates/view.html b/booth/templates/view.html index cd5043e..d21410b 100644 --- a/booth/templates/view.html +++ b/booth/templates/view.html @@ -32,7 +32,7 @@ - {% if film | selectattr('blurred') | list %}{% endif %} + {% if film | selectattr('blurred') | list %}{% endif %} ⬇
@@ -61,7 +61,10 @@ outside the stage it can never start a pan. #} {# JS-only, so `hidden` until the script binds it (heid bug-hunt: shown with scripts off, it did nothing) — the toggle's own pattern. #} - {% if blurred %}{% endif %} + {# as S5c (G17): the name is the words on it; the glyph is hidden and the + file rides as .sr-only text ("reveal a.png — blur is cosmetic", then + "hide a.png"). The script flips the spans, never the whole text. #} + {% if blurred %}{% endif %} {% if next_url %}›{% endif %}
+
''' +new = '''
+
''' + +[[mutation]] +label = "S5c the doc's tools wrap under the name on a wide screen" +file = "booth/templates/base.html" +test = "tests/test_antislop_s5c_browser.py::test_the_doc_bar_is_one_row_wide_and_two_on_a_phone" +old = ''' .doc-bar{display:flex;align-items:stretch;''' +new = ''' .doc-bar{display:block;align-items:stretch;''' + +[[mutation]] +label = "S5c a closed fold opens again on the next save" +file = "booth/templates/base.html" +test = "tests/test_antislop_s5c_browser.py::test_a_closed_doc_stays_closed_through_a_save" +old = ''' if (freshDetails[k]) freshDetails[k].open = d.open;''' +new = ''' if (false) freshDetails[k].open = d.open;''' + +[[mutation]] +label = "S5c tile pictures carry no size" +file = "booth/templates/booth.html" +test = "tests/test_antislop.py::test_tile_images_carry_their_drawn_size" +old = '''{% if it.dims %} width="{{ it.dims[0] }}" height="{{ it.dims[1] }}"{% endif %}''' +new = '''''' + +[[mutation]] +label = "S5c tile pictures carry no size, so a link lands off its tile" +file = "booth/templates/booth.html" +test = "tests/test_antislop_s5c_browser.py::test_a_link_to_a_tile_lands_where_it_points" +old = '''{% if it.dims %} width="{{ it.dims[0] }}" height="{{ it.dims[1] }}"{% endif %}''' +new = '''''' + +[[mutation]] +label = "S5c the gallery drops the size on its way to the template" +file = "booth/app.py" +test = "tests/test_antislop.py::test_tile_images_carry_their_drawn_size" +old = ''' "dims": image_dims(child, it),''' +new = ''' "dims": None,''' + +[[mutation]] +label = "S5c the drawn size ignores the EXIF orientation" +file = "booth/thumbs.py" +test = "tests/test_antislop.py::test_tile_images_carry_their_drawn_size" +old = ''' return (h, w) if orientation in (5, 6, 7, 8) else (w, h)''' +new = ''' return (w, h)''' + +[[mutation]] +label = "S5c reading a size decodes the picture (PNG getexif)" +file = "booth/thumbs.py" +test = "tests/test_antislop.py::test_reading_a_size_decodes_nothing" +old = ''' orientation = (im.getexif().get(0x0112, 1) if "exif" in im.info else 1)''' +new = ''' orientation = (im.getexif().get(0x0112, 1))''' + +[[mutation]] +label = "S5c a planted link is read through" +file = "booth/thumbs.py" +test = "tests/test_antislop.py::test_a_planted_fifo_or_link_costs_its_size_and_never_hangs" +old = ''' fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)''' +new = ''' fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)''' + +[[mutation]] +label = "S5c a planted FIFO hangs the render" +file = "booth/thumbs.py" +test = "tests/test_antislop.py::test_a_planted_fifo_or_link_costs_its_size_and_never_hangs" +old = ''' fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)''' +new = ''' fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)''' + +[[mutation]] +label = "S5c every render reads every header again" +file = "booth/thumbs.py" +test = "tests/test_antislop.py::test_a_size_is_read_once_per_version_of_the_file" +old = '''@functools.lru_cache(maxsize=SIZE_CACHE) +''' +new = '''''' + +[[mutation]] +label = "S5c a replaced file keeps its old size" +file = "booth/thumbs.py" +test = "tests/test_antislop.py::test_a_size_is_read_once_per_version_of_the_file" +old = ''' return _read_size(str(path), st.st_dev, st.st_ino, st.st_size, st.st_mtime_ns, st.st_ctime_ns)''' +new = ''' return _read_size(str(path), st.st_dev, st.st_ino, 0, 0, 0)''' + +[[mutation]] +label = "S5c no scroll padding: a control reached by Tab hides under the rail" +file = "booth/templates/base.html" +test = "tests/test_antislop_s5c_browser.py::test_a_tabbed_control_does_not_hide_under_the_rail" +old = ''' html:has(.rail){--rail-pad:calc(var(--rail-h, 120px) + 12px);scroll-padding-top:var(--rail-pad)}''' +new = ''' html:has(.rail){--rail-pad:calc(var(--rail-h, 120px) + 12px)}''' + +[[mutation]] +label = "S5c the tile keeps its scroll margin too, and a jump lands two rails down" +file = "booth/templates/base.html" +test = "tests/test_antislop_s5c_browser.py::test_a_jump_lands_just_below_the_rail" +old = ''' border-radius:var(--radius-xl);overflow:hidden;display:flex;flex-direction:column;box-shadow:var(--shadow-sm)}''' +new = ''' border-radius:var(--radius-xl);overflow:hidden;display:flex;flex-direction:column;box-shadow:var(--shadow-sm); + scroll-margin-top:calc(var(--rail-h, 120px) + 12px)}''' + +[[mutation]] +label = "S5c with scripts off, a phone falls back to the desktop rail height" +file = "booth/templates/base.html" +test = "tests/test_antislop_s5c_browser.py::test_the_rail_padding_falls_back_with_scripts_off" +old = ''' html:has(.rail){--rail-pad:calc(var(--rail-h, 205px) + 12px)}''' +new = ''' html:has(.rail){--rail-pad:calc(var(--rail-h, 120px) + 12px)}''' + +[[mutation]] +label = "S5c every page gets the rail's scroll padding" +file = "booth/templates/base.html" +test = "tests/test_antislop_s5c_browser.py::test_pages_without_a_rail_have_no_scroll_padding" +old = ''' html:has(.rail){--rail-pad:calc(var(--rail-h, 120px) + 12px);scroll-padding-top:var(--rail-pad)}''' +new = ''' html{--rail-pad:calc(var(--rail-h, 120px) + 12px);scroll-padding-top:var(--rail-pad)}''' + +[[mutation]] +label = "S5c a tile's reveal carries a fixed label again" +file = "booth/templates/booth.html" +test = "tests/test_antislop.py::test_reveal_glyphs_are_hidden_from_the_name" +old = '''
', tile, re.S) + assert tools, "no doc toolbar" + t = tools.group(1) + assert 'href="view?f=notes.md"' in t and 'download' in t, t + assert 'class="blurtoggle' in t and 'class="flagtoggle' in t and "doc-close" in t, t + + +def _image(path, size, exif_orientation=None, fmt="PNG"): + from PIL import Image + im = Image.new("RGB", size, (90, 120, 160)) + if exif_orientation is None: + im.save(path, fmt) + else: + exif = Image.Exif() + exif[0x0112] = exif_orientation + im.save(path, fmt, exif=exif) + + +def _img_size(tile): + img = re.search(r"]*>", tile).group(0) + w, h = re.search(r'\bwidth="(\d+)"', img), re.search(r'\bheight="(\d+)"', img) + return (int(w.group(1)), int(h.group(1))) if w and h else None + + +def test_tile_images_carry_their_drawn_size(client): + """G14: the picture's size as the browser draws it, so a lazy tile reserves + its box before it loads. Orientations 5-8 swap the numbers. Anything whose + header cannot be read safely gets no size, which is today's markup.""" + pytest.importorskip("PIL.Image") + c, data = client + b = data / "g" + b.mkdir() + _image(b / "wide.png", (300, 200)) + _image(b / "turned.jpg", (300, 200), exif_orientation=6, fmt="JPEG") + (b / "broken.png").write_bytes(b"\x89PNG\r\n\x1a\n") + (b / "pic.svg").write_text('') + (b / "link.png").symlink_to(b / "wide.png") + page = c.get("/b/g/").text + got = {r: _img_size(_figure(page, r)) for r in ("wide.png", "turned.jpg", "broken.png", "pic.svg", "link.png")} + assert got == {"wide.png": (300, 200), "turned.jpg": (200, 300), "broken.png": None, + "pic.svg": None, "link.png": None}, got + + +def test_reading_a_size_decodes_nothing(tmp_path, monkeypatch): + """G14: the header only. Pillow's PNG getexif() decodes the whole picture to + look for a late eXIf chunk; a gallery render must not.""" + Image = pytest.importorskip("PIL.Image") + from PIL import ImageFile + from booth import thumbs + _image(tmp_path / "a.png", (300, 200)) + _image(tmp_path / "b.jpg", (300, 200), exif_orientation=6, fmt="JPEG") + + def boom(self, *a, **k): + raise AssertionError("decoded") + monkeypatch.setattr(Image.Image, "load", boom) + monkeypatch.setattr(ImageFile.ImageFile, "load", boom) + thumbs._read_size.cache_clear() + assert thumbs.drawn_size(tmp_path / "a.png") == (300, 200) + assert thumbs.drawn_size(tmp_path / "b.jpg") == (200, 300) + + +_REVEAL = re.compile(r']*(?:class="[^"]*\breveal(?:-all-btn)?\b|data-reveal-all))([^>]*)>(.*?)', re.S) + + +def test_reveal_glyphs_are_hidden_from_the_name(client): + """G17: a reveal control's name is the words on it. No aria-label to + contradict the words after a flip, no aria-pressed on a control whose words + already say its state, and no glyph read aloud.""" + from booth.app import set_blurred + c, data = client + b = _s5_booth(data) + for rel in ("a.png", "c.png", "notes.md"): + set_blurred(b, rel, True) + seen = 0 + for url in ("/b/b/", "/b/b/view?f=a.png", "/b/b/compare?a=a.png&b=c.png", "/b/b/view?f=notes.md"): + for attrs, inner in _REVEAL.findall(_markup(c.get(url).text)): + seen += 1 + assert "aria-label" not in attrs and "aria-pressed" not in attrs, (url, attrs) + bare = re.sub(r']*aria-hidden="true"[^>]*>.*?', "", inner, flags=re.S) + assert not re.search("[\U0001F300-\U0001FAFF]", bare), (url, inner) + assert seen >= 7, seen # tile x3 (two images, the doc), review x2, compare x3, doc page x2 + + +def test_a_planted_fifo_or_link_costs_its_size_and_never_hangs(tmp_path): + """G14: opened O_NONBLOCK, a FIFO with no writer reads as empty instead of + holding the render forever; opened O_NOFOLLOW, a link is refused.""" + import threading + pytest.importorskip("PIL.Image") + from booth.thumbs import drawn_size + _image(tmp_path / "a.png", (300, 200)) + os.mkfifo(tmp_path / "pipe.png") + (tmp_path / "link.png").symlink_to(tmp_path / "a.png") + got = {} + t = threading.Thread(target=lambda: got.update(pipe=drawn_size(tmp_path / "pipe.png")), daemon=True) + t.start() + t.join(3) + assert not t.is_alive() and got == {"pipe": None}, got + assert drawn_size(tmp_path / "link.png") is None + assert drawn_size(tmp_path / "a.png") == (300, 200) # the positive control + + +def test_a_size_is_read_once_per_version_of_the_file(tmp_path, monkeypatch): + """G14: cached by the file's identity, so a gallery render reads each + header once while it is unchanged, and a replaced file is read again.""" + Image = pytest.importorskip("PIL.Image") + from booth import thumbs + _image(tmp_path / "a.png", (300, 200)) + thumbs._read_size.cache_clear() + opened = [] + real = Image.open + monkeypatch.setattr(thumbs._Image, "open", lambda *a, **k: (opened.append(1), real(*a, **k))[1]) + first = [thumbs.drawn_size(tmp_path / "a.png") for _ in range(3)] + _image(tmp_path / "a.png", (120, 480)) + os.utime(tmp_path / "a.png", ns=(1, 1)) + again = thumbs.drawn_size(tmp_path / "a.png") + assert first == [(300, 200)] * 3 and again == (120, 480), (first, again) + assert len(opened) == 2, opened + + +def test_a_file_replaced_in_place_keeps_no_stale_size(tmp_path): + """HRÖSKVA R7: `cp -p` over a file keeps its inode and restores its mtime, + and a same-length replacement kept its size too, so the cache served the + old picture's size. The change time moves on every write.""" + pytest.importorskip("PIL.Image") + from PIL import PngImagePlugin + from booth import thumbs + + def png(size, pad): + info = PngImagePlugin.PngInfo() + info.add_text("pad", "x" * pad) + from PIL import Image + import io + buf = io.BytesIO() + Image.new("RGB", size, (90, 120, 160)).save(buf, "PNG", pnginfo=info) + return buf.getvalue() + a, b = png((300, 200), 0), png((120, 480), 0) + a, b = (png((300, 200), max(0, len(b) - len(a))), png((120, 480), max(0, len(a) - len(b)))) + assert len(a) == len(b), (len(a), len(b)) + f = tmp_path / "a.png" + f.write_bytes(a) + st = os.stat(f) + thumbs._read_size.cache_clear() + first = thumbs.drawn_size(f) + time.sleep(0.01) + with open(f, "r+b") as fh: # in place: same inode, same length + fh.write(b) + os.utime(f, ns=(st.st_atime_ns, st.st_mtime_ns)) + assert os.stat(f).st_ino == st.st_ino and os.stat(f).st_size == st.st_size + assert (first, thumbs.drawn_size(f)) == ((300, 200), (120, 480)) diff --git a/tests/test_antislop_s5c_browser.py b/tests/test_antislop_s5c_browser.py new file mode 100644 index 0000000..ccf957f --- /dev/null +++ b/tests/test_antislop_s5c_browser.py @@ -0,0 +1,610 @@ +"""S5c of the anti-slop fix slices (docs/contracts/as_antislop.contract.md): +whose key it is, the doc bar, tile sizes, the rail's shadow, reveal names, and +a refused batch that an unrelated save must not bury. + +Measured in the same real uvicorn + Chromium harness as test_flow_browser. +Skips, never fails, when no usable Chromium exists. +""" + +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from test_flow_browser import PNG, _picks, _png, _set, browser, live # noqa: E402,F401 (fixtures) +from test_antislop_s5b_browser import COUNT, WIDE, _said, _wait_said, _wait_swaps # noqa: E402 + +PHONE = {"width": 390, "height": 844} + +_RECT = "e => { const b = e.getBoundingClientRect(); return {l: b.left, r: b.right, t: b.top, b: b.bottom}; }" + + +def _page(browser, base, path, viewport=WIDE, js=True): + ctx = browser.new_context(viewport=viewport, java_script_enabled=js) + page = ctx.new_page() + if js: + page.add_init_script(COUNT) + page.goto(f"{base}{path}", wait_until="networkidle") + return page + + +def _active(page): + return page.evaluate("""() => { const a = document.activeElement; + return a === document.body ? 'body' : (a.getAttribute('data-item') || a.id || a.tagName); }""") + + +def _posts(page): + seen = [] + page.on("request", lambda r: seen.append(r.url) if r.method == "POST" else None) + return seen + + +# ---- booth-dev's note: a refused batch is not buried by an unrelated save ------------ + +def test_a_batch_refusal_outlives_an_unrelated_save(browser, live): + """a2's POST is dropped, so the batch says "Not saved: a2" and a2 stays unsent + on the page. An unrelated flag then lands. Its "Saved." must not replace the + refusal's words, which S5b already guarantees for a one-form failure.""" + base, root = live + b = _set(root, 2) + _picks(b, ("a1", "a2")) + page = _page(browser, base, "/b/g/") + page.route("**/b/g/answer", lambda route: route.abort() + if "ask=a2" in (route.request.post_data or "") else route.continue_()) + page.check('.verdict #mark-a1 input[type=radio][value="yes"]') + page.check('.verdict #mark-a2 input[type=radio][value="no"]') + page.locator(".verdict #mark-a1 .mark-submit").click() + _wait_said(page, "Not saved") + page.wait_for_function( + "document.querySelectorAll('.verdict .mark-pick.is-answered').length === 1", timeout=10000) + swaps = page.evaluate("window.__swaps || 0") + page.locator('figure.item[data-item="02.png"] .flagtoggle button').click() + page.wait_for_selector('figure.item.is-flagged[data-item="02.png"]', timeout=10000) + _wait_swaps(page, swaps + 1) + page.wait_for_timeout(2500) # past the 2s beat that clears a "Saved." + said = _said(page) + kept = page.is_checked('.verdict #mark-a2 input[type=radio][value="no"]') + page.close() + assert kept, "the refused pick's choice was lost" + assert said["tone"] == "warn" and "Not saved: a2" in said["text"], said + + +def test_two_standing_failures_are_both_said_once_each(browser, live): + """Each failure that stays is said again after an unrelated save, each + distinct message once, in the order they failed: a note that could not be + saved, then a batch with two refusals.""" + base, root = live + b = _set(root, 3) + _picks(b, ("a1", "a2", "a3")) + page = _page(browser, base, "/b/g/") + fail = {"note": True} + page.route("**/b/g/note", lambda route: route.fulfill(status=500, body="") + if fail["note"] else route.continue_()) + page.route("**/b/g/answer", lambda route: route.abort() + if "ask=a1" not in (route.request.post_data or "") else route.continue_()) + page.locator(".verdict .mark-add textarea").fill("a draft that stays") # so no reload + tile = 'figure.item[data-item="01.png"]' + page.locator(f"{tile} .item-addnote summary").click() + page.locator(f"{tile} .item-addnote textarea").fill("did not go") + page.locator(f"{tile} .item-addnote button").click() + _wait_said(page, "Could not save in place. Reload to see what was saved") + for q, v in (("a1", "yes"), ("a2", "no"), ("a3", "no")): + page.check(f'.verdict #mark-{q} input[type=radio][value="{v}"]') + page.locator(".verdict #mark-a1 .mark-submit").click() + _wait_said(page, "Not saved") + at_refusal = _said(page)["text"] # R3: the note's failure is not buried + swaps = page.evaluate("window.__swaps || 0") + page.locator('figure.item[data-item="02.png"] .flagtoggle button').click() + page.wait_for_selector('figure.item.is-flagged[data-item="02.png"]', timeout=10000) + _wait_swaps(page, swaps + 1) + page.wait_for_timeout(2500) + said = _said(page) + page.close() + assert "Could not save in place" in at_refusal, at_refusal + text = said["text"] + assert said["tone"] == "warn", said + assert text.count("Could not save in place") == 1 and text.count("Not saved: a2") == 1, text + assert text.index("Could not save in place") < text.index("Not saved: a2"), text + assert "a3" in text, text + + +# ---- G6: a page's keys never take a key the focused element uses --------------------- + +def _media_set(root): + b = root / "g" + b.mkdir() + for n in ("01.png", "03.png"): + (b / n).write_bytes(PNG) + (b / "02.webm").write_bytes(b"\x1a\x45\xdf\xa3") + return b + + +def test_a_player_keeps_its_keys(browser, live): + base, root = live + _media_set(root) + # the review: a focused player keeps the arrows, the letters and Escape + page = _page(browser, base, "/b/g/view?f=02.webm") + posts = _posts(page) + page.locator("#vmedia").focus() + assert _active(page) == "vmedia", "precondition: the player takes focus" + for key in ("ArrowRight", "ArrowLeft", "f", "Space"): + page.keyboard.press(key) + page.wait_for_timeout(600) + stayed = page.url.endswith("view?f=02.webm") + # ...but not Escape, which a player has no use for outside fullscreen (heid + # bug-hunt HRÖSKVA R8): Esc still goes back to the grid + with page.expect_navigation(): + page.keyboard.press("Escape") + back = "/b/g/" in page.url and "view?" not in page.url + page.goto(f"{base}/b/g/view?f=02.webm", wait_until="networkidle") + with page.expect_navigation(): # the negative control: from the page it moves + page.keyboard.press("ArrowRight") + moved = page.url.endswith("view?f=03.png") + page.close() + assert stayed and posts == [], (stayed, posts) + assert back, "Escape from a focused player did not go back to the grid" + assert moved + + # the grid: an arrow on a focused tile video is the player's, not the cursor's + page = _page(browser, base, "/b/g/") + page.locator('figure.item[data-item="02.webm"] video').focus() + page.keyboard.press("ArrowRight") + page.wait_for_timeout(300) + none = page.locator("figure.item.is-cursor").count() + page.evaluate("document.activeElement.blur()") + page.keyboard.press("ArrowRight") + page.wait_for_selector("figure.item.is-cursor", timeout=5000) + page.close() + assert none == 0 + + # compare: an arrow on a focused player does not step the pair + page = _page(browser, base, "/b/g/compare?a=01.png&b=02.webm") + page.locator(".cmp-media").focus() + page.keyboard.press("ArrowRight") + page.wait_for_timeout(600) + kept = "b=02.webm" in page.url + page.close() + assert kept + + +def test_enter_on_a_control_does_not_open_the_cursor_tile(browser, live): + base, root = live + _set(root, 3) + page = _page(browser, base, "/b/g/") + page.keyboard.press("ArrowRight") + page.wait_for_selector("figure.item.is-cursor", timeout=5000) + summary = page.locator('figure.item[data-item="03.png"] .item-addnote summary') + summary.focus() + page.keyboard.press("Enter") + page.wait_for_timeout(600) + stayed = page.url.endswith("/b/g/") + opened = page.locator('figure.item[data-item="03.png"] details.item-addnote').get_attribute("open") + # nor from any other focused node that is not the page itself (the rail stands in + # for one, as an S5b fallback region would be) + page.evaluate("() => { const r = document.querySelector('.rail'); r.setAttribute('tabindex', '-1'); r.focus(); }") + page.keyboard.press("Enter") + page.wait_for_timeout(600) + stayed = stayed and page.url.endswith("/b/g/") + # the positive control: Enter on the focused cursor tile opens its review + page.locator('figure.item[data-item="01.png"]').focus() + with page.expect_navigation(): + page.keyboard.press("Enter") + reviewed = page.url.endswith("view?f=01.png") + page.close() + assert stayed, "Enter on a summary opened the cursor tile" + assert opened is not None, "Enter did not open the summary it was pressed on" + assert reviewed + + +def test_a_focused_pannable_stage_pans_with_the_arrows(browser, live): + from test_flow_browser import _stage_set + base, root = live + _stage_set(root, {"a.png": (3000, 2400), "b.png": (400, 300)}) + page = _page(browser, base, "/b/g/view?f=a.png") + page.locator("#btn-one").click() + page.wait_for_selector("#vstage.can-pan", timeout=5000) + page.locator("#vstage").focus() + assert _active(page) == "vstage", "precondition: Chromium puts a scroller in the Tab order" + page.keyboard.press("ArrowRight") + page.wait_for_timeout(600) + stayed = page.url.endswith("view?f=a.png") + panned = page.evaluate("document.getElementById('vstage').scrollLeft") + page.evaluate("document.activeElement.blur()") + with page.expect_navigation(): + page.keyboard.press("ArrowRight") + moved = page.url.endswith("view?f=b.png") + page.close() + assert stayed and panned > 0, (stayed, panned) + assert moved + + +def test_the_grid_cursor_is_real_focus(browser, live): + base, root = live + _set(root, 5) + page = _page(browser, base, "/b/g/") + page.keyboard.press("ArrowRight") + page.wait_for_selector("figure.item.is-cursor", timeout=5000) + first = page.evaluate("""() => { const a = document.activeElement; + return {cursor: a.classList.contains('is-cursor'), item: a.getAttribute('data-item'), + tabbed: [...document.querySelectorAll('figure.item[tabindex]')].map(e => e.getAttribute('data-item')), + outline: getComputedStyle(a).outlineStyle}; }""") + page.keyboard.press("ArrowRight") + second = page.evaluate("""() => { const a = document.activeElement; + return {cursor: a.classList.contains('is-cursor'), item: a.getAttribute('data-item'), + tabbed: [...document.querySelectorAll('figure.item[tabindex]')].map(e => e.getAttribute('data-item'))}; }""") + page.keyboard.press("Escape") + after = {"cursors": page.locator("figure.item.is-cursor").count(), "active": _active(page)} + page.close() + assert first == {"cursor": True, "item": "01.png", "tabbed": ["01.png"], "outline": "none"}, first + assert second == {"cursor": True, "item": "02.png", "tabbed": ["02.png"]}, second + assert after == {"cursors": 0, "active": "body"}, after + + +def test_focus_returned_to_a_tile_makes_it_the_cursor(browser, live): + from booth.marks import write_note + base, root = live + b = _set(root, 8) + write_note(b, "05.png", "withdraw me") + page = _page(browser, base, "/b/g/") + page.locator('figure.item[data-item="05.png"] .item-note .mark-x').focus() + page.keyboard.press("Enter") + page.wait_for_function( + "!document.querySelector('figure.item[data-item=\"05.png\"] .item-note')", timeout=10000) + _wait_swaps(page, 1) + landed = page.evaluate("""() => { const a = document.activeElement; + return {item: a.getAttribute('data-item'), cursor: a.classList.contains('is-cursor')}; }""") + page.keyboard.press("ArrowRight") + moved = _active(page) + page.close() + assert landed == {"item": "05.png", "cursor": True}, landed + assert moved == "06.png", moved + + +# ---- G7: the doc bar's controls leave its --------------------------------- + +def _doc_set(root): + b = root / "g" + b.mkdir() + (b / "notes.md").write_text("# Notes\n\n" + "A line of prose. " * 40 + "\n") + for n in ("01.png", "02.png"): + (b / n).write_bytes(PNG) + return b + + +def test_a_closed_doc_keeps_its_tools(browser, live): + base, root = live + _doc_set(root) + for js in (True, False): + page = _page(browser, base, "/b/g/", js=js) + doc = 'figure.item-doc[data-item="notes.md"]' + page.locator(f"{doc} summary.doc-sum").click() + page.wait_for_timeout(200) + state = page.evaluate("""d => { const f = document.querySelector(d); + const tools = f.querySelector('.doc-tools').getBoundingClientRect(); + return {body: f.querySelector('.doc-body').getClientRects().length > 0, + tools: tools.width > 0 && tools.height > 0}; }""", doc) + assert state == {"body": False, "tools": True}, (js, state) # the body is not rendered + if js: + page.locator(f"{doc} .doc-tools .flagtoggle button").click() + page.wait_for_function( + "d => document.querySelector(d + ' .doc-tools .flagtoggle button').textContent.includes('flagged')", + arg=doc, timeout=10000) + assert page.evaluate("window.__swaps || 0") >= 1, "the flag reloaded instead of landing in place" + page.close() + + +def test_the_doc_bar_is_one_row_wide_and_two_on_a_phone(browser, live): + base, root = live + _doc_set(root) + got = {} + for label, vp in (("wide", {"width": 1280, "height": 900}), ("phone", PHONE)): + page = _page(browser, base, "/b/g/", viewport=vp) + doc = 'figure.item-doc[data-item="notes.md"]' + got[label] = {"sum": page.locator(f"{doc} summary.doc-sum").evaluate(_RECT), + "name": page.locator(f"{doc} .doc-name").evaluate(_RECT), + "tools": page.locator(f"{doc} .doc-tools").evaluate(_RECT)} + page.close() + w, p = got["wide"], got["phone"] + assert w["tools"]["t"] < w["sum"]["b"] and w["tools"]["b"] > w["sum"]["t"], w # one row + assert p["tools"]["t"] >= p["name"]["b"] - 1, p # under the name + + +def test_a_closed_doc_stays_closed_through_a_save(browser, live): + base, root = live + _doc_set(root) + page = _page(browser, base, "/b/g/") + doc = 'figure.item-doc[data-item="notes.md"]' + page.locator(f"{doc} summary.doc-sum").click() + page.locator('figure.item[data-item="01.png"] .flagtoggle button').click() + page.wait_for_selector('figure.item.is-flagged[data-item="01.png"]', timeout=10000) + _wait_swaps(page, 1) + state = page.evaluate("""d => { const f = document.querySelector(d); + return {open: f.querySelector('details.doc-fold').open, + body: f.querySelector('.doc-body').getClientRects().length > 0}; }""", doc) + page.close() + assert state == {"open": False, "body": False}, state + + +# ---- G14: a gallery tile's picture carries its size --------------------------------- + +def _tall_set(root, n=40): + b = root / "g" + b.mkdir() + for i in range(1, n + 1): + (b / f"{i:02d}.png").write_bytes(_png(400, 500 + (i * 37) % 400)) + return b + + +def _landing(page, rel): + return page.evaluate("""r => ({top: document.getElementById('item-' + r).getBoundingClientRect().top, + rail: document.querySelector('.rail').getBoundingClientRect().bottom})""", rel) + + +def test_a_link_to_a_tile_lands_where_it_points(browser, live): + """Measured before the fix: the tile's top sat 44px below its mark (3 of 3).""" + base, root = live + _tall_set(root) + page = _page(browser, base, "/b/g/#item-30.png") + page.wait_for_timeout(600) + got = _landing(page, "30.png") + page.close() + assert abs(got["top"] - (got["rail"] + 12)) <= 2, got + + +# ---- G15: nothing reached by Tab sits under the sticky rail ------------------------- + +def test_a_tabbed_control_does_not_hide_under_the_rail(browser, live): + """Measured before the fix: the Tab-focused flag button sat at 19.6px, under + the rail's bottom at 47.6px.""" + base, root = live + _tall_set(root, 16) + page = _page(browser, base, "/b/g/", viewport={"width": 1400, "height": 700}) + btn = 'figure.item[data-item="10.png"] .flagtoggle button' + page.evaluate("""s => { const all = [...document.querySelectorAll('a[href],button,summary,input,textarea')] + .filter(e => e.offsetParent); + const b = document.querySelector(s); all[all.indexOf(b) - 1].focus({preventScroll: true}); + window.scrollBy(0, b.getBoundingClientRect().top - 20); }""", btn) + page.wait_for_timeout(200) + page.keyboard.press("Tab") + page.wait_for_timeout(400) + got = page.evaluate("""s => ({on: document.activeElement === document.querySelector(s), + top: document.activeElement.getBoundingClientRect().top, + rail: document.querySelector('.rail').getBoundingClientRect().bottom})""", btn) + page.close() + assert got["on"], "precondition: Tab reached the flag button" + assert got["top"] >= got["rail"], got + + +def test_a_jump_lands_just_below_the_rail(browser, live): + """One offset, not two: scroll padding on the page REPLACES the tile's scroll + margin, since the two add.""" + base, root = live + _set(root, 40) + page = _page(browser, base, "/b/g/#item-31.png", viewport={"width": 1400, "height": 700}) + page.wait_for_timeout(400) + got = _landing(page, "31.png") + page.close() + assert abs(got["top"] - (got["rail"] + 12)) <= 2, got + + +def test_pages_without_a_rail_have_no_scroll_padding(browser, live): + base, root = live + _set(root, 3) + got = {} + for path in ("/", "/b/g/view?f=01.png", "/b/g/"): + page = _page(browser, base, path) + got[path] = page.evaluate("getComputedStyle(document.documentElement).scrollPaddingTop") + page.close() + assert got["/"] == "auto" and got["/b/g/view?f=01.png"] == "auto", got + assert got["/b/g/"] != "auto", got # the positive control + + +def test_the_rail_padding_falls_back_with_scripts_off(browser, live): + """With scripts off --rail-h is unset and its fallback stands in: 120px, or + the measured worst case at <=480px, 205px. The 12px is added either way.""" + base, root = live + _set(root, 3) + got = {} + for label, vp in (("wide", {"width": 1400, "height": 900}), ("phone", PHONE)): + page = _page(browser, base, "/b/g/", viewport=vp, js=False) + got[label] = page.evaluate("getComputedStyle(document.documentElement).scrollPaddingTop") + page.close() + assert got == {"wide": "132px", "phone": "217px"}, got + + +# ---- G17: a reveal control's name is the words on it --------------------------------- + +def _named(page, name): + return page.get_by_role("button", name=name, exact=True).count() + + +def test_reveal_names_follow_their_words(browser, live): + from booth.app import set_blurred + base, root = live + b = root / "g" + b.mkdir() + for rel in ("a.png", "b.png"): + (b / rel).write_bytes(_png(40, 30)) + set_blurred(b, rel, True) + (b / "notes.md").write_text("# Notes\n\nprose\n") + set_blurred(b, "notes.md", True) + got = {} + + page = _page(browser, base, "/b/g/") + got["tile"] = [_named(page, "reveal a.png")] + page.get_by_role("button", name="reveal a.png", exact=True).click() + got["tile"] += [_named(page, "hide a.png")] + ra = page.locator("[data-reveal-all]") + page.mouse.move(1, 1) + look = ra.evaluate("e => getComputedStyle(e).borderTopColor") + got["all"] = [_named(page, "reveal all — blur is cosmetic")] + ra.click() + # the pointer leaves first: :hover draws the same border as the "on" look, + # and a read under the pointer measured the hover (a vacuous gate run) + page.mouse.move(1, 1) + page.wait_for_timeout(300) + got["all"] += [_named(page, "blur again"), ra.get_attribute("aria-pressed"), + ra.evaluate("e => getComputedStyle(e).borderTopColor") != look] + page.close() + + page = _page(browser, base, "/b/g/view?f=a.png") + got["review"] = [_named(page, "reveal a.png — blur is cosmetic")] + page.locator("#vreveal").click() + got["review"] += [_named(page, "hide a.png")] + page.close() + + page = _page(browser, base, "/b/g/compare?a=a.png&b=b.png") + got["compare"] = [_named(page, "reveal A — blur is cosmetic")] + page.locator('.cmp-reveal[data-side="a"]').click() + got["compare"] += [_named(page, "hide A")] + page.close() + + page = _page(browser, base, "/b/g/view?f=notes.md") + got["doc"] = [_named(page, "reveal notes.md — blur is cosmetic")] + page.locator("#docreveal").click() + got["doc"] += [_named(page, "hide notes.md")] + page.close() + + assert got == {"tile": [1, 1], "all": [1, 1, None, True], "review": [1, 1], + "compare": [1, 1], "doc": [1, 1]}, got + + +# ---- folded from the heid bug-hunt (HRÖSKVA) ----------------------------------------- + +def _doc_between(root): + b = root / "g" + b.mkdir() + for n in ("01.png", "03.png", "04.png"): + (b / n).write_bytes(PNG) + (b / "02-notes.md").write_text("# Notes\n\nprose\n") + return b + + +def test_the_cursor_skips_a_closed_doc(browser, live): + """R1: a doc closed with its ✕ is display:none, and a cursor that landed on + it could not take focus while f and Enter acted on it blind.""" + base, root = live + _doc_between(root) + page = _page(browser, base, "/b/g/") + page.locator('figure.item-doc[data-item="02-notes.md"] .doc-close').click() + page.evaluate("document.activeElement.blur(); scrollTo(0, 0)") + page.keyboard.press("ArrowRight") + page.wait_for_selector("figure.item.is-cursor", timeout=5000) + page.keyboard.press("ArrowRight") + got = page.evaluate("""() => ({active: document.activeElement.getAttribute('data-item'), + cursor: [...document.querySelectorAll('figure.item.is-cursor')].map(e => e.getAttribute('data-item')), + tabbed: [...document.querySelectorAll('figure.item[tabindex]')].map(e => e.getAttribute('data-item'))})""") + page.close() + assert got == {"active": "03.png", "cursor": ["03.png"], "tabbed": ["03.png"]}, got + + +def test_n_opens_the_note_on_a_closed_doc(browser, live): + """R10: the note field sits in the doc's body, which a closed fold hides.""" + base, root = live + _doc_between(root) + page = _page(browser, base, "/b/g/") + page.locator('figure.item-doc[data-item="02-notes.md"] summary.doc-sum').click() + page.evaluate("document.activeElement.blur(); scrollTo(0, 0)") + page.keyboard.press("ArrowRight") + page.keyboard.press("ArrowRight") + page.wait_for_selector('figure.item-doc.is-cursor[data-item="02-notes.md"]', timeout=5000) + page.keyboard.press("n") + got = page.evaluate("""() => { const f = document.querySelector('figure.item-doc[data-item="02-notes.md"]'); + return {open: f.querySelector('details.doc-fold').open, + typing: document.activeElement === f.querySelector('.item-addnote textarea')}; }""") + page.close() + assert got == {"open": True, "typing": True}, got + + +def test_enter_reviews_a_file_named_view(browser, live): + """R5: Enter followed the first link whose href merely STARTED with "view", + and a media tile's download link comes first: views.webm downloaded. A file + with no review (viewer.zip) is not downloaded by Enter either.""" + base, root = live + b = root / "g" + b.mkdir() + (b / "views.webm").write_bytes(b"\x1a\x45\xdf\xa3") + (b / "viewer.zip").write_bytes(b"PK\x05\x06" + b"\x00" * 18) + page = _page(browser, base, "/b/g/") + downloads = [] + page.on("download", lambda d: downloads.append(d.suggested_filename)) + page.locator('figure.item[data-item="viewer.zip"]').evaluate("e => e.setAttribute('tabindex', '-1')") + page.locator('figure.item[data-item="viewer.zip"]').focus() # the cursor follows focus + page.keyboard.press("Enter") + page.wait_for_timeout(800) + stayed = page.url.endswith("/b/g/") + page.locator('figure.item[data-item="views.webm"]').evaluate("e => e.setAttribute('tabindex', '-1')") + page.locator('figure.item[data-item="views.webm"]').focus() + with page.expect_navigation(): + page.keyboard.press("Enter") + reviewed = page.url.endswith("view?f=views.webm") + page.wait_for_timeout(300) + page.close() + assert stayed and reviewed and downloads == [], (stayed, reviewed, downloads) + + +def test_the_rail_height_follows_the_live_rail_after_a_save(browser, live): + """R2: the rail is a region a save replaces. The height script watched the + node it found first, so after a save it measured a detached rail.""" + base, root = live + _set(root, 6) + page = _page(browser, base, "/b/g/") + page.locator('figure.item[data-item="02.png"] .flagtoggle button').click() + page.wait_for_selector('figure.item.is-flagged[data-item="02.png"]', timeout=10000) + _wait_swaps(page, 1) + page.wait_for_timeout(300) + probe = """() => ({var: getComputedStyle(document.documentElement).getPropertyValue('--rail-h').trim(), + live: document.querySelector('.rail').offsetHeight + 'px'})""" + after_save = page.evaluate(probe) + page.set_viewport_size({"width": 420, "height": 800}) # the rail wraps taller + page.wait_for_timeout(400) + after_resize = page.evaluate(probe) + page.close() + assert after_save["var"] == after_save["live"], after_save + assert after_resize["var"] == after_resize["live"], after_resize + + +def test_tab_between_rail_links_does_not_move_the_page(browser, live): + """R9: the page's scroll padding clears the rail for everything under it, + and a control IN the stuck rail is not under it. Measured before: each Tab + between group links scrolled the page up 357px (3 of 3).""" + base, root = live + b = root / "g" + b.mkdir() + for grp in ("aa", "bb", "cc", "dd", "ee"): + for i in range(1, 13): + (b / f"{grp}{i:02d}.png").write_bytes(PNG) + page = _page(browser, base, "/b/g/", viewport={"width": 1400, "height": 700}) + page.evaluate("scrollTo(0, 2000)") + page.wait_for_timeout(300) + page.evaluate("document.querySelectorAll('.rail-g')[1].focus({preventScroll: true})") + y = page.evaluate("scrollY") + page.keyboard.press("Tab") + page.wait_for_timeout(400) + got = {"on": page.evaluate("document.activeElement.className"), "moved": page.evaluate("scrollY") - y} + page.close() + assert got == {"on": "rail-g", "moved": 0}, got + + +def test_a_later_failure_does_not_bury_an_earlier_one(browser, live): + """R3: every warning says the failures still standing first, so a later + failure never buries an earlier one: here a batch refusal, then a note.""" + base, root = live + b = _set(root, 3) + _picks(b, ("a1", "a2")) + page = _page(browser, base, "/b/g/") + page.route("**/b/g/answer", lambda route: route.abort() + if "ask=a2" in (route.request.post_data or "") else route.continue_()) + page.route("**/b/g/note", lambda route: route.fulfill(status=500, body="")) + page.check('.verdict #mark-a1 input[type=radio][value="yes"]') + page.check('.verdict #mark-a2 input[type=radio][value="no"]') + page.locator(".verdict #mark-a1 .mark-submit").click() + _wait_said(page, "Not saved") + page.locator(".verdict .mark-add textarea").fill("a draft that stays") + tile = 'figure.item[data-item="01.png"]' + page.locator(f"{tile} .item-addnote summary").click() + page.locator(f"{tile} .item-addnote textarea").fill("did not go") + page.locator(f"{tile} .item-addnote button").click() + _wait_said(page, "Could not save in place") + text = _said(page)["text"] + page.close() + assert "Not saved: a2" in text and text.index("Not saved: a2") < text.index("Could not save in place"), text