cbrowser 19.1.4 → 19.1.6

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.
Files changed (101) hide show
  1. package/dist/analysis/accessibility-empathy.d.ts.map +1 -1
  2. package/dist/analysis/accessibility-empathy.js +17 -2
  3. package/dist/analysis/accessibility-empathy.js.map +1 -1
  4. package/dist/analysis/agent-ready-audit.d.ts.map +1 -1
  5. package/dist/analysis/agent-ready-audit.js +318 -68
  6. package/dist/analysis/agent-ready-audit.js.map +1 -1
  7. package/dist/browser.d.ts +72 -8
  8. package/dist/browser.d.ts.map +1 -1
  9. package/dist/browser.js +146 -120
  10. package/dist/browser.js.map +1 -1
  11. package/dist/charge-scope.d.ts +20 -0
  12. package/dist/charge-scope.d.ts.map +1 -0
  13. package/dist/charge-scope.js +46 -0
  14. package/dist/charge-scope.js.map +1 -0
  15. package/dist/mcp-server-remote.d.ts.map +1 -1
  16. package/dist/mcp-server-remote.js +110 -7
  17. package/dist/mcp-server-remote.js.map +1 -1
  18. package/dist/mcp-server.d.ts.map +1 -1
  19. package/dist/mcp-server.js +33 -44
  20. package/dist/mcp-server.js.map +1 -1
  21. package/dist/mcp-tools/base/audit-tools.d.ts.map +1 -1
  22. package/dist/mcp-tools/base/audit-tools.js +6 -1
  23. package/dist/mcp-tools/base/audit-tools.js.map +1 -1
  24. package/dist/mcp-tools/base/browser-management-tools.d.ts +11 -0
  25. package/dist/mcp-tools/base/browser-management-tools.d.ts.map +1 -1
  26. package/dist/mcp-tools/base/browser-management-tools.js +27 -6
  27. package/dist/mcp-tools/base/browser-management-tools.js.map +1 -1
  28. package/dist/mcp-tools/base/browser-state-tools.js +3 -3
  29. package/dist/mcp-tools/base/browser-state-tools.js.map +1 -1
  30. package/dist/mcp-tools/base/capture-tools.d.ts.map +1 -1
  31. package/dist/mcp-tools/base/capture-tools.js +3 -1
  32. package/dist/mcp-tools/base/capture-tools.js.map +1 -1
  33. package/dist/mcp-tools/base/cognitive-tools.d.ts.map +1 -1
  34. package/dist/mcp-tools/base/cognitive-tools.js +6 -37
  35. package/dist/mcp-tools/base/cognitive-tools.js.map +1 -1
  36. package/dist/mcp-tools/base/extraction-tools.d.ts.map +1 -1
  37. package/dist/mcp-tools/base/extraction-tools.js +6 -5
  38. package/dist/mcp-tools/base/extraction-tools.js.map +1 -1
  39. package/dist/mcp-tools/base/gif-tools.d.ts.map +1 -1
  40. package/dist/mcp-tools/base/gif-tools.js +2 -0
  41. package/dist/mcp-tools/base/gif-tools.js.map +1 -1
  42. package/dist/mcp-tools/base/interaction-tools.d.ts.map +1 -1
  43. package/dist/mcp-tools/base/interaction-tools.js +1 -0
  44. package/dist/mcp-tools/base/interaction-tools.js.map +1 -1
  45. package/dist/mcp-tools/base/llms-txt-tools.d.ts.map +1 -1
  46. package/dist/mcp-tools/base/llms-txt-tools.js +1 -0
  47. package/dist/mcp-tools/base/llms-txt-tools.js.map +1 -1
  48. package/dist/mcp-tools/base/marketing-tools.d.ts.map +1 -1
  49. package/dist/mcp-tools/base/marketing-tools.js +4 -0
  50. package/dist/mcp-tools/base/marketing-tools.js.map +1 -1
  51. package/dist/mcp-tools/base/navigation-tools.d.ts.map +1 -1
  52. package/dist/mcp-tools/base/navigation-tools.js +2 -1
  53. package/dist/mcp-tools/base/navigation-tools.js.map +1 -1
  54. package/dist/mcp-tools/base/persona-comparison-tools.d.ts.map +1 -1
  55. package/dist/mcp-tools/base/persona-comparison-tools.js +2 -0
  56. package/dist/mcp-tools/base/persona-comparison-tools.js.map +1 -1
  57. package/dist/mcp-tools/base/security-tools.d.ts.map +1 -1
  58. package/dist/mcp-tools/base/security-tools.js +2 -0
  59. package/dist/mcp-tools/base/security-tools.js.map +1 -1
  60. package/dist/mcp-tools/base/site-knowledge-tools.d.ts.map +1 -1
  61. package/dist/mcp-tools/base/site-knowledge-tools.js +9 -1
  62. package/dist/mcp-tools/base/site-knowledge-tools.js.map +1 -1
  63. package/dist/mcp-tools/base/testing-tools.js +1 -1
  64. package/dist/mcp-tools/base/testing-tools.js.map +1 -1
  65. package/dist/mcp-tools/base/values-tools.d.ts.map +1 -1
  66. package/dist/mcp-tools/base/values-tools.js +2 -0
  67. package/dist/mcp-tools/base/values-tools.js.map +1 -1
  68. package/dist/mcp-tools/credit-refund.d.ts +20 -0
  69. package/dist/mcp-tools/credit-refund.d.ts.map +1 -0
  70. package/dist/mcp-tools/credit-refund.js +37 -0
  71. package/dist/mcp-tools/credit-refund.js.map +1 -0
  72. package/dist/mcp-tools/persona-creation-tools.d.ts.map +1 -1
  73. package/dist/mcp-tools/persona-creation-tools.js +2 -0
  74. package/dist/mcp-tools/persona-creation-tools.js.map +1 -1
  75. package/dist/mcp-tools/persona-lifecycle-tools.js +6 -6
  76. package/dist/mcp-tools/persona-lifecycle-tools.js.map +1 -1
  77. package/dist/mcp-tools/screenshot-utils.d.ts +15 -0
  78. package/dist/mcp-tools/screenshot-utils.d.ts.map +1 -1
  79. package/dist/mcp-tools/screenshot-utils.js +18 -0
  80. package/dist/mcp-tools/screenshot-utils.js.map +1 -1
  81. package/dist/mcp-tools/tier-gate.d.ts.map +1 -1
  82. package/dist/mcp-tools/tier-gate.js +25 -2
  83. package/dist/mcp-tools/tier-gate.js.map +1 -1
  84. package/dist/personas.d.ts +19 -0
  85. package/dist/personas.d.ts.map +1 -1
  86. package/dist/personas.js +79 -0
  87. package/dist/personas.js.map +1 -1
  88. package/dist/recording/engine.d.ts +29 -0
  89. package/dist/recording/engine.d.ts.map +1 -1
  90. package/dist/recording/engine.js +67 -7
  91. package/dist/recording/engine.js.map +1 -1
  92. package/dist/remediation/patch-generator.d.ts.map +1 -1
  93. package/dist/remediation/patch-generator.js +15 -18
  94. package/dist/remediation/patch-generator.js.map +1 -1
  95. package/dist/types.d.ts +12 -0
  96. package/dist/types.d.ts.map +1 -1
  97. package/dist/types.js.map +1 -1
  98. package/dist/visual/perceptual-transport.d.ts.map +1 -1
  99. package/dist/visual/perceptual-transport.js +10 -2
  100. package/dist/visual/perceptual-transport.js.map +1 -1
  101. package/package.json +1 -1
