@designesy/score 0.9.1 → 0.9.3

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 +2527 -275
  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);
@@ -2947,12 +5051,176 @@ function v46FluidFloor(value) {
2947
5051
  const statics = args.filter((a) => a !== null);
2948
5052
  return statics.length ? Math.max(...statics) : null;
2949
5053
  }
2950
- function v46Selector(css, at) {
2951
- const open = css.lastIndexOf('{', at);
2952
- if (open < 0)
2953
- return '';
2954
- 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);
5054
+ // Engine 1.5.2: the `font` shorthand sets a size too, and until 1.5.2 v46 read
5055
+ // only `font-size`, so `font: 500 0.66rem/1 var(--mono)` sat outside the check.
5056
+ // The size is the first token that is a length, a size keyword or a size
5057
+ // function, ahead of the line-height slash and the family; a weight (500) has
5058
+ // no unit and a family name is no length, so neither is taken. A system font
5059
+ // keyword (caption, menu…) or a CSS-wide keyword sets no measurable size.
5060
+ const V46_FONT_SYSTEM = /^(?:caption|icon|menu|message-box|small-caption|status-bar|inherit|initial|unset|revert|revert-layer)$/i;
5061
+ const V46_SIZE_WORDS = new Set(['small', 'large', 'x-small', 'x-large', 'xx-small', 'xx-large', 'xxx-small', 'xxx-large', 'medium', 'smaller', 'larger']);
5062
+ /** `s` with A to Z lower-cased and every other code unit kept: the case folding of the /i flag without the u flag. */
5063
+ function foldAsciiCase(s) {
5064
+ return s.replace(/[A-Z]+/g, (run) => run.toLowerCase());
5065
+ }
5066
+ /**
5067
+ * Whether a shorthand token is a size, as
5068
+ * /^(?:\d*\.?\d+(?:[a-z]+|%)|(?:xx?-|xxx-)?(?:small|large)|medium|smaller|larger|(?:clamp|min|max|calc)\(.*\))$/i
5069
+ * answers: a number and then a unit or %, a size keyword, or a size function
5070
+ * whose parenthesis closes at the end of the token with no line break inside.
5071
+ * Read in one pass: the number in that pattern can split a run of digits
5072
+ * between \d* and \d+ in as many ways as the run is long.
5073
+ */
5074
+ function isV46SizeToken(s) {
5075
+ const n = s.length;
5076
+ let k = 0;
5077
+ let dot = false;
5078
+ for (; k < n; k++) {
5079
+ const c = s.charCodeAt(k);
5080
+ if (c === 46) {
5081
+ if (dot)
5082
+ return false; // a second dot ends every reading
5083
+ dot = true;
5084
+ }
5085
+ else if (!isDigit(c))
5086
+ break;
5087
+ }
5088
+ if (k > 0) {
5089
+ if (k === n || s.charCodeAt(k - 1) === 46)
5090
+ return false; // the number ends in a digit, and a unit follows it
5091
+ if (k === n - 1 && s.charCodeAt(k) === 37)
5092
+ return true; // %
5093
+ for (let i = k; i < n; i++) {
5094
+ const c = s.charCodeAt(i) | 32;
5095
+ if (c < 97 || c > 122)
5096
+ return false;
5097
+ }
5098
+ return true;
5099
+ }
5100
+ if (n <= 9 && V46_SIZE_WORDS.has(foldAsciiCase(s)))
5101
+ return true;
5102
+ const open = s.indexOf('(');
5103
+ if (open < 3 || open > 5 || n < open + 2 || s.charCodeAt(n - 1) !== 41)
5104
+ return false;
5105
+ const fn = foldAsciiCase(s.slice(0, open));
5106
+ if (fn !== 'clamp' && fn !== 'min' && fn !== 'max' && fn !== 'calc')
5107
+ return false;
5108
+ for (let i = open + 1; i < n - 1; i++)
5109
+ if (isLineTerminator(s.charCodeAt(i)))
5110
+ return false;
5111
+ return true;
5112
+ }
5113
+ /** The size written inside a `font` shorthand value, or null when it carries none. */
5114
+ function v46ShorthandSize(value, tokens) {
5115
+ const whole = resolveVar(value, tokens); // a whole-shorthand token alias
5116
+ if (V46_FONT_SYSTEM.test(whole))
5117
+ return null;
5118
+ for (const part of splitCssTopLevel(whole.replace(/\//g, ' / ').replace(/\s+/g, ' '), ' ')) {
5119
+ if (part === '/')
5120
+ return null; // a slash before any size: not a shorthand v46 can read
5121
+ if (isV46SizeToken(resolveVar(part, tokens)))
5122
+ return part;
5123
+ }
5124
+ return null;
5125
+ }
5126
+ /**
5127
+ * The declarations v46 reads: matches of
5128
+ * /(font-size|(?<![\w-])font)\s*:\s*([^;}]+)/gi, groups [property, value].
5129
+ * A font-size match is read past its value, as the global pattern reads it.
5130
+ * After a `font` match the scan goes on from the end of the word, so a
5131
+ * shorthand never swallows a font-size written after it. A `font` match is
5132
+ * dropped when it sits inside the value of the shorthand before it (it is
5133
+ * that value's own text), or when its value holds a `{`: an @supports
5134
+ * condition such as `(font: -apple-system-body) and …{` declares nothing.
5135
+ *
5136
+ * Engine 1.5.2. Each start is tried once, and the starts that share a value's
5137
+ * end share one search for it, so the scan reads the sheet in linear time.
5138
+ */
5139
+ function v46SizeDecls(css) {
5140
+ const start = /font/gi;
5141
+ const sizeWord = /-size/iy;
5142
+ const valueEnd = nextMatchFinder(css, /[;}]/g);
5143
+ const brace = nextMatchFinder(css, /\{/g);
5144
+ const out = [];
5145
+ let shorthandEnd = -1;
5146
+ for (let pos = 0;;) {
5147
+ start.lastIndex = pos;
5148
+ const m = start.exec(css);
5149
+ if (!m)
5150
+ break;
5151
+ const at = m.index;
5152
+ pos = at + 1;
5153
+ sizeWord.lastIndex = at + 4;
5154
+ const size = sizeWord.test(css);
5155
+ if (!size && (at < shorthandEnd || (at > 0 && isNameChar(css.charCodeAt(at - 1)))))
5156
+ continue;
5157
+ const word = size ? 9 : 4;
5158
+ const colon = wsEnd(css, at + word);
5159
+ if (css.charCodeAt(colon) !== 58)
5160
+ continue;
5161
+ const ws = wsEnd(css, colon + 1);
5162
+ let from = ws;
5163
+ let end = ws;
5164
+ if (ws < css.length && css.charCodeAt(ws) !== 59 && css.charCodeAt(ws) !== 125) {
5165
+ const r = valueEnd(ws);
5166
+ end = r === Infinity ? css.length : r;
5167
+ }
5168
+ else if (ws > colon + 1) {
5169
+ from = ws - 1; // only white space before the end: the value is its last character
5170
+ }
5171
+ else
5172
+ continue;
5173
+ if (size) {
5174
+ pos = end;
5175
+ }
5176
+ else {
5177
+ pos = at + 4;
5178
+ if (brace(from) < end)
5179
+ continue;
5180
+ shorthandEnd = end;
5181
+ }
5182
+ out.push({ index: at, text: css.slice(at, end), groups: [css.slice(at, at + word), css.slice(from, end)] });
5183
+ }
5184
+ return out;
5185
+ }
5186
+ /**
5187
+ * The selector of the rule a v46 finding sits in, by offset: the text from
5188
+ * the `{`, `}` or `;` before the last `{` at or before the offset up to that
5189
+ * brace, without comments, white space collapsed, cut to 80 characters (''
5190
+ * when no `{` comes before the offset).
5191
+ *
5192
+ * Engine 1.5.2: the sheet's braces and semicolons are listed once, each offset
5193
+ * is answered by binary search, and the findings of one rule share one
5194
+ * reading of its selector. Before, each finding ran four lastIndexOf scans,
5195
+ * and on a sheet with few braces or semicolons each one read back toward the
5196
+ * start of the sheet, so the check's cost grew with the square of its length.
5197
+ */
5198
+ function v46SelectorFinder(css) {
5199
+ const opens = [];
5200
+ const marks = [];
5201
+ for (let i = 0; i < css.length; i++) {
5202
+ const c = css.charCodeAt(i);
5203
+ if (c === 123) {
5204
+ opens.push(i);
5205
+ marks.push(i);
5206
+ }
5207
+ else if (c === 125 || c === 59)
5208
+ marks.push(i);
5209
+ }
5210
+ const read = new Map();
5211
+ return (at) => {
5212
+ const k = lowerBound(opens, at + 1) - 1; // the last `{` at or before the offset
5213
+ if (k < 0)
5214
+ return '';
5215
+ const open = opens[k];
5216
+ let selector = read.get(open);
5217
+ if (selector === undefined) {
5218
+ const j = lowerBound(marks, open) - 1; // the `{`, `}` or `;` before it
5219
+ selector = withoutCssComments(css.slice(j < 0 ? 0 : marks[j] + 1, open), ' ').replace(/\s+/g, ' ').trim().slice(0, 80);
5220
+ read.set(open, selector);
5221
+ }
5222
+ return selector;
5223
+ };
2956
5224
  }
2957
5225
  function checkTypeScale(css, tokens) {
2958
5226
  const scale = v46TypeScale(tokens);
@@ -2974,10 +5242,12 @@ function checkTypeScale(css, tokens) {
2974
5242
  };
2975
5243
  let judged = 0;
2976
5244
  let fluid = 0;
2977
- const re = /font-size\s*:\s*([^;}]+)/gi;
2978
- let m;
2979
- while ((m = re.exec(css)) !== null) {
2980
- const written = m[1].replace(/\/\*[\s\S]*?\*\//g, '').replace(/!important/i, '').trim();
5245
+ const selectorAt = v46SelectorFinder(css);
5246
+ for (const m of v46SizeDecls(css)) { // engine 1.5.2: font-size and the font shorthand
5247
+ const declared = withoutCssComments(m.groups[1], '').replace(/!important/i, '').trim();
5248
+ const written = m.groups[0].length === 4 ? v46ShorthandSize(declared, tokens) : declared;
5249
+ if (written === null)
5250
+ continue;
2981
5251
  const value = resolveVar(written, tokens);
2982
5252
  if (/^var\(/i.test(value))
2983
5253
  continue; // an alias the token table cannot resolve
@@ -2985,7 +5255,7 @@ function checkTypeScale(css, tokens) {
2985
5255
  fluid++;
2986
5256
  const low = v46FluidFloor(value);
2987
5257
  if (low !== null && below(low)) {
2988
- add({ selector: v46Selector(css, m.index), value: written, px: Math.round(low * 100) / 100, issue: 'fluid minimum below the floor', nearest: floor });
5258
+ add({ selector: selectorAt(m.index), value: written, px: Math.round(low * 100) / 100, issue: 'fluid minimum below the floor', nearest: floor });
2989
5259
  }
2990
5260
  continue;
2991
5261
  }
@@ -2994,10 +5264,10 @@ function checkTypeScale(css, tokens) {
2994
5264
  continue; // em, %, keywords, calc(): relative or not static
2995
5265
  judged++;
2996
5266
  if (below(px)) {
2997
- add({ selector: v46Selector(css, m.index), value: written, px: Math.round(px * 100) / 100, issue: 'below the floor', nearest: floor });
5267
+ add({ selector: selectorAt(m.index), value: written, px: Math.round(px * 100) / 100, issue: 'below the floor', nearest: floor });
2998
5268
  }
2999
5269
  else if (!onScale(px)) {
3000
- add({ selector: v46Selector(css, m.index), value: written, px: Math.round(px * 100) / 100, issue: 'off the scale', nearest: nearest(px) });
5270
+ add({ selector: selectorAt(m.index), value: written, px: Math.round(px * 100) / 100, issue: 'off the scale', nearest: nearest(px) });
3001
5271
  }
3002
5272
  }
3003
5273
  const scaleText = `${steps.length}-step scale (ratio ${ratio.toFixed(3)}, ${scale.count} type tokens, floor ${floor}px)`;
@@ -3082,7 +5352,7 @@ function checkPausedEntrances(css) {
3082
5352
  visibility = d.value.trim().toLowerCase();
3083
5353
  }
3084
5354
  }
3085
- return (opacity !== null && /\d/.test(opacity) && /^(?:0*\.?0*|0*(?:\.0+)?%)$/.test(opacity)) || visibility === 'hidden';
5355
+ return (opacity !== null && /\d/.test(opacity) && isZeroOpacity(opacity)) || visibility === 'hidden';
3086
5356
  };
3087
5357
  if (frameHides(/^(?:from|0*(?:\.0+)?%)$/) && !frameHides(/^(?:to|100(?:\.0+)?%)$/))
3088
5358
  hiddenNames.add(name);
@@ -3302,55 +5572,45 @@ function checkAiDisclosure(html) {
3302
5572
  const CATEGORY = 'identity';
3303
5573
  // Strip scripts/styles for visible-text checks but keep them for script-src
3304
5574
  // 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, '');
5575
+ const visibleHtml = withoutScriptsAndStyles(html);
3308
5576
  // Text-only: strip all tags so attribute values like class="chatbot" don't
3309
5577
  // false-positive as disclosure TEXT. Disclosure text must be human-readable
3310
5578
  // content between tags, not class/id/aria attribute values.
3311
- const textOnly = visibleHtml.replace(/<[^>]+>/g, ' ');
5579
+ const textOnly = withoutTags(visibleHtml, ' ', true);
3312
5580
  // ── 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
5581
  // ── 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
5582
  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
5583
  const surfaceSignals = [];
3322
- if (aiContainerRe.test(html))
5584
+ if (hasAiChatContainer(html))
3323
5585
  surfaceSignals.push('chatbot container');
3324
- if (aiScriptRe.test(html))
5586
+ if (hasAiWidgetScript(html))
3325
5587
  surfaceSignals.push('AI widget script');
3326
- if (aiInputRe.test(html))
5588
+ if (hasAiInput(html))
3327
5589
  surfaceSignals.push('AI input');
3328
5590
  const possibleSignals = [];
3329
- if (chatContainerRe.test(html))
5591
+ if (hasChatWidgetContainer(html))
3330
5592
  possibleSignals.push('chat widget container');
3331
- if (chatScriptRe.test(html))
5593
+ if (hasChatScript(html))
3332
5594
  possibleSignals.push('chat or assistant script');
3333
5595
  if (aiTextRe.test(textOnly))
3334
5596
  possibleSignals.push('AI prompt text');
3335
- if (ambiguousRe.test(html))
5597
+ if (hasAmbiguousWidget(html))
3336
5598
  possibleSignals.push('ambiguous widget');
3337
5599
  // ── Disclosure detection ────────────────────────────────────────────────
3338
5600
  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
5601
  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
5602
  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
5603
  const dataAiRe = /data-ai-disclosure\s*=/i;
3344
5604
  const disclosureSignals = [];
3345
5605
  if (disclosureTextRe.test(textOnly))
3346
5606
  disclosureSignals.push('visible AI text');
3347
5607
  if (ariaDisclosureRe.test(html))
3348
5608
  disclosureSignals.push('aria-label');
3349
- if (generatorMetaRe.test(html))
5609
+ if (hasAiGeneratorMeta(html))
3350
5610
  disclosureSignals.push('generator meta');
3351
5611
  if (c2paRe.test(html))
3352
5612
  disclosureSignals.push('C2PA manifest');
3353
- if (jsonldAiRe.test(html))
5613
+ if (hasAiJsonLd(html))
3354
5614
  disclosureSignals.push('JSON-LD');
3355
5615
  if (dataAiRe.test(html))
3356
5616
  disclosureSignals.push('data-ai-disclosure');
@@ -3377,11 +5637,11 @@ function checkForcedColors(css) {
3377
5637
  const ITEM = 'Forced-colors readiness: @media (forced-colors: active) block present';
3378
5638
  const CATEGORY = 'accessibility';
3379
5639
  // Primary signal: @media (forced-colors: active) block exists
3380
- const hasForcedColorsMedia = /@media[^{]*forced-colors\s*:\s*active/i.test(css);
5640
+ const hasForcedColorsMedia = mediaQueryHasForcedColors(css);
3381
5641
  // Secondary signal: forced-color-adjust property used anywhere
3382
5642
  const hasForcedColorAdjust = /forced-color-adjust\s*:/i.test(css);
3383
5643
  // Tertiary signal: -ms-high-contrast (legacy Edge/IE) — still relevant
3384
- const hasHighContrast = /@media[^{]*-ms-high-contrast/i.test(css);
5644
+ const hasHighContrast = mediaQueryHasHighContrast(css);
3385
5645
  if (hasForcedColorsMedia && hasForcedColorAdjust) {
3386
5646
  return { id: 'v35', item: ITEM, category: CATEGORY, status: 'PASS', detail: 'forced-colors media query + forced-color-adjust both present' };
3387
5647
  }
@@ -3534,11 +5794,10 @@ function checkSecurityConfusables(css, tokens) {
3534
5794
  }
3535
5795
  // Scan 3: url() references — confusables in file paths can redirect to
3536
5796
  // different assets (logo spoofing, CSS injection via @import)
3537
- const urlRe = /url\(\s*['"]?([^'")]+)['"]?\s*\)/gi;
3538
5797
  const urlConfusables = [];
3539
5798
  const scannedUrls = new Set();
3540
- while ((m = urlRe.exec(css)) !== null) {
3541
- const urlPath = m[1];
5799
+ for (const u of cssUrlMatches(css)) {
5800
+ const urlPath = u.groups[0];
3542
5801
  if (scannedUrls.has(urlPath))
3543
5802
  continue;
3544
5803
  scannedUrls.add(urlPath);
@@ -3661,8 +5920,6 @@ function checkButtonTextVerb(html) {
3661
5920
  const ITEM = 'Button text is a verb phrase or recognized command (not a bare noun)';
3662
5921
  const CATEGORY = 'copywriting';
3663
5922
  // 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
5923
  // Remove aria-hidden subtrees before extracting text.
3667
5924
  //
3668
5925
  // Content marked aria-hidden is by definition not part of the accessible name,
@@ -3679,7 +5936,7 @@ function checkButtonTextVerb(html) {
3679
5936
  let out = inner;
3680
5937
  for (let i = 0; i < 4; i++) {
3681
5938
  // 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, '');
5939
+ const next = withoutAriaHiddenElements(out);
3683
5940
  if (next === out)
3684
5941
  break;
3685
5942
  out = next;
@@ -3701,12 +5958,10 @@ function checkButtonTextVerb(html) {
3701
5958
  // Carry the opening tag alongside the label so the violation loop can tell a
3702
5959
  // command button from a selection control (see isSelectionControl).
3703
5960
  const buttonTexts = [];
3704
- let m;
3705
5961
  // Strip keyboard shortcut hints that are fused to or appended after the
3706
5962
  // button label — e.g. "Find⌘K", "Search ⌘+K", "Save Ctrl+S". These are
3707
5963
  // visual hints, not part of the verb. Ranges: ⌘ (U+2318), ⌃ (U+2303),
3708
5964
  // ⌥ (U+2325), ⇧ (U+21E7), and common "Ctrl+", "Cmd+", "Shift+" prefixes.
3709
- const SHORTCUT_RE = /[\s]*[\u2303\u2318\u2325\u21E7\u21E7\u2387].*$/i;
3710
5965
  // The `+` is optional and the key may be fused directly to the modifier
3711
5966
  // ("CtrlK"), because the badge is now platform-aware and renders "Ctrl" with
3712
5967
  // no glyph to strip. Requiring a literal '+' meant "FindCtrlK" survived and
@@ -3715,14 +5970,13 @@ function checkButtonTextVerb(html) {
3715
5970
  // The key must be ADJACENT to the modifier (plus form, or fused alphanumerics
3716
5971
  // with no space between). A space separates a real word: "Alt text" is a
3717
5972
  // 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
5973
  // Engine 1.3.1: a "+key" hint written in lower case ("Save ctrl+s",
3720
5974
  // "Find ctrl+k") is a hint too. The modifier is case-insensitive here but
3721
5975
  // must stand as its own word (the start of the label or after a space), so
3722
5976
  // "Halt+1" is untouched, and only the plus form is read: a modifier followed
3723
5977
  // by a space and a letter ("Shift A") is a label, as it was in 1.3.0.
3724
5978
  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();
5979
+ const stripShortcut = (s) => withoutTextShortcut(withoutGlyphShortcut(s)).replace(PLUS_SHORTCUT_RE, '').trim();
3726
5980
  // Engine 1.3.1: keycaps are read from the markup, not guessed from text.
3727
5981
  // A <kbd> element is keyboard input (HTML Living Standard, the kbd element),
3728
5982
  // a hint beside the label rather than the label, so its text is dropped
@@ -3737,23 +5991,23 @@ function checkButtonTextVerb(html) {
3737
5991
  const visible = stripAriaHidden(inner);
3738
5992
  let out = visible;
3739
5993
  for (let i = 0; i < 4; i++) {
3740
- const next = out.replace(/<kbd\b[^>]*>(?:(?!<kbd\b)[\s\S])*?<\/kbd>/gi, ' ');
5994
+ const next = withoutKbdElements(out);
3741
5995
  if (next === out)
3742
5996
  break;
3743
5997
  out = next;
3744
5998
  }
3745
- const text = out.replace(/<[^>]*>/g, '').trim();
3746
- return out === visible ? text : text.replace(/(?:\s*\+)+$/, '').trim();
5999
+ const text = withoutTags(out, '', false).trim();
6000
+ return out === visible ? text : withoutTrailingPluses(text).trim();
3747
6001
  };
3748
- while ((m = buttonRe.exec(html)) !== null) {
3749
- let text = labelText(m[1]);
6002
+ for (const m of buttonElements(html)) {
6003
+ let text = labelText(m.groups[0]);
3750
6004
  // Strip leading icon characters so "✕Close" → "Close"
3751
6005
  text = text.replace(ICON_PREFIX_RE, '').trim();
3752
6006
  // Strip trailing keyboard shortcut hints so "Find⌘K" → "Find"
3753
6007
  text = stripShortcut(text);
3754
6008
  // Icon-only button with an accessible name: use the aria-label verb.
3755
6009
  if (!text) {
3756
- const aria = /aria-label=["']([^"']+)["']/i.exec(m[0]);
6010
+ const aria = /aria-label=["']([^"']+)["']/i.exec(m.text);
3757
6011
  if (aria)
3758
6012
  text = aria[1].trim();
3759
6013
  }
@@ -3763,19 +6017,19 @@ function checkButtonTextVerb(html) {
3763
6017
  // Without this, v38 reported six single-letter "buttons" as non-verb labels
3764
6018
  // on /maturity — an accurate observation of the wrong string.
3765
6019
  if (text && text.length <= 2) {
3766
- const aria = /aria-label=["']([^"']+)["']/i.exec(m[0]);
6020
+ const aria = /aria-label=["']([^"']+)["']/i.exec(m.text);
3767
6021
  if (aria)
3768
6022
  text = aria[1].trim();
3769
6023
  }
3770
6024
  if (text)
3771
- buttonTexts.push({ text, tag: m[0] });
6025
+ buttonTexts.push({ text, tag: m.text });
3772
6026
  }
3773
- while ((m = roleButtonRe.exec(html)) !== null) {
3774
- let text = labelText(m[1]);
6027
+ for (const m of roleButtonElements(html)) {
6028
+ let text = labelText(m.groups[0]);
3775
6029
  text = text.replace(ICON_PREFIX_RE, '').trim();
3776
6030
  text = stripShortcut(text);
3777
6031
  if (!text) {
3778
- const aria = /aria-label=["']([^"']+)["']/i.exec(m[0]);
6032
+ const aria = /aria-label=["']([^"']+)["']/i.exec(m.text);
3779
6033
  if (aria)
3780
6034
  text = aria[1].trim();
3781
6035
  }
@@ -3785,12 +6039,12 @@ function checkButtonTextVerb(html) {
3785
6039
  // Without this, v38 reported six single-letter "buttons" as non-verb labels
3786
6040
  // on /maturity — an accurate observation of the wrong string.
3787
6041
  if (text && text.length <= 2) {
3788
- const aria = /aria-label=["']([^"']+)["']/i.exec(m[0]);
6042
+ const aria = /aria-label=["']([^"']+)["']/i.exec(m.text);
3789
6043
  if (aria)
3790
6044
  text = aria[1].trim();
3791
6045
  }
3792
6046
  if (text)
3793
- buttonTexts.push({ text, tag: m[0] });
6047
+ buttonTexts.push({ text, tag: m.text });
3794
6048
  }
3795
6049
  if (buttonTexts.length === 0) {
3796
6050
  return { id: 'v38', item: ITEM, category: CATEGORY, status: 'SKIP', detail: 'no button elements found in HTML' };
@@ -3860,23 +6114,19 @@ function checkButtonTextVerb(html) {
3860
6114
  function checkNoTrailingPeriod(html) {
3861
6115
  const ITEM = 'No trailing period on button text, labels, or tab text';
3862
6116
  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
6117
  const texts = [];
3867
- let m;
3868
- while ((m = buttonRe.exec(html)) !== null) {
3869
- const text = m[1].replace(/<[^>]*>/g, '').trim();
6118
+ for (const m of buttonElements(html)) {
6119
+ const text = withoutTags(m.groups[0], '', false).trim();
3870
6120
  if (text)
3871
6121
  texts.push({ type: 'button', text });
3872
6122
  }
3873
- while ((m = labelRe.exec(html)) !== null) {
3874
- const text = m[1].replace(/<[^>]*>/g, '').trim();
6123
+ for (const m of labelElements(html)) {
6124
+ const text = withoutTags(m.groups[0], '', false).trim();
3875
6125
  if (text)
3876
6126
  texts.push({ type: 'label', text });
3877
6127
  }
3878
- while ((m = tabRe.exec(html)) !== null) {
3879
- const text = m[1].replace(/<[^>]*>/g, '').trim();
6128
+ for (const m of roleTabElements(html)) {
6129
+ const text = withoutTags(m.groups[0], '', false).trim();
3880
6130
  if (text)
3881
6131
  texts.push({ type: 'tab', text });
3882
6132
  }
@@ -3900,11 +6150,9 @@ const NON_DESCRIPTIVE_LINK_TEXT = /^(click here|here|learn more|read more|more|l
3900
6150
  function checkLinkTextDescriptive(html) {
3901
6151
  const ITEM = 'Link text is descriptive (not bare "click here", "learn more", "here")';
3902
6152
  const CATEGORY = 'copywriting';
3903
- const linkRe = /<a[^>]*>([\s\S]*?)<\/a>/gi;
3904
6153
  const linkTexts = [];
3905
- let m;
3906
- while ((m = linkRe.exec(html)) !== null) {
3907
- const text = m[1].replace(/<[^>]*>/g, '').trim();
6154
+ for (const m of anchorElements(html)) {
6155
+ const text = withoutTags(m.groups[0], '', false).trim();
3908
6156
  if (text)
3909
6157
  linkTexts.push(text);
3910
6158
  }
@@ -3934,20 +6182,18 @@ function checkNoAllCaps(html) {
3934
6182
  const ITEM = 'No ALL CAPS UI text except eyebrow labels';
3935
6183
  const CATEGORY = 'copywriting';
3936
6184
  // 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
6185
  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();
6186
+ for (const m of capsElementMatches(html)) {
6187
+ const attrs = m.groups[1] || '';
6188
+ const text = withoutTags(m.groups[2], '', false).trim();
3943
6189
  if (!text || text.length < 4)
3944
6190
  continue;
3945
6191
  // Skip eyebrow labels (class contains "eyebrow" or "label" — per typography
3946
6192
  // contract, eyebrows are intentionally uppercase)
3947
- if (/class=["'][^"']*(eyebrow|eyebro|meta-label)[^"']*["']/i.test(attrs))
6193
+ if (hasEyebrowClass(attrs))
3948
6194
  continue;
3949
6195
  // Skip elements with text-transform: uppercase in inline style
3950
- if (/style=["'][^"']*text-transform:\s*uppercase[^"']*["']/i.test(attrs))
6196
+ if (hasUppercaseStyle(attrs))
3951
6197
  continue;
3952
6198
  // Check if text is ALL CAPS (only letters, all uppercase, >3 chars)
3953
6199
  const letters = text.replace(/[^a-zA-Z]/g, '');
@@ -3979,19 +6225,19 @@ function checkPoiseInteractionRules(css) {
3979
6225
  const rules = [
3980
6226
  {
3981
6227
  name: 'fine-pointer hover guard',
3982
- re: /@media[^{]*(?:hover\s*:\s*hover|pointer\s*:\s*fine)/i,
6228
+ test: (s) => mediaQueryHasHoverGuard(s),
3983
6229
  },
3984
6230
  {
3985
6231
  name: 'press settle scale ~0.97',
3986
- re: /scale\s*\(\s*0?\.9[5-9]\s*\)/i,
6232
+ test: (s) => /scale\s*\(\s*0?\.9[5-9]\s*\)/i.test(s),
3987
6233
  },
3988
6234
  {
3989
6235
  name: 'opacity-only mark breath',
3990
- re: /@keyframes\s+[^{]*breath[^{]*\{[^}]*opacity\s*:/i,
6236
+ test: (s) => hasBreathKeyframes(s),
3991
6237
  },
3992
6238
  ];
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);
6239
+ const found = rules.filter(r => r.test(css)).map(r => r.name);
6240
+ const missing = rules.filter(r => !r.test(css)).map(r => r.name);
3995
6241
  if (found.length >= 2) {
3996
6242
  return {
3997
6243
  id: 'v08',
@@ -4020,8 +6266,8 @@ function checkPoiseKeyboardPath(css, html) {
4020
6266
  const hasFocusVisible = /:focus-visible/i.test(css);
4021
6267
  const hasFocus = /:focus[^-]/i.test(css);
4022
6268
  // 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);
6269
+ const stripsOutline = focusStripsOutline(css);
6270
+ const hasFocusRing = focusHasRing(css);
4025
6271
  const hasTabindex = /tabindex\s*=/i.test(html);
4026
6272
  const hasAria = /aria-(label|labelledby|describedby|expanded|selected|pressed)/i.test(html);
4027
6273
  const signals = [hasFocusVisible, hasFocus, hasFocusRing, hasTabindex, hasAria].filter(Boolean).length;
@@ -4063,19 +6309,19 @@ function checkTaktFeelRules(css) {
4063
6309
  const rules = [
4064
6310
  {
4065
6311
  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,
6312
+ test: (s) => /animation-delay\s*:\s*(?:0?\.(?:0?[6-9]|1[0-2])\d*s|\d{2,3}ms)/i.test(s),
4067
6313
  },
4068
6314
  {
4069
6315
  name: 'soften exit transform ease-out',
4070
- re: /transition\s*:[^;]*transform[^;]*(ease-out|cubic-bezier\([^)]*0[, ])/i,
6316
+ test: (s) => hasTransformEaseTransition(s),
4071
6317
  },
4072
6318
  {
4073
6319
  name: 'concentric border-radius set',
4074
- re: /border-radius\s*:\s*\d+/i,
6320
+ test: (s) => /border-radius\s*:\s*\d+/i.test(s),
4075
6321
  },
4076
6322
  ];
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);
6323
+ const found = rules.filter(r => r.test(css)).map(r => r.name);
6324
+ const missing = rules.filter(r => !r.test(css)).map(r => r.name);
4079
6325
  if (found.length >= 2) {
4080
6326
  return {
4081
6327
  id: 'v10',
@@ -4172,13 +6418,50 @@ async function checkDesignMdSpec(targetUrl) {
4172
6418
  // (an account name often does); the last one may not, so the sentence after
4173
6419
  // a path survives, and trailing punctuation is kept.
4174
6420
  function sanitizeErrorText(text) {
4175
- const keep = (label) => (m) => `${label}${/[.,;:!?)]+$/.exec(m)?.[0] ?? ''}`;
4176
- return text
6421
+ // The punctuation a replaced path ended with, read back from its end
6422
+ // (engine 1.5.1: /[.,;:!?)]+$/ was retried from every mark in a run).
6423
+ const tail = (m) => {
6424
+ let k = m.length;
6425
+ while (k > 0 && '.,;:!?)'.indexOf(m[k - 1]) >= 0)
6426
+ k--;
6427
+ return m.slice(k);
6428
+ };
6429
+ const keep = (label) => (m) => `${label}${tail(m)}`;
6430
+ const pathless = text
4177
6431
  .replace(/\bfile:\/\/[^\s'"<>`|]*/gi, keep('a local file'))
4178
6432
  .replace(/\\\\[^\\\s'"<>`|]+\\(?:[^\\\r\n'"<>`|*?]+\\)*[^\\\s'"<>`|*?]*/g, keep('a local path'))
4179
6433
  .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'));
6434
+ .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))}`);
6435
+ // npm cache segments: a run of characters other than space, quotes, angle
6436
+ // brackets, backtick and pipe that holds npm-cache, /_npx/ or /.npm/ is
6437
+ // replaced whole. Each run is read once (engine 1.5.1: the run pattern was
6438
+ // retried from every character in it).
6439
+ const marker = /npm-cache|[\\/]_npx[\\/]|[\\/]\.npm[\\/]/gi;
6440
+ const stop = /[\s'"<>`|]/;
6441
+ const parts = [];
6442
+ let last = 0;
6443
+ let markerAt = -1;
6444
+ for (let a = 0; a < pathless.length;) {
6445
+ if (stop.test(pathless[a])) {
6446
+ a++;
6447
+ continue;
6448
+ }
6449
+ let b = a + 1;
6450
+ while (b < pathless.length && !stop.test(pathless[b]))
6451
+ b++;
6452
+ if (markerAt < a) {
6453
+ marker.lastIndex = a;
6454
+ const found = marker.exec(pathless);
6455
+ markerAt = found ? found.index : Infinity;
6456
+ }
6457
+ if (markerAt < b) {
6458
+ parts.push(pathless.slice(last, a), keep('the npm cache')(pathless.slice(a, b)));
6459
+ last = b;
6460
+ }
6461
+ a = b;
6462
+ }
6463
+ parts.push(pathless.slice(last));
6464
+ return parts.join('');
4182
6465
  }
4183
6466
  /** The checks with every detail passed through sanitizeErrorText. */
4184
6467
  function sanitizeCheckDetails(checks) {
@@ -4318,7 +6601,7 @@ function scoreArithmetic(checks, slopTotal, originalityPoints) {
4318
6601
  // contrast or horizontal overflow cannot be A-grade no matter how good its
4319
6602
  // tokens are. Caps are applied AFTER the a11y floor (floor wins over ceilings).
4320
6603
  //
4321
- // No A while any check FAILs (engine 1.3.0, owner decision 2026-10-11). A
6604
+ // No A while any check FAILs (engine 1.3.0, see CHANGELOG). A
4322
6605
  // FAIL is the engine's own finding that the page breaks a rule, so any FAIL
4323
6606
  // caps the score at 89.9, a B at most. Until 1.3.0 a page could score 100, an
4324
6607
  // A, beside FAIL verdicts, including the engine's own WCAG contrast FAIL
@@ -4346,7 +6629,7 @@ function scoreArithmetic(checks, slopTotal, originalityPoints) {
4346
6629
  }
4347
6630
  // v44 Status text contrast — the words that report a state fail WCAG
4348
6631
  // 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
6632
+ // Same cap as v06 (engine 1.3.0): both are
4350
6633
  // measured WCAG contrast failures on text.
4351
6634
  if (c.id === 'v44') {
4352
6635
  cap = 65;
@@ -4596,40 +6879,13 @@ export async function scoreFromParts(input) {
4596
6879
  // reverse, `linear-gradient(color-mix(...) 1px, transparent 1px)`, and its
4597
6880
  // graph-paper grid was reported at the maximum severity. Match a 1px stop
4598
6881
  // 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
- };
6882
+ const isGridPattern = (text) => hasColorThenHairline(text) || hasHairlineThenColor(text);
4627
6883
  const pageGrad = [];
4628
6884
  for (const rule of parseCssRules(css).rules) {
4629
6885
  if (!/linear-gradient\s*\(/i.test(rule.decls))
4630
6886
  continue;
4631
6887
  const decls = parseCssDecls(rule.decls);
4632
- if (!decls.some((d) => (d.prop === 'background' || d.prop === 'background-image') && multiStop(d.value)))
6888
+ if (!decls.some((d) => (d.prop === 'background' || d.prop === 'background-image') && hasMultiStopGradient(d.value)))
4633
6889
  continue;
4634
6890
  const text = `${rule.selector}{${rule.decls}}`;
4635
6891
  if (isGridPattern(text))
@@ -4652,7 +6908,7 @@ export async function scoreFromParts(input) {
4652
6908
  const hairline = decls.some((d) => (/^(?:height|width|block-size|inline-size)$/.test(d.prop) && d.value.trim() === '1px')
4653
6909
  || (/^border(?:-top|-bottom)?-width$/.test(d.prop) && d.value.trim() === '1px')
4654
6910
  || (d.prop === 'background-size' && /(?:^|[\s,])1px(?:$|[\s,])/.test(d.value))
4655
- || (d.prop === 'background' && /\/\s*[^,/]*(?:^|\s)1px(?:$|[\s,])/.test(d.value)));
6911
+ || (d.prop === 'background' && hasSlashThenHairline(d.value)));
4656
6912
  const fixedCover = last('position') === 'fixed' && (pinned || viewportSized);
4657
6913
  if ((pageSelector || fixedCover || (viewportSized && !component)) && !hairline)
4658
6914
  pageGrad.push(text.replace(/\s+/g, ' ').slice(0, 160));
@@ -4670,8 +6926,7 @@ export async function scoreFromParts(input) {
4670
6926
  }
4671
6927
  // S3. Purple/violet gradient
4672
6928
  {
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);
6929
+ const matches = purpleGradientDecls(css);
4675
6930
  if (matches)
4676
6931
  slopFindings.push({ id: 'S3', label: 'Purple/violet AI gradient', severity: 4, instances: matches.length, evidence: matches.slice(0, 3) });
4677
6932
  }
@@ -4683,7 +6938,7 @@ export async function scoreFromParts(input) {
4683
6938
  // pattern anchored to `(?:^|})` takes 4 ms and matches the same rule.
4684
6939
  // Measured 2026-09-19, Node 24. This was the real cause of the 55s cold
4685
6940
  // score, not the network (0.9s) and not the sequential CSS fetch.
4686
- const gradientClips = css.match(/(?:^|\})[^{}]*\{[^}]*background-clip\s*:\s*text[^}]*\}/gi) || [];
6941
+ const gradientClips = gradientClipRules(css);
4687
6942
  const hueKey = (stop) => {
4688
6943
  if (/var\(/.test(stop))
4689
6944
  return null;
@@ -4705,7 +6960,7 @@ export async function scoreFromParts(input) {
4705
6960
  h = Math.round(((h * 60) + 360) % 360);
4706
6961
  return 'h' + Math.floor(h / 45);
4707
6962
  }
4708
- const rgb = stop.match(/rgba?\(([^)]+)\)/i);
6963
+ const rgb = rgbArgs(stop);
4709
6964
  if (rgb) {
4710
6965
  const p = rgb[1].split(',').map(x => parseFloat(x));
4711
6966
  if (p.length >= 3) {
@@ -4728,8 +6983,8 @@ export async function scoreFromParts(input) {
4728
6983
  return null;
4729
6984
  };
4730
6985
  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])) || '';
6986
+ const gradientMatch = linearGradientArgs(block);
6987
+ const gradText = (gradientMatch && (gradientMatch.groups[0] || gradientMatch.groups[1])) || '';
4733
6988
  const stops = gradText.split(',').map(s => s.trim());
4734
6989
  const hueKeys = new Set(stops.map(hueKey).filter((k) => k !== null && k !== 'neutral'));
4735
6990
  return hueKeys.size >= 2;
@@ -4750,8 +7005,7 @@ export async function scoreFromParts(input) {
4750
7005
  {
4751
7006
  const cardPattern = /\.(card|panel|tile|feature|item|box|cell|block)\b/gi;
4752
7007
  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) || [];
7008
+ const gridMatches = gridRepeatRules(css);
4755
7009
  if (cardClasses.length >= 3 && gridMatches.length > 0)
4756
7010
  slopFindings.push({ id: 'S6', label: 'Repeated identical card grid', severity: 5, instances: 1, evidence: [`${cardClasses.length} card-like classes with repeating grid layouts`] });
4757
7011
  }
@@ -4765,7 +7019,7 @@ export async function scoreFromParts(input) {
4765
7019
  // text-default ones promoted with U+FE0F. This also catches ⭐ (U+2B50), which
4766
7020
  // the old range missed while it was busy flagging checkmarks.
4767
7021
  {
4768
- const emojiInButtons = html.match(/(?:<button|<a[^>]*class[^>]*(?:btn|cta|primary|action))[^>]*>[\s\S]{0,200}(?:[\p{Emoji_Presentation}]|[\p{Emoji}]\uFE0F)/giu);
7022
+ const emojiInButtons = emojiCtaMatches(html);
4769
7023
  if (emojiInButtons && emojiInButtons.length >= 2)
4770
7024
  slopFindings.push({ id: 'S7', label: 'Emoji as UI icons', severity: 4, instances: emojiInButtons.length, evidence: [`${emojiInButtons.length} emoji in button/CTA elements`] });
4771
7025
  }
@@ -4783,11 +7037,7 @@ export async function scoreFromParts(input) {
4783
7037
  // Skipped on a page that documents the rules (see isDocumentingSlopRules):
4784
7038
  // the anti-slop rule table on /methodology lists these phrases.
4785
7039
  {
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, ' ');
7040
+ const visible = withoutNonVisibleMarkup(html);
4791
7041
  const pillPhrase = /\b(?:AI-powered|Generate|Chat with AI|Powered by AI|Built with AI|AI-driven)\b/i;
4792
7042
  const openRe = /<(span|a|button|div|small|strong|b|em|mark|label|p|li)\b([^<>]*)>/gi;
4793
7043
  const tagRe = {};
@@ -4818,7 +7068,7 @@ export async function scoreFromParts(input) {
4818
7068
  }
4819
7069
  if (end < 0)
4820
7070
  continue;
4821
- const text = windowText.slice(0, end).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
7071
+ const text = withoutTags(windowText.slice(0, end), ' ', false).replace(/\s+/g, ' ').trim();
4822
7072
  if (text && text.length <= 40 && pillPhrase.test(text))
4823
7073
  pills.push(text);
4824
7074
  }
@@ -4854,7 +7104,7 @@ export async function scoreFromParts(input) {
4854
7104
  // S11. Marketing buzzwords
4855
7105
  {
4856
7106
  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();
7107
+ const bodyText = withoutTags(html, ' ', true).toLowerCase();
4858
7108
  const found = [];
4859
7109
  for (const word of buzzwords) {
4860
7110
  if (bodyText.includes(word))
@@ -4880,21 +7130,20 @@ export async function scoreFromParts(input) {
4880
7130
  const originalitySignals = [];
4881
7131
  // O1. Bespoke easing
4882
7132
  {
4883
- const bezierRe = /cubic-bezier\(\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*\)/gi;
4884
7133
  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
7134
  const distinct = new Set();
4886
7135
  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, '');
7136
+ for (const b of cubicBezierMatches(css)) {
7137
+ const [x1, y1s, x2, y2s] = b.groups;
7138
+ const key = withoutTrailingDecimalZeros(`${x1},${y1s},${x2},${y2s}`).replace(/\s+/g, '');
4890
7139
  if (EASING_PRESETS.has(key))
4891
7140
  continue;
4892
7141
  distinct.add(key);
4893
- const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
7142
+ const y1 = parseFloat(y1s), y2 = parseFloat(y2s);
4894
7143
  if (y1 < 0 || y1 > 1 || y2 < 0 || y2 > 1)
4895
7144
  hasOvershoot = true;
4896
7145
  }
4897
- const linearSprings = css.match(/linear\(\s*[^)]{20,}\)/gi) || [];
7146
+ const linearSprings = longLinearEasings(css);
4898
7147
  if (linearSprings.length > 0)
4899
7148
  hasOvershoot = true;
4900
7149
  const easingCount = distinct.size;
@@ -4924,8 +7173,8 @@ export async function scoreFromParts(input) {
4924
7173
  }
4925
7174
  // O4. Tiered reduced-motion
4926
7175
  {
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);
7176
+ const reducedMotion = mediaQueryHasReducedMotion(css);
7177
+ const blanketKill = hasBlanketMotionKill(css);
4929
7178
  if (reducedMotion && !blanketKill)
4930
7179
  originalitySignals.push({ id: 'O4', label: 'Tiered reduced-motion', points: 1, evidence: 'targeted (not blanket) motion reduction' });
4931
7180
  }
@@ -4942,8 +7191,8 @@ export async function scoreFromParts(input) {
4942
7191
  }
4943
7192
  // O6. Bespoke iconography
4944
7193
  {
4945
- const inlineSvg = html.match(/<svg[^>]*viewBox=/gi) || [];
4946
- const svgSymbols = html.match(/<symbol[^>]*>/gi) || [];
7194
+ const inlineSvg = svgViewBoxTags(html);
7195
+ const svgSymbols = symbolTags(html);
4947
7196
  if (inlineSvg.length >= 3 || svgSymbols.length >= 2)
4948
7197
  originalitySignals.push({ id: 'O6', label: 'Bespoke iconography', points: 1, evidence: `${inlineSvg.length} inline SVGs${svgSymbols.length ? ` + ${svgSymbols.length} symbols` : ''}` });
4949
7198
  }
@@ -4953,11 +7202,11 @@ export async function scoreFromParts(input) {
4953
7202
  const shadcnHits = SHADCN_FINGERPRINT.filter((t) => css.includes(t + ':')).length;
4954
7203
  const referencesTwPrimitives = /var\(--color-[a-z]+-\d{2,3}\)/i.test(css);
4955
7204
  const isShadcnTemplate = shadcnHits >= 6 || (shadcnHits >= 4 && referencesTwPrimitives);
4956
- const customProps = css.match(/--[\w-]+\s*:/g) || [];
7205
+ const customProps = customPropertyDecls(css);
4957
7206
  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
7207
  const primitiveHue = /--(?:color|colour)-[a-z]+-\d{2,3}\s*:/i;
4959
7208
  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);
7209
+ const hasLayering = hasTokenLayering(css);
4961
7210
  const hasTheming = /light-dark\(|prefers-color-scheme\s*:\s*dark|data-theme/i.test(css);
4962
7211
  let tokenPoints = 0;
4963
7212
  const tokenEvidence = [];
@@ -5110,9 +7359,12 @@ export function emitReview(url, result) {
5110
7359
  continue;
5111
7360
  num++;
5112
7361
  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, '\\|');
7362
+ // A cell escapes its backslashes before its pipes: a detail carries text
7363
+ // read from the scored page, where a backslash before a pipe would
7364
+ // otherwise escape the escape and leave a live cell separator.
7365
+ const before = c.detail.replace(/\\/g, '\\\\').replace(/\|/g, '\\|').substring(0, 80);
7366
+ const after = (c.remediation || '').replace(/\\/g, '\\\\').replace(/\|/g, '\\|').substring(0, 80);
7367
+ const why = `${c.item} (${c.category})`.replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
5116
7368
  lines.push(`| ${num} | ${severity} | ${c.category} | ${url} | ${before} | ${after} | ${why} |`);
5117
7369
  }
5118
7370
  if (num === 0)