fix(as-S5c): whose key it is, the doc bar, tile sizes, the rail's shadow, reveal names
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 <head>, 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 <summary>. 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 <img> 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 <html>. 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.
This commit is contained in:
@@ -103,6 +103,7 @@ from booth.items import ( # noqa: E402,F401
|
|||||||
read_blurred,
|
read_blurred,
|
||||||
render_doc,
|
render_doc,
|
||||||
render_doc_body,
|
render_doc_body,
|
||||||
|
image_dims,
|
||||||
)
|
)
|
||||||
# The per-item blur writer lives with its reader in booth/blur.py, stdlib-only so
|
# 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.
|
# `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
|
# SILENT fallback to the full image — the adapter is exactly
|
||||||
# where a new Item field gets dropped without anything failing.
|
# where a new Item field gets dropped without anything failing.
|
||||||
"thumb": it.thumb,
|
"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.
|
# R2 C1. Same rule: the resolver numbers, this carries.
|
||||||
"ordinal": it.ordinal,
|
"ordinal": it.ordinal,
|
||||||
"caption": it.caption,
|
"caption": it.caption,
|
||||||
|
|||||||
+12
-1
@@ -33,7 +33,7 @@ except ImportError: # pragma: no cover
|
|||||||
from booth.asks import is_answer_file, is_ask_file
|
from booth.asks import is_answer_file, is_ask_file
|
||||||
from booth.blur import BLUR_FILE, read_blurred # noqa: F401 (re-exported)
|
from booth.blur import BLUR_FILE, read_blurred # noqa: F401 (re-exported)
|
||||||
from booth.links import is_safe_href
|
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.
|
# Browser-playable media buckets. Anything else renders as a download link.
|
||||||
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg", ".bmp"}
|
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
|
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:
|
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.
|
"""(body, is_html) for a doc item under DOC_MAX_BYTES, else None.
|
||||||
|
|
||||||
|
|||||||
+135
-35
@@ -76,6 +76,38 @@
|
|||||||
}, true);
|
}, true);
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
<script>
|
||||||
|
/* as S5c (G6): WHOSE KEY IS IT. The one rule the grid, the review and
|
||||||
|
compare share, so no page keeps its own copy: a page's shortcut never
|
||||||
|
takes a key the focused element uses itself.
|
||||||
|
- A field (input, textarea, select, anything editable) owns every key.
|
||||||
|
A player (video, audio) owns every key but Escape, which it has no use
|
||||||
|
for outside fullscreen (where the browser takes it first): Esc still
|
||||||
|
goes back from a focused player (heid bug-hunt R8).
|
||||||
|
- A control (a button, a link, a summary) owns Space: it presses it,
|
||||||
|
follows it or opens it. Arrows and letters still reach the page. Enter
|
||||||
|
is bound only by the grid, and only from the page itself (booth.html),
|
||||||
|
which already leaves every control its own Enter.
|
||||||
|
- A stage that can pan owns the arrows and Space while it has focus: they
|
||||||
|
scroll it. Chromium puts a scroller in the Tab order.
|
||||||
|
- A held Ctrl, Meta or Alt belongs to the browser.
|
||||||
|
Anything else reaches the page. */
|
||||||
|
var BoothKeys = {
|
||||||
|
/* A field: the caret's keys and the letters typed are its own. */
|
||||||
|
isEditable: function (t) {
|
||||||
|
return !!(t && (t.isContentEditable || (t.closest && t.closest('input, textarea, select'))));
|
||||||
|
},
|
||||||
|
theirs: function (e) {
|
||||||
|
if (e.metaKey || e.ctrlKey || e.altKey) return true;
|
||||||
|
var t = e.target, k = e.key;
|
||||||
|
if (!t || !t.closest) return false;
|
||||||
|
if (BoothKeys.isEditable(t) || (k !== 'Escape' && t.closest('video, audio'))) return true;
|
||||||
|
if (k === ' ' && t.closest('button, a[href], summary')) return true;
|
||||||
|
if ((k === ' ' || k.indexOf('Arrow') === 0) && t.closest('.vstage.can-pan')) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
<title>{% block title %}The Booth{% endblock %}</title>
|
<title>{% block title %}The Booth{% endblock %}</title>
|
||||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%2315191d'/%3E%3Cpath d='M7 12V7h5M20 7h5v5M7 20v5h5M25 20v5h-5' fill='none' stroke='%23b2cd12' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='16' cy='16' r='3' fill='%23b2cd12'/%3E%3C/svg%3E">
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%2315191d'/%3E%3Cpath d='M7 12V7h5M20 7h5v5M7 20v5h5M25 20v5h-5' fill='none' stroke='%23b2cd12' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='16' cy='16' r='3' fill='%23b2cd12'/%3E%3C/svg%3E">
|
||||||
{# The two SVOS voices. display=swap and the system stacks in --font-sans /
|
{# 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);
|
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}
|
pointer-events:none}
|
||||||
.item.blurred > .ord{top:44px}
|
.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 */
|
/* 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;
|
.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}
|
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. */
|
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}
|
.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);
|
.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);
|
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)}
|
/* 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 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 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}
|
.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}
|
.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}
|
.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)}
|
.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
|
/* ⚠ 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
|
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}
|
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 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)}
|
.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 <html> 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
|
/* 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"
|
four-line stacks. Below 600px the reveal drops its "— blur is cosmetic"
|
||||||
tail; the title still says it, as does every per-item reveal. */
|
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. */
|
full grid width so prose has a readable measure. */
|
||||||
.item-doc{grid-column:1 / -1}
|
.item-doc{grid-column:1 / -1}
|
||||||
.item-doc.is-closed{display:none}
|
.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{display:block}
|
||||||
.doc-inline > .doc-bar{list-style:none;cursor:pointer;display:flex;align-items:center;gap:8px;
|
.item-doc:has(.doc-fold:not([open])) > .doc-inline{display:none}
|
||||||
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}
|
|
||||||
.doc-chevron{color:var(--text-faint);transition:transform var(--dur-1) var(--ease-out);font-size:10px}
|
.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}
|
.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
|
/* 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) */
|
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}
|
@media (max-width:600px){.doc-bar{flex-wrap:wrap;row-gap:4px}
|
||||||
.doc-name{flex:1 1 calc(100% - 3rem);word-break:normal;overflow-wrap:anywhere}
|
.doc-fold{flex:1 1 100%}
|
||||||
.doc-spacer{display:none}}
|
.doc-sum{padding-bottom:0}
|
||||||
.doc-spacer{flex:1}
|
.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);
|
.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}
|
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-act:hover{color:var(--text-heading);background:var(--surface-sunken);text-decoration:none}
|
||||||
.doc-close:hover{color:var(--text-heading)}
|
.doc-close:hover{color:var(--text-heading)}
|
||||||
.doc-bar .blurtoggle,.doc-bar .flagtoggle{padding:0;background:none}
|
.doc-tools .blurtoggle,.doc-tools .flagtoggle{padding:0;background:none}
|
||||||
.doc-bar .blurtoggle{margin-left:4px}
|
.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{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}
|
.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
|
leaves the tile visible; undershooting hides it. JS on, --rail-h is exact
|
||||||
and this changes nothing. */
|
and this changes nothing. */
|
||||||
@media (max-width:480px){
|
@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){
|
@media (max-width:600px){
|
||||||
main{padding:20px 16px 40px}
|
main{padding:20px 16px 40px}
|
||||||
@@ -1197,18 +1254,36 @@
|
|||||||
the line goes back to that one's "Saving…". Otherwise "Saved.", the one
|
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. */
|
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.';
|
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 = {};
|
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) {
|
function liveForms(key) {
|
||||||
return [].filter.call(document.querySelectorAll('form[data-inplace]'),
|
return [].filter.call(document.querySelectorAll('form[data-inplace]'),
|
||||||
function (f) { return flightKey(f) === key; });
|
function (f) { return flightKey(f) === key; });
|
||||||
}
|
}
|
||||||
function landed(keys) {
|
function landed(keys) {
|
||||||
keys.forEach(function (k) { delete unsent[k]; });
|
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; });
|
var others = Object.keys(pending).some(function (k) { return keys.indexOf(k) < 0; });
|
||||||
if (others) { say('Saving…'); return; }
|
if (others) { say('Saving…'); return; }
|
||||||
var failed = Object.keys(unsent).filter(function (k) { return liveForms(k).length; });
|
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.');
|
var n = say('Saved.');
|
||||||
setTimeout(function () { if (said === n) say(''); }, 2000);
|
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 STAY = 'Could not save in place. Reload to see what was saved; your other entries are still here.';
|
||||||
var beat = null;
|
var beat = null;
|
||||||
function fail(form, key, snap) {
|
function fail(form, key, snap) {
|
||||||
unsent[key] = true;
|
unsent[key] = STAY;
|
||||||
if (!mayReload(key, snap)) { warn(STAY); return; }
|
if (!mayReload(key, snap)) { warn(also(STAY, [key])); return; }
|
||||||
/* Said, then reloaded after a beat, so the words are readable rather
|
/* 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
|
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
|
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). */
|
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.');
|
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
|
/* 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:
|
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) {
|
return refresh([{key: key, snap: snap}]).then(function (placed) {
|
||||||
if (placed) { landed([key]); return; }
|
if (placed) { landed([key]); return; }
|
||||||
if (mayReload(key, snap)) { reload(); 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 () {
|
}, function () {
|
||||||
if (mayReload(key, snap)) reload();
|
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); });
|
}, function () { fail(form, key, snap); });
|
||||||
}
|
}
|
||||||
@@ -1573,14 +1648,23 @@
|
|||||||
? 'Saved ' + saved.length + ' of ' + forms.length + '. Not saved: ' +
|
? 'Saved ' + saved.length + ' of ' + forms.length + '. Not saved: ' +
|
||||||
refused.join('; ') + '. Nothing else you entered was cleared.'
|
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 shown = saved.length ? refresh(saved, true) : Promise.resolve(true);
|
||||||
|
var mine = forms.map(flightKey);
|
||||||
return shown.then(function (placed) {
|
return shown.then(function (placed) {
|
||||||
if (!placed) warn((told || 'Saved.') + ' The page changed meanwhile; reload to see it.');
|
if (!placed) warn(also((told || 'Saved.') + ' The page changed meanwhile; reload to see it.', mine));
|
||||||
else if (told) warn(told);
|
else if (told) warn(also(told, mine));
|
||||||
else landed(forms.map(flightKey));
|
else landed(mine);
|
||||||
}, function () {
|
}, function () {
|
||||||
warn((told || 'Saved ' + saved.length + ' of ' + forms.length + '.') +
|
warn(also((told || 'Saved ' + saved.length + ' of ' + forms.length + '.') +
|
||||||
' Could not refresh the page; reload to see what was saved.');
|
' Could not refresh the page; reload to see what was saved.', mine));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1620,18 +1704,31 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
/* The sticky rail's real height, published as --rail-h for .item's
|
/* The sticky rail's real height, published as --rail-h for the page's
|
||||||
scroll-margin. ADDITIVE: with JS off the static fallback applies, and a
|
scroll-padding-top (as S5c; it was .item's scroll-margin). ADDITIVE: with JS off the static fallback applies, and a
|
||||||
page without a rail does nothing. Re-measured whenever the rail's height
|
page without a rail does nothing. Re-measured whenever the rail's height
|
||||||
changes, so a rail that wraps differently at another width stays right. */
|
changes, so a rail that wraps differently at another width stays right. */
|
||||||
|
/* as S5c (heid bug-hunt R2): the rail is a region (`filters`) that every
|
||||||
|
in-place save REPLACES. Holding the first node measured a detached rail
|
||||||
|
after a save, and its removal wrote --rail-h: 0px, so the padding
|
||||||
|
collapsed under the live rail. Now every measure re-finds the LIVE rail
|
||||||
|
and moves the observer onto it. What triggers that after a save is the
|
||||||
|
observer's own rule: a removed element reports a zero size, so the old
|
||||||
|
rail's removal calls set(), which then measures its replacement. */
|
||||||
(function () {
|
(function () {
|
||||||
var rail = document.querySelector('.rail');
|
if (!document.querySelector('.rail')) return;
|
||||||
if (!rail) return;
|
var ro = window.ResizeObserver ? new ResizeObserver(set) : null, seen = null;
|
||||||
function set() {
|
function set() {
|
||||||
|
var rail = document.querySelector('.rail');
|
||||||
|
if (!rail) return;
|
||||||
document.documentElement.style.setProperty('--rail-h', rail.offsetHeight + 'px');
|
document.documentElement.style.setProperty('--rail-h', rail.offsetHeight + 'px');
|
||||||
|
if (ro && rail !== seen) {
|
||||||
|
if (seen) ro.unobserve(seen);
|
||||||
|
ro.observe(rail);
|
||||||
|
seen = rail;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
set();
|
set();
|
||||||
if (window.ResizeObserver) new ResizeObserver(set).observe(rail);
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
@@ -1688,11 +1785,14 @@
|
|||||||
var ctl = document.querySelectorAll('[data-reveal-all]');
|
var ctl = document.querySelectorAll('[data-reveal-all]');
|
||||||
if (b === null || !ctl.length) return;
|
if (b === null || !ctl.length) return;
|
||||||
var key = 'booth.reveal:' + b;
|
var key = 'booth.reveal:' + b;
|
||||||
|
/* as S5c (G17): the glyph and the word, each in its own span, so the
|
||||||
|
name is the words on screen and the glyph (aria-hidden) is not read. No
|
||||||
|
aria-pressed: a control whose words flip must not also claim a state. */
|
||||||
function show(btn) {
|
function show(btn) {
|
||||||
var on = d.classList.contains('reveal-all');
|
var on = d.classList.contains('reveal-all');
|
||||||
btn.querySelector('.ra-label').textContent = on ? '🙈 blur again' : '👁 reveal all';
|
btn.querySelector('.ra-glyph').textContent = on ? '🙈' : '👁';
|
||||||
|
btn.querySelector('.ra-word').textContent = on ? 'blur again' : 'reveal all';
|
||||||
btn.querySelector('.ra-note').hidden = on;
|
btn.querySelector('.ra-note').hidden = on;
|
||||||
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
|
||||||
}
|
}
|
||||||
ctl.forEach(function (btn) {
|
ctl.forEach(function (btn) {
|
||||||
btn.hidden = false;
|
btn.hidden = false;
|
||||||
|
|||||||
+114
-38
@@ -67,6 +67,14 @@
|
|||||||
{# R2 C1: an item's number in the WHOLE set, zero-padded to the set's width so
|
{# R2 C1: an item's number in the WHOLE set, zero-padded to the set's width so
|
||||||
a column of them lines up. Width reads `all_items`, never the filtered list:
|
a column of them lines up. Width reads `all_items`, never the filtered list:
|
||||||
a filter must not change how a number is written any more than which. #}
|
a filter must not change how a number is written any more than which. #}
|
||||||
|
{# as S5c (G17): a tile's reveal. Its name is the words on it, glyph hidden,
|
||||||
|
the item's name riding as .sr-only text: "reveal a.png", and after the
|
||||||
|
script flips the glyph and the word, "hide a.png". No aria-label, which
|
||||||
|
would contradict the words after the flip. #}
|
||||||
|
{% macro revealbtn(name) -%}
|
||||||
|
<button type="button" class="reveal"><span class="rv-glyph" aria-hidden="true">👁</span> <span class="rv-word">reveal</span><span class="sr-only"> {{ name }}</span></button>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
{% macro ordinal(it) -%}
|
{% macro ordinal(it) -%}
|
||||||
<span class="ord" data-ordinal="{{ it.ordinal }}">#{{ "%0*d"|format((all_items|length|string|length), it.ordinal) }}</span>
|
<span class="ord" data-ordinal="{{ it.ordinal }}">#{{ "%0*d"|format((all_items|length|string|length), it.ordinal) }}</span>
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
@@ -122,7 +130,7 @@
|
|||||||
<input type="hidden" name="on" value="{{ '0' if booth_blurred else '1' }}">
|
<input type="hidden" name="on" value="{{ '0' if booth_blurred else '1' }}">
|
||||||
<button title="{{ 'un-blur the whole booth — per-item blur stays as it was' if booth_blurred else 'blur every image and video in this booth — cosmetic only, the files are still served' }}">{{ '◉ booth blurred' if booth_blurred else '◌ blur booth' }}</button>
|
<button title="{{ 'un-blur the whole booth — per-item blur stays as it was' if booth_blurred else 'blur every image and video in this booth — cosmetic only, the files are still served' }}">{{ '◉ booth blurred' if booth_blurred else '◌ blur booth' }}</button>
|
||||||
</form></span>
|
</form></span>
|
||||||
{% if all_items | selectattr('blurred') | list %}<button type="button" class="reveal-all-btn" data-reveal-all hidden title="blur is cosmetic — the files are still served"><span class="ra-label">👁 reveal all</span><span class="ra-note"> — blur is cosmetic</span></button>{% endif %}
|
{% if all_items | selectattr('blurred') | list %}<button type="button" class="reveal-all-btn" data-reveal-all hidden title="blur is cosmetic — the files are still served"><span class="ra-label"><span class="ra-glyph" aria-hidden="true">👁</span> <span class="ra-word">reveal all</span></span><span class="ra-note"> — blur is cosmetic</span></button>{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if not board %}
|
{% if not board %}
|
||||||
<form class="wipe wipe-lg" method="post" action="/b/{{ name_url }}/delete"
|
<form class="wipe wipe-lg" method="post" action="/b/{{ name_url }}/delete"
|
||||||
@@ -369,7 +377,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% if it.doc and it.rendered is not none %}
|
{% if it.doc and it.rendered is not none %}
|
||||||
{# Docs render INLINE, collapsible, and closable — not a link to a
|
{# Docs render INLINE, collapsible, and closable — not a link to a
|
||||||
separate page. <details open> is native collapse (works with JS off);
|
separate page. The fold is a native <details> (works with JS off);
|
||||||
the ✕ hides the item for the session (JS, progressive enhancement).
|
the ✕ hides the item for the session (JS, progressive enhancement).
|
||||||
The item spans the full grid width so prose has room to read. #}
|
The item spans the full grid width so prose has room to read. #}
|
||||||
<figure class="item item-doc{% if it.blurred %} blurred{% endif %}" data-name="{{ it.name }}" data-item="{{ it.name }}" id="item-{{ it.url }}" data-region="item-{{ it.url }}">
|
<figure class="item item-doc{% if it.blurred %} blurred{% endif %}" data-name="{{ it.name }}" data-item="{{ it.name }}" id="item-{{ it.url }}" data-region="item-{{ it.url }}">
|
||||||
@@ -378,20 +386,30 @@
|
|||||||
its text straight on the page, so "blur the picture" logic that skips
|
its text straight on the page, so "blur the picture" logic that skips
|
||||||
the doc branch leaves the most readable content unblurred. Missed on
|
the doc branch leaves the most readable content unblurred. Missed on
|
||||||
the first pass; caught by a live check, not by the suite. #}
|
the first pass; caught by a live check, not by the suite. #}
|
||||||
<button type="button" class="reveal" aria-label="reveal {{ it.name }}">👁 reveal</button>
|
{{ revealbtn(it.name) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<details class="doc-inline" open>
|
{# as S5c (G7): the bar holds the fold's summary, which is its LABEL
|
||||||
<summary class="doc-bar">
|
only, and the tools beside it. A <summary> is one button to a screen
|
||||||
<span class="doc-chevron" aria-hidden="true">▸</span>
|
reader, and a form is not valid inside one. The fold holds only its
|
||||||
{{ ordinal(it) }}
|
summary so a closed fold keeps the tools; the body and notes below
|
||||||
<span class="doc-name">{{ it.name }}</span>
|
are hidden with it by CSS (:has), scripts on or off. #}
|
||||||
<span class="doc-spacer"></span>
|
<div class="doc-bar">
|
||||||
|
<details class="doc-fold" open>
|
||||||
|
<summary class="doc-sum">
|
||||||
|
<span class="doc-chevron" aria-hidden="true">▸</span>
|
||||||
|
{{ ordinal(it) }}
|
||||||
|
<span class="doc-name">{{ it.name }}</span>
|
||||||
|
</summary>
|
||||||
|
</details>
|
||||||
|
<div class="doc-tools">
|
||||||
<a class="doc-act" href="view?f={{ it.url }}" title="open full page" aria-label="open {{ it.name }} full page">⤢</a>
|
<a class="doc-act" href="view?f={{ it.url }}" title="open full page" aria-label="open {{ it.name }} full page">⤢</a>
|
||||||
<a class="doc-act" href="{{ it.url }}" download title="download {{ it.name }}" aria-label="download {{ it.name }}">⬇</a>
|
<a class="doc-act" href="{{ it.url }}" download title="download {{ it.name }}" aria-label="download {{ it.name }}">⬇</a>
|
||||||
{{ blurtoggle(name_url, it, 'doc-act') }}
|
{{ blurtoggle(name_url, it, 'doc-act') }}
|
||||||
{{ markcontrols(name_url, it, item_marks.get(it.name, []), 'doc-act') }}
|
{{ markcontrols(name_url, it, item_marks.get(it.name, []), 'doc-act') }}
|
||||||
<button type="button" class="doc-act doc-close" title="close (hide for now)" aria-label="close">✕</button>
|
<button type="button" class="doc-act doc-close" title="close (hide for now)" aria-label="close">✕</button>
|
||||||
</summary>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="doc-inline">
|
||||||
{% if it.rendered_html %}
|
{% if it.rendered_html %}
|
||||||
<article class="markdown-body doc-body">{{ it.rendered|safe }}</article>
|
<article class="markdown-body doc-body">{{ it.rendered|safe }}</article>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -403,7 +421,7 @@
|
|||||||
"patched two of three" failure the blurtoggle macro above was
|
"patched two of three" failure the blurtoggle macro above was
|
||||||
written to prevent, recurring on the macro written to prevent it. #}
|
written to prevent, recurring on the macro written to prevent it. #}
|
||||||
{{ marknotes(name_url, it, item_marks.get(it.name, [])) }}
|
{{ marknotes(name_url, it, item_marks.get(it.name, [])) }}
|
||||||
</details>
|
</div>
|
||||||
</figure>
|
</figure>
|
||||||
{% else %}
|
{% else %}
|
||||||
<figure class="item item-{{ it.kind }}{% if it.blurred %} blurred{% endif %}{% if it.name in flagged_set %} is-flagged{% endif %}" data-item="{{ it.name }}" id="item-{{ it.url }}" data-region="item-{{ it.url }}">
|
<figure class="item item-{{ it.kind }}{% if it.blurred %} blurred{% endif %}{% if it.name in flagged_set %} is-flagged{% endif %}" data-item="{{ it.name }}" id="item-{{ it.url }}" data-region="item-{{ it.url }}">
|
||||||
@@ -412,10 +430,12 @@
|
|||||||
{# Click-to-reveal is per-viewer and client-side: nothing is persisted, so
|
{# Click-to-reveal is per-viewer and client-side: nothing is persisted, so
|
||||||
a reload re-hides it. No-JS degrades to STAYS BLURRED, which is the
|
a reload re-hides it. No-JS degrades to STAYS BLURRED, which is the
|
||||||
safe direction to fail in. #}
|
safe direction to fail in. #}
|
||||||
<button type="button" class="reveal" aria-label="reveal {{ it.name }}">👁 reveal</button>
|
{{ revealbtn(it.name) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if it.kind == 'image' %}
|
{% if it.kind == 'image' %}
|
||||||
<a href="view?f={{ it.url }}"><img loading="lazy" decoding="async" src="{{ it.thumb or it.url }}" alt="{{ it.name }}"></a>
|
{# as S5c (G14): the picture's drawn size, so a lazy tile reserves its
|
||||||
|
box before it loads; none when the header could not be read #}
|
||||||
|
<a href="view?f={{ it.url }}"><img loading="lazy" decoding="async" src="{{ it.thumb or it.url }}"{% if it.dims %} width="{{ it.dims[0] }}" height="{{ it.dims[1] }}"{% endif %} alt="{{ it.name }}"></a>
|
||||||
{% elif it.kind == 'video' %}
|
{% elif it.kind == 'video' %}
|
||||||
{# preload="none": a booth of a dozen webms was fetching them
|
{# preload="none": a booth of a dozen webms was fetching them
|
||||||
all at page load ("metadata" still pulls real ranges per
|
all at page load ("metadata" still pulls real ranges per
|
||||||
@@ -475,15 +495,53 @@
|
|||||||
(function () {
|
(function () {
|
||||||
var grid = document.getElementById('grid');
|
var grid = document.getElementById('grid');
|
||||||
if (!grid) return;
|
if (!grid) return;
|
||||||
var tiles = function () { return [].slice.call(grid.querySelectorAll('figure.item')); };
|
/* The tiles the cursor visits: every tile but a doc closed with its ✕,
|
||||||
|
which is display:none and cannot take focus (as S5c, heid bug-hunt R1). */
|
||||||
|
var tiles = function () { return [].slice.call(grid.querySelectorAll('figure.item:not(.is-closed)')); };
|
||||||
var at = -1;
|
var at = -1;
|
||||||
|
/* as S5c: THE CURSOR IS AN ITEM, NOT A POSITION. `cur` is the cursor tile's
|
||||||
|
data-item (its rel); `at` is re-read from it before every key and after
|
||||||
|
every swap, so a doc closed since cannot shift the cursor onto the wrong
|
||||||
|
tile. A cursor whose tile is gone (closed) is no cursor. */
|
||||||
|
var cur = null;
|
||||||
|
function locate() {
|
||||||
|
var t = tiles();
|
||||||
|
at = -1;
|
||||||
|
for (var i = 0; i < t.length; i++) {
|
||||||
|
if (t[i].getAttribute('data-item') === cur) { at = i; break; }
|
||||||
|
}
|
||||||
|
if (at < 0) cur = null;
|
||||||
|
return at;
|
||||||
|
}
|
||||||
|
/* as S5c (G6): THE CURSOR IS REAL FOCUS, so a screen reader and the browser
|
||||||
|
know where it is. The tile it moves to is made focusable (tabindex=-1, an
|
||||||
|
attribute on a server-rendered node, as S5b's fallback sets) and focused
|
||||||
|
without a scroll; the scroll below is today's. Among tiles only the cursor
|
||||||
|
tile carries it, so a mouse press focuses no other tile. */
|
||||||
|
function mark() {
|
||||||
|
var c = current();
|
||||||
|
[].forEach.call(grid.querySelectorAll('figure.item'), function (el) {
|
||||||
|
el.classList.toggle('is-cursor', el === c);
|
||||||
|
if (el !== c) el.removeAttribute('tabindex');
|
||||||
|
});
|
||||||
|
}
|
||||||
function focus(i) {
|
function focus(i) {
|
||||||
var t = tiles();
|
var t = tiles();
|
||||||
if (!t.length) return;
|
if (!t.length) return;
|
||||||
at = Math.max(0, Math.min(i, t.length - 1));
|
at = Math.max(0, Math.min(i, t.length - 1));
|
||||||
t.forEach(function (el, j) { el.classList.toggle('is-cursor', j === at); });
|
cur = t[at].getAttribute('data-item');
|
||||||
|
t[at].setAttribute('tabindex', '-1');
|
||||||
|
t[at].focus({ preventScroll: true });
|
||||||
|
mark();
|
||||||
t[at].scrollIntoView({ block: 'nearest' });
|
t[at].scrollIntoView({ block: 'nearest' });
|
||||||
}
|
}
|
||||||
|
/* ...and focus that lands on a tile by any other road makes it the cursor:
|
||||||
|
S5b's fallback after a swap whose focused control vanished (a note's ×)
|
||||||
|
puts focus on the fresh tile. The cursor and focus never disagree. */
|
||||||
|
grid.addEventListener('focusin', function (e) {
|
||||||
|
var i = tiles().indexOf(e.target);
|
||||||
|
if (i >= 0 && i !== at) { at = i; cur = e.target.getAttribute('data-item'); mark(); }
|
||||||
|
});
|
||||||
function current() { var t = tiles(); return at >= 0 && at < t.length ? t[at] : null; }
|
function current() { var t = tiles(); return at >= 0 && at < t.length ? t[at] : null; }
|
||||||
/* WHERE THE CURSOR STARTS WHEN THERE ISN'T ONE. Starting at tile 0
|
/* WHERE THE CURSOR STARTS WHEN THERE ISN'T ONE. Starting at tile 0
|
||||||
unconditionally meant the first arrow key after ANY scroll yanked the
|
unconditionally meant the first arrow key after ANY scroll yanked the
|
||||||
@@ -497,7 +555,8 @@
|
|||||||
/* ⚠ `.rail` IS A CROSS-FILE CONTRACT, read by two scripts in two files
|
/* ⚠ `.rail` IS A CROSS-FILE CONTRACT, read by two scripts in two files
|
||||||
owned by two different agents: this one, and the --rail-h measuring
|
owned by two different agents: this one, and the --rail-h measuring
|
||||||
script in base.html that publishes the rail's height for
|
script in base.html that publishes the rail's height for
|
||||||
`scroll-margin-top` (the rail wraps, so no CSS number can know it).
|
the page's `scroll-padding-top` (as S5c; it was `.item`'s
|
||||||
|
`scroll-margin-top`) — the rail wraps, so no CSS number can know it.
|
||||||
RENAMING IT BREAKS BOTH, and neither breaks loudly — this one falls back
|
RENAMING IT BREAKS BOTH, and neither breaks loudly — this one falls back
|
||||||
to treating the viewport top as the boundary and starts the cursor one
|
to treating the viewport top as the boundary and starts the cursor one
|
||||||
tile too high; that one falls back to a fixed guess. base.html carries
|
tile too high; that one falls back to a fixed guess. base.html carries
|
||||||
@@ -516,10 +575,10 @@
|
|||||||
var b = el.querySelector(sel); if (b) b.click();
|
var b = el.querySelector(sel); if (b) b.click();
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', function (e) {
|
document.addEventListener('keydown', function (e) {
|
||||||
/* Never steal a key the operator is typing into a note or a URL bar. */
|
/* as S5c (G6): never a key the focused element uses itself — a field's, a
|
||||||
var tag = (e.target.tagName || '').toLowerCase();
|
player's, a control's Space (base.html, BoothKeys). */
|
||||||
if (tag === 'input' || tag === 'textarea' || e.target.isContentEditable) return;
|
if (BoothKeys.theirs(e)) return;
|
||||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
locate();
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'ArrowRight': focus(at < 0 ? fromViewport() : at + 1); e.preventDefault(); break;
|
case 'ArrowRight': focus(at < 0 ? fromViewport() : at + 1); e.preventDefault(); break;
|
||||||
case 'ArrowLeft': focus(at < 0 ? fromViewport() : at - 1); e.preventDefault(); break;
|
case 'ArrowLeft': focus(at < 0 ? fromViewport() : at - 1); e.preventDefault(); break;
|
||||||
@@ -534,21 +593,37 @@
|
|||||||
take focus, so open it first. */
|
take focus, so open it first. */
|
||||||
var d = el && el.querySelector('details.item-addnote');
|
var d = el && el.querySelector('details.item-addnote');
|
||||||
if (d) d.open = true;
|
if (d) d.open = true;
|
||||||
|
/* ...and on a doc, the fold whose body holds it (as S5c, heid
|
||||||
|
bug-hunt R10): a closed fold hides the field too. */
|
||||||
|
var fold = el && el.querySelector('details.doc-fold');
|
||||||
|
if (fold) fold.open = true;
|
||||||
if (el) { var f = el.querySelector('input[type=text], textarea');
|
if (el) { var f = el.querySelector('input[type=text], textarea');
|
||||||
if (f) { f.focus(); e.preventDefault(); } }
|
if (f) { f.focus(); e.preventDefault(); } }
|
||||||
break;
|
break;
|
||||||
case 'Enter': click('a[href^="view"]'); break;
|
/* Enter opens the cursor tile's review only from the page itself: the
|
||||||
|
body, the grid, or the tile. Never from a control (its own Enter) or
|
||||||
|
any other focused node. The review link is `view?f=`, never a prefix
|
||||||
|
of it: a media tile's download link comes first, and for a file named
|
||||||
|
`views.webm` its href starts with "view" too (heid bug-hunt R5). */
|
||||||
|
case 'Enter':
|
||||||
|
if (e.target === document.body || e.target === grid || tiles().indexOf(e.target) >= 0) {
|
||||||
|
click('a[href^="view?f="]');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
/* Escape clears the cursor, and gives focus back to the page when the
|
||||||
|
cursor tile holds it: no ring is left behind without the reticle. */
|
||||||
case 'Escape':
|
case 'Escape':
|
||||||
tiles().forEach(function (x) { x.classList.remove('is-cursor'); });
|
var was = current();
|
||||||
at = -1; break;
|
if (was && document.activeElement === was) was.blur();
|
||||||
|
at = -1; cur = null; mark(); break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
/* R2 C3: an in-place save swaps the tiles for fresh server-rendered ones,
|
/* R2 C3: an in-place save swaps the tiles for fresh server-rendered ones,
|
||||||
and the cursor is client state the server cannot render. Put it back on
|
and the cursor is client state the server cannot render. Put it back on
|
||||||
the same position — the order did not change, only the judgment. */
|
the same position — the order did not change, only the judgment. */
|
||||||
document.addEventListener('booth:swapped', function () {
|
document.addEventListener('booth:swapped', function () {
|
||||||
if (at < 0) return;
|
if (locate() < 0) return;
|
||||||
tiles().forEach(function (el, j) { el.classList.toggle('is-cursor', j === at); });
|
mark();
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
@@ -591,11 +666,11 @@
|
|||||||
to the node it replaced goes with that node. `__bound` keeps a node from
|
to the node it replaced goes with that node. `__bound` keeps a node from
|
||||||
being bound twice.
|
being bound twice.
|
||||||
|
|
||||||
Inline-doc ✕ closes (hides) a rendered doc for the session. The button sits
|
Inline-doc ✕ closes (hides) a rendered doc for the session. It sits in the
|
||||||
inside <summary>, so without this its click would just toggle the <details>
|
bar's tools, beside the fold's summary (as S5c, G7), so its click toggles
|
||||||
open/closed — stopPropagation + preventDefault make ✕ mean "close", not
|
nothing; stopPropagation + preventDefault are kept as belt and braces.
|
||||||
"collapse". Collapse stays available via the rest of the summary bar. With
|
Collapse is the summary's. With JS off the button is inert and collapse via
|
||||||
JS off the button is inert and collapse via <details> still works.
|
<details> still works.
|
||||||
|
|
||||||
Blur reveal. WARNING: this handler previously sat after the content block's
|
Blur reveal. WARNING: this handler previously sat after the content block's
|
||||||
closing tag, which in a child template Jinja DISCARDS — the button rendered
|
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. */
|
block. Per-viewer and never persisted: a reload re-hides. */
|
||||||
function bindTiles() {
|
function bindTiles() {
|
||||||
function once(el) { if (el.__bound) return false; el.__bound = true; return true; }
|
function once(el) { if (el.__bound) return false; el.__bound = true; return true; }
|
||||||
/* A form inside <summary> 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) {
|
document.querySelectorAll('.doc-close').forEach(function (btn) {
|
||||||
if (!once(btn)) return;
|
if (!once(btn)) return;
|
||||||
btn.addEventListener('click', function (ev) {
|
btn.addEventListener('click', function (ev) {
|
||||||
@@ -617,16 +688,21 @@
|
|||||||
if (item) item.classList.add('is-closed');
|
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) {
|
document.querySelectorAll('.item.blurred .reveal').forEach(function (btn) {
|
||||||
var fig = btn.closest('.item');
|
var fig = btn.closest('.item');
|
||||||
/* a swap carries `revealed` across (base.html); the label follows it */
|
/* a swap carries `revealed` across (base.html); the words follow it */
|
||||||
btn.textContent = fig.classList.contains('revealed') ? '🙈 hide' : '👁 reveal';
|
said(btn, fig.classList.contains('revealed'));
|
||||||
if (!once(btn)) return;
|
if (!once(btn)) return;
|
||||||
btn.addEventListener('click', function (ev) {
|
btn.addEventListener('click', function (ev) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
var on = fig.classList.toggle('revealed');
|
said(btn, fig.classList.toggle('revealed'));
|
||||||
btn.textContent = on ? '🙈 hide' : '👁 reveal';
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
<button type="button" class="vseg on" id="btn-fit" aria-pressed="true" title="the whole picture, as large as the stage allows (Z)">Fit</button><button type="button" class="vseg" id="btn-one" aria-pressed="false" aria-label="1:1, natural pixels" title="natural pixels — drag to pan; both stages pan together (Z)">1:1</button>
|
<button type="button" class="vseg on" id="btn-fit" aria-pressed="true" title="the whole picture, as large as the stage allows (Z)">Fit</button><button type="button" class="vseg" id="btn-one" aria-pressed="false" aria-label="1:1, natural pixels" title="natural pixels — drag to pan; both stages pan together (Z)">1:1</button>
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if film | selectattr('blurred') | list %}<button type="button" class="reveal-all-btn" data-reveal-all hidden title="blur is cosmetic — the files are still served"><span class="ra-label">👁 reveal all</span><span class="ra-note"> — blur is cosmetic</span></button>{% endif %}
|
{% if film | selectattr('blurred') | list %}<button type="button" class="reveal-all-btn" data-reveal-all hidden title="blur is cosmetic — the files are still served"><span class="ra-label"><span class="ra-glyph" aria-hidden="true">👁</span> <span class="ra-word">reveal all</span></span><span class="ra-note"> — blur is cosmetic</span></button>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cmp-body">
|
<div class="cmp-body">
|
||||||
@@ -52,7 +52,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{# Over the stage, never inside its scrolled content; JS-only, so
|
{# Over the stage, never inside its scrolled content; JS-only, so
|
||||||
`hidden` until bound (the review's pattern). #}
|
`hidden` until bound (the review's pattern). #}
|
||||||
{% if s.blurred %}<button type="button" class="reveal cmp-reveal" data-side="{{ key }}" aria-label="reveal {{ L }}" hidden>👁 reveal — blur is cosmetic</button>{% 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 %}<button type="button" class="reveal cmp-reveal" data-side="{{ key }}" hidden><span class="rv-glyph" aria-hidden="true">👁</span> <span class="rv-word">reveal</span><span class="sr-only"> {{ L }}</span><span class="rv-note"> — blur is cosmetic</span></button>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="cmp-foot">
|
<div class="cmp-foot">
|
||||||
<div class="cmp-flag" data-region="flag-{{ key }}">
|
<div class="cmp-flag" data-region="flag-{{ key }}">
|
||||||
@@ -216,7 +218,9 @@
|
|||||||
btn.hidden = false;
|
btn.hidden = false;
|
||||||
btn.addEventListener('click', function () {
|
btn.addEventListener('click', function () {
|
||||||
var on = stage.classList.toggle('revealed');
|
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);
|
document.addEventListener('booth:swapped', restate);
|
||||||
|
|
||||||
/* THE KEYS (C3). EVERY key here is ignored while focus is in something
|
/* THE KEYS (C3). EVERY key here is left to the focused element when it
|
||||||
editable and whenever Ctrl, Meta or Alt is held (the review's rule,
|
uses it, and to the browser whenever Ctrl, Meta or Alt is held: the
|
||||||
applied to all of them). */
|
review's rule, now one rule for both (as S5c, G6: base.html's
|
||||||
function isEditable(el) {
|
BoothKeys). A focused player keeps every key; a focused 1:1 stage pans. */
|
||||||
return !!(el && (el.isContentEditable ||
|
|
||||||
/^(input|textarea|select)$/i.test(el.tagName || '')));
|
|
||||||
}
|
|
||||||
function step(dir) {
|
function step(dir) {
|
||||||
var which = (linked ? 'both' : active) + (dir < 0 ? '-prev' : '-next');
|
var which = (linked ? 'both' : active) + (dir < 0 ? '-prev' : '-next');
|
||||||
var a = document.querySelector('a[data-step="' + which + '"]');
|
var a = document.querySelector('a[data-step="' + which + '"]');
|
||||||
if (a) go(withState(a.getAttribute('href')));
|
if (a) go(withState(a.getAttribute('href')));
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', function (e) {
|
document.addEventListener('keydown', function (e) {
|
||||||
if (isEditable(e.target)) return;
|
if (BoothKeys.theirs(e)) return;
|
||||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
||||||
var k = e.key;
|
var k = e.key;
|
||||||
if (k === 'Escape' || k === 'c' || k === 'C') go(BACK);
|
if (k === 'Escape' || k === 'c' || k === 'C') go(BACK);
|
||||||
else if (k === 'ArrowLeft') step(-1);
|
else if (k === 'ArrowLeft') step(-1);
|
||||||
else if (k === 'ArrowRight') step(1);
|
else if (k === 'ArrowRight') step(1);
|
||||||
/* Space steps only from nowhere in particular: never from a focused
|
/* 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 === ' ') {
|
else if (k === ' ') {
|
||||||
if (e.target.closest && e.target.closest('button, a, summary, video, audio')) return;
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
step(e.shiftKey ? -1 : 1);
|
step(e.shiftKey ? -1 : 1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<span class="vspacer"></span>
|
<span class="vspacer"></span>
|
||||||
{# Reveal all can lift this page's blur, so this page must be able to put it
|
{# Reveal all can lift this page's blur, so this page must be able to put it
|
||||||
back (r2b, heid bug-hunt). #}
|
back (r2b, heid bug-hunt). #}
|
||||||
{% if blurred %}<button type="button" class="reveal-all-btn" data-reveal-all hidden title="blur is cosmetic — the files are still served"><span class="ra-label">👁 reveal all</span><span class="ra-note"> — blur is cosmetic</span></button>{% endif %}
|
{% if blurred %}<button type="button" class="reveal-all-btn" data-reveal-all hidden title="blur is cosmetic — the files are still served"><span class="ra-label"><span class="ra-glyph" aria-hidden="true">👁</span> <span class="ra-word">reveal all</span></span><span class="ra-note"> — blur is cosmetic</span></button>{% endif %}
|
||||||
<a class="vbtn" href="{{ file_url }}?dl=1" title="download {{ file }}" aria-label="download {{ file }}">⬇</a>
|
<a class="vbtn" href="{{ file_url }}?dl=1" title="download {{ file }}" aria-label="download {{ file }}">⬇</a>
|
||||||
</div>
|
</div>
|
||||||
{# Same record, same reason as the image viewer: the sidecar that says what
|
{# 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
|
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 <html> class. #}
|
review stage's; Reveal all lifts it by the same <html> class. #}
|
||||||
<div class="docbody{% if blurred %} is-blurred{% endif %}" id="docbody">
|
<div class="docbody{% if blurred %} is-blurred{% endif %}" id="docbody">
|
||||||
{% if blurred %}<button type="button" class="reveal" id="docreveal" hidden>👁 reveal — blur is cosmetic</button>{% endif %}
|
{# as S5c (G17): the name is the words on it, the glyph hidden #}
|
||||||
|
{% if blurred %}<button type="button" class="reveal" id="docreveal" hidden><span class="rv-glyph" aria-hidden="true">👁</span> <span class="rv-word">reveal</span><span class="sr-only"> {{ file }}</span><span class="rv-note"> — blur is cosmetic</span></button>{% endif %}
|
||||||
{% if is_html %}
|
{% if is_html %}
|
||||||
<article class="markdown-body">{{ body|safe }}</article>
|
<article class="markdown-body">{{ body|safe }}</article>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -64,7 +65,9 @@
|
|||||||
rv.hidden = false;
|
rv.hidden = false;
|
||||||
rv.addEventListener('click', function () {
|
rv.addEventListener('click', function () {
|
||||||
var on = document.getElementById('docbody').classList.toggle('revealed');
|
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) {
|
document.addEventListener('keydown', function (e) {
|
||||||
|
|||||||
+18
-18
@@ -32,7 +32,7 @@
|
|||||||
<input type="hidden" name="back" value="{{ file }}">
|
<input type="hidden" name="back" value="{{ file }}">
|
||||||
<button title="{{ 'un-blur the whole booth' if booth_blurred else 'blur every image and video in this booth — cosmetic only' }}">{{ '◉ booth blurred' if booth_blurred else '◌ blur booth' }}</button>
|
<button title="{{ 'un-blur the whole booth' if booth_blurred else 'blur every image and video in this booth — cosmetic only' }}">{{ '◉ booth blurred' if booth_blurred else '◌ blur booth' }}</button>
|
||||||
</form></span>
|
</form></span>
|
||||||
{% if film | selectattr('blurred') | list %}<button type="button" class="reveal-all-btn" data-reveal-all hidden title="blur is cosmetic — the files are still served"><span class="ra-label">👁 reveal all</span><span class="ra-note"> — blur is cosmetic</span></button>{% endif %}
|
{% if film | selectattr('blurred') | list %}<button type="button" class="reveal-all-btn" data-reveal-all hidden title="blur is cosmetic — the files are still served"><span class="ra-label"><span class="ra-glyph" aria-hidden="true">👁</span> <span class="ra-word">reveal all</span></span><span class="ra-note"> — blur is cosmetic</span></button>{% endif %}
|
||||||
<a class="vbtn" href="{{ file_url }}" download title="download {{ file }}" aria-label="download {{ file }}">⬇</a>
|
<a class="vbtn" href="{{ file_url }}" download title="download {{ file }}" aria-label="download {{ file }}">⬇</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -61,7 +61,10 @@
|
|||||||
outside the stage it can never start a pan. #}
|
outside the stage it can never start a pan. #}
|
||||||
{# JS-only, so `hidden` until the script binds it (heid bug-hunt: shown with
|
{# JS-only, so `hidden` until the script binds it (heid bug-hunt: shown with
|
||||||
scripts off, it did nothing) — the toggle's own pattern. #}
|
scripts off, it did nothing) — the toggle's own pattern. #}
|
||||||
{% if blurred %}<button type="button" class="reveal" id="vreveal" aria-label="reveal {{ file }}" hidden>👁 reveal — blur is cosmetic</button>{% 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 %}<button type="button" class="reveal" id="vreveal" hidden><span class="rv-glyph" aria-hidden="true">👁</span> <span class="rv-word">reveal</span><span class="sr-only"> {{ file }}</span><span class="rv-note"> — blur is cosmetic</span></button>{% endif %}
|
||||||
{% if next_url %}<a class="vnav vnext" href="?f={{ next_url }}" title="next (→)" aria-label="next">›</a>{% endif %}
|
{% if next_url %}<a class="vnav vnext" href="?f={{ next_url }}" title="next (→)" aria-label="next">›</a>{% endif %}
|
||||||
|
|
||||||
<aside class="vrail" id="rail" data-region="rail" aria-label="your judgment">
|
<aside class="vrail" id="rail" data-region="rail" aria-label="your judgment">
|
||||||
@@ -265,28 +268,25 @@
|
|||||||
if (rv) rv.hidden = false;
|
if (rv) rv.hidden = false;
|
||||||
if (rv) rv.addEventListener('click', function () {
|
if (rv) rv.addEventListener('click', function () {
|
||||||
var on = document.getElementById('vstage').classList.toggle('revealed');
|
var on = document.getElementById('vstage').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;
|
||||||
});
|
});
|
||||||
|
|
||||||
/* EVERY key here, new and old, is ignored while focus is in something
|
/* EVERY key here, new and old, is left to the focused element when it
|
||||||
editable: an arrow key in the note field is a caret move, and F typed
|
uses it (as S5c, G6: base.html's BoothKeys): an arrow key in the note
|
||||||
into a note is a letter, not a flag. */
|
field is a caret move, F typed into a note is a letter, not a flag, a
|
||||||
function isEditable(el) {
|
focused player keeps every key, and a focused 1:1 stage pans. */
|
||||||
return !!(el && (el.isContentEditable ||
|
|
||||||
/^(input|textarea|select)$/i.test(el.tagName || '')));
|
|
||||||
}
|
|
||||||
document.addEventListener('keydown', function (e) {
|
document.addEventListener('keydown', function (e) {
|
||||||
if (isEditable(e.target)) return;
|
if (BoothKeys.theirs(e)) return;
|
||||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
||||||
/* Space moves only when the stage is not a player that wants it. */
|
|
||||||
var player = document.getElementById('vmedia');
|
|
||||||
if (e.key === 'Escape') window.location.href = BACK;
|
if (e.key === 'Escape') window.location.href = BACK;
|
||||||
else if (e.key === 'ArrowLeft' && PREV) window.location.href = PREV;
|
else if (e.key === 'ArrowLeft' && PREV) window.location.href = PREV;
|
||||||
else if (e.key === 'ArrowRight' && NEXT) window.location.href = NEXT;
|
else if (e.key === 'ArrowRight' && NEXT) window.location.href = NEXT;
|
||||||
/* ...and never from a focused control: Space is how a keyboard presses a
|
/* Space moves, but never from a focused control or player: Space is how
|
||||||
button or follows a link (r2b, heid bug-hunt — Reveal all and the fog
|
a keyboard presses a button or follows a link (r2b, heid bug-hunt —
|
||||||
control could not be pressed). */
|
Reveal all and the fog control could not be pressed). BoothKeys has
|
||||||
else if (e.key === ' ' && NEXT && e.target !== player && !(e.target.closest && e.target.closest('button, a, summary'))) { e.preventDefault(); window.location.href = e.shiftKey && PREV ? PREV : NEXT; }
|
already left it to them. */
|
||||||
|
else if (e.key === ' ' && NEXT) { e.preventDefault(); window.location.href = e.shiftKey && PREV ? PREV : NEXT; }
|
||||||
else if (e.key === 'f' || e.key === 'F') {
|
else if (e.key === 'f' || e.key === 'F') {
|
||||||
var b = document.getElementById('vflag-btn'); /* re-read: the rail may have been swapped */
|
var b = document.getElementById('vflag-btn'); /* re-read: the rail may have been swapped */
|
||||||
if (b) { e.preventDefault(); b.click(); }
|
if (b) { e.preventDefault(); b.click(); }
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ did not do until this module needed them to.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import functools
|
||||||
import os
|
import os
|
||||||
import stat
|
import stat
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -204,3 +205,69 @@ def ensure_thumb(booth: Path, rel: str) -> Path | None:
|
|||||||
return out if _fresh(out, s_stat) else None
|
return out if _fresh(out, s_stat) else None
|
||||||
except Exception: # noqa: BLE001 — a bad image costs its own tile, never the page
|
except Exception: # noqa: BLE001 — a bad image costs its own tile, never the page
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# as S5c (G14): a gallery tile's <img> carries the picture's size, so a lazy
|
||||||
|
# tile reserves its box before it loads and a link to a tile far down lands
|
||||||
|
# where it points. Measured before: 40 portraits, `#item-30.png`, the tile's top
|
||||||
|
# 44px below its mark in 3 runs of 3; with sizes, on it.
|
||||||
|
#
|
||||||
|
# HEADER ONLY. `Image.open` reads the header and decodes nothing. The EXIF
|
||||||
|
# orientation is read only when the header already carried it: Pillow's PNG
|
||||||
|
# `getexif()` otherwise DECODES the whole picture looking for a late eXIf chunk,
|
||||||
|
# which on a 270-tile gallery is 270 decodes per render.
|
||||||
|
#
|
||||||
|
# CACHED by the file's identity, so a render reads each header once while it
|
||||||
|
# is unchanged. The size of an entry is two ints; the bound keeps a long-lived
|
||||||
|
# service from growing without limit as booths come and go.
|
||||||
|
SIZE_CACHE = 4096
|
||||||
|
|
||||||
|
|
||||||
|
def drawn_size(path: Path) -> tuple[int, int] | None:
|
||||||
|
"""(width, height) of the picture at `path` as a browser DRAWS it: EXIF
|
||||||
|
orientations 5-8 swap the two, as `ensure_thumb` does. None for anything
|
||||||
|
whose header cannot be read safely.
|
||||||
|
|
||||||
|
NEVER RAISES. The size only shapes a tile's box before its picture loads,
|
||||||
|
and the loaded picture's own ratio wins then (`aspect-ratio: auto w / h`),
|
||||||
|
so a missing size is today's markup and a wrong one costs a jump, never a
|
||||||
|
distorted picture. Any fleet session can write into a booth, so a link or
|
||||||
|
a FIFO may be planted where a picture was: the file is opened
|
||||||
|
`O_NOFOLLOW` (a link is refused) and `O_NONBLOCK` (a FIFO cannot hang the
|
||||||
|
render; it reads as empty, which is not an image)."""
|
||||||
|
if _Image is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
st = os.lstat(path) # the identity, never through a link
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return _read_size(str(path), st.st_dev, st.st_ino, st.st_size, st.st_mtime_ns, st.st_ctime_ns)
|
||||||
|
|
||||||
|
|
||||||
|
@functools.lru_cache(maxsize=SIZE_CACHE)
|
||||||
|
def _read_size(path: str, dev: int, ino: int, size: int, mtime_ns: int,
|
||||||
|
ctime_ns: int) -> tuple[int, int] | None:
|
||||||
|
"""The read behind `drawn_size`. Every argument after `path` is the cache
|
||||||
|
key's identity only: a file replaced in place is a new entry. The change
|
||||||
|
time is in it because `cp -p` over a file keeps its inode and restores its
|
||||||
|
mtime, and a same-length replacement kept the old size too (heid bug-hunt
|
||||||
|
R7); no write can restore a ctime."""
|
||||||
|
try:
|
||||||
|
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "rb") as fh:
|
||||||
|
fd = -1
|
||||||
|
with _Image.open(fh) as im:
|
||||||
|
w, h = im.size
|
||||||
|
# only when the header already carried it: see the note above
|
||||||
|
orientation = (im.getexif().get(0x0112, 1) if "exif" in im.info else 1)
|
||||||
|
if w <= 0 or h <= 0:
|
||||||
|
return None
|
||||||
|
return (h, w) if orientation in (5, 6, 7, 8) else (w, h)
|
||||||
|
except Exception: # noqa: BLE001 — a bad header costs its own size, never the page
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
if fd >= 0:
|
||||||
|
os.close(fd)
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ depends_on:
|
|||||||
- "app.py: the Jinja Environment and its filters (`human_dur`, `date_iso`, `date_stamp`, `date_day`, `date_ago`); the note/answer routes that record `who = request.client.host`."
|
- "app.py: the Jinja Environment and its filters (`human_dur`, `date_iso`, `date_stamp`, `date_day`, `date_ago`); the note/answer routes that record `who = request.client.host`."
|
||||||
- "marks.py: `Mark.created`, `Mark.by`; asks.py: `answer.answered_at`, `answer.answered_by` (ISO strings with microseconds and an offset, written by `now_stamp`)."
|
- "marks.py: `Mark.created`, `Mark.by`; asks.py: `answer.answered_at`, `answer.answered_by` (ISO strings with microseconds and an offset, written by `now_stamp`)."
|
||||||
- "links.py: `parse_link_entries` -> `when` (the text a `booth link` row carries, today `YYYY-MM-DD HH:MM`)."
|
- "links.py: `parse_link_entries` -> `when` (the text a `booth link` row carries, today `YYYY-MM-DD HH:MM`)."
|
||||||
|
- "items.py: `booth_items`, `Item.kind`, and `render_doc_body` as the pattern for a per-surface step; thumbs.py: the EXIF orientation rule in `ensure_thumb` (S5c)."
|
||||||
|
- "base.html's in-place client as S5b left it: `unsent`, `landed`, `runAll`, `carry`/`detailsMap`, `focusRecord`/`focusRestore` (S5c)."
|
||||||
language: "python + jinja"
|
language: "python + jinja"
|
||||||
complexity: "low per slice"
|
complexity: "low per slice"
|
||||||
touches:
|
touches:
|
||||||
@@ -17,6 +19,7 @@ touches:
|
|||||||
- "booth/templates/base.html (S4, S6)"
|
- "booth/templates/base.html (S4, S6)"
|
||||||
- "booth/app.py (`human_dur`); every page template; booth/static/embed.js (S5a)"
|
- "booth/app.py (`human_dur`); every page template; booth/static/embed.js (S5a)"
|
||||||
- "booth/templates/base.html (the in-place client), view.html, compare.html; booth/static/embed.js (S5b)"
|
- "booth/templates/base.html (the in-place client), view.html, compare.html; booth/static/embed.js (S5b)"
|
||||||
|
- "booth/templates/base.html (BoothKeys, the doc bar, scroll padding, Reveal all, `unsent`), booth.html (the grid keys, the doc bar, tile sizes, reveals), view.html, compare.html, doc.html (keys, reveals); booth/items.py (`image_dims`), booth/thumbs.py (`drawn_size`), booth/app.py (`build_gallery`) (S5c)"
|
||||||
- "tests/test_antislop.py; tests/mutations/antislop.toml"
|
- "tests/test_antislop.py; tests/mutations/antislop.toml"
|
||||||
assumptions:
|
assumptions:
|
||||||
- "ONE VIEWER, on this box: local time is the operator's time (US Pacific), as the existing date filters already assume."
|
- "ONE VIEWER, on this box: local time is the operator's time (US Pacific), as the existing date filters already assume."
|
||||||
@@ -317,6 +320,196 @@ This slice changes where the script's words appear, when they are said and how l
|
|||||||
- the embed's reload after a clean batch can discard text the operator typed into the HOST page, which `ourForms()` cannot see (R5, U3 behaviour);
|
- the embed's reload after a clean batch can discard text the operator typed into the HOST page, which `ourForms()` cannot see (R5, U3 behaviour);
|
||||||
- `carry()` loses an edit that returns a control to its original default while the save flies, because it copies only controls that differ from their old defaults (R6, C3 behaviour).
|
- `carry()` loses an edit that returns a control to its original default while the save flies, because it copies only controls that differ from their old defaults (R6, C3 behaviour).
|
||||||
|
|
||||||
## S5c
|
## S5c — whose key it is, the doc bar, tile sizes, the rail's shadow, reveal names
|
||||||
|
|
||||||
Keys and the doc bar: key handling that ignores keys from inside buttons, links, summaries and media, with real focus on the grid cursor (G6); the doc bar's controls out of its `<summary>` (G7); thumbnail dimensions (G14); scroll padding under the sticky rail (G15); and a reveal button whose name does not flip (G17). Its own contract section and review come before any code.
|
The last of the interaction work: guidelines items G6, G7, G14, G15 and G17, plus booth-dev's note carried from S5b's gate (a refused batch buried by an unrelated save). Every S5b promise holds: the client never re-POSTs, saves are serialized, a batch never reloads, and focus survives a swap.
|
||||||
|
|
||||||
|
**INV-6 here.** The script builds no markup and renders no server state. It sets `tabindex="-1"` on the tile it moves the cursor to (S5b sets the same attribute on a fallback region; how the two meet is under "The grid cursor is real focus"), and it writes the reveal controls' glyph and word into spans the server rendered. Both are the script's own view state.
|
||||||
|
|
||||||
|
### G6 — a page's keys never take a key the focused element uses
|
||||||
|
|
||||||
|
**One rule, in one place.** `BoothKeys.theirs(e)`, defined in `base.html`'s `<head>`, answers "does this key belong to the focused element?" The grid (`booth.html`), the review (`view.html`) and compare (`compare.html`) each call it first and act only when it says no. Their own copies of the field check (`isEditable`, the tag test) and of the Space exceptions are removed. The field check lives on as `BoothKeys.isEditable`, which `test_the_zoom_view_does_not_navigate_away_from_a_note_being_typed` (booth-dev's structural guard) still finds on the review page.
|
||||||
|
|
||||||
|
It answers only WHOSE key it is. What a key then does stays each page's own: the grid's Enter and Escape rules below, the review's Space, compare's letters. `doc.html` keeps its one key (Escape) and its own field guard, unchanged. The helper says **yes** when ANY of these four cases holds (they are a union, so no case outranks another), and in no other:
|
||||||
|
1. **A field owns every key, and a player every key but Escape.** The target is, or is inside, an `input`, `textarea`, `select` or an editable element; or a `video` or an `audio`, for any key but Escape. A player has no use for Escape outside fullscreen, where the browser takes it first, so Esc from a focused player still goes back (the review, compare) or clears the cursor (the grid).
|
||||||
|
- The grid gains `select` and the players. Today an arrow on a focused tile video moves the cursor instead of seeking.
|
||||||
|
- The review and compare gain every key but Escape from a player. Today only Space is left to it, so an arrow on a focused video navigates away.
|
||||||
|
2. **A control owns Space.** The target is, or is inside, a `button`, an `a[href]` or a `summary`: Space presses it, follows it or opens it. Arrows and letters from a control still reach the page. booth-dev's review test presses 1:1 with the mouse and then → to move on, and that flow stays.
|
||||||
|
- **Enter is not in the helper.** Only the grid binds Enter, and it takes Enter only from the page itself (below), which already leaves every control its own Enter. A clause for it here would be code no test could reach.
|
||||||
|
3. **A stage that can pan owns the arrows and Space** while it has focus. The target is, or is inside, a `.vstage.can-pan`, and those keys scroll it.
|
||||||
|
- Measured on the test Chromium (151): a 1:1 stage larger than its box sits in the Tab order between the ‹ and › arrows, and `focus()` takes. A mouse press does not focus it, so drag-to-pan followed by → still moves on.
|
||||||
|
- Today → on a focused 1:1 stage leaves the page instead of panning.
|
||||||
|
4. **A held Ctrl, Meta or Alt belongs to the browser.** All three pages already had this rule; it now lives in the helper.
|
||||||
|
|
||||||
|
- *Falsifiable:*
|
||||||
|
- `test_a_player_keeps_its_keys` (browser): with a video focused, ArrowRight, ArrowLeft, F and Space change nothing on the review (no navigation, no POST) and nothing on the grid (no cursor). Escape from the focused video goes back to the grid. The negative control is in the same test: with focus back on the page, ArrowRight navigates, or moves the cursor.
|
||||||
|
- `test_enter_on_a_control_does_not_open_the_cursor_tile` (browser): the cursor is set, focus moves to another tile's "+ note" summary, and Enter opens that summary and does not navigate. Nor does Enter from another focused node that is not the page (the rail, made focusable, stands in for one). Enter on the focused cursor tile opens its review (the positive control).
|
||||||
|
- `test_a_focused_pannable_stage_pans_with_the_arrows` (browser): 1:1 on a large picture, the stage focused, ArrowRight scrolls it and does not navigate. Blurred, ArrowRight navigates.
|
||||||
|
- Existing: `test_space_on_a_focused_review_button_presses_it_and_does_not_move_on`, `test_the_keys_keep_the_reviews_guards_and_c_toggles_the_view` (compare), and the review's 1:1-then-→ flow in `test_flow_browser.py`.
|
||||||
|
|
||||||
|
**The grid cursor is real focus.**
|
||||||
|
"The tile" below always means the tile element itself (`figure.item`), never a node inside it. The cursor visits every tile except a doc closed with its ✕ (`.is-closed`, `display:none`, which cannot take focus). **The cursor is an item, not a position:** the script holds the cursor tile's `data-item` (its rel) and finds its place again before every key and after every swap, so a doc closed since cannot shift the cursor onto a neighbour. A cursor whose tile is closed is no cursor.
|
||||||
|
- **Moving the cursor focuses the tile.** The script sets `tabindex="-1"` on the tile it moves to, focuses it without scrolling, then scrolls it into view as today (`block:'nearest'`). Among tiles, only the cursor tile carries a `tabindex`: every move removes it from every other tile. The server renders none, so a mouse press can focus only the tile that is already the cursor.
|
||||||
|
- **Focus that lands on a tile makes it the cursor**, whatever put it there. The case today is S5b's fallback after a swap, when the focused control vanished (a note's × withdrawn by keyboard): S5b sets `tabindex="-1"` on the fresh tile and focuses it, and that tile becomes the cursor, so it keeps its `tabindex` under the rule above. S5b's fallback on a region that is not a tile is not the grid's, and the grid never touches it. Focus on a control INSIDE a tile does not move the cursor: the cursor is a tile position, and a control is not a tile. So a focused tile is always the cursor, and the next arrow moves on from it.
|
||||||
|
- **Enter opens the cursor tile's review only from the page itself:** the body, the grid, or the tile itself. From a control, inside the cursor tile or anywhere else, Enter is the control's own and the grid does nothing. It never fires from any other focused node either (an S5b fallback region, for instance).
|
||||||
|
- **Escape clears the cursor.** When the cursor tile itself is the focused element, Escape releases focus to the page, so no focus ring is left behind without the reticle. Focus on a control is left where it is.
|
||||||
|
- **The reticle is the cursor tile's focus mark.** `.item.is-cursor:focus-visible` draws no outline, since the reticle and the accent border already mark it. A focused tile without the cursor cannot occur, and if it did it would keep the house ring.
|
||||||
|
- *Falsifiable:*
|
||||||
|
- `test_the_grid_cursor_is_real_focus` (browser): after →, `document.activeElement` is the `is-cursor` tile and exactly one tile carries a `tabindex`. After → again, the next tile holds both. After Escape, no tile is the cursor and focus is on `body`. The focused cursor tile computes `outline-style: none`.
|
||||||
|
- `test_focus_returned_to_a_tile_makes_it_the_cursor` (browser): a note's × on tile 5 is pressed from the keyboard. After the swap, focus is on tile 5 and tile 5 is the cursor, and → focuses tile 6.
|
||||||
|
- Existing, unchanged: `test_an_arrow_after_a_group_jump_does_not_scroll_back`, `test_the_keyboard_flag_actually_submits`, and S5b's `test_focus_returns_to_the_pressed_control`.
|
||||||
|
|
||||||
|
- **`n` on a doc opens its fold too**, since a closed fold hides the note field with the body.
|
||||||
|
- **Enter follows the tile's `view?f=` link, never a link that merely starts with "view".** A media tile's download link comes first, and for a file named `views.webm` its href starts with "view" too.
|
||||||
|
- *Falsifiable* (folded from the heid bug-hunt): `test_the_cursor_skips_a_closed_doc`, `test_n_opens_the_note_on_a_closed_doc`, `test_enter_reviews_a_file_named_view` (a video named `views.webm` opens its review, and `viewer.zip`, which has no review, is not downloaded).
|
||||||
|
|
||||||
|
**Not in S5c: a switch to turn the letter shortcuts off** (WCAG 2.1.4). The letters act from anywhere except fields and players. A setting to disable them is a new operator-facing control, so it is reported to the operator and not built.
|
||||||
|
|
||||||
|
### G7 — the doc bar's controls leave its `<summary>`
|
||||||
|
|
||||||
|
A `<summary>` is one button to a screen reader, so the links, forms and ✕ inside it were read as one control, and a `<form>` is not valid inside one.
|
||||||
|
|
||||||
|
- **The structure.**
|
||||||
|
- `div.doc-bar` is the bar, with the same look as today.
|
||||||
|
- Inside it, `details.doc-fold` holds only its `summary.doc-sum`: the chevron, the number and the name.
|
||||||
|
- Beside that sits `div.doc-tools`: open full page, download, blur, flag and ✕, the same controls with the same names.
|
||||||
|
- The doc's body and its notes follow in `div.doc-inline`.
|
||||||
|
- **Why the fold holds only its summary.** A closed `<details>` hides everything inside it but its summary, so tools inside it would vanish with the body, and a collapsed doc would lose its controls. Placing the tools beside the summary in the same row needs the summary's box outside the fold's content, which `::details-content{display:contents}` could do, but only in the newest browsers.
|
||||||
|
- **The fold still works with scripts off.** `.item-doc:has(.doc-fold:not([open])) > .doc-inline{display:none}` hides the body and notes when the fold is closed. `:has()` is in every current engine. The bar and its tools stay.
|
||||||
|
- **One row wide, the name first on a phone.** Wide, the fold takes the bar's free width and the tools sit at its end. The tools' vertical padding sets the bar's height, as the old summary's padding did around them, and the summary stretches to fill it, so its hit area is still the whole bar left of the tools. At ≤600px the bar wraps: the summary takes the full width, and the tools go under it (S3's claim, unchanged).
|
||||||
|
- Measured: the bar renders pixel-identical to today at 1280 and 390, light and dark, open and closed (before/after shots of the same booth).
|
||||||
|
- **A save carries the fold.** The fold holds no form, so S5b's `carry()` matches it by class and occurrence within its tile and keeps it open or closed across a swap, as it did the old `details`.
|
||||||
|
- **Removed as dead:** the handler that stopped a click in the bar's forms from toggling the fold. The forms are no longer in the summary. The ✕ handler keeps working and still hides the tile.
|
||||||
|
- **Known cost:** Chrome's find-in-page opens a closed `<details>` to show a match. A closed fold's body is now hidden by CSS outside it, so find-in-page does not reach a collapsed doc. An open doc is unaffected.
|
||||||
|
- *Falsifiable:*
|
||||||
|
- `test_the_doc_summary_holds_no_controls` (markup): no `a`, `button`, `form`, `input` or `textarea` inside `summary.doc-sum`, and each of the five controls is inside `.doc-tools`.
|
||||||
|
- `test_a_closed_doc_keeps_its_tools` (browser, run once with scripts on and once with scripts off): a click on the summary hides the body (no rendered box), and the tools stay visible. With scripts on, the flag still lands in place.
|
||||||
|
- `test_the_doc_bar_is_one_row_wide_and_two_on_a_phone` (browser): at 1280 the summary and the tools share a row; at 390 the tools sit below the name. Also S3's `test_doc_name_keeps_a_readable_line_on_a_phone`, with its row re-anchored.
|
||||||
|
- `test_a_closed_doc_stays_closed_through_a_save` (browser): the fold is closed, another tile is flagged in place, and after the swap the fold is still closed.
|
||||||
|
|
||||||
|
### G14 — a gallery tile's picture carries its size
|
||||||
|
|
||||||
|
A lazy `<img>` with no size is a zero-height box until it loads. So a link to a tile far down the gallery lands where the boxes above it WILL be, not where they are.
|
||||||
|
- Measured on the test Chromium, 40 portrait images, `#item-30.png`, 3 runs each, identical: without sizes the tile's top sits 44px below where the rail's offset puts it (103.4 against 59.6); with sizes it is on target (60.4).
|
||||||
|
|
||||||
|
**The rule.** A gallery tile's `<img>` carries `width` and `height`: the ORIGINAL picture's pixel size, oriented as the browser draws it (a 300×200 PNG is `300`×`200`, whether the tile loads the original or its thumbnail). The tile's CSS (`width:100%; height:auto`) uses the two only as a ratio, so the thumbnail needs no numbers of its own: Pillow's `thumbnail()` keeps the original's ratio, rounding aside, and the orientation rule is the one `ensure_thumb` applies (5–8 swap the numbers).
|
||||||
|
- **Read from the file's header only.** Pillow's `open` reads the header, and nothing is decoded. The EXIF orientation is read only when the header already carries it (`"exif" in im.info`). Pillow's PNG `getexif()` otherwise decodes the whole image to look for a late eXIf chunk. Orientations 5–8 swap the two numbers, as `ensure_thumb` does, so a phone's portrait keeps its portrait box. The thumbnail keeps the original's ratio, so the numbers fit it too.
|
||||||
|
- **A separate step, like `render_doc_body`:** `items.image_dims(booth, item)`, over `thumbs.drawn_size(path)`. `booth_items` and the `Item` record are unchanged, so the Desk, which calls `booth_items` for every booth, pays nothing. `build_gallery` adds `dims` to each tile's dict.
|
||||||
|
- **Cached by the file's identity** (device, inode, size, mtime and change time, after an `lstat`), so a gallery render reads each header once while it is unchanged. The change time is there because `cp -p` over a file keeps its inode and restores its mtime, and a same-length replacement would keep the old size. No write can restore a ctime.
|
||||||
|
- Measured on this box over the audit data copy (312 pictures, 3 runs): 243ms on a cold disk the first time, 15–16ms with the page cache warm, 2.5ms from the size cache.
|
||||||
|
- **Opened safely.**
|
||||||
|
- The identity is taken with `lstat`, never through a link.
|
||||||
|
- The file is opened `O_NOFOLLOW`, so a planted link is refused.
|
||||||
|
- It is opened `O_NONBLOCK`, so a planted FIFO reads as empty (not an image) instead of holding the render.
|
||||||
|
- **Never raises.** `drawn_size` returns None for every failure: a link refused by `O_NOFOLLOW`, a file Pillow cannot open (an SVG, a broken PNG), a FIFO read as empty, any error. The None is cached for that identity like a size. A None renders no attributes, which is today's markup. The size only shapes the box before the picture loads. `aspect-ratio: auto w / h` gives way to the loaded picture's own ratio, so a wrong number costs a jump, never a distorted picture.
|
||||||
|
- *Falsifiable:*
|
||||||
|
- `test_tile_images_carry_their_drawn_size`: a 300×200 PNG renders `width="300" height="200"`. A JPEG stored 300×200 with orientation 6 renders `200`×`300`. A broken `.png`, an SVG and a symlinked PNG render no size.
|
||||||
|
- `test_reading_a_size_decodes_nothing`: with `Image.load` made to raise, a PNG's size and a rotated JPEG's size are still read.
|
||||||
|
- `test_a_planted_fifo_or_link_costs_its_size_and_never_hangs`: a FIFO with no writer returns no size within 3s, a link returns none, and the real file returns its size.
|
||||||
|
- `test_a_size_is_read_once_per_version_of_the_file`: three reads open the file once, and a file rewritten in place is read again.
|
||||||
|
- `test_a_file_replaced_in_place_keeps_no_stale_size`: the same inode, the same length and a restored mtime, read again.
|
||||||
|
- `test_a_link_to_a_tile_lands_where_it_points` (browser): the measurement above, as a test. The tile's top is within 2px of the rail's bottom plus 12 once the page is idle.
|
||||||
|
|
||||||
|
**Measured, not changed.**
|
||||||
|
- **`content-visibility:auto` on `.item`,** which the report proposed alongside the sizes. It fights the in-place swap. A swapped-in tile is a new node with no remembered size, so tiles off-screen collapse to the placeholder height. A flag far down the gallery then moved the page by 2929px, in 3 runs of 3. Without it the page moved 0px, again 3 of 3. The sizes fix the landing, which was the finding. A 270-tile gallery renders without it.
|
||||||
|
- **The filmstrip, the tray and the Desk strip** draw their thumbnails into fixed boxes (`object-fit:cover`), so a size there moves nothing.
|
||||||
|
|
||||||
|
### G15 — nothing reached by Tab sits under the sticky rail
|
||||||
|
|
||||||
|
The rail sticks to the top of a gallery page. `.item{scroll-margin-top}` kept a TILE clear of it on a jump or a cursor move, but a control inside a tile, reached by Tab, scrolled only until it touched the viewport, which is under the rail.
|
||||||
|
- Measured: a tile's flag button reached by Tab sat at 19.6px, under the rail's bottom at 47.6px.
|
||||||
|
|
||||||
|
- **The page, not the tile, keeps the rail's height clear.** `html:has(.rail){scroll-padding-top:calc(var(--rail-h,120px) + 12px)}` REPLACES the two `.item{scroll-margin-top}` rules.
|
||||||
|
- `--rail-h` is the rail's measured height, set by the script. With scripts off it is unset, and the fallback stands in for it: 120px, or at ≤480px the measured worst case, 205px. The 12px is added either way, so scripts off computes 132px, or 217px on a phone. These are the old rules' numbers.
|
||||||
|
- Scroll padding applies to every scroll-into-view: a Tab, a fragment jump, `scrollIntoView` from the cursor.
|
||||||
|
- The two cannot stay together, because they add: a tile would land two rails down.
|
||||||
|
- `--rail-h` is still measured by the same script; only its consumer moves. The script now finds the live rail on every measure and moves its observer onto it. The rail is a region (`filters`) that each save replaces, and the old script watched the first node. What triggers the new measure is the observer's own rule: a removed element reports a zero size, so the old rail's removal calls the measure, which then finds its replacement. A `booth:swapped` listener was tried alongside and removed: its row stayed green, because the observer already covers it. Measured before: after one flag, `--rail-h` read `0px` against a live 48px rail, so the padding collapsed to 12px.
|
||||||
|
- The padding is carried as `--rail-pad`, and a control IN the rail (`.rail :is(a,button,input,summary)`) takes the same value as a negative `scroll-margin-top`. The rail is sticky, so its own controls are never under it. Measured before: every Tab between two group links of the stuck rail scrolled the page up 357px, in 3 runs of 3 (0px on the base).
|
||||||
|
- Pages without a rail get no padding.
|
||||||
|
- **Comments that name the consumer** (base.html's `.rail` note and the `--rail-h` script, and booth.html's `fromViewport` note) now say `scroll-padding-top`. The cross-file contract on the `.rail` class is unchanged.
|
||||||
|
- *Falsifiable:*
|
||||||
|
- `test_a_tabbed_control_does_not_hide_under_the_rail` (browser, 1400×700): the measurement above, as a test. The Tab-focused flag button's top is at or below the rail's bottom.
|
||||||
|
- `test_a_jump_lands_just_below_the_rail` (browser): the tile's top is at the rail's bottom plus 12, ±2. It guards against a doubled offset.
|
||||||
|
- `test_pages_without_a_rail_have_no_scroll_padding` (browser, the negative control): the Desk and the review compute `scroll-padding-top: auto`.
|
||||||
|
- `test_the_rail_height_follows_the_live_rail_after_a_save` (browser): after an in-place flag, and again after a resize wraps the rail taller, `--rail-h` equals the live rail's height.
|
||||||
|
- `test_tab_between_rail_links_does_not_move_the_page` (browser).
|
||||||
|
- `test_the_rail_padding_falls_back_with_scripts_off` (browser, scripts off): a gallery computes 132px at 1400 wide and 217px at 390.
|
||||||
|
|
||||||
|
### G17 — a reveal control's name is the words on it
|
||||||
|
|
||||||
|
The reveal controls flip their words: 👁 reveal ↔ 🙈 hide on a tile, the review stage, a compare side and a doc page, and 👁 reveal all ↔ 🙈 blur again (r2b's ruled copy). Two defects follow.
|
||||||
|
- A tile's, the review's and compare's reveal carried a fixed `aria-label="reveal …"`. After the flip the screen said "hide" and the name said "reveal", which fails WCAG 2.5.3 (label in name).
|
||||||
|
- The emoji were read aloud.
|
||||||
|
|
||||||
|
The report's fix was "a constant label plus `aria-pressed`". That needs constant visible words too (2.5.3), and r2b rules those words. So the name follows the words instead:
|
||||||
|
|
||||||
|
- **No `aria-label`** on any reveal control (`.reveal` on a tile, `#vreveal`, `.cmp-reveal`, `#docreveal`, `[data-reveal-all]`). The name is the visible words.
|
||||||
|
- **The glyph sits in its own `aria-hidden` span**, and the word in its own span. The script writes those two spans, never the button's whole text. It hides the "— blur is cosmetic" tail when revealed with the `hidden` attribute, so the tail leaves the name too: "reveal all — blur is cosmetic", then "blur again". (Below 600px CSS already hides the tail, and the name is the shorter words on screen.)
|
||||||
|
- **A control that reveals one item carries that item's name as `.sr-only` text:** the file on a tile, the review and a doc page, and the side's letter on compare. The name is "reveal a.png" before the flip and "hide a.png" after. S5a's per-item names are kept.
|
||||||
|
- **Reveal all carries no `aria-pressed`.** Its words already say which state it is in. A toggle whose label changes must not also announce a pressed state (WAI-ARIA APG, toggle button), because a screen reader would say "blur again, pressed". Its "on" look moves from `[aria-pressed="true"]` to `.reveal-all .reveal-all-btn`, which reads the one class on `<html>` that IS the state.
|
||||||
|
- *Falsifiable:*
|
||||||
|
- `test_reveal_names_follow_their_words` (browser): by accessible role and name, before and after a click, on a tile, the review, a compare side, a doc page and Reveal all. The "on" look of Reveal all still computes after the click, so the look did not silently go with the attribute.
|
||||||
|
- `test_reveal_glyphs_are_hidden_from_the_name` (markup): every reveal control's glyph is inside `aria-hidden="true"`, and no reveal control carries `aria-label` or `aria-pressed`.
|
||||||
|
|
||||||
|
### booth-dev's note — a refused batch is not buried by an unrelated save
|
||||||
|
|
||||||
|
S5b made a one-form failure outlive an unrelated "Saved." (R4) by keeping its key in `unsent`. A refused BATCH's keys never entered `unsent`. So after a batch said "Saved 1 of 2. Not saved: a2 (…)", an unrelated flag's "Saved." replaced those words while a2's pick was still unsent on the page.
|
||||||
|
|
||||||
|
- **Every failure that stays is remembered with its own words.** `unsent[key]` holds the words said when that form failed: S5b's stay message for a one-form failure, and the batch's whole refusal sentence for each refused form of a batch. The anchored refusal line in `runAll` is unchanged: the refused forms are the batch's forms the server did not take.
|
||||||
|
- A refusal sentence cannot outlive part of what it names. A refused pick stays dirty (a radio cannot be un-picked), so the next press on any pick re-sends every refused pick in one batch. That batch's own words then replace the old sentence for every form it refused, and a form it saved leaves `unsent`.
|
||||||
|
- **`landed()` says them again.** It says every standing failure's words: each distinct message once, in the order they failed, while its form is still on the page. It says them in place of "Saved.", as S5b does for a one-form failure. A new press on the refused form clears it, as today.
|
||||||
|
- **And no warning buries another.** Every warning the client says (a one-form failure, a partly refused batch, "you changed it while it was saving", a page that changed or would not refresh) says the other standing failures first, each once, in the order they failed. "Other" means those not of the forms the warning is about.
|
||||||
|
- *Falsifiable:*
|
||||||
|
- `test_a_batch_refusal_outlives_an_unrelated_save` (browser, written first). A batch with a2 refused, then an unrelated flag lands. Past the 2s beat, the line still says "Not saved: a2", in the warn tone.
|
||||||
|
- `test_two_standing_failures_are_both_said_once_each` (browser): a note that could not be saved, then a batch with a2 and a3 refused, then an unrelated flag. At the refusal and after the flag, the line says the note's words once and the batch's words once, in that order.
|
||||||
|
- `test_a_later_failure_does_not_bury_an_earlier_one` (browser): a batch refusal, then a note that fails. The line says the refusal first, then the note's failure.
|
||||||
|
- The negative control is existing: `test_a_later_save_clears_a_stale_not_saved_line`, where a2's own press clears the words.
|
||||||
|
- S5b's `test_an_unrelated_save_does_not_bury_a_failure` holds unchanged, with its row re-anchored.
|
||||||
|
|
||||||
|
### Folded from the heid contract review
|
||||||
|
|
||||||
|
BEINKA (panel 4/4, thread `01M3NZJNX8D3BEYD48M9K3MV3Q`): 24 flags, 9 themes and 3 solos. All were prose the tests left open, and none changed behaviour. The text above now states:
|
||||||
|
- the fallback arithmetic, with a new test (`test_the_rail_padding_falls_back_with_scripts_off`);
|
||||||
|
- that "the tile" is the element itself, and how the tile, focus and cursor rules meet S5b's fallback;
|
||||||
|
- that the helper's cases are a union and it answers only whose key it is;
|
||||||
|
- that doc.html is unchanged;
|
||||||
|
- where the picture's size comes from, and every path to no size;
|
||||||
|
- how Reveal all's tail leaves its name;
|
||||||
|
- why a batch's refusal sentence cannot outlive part of what it names;
|
||||||
|
- the measured pixel-identity of the doc bar.
|
||||||
|
|
||||||
|
**From this slice's own falsifier runs:**
|
||||||
|
- The first "one row wide" row mutated the fold's flex basis, which a bar that does not wrap simply shrinks. It stayed green. The row now takes the bar's flex away.
|
||||||
|
- "Reveal all loses its on look" proved in one run and went vacuous in the full gate. The test read the border under the pointer that had just clicked, and `:hover` draws the same border as the "on" look. The pointer now leaves before the read, and the row proved 3 runs of 3.
|
||||||
|
|
||||||
|
### Folded from the heid bug-hunt
|
||||||
|
|
||||||
|
HRÖSKVA (panel 4/4, thread `01M3P0ZPRSASFSE5K3PR4NTQP6`): 18 findings in 11 rows.
|
||||||
|
|
||||||
|
**Fixed.** The text above states each fix; each has its test and its row.
|
||||||
|
- R1: the cursor skips a doc closed with its ✕, and it is held as an item.
|
||||||
|
- R2: the rail's height follows the live rail after a save.
|
||||||
|
- R3: no warning buries a standing failure.
|
||||||
|
- R5: Enter follows `view?f=`.
|
||||||
|
- R7: the change time is in the size cache's key.
|
||||||
|
- R8: Escape passes a focused player.
|
||||||
|
- R9: the rail's own controls cancel the padding.
|
||||||
|
- R10: `n` opens a closed doc's fold.
|
||||||
|
|
||||||
|
**Refuted or accepted, with the reason.**
|
||||||
|
- **R4** (a shared refusal sentence names a pick that was later saved): it cannot happen through the UI. See the batch paragraph above: refused picks stay dirty and are re-sent together.
|
||||||
|
- **R6** (no pixel cap on the size read): a header's size costs nothing new. Chrome takes the same header's size for the picture whether or not the tile carries it. Measured: a PNG whose header says 8×200000 and whose body is garbage draws a 10,000,002px tall tile with the attributes and without them. A booth writer can already post that picture. The read's cost is measured above, and the gallery route runs off the event loop.
|
||||||
|
- **R8, second face** (Escape from a focused control clears the cursor): unchanged from before S5c, and nothing the control uses.
|
||||||
|
- **R11** (after a swap the cursor is a class until the next key): by design. S5b leaves focus where the operator put it, and it restores focus to the cursor tile only when that tile held it. The invariant is that a focused tile is the cursor, not that the cursor always holds focus.
|
||||||
|
- **The cached None** (R7's second face): a transient open failure is cached until the file changes. It costs the placeholder, which is today's markup.
|
||||||
|
|
||||||
|
### Existing tests and rows this slice edits
|
||||||
|
|
||||||
|
- **booth-dev's rows, re-anchored to the same failure** (their old anchors are gone):
|
||||||
|
- `r2b.toml` "Space on a focused review button moves to the next item", now in the helper;
|
||||||
|
- `r3.toml` "C3 a held modifier does not make the keys inert", "C3 Space on a focused control steps instead of pressing it" and "C3 Space on a focused player steps the pair", now in the helper;
|
||||||
|
- `r2c.toml` "the stage reveal shows with scripts off", now anchored on `id="vreveal" hidden>`, since the button lost its `aria-label`.
|
||||||
|
- **This contract's own rows, re-anchored:**
|
||||||
|
- `antislop.toml` "S3 the doc bar squeezes the name again" and "S5b an unrelated save buries a failure";
|
||||||
|
- after the bug-hunt fold, the S5b rows "a batch that lands says nothing", "the reload is not asked again at the beat", "a refresh failure after a 204 says it could not save" and "an edit made while saving is called Saved.", whose lines now go through `also()`.
|
||||||
|
- **No existing assertion changes.**
|
||||||
|
|||||||
@@ -283,8 +283,9 @@ new = ''' @media (max-width:1px){.board-head{flex-wrap:wrap}.board-title{white-
|
|||||||
label = "S3 the doc bar squeezes the name again"
|
label = "S3 the doc bar squeezes the name again"
|
||||||
file = "booth/templates/base.html"
|
file = "booth/templates/base.html"
|
||||||
test = "tests/test_antislop_browser.py::test_doc_name_keeps_a_readable_line_on_a_phone"
|
test = "tests/test_antislop_browser.py::test_doc_name_keeps_a_readable_line_on_a_phone"
|
||||||
old = ''' @media (max-width:600px){.doc-inline > .doc-bar{flex-wrap:wrap;row-gap:4px}'''
|
# re-anchored in S5c (G7): the bar is a div holding the fold and the tools
|
||||||
new = ''' @media (max-width:1px){.doc-inline > .doc-bar{flex-wrap:wrap;row-gap:4px}'''
|
old = ''' @media (max-width:600px){.doc-bar{flex-wrap:wrap;row-gap:4px}'''
|
||||||
|
new = ''' @media (max-width:1px){.doc-bar{flex-wrap:wrap;row-gap:4px}'''
|
||||||
|
|
||||||
[[mutation]]
|
[[mutation]]
|
||||||
label = "S3 a file tile's link starts under the number again"
|
label = "S3 a file tile's link starts under the number again"
|
||||||
@@ -903,9 +904,10 @@ new = '''if (!batch.length) return;'''
|
|||||||
label = "S5b a batch that lands says nothing"
|
label = "S5b a batch that lands says nothing"
|
||||||
file = "booth/templates/base.html"
|
file = "booth/templates/base.html"
|
||||||
test = "tests/test_antislop_s5b_browser.py::test_a_batch_speaks_too"
|
test = "tests/test_antislop_s5b_browser.py::test_a_batch_speaks_too"
|
||||||
|
# re-anchored in S5c (heid bug-hunt R3): the batch's keys are named once, as `mine`
|
||||||
old = '''
|
old = '''
|
||||||
|
|
||||||
else landed(forms.map(flightKey));'''
|
else landed(mine);'''
|
||||||
new = ''''''
|
new = ''''''
|
||||||
|
|
||||||
[[mutation]]
|
[[mutation]]
|
||||||
@@ -963,7 +965,8 @@ new = ''' { reload(); return; }'''
|
|||||||
label = "S5b the reload is not asked again at the beat"
|
label = "S5b the reload is not asked again at the beat"
|
||||||
file = "booth/templates/base.html"
|
file = "booth/templates/base.html"
|
||||||
test = "tests/test_antislop_s5b_browser.py::test_a_draft_typed_during_the_beat_stays"
|
test = "tests/test_antislop_s5b_browser.py::test_a_draft_typed_during_the_beat_stays"
|
||||||
old = ''' beat = setTimeout(function () { beat = null; if (mayReload(key, snap)) reload(); else warn(STAY); }, 900);'''
|
# re-anchored in S5c (heid bug-hunt R3): the stay message now says the other standing failures first
|
||||||
|
old = ''' beat = setTimeout(function () { beat = null; if (mayReload(key, snap)) reload(); else warn(also(STAY, [key])); }, 900);'''
|
||||||
new = ''' beat = setTimeout(reload, 900);'''
|
new = ''' beat = setTimeout(reload, 900);'''
|
||||||
|
|
||||||
[[mutation]]
|
[[mutation]]
|
||||||
@@ -1053,7 +1056,8 @@ new = ''''''
|
|||||||
label = "S5b an unrelated save buries a failure"
|
label = "S5b an unrelated save buries a failure"
|
||||||
file = "booth/templates/base.html"
|
file = "booth/templates/base.html"
|
||||||
test = "tests/test_antislop_s5b_browser.py::test_an_unrelated_save_does_not_bury_a_failure"
|
test = "tests/test_antislop_s5b_browser.py::test_an_unrelated_save_does_not_bury_a_failure"
|
||||||
old = ''' if (failed.length) { warn(STAY); return; }
|
# re-anchored in S5c: a standing failure now carries its own words
|
||||||
|
old = ''' if (failed.length) { warn(stayed(failed)); return; }
|
||||||
'''
|
'''
|
||||||
new = ''''''
|
new = ''''''
|
||||||
|
|
||||||
@@ -1061,7 +1065,8 @@ new = ''''''
|
|||||||
label = "S5b a refresh failure after a 204 says it could not save"
|
label = "S5b a refresh failure after a 204 says it could not save"
|
||||||
file = "booth/templates/base.html"
|
file = "booth/templates/base.html"
|
||||||
test = "tests/test_antislop_s5b_browser.py::test_a_save_whose_page_would_not_refresh_says_saved"
|
test = "tests/test_antislop_s5b_browser.py::test_a_save_whose_page_would_not_refresh_says_saved"
|
||||||
old = ''' else warn('Saved. Could not refresh the page; reload to see it.');'''
|
# re-anchored in S5c (heid bug-hunt R3)
|
||||||
|
old = ''' else warn(also('Saved. Could not refresh the page; reload to see it.', [key]));'''
|
||||||
new = ''' else fail(form, key, snap);'''
|
new = ''' else fail(form, key, snap);'''
|
||||||
|
|
||||||
[[mutation]]
|
[[mutation]]
|
||||||
@@ -1075,7 +1080,8 @@ new = ''' if (false) {'''
|
|||||||
label = "S5b an edit made while saving is called Saved."
|
label = "S5b an edit made while saving is called Saved."
|
||||||
file = "booth/templates/base.html"
|
file = "booth/templates/base.html"
|
||||||
test = "tests/test_antislop_s5b_browser.py::test_an_edit_made_while_saving_is_not_called_saved"
|
test = "tests/test_antislop_s5b_browser.py::test_an_edit_made_while_saving_is_not_called_saved"
|
||||||
old = ''' if (keys.some(function (k) { return liveForms(k).some(dirty); })) { warn(CHANGED); return; }
|
# re-anchored in S5c (heid bug-hunt R3)
|
||||||
|
old = ''' if (keys.some(function (k) { return liveForms(k).some(dirty); })) { warn(also(CHANGED, keys)); return; }
|
||||||
'''
|
'''
|
||||||
new = ''''''
|
new = ''''''
|
||||||
|
|
||||||
@@ -1110,3 +1116,351 @@ file = "booth/templates/_marks.html"
|
|||||||
test = "tests/test_antislop.py::test_the_in_place_client_can_read_every_page"
|
test = "tests/test_antislop.py::test_the_in_place_client_can_read_every_page"
|
||||||
old = '''<form class="mark-add" method="post" action="/b/{{ name_url }}/note" data-inplace>'''
|
old = '''<form class="mark-add" method="post" action="/b/{{ name_url }}/note" data-inplace>'''
|
||||||
new = '''<form class="mark-add" method="post" action="/b/{{ name_url }}/note" data-inplace data-region="nested">'''
|
new = '''<form class="mark-add" method="post" action="/b/{{ name_url }}/note" data-inplace data-region="nested">'''
|
||||||
|
|
||||||
|
# ---- S5c: whose key it is, the doc bar, tile sizes, the rail's shadow, reveal names --------
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5c a player gives up its keys to the page"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_antislop_s5c_browser.py::test_a_player_keeps_its_keys"
|
||||||
|
old = ''' if (BoothKeys.isEditable(t) || (k !== 'Escape' && t.closest('video, audio'))) return true;'''
|
||||||
|
new = ''' if (BoothKeys.isEditable(t)) return true;'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5c Enter opens the cursor tile from a control or any focused node"
|
||||||
|
file = "booth/templates/booth.html"
|
||||||
|
test = "tests/test_antislop_s5c_browser.py::test_enter_on_a_control_does_not_open_the_cursor_tile"
|
||||||
|
old = ''' if (e.target === document.body || e.target === grid || tiles().indexOf(e.target) >= 0) {'''
|
||||||
|
new = ''' if (true) {'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5c a focused 1:1 stage loses its arrows to the page"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_antislop_s5c_browser.py::test_a_focused_pannable_stage_pans_with_the_arrows"
|
||||||
|
old = ''' if ((k === ' ' || k.indexOf('Arrow') === 0) && t.closest('.vstage.can-pan')) return true;
|
||||||
|
'''
|
||||||
|
new = ''''''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5c the grid cursor is a class only"
|
||||||
|
file = "booth/templates/booth.html"
|
||||||
|
test = "tests/test_antislop_s5c_browser.py::test_the_grid_cursor_is_real_focus"
|
||||||
|
old = ''' t[at].focus({ preventScroll: true });
|
||||||
|
'''
|
||||||
|
new = ''''''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5c every tile the cursor visited stays focusable"
|
||||||
|
file = "booth/templates/booth.html"
|
||||||
|
test = "tests/test_antislop_s5c_browser.py::test_the_grid_cursor_is_real_focus"
|
||||||
|
old = ''' if (el !== c) el.removeAttribute('tabindex');
|
||||||
|
'''
|
||||||
|
new = ''''''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5c Escape leaves focus on the tile it un-cursors"
|
||||||
|
file = "booth/templates/booth.html"
|
||||||
|
test = "tests/test_antislop_s5c_browser.py::test_the_grid_cursor_is_real_focus"
|
||||||
|
old = ''' if (was && document.activeElement === was) was.blur();
|
||||||
|
at = -1; cur = null; mark(); break;'''
|
||||||
|
new = ''' at = -1; cur = null; tiles().forEach(function (x) { x.classList.remove('is-cursor'); }); break;'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5c the focused cursor tile draws a second ring over the reticle"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_antislop_s5c_browser.py::test_the_grid_cursor_is_real_focus"
|
||||||
|
old = ''' .item.is-cursor:focus-visible{outline:none}'''
|
||||||
|
new = ''' .item.is-cursor:focus-visible{}'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5c focus that lands on a tile does not make it the cursor"
|
||||||
|
file = "booth/templates/booth.html"
|
||||||
|
test = "tests/test_antislop_s5c_browser.py::test_focus_returned_to_a_tile_makes_it_the_cursor"
|
||||||
|
old = ''' if (i >= 0 && i !== at) { at = i; cur = e.target.getAttribute('data-item'); mark(); }'''
|
||||||
|
new = ''' if (false) { at = i; cur = e.target.getAttribute('data-item'); mark(); }'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5c a control is back inside the doc's summary"
|
||||||
|
file = "booth/templates/booth.html"
|
||||||
|
test = "tests/test_antislop.py::test_the_doc_summary_holds_no_controls"
|
||||||
|
old = ''' <span class="doc-name">{{ it.name }}</span>
|
||||||
|
</summary>'''
|
||||||
|
new = ''' <span class="doc-name">{{ it.name }}</span>
|
||||||
|
<a class="doc-act" href="view?f={{ it.url }}">⤢</a>
|
||||||
|
</summary>'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5c a closed fold leaves the doc's body showing"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_antislop_s5c_browser.py::test_a_closed_doc_keeps_its_tools"
|
||||||
|
old = ''' .item-doc:has(.doc-fold:not([open])) > .doc-inline{display:none}'''
|
||||||
|
new = ''' .item-doc:has(.doc-fold:not([open])) > .doc-none{display:none}'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5c the tools inside the fold, which a closed fold hides"
|
||||||
|
file = "booth/templates/booth.html"
|
||||||
|
test = "tests/test_antislop_s5c_browser.py::test_a_closed_doc_keeps_its_tools"
|
||||||
|
old = ''' </summary>
|
||||||
|
</details>
|
||||||
|
<div class="doc-tools">'''
|
||||||
|
new = ''' </summary>
|
||||||
|
<div class="doc-tools">'''
|
||||||
|
|
||||||
|
[[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 = ''' <button type="button" class="reveal"><span class="rv-glyph" aria-hidden="true">'''
|
||||||
|
new = ''' <button type="button" class="reveal" aria-label="reveal {{ name }}"><span class="rv-glyph" aria-hidden="true">'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5c a tile's reveal glyph is read aloud"
|
||||||
|
file = "booth/templates/booth.html"
|
||||||
|
test = "tests/test_antislop.py::test_reveal_glyphs_are_hidden_from_the_name"
|
||||||
|
old = ''' <button type="button" class="reveal"><span class="rv-glyph" aria-hidden="true">'''
|
||||||
|
new = ''' <button type="button" class="reveal"><span class="rv-glyph">'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5c a tile's reveal flips its whole text, and its name loses the item"
|
||||||
|
file = "booth/templates/booth.html"
|
||||||
|
test = "tests/test_antislop_s5c_browser.py::test_reveal_names_follow_their_words"
|
||||||
|
old = ''' btn.querySelector('.rv-word').textContent = on ? 'hide' : 'reveal';'''
|
||||||
|
new = ''' btn.textContent = on ? '🙈 hide' : '👁 reveal';'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5c the review's reveal flips its whole text"
|
||||||
|
file = "booth/templates/view.html"
|
||||||
|
test = "tests/test_antislop_s5c_browser.py::test_reveal_names_follow_their_words"
|
||||||
|
old = ''' rv.querySelector('.rv-word').textContent = on ? 'hide' : 'reveal';'''
|
||||||
|
new = ''' rv.textContent = on ? '🙈 hide' : '👁 reveal — blur is cosmetic'; return;'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5c compare's reveal flips its whole text"
|
||||||
|
file = "booth/templates/compare.html"
|
||||||
|
test = "tests/test_antislop_s5c_browser.py::test_reveal_names_follow_their_words"
|
||||||
|
old = ''' btn.querySelector('.rv-word').textContent = on ? 'hide' : 'reveal';'''
|
||||||
|
new = ''' btn.textContent = on ? '🙈 hide' : '👁 reveal — blur is cosmetic'; return;'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5c a doc page's reveal flips its whole text"
|
||||||
|
file = "booth/templates/doc.html"
|
||||||
|
test = "tests/test_antislop_s5c_browser.py::test_reveal_names_follow_their_words"
|
||||||
|
old = ''' rv.querySelector('.rv-word').textContent = on ? 'hide' : 'reveal';'''
|
||||||
|
new = ''' rv.textContent = on ? '🙈 hide' : '👁 reveal — blur is cosmetic'; return;'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5c Reveal all claims a pressed state its words already say"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_antislop_s5c_browser.py::test_reveal_names_follow_their_words"
|
||||||
|
old = ''' btn.querySelector('.ra-note').hidden = on;
|
||||||
|
}'''
|
||||||
|
new = ''' btn.querySelector('.ra-note').hidden = on;
|
||||||
|
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||||
|
}'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5c Reveal all loses its on look with the attribute"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_antislop_s5c_browser.py::test_reveal_names_follow_their_words"
|
||||||
|
old = ''' .reveal-all .reveal-all-btn{color:var(--text-body);border-color:var(--border-strong)}'''
|
||||||
|
new = ''''''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5c Reveal all's glyph is read aloud"
|
||||||
|
file = "booth/templates/view.html"
|
||||||
|
test = "tests/test_antislop.py::test_reveal_glyphs_are_hidden_from_the_name"
|
||||||
|
old = '''<span class="ra-glyph" aria-hidden="true">👁</span>'''
|
||||||
|
new = '''<span class="ra-glyph">👁</span>'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5c a refused batch's forms never enter unsent (booth-dev's note)"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_antislop_s5c_browser.py::test_a_batch_refusal_outlives_an_unrelated_save"
|
||||||
|
old = ''' if (!saved.some(function (x) { return x.key === k; })) unsent[k] = told;'''
|
||||||
|
new = ''''''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5c only the last standing failure is said again"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_antislop_s5c_browser.py::test_two_standing_failures_are_both_said_once_each"
|
||||||
|
old = ''' return words.join(' ');'''
|
||||||
|
new = ''' return words[words.length - 1];'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5c a refusal shared by two forms is said twice"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_antislop_s5c_browser.py::test_two_standing_failures_are_both_said_once_each"
|
||||||
|
old = ''' keys.forEach(function (k) { if (words.indexOf(unsent[k]) < 0) words.push(unsent[k]); });'''
|
||||||
|
new = ''' keys.forEach(function (k) { words.push(unsent[k]); });'''
|
||||||
|
|
||||||
|
# ---- S5c, folded from the heid bug-hunt (HRÖSKVA, panel 4/4) --------------------------
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5c R1 the cursor lands on a doc closed with its ✕"
|
||||||
|
file = "booth/templates/booth.html"
|
||||||
|
test = "tests/test_antislop_s5c_browser.py::test_the_cursor_skips_a_closed_doc"
|
||||||
|
old = '''grid.querySelectorAll('figure.item:not(.is-closed)')'''
|
||||||
|
new = '''grid.querySelectorAll('figure.item')'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5c R10 n leaves a closed doc's fold shut over its note field"
|
||||||
|
file = "booth/templates/booth.html"
|
||||||
|
test = "tests/test_antislop_s5c_browser.py::test_n_opens_the_note_on_a_closed_doc"
|
||||||
|
old = ''' if (fold) fold.open = true;'''
|
||||||
|
new = ''''''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5c R5 Enter follows the first link that merely starts with view"
|
||||||
|
file = "booth/templates/booth.html"
|
||||||
|
test = "tests/test_antislop_s5c_browser.py::test_enter_reviews_a_file_named_view"
|
||||||
|
old = ''' click('a[href^="view?f="]');'''
|
||||||
|
new = ''' click('a[href^="view"]');'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5c R2 the observer stays on the first rail"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_antislop_s5c_browser.py::test_the_rail_height_follows_the_live_rail_after_a_save"
|
||||||
|
old = ''' if (ro && rail !== seen) {'''
|
||||||
|
new = ''' if (ro && !seen) {'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5c R9 a Tab between stuck rail links scrolls the page up by the rail"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_antislop_s5c_browser.py::test_tab_between_rail_links_does_not_move_the_page"
|
||||||
|
old = ''' .rail :is(a,button,input,summary){scroll-margin-top:calc(-1 * var(--rail-pad, 0px))}'''
|
||||||
|
new = ''''''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5c R3 a later failure buries an earlier one"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_antislop_s5c_browser.py::test_a_later_failure_does_not_bury_an_earlier_one"
|
||||||
|
old = ''' return before ? before + ' ' + text : text;'''
|
||||||
|
new = ''' return text;'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5c R3 a partly refused batch buries an earlier failure"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_antislop_s5c_browser.py::test_two_standing_failures_are_both_said_once_each"
|
||||||
|
old = ''' else if (told) warn(also(told, mine));'''
|
||||||
|
new = ''' else if (told) warn(told);'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5c R8 a focused player swallows Escape"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_antislop_s5c_browser.py::test_a_player_keeps_its_keys"
|
||||||
|
old = '''(k !== 'Escape' && t.closest('video, audio'))'''
|
||||||
|
new = '''t.closest('video, audio')'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "S5c R7 a file rewritten in place with its mtime restored keeps its old size"
|
||||||
|
file = "booth/thumbs.py"
|
||||||
|
test = "tests/test_antislop.py::test_a_file_replaced_in_place_keeps_no_stale_size"
|
||||||
|
old = '''st.st_mtime_ns, st.st_ctime_ns)'''
|
||||||
|
new = '''st.st_mtime_ns, 0)'''
|
||||||
|
|||||||
@@ -239,10 +239,14 @@ new = '''
|
|||||||
|
|
||||||
[[mutation]]
|
[[mutation]]
|
||||||
label = "Space on a focused review button moves to the next item"
|
label = "Space on a focused review button moves to the next item"
|
||||||
file = "booth/templates/view.html"
|
# design-dev re-anchored this row in S5c: the review's own Space exception moved
|
||||||
|
# into base.html's BoothKeys, the one rule the grid, the review and compare
|
||||||
|
# share. Same failure: Space from a focused control reaches the page.
|
||||||
|
file = "booth/templates/base.html"
|
||||||
test = "tests/test_flow_browser.py::test_space_on_a_focused_review_button_presses_it_and_does_not_move_on"
|
test = "tests/test_flow_browser.py::test_space_on_a_focused_review_button_presses_it_and_does_not_move_on"
|
||||||
old = '''e.target !== player && !(e.target.closest && e.target.closest('button, a, summary'))) {'''
|
old = ''' if (k === ' ' && t.closest('button, a[href], summary')) return true;
|
||||||
new = '''e.target !== player) {'''
|
'''
|
||||||
|
new = ''''''
|
||||||
|
|
||||||
[[mutation]]
|
[[mutation]]
|
||||||
label = "the top-bar controls squeeze into multi-line stacks at phone width"
|
label = "the top-bar controls squeeze into multi-line stacks at phone width"
|
||||||
|
|||||||
@@ -247,8 +247,10 @@ new = '''
|
|||||||
label = "the stage reveal shows with scripts off (and does nothing)"
|
label = "the stage reveal shows with scripts off (and does nothing)"
|
||||||
file = "booth/templates/view.html"
|
file = "booth/templates/view.html"
|
||||||
test = "tests/test_flow_browser.py::test_the_stage_reveal_never_shows_without_js_and_keeps_the_fit_shadow"
|
test = "tests/test_flow_browser.py::test_the_stage_reveal_never_shows_without_js_and_keeps_the_fit_shadow"
|
||||||
old = '''aria-label="reveal {{ file }}" hidden>'''
|
# design-dev re-anchored in S5c (G17): the stage reveal lost its aria-label (its
|
||||||
new = '''aria-label="reveal {{ file }}">'''
|
# name is now the words on it). Same failure: the button renders unhidden.
|
||||||
|
old = '''id="vreveal" hidden>'''
|
||||||
|
new = '''id="vreveal">'''
|
||||||
|
|
||||||
[[mutation]]
|
[[mutation]]
|
||||||
label = "a revealed review picture loses Fit's shadow"
|
label = "a revealed review picture loses Fit's shadow"
|
||||||
|
|||||||
+13
-13
@@ -299,24 +299,24 @@ new = '''
|
|||||||
|
|
||||||
[[mutation]]
|
[[mutation]]
|
||||||
label = "C3 a held modifier does not make the keys inert"
|
label = "C3 a held modifier does not make the keys inert"
|
||||||
file = "booth/templates/compare.html"
|
# design-dev re-anchored this row and the two Space rows below in S5c: compare's
|
||||||
|
# guards moved into base.html's BoothKeys, which compare now calls first. Same
|
||||||
|
# failures, in the one place the rule now lives.
|
||||||
|
file = "booth/templates/base.html"
|
||||||
test = "tests/test_compare_browser.py::test_the_keys_keep_the_reviews_guards_and_c_toggles_the_view"
|
test = "tests/test_compare_browser.py::test_the_keys_keep_the_reviews_guards_and_c_toggles_the_view"
|
||||||
old = '''
|
old = '''
|
||||||
if (isEditable(e.target)) return;
|
if (e.metaKey || e.ctrlKey || e.altKey) return true;
|
||||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
var t = e.target, k = e.key;'''
|
||||||
var k = e.key;'''
|
|
||||||
new = '''
|
new = '''
|
||||||
if (isEditable(e.target)) return;
|
var t = e.target, k = e.key;'''
|
||||||
var k = e.key;'''
|
|
||||||
|
|
||||||
[[mutation]]
|
[[mutation]]
|
||||||
label = "C3 Space on a focused control steps instead of pressing it"
|
label = "C3 Space on a focused control steps instead of pressing it"
|
||||||
file = "booth/templates/compare.html"
|
file = "booth/templates/base.html"
|
||||||
test = "tests/test_compare_browser.py::test_the_keys_keep_the_reviews_guards_and_c_toggles_the_view"
|
test = "tests/test_compare_browser.py::test_the_keys_keep_the_reviews_guards_and_c_toggles_the_view"
|
||||||
old = '''
|
old = ''' if (k === ' ' && t.closest('button, a[href], summary')) return true;
|
||||||
if (e.target.closest && e.target.closest('button, a, summary, video, audio')) return;'''
|
|
||||||
new = '''
|
|
||||||
'''
|
'''
|
||||||
|
new = ''''''
|
||||||
|
|
||||||
[[mutation]]
|
[[mutation]]
|
||||||
label = "INV-6 the review has no C key"
|
label = "INV-6 the review has no C key"
|
||||||
@@ -455,10 +455,10 @@ new = '''
|
|||||||
|
|
||||||
[[mutation]]
|
[[mutation]]
|
||||||
label = "C3 Space on a focused player steps the pair"
|
label = "C3 Space on a focused player steps the pair"
|
||||||
file = "booth/templates/compare.html"
|
file = "booth/templates/base.html"
|
||||||
test = "tests/test_compare_browser.py::test_the_keys_keep_the_reviews_guards_and_c_toggles_the_view"
|
test = "tests/test_compare_browser.py::test_the_keys_keep_the_reviews_guards_and_c_toggles_the_view"
|
||||||
old = '''e.target.closest('button, a, summary, video, audio')'''
|
old = ''' if (BoothKeys.isEditable(t) || (k !== 'Escape' && t.closest('video, audio'))) return true;'''
|
||||||
new = '''e.target.closest('button, a, summary')'''
|
new = ''' if (BoothKeys.isEditable(t)) return true;'''
|
||||||
|
|
||||||
[[mutation]]
|
[[mutation]]
|
||||||
label = "C3 there is no back arrow"
|
label = "C3 there is no back arrow"
|
||||||
|
|||||||
@@ -631,3 +631,173 @@ def test_the_in_place_client_can_read_every_page(client):
|
|||||||
p.feed(page)
|
p.feed(page)
|
||||||
assert not p.odd, (url, p.odd)
|
assert not p.odd, (url, p.odd)
|
||||||
assert not p.nested, (url, p.nested)
|
assert not p.nested, (url, p.nested)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- S5c -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _figure(page, rel):
|
||||||
|
"""One tile's markup, by its data-item."""
|
||||||
|
m = re.search(r'<figure\b[^>]*data-item="' + re.escape(rel) + r'".*?</figure>', _markup(page), re.S)
|
||||||
|
assert m, f"no tile for {rel}"
|
||||||
|
return m.group(0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_doc_summary_holds_no_controls(client):
|
||||||
|
"""G7: a <summary> is one button to a screen reader, and a <form> is not
|
||||||
|
valid inside one. The doc's summary holds its label; the controls sit in a
|
||||||
|
sibling toolbar."""
|
||||||
|
c, data = client
|
||||||
|
_s5_booth(data)
|
||||||
|
tile = _figure(c.get("/b/b/").text, "notes.md")
|
||||||
|
labels = [s for s in re.findall(r"<summary\b[^>]*>(.*?)</summary>", tile, re.S) if "doc-name" in s]
|
||||||
|
assert len(labels) == 1, labels
|
||||||
|
assert not re.search(r"<(a|button|form|input|textarea)\b", labels[0]), labels[0]
|
||||||
|
tools = re.search(r'<div class="doc-tools">(.*?)</div>\s*</div>', 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"<img\b[^>]*>", 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('<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>')
|
||||||
|
(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'<button\b(?=[^>]*(?:class="[^"]*\breveal(?:-all-btn)?\b|data-reveal-all))([^>]*)>(.*?)</button>', 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'<span\b[^>]*aria-hidden="true"[^>]*>.*?</span>', "", 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))
|
||||||
|
|||||||
@@ -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 <summary> ---------------------------------
|
||||||
|
|
||||||
|
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
|
||||||
Reference in New Issue
Block a user