@molecule/app-margin-notes-html

Feature · margin-notes · App (browser) · v1.0.8 · Apache-2.0

Framework-free long-form reading layout with notes beside each paragraph, rendered as an HTML string (build time or server) plus a small browser script: aligned sticky notes column, margin marks, two-way hover, switchable note kinds, and a phone bar that follows the reader

npm install @molecule/app-margin-notes-html

npm · Source on GitHub

How it works

@molecule/app-margin-notes-html is a ready-made margin-notes feature for the app (browser) side. It composes the core interfaces it needs, so it works with whichever providers your app has bonded.

// A static-site build step: one post page, notes from provenance + summaries.
import {
  marginNotesCss,
  marginNotesScriptTag,
  renderMarginNotes,
  type MarginNote,
  type MarginNotesBlock,
} from '@molecule/app-margin-notes-html'

interface Span {
  text: string
  origin: 'human' | 'ai'
  prompt?: string
  model?: string
}

// spans: one per top-level markdown block, in page order (e.g. provenance.json).
// summaries: TL;DR text keyed by the index of the span that starts each section.
// renderBlock: your markdown renderer, one block → HTML.
export function postBody(
  spans: Span[],
  summaries: Record<number, string>,
  renderBlock: (markdown: string) => string,
): { body: string; switches: string } {
  const notes: MarginNote[] = []
  const promptIds = new Map<string, string>()
  let summaryId: string | undefined
  const blocks: MarginNotesBlock[] = spans.map((span, i) => {
    if (/^#{1,6}\s/.test(span.text) || i === 0) {
      // A new section: its summary covers every block until the next heading.
      summaryId = summaries[i] ? `summary-${i}` : undefined
      if (summaryId)
        notes.push({
          id: summaryId,
          kind: 'summary',
          label: 'TL;DR',
          html: `<p>${summaries[i]}</p>`,
        })
    }
    const noteIds = summaryId ? [summaryId] : []
    if (span.origin === 'ai' && span.prompt) {
      let id = promptIds.get(span.prompt)
      if (!id) {
        id = `prompt-${promptIds.size}`
        promptIds.set(span.prompt, id)
        notes.push({
          id,
          kind: 'prompt',
          label: `Prompt · ${span.model ?? ''}`,
          html: `<p>${span.prompt}</p>`,
        })
      }
      noteIds.push(id)
    }
    return { id: `block-${i}`, html: renderBlock(span.text), noteIds, marked: span.origin === 'ai' }
  })
  const { html, switchesHtml } = renderMarginNotes({
    blocks,
    notes,
    kinds: [
      { id: 'summary', label: 'Summaries', defaultOn: true },
      { id: 'prompt', label: 'Prompts', defaultOn: false, panel: 'tap' },
    ],
    markLabel: 'Written with AI',
  })
  return { body: html, switches: switchesHtml }
}

// In the page template: the switches under the title, the body, then the
// stylesheet (once, BEFORE your site CSS so your own rules win a tie) and
// the script (once, end of <body>):
//   <header><h1>…</h1>${switches}</header>${body}${marginNotesScriptTag()}
//   fs.writeFileSync('dist/styles.css', marginNotesCss + siteCss)

Works with: @molecule/app-i18n

Live demos

Running apps built with Molecule that install this package — open the app or study the template.

Reference

Long-form reading layout with notes beside each paragraph, as plain HTML — for static sites and server-rendered pages that have no React.

renderMarginNotes() turns your blocks (paragraphs, headings) and notes (summaries, prompts, citations…) into HTML: on a wide screen a prose column with a narrower notes column beside it, each note lined up with the block it covers and sticky while its section scrolls past; on a phone a bar fixed to the bottom with the switches and a panel that shows the notes for the section being read. marginNotesCss styles it and marginNotesScript makes it interactive (switches, two-way hover, the phone panel following the reader, tap to pin). Defaults are in the HTML, so the page reads correctly with no JavaScript. The same layout for React apps is @molecule/app-margin-notes-react.

Quick Start

// A static-site build step: one post page, notes from provenance + summaries.
import {
  marginNotesCss,
  marginNotesScriptTag,
  renderMarginNotes,
  type MarginNote,
  type MarginNotesBlock,
} from '@molecule/app-margin-notes-html'

interface Span {
  text: string
  origin: 'human' | 'ai'
  prompt?: string
  model?: string
}

