@seybel/breakhound 1.0.2 → 1.0.4

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
@@ -78,7 +78,13 @@ npx @seybel/breakhound --url https://staging.example.com # every route, on ano
78
78
  | Angular | your route definitions, from `src/main.ts` on, with lazily loaded `loadChildren` routes under their path |
79
79
  | Anything else | links followed from the home page (up to 50 pages, a few per section) |
80
80
 
81
- **Dynamic routes** like `/blog/[slug]` are scanned once, using a real link found on your pages (routes that share a template share a layout). If no page links to one, it's listed with a hint, and you can give it a URL:
81
+ **Dynamic routes** like `/blog/[slug]` are scanned once, using a real link found on your pages (routes that share a template share a layout). Routes below one reuse its values: once `/cohorts/css-101` is found, `/cohorts/[slug]/settings` is scanned as `/cohorts/css-101/settings`. If something is still missing, it's listed with a hint naming the parameters. Give them a real value once, by name, and every route that uses them is filled in:
82
+
83
+ ```ts
84
+ params: { slug: 'css-101', submissionId: '42' }
85
+ ```
86
+
87
+ Or give one route its own URL, which wins over `params`:
82
88
 
83
89
  ```ts
84
90
  samples: { '/products/[id]': '/products/42' }
@@ -122,15 +128,15 @@ Chromium finds most layout bugs, but Safari and Firefox lay some things out diff
122
128
 
123
129
  Two differences you'll meet early: Safari gives buttons, inputs and selects an 11px font unless you set one (Chrome and Firefox use about 13px), so unstyled controls show up as tiny text only in WebKit, with `font: inherit` as the fix (that's WebKit on a Mac: Playwright's WebKit on Linux, as in most CI, draws them like Chrome); and Firefox breaks long URLs at their slashes, so a URL that pushes the page wide in Chrome and Safari can wrap fine in Firefox.
124
130
 
125
- ### Menus, dialogs and expandable sections
131
+ ### Menus, dialogs, tabs and expandable sections
126
132
 
127
- A lot of what breaks on a phone is hidden until you tap: the mobile menu, a sign-up modal, an FAQ answer. After scanning a page as it loads, breakhound opens what it finds there one at a time and sweeps the widths where each can be opened. That covers buttons with `aria-expanded`, `<details>`, popovers, dialogs, Bootstrap toggles, and buttons that are clearly a menu (`hamburger`, `menu-toggle`, an accessible name of "Menu"). Only what breaks while something is open gets reported, with what to open first:
133
+ A lot of what breaks on a phone is hidden until you tap: the mobile menu, a sign-up modal, an FAQ answer, the second tab. After scanning a page as it loads, breakhound opens what it finds there one at a time and sweeps the widths where each can be opened. That covers buttons with `aria-expanded`, `<details>`, popovers, dialogs, tabs (`role="tab"`, as in Radix, shadcn/ui, Headless UI and MUI), Bootstrap toggles, and buttons that are clearly a menu (`hamburger`, `menu-toggle`, an accessible name of "Menu"). Only what breaks while something is open gets reported, with what to open first:
128
134
 
129
135
  ```
130
136
  ✖ 320–399px Page scrolls horizontally (80px wider than the viewport), with "Menu" open
131
137
  ```
132
138
 
133
- It never clicks a link to another page or a button that submits a form, and it opens only one question of an FAQ, since they all share a layout. It opens up to 5 per page. Set `states` to change that, or `--no-states` (or `data-breakhound-ignore="states"` on an element) to leave them closed.
139
+ It never clicks a link to another page or a button that submits a form, and it opens only one question of an FAQ, since they all share a layout, but every tab, since each shows something different. It opens up to 5 per page. Set `states` to change that, or `--no-states` (or `data-breakhound-ignore="states"` on an element) to leave them closed.
134
140
 
135
141
  ### A second opinion from a vision model (optional)
136
142
 
@@ -153,7 +159,7 @@ The folder is for you, not your repo, so it keeps itself out of git: breakhound
153
159
 
154
160
  ## Chrome extension
155
161
 
156
- The breakhound extension runs the same checks on the tab you have open, with nothing to install in your project. Get it from the [Chrome Web Store](https://chromewebstore.google.com/search/breakhound); it works on any page Chrome can open, from localhost to production. The quickest way in is to right-click the page and choose **Scan with breakhound**: the breakhound panel opens in Chrome's side panel and starts right away. Clicking the breakhound icon in the toolbar opens it ready to scan, and the same panel is a **breakhound** tab in DevTools. The page grows steadily from 320px to 1920px (or the widths you set) while the timeline fills in live: each run of widths that share a layout, every width where the layout changes, and where something breaks. Then you get:
162
+ The breakhound extension runs the same checks on the tab you have open, with nothing to install in your project. Get it from the [Chrome Web Store](https://chromewebstore.google.com/detail/ddnmhnjmcokbfabeileblceehjhefkpc); it works on any page Chrome can open, from localhost to production. The quickest way in is to right-click the page and choose **Scan with breakhound**: the breakhound panel opens in Chrome's side panel and starts right away. Clicking the breakhound icon in the toolbar opens it ready to scan, and the same panel is a **breakhound** tab in DevTools. The page grows steadily from 320px to 1920px (or the widths you set) while the timeline fills in live: each run of widths that share a layout, every width where the layout changes, and where something breaks. Then you get:
157
163
 
158
164
  - the issues, each with its exact width ranges, the element, the numbers and the likely fix
159
165
  - the timeline: the layout bands, the media query widths, the changes no media or container query explains, and a bar for each issue across the widths it covers
@@ -296,7 +302,8 @@ Running breakhound through `npx` without installing it, you get `breakhound.conf
296
302
  | `baseUrl` | Scan this server instead of finding or starting the dev server |
