@hegemonart/get-design-done 1.22.0 → 1.23.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ };