Skip to content

refactor: rebuild the contents drawer on the Popover API - #144

Merged
mmcky merged 9 commits into
mainfrom
refactor/toc-popover
Sep 4, 2026
Merged

refactor: rebuild the contents drawer on the Popover API#144
mmcky merged 9 commits into
mainfrom
refactor/toc-popover

Conversation

@DrDrij

@DrDrij DrDrij commented Aug 21, 2026

Copy link
Copy Markdown
Member

Rebuilds the contents drawer on the Popover API, replacing the React state, the transform classes, and the critical-CSS rule that kept the panel off-screen.

Why

The drawer's open/closed state lived in React (useNavOpen) while its visibility came from -translate-x-full. Neither exists on the first paint of a static-build navigation, so the panel rendered in flow and visible, then corrected itself once app.css arrived. #123 patched that with a zero-specificity rule in CRITICAL_CSS; this removes the failure mode instead.

A closed popover is display: none by the UA stylesheet. That holds on the very first paint with no author CSS at all, and it comes with the closed panel's links out of the tab order and the accessibility tree, plus Escape, light dismiss and the invoker's aria-expanded — none of which we now maintain.

Because the browser owns the toggle, the drawer also works on the server-rendered HTML before hydration. There is a test for that specifically (see below).

Behaviour change worth flagging

popover="auto" means clicking outside the drawer now closes it, and Escape closes it. Previously it stayed open until the button was pressed again. This reads as an improvement on mobile, but it is a change, not a like-for-like port.

Browser support — a deliberate trade

Browsers without the Popover API (Firefox < 125, Safari < 17, which includes anything that cannot upgrade past iOS 16) get no drawer: the @supports block hides the panel and the toggle, so there is no button that looks operable but is not.

A polyfill would restore it and is not a drop-in — :popover-open is a pseudo-class and cannot be polyfilled, so every rule keyed on it needs a class-based stand-in, and the polyfill only runs after first paint, which reintroduces the flash this removes. The in-page outline and site navigation still reach every page on those browsers. Happy to revisit if that share matters more than assumed.

Firefox 125–128 has popover but not @starting-style: the drawer opens without sliding, which degrades cleanly.

Also included

Two toolbar fixes found while testing this at narrow widths, both pre-existing on main:

  • The QuantEcon logo distorted between roughly 770px and 840px. It has a pinned height and an auto width, and preflight's max-width: 100% let it clamp to a shrinking flex item. shrink-0 pins it.
  • With the logo pinned, the real bug surfaced: the full desktop control set switches on at md (768px) but its intrinsic width is ~856px, so the last icons were pushed off the right edge. Gap, separator and container padding now widen at lg rather than md, returning ~148px to that band.

Measured 360–1400px afterwards: aspect holds at 3.5 throughout, no overflow at any width, 58px spare at the tightest point — enough for the 32px the live-compute toggle adds on notebook pages.

Tests

Full suite green locally on macOS (20 passed, 4 skipped). CI renders against the linux baselines, which may need /update-snapshots; no new screenshot baselines are introduced by this PR.

  • drawer-opens-without-javascript is new and guards the reason for the rewrite. Every other test runs with JS on and would pass against a React-state implementation, so without it an onClick or a client-only wrapper could take the property back silently. Verified it can fail: with popovertarget stripped from the served HTML the drawer stays shut and the assertion goes red.
  • fouc.spec.ts now asserts the drawer paints nothing in both cases, including the control with every stylesheet stripped — it is the UA stylesheet doing the hiding, not anything we ship.
  • The no-thebe sanity check was matching the drawer's link to the notebook page rather than its heading, and only counted as visible because the closed panel used to be rendered off-screen. Re-anchored to the heading; it was testing the wrong element regardless.

Suggested follow-up on open PRs

#135 — obsolete, suggest closing. It removes a stray unprefixed w-[250px] so the width bands are decided by intent rather than Tailwind's emission order (#130). This deletes that class string entirely; the widths are declared once in styles/app.css, so the ordering dependency is gone rather than disambiguated.

#141 — partly obsolete, suggest reducing rather than closing. Its #127 half (the toggle icons animating their own correction) no longer applies: the icons swap with display off the drawer's :popover-open state, there is no transition-all and no useMounted. But the other two pieces are not covered here and are still wanted:

  • Outline.tsx / BackToTop has the same fade-on-first-paint shape and is untouched by this PR.
  • The FOUC control race — hydration restoring the stripped inline <style> around 195ms in — is a real CI-flake fix and is independent of the drawer.

