@hegemonart/get-design-done 1.22.0 → 1.23.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +62 -0
- package/package.json +2 -1
- package/reference/output-contracts/planner-decision.schema.json +94 -0
- package/reference/output-contracts/verifier-decision.schema.json +66 -0
- package/scripts/lib/audit-aggregator/index.cjs +219 -0
- package/scripts/lib/design-solidify.mjs +265 -0
- package/scripts/lib/design-tokens/_js-harness.cjs +66 -0
- package/scripts/lib/design-tokens/css-vars.cjs +55 -0
- package/scripts/lib/design-tokens/figma.cjs +121 -0
- package/scripts/lib/design-tokens/index.cjs +100 -0
- package/scripts/lib/design-tokens/js-const.cjs +107 -0
- package/scripts/lib/design-tokens/tailwind.cjs +98 -0
- package/scripts/lib/domain-primitives/anti-patterns.cjs +66 -0
- package/scripts/lib/domain-primitives/nng.cjs +136 -0
- package/scripts/lib/domain-primitives/wcag.cjs +166 -0
- package/scripts/lib/parse-contract.cjs +168 -0
- package/scripts/lib/reference-resolver.cjs +184 -0
- package/scripts/lib/touches-analyzer/index.cjs +201 -0
- package/scripts/lib/touches-pattern-miner.cjs +195 -0
- package/scripts/lib/visual-baseline/diff.cjs +137 -0
- package/scripts/lib/visual-baseline/index.cjs +139 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* design-tokens/js-const.cjs — load token modules via spawned-node
|
|
3
|
+
* subprocess (Plan 23-08).
|
|
4
|
+
*
|
|
5
|
+
* Why subprocess: the project may use ESM, CJS, or .ts (with type-strip
|
|
6
|
+
* runtimes); evaluating user JS in-process risks side effects + version
|
|
7
|
+
* skew. Fork a child node with a tiny harness that requires/imports the
|
|
8
|
+
* target file and prints its tokens as JSON.
|
|
9
|
+
*
|
|
10
|
+
* Recognised export shapes (in priority order):
|
|
11
|
+
* 1. `module.exports.tokens = { … }` (CJS named)
|
|
12
|
+
* 2. `module.exports = { tokens: { … } }` (CJS object with `tokens`)
|
|
13
|
+
* 3. `module.exports = { … }` (CJS bag — direct map of name→value)
|
|
14
|
+
* 4. `export const tokens = { … }` (ESM named) — handled via dynamic import in harness
|
|
15
|
+
* 5. `export default { tokens: { … } }` (ESM default) — same
|
|
16
|
+
*
|
|
17
|
+
* Returns flat `{name: value}` map. Nested objects flattened with `.`
|
|
18
|
+
* separator (e.g. `{color: {primary: '#abc'}}` → `color.primary`).
|
|
19
|
+
*
|
|
20
|
+
* Values are stringified via `String(value)`; arrays JSON-encoded.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
const { spawnSync } = require('node:child_process');
|
|
26
|
+
const path = require('node:path');
|
|
27
|
+
const fs = require('node:fs');
|
|
28
|
+
|
|
29
|
+
const HARNESS_PATH = require('node:path').join(__dirname, '_js-harness.cjs');
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Flatten a nested object into dotted keys.
|
|
33
|
+
*
|
|
34
|
+
* @param {unknown} val
|
|
35
|
+
* @param {string} prefix
|
|
36
|
+
* @param {Record<string, string>} out
|
|
37
|
+
*/
|
|
38
|
+
function flatten(val, prefix, out) {
|
|
39
|
+
if (val === null || val === undefined) return;
|
|
40
|
+
if (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean') {
|
|
41
|
+
out[prefix] = String(val);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (Array.isArray(val)) {
|
|
45
|
+
out[prefix] = JSON.stringify(val);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (typeof val === 'object') {
|
|
49
|
+
for (const k of Object.keys(val)) {
|
|
50
|
+
const next = prefix ? `${prefix}.${k}` : k;
|
|
51
|
+
flatten(val[k], next, out);
|
|
52
|
+
}
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
out[prefix] = String(val);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Read tokens from a JS/CJS/MJS module file.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} filePath
|
|
62
|
+
* @returns {{tokens: Record<string, string>, source: string, format: 'js-const', warnings: string[]}}
|
|
63
|
+
*/
|
|
64
|
+
function readJsConst(filePath) {
|
|
65
|
+
const abs = path.resolve(filePath);
|
|
66
|
+
if (!fs.existsSync(abs)) {
|
|
67
|
+
throw new Error(`js-const: file not found: ${abs}`);
|
|
68
|
+
}
|
|
69
|
+
if (!fs.existsSync(HARNESS_PATH)) {
|
|
70
|
+
throw new Error(`js-const: harness missing at ${HARNESS_PATH}`);
|
|
71
|
+
}
|
|
72
|
+
const r = spawnSync(process.execPath, [HARNESS_PATH, abs], {
|
|
73
|
+
encoding: 'utf8',
|
|
74
|
+
timeout: 15_000,
|
|
75
|
+
});
|
|
76
|
+
/** @type {string[]} */
|
|
77
|
+
const warnings = [];
|
|
78
|
+
if (r.status !== 0) {
|
|
79
|
+
return {
|
|
80
|
+
tokens: {},
|
|
81
|
+
source: abs,
|
|
82
|
+
format: 'js-const',
|
|
83
|
+
warnings: [
|
|
84
|
+
`harness-exit-${r.status}: ${(r.stderr || '').slice(0, 400)}`,
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** @type {{tokens?: unknown, error?: string}} */
|
|
89
|
+
let parsed;
|
|
90
|
+
try {
|
|
91
|
+
parsed = JSON.parse(r.stdout);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
return {
|
|
94
|
+
tokens: {},
|
|
95
|
+
source: abs,
|
|
96
|
+
format: 'js-const',
|
|
97
|
+
warnings: [`harness-output-parse-failed: ${err.message}`],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (parsed.error) warnings.push(parsed.error);
|
|
101
|
+
/** @type {Record<string, string>} */
|
|
102
|
+
const flat = {};
|
|
103
|
+
flatten(parsed.tokens, '', flat);
|
|
104
|
+
return { tokens: flat, source: abs, format: 'js-const', warnings };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = { readJsConst, _flatten: flatten };
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* design-tokens/tailwind.cjs — extract tokens from a Tailwind config
|
|
3
|
+
* (Plan 23-08).
|
|
4
|
+
*
|
|
5
|
+
* Reuses the js-const subprocess harness to evaluate the config (which
|
|
6
|
+
* may be CJS or ESM, plain JS or .ts via type-strip), then walks
|
|
7
|
+
* `theme` + `theme.extend` into a flat token map keyed by
|
|
8
|
+
* `<scale>.<key>` (e.g. `colors.brand.500`, `spacing.4`).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const fs = require('node:fs');
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
const { spawnSync } = require('node:child_process');
|
|
16
|
+
|
|
17
|
+
const HARNESS_PATH = path.join(__dirname, '_js-harness.cjs');
|
|
18
|
+
|
|
19
|
+
function flatten(val, prefix, out) {
|
|
20
|
+
if (val === null || val === undefined) return;
|
|
21
|
+
if (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean') {
|
|
22
|
+
out[prefix] = String(val);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (Array.isArray(val)) {
|
|
26
|
+
out[prefix] = JSON.stringify(val);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (typeof val === 'object') {
|
|
30
|
+
for (const k of Object.keys(val)) {
|
|
31
|
+
const next = prefix ? `${prefix}.${k}` : k;
|
|
32
|
+
flatten(val[k], next, out);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Read a Tailwind config. Prefers `theme.extend` over base `theme` for
|
|
39
|
+
* each scale (matching Tailwind merge semantics: extend extends).
|
|
40
|
+
*
|
|
41
|
+
* @param {string} filePath
|
|
42
|
+
* @returns {{tokens: Record<string, string>, source: string, format: 'tailwind', warnings: string[]}}
|
|
43
|
+
*/
|
|
44
|
+
function readTailwind(filePath) {
|
|
45
|
+
const abs = path.resolve(filePath);
|
|
46
|
+
if (!fs.existsSync(abs)) {
|
|
47
|
+
throw new Error(`tailwind: file not found: ${abs}`);
|
|
48
|
+
}
|
|
49
|
+
const r = spawnSync(process.execPath, [HARNESS_PATH, abs], {
|
|
50
|
+
encoding: 'utf8',
|
|
51
|
+
timeout: 15_000,
|
|
52
|
+
});
|
|
53
|
+
/** @type {string[]} */
|
|
54
|
+
const warnings = [];
|
|
55
|
+
if (r.status !== 0) {
|
|
56
|
+
return {
|
|
57
|
+
tokens: {},
|
|
58
|
+
source: abs,
|
|
59
|
+
format: 'tailwind',
|
|
60
|
+
warnings: [`harness-exit-${r.status}: ${(r.stderr || '').slice(0, 400)}`],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
let parsed;
|
|
64
|
+
try {
|
|
65
|
+
parsed = JSON.parse(r.stdout);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
return {
|
|
68
|
+
tokens: {},
|
|
69
|
+
source: abs,
|
|
70
|
+
format: 'tailwind',
|
|
71
|
+
warnings: [`harness-output-parse-failed: ${err.message}`],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (parsed.error) warnings.push(parsed.error);
|
|
75
|
+
/** @type {Record<string, string>} */
|
|
76
|
+
const out = {};
|
|
77
|
+
const config = parsed.tokens ?? {};
|
|
78
|
+
// The harness returns the module exports (or `tokens` field). Tailwind
|
|
79
|
+
// configs export an object with a `theme` field. Some configs export
|
|
80
|
+
// `{theme, plugins, …}` directly.
|
|
81
|
+
const theme = config.theme || config;
|
|
82
|
+
// Base theme.
|
|
83
|
+
if (theme && typeof theme === 'object') {
|
|
84
|
+
for (const scale of Object.keys(theme)) {
|
|
85
|
+
if (scale === 'extend') continue;
|
|
86
|
+
flatten(theme[scale], scale, out);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// theme.extend overrides per-scale.
|
|
90
|
+
if (theme && theme.extend && typeof theme.extend === 'object') {
|
|
91
|
+
for (const scale of Object.keys(theme.extend)) {
|
|
92
|
+
flatten(theme.extend[scale], scale, out);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { tokens: out, source: abs, format: 'tailwind', warnings };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { readTailwind };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* domain-primitives/anti-patterns.cjs — anti-pattern regex matcher
|
|
3
|
+
* (Plan 23-09).
|
|
4
|
+
*
|
|
5
|
+
* Same shape as nng.cjs. Loads rules from `reference/anti-patterns.md`
|
|
6
|
+
* yaml blocks. Caller may inject rules via opts.rules.
|
|
7
|
+
*
|
|
8
|
+
* Rule yaml shape:
|
|
9
|
+
* id: ban-01
|
|
10
|
+
* severity: P1
|
|
11
|
+
* grep: 'side-stripe-class'
|
|
12
|
+
* summary: 'Side-stripe borders are an AI-slop tell'
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
const { parseRulesFromMarkdown } = require('./nng.cjs');
|
|
20
|
+
|
|
21
|
+
let _cache = null;
|
|
22
|
+
|
|
23
|
+
function loadRules(cwd) {
|
|
24
|
+
if (_cache) return _cache;
|
|
25
|
+
const root = cwd ?? path.resolve(__dirname, '..', '..', '..');
|
|
26
|
+
const file = path.join(root, 'reference', 'anti-patterns.md');
|
|
27
|
+
if (!fs.existsSync(file)) {
|
|
28
|
+
_cache = [];
|
|
29
|
+
return _cache;
|
|
30
|
+
}
|
|
31
|
+
_cache = parseRulesFromMarkdown(fs.readFileSync(file, 'utf8'));
|
|
32
|
+
return _cache;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {{file: string, content: string, type?: string, cwd?: string, rules?: object[]}} input
|
|
37
|
+
* @returns {Array<{rule_id: string, severity: string, summary: string, evidence?: string, line?: number, file: string}>}
|
|
38
|
+
*/
|
|
39
|
+
function check(input) {
|
|
40
|
+
if (!input || typeof input !== 'object') return [];
|
|
41
|
+
if (typeof input.content !== 'string' || typeof input.file !== 'string') return [];
|
|
42
|
+
const rules = Array.isArray(input.rules) ? input.rules : loadRules(input.cwd);
|
|
43
|
+
const hits = [];
|
|
44
|
+
const lines = input.content.split(/\r?\n/);
|
|
45
|
+
for (const rule of rules) {
|
|
46
|
+
for (let i = 0; i < lines.length; i++) {
|
|
47
|
+
const m = lines[i].match(rule.grep);
|
|
48
|
+
if (!m) continue;
|
|
49
|
+
hits.push({
|
|
50
|
+
rule_id: rule.id,
|
|
51
|
+
severity: rule.severity,
|
|
52
|
+
summary: rule.summary,
|
|
53
|
+
evidence: m[0].slice(0, 200),
|
|
54
|
+
line: i + 1,
|
|
55
|
+
file: input.file,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return hits;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function _resetCache() {
|
|
63
|
+
_cache = null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { check, _resetCache };
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* domain-primitives/nng.cjs — NNG-style heuristic checker
|
|
3
|
+
* (Plan 23-09).
|
|
4
|
+
*
|
|
5
|
+
* Runs grep-style rules against a single source artifact. Rules are
|
|
6
|
+
* loaded from `reference/heuristics.md` as fenced yaml blocks of the form:
|
|
7
|
+
*
|
|
8
|
+
* ```yaml
|
|
9
|
+
* id: nng-01
|
|
10
|
+
* severity: P1
|
|
11
|
+
* grep: 'placeholder-as-label'
|
|
12
|
+
* summary: 'Inputs use placeholder text instead of an explicit label'
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* If the reference file has no parseable yaml blocks today (the current
|
|
16
|
+
* registry is prose), the checker simply treats the rule list as empty.
|
|
17
|
+
* Caller may supply `opts.rules` directly to bypass the file-load path,
|
|
18
|
+
* which is the test-friendly entry point.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
const fs = require('node:fs');
|
|
24
|
+
const path = require('node:path');
|
|
25
|
+
|
|
26
|
+
const SEVERITIES = new Set(['P0', 'P1', 'P2', 'P3']);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {Object} HeuristicHit
|
|
30
|
+
* @property {string} rule_id
|
|
31
|
+
* @property {'P0'|'P1'|'P2'|'P3'} severity
|
|
32
|
+
* @property {string} summary
|
|
33
|
+
* @property {string} [evidence]
|
|
34
|
+
* @property {number} [line]
|
|
35
|
+
* @property {string} file
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @typedef {Object} CompiledRule
|
|
40
|
+
* @property {string} id
|
|
41
|
+
* @property {'P0'|'P1'|'P2'|'P3'} severity
|
|
42
|
+
* @property {RegExp} grep
|
|
43
|
+
* @property {string} summary
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Extract every fenced ```yaml block from a markdown string and parse
|
|
48
|
+
* each as a flat key:value mapping (single-level, no nesting). Skips
|
|
49
|
+
* blocks that don't have an `id` and `grep` field.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} markdown
|
|
52
|
+
* @returns {CompiledRule[]}
|
|
53
|
+
*/
|
|
54
|
+
function parseRulesFromMarkdown(markdown) {
|
|
55
|
+
const rules = [];
|
|
56
|
+
const re = /```yaml\s*\n([\s\S]*?)\n```/g;
|
|
57
|
+
let m;
|
|
58
|
+
while ((m = re.exec(markdown)) !== null) {
|
|
59
|
+
const body = m[1];
|
|
60
|
+
/** @type {Record<string, string>} */
|
|
61
|
+
const fields = {};
|
|
62
|
+
for (const line of body.split(/\r?\n/)) {
|
|
63
|
+
const kv = line.match(/^\s*([A-Za-z_][\w-]*)\s*:\s*(.*?)\s*$/);
|
|
64
|
+
if (!kv) continue;
|
|
65
|
+
let v = kv[2];
|
|
66
|
+
if ((v.startsWith("'") && v.endsWith("'")) || (v.startsWith('"') && v.endsWith('"'))) {
|
|
67
|
+
v = v.slice(1, -1);
|
|
68
|
+
}
|
|
69
|
+
fields[kv[1]] = v;
|
|
70
|
+
}
|
|
71
|
+
if (!fields.id || !fields.grep || !SEVERITIES.has(fields.severity)) continue;
|
|
72
|
+
let regex;
|
|
73
|
+
try {
|
|
74
|
+
regex = new RegExp(fields.grep);
|
|
75
|
+
} catch {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
rules.push({
|
|
79
|
+
id: fields.id,
|
|
80
|
+
severity: /** @type {'P0'|'P1'|'P2'|'P3'} */ (fields.severity),
|
|
81
|
+
grep: regex,
|
|
82
|
+
summary: fields.summary || fields.id,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return rules;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let _ruleCache = null;
|
|
89
|
+
|
|
90
|
+
function loadRules(cwd) {
|
|
91
|
+
if (_ruleCache) return _ruleCache;
|
|
92
|
+
const root = cwd ?? path.resolve(__dirname, '..', '..', '..');
|
|
93
|
+
const file = path.join(root, 'reference', 'heuristics.md');
|
|
94
|
+
if (!fs.existsSync(file)) {
|
|
95
|
+
_ruleCache = [];
|
|
96
|
+
return _ruleCache;
|
|
97
|
+
}
|
|
98
|
+
const md = fs.readFileSync(file, 'utf8');
|
|
99
|
+
_ruleCache = parseRulesFromMarkdown(md);
|
|
100
|
+
return _ruleCache;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {{file: string, content: string, type?: string, cwd?: string, rules?: CompiledRule[]}} input
|
|
105
|
+
* @returns {HeuristicHit[]}
|
|
106
|
+
*/
|
|
107
|
+
function check(input) {
|
|
108
|
+
if (!input || typeof input !== 'object') return [];
|
|
109
|
+
if (typeof input.content !== 'string' || typeof input.file !== 'string') return [];
|
|
110
|
+
const rules = Array.isArray(input.rules) ? input.rules : loadRules(input.cwd);
|
|
111
|
+
/** @type {HeuristicHit[]} */
|
|
112
|
+
const hits = [];
|
|
113
|
+
const lines = input.content.split(/\r?\n/);
|
|
114
|
+
for (const rule of rules) {
|
|
115
|
+
for (let i = 0; i < lines.length; i++) {
|
|
116
|
+
const ln = lines[i];
|
|
117
|
+
const match = ln.match(rule.grep);
|
|
118
|
+
if (!match) continue;
|
|
119
|
+
hits.push({
|
|
120
|
+
rule_id: rule.id,
|
|
121
|
+
severity: rule.severity,
|
|
122
|
+
summary: rule.summary,
|
|
123
|
+
evidence: match[0].slice(0, 200),
|
|
124
|
+
line: i + 1,
|
|
125
|
+
file: input.file,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return hits;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function _resetCache() {
|
|
133
|
+
_ruleCache = null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = { check, parseRulesFromMarkdown, _resetCache };
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* domain-primitives/wcag.cjs — minimal WCAG validator (Plan 23-09).
|
|
3
|
+
*
|
|
4
|
+
* Three checks (no axe-core dep):
|
|
5
|
+
* 1. Contrast ratio (WCAG 1.4.3 / 1.4.6) — given fg + bg colors,
|
|
6
|
+
* compute the ratio. Pass: ≥4.5 (AA normal text), fail: <4.5.
|
|
7
|
+
* 2. Tap target size (WCAG 2.5.5 AAA / 2.5.8 AA) — given width+height
|
|
8
|
+
* in CSS pixels, fail if either dimension <24 (AA) or <44 (AAA).
|
|
9
|
+
* 3. ARIA label presence — given a snippet of HTML, look for
|
|
10
|
+
* <button>, <a>, <input> elements without an accessible name
|
|
11
|
+
* (no text content AND no aria-label/aria-labelledby).
|
|
12
|
+
*
|
|
13
|
+
* All inputs are passed by the caller — this module does not parse
|
|
14
|
+
* arbitrary CSS or run a browser. Each check returns an Array<HeuristicHit>.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {Object} HeuristicHit
|
|
21
|
+
* @property {string} rule_id
|
|
22
|
+
* @property {'P0'|'P1'|'P2'|'P3'} severity
|
|
23
|
+
* @property {string} summary
|
|
24
|
+
* @property {string} [evidence]
|
|
25
|
+
* @property {number} [line]
|
|
26
|
+
* @property {string} file
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const HEX_RE = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
30
|
+
|
|
31
|
+
function hexToRgb(hex) {
|
|
32
|
+
if (typeof hex !== 'string') return null;
|
|
33
|
+
const m = hex.match(HEX_RE);
|
|
34
|
+
if (!m) return null;
|
|
35
|
+
let h = m[1];
|
|
36
|
+
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
|
|
37
|
+
return {
|
|
38
|
+
r: parseInt(h.slice(0, 2), 16),
|
|
39
|
+
g: parseInt(h.slice(2, 4), 16),
|
|
40
|
+
b: parseInt(h.slice(4, 6), 16),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function relLuminance({ r, g, b }) {
|
|
45
|
+
const channel = (v) => {
|
|
46
|
+
const s = v / 255;
|
|
47
|
+
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
48
|
+
};
|
|
49
|
+
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* WCAG 1.4.3 contrast ratio between two colors.
|
|
54
|
+
* @param {string} fgHex
|
|
55
|
+
* @param {string} bgHex
|
|
56
|
+
* @returns {number} 1.0 to 21.0
|
|
57
|
+
*/
|
|
58
|
+
function contrastRatio(fgHex, bgHex) {
|
|
59
|
+
const fg = hexToRgb(fgHex);
|
|
60
|
+
const bg = hexToRgb(bgHex);
|
|
61
|
+
if (!fg || !bg) return NaN;
|
|
62
|
+
const lf = relLuminance(fg);
|
|
63
|
+
const lb = relLuminance(bg);
|
|
64
|
+
const [hi, lo] = lf > lb ? [lf, lb] : [lb, lf];
|
|
65
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Check contrast against AA (4.5:1) or AAA (7:1) threshold.
|
|
70
|
+
*
|
|
71
|
+
* @param {{file: string, fg: string, bg: string, level?: 'AA'|'AAA', context?: string}} input
|
|
72
|
+
* @returns {HeuristicHit[]}
|
|
73
|
+
*/
|
|
74
|
+
function checkContrast(input) {
|
|
75
|
+
if (!input || typeof input.fg !== 'string' || typeof input.bg !== 'string') return [];
|
|
76
|
+
const ratio = contrastRatio(input.fg, input.bg);
|
|
77
|
+
const level = input.level ?? 'AA';
|
|
78
|
+
const minimum = level === 'AAA' ? 7 : 4.5;
|
|
79
|
+
if (Number.isNaN(ratio)) {
|
|
80
|
+
return [{
|
|
81
|
+
rule_id: 'wcag/1.4.3',
|
|
82
|
+
severity: 'P2',
|
|
83
|
+
summary: `unparseable color: fg=${input.fg} bg=${input.bg}`,
|
|
84
|
+
file: input.file,
|
|
85
|
+
}];
|
|
86
|
+
}
|
|
87
|
+
if (ratio >= minimum) return [];
|
|
88
|
+
return [{
|
|
89
|
+
rule_id: level === 'AAA' ? 'wcag/1.4.6' : 'wcag/1.4.3',
|
|
90
|
+
severity: ratio < 3 ? 'P0' : 'P1',
|
|
91
|
+
summary: `Contrast ratio ${ratio.toFixed(2)} below ${level} minimum ${minimum}:1`,
|
|
92
|
+
evidence: `fg=${input.fg}, bg=${input.bg}, ratio=${ratio.toFixed(2)}`,
|
|
93
|
+
file: input.file,
|
|
94
|
+
}];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Check tap target size (WCAG 2.5.5 / 2.5.8).
|
|
99
|
+
*
|
|
100
|
+
* @param {{file: string, width: number, height: number, level?: 'AA'|'AAA', name?: string}} input
|
|
101
|
+
* @returns {HeuristicHit[]}
|
|
102
|
+
*/
|
|
103
|
+
function checkTapTarget(input) {
|
|
104
|
+
if (!input || typeof input.width !== 'number' || typeof input.height !== 'number') return [];
|
|
105
|
+
const level = input.level ?? 'AA';
|
|
106
|
+
const min = level === 'AAA' ? 44 : 24;
|
|
107
|
+
if (input.width >= min && input.height >= min) return [];
|
|
108
|
+
return [{
|
|
109
|
+
rule_id: level === 'AAA' ? 'wcag/2.5.5' : 'wcag/2.5.8',
|
|
110
|
+
severity: 'P1',
|
|
111
|
+
summary: `Tap target ${input.width}×${input.height}px below ${level} minimum ${min}×${min}px${input.name ? ` (${input.name})` : ''}`,
|
|
112
|
+
evidence: `${input.width}×${input.height}`,
|
|
113
|
+
file: input.file,
|
|
114
|
+
}];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const INTERACTIVE_RE = /<(button|a|input)\b([^>]*)>([\s\S]*?)<\/\1>|<input\b([^>]*)\/?>/gi;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Check that interactive elements have an accessible name.
|
|
121
|
+
*
|
|
122
|
+
* @param {{file: string, content: string}} input
|
|
123
|
+
* @returns {HeuristicHit[]}
|
|
124
|
+
*/
|
|
125
|
+
function checkAriaLabels(input) {
|
|
126
|
+
if (!input || typeof input.content !== 'string') return [];
|
|
127
|
+
const hits = [];
|
|
128
|
+
// Walk lines so we can attach line numbers.
|
|
129
|
+
const lines = input.content.split(/\r?\n/);
|
|
130
|
+
for (let i = 0; i < lines.length; i++) {
|
|
131
|
+
const ln = lines[i];
|
|
132
|
+
const elementMatches = [...ln.matchAll(/<(button|a|input)\b([^>]*?)(?:\s*\/)?>([^<]*)?/gi)];
|
|
133
|
+
for (const em of elementMatches) {
|
|
134
|
+
const tag = em[1].toLowerCase();
|
|
135
|
+
const attrs = em[2] || '';
|
|
136
|
+
const inner = (em[3] || '').trim();
|
|
137
|
+
const hasAriaLabel = /\baria-labell?e?d?b?y?\s*=/.test(attrs);
|
|
138
|
+
const hasTitle = /\btitle\s*=\s*["'][^"']+["']/.test(attrs);
|
|
139
|
+
const hasAlt = /\balt\s*=\s*["'][^"']+["']/.test(attrs);
|
|
140
|
+
if (tag === 'input') {
|
|
141
|
+
// Inputs with type=submit/button can rely on `value` attr.
|
|
142
|
+
const hasValue = /\bvalue\s*=\s*["'][^"']+["']/.test(attrs);
|
|
143
|
+
if (hasAriaLabel || hasTitle || hasValue) continue;
|
|
144
|
+
} else if (inner.length > 0 || hasAriaLabel || hasTitle || hasAlt) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
hits.push({
|
|
148
|
+
rule_id: 'wcag/4.1.2',
|
|
149
|
+
severity: 'P1',
|
|
150
|
+
summary: `<${tag}> has no accessible name (no text content + no aria-label/title)`,
|
|
151
|
+
evidence: em[0].slice(0, 200),
|
|
152
|
+
line: i + 1,
|
|
153
|
+
file: input.file,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return hits;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = {
|
|
161
|
+
contrastRatio,
|
|
162
|
+
hexToRgb,
|
|
163
|
+
checkContrast,
|
|
164
|
+
checkTapTarget,
|
|
165
|
+
checkAriaLabels,
|
|
166
|
+
};
|