css-is-awesome 1.9.4 → 1.10.0

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.
package/AGENTS.md CHANGED
@@ -298,7 +298,7 @@ Inside this package (all whitelisted in `files`):
298
298
  - **Don't hand-edit `public/themes/**/theme.css` or `public/theme.css`.** They are build artifacts of `scss/themes/*.scss`. Edit the SCSS, run `npm run build:css:themes`, and `npm run check:theme-drift` to prove source and artifact agree.
299
299
  - **Don't hand-write a theme's selector.** `@include cia.theme(name)` emits `:root, :root[data-theme="name"]` — both halves, on purpose. Writing `[data-theme=x]` yourself breaks the single-file drop-in; writing a bare `:root` yourself breaks the bundle.
300
300
  - **Theme the numbered spacing scale, not the t-shirt aliases.** `--space-0`…`--space-9` are contract-required; `--space-md` and friends are optional `var()` aliases that follow them.
301
- - **Print/PDF is a pure-CSS layer.** Include `cia.print-base` once at the stylesheet ROOT (it emits `@page`), then `cia.print-hidden` to drop chrome and `cia.print-only` to reveal paper-only content. Read `--is-print` (`0` screen / `1` paper) for custom effects. cia ships **zero JS** for it — the browser's native Print → Save as PDF is the generator.
301
+ - **Print/PDF is a pure-CSS layer.** Include `cia.print-base` once at the stylesheet ROOT (it emits `@page`), then `cia.print-hidden` to drop chrome and `cia.print-only` to reveal paper-only content. Read `--is-print` (`0` screen / `1` paper) for custom effects. cia ships **zero JS** for it — the browser's native Print → Save as PDF is the generator. `print-base` always forces `color-scheme: light` in print (paired `light-dark()` themes print their light branch), plus four opt-in flags, all default OFF: `$legible` (darken body-text tokens so dark-only themes stay readable on white), `$link-urls` + `$link-origin` (print each link's full destination via `attr(href)`), `$page-numbers` (sheet numbers in the `@page` footer). Paginated spec-document output is the `print-spec` recipe.
302
302
  - **Do NOT "clean up" the `!important` in the print mixins.** It is load-bearing and deliberate. `@media` contributes no specificity, so `print-hidden` carries only the specificity of the selector it is included in; a later equal-specificity `display` (usually a utility class or a component library) wins in print. Verified in a browser: with `!important` the element hides, without it it prints anyway — a silent, paper-only failure. **`@layer` does not fix this** — layered CSS always loses to unlayered CSS, so a layered print rule loses to any unlayered consumer stylesheet, and `!important` inverts layer order on top of that. cia is unlayered by decision (`.agent/decisions/decided/04-at-layer-decision.md`). Scope is 8 declarations, all inside `@media print`, all variable-driven via `--print-hide` / `--print-show`.
303
303
  - **Don't ship JavaScript.** The npm package has zero `.js`/`.mjs` files. JS-dependent features ship as separate add-on packages.
304
304
  - **Variants are arguments, not classes.** `cia.btn(primary)`, not `cia-btn cia-btn-primary` (Tier 1 utilities are an exception, but only at consumer level).
