Skip to content

Popup-only footnotes with print fallback#642

Merged
srid merged 19 commits intomasterfrom
footnote-popups
Apr 22, 2026
Merged

Popup-only footnotes with print fallback#642
srid merged 19 commits intomasterfrom
footnote-popups

Conversation

@srid
Copy link
Copy Markdown
Owner

@srid srid commented Apr 22, 2026

Footnote refs open a popup — that's the only on-screen UI. No #fn1 anchor, no visible bottom list, no scroll-away. The <sup> is the click target (role=button, Enter/Space activates). Desktop gets a floating card; mobile gets a bottom-sheet. For print, the hidden <aside> flips to print:block with a decimal list so paper copies still carry the cited bodies.

The popup binds to a data-footnote-* contract (ref, id, list, scope), not Pandoc class names. Scope routing handles callouts and embedded notes as their own footnote boundaries — each one can legitimately carry a fn1.

Native HTML Popover API; no dep. Popover chrome styled entirely via inline Tailwind classes applied in JS; the only CSS rule that survives is ::backdrop { background: transparent } (Tailwind has no variant for that pseudo-element).

Verification

  • Chrome-mcp real-click walk on docs/guide/markdown/embed.html — all refs (parent / callout / embed) route to correct bodies, scrollY stays 0, no hash.
  • e2e (cucumber + playwright) covers all three popup paths plus a new print-mode scenario (page.emulateMedia({ media: "print" }) → at least one aside visible, body content present).
  • Vira CI green on 2a7c9238.

Try it locally

nix run github:srid/emanote/footnote-popups -- -L ./docs run

srid added 16 commits April 21, 2026 21:22
Footnote refs now open a popover with the cited body instead of
scrolling the reader away. Behavior is identical on desktop and
mobile per the spec — on small screens the popover slides up as a
bottom sheet; on wide screens it anchors above or below the ref.
The bottom <aside> list stays rendered as the no-JS / print fallback.

The lookup seam is a deliberate data-attribute contract
(data-footnote-ref / data-footnote-id / data-footnote-list /
data-footnote-embed), not Pandoc's .footnote-ref / .footnote-list
class names — those are stable-by-stasis, not by encapsulation, so
any Pandoc class-name churn would silently break the popup. The
closest([data-footnote-embed]) walk routes refs inside an embedded
note to that embed's own <aside>, and top-level refs to the
non-embedded aside; both can legitimately contain the same
numeric id since Pandoc numbers footnotes per document.

The popup uses the native HTML Popover API — auto-dismiss, Esc to
close, and accessibility come for free. No dependency added. If
the browser lacks Popover support, the click handler is never
installed and anchor navigation resumes.

e2e coverage (cucumber+playwright) exercises all three paths on
the fixture notebook: parent body, inside a callout, inside an
embedded note. Manual chrome-mcp check lands on
docs/guide/markdown/embed.md, which now carries one footnote per
context.
A callout renders its body via pandoc, which emits its own
<Note:List> for footnotes cited inside. So the "first top-level
aside" rule picked the callout's aside for a parent-level ref and
served the wrong body — caught by a chrome-mcp walk over
docs/guide/markdown/embed.html, confirmed by inspecting the three
<aside data-footnote-list> elements on that page.

Generalize the attribute from data-footnote-embed to
data-footnote-scope and apply it to both embed sections and callout
divs. findTarget() now looks up the ref's closest scope (callout,
embed, or none), then finds the aside owned by that exact scope
(nearest-scope-ancestor === self). Nested scopes no longer leak
across.

e2e: callout scenario switched from "index 2 in parent body" to
"index 1 inside a callout" to match the callout's own per-scope
numbering. New "inside a callout" step targets [data-callout]
refs; the "inside an embedded note" step is tightened to
section[data-footnote-scope] so it doesn't match a callout.
@srid
Copy link
Copy Markdown
Owner Author

srid commented Apr 22, 2026

Hickey/Lowy Analysis

# Lens Finding Disposition
1 Hickey Mobile breakpoint duplicated in CSS and JS Fixed in this PR
2 Hickey popoverEl + currentRef two-variable state No-op
3 Hickey Stale getBoundingClientRect on first click Fixed in this PR
4 Hickey Silent showPopover() failure Fixed in this PR
5 Hickey cloneContent stripped by Pandoc class, not data attribute Fixed in this PR
6 Hickey Unused vh variable in positionPopover Fixed in this PR
7 Lowy Data-attribute contract as the Heist↔popup seam No-op (correct)
8 Lowy <style> + <script> colocated in one .tpl No-op (correct)
9 Lowy Runtime closest() vs compile-time scope-id prefix No-op (correct)
10 Lowy Active-state CSS uses Pandoc class instead of data attribute Fixed in this PR