If #141 lands first this will conflict, since it adds app/hooks/useMounted.tsx for a hook this removes the last drawer use of.

@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-09-04 23:36 UTC

@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

🎭 Visual regression results

passed  19 passed
skipped  5 skipped

Details

stats  24 tests across 1 suite
duration  41.6 seconds
commit  094cc05

Skipped tests

mobile-chrome › theme.spec.ts › QuantEcon theme — visual regression › without JavaScript › drawer-opens-without-javascript
mobile-chrome › theme.spec.ts › QuantEcon theme — visual regression › drawer-closes-when-search-opens
mobile-chrome › theme.spec.ts › QuantEcon theme — visual regression › launch-colab
mobile-chrome › theme.spec.ts › QuantEcon theme — visual regression › live-compute-toggle
mobile-chrome › theme.spec.ts › QuantEcon theme — visual regression › live-compute-toggle-absent-without-thebe

@mmcky

mmcky commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Reviewed the diff in full and ran a Playwright probe against the preview (opens on click, closes on outside click and Escape, drawer rect 0,50 → 250×750 as designed). Code quality is well above the bar — the translate + display/overlay allow-discrete + @starting-style trio is the right recipe, the height: auto reset for the UA fit-content default is a real gotcha caught, theme(screens.*) properly addresses #130, and drawer-opens-without-javascript is exactly the guard this design needs. Approving on that basis; CI is green and no baselines moved. Three things to settle before merge:

1. Browser-support trade — accepted, but document it. Safari < 17 / iOS 16 and Firefox < 125 get no drawer and no toggle. The reasoning (a polyfill reintroduces the flash) is sound and I accept the trade, but please add a line to the [Unreleased] changelog so the lecture repos know before they repoint at this version.

2. Please confirm drawer state across client-side navigation. In the probe, clicking a link inside the drawer lands on the new page with the drawer closed. On main the open state lived in UiStateProvider and survived a Remix client-side navigation, so a reader could keep the TOC open while hopping between lectures. The preview is a static export (the URL gained a trailing slash, i.e. a full load), so I could not tell whether this is a real regression under the SPA-hydrated build the lecture sites run. If it is lost, it is a UX change beyond the light-dismiss one already flagged and should be called out (or restored).

3. Minor wording. The comments say the browser "supplies aria-expanded". It supplies the expanded state in the accessibility tree — the DOM attribute reads null (confirmed in the probe). Harmless, but worth rewording so nobody later "fixes" a missing attribute.

Follow-ups — agree with both suggestions. #135 is fully obsolete and I am closing it. #141 is being rescoped to its Outline/BackToTop and FOUC-control halves; it will rebase after this lands since the drawer no longer needs useMounted.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR rebuilds the contents drawer as a native HTML Popover (popover="auto") instead of a React-state-driven panel toggled via -translate-x-full. The browser now owns the open/closed state, which eliminates the first-paint FOUC that #123 patched with a zero-specificity critical-CSS rule, and comes with Escape, light-dismiss, focus management, aria-expanded, and removal-from-tab-order "for free". A closed popover is hidden by the UA stylesheet, so the drawer no longer depends on author CSS to stay off-screen — and it works on server-rendered HTML before hydration. Browsers lacking the Popover API get no drawer (both panel and toggle are hidden via @supports), a deliberate tradeoff. Two pre-existing toolbar layout bugs at narrow desktop widths are also fixed.

Changes:

  • Replace React useNavOpen/transform-class drawer with a Popover: new shared-ID module (contentsDrawer.ts), popover/popovertarget wiring, and all presentation moved to .qe-toc in styles/app.css (widths, slide via @starting-style/allow-discrete, icon swap off :popover-open, @supports fallback).
  • Remove the now-unnecessary .qe-contents-sidebar critical-CSS rule and the useSidebarHeight/inset plumbing; add popover.d.ts typings for React 18.
  • Toolbar fixes: shrink-0 on the logo and shifting gap/separator/padding from md to lg; tests updated for the popover (new no-JS test, rewritten FOUC assertions, re-anchored no-thebe check).

Reviewed changes

Copilot reviewed 9 out of 10 changed files in this pull request and generated no comments.

