css-is-awesome 1.1.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.
Files changed (198) hide show
  1. package/AGENTS.md +343 -0
  2. package/CHANGELOG.md +1036 -0
  3. package/CLAUDE.md +9 -0
  4. package/CONTRACT.md +580 -0
  5. package/GEMINI.md +9 -0
  6. package/LICENSE +21 -0
  7. package/LICENSE-third-party +88 -0
  8. package/MIGRATION.md +312 -0
  9. package/README.md +380 -0
  10. package/ROADMAP.md +714 -0
  11. package/THEMING.md +290 -0
  12. package/THREE-TIERS.md +158 -0
  13. package/VERSIONING.md +200 -0
  14. package/_index.scss +34 -0
  15. package/api.scss +16 -0
  16. package/bin/README.md +57 -0
  17. package/bin/cia.cjs +114 -0
  18. package/bin/migrate-bootstrap.cjs +427 -0
  19. package/bin/migrate-tailwind.cjs +832 -0
  20. package/css-is-awesome.instructions.md +533 -0
  21. package/dist/css-is-awesome.core.css +598 -0
  22. package/dist/css-is-awesome.core.min.css +1 -0
  23. package/dist/css-is-awesome.css +3733 -0
  24. package/dist/css-is-awesome.min.css +1 -0
  25. package/dist/css-is-awesome.utilities.css +2445 -0
  26. package/dist/css-is-awesome.utilities.min.css +1 -0
  27. package/dist/tokens.css +508 -0
  28. package/dist/tokens.d.ts +266 -0
  29. package/dist/tokens.min.css +1 -0
  30. package/figma-tokens/README.md +57 -0
  31. package/figma-tokens/primitives-brand.scss +121 -0
  32. package/figma-tokens/semantic-dark.scss +61 -0
  33. package/figma-tokens/semantic-light.scss +61 -0
  34. package/figma-tokens/tokens.json +144 -0
  35. package/llm.txt +183 -0
  36. package/mcp/server.cjs +1495 -0
  37. package/package.json +199 -0
  38. package/public/icons/README.md +278 -0
  39. package/public/icons/arrow-right.svg +4 -0
  40. package/public/icons/check.svg +4 -0
  41. package/public/icons/chevron-down.svg +4 -0
  42. package/public/icons/close.svg +4 -0
  43. package/public/icons/core/arrow-down.svg +13 -0
  44. package/public/icons/core/arrow-left.svg +13 -0
  45. package/public/icons/core/arrow-right.svg +13 -0
  46. package/public/icons/core/arrow-up.svg +13 -0
  47. package/public/icons/core/bell.svg +13 -0
  48. package/public/icons/core/calendar.svg +15 -0
  49. package/public/icons/core/check.svg +12 -0
  50. package/public/icons/core/chevron-down.svg +12 -0
  51. package/public/icons/core/chevron-left.svg +12 -0
  52. package/public/icons/core/chevron-right.svg +12 -0
  53. package/public/icons/core/chevron-up.svg +12 -0
  54. package/public/icons/core/clock.svg +13 -0
  55. package/public/icons/core/close.svg +13 -0
  56. package/public/icons/core/copy.svg +13 -0
  57. package/public/icons/core/download.svg +14 -0
  58. package/public/icons/core/edit.svg +13 -0
  59. package/public/icons/core/error.svg +14 -0
  60. package/public/icons/core/external-link.svg +14 -0
  61. package/public/icons/core/eye-off.svg +15 -0
  62. package/public/icons/core/eye.svg +13 -0
  63. package/public/icons/core/filter.svg +12 -0
  64. package/public/icons/core/heart.svg +12 -0
  65. package/public/icons/core/help.svg +14 -0
  66. package/public/icons/core/home.svg +13 -0
  67. package/public/icons/core/info.svg +14 -0
  68. package/public/icons/core/loading.svg +12 -0
  69. package/public/icons/core/lock.svg +13 -0
  70. package/public/icons/core/mail.svg +13 -0
  71. package/public/icons/core/menu.svg +14 -0
  72. package/public/icons/core/message.svg +12 -0
  73. package/public/icons/core/minus.svg +12 -0
  74. package/public/icons/core/more-horizontal.svg +14 -0
  75. package/public/icons/core/pause.svg +13 -0
  76. package/public/icons/core/play.svg +12 -0
  77. package/public/icons/core/plus.svg +13 -0
  78. package/public/icons/core/refresh.svg +15 -0
  79. package/public/icons/core/save.svg +14 -0
  80. package/public/icons/core/search.svg +13 -0
  81. package/public/icons/core/settings.svg +13 -0
  82. package/public/icons/core/share.svg +14 -0
  83. package/public/icons/core/sort.svg +15 -0
  84. package/public/icons/core/star.svg +12 -0
  85. package/public/icons/core/success.svg +13 -0
  86. package/public/icons/core/trash.svg +16 -0
  87. package/public/icons/core/unlock.svg +13 -0
  88. package/public/icons/core/upload.svg +14 -0
  89. package/public/icons/core/user.svg +13 -0
  90. package/public/icons/core/users.svg +15 -0
  91. package/public/icons/core/warning.svg +14 -0
  92. package/public/icons/download.svg +4 -0
  93. package/public/icons/edit.svg +4 -0
  94. package/public/icons/menu.svg +4 -0
  95. package/public/icons/search.svg +4 -0
  96. package/public/theme.css +4179 -0
  97. package/public/themes/README.md +102 -0
  98. package/public/themes/boilerplate/theme.css +154 -0
  99. package/public/themes/boilerplate-dark/theme.css +171 -0
  100. package/public/themes/boilerplate-light/theme.css +171 -0
  101. package/public/themes/cupertino/theme.css +165 -0
  102. package/public/themes/cupertino-dark/theme.css +200 -0
  103. package/public/themes/cupertino-light/theme.css +227 -0
  104. package/public/themes/glass/theme.css +190 -0
  105. package/public/themes/glass-dark/theme.css +201 -0
  106. package/public/themes/glass-light/theme.css +229 -0
  107. package/public/themes/graphite/theme.css +155 -0
  108. package/public/themes/graphite-dark/theme.css +223 -0
  109. package/public/themes/graphite-light/theme.css +191 -0
  110. package/public/themes/press/theme.css +189 -0
  111. package/public/themes/press-dark/theme.css +199 -0
  112. package/public/themes/press-light/theme.css +235 -0
  113. package/public/themes/prism/theme.css +159 -0
  114. package/public/themes/prism-dark/theme.css +185 -0
  115. package/public/themes/prism-light/theme.css +188 -0
  116. package/public/themes/sketchbook/theme.css +152 -0
  117. package/public/themes/sketchbook-dark/theme.css +194 -0
  118. package/public/themes/sketchbook-light/theme.css +188 -0
  119. package/public/themes/terminal/theme.css +159 -0
  120. package/public/themes/terminal-dark/theme.css +229 -0
  121. package/public/themes/terminal-light/theme.css +152 -0
  122. package/scripts/README.md +70 -0
  123. package/scripts/icon-contract.json +67 -0
  124. package/scripts/icon-validator.js +205 -0
  125. package/scripts/prepare-dist.mjs +65 -0
  126. package/scripts/theme-a11y.js +415 -0
  127. package/scripts/theme-contract.json +171 -0
  128. package/scripts/theme-validator.js +674 -0
  129. package/scss/README.md +55 -0
  130. package/scss/_animations-utilities.scss +107 -0
  131. package/scss/_animations.scss +248 -0
  132. package/scss/_app-styles.scss +15 -0
  133. package/scss/_generator.scss +174 -0
  134. package/scss/_icons.scss +475 -0
  135. package/scss/_index.scss +17 -0
  136. package/scss/_layout.scss +597 -0
  137. package/scss/_mixins.scss +1036 -0
  138. package/scss/_system.scss +291 -0
  139. package/scss/_utilities.scss +377 -0
  140. package/scss/api.scss +61 -0
  141. package/scss/components/_accordion.scss +72 -0
  142. package/scss/components/_buttons.scss +171 -0
  143. package/scss/components/_copy-button.scss +69 -0
  144. package/scss/components/_data.scss +190 -0
  145. package/scss/components/_feedback.scss +197 -0
  146. package/scss/components/_forms.scss +272 -0
  147. package/scss/components/_index.scss +17 -0
  148. package/scss/components/_navigation.scss +186 -0
  149. package/scss/components/_overlay.scss +259 -0
  150. package/scss/components/_stepper.scss +159 -0
  151. package/scss/components/_tabs.scss +72 -0
  152. package/scss/core.scss +54 -0
  153. package/scss/examples/_theming.scss +107 -0
  154. package/scss/examples/_usage.scss +317 -0
  155. package/scss/main.scss +64 -0
  156. package/scss/recipes/README.md +110 -0
  157. package/scss/recipes/_bare-tags.scss +232 -0
  158. package/scss/recipes/_recipe-template.md +119 -0
  159. package/scss/recipes/combobox.md +509 -0
  160. package/scss/recipes/dialog.md +265 -0
  161. package/scss/recipes/print-to-pdf.md +263 -0
  162. package/scss/theme/README.md +67 -0
  163. package/scss/theme/_brand.scss +18 -0
  164. package/scss/theme/_colors-dark.scss +68 -0
  165. package/scss/theme/_colors-light.scss +70 -0
  166. package/scss/theme/_components.scss +94 -0
  167. package/scss/theme/_icons.scss +104 -0
  168. package/scss/theme/_index.scss +62 -0
  169. package/scss/theme/_shadows.scss +65 -0
  170. package/scss/theme/_shape.scss +13 -0
  171. package/scss/theme/_typography.scss +9 -0
  172. package/scss/theme/registry.json +447 -0
  173. package/scss/themes/boilerplate-dark.scss +212 -0
  174. package/scss/themes/boilerplate-light.scss +209 -0
  175. package/scss/themes/boilerplate.scss +181 -0
  176. package/scss/themes/cupertino-dark.scss +238 -0
  177. package/scss/themes/cupertino-light.scss +266 -0
  178. package/scss/themes/cupertino.scss +194 -0
  179. package/scss/themes/glass-dark.scss +238 -0
  180. package/scss/themes/glass-light.scss +266 -0
  181. package/scss/themes/glass.scss +220 -0
  182. package/scss/themes/graphite-dark.scss +260 -0
  183. package/scss/themes/graphite-light.scss +228 -0
  184. package/scss/themes/graphite.scss +182 -0
  185. package/scss/themes/press-dark.scss +237 -0
  186. package/scss/themes/press-light.scss +273 -0
  187. package/scss/themes/press.scss +241 -0
  188. package/scss/themes/prism-dark.scss +217 -0
  189. package/scss/themes/prism-light.scss +220 -0
  190. package/scss/themes/prism.scss +190 -0
  191. package/scss/themes/sketchbook-dark.scss +231 -0
  192. package/scss/themes/sketchbook-light.scss +231 -0
  193. package/scss/themes/sketchbook.scss +178 -0
  194. package/scss/themes/terminal-dark.scss +267 -0
  195. package/scss/themes/terminal-light.scss +175 -0
  196. package/scss/themes/terminal.scss +185 -0
  197. package/scss/tokens.scss +23 -0
  198. package/scss/utilities-only.scss +4 -0
