@pro-vi/designer 0.3.5 → 0.3.7

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 CHANGED
@@ -118,7 +118,8 @@ Writes `tasting.html` with variant tabs + 1/2/3 shortcuts + persistent notes, se
118
118
  ## Operations
119
119
 
120
120
  - `designer doctor` — diagnose setup state. Exits 2 on failure.
121
- - `designer health` — probe 17 UI anchors. Wire into cron to catch claude.ai UI regressions.
121
+ - `designer health [--json]` — probe 17 UI anchors. Wire into cron to catch claude.ai UI regressions.
122
+ - **Daily CI** in `.github/workflows/`: `daily-health.yml` runs the auth-required UI probe on a self-hosted macOS runner once per day; `ci.yml` typechecks + builds + does a Docker clean-room install smoke on every PR; `release-please.yml` opens a release PR on conventional commits, merging it tags + publishes via `release-publish.yml`. Selector regressions land as auto-opened PRs under the `selectors-drift` label.
122
123
 
123
124
  ## Known quirks
124
125
 
package/dist/cli.js CHANGED
@@ -22,7 +22,9 @@ function parseFlags(args) {
22
22
  if (a.startsWith('--')) {
23
23
  const parts = a.slice(2).split('=');
24
24
  const k = parts[0] ?? '';
25
- const v = parts[1] ?? args[++i] ?? true;
25
+ const next = args[i + 1];
26
+ const v = parts[1] ??
27
+ (next !== undefined && !next.startsWith('--') ? args[++i] : true);
26
28
  if (k)
27
29
  out[k] = v;
28
30
  }
@@ -183,18 +185,31 @@ async function main() {
183
185
  case 'health': {
184
186
  const browser = createBrowser({ session: `designer-${key}` });
185
187
  const results = await runHealth(browser);
186
- const worst = results.some((r) => r.status === 'fail') ? 'fail' : 'ok';
187
- const icon = (s) => (s === 'ok' ? '✓' : s === 'fail' ? '✗' : '·');
188
- for (const r of results) {
189
- const line = `${icon(r.status)} [${r.category}] ${r.id} — ${r.description}${r.detail ? ' (' + r.detail + ')' : ''}`;
190
- console.log(line);
191
- }
192
188
  const counts = results.reduce((acc, r) => {
193
189
  acc[r.status] = (acc[r.status] || 0) + 1;
194
190
  return acc;
195
191
  }, {});
196
- console.log(`\n${counts['ok'] || 0} ok, ${counts['fail'] || 0} fail, ${counts['skip'] || 0} skip`);
197
- if (worst === 'fail')
192
+ const fail = results.some((r) => r.status === 'fail');
193
+ const url = (await browser.url().catch(() => '')) || '';
194
+ if (flags.json === true || flags.json === '') {
195
+ const payload = {
196
+ ok: !fail,
197
+ generatedAt: new Date().toISOString(),
198
+ url,
199
+ counts: { ok: counts['ok'] || 0, fail: counts['fail'] || 0, skip: counts['skip'] || 0 },
200
+ results
201
+ };
202
+ console.log(JSON.stringify(payload, null, 2));
203
+ }
204
+ else {
205
+ const icon = (s) => (s === 'ok' ? '✓' : s === 'fail' ? '✗' : '·');
206
+ for (const r of results) {
207
+ const line = `${icon(r.status)} [${r.category}] ${r.id} — ${r.description}${r.detail ? ' (' + r.detail + ')' : ''}`;
208
+ console.log(line);
209
+ }
210
+ console.log(`\n${counts['ok'] || 0} ok, ${counts['fail'] || 0} fail, ${counts['skip'] || 0} skip`);
211
+ }
212
+ if (fail)
198
213
  process.exit(2);
199
214
  break;
200
215
  }
@@ -427,11 +442,15 @@ Checks: agent-browser on PATH, CDP reachable at DESIGNER_CDP port, a /design tab
427
442
  selectors.json present, designer-loop skill installed at ~/.claude/skills/, MCP registration.
428
443
 
429
444
  Exits with code 2 if any check fails.`,
430
- health: `designer health — probe every UI anchor this MCP depends on.
445
+ health: `designer health [--json] — probe every UI anchor this MCP depends on.
431
446
 
432
447
  Walks the current Chrome state (home / session) and checks each selector / button / URL /
433
448
  DOM pattern we rely on. Reports pass / fail / skip per anchor with actionable detail.
434
449
 
450
+ Flags:
451
+ --json Emit a structured { ok, generatedAt, url, counts, results } JSON payload
452
+ on stdout instead of icons. Exit code is unchanged.
453
+
435
454
  Exit code 2 on any fail — wire into cron or CI to catch UI regressions (e.g., claude.ai
436
455
  moving the Share button) before users do.`,
437
456
  'mcp': `designer mcp serve — start the MCP stdio server.
@@ -542,18 +561,37 @@ async function checkOnDesignSurface() {
542
561
  const port = process.env.DESIGNER_CDP || '9222';
543
562
  try {
544
563
  const res = await fetch(`http://127.0.0.1:${port}/json/list`);
545
- if (!res.ok)
546
- return { name: 'logged into claude.ai/design', status: 'fail', detail: `HTTP ${res.status}` };
564
+ if (!res.ok) {
565
+ return {
566
+ name: 'claude.ai/design tab',
567
+ status: 'fail',
568
+ detail: `CDP HTTP ${res.status} — debug Chrome may have died. Run \`designer setup\` to relaunch it.`
569
+ };
570
+ }
547
571
  const tabs = await res.json();
548
572
  const onDesign = tabs.find((t) => t.url && /claude\.ai\/design/.test(t.url));
549
- if (!onDesign)
550
- return { name: 'logged into claude.ai/design', status: 'warn', detail: 'no tab on claude.ai/design — sign in and navigate there in the debug Chrome window' };
551
- if (/login|sign in/i.test(onDesign.title || ''))
552
- return { name: 'logged into claude.ai/design', status: 'fail', detail: 'on a login page; sign in inside the debug Chrome window' };
553
- return { name: 'logged into claude.ai/design', status: 'ok', detail: onDesign.url };
573
+ if (!onDesign) {
574
+ return {
575
+ name: 'claude.ai/design tab',
576
+ status: 'warn',
577
+ detail: 'no tab on claude.ai/design in the debug Chrome window. Open https://claude.ai/design THERE (not in your normal Chrome — they are separate profiles with separate cookies).'
578
+ };
579
+ }
580
+ if (/login|sign in/i.test(onDesign.title || '')) {
581
+ return {
582
+ name: 'signed in to claude.ai/design',
583
+ status: 'fail',
584
+ detail: 'on a login page. Sign in INSIDE the debug Chrome window (not your normal Chrome — that profile is signed in but its cookies are not shared).'
585
+ };
586
+ }
587
+ return { name: 'signed in to claude.ai/design', status: 'ok', detail: onDesign.url };
554
588
  }
555
589
  catch {
556
- return { name: 'logged into claude.ai/design', status: 'fail', detail: 'CDP not reachable; fix CDP first' };
590
+ return {
591
+ name: 'claude.ai/design tab',
592
+ status: 'fail',
593
+ detail: 'debug Chrome unreachable. Run `designer setup` to launch it.'
594
+ };
557
595
  }
558
596
  }