// spans: one per top-level markdown block, in page order (e.g. provenance.json).
// summaries: TL;DR text keyed by the index of the span that starts each section.
// renderBlock: your markdown renderer, one block → HTML.
export function postBody(
  spans: Span[],
  summaries: Record<number, string>,
  renderBlock: (markdown: string) => string,
): { body: string; switches: string } {
  const notes: MarginNote[] = []
  const promptIds = new Map<string, string>()
  let summaryId: string | undefined
  const blocks: MarginNotesBlock[] = spans.map((span, i) => {
    if (/^#{1,6}\s/.test(span.text) || i === 0) {
      // A new section: its summary covers every block until the next heading.
      summaryId = summaries[i] ? `summary-${i}` : undefined
      if (summaryId)
        notes.push({
          id: summaryId,
          kind: 'summary',
          label: 'TL;DR',
          html: `<p>${summaries[i]}</p>`,
        })
    }
    const noteIds = summaryId ? [summaryId] : []
    if (span.origin === 'ai' && span.prompt) {
      let id = promptIds.get(span.prompt)
      if (!id) {
        id = `prompt-${promptIds.size}`
        promptIds.set(span.prompt, id)
        notes.push({
          id,
          kind: 'prompt',
          label: `Prompt · ${span.model ?? ''}`,
          html: `<p>${span.prompt}</p>`,
        })
      }
      noteIds.push(id)
    }
    return { id: `block-${i}`, html: renderBlock(span.text), noteIds, marked: span.origin === 'ai' }
  })
  const { html, switchesHtml } = renderMarginNotes({
    blocks,
    notes,
    kinds: [
      { id: 'summary', label: 'Summaries', defaultOn: true },
      { id: 'prompt', label: 'Prompts', defaultOn: false, panel: 'tap' },
    ],
    markLabel: 'Written with AI',
  })
  return { body: html, switches: switchesHtml }
}

// In the page template: the switches under the title, the body, then the
// stylesheet (once, BEFORE your site CSS so your own rules win a tie) and
// the script (once, end of <body>):
//   <header><h1>…</h1>${switches}</header>${body}${marginNotesScriptTag()}
//   fs.writeFileSync('dist/styles.css', marginNotesCss + siteCss)

Type

feature

Installation

npm install @molecule/app-margin-notes-html @molecule/app-i18n

API

Interfaces

MarginNote

A note shown beside the block(s) it belongs to.

interface MarginNote {
  /** Stable id, referenced from `MarginNotesBlock.noteIds`. */
  id: string
  /** The kind this note belongs to (`MarginNoteKind.id`), for its switch. */
  kind: string
  /**
   * A small label above the note, already translated (e.g. "TL;DR" or
   * "Prompt · claude-fable-5"). Plain text; escaped.
   */
  label?: string
  /** The note's HTML. Trusted: it is inserted as is. */
  html: string
}

MarginNoteKind

A kind of note the reader can show or hide with a switch.

interface MarginNoteKind {
  /** Stable id, referenced from `MarginNote.kind`. */
  id: string
  /** The switch's visible label, already translated (e.g. "Summaries"). */
  label: string
  /** Whether this kind shows before the reader touches its switch. Default `true`. */
  defaultOn?: boolean
  /**
   * How this kind reaches the phone panel. `'follow'` (default): the panel
   * shows it for the section being read, following the reader. `'tap'`: for
   * notes about one paragraph rather than the section — while its switch is
   * OFF, only a tap on a block it belongs to shows it (gone on the second tap);
   * while its switch is ON it follows the reader like `'follow'`, so turning
   * the switch on always shows something. A tap shows the note either way.
   */
  panel?: 'follow' | 'tap'
  /**
   * The kind's accent colour (any CSS colour). Notes of different kinds are
   * told apart by colour alone: same font, same size. Defaults to
   * `var(--mn-accent-1)`, `var(--mn-accent-2)`, … by position.
   */
  accent?: string
}

MarginNotesBlock

One block of the text — usually a paragraph or heading from your markdown renderer. Blocks render in order, in the prose column.

interface MarginNotesBlock {
  /** Stable id, unique within the page (used as the DOM anchor). */
  id: string
  /** The block's HTML (e.g. `<p>…</p>`). Trusted: it is inserted as is. */
  html: string
  /** Ids of the notes that belong to this block (see `MarginNote`). */
  noteIds?: string[]
  /**
   * Draw a small mark in this block's left margin — the one permanent sign on
   * the prose itself (e.g. "written with AI"). Unmarked blocks carry none.
   */
  marked?: boolean
}

