typebulb 0.42.0 → 0.43.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 CHANGED
@@ -44,6 +44,8 @@ typebulb call <file> <fn> […] Invoke one server.ts export headlessly: prints
44
44
  typebulb send <file> [msg] Push a message into a running bulb's page (its tb.onMessage handlers); the client-side twin of call, no --trust.
45
45
  With --wait, a handler's non-undefined return prints on stdout (JSON; a bare string raw)
46
46
  typebulb send <file> tb:snapshot Print the live page's rendered outline (roles, names, visible text)
47
+ typebulb send <file> tb:click … Click a control by role+name ('tb:click button "Pass"'); the reply is a fresh snapshot
48
+ typebulb send <file> tb:set … Set a form control ('tb:set combobox "level" = hard'), firing input+change
47
49
  typebulb get <file> <kind> Print one block's content (data, insight, code, …) to stdout
48
50
  typebulb put <file> <k>=<src> Write a file's (or stdin's) content into a block, surgically
49
51
  typebulb pull <url|file> Fetch a bulb from typebulb.com into typebulbs/u/<user>/<slug>.bulb.md
@@ -267,8 +269,9 @@ That one launch *is* the loop: the server watches the file, so every save recomp
267
269
 
268
270
  - **Structured selftest** — a handler that returns `{ count, verdict }` beats one that logs prose: `typebulb send <file> selftest --wait` prints the object as JSON, and you assert on fields instead of parsing `logs`. At most one handler, in one page, may return a value; a slow check needs `--wait=<ms>` above the 5s default.
269
271
  - **Rendered truth** — `typebulb send <file> tb:snapshot` prints the page's accessibility outline (roles, names, visible text) without disturbing its state. Use it when logs say ok but the screen might not, and as the first probe on a live page in a state you can't reproduce — a save would hot-reload and destroy it. (`tb:` messages are answered by the runtime, never your handlers, and imply `--wait`.)
