+
Operating notes
Read an 837 or an 835, or turn an 837 into test data.
Load an interchange and it is decoded, indexed and checked in this tab. Change it and you get a byte-faithful copy back — whitespace, delimiters, and every segment you didn't touch preserved exactly as they came in.
@@ -1575,7 +1937,7 @@
Read an 837 or an 835, or turn an 837 into test data.
- 01LoadDelimiters are read from the ISA header, not assumed — a file using
| and \n parses just as well as * and ~.
- 02ReadFour views of the same interchange: the segments themselves, the structure they imply, one row per service line, and anything malformed.
- - 03Change it, if you need toShift service dates, swap a string, or both. Rules run in order, top to bottom. Creating a Limited Data Set is its own action, below them.
+ - 03Configure and runShift service dates, swap a string, or both. Rules run in order, top to bottom. Creating a Limited Data Set is its own action, below them.
- 04Review, then downloadChanged segments are marked in the document and shown before and after under Changes. Nothing is written until you ask for it.
@@ -3058,11 +3420,11 @@
Using it with real data
// Row height in px. Must match `.row { height }` exactly -- the virtual
// list multiplies by it to size the scroll container and place the window.
-const ROW_H = 24;
+const ROW_H = 26;
// The tree runs looser than the ledger: it is read as structure rather than
// scanned as a table, and at 24px a parent and its children crowd together.
// Must match `.trow { height }` exactly, for the same reason as above.
-const TREE_ROW_H = 28;
+const TREE_ROW_H = 30;
// Rows drawn above and below the viewport so a fast scroll doesn't show gaps.
const OVERSCAN = 12;
@@ -4165,10 +4527,65 @@
Using it with real data
if (file) file.text().then((text) => loadFile(file.name, text));
fileInput.value = "";
});
+$("sampleToggle").addEventListener("click", () => {
+ const list = $("sampleList");
+ list.hidden = !list.hidden;
+ $("sampleToggle").setAttribute("aria-expanded", String(!list.hidden));
+});
$("sampleBtn").addEventListener("click", () => loadFile("sample_837p.edi", SAMPLE_837P));
$("samplePacdrBtn").addEventListener("click", () => loadFile("sample_837_pacdr.edi", SAMPLE_PACDR));
$("sample835Btn").addEventListener("click", () => loadFile("sample_835.edi", SAMPLE_835));
+/* Built from state on every call, so the one that can go stale -- Mode -- does
+ not. Size stays the source interchange's: the stage block beside it already
+ names the result, and a size that changed under the same label would read as
+ the input itself having changed. */
+function updateReadouts() {
+ if (!state.doc) return renderReadouts([]);
+
+ const st = state.doc.segments.find((s) => s.id === "ST");
+ // Escaped rather than literal: this file carries no
(the
+ // artifact wrapper supplies the head), so it must decode identically
+ // whether it is served or opened straight off disk.
+ const transaction = st && st.elements.length ? st.elements[0] : "\u2014";
+
+ const pairs = [
+ ["Segments", state.doc.segments.length.toLocaleString()],
+ ["Size", formatBytes(state.raw.length)],
+ ["Transaction", transaction],
+ ["Version", versionLabel(state.doc.version)],
+ ];
+ const claimCount = countClaims(state.doc.segments);
+ if (claimCount) pairs.push(["Claims", claimCount.toLocaleString()]);
+ // Only stated when a transaction actually is one: 837P files carry no such
+ // slot today and an empty one would imply something is missing.
+ if (hasPacdr(state.doc.segments)) pairs.push(["Guide", `${GUIDE_PACDR} PACDR`]);
+ // What the app has done to the file, not what the file is for. Until a rule
+ // or the Limited Data Set runs, every byte on screen is the file as it came.
+ pairs.push(["Mode", state.result ? "EDITED" : "READ"]);
+
+ renderReadouts(pairs);
+}
+
+/* Rebuilt whole rather than patched: it is at most seven pairs, so there is
+ nothing worth diffing, and a stale pair left from the previous file would
+ state something false about this one. */
+function renderReadouts(pairs) {
+ $("readouts").innerHTML = pairs
+ .map(([key, value]) =>
+ `
${escapeHtml(key)}` +
+ `${escapeHtml(String(value))}
`)
+ .join("");
+}
+
+/* CLM is one per claim in every guide this app reads, so counting it needs no
+ loop resolution -- unlike the Claims tab, which has to attribute each one. */
+function countClaims(segments) {
+ let n = 0;
+ for (const seg of segments) if (seg.id === "CLM") n++;
+ return n;
+}
+
function loadFile(name, raw) {
state.fileName = name;
state.raw = raw;
@@ -4179,6 +4596,7 @@
Using it with real data
$("fileName").textContent = name;
$("loaded").hidden = false;
+ $("stageId").hidden = false;
$("tiles").hidden = true;
$("tabChanges").hidden = true;
$("tabOutput").hidden = true;
@@ -4187,8 +4605,24 @@
Using it with real data
// Drop the previous file's results rather than leaving them behind hidden tabs.
$("paneChanges").innerHTML = "";
$("paneOutput").innerHTML = "";
- // A new file gets the default view, not the last one's expansion set.
+ // Everything derived from the previous document goes, before anything is
+ // rendered. This is not tidiness: selectTab() paints the document list on
+ // the way in, which runs BEFORE renderDocument() rebuilds it, so a stale
+ // `visible` gets indexed against the new document's segments. Loading a
+ // 31-segment sample over a 3,000-segment file threw
+ // "Cannot read properties of undefined (reading 'id')" out of renderRow,
+ // which aborted loadFile before renderDocument and updateReadouts -- the
+ // file name changed, nothing else did, and no error was shown.
+ //
+ // The tree half of this was always here; the document half was not, which
+ // is why the Tree tab never had the bug.
$("paneTree").innerHTML = "";
+ state.visible = [];
+ state.needle = "";
+ state.window = null;
+ state.focus = null;
+ state.claims = null;
+ state.findings = null;
state.tree = null;
state.treeExpanded = null;
state.treeRows = [];
@@ -4208,7 +4642,10 @@
Using it with real data
$("blank").hidden = false;
errorSlot.innerHTML = errorBanner("Can't read this file. ", err.message);
errorSlot.hidden = false;
- $("fileMeta").innerHTML = "";
+ // A file that would not parse gets no readouts: every one of them is a
+ // claim about a document there isn't one of.
+ updateReadouts();
+ $("stageId").hidden = true;
$("delims").innerHTML = "";
syncRunButton();
return;
@@ -4227,29 +4664,21 @@
Using it with real data
syncChecksBadge();
$("blank").hidden = true;
$("workspace").hidden = false;
- $("stageLabel").textContent = "Loaded";
+ // A new document is a new scroll position. Without this, loading a small
+ // file while scrolled deep into a large one leaves the pane scrolled past
+ // the end of the new one: windowRange computes a first row beyond the total,
+ // the slice comes back empty, and the pane looks like nothing loaded even
+ // though the parse succeeded and the readouts updated.
+ const panes = document.querySelector(".panes");
+ if (panes) panes.scrollTop = 0;
+ window.scrollTo(0, 0);
+ syncPanesTop();
+ $("stageLabel").textContent = "Loaded interchange";
$("stageName").textContent = name;
selectTab("document");
renderDocument();
- const st = state.doc.segments.find((s) => s.id === "ST");
- // Escaped rather than literal: this file carries no
(the
- // artifact wrapper supplies the head), so it must decode identically
- // whether it is served or opened straight off disk.
- const transaction = st && st.elements.length ? st.elements[0] : "\u2014";
-
- const meta = [
- `
${state.doc.segments.length.toLocaleString()} segments`,
- `
${formatBytes(raw.length)}`,
- `
transaction ${escapeHtml(transaction)}`,
- `
version ${escapeHtml(versionLabel(state.doc.version))}`,
- ];
- // Only stated when a transaction actually is one: 837P files carry no such
- // slot today and an empty one would imply something is missing.
- if (hasPacdr(state.doc.segments)) {
- meta.push(`
guide ${escapeHtml(GUIDE_PACDR)} PACDR`);
- }
- $("fileMeta").innerHTML = meta.join("");
+ updateReadouts();
// Repetition is only listed when the file actually declares one: pre-5010
// interchanges have no such separator, and showing an empty slot for them
@@ -4350,7 +4779,14 @@
Using it with real data
// list -- only the rows move.
function showRowsFromTop() {
if (!$("docRows")) return;
- $("docRows").scrollIntoView({ block: "start" });
+ // scrollIntoView aligns the row list with the top of the scrollport, which
+ // drags the sticky filter and outline hard against the tab strip -- the
+ // panel reads as welded to the tabs. Going to the top of the pane instead
+ // leaves both headers at their resting position. The fallback is the narrow
+ // layout, where the page scrolls and the pane is not a scroll container.
+ const panes = document.querySelector(".panes");
+ if (panes && panes.scrollHeight > panes.clientHeight + 1) panes.scrollTop = 0;
+ else $("docRows").scrollIntoView({ block: "start" });
state.window = null; // the list moved relative to the fold; reslice it
paintWindow();
}
@@ -4414,7 +4850,13 @@
Using it with real data
// the tree passes its own.
function windowRange(containerTop, viewportHeight, total, rowHeight) {
const h = rowHeight || ROW_H;
- const first = Math.max(0, Math.floor(-containerTop / h) - OVERSCAN);
+ // Clamped at both ends. Below zero is overscroll above the list; past the
+ // last row is a shorter document arriving under a scroll position left over
+ // from a longer one, which produced first > last and a slice() that silently
+ // returned nothing -- a pane that looked like the file had failed to load.
+ // Rendering the tail is the honest degradation; loadFile also resets scroll.
+ const wanted = Math.floor(-containerTop / h) - OVERSCAN;
+ const first = Math.min(Math.max(0, wanted), Math.max(0, total - 1));
const fit = Math.ceil(viewportHeight / h) + OVERSCAN * 2;
return { first, last: Math.min(total, first + fit) };
}
@@ -4456,12 +4898,24 @@
Using it with real data
TREE_ROW_H);
}
+/* The outline is sticky to the top of .panes and caps its own height, so it
+ has to know where .panes starts. That offset changes when the tiles row
+ appears and when the window resizes, so it is measured rather than written
+ down -- a constant here is what broke after the masthead moved. */
+function syncPanesTop() {
+ const panes = document.querySelector(".panes");
+ if (!panes) return;
+ const top = Math.max(0, Math.round(panes.getBoundingClientRect().top));
+ document.documentElement.style.setProperty("--panes-top", `${top}px`);
+}
+
let paintQueued = false;
function queuePaint() {
if (paintQueued) return;
paintQueued = true;
requestAnimationFrame(() => {
paintQueued = false;
+ syncPanesTop();
// A hidden pane measures as zero, so painting one would cache a window
// computed from nothing. Each tab resets its own on the way back in.
if (!$("paneDocument").hidden) paintWindow();
@@ -5587,8 +6041,9 @@
Using it with real data
}
function render(result) {
- $("stageLabel").textContent = "Result";
+ $("stageLabel").textContent = "Result interchange";
$("stageName").textContent = state.outputName;
+ updateReadouts();
$("tiles").hidden = false;
$("tabChanges").hidden = false;
$("tabOutput").hidden = false;
@@ -5708,6 +6163,9 @@
Using it with real data
const TABS = ["document", "tree", "claims", "checks", "changes", "output"];
function selectTab(which) {
+ // Tabs change what sits above .panes -- the tiles row appears with a result
+ // -- so the outline's cap is re-measured on the way in, not only on scroll.
+ syncPanesTop();
for (const name of TABS) {
const key = name[0].toUpperCase() + name.slice(1);
$("tab" + key).setAttribute("aria-selected", String(name === which));
diff --git a/web/embed-font.py b/web/embed-font.py
new file mode 100644
index 0000000..1bcb851
--- /dev/null
+++ b/web/embed-font.py
@@ -0,0 +1,160 @@
+#!/usr/bin/env python3
+"""Subset Barlow Condensed and emit the @font-face CSS pasted into EDIWorkbench.html.
+
+The app is one self-contained HTML file with no external requests -- lint.mjs
+fails the suite on any src/href, fetch, XHR or socket -- so a webfont is either
+embedded as a base64 data URI or not used at all. Barlow Condensed carries the
+design's character in the wordmark, headings, card titles and tile numerals.
+
+Full Google "latin" subsets are ~22 KB per weight: +24% on a 250 KB app, for
+glyph coverage this app never renders. Cutting to the marks below lands near
+7 KB per weight, ~9.7 KB once base64'd -- about +7.6% for both weights.
+
+This is an authoring-time tool. Its output is a string baked into the HTML, so
+the app keeps its zero-dependency, zero-build-step property: nothing here runs
+at page load, and nothing here is needed to run the test suites.
+
+Requires fontTools and brotli (brotli is what writes woff2):
+ python -m pip install fonttools brotli
+
+Usage:
+ python web/embed-font.py SRC_600.woff2 SRC_700.woff2 > web/fonts/face.css
+
+Sources: any Barlow Condensed woff2 at weights 600 and 700 -- the latin subsets
+from Google Fonts, or the copies inside the design artifact under
+docs/notes/design-sources/unpacked/ (gitignored).
+"""
+
+import base64
+import io
+import sys
+
+from fontTools import subset
+from fontTools.ttLib import TTFont
+
+# Printable ASCII covers every heading, label and numeral the app renders in
+# this family. The extras are the typographic marks the markup actually uses
+# (·, —, –, …, curly quotes) plus a few that turn up in
+# decoded EDI values and check messages.
+UNICODES = ",".join([
+ "U+0020-007E", # printable ASCII
+ "U+00A0", # no-break space
+ "U+00A9", # (c)
+ "U+00B0", # degree
+ "U+00B7", # middle dot -- the wordmark and tagline separators
+ "U+00D7", # multiplication sign
+ "U+2013-2014", # en dash, em dash
+ "U+2018-201D", # curly quotes
+ "U+2022", # bullet
+ "U+2026", # ellipsis
+ "U+2192", # right arrow -- before/after captions
+ "U+2212", # true minus, for tabular figures
+ "U+2713", # check mark
+])
+
+# Barlow is published under the SIL Open Font License 1.1 with NO Reserved Font
+# Name -- its copyright string carries no "with Reserved Font Name" clause. That
+# is what makes it legal to keep the family name on a subset; a Modified Version
+# of a reserved-name family would have to be renamed.
+#
+# OFL section 1 requires the copyright and permission notices to travel with
+# every copy, and states they "can be included either as text files or in
+# machine-readable metadata fields". The single HTML file travels detached from
+# this repo, so the repo's copy of the licence cannot be what satisfies that --
+# the notices have to ride inside the font, which is what the name records below
+# do. web/fonts/OFL.txt keeps the full text for the repo.
+LICENSE_NOTICE = (
+ "This Font Software is licensed under the SIL Open Font License, "
+ "Version 1.1. This licence is available with a FAQ at "
+ "https://scripts.sil.org/OFL"
+)
+LICENSE_URL = "https://scripts.sil.org/OFL"
+
+NAME_COPYRIGHT, NAME_LICENSE, NAME_LICENSE_URL = 0, 13, 14
+
+# Windows/Unicode/US-English, the platform triple every browser reads.
+WIN_UNICODE_EN = (3, 1, 0x409)
+
+
+def parse_unicodes(spec):
+ """Expand a 'U+0020-007E,U+00A9' spec into a set of codepoints."""
+ points = set()
+ for part in spec.split(","):
+ part = part.strip().removeprefix("U+")
+ if "-" in part:
+ low, high = part.split("-")
+ points.update(range(int(low, 16), int(high, 16) + 1))
+ else:
+ points.add(int(part, 16))
+ return points
+
+
+def build(src_path):
+ """Subset src_path to UNICODES and return (woff2 bytes, copyright string)."""
+ font = TTFont(src_path)
+
+ options = subset.Options()
+ # Keep kerning; drop the rest. This family's other features (figure
+ # variants, alternates) are never selected by the app's CSS, and each one
+ # drags in a glyph set of its own.
+ options.layout_features = ["kern"]
+ options.hinting = False
+ options.desubroutinize = True
+ # Without 13 and 14 here the subsetter keeps only name IDs 0-6, dropping the
+ # licence records this font is required to carry.
+ options.name_IDs = [0, 1, 2, 3, 4, 5, 6, 13, 14]
+
+ subsetter = subset.Subsetter(options=options)
+ subsetter.populate(unicodes=parse_unicodes(UNICODES))
+ subsetter.subset(font)
+
+ copyright_notice = font["name"].getDebugName(NAME_COPYRIGHT)
+ if not copyright_notice:
+ raise SystemExit(f"{src_path}: no copyright record -- refusing to embed")
+
+ # Google's webfont builds carry the copyright but no licence notice. Set
+ # both explicitly so the embedded font states its own terms wherever the
+ # HTML file ends up.
+ font["name"].setName(LICENSE_NOTICE, NAME_LICENSE, *WIN_UNICODE_EN)
+ font["name"].setName(LICENSE_URL, NAME_LICENSE_URL, *WIN_UNICODE_EN)
+
+ font.flavor = "woff2"
+ buffer = io.BytesIO()
+ font.save(buffer)
+ return buffer.getvalue(), copyright_notice
+
+
+def main(argv):
+ if len(argv) != 3:
+ raise SystemExit(__doc__)
+
+ faces, notice = [], None
+ for weight, src in zip((600, 700), argv[1:]):
+ woff2, copyright_notice = build(src)
+ notice = notice or copyright_notice
+ encoded = base64.b64encode(woff2).decode("ascii")
+ # Sizes go to stderr so stdout stays pasteable CSS.
+ print(
+ f"Barlow Condensed {weight}: {len(woff2):,} bytes woff2, "
+ f"{len(encoded):,} base64",
+ file=sys.stderr,
+ )
+ faces.append(
+ " @font-face {\n"
+ " font-family: 'Barlow Condensed';\n"
+ " font-style: normal;\n"
+ f" font-weight: {weight};\n"
+ " font-display: swap;\n"
+ f" src: url(data:font/woff2;base64,{encoded}) format('woff2');\n"
+ " }"
+ )
+
+ print(f" /* {notice}")
+ print(f" {LICENSE_NOTICE}")
+ print(" Subset to the glyphs this app renders, by web/embed-font.py.")
+ print(" Full licence text: web/fonts/OFL.txt */")
+ print("\n".join(faces))
+
+
+if __name__ == "__main__":
+ main(sys.argv)
diff --git a/web/fonts/OFL.txt b/web/fonts/OFL.txt
new file mode 100644
index 0000000..1bde629
--- /dev/null
+++ b/web/fonts/OFL.txt
@@ -0,0 +1,94 @@
+Copyright (c) 2017, The Barlow Project Authors
+(https://github.com/jpt/barlow).
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://openfontlicense.org
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/web/tests/all.mjs b/web/tests/all.mjs
index b0b5381..455dd14 100644
--- a/web/tests/all.mjs
+++ b/web/tests/all.mjs
@@ -16,6 +16,7 @@ const SUITES = [
["validate", "control counts, claim balance, and count repair"],
["deid", "patient de-identification, consistency, and date shifting"],
["virtual", "virtual list windowing arithmetic"],
+ ["render", "the load path, driven against a throwaway DOM"],
["lint", "single-file self-containment and markup"],
];
diff --git a/web/tests/lint.mjs b/web/tests/lint.mjs
index 8b28fbe..25abd05 100644
--- a/web/tests/lint.mjs
+++ b/web/tests/lint.mjs
@@ -84,7 +84,7 @@ const blocks = {
'data-theme="light"': css.match(/:root\[data-theme="light"\] \{([\s\S]*?)\n \}/),
};
const colorTokens = (body) =>
- new Set([...body.matchAll(/(--[\w-]+):/g)].map((x) => x[1]).filter((t) => !t.startsWith("--step") && t !== "--mono" && t !== "--sans"));
+ new Set([...body.matchAll(/(--[\w-]+):/g)].map((x) => x[1]).filter((t) => !t.startsWith("--step") && !["--mono", "--sans", "--display"].includes(t)));
const base = colorTokens(blocks[":root"][1]);
for (const [name, match] of Object.entries(blocks)) {
if (name === ":root") continue;
@@ -107,17 +107,22 @@ else if (!/max-height:/.test(outlineRule[1])) bad(".outline must cap its height,
else if (!/overflow-y:\s*auto/.test(outlineRule[1])) bad(".outline caps its height but cannot scroll to its own bottom");
else ok(".outline caps its height and scrolls itself");
-/* --- 8. the rail masthead stays pinned ------------------------------ */
-// The mask and theme toggles act on whatever is on screen at the time, so
-// they have to be reachable at any scroll position. They were inside the
-// scroll container until the rail was split, and reaching the mask button
-// meant scrolling the rules back up first. Two invariants keep that fixed:
-// the masthead sits outside .rail-body, and .rail-body is what scrolls.
-const railBody = markup.slice(markup.indexOf('class="rail-body"'), markup.indexOf(""));
-if (markup.indexOf('class="rail-body"') === -1) bad("no .rail-body found: the rail no longer splits head from body");
-else if (railBody.includes('class="masthead"')) bad("the masthead is inside .rail-body, so it scrolls out of reach");
-else if (!markup.includes('class="rail-head"')) bad("no .rail-head wrapper around the masthead");
-else ok("the masthead sits outside the rail's scroll container");
+/* --- 8. the mask and theme toggles stay reachable -------------------- */
+// They act on whatever is on screen at the time, so they have to be reachable
+// at any scroll position. They lived inside the rail's scroll container once,
+// and reaching the mask button meant scrolling the rules back up first; the
+// rail was split into a pinned head and a scrolling body to fix that. They now
+// sit in the page header instead, which is unconditionally reachable -- so the
+// invariant is no longer "outside .rail-body" but "outside the rail entirely".
+// Keep it asserted: this is a bug that has already happened once.
+const rail = markup.slice(markup.indexOf("