@seybel/breakhound 1.0.3 → 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
@@ -128,15 +128,15 @@ Chromium finds most layout bugs, but Safari and Firefox lay some things out diff
128
128
 
129
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.
130
130
 
131
- ### Menus, dialogs and expandable sections
131
+ ### Menus, dialogs, tabs and expandable sections
132
132
 
133
- 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:
134
134
 
135
135
  ```
136
136
  ✖ 320–399px Page scrolls horizontally (80px wider than the viewport), with "Menu" open
137
137
  ```
138
138
 
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. 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.
140
140
 
141
141
  ### A second opinion from a vision model (optional)
142
142
 
@@ -159,7 +159,7 @@ The folder is for you, not your repo, so it keeps itself out of git: breakhound
159
159
 
160
160
  ## Chrome extension
161
161
 
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/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:
163
163
 
164
164
  - the issues, each with its exact width ranges, the element, the numbers and the likely fix
165
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
@@ -312,7 +312,7 @@ Running breakhound through `npx` without installing it, you get `breakhound.conf
312
312
  | `vision` | `true` to add a vision model's opinion, or `{ model, provider, widths }` |
313
313
  | `fix` | The model `breakhound fix` uses: `{ provider: 'ollama', model: 'qwen2.5-coder' }` |
314
314
  | `browsers` | Engines to check in: `["chromium", "webkit", "firefox"]` (default Chromium only) |
315
- | `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` |
316
316
  | `concurrency` | Browser tabs to use at once (default half your CPU cores, up to 4) |
317
317
  | `memoryLimit` | Most memory the dev server and browser may use together, e.g. `"6GB"`, `"50%"` or `"off"` (default 60% of your memory) |
318
318
 
@@ -351,7 +351,7 @@ breakhound mcp Run the MCP server (your agent starts it)
351
351
  --fail-on <level> Exit 1 at error, warning, info or none (default error)
352
352
  --pdf Also write report.pdf
353
353
  --no-screenshots Skip screenshots
354
- --no-states Don't open menus, dialogs and expandable sections
354
+ --no-states Don't open menus, dialogs, tabs and expandable sections
355
355
  --browsers <list> Also check other engines, e.g. chromium,webkit,firefox
356
356
  --vision Also ask your own vision model what looks off (info only)
357
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";
@@ -7136,7 +7138,7 @@ Scan options
7136
7138
  --out <dir> Report folder (default .breakhound)
7137
7139
  --pdf Also write report.pdf
7138
7140
  --no-screenshots Skip issue screenshots
7139
- --no-states Don't open menus, dialogs and expandable sections
7141
+ --no-states Don't open menus, dialogs, tabs and expandable sections
7140
7142
  --browsers <list> Also check other engines, e.g. chromium,webkit,firefox
7141
7143
  --vision Also ask your own vision model what looks off (info only;
7142
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;
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;
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seybel/breakhound",
3
- "version": "1.0.3",
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",