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 +1 -1
- package/CHANGELOG.md +23 -0
- package/README.md +5 -3
- package/css-is-awesome.instructions.md +12 -4
- package/llm.txt +2 -2
- package/package.json +1 -1
- package/scss/_mixins.scss +57 -2
- package/scss/recipes/print-spec.md +390 -0
- package/scss/recipes/print-to-pdf.md +1 -0
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
|
-
> **
|
|
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.
|
|
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`, `
|
|
481
|
-
`bottom-nav`. Queued next: `datepicker`, `data-table`,
|
|
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 —
|
|
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.
|
|
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(
|
|
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 {
|
|
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 & 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
|