@seybel/breakhound 1.0.9 → 1.0.10
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/dist/cli.js +103 -22
- package/dist/index.d.cts +3 -1
- package/dist/index.d.ts +3 -1
- package/dist/inject.js +187 -6
- package/dist/schema.json +20 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -77,6 +77,7 @@ function findingKey(f) {
|
|
|
77
77
|
}
|
|
78
78
|
function severityScore(f) {
|
|
79
79
|
const e = f.evidence;
|
|
80
|
+
if (typeof e.dayWidthPx === "number") return -e.dayWidthPx;
|
|
80
81
|
if (typeof e.overflowPx === "number") return e.overflowPx;
|
|
81
82
|
if (typeof e.overlapWidth === "number" && typeof e.overlapHeight === "number") {
|
|
82
83
|
return e.overlapWidth * e.overlapHeight;
|
|
@@ -173,6 +174,9 @@ function buildIssues(route, widths, results) {
|
|
|
173
174
|
evidence = { ...evidence, fixedWidthPx: onlyWidth };
|
|
174
175
|
suggestion = `It stays ${onlyWidth}px wide no matter the screen size. Replace the fixed width with max-width: 100% (or a fluid width) so it can shrink.`;
|
|
175
176
|
}
|
|
177
|
+
if (f.detector === "calendar") {
|
|
178
|
+
suggestion = calendarSuggestion(ranges, widths[widths.length - 1] ?? 0, f.evidence);
|
|
179
|
+
}
|
|
176
180
|
const issue = {
|
|
177
181
|
id: issueId(route, f),
|
|
178
182
|
fingerprint: findingKey(f),
|
|
@@ -195,6 +199,18 @@ function buildIssues(route, widths, results) {
|
|
|
195
199
|
(a, b) => order[a.severity] - order[b.severity] || (a.ranges[0]?.from ?? 0) - (b.ranges[0]?.from ?? 0)
|
|
196
200
|
);
|
|
197
201
|
}
|
|
202
|
+
function calendarSuggestion(ranges, widest, evidence) {
|
|
203
|
+
const last = ranges[ranges.length - 1]?.to ?? widest;
|
|
204
|
+
const list = (where) => `show the days as a list ${where} (an agenda view, one day per row), the usual choice for calendars on phones`;
|
|
205
|
+
if (last >= widest) {
|
|
206
|
+
return `Its 7 days don't fit side by side at any width up to ${widest}px. Give the calendar more room or show less in each day, and ${list("on small screens")}.`;
|
|
207
|
+
}
|
|
208
|
+
const below = `below ${last + 1}px`;
|
|
209
|
+
if (evidence.scrollsSideways) {
|
|
210
|
+
return `It already scrolls sideways, but its days are still too narrow ${below}. Give the grid a min-width so each day keeps its room as it scrolls, or ${list(below)}. Narrower days, or fewer than 7 columns, won't fix it.`;
|
|
211
|
+
}
|
|
212
|
+
return `A calendar needs its 7 days side by side, and ${below} they don't fit. Narrower days, or fewer than 7 columns, won't fix it. Either let it scroll sideways inside its own box ${below} (a wrapper with overflow-x: auto, and a min-width on the grid), which keeps one layout; or ${list(below)}.`;
|
|
213
|
+
}
|
|
198
214
|
function joinRanges(ranges) {
|
|
199
215
|
const sorted = [...ranges].sort((a, b) => a.from - b.from);
|
|
200
216
|
const out = [];
|
|
@@ -918,6 +934,7 @@ var DETECTOR_IDS = [
|
|
|
918
934
|
"unexpected-wrap",
|
|
919
935
|
"clipped-text",
|
|
920
936
|
"squished-text",
|
|
937
|
+
"calendar",
|
|
921
938
|
"distorted-media",
|
|
922
939
|
"small-tap-target",
|
|
923
940
|
"tiny-text",
|
|
@@ -1272,6 +1289,7 @@ var DETECTOR_LABELS = {
|
|
|
1272
1289
|
"unexpected-wrap": "Unexpected wrap",
|
|
1273
1290
|
"clipped-text": "Clipped text",
|
|
1274
1291
|
"squished-text": "Squished text",
|
|
1292
|
+
calendar: "Calendar too narrow",
|
|
1275
1293
|
"distorted-media": "Distorted image",
|
|
1276
1294
|
"small-tap-target": "Small tap target",
|
|
1277
1295
|
"tiny-text": "Tiny text",
|
|
@@ -5317,12 +5335,34 @@ function enabledDetectors(config) {
|
|
|
5317
5335
|
if (!overrides) return void 0;
|
|
5318
5336
|
return DETECTOR_IDS.filter((id) => overrides[id] !== false);
|
|
5319
5337
|
}
|
|
5338
|
+
var SEVERITY_RANK = { error: 0, warning: 1, info: 2 };
|
|
5339
|
+
function calendarChecks(issue, overrides) {
|
|
5340
|
+
return String(issue.evidence.checksInside ?? "").split(" ").filter(Boolean).map((part) => {
|
|
5341
|
+
const [id = "", severity = "error"] = part.split(":");
|
|
5342
|
+
const o = overrides[id];
|
|
5343
|
+
return { id, severity: typeof o === "string" ? o : severity };
|
|
5344
|
+
}).filter((c) => overrides[c.id] !== false);
|
|
5345
|
+
}
|
|
5346
|
+
function keepIssue(issue, overrides) {
|
|
5347
|
+
if (overrides[issue.detector] === false) return false;
|
|
5348
|
+
if (issue.detector !== "calendar" || !issue.evidence.checksInside) return true;
|
|
5349
|
+
return calendarChecks(issue, overrides).length > 0;
|
|
5350
|
+
}
|
|
5320
5351
|
function applySeverity(routes, config) {
|
|
5321
5352
|
const overrides = config.detectors ?? {};
|
|
5322
5353
|
for (const route of routes) {
|
|
5323
5354
|
for (const issue of route.issues) {
|
|
5324
5355
|
const o = overrides[issue.detector];
|
|
5325
5356
|
if (o === "error" || o === "warning" || o === "info") issue.severity = o;
|
|
5357
|
+
else if (issue.detector === "calendar") {
|
|
5358
|
+
const checks = calendarChecks(issue, overrides);
|
|
5359
|
+
if (checks.length) {
|
|
5360
|
+
issue.severity = checks.reduce(
|
|
5361
|
+
(worst, c) => SEVERITY_RANK[c.severity] < SEVERITY_RANK[worst] ? c.severity : worst,
|
|
5362
|
+
"info"
|
|
5363
|
+
);
|
|
5364
|
+
}
|
|
5365
|
+
}
|
|
5326
5366
|
}
|
|
5327
5367
|
}
|
|
5328
5368
|
}
|
|
@@ -5600,7 +5640,7 @@ async function saveExtensionScan(sent, config, cwd) {
|
|
|
5600
5640
|
const off = config.detectors ?? {};
|
|
5601
5641
|
const routes = sent.routes.map((r) => ({
|
|
5602
5642
|
...r,
|
|
5603
|
-
issues: r.issues.filter((i) =>
|
|
5643
|
+
issues: r.issues.filter((i) => keepIssue(i, off))
|
|
5604
5644
|
}));
|
|
5605
5645
|
applySeverity(routes, config);
|
|
5606
5646
|
relativeSources(routes, project?.root ?? cwd, project?.basePath ?? "", project?.workspace);
|
|
@@ -5772,7 +5812,7 @@ async function runVerify(req) {
|
|
|
5772
5812
|
root: req.cwd
|
|
5773
5813
|
});
|
|
5774
5814
|
const off = req.config.detectors ?? {};
|
|
5775
|
-
for (const r of now.routes) r.issues = r.issues.filter((i) =>
|
|
5815
|
+
for (const r of now.routes) r.issues = r.issues.filter((i) => keepIssue(i, off));
|
|
5776
5816
|
return finishVerify(req, baseline, now);
|
|
5777
5817
|
}
|
|
5778
5818
|
const { settings } = baseline;
|
|
@@ -6294,6 +6334,12 @@ function checkRoutes(value) {
|
|
|
6294
6334
|
}
|
|
6295
6335
|
for (const i of r.issues) {
|
|
6296
6336
|
const element = isObject(i) ? i.element : null;
|
|
6337
|
+
if (isObject(i) && typeof i.detector === "string" && !DETECTOR_IDS.includes(i.detector)) {
|
|
6338
|
+
throw new HttpError(
|
|
6339
|
+
400,
|
|
6340
|
+
`This project's breakhound doesn't know the check "${i.detector.slice(0, 40)}" yet. Restart your agent with the latest breakhound to send it.`
|
|
6341
|
+
);
|
|
6342
|
+
}
|
|
6297
6343
|
if (!isObject(i) || typeof i.id !== "string" || typeof i.title !== "string" || !DETECTOR_IDS.includes(i.detector) || !SEVERITIES.has(String(i.severity)) || !Array.isArray(i.ranges) || !isObject(element) || typeof element.selector !== "string") {
|
|
6298
6344
|
throw new HttpError(400, "Bad issue");
|
|
6299
6345
|
}
|
|
@@ -6349,7 +6395,9 @@ async function startBridge(opts) {
|
|
|
6349
6395
|
name: "breakhound",
|
|
6350
6396
|
version: opts.version,
|
|
6351
6397
|
root: opts.root,
|
|
6352
|
-
project: projectName(opts.root)
|
|
6398
|
+
project: projectName(opts.root),
|
|
6399
|
+
// So a newer extension sends only checks this version knows.
|
|
6400
|
+
detectors: DETECTOR_IDS
|
|
6353
6401
|
});
|
|
6354
6402
|
case "/breakhound/match": {
|
|
6355
6403
|
if (typeof body.url !== "string") throw new HttpError(400, "No url");
|
|
@@ -6430,7 +6478,14 @@ async function startBridge(opts) {
|
|
|
6430
6478
|
checks.delete(check.id);
|
|
6431
6479
|
clearTimeout(check.timer);
|
|
6432
6480
|
if (typeof body.error === "string") check.reject(new Error(body.error.slice(0, 500)));
|
|
6433
|
-
else
|
|
6481
|
+
else {
|
|
6482
|
+
try {
|
|
6483
|
+
check.resolve(checkRoutes(body.routes));
|
|
6484
|
+
} catch (error) {
|
|
6485
|
+
check.reject(error instanceof Error ? error : new Error(String(error)));
|
|
6486
|
+
throw error;
|
|
6487
|
+
}
|
|
6488
|
+
}
|
|
6434
6489
|
return reply({});
|
|
6435
6490
|
}
|
|
6436
6491
|
default:
|
|
@@ -6543,7 +6598,7 @@ Before finishing, run verify with no routes to check every page.
|
|
|
6543
6598
|
|
|
6544
6599
|
scan and verify answer within about 45 seconds. When one says it's still running, it carries on: call it again with the same arguments to get its result, rather than something else. It says what it's waiting for; if that's the developer's browser tab, ask them to bring it into view.
|
|
6545
6600
|
|
|
6546
|
-
Issues sent from the extension are checked again in the developer's own browser: verify asks the open breakhound panel to scan the pages, so their login and browser carry over. If no panel is open, verify says so; ask the developer to open it rather than calling scan, which replaces their issues with a headless scan.`;
|
|
6601
|
+
Issues sent from the extension are checked again in the developer's own browser: verify asks the open breakhound panel to scan the pages, so their login and browser carry over. If no panel is open, verify says so; ask the developer to open it rather than calling scan, which replaces their issues with a headless scan. Chrome doesn't draw a tab that's out of view, so before you first verify them, tell the developer in one line that breakhound is about to check your fixes in their Chrome tab, and that the tab needs to be in view (beside their editor is fine) until it's done.`;
|
|
6547
6602
|
var FIX_PROMPT = `Fix the responsive layout issues breakhound found in this project.
|
|
6548
6603
|
|
|
6549
6604
|
Start with list_issues: if I sent them from the breakhound extension they are already there, so don't scan again. Only scan when there are none.
|
|
@@ -6698,7 +6753,7 @@ function createTools() {
|
|
|
6698
6753
|
{
|
|
6699
6754
|
name: "verify",
|
|
6700
6755
|
kind: "long",
|
|
6701
|
-
description: "Scan routes again and compare with the baseline from scan: which issues are fixed, which are still there, and which are new (a change broke something, maybe at another width or on another route). Pass the routes you changed for a quick check, and nothing to check every route before you finish.",
|
|
6756
|
+
description: "Scan routes again and compare with the baseline from scan: which issues are fixed, which are still there, and which are new (a change broke something, maybe at another width or on another route). Pass the routes you changed for a quick check, and nothing to check every route before you finish. Issues sent from the browser extension are checked in the developer's Chrome tab, which must be in view: tell them before you first call it.",
|
|
6702
6757
|
inputSchema: {
|
|
6703
6758
|
type: "object",
|
|
6704
6759
|
properties: {
|
|
@@ -7124,11 +7179,16 @@ import { createInterface as createInterface3 } from "readline/promises";
|
|
|
7124
7179
|
import { styleText as styleText8 } from "util";
|
|
7125
7180
|
var SERVER_NAME = "breakhound";
|
|
7126
7181
|
var PACKAGE = "@seybel/breakhound";
|
|
7182
|
+
var LATEST = `${PACKAGE}@latest`;
|
|
7127
7183
|
function startsOtherPackage(args) {
|
|
7128
|
-
return Array.isArray(args) && args.includes("breakhound") && !args.
|
|
7184
|
+
return Array.isArray(args) && args.includes("breakhound") && !args.some((a) => typeof a === "string" && a.startsWith(PACKAGE));
|
|
7185
|
+
}
|
|
7186
|
+
function wroteUntagged(args) {
|
|
7187
|
+
return Array.isArray(args) && args.includes(PACKAGE) && args.includes("mcp");
|
|
7129
7188
|
}
|
|
7189
|
+
var toLatest = (args) => args.map((a) => a === PACKAGE ? LATEST : a);
|
|
7130
7190
|
function serverCommand(platform = process.platform) {
|
|
7131
|
-
const args = ["-y",
|
|
7191
|
+
const args = ["-y", LATEST, "mcp"];
|
|
7132
7192
|
return platform === "win32" ? { command: "cmd", args: ["/c", "npx", ...args] } : { command: "npx", args };
|
|
7133
7193
|
}
|
|
7134
7194
|
function jsonClient(id, name, file, key, entry) {
|
|
@@ -7149,8 +7209,11 @@ ${JSON.stringify({ [SERVER_NAME]: entry }, null, 2)}`
|
|
|
7149
7209
|
}
|
|
7150
7210
|
}
|
|
7151
7211
|
const existing = data[key]?.[SERVER_NAME];
|
|
7152
|
-
if (existing && !startsOtherPackage(existing.args))
|
|
7153
|
-
|
|
7212
|
+
if (existing && !startsOtherPackage(existing.args) && !wroteUntagged(existing.args)) {
|
|
7213
|
+
return null;
|
|
7214
|
+
}
|
|
7215
|
+
const next = existing && wroteUntagged(existing.args) ? { ...existing, args: toLatest(existing.args) } : entry;
|
|
7216
|
+
data[key] = { ...data[key], [SERVER_NAME]: next };
|
|
7154
7217
|
return `${JSON.stringify(data, null, 2)}
|
|
7155
7218
|
`;
|
|
7156
7219
|
}
|
|
@@ -7170,6 +7233,9 @@ args = ${JSON.stringify(args).replaceAll(",", ", ")}
|
|
|
7170
7233
|
const table = /^\[mcp_servers\.breakhound\]\n(?:(?!\[)[^\n]*\n?)*/m;
|
|
7171
7234
|
const found = current?.match(table)?.[0];
|
|
7172
7235
|
if (found) {
|
|
7236
|
+
if (found.includes(`"${PACKAGE}"`)) {
|
|
7237
|
+
return current.replace(table, found.replace(`"${PACKAGE}"`, `"${LATEST}"`));
|
|
7238
|
+
}
|
|
7173
7239
|
if (found.includes(PACKAGE) || !/["']breakhound["']/.test(found)) return null;
|
|
7174
7240
|
return current.replace(table, `${block}${found.endsWith("\n\n") ? "\n" : ""}`);
|
|
7175
7241
|
}
|
|
@@ -7256,7 +7322,10 @@ async function installMcp(cwd, options = {}) {
|
|
|
7256
7322
|
console.log(
|
|
7257
7323
|
`${styleText8("green", "\u2714")} ${client.name}: already set up in ${show(client.file, cwd)}`
|
|
7258
7324
|
);
|
|
7259
|
-
} else
|
|
7325
|
+
} else {
|
|
7326
|
+
const updating2 = !!current && /["']@?(?:seybel\/)?breakhound["']/.test(current);
|
|
7327
|
+
changes.push({ client, next, updating: updating2 });
|
|
7328
|
+
}
|
|
7260
7329
|
} catch (error) {
|
|
7261
7330
|
console.log(`${styleText8("yellow", "!")} ${client.name}: ${error.message}`);
|
|
7262
7331
|
}
|
|
@@ -7266,10 +7335,20 @@ async function installMcp(cwd, options = {}) {
|
|
|
7266
7335
|
${NOT_SHOWING_UP}`);
|
|
7267
7336
|
return 0;
|
|
7268
7337
|
}
|
|
7269
|
-
console.log("\nbreakhound will add its MCP server to:\n");
|
|
7270
7338
|
const { command, args } = serverCommand();
|
|
7271
|
-
|
|
7272
|
-
|
|
7339
|
+
const adding = changes.filter((c) => !c.updating);
|
|
7340
|
+
const updating = changes.filter((c) => c.updating);
|
|
7341
|
+
if (adding.length) {
|
|
7342
|
+
console.log("\nbreakhound will add its MCP server to:\n");
|
|
7343
|
+
for (const { client } of adding)
|
|
7344
|
+
console.log(` ${client.name.padEnd(12)} ${show(client.file, cwd)}`);
|
|
7345
|
+
}
|
|
7346
|
+
if (updating.length) {
|
|
7347
|
+
console.log(
|
|
7348
|
+
"\nbreakhound will update its MCP server, so your agent always starts the latest, in:\n"
|
|
7349
|
+
);
|
|
7350
|
+
for (const { client } of updating)
|
|
7351
|
+
console.log(` ${client.name.padEnd(12)} ${show(client.file, cwd)}`);
|
|
7273
7352
|
}
|
|
7274
7353
|
console.log(styleText8("dim", `
|
|
7275
7354
|
The server runs: ${command} ${args.join(" ")}
|
|
@@ -7280,17 +7359,19 @@ ${NOT_SHOWING_UP}`);
|
|
|
7280
7359
|
return 1;
|
|
7281
7360
|
}
|
|
7282
7361
|
const rl = createInterface3({ input: process.stdin, output: process.stdout });
|
|
7283
|
-
const answer = (await rl.question("Add it? [Y/n] ")).trim().toLowerCase();
|
|
7362
|
+
const answer = (await rl.question(adding.length ? "Add it? [Y/n] " : "Update it? [Y/n] ")).trim().toLowerCase();
|
|
7284
7363
|
rl.close();
|
|
7285
7364
|
if (answer && answer !== "y" && answer !== "yes") {
|
|
7286
7365
|
console.log("Nothing changed.");
|
|
7287
7366
|
return 0;
|
|
7288
7367
|
}
|
|
7289
7368
|
}
|
|
7290
|
-
for (const { client, next } of changes) {
|
|
7369
|
+
for (const { client, next, updating: updating2 } of changes) {
|
|
7291
7370
|
mkdirSync3(dirname5(client.file), { recursive: true });
|
|
7292
7371
|
writeFileSync8(client.file, next);
|
|
7293
|
-
console.log(
|
|
7372
|
+
console.log(
|
|
7373
|
+
`${styleText8("green", "\u2714")} ${client.name}: ${updating2 ? "updated in" : "added to"} ${show(client.file, cwd)}`
|
|
7374
|
+
);
|
|
7294
7375
|
}
|
|
7295
7376
|
console.log(
|
|
7296
7377
|
`
|
|
@@ -7401,7 +7482,7 @@ function normalizeBaseUrl(url) {
|
|
|
7401
7482
|
throw new Error(`--url must be a URL like http://localhost:3000 (got "${url}")`);
|
|
7402
7483
|
}
|
|
7403
7484
|
}
|
|
7404
|
-
var
|
|
7485
|
+
var SEVERITY_RANK2 = { error: 0, warning: 1, info: 2 };
|
|
7405
7486
|
function positiveInt(name, value) {
|
|
7406
7487
|
if (value === void 0) return void 0;
|
|
7407
7488
|
const n = Number(value);
|
|
@@ -7458,7 +7539,7 @@ async function main(argv) {
|
|
|
7458
7539
|
const cwd = process.cwd();
|
|
7459
7540
|
const [command, ...rest2] = positionals;
|
|
7460
7541
|
const askedFailOn = values["fail-on"];
|
|
7461
|
-
if (askedFailOn !== void 0 && askedFailOn !== "none" && !Object.hasOwn(
|
|
7542
|
+
if (askedFailOn !== void 0 && askedFailOn !== "none" && !Object.hasOwn(SEVERITY_RANK2, askedFailOn)) {
|
|
7462
7543
|
throw new Error("--fail-on must be one of: error, warning, info, none");
|
|
7463
7544
|
}
|
|
7464
7545
|
if (command === "init") {
|
|
@@ -7610,7 +7691,7 @@ ${formatVerify(v, severities.length ? severities : severitiesAtOrAbove("info"))}
|
|
|
7610
7691
|
const concurrency = positiveInt("concurrency", values.concurrency);
|
|
7611
7692
|
const maxRoutes = positiveInt("max-routes", values["max-routes"]);
|
|
7612
7693
|
const failOn = values["fail-on"] ?? fileConfig.failOn ?? "error";
|
|
7613
|
-
if (failOn !== "none" && !Object.hasOwn(
|
|
7694
|
+
if (failOn !== "none" && !Object.hasOwn(SEVERITY_RANK2, failOn)) {
|
|
7614
7695
|
throw new Error("--fail-on must be one of: error, warning, info, none");
|
|
7615
7696
|
}
|
|
7616
7697
|
const browsers = values.browsers?.split(",").map((b) => b.trim().toLowerCase()).filter(Boolean);
|
|
@@ -7648,9 +7729,9 @@ ${formatVerify(v, severities.length ? severities : severitiesAtOrAbove("info"))}
|
|
|
7648
7729
|
}
|
|
7649
7730
|
if (result.routes.some((r) => FAILED_STATUSES.includes(r.status))) return 2;
|
|
7650
7731
|
if (failOn === "none") return 0;
|
|
7651
|
-
const threshold =
|
|
7732
|
+
const threshold = SEVERITY_RANK2[failOn];
|
|
7652
7733
|
const failing = result.routes.some(
|
|
7653
|
-
(r) => r.issues.some((i) =>
|
|
7734
|
+
(r) => r.issues.some((i) => SEVERITY_RANK2[i.severity] <= threshold)
|
|
7654
7735
|
);
|
|
7655
7736
|
return failing ? 1 : 0;
|
|
7656
7737
|
}
|
package/dist/index.d.cts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export type Severity = "error" | "warning" | "info";
|
|
2
|
-
export type DetectorId = "page-overflow" | "content-overflow" | "media-overflow" | "overlap" | "unexpected-wrap" | "clipped-text" | "squished-text"
|
|
2
|
+
export type DetectorId = "page-overflow" | "content-overflow" | "media-overflow" | "overlap" | "unexpected-wrap" | "clipped-text" | "squished-text"
|
|
3
|
+
/** What breaks inside a month or week calendar, reported once as the calendar. */
|
|
4
|
+
| "calendar" | "distorted-media" | "small-tap-target" | "tiny-text" | "oversized-text" | "edge-gutter" | "viewport-meta"
|
|
3
5
|
/** Opt-in: what a vision model noticed in screenshots. Never run in the page. */
|
|
4
6
|
| "vision";
|
|
5
7
|
/** Where an element comes from in the project's code. */
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export type Severity = "error" | "warning" | "info";
|
|
2
|
-
export type DetectorId = "page-overflow" | "content-overflow" | "media-overflow" | "overlap" | "unexpected-wrap" | "clipped-text" | "squished-text"
|
|
2
|
+
export type DetectorId = "page-overflow" | "content-overflow" | "media-overflow" | "overlap" | "unexpected-wrap" | "clipped-text" | "squished-text"
|
|
3
|
+
/** What breaks inside a month or week calendar, reported once as the calendar. */
|
|
4
|
+
| "calendar" | "distorted-media" | "small-tap-target" | "tiny-text" | "oversized-text" | "edge-gutter" | "viewport-meta"
|
|
3
5
|
/** Opt-in: what a vision model noticed in screenshots. Never run in the page. */
|
|
4
6
|
| "vision";
|
|
5
7
|
/** Where an element comes from in the project's code. */
|
package/dist/inject.js
CHANGED
|
@@ -559,6 +559,169 @@
|
|
|
559
559
|
return info;
|
|
560
560
|
}
|
|
561
561
|
|
|
562
|
+
// ../analyzers/src/detectors/calendar.ts
|
|
563
|
+
var FOLDED = /* @__PURE__ */ new Set([
|
|
564
|
+
"page-overflow",
|
|
565
|
+
"content-overflow",
|
|
566
|
+
"media-overflow",
|
|
567
|
+
"overlap",
|
|
568
|
+
"clipped-text",
|
|
569
|
+
"squished-text"
|
|
570
|
+
]);
|
|
571
|
+
var EVENT_CELL_HEIGHT = 56;
|
|
572
|
+
var ALIGN_PX = 2;
|
|
573
|
+
var MAX_LEVELS = 10;
|
|
574
|
+
var ALIASES = { weds: "wednesday" };
|
|
575
|
+
var normalize = (text) => text.replace(/\s+/g, " ").trim().toLowerCase().replace(/[.,:]$/, "");
|
|
576
|
+
var names = null;
|
|
577
|
+
function weekNames() {
|
|
578
|
+
const langs = [document.documentElement.lang, navigator.language, "en"].filter(Boolean);
|
|
579
|
+
const key = langs.join("|");
|
|
580
|
+
if (names?.key === key) return names.weeks;
|
|
581
|
+
const weeks = [];
|
|
582
|
+
for (const lang of new Set(langs)) {
|
|
583
|
+
try {
|
|
584
|
+
const format = (weekday) => new Intl.DateTimeFormat(lang, { weekday, timeZone: "UTC" });
|
|
585
|
+
const days = Array.from({ length: 7 }, (_, i) => new Date(Date.UTC(2024, 0, 1 + i)));
|
|
586
|
+
const long = days.map((d) => normalize(format("long").format(d)));
|
|
587
|
+
const exact = days.map(
|
|
588
|
+
(d, i) => /* @__PURE__ */ new Set([
|
|
589
|
+
long[i],
|
|
590
|
+
normalize(format("short").format(d)),
|
|
591
|
+
normalize(format("narrow").format(d))
|
|
592
|
+
])
|
|
593
|
+
);
|
|
594
|
+
weeks.push({ long, exact });
|
|
595
|
+
} catch {
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
names = { key, weeks };
|
|
599
|
+
return weeks;
|
|
600
|
+
}
|
|
601
|
+
function isDay(text, week, day) {
|
|
602
|
+
if (week.exact[day]?.has(text) || ALIASES[text] === week.long[day]) return true;
|
|
603
|
+
return text.length >= 2 && (week.long[day]?.startsWith(text) ?? false);
|
|
604
|
+
}
|
|
605
|
+
function isWeek(texts) {
|
|
606
|
+
if (texts.length !== 7 || texts.some((t) => !t)) return false;
|
|
607
|
+
return weekNames().some(
|
|
608
|
+
(week) => Array.from({ length: 7 }, (_, start) => start).some(
|
|
609
|
+
(start) => texts.every((t, i) => isDay(t, week, (start + i) % 7))
|
|
610
|
+
)
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
var lined = (a, b) => Math.abs(a.rect.left - b.rect.left) <= ALIGN_PX && Math.abs(a.rect.width - b.rect.width) <= ALIGN_PX;
|
|
614
|
+
function dayCells(snap, scope, weekdays) {
|
|
615
|
+
const header = new Set(weekdays.map((w) => w.el));
|
|
616
|
+
const out = [];
|
|
617
|
+
for (const el of scope.querySelectorAll("*")) {
|
|
618
|
+
if (header.has(el) || weekdays.some((w) => w.el.contains(el))) continue;
|
|
619
|
+
const rec = snap.byElement.get(el);
|
|
620
|
+
if (!rec || rec.rect.height < 1) continue;
|
|
621
|
+
if (out.some((o) => o.el.contains(el))) continue;
|
|
622
|
+
if (weekdays.some((w) => lined(w, rec)) && rec.rect.top >= (weekdays[0]?.rect.bottom ?? 0) - 1)
|
|
623
|
+
out.push(rec);
|
|
624
|
+
}
|
|
625
|
+
return out;
|
|
626
|
+
}
|
|
627
|
+
function holdsEvents(days) {
|
|
628
|
+
const heights = days.map((d) => d.rect.height).sort((a, b) => a - b);
|
|
629
|
+
return (heights[Math.floor(heights.length / 2)] ?? 0) >= EVENT_CELL_HEIGHT;
|
|
630
|
+
}
|
|
631
|
+
function shownText(el) {
|
|
632
|
+
const text = el instanceof HTMLElement ? el.innerText : el.textContent;
|
|
633
|
+
return normalize((text ?? "").replace(/\d+/g, " "));
|
|
634
|
+
}
|
|
635
|
+
function weekRow(snap, cells) {
|
|
636
|
+
for (let start = 0; start <= 1 && start + 7 <= cells.length; start++) {
|
|
637
|
+
const row = cells.slice(start, start + 7).map((c) => snap.byElement.get(c)).filter((r) => !!r && r.rect.width > 0);
|
|
638
|
+
if (row.length !== 7 || !isWeek(row.map((r) => shownText(r.el)))) continue;
|
|
639
|
+
const tops = row.map((r) => Math.round(r.rect.top));
|
|
640
|
+
if (Math.max(...tops) - Math.min(...tops) <= ALIGN_PX) return row;
|
|
641
|
+
}
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
function scrollsSideways(snap, el) {
|
|
645
|
+
for (let p = el; p && p !== document.body; p = composedParent(p)) {
|
|
646
|
+
const overflow = snap.byElement.get(p)?.style.overflowX;
|
|
647
|
+
if (overflow === "auto" || overflow === "scroll") return true;
|
|
648
|
+
}
|
|
649
|
+
return false;
|
|
650
|
+
}
|
|
651
|
+
function findCalendars(snap) {
|
|
652
|
+
const found = [];
|
|
653
|
+
for (const rec of snap.records) {
|
|
654
|
+
const children = renderedChildren(rec.el);
|
|
655
|
+
if (children.length < 7) continue;
|
|
656
|
+
if (found.some((c) => c.root.contains(rec.el))) continue;
|
|
657
|
+
const weekdays = weekRow(snap, children);
|
|
658
|
+
if (!weekdays) continue;
|
|
659
|
+
let scope = rec.el;
|
|
660
|
+
for (let level = 0; scope && level < MAX_LEVELS; level++, scope = composedParent(scope)) {
|
|
661
|
+
if (scope === document.body) break;
|
|
662
|
+
const days = dayCells(snap, scope, weekdays);
|
|
663
|
+
const columns = new Set(days.map((d) => weekdays.findIndex((w) => lined(w, d))));
|
|
664
|
+
if (days.length < 7 || columns.size < 7) continue;
|
|
665
|
+
if (holdsEvents(days)) {
|
|
666
|
+
found.push({ root: scope, weekdays, days, scrolls: scrollsSideways(snap, scope) });
|
|
667
|
+
}
|
|
668
|
+
break;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
return found;
|
|
672
|
+
}
|
|
673
|
+
var RANK = { error: 0, warning: 1, info: 2 };
|
|
674
|
+
function inside(root, el) {
|
|
675
|
+
for (let p = el ?? null; p; p = composedParent(p)) if (p === root) return true;
|
|
676
|
+
return false;
|
|
677
|
+
}
|
|
678
|
+
function foldCalendars(snap, findings, keep) {
|
|
679
|
+
const calendars = findCalendars(snap).filter((c) => keep(c.root));
|
|
680
|
+
if (!calendars.length) return findings;
|
|
681
|
+
const folded = /* @__PURE__ */ new Map();
|
|
682
|
+
const rest = [];
|
|
683
|
+
for (const f of findings) {
|
|
684
|
+
const calendar = FOLDED.has(f.detector) ? calendars.find(
|
|
685
|
+
(c) => inside(c.root, elementOf(f.element)) && (!f.related || inside(c.root, elementOf(f.related)))
|
|
686
|
+
) : void 0;
|
|
687
|
+
if (!calendar) {
|
|
688
|
+
rest.push(f);
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
folded.set(calendar, [...folded.get(calendar) ?? [], f]);
|
|
692
|
+
}
|
|
693
|
+
for (const [calendar, list] of folded) {
|
|
694
|
+
const severity = list.reduce(
|
|
695
|
+
(worst, f) => RANK[f.severity] < RANK[worst] ? f.severity : worst,
|
|
696
|
+
"info"
|
|
697
|
+
);
|
|
698
|
+
const dayWidth = Math.round(Math.min(...calendar.weekdays.map((w) => w.rect.width)) * 10) / 10;
|
|
699
|
+
const kinds = [...new Map(list.map((f) => [f.detector, f])).values()];
|
|
700
|
+
const checks = /* @__PURE__ */ new Map();
|
|
701
|
+
for (const f of list) {
|
|
702
|
+
const was = checks.get(f.detector);
|
|
703
|
+
if (!was || RANK[f.severity] < RANK[was]) checks.set(f.detector, f.severity);
|
|
704
|
+
}
|
|
705
|
+
rest.push({
|
|
706
|
+
detector: "calendar",
|
|
707
|
+
severity,
|
|
708
|
+
title: "Calendar's 7 days don't fit side by side",
|
|
709
|
+
element: describe(calendar.root),
|
|
710
|
+
evidence: {
|
|
711
|
+
dayWidthPx: dayWidth,
|
|
712
|
+
elementsInside: new Set(list.map((f) => f.element.selector)).size,
|
|
713
|
+
found: kinds.map((f) => `${f.title} at ${f.element.selector}`).join("; "),
|
|
714
|
+
checksInside: [...checks].map(([id, s]) => `${id}:${s}`).join(" "),
|
|
715
|
+
...calendar.scrolls ? { scrollsSideways: true } : {}
|
|
716
|
+
},
|
|
717
|
+
styles: {},
|
|
718
|
+
suggestion: "A calendar needs its 7 days side by side, so narrower days or fewer columns won't fix it. Let it scroll sideways inside its own box, or show the days as a list (an agenda view) on small screens.",
|
|
719
|
+
lineage: lineage(calendar.root)
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
return rest;
|
|
723
|
+
}
|
|
724
|
+
|
|
562
725
|
// ../analyzers/src/detectors/page-overflow.ts
|
|
563
726
|
var TOLERANCE = 0.5;
|
|
564
727
|
var SIDEWAYS_CONTENT = /* @__PURE__ */ new Set(["TABLE", "PRE", "CODE"]);
|
|
@@ -1117,6 +1280,13 @@
|
|
|
1117
1280
|
function name(rec) {
|
|
1118
1281
|
return `\`${describe(rec.el).selector}\``;
|
|
1119
1282
|
}
|
|
1283
|
+
function itemsFit(grid) {
|
|
1284
|
+
const { left, right } = grid.rect;
|
|
1285
|
+
return Array.from(grid.el.children).every((child) => {
|
|
1286
|
+
const r = child.getBoundingClientRect();
|
|
1287
|
+
return r.width === 0 || r.left >= left - 1 && r.right <= right + 1;
|
|
1288
|
+
});
|
|
1289
|
+
}
|
|
1120
1290
|
function suggestionFor(g) {
|
|
1121
1291
|
if (g.spill?.direction === "down") {
|
|
1122
1292
|
return `The content of ${name(g.spill.box)} is taller than its box at this width and runs down over what comes next. Its height is fixed (height or max-height) and the content no longer fits once it wraps; use min-height instead, or let it grow with its content.`;
|
|
@@ -1130,7 +1300,10 @@
|
|
|
1130
1300
|
return `They sit side by side in the flex row ${name(lca)}, which runs out of room at this width. Let the row wrap (flex-wrap: wrap), let the item that should give way shrink (min-width: 0), or stack them below this width with a media query.`;
|
|
1131
1301
|
}
|
|
1132
1302
|
if (lca && display.includes("grid")) {
|
|
1133
|
-
|
|
1303
|
+
if (!itemsFit(lca)) {
|
|
1304
|
+
return `They share the grid ${name(lca)}, whose columns are too narrow here for what is in them. Use minmax(0, 1fr) columns so items can shrink, or switch to fewer columns below this width.`;
|
|
1305
|
+
}
|
|
1306
|
+
return `They share the grid ${name(lca)}, whose columns have already shrunk as far as they go and are too narrow here for what is in them. Switch to fewer columns below this width.`;
|
|
1134
1307
|
}
|
|
1135
1308
|
return "These two areas stop making room for each other at this width. Stack them earlier with a media query, or let the narrower one shrink (min-width: 0).";
|
|
1136
1309
|
}
|
|
@@ -1735,10 +1908,10 @@
|
|
|
1735
1908
|
for (const rec of snap.records) {
|
|
1736
1909
|
const s = rec.style;
|
|
1737
1910
|
if (!s.containerType || s.containerType === "normal") continue;
|
|
1738
|
-
const
|
|
1911
|
+
const names2 = s.containerName.split(/\s+/);
|
|
1739
1912
|
const size = rec.rect.width - (Number.parseFloat(s.paddingLeft) || 0) - (Number.parseFloat(s.paddingRight) || 0) - (Number.parseFloat(s.borderLeftWidth) || 0) - (Number.parseFloat(s.borderRightWidth) || 0);
|
|
1740
1913
|
for (const q of queries) {
|
|
1741
|
-
if (q.name && !
|
|
1914
|
+
if (q.name && !names2.includes(q.name)) continue;
|
|
1742
1915
|
const unit = q.unit === "px" ? 1 : q.unit === "em" ? Number.parseFloat(s.fontSize) || 16 : rootFont;
|
|
1743
1916
|
const threshold = q.value * unit;
|
|
1744
1917
|
bits += (q.after ? size > threshold : size >= threshold) ? "1" : "0";
|
|
@@ -1854,12 +2027,12 @@
|
|
|
1854
2027
|
}
|
|
1855
2028
|
}
|
|
1856
2029
|
function looksLikeMenu(el) {
|
|
1857
|
-
const
|
|
2030
|
+
const names2 = [
|
|
1858
2031
|
typeof el.className === "string" ? el.className : "",
|
|
1859
2032
|
el.id,
|
|
1860
2033
|
el.getAttribute("data-testid") ?? ""
|
|
1861
2034
|
].join(" ");
|
|
1862
|
-
return MENU_CLASS.test(
|
|
2035
|
+
return MENU_CLASS.test(names2) || MENU_LABEL.test(nameOf(el));
|
|
1863
2036
|
}
|
|
1864
2037
|
function kindOf(el) {
|
|
1865
2038
|
if (el.matches("details > summary")) {
|
|
@@ -2061,6 +2234,7 @@
|
|
|
2061
2234
|
"unexpected-wrap",
|
|
2062
2235
|
"clipped-text",
|
|
2063
2236
|
"squished-text",
|
|
2237
|
+
"calendar",
|
|
2064
2238
|
"distorted-media",
|
|
2065
2239
|
"small-tap-target",
|
|
2066
2240
|
"tiny-text",
|
|
@@ -2196,7 +2370,7 @@
|
|
|
2196
2370
|
const enabled = new Set(options.detectors ?? DETECTOR_IDS);
|
|
2197
2371
|
const rules = options.ignore ?? [];
|
|
2198
2372
|
const snap = snapshot();
|
|
2199
|
-
|
|
2373
|
+
let findings = [];
|
|
2200
2374
|
for (const [id, run] of Object.entries(DETECTORS)) {
|
|
2201
2375
|
if (!enabled.has(id)) continue;
|
|
2202
2376
|
for (const f of run(snap)) {
|
|
@@ -2204,6 +2378,13 @@
|
|
|
2204
2378
|
findings.push(f);
|
|
2205
2379
|
}
|
|
2206
2380
|
}
|
|
2381
|
+
if (enabled.has("calendar")) {
|
|
2382
|
+
findings = foldCalendars(
|
|
2383
|
+
snap,
|
|
2384
|
+
findings,
|
|
2385
|
+
(root) => !isIgnored(describe(root), "calendar", rules)
|
|
2386
|
+
);
|
|
2387
|
+
}
|
|
2207
2388
|
const wraps = enabled.has("unexpected-wrap") ? wrapStats(snap).filter((w) => !isIgnored(w.element, "unexpected-wrap", rules)) : [];
|
|
2208
2389
|
const containers = containerSignature(snap);
|
|
2209
2390
|
return {
|
package/dist/schema.json
CHANGED
|
@@ -223,6 +223,25 @@
|
|
|
223
223
|
}
|
|
224
224
|
]
|
|
225
225
|
},
|
|
226
|
+
"calendar": {
|
|
227
|
+
"anyOf": [
|
|
228
|
+
{
|
|
229
|
+
"type": "boolean"
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
"type": "string",
|
|
233
|
+
"const": "error"
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
"type": "string",
|
|
237
|
+
"const": "warning"
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
"type": "string",
|
|
241
|
+
"const": "info"
|
|
242
|
+
}
|
|
243
|
+
]
|
|
244
|
+
},
|
|
226
245
|
"distorted-media": {
|
|
227
246
|
"anyOf": [
|
|
228
247
|
{
|
|
@@ -386,6 +405,7 @@
|
|
|
386
405
|
"unexpected-wrap",
|
|
387
406
|
"clipped-text",
|
|
388
407
|
"squished-text",
|
|
408
|
+
"calendar",
|
|
389
409
|
"distorted-media",
|
|
390
410
|
"small-tap-target",
|
|
391
411
|
"tiny-text",
|