division-ds 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.
Files changed (95) hide show
  1. package/AGENTS.md +11 -5
  2. package/division-ds-0.3.0.tgz +0 -0
  3. package/package.json +1 -1
  4. package/public/docs/components/accordion.md +2 -0
  5. package/public/docs/components/ai-label.md +2 -0
  6. package/public/docs/components/alert-dialog.md +2 -0
  7. package/public/docs/components/alert.md +2 -0
  8. package/public/docs/components/approval-request.md +2 -0
  9. package/public/docs/components/auth-form.md +2 -0
  10. package/public/docs/components/avatar-group.md +2 -0
  11. package/public/docs/components/avatar.md +2 -0
  12. package/public/docs/components/badge.md +2 -0
  13. package/public/docs/components/breadcrumbs.md +2 -0
  14. package/public/docs/components/button.md +2 -0
  15. package/public/docs/components/card.md +2 -0
  16. package/public/docs/components/carousel.md +2 -0
  17. package/public/docs/components/cart.md +2 -0
  18. package/public/docs/components/chart-panel.md +2 -0
  19. package/public/docs/components/chat-message.md +3 -0
  20. package/public/docs/components/chat-thread.md +2 -0
  21. package/public/docs/components/checkbox.md +2 -0
  22. package/public/docs/components/checkout-form.md +2 -0
  23. package/public/docs/components/command-palette.md +6 -1
  24. package/public/docs/components/data-table.md +2 -0
  25. package/public/docs/components/date-picker.md +166 -0
  26. package/public/docs/components/date-presets.md +111 -0
  27. package/public/docs/components/date-range-picker.md +135 -0
  28. package/public/docs/components/description-list.md +2 -0
  29. package/public/docs/components/dialog.md +2 -0
  30. package/public/docs/components/divider.md +2 -0
  31. package/public/docs/components/dropdown-menu.md +2 -0
  32. package/public/docs/components/empty-state.md +2 -0
  33. package/public/docs/components/footer.md +2 -0
  34. package/public/docs/components/form-field.md +4 -1
  35. package/public/docs/components/icon-button.md +2 -0
  36. package/public/docs/components/input.md +2 -0
  37. package/public/docs/components/kanban-board.md +2 -0
  38. package/public/docs/components/kbd.md +2 -0
  39. package/public/docs/components/link.md +2 -0
  40. package/public/docs/components/list-item.md +3 -0
  41. package/public/docs/components/loading-animation.md +2 -0
  42. package/public/docs/components/multi-select.md +2 -0
  43. package/public/docs/components/navbar.md +2 -0
  44. package/public/docs/components/newsletter-form.md +2 -0
  45. package/public/docs/components/page-header.md +2 -0
  46. package/public/docs/components/pagination.md +2 -0
  47. package/public/docs/components/product-card.md +3 -0
  48. package/public/docs/components/progress.md +3 -1
  49. package/public/docs/components/prompt-input.md +2 -0
  50. package/public/docs/components/quantity-stepper.md +2 -0
  51. package/public/docs/components/radio.md +38 -1
  52. package/public/docs/components/rating.md +2 -0
  53. package/public/docs/components/score-ring.md +2 -0
  54. package/public/docs/components/search.md +2 -0
  55. package/public/docs/components/section-heading.md +2 -0
  56. package/public/docs/components/segmented-control.md +2 -0
  57. package/public/docs/components/select.md +3 -1
  58. package/public/docs/components/sheet.md +2 -0
  59. package/public/docs/components/sidebar.md +2 -0
  60. package/public/docs/components/skeleton.md +2 -0
  61. package/public/docs/components/slider.md +3 -1
  62. package/public/docs/components/sparkline.md +2 -0
  63. package/public/docs/components/spinner.md +4 -2
  64. package/public/docs/components/stat.md +2 -0
  65. package/public/docs/components/status-tracker.md +2 -0
  66. package/public/docs/components/stepper.md +2 -0
  67. package/public/docs/components/switch.md +4 -2
  68. package/public/docs/components/tabs.md +2 -0
  69. package/public/docs/components/tag.md +2 -0
  70. package/public/docs/components/textarea.md +2 -0
  71. package/public/docs/components/time-field.md +127 -0
  72. package/public/docs/components/timeline.md +2 -0
  73. package/public/docs/components/toast.md +2 -0
  74. package/public/docs/components/tool-call.md +2 -0
  75. package/public/docs/components/tooltip.md +2 -0
  76. package/public/manifest/components.json +947 -72
  77. package/public/manifest/tokens.json +36 -5
  78. package/scripts/division-ds.mjs +5 -5
  79. package/scripts/lib/agent-docs.mjs +49 -6
  80. package/scripts/lib/ai-copy.mjs +15 -1
  81. package/scripts/lib/check-text.mjs +13 -0
  82. package/scripts/lib/mcp-server.mjs +2 -1
  83. package/scripts/lib/mcp-tools.mjs +25 -12
  84. package/scripts/lib/props-validator.mjs +2 -1
  85. package/scripts/lib/search-score.mjs +24 -0
  86. package/scripts/lib/token-discipline.mjs +10 -3
  87. package/skills/division-ds/SKILL.md +2 -2
  88. package/skills/division-ds/commands/add.md +1 -1
  89. package/skills/division-ds/commands/build.md +1 -1
  90. package/skills/division-ds/commands/fix.md +1 -1
  91. package/skills/division-ds/commands/review.md +2 -2
  92. package/skills/division-ds/topics/ai.md +2 -1
  93. package/skills/division-ds/topics/forms.md +3 -0
  94. package/skills/division-ds/topics/layout.md +3 -1
  95. package/division-ds-0.2.0.tgz +0 -0
