@wxxb789/anc 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +295 -0
  3. package/astro.config.mjs +255 -0
  4. package/bin/anc.mjs +798 -0
  5. package/package.json +93 -0
  6. package/public/_headers +45 -0
  7. package/public/favicon.ico +0 -0
  8. package/public/favicon.svg +9 -0
  9. package/public/fonts/Temml.woff2 +0 -0
  10. package/scripts/build-snapshot.js +69 -0
  11. package/scripts/build-wasm.js +67 -0
  12. package/scripts/build-workspace.js +50 -0
  13. package/scripts/copy-snapshot.js +48 -0
  14. package/scripts/copy-wasm.js +57 -0
  15. package/scripts/emit-redirects.js +64 -0
  16. package/scripts/generate-corpus.js +421 -0
  17. package/scripts/init-repository.js +249 -0
  18. package/scripts/load-config.js +975 -0
  19. package/scripts/markdown-to-artifact.js +1286 -0
  20. package/scripts/preview-site.js +364 -0
  21. package/scripts/publish-set-review.js +157 -0
  22. package/scripts/resolve-links.js +640 -0
  23. package/scripts/run-pagefind.js +125 -0
  24. package/scripts/scan-residue.js +1016 -0
  25. package/scripts/scan-secrets.js +268 -0
  26. package/scripts/seed-gitignore.js +410 -0
  27. package/scripts/snapshot-rows.js +205 -0
  28. package/scripts/validate-content.js +95 -0
  29. package/scripts/vendor-provenance.js +307 -0
  30. package/scripts/verify-output-inventory.js +307 -0
  31. package/scripts/write-report.js +351 -0
  32. package/scripts/write-snapshot.js +136 -0
  33. package/src/components/CollectionExplorer.astro +149 -0
  34. package/src/components/FacetIndex.astro +45 -0
  35. package/src/components/LinkedNotes.astro +94 -0
  36. package/src/components/NoteGraph.astro +434 -0
  37. package/src/components/NoteList.astro +54 -0
  38. package/src/components/SiteMetadata.astro +143 -0
  39. package/src/components/TableOfContents.astro +69 -0
  40. package/src/components/TagBrowser.astro +70 -0
  41. package/src/layouts/Layout.astro +305 -0
  42. package/src/lib/artifact-source.js +72 -0
  43. package/src/lib/build-bindings.js +11 -0
  44. package/src/lib/collection-navigation.js +145 -0
  45. package/src/lib/content.js +92 -0
  46. package/src/lib/diagram-mode.js +18 -0
  47. package/src/lib/graph-layout.js +219 -0
  48. package/src/lib/graph-selection.js +84 -0
  49. package/src/lib/graph.js +42 -0
  50. package/src/lib/link-resolution.js +389 -0
  51. package/src/lib/markdown.js +1469 -0
  52. package/src/lib/math-mode.js +31 -0
  53. package/src/lib/math-output.js +131 -0
  54. package/src/lib/math.js +165 -0
  55. package/src/lib/mermaid-environment.js +475 -0
  56. package/src/lib/mermaid-render.js +769 -0
  57. package/src/lib/preview-model.js +96 -0
  58. package/src/lib/relations.js +242 -0
  59. package/src/lib/rendered-marker.js +55 -0
  60. package/src/lib/route-path.js +192 -0
  61. package/src/lib/routes.js +342 -0
  62. package/src/lib/schema.js +578 -0
  63. package/src/lib/site.js +578 -0
  64. package/src/lib/snapshot-contract.js +216 -0
  65. package/src/lib/snapshot-operations.js +147 -0
  66. package/src/lib/snapshot-queries.js +216 -0
  67. package/src/lib/snapshot-reader.js +210 -0
  68. package/src/lib/snapshot.js +233 -0
  69. package/src/lib/sqlite-warning.js +22 -0
  70. package/src/lib/sqlite.js +14 -0
  71. package/src/lib/tag-browser-model.js +18 -0
  72. package/src/lib/translations.js +533 -0
  73. package/src/lib/wasm-asset.js +47 -0
  74. package/src/lib/worker-protocol.js +93 -0
  75. package/src/pages/404.astro +35 -0
  76. package/src/pages/about.astro +100 -0
  77. package/src/pages/collections/[slug].astro +36 -0
  78. package/src/pages/collections/index.astro +33 -0
  79. package/src/pages/graph.astro +64 -0
  80. package/src/pages/index.astro +33 -0
  81. package/src/pages/notes/[slug].astro +509 -0
  82. package/src/pages/privacy.astro +121 -0
  83. package/src/pages/private.astro +59 -0
  84. package/src/pages/recent.astro +33 -0
  85. package/src/pages/robots.txt.js +15 -0
  86. package/src/pages/rss.xml.js +21 -0
  87. package/src/pages/sitemap.xml.js +20 -0
  88. package/src/pages/tags/[tag].astro +56 -0
  89. package/src/pages/tags/index.astro +37 -0
  90. package/src/scripts/code-copy.js +45 -0
  91. package/src/scripts/diagram-disabled.js +16 -0
  92. package/src/scripts/diagram.js +322 -0
  93. package/src/scripts/graph-client.js +222 -0
  94. package/src/scripts/graph-draw.js +166 -0
  95. package/src/scripts/link-preview.js +334 -0
  96. package/src/scripts/math-disabled.js +18 -0
  97. package/src/scripts/math.js +133 -0
  98. package/src/scripts/preferences.js +123 -0
  99. package/src/scripts/search-dialog.js +651 -0
  100. package/src/scripts/snapshot-client.js +157 -0
  101. package/src/scripts/snapshot-worker.js +341 -0
  102. package/src/scripts/tag-browser.js +153 -0
  103. package/src/scripts/theme-init.js +35 -0
  104. package/src/scripts/toc-active.js +38 -0
  105. package/src/styles/code.css +152 -0
  106. package/src/styles/diagram.css +149 -0
  107. package/src/styles/global.css +1739 -0
  108. package/src/styles/graph.css +317 -0
  109. package/src/styles/math.css +376 -0
  110. package/src/styles/tokens.css +171 -0
  111. package/tsconfig.json +25 -0
