@starklab/stark-mcp 0.2.0 → 0.3.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.
@@ -0,0 +1,814 @@
1
+ /**
2
+ * Whisperer — the design-system facts an answer is allowed to be built on,
3
+ * and the instructions they are handed to a model under.
4
+ *
5
+ * Whisperer answers questions about *this* design system, so the model is
6
+ * never asked to recall one. Everything it may assert is assembled here from
7
+ * the same generated files the rest of the toolchain validates against. This
8
+ * module is pure: it takes those files as data and returns the grounding
9
+ * functions, so the one code path serves every surface — the MCP tool and the
10
+ * CLI load the files from the installed `@starklab/stk` (see data.js), and
11
+ * Dominion's chat panel hands in the JSON its bundler already carries.
12
+ *
13
+ * - `catalog.json` — *which symbols exist*, on which platform, and
14
+ * what kind each one is. This is the existence
15
+ * authority; the manifest is built from it too,
16
+ * and the union below is kept so that a component
17
+ * the manifest has not yet been regenerated for
18
+ * still exists here.
19
+ * - `manifest.json` — the prose usage docs (dos/donts), per platform:
20
+ * `usage` is the web file, and `nativeUsage` the
21
+ * React Native one, which is its own document —
22
+ * its own rules, a typed prop table and a code
23
+ * example — because the RN port's props differ
24
+ * from web's for most components.
25
+ * - `component-props.json` — the *real* React signatures, generated from the
26
+ * component sources; the prop mapping files are a
27
+ * Figma contract and drift from the signature on
28
+ * purpose.
29
+ * - `json-meta` — every token: its resolved value, the layer its
30
+ * source file sits in, the token it aliases, the
31
+ * sentence written beside it and the name React
32
+ * Native exports it under.
33
+ * - `json-dark` — the value a token takes in dark mode, for the
34
+ * tokens the dark overlay redefines.
35
+ *
36
+ * Three rules hold this together, and all three are load-bearing:
37
+ *
38
+ * 1. **The full catalog always goes in, the detail never does.** Every name,
39
+ * its one-line description and its prop names cost ~20 KB and are what
40
+ * stop the model inventing a component or a prop that does not exist — or
41
+ * denying one that does. The per-component detail — usage, props, tokens
42
+ * — averages 4.5 KB each, so only the components the conversation is
43
+ * about are expanded — the ones the question names, then the most
44
+ * recently discussed — capped at MAX_DETAIL.
45
+ * 2. **The semantic layer and the scales are listed; the colour primitives
46
+ * are not.** The layer comes from the token's source directory, not from
47
+ * a list of family names kept here — a family added to `tokens/semantic/`
48
+ * is in the next build's list without anyone remembering this file (the
49
+ * chart palette was missing for exactly that reason). The 192
50
+ * `color-shade-*` primitives are deliberately withheld: CLAUDE.md's layer
51
+ * hierarchy says a component token must alias a semantic token and never
52
+ * a primitive, and a list the model can read from is a list it will
53
+ * suggest from. The twelve `color-brand-palette-*` primitives are the
54
+ * exception, listed under their own heading with the same warning,
55
+ * because "what is the exact brand colour?" deserves its real answer.
56
+ * Every primitive is still *resolved* when a question names it outright,
57
+ * so "what is `--stk-color-shade-palette-1-8`?" gets one too.
58
+ * 3. **Nothing here reads the questioner's code.** This module is the design
59
+ * system, not the workspace; what a consumer knows about its own code is
60
+ * its own business (Dominion's Whisperer knows nothing of it, by decision
61
+ * #23; an editor agent holding the MCP tool knows all of it).
62
+ */
63
+
64
+ /**
65
+ * @typedef {{ dos?: string[], donts?: string[], codeExample?: string }} UsageDoc
66
+ *
67
+ * @typedef {object} NativeUsageProp
68
+ * One row of a React Native usage file's prop table. The RN files enumerate
69
+ * their props (the web files do not — web's values come from the mapping
70
+ * file), and `type` there is the TypeScript-ish shape the author wrote:
71
+ * `'sm' | 'md' | 'lg'`, `ReactNode`, `(id: string) => void`.
72
+ * @property {string} name
73
+ * @property {string} type
74
+ * @property {string} [default]
75
+ * @property {boolean | string} [required] `true`, or the condition under which it is required.
76
+ * @property {string} [description]
77
+ *
78
+ * @typedef {UsageDoc & { props?: NativeUsageProp[] }} NativeUsageDoc
79
+ *
80
+ * @typedef {object} PropMapEntry
81
+ * One entry of a prop-mapping file's `propMap`, as the manifest republishes
82
+ * it. `values` is the whole point: for an enum it is every value the prop
83
+ * accepts, and it exists nowhere else the grounding reads — the React
84
+ * signature only carries a name and a default.
85
+ * @property {string} react
86
+ * @property {string} [type]
87
+ * @property {Record<string, string>} [values]
88
+ * @property {string} [transform] `presence`: the Figma boolean is derived from a React node being passed.
89
+ * @property {string} [description]
90
+ * @property {boolean} [componentProp] `false` when the Figma property has no React prop of that name.
91
+ *
92
+ * @typedef {object} ManifestComponent
93
+ * @property {string} name
94
+ * @property {string} slug
95
+ * @property {string} status
96
+ * @property {string} description
97
+ * @property {string[]} [platforms]
98
+ * @property {UsageDoc | null} [usage]
99
+ * @property {NativeUsageDoc | null} [nativeUsage]
100
+ * @property {{ web?: { propMap?: PropMapEntry[] } | null } | null} [props]
101
+ * @property {Record<string, unknown> | null} [tokens]
102
+ *
103
+ * @typedef {{ name: string, hasDefault?: boolean, defaultSource?: string, rest?: boolean }} PropEntry
104
+ * @typedef {{ package?: string, components?: Record<string, { props?: PropEntry[] }> }} SignaturePlatform
105
+ *
106
+ * @typedef {object} TokenMeta
107
+ * @property {unknown} value
108
+ * @property {string} type
109
+ * @property {'primitive' | 'semantic' | 'component' | null} layer
110
+ * @property {string | null} alias
111
+ * @property {string | null} description
112
+ * @property {{ name: string, module: string } | null} rn
113
+ *
114
+ * @typedef {object} WhispererData
115
+ * @property {{ components: ManifestComponent[] }} manifest
116
+ * @property {{ platforms: Record<string, { exports: Record<string, { kind: string }> }> }} catalog
117
+ * @property {{ platforms: { web?: SignaturePlatform, native?: SignaturePlatform } }} componentProps
118
+ * @property {Record<string, TokenMeta>} tokenMeta
119
+ * @property {Record<string, unknown>} tokenDark
120
+ *
121
+ * @typedef {object} Known
122
+ * @property {string} name
123
+ * @property {ManifestComponent | null} doc
124
+ * @property {string} kind
125
+ * @property {string} status
126
+ * @property {string[]} platforms
127
+ * @property {string | null} description
128
+ *
129
+ * @typedef {object} Grounding
130
+ * @property {string} text The grounding block, ready to sit under the system prompt.
131
+ * @property {string[]} components The components whose full detail the block carries.
132
+ * @property {string[]} named The subset the question itself named.
133
+ * @property {string[]} tokens The tokens the question named outright.
134
+ */
135
+
136
+ /** Kinds a question can legitimately be about. `hook` is not one of them. */
137
+ const LISTED_KINDS = new Set(['component', 'primitive', 'subpart']);
138
+
139
+ /** At most this many components get their full detail block. */
140
+ const MAX_DETAIL = 4;
141
+
142
+ /**
143
+ * A token value as it is written in an answer: a string as-is, a composite
144
+ * (the text styles) as its parts.
145
+ * @param {unknown} v
146
+ */
147
+ function valueText(v) {
148
+ if (v && typeof v === 'object') {
149
+ return Object.entries(v)
150
+ .map(([k, x]) => `${k} ${String(x)}`)
151
+ .join(', ');
152
+ }
153
+ return String(v);
154
+ }
155
+
156
+ /**
157
+ * The first sentence of a description, for the always-on list. A few of them
158
+ * run to a paragraph — the chart palette explains its CVD checks,
159
+ * `text.subtle` its contrast ratios — and that text is worth having, but
160
+ * once, next to the token when a question names it, not on every turn.
161
+ * @param {string} description
162
+ */
163
+ function brief(description) {
164
+ const first = description.split(/(?<=\.)\s+/)[0] ?? description;
165
+ return first.length > 140 ? `${first.slice(0, 137).trimEnd()}…` : first;
166
+ }
167
+
168
+ /** @param {string | undefined} ch */
169
+ const isWordChar = (ch) => !!ch && /[a-z0-9]/.test(ch);
170
+
171
+ /**
172
+ * Where a name first appears in the text as a whole word, or -1. Every
173
+ * occurrence is tried rather than only the first, because the first is often
174
+ * the one inside a longer name: in "SelectionCard and Card", `Card`'s first
175
+ * hit sits inside `SelectionCard` and is rejected, and stopping there would
176
+ * lose the standalone mention that follows it.
177
+ * @param {string} hay
178
+ * @param {string} needle
179
+ */
180
+ function wordIndexOf(hay, needle) {
181
+ let at = hay.indexOf(needle);
182
+ while (at !== -1) {
183
+ if (!isWordChar(hay[at - 1]) && !isWordChar(hay[at + needle.length])) return at;
184
+ at = hay.indexOf(needle, at + 1);
185
+ }
186
+ return -1;
187
+ }
188
+
189
+ /**
190
+ * Whether anything placed between the component's tags is rendered. Derived
191
+ * from the signature, never assumed: 8 of the 75 web components accept
192
+ * `children`, and the other 67 render nothing from it — a
193
+ * `<Button>Save</Button>` is an empty button, because the text goes through
194
+ * `label`.
195
+ * @param {string} name
196
+ * @param {PropEntry[]} props
197
+ */
198
+ function childrenRule(name, props) {
199
+ if (props.some((p) => p.name === 'children')) {
200
+ return `${name} accepts \`children\`: content between its tags is rendered.`;
201
+ }
202
+ const slots = props
203
+ .filter((p) => /^(label|title|text|content|description|value)$/.test(p.name))
204
+ .map((p) => `\`${p.name}\``);
205
+ const via = slots.length ? ` Its text goes through ${slots.join(', ')}.` : '';
206
+ return (
207
+ `${name} does not accept \`children\`: anything placed between its tags is not rendered, ` +
208
+ `and no prop outside the list above exists.${via}`
209
+ );
210
+ }
211
+
212
+ /**
213
+ * The grounding functions, built over one set of generated files.
214
+ * @param {WhispererData} data
215
+ */
216
+ export function createWhisperer({ manifest, catalog, componentProps, tokenMeta, tokenDark }) {
217
+ const DOCUMENTED = manifest.components;
218
+ const META = tokenMeta;
219
+ const DARK = tokenDark;
220
+
221
+ /** @param {string} platform */
222
+ function catalogNames(platform) {
223
+ const exports = catalog.platforms[platform]?.exports ?? {};
224
+ return new Set(
225
+ Object.entries(exports)
226
+ .filter(([, entry]) => LISTED_KINDS.has(entry?.kind))
227
+ .map(([name]) => name),
228
+ );
229
+ }
230
+
231
+ /**
232
+ * Everything a question may name, and what is known about it. Names are
233
+ * the union of the catalog and the manifest, and so are the platforms: the
234
+ * catalog says where a component is exported, the manifest repeats the
235
+ * mapping file's claim, and a mapping file that says `web` for a component
236
+ * the RN barrel also exports (DropdownMenu, once) must not turn into
237
+ * "web-only" in the index — that tag is what the answer to "does it exist
238
+ * on React Native?" is read from.
239
+ * @type {Known[]}
240
+ */
241
+ const KNOWN = (() => {
242
+ const web = catalogNames('web');
243
+ const rn = catalogNames('rn');
244
+ const documented = new Map(DOCUMENTED.map((c) => [c.name, c]));
245
+ const names = [...new Set([...web, ...rn, ...documented.keys()])].sort();
246
+ return names.map((name) => {
247
+ const doc = documented.get(name) ?? null;
248
+ const declared = new Set(doc?.platforms ?? []);
249
+ const platforms = /** @type {string[]} */ (
250
+ [
251
+ web.has(name) || declared.has('web') ? 'web' : null,
252
+ rn.has(name) || declared.has('native') ? 'native' : null,
253
+ ].filter(Boolean)
254
+ );
255
+ const kind =
256
+ catalog.platforms.web?.exports?.[name]?.kind ??
257
+ catalog.platforms.rn?.exports?.[name]?.kind ??
258
+ 'component';
259
+ // The manifest writes `status: "unknown"` for the layout primitives,
260
+ // which is a fact about the manifest, not about the component. Say what
261
+ // it is instead of quoting a status nobody set.
262
+ const status = doc?.status && doc.status !== 'unknown' ? doc.status : kind;
263
+ return {
264
+ name,
265
+ doc,
266
+ kind,
267
+ status,
268
+ platforms: platforms.length ? platforms : ['web'],
269
+ description: doc?.description ?? null,
270
+ };
271
+ });
272
+ })();
273
+
274
+ const SIGNATURES = componentProps.platforms;
275
+ const REACT_PROPS = SIGNATURES.web?.components ?? {};
276
+ const NATIVE_PROPS = SIGNATURES.native?.components ?? {};
277
+
278
+ /**
279
+ * Where each platform's components are imported from. Read from the
280
+ * signature file, which records the package it was generated from, because
281
+ * the manifest's own `package` field names the token package — and an
282
+ * import line is the one fact a model will most confidently make up when
283
+ * it is not in front of it.
284
+ */
285
+ const PACKAGES = {
286
+ web: SIGNATURES.web?.package ?? null,
287
+ native: SIGNATURES.native?.package ?? null,
288
+ };
289
+
290
+ /** Every token, the same order the build writes them in. */
291
+ const TOKEN_NAMES = Object.keys(META);
292
+
293
+ /**
294
+ * The tokens the always-on list carries: the whole semantic layer and every
295
+ * primitive scale that is not a colour — spacing, radius, type, opacity,
296
+ * motion, elevation, breakpoints. The colour primitives are the shades
297
+ * (withheld, rule 2 above) and the brand palette (listed apart). Component
298
+ * tokens reach the model through their own component's detail block.
299
+ * @param {string} name
300
+ */
301
+ function inScaleList(name) {
302
+ const t = META[name];
303
+ return t.layer === 'semantic' || (t.layer === 'primitive' && t.type !== 'color');
304
+ }
305
+
306
+ /** @param {string} name */
307
+ function isBrandPrimitive(name) {
308
+ return name.startsWith('stk-color-brand-');
309
+ }
310
+
311
+ /**
312
+ * One token, one line. Always: the name, the light value, the dark value
313
+ * when the dark overlay changes it, and `→` the token it aliases — that
314
+ * arrow is what answers "what does the button's background *really* point
315
+ * at". The description follows. `full` gives the whole description and the
316
+ * React Native export; the list gives the first sentence and leaves RN to
317
+ * the rule under its heading.
318
+ * @param {string} name
319
+ * @param {boolean} [full]
320
+ */
321
+ function tokenLine(name, full = false) {
322
+ const t = META[name];
323
+ let line = `--${name}: ${valueText(t.value)}`;
324
+ if (DARK[name] !== undefined && valueText(DARK[name]) !== valueText(t.value)) {
325
+ line += ` (dark: ${valueText(DARK[name])})`;
326
+ }
327
+ if (t.alias) line += ` → --${t.alias}`;
328
+ if (t.description) line += ` — ${full ? t.description : brief(t.description)}`;
329
+ if (full) {
330
+ line += t.rn
331
+ ? `\n React Native: \`${t.rn.name}\` from \`${t.rn.module}\``
332
+ : '\n React Native: not exported — web only';
333
+ }
334
+ return line;
335
+ }
336
+
337
+ /** @type {string | null} */
338
+ let catalogCache = null;
339
+
340
+ /**
341
+ * Every component, one line each — the answer to "does this exist?" — and,
342
+ * under it, the names of its web props. The names cost ~9 KB for the whole
343
+ * catalog and buy the closed list for components an answer *composes with*
344
+ * rather than asks about: a question about `Card` expands `Card` alone, and
345
+ * the example then reaches for `Stack` — which, without this line, got a
346
+ * `padding` prop it has never had. Values stay in the detail blocks.
347
+ */
348
+ function catalogIndex() {
349
+ if (catalogCache) return catalogCache;
350
+ catalogCache = KNOWN.map((k) => {
351
+ const description = k.description ?? 'Exported and usable; no description reached this index.';
352
+ const names = (REACT_PROPS[k.name]?.props ?? []).filter((p) => !p.rest).map((p) => p.name);
353
+ const props = names.length ? `\n props: ${names.join(', ')}` : '';
354
+ return `${k.name} [${k.status}, ${k.platforms.join('/')}] — ${description}${props}`;
355
+ }).join('\n');
356
+ return catalogCache;
357
+ }
358
+
359
+ /** @type {string | null} */
360
+ let tokenListCache = null;
361
+
362
+ /**
363
+ * The always-on token list, with the rules for reading it said once at the
364
+ * top rather than on every line: what the arrow means, what dark means, and
365
+ * how a name becomes its React Native export. The RN rule is stated instead
366
+ * of listed because it holds for every line here — the exceptions are the
367
+ * colour primitives, which are not exported on RN and are not in this list.
368
+ */
369
+ function tokenList() {
370
+ if (tokenListCache) return tokenListCache;
371
+ const scale = TOKEN_NAMES.filter(inScaleList).map((n) => tokenLine(n));
372
+ const brand = TOKEN_NAMES.filter(isBrandPrimitive).map((n) => tokenLine(n));
373
+ tokenListCache = [
374
+ 'How to read a line: `--name: light value (dark: value in dark mode) → the token it aliases — what it is for`.',
375
+ 'A token with no `(dark: …)` keeps its light value in dark mode. A token with no `→` holds a raw value: it is',
376
+ 'the end of the chain. Component tokens (listed under their component) alias these; these alias the primitives.',
377
+ 'React Native exports every token below under its camelCase name, from `@starklab/stk/rn`:',
378
+ '`--stk-border-default` is `stkBorderDefault`, `--stk-spacing-md` is `stkSpacingMd`. Easing tokens come from',
379
+ '`@starklab/stk/rn-easing` instead, and the per-platform spacing, type-size and shadow steps are folded into one',
380
+ 'Platform.select() export each (`stkSpacingMd` from `@starklab/stk/rn-spacing`, `stkTypographySizeMd` from',
381
+ '`@starklab/stk/rn-typography`, `stkShadowMd` from `@starklab/stk/rn-shadows`).',
382
+ '',
383
+ `### Semantic tokens and scales (${scale.length})`,
384
+ '',
385
+ ...scale,
386
+ '',
387
+ `### Brand colour primitives (${brand.length})`,
388
+ '',
389
+ 'The exact brand colours. Reference only: a component or a screen takes its colour from a semantic surface,',
390
+ 'text, border or icon token above, never from these directly, and they are not exported on React Native.',
391
+ '',
392
+ ...brand,
393
+ ].join('\n');
394
+ return tokenListCache;
395
+ }
396
+
397
+ /**
398
+ * Which components the question names, in the order it names them. Matched
399
+ * on word boundaries against the catalog's own names, so "card" hits `Card`
400
+ * but "discard" does not.
401
+ * @param {string} text
402
+ * @returns {{ entry: Known, at: number }[]}
403
+ */
404
+ function namedInQuestion(text) {
405
+ const hay = text.toLowerCase();
406
+ return KNOWN.map((entry) => ({ entry, at: wordIndexOf(hay, entry.name.toLowerCase()) }))
407
+ .filter((hit) => hit.at !== -1)
408
+ .sort((a, b) => a.at - b.at);
409
+ }
410
+
411
+ /**
412
+ * The components whose full detail block goes into the grounding. Longer
413
+ * names win first (`DataTable` before `Table`-like substrings) because a
414
+ * longer name is the more specific thing to expand when the cap bites.
415
+ * @param {string} text
416
+ */
417
+ function componentsInQuestion(text) {
418
+ const hits = namedInQuestion(text).map((h) => h.entry);
419
+ hits.sort((a, b) => b.name.length - a.name.length);
420
+ return hits.slice(0, MAX_DETAIL);
421
+ }
422
+
423
+ /**
424
+ * The question's own components first, then — while there is room under
425
+ * MAX_DETAIL — the ones the conversation was most recently about. A
426
+ * follow-up rarely repeats the name: "tell me the sizes" names nothing, and
427
+ * grounded on itself alone it carried no `Button` block at all, so the
428
+ * answer to it came from the model's memory of some other design system.
429
+ * `context` is the prior turns, newest first; the newest mention wins the
430
+ * last slot.
431
+ * @param {string} question
432
+ * @param {string[]} context
433
+ */
434
+ function componentsToExpand(question, context) {
435
+ const picked = componentsInQuestion(question);
436
+ for (const text of context) {
437
+ if (picked.length >= MAX_DETAIL) break;
438
+ for (const hit of componentsInQuestion(text)) {
439
+ if (picked.length >= MAX_DETAIL) break;
440
+ if (!picked.includes(hit)) picked.push(hit);
441
+ }
442
+ }
443
+ return picked;
444
+ }
445
+
446
+ /**
447
+ * The same matches, in reading order and uncapped — what "which component
448
+ * is this question about?" means to a reader, and what a consumer that
449
+ * counts asks per component classifies against. Exposed so such a count is
450
+ * built from the same catalog the answer was grounded in, never from a
451
+ * second list.
452
+ * @param {string} text
453
+ * @returns {{ name: string, platforms: string[] }[]}
454
+ */
455
+ function componentsNamedIn(text) {
456
+ return namedInQuestion(text).map((h) => ({ name: h.entry.name, platforms: h.entry.platforms }));
457
+ }
458
+
459
+ /**
460
+ * Every component name the catalog knows — the closed set, for validation.
461
+ * @param {string} name
462
+ */
463
+ function isKnownComponent(name) {
464
+ return KNOWN.some((k) => k.name === name);
465
+ }
466
+
467
+ /**
468
+ * Whether a token name (without its leading `--`) exists in the token build.
469
+ * @param {string} name
470
+ */
471
+ function isKnownToken(name) {
472
+ return META[name.replace(/^--/, '').toLowerCase()] !== undefined;
473
+ }
474
+
475
+ /**
476
+ * React Native export name → token name, for questions asked in RN terms. A
477
+ * Platform.select() export stands for several tokens; the first wins.
478
+ */
479
+ const RN_NAMES = (() => {
480
+ /** @type {Map<string, string>} */
481
+ const map = new Map();
482
+ for (const name of TOKEN_NAMES) {
483
+ const rn = META[name].rn;
484
+ if (rn && !map.has(rn.name)) map.set(rn.name, name);
485
+ }
486
+ return map;
487
+ })();
488
+
489
+ /**
490
+ * Any token the question names outright, primitives included, in either
491
+ * spelling: the CSS name (`--stk-border-default`, with or without the
492
+ * dashes) or the React Native export (`stkBorderDefault`).
493
+ * @param {string} text
494
+ * @returns {string[]}
495
+ */
496
+ function tokensInQuestion(text) {
497
+ /** @type {Set<string>} */
498
+ const seen = new Set();
499
+ for (const raw of text.match(/(?:--)?\bstk-[a-z0-9-]+/gi) ?? []) {
500
+ const name = raw.replace(/^--/, '').toLowerCase();
501
+ if (META[name] !== undefined) seen.add(name);
502
+ }
503
+ for (const raw of text.match(/\bstk[A-Z][A-Za-z0-9]*/g) ?? []) {
504
+ const name = RN_NAMES.get(raw);
505
+ if (name) seen.add(name);
506
+ }
507
+ return [...seen];
508
+ }
509
+
510
+ /**
511
+ * One line per prop, and every enum's values on it. The list itself comes
512
+ * from the React signature — that is the closed set, and a prop the source
513
+ * never destructures is not a prop however the mapping file describes it.
514
+ * The mapping file adds what the signature cannot say: the values an enum
515
+ * accepts, whether a prop is a slot, and the sentence the author wrote
516
+ * about it.
517
+ *
518
+ * The values matter more than anything else here. "Button has 3 sizes" is
519
+ * in the usage prose; `sm | md | lg` is in the mapping file and nowhere
520
+ * else, and a model told the count but not the names answers "what sizes
521
+ * are there?" with a guess dressed as a fact.
522
+ * @param {Known} k
523
+ * @param {PropEntry[]} props
524
+ */
525
+ function propLines(k, props) {
526
+ /** @type {Map<string, PropMapEntry>} */
527
+ const mapped = new Map();
528
+ for (const entry of k.doc?.props?.web?.propMap ?? []) {
529
+ if (entry.componentProp !== false) mapped.set(entry.react, entry);
530
+ }
531
+ const lines = props
532
+ .filter((p) => !p.rest)
533
+ .map((p) => {
534
+ const m = mapped.get(p.name);
535
+ const values = m?.values ? Object.keys(m.values) : [];
536
+ const isBool = values.length === 2 && values.includes('true') && values.includes('false');
537
+ let shape;
538
+ if (values.length && !isBool) shape = values.join(' | ');
539
+ else if (m?.type === 'instance' || m?.transform === 'presence') shape = 'a React element (slot)';
540
+ else if (m?.type === 'function') shape = 'callback';
541
+ else if (m?.type === 'boolean' || isBool) shape = 'boolean';
542
+ else if (m?.type === 'text') shape = 'text';
543
+ else shape = '';
544
+ const dflt = p.hasDefault ? `default ${p.defaultSource}` : 'no default';
545
+ const note = m?.description ? ` — ${m.description}` : '';
546
+ return `- ${p.name}${shape ? `: ${shape}` : ''} (${dflt})${note}`;
547
+ });
548
+ if (props.some((p) => p.rest)) {
549
+ lines.push(
550
+ '- …rest: any other attribute (aria-*, data-*, id, event handlers) is passed through to the underlying element',
551
+ );
552
+ }
553
+ return lines.join('\n');
554
+ }
555
+
556
+ /**
557
+ * The React Native half of a component's detail, or the one line that says
558
+ * there is none. The closed list is the RN signature (component-props.json,
559
+ * generated from `packages/stk-react-native/src`); the RN usage file adds
560
+ * what a signature cannot say — the type, whether it is required, and the
561
+ * sentence the author wrote — the way the mapping file does for web. Then
562
+ * the RN children rule, the RN Do/Don't and the RN code example, each its
563
+ * own set: `Button` on RN has no `href`, `Card` has `onPress`, and a rule
564
+ * written for the DOM ("pair it with a focus ring") is not a rule on a
565
+ * phone.
566
+ * @param {Known} k
567
+ * @returns {string[]}
568
+ */
569
+ function nativeSection(k) {
570
+ /** @type {string[]} */
571
+ const parts = [];
572
+ const sig = NATIVE_PROPS[k.name]?.props ?? [];
573
+ const nu = k.doc?.nativeUsage ?? null;
574
+ if (!k.platforms.includes('native') && !sig.length && !nu) {
575
+ parts.push(`${k.name} has no React Native version: it exists on web only.`);
576
+ return parts;
577
+ }
578
+
579
+ /** @type {Map<string, NativeUsageProp>} */
580
+ const documented = new Map();
581
+ for (const p of nu?.props ?? []) documented.set(p.name, p);
582
+
583
+ /**
584
+ * @param {string} name
585
+ * @param {NativeUsageProp | undefined} p
586
+ * @param {string | null} sigDefault
587
+ */
588
+ const describe = (name, p, sigDefault) => {
589
+ const shape = p?.type ? `: ${p.type}` : '';
590
+ const dflt = sigDefault ?? (p?.default ? p.default : null);
591
+ const required =
592
+ p?.required === true ? 'required' : typeof p?.required === 'string' ? `required ${p.required}` : null;
593
+ const state = [dflt ? `default ${dflt}` : null, required].filter(Boolean).join(', ') || 'no default';
594
+ const note = p?.description ? ` — ${p.description}` : '';
595
+ return `- ${name}${shape} (${state})${note}`;
596
+ };
597
+
598
+ /** @type {string[]} */
599
+ const lines = [];
600
+ /** @type {Set<string>} */
601
+ const listed = new Set();
602
+ for (const p of sig) {
603
+ if (p.rest) continue;
604
+ listed.add(p.name);
605
+ lines.push(describe(p.name, documented.get(p.name), p.hasDefault ? (p.defaultSource ?? null) : null));
606
+ }
607
+ if (sig.some((p) => p.rest)) {
608
+ lines.push('- …rest: any other prop (style, testID, accessibility*) is passed through to the underlying view');
609
+ }
610
+ // What the RN usage file documents beyond the signature is a compound
611
+ // part's prop (`DropdownMenu.Item.label`), never a prop of the component
612
+ // itself.
613
+ const extra = [...documented.values()].filter((p) => !listed.has(p.name));
614
+ for (const p of extra) lines.push(describe(p.name, p, null));
615
+
616
+ if (lines.length) {
617
+ const differs = (REACT_PROPS[k.name]?.props ?? []).length
618
+ ? ' (a different list from web — answer for the platform asked)'
619
+ : '';
620
+ parts.push(`React Native props — the complete list for that platform${differs}:\n${lines.join('\n')}`);
621
+ if (sig.length) parts.push(`On React Native, ${childrenRule(k.name, sig)}`);
622
+ }
623
+
624
+ if (nu?.dos?.length) parts.push(`React Native — Do:\n${nu.dos.map((d) => `- ${d}`).join('\n')}`);
625
+ if (nu?.donts?.length) parts.push(`React Native — Don't:\n${nu.donts.map((d) => `- ${d}`).join('\n')}`);
626
+ if (nu?.codeExample) {
627
+ parts.push(`React Native example (from the usage docs):\n\`\`\`jsx\n${nu.codeExample}\n\`\`\``);
628
+ }
629
+ return parts;
630
+ }
631
+
632
+ /** @param {Known} k */
633
+ function detailFor(k) {
634
+ const c = k.doc;
635
+ /** @type {string[]} */
636
+ const parts = [`### ${k.name} (${k.status})`];
637
+ if (k.description) parts.push(k.description);
638
+ else
639
+ parts.push(
640
+ 'Exported and usable, but no usage guidance reached this index. Answer from its props, ' +
641
+ 'and say that the written guidance for it is not in front of you rather than inventing any.',
642
+ );
643
+
644
+ const imports = [
645
+ PACKAGES.web && REACT_PROPS[k.name] ? `import { ${k.name} } from '${PACKAGES.web}'` : null,
646
+ PACKAGES.native && NATIVE_PROPS[k.name]
647
+ ? `import { ${k.name} } from '${PACKAGES.native}' (React Native)`
648
+ : null,
649
+ ].filter(Boolean);
650
+ if (imports.length) parts.push(`Import:\n${imports.join('\n')}`);
651
+
652
+ const props = REACT_PROPS[k.name]?.props ?? [];
653
+ if (props.length) {
654
+ parts.push(`Web props — the complete list (from the component source):\n${propLines(k, props)}`);
655
+ parts.push(childrenRule(k.name, props));
656
+ }
657
+
658
+ if (c?.usage?.codeExample) {
659
+ parts.push(`Web example (from the usage docs):\n\`\`\`jsx\n${c.usage.codeExample}\n\`\`\``);
660
+ }
661
+
662
+ // The usage files are written for layout authoring as much as for React,
663
+ // and a dozen of them speak in that vocabulary — `heading(title-4)`,
664
+ // `Stack(padding:'sm')` — which names nodes and keys the React components
665
+ // do not have. Said once, next to the lines that do it, rather than left
666
+ // for the model to reconcile against the prop list on its own.
667
+ if (c?.usage?.dos?.length || c?.usage?.donts?.length) {
668
+ parts.push(
669
+ 'Usage guidance (written for layout authoring too: a `heading`, `text` or `spacer` it names is ' +
670
+ 'a layout node, not a component, and a key like `padding` on another component is a layout ' +
671
+ 'key, not a prop — the prop lists are the only source of props):',
672
+ );
673
+ }
674
+ if (c?.usage?.dos?.length) {
675
+ parts.push(`Web — Do:\n${c.usage.dos.map((d) => `- ${d}`).join('\n')}`);
676
+ }
677
+ if (c?.usage?.donts?.length) {
678
+ parts.push(`Web — Don't:\n${c.usage.donts.map((d) => `- ${d}`).join('\n')}`);
679
+ }
680
+
681
+ parts.push(...nativeSection(k));
682
+
683
+ const tokenNames = TOKEN_NAMES.filter((name) =>
684
+ name.startsWith(`stk-${k.name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()}-`),
685
+ );
686
+ if (tokenNames.length) {
687
+ parts.push(
688
+ `Its own component tokens — each one aliases a semantic token (→), and that is the token to name when ` +
689
+ `asked what colour, size or radius the component uses; override the component token to change it:\n` +
690
+ tokenNames.map((n) => tokenLine(n, true)).join('\n'),
691
+ );
692
+ }
693
+
694
+ return parts.join('\n\n');
695
+ }
696
+
697
+ /** @type {string | null} */
698
+ let packagesCache = null;
699
+
700
+ /** The one import fact per platform, so no answer has to guess a package name. */
701
+ function packagesSection() {
702
+ if (packagesCache) return packagesCache;
703
+ packagesCache = [
704
+ PACKAGES.web ? `Web (React): components are imported from \`${PACKAGES.web}\`.` : null,
705
+ PACKAGES.native ? `React Native: components are imported from \`${PACKAGES.native}\`.` : null,
706
+ ]
707
+ .filter(Boolean)
708
+ .join('\n');
709
+ return packagesCache;
710
+ }
711
+
712
+ /**
713
+ * The grounding block for one question. `context` is the prior turns of the
714
+ * conversation, newest first, so a follow-up that names no component keeps
715
+ * the detail of the one it is about. Returns the text, the components it
716
+ * expanded, the subset the question itself named and the tokens it named,
717
+ * so the caller can say what the answer was built on without re-deriving
718
+ * it.
719
+ * @param {string} question
720
+ * @param {string[]} [context]
721
+ * @returns {Grounding}
722
+ */
723
+ function buildGrounding(question, context = []) {
724
+ const named = componentsInQuestion(question);
725
+ const picked = componentsToExpand(question, context);
726
+ const namedTokens = tokensInQuestion(question);
727
+
728
+ const sections = [
729
+ `## Every component in the system (${KNOWN.length})\n\n${catalogIndex()}`,
730
+ `## Packages\n\n${packagesSection()}`,
731
+ `## Tokens\n\n${tokenList()}`,
732
+ ];
733
+
734
+ if (picked.length) {
735
+ sections.push(`## The components this conversation is about\n\n${picked.map(detailFor).join('\n\n')}`);
736
+ }
737
+
738
+ if (namedTokens.length) {
739
+ sections.push(
740
+ `## The tokens this question names\n\n${namedTokens.map((n) => tokenLine(n, true)).join('\n')}`,
741
+ );
742
+ }
743
+
744
+ return {
745
+ text: sections.join('\n\n'),
746
+ components: picked.map((k) => k.name),
747
+ named: named.map((k) => k.name),
748
+ tokens: namedTokens,
749
+ };
750
+ }
751
+
752
+ return { PACKAGES, buildGrounding, componentsNamedIn, isKnownComponent, isKnownToken, tokensInQuestion };
753
+ }
754
+
755
+ /**
756
+ * The instructions the grounding is handed to the model under. Kept beside
757
+ * the grounding because the two only make sense together: the "every name
758
+ * must appear in the facts" rule is only honest while `buildGrounding` really
759
+ * does put the whole catalog in. These are the rules about the design system;
760
+ * a surface adds its own — how its panel renders, what it can and cannot see.
761
+ */
762
+ export const WHISPERER_SYSTEM_PROMPT = [
763
+ 'You are Whisperer, the customization assistant for the Stark design system.',
764
+ 'You answer questions about what a component can express, which token to reach for,',
765
+ 'and what to do when neither fits.',
766
+ '',
767
+ 'Rules:',
768
+ '- Answer only from the design system facts below. They are generated from the',
769
+ ' system itself and are current; your own recollection of any design system is not.',
770
+ '- Every component, prop and token name you write must appear in those facts.',
771
+ ' If the thing being asked for does not exist, say so plainly and name the',
772
+ ' closest thing that does — that gap is the most useful answer you can give.',
773
+ "- A component's prop list in the facts is complete. A prop that is not on it",
774
+ ' does not exist, `children` included: when the list says a component does',
775
+ ' not accept `children`, nothing between its tags is rendered and its text',
776
+ ' goes through the prop the list names. Never write a code example that',
777
+ ' passes a prop, or places content, the list does not allow.',
778
+ '- Where a prop lists its values, those are all of them. Name them; never',
779
+ ' answer with a count when the values are in front of you.',
780
+ '- The system has two platforms, web (React) and React Native, and the tag on',
781
+ ' each index line says where a component exists: `web/native` is both,',
782
+ ' `web` alone means there is no React Native version — say so plainly when',
783
+ ' asked, and name the closest thing that does exist there. A component that',
784
+ ' exists on both has two prop lists, and they differ: answer for the platform',
785
+ " the question asks about, from that platform's list and that platform's",
786
+ " Do/Don't. When the question does not say, answer for web and say that you",
787
+ ' did.',
788
+ '- Import lines come from the facts too: the package under "Packages", the',
789
+ ' import under the component. Never write a package name from memory.',
790
+ '- A code example may only use components from the index, with props from',
791
+ ' their lists — never a placeholder component, not even with a comment',
792
+ ' saying to assume it. For plain text inside a slot, use an HTML element',
793
+ ' and say the system has no component for it.',
794
+ '- If the question uses a word you do not find in the facts — a product, a',
795
+ ' platform, a tool, a component — do not guess what it means and do not answer',
796
+ ' around it. Say you do not recognise it and ask what it refers to.',
797
+ '- Tokens are three layers: primitives (shades, the spacing and radius scales),',
798
+ ' semantic tokens that alias them (`surface.*`, `text.*`, `border.*`, `icon.*`,',
799
+ ' `state.*`, `chart.*`), and component tokens that alias the semantic ones.',
800
+ ' Component tokens alias semantic tokens, never primitive shades. Never suggest',
801
+ ' a raw hex, px value or a `color-shade-*` token where a semantic one exists.',
802
+ ' When asked what a token "is", give the chain the facts show: its value, the',
803
+ ' token it aliases (the `→` on its line), and what it is for.',
804
+ "- A token's line carries its dark-mode value when it has one; a line without",
805
+ ' `(dark: …)` keeps the same value in both themes. Say which when asked.',
806
+ '- On React Native a token is the camelCase export named on its line or by the',
807
+ ' rule at the top of the token list, imported from the `@starklab/stk` module',
808
+ ' the line names — never a CSS custom property. Colour primitives have no',
809
+ ' React Native export.',
810
+ '- Chart colours are the `chart.*` tokens — categorical, sequential, diverging',
811
+ ' and de-emphasis, each validated for colour-vision deficiency. Never build a',
812
+ ' chart palette from surface or brand tokens.',
813
+ '- Be short. Two or three sentences and a small code example beat an essay.',
814
+ ].join('\n');