297
303
  | `routes` | Scan exactly these routes instead of discovering them |
298
304
  | `include`, `exclude` | Route globs: `*` is one segment, `**` any number |
299
- | `samples` | A real URL for each dynamic route |
305
+ | `samples` | A real URL for a dynamic route |
306
+ | `params` | Real values for dynamic route parameters by name, for every route that uses them |
300
307
  | `maxRoutes` | Most routes per run (default 100) |
301
308
  | `server` | How to start the app: `{ command: 'npm run preview', port: 4173, timeout: 120 }` |
302
309
  | `widths` | `{ min, max, step }` in CSS pixels |
@@ -305,7 +312,7 @@ Running breakhound through `npx` without installing it, you get `breakhound.conf
305
312
  | `vision` | `true` to add a vision model's opinion, or `{ model, provider, widths }` |
306
313
  | `fix` | The model `breakhound fix` uses: `{ provider: 'ollama', model: 'qwen2.5-coder' }` |
307
314
  | `browsers` | Engines to check in: `["chromium", "webkit", "firefox"]` (default Chromium only) |
308
- | `states` | Most menus, dialogs and expandable sections to open per page (default 5), or `false` |
315
+ | `states` | Most menus, dialogs, tabs and expandable sections to open per page (default 5), or `false` |
309
316
  | `concurrency` | Browser tabs to use at once (default half your CPU cores, up to 4) |
310
317
  | `memoryLimit` | Most memory the dev server and browser may use together, e.g. `"6GB"`, `"50%"` or `"off"` (default 60% of your memory) |
311
318
 
@@ -344,7 +351,7 @@ breakhound mcp Run the MCP server (your agent starts it)
344
351
  --fail-on <level> Exit 1 at error, warning, info or none (default error)
345
352
  --pdf Also write report.pdf
346
353
  --no-screenshots Skip screenshots
347
- --no-states Don't open menus, dialogs and expandable sections
354
+ --no-states Don't open menus, dialogs, tabs and expandable sections
348
355
  --browsers <list> Also check other engines, e.g. chromium,webkit,firefox
349
356
  --vision Also ask your own vision model what looks off (info only)
350
357
  --concurrency <n> Browser tabs to use at once
package/dist/cli.js CHANGED
@@ -693,6 +693,8 @@ function describeState(state) {
693
693
  switch (state.kind) {
694
694
  case "dialog":
695
695
  return named ? `with the "${state.label}" dialog open` : "with the dialog open";
696
+ case "tab":
697
+ return named ? `with the "${state.label}" tab open` : "with another tab open";
696
698
  case "details":
697
699
  case "disclosure":
698
700
  return named ? `with "${state.label}" expanded` : "with the section expanded";
@@ -1303,6 +1305,24 @@ function plural(n, word) {
1303
1305
  function routeLabel(route) {
1304
1306
  return route.route || new URL(route.url).pathname;
1305
1307
  }
1308
+ function leftOutHint(routes) {
1309
+ const skipped = routes.filter((r) => r.status === "skipped");
1310
+ if (!skipped.length) return null;
1311
+ const names = /* @__PURE__ */ new Set();
1312
+ for (const r of skipped) {
1313
+ const named = r.error?.match(/nothing gives (.+) a value/)?.[1] ?? "";
1314
+ for (const m of named.matchAll(/\[([^\]]+)\]/g)) names.add(m[1]);
1315
+ }
1316
+ const key = (n) => /^[A-Za-z_$][\w$]*$/.test(n) ? n : `'${n}'`;
1317
+ if (names.size) return `params: { ${[...names].map((n) => `${key(n)}: '1'`).join(", ")} }`;
1318
+ const first = skipped[0].route;
1319
+ const example = first.replace(/\[\[?\.\.\.[^\]]+\]\]?/g, "a/b").replace(/\[\[?[^\]]+\]\]?/g, "1");
1320
+ return `samples: { '${first}': '${example}' }`;
1321
+ }
1322
+
1323
+ // ../report/src/brand.ts
1324
+ var MARK_PNG = "data:image/png;base64,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";
1325
+ var WORDMARK_PNG = "data:image/png;base64,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";
1306
1326
 
1307
1327
  // ../report/src/markdown.ts
1308
1328
  var SEVERITY_ICON = { error: "\u2716", warning: "\u26A0", info: "\u2139" };
@@ -1364,7 +1384,7 @@ function renderSummary(result) {
1364
1384
  [
1365
1385
  "Routes left out",
1366
1386
  result.routes.filter((r) => r.status === "redirect" || r.status === "skipped"),
1367
- result.routes.some((r) => r.status === "skipped") ? 'Give dynamic routes a real URL with `samples` in your config, e.g. `{ "/products/[id]": "/products/42" }`.' : void 0
1387
+ leftOutHint(result.routes) ? `Give dynamic routes real values in your config, e.g. \`${leftOutHint(result.routes)}\` (replace the 1s with real ones).` : void 0
1368
1388
  ]
