hyperframes 0.7.46 → 0.7.47

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.
@@ -2,25 +2,59 @@
2
2
  // Loaded as a raw string and injected via page.addScriptTag to avoid
3
3
  // esbuild mangling (page.evaluate serializes functions; __name helpers break).
4
4
  //
5
- // NOTE: WCAG math (relLum, wcagRatio, parseColor, median) is duplicated in
6
- // skills/hyperframes/scripts/contrast-report.mjs keep in sync.
5
+ // Two-phase API see packages/cli/src/commands/validate.ts's
6
+ // runContrastAudit() for the calling contract:
7
+ //
8
+ // 1. window.__contrastAuditPrepare() walks the DOM for text-bearing
9
+ // elements, computes each one's foreground paint (CSS `color`, or SVG
10
+ // `fill` for SVG text — fill and color are independent CSS properties
11
+ // in SVG), and HIDES that element's own text paint (color/fill set to
12
+ // transparent). Hiding is layout-neutral — it doesn't reflow anything —
13
+ // so the caller can screenshot the frame right after with the glyphs
14
+ // invisible but everything else unchanged. Returns the candidate list
15
+ // (selector, text, fg, bbox, font metrics).
16
+ // 2. Caller takes ONE screenshot (page.screenshot()) — same number of
17
+ // screenshots as before, just moved to after prepare() instead of
18
+ // before it.
19
+ // 3. window.__contrastAuditFinish(imgBase64, time, candidates) restores
20
+ // the original paint FIRST (so a slow/failed decode can't leave the
21
+ // page's text stuck invisible), then decodes the screenshot and, for
22
+ // each candidate, samples the real composited pixels directly INSIDE
23
+ // its own bounding box for the true background.
24
+ //
25
+ // Why sample inside the box instead of the 4px ring just outside it (the
26
+ // previous approach): the ring is a proximity heuristic that's wrong
27
+ // whenever what's immediately outside the text differs from what's actually
28
+ // behind it — a neighboring panel/component just past the text's edge, a
29
+ // rounded pill/button whose corner falls inside the ring, a
30
+ // backdrop-filter-blurred glass panel sized only a couple pixels larger
31
+ // than the text, or a translucent decoration that only partially overlaps
32
+ // the ring (or sits entirely inside the bbox, never touching the ring at
33
+ // all). Hiding the glyphs and sampling their own box side-steps all of
34
+ // that: it reads the exact pixels that were behind them.
35
+ //
36
+ // window.__contrastAuditRestoreIfPending() is a safety net: if the caller's
37
+ // screenshot or finish() call throws between prepare() and finish(), calling
38
+ // this restores any still-hidden paint so the next sample in the loop
39
+ // doesn't audit a page with stale invisible text. It's a no-op after a
40
+ // normal finish() call.
41
+ //
42
+ // NOTE: this logic (DOM-walk, foreground/paint-hide, background sampling)
43
+ // plus the pure WCAG math (relLum, wcagRatio, median) is duplicated in
44
+ // skills/hyperframes-creative/scripts/contrast-report.mjs — keep in sync.
45
+ // The pure "which rect to sample" decision is also mirrored in
46
+ // contrast-sample.ts (unit-tested there since this file can't import).
7
47
 
8
48
  /* eslint-disable */