Show a summary per file
File Description
app/components/contentsDrawer.ts New module holding the shared popover id and heading id so panel and toggle agree without importing from each other.
app/components/ContentsSidebar.tsx Drawer becomes a popover="auto" div keyed on .qe-toc; drops React state and sidebar-height hooks; heading is now an <h2> labelling the <nav>.
app/components/toolbar/SidebarToggle.tsx Button uses popovertarget (no onClick/state); icons are aria-hidden and swapped via CSS from the drawer's :popover-open state.
app/components/NavigationAndArticleWrapper.tsx Removes useSidebarHeight, the container ref, and the inset prop now that height is CSS-driven.
styles/app.css Adds the full .qe-toc component layer: fixed positioning, responsive widths via theme(screens.*), slide transition, reduced-motion handling, focus outline, icon swap, and Popover-unsupported fallback.
app/root.tsx Drops the .qe-contents-sidebar critical-CSS rule and updates the explanatory comment (UA stylesheet now hides the closed popover).
app/popover.d.ts New ambient module augmentation adding popover/popovertarget/popovertargetaction to React 18 JSX types.
app/components/toolbar/Toolbar.tsx shrink-0 on the logo item and moves gap/separator/container padding from md to lg to fix distortion/overflow in the ~770–856px band.
tests/visual/theme.spec.ts Re-targets the toggle by accessible name, adds a no-JavaScript drawer test, and re-anchors the no-thebe check to the heading.
tests/visual/fouc.spec.ts Rewrites the drawer assertions to expect a hidden (UA-display:none) popover in both the guard and control cases; removes the off-screen epsilon logic.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

DrDrij added 7 commits August 25, 2026 09:50
Every bug this panel has produced — the flash on load, the animated
correction, the platform-dependent guard assertion, the icon fade in
#127, the width trap in #130 — came from one premise: the panel was
always rendered and always painted, with visibility expressed as a
transform that only exists once app.css has loaded. That guarantees an
unstyled frame where it is visible and a correction afterwards that a
transition will animate. Each fix so far has patched a symptom of it.

A popover is hidden by the UA stylesheet while closed, so it is already
invisible on the very first paint with no author CSS at all. There is no
unstyled frame to correct, and so nothing for a transition to animate.
The failure mode stops existing rather than being guarded against.

What this removes:

- `useNavOpen` from both components. There is no open/closed state in
  React at all now, so the drawer works before hydration and the button
  cannot desync from the panel.
- The `useMounted` transition gate — nothing left to gate.
- The `.qe-contents-sidebar` critical-CSS rule and its sync-list entry.
- `useSidebarHeight` from both call sites. It bails unless one call site
  holds both its refs; ours each attached one, so both instances
  early-returned on every scroll event and did nothing.
- The duplicate width utilities, so #130 cannot recur — the three widths
  are expressed once in CSS with no dependence on emission order.
- The icon cross-fade, replaced by a `display` swap driven off
  `:popover-open` via `:has()`, so it cannot animate on first paint.

Behaviour the browser now supplies for free: Escape, click-outside
dismissal, the closed panel kept out of the tab order and the
accessibility tree, and the invoker's expanded state (verified in the AX
tree as false -> true). Both icons are aria-hidden and the button holds
the single accessible name; previously each icon carried its own label,
so a screen reader could announce both "Show" and "Hide" for one control.

Verified against a real build in Chromium and WebKit: hidden on an
unstyled first paint (0 boxes, display none), nothing animates when
app.css arrives, slides 18-19 frames to x=0 on open, sized 250x750 in
both, Escape and light dismiss both close it. The existing visual
baselines pass untouched in both projects, so the rendered result is
pixel-identical to what it replaces; test:fouc passes 2/2.

Two details worth keeping in mind. The UA stylesheet sizes popovers
`fit-content` in both axes, so height has to be reset to `auto` or the
panel stops at its content instead of stretching to `inset-block`. And
React 18's JSX types predate the API, so `popover`/`popovertarget` need
the declarations in app/popover.d.ts (react-dom renders them correctly —
both survive renderToString on 18.3.1).

Non-supporting browsers (iOS <= 16, Firefox < 125) treat the attribute
as inert and would render the panel permanently open, so it is hidden
outright there via @supports; loading a popover polyfill would restore
it if that tail matters.
Closing the drawer with Escape returns focus to the toggle, and the
browser matches :focus-visible because the interaction was a keypress.
The UA default outline is a tight 1px square that reads as a rendering
glitch against the toolbar, so restyle it in the theme's blue with an
offset and a small radius. Removing it was the alternative, but that
leaves keyboard users on an unmarked control (WCAG 2.4.7); pointer users
never see it, since a click does not match :focus-visible.
Two related layout bugs in the band just above the md breakpoint.

