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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +91 -0
  3. package/bin/install.js +724 -0
  4. package/core/references/context-engine.md +190 -0
  5. package/core/references/design-inputs.md +421 -0
  6. package/core/references/runtime-adapters.md +180 -0
  7. package/core/references/state-machine.md +124 -0
  8. package/core/references/verticals/ecommerce.md +251 -0
  9. package/core/references/verticals/fintech.md +226 -0
  10. package/core/references/verticals/health.md +235 -0
  11. package/core/references/verticals/saas.md +248 -0
  12. package/core/templates/STATE-TEMPLATE.md +28 -0
  13. package/core/templates/SUMMARY-TEMPLATE.md +21 -0
  14. package/core/templates/VERTICAL-TEMPLATE.md +144 -0
  15. package/core/templates/token-showcase-template.html +946 -0
  16. package/core/workflows/compose-screen.md +163 -0
  17. package/core/workflows/evolve.md +64 -0
  18. package/core/workflows/fix.md +64 -0
  19. package/core/workflows/generate-system.md +336 -0
  20. package/core/workflows/quick.md +23 -0
  21. package/core/workflows/research.md +233 -0
  22. package/core/workflows/review.md +126 -0
  23. package/package.json +26 -0
  24. package/runtimes/claude-code/CLAUDE-MD-SNIPPET.md +34 -0
  25. package/runtimes/claude-code/agents/motif-design-reviewer.md +207 -0
  26. package/runtimes/claude-code/agents/motif-fix-agent.md +119 -0
  27. package/runtimes/claude-code/agents/motif-researcher.md +100 -0
  28. package/runtimes/claude-code/agents/motif-screen-composer.md +157 -0
  29. package/runtimes/claude-code/agents/motif-system-architect.md +120 -0
  30. package/runtimes/claude-code/commands/motif/compose.md +7 -0
  31. package/runtimes/claude-code/commands/motif/evolve.md +6 -0
  32. package/runtimes/claude-code/commands/motif/fix.md +7 -0
  33. package/runtimes/claude-code/commands/motif/help.md +29 -0
  34. package/runtimes/claude-code/commands/motif/init.md +229 -0
  35. package/runtimes/claude-code/commands/motif/progress.md +11 -0
  36. package/runtimes/claude-code/commands/motif/quick.md +7 -0
  37. package/runtimes/claude-code/commands/motif/research.md +4 -0
  38. package/runtimes/claude-code/commands/motif/review.md +7 -0
  39. package/runtimes/claude-code/commands/motif/system.md +4 -0
  40. package/runtimes/claude-code/hooks/motif-aria-check.js +164 -0
  41. package/runtimes/claude-code/hooks/motif-context-monitor.js +40 -0
  42. package/runtimes/claude-code/hooks/motif-font-check.js +192 -0
  43. package/runtimes/claude-code/hooks/motif-token-check.js +221 -0
  44. package/runtimes/cursor/README.md +24 -0
  45. package/runtimes/gemini/README.md +13 -0
  46. package/runtimes/opencode/README.md +28 -0
  47. package/scripts/contrast-checker.js +114 -0
  48. 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)}`);