@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 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) => off[i.detector] !== false)
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) => off[i.detector] !== false);
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 check.resolve(checkRoutes(body.routes));
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.includes(PACKAGE);
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", PACKAGE, "mcp"];
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)) return null;
7153
- data[key] = { ...data[key], [SERVER_NAME]: entry };
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 changes.push({ client, next });
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
- for (const { client } of changes) {
7272
- console.log(` ${client.name.padEnd(12)} ${show(client.file, cwd)}`);
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(`${styleText8("green", "\u2714")} ${client.name}: added to ${show(client.file, cwd)}`);
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 SEVERITY_RANK = { error: 0, warning: 1, info: 2 };
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(SEVERITY_RANK, askedFailOn)) {
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(SEVERITY_RANK, failOn)) {
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 = SEVERITY_RANK[failOn];
7732
+ const threshold = SEVERITY_RANK2[failOn];
7652
7733
  const failing = result.routes.some(
7653
- (r) => r.issues.some((i) => SEVERITY_RANK[i.severity] <= threshold)
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" | "distorted-media" | "small-tap-target" | "tiny-text" | "oversized-text" | "edge-gutter" | "viewport-meta"
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" | "distorted-media" | "small-tap-target" | "tiny-text" | "oversized-text" | "edge-gutter" | "viewport-meta"
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
- 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.`;
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 names = s.containerName.split(/\s+/);
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 && !names.includes(q.name)) continue;
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 names = [
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(names) || MENU_LABEL.test(nameOf(el));
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
- const findings = [];
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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seybel/breakhound",
3
- "version": "1.0.9",
3
+ "version": "1.0.10",
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",