Hickey rationale

Five things live cleanly in footnote-popup.tpl: the data-attribute contract, scope router, popover lifecycle, viewport-aware positioning, and the Popover-API guard. None mix into one another at the module level.

The duplicated mobile breakpoint (CSS @media (max-width: 640px) and JS matchMedia('(max-width: 640px)')) is two representations of the same fact with nothing enforcing sync. The fix caches a single MediaQueryList at module top and documents the coupling. The first-click off-center bug was getBoundingClientRect firing before browser layout after showPopover() inserted new content; deferring positionPopover to requestAnimationFrame closes it. The vh dead code, the silent showPopover() failure, and the a.footnote-backref class-name leak in cloneContent were all quick to fix — and the backref fix completes the data-attribute abstraction the PR explicitly set out to establish.

The one no-op — popoverEl / currentRef as a two-variable state machine — becomes risky only under partial-page updates, which Emanote's live-server reload model doesn't produce.

Lowy rationale

The data-attribute seam is the right volatility boundary: the markup layer emits semantic intent; the presentation layer reads it. Replacing click-to-open with hover-to-open or a sidebar panel touches only footnote-popup.tpl; the four emitters remain unchanged. Colocating style + script in one .tpl is also right — their axes of change are identical (both evolve when the popup presentation evolves, both are removed together if the feature is removed). A runtime closest() walk for scope routing beats pushing per-scope unique-id prefixes up into heist-extra: embedding-depth rules are an Emanote-level concern, not a generic Heist concern.

The one real coupling leak Lowy flagged (beyond the Hickey-caught backref strip) was sup.footnote-ref.emanote-footnote-active a in the active-state CSS — a Pandoc class mixed with an Emanote-owned class. Tightened to sup[data-footnote-ref].emanote-footnote-active a so the whole contract stays within Emanote's own attribute vocabulary.

@srid
Copy link
Copy Markdown
Owner Author

srid commented Apr 22, 2026

/do results

Step Status Duration Verification
sync 1s git fetch ok; forge=github; noGit=false
research 0s Mapped footnote rendering (pandoc.tpl:70-84), heist-extra splice API (Footnotes.hs:42-61), embed/callout templates, e2e structure. Identified embed-scope collision and refined attribute contract.
branch 2s Created branch footnote-popups off origin/master.
implement 3m 2s Added data-footnote-* attrs, new components/footnote-popup.tpl with scope-aware lookup + Popover-API open + mobile bottom-sheet. e2e fixtures + 3 scenarios + 3 steps. [^parent] footnote to docs/guide/markdown/embed.md for chrome-mcp check.
check 6s cabal build all: Up to date.
docs 17s Documented popup behavior in docs/guide/markdown.md.
fmt 11s pre-commit clean: cabal-fmt, fourmolu, hlint, nixpkgs-fmt.
commit 20s Pushed feature branch with primary feature commit.
hickey+lowy 5m 26s Hickey: 6 findings (5 Fix, 1 No-op). Lowy: 5 seams (2 Fix, 3 No-op). 6 follow-up commits.
police 4m 34s Rules: 2 fixes. Fact-check: 1 fix. Elegance: 5 refactors. 8 commits.
test 7m 49s Chrome-mcp walked 4 refs on /embed.html: parent/callout/embed scopes all routed correctly. Mobile bottom-sheet verified at 400×700; desktop popover at 1280×720. Found + fixed a callout-scope bug. e2e scenarios added.
create-pr 1m 43s Draft PR #642 + hickey/lowy analysis comment.
ci 1m 6s Vira CI passed against HEAD e9d435b. Built 5 packages incl. link-check (250 internal links).
Total 29m 27s

Slowest step: test (7m 49s).

