motif-design 0.1.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/LICENSE +21 -0
- package/README.md +91 -0
- package/bin/install.js +724 -0
- package/core/references/context-engine.md +190 -0
- package/core/references/design-inputs.md +421 -0
- package/core/references/runtime-adapters.md +180 -0
- package/core/references/state-machine.md +124 -0
- package/core/references/verticals/ecommerce.md +251 -0
- package/core/references/verticals/fintech.md +226 -0
- package/core/references/verticals/health.md +235 -0
- package/core/references/verticals/saas.md +248 -0
- package/core/templates/STATE-TEMPLATE.md +28 -0
- package/core/templates/SUMMARY-TEMPLATE.md +21 -0
- package/core/templates/VERTICAL-TEMPLATE.md +144 -0
- package/core/templates/token-showcase-template.html +946 -0
- package/core/workflows/compose-screen.md +163 -0
- package/core/workflows/evolve.md +64 -0
- package/core/workflows/fix.md +64 -0
- package/core/workflows/generate-system.md +336 -0
- package/core/workflows/quick.md +23 -0
- package/core/workflows/research.md +233 -0
- package/core/workflows/review.md +126 -0
- package/package.json +26 -0
- package/runtimes/claude-code/CLAUDE-MD-SNIPPET.md +34 -0
- package/runtimes/claude-code/agents/motif-design-reviewer.md +207 -0
- package/runtimes/claude-code/agents/motif-fix-agent.md +119 -0
- package/runtimes/claude-code/agents/motif-researcher.md +100 -0
- package/runtimes/claude-code/agents/motif-screen-composer.md +157 -0
- package/runtimes/claude-code/agents/motif-system-architect.md +120 -0
- package/runtimes/claude-code/commands/motif/compose.md +7 -0
- package/runtimes/claude-code/commands/motif/evolve.md +6 -0
- package/runtimes/claude-code/commands/motif/fix.md +7 -0
- package/runtimes/claude-code/commands/motif/help.md +29 -0
- package/runtimes/claude-code/commands/motif/init.md +229 -0
- package/runtimes/claude-code/commands/motif/progress.md +11 -0
- package/runtimes/claude-code/commands/motif/quick.md +7 -0
- package/runtimes/claude-code/commands/motif/research.md +4 -0
- package/runtimes/claude-code/commands/motif/review.md +7 -0
- package/runtimes/claude-code/commands/motif/system.md +4 -0
- package/runtimes/claude-code/hooks/motif-aria-check.js +164 -0
- package/runtimes/claude-code/hooks/motif-context-monitor.js +40 -0
- package/runtimes/claude-code/hooks/motif-font-check.js +192 -0
- package/runtimes/claude-code/hooks/motif-token-check.js +221 -0
- package/runtimes/cursor/README.md +24 -0
- package/runtimes/gemini/README.md +13 -0
- package/runtimes/opencode/README.md +28 -0
- package/scripts/contrast-checker.js +114 -0
- package/scripts/token-counter.js +107 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Motif Token Check Hook (HOOK-01)
|
|
6
|
+
*
|
|
7
|
+
* PostToolUse hook that flags hardcoded CSS values (colors, spacing,
|
|
8
|
+
* font-size, border-radius, box-shadow) and suggests design token
|
|
9
|
+
* alternatives from tokens.css.
|
|
10
|
+
*
|
|
11
|
+
* Reads PostToolUse JSON from stdin. Outputs decision JSON on stdout
|
|
12
|
+
* when violations are found. Exits silently (code 0, no output) when clean.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const TARGET_EXTENSIONS = ['css', 'tsx', 'jsx', 'vue', 'html'];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Extract the content to check based on tool type.
|
|
19
|
+
* Write provides full content; Edit provides new_string only.
|
|
20
|
+
*/
|
|
21
|
+
function getContentToCheck(data) {
|
|
22
|
+
if (data.tool_name === 'Write') {
|
|
23
|
+
return data.tool_input.content || '';
|
|
24
|
+
}
|
|
25
|
+
if (data.tool_name === 'Edit') {
|
|
26
|
+
return data.tool_input.new_string || '';
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Remove comment lines from content to avoid false positives.
|
|
33
|
+
* Strips single-line comments (//), block comments, and HTML comments.
|
|
34
|
+
*/
|
|
35
|
+
function stripComments(content) {
|
|
36
|
+
const lines = content.split('\n');
|
|
37
|
+
const result = [];
|
|
38
|
+
let inBlockComment = false;
|
|
39
|
+
|
|
40
|
+
for (const line of lines) {
|
|
41
|
+
const trimmed = line.trim();
|
|
42
|
+
|
|
43
|
+
// Track block comment state
|
|
44
|
+
if (inBlockComment) {
|
|
45
|
+
if (trimmed.includes('*/')) {
|
|
46
|
+
inBlockComment = false;
|
|
47
|
+
}
|
|
48
|
+
result.push(''); // Replace with empty line to preserve line numbers
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (trimmed.startsWith('/*')) {
|
|
53
|
+
if (!trimmed.includes('*/')) {
|
|
54
|
+
inBlockComment = true;
|
|
55
|
+
}
|
|
56
|
+
result.push('');
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('*')) {
|
|
61
|
+
result.push('');
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Strip inline block comments
|
|
66
|
+
let cleaned = line.replace(/\/\*.*?\*\//g, '');
|
|
67
|
+
// Strip HTML comments
|
|
68
|
+
cleaned = cleaned.replace(/<!--.*?-->/g, '');
|
|
69
|
+
|
|
70
|
+
result.push(cleaned);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Check content lines for hardcoded CSS values that should use tokens.
|
|
78
|
+
*/
|
|
79
|
+
function checkForViolations(contentLines) {
|
|
80
|
+
const violations = [];
|
|
81
|
+
|
|
82
|
+
const checks = [
|
|
83
|
+
{
|
|
84
|
+
name: 'hardcoded color',
|
|
85
|
+
pattern: /(color|background(?:-color)?|border(?:-color)?|outline-color|fill|stroke)\s*:\s*(#[0-9a-fA-F]{3,8}|rgba?\(|hsla?\()/g,
|
|
86
|
+
skip: (line) => line.includes('var('),
|
|
87
|
+
suggestion: 'Use var(--color-*) or appropriate color token from tokens.css',
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: 'hardcoded spacing',
|
|
91
|
+
pattern: /(margin|padding)(?:-(top|right|bottom|left))?\s*:\s*(\d+)px/g,
|
|
92
|
+
skip: (line, match) => {
|
|
93
|
+
// Allow 0px and 1px (common resets)
|
|
94
|
+
const pxMatch = match.match(/:\s*(\d+)px/);
|
|
95
|
+
if (pxMatch) {
|
|
96
|
+
const val = parseInt(pxMatch[1], 10);
|
|
97
|
+
if (val <= 1) return true;
|
|
98
|
+
}
|
|
99
|
+
return line.includes('var(');
|
|
100
|
+
},
|
|
101
|
+
suggestion: 'Use var(--space-*) or appropriate spacing token from tokens.css',
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: 'hardcoded gap',
|
|
105
|
+
pattern: /gap\s*:\s*(\d+)px/g,
|
|
106
|
+
skip: (line, match) => {
|
|
107
|
+
const pxMatch = match.match(/:\s*(\d+)px/);
|
|
108
|
+
if (pxMatch) {
|
|
109
|
+
const val = parseInt(pxMatch[1], 10);
|
|
110
|
+
if (val <= 1) return true;
|
|
111
|
+
}
|
|
112
|
+
return line.includes('var(');
|
|
113
|
+
},
|
|
114
|
+
suggestion: 'Use var(--space-*) or appropriate spacing token from tokens.css',
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: 'hardcoded font-size',
|
|
118
|
+
pattern: /font-size\s*:\s*\d+(?:px|rem|em)/g,
|
|
119
|
+
skip: (line) => line.includes('var('),
|
|
120
|
+
suggestion: 'Use var(--text-*) or appropriate font-size token from tokens.css',
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: 'hardcoded border-radius',
|
|
124
|
+
pattern: /border-radius\s*:\s*(\d+)(px|%)/g,
|
|
125
|
+
skip: (line, match) => {
|
|
126
|
+
// Allow 0px (reset), 50% and 9999px (circles)
|
|
127
|
+
const radiusMatch = match.match(/:\s*(\d+)(px|%)/);
|
|
128
|
+
if (radiusMatch) {
|
|
129
|
+
const val = parseInt(radiusMatch[1], 10);
|
|
130
|
+
const unit = radiusMatch[2];
|
|
131
|
+
if (val === 0 && unit === 'px') return true;
|
|
132
|
+
if (val === 50 && unit === '%') return true;
|
|
133
|
+
if (val === 9999 && unit === 'px') return true;
|
|
134
|
+
}
|
|
135
|
+
return line.includes('var(');
|
|
136
|
+
},
|
|
137
|
+
suggestion: 'Use var(--radius-*) or appropriate radius token from tokens.css',
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
name: 'hardcoded box-shadow',
|
|
141
|
+
pattern: /box-shadow\s*:\s*\S/g,
|
|
142
|
+
skip: (line) => {
|
|
143
|
+
// Allow var() references and 'none'
|
|
144
|
+
const shadowMatch = line.match(/box-shadow\s*:\s*([^;}\n]+)/);
|
|
145
|
+
if (shadowMatch) {
|
|
146
|
+
const val = shadowMatch[1].trim().toLowerCase();
|
|
147
|
+
if (val.startsWith('var(') || val === 'none') return true;
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
},
|
|
151
|
+
suggestion: 'Use var(--shadow-*) or appropriate shadow token from tokens.css',
|
|
152
|
+
},
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
for (let i = 0; i < contentLines.length; i++) {
|
|
156
|
+
const line = contentLines[i];
|
|
157
|
+
if (!line.trim()) continue;
|
|
158
|
+
|
|
159
|
+
for (const check of checks) {
|
|
160
|
+
// Reset regex lastIndex for each line
|
|
161
|
+
check.pattern.lastIndex = 0;
|
|
162
|
+
let match;
|
|
163
|
+
while ((match = check.pattern.exec(line)) !== null) {
|
|
164
|
+
if (check.skip && check.skip(line, match[0])) continue;
|
|
165
|
+
violations.push({
|
|
166
|
+
line: i + 1,
|
|
167
|
+
type: check.name,
|
|
168
|
+
matched: match[0],
|
|
169
|
+
suggestion: check.suggestion,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return violations;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Main: read JSON from stdin
|
|
179
|
+
let input = '';
|
|
180
|
+
process.stdin.on('data', (chunk) => (input += chunk));
|
|
181
|
+
process.stdin.on('end', () => {
|
|
182
|
+
try {
|
|
183
|
+
const data = JSON.parse(input);
|
|
184
|
+
|
|
185
|
+
// Extract file path and check extension
|
|
186
|
+
const filePath = data.tool_input && data.tool_input.file_path;
|
|
187
|
+
if (!filePath) process.exit(0);
|
|
188
|
+
|
|
189
|
+
const ext = filePath.split('.').pop().toLowerCase();
|
|
190
|
+
if (!TARGET_EXTENSIONS.includes(ext)) {
|
|
191
|
+
process.exit(0);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Get content to check
|
|
195
|
+
const content = getContentToCheck(data);
|
|
196
|
+
if (!content) process.exit(0);
|
|
197
|
+
|
|
198
|
+
// Strip comments and check for violations
|
|
199
|
+
const contentLines = stripComments(content);
|
|
200
|
+
const violations = checkForViolations(contentLines);
|
|
201
|
+
|
|
202
|
+
if (violations.length === 0) {
|
|
203
|
+
process.exit(0);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Format violation messages
|
|
207
|
+
const messages = violations.map(
|
|
208
|
+
(v) => `- Line ${v.line}: [${v.type}] \`${v.matched}\` -- ${v.suggestion}`
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const output = JSON.stringify({
|
|
212
|
+
decision: 'block',
|
|
213
|
+
reason: `Motif token-check: ${violations.length} violation(s) found:\n${messages.join('\n')}`,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
process.stdout.write(output);
|
|
217
|
+
} catch (e) {
|
|
218
|
+
// If JSON parsing fails or any error, exit silently
|
|
219
|
+
process.exit(0);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Cursor / Windsurf Runtime Adapter
|
|
2
|
+
|
|
3
|
+
## Status: NOT BUILT (v1.2 target)
|
|
4
|
+
|
|
5
|
+
## What Needs to Be Created
|
|
6
|
+
|
|
7
|
+
1. **rules-snippet.md** — Condensed Motif instructions for `.cursorrules` / `.windsurfrules`
|
|
8
|
+
- No slash commands (these editors don't support them)
|
|
9
|
+
- No subagent spawning (single context only)
|
|
10
|
+
- The entire Motif workflow compressed into rules-file instructions
|
|
11
|
+
- Include: vertical detection, token generation logic, review checklist
|
|
12
|
+
|
|
13
|
+
2. **install mapping** — Simpler than Claude Code:
|
|
14
|
+
- `core/*` → `.motif/` (just drop it in the project)
|
|
15
|
+
- Append rules snippet to `.cursorrules` or `.windsurfrules`
|
|
16
|
+
|
|
17
|
+
## Limitations
|
|
18
|
+
- No fresh context per screen — quality will degrade on 5+ screen projects
|
|
19
|
+
- No parallel research agents — runs sequentially in main context
|
|
20
|
+
- No hooks — no automated enforcement
|
|
21
|
+
- User triggers workflows manually by referencing the files
|
|
22
|
+
|
|
23
|
+
## Value Proposition
|
|
24
|
+
Even without subagents, the vertical references, differentiation seed, token decision algorithms, and review framework still improve output quality significantly compared to no design system.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Gemini CLI Runtime Adapter
|
|
2
|
+
|
|
3
|
+
## Status: NOT BUILT (v1.3 target)
|
|
4
|
+
|
|
5
|
+
## What Needs to Be Created
|
|
6
|
+
|
|
7
|
+
1. **commands/motif/*.md** — Adapt from Claude Code format to Gemini CLI command format
|
|
8
|
+
2. **config-snippet.md** — Adapt for GEMINI.md injection
|
|
9
|
+
3. Determine Gemini CLI's subagent/task delegation mechanism
|
|
10
|
+
|
|
11
|
+
## Notes
|
|
12
|
+
- Gemini CLI is still evolving — wait for stable API before building
|
|
13
|
+
- GSD's Gemini support had issues with TOML format conversion — learn from their mistakes
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# OpenCode Runtime Adapter
|
|
2
|
+
|
|
3
|
+
## Status: NOT BUILT (v1.1 target)
|
|
4
|
+
|
|
5
|
+
## What Needs to Be Created
|
|
6
|
+
|
|
7
|
+
1. **commands/motif/*.md** — Copy from `runtimes/claude-code/commands/motif/`, replace workflow paths:
|
|
8
|
+
- `.claude/get-motif/` → `.opencode/get-motif/`
|
|
9
|
+
- Adapt any Claude Code-specific syntax
|
|
10
|
+
|
|
11
|
+
2. **agents/*.md** — Copy from `runtimes/claude-code/agents/`, adapt:
|
|
12
|
+
- Replace `Task()` spawning with OpenCode's `agent()` mechanism
|
|
13
|
+
- Update model profile references to OpenCode's format
|
|
14
|
+
|
|
15
|
+
3. **config-snippet.md** — Adapt CLAUDE-MD-SNIPPET.md for OpenCode's AGENTS.md format
|
|
16
|
+
|
|
17
|
+
## Key Differences from Claude Code
|
|
18
|
+
- Subagent spawning uses different syntax
|
|
19
|
+
- Model profiles managed via `opencode.json` instead of Claude Code settings
|
|
20
|
+
- Agent definitions may use different frontmatter format
|
|
21
|
+
- No hooks support (or different hook format) — skip hooks for now
|
|
22
|
+
|
|
23
|
+
## Testing
|
|
24
|
+
Run full workflow on a test project using OpenCode to verify:
|
|
25
|
+
- Commands resolve correctly
|
|
26
|
+
- Subagents spawn with fresh context
|
|
27
|
+
- State management works
|
|
28
|
+
- Commits follow convention
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* WCAG 2.1 Contrast Ratio Calculator (SCRP-01)
|
|
6
|
+
*
|
|
7
|
+
* Usage: node scripts/contrast-checker.js <color1> <color2>
|
|
8
|
+
* Colors: hex values with or without # (supports 3-char and 6-char)
|
|
9
|
+
*
|
|
10
|
+
* Zero external dependencies -- pure Node.js.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const args = process.argv.slice(2);
|
|
14
|
+
|
|
15
|
+
if (args.length < 2) {
|
|
16
|
+
console.error('Usage: node scripts/contrast-checker.js <color1> <color2>');
|
|
17
|
+
console.error(' Colors are hex values, e.g. "#000000" "#FFFFFF" or "000" "fff"');
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parse hex color string to [r, g, b] in 0-255 range.
|
|
23
|
+
* Supports 3-char (#abc) and 6-char (#abcdef) formats, with or without #.
|
|
24
|
+
*/
|
|
25
|
+
function parseHex(hex) {
|
|
26
|
+
hex = hex.replace(/^#/, '');
|
|
27
|
+
if (!/^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6}$/.test(hex)) {
|
|
28
|
+
console.error(`Invalid color format: "${hex}". Use 3 or 6 hex digits (e.g. #abc or #abcdef).`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
if (hex.length === 3) {
|
|
32
|
+
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
|
|
33
|
+
}
|
|
34
|
+
return [
|
|
35
|
+
parseInt(hex.slice(0, 2), 16),
|
|
36
|
+
parseInt(hex.slice(2, 4), 16),
|
|
37
|
+
parseInt(hex.slice(4, 6), 16),
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Convert sRGB channel (0-255) to linear value.
|
|
43
|
+
* Uses WCAG 2.1 threshold of 0.04045 (NOT the old 0.03928).
|
|
44
|
+
*/
|
|
45
|
+
function linearize(channel) {
|
|
46
|
+
const sRGB = channel / 255;
|
|
47
|
+
return sRGB <= 0.04045
|
|
48
|
+
? sRGB / 12.92
|
|
49
|
+
: Math.pow((sRGB + 0.055) / 1.055, 2.4);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Calculate relative luminance per WCAG 2.1.
|
|
54
|
+
* L = 0.2126 * R + 0.7152 * G + 0.0722 * B
|
|
55
|
+
*/
|
|
56
|
+
function relativeLuminance(r, g, b) {
|
|
57
|
+
return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Calculate contrast ratio between two hex colors.
|
|
62
|
+
* Returns a number (e.g. 21.0, 4.5, etc.)
|
|
63
|
+
*/
|
|
64
|
+
function contrastRatio(hex1, hex2) {
|
|
65
|
+
const [r1, g1, b1] = parseHex(hex1);
|
|
66
|
+
const [r2, g2, b2] = parseHex(hex2);
|
|
67
|
+
const l1 = relativeLuminance(r1, g1, b1);
|
|
68
|
+
const l2 = relativeLuminance(r2, g2, b2);
|
|
69
|
+
const lighter = Math.max(l1, l2);
|
|
70
|
+
const darker = Math.min(l1, l2);
|
|
71
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Format contrast ratio for display.
|
|
76
|
+
* Rounds to 2 decimal places, removes trailing zeros.
|
|
77
|
+
*/
|
|
78
|
+
function formatRatio(ratio) {
|
|
79
|
+
// Round to 2 decimals
|
|
80
|
+
const rounded = Math.round(ratio * 100) / 100;
|
|
81
|
+
// If it's a whole number, show as N:1
|
|
82
|
+
if (rounded === Math.floor(rounded)) {
|
|
83
|
+
return `${rounded}:1`;
|
|
84
|
+
}
|
|
85
|
+
return `${rounded}:1`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Normalize input colors to have # prefix for display
|
|
89
|
+
function normalizeForDisplay(hex) {
|
|
90
|
+
hex = hex.replace(/^#/, '').toUpperCase();
|
|
91
|
+
if (hex.length === 3) {
|
|
92
|
+
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
|
|
93
|
+
}
|
|
94
|
+
return '#' + hex;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const color1 = args[0];
|
|
98
|
+
const color2 = args[1];
|
|
99
|
+
const ratio = contrastRatio(color1, color2);
|
|
100
|
+
|
|
101
|
+
const aaNormal = ratio >= 4.5; // WCAG AA normal text (4.5:1)
|
|
102
|
+
const aaLarge = ratio >= 3; // WCAG AA large text (3:1)
|
|
103
|
+
const aaaNormal = ratio >= 7; // WCAG AAA normal text (7:1)
|
|
104
|
+
const aaaLarge = ratio >= 4.5; // WCAG AAA large text (4.5:1)
|
|
105
|
+
|
|
106
|
+
const pass = '\x1b[32mPASS\x1b[0m';
|
|
107
|
+
const fail = '\x1b[31mFAIL\x1b[0m';
|
|
108
|
+
|
|
109
|
+
console.log(`Colors: ${normalizeForDisplay(color1)} vs ${normalizeForDisplay(color2)}`);
|
|
110
|
+
console.log(`Contrast ratio: ${formatRatio(ratio)}`);
|
|
111
|
+
console.log(`WCAG AA normal text (4.5:1): ${aaNormal ? pass : fail}`);
|
|
112
|
+
console.log(`WCAG AA large text (3:1): ${aaLarge ? pass : fail}`);
|
|
113
|
+
console.log(`WCAG AAA normal text (7:1): ${aaaNormal ? pass : fail}`);
|
|
114
|
+
console.log(`WCAG AAA large text (4.5:1): ${aaaLarge ? pass : fail}`);
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Approximate Token Counter (SCRP-02)
|
|
6
|
+
*
|
|
7
|
+
* Usage: node scripts/token-counter.js [directory]
|
|
8
|
+
* Defaults to .planning/design/ if no argument provided.
|
|
9
|
+
*
|
|
10
|
+
* Walks the target directory recursively, reads all text files,
|
|
11
|
+
* and reports an approximate token count using the ~4 chars/token heuristic.
|
|
12
|
+
*
|
|
13
|
+
* Zero external dependencies -- pure Node.js.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require('node:fs');
|
|
17
|
+
const path = require('node:path');
|
|
18
|
+
|
|
19
|
+
const targetDir = process.argv[2] || '.planning/design/';
|
|
20
|
+
|
|
21
|
+
// Resolve to absolute path for display consistency
|
|
22
|
+
const resolvedDir = path.resolve(targetDir);
|
|
23
|
+
|
|
24
|
+
if (!fs.existsSync(resolvedDir)) {
|
|
25
|
+
console.error(`Directory not found: ${targetDir}`);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const stat = fs.statSync(resolvedDir);
|
|
30
|
+
if (!stat.isDirectory()) {
|
|
31
|
+
console.error(`Not a directory: ${targetDir}`);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Check if a file appears to be binary by looking for null bytes
|
|
37
|
+
* in the first 512 bytes.
|
|
38
|
+
*/
|
|
39
|
+
function isBinary(filePath) {
|
|
40
|
+
const fd = fs.openSync(filePath, 'r');
|
|
41
|
+
const buf = Buffer.alloc(512);
|
|
42
|
+
const bytesRead = fs.readSync(fd, buf, 0, 512, 0);
|
|
43
|
+
fs.closeSync(fd);
|
|
44
|
+
for (let i = 0; i < bytesRead; i++) {
|
|
45
|
+
if (buf[i] === 0) return true;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Recursively walk a directory, summing character counts of text files.
|
|
52
|
+
* Returns { chars, files } totals.
|
|
53
|
+
*/
|
|
54
|
+
function walkDir(dir) {
|
|
55
|
+
let totalChars = 0;
|
|
56
|
+
let fileCount = 0;
|
|
57
|
+
|
|
58
|
+
let entries;
|
|
59
|
+
try {
|
|
60
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
61
|
+
} catch (e) {
|
|
62
|
+
// Permission denied or other read error -- skip this directory
|
|
63
|
+
return { chars: 0, files: 0 };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
const fullPath = path.join(dir, entry.name);
|
|
68
|
+
|
|
69
|
+
// Skip .DS_Store
|
|
70
|
+
if (entry.name === '.DS_Store') continue;
|
|
71
|
+
|
|
72
|
+
if (entry.isDirectory()) {
|
|
73
|
+
const sub = walkDir(fullPath);
|
|
74
|
+
totalChars += sub.chars;
|
|
75
|
+
fileCount += sub.files;
|
|
76
|
+
} else if (entry.isFile()) {
|
|
77
|
+
// Skip binary files
|
|
78
|
+
try {
|
|
79
|
+
if (isBinary(fullPath)) continue;
|
|
80
|
+
const content = fs.readFileSync(fullPath, 'utf8');
|
|
81
|
+
totalChars += content.length;
|
|
82
|
+
fileCount++;
|
|
83
|
+
} catch (e) {
|
|
84
|
+
// Unreadable file -- skip
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { chars: totalChars, files: fileCount };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Format a number with commas for readability.
|
|
95
|
+
* e.g. 48320 -> "48,320"
|
|
96
|
+
*/
|
|
97
|
+
function formatNumber(n) {
|
|
98
|
+
return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const result = walkDir(resolvedDir);
|
|
102
|
+
const approxTokens = Math.ceil(result.chars / 4);
|
|
103
|
+
|
|
104
|
+
console.log(`Directory: ${targetDir}`);
|
|
105
|
+
console.log(`Files scanned: ${formatNumber(result.files)}`);
|
|
106
|
+
console.log(`Total characters: ${formatNumber(result.chars)}`);
|
|
107
|
+
console.log(`Approximate tokens: ~${formatNumber(approxTokens)}`);
|