hyperframes 0.8.30 → 0.8.32

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.
@@ -245,10 +245,8 @@
245
245
  return rects;
246
246
  }
247
247
 
248
- function textRectFor(element, directOnly) {
249
- const rects = textClientRects(element, directOnly);
248
+ function unionRects(rects) {
250
249
  if (rects.length === 0) return null;
251
-
252
250
  const union = rects.reduce(
253
251
  (acc, rect) => ({
254
252
  left: Math.min(acc.left, rect.left),
@@ -263,7 +261,6 @@
263
261
  bottom: Number.NEGATIVE_INFINITY,
264
262
  },
265
263
  );
266
-
267
264
  return toRect({
268
265
  ...union,
269
266
  width: union.right - union.left,
@@ -271,6 +268,38 @@
271
268
  });
272
269
  }
273
270
 
271
+ function visibleTextClientRects(element, directOnly) {
272
+ // Range rects stay geometrically present outside an overflow clip. Reduce
273
+ // them in viewport coordinates so overlap measures only paintable text.
274
+ let rects = textClientRects(element, directOnly).map(toRect);
275
+ for (
276
+ let ancestor = element.parentElement;
277
+ ancestor && rects.length > 0;
278
+ ancestor = ancestor.parentElement
279
+ ) {
280
+ const style = getComputedStyle(ancestor);
281
+ const clipX = clipsOverflowValue(style.overflowX || style.overflow);
282
+ const clipY = clipsOverflowValue(style.overflowY || style.overflow);
283
+ if (!clipX && !clipY) continue;
284
+ const clip = toRect(ancestor.getBoundingClientRect());
285
+ rects = rects
286
+ .map((rect) => {
287
+ const left = clipX ? Math.max(rect.left, clip.left) : rect.left;
288
+ const right = clipX ? Math.min(rect.right, clip.right) : rect.right;
289
+ const top = clipY ? Math.max(rect.top, clip.top) : rect.top;
290
+ const bottom = clipY ? Math.min(rect.bottom, clip.bottom) : rect.bottom;
291
+ if (right - left <= 0.5 || bottom - top <= 0.5) return null;
292
+ return toRect({ left, right, top, bottom, width: right - left, height: bottom - top });
293
+ })
294
+ .filter(Boolean);
295
+ }
296
+ return rects;
297
+ }
298
+
299
+ function textRectFor(element, directOnly) {
300
+ return unionRects(textClientRects(element, directOnly));
301
+ }
302
+
274
303
  function parsePx(value) {
275
304
  const parsed = Number.parseFloat(value);
276
305
  return Number.isFinite(parsed) ? parsed : 0;
@@ -317,9 +346,13 @@
317
346
  return hasBackground || hasImage || hasBorder || hasRadius;
318
347
  }
319
348
 
349
+ function clipsOverflowValue(value) {
350
+ return value && value !== "visible" && value !== "clip visible";
351
+ }
352
+
320
353
  function clipsOverflow(style) {
321
- return [style.overflowX, style.overflowY, style.overflow].some(
322
- (value) => value && value !== "visible" && value !== "clip visible",
354
+ return [style.overflowX, style.overflowY, style.overflow].some((value) =>
355
+ clipsOverflowValue(value),
323
356
  );
324
357
  }
325
358
 
@@ -439,7 +472,7 @@
439
472
  return false;
440
473
  }
441
474
 
442
- function textOverflowIssues(element, root, rootRect, time, tolerance) {
475
+ function textOverflowIssues(element, root, rootRect, time, tolerance, clippedIssue) {
443
476
  const textRect = textRectFor(element, true);
444
477
  if (!textRect) return [];
445
478
  const text = textContentFor(element, true);
@@ -462,8 +495,15 @@
462
495
  ? tolerance
463
496
  : Math.max(tolerance, parsePx(elementStyle.fontSize) * 0.2);
464
497
  const containerOverflow = overflowFor(textRect, containerRect, tolerance, verticalTolerance);
498
+ const billedAsClippedText =
499
+ container === element &&
500
+ clippedIssue != null &&
501
+ containerOverflow != null &&
502
+ containerOverflow.left == null &&
503
+ containerOverflow.top == null;
465
504
  if (
466
505
  containerOverflow &&
506
+ !billedAsClippedText &&
467
507
  !hasTextClipOptOut(element) &&
468
508
  !clippedByAncestor(element, container)
469
509
  ) {
@@ -587,8 +627,8 @@
587
627
  const blocks = [];
588
628
  for (const element of Array.from(root.querySelectorAll("*"))) {
589
629
  if (!isSolidTextBlock(element)) continue;
590
- const rects = textClientRects(element, true);
591
- const rect = textRectFor(element, true);
630
+ const rects = visibleTextClientRects(element, true);
631
+ const rect = unionRects(rects);
592
632
  if (rect) blocks.push({ element, rect, rects });
593
633
  }
594
634
  return blocks;
@@ -663,8 +703,8 @@
663
703
  code: "content_overlap",
664
704
  severity: "warning",
665
705
  time,
666
- selector: selectorFor(a.element),
667
- containerSelector: selectorFor(b.element),
706
+ selector: uniqueSelectorFor(a.element),
707
+ containerSelector: uniqueSelectorFor(b.element),
668
708
  text: textContentFor(a.element),
669
709
  message: "Two text blocks overlap and may render unreadable.",
670
710
  rect: a.rect,
@@ -1021,8 +1061,8 @@
1021
1061
  code: "text_occluded",
1022
1062
  severity: "error",
1023
1063
  time,
1024
- selector: selectorFor(element),
1025
- containerSelector: selectorFor(occluder),
1064
+ selector: uniqueSelectorFor(element),
1065
+ containerSelector: uniqueSelectorFor(occluder),
1026
1066
  text,
1027
1067
  message: "Text is hidden beneath an opaque element.",
1028
1068
  rect: textRect,
@@ -1293,7 +1333,7 @@
1293
1333
  const threshold = Math.max(32, Math.min(rootRect.width, rootRect.height) * 0.02);
1294
1334
  const MIN_CONNECTOR_CHORD_PX = 8;
1295
1335
  for (const svg of Array.from(root.querySelectorAll("svg"))) {
1296
- if (!isVisibleElement(svg) || hasAllowOverflowFlag(svg)) continue;
1336
+ if (!isVisibleElement(svg)) continue;
1297
1337
  for (const path of Array.from(svg.querySelectorAll("path"))) {
1298
1338
  if (path.closest(CONNECTOR_SKIP_CONTAINERS)) continue;
1299
1339
  if (!isConnectorPath(svg, path)) continue;
@@ -1326,7 +1366,16 @@
1326
1366
  // Paste-into-`d` bug: both raw endpoints land on distinct anchors as screen pixels.
1327
1367
  const userStartKey = attachmentKey(user.start);
1328
1368
  const userEndKey = attachmentKey(user.end);
1329
- if (!userStartKey || !userEndKey || userStartKey === userEndKey) continue;
1369
+ const pasteBug = Boolean(userStartKey && userEndKey && userStartKey !== userEndKey);
1370
+ // Guessed marked shaft: both frames miss. Same-anchor grazes attach in user-space
1371
+ // and must stay skipped. Name-only decorative flow/arrow paths stay skipped.
1372
+ // 80px keeps short marker glyphs (chevrons, tips) out.
1373
+ const markedMiss =
1374
+ renderedChord >= 80 &&
1375
+ !userStartKey &&
1376
+ !userEndKey &&
1377
+ (path.hasAttribute("marker-start") || path.hasAttribute("marker-end"));
1378
+ if (!pasteBug && !markedMiss) continue;
1330
1379
  const gap = Math.round(
1331
1380
  Math.min(
1332
1381
  Math.min(...anchors.compact.map((a) => distanceToRect(rendered.start, a.rect))),
@@ -1339,7 +1388,113 @@
1339
1388
  time,
1340
1389
  selector: selectorFor(path),
1341
1390
  containerSelector: selectorFor(svg),
1342
- message: `Connector path endpoints render ${gap}px from the nearest anchorable element, but the path's user-space coordinates would attach if read as screen pixels — screen/viewport numbers were likely written into SVG \`d\` without inverting the CTM.`,
1391
+ message: pasteBug
1392
+ ? `Connector path endpoints render ${gap}px from the nearest anchorable element, but the path's user-space coordinates would attach if read as screen pixels — screen/viewport numbers were likely written into SVG \`d\` without inverting the CTM.`
1393
+ : `Connector path endpoints render ${gap}px from the nearest anchorable element — a marked shaft that meets no node.`,
1394
+ rect: toRect({
1395
+ left: Math.min(rendered.start.x, rendered.end.x),
1396
+ top: Math.min(rendered.start.y, rendered.end.y),
1397
+ right: Math.max(rendered.start.x, rendered.end.x),
1398
+ bottom: Math.max(rendered.start.y, rendered.end.y),
1399
+ width: Math.abs(rendered.end.x - rendered.start.x),
1400
+ height: Math.abs(rendered.end.y - rendered.start.y),
1401
+ }),
1402
+ fixHint: pasteBug
1403
+ ? "Convert measured screen coordinates into the SVG's user space (subtract the SVG rect / invert getScreenCTM) before writing path `d`, and keep the SVG a direct child of the stage."
1404
+ : "Measure the settled node boxes and write `d` in the SVG's user space (invert getScreenCTM), or grow a layout-owned shaft from the source node.",
1405
+ });
1406
+ }
1407
+ }
1408
+ return issues;
1409
+ }
1410
+
1411
+ function shaftIsPainted(path) {
1412
+ if (IGNORE_TAGS.has(path.tagName) || hasIgnoreFlag(path)) return false;
1413
+ const style = getComputedStyle(path);
1414
+ if (
1415
+ style.display === "none" ||
1416
+ style.visibility === "hidden" ||
1417
+ style.visibility === "collapse"
1418
+ ) {
1419
+ return false;
1420
+ }
1421
+ return opacityChain(path) >= 0.2;
1422
+ }
1423
+
1424
+ function shaftDashHidden(path) {
1425
+ if (typeof path.getTotalLength !== "function") return false;
1426
+ let total;
1427
+ try {
1428
+ total = path.getTotalLength();
1429
+ } catch {
1430
+ return false;
1431
+ }
1432
+ if (!Number.isFinite(total) || total <= 0) return false;
1433
+ const style = getComputedStyle(path);
1434
+ const offset = Number.parseFloat(style.strokeDashoffset || "0");
1435
+ const dash = Number.parseFloat(String(style.strokeDasharray || "").split(/[\s,]+/)[0] || "0");
1436
+ return offset >= total * 0.9 && dash >= total * 0.9;
1437
+ }
1438
+
1439
+ function connectorEndpointCandidates(root, rootRect) {
1440
+ const candidates = [];
1441
+ const rootArea = rectArea(rootRect);
1442
+ for (const element of Array.from(root.querySelectorAll("*"))) {
1443
+ if (element.closest("svg") || IGNORE_TAGS.has(element.tagName) || hasIgnoreFlag(element))
1444
+ continue;
1445
+ const style = getComputedStyle(element);
1446
+ const opaque = RASTER_TAGS.has(element.tagName) || hasOpaqueBackground(style);
1447
+ if (!opaque && !textContentFor(element)) continue;
1448
+ const rect = toRect(element.getBoundingClientRect());
1449
+ const area = rectArea(rect);
1450
+ if (area < 400 || area > rootArea * 0.15) continue;
1451
+ candidates.push({ rect, element });
1452
+ }
1453
+ return candidates;
1454
+ }
1455
+
1456
+ function connectorOrphanIssues(root, rootRect, time) {
1457
+ const issues = [];
1458
+ let candidates = null;
1459
+ const threshold = Math.max(32, Math.min(rootRect.width, rootRect.height) * 0.02);
1460
+ for (const svg of Array.from(root.querySelectorAll("svg"))) {
1461
+ if (!isVisibleElement(svg)) continue;
1462
+ for (const path of Array.from(svg.querySelectorAll("path"))) {
1463
+ if (path.closest(CONNECTOR_SKIP_CONTAINERS)) continue;
1464
+ if (!isConnectorPath(svg, path)) continue;
1465
+ if (!shaftIsPainted(path) || shaftDashHidden(path)) continue;
1466
+ const user = pathUserEndpoints(path);
1467
+ const rendered = pathScreenEndpoints(svg, path, user);
1468
+ if (!user || !rendered) continue;
1469
+ const renderedChord = Math.hypot(
1470
+ rendered.end.x - rendered.start.x,
1471
+ rendered.end.y - rendered.start.y,
1472
+ );
1473
+ if (renderedChord < 80) continue;
1474
+ if (candidates === null) candidates = connectorEndpointCandidates(root, rootRect);
1475
+ const dark = [];
1476
+ for (const point of [rendered.start, rendered.end]) {
1477
+ let best = null;
1478
+ let attached = false;
1479
+ for (const candidate of candidates) {
1480
+ const gap = distanceToRect(point, candidate.rect);
1481
+ if (gap > threshold) continue;
1482
+ if (isVisibleElement(candidate.element)) {
1483
+ attached = true;
1484
+ break;
1485
+ }
1486
+ if (best === null || gap < best.gap) best = { gap, candidate };
1487
+ }
1488
+ if (!attached && best !== null) dark.push(best.candidate);
1489
+ }
1490
+ if (dark.length === 0) continue;
1491
+ issues.push({
1492
+ code: "connector_orphan",
1493
+ severity: "warning",
1494
+ time,
1495
+ selector: selectorFor(path),
1496
+ containerSelector: selectorFor(svg),
1497
+ message: `Connector shaft is visible while ${dark.length === 2 ? "both endpoints are" : `its endpoint ${selectorFor(dark[0].element)} is`} not on stage.`,
1343
1498
  rect: toRect({
1344
1499
  left: Math.min(rendered.start.x, rendered.end.x),
1345
1500
  top: Math.min(rendered.start.y, rendered.end.y),
@@ -1349,7 +1504,7 @@
1349
1504
  height: Math.abs(rendered.end.y - rendered.start.y),
1350
1505
  }),
1351
1506
  fixHint:
1352
- "Convert measured screen coordinates into the SVG's user space (subtract the SVG rect / invert getScreenCTM) before writing path `d`, and keep the SVG a direct child of the stage.",
1507
+ "Show the shaft only after both ends are on, and hide it with the earlier exit. Do not give the line its own clock.",
1353
1508
  });
1354
1509
  }
1355
1510
  }
@@ -1444,7 +1599,7 @@
1444
1599
  if (!hasOwnTextCandidate(element)) continue;
1445
1600
  const clipped = clippedTextIssue(element, time, tolerance);
1446
1601
  if (clipped) issues.push(clipped);
1447
- issues.push(...textOverflowIssues(element, root, rootRect, time, tolerance));
1602
+ issues.push(...textOverflowIssues(element, root, rootRect, time, tolerance, clipped));
1448
1603
  const occluded = occludedTextIssue(element, time, proseCoverageFloor);
1449
1604
  if (occluded) issues.push(occluded);
1450
1605
  const invisible = invisibleTextIssue(element, time);
@@ -1460,6 +1615,7 @@
1460
1615
  issues.push(...escaped.issues);
1461
1616
  issues.push(...panelOutOfCanvasIssues(root, rootRect, time, tolerance, escaped.flagged));
1462
1617
  issues.push(...connectorDetachmentIssues(root, rootRect, time));
1618
+ issues.push(...connectorOrphanIssues(root, rootRect, time));
1463
1619
  return issues;
1464
1620
  };
1465
1621