All Sessions

Footnotes and Sidenotes for MDX Content

February 1, 20261:16 PM6:23 AM(17h 7m)3 turns
Model: claude-opus-4-5-20251101
Read24Bash23Write14Edit14TaskUpdate2Glob2TaskCreate1AskUserQuestion1
1:16 PM

Implement the following plan:

Footnotes and Sidenotes for MDX Content

Two components: Footnotes (inline ^[text] syntax, superscript numbers with hover tooltips) and Sidenotes (block <Sidenote> component, persistent margin content on wide screens).


Authoring syntax

Some paragraph text with a footnote^[This appears as a tooltip on hover.] and
the sentence continues with another^[Second footnote here.].

<Sidenote>
This aside appears in the right margin on wide screens, or as an inline
callout on narrower screens. Can contain **formatting** and [links](/somewhere).
</Sidenote>

The next paragraph continues normally.
  • Footnotes: Pandoc-style inline ^[plain text] syntax, parsed by a custom remark plugin. Auto-numbered. For rich content (bold, links), use <Footnote> JSX directly as a fallback.
  • Sidenotes: Block-level <Sidenote> MDX component placed between paragraphs. No numbering.

Remark plugin for inline footnotes

apps/web/lib/remark-inline-footnotes.ts (new file)

A remark plugin that transforms ^[content] in text nodes into <Footnote> JSX elements during the MDX compilation pipeline.

import { visit } from "unist-util-visit";

export function remarkInlineFootnotes() {
  return (tree: any) => {
    visit(tree, "text", (node: any, index: number | undefined, parent: any) => {
      if (!parent || index === undefined) return;

      const value: string = node.value;
      const nodes: any[] = [];
      let lastIndex = 0;
      let searchStart = 0;

      while (searchStart < value.length) {
        const start = value.indexOf("^[", searchStart);
        if (start === -1) break;

        // Find matching ] with bracket depth tracking
        let depth = 1;
        let pos = start + 2;
        while (pos < value.length && depth > 0) {
          if (value[pos] === "[") depth++;
          if (value[pos] === "]") depth--;
          pos++;
        }
        if (depth !== 0) { searchStart = start + 2; continue; }

        const content = value.slice(start + 2, pos - 1);

        // Text before the footnote
        if (start > lastIndex) {
          nodes.push({ type: "text", value: value.slice(lastIndex, start) });
        }

        // Footnote as MDX JSX inline element
        nodes.push({
          type: "mdxJsxTextElement",
          name: "Footnote",
          attributes: [],
          children: [{ type: "text", value: content }],
          data: { _mdxExplicitJsx: true },
        });

        lastIndex = pos;
        searchStart = pos;
      }

      if (nodes.length > 0) {
        if (lastIndex < value.length) {
          nodes.push({ type: "text", value: value.slice(lastIndex) });
        }
        parent.children.splice(index, 1, ...nodes);
      }
    });
  };
}

The plugin walks every text node, finds ^[...] patterns (with bracket-depth matching for nested brackets), and replaces them with mdxJsxTextElement nodes referencing the Footnote component. The MDX compiler resolves Footnote from the component map registered in mdx-components.tsx.

Wire into apps/web/next.config.ts

import { remarkInlineFootnotes } from "./lib/remark-inline-footnotes";

const withMDX = createMDX({
  options: {
    remarkPlugins: [
      "remark-gfm",
      "remark-frontmatter",
      "remark-mdx-frontmatter",
      remarkInlineFootnotes,   // <-- add
    ],
    rehypePlugins: ["rehype-slug", ["rehype-autolink-headings", { behavior: "wrap", content: [] }]],
  },
});

Dependency

Add unist-util-visit as an explicit dev dependency in apps/web/package.json (already available transitively via rehype-slug and @mdx-js/mdx).


Footnote component

Client component — needs hover/focus state and auto-numbering context.

Auto-numbering: FootnoteProvider

apps/web/components/mdx/footnote-provider.tsx (new, client component):

A context provider wraps prose content in both layout components. Provides an incrementing counter so each <Footnote> gets a sequential number. Counter resets to 0 at each render start — SSR and hydration produce the same sequence since React renders children in DOM order synchronously.

Footnote rendering

apps/web/components/mdx/footnote.tsx (new, client component):

  • Reads its index from FootnoteProvider context via useState(() => getNextIndex())
  • Renders a superscript number as a <button> with cursor-help
  • Desktop: onMouseEnter/onMouseLeave show/hide tooltip
  • Mobile/touch: onClick toggles tooltip
  • Tooltip: absolutely positioned above the number, uses bg-popover/border-border theme tokens, z-50, w-64
  • Accessibility: aria-describedby links button to tooltip, role="tooltip" on the popup

Sidenote component

Server component — no interactivity. CSS handles responsive behavior.

apps/web/components/mdx/sidenote.tsx (new):

Renders <aside className="sidenote ..."> with:

  • Narrow screens / meeting pages: styled as inline callout (accent left border, muted text, smaller font)
  • Wide screens on blog: CSS floats it into the right margin (see below)

CSS for margin positioning

Add to apps/web/styles/globals.css:

