division-ds 0.1.2 → 0.2.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.
Files changed (98) hide show
  1. package/AGENTS.md +23 -3
  2. package/README.md +11 -0
  3. package/division-ds-0.2.1.tgz +0 -0
  4. package/package.json +2 -2
  5. package/public/docs/components/accordion.md +207 -0
  6. package/public/docs/components/ai-label.md +90 -0
  7. package/public/docs/components/alert-dialog.md +292 -0
  8. package/public/docs/components/alert.md +175 -0
  9. package/public/docs/components/approval-request.md +148 -0
  10. package/public/docs/components/auth-form.md +132 -0
  11. package/public/docs/components/avatar-group.md +152 -0
  12. package/public/docs/components/avatar.md +179 -0
  13. package/public/docs/components/badge.md +178 -0
  14. package/public/docs/components/breadcrumbs.md +112 -0
  15. package/public/docs/components/button.md +204 -0
  16. package/public/docs/components/card.md +257 -0
  17. package/public/docs/components/carousel.md +130 -0
  18. package/public/docs/components/cart.md +172 -0
  19. package/public/docs/components/chart-panel.md +358 -0
  20. package/public/docs/components/chat-message.md +157 -0
  21. package/public/docs/components/chat-thread.md +244 -0
  22. package/public/docs/components/checkbox.md +149 -0
  23. package/public/docs/components/checkout-form.md +128 -0
  24. package/public/docs/components/command-palette.md +147 -0
  25. package/public/docs/components/data-table.md +401 -0
  26. package/public/docs/components/description-list.md +103 -0
  27. package/public/docs/components/dialog.md +197 -0
  28. package/public/docs/components/divider.md +97 -0
  29. package/public/docs/components/dropdown-menu.md +222 -0
  30. package/public/docs/components/empty-state.md +145 -0
  31. package/public/docs/components/footer.md +397 -0
  32. package/public/docs/components/form-field.md +216 -0
  33. package/public/docs/components/icon-button.md +127 -0
  34. package/public/docs/components/input.md +200 -0
  35. package/public/docs/components/kanban-board.md +300 -0
  36. package/public/docs/components/kbd.md +72 -0
  37. package/public/docs/components/link.md +98 -0
  38. package/public/docs/components/list-item.md +241 -0
  39. package/public/docs/components/loading-animation.md +72 -0
  40. package/public/docs/components/multi-select.md +202 -0
  41. package/public/docs/components/navbar.md +144 -0
  42. package/public/docs/components/newsletter-form.md +112 -0
  43. package/public/docs/components/page-header.md +151 -0
  44. package/public/docs/components/pagination.md +212 -0
  45. package/public/docs/components/product-card.md +172 -0
  46. package/public/docs/components/progress.md +101 -0
  47. package/public/docs/components/prompt-input.md +151 -0
  48. package/public/docs/components/quantity-stepper.md +140 -0
  49. package/public/docs/components/radio.md +177 -0
  50. package/public/docs/components/rating.md +106 -0
  51. package/public/docs/components/score-ring.md +95 -0
  52. package/public/docs/components/search.md +116 -0
  53. package/public/docs/components/section-heading.md +113 -0
  54. package/public/docs/components/segmented-control.md +126 -0
  55. package/public/docs/components/select.md +224 -0
  56. package/public/docs/components/sheet.md +145 -0
  57. package/public/docs/components/sidebar.md +193 -0
  58. package/public/docs/components/skeleton.md +135 -0
  59. package/public/docs/components/slider.md +114 -0
  60. package/public/docs/components/sparkline.md +128 -0
  61. package/public/docs/components/spinner.md +98 -0
  62. package/public/docs/components/stat.md +106 -0
  63. package/public/docs/components/status-tracker.md +145 -0
  64. package/public/docs/components/stepper.md +147 -0
  65. package/public/docs/components/switch.md +153 -0
  66. package/public/docs/components/tabs.md +180 -0
  67. package/public/docs/components/tag.md +105 -0
  68. package/public/docs/components/textarea.md +148 -0
  69. package/public/docs/components/timeline.md +152 -0
  70. package/public/docs/components/toast.md +194 -0
  71. package/public/docs/components/tool-call.md +107 -0
  72. package/public/docs/components/tooltip.md +108 -0
  73. package/public/manifest/components.json +198 -15
  74. package/public/manifest/tokens.json +75 -5
  75. package/scripts/division-ds.mjs +78 -42
  76. package/scripts/lib/agent-docs.mjs +80 -6
  77. package/scripts/lib/ai-copy.mjs +120 -0
  78. package/scripts/lib/button-spacing.mjs +5 -5
  79. package/scripts/lib/check-text.mjs +57 -0
  80. package/scripts/lib/contrast.mjs +111 -0
  81. package/scripts/lib/host-inline.mjs +59 -0
  82. package/scripts/lib/mcp-server.mjs +116 -0
  83. package/scripts/lib/mcp-tools.mjs +266 -0
  84. package/scripts/lib/props-validator.mjs +3 -2
  85. package/scripts/lib/rules-installer.mjs +42 -4
  86. package/scripts/lib/token-discipline.mjs +10 -3
  87. package/skills/division-ds/SKILL.md +90 -0
  88. package/skills/division-ds/commands/add.md +25 -0
  89. package/skills/division-ds/commands/build.md +46 -0
  90. package/skills/division-ds/commands/fix.md +28 -0
  91. package/skills/division-ds/commands/review.md +25 -0
  92. package/skills/division-ds/topics/ai.md +8 -0
  93. package/skills/division-ds/topics/colour.md +11 -0
  94. package/skills/division-ds/topics/feedback.md +9 -0
  95. package/skills/division-ds/topics/forms.md +22 -0
  96. package/skills/division-ds/topics/layout.md +36 -0
  97. package/skills/division-ds/topics/motion.md +10 -0
  98. package/skills/division-ds/topics/overlays.md +9 -0
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  // division-ds: the system's gates and rules, for a project that USES the components.
3
3
  //