MarginNotesRow

One row of the layout: a run of blocks and the notes shown beside it.

interface MarginNotesRow {
  /** The row's key: the id of its first block. */
  id: string
  /** The blocks in this row, in order. */
  blocks: MarginNotesBlock[]
  /** Notes shown beside this row — each note appears in exactly one row, its first. */
  notes: MarginNote[]
  /** Every note id any block in this row refers to (for the phone panel). */
  noteIds: string[]
}

RenderedMarginNotes

What renderMarginNotes returns.

interface RenderedMarginNotes {
  /** The layout: prose, gutter, and the phone bar (with its own switches). */
  html: string
  /**
   * The desktop switches, for you to place (e.g. centred under the title).
   * `''` when there is nothing to switch. Hidden at phone width, where the
   * bar's switches replace them.
   */
  switchesHtml: string
  /** Whether any block has a note (when `false`, `html` is plain centred prose). */
  hasNotes: boolean
}

RenderMarginNotesOptions

Options for renderMarginNotes.

interface RenderMarginNotesOptions {
  /** The text, in reading order. */
  blocks: MarginNotesBlock[]
  /** Every note the blocks refer to. A page with none renders centred prose: no gutter, no bar, no switches. */
  notes?: MarginNote[]
  /** The note kinds, in the order their switches appear. Kinds absent here are always shown. */
  kinds?: MarginNoteKind[]
  /** Accessible name of the margin mark, already translated (e.g. "Written with AI"). */
  markLabel?: string
  /**
   * Id of this layout. Switches rendered elsewhere on the page (e.g. under the
   * title) find their layout by it. Default `'margin-notes'`.
   */
  id?: string
}

Functions

attachMarginNotes(doc)

Makes every rendered layout on the page interactive: the switches show and hide their kind (desktop and phone switches stay in step), hovering or focusing a block emphasises its notes and hovering a note tints the blocks it covers, notes line up with the first line they cover, and on a phone the bottom panel follows the section being read and a tap on a block pins its notes (a second tap, or "Hide notes", lets go). Safe to call more than once.

This function is self-contained (no imports, no module state), so marginNotesScript ships it to the browser as a plain string.

function attachMarginNotes(doc?: Document): void
  • doc — The document to attach to (default: the global document).

buildRows(blocks, notes, kinds)

Group blocks into layout rows and place each note once.

A row runs for as long as its blocks are covered by a note it OPENED with: a section's summary (every block of the section refers to it) keeps the whole section in one row, and a note that first appears mid-row — a prompt behind one of the section's paragraphs — joins that row's gutter but does not extend the row. (A prompt that produced paragraphs in several sections once carried a row across the next heading, and that section's summary was drawn beside the previous section.) A sticky note is bounded by its row, so this is what keeps a summary pinned until its section ends; splitting the row wherever the note set changed released it after the first paragraph. A run of blocks with no notes is a row of its own. Each note is shown once, in the first row that refers to it.

Only a note about the whole section keeps a row going: a note of a 'tap' kind is about one paragraph, so it never carries a row past its block. Before that, a prompt that wrote a post's opening AND the next section's first paragraph opened the first row with the summary, and the next section's summary was stacked under it, 180–200 px above its own heading (X0 x375, x376, 2026-09-29).

function buildRows(
  blocks: MarginNotesBlock[],
  notes?: MarginNote[],
  kinds?: MarginNoteKind[],
): MarginNotesRow[]
  • blocks — The text, in reading order.
  • notes — Every note the blocks refer to; ids with no note are ignored.
  • kinds — The note kinds; a 'tap' kind never extends a row. Without them every note does.

Returns: The rows, in reading order.

defaultShownKinds(kinds)

The kinds shown before the reader touches a switch.

function defaultShownKinds(kinds?: { id: string; defaultOn?: boolean }[]): string[]
  • kinds — The switchable kinds.

Returns: The ids of the kinds that default on.

escapeHtml(s)

Escapes text for use in HTML text and attribute values.

function escapeHtml(s: string): string
  • s — Plain text.

Returns: The escaped text.

marginNotesScriptTag()

The browser script as a <script> element.

function marginNotesScriptTag(): string

Returns: <script>…</script>.

marginNotesStyleTag()

The stylesheet as a <style> element, for pages that inline it.

function marginNotesStyleTag(): string

Returns: <style>…</style>.

mergeRepeatedNotes(blocks, notes)