Every desktop control switches on at 768px, but at 20px spacing the
row's intrinsic width is about 856px, so between 768 and 856 the flex
row had no slack. The only item that could absorb it was the logo: an
img with a pinned height and an auto width, which Tailwind's preflight
also gives max-width: 100%. It shrank with its flex item and the fixed
height then distorted the aspect ratio, down to 0.93 against a natural
3.5 at 770px.

Pin the logo with shrink-0 so it can never distort, and widen the item
gap, the actions-cluster separator and the container padding at lg
rather than md, which returns about 148px to the narrow band. Without
that second half, shrink-0 alone just moves the deficit to the end of
the row and pushes the last icons off the right edge.

Measured 360-1400px: aspect holds at 3.5 throughout, no overflow at any
width, and 58px spare at the tightest point — enough for the 32px the
live-compute toggle adds on notebook pages.
The check that the notebook page actually loaded used
getByText("Notebook outputs").first(), which resolved to the contents
drawer's link to that page rather than its heading. It passed only
because the closed drawer used to be rendered and translated off-screen,
which Playwright counts as visible; as a popover the closed drawer is
display: none, so the link is correctly hidden and the assertion failed.

Match the heading instead, which is what the check was always after.
Review follow-ups on the popover drawer.

The @supports fallback hid the panel on browsers without the Popover API
but left the toggle visible, so those users got a button with an
accessible name that did nothing when pressed. Hide the toggle with the
panel: an absent control is better than a dead one. Its comment also
claimed a polyfill in entry.client.tsx restored the drawer there — no
such polyfill exists, and adding one is not a drop-in, since
:popover-open is a pseudo-class and cannot be polyfilled. The comment
now states the trade-off and what reversing it would cost, instead of
describing work that was never done.

The affected set is Firefox < 125 and Safari < 17, which includes every
device that cannot be upgraded past iOS 16. Those browsers keep the
in-page outline and the site navigation, so content stays reachable.

Also:
  - the panel no longer imports its id from the button that opens it;
    both take it from a shared module
  - the drawer's <nav> is labelled by its own heading rather than
    repeating the button's "Table of contents", which screen readers
    announced twice
  - the lg/2xl breakpoints come from theme(screens.*) rather than being
    transcribed as 1024px/1536px, which would desync silently
  - reduced motion drops the transition entirely instead of shortening
    it to 1ms; display/overlay only need transitioning so the slide can
    outlast display:none, and under the preference there is no slide
The reason the drawer is a popover is that the browser owns the toggle,
so it works on the server-rendered HTML before or without hydration —
that is what removes the open/closed flash. Every other test runs with
JavaScript on, where a React-state implementation passes identically,
so the property was undefended: adding an onClick or a client-only
wrapper would have lost it silently.

Verified the test can fail: with the markup as shipped it passes, and
with popovertarget stripped from the served HTML the drawer stays shut
and the assertion goes red.
The comments explained the change rather than the code — how the old
implementation behaved, what was measured, why one approach was rejected.
That belongs in commit messages and PR history, not in files someone
reads to make the next change.

Cut to the constraints that still bind: what must stay in sync, what
must not be added, what breaks if a rule is dropped. Roughly 70 lines
removed, and the drawer's CSS commentary is about half its previous
size.

Also drops the `inset` prop from NavigationAndArticleWrapper. It existed
only to feed useSidebarHeight, no caller ever passed it, and with that
hook gone it was dead plumbing kept alive by the comment above it.
@mmcky
mmcky force-pushed the refactor/toc-popover branch from 21710d1 to 75c2312 Compare August 24, 2026 23:50
@mmcky

mmcky commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Detailed review of the updated PR (multi-agent pass over the full diff, with the load-bearing claims verified against the built Tailwind output, the installed @myst-theme/site sources, and the Playwright config — not just read from the diff).