1369
1389
  ];
1370
1390
  for (const [title, routes, hint] of sections) {
@@ -1547,6 +1567,7 @@ function buildData(result, options) {
1547
1567
  baseUrl: result.project.baseUrl
1548
1568
  },
1549
1569
  summary: result.summary,
1570
+ leftOutHint: leftOutHint(result.routes),
1550
1571
  routes: result.routes.map((r) => ({
1551
1572
  route: routeLabel(r),
1552
1573
  url: r.url,
@@ -1586,7 +1607,7 @@ function renderHtml(result, options = {}) {
1586
1607
  <body>
1587
1608
  <header class="top">
1588
1609
  <div class="wrap top-inner">
1589
- <div class="brand"><span class="mark" aria-hidden="true"></span><span>breakhound</span></div>
1610
+ <div class="brand" role="img" aria-label="breakhound"><span class="mark"></span><span class="word"></span></div>
1590
1611
  <div class="top-actions">
1591
1612
  <button type="button" class="btn primary" id="copy-all">Copy fix prompt</button>
1592
1613
  </div>
@@ -1657,8 +1678,10 @@ code, .mono { font-family: var(--mono); font-size: 13px; }
1657
1678
 
1658
1679
  .top { position: sticky; top: 0; z-index: 10; background: color-mix(in srgb, var(--bg) 88%, transparent); backdrop-filter: blur(8px); border-bottom: 1px solid var(--border); }
1659
1680
  .top-inner { display: flex; align-items: center; justify-content: space-between; gap: 12px; height: 56px; }
1660
- .brand { display: flex; align-items: center; gap: 8px; font-weight: 650; letter-spacing: -.01em; }
1661
- .mark { width: 18px; height: 18px; border-radius: 5px; background: linear-gradient(135deg, var(--accent) 0 55%, var(--error) 55%); }
1681
+ .brand { display: flex; align-items: center; gap: 7px; color: var(--text); }
1682
+ .brand .mark, .brand .word { display: inline-block; background: currentColor; mask-position: center; mask-size: contain; mask-repeat: no-repeat; }
1683
+ .brand .mark { width: 27px; height: 20px; mask-image: url("${MARK_PNG}"); }
1684
+ .brand .word { width: 95px; height: 18px; mask-image: url("${WORDMARK_PNG}"); }
1662
1685
  .btn { border: 1px solid var(--border); background: var(--surface); border-radius: 8px; padding: 7px 12px; cursor: pointer; white-space: nowrap; }
1663
1686
  .btn:hover { border-color: var(--tick); }
1664
1687
  .btn.primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; font-weight: 600; }
@@ -1773,7 +1796,7 @@ dd code { background: var(--surface-2); padding: 1px 5px; border-radius: 4px; }
1773
1796
  a { color: inherit; }
1774
1797
  }
1775
1798
  `;
1776
- var JS = String.raw`
1799
+ var JS = `
1777
1800
  (() => {
1778
1801
  const D = JSON.parse(document.getElementById('bh-data').textContent);
1779
1802
  const $ = (id) => document.getElementById(id);
@@ -1792,7 +1815,7 @@ var JS = String.raw`
1792
1815
  for (const kid of kids.flat(Infinity)) if (kid != null && kid !== false) n.append(kid);
1793
1816
  return n;
1794
1817
  };
1795
- const range = (r) => (r.from === r.to ? r.from + 'px' : r.from + '–' + r.to + 'px');
1818
+ const range = (r) => (r.from === r.to ? r.from + 'px' : r.from + '\u2013' + r.to + 'px');
1796
1819
  const ranges = (rs) => rs.map(range).join(', ');
1797
1820
  const plural = (n, w) => n + ' ' + w + (n === 1 ? '' : 's');
1798
1821
 
@@ -1818,10 +1841,10 @@ var JS = String.raw`
1818
1841
  const started = new Date(D.meta.startedAt);
1819
1842
  const scanned = D.routes.filter((r) => r.status === 'scanned');
1820
1843
  $('meta').textContent = [
1821
- D.meta.baseUrl, plural(scanned.length, 'page'), min + '–' + max + 'px',
1844
+ D.meta.baseUrl, plural(scanned.length, 'page'), min + '\u2013' + max + 'px',
1822
1845
  started.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }),
1823
1846
  (D.meta.durationMs / 1000).toFixed(1) + 's',
1824
- ].filter(Boolean).join(' · ');
1847
+ ].filter(Boolean).join(' \xB7 ');
1825
1848
  $('version').textContent = D.meta.version;
1826
1849
  $('copy-all').addEventListener('click', () => copy(D.prompt, 'Fix prompt copied. Paste it into your coding agent.'));
1827
1850
  if (!s.issues) $('copy-all').hidden = true;
@@ -1891,11 +1914,9 @@ var JS = String.raw`
1891
1914
  }
1892
1915
  const skipped = D.routes.filter((r) => r.status === 'skipped' || r.status === 'redirect');
1893
1916
  if (skipped.length) {
1894
- const dynamic = skipped.find((r) => r.status === 'skipped');
1895
- const example = dynamic && dynamic.route.replace(/\[\[?\.\.\.[^\]]+\]\]?/g, 'a/b').replace(/\[\[?[^\]]+\]\]?/g, '1');
1896
1917
  $('failed').append(el('div', { class: 'callout' },
1897
1918
  el('h2', { text: plural(skipped.length, 'route') + ' left out' }),
1898
- dynamic ? el('p', {}, 'Give dynamic routes a real URL in your config: ', el('code', { text: "samples: { '" + dynamic.route + "': '" + example + "' }" })) : false,
1919
+ D.leftOutHint ? el('p', {}, 'Give dynamic routes real values in your config (replace the 1s with real ones): ', el('code', { text: D.leftOutHint })) : false,
1899
1920
  listing(skipped)));
