diff --git a/booth/app.py b/booth/app.py
index 8f37bea..cff67be 100644
--- a/booth/app.py
+++ b/booth/app.py
@@ -103,6 +103,7 @@ from booth.items import ( # noqa: E402,F401
read_blurred,
render_doc,
render_doc_body,
+ image_dims,
)
# The per-item blur writer lives with its reader in booth/blur.py, stdlib-only so
# `scripts/booth blur` shares both. Re-exported: tests import it from here.
@@ -916,6 +917,9 @@ def build_gallery(child: Path) -> list[dict]:
# SILENT fallback to the full image — the adapter is exactly
# where a new Item field gets dropped without anything failing.
"thumb": it.thumb,
+ # as S5c (G14): the tile's picture size, a separate step like
+ # the doc body below, so the Desk's `booth_items` never pays it.
+ "dims": image_dims(child, it),
# R2 C1. Same rule: the resolver numbers, this carries.
"ordinal": it.ordinal,
"caption": it.caption,
diff --git a/booth/items.py b/booth/items.py
index 5c11fe6..dcba229 100644
--- a/booth/items.py
+++ b/booth/items.py
@@ -33,7 +33,7 @@ except ImportError: # pragma: no cover
from booth.asks import is_answer_file, is_ask_file
from booth.blur import BLUR_FILE, read_blurred # noqa: F401 (re-exported)
from booth.links import is_safe_href
-from booth.thumbs import wants_thumb
+from booth.thumbs import drawn_size, wants_thumb
# Browser-playable media buckets. Anything else renders as a download link.
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg", ".bmp"}
@@ -500,6 +500,17 @@ def find_item(items: Sequence[Item], rel: str) -> Item | None:
return None
+def image_dims(booth: Path, item: Item) -> tuple[int, int] | None:
+ """(width, height) of an image item as the browser draws it, or None.
+
+ A SEPARATE STEP, like `render_doc_body` below and for its reason: the Desk
+ calls `booth_items` for every booth, and reading a header per picture there
+ would be the cost of a fact only the gallery's tiles use (as S5c, G14)."""
+ if item.kind != "image":
+ return None
+ return drawn_size(booth / item.rel)
+
+
def render_doc_body(booth: Path, item: Item) -> tuple[str, bool] | None:
"""(body, is_html) for a doc item under DOC_MAX_BYTES, else None.
diff --git a/booth/templates/base.html b/booth/templates/base.html
index df86ea0..8407bb5 100644
--- a/booth/templates/base.html
+++ b/booth/templates/base.html
@@ -76,6 +76,38 @@
}, true);
})();
+
{% block title %}The Booth{% endblock %}
{# The two SVOS voices. display=swap and the system stacks in --font-sans /
@@ -577,7 +609,7 @@
font:600 11px/1.2 var(--font-mono);background:oklch(0.17 0.01 250 / .82);color:oklch(0.91 0.008 216);
pointer-events:none}
.item.blurred > .ord{top:44px}
- .doc-bar .ord{font:600 11px/1 var(--font-mono);color:var(--text-muted)}
+ .doc-sum .ord{font:600 11px/1 var(--font-mono);color:var(--text-muted)}
/* the inline group header: spans the grid, is never a tile */
.grp-head{grid-column:1 / -1;display:flex;align-items:center;gap:8px;margin:8px 0 -4px;
font-family:var(--font-mono);font-size:var(--size-micro);letter-spacing:var(--tracking-caps);text-transform:uppercase}
@@ -631,8 +663,16 @@
untouched: this sizes boxes, it moves none. */
.gallery{display:grid;gap:var(--space-5);grid-template-columns:repeat(auto-fill,minmax(320px,1fr));align-items:start}
.item{position:relative;margin:0;background:var(--surface-card);border:1px solid var(--border-default);
- border-radius:var(--radius-xl);overflow:hidden;display:flex;flex-direction:column;box-shadow:var(--shadow-sm);
- scroll-margin-top:calc(var(--rail-h, 120px) + 12px)}
+ border-radius:var(--radius-xl);overflow:hidden;display:flex;flex-direction:column;box-shadow:var(--shadow-sm)}
+ /* as S5c (G15): the PAGE keeps the rail's height clear, not the tile, so a
+ control reached by Tab stops below the rail too, as a jump and the cursor
+ do. It replaces .item's scroll-margin-top: the two add, and a tile would
+ land two rails down. Only a page with a rail. */
+ html:has(.rail){--rail-pad:calc(var(--rail-h, 120px) + 12px);scroll-padding-top:var(--rail-pad)}
+ /* ...and a control IN the stuck rail is not under it: its own negative
+ margin cancels the padding, or every Tab between group links scrolled the
+ page up by the rail (357px measured; heid bug-hunt R9). */
+ .rail :is(a,button,input,summary){scroll-margin-top:calc(-1 * var(--rail-pad, 0px))}
.item img,.item video{width:100%;height:auto;display:block;background:var(--surface-sunken)}
.item audio{width:100%;margin:20px 14px 6px;max-width:calc(100% - 28px)}
.item .dl{padding:22px 14px;font-family:var(--font-mono);font-size:var(--size-sm);word-break:break-all}
@@ -706,6 +746,9 @@
.mark-opt:has(input:checked)::after{inset:3px;--rl:10px;--rt:1.5px;filter:none}
.film-f.is-current::after,.film-f.is-active::after{inset:2px;--rl:9px;--rt:1.5px}
.item.is-cursor{border-color:color-mix(in oklab,var(--accent) 60%,transparent)}
+ /* as S5c (G6): the cursor is real focus, and the reticle and the border
+ above already mark it, so the focused cursor tile draws no second ring */
+ .item.is-cursor:focus-visible{outline:none}
/* ⚠ Blur is COSMETIC. The file is still served at its own URL and still in
the zip. This hides an item from a glance, nothing more — and nothing here
@@ -745,7 +788,9 @@
background:none;color:var(--text-muted);cursor:pointer;-webkit-backdrop-filter:none;backdrop-filter:none}
.blur-all button:hover,.reveal-all-btn:hover{color:var(--text-body);border-color:var(--border-strong)}
.blur-all.is-on button{color:var(--text-body);border-color:var(--border-strong)}
- .reveal-all-btn[aria-pressed="true"]{color:var(--text-body);border-color:var(--border-strong)}
+ /* as S5c (G17): its "on" look reads the one class on that IS the
+ state; the words say it to a screen reader, so no aria-pressed */
+ .reveal-all .reveal-all-btn{color:var(--text-body);border-color:var(--border-strong)}
/* One line each, always: a top bar at phone width squeezed these into
four-line stacks. Below 600px the reveal drops its "— blur is cosmetic"
tail; the title still says it, as does every per-item reveal. */
@@ -765,26 +810,38 @@
full grid width so prose has a readable measure. */
.item-doc{grid-column:1 / -1}
.item-doc.is-closed{display:none}
+ /* as S5c (G7): the bar is a row holding the fold's summary (its label only)
+ and the tools beside it, outside the summary, which a screen reader reads
+ as one button. The fold holds only its summary, so a closed fold keeps the
+ tools; closing it hides the body and notes by CSS, scripts on or off. */
+ /* The tools' padding sets the bar's height, as the old summary's did around
+ them; the fold stretches to it and its summary fills it, so the whole bar
+ left of the tools is still the hit area. */
+ .doc-bar{display:flex;align-items:stretch;font-family:var(--font-mono);font-size:var(--size-caption);
+ color:var(--text-muted);background:var(--surface-raised);border-bottom:1px solid var(--border-subtle)}
+ .doc-fold{flex:1;min-width:0;display:flex}
+ .doc-sum{flex:1;min-width:0;list-style:none;cursor:pointer;display:flex;align-items:center;gap:8px;
+ padding:9px 14px;user-select:none}
+ .doc-sum::-webkit-details-marker{display:none}
+ .doc-tools{display:flex;align-items:center;gap:8px;padding:9px 14px 9px 0}
.doc-inline{display:block}
- .doc-inline > .doc-bar{list-style:none;cursor:pointer;display:flex;align-items:center;gap:8px;
- padding:9px 14px;font-family:var(--font-mono);font-size:var(--size-caption);color:var(--text-muted);
- background:var(--surface-raised);border-bottom:1px solid var(--border-subtle);user-select:none}
- .doc-inline > .doc-bar::-webkit-details-marker{display:none}
+ .item-doc:has(.doc-fold:not([open])) > .doc-inline{display:none}
.doc-chevron{color:var(--text-faint);transition:transform var(--dur-1) var(--ease-out);font-size:10px}
- .doc-inline[open] > .doc-bar .doc-chevron{transform:rotate(90deg)}
+ .doc-fold[open] .doc-chevron{transform:rotate(90deg)}
.doc-name{color:var(--text-heading);word-break:break-all}
/* as S3: at phone width the name takes the bar's full width and breaks only
where it must; the actions wrap under it (squeezed, it was one word wide) */
- @media (max-width:600px){.doc-inline > .doc-bar{flex-wrap:wrap;row-gap:4px}
- .doc-name{flex:1 1 calc(100% - 3rem);word-break:normal;overflow-wrap:anywhere}
- .doc-spacer{display:none}}
- .doc-spacer{flex:1}
+ @media (max-width:600px){.doc-bar{flex-wrap:wrap;row-gap:4px}
+ .doc-fold{flex:1 1 100%}
+ .doc-sum{padding-bottom:0}
+ .doc-tools{padding:0 14px 9px}
+ .doc-name{flex:1 1 calc(100% - 3rem);word-break:normal;overflow-wrap:anywhere}}
.doc-act{color:var(--text-muted);text-decoration:none;padding:2px 6px;border-radius:var(--radius-sm);
font-size:13px;line-height:1;background:none;border:0;cursor:pointer;font-family:inherit}
.doc-act:hover{color:var(--text-heading);background:var(--surface-sunken);text-decoration:none}
.doc-close:hover{color:var(--text-heading)}
- .doc-bar .blurtoggle,.doc-bar .flagtoggle{padding:0;background:none}
- .doc-bar .blurtoggle{margin-left:4px}
+ .doc-tools .blurtoggle,.doc-tools .flagtoggle{padding:0;background:none}
+ .doc-tools .blurtoggle{margin-left:4px}
.doc-body{margin:0;border:0;border-radius:0;max-height:32rem;overflow:auto;padding:18px 20px}
.doc-body.textview{background:var(--surface-card);border:0;border-radius:0}
@@ -1111,7 +1168,7 @@
leaves the tile visible; undershooting hides it. JS on, --rail-h is exact
and this changes nothing. */
@media (max-width:480px){
- .item{scroll-margin-top:calc(var(--rail-h, 205px) + 12px)}
+ html:has(.rail){--rail-pad:calc(var(--rail-h, 205px) + 12px)}
}
@media (max-width:600px){
main{padding:20px 16px 40px}
@@ -1197,18 +1254,36 @@
the line goes back to that one's "Saving…". Otherwise "Saved.", the one
message that clears itself, and only if the line still holds that write. */
var CHANGED = 'Saved. You changed it while it was saving, and that change is not saved yet: press again to save it.';
+ /* as S5c: every failure that stays, by form, with the words said when it
+ failed (a one-form failure's STAY, or a batch's refusal), so a later
+ save says them again instead of burying them under "Saved.". */
var unsent = {};
+ function stayed(keys) {
+ var words = [];
+ keys.forEach(function (k) { if (words.indexOf(unsent[k]) < 0) words.push(unsent[k]); });
+ return words.join(' ');
+ }
+ /* as S5c (heid bug-hunt R3): a warning says the failures still standing
+ first, except those of the forms it is about, so a later failure never
+ buries an earlier one. (`liveForms` is below.) */
+ function also(text, keys) {
+ var others = Object.keys(unsent).filter(function (k) {
+ return keys.indexOf(k) < 0 && liveForms(k).length;
+ });
+ var before = stayed(others);
+ return before ? before + ' ' + text : text;
+ }
function liveForms(key) {
return [].filter.call(document.querySelectorAll('form[data-inplace]'),
function (f) { return flightKey(f) === key; });
}
function landed(keys) {
keys.forEach(function (k) { delete unsent[k]; });
- if (keys.some(function (k) { return liveForms(k).some(dirty); })) { warn(CHANGED); return; }
+ if (keys.some(function (k) { return liveForms(k).some(dirty); })) { warn(also(CHANGED, keys)); return; }
var others = Object.keys(pending).some(function (k) { return keys.indexOf(k) < 0; });
if (others) { say('Saving…'); return; }
var failed = Object.keys(unsent).filter(function (k) { return liveForms(k).length; });
- if (failed.length) { warn(STAY); return; }
+ if (failed.length) { warn(stayed(failed)); return; }
var n = say('Saved.');
setTimeout(function () { if (said === n) say(''); }, 2000);
}
@@ -1478,14 +1553,14 @@
var STAY = 'Could not save in place. Reload to see what was saved; your other entries are still here.';
var beat = null;
function fail(form, key, snap) {
- unsent[key] = true;
- if (!mayReload(key, snap)) { warn(STAY); return; }
+ unsent[key] = STAY;
+ if (!mayReload(key, snap)) { warn(also(STAY, [key])); return; }
/* Said, then reloaded after a beat, so the words are readable rather
than a flash before the page goes; asked again at the beat, since a
draft typed in the meantime must not go with it. A press inside the
beat cancels it: that new save owns the page (heid bug-hunt R2). */
warn('Could not save in place — reloading to show what was saved.');
- beat = setTimeout(function () { beat = null; if (mayReload(key, snap)) reload(); else warn(STAY); }, 900);
+ beat = setTimeout(function () { beat = null; if (mayReload(key, snap)) reload(); else warn(also(STAY, [key])); }, 900);
}
/* The POST and the page GET fail differently. A POST that failed did not
save. A POST that answered 204 DID save, even if the GET then failed:
@@ -1497,10 +1572,10 @@
return refresh([{key: key, snap: snap}]).then(function (placed) {
if (placed) { landed([key]); return; }
if (mayReload(key, snap)) { reload(); return; }
- warn('Saved. The page changed meanwhile; reload to see it.');
+ warn(also('Saved. The page changed meanwhile; reload to see it.', [key]));
}, function () {
if (mayReload(key, snap)) reload();
- else warn('Saved. Could not refresh the page; reload to see it.');
+ else warn(also('Saved. Could not refresh the page; reload to see it.', [key]));
});
}, function () { fail(form, key, snap); });
}
@@ -1573,14 +1648,23 @@
? 'Saved ' + saved.length + ' of ' + forms.length + '. Not saved: ' +
refused.join('; ') + '. Nothing else you entered was cleared.'
: '';
+ /* as S5c (booth-dev's note): a refused form stays unsent with the
+ refusal's words, like a one-form failure, so an unrelated save's
+ "Saved." cannot replace them while it is still on the page. The
+ refused forms are the ones the server did not take. */
+ forms.forEach(function (f) {
+ var k = flightKey(f);
+ if (!saved.some(function (x) { return x.key === k; })) unsent[k] = told;
+ });
var shown = saved.length ? refresh(saved, true) : Promise.resolve(true);
+ var mine = forms.map(flightKey);
return shown.then(function (placed) {
- if (!placed) warn((told || 'Saved.') + ' The page changed meanwhile; reload to see it.');
- else if (told) warn(told);
- else landed(forms.map(flightKey));
+ if (!placed) warn(also((told || 'Saved.') + ' The page changed meanwhile; reload to see it.', mine));
+ else if (told) warn(also(told, mine));
+ else landed(mine);
}, function () {
- warn((told || 'Saved ' + saved.length + ' of ' + forms.length + '.') +
- ' Could not refresh the page; reload to see what was saved.');
+ warn(also((told || 'Saved ' + saved.length + ' of ' + forms.length + '.') +
+ ' Could not refresh the page; reload to see what was saved.', mine));
});
});
}
@@ -1620,18 +1704,31 @@
})();
@@ -591,11 +666,11 @@
to the node it replaced goes with that node. `__bound` keeps a node from
being bound twice.
- Inline-doc ✕ closes (hides) a rendered doc for the session. The button sits
- inside , so without this its click would just toggle the
- open/closed — stopPropagation + preventDefault make ✕ mean "close", not
- "collapse". Collapse stays available via the rest of the summary bar. With
- JS off the button is inert and collapse via still works.
+ Inline-doc ✕ closes (hides) a rendered doc for the session. It sits in the
+ bar's tools, beside the fold's summary (as S5c, G7), so its click toggles
+ nothing; stopPropagation + preventDefault are kept as belt and braces.
+ Collapse is the summary's. With JS off the button is inert and collapse via
+ still works.
Blur reveal. WARNING: this handler previously sat after the content block's
closing tag, which in a child template Jinja DISCARDS — the button rendered
@@ -604,10 +679,6 @@
block. Per-viewer and never persisted: a reload re-hides. */
function bindTiles() {
function once(el) { if (el.__bound) return false; el.__bound = true; return true; }
- /* A form inside would otherwise collapse the doc on submit. */
- document.querySelectorAll('.doc-bar .blurtoggle, .doc-bar .flagtoggle').forEach(function (f) {
- if (once(f)) f.addEventListener('click', function (ev) { ev.stopPropagation(); });
- });
document.querySelectorAll('.doc-close').forEach(function (btn) {
if (!once(btn)) return;
btn.addEventListener('click', function (ev) {
@@ -617,16 +688,21 @@
if (item) item.classList.add('is-closed');
});
});
+ /* as S5c (G17): the glyph and the word flip in their own spans, so the
+ name stays the words on screen and the item's .sr-only name stays put */
+ function said(btn, on) {
+ btn.querySelector('.rv-glyph').textContent = on ? '🙈' : '👁';
+ btn.querySelector('.rv-word').textContent = on ? 'hide' : 'reveal';
+ }
document.querySelectorAll('.item.blurred .reveal').forEach(function (btn) {
var fig = btn.closest('.item');
- /* a swap carries `revealed` across (base.html); the label follows it */
- btn.textContent = fig.classList.contains('revealed') ? '🙈 hide' : '👁 reveal';
+ /* a swap carries `revealed` across (base.html); the words follow it */
+ said(btn, fig.classList.contains('revealed'));
if (!once(btn)) return;
btn.addEventListener('click', function (ev) {
ev.preventDefault();
ev.stopPropagation();
- var on = fig.classList.toggle('revealed');
- btn.textContent = on ? '🙈 hide' : '👁 reveal';
+ said(btn, fig.classList.toggle('revealed'));
});
});
}
diff --git a/booth/templates/compare.html b/booth/templates/compare.html
index c6f975b..8078b37 100644
--- a/booth/templates/compare.html
+++ b/booth/templates/compare.html
@@ -31,7 +31,7 @@
{% endif %}
- {% if film | selectattr('blurred') | list %}{% endif %}
+ {% if film | selectattr('blurred') | list %}{% endif %}
@@ -52,7 +52,9 @@
{# Over the stage, never inside its scrolled content; JS-only, so
`hidden` until bound (the review's pattern). #}
- {% if s.blurred %}{% endif %}
+ {# as S5c (G17): the name is the words on it, the side's letter as
+ .sr-only text ("reveal A — blur is cosmetic", then "hide A") #}
+ {% if s.blurred %}{% endif %}
@@ -216,7 +218,9 @@
btn.hidden = false;
btn.addEventListener('click', function () {
var on = stage.classList.toggle('revealed');
- btn.textContent = on ? '🙈 hide' : '👁 reveal — blur is cosmetic';
+ btn.querySelector('.rv-glyph').textContent = on ? '🙈' : '👁';
+ btn.querySelector('.rv-word').textContent = on ? 'hide' : 'reveal';
+ btn.querySelector('.rv-note').hidden = on;
});
});
@@ -235,29 +239,25 @@
});
document.addEventListener('booth:swapped', restate);
- /* THE KEYS (C3). EVERY key here is ignored while focus is in something
- editable and whenever Ctrl, Meta or Alt is held (the review's rule,
- applied to all of them). */
- function isEditable(el) {
- return !!(el && (el.isContentEditable ||
- /^(input|textarea|select)$/i.test(el.tagName || '')));
- }
+ /* THE KEYS (C3). EVERY key here is left to the focused element when it
+ uses it, and to the browser whenever Ctrl, Meta or Alt is held: the
+ review's rule, now one rule for both (as S5c, G6: base.html's
+ BoothKeys). A focused player keeps every key; a focused 1:1 stage pans. */
function step(dir) {
var which = (linked ? 'both' : active) + (dir < 0 ? '-prev' : '-next');
var a = document.querySelector('a[data-step="' + which + '"]');
if (a) go(withState(a.getAttribute('href')));
}
document.addEventListener('keydown', function (e) {
- if (isEditable(e.target)) return;
- if (e.metaKey || e.ctrlKey || e.altKey) return;
+ if (BoothKeys.theirs(e)) return;
var k = e.key;
if (k === 'Escape' || k === 'c' || k === 'C') go(BACK);
else if (k === 'ArrowLeft') step(-1);
else if (k === 'ArrowRight') step(1);
/* Space steps only from nowhere in particular: never from a focused
- control (Space presses it) and never from a player on either stage. */
+ control (Space presses it) and never from a player on either stage;
+ BoothKeys has already left it to them. */
else if (k === ' ') {
- if (e.target.closest && e.target.closest('button, a, summary, video, audio')) return;
e.preventDefault();
step(e.shiftKey ? -1 : 1);
}
diff --git a/booth/templates/doc.html b/booth/templates/doc.html
index f7374c9..8605fb8 100644
--- a/booth/templates/doc.html
+++ b/booth/templates/doc.html
@@ -9,7 +9,7 @@
{# Reveal all can lift this page's blur, so this page must be able to put it
back (r2b, heid bug-hunt). #}
- {% if blurred %}{% endif %}
+ {% if blurred %}{% endif %}
⬇
{# Same record, same reason as the image viewer: the sidecar that says what
@@ -24,7 +24,8 @@
doc's own page rendered clear. Its reveal is per-page and JS-only, like the
review stage's; Reveal all lifts it by the same class. #}
- {% if blurred %}{% endif %}
+ {# as S5c (G17): the name is the words on it, the glyph hidden #}
+ {% if blurred %}{% endif %}
{% if is_html %}
{{ body|safe }}
{% else %}
@@ -64,7 +65,9 @@
rv.hidden = false;
rv.addEventListener('click', function () {
var on = document.getElementById('docbody').classList.toggle('revealed');
- rv.textContent = on ? '🙈 hide' : '👁 reveal — blur is cosmetic';
+ rv.querySelector('.rv-glyph').textContent = on ? '🙈' : '👁';
+ rv.querySelector('.rv-word').textContent = on ? 'hide' : 'reveal';
+ rv.querySelector('.rv-note').hidden = on;
});
}
document.addEventListener('keydown', function (e) {
diff --git a/booth/templates/view.html b/booth/templates/view.html
index cd5043e..d21410b 100644
--- a/booth/templates/view.html
+++ b/booth/templates/view.html
@@ -32,7 +32,7 @@
- {% if film | selectattr('blurred') | list %}{% endif %}
+ {% if film | selectattr('blurred') | list %}{% endif %}
⬇
@@ -61,7 +61,10 @@
outside the stage it can never start a pan. #}
{# JS-only, so `hidden` until the script binds it (heid bug-hunt: shown with
scripts off, it did nothing) — the toggle's own pattern. #}
- {% if blurred %}{% endif %}
+ {# as S5c (G17): the name is the words on it; the glyph is hidden and the
+ file rides as .sr-only text ("reveal a.png — blur is cosmetic", then
+ "hide a.png"). The script flips the spans, never the whole text. #}
+ {% if blurred %}{% endif %}
{% if next_url %}›{% endif %}