@seybel/breakhound 1.0.8 → 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");
@@ -6420,7 +6468,7 @@ async function startBridge(opts) {
6420
6468
  case "/breakhound/checks/progress": {
6421
6469
  const check = checks.get(String(body.id));
6422
6470
  if (check && typeof body.done === "number" && typeof body.message === "string") {
6423
- check.onProgress?.(body.done, body.message.slice(0, 200));
6471
+ check.onProgress?.(body.done, body.message.slice(0, 400));
6424
6472
  }
6425
6473
  return reply({});
6426
6474
  }
@@ -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:
@@ -6526,6 +6581,9 @@ async function startBridge(opts) {
6526
6581
  };
6527
6582
  }
6528
6583
 
6584
+ // src/icon.ts
6585
+ var ICON_PNG = "data:image/png;base64,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";
6586
+
6529
6587
  // src/mcp.ts
6530
6588
  var PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
6531
6589
  var INSTRUCTIONS = `breakhound finds responsive layout bugs by resizing pages in a real browser, and tells you the exact widths where they happen.
@@ -6538,12 +6596,15 @@ How to fix issues with it:
6538
6596
  5. When verify is clean for a route, checkpoint it. That commits the fix on its own, so it can be undone later with undo.
6539
6597
  Before finishing, run verify with no routes to check every page.
6540
6598
 
6541
- 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.`;
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.
6600
+
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.`;
6542
6602
  var FIX_PROMPT = `Fix the responsive layout issues breakhound found in this project.
6543
6603
 
6544
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.
6545
6605
  Work through the errors first, then the warnings. For each one: get_issue for the details, fix the cause in the code (not overflow: hidden), verify the routes you touched, and checkpoint each route once verify is clean for it.
6546
6606
  Before you finish, verify with no routes to check every page, and tell me what you fixed and anything you couldn't.`;