@@ -0,0 +1,674 @@
1
+ #!/usr/bin/env node
2
+ /* eslint-disable */
3
+ /**
4
+ * theme-validator.js
5
+ * --------------------------------------------------------------
6
+ * Validates a theme.css against the token contract in
7
+ * scripts/theme-contract.json. Exits non-zero if any required
8
+ * custom property is missing.
9
+ *
10
+ * Zero runtime dependencies — pure fs/path/process.
11
+ *
12
+ * Usage:
13
+ * node scripts/theme-validator.js <path-to-theme.css> [more-paths...]
14
+ * node scripts/theme-validator.js --all
15
+ *
16
+ * Two file shapes are supported transparently:
17
+ *
18
+ * 1. PER-FILE (legacy, still used for public/themes/*\/theme.css
19
+ * and any user-authored theme): one or more `:root { ... }`
20
+ * blocks. Each block contributes tokens to a single set;
21
+ * the file passes when that set covers every required token.
22
+ *
23
+ * 2. CONSOLIDATED: one file with multiple
24
+ * `[data-theme="<name>"] { ... }` blocks (plus an optional
25
+ * `:root:not([data-theme])` fallback). Each data-theme block
26
+ * is validated as its own theme; the file passes only when
27
+ * every block declares every required token. This is the
28
+ * shape of the root `public/theme.css` under Path A.
29
+ *
30
+ * Detection is automatic — if at least one `[data-theme="..."]`
31
+ * selector is present, the file is treated as consolidated.
32
+ *
33
+ * Exit codes:
34
+ * 0 — every validated file / block declares every required token
35
+ * 1 — one or more files are missing tokens
36
+ * 2 — usage error (file not found, bad args, bad contract)
37
+ */
38
+
39
+ 'use strict';
40
+
41
+ const fs = require('fs');
42
+ const path = require('path');
43
+ const a11y = require('./theme-a11y');
44
+
45
+ // -----------------------------------------------------------
46
+ // Paths
47
+ // -----------------------------------------------------------
48
+ const REPO_ROOT = path.resolve(__dirname, '..');
49
+ const CONTRACT_PATH = path.join(__dirname, 'theme-contract.json');
50
+ const PUBLIC_DIR = path.join(REPO_ROOT, 'public');
51
+ const THEMES_DIR = path.join(PUBLIC_DIR, 'themes');
52
+ const ROOT_THEME = path.join(PUBLIC_DIR, 'theme.css');
53
+
54
+ // -----------------------------------------------------------
55
+ // ANSI coloring — only when stdout is a TTY
56
+ // -----------------------------------------------------------
57
+ const USE_COLOR = Boolean(process.stdout.isTTY);
58
+ const color = (code, s) => (USE_COLOR ? `\x1b[${code}m${s}\x1b[0m` : s);
59
+ const green = (s) => color('32', s);
60
+ const red = (s) => color('31', s);
61
+ const dim = (s) => color('2', s);
62
+ const yellow = (s) => color('33', s);
63
+ const bold = (s) => color('1', s);
64
+
65
+ // -----------------------------------------------------------
66
+ // Contract loader
67
+ // -----------------------------------------------------------
68
+ function loadContract() {
69
+ let raw;
70
+ try {
71
+ raw = fs.readFileSync(CONTRACT_PATH, 'utf8');
72
+ } catch (err) {
73
+ console.error(
74
+ red('error:') +
75
+ ` could not read contract at ${CONTRACT_PATH}\n ${err.message}`
76
+ );
77
+ process.exit(2);
78
+ }
79
+ let parsed;
80
+ try {
81
+ parsed = JSON.parse(raw);
82
+ } catch (err) {
83
+ console.error(
84
+ red('error:') + ` theme-contract.json is not valid JSON: ${err.message}`
85
+ );
86
+ process.exit(2);
87
+ }
88
+ if (!parsed || !Array.isArray(parsed.required)) {
89
+ console.error(red('error:') + ' theme-contract.json is missing `required` array');
90
+ process.exit(2);
91
+ }
92
+ return parsed;
93
+ }
94
+
95
+ // -----------------------------------------------------------
96
+ // File discovery for --all
97
+ // -----------------------------------------------------------
98
+ function discoverAllThemes() {
99
+ const files = [];
100
+ if (fs.existsSync(ROOT_THEME)) files.push(ROOT_THEME);
101
+
102
+ if (fs.existsSync(THEMES_DIR)) {
103
+ for (const entry of fs.readdirSync(THEMES_DIR, { withFileTypes: true })) {
104
+ if (!entry.isDirectory()) continue;
105
+ const themeFile = path.join(THEMES_DIR, entry.name, 'theme.css');
106
+ if (fs.existsSync(themeFile)) files.push(themeFile);
107
+ }
108
+ }
109
+
110
+ return files;
111
+ }
112
+
113
+ // -----------------------------------------------------------
114
+ // Helper — strip block comments so comment-shaped `--foo: bar;`
115
+ // inside a comment (docs example) isn't counted.
116
+ // -----------------------------------------------------------
117
+ function stripBlockComments(cssText) {
118
+ return cssText.replace(/\/\*[\s\S]*?\*\//g, '');
119
+ }
120
+
121
+ // -----------------------------------------------------------
122
+ // Helper — match a balanced `{ ... }` block starting AT `openIdx`
123
+ // where stripped[openIdx] === '{'. Returns the body (contents
124
+ // between the braces) plus the index of the closing brace, or
125
+ // null if the braces aren't balanced.
126
+ // -----------------------------------------------------------
127
+ function readBracedBlock(stripped, openIdx) {
128
+ if (stripped[openIdx] !== '{') return null;
129
+ let depth = 1;
130
+ let k = openIdx + 1;
131
+ while (k < stripped.length && depth > 0) {
132
+ const ch = stripped[k];
133
+ if (ch === '{') depth++;
134
+ else if (ch === '}') depth--;
135
+ if (depth === 0) break;
136
+ k++;
137
+ }
138
+ if (depth !== 0) return null;
139
+ return { body: stripped.slice(openIdx + 1, k), end: k };
140
+ }
141
+
142
+ // -----------------------------------------------------------
143
+ // Helper — extract every top-level `--name:` declaration from a
144
+ // block body into a Set of token names.
145
+ // -----------------------------------------------------------
146
+ function collectTokensFromBlock(blockBody) {
147
+ const declRe = /(^|[{;\s])(--[A-Za-z_][A-Za-z0-9_-]*)\s*:/g;
148
+ const out = new Set();
149
+ let m;
150
+ while ((m = declRe.exec(blockBody)) !== null) {
151
+ out.add(m[2]);
152
+ }
153
+ return out;
154
+ }
155
+
156
+ // Same shape as collectTokensFromBlock but returns Map<name, rawValue>.
157
+ // Values include everything between ':' and the next top-level ';',
158
+ // paren-aware so functional notation survives.
159
+ function collectTokenValuesFromBlock(blockBody) {
160
+ const re = /(--[A-Za-z_][A-Za-z0-9_-]*)s*:/g;
161
+ const out = new Map();
162
+ let m;
163
+ while ((m = re.exec(blockBody)) !== null) {
164
+ const name = m[1];
165
+ let i = m.index + m[0].length;
166
+ let depth = 0;
167
+ let value = '';
168
+ while (i < blockBody.length) {
169
+ const ch = blockBody[i];
170
+ if (ch === '(') depth++;
171
+ else if (ch === ')') depth--;
172
+ else if (ch === ';' && depth === 0) break;
173
+ else if (ch === '}' && depth === 0) break;
174
+ value += ch;
175
+ i++;
176
+ }
177
+ out.set(name, value.trim());
178
+ }
179
+ return out;
180
+ }
181
+
182
+ // -----------------------------------------------------------
183
+ // Parser — extract `--token-name` declarations from any :root
184
+ // block(s) in the file. Values don't matter, only names.
185
+ //
186
+ // KEPT for backward compatibility: other scripts may import this
187
+ // module via require() and rely on the old function name/shape.
188
+ // -----------------------------------------------------------
189
+ function extractDeclaredTokens(cssText) {
190
+ return extractRootBlock(cssText).tokens;
191
+ }
192
+
193
+ // Same walk as extractDeclaredTokens but collects both names and values.
194
+ function extractRootBlock(cssText) {
195
+ const stripped = stripBlockComments(cssText);
196
+ const tokens = new Set();
197
+ const values = new Map();
198
+
199
+ let i = 0;
200
+ while (i < stripped.length) {
201
+ const rootIdx = stripped.indexOf(':root', i);
202
+ if (rootIdx === -1) break;
203
+
204
+ let j = rootIdx + ':root'.length;
205
+ while (j < stripped.length && stripped[j] !== '{' && stripped[j] !== ';') j++;
206
+ if (j >= stripped.length || stripped[j] !== '{') {
207
+ i = rootIdx + ':root'.length;
208
+ continue;
209
+ }
210
+
211
+ const block = readBracedBlock(stripped, j);
212
+ if (!block) { i = j + 1; continue; }
213
+
214
+ for (const tok of collectTokensFromBlock(block.body)) tokens.add(tok);
215
+ for (const [t, v] of collectTokenValuesFromBlock(block.body)) values.set(t, v);
216
+ i = block.end + 1;
217
+ }
218
+
219
+ return { tokens: tokens, values: values };
220
+ }
221
+
222
+ // -----------------------------------------------------------
223
+ // Parser — find every `[data-theme="<name>"] ... { ... }` block
224
+ // (possibly preceded or followed by other comma-separated
225
+ // selectors on the same rule) and return them as
226
+ // { name, tokens: Set<string> } entries in source order.
227
+ //
228
+ // Rules whose selector list includes `[data-theme="<name>"]`
229
+ // contribute their tokens to that name. A rule may carry
230
+ // multiple data-theme selectors (rare, but supported) — in
231
+ // that case the tokens are counted toward each named theme.
232
+ // -----------------------------------------------------------
233
+ // Matches `[data-theme="dark"]`, `[data-theme='dark']` and `[data-theme=dark]`.
234
+ // Group 1 is the quoted value, group 2 the unquoted one — read `m[1] || m[2]`.
235
+ // Deliberately NOT global: it is used with .test(), and a /g regex makes .test()
236
+ // stateful via lastIndex, so alternating calls would return alternating answers.
237
+ // Callers that need to iterate build their own /g copy from .source.
238
+ const DATA_THEME_RE = /\[data-theme\s*=\s*(?:["']([^"']+)["']|([^\]\s"']+))\s*\]/;
239
+
240
+ function extractDataThemeBlocks(cssText) {
241
+ const stripped = stripBlockComments(cssText);
242
+ const blocks = [];
243
+ const order = []; // preserves first-seen order of theme names
244
+ const byName = new Map(); // name -> Set<token>
245
+ const valuesByName = new Map(); // name -> Map<token, rawValue>
246
+
247
+ let i = 0;
248
+ while (i < stripped.length) {
249
+ // Find the next `{` — the block-opener — then look BACKWARDS
250
+ // for its selector list (up to the previous `}` or start).
251
+ const braceIdx = stripped.indexOf('{', i);
252
+ if (braceIdx === -1) break;
253
+
254
+ // Find the start of this rule's selector list. The selector
255
+ // starts after the previous `}` (or `;` at top level, for
256
+ // @import etc.) or at position 0.
257
+ let selStart = braceIdx - 1;
258
+ while (selStart >= 0) {
259
+ const ch = stripped[selStart];
260
+ if (ch === '}' || ch === ';') {
261
+ selStart++;
262
+ break;
263
+ }
264
+ selStart--;
265
+ }
266
+ if (selStart < 0) selStart = 0;
267
+
268
+ const selector = stripped.slice(selStart, braceIdx);
269
+
270
+ const block = readBracedBlock(stripped, braceIdx);
271
+ if (!block) break;
272
+
273
+ // Find every [data-theme=<name>] occurrence in this rule's
274
+ // selector list. A rule can have multiple (e.g. grouped).
275
+ // Quoted and unquoted values both match — see DATA_THEME_RE.
276
+ const nameRe = new RegExp(DATA_THEME_RE.source, 'g');
277
+ const names = [];
278
+ let nm;
279
+ while ((nm = nameRe.exec(selector)) !== null) {
280
+ names.push(nm[1] || nm[2]);
281
+ }
282
+
283
+ if (names.length > 0) {
284
+ const blockTokens = collectTokensFromBlock(block.body);
285
+ const blockValues = collectTokenValuesFromBlock(block.body);
286
+ for (const name of names) {
287
+ if (!byName.has(name)) {
288
+ byName.set(name, new Set());
289
+ valuesByName.set(name, new Map());
290
+ order.push(name);
291
+ }
292
+ const set = byName.get(name);
293
+ for (const t of blockTokens) set.add(t);
294
+ const vmap = valuesByName.get(name);
295
+ for (const [t, v] of blockValues) vmap.set(t, v);
296
+ }
297
+ }
298
+
299
+ i = block.end + 1;
300
+ }
301
+
302
+ for (const name of order) {
303
+ blocks.push({
304
+ name,
305
+ tokens: byName.get(name),
306
+ values: valuesByName.get(name),
307
+ });
308
+ }
309
+ return blocks;
310
+ }
311
+
312
+ // -----------------------------------------------------------
313
+ // Detect whether a file is the consolidated [data-theme] shape.
314
+ // Any occurrence of `[data-theme=` in the (comment-stripped)
315
+ // source is enough to switch modes.
316
+ // -----------------------------------------------------------
317
+ // Quotes around an attribute value are OPTIONAL in CSS: `[data-theme=dark]`
318
+ // is as valid as `[data-theme="dark"]`. This test used to require them, so an
319
+ // unquoted theme fell through to the legacy `:root` path below — where every
320
+ // [data-theme] block is invisible. The validator then audited `:root` alone,
321
+ // found it fine, and exited 0 while a theme block carried near-black text on
322
+ // near-black paper. It reported "1 theme block(s)" and passed a failing theme.
323
+ // Reported by a consumer 2026-08-29; see DATA_THEME_RE for the matching parser.
324
+ function isConsolidated(cssText) {
325
+ const stripped = stripBlockComments(cssText);
326
+ return DATA_THEME_RE.test(stripped);
327
+ }
328
+
329
+ // -----------------------------------------------------------
330
+ // Validate a single theme block (used in both modes)
331
+ // -----------------------------------------------------------
332
+ function validateTokenSet(declared, contract) {
333
+ const missing = [];
334
+ for (const required of contract.required) {
335
+ if (!declared.has(required)) missing.push(required);
336
+ }
337
+ return { ok: missing.length === 0, missing, declaredCount: declared.size };
338
+ }
339
+
340
+ // -----------------------------------------------------------
341
+ // Validate a single file. Auto-detects per-file vs consolidated.
342
+ // Returns a unified result shape with either a flat pass/fail
343
+ // (per-file) or a list of per-theme pass/fails (consolidated).
344
+ // -----------------------------------------------------------
345
+ function validateFile(filePath, contract, options) {
346
+ const opts = options || {};
347
+ const wantA11y = opts.a11y !== false;
348
+ const result = {
349
+ file: filePath,
350
+ mode: 'per-file',
351
+ ok: false,
352
+ declaredCount: 0,
353
+ missing: [],
354
+ themes: null,
355
+ a11y: null,
356
+ error: null,
357
+ };
358
+
359
+ let text;
360
+ try {
361
+ text = fs.readFileSync(filePath, 'utf8');
362
+ } catch (err) {
363
+ result.error = err.message;
364
+ return result;
365
+ }
366
+
367
+ if (isConsolidated(text)) {
368
+ result.mode = 'consolidated';
369
+ const blocks = extractDataThemeBlocks(text);
370
+
371
+ if (blocks.length === 0) {
372
+ result.error = 'consolidated file has no [data-theme="<name>"] blocks we could parse';
373
+ return result;
374
+ }
375
+
376
+ result.themes = blocks.map(function (b) {
377
+ const v = validateTokenSet(b.tokens, contract);
378
+ const theme = {
379
+ name: b.name,
380
+ ok: v.ok,
381
+ declaredCount: v.declaredCount,
382
+ missing: v.missing,
383
+ a11y: null,
384
+ };
385
+ if (wantA11y) theme.a11y = a11y.auditThemeTokens({ name: b.name, values: b.values });
386
+ return theme;
387
+ });
388
+ result.ok = result.themes.every(function (t) { return t.ok; });
389
+ return result;
390
+ }
391
+
392
+ // Legacy per-file mode - union of all :root { ... } blocks.
393
+ //
394
+ // Belt-and-braces: if the file mentions data-theme anywhere but no block
395
+ // parsed, we are about to audit `:root` alone and silently ignore every
396
+ // theme block in the file. That is the exact shape of the bug fixed above —
397
+ // a pass that means nothing. Refuse to grade rather than grade wrongly.
398
+ if (/data-theme/.test(stripBlockComments(text))) {
399
+ result.error =
400
+ 'file contains "data-theme" but no theme block could be parsed. Refusing to ' +
401
+ 'fall back to :root-only mode, which would ignore every theme block and ' +
402
+ 'report a pass. Expected a selector like [data-theme=dark] or [data-theme="dark"].';
403
+ return result;
404
+ }
405
+
406
+ const root = extractRootBlock(text);
407
+ const v = validateTokenSet(root.tokens, contract);
408
+ result.declaredCount = v.declaredCount;
409
+ result.missing = v.missing;
410
+ result.ok = v.ok;
411
+ if (wantA11y) {
412
+ const inferredName = path.basename(path.dirname(filePath)) || path.basename(filePath, '.css');
413
+ result.a11y = a11y.auditThemeTokens({ name: inferredName, values: root.values });
414
+ }
415
+ return result;
416
+ }
417
+
418
+ // -----------------------------------------------------------
419
+ // Reporter
420
+ // -----------------------------------------------------------
421
+ function relForDisplay(p) {
422
+ const rel = path.relative(REPO_ROOT, p).split(path.sep).join('/');
423
+ return rel || p;
424
+ }
425
+
426
+ function reportResult(result) {
427
+ const rel = relForDisplay(result.file);
428
+
429
+ if (result.error) {
430
+ console.log(`${red('x')} ${bold(rel)} ${red('— could not read / parse file')}`);
431
+ console.log(` ${dim(result.error)}`);
432
+ return;
433
+ }
434
+
435
+ if (result.mode === 'consolidated') {
436
+ const header = result.ok
437
+ ? `${green('✓')} ${bold(rel)} ${dim(`consolidated — ${result.themes.length} theme block(s)`)}`
438
+ : `${red('x')} ${bold(rel)} ${red('— consolidated, one or more theme blocks failed')}`;
439
+ console.log(header);
440
+
441
+ for (const t of result.themes) {
442
+ if (t.ok) {
443
+ console.log(
444
+ ` ${green('✓')} [data-theme="${t.name}"] ${dim(`(${t.declaredCount} tokens)`)}`
445
+ );
446
+ } else {
447
+ const n = t.missing.length;
448
+ console.log(
449
+ ` ${red('x')} [data-theme="${t.name}"] ${red(`— ${n} missing:`)}`
450
+ );
451
+ for (const token of t.missing) {
452
+ console.log(` ${red(token)}`);
453
+ }
454
+ }
455
+ }
456
+ return;
457
+ }
458
+
459
+ // per-file mode
460
+ if (result.ok) {
461
+ console.log(
462
+ `${green('✓')} ${bold(rel)} ${dim(`passes (${result.declaredCount} tokens declared)`)}`
463
+ );
464
+ return;
465
+ }
466
+
467
+ const n = result.missing.length;
468
+ console.log(
469
+ `${red('x')} ${bold(rel)} ${red(`— ${n} missing:`)}`
470
+ );
471
+ for (const token of result.missing) {
472
+ console.log(` ${red(token)}`);
473
+ }
474
+ }
475
+
476
+ // -----------------------------------------------------------
477
+ // A11y reporter - pretty-print one theme's contrast audit.
478
+ // Returns { fail, warn, pass, skip } counts so the CLI can decide exit code.
479
+ // -----------------------------------------------------------
480
+ function reportA11yForTheme(themeName, audit, indent) {
481
+ const pad = indent || ' ';
482
+ let fail = 0, warn = 0, info = 0, pass = 0, skip = 0;
483
+ for (const r of audit) {
484
+ if (r.status === 'fail') fail++;
485
+ else if (r.status === 'warn') warn++;
486
+ else if (r.status === 'info') info++;
487
+ else if (r.status === 'pass') pass++;
488
+ else skip++;
489
+ }
490
+ let head;
491
+ if (fail > 0) {
492
+ head = red('x') + ' a11y [' + themeName + '] ' + red(fail + ' fail') + ' ' +
493
+ (warn ? yellow(warn + ' warn') + ' ' : '') +
494
+ (info ? dim(info + ' info') + ' ' : '') +
495
+ dim(pass + ' pass') + (skip ? dim(' ' + skip + ' skip') : '');
496
+ } else if (warn > 0) {
497
+ head = yellow('!') + ' a11y [' + themeName + '] ' + yellow(warn + ' warn') + ' ' +
498
+ (info ? dim(info + ' info') + ' ' : '') +
499
+ dim(pass + ' pass') + (skip ? dim(' ' + skip + ' skip') : '');
500
+ } else {
501
+ head = green('✓') + ' a11y [' + themeName + '] ' + dim(pass + '/' + audit.length + ' contract pairs >= AA') +
502
+ (info ? dim(' ' + info + ' info') : '') +
503
+ (skip ? dim(' ' + skip + ' skip') : '');
504
+ }
505
+ console.log(pad + head);
506
+
507
+ for (const r of audit) {
508
+ if (r.status === 'pass') continue;
509
+ const pair = r.pair;
510
+ const ratio = typeof r.ratio === 'number' ? r.ratio.toFixed(2) : '?';
511
+ const required = r.required ? r.required.toFixed(1) : '?';
512
+ if (r.status === 'fail') {
513
+ console.log(pad + ' ' + red('x') + ' ' + bold(pair.fg) + ' on ' + bold(pair.bg) + ' - ' + red(ratio + ':1') + ' (need >= ' + required + ':1, ' + pair.kind + ')');
514
+ console.log(pad + ' ' + dim('fg=' + r.fgRaw + ' bg=' + r.bgRaw + ' - ' + pair.note));
515
+ } else if (r.status === 'warn') {
516
+ console.log(pad + ' ' + yellow('!') + ' ' + bold(pair.fg) + ' on ' + bold(pair.bg) + ' - ' + yellow(ratio + ':1') + ' (passes ' + required + ':1 ' + pair.kind + ', body-text threshold 4.5:1)');
517
+ } else if (r.status === 'info') {
518
+ console.log(pad + ' ' + dim('i') + ' ' + dim(pair.fg + ' on ' + pair.bg + ' - ' + ratio + ':1 (decorative; WCAG 2.2 SC 1.4.11 applies to graphical objects essential for understanding content)'));
519
+ } else if (r.status === 'skip') {
520
+ console.log(pad + ' ' + dim('-') + ' ' + dim(pair.fg + ' / ' + pair.bg + ' - skipped (' + r.skipReason + ')'));
521
+ }
522
+ }
523
+ return { fail: fail, warn: warn, info: info, pass: pass, skip: skip };
524
+ }
525
+
526
+ // -----------------------------------------------------------
527
+ // CLI
528
+ // -----------------------------------------------------------
529
+ function printUsage() {
530
+ const u = [
531
+ 'Usage:',
532
+ ' node scripts/theme-validator.js <path-to-theme.css> [more-paths...]',
533
+ ' node scripts/theme-validator.js --all',
534
+ '',
535
+ 'Each input file is auto-detected as:',
536
+ ' per-file - one or more `:root { ... }` blocks (legacy)',
537
+ ' consolidated - contains `[data-theme="<name>"] { ... }` blocks;',
538
+ ' every block must satisfy the contract.',
539
+ '',
540
+ 'Flags:',
541
+ ' --all validate every theme.css under public/ (CI mode)',
542
+ ' --no-a11y skip the WCAG 2.2 AA contrast audit',
543
+ ' --allow-a11y-fail do NOT exit non-zero on a11y FAILs (report only)',
544
+ ' --strict accepted for backwards compatibility (no-op; FAIL is now the default)',
545
+ '',
546
+ 'Exit codes:',
547
+ ' 0 every file/block declares every required contract token AND all',
548
+ ' audited contrast pairs meet WCAG 2.2 AA (or --allow-a11y-fail).',
549
+ ' 1 one or more files/blocks missing tokens (always fatal), OR an',
550
+ ' a11y FAIL (default behaviour as of v0.7).',
551
+ ' 2 usage error (file not found, bad args)',
552
+ ].join(String.fromCharCode(10));
553
+ console.log(u);
554
+ }
555
+
556
+ function main(argv) {
557
+ const argsRaw = argv.slice(2).filter(function (a) { return a !== '--watch'; });
558
+ if (argsRaw.length === 0 || argsRaw.includes('-h') || argsRaw.includes('--help')) {
559
+ printUsage();
560
+ process.exit(argsRaw.length === 0 ? 2 : 0);
561
+ }
562
+ const wantA11y = !argsRaw.includes('--no-a11y');
563
+ const wantLenient = argsRaw.includes('--allow-a11y-fail');
564
+ // --strict is accepted as a no-op for backwards compatibility — a11y FAIL is now the default
565
+ const args = argsRaw.filter(function (a) {
566
+ return a !== '--no-a11y' && a !== '--strict' && a !== '--allow-a11y-fail';
567
+ });
568
+
569
+ const contract = loadContract();
570
+
571
+ let files = [];
572
+ if (args.includes('--all')) {
573
+ files = discoverAllThemes();
574
+ if (files.length === 0) {
575
+ console.error(red('error:') + ' --all found no theme files to validate');
576
+ process.exit(2);
577
+ }
578
+ } else {
579
+ for (const a of args) {
580
+ if (a.startsWith('--')) continue;
581
+ const resolved = path.resolve(a);
582
+ if (!fs.existsSync(resolved)) {
583
+ console.error(red('error:') + ' file not found: ' + a);
584
+ process.exit(2);
585
+ }
586
+ files.push(resolved);
587
+ }
588
+ if (files.length === 0) {
589
+ printUsage();
590
+ process.exit(2);
591
+ }
592
+ }
593
+
594
+ console.log(dim('theme-validator - contract v' + contract.version + ' (' + contract.required.length + ' required tokens)'));
595
+ if (wantA11y) {
596
+ console.log(dim('a11y - WCAG 2.2 AA contrast audit (' + a11y.AUDIT_PAIRS.length + ' pairs per theme)'));
597
+ } else {
598
+ console.log(dim('a11y - skipped (--no-a11y)'));
599
+ }
600
+ console.log('');
601
+
602
+ let hadFailure = false;
603
+ let totalA11yFail = 0;
604
+ let totalA11yWarn = 0;
605
+ let totalA11yInfo = 0;
606
+ let themeBlocksCounted = 0;
607
+ for (const f of files) {
608
+ const r = validateFile(f, contract, { a11y: wantA11y });
609
+ reportResult(r);
610
+ if (!r.ok || r.error) hadFailure = true;
611
+ if (r.mode === 'consolidated' && r.themes) {
612
+ themeBlocksCounted += r.themes.length;
613
+ if (wantA11y) {
614
+ for (const t of r.themes) {
615
+ if (!t.a11y) continue;
616
+ const counts = reportA11yForTheme(t.name, t.a11y);
617
+ totalA11yFail += counts.fail;
618
+ totalA11yWarn += counts.warn;
619
+ totalA11yInfo += counts.info || 0;
620
+ }
621
+ }
622
+ } else if (!r.error) {
623
+ themeBlocksCounted += 1;
624
+ if (wantA11y && r.a11y) {
625
+ const inferredName = path.basename(path.dirname(r.file)) || path.basename(r.file, '.css');
626
+ const counts = reportA11yForTheme(inferredName, r.a11y, ' ');
627
+ totalA11yFail += counts.fail;
628
+ totalA11yWarn += counts.warn;
629
+ totalA11yInfo += counts.info || 0;
630
+ }
631
+ }
632
+ }
633
+
634
+ const infoTail = totalA11yInfo > 0 ? ', ' + totalA11yInfo + ' decorative info' : '';
635
+
636
+ console.log('');
637
+ if (hadFailure) {
638
+ console.log(red(bold('FAIL')) + dim(' - one or more theme files/blocks are incomplete'));
639
+ process.exit(1);
640
+ }
641
+ if (wantA11y && totalA11yFail > 0) {
642
+ if (wantLenient) {
643
+ console.log(yellow(bold('OK with A11Y FAILS')) + dim(' - ' + files.length + ' file(s) / ' + themeBlocksCounted + ' theme block(s); ' + totalA11yFail + ' pair(s) below AA, ' + totalA11yWarn + ' inside the AA buffer' + infoTail + ' (--allow-a11y-fail)'));
644
+ process.exit(0);
645
+ }
646
+ console.log(red(bold('FAIL')) + dim(' - ' + totalA11yFail + ' contrast pair(s) below WCAG 2.2 AA across all themes. Pass --allow-a11y-fail to downgrade.'));
647
+ process.exit(1);
648
+ }
649
+ if (wantA11y && totalA11yWarn > 0) {
650
+ console.log(yellow(bold('OK with WARN')) + dim(' - ' + files.length + ' file(s) / ' + themeBlocksCounted + ' theme block(s); ' + totalA11yWarn + ' pair(s) inside the AA buffer' + infoTail));
651
+ process.exit(0);
652
+ }
653
+ console.log(green(bold('OK')) + dim(' - ' + files.length + ' file(s) / ' + themeBlocksCounted + ' theme block(s) validated' + (wantA11y ? ' (contract + a11y)' : ' (contract only)') + infoTail));
654
+ process.exit(0);
655
+ }
656
+
657
+ // -----------------------------------------------------------
658
+ // Module exports — some scripts may require() this file. Keep
659
+ // the historical name (`extractDeclaredTokens`) plus the new
660
+ // consolidated helpers.
661
+ // -----------------------------------------------------------
662
+ module.exports = {
663
+ extractDeclaredTokens,
664
+ extractRootBlock,
665
+ extractDataThemeBlocks,
666
+ isConsolidated,
667
+ validateFile,
668
+ loadContract,
669
+ a11y: a11y,
670
+ };
671
+
672
+ if (require.main === module) {
673
+ main(process.argv);
674
+ }