@@ -564,7 +564,11 @@
564
564
  "name": "--color-accent-signal-fill",
565
565
  "layer": "semantic",
566
566
  "value": "#f2550f",
567
- "use": "A solid block in the brand orange: the primary Button, a solid signal Badge. Put accent.signal.ink on it, never text.primary."
567
+ "use": "A solid block in the brand colour, exactly as chosen: the primary Button, IconButton, a solid signal Badge. Put accent.signal.ink on it, never text.primary. Never use it as the only sign of a state; use accent.signal.indicator.",
568
+ "measured": {
569
+ "dark": "The brand colour, exactly as chosen: Button, IconButton and solid Badge backgrounds, and brand decoration. Text on it is accent.signal.ink. Never the only sign of a state; that is accent.signal.indicator. The theme builder never moves it.",
570
+ "light": "The brand colour, exactly as chosen: Button, IconButton and solid Badge backgrounds, and brand decoration. Text on it is accent.signal.ink. Never the only sign of a state; that is accent.signal.indicator. The theme builder never moves it."
571
+ }
568
572
  },
569
573
  {
570
574
  "name": "--color-accent-signal-text",
@@ -585,7 +589,28 @@
585
589
  "value": "#0a0602",
586
590
  "use": "Text and icons on accent.signal.fill.",
587
591
  "measured": {
588
- "dark": "5.85:1 on accent.signal.fill"
592
+ "dark": "5.85:1 on accent.signal.fill",
593
+ "light": "5.85:1 on accent.signal.fill"
594
+ }
595
+ },
596
+ {
597
+ "name": "--color-accent-signal-indicator",
598
+ "layer": "semantic",
599
+ "value": "#f2550f",
600
+ "use": "The accent where colour alone shows a state or value: a checked control, Switch on, Progress, Slider, Rating stars, the current item in Sidebar, Navbar, Tabs, SegmentedControl, Stepper and highlighted menu rows. Put accent.signal.indicator-ink on it.",
601
+ "measured": {
602
+ "dark": "The accent where colour alone marks a state or a value: checked controls, Switch on, Progress, Slider, Rating stars, the current item of Sidebar, Navbar, Tabs, SegmentedControl, Stepper and highlighted menu rows, and the hero numerals. 5.80:1 on surface.base. Same stop as accent.signal.fill here; the theme builder moves it to a nearby stop when a chosen accent is too pale or too dark to reach 3:1, and leaves accent.signal.fill alone.",
603
+ "light": "The accent where colour alone marks a state or a value: checked controls, Switch on, Progress, Slider, Rating stars, the current item of Sidebar, Navbar, Tabs, SegmentedControl, Stepper and highlighted menu rows, and the hero numerals. 3.14:1 on surface.base. Same stop as accent.signal.fill here; the theme builder moves it to a nearby stop when a chosen accent is too pale or too dark to reach 3:1, and leaves accent.signal.fill alone."
604
+ }
605
+ },
606
+ {
607
+ "name": "--color-accent-signal-indicator-ink",
608
+ "layer": "semantic",
609
+ "value": "#0a0602",
610
+ "use": "Text and icons on accent.signal.indicator.",
611
+ "measured": {
612
+ "dark": "Text and icons on accent.signal.indicator. 5.85:1. The theme builder picks white or this dark ink, whichever reaches 4.5:1.",
613
+ "light": "Text and icons on accent.signal.indicator. 5.85:1. The theme builder picks white or this dark ink, whichever reaches 4.5:1."
589
614
  }
590
615
  },
591
616
  {
@@ -595,7 +620,7 @@
595
620
  "use": "The faint orange hover wash under a nav item or an inactive tab.",
596
621
  "measured": {
597
622
  "dark": "Hover wash under a nav item or an inactive tab. Text on it is text.primary and gated. The focus ring and a checked control's fill drop under 3:1 on it in light theme, so neither is ever drawn on it.",
598
- "light": "Hover wash under a nav item or an inactive tab. Text on it is text.primary and gated. The focus ring and a checked control's fill drop under 3:1 on it in light theme, so neither is ever drawn on it."
623
+ "light": "Hover wash under a nav item or an inactive tab. Text on it is text.primary and gated. A checked control's indicator drops under 3:1 on it in light theme and the focus ring is not gated on it, so neither is ever drawn on it."
599
624
  }
600
625
  },
