@orkestrel/test 0.0.18 → 0.0.20

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.
@@ -1,6 +1,6 @@
1
1
  import { isError, isString } from "@orkestrel/contract";
2
- import { STATECHART_ATTRIBUTES, STATECHART_STATUSES, buildRefusal, checkBounds, executeScenario, requireValue, waitForAbort, waitForCondition, waitForDelay } from "../core/index.js";
3
- import { commands, page, userEvent } from "vitest/browser";
2
+ import { STATECHART_ATTRIBUTES, STATECHART_STATUSES, buildRefusal, checkBounds, executeScenario, invokeUnchecked, readProperty, requireValue, waitForAbort, waitForCondition, waitForDelay } from "../core/index.js";
3
+ import { cdp, commands, page, userEvent } from "vitest/browser";
4
4
  //#region src/browser/constants.ts
5
5
  /**
6
6
  * Names the interactive ARIA roles a bare accessible name is searched across.
@@ -192,6 +192,22 @@ var IMPLICIT_ROLES = Object.freeze({
192
192
  TR: "row",
193
193
  UL: "list"
194
194
  });
195
+ /**
196
+ * Names the tester root's attribute holding the media readings observed before the first stage.
197
+ *
198
+ * @remarks
199
+ * The value is a bit string in print, reduced motion, dark colour scheme, and forced colours order.
200
+ * Each bit is `1` for a matching query and `0` otherwise, for example `0100`.
201
+ */
202
+ var MEDIA_STAGE = "data-media-stage";
203
+ /**
204
+ * Names the tester root's attribute holding the pressed pointer's page coordinates.
205
+ *
206
+ * @remarks
207
+ * The value has the form `<x>x<y>`. The release removes it only after the button-up send resolves,
208
+ * so a rejected send keeps the marker for a retry.
209
+ */
210
+ var POINTER_HOLD = "data-pointer-hold";
195
211
  //#endregion
196
212
  //#region src/browser/helpers.ts
197
213
  /**
@@ -487,6 +503,29 @@ async function clickAccessible(first, second) {
487
503
  * ```
488
504
  */
