Skip to content
Merged
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
and appended to the `sans` stack, so the swap to the self-hosted webfont no
longer reflows the page: measured text width moves from about 8% off target
to under 0.5% while the woff2 is still loading ([#155](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/155)).
- The contents drawer is rebuilt on the native Popover API. The browser now
owns its open/closed state (`popovertarget` on the toolbar toggle), so the
drawer works on the server-rendered HTML before hydration, a closed drawer is
`display: none` from the UA stylesheet on the very first paint — the
critical-CSS rule that parked it off-screen is gone rather than patched —
and its links leave the tab order and accessibility tree while closed.
**Behaviour changes:** the drawer is now light-dismissed (`popover="auto"`):
Escape closes it, and so does clicking or selecting anywhere outside it,
where before it stayed open until toggled. It also closes itself when the
search dialog opens, since a popover paints above every z-indexed layer
including a modal's backdrop. **Browser support:** browsers without the
Popover API (Firefox < 125, Safari < 17, which includes anything on iOS 16)
get neither the drawer nor its toggle — the in-page outline and site
navigation still reach every page there. A polyfill was considered and
rejected because it runs after first paint, reintroducing the flash this
removes; revisit if that share matters. Drawer widths now live in
`styles/app.css` keyed on `theme(screens.*)`, so they no longer depend on
Tailwind's emission order
([#130](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/130),
[#144](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/144)).

### Fixed
- Inline code no longer renders wrapped in literal backticks.
Expand All @@ -67,6 +87,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
directory rather than assuming one. A production build now emits **zero**
absolute asset URLs and all 78 references resolve
([#150](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/150)).
- The contents toggle icons no longer animate their first-paint correction,
and the close icon no longer paints beside the hamburger on the pre-`app.css`
frame. The icons now swap with `display` off the drawer's `:popover-open`
state instead of cross-fading with `transition-all`, so there is nothing to
animate, and a zero-specificity critical-CSS rule hides the close icon until
the stylesheet lands
([#127](https://github.com/QuantEcon/quantecon-theme.mystmd/issues/127),
[#144](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/144)).
- The toolbar logo no longer distorts between roughly 770px and 840px, and the
desktop control set no longer overflows the right edge in the 768–856px band.
The logo is `shrink-0` (preflight's `max-width: 100%` was letting it clamp to
a shrinking flex item), and the toolbar's gap, separator and padding widen at
`lg` rather than `md`, returning about 148px to that band
([#144](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/144)).
- The contents toggle has a visible `:focus-visible` ring, so keyboard users
can see where focus returns after Escape closes the drawer (WCAG 2.4.7)
([#144](https://github.com/QuantEcon/quantecon-theme.mystmd/pull/144)).

## [2.3.1] - 2026-08-26

Expand Down
93 changes: 45 additions & 48 deletions app/components/ContentsSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,13 @@ import { getProjectHeadings } from '@myst-theme/common';
import {
useBaseurl,
useLinkProvider,
useNavOpen,
useProjectManifest,
useSiteManifest,
useThemeTop,
withBaseurl,
} from '@myst-theme/providers';
import { useSidebarHeight } from '@myst-theme/site';
import classNames from 'classnames';
import { slugToUrl } from 'myst-common';
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { TOC_HEADING_ID, TOC_POPOVER_ID } from './contentsDrawer';

type StrictHeading = Omit<Heading, 'level'> & { level: number };
type HeadingGroup = StrictHeading[];
Expand Down Expand Up @@ -45,25 +42,43 @@ function Section({ group }: { group: HeadingGroup }) {
}

/**
* True only after the component has mounted on the client.
* Closes the drawer when a modal dialog opens.
*
* Both the server render and the first (hydrating) client render return
* `false`, so the markup matches and React does not warn; the effect then
* flips it on the frame after hydration.
* A popover lives in the browser's top layer, which paints above every
* z-indexed element — including the search dialog's backdrop (`z-[1000]`) and
* panel. Light dismiss does not help: it fires on pointerdown, and the search
* hotkey (Cmd/Ctrl+K) is a keydown, so the drawer would stay open above the
* modal, un-dimmed, with its links dead under the dialog's pointer-events lock.
*
* Radix portals the dialog into a container appended directly to <body>, so a
* shallow childList observer on <body> is enough — no subtree walk on every
* mutation. Any `role="dialog"` counts: a modal opening over an open drawer is
* wrong regardless of which dialog it is.
*/
function useMounted() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return mounted;
function useCloseOnDialogOpen(popoverId: string) {
useEffect(() => {
const drawer = document.getElementById(popoverId);
if (!drawer || typeof drawer.hidePopover !== 'function') return;
const observer = new MutationObserver((records) => {
for (const record of records) {
for (const node of record.addedNodes) {
if (!(node instanceof Element)) continue;
if (node.matches('[role="dialog"]') || node.querySelector('[role="dialog"]')) {
drawer.hidePopover(); // no-op when already closed
return;
}
}
}
});
observer.observe(document.body, { childList: true });
return () => observer.disconnect();
}, [popoverId]);
}

export function ContentsSidebar() {
const [open] = useNavOpen();
const mounted = useMounted();
useCloseOnDialogOpen(TOC_POPOVER_ID);
const config = useSiteManifest();
const project = useProjectManifest();
const top = useThemeTop();
const { toc } = useSidebarHeight(top);
const baseurl = useBaseurl();
const Link = useLinkProvider();

Expand Down Expand Up @@ -96,37 +111,19 @@ export function ContentsSidebar() {
);

return (
<div
ref={toc}
className={classNames(
// Stable hook for the inline critical CSS in app/root.tsx, which parks
// this panel off-screen on the very first paint — before app.css lands.
'qe-contents-sidebar',
'fixed top-0 left-0',
'w-[350px] lg:w-[250px] 2xl:w-[350px]',
'h-screen w-[250px] z-[20] pt-[40px] pb-[90px] px-9',
'bg-qetoolbar-light dark:bg-qetoolbar-dark ',
'border-r-[1px] border-qetoolbar-border',
'overflow-y-auto',
// Belt and braces, not the primary guard. The critical CSS above is
// what actually prevents the flash; because both states are a -100%
// translate there is nothing for a transition to interpolate, so
// removing this gate does not by itself reintroduce it (measured).
//
// It is kept because the transition is only ever wanted in response to
// a click: withholding it until after mount means any correction made
// when app.css lands is applied instantly rather than animated, which
// keeps this component correct even if the critical rule is later
// changed or dropped.
//
// `transform` (not `all`) so only the slide animates, on the compositor.
mounted && 'transition-transform duration-300 ease-in-out',
{ 'translate-x-0': open, '-translate-x-full': !open }
)}
style={{ top: '50px' }}
>
<div className="mb-4 text-lg font-bold text-qetext-light dark:text-qetext-dark">Contents</div>
<nav className="text-qetext-light">
// A popover: the browser owns the open/closed state, so there is none in
// React and the drawer works before hydration. All presentation lives in
// `.qe-toc` (styles/app.css).
<div id={TOC_POPOVER_ID} popover="auto" className="qe-toc">
{/* A styled div, not a heading: the drawer precedes every page's <h1>
in DOM order, so an <h2> here would invert heading-order navigation
whenever the drawer is open. */}
<div id={TOC_HEADING_ID} className="mb-4 text-lg font-bold">
Contents
</div>
{/* Labelled by the title above, not a second `aria-label` — the toggle
is already named "Table of contents"; don't repeat it here. */}
<nav aria-labelledby={TOC_HEADING_ID}>
{headings?.map((headingOrGroup) => {
if (Array.isArray(headingOrGroup))
return (
Expand Down
9 changes: 1 addition & 8 deletions app/components/NavigationAndArticleWrapper.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { useSidebarHeight } from '@myst-theme/site';
import {
BannerStateProvider,
TabStateProvider,
Expand All @@ -10,21 +9,18 @@ import { ContentsSidebar } from './ContentsSidebar';

function NavigationAndArticleWrapperInternal({
children,
inset = 20, // begin text 20px from the top (aligned with menu)
}: {
hide_toc?: boolean;
hideSearch?: boolean;
children: React.ReactNode;
inset?: number;
}) {
const top = useThemeTop();
const { container } = useSidebarHeight(top, inset);
return (
<>
<Toolbar />
<ContentsSidebar />
<TabStateProvider>
<article ref={container} className="article content" style={{ marginTop: top }}>
<article className="article content" style={{ marginTop: top }}>
{children}
</article>
</TabStateProvider>
Expand All @@ -36,12 +32,10 @@ export function NavigationAndArticleWrapper({
children,
hide_toc,
hideSearch,
inset = 20, // begin text 20px from the top (aligned with menu)
}: {
hide_toc?: boolean;
hideSearch?: boolean;
children: React.ReactNode;
inset?: number;
}) {
return (
<UiStateProvider>
Expand All @@ -50,7 +44,6 @@ export function NavigationAndArticleWrapper({
children={children}
hide_toc={hide_toc}
hideSearch={hideSearch}
inset={inset}
/>
</BannerStateProvider>
</UiStateProvider>
Expand Down
12 changes: 12 additions & 0 deletions app/components/contentsDrawer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* Identifiers shared by the contents drawer and the button that opens it.
*
* `popovertarget` on the button is matched to the panel's `id` by the browser,
* so the two must agree. Neither component owns the value: the panel
* (ContentsSidebar) and the toggle (toolbar/SidebarToggle) both import it from
* here, rather than one importing from the other.
*/
export const TOC_POPOVER_ID = 'qe-toc';

/** Labels the drawer's <nav>, so the heading is not announced twice. */
export const TOC_HEADING_ID = 'qe-toc-heading';
44 changes: 21 additions & 23 deletions app/components/toolbar/SidebarToggle.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,30 @@
import { useNavOpen } from '@myst-theme/providers';
import classNames from 'classnames';
import { Menu, X } from 'lucide-react';
import { TOC_POPOVER_ID } from '../contentsDrawer';

/**
* Opens the contents drawer.
*
* No `onClick` and no React state by design — `popovertarget` hands the toggle
* to the browser, which also supplies Escape, light dismiss and the expanded
* state in the accessibility tree. Don't add any of those here — including an
* `aria-expanded` attribute: the DOM attribute reads `null` by design, the
* browser exposes the state directly to assistive tech.
*
* Both icons are `aria-hidden` so the button is the single accessible name;
* which one shows is decided in CSS from the drawer's `:popover-open` state
* (`.qe-toc-toggle__*` in styles/app.css). Browsers without the Popover API
* hide this button along with the drawer — see the `@supports` block there.
*/
export function SidebarToggle() {
const [open, setOpen] = useNavOpen();
return (
<button
className="relative flex items-center w-6 h-6 cursor-pointer opacity-90"
onClick={() => setOpen(!open)}
type="button"
popovertarget={TOC_POPOVER_ID}
aria-label="Table of contents"
className="qe-toc-toggle flex items-center w-6 h-6 cursor-pointer opacity-90 transition-transform duration-300 ease-in-out hover:scale-110"
>
<X
className={classNames('absolute transition-all duration-300 ease-in-out hover:scale-110', {
'opacity-0': !open,
'opacity-100': open,
})}
width={24}
height={24}
aria-label="Hide table of contents"
/>
<Menu
className={classNames('absolute transition-all duration-300 ease-in-out hover:scale-110', {
'opacity-100': !open,
'opacity-0': open,
})}
width={24}
height={24}
aria-label="Show table of contents"
/>
<Menu className="qe-toc-toggle__open" width={24} height={24} aria-hidden="true" />
<X className="qe-toc-toggle__close" width={24} height={24} aria-hidden="true" />
</button>
);
}
22 changes: 18 additions & 4 deletions app/components/toolbar/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,18 @@ export function Toolbar() {
return (
<div
className={classNames(
'fixed top-0 left-0 right-0 z-[2] flex items-center justify-between h-[50px] px-3 md:px-6',
'fixed top-0 left-0 right-0 z-[2] flex items-center justify-between h-[50px] px-3 lg:px-6',
'bg-qetoolbar-light dark:bg-qetoolbar-dark',
'border-b-[1px] border-qetoolbar-border'
)}
>
<ul className="flex items-center w-full space-x-3 md:space-x-5 text-qetext-light dark:text-qetext-dark">
{/*
Gap and container padding widen at `lg`, not `md`. The full desktop
control set switches on at `md`, and at 20px spacing it does not fit
between 768px and ~856px — the last icons get pushed off the right
edge. Keep the tighter spacing for that band when adding controls here.
*/}
<ul className="flex items-center w-full space-x-3 lg:space-x-5 text-qetext-light dark:text-qetext-dark">
<li>
<SidebarToggle />
</li>
Expand All @@ -37,7 +43,13 @@ export function Toolbar() {
</Tooltip>
</Link>
</li>
<li>
{/*
`shrink-0` is load-bearing: the logo has a pinned height and an auto
width, and preflight's `max-width: 100%` lets it clamp to a shrinking
flex item, distorting the aspect ratio. The row's flexible space is
the spacer below, not this.
*/}
<li className="shrink-0">
<QuantEconButton />
</li>
<li className="flex-grow" />
Expand All @@ -48,7 +60,9 @@ export function Toolbar() {
<FullScreenButton size={iconSize} />
</li>
<FontScaleListItems className="hidden md:block" size={iconSize} />
<li className="flex items-center md:pr-[36px]">
{/* Separator between the view controls and the actions cluster; scaled
down in the narrow desktop band for the same reason as the gap. */}
<li className="flex items-center md:pr-4 lg:pr-[36px]">
<ThemeButton className="w-5 h-5 opacity-60" />
</li>
<li className="hidden md:block">
Expand Down
20 changes: 20 additions & 0 deletions app/popover.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Popover API attribute typings.
*
* React 18's JSX types predate the Popover API, though react-dom does render
* these attributes (it passes through unknown lowercase ones).
*
* Delete this file on the move to React 19, which types them natively.
*/
import 'react';

declare module 'react' {
interface HTMLAttributes<T> {
popover?: 'auto' | 'manual';
}

interface ButtonHTMLAttributes<T> {
popovertarget?: string;
popovertargetaction?: 'toggle' | 'show' | 'hide';
}
}
Loading
Loading