hyperframes 0.7.57 → 0.7.58

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.
@@ -58,28 +58,29 @@ window.__contrastAuditPrepare = function () {
58
58
  }
59
59
 
60
60
  function parseColor(c) {
61
- var m = c.match(/rgba?\(([^)]+)\)/);
62
- if (!m) return [0, 0, 0, 1];
63
- var p = m[1].split(",").map(function (s) {
64
- return parseFloat(s.trim());
65
- });
66
- return [p[0], p[1], p[2], p[3] != null ? p[3] : 1];
67
- }
68
-
69
- // Like parseColor, but returns null instead of defaulting to black when the
70
- // value isn't a solid rgb()/rgba() color — e.g. SVG paint keywords such as
71
- // "none"/"context-fill", or a gradient/pattern reference like
72
- // 'url("#grad")'. Callers should fall back to another source of truth
73
- // rather than trust a fabricated black.
74
- function tryParseSolidColor(c) {
75
- var m = c.match(/rgba?\(([^)]+)\)/);
76
- if (!m) return null;
61
+ var m = (c || "").match(/^rgba?\(([^)]+)\)$/i);
62
+ if (!m) {
63
+ var mix = (c || "").match(
64
+ /^color-mix\(\s*in\s+srgb\s*,\s*(rgba?\([^)]+\))\s+(\d+(?:\.\d+)?|\.\d+)%\s*,\s*(rgba?\([^)]+\))\s+(\d+(?:\.\d+)?|\.\d+)%\s*\)$/i,
65
+ );
66
+ if (!mix) return null;
67
+ var first = parseColor(mix[1]);
68
+ var second = parseColor(mix[3]);
69
+ var firstWeight = parseFloat(mix[2]);
70
+ var secondWeight = parseFloat(mix[4]);
71
+ var weightTotal = firstWeight + secondWeight;
72
+ if (!first || !second || !isFinite(weightTotal) || weightTotal <= 0) return null;
73
+ return first.map(function (channel, index) {
74
+ return (channel * firstWeight + second[index] * secondWeight) / weightTotal;
75
+ });
76
+ }
77
77
  var p = m[1].split(",").map(function (s) {
78
78
  return parseFloat(s.trim());
79
79
  });
80
80
  if (
81
+ (p.length !== 3 && p.length !== 4) ||
81
82
  p.some(function (v) {
82
- return isNaN(v);
83
+ return !isFinite(v);
83
84
  })
84
85
  )
85
86
  return null;
