@hustle-together/api-dev-tools 3.6.5 → 3.10.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/README.md +5599 -258
- package/bin/cli.js +395 -20
- package/commands/README.md +459 -71
- package/commands/hustle-api-continue.md +158 -0
- package/commands/{api-create.md → hustle-api-create.md} +35 -15
- package/commands/{api-env.md → hustle-api-env.md} +4 -4
- package/commands/{api-interview.md → hustle-api-interview.md} +1 -1
- package/commands/{api-research.md → hustle-api-research.md} +3 -3
- package/commands/hustle-api-sessions.md +149 -0
- package/commands/{api-status.md → hustle-api-status.md} +16 -16
- package/commands/{api-verify.md → hustle-api-verify.md} +2 -2
- package/commands/hustle-combine.md +763 -0
- package/commands/hustle-ui-create-page.md +933 -0
- package/commands/hustle-ui-create.md +825 -0
- package/hooks/api-workflow-check.py +545 -21
- package/hooks/cache-research.py +337 -0
- package/hooks/check-api-routes.py +168 -0
- package/hooks/check-playwright-setup.py +103 -0
- package/hooks/check-storybook-setup.py +81 -0
- package/hooks/detect-interruption.py +165 -0
- package/hooks/enforce-a11y-audit.py +202 -0
- package/hooks/enforce-brand-guide.py +241 -0
- package/hooks/enforce-documentation.py +60 -8
- package/hooks/enforce-freshness.py +184 -0
- package/hooks/enforce-page-components.py +186 -0
- package/hooks/enforce-page-data-schema.py +155 -0
- package/hooks/enforce-questions-sourced.py +146 -0
- package/hooks/enforce-schema-from-interview.py +248 -0
- package/hooks/enforce-ui-disambiguation.py +108 -0
- package/hooks/enforce-ui-interview.py +130 -0
- package/hooks/generate-manifest-entry.py +1161 -0
- package/hooks/session-logger.py +297 -0
- package/hooks/session-startup.py +160 -15
- package/hooks/track-scope-coverage.py +220 -0
- package/hooks/track-tool-use.py +81 -1
- package/hooks/update-api-showcase.py +149 -0
- package/hooks/update-registry.py +352 -0
- package/hooks/update-ui-showcase.py +212 -0
- package/package.json +8 -3
- package/templates/BRAND_GUIDE.md +299 -0
- package/templates/CLAUDE-SECTION.md +56 -24
- package/templates/SPEC.json +640 -0
- package/templates/api-dev-state.json +217 -161
- package/templates/api-showcase/_components/APICard.tsx +153 -0
- package/templates/api-showcase/_components/APIModal.tsx +375 -0
- package/templates/api-showcase/_components/APIShowcase.tsx +231 -0
- package/templates/api-showcase/_components/APITester.tsx +522 -0
- package/templates/api-showcase/page.tsx +41 -0
- package/templates/component/Component.stories.tsx +172 -0
- package/templates/component/Component.test.tsx +237 -0
- package/templates/component/Component.tsx +86 -0
- package/templates/component/Component.types.ts +55 -0
- package/templates/component/index.ts +15 -0
- package/templates/dev-tools/_components/DevToolsLanding.tsx +320 -0
- package/templates/dev-tools/page.tsx +10 -0
- package/templates/page/page.e2e.test.ts +218 -0
- package/templates/page/page.tsx +42 -0
- package/templates/performance-budgets.json +58 -0
- package/templates/registry.json +13 -0
- package/templates/settings.json +90 -0
- package/templates/shared/HeroHeader.tsx +261 -0
- package/templates/shared/index.ts +1 -0
- package/templates/ui-showcase/_components/PreviewCard.tsx +315 -0
- package/templates/ui-showcase/_components/PreviewModal.tsx +676 -0
- package/templates/ui-showcase/_components/UIShowcase.tsx +262 -0
- package/templates/ui-showcase/page.tsx +26 -0
- package/demo/hustle-together/blog/gemini-vs-claude-widgets.html +0 -959
- package/demo/hustle-together/blog/interview-driven-api-development.html +0 -1146
- package/demo/hustle-together/blog/tdd-for-ai.html +0 -982
- package/demo/hustle-together/index.html +0 -1312
- package/demo/workflow-demo-v3.5-backup.html +0 -5008
- package/demo/workflow-demo.html +0 -6202
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Hook: SessionStart
|
|
4
|
+
Purpose: Detect and prompt for interrupted workflows
|
|
5
|
+
|
|
6
|
+
This hook runs at session start and checks if there are any
|
|
7
|
+
in-progress workflows that were interrupted. If found, it injects
|
|
8
|
+
a prompt asking the user if they want to resume.
|
|
9
|
+
|
|
10
|
+
Added in v3.6.7 for session continuation support.
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
- JSON with additionalContext about interrupted workflows
|
|
14
|
+
"""
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
import os
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
STATE_FILE = Path(__file__).parent.parent / "api-dev-state.json"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_interrupted_workflows(state):
|
|
25
|
+
"""Find all workflows that are in_progress but not active."""
|
|
26
|
+
interrupted = []
|
|
27
|
+
|
|
28
|
+
# New format (v3.6.7+): check endpoints object
|
|
29
|
+
if "endpoints" in state:
|
|
30
|
+
active = state.get("active_endpoint")
|
|
31
|
+
for endpoint_name, endpoint_data in state["endpoints"].items():
|
|
32
|
+
status = endpoint_data.get("status", "not_started")
|
|
33
|
+
if status == "in_progress" and endpoint_name != active:
|
|
34
|
+
# Find the current phase
|
|
35
|
+
phases = endpoint_data.get("phases", {})
|
|
36
|
+
current_phase = None
|
|
37
|
+
for phase_name, phase_data in phases.items():
|
|
38
|
+
if phase_data.get("status") == "in_progress":
|
|
39
|
+
current_phase = phase_name
|
|
40
|
+
break
|
|
41
|
+
|
|
42
|
+
interrupted.append({
|
|
43
|
+
"endpoint": endpoint_name,
|
|
44
|
+
"status": status,
|
|
45
|
+
"current_phase": current_phase,
|
|
46
|
+
"started_at": endpoint_data.get("started_at"),
|
|
47
|
+
"interrupted_at": endpoint_data.get("session", {}).get("interrupted_at"),
|
|
48
|
+
"interrupted_phase": endpoint_data.get("session", {}).get("interrupted_phase")
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
# Also check if active endpoint is not fully started
|
|
52
|
+
if active and active in state["endpoints"]:
|
|
53
|
+
active_data = state["endpoints"][active]
|
|
54
|
+
session = active_data.get("session", {})
|
|
55
|
+
if session.get("interrupted_at"):
|
|
56
|
+
# Active endpoint was previously interrupted
|
|
57
|
+
interrupted.insert(0, {
|
|
58
|
+
"endpoint": active,
|
|
59
|
+
"status": active_data.get("status"),
|
|
60
|
+
"current_phase": session.get("interrupted_phase"),
|
|
61
|
+
"started_at": active_data.get("started_at"),
|
|
62
|
+
"interrupted_at": session.get("interrupted_at"),
|
|
63
|
+
"is_active": True
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
# Old format: single endpoint
|
|
67
|
+
elif state.get("endpoint"):
|
|
68
|
+
endpoint = state.get("endpoint")
|
|
69
|
+
phases = state.get("phases", {})
|
|
70
|
+
|
|
71
|
+
# Check if any phase is in_progress
|
|
72
|
+
for phase_name, phase_data in phases.items():
|
|
73
|
+
if phase_data.get("status") == "in_progress":
|
|
74
|
+
interrupted.append({
|
|
75
|
+
"endpoint": endpoint,
|
|
76
|
+
"status": "in_progress",
|
|
77
|
+
"current_phase": phase_name,
|
|
78
|
+
"started_at": state.get("created_at"),
|
|
79
|
+
"is_legacy": True
|
|
80
|
+
})
|
|
81
|
+
break
|
|
82
|
+
|
|
83
|
+
return interrupted
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def format_interrupted_message(interrupted):
|
|
87
|
+
"""Format a user-friendly message about interrupted workflows."""
|
|
88
|
+
if not interrupted:
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
lines = [
|
|
92
|
+
"",
|
|
93
|
+
"=" * 60,
|
|
94
|
+
" INTERRUPTED WORKFLOW DETECTED",
|
|
95
|
+
"=" * 60,
|
|
96
|
+
""
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
for i, workflow in enumerate(interrupted, 1):
|
|
100
|
+
endpoint = workflow["endpoint"]
|
|
101
|
+
phase = workflow.get("current_phase", "unknown")
|
|
102
|
+
started = workflow.get("started_at", "unknown")
|
|
103
|
+
interrupted_at = workflow.get("interrupted_at", "")
|
|
104
|
+
|
|
105
|
+
lines.append(f"{i}. **{endpoint}**")
|
|
106
|
+
lines.append(f" - Phase: {phase}")
|
|
107
|
+
lines.append(f" - Started: {started}")
|
|
108
|
+
if interrupted_at:
|
|
109
|
+
lines.append(f" - Interrupted: {interrupted_at}")
|
|
110
|
+
lines.append("")
|
|
111
|
+
|
|
112
|
+
lines.extend([
|
|
113
|
+
"To resume an interrupted workflow, use:",
|
|
114
|
+
" /api-continue [endpoint-name]",
|
|
115
|
+
"",
|
|
116
|
+
"Or start a new workflow with:",
|
|
117
|
+
" /api-create [new-endpoint-name]",
|
|
118
|
+
"",
|
|
119
|
+
"=" * 60
|
|
120
|
+
])
|
|
121
|
+
|
|
122
|
+
return "\n".join(lines)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def main():
|
|
126
|
+
try:
|
|
127
|
+
input_data = json.load(sys.stdin)
|
|
128
|
+
except json.JSONDecodeError:
|
|
129
|
+
input_data = {}
|
|
130
|
+
|
|
131
|
+
# Check if state file exists
|
|
132
|
+
if not STATE_FILE.exists():
|
|
133
|
+
print(json.dumps({"continue": True}))
|
|
134
|
+
sys.exit(0)
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
state = json.loads(STATE_FILE.read_text())
|
|
138
|
+
except json.JSONDecodeError:
|
|
139
|
+
print(json.dumps({"continue": True}))
|
|
140
|
+
sys.exit(0)
|
|
141
|
+
|
|
142
|
+
# Find interrupted workflows
|
|
143
|
+
interrupted = get_interrupted_workflows(state)
|
|
144
|
+
|
|
145
|
+
if not interrupted:
|
|
146
|
+
print(json.dumps({"continue": True}))
|
|
147
|
+
sys.exit(0)
|
|
148
|
+
|
|
149
|
+
# Format message
|
|
150
|
+
message = format_interrupted_message(interrupted)
|
|
151
|
+
|
|
152
|
+
output = {
|
|
153
|
+
"hookSpecificOutput": {
|
|
154
|
+
"hookEventName": "SessionStart",
|
|
155
|
+
"additionalContext": message,
|
|
156
|
+
"interruptedWorkflows": interrupted
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
print(json.dumps(output))
|
|
161
|
+
sys.exit(0)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
if __name__ == "__main__":
|
|
165
|
+
main()
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Hook: PostToolUse for Write/Edit
|
|
4
|
+
Purpose: Trigger accessibility audit after UI component/page implementation
|
|
5
|
+
|
|
6
|
+
This hook runs after Phase 9 (TDD GREEN) for UI workflows. It notifies Claude
|
|
7
|
+
to run axe-core audit on Storybook stories or pages to verify WCAG compliance.
|
|
8
|
+
|
|
9
|
+
Version: 3.10.0
|
|
10
|
+
|
|
11
|
+
Returns:
|
|
12
|
+
- {"continue": true} - Always continues
|
|
13
|
+
- May include "notify" with accessibility check reminder
|
|
14
|
+
- May include "additionalContext" with accessibility guidelines
|
|
15
|
+
"""
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
# State file is in .claude/ directory (sibling to hooks/)
|
|
21
|
+
STATE_FILE = Path(__file__).parent.parent / "api-dev-state.json"
|
|
22
|
+
|
|
23
|
+
# WCAG 2.1 Level AA Quick Reference
|
|
24
|
+
WCAG_AA_CHECKLIST = [
|
|
25
|
+
"Color contrast: 4.5:1 for normal text, 3:1 for large text",
|
|
26
|
+
"Focus visible: All interactive elements show focus state",
|
|
27
|
+
"Keyboard nav: All functionality accessible via keyboard",
|
|
28
|
+
"Labels: All form inputs have associated labels",
|
|
29
|
+
"Alt text: All images have meaningful alt text",
|
|
30
|
+
"Headings: Proper heading hierarchy (h1-h6)",
|
|
31
|
+
"Touch targets: Min 44x44px for touch targets",
|
|
32
|
+
"Error messages: Clear error identification and suggestions",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_workflow_type(state):
|
|
37
|
+
"""Detect the workflow type from state."""
|
|
38
|
+
workflow = state.get("workflow", "")
|
|
39
|
+
if workflow:
|
|
40
|
+
return workflow
|
|
41
|
+
|
|
42
|
+
if state.get("ui_config"):
|
|
43
|
+
mode = state.get("ui_config", {}).get("mode", "")
|
|
44
|
+
return f"ui-create-{mode}" if mode else "ui-create-component"
|
|
45
|
+
|
|
46
|
+
return "api-create"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_active_element(state):
|
|
50
|
+
"""Get active element name and data."""
|
|
51
|
+
if "elements" in state and "active_element" in state:
|
|
52
|
+
active = state.get("active_element")
|
|
53
|
+
if active and active in state["elements"]:
|
|
54
|
+
return active, state["elements"][active]
|
|
55
|
+
return None, None
|
|
56
|
+
|
|
57
|
+
active = state.get("active_element")
|
|
58
|
+
if active:
|
|
59
|
+
return active, state
|
|
60
|
+
|
|
61
|
+
return None, None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def is_verify_phase(phases):
|
|
65
|
+
"""Check if we're in or just completed the verify phase."""
|
|
66
|
+
verify = phases.get("verify", {})
|
|
67
|
+
tdd_green = phases.get("tdd_green", {})
|
|
68
|
+
|
|
69
|
+
# After green, before or during verify
|
|
70
|
+
return (
|
|
71
|
+
tdd_green.get("status") == "complete" and
|
|
72
|
+
verify.get("status") in ["not_started", "in_progress"]
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_accessibility_level(state, element_data):
|
|
77
|
+
"""Get the accessibility level requirement."""
|
|
78
|
+
ui_config = state.get("ui_config", {})
|
|
79
|
+
if not ui_config and element_data:
|
|
80
|
+
ui_config = element_data.get("ui_config", {})
|
|
81
|
+
|
|
82
|
+
return ui_config.get("accessibility_level", "AA")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def generate_a11y_commands(element_name, workflow_type):
|
|
86
|
+
"""Generate accessibility testing commands."""
|
|
87
|
+
commands = []
|
|
88
|
+
|
|
89
|
+
if "component" in workflow_type:
|
|
90
|
+
commands.extend([
|
|
91
|
+
f"# Storybook accessibility check",
|
|
92
|
+
f"pnpm storybook --ci",
|
|
93
|
+
f"# Then run axe in browser or:",
|
|
94
|
+
f"pnpm dlx @storybook/test-runner --url http://localhost:6006",
|
|
95
|
+
f"",
|
|
96
|
+
f"# Or manual axe-core check:",
|
|
97
|
+
f"pnpm dlx @axe-core/cli http://localhost:6006/?path=/story/{element_name.lower()}--default"
|
|
98
|
+
])
|
|
99
|
+
else:
|
|
100
|
+
commands.extend([
|
|
101
|
+
f"# Page accessibility check",
|
|
102
|
+
f"pnpm dev",
|
|
103
|
+
f"# Then in another terminal:",
|
|
104
|
+
f"pnpm dlx @axe-core/cli http://localhost:3000/{element_name}",
|
|
105
|
+
f"",
|
|
106
|
+
f"# Or use Playwright accessibility tests:",
|
|
107
|
+
f"pnpm test:e2e --grep 'accessibility'"
|
|
108
|
+
])
|
|
109
|
+
|
|
110
|
+
return "\n".join(commands)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def main():
|
|
114
|
+
# Read hook input from stdin
|
|
115
|
+
try:
|
|
116
|
+
input_data = json.load(sys.stdin)
|
|
117
|
+
except json.JSONDecodeError:
|
|
118
|
+
print(json.dumps({"continue": True}))
|
|
119
|
+
sys.exit(0)
|
|
120
|
+
|
|
121
|
+
tool_name = input_data.get("tool_name", "")
|
|
122
|
+
|
|
123
|
+
# Only process Write/Edit operations
|
|
124
|
+
if tool_name not in ["Write", "Edit"]:
|
|
125
|
+
print(json.dumps({"continue": True}))
|
|
126
|
+
sys.exit(0)
|
|
127
|
+
|
|
128
|
+
# Check if state file exists
|
|
129
|
+
if not STATE_FILE.exists():
|
|
130
|
+
print(json.dumps({"continue": True}))
|
|
131
|
+
sys.exit(0)
|
|
132
|
+
|
|
133
|
+
# Load state
|
|
134
|
+
try:
|
|
135
|
+
state = json.loads(STATE_FILE.read_text())
|
|
136
|
+
except json.JSONDecodeError:
|
|
137
|
+
print(json.dumps({"continue": True}))
|
|
138
|
+
sys.exit(0)
|
|
139
|
+
|
|
140
|
+
workflow_type = get_workflow_type(state)
|
|
141
|
+
|
|
142
|
+
# Only apply for UI workflows
|
|
143
|
+
if not workflow_type.startswith("ui-create"):
|
|
144
|
+
print(json.dumps({"continue": True}))
|
|
145
|
+
sys.exit(0)
|
|
146
|
+
|
|
147
|
+
# Get active element
|
|
148
|
+
element_name, element_data = get_active_element(state)
|
|
149
|
+
if not element_name or not element_data:
|
|
150
|
+
print(json.dumps({"continue": True}))
|
|
151
|
+
sys.exit(0)
|
|
152
|
+
|
|
153
|
+
phases = element_data.get("phases", {}) if element_data else state.get("phases", {})
|
|
154
|
+
|
|
155
|
+
# Check if we should trigger a11y audit (after TDD Green)
|
|
156
|
+
if not is_verify_phase(phases):
|
|
157
|
+
print(json.dumps({"continue": True}))
|
|
158
|
+
sys.exit(0)
|
|
159
|
+
|
|
160
|
+
# Get accessibility level
|
|
161
|
+
a11y_level = get_accessibility_level(state, element_data)
|
|
162
|
+
|
|
163
|
+
# Generate audit commands
|
|
164
|
+
commands = generate_a11y_commands(element_name, workflow_type)
|
|
165
|
+
|
|
166
|
+
# Build accessibility context
|
|
167
|
+
checklist = "\n".join([f" - {item}" for item in WCAG_AA_CHECKLIST])
|
|
168
|
+
|
|
169
|
+
context = f"""
|
|
170
|
+
## Accessibility Audit Required (WCAG 2.1 {a11y_level})
|
|
171
|
+
|
|
172
|
+
The TDD Green phase is complete. Before marking verify as complete, run an accessibility audit.
|
|
173
|
+
|
|
174
|
+
### Quick Commands
|
|
175
|
+
```bash
|
|
176
|
+
{commands}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### WCAG 2.1 {a11y_level} Checklist
|
|
180
|
+
{checklist}
|
|
181
|
+
|
|
182
|
+
### 4-Step Verification for UI
|
|
183
|
+
1. **Responsive**: Test at 320px, 768px, 1024px, 1440px
|
|
184
|
+
2. **Data Binding**: Verify all data sources load correctly
|
|
185
|
+
3. **Tests**: All unit/e2e tests pass
|
|
186
|
+
4. **Accessibility**: Run axe-core, fix any violations
|
|
187
|
+
|
|
188
|
+
If violations are found, fix them before completing the verify phase.
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
output = {
|
|
192
|
+
"continue": True,
|
|
193
|
+
"notify": f"Accessibility audit required for {element_name} (WCAG 2.1 {a11y_level})",
|
|
194
|
+
"additionalContext": context
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
print(json.dumps(output))
|
|
198
|
+
sys.exit(0)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
if __name__ == "__main__":
|
|
202
|
+
main()
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Hook: PreToolUse for Write/Edit
|
|
4
|
+
Purpose: Inject brand guide content and validate color compliance during UI implementation
|
|
5
|
+
|
|
6
|
+
This hook runs before writing component/page files. When use_brand_guide=true
|
|
7
|
+
in the state, it logs the brand guide summary to remind Claude to apply
|
|
8
|
+
consistent branding and validates that only approved colors are used.
|
|
9
|
+
|
|
10
|
+
Version: 3.10.0
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
- {"continue": true} - Always continues (notifies on violations)
|
|
14
|
+
- May include "notify" with brand guide summary or color violations
|
|
15
|
+
"""
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
import re
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
# State file is in .claude/ directory (sibling to hooks/)
|
|
22
|
+
STATE_FILE = Path(__file__).parent.parent / "api-dev-state.json"
|
|
23
|
+
BRAND_GUIDE_FILE = Path(__file__).parent.parent / "BRAND_GUIDE.md"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def extract_brand_colors(content):
|
|
27
|
+
"""Extract all brand colors from brand guide markdown.
|
|
28
|
+
|
|
29
|
+
Returns a set of allowed colors (hex values, CSS variables, Tailwind classes).
|
|
30
|
+
"""
|
|
31
|
+
allowed_colors = set()
|
|
32
|
+
|
|
33
|
+
# Extract hex colors from brand guide
|
|
34
|
+
hex_pattern = r'#[0-9A-Fa-f]{3,8}'
|
|
35
|
+
for match in re.finditer(hex_pattern, content):
|
|
36
|
+
allowed_colors.add(match.group(0).upper())
|
|
37
|
+
|
|
38
|
+
# Extract CSS variable names
|
|
39
|
+
css_var_pattern = r'var\(--([a-zA-Z0-9-]+)\)'
|
|
40
|
+
for match in re.finditer(css_var_pattern, content):
|
|
41
|
+
allowed_colors.add(f"--{match.group(1)}")
|
|
42
|
+
|
|
43
|
+
# Extract Tailwind color classes mentioned in brand guide
|
|
44
|
+
tailwind_pattern = r'(?:bg|text|border|ring)-([a-zA-Z]+-[0-9]+|[a-zA-Z]+)'
|
|
45
|
+
for match in re.finditer(tailwind_pattern, content):
|
|
46
|
+
allowed_colors.add(match.group(0))
|
|
47
|
+
|
|
48
|
+
# Always allow these common values
|
|
49
|
+
allowed_colors.update([
|
|
50
|
+
'transparent', 'inherit', 'currentColor', 'current',
|
|
51
|
+
'white', 'black', 'bg-white', 'bg-black', 'text-white', 'text-black',
|
|
52
|
+
'bg-transparent', 'border-transparent',
|
|
53
|
+
# Common utility colors
|
|
54
|
+
'bg-background', 'text-foreground', 'border-border',
|
|
55
|
+
'bg-primary', 'text-primary', 'border-primary',
|
|
56
|
+
'bg-secondary', 'text-secondary', 'border-secondary',
|
|
57
|
+
'bg-accent', 'text-accent', 'border-accent',
|
|
58
|
+
'bg-muted', 'text-muted', 'border-muted',
|
|
59
|
+
'bg-destructive', 'text-destructive', 'border-destructive',
|
|
60
|
+
])
|
|
61
|
+
|
|
62
|
+
return allowed_colors
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def extract_colors_from_code(code_content):
|
|
66
|
+
"""Extract colors used in component code.
|
|
67
|
+
|
|
68
|
+
Returns a list of color usages found.
|
|
69
|
+
"""
|
|
70
|
+
used_colors = []
|
|
71
|
+
|
|
72
|
+
# Find hex colors
|
|
73
|
+
hex_pattern = r'#[0-9A-Fa-f]{3,8}'
|
|
74
|
+
for match in re.finditer(hex_pattern, code_content):
|
|
75
|
+
used_colors.append(('hex', match.group(0).upper()))
|
|
76
|
+
|
|
77
|
+
# Find Tailwind color classes (excluding allowed dynamic patterns)
|
|
78
|
+
tailwind_pattern = r'(?:bg|text|border|ring|from|to|via)-([a-zA-Z]+-[0-9]+)'
|
|
79
|
+
for match in re.finditer(tailwind_pattern, code_content):
|
|
80
|
+
# Skip if it's a dynamic value like bg-[#xxx]
|
|
81
|
+
full_match = match.group(0)
|
|
82
|
+
if '[' not in full_match:
|
|
83
|
+
used_colors.append(('tailwind', full_match))
|
|
84
|
+
|
|
85
|
+
# Find inline style colors
|
|
86
|
+
style_pattern = r'(?:color|backgroundColor|borderColor):\s*["\']([^"\']+)["\']'
|
|
87
|
+
for match in re.finditer(style_pattern, code_content):
|
|
88
|
+
value = match.group(1)
|
|
89
|
+
if value.startswith('#'):
|
|
90
|
+
used_colors.append(('style', value.upper()))
|
|
91
|
+
|
|
92
|
+
return used_colors
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def validate_color_compliance(code_content, allowed_colors):
|
|
96
|
+
"""Check if code uses only brand-approved colors.
|
|
97
|
+
|
|
98
|
+
Returns list of violations found.
|
|
99
|
+
"""
|
|
100
|
+
violations = []
|
|
101
|
+
used_colors = extract_colors_from_code(code_content)
|
|
102
|
+
|
|
103
|
+
for color_type, color_value in used_colors:
|
|
104
|
+
# Check if color is allowed
|
|
105
|
+
is_allowed = False
|
|
106
|
+
|
|
107
|
+
if color_type == 'hex':
|
|
108
|
+
is_allowed = color_value in allowed_colors
|
|
109
|
+
elif color_type == 'tailwind':
|
|
110
|
+
is_allowed = color_value in allowed_colors or color_value.split('-')[0] in ['bg', 'text', 'border']
|
|
111
|
+
elif color_type == 'style':
|
|
112
|
+
is_allowed = color_value in allowed_colors
|
|
113
|
+
|
|
114
|
+
if not is_allowed:
|
|
115
|
+
# Check against all allowed colors more loosely
|
|
116
|
+
if color_value not in allowed_colors:
|
|
117
|
+
violations.append(f"{color_type}: {color_value}")
|
|
118
|
+
|
|
119
|
+
return violations
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def extract_brand_summary(content):
|
|
123
|
+
"""Extract key brand values from brand guide markdown."""
|
|
124
|
+
summary = []
|
|
125
|
+
|
|
126
|
+
lines = content.split("\n")
|
|
127
|
+
current_section = ""
|
|
128
|
+
|
|
129
|
+
for line in lines:
|
|
130
|
+
line = line.strip()
|
|
131
|
+
|
|
132
|
+
# Track section
|
|
133
|
+
if line.startswith("## "):
|
|
134
|
+
current_section = line[3:].lower()
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
# Extract key values
|
|
138
|
+
if line.startswith("- **") and ":" in line:
|
|
139
|
+
# Parse "- **Key:** Value" format
|
|
140
|
+
try:
|
|
141
|
+
key_part = line.split(":**")[0].replace("- **", "")
|
|
142
|
+
value_part = line.split(":**")[1].strip()
|
|
143
|
+
|
|
144
|
+
# Only include primary brand values
|
|
145
|
+
if current_section == "colors" and key_part in ["Primary", "Accent", "Background"]:
|
|
146
|
+
summary.append(f"{key_part}: {value_part}")
|
|
147
|
+
elif current_section == "typography" and key_part in ["Headings", "Body"]:
|
|
148
|
+
summary.append(f"{key_part}: {value_part}")
|
|
149
|
+
elif current_section == "component styling" and key_part in ["Border Radius", "Focus Ring"]:
|
|
150
|
+
summary.append(f"{key_part}: {value_part}")
|
|
151
|
+
except IndexError:
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
return summary
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def main():
|
|
158
|
+
# Read hook input from stdin
|
|
159
|
+
try:
|
|
160
|
+
input_data = json.load(sys.stdin)
|
|
161
|
+
except json.JSONDecodeError:
|
|
162
|
+
print(json.dumps({"continue": True}))
|
|
163
|
+
sys.exit(0)
|
|
164
|
+
|
|
165
|
+
tool_name = input_data.get("tool_name", "")
|
|
166
|
+
tool_input = input_data.get("tool_input", {})
|
|
167
|
+
|
|
168
|
+
# Only check Write/Edit operations
|
|
169
|
+
if tool_name not in ["Write", "Edit"]:
|
|
170
|
+
print(json.dumps({"continue": True}))
|
|
171
|
+
sys.exit(0)
|
|
172
|
+
|
|
173
|
+
# Check if targeting component or page files
|
|
174
|
+
file_path = tool_input.get("file_path", "")
|
|
175
|
+
is_component = "/components/" in file_path and file_path.endswith(".tsx")
|
|
176
|
+
is_page = "/app/" in file_path and "page.tsx" in file_path
|
|
177
|
+
|
|
178
|
+
if not is_component and not is_page:
|
|
179
|
+
print(json.dumps({"continue": True}))
|
|
180
|
+
sys.exit(0)
|
|
181
|
+
|
|
182
|
+
# Check if state file exists
|
|
183
|
+
if not STATE_FILE.exists():
|
|
184
|
+
print(json.dumps({"continue": True}))
|
|
185
|
+
sys.exit(0)
|
|
186
|
+
|
|
187
|
+
# Load state
|
|
188
|
+
try:
|
|
189
|
+
state = json.loads(STATE_FILE.read_text())
|
|
190
|
+
except json.JSONDecodeError:
|
|
191
|
+
print(json.dumps({"continue": True}))
|
|
192
|
+
sys.exit(0)
|
|
193
|
+
|
|
194
|
+
workflow = state.get("workflow", "")
|
|
195
|
+
|
|
196
|
+
# Only apply for UI workflows
|
|
197
|
+
if workflow not in ["ui-create-component", "ui-create-page"]:
|
|
198
|
+
print(json.dumps({"continue": True}))
|
|
199
|
+
sys.exit(0)
|
|
200
|
+
|
|
201
|
+
# Check if brand guide is enabled
|
|
202
|
+
ui_config = state.get("ui_config", {})
|
|
203
|
+
use_brand_guide = ui_config.get("use_brand_guide", False)
|
|
204
|
+
|
|
205
|
+
if not use_brand_guide:
|
|
206
|
+
print(json.dumps({"continue": True}))
|
|
207
|
+
sys.exit(0)
|
|
208
|
+
|
|
209
|
+
# Check if brand guide file exists
|
|
210
|
+
if not BRAND_GUIDE_FILE.exists():
|
|
211
|
+
print(json.dumps({"continue": True}))
|
|
212
|
+
sys.exit(0)
|
|
213
|
+
|
|
214
|
+
# Extract brand summary
|
|
215
|
+
brand_content = BRAND_GUIDE_FILE.read_text()
|
|
216
|
+
summary = extract_brand_summary(brand_content)
|
|
217
|
+
|
|
218
|
+
# For Edit operations, check color compliance
|
|
219
|
+
tool_input = input_data.get("tool_input", {})
|
|
220
|
+
if tool_name == "Edit":
|
|
221
|
+
new_content = tool_input.get("new_string", "")
|
|
222
|
+
if new_content:
|
|
223
|
+
allowed_colors = extract_brand_colors(brand_content)
|
|
224
|
+
violations = validate_color_compliance(new_content, allowed_colors)
|
|
225
|
+
|
|
226
|
+
if violations:
|
|
227
|
+
notify_msg = f"⚠️ Brand color check: {len(violations)} potential non-brand colors: " + ", ".join(violations[:3])
|
|
228
|
+
print(json.dumps({"continue": True, "notify": notify_msg}))
|
|
229
|
+
sys.exit(0)
|
|
230
|
+
|
|
231
|
+
if summary:
|
|
232
|
+
notify_msg = "Applying brand guide: " + " | ".join(summary[:5])
|
|
233
|
+
print(json.dumps({"continue": True, "notify": notify_msg}))
|
|
234
|
+
else:
|
|
235
|
+
print(json.dumps({"continue": True}))
|
|
236
|
+
|
|
237
|
+
sys.exit(0)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
if __name__ == "__main__":
|
|
241
|
+
main()
|