270
- - **Poking state** — to tweak a value in a live page (a save hot-reloads and destroys its state), author a set-handler up front: a `tb.onMessage` branch that takes a data payload (JSON arrives parsed), applies it to your state — committing the change if your framework needs an explicit step — and returns the new state: `typebulb send <file> '{"set":"speed","value":2}' --wait` prints it. In React, register it in an effect so it closes over the setters (the returned unsubscribe is the cleanup). Adding the handler later is itself the edit that destroys the state.
271
- - **A page must be open** — the CLI runs no browser of its own, so every client-side check waits on a real window (and `--no-open` means there isn't one). `send` says which case it is: nobody has ever connected (share the link), or a page dropped and hasn't returned (it's stale — reload it).
272
+ - **Acting on the page** — `typebulb send <file> 'tb:click button "Pass"'` clicks the one control matching that role and name (exact, else a unique case-insensitive substring) and replies with a fresh snapshot; `tb:set combobox "strength" = hard` is the same for form controls (checkboxes and radios take `tb:click`). A disabled, readonly, or covered target is an error naming it — that silence is the bug class these verbs catch. Needs exactly one page open, and the reply is the immediate frame (slow work: follow up with `tb:snapshot`). Only what the outline names is targetable: real `<button>`s and labeled controls, not an `onClick` `<div>`.
273
+ - **Poking state** — for state beyond what a form control expresses (`tb:set` covers those), author a set-handler up front: a `tb.onMessage` branch that takes a data payload (JSON arrives parsed), applies it to your state — committing the change if your framework needs an explicit step — and returns the new state: `typebulb send <file> '{"set":"speed","value":2}' --wait` prints it. In React, register it in an effect so it closes over the setters (the returned unsubscribe is the cleanup). Adding the handler later is itself the edit that destroys the state.
274
+ - **A page must be open** — the CLI runs no browser of its own, so every client-side check waits on a real window (and `--no-open` means there isn't one). `send` says which case it is: nobody has ever connected (share the link), or a page dropped and hasn't returned (it's stale — reload it). Never open a window at the user: the server logs `[page] connected` when a page attaches, so end your turn with the link, arming `typebulb wait <file> --match "[page] connected"` in the background first — the user opening the page is your wake-up.
272
275
 
273
276
  ### Emitting a server-only bulb
274
277
 
@@ -1304,60 +1304,206 @@ const something = require('module-name') // NOT SUPPORTED!
1304
1304
  set theme(v) { if (window.__tbTheme) window.__tbTheme.set(v); }
1305
1305
  });
1306
1306
 
1307
- // tb:snapshot \u2014 the page serializes its own accessibility outline (roles, names, visible text;
1308
- // the YAML-not-pixels shape) as the reply to \`typebulb send <file> tb:snapshot\`
1309
- // (TB-Interrogation.md). Heuristic by design: explicit role attr, a small
1310
- // implicit-role map, aria-label/alt/value then visible text for names \u2014 enough to catch
1311
- // text/structure divergence, which is what an agent can judge without pixels.
1307
+ // tb:snapshot / tb:click / tb:set \u2014 the reserved interrogation-and-actuation namespace
1308
+ // (TB-Interrogation.md, TB-Interrogation-Actuation.md). One walk is both the outline and the
1309
+ // selector vocabulary: snapshot renders its lines, the verbs resolve targets from its entries,
1310
+ // so nothing is targetable the outline doesn't name. Heuristic by design: explicit role attr, a
1311
+ // small implicit-role map; names come from aria-label, label association, placeholder, then
1312
+ // visible text \u2014 a form control's value is a stated facet ([value=\u2026]), never its name, because a
1313
+ // value-derived name is a self-invalidating selector.
1312
1314
  const IMPLICIT_ROLES = { BUTTON: 'button', SELECT: 'combobox', TEXTAREA: 'textbox', OPTION: 'option', IMG: 'img', NAV: 'navigation', MAIN: 'main', HEADER: 'banner', FOOTER: 'contentinfo', ASIDE: 'complementary', FORM: 'form', DIALOG: 'dialog', TABLE: 'table', TR: 'row', TH: 'columnheader', TD: 'cell', UL: 'list', OL: 'list', LI: 'listitem', LABEL: 'label', P: 'paragraph', PROGRESS: 'progressbar', SUMMARY: 'button', FIGURE: 'figure', BLOCKQUOTE: 'blockquote', HR: 'separator' };
1313
1315
  const INPUT_ROLES = { checkbox: 'checkbox', radio: 'radio', range: 'slider', number: 'spinbutton', search: 'searchbox', button: 'button', submit: 'button', reset: 'button', hidden: '' };
1314
- const snapshot = () => {
1315
- const MAX_LINES = 400;
1316
- const squash = (s) => (s || '').replace(/\\s+/g, ' ').trim();
1317
- const clip = (s) => (s.length > 200 ? s.slice(0, 200) + '\u2026' : s);
1318
- const roleOf = (el) => {
1319
- const explicit = el.getAttribute('role');
1320
- if (explicit) return explicit;
1321
- const t = el.tagName;
1322
- if (t === 'A') return el.hasAttribute('href') ? 'link' : '';
1323
- if (t === 'INPUT') { const r = INPUT_ROLES[(el.getAttribute('type') || 'text').toLowerCase()]; return r === undefined ? 'textbox' : r; }
1324
- if (/^H[1-6]$/.test(t)) return 'heading';
1325
- return IMPLICIT_ROLES[t] || '';
1326
- };
1327
- const hiddenEl = (el) => {
1328
- if (el.hidden || el.getAttribute('aria-hidden') === 'true') return true;
1329
- const cs = getComputedStyle(el);
1330
- return cs.display === 'none' || cs.visibility === 'hidden';
1316
+ const FORM_TAGS = { INPUT: 1, TEXTAREA: 1, SELECT: 1 };
1317
+ const squash = (s) => (s || '').replace(/\\s+/g, ' ').trim();
1318
+ const clip = (s) => (s.length > 200 ? s.slice(0, 200) + '\u2026' : s);
1319
+ const roleOf = (el) => {
1320
+ const explicit = el.getAttribute('role');
1321
+ if (explicit) return explicit;
1322
+ const t = el.tagName;
1323
+ if (t === 'A') return el.hasAttribute('href') ? 'link' : '';
1324
+ if (t === 'INPUT') { const r = INPUT_ROLES[(el.getAttribute('type') || 'text').toLowerCase()]; return r === undefined ? 'textbox' : r; }
1325
+ if (/^H[1-6]$/.test(t)) return 'heading';
1326
+ return IMPLICIT_ROLES[t] || '';
1327
+ };
1328
+ const hiddenEl = (el) => {
1329
+ if (el.hidden || el.getAttribute('aria-hidden') === 'true') return true;
1330
+ const cs = getComputedStyle(el);
1331
+ return cs.display === 'none' || cs.visibility === 'hidden';
1332
+ };
1333
+ // The associated label's text minus the control's own subtree (accname's core rule) \u2014 the
1334
+ // stable identity a wrapping label's raw textContent would drown in the control's own text.
1335
+ const labelName = (el) => {
1336
+ const label = el.labels && el.labels[0];
1337
+ if (!label) return '';
1338
+ let t = '';
1339
+ const gather = (n) => {
1340
+ if (n === el) return;
1341
+ if (n.nodeType === 3) { t += ' ' + n.nodeValue; return; }
1342
+ if (n.nodeType !== 1) return;
1343
+ for (let c = n.firstChild; c; c = c.nextSibling) gather(c);
1331
1344
  };
1345
+ gather(label);
1346
+ return squash(t);
1347
+ };
1348
+ const collectOutline = () => {
1349
+ const MAX_LINES = 400;
1332
1350
  const lines = [];
1333
- const walk = (node, depth) => {
1334
- for (let ch = node.firstChild; ch && lines.length <= MAX_LINES; ch = ch.nextSibling) {
1351
+ const targets = []; // { el, role, name } per role line, in outline order
1352
+ const walk = (node, depth, inLabel) => {
1353
+ // No early stop at MAX_LINES: the cap bounds what's PRINTED, below \u2014 a truncated outline
1354
+ // must not make a below-the-cap target unresolvable (the walk is a plain DOM read anyway).
1355
+ for (let ch = node.firstChild; ch; ch = ch.nextSibling) {
1335
1356
  if (ch.nodeType === 3) {
1336
1357
  const t = squash(ch.nodeValue);
1337
- if (t) lines.push(' '.repeat(depth) + '- text: ' + clip(t));
1358
+ if (t && !inLabel) lines.push(' '.repeat(depth) + '- text: ' + clip(t));
1338
1359
  continue;
1339
1360
  }
1340
1361
  if (ch.nodeType !== 1) continue;
1341
1362
  const tag = ch.tagName;
1342
1363
  if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'TEMPLATE' || tag === 'NOSCRIPT' || tag === 'LINK' || tag === 'META') continue;
1343
1364
  if (hiddenEl(ch)) continue;
1365
+ // An associated label is naming material, not a node: its text is the control's name, so
1366
+ // both its own line and its raw text would be duplicates. An orphan label keeps its line.
1367
+ if (tag === 'LABEL' && ch.control) { walk(ch, depth, true); continue; }
1344
1368
  const role = roleOf(ch);
1345
- if (!role) { walk(ch, depth); continue; } // structural (div/span/\u2026): descend transparently
1369
+ if (!role) { walk(ch, depth, inLabel); continue; } // structural (div/span/\u2026): descend transparently
1346
1370
  const attr = (n) => squash(ch.getAttribute(n) || '');
1347
1371
  const text = squash(ch.textContent);
1348
- const name = attr('aria-label') || (tag === 'IMG' ? attr('alt') : '') || (typeof ch.value === 'string' ? squash(ch.value) || attr('placeholder') : '') || text;
1372
+ const formControl = FORM_TAGS[tag] && role !== 'button';
1373
+ let name;
1374
+ const aria = attr('aria-label');
1375
+ if (aria) name = aria;
1376
+ else if (tag === 'IMG') name = attr('alt') || text;
1377
+ else if (formControl) name = labelName(ch) || attr('placeholder') || attr('title');
1378
+ else if (tag === 'INPUT') name = squash(ch.value) || text; // a button-role input: value is its caption
1379
+ else name = text;
1380
+ const facets = [];
1381
+ if (role === 'heading') {
1382
+ const lv = attr('aria-level') || (/^H[1-6]$/.test(tag) ? tag.charAt(1) : '');
1383
+ if (lv) facets.push('level=' + lv);
1384
+ }
1385
+ if (formControl && role !== 'checkbox' && role !== 'radio') {
1386
+ const v = tag === 'SELECT'
1387
+ ? squash(ch.selectedOptions && ch.selectedOptions[0] ? ch.selectedOptions[0].textContent : ch.value)
1388
+ : squash(ch.value);
1389
+ if (v) facets.push('value=' + clip(v));
1390
+ }
1391
+ if ((role === 'checkbox' || role === 'radio') && ch.checked) facets.push('checked');
1392
+ if (ch.disabled === true || attr('aria-disabled') === 'true') facets.push('disabled');
1349
1393
  let line = role + (name ? ' "' + clip(name) + '"' : '');
1350
- const lv = role === 'heading' ? (attr('aria-level') || (/^H[1-6]$/.test(tag) ? tag.charAt(1) : '')) : '';
1351
- if (lv) line += ' [level=' + lv + ']';
1394
+ for (const f of facets) line += ' [' + f + ']';
1352
1395
  lines.push(' '.repeat(depth) + '- ' + line);
1353
- if (!(name && name === text)) walk(ch, depth + 1); // subtree \u2261 name \u21D2 the one line says it all
1396
+ targets.push({ el: ch, role, name: name || '' });
1397
+ if (!(name && name === text)) walk(ch, depth + 1, false); // subtree \u2261 name \u21D2 the one line says it all
1354
1398
  }
1355
1399
  };
1356
- walk(document.body, 0);
1400
+ walk(document.body, 0, false);
1357
1401
  if (lines.length > MAX_LINES) { lines.length = MAX_LINES; lines.push('- \u2026 (truncated)'); }
1402
+ return { lines, targets };
1403
+ };
1404
+ const snapshot = () => {
1405
+ const { lines } = collectOutline();
1358
1406
  return lines.length ? lines.join('\\n') : '(empty page)';
1359
1407
  };
1360
1408
 
1409
+ // tb:click / tb:set (TB-Interrogation-Actuation.md). A target is role + name exactly as the
1410
+ // outline prints them: exact name first, else a unique case-insensitive substring; zero or
1411
+ // several is a reported error, never a guess. Errors throw \u2014 the reply leg carries them to
1412
+ // stderr and a non-zero exit.
1413
+ const describeEl = (el) => el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (typeof el.className === 'string' && el.className ? '.' + el.className.split(/\\s+/)[0] : '');
1414
+ const resolveTarget = (role, name) => {
1415
+ const ofRole = collectOutline().targets.filter((t) => t.role === role);
1416
+ if (!ofRole.length) throw new Error('no ' + role + ' in the outline \u2014 run tb:snapshot for the vocabulary');
1417
+ const named = ofRole.filter((t) => t.name);
1418
+ const exact = named.filter((t) => t.name === name);
1419
+ const q = name.toLowerCase();
1420
+ const hits = exact.length ? exact : named.filter((t) => t.name.toLowerCase().indexOf(q) !== -1);
1421
+ if (hits.length === 1) return hits[0].el;
1422
+ if (!hits.length) {
1423
+ const unnamed = ofRole.length - named.length;
1424
+ throw new Error('no ' + role + ' named "' + name + '"'
1425
+ + (named.length ? ' \u2014 ' + role + ' names here: ' + named.slice(0, 8).map((t) => '"' + t.name + '"').join(', ') : '')
1426
+ + (unnamed ? ' (' + unnamed + ' unnamed \u2014 an aria-label would make them targetable)' : ''));
1427
+ }
1428
+ throw new Error(hits.length + ' ' + role + 's match "' + name + '" (' + hits.map((t) => '"' + t.name + '"').join(', ') + ') \u2014 name one exactly');
1429
+ };
1430
+ // A dead control is a finding, not a no-op \u2014 one sentence, whatever deadened it.
1431
+ const deadControl = (role, name, state) => new Error(role + ' "' + name + '" is ' + state + ' \u2014 a dead control is a finding, not a no-op');
1432
+ const requireEnabled = (el, role, name) => {
1433
+ if (el.disabled === true || el.getAttribute('aria-disabled') === 'true') throw deadControl(role, name, 'disabled');
1434
+ };
1435
+ // The gesture's own preamble: a user scrolls to a control before clicking it (this is not a
1436
+ // scroll verb \u2014 and instant, because a page's \`scroll-behavior: smooth\` would animate while the
1437
+ // geometry reads below saw pre-scroll layout, silently disarming the hit-test). A center the
1438
+ // viewport can't reach, or whose click another element would receive, is a finding.
1439
+ const requireClickable = (el, role, name) => {
1440
+ el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
1441
+ const r = el.getBoundingClientRect();
1442
+ const p = { x: r.left + r.width / 2, y: r.top + r.height / 2 };
1443
+ const top = document.elementFromPoint(p.x, p.y);
1444
+ if (!top) throw new Error(role + ' "' + name + '" is not clickable: its center is outside the viewport even after scrolling it into view (clipped by overflow?)');
1445
+ if (top !== el && !el.contains(top)) throw new Error(role + ' "' + name + '" is not clickable: a click at its center lands on ' + describeEl(top));
1446
+ return p;
1447
+ };
1448
+ // Reply after a double rAF registered post-dispatch (so a rAF-rendering framework's scheduled
1449
+ // frame lands first; the second survives one scheduled from a microtask), RACED against a short
1450
+ // timer: a hidden or occluded document runs no frames at all, and a page the browser isn't
1451
+ // rendering must still answer with its current DOM rather than hang the sender into a false
1452
+ // "stale tab" diagnosis. (The Playwright tier can't cover the hidden case \u2014 Playwright keeps
1453
+ // backgrounded pages rendered.) Either way: the immediate rendered consequence, never the
1454
+ // settled outcome \u2014 settled truth is the caller's follow-up tb:snapshot.
1455
+ const postFrameSnapshot = () => new Promise((resolve) => {
1456
+ let done = false;
1457
+ const finish = () => { if (!done) { done = true; resolve(snapshot()); } };
1458
+ requestAnimationFrame(() => requestAnimationFrame(finish));
1459
+ setTimeout(finish, 150);
1460
+ });
1461
+ const parseActuation = (usage, rest) => {
1462
+ const m = /^\\s*([\\w-]+)\\s+(?:"([^"]*)"|(\\S+))\\s*([\\s\\S]*)$/.exec(rest);
1463
+ if (!m) throw new Error('usage: ' + usage);
1464
+ return { role: m[1], name: m[2] !== undefined ? m[2] : m[3], rest: m[4] };
1465
+ };
1466
+ const actClick = (rest) => {
1467
+ const t = parseActuation('tb:click <role> "<name>"', rest);
1468
+ if (t.rest) throw new Error('usage: tb:click <role> "<name>"');
1469
+ const el = resolveTarget(t.role, t.name);
1470
+ requireEnabled(el, t.role, t.name);
1471
+ const p = requireClickable(el, t.role, t.name);
1472
+ for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) {
1473
+ const Ctor = type.indexOf('pointer') === 0 && window.PointerEvent ? PointerEvent : MouseEvent;
1474
+ el.dispatchEvent(new Ctor(type, { bubbles: true, cancelable: true, view: window, clientX: p.x, clientY: p.y }));
1475
+ }
1476
+ return postFrameSnapshot();
1477
+ };
1478
+ const actSet = (rest) => {
1479
+ const t = parseActuation('tb:set <role> "<name>" = <value>', rest);
1480
+ const vm = /^=\\s*([\\s\\S]*)$/.exec(t.rest.trim());
1481
+ if (!vm) throw new Error('usage: tb:set <role> "<name>" = <value>');
1482
+ let value = vm[1].trim();
1483
+ if (value.length >= 2 && value.charAt(0) === '"' && value.charAt(value.length - 1) === '"') value = value.slice(1, -1);
1484
+ const el = resolveTarget(t.role, t.name);
1485
+ const tag = el.tagName;
1486
+ if (!FORM_TAGS[tag]) throw new Error('tb:set targets form controls (input, textarea, select) \u2014 for app state beyond a control, author a poke handler (README, "Poking state")');
1487
+ const type = tag === 'INPUT' ? (el.getAttribute('type') || 'text').toLowerCase() : '';
1488
+ if (type === 'checkbox' || type === 'radio') throw new Error('a ' + t.role + ' takes tb:click, not a value');
1489
+ requireEnabled(el, t.role, t.name);
1490
+ if (el.readOnly === true) throw deadControl(t.role, t.name, 'readonly');
1491
+ if (tag === 'SELECT') {
1492
+ const opts = Array.from(el.options);
1493
+ const hit = opts.find((o) => o.value === value) || opts.find((o) => squash(o.textContent).toLowerCase() === value.toLowerCase());
1494
+ if (!hit) throw new Error('no option "' + value + '" in ' + t.role + ' "' + t.name + '" \u2014 options: ' + opts.map((o) => '"' + squash(o.textContent) + '"').join(', '));
1495
+ value = hit.value;
1496
+ }
1497
+ // The NATIVE prototype setter, not the instance property: a framework's value tracker
1498
+ // (React) shadows the instance to dedupe events, and a plain assignment goes unseen.
1499
+ const proto = tag === 'SELECT' ? HTMLSelectElement.prototype : tag === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
1500
+ const desc = Object.getOwnPropertyDescriptor(proto, 'value');
1501
+ if (desc && desc.set) desc.set.call(el, value); else el.value = value;
1502
+ el.dispatchEvent(new Event('input', { bubbles: true }));
1503
+ el.dispatchEvent(new Event('change', { bubbles: true }));
1504
+ return postFrameSnapshot();
1505
+ };
1506
+
1361
1507
  // Events channel (dev server only): 'reload' drives hot reload (only emitted when watching),
1362
1508
  // 'message' delivers \`typebulb send\` pushes to tb.onMessage. Connect for a CLI-served page (http
1363
1509
  // origin, not an embed); a srcdoc embed or a file:// static export has no server, so onMessage just
@@ -1381,8 +1527,11 @@ ${wCt}
1381
1527
  } catch (err) { errors.push('reply is not JSON-serializable: ' + errText(err)); }
1382
1528
  };
1383
1529
  const reservedCall = async () => {
1384
- if (env.payload === 'tb:snapshot') return snapshot();
1385
- throw new Error('unknown reserved message: ' + env.payload);
1530
+ const p = env.payload;
1531
+ if (p === 'tb:snapshot') return snapshot();
1532
+ if (p === 'tb:click' || p.indexOf('tb:click ') === 0) return actClick(p.slice(9));
1533
+ if (p === 'tb:set' || p.indexOf('tb:set ') === 0) return actSet(p.slice(7));
1534
+ throw new Error('unknown reserved message: ' + p + ' (known: tb:snapshot, tb:click, tb:set)');
1386
1535
  };
1387
1536
  const value = parseMsg(env.payload);
1388
1537
  const calls = typeof env.payload === 'string' && env.payload.indexOf('tb:') === 0