@@ -525,45 +525,216 @@ async function detectBadLinks(ctx) {
525
525
  });
526
526
  }
527
527
  }
528
+ /** Fill-in label, used ONLY for an element that has no accessible name at all. */
529
+ const NAMELESS_LABEL_PLACEHOLDER = "Describe the action";
530
+ /** Elements with no closing tag; [role="button"] can land on any of them. */
531
+ const VOID_ELEMENTS = new Set([
532
+ "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr",
533
+ ]);
534
+ /**
535
+ * Attribute values longer than this are elided in a suggested example, keeping
536
+ * the first EXAMPLE_ATTR_HEAD characters and saying how many were left out.
537
+ * Copying every value verbatim turned one <a href="data:text/csv;base64,..."> on
538
+ * a fixture into a 200,092-char codeExample, sent three times over (example,
539
+ * recommendation snippet, patch "after"): the audit JSON went from 6,292 to
540
+ * 406,336 bytes. elementHtml is capped at 600 for the same reason. (2026-10-07)
541
+ */
542
+ const EXAMPLE_ATTR_MAX = 200;
543
+ const EXAMPLE_ATTR_HEAD = 80;
544
+ /**
545
+ * Suggested markup for a findability finding: the element's REAL opening tag
546
+ * with one attribute added, data-testid.
547
+ *
548
+ * Measured 2026-10-06 on cbrowser.ai: this example was built from textContent
549
+ * alone, so an icon button named aria-label="Previous slide" came back as
550
+ * <button data-testid="button" aria-label="button action">...</button>
551
+ * which deletes the real name and every other attribute. remediation_patches
552
+ * then shipped that as the fix. Now nothing the element already has is
553
+ * dropped or overridden. An aria-label is added only when the element has no
554
+ * accessible name from any source, because a placeholder aria-label on a link
555
+ * named by its <img alt> or title REPLACES that name. (2026-10-07)
556
+ */
557
+ function buildFindabilityCodeExample(el, usedTestIds) {
558
+ // Attribute values are quoted, so any quote or angle bracket in the page
559
+ // text would otherwise emit malformed HTML.
560
+ const attr = (v) => v.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
561
+ // A testid is a slug, so truncating THIS is correct and expected.
562
+ const slug = (v) => v.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-+|-+$/g, '').substring(0, 40).replace(/-+$/, '');
563
+ const name = el.accessibleName.trim();
564
+ // Two elements that share a name are flagged precisely because they are
565
+ // ambiguous; giving both the same testid would leave them ambiguous.
566
+ const base = slug(name) || slug(NAMELESS_LABEL_PLACEHOLDER);
567
+ let testId = base;
568
+ for (let n = 2; usedTestIds.has(testId); n++)
569
+ testId = `${base}-${n}`;
570
+ usedTestIds.add(testId);
571
+ // The marker is plain text inside the quotes, so the example stays valid
572
+ // markup and nobody mistakes the cut value for the real one.
573
+ const shown = (v) => v.length > EXAMPLE_ATTR_MAX
574
+ ? `${v.slice(0, EXAMPLE_ATTR_HEAD)}[...${v.length - EXAMPLE_ATTR_HEAD} more characters unchanged]`
575
+ : v;
576
+ let hasAriaLabelAttr = false;
577
+ const kept = el.attributes.map(([attrName, value]) => {
578
+ if (attrName === 'aria-label') {
579
+ hasAriaLabelAttr = true;
580
+ // aria-label=" " names nothing. Fill it in place rather than appending
581
+ // a second aria-label, which is invalid markup.
582
+ if (!value.trim() && !name)
583
+ return ` aria-label="${attr(NAMELESS_LABEL_PLACEHOLDER)}"`;
584
+ }
585
+ return ` ${attrName}="${attr(shown(value))}"`;
586
+ }).join('');
587
+ const addedLabel = !name && !hasAriaLabelAttr ? ` aria-label="${attr(NAMELESS_LABEL_PLACEHOLDER)}"` : '';
588
+ const open = `<${el.tag} data-testid="${attr(testId)}"${kept}${addedLabel}>`;
589
+ if (VOID_ELEMENTS.has(el.tag))
590
+ return open;
591
+ // Built from fullText, not the 30-char display copy. Using the truncated
592
+ // text here produced a patch that REWROTE the element's visible content:
593
+ // <a data-testid="sign-up-for-pro-and-get-500-bo"
594
+ // aria-label="Sign up for Pro and get 500 bo">Sign up for Pro and get 500 bo</a>
595
+ // Applying that silently amputates the link label on the live page. The
596
+ // recommendation is "add a data-testid", so the example must leave the
597
+ // content exactly as it is. (2026-07-29) Content with child elements (an
598
+ // icon, an <img>, a card's heading + paragraph) is not flattened to text
599
+ // either; it is left alone. (2026-10-07)
600
+ const body = el.hasElementChildren || el.textTruncated
601
+ ? '<!-- existing content unchanged -->'
602
+ : el.fullText.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
603
+ return `${open}${body}</${el.tag}>`;
604
+ }
528
605
  /**
529
606
  * Detect elements only findable by fuzzy/visual match
530
607
  */