First: this is a genuinely well-engineered change. The popover design is the right altitude for the FOUC class of bug, the trade-off commentary in the code and PR body is unusually honest, and the drawer-opens-without-javascript test guards exactly the property that justifies the rewrite. Verified clean along the way: all .qe-toc* rules (including theme(screens.*) in media queries, @starting-style, @supports, and the body:has() icon swap) survive to the minified CSS; dark mode matches (.dark on <html>, popovers keep their DOM position); every removed API (useNavOpen, useSidebarHeight, inset, .qe-contents-sidebar, the old aria-labels) has zero remaining consumers; popover.d.ts is genuinely needed on @types/react 18.3.28 and self-enforces its own deletion on React 19; and the md→lg toolbar spacing changes are invisible to both snapshot viewports, so the untouched baselines are legitimately valid.

The one blocker

The open drawer paints above the search dialog. As a popover the drawer lives in the browser top layer, which paints above every z-indexed element. The search hotkey is a global keydown (Search.tsx:616 upstream), so the popover's light dismiss — which fires on pointerdown — never runs: open the drawer, press Cmd/Ctrl+K, and the Radix dialog (overlay z-[1000], panel z-[1001]) opens under the still-open drawer. Below ~1090px viewport width the drawer physically covers the dialog's left edge, un-dimmed above the modal backdrop, and because the dialog holds a focus trap with body pointer-events disabled, the visible drawer links are dead. Pre-PR the z-[20] drawer was correctly covered. The same inversion applies in principle to tooltips (z-10) and any portal overlay shown while the drawer is open, but search is the one a reader will actually hit. Two plausible fixes: close the drawer when the search dialog opens (a small listener calling hidePopover()), or move the search dialog into the top layer too (native <dialog> / popover) so the two stack by open order.

Two trades that deserve an explicit maintainer sign-off

Light dismiss is broader than the PR body states. The body owns "clicking outside the drawer now closes it", but pointerdown-based dismissal also means selecting text, clicking a code cell, or clicking to focus before keyboard-scrolling closes the drawer. That ends its old role as a persistent side map while reading — including on 2xl viewports where the 350px panel overlaps nothing and persistence had no cost. If persistent-while-reading is worth keeping, popover="manual" plus explicit close handling preserves it with every other benefit intact; if auto is the intent, worth saying so knowing the full scope. No test covers either behaviour, so whichever is chosen ships silently.

The no-Popover-API fallback removes the TOC entirely for iOS 16 Safari and Firefox <125. The reasoning in the @supports comment is sound and the polyfill analysis is correct, but this is a shipped capability removed with no fallback affordance, and the readership skews toward older devices in some regions. Fine if chosen knowingly — flagging it as a decision, not a defect.

Small fixes worth folding in

  • drawer-opens-without-javascript: expect(drawer).toBeHidden() passes when .qe-toc matches zero elements, so the SSR assertion can't detect an absent or renamed drawer — add await expect(drawer).toHaveCount(1) first (fouc.spec.ts guards this exact trap for the same element).
  • Same test: testInfo.config.projects[0].use.baseURL is a positional coupling (the config already runs a second server on NO_THEBE_PORT; a reorder silently points this at the wrong fixture), and the ?? "/" fallback would throw on the hand-built context. A test.use({ javaScriptEnabled: false }) describe block with the ordinary page fixture removes the manual context, the try/finally, and the index coupling in one move; testInfo.project.use.baseURL is the minimal fix.
  • First paint shows both toggle icons side by side in the pre-stylesheet frame (.qe-toc-toggle__close{display:none} lives only in app.css). This is parity with the old code, not a regression — but the rewritten CRITICAL_CSS comment now claims the drawer needs no inline rule, which is true for the panel and false for its toggle. One line closes both the frame and the comment drift: :where(.qe-toc-toggle__close){display:none} in CRITICAL_CSS (the body:has() rule outranks it).
  • The px→rem conversion quietly enrolls the drawer in the toolbar's A+/A− font-scale control (it sets documentElement.style.fontSize), while inset-block: 50px and the toolbar stay px — at "+" the drawer is 385/275px instead of 350/250. Decide px parity or document the scaling as intended; no baseline exercises non-default scale either way.
  • The drawer heading became an <h2> that precedes every page's <h1> in DOM order. Mitigated while closed (a closed popover is display:none, so it leaves the accessibility tree), but with the drawer open, heading-order navigation and axe audits see the inversion. The old styled div with aria-labelledby intact avoids it.

Follow-up material, not blocking