6607
+ var REPLY_WITHIN_MS = 45e3;
6547
6608
  var text = (t, isError = false) => ({
6548
6609
  content: [{ type: "text", text: t }],
6549
6610
  ...isError ? { isError } : {}
@@ -6602,6 +6663,7 @@ function createTools() {
6602
6663
  return [
6603
6664
  {
6604
6665
  name: "scan",
6666
+ kind: "long",
6605
6667
  description: "Find the app (starting its dev server if needed), discover its routes and scan each one at every width from 320 to 1920px in a headless browser. Returns every issue with its id. The result is the baseline that verify compares with, replacing any issues the developer sent from the browser extension, so check list_issues first. Takes from seconds to a few minutes on large apps.",
6606
6668
  inputSchema: {
6607
6669
  type: "object",
@@ -6629,6 +6691,7 @@ function createTools() {
6629
6691
  },
6630
6692
  {
6631
6693
  name: "list_routes",
6694
+ kind: "read",
6632
6695
  description: "Routes from the last scan, with their status and number of issues.",
6633
6696
  inputSchema: { type: "object", properties: {} },
6634
6697
  async run(_args, ctx) {
@@ -6638,6 +6701,7 @@ function createTools() {
6638
6701
  },
6639
6702
  {
6640
6703
  name: "list_issues",
6704
+ kind: "read",
6641
6705
  description: "Issues from the last scan or verify, each listed once even when it shows up on several routes. Filter by route or severity.",
6642
6706
  inputSchema: {
6643
6707
  type: "object",
@@ -6657,6 +6721,7 @@ function createTools() {
6657
6721
  },
6658
6722
  {
6659
6723
  name: "get_issue",
6724
+ kind: "read",
6660
6725
  description: "Everything about one issue: the widths, the element and where it is in the code (when known), measurements, computed styles, a likely fix, every page it is on, and a screenshot.",
6661
6726
  inputSchema: {
6662
6727
  type: "object",
@@ -6687,7 +6752,8 @@ function createTools() {
6687
6752
  },
6688
6753
  {
6689
6754
  name: "verify",
6690
- 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.",
6755
+ kind: "long",
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.",
6691
6757
  inputSchema: {
6692
6758
  type: "object",
6693
6759
  properties: {
@@ -6861,6 +6927,8 @@ async function runMcpServer(version, io = { input: process.stdin, output: proces
6861
6927
  let clientCanListRoots = false;
6862
6928
  let root = process.env.BREAKHOUND_ROOT ?? null;
6863
6929
  let queue = Promise.resolve();
6930
+ let job = null;
6931
+ const replyWithinMs = Number(process.env.BREAKHOUND_REPLY_WITHIN_MS) || REPLY_WITHIN_MS;
6864
6932
  const send = (message) => io.output.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}
6865
6933
  `);
6866
6934
  const request = (method, params = {}) => new Promise((resolve8, reject) => {
@@ -6890,13 +6958,17 @@ async function runMcpServer(version, io = { input: process.stdin, output: proces
6890
6958
  root ??= cwd;
6891
6959
  return root;
6892
6960
  }
6893
- async function callTool(id, params) {
6894
- const tool = tools.find((t) => t.name === params.name);
6895
- if (!tool) {
6896
- send({ id, error: { code: -32602, message: `Unknown tool: ${String(params.name)}` } });
6897
- return;
6898
- }
6961
+ const progressFor = (params) => {
6899
6962
  const token = params._meta?.progressToken;
6963
+ return (done, message) => {
6964
+ if (token !== void 0)
6965
+ send({
6966
+ method: "notifications/progress",
6967
+ params: { progressToken: token, progress: done, message }
6968
+ });
6969
+ };
6970
+ };
6971
+ async function runTool(tool, params, progress) {
6900
6972
  try {
6901
6973
  const dir = await projectRoot();
6902
6974
  const { config, warnings } = await loadConfig(dir);
@@ -6907,22 +6979,93 @@ async function runMcpServer(version, io = { input: process.stdin, output: proces
6907
6979
  root: dir,
6908
6980
  config,
6909
6981
  bridge,
6910
- progress(done, message) {
6911
- if (token !== void 0)
6912
- send({
6913
- method: "notifications/progress",
6914
- params: { progressToken: token, progress: done, message }
6915
- });
6916
- }
6982
+ progress
6917
6983
  })
6918
6984
  );
6919
6985
  if (warnings.length)
6920
6986
  result.content.push({ type: "text", text: `Config warnings: ${warnings.join(" ")}` });
6921
- send({ id, result });
6987
+ return result;
6922
6988
  } catch (error) {
6923
6989
  if (error instanceof MemoryError) server.current = null;
6924
- send({ id, result: text(error instanceof Error ? error.message : String(error), true) });
6990
+ return text(error instanceof Error ? error.message : String(error), true);
6991
+ }
6992
+ }
6993
+ const running = (j) => `${j.label} has been running for ${Math.round((Date.now() - j.startedAt) / 1e3)}s${j.latest ? ` (${j.latest})` : ""}.`;
6994
+ async function collect(j, params) {
6995
+ const late = new Promise((r) => setTimeout(() => r(null), replyWithinMs).unref());
6996
+ j.waiting = progressFor(params);
6997
+ const result = await Promise.race([j.done, late]);
6998
+ j.waiting = null;
6999
+ if (result) {
7000
+ if (job === j) job = null;
7001
+ return result;
7002
+ }
7003
+ return text(
7004
+ `Still running: ${running(j)} It carries on by itself. Call ${j.name} again with the same arguments to get its result; list_issues and get_issue answer meanwhile.`
7005
+ );
7006
+ }
7007
+ async function callTool(id, params) {
7008
+ const tool = tools.find((t) => t.name === params.name);
7009
+ if (!tool) {
7010
+ send({ id, error: { code: -32602, message: `Unknown tool: ${String(params.name)}` } });
7011
+ return;
6925
7012
  }
7013
+ const key = `${tool.name} ${JSON.stringify(params.arguments ?? {})}`;
7014
+ const busy = job && !job.finished ? job : null;
7015
+ if (tool.kind === "read") {
7016
+ const result = await runTool(tool, params, progressFor(params));
7017
+ if (busy)
7018
+ result.content.push({
7019
+ type: "text",
7020
+ text: `Note: ${running(busy)} Call ${busy.name} again with the same arguments for its result.`
7021
+ });
7022
+ send({ id, result });
7023
+ return;
7024
+ }
7025
+ if (job?.key === key) {
7026
+ send({ id, result: await collect(job, params) });
7027
+ return;
7028
+ }
7029
+ if (busy) {
7030
+ send({
7031
+ id,
7032
+ result: text(
7033
+ `Not yet: ${running(busy)} Call ${busy.name} again with the same arguments to get its result first.`,
7034
+ true
7035
+ )
7036
+ });
7037
+ return;
7038
+ }
7039
+ if (tool.kind !== "long") {
7040
+ job = null;
7041
+ queue = queue.then(() => runTool(tool, params, progressFor(params)));
7042
+ send({ id, result: await queue });
7043
+ return;
7044
+ }
7045
+ const args = params.arguments ?? {};
7046
+ const routes = strings(args.routes);
7047
+ const j = {
7048
+ key,
7049
+ name: tool.name,
7050
+ label: `${tool.name}${routes.length ? ` of ${routes.join(", ")}` : ""}`,
7051
+ startedAt: Date.now(),
7052
+ latest: "",
7053
+ finished: false,
7054
+ waiting: null,
7055
+ done: Promise.resolve(text(""))
7056
+ };
7057
+ j.done = queue.then(
7058
+ () => runTool(tool, params, (done, message) => {
7059
+ j.latest = message;
7060
+ j.waiting?.(done, message);
7061
+ })
7062
+ );
7063
+ j.done.then(() => {
7064
+ j.finished = true;
7065
+ });
7066
+ queue = j.done;
7067
+ job = j;
7068
+ send({ id, result: await collect(j, params) });
6926
7069
  }
6927
7070
  function handle(message) {
6928
7071
  const { id, method } = message;
@@ -6948,7 +7091,13 @@ async function runMcpServer(version, io = { input: process.stdin, output: proces
6948
7091
  result: {
6949
7092
  protocolVersion: PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0],
6950
7093
  capabilities: { tools: { listChanged: false }, prompts: { listChanged: false } },
6951
- serverInfo: { name: "breakhound", version: VERSION },
7094
+ serverInfo: {
7095
+ name: "breakhound",
7096
+ version: VERSION,
7097
+ // Shown beside the server by clients that show icons. No sizes: some clients' older
7098
+ // schemas expect another shape for it, and an icon isn't worth a failed connection.
7099
+ icons: [{ src: ICON_PNG, mimeType: "image/png" }]
7100
+ },
6952
7101
  instructions: INSTRUCTIONS
6953
7102
  }
6954
7103
  });
@@ -6996,7 +7145,7 @@ async function runMcpServer(version, io = { input: process.stdin, output: proces
6996
7145
  });
6997
7146
  return;
6998
7147
  case "tools/call":
6999
- queue = queue.then(() => callTool(id, params));
7148
+ void callTool(id, params);
7000
7149
  return;
7001
7150
  default:
7002
7151
  send({ id, error: { code: -32601, message: `Method not found: ${String(method)}` } });
@@ -7030,11 +7179,16 @@ import { createInterface as createInterface3 } from "readline/promises";
7030
7179
  import { styleText as styleText8 } from "util";
7031
7180
  var SERVER_NAME = "breakhound";
7032
7181
  var PACKAGE = "@seybel/breakhound";
7182
+ var LATEST = `${PACKAGE}@latest`;
7033
7183
  function startsOtherPackage(args) {
7034
- 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");
7035
7188
  }
7189
+ var toLatest = (args) => args.map((a) => a === PACKAGE ? LATEST : a);
7036
7190
  function serverCommand(platform = process.platform) {
7037
- const args = ["-y", PACKAGE, "mcp"];
7191
+ const args = ["-y", LATEST, "mcp"];
7038
7192
  return platform === "win32" ? { command: "cmd", args: ["/c", "npx", ...args] } : { command: "npx", args };
7039
7193
  }
7040
7194
  function jsonClient(id, name, file, key, entry) {
@@ -7055,8 +7209,11 @@ ${JSON.stringify({ [SERVER_NAME]: entry }, null, 2)}`
7055
7209
  }
7056
7210
  }
7057
7211
  const existing = data[key]?.[SERVER_NAME];
7058
- if (existing && !startsOtherPackage(existing.args)) return null;
7059
- 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 };
7060
7217
  return `${JSON.stringify(data, null, 2)}
7061
7218
  `;
7062
7219
  }
@@ -7076,6 +7233,9 @@ args = ${JSON.stringify(args).replaceAll(",", ", ")}
7076
7233
  const table = /^\[mcp_servers\.breakhound\]\n(?:(?!\[)[^\n]*\n?)*/m;
7077
7234
  const found = current?.match(table)?.[0];
7078
7235
  if (found) {
7236
+ if (found.includes(`"${PACKAGE}"`)) {
7237
+ return current.replace(table, found.replace(`"${PACKAGE}"`, `"${LATEST}"`));
7238
+ }
7079
7239
  if (found.includes(PACKAGE) || !/["']breakhound["']/.test(found)) return null;
7080
7240
  return current.replace(table, `${block}${found.endsWith("\n\n") ? "\n" : ""}`);
7081
7241
  }
@@ -7140,6 +7300,7 @@ function show(path, cwd) {
7140
7300
  const rel = relative7(cwd, path);
7141
7301
  return rel.startsWith("..") ? path.replace(homedir(), "~") : rel;
7142
7302
  }
7303
+ var NOT_SHOWING_UP = "Not showing up? Your agent runs breakhound only while this folder is open in it, and with breakhound switched on in its MCP settings (Cursor and VS Code may add it switched off), so check both. How, for each agent: https://seybel.github.io/breakhound/guide/agents#not-connected";
7143
7304
  async function installMcp(cwd, options = {}) {
7144
7305
  const home = options.home ?? homedir();
7145
7306
  const clients = options.clients?.length ? allClients(cwd, home).filter((c) => options.clients?.includes(c.id)) : detectClients(cwd, home);
@@ -7151,24 +7312,43 @@ async function installMcp(cwd, options = {}) {
7151
7312
  return 1;
7152
7313
  }
7153
7314
  const changes = [];
7315
+ let alreadySetUp = false;
7154
7316
  for (const client of clients) {
7155
7317
  const current = existsSync15(client.file) ? readFileSync18(client.file, "utf8") : null;
7156
7318
  try {
7157
7319
  const next = client.update(current);
7158
- if (next === null)
7320
+ if (next === null) {
7321
+ alreadySetUp = true;
7159
7322
  console.log(
7160
7323
  `${styleText8("green", "\u2714")} ${client.name}: already set up in ${show(client.file, cwd)}`
7161
7324
  );
7162
- else changes.push({ client, next });
7325
+ } else {
7326
+ const updating2 = !!current && /["']@?(?:seybel\/)?breakhound["']/.test(current);
7327
+ changes.push({ client, next, updating: updating2 });
7328
+ }
7163
7329
  } catch (error) {
7164
7330
  console.log(`${styleText8("yellow", "!")} ${client.name}: ${error.message}`);
7165
7331
  }
7166
7332
  }
7167
- if (!changes.length) return 0;
7168
- console.log("\nbreakhound will add its MCP server to:\n");
7333
+ if (!changes.length) {
7334
+ if (alreadySetUp) console.log(`
7335
+ ${NOT_SHOWING_UP}`);
7336
+ return 0;
7337
+ }
7169
7338
  const { command, args } = serverCommand();
7170
- for (const { client } of changes) {
7171
- 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)}`);
7172
7352
  }
7173
7353
  console.log(styleText8("dim", `
7174
7354
  The server runs: ${command} ${args.join(" ")}
@@ -7179,22 +7359,25 @@ async function installMcp(cwd, options = {}) {
7179
7359
  return 1;
7180
7360
  }
7181
7361
  const rl = createInterface3({ input: process.stdin, output: process.stdout });
7182
- 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();
7183
7363
  rl.close();
7184
7364
  if (answer && answer !== "y" && answer !== "yes") {
7185
7365
  console.log("Nothing changed.");
7186
7366
  return 0;
7187
7367
  }
7188
7368
  }
7189
- for (const { client, next } of changes) {
7369
+ for (const { client, next, updating: updating2 } of changes) {
7190
7370
  mkdirSync3(dirname5(client.file), { recursive: true });
7191
7371
  writeFileSync8(client.file, next);
7192
- 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
+ );
7193
7375
  }
7194
7376
  console.log(
7195
7377
  `
7196
7378
  Restart your agent (or reload its MCP servers), then ask it to "fix the responsive issues breakhound finds".`
7197
7379
  );
7380
+ console.log(NOT_SHOWING_UP);
7198
7381
  return 0;
7199
7382
  }
7200
7383
 
@@ -7299,7 +7482,7 @@ function normalizeBaseUrl(url) {
7299
7482
  throw new Error(`--url must be a URL like http://localhost:3000 (got "${url}")`);
7300
7483
  }
7301
7484
  }
7302
- var SEVERITY_RANK = { error: 0, warning: 1, info: 2 };
7485
+ var SEVERITY_RANK2 = { error: 0, warning: 1, info: 2 };
7303
7486
  function positiveInt(name, value) {
7304
7487
  if (value === void 0) return void 0;
7305
7488
  const n = Number(value);
@@ -7356,7 +7539,7 @@ async function main(argv) {
7356
7539
  const cwd = process.cwd();
7357
7540
  const [command, ...rest2] = positionals;
7358
7541
  const askedFailOn = values["fail-on"];
7359
- if (askedFailOn !== void 0 && askedFailOn !== "none" && !Object.hasOwn(SEVERITY_RANK, askedFailOn)) {
7542
+ if (askedFailOn !== void 0 && askedFailOn !== "none" && !Object.hasOwn(SEVERITY_RANK2, askedFailOn)) {
7360
7543
  throw new Error("--fail-on must be one of: error, warning, info, none");
7361
7544
  }
7362
7545
  if (command === "init") {
@@ -7508,7 +7691,7 @@ ${formatVerify(v, severities.length ? severities : severitiesAtOrAbove("info"))}
7508
7691
  const concurrency = positiveInt("concurrency", values.concurrency);
7509
7692
  const maxRoutes = positiveInt("max-routes", values["max-routes"]);
7510
7693
  const failOn = values["fail-on"] ?? fileConfig.failOn ?? "error";
7511
- if (failOn !== "none" && !Object.hasOwn(SEVERITY_RANK, failOn)) {
7694
+ if (failOn !== "none" && !Object.hasOwn(SEVERITY_RANK2, failOn)) {
7512
7695
  throw new Error("--fail-on must be one of: error, warning, info, none");
7513
7696
  }
7514
7697
  const browsers = values.browsers?.split(",").map((b) => b.trim().toLowerCase()).filter(Boolean);
@@ -7546,9 +7729,9 @@ ${formatVerify(v, severities.length ? severities : severitiesAtOrAbove("info"))}
7546
7729
  }
7547
7730
  if (result.routes.some((r) => FAILED_STATUSES.includes(r.status))) return 2;
7548
7731
  if (failOn === "none") return 0;
7549
- const threshold = SEVERITY_RANK[failOn];
7732
+ const threshold = SEVERITY_RANK2[failOn];
7550
7733
  const failing = result.routes.some(
7551
- (r) => r.issues.some((i) => SEVERITY_RANK[i.severity] <= threshold)
7734
+ (r) => r.issues.some((i) => SEVERITY_RANK2[i.severity] <= threshold)
7552
7735
  );
7553
7736
  return failing ? 1 : 0;
7554
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.8",
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",