Optimization suggestions

  • test dominated at 7m 49s because chrome-mcp surfaced a real bug (callouts also needing data-footnote-scope), which pushed an extra edit-commit-reverify cycle into this step. That's the test step doing its job — no change recommended. The alternative (catching it only in e2e during ci) would have been longer since the fix would have landed during ci retry-mode.
  • hickey+lowy at 5m 26s ran both sub-agents in parallel; individual agent wall-clock was ~3m each. If both had to re-run on a future touch, --review-model=haiku would trim this at the cost of shallower critique.
  • police at 4m 34s is close-to-floor for a change of this size; most of the cost is the three passes (rules + fact-check + elegance). No slack to cut without dropping a pass.
  • create-pr at 1m 43s includes a backtick-escape fix round-trip (gh pr edit). Worth remembering: single-quoted heredoc is load-bearing, but writing backticks inside the heredoc must stay bare — escaping them as ``` produces literal backslashes.

Workflow completed at 2026-04-21 21:43 UTC.

…lash

Footnote refs no longer render as anchors, so there is no #fn1 /
#fnref1 navigation for the browser (or the live-server) to scroll to.
The <sup> itself is the click target, with role="button" / tabindex=0
/ aria-label and a keyboard handler (Enter, Space) to match.

The bottom <aside> is still emitted so the popup has a place to clone
content from, but carries the HTML `hidden` attribute and aria-hidden,
so it never renders on screen — the popup is the only footnote UI.

Dead CSS removed: the :target flash keyframe in styles.tpl targeted
`ol.footnote-list li:target` and `sup.footnote-ref:target a`, both of
which are now unreachable (no fragment navigation, no inner anchor).

Popover recreation is now defensive against Emanote's live-server
DOM patches: `ensurePopover` checks `isConnected` and re-attaches if
a patch detached the previously cached element. `findTarget` does
the same check for the cached top-level aside.
@srid srid marked this pull request as ready for review April 22, 2026 12:01
Comment thread docs/guide/markdown/embed.md Outdated
> [!warning]
> The embed wiki-link syntax must appear on a paragraph of its own, with no other text added next to it.[^blk] Recursive embeds are supported.

[^parent]: A page-level footnote, rendered into the bottom list and also shown in the click-anywhere popup. The popup is a progressive enhancement — the bottom list stays usable when JS is off or on print.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove this outdated footnote.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed in 2a7c923 — the copy described a progressive-enhancement fallback that no longer exists (the popup is the only on-screen UI now).

@srid srid changed the title Click-to-open popup footnotes, scope-aware on desktop and mobile Popup-only footnotes: no #fn1, no bottom list, click or keyboard Apr 22, 2026
popup clone. Bound to data-footnote-ref / data-footnote-id /
data-footnote-list / data-footnote-scope. -->

<style data-category="footnote-popup">
Copy link
Copy Markdown
Owner Author

@srid srid Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fact check if we stricktly need all these styles in this file and that they cannot be done in tailwind itself.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Audited in 2a7c923. Popover chrome, body styling, active-state, focus-visible, and the mobile bottom-sheet shape all moved to inline Tailwind utilities (applied via JS className for the popover/body, inline on the <sup> for state variants). The .emanote-footnote-popup--mobile class-toggle is gone — max-sm:* variants do it viewport-conditionally. The only CSS that survives is one rule: #emanote-footnote-popover::backdrop { background: transparent; }. Tailwind has no variant for the Popover ::backdrop pseudo-element, and expressing it any other way would be strictly worse than one line of CSS.

…sync

Addresses PR review:

- Popover chrome and body styling move from the <style> block to
  inline Tailwind utilities applied via JS `className` (`POPOVER_CLASS`
  / `BODY_CLASS`). Only the Popover `::backdrop` pseudo-element
  remains in `<style>` — Tailwind has no variant for it.
- Active-state and focus-visible states on the <sup> ref collapse
  into inline variant classes (`[&.emanote-footnote-active]:*`,
  `focus-visible:*`), matching the rest of components/pandoc.tpl.
- Mobile bottom-sheet layout drops its `.emanote-footnote-popup--mobile`
  class toggle: `max-sm:*` variants on the popover + body are
  viewport-conditional by construction, and `positionPopover` still
  clears inline top/left on mobile so the utility-driven layout isn't
  overridden by stale desktop positioning.

Separately, footnotes now render in print mode: the <aside> emits
`class="hidden print:block …"` with a decimal-list header, so
printed output carries the cited bodies even though the popup is
the only on-screen UI. e2e suite gains a scenario that asserts no
<aside> is visible on screen and at least one becomes visible under
`page.emulateMedia({ media: 'print' })`, plus a content check.

Outdated `[^parent]` footnote on docs/guide/markdown/embed.md
(reviewer: "Remove this outdated footnote") dropped; its copy
described the now-removed progressive-enhancement fallback.
@srid srid changed the title Popup-only footnotes: no #fn1, no bottom list, click or keyboard Popup-only footnotes with print fallback Apr 22, 2026
@srid srid merged commit 227e5de into master Apr 22, 2026
4 checks passed
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.

Footnotes and embedding: duplicate HTML element ids Better footnotes UX

1 participant