The toolbar height 50px now exists in three comment-synced copies (inset-block here, h-[50px] in Toolbar.tsx, top={50} in root.tsx) — the same transcription hazard the theme(screens.*) comment in this very block warns about; a Tailwind spacing token plus a shared constant (the pattern contentsDrawer.ts itself establishes) would collapse them. With every UiContext consumer now gone, UiStateProvider and the Internal/External wrapper split are dead scaffolding, and the hide_toc/hideSearch props are threaded but never read (pre-existing, but this PR edited those exact signatures while deleting the equally-inert inset). The 768–856px band fix works but is a hand-maintained pixel budget guarded by a comment with no CI viewport in the band — a structural fix (desktop cluster at lg with MobileActionsMenu below, or visible overflow degradation) would stop the next toolbar control from silently re-breaking it, and the logo's shrink-0 invariant arguably belongs on the container. Minor duplications: the 2xl width re-declares the base value, qe-toc is transcribed in five places despite TOC_POPOVER_ID existing to be the single source, and the toggle's duration-300 mirrors the drawer's 0.3s.

On the PR's suggestions for open PRs

Agreed on both — #135 was already closed as superseded on 2026-08-22, and the #141 reduction plan (keep the Outline/BackToTop piece and the FOUC-control hardening, drop the drawer half) matches what we'd concluded independently — the plan is to rebase #141 onto this PR after it merges, re-applying its fouc.spec.ts init-script change on top of this PR's rewrite of that file.

Posted by Claude on mmcky's behalf; review method: 8 independent analysis angles + adversarial verification of surviving candidates.

@mmcky

mmcky commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

thanks @DrDrij -- posted a review above. I find iterative, adversarial reviews pretty fruitful. Thanks for your work on this. Once this PR is merged I will revise #141 then organise a new release and get the demo page re-built for you.

mmcky and others added 2 commits September 3, 2026 11:05
…nt, harden tests

Follow-up to the 2026-08-25 review of #144, applying the items that need no
design decision. Light dismiss (`popover="auto"`) is accepted as-is.

- Close the drawer when a modal dialog mounts. A popover paints in the top
  layer above the search dialog's backdrop and panel, and light dismiss cannot
  cover the Cmd/Ctrl+K route because it fires on pointerdown. A shallow
  childList observer on <body> catches Radix's portal container and calls
  hidePopover(). New test `drawer-closes-when-search-opens` pins it.
- Hide `.qe-toc-toggle__close` in CRITICAL_CSS so both icons no longer paint
  side by side on the pre-app.css frame, and make the "needs no rule here"
  comment true. The FOUC guard now samples the icon: hidden in the main test,
  rendered in the control, which proves the inline rule does work.
- `drawer-opens-without-javascript` moves into a `test.use({ javaScriptEnabled:
  false })` describe: the ordinary page fixture with the project's own baseURL,
  and a `toHaveCount(1)` presence guard before `toBeHidden`, which otherwise
  passes on zero matches.
- The drawer title is a styled div again, not an <h2> that precedes every
  page's <h1> in DOM order while the drawer is open.
- Reword the toggle comment: the browser exposes the expanded state in the
  accessibility tree; the DOM `aria-expanded` attribute reads null by design.
- Note in app.css that the rem widths scale with the A+/A- control on purpose.
- Changelog entries under [Unreleased] for the rebuild, its behaviour and
  browser-support changes, and the three toolbar/a11y fixes.

Also merges main so the branch carries #155's critical CSS and changelog.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@mmcky

