dracula-nineties

CONTRACT.md: what a consumer’s generator must emit

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.

1. Inline the payload

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:

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.

2. Emit this markup

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.

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.

3. What changed, by release

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

The v2.0.0 baseline, in one set

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:

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.

4. Regenerate on a byte compare, not a version string

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.

5. Pin the payload

Three modes are in use. They are not equal.

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.

6. Scope of filter.js

The 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.