601
626
  {
@@ -782,8 +807,14 @@
782
807
  {
783
808
  "name": "--color-focus-ring",
784
809
  "layer": "semantic",
785
- "value": "#f2550f",
786
- "use": "The keyboard focus outline. Use it only in focus-visible:outline-[var(--color-focus-ring)], with a 2px outline and a 2px offset."
810
+ "value": {
811
+ "dark": "#f2550f",
812
+ "light": "#c6440b"
813
+ },
814
+ "use": "The keyboard focus outline. Use it only in focus-visible:outline-[var(--color-focus-ring)], with a 2px outline and a 2px offset.",
815
+ "measured": {
816
+ "light": "4.51:1 on surface.base. A step darker than accent.signal.fill, which reaches only 3.14:1 on the light page: the ring is the one mark every keyboard user depends on, so it gets a margin."
817
+ }
787
818
  },
788
819
  {
789
820
  "name": "--color-chart-series-1",
@@ -19,7 +19,7 @@
19
19
  import { existsSync, globSync, readdirSync, readFileSync, statSync } from 'node:fs'
20
20
  import { dirname, join, relative, resolve } from 'node:path'
21
21
  import { fileURLToPath } from 'node:url'
22
- import { extractRules } from './lib/agent-docs.mjs'
22
+ import { DOCS_SITE, extractRules, SKILL_SOURCE } from './lib/agent-docs.mjs'
23
23
  import { checkText } from './lib/check-text.mjs'
24
24
  import { createServer } from './lib/mcp-server.mjs'
25
25
  import { createTools } from './lib/mcp-tools.mjs'
@@ -28,14 +28,14 @@ import { applyPlan, diff, planRules, planSkill, SKILL_TARGETS, TARGETS } from '.
28
28
 
29
29
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
30
30
  const DEFAULT_MANIFEST = join(ROOT, 'public/manifest/components.json')
31
- const CHECKED = /\.(tsx|jsx|ts)$/
31
+ const CHECKED = /\.(tsx|jsx|ts|css)$/
32
32
  const SKIPPED_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'coverage'])
33
33
 