@@ -0,0 +1,1739 @@
1
+ @import './tokens.css';
2
+
3
+ /*
4
+ * Layout shell and reading surface.
5
+ *
6
+ * Everything here is plain CSS with no JavaScript dependency: the theme follows
7
+ * `prefers-color-scheme` through `light-dark()` in `tokens.css`, and the two
8
+ * preference toggles only override an attribute that CSS already understands.
9
+ */
10
+
11
+ *,
12
+ *::before,
13
+ *::after {
14
+ box-sizing: border-box;
15
+ }
16
+
17
+ html {
18
+ scroll-behavior: smooth;
19
+ /* Keeps an in-page anchor target clear of the sticky header. The header
20
+ wraps to several rows at 320 px, so the mobile-first value allows for
21
+ that and the wide-viewport override below tightens it. */
22
+ scroll-padding-top: 9rem;
23
+ }
24
+
25
+ body {
26
+ margin: 0;
27
+ background: var(--color-bg);
28
+ color: var(--color-text);
29
+ font-family: var(--font-sans);
30
+ font-size: var(--text-base);
31
+ line-height: var(--leading-body);
32
+ /* A long unbroken token (a URL, a hash) must wrap rather than widen the page. */
33
+ overflow-wrap: break-word;
34
+ }
35
+
36
+ /*
37
+ * Mixed zh-CN / English line breaking.
38
+ *
39
+ * `word-break: normal` keeps English words whole; CJK already breaks between
40
+ * characters, so nothing extra is needed to wrap Chinese text. `line-break:
41
+ * strict` stops a line from starting with a CJK closing bracket or ending with
42
+ * an opening one, which is the visible failure mode in bilingual prose.
43
+ */
44
+ :where(body) {
45
+ word-break: normal;
46
+ line-break: strict;
47
+ }
48
+
49
+ :is(h1, h2, h3, h4, h5, h6) {
50
+ line-height: var(--leading-tight);
51
+ text-wrap: balance;
52
+ }
53
+
54
+ p,
55
+ li {
56
+ text-wrap: pretty;
57
+ }
58
+
59
+ a {
60
+ color: var(--color-link);
61
+ text-underline-offset: 0.2em;
62
+ }
63
+
64
+ /* Focus is never signalled by colour alone: the ring is a shape change too. */
65
+ :focus-visible {
66
+ outline: 2px solid var(--color-focus);
67
+ outline-offset: 2px;
68
+ border-radius: var(--radius-sm);
69
+ }
70
+
71
+ :is(img, svg, video) {
72
+ max-width: 100%;
73
+ height: auto;
74
+ }
75
+
76
+ .skip-link {
77
+ position: absolute;
78
+ top: var(--space-2);
79
+ left: var(--space-2);
80
+ z-index: 100;
81
+ padding: var(--space-2) var(--space-4);
82
+ border: 1px solid var(--color-line-strong);
83
+ border-radius: var(--radius-sm);
84
+ background: var(--color-surface);
85
+ color: var(--color-text);
86
+ box-shadow: var(--elevation-2);
87
+ /* Off-screen until focused, rather than `display: none`, so it stays tabbable. */
88
+ transform: translateY(calc(-100% - var(--space-4)));
89
+ transition: transform var(--motion-fast) var(--ease);
90
+ }
91
+
92
+ .skip-link:focus-visible {
93
+ transform: translateY(0);
94
+ }
95
+
96
+ /*
97
+ * Controls that only work with JavaScript. `theme-init.js` sets `data-js` on
98
+ * the root before first paint, so they never appear as dead buttons.
99
+ *
100
+ * The selector deliberately outranks `.site-nav a, .site-nav button` below: a
101
+ * bare `[data-js-only]` is (0,1,0) and loses to that rule's (0,1,1), which
102
+ * would leave these visible with scripting disabled — the failure this rule
103
+ * exists to prevent. `tests/built-output.test.ts` resolves the cascade.
104
+ */
105
+ :root:not([data-js='on']) [data-js-only] {
106
+ display: none;
107
+ }
108
+
109
+ /* ---------------------------------------------------------------- shell -- */
110
+
111
+ .site-header {
112
+ position: sticky;
113
+ top: 0;
114
+ z-index: 10;
115
+ display: flex;
116
+ flex-wrap: wrap;
117
+ gap: var(--space-3);
118
+ justify-content: space-between;
119
+ align-items: center;
120
+ padding: var(--space-3) var(--gutter);
121
+ border-bottom: 1px solid var(--color-line);
122
+ background: var(--color-bg);
123
+ }
124
+
125
+ .brand {
126
+ color: var(--color-text);
127
+ font-weight: 700;
128
+ letter-spacing: -0.03em;
129
+ text-decoration: none;
130
+ }
131
+
132
+ .site-nav {
133
+ display: flex;
134
+ flex-wrap: wrap;
135
+ gap: var(--space-2);
136
+ align-items: center;
137
+ }
138
+
139
+ .site-nav a,
140
+ .site-nav button {
141
+ display: inline-flex;
142
+ gap: var(--space-1);
143
+ align-items: center;
144
+ padding: var(--space-1) var(--space-2);
145
+ border: 1px solid transparent;
146
+ border-radius: var(--radius-sm);
147
+ background: none;
148
+ color: var(--color-muted);
149
+ font: inherit;
150
+ font-size: var(--text-sm);
151
+ text-decoration: none;
152
+ cursor: pointer;
153
+ }
154
+
155
+ .site-nav a:hover,
156
+ .site-nav button:hover {
157
+ border-color: var(--color-line-strong);
158
+ color: var(--color-text);
159
+ }
160
+
161
+ /* Pressed state carries a border and a background, not just a colour shift. */
162
+ .site-nav button[aria-pressed='true'] {
163
+ border-color: var(--color-accent);
164
+ background: var(--color-wash);
165
+ color: var(--color-text);
166
+ }
167
+
168
+ main {
169
+ width: min(var(--width-page), 100%);
170
+ margin: 0 auto;
171
+ padding: var(--space-12) var(--gutter) var(--space-16);
172
+ }
173
+
174
+ /* `#main` is focused programmatically by the skip link and carries
175
+ `tabindex="-1"`, so it is never reached by Tab. Suppress the ring only for
176
+ that programmatic case; `:focus-visible` keeps it wherever a browser judges
177
+ the focus worth showing. */
178
+ main:focus:not(:focus-visible) {
179
+ outline: none;
180
+ }
181
+
182
+ /* ------------------------------------------------------------ explorer -- */
183
+
184
+ /*
185
+ * The collection rail (TK-05c).
186
+ *
187
+ * Below the two-column breakpoint it is a block after the article: one column,
188
+ * every group closed except the reader's own, and the disclosures *are* the
189
+ * drawer requirements section 16 asks for on mobile. The desktop treatment —
190
+ * sticky, beside the article — is the `@media (min-width: 64rem)` block at the
191
+ * end of this file.
192
+ *
193
+ * Nothing here needs JavaScript. `<details>` is the collapse, `position: sticky`
194
+ * is the follow, and `aria-current` is the location; there is no script to load
195
+ * and no state to persist.
196
+ */
197
+ .explorer {
198
+ width: min(var(--width-page), 100%);
199
+ margin: 0 auto var(--space-16);
200
+ padding: 0 var(--gutter);
201
+ font-size: var(--text-sm);
202
+ }
203
+
204
+ .explorer-groups,
205
+ .explorer-notes {
206
+ margin: 0;
207
+ padding: 0;
208
+ list-style: none;
209
+ }
210
+
211
+ .explorer-group {
212
+ border-bottom: 1px solid var(--color-line);
213
+ }
214
+
215
+ .explorer-group > summary {
216
+ display: flex;
217
+ gap: var(--space-2);
218
+ justify-content: space-between;
219
+ align-items: baseline;
220
+ padding: var(--space-3) var(--space-1);
221
+ color: var(--color-text);
222
+ font-weight: 600;
223
+ cursor: pointer;
224
+ }
225
+
226
+ /* Text, not a badge — same treatment as every other count on the site. */
227
+ .explorer-count {
228
+ color: var(--color-muted);
229
+ font-size: var(--text-xs);
230
+ font-variant-numeric: tabular-nums;
231
+ }
232
+
233
+ /*
234
+ * The open/close transition, on the native `::details-content` pseudo-element.
235
+ *
236
+ * This is Quartz's `0fr → 1fr` idea reached through the platform instead of
237
+ * through script: `interpolate-size: allow-keywords` lets `block-size` animate
238
+ * between `0` and `auto`, and `content-visibility` is in the transition list
239
+ * with `allow-discrete` so the content is not clipped away before the height
240
+ * has finished collapsing.
241
+ *
242
+ * A `transition`, deliberately **not** an `animation`, and that distinction is a
243
+ * WCAG fix rather than a stylistic one. An `animation` on `[open]` runs when the
244
+ * page *loads*, because the reader's own group is already open in the served
245
+ * HTML — so the rail faded in from `opacity: 0` on every navigation, and the
246
+ * out-of-band axe audit caught the links mid-fade at a computed 4.44:1 against
247
+ * the 4.5 threshold, on 245 nodes across both corpora. A transition fires only
248
+ * on a state change, so an already-open group is simply open at first paint and
249
+ * there is no partial-opacity frame for anything to measure. That audit is run
250
+ * from `.tmp/axe-audit.mjs` against an `axe.min.js` on disk — axe-core is in
251
+ * neither `dependencies` nor `devDependencies`, so no gate in this repository
252
+ * recomputes the number and TK-09 owns whether one should.
253
+ *
254
+ * `::details-content` rather than Quartz's `grid-template-rows: 0fr → 1fr`,
255
+ * which was the shape L5 named. Both are transitions and either would have
256
+ * fixed the contrast defect; the reason to prefer this one is that
257
+ * `grid-template-rows` needs a wrapper element to be the single grid row, and
258
+ * a `<details>` given `display: grid` loses the browser's own open/closed box
259
+ * behaviour — so that shape costs a `<div>` per group whose only job is to be
260
+ * animated. The trade is browser support: `interpolate-size` is Chromium-only
261
+ * today, where `grid-template-rows` is not. That is why the whole block sits
262
+ * behind `@supports` and the collapse itself is the native `<details>` — a
263
+ * browser that skips this snaps open exactly as it always has, which is a
264
+ * missing animation rather than a missing feature.
265
+ *
266
+ * The `prefers-reduced-motion` block at the end of this file turns the
267
+ * transition off. It needs `*::details-content` in its selector list to do so:
268
+ * a type selector matches elements and never a pseudo-element, so `*` alone
269
+ * left this transition running at its full duration under `reduce`.
270
+ */
271
+ @supports (interpolate-size: allow-keywords) {
272
+ .explorer-group {
273
+ interpolate-size: allow-keywords;
274
+ }
275
+
276
+ .explorer-group::details-content {
277
+ block-size: 0;
278
+ /*
279
+ * `clip` with a margin rather than `hidden`, so the focus ring survives.
280
+ *
281
+ * This pseudo-element is a clip box that hugs the links exactly, and the
282
+ * global ring is a 2 px outline at `outline-offset: 2px` — so under
283
+ * `overflow: hidden` every link's ring was cut flat on both horizontal
284
+ * edges. Measured: 0 px of ring reached the left and right of a note link
285
+ * at every width, against the 4 px it needs. The rail's own padding cannot
286
+ * fix this one; it is a second, inner clip box.
287
+ *
288
+ * `overflow-clip-margin` lets the ring paint outside the box while the box
289
+ * still clips the collapse — which `scroll` or `visible` would not, since
290
+ * either would let a closed group's links bleed below its summary. WCAG
291
+ * 2.4.11.
292
+ */
293
+ overflow: clip;
294
+ overflow-clip-margin: 4px;
295
+ transition:
296
+ block-size var(--motion-fast) var(--ease),
297
+ content-visibility var(--motion-fast) allow-discrete;
298
+ }
299
+
300
+ .explorer-group[open]::details-content {
301
+ block-size: auto;
302
+ }
303
+ }
304
+
305
+ .explorer-index,
306
+ .explorer-notes a {
307
+ display: block;
308
+ padding: var(--space-1) var(--space-2);
309
+ border-radius: var(--radius-sm);
310
+ color: var(--color-link);
311
+ text-decoration: none;
312
+ }
313
+
314
+ .explorer-index {
315
+ color: var(--color-muted);
316
+ font-size: var(--text-xs);
317
+ }
318
+
319
+ .explorer-notes {
320
+ padding-bottom: var(--space-2);
321
+ }
322
+
323
+ .explorer-index:hover,
324
+ .explorer-notes a:hover,
325
+ .explorer-index:focus-visible,
326
+ .explorer-notes a:focus-visible {
327
+ background: var(--color-wash);
328
+ text-decoration: underline;
329
+ }
330
+
331
+ /*
332
+ * A windowed group's own sentence: how many of its notes the rail drew, and
333
+ * where the rest are. Same muted treatment as `.explorer-index`, because it is
334
+ * the same kind of thing — a note about the list rather than a member of it.
335
+ */
336
+ .explorer-bound {
337
+ display: flex;
338
+ flex-wrap: wrap;
339
+ gap: var(--space-2);
340
+ margin: 0;
341
+ padding: var(--space-1) var(--space-2) var(--space-2);
342
+ color: var(--color-muted);
343
+ font-size: var(--text-xs);
344
+ }
345
+
346
+ .explorer-bound a {
347
+ color: var(--color-link);
348
+ }
349
+
350
+ /*
351
+ * The page being read. Three signals, not one: a weight change, a left rule,
352
+ * and the text colour. WCAG 1.4.1 — a reader who cannot distinguish the colour
353
+ * still sees where they are, and `aria-current="page"` says it outright to a
354
+ * screen reader.
355
+ */
356
+ .explorer-notes a[aria-current='page'] {
357
+ border-left: 3px solid var(--color-accent);
358
+ background: var(--color-wash);
359
+ color: var(--color-text);
360
+ font-weight: 700;
361
+ }
362
+
363
+ .site-footer {
364
+ padding: var(--space-8) var(--gutter);
365
+ border-top: 1px solid var(--color-line);
366
+ color: var(--color-muted);
367
+ font-size: var(--text-sm);
368
+ }
369
+
370
+ .site-footer p {
371
+ max-width: var(--measure);
372
+ margin: 0;
373
+ }
374
+
375
+ /* The site map is the guarantee that no route is an orphan, so it lays out as
376
+ wrapping text links that stay operable at 320 px. */
377
+ .site-map {
378
+ display: flex;
379
+ flex-wrap: wrap;
380
+ gap: var(--space-2) var(--space-4);
381
+ margin-bottom: var(--space-4);
382
+ }
383
+
384
+ /* Shown only on paper — the canonical route belongs on a printed page, not on
385
+ a screen where the address bar already carries it. */
386
+ .print-only {
387
+ display: none;
388
+ }
389
+
390
+ /* ------------------------------------------------------------- surfaces -- */
391
+
392
+ .hero {
393
+ max-width: var(--measure);
394
+ padding-bottom: var(--space-12);
395
+ }
396
+
397
+ .eyebrow {
398
+ margin: 0;
399
+ color: var(--color-accent);
400
+ font: 700 var(--text-xs) / 1 var(--font-mono);
401
+ letter-spacing: 0.14em;
402
+ text-transform: uppercase;
403
+ }
404
+
405
+ .hero h1 {
406
+ margin: var(--space-4) 0 var(--space-4);
407
+ font-size: var(--text-3xl);
408
+ letter-spacing: -0.04em;
409
+ }
410
+
411
+ .hero > p:last-child {
412
+ color: var(--color-muted);
413
+ font-size: var(--text-lg);
414
+ }
415
+
416
+ .section-heading {
417
+ display: flex;
418
+ flex-wrap: wrap;
419
+ gap: var(--space-2);
420
+ justify-content: space-between;
421
+ align-items: baseline;
422
+ padding-bottom: var(--space-2);
423
+ border-bottom: 1px solid var(--color-line);
424
+ }
425
+
426
+ .section-heading h2 {
427
+ margin: 0;
428
+ font-size: var(--text-xl);
429
+ }
430
+
431
+ .section-heading span {
432
+ color: var(--color-muted);
433
+ font-size: var(--text-sm);
434
+ }
435
+
436
+ /* `minmax(0, …)` rather than a fixed floor: a fixed floor is what pushes a
437
+ grid past a 320 px viewport. */
438
+ .note-grid {
439
+ display: grid;
440
+ grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
441
+ gap: var(--space-4);
442
+ margin-top: var(--space-6);
443
+ }
444
+
445
+ .note-card {
446
+ display: flex;
447
+ flex-direction: column;
448
+ gap: var(--space-2);
449
+ min-width: 0;
450
+ padding: var(--space-6);
451
+ border: 1px solid var(--color-line);
452
+ border-radius: var(--radius-lg);
453
+ background: var(--color-surface);
454
+ box-shadow: var(--elevation-1);
455
+ }
456
+
457
+ .note-card h3 {
458
+ margin: 0;
459
+ font-size: var(--text-lg);
460
+ }
461
+
462
+ .note-card h3 a {
463
+ color: var(--color-text);
464
+ text-decoration: none;
465
+ }
466
+
467
+ .note-card h3 a:hover {
468
+ text-decoration: underline;
469
+ }
470
+
471
+ .note-card p {
472
+ margin: 0;
473
+ color: var(--color-muted);
474
+ font-size: var(--text-sm);
475
+ }
476
+
477
+ .read-more {
478
+ margin-top: auto;
479
+ font-size: var(--text-sm);
480
+ }
481
+
482
+ /* ------------------------------------------------- facets and empty states -- */
483
+
484
+ /*
485
+ * An empty tag or collection index is the normal state for the current
486
+ * projection, not an error, so it reads as ordinary prose rather than as a
487
+ * warning. Nothing here is signalled by colour.
488
+ */
489
+ .empty-state,
490
+ .facet-note {
491
+ max-width: var(--measure);
492
+ margin: var(--space-6) 0 0;
493
+ color: var(--color-muted);
494
+ }
495
+
496
+ .facet-list {
497
+ max-width: var(--measure);
498
+ margin: var(--space-6) 0 0;
499
+ padding: 0;
500
+ list-style: none;
501
+ }
502
+
503
+ .facet-list li {
504
+ display: flex;
505
+ flex-wrap: wrap;
506
+ gap: var(--space-2) var(--space-4);
507
+ justify-content: space-between;
508
+ align-items: baseline;
509
+ padding: var(--space-3) 0;
510
+ border-bottom: 1px solid var(--color-line);
511
+ }
512
+
513
+ /* Text, not a badge. Same treatment as the facet count, which is the same
514
+ information in the same relationship to a heading — one rule rather than two
515
+ that would drift. */
516
+ .facet-count,
517
+ .relations-count {
518
+ color: var(--color-muted);
519
+ font-size: var(--text-sm);
520
+ }
521
+
522
+ /* ---------------------------------------------------------------- prose -- */
523
+
524
+ .prose {
525
+ max-width: var(--measure);
526
+ margin-inline: auto;
527
+ min-width: 0;
528
+ }
529
+
530
+ .prose h1 {
531
+ font-size: var(--text-2xl);
532
+ letter-spacing: -0.03em;
533
+ }
534
+
535
+ .prose h2 {
536
+ margin-top: var(--space-12);
537
+ padding-bottom: var(--space-2);
538
+ border-bottom: 1px solid var(--color-line);
539
+ font-size: var(--text-xl);
540
+ }
541
+
542
+ .prose h3 {
543
+ margin-top: var(--space-8);
544
+ font-size: var(--text-lg);
545
+ }
546
+
547
+ /* In-content links keep their underline: the link is not signalled by colour
548
+ alone. Navigation and card links are distinguished by position and role. */
549
+ .prose a {
550
+ text-decoration: underline;
551
+ }
552
+
553
+ .prose pre {
554
+ max-width: 100%;
555
+ overflow-x: auto;
556
+ padding: var(--space-4);
557
+ border: 1px solid var(--color-line);
558
+ border-radius: var(--radius-md);
559
+ background: var(--color-surface-alt);
560
+ font-size: var(--text-sm);
561
+ }
562
+
563
+ .prose code {
564
+ font-family: var(--font-mono);
565
+ font-size: 0.925em;
566
+ }
567
+
568
+ .prose :not(pre) > code {
569
+ padding: 0.1em 0.35em;
570
+ border-radius: var(--radius-sm);
571
+ background: var(--color-surface-alt);
572
+ }
573
+
574
+ .prose blockquote {
575
+ margin-inline: 0;
576
+ padding-left: var(--space-4);
577
+ border-left: 3px solid var(--color-accent);
578
+ color: var(--color-muted);
579
+ }
580
+
581
+ /* --------------------------------------------------- rendered markdown -- */
582
+
583
+ /*
584
+ * Every class `src/lib/markdown.ts` can emit has a rule below, or is named in
585
+ * the "deliberately unstyled" block at the end of this section, or lives in a
586
+ * per-page stylesheet that loads only where it is needed: the `token-*` map in
587
+ * `src/styles/code.css`, the math classes in `src/styles/math.css`, and the
588
+ * diagram classes in `src/styles/diagram.css`. The allowlist at `markdown.ts`
589
+ * is the list: `heading-anchor`, `code-block`, `language-*`, `token` and
590
+ * `token-*`, `callout` and `callout-*`, `callout-title`, `contains-task-list`,
591
+ * `task-list-item`, `footnotes`, `data-footnote-backref`, `sr-only`,
592
+ * `math-inline`, `math-display`, `diagram`, `diagram-canvas`, `diagram-source`.
593
+ *
594
+ * Quartz's thirteen-kind callout vocabulary and its mask-image icon set are
595
+ * deliberately not imported: the corpus has zero callouts, and a colour-per-kind
596
+ * table is thirteen decisions for a construct nobody has written yet. One
597
+ * neutral callout treatment carries the label, which is the part that means
598
+ * something.
599
+ */
600
+
601
+ /*
602
+ * The one live accessibility bug in this section. satteri emits
603
+ * `<h2 class="sr-only" id="footnote-label">Footnotes</h2>` and, with no rule for
604
+ * it, that heading rendered as a full-size visible heading above the footnote
605
+ * list — a piece of generated scaffolding presented to the reader as content.
606
+ *
607
+ * The clip-path recipe rather than `display: none` or `visibility: hidden`,
608
+ * because those remove the element from the accessibility tree too, which is
609
+ * the opposite of what a screen-reader-only heading is for.
610
+ */
611
+ .sr-only {
612
+ position: absolute;
613
+ width: 1px;
614
+ height: 1px;
615
+ margin: -1px;
616
+ padding: 0;
617
+ border: 0;
618
+ overflow: hidden;
619
+ clip-path: inset(50%);
620
+ white-space: nowrap;
621
+ }
622
+
623
+ /*
624
+ * The heading anchor: a `#` that appears on hover or focus.
625
+ *
626
+ * Hidden until then rather than always visible, because fifteen of them down an
627
+ * article is visual noise — but `opacity`, not `display: none`, so the link
628
+ * stays in the tab order and in the accessibility tree. It carries its own
629
+ * `aria-label` from the renderer, so its `#` is decoration a screen reader never
630
+ * has to read as a heading's name.
631
+ *
632
+ * `.prose .heading-anchor`, not a bare `.heading-anchor`: `.prose a` is (0,1,1)
633
+ * and sets `text-decoration: underline` on every in-content link, so a (0,1,0)
634
+ * rule loses and the `#` ships underlined. The same trap TK-02 documented for
635
+ * `[data-js-only]` against `.site-nav button`.
636
+ */
637
+ .prose .heading-anchor {
638
+ margin-left: var(--space-2);
639
+ color: var(--color-muted);
640
+ font-weight: 400;
641
+ text-decoration: none;
642
+ opacity: 0;
643
+ transition: opacity var(--motion-fast) var(--ease);
644
+ }
645
+
646
+ :is(h1, h2, h3, h4, h5, h6):hover > .heading-anchor,
647
+ .heading-anchor:focus-visible {
648
+ opacity: 1;
649
+ }
650
+
651
+ /* Touch and keyboard have no hover, so the anchor is simply always there. */
652
+ @media (hover: none) {
653
+ .heading-anchor {
654
+ opacity: 1;
655
+ }
656
+ }
657
+
658
+ /*
659
+ * A fenced code block. `<figure>` carries a UA `margin-inline: 40px` that would
660
+ * indent every code block away from the prose, so the margin is set explicitly.
661
+ * `min-width: 0` stops the figure from being sized by its widest line — the
662
+ * classic way a code block widens the whole page — leaving `.prose pre`'s own
663
+ * `overflow-x: auto` to do the scrolling.
664
+ */
665
+ .code-block {
666
+ margin: var(--space-6) 0;
667
+ min-width: 0;
668
+ }
669
+
670
+ .code-block > pre {
671
+ margin: 0;
672
+ }
673
+
674
+ /*
675
+ * Syntax highlighting is not here. The `token-*` map lives in
676
+ * `src/styles/code.css`, which `src/pages/notes/[slug].astro` links only when
677
+ * `RenderedNote.hasCode` is true — owner decision 7, heavy assets load per page.
678
+ * `.code-block` stays in this file on purpose: a Mermaid fence renders as one
679
+ * while setting `hasMermaid` rather than `hasCode`, so gating the figure's own
680
+ * rules would leave a diagram-only page with an unstyled block.
681
+ */
682
+
683
+ /*
684
+ * A callout. One neutral treatment for every kind: the kind is announced by the
685
+ * title text the renderer promotes to `<strong>`, not by a colour a reader has
686
+ * to have learned. `data-callout` is on the element for a later ticket that
687
+ * decides whether differentiating them is worth a second visual vocabulary.
688
+ *
689
+ * The published corpus has none; TK-11's fixture has `note`, `tip`, and
690
+ * `warning`, which is what makes this rule observable at all. That does not
691
+ * change the treatment — it is what confirms the neutral one reads correctly
692
+ * when three kinds sit on one page.
693
+ *
694
+ * `.prose .callout`, not a bare `.callout`: a callout *is* a `<blockquote>`, and
695
+ * `.prose blockquote` (0,1,1) would otherwise win and paint it muted-on-muted.
696
+ */
697
+ .prose .callout {
698
+ margin: var(--space-6) 0;
699
+ padding: var(--space-4);
700
+ border-left: 3px solid var(--color-accent);
701
+ border-radius: 0 var(--radius-md) var(--radius-md) 0;
702
+ background: var(--color-surface-alt);
703
+ color: var(--color-text);
704
+ }
705
+
706
+ .callout > :first-child {
707
+ margin-top: 0;
708
+ }
709
+
710
+ .callout > :last-child {
711
+ margin-bottom: 0;
712
+ }
713
+
714
+ .callout-title {
715
+ display: block;
716
+ color: var(--color-accent);
717
+ }
718
+
719
+ /*
720
+ * Task lists. The checkboxes are `disabled` by the renderer — they are printed
721
+ * state, not controls — so the list loses its bullet and the box sits in the
722
+ * margin rather than indenting the text away from every other list.
723
+ */
724
+ .contains-task-list {
725
+ padding-left: 0;
726
+ list-style: none;
727
+ }
728
+
729
+ .task-list-item {
730
+ padding-left: 1.6em;
731
+ text-indent: -1.6em;
732
+ }
733
+
734
+ .task-list-item > input[type='checkbox'] {
735
+ width: 1em;
736
+ height: 1em;
737
+ margin-right: 0.6em;
738
+ text-indent: 0;
739
+ vertical-align: -0.1em;
740
+ }
741
+
742
+ /* Footnotes: a smaller, separated block at the end of the article. */
743
+ .footnotes {
744
+ margin-top: var(--space-12);
745
+ padding-top: var(--space-6);
746
+ border-top: 1px solid var(--color-line);
747
+ color: var(--color-muted);
748
+ font-size: var(--text-sm);
749
+ }
750
+
751
+ .footnotes li:target {
752
+ background: var(--color-wash);
753
+ }
754
+
755
+ /* The `↩` back-reference is a link, so it gets the link treatment and a target
756
+ large enough to hit — the glyph alone is well under the 24 px minimum.
757
+ Scoped to `.prose` for the same specificity reason as `.heading-anchor`. */
758
+ .prose .data-footnote-backref {
759
+ display: inline-block;
760
+ min-width: 1.5em;
761
+ text-align: center;
762
+ text-decoration: none;
763
+ }
764
+
765
+ /*
766
+ * Math and diagrams are styled by `src/styles/math.css` and
767
+ * `src/styles/diagram.css`, which load only on the pages that carry one — owner
768
+ * decision 7. `math-inline`, `math-display`, `diagram`, and the classes the
769
+ * diagram flattening pass writes are therefore deliberately absent here rather
770
+ * than missing: a rule in this sheet would ship the styling to every page and
771
+ * defeat the gating.
772
+ *
773
+ * Until TK-15 this file styled `math-inline`/`math-display` as monospace source,
774
+ * which was correct while math was a downgrade and is wrong now that it is
775
+ * MathML: the monospace family and the literal colour would override the math
776
+ * font on every expression.
777
+ */
778
+
779
+ /*
780
+ * Deliberately unstyled, with the reason:
781
+ *
782
+ * - `token` — the bare class Prism puts on every token alongside its specific
783
+ * one. A colour here would apply to *every* token, and the built `<head>`
784
+ * orders `code.css` before this sheet, so at equal specificity this rule
785
+ * would win and repaint the whole stream one colour. Inheriting the body
786
+ * colour is both the correct unhighlighted default and the safe one.
787
+ * - `language-*` — the fence language is already carried visually by the block
788
+ * itself and semantically by `data-code-language`.
789
+ * - `callout-*` — the per-kind classes. See the `.callout` note above: every
790
+ * kind gets one neutral treatment, and the kind is carried by the title text
791
+ * rather than by a colour. TK-11's fixture ships three of them, so this is
792
+ * now a deliberate choice observed at scale rather than an untested default.
793
+ *
794
+ * This block is the single record, including for the classes `code.css` styles:
795
+ * `tests/built-output.test.ts` reads it and asserts each name appears, so a
796
+ * second copy beside those rules would be an unchecked one.
797
+ */
798
+
799
+ /* ponytail: a wide table still needs a scroll container, and a wrapper element
800
+ is the Markdown pipeline's to emit. TK-03 owns that; styling it here without
801
+ the wrapper would mean `display: block`, which strips table semantics. */
802
+ .prose table {
803
+ width: 100%;
804
+ border-collapse: collapse;
805
+ }
806
+
807
+ .prose :is(th, td) {
808
+ padding: var(--space-2) var(--space-3);
809
+ /* `--color-line-strong`, not `--color-line`: a gridline is the only thing
810
+ separating one cell's data from the next, so it carries information and
811
+ needs the 3:1 non-text contrast. `--color-line` is for decorative rules
812
+ where the layout already communicates the boundary. */
813
+ border: 1px solid var(--color-line-strong);
814
+ text-align: left;
815
+ }
816
+
817
+ /* ------------------------------------------------------- note anatomy -- */
818
+
819
+ /*
820
+ * Requirements section 9.2's thirteen elements, in the order the page emits
821
+ * them. Everything here is above or below `.prose` rather than inside it: the
822
+ * article body is rendered Markdown and its rules are the section above.
823
+ *
824
+ * `--measure` on each region, not a shared wrapper, so a region can opt out
825
+ * when its content is not prose — none does yet, and the day the table of
826
+ * contents moves into a rail (TK-05c) it stops being a measure-width block
827
+ * without dragging the header and footer with it.
828
+ */
829
+
830
+ .note-header,
831
+ .note-footer {
832
+ max-width: var(--measure);
833
+ margin-inline: auto;
834
+ }
835
+
836
+ .note-header {
837
+ margin-bottom: var(--space-8);
838
+ }
839
+
840
+ /*
841
+ * The trail. `<ol>` because the order is the meaning, and the separator is
842
+ * generated content rather than markup so it is decoration a reader can never
843
+ * select or a screen reader mistake for a crumb.
844
+ *
845
+ * `content: '/' / ''` gives the generated string empty alternative text, so a
846
+ * screen reader that supports the syntax announces "Home, Notes, <title>"
847
+ * rather than interleaving "slash". In a browser that does not, the whole
848
+ * declaration is invalid and the separator simply does not render — the
849
+ * crumbs are still spaced `<li>` elements in an ordered list, which reads
850
+ * correctly. That is the safe direction for an unsupported value, and it is why
851
+ * this is worth using here and not on the table of contents' counters, where
852
+ * losing the number would cost a sighted reader the depth cue.
853
+ */
854
+ .breadcrumbs ol {
855
+ display: flex;
856
+ flex-wrap: wrap;
857
+ gap: var(--space-1) var(--space-2);
858
+ margin: 0;
859
+ padding: 0;
860
+ color: var(--color-muted);
861
+ font-size: var(--text-sm);
862
+ list-style: none;
863
+ }
864
+
865
+ .breadcrumbs li + li::before {
866
+ content: '/' / '';
867
+ margin-right: var(--space-2);
868
+ color: var(--color-line-strong);
869
+ }
870
+
871
+ .breadcrumbs [aria-current='page'] {
872
+ color: var(--color-text);
873
+ }
874
+
875
+ .note-title {
876
+ margin: var(--space-4) 0 0;
877
+ font-size: var(--text-2xl);
878
+ letter-spacing: -0.03em;
879
+ }
880
+
881
+ /*
882
+ * Public metadata: a description list, because each row genuinely is a term and
883
+ * its value. The label is text, never an icon alone, so nothing here depends on
884
+ * recognizing a glyph — and nothing is encoded by colour.
885
+ */
886
+ .note-meta {
887
+ display: flex;
888
+ flex-wrap: wrap;
889
+ gap: var(--space-2) var(--space-6);
890
+ margin: var(--space-4) 0 0;
891
+ color: var(--color-muted);
892
+ font-size: var(--text-sm);
893
+ }
894
+
895
+ .note-meta > div {
896
+ display: flex;
897
+ flex-wrap: wrap;
898
+ gap: var(--space-2);
899
+ align-items: baseline;
900
+ }
901
+
902
+ .note-meta dt {
903
+ font-weight: 600;
904
+ }
905
+
906
+ .note-meta dd {
907
+ margin: 0;
908
+ }
909
+
910
+ /* Public label lists inside metadata rows: semantic lists, wrapping, comma-free. */
911
+ .note-tags,
912
+ .note-aliases {
913
+ display: flex;
914
+ flex-wrap: wrap;
915
+ gap: var(--space-2);
916
+ margin: 0;
917
+ padding: 0;
918
+ list-style: none;
919
+ }
920
+
921
+ .note-tags a {
922
+ display: inline-block;
923
+ padding: 0 var(--space-2);
924
+ border: 1px solid var(--color-line-strong);
925
+ border-radius: var(--radius-sm);
926
+ }
927
+
928
+ /*
929
+ * The optional summary, when the artifact carries a `description` distinct from
930
+ * the excerpt. Larger than body text and not italic: an italic run of two lines
931
+ * is harder to read and says nothing the position does not already say.
932
+ */
933
+ .note-summary {
934
+ margin: var(--space-6) 0 0;
935
+ padding-left: var(--space-4);
936
+ border-left: 3px solid var(--color-accent);
937
+ color: var(--color-muted);
938
+ font-size: var(--text-lg);
939
+ }
940
+
941
+ /*
942
+ * The table of contents.
943
+ *
944
+ * `<details open>` is the collapse: native, keyboard-operable, announced as a
945
+ * disclosure, and zero JavaScript. The summary is the only interactive chrome,
946
+ * and it keeps a real focus ring because `:focus-visible` is global.
947
+ */
948
+ .toc {
949
+ max-width: var(--measure);
950
+ margin: 0 auto var(--space-8);
951
+ padding: var(--space-4) var(--space-6);
952
+ border: 1px solid var(--color-line);
953
+ border-radius: var(--radius-md);
954
+ background: var(--color-surface);
955
+ }
956
+
957
+ .toc-details > summary {
958
+ color: var(--color-text);
959
+ font-size: var(--text-sm);
960
+ font-weight: 600;
961
+ letter-spacing: 0.04em;
962
+ text-transform: uppercase;
963
+ cursor: pointer;
964
+ }
965
+
966
+ /*
967
+ * Nested ordered lists, numbered by CSS counters rather than by the browser's
968
+ * default numbering, so a nested item reads `2.1` and depth is legible from the
969
+ * number as well as from the indentation — which matters at 320 px, where three
970
+ * levels of indent would cost more width than the text can spare.
971
+ *
972
+ * `list-style: none` removes the browser's own marker, which a screen reader
973
+ * would otherwise announce alongside this one. The list is still an `<ol>`, so
974
+ * the position and the count are conveyed by the element, not by the digits:
975
+ * nothing here is information a reader gets only from the generated content.
976
+ */
977
+ .toc-list {
978
+ margin: var(--space-3) 0 0;
979
+ padding-left: var(--space-4);
980
+ counter-reset: toc-item;
981
+ list-style: none;
982
+ }
983
+
984
+ .toc-list .toc-list {
985
+ margin-top: var(--space-1);
986
+ }
987
+
988
+ .toc-list li {
989
+ margin-top: var(--space-1);
990
+ counter-increment: toc-item;
991
+ font-size: var(--text-sm);
992
+ text-indent: -1.4em;
993
+ padding-left: 1.4em;
994
+ }
995
+
996
+ .toc-list li::before {
997
+ content: counters(toc-item, '.') '. ';
998
+ color: var(--color-muted);
999
+ }
1000
+
1001
+ /*
1002
+ * `--color-link`, declared rather than inherited. Quartz's unread
1003
+ * table-of-contents links measure 2.04:1 in light and 2.92:1 in dark — both
1004
+ * WCAG AA failures in the default state, on the component it is otherwise best
1005
+ * at. Its failure mode is inheritance: the list is styled as secondary chrome,
1006
+ * so its links take the dimmest colour in the palette. Naming the token here
1007
+ * costs one declaration and is what `tests/design-tokens.test.ts` can actually
1008
+ * check; ours measure 6.72:1 and 10.37:1.
1009
+ */
1010
+ .toc-list a {
1011
+ color: var(--color-link);
1012
+ text-decoration: none;
1013
+ }
1014
+
1015
+ .toc-list a[aria-current='location'] {
1016
+ color: var(--color-text);
1017
+ font-weight: 700;
1018
+ text-decoration-thickness: 0.16em;
1019
+ }
1020
+
1021
+ .toc-list a:hover,
1022
+ .toc-list a:focus-visible {
1023
+ text-decoration: underline;
1024
+ }
1025
+
1026
+ /*
1027
+ * The footer: public provenance and the canonical route.
1028
+ *
1029
+ * `.note-canonical` is the on-screen provenance line. The layout's own
1030
+ * `.print-only` paragraph carries the same route for paper, and TK-08 upgrades
1031
+ * both to the absolute canonical URL once `site:` is configured.
1032
+ */
1033
+ .note-footer {
1034
+ margin-top: var(--space-16);
1035
+ padding-top: var(--space-6);
1036
+ border-top: 1px solid var(--color-line);
1037
+ color: var(--color-muted);
1038
+ font-size: var(--text-sm);
1039
+ }
1040
+
1041
+ .note-footer p {
1042
+ margin: 0 0 var(--space-2);
1043
+ }
1044
+
1045
+ .note-canonical {
1046
+ font-family: var(--font-mono);
1047
+ word-break: break-all;
1048
+ }
1049
+
1050
+ /* ------------------------------------------------ relationship surfaces -- */
1051
+
1052
+ /*
1053
+ * Requirements section 9.2's items 8, 9, 10, and 12: what this note links to,
1054
+ * what links to it, what it is near, and where it sits in its collection.
1055
+ *
1056
+ * The three lists are one component rendered three times and share one rule
1057
+ * set. They are visually identical on purpose: a reader distinguishes them by
1058
+ * the heading, which is text, rather than by a colour or a border treatment
1059
+ * that would encode the difference in a way section 17 forbids.
1060
+ */
1061
+
1062
+ .relations,
1063
+ .collection-pager {
1064
+ max-width: var(--measure);
1065
+ margin-inline: auto;
1066
+ }
1067
+
1068
+ .relations {
1069
+ margin-top: var(--space-12);
1070
+ padding-top: var(--space-6);
1071
+ border-top: 1px solid var(--color-line);
1072
+ }
1073
+
1074
+ /*
1075
+ * The graph region's frame, here rather than in `graph.css`.
1076
+ *
1077
+ * `graph.css` is conditional — it loads only on a page that actually draws a
1078
+ * figure (owner decision 7), which is the right trade for a stylesheet of SVG
1079
+ * styling that most pages have no use for. But the region itself renders on
1080
+ * *every* note page, carrying its heading and, where there is no neighbourhood,
1081
+ * an empty state. Leaving the frame in the conditional sheet shipped that empty
1082
+ * state unstyled: full width, no rule above it, sitting under three
1083
+ * `.relations` sections that all have one. The published corpus is a single
1084
+ * note with no edges, so that was the state on every page of the live site.
1085
+ *
1086
+ * Same rules as `.relations`, because it is the same kind of region and a
1087
+ * reader distinguishes them by their headings.
1088
+ */
1089
+ .graph-region {
1090
+ max-width: var(--measure);
1091
+ margin-inline: auto;
1092
+ margin-top: var(--space-12);
1093
+ padding-top: var(--space-6);
1094
+ border-top: 1px solid var(--color-line);
1095
+ }
1096
+
1097
+ .relations-heading {
1098
+ display: flex;
1099
+ flex-wrap: wrap;
1100
+ gap: var(--space-2) var(--space-4);
1101
+ justify-content: space-between;
1102
+ align-items: baseline;
1103
+ }
1104
+
1105
+ .relations-heading h2 {
1106
+ margin: 0;
1107
+ font-size: var(--text-lg);
1108
+ }
1109
+
1110
+ /* Text, not a badge: nothing here is carried by colour or shape alone. Styled
1111
+ with `.facet-count` in the facets section — the same information in the same
1112
+ relationship to a heading. */
1113
+
1114
+ /*
1115
+ * The derived list states its rule. Only the related-note section carries one:
1116
+ * "Links to" and "Linked from" are authored edges and need no explanation,
1117
+ * while a list nobody wrote does — otherwise a reader cannot tell a suggestion
1118
+ * from an editorial choice.
1119
+ */
1120
+ .relations-derivation {
1121
+ margin: var(--space-2) 0 0;
1122
+ color: var(--color-muted);
1123
+ font-size: var(--text-sm);
1124
+ }
1125
+
1126
+ /*
1127
+ * The large-list treatment, and the whole of it.
1128
+ *
1129
+ * `auto-fit` with a `min()` floor is one column at 320 px and as many as fit
1130
+ * beyond it, so a note with forty backlinks is a block a reader takes in rather
1131
+ * than a screen of scrolling — and it needs no truncation, no "show more"
1132
+ * control, and therefore no JavaScript. The `min(…, 100%)` floor is what keeps
1133
+ * the track from pushing the page past a narrow viewport; a fixed floor is the
1134
+ * usual cause of exactly that.
1135
+ *
1136
+ * It stays one `<ul>` in document order, so the element still carries the count
1137
+ * and the reading order: multi-column is presentation, and a screen reader
1138
+ * announces "list, 40 items" either way.
1139
+ */
1140
+ .relations-list {
1141
+ display: grid;
1142
+ grid-template-columns: repeat(auto-fit, minmax(min(16rem, 100%), 1fr));
1143
+ gap: var(--space-1) var(--space-6);
1144
+ margin: var(--space-4) 0 0;
1145
+ padding: 0;
1146
+ list-style: none;
1147
+ }
1148
+
1149
+ .relations-list li {
1150
+ min-width: 0;
1151
+ }
1152
+
1153
+ /*
1154
+ * The collection pager: previous and next within one collection.
1155
+ *
1156
+ * Two links in a row that wraps to two rows at 320 px, and `margin-left: auto`
1157
+ * on the next link so a note with only a next neighbour keeps it on the right
1158
+ * where a reader expects it, with no placeholder standing in for the absent
1159
+ * one.
1160
+ */
1161
+ .collection-pager {
1162
+ display: flex;
1163
+ flex-wrap: wrap;
1164
+ gap: var(--space-4);
1165
+ margin-top: var(--space-12);
1166
+ padding-top: var(--space-6);
1167
+ border-top: 1px solid var(--color-line);
1168
+ }
1169
+
1170
+ .collection-pager a {
1171
+ display: flex;
1172
+ flex-direction: column;
1173
+ gap: var(--space-1);
1174
+ min-width: 0;
1175
+ max-width: 100%;
1176
+ padding: var(--space-3) var(--space-4);
1177
+ border: 1px solid var(--color-line);
1178
+ border-radius: var(--radius-md);
1179
+ background: var(--color-surface);
1180
+ text-decoration: none;
1181
+ }
1182
+
1183
+ .collection-pager a:hover .pager-title,
1184
+ .collection-pager a:focus-visible .pager-title {
1185
+ text-decoration: underline;
1186
+ }
1187
+
1188
+ /*
1189
+ * `.pager-next` is the only positional class the pager needs: `margin-left:
1190
+ * auto` is what keeps a lone next link on the right, where a reader expects it,
1191
+ * with no placeholder standing in for the absent previous one. There is no
1192
+ * `.pager-previous` — the previous link is the flex row's first item already,
1193
+ * and a class carrying no declaration is a hook nobody maintains.
1194
+ */
1195
+ .pager-next {
1196
+ margin-left: auto;
1197
+ text-align: right;
1198
+ }
1199
+
1200
+ .pager-direction {
1201
+ color: var(--color-muted);
1202
+ font-size: var(--text-xs);
1203
+ letter-spacing: 0.08em;
1204
+ text-transform: uppercase;
1205
+ }
1206
+
1207
+ .pager-title {
1208
+ color: var(--color-link);
1209
+ font-size: var(--text-sm);
1210
+ }
1211
+
1212
+ /* --------------------------------------------------------- enhancements -- */
1213
+
1214
+ /*
1215
+ * The link preview.
1216
+ *
1217
+ * `left`/`top` are set by `link-preview.ts` from `placePreview`, which clamps to
1218
+ * all four viewport edges. Two of this rule's declarations are what let that
1219
+ * arithmetic hold rather than merely try:
1220
+ *
1221
+ * - The width is bounded by `calc(100% - 1rem)`, twice the 8 px margin the
1222
+ * script clamps to. A percentage on a fixed-position element resolves against
1223
+ * the initial containing block, which — unlike `100vw` — excludes a classic
1224
+ * scrollbar, so the CSS and the script measure the same box. The previous
1225
+ * `100vw` spelling was a scrollbar wider than the space the script clamps to.
1226
+ * - The excerpt is bounded to four lines, so the panel's height cannot grow with
1227
+ * the payload. Requirements section 8.1 asks for a *bounded* excerpt, and an
1228
+ * unbounded one is also the case where the vertical clamp has to give up an
1229
+ * edge. Nothing is lost: the full excerpt is on the note's own page and on its
1230
+ * card, and the preview is never the only way to reach it.
1231
+ *
1232
+ * No transition and no animation: a reduced-motion reader has nothing here to
1233
+ * opt out of.
1234
+ */
1235
+ #link-preview {
1236
+ position: fixed;
1237
+ z-index: 50;
1238
+ width: min(22rem, calc(100% - 1rem));
1239
+ padding: var(--space-4);
1240
+ border: 1px solid var(--color-line-strong);
1241
+ border-radius: var(--radius-md);
1242
+ background: var(--color-surface);
1243
+ box-shadow: var(--elevation-2);
1244
+ }
1245
+
1246
+ #link-preview p {
1247
+ display: -webkit-box;
1248
+ overflow: hidden;
1249
+ margin: var(--space-2) 0 0;
1250
+ -webkit-box-orient: vertical;
1251
+ -webkit-line-clamp: 4;
1252
+ line-clamp: 4;
1253
+ color: var(--color-muted);
1254
+ font-size: var(--text-sm);
1255
+ }
1256
+
1257
+ /* The `#heading` a link lands on, shown verbatim because that is what the
1258
+ address bar will show; it is a route fragment, so it is set in the mono face
1259
+ the rest of the site uses for literal strings. */
1260
+ .preview-fragment {
1261
+ display: block;
1262
+ margin-top: var(--space-1);
1263
+ overflow-wrap: anywhere;
1264
+ color: var(--color-muted);
1265
+ font-family: var(--font-mono);
1266
+ font-size: var(--text-xs);
1267
+ }
1268
+
1269
+ /* --------------------------------------------------------------- search -- */
1270
+
1271
+ /*
1272
+ * TK-06. The dialog's own chrome, plus the handful of Pagefind classes worth
1273
+ * restyling.
1274
+ *
1275
+ * Pagefind's `pagefind-modular-ui.css` ships alongside its bundle and is loaded
1276
+ * with it on first open, so most of the result layout is already handled. What
1277
+ * is below is only what the shipped sheet gets wrong for this site: it assumes
1278
+ * its own colours, and it has no notion of these tokens. Everything here is
1279
+ * scoped inside `#search-dialog` so none of it can reach the page.
1280
+ *
1281
+ * Nothing here is a CSP relaxation. `pagefind-highlight.js` — which creates a
1282
+ * `<style>` element and sets `innerText`, defaulting `addStyles` to true — is
1283
+ * never loaded: it is a separate opt-in bundle, and this file does not opt in.
1284
+ * The `<mark>` rule below is ours, on Pagefind's own excerpt markup.
1285
+ */
1286
+
1287
+ #search-dialog {
1288
+ width: min(44rem, calc(100% - var(--space-4)));
1289
+ padding: var(--space-4);
1290
+ border: 1px solid var(--color-line-strong);
1291
+ border-radius: var(--radius-lg);
1292
+ background: var(--color-surface);
1293
+ color: var(--color-text);
1294
+ }
1295
+
1296
+ #search-dialog::backdrop {
1297
+ background: rgb(0 0 0 / 0.55);
1298
+ }
1299
+
1300
+ .search-close {
1301
+ display: flex;
1302
+ justify-content: flex-end;
1303
+ }
1304
+
1305
+ .search-label {
1306
+ display: block;
1307
+ margin-bottom: var(--space-1);
1308
+ color: var(--color-muted);
1309
+ font-size: var(--text-sm);
1310
+ }
1311
+
1312
+ #search-input {
1313
+ width: 100%;
1314
+ padding: var(--space-2) var(--space-3);
1315
+ border: 1px solid var(--color-line-strong);
1316
+ border-radius: var(--radius-md);
1317
+ background: var(--color-bg);
1318
+ color: var(--color-text);
1319
+ font: inherit;
1320
+ /* 16px minimum, or iOS Safari zooms the page on focus and the reader lands
1321
+ on a layout they then have to pinch back out of. */
1322
+ font-size: max(1rem, var(--text-base));
1323
+ }
1324
+
1325
+ #search-filters {
1326
+ display: flex;
1327
+ flex-wrap: wrap;
1328
+ gap: var(--space-2);
1329
+ }
1330
+
1331
+ /*
1332
+ * The filter groups collapse to nothing when the index carries no values for
1333
+ * them — which is the published corpus, whose artifact has no `tags` and no
1334
+ * `collection`.
1335
+ *
1336
+ * The two child rules do the work: the script builds a pill group into a
1337
+ * container only when the index proves it has values for that filter, so an
1338
+ * unused container stays genuinely empty. The parent rule is what stops the
1339
+ * flex container contributing its own `gap` and padding around two empty
1340
+ * children, and it needs `:has()` rather than `:empty` — `#search-filters`
1341
+ * always has both child elements, so `:empty` could never match it.
1342
+ */
1343
+ #search-filters:not(:has(*:not(:empty))) {
1344
+ display: none;
1345
+ }
1346
+
1347
+ #search-filter-tag:empty,
1348
+ #search-filter-collection:empty {
1349
+ display: none;
1350
+ }
1351
+
1352
+ /*
1353
+ * The status line: one sentence, always in the same place.
1354
+ *
1355
+ * It keeps its box even when empty — `min-height` rather than collapsing —
1356
+ * because the `ready` state writes an empty string, and a line that vanishes
1357
+ * would shift every result down by its height on the first keystroke.
1358
+ */
1359
+ #search-status {
1360
+ min-height: 1.5em;
1361
+ margin: var(--space-3) 0 0;
1362
+ color: var(--color-muted);
1363
+ font-size: var(--text-sm);
1364
+ }
1365
+
1366
+ #search-results {
1367
+ max-height: min(60vh, 32rem);
1368
+ margin: 0;
1369
+ padding: 0;
1370
+ overflow-y: auto;
1371
+ list-style: none;
1372
+ }
1373
+
1374
+ #search-dialog .pagefind-modular-list-result {
1375
+ padding: var(--space-3) 0;
1376
+ border-top: 1px solid var(--color-line);
1377
+ }
1378
+
1379
+ #search-dialog .pagefind-modular-list-title {
1380
+ margin: 0;
1381
+ font-size: var(--text-base);
1382
+ }
1383
+
1384
+ #search-dialog .pagefind-modular-list-link {
1385
+ color: var(--color-link);
1386
+ font-weight: 600;
1387
+ }
1388
+
1389
+ #search-dialog .pagefind-modular-list-excerpt {
1390
+ margin: var(--space-1) 0 0;
1391
+ color: var(--color-muted);
1392
+ font-size: var(--text-sm);
1393
+ }
1394
+
1395
+ /*
1396
+ * The matched term. `--color-wash` behind ordinary text rather than a browser
1397
+ * default yellow, which fails contrast against this site's dark theme, and a
1398
+ * weight change beside it so the match is never signalled by colour alone.
1399
+ */
1400
+ #search-dialog mark {
1401
+ background: var(--color-wash);
1402
+ color: var(--color-text);
1403
+ font-weight: 600;
1404
+ }
1405
+
1406
+ /* The filter pills. Pagefind renders real `<button>` elements, so they are
1407
+ keyboard operable already; this is colour and shape only. `aria-pressed`
1408
+ carries the selected state, and the border weight shows it without colour. */
1409
+ #search-dialog .pagefind-modular-filter-pill {
1410
+ padding: var(--space-1) var(--space-3);
1411
+ border: 1px solid var(--color-line-strong);
1412
+ border-radius: var(--radius-lg);
1413
+ background: var(--color-surface-alt);
1414
+ color: var(--color-text);
1415
+ font: inherit;
1416
+ font-size: var(--text-sm);
1417
+ }
1418
+
1419
+ #search-dialog .pagefind-modular-filter-pill[aria-pressed='true'] {
1420
+ border-color: var(--color-accent);
1421
+ border-width: 2px;
1422
+ font-weight: 600;
1423
+ }
1424
+
1425
+ /* Pagefind's own hidden markers. Its stylesheet already implements these, but
1426
+ this site's rules are more specific inside the dialog, so restating them is
1427
+ what keeps a suppressed control suppressed. */
1428
+ #search-dialog [data-pfmod-hidden] {
1429
+ display: none;
1430
+ }
1431
+
1432
+ #search-dialog [data-pfmod-suppressed] {
1433
+ visibility: hidden;
1434
+ }
1435
+
1436
+ /* --------------------------------------------------------- reader mode -- */
1437
+
1438
+ /*
1439
+ * Reader mode strips the page to the reading surface: the header's route links
1440
+ * go, the header stops following the scroll, and card chrome flattens. The
1441
+ * toggles themselves stay — hiding the control that turns the mode off is how
1442
+ * a keyboard user gets stranded — and the footer site map stays too, so every
1443
+ * route this site owns is still one link away and reader mode removes chrome
1444
+ * rather than information.
1445
+ */
1446
+ :root[data-reader='on'] {
1447
+ --measure: 65ch;
1448
+ --leading-body: 1.85;
1449
+ }
1450
+
1451
+ :root[data-reader='on'] .site-nav a,
1452
+ /* The collection rail, for the same reason as the header's route links: it is
1453
+ navigation into the rest of the site rather than part of the page being
1454
+ read. No route is stranded — the footer site map survives reader mode, and
1455
+ every note is one link from the home page it lists them on. */
1456
+ :root[data-reader='on'] .explorer,
1457
+ :root[data-reader='on'] .site-footer p:not(.print-only) {
1458
+ display: none;
1459
+ }
1460
+
1461
+ /*
1462
+ * And the two-column track goes with it. `:has(> .explorer)` still matches a
1463
+ * `display: none` child, so without this reader mode would keep a 240 px column
1464
+ * reserved for a rail it just hid, and push the article off centre — the same
1465
+ * defect the `:has()` guard exists to prevent on a corpus with no rail.
1466
+ */
1467
+ :root[data-reader='on'] .page-shell {
1468
+ display: block;
1469
+ }
1470
+
1471
+ :root[data-reader='on'] .site-header {
1472
+ position: static;
1473
+ border-bottom-color: transparent;
1474
+ }
1475
+
1476
+ :root[data-reader='on'] .note-card {
1477
+ border-color: transparent;
1478
+ box-shadow: none;
1479
+ }
1480
+
1481
+ /* ------------------------------------------------------------ responsive -- */
1482
+
1483
+ /*
1484
+ * Mobile-first: the rules above are the 320 px layout — one column, a nav that
1485
+ * wraps rather than scrolls, and no fixed width anywhere.
1486
+ *
1487
+ * ponytail: the nav's five text links wrap to two or three rows at 320 px and
1488
+ * stay fully readable and operable, so no drawer or disclosure is built for
1489
+ * them; a `<details>` around five links is more machinery than the problem.
1490
+ *
1491
+ * The rail is TK-05c and reverses this block's earlier "no rail, and no empty
1492
+ * media query pretending otherwise" note, which was written when the content
1493
+ * that would fill one did not exist yet. It does now — `.page-shell` below is
1494
+ * the three layouts requirements section 16 asks for, and each is a property of
1495
+ * one grid rather than three separate treatments:
1496
+ *
1497
+ * - **mobile** (below 48rem): one column, the explorer below the article as a
1498
+ * stack of closed `<details>` — which is the "drawer" the requirement asks
1499
+ * for, built out of the disclosure the rail already is rather than out of a
1500
+ * second navigation model with its own script and focus trap;
1501
+ * - **tablet** (48rem to 64rem): still one column, but the article gains its
1502
+ * wide padding while the panels stay collapsed and collapsible;
1503
+ * - **desktop** (64rem and up): two columns, article centred on its own measure,
1504
+ * rail sticky beside it.
1505
+ *
1506
+ * 64rem rather than 48rem for the two-column switch because the rail costs
1507
+ * 15rem: at 48rem the article would be left with under 33rem, which is below
1508
+ * `--measure` and would narrow the prose to buy chrome.
1509
+ */
1510
+ @media (min-width: 48rem) {
1511
+ main {
1512
+ padding-block: var(--space-16);
1513
+ }
1514
+
1515
+ /* The header is one row once there is width for it. */
1516
+ html {
1517
+ scroll-padding-top: 5rem;
1518
+ }
1519
+
1520
+ /*
1521
+ * Tablet: the groups become panels beside each other rather than a single
1522
+ * stack, which is the "collapsible side panels" of requirements section 16.
1523
+ *
1524
+ * This band gets its own treatment rather than inheriting the mobile one
1525
+ * because it has width the mobile layout cannot use and not enough width to
1526
+ * put the rail beside the article: measured, `--measure` resolves to 623 px,
1527
+ * and a 240 px rail at 768 px would leave the prose 528 px — narrowing the
1528
+ * reading column to buy chrome, which is the wrong trade in the one band
1529
+ * where the reader is most likely reading. Laying the panels out
1530
+ * horizontally spends the extra width on the navigation instead, and costs
1531
+ * the article nothing.
1532
+ */
1533
+ .explorer-groups {
1534
+ display: grid;
1535
+ grid-template-columns: repeat(auto-fit, minmax(min(16rem, 100%), 1fr));
1536
+ gap: 0 var(--space-6);
1537
+ align-items: start;
1538
+ }
1539
+ }
1540
+
1541
+ /*
1542
+ * Desktop: the article and its rail, side by side.
1543
+ *
1544
+ * `:has(> .explorer)` is load-bearing, not a flourish. The rail renders nothing
1545
+ * on a corpus with fewer than two notes and nothing in reader mode, and an
1546
+ * unconditional two-column grid then reserves a 240 px track for an element
1547
+ * that is not there — measured on the *published* build, which is exactly that
1548
+ * corpus: the article sat 136 px left of centre at 1440 px with an empty column
1549
+ * beside it. The requirement says "centered article", so the grid engages only
1550
+ * when there is a rail to put in it, and every other case keeps the centred
1551
+ * single column it had before this ticket.
1552
+ *
1553
+ * `minmax(0, …)` on the article track rather than `1fr` alone: a `1fr` track has
1554
+ * an automatic minimum of its content's min-content width, so one long unbroken
1555
+ * token inside the article would push the whole grid wider than the viewport
1556
+ * instead of wrapping. That is the fixed-track overflow the 320 px gate exists
1557
+ * to catch, arriving at 1280 px instead.
1558
+ */
1559
+ @media (min-width: 64rem) {
1560
+ .page-shell:has(> .explorer) {
1561
+ display: grid;
1562
+ grid-template-columns: minmax(0, 1fr) var(--width-rail);
1563
+ gap: var(--gutter);
1564
+ width: min(calc(var(--width-page) + var(--width-rail) + var(--gutter)), 100%);
1565
+ margin-inline: auto;
1566
+ align-items: start;
1567
+ }
1568
+
1569
+ /* `main` is centred inside its own track rather than by the shell, so the
1570
+ article keeps the measure it has at every other width and the rail does not
1571
+ shift the prose off centre relative to itself. */
1572
+ .page-shell:has(> .explorer) > main {
1573
+ width: 100%;
1574
+ }
1575
+
1576
+ .explorer {
1577
+ /* `sticky`, not a scroll listener: the rail follows the reader down a long
1578
+ article at zero JavaScript. The offset clears the sticky header. */
1579
+ position: sticky;
1580
+ top: 5rem;
1581
+ /* The page gutter goes: it is the page's own edge padding, and inside a
1582
+ grid track the shell's `gap` already separates the rail from the article.
1583
+ Keeping it would spend roughly a quarter of `--width-rail` on whitespace
1584
+ and wrap titles that would otherwise fit.
1585
+
1586
+ Not zero, though. `overflow-y: auto` below makes this a scroll container,
1587
+ which clips on *both* axes — and the global focus ring is a 2 px outline
1588
+ at `outline-offset: 2px`, so it reaches 4 px outside the element it is
1589
+ drawn on. Measured with no padding: the ring on a summary sat exactly
1590
+ 4 px left of the rail's box and was cut flat. This is the space it needs
1591
+ to be seen (WCAG 2.4.11). */
1592
+ margin: var(--space-16) 0 0;
1593
+ padding: 0 0 0 4px;
1594
+ /* Its own scroll container, so a rail longer than the viewport scrolls
1595
+ within itself instead of stretching the grid row and stranding the
1596
+ bottom of the list where no scroll can reach it. */
1597
+ max-height: calc(100vh - 7rem);
1598
+ overflow-y: auto;
1599
+ /* Reserve the gutter the scrollbar takes, so appearing and disappearing
1600
+ does not reflow the list under the reader's pointer. */
1601
+ scrollbar-gutter: stable;
1602
+ }
1603
+
1604
+ /* Back to one stack: the tablet band's side-by-side panels are for width the
1605
+ rail does not have once it is a 15rem column. Without this the `auto-fit`
1606
+ grid above would still apply and each group would be squeezed into a
1607
+ fraction of the rail. */
1608
+ .explorer-groups {
1609
+ display: block;
1610
+ }
1611
+ }
1612
+
1613
+ @media (prefers-reduced-motion: reduce) {
1614
+ html {
1615
+ scroll-behavior: auto;
1616
+ }
1617
+
1618
+ *,
1619
+ *::before,
1620
+ *::after {
1621
+ animation-duration: 0.01ms !important;
1622
+ animation-iteration-count: 1 !important;
1623
+ transition-duration: 0.01ms !important;
1624
+ scroll-behavior: auto !important;
1625
+ }
1626
+
1627
+ /*
1628
+ * The explorer's open transition, in its own rule and **not** appended to the
1629
+ * list above.
1630
+ *
1631
+ * Two facts make that separation load-bearing. A type selector matches
1632
+ * elements and never a pseudo-element, so `*` alone does not reach
1633
+ * `::details-content` — measured, the transition still computed to 0.12s
1634
+ * under `reduce` until this rule existed. And an unrecognized pseudo-element
1635
+ * invalidates the *entire* selector list it appears in, not just its own
1636
+ * compound — also measured: `#a, #a::before, #a::unknown { … }` applies to
1637
+ * nothing at all. `::details-content` is new enough that plenty of browsers
1638
+ * do not know it, so putting it in the list above would have traded a
1639
+ * rail-only reduced-motion gap for a site-wide one on exactly those
1640
+ * browsers. Alone, it fails alone.
1641
+ */
1642
+ *::details-content {
1643
+ animation-duration: 0.01ms !important;
1644
+ transition-duration: 0.01ms !important;
1645
+ }
1646
+ }
1647
+
1648
+ /* ---------------------------------------------------------------- print -- */
1649
+
1650
+ @media print {
1651
+ /* `:root:root` is (0,2,0), which ties `:root[data-theme='dark']` in
1652
+ tokens.css and wins on order. A plain `:root` here is (0,1,0) and loses,
1653
+ which would print the dark palette — light text on white paper — for
1654
+ anyone who has chosen the dark theme. A media query adds no specificity. */
1655
+ :root:root {
1656
+ color-scheme: light;
1657
+ }
1658
+
1659
+ body {
1660
+ background: #fff;
1661
+ color: #000;
1662
+ font-size: 11pt;
1663
+ }
1664
+
1665
+ .site-header,
1666
+ .skip-link,
1667
+ .breadcrumbs,
1668
+ .toc,
1669
+ /* Site-wide navigation, like the breadcrumbs above it: a list of every other
1670
+ page on the site is the definition of chrome on paper. */
1671
+ .explorer,
1672
+ /* Navigation into the site, like the breadcrumbs above it: on paper "Next"
1673
+ points at nothing. The relationship lists stay — those are information, and
1674
+ the rule below prints each one's route. */
1675
+ .collection-pager,
1676
+ /* The layout's `.print-only` paragraph already prints this page's route.
1677
+ Printing the on-screen provenance line too would put the same path on the
1678
+ page twice, in two different wordings. TK-08 upgrades both to the absolute
1679
+ canonical URL; whichever survives, it should be one of them. */
1680
+ .note-canonical,
1681
+ #search-dialog,
1682
+ #link-preview {
1683
+ display: none !important;
1684
+ }
1685
+
1686
+ /* The two-column track goes with the rail it was reserved for. `:has(>
1687
+ .explorer)` still matches the `display: none` child above, so without this
1688
+ a landscape sheet keeps a 240 px column for something that is not printed
1689
+ — measured at 1056 px, 272 px of blank paper down the right-hand edge. The
1690
+ `:has()` selector is repeated rather than written plain because
1691
+ `.page-shell` alone loses to it on specificity. Same defect and same fix as
1692
+ the reader-mode reset above. */
1693
+ .page-shell:has(> .explorer) {
1694
+ display: block;
1695
+ }
1696
+
1697
+ main {
1698
+ width: auto;
1699
+ max-width: none;
1700
+ padding: 0;
1701
+ }
1702
+
1703
+ .prose,
1704
+ .note-header,
1705
+ .note-footer,
1706
+ .relations {
1707
+ max-width: none;
1708
+ }
1709
+
1710
+ .print-only {
1711
+ display: block;
1712
+ }
1713
+
1714
+ /* ponytail: prints each link's route, since the absolute canonical origin is
1715
+ configured by TK-08. Swap `attr(href)` for the canonical URL there. */
1716
+ .prose a[href]::after,
1717
+ .note-header a[href]::after,
1718
+ .relations a[href]::after {
1719
+ content: ' (' attr(href) ')';
1720
+ font-size: 0.85em;
1721
+ word-break: break-all;
1722
+ }
1723
+
1724
+ .prose a[href^='#']::after {
1725
+ content: '';
1726
+ }
1727
+
1728
+ .prose pre {
1729
+ white-space: pre-wrap;
1730
+ }
1731
+
1732
+ :is(h1, h2, h3) {
1733
+ break-after: avoid;
1734
+ }
1735
+
1736
+ .prose :is(pre, blockquote, table) {
1737
+ break-inside: avoid;
1738
+ }
1739
+ }