loki-mode 6.77.2 → 6.80.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/SKILL.md +2 -2
- package/VERSION +1 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +34 -0
- package/docs/INSTALLATION.md +1 -1
- package/docs/architecture/STATE-MACHINES.md +10 -10
- package/magic/__init__.py +7 -0
- package/magic/core/__init__.py +0 -0
- package/magic/core/debate.py +781 -0
- package/magic/core/design_tokens.py +469 -0
- package/magic/core/freshness.py +86 -0
- package/magic/core/generator.py +755 -0
- package/magic/core/memory_bridge.py +220 -0
- package/magic/core/prd_scanner.py +265 -0
- package/magic/core/registry.py +340 -0
- package/magic/core/spec.py +337 -0
- package/magic/debate/personas/a11y.md +95 -0
- package/magic/debate/personas/conservative.md +83 -0
- package/magic/debate/personas/creative.md +73 -0
- package/magic/debate/personas/performance.md +93 -0
- package/magic/registry/schema.json +38 -0
- package/magic/testing/__init__.py +0 -0
- package/magic/testing/snapshot.py +224 -0
- package/magic/testing/test_generator.py +453 -0
- package/magic/tokens/README.md +83 -0
- package/magic/tokens/defaults.json +59 -0
- package/mcp/__init__.py +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
"""Unified component generator for React and Web Component variants.
|
|
2
|
+
|
|
3
|
+
Takes a markdown spec plus optional design tokens and produces a component
|
|
4
|
+
in either of two targets:
|
|
5
|
+
|
|
6
|
+
- React + TypeScript (functional component, Tailwind classes)
|
|
7
|
+
- Web Component extending LokiElement (Shadow DOM, scoped styles)
|
|
8
|
+
|
|
9
|
+
Inspired by MagicModules (Roman Nurik) React engine, adapted to Loki
|
|
10
|
+
Mode's provider abstraction so the same spec works with claude, codex,
|
|
11
|
+
gemini, cline, or aider.
|
|
12
|
+
|
|
13
|
+
Rules followed by this module:
|
|
14
|
+
|
|
15
|
+
- Standard library only (subprocess, pathlib, json, hashlib, base64,
|
|
16
|
+
os, shutil, re, typing).
|
|
17
|
+
- No emojis anywhere in prompts or generated scaffolds.
|
|
18
|
+
- Graceful degradation: when the provider CLI is unavailable or
|
|
19
|
+
fails, fall back to a deterministic template scaffold so callers
|
|
20
|
+
always receive usable code.
|
|
21
|
+
- SHA256 hash header is embedded in every artifact so callers can
|
|
22
|
+
detect when spec/token inputs change and regenerate.
|
|
23
|
+
|
|
24
|
+
The provider invocation pattern mirrors _docs_invoke_provider() in
|
|
25
|
+
autonomy/loki (line 18487): honor LOKI_PROVIDER, prefer timeout or
|
|
26
|
+
gtimeout when available, return an empty string on failure so the
|
|
27
|
+
caller may fall back to a template.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import base64
|
|
33
|
+
import hashlib
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import re
|
|
37
|
+
import shutil
|
|
38
|
+
import subprocess
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import Dict, List, Optional
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Providers supported by the Loki Mode runtime. Any value outside this
|
|
44
|
+
# set is treated as claude to stay safe.
|
|
45
|
+
_SUPPORTED_PROVIDERS = {"claude", "codex", "gemini", "cline", "aider"}
|
|
46
|
+
|
|
47
|
+
# Default timeout in seconds for a single provider invocation. Matches
|
|
48
|
+
# the 120s used by the shell _docs_invoke_provider helper.
|
|
49
|
+
_DEFAULT_TIMEOUT_SECONDS = 120
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ComponentGenerator:
|
|
53
|
+
"""Spec-driven generator for React and Web Component artifacts."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, provider: str = "claude", project_dir: str = ".") -> None:
|
|
56
|
+
resolved_provider = (provider or "claude").strip().lower()
|
|
57
|
+
if resolved_provider not in _SUPPORTED_PROVIDERS:
|
|
58
|
+
resolved_provider = "claude"
|
|
59
|
+
self.provider: str = resolved_provider
|
|
60
|
+
self.project_dir: Path = Path(project_dir).resolve()
|
|
61
|
+
self.timeout_seconds: int = _DEFAULT_TIMEOUT_SECONDS
|
|
62
|
+
|
|
63
|
+
# -----------------------------------------------------------------
|
|
64
|
+
# Public API
|
|
65
|
+
# -----------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
def generate_react(
|
|
68
|
+
self,
|
|
69
|
+
spec: str,
|
|
70
|
+
name: str,
|
|
71
|
+
design_tokens: Optional[Dict] = None,
|
|
72
|
+
) -> str:
|
|
73
|
+
"""Generate a React + TypeScript component from a markdown spec.
|
|
74
|
+
|
|
75
|
+
The returned component:
|
|
76
|
+
- Uses TypeScript with a proper Props interface.
|
|
77
|
+
- Applies design tokens via Tailwind CSS classes.
|
|
78
|
+
- Includes accessibility attributes (aria-*, role).
|
|
79
|
+
- Starts with a SHA256 hash header for freshness checking.
|
|
80
|
+
"""
|
|
81
|
+
safe_name = _sanitize_component_name(name)
|
|
82
|
+
tokens = design_tokens or {}
|
|
83
|
+
prompt = self._build_react_prompt(spec, safe_name, tokens)
|
|
84
|
+
body = self._invoke_provider(prompt)
|
|
85
|
+
if not body.strip():
|
|
86
|
+
body = _fallback_react_component(safe_name, spec, tokens)
|
|
87
|
+
cleaned = _strip_markdown_fences(body)
|
|
88
|
+
header = _hash_header("react", safe_name, spec, tokens)
|
|
89
|
+
return f"{header}\n{cleaned.rstrip()}\n"
|
|
90
|
+
|
|
91
|
+
def generate_webcomponent(
|
|
92
|
+
self,
|
|
93
|
+
spec: str,
|
|
94
|
+
name: str,
|
|
95
|
+
design_tokens: Optional[Dict] = None,
|
|
96
|
+
) -> str:
|
|
97
|
+
"""Generate a Web Component (loki-* custom element) from a spec.
|
|
98
|
+
|
|
99
|
+
The component:
|
|
100
|
+
- Extends the LokiElement base class used by dashboard-ui.
|
|
101
|
+
- Uses Shadow DOM with scoped styles.
|
|
102
|
+
- Consumes CSS custom properties sourced from design tokens.
|
|
103
|
+
- Starts with a SHA256 hash header for freshness checking.
|
|
104
|
+
"""
|
|
105
|
+
safe_name = _sanitize_component_name(name)
|
|
106
|
+
tokens = design_tokens or {}
|
|
107
|
+
prompt = self._build_webcomponent_prompt(spec, safe_name, tokens)
|
|
108
|
+
body = self._invoke_provider(prompt)
|
|
109
|
+
if not body.strip():
|
|
110
|
+
body = _fallback_webcomponent(safe_name, spec, tokens)
|
|
111
|
+
cleaned = _strip_markdown_fences(body)
|
|
112
|
+
header = _hash_header("webcomponent", safe_name, spec, tokens)
|
|
113
|
+
return f"{header}\n{cleaned.rstrip()}\n"
|
|
114
|
+
|
|
115
|
+
def generate_test(self, component_code: str, name: str, target: str) -> str:
|
|
116
|
+
"""Generate a test skeleton for the given component.
|
|
117
|
+
|
|
118
|
+
Vitest is used for React targets and Playwright for web-component
|
|
119
|
+
targets. Callers may pass 'react' or 'webcomponent' as target.
|
|
120
|
+
"""
|
|
121
|
+
safe_name = _sanitize_component_name(name)
|
|
122
|
+
resolved_target = (target or "react").strip().lower()
|
|
123
|
+
if resolved_target not in {"react", "webcomponent"}:
|
|
124
|
+
resolved_target = "react"
|
|
125
|
+
prompt = self._build_test_prompt(component_code, safe_name, resolved_target)
|
|
126
|
+
body = self._invoke_provider(prompt)
|
|
127
|
+
if not body.strip():
|
|
128
|
+
body = _fallback_test(safe_name, resolved_target)
|
|
129
|
+
cleaned = _strip_markdown_fences(body)
|
|
130
|
+
header = _hash_header(f"test-{resolved_target}", safe_name, component_code, {})
|
|
131
|
+
return f"{header}\n{cleaned.rstrip()}\n"
|
|
132
|
+
|
|
133
|
+
def generate_from_screenshot(self, image_path: str, name: str) -> str:
|
|
134
|
+
"""Analyze a screenshot and produce a markdown spec.
|
|
135
|
+
|
|
136
|
+
When the Claude CLI supports vision input the screenshot is sent
|
|
137
|
+
as a base64 data URL. Otherwise a deterministic template spec is
|
|
138
|
+
returned so downstream generators still have a starting point.
|
|
139
|
+
"""
|
|
140
|
+
safe_name = _sanitize_component_name(name)
|
|
141
|
+
resolved_path = Path(image_path).expanduser()
|
|
142
|
+
if not resolved_path.is_file():
|
|
143
|
+
return _fallback_spec(safe_name, f"screenshot {image_path} not found")
|
|
144
|
+
|
|
145
|
+
encoded = _encode_image(resolved_path)
|
|
146
|
+
if not encoded:
|
|
147
|
+
return _fallback_spec(safe_name, "screenshot could not be encoded")
|
|
148
|
+
|
|
149
|
+
prompt = self._build_vision_prompt(safe_name, resolved_path, encoded)
|
|
150
|
+
body = self._invoke_provider(prompt)
|
|
151
|
+
if not body.strip():
|
|
152
|
+
return _fallback_spec(safe_name, f"analyzed screenshot at {resolved_path}")
|
|
153
|
+
return _strip_markdown_fences(body).rstrip() + "\n"
|
|
154
|
+
|
|
155
|
+
# -----------------------------------------------------------------
|
|
156
|
+
# Provider invocation
|
|
157
|
+
# -----------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
def _invoke_provider(self, prompt: str) -> str:
|
|
160
|
+
"""Call the selected provider CLI with the given prompt.
|
|
161
|
+
|
|
162
|
+
Mirrors the behavior of autonomy/loki _docs_invoke_provider():
|
|
163
|
+
honors LOKI_PROVIDER, uses timeout or gtimeout when available,
|
|
164
|
+
and returns the empty string on any error so the caller can
|
|
165
|
+
fall back to a template.
|
|
166
|
+
"""
|
|
167
|
+
provider = os.environ.get("LOKI_PROVIDER", self.provider).strip().lower()
|
|
168
|
+
if provider not in _SUPPORTED_PROVIDERS:
|
|
169
|
+
provider = self.provider
|
|
170
|
+
|
|
171
|
+
binary = shutil.which(provider)
|
|
172
|
+
if not binary:
|
|
173
|
+
return ""
|
|
174
|
+
|
|
175
|
+
timeout_bin = shutil.which("timeout") or shutil.which("gtimeout") or ""
|
|
176
|
+
base_cmd: List[str] = []
|
|
177
|
+
if timeout_bin:
|
|
178
|
+
base_cmd.extend([timeout_bin, str(self.timeout_seconds)])
|
|
179
|
+
|
|
180
|
+
if provider == "claude":
|
|
181
|
+
cmd = base_cmd + [binary, "-p", prompt]
|
|
182
|
+
elif provider == "codex":
|
|
183
|
+
cmd = base_cmd + [binary, "exec", "--full-auto", prompt]
|
|
184
|
+
elif provider == "gemini":
|
|
185
|
+
cmd = base_cmd + [binary, "--approval-mode=yolo", prompt]
|
|
186
|
+
elif provider == "cline":
|
|
187
|
+
cmd = base_cmd + [binary, "-y", prompt]
|
|
188
|
+
elif provider == "aider":
|
|
189
|
+
cmd = base_cmd + [
|
|
190
|
+
binary,
|
|
191
|
+
"--message",
|
|
192
|
+
prompt,
|
|
193
|
+
"--yes-always",
|
|
194
|
+
"--no-auto-commits",
|
|
195
|
+
]
|
|
196
|
+
else:
|
|
197
|
+
return ""
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
completed = subprocess.run(
|
|
201
|
+
cmd,
|
|
202
|
+
cwd=str(self.project_dir),
|
|
203
|
+
input="" if provider == "aider" else None,
|
|
204
|
+
capture_output=True,
|
|
205
|
+
text=True,
|
|
206
|
+
timeout=self.timeout_seconds + 5,
|
|
207
|
+
check=False,
|
|
208
|
+
)
|
|
209
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
210
|
+
return ""
|
|
211
|
+
|
|
212
|
+
if completed.returncode != 0 and not completed.stdout:
|
|
213
|
+
return ""
|
|
214
|
+
|
|
215
|
+
return completed.stdout or ""
|
|
216
|
+
|
|
217
|
+
# -----------------------------------------------------------------
|
|
218
|
+
# Prompt builders
|
|
219
|
+
# -----------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
def _build_react_prompt(self, spec: str, name: str, tokens: Dict) -> str:
|
|
222
|
+
tokens_summary = _summarize_tokens(tokens)
|
|
223
|
+
example = (
|
|
224
|
+
"import * as React from 'react';\n"
|
|
225
|
+
f"export interface {name}Props {{ label: string; onAction?: () => void; }}\n"
|
|
226
|
+
f"export function {name}(props: {name}Props) {{\n"
|
|
227
|
+
" return (\n"
|
|
228
|
+
" <button\n"
|
|
229
|
+
" type=\"button\"\n"
|
|
230
|
+
" aria-label={props.label}\n"
|
|
231
|
+
" className=\"px-4 py-2 rounded-md bg-[--loki-accent] text-white\"\n"
|
|
232
|
+
" onClick={props.onAction}\n"
|
|
233
|
+
" >\n"
|
|
234
|
+
" {props.label}\n"
|
|
235
|
+
" </button>\n"
|
|
236
|
+
" );\n"
|
|
237
|
+
"}\n"
|
|
238
|
+
)
|
|
239
|
+
return (
|
|
240
|
+
"You are an expert React + TypeScript component author.\n"
|
|
241
|
+
"\n"
|
|
242
|
+
"Hard rules (must all hold):\n"
|
|
243
|
+
" - Output pure TypeScript/TSX source code for a single React\n"
|
|
244
|
+
" functional component.\n"
|
|
245
|
+
" - NO emojis anywhere (code, comments, strings, JSX text).\n"
|
|
246
|
+
" - Declare an exported Props interface named <Name>Props with\n"
|
|
247
|
+
" precise, non-any types.\n"
|
|
248
|
+
" - Style with Tailwind CSS utility classes only. Reference\n"
|
|
249
|
+
" design tokens via Tailwind arbitrary values such as\n"
|
|
250
|
+
" `bg-[--loki-accent]` or `text-[--loki-text-primary]`.\n"
|
|
251
|
+
" - Include accessibility attributes (aria-*, role) that fit\n"
|
|
252
|
+
" the component's semantics.\n"
|
|
253
|
+
" - Use only React imports (no external UI libraries).\n"
|
|
254
|
+
" - Output just the source code. No markdown fences, no prose,\n"
|
|
255
|
+
" no explanation before or after.\n"
|
|
256
|
+
"\n"
|
|
257
|
+
f"Component name: {name}\n"
|
|
258
|
+
"\n"
|
|
259
|
+
"Design tokens (CSS custom properties available):\n"
|
|
260
|
+
f"{tokens_summary}\n"
|
|
261
|
+
"\n"
|
|
262
|
+
"Specification (markdown):\n"
|
|
263
|
+
"---\n"
|
|
264
|
+
f"{spec.strip()}\n"
|
|
265
|
+
"---\n"
|
|
266
|
+
"\n"
|
|
267
|
+
"Example of the expected output shape (structure only, do not\n"
|
|
268
|
+
"copy verbatim):\n"
|
|
269
|
+
"---\n"
|
|
270
|
+
f"{example}"
|
|
271
|
+
"---\n"
|
|
272
|
+
"\n"
|
|
273
|
+
f"Now emit the {name} component source."
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def _build_webcomponent_prompt(self, spec: str, name: str, tokens: Dict) -> str:
|
|
277
|
+
tokens_summary = _summarize_tokens(tokens)
|
|
278
|
+
tag = _kebab_case(name)
|
|
279
|
+
if not tag.startswith("loki-"):
|
|
280
|
+
tag = f"loki-{tag}"
|
|
281
|
+
example = (
|
|
282
|
+
"import { LokiElement } from '../core/loki-element.js';\n"
|
|
283
|
+
f"export class {name} extends LokiElement {{\n"
|
|
284
|
+
" static get observedAttributes() { return ['label']; }\n"
|
|
285
|
+
" connectedCallback() { this.render(); }\n"
|
|
286
|
+
" attributeChangedCallback() { this.render(); }\n"
|
|
287
|
+
" render() {\n"
|
|
288
|
+
" const label = this.getAttribute('label') || '';\n"
|
|
289
|
+
" this.shadowRoot.innerHTML = `\n"
|
|
290
|
+
" <style>\n"
|
|
291
|
+
" :host { display: inline-block; }\n"
|
|
292
|
+
" .btn { background: var(--loki-accent); color: #fff;\n"
|
|
293
|
+
" padding: 0.5rem 1rem; border-radius: 0.375rem; }\n"
|
|
294
|
+
" </style>\n"
|
|
295
|
+
" <button class=\"btn\" aria-label=\"${label}\">${label}</button>\n"
|
|
296
|
+
" `;\n"
|
|
297
|
+
" }\n"
|
|
298
|
+
"}\n"
|
|
299
|
+
f"customElements.define('{tag}', {name});\n"
|
|
300
|
+
)
|
|
301
|
+
return (
|
|
302
|
+
"You are an expert Web Components author working in the Loki\n"
|
|
303
|
+
"Mode dashboard-ui codebase.\n"
|
|
304
|
+
"\n"
|
|
305
|
+
"Hard rules (must all hold):\n"
|
|
306
|
+
" - Output pure JavaScript source for a single custom element\n"
|
|
307
|
+
" class that extends LokiElement.\n"
|
|
308
|
+
" - NO emojis anywhere (code, comments, strings, template\n"
|
|
309
|
+
" literals).\n"
|
|
310
|
+
" - Import LokiElement from '../core/loki-element.js'.\n"
|
|
311
|
+
" - Attach a Shadow DOM with scoped <style> and render via\n"
|
|
312
|
+
" this.shadowRoot.innerHTML.\n"
|
|
313
|
+
" - Style exclusively with CSS custom properties declared in\n"
|
|
314
|
+
" the design tokens. Example: var(--loki-accent).\n"
|
|
315
|
+
" - Include accessibility attributes (aria-*, role) on\n"
|
|
316
|
+
" interactive elements.\n"
|
|
317
|
+
f" - Register the element as '{tag}' via customElements.define.\n"
|
|
318
|
+
" - Output just the source code. No markdown fences, no prose,\n"
|
|
319
|
+
" no explanation before or after.\n"
|
|
320
|
+
"\n"
|
|
321
|
+
f"Class name: {name}\n"
|
|
322
|
+
f"Custom element tag: {tag}\n"
|
|
323
|
+
"\n"
|
|
324
|
+
"Design tokens (CSS custom properties available):\n"
|
|
325
|
+
f"{tokens_summary}\n"
|
|
326
|
+
"\n"
|
|
327
|
+
"Specification (markdown):\n"
|
|
328
|
+
"---\n"
|
|
329
|
+
f"{spec.strip()}\n"
|
|
330
|
+
"---\n"
|
|
331
|
+
"\n"
|
|
332
|
+
"Example of the expected output shape (structure only, do not\n"
|
|
333
|
+
"copy verbatim):\n"
|
|
334
|
+
"---\n"
|
|
335
|
+
f"{example}"
|
|
336
|
+
"---\n"
|
|
337
|
+
"\n"
|
|
338
|
+
f"Now emit the {name} custom element source."
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
def _build_test_prompt(self, component_code: str, name: str, target: str) -> str:
|
|
342
|
+
if target == "webcomponent":
|
|
343
|
+
runner = "Playwright"
|
|
344
|
+
example = (
|
|
345
|
+
"import { test, expect } from '@playwright/test';\n"
|
|
346
|
+
f"test('{name} renders', async ({{ page }}) => {{\n"
|
|
347
|
+
" await page.goto('/fixtures/component.html');\n"
|
|
348
|
+
f" const el = page.locator('{_kebab_case(name)}');\n"
|
|
349
|
+
" await expect(el).toBeVisible();\n"
|
|
350
|
+
"});\n"
|
|
351
|
+
)
|
|
352
|
+
else:
|
|
353
|
+
runner = "Vitest with @testing-library/react"
|
|
354
|
+
example = (
|
|
355
|
+
"import { describe, it, expect } from 'vitest';\n"
|
|
356
|
+
"import { render, screen } from '@testing-library/react';\n"
|
|
357
|
+
f"import {{ {name} }} from './{name}';\n"
|
|
358
|
+
f"describe('{name}', () => {{\n"
|
|
359
|
+
" it('renders label', () => {\n"
|
|
360
|
+
f" render(<{name} label=\"Go\" />);\n"
|
|
361
|
+
" expect(screen.getByText('Go')).toBeInTheDocument();\n"
|
|
362
|
+
" });\n"
|
|
363
|
+
"});\n"
|
|
364
|
+
)
|
|
365
|
+
return (
|
|
366
|
+
f"You are an expert test author. Produce a {runner} test\n"
|
|
367
|
+
f"skeleton for the component named {name}.\n"
|
|
368
|
+
"\n"
|
|
369
|
+
"Hard rules:\n"
|
|
370
|
+
" - Output just the test source. No markdown fences, no\n"
|
|
371
|
+
" explanation, no emojis.\n"
|
|
372
|
+
" - Cover a rendering smoke test and one behavior assertion.\n"
|
|
373
|
+
" - Use plain, non-flaky selectors (role, label, tag name).\n"
|
|
374
|
+
"\n"
|
|
375
|
+
"Component source under test:\n"
|
|
376
|
+
"---\n"
|
|
377
|
+
f"{component_code.strip()}\n"
|
|
378
|
+
"---\n"
|
|
379
|
+
"\n"
|
|
380
|
+
"Expected output shape (structure only):\n"
|
|
381
|
+
"---\n"
|
|
382
|
+
f"{example}"
|
|
383
|
+
"---\n"
|
|
384
|
+
"\n"
|
|
385
|
+
f"Now emit the test file for {name}."
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
def _build_vision_prompt(self, name: str, image_path: Path, encoded: str) -> str:
|
|
389
|
+
mime = _guess_mime(image_path)
|
|
390
|
+
return (
|
|
391
|
+
"You are an expert UI analyst. Inspect the attached screenshot\n"
|
|
392
|
+
"and emit a concise markdown specification describing the\n"
|
|
393
|
+
"component so it can be regenerated in code.\n"
|
|
394
|
+
"\n"
|
|
395
|
+
"Hard rules:\n"
|
|
396
|
+
" - Output markdown only. No emojis.\n"
|
|
397
|
+
" - Include sections: Structure, Layout, Colors, Typography,\n"
|
|
398
|
+
" States, Accessibility.\n"
|
|
399
|
+
" - Use color hex codes where visible.\n"
|
|
400
|
+
" - Keep the spec under 80 lines.\n"
|
|
401
|
+
"\n"
|
|
402
|
+
f"Component name: {name}\n"
|
|
403
|
+
f"Screenshot path: {image_path}\n"
|
|
404
|
+
f"Screenshot (base64 {mime}):\n"
|
|
405
|
+
f"{encoded}\n"
|
|
406
|
+
"\n"
|
|
407
|
+
f"Now emit the markdown spec for {name}."
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# ---------------------------------------------------------------------
|
|
412
|
+
# Module-level helpers
|
|
413
|
+
# ---------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _sanitize_component_name(name: str) -> str:
|
|
417
|
+
"""Coerce an arbitrary string to a PascalCase identifier."""
|
|
418
|
+
if not name:
|
|
419
|
+
return "Component"
|
|
420
|
+
parts = re.split(r"[^0-9A-Za-z]+", name)
|
|
421
|
+
pascal = "".join(part[:1].upper() + part[1:] for part in parts if part)
|
|
422
|
+
if not pascal:
|
|
423
|
+
return "Component"
|
|
424
|
+
if pascal[0].isdigit():
|
|
425
|
+
pascal = f"C{pascal}"
|
|
426
|
+
return pascal
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _kebab_case(name: str) -> str:
|
|
430
|
+
"""Convert PascalCase or camelCase to kebab-case."""
|
|
431
|
+
step1 = re.sub(r"(.)([A-Z][a-z]+)", r"\1-\2", name)
|
|
432
|
+
step2 = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", step1)
|
|
433
|
+
return step2.lower().strip("-")
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _summarize_tokens(tokens: Dict) -> str:
|
|
437
|
+
"""Render the design token dict as a compact deterministic summary."""
|
|
438
|
+
if not tokens:
|
|
439
|
+
return " (none provided; use sensible defaults)"
|
|
440
|
+
try:
|
|
441
|
+
flat: List[str] = []
|
|
442
|
+
for key, value in sorted(tokens.items()):
|
|
443
|
+
if isinstance(value, dict):
|
|
444
|
+
nested = ", ".join(
|
|
445
|
+
f"{inner_key}={inner_val}"
|
|
446
|
+
for inner_key, inner_val in sorted(value.items())
|
|
447
|
+
)
|
|
448
|
+
flat.append(f" {key}: {nested}")
|
|
449
|
+
else:
|
|
450
|
+
flat.append(f" {key}: {value}")
|
|
451
|
+
return "\n".join(flat) if flat else " (empty)"
|
|
452
|
+
except Exception:
|
|
453
|
+
return " (tokens unavailable)"
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _hash_header(kind: str, name: str, spec: str, tokens: Dict) -> str:
|
|
457
|
+
"""Build a SHA256 freshness header for a generated artifact."""
|
|
458
|
+
payload = json.dumps(
|
|
459
|
+
{"kind": kind, "name": name, "spec": spec, "tokens": tokens},
|
|
460
|
+
sort_keys=True,
|
|
461
|
+
default=str,
|
|
462
|
+
).encode("utf-8")
|
|
463
|
+
digest = hashlib.sha256(payload).hexdigest()
|
|
464
|
+
return (
|
|
465
|
+
f"// loki-magic: kind={kind} name={name} sha256={digest}\n"
|
|
466
|
+
"// Generated by magic/core/generator.py. Regenerate when the\n"
|
|
467
|
+
"// spec or design tokens change; the hash above detects drift."
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _strip_markdown_fences(text: str) -> str:
|
|
472
|
+
"""Remove surrounding triple-backtick fences if a provider added them."""
|
|
473
|
+
stripped = text.strip()
|
|
474
|
+
if stripped.startswith("```"):
|
|
475
|
+
first_newline = stripped.find("\n")
|
|
476
|
+
if first_newline != -1:
|
|
477
|
+
stripped = stripped[first_newline + 1 :]
|
|
478
|
+
if stripped.rstrip().endswith("```"):
|
|
479
|
+
stripped = stripped.rstrip()[: -3].rstrip()
|
|
480
|
+
return stripped
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _encode_image(path: Path) -> str:
|
|
484
|
+
try:
|
|
485
|
+
data = path.read_bytes()
|
|
486
|
+
except OSError:
|
|
487
|
+
return ""
|
|
488
|
+
return base64.b64encode(data).decode("ascii")
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _guess_mime(path: Path) -> str:
|
|
492
|
+
suffix = path.suffix.lower()
|
|
493
|
+
if suffix in {".jpg", ".jpeg"}:
|
|
494
|
+
return "image/jpeg"
|
|
495
|
+
if suffix == ".webp":
|
|
496
|
+
return "image/webp"
|
|
497
|
+
if suffix == ".gif":
|
|
498
|
+
return "image/gif"
|
|
499
|
+
return "image/png"
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
# ---------------------------------------------------------------------
|
|
503
|
+
# Deterministic fallbacks (used when no provider is available)
|
|
504
|
+
# ---------------------------------------------------------------------
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _fallback_react_component(name: str, spec: str, tokens: Dict) -> str:
|
|
508
|
+
description = _first_line(spec) or f"{name} component"
|
|
509
|
+
return (
|
|
510
|
+
"import * as React from 'react';\n"
|
|
511
|
+
"\n"
|
|
512
|
+
f"export interface {name}Props {{\n"
|
|
513
|
+
" label: string;\n"
|
|
514
|
+
" onAction?: () => void;\n"
|
|
515
|
+
" className?: string;\n"
|
|
516
|
+
"}\n"
|
|
517
|
+
"\n"
|
|
518
|
+
f"// {description}\n"
|
|
519
|
+
f"export function {name}(props: {name}Props): JSX.Element {{\n"
|
|
520
|
+
" const { label, onAction, className } = props;\n"
|
|
521
|
+
" return (\n"
|
|
522
|
+
" <button\n"
|
|
523
|
+
" type=\"button\"\n"
|
|
524
|
+
" role=\"button\"\n"
|
|
525
|
+
" aria-label={label}\n"
|
|
526
|
+
" onClick={onAction}\n"
|
|
527
|
+
" className={[\n"
|
|
528
|
+
" 'inline-flex items-center justify-center',\n"
|
|
529
|
+
" 'px-4 py-2 rounded-md text-sm font-medium',\n"
|
|
530
|
+
" 'bg-[--loki-accent] text-white',\n"
|
|
531
|
+
" 'hover:bg-[--loki-accent-light]',\n"
|
|
532
|
+
" className || ''\n"
|
|
533
|
+
" ].join(' ')}\n"
|
|
534
|
+
" >\n"
|
|
535
|
+
" {label}\n"
|
|
536
|
+
" </button>\n"
|
|
537
|
+
" );\n"
|
|
538
|
+
"}\n"
|
|
539
|
+
"\n"
|
|
540
|
+
f"export default {name};\n"
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _fallback_webcomponent(name: str, spec: str, tokens: Dict) -> str:
|
|
545
|
+
description = _first_line(spec) or f"{name} custom element"
|
|
546
|
+
tag = _kebab_case(name)
|
|
547
|
+
if not tag.startswith("loki-"):
|
|
548
|
+
tag = f"loki-{tag}"
|
|
549
|
+
return (
|
|
550
|
+
"import { LokiElement } from '../core/loki-element.js';\n"
|
|
551
|
+
"\n"
|
|
552
|
+
f"// {description}\n"
|
|
553
|
+
f"export class {name} extends LokiElement {{\n"
|
|
554
|
+
" static get observedAttributes() {\n"
|
|
555
|
+
" return ['label'];\n"
|
|
556
|
+
" }\n"
|
|
557
|
+
"\n"
|
|
558
|
+
" connectedCallback() {\n"
|
|
559
|
+
" this.render();\n"
|
|
560
|
+
" }\n"
|
|
561
|
+
"\n"
|
|
562
|
+
" attributeChangedCallback() {\n"
|
|
563
|
+
" this.render();\n"
|
|
564
|
+
" }\n"
|
|
565
|
+
"\n"
|
|
566
|
+
" render() {\n"
|
|
567
|
+
" const label = this.getAttribute('label') || '';\n"
|
|
568
|
+
" this.shadowRoot.innerHTML = `\n"
|
|
569
|
+
" <style>\n"
|
|
570
|
+
" :host { display: inline-block; }\n"
|
|
571
|
+
" .btn {\n"
|
|
572
|
+
" display: inline-flex;\n"
|
|
573
|
+
" align-items: center;\n"
|
|
574
|
+
" justify-content: center;\n"
|
|
575
|
+
" padding: 0.5rem 1rem;\n"
|
|
576
|
+
" border-radius: 0.375rem;\n"
|
|
577
|
+
" font-size: 0.875rem;\n"
|
|
578
|
+
" font-weight: 500;\n"
|
|
579
|
+
" color: #ffffff;\n"
|
|
580
|
+
" background: var(--loki-accent, #553DE9);\n"
|
|
581
|
+
" border: none;\n"
|
|
582
|
+
" cursor: pointer;\n"
|
|
583
|
+
" }\n"
|
|
584
|
+
" .btn:hover { background: var(--loki-accent-light, #7B6BF0); }\n"
|
|
585
|
+
" </style>\n"
|
|
586
|
+
" <button class=\"btn\" type=\"button\" role=\"button\" aria-label=\"${label}\">\n"
|
|
587
|
+
" ${label}\n"
|
|
588
|
+
" </button>\n"
|
|
589
|
+
" `;\n"
|
|
590
|
+
" }\n"
|
|
591
|
+
"}\n"
|
|
592
|
+
"\n"
|
|
593
|
+
f"if (!customElements.get('{tag}')) {{\n"
|
|
594
|
+
f" customElements.define('{tag}', {name});\n"
|
|
595
|
+
"}\n"
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _fallback_test(name: str, target: str) -> str:
|
|
600
|
+
if target == "webcomponent":
|
|
601
|
+
tag = _kebab_case(name)
|
|
602
|
+
if not tag.startswith("loki-"):
|
|
603
|
+
tag = f"loki-{tag}"
|
|
604
|
+
return (
|
|
605
|
+
"import { test, expect } from '@playwright/test';\n"
|
|
606
|
+
"\n"
|
|
607
|
+
f"test('{name} renders and is accessible', async ({{ page }}) => {{\n"
|
|
608
|
+
" await page.goto('/fixtures/component.html');\n"
|
|
609
|
+
f" const el = page.locator('{tag}');\n"
|
|
610
|
+
" await expect(el).toBeVisible();\n"
|
|
611
|
+
" const label = await el.getAttribute('label');\n"
|
|
612
|
+
" if (label) {\n"
|
|
613
|
+
" await expect(el).toHaveAttribute('label', label);\n"
|
|
614
|
+
" }\n"
|
|
615
|
+
"});\n"
|
|
616
|
+
)
|
|
617
|
+
return (
|
|
618
|
+
"import { describe, it, expect, vi } from 'vitest';\n"
|
|
619
|
+
"import { render, screen, fireEvent } from '@testing-library/react';\n"
|
|
620
|
+
f"import {{ {name} }} from './{name}';\n"
|
|
621
|
+
"\n"
|
|
622
|
+
f"describe('{name}', () => {{\n"
|
|
623
|
+
" it('renders the provided label', () => {\n"
|
|
624
|
+
f" render(<{name} label=\"Go\" />);\n"
|
|
625
|
+
" expect(screen.getByText('Go')).toBeInTheDocument();\n"
|
|
626
|
+
" });\n"
|
|
627
|
+
"\n"
|
|
628
|
+
" it('invokes onAction when clicked', () => {\n"
|
|
629
|
+
" const handler = vi.fn();\n"
|
|
630
|
+
f" render(<{name} label=\"Go\" onAction={{handler}} />);\n"
|
|
631
|
+
" fireEvent.click(screen.getByText('Go'));\n"
|
|
632
|
+
" expect(handler).toHaveBeenCalledTimes(1);\n"
|
|
633
|
+
" });\n"
|
|
634
|
+
"});\n"
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _fallback_spec(name: str, note: str) -> str:
|
|
639
|
+
return (
|
|
640
|
+
f"# {name} Specification\n"
|
|
641
|
+
"\n"
|
|
642
|
+
"## Structure\n"
|
|
643
|
+
f"- Root element for {name}.\n"
|
|
644
|
+
"- Primary interactive control with label text.\n"
|
|
645
|
+
"\n"
|
|
646
|
+
"## Layout\n"
|
|
647
|
+
"- Inline-block container, centered content, comfortable padding.\n"
|
|
648
|
+
"\n"
|
|
649
|
+
"## Colors\n"
|
|
650
|
+
"- Background: var(--loki-accent).\n"
|
|
651
|
+
"- Foreground: #FFFFFF.\n"
|
|
652
|
+
"\n"
|
|
653
|
+
"## Typography\n"
|
|
654
|
+
"- System sans-serif, 14px, medium weight.\n"
|
|
655
|
+
"\n"
|
|
656
|
+
"## States\n"
|
|
657
|
+
"- Default, hover (accent-light), focus (visible outline), disabled.\n"
|
|
658
|
+
"\n"
|
|
659
|
+
"## Accessibility\n"
|
|
660
|
+
"- role=button, aria-label mirrors the visible label.\n"
|
|
661
|
+
"\n"
|
|
662
|
+
f"Note: {note}\n"
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _first_line(text: str) -> str:
|
|
667
|
+
if not text:
|
|
668
|
+
return ""
|
|
669
|
+
for line in text.splitlines():
|
|
670
|
+
trimmed = line.strip().lstrip("#").strip()
|
|
671
|
+
if trimmed:
|
|
672
|
+
return trimmed
|
|
673
|
+
return ""
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
# ---------------------------------------------------------------------------
|
|
677
|
+
# Module-level convenience API (called by autonomy/loki cmd_magic)
|
|
678
|
+
# ---------------------------------------------------------------------------
|
|
679
|
+
|
|
680
|
+
def generate_component(
|
|
681
|
+
name: str,
|
|
682
|
+
spec_path: str,
|
|
683
|
+
target: str = "react",
|
|
684
|
+
react_out: str = "",
|
|
685
|
+
wc_out: str = "",
|
|
686
|
+
test_out: str = "",
|
|
687
|
+
placement=None,
|
|
688
|
+
project_dir: str = ".",
|
|
689
|
+
) -> dict:
|
|
690
|
+
"""Generate component variants from a spec file and write them to disk.
|
|
691
|
+
|
|
692
|
+
Returns a dict with the paths actually written and their SHA256 hashes.
|
|
693
|
+
"""
|
|
694
|
+
from pathlib import Path as _P
|
|
695
|
+
spec_text = _P(spec_path).read_text() if _P(spec_path).exists() else ""
|
|
696
|
+
gen = ComponentGenerator(project_dir=project_dir)
|
|
697
|
+
tokens = None
|
|
698
|
+
try:
|
|
699
|
+
from magic.core.design_tokens import DesignTokens
|
|
700
|
+
tokens = DesignTokens(project_dir).tokens
|
|
701
|
+
except Exception:
|
|
702
|
+
tokens = None
|
|
703
|
+
|
|
704
|
+
written = {}
|
|
705
|
+
targets = {"react", "webcomponent"} if target == "both" else {target}
|
|
706
|
+
|
|
707
|
+
if "react" in targets and react_out:
|
|
708
|
+
code = gen.generate_react(spec_text, name, tokens)
|
|
709
|
+
_P(react_out).parent.mkdir(parents=True, exist_ok=True)
|
|
710
|
+
_P(react_out).write_text(code)
|
|
711
|
+
written["react"] = react_out
|
|
712
|
+
if "webcomponent" in targets and wc_out:
|
|
713
|
+
code = gen.generate_webcomponent(spec_text, name, tokens)
|
|
714
|
+
_P(wc_out).parent.mkdir(parents=True, exist_ok=True)
|
|
715
|
+
_P(wc_out).write_text(code)
|
|
716
|
+
written["webcomponent"] = wc_out
|
|
717
|
+
if test_out and "react" in written:
|
|
718
|
+
try:
|
|
719
|
+
test_code = gen.generate_test(open(written["react"]).read(), name, "react")
|
|
720
|
+
_P(test_out).parent.mkdir(parents=True, exist_ok=True)
|
|
721
|
+
_P(test_out).write_text(test_code)
|
|
722
|
+
written["test"] = test_out
|
|
723
|
+
except Exception:
|
|
724
|
+
pass
|
|
725
|
+
return written
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def update_components(name: str = "", force: bool = False, project_dir: str = ".", **extra) -> list:
|
|
729
|
+
"""Re-run generate_component for any spec whose generated output is stale.
|
|
730
|
+
|
|
731
|
+
Extra kwargs (registry_path, etc.) are accepted for CLI compatibility.
|
|
732
|
+
"""
|
|
733
|
+
from pathlib import Path as _P
|
|
734
|
+
from magic.core.freshness import needs_regen
|
|
735
|
+
specs_dir = _P(project_dir) / ".loki" / "magic" / "specs"
|
|
736
|
+
gen_dir = _P(project_dir) / ".loki" / "magic" / "generated"
|
|
737
|
+
updated = []
|
|
738
|
+
for spec_path in specs_dir.glob("*.md"):
|
|
739
|
+
cname = spec_path.stem
|
|
740
|
+
if name and cname != name:
|
|
741
|
+
continue
|
|
742
|
+
react_out = gen_dir / "react" / f"{cname}.tsx"
|
|
743
|
+
wc_out = gen_dir / "webcomponent" / f"{cname}.js"
|
|
744
|
+
if force or needs_regen(spec_path, react_out) or needs_regen(spec_path, wc_out):
|
|
745
|
+
generate_component(
|
|
746
|
+
name=cname,
|
|
747
|
+
spec_path=str(spec_path),
|
|
748
|
+
target="both",
|
|
749
|
+
react_out=str(react_out),
|
|
750
|
+
wc_out=str(wc_out),
|
|
751
|
+
test_out=str(gen_dir / "tests" / f"{cname}.test.tsx"),
|
|
752
|
+
project_dir=project_dir,
|
|
753
|
+
)
|
|
754
|
+
updated.append(cname)
|
|
755
|
+
return updated
|