package/CHANGELOG.md CHANGED
@@ -1,3 +1,26 @@
1
+ # [1.10.0](https://github.com/Jerry2d3d/css-is-awesome/compare/v1.9.5...v1.10.0) (2026-09-06)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **a11y:** TOC summary is not a nested interactive control ([5c65f8e](https://github.com/Jerry2d3d/css-is-awesome/commit/5c65f8e8d9c3318cfe89c9e623fec5a9994127ef))
7
+ * **site:** TOC group carets sit at the right end of the summary ([6186a20](https://github.com/Jerry2d3d/css-is-awesome/commit/6186a20de6158541f2a9743f83b48b112789bf95))
8
+
9
+
10
+ ### Features
11
+
12
+ * **print:** legible dark themes, full URLs, and sheet numbers via print-base flags ([213c8bd](https://github.com/Jerry2d3d/css-is-awesome/commit/213c8bdf76454b3ac0c0cfcc13bae7c5224f37aa)), closes [#anchors](https://github.com/Jerry2d3d/css-is-awesome/issues/anchors)
13
+ * **site:** docs TOC collapses into groups - open one, the others close ([0c2ab84](https://github.com/Jerry2d3d/css-is-awesome/commit/0c2ab84e02e5e49ef11c204c67107c047135bf41))
14
+
15
+ ## [1.9.5](https://github.com/Jerry2d3d/css-is-awesome/compare/v1.9.4...v1.9.5) (2026-09-06)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * **site:** docs print index harvests on beforeprint - was showing stale headings ([21a066b](https://github.com/Jerry2d3d/css-is-awesome/commit/21a066bdaa7b488f012333e5e07fa6fd34c57086))
21
+ * **site:** docs screen TOC re-harvests per page - was frozen on page one ([e746ab4](https://github.com/Jerry2d3d/css-is-awesome/commit/e746ab47072b407c627e9f375819cad9c3d7abab))
22
+ * **site:** print docs left nav hidden - the On this page bullets are the index ([8fb6b19](https://github.com/Jerry2d3d/css-is-awesome/commit/8fb6b19e656382d7d2c708ba8ef62466d03f4983))
23
+
1
24
  ## [1.9.4](https://github.com/Jerry2d3d/css-is-awesome/compare/v1.9.3...v1.9.4) (2026-09-06)
2
25
 
3
26
 
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  **Docs:** [cssisawesome.com](https://cssisawesome.com/) · **Install:** `npm install css-is-awesome`
10
10
 
11
- > **Shipped in 1.0.0:** a **recipes book** for building any component in any framework using cia mixins — five recipes today (`dialog`, `combobox`, `print-to-pdf`, `mobile-nav`, `bottom-nav`), with `datepicker`, `data-table` and `command-palette` queued. AI agents read recipes via MCP and generate components in your stack; humans read them at [`/docs/recipes`](https://cssisawesome.com/docs/recipes/).
11
+ > **The recipes book:** build any component in any framework using cia mixins — six recipes today (`dialog`, `combobox`, `print-to-pdf`, `print-spec`, `mobile-nav`, `bottom-nav`), with `datepicker`, `data-table` and `command-palette` queued. AI agents read recipes via MCP and generate components in your stack; humans read them at [`/docs/recipes`](https://cssisawesome.com/docs/recipes/).
12
12
 
13
13
  ## For AI agents — start here
14
14
 
@@ -241,14 +241,16 @@ Print support is a pure-CSS layer — the browser's native **Print → Save as P
241
241
 
242
242
  // Once, in a GLOBAL stylesheet — never inside a component module.
243
243
  // It emits its own :root block plus @page, so don't wrap it in a selector.
244
- @include cia.print-base; // optional: ($size, $margin, $freeze-animations)
244
+ @include cia.print-base; // optional flags: ($size, $margin, $freeze-animations, $legible, $link-urls, $link-origin, $page-numbers)
245
245
 
246
246
  .site-nav { @include cia.print-hidden; } // drop chrome on paper
247
247
  .print-note { @include cia.print-only; } // reveal paper-only content
248
248
  .invoice { @include cia.print { border: 1px solid; } } // bare @media print wrapper
249
249
  ```
250
250
 
251
- `print-base` also collapses animations to zero duration and pins them to their final frame, so a page snapshotted mid-entrance-fade doesn't print as invisible text. It deliberately does **not** force `opacity: 1` or `transform: none` — that would fix the fade while flattening every intentional use of the same properties (a 0.15 watermark, a 0.4 disabled control, a stamp rotated `-4deg`). Elements that were never animating are left untouched. Read `--is-print` (`0` on screen, `1` on paper) for custom effects. Full walkthrough: the [`print-to-pdf`](./scss/recipes/print-to-pdf.md) recipe.
251
+ `print-base` also collapses animations to zero duration and pins them to their final frame, so a page snapshotted mid-entrance-fade doesn't print as invisible text. It deliberately does **not** force `opacity: 1` or `transform: none` — that would fix the fade while flattening every intentional use of the same properties (a 0.15 watermark, a 0.4 disabled control, a stamp rotated `-4deg`). Elements that were never animating are left untouched. Read `--is-print` (`0` on screen, `1` on paper) for custom effects.
252
+
253
+ Inside `@media print`, `print-base` always forces `color-scheme: light`, so paired `light-dark()` themes print their light branch for free. Four opt-in flags (all default off) take it further: `$legible` darkens the body-text tokens so dark-only themes (Terminal) stay readable as ink on white; `$link-urls` prints every link's destination via `attr(href)`; `$link-origin` prepends an origin so internal `/…` links resolve to full URLs on paper; `$page-numbers` numbers the sheets in the `@page` footer. The docs site and the theme editor at `/themes` turn those flags on to print themselves as paginated spec documents. Full walkthroughs: the [`print-to-pdf`](./scss/recipes/print-to-pdf.md) and [`print-spec`](./scss/recipes/print-spec.md) recipes.
252
254
 
253
255
  ### Why the print mixins use `!important`
254
256
 
@@ -171,11 +171,18 @@ browser's native Print → Save as PDF is the generator; cia just supplies the
171
171
 
172
172
  - **`print`** — bare `@media print { @content }` wrapper. Co-locate it inside
173
173
  a selector to override that element on paper.
174
- - **`print-base($freeze-animations: true, $size: letter, $margin: 0.5in)`** —
174
+ - **`print-base($freeze-animations: true, $size: letter, $margin: 0.5in, $legible: false, $link-urls: false, $link-origin: null, $page-numbers: false)`** —
175
175
  page-level defaults, ON by default. Include it **once at the stylesheet
176
176
  ROOT** — it emits `@page` (invalid when nested in a selector), freezes
177
177
  animations so nothing prints invisible, and emits the print variable
178
- control plane.
178
+ control plane. In `@media print` it always forces `color-scheme: light`, so
179
+ paired `light-dark()` themes print their light branch for free. Four opt-in
180
+ flags (all default OFF): `$legible` darkens the body-text tokens (`--ink`,
181
+ `--muted`…) so dark-only themes (Terminal) stay readable on white — code
182
+ blocks and accents untouched; `$link-urls` prints each link's destination via
183
+ `attr(href)`; `$link-origin` prepends an origin to internal `/…` links so they
184
+ resolve to full URLs; `$page-numbers` puts `counter(page)` in the `@page`
185
+ bottom-center margin box.
179
186
  - **`print-hidden`** — hide an element on paper (the "hide the nav" case).
180
187
  - **`print-only`** — show an element only on paper (e.g. an inline URL
181
188
  footer); hidden on screen.
@@ -477,8 +484,9 @@ A recipe is a markdown file at `scss/recipes/<name>.md` carrying:
477
484
  - an a11y checklist graded against WCAG 2.2 AA
478
485
  - framework-neutral notes so it ports to React / Vue / Svelte / vanilla
479
486
 
480
- **Shipped today:** `dialog`, `combobox`, `print-to-pdf`, `mobile-nav`,
481
- `bottom-nav`. Queued next: `datepicker`, `data-table`, `command-palette`.
487
+ **Shipped today:** `dialog`, `combobox`, `print-to-pdf`, `print-spec`,
488
+ `mobile-nav`, `bottom-nav`. Queued next: `datepicker`, `data-table`,
489
+ `command-palette`.
482
490
 
483
491
  The two mobile recipes ride the `hamburger` / `drawer` / `sheet` / `dock`
484
492
  mixin family — zero JS on the native Popover API. `mobile-nav` is the flex
package/llm.txt CHANGED
@@ -45,7 +45,7 @@ silent.
45
45
 
46
46
  ## What cia provides (one-line each)
47
47
 
48
- - **Mixins** — `cia.btn`, `cia.card`, `cia.accordion`, `cia.modal`, `cia.tooltip`, `cia.dropdown`, `cia.tabs`, `cia.copy-button`, `cia.hamburger`, `cia.drawer`, `cia.sheet`, `cia.dock`, `cia.stepper`, `cia.progress`, `cia.wizard-shell`, `cia.sidebar`, `cia.toolbar`, `cia.stack`, `cia.cluster`, `cia.switcher`, `cia.cover`, `cia.frame`, `cia.media`, `cia.contain`, `cia.focus-ring`, `cia.sr-only`, `cia.print`, `cia.print-base`, `cia.print-hidden`, `cia.print-only`, `cia.color`, `cia.space`, `cia.radius`, `cia.shadow`, `cia.font`, `cia.transition`, `cia.animate`, plus many more.
48
+ - **Mixins** — `cia.btn`, `cia.card`, `cia.accordion`, `cia.modal`, `cia.tooltip`, `cia.dropdown`, `cia.tabs`, `cia.copy-button`, `cia.hamburger`, `cia.drawer`, `cia.sheet`, `cia.dock`, `cia.stepper`, `cia.progress`, `cia.wizard-shell`, `cia.sidebar`, `cia.toolbar`, `cia.stack`, `cia.cluster`, `cia.switcher`, `cia.cover`, `cia.frame`, `cia.media`, `cia.contain`, `cia.focus-ring`, `cia.sr-only`, `cia.print`, `cia.print-base`, `cia.print-hidden`, `cia.print-only`, `cia.color`, `cia.space`, `cia.radius`, `cia.shadow`, `cia.font`, `cia.transition`, `cia.animate`, plus many more. **`cia.print-base` always forces `color-scheme: light` in print (paired `light-dark()` themes print their light branch) plus four opt-in flags, all default OFF:** `$legible` (darken body-text tokens so dark-only themes stay readable on white; code/accents untouched), `$link-urls` + `$link-origin` (print each link's full destination via `attr(href)`), `$page-numbers` (sheet numbers in the `@page` footer).
49
49
  - **24 themes / 8 families** — boilerplate, sketchbook, press, prism, cupertino, glass, graphite, terminal. Each family ships three files: an unsuffixed dual-mode base (both modes via `light-dark()`) plus pinned `-light` and `-dark` single-mode variants for `<link media>` pairing. Every one has exactly one SCSS source under `scss/themes/`; `public/themes/<name>/theme.css` is BUILD OUTPUT and is gated against its source by `npm run check:theme-drift` — never hand-edit it. MCP `list_themes` returns **24**. Say **24 themes across 8 families** when you need one number.
50
50
  - **6 zero-JS interactive components** — accordion (`<details name>`), modal (`<dialog>`), tooltip (`popover="hint"`), dropdown (`[popover]` — the mixin re-asserts the UA's closed state and restores `display: flex` only under `:popover-open`, so menus never render permanently open on popover markup), tabs (radio + `:has()`), copy-button (Clipboard API via consumer-wired JS).
51
51
  - **CLI** (`npx cia`) — `migrate tailwind|bootstrap` (config → cia theme), `add <recipe>` (copy a recipe from the book into the project), `analyze [path]` (audit stylesheets against the installed API: dead `cia.*` symbols, the `space()` 1–9 trap, hard-coded hex, BEM; health score, CI exit codes).
@@ -170,7 +170,7 @@ cia ships a **recipes book**: portable patterns for building accessible componen
170
170
 
171
171
  AI agents read recipes via MCP `list_recipes` / `get_recipe(name)` and generate consumer components in any framework. Humans read them at `/docs/recipes` and copy patterns directly.
172
172
 
173
- **Shipped today — 5 recipes: dialog, combobox, print-to-pdf, mobile-nav, bottom-nav.** Queued next: datepicker, data-table, command-palette. **No component library** — recipes are the deliverable. See `scss/recipes/README.md` for the schema.
173
+ **Shipped today — 6 recipes: dialog, combobox, print-to-pdf, print-spec, mobile-nav, bottom-nav.** Queued next: datepicker, data-table, command-palette. **No component library** — recipes are the deliverable. See `scss/recipes/README.md` for the schema.
174
174
 
175
175
  ## Priority ladder (the v1.0 pitch order)
176
176
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "css-is-awesome",
3
- "version": "1.9.4",
3
+ "version": "1.10.0",
4
4
  "description": "A token-driven SCSS design system with light/dark theming, semantic color tokens, and a 800+ LOC mixin API.",
5
5
  "homepage": "https://github.com/Jerry2d3d/css-is-awesome#readme",
6
6
  "bugs": {
package/scss/_mixins.scss CHANGED
@@ -598,7 +598,15 @@ $_type-scale: (
598
598
  // Override either locally for a per-element exception — no rule rewrite:
599
599
  // .keep-this { --print-hide: revert; } // stays on paper despite print-hidden
600
600
  // .pdf-row { --print-show: flex; } // print-only block laid out as flex
601
- @mixin print-base($freeze-animations: true, $size: letter, $margin: 0.5in) {
601
+ @mixin print-base(
602
+ $freeze-animations: true,
603
+ $size: letter,
604
+ $margin: 0.5in,
605
+ $legible: false,
606
+ $link-urls: false,
607
+ $link-origin: null,
608
+ $page-numbers: false
609
+ ) {
602
610
  :root {
603
611
  --is-print: 0;
604
612
  --print-hide: none;
@@ -606,10 +614,57 @@ $_type-scale: (
606
614
  }
607
615
 
608
616
  @media print {
609
- :root { --is-print: 1; }
617
+ :root {
618
+ --is-print: 1;
619
+
620
+ // Paired themes build color as light-dark(): forcing the light scheme
621
+ // makes them print their light branch — light text becomes dark text
622
+ // on the (already white) sheet, for free. Dark-only themes use literal
623
+ // values and ignore this; $legible below covers them.
624
+ color-scheme: light;
625
+
626
+ @if $legible {
627
+ // Dark-only themes (e.g. Terminal) have literal LIGHT ink — unreadable
628
+ // once the background drops to white in print. Darken just the body
629
+ // text tokens; --code-* stay untouched so code blocks keep their ink,
630
+ // and accents are left alone so links/headings keep the theme's voice.
631
+ --ink: CanvasText;
632
+ --ink-soft: color-mix(in sRGB, CanvasText 78%, Canvas);
633
+ --ink-faint: color-mix(in sRGB, CanvasText 60%, Canvas);
634
+ --muted: color-mix(in sRGB, CanvasText 70%, Canvas);
635
+ }
636
+ }
610
637
 
611
638
  @page { size: $size; margin: $margin; }
612
639
 
640
+ @if $page-numbers {
641
+ // Sheet numbers in the bottom-center margin box. Progressive: engines
642
+ // without margin-box support simply print unnumbered.
643
+ @page { @bottom-center { content: counter(page); font-size: 9pt; } }
644
+ }
645
+
646
+ @if $link-urls {
647
+ // Print each link's destination after it, so a paper copy is followable.
648
+ // External links print their href; internal links prepend $link-origin
649
+ // to become full URLs; same-page anchors are skipped (the URL adds
650
+ // nothing there).
651
+ a[href^="http"]::after {
652
+ content: " (" attr(href) ")";
653
+ font-size: 0.85em;
654
+ color: color-mix(in sRGB, CanvasText 60%, Canvas);
655
+ word-break: break-all;
656
+ }
657
+ @if $link-origin {
658
+ a[href^="/"]::after {
659
+ content: " (#{$link-origin}" attr(href) ")";
660
+ font-size: 0.85em;
661
+ color: color-mix(in sRGB, CanvasText 60%, Canvas);
662
+ word-break: break-all;
663
+ }
664
+ }
665
+ a[href^="#"]::after { content: none; }
666
+ }
667
+
613
668
  @if $freeze-animations {
614
669
  // The opacity:0 entrance-fade trap: a page snapshotted mid-animation
615
670
  // prints invisible text.
@@ -0,0 +1,390 @@
1
+ ---
2
+ name: print-spec
3
+ description: Turn a live page into a paginated specification document on Ctrl+P — cover + table index, one page per section, printed sheet numbers — with pure-CSS page breaks.
4
+ category: layout
5
+ complexity: medium
6
+ cia-version: ">=1.9.0"
7
+ ---
8
+
9
+ ## Use this when
10
+
11
+ You want `Ctrl/Cmd+P` (or Save-as-PDF) to emit a **spec document** from a page you already have — a cover page with a numbered table index, then every major section starting on its own sheet, with a page number printed in the margin of each. The cia site itself does this: the theme editor at `/themes` prints as a "Theme Specification," and the docs render a print-only "On this page" index. This is the *paginate-it-as-a-spec* recipe. If you only need a page to save as a faithful PDF — a résumé, an invoice, a receipt where the page **is** the document and pagination is incidental — use [`print-to-pdf`](./print-to-pdf.md) instead; it is simpler and this recipe builds on it. Reach for this one when the printed artifact has a *structure of its own* (index → sections → numbered pages) that the screen page does not show.
12
+
13
+ ## Structure (raw HTML)
14
+
15
+ Three ingredients: **screen chrome** that must vanish on paper, a **cover/index** that appears only on paper, and the **sections** — each one a page. Put `data-cia-recipe` on the root; mark the index and each section with `data-slot` so tooling and your styles can find them.
16
+
17
+ ```html
18
+ <main data-cia-recipe="print-spec">
19
+ <!-- 1. Screen-only chrome — hidden on paper. -->
20
+ <nav class="site-nav no-print">…</nav>
21
+ <aside class="doc-sidebar no-print">…</aside>
22
+
23
+ <!-- 2. Cover + table index — PRINT ONLY (hidden on screen).
24
+ The page numbers are hard-coded in the markup; they are
25
+ honest because each section forces its own page below,
26
+ so section k always lands on page k+1. -->
27
+ <nav class="print-index" data-slot="index" aria-label="Table index">
28
+ <h2>Table index</h2>
29
+ <ol>
30
+ <li><span>Cover &amp; index</span><span class="print-leader" aria-hidden="true"></span><span class="print-page">1</span></li>
31
+ <li><span>Overview</span><span class="print-leader" aria-hidden="true"></span><span class="print-page">2</span></li>
32
+ <li><span>Tokens</span><span class="print-leader" aria-hidden="true"></span><span class="print-page">3</span></li>
33
+ <li><span>Components</span><span class="print-leader" aria-hidden="true"></span><span class="print-page">4</span></li>
34
+ </ol>
35
+ </nav>
36
+
37
+ <!-- 3. Each section is one printed page. -->
38
+ <section class="section" data-slot="section" id="overview">
39
+ <h2>Overview</h2>
40
+ <p>…</p>
41
+ </section>
42
+ <section class="section" data-slot="section" id="tokens">
43
+ <h2>Tokens</h2>
44
+ <p>…</p>
45
+ </section>
46
+ <section class="section" data-slot="section" id="components">
47
+ <h2>Components</h2>
48
+ <p>…</p>
49
+ </section>
50
+ </main>
51
+ ```
52
+
53
+ Notes on the markup:
54
+
55
+ - **No button is required.** Every print path — `Ctrl/Cmd+P`, File → Print, the dialog's built-in "Save as PDF" — triggers the same `@media print` layer. A `<button onclick="window.print()">` is a discoverability signpost only (see [`print-to-pdf`](./print-to-pdf.md) for that one line).
56
+ - The index is a real `<nav aria-label="Table index">`, not a decorative block — it is genuine navigation for the printed artifact.
57
+ - The `print-leader` span is the dotted line between a label and its page number; it is `aria-hidden` because it carries no meaning for a screen reader.
58
+
59
+ ## Styling (cia mixins)
60
+
61
+ Four moves, all pure CSS: `print-base` once at the root, `print-only` on the index, `print-hidden` on the chrome, and `break-before: page` on each section. A `@page` margin box adds the printed sheet number.
62
+
63
+ **1. In your GLOBAL stylesheet** — `print-base` emits its own `:root` block plus `@page`, so it must sit at the top level of a global/root stylesheet, never inside a `.module.scss` (a top-level `:root` is a hard build error under Next.js CSS Modules pure mode). This is also where the sheet-number `@page` box lives, because it extends the same `@page` box `print-base` opened.
64
+
65
+ ```scss
66
+ // app/globals.scss (or your single root stylesheet) — included ONCE.
67
+ @use 'css-is-awesome/api' as cia;
68
+
69
+ // The control plane, the @page box, the animation freeze — plus the print
70
+ // polish flags. Each defaults OFF except the two structural ones, so nothing
71
+ // changes for existing callers until you opt in:
72
+ // $legible — dark-only themes (literal light ink) print dark body text.
73
+ // $link-urls — every link prints its destination, so paper is followable.
74
+ // $link-origin — prepended to internal (/…) hrefs so they print as full URLs.
75
+ // $page-numbers — sheet numbers in the bottom-center margin box.
76
+ // print-base also forces `color-scheme: light` in print for free, so PAIRED
77
+ // themes (light-dark() tokens) land on their light branch automatically.
78
+ @include cia.print-base(
79
+ $legible: true,
80
+ $link-urls: true,
81
+ $link-origin: 'https://example.com',
82
+ $page-numbers: true
83
+ );
84
+
85
+ // Hide site chrome on paper.
86
+ .site-nav,
87
+ .doc-sidebar,
88
+ .no-print {
89
+ @include cia.print-hidden;
90
+ }
91
+ ```
92
+
93
+ `$legible` darkens only the body text tokens (`--ink`, `--ink-soft`,
94
+ `--ink-faint`, `--muted`) — code blocks keep their own `--code-*` ink, and
95
+ accents are left alone so links and headings keep the theme's voice.
96
+ `$link-urls` prints external hrefs directly and internal ones prefixed with
97
+ `$link-origin`; same-page `#anchor` links are skipped (the URL adds nothing).
98
+
99
+ **2. In the page's COMPONENT stylesheet** — the index show/hide and the per-section page break emit nothing until called, so they are safe in a `.module.scss`.
100
+
101
+ ```scss
102
+ // PrintSpec.module.scss — component stylesheet, so import the zero-emit barrel.
103
+ @use 'css-is-awesome/api' as cia;
104
+
105
+ // The cover/index is paper-only: hidden on screen, revealed in print.
106
+ .print-index {
107
+ @include cia.print-only;
108
+ }
109
+ .print-index li {
110
+ display: flex;
111
+ align-items: baseline;
112
+ gap: cia.space(2);
113
+ }
114
+ .print-leader {
115
+ flex: 1;
116
+ border-block-end: 1px dotted cia.color(text-muted);
117
+ }
118
+ .print-page {
119
+ font-family: var(--font-mono, monospace);
120
+ }
121
+
122
+ // THE KEY MOVE: each section starts a new sheet. This is what makes the
123
+ // index's hard-coded numbers a guarantee rather than a hope — section k
124
+ // is deterministically page k+1.
125
+ .section {
126
+ @include cia.print { break-before: page; }
127
+ }
128
+ ```
129
+
130
+ Class names are consumer-chosen (`.print-index`, `.section`) — never `cia-*`. For the full account of *why* the print mixins compile to `!important` and why `@layer` cannot replace them, see [`print-to-pdf`](./print-to-pdf.md); the same variable-driven control plane (`--is-print` / `--print-hide` / `--print-show`) powers this recipe.
131
+
132
+ **Honest numbering.** Hard-coded page numbers in the index are only truthful while every section forces its own break. Drop the `break-before: page` and section 3 might reflow onto page 2 — the index now *lies*. If you cannot guarantee one-page-per-section (long, flowing prose that legitimately spans pages), do not print hard numbers; use the dynamic harvested index below with bullets instead, which is exactly why the docs site prints bullets, not numbers, for article prose.
133
+
134
+ ## Interactivity
135
+
136
+ **The pagination is pure CSS — zero JavaScript.** `print-only`, `print-hidden`, `break-before: page`, and the `@page` margin box are the entire engine. A **static** spec with a hard-coded table index needs no script at all.
137
+
138
+ The **only** optional JS is a *dynamic* "On this page" index that harvests the current page's own `<h2>` headings so the printed index matches whatever page is being printed. There are two non-obvious requirements, and getting either wrong ships a stale index:
139
+
140
+ - **Re-read on navigation.** In an SPA a shared layout persists across client-side route changes, so a harvest that runs once at mount goes stale the moment the user navigates. Key the harvest to the current route.
141
+ - **Re-read on `beforeprint`.** The window fires `beforeprint` immediately before the print dialog snapshots the page. Harvesting there guarantees the printed list reflects the DOM *as printed*, even if the route-keyed read missed a late render. This is the belt-and-braces guarantee.
142
+
143
+ Harvest `article h2[id]`, store `{ id, text }`, render a `print-only` `<nav>`. Return nothing when there are no headings. The screen never sees it; only paper does.
144
+
145
+ ## A11y checklist
146
+
147
+ - [ ] The printed index is a real `<nav>` with an `aria-label`, so it is an addressable landmark, not a decorative block ([WAI-ARIA APG: Landmark regions](https://www.w3.org/WAI/ARIA/apg/practices/landmark-regions/))
148
+ - [ ] Section headings stay real `<h2>` elements (the harvest reads them, it does not replace them), preserving the document outline ([WCAG 2.2 SC 2.4.6 Headings and Labels](https://www.w3.org/WAI/WCAG22/Understanding/headings-and-labels.html))
149
+ - [ ] Print output follows DOM/reading order — the cover/index precedes the sections in source, so it prints first ([WCAG 2.2 SC 1.3.2 Meaningful Sequence](https://www.w3.org/WAI/WCAG22/Understanding/meaningful-sequence.html))
150
+ - [ ] Hiding chrome with `print-hidden` removes only navigation and controls, never content the spec needs to make sense ([WCAG 2.2 SC 1.3.1 Info and Relationships](https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html))
151
+ - [ ] Ink-on-paper contrast meets the minimum — the `color-scheme: light` flip keeps dark text on a light sheet ([WCAG 2.2 SC 1.4.3 Contrast (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html))
152
+ - [ ] Purely visual leader dots between a label and its page number are `aria-hidden`, so a screen reader hears "Overview 2", not a run of dots ([WCAG 2.2 SC 1.3.1 Info and Relationships](https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html))
153
+
154
+ ## Framework examples
155
+
156
+ Every example is the **optional** dynamic "On this page" index — the only framework-specific code in this recipe. The pagination itself (section breaks, show/hide, sheet numbers) is pure CSS and identical across stacks. Each harvests `article h2[id]`, re-reads on navigation **and** on `beforeprint`, and renders a `print-only` `<nav>`. The static hard-coded index needs none of this.
157
+
158
+ ### React
159
+
160
+ ```tsx
161
+ "use client";
162
+ import { useEffect, useState } from "react";
163
+ import { usePathname } from "next/navigation";
164
+ import styles from "./PrintSpecIndex.module.scss";
165
+
166
+ // Mirrors the cia docs site's DocsPrintIndex.
167
+ export default function PrintSpecIndex() {
168
+ const pathname = usePathname();
169
+ const [headings, setHeadings] = useState<{ id: string; text: string }[]>([]);
170
+
171
+ useEffect(() => {
172
+ const harvest = () => {
173
+ const article = document.querySelector<HTMLElement>("article");
174
+ if (!article) return;
175
+ const nodes = Array.from(article.querySelectorAll<HTMLHeadingElement>("h2[id]"));
176
+ setHeadings(nodes.map((n) => ({ id: n.id, text: n.textContent ?? "" })));
177
+ };
178
+ // Harvest on navigation (pathname dep) AND at the moment of printing.
179
+ // The shared layout persists across client navigations, so a
180
+ // pathname-only read can go stale; beforeprint reads the CURRENT DOM.
181
+ harvest();
182
+ window.addEventListener("beforeprint", harvest);
183
+ return () => window.removeEventListener("beforeprint", harvest);
184
+ }, [pathname]);
185
+
186
+ if (headings.length === 0) return null;
187
+
188
+ return (
189
+ <nav className={styles.printIndex} aria-label="On this page">
190
+ <h2>On this page</h2>
191
+ <ul>
192
+ {headings.map((h) => (
193
+ <li key={h.id}>{h.text}</li>
194
+ ))}
195
+ </ul>
196
+ </nav>
197
+ );
198
+ }
199
+ ```
200
+
201
+ ### Angular
202
+
203
+ ```ts
204
+ import { Component, ElementRef, HostListener, OnDestroy, OnInit } from "@angular/core";
205
+ import { NavigationEnd, Router } from "@angular/router";
206
+ import { Subscription, filter } from "rxjs";
207
+
208
+ @Component({
209
+ selector: "print-spec-index",
210
+ standalone: true,
211
+ template: `
212
+ <nav class="print-index" aria-label="On this page" *ngIf="headings.length">
213
+ <h2>On this page</h2>
214
+ <ul>
215
+ <li *ngFor="let h of headings">{{ h.text }}</li>
216
+ </ul>
217
+ </nav>
218
+ `,
219
+ })
220
+ export class PrintSpecIndexComponent implements OnInit, OnDestroy {
221
+ headings: { id: string; text: string }[] = [];
222
+ private sub?: Subscription;
223
+
224
+ constructor(private router: Router, private host: ElementRef<HTMLElement>) {}
225
+
226
+ ngOnInit(): void {
227
+ this.harvest();
228
+ // Re-read on every completed navigation — the layout persists.
229
+ this.sub = this.router.events
230
+ .pipe(filter((e) => e instanceof NavigationEnd))
231
+ .subscribe(() => this.harvest());
232
+ }
233
+
234
+ // The guarantee: re-read the CURRENT DOM right before the print dialog.
235
+ @HostListener("window:beforeprint")
236
+ harvest(): void {
237
+ const article = this.host.nativeElement.ownerDocument.querySelector("article");
238
+ if (!article) return;
239
+ this.headings = Array.from(article.querySelectorAll<HTMLHeadingElement>("h2[id]")).map(
240
+ (n) => ({ id: n.id, text: n.textContent ?? "" }),
241
+ );
242
+ }
243
+
244
+ ngOnDestroy(): void {
245
+ this.sub?.unsubscribe();
246
+ }
247
+ }
248
+ ```
249
+
250
+ ### Vue
251
+
252
+ ```vue
253
+ <script setup>
254
+ import { ref, onMounted, onUnmounted, watch } from "vue";
255
+ import { useRoute } from "vue-router";
256
+
257
+ const route = useRoute();
258
+ const headings = ref([]);
259
+
260
+ const harvest = () => {
261
+ const article = document.querySelector("article");
262
+ if (!article) return;
263
+ headings.value = Array.from(article.querySelectorAll("h2[id]")).map((n) => ({
264
+ id: n.id,
265
+ text: n.textContent ?? "",
266
+ }));
267
+ };
268
+
269
+ watch(() => route.fullPath, harvest); // re-read on navigation
270
+ onMounted(() => {
271
+ harvest();
272
+ window.addEventListener("beforeprint", harvest); // re-read before print
273
+ });
274
+ onUnmounted(() => window.removeEventListener("beforeprint", harvest));
275
+ </script>
276
+
277
+ <template>
278
+ <nav v-if="headings.length" class="print-index" aria-label="On this page">
279
+ <h2>On this page</h2>
280
+ <ul>
281
+ <li v-for="h in headings" :key="h.id">{{ h.text }}</li>
282
+ </ul>
283
+ </nav>
284
+ </template>
285
+ ```
286
+
287
+ ### Svelte
288
+
289
+ ```svelte
290
+ <script>
291
+ import { onMount } from "svelte";
292
+ import { page } from "$app/stores"; // SvelteKit route store
293
+
294
+ let headings = [];
295
+ const harvest = () => {
296
+ const article = document.querySelector("article");
297
+ if (!article) return;
298
+ headings = Array.from(article.querySelectorAll("h2[id]")).map((n) => ({
299
+ id: n.id,
300
+ text: n.textContent ?? "",
301
+ }));
302
+ };
303
+
304
+ $: $page.url.pathname, harvest(); // re-read on navigation
305
+
306
+ onMount(() => {
307
+ harvest();
308
+ window.addEventListener("beforeprint", harvest); // re-read before print
309
+ return () => window.removeEventListener("beforeprint", harvest);
310
+ });
311
+ </script>
312
+
313
+ {#if headings.length}
314
+ <nav class="print-index" aria-label="On this page">
315
+ <h2>On this page</h2>
316
+ <ul>
317
+ {#each headings as h (h.id)}
318
+ <li>{h.text}</li>
319
+ {/each}
320
+ </ul>
321
+ </nav>
322
+ {/if}
323
+ ```
324
+
325
+ ### Vanilla (Web Component)
326
+
327
+ ```js
328
+ // No router to hook: in a multi-page site each load re-runs the harvest,
329
+ // so `beforeprint` is the only listener needed. In an SPA, also call
330
+ // harvest() from your router's after-navigation hook.
331
+ class PrintSpecIndex extends HTMLElement {
332
+ connectedCallback() {
333
+ this.harvest();
334
+ this._onBeforePrint = () => this.harvest();
335
+ window.addEventListener("beforeprint", this._onBeforePrint);
336
+ }
337
+ disconnectedCallback() {
338
+ window.removeEventListener("beforeprint", this._onBeforePrint);
339
+ }
340
+ harvest() {
341
+ const article = document.querySelector("article");
342
+ const items = article
343
+ ? Array.from(article.querySelectorAll("h2[id]")).map((n) => `<li>${n.textContent}</li>`)
344
+ : [];
345
+ this.innerHTML = items.length
346
+ ? `<nav class="print-index" aria-label="On this page"><h2>On this page</h2><ul>${items.join("")}</ul></nav>`
347
+ : "";
348
+ }
349
+ }
350
+ customElements.define("print-spec-index", PrintSpecIndex);
351
+
352
+ // Usage: <print-spec-index></print-spec-index>
353
+ // The STATIC hard-coded index needs zero JS — just the markup + CSS above.
354
+ ```
355
+
356
+ ## Variants
357
+
358
+ ### Static, hard-coded table index (zero JS)
359
+
360
+ Skip every framework example. Write the numbers into the markup (the Structure block above) and rely on `break-before: page` to make them true. Deterministic, no runtime, nothing to hydrate. Choose this whenever your sections are guaranteed one page each.
361
+
362
+ ### International paper (A4) / landscape
363
+
364
+ Pass the size through `print-base` at the root, and set orientation on `@page`:
365
+
366
+ ```scss
367
+ // app/globals.scss — top level, not inside a selector, not in a component module
368
+ @include cia.print-base($size: A4, $margin: 0.75in);
369
+
370
+ @include cia.print {
371
+ @page { size: A4 landscape; }
372
+ }
373
+ ```
374
+
375
+ ### Bullets instead of numbers (flowing prose)
376
+
377
+ When a section legitimately spans several pages, drop the page-number column and print the harvested index as plain bullets — an honest "here's what's in this document" without a number that would be a guess. This is what the cia docs do for article prose.
378
+
379
+ ## Pitfalls
380
+
381
+ - **`print-base` must be root/global.** It emits a `:root` block plus `@page`; inside a `.module.scss` that is a hard build error under Next.js CSS Modules pure mode, and wrapping it in a selector double-nests the `:root` it already writes. It belongs at the top level of a single global stylesheet, included once.
382
+ - **`@page` margin boxes are not universal.** `@bottom-center { content: counter(page) }` is unsupported in some engines, which simply print **unnumbered** sheets — no error, no fallback. Keep the table index's own numbers in the markup so navigation survives; treat the margin-box number as progressive enhancement.
383
+ - **Hard-coded index numbers lie without forced breaks.** The numbers are only honest while every section forces its own page. Remove a `break-before: page` and the index silently points at the wrong pages — a bug that only shows up in print preview. Either guarantee one-page-per-section or switch to the bullets variant.
384
+ - **Forcing `break-before` on flowing prose wastes paper.** A `break-before: page` on every heading of a long article strands half-empty sheets. The docs site deliberately prints *bullets, not numbers* for prose precisely so it does not have to force a break per heading. Reserve forced breaks for genuinely page-sized sections (a component demo, a spec table), not every paragraph.
385
+ - **A stale index from a run-once harvest.** In an SPA the layout persists across navigation, so a mount-only read prints the previous page's headings. Re-read on the route change **and** on `beforeprint` — the `beforeprint` read is the one that guarantees the paper matches the page.
386
+ - **Dark themes print white-on-white** unless the `color-scheme: light` flip is in place (it lands every `light-dark()` token on its light value). See [`print-to-pdf`](./print-to-pdf.md) Pitfalls for the element-level escape hatch.
387
+
388
+ ## Related recipes
389
+
390
+ - [`print-to-pdf`](./print-to-pdf.md) — the foundation: save a styled page as a faithful PDF with a `@media print` stylesheet. This recipe adds pagination, a cover/index, and sheet numbers on top of it.
@@ -260,4 +260,5 @@ These are the bugs that *will* bite — each one cost real debugging time. This
260
260
 
261
261
  ## Related recipes
262
262
 
263
+ - [`print-spec`](./print-spec.md) — the paginated-spec companion (cover + table index, one page per section, printed sheet numbers). It builds on this recipe and leans on `print-base`'s opt-in flags — `$legible` (readable dark themes on paper), `$link-urls` / `$link-origin` (full followable URLs), `$page-numbers` (sheet numbers) — which automate the manual URL-and-page-break handling shown above.
263
264
  - [`dialog`](./dialog.md) — a print-only summary often lives inside a confirmation dialog before export