@pro-vi/designer 0.3.6 → 0.3.8

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.
@@ -195,17 +195,42 @@ export class DesignerController {
195
195
  const { promptTextarea, sendButton } = this.selectors.composer;
196
196
  await this.browser.waitFor(promptTextarea);
197
197
  await this.browser.evalValue(`(() => {
198
- const ta = document.querySelector(${JSON.stringify(promptTextarea)});
199
- if (!ta) throw new Error('textarea not found');
200
- const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
201
- setter.call(ta, ${JSON.stringify(prompt)});
202
- ta.dispatchEvent(new Event('input', { bubbles: true }));
203
- ta.focus();
204
- return true;
198
+ const el = document.querySelector(${JSON.stringify(promptTextarea)});
199
+ if (!el) throw new Error('composer input not found');
200
+ const text = ${JSON.stringify(prompt)};
201
+ if (el instanceof HTMLTextAreaElement) {
202
+ // Bypass React's value ownership via the native setter, then fire a
203
+ // bubbling input event.
204
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
205
+ setter.call(el, text);
206
+ el.dispatchEvent(new Event('input', { bubbles: true }));
207
+ el.focus();
208
+ return true;
209
+ }
210
+ if (el.isContentEditable) {
211
+ // Deliver the text as a synthetic paste so the editor's own paste
212
+ // pipeline updates its internal state; execCommand('insertText')
213
+ // flattens multi-line prompts into one paragraph.
214
+ el.focus();
215
+ const sel = window.getSelection();
216
+ const range = document.createRange();
217
+ range.selectNodeContents(el);
218
+ sel.removeAllRanges();
219
+ sel.addRange(range);
220
+ const dt = new DataTransfer();
221
+ dt.setData('text/plain', text);
222
+ const unhandled = el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
223
+ if (unhandled) {
224
+ // No editor intercepted the paste — plain contenteditable fallback.
225
+ document.execCommand('insertText', false, text);
226
+ }
227
+ return true;
228
+ }
229
+ throw new Error('composer input is neither textarea nor contenteditable: ' + el.tagName);
205
230
  })()`);
206
231
  for (let i = 0; i < 30; i++) {
207
232
  await new Promise((r) => setTimeout(r, 150));
208
- const disabled = await this.browser.evalValue(`(() => { const b = document.querySelector(${JSON.stringify(sendButton)}); return !b || b.disabled; })()`);
233
+ const disabled = await this.browser.evalValue(`(() => { const b = document.querySelector(${JSON.stringify(sendButton)}); return !b || b.disabled || b.getAttribute('aria-disabled') === 'true'; })()`);
209
234
  if (!disabled)
210
235
  break;
211
236
  }
@@ -520,7 +545,9 @@ export class DesignerController {
520
545
  if (!opened)
521
546
  await this._clickButtonByText(/^Export$/);
522
547
  await new Promise((r) => setTimeout(r, 400));
523
- await this._clickButtonByText(/handoff to claude code/i);
548
+ const viaSendTo = await this._clickClaudeCodeSendTo().catch(() => false);
549
+ if (!viaSendTo)
550
+ await this._clickButtonByText(/handoff to claude code/i);
524
551
  let handoffUrl = '';
525
552
  for (let i = 0; i < 30; i++) {
526
553
  await new Promise((r) => setTimeout(r, 300));
@@ -583,6 +610,28 @@ export class DesignerController {
583
610
  return true;
584
611
  })()`);
585
612
  }
613
+ async _clickClaudeCodeSendTo() {
614
+ const tabClicked = await this.browser.evalValue(`(() => {
615
+ const tab = Array.from(document.querySelectorAll('button[role="tab"]')).find(t => /send to/i.test(t.textContent || ''));
616
+ if (!tab) return false;
617
+ tab.click();
618
+ return true;
619
+ })()`);
620
+ if (!tabClicked)
621
+ return false;
622
+ await new Promise((r) => setTimeout(r, 400));
623
+ return this.browser.evalValue(`(() => {
624
+ const sends = Array.from(document.querySelectorAll('button')).filter(b => (b.textContent || '').trim() === 'Send');
625
+ const target = sends.find(b => {
626
+ let row = b;
627
+ for (let i = 0; i < 3 && row.parentElement; i++) row = row.parentElement;
628
+ return /claude code/i.test(row.textContent || '');
629
+ });
630
+ if (!target) return false;
631
+ target.click();
632
+ return true;
633
+ })()`);
634
+ }
586
635
  async _dialogText() {
587
636
  return ((await this.browser
588
637
  .evalValue(`(() => {
@@ -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
+ }