489
505
  async function clickAccessibleWithin(region, role, name) {
506
+ await userEvent.click(resolveAccessibleWithin(region, role, name));
507
+ }
508
+ /**
509
+ * Resolves one human-reachable control by role and accessible-name text inside a named region.
510
+ *
511
+ * @param region - The containing region's exact accessible name.
512
+ * @param role - The control's exact ARIA role.
513
+ * @param name - The rendered accessible-name text that identifies the control in that region.
514
+ * @returns The one reachable element carrying that role and name inside the region.
515
+ * @throws When the named control is absent, unreachable, or ambiguous inside the region.
516
+ *
517
+ * @remarks
518
+ * The region's name is matched exactly and the control's name loosely, over a computed name that
519
+ * includes hidden subtrees, so a glyph joins the text rather than displacing it. One pass answers
520
+ * this: a control the region cannot reach is refused whether it is absent or hidden. This is the
521
+ * resolver the region-scoped verbs share.
522
+ *
523
+ * @example
524
+ * ```ts
525
+ * resolveAccessibleWithin('Ledger', 'button', 'Monthly income')
526
+ * ```
527
+ */
528
+ function resolveAccessibleWithin(region, role, name) {
490
529
  const reachable = page.getByRole("region", {
491
530
  name: region,
492
531
  exact: true
@@ -499,7 +538,7 @@ async function clickAccessibleWithin(region, role, name) {
499
538
  if (reachable.length > 1) throw new Error(`Interactive target "${name}" is ambiguous across ${reachable.length} elements inside "${region}"`);
500
539
  const [target] = reachable;
501
540
  if (!(target instanceof HTMLElement)) throw new Error(`Interactive target "${name}" could not be resolved inside "${region}"`);
502
- await userEvent.click(target);
541
+ return target;
503
542
  }
504
543
  /**
505
544
  * Opens or closes one native details disclosure by its rendered summary.
@@ -529,6 +568,176 @@ async function clickDisclosure(name) {
529
568
  await userEvent.click(target);
530
569
  }
531
570
  /**
571
+ * Sends one DevTools protocol command through the browser provider.
572
+ *
573
+ * @param method - The protocol method name.
574
+ * @param params - The protocol parameters.
575
+ * @returns A promise resolving after the command completes, discarding its response.
576
+ * @throws Thrown when the provider exposes no DevTools session, or the command fails.
577
+ *
578
+ * @example
579
+ * ```ts
580
+ * await sendProtocol('Emulation.setEmulatedMedia', { media: '', features: [] })
581
+ * ```
582
+ */
583
+ async function sendProtocol(method, params) {
584
+ let session;
585
+ let send;
586
+ try {
587
+ session = cdp();
588
+ send = readProperty(session, "send");
589
+ } catch (cause) {
590
+ throw new Error("Browser provider exposes no DevTools session", { cause });
591
+ }
592
+ if (typeof send !== "function") throw new Error("Browser provider exposes no DevTools session");
593
+ await invokeUnchecked(session, send, [method, params]);
594
+ }
595
+ async function hoverAccessible(first, second) {
596
+ await userEvent.hover(resolveRendered(first, second));
597
+ }
598
+ async function holdAccessible(first, second) {
599
+ await driveHold(() => second === void 0 ? resolveAccessible(first) : resolveAccessible(first, second), second ?? first);
600
+ }
601
+ /**
602
+ * Holds the primary pointer button on one control by role and accessible-name text inside a named
603
+ * region.
604
+ *
605
+ * @param region - The containing region's exact accessible name.
606
+ * @param role - The control's exact ARIA role.
607
+ * @param name - The rendered accessible-name text that identifies the control in that region.
608
+ * @returns A promise resolving after the control enters its pressed state.
609
+ * @throws Thrown when a pointer is already held, the region refuses the target, or the press
610
+ * misses. A missed press that also fails to release carries the release rejection as its cause.
611
+ *
612
+ * @remarks
613
+ * The resolution is {@link resolveAccessibleWithin} and the hold is {@link driveHold}, so a twin
614
+ * of the same name in another region is left alone. Register {@link releasePointer} in teardown
615
+ * before holding.
616
+ *
617
+ * @example
618
+ * ```ts
619
+ * await holdAccessibleWithin('Ledger', 'button', 'Apply')
620
+ * await releasePointer()
621
+ * ```
622
+ */
623
+ async function holdAccessibleWithin(region, role, name) {
624
+ await driveHold(() => resolveAccessibleWithin(region, role, name), name);
625
+ }
626
+ /**
627
+ * Holds the primary pointer button on the control a resolver returns, through the browser provider.
628
+ *
629
+ * @param resolve - The resolver that returns the target, called after the held-pointer refusal.
630
+ * @param name - The target's name, as the refusals voice it.
631
+ * @returns A promise resolving after the control enters its pressed state.
632
+ * @throws Thrown when a pointer is already held, the resolver refuses, the target stays outside
633
+ * the viewport after scrolling, or the press misses. A missed press that also fails to release
634
+ * carries the release rejection as its cause.
635
+ *
636
+ * @remarks
637
+ * This is the one pointer drive every hold verb shares: the held-marker refusal, a scroll that
638
+ * brings a wholly off-viewport target into view and a refusal for one that stays outside, the
639
+ * centre mapped through the tester iframe's painted scale into page coordinates, the trusted move
640
+ * and press, the marker, a frame wait, and the `:active` read-back that releases before refusing a
641
+ * missed press. The refusal precedes resolution, so a double hold is refused before an absent
642
+ * name is.
643
+ *
644
+ * @example
645
+ * ```ts
646
+ * await driveHold(() => resolveAccessible('Apply'), 'Apply')
647
+ * await releasePointer()
648
+ * ```
649
+ */
650
+ async function driveHold(resolve, name) {
651
+ const held = document.documentElement.getAttribute(POINTER_HOLD);
652
+ if (held !== null) throw new Error(`Pointer is already held at ${held}`);
653
+ const target = resolve();
654
+ if (isOutsideViewport(target.getBoundingClientRect())) target.scrollIntoView({
655
+ block: "nearest",
656
+ behavior: "instant"
657
+ });
658
+ const box = target.getBoundingClientRect();
659
+ if (isOutsideViewport(box)) throw new Error(`Interactive target "${name}" is unreachable after scrolling`);
660
+ const frame = window.frameElement?.getBoundingClientRect();
661
+ const scale = frame === void 0 ? 1 : frame.width / window.innerWidth;
662
+ const x = (frame?.left ?? 0) + (box.left + box.width / 2) * scale;
663
+ const y = (frame?.top ?? 0) + (box.top + box.height / 2) * scale;
664
+ await sendProtocol("Input.dispatchMouseEvent", {
665
+ type: "mouseMoved",
666
+ x,
667
+ y
668
+ });
669
+ await sendProtocol("Input.dispatchMouseEvent", {
670
+ type: "mousePressed",
671
+ x,
672
+ y,
673
+ button: "left",
674
+ buttons: 1,
675
+ clickCount: 1
676
+ });
677
+ document.documentElement.setAttribute(POINTER_HOLD, `${String(x)}x${String(y)}`);
678
+ await waitForFrame();
679
+ if (!target.matches(":active")) {
680
+ try {
681
+ await releasePointer();
682
+ } catch (cause) {
683
+ throw new Error(`Interactive target "${name}" did not enter the pressed state`, { cause });
684
+ }
685
+ throw new Error(`Interactive target "${name}" did not enter the pressed state`);
686
+ }
687
+ }
688
+ /**
689
+ * Releases a held pointer and parks it at the page origin, clearing hover.
690
+ *
691
+ * @returns A promise resolving after the released pointer's frame paints.
692
+ * @throws Thrown when the release or the park rejects. If both reject, the aggregate carries the
693
+ * park rejection as its cause and the release rejection in its errors.
694
+ *
695
+ * @remarks
696
+ * An idle pointer sends no button release. Calling this after an explicit release is safe in an
697
+ * `afterEach` hook. A rejected release keeps the marker for a later retry. The pointer still moves
698
+ * to the origin in cleanup, and a rejection there joins the aggregate described under `@throws`.
699
+ * The provider must expose a DevTools session.
700
+ *
701
+ * @example
702
+ * ```ts
703
+ * await releasePointer()
704
+ * ```
705
+ */
706
+ async function releasePointer() {
707
+ const held = document.documentElement.getAttribute(POINTER_HOLD);
708
+ let released = held === null;
709
+ let rejection;
710
+ try {
711
+ if (held !== null) {
712
+ const [x, y] = held.split("x").map(Number);
713
+ await sendProtocol("Input.dispatchMouseEvent", {
714
+ type: "mouseReleased",
715
+ x,
716
+ y,
717
+ button: "left",
718
+ buttons: 0,
719
+ clickCount: 1
720
+ });
721
+ released = true;
722
+ }
723
+ } catch (cause) {
724
+ rejection = cause;
725
+ }
726
+ if (released) document.documentElement.removeAttribute(POINTER_HOLD);
727
+ try {
728
+ await sendProtocol("Input.dispatchMouseEvent", {
729
+ type: "mouseMoved",
730
+ x: 0,
731
+ y: 0
732
+ });
733
+ await waitForFrame();
734
+ } catch (cause) {
735
+ if (!released) throw new AggregateError([rejection], isError(cause) ? cause.message : String(cause), { cause });
736
+ throw cause;
737
+ }
738
+ if (!released) throw rejection;
739
+ }
740
+ /**
532
741
  * Replaces a named field's value through focus, select-all, deletion, and real keystrokes.
533
742
  *
534
743
  * @param name - The field's exact accessible name.
@@ -605,13 +814,61 @@ async function pressKeys(keys) {
605
814
  * @returns The target after the browser moves focus to it.
606
815
  * @throws When one complete traversal cannot reach the target.
607
816
  *
817
+ * @remarks
818
+ * The loop is {@link driveTraversal} over {@link resolveRendered}.
819
+ *
608
820
  * @example
609
821
  * ```ts
610
822
  * await traverseAccessible('Evaluate')
611
823
  * ```
612
824
  */
613
825
  async function traverseAccessible(name) {
614
- resolveRendered(name);
826
+ return driveTraversal(() => resolveRendered(name), name);
827
+ }
828
+ /**
829
+ * Reaches a control by role and accessible-name text inside a named region, only through natural
830
+ * forward Tab traversal from the current focus.
831
+ *
832
+ * @param region - The containing region's exact accessible name.
833
+ * @param role - The control's exact ARIA role.
834
+ * @param name - The rendered accessible-name text that identifies the control in that region.
835
+ * @returns The target after the browser moves focus to it.
836
+ * @throws When the region refuses the target, or one complete traversal cannot reach it.
837
+ *
838
+ * @remarks
839
+ * The resolution is {@link resolveAccessibleWithin} and the loop is {@link driveTraversal}, so a
840
+ * twin of the same name earlier in the tab order is passed over rather than reached.
841
+ *
842
+ * @example
843
+ * ```ts
844
+ * await traverseAccessibleWithin('Ledger', 'button', 'Evaluate')
845
+ * ```
846
+ */
847
+ async function traverseAccessibleWithin(region, role, name) {
848
+ return driveTraversal(() => resolveAccessibleWithin(region, role, name), name);
849
+ }
850
+ /**
851
+ * Reaches the control a resolver returns, only through natural forward Tab traversal from the
852
+ * current focus.
853
+ *
854
+ * @param resolve - The resolver that returns the target, called before the first step and again on
855
+ * every step, because a framework may replace the node between resolution and focus arrival.
856
+ * @param name - The target's name, as the refusal voices it.
857
+ * @returns The target after the browser moves focus to it.
858
+ * @throws When the resolver refuses, or one complete traversal cannot reach the target.
859
+ *
860
+ * @remarks
861
+ * This is the one loop every traversal verb shares. A step counts only when focus lands on an
862
+ * element, the traversal is over when focus revisits one, and the cap is counted off
863
+ * {@link FOCUSABLE_SELECTOR}.
864
+ *
865
+ * @example
866
+ * ```ts
867
+ * await driveTraversal(() => resolveRendered('Evaluate'), 'Evaluate')
868
+ * ```
869
+ */
870
+ async function driveTraversal(resolve, name) {
871
+ resolve();
615
872
  const cap = document.querySelectorAll(FOCUSABLE_SELECTOR).length * 3 + 10;
616
873
  const visited = /* @__PURE__ */ new Set();
617
874
  const trail = [];
@@ -621,7 +878,7 @@ async function traverseAccessible(name) {
621
878
  if (!(focused instanceof HTMLElement) || focused === document.body) continue;
622
879
  let current;
623
880
  try {
624
- current = resolveRendered(name);
881
+ current = resolve();
625
882
  } catch {
626
883
  continue;
627
884
  }
@@ -1271,53 +1528,317 @@ function removeDatabase(name) {
1271
1528
  });
1272
1529
  }
1273
1530
  /**
1274
- * Parses one computed CSS color value into straight sRGB channels.
1531
+ * Converts normalized encoded sRGB channels to the clipped paint scale.
1532
+ *
1533
+ * @param red - The encoded red channel, with `1` representing full intensity.
1534
+ * @param green - The encoded green channel.
1535
+ * @param blue - The encoded blue channel.
1536
+ * @param alpha - The opacity. Default: `1`.
1537
+ * @returns Frozen straight sRGB channels clipped to 0–255, with alpha clipped to 0–1.
1538
+ *
1539
+ * @example
1540
+ * ```ts
1541
+ * convertSRGB(1.2, -0.1, 0.5) // [255, 0, 127.5, 1]
1542
+ * ```
1543
+ */
1544
+ function convertSRGB(red, green, blue, alpha = 1) {
1545
+ return Object.freeze([
1546
+ Math.min(255, Math.max(0, red * 255)),
1547
+ Math.min(255, Math.max(0, green * 255)),
1548
+ Math.min(255, Math.max(0, blue * 255)),
1549
+ Math.min(1, Math.max(0, alpha))
1550
+ ]);
1551
+ }
1552
+ /**
1553
+ * Converts linear sRGB channels to encoded, clipped paint channels.
1275
1554
  *
1276
- * @param value - A computed `rgb()`, `rgba()`, or `color(srgb …)` value.
1277
- * @returns The color's channels, or `undefined` when the value names no color this reader speaks.
1555
+ * @param red - The linear red channel on the normalized scale.
1556
+ * @param green - The linear green channel.
1557
+ * @param blue - The linear blue channel.
1558
+ * @param alpha - The opacity. Default: `1`.
1559
+ * @returns Frozen straight sRGB channels clipped to 0–255, with alpha clipped to 0–1.
1278
1560
  *
1279
1561
  * @remarks
1280
- * A computed color resolves to `rgb()` or `rgba()` for every legacy source, and a `color-mix()`
1281
- * declaration resolves to `color(srgb r g b [/ a])` with channels on the 0–1 scale. Both forms are
1282
- * read here and nothing else is: a keyword, a hex triple, an empty string from a detached element,
1283
- * and a color space the cascade never hands back all return `undefined`. Absence is the answer
1284
- * rather than a transparent color, so a caller decides what an unreadable value means instead of
1285
- * measuring a black it never saw.
1562
+ * Applies the CSS Color 4 extended sRGB transfer function before clipping.
1286
1563
  *
1287
1564
  * @example
1288
1565
  * ```ts
1289
- * parseColor('rgba(255, 255, 255, 0.5)') // [255, 255, 255, 0.5]
1290
- * parseColor('rebeccapurple') // undefined
1566
+ * convertLinearSRGB(0, 0, 0) // [0, 0, 0, 1]
1291
1567
  * ```
1292
1568
  */
1293
- function parseColor(value) {
1294
- const modern = /^color\(srgb\s+(?<red>[\d.]+)\s+(?<green>[\d.]+)\s+(?<blue>[\d.]+)(?:\s*\/\s*(?<alpha>[\d.]+))?\)$/u.exec(value);
1295
- const legacy = /^rgba?\((?<channels>[^)]*)\)$/u.exec(value);
1296
- const [red, green, blue, alpha = 1] = modern?.groups === void 0 ? (legacy?.groups?.channels ?? "").split(/[\s,/]+/u).filter((part) => part.length > 0).map((part) => Number.parseFloat(part)) : [
1297
- Number.parseFloat(modern.groups.red ?? "") * 255,
1298
- Number.parseFloat(modern.groups.green ?? "") * 255,
1299
- Number.parseFloat(modern.groups.blue ?? "") * 255,
1300
- modern.groups.alpha === void 0 ? 1 : Number.parseFloat(modern.groups.alpha)
1301
- ];
1302
- if (red === void 0 || green === void 0 || blue === void 0) return void 0;
1303
- if (![
1569
+ function convertLinearSRGB(red, green, blue, alpha = 1) {
1570
+ const [encodedRed = 0, encodedGreen = 0, encodedBlue = 0] = [
1304
1571
  red,
1305
1572
  green,
1306
- blue,
1307
- alpha
1308
- ].every((channel) => Number.isFinite(channel))) return void 0;
1309
- return Object.freeze([
1573
+ blue
1574
+ ].map((channel) => Math.abs(channel) <= .0031308 ? 12.92 * channel : Math.sign(channel) * (1.055 * Math.abs(channel) ** (1 / 2.4) - .055));
1575
+ return convertSRGB(encodedRed, encodedGreen, encodedBlue, alpha);
1576
+ }
1577
+ /**
1578
+ * Converts D65 XYZ coordinates to clipped sRGB paint channels.
1579
+ *
1580
+ * @param x - The normalized X coordinate.
1581
+ * @param y - The normalized Y coordinate.
1582
+ * @param z - The normalized Z coordinate.
1583
+ * @param alpha - The opacity. Default: `1`.
1584
+ * @returns Frozen straight sRGB channels clipped to 0–255, with alpha clipped to 0–1.
1585
+ *
1586
+ * @remarks
1587
+ * Uses the CSS Color 4 XYZ D65 to linear sRGB matrix, then the sRGB transfer function.
1588
+ *
1589
+ * @example
1590
+ * ```ts
1591
+ * convertXYZD65(0, 0, 0) // [0, 0, 0, 1]
1592
+ * ```
1593
+ */
1594
+ function convertXYZD65(x, y, z, alpha = 1) {
1595
+ return convertLinearSRGB(12831 / 3959 * x - 329 / 214 * y - 1974 / 3959 * z, -851781 / 878810 * x + 1648619 / 878810 * y + 36519 / 878810 * z, 705 / 12673 * x - 2585 / 12673 * y + 705 / 667 * z, alpha);
1596
+ }
1597
+ /**
1598
+ * Converts D50 XYZ coordinates to clipped sRGB paint channels.
1599
+ *
1600
+ * @param x - The normalized X coordinate relative to D50.
1601
+ * @param y - The normalized Y coordinate relative to D50.
1602
+ * @param z - The normalized Z coordinate relative to D50.
1603
+ * @param alpha - The opacity. Default: `1`.
1604
+ * @returns Frozen straight sRGB channels clipped to 0–255, with alpha clipped to 0–1.
1605
+ *
1606
+ * @remarks
1607
+ * Applies the CSS Color 4 Bradford adaptation from D50 to D65 before converting to sRGB.
1608
+ *
1609
+ * @example
1610
+ * ```ts
1611
+ * convertXYZD50(0, 0, 0) // [0, 0, 0, 1]
1612
+ * ```
1613
+ */
1614
+ function convertXYZD50(x, y, z, alpha = 1) {
1615
+ return convertXYZD65(.955473421488075 * x - .02309845494876471 * y + .06325924320057072 * z, -.0283697093338637 * x + 1.0099953980813041 * y + .021041441191917323 * z, .012314014864481998 * x - .020507649298898964 * y + 1.330365926242124 * z, alpha);
1616
+ }
1617
+ /**
1618
+ * Converts OKLab coordinates to clipped sRGB paint channels.
1619
+ *
1620
+ * @param lightness - The OKLab lightness on the 0–1 scale.
1621
+ * @param a - The signed green-to-red axis.
1622
+ * @param b - The signed blue-to-yellow axis.
1623
+ * @param alpha - The opacity. Default: `1`.
1624
+ * @returns Frozen straight sRGB channels clipped to 0–255, with alpha clipped to 0–1.
1625
+ *
1626
+ * @remarks
1627
+ * Cubes the transformed cone responses before applying the linear sRGB matrix.
1628
+ *
1629
+ * @example
1630
+ * ```ts
1631
+ * convertOKLab(0, 0, 0) // [0, 0, 0, 1]
1632
+ * ```
1633
+ */
1634
+ function convertOKLab(lightness, a, b, alpha = 1) {
1635
+ const long = (lightness + .3963377773761749 * a + .2158037573099136 * b) ** 3;
1636
+ const medium = (lightness - .1055613458156586 * a - .0638541728258133 * b) ** 3;
1637
+ const short = (lightness - .0894841775298119 * a - 1.2914855480194092 * b) ** 3;
1638
+ return convertLinearSRGB(4.076741661347994 * long - 3.307711590408193 * medium + .230969929060199 * short, -1.2684380040921763 * long + 2.6097574006633715 * medium - .3413193965711952 * short, -.0041960865418371 * long - .7034186144594493 * medium + 1.7076147010012863 * short, alpha);
1639
+ }
1640
+ /**
1641
+ * Converts CIE Lab coordinates relative to D50 to clipped sRGB paint channels.
1642
+ *
1643
+ * @param lightness - The CIE lightness on the 0–100 scale.
1644
+ * @param a - The signed green-to-red axis.
1645
+ * @param b - The signed blue-to-yellow axis.
1646
+ * @param alpha - The opacity. Default: `1`.
1647
+ * @returns Frozen straight sRGB channels clipped to 0–255, with alpha clipped to 0–1.
1648
+ *
1649
+ * @remarks
1650
+ * Resolves the CSS Color 4 Lab curve against the D50 white point before adapting to D65.
1651
+ *
1652
+ * @example
1653
+ * ```ts
1654
+ * convertLab(0, 0, 0) // [0, 0, 0, 1]
1655
+ * ```
1656
+ */
1657
+ function convertLab(lightness, a, b, alpha = 1) {
1658
+ const luminance = (lightness + 16) / 116;
1659
+ const [x = 0, y = 0, z = 0] = [
1660
+ luminance + a / 500,
1661
+ luminance,
1662
+ luminance - b / 200
1663
+ ].map((channel) => channel ** 3 > 216 / 24389 ? channel ** 3 : (116 * channel - 16) / (24389 / 27));
1664
+ return convertXYZD50(x * (.3457 / .3585), y, z * (.2958 / .3585), alpha);
1665
+ }
1666
+ /**
1667
+ * Converts encoded Display P3 channels to clipped sRGB paint channels.
1668
+ *
1669
+ * @param red - The normalized encoded red channel.
1670
+ * @param green - The normalized encoded green channel.
1671
+ * @param blue - The normalized encoded blue channel.
1672
+ * @param alpha - The opacity. Default: `1`.
1673
+ * @returns Frozen straight sRGB channels clipped to 0–255, with alpha clipped to 0–1.
1674
+ *
1675
+ * @remarks
1676
+ * Decodes the extended sRGB transfer curve and uses the CSS Color 4 P3 to XYZ D65 matrix.
1677
+ *
1678
+ * @example
1679
+ * ```ts
1680
+ * convertDisplayP3(0, 0, 0) // [0, 0, 0, 1]
1681
+ * ```
1682
+ */
1683
+ function convertDisplayP3(red, green, blue, alpha = 1) {
1684
+ const [r = 0, g = 0, b = 0] = [
1310
1685
  red,
1311
1686
  green,
1312
- blue,
1313
- alpha
1314
- ]);
1687
+ blue
1688
+ ].map((channel) => Math.abs(channel) <= .04045 ? channel / 12.92 : Math.sign(channel) * ((Math.abs(channel) + .055) / 1.055) ** 2.4);
1689
+ return convertXYZD65(608311 / 1250200 * r + 189793 / 714400 * g + 198249 / 1000160 * b, 35783 / 156275 * r + 247089 / 357200 * g + 198249 / 2500400 * b, 32229 / 714400 * g + 5220557 / 5000800 * b, alpha);
1690
+ }
1691
+ /**
1692
+ * Converts encoded A98 RGB channels to clipped sRGB paint channels.
1693
+ *
1694
+ * @param red - The normalized encoded red channel.
1695
+ * @param green - The normalized encoded green channel.
1696
+ * @param blue - The normalized encoded blue channel.
1697
+ * @param alpha - The opacity. Default: `1`.
1698
+ * @returns Frozen straight sRGB channels clipped to 0–255, with alpha clipped to 0–1.
1699
+ *
1700
+ * @remarks
1701
+ * Decodes the signed 563/256 power curve and uses the CSS Color 4 A98 RGB to XYZ D65 matrix.
1702
+ *
1703
+ * @example
1704
+ * ```ts
1705
+ * convertA98RGB(0, 0, 0) // [0, 0, 0, 1]
1706
+ * ```
1707
+ */
1708
+ function convertA98RGB(red, green, blue, alpha = 1) {
1709
+ const [r = 0, g = 0, b = 0] = [
1710
+ red,
1711
+ green,
1712
+ blue
1713
+ ].map((channel) => Math.sign(channel) * Math.abs(channel) ** (563 / 256));
1714
+ return convertXYZD65(573536 / 994567 * r + 263643 / 1420810 * g + 187206 / 994567 * b, 591459 / 1989134 * r + 6239551 / 9945670 * g + 374412 / 4972835 * b, 53769 / 1989134 * r + 351524 / 4972835 * g + 4929758 / 4972835 * b, alpha);
1715
+ }
1716
+ /**
1717
+ * Converts encoded ProPhoto RGB channels to clipped sRGB paint channels.
1718
+ *
1719
+ * @param red - The normalized encoded red channel.
1720
+ * @param green - The normalized encoded green channel.
1721
+ * @param blue - The normalized encoded blue channel.
1722
+ * @param alpha - The opacity. Default: `1`.
1723
+ * @returns Frozen straight sRGB channels clipped to 0–255, with alpha clipped to 0–1.
1724
+ *
1725
+ * @remarks
1726
+ * Decodes the signed ProPhoto curve, converts to XYZ D50, and adapts to D65 before clipping.
1727
+ *
1728
+ * @example
1729
+ * ```ts
1730
+ * convertProPhotoRGB(0, 0, 0) // [0, 0, 0, 1]
1731
+ * ```
1732
+ */
1733
+ function convertProPhotoRGB(red, green, blue, alpha = 1) {
1734
+ const [r = 0, g = 0, b = 0] = [
1735
+ red,
1736
+ green,
1737
+ blue
1738
+ ].map((channel) => Math.abs(channel) <= 16 / 512 ? channel / 16 : Math.sign(channel) * Math.abs(channel) ** 1.8);
1739
+ return convertXYZD50(.7977666449006423 * r + .13518129740053308 * g + .0313477341283922 * b, .2880748288194013 * r + .711835234241873 * g + 8993693872564e-17 * b, .8251046025104602 * b, alpha);
1740
+ }
1741
+ /**
1742
+ * Converts encoded Rec. 2020 channels to clipped sRGB paint channels.
1743
+ *
1744
+ * @param red - The normalized encoded red channel.
1745
+ * @param green - The normalized encoded green channel.
1746
+ * @param blue - The normalized encoded blue channel.
1747
+ * @param alpha - The opacity. Default: `1`.
1748
+ * @returns Frozen straight sRGB channels clipped to 0–255, with alpha clipped to 0–1.
1749
+ *
1750
+ * @remarks
1751
+ * Uses the piecewise Rec. 2020 transfer curve Chromium computes and the CSS Color 4 XYZ matrix.
1752
+ *
1753
+ * @example
1754
+ * ```ts
1755
+ * convertRec2020(0, 0, 0) // [0, 0, 0, 1]
1756
+ * ```
1757
+ */
1758
+ function convertRec2020(red, green, blue, alpha = 1) {
1759
+ const [r = 0, g = 0, b = 0] = [
1760
+ red,
1761
+ green,
1762
+ blue
1763
+ ].map((channel) => Math.abs(channel) < .08124285829863151 ? channel / 4.5 : Math.sign(channel) * ((Math.abs(channel) + 1.09929682680944 - 1) / 1.09929682680944) ** (1 / .45));
1764
+ return convertXYZD65(63426534 / 99577255 * r + 20160776 / 139408157 * g + 47086771 / 278816314 * b, 26158966 / 99577255 * r + 472592308 / 697040785 * g + 8267143 / 139408157 * b, 19567812 / 697040785 * g + 295819943 / 278816314 * b, alpha);
1765
+ }
1766
+ /**
1767
+ * Parses computed CSS Color 4 values into clipped straight sRGB channels.
1768
+ *
1769
+ * @param value - A computed `rgb()`, `rgba()`, `oklab()`, `oklch()`, `lab()`, `lch()`, or `color()` value.
1770
+ * @returns Frozen channels, or `undefined` for an unsupported syntax or non-finite channel.
1771
+ *
1772
+ * @remarks
1773
+ * Reads signed channels, scientific notation, percentage lightness, degree hues, and `none` as
1774
+ * zero. The `color()` spaces are `srgb`, `srgb-linear`, `display-p3`, `a98-rgb`, `prophoto-rgb`,
1775
+ * `rec2020`, `xyz`, `xyz-d50`, and `xyz-d65`. Conversion uses the CSS Color 4 matrices and white
1776
+ * point adaptation, then clips each encoded sRGB channel to 0–255 rather than gamut-mapping it.
1777
+ * Alpha is clipped to 0–1. Keywords, hex colors, unresolved expressions, and non-finite calculations
1778
+ * such as `color(srgb calc(infinity) 0 0)` remain unreadable; use {@link parseCSSColor} to resolve
1779
+ * ordinary authored expressions through the cascade.
1780
+ *
1781
+ * @example
1782
+ * ```ts
1783
+ * parseColor('rgba(255, 255, 255, 0.5)') // [255, 255, 255, 0.5]
1784
+ * parseColor('rebeccapurple') // undefined
1785
+ * ```
1786
+ */
1787
+ function parseColor(value) {
1788
+ const match = /^(?<syntax>rgba?|oklab|oklch|lab|lch|color)\((?<body>[^()]*)\)$/u.exec(value);
1789
+ if (match?.groups === void 0) return void 0;
1790
+ const { syntax, body = "" } = match.groups;
1791
+ const legacy = syntax === "rgb" || syntax === "rgba";
1792
+ const polar = syntax === "oklch" || syntax === "lch";
1793
+ const perceptual = syntax === "oklab" || syntax === "oklch" || syntax === "lab" || syntax === "lch";
1794
+ const tokens = body.trim().split(legacy && body.includes(",") ? /\s*,\s*/u : /\s*\/\s*|\s+/u);
1795
+ const space = syntax === "color" ? tokens.shift() : syntax;
1796
+ if (tokens.length !== 3 && tokens.length !== 4) return void 0;
1797
+ if (!legacy && body.includes(",")) return void 0;
1798
+ if (body.split("/").length > 2) return void 0;
1799
+ if (body.includes("/") && (tokens.length !== 4 || !/\/\s*[^\s/]+\s*$/u.test(body))) return void 0;
1800
+ if (!(legacy && body.includes(",")) && tokens.length === 4 && !body.includes("/")) return void 0;
1801
+ const parts = tokens.map((token, index) => {
1802
+ if (token === "none") return 0;
1803
+ const part = /^(?<number>[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)(?<unit>%|deg)?$/u.exec(token);
1804
+ if (part?.groups === void 0) return NaN;
1805
+ const number = Number(part.groups.number);
1806
+ const unit = part.groups.unit;
1807
+ if (unit === "deg") return polar && index === 2 ? number : NaN;
1808
+ if (unit !== "%") return number;
1809
+ if (index === 3 || syntax === "color") return number / 100;
1810
+ if (legacy) return number * 255 / 100;
1811
+ if (index === 0) return syntax === "oklab" || syntax === "oklch" ? number / 100 : number;
1812
+ return NaN;
1813
+ });
1814
+ const [first, second, third, alpha = 1] = parts;
1815
+ if (first === void 0 || second === void 0 || third === void 0) return void 0;
1816
+ if (!parts.every((part) => Number.isFinite(part))) return void 0;
1817
+ if (legacy) return convertSRGB(first / 255, second / 255, third / 255, alpha);
1818
+ if (perceptual) {
1819
+ const lightness = Math.max(0, Math.min(syntax === "oklab" || syntax === "oklch" ? 1 : 100, first));
1820
+ const a = polar ? Math.max(0, second) * Math.cos(third * Math.PI / 180) : second;
1821
+ const b = polar ? Math.max(0, second) * Math.sin(third * Math.PI / 180) : third;
1822
+ return syntax === "oklab" || syntax === "oklch" ? convertOKLab(lightness, a, b, alpha) : convertLab(lightness, a, b, alpha);
1823
+ }
1824
+ switch (space) {
1825
+ case "srgb": return convertSRGB(first, second, third, alpha);
1826
+ case "srgb-linear": return convertLinearSRGB(first, second, third, alpha);
1827
+ case "display-p3": return convertDisplayP3(first, second, third, alpha);
1828
+ case "a98-rgb": return convertA98RGB(first, second, third, alpha);
1829
+ case "prophoto-rgb": return convertProPhotoRGB(first, second, third, alpha);
1830
+ case "rec2020": return convertRec2020(first, second, third, alpha);
1831
+ case "xyz":
1832
+ case "xyz-d65": return convertXYZD65(first, second, third, alpha);
1833
+ case "xyz-d50": return convertXYZD50(first, second, third, alpha);
1834
+ default: return;
1835
+ }
1315
1836
  }
1316
1837
  /**
1317
1838
  * Resolves any CSS color expression to straight sRGB channels, by asking the browser.
1318
1839
  *
1319
1840
  * @param value - Any value the `color` property accepts: a keyword, a hex triple, a `var()`
1320
- * reference, a `color-mix()`, or an already-computed `rgb()`.
1841
+ * reference, a `color-mix()`, or a computed CSS Color 4 value.
1321
1842
  * @returns The resolved color's channels, or `undefined` when the CSSOM refuses the value or the
1322
1843
  * computed result names no color {@link parseColor} speaks.
1323
1844
  *
@@ -1327,6 +1848,8 @@ function parseColor(value) {
1327
1848
  * cascade, and reads back what the engine computed — which is the only way a keyword, a hex triple,
1328
1849
  * or a `var()` reference becomes channels at all. The read itself goes through `parseColor`, so both
1329
1850
  * halves agree on what a computed value means.
1851
+ * Modern perceptual and predefined RGB/XYZ spaces resolve through that parser's conversions;
1852
+ * out-of-gamut sRGB channels are clipped to 0–255 after conversion.
1330
1853
  *
1331
1854
  * The probe is mounted, because an unmounted element inherits nothing and a `var()` reference to a
1332
1855
  * token declared on `:root` would resolve to the initial value instead. It is removed in a `finally`,
@@ -1454,16 +1977,23 @@ function measureContrast(front, back) {
1454
1977
  return (bright + .05) / (dark + .05);
1455
1978
  }
1456
1979
  /**
1457
- * Collects the painted layers standing between one element and the surface it sits on.
1980
+ * Collects readable background color layers and refuses an unreadable painted layer.
1458
1981
  *
1459
1982
  * @param element - The element to walk up from.
1460
1983
  * @returns Every layer the walk paints, the element's own first and the deepest last.
1984
+ * @throws Thrown when a painted background color is unreadable; the error names the element and
1985
+ * its computed value.
1461
1986
  *
1462
1987
  * @remarks
1463
1988
  * A surface token paints one ancestor while every element between it and the text paints nothing,
1464
1989
  * so a backdrop is found by walking up rather than by reading the element's own `background-color`,
1465
1990
  * which is almost always transparent. A fully transparent layer paints nothing and is left out, and
1466
1991
  * the walk stops at the first fully opaque layer, because nothing above that layer is visible.
1992
+ * Legacy and modern CSS Color 4 values resolve through {@link parseColor}, with sRGB channels
1993
+ * clipped after conversion. Non-finite calculations such as `color(srgb calc(infinity) 0 0)` are
1994
+ * deliberately unreadable. An unreadable value with an explicit zero alpha paints nothing and is
1995
+ * skipped. An empty detached-element reading paints nothing too. Background images remain outside
1996
+ * this color reader.
1467
1997
  *
1468
1998
  * The stack is what tells a resolved backdrop from an assumed one: the walk reached an opaque
1469
1999
  * surface exactly when its last layer's alpha is `1`. {@link readContrast} refuses on that reading,
@@ -1479,8 +2009,14 @@ function measureContrast(front, back) {
1479
2009
  function readLayers(element) {
1480
2010
  const layers = [];
1481
2011
  for (let node = element; node !== null; node = node.parentElement) {
1482
- const layer = parseColor(getComputedStyle(node).backgroundColor);
1483
- if (layer === void 0 || layer[3] === 0) continue;
2012
+ const computed = getComputedStyle(node).backgroundColor;
2013
+ if (computed === "") continue;
2014
+ const layer = parseColor(computed);
2015
+ if (layer === void 0) {
2016
+ if (/\/\s*(?:0(?:\.0*)?|none)\s*\)$/u.test(computed)) continue;
2017
+ throw new Error(`Computed background color is unreadable on ${node.localName}${node.id === "" ? "" : `#${node.id}`}: ${computed}`);
2018
+ }
2019
+ if (layer[3] === 0) continue;
1484
2020
  layers.push(layer);
1485
2021
  if (layer[3] >= 1) break;
1486
2022
  }
@@ -1492,10 +2028,13 @@ function readLayers(element) {
1492
2028
  * @param element - The element whose backdrop to resolve.
1493
2029
  * @param floor - The opaque color the walk ends on when nothing above it paints.
1494
2030
  * @returns The composited color a reader sees behind the element.
2031
+ * @throws Thrown when the backdrop contains an unreadable painted color layer.
1495
2032
  *
1496
2033
  * @remarks
1497
2034
  * The layers {@link readLayers} collects composite top-over-bottom onto the floor, so a 3% surface
1498
2035
  * tint reads as a tint over what shows through it rather than as a full-strength paint.
2036
+ * Those layers include the modern CSS Color 4 spaces, converted and clipped to sRGB by
2037
+ * {@link parseColor}; an unreadable painted layer refuses the entire reading.
1499
2038
  *
1500
2039
  * The floor is required, because this leaf never guesses what a document sits on. Pass
1501
2040
  * {@link CANVAS_COLOR} for the page a browser paints behind an unstyled document, or the color of
@@ -1521,7 +2060,8 @@ function readBackdrop(element, floor) {
1521
2060
  * would show through instead of assuming one.
1522
2061
  * @returns The relative-luminance contrast ratio.
1523
2062
  * @throws Thrown when the element exposes no computed foreground color, and — with `floor` omitted
1524
- * — when the walk from the element upwards reaches no opaque layer.
2063
+ * — when the walk from the element upwards reaches no opaque layer. An unreadable painted
2064
+ * background layer throws even when a floor is supplied.
1525
2065
  *
1526
2066
  * @remarks
1527
2067
  * A transparent or translucent background resolves through the element's ancestors: every painted
@@ -1529,6 +2069,8 @@ function readBackdrop(element, floor) {
1529
2069
  * base, so a 3% surface tint reads as a tint over what shows through it rather than as a
1530
2070
  * full-strength paint. A translucent foreground then resolves against that effective background
1531
2071
  * before luminance is measured.
2072
+ * Text and background colors can use the modern CSS Color 4 spaces {@link parseColor} reads;
2073
+ * each is converted to sRGB and clipped before composition and luminance measurement.
1532
2074
  *
1533
2075
  * With `floor` omitted, the walk from the target upwards must reach a fully opaque layer: the
1534
2076
  * measurement throws rather than assuming a white canvas wherever that canvas would still be part
@@ -1547,7 +2089,7 @@ function readBackdrop(element, floor) {
1547
2089
  * ```ts
1548
2090
  * const container = render('<p style="background: #000; color: #fff">Ready</p>')
1549
2091
  * readContrast(requireValue(container.firstElementChild)) // 21
1550
- * readContrast(requireValue(container.firstElementChild), CANVAS_COLOR) // 21, and never refuses
2092
+ * readContrast(requireValue(container.firstElementChild), CANVAS_COLOR) // 21
1551
2093
  * ```
1552
2094
  */
1553
2095
  function readContrast(element, floor) {
@@ -1566,6 +2108,7 @@ function readContrast(element, floor) {
1566
2108
  * @param worn - The element the control's focus chrome is painted onto. Default: `control`.
1567
2109
  * @returns The strongest ratio the painted focus chrome reaches, or `undefined` when the control is
1568
2110
  * not showing `:focus-visible` or the cascade paints no chrome of its own.
2111
+ * @throws Thrown when the focused control's backdrop contains an unreadable painted color layer.
1569
2112
  *
1570
2113
  * @remarks
1571
2114
  * This reads and never acts. Focus arrives through the published verbs — `traverseAccessible`,
@@ -1589,6 +2132,9 @@ function readContrast(element, floor) {
1589
2132
  * color names neither. A focus style that only changes the control's own fill reports `undefined`
1590
2133
  * too: the resting fill is gone by the time focus is on the control, and this never moves focus to
1591
2134
  * go and read it.
2135
+ * Outline and shadow colors include `oklch()`, `oklab()`, `lab()`, `lch()`, and predefined
2136
+ * `color()` spaces. Each readable color converts to clipped sRGB through {@link parseColor}, and
2137
+ * the result remains a contrast ratio rather than a ring width.
1592
2138
  *
1593
2139
  * @example
1594
2140
  * ```ts
@@ -1602,7 +2148,7 @@ function readRing(control, worn) {
1602
2148
  const declared = getComputedStyle(target);
1603
2149
  const backdrop = readBackdrop(target.parentElement ?? target, CANVAS_COLOR);
1604
2150
  const outline = declared.outlineStyle === "none" || declared.outlineStyle === "auto" || Number.parseFloat(declared.outlineWidth) === 0 ? void 0 : parseColor(declared.outlineColor);
1605
- const shadow = parseColor(/(?:rgba?|color)\([^)]*\)/u.exec(declared.boxShadow)?.[0] ?? "");
2151
+ const shadow = parseColor(/(?:rgba?|color|oklab|oklch|lab|lch)\([^)]*\)/u.exec(declared.boxShadow)?.[0] ?? "");
1606
2152
  const ratios = [];
1607
2153
  for (const painted of [outline, shadow]) {
1608
2154
  if (painted === void 0) continue;
@@ -1882,12 +2428,14 @@ function extractStyles(root) {
1882
2428
  return styled;
1883
2429
  }
1884
2430
  /**
1885
- * Reads one resolved CSS property from a real browser element.
2431
+ * Reads one resolved CSS property from a real browser element or a named pseudo-element.
1886
2432
  *
1887
2433
  * @param element - The element whose resolved style to inspect.
1888
2434
  * @param property - The CSS property name, registered or custom.
2435
+ * @param pseudo - The pseudo-element selector. Omit it to read the element itself.
1889
2436
  * @returns The browser's resolved property value, trimmed; an empty string when the element resolves
1890
2437
  * none.
2438
+ * @throws Thrown when the pseudo argument lacks the `::` prefix or the engine does not support it.
1891
2439
  *
1892
2440
  * @remarks
1893
2441
  * The value is trimmed, so what comes back is the value and never the whitespace around it. Internal
@@ -1898,8 +2446,12 @@ function extractStyles(root) {
1898
2446
  * readStyle(button, 'padding-left')
1899
2447
  * ```
1900
2448
  */
1901
- function readStyle(element, property) {
1902
- return getComputedStyle(element).getPropertyValue(property).trim();
2449
+ function readStyle(element, property, pseudo) {
2450
+ if (pseudo !== void 0) {
2451
+ if (!pseudo.startsWith("::")) throw new Error(`Pseudo-element "${pseudo}" must start with "::"`);
2452
+ if (!CSS.supports(`selector(${pseudo})`)) throw new Error(`Pseudo-element "${pseudo}" is not one this engine exposes`);
2453
+ }
2454
+ return getComputedStyle(element, pseudo).getPropertyValue(property).trim();
1903
2455
  }
1904
2456
  /**
1905
2457
  * Reads one custom property from an element's resolved style.
@@ -1953,7 +2505,9 @@ function readRootToken(name) {
1953
2505
  *
1954
2506
  * @param element - The element whose resolved style to inspect.
1955
2507
  * @param property - The CSS property name, registered or custom.
2508
+ * @param pseudo - The pseudo-element selector. Omit it to read the element itself.
1956
2509
  * @returns The leading numeric part of the resolved value, and `0` when it carries none.
2510
+ * @throws Thrown when the pseudo argument lacks the `::` prefix or the engine does not support it.
1957
2511
  *
1958
2512
  * @remarks
1959
2513
  * A resolved length is text with a unit — `'12px'` — so this reads the number in front of the unit
@@ -1971,8 +2525,8 @@ function readRootToken(name) {
1971
2525
  * readPixels(button, 'width') // 0 when the width resolves to `auto`
1972
2526
  * ```
1973
2527
  */
1974
- function readPixels(element, property) {
1975
- const measured = Number.parseFloat(readStyle(element, property));
2528
+ function readPixels(element, property, pseudo) {
2529
+ const measured = Number.parseFloat(readStyle(element, property, pseudo));
1976
2530
  return Number.isFinite(measured) ? measured : 0;
1977
2531
  }
1978
2532
  /**
@@ -2126,6 +2680,165 @@ async function releasePane() {
2126
2680
  if (Number.isFinite(width) && Number.isFinite(height)) await page.viewport(width, height);
2127
2681
  }
2128
2682
  /**
2683
+ * Stages the tester's print medium, motion preference, and forced colours through the browser
2684
+ * provider.
2685
+ *
2686
+ * @param options - The media axes to override.
2687
+ * @returns A promise resolving after a bounded read-back for `print: true`, either `motion` value,
2688
+ * and either `forced` value. A `print: false` stage is sent and followed by a frame wait without a
2689
+ * read-back.
2690
+ * @throws Thrown when no axis is supplied or a staged query does not reach the tester.
2691
+ *
2692
+ * @remarks
2693
+ * If `print` is true, uses print; if false, uses screen. If `motion` is true, uses no preference;
2694
+ * if false, uses reduced motion. If `forced` is true, uses active forced colours; if false, uses
2695
+ * none. The print medium, reduced motion, colour scheme, and forced colours keep their effective
2696
+ * readings when omitted. Any other emulated feature the provider configured
2697
+ * is cleared. Each staged query waits up to 1000 milliseconds, polling every 10 milliseconds.
2698
+ * A refused read-back restores the carried pre-call readings before throwing and can take two
2699
+ * budgets. A restoration failure is attached as the refusal's cause. Register
2700
+ * {@link releaseMedia} in teardown; it restores the readings observed before the first stage.
2701
+ *
2702
+ * @example
2703
+ * ```ts
2704
+ * await stageMedia({ motion: false, print: true })
2705
+ * await releaseMedia()
2706
+ * ```
2707
+ */
2708
+ async function stageMedia(options) {
2709
+ const print = options.print;
2710
+ const motion = options.motion;
2711
+ const forced = options.forced;
2712
+ if (print === void 0 && motion === void 0 && forced === void 0) throw new Error("Media emulation was staged with nothing to emulate");
2713
+ const media = matchMedia("print").matches ? "print" : "screen";
2714
+ const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
2715
+ const dark = matchMedia("(prefers-color-scheme: dark)").matches;
2716
+ const active = matchMedia("(forced-colors: active)").matches;
2717
+ if (!document.documentElement.hasAttribute("data-media-stage")) document.documentElement.setAttribute(MEDIA_STAGE, [
2718
+ media === "print",
2719
+ reduced,
2720
+ dark,
2721
+ active
2722
+ ].map(Number).join(""));
2723
+ const features = [
2724
+ {
2725
+ name: "prefers-color-scheme",
2726
+ value: dark ? "dark" : "light"
2727
+ },
2728
+ {
2729
+ name: "forced-colors",
2730
+ value: active ? "active" : "none"
2731
+ },
2732
+ {
2733
+ name: "prefers-reduced-motion",
2734
+ value: reduced ? "reduce" : "no-preference"
2735
+ }
2736
+ ];
2737
+ const queries = [];
2738
+ if (print === true) queries.push("print");
2739
+ if (motion !== void 0) queries.push(`(prefers-reduced-motion: ${motion ? "no-preference" : "reduce"})`);
2740
+ if (forced !== void 0) queries.push(`(forced-colors: ${forced ? "active" : "none"})`);
2741
+ await sendProtocol("Emulation.setEmulatedMedia", {
2742
+ media: print === void 0 ? media : print ? "print" : "screen",
2743
+ features: features.map((feature) => feature.name === "prefers-reduced-motion" && motion !== void 0 ? {
2744
+ name: feature.name,
2745
+ value: motion ? "no-preference" : "reduce"
2746
+ } : feature.name === "forced-colors" && forced !== void 0 ? {
2747
+ name: feature.name,
2748
+ value: forced ? "active" : "none"
2749
+ } : feature)
2750
+ });
2751
+ await waitForFrame();
2752
+ for (const query of queries) try {
2753
+ await waitForCondition(query, () => matchMedia(query).matches);
2754
+ } catch (cause) {
2755
+ try {
2756
+ await sendProtocol("Emulation.setEmulatedMedia", {
2757
+ media,
2758
+ features
2759
+ });
2760
+ await waitForCondition("pre-call media readings restored", () => matchMedia(media).matches && features.every((feature) => matchMedia(`(${feature.name}: ${feature.value})`).matches));
2761
+ } catch (restoration) {
2762
+ throw new Error(`Media emulation did not reach the tester: ${query}`, { cause: restoration });
2763
+ }
2764
+ throw new Error(`Media emulation did not reach the tester: ${query}`, { cause });
2765
+ }
2766
+ }
2767
+ /**
2768
+ * Restores the media readings observed before the first stage as explicit emulation. With nothing
2769
+ * staged, clears every override and waits for a stable reading, not a proved engine baseline. Checks
2770
+ * the budget between polls, so a frame that never paints is not bounded by it.
2771
+ *
2772
+ * @returns A promise resolving after the media readings settle.
2773
+ * @throws Thrown when a media read-back exhausts its 1000 millisecond budget between polls.
2774
+ *
2775
+ * @remarks
2776
+ * Restores print, reduced motion, colour scheme, and forced colours from {@link MEDIA_STAGE},
2777
+ * waits per axis for the recorded value, and removes the marker after every axis agrees. With no
2778
+ * marker, sends the empty reset and compares readings taken strictly after that send. Each poll
2779
+ * waits for a frame; the interval is 10 milliseconds. The provider must expose a DevTools session.
2780
+ *
2781
+ * @example
2782
+ * ```ts
2783
+ * await releaseMedia()
2784
+ * ```
2785
+ */
2786
+ async function releaseMedia() {
2787
+ const queries = [
2788
+ "print",
2789
+ "(prefers-reduced-motion: reduce)",
2790
+ "(prefers-color-scheme: dark)",
2791
+ "(forced-colors: active)"
2792
+ ];
2793
+ const staged = document.documentElement.getAttribute(MEDIA_STAGE);
2794
+ if (staged !== null) {
2795
+ const readings = staged.split("").map((value) => value === "1");
2796
+ await sendProtocol("Emulation.setEmulatedMedia", {
2797
+ media: readings[0] ? "print" : "screen",
2798
+ features: [
2799
+ {
2800
+ name: "prefers-reduced-motion",
2801
+ value: readings[1] ? "reduce" : "no-preference"
2802
+ },
2803
+ {
2804
+ name: "prefers-color-scheme",
2805
+ value: readings[2] ? "dark" : "light"
2806
+ },
2807
+ {
2808
+ name: "forced-colors",
2809
+ value: readings[3] ? "active" : "none"
2810
+ }
2811
+ ]
2812
+ });
2813
+ try {
2814
+ await Promise.all(queries.map((query, index) => waitForCondition(query, async () => {
2815
+ await waitForFrame();
2816
+ return matchMedia(query).matches === readings[index];
2817
+ })));
2818
+ } catch (cause) {
2819
+ throw new Error("Media emulation did not clear from the tester", { cause });
2820
+ }
2821
+ document.documentElement.removeAttribute(MEDIA_STAGE);
2822
+ return;
2823
+ }
2824
+ await sendProtocol("Emulation.setEmulatedMedia", {
2825
+ media: "",
2826
+ features: []
2827
+ });
2828
+ let previous = queries.map((query) => matchMedia(query).matches);
2829
+ try {
2830
+ await waitForCondition("media emulation cleared", async () => {
2831
+ await waitForFrame();
2832
+ const readings = queries.map((query) => matchMedia(query).matches);
2833
+ const stable = readings.every((reading, index) => reading === previous[index]);
2834
+ previous = readings;
2835
+ return stable;
2836
+ });
2837
+ } catch (cause) {
2838
+ throw new Error("Media emulation did not clear from the tester", { cause });
2839
+ }
2840
+ }
2841
+ /**
2129
2842
  * Shoots one frame at one viewport size and proves the file on disk holds this run's bytes.
2130
2843
  *
2131
2844
  * @param options - The path to write, the viewport to shoot at, and the element to shoot.
@@ -2954,6 +3667,6 @@ function createHarness(options) {
2954
3667
  };
2955
3668
  }
2956
3669
  //#endregion
2957
- export { ACCESSIBLE_ROLES, CANVAS_COLOR, CAPTURE_PANE, CAPTURE_STAGINGS, CONTENT_ROLES, FIELD_ROLES, FOCUSABLE_SELECTOR, HEADER_ROLES, IMPLICIT_ROLES, blendColor, build, buildCensus, buildContrast, buildDenial, buildEscapes, captureFrame, clearStorage, clickAccessible, clickAccessibleWithin, clickDisclosure, commitInput, computeNamePattern, createChannel, createDragEvent, createHarness, createJournal, createPointerEvent, createPortfolio, createStorage, describeFocus, describeTree, expandCaptures, extractOrphans, extractStyles, fillAccessible, findKeyframes, findRule, isOutsideViewport, isReachable, isRendered, matchesColor, measureContent, measureContrast, measureLuminance, mount, parseCSSColor, parseColor, pressKeys, readBackdrop, readCascade, readCensus, readClasses, readContrast, readFocus, readFrame, readHit, readLayers, readName, readPage, readPerception, readPixels, readRefusal, readRing, readRole, readRootToken, readRows, readRules, readStates, readStyle, readText, readToken, readValue, releasePane, removeDatabase, render, resolveAccessible, resolveRendered, stagePane, traverseAccessible, typeAccessible, typeInput, waitForAnimations, waitForFrame, waitForState };
3670
+ export { ACCESSIBLE_ROLES, CANVAS_COLOR, CAPTURE_PANE, CAPTURE_STAGINGS, CONTENT_ROLES, FIELD_ROLES, FOCUSABLE_SELECTOR, HEADER_ROLES, IMPLICIT_ROLES, MEDIA_STAGE, POINTER_HOLD, blendColor, build, buildCensus, buildContrast, buildDenial, buildEscapes, captureFrame, clearStorage, clickAccessible, clickAccessibleWithin, clickDisclosure, commitInput, computeNamePattern, convertA98RGB, convertDisplayP3, convertLab, convertLinearSRGB, convertOKLab, convertProPhotoRGB, convertRec2020, convertSRGB, convertXYZD50, convertXYZD65, createChannel, createDragEvent, createHarness, createJournal, createPointerEvent, createPortfolio, createStorage, describeFocus, describeTree, driveHold, driveTraversal, expandCaptures, extractOrphans, extractStyles, fillAccessible, findKeyframes, findRule, holdAccessible, holdAccessibleWithin, hoverAccessible, isOutsideViewport, isReachable, isRendered, matchesColor, measureContent, measureContrast, measureLuminance, mount, parseCSSColor, parseColor, pressKeys, readBackdrop, readCascade, readCensus, readClasses, readContrast, readFocus, readFrame, readHit, readLayers, readName, readPage, readPerception, readPixels, readRefusal, readRing, readRole, readRootToken, readRows, readRules, readStates, readStyle, readText, readToken, readValue, releaseMedia, releasePane, releasePointer, removeDatabase, render, resolveAccessible, resolveAccessibleWithin, resolveRendered, sendProtocol, stageMedia, stagePane, traverseAccessible, traverseAccessibleWithin, typeAccessible, typeInput, waitForAnimations, waitForFrame, waitForState };
2958
3671
 
2959
3672
  //# sourceMappingURL=index.js.map