@seybel/breakhound 1.0.3 → 1.0.5
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 +8 -8
- package/dist/cli.js +6 -4
- package/dist/index.d.cts +3 -2
- package/dist/index.d.ts +3 -2
- package/dist/inject.js +38 -9
- package/dist/schema.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -78,10 +78,10 @@ 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). Routes below one reuse its values: once `/
|
|
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 `/users/jane` is found, `/users/[username]/settings` is scanned as `/users/jane/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
82
|
|
|
83
83
|
```ts
|
|
84
|
-
params: {
|
|
84
|
+
params: { username: 'jane', orderId: '1001' }
|
|
85
85
|
```
|
|
86
86
|
|
|
87
87
|
Or give one route its own URL, which wins over `params`:
|
|
@@ -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/
|
|
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";
|
|
@@ -3105,8 +3107,8 @@ var RouteQueue = class {
|
|
|
3105
3107
|
/** For routes left waiting at the end: the parameters nothing gave a value. */
|
|
3106
3108
|
missing = /* @__PURE__ */ new Map();
|
|
3107
3109
|
/**
|
|
3108
|
-
* Values parameters took in real URLs, under the route they belong to: `/
|
|
3109
|
-
* `
|
|
3110
|
+
* Values parameters took in real URLs, under the route they belong to: `/users/[username]` →
|
|
3111
|
+
* `jane`. Routes below it reuse them, since a child of a real page is real too.
|
|
3110
3112
|
*/
|
|
3111
3113
|
seen = /* @__PURE__ */ new Map();
|
|
3112
3114
|
patterns = /* @__PURE__ */ new Map();
|
|
@@ -4345,7 +4347,7 @@ function validateConfig(config) {
|
|
|
4345
4347
|
const ok = config.params && typeof config.params === "object" && !Array.isArray(config.params) && Object.values(config.params).every((v) => typeof v === "string" && v !== "");
|
|
4346
4348
|
if (!ok) {
|
|
4347
4349
|
throw new Error(
|
|
4348
|
-
`params must give dynamic route parameters a real value by name, like { "
|
|
4350
|
+
`params must give dynamic route parameters a real value by name, like { "username": "jane" }`
|
|
4349
4351
|
);
|
|
4350
4352
|
}
|
|
4351
4353
|
}
|
|
@@ -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;
|
|
@@ -174,7 +175,7 @@ export interface BreakhoundConfig {
|
|
|
174
175
|
samples?: Record<string, string>;
|
|
175
176
|
/**
|
|
176
177
|
* Values for dynamic route parameters by name, for routes no link on your pages fits,
|
|
177
|
-
* e.g. {
|
|
178
|
+
* e.g. { username: 'jane', orderId: '1001' }. Every route using those names is filled in;
|
|
178
179
|
* a parent route's real URL fills its own parameters for the routes below it already.
|
|
179
180
|
*/
|
|
180
181
|
params?: Record<string, string>;
|
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;
|
|
@@ -174,7 +175,7 @@ export interface BreakhoundConfig {
|
|
|
174
175
|
samples?: Record<string, string>;
|
|
175
176
|
/**
|
|
176
177
|
* Values for dynamic route parameters by name, for routes no link on your pages fits,
|
|
177
|
-
* e.g. {
|
|
178
|
+
* e.g. { username: 'jane', orderId: '1001' }. Every route using those names is filled in;
|
|
178
179
|
* a parent route's real URL fills its own parameters for the routes below it already.
|
|
179
180
|
*/
|
|
180
181
|
params?: Record<string, string>;
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"additionalProperties": {
|
|
44
44
|
"type": "string"
|
|
45
45
|
},
|
|
46
|
-
"description": "Values for dynamic route parameters by name, for routes no link on your pages fits, e.g. {
|
|
46
|
+
"description": "Values for dynamic route parameters by name, for routes no link on your pages fits, e.g. { username: 'jane', orderId: '1001' }. 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
47
|
},
|
|
48
48
|
"maxRoutes": {
|
|
49
49
|
"type": "number",
|