4
- // division-ds check [files, dirs or globs...] token discipline, touching buttons, props
4
+ // division-ds check [files, dirs or globs...] token discipline, touching buttons, props, AI copy
5
5
  // division-ds rules [--target <dir>] ... install the agent rules into a project
6
+ // division-ds skill [--target <dir>] ... install the agent skill into a project
7
+ // division-ds mcp serve the docs, tokens and gates to an agent (MCP)
6
8
  // division-ds help
7
9
  //
8
10
  // Inside this repository `npm run verify` is the loop. A consumer project has none of that: an
@@ -17,28 +19,33 @@
17
19
  import { existsSync, globSync, readdirSync, readFileSync, statSync } from 'node:fs'
18
20
  import { dirname, join, relative, resolve } from 'node:path'
19
21
  import { fileURLToPath } from 'node:url'
20
- import { extractRules } from './lib/agent-docs.mjs'
21
- import { findViolations as buttonSpacing, formatViolation as formatButtonSpacing } from './lib/button-spacing.mjs'
22
- import { buildIndex, findPropViolations, formatPropViolation } from './lib/props-validator.mjs'
23
- import { applyPlan, diff, planRules, TARGETS } from './lib/rules-installer.mjs'
24
- import { findViolations as tokenDiscipline, formatViolation as formatTokenDiscipline } from './lib/token-discipline.mjs'
22
+ import { DOCS_SITE, extractRules, SKILL_SOURCE } from './lib/agent-docs.mjs'
23
+ import { checkText } from './lib/check-text.mjs'
24
+ import { createServer } from './lib/mcp-server.mjs'
25
+ import { createTools } from './lib/mcp-tools.mjs'
26
+ import { buildIndex } from './lib/props-validator.mjs'
27
+ import { applyPlan, diff, planRules, planSkill, SKILL_TARGETS, TARGETS } from './lib/rules-installer.mjs'
25
28
 
26
29
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
27
30
  const DEFAULT_MANIFEST = join(ROOT, 'public/manifest/components.json')
28
- const CHECKED = /\.(tsx|jsx|ts)$/
31
+ const CHECKED = /\.(tsx|jsx|ts|css)$/
29
32
  const SKIPPED_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'coverage'])
30
33
 
31
34
  const HELP = `division-ds: Division DS gates and agent rules for a project that uses the components.
32
35
 
33
36
  Usage:
34
37
  division-ds check [paths...] [--manifest <path|url>] [--tokens <path|url>] [--json]
35
- Checks .tsx, .jsx and .ts files (default: ./src). A path may be a file, a directory or a
36
- glob ("src/**/*.tsx"). Runs three gates and exits 1 on any finding:
38
+ Checks .tsx, .jsx, .ts and .css files (default: ./src; a .css file gets the colour gate only). A path may be a file, a directory or a
39
+ glob ("src/**/*.tsx"). Runs five gates and exits 1 on any finding:
37
40
  token-discipline raw colours, Tailwind palette classes, token names that do not exist
38
41
  button-spacing two Buttons/IconButtons side by side with no gap on their parent
42
+ host-inline an element marked data-host-mode="inline" (an MCP Apps inline card)
43
+ with over 2 actions, a scroll container, or a menu/popover inside
39
44
  props an unknown prop or an enum value outside the union, on elements
40
45
  imported from Division DS (components/atoms|molecules|organisms, or
41
46
  the package name); a component of your own with the same name is skipped
47
+ ai-copy "AI" in a button label; "answer", "please" or "!" in the labels of
48
+ the assistant components (ChatThread, PromptInput, ApprovalRequest, ...)
42
49
  --manifest components.json to check props against (default: the one beside this script)
43
50
  --tokens tokens.json for token names (default: tokens.json beside the manifest)
44
51
  --json one JSON object on stdout instead of text
@@ -49,6 +56,17 @@ Usage:
49
56
  .cursor/rules/division-ds.mdc. --dry-run prints the changes and writes nothing. --remove
50
57
  takes the blocks out and deletes the .mdc (and a file left holding nothing).
51
58
 
59
+ division-ds skill [--target <dir>] [--only agents,claude] [--dry-run] [--remove]
60
+ Copies the Division DS skill (SKILL.md, topics/, commands/) into .agents/skills/division-ds/
61
+ (Codex and Cursor read it) and .claude/skills/division-ds/ (Claude Code). The folders are
62
+ replaced whole on re-run. --dry-run lists the changes and writes nothing; --remove deletes them.
63
+
64
+ division-ds mcp [--manifest <path|url>] [--tokens <path|url>]
65
+ An MCP server on stdio, for Claude Code, Cursor and other agents. Tools: list_components,
66
+ get_component, search, get_tokens, validate_code (the gates above, on code the agent just
67
+ wrote), check_contrast, get_add_command, get_review_checklist. Register it once:
68
+ claude mcp add division-ds -- npx -y division-ds mcp
69
+
52
70
  division-ds help
53
71
  `
54
72
 