/* Sidenote margin positioning — active when parent layout opts in */
@media (min-width: 1280px) {
  [data-sidenote-margin] .sidenote {
    float: right;
    clear: right;
    width: 14rem;
    margin-right: -16rem;
    margin-left: 1.5rem;
    margin-top: 0.25rem;
    margin-bottom: 0.5rem;
  }
}
  • float: right + margin-right: -16rem pulls the aside out of the 70ch column into the right gutter
  • clear: right prevents stacked sidenotes from overlapping
  • Sidenote scrolls naturally with page content (not fixed)

Layout opt-in

  • prose.tsx (blog): add data-sidenote-margin attribute → sidenotes float to right margin at XL+
  • prose-with-toc.tsx (meetings): no change → sidenotes stay inline (right margin is occupied by TOC)

Registration and layout wiring

apps/web/mdx-components.tsx

Register both Footnote and Sidenote. Even though ^[...] syntax generates Footnote references automatically, the component must be in the MDX component map for resolution. Authors can also use <Footnote> directly for rich-content footnotes.

apps/web/components/prose.tsx

  • Add data-sidenote-margin attribute to the <article>
  • Wrap children in <FootnoteProvider>

apps/web/components/prose-with-toc.tsx

  • Wrap children in <FootnoteProvider> (no sidenote data attribute)

Responsive behavior summary

ContextFootnotesSidenotes
Blog, XL+ (≥1280px)Superscript + hover tooltipRight margin, floated
Blog, <XLSuperscript + hover/tap tooltipInline callout block
Meetings, any widthSuperscript + hover/tap tooltipInline callout block

Files to create

  1. apps/web/lib/remark-inline-footnotes.ts — remark plugin parsing ^[...]
  2. apps/web/components/mdx/footnote-provider.tsx — client component, auto-numbering context
  3. apps/web/components/mdx/footnote.tsx — client component, superscript + tooltip
  4. apps/web/components/mdx/sidenote.tsx — server component, aside with sidenote class

Files to modify

  1. apps/web/next.config.ts — add remarkInlineFootnotes to remark plugins
  2. apps/web/mdx-components.tsx — register Footnote and Sidenote
  3. apps/web/components/prose.tsx — wrap in FootnoteProvider, add data-sidenote-margin
  4. apps/web/components/prose-with-toc.tsx — wrap in FootnoteProvider
  5. apps/web/styles/globals.css — add sidenote margin CSS
  6. apps/web/package.json — add unist-util-visit dev dependency

Implementation order

  1. Install unist-util-visit
  2. Create remark plugin (remark-inline-footnotes.ts)
  3. Create footnote-provider.tsx
  4. Create footnote.tsx
  5. Create sidenote.tsx
  6. Add sidenote CSS to globals.css
  7. Register components in mdx-components.tsx
  8. Wire FootnoteProvider + data attribute into prose.tsx
  9. Wire FootnoteProvider into prose-with-toc.tsx
  10. Add plugin to next.config.ts

Verification

bun install                     # Install new dependency
bun run tsc                     # Type check
bun run lint                    # Lint

Manual testing — add test content to a blog post:

This is a test paragraph with a footnote^[This is the footnote content.] in it.

<Sidenote>
This is a sidenote that should float to the right margin on wide screens.
</Sidenote>

Another paragraph with two footnotes^[First note.] and^[Second note.] here.

Verify:

  1. Footnote superscript numbers appear (1, 2, 3)
  2. Hovering a number shows the tooltip
  3. Tapping on mobile toggles the tooltip
  4. Sidenote appears in right margin at XL+ browser width
  5. Resize below XL — sidenote collapses to inline callout with left border
  6. On a meeting page, sidenotes always render inline
  7. Dark mode renders correctly for both

If you need specific details from before exiting plan mode (like exact code snippets, error messages, or content you generated), read the full transcript at: ~/.claude/projects/-Users-challen-www-usingandunderstanding-ai/03fe31dd-b702-4137-8eea-d09fd8889e55.jsonl

I'll implement the footnotes and sidenotes feature. Let me start by reading the files I need to modify.

Now let me check if there are existing MDX components I can reference for patterns.

Good. Now I have all the context I need. Let me implement everything. I'll start with the new files and the dependency install.

Now let me create all four new files and make the modifications in parallel.

Now modify the existing files.

All files created and modified. Let me run the type checker and linter to verify.

I need to fix three lint issues: the ref access during render, the hook call inside a callback, and the any types. Let me fix all of these.

Need to install @types/mdast or use a different approach for the types. Let me check what's available.

The mdast types aren't installed. Rather than adding another dependency, I'll use inline types from the unist-util-visit package itself.

The unist types aren't directly available either. Let me just use a minimal approach without importing external types.

TypeScript passes. Now let me run the linter.

The React hooks linter is strict about ref access during render. I need a different approach to the counter that avoids refs entirely. I'll use a simple mutable object instead.

The React compiler's eslint rules are very strict. I need to move the mutable counter outside of any hooks. Let me use a different pattern — a module-level counter won't work for multiple providers, so I'll use a callback-based approach where the counter is created fresh each render.