@@ -218,8 +219,12 @@ window.__contrastAuditPrepare = function () {
218
219
  // `color` rather than crashing parseColor or reporting a fabricated
219
220
  // black.
220
221
  var isSvgText = isSvgTextElement(el);
221
- var fg = isSvgText ? tryParseSolidColor(cs.fill) || parseColor(cs.color) : parseColor(cs.color);
222
+ var fg = isSvgText ? parseColor(cs.fill) || parseColor(cs.color) : parseColor(cs.color);
223
+ if (!fg) continue;
222
224
  if (fg[3] <= 0.01) continue;
225
+ var strokeWidth = parseFloat(cs.webkitTextStrokeWidth || "0");
226
+ var stroke = strokeWidth > 0 ? parseColor(cs.webkitTextStrokeColor || "") : null;
227
+ if (stroke && stroke[3] <= 0.01) stroke = null;
223
228
 
224
229
  var fontSize = parseFloat(cs.fontSize);
225
230
  var fontWeight = Number(cs.fontWeight) || 400;
@@ -251,6 +256,13 @@ window.__contrastAuditPrepare = function () {
251
256
  origFillPriority = el.style.getPropertyPriority("fill");
252
257
  el.style.setProperty("fill", "transparent", "important");
253
258
  }
259
+ var origStrokeColor = null,
260
+ origStrokeColorPriority = null;
261
+ if (stroke) {
262
+ origStrokeColor = el.style.getPropertyValue("-webkit-text-stroke-color");
263
+ origStrokeColorPriority = el.style.getPropertyPriority("-webkit-text-stroke-color");
264
+ el.style.setProperty("-webkit-text-stroke-color", "transparent", "important");
265
+ }
254
266
  restores.push({
255
267
  el: el,
256
268
  origTransition: origTransition,
@@ -259,6 +271,9 @@ window.__contrastAuditPrepare = function () {
259
271
  origColorPriority: origColorPriority,
260
272
  origFill: origFill,
261
273
  origFillPriority: origFillPriority,
274
+ origStrokeColor: origStrokeColor,
275
+ origStrokeColorPriority: origStrokeColorPriority,
276
+ hasStroke: !!stroke,
262
277
  isSvgText: isSvgText,
263
278
  });
264
279
 
@@ -266,6 +281,7 @@ window.__contrastAuditPrepare = function () {
266
281
  selector: selectorOf(el),
267
282
  text: (el.textContent || "").trim().slice(0, 50),
268
283
  fg: fg,
284
+ stroke: stroke,
269
285
  fontSize: fontSize,
270
286
  fontWeight: fontWeight,
271
287
  large: large,
@@ -287,6 +303,15 @@ function __contrastAuditRestoreAll() {
287
303
  if (r.origFill) r.el.style.setProperty("fill", r.origFill, r.origFillPriority);
288
304
  else r.el.style.removeProperty("fill");
289
305
  }
306
+ if (r.hasStroke) {
307
+ if (r.origStrokeColor)
308
+ r.el.style.setProperty(
309
+ "-webkit-text-stroke-color",
310
+ r.origStrokeColor,
311
+ r.origStrokeColorPriority,
312
+ );
313
+ else r.el.style.removeProperty("-webkit-text-stroke-color");
314
+ }
290
315
  if (r.origTransition)
291
316
  r.el.style.setProperty("transition", r.origTransition, r.origTransitionPriority);
292
317
  else r.el.style.removeProperty("transition");
@@ -371,17 +396,25 @@ window.__contrastAuditFinish = async function (imgBase64, time, candidates) {
371
396
  var stepY = Math.max(1, Math.floor((y1 - y0) / 6));
372
397
  var rr = [],
373
398
  gg = [],
374
- bb = [];
399
+ bb = [],
400
+ aa = [];
375
401
  for (var y = y0; y <= y1; y += stepY) {
376
402
  for (var x = x0; x <= x1; x += stepX) {
377
403
  var idx = (y * w + x) * 4;
378
404
  rr.push(px[idx]);
379
405
  gg.push(px[idx + 1]);
380
406
  bb.push(px[idx + 2]);
407
+ aa.push(px[idx + 3]);
381
408
  }
382
409
  }
383
410
  if (rr.length === 0) continue;
384
411
 
412
+ // A transparent sampled backdrop is supplied by the downstream editor,
413
+ // not this composition. WCAG contrast cannot be inferred until that live
414
+ // video/image is known, so do not invent an opaque browser default and
415
+ // hard-fail an otherwise valid alpha overlay.
416
+ if (median(aa) < 255) continue;
417
+
385
418
  var bgR = median(rr),
386
419
  bgG = median(gg),
387
420
  bgB = median(bb);
@@ -394,6 +427,23 @@ window.__contrastAuditFinish = async function (imgBase64, time, candidates) {
394
427
 
395
428
  var ratio = +wcagRatio(compR, compG, compB, bgR, bgG, bgB).toFixed(2);
396
429
 
430
+ // A solid text stroke is part of the visible glyph paint. Use whichever
431
+ // of fill or stroke provides the stronger edge contrast, rather than
432
+ // failing readable outlined captions based on fill alone.
433
+ if (c.stroke) {
434
+ var stroke = c.stroke;
435
+ var strokeR = Math.round(stroke[0] * stroke[3] + bgR * (1 - stroke[3]));
436
+ var strokeG = Math.round(stroke[1] * stroke[3] + bgG * (1 - stroke[3]));
437
+ var strokeB = Math.round(stroke[2] * stroke[3] + bgB * (1 - stroke[3]));
438
+ var strokeRatio = +wcagRatio(strokeR, strokeG, strokeB, bgR, bgG, bgB).toFixed(2);
439
+ if (strokeRatio > ratio) {
440
+ compR = strokeR;
441
+ compG = strokeG;
442
+ compB = strokeB;
443
+ ratio = strokeRatio;
444
+ }
445
+ }
446
+
397
447
  out.push({
398
448
  time: time,
399
449
  selector: c.selector,
@@ -105,6 +105,10 @@
105
105
  return !!element.closest("[data-layout-allow-overflow]");
106
106
  }
107
107
 
108
+ function hasTextClipOptOut(element) {
109
+ return hasAllowOverflowFlag(element) || element.hasAttribute("data-layout-bleed");
110
+ }
111
+
108
112
  function opacityChain(element) {
109
113
  let opacity = 1;
110
114
  for (let current = element; current; current = current.parentElement) {
@@ -394,6 +398,7 @@
394
398
  }
395
399
 
396
400
  function clippedTextIssue(element, time, tolerance) {
401
+ if (hasTextClipOptOut(element)) return null;
397
402
  const style = getComputedStyle(element);
398
403
  if (!clipsOverflow(style)) return null;
399
404
  const overflowX = element.scrollWidth - element.clientWidth;
@@ -455,7 +460,7 @@
455
460
  const containerOverflow = overflowFor(textRect, containerRect, tolerance, verticalTolerance);
456
461
  if (
457
462
  containerOverflow &&
458
- !hasAllowOverflowFlag(element) &&
463
+ !hasTextClipOptOut(element) &&
459
464
  !clippedByAncestor(element, container)
460
465
  ) {
461
466
  const style = elementStyle;
@@ -481,7 +486,7 @@
481
486
  }
482
487
 
483
488
  const canvasOverflow = overflowFor(textRect, rootRect, tolerance);
484
- if (canvasOverflow && !hasAllowOverflowFlag(element)) {
489
+ if (canvasOverflow && !hasTextClipOptOut(element)) {
485
490
  issues.push({
486
491
  code: "canvas_overflow",
487
492
  severity: "info",
@@ -730,13 +735,117 @@
730
735
 
731
736
  const RASTER_TAGS = new Set(["IMG", "VIDEO", "CANVAS"]);
732
737
  const FRAME_MEDIA_TAGS = new Set([...RASTER_TAGS, "SVG"]);
738
+ const imageAlphaCanvases = new WeakMap();
739
+
740
+ function objectPositionOffset(value, freeSpace) {
741
+ const token = String(value || "50%")
742
+ .trim()
743
+ .split(/\s+/)[0];
744
+ if (token === "left" || token === "top") return 0;
745
+ if (token === "right" || token === "bottom") return freeSpace;
746
+ if (token === "center") return freeSpace / 2;
747
+ if (token.endsWith("%")) return (freeSpace * parseFloat(token)) / 100;
748
+ const pixels = parseFloat(token);
749
+ return Number.isFinite(pixels) ? pixels : freeSpace / 2;
750
+ }
751
+
752
+ function objectPositionOffsets(value, freeX, freeY) {
753
+ const tokens = String(value || "50% 50%")
754
+ .trim()
755
+ .split(/\s+/)
756
+ .slice(0, 2);
757
+ let x = "50%";
758
+ let y = "50%";
759
+ if (tokens.length === 1) {
760
+ if (tokens[0] === "top" || tokens[0] === "bottom") y = tokens[0];
761
+ else x = tokens[0];
762
+ } else {
763
+ for (const token of tokens) {
764
+ if (token === "top" || token === "bottom") y = token;
765
+ else if (token === "left" || token === "right") x = token;
766
+ else if (x === "50%") x = token;
767
+ else y = token;
768
+ }
769
+ }
770
+ return { x: objectPositionOffset(x, freeX), y: objectPositionOffset(y, freeY) };
771
+ }
772
+
773
+ // Return the alpha painted by an <img> at a viewport point. `null` means the
774
+ // browser would not let us inspect the image (not loaded or cross-origin), in
775
+ // which case callers preserve the conservative opaque fallback.
776
+ function imageAlphaAt(element, x, y) {
777
+ const sourceWidth = element.naturalWidth;
778
+ const sourceHeight = element.naturalHeight;
779
+ const rect = element.getBoundingClientRect();
780
+ if (!sourceWidth || !sourceHeight || !rect.width || !rect.height) return null;
781
+
782
+ const style = getComputedStyle(element);
783
+ const fit = style.objectFit || "fill";
784
+ let scaleX = rect.width / sourceWidth;
785
+ let scaleY = rect.height / sourceHeight;
786
+ if (fit !== "fill") {
787
+ const contain = Math.min(scaleX, scaleY);
788
+ const cover = Math.max(scaleX, scaleY);
789
+ const scale =
790
+ fit === "cover"
791
+ ? cover
792
+ : fit === "none"
793
+ ? 1
794
+ : fit === "scale-down"
795
+ ? Math.min(1, contain)
796
+ : contain;
797
+ scaleX = scale;
798
+ scaleY = scale;
799
+ }
800
+
801
+ const paintedWidth = sourceWidth * scaleX;
802
+ const paintedHeight = sourceHeight * scaleY;
803
+ const offsets = objectPositionOffsets(
804
+ style.objectPosition,
805
+ rect.width - paintedWidth,
806
+ rect.height - paintedHeight,
807
+ );
808
+ const localX = x - rect.left - offsets.x;
809
+ const localY = y - rect.top - offsets.y;
810
+ if (localX < 0 || localY < 0 || localX >= paintedWidth || localY >= paintedHeight) return 0;
811
+
812
+ try {
813
+ let cached = imageAlphaCanvases.get(element);
814
+ const source = element.currentSrc || element.src;
815
+ if (
816
+ !cached ||
817
+ cached.width !== sourceWidth ||
818
+ cached.height !== sourceHeight ||
819
+ cached.source !== source
820
+ ) {
821
+ const canvas = document.createElement("canvas");
822
+ canvas.width = sourceWidth;
823
+ canvas.height = sourceHeight;
824
+ const context = canvas.getContext("2d", { willReadFrequently: true });
825
+ if (!context) return null;
826
+ context.drawImage(element, 0, 0, sourceWidth, sourceHeight);
827
+ cached = { context, width: sourceWidth, height: sourceHeight, source };
828
+ imageAlphaCanvases.set(element, cached);
829
+ }
830
+ const sourceX = Math.min(sourceWidth - 1, Math.max(0, Math.floor(localX / scaleX)));
831
+ const sourceY = Math.min(sourceHeight - 1, Math.max(0, Math.floor(localY / scaleY)));
832
+ return cached.context.getImageData(sourceX, sourceY, 1, 1).data[3] / 255;
833
+ } catch {
834
+ return null;
835
+ }
836
+ }
733
837
 
734
838
  // An element hides text beneath it when it paints opaque pixels at near-full
735
839
  // opacity: raster content (img/video/canvas), a background image, or a solid
736
840
  // background colour. Low-opacity overlays (grain, scrims) do not occlude.
737
- function isOpaqueOccluder(element) {
738
- if (opacityChain(element) < 0.6) return false;
841
+ function isOpaqueOccluder(element, x, y) {
842
+ const opacity = opacityChain(element);
843
+ if (opacity < 0.6) return false;
739
844
  if (IGNORE_TAGS.has(element.tagName)) return false;
845
+ if (element.tagName === "IMG") {
846
+ const alpha = imageAlphaAt(element, x, y);
847
+ if (alpha !== null) return alpha * opacity >= 0.6;
848
+ }
740
849
  if (RASTER_TAGS.has(element.tagName)) return true;
741
850
  return hasOpaqueBackground(getComputedStyle(element));
742
851
  }
@@ -798,7 +907,7 @@
798
907
  // Pair-specific exemptions excuse this hit only; keep walking for deeper occluders.
799
908
  if (sharedPreserve3d(element, hit)) continue;
800
909
  if (isCrossSceneTransitionOverlap(element, hit)) continue;
801
- if (isOpaqueOccluder(hit)) return hit;
910
+ if (isOpaqueOccluder(hit, x, y)) return hit;
802
911
  }
803
912
  return null;
804
913
  }