Skip to content

Keep glossary popovers inside the viewport, and gate reflow at 380px - #20

Merged
systemslibrarian merged 1 commit into
mainfrom
claude/crypto-lab-catalog-audit-f30zcn
Oct 1, 2026
Merged

systemslibrarian merged 1 commit into
mainfrom
claude/crypto-lab-catalog-audit-f30zcn

Conversation

@systemslibrarian

Copy link
Copy Markdown
Owner

What was wrong

I built main (f8cc01a) and served it at /crypto-lab-bulletproofs/. At 380x800 in Chromium, document.documentElement.scrollWidth was 561 (clientWidth 380). It stayed 561 in every state I drove: first paint, proof generated, verifier accepted, the attack panels, export/import accepted, import failed, and the aggregate and benchmark tables.

Culprit: span.gloss-pop, 304px wide, right edge at 561. Several others sat at 501, 440, 417 and so on.

Why it happened:

  • Every glossary popover is position:absolute and is hidden with visibility:hidden. A hidden box like that still takes part in layout.
  • Under 768px the CSS anchors each popover at its term's left edge (left:0, max-width:80vw). A term near the right of a line therefore pushed its hidden popover past the viewport.
  • The existing JS anchoring, positionGloss, could not catch this. It only ran on hover or focus. It also chose gloss-left / gloss-right from the term's centre, which assumes the desktop centred layout and not the narrow-viewport left anchor. So a popover could still run off the edge even while it was visible.

Fix

Plain CSS cannot fix this: where a popover lands depends on where its term wraps.

  • src/app.ts:
    • New fitAllGlosses() measures every popover and shifts it horizontally into [8, vw - 8] by setting a --gloss-dx custom property. It resets all shifts, reads all rects, then writes them, so it costs one layout pass.
    • A MutationObserver re-runs it, batched to one call per animation frame, whenever glossary terms are rendered. It also re-runs on resize.
    • The block is skipped when MutationObserver or requestAnimationFrame is missing. The linkedom smoke scripts in npm test have neither.
    • positionGloss() now measures the popover itself in the same way, instead of guessing from the term's centre.
  • src/style.css: added translate: var(--gloss-dx, 0) 0 on .gloss-pop. This is the separate translate property, so it combines with the existing transform: translateX(-50%). No other styling or theme changes.

After the fix: scrollWidth is 380 / 380 in every state above. I also focused each of the 15 glossary terms in turn at 380, 800 and 1280px wide. Every visible popover stayed fully on-screen.

New guard

  • e2e/reflow.ts: added expectNoHorizontalOverflow(page, label), adapted from crypto-lab-world-ciphers/e2e/gate.ts. When it names the culprit, it accounts for the containing block of absolutely positioned boxes.
  • e2e/a11y.spec.ts: added a reflow at 380px block with four tests:
    • first paint
    • the proof journey, the attack panels and the import/export states (the same states the axe scans drive)
    • the aggregate and benchmark result tables
    • every glossary term focused in turn: the page must not scroll, and the visible popover must sit inside both edges

The check fails without the fix

  • I reverted src/app.ts and src/style.css with git checkout. The reverted diff was non-empty: 90 lines.
  • With the revert, all 4 reflow tests failed with scrollWidth: 561, widest: span.gloss-pop @304px right=561.
  • With the fix restored, all 4 pass.

Local gate

  • npm test: all 10 scripts passed. That covers 127 PASS lines in the linkedom smoke and a11y scripts and every KAT suite, with 0 FAIL.
  • Build (tsc && vite build): passed.
  • Full Playwright suite with CI=1: 24 passed, which is the existing 20 plus 4 new. I used an uncommitted config to point Playwright at the local Chromium.

One caveat about the timeout. I ran the suite locally with --timeout 150000. On this machine the existing no WCAG A/AA violations in the result tables test takes longer than Playwright's default 30s timeout, and it fails that way on unmodified main too. On CI it takes about 14s; the last main run passed 20/20. The new tables reflow test runs the same benchmark, so it costs about the same on CI.

There are no visual snapshots in this lab.

🤖 Generated with Claude Code

https://claude.ai/code/session_01P9wNFmWChS4HdDbqrEAq86


Generated by Claude Code

At 380px the page scrolled sideways to 561px on first paint. Every
.gloss-pop is position:absolute and hidden with visibility, which still
occupies layout; under 768px the CSS anchors each at its term's left edge
with max-width 80vw, so a term near the right of a line pushed its hidden
popover past the viewport. The hover/focus anchoring could not help: it
ran only on interaction, and chose gloss-left/gloss-right from the term's
centre, which disagreed with the narrow-viewport CSS, so a visible popover
could run off the edge too.

fitAllGlosses() now measures every popover and shifts it into
[8, vw - 8] through a --gloss-dx custom property (applied with the
individual `translate` property, so it composes with the existing
transform), re-run when glossary terms render and on resize.
positionGloss() measures the popover itself the same way.

The a11y suite gains a 380px reflow block: first paint, the proof and
attack journey, the result tables, and every glossary term focused in
turn. With the fix reverted all four tests fail on
span.gloss-pop right=561; with it in, all pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9wNFmWChS4HdDbqrEAq86
@systemslibrarian
systemslibrarian merged commit d523ff4 into main Oct 1, 2026
3 checks passed
@systemslibrarian
systemslibrarian deleted the claude/crypto-lab-catalog-audit-f30zcn branch October 3, 2026 03:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants