The editorial-HTML channel and its tooling. Covers:
- Marginalia — the typographic callout library for blog posts and paper-style pages
- Pandoc bridge — the Lua filter that emits marginalia classes from markdown
- Web rendering & static capture — Playwright / weasyprint / headless Chrome for DOM → PNG / PDF
- Data-URI embedding — single-file portability trick for artifacts that must travel self-contained
Part of the muriel skill — see the top-level index for mission, universal rules, and channel map.
The editorial layer for blog posts, paper-style web pages, and any prose that needs typographic callouts. 15 components, zero dependencies, dark theme by default. CSS-only — JS is optional.
Library: marginalia on npm (CDN: marginalia@latest via jsDelivr, see below). Source and full pattern catalog in the andyed/marginalia repo; the repo's SKILL.md and llm.md are the reference docs when depth is needed.
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/marginalia@latest/marginalia.css">
<script src="https://cdn.jsdelivr.net/npm/marginalia@latest/marginalia.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/marginalia@latest/marginalia-md.js" defer></script>> [!NOTE] > [!TIP] > [!WARNING] > [!IMPORTANT]
> [!QUOTE] > [!ASIDE] > [!MARGIN]
==highlighted== {Badge} {Badge: tip} [^1](footnote)
{dropcap}
Plain > blockquotes become 3D perspective pull quotes — the signature component. Wrap content in <div class="mg-spread"> for two-column magazine layout.
- Core narrative inline always. Sidebars are for "go deeper" references only — one per section, at the heading.
- Decorative elements ≥3:1 contrast. Don't fade below — compute the ratio.
- All text 8:1 minimum.
- All marginalia classes use
mg-prefix. Theme via--mg-*CSS custom properties — these inherit into child SVGs, so a single theme switch repaints the whole page including embedded vector graphics.
Marginalia essays with math drop KaTeX in via CDN + auto-render. MIT, no bundler, no MathJax-style runtime overhead. Pin to ^0.16. Display math is $$…$$, inline is $…$. Wrap display equations in a .eq-block container themed from --mg-* variables so equations participate in the marginalia palette.
Reference exemplar: inside_the_math — math-heavy essay, marginalia + KaTeX + WebGL demos. Full vocabulary in vocabularies/katex.md: setup, the .eq-block pattern, color/emphasis with \textcolor, server-side rendering for stills.
Do not use KaTeX for paper figures — see channels/science.md, where matplotlib + usetex=True keeps math inside the same TeX pass as the rest of the figure. KaTeX is the web answer; LaTeX is the paper answer.
Pandoc 3.2+ recognizes GitHub-style alerts (> [!NOTE]) via the +alerts extension. A Lua filter at <marginalia>/pandoc/marginalia.lua rewrites pandoc's output to marginalia classes: GitHub alerts → mg-callout[data-type], extended markers (ASIDE/MARGIN/QUOTE) → mg-sidebar / mg-margin / mg-pull, plain blockquotes → mg-pull, ==mark== → mg-mark, {Badge} and multi-word {Text: type} → mg-badge, pandoc footnotes → mg-fn popover, fenced code → mg-code, inline code → mg-inline-code, {dropcap} line marker → mg-dropcap wrap.
pandoc draft.md \
--from markdown+mark+alerts \
--lua-filter <marginalia>/pandoc/marginalia.lua \
--template <marginalia>/pandoc/template.html \
--standalone \
-o draft.htmlSame source fans out to PDF (via weasyprint), DOCX, or EPUB without losing marginalia's typographic voice:
# HTML → PDF
weasyprint draft.html draft.pdf
# Direct to DOCX (filter still applies)
pandoc draft.md --from markdown+mark+alerts \
--lua-filter <marginalia>/pandoc/marginalia.lua \
-o draft.docxThe us-constitution.md example in the marginalia repo — an annotated US Constitution reader's edition. Exercises every transformation: dropcap, all four callout colors, sidebars, margin notes in the desktop gutter via CSS Grid subgrid, pull quote, inline highlights, badges, footnote popover, fenced code, plus a cursive copperplate dropcap initial and closing signature paragraph. Good starting point for any new marginalia page.
The Attentional Foraging F-pattern explainer is the reference exemplar for a warm, reader-friendly light mode that extends marginalia beyond its default OLED palette. Reach for this pattern when a project wants explainer pages, blog posts, or paper drafts on a warm cream background instead of dark.
Note on status: the explainer is live and in active use, but some supplementary sections are still in draft; treat specific claims as provisional until the corresponding draft is lifted.
The F explainer uses the browser-side marginalia-md.js converter via a Node build script, not pandoc. Different path from the pandoc bridge above, same underlying marginalia classes — the build script also post-processes {dropcap} into <p class="has-dropcap"> as a workaround for a marginalia-md rendering quirk.
When to use each path:
- Pandoc bridge — for paper-draft round-trips (markdown → HTML → PDF → DOCX), when the same source needs to fan out to multiple output formats
- marginalia-md.js + Node build — for single-target static explainer pages with heavy custom extensions and inline raw HTML
The F explainer adds four non-canonical patterns. They're worth knowing by name because they solve gaps marginalia doesn't cover:
| Class / pattern | Purpose | Why it's not in core marginalia |
|---|---|---|
.outer-note |
Short punchy margin labels (0.75em, amber #b08050, ~150px wide, position: absolute; right: -180px). Hidden at max-width: 1100px. |
mg-margin is for full-sentence annotations; these are one-phrase rubrications that work like Tufte sidenotes. |
.stats-detail |
Inline span for numerical details with a dashed amber underline (ρ = 0.02, p = .30). Warm cream background, smaller font. |
No native inline-stats treatment in marginalia; inline <code> is wrong semantically. |
.has-dropcap on <p> directly |
{dropcap} line marker post-processed to <p class="has-dropcap">. CSS targets p.has-dropcap::first-letter. |
Works around a marginalia-md.js rendering quirk; conceptually equivalent to our pandoc filter's mg-dropcap div wrap. |
Staged h2 colors via :nth-of-type |
h2:nth-of-type(1..4) get gold/red/blue/green for the OSEC stages (Orient, Survey, Evaluate, Commit). |
Document-structure-driven color. Works because this page's h2s are the OSEC stages in order, so position is semantics. |
The F explainer also floats .mg-margin and .mg-sidebar to the right inside the content area (230px width, amber-tinted #d4a574 border) rather than escaping to the outer gutter the way the US Constitution example does. Reader-friendly on narrower viewports.
| Token | Value | Role |
|---|---|---|
| Background | #fafaf8 |
Warm cream, not pure white |
| Body text | #222222 |
Near-black on Georgia serif |
| Accent amber | #d4a574 |
Margin-note borders, legend edges |
| Note blue | #f5f8fd bg + #2266cc80 border |
Info callouts |
| Tip green | #f2faf4 bg + #22883380 border |
Positive notes |
| Warning amber | #fdf9f0 bg + #cc880080 border |
Cautions |
| Important red | #fdf5f5 bg + #cc444480 border |
Emphasis |
| Stats-detail bg | #f8f4ee + dashed #d4a574 underline |
Inline numerical callouts |
Matching matplotlibrc: muriel/matplotlibrc_light.py. Same Georgia stack, same #fafaf8 figure facecolor — figures rendered with the light rcparams match the editorial palette 1:1.
- Dark (OLED) — default. Blog posts on dark-themed sites, paper figures destined for a dark slide deck, any page using the default
data-mg-theme="dark". - Light editorial — long-form explainers, paper drafts in light review UI, blog posts on light-themed sites. Match to the F explainer when the voice is "reader's edition" not "research console".
Pick one per document. Don't mix dark and light callouts in the same paper or post.
The "DOM is the rendering engine" trick: build the artifact as HTML/CSS/SVG/JS, then capture it with headless Chrome or Playwright. Combines marginalia's editorial chrome, SVG's vector precision, and interactive JS's parameter sliders — then freezes one state into PNG or PDF for distribution.
- Capture an SVG that's CSS-themed at runtime (avoids redoing colors per export)
- Snapshot an interactive demo at a specific parameter state
- Render a marginalia paper-style HTML page as PDF for paper submission
- Build infographics in HTML (CSS Grid, marginalia callouts, embedded SVG) and rasterize for store/social
- Capture a Three.js scene at exact framing for blog hero images
- Generate device-frame screenshots from real page renders rather than mocking in Photoshop
| Tool | Strength | Recipe |
|---|---|---|
| Playwright (Python or Node) | Full automation, waits for fonts/network/JS, supports clip regions | page.goto(url); page.locator('svg').screenshot() |
| Puppeteer | Same idea, Chrome-only, lighter | await page.screenshot({ path, clip }) |
chrome --headless --screenshot |
Zero-script one-off | chrome --headless --screenshot=out.png --window-size=1280,720 file://... |
weasyprint |
HTML+CSS → PDF without a browser; respects @page rules |
weasyprint input.html out.pdf |
- Wait for fonts.
page.evaluate(() => document.fonts.ready)before screenshot, or webfonts render mid-capture. - Set
device_scale_factor=2for retina-quality output. - Use
clipregions to capture a single SVG/canvas instead of the whole viewport. - Inject parameters via URL hash so the same demo HTML can be captured at multiple states (pairs with PermalinkManager pattern).
- Use
--virtual-time-budgetin headless Chrome to fast-forward animations to a stable frame. prefers-color-scheme: darkmatters. Force it viapage.emulate_media(color_scheme='dark')so OLED palette renders correctly.- Capture small multiples in one run. Loop over parameter values, set URL hash, screenshot. Output a grid of frames that compose into a small-multiples figure.
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(device_scale_factor=2)
page.set_content(open('chart.html').read())
page.evaluate('document.fonts.ready')
page.locator('svg#chart').screenshot(path='chart.png')
browser.close()For "what does this look like at mobile/tablet/laptop/desktop?" in one command, the paired module muriel/capture.py wraps Playwright around the tier data in muriel/dimensions.py:
from muriel.capture import capture_responsive
paths = capture_responsive(
url="https://example.com/",
output_dir="captures/",
color_scheme="dark",
)
# Writes four retina PNGs (device_scale_factor=2):
# captures/example-com-mobile-390x844.png
# captures/example-com-tablet-820x1180.png
# captures/example-com-laptop-1280x800.png
# captures/example-com-desktop-1920x1080.pngOr from the command line:
python -m muriel.capture https://example.com/
python -m muriel.capture https://example.com --dir captures/ --dark
python -m muriel.capture https://example.com --selector main --slug hero
python -m muriel.capture https://example.com --tiers mobile-large desktop-qhd ultrawide
python -m muriel.capture https://example.com --full-pageDefaults: four-tier sweep (mobile tablet laptop desktop), device_scale_factor=2, wait for document.fonts.ready, wait for networkidle. Output filenames follow the naming convention in channels/dimensions.md. Exit 0 on full success, 1 if any tier failed, 2 on missing Playwright / Chromium or usage error. Playwright is an optional install — the module is importable without it and raises a clean ImportError with install instructions on first call to capture_responsive().
weasyprint paper-draft.html paper-draft.pdfAdd @page { size: A4; margin: 2cm } to the marginalia stylesheet override for paper formatting.
This channel is what makes the whole toolkit composable. SVG rendered with CSS theming → captured as PNG → embedded in a video. Interactive demo at three parameter states → captured as small multiples → assembled into one image. Marginalia page → captured as PDF for submission. The DOM is the most flexible compositor; static capture is the export step.
The portability trick: embed images directly inside HTML/SVG/CSS as data:image/png;base64,... URIs. Single-file artifacts with zero asset management — they email, paste into Slack, copy onto USB sticks, embed in iframes from sandboxes that block external loads.
- Single-file HTML demos (one file = the whole thing)
- SVG with embedded raster textures (one file = the whole figure)
- Marginalia blog post fragments that need to be paste-able
- Email-friendly artifacts (Outlook/Gmail strip external
<img>until clicked) - Paper figures where the journal wants a self-contained submission
- CSS backgrounds without HTTP round-trips
- Inlining icons inside SVG so a single file renders correctly when downloaded
Bash → data URI:
# PNG → data URI string
echo "data:image/png;base64,$(base64 -i input.png | tr -d '\n')"
# SVG → data URI (URL-encoded, smaller than base64 for SVG)
python3 -c "import urllib.parse; print('data:image/svg+xml,' + urllib.parse.quote(open('input.svg').read()))"Python → data URI:
import base64
def data_uri(path, mime='image/png'):
with open(path, 'rb') as f:
b64 = base64.b64encode(f.read()).decode()
return f'data:{mime};base64,{b64}'
img_uri = data_uri('icon.png')
html = f'<img src="{img_uri}">'Pillow → data URI without writing a file:
import io, base64
from PIL import Image
buf = io.BytesIO()
img.save(buf, format='PNG')
uri = f'data:image/png;base64,{base64.b64encode(buf.getvalue()).decode()}'SVG <image> element with embedded raster:
<svg viewBox="0 0 400 200">
<image href="data:image/png;base64,iVBORw0KG..." width="400" height="200"/>
</svg>Base64 inflates by ~33%. Stay under ~200 KB per image inside HTML; above that, prefer external assets unless portability is non-negotiable. SVGs URL-encode smaller than base64 — use urllib.parse.quote instead.
- Data URIs bypass browser cache. Don't use for images that repeat across many pages.
- Some XML parsers reject very long attribute values.
- Email clients are inconsistent: Gmail strips data URIs in some contexts, Outlook handles them in HTML body but not signatures.
- Don't exceed ~75 characters per line of body prose. Reading span research backs the 65–75ch cap.
- Don't animate layout properties (
width,margin,top,left). Usetransform+opacityonly; layout animations trigger reflow every frame. - Don't use pure
#000or#fff. Cream + near-black per the OLED palette; pure black/white is harsh on OLED and pure white flares on cream backgrounds. - Don't chain more than two CSS custom-property fallbacks. A third fallback means the token system is underspecified — fix the schema, don't paper over it.
Five escalated anti-patterns — channel-spanning but most visible in web work. Named so the muriel-critique agent can cite them directly and so a human reviewer has a finite list of "stop, don't ship this."
-
gradient-text—background-clip: textwith a gradient fill. Unreadable at body sizes (the gradient traverses low-contrast hues by construction, failing 8:1 across its span) and telegraphs designer reached for the default Figma preset. Vary weight/size for hierarchy; keep text one color. -
side-stripe-borders— card or alert withborder-left: 4px solid accent(or any border > 1px on a single side). Identifies the artifact as a Tailwind-CSS-template card at a glance. Use background tint or iconography for the same semantic signal. -
rounded-rect-with-drop-shadow— defaultborder-radius: 8–12pxplusbox-shadow: 0 4px 12px rgba(0,0,0,0.1)is the most-recognizable "AI card" aesthetic. If someone can identify the artifact as AI-generated in the first second, it has no distinctiveness. Make a deliberate choice: square corners + no shadow, a pronounced shadow that commits to elevation, or a different grouping signal entirely (proximity, background tint). -
bounce-easing—cubic-beziercurves with overshoot (elastic, back-out, Material's "playful" namespace). Reads as amateur motion design outside a toy. Use smooth curves —cubic-bezier(0.2, 0, 0.2, 1)or your brand'smotion.easing_defaulttoken. -
reflex-fonts— defaulting to the overused web stack without a reason: Inter, DM Sans, Instrument Sans, Geist, Satoshi, orsystem-uichosen because it's safe. Fine tools, but everyone reaches for them because Vercel's starters do, which means the artifact looks like every other 2024–2026 landing page. Choose deliberately; if your brand doesn't have a display typeface, consider Helvetica Neue (restrained), Söhne (licensed), Work Sans, IBM Plex, or a serif/slab your brand is willing to commit to. muriel'sbrand.tomlhas a[typography]block — use it.
The "absolute bans" escalation and the reflex-fonts concept are borrowed with permission from pbakaus/impeccable (Apache-2.0), rephrased in muriel's voice. The underlying insight that a short named-ban list is a stronger instrument than a long positive-rule list is theirs.