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
package/mcp/server.cjs ADDED
@@ -0,0 +1,1495 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * css-is-awesome MCP Server
4
+ *
5
+ * Exposes the css-is-awesome design system (themes, mixins, functions, tokens,
6
+ * animations, components, recipes, docs) as MCP tools so any agent (Claude
7
+ * Code, Cursor, Aider, Gemini, Copilot, etc.) can discover the API surface
8
+ * and assemble prompts that consume the library correctly.
9
+ *
10
+ * Resource families:
11
+ * Themes: list_themes, get_theme, search_themes
12
+ * Mixins: list_mixins, get_mixin, search_mixins
13
+ * Functions: list_functions, get_function, search_functions
14
+ * Tokens: list_tokens, get_token, search_tokens
15
+ * Animations: list_animations, get_animation
16
+ * Components: list_components, get_component, search_components
17
+ * Recipes: list_recipes, get_recipe
18
+ * Docs: read_llm_txt, read_changelog, read_migration,
19
+ * read_theming, read_agents, read_contract,
20
+ * read_three_tiers, read_readme, read_versioning
21
+ * Sizing: resolve_size
22
+ * Prompt: assemble_prompt(intent[, args])
23
+ *
24
+ * 30 tools total.
25
+ *
26
+ * Discovery model: filesystem scan, no database. Parses SCSS files with
27
+ * focused regex (no full SCSS AST). Tokens come from the authoritative
28
+ * `scripts/theme-contract.json`.
29
+ *
30
+ * Transport: stdio. Usage in a client's .mcp.json:
31
+ * {
32
+ * "mcpServers": {
33
+ * "css-is-awesome": {
34
+ * "command": "node",
35
+ * "args": ["K:/Repo/css-is-awesome/mcp/server.cjs"]
36
+ * }
37
+ * }
38
+ * }
39
+ *
40
+ * Aligned with the canonical sibling MCP shape: ui-ux-builder, ideas-master,
41
+ * video-maker. Response envelope is `{ total, items }` for list/search;
42
+ * get_* tools return the full record.
43
+ */
44
+
45
+ 'use strict';
46
+
47
+ const fs = require('fs');
48
+ const path = require('path');
49
+
50
+ const PROJECT_ROOT = path.resolve(__dirname, '..');
51
+ const SCSS_DIR = path.join(PROJECT_ROOT, 'scss');
52
+ const THEMES_DIR = path.join(SCSS_DIR, 'themes');
53
+ const COMPONENTS_DIR = path.join(SCSS_DIR, 'components');
54
+ const RECIPES_DIR = path.join(SCSS_DIR, 'recipes');
55
+ const SCRIPTS_DIR = path.join(PROJECT_ROOT, 'scripts');
56
+ const THEME_CONTRACT_PATH = path.join(SCRIPTS_DIR, 'theme-contract.json');
57
+
58
+ const SERVER_NAME = 'css-is-awesome';
59
+ // Read from package.json rather than hardcoding — a duplicated literal here
60
+ // silently reports a stale version to every MCP client after a release bump.
61
+ const SERVER_VERSION = (function () {
62
+ try {
63
+ return require(path.join(PROJECT_ROOT, 'package.json')).version;
64
+ } catch (err) {
65
+ return '0.0.0';
66
+ }
67
+ })();
68
+
69
+ // Files that contribute mixins/functions to the public surface.
70
+ // Order matters only for tie-breaking; we tag every parsed entry with its
71
+ // source path so callers can disambiguate same-name overloads if they appear.
72
+ const MIXIN_SOURCES = [
73
+ { file: 'scss/_mixins.scss', category: 'core' },
74
+ { file: 'scss/_layout.scss', category: 'layout' },
75
+ { file: 'scss/_animations.scss', category: 'animation' },
76
+ { file: 'scss/_icons.scss', category: 'icons' },
77
+ { file: 'scss/_generator.scss', category: 'generator' },
78
+ ];
79
+
80
+ // Component directory is enumerated dynamically — every _foo.scss becomes a
81
+ // component "foo" whose mixins are listed under it.
82
+
83
+ // ─── MCP SDK resolution (lazy + friendly errors) ─────────────────────────────
84
+
85
+ function resolveMcp() {
86
+ try { return require('@modelcontextprotocol/sdk/server/mcp.js'); }
87
+ catch {
88
+ throw new Error(
89
+ 'css-is-awesome MCP server: @modelcontextprotocol/sdk is not installed. ' +
90
+ 'Install with: npm install @modelcontextprotocol/sdk zod'
91
+ );
92
+ }
93
+ }
94
+
95
+ function resolveStdio() {
96
+ try { return require('@modelcontextprotocol/sdk/server/stdio.js'); }
97
+ catch {
98
+ throw new Error('css-is-awesome MCP server: @modelcontextprotocol/sdk (stdio) missing — reinstall.');
99
+ }
100
+ }
101
+
102
+ // ─── File helpers ────────────────────────────────────────────────────────────
103
+
104
+ function readFileSafe(absPath) {
105
+ try { return fs.readFileSync(absPath, 'utf8'); }
106
+ catch { return null; }
107
+ }
108
+
109
+ function readFileNormalized(absPath) {
110
+ const raw = readFileSafe(absPath);
111
+ if (raw == null) return null;
112
+ // Strip BOM, normalize CRLF — everything downstream gets clean LF text.
113
+ const noBom = raw.charCodeAt(0) === 0xFEFF ? raw.slice(1) : raw;
114
+ return noBom.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
115
+ }
116
+
117
+ function readJsonSafe(absPath) {
118
+ try { return JSON.parse(fs.readFileSync(absPath, 'utf8')); }
119
+ catch { return null; }
120
+ }
121
+
122
+ function relFromRoot(absPath) {
123
+ return path.relative(PROJECT_ROOT, absPath).replace(/\\/g, '/');
124
+ }
125
+
126
+ function listScssFiles(dir) {
127
+ if (!fs.existsSync(dir)) return [];
128
+ return fs.readdirSync(dir)
129
+ .filter((f) => f.endsWith('.scss'))
130
+ .sort()
131
+ .map((f) => path.join(dir, f));
132
+ }
133
+
134
+ // Recipes come in two kinds:
135
+ // - markdown pattern recipes (<slug>.md) — the v1.0 recipes book: YAML
136
+ // frontmatter + prose + framework examples. A pattern to follow, NOT a
137
+ // SCSS import. Skips _templates and README.md.
138
+ // - opt-in SCSS recipes (_<slug>.scss, e.g. bare-tags) — wire mixins to
139
+ // bare HTML tags (Pico-mode); consumed via @use.
140
+ function listRecipeFiles(dir) {
141
+ if (!fs.existsSync(dir)) return [];
142
+ const out = [];
143
+ for (const f of fs.readdirSync(dir).sort()) {
144
+ if (f.endsWith('.md')) {
145
+ if (f.startsWith('_') || f.toLowerCase() === 'readme.md') continue;
146
+ out.push({ abs: path.join(dir, f), kind: 'md', name: f.slice(0, -3) });
147
+ } else if (f.endsWith('.scss')) {
148
+ out.push({ abs: path.join(dir, f), kind: 'scss', name: path.basename(f, '.scss').replace(/^_/, '') });
149
+ }
150
+ }
151
+ return out;
152
+ }
153
+
154
+ // Minimal flat-YAML frontmatter parser (key: value pairs between --- fences).
155
+ function parseFrontmatter(text) {
156
+ const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
157
+ if (!m) return null;
158
+ const meta = {};
159
+ for (const line of m[1].split(/\r?\n/)) {
160
+ const kv = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
161
+ if (kv) meta[kv[1]] = kv[2].replace(/^["']|["']$/g, '').trim();
162
+ }
163
+ return meta;
164
+ }
165
+
166
+ // Normalize either recipe kind into a single shape for the MCP tools.
167
+ function recipeInfo(file) {
168
+ const text = readFileNormalized(file.abs) || '';
169
+ if (file.kind === 'md') {
170
+ const fm = parseFrontmatter(text) || {};
171
+ const name = fm.name || file.name;
172
+ return {
173
+ name,
174
+ kind: 'md',
175
+ path: relFromRoot(file.abs),
176
+ description: fm.description || firstSentence(text),
177
+ category: fm.category || null,
178
+ complexity: fm.complexity || null,
179
+ usage: `Pattern recipe — read it (get_recipe) and follow it in your stack; humans read it at /docs/recipes/${name}. Not a SCSS import.`,
180
+ body: text,
181
+ };
182
+ }
183
+ const headerLines = [];
184
+ for (const l of text.split('\n')) {
185
+ if (l.startsWith('//')) headerLines.push(l.replace(/^\/\/\s?/, ''));
186
+ else if (l.trim() === '') headerLines.push('');
187
+ else break;
188
+ }
189
+ return {
190
+ name: file.name,
191
+ kind: 'scss',
192
+ path: relFromRoot(file.abs),
193
+ description: firstSentence(headerLines.join('\n')),
194
+ category: null,
195
+ complexity: null,
196
+ usage: `@use 'css-is-awesome/scss/recipes/${file.name}';`,
197
+ body: text,
198
+ };
199
+ }
200
+
201
+ function ensureInProject(fp) {
202
+ const abs = path.resolve(fp);
203
+ if (!abs.startsWith(path.resolve(PROJECT_ROOT))) {
204
+ throw new Error('Path traversal blocked: must stay inside project root');
205
+ }
206
+ return abs;
207
+ }
208
+
209
+ function snippet(body, query) {
210
+ if (!body || !query) return '';
211
+ const hay = body.toLowerCase();
212
+ const idx = hay.indexOf(query.toLowerCase());
213
+ if (idx === -1) return '';
214
+ const start = Math.max(0, idx - 80);
215
+ const end = Math.min(body.length, idx + query.length + 160);
216
+ return (start > 0 ? '…' : '') + body.slice(start, end).replace(/\s+/g, ' ').trim() + (end < body.length ? '…' : '');
217
+ }
218
+
219
+ // ─── SCSS parsers (regex-based, intentionally narrow) ────────────────────────
220
+
221
+ /**
222
+ * Find every `@mixin` and `@function` declaration in a single SCSS file.
223
+ * Returns rich records: name, signature, body (until the matching closing
224
+ * brace), preceding doc comments, line number, and file path.
225
+ *
226
+ * Parser is deliberately simple — it counts braces. Inside-string braces or
227
+ * SCSS interpolation that contains `{` could confuse it. The library's
228
+ * mixin/function bodies are well-behaved so this is safe in practice; if a
229
+ * mixin's body ever genuinely breaks the brace counter, callers can fall
230
+ * back to `read_file_raw`-style consumption via the source path in the record.
231
+ */
232
+ function parseScssDeclarations(absPath) {
233
+ const text = readFileNormalized(absPath);
234
+ if (text == null) return [];
235
+
236
+ const lines = text.split('\n');
237
+ const out = [];
238
+ // Head regex matches the `@mixin/@function name` part; param list and body
239
+ // are handled below so multi-line signatures (very common in cia mixins
240
+ // like `@mixin font($type: reg, $size: null, ...)`) are captured intact.
241
+ const headRegex = /^(@(?:mixin|function))\s+([A-Za-z_][\w-]*)\s*(.*)$/;
242
+
243
+ for (let i = 0; i < lines.length; i++) {
244
+ const line = lines[i];
245
+ const m = line.match(headRegex);
246
+ if (!m) continue;
247
+
248
+ const kind = m[1] === '@mixin' ? 'mixin' : 'function';
249
+ const name = m[2];
250
+
251
+ // Build the param list by walking forward until we either hit `)` at
252
+ // matched depth (for paren-bearing decls) or `{` (for paren-less decls
253
+ // like `@mixin tablet { ... }`). Strips inline `//` comments.
254
+ let paramsRaw = '';
255
+ let cursor = i;
256
+ let parenDepth = 0;
257
+ let sawOpenParen = false;
258
+ let restOfHead = m[3];
259
+ let consumedHeadLine = false;
260
+
261
+ // Pre-scan the trailing chars on the decl line. If it contains `(`,
262
+ // we're in param-collection mode; otherwise the params are simply empty.
263
+ if (restOfHead.includes('(')) {
264
+ // Walk character-by-character across this line and subsequent lines
265
+ // until parenDepth returns to 0 after we've seen the opening paren.
266
+ let collected = '';
267
+ let workLine = restOfHead;
268
+ while (true) {
269
+ for (let ci = 0; ci < workLine.length; ci++) {
270
+ const ch = workLine[ci];
271
+ if (ch === '(') {
272
+ if (sawOpenParen) collected += ch;
273
+ parenDepth++;
274
+ sawOpenParen = true;
275
+ continue;
276
+ }
277
+ if (ch === ')') {
278
+ parenDepth--;
279
+ if (parenDepth === 0) {
280
+ paramsRaw = collected.trim().replace(/\s+/g, ' ');
281
+ cursor = i + (consumedHeadLine ? 1 : 0);
282
+ // we'll find the brace below
283
+ break;
284
+ }
285
+ collected += ch;
286
+ continue;
287
+ }
288
+ if (sawOpenParen) collected += ch;
289
+ }
290
+ if (parenDepth === 0 && sawOpenParen) break;
291
+ // Continue onto next line
292
+ cursor++;
293
+ consumedHeadLine = true;
294
+ if (cursor >= lines.length) break;
295
+ workLine = lines[cursor];
296
+ collected += ' ';
297
+ }
298
+ }
299
+
300
+ const signature = `${kind === 'mixin' ? '@mixin' : '@function'} ${name}(${paramsRaw})`;
301
+
302
+ // Walk back to find a doc comment block (consecutive `//` lines above).
303
+ const docLines = [];
304
+ let j = i - 1;
305
+ while (j >= 0) {
306
+ const prev = lines[j];
307
+ const trimmed = prev.trim();
308
+ if (trimmed.startsWith('//')) {
309
+ docLines.unshift(trimmed.replace(/^\/\/\s?/, ''));
310
+ j--;
311
+ continue;
312
+ }
313
+ // Skip blank lines between a separator banner and the decl; banners
314
+ // like `// ====` we keep, but bail if we hit a non-comment line.
315
+ if (trimmed === '') { j--; continue; }
316
+ break;
317
+ }
318
+ // Drop banner-only "====" lines from the front/back of the doc block.
319
+ while (docLines.length && /^=+$/.test(docLines[0])) docLines.shift();
320
+ while (docLines.length && /^=+$/.test(docLines[docLines.length - 1])) docLines.pop();
321
+
322
+ // Capture body until matching closing brace. Start brace count at 1
323
+ // assuming the opening `{` is on the same line — if it isn't, the
324
+ // outer loop will see the next line(s) start with `{` and we adjust.
325
+ let depth = 0;
326
+ let started = false;
327
+ let bodyStart = i;
328
+ const bodyLines = [];
329
+
330
+ for (let k = i; k < lines.length; k++) {
331
+ const l = lines[k];
332
+ for (const ch of l) {
333
+ if (ch === '{') { depth++; started = true; }
334
+ else if (ch === '}') { depth--; }
335
+ }
336
+ bodyLines.push(l);
337
+ if (started && depth === 0) {
338
+ // Done — `bodyLines` now contains decl line through closing brace.
339
+ out.push({
340
+ kind,
341
+ name,
342
+ params: paramsRaw,
343
+ signature,
344
+ doc: docLines.join('\n').trim(),
345
+ body: bodyLines.join('\n'),
346
+ startLine: bodyStart + 1,
347
+ endLine: k + 1,
348
+ path: relFromRoot(absPath),
349
+ });
350
+ break;
351
+ }
352
+ // Special case: single-line mixin like `@mixin tablet { @include media(md) { @content; } }`
353
+ // — handled by the brace counter naturally.
354
+ }
355
+ }
356
+ return out;
357
+ }
358
+
359
+ /**
360
+ * Pull theme metadata out of a theme file. Themes use `@include m.theme('name') { ... }`
361
+ * (or sometimes the older `:root[data-theme="name"]`). Returns the declared
362
+ * name, the inferred token assignments, and the raw body for callers that
363
+ * want the source.
364
+ */
365
+ function parseThemeFile(absPath) {
366
+ const text = readFileNormalized(absPath);
367
+ if (text == null) return null;
368
+ const baseName = path.basename(absPath, '.scss');
369
+
370
+ // Find theme name from `@include <ns>.theme('name')` (any namespace: m, cia, …)
371
+ // or `[data-theme="name"]`.
372
+ const includeMatch = text.match(/@include\s+[\w-]+\.theme\s*\(\s*['"]([^'"]+)['"]\s*\)/);
373
+ const dataMatch = text.match(/\[data-theme=['"]([^'"]+)['"]\]/);
374
+ const name = (includeMatch && includeMatch[1]) || (dataMatch && dataMatch[1]) || baseName;
375
+
376
+ // Header doc block — top-of-file `// ===` banner + body.
377
+ const headerLines = [];
378
+ const lines = text.split('\n');
379
+ for (const l of lines) {
380
+ if (l.startsWith('//')) headerLines.push(l.replace(/^\/\/\s?/, ''));
381
+ else if (l.trim() === '') headerLines.push('');
382
+ else break;
383
+ }
384
+ const header = headerLines.join('\n').trim();
385
+
386
+ // Collect token assignments: `--token-name: value;` (one per line).
387
+ // The value may span multiple lines for shadow stacks; we just grab the
388
+ // first-line head and let the caller fetch the raw body if they need more.
389
+ const tokens = {};
390
+ const tokenRegex = /^\s*(--[a-zA-Z0-9_-]+)\s*:\s*([^;]+);/gm;
391
+ let m;
392
+ while ((m = tokenRegex.exec(text)) !== null) {
393
+ tokens[m[1]] = m[2].trim().replace(/\s+/g, ' ');
394
+ }
395
+
396
+ const supportsLightDark = /light-dark\s*\(/.test(text);
397
+
398
+ return {
399
+ name,
400
+ file: baseName,
401
+ path: relFromRoot(absPath),
402
+ description: header,
403
+ supportsLightDark,
404
+ tokenCount: Object.keys(tokens).length,
405
+ tokens,
406
+ body: text,
407
+ };
408
+ }
409
+
410
+ /**
411
+ * Theme contract → categorized token list. Categorization is by prefix +
412
+ * a couple of well-known scalar tokens so the LLM can ask "what surface
413
+ * tokens are there?" and get a sensible answer without scanning the whole
414
+ * contract. Counts are never hardcoded here — they are read from
415
+ * `scripts/theme-contract.json`, which is the only source that can't go stale.
416
+ */
417
+ function loadTokenContract() {
418
+ const contract = readJsonSafe(THEME_CONTRACT_PATH);
419
+ if (!contract || !Array.isArray(contract.required)) {
420
+ return { required: [], optional: [], byName: {}, byCategory: {} };
421
+ }
422
+ const optional = Array.isArray(contract.optional) ? contract.optional : [];
423
+
424
+ const byName = {};
425
+ const byCategory = {};
426
+
427
+ const categorize = (name) => {
428
+ // surface / paper
429
+ if (/^--(paper|background|surface)-/.test(name)) return 'surface';
430
+ if (/^--(ink|graphite|muted|text)-?/.test(name)) return 'ink';
431
+ if (/^--(guide|hair|line|border)-?/.test(name)) return 'lines';
432
+ if (/^--action-/.test(name)) return 'action';
433
+ if (/^--(brand|ai|shu|ochre)-?/.test(name)) return 'brand';
434
+ if (/^--code-/.test(name)) return 'code';
435
+ if (/^--font/.test(name)) return 'type';
436
+ if (/^--(line-height|letter-spacing)/.test(name)) return 'type';
437
+ if (/^--radius/.test(name) || /^--r-/.test(name)) return 'radius';
438
+ if (/^--shadow/.test(name)) return 'shadow';
439
+ if (/^--blur/.test(name) || /^--glow/.test(name)) return 'fx';
440
+ if (/^--(duration|ease)/.test(name)) return 'motion';
441
+ if (/^--space/.test(name) || /^--gap/.test(name) || /^--padding/.test(name)) return 'space';
442
+ if (/^--z-/.test(name)) return 'z-index';
443
+ if (/^--(info|success|warning|error|feedback)/.test(name)) return 'semantic';
444
+ if (/^--interactive/.test(name)) return 'interactive';
445
+ if (/^--touch-target/.test(name)) return 'a11y';
446
+ if (/^--logo/.test(name)) return 'brand';
447
+ return 'misc';
448
+ };
449
+
450
+ for (const t of contract.required) {
451
+ const category = categorize(t);
452
+ byName[t] = { name: t, category, required: true };
453
+ (byCategory[category] = byCategory[category] || []).push(t);
454
+ }
455
+ for (const t of optional) {
456
+ const category = categorize(t);
457
+ byName[t] = { name: t, category, required: false };
458
+ (byCategory[category] = byCategory[category] || []).push(t);
459
+ }
460
+
461
+ return { required: contract.required, optional, byName, byCategory };
462
+ }
463
+
464
+ /**
465
+ * Pull the animation vocabulary out of `_animations.scss`. Returns the
466
+ * { slug → keyframe-name } map, the list of speed keys, and the parsed
467
+ * `@mixin animate(...)` / `@mixin animate-on(...)` records so callers can
468
+ * see the full signature without a second round-trip.
469
+ */
470
+ function loadAnimations() {
471
+ const animFile = path.join(SCSS_DIR, '_animations.scss');
472
+ const text = readFileNormalized(animFile);
473
+ if (text == null) {
474
+ return { vocabulary: {}, speeds: [], mixins: [], effects: [] };
475
+ }
476
+
477
+ // Vocabulary block: `$_anims: ( slug: keyframe, ... );`
478
+ const vocab = {};
479
+ const vocabBlock = text.match(/\$_anims:\s*\(([\s\S]*?)\)\s*;/);
480
+ if (vocabBlock) {
481
+ const pairRe = /([a-z][\w-]*)\s*:\s*([a-z][\w-]*)/g;
482
+ let m;
483
+ while ((m = pairRe.exec(vocabBlock[1])) !== null) {
484
+ vocab[m[1]] = m[2];
485
+ }
486
+ }
487
+
488
+ const speeds = [];
489
+ const speedsBlock = text.match(/\$_speeds:\s*\(([\s\S]*?)\)\s*;/);
490
+ if (speedsBlock) {
491
+ const re = /([a-z][\w-]*)\s*:/g;
492
+ let m;
493
+ while ((m = re.exec(speedsBlock[1])) !== null) speeds.push(m[1]);
494
+ }
495
+
496
+ const mixins = parseScssDeclarations(animFile).filter((d) => d.kind === 'mixin');
497
+
498
+ // animate-on effect names — they live in `@if $effect == 'lift'` style
499
+ // branches. Grab them for documentation.
500
+ const effects = [];
501
+ const effectRegex = /\$effect\s*==\s*([a-z][\w-]*)/g;
502
+ let em;
503
+ while ((em = effectRegex.exec(text)) !== null) {
504
+ if (!effects.includes(em[1])) effects.push(em[1]);
505
+ }
506
+
507
+ return { vocabulary: vocab, speeds, mixins, effects };
508
+ }
509
+
510
+ // ─── Aggregators (built once on first call, cheap to rebuild) ────────────────
511
+
512
+ let cachedDeclarations = null;
513
+ let cachedComponents = null;
514
+ let cachedThemes = null;
515
+ let cachedTokens = null;
516
+ let cachedAnimations = null;
517
+
518
+ function getAllDeclarations() {
519
+ if (cachedDeclarations) return cachedDeclarations;
520
+ const out = [];
521
+
522
+ // Core/library files
523
+ for (const { file, category } of MIXIN_SOURCES) {
524
+ const abs = path.join(PROJECT_ROOT, file);
525
+ for (const d of parseScssDeclarations(abs)) {
526
+ out.push({ ...d, category, component: null });
527
+ }
528
+ }
529
+
530
+ // Components (one component per _foo.scss in scss/components/)
531
+ for (const abs of listScssFiles(COMPONENTS_DIR)) {
532
+ const componentName = path.basename(abs, '.scss').replace(/^_/, '');
533
+ if (componentName === 'index') continue;
534
+ for (const d of parseScssDeclarations(abs)) {
535
+ out.push({ ...d, category: 'component', component: componentName });
536
+ }
537
+ }
538
+
539
+ // Recipes
540
+ for (const abs of listScssFiles(RECIPES_DIR)) {
541
+ const recipeName = path.basename(abs, '.scss').replace(/^_/, '');
542
+ for (const d of parseScssDeclarations(abs)) {
543
+ out.push({ ...d, category: 'recipe', component: null, recipe: recipeName });
544
+ }
545
+ }
546
+
547
+ cachedDeclarations = out;
548
+ return out;
549
+ }
550
+
551
+ function getComponents() {
552
+ if (cachedComponents) return cachedComponents;
553
+ const out = [];
554
+ for (const abs of listScssFiles(COMPONENTS_DIR)) {
555
+ const componentName = path.basename(abs, '.scss').replace(/^_/, '');
556
+ if (componentName === 'index') continue;
557
+ const text = readFileNormalized(abs) || '';
558
+ // First doc comment block as the description
559
+ const headerLines = [];
560
+ for (const l of text.split('\n')) {
561
+ if (l.startsWith('//')) headerLines.push(l.replace(/^\/\/\s?/, ''));
562
+ else if (l.trim() === '') headerLines.push('');
563
+ else break;
564
+ }
565
+ const mixins = parseScssDeclarations(abs)
566
+ .filter((d) => d.kind === 'mixin' && !d.name.startsWith('_'))
567
+ .map((d) => d.name);
568
+ out.push({
569
+ name: componentName,
570
+ path: relFromRoot(abs),
571
+ description: headerLines.join('\n').trim(),
572
+ mixinNames: mixins,
573
+ mixinCount: mixins.length,
574
+ });
575
+ }
576
+ cachedComponents = out;
577
+ return out;
578
+ }
579
+
580
+ function getThemes() {
581
+ if (cachedThemes) return cachedThemes;
582
+ const out = [];
583
+ for (const abs of listScssFiles(THEMES_DIR)) {
584
+ const parsed = parseThemeFile(abs);
585
+ if (parsed) out.push(parsed);
586
+ }
587
+ cachedThemes = out;
588
+ return out;
589
+ }
590
+
591
+ function getTokens() {
592
+ if (cachedTokens) return cachedTokens;
593
+ cachedTokens = loadTokenContract();
594
+ return cachedTokens;
595
+ }
596
+
597
+ function getAnimations() {
598
+ if (cachedAnimations) return cachedAnimations;
599
+ cachedAnimations = loadAnimations();
600
+ return cachedAnimations;
601
+ }
602
+
603
+ // ─── Handlers ────────────────────────────────────────────────────────────────
604
+
605
+ function declSummary(d) {
606
+ return {
607
+ name: d.name,
608
+ kind: d.kind,
609
+ category: d.category,
610
+ component: d.component || null,
611
+ signature: d.signature,
612
+ summary: firstSentence(d.doc),
613
+ path: d.path,
614
+ line: d.startLine,
615
+ };
616
+ }
617
+
618
+ function firstSentence(doc) {
619
+ if (!doc) return '';
620
+ // Drop banner/section headers ('============' or '------------'), keep the
621
+ // first real prose line. Also drops lines that are all dashes/equals
622
+ // even if mixed-case (e.g. "----- Section -----" keeps its content).
623
+ const lines = doc.split('\n')
624
+ .map((l) => l.trim())
625
+ .filter((l) => l && !/^[=\-]{3,}$/.test(l));
626
+ const text = (lines[0] || '').replace(/\s+/g, ' ');
627
+ return text.length > 240 ? text.slice(0, 237) + '…' : text;
628
+ }
629
+
630
+ const handlers = {
631
+ // ─── Themes ────────────────────────────────────────────────────────────
632
+
633
+ list_themes() {
634
+ const items = getThemes().map((t) => ({
635
+ name: t.name,
636
+ file: t.file,
637
+ description: firstSentence(t.description),
638
+ supportsLightDark: t.supportsLightDark,
639
+ tokenCount: t.tokenCount,
640
+ path: t.path,
641
+ }));
642
+ return { total: items.length, items };
643
+ },
644
+
645
+ get_theme({ name } = {}) {
646
+ if (!name) throw new Error('get_theme: name is required');
647
+ const needle = String(name).trim();
648
+ const themes = getThemes();
649
+ const found = themes.find((t) => t.name === needle || t.file === needle);
650
+ if (!found) throw new Error(`Unknown theme: ${needle}. Try list_themes.`);
651
+ return {
652
+ name: found.name,
653
+ file: found.file,
654
+ path: found.path,
655
+ description: found.description,
656
+ supportsLightDark: found.supportsLightDark,
657
+ tokenCount: found.tokenCount,
658
+ tokens: found.tokens,
659
+ raw_scss: found.body,
660
+ };
661
+ },
662
+
663
+ search_themes({ query, limit = 50 } = {}) {
664
+ if (!query || typeof query !== 'string') throw new Error('search_themes: query is required');
665
+ const q = query.toLowerCase();
666
+ const matches = [];
667
+ for (const t of getThemes()) {
668
+ const hay = `${t.name}\n${t.file}\n${t.description}\n${Object.keys(t.tokens).join(' ')}\n${Object.values(t.tokens).join(' ')}`.toLowerCase();
669
+ if (hay.includes(q)) {
670
+ matches.push({
671
+ name: t.name,
672
+ file: t.file,
673
+ path: t.path,
674
+ description: firstSentence(t.description),
675
+ snippet: snippet(t.body, query),
676
+ });
677
+ if (matches.length >= limit) break;
678
+ }
679
+ }
680
+ return { total: matches.length, items: matches };
681
+ },
682
+
683
+ // ─── Mixins ────────────────────────────────────────────────────────────
684
+
685
+ list_mixins({ category, component, limit = 500, offset = 0 } = {}) {
686
+ const all = getAllDeclarations()
687
+ .filter((d) => d.kind === 'mixin')
688
+ .filter((d) => !d.name.startsWith('_'))
689
+ .filter((d) => !category || d.category === category)
690
+ .filter((d) => !component || d.component === component)
691
+ .map(declSummary);
692
+ return { total: all.length, items: all.slice(offset, offset + limit) };
693
+ },
694
+
695
+ get_mixin({ name } = {}) {
696
+ if (!name) throw new Error('get_mixin: name is required');
697
+ const needle = String(name).trim();
698
+ const all = getAllDeclarations().filter((d) => d.kind === 'mixin');
699
+ // Prefer exact match on public name (not starting with `_`).
700
+ let found = all.find((d) => d.name === needle && !d.name.startsWith('_'));
701
+ if (!found) found = all.find((d) => d.name === needle);
702
+ if (!found) throw new Error(`Unknown mixin: ${needle}. Try list_mixins.`);
703
+ return {
704
+ name: found.name,
705
+ kind: 'mixin',
706
+ category: found.category,
707
+ component: found.component || null,
708
+ signature: found.signature,
709
+ params: found.params,
710
+ doc: found.doc,
711
+ body: found.body,
712
+ path: found.path,
713
+ startLine: found.startLine,
714
+ endLine: found.endLine,
715
+ };
716
+ },
717
+
718
+ search_mixins({ query, category, limit = 100 } = {}) {
719
+ if (!query || typeof query !== 'string') throw new Error('search_mixins: query is required');
720
+ const q = query.toLowerCase();
721
+ const matches = [];
722
+ for (const d of getAllDeclarations()) {
723
+ if (d.kind !== 'mixin') continue;
724
+ if (d.name.startsWith('_')) continue;
725
+ if (category && d.category !== category) continue;
726
+ const hay = `${d.name}\n${d.signature}\n${d.doc}\n${d.body}`.toLowerCase();
727
+ if (hay.includes(q)) {
728
+ matches.push({ ...declSummary(d), snippet: snippet(`${d.doc}\n${d.body}`, query) });
729
+ if (matches.length >= limit) break;
730
+ }
731
+ }
732
+ return { total: matches.length, items: matches };
733
+ },
734
+
735
+ // ─── Functions ─────────────────────────────────────────────────────────
736
+
737
+ list_functions({ category, limit = 500, offset = 0 } = {}) {
738
+ const all = getAllDeclarations()
739
+ .filter((d) => d.kind === 'function')
740
+ .filter((d) => !d.name.startsWith('_'))
741
+ .filter((d) => !category || d.category === category)
742
+ .map(declSummary);
743
+ return { total: all.length, items: all.slice(offset, offset + limit) };
744
+ },
745
+
746
+ get_function({ name } = {}) {
747
+ if (!name) throw new Error('get_function: name is required');
748
+ const needle = String(name).trim();
749
+ const all = getAllDeclarations().filter((d) => d.kind === 'function');
750
+ let found = all.find((d) => d.name === needle && !d.name.startsWith('_'));
751
+ if (!found) found = all.find((d) => d.name === needle);
752
+ if (!found) throw new Error(`Unknown function: ${needle}. Try list_functions.`);
753
+ return {
754
+ name: found.name,
755
+ kind: 'function',
756
+ category: found.category,
757
+ signature: found.signature,
758
+ params: found.params,
759
+ doc: found.doc,
760
+ body: found.body,
761
+ path: found.path,
762
+ startLine: found.startLine,
763
+ endLine: found.endLine,
764
+ };
765
+ },
766
+
767
+ search_functions({ query, limit = 100 } = {}) {
768
+ if (!query || typeof query !== 'string') throw new Error('search_functions: query is required');
769
+ const q = query.toLowerCase();
770
+ const matches = [];
771
+ for (const d of getAllDeclarations()) {
772
+ if (d.kind !== 'function') continue;
773
+ if (d.name.startsWith('_')) continue;
774
+ const hay = `${d.name}\n${d.signature}\n${d.doc}\n${d.body}`.toLowerCase();
775
+ if (hay.includes(q)) {
776
+ matches.push({ ...declSummary(d), snippet: snippet(`${d.doc}\n${d.body}`, query) });
777
+ if (matches.length >= limit) break;
778
+ }
779
+ }
780
+ return { total: matches.length, items: matches };
781
+ },
782
+
783
+ // ─── Tokens ────────────────────────────────────────────────────────────
784
+
785
+ list_tokens({ category, required, limit = 1000, offset = 0 } = {}) {
786
+ const { byName } = getTokens();
787
+ let items = Object.values(byName);
788
+ if (category) items = items.filter((t) => t.category === category);
789
+ if (typeof required === 'boolean') items = items.filter((t) => t.required === required);
790
+ items = items.sort((a, b) => a.name.localeCompare(b.name));
791
+ return { total: items.length, items: items.slice(offset, offset + limit) };
792
+ },
793
+
794
+ get_token({ name } = {}) {
795
+ if (!name) throw new Error('get_token: name is required');
796
+ const needle = String(name).trim();
797
+ const { byName } = getTokens();
798
+ const withDashes = needle.startsWith('--') ? needle : `--${needle}`;
799
+ const entry = byName[withDashes] || byName[needle];
800
+ if (!entry) throw new Error(`Unknown token: ${needle}. Try list_tokens.`);
801
+ // Look up sample values across themes so the caller sees how the
802
+ // token gets resolved in practice.
803
+ const themeValues = {};
804
+ for (const t of getThemes()) {
805
+ if (t.tokens[entry.name] != null) themeValues[t.name] = t.tokens[entry.name];
806
+ }
807
+ // Find mixins/functions whose body references this token. Match both
808
+ // the full `--name` form (when the mixin emits a CSS custom property)
809
+ // AND the bare `name` form (when the mixin calls `color(name)` or
810
+ // `comp(name, ...)`). Use word boundaries so `--ai` doesn't match
811
+ // every line that happens to contain the letters "ai".
812
+ const bareName = entry.name.replace(/^--/, '');
813
+ const fullRe = new RegExp(`(^|[^A-Za-z0-9_-])${entry.name.replace(/[-]/g, '\\-')}([^A-Za-z0-9_-]|$)`);
814
+ const bareRe = new RegExp(`(^|[^A-Za-z0-9_-])${bareName.replace(/[-]/g, '\\-')}([^A-Za-z0-9_-]|$)`);
815
+ const referencedBy = [];
816
+ for (const d of getAllDeclarations()) {
817
+ if (!d.body) continue;
818
+ if (fullRe.test(d.body) || bareRe.test(d.body)) {
819
+ referencedBy.push({ name: d.name, kind: d.kind, path: d.path });
820
+ }
821
+ }
822
+ return {
823
+ name: entry.name,
824
+ category: entry.category,
825
+ required: entry.required,
826
+ themeValues,
827
+ referencedBy: referencedBy.slice(0, 20),
828
+ };
829
+ },
830
+
831
+ search_tokens({ query, category, limit = 200 } = {}) {
832
+ if (!query || typeof query !== 'string') throw new Error('search_tokens: query is required');
833
+ const q = query.toLowerCase();
834
+ const { byName } = getTokens();
835
+ const matches = [];
836
+ for (const entry of Object.values(byName)) {
837
+ if (category && entry.category !== category) continue;
838
+ if (entry.name.toLowerCase().includes(q) || entry.category.toLowerCase().includes(q)) {
839
+ matches.push(entry);
840
+ if (matches.length >= limit) break;
841
+ }
842
+ }
843
+ return { total: matches.length, items: matches };
844
+ },
845
+
846
+ // ─── Animations ────────────────────────────────────────────────────────
847
+
848
+ list_animations() {
849
+ const { vocabulary, speeds, mixins, effects } = getAnimations();
850
+ const items = Object.entries(vocabulary).map(([slug, keyframe]) => ({
851
+ slug,
852
+ keyframe,
853
+ // The mixin call form most consumers reach for.
854
+ usage: `@include cia.animate(${slug});`,
855
+ }));
856
+ return {
857
+ total: items.length,
858
+ items,
859
+ speeds,
860
+ effects,
861
+ mixins: mixins.map((m) => ({
862
+ name: m.name,
863
+ signature: m.signature,
864
+ summary: firstSentence(m.doc),
865
+ })),
866
+ };
867
+ },
868
+
869
+ get_animation({ name } = {}) {
870
+ if (!name) throw new Error('get_animation: name is required');
871
+ const needle = String(name).trim();
872
+ const { vocabulary, speeds, mixins } = getAnimations();
873
+ if (vocabulary[needle]) {
874
+ return {
875
+ slug: needle,
876
+ keyframe: vocabulary[needle],
877
+ speeds,
878
+ usage: [
879
+ `@include cia.animate(${needle});`,
880
+ `@include cia.animate(${needle}, $speed: slow);`,
881
+ `@include cia.animate(${needle}, $iteration: infinite);`,
882
+ ],
883
+ mixin: mixins.find((m) => m.name === 'animate') ? {
884
+ name: 'animate',
885
+ signature: mixins.find((m) => m.name === 'animate').signature,
886
+ } : null,
887
+ };
888
+ }
889
+ // Allow callers to ask for the `animate` / `animate-on` mixins directly.
890
+ const asMixin = mixins.find((m) => m.name === needle);
891
+ if (asMixin) {
892
+ return {
893
+ name: asMixin.name,
894
+ kind: 'mixin',
895
+ signature: asMixin.signature,
896
+ doc: asMixin.doc,
897
+ body: asMixin.body,
898
+ path: asMixin.path,
899
+ };
900
+ }
901
+ throw new Error(`Unknown animation: ${needle}. Try list_animations.`);
902
+ },
903
+
904
+ // ─── Components ────────────────────────────────────────────────────────
905
+
906
+ list_components() {
907
+ const items = getComponents().map((c) => ({
908
+ name: c.name,
909
+ description: firstSentence(c.description),
910
+ mixinCount: c.mixinCount,
911
+ path: c.path,
912
+ }));
913
+ return { total: items.length, items };
914
+ },
915
+
916
+ get_component({ name } = {}) {
917
+ if (!name) throw new Error('get_component: name is required');
918
+ const needle = String(name).trim();
919
+ const found = getComponents().find((c) => c.name === needle);
920
+ if (!found) throw new Error(`Unknown component: ${needle}. Try list_components.`);
921
+ const mixins = getAllDeclarations()
922
+ .filter((d) => d.component === needle && d.kind === 'mixin' && !d.name.startsWith('_'))
923
+ .map((d) => ({
924
+ name: d.name,
925
+ signature: d.signature,
926
+ summary: firstSentence(d.doc),
927
+ body: d.body,
928
+ }));
929
+ return {
930
+ name: found.name,
931
+ path: found.path,
932
+ description: found.description,
933
+ mixins,
934
+ };
935
+ },
936
+
937
+ search_components({ query, limit = 100 } = {}) {
938
+ if (!query || typeof query !== 'string') throw new Error('search_components: query is required');
939
+ const q = query.toLowerCase();
940
+ const matches = [];
941
+ for (const c of getComponents()) {
942
+ const mixins = getAllDeclarations().filter(
943
+ (d) => d.component === c.name && d.kind === 'mixin' && !d.name.startsWith('_')
944
+ );
945
+ const hay = `${c.name}\n${c.description}\n${mixins
946
+ .map((m) => `${m.name}\n${m.signature}\n${m.doc}\n${m.body}`)
947
+ .join('\n')}`.toLowerCase();
948
+ if (hay.includes(q)) {
949
+ matches.push({
950
+ name: c.name,
951
+ description: firstSentence(c.description),
952
+ mixinCount: c.mixinCount,
953
+ path: c.path,
954
+ snippet: snippet(`${c.description}\n${mixins.map((m) => m.doc).join('\n')}`, query),
955
+ });
956
+ if (matches.length >= limit) break;
957
+ }
958
+ }
959
+ return { total: matches.length, items: matches };
960
+ },
961
+
962
+ // ─── Recipes ───────────────────────────────────────────────────────────
963
+
964
+ list_recipes() {
965
+ const items = listRecipeFiles(RECIPES_DIR).map((f) => {
966
+ const info = recipeInfo(f);
967
+ return {
968
+ name: info.name,
969
+ kind: info.kind,
970
+ category: info.category,
971
+ complexity: info.complexity,
972
+ path: info.path,
973
+ description: info.description,
974
+ usage: info.usage,
975
+ };
976
+ });
977
+ return { total: items.length, items };
978
+ },
979
+
980
+ get_recipe({ name } = {}) {
981
+ if (!name) throw new Error('get_recipe: name is required');
982
+ const needle = String(name).trim().replace(/\.(md|scss)$/, '').replace(/^_/, '');
983
+ const file = listRecipeFiles(RECIPES_DIR).find((f) => f.name === needle);
984
+ if (!file) throw new Error(`Unknown recipe: ${needle}. Try list_recipes.`);
985
+ const info = recipeInfo(file);
986
+ return {
987
+ name: info.name,
988
+ kind: info.kind,
989
+ category: info.category,
990
+ complexity: info.complexity,
991
+ path: info.path,
992
+ description: info.description,
993
+ usage: info.usage,
994
+ body: info.body,
995
+ };
996
+ },
997
+
998
+ // ─── Docs ──────────────────────────────────────────────────────────────
999
+
1000
+ read_llm_txt() { return readDocFile('llm.txt'); },
1001
+ read_changelog() { return readDocFile('CHANGELOG.md'); },
1002
+ read_migration() { return readDocFile('MIGRATION.md'); },
1003
+ read_theming() { return readDocFile('THEMING.md'); },
1004
+ read_agents() { return readDocFile('AGENTS.md'); },
1005
+ read_contract() { return readDocFile('CONTRACT.md'); },
1006
+ read_three_tiers() { return readDocFile('THREE-TIERS.md'); },
1007
+ read_readme() { return readDocFile('README.md'); },
1008
+ read_versioning() { return readDocFile('VERSIONING.md'); },
1009
+
1010
+ // ─── Prompt assembly ───────────────────────────────────────────────────
1011
+
1012
+ /**
1013
+ * Build a ready-to-paste context block for an LLM consuming cia. Intent
1014
+ * picks which slice of the surface gets bundled:
1015
+ * - 'mixin:<name>' → mixin signature + doc + body + token refs
1016
+ * - 'component:<name>' → component file body + all its mixins
1017
+ * - 'theme:<name>' → theme tokens + light-dark notes + raw scss
1018
+ * - 'overview' → llm.txt + theme list + key rules
1019
+ * - 'tokens' → categorized token contract
1020
+ * - 'animations' → animation vocabulary + mixin signature
1021
+ * - 'recipe:<name>' → recipe body + import path
1022
+ */
1023
+ assemble_prompt({ intent, args } = {}) {
1024
+ if (!intent || typeof intent !== 'string') throw new Error('assemble_prompt: intent is required');
1025
+ const [kind, target] = intent.includes(':') ? intent.split(':', 2) : [intent, null];
1026
+
1027
+ const lines = [];
1028
+ const banner = (s) => { lines.push(`# ${s}`); lines.push(''); };
1029
+ const sub = (s) => { lines.push(`## ${s}`); lines.push(''); };
1030
+
1031
+ switch (kind) {
1032
+ case 'overview': {
1033
+ banner('css-is-awesome — agent context');
1034
+ sub('llm.txt (canonical agent intro)');
1035
+ lines.push(readDocFile('llm.txt').body);
1036
+ lines.push('');
1037
+ sub('Themes available');
1038
+ for (const t of getThemes()) {
1039
+ lines.push(`- **${t.name}** — ${firstSentence(t.description) || '(no description)'}`);
1040
+ }
1041
+ lines.push('');
1042
+ sub('Hard rules');
1043
+ lines.push('1. No JavaScript in the cia npm package.');
1044
+ lines.push('2. No `@layer` — Tier 3 bare-tags use `:where()` (0,0,0 specificity), and library token defaults emit under `:where(:root)` so any theme outranks them.');
1045
+ lines.push('3. No BEM. Consumers pick their own selector names.');
1046
+ lines.push('4. One theme = one file, emitting `:root, :root[data-theme="<name>"]`. A single file dropped in as theme.css restyles the page with no markup change; `data-theme` is only required for the multi-theme bundle. Unsuffixed themes carry both modes via `light-dark()`.');
1047
+ lines.push('5. The `cia-` prefix is library-owned.');
1048
+ lines.push('6. Themes declare the numbered spacing scale `--space-0`…`--space-9`; the t-shirt names are optional `var()` aliases.');
1049
+ lines.push('7. `public/theme.css` and `public/themes/**/theme.css` are generated by `npm run build:css:themes` and gated by `npm run check:theme-drift` — never hand-edit them.');
1050
+ break;
1051
+ }
1052
+ case 'mixin': {
1053
+ if (!target) throw new Error('assemble_prompt: intent "mixin:" requires a name');
1054
+ const m = handlers.get_mixin({ name: target });
1055
+ banner(`css-is-awesome mixin: ${m.name}`);
1056
+ sub('Signature');
1057
+ lines.push('```scss');
1058
+ lines.push(m.signature);
1059
+ lines.push('```');
1060
+ lines.push('');
1061
+ if (m.doc) { sub('Documentation'); lines.push(m.doc); lines.push(''); }
1062
+ sub('Body');
1063
+ lines.push('```scss');
1064
+ lines.push(m.body);
1065
+ lines.push('```');
1066
+ lines.push('');
1067
+ sub('Source');
1068
+ lines.push(`${m.path}:${m.startLine}-${m.endLine}`);
1069
+ break;
1070
+ }
1071
+ case 'component': {
1072
+ if (!target) throw new Error('assemble_prompt: intent "component:" requires a name');
1073
+ const c = handlers.get_component({ name: target });
1074
+ banner(`css-is-awesome component: ${c.name}`);
1075
+ if (c.description) { sub('Description'); lines.push(c.description); lines.push(''); }
1076
+ sub(`Public mixins (${c.mixins.length})`);
1077
+ for (const m of c.mixins) {
1078
+ lines.push(`### ${m.name}`);
1079
+ lines.push('');
1080
+ lines.push('```scss');
1081
+ lines.push(m.signature);
1082
+ lines.push('```');
1083
+ if (m.summary) { lines.push(''); lines.push(m.summary); }
1084
+ lines.push('');
1085
+ }
1086
+ sub('Usage pattern');
1087
+ lines.push('```scss');
1088
+ lines.push('@use \'css-is-awesome/api\' as cia;');
1089
+ lines.push('');
1090
+ lines.push(`.my-${c.name} { @include cia.${c.name}; }`);
1091
+ lines.push('```');
1092
+ break;
1093
+ }
1094
+ case 'theme': {
1095
+ if (!target) throw new Error('assemble_prompt: intent "theme:" requires a name');
1096
+ const t = handlers.get_theme({ name: target });
1097
+ banner(`css-is-awesome theme: ${t.name}`);
1098
+ if (t.description) { sub('Intent'); lines.push(t.description); lines.push(''); }
1099
+ sub('Stats');
1100
+ lines.push(`- Tokens declared: ${t.tokenCount}`);
1101
+ lines.push(`- light-dark() pairs: ${t.supportsLightDark ? 'yes' : 'no'}`);
1102
+ lines.push(`- Source: ${t.path}`);
1103
+ lines.push('');
1104
+ sub('Usage');
1105
+ lines.push('```html');
1106
+ lines.push('<!-- Single theme file: data-theme is OPTIONAL (the block also emits a bare :root) -->');
1107
+ lines.push(`<link rel="stylesheet" href="/themes/${t.file}/theme.css">`);
1108
+ lines.push('');
1109
+ lines.push('<!-- Multi-theme bundle (public/theme.css): data-theme is REQUIRED -->');
1110
+ lines.push(`<html data-theme="${t.name}">`);
1111
+ lines.push('```');
1112
+ lines.push('');
1113
+ sub('Token assignments');
1114
+ lines.push('```scss');
1115
+ lines.push(t.raw_scss);
1116
+ lines.push('```');
1117
+ break;
1118
+ }
1119
+ case 'tokens': {
1120
+ const t = getTokens();
1121
+ banner('css-is-awesome — token contract');
1122
+ lines.push(`Required: ${t.required.length}. Optional: ${t.optional.length}.`);
1123
+ lines.push('');
1124
+ sub('By category');
1125
+ const cats = Object.keys(t.byCategory).sort();
1126
+ for (const cat of cats) {
1127
+ lines.push(`### ${cat} (${t.byCategory[cat].length})`);
1128
+ lines.push('');
1129
+ for (const name of t.byCategory[cat].sort()) lines.push(`- ${name}`);
1130
+ lines.push('');
1131
+ }
1132
+ break;
1133
+ }
1134
+ case 'animations': {
1135
+ const a = getAnimations();
1136
+ banner('css-is-awesome — animations');
1137
+ sub('Vocabulary');
1138
+ for (const [slug, keyframe] of Object.entries(a.vocabulary)) {
1139
+ lines.push(`- ${slug} → ${keyframe}`);
1140
+ }
1141
+ lines.push('');
1142
+ sub('Speeds');
1143
+ lines.push(a.speeds.map((s) => `- ${s}`).join('\n'));
1144
+ lines.push('');
1145
+ sub('Effects (animate-on)');
1146
+ lines.push(a.effects.map((e) => `- ${e}`).join('\n'));
1147
+ lines.push('');
1148
+ sub('Usage');
1149
+ lines.push('```scss');
1150
+ lines.push('@include cia.animate(fade-in);');
1151
+ lines.push('@include cia.animate(slide-up, $speed: slow);');
1152
+ lines.push('@include cia.animate-on(hover, lift);');
1153
+ lines.push('```');
1154
+ break;
1155
+ }
1156
+ case 'recipe': {
1157
+ if (!target) throw new Error('assemble_prompt: intent "recipe:" requires a name');
1158
+ const r = handlers.get_recipe({ name: target });
1159
+ banner(`css-is-awesome recipe: ${r.name}`);
1160
+ if (r.description) { sub('Description'); lines.push(r.description); lines.push(''); }
1161
+ sub('Import');
1162
+ lines.push('```scss');
1163
+ lines.push(r.usage);
1164
+ lines.push('```');
1165
+ lines.push('');
1166
+ sub('Body');
1167
+ lines.push('```scss');
1168
+ lines.push(r.body);
1169
+ lines.push('```');
1170
+ break;
1171
+ }
1172
+ default:
1173
+ throw new Error(`assemble_prompt: unknown intent "${intent}". Try overview | mixin:<name> | component:<name> | theme:<name> | tokens | animations | recipe:<name>.`);
1174
+ }
1175
+
1176
+ if (args && typeof args === 'string' && args.trim()) {
1177
+ lines.push('');
1178
+ sub('User input');
1179
+ lines.push(args.trim());
1180
+ }
1181
+
1182
+ return {
1183
+ intent,
1184
+ target: target || null,
1185
+ prompt: lines.join('\n'),
1186
+ };
1187
+ },
1188
+
1189
+ /**
1190
+ * Snap a design px value to cia's 4px geometric grid.
1191
+ * Returns the step number, the SCSS call to emit (m.grid(n) when the
1192
+ * value is exactly on the grid; m.px(value) as the off-grid fallback),
1193
+ * and a human-readable note for AI consumers.
1194
+ *
1195
+ * Contract: AI agents receiving a px value from a design tool (Figma,
1196
+ * mockup, screenshot) should call this and emit the returned scssCall
1197
+ * in their generated SCSS — never raw rem/px literals when a cia
1198
+ * function applies.
1199
+ */
1200
+ resolve_size({ px, base = 4 } = {}) {
1201
+ if (typeof px !== 'number' || !isFinite(px) || px < 0) {
1202
+ throw new Error('resolve_size: px must be a non-negative finite number');
1203
+ }
1204
+ if (typeof base !== 'number' || base <= 0) {
1205
+ throw new Error('resolve_size: base must be a positive number (default 4)');
1206
+ }
1207
+ const step = Math.round(px / base);
1208
+ const snappedPx = step * base;
1209
+ const exact = snappedPx === px;
1210
+ const remValue = step * (base / 16); // assumes 16px root font-size
1211
+ const rawRem = px / 16;
1212
+ return {
1213
+ px,
1214
+ base,
1215
+ step,
1216
+ exact,
1217
+ rem: Number(remValue.toFixed(6)),
1218
+ scssCall: exact ? `cia.grid(${step})` : `cia.px(${px})`,
1219
+ alternative: exact ? null : `cia.grid(${step}) // snaps to ${snappedPx}px (${Number(remValue.toFixed(4))}rem)`,
1220
+ notes: exact
1221
+ ? `${px}px is exactly on cia's 4px grid at step ${step}. Use cia.grid(${step}) — emits ${Number(remValue.toFixed(4))}rem.`
1222
+ : `${px}px is OFF cia's 4px grid (nearest step ${step} = ${snappedPx}px). Two options: (a) cia.px(${px}) emits ${Number(rawRem.toFixed(4))}rem off-grid, or (b) cia.grid(${step}) snaps to ${snappedPx}px which is ${Number(remValue.toFixed(4))}rem. Prefer (b) unless the design intent specifically requires the off-grid value.`,
1223
+ };
1224
+ },
1225
+ };
1226
+
1227
+ function readDocFile(name) {
1228
+ const abs = path.join(PROJECT_ROOT, name);
1229
+ ensureInProject(abs);
1230
+ if (!fs.existsSync(abs)) {
1231
+ return { path: name, exists: false, body: '' };
1232
+ }
1233
+ return {
1234
+ path: name,
1235
+ exists: true,
1236
+ body: readFileNormalized(abs) || '',
1237
+ };
1238
+ }
1239
+
1240
+ // ─── MCP wiring ──────────────────────────────────────────────────────────────
1241
+
1242
+ async function startServer() {
1243
+ const { McpServer } = resolveMcp();
1244
+ const { StdioServerTransport } = resolveStdio();
1245
+ const { z } = require('zod');
1246
+
1247
+ const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION });
1248
+ const ok = (payload) => ({ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] });
1249
+
1250
+ // ─── Derived facts for tool descriptions ───────────────────────────────
1251
+ // Counts in a description are read from the source of truth, never typed
1252
+ // in. A hardcoded number here is exactly the thing that goes stale and
1253
+ // then teaches every connected agent something false — the token contract
1254
+ // sat at "123" through two contract bumps.
1255
+ const _tokens = getTokens();
1256
+ const REQUIRED_TOKENS = _tokens.required.length;
1257
+ const OPTIONAL_TOKENS = _tokens.optional.length;
1258
+ const TOKEN_CATEGORIES = Object.keys(_tokens.byCategory).sort().join(', ');
1259
+ const _themes = getThemes();
1260
+ const THEME_COUNT = _themes.length;
1261
+ const THEME_NAMES = _themes.map((t) => t.name).join(', ');
1262
+ const _decls = getAllDeclarations().filter((d) => !d.name.startsWith('_'));
1263
+ const MIXIN_COUNT = _decls.filter((d) => d.kind === 'mixin').length;
1264
+ const FUNCTION_COUNT = _decls.filter((d) => d.kind === 'function').length;
1265
+ const COMPONENT_NAMES = getComponents().map((c) => c.name).join(', ');
1266
+
1267
+ // Themes
1268
+ server.registerTool('list_themes', {
1269
+ description:
1270
+ `List all ${THEME_COUNT} shipped themes (${THEME_NAMES}). ` +
1271
+ 'Eight families; each ships an unsuffixed parent carrying both modes via light-dark() plus pinned -light and -dark siblings (terminal is the exception — its unsuffixed file is dark-only). ' +
1272
+ 'Every theme emits `:root, :root[data-theme="<name>"]`, so ONE theme file dropped in as theme.css restyles the page with no markup change; the data-theme attribute is only required when several themes share a document (public/theme.css, the bundle).',
1273
+ inputSchema: {},
1274
+ }, async () => ok(handlers.list_themes()));
1275
+
1276
+ server.registerTool('get_theme', {
1277
+ description:
1278
+ 'Return one theme: declared token assignments, raw SCSS body, light-dark() support flag, and source path. ' +
1279
+ 'Themes are authored through `@mixin theme($name, $scheme: light dark, $standalone: true)`; $standalone: false drops the bare :root for multi-theme bundles. Never hand-write the selector.',
1280
+ inputSchema: { name: z.string().describe('Theme name (e.g. "boilerplate", "terminal", "press", "sketchbook-dark").') },
1281
+ }, async (a) => ok(handlers.get_theme(a || {})));
1282
+
1283
+ server.registerTool('search_themes', {
1284
+ description: 'Substring search across theme names, descriptions, and token values.',
1285
+ inputSchema: {
1286
+ query: z.string(),
1287
+ limit: z.number().int().min(1).max(50).optional(),
1288
+ },
1289
+ }, async (a) => ok(handlers.search_themes(a || {})));
1290
+
1291
+ // Mixins
1292
+ server.registerTool('list_mixins', {
1293
+ description: `List all ${MIXIN_COUNT} public @mixins across core, layout, animation, icons, generator, per-component and recipe sources. Filter by category (core/layout/animation/icons/generator/component/recipe) or component name.`,
1294
+ inputSchema: {
1295
+ category: z.string().optional(),
1296
+ component: z.string().optional().describe('Restrict to one component (e.g. "buttons", "overlay", "forms").'),
1297
+ limit: z.number().int().min(1).max(2000).optional(),
1298
+ offset: z.number().int().min(0).optional(),
1299
+ },
1300
+ }, async (a) => ok(handlers.list_mixins(a || {})));
1301
+
1302
+ server.registerTool('get_mixin', {
1303
+ description: 'Return one mixin: signature, parameter list, doc comment, full body, source path + line range.',
1304
+ inputSchema: { name: z.string().describe('Mixin name (e.g. "btn", "card", "animate", "wrap").') },
1305
+ }, async (a) => ok(handlers.get_mixin(a || {})));
1306
+
1307
+ server.registerTool('search_mixins', {
1308
+ description: 'Substring search across mixin names, signatures, docs, and bodies.',
1309
+ inputSchema: {
1310
+ query: z.string(),
1311
+ category: z.string().optional(),
1312
+ limit: z.number().int().min(1).max(200).optional(),
1313
+ },
1314
+ }, async (a) => ok(handlers.search_mixins(a || {})));
1315
+
1316
+ // Functions
1317
+ server.registerTool('list_functions', {
1318
+ description: `List all ${FUNCTION_COUNT} public @functions (color, space, radius, shadow, font-size, z, etc.). Same shape as list_mixins.`,
1319
+ inputSchema: {
1320
+ category: z.string().optional(),
1321
+ limit: z.number().int().min(1).max(2000).optional(),
1322
+ offset: z.number().int().min(0).optional(),
1323
+ },
1324
+ }, async (a) => ok(handlers.list_functions(a || {})));
1325
+
1326
+ server.registerTool('get_function', {
1327
+ description: 'Return one function: signature, parameters, doc, body, source location.',
1328
+ inputSchema: { name: z.string().describe('Function name (e.g. "color", "space", "radius", "shadow").') },
1329
+ }, async (a) => ok(handlers.get_function(a || {})));
1330
+
1331
+ server.registerTool('search_functions', {
1332
+ description: 'Substring search across function names, signatures, docs, and bodies.',
1333
+ inputSchema: {
1334
+ query: z.string(),
1335
+ limit: z.number().int().min(1).max(200).optional(),
1336
+ },
1337
+ }, async (a) => ok(handlers.search_functions(a || {})));
1338
+
1339
+ // Tokens
1340
+ server.registerTool('list_tokens', {
1341
+ description:
1342
+ `List the CSS custom-property tokens in the theme contract (scripts/theme-contract.json): ${REQUIRED_TOKENS} required + ${OPTIONAL_TOKENS} optional = ${REQUIRED_TOKENS + OPTIONAL_TOKENS} total. ` +
1343
+ `Categories: ${TOKEN_CATEGORIES}. ` +
1344
+ 'Spacing note: the NUMBERED scale --space-0…--space-9 is required and is what components read (cia.space(4) → var(--space-4)); the t-shirt names --space-2xs/xs/sm/md/lg/xl are optional aliases the library emits as var() references. Theme the numbered step, not the alias. ' +
1345
+ 'Radius note: --radius-avatar/badge/button/card/input/modal were removed (nothing read them). The working per-component knobs are --btn-radius, --card-radius, --input-radius, --modal-radius, --badge-radius, --tag-radius, each cascading from a generic radius (e.g. --btn-radius: var(--radius-md, 0.25rem)).',
1346
+ inputSchema: {
1347
+ category: z.string().optional(),
1348
+ required: z.boolean().optional().describe('Filter to required-only (true) or optional-only (false).'),
1349
+ limit: z.number().int().min(1).max(2000).optional(),
1350
+ offset: z.number().int().min(0).optional(),
1351
+ },
1352
+ }, async (a) => ok(handlers.list_tokens(a || {})));
1353
+
1354
+ server.registerTool('get_token', {
1355
+ description: 'Return one token: category, required flag, sample values across all themes, and the list of mixins/functions that reference it.',
1356
+ inputSchema: { name: z.string().describe('Token name with or without the leading "--" (e.g. "--action-primary-default" or "action-primary-default").') },
1357
+ }, async (a) => ok(handlers.get_token(a || {})));
1358
+
1359
+ server.registerTool('search_tokens', {
1360
+ description: 'Substring search across token names and categories.',
1361
+ inputSchema: {
1362
+ query: z.string(),
1363
+ category: z.string().optional(),
1364
+ limit: z.number().int().min(1).max(500).optional(),
1365
+ },
1366
+ }, async (a) => ok(handlers.search_tokens(a || {})));
1367
+
1368
+ // Animations
1369
+ server.registerTool('list_animations', {
1370
+ description: 'List the animation vocabulary (fade-in, slide-up, scale-in, pop, pulse, shimmer, spin, wiggle, …), the speed keys (fast/normal/slow), the animate-on effects (lift/glow/press/fade), and the mixin signatures.',
1371
+ inputSchema: {},
1372
+ }, async () => ok(handlers.list_animations()));
1373
+
1374
+ server.registerTool('get_animation', {
1375
+ description: 'Return one animation slug → keyframe mapping with usage examples, or the animate/animate-on mixin record by name.',
1376
+ inputSchema: { name: z.string().describe('Animation slug (e.g. "fade-in") or mixin name ("animate", "animate-on").') },
1377
+ }, async (a) => ok(handlers.get_animation(a || {})));
1378
+
1379
+ // Components
1380
+ server.registerTool('list_components', {
1381
+ description: `List every component file under scss/components/ (${COMPONENT_NAMES}).`,
1382
+ inputSchema: {},
1383
+ }, async () => ok(handlers.list_components()));
1384
+
1385
+ server.registerTool('get_component', {
1386
+ description: 'Return one component: description, all its public mixins (signature + summary + body), and the source path.',
1387
+ inputSchema: { name: z.string().describe('Component name (e.g. "buttons", "overlay", "forms").') },
1388
+ }, async (a) => ok(handlers.get_component(a || {})));
1389
+
1390
+ server.registerTool('search_components', {
1391
+ description: 'Substring search across component names, descriptions, and their mixin names/signatures/docs/bodies.',
1392
+ inputSchema: {
1393
+ query: z.string(),
1394
+ limit: z.number().int().min(1).max(200).optional(),
1395
+ },
1396
+ }, async (a) => ok(handlers.search_components(a || {})));
1397
+
1398
+ // Recipes
1399
+ server.registerTool('list_recipes', {
1400
+ description: 'List recipes from scss/recipes/. Two kinds: markdown pattern recipes (kind:"md" — dialog, combobox, print-to-pdf: a pattern to follow in any framework, with category + complexity) and opt-in SCSS recipes (kind:"scss" — e.g. bare-tags, consumed via @use).',
1401
+ inputSchema: {},
1402
+ }, async () => ok(handlers.list_recipes()));
1403
+
1404
+ server.registerTool('get_recipe', {
1405
+ description: 'Return one recipe: name, kind ("md" pattern recipe or "scss" import), category/complexity, usage, and full body (markdown for md recipes, SCSS for scss recipes).',
1406
+ inputSchema: { name: z.string().describe('Recipe slug (e.g. "print-to-pdf", "combobox", "bare-tags").') },
1407
+ }, async (a) => ok(handlers.get_recipe(a || {})));
1408
+
1409
+ // Docs (each as its own tool so callers don't need to guess paths)
1410
+ server.registerTool('read_llm_txt', {
1411
+ description: 'Return llm.txt — the canonical single-fetch summary for AI agents.',
1412
+ inputSchema: {},
1413
+ }, async () => ok(handlers.read_llm_txt()));
1414
+
1415
+ server.registerTool('read_changelog', {
1416
+ description: 'Return CHANGELOG.md — full release history including breaking-change notes.',
1417
+ inputSchema: {},
1418
+ }, async () => ok(handlers.read_changelog()));
1419
+
1420
+ server.registerTool('read_migration', {
1421
+ description: 'Return MIGRATION.md — v0.7 → v0.8 migration guide (renames, removed features, breaking changes).',
1422
+ inputSchema: {},
1423
+ }, async () => ok(handlers.read_migration()));
1424
+
1425
+ server.registerTool('read_theming', {
1426
+ description: 'Return THEMING.md — theme authoring + override patterns.',
1427
+ inputSchema: {},
1428
+ }, async () => ok(handlers.read_theming()));
1429
+
1430
+ server.registerTool('read_agents', {
1431
+ description: 'Return AGENTS.md — entry point for AI coding agents (rules, tiers, quick decisions).',
1432
+ inputSchema: {},
1433
+ }, async () => ok(handlers.read_agents()));
1434
+
1435
+ server.registerTool('read_contract', {
1436
+ description: 'Return CONTRACT.md — human-readable token contract.',
1437
+ inputSchema: {},
1438
+ }, async () => ok(handlers.read_contract()));
1439
+
1440
+ server.registerTool('read_three_tiers', {
1441
+ description: 'Return THREE-TIERS.md — the three authoring tiers (mixin / utility class / bare tag).',
1442
+ inputSchema: {},
1443
+ }, async () => ok(handlers.read_three_tiers()));
1444
+
1445
+ server.registerTool('read_readme', {
1446
+ description: 'Return README.md — top-level install + usage.',
1447
+ inputSchema: {},
1448
+ }, async () => ok(handlers.read_readme()));
1449
+
1450
+ server.registerTool('read_versioning', {
1451
+ description: 'Return VERSIONING.md — semver policy, deprecation lifecycle, and the Conventional Commits to changelog mapping.',
1452
+ inputSchema: {},
1453
+ }, async () => ok(handlers.read_versioning()));
1454
+
1455
+ // Prompt assembly
1456
+ server.registerTool('assemble_prompt', {
1457
+ description: 'Build a ready-to-paste context block for an LLM consuming cia. Intent picks the slice: "overview" | "mixin:<name>" | "component:<name>" | "theme:<name>" | "tokens" | "animations" | "recipe:<name>".',
1458
+ inputSchema: {
1459
+ intent: z.string().describe('Intent string. Examples: "overview", "mixin:btn", "component:overlay", "theme:terminal", "tokens", "animations", "recipe:bare-tags".'),
1460
+ args: z.string().optional().describe('Optional user input appended to the assembled block.'),
1461
+ },
1462
+ }, async (a) => ok(handlers.assemble_prompt(a || {})));
1463
+
1464
+ // Size resolution — map design px values to cia's 4px geometric grid.
1465
+ server.registerTool('resolve_size', {
1466
+ description: 'Snap a design px value to cia\'s 4px geometric grid. Returns the step number, the SCSS call to emit (cia.grid(n) when exactly on grid; cia.px(value) when off-grid), the equivalent rem, and a human-readable note. AI agents: call this whenever you get a px value from a design tool (Figma, mockup, screenshot) and need to express it in cia code. NEVER write raw rem/px literals when a cia function applies. See /docs/composition for the full decision tree.',
1467
+ inputSchema: {
1468
+ px: z.number().describe('The px value from the design (e.g. 24 for a 24px button height).'),
1469
+ base: z.number().optional().describe('Grid base in px (default 4, matching cia\'s 4px grid).'),
1470
+ },
1471
+ }, async (a) => ok(handlers.resolve_size(a || {})));
1472
+
1473
+ const transport = new StdioServerTransport();
1474
+ await server.connect(transport);
1475
+ }
1476
+
1477
+ if (require.main === module) {
1478
+ startServer().catch((err) => {
1479
+ // stderr is fine — stdio MCP transport owns stdout for protocol traffic.
1480
+ console.error(`css-is-awesome MCP server failed: ${err.message}`);
1481
+ process.exit(1);
1482
+ });
1483
+ }
1484
+
1485
+ module.exports = {
1486
+ handlers,
1487
+ startServer,
1488
+ SERVER_NAME,
1489
+ SERVER_VERSION,
1490
+ // Exported for ad-hoc tests / external introspection.
1491
+ parseScssDeclarations,
1492
+ parseThemeFile,
1493
+ loadTokenContract,
1494
+ loadAnimations,
1495
+ };