One note per act of writing: when consecutive blocks each carry their own note with the same kind, label and HTML (one prompt that produced three paragraphs, recorded as three notes), every later block is pointed at the first block's note instead. buildRows places each note once, so the prompt is shown once, beside the first paragraph it produced, and hovering any of those paragraphs highlights that one note. The same text appearing again after a block without it is a new act, and keeps its own note.

function mergeRepeatedNotes(blocks: MarginNotesBlock[], notes?: MarginNote[]): MarginNotesBlock[]
  • blocks — The text, in reading order.
  • notes — Every note the blocks refer to.

Returns: The blocks, with repeated notes pointed at the first copy.

renderMarginNotes(options)

Renders a long-form text with notes beside each paragraph, as HTML.

Desktop: the prose column and, beside it, a narrower notes column; each note lines up with the block(s) it belongs to, appears once, and stays in view while its section scrolls past. Phone: the notes column disappears and a bar fixed to the bottom carries the switches and a panel with the notes for the section being read. Every default — which kinds show, which surface a width gets — is in the HTML and the CSS, so the page is right before any script runs; marginNotesScript adds the switches, hover, and the phone panel's following.

function renderMarginNotes(options: RenderMarginNotesOptions): RenderedMarginNotes
  • options — See RenderMarginNotesOptions.

Returns: The layout, the desktop switches to place, and whether it has notes.

Constants

marginNotesCss

The layout's stylesheet. It selects only on the data-mn-* attributes the renderer writes (no class names), and every size and colour is a custom property you can override on :root or on the layout:

