division-ds 0.1.1 → 0.2.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.
- package/AGENTS.md +16 -2
- package/README.md +11 -0
- package/division-ds-0.2.0.tgz +0 -0
- package/package.json +2 -2
- package/public/docs/components/accordion.md +205 -0
- package/public/docs/components/ai-label.md +88 -0
- package/public/docs/components/alert-dialog.md +290 -0
- package/public/docs/components/alert.md +173 -0
- package/public/docs/components/approval-request.md +146 -0
- package/public/docs/components/auth-form.md +130 -0
- package/public/docs/components/avatar-group.md +150 -0
- package/public/docs/components/avatar.md +177 -0
- package/public/docs/components/badge.md +176 -0
- package/public/docs/components/breadcrumbs.md +110 -0
- package/public/docs/components/button.md +202 -0
- package/public/docs/components/card.md +255 -0
- package/public/docs/components/carousel.md +128 -0
- package/public/docs/components/cart.md +170 -0
- package/public/docs/components/chart-panel.md +356 -0
- package/public/docs/components/chat-message.md +154 -0
- package/public/docs/components/chat-thread.md +242 -0
- package/public/docs/components/checkbox.md +147 -0
- package/public/docs/components/checkout-form.md +126 -0
- package/public/docs/components/command-palette.md +145 -0
- package/public/docs/components/data-table.md +399 -0
- package/public/docs/components/description-list.md +101 -0
- package/public/docs/components/dialog.md +195 -0
- package/public/docs/components/divider.md +95 -0
- package/public/docs/components/dropdown-menu.md +220 -0
- package/public/docs/components/empty-state.md +143 -0
- package/public/docs/components/footer.md +395 -0
- package/public/docs/components/form-field.md +213 -0
- package/public/docs/components/icon-button.md +125 -0
- package/public/docs/components/input.md +198 -0
- package/public/docs/components/kanban-board.md +298 -0
- package/public/docs/components/kbd.md +70 -0
- package/public/docs/components/link.md +96 -0
- package/public/docs/components/list-item.md +238 -0
- package/public/docs/components/loading-animation.md +70 -0
- package/public/docs/components/multi-select.md +200 -0
- package/public/docs/components/navbar.md +142 -0
- package/public/docs/components/newsletter-form.md +110 -0
- package/public/docs/components/page-header.md +149 -0
- package/public/docs/components/pagination.md +210 -0
- package/public/docs/components/product-card.md +169 -0
- package/public/docs/components/progress.md +99 -0
- package/public/docs/components/prompt-input.md +149 -0
- package/public/docs/components/quantity-stepper.md +138 -0
- package/public/docs/components/radio.md +140 -0
- package/public/docs/components/rating.md +104 -0
- package/public/docs/components/score-ring.md +93 -0
- package/public/docs/components/search.md +114 -0
- package/public/docs/components/section-heading.md +111 -0
- package/public/docs/components/segmented-control.md +124 -0
- package/public/docs/components/select.md +222 -0
- package/public/docs/components/sheet.md +143 -0
- package/public/docs/components/sidebar.md +191 -0
- package/public/docs/components/skeleton.md +133 -0
- package/public/docs/components/slider.md +112 -0
- package/public/docs/components/sparkline.md +126 -0
- package/public/docs/components/spinner.md +96 -0
- package/public/docs/components/stat.md +104 -0
- package/public/docs/components/status-tracker.md +143 -0
- package/public/docs/components/stepper.md +145 -0
- package/public/docs/components/switch.md +151 -0
- package/public/docs/components/tabs.md +178 -0
- package/public/docs/components/tag.md +103 -0
- package/public/docs/components/textarea.md +146 -0
- package/public/docs/components/timeline.md +150 -0
- package/public/docs/components/toast.md +192 -0
- package/public/docs/components/tool-call.md +105 -0
- package/public/docs/components/tooltip.md +106 -0
- package/public/manifest/components.json +189 -7
- package/public/manifest/tokens.json +39 -0
- package/scripts/division-ds.mjs +76 -37
- package/scripts/lib/agent-docs.mjs +59 -2
- package/scripts/lib/ai-copy.mjs +106 -0
- package/scripts/lib/button-spacing.mjs +5 -5
- package/scripts/lib/check-text.mjs +44 -0
- package/scripts/lib/contrast.mjs +111 -0
- package/scripts/lib/host-inline.mjs +59 -0
- package/scripts/lib/mcp-server.mjs +115 -0
- package/scripts/lib/mcp-tools.mjs +241 -0
- package/scripts/lib/props-validator.mjs +1 -1
- package/scripts/lib/rules-installer.mjs +42 -4
- package/skills/division-ds/SKILL.md +90 -0
- package/skills/division-ds/commands/add.md +25 -0
- package/skills/division-ds/commands/build.md +46 -0
- package/skills/division-ds/commands/fix.md +28 -0
- package/skills/division-ds/commands/review.md +25 -0
- package/skills/division-ds/topics/ai.md +7 -0
- package/skills/division-ds/topics/colour.md +11 -0
- package/skills/division-ds/topics/feedback.md +9 -0
- package/skills/division-ds/topics/forms.md +19 -0
- package/skills/division-ds/topics/layout.md +34 -0
- package/skills/division-ds/topics/motion.md +10 -0
- package/skills/division-ds/topics/overlays.md +9 -0
package/scripts/division-ds.mjs
CHANGED
|
@@ -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
|
|
@@ -18,10 +20,11 @@ import { existsSync, globSync, readdirSync, readFileSync, statSync } from 'node:
|
|
|
18
20
|
import { dirname, join, relative, resolve } from 'node:path'
|
|
19
21
|
import { fileURLToPath } from 'node:url'
|
|
20
22
|
import { extractRules } from './lib/agent-docs.mjs'
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
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')
|
|
@@ -33,12 +36,16 @@ const HELP = `division-ds: Division DS gates and agent rules for a project that
|
|
|
33
36
|
Usage:
|
|
34
37
|
division-ds check [paths...] [--manifest <path|url>] [--tokens <path|url>] [--json]
|
|
35
38
|
Checks .tsx, .jsx and .ts files (default: ./src). A path may be a file, a directory or a
|
|
36
|
-
glob ("src/**/*.tsx"). Runs
|
|
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
|
-
|
|
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})`)
|
|
@@ -154,35 +152,76 @@ async function check(options) {
|
|
|
154
152
|
console.error('Each component\'s props, values and examples: /docs/components/<slug>.md. Tokens: public/manifest/tokens.json.')
|
|
155
153
|
} else {
|
|
156
154
|
console.log(`division-ds check passed: ${files.length} file(s), ${index.size} components, ${tokenNames.length} tokens, 0 findings.`)
|
|
155
|
+
// It reads code, not the running page: say so, or a pass reads as "the screen works" (the
|
|
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
158
|
}
|
|
158
159
|
process.exit(findings.length > 0 ? 1 : 0)
|
|
159
160
|
}
|
|
160
161
|
|
|
161
|
-
function
|
|
162
|
-
const
|
|
163
|
-
const
|
|
164
|
-
|
|
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(', ')}.`)
|
|
165
187
|
const targetDir = resolve(options.target ?? '.')
|
|
166
188
|
if (!existsSync(targetDir) || !statSync(targetDir).isDirectory()) usage(`--target is not a directory: ${targetDir}`)
|
|
167
|
-
// This repository's AGENTS.md
|
|
168
|
-
// 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.
|
|
169
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)
|
|
170
197
|
const source = readFileSync(join(ROOT, 'AGENTS.md'), 'utf8').replace(/\r\n/g, '\n')
|
|
171
|
-
|
|
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
|
+
}
|
|
172
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) {
|
|
173
210
|
for (const change of plan) {
|
|
174
211
|
const shown = relative(process.cwd(), change.path) || change.path
|
|
175
212
|
console.log(`${options['dry-run'] ? 'would ' : ''}${change.action.padEnd(9)} ${shown}`)
|
|
176
|
-
if (options['dry-run'] && change.action !== 'unchanged' && change.action !== 'absent') {
|
|
213
|
+
if (options['dry-run'] && !options.quiet && change.action !== 'unchanged' && change.action !== 'absent') {
|
|
177
214
|
console.log(diff(change.before, change.after).replace(/^/gm, ' '))
|
|
178
215
|
}
|
|
179
216
|
}
|
|
180
|
-
if (!options['dry-run']) applyPlan(plan)
|
|
217
|
+
if (!options['dry-run']) applyPlan(plan, stopAt)
|
|
181
218
|
}
|
|
182
219
|
|
|
183
220
|
const [command, ...rest] = process.argv.slice(2)
|
|
184
221
|
const options = parseArgs(rest)
|
|
185
222
|
if (command === 'check') await check(options)
|
|
186
223
|
else if (command === 'rules') rules(options)
|
|
224
|
+
else if (command === 'skill') skill(options)
|
|
225
|
+
else if (command === 'mcp') await mcp(options)
|
|
187
226
|
else if (command === undefined || command === 'help' || command === '--help' || command === '-h') console.log(HELP)
|
|
188
227
|
else usage(`Unknown command "${command}".`)
|
|
@@ -124,6 +124,7 @@ export function llmsIndex(entries, { rules }) {
|
|
|
124
124
|
'## Start here',
|
|
125
125
|
'',
|
|
126
126
|
'- [Rules](llms-guidelines.txt): imports, colour, providers, forms. Short. Read it first.',
|
|
127
|
+
'- [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
128
|
'- [Tokens](llms-tokens.txt): every semantic and component token with when to use it.',
|
|
128
129
|
'- [Helpers](docs/helpers.md): the shared functions and types under src/lib (form errors, money, links, theme, streamed text).',
|
|
129
130
|
'- [All components, compact](llms-components.txt): every prop signature in one file.',
|
|
@@ -145,7 +146,7 @@ export function ruleHeadlines(rules) {
|
|
|
145
146
|
.split(/\n\s*\n/)
|
|
146
147
|
.map(block => block.replace(/\s*\n\s*/g, ' ').trim())
|
|
147
148
|
.filter(block => block.startsWith('**'))
|
|
148
|
-
.map(block => block.match(/^\*\*[^*]+\*\*\s
|
|
149
|
+
.map(block => block.match(/^\*\*[^*]+\*\*\s+.*?\.(?=\s|$)/)?.[0] ?? block)
|
|
149
150
|
}
|
|
150
151
|
|
|
151
152
|
/** Every component's contract in one compact file: no examples, no prose beyond the summary. */
|
|
@@ -203,6 +204,54 @@ export function tokensBundle(manifest) {
|
|
|
203
204
|
].join('\n')
|
|
204
205
|
}
|
|
205
206
|
|
|
207
|
+
/** DESIGN.md: the whole system in one short file, for a tool that takes a single design file.
|
|
208
|
+
* It carries the choosing layer only (semantic colour, spacing, motion, type, one line per
|
|
209
|
+
* component, the rule headlines) and links the full pages rather than inlining them. `fonts` is
|
|
210
|
+
* `[{ name, stack }]` from the `--font-*` theme variables in src/styles/tailwind.css. */
|
|
211
|
+
export function designFile(entries, manifest, { fonts, rules }) {
|
|
212
|
+
const value = token => (typeof token.value === 'string' ? token.value : `dark ${token.value.dark}, light ${token.value.light}`)
|
|
213
|
+
const table = layer => [
|
|
214
|
+
'| Token | Use | Value |',
|
|
215
|
+
'|---|---|---|',
|
|
216
|
+
...manifest.tokens
|
|
217
|
+
.filter(token => token.layer === layer)
|
|
218
|
+
.map(token => `| ${code(token.name)} | ${cell(token.use)} | ${code(value(token))} |`),
|
|
219
|
+
'',
|
|
220
|
+
]
|
|
221
|
+
const out = [
|
|
222
|
+
'# Division DS: design',
|
|
223
|
+
'',
|
|
224
|
+
`> 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.`,
|
|
225
|
+
'',
|
|
226
|
+
'Links are relative to this file. Generated by `npm run agent-docs`.',
|
|
227
|
+
'',
|
|
228
|
+
'## Colour',
|
|
229
|
+
'',
|
|
230
|
+
manifest.rule,
|
|
231
|
+
'',
|
|
232
|
+
'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).',
|
|
233
|
+
'',
|
|
234
|
+
...table('semantic'),
|
|
235
|
+
'## Type',
|
|
236
|
+
'',
|
|
237
|
+
...fonts.map(font => `- ${code(`font-${font.name}`)}: ${code(font.stack)}`),
|
|
238
|
+
'',
|
|
239
|
+
'## Spacing',
|
|
240
|
+
'',
|
|
241
|
+
...table('spacing'),
|
|
242
|
+
'## Motion',
|
|
243
|
+
'',
|
|
244
|
+
...table('motion'),
|
|
245
|
+
]
|
|
246
|
+
for (const [tier, group] of byTier(entries)) {
|
|
247
|
+
out.push(`## ${tier[0].toUpperCase()}${tier.slice(1)}`, '')
|
|
248
|
+
for (const entry of group) out.push(`- [${entry.name}](${entry.docs.replace(/^\//, '')}): ${firstSentence(entry.summary)}`)
|
|
249
|
+
out.push('')
|
|
250
|
+
}
|
|
251
|
+
out.push('## Rules', '', 'In full: [llms-guidelines.txt](llms-guidelines.txt).', '', ...ruleHeadlines(rules).map(line => `- ${line}`), '')
|
|
252
|
+
return out.join('\n')
|
|
253
|
+
}
|
|
254
|
+
|
|
206
255
|
/** The shared helpers under src/lib, one section per file: exported types whole, functions as
|
|
207
256
|
* signatures, each with its doc comment. `helpers` is `[{ from, api }]`. */
|
|
208
257
|
export function helpersPage(helpers) {
|
|
@@ -265,6 +314,7 @@ export const SKILL_TOPICS = {
|
|
|
265
314
|
overlays: { title: 'Overlays', when: 'adding a Dialog, an AlertDialog or a Sheet' },
|
|
266
315
|
feedback: { title: 'Feedback', when: 'a screen reports a result or a wait: a toast, an alert, progress, a spinner' },
|
|
267
316
|
motion: { title: 'Motion', when: 'animating anything yourself' },
|
|
317
|
+
ai: { title: 'Assistant copy', when: 'wording a chat' },
|
|
268
318
|
}
|
|
269
319
|
|
|
270
320
|
/**
|
|
@@ -397,6 +447,8 @@ const SKILL_COMMANDS = {
|
|
|
397
447
|
'| `[unknown-prop]` "has no prop" | The prop does not exist. Open the page the message names and use a prop from its table. |',
|
|
398
448
|
'| `[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
449
|
'| `[invalid-value]` | The value is not in the prop\'s union. The message lists the allowed values. |',
|
|
450
|
+
'| `[ai-in-cta]` | A button says "AI". Name what it does instead: "Summarize", "Draft a reply". |',
|
|
451
|
+
'| `[assistant-copy]` | An assistant component\'s label says "answer", "please" or "!". Write "response", drop "please", drop the exclamation point. |',
|
|
400
452
|
'| "Vocabulary gate FAILED" (this repository only) | Reword the line. If it is an ordinary idiom, ask before adding it to ALLOWED_IDIOMS. |',
|
|
401
453
|
'| "Agent docs are stale" (this repository only) | Run `npm run agent-docs`, then `npm run verify`. |',
|
|
402
454
|
'| 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. |',
|
|
@@ -416,11 +468,12 @@ const SKILL_COMMANDS = {
|
|
|
416
468
|
'- [ ] **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`.',
|
|
417
469
|
'- [ ] **Forms.** Every `Input`, `Textarea`, `Select`, `MultiSelect` and `QuantityStepper` is inside a `FormField`; `Switch`, `Checkbox` and `Radio` have an `id` and a `<label htmlFor>`. A failed submit shows `ErrorSummary` and focuses it.',
|
|
418
470
|
'- [ ] **Status.** Every status has a word, not only a colour.',
|
|
419
|
-
'- [ ] **Focus.** Everything interactive is reached by Tab in reading order and shows the focus ring; anything styled by hand has the `focus-visible:outline-*` classes. An overlay hands focus back to what opened it.',
|
|
471
|
+
'- [ ] **Focus.** Everything interactive is reached by Tab in reading order and shows the focus ring; anything styled by hand has the `focus-visible:outline-*` classes. An overlay hands focus back to what opened it. A button that removes its own row or item (Acknowledge, Delete, Dismiss) moves focus to the next one, else the previous one, else the empty state; focus never drops to the page.',
|
|
420
472
|
'- [ ] **Text size.** Secondary text is 14px or larger; 11 to 12px only for uppercase mono labels.',
|
|
421
473
|
'- [ ] **320px.** At 320px wide nothing scrolls sideways (`document.documentElement.scrollWidth <= 320`) and button rows wrap.',
|
|
422
474
|
'- [ ] **Both themes.** It reads in dark and in light (`data-theme="light"` on `<html>`).',
|
|
423
475
|
'- [ ] **Motion.** Anything animated by hand stops under reduced motion; nothing moves on its own.',
|
|
476
|
+
'- [ ] **Live data.** Values that update on their own change in place, with no announcement for each number; a change of status (good to unhealthy) is announced once, through one polite live region (`role="status"`). Charts animate only their first draw, so new data every few seconds is fine.',
|
|
424
477
|
'',
|
|
425
478
|
'## Check',
|
|
426
479
|
'',
|
|
@@ -502,6 +555,10 @@ export function skillFiles(split, componentCount) {
|
|
|
502
555
|
'',
|
|
503
556
|
`> Load when ${command.when}. Generated by \`npm run agent-docs\`; the procedure lives in scripts/lib/agent-docs.mjs.`,
|
|
504
557
|
'',
|
|
558
|
+
// A project that installed the components has no public/ folder of ours (the 2026-10-20
|
|
559
|
+
// 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.`,
|
|
561
|
+
'',
|
|
505
562
|
...command.body,
|
|
506
563
|
'',
|
|
507
564
|
].join('\n')
|
|
@@ -0,0 +1,106 @@
|
|
|
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.
|
|
20
|
+
// Only literals are read (a string attribute, JSX text, a ternary of two literals). A variable is
|
|
21
|
+
// not judged, the same as the props gate.
|
|
22
|
+
import ts from 'typescript'
|
|
23
|
+
import { scriptKind } from './props-validator.mjs'
|
|
24
|
+
|
|
25
|
+
const BUTTONS = new Set(['Button', 'button'])
|
|
26
|
+
/** Each assistant component's interface-text props. Everything else on them is content or data. */
|
|
27
|
+
const ASSISTANT_COPY = {
|
|
28
|
+
ChatThread: ['label', 'busyLabel', 'jumpLabel'],
|
|
29
|
+
ChatMessage: ['author', 'timeLabel'],
|
|
30
|
+
PromptInput: ['label', 'placeholder', 'hint'],
|
|
31
|
+
ApprovalRequest: ['title', 'approveLabel', 'rejectLabel', 'approvedLabel', 'rejectedLabel'],
|
|
32
|
+
ToolCall: ['title'],
|
|
33
|
+
}
|
|
34
|
+
const BUTTON_PROPS = { IconButton: ['label', 'aria-label'], Button: ['aria-label'], button: ['aria-label'], ApprovalRequest: ['approveLabel', 'rejectLabel'], ChatThread: ['jumpLabel'] }
|
|
35
|
+
|
|
36
|
+
const AI_WORD = /\bAI\b/
|
|
37
|
+
const ASSISTANT_WORDS = [
|
|
38
|
+
{ pattern: /\banswer(s|ed|ing)?\b/i, fix: 'Write "response" (or "reply"), not "answer": the assistant responds; it is not always right.' },
|
|
39
|
+
{ pattern: /\bplease\b/i, fix: 'Drop "please": the interface states the action plainly.' },
|
|
40
|
+
{ pattern: /!/, fix: 'Drop the exclamation point: assistant copy stays calm, whatever the result.' },
|
|
41
|
+
]
|
|
42
|
+
const AI_FIX = 'Name the action ("Summarize", "Draft a reply"), not the technology: a call to action never says "AI".'
|
|
43
|
+
|
|
44
|
+
/** The literal strings an attribute can hold, as nodes; none when it is not a literal. */
|
|
45
|
+
function literals(initializer) {
|
|
46
|
+
if (!initializer) return []
|
|
47
|
+
if (ts.isStringLiteral(initializer)) return [initializer]
|
|
48
|
+
if (!ts.isJsxExpression(initializer) || !initializer.expression) return []
|
|
49
|
+
const collect = node => {
|
|
50
|
+
while (ts.isParenthesizedExpression(node)) node = node.expression
|
|
51
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return [node]
|
|
52
|
+
if (ts.isConditionalExpression(node)) return [...collect(node.whenTrue), ...collect(node.whenFalse)]
|
|
53
|
+
return []
|
|
54
|
+
}
|
|
55
|
+
return collect(initializer.expression)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Every AI copy finding in one file: `{ path, line, column, rule, found, fix }`. */
|
|
59
|
+
export function findAiCopyViolations(text, path) {
|
|
60
|
+
const source = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true, scriptKind(path))
|
|
61
|
+
const violations = []
|
|
62
|
+
const add = (node, rule, found, fix) => {
|
|
63
|
+
const at = source.getLineAndCharacterOfPosition(node.getStart(source))
|
|
64
|
+
violations.push({ path, line: at.line + 1, column: at.character + 1, rule, found, fix })
|
|
65
|
+
}
|
|
66
|
+
const checkCta = (node, value, where) => {
|
|
67
|
+
if (AI_WORD.test(value)) add(node, 'ai-in-cta', `${where}: "${value.trim()}"`, AI_FIX)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const visit = node => {
|
|
71
|
+
if (ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node)) {
|
|
72
|
+
const tag = node.tagName.getText(source)
|
|
73
|
+
for (const attribute of node.attributes.properties) {
|
|
74
|
+
if (!ts.isJsxAttribute(attribute)) continue
|
|
75
|
+
const prop = attribute.name.getText(source)
|
|
76
|
+
for (const value of literals(attribute.initializer)) {
|
|
77
|
+
if (BUTTON_PROPS[tag]?.includes(prop)) checkCta(value, value.text, `<${tag} ${prop}>`)
|
|
78
|
+
if (ASSISTANT_COPY[tag]?.includes(prop)) {
|
|
79
|
+
for (const word of ASSISTANT_WORDS) {
|
|
80
|
+
if (word.pattern.test(value.text)) add(value, 'assistant-copy', `<${tag} ${prop}>: "${value.text}"`, word.fix)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (ts.isJsxElement(node) && BUTTONS.has(node.openingElement.tagName.getText(source))) {
|
|
87
|
+
const tag = node.openingElement.tagName.getText(source)
|
|
88
|
+
const parts = []
|
|
89
|
+
for (const child of node.children) {
|
|
90
|
+
if (ts.isJsxText(child)) parts.push(child.text)
|
|
91
|
+
else if (ts.isJsxExpression(child) && child.expression && (ts.isStringLiteral(child.expression) || ts.isNoSubstitutionTemplateLiteral(child.expression))) {
|
|
92
|
+
parts.push(child.expression.text)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const label = parts.join(' ').replace(/\s+/g, ' ')
|
|
96
|
+
if (label.trim()) checkCta(node, label, `<${tag}> label`)
|
|
97
|
+
}
|
|
98
|
+
ts.forEachChild(node, visit)
|
|
99
|
+
}
|
|
100
|
+
visit(source)
|
|
101
|
+
return violations.sort((a, b) => a.line - b.line || a.column - b.column)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function formatAiCopyViolation({ path, line, column, rule, found, fix }) {
|
|
105
|
+
return `${path}:${line}:${column} [${rule}] ${found}\n ${fix}`
|
|
106
|
+
}
|
|
@@ -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,44 @@
|
|
|
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
|
+
/** Every finding in `text`. `path` decides which gates apply (button spacing and AI copy need JSX) and is
|
|
14
|
+
* echoed into each finding. `index` is buildIndex(manifest); `tokenNames` the token names. */
|
|
15
|
+
export function checkText(text, path, index, tokenNames) {
|
|
16
|
+
const findings = []
|
|
17
|
+
for (const v of tokenDiscipline(text, path, tokenNames)) {
|
|
18
|
+
findings.push({ gate: 'token-discipline', path, line: v.line, rule: v.rule, found: v.found, fix: v.fix, text: formatTokenDiscipline(v) })
|
|
19
|
+
}
|
|
20
|
+
if (/\.[jt]sx$/.test(path)) {
|
|
21
|
+
for (const v of buttonSpacing(text, path)) {
|
|
22
|
+
findings.push({
|
|
23
|
+
gate: 'button-spacing',
|
|
24
|
+
path,
|
|
25
|
+
line: v.line,
|
|
26
|
+
column: v.column,
|
|
27
|
+
rule: 'touching-buttons',
|
|
28
|
+
found: `<${v.tag}> with ${v.count} button-like children`,
|
|
29
|
+
fix: BUTTON_SPACING_FIX,
|
|
30
|
+
text: formatButtonSpacing({ ...v, file: path }),
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
for (const v of findInlineViolations(text)) {
|
|
34
|
+
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) })
|
|
35
|
+
}
|
|
36
|
+
for (const v of findAiCopyViolations(text, path)) {
|
|
37
|
+
findings.push({ gate: 'ai-copy', path, line: v.line, column: v.column, rule: v.rule, found: v.found, fix: v.fix, text: formatAiCopyViolation(v) })
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
for (const v of findPropViolations(text, path, index)) {
|
|
41
|
+
findings.push({ gate: 'props', path, line: v.line, column: v.column, rule: v.rule, found: v.found, fix: v.fix, text: formatPropViolation(v) })
|
|
42
|
+
}
|
|
43
|
+
return findings
|
|
44
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Contrast maths for the AA gate. Two jobs: parse a colour, and composite it over whatever it is
|
|
2
|
+
// actually painted on before measuring.
|
|
3
|
+
//
|
|
4
|
+
// The compositing half exists because this design system writes real translucent tokens
|
|
5
|
+
// (`border.hairline`, `surface.raised`), and a hex-only parser silently could not evaluate them —
|
|
6
|
+
// which is how a whole class of SC 1.4.11 failure on the form controls' boundaries stayed
|
|
7
|
+
// invisible to a gate that reported 28/28 green. A translucent colour has no contrast ratio of
|
|
8
|
+
// its own; it only has one against a named backdrop. So every alpha-carrying colour here must be
|
|
9
|
+
// given one, and the gate names it explicitly rather than guessing.
|
|
10
|
+
|
|
11
|
+
/** Parses #rgb, #rrggbb, #rrggbbaa, rgb()/rgba() (comma or space separated, alpha as number or
|
|
12
|
+
* percentage) into `[r, g, b, a]` with channels 0–255 and alpha 0–1. */
|
|
13
|
+
function parseColor(color) {
|
|
14
|
+
if (typeof color !== 'string') throw new Error(`Not a colour: ${String(color)}`)
|
|
15
|
+
const value = color.trim()
|
|
16
|
+
|
|
17
|
+
if (value.startsWith('#')) {
|
|
18
|
+
const hex = value.slice(1)
|
|
19
|
+
if (hex.length === 3 || hex.length === 4) {
|
|
20
|
+
const channels = [...hex].map(c => parseInt(c + c, 16))
|
|
21
|
+
return [channels[0], channels[1], channels[2], channels[3] === undefined ? 1 : channels[3] / 255]
|
|
22
|
+
}
|
|
23
|
+
if (hex.length === 6 || hex.length === 8) {
|
|
24
|
+
const channels = [0, 2, 4, 6]
|
|
25
|
+
.filter(i => i < hex.length)
|
|
26
|
+
.map(i => parseInt(hex.slice(i, i + 2), 16))
|
|
27
|
+
return [channels[0], channels[1], channels[2], channels[3] === undefined ? 1 : channels[3] / 255]
|
|
28
|
+
}
|
|
29
|
+
throw new Error(`Unparseable hex colour: ${color}`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const fn = value.match(/^rgba?\(([^)]*)\)$/i)
|
|
33
|
+
if (fn) {
|
|
34
|
+
const parts = fn[1].split(/[\s,/]+/).filter(Boolean)
|
|
35
|
+
if (parts.length < 3) throw new Error(`Unparseable rgb() colour: ${color}`)
|
|
36
|
+
const channel = part =>
|
|
37
|
+
part.endsWith('%') ? (parseFloat(part) / 100) * 255 : parseFloat(part)
|
|
38
|
+
const alpha = parts[3] === undefined
|
|
39
|
+
? 1
|
|
40
|
+
: parts[3].endsWith('%')
|
|
41
|
+
? parseFloat(parts[3]) / 100
|
|
42
|
+
: parseFloat(parts[3])
|
|
43
|
+
const [r, g, b] = parts.slice(0, 3).map(channel)
|
|
44
|
+
if ([r, g, b, alpha].some(Number.isNaN)) throw new Error(`Unparseable rgb() colour: ${color}`)
|
|
45
|
+
return [r, g, b, alpha]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
throw new Error(`Unsupported colour format: ${color} (expected #hex or rgb()/rgba())`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const toHex = n => Math.round(Math.min(255, Math.max(0, n))).toString(16).padStart(2, '0')
|
|
52
|
+
|
|
53
|
+
/** Source-over compositing: paints `color` onto `backdrop` and returns the resulting opaque hex.
|
|
54
|
+
* An opaque `color` is returned unchanged (and needs no backdrop). A translucent one without a
|
|
55
|
+
* backdrop throws rather than silently measuring its own nominal RGB, which is exactly the
|
|
56
|
+
* mistake the old hex-only parser could not even make visible. */
|
|
57
|
+
export function composite(color, backdrop) {
|
|
58
|
+
const [r, g, b, a] = parseColor(color)
|
|
59
|
+
if (a >= 1) return `#${toHex(r)}${toHex(g)}${toHex(b)}`
|
|
60
|
+
if (backdrop === undefined || backdrop === null) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`${color} is translucent (alpha ${a}) and has no contrast ratio on its own — ` +
|
|
63
|
+
'pass the colour it is painted over as the backdrop.'
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
const [br, bg, bb] = parseColor(composite(backdrop))
|
|
67
|
+
return `#${toHex(r * a + br * (1 - a))}${toHex(g * a + bg * (1 - a))}${toHex(b * a + bb * (1 - a))}`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function relativeLuminance(color) {
|
|
71
|
+
const [r, g, b] = parseColor(color)
|
|
72
|
+
const linear = [r, g, b]
|
|
73
|
+
.map(v => v / 255)
|
|
74
|
+
.map(v => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4))
|
|
75
|
+
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Contrast ratio between `a` (the foreground) and `b` (its background).
|
|
80
|
+
*
|
|
81
|
+
* `backdrop` is what `b` itself is painted on, and is only needed when a translucent colour is
|
|
82
|
+
* involved: `b` is composited over `backdrop`, then `a` is composited over that result — the
|
|
83
|
+
* same order the browser paints them in. Two opaque hexes behave exactly as before, so every
|
|
84
|
+
* existing caller (`generate-ramps.mjs`, `ColorGuideline.tsx`) is unaffected.
|
|
85
|
+
*/
|
|
86
|
+
export function contrastRatio(a, b, backdrop) {
|
|
87
|
+
const bg = composite(b, backdrop)
|
|
88
|
+
const fg = composite(a, bg)
|
|
89
|
+
const [lighter, darker] = [relativeLuminance(fg), relativeLuminance(bg)].sort((x, y) => y - x)
|
|
90
|
+
return (lighter + 0.05) / (darker + 0.05)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Measures every pair and says whether each one holds.
|
|
95
|
+
*
|
|
96
|
+
* `maxRatio` is optional and is a CEILING, which most pairs have no use for: an accessibility
|
|
97
|
+
* minimum is a floor and more contrast is never a failure. One role in this system is the
|
|
98
|
+
* exception. The dimmed leading-zero pad on a hero numeral is decorative and `aria-hidden`, so no
|
|
99
|
+
* SC 1.4.3 obligation attaches to it at all, and its actual design rule is the opposite shape:
|
|
100
|
+
* it must stay well UNDER the ink it pads or it stops reading as padding and starts reading as a
|
|
101
|
+
* digit. That is exactly what happened in light theme, where the pad measured 65% of ink contrast
|
|
102
|
+
* while dark measured 31%, and a floor-only gate could not see it. A rule that is not a gate is a
|
|
103
|
+
* suggestion, so the gate learned to express a band.
|
|
104
|
+
*/
|
|
105
|
+
export function checkPairs(pairs) {
|
|
106
|
+
return pairs.map(({ name, fg, bg, backdrop, minRatio, maxRatio }) => {
|
|
107
|
+
const ratio = contrastRatio(fg, bg, backdrop)
|
|
108
|
+
const pass = ratio >= minRatio && (maxRatio === undefined || ratio <= maxRatio)
|
|
109
|
+
return { name, ratio, minRatio, maxRatio, pass }
|
|
110
|
+
})
|
|
111
|
+
}
|