@designesy/score 0.9.1 → 0.9.2

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.
Files changed (3) hide show
  1. package/LICENSE +21 -0
  2. package/dist/engine.js +2350 -264
  3. package/package.json +1 -1
package/dist/engine.js CHANGED
@@ -267,17 +267,2046 @@ function httpsFetch(url, headers, timeoutMs = 8000, hop = 0) {
267
267
  }
268
268
  });
269
269
  }
270
+ // ── Linear-time scanning, shared by both engine copies (engine 1.5.1) ──────
271
+ // Each helper below returns exactly what the regular expression it replaced
272
+ // returned: the same matches at the same indexes with the same groups, or the
273
+ // same string after the same replace. The replaced patterns retried from every
274
+ // candidate start and rescanned the rest of the run each time, so their cost
275
+ // grew with the square (one url() pattern with the cube) of a long run of the
276
+ // right characters on a fetched page. These read each run once.
277
+ // test/linear-scan.test.mjs runs every helper against the pattern it replaced,
278
+ // over the fixture pages and generated input, and times each on 100 KB.
279
+ /** JavaScript's \s: WhiteSpace and LineTerminator code units. */
280
+ function isJsSpace(c) {
281
+ return c === 32 || (c >= 9 && c <= 13) || c === 0xa0 || c === 0x1680 || (c >= 0x2000 && c <= 0x200a)
282
+ || c === 0x2028 || c === 0x2029 || c === 0x202f || c === 0x205f || c === 0x3000 || c === 0xfeff;
283
+ }
284
+ /** The index after the run of \s that starts at `i`. */
285
+ function wsEnd(s, i) {
286
+ while (i < s.length && isJsSpace(s.charCodeAt(i)))
287
+ i++;
288
+ return i;
289
+ }
290
+ /**
291
+ * Spans of OPEN[\s\S]*?CLOSE, global and leftmost, as [start, end, ...] pairs.
292
+ * `open` matches a fixed-length opener, `close` the closer; both global. A
293
+ * closer is looked for once per opener, and when there is none after one
294
+ * opener there is none after any later one, so the scan stops there.
295
+ */
296
+ function lazyBlockSpans(text, open, close) {
297
+ const spans = [];
298
+ let pos = 0;
299
+ for (;;) {
300
+ open.lastIndex = pos;
301
+ const o = open.exec(text);
302
+ if (!o)
303
+ break;
304
+ close.lastIndex = o.index + o[0].length;
305
+ const c = close.exec(text);
306
+ if (!c)
307
+ break;
308
+ spans.push(o.index, c.index + c[0].length);
309
+ pos = c.index + c[0].length;
310
+ }
311
+ return spans;
312
+ }
313
+ /** `text` with each [start, end) span replaced by `rep`. */
314
+ function replaceSpans(text, spans, rep) {
315
+ if (spans.length === 0)
316
+ return text;
317
+ const parts = [];
318
+ let last = 0;
319
+ for (let k = 0; k < spans.length; k += 2) {
320
+ parts.push(text.slice(last, spans[k]), rep);
321
+ last = spans[k + 1];
322
+ }
323
+ parts.push(text.slice(last));
324
+ return parts.join('');
325
+ }
326
+ /** html.replace(/<script[\s\S]*?<\/script>/gi, '').replace(/<style[\s\S]*?<\/style>/gi, '') */
327
+ function withoutScriptsAndStyles(html) {
328
+ const noScripts = replaceSpans(html, lazyBlockSpans(html, /<script/gi, /<\/script>/gi), '');
329
+ return replaceSpans(noScripts, lazyBlockSpans(noScripts, /<style/gi, /<\/style>/gi), '');
330
+ }
331
+ /** css.replace(/\/\*[\s\S]*?\*\//g, rep) */
332
+ function withoutCssComments(css, rep) {
333
+ return replaceSpans(css, lazyBlockSpans(css, /\/\*/g, /\*\//g), rep);
334
+ }
335
+ /** text.replace(/<[^>]*>/g, rep), or /<[^>]+>/g when `nonEmpty`. */
336
+ function withoutTags(text, rep, nonEmpty) {
337
+ const spans = [];
338
+ let pos = 0;
339
+ for (;;) {
340
+ const lt = text.indexOf('<', pos);
341
+ if (lt < 0)
342
+ break;
343
+ if (nonEmpty && text.charCodeAt(lt + 1) === 62) {
344
+ pos = lt + 1;
345
+ continue;
346
+ }
347
+ const gt = text.indexOf('>', lt + 1);
348
+ if (gt < 0)
349
+ break;
350
+ spans.push(lt, gt + 1);
351
+ pos = gt + 1;
352
+ }
353
+ return replaceSpans(text, spans, rep);
354
+ }
355
+ /**
356
+ * Matches of <TAG[^>]*>([\s\S]*?)<\/TAG> (global): `open` matches `<TAG`,
357
+ * `close` the closing tag, both global. groups[0] is the content.
358
+ */
359
+ function elementMatches(html, open, close) {
360
+ const out = [];
361
+ let pos = 0;
362
+ for (;;) {
363
+ open.lastIndex = pos;
364
+ const o = open.exec(html);
365
+ if (!o)
366
+ break;
367
+ const gt = html.indexOf('>', o.index + o[0].length);
368
+ if (gt < 0)
369
+ break;
370
+ close.lastIndex = gt + 1;
371
+ const c = close.exec(html);
372
+ if (!c)
373
+ break;
374
+ const end = c.index + c[0].length;
375
+ out.push({ index: o.index, text: html.slice(o.index, end), groups: [html.slice(gt + 1, c.index)] });
376
+ pos = end;
377
+ }
378
+ return out;
379
+ }
380
+ /**
381
+ * Matches of <NAME[^>]*ATTR[^>]*>([\s\S]*?)CLOSE (global). `open` matches `<`
382
+ * and as much of the name as must precede the attribute; `attr` and `close`
383
+ * are global. An opener whose tag holds no ATTR rules out every opener before
384
+ * the same `>`; one with no CLOSE after it rules out every later one.
385
+ */
386
+ function roleElementMatches(html, open, attr, close) {
387
+ const out = [];
388
+ let pos = 0;
389
+ let attrFrom = -1;
390
+ let attrAt = -1;
391
+ for (;;) {
392
+ open.lastIndex = pos;
393
+ const o = open.exec(html);
394
+ if (!o)
395
+ break;
396
+ const gt = html.indexOf('>', o.index + 1);
397
+ if (gt < 0)
398
+ break;
399
+ const from = o.index + o[0].length;
400
+ if (!(attrFrom >= 0 && from >= attrFrom && from <= attrAt)) {
401
+ attr.lastIndex = from;
402
+ const a = attr.exec(html);
403
+ attrFrom = from;
404
+ attrAt = a ? a.index : Infinity;
405
+ }
406
+ if (attrAt >= gt) {
407
+ pos = gt + 1;
408
+ continue;
409
+ }
410
+ close.lastIndex = gt + 1;
411
+ const c = close.exec(html);
412
+ if (!c)
413
+ break;
414
+ const end = c.index + c[0].length;
415
+ out.push({ index: o.index, text: html.slice(o.index, end), groups: [html.slice(gt + 1, c.index)] });
416
+ pos = end;
417
+ }
418
+ return out;
419
+ }
420
+ /** v41: matches of /<(button|a|label|td|th|p|li|h[1-6])\s([^>]*?)>([\s\S]*?)<\/\1>/gi, groups [tag, attrs, content]. */
421
+ function capsElementMatches(html) {
422
+ const out = [];
423
+ const open = /<(button|a|label|td|th|p|li|h[1-6])\s/gi;
424
+ const closers = {};
425
+ const noCloserFrom = {};
426
+ let gtFrom = -1;
427
+ let gtAt = -1;
428
+ let pos = 0;
429
+ for (;;) {
430
+ open.lastIndex = pos;
431
+ const o = open.exec(html);
432
+ if (!o)
433
+ break;
434
+ const from = o.index + o[0].length;
435
+ if (!(gtFrom >= 0 && from >= gtFrom && from <= gtAt)) {
436
+ gtFrom = from;
437
+ gtAt = html.indexOf('>', from);
438
+ }
439
+ if (gtAt < 0)
440
+ break;
441
+ const tag = o[1].toLowerCase();
442
+ if (noCloserFrom[tag] !== undefined && gtAt + 1 >= noCloserFrom[tag]) {
443
+ pos = o.index + 1;
444
+ continue;
445
+ }
446
+ const close = closers[tag] ?? (closers[tag] = new RegExp(`</${tag}>`, 'gi'));
447
+ close.lastIndex = gtAt + 1;
448
+ const c = close.exec(html);
449
+ if (!c) {
450
+ noCloserFrom[tag] = gtAt + 1;
451
+ pos = o.index + 1;
452
+ continue;
453
+ }
454
+ const end = c.index + c[0].length;
455
+ out.push({ index: o.index, text: html.slice(o.index, end), groups: [o[1], html.slice(from, gtAt), html.slice(gtAt + 1, c.index)] });
456
+ pos = end;
457
+ }
458
+ return out;
459
+ }
460
+ /**
461
+ * /NAME[^"']*NEEDLE[^"']*["']/i.test(attrs), where `name` (global) matches
462
+ * the attribute name, `=` and its opening quote, and `needle` (global) holds
463
+ * no quote: true when NEEDLE sits wholly inside one quoted value that closes.
464
+ */
465
+ function quotedAttrHas(attrs, name, needle) {
466
+ let pos = 0;
467
+ let needleFrom = -1;
468
+ let needleAt = -1;
469
+ for (;;) {
470
+ name.lastIndex = pos;
471
+ const n = name.exec(attrs);
472
+ if (!n)
473
+ return false;
474
+ const from = n.index + n[0].length;
475
+ let q = from;
476
+ while (q < attrs.length && attrs[q] !== '"' && attrs[q] !== "'")
477
+ q++;
478
+ if (q >= attrs.length)
479
+ return false;
480
+ if (!(needleFrom >= 0 && from >= needleFrom && from <= needleAt)) {
481
+ needle.lastIndex = from;
482
+ const m = needle.exec(attrs);
483
+ needleFrom = from;
484
+ needleAt = m ? m.index : Infinity;
485
+ }
486
+ if (needleAt < q)
487
+ return true;
488
+ pos = n.index + 1;
489
+ }
490
+ }
491
+ /** The first match of a global regex at or after `from`, remembered while later queries fall before it. */
492
+ function nextMatchFinder(text, re) {
493
+ let memoFrom = -1;
494
+ let memoAt = -1;
495
+ return (from) => {
496
+ if (!(memoFrom >= 0 && from >= memoFrom && from <= memoAt)) {
497
+ re.lastIndex = from;
498
+ const m = re.exec(text);
499
+ memoFrom = from;
500
+ memoAt = m ? m.index : Infinity;
501
+ }
502
+ return memoAt;
503
+ };
504
+ }
505
+ /** Start positions of every match of a global regex, ascending. */
506
+ function matchStarts(text, re) {
507
+ const out = [];
508
+ re.lastIndex = 0;
509
+ for (let m = re.exec(text); m; m = re.exec(text)) {
510
+ out.push(m.index);
511
+ if (m[0].length === 0)
512
+ re.lastIndex++;
513
+ }
514
+ return out;
515
+ }
516
+ /**
517
+ * Matches of START[^{]*\{[^}]*BODY (global): after each start, the rule body
518
+ * from the first `{` to the first `}` must hold BODY, and the match ends where
519
+ * BODY last matches in it (the greedy [^}]*). `next(from, lastEnd)` returns the
520
+ * first start at or after `from` whose prelude reaches a `{` (brace -1 when no
521
+ * later start can), or null; it is told where the previous match ended because
522
+ * some starts reach back before their first character. `lead` (global) finds
523
+ * where BODY can begin and `body` is BODY, sticky. Starts that share a body's
524
+ * `}` share its answer, so each body is read once. `first` stops at one match.
525
+ */
526
+ function ruleBodyScan(css, next, lead, body, first) {
527
+ const out = [];
528
+ const leads = matchStarts(css, lead);
529
+ let ptr = 0;
530
+ let pos = 0;
531
+ let lastEnd = 0;
532
+ let deadUntil = -1;
533
+ for (;;) {
534
+ const st = next(pos, lastEnd);
535
+ if (!st || st.brace < 0)
536
+ break;
537
+ const b = st.brace;
538
+ if (b < deadUntil) {
539
+ pos = b + 1;
540
+ continue;
541
+ }
542
+ let c = css.indexOf('}', b + 1);
543
+ if (c < 0)
544
+ c = css.length;
545
+ deadUntil = c;
546
+ while (ptr < leads.length && leads[ptr] <= b)
547
+ ptr++;
548
+ let end = ptr;
549
+ while (end < leads.length && leads[end] <= c)
550
+ end++;
551
+ let hit = null;
552
+ for (let k = end - 1; k >= ptr && !hit; k--) {
553
+ body.lastIndex = leads[k];
554
+ hit = body.exec(css);
555
+ }
556
+ ptr = end;
557
+ if (!hit) {
558
+ pos = b + 1;
559
+ continue;
560
+ }
561
+ const stop = hit.index + hit[0].length;
562
+ out.push({ index: st.index, text: css.slice(st.index, stop), groups: hit.slice(1) });
563
+ if (first)
564
+ break;
565
+ pos = stop;
566
+ lastEnd = stop;
567
+ }
568
+ return out;
569
+ }
570
+ /** next() for ruleBodyScan when the start is a plain pattern (global) followed by [^{]*\{. */
571
+ function bracedStarts(css, start) {
572
+ const brace = nextMatchFinder(css, /\{/g);
573
+ return (from) => {
574
+ start.lastIndex = from;
575
+ const m = start.exec(css);
576
+ if (!m)
577
+ return null;
578
+ const b = brace(m.index + m[0].length);
579
+ return { index: m.index, brace: b === Infinity ? -1 : b };
580
+ };
581
+ }
582
+ /** next() for v27's floor rule: input\s*(?:,[^{]*)?\{ */
583
+ function inputFloorStarts(css) {
584
+ const start = /input/gi;
585
+ const brace = nextMatchFinder(css, /\{/g);
586
+ return (from) => {
587
+ for (let at = from;;) {
588
+ start.lastIndex = at;
589
+ const m = start.exec(css);
590
+ if (!m)
591
+ return null;
592
+ const p = wsEnd(css, m.index + 5);
593
+ if (css.charCodeAt(p) === 123)
594
+ return { index: m.index, brace: p };
595
+ if (css.charCodeAt(p) === 44) {
596
+ const b = brace(p + 1);
597
+ return { index: m.index, brace: b === Infinity ? -1 : b };
598
+ }
599
+ at = m.index + 1;
600
+ }
601
+ };
602
+ }
603
+ /** A LineTerminator code unit, where ^ matches under the m flag. */
604
+ function isLineTerminator(c) {
605
+ return c === 10 || c === 13 || c === 0x2028 || c === 0x2029;
606
+ }
607
+ /**
608
+ * next() for v24's selector start (?:^|[,}\s])\s*(?:button|a\b|...) with the m
609
+ * flag: a keyword counts when the space before it begins on a line start, a
610
+ * space, a comma or a closing brace at or after the previous match's end.
611
+ */
612
+ function touchTargetStarts(css) {
613
+ const keyword = /button|a\b|input|textarea|select|\[role\s*=\s*["']?button["']?\]|\.btn|\.button|\.chip|\.tab|\.nav-link/gi;
614
+ const brace = nextMatchFinder(css, /\{/g);
615
+ return (from, lastEnd) => {
616
+ for (let at = from;;) {
617
+ keyword.lastIndex = at;
618
+ const k = keyword.exec(css);
619
+ if (!k)
620
+ return null;
621
+ const q = k.index;
622
+ let w = q;
623
+ while (w > lastEnd && isJsSpace(css.charCodeAt(w - 1)))
624
+ w--;
625
+ let index = -1;
626
+ if (w - 1 >= lastEnd && (css.charCodeAt(w - 1) === 44 || css.charCodeAt(w - 1) === 125))
627
+ index = w - 1;
628
+ else if (w < q)
629
+ index = w;
630
+ else if (q >= lastEnd && (q === 0 || isLineTerminator(css.charCodeAt(q - 1))))
631
+ index = q;
632
+ if (index < 0) {
633
+ at = q + 1;
634
+ continue;
635
+ }
636
+ const b = brace(q + k[0].length);
637
+ return { index, brace: b === Infinity ? -1 : b };
638
+ }
639
+ };
640
+ }
641
+ /** next() for O-motion's breath keyframes: @keyframes\s+[^{]*breath[^{]*\{ */
642
+ function breathKeyframeStarts(css) {
643
+ const start = /@keyframes/gi;
644
+ const brace = nextMatchFinder(css, /\{/g);
645
+ const breath = nextMatchFinder(css, /breath/gi);
646
+ return (from) => {
647
+ for (let at = from;;) {
648
+ start.lastIndex = at;
649
+ const m = start.exec(css);
650
+ if (!m)
651
+ return null;
652
+ const after = m.index + m[0].length;
653
+ if (!isJsSpace(css.charCodeAt(after))) {
654
+ at = m.index + 1;
655
+ continue;
656
+ }
657
+ const b = brace(after);
658
+ if (b === Infinity)
659
+ return { index: m.index, brace: -1 };
660
+ if (breath(after + 1) < b)
661
+ return { index: m.index, brace: b };
662
+ at = b + 1;
663
+ }
664
+ };
665
+ }
666
+ /**
667
+ * /START[^{]*X/i.test(css): X (global, no `{`) begins in the prelude between
668
+ * a start and the first `{` after it. Only the first start of each prelude
669
+ * needs reading; later ones see less of it.
670
+ */
671
+ function preludeHas(css, start, x) {
672
+ const brace = nextMatchFinder(css, /\{/g);
673
+ const found = nextMatchFinder(css, x);
674
+ for (let pos = 0;;) {
675
+ start.lastIndex = pos;
676
+ const m = start.exec(css);
677
+ if (!m)
678
+ return false;
679
+ const after = m.index + m[0].length;
680
+ const b = brace(after);
681
+ if (found(after) < b)
682
+ return true;
683
+ if (b === Infinity)
684
+ return false;
685
+ pos = b + 1;
686
+ }
687
+ }
688
+ /**
689
+ * Matches of START[^{]*X[^{]*\{ (global): X's last match in the prelude before
690
+ * the first `{` after the start; the match runs to that `{`. `lead` (global)
691
+ * finds where X can begin, `x` is X, sticky.
692
+ */
693
+ function preludeMatches(css, start, lead, x) {
694
+ const out = [];
695
+ const leads = matchStarts(css, lead);
696
+ let ptr = 0;
697
+ for (let pos = 0;;) {
698
+ start.lastIndex = pos;
699
+ const m = start.exec(css);
700
+ if (!m)
701
+ break;
702
+ const after = m.index + m[0].length;
703
+ const b = css.indexOf('{', after);
704
+ if (b < 0)
705
+ break;
706
+ while (ptr < leads.length && leads[ptr] < after)
707
+ ptr++;
708
+ let end = ptr;
709
+ while (end < leads.length && leads[end] < b)
710
+ end++;
711
+ let hit = null;
712
+ for (let k = end - 1; k >= ptr && !hit; k--) {
713
+ x.lastIndex = leads[k];
714
+ hit = x.exec(css);
715
+ }
716
+ ptr = end;
717
+ if (hit)
718
+ out.push({ index: m.index, text: css.slice(m.index, b + 1), groups: hit.slice(1) });
719
+ pos = b + 1;
720
+ }
721
+ return out;
722
+ }
723
+ /** css.replace(/@media[^{]*\{[^@]*?\}\s*\}/gi, '') */
724
+ function withoutMediaBlocks(css) {
725
+ const start = /@media/gi;
726
+ const spans = [];
727
+ for (let pos = 0;;) {
728
+ start.lastIndex = pos;
729
+ const m = start.exec(css);
730
+ if (!m)
731
+ break;
732
+ const b = css.indexOf('{', m.index + m[0].length);
733
+ if (b < 0)
734
+ break;
735
+ let end = -1;
736
+ for (let k = b + 1; k < css.length; k++) {
737
+ const ch = css.charCodeAt(k);
738
+ if (ch === 64)
739
+ break;
740
+ if (ch === 125) {
741
+ const t = wsEnd(css, k + 1);
742
+ if (css.charCodeAt(t) === 125) {
743
+ end = t + 1;
744
+ break;
745
+ }
746
+ }
747
+ }
748
+ if (end < 0) {
749
+ pos = b + 1;
750
+ continue;
751
+ }
752
+ spans.push(m.index, end);
753
+ pos = end;
754
+ }
755
+ return replaceSpans(css, spans, '');
756
+ }
757
+ /** css.replace(/@keyframes\s+[^{]+\{[^@]*?\}/gi, '') */
758
+ function withoutKeyframesBlocks(css) {
759
+ const start = /@keyframes/gi;
760
+ const spans = [];
761
+ for (let pos = 0;;) {
762
+ start.lastIndex = pos;
763
+ const m = start.exec(css);
764
+ if (!m)
765
+ break;
766
+ const after = m.index + m[0].length;
767
+ if (!isJsSpace(css.charCodeAt(after))) {
768
+ pos = m.index + 1;
769
+ continue;
770
+ }
771
+ const b = css.indexOf('{', after);
772
+ if (b < 0)
773
+ break;
774
+ if (b < after + 2) {
775
+ pos = m.index + 1;
776
+ continue;
777
+ }
778
+ let end = -1;
779
+ for (let k = b + 1; k < css.length; k++) {
780
+ const ch = css.charCodeAt(k);
781
+ if (ch === 64)
782
+ break;
783
+ if (ch === 125) {
784
+ end = k + 1;
785
+ break;
786
+ }
787
+ }
788
+ if (end < 0) {
789
+ pos = b + 1;
790
+ continue;
791
+ }
792
+ spans.push(m.index, end);
793
+ pos = end;
794
+ }
795
+ return replaceSpans(css, spans, '');
796
+ }
797
+ /** css.match(/::selection\s*\{[^}]*\}/gi) */
798
+ function selectionRules(css) {
799
+ const start = /::selection/gi;
800
+ const out = [];
801
+ for (let pos = 0;;) {
802
+ start.lastIndex = pos;
803
+ const m = start.exec(css);
804
+ if (!m)
805
+ break;
806
+ const b = wsEnd(css, m.index + m[0].length);
807
+ if (css.charCodeAt(b) !== 123) {
808
+ pos = m.index + 1;
809
+ continue;
810
+ }
811
+ const c = css.indexOf('}', b + 1);
812
+ if (c < 0)
813
+ break;
814
+ out.push(css.slice(m.index, c + 1));
815
+ pos = c + 1;
816
+ }
817
+ return out.length ? out : null;
818
+ }
819
+ /** The matches css.replace(/(font-size\s*:\s*)(var\([^;{}]*\))/gi, fn) hands fn: groups [lead, value]. */
820
+ function fontSizeVarMatches(css) {
821
+ const start = /font-size/gi;
822
+ const varAt = /var\(/iy;
823
+ const out = [];
824
+ for (let pos = 0;;) {
825
+ start.lastIndex = pos;
826
+ const m = start.exec(css);
827
+ if (!m)
828
+ break;
829
+ const colon = wsEnd(css, m.index + m[0].length);
830
+ if (css.charCodeAt(colon) !== 58) {
831
+ pos = m.index + 1;
832
+ continue;
833
+ }
834
+ const v = wsEnd(css, colon + 1);
835
+ varAt.lastIndex = v;
836
+ if (!varAt.test(css)) {
837
+ pos = m.index + 1;
838
+ continue;
839
+ }
840
+ let r = v + 4;
841
+ while (r < css.length) {
842
+ const ch = css.charCodeAt(r);
843
+ if (ch === 59 || ch === 123 || ch === 125)
844
+ break;
845
+ r++;
846
+ }
847
+ let close = r - 1;
848
+ while (close >= v + 4 && css.charCodeAt(close) !== 41)
849
+ close--;
850
+ if (close >= v + 4) {
851
+ out.push({ index: m.index, text: css.slice(m.index, close + 1), groups: [css.slice(m.index, v), css.slice(v, close + 1)] });
852
+ }
853
+ // Every later start before r ends at the same r and finds no ')' after this one.
854
+ pos = r;
855
+ }
856
+ return out;
857
+ }
858
+ /** Matches of /([^{}]+)\{([^{}]*)\}/g: groups [prelude, body]. */
859
+ function cssRuleBlocks(css) {
860
+ const out = [];
861
+ const n = css.length;
862
+ const braceAt = (k) => {
863
+ const ch = css.charCodeAt(k);
864
+ return ch === 123 || ch === 125;
865
+ };
866
+ for (let pos = 0; pos < n;) {
867
+ let b = pos;
868
+ while (b < n && !braceAt(b))
869
+ b++;
870
+ if (b === n)
871
+ break;
872
+ if (b === pos || css.charCodeAt(b) === 125) {
873
+ pos = b + 1;
874
+ continue;
875
+ }
876
+ let c = b + 1;
877
+ while (c < n && !braceAt(c))
878
+ c++;
879
+ if (c === n)
880
+ break;
881
+ if (css.charCodeAt(c) === 123) {
882
+ pos = b + 1;
883
+ continue;
884
+ }
885
+ out.push({ index: pos, text: css.slice(pos, c + 1), groups: [css.slice(pos, b), css.slice(b + 1, c)] });
886
+ pos = c + 1;
887
+ }
888
+ return out;
889
+ }
890
+ /** Matches of /url\(\s*['"]?([^'")]+)['"]?\s*\)/gi: groups [path]. */
891
+ function cssUrlMatches(css) {
892
+ const start = /url\(/gi;
893
+ const urlAt = /url\(/iy;
894
+ const out = [];
895
+ const n = css.length;
896
+ const isQuote = (c) => c === 34 || c === 39;
897
+ // First quote or ')' at or after `from`.
898
+ const stopAt = (from) => {
899
+ let k = from;
900
+ while (k < n) {
901
+ const ch = css.charCodeAt(k);
902
+ if (ch === 34 || ch === 39 || ch === 41)
903
+ break;
904
+ k++;
905
+ }
906
+ return k;
907
+ };
908
+ // ['"]?\s*\) at a quote or ')': the index after it, or -1.
909
+ const tailEnd = (r) => {
910
+ if (r >= n)
911
+ return -1;
912
+ if (css.charCodeAt(r) === 41)
913
+ return r + 1;
914
+ const t = wsEnd(css, r + 1);
915
+ return css.charCodeAt(t) === 41 ? t + 1 : -1;
916
+ };
917
+ for (let pos = 0;;) {
918
+ start.lastIndex = pos;
919
+ const m = start.exec(css);
920
+ if (!m)
921
+ break;
922
+ const p0 = m.index + m[0].length;
923
+ const p1 = wsEnd(css, p0);
924
+ let hit = -1;
925
+ let from = -1;
926
+ let to = -1;
927
+ // All the leading space, then the quote after it taken, then not taken.
928
+ if (p1 < n && isQuote(css.charCodeAt(p1))) {
929
+ const r = stopAt(p1 + 1);
930
+ const e = r > p1 + 1 ? tailEnd(r) : -1;
931
+ if (e >= 0) {
932
+ hit = e;
933
+ from = p1 + 1;
934
+ to = r;
935
+ }
936
+ }
937
+ if (hit < 0) {
938
+ const r = stopAt(p1);
939
+ const e = r > p1 ? tailEnd(r) : -1;
940
+ if (e >= 0) {
941
+ hit = e;
942
+ from = p1;
943
+ to = r;
944
+ }
945
+ }
946
+ // Less leading space: the path starts on a space and runs to the same stop.
947
+ if (hit < 0 && p1 > p0) {
948
+ const r = stopAt(p1 - 1);
949
+ const e = tailEnd(r);
950
+ if (e >= 0) {
951
+ hit = e;
952
+ from = p1 - 1;
953
+ to = r;
954
+ }
955
+ }
956
+ if (hit >= 0) {
957
+ out.push({ index: m.index, text: css.slice(m.index, hit), groups: [css.slice(from, to)] });
958
+ pos = hit;
959
+ continue;
960
+ }
961
+ // A later url( before the first quote or ')' after this one reads the same
962
+ // stop and fails the same way, unless only space separates it from a
963
+ // quote at that stop: that one can take the quote and must be tried.
964
+ const r1 = stopAt(p0);
965
+ if (r1 >= n)
966
+ break;
967
+ let w = r1;
968
+ while (w > p0 && isJsSpace(css.charCodeAt(w - 1)))
969
+ w--;
970
+ urlAt.lastIndex = w - 4;
971
+ pos = w - 4 > m.index && urlAt.test(css) ? w - 4 : r1;
972
+ }
973
+ return out;
974
+ }
975
+ /** css.match(/(?:^|\})[^{}]*\{[^}]*background-clip\s*:\s*text[^}]*\}/gi) || [] */
976
+ function gradientClipRules(css) {
977
+ const found = nextMatchFinder(css, /background-clip\s*:\s*text/gi);
978
+ const out = [];
979
+ const n = css.length;
980
+ let pos = 0;
981
+ for (let first = true;;) {
982
+ let i;
983
+ let from;
984
+ if (first) {
985
+ first = false;
986
+ i = 0;
987
+ from = css.charCodeAt(0) === 125 ? 1 : 0;
988
+ }
989
+ else {
990
+ i = css.indexOf('}', pos);
991
+ if (i < 0)
992
+ break;
993
+ from = i + 1;
994
+ }
995
+ let b = from;
996
+ while (b < n && css.charCodeAt(b) !== 123 && css.charCodeAt(b) !== 125)
997
+ b++;
998
+ if (b >= n)
999
+ break;
1000
+ if (css.charCodeAt(b) === 125) {
1001
+ pos = b;
1002
+ continue;
1003
+ }
1004
+ const c = css.indexOf('}', b + 1);
1005
+ if (c < 0)
1006
+ break;
1007
+ if (found(b + 1) < c) {
1008
+ out.push(css.slice(i, c + 1));
1009
+ pos = c + 1;
1010
+ }
1011
+ else
1012
+ pos = c;
1013
+ }
1014
+ return out;
1015
+ }
1016
+ /**
1017
+ * block.match(/linear-gradient\(([\s\S]+)\)\s*;?\s*background-size|linear-gradient\(([^;]+)\)/i):
1018
+ * groups [args before background-size, args before the first ';'].
1019
+ */
1020
+ function linearGradientArgs(block) {
1021
+ const start = /linear-gradient\(/gi;
1022
+ const size = /background-size/iy;
1023
+ const n = block.length;
1024
+ // The last ')' followed by \s*;?\s*background-size anywhere in the block.
1025
+ let tailParen = -1;
1026
+ let tailStop = -1;
1027
+ for (let p = block.lastIndexOf(')'); p >= 0; p = p > 0 ? block.lastIndexOf(')', p - 1) : -1) {
1028
+ let t = wsEnd(block, p + 1);
1029
+ if (block.charCodeAt(t) === 59)
1030
+ t = wsEnd(block, t + 1);
1031
+ size.lastIndex = t;
1032
+ if (size.test(block)) {
1033
+ tailParen = p;
1034
+ tailStop = t + 15;
1035
+ break;
1036
+ }
1037
+ }
1038
+ let semiFor = -1;
1039
+ let semi = -1;
1040
+ let parenFor = -1;
1041
+ let paren = -1;
1042
+ for (let m = start.exec(block); m; m = start.exec(block)) {
1043
+ const a = m.index + m[0].length;
1044
+ if (tailParen >= a + 1) {
1045
+ return { index: m.index, text: block.slice(m.index, tailStop), groups: [block.slice(a, tailParen), undefined] };
1046
+ }
1047
+ if (!(semiFor >= 0 && a >= semiFor && a <= semi)) {
1048
+ semiFor = a;
1049
+ semi = block.indexOf(';', a);
1050
+ if (semi < 0)
1051
+ semi = n;
1052
+ }
1053
+ // The last ')' before that ';' and after this opener: the same for every
1054
+ // opener before the ';', so it is looked for once and only as far back as
1055
+ // the first of them.
1056
+ if (parenFor !== semi) {
1057
+ parenFor = semi;
1058
+ paren = semi - 1;
1059
+ while (paren >= a + 1 && block.charCodeAt(paren) !== 41)
1060
+ paren--;
1061
+ }
1062
+ if (paren >= a + 1) {
1063
+ return { index: m.index, text: block.slice(m.index, paren + 1), groups: [undefined, block.slice(a, paren)] };
1064
+ }
1065
+ }
1066
+ return null;
1067
+ }
1068
+ /** css.match(/grid-template-columns[^}]*repeat\s*\(\s*(?:auto-fit|auto-fill|\d+)/gi) || [] */
1069
+ function gridRepeatRules(css) {
1070
+ const start = /grid-template-columns/gi;
1071
+ const x = /repeat\s*\(\s*(?:auto-fit|auto-fill|\d+)/iy;
1072
+ const leads = matchStarts(css, /repeat/gi);
1073
+ const out = [];
1074
+ let ptr = 0;
1075
+ for (let pos = 0;;) {
1076
+ start.lastIndex = pos;
1077
+ const m = start.exec(css);
1078
+ if (!m)
1079
+ break;
1080
+ const after = m.index + m[0].length;
1081
+ let c = css.indexOf('}', after);
1082
+ if (c < 0)
1083
+ c = css.length;
1084
+ while (ptr < leads.length && leads[ptr] < after)
1085
+ ptr++;
1086
+ let end = ptr;
1087
+ while (end < leads.length && leads[end] < c)
1088
+ end++;
1089
+ let hit = null;
1090
+ for (let k = end - 1; k >= ptr && !hit; k--) {
1091
+ x.lastIndex = leads[k];
1092
+ hit = x.exec(css);
1093
+ }
1094
+ ptr = end;
1095
+ if (hit)
1096
+ out.push(css.slice(m.index, hit.index + hit[0].length));
1097
+ if (c >= css.length)
1098
+ break;
1099
+ pos = c + 1;
1100
+ }
1101
+ return out;
1102
+ }
1103
+ /**
1104
+ * html.match(/(?:<button|<a[^>]*class[^>]*(?:btn|cta|primary|action))[^>]*>[\s\S]{0,200}(?:[\p{Emoji_Presentation}]|[\p{Emoji}]\uFE0F)/giu)
1105
+ */
1106
+ function emojiCtaMatches(html) {
1107
+ const n = html.length;
1108
+ const start = /<button|<a/giu;
1109
+ const cls = nextMatchFinder(html, /class/giu);
1110
+ const word = nextMatchFinder(html, /btn|cta|primary|action/giu);
1111
+ const gt = nextMatchFinder(html, />/g);
1112
+ const emoji = /[\p{Emoji_Presentation}]|[\p{Emoji}]\uFE0F/gu;
1113
+ const emojiAt = [];
1114
+ const emojiEnd = [];
1115
+ for (let e = emoji.exec(html); e; e = emoji.exec(html)) {
1116
+ emojiAt.push(e.index);
1117
+ emojiEnd.push(e.index + e[0].length);
1118
+ }
1119
+ // End of the match for the content after `>` at g: the last emoji that
1120
+ // starts within 200 code points of it, or -1.
1121
+ const tail = (g) => {
1122
+ let w = g + 1;
1123
+ for (let k = 0; k < 200 && w < n; k++) {
1124
+ const c = html.charCodeAt(w);
1125
+ w += c >= 0xd800 && c <= 0xdbff && w + 1 < n && html.charCodeAt(w + 1) >= 0xdc00 && html.charCodeAt(w + 1) <= 0xdfff ? 2 : 1;
1126
+ }
1127
+ let lo = 0;
1128
+ let hi = emojiAt.length - 1;
1129
+ let best = -1;
1130
+ while (lo <= hi) {
1131
+ const mid = (lo + hi) >> 1;
1132
+ if (emojiAt[mid] <= w) {
1133
+ best = mid;
1134
+ lo = mid + 1;
1135
+ }
1136
+ else
1137
+ hi = mid - 1;
1138
+ }
1139
+ return best >= 0 && emojiAt[best] >= g + 1 ? emojiEnd[best] : -1;
1140
+ };
1141
+ const out = [];
1142
+ let tailFor = -1;
1143
+ let tailStop = -1;
1144
+ for (let pos = 0;;) {
1145
+ start.lastIndex = pos;
1146
+ const m = start.exec(html);
1147
+ if (!m)
1148
+ break;
1149
+ const g = gt(m.index + 1);
1150
+ if (g === Infinity)
1151
+ break;
1152
+ if (tailFor !== g) {
1153
+ tailFor = g;
1154
+ tailStop = tail(g);
1155
+ }
1156
+ if (tailStop < 0) {
1157
+ pos = g + 1;
1158
+ continue;
1159
+ }
1160
+ let ok = m[0].length === 7;
1161
+ if (!ok) {
1162
+ const c = cls(m.index + 2);
1163
+ ok = c < g && word(c + 5) < g;
1164
+ }
1165
+ if (!ok) {
1166
+ pos = m.index + 1;
1167
+ continue;
1168
+ }
1169
+ out.push(html.slice(m.index, tailStop));
1170
+ pos = tailStop;
1171
+ }
1172
+ return out.length ? out : null;
1173
+ }
1174
+ /** key.replace(/(\.\d*?)0+(?=\D|$)/g, '$1') */
1175
+ function withoutTrailingDecimalZeros(key) {
1176
+ const parts = [];
1177
+ let last = 0;
1178
+ for (let i = key.indexOf('.'); i >= 0;) {
1179
+ let d = i + 1;
1180
+ while (d < key.length && key.charCodeAt(d) >= 48 && key.charCodeAt(d) <= 57)
1181
+ d++;
1182
+ let z = d;
1183
+ while (z > i + 1 && key.charCodeAt(z - 1) === 48)
1184
+ z--;
1185
+ if (z < d) {
1186
+ parts.push(key.slice(last, z));
1187
+ last = d;
1188
+ }
1189
+ i = key.indexOf('.', d);
1190
+ }
1191
+ parts.push(key.slice(last));
1192
+ return parts.join('');
1193
+ }
1194
+ /** /prefers-reduced-motion[\s\S]{0,200}\*\s*\{[^}]*animation\s*:\s*none/i.test(css) */
1195
+ function hasBlanketMotionKill(css) {
1196
+ const start = /prefers-reduced-motion/gi;
1197
+ const kill = nextMatchFinder(css, /animation\s*:\s*none/gi);
1198
+ const close = nextMatchFinder(css, /\}/g);
1199
+ let checked = -1;
1200
+ for (let m = start.exec(css); m; m = start.exec(css)) {
1201
+ const from = m.index + m[0].length;
1202
+ const to = Math.min(css.length - 1, from + 200);
1203
+ // A `*` reads the same whichever start reaches it, so each is read once.
1204
+ for (let q = Math.max(from, checked + 1); q <= to; q++) {
1205
+ if (css.charCodeAt(q) !== 42)
1206
+ continue;
1207
+ const b = wsEnd(css, q + 1);
1208
+ if (css.charCodeAt(b) !== 123)
1209
+ continue;
1210
+ if (kill(b + 1) < close(b + 1))
1211
+ return true;
1212
+ }
1213
+ if (to > checked)
1214
+ checked = to;
1215
+ }
1216
+ return false;
1217
+ }
1218
+ /** html.match(/<svg[^>]*viewBox=/gi) || [] */
1219
+ function svgViewBoxTags(html) {
1220
+ const start = /<svg/gi;
1221
+ const leads = matchStarts(html, /viewBox=/gi);
1222
+ const gt = nextMatchFinder(html, />/g);
1223
+ const out = [];
1224
+ let ptr = 0;
1225
+ for (let pos = 0;;) {
1226
+ start.lastIndex = pos;
1227
+ const m = start.exec(html);
1228
+ if (!m)
1229
+ break;
1230
+ const after = m.index + m[0].length;
1231
+ const g = Math.min(gt(after), html.length);
1232
+ while (ptr < leads.length && leads[ptr] < after)
1233
+ ptr++;
1234
+ let end = ptr;
1235
+ while (end < leads.length && leads[end] < g)
1236
+ end++;
1237
+ if (end > ptr) {
1238
+ const k = leads[end - 1];
1239
+ out.push(html.slice(m.index, k + 8));
1240
+ pos = k + 8;
1241
+ ptr = end;
1242
+ continue;
1243
+ }
1244
+ if (g >= html.length)
1245
+ break;
1246
+ pos = g + 1;
1247
+ }
1248
+ return out;
1249
+ }
1250
+ /** html.match(/<symbol[^>]*>/gi) || [] */
1251
+ function symbolTags(html) {
1252
+ const start = /<symbol/gi;
1253
+ const out = [];
1254
+ for (let pos = 0;;) {
1255
+ start.lastIndex = pos;
1256
+ const m = start.exec(html);
1257
+ if (!m)
1258
+ break;
1259
+ const g = html.indexOf('>', m.index + m[0].length);
1260
+ if (g < 0)
1261
+ break;
1262
+ out.push(html.slice(m.index, g + 1));
1263
+ pos = g + 1;
1264
+ }
1265
+ return out;
1266
+ }
1267
+ /** A [\w-] code unit (\w without the u flag). */
1268
+ function isNameChar(c) {
1269
+ return (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c === 95 || c === 45;
1270
+ }
1271
+ /** The index after the run of [\w-] that starts at `i`. */
1272
+ function nameEnd(s, i) {
1273
+ while (i < s.length && isNameChar(s.charCodeAt(i)))
1274
+ i++;
1275
+ return i;
1276
+ }
1277
+ /** css.match(/--[\w-]+\s*:/g) || [] */
1278
+ function customPropertyDecls(css) {
1279
+ const out = [];
1280
+ for (let pos = 0;;) {
1281
+ const i = css.indexOf('--', pos);
1282
+ if (i < 0)
1283
+ break;
1284
+ const r = nameEnd(css, i + 2);
1285
+ if (r === i + 2) {
1286
+ pos = i + 1;
1287
+ continue;
1288
+ }
1289
+ const t = wsEnd(css, r);
1290
+ if (css.charCodeAt(t) === 58) {
1291
+ out.push(css.slice(i, t + 1));
1292
+ pos = t + 1;
1293
+ continue;
1294
+ }
1295
+ // Every later `--` inside the same name ends at the same r and fails too.
1296
+ pos = r;
1297
+ }
1298
+ return out;
1299
+ }
1300
+ /** Matches of /--([\w-]+)\s*:\s*([^;]+?)(?:;|$)/g: groups [name, value]. */
1301
+ function customPropertyValues(block) {
1302
+ const out = [];
1303
+ const n = block.length;
1304
+ for (let pos = 0;;) {
1305
+ const i = block.indexOf('--', pos);
1306
+ if (i < 0)
1307
+ break;
1308
+ const r = nameEnd(block, i + 2);
1309
+ if (r === i + 2) {
1310
+ pos = i + 1;
1311
+ continue;
1312
+ }
1313
+ const colon = wsEnd(block, r);
1314
+ if (block.charCodeAt(colon) !== 58) {
1315
+ pos = r;
1316
+ continue;
1317
+ }
1318
+ let v = wsEnd(block, colon + 1);
1319
+ // ([^;]+?) needs one character: when the space runs up to a ';' or the
1320
+ // end, the last space is that character.
1321
+ if (v >= n || block.charCodeAt(v) === 59) {
1322
+ if (v === colon + 1) {
1323
+ pos = r;
1324
+ continue;
1325
+ }
1326
+ v--;
1327
+ }
1328
+ let e = block.indexOf(';', v + 1);
1329
+ if (e < 0)
1330
+ e = n;
1331
+ const stop = e < n ? e + 1 : n;
1332
+ out.push({ index: i, text: block.slice(i, stop), groups: [block.slice(i + 2, r), block.slice(v, e)] });
1333
+ pos = stop;
1334
+ }
1335
+ return out;
1336
+ }
1337
+ /** /--(?:color|surface|accent|action|feedback)[\w-]*\s*:\s*var\(--/i.test(css) */
1338
+ function hasTokenLayering(css) {
1339
+ const start = /--(?:color|surface|accent|action|feedback)/gi;
1340
+ const varAt = /var\(--/iy;
1341
+ for (let pos = 0;;) {
1342
+ start.lastIndex = pos;
1343
+ const m = start.exec(css);
1344
+ if (!m)
1345
+ return false;
1346
+ const r = nameEnd(css, m.index + m[0].length);
1347
+ const t = wsEnd(css, r);
1348
+ if (css.charCodeAt(t) === 58) {
1349
+ varAt.lastIndex = wsEnd(css, t + 1);
1350
+ if (varAt.test(css))
1351
+ return true;
1352
+ }
1353
+ // Every later start inside the same name reaches the same r.
1354
+ pos = Math.max(r, m.index + 1);
1355
+ }
1356
+ }
1357
+ /** `text` with each [start, end) span replaced by fn(the spanned text). */
1358
+ function replaceSpansWith(text, spans, fn) {
1359
+ if (spans.length === 0)
1360
+ return text;
1361
+ const parts = [];
1362
+ let last = 0;
1363
+ for (let k = 0; k < spans.length; k += 2) {
1364
+ parts.push(text.slice(last, spans[k]), fn(text.slice(spans[k], spans[k + 1])));
1365
+ last = spans[k + 1];
1366
+ }
1367
+ parts.push(text.slice(last));
1368
+ return parts.join('');
1369
+ }
1370
+ /** Spans of /\[[^\]]*\]/g. */
1371
+ function bracketSpans(text) {
1372
+ const spans = [];
1373
+ for (let pos = 0;;) {
1374
+ const i = text.indexOf('[', pos);
1375
+ if (i < 0)
1376
+ break;
1377
+ const j = text.indexOf(']', i + 1);
1378
+ if (j < 0)
1379
+ break;
1380
+ spans.push(i, j + 1);
1381
+ pos = j + 1;
1382
+ }
1383
+ return spans;
1384
+ }
1385
+ /** text.match(/\[[^\]]*\]|\.[\w-]+/g) */
1386
+ function attrAndClassParts(text) {
1387
+ const out = [];
1388
+ const lastClose = text.lastIndexOf(']');
1389
+ for (let pos = 0; pos < text.length;) {
1390
+ const ch = text.charCodeAt(pos);
1391
+ if (ch === 91 && lastClose > pos) {
1392
+ const j = text.indexOf(']', pos + 1);
1393
+ out.push(text.slice(pos, j + 1));
1394
+ pos = j + 1;
1395
+ continue;
1396
+ }
1397
+ if (ch === 46) {
1398
+ const r = nameEnd(text, pos + 1);
1399
+ if (r > pos + 1) {
1400
+ out.push(text.slice(pos, r));
1401
+ pos = r;
1402
+ continue;
1403
+ }
1404
+ }
1405
+ pos++;
1406
+ }
1407
+ return out.length ? out : null;
1408
+ }
1409
+ /** v45: /^\[[\w-]*(?:motion|anim)[\w-]*(?:[~|^$*]?=[^\]]*)?\]$/i.test(s) */
1410
+ function isMotionToggleAttr(s) {
1411
+ const n = s.length;
1412
+ if (n < 2 || s.charCodeAt(0) !== 91 || s.charCodeAt(n - 1) !== 93)
1413
+ return false;
1414
+ const r = nameEnd(s, 1);
1415
+ if (!/motion|anim/i.test(s.slice(1, r)))
1416
+ return false;
1417
+ if (r === n - 1)
1418
+ return true;
1419
+ let k = r;
1420
+ if ('~|^$*'.indexOf(s[k]) >= 0)
1421
+ k++;
1422
+ if (s.charCodeAt(k) !== 61)
1423
+ return false;
1424
+ return s.indexOf(']', k + 1) === n - 1;
1425
+ }
1426
+ /** Where `\s*!\s*important\s*$` (case-insensitive) begins in `value`, or -1. */
1427
+ function importantSuffixStart(value) {
1428
+ let e = value.length;
1429
+ while (e > 0 && isJsSpace(value.charCodeAt(e - 1)))
1430
+ e--;
1431
+ if (e < 9 || !/^important$/i.test(value.slice(e - 9, e)))
1432
+ return -1;
1433
+ let k = e - 9;
1434
+ while (k > 0 && isJsSpace(value.charCodeAt(k - 1)))
1435
+ k--;
1436
+ if (k === 0 || value.charCodeAt(k - 1) !== 33)
1437
+ return -1;
1438
+ k--;
1439
+ while (k > 0 && isJsSpace(value.charCodeAt(k - 1)))
1440
+ k--;
1441
+ return k;
1442
+ }
1443
+ /** A [0-9] code unit. */
1444
+ function isDigit(c) {
1445
+ return c >= 48 && c <= 57;
1446
+ }
1447
+ /** color-mix: /^([\s\S]*?)\s+(\d*\.?\d+)%$/.exec(p) */
1448
+ function percentAfter(p) {
1449
+ const n = p.length;
1450
+ if (n === 0 || p.charCodeAt(n - 1) !== 37)
1451
+ return null;
1452
+ let q = n - 1;
1453
+ while (q > 0 && (isDigit(p.charCodeAt(q - 1)) || p.charCodeAt(q - 1) === 46))
1454
+ q--;
1455
+ if (!/^(?:\d+(?:\.\d+)?|\.\d+)$/.test(p.slice(q, n - 1)))
1456
+ return null;
1457
+ if (q === 0 || !isJsSpace(p.charCodeAt(q - 1)))
1458
+ return null;
1459
+ let w = q - 1;
1460
+ while (w > 0 && isJsSpace(p.charCodeAt(w - 1)))
1461
+ w--;
1462
+ return [p, p.slice(0, w), p.slice(q, n - 1)];
1463
+ }
1464
+ /**
1465
+ * Relative colour syntax:
1466
+ * /^from\s+([\s\S]+?)\s+(r\s+g\s+b|h\s+s\s+l|l\s+c\s+h|l\s+a\s+b)\s*(?:\/\s*([\s\S]+))?$/.exec(args)
1467
+ */
1468
+ function relativeColorMatch(args) {
1469
+ if (!args.startsWith('from') || !isJsSpace(args.charCodeAt(4)))
1470
+ return null;
1471
+ const channels = /(r\s+g\s+b|h\s+s\s+l|l\s+c\s+h|l\s+a\s+b)\s*(?:\/\s*([\s\S]+))?$/y;
1472
+ const tail = (k) => {
1473
+ channels.lastIndex = k;
1474
+ return channels.exec(args);
1475
+ };
1476
+ const start = wsEnd(args, 4);
1477
+ // The base is lazy: it ends at the first run of space after its first
1478
+ // character that the channel keywords and the rest of the value follow.
1479
+ for (let k = start + 1; k < args.length; k++) {
1480
+ if (!isJsSpace(args.charCodeAt(k)))
1481
+ continue;
1482
+ const w = wsEnd(args, k);
1483
+ const t = tail(w);
1484
+ if (t)
1485
+ return [args, args.slice(start, k), t[1], t[2]];
1486
+ k = w;
1487
+ }
1488
+ // With less of the leading space taken, the base can be one space whose
1489
+ // following space leads straight to the keywords.
1490
+ if (start - 4 >= 3) {
1491
+ const t = tail(start);
1492
+ if (t)
1493
+ return [args, args.slice(start - 2, start - 1), t[1], t[2]];
1494
+ }
1495
+ return null;
1496
+ }
1497
+ /** html.match(/<input\b[^>]*>/gi) || [] */
1498
+ function inputTags(html) {
1499
+ const start = /<input\b/gi;
1500
+ const out = [];
1501
+ for (let pos = 0;;) {
1502
+ start.lastIndex = pos;
1503
+ const m = start.exec(html);
1504
+ if (!m)
1505
+ break;
1506
+ const g = html.indexOf('>', m.index + m[0].length);
1507
+ if (g < 0)
1508
+ break;
1509
+ out.push(html.slice(m.index, g + 1));
1510
+ pos = g + 1;
1511
+ }
1512
+ return out;
1513
+ }
1514
+ /** /<title\b[^>]*>[^<]+<\/title>/i.test(html) */
1515
+ function hasTitleText(html) {
1516
+ const start = /<title\b/gi;
1517
+ const close = /<\/title>/iy;
1518
+ for (let pos = 0;;) {
1519
+ start.lastIndex = pos;
1520
+ const m = start.exec(html);
1521
+ if (!m)
1522
+ return false;
1523
+ const g = html.indexOf('>', m.index + m[0].length);
1524
+ if (g < 0)
1525
+ return false;
1526
+ const lt = html.indexOf('<', g + 1);
1527
+ if (lt < 0)
1528
+ return false;
1529
+ if (lt > g + 1) {
1530
+ close.lastIndex = lt;
1531
+ if (close.test(html))
1532
+ return true;
1533
+ }
1534
+ // Every start before g reaches the same `>` and the same `<` after it.
1535
+ pos = g + 1;
1536
+ }
1537
+ }
1538
+ /**
1539
+ * /TAG[^>]*ATTR[^"']*WORD/i.test(html): `open` (global) matches the tag's
1540
+ * start, `attr` (global) an attribute name with its `=` and opening quote, and
1541
+ * `word` (global, holding no quote) what the value must contain before its
1542
+ * closing quote.
1543
+ */
1544
+ function tagAttrValueHas(html, open, attr, word) {
1545
+ const attrs = [];
1546
+ const values = [];
1547
+ attr.lastIndex = 0;
1548
+ for (let a = attr.exec(html); a; a = attr.exec(html)) {
1549
+ attrs.push(a.index);
1550
+ values.push(a.index + a[0].length);
1551
+ }
1552
+ const gt = nextMatchFinder(html, />/g);
1553
+ const found = nextMatchFinder(html, word);
1554
+ const n = html.length;
1555
+ let ptr = 0;
1556
+ for (let pos = 0;;) {
1557
+ open.lastIndex = pos;
1558
+ const m = open.exec(html);
1559
+ if (!m)
1560
+ return false;
1561
+ const after = m.index + m[0].length;
1562
+ const g = Math.min(gt(after), n);
1563
+ // Each attribute in the tag is read once: what follows it does not
1564
+ // depend on which start reached it.
1565
+ while (ptr < attrs.length && attrs[ptr] < after)
1566
+ ptr++;
1567
+ for (; ptr < attrs.length && attrs[ptr] < g; ptr++) {
1568
+ const v = values[ptr];
1569
+ let q = v;
1570
+ while (q < n && html.charCodeAt(q) !== 34 && html.charCodeAt(q) !== 39)
1571
+ q++;
1572
+ if (found(v) < q)
1573
+ return true;
1574
+ }
1575
+ if (g >= n)
1576
+ return false;
1577
+ pos = g + 1;
1578
+ }
1579
+ }
1580
+ /** The index of the first entry of an ascending list at or after `x` (the length when none). */
1581
+ function lowerBound(list, x) {
1582
+ let lo = 0;
1583
+ let hi = list.length;
1584
+ while (lo < hi) {
1585
+ const mid = (lo + hi) >> 1;
1586
+ if (list[mid] < x)
1587
+ lo = mid + 1;
1588
+ else
1589
+ hi = mid;
1590
+ }
1591
+ return lo;
1592
+ }
1593
+ /** Matches of /:root(?:\s*,\s*:host)?\s*\{([^}]*)\}/g: groups [body]. */
1594
+ function rootTokenBlocks(css) {
1595
+ const start = /:root/g;
1596
+ const out = [];
1597
+ for (let pos = 0;;) {
1598
+ start.lastIndex = pos;
1599
+ const m = start.exec(css);
1600
+ if (!m)
1601
+ break;
1602
+ const p = m.index + m[0].length;
1603
+ let b = -1;
1604
+ // The optional ", :host": whitespace, a comma, whitespace, ":host".
1605
+ const comma = wsEnd(css, p);
1606
+ if (css.charCodeAt(comma) === 44) {
1607
+ const h = wsEnd(css, comma + 1);
1608
+ if (css.startsWith(':host', h)) {
1609
+ const q = wsEnd(css, h + 5);
1610
+ if (css.charCodeAt(q) === 123)
1611
+ b = q;
1612
+ }
1613
+ }
1614
+ if (b < 0) {
1615
+ const q = wsEnd(css, p);
1616
+ if (css.charCodeAt(q) === 123)
1617
+ b = q;
1618
+ }
1619
+ if (b < 0) {
1620
+ pos = m.index + 1;
1621
+ continue;
1622
+ }
1623
+ const c = css.indexOf('}', b + 1);
1624
+ if (c < 0)
1625
+ break;
1626
+ out.push({ index: m.index, text: css.slice(m.index, c + 1), groups: [css.slice(b + 1, c)] });
1627
+ pos = c + 1;
1628
+ }
1629
+ return out;
1630
+ }
1631
+ /** v05: /@view-transition\s*\{[^}]*navigation\s*:\s*auto/i.test(body) */
1632
+ function hasViewTransitionNavigation(body) {
1633
+ const start = /@view-transition/gi;
1634
+ const found = nextMatchFinder(body, /navigation\s*:\s*auto/gi);
1635
+ const close = nextMatchFinder(body, /\}/g);
1636
+ for (let pos = 0;;) {
1637
+ start.lastIndex = pos;
1638
+ const m = start.exec(body);
1639
+ if (!m)
1640
+ return false;
1641
+ const b = wsEnd(body, m.index + m[0].length);
1642
+ if (body.charCodeAt(b) !== 123) {
1643
+ pos = m.index + 1;
1644
+ continue;
1645
+ }
1646
+ const c = close(b + 1);
1647
+ if (found(b + 1) < c)
1648
+ return true;
1649
+ if (c === Infinity)
1650
+ return false;
1651
+ // Every later start before c reads the same body, or less of it.
1652
+ pos = c + 1;
1653
+ }
1654
+ }
1655
+ /**
1656
+ * v34: /<script\s+type\s*=\s*["']application\/ld\+json["'][\s\S]*?"@type"\s*:\s*"[^"]*\b(?:ai|softwareapplication)\b[^"]*"[\s\S]*?(?:"applicationCategory"\s*:\s*"[^"]*ai[^"]*"|"[^"]*ai[^"]*")/i.test(html)
1657
+ * Only the first opener and the first matching @type matter: later ones see
1658
+ * less of the page. The applicationCategory form is one of the quoted
1659
+ * strings holding "ai" that the second form accepts.
1660
+ */
1661
+ function hasAiJsonLd(html) {
1662
+ const open = /<script\s+type\s*=\s*["']application\/ld\+json["']/i.exec(html);
1663
+ if (!open)
1664
+ return false;
1665
+ const type = /"@type"\s*:\s*"/gi;
1666
+ const word = nextMatchFinder(html, /\b(?:ai|softwareapplication)\b/gi);
1667
+ type.lastIndex = open.index + open[0].length;
1668
+ for (let t = type.exec(html); t; t = type.exec(html)) {
1669
+ const v = t.index + t[0].length;
1670
+ const q = html.indexOf('"', v);
1671
+ if (q < 0)
1672
+ return false;
1673
+ if (word(v) < q) {
1674
+ for (let p = html.indexOf('"', q + 1); p >= 0;) {
1675
+ const e = html.indexOf('"', p + 1);
1676
+ if (e < 0)
1677
+ return false;
1678
+ if (/ai/i.test(html.slice(p + 1, e)))
1679
+ return true;
1680
+ p = e;
1681
+ }
1682
+ return false;
1683
+ }
1684
+ type.lastIndex = q;
1685
+ }
1686
+ return false;
1687
+ }
1688
+ /** v10: /transition\s*:[^;]*transform[^;]*(ease-out|cubic-bezier\([^)]*0[, ])/i.test(css) */
1689
+ function hasTransformEaseTransition(css) {
1690
+ const start = /transition\s*:/gi;
1691
+ const transform = nextMatchFinder(css, /transform/gi);
1692
+ const ease = nextMatchFinder(css, /ease-out/gi);
1693
+ const paren = nextMatchFinder(css, /\)/g);
1694
+ const beziers = matchStarts(css, /cubic-bezier\(/gi);
1695
+ const zeros = matchStarts(css, /0[, ]/g);
1696
+ for (let pos = 0;;) {
1697
+ start.lastIndex = pos;
1698
+ const m = start.exec(css);
1699
+ if (!m)
1700
+ return false;
1701
+ const p = m.index + m[0].length;
1702
+ let r = css.indexOf(';', p);
1703
+ if (r < 0)
1704
+ r = css.length;
1705
+ const t = transform(p);
1706
+ if (t < r) {
1707
+ const from = t + 9;
1708
+ if (ease(from) < r)
1709
+ return true;
1710
+ // A cubic-bezier( that starts before r and holds a 0 followed by a
1711
+ // comma or a space before its first ')'.
1712
+ for (let k = lowerBound(beziers, from); k < beziers.length && beziers[k] < r; k++) {
1713
+ const inner = beziers[k] + 13;
1714
+ const z = lowerBound(zeros, inner);
1715
+ if (z < zeros.length && zeros[z] + 1 < paren(inner))
1716
+ return true;
1717
+ }
1718
+ }
1719
+ // Every later start before r reads the same declaration, or less of it.
1720
+ if (r >= css.length)
1721
+ return false;
1722
+ pos = r + 1;
1723
+ }
1724
+ }
1725
+ /** S3: css.match(/(?:background(?:-image)?\s*:[^;]*linear-gradient[^;]*(?:#?(?:615fff|8e51ff|4f39f6|7f22fe|a855f7|9333ea|7c3aed|6d28d9|5b21b6|4c1d95)))/gi) */
1726
+ function purpleGradientDecls(css) {
1727
+ const start = /background(?:-image)?\s*:/gi;
1728
+ const gradient = nextMatchFinder(css, /linear-gradient/gi);
1729
+ const hueRe = /615fff|8e51ff|4f39f6|7f22fe|a855f7|9333ea|7c3aed|6d28d9|5b21b6|4c1d95/gi;
1730
+ // Every place a hue starts, overlapping ones included (a855f7f22fe holds two).
1731
+ const hues = [];
1732
+ for (let h = hueRe.exec(css); h; h = hueRe.exec(css)) {
1733
+ hues.push(h.index);
1734
+ hueRe.lastIndex = h.index + 1;
1735
+ }
1736
+ const out = [];
1737
+ for (let pos = 0;;) {
1738
+ start.lastIndex = pos;
1739
+ const m = start.exec(css);
1740
+ if (!m)
1741
+ break;
1742
+ const p = m.index + m[0].length;
1743
+ let r = css.indexOf(';', p);
1744
+ if (r < 0)
1745
+ r = css.length;
1746
+ const g = gradient(p);
1747
+ if (g < r) {
1748
+ // The last hue before r, if it starts after the gradient's name.
1749
+ const k = lowerBound(hues, r) - 1;
1750
+ if (k >= 0 && hues[k] >= g + 15)
1751
+ out.push(css.slice(m.index, hues[k] + 6));
1752
+ }
1753
+ // Every later start before r finds no hue after this one.
1754
+ if (r >= css.length)
1755
+ break;
1756
+ pos = r + 1;
1757
+ }
1758
+ return out.length ? out : null;
1759
+ }
1760
+ /** S4: stop.match(/rgba?\(([^)]+)\)/i) */
1761
+ function rgbArgs(stop) {
1762
+ const start = /rgba?\(/gi;
1763
+ for (let m = start.exec(stop); m; m = start.exec(stop)) {
1764
+ const p = m.index + m[0].length;
1765
+ const c = stop.indexOf(')', p);
1766
+ if (c < 0)
1767
+ return null;
1768
+ if (c > p)
1769
+ return [stop.slice(m.index, c + 1), stop.slice(p, c)];
1770
+ }
1771
+ return null;
1772
+ }
1773
+ /** A tag-name code unit for [a-z0-9-] under the i flag. */
1774
+ function isTagNameChar(c) {
1775
+ return (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c === 45;
1776
+ }
1777
+ /** A \w code unit (no u flag). */
1778
+ function isWordChar(c) {
1779
+ return (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c === 95;
1780
+ }
1781
+ /** The first entry of an ascending list at or after `x`, or -1. */
1782
+ function firstAtOrAfter(list, x) {
1783
+ let lo = 0;
1784
+ let hi = list.length;
1785
+ while (lo < hi) {
1786
+ const mid = (lo + hi) >> 1;
1787
+ if (list[mid] < x)
1788
+ lo = mid + 1;
1789
+ else
1790
+ hi = mid;
1791
+ }
1792
+ return lo < list.length ? list[lo] : -1;
1793
+ }
1794
+ /**
1795
+ * v38: inner.replace(/<([a-z][a-z0-9-]*)\b[^>]*\baria-hidden\s*=\s*["']?true["']?[^>]*>[\s\S]*?<\/\1>/gi, '')
1796
+ * The closing tag must repeat the name, so the names of the closing tags are
1797
+ * kept in a trie and each opener's name is walked through it once.
1798
+ */
1799
+ function withoutAriaHiddenElements(inner) {
1800
+ const n = inner.length;
1801
+ const root = { kids: new Map(), at: [] };
1802
+ const closeRe = /<\/([a-z0-9-]+)>/gi;
1803
+ for (let c = closeRe.exec(inner); c; c = closeRe.exec(inner)) {
1804
+ let node = root;
1805
+ const name = c[1].toLowerCase();
1806
+ for (let k = 0; k < name.length; k++) {
1807
+ const code = name.charCodeAt(k);
1808
+ let next = node.kids.get(code);
1809
+ if (!next) {
1810
+ next = { kids: new Map(), at: [] };
1811
+ node.kids.set(code, next);
1812
+ }
1813
+ node = next;
1814
+ }
1815
+ node.at.push(c.index);
1816
+ }
1817
+ const hidden = matchStarts(inner, /\baria-hidden\s*=\s*["']?true/gi);
1818
+ const gt = nextMatchFinder(inner, />/g);
1819
+ const start = /<[a-z]/gi;
1820
+ const spans = [];
1821
+ for (let pos = 0;;) {
1822
+ start.lastIndex = pos;
1823
+ const m = start.exec(inner);
1824
+ if (!m)
1825
+ break;
1826
+ const i = m.index;
1827
+ const g = gt(i + 1);
1828
+ if (g === Infinity)
1829
+ break;
1830
+ // Name lengths whose name is also a closing tag's name, longest last.
1831
+ const lens = [];
1832
+ const nodes = [];
1833
+ let node = root;
1834
+ for (let e = i + 1; e < n && isTagNameChar(inner.charCodeAt(e)); e++) {
1835
+ const code = inner.charCodeAt(e);
1836
+ node = node.kids.get(code >= 65 && code <= 90 ? code + 32 : code);
1837
+ if (!node)
1838
+ break;
1839
+ if (node.at.length) {
1840
+ lens.push(e + 1 - (i + 1));
1841
+ nodes.push(node);
1842
+ }
1843
+ }
1844
+ let stop = -1;
1845
+ for (let k = lens.length - 1; k >= 0 && stop < 0; k--) {
1846
+ const ne = i + 1 + lens[k];
1847
+ const before = isWordChar(inner.charCodeAt(ne - 1));
1848
+ const after = ne < n && isWordChar(inner.charCodeAt(ne));
1849
+ if (before === after)
1850
+ continue;
1851
+ const a = firstAtOrAfter(hidden, ne);
1852
+ if (a < 0 || a >= g)
1853
+ continue;
1854
+ const c = firstAtOrAfter(nodes[k].at, g + 1);
1855
+ if (c < 0)
1856
+ continue;
1857
+ stop = c + 3 + lens[k];
1858
+ }
1859
+ if (stop >= 0) {
1860
+ spans.push(i, stop);
1861
+ pos = stop;
1862
+ }
1863
+ else
1864
+ pos = i + 1;
1865
+ }
1866
+ return replaceSpans(inner, spans, '');
1867
+ }
1868
+ /** text.replace(/<kbd\b[^>]*>(?:(?!<kbd\b)[\s\S])*?<\/kbd>/gi, ' ') */
1869
+ function withoutKbdElements(text) {
1870
+ const start = /<kbd\b/gi;
1871
+ const gt = nextMatchFinder(text, />/g);
1872
+ const opener = nextMatchFinder(text, /<kbd\b/gi);
1873
+ const closer = nextMatchFinder(text, /<\/kbd>/gi);
1874
+ const spans = [];
1875
+ for (let pos = 0;;) {
1876
+ start.lastIndex = pos;
1877
+ const m = start.exec(text);
1878
+ if (!m)
1879
+ break;
1880
+ const g = gt(m.index + m[0].length);
1881
+ if (g === Infinity)
1882
+ break;
1883
+ const c = closer(g + 1);
1884
+ if (c === Infinity)
1885
+ break;
1886
+ // A nested <kbd> opens first: no start before this `>` matches; the
1887
+ // nested one is tried in turn.
1888
+ if (opener(g + 1) < c) {
1889
+ pos = g + 1;
1890
+ continue;
1891
+ }
1892
+ spans.push(m.index, c + 6);
1893
+ pos = c + 6;
1894
+ }
1895
+ return replaceSpans(text, spans, ' ');
1896
+ }
1897
+ /** s.replace(/[\s]*[\u2303\u2318\u2325\u21E7\u21E7\u2387].*$/i, '') */
1898
+ function withoutGlyphShortcut(s) {
1899
+ let lt = -1;
1900
+ for (let k = s.length - 1; k >= 0; k--) {
1901
+ if (isLineTerminator(s.charCodeAt(k))) {
1902
+ lt = k;
1903
+ break;
1904
+ }
1905
+ }
1906
+ for (let k = lt + 1; k < s.length; k++) {
1907
+ const c = s.charCodeAt(k);
1908
+ if (c === 0x2303 || c === 0x2318 || c === 0x2325 || c === 0x21e7 || c === 0x2387) {
1909
+ let w = k;
1910
+ while (w > 0 && isJsSpace(s.charCodeAt(w - 1)))
1911
+ w--;
1912
+ return s.slice(0, w);
1913
+ }
1914
+ }
1915
+ return s;
1916
+ }
1917
+ /** s.replace(/[\s]*(?:Ctrl|Cmd|Shift|Alt|Option|Command)(?:\+[A-Za-z0-9+\-]*|[A-Z0-9]+)?$/, '') */
1918
+ function withoutTextShortcut(s) {
1919
+ const n = s.length;
1920
+ let plusRun = n;
1921
+ while (plusRun > 0) {
1922
+ const c = s.charCodeAt(plusRun - 1);
1923
+ if (!(isDigit(c) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c === 43 || c === 45))
1924
+ break;
1925
+ plusRun--;
1926
+ }
1927
+ let upperRun = n;
1928
+ while (upperRun > 0) {
1929
+ const c = s.charCodeAt(upperRun - 1);
1930
+ if (!(isDigit(c) || (c >= 65 && c <= 90)))
1931
+ break;
1932
+ upperRun--;
1933
+ }
1934
+ const mod = /Ctrl|Cmd|Shift|Alt|Option|Command/g;
1935
+ for (let m = mod.exec(s); m; m = mod.exec(s)) {
1936
+ const t = m.index + m[0].length;
1937
+ if (t === n || (s.charCodeAt(t) === 43 && t + 1 >= plusRun) || t >= upperRun) {
1938
+ let w = m.index;
1939
+ while (w > 0 && isJsSpace(s.charCodeAt(w - 1)))
1940
+ w--;
1941
+ return s.slice(0, w);
1942
+ }
1943
+ }
1944
+ return s;
1945
+ }
1946
+ /** s.replace(/(?:\s*\+)+$/, '') */
1947
+ function withoutTrailingPluses(s) {
1948
+ if (s.charCodeAt(s.length - 1) !== 43)
1949
+ return s;
1950
+ let k = s.length;
1951
+ while (k > 0 && (s.charCodeAt(k - 1) === 43 || isJsSpace(s.charCodeAt(k - 1))))
1952
+ k--;
1953
+ return s.slice(0, k);
1954
+ }
1955
+ /** S2: /(?:transparent|rgba\([^)]+\)|color-mix\([^)]*\)|var\([^)]*\)|#[0-9a-fA-F]{3,8})\s+1px(?:\s*,)/.test(text) */
1956
+ function hasColorThenHairline(text) {
1957
+ const close = nextMatchFinder(text, /\)/g);
1958
+ let tailAt = -1;
1959
+ let tailOk = false;
1960
+ const tail = (e) => {
1961
+ if (e !== tailAt) {
1962
+ tailAt = e;
1963
+ const t = wsEnd(text, e);
1964
+ tailOk = t > e && text.startsWith('1px', t) && text.charCodeAt(wsEnd(text, t + 3)) === 44;
1965
+ }
1966
+ return tailOk;
1967
+ };
1968
+ const head = /transparent|rgba\(|color-mix\(|var\(|#/g;
1969
+ for (let m = head.exec(text); m; m = head.exec(text)) {
1970
+ const i = m.index;
1971
+ const after = i + m[0].length;
1972
+ // `transparent` can overlap itself: try the very next position too.
1973
+ head.lastIndex = i + 1;
1974
+ if (m[0] === 'transparent') {
1975
+ if (tail(after))
1976
+ return true;
1977
+ }
1978
+ else if (m[0] === '#') {
1979
+ let h = after;
1980
+ while (h < text.length && /[0-9a-fA-F]/.test(text[h]))
1981
+ h++;
1982
+ if (h - after >= 3 && h - after <= 8 && tail(h))
1983
+ return true;
1984
+ }
1985
+ else {
1986
+ const p = close(after);
1987
+ if (p === Infinity)
1988
+ continue;
1989
+ if (m[0] === 'rgba(' && p === after)
1990
+ continue;
1991
+ if (tail(p + 1))
1992
+ return true;
1993
+ }
1994
+ }
1995
+ return false;
1996
+ }
1997
+ /** S2: /\s+1px\s*,\s*(?:transparent|rgba\([^)]+\)|color-mix\([^)]*\)|var\([^)]*\))/.test(text) */
1998
+ function hasHairlineThenColor(text) {
1999
+ const lastClose = text.lastIndexOf(')');
2000
+ for (let at = text.indexOf('1px'); at >= 0; at = text.indexOf('1px', at + 1)) {
2001
+ if (at === 0 || !isJsSpace(text.charCodeAt(at - 1)))
2002
+ continue;
2003
+ let t = wsEnd(text, at + 3);
2004
+ if (text.charCodeAt(t) !== 44)
2005
+ continue;
2006
+ t = wsEnd(text, t + 1);
2007
+ if (text.startsWith('transparent', t))
2008
+ return true;
2009
+ if (text.startsWith('rgba(', t) && text.charCodeAt(t + 5) !== 41 && lastClose > t + 5)
2010
+ return true;
2011
+ if (text.startsWith('color-mix(', t) && lastClose >= t + 10)
2012
+ return true;
2013
+ if (text.startsWith('var(', t) && lastClose >= t + 4)
2014
+ return true;
2015
+ }
2016
+ return false;
2017
+ }
2018
+ /** S2: /\/\s*[^,/]*(?:^|\s)1px(?:$|[\s,])/.test(value) */
2019
+ function hasSlashThenHairline(value) {
2020
+ let lastSep = -1;
2021
+ for (let k = 0; k < value.length; k++) {
2022
+ const c = value.charCodeAt(k);
2023
+ if (c === 44 || c === 47) {
2024
+ lastSep = k;
2025
+ continue;
2026
+ }
2027
+ if (c !== 49 || k === 0 || !value.startsWith('1px', k) || !isJsSpace(value.charCodeAt(k - 1)))
2028
+ continue;
2029
+ const next = value.charCodeAt(k + 3);
2030
+ if ((k + 3 === value.length || isJsSpace(next) || next === 44) && lastSep >= 0 && value.charCodeAt(lastSep) === 47)
2031
+ return true;
2032
+ }
2033
+ return false;
2034
+ }
2035
+ /** css.match(/linear\(\s*[^)]{20,}\)/gi) || [] */
2036
+ function longLinearEasings(css) {
2037
+ const start = /linear\(/gi;
2038
+ const out = [];
2039
+ for (let pos = 0;;) {
2040
+ start.lastIndex = pos;
2041
+ const m = start.exec(css);
2042
+ if (!m)
2043
+ break;
2044
+ const after = m.index + m[0].length;
2045
+ const c = css.indexOf(')', after);
2046
+ if (c < 0)
2047
+ break;
2048
+ if (c - after >= 20)
2049
+ out.push(css.slice(m.index, c + 1));
2050
+ // Every start before c reads the same ')' with less between.
2051
+ pos = c + 1;
2052
+ }
2053
+ return out;
2054
+ }
2055
+ /**
2056
+ * Group 1 of each match of
2057
+ * /<link[^>]*rel=["']?stylesheet["']?[^>]*href=["']([^"']+)["']/gi:
2058
+ * the last quoted href in the tag after its first rel=stylesheet.
2059
+ */
2060
+ function stylesheetLinkMatches(html) {
2061
+ const start = /<link/gi;
2062
+ const rel = nextMatchFinder(html, /rel=["']?stylesheet/gi);
2063
+ const hrefs = matchStarts(html, /href=["']/gi);
2064
+ const gt = nextMatchFinder(html, />/g);
2065
+ const n = html.length;
2066
+ const isQuote = (c) => c === 34 || c === 39;
2067
+ let lastQuote = n - 1;
2068
+ while (lastQuote >= 0 && !isQuote(html.charCodeAt(lastQuote)))
2069
+ lastQuote--;
2070
+ const out = [];
2071
+ let ptr = 0;
2072
+ for (let pos = 0;;) {
2073
+ start.lastIndex = pos;
2074
+ const m = start.exec(html);
2075
+ if (!m)
2076
+ break;
2077
+ const after = m.index + m[0].length;
2078
+ const g = Math.min(gt(after), n);
2079
+ while (ptr < hrefs.length && hrefs[ptr] < after)
2080
+ ptr++;
2081
+ let end = ptr;
2082
+ while (end < hrefs.length && hrefs[end] < g)
2083
+ end++;
2084
+ // The last href=" in the tag whose value is non-empty and closes.
2085
+ let h = -1;
2086
+ for (let k = end - 1; k >= ptr && h < 0; k--) {
2087
+ const v = hrefs[k] + 6;
2088
+ if (v < n && !isQuote(html.charCodeAt(v)) && lastQuote > v)
2089
+ h = hrefs[k];
2090
+ }
2091
+ const r = rel(after);
2092
+ if (h >= 0 && r < g && r + (isQuote(html.charCodeAt(r + 4)) ? 15 : 14) <= h) {
2093
+ let q = h + 7;
2094
+ while (!isQuote(html.charCodeAt(q)))
2095
+ q++;
2096
+ out.push({ index: m.index, text: html.slice(m.index, q + 1), groups: [html.slice(h + 6, q)] });
2097
+ pos = q + 1;
2098
+ continue;
2099
+ }
2100
+ // Every start before g sees less of the same tag.
2101
+ if (g >= n)
2102
+ break;
2103
+ pos = g + 1;
2104
+ }
2105
+ return out;
2106
+ }
2107
+ /**
2108
+ * For each `{` in `opens`, the index of the `}` that brings the depth back to
2109
+ * zero from it, or -1: what a depth-counting walk from that brace finds, for
2110
+ * every brace in one pass.
2111
+ */
2112
+ function closingBraces(code, opens) {
2113
+ const out = new Map();
2114
+ const stack = [];
2115
+ for (let k = 0; k < code.length; k++) {
2116
+ const ch = code.charCodeAt(k);
2117
+ if (ch === 123)
2118
+ stack.push(k);
2119
+ else if (ch === 125 && stack.length) {
2120
+ const open = stack.pop();
2121
+ if (opens.has(open))
2122
+ out.set(open, k);
2123
+ }
2124
+ }
2125
+ return out;
2126
+ }
2127
+ // ── The call sites, one helper each, so the test runs exactly what they run ──
2128
+ /** /<button[^>]*>([\s\S]*?)<\/button>/gi (v38, v39). */
2129
+ function buttonElements(html) {
2130
+ return elementMatches(html, /<button/gi, /<\/button>/gi);
2131
+ }
2132
+ /** /<label[^>]*>([\s\S]*?)<\/label>/gi (v39). */
2133
+ function labelElements(html) {
2134
+ return elementMatches(html, /<label/gi, /<\/label>/gi);
2135
+ }
2136
+ /** /<a[^>]*>([\s\S]*?)<\/a>/gi (v40). */
2137
+ function anchorElements(html) {
2138
+ return elementMatches(html, /<a/gi, /<\/a>/gi);
2139
+ }
2140
+ /** /<style[^>]*>([\s\S]*?)<\/style>/gi (inline stylesheets). */
2141
+ function styleElements(html) {
2142
+ return elementMatches(html, /<style/gi, /<\/style>/gi);
2143
+ }
2144
+ /** /<(?:a|div|span)[^>]*role=["']button["'][^>]*>([\s\S]*?)<\/(?:a|div|span)>/gi (v38). */
2145
+ function roleButtonElements(html) {
2146
+ return roleElementMatches(html, /<(?:a|div|span)/gi, /role=["']button["']/gi, /<\/(?:a|div|span)>/gi);
2147
+ }
2148
+ /** /<[a-z]+[^>]*role=["']tab["'][^>]*>([\s\S]*?)<\/[a-z]+>/gi (v39). */
2149
+ function roleTabElements(html) {
2150
+ return roleElementMatches(html, /<[a-z]/gi, /role=["']tab["']/gi, /<\/[a-z]+>/gi);
2151
+ }
2152
+ /** v41: /class=["'][^"']*(eyebrow|eyebro|meta-label)[^"']*["']/i.test(attrs) */
2153
+ function hasEyebrowClass(attrs) {
2154
+ return quotedAttrHas(attrs, /class=["']/gi, /eyebro|meta-label/gi);
2155
+ }
2156
+ /** v41: /style=["'][^"']*text-transform:\s*uppercase[^"']*["']/i.test(attrs) */
2157
+ function hasUppercaseStyle(attrs) {
2158
+ return quotedAttrHas(attrs, /style=["']/gi, /text-transform:\s*uppercase/gi);
2159
+ }
2160
+ /** v17: /(?:h1|h2|h3|h4|h5|h6|\.h\d|heading|title)[^{]*\{[^}]*line-height\s*:\s*([0-9.]+)/gi */
2161
+ function headingLineHeights(css) {
2162
+ return ruleBodyScan(css, bracedStarts(css, /h1|h2|h3|h4|h5|h6|\.h\d|heading|title/gi), /line-height/gi, /line-height\s*:\s*([0-9.]+)/iy, false);
2163
+ }
2164
+ /** v17: /(?:body|p|article|\.body|\.prose|\.copy)[^{]*\{[^}]*line-height\s*:\s*([0-9.]+)/gi */
2165
+ function bodyLineHeights(css) {
2166
+ return ruleBodyScan(css, bracedStarts(css, /body|p|article|\.body|\.prose|\.copy/gi), /line-height/gi, /line-height\s*:\s*([0-9.]+)/iy, false);
2167
+ }
2168
+ /**
2169
+ * v24: /(?:^|[,}\s])\s*(?:button|a\b|input|textarea|select|\[role\s*=\s*["']?button["']?\]|\.btn|\.button|\.chip|\.tab|\.nav-link)\s*[^{]*\{[^}]*(?:min-height|min-width)\s*:\s*(\d+(?:\.\d+)?)(px|rem)/gim
2170
+ */
2171
+ function touchTargetSizes(css) {
2172
+ return ruleBodyScan(css, touchTargetStarts(css), /min-(?:height|width)/gi, /(?:min-height|min-width)\s*:\s*(\d+(?:\.\d+)?)(px|rem)/iy, false);
2173
+ }
2174
+ /** v27: /(?:input|textarea|select|\.input|\.field)[^{]*\{[^}]*font-size\s*:\s*(\d+(?:\.\d+)?)(px|rem)/gi */
2175
+ function fieldFontSizes(sheet) {
2176
+ return ruleBodyScan(sheet, bracedStarts(sheet, /input|textarea|select|\.input|\.field/gi), /font-size/gi, /font-size\s*:\s*(\d+(?:\.\d+)?)(px|rem)/iy, false);
2177
+ }
2178
+ /** v27: /input\s*(?:,[^{]*)?\{[^}]*font-size\s*:\s*(?:1rem|16px|1\.0(?:\d+)?rem|[2-9]\dpx)/gi */
2179
+ function fieldFontFloors(sheet) {
2180
+ return ruleBodyScan(sheet, inputFloorStarts(sheet), /font-size/gi, /font-size\s*:\s*(?:1rem|16px|1\.0(?:\d+)?rem|[2-9]\dpx)/iy, false);
2181
+ }
2182
+ /** v09: /:focus[^{]*\{[^}]*outline\s*:\s*(none|0)\s*[;}]/i.test(css) */
2183
+ function focusStripsOutline(css) {
2184
+ return ruleBodyScan(css, bracedStarts(css, /:focus/gi), /outline/gi, /outline\s*:\s*(none|0)\s*[;}]/iy, true).length > 0;
2185
+ }
2186
+ /** v09: /:focus[^{]*\{[^}]*(box-shadow|outline\s*:\s*[^n0])/i.test(css) */
2187
+ function focusHasRing(css) {
2188
+ return ruleBodyScan(css, bracedStarts(css, /:focus/gi), /box-shadow|outline/gi, /(box-shadow|outline\s*:\s*[^n0])/iy, true).length > 0;
2189
+ }
2190
+ /** v08: /@keyframes\s+[^{]*breath[^{]*\{[^}]*opacity\s*:/i.test(css) */
2191
+ function hasBreathKeyframes(css) {
2192
+ return ruleBodyScan(css, breathKeyframeStarts(css), /opacity/gi, /opacity\s*:/iy, true).length > 0;
2193
+ }
2194
+ /** v08: /@media[^{]*(?:hover\s*:\s*hover|pointer\s*:\s*fine)/i.test(css) */
2195
+ function mediaQueryHasHoverGuard(css) {
2196
+ return preludeHas(css, /@media/gi, /hover\s*:\s*hover|pointer\s*:\s*fine/gi);
2197
+ }
2198
+ /** v35: /@media[^{]*forced-colors\s*:\s*active/i.test(css) */
2199
+ function mediaQueryHasForcedColors(css) {
2200
+ return preludeHas(css, /@media/gi, /forced-colors\s*:\s*active/gi);
2201
+ }
2202
+ /** v35: /@media[^{]*-ms-high-contrast/i.test(css) */
2203
+ function mediaQueryHasHighContrast(css) {
2204
+ return preludeHas(css, /@media/gi, /-ms-high-contrast/gi);
2205
+ }
2206
+ /** O4: /@media[^{]*prefers-reduced-motion/i.test(css) */
2207
+ function mediaQueryHasReducedMotion(css) {
2208
+ return preludeHas(css, /@media/gi, /prefers-reduced-motion/gi);
2209
+ }
2210
+ /** v05: /@media[^{]*prefers-reduced-motion\s*:\s*(reduce|no-preference)\b[^{]*\{/gi */
2211
+ function reducedMotionQueries(code) {
2212
+ return preludeMatches(code, /@media/gi, /prefers-reduced-motion/gi, /prefers-reduced-motion\s*:\s*(reduce|no-preference)\b/iy);
2213
+ }
2214
+ /** color-mix: /^(\d*\.?\d+)%\s+([\s\S]*)$/.exec(p), the number without the ambiguous \d*\.?\d+ */
2215
+ function percentBefore(p) {
2216
+ return /^(\d+(?:\.\d+)?|\.\d+)%\s+([\s\S]*)$/.exec(p);
2217
+ }
2218
+ /** v45: /^(?:0*\.?0*|0*(?:\.0+)?%)$/.test(v), without the ambiguous 0*\.?0* */
2219
+ function isZeroOpacity(v) {
2220
+ return /^(?:0*(?:\.0*)?|0*(?:\.0+)?%)$/.test(v);
2221
+ }
2222
+ /** O1: the cubic-bezier() matches, each number written without the ambiguous \d*\.?\d+ */
2223
+ function cubicBezierMatches(css) {
2224
+ const re = /cubic-bezier\(\s*(-?(?:\d+(?:\.\d+)?|\.\d+))\s*,\s*(-?(?:\d+(?:\.\d+)?|\.\d+))\s*,\s*(-?(?:\d+(?:\.\d+)?|\.\d+))\s*,\s*(-?(?:\d+(?:\.\d+)?|\.\d+))\s*\)/gi;
2225
+ const out = [];
2226
+ for (let m = re.exec(css); m; m = re.exec(css))
2227
+ out.push({ index: m.index, text: m[0], groups: m.slice(1) });
2228
+ return out;
2229
+ }
2230
+ /** v34: /<script\b[^>]*src\s*=\s*["'][^"']*\b(?:chatbot|ai-widget|dialogflow)\b/i.test(html) */
2231
+ function hasAiWidgetScript(html) {
2232
+ return tagAttrValueHas(html, /<script\b/gi, /src\s*=\s*["']/gi, /\b(?:chatbot|ai-widget|dialogflow)\b/gi);
2233
+ }
2234
+ /** v34: /<script\b[^>]*src\s*=\s*["'][^"']*\b(?:assistant|claude|gemini|rasa|intercom|drift|tawk|crisp|livechat|conversational)\b/i.test(html) */
2235
+ function hasChatScript(html) {
2236
+ return tagAttrValueHas(html, /<script\b/gi, /src\s*=\s*["']/gi, /\b(?:assistant|claude|gemini|rasa|intercom|drift|tawk|crisp|livechat|conversational)\b/gi);
2237
+ }
2238
+ /** v34: /<(?:iframe|div|section|aside)\b[^>]*(?:class|id)\s*=\s*["'][^"']*\b(?:chatbot|chat-bot|ai-assistant|ai_chat)\b/i.test(html) */
2239
+ function hasAiChatContainer(html) {
2240
+ return tagAttrValueHas(html, /<(?:iframe|div|section|aside)\b/gi, /(?:class|id)\s*=\s*["']/gi, /\b(?:chatbot|chat-bot|ai-assistant|ai_chat)\b/gi);
2241
+ }
2242
+ /** v34: /<(?:input|textarea|form)\b[^>]*(?:placeholder|aria-label)\s*=\s*["'][^"']*\b(?:ask\s+ai|message\s+ai|chat\s+with\s+ai|ai\s+assistant)\b/i.test(html) */
2243
+ function hasAiInput(html) {
2244
+ return tagAttrValueHas(html, /<(?:input|textarea|form)\b/gi, /(?:placeholder|aria-label)\s*=\s*["']/gi, /\b(?:ask\s+ai|message\s+ai|chat\s+with\s+ai|ai\s+assistant)\b/gi);
2245
+ }
2246
+ /** v34: /<(?:iframe|div|section|aside)\b[^>]*(?:class|id)\s*=\s*["'][^"']*\b(?:assistant-widget|intercom|drift|zendesk-chat|tawk(?:\.to)?|crisp\.chat|livechat|chat-widget|chat-container)\b/i.test(html) */
2247
+ function hasChatWidgetContainer(html) {
2248
+ return tagAttrValueHas(html, /<(?:iframe|div|section|aside)\b/gi, /(?:class|id)\s*=\s*["']/gi, /\b(?:assistant-widget|intercom|drift|zendesk-chat|tawk(?:\.to)?|crisp\.chat|livechat|chat-widget|chat-container)\b/gi);
2249
+ }
2250
+ /** v34: /<(?:iframe|div|section)\b[^>]*(?:class|id)\s*=\s*["'][^"']*\b(?:contact-widget|smart-search|virtual-agent|message-us|help-widget)\b/i.test(html) */
2251
+ function hasAmbiguousWidget(html) {
2252
+ return tagAttrValueHas(html, /<(?:iframe|div|section)\b/gi, /(?:class|id)\s*=\s*["']/gi, /\b(?:contact-widget|smart-search|virtual-agent|message-us|help-widget)\b/gi);
2253
+ }
2254
+ /** v34: /<meta\s+name\s*=\s*["']generator["'][^>]*content\s*=\s*["'][^"']*\b(?:ai|gpt|claude|gemini|llm|generated|artificial\s+intelligence)\b/i.test(html) */
2255
+ function hasAiGeneratorMeta(html) {
2256
+ return tagAttrValueHas(html, /<meta\s+name\s*=\s*["']generator["']/gi, /content\s*=\s*["']/gi, /\b(?:ai|gpt|claude|gemini|llm|generated|artificial\s+intelligence)\b/gi);
2257
+ }
2258
+ /** text.replace(/\[[^\]]*\]/g, fn) */
2259
+ function replaceBrackets(text, fn) {
2260
+ return replaceSpansWith(text, bracketSpans(text), fn);
2261
+ }
2262
+ /**
2263
+ * S8: html with <script>, <style> and <template> elements and comments each
2264
+ * replaced by a space (/<script\b[\s\S]*?<\/script\s*>/gi and the like).
2265
+ */
2266
+ function withoutNonVisibleMarkup(html) {
2267
+ const a = replaceSpans(html, lazyBlockSpans(html, /<script\b/gi, /<\/script\s*>/gi), ' ');
2268
+ const b = replaceSpans(a, lazyBlockSpans(a, /<style\b/gi, /<\/style\s*>/gi), ' ');
2269
+ const c = replaceSpans(b, lazyBlockSpans(b, /<template\b/gi, /<\/template\s*>/gi), ' ');
2270
+ return replaceSpans(c, lazyBlockSpans(c, /<!--/g, /-->/g), ' ');
2271
+ }
2272
+ /**
2273
+ * S2: does some linear-gradient() in the value carry two or more colour stops?
2274
+ * The lower-cased copy is made once; it was remade for every gradient read.
2275
+ */
2276
+ function hasMultiStopGradient(value) {
2277
+ const lower = value.toLowerCase();
2278
+ let from = 0;
2279
+ for (;;) {
2280
+ const at = lower.indexOf('linear-gradient(', from);
2281
+ if (at < 0)
2282
+ return false;
2283
+ let depth = 0;
2284
+ let end = -1;
2285
+ for (let i = at + 15; i < value.length; i++) {
2286
+ if (value[i] === '(')
2287
+ depth++;
2288
+ else if (value[i] === ')' && --depth === 0) {
2289
+ end = i;
2290
+ break;
2291
+ }
2292
+ }
2293
+ if (end < 0)
2294
+ return false;
2295
+ const args = splitCssTopLevel(value.slice(at + 16, end), ',');
2296
+ const stops = /^(?:to\s|[-+]?[\d.]+(?:deg|turn|rad|grad)\b)/i.test(args[0] ?? '') ? args.slice(1) : args;
2297
+ if (stops.length >= 2)
2298
+ return true;
2299
+ from = end;
2300
+ }
2301
+ }
270
2302
  // ── CSS extraction + token parsing ──────────────────────────────────────────
271
2303
  function extractCssLinks(html, baseUrl) {
272
2304
  const links = [];
273
- const styleRe = /<style[^>]*>([\s\S]*?)<\/style>/gi;
274
- let m;
275
- while ((m = styleRe.exec(html)) !== null)
276
- links.push(m[1]);
277
- const linkRe = /<link[^>]*rel=["']?stylesheet["']?[^>]*href=["']([^"']+)["']/gi;
278
- while ((m = linkRe.exec(html)) !== null) {
2305
+ for (const m of styleElements(html))
2306
+ links.push(m.groups[0]);
2307
+ for (const m of stylesheetLinkMatches(html)) {
279
2308
  try {
280
- links.push(new URL(m[1], baseUrl).href);
2309
+ links.push(new URL(m.groups[0], baseUrl).href);
281
2310
  }
282
2311
  catch { /* skip */ }
283
2312
  }
@@ -325,18 +2354,13 @@ async function fetchPageResilient(targetUrl) {
325
2354
  return { ok: true, html, css: cssParts.join('\n') };
326
2355
  }
327
2356
  function extractRootTokens(css) {
328
- const stripped = css.replace(/@media[^{]*\{[^@]*?\}\s*\}/gi, '');
2357
+ const stripped = withoutMediaBlocks(css);
329
2358
  const tokens = {};
330
2359
  // `:root, :host { ... }` is the block Tailwind v4 writes its theme into;
331
2360
  // until engine 1.3.0 only `:root {` was read, so those tokens were missed.
332
- const rootRe = /:root(?:\s*,\s*:host)?\s*\{([^}]*)\}/g;
333
- let m;
334
- while ((m = rootRe.exec(stripped)) !== null) {
335
- const block = m[1];
336
- const propRe = /--([\w-]+)\s*:\s*([^;]+?)(?:;|$)/g;
337
- let p;
338
- while ((p = propRe.exec(block)) !== null) {
339
- tokens[`--${p[1]}`] = p[2].trim();
2361
+ for (const m of rootTokenBlocks(stripped)) {
2362
+ for (const p of customPropertyValues(m.groups[0])) {
2363
+ tokens[`--${p.groups[0]}`] = p.groups[1].trim();
340
2364
  }
341
2365
  }
342
2366
  return tokens;
@@ -699,7 +2723,7 @@ function checkTabularNums(css) {
699
2723
  * fixture broken-reduced-motion-inverted pins that case.
700
2724
  */
701
2725
  function optsIntoMotion(body) {
702
- if (/@view-transition\s*\{[^}]*navigation\s*:\s*auto/i.test(body))
2726
+ if (hasViewTransitionNavigation(body))
703
2727
  return true;
704
2728
  const declRe = /(?:^|[{;\s])(animation|animation-name|animation-duration|transition|transition-property|transition-duration|scroll-behavior|view-transition-name)\s*:\s*([^;{}]+)/gi;
705
2729
  let m;
@@ -713,7 +2737,7 @@ function optsIntoMotion(body) {
713
2737
  }
714
2738
  if (/^(?:none|initial|unset|revert|revert-layer|0|0s|0ms)$/.test(value))
715
2739
  continue;
716
- const times = [...value.matchAll(/(?:^|[\s,(])(\d*\.?\d+)(ms|s)\b/g)].map((t) => parseFloat(t[1]) * (t[2] === 's' ? 1000 : 1));
2740
+ const times = [...value.matchAll(/(?:^|[\s,(])(\d+(?:\.\d+)?|\.\d+)(ms|s)\b/g)].map((t) => parseFloat(t[1]) * (t[2] === 's' ? 1000 : 1));
717
2741
  if (times.length > 0 && times.every((t) => t <= 10))
718
2742
  continue;
719
2743
  return true;
@@ -727,7 +2751,7 @@ function checkReducedMotion(css) {
727
2751
  // prose mentioning prefers-reduced-motion — satisfied the old check. The
728
2752
  // calibration corpus caught exactly that: the previous regex matched the
729
2753
  // string anywhere, including inside /* */.
730
- const code = css.replace(/\/\*[\s\S]*?\*\//g, '');
2754
+ const code = withoutCssComments(css, '');
731
2755
  // Match the media query and read its VALUE. Two shapes honour reduced motion:
732
2756
  // * `reduce` with a non-empty body: motion is switched off for those users.
733
2757
  // * `no-preference` with a body that opts INTO motion (optsIntoMotion): the
@@ -737,33 +2761,29 @@ function checkReducedMotion(css) {
737
2761
  // pattern. A bare substring match on the feature name stays wrong in both
738
2762
  // directions: an empty block, or a `no-preference` block that only switches
739
2763
  // motion off, honours nothing and still WARNs.
740
- const mqRe = /@media[^{]*prefers-reduced-motion\s*:\s*(reduce|no-preference)\b[^{]*\{/gi;
741
- let match;
2764
+ const queries = reducedMotionQueries(code);
742
2765
  let sawReduce = false;
743
2766
  let sawMotionOptIn = false;
744
2767
  let sawNoPreference = false;
745
- while ((match = mqRe.exec(code)) !== null) {
746
- const value = match[1].toLowerCase();
747
- // Capture the block body by brace-matching from the opening brace.
748
- const open = match.index + match[0].length - 1;
749
- let depth = 0;
750
- let close = -1;
751
- for (let i = open; i < code.length; i++) {
752
- if (code[i] === '{')
753
- depth++;
754
- else if (code[i] === '}') {
755
- depth--;
756
- if (depth === 0) {
757
- close = i;
758
- break;
759
- }
760
- }
761
- }
2768
+ // Each block body ends at the brace that brings the depth back to zero,
2769
+ // found for every query in one pass (engine 1.5.1; a walk per query read
2770
+ // nested queries' bodies over and over).
2771
+ const closes = closingBraces(code, new Set(queries.map((q) => q.index + q.text.length - 1)));
2772
+ // A no-preference block inside one already read opts into motion only if
2773
+ // that one does, so it is not read again.
2774
+ let readUntil = -1;
2775
+ for (const match of queries) {
2776
+ const value = match.groups[0].toLowerCase();
2777
+ const open = match.index + match.text.length - 1;
2778
+ const close = closes.get(open) ?? -1;
762
2779
  const body = close > open ? code.slice(open + 1, close).trim() : '';
763
2780
  if (value === 'no-preference') {
764
2781
  sawNoPreference = true;
765
- if (optsIntoMotion(body))
766
- sawMotionOptIn = true;
2782
+ if (!sawMotionOptIn && close > readUntil) {
2783
+ if (optsIntoMotion(body))
2784
+ sawMotionOptIn = true;
2785
+ readUntil = close;
2786
+ }
767
2787
  continue;
768
2788
  }
769
2789
  // A `reduce` query that declares no rules reduces nothing.
@@ -792,9 +2812,9 @@ function checkSemanticHtmlFoundation(html) {
792
2812
  // <meta name="description">, and a <main>/<header>/<nav> landmark. These are
793
2813
  // Lighthouse a11y basics (document-title weight 4.1, heading-order 0.8) and
794
2814
  // apply to every site. The check name/id stays v07 for continuity.
795
- const visibleHtml = html.replace(/<script[\s\S]*?<\/script>/gi, '').replace(/<style[\s\S]*?<\/style>/gi, '');
2815
+ const visibleHtml = withoutScriptsAndStyles(html);
796
2816
  const h1Count = (visibleHtml.match(/<h1\b/gi) || []).length;
797
- const hasTitle = /<title\b[^>]*>[^<]+<\/title>/i.test(html);
2817
+ const hasTitle = hasTitleText(html);
798
2818
  const hasMetaDesc = /<meta\s+name=["']description["']/i.test(html);
799
2819
  const hasLandmark = /<(main|header|nav)\b/i.test(visibleHtml);
800
2820
  const signals = [];
@@ -830,7 +2850,7 @@ function checkSemanticHtmlFoundation(html) {
830
2850
  function checkPressScale(css) {
831
2851
  // Strip @keyframes blocks — scale(0) inside a ripple/particle keyframe is a
832
2852
  // legitimate animation starting point, not a press scale.
833
- const stripped = css.replace(/@keyframes\s+[^{]+\{[^@]*?\}/gi, '');
2853
+ const stripped = withoutKeyframesBlocks(css);
834
2854
  // Collect all press-scale candidates: scale() < 1 found in CSS rule blocks
835
2855
  // (not keyframes). We split by '}' to get individual rule blocks so we can
836
2856
  // inspect the selector context for each scale() value.
@@ -888,15 +2908,8 @@ function checkPressScale(css) {
888
2908
  // v17 — REAL line-height by role check. Scans for heading-tight (1.0-1.15)
889
2909
  // and body-relaxed (1.4-1.7) line-heights. Contract: headings 1.08, body 1.55.
890
2910
  function checkLineHeightByRole(css) {
891
- const headingRe = /(?:h1|h2|h3|h4|h5|h6|\.h\d|heading|title)[^{]*\{[^}]*line-height\s*:\s*([0-9.]+)/gi;
892
- const bodyRe = /(?:body|p|article|\.body|\.prose|\.copy)[^{]*\{[^}]*line-height\s*:\s*([0-9.]+)/gi;
893
- const headingLhs = [];
894
- const bodyLhs = [];
895
- let m;
896
- while ((m = headingRe.exec(css)) !== null)
897
- headingLhs.push(parseFloat(m[1]));
898
- while ((m = bodyRe.exec(css)) !== null)
899
- bodyLhs.push(parseFloat(m[1]));
2911
+ const headingLhs = headingLineHeights(css).map((m) => parseFloat(m.groups[0]));
2912
+ const bodyLhs = bodyLineHeights(css).map((m) => parseFloat(m.groups[0]));
900
2913
  const headingOk = headingLhs.some(lh => lh >= 1.0 && lh <= 1.15);
901
2914
  const bodyOk = bodyLhs.some(lh => lh >= 1.4 && lh <= 1.7);
902
2915
  if (headingOk && bodyOk)
@@ -914,7 +2927,7 @@ function checkLineHeightByRole(css) {
914
2927
  // var(--signal) reference (or any non-default color). Browser default is
915
2928
  // #000/#fff text on #0000ff blue background — any custom rule beats that.
916
2929
  function checkSelectionStyled(css) {
917
- const selMatch = css.match(/::selection\s*\{[^}]*\}/gi);
2930
+ const selMatch = selectionRules(css);
918
2931
  if (!selMatch || selMatch.length === 0)
919
2932
  return { id: 'v20', item: '::selection styled with var(--signal) instead of the browser default', category: 'cadence', status: 'WARN', detail: 'no ::selection rule found: browser default will show' };
920
2933
  const usesSignal = selMatch.some(r => /var\(\s*--signal/i.test(r));
@@ -971,12 +2984,10 @@ function checkSkipInk(css) {
971
2984
  // Full verification needs a browser (getBoundingClientRect on rendered elements).
972
2985
  function checkTouchTargets(css) {
973
2986
  // Look for min-height/min-width ≥ 44px on button, a, input, [role=button] selectors.
974
- const interactiveRe = /(?:^|[,}\s])\s*(?:button|a\b|input|textarea|select|\[role\s*=\s*["']?button["']?\]|\.btn|\.button|\.chip|\.tab|\.nav-link)\s*[^{]*\{[^}]*(?:min-height|min-width)\s*:\s*(\d+(?:\.\d+)?)(px|rem)/gim;
975
2987
  const targets = [];
976
- let m;
977
- while ((m = interactiveRe.exec(css)) !== null) {
978
- const val = parseFloat(m[1]);
979
- const unit = m[2].toLowerCase();
2988
+ for (const m of touchTargetSizes(css)) {
2989
+ const val = parseFloat(m.groups[0]);
2990
+ const unit = m.groups[1].toLowerCase();
980
2991
  const px = unit === 'rem' ? val * 16 : val;
981
2992
  targets.push(px);
982
2993
  }
@@ -990,7 +3001,7 @@ function checkTouchTargets(css) {
990
3001
  // v25 — Heading hierarchy: single h1, no skipped levels (h1→h3 jump = fail).
991
3002
  // Scans HTML for heading order. design-auditor + Lighthouse heading-order check.
992
3003
  function checkHeadingHierarchy(html) {
993
- const visibleHtml = html.replace(/<script[\s\S]*?<\/script>/gi, '').replace(/<style[\s\S]*?<\/style>/gi, '');
3004
+ const visibleHtml = withoutScriptsAndStyles(html);
994
3005
  const headings = [...visibleHtml.matchAll(/<h([1-6])\b/gi)];
995
3006
  if (headings.length === 0)
996
3007
  return { id: 'v25', item: 'Heading hierarchy: single h1, no skipped levels', category: 'accessibility', status: 'WARN', detail: 'no heading elements found: page may lack structure' };
@@ -1129,7 +3140,7 @@ function checkFontFamilyCount(css, tokens) {
1129
3140
  * ".field label" and "user-select" are not fields.
1130
3141
  */
1131
3142
  function isFieldSelector(selector) {
1132
- const clean = selector.replace(/\/\*[\s\S]*?\*\//g, ' ');
3143
+ const clean = withoutCssComments(selector, ' ');
1133
3144
  return splitCssTopLevel(clean, ',').some((part) => {
1134
3145
  const compounds = part.trim().split(/\s*[>+~]\s*|\s+/);
1135
3146
  const subject = compounds[compounds.length - 1] || '';
@@ -1140,22 +3151,60 @@ function isFieldSelector(selector) {
1140
3151
  });
1141
3152
  }
1142
3153
  /**
1143
- * v27 helper (engine 1.4.1): the selector of the rule whose body holds the
1144
- * declaration at `offset`, read backwards from that body's opening brace to
1145
- * the previous closing brace, opening brace or semicolon. Null when the offset
1146
- * is not inside a rule body (a stray declaration, or a brace closed in
1147
- * between). Linear: a few lastIndexOf scans per call, no whole-sheet regex.
3154
+ * v27 helper (engine 1.4.1): the rule whose body holds the declaration at an
3155
+ * offset, its selector read backwards from that body's opening brace to the
3156
+ * previous closing brace, opening brace or semicolon. The finder returns
3157
+ * [start, open], the selector being css.slice(start, open), or null when the
3158
+ * offset is not inside a rule body (a stray declaration, or a brace closed in
3159
+ * between).
3160
+ *
3161
+ * Engine 1.5.1: the finder lists the sheet's braces and semicolons once and
3162
+ * answers each offset by binary search. Before, each declaration ran three
3163
+ * lastIndexOf scans, each reading back to the last character of its kind, so
3164
+ * a sheet with no semicolons was read back to its start once per declaration
3165
+ * and cost the square of its length. Each answer is the one those scans gave.
1148
3166
  */
1149
- function ruleSelectorAt(css, offset) {
1150
- const open = css.lastIndexOf('{', offset);
1151
- if (open < 0 || css.lastIndexOf('}', offset) > open)
1152
- return null;
1153
- const start = Math.max(css.lastIndexOf('}', open - 1), css.lastIndexOf('{', open - 1), css.lastIndexOf(';', open - 1)) + 1;
1154
- return css.slice(start, open);
3167
+ function ruleSelectorFinder(css) {
3168
+ const opens = [];
3169
+ const closes = [];
3170
+ const marks = [];
3171
+ for (let i = 0; i < css.length; i++) {
3172
+ const c = css.charCodeAt(i);
3173
+ if (c === 123) {
3174
+ opens.push(i);
3175
+ marks.push(i);
3176
+ }
3177
+ else if (c === 125) {
3178
+ closes.push(i);
3179
+ marks.push(i);
3180
+ }
3181
+ else if (c === 59)
3182
+ marks.push(i);
3183
+ }
3184
+ // The last entry at or before x in an ascending list, or -1.
3185
+ const atOrBefore = (list, x) => {
3186
+ let lo = 0;
3187
+ let hi = list.length;
3188
+ while (lo < hi) {
3189
+ const mid = (lo + hi) >> 1;
3190
+ if (list[mid] <= x)
3191
+ lo = mid + 1;
3192
+ else
3193
+ hi = mid;
3194
+ }
3195
+ return lo > 0 ? list[lo - 1] : -1;
3196
+ };
3197
+ return (offset) => {
3198
+ const at = Math.max(0, offset);
3199
+ const open = atOrBefore(opens, at);
3200
+ if (open < 0 || atOrBefore(closes, at) > open)
3201
+ return null;
3202
+ return [atOrBefore(marks, open - 1) + 1, open];
3203
+ };
1155
3204
  }
1156
3205
  /**
1157
- * v27 helper (engine 1.4.1): does the font-size declaration at `offset` sit in
1158
- * a rule whose own selector names a field keyword (input, textarea, select,
3206
+ * v27 helper (engine 1.4.1): for an offset, does the font-size declaration
3207
+ * there sit in a rule whose own selector names a field keyword (input, textarea, select,
1159
3208
  * .input, .field), the same words the literal scan looks for? v27's literal
1160
3209
  * regexes find a keyword and then the next rule body, and their `[^{]*` runs
1161
3210
  * across a closing brace. So the word "select" in `.chip { user-select: none }`
@@ -1168,9 +3217,22 @@ function ruleSelectorAt(css, offset) {
1168
3217
  * isFieldSelector test dropped real fields (a 14px textarea, a 16px search
1169
3218
  * input's floor).
1170
3219
  */
1171
- function inFieldRule(css, offset) {
1172
- const selector = ruleSelectorAt(css, offset);
1173
- return selector !== null && /input|textarea|select|\.field/i.test(selector.replace(/\/\*[\s\S]*?\*\//g, ' '));
3220
+ function fieldRuleFinder(css) {
3221
+ const ruleAt = ruleSelectorFinder(css);
3222
+ let seen = -1;
3223
+ let verdict = false;
3224
+ return (offset) => {
3225
+ const rule = ruleAt(offset);
3226
+ if (rule === null)
3227
+ return false;
3228
+ // The declarations of one rule share its verdict (engine 1.5.1: each one
3229
+ // read the selector again).
3230
+ if (rule[1] !== seen) {
3231
+ seen = rule[1];
3232
+ verdict = /input|textarea|select|\.field/i.test(withoutCssComments(css.slice(rule[0], rule[1]), ' '));
3233
+ }
3234
+ return verdict;
3235
+ };
1174
3236
  }
1175
3237
  /**
1176
3238
  * v27 helper (engine 1.3.1): in each rule whose selector styles a field
@@ -1184,22 +3246,45 @@ function inFieldRule(css, offset) {
1184
3246
  * a card caption) as an input below 16px. Only values that start with var(
1185
3247
  * are touched; calc(), clamp() and min()/max() stay as written, as before.
1186
3248
  *
1187
- * The scan is anchored on each `font-size: var(` and reads its rule's selector
1188
- * backwards from the opening brace, so its cost is linear in the sheet. A
3249
+ * The scan is anchored on each `font-size: var(` and finds its rule's selector
3250
+ * through ruleSelectorFinder, so its cost is linear in the sheet. A
1189
3251
  * whole-sheet rule regex (`([^{}]*)\{([^{}]*)\}`) is quadratic on a long
1190
3252
  * @media prelude: measured at +4.5 s on five 43 KB preludes.
1191
3253
  */
1192
3254
  function resolveFontSizeVars(css, tokens) {
1193
3255
  if (css.indexOf('var(') < 0)
1194
3256
  return css;
1195
- return css.replace(/(font-size\s*:\s*)(var\([^;{}]*\))/gi, (decl, lead, value, offset) => {
3257
+ const parts = [];
3258
+ const ruleAt = ruleSelectorFinder(css);
3259
+ let seen = -1;
3260
+ let styled = false;
3261
+ let last = 0;
3262
+ for (const m of fontSizeVarMatches(css)) {
3263
+ const decl = m.text;
3264
+ const lead = m.groups[0];
3265
+ const value = m.groups[1];
3266
+ const offset = m.index;
3267
+ parts.push(css.slice(last, offset));
3268
+ last = offset + decl.length;
1196
3269
  // Not inside a rule body (a stray declaration, or a brace closed between).
1197
- const selector = ruleSelectorAt(css, offset);
1198
- if (selector === null || !isFieldSelector(selector))
1199
- return decl;
3270
+ const rule = ruleAt(offset);
3271
+ if (rule === null) {
3272
+ parts.push(decl);
3273
+ continue;
3274
+ }
3275
+ if (rule[1] !== seen) {
3276
+ seen = rule[1];
3277
+ styled = isFieldSelector(css.slice(rule[0], rule[1]));
3278
+ }
3279
+ if (!styled) {
3280
+ parts.push(decl);
3281
+ continue;
3282
+ }
1200
3283
  const sub = substituteCssVars(value, [tokens]);
1201
- return sub === null ? decl : lead + sub.trim();
1202
- });
3284
+ parts.push(sub === null ? decl : lead + sub.trim());
3285
+ }
3286
+ parts.push(css.slice(last));
3287
+ return parts.join('');
1203
3288
  }
1204
3289
  /**
1205
3290
  * v27 helper: does the delivered markup carry a field iOS Safari zooms into on
@@ -1208,10 +3293,10 @@ function resolveFontSizeVars(css, tokens) {
1208
3293
  * image, file, range and color. An <input> with no type is a text field.
1209
3294
  */
1210
3295
  function hasZoomableField(html) {
1211
- const visible = html.replace(/<script[\s\S]*?<\/script>/gi, '').replace(/<style[\s\S]*?<\/style>/gi, '');
3296
+ const visible = withoutScriptsAndStyles(html);
1212
3297
  if (/<(?:textarea|select)\b/i.test(visible))
1213
3298
  return true;
1214
- const inputs = visible.match(/<input\b[^>]*>/gi) || [];
3299
+ const inputs = inputTags(visible);
1215
3300
  return inputs.some((tag) => {
1216
3301
  const type = /\stype\s*=\s*["']?([a-z-]+)/i.exec(tag);
1217
3302
  return !type || !/^(?:hidden|checkbox|radio|submit|button|reset|image|file|range|color)$/i.test(type[1]);
@@ -1236,16 +3321,15 @@ function hasZoomableField(html) {
1236
3321
  // cannot resolve is left as written, so it reads as it did before.
1237
3322
  function checkInputFontFloor(css, html, tokens = {}) {
1238
3323
  const sheet = resolveFontSizeVars(css, tokens);
3324
+ const inFieldRule = fieldRuleFinder(sheet);
1239
3325
  // Find input/textarea/select rules with font-size < 16px or < 1rem.
1240
- const inputRe = /(?:input|textarea|select|\.input|\.field)[^{]*\{[^}]*font-size\s*:\s*(\d+(?:\.\d+)?)(px|rem)/gi;
1241
3326
  const below = [];
1242
- let m;
1243
- while ((m = inputRe.exec(sheet)) !== null) {
3327
+ for (const m of fieldFontSizes(sheet)) {
1244
3328
  // Engine 1.4.1: only a declaration whose own rule styles a field counts.
1245
- if (!inFieldRule(sheet, m.index + m[0].lastIndexOf('font-size')))
3329
+ if (!inFieldRule(m.index + m.text.lastIndexOf('font-size')))
1246
3330
  continue;
1247
- const val = parseFloat(m[1]);
1248
- const unit = m[2].toLowerCase();
3331
+ const val = parseFloat(m.groups[0]);
3332
+ const unit = m.groups[1].toLowerCase();
1249
3333
  const px = unit === 'rem' ? val * 16 : val;
1250
3334
  if (px < 16)
1251
3335
  below.push(`${val}${unit} (${px}px)`);
@@ -1255,11 +3339,11 @@ function checkInputFontFloor(css, html, tokens = {}) {
1255
3339
  // The strict form `input\s*\{` matches standalone rules; the grouped form
1256
3340
  // `input,...,{` matches selectors merged by CSS minifiers (e.g. `input,textarea,select{font-size:1rem}`).
1257
3341
  // Engine 1.4.1: the floor rule must itself style a field, as above.
1258
- const floorRe = /input\s*(?:,[^{]*)?\{[^}]*font-size\s*:\s*(?:1rem|16px|1\.0(?:\d+)?rem|[2-9]\dpx)/gi;
1259
3342
  let hasGlobalFloor = false;
1260
- let f;
1261
- while (!hasGlobalFloor && (f = floorRe.exec(sheet)) !== null) {
1262
- hasGlobalFloor = inFieldRule(sheet, f.index + f[0].lastIndexOf('font-size'));
3343
+ for (const f of fieldFontFloors(sheet)) {
3344
+ if (hasGlobalFloor)
3345
+ break;
3346
+ hasGlobalFloor = inFieldRule(f.index + f.text.lastIndexOf('font-size'));
1263
3347
  }
1264
3348
  if (hasGlobalFloor)
1265
3349
  return { id: 'v27', item: 'Input font-size ≥16px (prevents iOS Safari auto-zoom)', category: 'accessibility', status: 'PASS', detail: 'input font-size floor detected' };
@@ -1281,14 +3365,12 @@ function checkReadingWidth(css) {
1281
3365
  // contains a prose-ish word (e.g. `.prose-grid`).
1282
3366
  const STRUCTURAL_SELECTOR = /grid|table|row|col\b|flex|swatch|chip|badge|tab\b|nav\b|toolbar|chart|canvas|pre\b|code\b|kbd/i;
1283
3367
  // Walk every rule block, capturing selector + declarations together.
1284
- const ruleRe = /([^{}]+)\{([^{}]*)\}/g;
1285
3368
  const proseInRange = [];
1286
3369
  const proseOutOfRange = [];
1287
3370
  const nonProse = [];
1288
- let m;
1289
- while ((m = ruleRe.exec(css)) !== null) {
1290
- const selector = m[1].replace(/\/\*[\s\S]*?\*\//g, '').trim();
1291
- const body = m[2];
3371
+ for (const m of cssRuleBlocks(css)) {
3372
+ const selector = withoutCssComments(m.groups[0], '').trim();
3373
+ const body = m.groups[1];
1292
3374
  // Skip at-rule preludes (`@media (...)`, `@supports (...)`) — the block
1293
3375
  // body is walked on the next iteration when the inner rule is seen. A
1294
3376
  // media query wrapping prose rules should not itself be classified.
@@ -1607,6 +3689,16 @@ function parseCssRules(css) {
1607
3689
  lastCssParse = { css, result };
1608
3690
  return result;
1609
3691
  }
3692
+ // Engine 1.5.1: limits that only a malformed or hostile sheet reaches. The
3693
+ // leaderboard cohort, measured 2026-10-15, nests at most 6 levels deep and
3694
+ // leaves at most one block open at the end of a page's CSS. A brace that
3695
+ // would open a level deeper than CSS_MAX_NESTING is read as text of the block
3696
+ // it sits in. At the end of the sheet at most CSS_MAX_OPEN_AT_END blocks left
3697
+ // open are closed and read, innermost first: each one reads to the end of the
3698
+ // sheet, so every further one was another full copy of the rest of it, and a
3699
+ // short sheet that left many blocks open cost seconds.
3700
+ const CSS_MAX_NESTING = 16;
3701
+ const CSS_MAX_OPEN_AT_END = 4;
1610
3702
  function parseCssRulesUncached(css) {
1611
3703
  const src = stripCssComments(css);
1612
3704
  const n = src.length;
@@ -1616,6 +3708,7 @@ function parseCssRulesUncached(css) {
1616
3708
  let order = 0;
1617
3709
  let start = 0;
1618
3710
  let paren = 0;
3711
+ let overflow = 0;
1619
3712
  const close = (f, end) => {
1620
3713
  f.parts.push(src.slice(f.segStart, end));
1621
3714
  const decls = f.parts.join(' ').trim();
@@ -1674,6 +3767,12 @@ function parseCssRulesUncached(css) {
1674
3767
  }
1675
3768
  if (c === 123) {
1676
3769
  paren = 0;
3770
+ if (stack.length >= CSS_MAX_NESTING) {
3771
+ overflow++;
3772
+ start = i + 1;
3773
+ i++;
3774
+ continue;
3775
+ }
1677
3776
  const prelude = src.slice(start, i).trim();
1678
3777
  const parent = stack.length ? stack[stack.length - 1] : null;
1679
3778
  if (parent)
@@ -1714,6 +3813,12 @@ function parseCssRulesUncached(css) {
1714
3813
  }
1715
3814
  if (c === 125) {
1716
3815
  paren = 0;
3816
+ if (overflow > 0) {
3817
+ overflow--;
3818
+ start = i + 1;
3819
+ i++;
3820
+ continue;
3821
+ }
1717
3822
  const f = stack.pop();
1718
3823
  if (f) {
1719
3824
  close(f, i);
@@ -1727,9 +3832,9 @@ function parseCssRulesUncached(css) {
1727
3832
  }
1728
3833
  i++;
1729
3834
  }
1730
- while (stack.length) {
3835
+ for (let closed = 0; stack.length; closed++) {
1731
3836
  const f = stack.pop();
1732
- if (f)
3837
+ if (f && closed < CSS_MAX_OPEN_AT_END)
1733
3838
  close(f, n);
1734
3839
  }
1735
3840
  return { rules, keyframes };
@@ -1744,9 +3849,10 @@ function parseCssDecls(decls) {
1744
3849
  if (!/^-{0,2}[a-zA-Z_][\w-]*$/.test(name))
1745
3850
  continue;
1746
3851
  let value = raw.slice(colon + 1).trim();
1747
- const important = /!\s*important\s*$/i.test(value);
3852
+ const cut = importantSuffixStart(value);
3853
+ const important = cut >= 0;
1748
3854
  if (important)
1749
- value = value.replace(/\s*!\s*important\s*$/i, '').trim();
3855
+ value = value.slice(0, cut).trim();
1750
3856
  out.push({ prop: name.startsWith('--') ? name : name.toLowerCase(), value, important });
1751
3857
  }
1752
3858
  return out;
@@ -1837,7 +3943,7 @@ function cssNumber(token, percentScale) {
1837
3943
  const t = token.trim().toLowerCase();
1838
3944
  if (t === 'none')
1839
3945
  return 0;
1840
- const m = /^([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)(%|deg|turn|rad|grad)?$/.exec(t);
3946
+ const m = /^([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)(%|deg|turn|rad|grad)?$/.exec(t);
1841
3947
  if (!m)
1842
3948
  return null;
1843
3949
  const x = parseFloat(m[1]);
@@ -1919,8 +4025,8 @@ function parseCssColor(input, dark, depth = 0) {
1919
4025
  if (!['srgb', 'oklab', 'oklch'].includes(space))
1920
4026
  return null;
1921
4027
  const side = (p) => {
1922
- const after = /^([\s\S]*?)\s+(\d*\.?\d+)%$/.exec(p);
1923
- const before = /^(\d*\.?\d+)%\s+([\s\S]*)$/.exec(p);
4028
+ const after = percentAfter(p);
4029
+ const before = percentBefore(p);
1924
4030
  if (after)
1925
4031
  return { color: after[1], pct: parseFloat(after[2]) / 100 };
1926
4032
  if (before)
@@ -1986,7 +4092,7 @@ function parseCssColor(input, dark, depth = 0) {
1986
4092
  const mult = Math.min(1, sum);
1987
4093
  return [Math.round(rgb[0]), Math.round(rgb[1]), Math.round(rgb[2]), Math.round(alpha * mult * 255) / 255];
1988
4094
  }
1989
- const rel = /^from\s+([\s\S]+?)\s+(r\s+g\s+b|h\s+s\s+l|l\s+c\s+h|l\s+a\s+b)\s*(?:\/\s*([\s\S]+))?$/.exec(args);
4095
+ const rel = relativeColorMatch(args);
1990
4096
  if (rel) {
1991
4097
  const base = parseCssColor(rel[1], dark, depth + 1);
1992
4098
  if (!base)
@@ -2132,8 +4238,7 @@ function themeSelectorKeys(selector) {
2132
4238
  }
2133
4239
  /** A selector part with its theme attributes and classes removed. */
2134
4240
  function withoutThemeKeys(part) {
2135
- return part
2136
- .replace(/\[[^\]]*\]/g, (a) => (themeSelectorKeys(a).length ? '' : a))
4241
+ return replaceBrackets(part, (a) => (themeSelectorKeys(a).length ? '' : a))
2137
4242
  .replace(V44_THEME_CLASS, '')
2138
4243
  .trim();
2139
4244
  }
@@ -2267,7 +4372,7 @@ function checkStatusTextContrast(css) {
2267
4372
  const mediaKey = `@media (prefers-color-scheme: ${scheme})`;
2268
4373
  const theme = t === 'root'
2269
4374
  ? (scheme ? ensure(mediaKey, [], mediaKey) : root)
2270
- : ensure(t, t.match(/\[[^\]]*\]|\.[\w-]+/g) ?? [], null);
4375
+ : ensure(t, attrAndClassParts(t) ?? [], null);
2271
4376
  if (t === 'root' && scheme)
2272
4377
  theme.dark = scheme === 'dark';
2273
4378
  for (const d of decls) {
@@ -2799,7 +4904,7 @@ function compareSpecificity(a, b) {
2799
4904
  }
2800
4905
  /** One time value in ms, null when it is not a literal time. */
2801
4906
  function cssTimeMs(token) {
2802
- const m = /^(-?(?:\d+\.?\d*|\.\d+))(ms|s)$/i.exec(token.trim());
4907
+ const m = /^(-?(?:\d+(?:\.\d*)?|\.\d+))(ms|s)$/i.exec(token.trim());
2803
4908
  return m ? parseFloat(m[1]) * (m[2].toLowerCase() === 's' ? 1000 : 1) : null;
2804
4909
  }
2805
4910
  /** :is() and :where() in a selector, expanded into their alternatives (bounded). */
@@ -2861,7 +4966,6 @@ const V45_USER_ACTION = /^:(?:hover|focus|focus-within|focus-visible|active|targ
2861
4966
  // ([data-motion=paused], [data-reduced-motion]), or a class that is one
2862
4967
  // (.reduce-motion, .no-motion, .motion-paused, .animations-off). Component
2863
4968
  // classes that only end in "paused" (a marquee's .logobar--paused) are not.
2864
- const V45_TOGGLE_ATTR = /^\[[\w-]*(?:motion|anim)[\w-]*(?:[~|^$*]?=[^\]]*)?\]$/i;
2865
4969
  const V45_TOGGLE_CLASS = /^\.(?:is-|has-|prefers-)?(?:reduced?-motion|no-motion|motion-(?:off|paused|reduced?|none)|paused-motion|(?:no-)?animations?(?:-(?:off|paused|disabled|none))?|(?:pause|stop)-animations?|still)$/i;
2866
4970
  /**
2867
4971
  * Whether a compound before `*` reaches the whole document: html, :root or
@@ -2879,7 +4983,7 @@ function isDocumentScope(compound) {
2879
4983
  if (type)
2880
4984
  return /^(?:html|body|:root)$/i.test(type);
2881
4985
  return simple.every((s) => s.startsWith('.') || s.startsWith('['))
2882
- && simple.some((s) => V45_TOGGLE_ATTR.test(s) || V45_TOGGLE_CLASS.test(s));
4986
+ && simple.some((s) => isMotionToggleAttr(s) || V45_TOGGLE_CLASS.test(s));
2883
4987
  }
2884
4988
  // v46 — Type sizes come from the scale (engine 1.4.0).
2885
4989
  //
@@ -2905,7 +5009,7 @@ const V46_ITEM = 'Type sizes come from the scale';
2905
5009
  const V46_TOLERANCE = 0.025;
2906
5010
  const V46_FLUID = /(?:^|[^a-z-])(?:[sld]?v[wh]|v[ib]|vmin|vmax|cq[iwhb]|cqmin|cqmax)\b/i;
2907
5011
  function v46StaticPx(value) {
2908
- const m = value.trim().match(/^(\d*\.?\d+)(rem|px)$/i);
5012
+ const m = value.trim().match(/^(\d+(?:\.\d+)?|\.\d+)(rem|px)$/i);
2909
5013
  if (!m)
2910
5014
  return null;
2911
5015
  return parseFloat(m[1]) * (m[2].toLowerCase() === 'rem' ? 16 : 1);
@@ -2952,7 +5056,7 @@ function v46Selector(css, at) {
2952
5056
  if (open < 0)
2953
5057
  return '';
2954
5058
  const start = Math.max(css.lastIndexOf('}', open), css.lastIndexOf(';', open), css.lastIndexOf('{', open - 1)) + 1; // stop at an enclosing @media brace too
2955
- return css.slice(start, open).replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/\s+/g, ' ').trim().slice(0, 80);
5059
+ return withoutCssComments(css.slice(start, open), ' ').replace(/\s+/g, ' ').trim().slice(0, 80);
2956
5060
  }
2957
5061
  function checkTypeScale(css, tokens) {
2958
5062
  const scale = v46TypeScale(tokens);
@@ -2977,7 +5081,7 @@ function checkTypeScale(css, tokens) {
2977
5081
  const re = /font-size\s*:\s*([^;}]+)/gi;
2978
5082
  let m;
2979
5083
  while ((m = re.exec(css)) !== null) {
2980
- const written = m[1].replace(/\/\*[\s\S]*?\*\//g, '').replace(/!important/i, '').trim();
5084
+ const written = withoutCssComments(m[1], '').replace(/!important/i, '').trim();
2981
5085
  const value = resolveVar(written, tokens);
2982
5086
  if (/^var\(/i.test(value))
2983
5087
  continue; // an alias the token table cannot resolve
@@ -3082,7 +5186,7 @@ function checkPausedEntrances(css) {
3082
5186
  visibility = d.value.trim().toLowerCase();
3083
5187
  }
3084
5188
  }
3085
- return (opacity !== null && /\d/.test(opacity) && /^(?:0*\.?0*|0*(?:\.0+)?%)$/.test(opacity)) || visibility === 'hidden';
5189
+ return (opacity !== null && /\d/.test(opacity) && isZeroOpacity(opacity)) || visibility === 'hidden';
3086
5190
  };
3087
5191
  if (frameHides(/^(?:from|0*(?:\.0+)?%)$/) && !frameHides(/^(?:to|100(?:\.0+)?%)$/))
3088
5192
  hiddenNames.add(name);
@@ -3302,55 +5406,45 @@ function checkAiDisclosure(html) {
3302
5406
  const CATEGORY = 'identity';
3303
5407
  // Strip scripts/styles for visible-text checks but keep them for script-src
3304
5408
  // and JSON-LD detection. We use both raw html and a visible-only variant.
3305
- const visibleHtml = html
3306
- .replace(/<script[\s\S]*?<\/script>/gi, '')
3307
- .replace(/<style[\s\S]*?<\/style>/gi, '');
5409
+ const visibleHtml = withoutScriptsAndStyles(html);
3308
5410
  // Text-only: strip all tags so attribute values like class="chatbot" don't
3309
5411
  // false-positive as disclosure TEXT. Disclosure text must be human-readable
3310
5412
  // content between tags, not class/id/aria attribute values.
3311
- const textOnly = visibleHtml.replace(/<[^>]+>/g, ' ');
5413
+ const textOnly = withoutTags(visibleHtml, ' ', true);
3312
5414
  // ── AI chat surfaces, positively identified ─────────────────────────────
3313
- const aiContainerRe = /<(?:iframe|div|section|aside)\b[^>]*(?:class|id)\s*=\s*["'][^"']*\b(?:chatbot|chat-bot|ai-assistant|ai_chat)\b/i;
3314
- const aiScriptRe = /<script\b[^>]*src\s*=\s*["'][^"']*\b(?:chatbot|ai-widget|dialogflow)\b/i;
3315
- const aiInputRe = /<(?:input|textarea|form)\b[^>]*(?:placeholder|aria-label)\s*=\s*["'][^"']*\b(?:ask\s+ai|message\s+ai|chat\s+with\s+ai|ai\s+assistant)\b/i;
3316
5415
  // ── Possible AI surfaces: a review item, never a FAIL ───────────────────
3317
- const chatContainerRe = /<(?:iframe|div|section|aside)\b[^>]*(?:class|id)\s*=\s*["'][^"']*\b(?:assistant-widget|intercom|drift|zendesk-chat|tawk(?:\.to)?|crisp\.chat|livechat|chat-widget|chat-container)\b/i;
3318
- const chatScriptRe = /<script\b[^>]*src\s*=\s*["'][^"']*\b(?:assistant|claude|gemini|rasa|intercom|drift|tawk|crisp|livechat|conversational)\b/i;
3319
5416
  const aiTextRe = /\b(?:chat\s*with\s*(?:ai|our\s*bot|ai\s*assistant)|ask\s+ai|ai\s+assistant|talk\s+to\s+(?:our\s+)?bot|powered\s+by\s+ai|ai-generated|ai\s+chatbot|virtual\s+assistant)\b/i;
3320
- const ambiguousRe = /<(?:iframe|div|section)\b[^>]*(?:class|id)\s*=\s*["'][^"']*\b(?:contact-widget|smart-search|virtual-agent|message-us|help-widget)\b/i;
3321
5417
  const surfaceSignals = [];
3322
- if (aiContainerRe.test(html))
5418
+ if (hasAiChatContainer(html))
3323
5419
  surfaceSignals.push('chatbot container');
3324
- if (aiScriptRe.test(html))
5420
+ if (hasAiWidgetScript(html))
3325
5421
  surfaceSignals.push('AI widget script');
3326
- if (aiInputRe.test(html))
5422
+ if (hasAiInput(html))
3327
5423
  surfaceSignals.push('AI input');
3328
5424
  const possibleSignals = [];
3329
- if (chatContainerRe.test(html))
5425
+ if (hasChatWidgetContainer(html))
3330
5426
  possibleSignals.push('chat widget container');
3331
- if (chatScriptRe.test(html))
5427
+ if (hasChatScript(html))
3332
5428
  possibleSignals.push('chat or assistant script');
3333
5429
  if (aiTextRe.test(textOnly))
3334
5430
  possibleSignals.push('AI prompt text');
3335
- if (ambiguousRe.test(html))
5431
+ if (hasAmbiguousWidget(html))
3336
5432
  possibleSignals.push('ambiguous widget');
3337
5433
  // ── Disclosure detection ────────────────────────────────────────────────
3338
5434
  const disclosureTextRe = /\b(?:ai\s+assistant|ai\s+chatbot|ai-powered|powered\s+by\s+ai|automated\s+assistant|virtual\s+assistant|chatbot)\b/i;
3339
5435
  const ariaDisclosureRe = /aria-(?:label|description)\s*=\s*["'][^"']*\b(?:ai|assistant|bot|automated|chatbot)\b/i;
3340
- const generatorMetaRe = /<meta\s+name\s*=\s*["']generator["'][^>]*content\s*=\s*["'][^"']*\b(?:ai|gpt|claude|gemini|llm|generated|artificial\s+intelligence)\b/i;
3341
5436
  const c2paRe = /(?:<link\s+rel\s*=\s*["']c2pa-manifest["']|<meta\s+name\s*=\s*["']c2pa["'])/i;
3342
- const jsonldAiRe = /<script\s+type\s*=\s*["']application\/ld\+json["'][\s\S]*?"@type"\s*:\s*"[^"]*\b(?:ai|softwareapplication)\b[^"]*"[\s\S]*?(?:"applicationCategory"\s*:\s*"[^"]*ai[^"]*"|"[^"]*ai[^"]*")/i;
3343
5437
  const dataAiRe = /data-ai-disclosure\s*=/i;
3344
5438
  const disclosureSignals = [];
3345
5439
  if (disclosureTextRe.test(textOnly))
3346
5440
  disclosureSignals.push('visible AI text');
3347
5441
  if (ariaDisclosureRe.test(html))
3348
5442
  disclosureSignals.push('aria-label');
3349
- if (generatorMetaRe.test(html))
5443
+ if (hasAiGeneratorMeta(html))
3350
5444
  disclosureSignals.push('generator meta');
3351
5445
  if (c2paRe.test(html))
3352
5446
  disclosureSignals.push('C2PA manifest');
3353
- if (jsonldAiRe.test(html))
5447
+ if (hasAiJsonLd(html))
3354
5448
  disclosureSignals.push('JSON-LD');
3355
5449
  if (dataAiRe.test(html))
3356
5450
  disclosureSignals.push('data-ai-disclosure');
@@ -3377,11 +5471,11 @@ function checkForcedColors(css) {
3377
5471
  const ITEM = 'Forced-colors readiness: @media (forced-colors: active) block present';
3378
5472
  const CATEGORY = 'accessibility';
3379
5473
  // Primary signal: @media (forced-colors: active) block exists
3380
- const hasForcedColorsMedia = /@media[^{]*forced-colors\s*:\s*active/i.test(css);
5474
+ const hasForcedColorsMedia = mediaQueryHasForcedColors(css);
3381
5475
  // Secondary signal: forced-color-adjust property used anywhere
3382
5476
  const hasForcedColorAdjust = /forced-color-adjust\s*:/i.test(css);
3383
5477
  // Tertiary signal: -ms-high-contrast (legacy Edge/IE) — still relevant
3384
- const hasHighContrast = /@media[^{]*-ms-high-contrast/i.test(css);
5478
+ const hasHighContrast = mediaQueryHasHighContrast(css);
3385
5479
  if (hasForcedColorsMedia && hasForcedColorAdjust) {
3386
5480
  return { id: 'v35', item: ITEM, category: CATEGORY, status: 'PASS', detail: 'forced-colors media query + forced-color-adjust both present' };
3387
5481
  }
@@ -3534,11 +5628,10 @@ function checkSecurityConfusables(css, tokens) {
3534
5628
  }
3535
5629
  // Scan 3: url() references — confusables in file paths can redirect to
3536
5630
  // different assets (logo spoofing, CSS injection via @import)
3537
- const urlRe = /url\(\s*['"]?([^'")]+)['"]?\s*\)/gi;
3538
5631
  const urlConfusables = [];
3539
5632
  const scannedUrls = new Set();
3540
- while ((m = urlRe.exec(css)) !== null) {
3541
- const urlPath = m[1];
5633
+ for (const u of cssUrlMatches(css)) {
5634
+ const urlPath = u.groups[0];
3542
5635
  if (scannedUrls.has(urlPath))
3543
5636
  continue;
3544
5637
  scannedUrls.add(urlPath);
@@ -3661,8 +5754,6 @@ function checkButtonTextVerb(html) {
3661
5754
  const ITEM = 'Button text is a verb phrase or recognized command (not a bare noun)';
3662
5755
  const CATEGORY = 'copywriting';
3663
5756
  // Extract <button> and [role="button"] text content
3664
- const buttonRe = /<button[^>]*>([\s\S]*?)<\/button>/gi;
3665
- const roleButtonRe = /<(?:a|div|span)[^>]*role=["']button["'][^>]*>([\s\S]*?)<\/(?:a|div|span)>/gi;
3666
5757
  // Remove aria-hidden subtrees before extracting text.
3667
5758
  //
3668
5759
  // Content marked aria-hidden is by definition not part of the accessible name,
@@ -3679,7 +5770,7 @@ function checkButtonTextVerb(html) {
3679
5770
  let out = inner;
3680
5771
  for (let i = 0; i < 4; i++) {
3681
5772
  // Repeat to handle an aria-hidden wrapper containing another.
3682
- const next = out.replace(/<([a-z][a-z0-9-]*)\b[^>]*\baria-hidden\s*=\s*["']?true["']?[^>]*>[\s\S]*?<\/\1>/gi, '');
5773
+ const next = withoutAriaHiddenElements(out);
3683
5774
  if (next === out)
3684
5775
  break;
3685
5776
  out = next;
@@ -3701,12 +5792,10 @@ function checkButtonTextVerb(html) {
3701
5792
  // Carry the opening tag alongside the label so the violation loop can tell a
3702
5793
  // command button from a selection control (see isSelectionControl).
3703
5794
  const buttonTexts = [];
3704
- let m;
3705
5795
  // Strip keyboard shortcut hints that are fused to or appended after the
3706
5796
  // button label — e.g. "Find⌘K", "Search ⌘+K", "Save Ctrl+S". These are
3707
5797
  // visual hints, not part of the verb. Ranges: ⌘ (U+2318), ⌃ (U+2303),
3708
5798
  // ⌥ (U+2325), ⇧ (U+21E7), and common "Ctrl+", "Cmd+", "Shift+" prefixes.
3709
- const SHORTCUT_RE = /[\s]*[\u2303\u2318\u2325\u21E7\u21E7\u2387].*$/i;
3710
5799
  // The `+` is optional and the key may be fused directly to the modifier
3711
5800
  // ("CtrlK"), because the badge is now platform-aware and renders "Ctrl" with
3712
5801
  // no glyph to strip. Requiring a literal '+' meant "FindCtrlK" survived and
@@ -3715,14 +5804,13 @@ function checkButtonTextVerb(html) {
3715
5804
  // The key must be ADJACENT to the modifier (plus form, or fused alphanumerics
3716
5805
  // with no space between). A space separates a real word: "Alt text" is a
3717
5806
  // label, not a shortcut, and must not be eaten.
3718
- const TEXT_SHORTCUT_RE = /[\s]*(?:Ctrl|Cmd|Shift|Alt|Option|Command)(?:\+[A-Za-z0-9+\-]*|[A-Z0-9]+)?$/;
3719
5807
  // Engine 1.3.1: a "+key" hint written in lower case ("Save ctrl+s",
3720
5808
  // "Find ctrl+k") is a hint too. The modifier is case-insensitive here but
3721
5809
  // must stand as its own word (the start of the label or after a space), so
3722
5810
  // "Halt+1" is untouched, and only the plus form is read: a modifier followed
3723
5811
  // by a space and a letter ("Shift A") is a label, as it was in 1.3.0.
3724
5812
  const PLUS_SHORTCUT_RE = /(?:^|\s)(?:ctrl|cmd|shift|alt|option|command)(?:\s*\+\s*[a-z0-9]+)+$/i;
3725
- const stripShortcut = (s) => s.replace(SHORTCUT_RE, '').replace(TEXT_SHORTCUT_RE, '').replace(PLUS_SHORTCUT_RE, '').trim();
5813
+ const stripShortcut = (s) => withoutTextShortcut(withoutGlyphShortcut(s)).replace(PLUS_SHORTCUT_RE, '').trim();
3726
5814
  // Engine 1.3.1: keycaps are read from the markup, not guessed from text.
3727
5815
  // A <kbd> element is keyboard input (HTML Living Standard, the kbd element),
3728
5816
  // a hint beside the label rather than the label, so its text is dropped
@@ -3737,23 +5825,23 @@ function checkButtonTextVerb(html) {
3737
5825
  const visible = stripAriaHidden(inner);
3738
5826
  let out = visible;
3739
5827
  for (let i = 0; i < 4; i++) {
3740
- const next = out.replace(/<kbd\b[^>]*>(?:(?!<kbd\b)[\s\S])*?<\/kbd>/gi, ' ');
5828
+ const next = withoutKbdElements(out);
3741
5829
  if (next === out)
3742
5830
  break;
3743
5831
  out = next;
3744
5832
  }
3745
- const text = out.replace(/<[^>]*>/g, '').trim();
3746
- return out === visible ? text : text.replace(/(?:\s*\+)+$/, '').trim();
5833
+ const text = withoutTags(out, '', false).trim();
5834
+ return out === visible ? text : withoutTrailingPluses(text).trim();
3747
5835
  };
3748
- while ((m = buttonRe.exec(html)) !== null) {
3749
- let text = labelText(m[1]);
5836
+ for (const m of buttonElements(html)) {
5837
+ let text = labelText(m.groups[0]);
3750
5838
  // Strip leading icon characters so "✕Close" → "Close"
3751
5839
  text = text.replace(ICON_PREFIX_RE, '').trim();
3752
5840
  // Strip trailing keyboard shortcut hints so "Find⌘K" → "Find"
3753
5841
  text = stripShortcut(text);
3754
5842
  // Icon-only button with an accessible name: use the aria-label verb.
3755
5843
  if (!text) {
3756
- const aria = /aria-label=["']([^"']+)["']/i.exec(m[0]);
5844
+ const aria = /aria-label=["']([^"']+)["']/i.exec(m.text);
3757
5845
  if (aria)
3758
5846
  text = aria[1].trim();
3759
5847
  }
@@ -3763,19 +5851,19 @@ function checkButtonTextVerb(html) {
3763
5851
  // Without this, v38 reported six single-letter "buttons" as non-verb labels
3764
5852
  // on /maturity — an accurate observation of the wrong string.
3765
5853
  if (text && text.length <= 2) {
3766
- const aria = /aria-label=["']([^"']+)["']/i.exec(m[0]);
5854
+ const aria = /aria-label=["']([^"']+)["']/i.exec(m.text);
3767
5855
  if (aria)
3768
5856
  text = aria[1].trim();
3769
5857
  }
3770
5858
  if (text)
3771
- buttonTexts.push({ text, tag: m[0] });
5859
+ buttonTexts.push({ text, tag: m.text });
3772
5860
  }
3773
- while ((m = roleButtonRe.exec(html)) !== null) {
3774
- let text = labelText(m[1]);
5861
+ for (const m of roleButtonElements(html)) {
5862
+ let text = labelText(m.groups[0]);
3775
5863
  text = text.replace(ICON_PREFIX_RE, '').trim();
3776
5864
  text = stripShortcut(text);
3777
5865
  if (!text) {
3778
- const aria = /aria-label=["']([^"']+)["']/i.exec(m[0]);
5866
+ const aria = /aria-label=["']([^"']+)["']/i.exec(m.text);
3779
5867
  if (aria)
3780
5868
  text = aria[1].trim();
3781
5869
  }
@@ -3785,12 +5873,12 @@ function checkButtonTextVerb(html) {
3785
5873
  // Without this, v38 reported six single-letter "buttons" as non-verb labels
3786
5874
  // on /maturity — an accurate observation of the wrong string.
3787
5875
  if (text && text.length <= 2) {
3788
- const aria = /aria-label=["']([^"']+)["']/i.exec(m[0]);
5876
+ const aria = /aria-label=["']([^"']+)["']/i.exec(m.text);
3789
5877
  if (aria)
3790
5878
  text = aria[1].trim();
3791
5879
  }
3792
5880
  if (text)
3793
- buttonTexts.push({ text, tag: m[0] });
5881
+ buttonTexts.push({ text, tag: m.text });
3794
5882
  }
3795
5883
  if (buttonTexts.length === 0) {
3796
5884
  return { id: 'v38', item: ITEM, category: CATEGORY, status: 'SKIP', detail: 'no button elements found in HTML' };
@@ -3860,23 +5948,19 @@ function checkButtonTextVerb(html) {
3860
5948
  function checkNoTrailingPeriod(html) {
3861
5949
  const ITEM = 'No trailing period on button text, labels, or tab text';
3862
5950
  const CATEGORY = 'copywriting';
3863
- const buttonRe = /<button[^>]*>([\s\S]*?)<\/button>/gi;
3864
- const labelRe = /<label[^>]*>([\s\S]*?)<\/label>/gi;
3865
- const tabRe = /<[a-z]+[^>]*role=["']tab["'][^>]*>([\s\S]*?)<\/[a-z]+>/gi;
3866
5951
  const texts = [];
3867
- let m;
3868
- while ((m = buttonRe.exec(html)) !== null) {
3869
- const text = m[1].replace(/<[^>]*>/g, '').trim();
5952
+ for (const m of buttonElements(html)) {
5953
+ const text = withoutTags(m.groups[0], '', false).trim();
3870
5954
  if (text)
3871
5955
  texts.push({ type: 'button', text });
3872
5956
  }
3873
- while ((m = labelRe.exec(html)) !== null) {
3874
- const text = m[1].replace(/<[^>]*>/g, '').trim();
5957
+ for (const m of labelElements(html)) {
5958
+ const text = withoutTags(m.groups[0], '', false).trim();
3875
5959
  if (text)
3876
5960
  texts.push({ type: 'label', text });
3877
5961
  }
3878
- while ((m = tabRe.exec(html)) !== null) {
3879
- const text = m[1].replace(/<[^>]*>/g, '').trim();
5962
+ for (const m of roleTabElements(html)) {
5963
+ const text = withoutTags(m.groups[0], '', false).trim();
3880
5964
  if (text)
3881
5965
  texts.push({ type: 'tab', text });
3882
5966
  }
@@ -3900,11 +5984,9 @@ const NON_DESCRIPTIVE_LINK_TEXT = /^(click here|here|learn more|read more|more|l
3900
5984
  function checkLinkTextDescriptive(html) {
3901
5985
  const ITEM = 'Link text is descriptive (not bare "click here", "learn more", "here")';
3902
5986
  const CATEGORY = 'copywriting';
3903
- const linkRe = /<a[^>]*>([\s\S]*?)<\/a>/gi;
3904
5987
  const linkTexts = [];
3905
- let m;
3906
- while ((m = linkRe.exec(html)) !== null) {
3907
- const text = m[1].replace(/<[^>]*>/g, '').trim();
5988
+ for (const m of anchorElements(html)) {
5989
+ const text = withoutTags(m.groups[0], '', false).trim();
3908
5990
  if (text)
3909
5991
  linkTexts.push(text);
3910
5992
  }
@@ -3934,20 +6016,18 @@ function checkNoAllCaps(html) {
3934
6016
  const ITEM = 'No ALL CAPS UI text except eyebrow labels';
3935
6017
  const CATEGORY = 'copywriting';
3936
6018
  // Match elements with their class attributes so we can exclude eyebrow labels
3937
- const elementRe = /<(button|a|label|td|th|p|li|h[1-6])\s([^>]*?)>([\s\S]*?)<\/\1>/gi;
3938
6019
  const violations = [];
3939
- let m;
3940
- while ((m = elementRe.exec(html)) !== null) {
3941
- const attrs = m[2] || '';
3942
- const text = m[3].replace(/<[^>]*>/g, '').trim();
6020
+ for (const m of capsElementMatches(html)) {
6021
+ const attrs = m.groups[1] || '';
6022
+ const text = withoutTags(m.groups[2], '', false).trim();
3943
6023
  if (!text || text.length < 4)
3944
6024
  continue;
3945
6025
  // Skip eyebrow labels (class contains "eyebrow" or "label" — per typography
3946
6026
  // contract, eyebrows are intentionally uppercase)
3947
- if (/class=["'][^"']*(eyebrow|eyebro|meta-label)[^"']*["']/i.test(attrs))
6027
+ if (hasEyebrowClass(attrs))
3948
6028
  continue;
3949
6029
  // Skip elements with text-transform: uppercase in inline style
3950
- if (/style=["'][^"']*text-transform:\s*uppercase[^"']*["']/i.test(attrs))
6030
+ if (hasUppercaseStyle(attrs))
3951
6031
  continue;
3952
6032
  // Check if text is ALL CAPS (only letters, all uppercase, >3 chars)
3953
6033
  const letters = text.replace(/[^a-zA-Z]/g, '');
@@ -3979,19 +6059,19 @@ function checkPoiseInteractionRules(css) {
3979
6059
  const rules = [
3980
6060
  {
3981
6061
  name: 'fine-pointer hover guard',
3982
- re: /@media[^{]*(?:hover\s*:\s*hover|pointer\s*:\s*fine)/i,
6062
+ test: (s) => mediaQueryHasHoverGuard(s),
3983
6063
  },
3984
6064
  {
3985
6065
  name: 'press settle scale ~0.97',
3986
- re: /scale\s*\(\s*0?\.9[5-9]\s*\)/i,
6066
+ test: (s) => /scale\s*\(\s*0?\.9[5-9]\s*\)/i.test(s),
3987
6067
  },
3988
6068
  {
3989
6069
  name: 'opacity-only mark breath',
3990
- re: /@keyframes\s+[^{]*breath[^{]*\{[^}]*opacity\s*:/i,
6070
+ test: (s) => hasBreathKeyframes(s),
3991
6071
  },
3992
6072
  ];
3993
- const found = rules.filter(r => r.re.test(css)).map(r => r.name);
3994
- const missing = rules.filter(r => !r.re.test(css)).map(r => r.name);
6073
+ const found = rules.filter(r => r.test(css)).map(r => r.name);
6074
+ const missing = rules.filter(r => !r.test(css)).map(r => r.name);
3995
6075
  if (found.length >= 2) {
3996
6076
  return {
3997
6077
  id: 'v08',
@@ -4020,8 +6100,8 @@ function checkPoiseKeyboardPath(css, html) {
4020
6100
  const hasFocusVisible = /:focus-visible/i.test(css);
4021
6101
  const hasFocus = /:focus[^-]/i.test(css);
4022
6102
  // Detect focus styles that strip outline without a replacement ring/box-shadow
4023
- const stripsOutline = /:focus[^{]*\{[^}]*outline\s*:\s*(none|0)\s*[;}]/i.test(css);
4024
- const hasFocusRing = /:focus[^{]*\{[^}]*(box-shadow|outline\s*:\s*[^n0])/i.test(css);
6103
+ const stripsOutline = focusStripsOutline(css);
6104
+ const hasFocusRing = focusHasRing(css);
4025
6105
  const hasTabindex = /tabindex\s*=/i.test(html);
4026
6106
  const hasAria = /aria-(label|labelledby|describedby|expanded|selected|pressed)/i.test(html);
4027
6107
  const signals = [hasFocusVisible, hasFocus, hasFocusRing, hasTabindex, hasAria].filter(Boolean).length;
@@ -4063,19 +6143,19 @@ function checkTaktFeelRules(css) {
4063
6143
  const rules = [
4064
6144
  {
4065
6145
  name: 'stagger enter animation-delay',
4066
- re: /animation-delay\s*:\s*(?:0?\.(?:0?[6-9]|1[0-2])\d*s|\d{2,3}ms)/i,
6146
+ test: (s) => /animation-delay\s*:\s*(?:0?\.(?:0?[6-9]|1[0-2])\d*s|\d{2,3}ms)/i.test(s),
4067
6147
  },
4068
6148
  {
4069
6149
  name: 'soften exit transform ease-out',
4070
- re: /transition\s*:[^;]*transform[^;]*(ease-out|cubic-bezier\([^)]*0[, ])/i,
6150
+ test: (s) => hasTransformEaseTransition(s),
4071
6151
  },
4072
6152
  {
4073
6153
  name: 'concentric border-radius set',
4074
- re: /border-radius\s*:\s*\d+/i,
6154
+ test: (s) => /border-radius\s*:\s*\d+/i.test(s),
4075
6155
  },
4076
6156
  ];
4077
- const found = rules.filter(r => r.re.test(css)).map(r => r.name);
4078
- const missing = rules.filter(r => !r.re.test(css)).map(r => r.name);
6157
+ const found = rules.filter(r => r.test(css)).map(r => r.name);
6158
+ const missing = rules.filter(r => !r.test(css)).map(r => r.name);
4079
6159
  if (found.length >= 2) {
4080
6160
  return {
4081
6161
  id: 'v10',
@@ -4172,13 +6252,50 @@ async function checkDesignMdSpec(targetUrl) {
4172
6252
  // (an account name often does); the last one may not, so the sentence after
4173
6253
  // a path survives, and trailing punctuation is kept.
4174
6254
  function sanitizeErrorText(text) {
4175
- const keep = (label) => (m) => `${label}${/[.,;:!?)]+$/.exec(m)?.[0] ?? ''}`;
4176
- return text
6255
+ // The punctuation a replaced path ended with, read back from its end
6256
+ // (engine 1.5.1: /[.,;:!?)]+$/ was retried from every mark in a run).
6257
+ const tail = (m) => {
6258
+ let k = m.length;
6259
+ while (k > 0 && '.,;:!?)'.indexOf(m[k - 1]) >= 0)
6260
+ k--;
6261
+ return m.slice(k);
6262
+ };
6263
+ const keep = (label) => (m) => `${label}${tail(m)}`;
6264
+ const pathless = text
4177
6265
  .replace(/\bfile:\/\/[^\s'"<>`|]*/gi, keep('a local file'))
4178
6266
  .replace(/\\\\[^\\\s'"<>`|]+\\(?:[^\\\r\n'"<>`|*?]+\\)*[^\\\s'"<>`|*?]*/g, keep('a local path'))
4179
6267
  .replace(/(?<![\w\\/])[A-Za-z]:([\\/])(?:[^\\/\r\n'"<>`|*?]+\1)*[^\\/\s'"<>`|*?]*/g, keep('a local path'))
4180
- .replace(/(^|[\s'"`(=,:[])\/(?:home|Users|root|tmp|var|private|opt|usr|srv|mnt|Volumes|Library|app|vercel|workspace|workspaces|github|runner|nix|snap)(?:\/[^/\r\n'"<>`|]+(?=\/))*\/[^/\s'"<>`|]+/g, (m, lead) => `${lead}${keep('a local path')(m.slice(lead.length))}`)
4181
- .replace(/[^\s'"<>`|]*(?:npm-cache|[\\/]_npx[\\/]|[\\/]\.npm[\\/])[^\s'"<>`|]*/gi, keep('the npm cache'));
6268
+ .replace(/(^|[\s'"`(=,:[])\/(?:home|Users|root|tmp|var|private|opt|usr|srv|mnt|Volumes|Library|app|vercel|workspace|workspaces|github|runner|nix|snap)(?:\/[^/\r\n'"<>`|]+(?=\/))*\/[^/\s'"<>`|]+/g, (m, lead) => `${lead}${keep('a local path')(m.slice(lead.length))}`);
6269
+ // npm cache segments: a run of characters other than space, quotes, angle
6270
+ // brackets, backtick and pipe that holds npm-cache, /_npx/ or /.npm/ is
6271
+ // replaced whole. Each run is read once (engine 1.5.1: the run pattern was
6272
+ // retried from every character in it).
6273
+ const marker = /npm-cache|[\\/]_npx[\\/]|[\\/]\.npm[\\/]/gi;
6274
+ const stop = /[\s'"<>`|]/;
6275
+ const parts = [];
6276
+ let last = 0;
6277
+ let markerAt = -1;
6278
+ for (let a = 0; a < pathless.length;) {
6279
+ if (stop.test(pathless[a])) {
6280
+ a++;
6281
+ continue;
6282
+ }
6283
+ let b = a + 1;
6284
+ while (b < pathless.length && !stop.test(pathless[b]))
6285
+ b++;
6286
+ if (markerAt < a) {
6287
+ marker.lastIndex = a;
6288
+ const found = marker.exec(pathless);
6289
+ markerAt = found ? found.index : Infinity;
6290
+ }
6291
+ if (markerAt < b) {
6292
+ parts.push(pathless.slice(last, a), keep('the npm cache')(pathless.slice(a, b)));
6293
+ last = b;
6294
+ }
6295
+ a = b;
6296
+ }
6297
+ parts.push(pathless.slice(last));
6298
+ return parts.join('');
4182
6299
  }
4183
6300
  /** The checks with every detail passed through sanitizeErrorText. */
4184
6301
  function sanitizeCheckDetails(checks) {
@@ -4318,7 +6435,7 @@ function scoreArithmetic(checks, slopTotal, originalityPoints) {
4318
6435
  // contrast or horizontal overflow cannot be A-grade no matter how good its
4319
6436
  // tokens are. Caps are applied AFTER the a11y floor (floor wins over ceilings).
4320
6437
  //
4321
- // No A while any check FAILs (engine 1.3.0, owner decision 2026-10-11). A
6438
+ // No A while any check FAILs (engine 1.3.0, see CHANGELOG). A
4322
6439
  // FAIL is the engine's own finding that the page breaks a rule, so any FAIL
4323
6440
  // caps the score at 89.9, a B at most. Until 1.3.0 a page could score 100, an
4324
6441
  // A, beside FAIL verdicts, including the engine's own WCAG contrast FAIL
@@ -4346,7 +6463,7 @@ function scoreArithmetic(checks, slopTotal, originalityPoints) {
4346
6463
  }
4347
6464
  // v44 Status text contrast — the words that report a state fail WCAG
4348
6465
  // 1.4.3 (or 1.4.11 for a graphic) on a surface the stylesheet attests.
4349
- // Same cap as v06 (engine 1.3.0, owner decision 2026-10-11): both are
6466
+ // Same cap as v06 (engine 1.3.0): both are
4350
6467
  // measured WCAG contrast failures on text.
4351
6468
  if (c.id === 'v44') {
4352
6469
  cap = 65;
@@ -4596,40 +6713,13 @@ export async function scoreFromParts(input) {
4596
6713
  // reverse, `linear-gradient(color-mix(...) 1px, transparent 1px)`, and its
4597
6714
  // graph-paper grid was reported at the maximum severity. Match a 1px stop
4598
6715
  // in EITHER order, which is what "hairline" means however it is written.
4599
- const isGridPattern = (text) => /(?:transparent|rgba\([^)]+\)|color-mix\([^)]*\)|var\([^)]*\)|#[0-9a-fA-F]{3,8})\s+1px(?:\s*,)/.test(text)
4600
- || /\s+1px\s*,\s*(?:transparent|rgba\([^)]+\)|color-mix\([^)]*\)|var\([^)]*\))/.test(text);
4601
- // Two or more colour stops in some linear-gradient() of the value.
4602
- const multiStop = (value) => {
4603
- let from = 0;
4604
- for (;;) {
4605
- const at = value.toLowerCase().indexOf('linear-gradient(', from);
4606
- if (at < 0)
4607
- return false;
4608
- let depth = 0;
4609
- let end = -1;
4610
- for (let i = at + 15; i < value.length; i++) {
4611
- if (value[i] === '(')
4612
- depth++;
4613
- else if (value[i] === ')' && --depth === 0) {
4614
- end = i;
4615
- break;
4616
- }
4617
- }
4618
- if (end < 0)
4619
- return false;
4620
- const args = splitCssTopLevel(value.slice(at + 16, end), ',');
4621
- const stops = /^(?:to\s|[-+]?[\d.]+(?:deg|turn|rad|grad)\b)/i.test(args[0] ?? '') ? args.slice(1) : args;
4622
- if (stops.length >= 2)
4623
- return true;
4624
- from = end;
4625
- }
4626
- };
6716
+ const isGridPattern = (text) => hasColorThenHairline(text) || hasHairlineThenColor(text);
4627
6717
  const pageGrad = [];
4628
6718
  for (const rule of parseCssRules(css).rules) {
4629
6719
  if (!/linear-gradient\s*\(/i.test(rule.decls))
4630
6720
  continue;
4631
6721
  const decls = parseCssDecls(rule.decls);
4632
- if (!decls.some((d) => (d.prop === 'background' || d.prop === 'background-image') && multiStop(d.value)))
6722
+ if (!decls.some((d) => (d.prop === 'background' || d.prop === 'background-image') && hasMultiStopGradient(d.value)))
4633
6723
  continue;
4634
6724
  const text = `${rule.selector}{${rule.decls}}`;
4635
6725
  if (isGridPattern(text))
@@ -4652,7 +6742,7 @@ export async function scoreFromParts(input) {
4652
6742
  const hairline = decls.some((d) => (/^(?:height|width|block-size|inline-size)$/.test(d.prop) && d.value.trim() === '1px')
4653
6743
  || (/^border(?:-top|-bottom)?-width$/.test(d.prop) && d.value.trim() === '1px')
4654
6744
  || (d.prop === 'background-size' && /(?:^|[\s,])1px(?:$|[\s,])/.test(d.value))
4655
- || (d.prop === 'background' && /\/\s*[^,/]*(?:^|\s)1px(?:$|[\s,])/.test(d.value)));
6745
+ || (d.prop === 'background' && hasSlashThenHairline(d.value)));
4656
6746
  const fixedCover = last('position') === 'fixed' && (pinned || viewportSized);
4657
6747
  if ((pageSelector || fixedCover || (viewportSized && !component)) && !hairline)
4658
6748
  pageGrad.push(text.replace(/\s+/g, ' ').slice(0, 160));
@@ -4670,8 +6760,7 @@ export async function scoreFromParts(input) {
4670
6760
  }
4671
6761
  // S3. Purple/violet gradient
4672
6762
  {
4673
- const purplePattern = /(?:background(?:-image)?\s*:[^;]*linear-gradient[^;]*(?:#?(?:615fff|8e51ff|4f39f6|7f22fe|a855f7|9333ea|7c3aed|6d28d9|5b21b6|4c1d95)))/gi;
4674
- const matches = css.match(purplePattern);
6763
+ const matches = purpleGradientDecls(css);
4675
6764
  if (matches)
4676
6765
  slopFindings.push({ id: 'S3', label: 'Purple/violet AI gradient', severity: 4, instances: matches.length, evidence: matches.slice(0, 3) });
4677
6766
  }
@@ -4683,7 +6772,7 @@ export async function scoreFromParts(input) {
4683
6772
  // pattern anchored to `(?:^|})` takes 4 ms and matches the same rule.
4684
6773
  // Measured 2026-09-19, Node 24. This was the real cause of the 55s cold
4685
6774
  // score, not the network (0.9s) and not the sequential CSS fetch.
4686
- const gradientClips = css.match(/(?:^|\})[^{}]*\{[^}]*background-clip\s*:\s*text[^}]*\}/gi) || [];
6775
+ const gradientClips = gradientClipRules(css);
4687
6776
  const hueKey = (stop) => {
4688
6777
  if (/var\(/.test(stop))
4689
6778
  return null;
@@ -4705,7 +6794,7 @@ export async function scoreFromParts(input) {
4705
6794
  h = Math.round(((h * 60) + 360) % 360);
4706
6795
  return 'h' + Math.floor(h / 45);
4707
6796
  }
4708
- const rgb = stop.match(/rgba?\(([^)]+)\)/i);
6797
+ const rgb = rgbArgs(stop);
4709
6798
  if (rgb) {
4710
6799
  const p = rgb[1].split(',').map(x => parseFloat(x));
4711
6800
  if (p.length >= 3) {
@@ -4728,8 +6817,8 @@ export async function scoreFromParts(input) {
4728
6817
  return null;
4729
6818
  };
4730
6819
  const multiColorGradient = gradientClips.filter(block => {
4731
- const gradientMatch = block.match(/linear-gradient\(([\s\S]+)\)\s*;?\s*background-size|linear-gradient\(([^;]+)\)/i);
4732
- const gradText = (gradientMatch && (gradientMatch[1] || gradientMatch[2])) || '';
6820
+ const gradientMatch = linearGradientArgs(block);
6821
+ const gradText = (gradientMatch && (gradientMatch.groups[0] || gradientMatch.groups[1])) || '';
4733
6822
  const stops = gradText.split(',').map(s => s.trim());
4734
6823
  const hueKeys = new Set(stops.map(hueKey).filter((k) => k !== null && k !== 'neutral'));
4735
6824
  return hueKeys.size >= 2;
@@ -4750,8 +6839,7 @@ export async function scoreFromParts(input) {
4750
6839
  {
4751
6840
  const cardPattern = /\.(card|panel|tile|feature|item|box|cell|block)\b/gi;
4752
6841
  const cardClasses = css.match(cardPattern) || [];
4753
- const gridCardPattern = /grid-template-columns[^}]*repeat\s*\(\s*(?:auto-fit|auto-fill|\d+)/gi;
4754
- const gridMatches = css.match(gridCardPattern) || [];
6842
+ const gridMatches = gridRepeatRules(css);
4755
6843
  if (cardClasses.length >= 3 && gridMatches.length > 0)
4756
6844
  slopFindings.push({ id: 'S6', label: 'Repeated identical card grid', severity: 5, instances: 1, evidence: [`${cardClasses.length} card-like classes with repeating grid layouts`] });
4757
6845
  }
@@ -4765,7 +6853,7 @@ export async function scoreFromParts(input) {
4765
6853
  // text-default ones promoted with U+FE0F. This also catches ⭐ (U+2B50), which
4766
6854
  // the old range missed while it was busy flagging checkmarks.
4767
6855
  {
4768
- const emojiInButtons = html.match(/(?:<button|<a[^>]*class[^>]*(?:btn|cta|primary|action))[^>]*>[\s\S]{0,200}(?:[\p{Emoji_Presentation}]|[\p{Emoji}]\uFE0F)/giu);
6856
+ const emojiInButtons = emojiCtaMatches(html);
4769
6857
  if (emojiInButtons && emojiInButtons.length >= 2)
4770
6858
  slopFindings.push({ id: 'S7', label: 'Emoji as UI icons', severity: 4, instances: emojiInButtons.length, evidence: [`${emojiInButtons.length} emoji in button/CTA elements`] });
4771
6859
  }
@@ -4783,11 +6871,7 @@ export async function scoreFromParts(input) {
4783
6871
  // Skipped on a page that documents the rules (see isDocumentingSlopRules):
4784
6872
  // the anti-slop rule table on /methodology lists these phrases.
4785
6873
  {
4786
- const visible = html
4787
- .replace(/<script\b[\s\S]*?<\/script\s*>/gi, ' ')
4788
- .replace(/<style\b[\s\S]*?<\/style\s*>/gi, ' ')
4789
- .replace(/<template\b[\s\S]*?<\/template\s*>/gi, ' ')
4790
- .replace(/<!--[\s\S]*?-->/g, ' ');
6874
+ const visible = withoutNonVisibleMarkup(html);
4791
6875
  const pillPhrase = /\b(?:AI-powered|Generate|Chat with AI|Powered by AI|Built with AI|AI-driven)\b/i;
4792
6876
  const openRe = /<(span|a|button|div|small|strong|b|em|mark|label|p|li)\b([^<>]*)>/gi;
4793
6877
  const tagRe = {};
@@ -4818,7 +6902,7 @@ export async function scoreFromParts(input) {
4818
6902
  }
4819
6903
  if (end < 0)
4820
6904
  continue;
4821
- const text = windowText.slice(0, end).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
6905
+ const text = withoutTags(windowText.slice(0, end), ' ', false).replace(/\s+/g, ' ').trim();
4822
6906
  if (text && text.length <= 40 && pillPhrase.test(text))
4823
6907
  pills.push(text);
4824
6908
  }
@@ -4854,7 +6938,7 @@ export async function scoreFromParts(input) {
4854
6938
  // S11. Marketing buzzwords
4855
6939
  {
4856
6940
  const buzzwords = ['streamline', 'empower', 'supercharge', 'world-class', 'enterprise-grade', 'next-generation', 'unlock', 'leverage', 'seamless', 'cutting-edge', 'revolutionize', 'game-chang', 'disrupt', 'synerg'];
4857
- const bodyText = html.replace(/<[^>]+>/g, ' ').toLowerCase();
6941
+ const bodyText = withoutTags(html, ' ', true).toLowerCase();
4858
6942
  const found = [];
4859
6943
  for (const word of buzzwords) {
4860
6944
  if (bodyText.includes(word))
@@ -4880,21 +6964,20 @@ export async function scoreFromParts(input) {
4880
6964
  const originalitySignals = [];
4881
6965
  // O1. Bespoke easing
4882
6966
  {
4883
- const bezierRe = /cubic-bezier\(\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*\)/gi;
4884
6967
  const EASING_PRESETS = new Set(['0.25,0.1,0.25,1', '0.42,0,1,1', '0,0,0.58,1', '0.42,0,0.58,1', '0.4,0,0.2,1', '0.4,0,0.6,1', '0,0,0.2,1', '0.4,0,1,1', '0.68,-0.55,0.265,1.55']);
4885
6968
  const distinct = new Set();
4886
6969
  let hasOvershoot = false;
4887
- let m;
4888
- while ((m = bezierRe.exec(css)) !== null) {
4889
- const key = `${m[1]},${m[2]},${m[3]},${m[4]}`.replace(/(\.\d*?)0+(?=\D|$)/g, '$1').replace(/\s+/g, '');
6970
+ for (const b of cubicBezierMatches(css)) {
6971
+ const [x1, y1s, x2, y2s] = b.groups;
6972
+ const key = withoutTrailingDecimalZeros(`${x1},${y1s},${x2},${y2s}`).replace(/\s+/g, '');
4890
6973
  if (EASING_PRESETS.has(key))
4891
6974
  continue;
4892
6975
  distinct.add(key);
4893
- const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
6976
+ const y1 = parseFloat(y1s), y2 = parseFloat(y2s);
4894
6977
  if (y1 < 0 || y1 > 1 || y2 < 0 || y2 > 1)
4895
6978
  hasOvershoot = true;
4896
6979
  }
4897
- const linearSprings = css.match(/linear\(\s*[^)]{20,}\)/gi) || [];
6980
+ const linearSprings = longLinearEasings(css);
4898
6981
  if (linearSprings.length > 0)
4899
6982
  hasOvershoot = true;
4900
6983
  const easingCount = distinct.size;
@@ -4924,8 +7007,8 @@ export async function scoreFromParts(input) {
4924
7007
  }
4925
7008
  // O4. Tiered reduced-motion
4926
7009
  {
4927
- const reducedMotion = /@media[^{]*prefers-reduced-motion/i.test(css);
4928
- const blanketKill = /prefers-reduced-motion[\s\S]{0,200}\*\s*\{[^}]*animation\s*:\s*none/i.test(css);
7010
+ const reducedMotion = mediaQueryHasReducedMotion(css);
7011
+ const blanketKill = hasBlanketMotionKill(css);
4929
7012
  if (reducedMotion && !blanketKill)
4930
7013
  originalitySignals.push({ id: 'O4', label: 'Tiered reduced-motion', points: 1, evidence: 'targeted (not blanket) motion reduction' });
4931
7014
  }
@@ -4942,8 +7025,8 @@ export async function scoreFromParts(input) {
4942
7025
  }
4943
7026
  // O6. Bespoke iconography
4944
7027
  {
4945
- const inlineSvg = html.match(/<svg[^>]*viewBox=/gi) || [];
4946
- const svgSymbols = html.match(/<symbol[^>]*>/gi) || [];
7028
+ const inlineSvg = svgViewBoxTags(html);
7029
+ const svgSymbols = symbolTags(html);
4947
7030
  if (inlineSvg.length >= 3 || svgSymbols.length >= 2)
4948
7031
  originalitySignals.push({ id: 'O6', label: 'Bespoke iconography', points: 1, evidence: `${inlineSvg.length} inline SVGs${svgSymbols.length ? ` + ${svgSymbols.length} symbols` : ''}` });
4949
7032
  }
@@ -4953,11 +7036,11 @@ export async function scoreFromParts(input) {
4953
7036
  const shadcnHits = SHADCN_FINGERPRINT.filter((t) => css.includes(t + ':')).length;
4954
7037
  const referencesTwPrimitives = /var\(--color-[a-z]+-\d{2,3}\)/i.test(css);
4955
7038
  const isShadcnTemplate = shadcnHits >= 6 || (shadcnHits >= 4 && referencesTwPrimitives);
4956
- const customProps = css.match(/--[\w-]+\s*:/g) || [];
7039
+ const customProps = customPropertyDecls(css);
4957
7040
  const semanticTokens = customProps.filter((p) => /--(surface|ink|paper|signal|line|muted|elevation|radius|duration|ease|accent|foreground|space|gap|shadow|canvas|action|feedback|success|warning|danger|error|info|subtle|on-[a-z]+)/i.test(p));
4958
7041
  const primitiveHue = /--(?:color|colour)-[a-z]+-\d{2,3}\s*:/i;
4959
7042
  const semanticCount = isShadcnTemplate ? 0 : semanticTokens.filter((p) => !primitiveHue.test(p)).length;
4960
- const hasLayering = /--(?:color|surface|accent|action|feedback)[\w-]*\s*:\s*var\(--/i.test(css);
7043
+ const hasLayering = hasTokenLayering(css);
4961
7044
  const hasTheming = /light-dark\(|prefers-color-scheme\s*:\s*dark|data-theme/i.test(css);
4962
7045
  let tokenPoints = 0;
4963
7046
  const tokenEvidence = [];
@@ -5110,9 +7193,12 @@ export function emitReview(url, result) {
5110
7193
  continue;
5111
7194
  num++;
5112
7195
  const severity = c.status === 'FAIL' ? 'HIGH' : 'MEDIUM';
5113
- const before = c.detail.replace(/\|/g, '\\|').substring(0, 80);
5114
- const after = (c.remediation || '').replace(/\|/g, '\\|').substring(0, 80);
5115
- const why = `${c.item} (${c.category})`.replace(/\|/g, '\\|');
7196
+ // A cell escapes its backslashes before its pipes: a detail carries text
7197
+ // read from the scored page, where a backslash before a pipe would
7198
+ // otherwise escape the escape and leave a live cell separator.
7199
+ const before = c.detail.replace(/\\/g, '\\\\').replace(/\|/g, '\\|').substring(0, 80);
7200
+ const after = (c.remediation || '').replace(/\\/g, '\\\\').replace(/\|/g, '\\|').substring(0, 80);
7201
+ const why = `${c.item} (${c.category})`.replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
5116
7202
  lines.push(`| ${num} | ${severity} | ${c.category} | ${url} | ${before} | ${after} | ${why} |`);
5117
7203
  }
5118
7204
  if (num === 0)