34
34
  const HELP = `division-ds: Division DS gates and agent rules for a project that uses the components.
35
35
 
36
36
  Usage:
37
37
  division-ds check [paths...] [--manifest <path|url>] [--tokens <path|url>] [--json]
38
- Checks .tsx, .jsx and .ts files (default: ./src). A path may be a file, a directory or a
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
39
  glob ("src/**/*.tsx"). Runs five gates and exits 1 on any finding:
40
40
  token-discipline raw colours, Tailwind palette classes, token names that do not exist
41
41
  button-spacing two Buttons/IconButtons side by side with no gap on their parent
@@ -149,12 +149,12 @@ async function check(options) {
149
149
  if (gate === 'button-spacing') console.error(` ${ofGate[0].fix}`)
150
150
  console.error('')
151
151
  }
152
- 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.`)
153
153
  } else {
154
154
  console.log(`division-ds check passed: ${files.length} file(s), ${index.size} components, ${tokenNames.length} tokens, 0 findings.`)
155
155
  // It reads code, not the running page: say so, or a pass reads as "the screen works" (the
156
156
  // 2026-10-20 outside test shipped three runtime bugs past a clean check).
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 (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).`)
158
158
  }
159
159
  process.exit(findings.length > 0 ? 1 : 0)
160
160
  }
@@ -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,50 @@ 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
+ /** The docs site's top-bar search reads this, fetched the first time search opens. Slim on purpose:
35
+ * the names, the one-line summaries and the prop names and descriptions the shared scorer
36
+ * (search-score.mjs) ranks on, and the screen-facing tokens. Primitives are left out, as in the
37
+ * MCP search: they are never the answer for a screen. */
38
+ export function searchIndex(entries, tokens) {
39
+ const index = {
40
+ generatedBy: 'scripts/build-agent-docs.mjs',
41
+ components: entries.map(entry => ({
42
+ name: entry.name,
43
+ slug: entry.slug,
44
+ tier: entry.tier,
45
+ summary: entry.summary,
46
+ useWhen: entry.useWhen,
47
+ also: alsoExports(entry),
48
+ props: entry.props.map(prop => ({ name: prop.name, description: prop.description ?? '' })),
49
+ })),
50
+ tokens: tokens.tokens.filter(token => token.layer !== 'primitive').map(token => ({ name: token.name, layer: token.layer, use: token.use ?? '' })),
51
+ }
52
+ return JSON.stringify(index) + '\n'
53
+ }
54
+
55
+ /** Said under every Import block: the paths are the repository's, not an installed project's. */
56
+ 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.'
57
+
22
58
  /** One component as a markdown page. `entry` is a manifest entry (see `manifestEntry`). */
23
59
  export function componentMarkdown(entry) {
24
60
  const out = [`# ${entry.name}`, '', `> ${entry.summary}`, '']
25
61
  out.push(`- **Best for:** ${entry.useWhen}`, `- **Not for:** ${entry.notFor}`)
62
+ const also = alsoExports(entry)
63
+ if (also.length) out.push(`- **Also on this page:** ${also.map(code).join(', ')}`)
26
64
  out.push(`- ${TIER_NOUN[entry.tier]}. Source: \`${entry.source}\``)
27
65
  out.push(`- Rendered docs page: \`/components/${entry.slug}\``, '')
28
66
 
29
- out.push('## Import', '', fence(importLines(entry)), '')
67
+ out.push('## Import', '', fence(importLines(entry)), '', INSTALLED_PATHS, '')
30
68
 
31
69
  if (entry.requires?.length) {
32
70
  out.push('## Requires', '', ...entry.requires.map(item => `- ${item}`), '')
@@ -108,8 +146,10 @@ const byTier = entries =>
108
146
  export const firstSentence = text => text.match(/^.*?[.!?](?=\s|$)/)?.[0] ?? text
109
147
 
110
148
  /** 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}`
149
+ export const indexLine = entry => {
150
+ const also = alsoExports(entry)
151
+ return `- [${entry.name}](${entry.docs.replace(/^\//, '')}): ${firstSentence(entry.summary)} Best for: ${entry.useWhen} Not for: ${entry.notFor}${also.length ? ` Also: ${also.join(', ')}.` : ''}`
152
+ }
113
153
 
114
154
  /** llms.txt, in the llmstxt.org shape: a title, a summary, then sections of links. */
115
155
  export function llmsIndex(entries, { rules }) {
@@ -245,7 +285,10 @@ export function designFile(entries, manifest, { fonts, rules }) {
245
285
  ]
246
286
  for (const [tier, group] of byTier(entries)) {
247
287
  out.push(`## ${tier[0].toUpperCase()}${tier.slice(1)}`, '')
248
- for (const entry of group) out.push(`- [${entry.name}](${entry.docs.replace(/^\//, '')}): ${firstSentence(entry.summary)}`)
288
+ for (const entry of group) {
289
+ const also = alsoExports(entry)
290
+ out.push(`- [${entry.name}](${entry.docs.replace(/^\//, '')}): ${firstSentence(entry.summary)}${also.length ? ` Also: ${also.join(', ')}.` : ''}`)
291
+ }
249
292
  out.push('')
250
293
  }
251
294
  out.push('## Rules', '', 'In full: [llms-guidelines.txt](llms-guidelines.txt).', '', ...ruleHeadlines(rules).map(line => `- ${line}`), '')
@@ -462,7 +505,7 @@ const SKILL_COMMANDS = {
462
505
  body: [
463
506
  'Go through every line. A line that does not hold is a change to make, not a note to leave.',
464
507
  '',
465
- '- [ ] **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.',
508
+ '- [ ] **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.',
466
509
  '- [ ] **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.',
467
510
  '- [ ] **Tokens only.** No hex, `rgb()`, palette class or invented token name. `division-ds check` passes.',
468
511
  '- [ ] **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`.',
@@ -557,7 +600,7 @@ export function skillFiles(split, componentCount) {
557
600
  '',
558
601
  // A project that installed the components has no public/ folder of ours (the 2026-10-20
559
602
  // outside test followed these steps to files that did not exist).
560
- `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.`,
603
+ `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.`,
561
604
  '',
562
605
  ...command.body,
563
606
  '',
@@ -16,7 +16,9 @@
16
16
  // - `assistant-copy`: "answer", "please" or "!" in the interface text of the assistant components
17
17
  // (ChatThread, ChatMessage, PromptInput, ApprovalRequest, ToolCall): their label-like string
18
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.
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).
20
22
  // Only literals are read (a string attribute, JSX text, a ternary of two literals). A variable is
21
23
  // not judged, the same as the props gate.
22
24
  import ts from 'typescript'
@@ -63,11 +65,22 @@ export function findAiCopyViolations(text, path) {
63
65
  const at = source.getLineAndCharacterOfPosition(node.getStart(source))
64
66
  violations.push({ path, line: at.line + 1, column: at.character + 1, rule, found, fix })
65
67
  }
68
+ // How many assistant components the walk is inside: a button in there speaks for the assistant.
69
+ let insideAssistant = 0
66
70
  const checkCta = (node, value, where) => {
67
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
+ }
68
77
  }
78
+ const tagOf = node =>
79
+ ts.isJsxElement(node) ? node.openingElement.tagName.getText(source) : ts.isJsxSelfClosingElement(node) ? node.tagName.getText(source) : undefined
69
80
 
70
81
  const visit = node => {
82
+ const opensAssistant = tagOf(node) in ASSISTANT_COPY
83
+ if (opensAssistant) insideAssistant++
71
84
  if (ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node)) {
72
85
  const tag = node.tagName.getText(source)
73
86
  for (const attribute of node.attributes.properties) {
@@ -96,6 +109,7 @@ export function findAiCopyViolations(text, path) {
96
109
  if (label.trim()) checkCta(node, label, `<${tag}> label`)
97
110
  }
98
111
  ts.forEachChild(node, visit)
112
+ if (opensAssistant) insideAssistant--
99
113
  }
100
114
  visit(source)
101
115
  return violations.sort((a, b) => a.line - b.line || a.column - b.column)
@@ -10,10 +10,23 @@ import { findViolations as tokenDiscipline, formatViolation as formatTokenDiscip
10
10
  export const BUTTON_SPACING_FIX =
11
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
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
+
13
25
  /** Every finding in `text`. `path` decides which gates apply (button spacing and AI copy need JSX) and is
14
26
  * echoed into each finding. `index` is buildIndex(manifest); `tokenNames` the token names. */
15
27
  export function checkText(text, path, index, tokenNames) {
16
28
  const findings = []
29
+ if (/\.css$/.test(path)) text = blankTokenOverrides(text, tokenNames)
17
30
  for (const v of tokenDiscipline(text, path, tokenNames)) {
18
31
  findings.push({ gate: 'token-discipline', path, line: v.line, rule: v.rule, found: v.found, fix: v.fix, text: formatTokenDiscipline(v) })
19
32
  }
@@ -12,7 +12,8 @@ export const PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '202
12
12
 
13
13
  const INSTRUCTIONS = `Division DS: a React 19, Tailwind 4 and Radix design system.
14
14
  Before writing a component, call get_component for its props and examples. Use only tokens from get_tokens or search, as var(--name).
15
- Before saying a screen is done, call validate_code on each file you wrote and fix every finding, then go through get_review_checklist.`
15
+ Before saying a screen is done, call validate_code on each file you wrote (.css too) and fix every finding, then go through get_review_checklist.
16
+ Shared helpers (theme toggle, form errors, money, links): https://victor-ilioiu.vercel.app/systems/division-ds/docs/helpers.md`
16
17
 
17
18
  /** A JSON-RPC error reply. */
18
19
  const failure = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } })
@@ -5,9 +5,11 @@
5
5
  // Most design-system MCP servers stop at list / search / get docs (shadcn, Chakra, Dialtone,
6
6
  // Blade, Vibe; surveyed 2026-10-25). `validate_code` is the one they lack: it runs the same gates
7
7
  // as `division-ds check` on the code the agent has just written, before the agent says it is done.
8
+ import { alsoExports, INSTALLED_PATHS } from './agent-docs.mjs'
8
9
  import { checkText } from './check-text.mjs'
9
10
  import { contrastRatio } from './contrast.mjs'
10
11
  import { buildIndex, closest } from './props-validator.mjs'
12
+ import { scorer, words } from './search-score.mjs'
11
13
 
12
14
  export const REGISTRY_BASE = 'https://raw.githubusercontent.com/Victor-Ilioiu/division-ds/main/public/r'
13
15
  const TIERS = ['atoms', 'molecules', 'organisms']
@@ -15,7 +17,6 @@ const LAYERS = ['primitive', 'spacing', 'motion', 'semantic', 'component']
15
17
  // WCAG 2.2 minimums: SC 1.4.3 for text, SC 1.4.11 for the boundary of a control or a graphic.
16
18
  const MINIMUMS = { normal: 4.5, large: 3, ui: 3 }
17
19
 
18
- const words = text => String(text).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)
19
20
 
20
21
  /** `context`: { manifest, tokens, version, readDoc(slug) -> string|undefined, readChecklist() -> string|undefined } */
21
22
  export function createTools(context) {
@@ -30,7 +31,11 @@ export function createTools(context) {
30
31
 
31
32
  function findComponent(name) {
32
33
  const wanted = String(name ?? '').trim().toLowerCase()
33
- return components.find(c => c.name.toLowerCase() === wanted || c.slug === wanted)
34
+ return (
35
+ components.find(c => c.name.toLowerCase() === wanted || c.slug === wanted) ??
36
+ // A component documented on another's page (ResponseFeedback on ChatMessage's) opens that page.
37
+ components.find(c => alsoExports(c).some(other => other.toLowerCase() === wanted))
38
+ )
34
39
  }
35
40
 
36
41
  function unknownComponent(name) {
@@ -66,7 +71,12 @@ export function createTools(context) {
66
71
  run({ tier } = {}) {
67
72
  if (tier !== undefined && !TIERS.includes(tier)) throw new ToolError(`tier is one of ${TIERS.join(', ')}.`)
68
73
  const rows = components.filter(c => !tier || c.tier === tier)
69
- return rows.map(c => `- ${c.name} (${c.tier}): ${c.useWhen}${c.notFor ? ` Not for: ${c.notFor}` : ''}`).join('\n')
74
+ return rows
75
+ .map(c => {
76
+ const also = alsoExports(c)
77
+ return `- ${c.name} (${c.tier}): ${c.useWhen}${c.notFor ? ` Not for: ${c.notFor}` : ''}${also.length ? ` Also on its page: ${also.join(', ')}.` : ''}`
78
+ })
79
+ .join('\n')
70
80
  },
71
81
  },
72
82
  {
@@ -85,7 +95,7 @@ export function createTools(context) {
85
95
  const c = findComponent(name) ?? unknownComponent(name)
86
96
  const imports = c.imports.map(i => `import { ${i.names.join(', ')} } from '${i.from}'`).join('\n')
87
97
  const props = c.props.map(p => `- ${p.name}: ${p.type}${p.default ? ` = ${p.default}` : ''}. ${p.description ?? ''}`.trimEnd()).join('\n')
88
- const head = `# ${c.name} (${c.tier})\n\n${c.summary}\n\n${imports}\n\nPaths are from the Division DS repository root; installed with shadcn they start src/division-ds/.\n\n## Props\n\n${props}`
98
+ const head = `# ${c.name} (${c.tier})\n\n${c.summary}\n\n${imports}\n\n${INSTALLED_PATHS}\n\n## Props\n\n${props}`
89
99
  if (section === 'props') return head
90
100
  const page = context.readDoc?.(c.slug)
91
101
  // The page is the full contract (examples, keyboard, do and don't, written for agents).
@@ -112,13 +122,16 @@ export function createTools(context) {
112
122
  run({ query, limit = 10 }) {
113
123
  const terms = words(query)
114
124
  if (terms.length === 0) throw new ToolError('query has no words to search for.')
115
- const score = (title, body) => {
116
- const t = words(title).join(' ')
117
- const b = words(body)
118
- return terms.reduce((sum, term) => sum + (t.includes(term) ? 3 : 0) + b.filter(w => w.startsWith(term)).length, 0)
119
- }
125
+ const score = scorer(query)
120
126
  const hits = [
121
- ...components.map(c => ({ kind: 'component', name: c.name, line: c.useWhen, score: score(c.name, `${c.summary} ${c.useWhen}`) })),
127
+ // The title holds every name on the page (ChatMessage and ResponseFeedback); the body adds
128
+ // the prop descriptions, where what a part does is written ("helpful", "thumbs up").
129
+ ...components.map(c => {
130
+ const also = alsoExports(c)
131
+ const props = c.props.map(p => `${p.name} ${p.description ?? ''}`).join(' ')
132
+ const line = `${c.useWhen}${also.length ? ` Also on its page: ${also.join(', ')}.` : ''}`
133
+ return { kind: 'component', name: c.name, line, score: score(`${c.name} ${also.join(' ')}`, `${c.summary} ${c.useWhen}`, props, c.props.map(p => p.name).join(' ')) }
134
+ }),
122
135
  // Primitives are not for screens (tokens.json's rule), so they never come up as an answer.
123
136
  ...tokens.tokens.filter(t => t.layer !== 'primitive').map(t => ({ kind: `${t.layer} token`, name: t.name, line: t.use ?? '', score: score(t.name, t.use ?? '') })),
124
137
  ]
@@ -158,13 +171,13 @@ export function createTools(context) {
158
171
  type: 'object',
159
172
  properties: {
160
173
  code: { type: 'string', description: 'The whole file, imports included: props are matched through the imports.' },
161
- filename: { type: 'string', description: 'Its name, e.g. "src/pages/Settings.tsx". The extension decides which gates run. Default "Screen.tsx".' },
174
+ filename: { type: 'string', description: 'Its name, e.g. "src/pages/Settings.tsx". The extension decides which gates run: a .css file gets the colour and token checks, and setting a real token (--color-surface-base: ...) is allowed there, since that is how a project themes the system. Default "Screen.tsx".' },
162
175
  },
163
176
  required: ['code'],
164
177
  additionalProperties: false,
165
178
  },
166
179
  run({ code, filename = 'Screen.tsx' }) {
167
- if (!/\.(tsx|jsx|ts)$/.test(filename)) throw new ToolError('filename ends in .tsx, .jsx or .ts.')
180
+ if (!/\.(tsx|jsx|ts|css)$/.test(filename)) throw new ToolError('filename ends in .tsx, .jsx, .ts or .css.')
168
181
  const findings = checkText(String(code), filename, index, tokenNames)
169
182
  if (findings.length === 0) {
170
183
  return `No findings in ${filename}: colours, tokens, button spacing, props and AI copy are clean.\nNot checked: how the screen behaves. Run get_review_checklist, in a browser.`
@@ -28,6 +28,7 @@
28
28
  // accepted; one the rest type names in its Omit<> is not. With a `...rest` row of a Radix
29
29
  // component's props, any other prop is accepted (the Radix surface is not in the manifest).
30
30
  import ts from 'typescript'
31
+ import { DOCS_SITE } from './agent-docs.mjs'
31
32
  import { parseRowName, resolvePart } from './component-props.mjs'
32
33
 
33
34
  /** Always accepted on any component: React's own props, and attributes every DOM wrapper takes. */
@@ -125,7 +126,7 @@ export function buildIndex(manifest) {
125
126
  if (exportNames.length === 0) continue
126
127
  const components = Object.fromEntries(exportNames.map(name => [name, true]))
127
128
  const specs = Object.fromEntries(
128
- exportNames.map(name => [name, { entry: entry.name, docs: entry.docs, props: new Map(), rest: null }])
129
+ exportNames.map(name => [name, { entry: entry.name, docs: entry.docs && `${DOCS_SITE}${entry.docs.replace(/^\//, '')}`, props: new Map(), rest: null }])
129
130
  )
130
131
  let unresolved = false
131
132
  for (const row of entry.props ?? []) {
@@ -0,0 +1,24 @@
1
+ // The one ranking for "find me the thing for X". The MCP server's `search` tool and the docs site's
2
+ // top-bar search both call it, so an agent and a person typing the same words get the same order.
3
+ // Pure and dependency-free: it runs in Node and in the browser.
4
+
5
+ export const words = text => String(text).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)
6
+
7
+ /** A scorer for one query. `title` is the names, `body` the summary; `props` (descriptions) and
8
+ * `propNames` count once each, as hints, so a long prop table does not outrank a component or
9
+ * token whose own summary is about the query. 0 means no match. */
10
+ export function scorer(query) {
11
+ const terms = words(query)
12
+ return (title, body, props = '', propNames = '') => {
13
+ if (terms.length === 0) return 0
14
+ const t = words(title).join(' ')
15
+ const b = words(body)
16
+ const p = words(props)
17
+ const n = words(propNames)
18
+ const inMain = term => t.includes(term) || b.some(w => w.startsWith(term))
19
+ // Every word of the query in the name or summary beats one word found often: "page
20
+ // background" is the surface token, not PageHeader, whose name matches "page" alone.
21
+ const all = terms.length > 1 && terms.every(inMain) ? 5 : 0
22
+ return all + terms.reduce((sum, term) => sum + (t.includes(term) ? 3 : 0) + Math.min(b.filter(w => w.startsWith(term)).length, 3) + (inMain(term) ? 0 : n.some(w => w.startsWith(term)) ? 2 : p.some(w => w.startsWith(term)) ? 1 : 0), 0)
23
+ }
24
+ }
@@ -9,6 +9,12 @@
9
9
  // Colour only, on purpose. Spacing is not gated: enforcing it before the scale is complete makes
10
10
  // an author (or an agent) invent a token name to satisfy the rule, which is the worse failure.
11
11
 
12
+ import { DOCS_SITE } from './agent-docs.mjs'
13
+
14
+ /** Where a token list lives for whoever reads the finding: the MCP tool, or the docs site. A
15
+ * repository path meant nothing in a project that installed the components (2026-10-29 test). */
16
+ const TOKEN_LIST = `get_tokens over MCP, or ${DOCS_SITE}llms-tokens.txt`
17
+
12
18
  export const SCANNED_ROOTS = ['src']
13
19
  export const SCANNED_EXTENSIONS = /\.(tsx?|css)$/
14
20
 
@@ -16,6 +22,7 @@ export const SCANNED_EXTENSIONS = /\.(tsx?|css)$/
16
22
  * A new entry needs a reason as good as these; "it was easier" is the case the gate exists for. */
17
23
  export const EXEMPT = [
18
24
  { pattern: /^src\/styles\/tokens\.css$/, why: 'generated from tokens/: this is where the literals are meant to be' },
25
+ { pattern: /(^|\/)division-ds\/styles\/tokens\.css$/, why: 'the same file, installed into a project by shadcn; it also names the MCP Apps host\'s own variables' },
19
26
  { pattern: /\.test\.tsx?$/, why: 'tests assert on literal values and on deliberately wrong input' },
20
27
  { pattern: /^src\/demo\/(scene|blob|terrain)\//, why: 'WebGL materials: three.js takes colours as numbers, not CSS variables' },
21
28
  { pattern: /^src\/components\/atoms\/loading-animation\/(palette|scene|kit|variants)\.ts$/, why: 'WebGL materials, read from the tokens at runtime where they can be' },
@@ -98,9 +105,9 @@ export function findViolations(text, path, tokenNames) {
98
105
  if (name.endsWith('-')) continue
99
106
  if (!known.has(name)) {
100
107
  const near = suggest(name, tokenNames)
101
- add('unknown-token', `var(${name})`, near.length ? `No such token. Did you mean ${near.join(', ')}?` : 'No such token. The full list is public/manifest/tokens.json.')
108
+ add('unknown-token', `var(${name})`, near.length ? `No such token. Did you mean ${near.join(', ')}?` : `No such token. The full list: ${TOKEN_LIST}.`)
102
109
  } else if (PRIMITIVE_RAMP.test(name) && inComponents(path)) {
103
- add('primitive-in-component', `var(${name})`, 'A component reads semantic tokens, not a ramp step. Pick one by intent from public/manifest/tokens.json (accent.<state>.text, .fill or .ink).')
110
+ add('primitive-in-component', `var(${name})`, `A component reads semantic tokens, not a ramp step. Pick one by intent (accent.<state>.text, .fill or .ink): ${TOKEN_LIST}.`)
104
111
  }
105
112
  }
106
113
 
@@ -108,7 +115,7 @@ export function findViolations(text, path, tokenNames) {
108
115
  // where a literal colour would be.
109
116
  const rest = line.replace(/var\([^)]*\)/g, '')
110
117
  for (const match of rest.matchAll(HEX)) {
111
- add('raw-colour', match[0], 'Use a semantic token: text-[var(--color-text-primary)], bg-[var(--color-surface-base)]. Tokens and what each is for: public/manifest/tokens.json.')
118
+ add('raw-colour', match[0], `Use a semantic token: text-[var(--color-text-primary)], bg-[var(--color-surface-base)]. Tokens and what each is for: ${TOKEN_LIST}.`)
112
119
  }
113
120
  for (const match of rest.matchAll(COLOR_FUNCTION)) {
114
121
  add('raw-colour', match[0] + '…)', 'Use a semantic token. If no token fits, the colour is a change to the system: add it under tokens/ with a contrast pair.')
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: division-ds
3
- description: Use when building or changing UI with Division DS (React 19, Tailwind 4, Radix; 68 components). Covers which component and token to use, the composition rules prop types cannot express (providers, FormField, button gaps, card borders), planning a screen's states, and how to check the result. Load it before writing any screen or component in a project that has Division DS components.
3
+ description: Use when building or changing UI with Division DS (React 19, Tailwind 4, Radix; 72 components). Covers which component and token to use, the composition rules prop types cannot express (providers, FormField, button gaps, card borders), planning a screen's states, and how to check the result. Load it before writing any screen or component in a project that has Division DS components.
4
4
  ---
5
5
 
6
6
  # Division DS
@@ -82,7 +82,7 @@ provider. A project that uses the components mounts both itself (step 4 of `inst
82
82
  `src/demo/states/` files are not part of the system.
83
83
 
84
84
  **What the system does not have.** No Popover, no Combobox (single value: use `Select`; several:
85
- `MultiSelect`), no date picker. Do not reach into `src/demo/` from a component.
85
+ `MultiSelect`). Do not reach into `src/demo/` from a component.
86
86
 
87
87
  **Vocabulary.** Inside this repository, `npm run check:vocabulary` bans a list of words in `src/`,
88
88
  `scripts/` and `e2e/` (the list is `scripts/lib/vocabulary.mjs`; it is not published with the
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Load when adding one component to a screen that already exists. Generated by `npm run agent-docs`; the procedure lives in scripts/lib/agent-docs.mjs.
4
4
 
5
- Paths starting `public/` are in the Division DS repository. In a project that uses the components, read them online: `public/llms.txt` is `https://victor-ilioiu.vercel.app/systems/division-ds/llms.txt`, and so on.
5
+ Paths starting `public/` are in the Division DS repository. In a project that uses the components, read them online: `public/llms.txt` is `https://victor-ilioiu.vercel.app/systems/division-ds/llms.txt`, and so on. The other procedures named here (`commands/build.md` and the rest) are in the skill: `https://github.com/Victor-Ilioiu/division-ds/blob/main/skills/division-ds/commands/<name>.md`, or `npx division-ds skill` to install them.
6
6
 
7
7
  1. Find it in `public/llms.txt`. Its "Best for" should describe your case and its "Not for"
8
8
  should not; if "Not for" names your case, use the component it names instead.
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Load when building a new screen or page. Plans the regions and their states before any code. Generated by `npm run agent-docs`; the procedure lives in scripts/lib/agent-docs.mjs.
4
4
 
5
- Paths starting `public/` are in the Division DS repository. In a project that uses the components, read them online: `public/llms.txt` is `https://victor-ilioiu.vercel.app/systems/division-ds/llms.txt`, and so on.
5
+ Paths starting `public/` are in the Division DS repository. In a project that uses the components, read them online: `public/llms.txt` is `https://victor-ilioiu.vercel.app/systems/division-ds/llms.txt`, and so on. The other procedures named here (`commands/build.md` and the rest) are in the skill: `https://github.com/Victor-Ilioiu/division-ds/blob/main/skills/division-ds/commands/<name>.md`, or `npx division-ds skill` to install them.
6
6
 
7
7
  Plan before code. The states a screen gets wrong are the ones nobody planned: an empty list
8
8
  with no message, a load that fails into a blank panel, a button that is disabled with no reason.
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Load when a gate, `division-ds check` or a test has failed and named a rule. What each message means and the fix. Generated by `npm run agent-docs`; the procedure lives in scripts/lib/agent-docs.mjs.
4
4
 
5
- Paths starting `public/` are in the Division DS repository. In a project that uses the components, read them online: `public/llms.txt` is `https://victor-ilioiu.vercel.app/systems/division-ds/llms.txt`, and so on.
5
+ Paths starting `public/` are in the Division DS repository. In a project that uses the components, read them online: `public/llms.txt` is `https://victor-ilioiu.vercel.app/systems/division-ds/llms.txt`, and so on. The other procedures named here (`commands/build.md` and the rest) are in the skill: `https://github.com/Victor-Ilioiu/division-ds/blob/main/skills/division-ds/commands/<name>.md`, or `npx division-ds skill` to install them.
6
6
 
7
7
  Every finding prints `path:line [rule] what was found` and a line saying what to write instead.
8
8
  Do what that line says. Never silence a gate: no new EXEMPT entry, no `token-discipline: allow`
@@ -2,11 +2,11 @@
2
2
 
3
3
  > Load when a screen is written and you are about to say it is done. The checklist to run over it. Generated by `npm run agent-docs`; the procedure lives in scripts/lib/agent-docs.mjs.
4
4
 
5
- Paths starting `public/` are in the Division DS repository. In a project that uses the components, read them online: `public/llms.txt` is `https://victor-ilioiu.vercel.app/systems/division-ds/llms.txt`, and so on.
5
+ Paths starting `public/` are in the Division DS repository. In a project that uses the components, read them online: `public/llms.txt` is `https://victor-ilioiu.vercel.app/systems/division-ds/llms.txt`, and so on. The other procedures named here (`commands/build.md` and the rest) are in the skill: `https://github.com/Victor-Ilioiu/division-ds/blob/main/skills/division-ds/commands/<name>.md`, or `npx division-ds skill` to install them.
6
6
 
7
7
  Go through every line. A line that does not hold is a change to make, not a note to leave.
8
8
 
9
- - [ ] **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.
9
+ - [ ] **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.
10
10
  - [ ] **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.
11
11
  - [ ] **Tokens only.** No hex, `rgb()`, palette class or invented token name. `division-ds check` passes.
12
12
  - [ ] **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`.
@@ -4,4 +4,5 @@
4
4
 
5
5
  **Assistant copy.** A button names the action ("Summarize"), never "AI". Say "response", not
6
6
  "answer". No "please" or "!" in assistant labels. The interface says "Assistant"; only its own
7
- messages say "I".
7
+ messages say "I". Response feedback: `ResponseFeedback` in `actions`.
8
+ A value the assistant filled is `aiValue` on that `Input` or `Textarea` only; leaving it accepts it.
@@ -10,6 +10,9 @@ bare and name it through its own `label` prop ("Role for Ilse Varga"). `Switch`,
10
10
  renders `ErrorSummary` at the top and focuses it; `useFieldErrors` holds the state (signature in
11
11
  `public/docs/helpers.md`).
12
12
 
13
+ A `RadioGroup` in a form sits in a `<fieldset>` whose `<legend>` names it (code on the Radio
14
+ page). A date is `DatePicker` in a `FormField`; its value is 'YYYY-MM-DD'.
15
+
13
16
  **Buttons.** Two or more buttons side by side need a parent with a gap
14
17
  (`flex flex-wrap gap-3`); `DialogFooter` and `CardFooter` already are one. A `Button` is
15
18
  `type="button"` unless you say `type="submit"`. A busy button takes `loading`, not `disabled`.
@@ -8,7 +8,9 @@ The shell paints the page background (`surface.base`) and supplies the gutter; a
8
8
  own `max-w-*` and vertical spacing, and no page background. A form or an overlay is the
9
9
  exception: it paints `surface.base` on itself, so its field colours never depend on what is
10
10
  behind it. In a project that uses the components, your own root does this
11
- (step 4 of `install.md`).
11
+ (step 4 of `install.md`). Toggle it with `setTheme()` from `src/lib/theme.ts`.
12
+
13
+ **App navigation.** A few views: `Navbar` (`current: true` on the open one). Many: `Sidebar`.
12
14
 
13
15
  **Type.** Three families, as utilities: `font-display` (headings, figures), `font-body` (the
14
16
  default, set by the shell), `font-mono` (labels, code, tabular numbers). A page title is
Binary file