@primer/mcp 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/primitives.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import {readFileSync} from 'node:fs'
2
+ import {createRequire} from 'node:module'
3
+ import {spawn} from 'child_process'
1
4
  import baseMotion from '@primer/primitives/dist/docs/base/motion/motion.json' with {type: 'json'}
2
5
  import baseSize from '@primer/primitives/dist/docs/base/size/size.json' with {type: 'json'}
3
6
  import baseTypography from '@primer/primitives/dist/docs/base/typography/typography.json' with {type: 'json'}
@@ -8,6 +11,16 @@ import functionalSize from '@primer/primitives/dist/docs/functional/size/size.js
8
11
  import light from '@primer/primitives/dist/docs/functional/themes/light.json' with {type: 'json'}
9
12
  import functionalTypography from '@primer/primitives/dist/docs/functional/typography/typography.json' with {type: 'json'}
10
13
 
14
+ // radius.json may not exist in all versions of @primer/primitives
15
+ let functionalSizeRadius: Record<string, {name: string; type: string; value: string}> = {}
16
+ try {
17
+ const require = createRequire(import.meta.url)
18
+ const radiusPath = require.resolve('@primer/primitives/dist/docs/functional/size/radius.json')
19
+ functionalSizeRadius = JSON.parse(readFileSync(radiusPath, 'utf-8'))
20
+ } catch {
21
+ // radius.json not available in this version of @primer/primitives
22
+ }
23
+
11
24
  const categories = {
12
25
  base: {
13
26
  motion: Object.values(baseMotion).map(token => {
@@ -40,6 +53,13 @@ const categories = {
40
53
  value: token.value,
41
54
  }
42
55
  }),
56
+ radius: Object.values(functionalSizeRadius).map(token => {
57
+ return {
58
+ name: token.name,
59
+ type: token.type,
60
+ value: token.value,
61
+ }
62
+ }),
43
63
  sizeCoarse: Object.values(functionalSizeCoarse).map(token => {
44
64
  return {
45
65
  name: token.name,
@@ -85,6 +105,7 @@ const tokens = [
85
105
  ...categories.base.size,
86
106
  ...categories.base.typography,
87
107
  ...categories.functional.border,
108
+ ...categories.functional.radius,
88
109
  ...categories.functional.sizeCoarse,
89
110
  ...categories.functional.sizeFine,
90
111
  ...categories.functional.size,
@@ -100,4 +121,676 @@ function serialize(value: typeof tokens): string {
100
121
  .join('\n')
101
122
  }
102
123
 
103
- export {categories, tokens, serialize}
124
+ // Semantic group prefixes that apply to any element
125
+ const SEMANTIC_PREFIXES = [
126
+ 'bgColor',
127
+ 'fgColor',
128
+ 'border',
129
+ 'borderColor',
130
+ 'shadow',
131
+ 'focus',
132
+ 'color',
133
+ 'animation',
134
+ 'duration',
135
+ ]
136
+
137
+ type TokenGroup = {
138
+ name: string
139
+ count: number
140
+ subGroups?: string[]
141
+ }
142
+
143
+ type TokenGroups = {
144
+ semantic: TokenGroup[]
145
+ component: TokenGroup[]
146
+ }
147
+
148
+ function listTokenGroups(): TokenGroups {
149
+ // Use the full token set so non-theme groups (stack, text, borderRadius, etc.) are included
150
+ const allTokens = tokens
151
+
152
+ // Group tokens by their first segment
153
+ const groupMap = new Map<string, {count: number; subGroups: Set<string>}>()
154
+
155
+ for (const token of allTokens) {
156
+ const parts = token.name.split('-')
157
+ const prefix = parts[0]
158
+
159
+ if (!groupMap.has(prefix)) {
160
+ groupMap.set(prefix, {count: 0, subGroups: new Set()})
161
+ }
162
+
163
+ const group = groupMap.get(prefix)!
164
+ group.count++
165
+
166
+ // For component tokens, track sub-groups (e.g., button-bgColor -> bgColor)
167
+ if (!SEMANTIC_PREFIXES.includes(prefix) && parts.length > 1) {
168
+ const subGroup = parts[1]
169
+ if (SEMANTIC_PREFIXES.includes(subGroup)) {
170
+ group.subGroups.add(subGroup)
171
+ }
172
+ }
173
+ }
174
+
175
+ const semantic: TokenGroup[] = []
176
+ const component: TokenGroup[] = []
177
+
178
+ for (const [name, data] of groupMap.entries()) {
179
+ const group: TokenGroup = {
180
+ name,
181
+ count: data.count,
182
+ }
183
+
184
+ if (data.subGroups.size > 0) {
185
+ group.subGroups = Array.from(data.subGroups).sort()
186
+ }
187
+
188
+ if (SEMANTIC_PREFIXES.includes(name)) {
189
+ semantic.push(group)
190
+ } else {
191
+ component.push(group)
192
+ }
193
+ }
194
+
195
+ // Sort by count descending
196
+ semantic.sort((a, b) => b.count - a.count)
197
+ component.sort((a, b) => b.count - a.count)
198
+
199
+ return {semantic, component}
200
+ }
201
+
202
+ export {categories, tokens, serialize, listTokenGroups, type TokenGroups}
203
+
204
+ // Token with guidelines from markdown
205
+ type TokenWithGuidelines = {
206
+ name: string
207
+ value: string
208
+ useCase: string
209
+ rules: string
210
+ group: string
211
+ }
212
+
213
+ // Parse design tokens spec from new DESIGN_TOKENS_SPEC.md format
214
+ function parseDesignTokensSpec(markdown: string): TokenWithGuidelines[] {
215
+ const results: TokenWithGuidelines[] = []
216
+ const lines = markdown.split('\n')
217
+
218
+ let currentGroup = ''
219
+ let currentToken: Partial<TokenWithGuidelines> | null = null
220
+ let descriptionLines: string[] = []
221
+
222
+ for (const line of lines) {
223
+ // Match group headings (## heading)
224
+ const groupMatch = line.match(/^## (.+)$/)
225
+ if (groupMatch) {
226
+ // Save previous token if exists
227
+ if (currentToken?.name) {
228
+ results.push({
229
+ name: currentToken.name,
230
+ value: getTokenValue(currentToken.name),
231
+ useCase: currentToken.useCase || descriptionLines.join(' '),
232
+ rules: currentToken.rules || '',
233
+ group: currentToken.group || '',
234
+ })
235
+ descriptionLines = []
236
+ }
237
+ currentGroup = groupMatch[1].trim()
238
+ currentToken = null
239
+ continue
240
+ }
241
+
242
+ // Match token name (### tokenName)
243
+ const tokenMatch = line.match(/^### (.+)$/)
244
+ if (tokenMatch) {
245
+ // Save previous token if exists
246
+ if (currentToken?.name) {
247
+ results.push({
248
+ name: currentToken.name,
249
+ value: getTokenValue(currentToken.name),
250
+ useCase: currentToken.useCase || descriptionLines.join(' '),
251
+ rules: currentToken.rules || '',
252
+ group: currentToken.group || '',
253
+ })
254
+ }
255
+ descriptionLines = []
256
+ currentToken = {
257
+ name: tokenMatch[1].trim(),
258
+ group: currentGroup,
259
+ }
260
+ continue
261
+ }
262
+
263
+ // Match new format usage line (**U:**)
264
+ const newUsageMatch = line.match(/^\*\*U:\*\*\s*(.+)$/)
265
+ if (newUsageMatch && currentToken) {
266
+ currentToken.useCase = newUsageMatch[1].trim()
267
+ continue
268
+ }
269
+
270
+ // Match new format rules line (**R:**)
271
+ const newRulesMatch = line.match(/^\*\*R:\*\*\s*(.+)$/)
272
+ if (newRulesMatch && currentToken) {
273
+ currentToken.rules = newRulesMatch[1].trim()
274
+ continue
275
+ }
276
+
277
+ // Description line (line after token name, before U:/R:)
278
+ if (currentToken && !currentToken.useCase && !line.startsWith('**') && line.trim() && !line.startsWith('#')) {
279
+ descriptionLines.push(line.trim())
280
+ }
281
+ }
282
+
283
+ // Don't forget the last token
284
+ if (currentToken?.name) {
285
+ results.push({
286
+ name: currentToken.name,
287
+ value: getTokenValue(currentToken.name),
288
+ useCase: currentToken.useCase || descriptionLines.join(' '),
289
+ rules: currentToken.rules || '',
290
+ group: currentToken.group || '',
291
+ })
292
+ }
293
+
294
+ return results
295
+ }
296
+
297
+ // Get token value from the loaded tokens
298
+ function getTokenValue(tokenName: string): string {
299
+ const found = tokens.find(token => token.name === tokenName)
300
+ return found ? String(found.value) : ''
301
+ }
302
+
303
+ // Human-readable display labels for canonical group prefixes
304
+ const GROUP_LABELS: Record<string, string> = {
305
+ bgColor: 'Background Color',
306
+ fgColor: 'Foreground Color',
307
+ borderColor: 'Border Color',
308
+ border: 'Border',
309
+ shadow: 'Shadow',
310
+ focus: 'Focus',
311
+ color: 'Color',
312
+ borderWidth: 'Border Width',
313
+ borderRadius: 'Border Radius',
314
+ boxShadow: 'Box Shadow',
315
+ controlStack: 'Control Stack',
316
+ fontStack: 'Font Stack',
317
+ outline: 'Outline',
318
+ text: 'Text',
319
+ control: 'Control',
320
+ overlay: 'Overlay',
321
+ stack: 'Stack',
322
+ spinner: 'Spinner',
323
+ }
324
+
325
+ // Get canonical group prefix from token name
326
+ function getGroupFromName(name: string): string {
327
+ return name.split('-')[0]
328
+ }
329
+
330
+ // Build complete token list from JSON (includes all tokens, not just those with guidelines)
331
+ function buildAllTokens(guidelinesTokens: TokenWithGuidelines[]): TokenWithGuidelines[] {
332
+ const guidelinesMap = new Map(guidelinesTokens.map(t => [t.name, t]))
333
+
334
+ // Include theme tokens AND size/typography/border tokens
335
+ const allSourceTokens = [
336
+ ...categories.base.motion,
337
+ ...categories.base.size,
338
+ ...categories.base.typography,
339
+ ...categories.functional.themes.light,
340
+ ...categories.functional.size,
341
+ ...categories.functional.sizeCoarse,
342
+ ...categories.functional.sizeFine,
343
+ ...categories.functional.border,
344
+ ...categories.functional.radius,
345
+ ...categories.functional.typography,
346
+ ]
347
+
348
+ const allTokens: TokenWithGuidelines[] = []
349
+ const seen = new Set<string>()
350
+
351
+ for (const token of allSourceTokens) {
352
+ if (seen.has(token.name)) continue
353
+ seen.add(token.name)
354
+
355
+ const existing = guidelinesMap.get(token.name)
356
+ if (existing) {
357
+ allTokens.push(existing)
358
+ } else {
359
+ allTokens.push({
360
+ name: token.name,
361
+ value: String(token.value),
362
+ useCase: '',
363
+ rules: '',
364
+ group: getGroupFromName(token.name),
365
+ })
366
+ }
367
+ }
368
+
369
+ return allTokens
370
+ }
371
+
372
+ // Find tokens matching query and optional group filter
373
+ function findTokens(allTokens: TokenWithGuidelines[], query: string, group?: string): TokenWithGuidelines[] {
374
+ const lowerQuery = query.toLowerCase()
375
+ const lowerGroup = group?.toLowerCase()
376
+
377
+ return allTokens.filter(token => {
378
+ // Filter by group if provided - exact match only
379
+ if (lowerGroup && token.group.toLowerCase() !== lowerGroup) {
380
+ return false
381
+ }
382
+
383
+ // Fuzzy match against name, useCase, and rules
384
+ const searchText = `${token.name} ${token.useCase} ${token.rules}`.toLowerCase()
385
+ return searchText.includes(lowerQuery)
386
+ })
387
+ }
388
+
389
+ // Expand token patterns like [accent, danger] into multiple tokens
390
+ function expandTokenPattern(token: TokenWithGuidelines): TokenWithGuidelines[] {
391
+ const bracketRegex = /\[(.*?)\]/
392
+ const match = token.name.match(bracketRegex)
393
+
394
+ if (!match) return [token]
395
+
396
+ const variants = match[1].split(',').map((s: string) => s.trim())
397
+ return variants.map(variant => ({
398
+ ...token,
399
+ name: token.name.replace(bracketRegex, variant),
400
+ }))
401
+ }
402
+
403
+ // Load and parse token guidelines, then build complete token list
404
+ function loadAllTokensWithGuidelines(): TokenWithGuidelines[] {
405
+ try {
406
+ const specMarkdown = loadDesignTokensSpec()
407
+ const specTokens = parseDesignTokensSpec(specMarkdown)
408
+ return buildAllTokens(specTokens)
409
+ } catch {
410
+ // DESIGN_TOKENS_SPEC.md not available in this version of @primer/primitives
411
+ return buildAllTokens([])
412
+ }
413
+ }
414
+
415
+ // Load the design tokens guide (logic, rules, patterns, golden examples)
416
+ function loadDesignTokensGuide(): string {
417
+ const require = createRequire(import.meta.url)
418
+ const guidePath = require.resolve('@primer/primitives/DESIGN_TOKENS_GUIDE.md')
419
+ return readFileSync(guidePath, 'utf-8')
420
+ }
421
+
422
+ // Load the design tokens spec (token dictionary with use cases and rules)
423
+ function loadDesignTokensSpec(): string {
424
+ const require = createRequire(import.meta.url)
425
+ const specPath = require.resolve('@primer/primitives/DESIGN_TOKENS_SPEC.md')
426
+ return readFileSync(specPath, 'utf-8')
427
+ }
428
+
429
+ // Get design token specifications text with dynamic group information
430
+ function getDesignTokenSpecsText(groups: TokenGroups): string {
431
+ return `
432
+ # Design Token Specifications
433
+
434
+ ## 1. Core Rule & Enforcement
435
+ * **Expert Mode**: CSS expert. NEVER use raw values (hex, px, etc.). Tokens only.
436
+ * **Motion & Transitions:** Every interactive state change (Hover, Active) MUST include a transition. NEVER use raw values like 200ms or ease-in. Use var(--base-duration-...) and var(--base-easing-...).
437
+ * **Shorthand**: MUST use \`font: var(...)\`. NEVER split size/weight.
438
+ * **Shorthand Fallback**: If no shorthand exists (e.g. Monospace), use individual tokens for font-size, family, and line-height. NEVER raw 1.5.
439
+ * **States**: Define 5: Rest, Hover, Focus-visible, Active, Disabled.
440
+ * **Focus**: \`:focus-visible\` MUST use \`outline: var(--focus-outline)\` AND \`outline-offset: var(--outline-focus-offset)\`.
441
+ * **Validation**: CALL \`lint_css\` after any CSS change. Task is incomplete without a success message.
442
+ * **Self-Correction**: Adopt autofixes immediately. Report unfixable errors to the user.
443
+
444
+ ## 2. Typography Constraints (STRICT)
445
+ - **Body Only**: Only \`body\` group supports size suffixes (e.g., \`body-small\`).
446
+ - **Static Shorthands**: NEVER add suffixes to \`caption\`, \`display\`, \`codeBlock\`, or \`codeInline\`.
447
+
448
+ ## 3. Logic Matrix: Color & Semantic Mapping
449
+ | Input Color/Intent | Semantic Role | Background Suffix | Foreground Requirement |
450
+ | :--- | :--- | :--- | :--- |
451
+ | Blue / Interactive | \`accent\` | \`-emphasis\` (Solid) | \`fgColor-onEmphasis\` |
452
+ | Green / Positive | \`success\` | \`-muted\` (Light) | \`fgColor-{semantic}\` |
453
+ | Red / Danger | \`danger\` | \`-emphasis\` | \`fgColor-onEmphasis\` |
454
+ | Yellow / Warning | \`attention\` | \`-muted\` | \`fgColor-attention\` |
455
+ | Orange / Critical | \`severe\` | \`-emphasis\` | \`fgColor-onEmphasis\` |
456
+ | Purple / Done | \`done\` | Any | Match intent |
457
+ | Pink / Sponsors | \`sponsors\` | Any | Match intent |
458
+ | Grey / Neutral | \`default\` | \`bgColor-muted\` | \`fgColor-default\` (Not muted) |
459
+
460
+ ## 4. Optimization & Recipes (MANDATORY)
461
+ **Strategy**: STOP property-by-property searching. Use \`get_token_group_bundle\` for these common patterns:
462
+ - **Forms**: \`["control", "focus", "outline", "text", "borderRadius", "stack", "animation"]\`
463
+ - **Modals/Cards**: \`["overlay", "shadow", "outline", "borderRadius", "bgColor", "stack", "animation"]\`
464
+ - **Tables/Lists**: \`["stack", "borderColor", "text", "bgColor", "control"]\`
465
+ - **Nav/Sidebars**: \`["control", "text", "accent", "stack", "focus", "animation"]\`
466
+ - **Status/Badges**: \`["text", "success", "danger", "attention", "severe", "stack"]\`
467
+
468
+ ## 5. Available Groups
469
+ - **Semantic**: ${groups.semantic.map(g => `${g.name}\``).join(', ')}
470
+ - **Components**: ${groups.component.map(g => `\`${g.name}\``).join(', ')}
471
+ `.trim()
472
+ }
473
+
474
+ // Get token usage patterns text (static golden examples)
475
+ function getTokenUsagePatternsText(): string {
476
+ return `
477
+ # Design Token Reference Examples
478
+
479
+ > **CRITICAL FOR AI**: To implement the examples below, DO NOT search for tokens one-by-one.
480
+ > Use \`get_token_group_bundle(groups: ["control", "stack", "focus", "borderRadius"])\` to fetch the required token values in a single call.
481
+
482
+ ---
483
+
484
+ ## 1. Interaction Pattern: The Primary Button
485
+ *Demonstrates: 5 states, color pairing, typography shorthand, and motion.*
486
+
487
+ \`\`\`css
488
+ .btn-primary {
489
+ /* Logic: Use control tokens for interactive elements */
490
+ background-color: var(--control-bgColor-rest);
491
+ color: var(--fgColor-default);
492
+ font: var(--text-body-shorthand-medium); /* MUST use shorthand */
493
+
494
+ /* Scale: DEFAULT is medium/normal */
495
+ padding-block: var(--control-medium-paddingBlock);
496
+ padding-inline: var(--control-medium-paddingInline-normal);
497
+ border: none;
498
+ border-radius: var(--borderRadius-medium);
499
+ cursor: pointer;
500
+
501
+ /* Motion: MUST be <300ms */
502
+ transition: background-color 150ms ease, transform 100ms ease;
503
+ }
504
+
505
+ .btn-primary:hover {
506
+ background-color: var(--control-bgColor-hover);
507
+ }
508
+
509
+ .btn-primary:focus-visible {
510
+ outline: var(--focus-outline);
511
+ outline-offset: var(--outline-focus-offset);
512
+ }
513
+
514
+ .btn-primary:active {
515
+ background-color: var(--control-bgColor-active);
516
+ transform: scale(0.98);
517
+ }
518
+
519
+ .btn-primary:disabled {
520
+ /* Logic: MUST pair bgColor-disabled with fgColor-disabled */
521
+ background-color: var(--bgColor-disabled);
522
+ color: var(--fgColor-disabled);
523
+ cursor: not-allowed;
524
+ }
525
+ \`\`\`
526
+
527
+ ---
528
+
529
+ ## 2. Layout Pattern: Vertical Stack
530
+ *Demonstrates: Layout spacing rules and matching padding density.*
531
+
532
+ \`\`\`css
533
+ .card-stack {
534
+ display: flex;
535
+ flex-direction: column;
536
+
537
+ /* Logic: Use stack tokens for layout spacing */
538
+ gap: var(--stack-gap-normal);
539
+ padding: var(--stack-padding-normal);
540
+
541
+ background-color: var(--bgColor-default);
542
+ border: 1px solid var(--borderColor-default);
543
+ border-radius: var(--borderRadius-large);
544
+ }
545
+
546
+ /* Logic: Matching padding density to purpose */
547
+ .card-header {
548
+ padding-block-end: var(--stack-gap-condensed);
549
+ border-bottom: 1px solid var(--borderColor-muted);
550
+ }
551
+ \`\`\`
552
+
553
+ ---
554
+
555
+ ## Implementation Rules for AI:
556
+ 1. **Shorthand First**: Always use \`font: var(...)\` rather than splitting size/weight.
557
+ 2. **States**: Never implement a button without all 5 states.
558
+ 3. **Spacing**: Use \`control-\` tokens for the component itself and \`stack-\` tokens for the container/layout.
559
+ 4. **Motion**: Always include the \`prefers-reduced-motion\` media query to set transitions to \`none\`.
560
+ \`\`\`css
561
+ @media (prefers-reduced-motion: reduce) {
562
+ .btn-primary {
563
+ transition: none;
564
+ }
565
+ }
566
+ \`\`\`
567
+ `.trim()
568
+ }
569
+
570
+ // Search tokens with keyword matching and optional group filter
571
+ // Returns expanded tokens (patterns like [accent, danger] are expanded) filtered by query
572
+ function searchTokens(allTokens: TokenWithGuidelines[], query: string, group?: string): TokenWithGuidelines[] {
573
+ // 1. Flatten and expand all patterns first (e.g., [accent, danger])
574
+ const expandedTokens = allTokens.flatMap(expandTokenPattern)
575
+
576
+ // 2. Prepare keywords and group filter
577
+ const keywords = query
578
+ .toLowerCase()
579
+ .split(/\s+/)
580
+ .filter(k => k.length > 0)
581
+
582
+ // 3. Perform filtered search with keyword splitting (Logical AND)
583
+ return expandedTokens.filter(token => {
584
+ // Combine all relevant metadata into one searchable string
585
+ const searchableText = `${token.name} ${token.useCase} ${token.rules} ${token.group}`.toLowerCase()
586
+
587
+ // Ensure EVERY keyword in the query exists somewhere in this token's metadata
588
+ const matchesKeywords = keywords.every(word => searchableText.includes(word))
589
+
590
+ const matchesGroup = !group || tokenMatchesGroup(token, group)
591
+
592
+ return matchesKeywords && matchesGroup
593
+ })
594
+ }
595
+
596
+ // Alias map: fuzzy/human-readable names → canonical token name prefix
597
+ const GROUP_ALIASES: Record<string, string> = {
598
+ // Identity mappings (canonical prefixes, lowercased key)
599
+ bgcolor: 'bgColor',
600
+ fgcolor: 'fgColor',
601
+ bordercolor: 'borderColor',
602
+ border: 'border',
603
+ shadow: 'shadow',
604
+ focus: 'focus',
605
+ color: 'color',
606
+ button: 'button',
607
+ control: 'control',
608
+ overlay: 'overlay',
609
+ borderradius: 'borderRadius',
610
+ boxshadow: 'boxShadow',
611
+ fontstack: 'fontStack',
612
+ spinner: 'spinner',
613
+
614
+ // Fuzzy aliases
615
+ background: 'bgColor',
616
+ backgroundcolor: 'bgColor',
617
+ bg: 'bgColor',
618
+ foreground: 'fgColor',
619
+ foregroundcolor: 'fgColor',
620
+ textcolor: 'fgColor',
621
+ fg: 'fgColor',
622
+ radius: 'borderRadius',
623
+ rounded: 'borderRadius',
624
+ elevation: 'overlay',
625
+ depth: 'overlay',
626
+ btn: 'button',
627
+ typography: 'text',
628
+ font: 'text',
629
+ text: 'text',
630
+ 'line-height': 'text',
631
+ lineheight: 'text',
632
+ leading: 'text',
633
+
634
+ // Layout & Spacing
635
+ stack: 'stack',
636
+ controlstack: 'controlStack',
637
+ padding: 'stack',
638
+ margin: 'stack',
639
+ gap: 'stack',
640
+ spacing: 'stack',
641
+ layout: 'stack',
642
+
643
+ // State & Interaction
644
+ offset: 'focus',
645
+ outline: 'outline',
646
+ ring: 'focus',
647
+
648
+ // Decoration & Borders
649
+ borderwidth: 'borderWidth',
650
+ line: 'borderColor',
651
+ stroke: 'borderColor',
652
+ separator: 'borderColor',
653
+
654
+ // Color-to-Semantic Intent Mapping
655
+ red: 'danger',
656
+ green: 'success',
657
+ yellow: 'attention',
658
+ orange: 'severe',
659
+ blue: 'accent',
660
+ purple: 'done',
661
+ pink: 'sponsors',
662
+ grey: 'neutral',
663
+ gray: 'neutral',
664
+
665
+ // Descriptive Aliases
666
+ light: 'muted',
667
+ subtle: 'muted',
668
+ dark: 'emphasis',
669
+ strong: 'emphasis',
670
+ intense: 'emphasis',
671
+ bold: 'emphasis',
672
+ vivid: 'emphasis',
673
+ highlight: 'emphasis',
674
+ }
675
+
676
+ // Match a token against a resolved group by checking both the token name prefix and the group label
677
+ function tokenMatchesGroup(token: TokenWithGuidelines, resolvedGroup: string): boolean {
678
+ const rg = resolvedGroup.toLowerCase()
679
+ const tokenPrefix = token.name.split('-')[0].toLowerCase()
680
+ const tokenGroup = token.group.toLowerCase()
681
+ return tokenPrefix === rg || tokenGroup === rg
682
+ }
683
+
684
+ // Group tokens by their group property and format as Markdown
685
+ function formatBundle(bundleTokens: TokenWithGuidelines[]): string {
686
+ const grouped = bundleTokens.reduce<Record<string, TokenWithGuidelines[]>>((acc, token) => {
687
+ const group = GROUP_LABELS[token.group] || token.group || 'Ungrouped'
688
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
689
+ if (!acc[group]) acc[group] = []
690
+ acc[group].push(token)
691
+ return acc
692
+ }, {})
693
+
694
+ return Object.entries(grouped)
695
+ .map(([group, groupTokens]) => {
696
+ const tokenList = groupTokens
697
+ .map(t => {
698
+ const nameLabel = t.value ? `\`${t.name}\` → \`${t.value}\`` : `\`${t.name}\``
699
+ return `- ${nameLabel}\n - **U**: ${t.useCase || '(none)'}\n - **R**: ${t.rules || '(none)'}`
700
+ })
701
+ .join('\n')
702
+ return `## ${group}\n\n${tokenList}`
703
+ })
704
+ .join('\n\n---\n\n')
705
+ }
706
+
707
+ /**
708
+ * Generates a sorted, unique list of group names from the current token cache.
709
+ * Used for "Healing" error messages and the Design System Search Map.
710
+ */
711
+ function getValidGroupsList(validTokens: TokenWithGuidelines[]): string {
712
+ if (validTokens.length === 0) {
713
+ return 'No groups available.'
714
+ }
715
+
716
+ // 1. Extract unique group names
717
+ const uniqueGroups = Array.from(new Set(validTokens.map(t => t.group)))
718
+
719
+ // 2. Sort alphabetically for consistency
720
+ uniqueGroups.sort((a, b) => a.localeCompare(b))
721
+
722
+ // 3. Return as a formatted Markdown string with backticks
723
+ return uniqueGroups.map(g => `\`${g}\``).join(', ')
724
+ }
725
+
726
+ // Usage Guidance Hints
727
+ const groupHints: Record<string, string> = {
728
+ control: '`control` tokens are for form inputs/checkboxes. For buttons, use the `button` group.',
729
+ button: '`button` tokens are for standard triggers. For form-fields, see the `control` group.',
730
+ text: 'STRICT: The following typography groups do NOT support size suffixes (-small, -medium, -large): `caption`, `display`, `codeBlock`, and `codeInline`. STRICT: Use shorthand tokens where possible. If splitting, you MUST fetch line-height tokens (e.g., --text-body-lineHeight-small) instead of using raw numbers.',
731
+ fgColor: 'Use `fgColor` for text. For borders, use `borderColor`.',
732
+ borderWidth:
733
+ '`borderWidth` only has sizing values (thin, thick, thicker). For border *colors*, use the `borderColor` or `border` group.',
734
+ animation:
735
+ 'TRANSITION RULE: Apply duration and easing to the base class, not the :hover state. Standard pairing: `transition: background-color var(--base-duration-200) var(--base-easing-easeInOut);`',
736
+ }
737
+
738
+ // -----------------------------------------------------------------------------
739
+ // Stylelint runner
740
+ // -----------------------------------------------------------------------------
741
+ function runStylelint(css: string): Promise<{stdout: string; stderr: string}> {
742
+ return new Promise((resolve, reject) => {
743
+ const proc = spawn('npx', ['stylelint', '--stdin', '--fix'], {
744
+ stdio: ['pipe', 'pipe', 'pipe'],
745
+ shell: true,
746
+ })
747
+
748
+ let stdout = ''
749
+ let stderr = ''
750
+
751
+ proc.stdout.on('data', (data: Buffer) => {
752
+ stdout += data.toString()
753
+ })
754
+
755
+ proc.stderr.on('data', (data: Buffer) => {
756
+ stderr += data.toString()
757
+ })
758
+
759
+ proc.on('close', code => {
760
+ if (code === 0) {
761
+ resolve({stdout, stderr})
762
+ } else {
763
+ const error = new Error(`Stylelint exited with code ${code}`) as Error & {stdout: string; stderr: string}
764
+ error.stdout = stdout
765
+ error.stderr = stderr
766
+ reject(error)
767
+ }
768
+ })
769
+
770
+ proc.on('error', reject)
771
+
772
+ proc.stdin.write(css)
773
+ proc.stdin.end()
774
+ })
775
+ }
776
+
777
+ export {
778
+ parseDesignTokensSpec,
779
+ findTokens,
780
+ buildAllTokens,
781
+ expandTokenPattern,
782
+ loadAllTokensWithGuidelines,
783
+ loadDesignTokensGuide,
784
+ loadDesignTokensSpec,
785
+ getDesignTokenSpecsText,
786
+ getTokenUsagePatternsText,
787
+ getValidGroupsList,
788
+ searchTokens,
789
+ formatBundle,
790
+ groupHints,
791
+ GROUP_ALIASES,
792
+ GROUP_LABELS,
793
+ tokenMatchesGroup,
794
+ runStylelint,
795
+ type TokenWithGuidelines,
796
+ }