531
608
  async function detectLowFindabilityElements(ctx) {
532
609
  const { page, issues, summary } = ctx;
533
610
  // Check for buttons/links that lack good selectors
534
- const poorSelectors = await page.$$eval('button, a, [role="button"]', (elements) => elements.map(el => {
535
- const hasId = !!el.id;
536
- const hasTestId = el.hasAttribute('data-testid') || el.hasAttribute('data-test') || el.hasAttribute('data-cy');
537
- const hasAriaLabel = el.hasAttribute('aria-label');
538
- const hasName = el.hasAttribute('name');
539
- const hasGoodClass = el.className && typeof el.className === 'string' &&
540
- /btn|button|cta|submit|action/i.test(el.className);
541
- const text = el.textContent?.trim() || '';
542
- // Score how findable this element is
543
- const findabilityScore = (hasId ? 3 : 0) +
544
- (hasTestId ? 3 : 0) +
545
- (hasAriaLabel ? 2 : 0) +
546
- (hasName ? 2 : 0) +
547
- (hasGoodClass ? 1 : 0) +
548
- (text.length > 0 && text.length < 50 ? 2 : 0);
549
- return {
550
- selector: el.tagName.toLowerCase() + (el.id ? `#${el.id}` : ''),
551
- // Truncated copy, for human-readable descriptions ONLY. Never build
552
- // suggested markup from this — see fullText. (2026-07-29)
553
- text: text.slice(0, 30),
554
- // The real text, for code examples. Capped generously to bound payload
555
- // size without amputating a normal link label.
556
- fullText: text.slice(0, 200),
557
- outerHTML: (el.outerHTML || '').slice(0, 600),
558
- outerHTMLTruncated: (el.outerHTML || '').length > 600,
559
- findabilityScore,
560
- suggestions: {
561
- needsId: !hasId,
562
- needsTestId: !hasTestId,
563
- needsAriaLabel: !hasAriaLabel && !text,
564
- },
611
+ const poorSelectors = await page.$$eval('button, a, [role="button"]', (elements) => {
612
+ // Trimmed: aria-label=" " is not a name and is not a hook.
613
+ const ariaLabel = (e) => (e.getAttribute('aria-label') || '').trim();
614
+ // aria-labelledby ids resolve in the element's OWN tree: a button in an
615
+ // open shadow root is labelled by an id in that shadow root, which
616
+ // document.getElementById cannot see. (2026-10-07)
617
+ const byId = (e, id) => {
618
+ const root = e.getRootNode();
619
+ return typeof root.getElementById === 'function' ? root.getElementById(id) : null;
620
+ };
621
+ // aria-label first, then aria-labelledby resolved to text: the order
622
+ // find_element_by_intent tries them (natural-language.ts SELECTOR_PRIORITY).
623
+ const ariaName = (e) => {
624
+ const label = ariaLabel(e);
625
+ if (label)
626
+ return label;
627
+ const ids = (e.getAttribute('aria-labelledby') || '').trim();
628
+ if (!ids)
629
+ return '';
630
+ return ids.split(/\s+/)
631
+ .map(id => byId(e, id)?.textContent || '')
632
+ .join(' ').replace(/\s+/g, ' ').trim();
633
+ };
634
+ const nameKey = (s) => s.replace(/\s+/g, ' ').trim().toLowerCase();
635
+ // An ARIA name is a stable hook only when it picks out ONE element:
636
+ // two buttons both labelled "Add to cart" match [aria-label="Add to cart"]
637
+ // twice, which is exactly the ambiguity this check exists to report.
638
+ // Counted across open shadow roots too: the elements scored here come
639
+ // from $$eval, which pierces them, and so does a Playwright
640
+ // [aria-label="..."] lookup, so a light-DOM and a shadow-DOM "Open menu"
641
+ // are ambiguous to an agent. (2026-10-07)
642
+ const ariaNameCounts = new Map();
643
+ const countAriaNames = (root) => {
644
+ for (const e of Array.from(root.querySelectorAll('[aria-label], [aria-labelledby]'))) {
645
+ const k = nameKey(ariaName(e));
646
+ if (k)
647
+ ariaNameCounts.set(k, (ariaNameCounts.get(k) || 0) + 1);
648
+ }
649
+ for (const host of Array.from(root.querySelectorAll('*'))) {
650
+ if (host.shadowRoot)
651
+ countAriaNames(host.shadowRoot);
652
+ }
565
653
  };
566
- }).filter(el => el.findabilityScore < 3));
654
+ countAriaNames(document);
655
+ return elements.map(el => {
656
+ const hasId = !!el.id;
657
+ const hasTestId = el.hasAttribute('data-testid') || el.hasAttribute('data-test') || el.hasAttribute('data-cy');
658
+ const aria = ariaName(el);
659
+ const ariaIsUnique = !!aria && ariaNameCounts.get(nameKey(aria)) === 1;
660
+ const hasName = el.hasAttribute('name');
661
+ const hasGoodClass = el.className && typeof el.className === 'string' &&
662
+ /btn|button|cta|submit|action/i.test(el.className);
663
+ const text = el.textContent?.trim() || '';
664
+ // Score how findable this element is.
665
+ // A unique aria-label weighs the same as id and data-testid. It was 2,
666
+ // so an icon button named aria-label="Previous slide" scored 2/10 and
667
+ // was flagged, while find_element_by_intent resolves it FIRST, at 0.95
668
+ // confidence (natural-language.ts SELECTOR_PRIORITY), and browser.ts
669
+ // ranks aria-label above both testid and id. A name shared with another
670
+ // element keeps its OLD weight, aria-label 2 and aria-labelledby 0 (it
671
+ // was never scored), so a duplicate is flagged exactly as before.
672
+ // Giving a shared aria-labelledby 2 stopped flagging two text buttons
673
+ // labelled by the same "Edit profile" span. (2026-10-07)
674
+ const findabilityScore = (hasId ? 3 : 0) +
675
+ (hasTestId ? 3 : 0) +
676
+ (ariaIsUnique ? 3 : ariaLabel(el) ? 2 : 0) +
677
+ (hasName ? 2 : 0) +
678
+ (hasGoodClass ? 1 : 0) +
679
+ (text.length > 0 && text.length < 50 ? 2 : 0);
680
+ // The element's accessible name from every source, so the suggested
681
+ // markup never invents a name for an element that already has one.
682
+ const imgAlt = [el, ...Array.from(el.querySelectorAll('img'))]
683
+ .filter(e => e.tagName === 'IMG')
684
+ .map(e => (e.getAttribute('alt') || '').trim())
685
+ .find(Boolean) || '';
686
+ const title = (el.getAttribute('title') || '').trim();
687
+ // <input type="submit" value="Send feedback"> is named by its value, a
688
+ // submit or reset input without one by the browser's default label,
689
+ // and an image input by its alt. Missing this put a placeholder
690
+ // aria-label over value="Send feedback". (2026-10-07)
691
+ const inputName = (() => {
692
+ if (el.tagName !== 'INPUT')
693
+ return '';
694
+ const type = (el.getAttribute('type') || '').toLowerCase();
695
+ if (type === 'image')
696
+ return (el.getAttribute('alt') || '').trim();
697
+ if (type !== 'submit' && type !== 'reset' && type !== 'button')
698
+ return '';
699
+ return (el.getAttribute('value') || '').trim() ||
700
+ (type === 'submit' ? 'Submit' : type === 'reset' ? 'Reset' : '');
701
+ })();
702
+ // innerText keeps a card's "Heading" and "Description" apart, where
703
+ // textContent glues them into "HeadingDescription".
704
+ const rendered = (el.innerText ?? '').replace(/\s+/g, ' ').trim();
705
+ // Rendered text beats img alt: in a list of user cards every
706
+ // <a><img alt="Avatar"> Jane Doe</a> has the same alt, so ranking alt
707
+ // first made every testid "avatar", "avatar-2", ... where base gave
708
+ // "jane-doe". alt still names a link that has no text (a logo).
709
+ // title is last, as in the accessible-name computation: it names an
710
+ // element only when the content does not. <a title="a > b">Short link</a>
711
+ // is "Short link", and its testid must be "short-link", not "a-b".
712
+ const accessibleName = aria || rendered || imgAlt || inputName || text || title;
713
+ return {
714
+ selector: el.tagName.toLowerCase() + (el.id ? `#${el.id}` : ''),
715
+ tag: el.tagName.toLowerCase(),
716
+ attributes: Array.from(el.attributes).map(a => [a.name, a.value]),
717
+ accessibleName: accessibleName.slice(0, 200),
718
+ hasElementChildren: el.children.length > 0,
719
+ // Truncated copy, for human-readable descriptions ONLY. Never build
720
+ // suggested markup from this — see fullText. (2026-07-29)
721
+ text: text.slice(0, 30),
722
+ // The real text, for code examples. Capped generously to bound payload
723
+ // size without amputating a normal link label.
724
+ fullText: text.slice(0, 200),
725
+ textTruncated: text.length > 200,
726
+ outerHTML: (el.outerHTML || '').slice(0, 600),
727
+ outerHTMLTruncated: (el.outerHTML || '').length > 600,
728
+ findabilityScore,
729
+ suggestions: {
730
+ needsId: !hasId,
731
+ needsTestId: !hasTestId,
732
+ needsAriaLabel: !accessibleName,
733
+ },
734
+ };
735
+ }).filter(el => el.findabilityScore < 3);
736
+ });
737
+ const usedTestIds = new Set();
567
738
  for (const el of poorSelectors.slice(0, 10)) { // Limit to avoid noise
568
739
  issues.push({
569
740
  category: "findability",
@@ -578,24 +749,7 @@ async function detectLowFindabilityElements(ctx) {
578
749
  : el.suggestions.needsAriaLabel
579
750
  ? "Add aria-label for accessibility and findability"
580
751
  : "Add unique id or data-testid",
581
- // Built from fullText, not the 30-char display copy. Using the truncated
582
- // text here produced a patch that REWROTE the element's visible content:
583
- // <a data-testid="sign-up-for-pro-and-get-500-bo"
584
- // aria-label="Sign up for Pro and get 500 bo">Sign up for Pro and get 500 bo</a>
585
- // Applying that silently amputates the link label on the live page. The
586
- // recommendation is "add a data-testid / aria-label", so the example must
587
- // leave the text exactly as it is. (2026-07-29)
588
- codeExample: (() => {
589
- const tag = el.selector.split('#')[0].split('.')[0] || 'button';
590
- const full = el.fullText || '';
591
- // A testid is a slug, so truncating THIS is correct and expected.
592
- const testId = (full || tag).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').substring(0, 40) || tag;
593
- const label = full || `${tag} action`;
594
- // Attribute values are quoted, so any quote or angle bracket in the page
595
- // text would otherwise emit malformed HTML.
596
- const attr = (v) => v.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
597
- return `<${tag} data-testid="${attr(testId)}" aria-label="${attr(label)}">${full || '...'}</${tag}>`;
598
- })(),
752
+ codeExample: buildFindabilityCodeExample(el, usedTestIds),
599
753
  });
600
754
  }
601
755
  summary.totalElements += poorSelectors.length;
@@ -879,17 +1033,40 @@ async function detectActionableElements(ctx) {
879
1033
  const { page, issues } = ctx;
880
1034
  const actionAnalysis = await page.evaluate(() => {
881
1035
  const buttons = Array.from(document.querySelectorAll('button, [role="button"], input[type="submit"], input[type="button"]'));
882
- // Generic/weak button labels
883
- const genericLabels = ['submit', 'click', 'ok', 'yes', 'no', 'go', 'next', 'back', 'continue', 'send', 'done'];
884
- // Strong action verbs
885
- const actionVerbs = ['add', 'create', 'save', 'delete', 'remove', 'edit', 'update', 'download', 'upload', 'share', 'copy', 'search', 'filter', 'sort', 'buy', 'subscribe', 'register', 'login', 'logout', 'sign'];
1036
+ // A label is generic only when EVERY word in it is. Matching ANY word
1037
+ // called "next slide" generic on cbrowser.ai because it contains "next",
1038
+ // and suggested "Save Changes" for a carousel arrow. "slide" names the
1039
+ // thing acted on, so the label is specific. "click here", "go back" and
1040
+ // "OK, got it" are generic through and through. So is "submit form":
1041
+ // "form" and "button" name the widget, not the outcome, and two forms'
1042
+ // "Submit form" buttons give an agent nothing to choose by.
1043
+ // Filler words (me, now, to, the, please, thanks) are generic too. Without
1044
+ // them "Click me", "Submit now", "Send now", "Go now" and "Yes please",
1045
+ // all flagged by the old any-word rule, stopped being flagged. They add no
1046
+ // information, so "Click to copy" and "Go to the dashboard" stay specific.
1047
+ // show/see/view join read/learn so "Show more" is as generic as "Read
1048
+ // more", while "Show details" is not. (2026-10-07)
1049
+ const genericWords = new Set([
1050
+ 'click', 'tap', 'press', 'here', 'this', 'go', 'back', 'next', 'more', 'read', 'learn',
1051
+ 'show', 'see', 'view',
1052
+ 'continue', 'done', 'submit', 'send', 'ok', 'okay', 'got', 'it', 'yes', 'no',
1053
+ 'button', 'link', 'form',
1054
+ 'me', 'now', 'to', 'the', 'please', 'thanks',
1055
+ ]);
886
1056
  const weakButtons = [];
887
1057
  let elementsWithDescribedBy = 0;
888
1058
  buttons.forEach(btn => {
889
- const text = (btn.textContent?.trim() || btn.getAttribute('value') || btn.getAttribute('aria-label') || '').toLowerCase();
890
- const words = text.split(/\s+/);
891
- // Check if button uses generic label without action verb
892
- const isGeneric = words.some(w => genericLabels.includes(w)) && !words.some(w => actionVerbs.some(v => w.startsWith(v)));
1059
+ // The accessible name, in the order a screen reader or role+name lookup
1060
+ // resolves it: aria-label="Next slide" names the button even when its
1061
+ // visible (aria-hidden) glyph text says "Next". (2026-10-07)
1062
+ const labelledBy = (btn.getAttribute('aria-labelledby') || '').trim().split(/\s+/).filter(Boolean)
1063
+ .map(id => document.getElementById(id)?.textContent || '').join(' ').trim();
1064
+ const text = ((btn.getAttribute('aria-label') || '').trim() || labelledBy || btn.textContent?.trim() || btn.getAttribute('value') || '')
1065
+ .replace(/\s+/g, ' ').toLowerCase();
1066
+ // Punctuation and arrows are not words: "Next →" is "next", "OK, got it" is "ok got it".
1067
+ const words = text.split(/[^\p{L}\p{N}']+/u).filter(Boolean);
1068
+ // `every` over zero words is true, so an unnamed icon button needs the length guard.
1069
+ const isGeneric = words.length > 0 && words.every(w => genericWords.has(w));
893
1070
  if (isGeneric && text.length < 20) {
894
1071
  weakButtons.push({
895
1072
  selector: btn.tagName.toLowerCase() + (btn.id ? `#${btn.id}` : ''),
@@ -1225,6 +1402,88 @@ async function detectCaptcha(ctx) {
1225
1402
  // ============================================================================
1226
1403
  // Report Generation
1227
1404
  // ============================================================================
1405
+ /**
1406
+ * How to say "N of these" for detections whose per-element description carries
1407
+ * that element's own figures.
1408
+ *
1409
+ * The aggregate line used to be `${count} ${typeIssues[0].description}`, so one
1410
+ * element's details were printed as if they described all of them. Measured
1411
+ * 2026-10-06 on cbrowser.ai: "10 elements lack stable selectors (score: 0/10)"
1412
+ * over per-element scores [0,2,2,2,2,2,0,0,0,0]. On the triage fixture:
1413
+ * "2 sticky element may intercept clicks (z-index: 50, 1264x40px, ...)" over a
1414
+ * sticky bar AND a 300x60 fixed dock at z-index 10, and
1415
+ * "3 Button with generic label: "go"" over "go", "done" and "click here".
1416
+ * Each summary below is true of every issue it counts. The per-issue
1417
+ * descriptions are unchanged; competitive-benchmark matches on them. (2026-10-07)
1418
+ */
1419
+ const GROUP_SUMMARIES = {
1420
+ "findability-score-check": {
1421
+ pattern: /^Element lacks stable selectors \(score: (\d+)\/10\)$/,
1422
+ summarize: (count, m) => {
1423
+ const scores = m.map(x => Number(x[1]));
1424
+ const lo = Math.min(...scores), hi = Math.max(...scores);
1425
+ return `${count} elements lack stable selectors (${lo === hi ? `score: ${lo}/10` : `scores: ${lo}-${hi}/10`})`;
1426
+ },
1427
+ },
1428
+ "sticky-element-check": {
1429
+ pattern: /^(\w+) element may intercept clicks \(z-index: (-?\d+)/,
1430
+ summarize: (count, m) => {
1431
+ const positions = [...new Set(m.map(x => x[1]))].join(" or ");
1432
+ const z = m.map(x => Number(x[2]));
1433
+ const lo = Math.min(...z), hi = Math.max(...z);
1434
+ return `${count} ${positions} elements may intercept clicks (z-index: ${lo === hi ? lo : `${lo}-${hi}`})`;
1435
+ },
1436
+ },
1437
+ "action-verb-check": {
1438
+ pattern: /^Button with generic label: "(.*)"$/,
1439
+ summarize: (count, m) => {
1440
+ // Repeats collapse: cbrowser.ai's two nav "More" triggers read "more" x2.
1441
+ const seen = new Map();
1442
+ for (const x of m)
1443
+ seen.set(x[1], (seen.get(x[1]) || 0) + 1);
1444
+ const labels = [...seen].map(([label, n]) => `"${label}"${n > 1 ? ` x${n}` : ""}`);
1445
+ return `${count} buttons with generic labels: ${labels.join(", ")}`;
1446
+ },
1447
+ },
1448
+ "clickable-div-check": {
1449
+ pattern: /^Clickable (\S+) without button role$/,
1450
+ // Element types only, at most five. The selector carries the element's id,
1451
+ // so listing selectors printed all 60 ids of a grid of
1452
+ // <div id="cell-N" onclick>, 812 chars in a top recommendation. (2026-10-07)
1453
+ summarize: (count, m) => {
1454
+ const tags = [...new Set(m.map(x => x[1].split("#")[0]))];
1455
+ const more = tags.length > 5 ? `, +${tags.length - 5} more` : "";
1456
+ return `${count} clickable elements without button role (${tags.slice(0, 5).join(", ")}${more})`;
1457
+ },
1458
+ },
1459
+ "link-href-check": {
1460
+ pattern: /^Link with (javascript:|no) href acts as button$/,
1461
+ summarize: (count, m) => `${count} links with ${[...new Set(m.map(x => x[1]))].join(" or ")} href act as buttons`,
1462
+ },
1463
+ };
1464
+ /** One line for a group of same-detection issues that is true of every one of them. */
1465
+ function summarizeIssueGroup(typeIssues) {
1466
+ const representative = typeIssues[0];
1467
+ const count = typeIssues.length;
1468
+ if (count === 1)
1469
+ return representative.description;
1470
+ const summary = GROUP_SUMMARIES[representative.detectionMethod];
1471
+ if (summary) {
1472
+ const matches = typeIssues.map(i => summary.pattern.exec(i.description));
1473
+ if (matches.every((m) => m !== null))
1474
+ return summary.summarize(count, matches);
1475
+ }
1476
+ if (typeIssues.every(i => i.description === representative.description)) {
1477
+ // v14.2.4: Fix grammar - "10 elements lack" not "10 Elements lacks"
1478
+ const issueText = representative.description
1479
+ .replace(/^Element /, "elements ")
1480
+ .replace(/ lacks /, " lack ");
1481
+ return `${count} ${issueText}`;
1482
+ }
1483
+ // Descriptions differ and there is no summary for this detection: say the
1484
+ // quoted one is an example rather than present it as all of them.
1485
+ return `${count} similar issues, for example: ${representative.description}`;
1486
+ }
1228
1487
  function generateRecommendations(issues) {
1229
1488
  // Group issues by category and sort by severity
1230
1489
  const grouped = issues.reduce((acc, issue) => {
@@ -1253,16 +1512,7 @@ function generateRecommendations(issues) {
1253
1512
  }
1254
1513
  for (const [_type, typeIssues] of issueTypes) {
1255
1514
  const representative = typeIssues[0];
1256
- const count = typeIssues.length;
1257
- // v14.2.4: Fix grammar - "10 elements lack" not "10 Elements lacks"
1258
- let issueText = representative.description;
1259
- if (count > 1) {
1260
- // Replace "Element lacks" with "elements lack" for proper grammar
1261
- issueText = representative.description
1262
- .replace(/^Element /, "elements ")
1263
- .replace(/ lacks /, " lack ");
1264
- issueText = `${count} ${issueText}`;
1265
- }
1515
+ const issueText = summarizeIssueGroup(typeIssues);
1266
1516
  recommendations.push({
1267
1517
  priority: priority++,
1268
1518
  category: representative.category,