mmcky commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Pushed a follow-up commit onto this branch (with a merge of main, so it now carries #155's critical CSS and changelog) covering the items from the review above that needed no design decision. Details of what landed and what was decided, so nothing here is a surprise.

Decisions

Light dismiss: popover="auto" is accepted. Below the 2xl breakpoint the drawer overlays the article on every viewport, so a reader has to dismiss it to keep reading regardless, and Escape plus click-outside is what mobile users expect. If persistence turns out to matter on wide screens it is a one-attribute change plus an Escape handler, so it can be revisited on the demo site rather than here.

Drawer state across client-side navigation is not a regression. I probed a build of current main the same way: the sidebar node is remounted on every route change there too, so the open state was already lost on main. That question from the 2026-08-22 review is closed; if persistence across navigation is wanted it is a follow-up issue, not a condition on this PR.

Browser-support trade stands as accepted on 2026-08-22, and is now documented in the changelog.

What the commit changes

  • The blocker: the drawer now closes when a modal dialog mounts. A popover paints in the top layer above the search dialog's backdrop and panel, and light dismiss cannot cover the Cmd/Ctrl+K route because it fires on pointerdown. ContentsSidebar gains a small hook: a shallow childList observer on <body> (Radix portals the dialog into a container appended directly there, so no subtree walk) that calls hidePopover() when a role="dialog" arrives. A new drawer-closes-when-search-opens test pins it. Chosen over moving search into the top layer because it touches nothing upstream.
  • First-paint icon frame closed. :where(.qe-toc-toggle__close){display:none} is in CRITICAL_CSS, the body:has() rule in app.css outranks it, and the "needs no rule here" comment now says what is actually true. The FOUC guard samples the icon in both cases: hidden in the main test, rendered in the control, which proves the inline rule does work.
  • drawer-opens-without-javascript hardened as suggested: a test.use({ javaScriptEnabled: false }) describe with the ordinary page fixture, so the project's own baseURL applies, plus toHaveCount(1) before toBeHidden, which otherwise passes on zero matches.
  • Drawer title is a styled div again, not an <h2> ahead of every page's <h1> while open; aria-labelledby on the nav still points at it.
  • Toggle comment reworded: the browser exposes the expanded state in the accessibility tree and the DOM aria-expanded attribute reads null by design, so nobody later adds one.
  • Font-scale coupling documented as intended in app.css: rem widths scale the drawer with the text it holds under A+/A-; the 50px block-start stays px like the toolbar.
  • Changelog entries under [Unreleased]: the rebuild with its behaviour and browser-support changes under Changed, and the toggle-icon fix (Sidebar toggle icons still animate their first-paint correction (same mechanism as the contents panel) #127), the toolbar logo/overflow fix and the focus ring under Fixed.

Verified locally on the pushed tree

npm run compile clean, test:unit 20/20, test:fouc 2/2 (WebKit), and both drawer tests green on desktop-chrome. Page snapshots were not re-run locally because the darwin baselines predate #155; CI diffs against the linux set, which #155 refreshed.

Not done here, for follow-up

The 08-25 review's non-blocking items are untouched: the three comment-synced copies of the 50px toolbar height, the now-dead UiStateProvider scaffolding and inert hide_toc/hideSearch props, and the hand-maintained 768–856px toolbar budget. Worth an issue each rather than growing this PR.

@DrDrij please shout if any of this cuts across something you had in progress; otherwise, once CI is green on this push, I intend to merge and then rebase #141 on top as planned.

Posted by Claude on mmcky's behalf.

@mmcky

mmcky commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

@DrDrij let me know when ready if you're happy with these changes and I'll merge.

@DrDrij

DrDrij commented Sep 4, 2026

Copy link
Copy Markdown
Member Author

This is good to merge @mmcky. Thanks for the final tidy and hardening.

@mmcky

mmcky commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

thanks @DrDrij

@mmcky
mmcky merged commit 1bf25fe into main Sep 4, 2026
4 checks passed
@mmcky
mmcky deleted the refactor/toc-popover branch September 4, 2026 23:36
mmcky added a commit that referenced this pull request Sep 5, 2026
…econditions (#169)

* fix: pin "back to top" to opacity 0 in the critical CSS

BackToTop is hidden by opacity-0 and carries transition-opacity, so on any
frame where the stylesheet is absent it paints at full opacity and fades out
once the sheet lands. That frame is produced by the React #423 hydration
recovery (#126), which re-renders the head and briefly drops the stylesheet
after the component has mounted — so gating the transition on mount, as #141
tried, cannot prevent it. Measured in WebKit with the stylesheet delayed:
17-18 animating frames without this rule, none with it.

The FOUC guard asserts the rule: the main test expects opacity 0, the control
expects 1 with the inline block stripped.

Supersedes the Outline.tsx change in #141. Relates to #126.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(fouc): sample first paint from an init script, assert control preconditions

The React #423 hydration recovery restores the inlined critical <style>
150-240ms after DOMContentLoaded (#126). The control strips that block from
the served HTML, so a post-domcontentloaded sample can see it restored and
every control assertion flips at once. Sample in-page on DOMContentLoaded
instead, before hydration is scheduled, and have both cases assert their own
preconditions so a reshaped CRITICAL_CSS reports as a stale strip pattern.

Carries the test half of #141 onto the post-#144 probes. Relates to #126.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
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.

3 participants