@@ -115,27 +133,7 @@ async function check(options) {
115
133
  const findings = []
116
134
  for (const file of files) {
117
135
  const path = relative(process.cwd(), file).split('\\').join('/')
118
- const text = readFileSync(file, 'utf8')
119
- for (const v of tokenDiscipline(text, path, tokenNames)) {
120
- findings.push({ gate: 'token-discipline', path, line: v.line, rule: v.rule, found: v.found, fix: v.fix, text: formatTokenDiscipline(v) })
121
- }
122
- if (/\.[jt]sx$/.test(path)) {
123
- for (const v of buttonSpacing(text, path)) {
124
- findings.push({
125
- gate: 'button-spacing',
126
- path,
127
- line: v.line,
128
- column: v.column,
129
- rule: 'touching-buttons',
130
- found: `<${v.tag}> with ${v.count} button-like children`,
131
- fix: 'Wrap the buttons in one parent with a flex/grid class and a gap- utility (flex flex-wrap items-center gap-3), and move any margin from the buttons onto it.',
132
- text: formatButtonSpacing({ ...v, file: path }),
133
- })
134
- }
135
- }
136
- for (const v of findPropViolations(text, path, index)) {
137
- findings.push({ gate: 'props', path, line: v.line, column: v.column, rule: v.rule, found: v.found, fix: v.fix, text: formatPropViolation(v) })
138
- }
136
+ findings.push(...checkText(readFileSync(file, 'utf8'), path, index, tokenNames))
139
137
  }
140
138
 
141
139
  if (options.json) {
@@ -143,7 +141,7 @@ async function check(options) {
143
141
  console.log(JSON.stringify({ files: files.length, manifest: manifestSource, findings: out }, null, 2))
144
142
  } else if (findings.length > 0) {
145
143
  console.error(`\ndivision-ds check FAILED: ${findings.length} finding(s) in ${files.length} file(s).\n`)
146
- for (const gate of ['token-discipline', 'button-spacing', 'props']) {
144
+ for (const gate of ['token-discipline', 'button-spacing', 'host-inline', 'props', 'ai-copy']) {
147
145
  const ofGate = findings.filter(finding => finding.gate === gate)
148
146
  if (ofGate.length === 0) continue
149
147
  console.error(`${gate} (${ofGate.length})`)
@@ -151,41 +149,79 @@ async function check(options) {
151
149
  if (gate === 'button-spacing') console.error(` ${ofGate[0].fix}`)
152
150
  console.error('')
153
151
  }
154
- console.error('Each component\'s props, values and examples: /docs/components/<slug>.md. Tokens: public/manifest/tokens.json.')
152
+ console.error(`Each component's props, values and examples: ${DOCS_SITE}docs/components/<slug>.md. Tokens: ${DOCS_SITE}llms-tokens.txt.`)
155
153
  } else {
156
154
  console.log(`division-ds check passed: ${files.length} file(s), ${index.size} components, ${tokenNames.length} tokens, 0 findings.`)
157
155
  // It reads code, not the running page: say so, or a pass reads as "the screen works" (the
158
156
  // 2026-10-20 outside test shipped three runtime bugs past a clean check).
159
- console.log('Checked: colours, tokens, button spacing and props. Not checked: how the screen behaves; run the review checklist (commands/review.md) in a browser.')
157
+ console.log(`Checked: colours, tokens, button spacing, inline host cards, props and AI copy. Not checked: how the screen behaves; run the review checklist in a browser (commands/review.md from \`npx division-ds skill\`, or ${SKILL_SOURCE}commands/review.md).`)
160
158
  }
161
159
  process.exit(findings.length > 0 ? 1 : 0)
162
160
  }
163
161
 
164
- function rules(options) {
165
- const only = options.only ? options.only.split(',').map(target => target.trim()) : TARGETS
166
- const unknown = only.filter(target => !TARGETS.includes(target))
167
- if (unknown.length) usage(`--only takes ${TARGETS.join(', ')}; not ${unknown.join(', ')}.`)
162
+ async function mcp(options) {
163
+ const manifestSource = options.manifest ?? DEFAULT_MANIFEST
164
+ const tokensSource = options.tokens ?? manifestSource.replace(/components\.json$/, 'tokens.json')
165
+ const [manifest, tokens] = await Promise.all([load(manifestSource), load(tokensSource)])
166
+ const version = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version
167
+ const readIfThere = path => (existsSync(path) ? readFileSync(path, 'utf8') : undefined)
168
+ const tools = createTools({
169
+ manifest,
170
+ tokens,
171
+ version,
172
+ // Shipped beside the manifests in the npm package (build-cli-package.mjs); a slug comes from
173
+ // the manifest, never from the agent, so it cannot reach outside the docs folder.
174
+ readDoc: slug => readIfThere(join(ROOT, 'public/docs/components', `${slug}.md`)),
175
+ readChecklist: () => readIfThere(join(ROOT, 'skills/division-ds/commands/review.md')),
176
+ })
177
+ createServer({ tools, version }).listen(process.stdin, process.stdout)
178
+ // stdout is the protocol channel: this goes to stderr, where MCP clients put server logs.
179
+ console.error(`division-ds ${version} MCP server on stdio: ${tools.length} tools, ${manifest.components.length} components.`)
180
+ }
181
+
182
+ /** --only and --target, checked the same way for `rules` and `skill`. */
183
+ function targetsOf(options, all) {
184
+ const only = options.only ? options.only.split(',').map(target => target.trim()) : all
185
+ const unknown = only.filter(target => !all.includes(target))
186
+ if (unknown.length) usage(`--only takes ${all.join(', ')}; not ${unknown.join(', ')}.`)
168
187
  const targetDir = resolve(options.target ?? '.')
169
188
  if (!existsSync(targetDir) || !statSync(targetDir).isDirectory()) usage(`--target is not a directory: ${targetDir}`)
170
- // This repository's AGENTS.md is the source; a block of its own rules appended to it would be
171
- // read twice by every agent here, and regenerated nowhere.
189
+ // This repository's AGENTS.md and skills/ are the source; a copy of its own rules beside them
190
+ // would be read twice by every agent here, and regenerated nowhere.
172
191
  if (resolve(targetDir) === resolve(ROOT)) usage('--target is Division DS itself. Point it at the project that uses the components.')
192
+ return { only, targetDir }
193
+ }
194
+
195
+ function rules(options) {
196
+ const { only, targetDir } = targetsOf(options, TARGETS)
173
197
  const source = readFileSync(join(ROOT, 'AGENTS.md'), 'utf8').replace(/\r\n/g, '\n')
174
- const plan = planRules({ targetDir, rules: extractRules(source), only, remove: Boolean(options.remove) })
198
+ report(planRules({ targetDir, rules: extractRules(source), only, remove: Boolean(options.remove) }), options)
199
+ }
200
+
201
+ function skill(options) {
202
+ const { only, targetDir } = targetsOf(options, SKILL_TARGETS)
203
+ const plan = planSkill({ targetDir, sourceDir: join(ROOT, 'skills/division-ds'), only, remove: Boolean(options.remove) })
204
+ // A diff of every skill file is pages long; the list of files is the review.
205
+ report(plan, { ...options, quiet: true }, targetDir)
206
+ }
175
207
 
208
+ /** Prints a plan (with a diff per file on --dry-run, unless quiet) and applies it unless --dry-run. */
209
+ function report(plan, options, stopAt) {
176
210
  for (const change of plan) {
177
211
  const shown = relative(process.cwd(), change.path) || change.path
178
212
  console.log(`${options['dry-run'] ? 'would ' : ''}${change.action.padEnd(9)} ${shown}`)
179
- if (options['dry-run'] && change.action !== 'unchanged' && change.action !== 'absent') {
213
+ if (options['dry-run'] && !options.quiet && change.action !== 'unchanged' && change.action !== 'absent') {
180
214
  console.log(diff(change.before, change.after).replace(/^/gm, ' '))
181
215
  }
182
216
  }
183
- if (!options['dry-run']) applyPlan(plan)
217
+ if (!options['dry-run']) applyPlan(plan, stopAt)
184
218
  }
185
219
 
186
220
  const [command, ...rest] = process.argv.slice(2)
187
221
  const options = parseArgs(rest)
188
222
  if (command === 'check') await check(options)
189
223
  else if (command === 'rules') rules(options)
224
+ else if (command === 'skill') skill(options)
225
+ else if (command === 'mcp') await mcp(options)
190
226
  else if (command === undefined || command === 'help' || command === '--help' || command === '-h') console.log(HELP)
191
227
  else usage(`Unknown command "${command}".`)
@@ -8,6 +8,8 @@ const TIER_NOUN = { atoms: 'Atom', molecules: 'Molecule', organisms: 'Organism'
8
8
  export const DOCS_SITE = 'https://victor-ilioiu.vercel.app/systems/division-ds/'
9
9
  /** Where shadcn downloads the components from: public/r/ in the public GitHub repository. */
10
10
  export const REGISTRY = 'https://raw.githubusercontent.com/Victor-Ilioiu/division-ds/main/public/r'
11
+ /** The skill's files in the public repository, for a reader who has the package but not the skill. */
12
+ export const SKILL_SOURCE = 'https://github.com/Victor-Ilioiu/division-ds/blob/main/skills/division-ds/'
11
13
 
12
14
  /** A table cell: one line, with the pipes that would end the cell escaped. */
13
15
  const cell = text => String(text ?? '').replace(/\s*\n\s*/g, ' ').replace(/\|/g, '\\|')
@@ -19,14 +21,29 @@ export function importLines(entry) {
19
21
  return entry.imports.map(({ names, from }) => `import { ${names.join(', ')} } from '${from}'`).join('\n')
20
22
  }
21
23
 
24
+ /** The other components a page documents, by export name: ChatMessage's ResponseFeedback,
25
+ * ProductCard's ProductGrid. Parts named after the component (CardBody, RadioGroup) and helpers
26
+ * (lower case) are left out. The 2026-10-29 outside test could not find ResponseFeedback because
27
+ * only its parent was listed anywhere an agent looks first. */
28
+ export function alsoExports(entry) {
29
+ return entry.imports
30
+ .flatMap(({ names }) => names)
31
+ .filter(name => /^[A-Z][a-z]/.test(name) && !name.startsWith(entry.name) && !name.endsWith('Provider'))
32
+ }
33
+
34
+ /** Said under every Import block: the paths are the repository's, not an installed project's. */
35
+ export const INSTALLED_PATHS = 'Paths are from the Division DS repository root. Installed with shadcn they start `src/division-ds/` instead of `src/` (`src/division-ds/components/...`); import them relative to your file.'
36
+
22
37
  /** One component as a markdown page. `entry` is a manifest entry (see `manifestEntry`). */
23
38
  export function componentMarkdown(entry) {
24
39
  const out = [`# ${entry.name}`, '', `> ${entry.summary}`, '']
25
40
  out.push(`- **Best for:** ${entry.useWhen}`, `- **Not for:** ${entry.notFor}`)
41
+ const also = alsoExports(entry)
42
+ if (also.length) out.push(`- **Also on this page:** ${also.map(code).join(', ')}`)
26
43
  out.push(`- ${TIER_NOUN[entry.tier]}. Source: \`${entry.source}\``)
27
44
  out.push(`- Rendered docs page: \`/components/${entry.slug}\``, '')
28
45
 
29
- out.push('## Import', '', fence(importLines(entry)), '')
46
+ out.push('## Import', '', fence(importLines(entry)), '', INSTALLED_PATHS, '')
30
47
 
31
48
  if (entry.requires?.length) {
32
49
  out.push('## Requires', '', ...entry.requires.map(item => `- ${item}`), '')
@@ -108,8 +125,10 @@ const byTier = entries =>
108
125
  export const firstSentence = text => text.match(/^.*?[.!?](?=\s|$)/)?.[0] ?? text
109
126
 
110
127
  /** One component's line in llms.txt: what it is, then best for and not for. */
111
- export const indexLine = entry =>
112
- `- [${entry.name}](${entry.docs.replace(/^\//, '')}): ${firstSentence(entry.summary)} Best for: ${entry.useWhen} Not for: ${entry.notFor}`
128
+ export const indexLine = entry => {
129
+ const also = alsoExports(entry)
130
+ return `- [${entry.name}](${entry.docs.replace(/^\//, '')}): ${firstSentence(entry.summary)} Best for: ${entry.useWhen} Not for: ${entry.notFor}${also.length ? ` Also: ${also.join(', ')}.` : ''}`
131
+ }
113
132
 
114
133
  /** llms.txt, in the llmstxt.org shape: a title, a summary, then sections of links. */
115
134
  export function llmsIndex(entries, { rules }) {
@@ -124,6 +143,7 @@ export function llmsIndex(entries, { rules }) {
124
143
  '## Start here',
125
144
  '',
126
145
  '- [Rules](llms-guidelines.txt): imports, colour, providers, forms. Short. Read it first.',
146
+ '- [DESIGN.md](DESIGN.md): the system in one file (colour, type, spacing, motion, every component in a line, the rules), for a tool that takes a single design file.',
127
147
  '- [Tokens](llms-tokens.txt): every semantic and component token with when to use it.',
128
148
  '- [Helpers](docs/helpers.md): the shared functions and types under src/lib (form errors, money, links, theme, streamed text).',
129
149
  '- [All components, compact](llms-components.txt): every prop signature in one file.',
@@ -145,7 +165,7 @@ export function ruleHeadlines(rules) {
145
165
  .split(/\n\s*\n/)
146
166
  .map(block => block.replace(/\s*\n\s*/g, ' ').trim())
147
167
  .filter(block => block.startsWith('**'))
148
- .map(block => block.match(/^\*\*[^*]+\*\*\s+.*?[.:](?=\s|$)/)?.[0] ?? block)
168
+ .map(block => block.match(/^\*\*[^*]+\*\*\s+.*?\.(?=\s|$)/)?.[0] ?? block)
149
169
  }
150
170
 
151
171
  /** Every component's contract in one compact file: no examples, no prose beyond the summary. */
@@ -203,6 +223,57 @@ export function tokensBundle(manifest) {
203
223
  ].join('\n')
204
224
  }
205
225
 
226
+ /** DESIGN.md: the whole system in one short file, for a tool that takes a single design file.
227
+ * It carries the choosing layer only (semantic colour, spacing, motion, type, one line per
228
+ * component, the rule headlines) and links the full pages rather than inlining them. `fonts` is
229
+ * `[{ name, stack }]` from the `--font-*` theme variables in src/styles/tailwind.css. */
230
+ export function designFile(entries, manifest, { fonts, rules }) {
231
+ const value = token => (typeof token.value === 'string' ? token.value : `dark ${token.value.dark}, light ${token.value.light}`)
232
+ const table = layer => [
233
+ '| Token | Use | Value |',
234
+ '|---|---|---|',
235
+ ...manifest.tokens
236
+ .filter(token => token.layer === layer)
237
+ .map(token => `| ${code(token.name)} | ${cell(token.use)} | ${code(value(token))} |`),
238
+ '',
239
+ ]
240
+ const out = [
241
+ '# Division DS: design',
242
+ '',
243
+ `> A React 19, Tailwind 4 and Radix design system: ${entries.length} components, design tokens in three layers, light and dark themes, WCAG 2.2 AA. This file is the system in one page; each component links its full page with props and examples.`,
244
+ '',
245
+ 'Links are relative to this file. Generated by `npm run agent-docs`.',
246
+ '',
247
+ '## Colour',
248
+ '',
249
+ manifest.rule,
250
+ '',
251
+ 'Write a token as a CSS variable: `bg-[var(--color-surface-base)]`. Use only the names below. Component tokens and the full list: [llms-tokens.txt](llms-tokens.txt).',
252
+ '',
253
+ ...table('semantic'),
254
+ '## Type',
255
+ '',
256
+ ...fonts.map(font => `- ${code(`font-${font.name}`)}: ${code(font.stack)}`),
257
+ '',
258
+ '## Spacing',
259
+ '',
260
+ ...table('spacing'),
261
+ '## Motion',
262
+ '',
263
+ ...table('motion'),
264
+ ]
265
+ for (const [tier, group] of byTier(entries)) {
266
+ out.push(`## ${tier[0].toUpperCase()}${tier.slice(1)}`, '')
267
+ for (const entry of group) {
268
+ const also = alsoExports(entry)
269
+ out.push(`- [${entry.name}](${entry.docs.replace(/^\//, '')}): ${firstSentence(entry.summary)}${also.length ? ` Also: ${also.join(', ')}.` : ''}`)
270
+ }
271
+ out.push('')
272
+ }
273
+ out.push('## Rules', '', 'In full: [llms-guidelines.txt](llms-guidelines.txt).', '', ...ruleHeadlines(rules).map(line => `- ${line}`), '')
274
+ return out.join('\n')
275
+ }
276
+
206
277
  /** The shared helpers under src/lib, one section per file: exported types whole, functions as
207
278
  * signatures, each with its doc comment. `helpers` is `[{ from, api }]`. */
208
279
  export function helpersPage(helpers) {
@@ -265,6 +336,7 @@ export const SKILL_TOPICS = {
265
336
  overlays: { title: 'Overlays', when: 'adding a Dialog, an AlertDialog or a Sheet' },
266
337
  feedback: { title: 'Feedback', when: 'a screen reports a result or a wait: a toast, an alert, progress, a spinner' },
267
338
  motion: { title: 'Motion', when: 'animating anything yourself' },
339
+ ai: { title: 'Assistant copy', when: 'wording a chat' },
268
340
  }
269
341
 
270
342
  /**
@@ -397,6 +469,8 @@ const SKILL_COMMANDS = {
397
469
  '| `[unknown-prop]` "has no prop" | The prop does not exist. Open the page the message names and use a prop from its table. |',
398
470
  '| `[unknown-prop]` "types X away on purpose" | The component sets that attribute itself. Use the prop it names (`label` for a name, `invalid` for an error state). |',
399
471
  '| `[invalid-value]` | The value is not in the prop\'s union. The message lists the allowed values. |',
472
+ '| `[ai-in-cta]` | A button says "AI". Name what it does instead: "Summarize", "Draft a reply". |',
473
+ '| `[assistant-copy]` | An assistant component\'s label says "answer", "please" or "!". Write "response", drop "please", drop the exclamation point. |',
400
474
  '| "Vocabulary gate FAILED" (this repository only) | Reword the line. If it is an ordinary idiom, ask before adding it to ALLOWED_IDIOMS. |',
401
475
  '| "Agent docs are stale" (this repository only) | Run `npm run agent-docs`, then `npm run verify`. |',
402
476
  '| A props-table test fails in `meta.test.ts` (this repository only) | The `<Name>.meta.ts` table and the real props disagree. Change the table to match the code, both ways. |',
@@ -410,7 +484,7 @@ const SKILL_COMMANDS = {
410
484
  body: [
411
485
  'Go through every line. A line that does not hold is a change to make, not a note to leave.',
412
486
  '',
413
- '- [ ] **States.** Every region has its empty, loading, error, success and disabled or no-permission state, as planned in `commands/build.md`. An empty list says what is empty and offers the action that fills it.',
487
+ '- [ ] **States.** Every region has its empty, loading, error, success and disabled or no-permission state, as planned before the code (the regions-and-states plan of `commands/build.md`). An empty list says what is empty and offers the action that fills it.',
414
488
  '- [ ] **Headings.** One `PageHeader`, so one `h1`. Section headings continue the outline: `h2` under the `h1`, `h3` inside an `h2`, no level skipped. `SectionHeading` is given the level.',
415
489
  '- [ ] **Tokens only.** No hex, `rgb()`, palette class or invented token name. `division-ds check` passes.',
416
490
  '- [ ] **Buttons.** Two or more side by side share a parent with a gap. One primary button per view. A busy button takes `loading`; a disabled one says why nearby. Icon-only actions are `IconButton` with a `label`.',
@@ -505,7 +579,7 @@ export function skillFiles(split, componentCount) {
505
579
  '',
506
580
  // A project that installed the components has no public/ folder of ours (the 2026-10-20
507
581
  // outside test followed these steps to files that did not exist).
508
- `Paths starting \`public/\` are in the Division DS repository. In a project that uses the components, read them online: \`public/llms.txt\` is \`${DOCS_SITE}llms.txt\`, and so on.`,
582
+ `Paths starting \`public/\` are in the Division DS repository. In a project that uses the components, read them online: \`public/llms.txt\` is \`${DOCS_SITE}llms.txt\`, and so on. The other procedures named here (\`commands/build.md\` and the rest) are in the skill: \`${SKILL_SOURCE}commands/<name>.md\`, or \`npx division-ds skill\` to install them.`,
509
583
  '',
510
584
  ...command.body,
511
585
  '',
@@ -0,0 +1,120 @@
1
+ // The AI copy gate: the parts of the "Assistant copy" rule (AGENTS.md, topic `ai`) that a script
2
+ // can check without guessing. Run in a consumer project through `division-ds check` and the MCP
3
+ // `validate_code` (both via check-text.mjs).
4
+ //
5
+ // Where the rules come from: GitLab Pajamas (no "AI" in a call-to-action label: name the action),
6
+ // Cloudscape's generative-AI chat pattern ("response", not "answer"; no exclamation points; no
7
+ // "please") and Intuit's content design guide on writing about AI. The rest of that guidance
8
+ // (who speaks in which person, hedging, naming the model) needs a reader, so it stays prose.
9
+ //
10
+ // Scope is kept narrow on purpose, because a false positive teaches an agent to stop reading the
11
+ // gate:
12
+ // - `ai-in-cta`: the word "AI" (upper case, a whole word) in a button's own label: the text of a
13
+ // <Button> or <button>, an <IconButton label>, an `aria-label` on either, and the button props
14
+ // of the assistant components (approveLabel, rejectLabel, jumpLabel). Any button, imported from
15
+ // Division DS or not: a copy rule is about the words, not the component.
16
+ // - `assistant-copy`: "answer", "please" or "!" in the interface text of the assistant components
17
+ // (ChatThread, ChatMessage, PromptInput, ApprovalRequest, ToolCall): their label-like string
18
+ // props only. Message bodies, a ToolCall's input and output and PromptInput's suggestions are
19
+ // content written by the person or the model, not the interface, and are never read. A button
20
+ // placed inside one of them (a Button in ChatMessage's `actions`) is its interface too: its label
21
+ // and aria-label are checked the same way (missed by the 2026-10-29 outside test's probe).
22
+ // Only literals are read (a string attribute, JSX text, a ternary of two literals). A variable is
23
+ // not judged, the same as the props gate.
24
+ import ts from 'typescript'
25
+ import { scriptKind } from './props-validator.mjs'
26
+
27
+ const BUTTONS = new Set(['Button', 'button'])
28
+ /** Each assistant component's interface-text props. Everything else on them is content or data. */
29
+ const ASSISTANT_COPY = {
30
+ ChatThread: ['label', 'busyLabel', 'jumpLabel'],
31
+ ChatMessage: ['author', 'timeLabel'],
32
+ PromptInput: ['label', 'placeholder', 'hint'],
33
+ ApprovalRequest: ['title', 'approveLabel', 'rejectLabel', 'approvedLabel', 'rejectedLabel'],
34
+ ToolCall: ['title'],
35
+ }
36
+ const BUTTON_PROPS = { IconButton: ['label', 'aria-label'], Button: ['aria-label'], button: ['aria-label'], ApprovalRequest: ['approveLabel', 'rejectLabel'], ChatThread: ['jumpLabel'] }
37
+
38
+ const AI_WORD = /\bAI\b/
39
+ const ASSISTANT_WORDS = [
40
+ { pattern: /\banswer(s|ed|ing)?\b/i, fix: 'Write "response" (or "reply"), not "answer": the assistant responds; it is not always right.' },
41
+ { pattern: /\bplease\b/i, fix: 'Drop "please": the interface states the action plainly.' },
42
+ { pattern: /!/, fix: 'Drop the exclamation point: assistant copy stays calm, whatever the result.' },
43
+ ]
44
+ const AI_FIX = 'Name the action ("Summarize", "Draft a reply"), not the technology: a call to action never says "AI".'
45
+
46
+ /** The literal strings an attribute can hold, as nodes; none when it is not a literal. */
47
+ function literals(initializer) {
48
+ if (!initializer) return []
49
+ if (ts.isStringLiteral(initializer)) return [initializer]
50
+ if (!ts.isJsxExpression(initializer) || !initializer.expression) return []
51
+ const collect = node => {
52
+ while (ts.isParenthesizedExpression(node)) node = node.expression
53
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return [node]
54
+ if (ts.isConditionalExpression(node)) return [...collect(node.whenTrue), ...collect(node.whenFalse)]
55
+ return []
56
+ }
57
+ return collect(initializer.expression)
58
+ }
59
+
60
+ /** Every AI copy finding in one file: `{ path, line, column, rule, found, fix }`. */
61
+ export function findAiCopyViolations(text, path) {
62
+ const source = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true, scriptKind(path))
63
+ const violations = []
64
+ const add = (node, rule, found, fix) => {
65
+ const at = source.getLineAndCharacterOfPosition(node.getStart(source))
66
+ violations.push({ path, line: at.line + 1, column: at.character + 1, rule, found, fix })
67
+ }
68
+ // How many assistant components the walk is inside: a button in there speaks for the assistant.
69
+ let insideAssistant = 0
70
+ const checkCta = (node, value, where) => {
71
+ if (AI_WORD.test(value)) add(node, 'ai-in-cta', `${where}: "${value.trim()}"`, AI_FIX)
72
+ if (insideAssistant > 0) {
73
+ for (const word of ASSISTANT_WORDS) {
74
+ if (word.pattern.test(value)) add(node, 'assistant-copy', `${where} in an assistant component: "${value.trim()}"`, word.fix)
75
+ }
76
+ }
77
+ }
78
+ const tagOf = node =>
79
+ ts.isJsxElement(node) ? node.openingElement.tagName.getText(source) : ts.isJsxSelfClosingElement(node) ? node.tagName.getText(source) : undefined
80
+
81
+ const visit = node => {
82
+ const opensAssistant = tagOf(node) in ASSISTANT_COPY
83
+ if (opensAssistant) insideAssistant++
84
+ if (ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node)) {
85
+ const tag = node.tagName.getText(source)
86
+ for (const attribute of node.attributes.properties) {
87
+ if (!ts.isJsxAttribute(attribute)) continue
88
+ const prop = attribute.name.getText(source)
89
+ for (const value of literals(attribute.initializer)) {
90
+ if (BUTTON_PROPS[tag]?.includes(prop)) checkCta(value, value.text, `<${tag} ${prop}>`)
91
+ if (ASSISTANT_COPY[tag]?.includes(prop)) {
92
+ for (const word of ASSISTANT_WORDS) {
93
+ if (word.pattern.test(value.text)) add(value, 'assistant-copy', `<${tag} ${prop}>: "${value.text}"`, word.fix)
94
+ }
95
+ }
96
+ }
97
+ }
98
+ }
99
+ if (ts.isJsxElement(node) && BUTTONS.has(node.openingElement.tagName.getText(source))) {
100
+ const tag = node.openingElement.tagName.getText(source)
101
+ const parts = []
102
+ for (const child of node.children) {
103
+ if (ts.isJsxText(child)) parts.push(child.text)
104
+ else if (ts.isJsxExpression(child) && child.expression && (ts.isStringLiteral(child.expression) || ts.isNoSubstitutionTemplateLiteral(child.expression))) {
105
+ parts.push(child.expression.text)
106
+ }
107
+ }
108
+ const label = parts.join(' ').replace(/\s+/g, ' ')
109
+ if (label.trim()) checkCta(node, label, `<${tag}> label`)
110
+ }
111
+ ts.forEachChild(node, visit)
112
+ if (opensAssistant) insideAssistant--
113
+ }
114
+ visit(source)
115
+ return violations.sort((a, b) => a.line - b.line || a.column - b.column)
116
+ }
117
+
118
+ export function formatAiCopyViolation({ path, line, column, rule, found, fix }) {
119
+ return `${path}:${line}:${column} [${rule}] ${found}\n ${fix}`
120
+ }
@@ -91,7 +91,7 @@ function scanAttrs(text, start) {
91
91
 
92
92
  /** Every JSX tag token in `text`, in source order: `{ type: 'open' | 'close' | 'self', name,
93
93
  * attrsText, start }`. `start` is the index of the tag's leading `<`. */
94
- function tokenizeTags(text) {
94
+ export function tokenizeTags(text) {
95
95
  const tokens = []
96
96
  let i = 0
97
97
  while (i < text.length) {
@@ -306,7 +306,7 @@ function hasWorkingGap(className) {
306
306
  * on the stack, otherwise it is ignored -- real TSX is always balanced; this only has to not throw
307
307
  * on the odd case this scanner misreads.
308
308
  */
309
- function buildTree(tokens) {
309
+ export function buildTree(tokens) {
310
310
  const root = { name: null, attrsText: '', attrs: new Map(), start: -1, children: [] }
311
311
  const stack = [root]
312
312
  for (const token of tokens) {
@@ -343,7 +343,7 @@ function buildTree(tokens) {
343
343
  * only child element is a button -- the `SheetClose asChild><Button/></SheetClose>` shape that is
344
344
  * exactly what shipped broken. Only one level of unwrapping: an `asChild` wrapper around an
345
345
  * `asChild` wrapper is not chased further. */
346
- function isButtonLike(node) {
346
+ export function isButtonLike(node) {
347
347
  if (BUTTON_TAGS.has(node.name)) return true
348
348
  if (node.attrs.get('asChild') === 'true' && node.children.length === 1) {
349
349
  return BUTTON_TAGS.has(node.children[0].name)
@@ -351,7 +351,7 @@ function isButtonLike(node) {
351
351
  return false
352
352
  }
353
353
 
354
- function lineIndex(text) {
354
+ export function lineIndex(text) {
355
355
  const offsets = [0]
356
356
  for (let i = 0; i < text.length; i++) {
357
357
  if (text[i] === '\n') offsets.push(i + 1)
@@ -359,7 +359,7 @@ function lineIndex(text) {
359
359
  return offsets
360
360
  }
361
361
 
362
- function lineColAt(offsets, index) {
362
+ export function lineColAt(offsets, index) {
363
363
  let lo = 0
364
364
  let hi = offsets.length - 1
365
365
  while (lo < hi) {
@@ -0,0 +1,57 @@
1
+ // The five consumer gates over one file's text, as data. `division-ds check` runs it per file
2
+ // on disk; the MCP server's `validate_code` runs it on a snippet an agent passes in. One function,
3
+ // so the two can never disagree about what a finding is.
4
+ import { findAiCopyViolations, formatAiCopyViolation } from './ai-copy.mjs'
5
+ import { findViolations as buttonSpacing, formatViolation as formatButtonSpacing } from './button-spacing.mjs'
6
+ import { findInlineViolations, formatInlineViolation } from './host-inline.mjs'
7
+ import { findPropViolations, formatPropViolation } from './props-validator.mjs'
8
+ import { findViolations as tokenDiscipline, formatViolation as formatTokenDiscipline } from './token-discipline.mjs'
9
+
10
+ export const BUTTON_SPACING_FIX =
11
+ 'Wrap the buttons in one parent with a flex/grid class and a gap- utility (flex flex-wrap items-center gap-3), and move any margin from the buttons onto it.'
12
+
13
+ /** A stylesheet with the value of every declaration of a real token blanked, newlines kept so line
14
+ * numbers hold. Setting a token is how a project themes the system (the theme builder writes
15
+ * exactly that into index.css), so a literal colour there is the point; anywhere else in the file,
16
+ * or on a custom property the system does not have, it is reported as in any other file. */
17
+ export function blankTokenOverrides(text, tokenNames) {
18
+ const known = new Set(tokenNames)
19
+ return text.replace(/(--[a-z0-9-]+)(\s*:)([^;}]*)/g, (match, name, colon, value) =>
20
+ // The var() references stay, so a token set from a name that does not exist is still caught.
21
+ known.has(name) ? name + colon + value.replace(/var\([^)]*\)|[^\n]/g, part => (part.length > 1 ? part : ' ')) : match
22
+ )
23
+ }
24
+
25
+ /** Every finding in `text`. `path` decides which gates apply (button spacing and AI copy need JSX) and is
26
+ * echoed into each finding. `index` is buildIndex(manifest); `tokenNames` the token names. */
27
+ export function checkText(text, path, index, tokenNames) {
28
+ const findings = []
29
+ if (/\.css$/.test(path)) text = blankTokenOverrides(text, tokenNames)
30
+ for (const v of tokenDiscipline(text, path, tokenNames)) {
31
+ findings.push({ gate: 'token-discipline', path, line: v.line, rule: v.rule, found: v.found, fix: v.fix, text: formatTokenDiscipline(v) })
32
+ }
33
+ if (/\.[jt]sx$/.test(path)) {
34
+ for (const v of buttonSpacing(text, path)) {
35
+ findings.push({
36
+ gate: 'button-spacing',
37
+ path,
38
+ line: v.line,
39
+ column: v.column,
40
+ rule: 'touching-buttons',
41
+ found: `<${v.tag}> with ${v.count} button-like children`,
42
+ fix: BUTTON_SPACING_FIX,
43
+ text: formatButtonSpacing({ ...v, file: path }),
44
+ })
45
+ }
46
+ for (const v of findInlineViolations(text)) {
47
+ findings.push({ gate: 'host-inline', path, line: v.line, column: v.column, rule: v.rule, found: v.found, fix: v.fix, text: formatInlineViolation(v, path) })
48
+ }
49
+ for (const v of findAiCopyViolations(text, path)) {
50
+ findings.push({ gate: 'ai-copy', path, line: v.line, column: v.column, rule: v.rule, found: v.found, fix: v.fix, text: formatAiCopyViolation(v) })
51
+ }
52
+ }
53
+ for (const v of findPropViolations(text, path, index)) {
54
+ findings.push({ gate: 'props', path, line: v.line, column: v.column, rule: v.rule, found: v.found, fix: v.fix, text: formatPropViolation(v) })
55
+ }
56
+ return findings
57
+ }