@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.
- package/dist/src/browser/index.d.ts +526 -16
- package/dist/src/browser/index.js +760 -47
- package/dist/src/browser/index.js.map +1 -1
- package/package.json +6 -5
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
*
|
|
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
|
|
1277
|
-
* @
|
|
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
|
-
*
|
|
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
|
-
*
|
|
1290
|
-
* parseColor('rebeccapurple') // undefined
|
|
1566
|
+
* convertLinearSRGB(0, 0, 0) // [0, 0, 0, 1]
|
|
1291
1567
|
* ```
|
|
1292
1568
|
*/
|
|
1293
|
-
function
|
|
1294
|
-
const
|
|
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
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
1483
|
-
if (
|
|
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
|
|
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
|
-
|
|
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
|