Every generated HTML file carries dracula-nineties.css, mermaid.js and filter.js verbatim. Those
files therefore carry no comments. See AGENTS.md. This file holds what those comments would say.
This file states decisions and prohibitions. It does not carry the measurements behind them.
Chromium measured every number. Those measurements live in git history and in the gates.
.github/palette-check.py and .github/render-modes.py enforce the color and mode claims. A number
that matters is therefore a check rather than a paragraph. When a decision here disagrees with a
gate, the gate is right.
Do not re-litigate an entry here without a new measurement. Several changes in this repo were correct on paper, shipped, and then went back out. Every “do not” below is one of those.
Two comments remain in the CSS. A machine reads both.
/* Dracula-Nineties vMAJOR.MINOR.PATCH */).
scripts/build-sample.nu reads it to stamp tokens.css. scripts/maintain.nu bump rewrites it.
Strip that line and regeneration dies. The failure is silent through a pipe, and it leaves stale
fixtures that look correct./* was #rrggbb */ notes on ten :root tokens. Check 3 of .github/palette-check.py
fails when a stated hex disagrees with its oklch().| Section | Covers |
|---|---|
| Fonts | Times New Roman and Courier New, the two weights, the fallback path |
| Type scale | The one body size, em steps, compounding traps |
| Italics | Which eight rules slant, and why h3 does not |
| Width and measure | --page-width, sidenote stacking, hyphenation scope |
| Document shell and the 1996 register | The title box, the left rail, the bordered panels, squared corners, the bracketed nav |
| Paragraphs and section rhythm | Section gaps, .indented, --space-* |
| Lists | Markers, list semantics, dl.timeline and its .log variant |
| Tables | table.tree, widths, fences, fills, .num, sticky th, .table-scroll |
| Links | Underline floor and colour |
| Color and the contrast budget | Grounds, floors, the data ramp, gamut, forced colors |
| Form follows role | Filled and outlined chips, bars and boxes, the hue budget |
| Form controls | The control reset, the redrawn select arrow, the repainted checkbox, accent-color |
| Borrowed components | .kicker, .tag-dot, .live-dot, .icon-list, the .step-* chain |
| CSS charts | table.bar-chart, the alpha under the number, why the CSS pie went out, print and forced colors |
| Progressive disclosure | nav.toc, details.deep, the narrow and print column overrides |
| Editor themes | Why the Rider slot map differs from the prose one |
| Mermaid | Init config, label measurement, sizing, zoom, diagram types |
| Connections-map layout | body.conn-map, markup order, no breakouts |
| Interaction states | Press, hover, focus rings |
| Keyboard and assistive technology | Zoom button, modal overlay, inert, scroll regions |
| Direction, zoom and growth | RTL, text-only zoom, safe-area insets |
| Cascade layer | @layer dracula-nineties, the !important inversion |
| Appearance modes | High contrast, light, --mermaid-scheme, the mode gates |
| Token reassignment, page breaks, chip outlining | |
| Filter | filter.js scope and its load-bearing decisions |
| Nav link separators | nav.tabs, the nav > a + a rule, the wrapped-line artefact |
| Version stamps are not version history | Why bump rewrites three anchored stamps and nothing more |
| Unclaimed elements | mark, kbd, caption, figure, figcaption |
| Markdown coverage | What a converter emits, and how the sheet claims it |
| Raw HTML and other generators | Intrinsic-width media, unbreakable tokens, permalinks |
| Fixtures are coverage | Which fixture details catch a regression, why a pointer is a search string, and the one check that runs the payload |
| Repo layout | Why scripts/ holds the Nushell, .github/ keeps the Python, and AGENTS.md holds the rules |
| Odds and ends | hr, scroll behavior, --ring, the validator |
The body face is Times New Roman, and the code face is Courier New. Those are the two faces a 1996 document actually had, and each one existed on Windows, macOS and Linux. The payload loads a libre metric clone of each from a pinned jsDelivr version: Tinos for Times New Roman and Cousine for Courier New. A reader who already has the real face still gets the clone’s metrics, so the page measures the same everywhere.
Both families ship 400 and 700, and the sheet requests nothing else. Body copy is 400, and
strong, .newthought, dt, .kicker and the small headings are 700. The variable face this
replaced let the body sit at 450; a two-weight family cannot, and a 500 or 600 request against it
renders a synthesized face. Do not reintroduce a middle weight. A 1996 desktop had two stops.
Times New Roman has lining figures, but not tabular ones. Source Serif 4 was tabular; Tinos
measures 1111 narrower than 0000, and tabular-nums does nothing, because tnum is a modern
OpenType feature no period serif carries. A numeric column stays readable only because .num and
[align="right"] right-align it: proportional digits still line up on the right edge. Do not add a
left-aligned numeric column and expect its digits to stack. Georgia was rejected again here, since its
old-style figures are proportional as well and visibly so.
The fallback path is a real downgrade, not an equivalence, and the repo accepts it. Offline the
body stack falls to the system Times New Roman (Windows, macOS), then Times (macOS), Nimbus Roman
(Linux), Noto Serif (Android/ChromeOS), then DejaVu Serif. The mono stack falls to the system Courier
New (Windows, macOS), then Courier, Monaco, Lucida Console, then Nimbus Mono PS (Linux).
font-display: swap keeps text visible during load.
The mono face is Courier New, and mermaid.js names the same stack in its font literal, because
the measurement font must stay the render font (see Mermaid). Do not lead the mono stack
with ui-monospace or Fira Code: both are modern faces, and neither was on a 1996 desktop. The
Rider scheme pins Courier New too, with ligatures off, since the face draws none.
Both font stacks are tokens because three rules need the literal. .mermaid-zoom’s
font: inherit would otherwise resolve to pre.mermaid’s code face, so it re-sets
font-family: var(--body-font) after the shorthand. pre sets its own family too, so a code block
with no inner <code> still matches its neighbors. mermaid.js writes the mono stack out twice as a
JS literal, because neither copy can read a custom property, and each is load-bearing for a
different reason. That duplication is not drift to fix.
No -webkit-font-smoothing: antialiased. That advice is for dark text on light. This theme is
the inverse, and grayscale-only antialiasing thins strokes.
The body font-size is a fixed 1rem. A fluid clamp() is a lever a 1996 page never had: the
sheet sets one size and lets every other step be em-relative to it, headings included. 1rem is the
browser default, and it stays rem and never px, so the page scales with a raised default.
Do not turn the body size into a clamp() or lower it past 1rem without a separate 16px floor
on .filter-box (the iOS input-zoom threshold).
1.75em h1 ┐ headings in em so they track the body size, not the fixed root
1.35em h2 │
1.12em h3 ┘
1em body copy, prose li, .filter-box
0.95em structural: table, aside, nav, .scorecard, .nav-list li
0.9em annotation: .byline, .sidenote, cite, code, pre, footer
0.8em chips: .badge, .verdict
text-wrap is not used anywhere. balance and pretty landed in 2023, and a 1996 browser
wrapped lines one way: the sheet takes that default. Do not put text-wrap: balance on a heading
or pretty on body copy: a stray last-line word is the period’s cost, not a defect to fix at the
price of a modern property.
Nested ratios compound. Check the parent before adding a step. Three paid-for traps:
em, not rem. Anchored to the root they’d diverge from body copy the moment a
consumer changes the body size, and h3 could render smaller than its own paragraphs..verdict/.badge sit at 0.8em, not 0.75em. They nest inside a 0.95em parent, and 0.75em
lands under the 12px floor. Re-check both if the body floor moves.pre code is font-size: 1em to halt the compounding; code/pre each carry 0.9em.Do not write a pt or px floor. An earlier max(Xem, 12pt) scale pinned nine elements to one
size, made body copy the smallest text on a phone, and ignored the reader’s own setting.
h1/h2 sit at weight 400. h3 is 500, the one heading at text size, since 400 would render
lighter than the paragraph beneath it. Nothing at text size may go lighter than body copy: h3-h6,
summary, th.
That rule now covers color too: h5/h6 sit at --label, not --muted. At body size, weight
is the only other axis marking them as headings; on --muted a heading was the dimmest thing in its
own section, the same inversion this file refuses for dl.timeline > dd. --muted is pinned at the
contrast floor; --label moves toward body text, so that’s where a heading belongs. h3/h4 stay at
--label, not --on-surface: both are already larger or heavier, so size and weight do the work
color alone has to do at h5.
code/cite sit at 0.9em, x-height-matched against the mono stack.
li carries no font-size of its own. At 0.95em a bulleted list read subordinate to body copy,
and nav > ul > li compounded to the smallest text on the page. .nav-list li and nav ask for
0.95em themselves instead.
Eight rules slant: .byline, h2, th, summary, blockquote, details.nav-group > summary,
.filter-box::placeholder, .filter-empty.
h3 is upright and h2 is italic, deliberately. Adjacent levels sharing an italic would
distinguish neither, resting the hierarchy on size and color alone; upright h3 gives the pair a
second axis, and italic costs the most legibility at text size on a dark surface anyway.
summary and th are both 400, italic near reading size. Italic and color are what separate
them from the copy around them, because the two-weight face ships nothing between 400 and 700.
th can’t drop its declaration (UA default is bold), so italic plus --pink carry the distinction
instead.
Page width is one number, --page-width: min(94vw, 66rem) in :root, shared by body and
body.conn-map’s article so the two layouts cannot drift. 94vw is the proportional dial, 66rem
(scales with root size) is the 1996 document measure, and 100% - 2 * var(--gutter) on body is the
floor respecting the safe-area insets folded into --gutter. This is the width every consumer page
inherits, so a change here is a decision rather than a dial. It narrowed from min(90vw, 160rem) with
the document shell below.
Sidenotes stack below 1000px, not 600px: the 28% float is narrower than any measure worth reading between 600 and about 1280px.
Hyphenation is off: the sheet declares no hyphens. The property landed in 2015, long after the
period, and a 1996 browser never broke a word across lines. Do not scope hyphens: auto back onto
the narrow measures: the sidenote and .col-2 wrap without it, the way every page in the
reference corpus does.
The 1000px and 600px breakpoints stay separate on purpose: the 600px block is genuinely phone-sized, and one breakpoint wrong for half its contents beats two with distinct reasons.
The shell is the be-1996 frame: a filled title box, a narrow left rail, a center stack of bordered
panels, and a bracketed link row. That page is the closest single reference in inspiration/ to the
objective, so the document follows it rather than a multi-column grid. The corpus says the same thing
at scale: of the references, about 20 carry a left link rail and only 2 name a bevel, so the rail is
the register and the 3D edge is not.
h1 is a filled box, --code-bg with --on-surface text. The heading is the masthead, not
run-in text, which is what the reference’s navy title box is. This reverses the pink h1: the box
is a text ground, and --pink on the light --code-bg (the Win9x #d4d0c8) fails the text floor,
so the pair is --on-surface on the box in every mode. --on-surface is not one of check 5’s TEXT
roles, so that pair is not gated; it clears by construction (about 15:1 dark, 9:1 light) and the mode
renders show it, but the gap is recorded here rather than papered over.
Every direct <section> child of <article> is a panel: a one-pixel --rule-light border with
interior padding. The border sits on article > section, not on bare section, because a nested
section (the .footnotes block, an .indented block) then reads as subordinate content rather than
as a panel inside a panel. A bare section selector boxed the footnotes inside their own box, which a
render showed. Do not move the border back to bare section.
The plain <nav> becomes the left rail above 1000px. Below that width it stays the horizontal
nav:has(> a) flex row. Each <a> takes a bracketed pair, [label], through ::before/::after,
with the empty alternative text the decorative-glyph convention already requires (the
@supports (content: "x" / "y") twin, after its base rule, asserted in .github/script-probe.py).
The rail is 15rem wide so the fixtures’ longest label (REDISCOVERY AND SURVEY) holds one line; a
longer consumer label wraps inside its bracket pair, the same accepted artefact class as the wrapped
nav > a + a separator.
The rail is position: absolute with inset-block: 0, and the divider is the full article
height. Grid was the first build and it needed grid-row: 1 / span 1000 to make the rail’s area
tall enough for the row heights to track the content column, because grid-row: 1 / -1 resolves to
row 1 on an implicit grid in Chromium: a magic number for no gain. Absolute positioning has no row to
span and no magic number, and it drops the sticky with it. A 1996 rail scrolled with the page, so
sticky was never wanted. Do not put the rail back on a grid row-span.
The rail rules are scoped three ways, and each scope is load-bearing.
body:not(.conn-map), because body.conn-map already has its own two-column layout whose first
section is the sticky link rail. Without the scope the new panel border drew a second border inside
that rail and the page read as two rails.article:has(> nav:not(.toc)), because a page with no plain nav (the charts fixture, a page built
from the toc alone) must not take the rail’s 17.5rem indent. A bare article selector left a wide
empty left margin on those pages, which a render showed.> nav:not(.toc) and > section, so the toc, the margin notes and the byline are not caught.Corners are square: --radius and --radius-sm are both 0. border-radius postdates the
period, and the sheet’s boxes are period boxes. Every rounding went with it, including the shapes
that are not boxes: .tag-dot::before, .live-dot and .step-node are squares, and the .kicker
pill is plain text (see Borrowed components). A 1996 bullet, a GIF status
marker and a process node were all square. Do not round a dot, a node or a chip.
section spacing matches h2’s top margin, collapsing to one value; unequal values hid a
defect (a section opening with anything but h2 got the smaller gap) rather than caused one.
section > :first-child { margin-top: 0 } was tried and rejected: it flattens the gap under the
byline, and in body.conn-map the sections are flex items where margins don’t collapse at all, so a
rule tuned for the collapsing case misaligns the two columns.
.indented (book setting) is opt-in, not the default, because the two conventions can’t mix on
one page without reading as an accident, and it’s not what existing consumer output assumes. It’s
two rules: margin-block: 0 on paragraphs, text-indent: 1.5em on every p + p (hung off the
sibling combinator since nothing precedes the first paragraph).
A sidenote marker must not use vertical-align: super, which grows the line box and throws off
every paragraph with a note. position: relative; top: -0.4em; line-height: 0 lifts the glyph with
no line-box impact instead.
.sidenote/.marginnote reset what a surrounding .newthought inherits into them (small caps,
weight 700, tracking, 1.2em size), so a note pins the annotation register regardless of where its
anchor lands. Do not replace the reset with a markup rule. A rendering invariant is the
stylesheet’s job.
--space-* covers block rhythm only; component padding stays literal, tuned by measurement
rather than snapped to a scale that would move rendered boxes to satisfy an abstraction. The list
indent stays literal too, pairing with --tree-step rather than the vertical scale.
Paragraph, sidenote and caption wrapping is the browser default. text-wrap is not used
anywhere in the sheet; see the type-scale decision above for the reason and the prohibition.
A prose ul keeps its markers. A global list-style: none reset made bulleted lists read as a
run of short paragraphs and made li::marker dead code. ul/ol share one indent; ul keeps the
muted UA marker progression.
That’s also half of a list-semantics problem: WebKit drops list semantics from list-style: none.
.nav-list/.icon-list reset it on the <ul> itself, so both need role="list" in markup, a
CONTRACT.md consumer obligation.
.edge-listTwo tracks carry a hairline between them. An antecedent/descendant pair reads as one unbroken
field when a gap alone separates the columns, so the second track takes
border-inline-start: var(--edge-rule) and the gutter becomes two half-gutters of padding instead
of a column gap. It is the same move dl.timeline > dd already makes: a structural hairline at
--rule-light, not a box and not an accent bar.
--edge-gutter and --edge-rule exist so each collapse site resets the divider in one
declaration rather than three. .edge-list collapses to one track at 600px, and again inside
body.conn-map, where the first section is a narrow link rail rather than a pair. A divider left
standing in either state is a stray vertical line beside nothing, so both sites set
--edge-gutter: 0 and --edge-rule: none. Do not delete either reset because the conn-map one
looks unused: no fixture page puts an .edge-list in the rail, and CONTRACT.md § 2 leaves that
markup to the consumer, so the rule collapsing it there is defensive by design.
.recent-groups does not take the same divider. Its auto-fit tracks number anywhere from one
to several, and no selector knows how many tracks were laid out, so a rule cannot be placed between
them. Getting one there costs a script or a fixed column count, and both are worse than the gap.
.recent-groupsA landing index of several category lists is auto-fit, not a fixed column count.
repeat(auto-fit, minmax(min(36rem, 100%), 1fr)) sizes to whatever count a generator emits, unlike
.edge-list’s fixed two columns (always an antecedent/descendant pair). This is also why it needs no
breakpoint override where .edge-list does: below two 36rem tracks, auto-fit collapses to one
column on its own.
A bare minmax(36rem, 1fr) only fixed the column count, not the surviving column’s own width:
it still floored at 576px, overflowing the document below that, a real WCAG 1.4.10 failure.
minmax(min(36rem, 100%), 1fr) is the standard guard against exactly this auto-fit trap.
Item count and the “view all” link are a generator concern, not a stylesheet one. An uncategorized
.recent-group is styled identically to a named one, one class not two.
A .recent-group .nav-list li is one line, title leading with .count trailing on the same
row, never below it. a { display: block } made the date wrap to its own line beneath the title,
outranking it visually. Now display: flex: the anchor (flex: 1 1 auto, min-width: 0, ellipsis
truncation) keeps the title from wrapping and pushing the date off the row; .count (flex: none,
--muted, 0.82em) never shrinks to make room. Do not revert to display: block, and do not let
the title wrap.
.recent-group and its .nav-list stretch to fill the grid row (flex-direction: column plus
.nav-list { flex: 1 }), so a card with fewer items doesn’t leave its “view all” link at a different
baseline than its neighbors.
dl.timelineA timeline entry is content, not annotation. dl.timeline > dd/dt both take --on-surface
(not the plain-dd caption tier), since the date is the axis a reader scans. An h3 date line is
the wrong shape too: label-tier weight 500 reads fainter than the event title, and dates never align
into a column.
dl.timeline.log is the reverse-chronological log, the shape the corpus’s developer logs take
(fallout-2-1999 among them): one dated post under the last, newest first. It drops the spine
(display: block), the dt becomes a full-width heading with a hairline beneath it, and the dd
loses its own start rule and indent. It wants no --timeline-date, since there is no column to
align, and the narrow-width collapse already matches what .log wants, so the two rules never
conflict.
--timeline-date exists because era groups are separate lists, and max-content sizes each
track against only its own rows, so a multi-list timeline needs var(--timeline-date, max-content)
to pin one width in ch across lists CSS otherwise cannot see (no shared-parent subgrid applies).
Measure the widest label at weight 500 (what dt renders at) and round up. tabular-nums pins
digits to 1ch; a spelled-out century often beats a numeric range. A short --timeline-date has
no CSS backstop: minmax(var(--timeline-date), max-content) does not fix a bled label, since the
1fr sibling consumes free space first. The CONTRACT.md measurement instruction is the only
defense.
text-align: end plus tabular-nums does not align mixed date formats (real labels end in CE
or s, not always a digit); tabular-nums stays only to make the ch measurement predictable.
text-align: start was rejected, opening a wide gap between a short date and its rule.
No .approx class exists for a c. prefix: no generator emits a span for it, and a class with
no emitter is a guess about markup this repo doesn’t control.
A floated .sidenote can’t escape a dl.timeline entry (the float resolves against the dd).
Cite a timeline entry with a sup link into a numbered source list instead: better at density
too, since a margin column loses sync with its anchors down a long page.
white-space: nowrap on the date releases below 600px, where the collapsed layout has no track
left to protect and nowrap would be the one thing pushing the page wider than the viewport.
A deep link’s arrival cue is an outline, not the --highlight wash (which takes link text
under 4.5:1). It costs no layout shift, reads distinct from the link-blue focus ring by hue, and
is static, so it needs no reduced-motion exemption the way a flash would. dt:target takes the orange text color
instead (an outline can’t reach a dt, and a background wash can’t either since a grid gap takes no
background). :target also carries scroll-margin-block-start, so a deep link doesn’t land with its
era heading scrolled off above. Smooth scrolling was rejected: on a long page it becomes a long
animated scroll, and the outline already answers “where did I land”.
.footnote-ref is nowrap, scoped there rather than to sup since a converter may put anything
in a bare sup. The citation marker’s hit area grows with padding-block alone, never
padding-inline, which would drag the underline out past the digit.
Print: break-inside: avoid on the entry, never the list (a whole timeline forced onto one page is
worse). break-after: avoid on dt is not needed (the grid row travels as a unit, measured).
The collapse to one column happens at 760px, not 600px: the date track doesn’t shrink out of the
way below that, leaving a narrow prose ribbon beside a wide empty date column. A container query
was rejected: container-type: inline-size on
<article> would apply layout containment to every consumer’s whole document for one component’s
breakpoint.
A strong inside an <a> repaints the link (strong { color: var(--orange) } wins on the inner
element), so take strong out of any anchor a consumer wraps a link title in.
Nothing in dl.timeline encodes elapsed time, by design. A proportional axis buries most content
in a pile and most of the page in whitespace; era grouping already does the coarse chunking, and
anyone wanting a real axis wants a chart, which this stylesheet does not grow into.
No font-family on table. Tables inherit the body serif (tabular, lining digits by
construction). Do not put tables in the mono stack: costs about a quarter of table width and
puts tables in a different register from the prose.
Every th and td is fenced with a one-pixel --rule-light border, the table carries one too,
and the header row is filled --surface-alt. The 1996 corpus drew its directories as fenced cells
headed by a bar (altavista-1999, “each cell fenced by a one-pixel rule and headed by a colored bar”;
tucows-1996, “a flat one-pixel bordered box with a heading row”). The sheet had drawn a modern
booktabs table: horizontal hairlines, no vertical rules, no header fill. Do not strip the fence
back to hairlines.
The frame is the period’s doubled line, and it comes from border-collapse: separate with
border-spacing: 2px, not from a thicker single border. The table border, every cell border and
the table’s own frame each draw their own one-pixel line 2px apart, which is the heavy-framed table
a maintainer’s reference screenshot asked for. The fence was a collapsed one pixel through v2.5.0
and a collapsed two pixels at v2.6.0; the corpus drew flat one-pixel rules and both collapsed reads
were period, but the doubling is what the reference shows. Do not set border-collapse: collapse
back: it merges the table and cell borders into one line and drops the doubling. Do not raise
the cell border width to recover the frame: the frame comes from the second border 2px out, not
from a thicker first.
The header fill is --surface-alt, and the sticky th carries a box-shadow halo of the same
colour. With separate borders the 2px spacing between header cells belongs to no cell, so a
scrolled row paints through it. Do not drop that halo while the borders stay separate: it is one
line and it is the only thing covering the gap. Forced colors suppresses shadows, so the gap is
exposed there, the same accepted trade the print and forced-colors sections already take; the mode
still restores the grid. The forced-colors block’s structural currentColor border tracks the same
one-pixel width. The header fill is the row-hover ground, so --pink’s floor against it is already
check 5’s. A saturated header bar in the corpus’s own colors would need a new inverted
accent-as-ground pair and a check-8 entry, which the hue budget does not have to spend; the neutral
band keeps the heading row without one.
table.tree is a table, deliberately not a treegrid. That role promises roving tabindex,
arrow keys, aria-level/expanded/posinset/setsize, a keyboard contract this repo ships no
script for, and it would strip the native row/column semantics a plain <table> announces. Depth is
an author attribute (data-depth); everything else is presentation.
--tree-step custom property, levels 0-3 only. attr() can’t feed a length
into calc() with useful support; a per-row custom property pushes styling into the generator. A
level-4 row degrades to flat, not wrong.--label at levels 2-3, not smaller type (nested ratios compound, and
the table already sits at 0.95em).↳ needs the same alt-text treatment as every other decorative glyph (content: "\21B3\A0" / ""
behind @supports), or the glyph lands in the row’s accessible name. Any future decorative
::before owes this.width: auto; max-width: 100%, not width: 100%, or a narrow table stretches to the full page.
The sideways-scroll escape hatch is @media (max-width: 1000px), pairing display: block with
width: fit-content (block alone reinstates document-level scroll via width: auto; fit-content
resolves correctly for both narrow and wide tables). The trigger is smaller than it looks: it starts
at 200% text-only zoom, not 400%.
The hatch’s cost is the sticky header, inert below 1000px, deliberately: a page that scrolls
sideways is a WCAG 1.4.10 failure at every width it happens, and that outranks a pinned header at
desktop width. The opt-in answer to both is .table-scroll. Chromium does not strip table
semantics on display: block, measured rather than assumed; role counts are identical above and
below the breakpoint.
A sticky th needs an opaque background and a rule that survives the stick: the header fill is
--surface-alt, which is opaque, and with separate borders the header cell keeps its own border as
it sticks, so no inset-shadow substitute is needed. The box-shadow on thead th is the gap halo
described above, not a border: the border itself is the cell’s own rule.
Two consequences of the current fills are load-bearing: table.tree [data-depth="0"] td is the
only in-table --code-bg fill (the meaning is root row; the header band is --surface-alt, a
different ground), and tbody tr:hover td keeps its tbody qualifier to stay clear of thead.
.num is an opt-in class, not a heuristic: CSS can’t tell a number from a label, and :has()
can’t match text content. Goes on the th too, or the header floats off its own column.
An unwrapped table takes tabindex="0" and a <caption>, never role="region". The escape
hatch makes it a scroll container, and an unreachable one is a 2.1.1 failure Chrome papers over (with
a non-conforming ring) but Firefox and Safari do not. role="region" on the <table> overrides
role="table" and takes row/column semantics with it, the same defect as role="button" on
pre.mermaid. This rule is gated in scripts/maintain.nu check (prose alone didn’t hold it):
the check deletes the .table-scroll-wrapped case first, then asserts tabindex="0" on every
remaining <table> and no role="region" on any.
.table-scroll is the opt-in wrapper for a wide table: overflow: auto with a 70vh cap plus
.table-scroll > table { display: table }, scrolling both axes and keeping the header pinned. A
wrapper scrolling only one axis does not work (forces the other off visible, so the wrapper
becomes the scrollport and the header still leaves). tabindex="0", role="region" and a label go
on the wrapper. The wrapper stays opt-in and overflow-x stays on table too, so both paths run at
once rather than breaking un-wrapped consumers.
.table-scroll carries scroll-padding-top: 3em, matched to the sticky th’s height, or a
focused cell in an early row lands under the pinned header (WCAG 2.4.11), confirmed by a headless
repro: without it a focused link’s rect sat inside the header’s bounds, with it the native scroll
cleared the header by about 46px. The fixture’s wide table carries one link for exactly this reason.
Underline thickness has a 1px floor. A sub-pixel underline paints as a faint partial-coverage
line, and the underline is the only thing that marks a link. overflow-wrap: break-word lets a long
URL or slug break rather than escape its container (the conn-map Links column runs as narrow as
220px).
The underline takes the link’s own colour. text-decoration-color was --muted, which painted
the underline quieter than the text it marked; the period underlined link carried its own blue
(apogee-3d-realms-1996, “medium blue, underlined”). The property is left at its currentColor
initial, so this is the browser default the sheet stopped overriding. Do not dim
text-decoration-color back toward a tier: the underline is the one affordance a link has, and a
quieter underline weakens it.
.nav-list li a no longer suppresses the underline until hover. It carried
text-decoration-color: transparent, restored on @media (hover: hover) only. A touch client never
matches that query, so every nav index entry, conn-map column and recent-group link was left
distinguished by colour alone, and CONTRACT.md § 2 asks a consumer page to work with a finger.
Do not restore the transparent/restore pair: the hover rule keeps its --code-bg fill, which is
what a pointer gets instead.
There is no outbound-link marker. The sheet painted a ↗ after every a[href^="http"]. It is
gone, because none of the references carries one, and a glyph that promises a destination the
link never names is a modern docs convention. Do not reintroduce an external-link glyph.
A decorative ::before/::after glyph carries empty alternative text (content: "…" / ""),
behind @supports (content: "x" / "y"), since the alt-text syntax is a single value a browser that
can’t parse it discards whole (Firefox ESR still ships in that state). The twin sits after its
base rule, never above it. @supports adds no specificity, so a twin above the base loses on
source order and the glyph reaches the accessibility tree anyway (a tree cell once read “↳ F1”, and
a screen reader once read “north east arrow” after every outbound label).
.github/script-probe.py asserts every twin that remains. Print drops the underline (the
destination is unreachable on paper).
cite is monospace and font-style: normal: the browser default (italic serif) is indistinguishable
from <em> in this theme.
a:visited takes --visited, a token separate from --link. A period page had exactly two link
states, and the color a visited link took was part of the browser’s own defaults (Mosaic’s
#551A8B). One rule paints it. Do not fold --visited back into --link: the two states must
read apart, which is the whole point of the token, and each carries its own floor in check 5.
The :root block is the only source of color truth. tokens.css, mermaid-palette.json, and
the inline hex in mermaid.js are machine-checked projections of it. Measure a composited color
from rendered pixels, never a computed value: a computed-value reading reports the un-composited
mix, which is wrong (two surfaces went unmeasured this way for a long time).
The palette is a 1990s register of Dracula: a black ground, a silver structural tier, and the
Dracula pastels kept as the accents. This replaces the earlier “authoritative upstream baseline,
exact hexes” decision, which held that the sheet should ship Dracula’s own tokens and move the gates
to fit them. Two things forced the reversal. The upstream Background #282a36 is a purple-blue
charcoal, and period pages are overwhelmingly black-on-#000000 or white-on-#ffffff; the exact
upstream ground never looked 1990s. And Dracula’s structural tokens are a blue-grey family, where the
period register is un-hued silver (#c0c0c0, the X11 silver and the Netscape default ground). The
accents stay exactly Dracula, so it still reads as Dracula. Do not restore the upstream #282a36
ground, and do not re-saturate the accents to “recover” the baseline: both were measured, shipped,
and reversed.
Text can land on three grounds (--surface, --code-bg, --surface-alt) and a new token must
clear its floor against all three. Dark --surface is pure black; --code-bg stays Dracula
Current Line #1e1e2e, now the lighter fill against the black and the hardest dark ground;
--surface-alt stays the darkened #12121a so a hovered row remains distinct from a code block.
Light inverts to a silver scheme: --surface is #c0c0c0, --surface-alt darkens to #b0b0b0, and
--code-bg is the Win9x dialog face #d4d0c8. Do not raise the light ground toward white: the
silver field is the light-mode half of the period register, and #c0c0c0 is the one value that both
Netscape and the X11 silver name agree on.
An accent can also be the ground. .verdict-* and .step-node (including the --data-* ramp
now) both invert and paint --surface text on an accent fill; check 8 gates both (print is exempt
for .verdict-* alone, since its print block swaps to an outline).
The link is the period bright, and there is now a --visited token. Dark keeps the Dracula cyan
#8be9fd; light is a blue derived from the HTML5 default #0000EE, which cannot be stated exactly
because it sits on the sRGB corner and clips. It is held at 95% of the ceiling and darkened until it
clears the body floor on the hover fill as well as on the page, so it is no longer the lightest blue
that reads as the period default. --visited is the HTML5 visited
purple: #bd93f9 dark (Dracula’s own purple, so the token is not new ink) and #551a8b light, the
Mosaic-default visited color. A link and a visited link are the two link states a 1990s page actually
had, so the pair is role-exact, not decorative. Do not collapse --visited back into --link:
the two must differ, and both carry their own contrast floor.
--ring is oklch(1 0 0 / 0.18), up from 0.1. On the black ground a 0.1 white ring is
invisible. Do not lower it back to the old alpha while the ground is black.
--purple-bright is a literal in all four modes. --purple sits near the sRGB ceiling, so a
relative calc(l + 0.07) off it leaves sRGB: chroma has to fall as lightness rises. Dark, high
contrast and print state it outright. Do not put it back in relative color syntax, and do not
park it on the gamut boundary (the light value is held at 94% of the ceiling for exactly this).
--highlight alpha is 0.30, down from 0.35: the Dracula orange is bright, and mark’s
--on-surface text clears 4.5:1 on the wash over --code-bg at 0.30 and not at 0.35. Check 9
measures the composite.
Floors by mode: default 4.5:1, prefers-contrast: more 7:1, light 4.5:1, print 4.5:1. Every mode
that paints text carries the body floor; high contrast carries 7:1. Rule tokens sit at 3:1 against
--surface and --surface-alt, and the data ramp at 3:1 against --surface and --code-bg. A
rule is never drawn inside a code fill, so --code-bg is not a ground for that role, and print’s
near-white --code-bg would fail a token that is fine where a rule actually lands. The default floor
was 3.0 and then 4.0, and the light floor 3.2, each priced to carry a token the palette has since
been re-derived around; the accents were darkened to meet the body floor rather than the floor
lowered to them, and FLOOR_OVERRIDE is empty. Do not lower a mode floor to fit a token: raise
the token, or name the pair in FLOOR_OVERRIDE and record the shortfall here. A text pair that
fails in the light mode’s own hover fill is a text pair, which is why the light accents are darker
than the sRGB hex they were sampled from.
--label and --muted are the silver structural tiers and have no upstream equivalent. --label
carries headings h3-h6, .badge text and kickers; --muted carries comments, dt terms in a
timeline and de-emphasized notes. --label is #c0c0c0 (the silver itself) and --muted is
#868686, so the two read apart where they co-occur, and --rule-light #6a6a6a is a third step
down. Light inverts the tier to near-black on the silver field (#262626/#404040/#5d5d5d).
Do not reintroduce a blue-grey tint into these tokens: un-hued silver is the register, and a hue
there is what made the sheet read as Dracula-generic rather than 1990s.
The row-hover fill, --surface-alt, is a flat token that darkens, not a lightening color-mix.
A color-mix composited to a lighter row that took every accent below floor. Do not reach for
another color-mix: --surface-alt already exists and needs no compositing to reason about.
aside has no fill at all, screen or paper: a tint took a cite/.sc-note/.count/::marker/
status span below floor. The orange accent bar marks the callout instead.
Borders on --code-bg take --rule (which resolves to --muted). With the silver --muted on
Dracula Current Line the border is now well clear of 1.4.11, where the old blue-grey --muted sat at
1.94:1. Do not introduce a --rule-control token: the token that was going to exist for exactly
this is unnecessary now, and the note that asked for it was a consequence of the baseline that has
since moved.
em carries no color, it inherits: correct inside aside/blockquote/.sidenote, matching
surroundings rather than overriding a color they already chose.
--highlight is a body-copy surface only. mark pins color: var(--on-surface) since every
other tier fails on the wash. Do not paint the wash under anything but body copy (a li:target
highlight was built, measured, and removed on this basis).
.kicker’s pill carries a border, because its 8% fill is near-invisible on black. The fill is
oklch(from var(--link) l c h / 0.08), an 8% wash of the link cyan. Over the old #282a36 ground it
read as a faint pill; over pure black the composite is almost the black itself. The fix is a
oklch(from var(--link) l c h / 0.65) hairline on the same pill, which clears 3:1 against the ground
in both registers (6.43 dark, 3.26 light) where the fill alone does not. The fill stays at 0.08:
raising it trades the pill’s visibility for the kicker text’s contrast, which falls as the fill moves
toward the link hue. Do not darken the wash, and do not drop the border while the dark ground is
black.
--data-1 to --data-4 are the period categoricals: cyan, pink, green, yellow in dark; blue,
magenta, green, olive in light. They exist so a diagram category can be told apart; hue separation
between members is what matters, since they co-occur in one diagram. The ramp carries its own light
and print values (a single base :root declaration once drew dark-ground fills on a light page).
--data-1 shares the link hex deliberately (Dracula has eight colors and spends cyan on both), as
do --data-2/--pink and --data-3/--green in dark. Dark --data-4 stays the Dracula yellow
#f1fa8c; the light ramp moves to fully web-safe values so a diagram on the silver page reads as
period stock. Do not “fix” the --data-1/--link collision by re-muting a ramp member.
A --gold token was proposed and dropped. A warm web-safe gold was a candidate for --data-4
and for strong. No declaration consumes a token by that name, and the light ramp already reaches an
olive gold for --data-4, so a separate token would be ink with no reader. Do not add --gold
without naming the consumer first.
A pie slice renders the token. Check 5 always asserted the token cleared the
non-text floor (governing a classDef fill, a legend swatch, any direct var(--data-2) use) but
said nothing about what a pie fence itself rendered at Mermaid’s default 0.7 pieOpacity.
pieOpacity: '1' closes that gap; check 10 measures the label that lands on it. See
Diagram types.
classdef/classdefLight fills both exist, check 2 gating both sets plus the letter each paints
on its own fill. Both sets paint --surface: the dark ramp is legible under black and the light
ramp under #c0c0c0 (4.80 to 5.42:1), so neither set inverts to --on-surface.
CONTRACT.md § 2 bans a classDef name fill:#hex line on any page that follows the reader’s
appearance, permitting it only on a page locked to one palette: a fence has no CSS to read, so one
literal hex can’t follow appearance and no palette work changes that.
A declared chroma sRGB can’t hold is silently clipped, and every contrast check stays green
(three high-contrast tokens shipped that way for two releases, since oklch_to_linear clips before
measuring). Check 7 bisects the sRGB boundary and gates every parsed token in every mode. The trap
is directional: the chroma ceiling shrinks as lightness climbs, so raising L for “more contrast”
washes a color out faster than the numbers predict. Do not park a token exactly on the gamut
boundary: it tips out on any later lightness nudge. The light period blue is the sharp case, since
the HTML5 default #0000EE sits on the sRGB corner: --link and --data-1 are declared at 95% of
the ceiling, --purple-bright at 94%, and all three are gated.
Every token is an exact sRGB value, so the sRGB ceiling is the only ruler. The old sheet carried
a Display P3 widening for six accents and two palettes with it; that is retired, P3_WIDENED/
P3_MODES are gone, and every hex-only consumer (Mermaid, the editor themes) gets a straight
conversion rather than a gamut map. Do not re-widen a token into P3: it reintroduces a color the
hex-only projections cannot show.
High contrast compresses the accent set (the ceiling collapses at high lightness), and no token
edit fixes that. Text mitigates it (verified/unverified/correction spelled out), not color.
Do not try to widen these by a hue move: chroma compressed, not hue separation.
An APCA reading inverts the WCAG 2 story (dark mode runs below light mode on identical roles), and no token moved because of it: that’s the known WCAG 2 overstatement of light-on-dark, not a defect here, and a flat Lc threshold misapplies APCA, whose threshold falls with size and weight.
.github/palette-check.py runs eleven checks: (1) hex projections in both Mermaid palettes; (2) the
classdef fills in both sets plus their painted letter; (3) /* was */ provenance comments; (4)
stray hex in mermaid.js; (5) the contrast floor in all four modes against three grounds, plus rules
and the data ramp (FLOOR_OVERRIDE is empty since the reset); (6) --mermaid-scheme both directions,
no prefers-color-scheme in mermaid.js, and the (max-width: 600px) breakpoint pinned in both
files; (7) sRGB gamut for every parsed token in every mode; (8) the inverted accent-as-ground pairs
(.verdict-*, h2.band, .step-node including the ramp); (9) the relative-color --highlight
token per mode, plus mark’s alpha composite; (10) the pie slice label against all four fills in
both palettes, plus pieOpacity pinned to 1; (11) --on-surface over the table.bar-chart band
at its alpha, against both grounds in every mode, plus print and forced-colors pins.
A new token joins check 5’s roles. The vividness band table is gone with the palette it measured,
and the .step-node ramp exclusion is gone with it, because --surface is legible on the ramp in
every mode in both the black-ground and silver-ground registers.
One CONTRACT.md § 2 requirement reaches the sheet where no palette check can see it. A
quadrantChart point label (inside a pre.mermaid fence, diagram source) stays prose: the fixture’s
own two overlong labels are the only instance in the repo and exist to exercise overflow: visible
(see Diagram sizing), so a length gate would have to exempt its only subject and assert nothing.
Some obligations stay prose: writing a gate that skips its only subject reports green about a
question it never asked.
Two things stay open, neither a measurement: --orange carries eight roles (strong, the mark
wash, syntax numerals/constants, the aside/alert bar, .markdown-alert-title, .unverified,
.verdict-partial, :target): every alternative trades one hue collision for another, and a move
would break the “orange arrival cue reads distinct from the link-blue focus ring” reasoning
elsewhere. The high-contrast set is gamut-compressed and text mitigates it (above).
Forced colors suppresses shadows. Anything whose only boundary was a shadow needs a border
instead. The sticky th’s gap halo is one such shadow, and it is not a boundary, so losing it here
exposes the header gap rather than the frame, which the cell’s own border keeps.
code, kbd, .verdict, .badge { border: 1px solid
currentColor }. State survives in the chip’s own text (PASS/PARTIAL/etc); the border restores
the boundary, not the meaning.pre > code is display: inline, so the chip border fragmented
across every code-block line into three broken boxes. pre code { border: none } undoes it; pre
keeps its own accent bar, which forced colors already preserves.table, th, td { border: 1px solid currentColor }), so the outer
rule and the header shading keep their structure under the mode’s own colors. The sticky th’s
box-shadow halo is suppressed here, so the 2px header gap is exposed; the grid itself survives.Fills inside a table still flatten, left alone deliberately: the mode restores the grid, not the tint.
This block’s coverage is structural, not a color, so a pixel check cannot see it. render-modes.py’s pixel assertion (checking
--surface painted) can’t transfer here, since forced colors means the sheet’s colors stop deciding
anything. So it’s gated on structure instead, in .github/script-probe.py: the condition is
rewritten to @media all in a scratch copy, and eight assertions read computed styles an edit here
actually breaks (chip outlined, table/cell outlined, block code not outlined, pre keeps its bar). A mode whose correctness is not a color needs a structural gate, not a pixel
one.
Three families were drawn the same way and meant different things; form now separates them so the shape carries the role and color is free to mean one thing.
A filled chip is a state; an outlined chip is a label. .verdict-* keeps its fill (pass/partial/
failed/N/A are states of a claim). .badge is --label text with a currentColor ring, no fill: it had been three tier fills, which told the reader tier 3 was “failing” on a green-to-red ramp when
tiers are ordinal, not health states. .badge-t1/-t2/-t3 are not removed, just carry no
declarations. What that trades away: tier is no longer scannable at a glance, but the text always
carried the level. Three steps of one new hue was costed and rejected (three more :root tokens
plus three print overrides, and the free hue gaps are too narrow to read as three colors at chip
size).
A border means interactive; an accent bar means passive block. .scorecard was bordered
identically to details/.nav-group/.nav-list/.filter-box/.mermaid-zoom, so a data panel and
a button read as one kind of object. .scorecard now takes border-inline-start. Every remaining
bordered box in the sheet is something you can click, type in, or open.
Three prose accents carry two or three roles each, accepted: --pink (h1, th), --purple (h2,
pre bar), --orange (strong, aside bar, .unverified). Only --orange’s overlap
is visible to a reader; the other two pairs are separated by form (a heading vs. an italic small
header; a heading vs. a 3px bar and a selection fill).
The hue budget is spent. A new role takes an existing accent plus a different form (weight,
bar, ring, fill), or a diagram role takes --data-1..4. A new hue is the last resort, and reusing one
for a third prose role needs a line here explaining why the two won’t appear together.
A h2.band is the corpus’s one saturated bar, opt-in. A section heading takes the --purple
fill with --surface text, the period’s single loud band. It stays opt-in (class="band") because
the corpus spends its strip once per page, on a lead heading, not on every h2. --surface on
--purple clears the floor in all four modes, so the band is check 8’s third inverted pair. Print
drops the fill for a currentColor ring and --purple text, the same treatment .verdict takes,
so no saturated band spends ink and the heading text never goes white-on-white.
The UA draws today’s widgets, not 1996’s. The sheet had fixed only the control font and left the
rest to the browser, on the argument that a UA widget is the period look. That is half the period
look: the era delegated the drawing, but the drawing changed. A 2026 Chrome checkbox is rounded and
Chrome-blue; a 1996 one is a square with a black inset bevel and a thick tick. A 2026 select and
search field carry rounded corners, a shadow and a WebKit clear button. The sheet now resets
appearance and repaints the control set square, in the same register as every other box here.
select and textarea take a one-pixel --rule fence on --code-bg, square
corners. This generalises the .filter-box treatment, and it drops the WebKit searchfield chrome
(::-webkit-search-cancel-button, ::-webkit-search-decoration) so input[type="search"] reads as
the text box it is.select redraws its own indicator. appearance: none removes the UA arrow, and a select
cannot carry a pseudo-element, so the arrow is two half-square linear-gradients in --label
positioned inside the reserved inline-end padding.background-color, never the background shorthand. The shorthand
zeroes background-image on every input, and at a specificity above the bare select rule it
deleted the arrow in silence. Keep the shorthand off that rule.appearance: none, a --rule-light box on
--surface-alt), with :checked filling --green. The checkbox tick is a ::before rotated
border, and :checked also fills the box, so a browser that drops a replaced element’s ::before
still shows a checked box rather than an empty square. GFM’s task box stays disabled; the sheet
keeps it at full strength, because the box states the item’s value.input.margin-toggle,
and its display: none rule (see Keyboard and assistive technology) sits above this reset at the
same specificity, so the checkbox reset repainted every hidden control as a visible square and put it
back in the tab order. Do not let the control reset match .margin-toggle: exclude the class in
the selector rather than raising the hide rule’s specificity.accent-color: var(--green) colors the widgets the sheet does not repaint (progress, meter,
input[type="range"]). The repainted checkbox and radio ignore it. Those three are kept out of
the appearance: none reset for exactly this reason: accent-color only reaches a widget the
user agent is still drawing, so resetting them deleted the platform painting and left range a
bare white bar and meter in the UA zone colors. The reset is therefore scoped to the controls the
sheet repaints in full (input:not([type="range"]), button, select, textarea), and the
color/accent-color pair sits on its own rule that still covers all six.::before is a rotated border,
which is the wrong shape for “neither checked nor unchecked”; :indeterminate takes a --on-surface
dash on the --surface-alt box. Without it an indeterminate box computed identically to an
unchecked one, so the third state was carried by nothing.color-scheme stays undeclared (see Appearance modes): the reset is a shape fix, not a request
to darken the UA’s remaining widgets.Gated: .github/script-probe.py drives the fixture and asserts the search box’s appearance is
none, the checked task box is a square with a tick, and a table cell keeps its fence. Do not
repaint a control without a render: the checkbox tick and the select arrow both draw through a
mechanism a screenshot proved, not a computed style alone.
Five components were adapted from factory.strongdm.ai’s product pages: structure only, no color,
every component reusing an existing token under the hue-budget rule above. Each borrowed shape was
then pulled back to a period form. A 1996 page drew no pills, no animated dots, no rounded nodes
and no rounded icon chips, so every one of these is now the plain rectangle or square the era drew.
A sixth component, blockquote.pull, and the page-wide film grain were deleted outright, because
neither had a period form worth keeping.
.kicker is plain uppercase text: bold, letter-spaced and --link-colored, with no pill. A
rounded, tinted pill is a modern eyebrow; a 1996 page set an overline in caps and moved on. Do not
reintroduce a fill or a border on .kicker: the link color already separates it from the heading,
and a tinted box on the black ground was the reason the old pill needed a border of its own.
.tag-dot (a ::before square) paints currentColor, carrying no color of its own, so it can’t
reopen the rejected tier-ramp above. A square, not a circle: a 1996 bullet was square. Belongs on
an empty element, never one that also holds the label: currentColor recolors sibling text too,
and the first fixture draft measured a label painted directly under the text floor (the Dracula ramp
under --surface now clears it, so the reason is the double-recolor, not the ratio).
.live-dot is a static --green square, reusing the existing “healthy” role. It used to pulse
via the sheet’s first @keyframes; the animation and its prefers-reduced-motion cover are gone
with the rest of the motion. Do not give it an animation: a static mark is the period form, and
there is no reduced-motion reset left to cover a new one.
.icon-list/.icon-chip takes its accent from an inline --icon-color per <li> (the
--timeline-date convention) on the row’s leading border, keeping the component color-free by
default. The chip itself is a square of --code-bg with a --rule-light border, no tint. The
glyph is fixed --on-surface, never --icon-color: a same-hue glyph on its own tint measured as
low as 3.35:1 dark and failed outright in light mode for all four demo hues, where --on-surface on
--code-bg clears the floor in every mode. The .icon-chip glyph is aria-hidden="true",
since it always sits beside a visible label (unhidden, a screen reader announces the initial and
then the title right after it, twice). .step-node is the opposite case (no adjacent label) and
keeps its letter as real text.
An .icon-list row never breaks its title from its detail with <br>. br is for line breaks that
are content (a poem, an address), so .icon-list strong is display: block and the CSS makes the break.
A cite marks the title of a standalone work. A file path and line is <code>.
A step chain names its steps in the prose that introduces it, and every .step-arrow is
aria-hidden="true". A node is a square with room for one character, so the words can’t go inside
it: bare letters (S, I, O, V) decode to nothing for any
reader. Do not answer this with a visually-hidden label class: the sheet has none, and adding
one turns a problem one sentence of prose already solves into a payload API.
.step-chain/.step-hop/.step-node/.step-arrow is a linear process strip for a flow too
trivial for Mermaid (three or four stages, no branching): not a Mermaid replacement; any graph with
a branch or loop still belongs in pre.mermaid. Same --icon-color convention as .icon-list, and
its --icon-color may take either a prose accent or a --data-* slot: the node fills at full
strength under real text (--surface), and the Dracula baseline keeps --surface legible on both
sets in every mode, which is what check 8 measures. This reverses an older prohibition that held the
ramp out of .step-node when its light and print values were pale. .tag-dot/.icon-chip carry
--data-* too, since neither puts text on the fill. Only a full-strength fill under real text is
the problem: check the distinction before adding a fifth component to this convention; check 8
gates the permitted set, CONTRACT.md § 2 states the consumer obligation. Every node but the first is
wrapped with its leading arrow in one .step-hop, so a flex-wrap can’t strand an arrow on one line
and its node on the next with no visible connector.
.icon-chip and .step-node join the forced-colors border list (both carry their whole shape
through fill alone). .kicker left that list with its pill. .tag-dot/.live-dot take
background: CanvasText there, not a border: both are empty, so a border would ring nothing; a
render showed any author background (even currentColor on an empty ::before) resolves to Canvas
under forced colors, so the dot doesn’t flatten, it vanishes without this. .verified/.unverified/.correction need no
override: they color a glyph forced colors keeps drawing, so they only lose a hue, not their content.
A sticky element does not take backdrop-filter blur: the sticky-th decision above (opaque
background required) already rules it out; blur over an opaque layer is a no-op.
One component draws a chart from ordinary markup, table.bar-chart: CSS only, no script, no CDN, no
build step. A pie is a Mermaid pie showData fence instead (the existing hard-offline CDN exception).
A second CSS pie was tried for one release and removed; see below.
Values are text in the markup, always: the template themes a pie because a part-to-whole
share of a few categories is real, provided the chart is always a second reading of a number the
page already states (CONTRACT.md § 2 obligation). A ranking, a magnitude comparison, or five-plus
categories is table.bar-chart.
The bar chart is a table with a class, and the band paints inside the cell holding the number (an empty cell was rejected: it would make the graphic the only carrier of the value).
--data-1, never full strength (a full-strength pale fill
under the near-white --on-surface number fails in dark mode). Check 11 measures the actual
composite rather than restating the alpha; the first floor failure appears at 0.5.--bar-color was tried and reverted. At 0.3 alpha the four
washes land within Lc 2 of each other (1.00-1.13:1 band-against-band) while the legend dot at full
strength reads 7.39:1: no alpha fix exists (check 11 measures the one alpha, 0.3, and 0.4
leaves Lc 3). Do not
reintroduce a per-bar color property: it can’t deliver the category key it implies, and as an
inline hook it also invites a prose accent onto a neutral number. The row label carries the
category.inline-size, not min-inline-size: Chromium’s auto table layout doesn’t
size a cell from min-width (measured: an 8rem minimum still produced a 130px column).tbody tr:hover td sets the shorthand at lower
specificity than table.bar-chart td.bar’s two classes, so a shorthand there would delete the
row-hover fill on exactly the rows with a bar.[dir="rtl"] flips the gradient’s background-position, since
neither a gradient nor background-position takes a logical direction keyword.The pie is a Mermaid pie showData fence; a CSS pie was built, rendered beside it, and removed,
after the comparison: the Mermaid pie drew in-slice percentages, a
legend, a title; the CSS conic-gradient had none of that (nothing inside it was text, forcing a
role="img" plus an aria-label plus a caption legend: three restatements of the numbers to draw a
shape still unreadable). Mermaid draws the label in the slice and themes every slice from
--data-*, at the cost of a CDN request. The offline case is table.bar-chart, not a second CSS
pie: keeping one alive to cover Mermaid’s offline failure cost a component, a § 2 requirement, a
print pin, a forced-colors rule, and a check.
Four slices stays the ceiling: the ramp has four members, and a fifth has no color left that
clears the floor. A pie needs a boundary between slices: the ramp holds one lightness by design,
so adjacent slices had no luminance step at all (measured 1.00-1.14:1 across palettes and even under
simulated color-blindness). Mermaid’s pieStrokeColor (--surface, so slices separate by a gap not
a line) and pieOuterStrokeColor (--muted, matching every other hairline) answer this.
The pie’s title is 1.15rem, its slice stroke 1.5px, its outer ring 1px, and its labels sit
at textPosition: 0.65. The stock 25px title outweighed the 17px labels and the pie under it, and
the 2px seams read as heavy dark lines in the dark palette. pieTitleTextSize, pieStrokeWidth and
pieOuterStrokeWidth are live themeVariables (script-probe.py asserts they reach the SVG).
textPosition is a pie config key, not a theme variable. Do not drop the stroke below 1px: the
gap between slices is what separates two fills of one lightness.
Do not drop either stroke to Mermaid’s black default.
The bar band paints through a background image, so print and forced-colors both need a pin,
verified by rendering rather than reasoning. Print drops background graphics by default (verified
via print_background=False), so table.bar-chart td.bar takes print-color-adjust: exact (check
12 holds it). .tag-dot::before needed the same pin: a legend’s dots vanished on paper without
it, costing the legend’s whole key, found by reading a printed PDF. forced-colors: active computes
background-image: none on its own (verified), so the bar table needs no rule; the CSS pie did, and
it’s gone with the component. Never answer a forced-colors case with forced-color-adjust: none: it overrides the reader’s own accessibility setting for a graphic whose numbers the page already
states.
Nothing here draws a line chart. A Mermaid xychart-beta fence can’t be themed (see
Diagram types), and a pure-CSS line chart needs hand-authored SVG paths a generator can’t
be asked to emit correctly. The gap is deliberate.
samples/dark-charts.html carries two bar tables (share of whole vs. share of the largest value, the
convention that makes an unlabelled axis honest) plus a Mermaid pie of the same numbers.
Bar cells use neutral quarter guides behind the value band. The guides show the bar scale, while
text remains the source of each value. Do not use guides to encode categories or remove the text
values. Guides draw at 25, 50 and 75 percent only, faint, with a strong 1px baseline at the zero
edge. The axis edge and the grid carry two weights, as on a printed chart. Do not add a guide at
100 percent (it drew a second border on the cell). .evidence-table no longer draws a rule family
of its own. It used to add a strong rule under the header, a hairline between rows, and a strong
rule above its tfoot total; the per-cell fence (Tables) separates every row and closes the table,
so both the row rules and the tfoot rule are gone. The first column of that table stays bold.
Do not add a second, stronger rule family back onto one table: the fence is the register now, and
a per-table override reopens the booktabs form the sheet just dropped.
td.bar.lead marks one emphasis row by weight 700 only. A higher band alpha would fall outside
check 11, which measures one alpha.
.stat-strip is a dl of div groups: dt label, dd.v value, dd.s gloss. Cell borders
overlap by 1px through negative margins, not a gap on a colored ground, because forced colors resets
backgrounds and the dividers would vanish.
A caption’s .label is a small-caps line naming what the figure is, above the sentence on how to
read it. .chart-takeaway .more is its second tier. Both are opt-in spans.
Two components: nav.toc (on-this-page index), details.deep (collapsed detail
tier).
nav.toc is a fenced directory box. A one-pixel --rule-light frame, a --surface-alt label
strip, and a rule under each row: the corpus drew its indexes this way (tucows-1996, “a flat
one-pixel bordered box with a heading row”; altavista-1999, a directory table “each cell fenced by a
one-pixel rule and headed by a colored bar”). Its links are plain underlined links. It used to mark
them with a dotted border-block-end over a translucent color-mix ground; do not put either
back. The dotted rule stood in for a border the box now draws, and the ground was the one
composited fill no gate could reach, which is gone with it.
The index runs two columns above 600px, one below: at 320px two columns left each entry about
68px and wrapped titles to five lines (matches .col-2’s breakpoint). The print override targets
nav.toc ol, not nav.toc (columns on the wrapper does nothing to the list inside it).
Both components use logical properties only (border-left/padding-left would keep the index
rule and list indent on the left in RTL while prose flipped).
Neither component names a font of its own. The first version set font-family: var(--sans), a
token :root never declares, resolving to the inherited serif either way. The sheet ships two faces
(body serif, code mono); a third is a decision this section doesn’t make. Do not reintroduce the
token without declaring it.
The summary triangles are decorative and once reached the accessibility tree (<details> already
exposes its own open state, so the marker only added a spoken “black right-pointing small triangle”).
Same content: "…" / "" alt-text convention as every other decorative glyph.
nav.toc clears the floated note column (clear: right; clear: inline-end, the same value
pair .sidenote already carries). The index draws a full-width box, and a .sidenote/
.marginnote stack taller than its anchor paragraph (three citations in one paragraph is the
common case) extends past that paragraph into the block after it. A float shortens the line
boxes of a block it overlaps, never its box, so the box painted under the note. The index’s
ol escaped on its own only because columns: 2 makes it a block formatting context; the nav
box did not. Clearing drops the whole index below the stack at full width. Rejected:
display: flow-root, which also stops the overlap but squeezes the index into a half column
beside the note, compressing the two-column list for no reason. Every other full-width box
in the sheet is already a BFC (pre through overflow-x, .icon-list li, .edge-list,
.recent-groups, .stat-strip, .scorecard through their display), so nav.toc was the only
one exposed. The fixture carries a three-note stack immediately before its index, and
.github/script-probe.py asserts the index does not overlap it. Do not remove the clear to
recover width beside a note, and do not move this behind a consumer opt-in.
Hover on both components sits inside @media (hover: hover) (outside it, a tap on a touch device
leaves the affordance stuck in its hover state: see Interaction states). Both components carry a
fixture instance, without which no mode render and no forced-colors sweep draws them.
Every var() here resolves to a declared token or carries a fallback. Six references resolve
outside :root, all deliberate: --tree-step/--bar-tint are declared on the component that reads
them; --bar/--icon-color/--natural-width/--timeline-date are consumer-supplied with their own
fallback in the var(). --sans had neither, which is what made it dead. A new consumer-supplied
token states its fallback in the reference.
themes/ shares the palette; the slot map (which token paints which syntax class) is a separate
decision. Prose logic does not transfer to an editor: a chroma tuned for a page where most glyphs carry no
color at all reads as wash in an editor, where almost every glyph carries one.
--label is the document’s caption tier, not a code tier. Punctuation, parameters and both field
kinds on it would collapse a buffer into one blue-gray band. Punctuation sits at --on-surface
(matching upstream Dracula); parameters at --orange; --label keeps only legitimately secondary
instance/static fields.
Types cannot sit on plain --purple (the dimmest accent) despite being C#’s highest-frequency
token: they use the existing .bright lift, not a new placeholder or palette change.
Three alternatives (closer to Dracula’s own slot map: functions to --green, strings to
--data-4, numbers to --purple) were rendered and rejected: --data-4 reads olive not yellow at
this chroma, and moving strings off green breaks the one cross-medium tie the theme has (inline
code is also green).
Do not answer “the theme looks washed out” with a chroma raise in :root. Every contrast-budget
ratio was measured against those values, on every published page. A dim-reading theme is a slot-map
problem first. Verify against the generated .icls, not the template: placeholders hide which
hex actually lands.
Rider, Zed, Ghostty, iTerm2 and VS Code ship a light variant, projected from the same
prefers-color-scheme: light override the page itself uses. opencode and tmux stay dark only:
neither format has an appearance-switch mechanism to project a second palette into. .github/palette-check.py --dump
returns {dark: {...}, light: {...}} rather than one flat map, and scripts/create-themes.nu’s
render/resolve pick a palette per placeholder: bare resolves against a template's
default scheme, (or dark:) overrides that one placeholder regardless of default.
Two shapes cover every format, chosen by whether the format’s own schema holds more than one
appearance per file. A format that does not (Rider’s theme.json/.icls, VS Code’s
color-theme.json, Ghostty’s theme file, iTerm2’s .itermcolors) gets a second, separately
generated -light file: same template rendered a second time with --scheme light, so every bare
placeholder in it resolves to light with no per-placeholder prefix. A format whose schema already
holds several appearances in one document (Zed’s themes array) keeps one file: the dark theme
object renders unchanged and the light one, alongside it in the same template, prefixes every
placeholder with light:. Do not invent a third shape for a future target before checking which of
these two its schema actually is.
Rider ships two complete themes, not one that follows the system setting: IntelliJ Platform
themes are one appearance per theme.json, so plugin.xml registers a second themeProvider for
the light one, and the light .icls inherits IntelliJ’s built-in Default scheme
(parent_scheme="Default") where the dark one inherits Darcula. The light theme.json’s four
Checkbox.* icon keys drop the .Dark suffix the dark theme’s copies carry, per JetBrains’ icon
palette convention (a bare key is the light-icon asset, .Dark overrides it for the dark one).
Neither of those two differences has been run against an actual Rider install, only checked
against JetBrains’ own theme documentation, because nothing in this repo’s toolchain can render an
IntelliJ theme the way Playwright renders the CSS payload. Flag it if either turns out wrong once
installed, rather than trusting the doc citation as verification.
iTerm2 has no single-file dual mode: a profile’s “Use different colors for light and dark mode”
checkbox takes two separately imported presets, so dracula-nineties-light.itermcolors is a second
plist built by the same render-itermcolors function with scheme: "light", not a field inside the
existing file.
Use theme: 'base' plus explicit themeVariables. Never theme: 'dark', which ignores this
palette entirely. Pass hex, never oklch(): khroma throws and aborts init on an oklch string,
so no diagram renders at all. Values mirror mermaid-palette.json, CI-enforced.
look: 'classic' is explicit, as of Mermaid v12.0.0. Mermaid 12 changed its default look from
classic to neo and its default layout from dagre to a bundled ELK. theme: 'base' stays
explicit so colors don’t move; look is pinned rather than left to follow the new default, since
neo adds a filter: drop-shadow(...) and a brighter stroke halo on every node. Layout is
left unset: every diagram now lays out with ELK by default, an upstream default this template
accepts rather than pinning layout: 'dagre' to hold the old geometry (see Large maps
for what changed on the one fixture that already used ELK deliberately). Mermaid v12 also raises
its runtime floor to ES2024, Safari 17.4+, Node 22.12+: this template ships no polyfill and states
no browser matrix of its own, so that’s now the floor for any page pulling Mermaid.
The @mermaid-js/layout-elk CDN import is gone: Mermaid v12 bundles ELK into core, so the
second CDN pin and the dynamic import() that only ran for a layout: elk fence are dead weight.
Verified by rendering samples/dark-conn-map.html’s three-subgraph flowchart and its explicit-ELK
diagram with the import removed: both render with no layout-loader error.
darkMode belongs inside themeVariables, not root-level: mermaidAPI only passes
config.themeVariables to base.getThemeVariables(), so a root-level darkMode never reaches the
theme and every derived color computes light-mode.
fontFamily, fontSize, pieOpacity are the only non-color themeVariables, none mirrored
into mermaid-palette.json (nothing there to catch). fontSize: '1rem' tracks the reader’s root
size (Mermaid defaults to a hardcoded 16px); pieOpacity: '1' is pinned by check 10 instead, since it
changes a measured contrast pair. background is inert (swept across twelve diagram types,
never reaches output): correct by intent, not load-bearing.
Theme the note* family explicitly, or Note over renders at Mermaid’s stock yellow, the only
light surface on a dark page. actorTextColor is not needed and is deliberately absent: a
text,tspan sweep that looks like a hidden dark layer is really one <text> wrapping one <tspan>
with no direct text child to paint. Probe a tspan, not its parent text, before adding this
back; a no-op themeVariable still costs two CI gates to keep in step.
Pin the CDN to an exact version, never a range.
fontFamily is set at both the config top level and in themeVariables, and both copies are
load-bearing. The themeVariable reaches the injected CSS that paints labels; the root one is what
calculateTextDimensions measures with, deciding label box width. The measurement font is the
render font, or the arithmetic is wrong. sequence.noteFontFamily/noteFontSize are accepted by
initialize() and read back by getConfig() but change nothing through 11.16.1: not the fix.
Label size follows SVG scale, so both viewport extremes are the same bug.
Wide end: pre.mermaid svg takes width: auto, not width: 100%: stretching a viewBox SVG to
its container multiplies label size with it (a sparse graph renders labels larger than h1).
max-width: 100% still shrinks an oversized graph; text-align: center (in both layouts) keeps a
small one centered.
Narrow end: below 600px the diagram renders at natural size and scrolls, or diagram text would
render at half the size of the prose beside it. --natural-width recovers the natural size CSS
otherwise can’t: with useMaxWidth default, mermaid writes width="100%" plus a real-size inline
max-width; mermaid.js copies that into the custom property. Two earlier attempts failed: plain
width: auto + max-width: none (an SVG with a viewBox resolves auto to its container, so labels
stayed small); copying inline max-width into inline width (broke the band above the breakpoint
with a page-level sideways scroll). The !important fights mermaid’s own inline styles; the selector
repeats inside the media block since it needs both higher specificity and !important.
pre.mermaid svg { overflow: visible } exists because some diagram types write a viewBox that
excludes their own content (quadrantChart’s fixed viewBox with edge-centered point labels forced
this). Needed on pre.mermaid (or inherited overflow-x: auto clips the same content) and on
.mermaid-overlay svg (or zoom shows the same truncation it’s meant to escape).
The cost: a label outside its own viewBox is unreachable below about 690px (not 600px), and no
overflow value anywhere recovers it. overflow-x: auto unconditionally was tried and reverted: strictly worse: SVG ink outside the root <svg>’s box is not scrollable overflow for any CSS
ancestor, so a scroller recovers nothing and instead clips content overflow: visible had at least
been painting into the page gutter. pre.mermaid:not(:has(svg)) takes the scroller instead, for
the fence that never rendered: with no SVG there is no ink outside a viewBox to clip, and without a
scroller the raw diagram source keeps its own width, which pushes the page sideways and makes the
rest of the document unreadable. The selector is a :has() test on what mermaid actually drew, so
it turns itself off the moment a diagram lands. Do not widen it to the bare pre.mermaid rule. The zoom overlay doesn’t rescue it either: it’s proportional to
the SVG’s own width, so the escape scales with the zoom (measured 69-105px lost at a 640px viewport): only shrinking the SVG helps, which would shrink every zoomed diagram, the one thing the overlay
exists not to do. This is a consumer constraint, stated in CONTRACT.md § 2, not a stylesheet
defect: the fixture’s two overlong point labels exist to exercise this; realistic labels lose nothing
above 601px. Same defect class as packet/xyChart (a Mermaid viewBox bug): take it when Mermaid
fixes it.
A JS refit() growing the viewBox to getBBox() was tried and reverted: fired from a
MutationObserver before flowchart foreignObject labels lay out, producing an enormous bbox. One
CSS declaration needs no timing at all.
The clone is stripped of mermaid’s own sizing, so the overlay’s CSS governs every diagram
identically (an inline max-width left in place would zoom in one layout and do nothing in the
other; the overlay sets width/height, not max-*).
The zoomed diagram’s halo is tinted from the scrim, oklch(from var(--surface-alt) 0.15 c h /
0.5): a pure-black shadow only looked right in one palette.
The close mark honors safe-area insets via max() against env(safe-area-inset-*) (not
verified on hardware with a real notch). The ✕ is a pseudo-element, not a button: the whole
overlay dismisses on click and Escape already closes it, so a button would add a second path to one
action and a needless focus stop.
securityLevel defaults to strict, sanitizing click directives away; a consumer sets
window.mermaidSecurityLevel = 'loose' to opt in. The overlay throws loudly when #mermaid-zoom is
missing, rather than a bare TypeError pointing nowhere.
Clicking a diagram opened nothing, on any fixture, until this was found. A data-* “already
wired” guard is not safe against cloneNode: Mermaid clones the <svg> at least once during its own
render pipeline, the clone carries the marker attribute over, but a JS listener added with
addEventListener does not survive a clone, so the handler stays bound to the discarded original.
The fix is a WeakSet keyed on the element itself, not an attribute a clone can copy: a clone is
a different object and correctly misses the set; the same object on a later pass correctly doesn’t
get a second listener. Confirmed with a real driven click, not a synthetic dispatchEvent (which
proves nothing about which element a listener is bound to).
A node’s own link must win over click-to-zoom. The svg-wide zoom listener now checks
e.target.closest('a') and defers to the node’s own navigation when the click lands on one: verified with a real click on a click-directive node (navigates, overlay never opens) against one
with no directive (opens as before).
packet is not themeable through config, so CSS overrides it. defaultPacketStyleOptions
hard-codes black text on #efefef; neither initialize() nor a fence directive moves it.
dracula-nineties.css overrides mermaid’s id-scoped injected rule with !important (required, not
convenient: id-scoping beats a page-level class rule regardless of source order). Verify a
mermaid override by rendered pixels, never by reading the exported SVG’s own <style>, which
doesn’t change even when an override is visually in effect.
xyChart cannot be fixed here. Same inert-config defect as packet, but no CSS door: bars and
line are plain <rect>/<path> with a literal fill/stroke and no class attribute at all. Take
it when Mermaid ships a fix, or use a per-diagram <style> scoped by a hand-added id. Never
widen a selector on the shared sheet for it.
sankey and block render in d3’s Tableau10 categorical scheme, no config surface in front of
it. Left alone: nothing puts a category fill beside body copy.
mindmap was tried for the connections map and rejected. Mermaid’s own section coloring resolved
to literal black under theme: 'base' with this palette’s dark primaryColor (an hsl(_, _, 0%)
Mermaid computes by hue-rotating off primaryColor), fixable the way packet/ELK clusters are. What
actually killed it: mindmap accepts no accTitle/accDescr at any indentation and rejects the
whole diagram (not just the directive) the moment either appears, a real accessibility regression
against every other diagram type here; and it has no equivalent to a legend, since a connection is
always a plain parent-child line with no per-edge line style. Do not re-add mindmap on the
strength of a theming fix alone: neither gap above is a CSS problem.
usecase (v12.0.0+, keyword usecase-beta) needs no new themeVariables, and none were added.
Actors and ellipses already resolve through primaryColor/primaryBorderColor/primaryTextColor/
lineColor, verified by rendering against both palettes. A systemBoundary doesn’t read those: Mermaid numbers boundaries from its own categorical palette, and usecaseBoundaryBkg/Border are
only a fallback for a theme with no such palette (setting them changed nothing, confirmed by
rendering with and without). What carries the boundary correctly instead: a systemBoundary renders
with a cluster class like a flowchart subgraph, so the existing pre.mermaid .cluster rect/
.cluster-label override (written for ELK, see Large maps) covers it for
free. samples/dark.html carries a usecase-beta fence with one systemBoundary as fixture
coverage.
C4Context/Container/Component/Dynamic/Deployment are not themeable here, and no fixture
exists for any of them. A probe render found Person()/System() painted with Mermaid’s hardcoded
C4 defaults (#08427B, #1168BD) regardless of theme, since the C4 renderer sets fill/stroke as a
literal shape attribute: same defect class as xyChart. A relationship label rendered at #444444,
failing contrast on this dark ground outright. A CSS door does exist (the !important route
packet/ELK clusters use), but C4’s several shape tiers (person, external_person, system,
external_system, system_db, system_queue, container/component tiers) are too much surface to
theme with no fixture to prove it against (see Fixtures are coverage). A
consumer adding a C4 diagram on this dark palette should verify contrast themselves.
Diagram text on the page ground (not a node fill) was invisible in print: Mermaid bakes hex into
the SVG at init, and print is a media query with no re-render, so a dark-page-themed diagram kept
painting textColor at #f8f8f2 while @media print turned --surface white (measured: pie
title/legend, quadrantChart axis labels, every sequenceDiagram message label at about 1.0:1).
Recoloring the measured classes was the first fix and it covered only the diagram types the
fixtures carried. Giving the diagram back its own palette covers every type with no per-class
enumeration.
The diagram is a dark object on a white page, so @media print treats it as one: pre.mermaid
re-declares the five tokens its own rules resolve through (--surface, --on-surface, --code-bg,
--purple, --muted) and takes background: var(--surface) from the frozen value. Custom
properties inherit, so every var() inside the diagram resolves correctly with no class enumeration.
pre.mermaid rule reads, or the half you miss goes dark on dark: a
first attempt froze only the background, so packet’s own overrides still resolved --on-surface
to the paper palette, printing dark text on the new dark ground.print-color-adjust: exact is load-bearing, verified by rendering a PDF with backgrounds
suppressed (the default): without it, backgrounds drop and light text lands on white paper again.
Pinned by palette-check.py alongside -webkit- and all five frozen literals.@media print and (prefers-color-scheme: light) block unsets all five (falling back to the
inherited paper palette), sitting after @media print on purpose since source order, not
specificity, is what lets it win.pie paints text directly onto a --data-* fill and took two coupled fixes, both found by
rendering rather than reading tokens. pieOpacity is now 1 (Mermaid’s 0.7 default composited every
slice toward the card, undoing the ramp’s own contrast work). pieSectionTextColor is per palette
and inverts (--surface in the pale dark ramp, --on-surface in the mid-tone light ramp: one value
can’t serve both). Both are real themeVariables in mermaid 11.17.2; no !important was needed.
Both pie strokes are themed too (pieStrokeColor: --surface so slices separate by a gap not a
line; pieOuterStrokeColor: --muted matching every other hairline): Mermaid defaults both to
literal black, not a palette color in any mode. Do not drop either stroke back to Mermaid’s
default.
samples/dark.html carries a pie showData fence; its absence is why the above went unmeasured
(see Fixtures are coverage).
Nine more diagram types theme correctly with no code change, verified by rendering each against
both palettes and reading getComputedStyle on every colored element, not by reading the exported
SVG’s own attributes. That last part matters: a Mermaid element can carry a literal
fill="#191970" attribute and still paint the themed color, because an SVG presentation attribute
sits at the bottom of the cascade and any ordinary CSS rule beats it with no !important needed.
Reading the attribute string alone would have misreported every one of these as broken.
classDiagram, stateDiagram-v2, erDiagram, requirementDiagram, kanban and radar-beta
resolve every color through primaryColor/primaryBorderColor/primaryTextColor/lineColor, the
same four this template already themes for flowchart and sequenceDiagram. gitGraph and
treemap-beta looked suspect at first pass (their commit and section colors are not literal hex
matching mermaid-palette.json) until a second render with primaryColor swapped to a probe red
moved those same colors in step: both derive their palette from primaryColor by hue rotation,
so they track this template’s purple correctly, they just do not equal one of the pinned hexes.
All nine carry a fixture in samples/dark-charts.html under “More diagram types”
(search classDiagram).
Mermaid’s own timeline keyword is a twelfth working type, unrelated to this template’s
dl.timeline component. Same keyword, two different things: one is a Mermaid diagram, the other
is HTML this stylesheet themes directly. Confirmed themed the same way as the six above. Fixed in
samples/dark-charts.html (search Mermaid's own <code>timeline</code>), and the name collision
is worth flagging to a generator that greps this file for “timeline” and finds the wrong hit.
architecture-beta needed two new themeVariables, confirmed by the same swap
test. architecture-beta’s connecting lines and group boundary rendered at Mermaid’s literal
mid-grey; archEdgeColor (the same token as lineColor) and archGroupBorderColor (the same
token as clusterBorder) fixed both. Both are real themeVariables, mirrored into
mermaid-palette.json like every other hex here.
gantt needed no themeVariable at all, and the gridColor this repo once added for it was
removed as dead. The axis already paints from this stylesheet: in the fixture, .grid .tick line
computes to the themed rule colour and .grid .tick text to the foreground, in both schemes, with
no themeVariable in front of either. The swap test settles it: writing a probe red into
gridColor in both palettes left the gantt render byte for byte identical, md5
ae55bd6ce30e1c68ed5f7a24e126b1ae before and after, while the same test on archEdgeColor moved
the edge stroke as expected. A themeVariable with nothing to paint is a dead declaration, the
same objection that removed a dead font-family rule. gantt’s axis was a real
defect, and a chart option rather than a themeVariable is what fixed it: at default tick
spacing Mermaid drew every date twice, thirteen labels for seven days, so tickInterval 1day in
the fence is what makes the axis print each date once. gantt’s today marker stays Mermaid’s
literal red on purpose: it is a “you are here” signal, not a category, the same reasoning that
keeps the pie and cluster strokes off any accent this template already assigns a meaning
(see the settled decision on --data-1..4 above). Setting it would cost a new gamut-clipped
hex for a signal that already reads correctly. The trade: no fixture renders that marker, since
the sample’s axis sits months away from the current date and the marker lands at x=47252 in a
1228-wide viewBox, outside the visible area. The decision is therefore untested rather than wrong,
and a consumer chart that does include today is the first place it will show.
architecture-beta’s arrowheads were checked and
found not to exist on this diagram’s edges at all (no <marker> in the render), so
archEdgeArrowColor was left out, on the same dead-declaration objection as gridColor above.
Both diagrams carry a fixture in samples/dark-charts.html (search Gantt chart and
Architecture diagram).
kanban and timeline accept accTitle/accDescr and never surface them, and the inline form
corrupts kanban. Written inside the fence body, kanban reads both lines as column names and
draws two junk columns, accTitle: Kanban board sample and accDescr: ..., ahead of the real
ones; timeline drops both in silence. Neither type puts the text into an SVG <title>, which is
the one place the zoom button reads a diagram name from (see Keyboard and assistive technology),
so a button over either diagram keeps the bare Zoom diagram label and a page with several
diagrams gets that repeated name back. The fixture writes both keys as front matter instead, which
both types parse without drawing anything, and a sentence above each fence carries the text
alternative. Fixed in samples/dark-charts.html (search A kanban board).
journey renders empty on roughly one load in five, and no gate in this repo can see it. The
fence parses, data-processed is set, an <svg> element exists, and the <svg> is empty: the
readiness check every harness here uses (an svg with a .mermaid-zoom button inside the pre)
is satisfied by exactly that state, so script-probe.py calls it rendered. Re-running the same
fixture under CDP with a real-time wait rendered it on four loads out of five and left it empty on
the fifth, with no console error on the failing load. Recorded rather than patched: the failure is
inside Mermaid’s journey renderer, this template inlines the built bundle and cannot fix it, and a
fixture-level retry would hide a defect a consumer page will also hit.
journey’s task and section fills theme correctly through fillType0..7; its actor band and
mood face do not, and are left alone. The swap test is what told them apart: task and section
color moved with primaryColor, the actor band (a fixed seagreen) and the per-task face (a fixed
cornsilk circle with grey eyes and mouth) did not move at all. Both are Mermaid’s own literal
colors with no themeVariable in front of them, the same defect class as sankey/block. Left
alone for the same reason today was left alone above: a mood face reads as a fixed emotional
scale, not a brand category, and repainting it purple would cost the one piece of information it
carries. Fixed in samples/dark-charts.html (search User journey).
zenuml does not render at all, and stays out. Mermaid ships it as a second package
(@mermaid-js/mermaid-zenuml) registered through mermaid.registerExternalDiagrams, not inside
the core bundle this template imports. Adding it means a second CDN import and a load-order
dependency against mermaid.initialize(), exactly the complexity the ELK move into core removed.
Declined on the same cost basis, with no fixture and no demonstrated consumer need.
body.conn-map has exactly two sections in order: (1) Links, (2) Graph. Above 900px Links sits
left and sticky with the graph right; below 900px they stack.
Markup order is the layout order; the stylesheet does not reorder. CSS order once reversed
them, so the visual leading column was Links while tab/screen-reader order started in the graph.
That was a silent breaking change for consumers: a page emitted with the old order renders with
the graph in the narrow sticky column and nothing errors. Supporting both orders behind
:has(> .links) was rejected: two layout paths in a file every consumer inlines verbatim.
The article sets layout only, never width: it once broke out of the page container to be wider
than the default layout, so a connections map and an ordinary page never shared a left edge.
pre.mermaid broke out a second time. Both breakouts were deleted, not ported. The container is
--page-width; the SVG renders at natural size; there’s nothing to escape to.
The full-width row is article > *, not an allow-list: an allow-list would let a new direct
child (e.g. a generator-added footer) silently join the two-column flex row instead of spanning it.
The sticky column has a height ceiling (max-height: calc(100vh - 2rem); overflow-y: auto;
overscroll-behavior: contain): position: sticky pins nothing once the element is taller than the
viewport; it silently scrolls with the page instead. tabindex="0" is deliberately not on that
column: the sideways-scroller rule exists for a pre/table/math whose overflow holds nothing
focusable; the Links column holds links, and focusing one already scrolls it into view.
The default flowchart BT fan-out does not scale past a small map: a 50-node production map measured
viewBox="0 0 7435 798", one rank holding 27 of 50 nodes, a horizontal-scroll wall. Past roughly
15-20 nodes on one rank, restructure rather than widen:
1. Cluster into open subgraphs for visual grouping. Never collapse one a reader must click into.
subgraphId@{ view: collapsed } (Mermaid 11.17) deletes every node inside a cluster from the render,
along with each node’s own click anchors: disqualified outright for a template that exists to
link out to every item it names. An open subgraph still gives visual structure; it just doesn’t
reduce node count (point 4 does that).
2. Switch a large map to the ELK layout engine. As of v12.0.0 ELK ships inside core rather than
a separate @mermaid-js/layout-elk module: a
config: { layout: elk } fence opts in with nothing to load
conditionally or race against mermaid.initialize(). ELK is also the default layout for every diagram naming no
layout at all, as of the same release; this template accepts that default (see
Init config).
ELK trades width for height on an unbalanced fan-out: it does not just shrink the diagram.
Eighteen leaf nodes into one focus node measured 3063 x 174 under dagre vs. 2671 x 324 under ELK:
about 13% narrower, roughly twice as tall. A map already short and wide will read taller under ELK,
not merely narrower.
ELK clusters ignore clusterBkg/clusterBorder outright: isolated against dagre with an
identical subgraph and theme: 'base', ELK hardcodes Mermaid’s stock #ffffde fill, #aaaa33
stroke, #333 label text regardless of themeVariables. Same defect class as packet/xyChart, so
dracula-nineties.css overrides it the same way: pre.mermaid .cluster rect and
pre.mermaid .cluster-label :is(p, span) with !important (Mermaid’s injected rule is id-scoped).
Verified by rendered pixels in both dark and forced-light, not by reading the exported SVG’s own
<style>.
3. Encode relationship type as line style, once, in a legend, not as a text label on every edge.
The production map carried 39 edges each labelled technological/conceptual in its own
foreignObject, adding to the width dagre solves for. A classDef on two edge classes (solid vs.
stroke-dasharray) plus one small unconnected legend subgraph states the distinction once.
4. Past that node count, split into multiple maps rather than hide any node. Every node stays present and clickable on whichever map holds it. The production map’s 31 technological vs. 8 conceptual edges read better as two focused maps by relationship type (or by era) than as one map carrying both past the point either reads clearly. A generator decision, not something the template enforces, but the recommended default past the threshold above.
There is no press feedback: the sheet declares no transition and no animation. A pressed nav
link or zoom button used to scale to 0.96 through a transition; both are gone, because neither
existed before CSS transitions (2008) and a 1996 button gave no such feedback. Do not reintroduce
a transition, a :active scale, or a @keyframes rule: the sheet is static by decision, and the
prefers-reduced-motion reset that used to cover for motion is gone with the motion.
[tabindex="0"]:focus-visible is in the focus rule: the one stop this sheet doesn’t own is the
one consumers are told to add (a focused pre/math/.table-scroll would otherwise fall back to
Chromium’s default ring). No border-radius in :focus-visible: it tightened .filter-box
corners unevenly at the moment the ring appeared (Chromium already rounds an outline to the
element’s own radius, which is 0 here). .nav-list radius is var(--radius), square like
every other box.
Every hover rule sits inside @media (hover: hover). Eight rules didn’t, and an unguarded hover
rule doesn’t fail to apply on touch: it applies at the wrong time and sticks: the browser sets
:hover on tap and leaves it set until the next tap elsewhere (measured with real touch emulation).
The block’s position, before @media (prefers-contrast: more), is load-bearing: a media query
adds no specificity, so placed after that block instead, .nav-list li a:hover would tie and win on
source order, stripping that mode’s underline from hovered nav links.
pre.mermaid:hover gets an instant, untransitioned 1px ring, for a pointer only. It is not the
touch affordance: a hover rule fires after the tap, so it can never advertise anything in advance on
touch. The injected .mermaid-zoom button is the real touch affordance.
The overlay’s way out is a ✕ glyph on .mermaid-overlay::after: click-anywhere and Escape
already dismiss it, so this is a cue on an already-clickable surface, not a new target. cursor:
zoom-out alone is invisible on touch. Glyph rather than a word (untranslatable in a file consumers
inline verbatim), same content: "✕" / "" alt-text convention as every other decorative glyph.
Zoom is a real <button> mermaid.js injects, not a focusable pre. Two cheaper fixes were
rejected: tabindex="0" + role="button" on pre.mermaid makes the SVG’s content presentational,
hiding its graphics-document name; tabindex="0" with no role leaves a focusable generic, and
aria-label can’t name role=generic. A native button gets keyboard/pointer support and an
accessible name for free, leaves the SVG untouched, and doubles as the touch affordance cursor:
zoom-in could never be.
The observer that creates the button has to be idempotent: Mermaid rewrites the pre’s children
after first render, so a one-shot guard let the second pass delete the button and then blocked
recreation. It now re-adds the button whenever one is missing, and marks the SVG (not the pre)
for the click listener.
The overlay is a native <dialog>, opened with showModal(), replacing an earlier hand-rolled
<div> (role="dialog", aria-modal, manual focus, manual inert toggling, a guarded document-level
Escape listener). showModal() provides all of it natively: implicit role/aria-modal, the rest of
the page excluded from focus/a11y tree with no sibling touched, focus moves in automatically, Escape
closes via the browser’s own cancel event. aria-label still needs setting by hand (a <dialog>
has no accessible name of its own).
Close runs through one hide(), called from both a click listener on the overlay and a
cancel listener that calls preventDefault() first, so the same cleanup runs either way (drop the
active class, call overlay.close(), empty its innerHTML). close() runs synchronously, so the
same cleanup runs whichever way the overlay is dismissed. The overlay opens and closes instantly:
it carries no transition, so there is no fade in or out and nothing left waiting on a
transitionend. Focus returns to the opening button for free via close()’s native restore.
The zoom button is named from the diagram (accTitle → SVG <title>), not a hardcoded constant: a page with several diagrams would otherwise get several identically named buttons. aria-label is
label + ': ' + title; the overlay takes the same name on open. The trade: kanban and
timeline never produce that <title>, whatever form accTitle/accDescr take, so their
buttons read the bare Zoom diagram and a page carrying several of them is back to repeated
names. Mermaid’s own renderers are the reason and this template inlines the built bundle, so
there is nothing to fix here without patching Mermaid. See Mermaid for what the fixture does
instead.
The pre region is named for what the container IS, not for the diagram inside it
(window.mermaidRegionLabel || 'Scrollable diagram'): the bare title would repeat what the SVG
already exposes as its own graphics-document name, so a screen reader read the diagram title, the
word “region”, and the diagram title again. The trade: several regions on one page now share a
name where each was once unique: weighed deliberately, since the duplication cost was paid on
every entry into every diagram, while the shared-name cost only hits a reader browsing a region list
below 600px, one step before a uniquely named diagram. Do not “fix” this by putting the title back
in the label.
pre.mermaid is a labelled region only at widths where it can actually scroll, because an unconditional tab stop
would put a stop on every diagram above 600px (measured scrollWidth == clientWidth on every fixture
diagram) with nothing to scroll, and it would carry a duplicate-name aria-label. Two cheaper alternatives (a second
invented English string; a focusable generic) were declined for the reasons already stated above.
mermaid.js matches window.matchMedia('(max-width: 600px)') and syncs tabindex/role/
aria-label on change: verified by driving a real resize (region absent at 1440px, present at
400px, absent again returning), and swept at 1440/1000/700/601/600/400px confirming no diagram above
the breakpoint both scrolls and lacks a region. matchMedia is now permitted in mermaid.js,
prefers-color-scheme is not: the real prohibition was always reading the host’s appearance
instead of the cascade. The breakpoint is pinned in both files (stylesheet and mermaid.js
separately); a move on one side alone is invisible in a render of the other.
window.mermaidZoomLabel/mermaidRegionLabel override the two label words, following the
mermaidSecurityLevel convention (all three are hardcoded English in a file consumers inline
verbatim). accTitle/accDescr are consumer obligations: fence directives no stylesheet change
can supply; without them the SVG is a graphics-document with no accessible name.
The sidenote margin-toggle checkbox is inert by design, and its display: none rules must stay: .sidenote is display: block at every width, so the classic margin-toggle collapse pattern does nothing here, but
consumer generators still emit that markup and dropping the rules would show raw checkboxes on every
page.
math[display="block"] takes tabindex="0", role="region", and a label: it’s a scroll
container like pre (own overflow-x: auto), and role="region" costs nothing here since Chrome
exposes no native math role either way. Pandoc’s span.math.display takes the same three, and
the consumer supplies them. CSS cannot add a tab stop. Chrome 132 and Firefox focus a scroller
with no focusable child on their own, and Safari does not. No script adds them either: mermaid.js
fails offline and filter.js loads only with a filter box.
Sidenotes float to the inline end, with the physical value first as fallback: float: right;
float: inline-end; clear: right; clear: inline-end: a browser that can’t parse inline-end drops
that line and keeps LTR behavior. margin is margin-block/margin-inline for the same reason.
th/td are text-align: start, not left (.num uses end), or cells stay left-aligned in
RTL while the surrounding prose flips. h1/h2/h3 carry overflow-wrap: break-word: the
only text in the sheet without a break rule, so a long title word ran off the page under text-only
zoom.
--gutter folds safe-area insets at every width, not only under 600px: a landscape phone is
wider than the mobile breakpoint yet still has lateral insets larger than the desktop gutter, so text
ran under the notch without this. The 0px env() fallbacks are load-bearing: without them, a
browser with no support makes the whole custom property invalid at computed-value time, taking the
width: min(...) calc down with it. Not verified on real hardware (Chromium doesn’t emulate insets).
.scorecard collapses to one column inside the conn-map sidebar, by a descendant rule, not a
container query: container-type: inline-size on every section, scoped with :has(), once
caught a text-only-zoom overflow, but container-type and :has() both postdate the period. The
sidebar is the one place a .scorecard meets a narrow measure, so
body.conn-map article > section:nth-of-type(1) .scorecard sets one column directly. Do not
reintroduce a container query for this: the sidebar rule covers the only narrow container a
scorecard lands in.
Two earlier attempts failed: minmax(0, max-content) tracks let a track shrink to zero without the
.verdict chip shrinking with it (chip spilled out); auto tracks plus overflow-wrap: break-word
fixed only one width, since break-word does not reduce a box’s min-content contribution, and
anywhere does.
At 400% text-only zoom the page still scrolls sideways: past what WCAG 1.4.4 asks for, and nobody chases it further.
The whole sheet sits in one layer, @layer dracula-nineties. Before it, a consumer override had to
win on specificity against syntax-highlight groups at 0,2,0. Unlayered author styles beat every
layered author style for normal declarations, whatever the specificity: a consumer’s plain
h1 { color: … } now wins with nothing here needing to move.
The !important declarations became harder to override, not easier: that’s the trade. In the
important half of the cascade the layer order reverses, so every !important here beats a consumer’s
unlayered !important. A consumer who genuinely needs to win declares their own layer ahead of this
one. Lifting those rules outside the layer was rejected (two sit inside a media query, meaning
duplicated @media blocks).
They fall in three groups: six fight Mermaid, which nothing else can reach (four fight its
id-scoped injected stylesheet: packet, cluster fill/label; two fight its inline style
attributes: conn-map and narrow-viewport svg sizing); .filter-hidden { display: none !important }
(a consumer override there means a filtered row stays on the page). The
prefers-reduced-motion reset left with the motion it covered.
One layer, not four (@layer reset, base, components, utilities): that convention is for a
stylesheet a consumer composes from parts and can reorder; this is one file, inlined verbatim, in a
fixed order.
Do not re-indent the sheet body. The wrapper opens on line 3 and closes before </style>; a
re-indent rewrites every line, putting git blame on the whole stylesheet at one commit, breaking the
trace from a declaration to the change that made it look that way. scripts/build-sample.nu also
slices :root with a hard-coded ^ de-indent.
:is()/:not() both take the highest specificity of their arguments: :is(ul, ol, menu):not(.nav-list) scored a class weight from a class it never matches, silently
outranking the nested-list rule below it. The :where() form scores zero on both sides. Check the
specificity of a negation before trusting source order. The other :is() groups stay (the layer
already gives consumers an override, and two would break if lowered: syntax-highlight groups must
beat a loaded highlighter theme, the permalink group must beat the plain a rule).
@media (prefers-contrast: more) reassigns tokens, not elements: raises every accent to the
mode’s 7:1 floor against --code-bg (the harder ground); --surface-alt darkens there (its job as
row-hover/tinted-root fill is to be unmistakable). a takes a thicker currentColor underline, and
the focus ring widens. --label and --muted do not collapse onto one lightness here: they are
the two structural text tiers, and at equal lightness a dd and the h3 above it render the same
colour. The mode raises --label rather than dimming --muted, because nothing in this block is
dimmed. mark does not reach the mode’s floor (the --highlight alpha wash caps what the composite
can reach, and lowering alpha would defeat the highlight’s purpose).
A mode block’s own token overrides are not a reason to repeat a base declaration. @media
adds no specificity, so a base rule that suppresses a property still wins inside the block; a
@media (prefers-contrast: more) copy of .nav-list li a { text-decoration-color: currentColor }
existed only because the base rule set that property to transparent. The base rule no longer sets
it, so the copy is gone with it. Before adding a declaration to a mode block, check whether the
base rule it defends against still exists.
@media (prefers-color-scheme: light) is a full second screen palette, not the print palette: reusing print fails on screen for three reasons: --surface/--surface-alt are both pure white
there (killing row hover and the overlay backdrop), --code-bg is a paper compromise, and the
accents are tuned against white rather than a light code fill. In light mode --surface-alt is
darker than --surface (row hover reads like dark mode); .verdict keeps its filled form (no
print-style outline needed); --purple-bright is a literal here too, oklch(0.360 0.173 308.158) at
94% of the sRGB ceiling, and it is darker than --purple, since brighter is less contrast on a light
ground.
Mermaid follows the media query by reading a CSS token, never matchMedia. :root declares
--mermaid-scheme: dark, the light block overrides it. matchMedia reads the host; the token
reads the cascade: the forced-light preview pages only work because they rewrite the @media
condition, which matchMedia can’t see but a computed custom property resolves correctly. A deleted
token fails silently (getPropertyValue on a missing property returns '', not 'light');
check 6 now asserts all four: :root declares dark, the light block declares light, mermaid.js
reads the token by name, and mermaid.js does not read matchMedia.
The dark-island design (light mode handing pre.mermaid the dark palette back as inherited custom
properties) was tried and removed: it looked fine but was wrong three ways: a dark slab beside a
light sidebar reads as broken; the card-width fix needed a width: fit-content that clipped
quadrantChart and collapsed self-sizing SVGs; the re-declared palette was a third :root projection
needing its own gate. The net change after deleting it is fewer rules than before it existed.
A scheme flip with no reload leaves the diagram stale: the token is read once, at init; a live
re-theme means a re-init, a re-render from source, and a fresh MutationObserver race. An in-page
toggle was asked for and refused: the fixtures carry exactly one <style> and two <script>
blocks, both gated, so a toggle would need a second theming convention or a fourth inlined script.
Take it only as a deliberate public-API decision if a consumer asks for a manual override as a
feature.
Two generated preview pages carry the light palette to the web instead, rewriting the light
condition to @media all and the contrast condition to @media not all (forcing light alone isn’t
enough: it would leave the contrast block’s non-token rules live). The generator raises when the
rewrite no-ops, or Pages would serve a dark page called light while regeneration still compares
clean. Do not remove the light-preview banner to tidy it: the filename no longer carries the
warning, so the markdown-alert-caution block, the CONTRACT.md § 1 statement, and the pages’
deliberate exclusion from the contract-files list are what’s left to carry it. Nothing outside this
repo should pin a page whose media queries were rewritten.
There is no high-contrast preview page: that mode leaves --surface alone, so a forced page
would look almost like the dark sample and teach nothing; CI renders it and attaches the image to the
PR instead.
Two gates cover the modes, covering different halves, neither a screenshot diff (layout is
identical across modes, only color moves). .github/palette-check.py check 5 re-derives the contrast
floor for all four palettes on every run, overlaying each mode block’s overrides on the default
palette the way the cascade actually resolves it. .github/render-modes.py covers that the palette
arrives: headless Chrome cannot be told which media query to match (--force-dark-mode etc.
all leave the OS’s own result unchanged), so each render rewrites every mode condition in a
scratch copy, the target becoming @media all and the rest @media not all. Neutralizing the
other conditions is the load-bearing half: its absence failed CI on the first attempt (a
dark-appearance mac measured its own light-runner result as correct). The pixel read needs no image
library: for the first pixel of PNG row 0, every filter type predicts from an all-zero left/above
byte, so the filtered byte is the raw byte. Renders are advisory on purpose, not in
REQUIRED_CHECKS: the assertions are the gate, the images are for a person to look at.
light-dark() was considered for the mode swap and passed over: each mode redeclares roughly
fifteen tokens in one :root block; light-dark() sets one declaration at a time from two values,
meaning fifteen inline calls instead of one block to keep in step.
High contrast and light mode do not compose, and the ordering is deliberate:
prefers-contrast: more is declared before the light block, so a reader asking for both gets the
light palette at its own floor rather than a fourth, never-built high-contrast-light palette. The
failure mode of a wrong order is much worse: dark high-contrast accents on a white surface. Do not
reorder the two blocks.
The print block overrides palette tokens, not elements: background/color on body alone
would leave every accent at its dark value on white, breaking both print paths (near-black text on
dark fills with backgrounds on; light text stranded on white with them off, Chrome’s default).
Accent lightness is chosen against the print --code-bg gray, not white (the harder ground).
--surface-alt goes pure white too (only the overlay backdrop, never simultaneously on screen and
paper).
.table-scroll releases its cap on paper (max-height: none; overflow: visible): a scrollport
is a screen affordance; on paper it’s a guillotine with no mark that content is missing. The existing
tr { break-inside: avoid } and thead { display: table-header-group } carry the released table
across pages. The fixture’s wrapped table is twenty-four rows, not three, to actually exercise this.
Page breaks: p takes orphans: 2; widows: 2; headings take break-after: avoid;
break-inside: avoid covers tr, blockquote, aside, details, .scorecard, .verdict, img,
.markdown-alert, pre, dl.timeline > dd, math[display="block"]; thead takes
display: table-header-group.
pre belongs in that break-inside: avoid list: the worry that it would be “ignored or
overflow” on a block longer than a page was wrong: a fence that fits moves whole to the next page
with its heading; one that can’t fit splits with nothing lost, reprinting its fill and accent bar on
every fragment. That’s the standard resolution, and it’s the behavior you want.
.verdict prints as an outlined label, moving its semantic color to color (its fill carried
the meaning, invisible with backgrounds off). .badge needs no print rule: already an outlined
--label chip, and print reassigns --label for it automatically.
filter.js is the third inlined payload. Three load-bearing decisions:
input.filter-box or the end; within that span filter tbody tr and
.nav-list > li. No closest(), no id-matching: the script never needs a consumer’s ids, and
a stop at the next filter box is predictable with no read of the source.hidden (.filter-empty
has no display of its own). It names no query on purpose: an interpolated string would put
untranslatable template text in a payload consumers inline verbatim.querySelectorAll plus
classList.toggle.The one-table scope was reversed from an earlier one-input-one-table-one-listener rule, and the reversal is deliberate: the fixture’s own filter box was inert for six releases under the old rule, and the largest generator of these pages hand-maintained its own 41-line replacement rather than inherit a fix scoped too narrow to reach its markup. A shipped contract file with no reachable user is worse than no file.
The script captures details.nav-group open state (and summary .count) once at bind time and
restores both when the query clears: a group the script opened during a search must not read as one
the reader opened, and the authored count, not a recomputed rows.length, is what’s restored
(the number a generator wrote is a claim about the group, not necessarily a row count; restoring the
authored text avoids the page stating two different, disagreeing numbers).
The [role="status"] line takes a register: .filter-box ~ [role="status"] styles it at body
weight on --on-surface, matching every other filter-chrome piece. The selector stays a sibling
combinator on purpose: filter.js finds the element via input.parentElement.querySelector,
wider than any CSS selector could be without claiming every [role="status"] on a consumer’s page;
the narrower CSS rule styles only the shape CONTRACT.md § 6 documents.
.filter-box is font-size: 1em. Do not write a pt floor here: 16pt is not 16px, and the
iOS-zoom threshold is 12pt.
nav > a + a takes a border-inline-start plus symmetric padding: without it, sibling <a>
children in a <nav> render as an undifferentiated run of link text.
nav.tabsA nav.tabs is the corpus’s tab strip, rebuilt so it collides with no existing selector. The
markup is <nav class="tabs"><ol><li><a>, so no nav > a or nav:has(> a) rule matches it and the
left rail, the bracket pseudos and the link separator all stay off it. The active tab takes
aria-current="page" and swaps to the page’s own ground (--surface), so it opens into the panel
below the way a 1996 tab does; the rest sit at --surface-alt. Overlapping -1px start margins
share one border between adjacent tabs. No new token, no new gate.
It is the one horizontal, boxed nav form in the sheet, which is why it is a separate class
rather than a shape on the bracketed nav > a rail: those carry [label] brackets and read as a run
of links, not as tabs. The corpus draws this strip ten times over (altavista-1999’s navy row,
apple-1998’s black row, IE5’s six-section row), always at the top of a page.
The wrapped-line separator is a known artefact, accepted. A link beginning a wrapped line carries a separator with nothing to its left. No pure-CSS rule can suppress a border at a line break (the wrap position isn’t addressable from a selector, and flex wrapping just moves the problem). The alternatives (a dangling pseudo-element glyph, or no separators at all) were both worse. The fixture carries enough links to wrap at a phone width so the artefact stays visible.
scripts/maintain.nu bump rewrites three anchored stamps and nothing else: the stylesheet header
comment, the (template vX.Y.Z, oklch palette) cell, the is **vX.Y.Z** line. A blanket
find-and-replace across README.md once walked historical claims forward too, crediting the wrong
release on every bump for prose like “raw HTML is covered as of vX.Y.Z”. Each pattern must match,
or the bump fails: a loud failure beats a silent no-op that leaves the tree claiming the previous
version while the release process believes it stamped. CONTRACT.md’s per-version delta table is the
same shape of risk; a blanket replace would rewrite every row of it.
An element the sheet doesn’t claim renders in whatever the UA decided, usually a light-mode
default that survives a dark theme (mark came out pure yellow on pure black, caption centered
itself, figcaption read as an ordinary paragraph, figure had no margins at all since the *
reset ate the UA’s).
mark is a wash, not a chip: --highlight is --orange at a tuned alpha, and body copy on it
still clears the text floor with headroom (a higher alpha would read as a chip and cost contrast).
mark pins color: var(--on-surface) rather than inheriting, since it renders inside --label
containers where every other tier fails on the wash. Print inverts it to an outline, the same move as
.verdict.
kbd is a ringed chip, deliberately not code: same fill and mono face, but --on-surface
text (not --green) and a --rule ring (not --rule-light, since it sits on --code-bg). A second
shadow layer (0 1px 0 var(--rule) outside the ring, borrowed from factory.strongdm.ai’s keycap)
reads as a raised edge. Joins the forced-colors border list (an inset shadow is its only boundary).
A caption sits above the table’s frame, not inside it: table:has(caption) drops the top rule,
or the table’s own top rule paints above the caption and it reads as a stray first row.
figcaption sets text-align: start explicitly: pre.mermaid is centered, and an inheriting
caption would float mid-column.
A caption takes a second tier for a source or credit line. figure/figcaption were claimed as
one register, so a caption naming what it shows and then where it came from ran together as two
sentences of the same weight. .byline inside :is(figcaption, caption) becomes a block: the
annotation tier the sheet already has, italic and muted, one line under the caption it belongs to.
No new class and no new token, since the hue budget is spent and the tier exists. Its
font-size: 1em is load-bearing, not cosmetic: .byline carries 0.9em and a caption carries
0.9em, so without the reset the source line compounds to 0.81em, the trap this file records under
Type scale. Nested inside a caption it also loses the italic that distinguishes it there, since
caption is already italic, and colour alone carries the tier.
The sheet was written for hand-authored markup; a consumer can also point a markdown converter at it. Every construct a converter emits lands in a theme register with no classes of its own. Do not invent classes for markdown constructs.
h4-h6 all sit at 1em; weight 700 and color (--label then --muted, h6 also italic)
carry the tier, since the * reset ate UA margins while the UA font-size ramp survived
(a sixth-level heading would otherwise render smaller and heavier than body copy, the exact inverse
of the type scale). Do not drop h5/h6 to 400: with the two-weight face that ties body copy and
pushes the same problem one tier down.
A presentational attribute loses to author CSS: td { text-align: start } silently beat
<td align="right">, so three [align] rules fix pipe-table alignment (inline
style="text-align:…", which pandoc emits, already won on its own).
GFM alerts take the aside rule rather than a second callout form: an alert is an aside. The
hue lands on the bar and .markdown-alert-title, never body text; warning keeps plain aside
orange so alert-free and alert-heavy documents read the same. GitHub’s octicon is fill: currentColor
and takes the hue for free.
Highlighted code reuses the Rider slot map rather than invents one: keywords --pink, strings
--green, numbers/parameters --orange, comments --muted italic, functions --link, types
--purple-bright, fields/attributes --label, errors/deletions --red, punctuation inherits. One
grouped selector per role covers highlight.js, pandoc/skylighting, Prism, Pygments, all scoped
under :is(pre, code) since the pandoc/Pygments classes are one and two letters that would
otherwise repaint a consumer’s own markup. One known, accepted collision: .ch is pandoc’s
Char in the string group and Pygments’ Comment.Hashbang, so a shebang renders in the string tier: cheaper than a second selector set.
Types take --purple-bright, and print inverts the lift back to plain --purple: plain
--purple is the one ratio the contrast budget records as a failure on --code-bg (“nothing puts
purple text on the gray”), but a syntax slot map does exactly that; --purple-bright is the same
oklch(from var(--purple) calc(l + 0.07) c h) lift scripts/create-themes.nu already calls
bright, needing no new hex. Print redeclares plain --purple since more lightness is less contrast
on light ground.
Monospace was inheriting italic from blockquote/th/summary: one rule resets code, pre,
kbd, samp inside those three (and h2/h6 for the same reason).
color-scheme is not declared. The property landed in 2019, and a 1996 page took the UA’s own
widgets and left them alone: on the black ground a native control renders light-mode, which is the
period look. Do not add color-scheme: dark to darken the widgets; the sheet deliberately lets
them render as the browser draws them.
The task-list checkbox stays fully native: GFM emits it disabled, and the sheet sets no
accent-color, so it draws as the browser’s own widget. A repaint would mean a literal hex data-URI
SVG with nothing to gate its drift and unreliable pseudo-elements in Safari. A read-only checkbox
that reads as read-only is the cheaper answer. list-style drops through
li:has(input[type="checkbox"]:first-child) rather than GFM’s own class, so it holds for
markdown-it output too.
Five smaller claims: del/s drop to --muted (full body color would read as emphasis); samp
takes --mono-font; sub/sup take line-height: 0 (a footnote ref shouldn’t open its line);
abbr[title] gets a dotted rule and cursor: help; img.emoji loses the --ring outline (that’s
for figures) and takes 1.1em.
Footnotes land in :is(.footnotes, .footnote) behind a hairline at the caption tier, matching
cmark-gfm, pandoc, and Python-Markdown’s shapes, dropping the duplicate leading <hr>. The
.sidenote apparatus is separate and still needs hand-authored markup.
.footnote-backref needs an aria-label: the one accessible-name requirement this repo asks of
converter output, not just hand-authored markup, since cmark-gfm/pandoc emit it as a bare ↩
glyph a screen reader can’t name. This is the opposite fix from a decorative glyph (which is
silenced rather than named): a backref is a real functional control, so it gets a name rather
than losing one. CONTRACT.md § 2 states it as a generator obligation, numbered per footnote.
The sheet styles math where it arrives as real HTML; it renders none. An unstyled
<math display="block"> overflows the page (same failure as a wide table, same two-rule fix: its own
scroll axis plus pre’s margin rhythm). .math.display covers both the pandoc --mathml span and a
KaTeX box. A font-size bump was built and dropped: the math font’s x-height already matches the
body serif, and a bump would re-scale math inside h3/td too.
TeX is not rendered: KaTeX/MathJax would be a second hard-offline dependency (Mermaid’s kind) for a construct that may never appear; the sheet styles the containers so a consumer adding KaTeX gets block layout free.
Chroma was declined on a namespace argument that turned out to silently exclude Pygments. Hugo
defaults noClasses = true (inline color, mostly moot), but Pygments emits classes by default and is
the highlighter behind Sphinx, MkDocs, Quarto, nbconvert. Chroma copies Pygments’ class names, so
the same selectors already cover consumers who turn noClasses off.
Every converter passes raw HTML through untouched.
Intrinsic-width media pushes the document sideways, and img was the only element claimed.
:is(svg, video, canvas, iframe, object, embed) { max-width: 100% } fixes the same 1.4.10 failure
the MathML block had. No height: auto on that rule, deliberately: an SVG with a viewBox
preserves its own ratio, and adding it would put a second sizing input on pre.mermaid svg, where
three earlier attempts were correct on paper and wrong on screen. svg is in that selector only
because a fixture diff proved it inert against mermaid: pre.mermaid svg already carries its own
max-width at higher specificity in every band that matters.
body takes overflow-wrap: break-word, not a list of nine selectors: one declaration inherits
to every prose container, including ones a hand list would forget, at no layout cost (break-word
doesn’t reduce min-content contribution).
position: sticky is scoped to thead th: unscoped, a tfoot header cell would pin to the top
too (a totals row stuck in the header’s place).
Permalink anchors reveal on hover, and the :focus-visible half is not optional. Sphinx, MkDocs
and markdown-it-anchor emit them visible by default; opacity: 0 hides them, lifting on :hover of
the heading and :focus-visible of the link: without the focus half, the link stays in tab
order while invisible, a keyboard stop nobody can see.
Form controls take font: inherit and a 1rem floor, nothing else: .filter-box was the only
control that set a family; every other one fell to a small sans-serif below the iOS Safari zoom
threshold. Appearance is deliberately not styled: a focus ring, hover, disabled and pressed state
is a button design this document theme (one control) doesn’t need, and the sheet declares no
color-scheme, so the UA draws its own widgets.
Three conventions stay unclaimed, reasons worth keeping: non-GFM callouts (.admonition,
.callout-*, .admonitionblock) render bare: Asciidoctor’s is worse, rendering as a <table> that
inherits the sheet’s sticky header; against three more names for a role the sheet already paints
twice, revisit only when a consumer actually runs Sphinx/MkDocs. Jupyter ANSI output: sixteen names
onto seven accents is a set of choices, not a translation, and the intense variants have nowhere
sensible to land (the .dataframe table pandas emits already inherits table rules). address and
.tabbed-set: address keeps UA italic (arguably right for a postal block); .tabbed-set shows
every panel at once, and a fix means a claim on a radio-driven widget rather than one rule.
A solid underline means a link; a dotted underline means an annotation. ins/u took the UA
underline at body color (the one mark this theme uses for a link), so track-changes markup read as
clickable. Both now use the dotted form abbr[title] already uses.
menu joins all three list rules (takes list-item children): without the indent rule the *
reset left its markers hanging outside the box.
The zero-user class families stay: scorecard, edge-list, col-2, badge, newthought,
sidenote, marginnote, the filter family, body.conn-map have no documents in the measured lode,
which measures the generator as much as the stylesheet: a generator that never offers a component
guarantees no document uses it. The zero on sidenote/marginnote is a generator gap, not a
design failure.
.verdict/.scorecard had the same generator gap, undocumented rather than merely unused: a
real proof-test page rendered every verdict as bare text, since CONTRACT.md had never listed the
markup for a generator to discover. Fixed by adding it (CONTRACT.md § 2, v1.31.0 row of § 3).
.verdict was missing display: inline-block, and min-width had been silently dead outside
.scorecard’s grid the whole time: a grid item’s computed display blockifies regardless of the
rule’s own declaration, so min-width: 5.2ch only ever held inside .scorecard; min-width doesn’t
apply to a non-replaced inline box at all, per spec, so the same class on a bare <span> elsewhere
had no floor under it. No fixture ever put .verdict outside .scorecard, so nothing caught it; a
short word like N/A happened to clear the floor on its own padding, producing an inconsistent
badge rather than a visibly broken one.
A fixture demonstrates states. It does not simulate them. Several details that look like filler are regression checks. A cut to any of these retires the check it exists to be.
A fixture is also the copy consumers paste, so its own strings have to be right. Two weren’t:
.filter-empty must not state a count (the fixture once said “Clear the filter to see all 4”, wrong
for any other list length, and filter.js only writes its own copy when the element is absent: so
whatever’s in the fixture is what ships and gets copied); .verdict text is written in sentence case
(the class already carries text-transform: uppercase, so PASS/SEE BELOW baked presentation into
copy).
A new page means five hardcoded fixture lists, not one: the page list in
scripts/build-sample.nu; the presence, style-and-script-count, and light-preview lists in
scripts/maintain.nu; the staleness list there plus FIXTURES in .github/render-modes.py. Miss
the render-modes.py one and the new page renders in no appearance mode while check still prints
Contract OK: that list drives the image count rather than deriving from the directory (four
fixtures times three modes is twelve images, the tell).
Specific fixture details and what they catch: samples/dark-timeline.html’s length is the point (four
era groups exercise --timeline-date’s multi-list case; citation density is where floated sidenotes
fail; the enlarged marker hit area and :target outline were measured against it). The sequence
diagram and quadrant chart sit beside the flowchart because a flowchart shows neither
label-measurement bug (its labels are browser-measured foreignObject HTML). The pie fence is the
only one putting text on a --data-* fill (its absence hid two real contrast defects for four
releases) and the only one depending on a non-color themeVariable. samples/dark-charts.html
carries both bar tables (no other coverage exists for table.bar-chart, and its background-image
band is exactly what print/forced-colors take away) plus a Mermaid pie of the same numbers, so the
CSS-chart-vs-pie guidance is checkable by eye. The conn-map focus node’s long label is the only place
a mis-sized node box lands in a constrained column. The filter’s role="status" and
.filter-empty lines are the only reference copy for both: nothing hand-written into a fixture
may restate a runtime value the reader controls (a count can be checked against the page; a quoted
query cannot). The wide .table-scroll table is the only instance of that wrapper (eight columns,
twenty-four rows, deliberately past both the sideways-scroll and 70vh thresholds). The highlighted
code block carries real highlight.js emitter classes (the only check the Rider slot map still
matches, and the only place --purple-bright renders); the Pygments block beside it is the only
check on the one/two-letter half of the slot map. The MathML block proves math[display="block"] is
claimed at all, and the pandoc span after it does the same for .math.display. The two permalinks
(a.headerlink on an h2, a.anchor on an h3) and the emoji image are the only instances of
their rules.
CONTRACT.md § 2 once pointed a consumer’s generator at fixture line numbers, and all 22 were
wrong: a fixture regenerates on every payload edit, so a line number
rots on a change unrelated to the requirement it names, and nothing could see it happen. Renumbering
was rejected (resets the same clock, needs a machine-readable token to gate at all, which is the
whole cost of the real fix). Each pointer is now a search string instead:
(in `samples/dark.html`, search `class="tag-dot"`), parsed and verified by
nu scripts/maintain.nu check, which refuses to pass on fewer than 20 pointers so a bulk delete can’t
make the gate vacuous.
§ 2 also made two countable claims about itself that had gone false: that every requirement
points at a fixture or says it has none (five bullets did neither), and its own bullet count stated
in prose (it read “Sixteen” against 21 bullets, with README.md carrying its own stale
copy). Both are now derived from the bullets rather than trusted. A prose claim about a countable
property is a gate waiting to be written.
Both the pointer gate and the fixture table-tab-stop scan were local-only for a while, which
doesn’t hold a merge. Both are now one function, contract-markup-ok, called from check and from
a main contract-markup CI subcommand.
The fixture breaks exactly one § 2 requirement on purpose, and that had to be said out loud: samples/dark.html carries two deliberately overlong quadrantChart point labels that § 2 otherwise
bans, since CONTRACT.md tells a generator the fixture wins any disagreement. The exception is now
named in both the intro and the bullet.
.github/script-probe.py loads samples/dark.html in headless Chrome, drives both inlined scripts,
and asserts what a reader would see (eighteen assertions over filter.js scope/counts/group-state,
mermaid.js rendering/zoom-binding/conditional-region, plus eight over the forced-colors block).
Everything else in CI counts blocks, compares bytes, or measures colors: none of that asks “does
this handler attach to anything.” Two real defects proved the gap: the fixture shipped an inert
input.filter-box for five releases, and a diagram click opened nothing for several more (see
Zoom).
It needs no new dependency: reuses render-modes.py’s same headless Chrome binary with
--dump-dom instead of --screenshot, a driver script appended to a scratch copy writing results
into the DOM for a one-regex readback (this sat in backlog.md for releases on a stated Node/jsdom
cost that was never actually required). The mermaid half is network-dependent and says so out
loud: the pinned CDN is probed for reachability first and reported as a SKIP, never folded into
a pass. It refuses to pass on fewer than 13 binding assertions and 8 forced-colors ones, and both
halves were mutation-tested (moving filter.js’s scope walk up one level, and making the mermaid
region unconditional, each turned the gate red on the exact assertion naming them).
The Nushell scripts live in scripts/, Python helpers stay in .github/: split by who invokes a
file, not by language. Each Python helper is a CI step appearing verbatim in contract-check.yml
and lives beside it; the Nushell scripts are commands a person types.
Both kinds resolve every path from the repo root, never cwd. Nushell needs two constants,
since path self | path dirname | path dirname is not a legal const chain:
const SCRIPTS = path self | path dirname
const ROOT = $SCRIPTS | path dirname
One instruction file: AGENTS.md, read directly by every harness. The rules once lived in
CLAUDE.md, which made a filename a dependency (another harness reading AGENTS.md would have
found nothing). The split that replaced it, with AGENTS.md for the rules and CLAUDE.md for the
Claude Code entry points, then failed the other way: Claude Code’s default claude-md-or-agents-md
setting loads a CLAUDE.md and skips AGENTS.md altogether, so a session read the short
pointer and never opened the contract it pointed at, with every check still green. Claude Code reads
AGENTS.md on its own from v2.1.277 on, so the pointer is deleted and its only content (the two
skills in .claude/skills/, and the rules binding a skill) moved into AGENTS.md, Harness entry
points. A second instruction file beside AGENTS.md is a regression, not a fallback: it
suppresses the contract instead of accompanying it, so do not add one, leave a stale one or symlink
one. A skill may only wrap a flow AGENTS.md already states in full: that’s what keeps a
harness without skills at full capability, losing an entry point, never a rule. Do not let a rule
come to rest only inside a skill, and don’t put repo policy in .claude/settings.json (status
line config only). AGENTS.md sits in the presence gate (scripts/maintain.nu and
contract-check.yml): a deleted instruction file is the one deletion that leaves every check green
while removing the reason the checks exist.
Python stays in the two .github/ helpers, measured rather than argued. Bash is not a real
alternative (no floating-point arithmetic; the Oklab matrix needs cos/sin/fractional powers, so a
bash version would really be an awk program in a shell wrapper, trading Python for a less readable
language). Nushell would genuinely drop a language for palette-check.py (the math ports exactly),
but it’s two hundred lines of the most load-bearing check in the repo, rewritten only to save an
already-preinstalled dependency: take it if the check needs a substantial change for its own
reasons, not on its own. render-modes.py cannot move at all: reading one PNG pixel needs zlib
inflate, which neither Nushell nor bash has, and the gzip-header inflate workaround always fails its
trailer (a zlib adler32 isn’t a gzip crc32), forcing the check to ignore its own exit status: exactly
the quiet wrongness the gate exists to catch.
A shadow-drawn rule on a zero-height box paints nothing. hr read border: none; box-shadow: …,
and border: none collapses the element to zero height, making the separator invisible at every
width, in print, and in forced colors. Now a border-block-start. Shadow-drawn rules are fine.
Shadow-drawn rules on a zero-height box are not.
A pipe-separated nav is a flex row, and the rule goes on the link, not between the links: nav:has(> a) takes display: flex; flex-wrap: wrap, nav > a + a carries the border. As plain
inline anchors, a long destination name broke inside the link across lines, splitting labels with
no separator between the halves; a flex item wraps as a unit and carries its leading rule with it.
The :has(> a) scoping is deliberate: a bare nav { display: flex } would also catch a
consumer’s nav > ul, silently turning it into a shrinking flex item.
The scrollbar is left to the UA: the sheet sets no scrollbar-color. The property landed in
2018, and a 1996 page had no way to restyle a scrollbar. overscroll-behavior: contain goes on
every scroll container the sheet owns
(.table-scroll, the mermaid overlay, narrow-viewport pre.mermaid, the conn-map sticky column), or
a scroll to an edge chains into the page behind it.
--ring is a token because the img hairline was the one color declared twice as a literal: as a token it flips in print with the other overrides. Deliberately not a palette color (white at
10% over an arbitrary image is a translucent veil, not a hue), so its alpha-slash form doesn’t match
palette-check.py’s token regex, leaving the parsed token count unaffected.
The W3C CSS validator’s css3 profile once flagged container-type and @container; both are
gone, so the flags are too. The validator now reports the sheet clean. If a future edit adds them
back, the two “errors” return, and they are the validator’s profile rather than the sheet: do not
delete a load-bearing rule to quiet it.