559
597
  function checkSelectors() {
@@ -0,0 +1,118 @@
1
+ import ts from 'typescript';
2
+ export function findAnchor(source, id) {
3
+ const sf = ts.createSourceFile('ui-anchors.ts', source, ts.ScriptTarget.Latest, true);
4
+ let result = null;
5
+ const visit = (node) => {
6
+ if (result)
7
+ return;
8
+ if (ts.isObjectLiteralExpression(node) && matchesAnchorWithId(node, id)) {
9
+ const checkProp = findProperty(node, 'check');
10
+ if (checkProp) {
11
+ const sel = extractSimpleHasSelectorArg(checkProp.initializer, sf);
12
+ if (sel)
13
+ result = sel;
14
+ }
15
+ return;
16
+ }
17
+ ts.forEachChild(node, visit);
18
+ };
19
+ visit(sf);
20
+ return result;
21
+ }
22
+ export function canPatch(source, id) {
23
+ return findAnchor(source, id) !== null;
24
+ }
25
+ export function patchSelector(source, id, newSelector) {
26
+ const match = findAnchor(source, id);
27
+ if (!match) {
28
+ throw new Error(`anchor-patcher: id "${id}" is not patchable — either not found or its check is not the simple hasSelector(b, '...') shape`);
29
+ }
30
+ const escaped = escapeForQuote(newSelector, match.quote);
31
+ return (source.slice(0, match.literalStart) +
32
+ match.quote +
33
+ escaped +
34
+ match.quote +
35
+ source.slice(match.literalEnd));
36
+ }
37
+ function matchesAnchorWithId(obj, id) {
38
+ const idProp = findProperty(obj, 'id');
39
+ if (!idProp)
40
+ return false;
41
+ const init = idProp.initializer;
42
+ if (ts.isStringLiteralLike(init)) {
43
+ return init.text === id;
44
+ }
45
+ return false;
46
+ }
47
+ function findProperty(obj, name) {
48
+ for (const p of obj.properties) {
49
+ if (!ts.isPropertyAssignment(p))
50
+ continue;
51
+ const n = p.name;
52
+ if (ts.isIdentifier(n) && n.text === name)
53
+ return p;
54
+ if (ts.isStringLiteral(n) && n.text === name)
55
+ return p;
56
+ }
57
+ return null;
58
+ }
59
+ function extractSimpleHasSelectorArg(expr, sf) {
60
+ if (!ts.isArrowFunction(expr))
61
+ return null;
62
+ let body = expr.body;
63
+ if (ts.isParenthesizedExpression(body))
64
+ body = body.expression;
65
+ if (!ts.isObjectLiteralExpression(body))
66
+ return null;
67
+ if (body.properties.length !== 1)
68
+ return null;
69
+ const okProp = body.properties[0];
70
+ if (!okProp || !ts.isPropertyAssignment(okProp))
71
+ return null;
72
+ if (!ts.isIdentifier(okProp.name) || okProp.name.text !== 'ok')
73
+ return null;
74
+ const okValue = okProp.initializer;
75
+ if (!ts.isAwaitExpression(okValue))
76
+ return null;
77
+ const call = okValue.expression;
78
+ if (!ts.isCallExpression(call))
79
+ return null;
80
+ if (!ts.isIdentifier(call.expression) || call.expression.text !== 'hasSelector')
81
+ return null;
82
+ if (call.arguments.length !== 2)
83
+ return null;
84
+ const [arg0, arg1] = call.arguments;
85
+ if (!arg0 || !arg1)
86
+ return null;
87
+ if (!ts.isIdentifier(arg0) || arg0.text !== 'b')
88
+ return null;
89
+ if (!ts.isStringLiteralLike(arg1))
90
+ return null;
91
+ const raw = arg1.getText(sf);
92
+ const quote = detectQuote(raw);
93
+ if (!quote)
94
+ return null;
95
+ return {
96
+ literalStart: arg1.getStart(sf),
97
+ literalEnd: arg1.getEnd(),
98
+ quote,
99
+ currentSelector: arg1.text
100
+ };
101
+ }
102
+ function detectQuote(literalText) {
103
+ const first = literalText[0];
104
+ if (first === "'" || first === '"' || first === '`')
105
+ return first;
106
+ return null;
107
+ }
108
+ function escapeForQuote(s, quote) {
109
+ let out = s.replace(/\\/g, '\\\\');
110
+ if (quote === "'")
111
+ out = out.replace(/'/g, "\\'");
112
+ else if (quote === '"')
113
+ out = out.replace(/"/g, '\\"');
114
+ else {
115
+ out = out.replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
116
+ }
117
+ return out;
118
+ }