css-is-awesome 1.10.3 → 1.11.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.
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. `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.
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), defines a themeable print palette (`--print-ink/-paper/-line/-muted`, default ink-on-white) and rebinds the theme's colour tokens (`--ink`, `--surface-*`, `--border-*`, `--code-*`) onto it, so every theme — dark-only included prints legible ink-on-paper; override a `--print-*` token to restyle paper (a letterhead), in a theme's own `@media print` or a `<link media="print">` sheet. Three opt-in flags, all default OFF: `$link-urls` + `$link-origin` (print each link's full destination via `attr(href)`), `$page-numbers` (sheet numbers in the `@page` footer). `$legible` is a deprecated no-op — the rebind supersedes it. 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).
@@ -342,8 +342,10 @@ The SDK is an optional peer dep — `npm install -D @modelcontextprotocol/sdk zo
342
342
  dumps a cia theme; `npx cia add <recipe>` (`--list` to browse) copies a recipe
343
343
  from the book into the project — own the pattern; `npx cia analyze [path]`
344
344
  audits a project's stylesheets against the installed API — dead `cia.*` symbols,
345
- the `space()` 1–9 scale trap, hard-coded hex colors, BEM chains — with a health
346
- score and CI-ready exit codes. Run any verb with `--help`. (`cia init` remains
345
+ the `space()` 1–9 scale trap, off-contract tokens (near-miss typos only), hard-coded
346
+ hex colors, BEM chains — with a health score and CI-ready exit codes. Low-noise on
347
+ color: a hex in a `var(--token, #hex)` fallback is token-driven, and a literal inside
348
+ `@media print` is an intentional paper colour — neither is flagged. Run any verb with `--help`. (`cia init` remains
347
349
  planned.)
348
350
  - **JSON token export** — DTCG-format token list in `figma-tokens/`.
349
351
  - **`llm.txt`** — at the repo root and served from the docs site; single-fetch
package/CHANGELOG.md CHANGED
@@ -1,3 +1,22 @@
1
+ ## [1.11.1](https://github.com/Jerry2d3d/css-is-awesome/compare/v1.11.0...v1.11.1) (2026-09-09)
2
+
3
+
4
+ ### Features
5
+
6
+ * **site:** second print example — a letter with letterhead + footer ([4891391](https://github.com/Jerry2d3d/css-is-awesome/commit/4891391bf29aaa0489473318e7ad0face04a772e))
7
+
8
+ # [1.11.0](https://github.com/Jerry2d3d/css-is-awesome/compare/v1.10.3...v1.11.0) (2026-09-09)
9
+
10
+
11
+ ### Features
12
+
13
+ * **cli:** analyzer off-contract-token rule + print/var-aware color analysis ([6cd1eeb](https://github.com/Jerry2d3d/css-is-awesome/commit/6cd1eeb5feb082cc9c1bf5330d9c540b4cb332d7)), closes [hi#signal](https://github.com/hi/issues/signal) [#hex](https://github.com/Jerry2d3d/css-is-awesome/issues/hex)
14
+ * **cli:** graded, categorized analyzer report (EPIC-06 F3.1) ([ee56cdb](https://github.com/Jerry2d3d/css-is-awesome/commit/ee56cdbd17695f115226c4faeee6382a6041e7d5))
15
+ * **print:** letterhead recipe + Press newsprint print identity ([5963b0c](https://github.com/Jerry2d3d/css-is-awesome/commit/5963b0cce30eb1ce26d59230423477e41990747f))
16
+ * **print:** themeable print palette + token rebind; deprecate $legible ([bf4c657](https://github.com/Jerry2d3d/css-is-awesome/commit/bf4c65783ec22d03eed2b9836928151597828124))
17
+ * **site:** ACME letterhead invoice demo for the print theme ([5d5ed81](https://github.com/Jerry2d3d/css-is-awesome/commit/5d5ed81bf6f9c8490f2173f9d79b37f8f15fd7df))
18
+ * **site:** theme-editor print mode — paper-preview modal (EPIC-08 F5) ([1cfc83e](https://github.com/Jerry2d3d/css-is-awesome/commit/1cfc83e6c1a68eb39b144669802061710337c99c))
19
+
1
20
  ## [1.10.3](https://github.com/Jerry2d3d/css-is-awesome/compare/v1.10.2...v1.10.3) (2026-09-07)
2
21
 
3
22
 
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
- > **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/).
11
+ > **The recipes book:** build any component in any framework using cia mixins — seven recipes today (`dialog`, `combobox`, `print-to-pdf`, `print-spec`, `letterhead`, `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
 
@@ -202,7 +202,7 @@ Missing font files don't error, so a silent tofu box is the failure mode. If you
202
202
 
203
203
  cia ships **no component library** — deliberately. Interactive patterns arrive as *recipes*: portable markdown files at [`scss/recipes/`](./scss/recipes/) that give you the correct HTML, the `cia.X` mixin calls to style it, and an a11y checklist graded against WCAG 2.2 AA. Copy the pattern into your own framework; you own the component, cia owns the styling and the accessibility homework.
204
204
 
205
- **Shipped:** `dialog`, `combobox`, `print-to-pdf`, `print-spec`, `mobile-nav`, `bottom-nav`. Queued next: `datepicker`, `data-table`, `command-palette`.
205
+ **Shipped:** `dialog`, `combobox`, `print-to-pdf`, `print-spec`, `letterhead`, `mobile-nav`, `bottom-nav`. Queued next: `datepicker`, `data-table`, `command-palette`.
206
206
 
207
207
  **Layout doctrine: Grid is the skeleton, Flex is the quick moves.** The page shell is CSS Grid with landmark-named areas (`nav / main / footer` — the map reads like the page); any control-dense region inside gets its own named-area grid whose `gap` carries all vertical rhythm; flex lives at the leaves for one-command flips (`cia.flex($direction: column)`). `cia.page-layout()` and `cia.layout()` own the maps — mobile is a different area map, never margin overrides.
208
208
 
@@ -227,10 +227,11 @@ The CLI also carries the registry and the health check:
227
227
  npx cia add --list # browse the recipe book
228
228
  npx cia add bottom-nav # copy a recipe into your project — you own the pattern
229
229
  npx cia analyze src/styles # design-system health: dead cia.* symbols, the
230
- # space() scale trap, hard-coded colors, BEM creep
230
+ # space() scale trap, off-contract tokens (typos),
231
+ # hard-coded colors, BEM creep
231
232
  ```
232
233
 
233
- `cia analyze` reads the real API surface from the installed package and exits non-zero on errors, so it slots straight into CI.
234
+ `cia analyze` reads the real API surface from the installed package and exits non-zero on errors, so it slots straight into CI. It's deliberately low-noise about color: a hex used as a `var(--token, #hex)` fallback is token-driven (not flagged), and a literal inside `@media print` is an intentional paper colour (print escapes theme colours by design). Off-contract-token findings only fire on a **near-miss** of a real token — a typo like `--inkk` — never on your own custom tokens. The default output is a **graded report** — a health score, a section per concern (Contract / Spacing / Color / Naming / Layout / API) with a `✓` when clean, and a suggested fix on each finding; add `--verbose` for the flat per-file list or `--json` for the machine shape.
234
235
 
235
236
  ## Print / PDF (zero JS)
236
237
 
@@ -241,7 +242,7 @@ Print support is a pure-CSS layer — the browser's native **Print → Save as P
241
242
 
242
243
  // Once, in a GLOBAL stylesheet — never inside a component module.
243
244
  // It emits its own :root block plus @page, so don't wrap it in a selector.
244
- @include cia.print-base; // optional flags: ($size, $margin, $freeze-animations, $legible, $link-urls, $link-origin, $page-numbers)
245
+ @include cia.print-base; // optional flags: ($size, $margin, $freeze-animations, $link-urls, $link-origin, $page-numbers)
245
246
 
246
247
  .site-nav { @include cia.print-hidden; } // drop chrome on paper
247
248
  .print-note { @include cia.print-only; } // reveal paper-only content
@@ -250,7 +251,30 @@ Print support is a pure-CSS layer — the browser's native **Print → Save as P
250
251
 
251
252
  `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
 
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.
254
+ Inside `@media print`, `print-base` always forces `color-scheme: light`, so paired `light-dark()` themes print their light branch for free. It also defines a **print palette** — `--print-ink`, `--print-paper`, `--print-line`, `--print-muted` (default ink-on-white with light grays) and rebinds the theme's own colour tokens (`--ink`, `--surface-*`, `--border-*`, `--code-*`) onto it, so every theme prints as clean ink-on-paper and dark-only themes (Terminal) are legible with no flag. Three opt-in flags (all default off) take it further: `$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. (`$legible` is now a **deprecated no-op** — the token rebind supersedes it; passing it warns.) The docs site and the theme editor at `/themes` turn those flags on to print themselves as paginated spec documents.
255
+
256
+ ### A print theme
257
+
258
+ Those four `--print-*` tokens are ordinary custom properties, so print is themeable like any other surface. The default every theme inherits is clean black-on-white with light grays; a theme (or a single project) opts into its own paper identity only if it wants one — override the tokens and every element re-inks, no component touched:
259
+
260
+ ```scss
261
+ // A letterhead: navy ink, silver rules. Put this in a theme's own @media print
262
+ // block, or ship it as a print-only stylesheet loaded with <link media="print">.
263
+ @media print {
264
+ :root {
265
+ --print-ink: #16233f; // deep navy
266
+ --print-muted: #5b6b86;
267
+ --print-line: #c7ccd6; // silver hairlines
268
+ // --print-paper stays #fff
269
+ }
270
+ }
271
+ ```
272
+
273
+ Loading it as a paired print sheet is the same mechanism as paired light/dark themes:
274
+
275
+ ```html
276
+ <link rel="stylesheet" href="/themes/letterhead/print.css" media="print">
277
+ ```
254
278
 
255
279
  **See it work.** Two live demonstrations back the walkthroughs. The [`print-spec`](./scss/recipes/print-spec.md) recipe paginates a page into a spec document (cover + table index, one page per section, honest sheet numbers). And [`/examples/print-to-pdf`](https://cssisawesome.com/examples/print-to-pdf/) is a real invoice you `Ctrl+P`: on paper the site chrome and the button vanish, the invoice fills the whole sheet, its links print as full followable URLs (`$link-urls` + `$link-origin`), a sheet number appears, and a **print-only QR code** — generated at build time as inline SVG, zero browser JS, no image fetch — links the paper back to the live [invoice-online](https://cssisawesome.com/examples/print-to-pdf/online) page. Full walkthroughs: the [`print-to-pdf`](./scss/recipes/print-to-pdf.md) and [`print-spec`](./scss/recipes/print-spec.md) recipes.
256
280
 
package/bin/analyze.cjs CHANGED
@@ -19,6 +19,32 @@ const fs = require('fs');
19
19
  const path = require('path');
20
20
 
21
21
  const PKG_SCSS = path.join(__dirname, '..', 'scss');
22
+ const CONTRACT_PATH = path.join(__dirname, '..', 'scripts', 'theme-contract.json');
23
+
24
+ // All contract token names (required + optional). Used to catch typo'd tokens —
25
+ // a var(--x) that's a near-miss of a real token silently breaks the style.
26
+ // The contract is optional; if it can't be read the rule simply goes quiet.
27
+ function loadContractTokens() {
28
+ try {
29
+ const c = JSON.parse(fs.readFileSync(CONTRACT_PATH, 'utf8'));
30
+ return new Set([...(c.required || []), ...(c.optional || [])]);
31
+ } catch {
32
+ return new Set();
33
+ }
34
+ }
35
+
36
+ // Tiny Levenshtein (zero-dep). Only called on var(--x) misses, so cost is trivial.
37
+ function editDistance(a, b) {
38
+ const m = a.length, n = b.length;
39
+ const d = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)]);
40
+ for (let j = 0; j <= n; j++) d[0][j] = j;
41
+ for (let i = 1; i <= m; i++) {
42
+ for (let j = 1; j <= n; j++) {
43
+ d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
44
+ }
45
+ }
46
+ return d[m][n];
47
+ }
22
48
 
23
49
  const HELP = `cia analyze — design-system health check
24
50
 
@@ -33,6 +59,7 @@ Options:
33
59
  Auto-detected per file from @use lines; use this when
34
60
  imports are aliased through an intermediate file.
35
61
  --json Machine-readable report on stdout.
62
+ --verbose The per-file listing instead of the graded report.
36
63
  --strict Exit 1 on warnings too (default: errors only).
37
64
 
38
65
  Examples:
@@ -106,7 +133,47 @@ function stripComments(src) {
106
133
  return src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
107
134
  }
108
135
 
109
- function analyzeFile(file, symbols, extraNs) {
136
+ // A hex inside a `var(--token, #hex)` fallback is token-driven, not a hard-coded
137
+ // color — that's the correct pattern (token first, literal only if unset).
138
+ // Strip var() expressions innermost-first so nested fallbacks
139
+ // (`var(--a, var(--b, #fff))`) collapse too, leaving only genuine literals.
140
+ function stripVarExpr(src) {
141
+ let prev;
142
+ let s = src;
143
+ do {
144
+ prev = s;
145
+ s = s.replace(/var\([^()]*\)/g, '');
146
+ } while (s !== prev);
147
+ return s;
148
+ }
149
+
150
+ // Literals inside an @media print block are intentional: print deliberately
151
+ // escapes theme colours (ink-on-white for legibility, grays for rules) so a
152
+ // hard-coded #000/#fff there is correct, not a smell. Remove print @media
153
+ // blocks — brace-balanced, so nested selectors are handled — before the
154
+ // hard-coded-color scan. Non-print @media queries are kept and still scanned.
155
+ function stripPrintBlocks(src) {
156
+ let out = '';
157
+ let i = 0;
158
+ while (i < src.length) {
159
+ const at = src.indexOf('@media', i);
160
+ if (at === -1) { out += src.slice(i); break; }
161
+ const braceOpen = src.indexOf('{', at);
162
+ if (braceOpen === -1) { out += src.slice(i); break; }
163
+ out += src.slice(i, at);
164
+ let depth = 0;
165
+ let j = braceOpen;
166
+ for (; j < src.length; j++) {
167
+ if (src[j] === '{') depth++;
168
+ else if (src[j] === '}' && --depth === 0) { j++; break; }
169
+ }
170
+ if (!/\bprint\b/.test(src.slice(at + 6, braceOpen))) out += src.slice(at, j);
171
+ i = j;
172
+ }
173
+ return out;
174
+ }
175
+
176
+ function analyzeFile(file, symbols, contractTokens, extraNs) {
110
177
  const raw = fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n');
111
178
  const src = stripComments(raw);
112
179
  const ns = detectNamespaces(src, extraNs);
@@ -130,7 +197,29 @@ function analyzeFile(file, symbols, extraNs) {
130
197
  }
131
198
  }
132
199
  }
133
- for (const m of src.matchAll(/#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/g)) {
200
+ // off-contract-token: a var(--x) that's a near-miss of a real contract token
201
+ // (a typo → the declaration silently fails). Pure custom tokens — not close to
202
+ // any contract token — are the consumer's own and are left alone.
203
+ if (contractTokens && contractTokens.size) {
204
+ const seen = new Set();
205
+ for (const m of src.matchAll(/var\(\s*(--[\w-]+)/g)) {
206
+ const tok = m[1];
207
+ if (contractTokens.has(tok) || seen.has(tok)) continue;
208
+ seen.add(tok);
209
+ let best = null;
210
+ let bestD = Infinity;
211
+ for (const known of contractTokens) {
212
+ const d = editDistance(tok, known);
213
+ if (d < bestD) { bestD = d; best = known; }
214
+ }
215
+ if (best && bestD > 0 && bestD <= 2) {
216
+ findings.push({ level: 'warn', rule: 'off-contract-token', detail: `${tok} — not a contract token; did you mean ${best}?` });
217
+ }
218
+ }
219
+ }
220
+ // Genuine literal colors only — a hex used as a var() fallback is token-driven,
221
+ // and a hex inside @media print is an intentional paper colour.
222
+ for (const m of stripVarExpr(stripPrintBlocks(src)).matchAll(/#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/g)) {
134
223
  findings.push({ level: 'warn', rule: 'hard-coded-color', detail: `${m[0]} — values should come from tokens (cia.color(...) / var(--...))` });
135
224
  }
136
225
  for (const m of src.matchAll(/\.[a-zA-Z][\w]*(?:__|--)[\w-]+/g)) {
@@ -144,6 +233,31 @@ function analyzeFile(file, symbols, extraNs) {
144
233
 
145
234
  // ── report ──────────────────────────────────────────────────────────────────
146
235
 
236
+ // Every rule belongs to a category, so the default report reads as a graded
237
+ // health check (a section per concern) rather than a flat dump. Categories with
238
+ // no findings print a ✓.
239
+ const RULE_CATEGORY = {
240
+ 'unknown-symbol': 'API',
241
+ 'space-scale': 'Spacing',
242
+ 'off-contract-token': 'Contract',
243
+ 'hard-coded-color': 'Color',
244
+ bem: 'Naming',
245
+ 'hand-written-areas': 'Layout',
246
+ };
247
+ // The categories with rules implemented today — shown ✓ when clean so a passing
248
+ // audit reads as coverage, not silence.
249
+ const IMPLEMENTED_CATEGORIES = ['API', 'Contract', 'Spacing', 'Color', 'Naming', 'Layout'];
250
+
251
+ // Details are authored as "claim — suggested fix"; split so the graded report
252
+ // can put the fix on its own `→` line.
253
+ function splitDetail(detail) {
254
+ const i = detail.indexOf(' — ');
255
+ if (i === -1) return { claim: detail, suggestion: '' };
256
+ return { claim: detail.slice(0, i), suggestion: detail.slice(i + 3) };
257
+ }
258
+
259
+ const LEVEL_MARK = { error: '✗', warn: '⚠', info: 'ℹ' };
260
+
147
261
  async function run(args) {
148
262
  if (args[0] === '-h' || args[0] === '--help' || args[0] === 'help') {
149
263
  process.stdout.write(HELP);
@@ -151,6 +265,7 @@ async function run(args) {
151
265
  }
152
266
  const json = args.includes('--json');
153
267
  const strict = args.includes('--strict');
268
+ const verbose = args.includes('--verbose');
154
269
  const nsFlag = args.indexOf('--namespace');
155
270
  const extraNs = nsFlag !== -1 && args[nsFlag + 1] ? args[nsFlag + 1].split(',') : [];
156
271
  const target = path.resolve(args.find((a) => !a.startsWith('--') && a !== extraNs.join(',')) || '.');
@@ -161,28 +276,72 @@ async function run(args) {
161
276
  }
162
277
 
163
278
  const symbols = collectSymbols();
279
+ const contractTokens = loadContractTokens();
164
280
  const files = walkScss(target);
165
- const results = files.map((f) => analyzeFile(f, symbols, extraNs)).filter((r) => r.findings.length || r.namespaces.length);
281
+ const results = files.map((f) => analyzeFile(f, symbols, contractTokens, extraNs)).filter((r) => r.findings.length || r.namespaces.length);
166
282
 
167
283
  const counts = { error: 0, warn: 0, info: 0 };
168
284
  for (const r of results) for (const f of r.findings) counts[f.level]++;
169
285
  const ciaFiles = results.filter((r) => r.namespaces.length).length;
170
286
  const health = Math.max(0, 100 - counts.error * 10 - counts.warn * 2 - counts.info);
171
287
 
288
+ // Enrich every finding with its category and a split-out suggestion — additive
289
+ // fields, so `--json` consumers of counts/health/results keep working.
290
+ for (const r of results) {
291
+ for (const f of r.findings) {
292
+ f.category = RULE_CATEGORY[f.rule] || 'Other';
293
+ const { suggestion } = splitDetail(f.detail);
294
+ if (suggestion) f.suggestion = suggestion;
295
+ }
296
+ }
297
+
298
+ const rel = path.relative(process.cwd(), target) || '.';
299
+ const out = (s) => process.stdout.write(s);
300
+
172
301
  if (json) {
173
- process.stdout.write(JSON.stringify({ target, files: files.length, ciaFiles, apiSymbols: symbols.size, counts, health, results }, null, 2) + '\n');
174
- } else {
175
- process.stdout.write(`\ncia analyze${path.relative(process.cwd(), target) || '.'}\n`);
176
- process.stdout.write(`${files.length} scss file(s), ${ciaFiles} using cia, ${symbols.size} API symbols known\n\n`);
302
+ out(JSON.stringify({ target, files: files.length, ciaFiles, apiSymbols: symbols.size, counts, health, results }, null, 2) + '\n');
303
+ } else if (verbose) {
304
+ // The per-file listing every finding under its file, unabridged.
305
+ out(`\ncia analyze ${rel}\n`);
306
+ out(`${files.length} scss file(s), ${ciaFiles} using cia, ${symbols.size} API symbols known\n\n`);
177
307
  for (const r of results) {
178
308
  if (!r.findings.length) continue;
179
- process.stdout.write(`${path.relative(process.cwd(), r.file)}\n`);
309
+ out(`${path.relative(process.cwd(), r.file)}\n`);
180
310
  for (const f of r.findings) {
181
- const mark = f.level === 'error' ? '✗' : f.level === 'warn' ? '⚠' : 'ℹ';
182
- process.stdout.write(` ${mark} [${f.rule}] ${f.detail}\n`);
311
+ out(` ${LEVEL_MARK[f.level]} [${f.rule}] ${f.detail}\n`);
183
312
  }
184
313
  }
185
- process.stdout.write(`\nDesign-system health: ${health}% (${counts.error} error, ${counts.warn} warn, ${counts.info} info)\n`);
314
+ out(`\nDesign-system health: ${health}% (${counts.error} error, ${counts.warn} warn, ${counts.info} info)\n`);
315
+ } else {
316
+ // The graded report — a section per category, ✓ when clean, each finding
317
+ // with its file and a suggested fix on its own line.
318
+ const all = [];
319
+ for (const r of results) {
320
+ for (const f of r.findings) all.push({ ...f, file: path.relative(process.cwd(), r.file) });
321
+ }
322
+ const extraCats = [...new Set(all.map((f) => f.category))].filter((c) => !IMPLEMENTED_CATEGORIES.includes(c));
323
+ const cats = [...IMPLEMENTED_CATEGORIES, ...extraCats];
324
+
325
+ out(`\ncia analyze — ${rel}\n`);
326
+ out(`${files.length} scss file(s) · ${ciaFiles} using cia · ${symbols.size} API symbols\n\n`);
327
+ out(`Design-system health: ${health}/100\n\n`);
328
+
329
+ for (const cat of cats) {
330
+ const items = all.filter((f) => f.category === cat);
331
+ if (!items.length) {
332
+ out(` ${cat.padEnd(9)} ✓\n`);
333
+ continue;
334
+ }
335
+ const worst = items.some((i) => i.level === 'error') ? 'error' : items.some((i) => i.level === 'warn') ? 'warn' : 'info';
336
+ out(` ${cat.padEnd(9)} ${LEVEL_MARK[worst]} ${items.length}\n`);
337
+ for (const it of items) {
338
+ out(` ${LEVEL_MARK[it.level]} ${it.file} ${it.suggestion ? it.claim ?? splitDetail(it.detail).claim : it.detail}\n`);
339
+ if (it.suggestion) out(` → ${it.suggestion}\n`);
340
+ }
341
+ }
342
+
343
+ const tally = `${counts.error} error · ${counts.warn} warn · ${counts.info} info`;
344
+ out(`\n${tally}${counts.error || counts.warn || counts.info ? ' · run with --verbose for the per-file list' : ''}\n`);
186
345
  }
187
346
 
188
347
  if (counts.error > 0 || (strict && counts.warn > 0)) process.exit(1);
@@ -176,13 +176,17 @@ browser's native Print → Save as PDF is the generator; cia just supplies the
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
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
179
+ paired `light-dark()` themes print their light branch for free. It also
180
+ defines a **print palette** `--print-ink`, `--print-paper`, `--print-line`,
181
+ `--print-muted` (default ink-on-white with grays) and rebinds the theme's
182
+ own colour tokens (`--ink`, `--surface-*`, `--border-*`, `--code-*`) onto it,
183
+ so every theme, dark-only (Terminal) included, prints legible ink-on-paper;
184
+ override a `--print-*` token to restyle paper (a letterhead). Three opt-in
185
+ flags (all default OFF): `$link-urls` prints each link's destination via
183
186
  `attr(href)`; `$link-origin` prepends an origin to internal `/…` links so they
184
187
  resolve to full URLs; `$page-numbers` puts `counter(page)` in the `@page`
185
- bottom-center margin box.
188
+ bottom-center margin box. `$legible` is retained as a **deprecated no-op** —
189
+ the rebind supersedes it and passing it warns.
186
190
  - **`print-hidden`** — hide an element on paper (the "hide the nav" case).
187
191
  - **`print-only`** — show an element only on paper (e.g. an inline URL
188
192
  footer); hidden on screen.
package/dist/tokens.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- // Generated from scripts/theme-contract.json on 2026-09-07.
1
+ // Generated from scripts/theme-contract.json on 2026-09-09.
2
2
  // Do not edit by hand. Run `npm run build:token-types` to regenerate.
3
3
 
4
4
  /** Every CSS custom property cia themes are required to declare. */
package/llm.txt CHANGED
@@ -45,10 +45,10 @@ 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. **`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).
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), defines a themeable print palette (`--print-ink` / `--print-paper` / `--print-line` / `--print-muted`, default ink-on-white with grays) and rebinds the theme's own colour tokens (`--ink`, `--surface-*`, `--border-*`, `--code-*`) onto it — so every theme, dark-only (Terminal) included, prints legible ink-on-paper, and overriding a `--print-*` token restyles paper (a letterhead) without touching a component. Three opt-in flags, all default OFF:** `$link-urls` + `$link-origin` (print each link's full destination via `attr(href)`), `$page-numbers` (sheet numbers in the `@page` footer). `$legible` is a DEPRECATED no-op — the token rebind supersedes it and passing it warns.
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
- - **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).
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, off-contract tokens (near-miss typos of real tokens only, never your own custom tokens), hard-coded hex, BEM; health score, CI exit codes). Low-noise on color: a hex in a `var(--token, #hex)` fallback is token-driven and a literal inside `@media print` is an intentional paper colour — neither is flagged.
52
52
  - **Mobile navigation family** — `cia.hamburger` / `cia.drawer` / `cia.sheet` / `cia.dock` ride `[popover]` + CSS Grid, zero JS; recipes `mobile-nav` (hamburger + drawer) and `bottom-nav` (dock + sheets). House rule: **on phones things take the space they're in** — a `cia.dropdown` menu opens 1px under its full-width trigger at the trigger's exact width via CSS anchor positioning (`position-try-fallbacks: flip-block` flips it above at the screen bottom; set `width: auto` at `&[popover]` specificity or the UA's `[popover] { width: fit-content }` and the mixin's inset reset win). Full spec: AGENTS.md quick decision #8 and `/docs/mobile`.
53
53
  - **163 contract tokens — 127 required + 36 optional** — surfaces, ink, lines, colors, type, radius, shadow, blur, glow, motion, z-index, spacing, semantic aliases.
54
54
  - **Spacing is themeable, and the numbered scale is the knob.** Themes declare `--space-0` … `--space-9` (contract-required). The t-shirt names (`--space-md`, `--space-lg` …) are contract-OPTIONAL and emitted by the library as `var()` aliases onto the numbered steps. `space(4)` compiles to `var(--space-4)`, so **theme the numbered step, never the alias** — setting `--space-md` alone leaves every component untouched. Library defaults emit under `:where(:root)` (specificity 0,0,0) so any theme declaration outranks them regardless of load order.
@@ -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 — 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.
173
+ **Shipped today — 7 recipes: dialog, combobox, print-to-pdf, print-spec, letterhead, 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.10.3",
3
+ "version": "1.11.1",
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/public/theme.css CHANGED
@@ -2405,6 +2405,14 @@
2405
2405
  --card-border: 1px solid var(--hair); /* thin rule instead */
2406
2406
  --input-radius: var(--r-sm); /* 2px */
2407
2407
  --modal-radius: var(--r-md); /* 3px */
2408
+ /* ----------------------------------------------------------------
2409
+ PRINT — Press's own paper identity (opt-in per-theme override).
2410
+ Sass hoists this @media out of the theme selector, so it emits as
2411
+ `@media print { :root, :root[data-theme="press"] { … } }` and
2412
+ overrides print-base's default palette on paper only. Newsprint:
2413
+ a faint warm stock, warm near-black ink, press-red masthead rules.
2414
+ Every other theme inherits print-base's plain ink-on-white.
2415
+ ---------------------------------------------------------------- */
2408
2416
  }
2409
2417
  :root[data-theme=press-dark] {
2410
2418
  color-scheme: dark;
@@ -196,4 +196,25 @@
196
196
  --card-border: 1px solid var(--hair); /* thin rule instead */
197
197
  --input-radius: var(--r-sm); /* 2px */
198
198
  --modal-radius: var(--r-md); /* 3px */
199
+ /* ----------------------------------------------------------------
200
+ PRINT — Press's own paper identity (opt-in per-theme override).
201
+ Sass hoists this @media out of the theme selector, so it emits as
202
+ `@media print { :root, :root[data-theme="press"] { … } }` and
203
+ overrides print-base's default palette on paper only. Newsprint:
204
+ a faint warm stock, warm near-black ink, press-red masthead rules.
205
+ Every other theme inherits print-base's plain ink-on-white.
206
+ ---------------------------------------------------------------- */
207
+ }
208
+ @media print {
209
+ :root, :root[data-theme=press] {
210
+ /* stylelint-disable no-invalid-position-declaration -- these are custom-property
211
+ overrides scoped to the theme selector m.theme() supplies; the position is valid
212
+ once the mixin wraps them (emits `@media print { :root, :root[data-theme=press] }`),
213
+ but stylelint can't see the mixin's parent selector statically. */
214
+ --print-paper: #FBFAF6; /* faint warm newsprint stock */
215
+ --print-ink: #0A0A0A; /* warm near-black */
216
+ --print-line: #D93025; /* press red — the masthead rule */
217
+ --print-muted: #6B6963; /* warm editorial gray */
218
+ /* stylelint-enable no-invalid-position-declaration */
219
+ }
199
220
  }
@@ -151,6 +151,10 @@
151
151
  "--logo-monochrome",
152
152
  "--logo-wordmark",
153
153
  "--modal-radius",
154
+ "--print-ink",
155
+ "--print-line",
156
+ "--print-muted",
157
+ "--print-paper",
154
158
  "--shadow-button",
155
159
  "--shadow-card",
156
160
  "--shadow-dropdown",
package/scss/_mixins.scss CHANGED
@@ -598,6 +598,14 @@ $_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
+ //
602
+ // Also emits a themeable PRINT PALETTE — --print-ink / --print-paper /
603
+ // --print-line / --print-muted (default ink-on-white) — and rebinds the theme's
604
+ // colour tokens (--ink, --surface-*, --border-*, --code-*) onto it inside
605
+ // @media print, so every theme (dark-only included) prints legible ink-on-paper.
606
+ // Override a --print-* token (in a theme's @media print, or a <link media=print>
607
+ // sheet) to restyle paper. $legible is a deprecated no-op — the rebind supersedes
608
+ // it and passing it warns.
601
609
  @mixin print-base(
602
610
  $freeze-animations: true,
603
611
  $size: letter,
@@ -607,6 +615,10 @@ $_type-scale: (
607
615
  $link-origin: null,
608
616
  $page-numbers: false
609
617
  ) {
618
+ @if $legible {
619
+ @warn "print-base: $legible is deprecated and now a no-op — print rebinds --ink onto --print-ink for every theme, so dark-only themes are legible without it. Remove the flag; override --print-* tokens to restyle paper.";
620
+ }
621
+
610
622
  :root {
611
623
  --is-print: 0;
612
624
  --print-hide: none;
@@ -619,22 +631,48 @@ $_type-scale: (
619
631
 
620
632
  // Paired themes build color as light-dark(): forcing the light scheme
621
633
  // 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.
634
+ // on the (already white) sheet, for free.
624
635
  color-scheme: light;
625
636
 
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
- }
637
+ // Print palette — themeable. Override these (in a theme's own @media
638
+ // print, or a <link media="print"> theme file) to restyle paper: navy
639
+ // ink, a letterhead rule, a grayscale draft. Default: ink on white.
640
+ --print-ink: #000;
641
+ --print-paper: #fff;
642
+ --print-line: #999;
643
+ --print-muted: #666;
644
+
645
+ // Rebind the theme's colour tokens onto the print palette, so every
646
+ // element that reads --ink / --surface / --border prints ink-on-paper
647
+ // and re-inks when a --print-* token is overridden. Supersedes the old
648
+ // $legible remap: dark-only themes (Terminal) are legible with no flag.
649
+ --ink: var(--print-ink);
650
+ --ink-soft: var(--print-muted);
651
+ --ink-faint: var(--print-muted);
652
+ --muted: var(--print-muted);
653
+ --text-primary: var(--print-ink);
654
+ --text-secondary: var(--print-muted);
655
+ --text-tertiary: var(--print-muted);
656
+
657
+ --paper: var(--print-paper);
658
+ --paper-raised: var(--print-paper);
659
+ --paper-sunk: var(--print-paper);
660
+ --surface-default: var(--print-paper);
661
+ --surface-raised: var(--print-paper);
662
+ --surface-sunk: var(--print-paper);
663
+ --background-default: var(--print-paper);
664
+
665
+ --border-default: var(--print-line);
666
+ --hair: var(--print-line);
667
+
668
+ --code-bg: var(--print-paper);
669
+ --code-ink: var(--print-ink);
670
+ --code-muted: var(--print-muted);
636
671
  }
637
672
 
673
+ // Paper floor: ink on white regardless of what the app sets on <body>.
674
+ body { background: var(--print-paper); color: var(--print-ink); }
675
+
638
676
  @page { size: $size; margin: $margin; }
639
677
 
640
678
  @if $page-numbers {
@@ -651,14 +689,14 @@ $_type-scale: (
651
689
  a[href^="http"]::after {
652
690
  content: " (" attr(href) ")";
653
691
  font-size: 0.85em;
654
- color: color-mix(in sRGB, CanvasText 60%, Canvas);
692
+ color: var(--print-muted);
655
693
  word-break: break-all;
656
694
  }
657
695
  @if $link-origin {
658
696
  a[href^="/"]::after {
659
697
  content: " (#{$link-origin}" attr(href) ")";
660
698
  font-size: 0.85em;
661
- color: color-mix(in sRGB, CanvasText 60%, Canvas);
699
+ color: var(--print-muted);
662
700
  word-break: break-all;
663
701
  }
664
702
  }
@@ -0,0 +1,156 @@
1
+ ---
2
+ name: letterhead
3
+ description: A print-only letterhead — a branded header (name, address, a rule) that appears only when the page is printed, styled with cia's print-only mixin and the themeable --print-* palette.
4
+ category: layout
5
+ complexity: simple
6
+ cia-version: ">=1.11.0"
7
+ ---
8
+
9
+ ## Use this when
10
+
11
+ You have a page — an invoice, a quote, a report — that people **print**, and
12
+ you want a branded header on the paper that isn't on the screen: a company
13
+ name, an address line, a rule under it. A letterhead. It's not a theme and it's
14
+ not a colour scheme — it's a small piece of **markup you add**, styled with
15
+ cia's print helpers so it shows on paper only and matches the print palette.
16
+
17
+ Pair it with [`print-to-pdf`](./print-to-pdf.md) (the page *is* the document) or
18
+ [`print-spec`](./print-spec.md) (a paginated spec document).
19
+
20
+ ## Structure (raw HTML)
21
+
22
+ ```html
23
+ <article class="doc">
24
+ <!-- Only rendered on paper. On screen your site chrome carries identity. -->
25
+ <header class="letterhead">
26
+ <p class="letterhead-name">ACME CORPORATION</p>
27
+ <p class="letterhead-tag">Threat mitigation since 1949</p>
28
+ <p class="letterhead-addr">1 Cliffside Drive · Painted Desert Mesa · acme.example</p>
29
+ </header>
30
+
31
+ <!-- …the rest of the document… -->
32
+ </article>
33
+ ```
34
+
35
+ ## Styling (cia mixins)
36
+
37
+ `cia.print-only` hides the block on screen and reveals it in `@media print`. It
38
+ reads the **print palette** — `--print-ink`, `--print-line`, `--print-muted` —
39
+ that `cia.print-base` defines, so the letterhead always matches the paper look
40
+ and follows any per-theme print override (a newsprint red rule, a navy ink).
41
+
42
+ ```scss
43
+ @use 'css-is-awesome/api' as cia;
44
+
45
+ // Requires cia.print-base once at the stylesheet ROOT (it defines --print-*).
46
+ .letterhead {
47
+ @include cia.print-only;
48
+ margin-block-end: 1rem;
49
+ padding-block-end: 0.5rem;
50
+ border-block-end: 3px double var(--print-line); // the paper's rule colour
51
+ text-align: center;
52
+ }
53
+
54
+ .letterhead-name {
55
+ margin: 0;
56
+ font-family: var(--font-display);
57
+ font-size: 1.9rem;
58
+ letter-spacing: 0.14em;
59
+ color: var(--print-ink); // the paper's ink
60
+ }
61
+ .letterhead-tag { margin: 0.15rem 0 0; font-style: italic; color: var(--print-muted); }
62
+ .letterhead-addr { margin: 0.35rem 0 0; font-family: var(--font-mono); font-size: 0.62rem; color: var(--print-muted); }
63
+ ```
64
+
65
+ Read `--print-*` directly rather than `--ink` / `--border-default`: in print
66
+ those are already rebound onto the palette, but naming the print tokens states
67
+ the intent and survives a per-theme print override unchanged.
68
+
69
+ ## Interactivity
70
+
71
+ **Zero JS.** A letterhead is static markup — there is nothing to wire. It is
72
+ absent from the screen (`print-only` sets `display: none`) and present on paper
73
+ because `@media print` reveals it. No script, no state, no server.
74
+
75
+ ## A11y checklist
76
+
77
+ - [ ] The letterhead is branding, not content — never put information the reader needs *only* here; it is absent from the screen, so the on-screen header must carry the real identity and links ([WCAG 2.2 SC 1.3.1 Info and Relationships](https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html))
78
+ - [ ] Keep the wordmark a `<p>`, not a heading, so a print-only `<h1>` doesn't fork the document outline between screen and paper ([WCAG 2.2 SC 2.4.6 Headings and Labels](https://www.w3.org/WAI/WCAG22/Understanding/headings-and-labels.html))
79
+ - [ ] A logo `<img>` carries a real `alt` (the company name), or `alt=""` when adjacent text already names it ([WCAG 2.2 SC 1.1.1 Non-text Content](https://www.w3.org/WAI/WCAG22/Understanding/non-text-content.html))
80
+ - [ ] Ink-on-paper contrast meets the minimum — `--print-ink` on `--print-paper` is dark-on-light by default; keep any per-theme override readable ([WCAG 2.2 SC 1.4.3 Contrast (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html))
81
+
82
+ ## Framework examples
83
+
84
+ The letterhead is static markup — no props, no lifecycle, no client code. Every
85
+ stack renders the same header; the visibility and styling live entirely in the
86
+ `@media print` CSS above.
87
+
88
+ ### React
89
+
90
+ ```tsx
91
+ export function Letterhead() {
92
+ return (
93
+ <header className="letterhead">
94
+ <p className="letterhead-name">ACME CORPORATION</p>
95
+ <p className="letterhead-addr">1 Cliffside Drive · acme.example</p>
96
+ </header>
97
+ );
98
+ }
99
+ ```
100
+
101
+ ### Vue
102
+
103
+ ```vue
104
+ <template>
105
+ <header class="letterhead">
106
+ <p class="letterhead-name">ACME CORPORATION</p>
107
+ <p class="letterhead-addr">1 Cliffside Drive · acme.example</p>
108
+ </header>
109
+ </template>
110
+ ```
111
+
112
+ ### Svelte
113
+
114
+ ```svelte
115
+ <header class="letterhead">
116
+ <p class="letterhead-name">ACME CORPORATION</p>
117
+ <p class="letterhead-addr">1 Cliffside Drive · acme.example</p>
118
+ </header>
119
+ ```
120
+
121
+ ### Vanilla
122
+
123
+ ```html
124
+ <header class="letterhead">
125
+ <p class="letterhead-name">ACME CORPORATION</p>
126
+ <p class="letterhead-addr">1 Cliffside Drive · acme.example</p>
127
+ </header>
128
+ ```
129
+
130
+ ## Variants
131
+
132
+ - **Newsprint rule.** A theme (e.g. Press) overrides `--print-line` in its own
133
+ `@media print` block; the letterhead's rule turns that colour automatically —
134
+ no change to the letterhead itself.
135
+ - **Logo instead of a wordmark.** Swap `.letterhead-name` for an `<img>` (give
136
+ it `alt`). Prefer an inline SVG or a build-time data URI so nothing is fetched
137
+ at print time.
138
+ - **Footer letterhead.** The same pattern as a `print-only` footer — page-foot
139
+ address and a rule — using `margin-block-start: auto` inside a full-page flex
140
+ column to pin it to the bottom (see [`print-to-pdf`](./print-to-pdf.md)).
141
+
142
+ ## Pitfalls
143
+
144
+ - **`print-base` must be included once at the stylesheet root**, or the
145
+ `--print-*` tokens are undefined and the rule/ink fall back to initial values.
146
+ - **Don't duplicate identity on screen.** Because it is `print-only`, let your
147
+ normal on-screen header carry the brand; the letterhead is for paper alone.
148
+ - **A full-bleed coloured band costs toner.** Prefer ink, a rule, and restraint;
149
+ let `--print-paper` stay near-white unless a theme deliberately wants a tint.
150
+
151
+ ## Related recipes
152
+
153
+ - [`print-to-pdf`](./print-to-pdf.md) — the page *is* the document; save it as a
154
+ faithful PDF. The letterhead lives on top of this.
155
+ - [`print-spec`](./print-spec.md) — paginate a page into a spec document with a
156
+ cover, index, and printed sheet numbers.
@@ -67,16 +67,16 @@ Four moves, all pure CSS: `print-base` once at the root, `print-only` on the ind
67
67
  @use 'css-is-awesome/api' as cia;
68
68
 
69
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.
70
+ // polish flags. Each defaults OFF, so nothing changes for existing callers
71
+ // until you opt in:
73
72
  // $link-urls — every link prints its destination, so paper is followable.
74
73
  // $link-origin — prepended to internal (/…) hrefs so they print as full URLs.
75
74
  // $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.
75
+ // print-base also forces `color-scheme: light` in print for free (PAIRED
76
+ // light-dark() themes land on their light branch), and rebinds the theme's
77
+ // colour tokens onto a print palette (--print-ink/-paper/-line/-muted, default
78
+ // ink-on-white) so every theme — dark-only included — prints legible.
78
79
  @include cia.print-base(
79
- $legible: true,
80
80
  $link-urls: true,
81
81
  $link-origin: 'https://example.com',
82
82
  $page-numbers: true
@@ -90,11 +90,14 @@ Four moves, all pure CSS: `print-base` once at the root, `print-only` on the ind
90
90
  }
91
91
  ```
92
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).
93
+ Print colour is handled by the palette, not a flag: `print-base` rebinds the
94
+ theme's `--ink`, `--surface-*`, `--border-*` and `--code-*` onto `--print-ink`,
95
+ `--print-paper`, `--print-line` and `--print-muted` (default ink-on-white), so
96
+ every theme — dark-only ones included prints legible with no setting. Override
97
+ a `--print-*` token to restyle paper (a navy letterhead, silver rules). The old
98
+ `$legible` flag is a deprecated no-op. `$link-urls` prints external hrefs
99
+ directly and internal ones prefixed with `$link-origin`; same-page `#anchor`
100
+ links are skipped (the URL adds nothing).
98
101
 
99
102
  **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
103
 
@@ -249,4 +249,24 @@
249
249
  --card-border: 1px solid var(--hair); /* thin rule instead */
250
250
  --input-radius: var(--r-sm); /* 2px */
251
251
  --modal-radius: var(--r-md); /* 3px */
252
+
253
+ /* ----------------------------------------------------------------
254
+ PRINT — Press's own paper identity (opt-in per-theme override).
255
+ Sass hoists this @media out of the theme selector, so it emits as
256
+ `@media print { :root, :root[data-theme="press"] { … } }` and
257
+ overrides print-base's default palette on paper only. Newsprint:
258
+ a faint warm stock, warm near-black ink, press-red masthead rules.
259
+ Every other theme inherits print-base's plain ink-on-white.
260
+ ---------------------------------------------------------------- */
261
+ @media print {
262
+ /* stylelint-disable no-invalid-position-declaration -- these are custom-property
263
+ overrides scoped to the theme selector m.theme() supplies; the position is valid
264
+ once the mixin wraps them (emits `@media print { :root, :root[data-theme=press] }`),
265
+ but stylelint can't see the mixin's parent selector statically. */
266
+ --print-paper: #FBFAF6; /* faint warm newsprint stock */
267
+ --print-ink: #0A0A0A; /* warm near-black */
268
+ --print-line: #D93025; /* press red — the masthead rule */
269
+ --print-muted: #6B6963; /* warm editorial gray */
270
+ /* stylelint-enable no-invalid-position-declaration */
271
+ }
252
272
  }