1900
1921
  }
1901
1922
  const shaky = D.routes.filter((r) => r.warnings.length);
@@ -2217,9 +2238,6 @@ function globMatches(glob, path) {
2217
2238
  }
2218
2239
  return new RegExp(`^${source}/?$`).test(path);
2219
2240
  }
2220
- function examplePath(path) {
2221
- return path.replace(/\[\[?\.\.\.[^\]]+\]\]?/g, "a/b").replace(/\[\[?[^\]]+\]\]?/g, "1");
2222
- }
2223
2241
 
2224
2242
  // ../routes/src/syntax.ts
2225
2243
  function joinPaths(base, path) {
@@ -3055,26 +3073,44 @@ function detectProject(cwd) {
3055
3073
  }
3056
3074
 
3057
3075
  // ../routes/src/queue.ts
3076
+ function namesIn(label) {
3077
+ return [...label.matchAll(/\[\[?(?:\.\.\.)?([^\]]+)\]\]?/g)].map((m) => m[1]);
3078
+ }
3079
+ function isPlainParam(s) {
3080
+ return s.kind === "param" && !s.optional && namesIn(s.label).length === 1 && s.label.startsWith("[") && s.label.endsWith("]");
3081
+ }
3082
+ function prefixOf(segments, i) {
3083
+ return `/${segments.slice(0, i + 1).map((s) => s.kind === "static" ? s.value : s.label).join("/")}`;
3084
+ }
3058
3085
  function trimSlash(path) {
3059
3086
  return path.length > 1 ? path.replace(/\/+$/, "") : path;
3060
3087
  }
