This file is for the agent or the script that generates HTML in a consumer repository. It states what to inline, what markup to emit, and what changed in each release. It carries no reasoning. NOTES.md holds the decisions and the prohibitions behind these rules, and README.md orients a person arriving for the first time.
The fixtures are the executable specification. CI fails when they drift from the stylesheet, so when this file and a fixture disagree about markup, the fixture is right. Read it before you invent any.
Two limits on that, and both are stated where they apply rather than left for you to discover.
Most requirements below are modelled in samples/dark.html, a few only in
samples/dark-timeline.html, one only in samples/dark-charts.html, and three in no fixture at
all, which each of those three says in its own bullet.
One requirement the fixture deliberately breaks: its quadrantChart carries
two overlong point labels on purpose, as the stress case for a stylesheet rule that has to survive
them. That bullet is the one place to follow this file and not the fixture, and it says so.
Three files. Each one carries its own wrapper tag.
| file | when | wrapper |
|---|---|---|
dracula-nineties.css |
always | <style> |
mermaid.js |
only when the document holds a ` ```mermaid ` fence | <script type="module"> |
filter.js |
only when the document holds an input.filter-box |
<script type="module"> |
Two supported modes. Pick one and hold it:
sed '1d;$d' and supply your own wrapper. The wrapper is exactly
one line at each end, and CI holds it there, so the slice cannot rot.mermaid.js needs <dialog class="mermaid-overlay" id="mermaid-zoom"></dialog> as the first
child of <body>. It throws a named error when the dialog is absent. Emit the dialog only on a
page that also inlines mermaid.js. A page with no ` ```mermaid ` fence needs neither; a dialog
with no script to open it is dead markup.
Take the CSS from dracula-nineties.css, never from a page. samples/light.html,
samples/light-conn-map.html, samples/light-timeline.html and samples/light-charts.html exist
so a reader can see the light palette on a dark-mode machine. They carry a stylesheet whose
@media conditions were rewritten to force that, so a copy taken from one of them is locked to
light and can never follow a reader’s system appearance. Each says so in a banner.
samples/dark.html, samples/dark-conn-map.html, samples/dark-timeline.html and
samples/dark-charts.html do carry the payload verbatim, but the file is still the source.
Do not inline a second <style> block that re-declares :root inside
@media (prefers-color-scheme: light). A generator that adds its own “force dark” block after
the payload’s <style> wins the cascade on equal specificity, so the page never shows the light
palette regardless of the reader’s OS setting, and prefers-color-scheme still reports correctly
in devtools while the rendered page stays dark. To force one scheme only, rewrite the payload’s own
@media conditions before inlining it (the way samples/light.html does), never append an
overriding block. To respond to the reader’s system setting, inline the payload once and add
nothing after it.
Twenty-seven requirements. No stylesheet change can supply any of them. Most name a string to search for in a fixture, so you have a working example instead of only a sentence. The few with no fixture yet say so.
The pointers are search strings, not line numbers, and that is deliberate. Every fixture in
samples/ is generated by scripts/build-sample.nu, so a line number goes stale on the next
regeneration with nothing to notice: all 22 of the line references this section used to carry were
wrong by v1.38.1, and every check stayed green. A search string survives regeneration, and
nu scripts/maintain.nu check fails when one of them stops matching the file it names.
<main> around the content, with <article> inside it
(in samples/dark.html, search <article>).<section> that is a direct child of
<article> (in samples/dark.html, search <section>). The document shell gives every such
section a one-pixel panel border and interior padding, and a plain <nav> that is also a
direct child of <article> becomes the left rail above 1000px. A section nested inside
another (a .footnotes block) is left unbordered on purpose, so a nested block reads as
subordinate rather than as a panel inside a panel; the border is on article > section, so a
block emitted outside a <section> gets no panel.<label for> on every input.filter-box. A placeholder is not a label
(in samples/dark.html, search class="filter-label").accTitle: and accDescr: inside every ` ```mermaid ` fence
(in samples/dark.html, search accTitle: Decision flow sample).scope="col" on table headers, and heading levels that nest with no skips
(in samples/dark.html, search scope="col").role="list" on every <ul class="nav-list"> (in samples/dark.html, search
class="nav-list" role="list") and every <ul class="icon-list"> (in samples/dark.html,
search class="icon-list" role="list"). Both reset list-style: none on the
<ul> itself, which drops native list semantics in WebKit.data-depth on every row of a <table class="tree">, counting from 0, in document order
(in samples/dark.html, search <table class="tree" tabindex="0">).tabindex="0" plus role="region" plus a label on anything that scrolls sideways: pre
(in samples/dark.html, search <pre tabindex="0" role="region" aria-label="Code block">)
and a <math display="block"> (in samples/dark.html, search
<math display="block" tabindex="0" role="region"), a pandoc span.math.display (in
samples/dark.html, search <span class="math display" tabindex="0" role="region"), and any .table-scroll wrapper (in
samples/dark.html, search class="table-scroll"). Never put role="region" on the
<table> itself. It overrides role="table" and takes the row and column semantics with
it. An unwrapped table takes tabindex="0" alone, named by its own <caption>
(in samples/dark.html, search <caption>Table caption: annotation register), because the
stylesheet hands it a sideways-scroll axis of its own below 1000px.--timeline-date on an ancestor when a page carries more than one dl.timeline, set in
ch against the widest date label in the whole document (in samples/dark-timeline.html,
search --timeline-date: 16ch). Each
list otherwise sizes its own date track and the axis steps left down the page. Measure the
label: tabular-nums pins digits to exactly 1ch while letters stay proportional, so a
spelled-out c. 6th century CE is wider than a numeric c. 1182-1201. Measure at weight
500, the weight dt renders at, and round up to the next whole ch. ch resolves
against the element the variable is set on, which is weight 400, and 400 is about 1.7%
narrower per character, so a value measured at 400 is short by a quarter character at 16ch.
Too small a value has no CSS backstop: the label overflows into the column gap and touches
the rule. One list needs nothing. A dl.timeline.log stacks each date as a heading over its
entry instead of a date column, so it needs no --timeline-date (in samples/dark.html,
search class="timeline log").dl.timeline entry as sup links into a numbered source list, never a
.sidenote (in samples/dark-timeline.html, search class="footnote-ref"). A float cannot
escape a grid item, so the note lands inside the entry column instead of the page margin.
Give each dt an id to make an entry deep-linkable (in samples/dark-timeline.html,
search <dt id="); the arrival outline is automatic once it has one..sidenote carrying the same title, authors and URL again. Link back into a numbered
Sources list instead, the same sup-into-source-list pattern the requirement above already
asks for inside dl.timeline, generalized to prose. No fixture demonstrates this yet.
.sidenote floats into a fixed 28%-wide margin column, the narrowest real estate on the
page; three near-identical citations for one fact, stacked in that column because a
generator re-emitted the same source three times in one paragraph, spend it three times over
for a single piece of corroboration..verdict plus one of .verdict-pass, .verdict-partial, .verdict-failed or
.verdict-neutral on a <span> around a verdict token, wherever a page grades a claim: a
<table> cell, a heading, prose, or a bulleted claim summary (a “What You Need to Know”
list naming a verdict per item is exactly this). Bare text carries no pass, partial, failed
or N/A distinction; the classes carry all of it. .scorecard (a compact .sc-label /
.verdict / .sc-note three-column grid; in samples/dark.html, search
class="sc-label") suits a short two-to-six-item summary. A longer graded list wants a real
<table> instead, scope="col" headers per the requirement above, with the same .verdict
classes inside each Verdict cell (in samples/dark.html, search
<caption>Proof-test scorecard, table form). A row that rolls up sub-rows instead of
carrying its own verdict (a “Five principles” parent row whose sub-rows are what’s graded)
still gets a .verdict verdict-neutral badge, with a token like See below, not bare
punctuation (in samples/dark.html, search
<span class="verdict verdict-neutral">See below</span>). An ungraded row that skips the
badge reads as a rendering gap next
to every other row’s colored pill, not as a deliberate design choice. <strong> is not
.verdict, and reaching for it is the failure this bullet most needs to name: strong
already carries color: var(--orange) for unrelated reasons (see NOTES.md, Form follows
role), so <strong>PASS</strong> and <strong>FAILED</strong> render in the identical
color, and a page that bolds every verdict this way looks styled without carrying any of the
pass/partial/failed/N/A distinction the classes exist for. A table that grades a dozen rows
and applies the badge to zero of them is the same missing markup as the rollup case above,
just at the scale that makes it costliest to regenerate afterward.
.tag-dot on an element that holds no label text, never one that wraps the dot and the
label together (in samples/dark.html, search class="tag-dot"). The class paints its dot with currentColor, so a
color set to recolor the dot recolors any text inside the same element too, and a
categorical accent (--data-1 through --data-4) is contrast-checked for a 3:1 graphic, not
4.5:1 body text. <span class="tag-dot" style="color: var(--data-1)"></span>Rust keeps the
color on the dot; the label sits outside the span as plain text.aria-hidden="true" on every .icon-chip span (in samples/dark.html, search class="icon-chip"). The glyph always sits
beside a visible <strong> label that already states what it identifies, so an unhidden glyph
is a screen reader announcing the same information twice per row. .step-node is the opposite
case and stays real text: it carries no adjacent label of its own.--icon-color on a .step-node set from any accent: a prose accent (--orange, --link,
--purple, --green, --pink, --red) or the --data-1 through --data-4 ramp. The
node fills at full strength and its letter is --surface painted on that fill, so the fill is
a text ground, and both registers are legible under it: --surface sits on every ramp member
above the text floor in every mode, which is what palette-check.py check 8 measures for the
ramp and .verdict-*. This used to be a prohibition when the ramp was pale in dark and
printed; it is a permitted set now. .tag-dot and .icon-chip may also
take a --data-* color, because neither puts text on the fill. No CSS rule can enforce the
choice either way, because the color arrives in an inline style attribute (in
samples/dark.html, search class="step-node" style="--icon-color: var(--orange)").classDef name fill:#hex line inside a ` ```mermaid ` fence on a page that follows the
reader’s appearance. A fence is diagram source, so it has no CSS to read and no var() it
can resolve: one literal hex cannot serve both palettes, and the set in
mermaid-palette.json under classdef is the dark projection. Emitted onto a page a
reader opens in light mode, those fills land at 1.31 to 1.63:1 against the light card, which
is the failure the --data-* light values exist to prevent everywhere else. Let the nodes
take the themed default instead, which mermaid.js already picks per scheme. Only a page
locked to one palette (the way samples/light.html is) may carry the hex, and then it
takes the matching set: classdef when locked to dark, classdefLight when locked to light.
No fixture demonstrates this yet, because no fixture emits its own classDef lines.quadrantChart kept short enough to sit inside the chart. Mermaid writes
that diagram a fixed viewBox that does not contain its own point labels, so an overlong label
paints outside it, and below roughly 700px the overflow lands past the viewport edge where
nothing can reach it: not a scroll container, because SVG ink outside the root <svg> is not
scrollable overflow for any ancestor, and not the zoom overlay, because the overlay scales the
svg and scales the overrun with it. A chart with realistic labels loses nothing at any width
from 601px up. samples/dark.html carries two deliberately overlong labels as a stress case
for pre.mermaid svg { overflow: visible }, so do not copy their length (in
samples/dark.html, search quadrantChart). This is the one requirement the fixture
deliberately breaks, so follow this bullet and not the fixture.--bar on every td.bar inside a table.bar-chart, as a percentage, with the value itself
as text in that same cell (in samples/dark-charts.html, search
class="bar" style="--bar: 21%). The band is a background image, so forced-colors: active
drops it and so does a reader who prints with background graphics off: a cell whose only
content is the band then states nothing at all, while a cell that also carries the number
loses only the second reading of it. Say in the <caption> what the percentage is a share
of, because the component draws no axis and a bare band implies one. State the measure, the
denominator, the cohort and the date range there when they change how a reader reads the
numbers, and put the source in a <span class="byline">. This stays prose: no check can tell
a caption that states them from one that does not. The band has no color
property, and that is deliberate: it is a 0.3 alpha wash of --data-1 in every row, which
is the alpha the number sitting on top of it needs (check 11 of palette-check.py measures
that pair in all four modes). A per-row hue was measured out, because four washes at that
alpha land within Lc 2 of each other and cannot carry a category. Put the category in the row
label..step-node after the first, together with the .step-arrow in front of it, wrapped in
one .step-hop (in samples/dark.html, search class="step-hop"). .step-chain wraps at a narrow measure, and an
arrow and its node are two separate flex items unless paired: a wrap can then land between
them, stranding the arrow on the line above with no node after it and leaving the wrapped node
with no connector in front of it. .step-hop makes the pair one flex item, so a wrap carries
the arrow down with the node it points to..step-chain named in the sentence that introduces it, and aria-hidden="true"
on every .step-arrow (in samples/dark.html, search class="step-arrow" aria-hidden="true").
A .step-node is a square with room for one character, so its letter stays real text and
carries no adjacent label, per the .icon-chip bullet above. That makes this the one
requirement here that asks you for copy rather than for markup: an unexpanded S, I, O,
V decodes to nothing for any reader, and no CSS rule can supply words the stylesheet did not
write. The arrow is the other half. Left unhidden, a screen reader reads the glyph between
every pair of steps, which is the duplication the .icon-chip bullet exists to prevent.title carrying the full text on every <a> inside .recent-group .nav-list li
(in samples/dark.html, search title="Item One"). The stylesheet clips that link to one line with
text-overflow: ellipsis, so a title longer than the row has no way back to sighted mouse
users once clipped. title gives the browser’s native tooltip a value to show. The DOM text
stays intact either way, so a screen reader already reads the full title regardless.aria-label on every .footnote-backref, naming which reference it returns to
(in samples/dark.html, search aria-label="Back to reference 1"). Its only content is ↩, a bare glyph with no accessible
name a reader can act on. Number it: Back to reference 1, Back to reference 2, and so on,
so two backrefs on one page do not announce identically. This holds whether you hand-author
the footnote block or pass a document through a Markdown converter. No stylesheet rule can
supply an accessible name onto content it did not write, so this is the one exception to the
line below: the class arrives from the converter, and the label is still yours to add.
#src-1), never absolute
self-URLs. An absolute self-URL is a full navigation where the reader wanted a scroll, and it
leaves the address bar and the browser history pointing at a different page than the one the
reader is on (in samples/dark-timeline.html, search href="#src-1").[2, 3]) is a link into the numbered source list, not bare
text. The page’s other citations are sup links into Sources; a bare [2, 3] asks the
reader to count an ordered list by hand to learn what corroborates the claim. Emit the same
sup-into-source-list pattern the timeline requirement above already asks for, one sup per
source number. No fixture demonstrates this yet..newthought covers the opening few words of a section’s first paragraph, never the whole
sentence, and never splits a proper name at the span boundary. The class sets small caps at
1.2em and weight 700, which is display treatment: a four-line paragraph inside it reads as
shouting, and of St.</span> John the Baptist breaks a name in half. The stylesheet resets
variant, weight, letter-spacing and size on a note nested inside the span, so the note renders
normally, but the treatment itself is for opening words, not paragraphs. Keep the span short
(in samples/dark.html, search class="newthought").nav.toc names itself with aria-labelledby pointing at its own visible
p.toc-label, never with an aria-label repeating that same string. The index is a landmark,
so it needs a name to be findable in a landmark list, and the label is already on the page:
two copies of it make a screen reader read “On this page” twice on entry. This is the same
duplication the pre.mermaid region avoids, reached from the other direction. Give
the label an id and point at it. Entries are <a> inside <li> inside a single <ol>, so
the two-column rule and its narrow and print overrides have a list to act on (in
samples/dark.html, search class="toc" aria-labelledby).nav.tabs names itself with an aria-label and marks the current tab with
aria-current="page", the one attribute the strip paints differently: that tab takes the
page’s own ground and the rest stay at --surface-alt. Emit <nav class="tabs"><ol><li><a>,
so no nav > a rule matches and the rail, the bracket pseudos and the link separator stay
off it (in samples/dark.html, search class="tabs" aria-label).Markdown constructs otherwise need no classes of their own. Do not invent any beyond what this section already asks for. A converter’s output lands in a theme register already.
Check this table when you move the pin. A row here means your generator needs an edit, because regeneration re-inlines fresh CSS around whatever markup you already emitted.
Each row states what you must do and what is still open. The reasoning behind a change lives in
NOTES.md, which is where the rejected alternatives are recorded, so a row here stays short.
| since | your generator must now |
|---|---|
| v2.8.0 | nothing to emit, but every light-mode page repaints: the light accents darken until each clears 4.5:1 on the row-hover fill as well as on the page, so <strong>, .unverified, .correction, links, .verdict-partial, .verdict-failed and every --data-* fill move together under prefers-color-scheme: light. Dark shifts two greys: --muted (#808080 to #868686) and --rule-light (#666666 to #6a6a6a). A nav index entry is underlined at rest, in its own ink, on a touch client as well as a pointer one. accent-color is back on the controls the sheet does not repaint (range, meter, progress, which now draw as the user agent draws them), and an indeterminate checkbox draws a bar. No new § 2 requirement |
| v2.5.0 | one new § 2 requirement and one removed, and every table and link repaints. A nav.tabs (<nav class="tabs"><ol><li><a>) is now a tab strip: name it with aria-label and mark the current tab aria-current="page". A generator that emitted the outbound-link marker must drop it: the ↗ appended to a[href^="http"] is gone, so the markup needs no edit but a screenshot diff shows it missing. Tables now fence every cell with a one-pixel --rule-light border and fill the header row --surface-alt; links underline in the ink’s own colour; nav.toc is a bordered directory box. Two opt-ins are added: dl.timeline.log, a reverse-chronological log with no date column, and h2.band, a section heading on the violet bar |
| v2.4.0 | nothing to emit, but every page repaints: body copy is Times New Roman (Tinos) and code is Courier New (Cousine), both pinned metric clones, so a screenshot diff moves across every page; every transition, the live-dot pulse, the press scales and the prefers-reduced-motion reset are gone, so the sheet is static; accent-color, scrollbar-color, ::selection and color-scheme are dropped, so native controls and scrollbars draw as the user agent draws them; the body size is a fixed 1rem, and the fluid clamp(), text-wrap, hyphens: auto and the h1 negative tracking are gone. blockquote.pull is removed, so a generator that emitted class="pull" should drop it. Six borrowed components restyle to period forms: .kicker loses its pill, .tag-dot, .live-dot and .step-node square off, .icon-chip becomes a bordered square of --code-bg. No new § 2 requirement |
| v2.2.0 | nothing to emit, but every page repaints: the palette becomes a 1990s register of Dracula, so a pinned consumer that diffs screenshots sees a new ground (#000000 dark, silver #c0c0c0 light), a silver structural tier where the blue-grey was, and a web-safe link blue in light. One CSS-provided addition needs no markup: a:visited now takes --visited, a token separate from --link. No class, no token a generator writes, no § 2 requirement |
| v2.1.0 to v2.1.1 | nothing to emit. The palette reset to the upstream Dracula and Alucard baseline, so a pinned consumer that diffs screenshots sees every accent and ground move at once. v2.1.1 dropped the (muted) qualifier from the template name. No class, no token a generator writes, no § 2 requirement |
| v2.0.0 | the baseline, and every requirement the pre-2.0.0 line carried is part of it, in the set below. Counting in this repo begins here, so no earlier release is a row of its own |
These requirements are part of the v2.0.0 contract, reconciled from every release before it. A generator pinned at v2.0.0 or later must satisfy all of them, grouped by what it must change. Anything not named here needs no edit.
Markup a generator emits:
.table-scroll for a working sticky header, and put tabindex="0" plus a
<caption> on a bare scrolling <table>, in place of role="region".align attribute or inline text-align.<nav> with sibling <a> children to get link separators.tabindex="0", role="region" and a label on any <math display="block"> and on any
pandoc span.math.display.markdown-alert and markdown-alert-<type> class pair on GFM alerts.aria-label to every .footnote-backref, numbered per footnote (Back to reference 1, …).
The glyph carries no accessible name of its own and no CSS rule can add one..verdict plus its four state classes on any page that grades a claim.dl.timeline, emit the class and follow § 2’s two dl.timeline requirements
(--timeline-date when a page carries more than one, and sup citations into a source list).
A dl.timeline.log stacks each date as a full-width heading over its entry instead of a date
spine, so it needs no --timeline-date..step-chain, name its steps in the introducing sentence and put aria-hidden="true" on
every .step-arrow; wrap every arrow-plus-node pair after the first in .step-hop..icon-list, put the <strong> and <code> side by side with no <br>, and mark a file
path with <code>, not <cite>.nav.toc, name the landmark with aria-labelledby pointing at its own p.toc-label.nav.tabs, emit <nav class="tabs"><ol><li><a> with an aria-label, and put
aria-current="page" on the one tab for the current page. The strip paints that tab in the
page’s own ground and leaves the rest at --surface-alt..pie-chart div with a Mermaid pie showData fence; a bar chart is
table.bar-chart with a --bar percentage per cell.classDef name fill:#hex line from a fence on a page that follows the reader’s
appearance.!important added to override the template: the sheet is in
@layer dracula-nineties and unlayered CSS wins on its own.Opt-ins in the v2.0.0 baseline: nav.toc, nav.tabs, details.deep, .edge-list, a
.byline inside a figcaption or caption, .stat-strip, .label inside a caption,
.chart-takeaway, table.evidence-table, td.bar.lead, a dl.timeline with .log, and the six
presentational classes .kicker, .tag-dot, .live-dot, .icon-list/.icon-chip, the .step-*
chain, and h2.band.
Self-contained, no generator edit: the filter’s live count and its no-matches line, the
print and forced-colors fixes, and the Mermaid CDN and layout-engine moves.
The one worth a screenshot diff is the v1.47.0 layout engine change from dagre to the bundled
ELK, which can reflow an existing diagram’s geometry.
A version comment is not a staleness signal. A generated file can carry a current comment over stale bytes, and a file generated before the comment existed carries no version at all. Both classes are invisible to a version check and both are caught by one byte compare.
Store the inline <style> block of each generated file. On regeneration, compare it against the
current dracula-nineties.css body. Regenerate the files that differ.
This is a requirement, not a recommendation. It scales to thousands of files and it needs no metadata beyond what the artifact already carries.
Don’t keep a second version field that duplicates the one you inlined. A generated page that
stamps its own <meta name="template-version"> or similar, separate from the CSS it actually
shipped, can drift the same way a version-string staleness check can: the stamp is written once
and the payload moves on without it. Line 2 of dracula-nineties.css is
/* Dracula-Nineties vMAJOR.MINOR.PATCH */, the version of the bytes you are inlining right
now. Read it from there at generation time if you need to display or log a version, so the number
you show a reader can never disagree with the stylesheet you gave them.
Three modes are in use. They are not equal.
main. Fresh, and gated by CI, but two generations from the same source
can differ.Keep the source. A generator that deletes its own source after converting cannot regenerate, so it can never adopt an improvement. Repairing generated HTML in place is a much harder problem than running the generator again.
filter.jsThe script wires each input.filter-box to the siblings that follow it, stopping at the next
filter box. Within that span it filters tbody tr rows and .nav-list > li items, and it hides
or opens a details.nav-group by whether anything inside it still matches.
A group’s summary .count follows the filter as of v1.27.0. It reads the matched count while a
query is live and returns to the number you authored when the query clears. Emit the span if you
want the behavior; a group without one filters exactly as before.
It does not read your ids and it does not use closest(). The input-to-content pairing is the
only relationship your markup states, so state it by putting the content after the input.
A [role="status"] element inside the input’s parent receives the visible count. A
.filter-empty element receives the no-matches line, and the script creates a hidden one after
the span when your markup omits it. The created one carries its own copy as of v1.25.0, so a page
that omits the element still shows a real no-matches line rather than a blank gap.