loki-mode 6.79.0 → 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/docs/INSTALLATION.md +1 -1
- 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,453 @@
|
|
|
1
|
+
"""Test scaffold generator for Magic components.
|
|
2
|
+
|
|
3
|
+
Generates Vitest tests for React components and Playwright tests for
|
|
4
|
+
Web Components. Uses heuristics from the spec (detected props, behaviors,
|
|
5
|
+
accessibility requirements) to scaffold meaningful assertions, not just
|
|
6
|
+
smoke tests.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestGenerator:
|
|
15
|
+
"""Generate test scaffolds for Magic-generated components.
|
|
16
|
+
|
|
17
|
+
The generator consumes a component's source code together with its
|
|
18
|
+
spec (typically a markdown doc) and produces unit tests (Vitest for
|
|
19
|
+
React) or end-to-end tests (Playwright for Web Components) with
|
|
20
|
+
assertions that go beyond smoke tests. A Storybook-compatible
|
|
21
|
+
snapshot story can also be produced for Chromatic integration.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, project_dir: str = "."):
|
|
25
|
+
self.project_dir = Path(project_dir)
|
|
26
|
+
|
|
27
|
+
# ----- Public API --------------------------------------------------
|
|
28
|
+
|
|
29
|
+
def generate_react_test(
|
|
30
|
+
self, component_code: str, component_name: str, spec: str
|
|
31
|
+
) -> str:
|
|
32
|
+
"""Generate a Vitest + React Testing Library test file.
|
|
33
|
+
|
|
34
|
+
Included assertions:
|
|
35
|
+
- Component renders without crashing
|
|
36
|
+
- Each prop is exercised
|
|
37
|
+
- Accessibility: has expected ARIA attributes
|
|
38
|
+
- Keyboard interaction (if spec mentions it)
|
|
39
|
+
- Snapshot test
|
|
40
|
+
"""
|
|
41
|
+
safe_name = self._safe_name(component_name)
|
|
42
|
+
props = self._extract_props(spec)
|
|
43
|
+
a11y_reqs = self._extract_a11y(spec)
|
|
44
|
+
keyboard = self._has_keyboard_interaction(spec)
|
|
45
|
+
|
|
46
|
+
default_props = self._default_props(props)
|
|
47
|
+
|
|
48
|
+
lines = []
|
|
49
|
+
lines.append('import { describe, it, expect } from "vitest";')
|
|
50
|
+
lines.append('import { render, screen } from "@testing-library/react";')
|
|
51
|
+
if keyboard:
|
|
52
|
+
lines.append('import { fireEvent } from "@testing-library/react";')
|
|
53
|
+
lines.append(f'import {{ {safe_name} }} from "./{safe_name}";')
|
|
54
|
+
lines.append("")
|
|
55
|
+
lines.append(f'describe("{safe_name}", () => {{')
|
|
56
|
+
lines.append(' it("renders without crashing", () => {')
|
|
57
|
+
lines.append(
|
|
58
|
+
f" render(<{safe_name}{(' ' + default_props) if default_props else ''} />);"
|
|
59
|
+
)
|
|
60
|
+
lines.append(
|
|
61
|
+
f' expect(screen.getByTestId("{safe_name.lower()}")).toBeDefined();'
|
|
62
|
+
)
|
|
63
|
+
lines.append(" });")
|
|
64
|
+
lines.append("")
|
|
65
|
+
|
|
66
|
+
for prop in props:
|
|
67
|
+
lines.append(self._prop_test(safe_name, prop))
|
|
68
|
+
|
|
69
|
+
for req in a11y_reqs:
|
|
70
|
+
lines.append(self._a11y_test(safe_name, req))
|
|
71
|
+
|
|
72
|
+
if keyboard:
|
|
73
|
+
lines.append(self._keyboard_test(safe_name, default_props))
|
|
74
|
+
|
|
75
|
+
lines.append(self._snapshot_test(safe_name, default_props))
|
|
76
|
+
lines.append("});")
|
|
77
|
+
lines.append("")
|
|
78
|
+
return "\n".join(lines)
|
|
79
|
+
|
|
80
|
+
def generate_webcomponent_test(
|
|
81
|
+
self, component_code: str, component_name: str, spec: str
|
|
82
|
+
) -> str:
|
|
83
|
+
"""Generate a Playwright test for a Web Component.
|
|
84
|
+
|
|
85
|
+
Covers:
|
|
86
|
+
- Component mounts in a page and shadow root renders
|
|
87
|
+
- Attributes (derived from detected props) reflect to state
|
|
88
|
+
- Accessibility requirements mentioned in spec
|
|
89
|
+
"""
|
|
90
|
+
safe_name = self._safe_name(component_name)
|
|
91
|
+
tag = self._kebab_case(safe_name)
|
|
92
|
+
props = self._extract_props(spec)
|
|
93
|
+
a11y_reqs = self._extract_a11y(spec)
|
|
94
|
+
|
|
95
|
+
lines = []
|
|
96
|
+
lines.append('import { test, expect } from "@playwright/test";')
|
|
97
|
+
lines.append("")
|
|
98
|
+
lines.append(f'const FIXTURE_URL = "/fixtures/{tag}.html";')
|
|
99
|
+
lines.append("")
|
|
100
|
+
lines.append(f'test.describe("{safe_name} web component", () => {{')
|
|
101
|
+
lines.append(' test("mounts and exposes shadow DOM", async ({ page }) => {')
|
|
102
|
+
lines.append(" await page.goto(FIXTURE_URL);")
|
|
103
|
+
lines.append(f' const host = page.locator("{tag}");')
|
|
104
|
+
lines.append(" await expect(host).toBeVisible();")
|
|
105
|
+
lines.append(
|
|
106
|
+
" const hasShadow = await host.evaluate("
|
|
107
|
+
"(el) => !!el.shadowRoot);"
|
|
108
|
+
)
|
|
109
|
+
lines.append(" expect(hasShadow).toBe(true);")
|
|
110
|
+
lines.append(" });")
|
|
111
|
+
lines.append("")
|
|
112
|
+
|
|
113
|
+
for prop in props:
|
|
114
|
+
attr = self._kebab_case(prop["name"])
|
|
115
|
+
lines.append(
|
|
116
|
+
f' test("reflects attribute {attr}", async ({{ page }}) => {{'
|
|
117
|
+
)
|
|
118
|
+
lines.append(" await page.goto(FIXTURE_URL);")
|
|
119
|
+
lines.append(f' const host = page.locator("{tag}");')
|
|
120
|
+
lines.append(
|
|
121
|
+
f' await host.evaluate((el, v) => el.setAttribute("{attr}", v), '
|
|
122
|
+
f'"{self._sample_value(prop)}");'
|
|
123
|
+
)
|
|
124
|
+
lines.append(
|
|
125
|
+
f' const value = await host.getAttribute("{attr}");'
|
|
126
|
+
)
|
|
127
|
+
lines.append(
|
|
128
|
+
f' expect(value).toBe("{self._sample_value(prop)}");'
|
|
129
|
+
)
|
|
130
|
+
lines.append(" });")
|
|
131
|
+
lines.append("")
|
|
132
|
+
|
|
133
|
+
for req in a11y_reqs:
|
|
134
|
+
slug = re.sub(r"\W+", "-", req.lower()).strip("-") or "a11y"
|
|
135
|
+
lines.append(
|
|
136
|
+
f' test("a11y: {req}", async ({{ page }}) => {{'
|
|
137
|
+
)
|
|
138
|
+
lines.append(" await page.goto(FIXTURE_URL);")
|
|
139
|
+
lines.append(f' const host = page.locator("{tag}");')
|
|
140
|
+
lines.append(" const role = await host.getAttribute(\"role\");")
|
|
141
|
+
lines.append(" const ariaLabel = await host.getAttribute(\"aria-label\");")
|
|
142
|
+
lines.append(
|
|
143
|
+
" expect(role || ariaLabel).toBeTruthy();"
|
|
144
|
+
)
|
|
145
|
+
lines.append(f' // requirement: {req}')
|
|
146
|
+
lines.append(f' // tag: {slug}')
|
|
147
|
+
lines.append(" });")
|
|
148
|
+
lines.append("")
|
|
149
|
+
|
|
150
|
+
lines.append("});")
|
|
151
|
+
lines.append("")
|
|
152
|
+
return "\n".join(lines)
|
|
153
|
+
|
|
154
|
+
def generate_snapshot(self, component_name: str) -> str:
|
|
155
|
+
"""Generate a Storybook story (also usable as Chromatic snapshot)."""
|
|
156
|
+
safe_name = self._safe_name(component_name)
|
|
157
|
+
lines = []
|
|
158
|
+
lines.append(f'import type {{ Meta, StoryObj }} from "@storybook/react";')
|
|
159
|
+
lines.append(f'import {{ {safe_name} }} from "./{safe_name}";')
|
|
160
|
+
lines.append("")
|
|
161
|
+
lines.append(f'const meta: Meta<typeof {safe_name}> = {{')
|
|
162
|
+
lines.append(f' title: "Magic/{safe_name}",')
|
|
163
|
+
lines.append(f' component: {safe_name},')
|
|
164
|
+
lines.append(' parameters: {')
|
|
165
|
+
lines.append(' chromatic: { viewports: [320, 768, 1280] },')
|
|
166
|
+
lines.append(' },')
|
|
167
|
+
lines.append("};")
|
|
168
|
+
lines.append("")
|
|
169
|
+
lines.append("export default meta;")
|
|
170
|
+
lines.append(f'type Story = StoryObj<typeof {safe_name}>;')
|
|
171
|
+
lines.append("")
|
|
172
|
+
lines.append("export const Default: Story = {")
|
|
173
|
+
lines.append(" args: {},")
|
|
174
|
+
lines.append("};")
|
|
175
|
+
lines.append("")
|
|
176
|
+
return "\n".join(lines)
|
|
177
|
+
|
|
178
|
+
# ----- Spec parsing helpers ---------------------------------------
|
|
179
|
+
|
|
180
|
+
def _extract_props(self, spec: str) -> list:
|
|
181
|
+
"""Parse `## Props` section of spec to get prop names and types.
|
|
182
|
+
|
|
183
|
+
Supports a few common markdown styles:
|
|
184
|
+
- ``- name (type): description``
|
|
185
|
+
- ``| name | type | description |`` (markdown table rows)
|
|
186
|
+
- ``* name - type - description``
|
|
187
|
+
Returns a list of dicts: {name, type, description, required}.
|
|
188
|
+
"""
|
|
189
|
+
if not spec:
|
|
190
|
+
return []
|
|
191
|
+
section = self._extract_section(spec, "Props")
|
|
192
|
+
if not section:
|
|
193
|
+
return []
|
|
194
|
+
|
|
195
|
+
props: list = []
|
|
196
|
+
seen: set = set()
|
|
197
|
+
|
|
198
|
+
# Dash/bullet lines: - name (type[, required]): description
|
|
199
|
+
bullet_re = re.compile(
|
|
200
|
+
r"^\s*[-*]\s+`?(?P<name>[A-Za-z_][\w-]*)`?"
|
|
201
|
+
r"(?:\s*\((?P<type>[^)]+)\))?"
|
|
202
|
+
r"\s*[:\-]?\s*(?P<desc>.*)$"
|
|
203
|
+
)
|
|
204
|
+
# Table rows: | name | type | desc |
|
|
205
|
+
table_re = re.compile(
|
|
206
|
+
r"^\s*\|\s*`?(?P<name>[A-Za-z_][\w-]*)`?\s*\|"
|
|
207
|
+
r"\s*(?P<type>[^|]+)\|"
|
|
208
|
+
r"\s*(?P<desc>[^|]*)\|"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
for raw_line in section.splitlines():
|
|
212
|
+
line = raw_line.rstrip()
|
|
213
|
+
if not line.strip():
|
|
214
|
+
continue
|
|
215
|
+
# Skip markdown table header/separator rows
|
|
216
|
+
if re.match(r"^\s*\|?\s*-{3,}", line):
|
|
217
|
+
continue
|
|
218
|
+
if re.match(r"^\s*\|\s*name\s*\|", line, flags=re.IGNORECASE):
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
m = table_re.match(line)
|
|
222
|
+
if not m:
|
|
223
|
+
m = bullet_re.match(line)
|
|
224
|
+
if not m:
|
|
225
|
+
continue
|
|
226
|
+
|
|
227
|
+
name = m.group("name").strip()
|
|
228
|
+
if not name or name.lower() == "name":
|
|
229
|
+
continue
|
|
230
|
+
if name in seen:
|
|
231
|
+
continue
|
|
232
|
+
seen.add(name)
|
|
233
|
+
|
|
234
|
+
raw_type = (m.groupdict().get("type") or "string").strip()
|
|
235
|
+
required = False
|
|
236
|
+
lower_type = raw_type.lower()
|
|
237
|
+
if "required" in lower_type:
|
|
238
|
+
required = True
|
|
239
|
+
raw_type = re.sub(
|
|
240
|
+
r",?\s*required", "", raw_type, flags=re.IGNORECASE
|
|
241
|
+
).strip() or "string"
|
|
242
|
+
|
|
243
|
+
desc = (m.groupdict().get("desc") or "").strip().strip("|")
|
|
244
|
+
|
|
245
|
+
props.append(
|
|
246
|
+
{
|
|
247
|
+
"name": name,
|
|
248
|
+
"type": raw_type or "string",
|
|
249
|
+
"description": desc,
|
|
250
|
+
"required": required,
|
|
251
|
+
}
|
|
252
|
+
)
|
|
253
|
+
return props
|
|
254
|
+
|
|
255
|
+
def _extract_a11y(self, spec: str) -> list:
|
|
256
|
+
"""Parse `## Accessibility` section.
|
|
257
|
+
|
|
258
|
+
Returns a flat list of strings, each describing one requirement.
|
|
259
|
+
"""
|
|
260
|
+
if not spec:
|
|
261
|
+
return []
|
|
262
|
+
section = self._extract_section(spec, "Accessibility")
|
|
263
|
+
if not section:
|
|
264
|
+
return []
|
|
265
|
+
|
|
266
|
+
reqs: list = []
|
|
267
|
+
for raw_line in section.splitlines():
|
|
268
|
+
line = raw_line.strip()
|
|
269
|
+
if not line:
|
|
270
|
+
continue
|
|
271
|
+
if line.startswith(("-", "*")):
|
|
272
|
+
reqs.append(line[1:].strip())
|
|
273
|
+
elif re.match(r"^\d+\.\s+", line):
|
|
274
|
+
reqs.append(re.sub(r"^\d+\.\s+", "", line))
|
|
275
|
+
return reqs
|
|
276
|
+
|
|
277
|
+
def _extract_section(self, spec: str, heading: str) -> Optional[str]:
|
|
278
|
+
"""Return the body of a markdown ``## <heading>`` section."""
|
|
279
|
+
pattern = re.compile(
|
|
280
|
+
r"^#{1,6}\s+" + re.escape(heading) + r"\s*$",
|
|
281
|
+
flags=re.IGNORECASE | re.MULTILINE,
|
|
282
|
+
)
|
|
283
|
+
match = pattern.search(spec)
|
|
284
|
+
if not match:
|
|
285
|
+
return None
|
|
286
|
+
start = match.end()
|
|
287
|
+
# Next heading of same or higher level
|
|
288
|
+
next_heading = re.compile(r"^#{1,6}\s+\S", flags=re.MULTILINE)
|
|
289
|
+
tail = spec[start:]
|
|
290
|
+
next_match = next_heading.search(tail)
|
|
291
|
+
body = tail[: next_match.start()] if next_match else tail
|
|
292
|
+
return body.strip()
|
|
293
|
+
|
|
294
|
+
def _has_keyboard_interaction(self, spec: str) -> bool:
|
|
295
|
+
if not spec:
|
|
296
|
+
return False
|
|
297
|
+
patterns = [
|
|
298
|
+
r"\bkeyboard\b",
|
|
299
|
+
r"\bkeypress\b",
|
|
300
|
+
r"\bkeydown\b",
|
|
301
|
+
r"\bEnter key\b",
|
|
302
|
+
r"\bSpace key\b",
|
|
303
|
+
r"\btab\s+order\b",
|
|
304
|
+
]
|
|
305
|
+
return any(re.search(p, spec, flags=re.IGNORECASE) for p in patterns)
|
|
306
|
+
|
|
307
|
+
# ----- JSX/test rendering helpers ---------------------------------
|
|
308
|
+
|
|
309
|
+
def _default_props(self, props: list) -> str:
|
|
310
|
+
"""Produce default JSX props string: prop1="value" prop2={true}"""
|
|
311
|
+
pieces: list = []
|
|
312
|
+
for prop in props:
|
|
313
|
+
pieces.append(self._jsx_prop(prop))
|
|
314
|
+
return " ".join(pieces)
|
|
315
|
+
|
|
316
|
+
def _jsx_prop(self, prop: dict) -> str:
|
|
317
|
+
name = prop["name"]
|
|
318
|
+
ptype = (prop.get("type") or "string").lower()
|
|
319
|
+
if "bool" in ptype:
|
|
320
|
+
return f"{name}={{true}}"
|
|
321
|
+
if "number" in ptype or "int" in ptype or "float" in ptype:
|
|
322
|
+
return f"{name}={{0}}"
|
|
323
|
+
if "func" in ptype or "=>" in ptype or "callback" in ptype:
|
|
324
|
+
return f"{name}={{() => {{}}}}"
|
|
325
|
+
if "array" in ptype or "[]" in ptype:
|
|
326
|
+
return f"{name}={{[]}}"
|
|
327
|
+
if "object" in ptype or "{" in ptype:
|
|
328
|
+
return f"{name}={{{{}}}}"
|
|
329
|
+
# default: string
|
|
330
|
+
return f'{name}="{self._sample_value(prop)}"'
|
|
331
|
+
|
|
332
|
+
def _sample_value(self, prop: dict) -> str:
|
|
333
|
+
ptype = (prop.get("type") or "string").lower()
|
|
334
|
+
if "bool" in ptype:
|
|
335
|
+
return "true"
|
|
336
|
+
if "number" in ptype or "int" in ptype or "float" in ptype:
|
|
337
|
+
return "1"
|
|
338
|
+
return f"sample-{prop['name']}"
|
|
339
|
+
|
|
340
|
+
def _prop_test(self, name: str, prop: dict) -> str:
|
|
341
|
+
prop_name = prop["name"]
|
|
342
|
+
ptype = (prop.get("type") or "string").lower()
|
|
343
|
+
default_jsx = self._jsx_prop(prop)
|
|
344
|
+
lines = []
|
|
345
|
+
lines.append(f' it("accepts prop {prop_name}", () => {{')
|
|
346
|
+
lines.append(f" render(<{name} {default_jsx} />);")
|
|
347
|
+
if "bool" in ptype:
|
|
348
|
+
lines.append(
|
|
349
|
+
f' const el = screen.getByTestId("{name.lower()}");'
|
|
350
|
+
)
|
|
351
|
+
lines.append(" expect(el).toBeDefined();")
|
|
352
|
+
elif "func" in ptype or "=>" in ptype or "callback" in ptype:
|
|
353
|
+
lines.append(
|
|
354
|
+
f' const el = screen.getByTestId("{name.lower()}");'
|
|
355
|
+
)
|
|
356
|
+
lines.append(" expect(el).toBeDefined();")
|
|
357
|
+
else:
|
|
358
|
+
sample = self._sample_value(prop)
|
|
359
|
+
lines.append(
|
|
360
|
+
f' const el = screen.getByTestId("{name.lower()}");'
|
|
361
|
+
)
|
|
362
|
+
lines.append(" expect(el).toBeDefined();")
|
|
363
|
+
lines.append(f' // prop {prop_name} sample value: {sample}')
|
|
364
|
+
lines.append(" });")
|
|
365
|
+
lines.append("")
|
|
366
|
+
return "\n".join(lines)
|
|
367
|
+
|
|
368
|
+
def _a11y_test(self, name: str, req: str) -> str:
|
|
369
|
+
lower = req.lower()
|
|
370
|
+
slug = re.sub(r"\W+", " ", req).strip().replace(" ", "-")[:60] or "a11y"
|
|
371
|
+
lines = []
|
|
372
|
+
lines.append(f' it("a11y: {req}", () => {{')
|
|
373
|
+
lines.append(f" render(<{name} />);")
|
|
374
|
+
lines.append(
|
|
375
|
+
f' const el = screen.getByTestId("{name.lower()}");'
|
|
376
|
+
)
|
|
377
|
+
if "role=" in lower or "role " in lower:
|
|
378
|
+
# e.g. "role=button"
|
|
379
|
+
m = re.search(r"role[=\s]+['\"]?([\w-]+)", lower)
|
|
380
|
+
role = m.group(1) if m else "presentation"
|
|
381
|
+
lines.append(
|
|
382
|
+
f' expect(el.getAttribute("role") || "").toMatch(/{role}/i);'
|
|
383
|
+
)
|
|
384
|
+
elif "aria-label" in lower:
|
|
385
|
+
lines.append(
|
|
386
|
+
' expect(el.getAttribute("aria-label")).not.toBeNull();'
|
|
387
|
+
)
|
|
388
|
+
elif "aria-" in lower:
|
|
389
|
+
m = re.search(r"aria-([\w-]+)", lower)
|
|
390
|
+
attr = f"aria-{m.group(1)}" if m else "aria-label"
|
|
391
|
+
lines.append(
|
|
392
|
+
f' expect(el.getAttribute("{attr}")).not.toBeNull();'
|
|
393
|
+
)
|
|
394
|
+
elif "focus" in lower:
|
|
395
|
+
lines.append(" el.focus();")
|
|
396
|
+
lines.append(" expect(document.activeElement).toBe(el);")
|
|
397
|
+
else:
|
|
398
|
+
lines.append(" expect(el).toBeDefined();")
|
|
399
|
+
lines.append(f' // requirement slug: {slug}')
|
|
400
|
+
lines.append(" });")
|
|
401
|
+
lines.append("")
|
|
402
|
+
return "\n".join(lines)
|
|
403
|
+
|
|
404
|
+
def _keyboard_test(self, name: str, default_props: str) -> str:
|
|
405
|
+
lines = []
|
|
406
|
+
lines.append(' it("handles keyboard interaction", () => {')
|
|
407
|
+
props_str = (" " + default_props) if default_props else ""
|
|
408
|
+
lines.append(f" render(<{name}{props_str} />);")
|
|
409
|
+
lines.append(
|
|
410
|
+
f' const el = screen.getByTestId("{name.lower()}");'
|
|
411
|
+
)
|
|
412
|
+
lines.append(" el.focus();")
|
|
413
|
+
lines.append(
|
|
414
|
+
' fireEvent.keyDown(el, { key: "Enter", code: "Enter" });'
|
|
415
|
+
)
|
|
416
|
+
lines.append(" expect(el).toBeDefined();")
|
|
417
|
+
lines.append(" });")
|
|
418
|
+
lines.append("")
|
|
419
|
+
return "\n".join(lines)
|
|
420
|
+
|
|
421
|
+
def _snapshot_test(self, name: str, default_props: str) -> str:
|
|
422
|
+
props_str = (" " + default_props) if default_props else ""
|
|
423
|
+
lines = []
|
|
424
|
+
lines.append(' it("matches snapshot", () => {')
|
|
425
|
+
lines.append(
|
|
426
|
+
f" const {{ container }} = render(<{name}{props_str} />);"
|
|
427
|
+
)
|
|
428
|
+
lines.append(" expect(container.firstChild).toMatchSnapshot();")
|
|
429
|
+
lines.append(" });")
|
|
430
|
+
return "\n".join(lines)
|
|
431
|
+
|
|
432
|
+
# ----- String helpers ---------------------------------------------
|
|
433
|
+
|
|
434
|
+
@staticmethod
|
|
435
|
+
def _safe_name(name: str) -> str:
|
|
436
|
+
"""Produce a valid JS identifier from ``name``.
|
|
437
|
+
|
|
438
|
+
Keeps PascalCase if already valid; otherwise strips non-word
|
|
439
|
+
characters.
|
|
440
|
+
"""
|
|
441
|
+
cleaned = re.sub(r"\W+", "", name or "")
|
|
442
|
+
if not cleaned:
|
|
443
|
+
return "Component"
|
|
444
|
+
if cleaned[0].isdigit():
|
|
445
|
+
cleaned = "C" + cleaned
|
|
446
|
+
return cleaned
|
|
447
|
+
|
|
448
|
+
@staticmethod
|
|
449
|
+
def _kebab_case(name: str) -> str:
|
|
450
|
+
step = re.sub(r"(?<!^)(?=[A-Z])", "-", name).lower()
|
|
451
|
+
step = re.sub(r"[_\s]+", "-", step)
|
|
452
|
+
step = re.sub(r"-{2,}", "-", step)
|
|
453
|
+
return step.strip("-")
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Magic Modules Design Tokens
|
|
2
|
+
|
|
3
|
+
Design tokens are the named constants that describe Loki Mode's visual
|
|
4
|
+
language: colors, spacing steps, typography, border radii, shadows, and
|
|
5
|
+
motion curves. Generated components read these tokens so every new screen
|
|
6
|
+
stays consistent with the rest of the dashboard and web app.
|
|
7
|
+
|
|
8
|
+
## Files
|
|
9
|
+
|
|
10
|
+
- `defaults.json` -- baseline tokens shipped with Magic Modules. Reflects the
|
|
11
|
+
live Loki Mode dashboard palette (primary `#553DE9`, success `#1FC5A8`,
|
|
12
|
+
etc.) and the Inter / JetBrains Mono type pairing.
|
|
13
|
+
- `.loki/magic/tokens.json` (per project) -- optional override file. Merged
|
|
14
|
+
on top of the defaults at load time, so you only have to list the values
|
|
15
|
+
you want to change.
|
|
16
|
+
|
|
17
|
+
## Override defaults
|
|
18
|
+
|
|
19
|
+
Create `.loki/magic/tokens.json` at your project root:
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{
|
|
23
|
+
"colors": {
|
|
24
|
+
"primary": "#1E88E5",
|
|
25
|
+
"success": "#2E7D32"
|
|
26
|
+
},
|
|
27
|
+
"radii": {
|
|
28
|
+
"md": "10px"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The loader deep-merges this file onto `defaults.json`, so any key you omit
|
|
34
|
+
falls back to the shipped value.
|
|
35
|
+
|
|
36
|
+
## Extract from an existing codebase
|
|
37
|
+
|
|
38
|
+
Run the extractor to learn tokens from what's already in the repo (CSS
|
|
39
|
+
custom properties, Tailwind spacing utilities, hex literals, font-family
|
|
40
|
+
declarations, box-shadow values, border-radius declarations):
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
loki magic tokens extract # dry run, prints observed tokens
|
|
44
|
+
loki magic tokens extract --save # writes to .loki/magic/tokens.json
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Programmatic equivalent:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from magic.core.design_tokens import DesignTokens
|
|
51
|
+
|
|
52
|
+
tokens = DesignTokens(project_dir=".")
|
|
53
|
+
observed = tokens.extract_from_codebase(save=True)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The extractor scans (relative to the project root):
|
|
57
|
+
|
|
58
|
+
- `web-app/src/index.css` and any other `web-app/src/**/*.css`
|
|
59
|
+
- `dashboard-ui/**/*.css` and `dashboard-ui/loki-unified-styles.js`
|
|
60
|
+
- `dashboard/static/**/*.css`
|
|
61
|
+
- `.tsx` and `.jsx` files under `web-app/src/` and `dashboard-ui/` for
|
|
62
|
+
Tailwind spacing classes and inline hex colors
|
|
63
|
+
|
|
64
|
+
## How generated components use tokens
|
|
65
|
+
|
|
66
|
+
The generator calls `DesignTokens.to_prompt_context()` and injects the
|
|
67
|
+
resulting block into the component-generation prompt so the model always
|
|
68
|
+
knows the approved palette, spacing scale, and type stack. Example output:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
DESIGN TOKENS:
|
|
72
|
+
Colors: primary=#553DE9, success=#1FC5A8, danger=#C45B5B, ...
|
|
73
|
+
Spacing: xs=4px, sm=8px, md=12px, lg=16px, xl=24px
|
|
74
|
+
Typography: Inter (body), JetBrains Mono (code)
|
|
75
|
+
Radii: sm=4px, md=6px, lg=8px, xl=12px, full=9999px
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Additional renderers for post-processing generated code:
|
|
79
|
+
|
|
80
|
+
- `to_tailwind_config()` -- returns a Tailwind `theme.extend` dict you can
|
|
81
|
+
drop into a component-local `tailwind.config.js`.
|
|
82
|
+
- `to_css_variables()` -- returns a `:root { --color-primary: ... }` CSS
|
|
83
|
+
block for plain-CSS components.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"colors": {
|
|
3
|
+
"primary": "#553DE9",
|
|
4
|
+
"primary-hover": "#4832c7",
|
|
5
|
+
"primary-foreground": "#FFFFFF",
|
|
6
|
+
"success": "#1FC5A8",
|
|
7
|
+
"warning": "#D4A03C",
|
|
8
|
+
"danger": "#C45B5B",
|
|
9
|
+
"info": "#2F71E3",
|
|
10
|
+
"background": "#FFFEFB",
|
|
11
|
+
"card": "#FFFFFF",
|
|
12
|
+
"border": "#ECEAE3",
|
|
13
|
+
"muted": "#939084",
|
|
14
|
+
"ink": "#36342E",
|
|
15
|
+
"dark-background": "#0F0F11",
|
|
16
|
+
"dark-card": "#1A1A1E",
|
|
17
|
+
"dark-border": "#2A2A30",
|
|
18
|
+
"dark-ink": "#E8E6E3"
|
|
19
|
+
},
|
|
20
|
+
"spacing": {
|
|
21
|
+
"xs": "4px",
|
|
22
|
+
"sm": "8px",
|
|
23
|
+
"md": "12px",
|
|
24
|
+
"lg": "16px",
|
|
25
|
+
"xl": "24px",
|
|
26
|
+
"2xl": "32px",
|
|
27
|
+
"3xl": "48px"
|
|
28
|
+
},
|
|
29
|
+
"typography": {
|
|
30
|
+
"font-sans": "Inter, -apple-system, sans-serif",
|
|
31
|
+
"font-mono": "JetBrains Mono, monospace",
|
|
32
|
+
"font-serif": "DM Serif Display, serif",
|
|
33
|
+
"size-xs": "11px",
|
|
34
|
+
"size-sm": "13px",
|
|
35
|
+
"size-base": "14px",
|
|
36
|
+
"size-lg": "16px",
|
|
37
|
+
"size-xl": "20px",
|
|
38
|
+
"size-h1": "28px"
|
|
39
|
+
},
|
|
40
|
+
"radii": {
|
|
41
|
+
"sm": "4px",
|
|
42
|
+
"md": "6px",
|
|
43
|
+
"lg": "8px",
|
|
44
|
+
"xl": "12px",
|
|
45
|
+
"full": "9999px"
|
|
46
|
+
},
|
|
47
|
+
"shadows": {
|
|
48
|
+
"sm": "0 1px 2px rgba(0,0,0,0.05)",
|
|
49
|
+
"md": "0 4px 6px -1px rgba(0,0,0,0.1)",
|
|
50
|
+
"lg": "0 8px 25px rgba(0,0,0,0.1)",
|
|
51
|
+
"focus-ring": "0 0 0 3px rgba(85,61,233,0.15)"
|
|
52
|
+
},
|
|
53
|
+
"motion": {
|
|
54
|
+
"duration-fast": "100ms",
|
|
55
|
+
"duration-normal": "200ms",
|
|
56
|
+
"duration-slow": "300ms",
|
|
57
|
+
"ease-default": "cubic-bezier(0.4, 0, 0.2, 1)"
|
|
58
|
+
}
|
|
59
|
+
}
|
package/mcp/__init__.py
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loki-mode",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.80.0",
|
|
4
4
|
"description": "Loki Mode by Autonomi - Multi-agent autonomous startup system for Claude Code, Codex CLI, and Gemini CLI",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent",
|
|
@@ -75,6 +75,7 @@
|
|
|
75
75
|
"events/",
|
|
76
76
|
"memory/",
|
|
77
77
|
"learning/",
|
|
78
|
+
"magic/",
|
|
78
79
|
"templates/",
|
|
79
80
|
"dashboard/*.py",
|
|
80
81
|
"dashboard/static/",
|