3061
3088
  var RouteQueue = class {
3062
- constructor(baseUrl, samples, crawler, hashRouting = false) {
3089
+ constructor(baseUrl, samples, crawler, hashRouting = false, params = {}) {
3063
3090
  this.baseUrl = baseUrl;
3064
3091
  this.samples = samples;
3065
3092
  this.crawler = crawler;
3066
3093
  this.hashRouting = hashRouting;
3094
+ this.params = params;
3067
3095
  this.basePath = baseUrl ? new URL(baseUrl).pathname.replace(/\/+$/, "") : "";
3068
3096
  }
3069
3097
  baseUrl;
3070
3098
  samples;
3071
3099
  crawler;
3072
3100
  hashRouting;
3101
+ params;
3073
3102
  jobs = [];
3074
3103
  /** App paths already scanned or planned, so redirects to them aren't scanned twice. */
3075
3104
  planned = /* @__PURE__ */ new Set();
3076
3105
  /** Dynamic routes still waiting for a link that fits them. */
3077
3106
  waiting = /* @__PURE__ */ new Map();
3107
+ /** For routes left waiting at the end: the parameters nothing gave a value. */
3108
+ missing = /* @__PURE__ */ new Map();
3109
+ /**
3110
+ * Values parameters took in real URLs, under the route they belong to: `/cohorts/[slug]` →
3111
+ * `css-101`. Routes below it reuse them, since a child of a real page is real too.
3112
+ */
3113
+ seen = /* @__PURE__ */ new Map();
3078
3114
  patterns = /* @__PURE__ */ new Map();
3079
3115
  order = 0;
3080
3116
  /** Where the app is mounted on its server, e.g. "/docs"; "" at the root. */
@@ -3128,17 +3164,84 @@ var RouteQueue = class {
3128
3164
  return;
3129
3165
  }
3130
3166
  const sample = this.samples[pattern.path];
3131
- if (sample) this.add({ url: this.urlFor(sample), route: pattern.path });
3132
- else this.waiting.set(pattern.path, pattern);
3167
+ if (sample) {
3168
+ const samplePath = /^https?:\/\//i.test(sample) ? this.appPath(new URL(sample).pathname) : sample;
3169
+ if (samplePath !== null) this.learn(pattern, samplePath);
3170
+ this.add({ url: this.urlFor(sample), route: pattern.path });
3171
+ } else this.waiting.set(pattern.path, pattern);
3172
+ }
3173
+ /** Remembers the values a real URL gives a route's parameters. */
3174
+ learn(pattern, path) {
3175
+ if (pattern.segments.some((s) => s.kind === "rest" || s.kind === "param" && s.optional))
3176
+ return;
3177
+ const parts = path.split(/[?#]/)[0].split("/").filter(Boolean);
3178
+ if (parts.length !== pattern.segments.length) return;
3179
+ pattern.segments.forEach((s, i) => {
3180
+ if (isPlainParam(s)) {
3181
+ const key = prefixOf(pattern.segments, i);
3182
+ if (!this.seen.has(key)) this.seen.set(key, parts[i]);
3183
+ }
3184
+ });
3185
+ }
3186
+ /**
3187
+ * A URL for a dynamic route from values already known: a parent route's real URL, then the
3188
+ * config's params by name. Null, with the names still needed, when something is missing.
3189
+ */
3190
+ build(pattern) {
3191
+ const parts = [];
3192
+ const missing = [];
3193
+ pattern.segments.forEach((s, i) => {
3194
+ if (s.kind === "static") {
3195
+ parts.push(s.value);
3196
+ return;
3197
+ }
3198
+ const inherited = isPlainParam(s) ? this.seen.get(prefixOf(pattern.segments, i)) : void 0;
3199
+ if (inherited !== void 0) {
3200
+ parts.push(inherited);
3201
+ return;
3202
+ }
3203
+ const names = namesIn(s.label);
3204
+ const absent = names.filter((n) => this.params[n] === void 0);
3205
+ if (absent.length) {
3206
+ if (!s.optional) missing.push(...absent);
3207
+ return;
3208
+ }
3209
+ let value = s.label;
3210
+ for (const n of names)
3211
+ value = value.replace(/\[\[?(?:\.\.\.)?[^\]]+\]\]?/, () => this.params[n]);
3212
+ parts.push(value.replace(/^\/+|\/+$/g, ""));
3213
+ });
3214
+ return missing.length ? { missing: [...new Set(missing)] } : { path: `/${parts.join("/")}` };
3215
+ }
3216
+ /**
3217
+ * Gives the routes no link fitted a URL from known values, once the links have had their
3218
+ * chance. Returns how many were added; the rest stay waiting, with what they're missing.
3219
+ */
3220
+ fillWaiting() {
3221
+ let added = 0;
3222
+ for (const [key, pattern] of this.waiting) {
3223
+ const built = this.build(pattern);
3224
+ if ("missing" in built) {
3225
+ this.missing.set(key, built.missing);
3226
+ continue;
3227
+ }
3228
+ this.waiting.delete(key);
3229
+ this.missing.delete(key);
3230
+ this.add({ url: this.urlFor(built.path), route: pattern.path });
3231
+ added++;
3232
+ }
3233
+ return added;
3133
3234
  }
3134
3235
  /** Links from a scanned page: samples for dynamic routes, and new pages when crawling. */
3135
3236
  addLinks(found) {
3136
3237
  if (!this.baseUrl) return;
3137
3238
  const links = found.map((link) => this.linkPath(link)).filter((p) => p !== null && isCrawlable(p));
3138
- if (this.waiting.size) {
3239
+ if (this.patterns.size) {
3139
3240
  for (const link of links) {
3140
3241
  const route = routeFor(link, this.patterns.values());
3141
- if (route && this.waiting.has(route.path)) {
3242
+ if (!route?.dynamic) continue;
3243
+ this.learn(route, link);
3244
+ if (this.waiting.has(route.path)) {
3142
3245
  this.waiting.delete(route.path);
3143
3246
  this.add({ url: this.urlFor(link), route: route.path });
3144
3247
  }
@@ -4193,6 +4296,7 @@ var KNOWN_KEYS = /* @__PURE__ */ new Set([
4193
4296
  "include",
4194
4297
  "exclude",
4195
4298
  "samples",
4299
+ "params",
4196
4300
  "maxRoutes",
4197
4301
  "server",
4198
4302
  "widths",
@@ -4239,6 +4343,14 @@ function validateConfig(config) {
4239
4343
  );
4240
4344
  }
4241
4345
  }
4346
+ if (config.params !== void 0) {
4347
+ const ok = config.params && typeof config.params === "object" && !Array.isArray(config.params) && Object.values(config.params).every((v) => typeof v === "string" && v !== "");
4348
+ if (!ok) {
4349
+ throw new Error(
4350
+ `params must give dynamic route parameters a real value by name, like { "slug": "css-101" }`
4351
+ );
4352
+ }
4353
+ }
4242
4354
  if (config.states !== void 0 && typeof config.states !== "boolean" && !(Number.isInteger(config.states) && config.states >= 0)) {
4243
4355
  throw new Error("states must be true, false, or the most menus and dialogs to open per page");
4244
4356
  }
@@ -4885,13 +4997,12 @@ function printNotes(result, hasSavedLogin) {
4885
4997
  ` ${styleText4("yellow", "\u25CB")} ${plural22(login, "page needs", "pages need")} a login. ${how}`
4886
4998
  );
4887
4999
  }
4888
- if (skipped.length) {
5000
+ const hint = leftOutHint(skipped);
5001
+ if (hint) {
4889
5002
  console.log(
4890
- ` ${styleText4("dim", "\u2013")} ${plural22(skipped.length, "dynamic route", "dynamic routes")} had no link to follow. Give each a real URL with samples in your config, e.g.`
5003
+ ` ${styleText4("dim", "\u2013")} ${plural22(skipped.length, "dynamic route", "dynamic routes")} had no link to follow. Give them real values in your config (replace the 1s), e.g.`
4891
5004
  );
4892
- const first = skipped[0]?.route;
4893
- if (first)
4894
- console.log(styleText4("dim", ` samples: { '${first}': '${examplePath(first)}' }`));
5005
+ console.log(styleText4("dim", ` ${hint}`));
4895
5006
  }
4896
5007
  }
4897
5008
 
@@ -5103,6 +5214,9 @@ async function pool(items, size, work) {
5103
5214
  };
5104
5215
  await Promise.all(Array.from({ length: Math.min(size, items.length) }, worker));
5105
5216
  }
5217
+ function leftOutReason(missing) {
5218
+ return missing.length ? `No page links to it, and nothing gives ${missing.map((n) => `[${n}]`).join(", ")} a value.` : "No page links to it, so there was no real URL to try.";
5219
+ }
5106
5220
  async function drain(queue, size, work) {
5107
5221
  const active = /* @__PURE__ */ new Set();
5108
5222
  for (; ; ) {
@@ -5111,7 +5225,8 @@ async function drain(queue, size, work) {
5111
5225
  const p = work(job).finally(() => active.delete(p));
5112
5226
  active.add(p);
5113
5227
  }
5114
- if (!active.size) return;
5228
+ if (!active.size && !queue.fillWaiting()) return;
5229
+ if (!active.size) continue;
5115
5230
  await Promise.race(active);
5116
5231
  }
5117
5232
  }
@@ -5159,7 +5274,8 @@ async function scan(req) {
5159
5274
  app.baseUrl,
5160
5275
  config.samples ?? {},
5161
5276
  plan.crawl ? new Crawler(max2, (path) => isWanted(path, config)) : null,
5162
- plan.hashRouting
5277
+ plan.hashRouting,
5278
+ config.params ?? {}
5163
5279
  );
5164
5280
  for (const url of plan.urls) queue.add({ url, route: routeOf(url, cwd) });
5165
5281
  for (const p of plan.patterns) {
@@ -5307,7 +5423,7 @@ async function scan(req) {
5307
5423
  url: "",
5308
5424
  finalUrl: "",
5309
5425
  status: "skipped",
5310
- error: "No page links to it, so there was no real URL to try.",
5426
+ error: leftOutReason(queue.missing.get(pattern.path) ?? []),
5311
5427
  pageErrors: [],
5312
5428
  warnings: [],
5313
5429
  breakpoints: { observed: [], declared: [], unexplained: [] },
@@ -5906,8 +6022,8 @@ var TEMPLATE = (baseUrl, typed) => `${typed ? "import { defineConfig } from '@se
5906
6022
  // Every route is found automatically. To narrow it down:
5907
6023
  // include: ['/blog/**'],
5908
6024
  // exclude: ['/admin/**'],
5909
- // A real URL for dynamic routes that no page links to:
5910
- // samples: { '/products/[id]': '/products/42' },
6025
+ // Real values for dynamic route parameters no page links to, by name:
6026
+ // params: { slug: 'my-first-post', id: '42' },
5911
6027
 
5912
6028
  // Viewport widths to sweep, in CSS pixels.
5913
6029
  widths: { min: 320, max: 1920 },
@@ -7022,7 +7138,7 @@ Scan options
7022
7138
  --out <dir> Report folder (default .breakhound)
7023
7139
  --pdf Also write report.pdf
7024
7140
  --no-screenshots Skip issue screenshots
7025
- --no-states Don't open menus, dialogs and expandable sections
7141
+ --no-states Don't open menus, dialogs, tabs and expandable sections
7026
7142
  --browsers <list> Also check other engines, e.g. chromium,webkit,firefox
7027
7143
  --vision Also ask your own vision model what looks off (info only;
7028
7144
  uses the same model setup as fix)
package/dist/index.d.cts CHANGED
@@ -37,8 +37,9 @@ export interface IgnoreRule {
37
37
  /**
38
38
  * What a toggle opens. `menu`: navigation menus, drawers and dropdowns.
39
39
  * `disclosure`: anything else with aria-expanded, such as accordions.
40
+ * `tab`: a tab (role="tab") that isn't selected.
40
41
  */
41
- export type ToggleKind = "menu" | "dialog" | "popover" | "details" | "disclosure";
42
+ export type ToggleKind = "menu" | "dialog" | "popover" | "details" | "disclosure" | "tab";
42
43
  export interface WidthRange {
43
44
  from: number;
44
45
  to: number;
@@ -172,6 +173,12 @@ export interface BreakhoundConfig {
172
173
  exclude?: string[];
173
174
  /** A real URL for each dynamic route, e.g. { '/products/[id]': '/products/42' }. */
174
175
  samples?: Record<string, string>;
176
+ /**
177
+ * Values for dynamic route parameters by name, for routes no link on your pages fits,
178
+ * e.g. { slug: 'css-101', submissionId: '42' }. Every route using those names is filled in;
179
+ * a parent route's real URL fills its own parameters for the routes below it already.
180
+ */
181
+ params?: Record<string, string>;
175
182
  /** Most routes to scan in one run (default 100). */
176
183
  maxRoutes?: number;
177
184
  /** How to start the app when it isn't running, if not with its "dev" script. */
package/dist/index.d.ts CHANGED
@@ -37,8 +37,9 @@ export interface IgnoreRule {
37
37
  /**
38
38
  * What a toggle opens. `menu`: navigation menus, drawers and dropdowns.
39
39
  * `disclosure`: anything else with aria-expanded, such as accordions.
40
+ * `tab`: a tab (role="tab") that isn't selected.
40
41
  */
41
- export type ToggleKind = "menu" | "dialog" | "popover" | "details" | "disclosure";
42
+ export type ToggleKind = "menu" | "dialog" | "popover" | "details" | "disclosure" | "tab";
42
43
  export interface WidthRange {
43
44
  from: number;
44
45
  to: number;
@@ -172,6 +173,12 @@ export interface BreakhoundConfig {
172
173
  exclude?: string[];
173
174
  /** A real URL for each dynamic route, e.g. { '/products/[id]': '/products/42' }. */
174
175
  samples?: Record<string, string>;
176
+ /**
177
+ * Values for dynamic route parameters by name, for routes no link on your pages fits,
178
+ * e.g. { slug: 'css-101', submissionId: '42' }. Every route using those names is filled in;
179
+ * a parent route's real URL fills its own parameters for the routes below it already.
180
+ */
181
+ params?: Record<string, string>;
175
182
  /** Most routes to scan in one run (default 100). */
176
183
  maxRoutes?: number;
177
184
  /** How to start the app when it isn't running, if not with its "dev" script. */
package/dist/inject.js CHANGED
@@ -1804,6 +1804,7 @@
1804
1804
 
1805
1805
  // ../analyzers/src/states.ts
1806
1806
  var CANDIDATES = [
1807
+ '[role="tab"]',
1807
1808
  "[aria-expanded]",
1808
1809
  "[aria-haspopup]",
1809
1810
  "[popovertarget]",
@@ -1866,6 +1867,8 @@
1866
1867
  return details.open || details.querySelector(":scope > summary") !== el ? null : "details";
1867
1868
  }
1868
1869
  if (el.matches('input, select, textarea, [role="combobox"], [contenteditable]')) return null;
1870
+ if (el.getAttribute("role") === "tab")
1871
+ return el.getAttribute("aria-selected") === "true" ? null : "tab";
1869
1872
  if (el.hasAttribute("popovertarget")) {
1870
1873
  return el.getAttribute("popovertargetaction") === "hide" ? null : "popover";
1871
1874
  }
@@ -1894,10 +1897,18 @@
1894
1897
  const href = el instanceof HTMLAnchorElement ? el.getAttribute("href") : null;
1895
1898
  return !href || href.startsWith("#") || /^javascript:/i.test(href);
1896
1899
  }
1897
- function isShown(el) {
1900
+ function sidewaysScroller(el) {
1901
+ for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) {
1902
+ const { overflowX } = getComputedStyle(p);
1903
+ if ((overflowX === "auto" || overflowX === "scroll") && p.scrollWidth > p.clientWidth) return p;
1904
+ }
1905
+ return null;
1906
+ }
1907
+ function isShown(el, scrolls = false) {
1898
1908
  const r = el.getBoundingClientRect();
1899
1909
  if (r.width < 1 || r.height < 1) return false;
1900
- if (r.right <= 0 || r.left >= window.innerWidth) return false;
1910
+ if ((r.right <= 0 || r.left >= window.innerWidth) && !(scrolls && sidewaysScroller(el)))
1911
+ return false;
1901
1912
  const check = el.checkVisibility;
1902
1913
  if (check && !check.call(el, { opacityProperty: true, visibilityProperty: true })) return false;
1903
1914
  if (!check && getComputedStyle(el).visibility === "hidden") return false;
@@ -1927,6 +1938,7 @@
1927
1938
  menu: 0,
1928
1939
  dialog: 0,
1929
1940
  popover: 1,
1941
+ tab: 1,
1930
1942
  disclosure: 2,
1931
1943
  details: 3
1932
1944
  };
@@ -1937,7 +1949,7 @@
1937
1949
  if (out.length >= MAX_TOGGLES) break;
1938
1950
  if (el.closest(DEV_OVERLAYS)) continue;
1939
1951
  const kind = kindOf(el);
1940
- if (!kind || !safeToClick(el) || !isShown(el) || ignored(el, rules)) continue;
1952
+ if (!kind || !safeToClick(el) || !isShown(el, kind === "tab") || ignored(el, rules)) continue;
1941
1953
  const panel = targetOf(el, "aria-controls") ?? targetOf(el, "popovertarget");
1942
1954
  if (panel) {
1943
1955
  if (panels.has(panel)) continue;
@@ -1948,19 +1960,30 @@
1948
1960
  selector: cachedSelector(el),
1949
1961
  kind,
1950
1962
  label: shortLabel(el, kind),
1951
- group: groupOf(el, kind),
1963
+ // Each tab shows different content, unlike the questions of an FAQ.
1964
+ group: kind === "tab" ? `tab|${cachedSelector(el)}` : groupOf(el, kind),
1952
1965
  priority: PRIORITY[kind] * 2 + (inHeader ? 0 : 1)
1953
1966
  });
1954
1967
  }
1955
1968
  return out;
1956
1969
  }
1957
- function toggleState(selector) {
1970
+ var toggles = /* @__PURE__ */ new Map();
1971
+ function toggleOf(selector) {
1972
+ const known = toggles.get(selector);
1973
+ if (known?.isConnected) return known;
1958
1974
  const el = resolveSelector(selector);
1975
+ if (el) toggles.set(selector, el);
1976
+ return el;
1977
+ }
1978
+ function toggleState(selector) {
1979
+ const el = toggleOf(selector);
1959
1980
  if (!el) return { shown: false, open: null };
1960
- const shown = isShown(el);
1981
+ const shown = isShown(el, el.getAttribute("role") === "tab");
1961
1982
  if (el.matches("details > summary")) {
1962
1983
  return { shown, open: el.parentElement.open };
1963
1984
  }
1985
+ if (el.getAttribute("role") === "tab")
1986
+ return { shown, open: el.getAttribute("aria-selected") === "true" };
1964
1987
  const expanded = el.getAttribute("aria-expanded");
1965
1988
  if (expanded === "true" || expanded === "false") return { shown, open: expanded === "true" };
1966
1989
  const target = targetOf(el, "popovertarget") ?? targetOf(el, "commandfor");
@@ -1975,17 +1998,23 @@
1975
1998
  return { shown, open: null };
1976
1999
  }
1977
2000
  function setDetails(selector, open) {
1978
- const details = resolveSelector(selector)?.parentElement;
2001
+ const details = toggleOf(selector)?.parentElement;
1979
2002
  if (details instanceof HTMLDetailsElement) details.open = open;
1980
2003
  }
1981
2004
  function clickPoint(selector) {
1982
- const el = resolveSelector(selector);
2005
+ const el = toggleOf(selector);
1983
2006
  if (!el) return null;
1984
2007
  let r = el.getBoundingClientRect();
1985
2008
  if (r.top < 0 || r.bottom > window.innerHeight) {
1986
2009
  el.scrollIntoView({ block: "center", inline: "nearest" });
1987
2010
  r = el.getBoundingClientRect();
1988
2011
  }
2012
+ const row = r.left < 0 || r.right > window.innerWidth ? sidewaysScroller(el) : null;
2013
+ if (row) {
2014
+ const box = row.getBoundingClientRect();
2015
+ row.scrollLeft += r.left + r.width / 2 - (box.left + box.width / 2);
2016
+ r = el.getBoundingClientRect();
2017
+ }
1989
2018
  const x = Math.min(window.innerWidth - 1, Math.max(0, r.left + r.width / 2));
1990
2019
  const y = Math.min(window.innerHeight - 1, Math.max(0, r.top + r.height / 2));
1991
2020
  let hit = document.elementFromPoint(x, y);
@@ -2006,7 +2035,7 @@
2006
2035
  const seen = before;
2007
2036
  before = null;
2008
2037
  if (!seen) return null;
2009
- const trigger = resolveSelector(toggle);
2038
+ const trigger = toggleOf(toggle);
2010
2039
  let best = null;
2011
2040
  for (const el of document.body.querySelectorAll("*")) {
2012
2041
  if (seen.has(el) || trigger && (trigger === el || trigger.contains(el))) continue;
package/dist/schema.json CHANGED
@@ -38,6 +38,13 @@
38
38
  },
39
39
  "description": "A real URL for each dynamic route, e.g. { '/products/[id]': '/products/42' }."
40
40
  },
41
+ "params": {
42
+ "type": "object",
43
+ "additionalProperties": {
44
+ "type": "string"
45
+ },
46
+ "description": "Values for dynamic route parameters by name, for routes no link on your pages fits, e.g. { slug: 'css-101', submissionId: '42' }. Every route using those names is filled in; a parent route's real URL fills its own parameters for the routes below it already."
47
+ },
41
48
  "maxRoutes": {
42
49
  "type": "number",
43
50
  "description": "Most routes to scan in one run (default 100)."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seybel/breakhound",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Find where your layout breaks across every viewport width, then hand the fix to your coding agent.",
5
5
  "keywords": [
6
6
  "responsive",