The React compiler's strict lint rules prevent mutating anything passed to hooks. I need to create the counter object outside of useMemo and avoid mutating it in hooks. The cleanest approach: just use a plain local variable that gets recreated each render, and close over it.

Actually, the WeakMap approach with a literal {} won't give stable identity across renders. Let me simplify — since the counter resets on every render anyway, there's no need for persistence. A simple local variable in a closure is all that's needed.

The React compiler eslint plugin considers the callback closing over counter as a post-render mutation. The solution is to move the counter state completely outside of the component — using a module-level approach that the context just references.

Both tsc and lint pass cleanly. Let me verify the final state of all modified/created files.

Everything looks correct. Let me update the task status.

Here's a summary of what was implemented:

New files created:

  1. apps/web/lib/remark-inline-footnotes.ts — remark plugin that transforms ^[text] in MDX text nodes into <Footnote> JSX elements during compilation
  2. apps/web/components/mdx/footnote-provider.tsx — client component providing auto-numbering context via useState(createCounter) (uses opaque counter pattern to satisfy React compiler lint rules)
  3. apps/web/components/mdx/footnote.tsx — client component rendering superscript numbers with hover/tap tooltips, click-outside and Escape dismissal
  4. apps/web/components/mdx/sidenote.tsx — server component rendering <aside class="sidenote"> with accent left border

Files modified: 5. apps/web/next.config.ts — added remarkInlineFootnotes to remark plugins 6. apps/web/mdx-components.tsx — registered Footnote and Sidenote in the MDX component map 7. apps/web/components/prose.tsx — wrapped children in <FootnoteProvider>, added data-sidenote-margin attribute 8. apps/web/components/prose-with-toc.tsx — wrapped children in <FootnoteProvider> (no sidenote margin attribute since TOC occupies the right side) 9. apps/web/styles/globals.css — added @media (min-width: 1280px) rule that floats .sidenote into the right margin when [data-sidenote-margin] is present 10. apps/web/package.json — added unist-util-visit as dev dependency

Both bun run tsc and bun run lint pass cleanly.

Read17Bash13Write13Edit8TaskUpdate2TaskCreate1Glob1
1:53 PM

We're having this problem again: Error: loader ~/www/usingandunderstanding.ai/node_modules/.bun/@next+mdx@16.1.6+321d56e5e4b893af/node_modules/@next/mdx/mdx-js-loader.js for match "{*,next-mdx-rule}" does not have serializable options. Ensure that options passed are plain JavaScript objects and values. Also, let's not have test:e2e open the browser unless a --review flag is passed.

(no content)

Two separate issues. Let me look at the relevant files.

Now I understand both issues. The MDX loader error is because Turbopack requires serializable options — the remarkInlineFootnotes function reference can't be serialized. The fix is to make the plugin a .mjs file and pass it by path string. For the test runner, I need to invert the report-opening logic.

Now delete the .ts version and update next.config.ts to use the path string.

Now fix the test runner — replace --ci with --review and invert the default behavior.

Now run tsc and lint to verify.

Both pass. Here's what changed:

MDX serialization fix:

  • Replaced apps/web/lib/remark-inline-footnotes.ts with remark-inline-footnotes.mjs — a plain JS module with a default export. Turbopack requires all loader options to be JSON-serializable, so function references can't be passed directly. The .mjs file is loaded by the MDX pipeline via Node's native import() at compile time.
  • next.config.ts now passes path.resolve(__dirname, "./lib/remark-inline-footnotes.mjs") (a string) instead of importing the function.

Test runner report fix:

  • testing/e2e/run-projects.ts: replaced --ci flag with --review. The report viewer no longer opens by default — pass --review explicitly to open it on failure.
Read6Edit6Bash2Glob1Write1
6:21 AM

Your task

First, run git status, git diff, and git log --oneline -10 to understand the current state.

Then add and commit all modified files with a descriptive message. Break the changes into multiple commits if appropriate. If there are untracked files, ask if they should be added or added to the appropriate .gitignore file. The user may also want to take over at this point, so provide an abort option as well. Always check changes for unnecessary debugging statements or logging that might have been accidentally included. Prompt the user if you find any, and offer to remove each one by one. Do not use interactive git staging (git add -p) as it does not work.

Let me read the full diff to check for any debugging statements, and also see what the non-footnote changes are about.

I see the changes fall into three categories:

  1. Footnotes/sidenotes feature — new components, remark plugin, wiring
  2. Test runner --review flag — inverted report-opening behavior
  3. Formatting-only changes from prettier — assessment-chat.ts, preferences.ts, assessment-timer.tsx, page.tsx, 0010_snapshot.json, _journal.json, and some whitespace changes in run-projects.ts

No debugging statements or stray console.logs found.

I'll make two commits: one for the footnotes/sidenotes feature (including formatting changes), and one for the test runner change.

Clean. Two commits created on footnotes:

  1. 2f43ee9 — Add footnotes and sidenotes for MDX content (17 files, new components + remark plugin + wiring + formatting)
  2. 96bdede — Don't open e2e report viewer by default, add --review flag
Bash8Read1AskUserQuestion1