9
- window.__contrastAudit = async function (imgBase64, time) {
10
- function relLum(r, g, b) {
11
- function ch(v) {
12
- var s = v / 255;
13
- return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
14
- }
15
- return 0.2126 * ch(r) + 0.7152 * ch(g) + 0.0722 * ch(b);
16
- }
17
-
18
- function wcagRatio(r1, g1, b1, r2, g2, b2) {
19
- var l1 = relLum(r1, g1, b1),
20
- l2 = relLum(r2, g2, b2);
21
- var hi = l1 > l2 ? l1 : l2,
22
- lo = l1 > l2 ? l2 : l1;
23
- return (hi + 0.05) / (lo + 0.05);
49
+ window.__contrastAuditPrepare = function () {
50
+ // SVG text (<text>, <tspan>, <textPath>) is painted via the `fill`
51
+ // property, not `color` — the two are independent CSS properties in SVG.
52
+ // A page can set `fill` (inline style, `fill` attribute, or a CSS rule)
53
+ // without ever touching `color`, in which case getComputedStyle(el).color
54
+ // resolves to the inherited/initial value (often black) and does not
55
+ // reflect what's actually rendered on screen.
56
+ function isSvgTextElement(el) {
57
+ return !!el.ownerSVGElement;
24
58
  }
25
59
 
26
60
  function parseColor(c) {
@@ -32,19 +66,32 @@ window.__contrastAudit = async function (imgBase64, time) {
32
66
  return [p[0], p[1], p[2], p[3] != null ? p[3] : 1];
33
67
  }
34
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;
77
+ var p = m[1].split(",").map(function (s) {
78
+ return parseFloat(s.trim());
79
+ });
80
+ if (
81
+ p.some(function (v) {
82
+ return isNaN(v);
83
+ })
84
+ )
85
+ return null;
86
+ return [p[0], p[1], p[2], p[3] != null ? p[3] : 1];
87
+ }
88
+
35
89
  function selectorOf(el) {
36
90
  if (el.id) return "#" + el.id;
37
91
  var cls = Array.from(el.classList).slice(0, 2).join(".");
38
92
  return cls ? el.tagName.toLowerCase() + "." + cls : el.tagName.toLowerCase();
39
93
  }
40
94
 
41
- function median(arr) {
42
- var s = arr.slice().sort(function (a, b) {
43
- return a - b;
44
- });
45
- return s[Math.floor(s.length / 2)];
46
- }
47
-
48
95
  function hasClipPath(el) {
49
96
  for (var ce = el; ce; ce = ce.parentElement) {
50
97
  var cp = getComputedStyle(ce).clipPath;
@@ -84,28 +131,15 @@ window.__contrastAudit = async function (imgBase64, time) {
84
131
  return !paintsAnyProbePoint(el, rect);
85
132
  }
86
133
 
87
- // Decode screenshot into canvas pixel data
88
- var img = new Image();
89
- await new Promise(function (resolve) {
90
- img.onload = resolve;
91
- img.onerror = function () {
92
- resolve();
93
- };
94
- img.src = "data:image/png;base64," + imgBase64;
95
- });
96
- if (!img.naturalWidth) return [];
97
- var canvas = document.createElement("canvas");
98
- canvas.width = img.naturalWidth || 1920;
99
- canvas.height = img.naturalHeight || 1080;
100
- var ctx = canvas.getContext("2d");
101
- if (!ctx) return [];
102
- ctx.drawImage(img, 0, 0);
103
- var px = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
104
- var w = canvas.width;
105
- var h = canvas.height;
106
-
107
- // Walk DOM for text elements
108
134
  var out = [];
135
+ var restores = [];
136
+ // Registered BEFORE the walk starts (not after it finishes) and pushed to
137
+ // incrementally as each element is hidden: if getComputedStyle/
138
+ // getBoundingClientRect/etc. throws partway through the walk (e.g. on a
139
+ // detached or otherwise pathological element), everything hidden before
140
+ // the throw is still reachable for restore instead of leaking hidden
141
+ // indefinitely.
142
+ window.__contrastAuditRestores = restores;
109
143
  var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
110
144
  var node;
111
145
  while ((node = walker.nextNode())) {
@@ -148,85 +182,199 @@ window.__contrastAudit = async function (imgBase64, time) {
148
182
  if (ancHidden) continue;
149
183
  var rect = el.getBoundingClientRect();
150
184
  if (rect.width < 8 || rect.height < 8) continue;
151
- if (rect.right <= 0 || rect.bottom <= 0 || rect.left >= w || rect.top >= h) continue;
185
+ if (rect.right <= 0 || rect.bottom <= 0) continue;
152
186
  if (isClippedAway(el, rect)) continue;
153
187
 
154
- var fg = parseColor(cs.color);
188
+ // For SVG text, `fill` is the paint that's actually rendered; `color` is
189
+ // frequently just the inherited/initial value and unrelated to what's on
190
+ // screen. Only trust `fill` when it resolves to a solid color — "none",
191
+ // "context-fill", and gradient/pattern refs (url(#...)) fall back to
192
+ // `color` rather than crashing parseColor or reporting a fabricated
193
+ // black.
194
+ var isSvgText = isSvgTextElement(el);
195
+ var fg = isSvgText ? tryParseSolidColor(cs.fill) || parseColor(cs.color) : parseColor(cs.color);
155
196
  if (fg[3] <= 0.01) continue;
156
197
 
157
- // Prefer an opaque own/ancestor background-color over the pixel ring. A
158
- // caption/CTA that paints its OWN solid background (a pill, a button, a
159
- // card) composites its text over THAT color, not over whatever surrounds
160
- // the box. Sampling the ring there measured the text against the scene
161
- // behind the element (often a dark photo) and reported false ~1:1 warnings
162
- // for perfectly readable CTAs. Walk up until a fully-opaque background-color
163
- // is found; stop and defer to the ring on the first background-image (text
164
- // over real pixels). Keep this in sync with commands/contrast-bg.ts.
165
- var ownBg = null;
166
- for (var bn = el; bn && bn !== document.body; bn = bn.parentElement) {
167
- var bcs = getComputedStyle(bn);
168
- if (bcs.backgroundImage && bcs.backgroundImage !== "none") break;
169
- var bc = parseColor(bcs.backgroundColor);
170
- if (bc[3] >= 0.999) {
171
- ownBg = [bc[0], bc[1], bc[2]];
172
- break;
173
- }
198
+ var fontSize = parseFloat(cs.fontSize);
199
+ var fontWeight = Number(cs.fontWeight) || 400;
200
+ var large = fontSize >= 24 || (fontSize >= 19 && fontWeight >= 700);
201
+
202
+ // Hide this element's OWN text paint so the caller's next screenshot
203
+ // reveals the true pixels behind the glyphs. Layout-neutral: color/fill
204
+ // never affect box geometry. `!important` beats any non-!important rule
205
+ // that might otherwise win on specificity; we restore the exact prior
206
+ // inline value (or remove the property entirely) afterward.
207
+ //
208
+ // A `transition` on color/fill would otherwise animate this hide instead
209
+ // of applying it instantly the caller's screenshot lands in the same
210
+ // task-queue gap as the transition's own frames, so it can catch a
211
+ // partially-transparent (still partly the original color) glyph instead
212
+ // of a fully hidden one, contaminating the background sample. Force
213
+ // `transition: none` alongside color/fill so the hide is atomic, and
214
+ // restore it alongside them.
215
+ var origTransition = el.style.getPropertyValue("transition");
216
+ var origTransitionPriority = el.style.getPropertyPriority("transition");
217
+ el.style.setProperty("transition", "none", "important");
218
+ var origColor = el.style.getPropertyValue("color");
219
+ var origColorPriority = el.style.getPropertyPriority("color");
220
+ el.style.setProperty("color", "transparent", "important");
221
+ var origFill = null,
222
+ origFillPriority = null;
223
+ if (isSvgText) {
224
+ origFill = el.style.getPropertyValue("fill");
225
+ origFillPriority = el.style.getPropertyPriority("fill");
226
+ el.style.setProperty("fill", "transparent", "important");
174
227
  }
228
+ restores.push({
229
+ el: el,
230
+ origTransition: origTransition,
231
+ origTransitionPriority: origTransitionPriority,
232
+ origColor: origColor,
233
+ origColorPriority: origColorPriority,
234
+ origFill: origFill,
235
+ origFillPriority: origFillPriority,
236
+ isSvgText: isSvgText,
237
+ });
238
+
239
+ out.push({
240
+ selector: selectorOf(el),
241
+ text: (el.textContent || "").trim().slice(0, 50),
242
+ fg: fg,
243
+ fontSize: fontSize,
244
+ fontWeight: fontWeight,
245
+ large: large,
246
+ bbox: { x: rect.x, y: rect.y, w: rect.width, h: rect.height },
247
+ });
248
+ }
175
249
 
176
- var bgR, bgG, bgB;
177
- if (ownBg) {
178
- bgR = ownBg[0];
179
- bgG = ownBg[1];
180
- bgB = ownBg[2];
181
- } else {
182
- // Sample 4px ring outside bbox for background color
183
- var rr = [],
184
- gg = [],
185
- bb = [];
186
- var x0 = Math.max(0, Math.floor(rect.x) - 4);
187
- var x1 = Math.min(w - 1, Math.ceil(rect.x + rect.width) + 4);
188
- var y0 = Math.max(0, Math.floor(rect.y) - 4);
189
- var y1 = Math.min(h - 1, Math.ceil(rect.y + rect.height) + 4);
190
- var sample = function (sx, sy) {
191
- if (sx < 0 || sx >= w || sy < 0 || sy >= h) return;
192
- var idx = (sy * w + sx) * 4;
250
+ return out;
251
+ };
252
+
253
+ function __contrastAuditRestoreAll() {
254
+ var restores = window.__contrastAuditRestores;
255
+ if (!restores) return;
256
+ for (var i = 0; i < restores.length; i++) {
257
+ var r = restores[i];
258
+ if (r.origColor) r.el.style.setProperty("color", r.origColor, r.origColorPriority);
259
+ else r.el.style.removeProperty("color");
260
+ if (r.isSvgText) {
261
+ if (r.origFill) r.el.style.setProperty("fill", r.origFill, r.origFillPriority);
262
+ else r.el.style.removeProperty("fill");
263
+ }
264
+ if (r.origTransition)
265
+ r.el.style.setProperty("transition", r.origTransition, r.origTransitionPriority);
266
+ else r.el.style.removeProperty("transition");
267
+ }
268
+ window.__contrastAuditRestores = null;
269
+ }
270
+
271
+ // Safety net for the caller: if the screenshot or finish() call throws
272
+ // between prepare() and finish(), call this to restore any still-hidden
273
+ // paint so the next sample in the loop isn't auditing a page with stale
274
+ // invisible text. No-op if finish() already ran normally.
275
+ window.__contrastAuditRestoreIfPending = function () {
276
+ __contrastAuditRestoreAll();
277
+ };
278
+
279
+ window.__contrastAuditFinish = async function (imgBase64, time, candidates) {
280
+ function relLum(r, g, b) {
281
+ function ch(v) {
282
+ var s = v / 255;
283
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
284
+ }
285
+ return 0.2126 * ch(r) + 0.7152 * ch(g) + 0.0722 * ch(b);
286
+ }
287
+
288
+ function wcagRatio(r1, g1, b1, r2, g2, b2) {
289
+ var l1 = relLum(r1, g1, b1),
290
+ l2 = relLum(r2, g2, b2);
291
+ var hi = l1 > l2 ? l1 : l2,
292
+ lo = l1 > l2 ? l2 : l1;
293
+ return (hi + 0.05) / (lo + 0.05);
294
+ }
295
+
296
+ function median(arr) {
297
+ var s = arr.slice().sort(function (a, b) {
298
+ return a - b;
299
+ });
300
+ return s[Math.floor(s.length / 2)];
301
+ }
302
+
303
+ // Restore original paint first — we already have the screenshot, and we
304
+ // never want a decode failure below to leave the page's text invisible.
305
+ __contrastAuditRestoreAll();
306
+
307
+ var img = new Image();
308
+ await new Promise(function (resolve) {
309
+ img.onload = resolve;
310
+ img.onerror = function () {
311
+ resolve();
312
+ };
313
+ img.src = "data:image/png;base64," + imgBase64;
314
+ });
315
+ if (!img.naturalWidth) return [];
316
+ var canvas = document.createElement("canvas");
317
+ canvas.width = img.naturalWidth || 1920;
318
+ canvas.height = img.naturalHeight || 1080;
319
+ var ctx = canvas.getContext("2d");
320
+ if (!ctx) return [];
321
+ ctx.drawImage(img, 0, 0);
322
+ var px = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
323
+ var w = canvas.width;
324
+ var h = canvas.height;
325
+
326
+ var out = [];
327
+ for (var ci = 0; ci < candidates.length; ci++) {
328
+ var c = candidates[ci];
329
+ var bbox = c.bbox;
330
+
331
+ // Sample the element's OWN box (glyphs are hidden in this screenshot),
332
+ // inset 1px on each side to dodge anti-aliased edge pixels, clamped to
333
+ // the canvas. Mirrors contrast-sample.ts's computeSampleRect.
334
+ var x0 = Math.max(0, Math.round(bbox.x) + 1);
335
+ var x1 = Math.min(w - 1, Math.round(bbox.x + bbox.w) - 1);
336
+ var y0 = Math.max(0, Math.round(bbox.y) + 1);
337
+ var y1 = Math.min(h - 1, Math.round(bbox.y + bbox.h) - 1);
338
+ if (x1 <= x0 || y1 <= y0) continue;
339
+
340
+ // Bounded grid, not a full scan — dense enough to catch a
341
+ // partially-overlapping decoration without turning a wide caption bar
342
+ // into thousands of samples. Mirrors contrast-sample.ts's
343
+ // sampleGridPoints.
344
+ var stepX = Math.max(1, Math.floor((x1 - x0) / 12));
345
+ var stepY = Math.max(1, Math.floor((y1 - y0) / 6));
346
+ var rr = [],
347
+ gg = [],
348
+ bb = [];
349
+ for (var y = y0; y <= y1; y += stepY) {
350
+ for (var x = x0; x <= x1; x += stepX) {
351
+ var idx = (y * w + x) * 4;
193
352
  rr.push(px[idx]);
194
353
  gg.push(px[idx + 1]);
195
354
  bb.push(px[idx + 2]);
196
- };
197
- for (var x = x0; x <= x1; x++) {
198
- sample(x, y0);
199
- sample(x, y1);
200
355
  }
201
- for (var y = y0; y <= y1; y++) {
202
- sample(x0, y);
203
- sample(x1, y);
204
- }
205
-
206
- if (rr.length === 0) continue;
356
+ }
357
+ if (rr.length === 0) continue;
207
358
 
208
- bgR = median(rr);
209
- bgG = median(gg);
359
+ var bgR = median(rr),
360
+ bgG = median(gg),
210
361
  bgB = median(bb);
211
- }
212
362
 
213
- // Composite foreground alpha over measured background
363
+ // Composite foreground alpha over the measured background
364
+ var fg = c.fg;
214
365
  var compR = Math.round(fg[0] * fg[3] + bgR * (1 - fg[3]));
215
366
  var compG = Math.round(fg[1] * fg[3] + bgG * (1 - fg[3]));
216
367
  var compB = Math.round(fg[2] * fg[3] + bgB * (1 - fg[3]));
217
368
 
218
369
  var ratio = +wcagRatio(compR, compG, compB, bgR, bgG, bgB).toFixed(2);
219
- var fontSize = parseFloat(cs.fontSize);
220
- var fontWeight = Number(cs.fontWeight) || 400;
221
- var large = fontSize >= 24 || (fontSize >= 19 && fontWeight >= 700);
222
370
 
223
371
  out.push({
224
372
  time: time,
225
- selector: selectorOf(el),
226
- text: (el.textContent || "").trim().slice(0, 50),
373
+ selector: c.selector,
374
+ text: c.text,
227
375
  ratio: ratio,
228
- wcagAA: large ? ratio >= 3 : ratio >= 4.5,
229
- large: large,
376
+ wcagAA: c.large ? ratio >= 3 : ratio >= 4.5,
377
+ large: c.large,
230
378
  fg: "rgb(" + compR + "," + compG + "," + compB + ")",
231
379
  bg: "rgb(" + bgR + "," + bgG + "," + bgB + ")",
232
380
  });