propertydefaultwhat
--mn-measure36remprose column width
--mn-gutter18remnotes column width
--mn-gap2.5remspace between the columns
--mn-prose-size / --mn-prose-size-phone1.25rem / 1.125remprose font size
--mn-line-height1.6prose line height
--mn-note-scale0.75note size ÷ prose size (desktop)
--mn-panel-scale0.8note size ÷ prose size (phone panel)
--mn-accent-1, --mn-accent-2, …blue, ambereach kind's colour, by position
--mn-sticky-top1remhow far below the viewport top a note sticks (your header's height)
--mn-surfaceCanvasthe phone bar's background
--mn-breakpoint—fixed at 768px (media queries cannot read custom properties)
const marginNotesCss: '\n:where(:root) {\n  --mn-measure: 36rem;\n  --mn-gutter: 18rem;\n  --mn-gap: 2.5rem;\n  --mn-prose-size: 1.25rem;\n  --mn-prose-size-phone: 1.125rem;\n  --mn-line-height: 1.6;\n  --mn-note-scale: 0.75;\n  --mn-panel-scale: 0.8;\n  --mn-accent-1: #2563eb;\n  --mn-accent-2: #b45309;\n  --mn-accent-3: #047857;\n  --mn-sticky-top: 1rem;\n  --mn-surface: Canvas;\n}\n@media (prefers-color-scheme: dark) {\n  :where(:root) { --mn-accent-1: #60a5fa; --mn-accent-2: #f59e0b; --mn-accent-3: #34d399; }\n}\n[data-mn-root] {\n  box-sizing: border-box;\n  max-width: var(--mn-measure);\n  margin-inline: auto;\n  font-size: var(--mn-prose-size);\n  line-height: var(--mn-line-height);\n}\n[data-mn-root][data-mn-has-notes] { max-width: calc(var(--mn-measure) + var(--mn-gap) + var(--mn-gutter)); }\n[data-mn-rows] > [data-mn-row]:first-child [data-mn-block]:first-child > :first-child { margin-top: 0; }\n[data-mn-block] { position: relative; border-radius: 0.25rem; transition: background-color 0.15s; }\n[data-mn-block][data-mn-notes] { cursor: pointer; }\n[data-mn-block][data-mn-tint] {\n  background: color-mix(in srgb, var(--mn-tint-color, var(--mn-accent-1)) 10%, transparent);\n}\n[data-mn-block][data-mn-notes]:focus-visible { outline: 2px solid var(--mn-accent-1); outline-offset: 2px; }\n[data-mn-mark] {\n  position: absolute; left: -0.9rem; top: 0.75em;\n  width: 6px; height: 6px; border-radius: 50%;\n  background: var(--mn-accent-1); display: none;\n}\n[data-mn-gutter] { display: none; }\n[data-mn-sticky] {\n  position: sticky; top: var(--mn-sticky-top);\n  display: flex; flex-direction: column; gap: 0.75rem;\n  margin-top: var(--mn-offset, 0px);\n}\n[data-mn-note] {\n  position: relative;\n  box-sizing: border-box;\n  font-family: inherit;\n  font-size: calc(var(--mn-prose-size) * var(--mn-note-scale));\n  line-height: 1.5;\n  padding: 0.625rem 0.75rem 0.625rem 1.5rem;\n  border-radius: 0.375rem;\n  background: color-mix(in srgb, var(--mn-note-accent, var(--mn-accent-1)) 7%, transparent);\n  transition: background-color 0.15s, box-shadow 0.15s;\n}\n/* The accent bar sits inside the note, clear of its rounded corners, and the\n   label carries the same color (the fleet\'s accent-bar treatment). */\n[data-mn-note]::before {\n  content: \'\';\n  position: absolute;\n  left: 0.625rem;\n  top: 0.75rem;\n  bottom: 0.75rem;\n  width: 4px;\n  border-radius: 2px;\n  background: var(--mn-note-accent, var(--mn-accent-1));\n  pointer-events: none;\n}\n[data-mn-note][hidden] { display: none !important; }\n[data-mn-note][data-mn-emphasis] {\n  background: color-mix(in srgb, var(--mn-note-accent, var(--mn-accent-1)) 16%, transparent);\n  box-shadow: 0 1px 4px rgb(0 0 0 / 0.14);\n}\n[data-mn-note]:focus-visible { outline: 2px solid var(--mn-note-accent, var(--mn-accent-1)); outline-offset: 2px; }\n[data-mn-note-label] {\n  margin: 0 0 0.25rem;\n  font-size: 0.75em; font-weight: 600;\n  text-transform: uppercase; letter-spacing: 0.06em;\n  color: var(--mn-note-accent, var(--mn-accent-1));\n}\n[data-mn-note-body] > :first-child { margin-top: 0; }\n[data-mn-note-body] > :last-child { margin-bottom: 0; }\n[data-mn-switches] {\n  display: flex; flex-wrap: nowrap; align-items: center; justify-content: center; gap: 1rem;\n}\n[data-mn-switches="side"] { display: none; }\n[data-mn-switch] {\n  position: relative;\n  display: inline-flex; align-items: center; gap: 0.5rem;\n  min-height: 44px; padding: 0 0.25rem;\n  background: none; border: 0; color: inherit;\n  font: inherit; font-size: 0.9rem; white-space: nowrap; cursor: pointer;\n}\n[data-mn-switch]:focus-visible { outline: 2px solid var(--mn-note-accent, var(--mn-accent-1)); outline-offset: 2px; border-radius: 0.375rem; }\n[data-mn-track] {\n  position: relative; flex-shrink: 0;\n  width: 40px; height: 22px; border-radius: 999px;\n  background: color-mix(in srgb, currentColor 22%, transparent);\n  transition: background-color 0.15s;\n}\n[data-mn-switch][aria-checked="true"] [data-mn-track] { background: var(--mn-note-accent, var(--mn-accent-1)); }\n[data-mn-knob] {\n  position: absolute; top: 2px; left: 2px;\n  width: 18px; height: 18px; border-radius: 50%;\n  background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / 0.3);\n  transition: transform 0.15s;\n}\n[data-mn-switch][aria-checked="true"] [data-mn-knob] { transform: translateX(18px); }\n[data-mn-bar] {\n  position: fixed; left: 0; right: 0; bottom: 0; z-index: 20;\n  box-sizing: border-box;\n  padding: 0.25rem 0.75rem calc(0.25rem + env(safe-area-inset-bottom, 0px));\n  background: var(--mn-surface);\n  border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent);\n  box-shadow: 0 -2px 10px rgb(0 0 0 / 0.08);\n  font-size: var(--mn-prose-size-phone);\n}\n[data-mn-panel] {\n  max-height: 0; overflow: hidden;\n  transition: max-height 0.2s ease;\n}\n[data-mn-panel][data-mn-open] { max-height: 45vh; overflow-y: auto; padding-top: 0.5rem; }\n[data-mn-panel] [data-mn-note] {\n  font-size: calc(var(--mn-prose-size-phone) * var(--mn-panel-scale));\n  margin-bottom: 0.5rem;\n}\n[data-mn-dismiss] {\n  min-height: 44px; padding: 0 0.25rem;\n  background: none; border: 0; color: inherit;\n  font: inherit; font-size: 0.85rem; text-decoration: underline; cursor: pointer;\n}\n[data-mn-dismiss][hidden] { display: none; }\n@media (max-width: 767.98px) {\n  [data-mn-root] { font-size: var(--mn-prose-size-phone); }\n  /* The bar is fixed to the viewport, so it covers the END OF THE PAGE, not just\n     the article: reserve its height after everything the page shows. */\n  html:has([data-mn-root][data-mn-has-notes] [data-mn-bar]) body::after {\n    content: \'\'; display: block; height: var(--mn-bar-space, 9rem);\n  }\n}\n@media (min-width: 768px) {\n  [data-mn-root][data-mn-has-notes] [data-mn-row] {\n    display: grid;\n    grid-template-columns: minmax(0, var(--mn-measure)) var(--mn-gutter);\n    column-gap: var(--mn-gap);\n  }\n  [data-mn-gutter] { display: block; }\n  [data-mn-mark] { display: block; }\n  [data-mn-switches="side"] { display: flex; }\n  [data-mn-bar] { display: none; }\n}\n@media (prefers-reduced-motion: reduce) {\n  [data-mn-panel], [data-mn-knob], [data-mn-track], [data-mn-note], [data-mn-block] { transition: none; }\n}\n'

marginNotesScript

The browser script: attachMarginNotes as a string that runs itself. Put it in the page once (see marginNotesScriptTag) or in your own bundle; it attaches to every layout on the page when it runs, so place it after the layout (end of <body>), or give the tag defer.

const marginNotesScript: string

Injection Notes

Requirements

Peer dependencies:

  • @molecule/app-i18n ^1.0.1

Runtime Dependencies

  • @molecule/app-i18n

  • Do NOT hand-build any of it — no grid of your own, no hover, scroll or IntersectionObserver script, no margin dots, no bottom bar, no switch markup or toggle state. renderMarginNotes + marginNotesCss + marginNotesScript are all of it.

  • Put switchesHtml where the page's toggles go (e.g. centred under the title). It is '' when no block has a note — then there is no gutter, no bar and nothing to toggle, and the prose is centred at the reading measure. At phone width the side switches are hidden and the bar's switches are the only ones: never render a second set yourself.

  • Defaults live in kinds[].defaultOn, not in CSS or a script: a default-off kind's notes are rendered with the hidden attribute and its switches read aria-checked="false" in the HTML.

  • One block per top-level markdown block (paragraph, heading, list, code), each with a stable id; ONE note per prompt or per section summary, its id in the noteIds of every block it covers — it shows once, beside the first. Consecutive blocks with the same noteIds form one row.

  • Kinds are told apart by colour alone (same font, same size): each kind gets --mn-accent-<position> or its own accent. A note's label is a small uppercase line above it (e.g. TL;DR, Prompt · <model>).

  • html on blocks and notes is inserted as is — pass HTML from your own renderer, and escape any plain text you put in it (escapeHtml).

  • Style by overriding the --mn-* custom properties (see marginNotesCss): measure, gutter, prose size, note scale, accents, and --mn-sticky-top (set it to your sticky header's height). The stylesheet selects on data-mn-* attributes only.

  • renderMarginNotes runs anywhere (Node at build time, a server, a browser); the script needs a browser. UI strings (switch group, notes landmark, phone panel, "Hide notes") come from @molecule/app-i18n with the keys of @molecule/app-locales-margin-notes; kind labels, note labels and markLabel are yours to translate.

  • One layout per page by default; give each an id if you render more.

  • Order the stylesheet BEFORE your site's CSS. Its rules are plain attribute selectors, so whichever sheet comes last wins a tie: put marginNotesCss first and your overrides (tokens or rules) after it.

  • Selectors for tests — every piece carries a data-mol-id (placement is side for the desktop switches, bar for the phone bar):

    Elementdata-mol-idalso
    the layout rootmargin-notes[data-mn-root]
    a switch groupmargin-notes-switches-<placement>[data-mn-switches]
    one switch (the button to click)margin-notes-switch-<placement>-<kind>[data-mn-switch][data-mn-kind=<kind>], aria-checked
    a rowmargin-notes-row-<blockId>[data-mn-row]
    a blockmargin-notes-block-<blockId>[data-mn-block], [data-mn-notes]
    a notemargin-note-<gutter|panel>-<noteId>[data-mn-note], hidden when off
    the phone bar / its panelmargin-notes-bar / margin-notes-panel[data-mn-open] when showing notes
    "Hide notes"margin-notes-dismissshown while a block is pinned

    At phone width the side switches are hidden: click the bar ones.