@orkestrel/test 0.0.17 → 0.0.19
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 +422 -18
- package/dist/src/browser/index.js +647 -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
|
/**
|
|
@@ -213,8 +229,9 @@ function isOutsideViewport(rectangle) {
|
|
|
213
229
|
*
|
|
214
230
|
* @param element - The element to judge.
|
|
215
231
|
* @returns True if the element is connected, visible, laid out with a non-zero box, in the
|
|
216
|
-
* sequential focus order, neither disabled nor marked `aria-disabled="true"`,
|
|
217
|
-
* `[inert]` subtree
|
|
232
|
+
* sequential focus order, neither disabled nor marked `aria-disabled="true"`, outside every
|
|
233
|
+
* `[inert]` subtree, and inside every shown `[aria-modal="true"]` element its document carries;
|
|
234
|
+
* false otherwise.
|
|
218
235
|
*
|
|
219
236
|
* @remarks
|
|
220
237
|
* This is the one reachability filter the layer applies. `resolveRendered`, `clickAccessibleWithin`,
|
|
@@ -226,6 +243,22 @@ function isOutsideViewport(rectangle) {
|
|
|
226
243
|
* refuses it. Nothing here asks about the viewport: `resolveAccessible` scrolls a wholly
|
|
227
244
|
* off-viewport target into view and measures that separately with {@link isOutsideViewport}.
|
|
228
245
|
*
|
|
246
|
+
* An open modal dialog takes the page behind it away, and the element's own facts cannot report
|
|
247
|
+
* that: the covered control stays connected, laid out, focusable, and outside every `[inert]`
|
|
248
|
+
* subtree while a pointer, a Tab, and a reader honouring `aria-modal` all stop at the dialog. So
|
|
249
|
+
* this asks the document for its `[aria-modal="true"]` elements, puts each through
|
|
250
|
+
* {@link isRendered}, and refuses the subject wherever a shown one does not contain it. A closed
|
|
251
|
+
* dialog is hidden and excludes nothing, which is why the visibility reading is the announced one
|
|
252
|
+
* rather than a bare `checkVisibility` call: a drawer parked at `visibility: hidden` is still laid
|
|
253
|
+
* out. Nesting needs no separate rule, because an element inside the innermost dialog sits inside
|
|
254
|
+
* every dialog around it.
|
|
255
|
+
*
|
|
256
|
+
* Containment follows the flat tree, so a subject inside a shadow tree is judged by its host chain
|
|
257
|
+
* as well as itself and a dialog holding the host holds its shadow content too. Two arrangements
|
|
258
|
+
* stay outside the read: a native `<dialog>` opened with `showModal` carries no `aria-modal`
|
|
259
|
+
* attribute, and a dialog declared inside a shadow tree is not in what a document query returns.
|
|
260
|
+
* Each leaves the page behind it reachable here.
|
|
261
|
+
*
|
|
229
262
|
* Inside a shadow tree it answers for the element's own facts, in an open root and a closed one
|
|
230
263
|
* alike: the box, the focus order, `:disabled`, and `aria-disabled` are all the element's. The
|
|
231
264
|
* `[inert]` ancestor is the one read that stops at the boundary, because `closest` never leaves the
|
|
@@ -240,11 +273,17 @@ function isOutsideViewport(rectangle) {
|
|
|
240
273
|
*/
|
|
241
274
|
function isReachable(element) {
|
|
242
275
|
if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) return false;
|
|
276
|
+
const chain = [element];
|
|
277
|
+
let root = element.getRootNode();
|
|
278
|
+
while (root instanceof ShadowRoot) {
|
|
279
|
+
chain.push(root.host);
|
|
280
|
+
root = root.host.getRootNode();
|
|
281
|
+
}
|
|
243
282
|
const rectangle = element.getBoundingClientRect();
|
|
244
283
|
return element.isConnected && element.checkVisibility({
|
|
245
284
|
checkOpacity: true,
|
|
246
285
|
checkVisibilityCSS: true
|
|
247
|
-
}) && rectangle.width > 0 && rectangle.height > 0 && element.tabIndex >= 0 && !element.matches(":disabled, [aria-disabled=\"true\"]") && element.closest("[inert]") === null;
|
|
286
|
+
}) && rectangle.width > 0 && rectangle.height > 0 && element.tabIndex >= 0 && !element.matches(":disabled, [aria-disabled=\"true\"]") && element.closest("[inert]") === null && [...element.ownerDocument.querySelectorAll("[aria-modal=\"true\"]")].every((dialog) => !isRendered(dialog) || chain.some((node) => dialog.contains(node)));
|
|
248
287
|
}
|
|
249
288
|
/**
|
|
250
289
|
* Determines whether the accessibility tree presents one element at all.
|
|
@@ -506,6 +545,119 @@ async function clickDisclosure(name) {
|
|
|
506
545
|
await userEvent.click(target);
|
|
507
546
|
}
|
|
508
547
|
/**
|
|
548
|
+
* Sends one DevTools protocol command through the browser provider.
|
|
549
|
+
*
|
|
550
|
+
* @param method - The protocol method name.
|
|
551
|
+
* @param params - The protocol parameters.
|
|
552
|
+
* @returns A promise resolving after the command completes, discarding its response.
|
|
553
|
+
* @throws Thrown when the provider exposes no DevTools session, or the command fails.
|
|
554
|
+
*
|
|
555
|
+
* @example
|
|
556
|
+
* ```ts
|
|
557
|
+
* await sendProtocol('Emulation.setEmulatedMedia', { media: '', features: [] })
|
|
558
|
+
* ```
|
|
559
|
+
*/
|
|
560
|
+
async function sendProtocol(method, params) {
|
|
561
|
+
let session;
|
|
562
|
+
let send;
|
|
563
|
+
try {
|
|
564
|
+
session = cdp();
|
|
565
|
+
send = readProperty(session, "send");
|
|
566
|
+
} catch (cause) {
|
|
567
|
+
throw new Error("Browser provider exposes no DevTools session", { cause });
|
|
568
|
+
}
|
|
569
|
+
if (typeof send !== "function") throw new Error("Browser provider exposes no DevTools session");
|
|
570
|
+
await invokeUnchecked(session, send, [method, params]);
|
|
571
|
+
}
|
|
572
|
+
async function hoverAccessible(first, second) {
|
|
573
|
+
await userEvent.hover(resolveRendered(first, second));
|
|
574
|
+
}
|
|
575
|
+
async function holdAccessible(first, second) {
|
|
576
|
+
const held = document.documentElement.getAttribute(POINTER_HOLD);
|
|
577
|
+
if (held !== null) throw new Error(`Pointer is already held at ${held}`);
|
|
578
|
+
const target = second === void 0 ? resolveAccessible(first) : resolveAccessible(first, second);
|
|
579
|
+
const box = target.getBoundingClientRect();
|
|
580
|
+
const frame = window.frameElement?.getBoundingClientRect();
|
|
581
|
+
const scale = frame === void 0 ? 1 : frame.width / window.innerWidth;
|
|
582
|
+
const x = (frame?.left ?? 0) + (box.left + box.width / 2) * scale;
|
|
583
|
+
const y = (frame?.top ?? 0) + (box.top + box.height / 2) * scale;
|
|
584
|
+
await sendProtocol("Input.dispatchMouseEvent", {
|
|
585
|
+
type: "mouseMoved",
|
|
586
|
+
x,
|
|
587
|
+
y
|
|
588
|
+
});
|
|
589
|
+
await sendProtocol("Input.dispatchMouseEvent", {
|
|
590
|
+
type: "mousePressed",
|
|
591
|
+
x,
|
|
592
|
+
y,
|
|
593
|
+
button: "left",
|
|
594
|
+
buttons: 1,
|
|
595
|
+
clickCount: 1
|
|
596
|
+
});
|
|
597
|
+
document.documentElement.setAttribute(POINTER_HOLD, `${String(x)}x${String(y)}`);
|
|
598
|
+
await waitForFrame();
|
|
599
|
+
if (!target.matches(":active")) {
|
|
600
|
+
try {
|
|
601
|
+
await releasePointer();
|
|
602
|
+
} catch (cause) {
|
|
603
|
+
throw new Error(`Interactive target "${second ?? first}" did not enter the pressed state`, { cause });
|
|
604
|
+
}
|
|
605
|
+
throw new Error(`Interactive target "${second ?? first}" did not enter the pressed state`);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Releases a held pointer and parks it at the page origin, clearing hover.
|
|
610
|
+
*
|
|
611
|
+
* @returns A promise resolving after the released pointer's frame paints.
|
|
612
|
+
* @throws Thrown when the release or the park rejects. If both reject, the aggregate carries the
|
|
613
|
+
* park rejection as its cause and the release rejection in its errors.
|
|
614
|
+
*
|
|
615
|
+
* @remarks
|
|
616
|
+
* An idle pointer sends no button release. Calling this after an explicit release is safe in an
|
|
617
|
+
* `afterEach` hook. A rejected release keeps the marker for a later retry. The pointer still moves
|
|
618
|
+
* to the origin in cleanup, and a rejection there joins the aggregate described under `@throws`.
|
|
619
|
+
* The provider must expose a DevTools session.
|
|
620
|
+
*
|
|
621
|
+
* @example
|
|
622
|
+
* ```ts
|
|
623
|
+
* await releasePointer()
|
|
624
|
+
* ```
|
|
625
|
+
*/
|
|
626
|
+
async function releasePointer() {
|
|
627
|
+
const held = document.documentElement.getAttribute(POINTER_HOLD);
|
|
628
|
+
let released = held === null;
|
|
629
|
+
let rejection;
|
|
630
|
+
try {
|
|
631
|
+
if (held !== null) {
|
|
632
|
+
const [x, y] = held.split("x").map(Number);
|
|
633
|
+
await sendProtocol("Input.dispatchMouseEvent", {
|
|
634
|
+
type: "mouseReleased",
|
|
635
|
+
x,
|
|
636
|
+
y,
|
|
637
|
+
button: "left",
|
|
638
|
+
buttons: 0,
|
|
639
|
+
clickCount: 1
|
|
640
|
+
});
|
|
641
|
+
released = true;
|
|
642
|
+
}
|
|
643
|
+
} catch (cause) {
|
|
644
|
+
rejection = cause;
|
|
645
|
+
}
|
|
646
|
+
if (released) document.documentElement.removeAttribute(POINTER_HOLD);
|
|
647
|
+
try {
|
|
648
|
+
await sendProtocol("Input.dispatchMouseEvent", {
|
|
649
|
+
type: "mouseMoved",
|
|
650
|
+
x: 0,
|
|
651
|
+
y: 0
|
|
652
|
+
});
|
|
653
|
+
await waitForFrame();
|
|
654
|
+
} catch (cause) {
|
|
655
|
+
if (!released) throw new AggregateError([rejection], isError(cause) ? cause.message : String(cause), { cause });
|
|
656
|
+
throw cause;
|
|
657
|
+
}
|
|
658
|
+
if (!released) throw rejection;
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
509
661
|
* Replaces a named field's value through focus, select-all, deletion, and real keystrokes.
|
|
510
662
|
*
|
|
511
663
|
* @param name - The field's exact accessible name.
|
|
@@ -1248,53 +1400,317 @@ function removeDatabase(name) {
|
|
|
1248
1400
|
});
|
|
1249
1401
|
}
|
|
1250
1402
|
/**
|
|
1251
|
-
*
|
|
1403
|
+
* Converts normalized encoded sRGB channels to the clipped paint scale.
|
|
1252
1404
|
*
|
|
1253
|
-
* @param
|
|
1254
|
-
* @
|
|
1405
|
+
* @param red - The encoded red channel, with `1` representing full intensity.
|
|
1406
|
+
* @param green - The encoded green channel.
|
|
1407
|
+
* @param blue - The encoded blue channel.
|
|
1408
|
+
* @param alpha - The opacity. Default: `1`.
|
|
1409
|
+
* @returns Frozen straight sRGB channels clipped to 0–255, with alpha clipped to 0–1.
|
|
1410
|
+
*
|
|
1411
|
+
* @example
|
|
1412
|
+
* ```ts
|
|
1413
|
+
* convertSRGB(1.2, -0.1, 0.5) // [255, 0, 127.5, 1]
|
|
1414
|
+
* ```
|
|
1415
|
+
*/
|
|
1416
|
+
function convertSRGB(red, green, blue, alpha = 1) {
|
|
1417
|
+
return Object.freeze([
|
|
1418
|
+
Math.min(255, Math.max(0, red * 255)),
|
|
1419
|
+
Math.min(255, Math.max(0, green * 255)),
|
|
1420
|
+
Math.min(255, Math.max(0, blue * 255)),
|
|
1421
|
+
Math.min(1, Math.max(0, alpha))
|
|
1422
|
+
]);
|
|
1423
|
+
}
|
|
1424
|
+
/**
|
|
1425
|
+
* Converts linear sRGB channels to encoded, clipped paint channels.
|
|
1426
|
+
*
|
|
1427
|
+
* @param red - The linear red channel on the normalized scale.
|
|
1428
|
+
* @param green - The linear green channel.
|
|
1429
|
+
* @param blue - The linear blue channel.
|
|
1430
|
+
* @param alpha - The opacity. Default: `1`.
|
|
1431
|
+
* @returns Frozen straight sRGB channels clipped to 0–255, with alpha clipped to 0–1.
|
|
1255
1432
|
*
|
|
1256
1433
|
* @remarks
|
|
1257
|
-
*
|
|
1258
|
-
* declaration resolves to `color(srgb r g b [/ a])` with channels on the 0–1 scale. Both forms are
|
|
1259
|
-
* read here and nothing else is: a keyword, a hex triple, an empty string from a detached element,
|
|
1260
|
-
* and a color space the cascade never hands back all return `undefined`. Absence is the answer
|
|
1261
|
-
* rather than a transparent color, so a caller decides what an unreadable value means instead of
|
|
1262
|
-
* measuring a black it never saw.
|
|
1434
|
+
* Applies the CSS Color 4 extended sRGB transfer function before clipping.
|
|
1263
1435
|
*
|
|
1264
1436
|
* @example
|
|
1265
1437
|
* ```ts
|
|
1266
|
-
*
|
|
1267
|
-
* parseColor('rebeccapurple') // undefined
|
|
1438
|
+
* convertLinearSRGB(0, 0, 0) // [0, 0, 0, 1]
|
|
1268
1439
|
* ```
|
|
1269
1440
|
*/
|
|
1270
|
-
function
|
|
1271
|
-
const
|
|
1272
|
-
const legacy = /^rgba?\((?<channels>[^)]*)\)$/u.exec(value);
|
|
1273
|
-
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)) : [
|
|
1274
|
-
Number.parseFloat(modern.groups.red ?? "") * 255,
|
|
1275
|
-
Number.parseFloat(modern.groups.green ?? "") * 255,
|
|
1276
|
-
Number.parseFloat(modern.groups.blue ?? "") * 255,
|
|
1277
|
-
modern.groups.alpha === void 0 ? 1 : Number.parseFloat(modern.groups.alpha)
|
|
1278
|
-
];
|
|
1279
|
-
if (red === void 0 || green === void 0 || blue === void 0) return void 0;
|
|
1280
|
-
if (![
|
|
1441
|
+
function convertLinearSRGB(red, green, blue, alpha = 1) {
|
|
1442
|
+
const [encodedRed = 0, encodedGreen = 0, encodedBlue = 0] = [
|
|
1281
1443
|
red,
|
|
1282
1444
|
green,
|
|
1283
|
-
blue
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1445
|
+
blue
|
|
1446
|
+
].map((channel) => Math.abs(channel) <= .0031308 ? 12.92 * channel : Math.sign(channel) * (1.055 * Math.abs(channel) ** (1 / 2.4) - .055));
|
|
1447
|
+
return convertSRGB(encodedRed, encodedGreen, encodedBlue, alpha);
|
|
1448
|
+
}
|
|
1449
|
+
/**
|
|
1450
|
+
* Converts D65 XYZ coordinates to clipped sRGB paint channels.
|
|
1451
|
+
*
|
|
1452
|
+
* @param x - The normalized X coordinate.
|
|
1453
|
+
* @param y - The normalized Y coordinate.
|
|
1454
|
+
* @param z - The normalized Z coordinate.
|
|
1455
|
+
* @param alpha - The opacity. Default: `1`.
|
|
1456
|
+
* @returns Frozen straight sRGB channels clipped to 0–255, with alpha clipped to 0–1.
|
|
1457
|
+
*
|
|
1458
|
+
* @remarks
|
|
1459
|
+
* Uses the CSS Color 4 XYZ D65 to linear sRGB matrix, then the sRGB transfer function.
|
|
1460
|
+
*
|
|
1461
|
+
* @example
|
|
1462
|
+
* ```ts
|
|
1463
|
+
* convertXYZD65(0, 0, 0) // [0, 0, 0, 1]
|
|
1464
|
+
* ```
|
|
1465
|
+
*/
|
|
1466
|
+
function convertXYZD65(x, y, z, alpha = 1) {
|
|
1467
|
+
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);
|
|
1468
|
+
}
|
|
1469
|
+
/**
|
|
1470
|
+
* Converts D50 XYZ coordinates to clipped sRGB paint channels.
|
|
1471
|
+
*
|
|
1472
|
+
* @param x - The normalized X coordinate relative to D50.
|
|
1473
|
+
* @param y - The normalized Y coordinate relative to D50.
|
|
1474
|
+
* @param z - The normalized Z coordinate relative to D50.
|
|
1475
|
+
* @param alpha - The opacity. Default: `1`.
|
|
1476
|
+
* @returns Frozen straight sRGB channels clipped to 0–255, with alpha clipped to 0–1.
|
|
1477
|
+
*
|
|
1478
|
+
* @remarks
|
|
1479
|
+
* Applies the CSS Color 4 Bradford adaptation from D50 to D65 before converting to sRGB.
|
|
1480
|
+
*
|
|
1481
|
+
* @example
|
|
1482
|
+
* ```ts
|
|
1483
|
+
* convertXYZD50(0, 0, 0) // [0, 0, 0, 1]
|
|
1484
|
+
* ```
|
|
1485
|
+
*/
|
|
1486
|
+
function convertXYZD50(x, y, z, alpha = 1) {
|
|
1487
|
+
return convertXYZD65(.955473421488075 * x - .02309845494876471 * y + .06325924320057072 * z, -.0283697093338637 * x + 1.0099953980813041 * y + .021041441191917323 * z, .012314014864481998 * x - .020507649298898964 * y + 1.330365926242124 * z, alpha);
|
|
1488
|
+
}
|
|
1489
|
+
/**
|
|
1490
|
+
* Converts OKLab coordinates to clipped sRGB paint channels.
|
|
1491
|
+
*
|
|
1492
|
+
* @param lightness - The OKLab lightness on the 0–1 scale.
|
|
1493
|
+
* @param a - The signed green-to-red axis.
|
|
1494
|
+
* @param b - The signed blue-to-yellow axis.
|
|
1495
|
+
* @param alpha - The opacity. Default: `1`.
|
|
1496
|
+
* @returns Frozen straight sRGB channels clipped to 0–255, with alpha clipped to 0–1.
|
|
1497
|
+
*
|
|
1498
|
+
* @remarks
|
|
1499
|
+
* Cubes the transformed cone responses before applying the linear sRGB matrix.
|
|
1500
|
+
*
|
|
1501
|
+
* @example
|
|
1502
|
+
* ```ts
|
|
1503
|
+
* convertOKLab(0, 0, 0) // [0, 0, 0, 1]
|
|
1504
|
+
* ```
|
|
1505
|
+
*/
|
|
1506
|
+
function convertOKLab(lightness, a, b, alpha = 1) {
|
|
1507
|
+
const long = (lightness + .3963377773761749 * a + .2158037573099136 * b) ** 3;
|
|
1508
|
+
const medium = (lightness - .1055613458156586 * a - .0638541728258133 * b) ** 3;
|
|
1509
|
+
const short = (lightness - .0894841775298119 * a - 1.2914855480194092 * b) ** 3;
|
|
1510
|
+
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);
|
|
1511
|
+
}
|
|
1512
|
+
/**
|
|
1513
|
+
* Converts CIE Lab coordinates relative to D50 to clipped sRGB paint channels.
|
|
1514
|
+
*
|
|
1515
|
+
* @param lightness - The CIE lightness on the 0–100 scale.
|
|
1516
|
+
* @param a - The signed green-to-red axis.
|
|
1517
|
+
* @param b - The signed blue-to-yellow axis.
|
|
1518
|
+
* @param alpha - The opacity. Default: `1`.
|
|
1519
|
+
* @returns Frozen straight sRGB channels clipped to 0–255, with alpha clipped to 0–1.
|
|
1520
|
+
*
|
|
1521
|
+
* @remarks
|
|
1522
|
+
* Resolves the CSS Color 4 Lab curve against the D50 white point before adapting to D65.
|
|
1523
|
+
*
|
|
1524
|
+
* @example
|
|
1525
|
+
* ```ts
|
|
1526
|
+
* convertLab(0, 0, 0) // [0, 0, 0, 1]
|
|
1527
|
+
* ```
|
|
1528
|
+
*/
|
|
1529
|
+
function convertLab(lightness, a, b, alpha = 1) {
|
|
1530
|
+
const luminance = (lightness + 16) / 116;
|
|
1531
|
+
const [x = 0, y = 0, z = 0] = [
|
|
1532
|
+
luminance + a / 500,
|
|
1533
|
+
luminance,
|
|
1534
|
+
luminance - b / 200
|
|
1535
|
+
].map((channel) => channel ** 3 > 216 / 24389 ? channel ** 3 : (116 * channel - 16) / (24389 / 27));
|
|
1536
|
+
return convertXYZD50(x * (.3457 / .3585), y, z * (.2958 / .3585), alpha);
|
|
1537
|
+
}
|
|
1538
|
+
/**
|
|
1539
|
+
* Converts encoded Display P3 channels to clipped sRGB paint channels.
|
|
1540
|
+
*
|
|
1541
|
+
* @param red - The normalized encoded red channel.
|
|
1542
|
+
* @param green - The normalized encoded green channel.
|
|
1543
|
+
* @param blue - The normalized encoded blue channel.
|
|
1544
|
+
* @param alpha - The opacity. Default: `1`.
|
|
1545
|
+
* @returns Frozen straight sRGB channels clipped to 0–255, with alpha clipped to 0–1.
|
|
1546
|
+
*
|
|
1547
|
+
* @remarks
|
|
1548
|
+
* Decodes the extended sRGB transfer curve and uses the CSS Color 4 P3 to XYZ D65 matrix.
|
|
1549
|
+
*
|
|
1550
|
+
* @example
|
|
1551
|
+
* ```ts
|
|
1552
|
+
* convertDisplayP3(0, 0, 0) // [0, 0, 0, 1]
|
|
1553
|
+
* ```
|
|
1554
|
+
*/
|
|
1555
|
+
function convertDisplayP3(red, green, blue, alpha = 1) {
|
|
1556
|
+
const [r = 0, g = 0, b = 0] = [
|
|
1287
1557
|
red,
|
|
1288
1558
|
green,
|
|
1289
|
-
blue
|
|
1290
|
-
|
|
1291
|
-
|
|
1559
|
+
blue
|
|
1560
|
+
].map((channel) => Math.abs(channel) <= .04045 ? channel / 12.92 : Math.sign(channel) * ((Math.abs(channel) + .055) / 1.055) ** 2.4);
|
|
1561
|
+
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);
|
|
1562
|
+
}
|
|
1563
|
+
/**
|
|
1564
|
+
* Converts encoded A98 RGB channels to clipped sRGB paint channels.
|
|
1565
|
+
*
|
|
1566
|
+
* @param red - The normalized encoded red channel.
|
|
1567
|
+
* @param green - The normalized encoded green channel.
|
|
1568
|
+
* @param blue - The normalized encoded blue channel.
|
|
1569
|
+
* @param alpha - The opacity. Default: `1`.
|
|
1570
|
+
* @returns Frozen straight sRGB channels clipped to 0–255, with alpha clipped to 0–1.
|
|
1571
|
+
*
|
|
1572
|
+
* @remarks
|
|
1573
|
+
* Decodes the signed 563/256 power curve and uses the CSS Color 4 A98 RGB to XYZ D65 matrix.
|
|
1574
|
+
*
|
|
1575
|
+
* @example
|
|
1576
|
+
* ```ts
|
|
1577
|
+
* convertA98RGB(0, 0, 0) // [0, 0, 0, 1]
|
|
1578
|
+
* ```
|
|
1579
|
+
*/
|
|
1580
|
+
function convertA98RGB(red, green, blue, alpha = 1) {
|
|
1581
|
+
const [r = 0, g = 0, b = 0] = [
|
|
1582
|
+
red,
|
|
1583
|
+
green,
|
|
1584
|
+
blue
|
|
1585
|
+
].map((channel) => Math.sign(channel) * Math.abs(channel) ** (563 / 256));
|
|
1586
|
+
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);
|
|
1587
|
+
}
|
|
1588
|
+
/**
|
|
1589
|
+
* Converts encoded ProPhoto RGB channels to clipped sRGB paint channels.
|
|
1590
|
+
*
|
|
1591
|
+
* @param red - The normalized encoded red channel.
|
|
1592
|
+
* @param green - The normalized encoded green channel.
|
|
1593
|
+
* @param blue - The normalized encoded blue channel.
|
|
1594
|
+
* @param alpha - The opacity. Default: `1`.
|
|
1595
|
+
* @returns Frozen straight sRGB channels clipped to 0–255, with alpha clipped to 0–1.
|
|
1596
|
+
*
|
|
1597
|
+
* @remarks
|
|
1598
|
+
* Decodes the signed ProPhoto curve, converts to XYZ D50, and adapts to D65 before clipping.
|
|
1599
|
+
*
|
|
1600
|
+
* @example
|
|
1601
|
+
* ```ts
|
|
1602
|
+
* convertProPhotoRGB(0, 0, 0) // [0, 0, 0, 1]
|
|
1603
|
+
* ```
|
|
1604
|
+
*/
|
|
1605
|
+
function convertProPhotoRGB(red, green, blue, alpha = 1) {
|
|
1606
|
+
const [r = 0, g = 0, b = 0] = [
|
|
1607
|
+
red,
|
|
1608
|
+
green,
|
|
1609
|
+
blue
|
|
1610
|
+
].map((channel) => Math.abs(channel) <= 16 / 512 ? channel / 16 : Math.sign(channel) * Math.abs(channel) ** 1.8);
|
|
1611
|
+
return convertXYZD50(.7977666449006423 * r + .13518129740053308 * g + .0313477341283922 * b, .2880748288194013 * r + .711835234241873 * g + 8993693872564e-17 * b, .8251046025104602 * b, alpha);
|
|
1612
|
+
}
|
|
1613
|
+
/**
|
|
1614
|
+
* Converts encoded Rec. 2020 channels to clipped sRGB paint channels.
|
|
1615
|
+
*
|
|
1616
|
+
* @param red - The normalized encoded red channel.
|
|
1617
|
+
* @param green - The normalized encoded green channel.
|
|
1618
|
+
* @param blue - The normalized encoded blue channel.
|
|
1619
|
+
* @param alpha - The opacity. Default: `1`.
|
|
1620
|
+
* @returns Frozen straight sRGB channels clipped to 0–255, with alpha clipped to 0–1.
|
|
1621
|
+
*
|
|
1622
|
+
* @remarks
|
|
1623
|
+
* Uses the piecewise Rec. 2020 transfer curve Chromium computes and the CSS Color 4 XYZ matrix.
|
|
1624
|
+
*
|
|
1625
|
+
* @example
|
|
1626
|
+
* ```ts
|
|
1627
|
+
* convertRec2020(0, 0, 0) // [0, 0, 0, 1]
|
|
1628
|
+
* ```
|
|
1629
|
+
*/
|
|
1630
|
+
function convertRec2020(red, green, blue, alpha = 1) {
|
|
1631
|
+
const [r = 0, g = 0, b = 0] = [
|
|
1632
|
+
red,
|
|
1633
|
+
green,
|
|
1634
|
+
blue
|
|
1635
|
+
].map((channel) => Math.abs(channel) < .08124285829863151 ? channel / 4.5 : Math.sign(channel) * ((Math.abs(channel) + 1.09929682680944 - 1) / 1.09929682680944) ** (1 / .45));
|
|
1636
|
+
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);
|
|
1637
|
+
}
|
|
1638
|
+
/**
|
|
1639
|
+
* Parses computed CSS Color 4 values into clipped straight sRGB channels.
|
|
1640
|
+
*
|
|
1641
|
+
* @param value - A computed `rgb()`, `rgba()`, `oklab()`, `oklch()`, `lab()`, `lch()`, or `color()` value.
|
|
1642
|
+
* @returns Frozen channels, or `undefined` for an unsupported syntax or non-finite channel.
|
|
1643
|
+
*
|
|
1644
|
+
* @remarks
|
|
1645
|
+
* Reads signed channels, scientific notation, percentage lightness, degree hues, and `none` as
|
|
1646
|
+
* zero. The `color()` spaces are `srgb`, `srgb-linear`, `display-p3`, `a98-rgb`, `prophoto-rgb`,
|
|
1647
|
+
* `rec2020`, `xyz`, `xyz-d50`, and `xyz-d65`. Conversion uses the CSS Color 4 matrices and white
|
|
1648
|
+
* point adaptation, then clips each encoded sRGB channel to 0–255 rather than gamut-mapping it.
|
|
1649
|
+
* Alpha is clipped to 0–1. Keywords, hex colors, unresolved expressions, and non-finite calculations
|
|
1650
|
+
* such as `color(srgb calc(infinity) 0 0)` remain unreadable; use {@link parseCSSColor} to resolve
|
|
1651
|
+
* ordinary authored expressions through the cascade.
|
|
1652
|
+
*
|
|
1653
|
+
* @example
|
|
1654
|
+
* ```ts
|
|
1655
|
+
* parseColor('rgba(255, 255, 255, 0.5)') // [255, 255, 255, 0.5]
|
|
1656
|
+
* parseColor('rebeccapurple') // undefined
|
|
1657
|
+
* ```
|
|
1658
|
+
*/
|
|
1659
|
+
function parseColor(value) {
|
|
1660
|
+
const match = /^(?<syntax>rgba?|oklab|oklch|lab|lch|color)\((?<body>[^()]*)\)$/u.exec(value);
|
|
1661
|
+
if (match?.groups === void 0) return void 0;
|
|
1662
|
+
const { syntax, body = "" } = match.groups;
|
|
1663
|
+
const legacy = syntax === "rgb" || syntax === "rgba";
|
|
1664
|
+
const polar = syntax === "oklch" || syntax === "lch";
|
|
1665
|
+
const perceptual = syntax === "oklab" || syntax === "oklch" || syntax === "lab" || syntax === "lch";
|
|
1666
|
+
const tokens = body.trim().split(legacy && body.includes(",") ? /\s*,\s*/u : /\s*\/\s*|\s+/u);
|
|
1667
|
+
const space = syntax === "color" ? tokens.shift() : syntax;
|
|
1668
|
+
if (tokens.length !== 3 && tokens.length !== 4) return void 0;
|
|
1669
|
+
if (!legacy && body.includes(",")) return void 0;
|
|
1670
|
+
if (body.split("/").length > 2) return void 0;
|
|
1671
|
+
if (body.includes("/") && (tokens.length !== 4 || !/\/\s*[^\s/]+\s*$/u.test(body))) return void 0;
|
|
1672
|
+
if (!(legacy && body.includes(",")) && tokens.length === 4 && !body.includes("/")) return void 0;
|
|
1673
|
+
const parts = tokens.map((token, index) => {
|
|
1674
|
+
if (token === "none") return 0;
|
|
1675
|
+
const part = /^(?<number>[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)(?<unit>%|deg)?$/u.exec(token);
|
|
1676
|
+
if (part?.groups === void 0) return NaN;
|
|
1677
|
+
const number = Number(part.groups.number);
|
|
1678
|
+
const unit = part.groups.unit;
|
|
1679
|
+
if (unit === "deg") return polar && index === 2 ? number : NaN;
|
|
1680
|
+
if (unit !== "%") return number;
|
|
1681
|
+
if (index === 3 || syntax === "color") return number / 100;
|
|
1682
|
+
if (legacy) return number * 255 / 100;
|
|
1683
|
+
if (index === 0) return syntax === "oklab" || syntax === "oklch" ? number / 100 : number;
|
|
1684
|
+
return NaN;
|
|
1685
|
+
});
|
|
1686
|
+
const [first, second, third, alpha = 1] = parts;
|
|
1687
|
+
if (first === void 0 || second === void 0 || third === void 0) return void 0;
|
|
1688
|
+
if (!parts.every((part) => Number.isFinite(part))) return void 0;
|
|
1689
|
+
if (legacy) return convertSRGB(first / 255, second / 255, third / 255, alpha);
|
|
1690
|
+
if (perceptual) {
|
|
1691
|
+
const lightness = Math.max(0, Math.min(syntax === "oklab" || syntax === "oklch" ? 1 : 100, first));
|
|
1692
|
+
const a = polar ? Math.max(0, second) * Math.cos(third * Math.PI / 180) : second;
|
|
1693
|
+
const b = polar ? Math.max(0, second) * Math.sin(third * Math.PI / 180) : third;
|
|
1694
|
+
return syntax === "oklab" || syntax === "oklch" ? convertOKLab(lightness, a, b, alpha) : convertLab(lightness, a, b, alpha);
|
|
1695
|
+
}
|
|
1696
|
+
switch (space) {
|
|
1697
|
+
case "srgb": return convertSRGB(first, second, third, alpha);
|
|
1698
|
+
case "srgb-linear": return convertLinearSRGB(first, second, third, alpha);
|
|
1699
|
+
case "display-p3": return convertDisplayP3(first, second, third, alpha);
|
|
1700
|
+
case "a98-rgb": return convertA98RGB(first, second, third, alpha);
|
|
1701
|
+
case "prophoto-rgb": return convertProPhotoRGB(first, second, third, alpha);
|
|
1702
|
+
case "rec2020": return convertRec2020(first, second, third, alpha);
|
|
1703
|
+
case "xyz":
|
|
1704
|
+
case "xyz-d65": return convertXYZD65(first, second, third, alpha);
|
|
1705
|
+
case "xyz-d50": return convertXYZD50(first, second, third, alpha);
|
|
1706
|
+
default: return;
|
|
1707
|
+
}
|
|
1292
1708
|
}
|
|
1293
1709
|
/**
|
|
1294
1710
|
* Resolves any CSS color expression to straight sRGB channels, by asking the browser.
|
|
1295
1711
|
*
|
|
1296
1712
|
* @param value - Any value the `color` property accepts: a keyword, a hex triple, a `var()`
|
|
1297
|
-
* reference, a `color-mix()`, or
|
|
1713
|
+
* reference, a `color-mix()`, or a computed CSS Color 4 value.
|
|
1298
1714
|
* @returns The resolved color's channels, or `undefined` when the CSSOM refuses the value or the
|
|
1299
1715
|
* computed result names no color {@link parseColor} speaks.
|
|
1300
1716
|
*
|
|
@@ -1304,6 +1720,8 @@ function parseColor(value) {
|
|
|
1304
1720
|
* cascade, and reads back what the engine computed — which is the only way a keyword, a hex triple,
|
|
1305
1721
|
* or a `var()` reference becomes channels at all. The read itself goes through `parseColor`, so both
|
|
1306
1722
|
* halves agree on what a computed value means.
|
|
1723
|
+
* Modern perceptual and predefined RGB/XYZ spaces resolve through that parser's conversions;
|
|
1724
|
+
* out-of-gamut sRGB channels are clipped to 0–255 after conversion.
|
|
1307
1725
|
*
|
|
1308
1726
|
* The probe is mounted, because an unmounted element inherits nothing and a `var()` reference to a
|
|
1309
1727
|
* token declared on `:root` would resolve to the initial value instead. It is removed in a `finally`,
|
|
@@ -1431,16 +1849,23 @@ function measureContrast(front, back) {
|
|
|
1431
1849
|
return (bright + .05) / (dark + .05);
|
|
1432
1850
|
}
|
|
1433
1851
|
/**
|
|
1434
|
-
* Collects
|
|
1852
|
+
* Collects readable background color layers and refuses an unreadable painted layer.
|
|
1435
1853
|
*
|
|
1436
1854
|
* @param element - The element to walk up from.
|
|
1437
1855
|
* @returns Every layer the walk paints, the element's own first and the deepest last.
|
|
1856
|
+
* @throws Thrown when a painted background color is unreadable; the error names the element and
|
|
1857
|
+
* its computed value.
|
|
1438
1858
|
*
|
|
1439
1859
|
* @remarks
|
|
1440
1860
|
* A surface token paints one ancestor while every element between it and the text paints nothing,
|
|
1441
1861
|
* so a backdrop is found by walking up rather than by reading the element's own `background-color`,
|
|
1442
1862
|
* which is almost always transparent. A fully transparent layer paints nothing and is left out, and
|
|
1443
1863
|
* the walk stops at the first fully opaque layer, because nothing above that layer is visible.
|
|
1864
|
+
* Legacy and modern CSS Color 4 values resolve through {@link parseColor}, with sRGB channels
|
|
1865
|
+
* clipped after conversion. Non-finite calculations such as `color(srgb calc(infinity) 0 0)` are
|
|
1866
|
+
* deliberately unreadable. An unreadable value with an explicit zero alpha paints nothing and is
|
|
1867
|
+
* skipped. An empty detached-element reading paints nothing too. Background images remain outside
|
|
1868
|
+
* this color reader.
|
|
1444
1869
|
*
|
|
1445
1870
|
* The stack is what tells a resolved backdrop from an assumed one: the walk reached an opaque
|
|
1446
1871
|
* surface exactly when its last layer's alpha is `1`. {@link readContrast} refuses on that reading,
|
|
@@ -1456,8 +1881,14 @@ function measureContrast(front, back) {
|
|
|
1456
1881
|
function readLayers(element) {
|
|
1457
1882
|
const layers = [];
|
|
1458
1883
|
for (let node = element; node !== null; node = node.parentElement) {
|
|
1459
|
-
const
|
|
1460
|
-
if (
|
|
1884
|
+
const computed = getComputedStyle(node).backgroundColor;
|
|
1885
|
+
if (computed === "") continue;
|
|
1886
|
+
const layer = parseColor(computed);
|
|
1887
|
+
if (layer === void 0) {
|
|
1888
|
+
if (/\/\s*(?:0(?:\.0*)?|none)\s*\)$/u.test(computed)) continue;
|
|
1889
|
+
throw new Error(`Computed background color is unreadable on ${node.localName}${node.id === "" ? "" : `#${node.id}`}: ${computed}`);
|
|
1890
|
+
}
|
|
1891
|
+
if (layer[3] === 0) continue;
|
|
1461
1892
|
layers.push(layer);
|
|
1462
1893
|
if (layer[3] >= 1) break;
|
|
1463
1894
|
}
|
|
@@ -1469,10 +1900,13 @@ function readLayers(element) {
|
|
|
1469
1900
|
* @param element - The element whose backdrop to resolve.
|
|
1470
1901
|
* @param floor - The opaque color the walk ends on when nothing above it paints.
|
|
1471
1902
|
* @returns The composited color a reader sees behind the element.
|
|
1903
|
+
* @throws Thrown when the backdrop contains an unreadable painted color layer.
|
|
1472
1904
|
*
|
|
1473
1905
|
* @remarks
|
|
1474
1906
|
* The layers {@link readLayers} collects composite top-over-bottom onto the floor, so a 3% surface
|
|
1475
1907
|
* tint reads as a tint over what shows through it rather than as a full-strength paint.
|
|
1908
|
+
* Those layers include the modern CSS Color 4 spaces, converted and clipped to sRGB by
|
|
1909
|
+
* {@link parseColor}; an unreadable painted layer refuses the entire reading.
|
|
1476
1910
|
*
|
|
1477
1911
|
* The floor is required, because this leaf never guesses what a document sits on. Pass
|
|
1478
1912
|
* {@link CANVAS_COLOR} for the page a browser paints behind an unstyled document, or the color of
|
|
@@ -1498,7 +1932,8 @@ function readBackdrop(element, floor) {
|
|
|
1498
1932
|
* would show through instead of assuming one.
|
|
1499
1933
|
* @returns The relative-luminance contrast ratio.
|
|
1500
1934
|
* @throws Thrown when the element exposes no computed foreground color, and — with `floor` omitted
|
|
1501
|
-
* — when the walk from the element upwards reaches no opaque layer.
|
|
1935
|
+
* — when the walk from the element upwards reaches no opaque layer. An unreadable painted
|
|
1936
|
+
* background layer throws even when a floor is supplied.
|
|
1502
1937
|
*
|
|
1503
1938
|
* @remarks
|
|
1504
1939
|
* A transparent or translucent background resolves through the element's ancestors: every painted
|
|
@@ -1506,6 +1941,8 @@ function readBackdrop(element, floor) {
|
|
|
1506
1941
|
* base, so a 3% surface tint reads as a tint over what shows through it rather than as a
|
|
1507
1942
|
* full-strength paint. A translucent foreground then resolves against that effective background
|
|
1508
1943
|
* before luminance is measured.
|
|
1944
|
+
* Text and background colors can use the modern CSS Color 4 spaces {@link parseColor} reads;
|
|
1945
|
+
* each is converted to sRGB and clipped before composition and luminance measurement.
|
|
1509
1946
|
*
|
|
1510
1947
|
* With `floor` omitted, the walk from the target upwards must reach a fully opaque layer: the
|
|
1511
1948
|
* measurement throws rather than assuming a white canvas wherever that canvas would still be part
|
|
@@ -1524,7 +1961,7 @@ function readBackdrop(element, floor) {
|
|
|
1524
1961
|
* ```ts
|
|
1525
1962
|
* const container = render('<p style="background: #000; color: #fff">Ready</p>')
|
|
1526
1963
|
* readContrast(requireValue(container.firstElementChild)) // 21
|
|
1527
|
-
* readContrast(requireValue(container.firstElementChild), CANVAS_COLOR) // 21
|
|
1964
|
+
* readContrast(requireValue(container.firstElementChild), CANVAS_COLOR) // 21
|
|
1528
1965
|
* ```
|
|
1529
1966
|
*/
|
|
1530
1967
|
function readContrast(element, floor) {
|
|
@@ -1543,6 +1980,7 @@ function readContrast(element, floor) {
|
|
|
1543
1980
|
* @param worn - The element the control's focus chrome is painted onto. Default: `control`.
|
|
1544
1981
|
* @returns The strongest ratio the painted focus chrome reaches, or `undefined` when the control is
|
|
1545
1982
|
* not showing `:focus-visible` or the cascade paints no chrome of its own.
|
|
1983
|
+
* @throws Thrown when the focused control's backdrop contains an unreadable painted color layer.
|
|
1546
1984
|
*
|
|
1547
1985
|
* @remarks
|
|
1548
1986
|
* This reads and never acts. Focus arrives through the published verbs — `traverseAccessible`,
|
|
@@ -1566,6 +2004,9 @@ function readContrast(element, floor) {
|
|
|
1566
2004
|
* color names neither. A focus style that only changes the control's own fill reports `undefined`
|
|
1567
2005
|
* too: the resting fill is gone by the time focus is on the control, and this never moves focus to
|
|
1568
2006
|
* go and read it.
|
|
2007
|
+
* Outline and shadow colors include `oklch()`, `oklab()`, `lab()`, `lch()`, and predefined
|
|
2008
|
+
* `color()` spaces. Each readable color converts to clipped sRGB through {@link parseColor}, and
|
|
2009
|
+
* the result remains a contrast ratio rather than a ring width.
|
|
1569
2010
|
*
|
|
1570
2011
|
* @example
|
|
1571
2012
|
* ```ts
|
|
@@ -1579,7 +2020,7 @@ function readRing(control, worn) {
|
|
|
1579
2020
|
const declared = getComputedStyle(target);
|
|
1580
2021
|
const backdrop = readBackdrop(target.parentElement ?? target, CANVAS_COLOR);
|
|
1581
2022
|
const outline = declared.outlineStyle === "none" || declared.outlineStyle === "auto" || Number.parseFloat(declared.outlineWidth) === 0 ? void 0 : parseColor(declared.outlineColor);
|
|
1582
|
-
const shadow = parseColor(/(?:rgba?|color)\([^)]*\)/u.exec(declared.boxShadow)?.[0] ?? "");
|
|
2023
|
+
const shadow = parseColor(/(?:rgba?|color|oklab|oklch|lab|lch)\([^)]*\)/u.exec(declared.boxShadow)?.[0] ?? "");
|
|
1583
2024
|
const ratios = [];
|
|
1584
2025
|
for (const painted of [outline, shadow]) {
|
|
1585
2026
|
if (painted === void 0) continue;
|
|
@@ -1859,12 +2300,14 @@ function extractStyles(root) {
|
|
|
1859
2300
|
return styled;
|
|
1860
2301
|
}
|
|
1861
2302
|
/**
|
|
1862
|
-
* Reads one resolved CSS property from a real browser element.
|
|
2303
|
+
* Reads one resolved CSS property from a real browser element or a named pseudo-element.
|
|
1863
2304
|
*
|
|
1864
2305
|
* @param element - The element whose resolved style to inspect.
|
|
1865
2306
|
* @param property - The CSS property name, registered or custom.
|
|
2307
|
+
* @param pseudo - The pseudo-element selector. Omit it to read the element itself.
|
|
1866
2308
|
* @returns The browser's resolved property value, trimmed; an empty string when the element resolves
|
|
1867
2309
|
* none.
|
|
2310
|
+
* @throws Thrown when the pseudo argument lacks the `::` prefix or the engine does not support it.
|
|
1868
2311
|
*
|
|
1869
2312
|
* @remarks
|
|
1870
2313
|
* The value is trimmed, so what comes back is the value and never the whitespace around it. Internal
|
|
@@ -1875,8 +2318,12 @@ function extractStyles(root) {
|
|
|
1875
2318
|
* readStyle(button, 'padding-left')
|
|
1876
2319
|
* ```
|
|
1877
2320
|
*/
|
|
1878
|
-
function readStyle(element, property) {
|
|
1879
|
-
|
|
2321
|
+
function readStyle(element, property, pseudo) {
|
|
2322
|
+
if (pseudo !== void 0) {
|
|
2323
|
+
if (!pseudo.startsWith("::")) throw new Error(`Pseudo-element "${pseudo}" must start with "::"`);
|
|
2324
|
+
if (!CSS.supports(`selector(${pseudo})`)) throw new Error(`Pseudo-element "${pseudo}" is not one this engine exposes`);
|
|
2325
|
+
}
|
|
2326
|
+
return getComputedStyle(element, pseudo).getPropertyValue(property).trim();
|
|
1880
2327
|
}
|
|
1881
2328
|
/**
|
|
1882
2329
|
* Reads one custom property from an element's resolved style.
|
|
@@ -1930,7 +2377,9 @@ function readRootToken(name) {
|
|
|
1930
2377
|
*
|
|
1931
2378
|
* @param element - The element whose resolved style to inspect.
|
|
1932
2379
|
* @param property - The CSS property name, registered or custom.
|
|
2380
|
+
* @param pseudo - The pseudo-element selector. Omit it to read the element itself.
|
|
1933
2381
|
* @returns The leading numeric part of the resolved value, and `0` when it carries none.
|
|
2382
|
+
* @throws Thrown when the pseudo argument lacks the `::` prefix or the engine does not support it.
|
|
1934
2383
|
*
|
|
1935
2384
|
* @remarks
|
|
1936
2385
|
* A resolved length is text with a unit — `'12px'` — so this reads the number in front of the unit
|
|
@@ -1948,8 +2397,8 @@ function readRootToken(name) {
|
|
|
1948
2397
|
* readPixels(button, 'width') // 0 when the width resolves to `auto`
|
|
1949
2398
|
* ```
|
|
1950
2399
|
*/
|
|
1951
|
-
function readPixels(element, property) {
|
|
1952
|
-
const measured = Number.parseFloat(readStyle(element, property));
|
|
2400
|
+
function readPixels(element, property, pseudo) {
|
|
2401
|
+
const measured = Number.parseFloat(readStyle(element, property, pseudo));
|
|
1953
2402
|
return Number.isFinite(measured) ? measured : 0;
|
|
1954
2403
|
}
|
|
1955
2404
|
/**
|
|
@@ -2103,6 +2552,157 @@ async function releasePane() {
|
|
|
2103
2552
|
if (Number.isFinite(width) && Number.isFinite(height)) await page.viewport(width, height);
|
|
2104
2553
|
}
|
|
2105
2554
|
/**
|
|
2555
|
+
* Stages the tester's print medium and motion preference through the browser provider.
|
|
2556
|
+
*
|
|
2557
|
+
* @param options - The media axes to override.
|
|
2558
|
+
* @returns A promise resolving after a bounded read-back for `print: true` and either
|
|
2559
|
+
* `motion` value. A `print: false` stage is sent and followed by a frame wait without a read-back.
|
|
2560
|
+
* @throws Thrown when no axis is supplied or a staged query does not reach the tester.
|
|
2561
|
+
*
|
|
2562
|
+
* @remarks
|
|
2563
|
+
* If `print` is true, uses print; if false, uses screen. If `motion` is true, uses no preference;
|
|
2564
|
+
* if false, uses reduced motion. The print medium, reduced motion, colour scheme, and forced colours
|
|
2565
|
+
* keep their effective readings when omitted. Any other emulated feature the provider configured
|
|
2566
|
+
* is cleared. Each staged query waits up to 1000 milliseconds, polling every 10 milliseconds.
|
|
2567
|
+
* A refused read-back restores the carried pre-call readings before throwing and can take two
|
|
2568
|
+
* budgets. A restoration failure is attached as the refusal's cause. Register
|
|
2569
|
+
* {@link releaseMedia} in teardown; it restores the readings observed before the first stage.
|
|
2570
|
+
*
|
|
2571
|
+
* @example
|
|
2572
|
+
* ```ts
|
|
2573
|
+
* await stageMedia({ motion: false, print: true })
|
|
2574
|
+
* await releaseMedia()
|
|
2575
|
+
* ```
|
|
2576
|
+
*/
|
|
2577
|
+
async function stageMedia(options) {
|
|
2578
|
+
const print = options.print;
|
|
2579
|
+
const motion = options.motion;
|
|
2580
|
+
if (print === void 0 && motion === void 0) throw new Error("Media emulation was staged with nothing to emulate");
|
|
2581
|
+
const media = matchMedia("print").matches ? "print" : "screen";
|
|
2582
|
+
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
2583
|
+
const dark = matchMedia("(prefers-color-scheme: dark)").matches;
|
|
2584
|
+
const forced = matchMedia("(forced-colors: active)").matches;
|
|
2585
|
+
if (!document.documentElement.hasAttribute("data-media-stage")) document.documentElement.setAttribute(MEDIA_STAGE, [
|
|
2586
|
+
media === "print",
|
|
2587
|
+
reduced,
|
|
2588
|
+
dark,
|
|
2589
|
+
forced
|
|
2590
|
+
].map(Number).join(""));
|
|
2591
|
+
const features = [
|
|
2592
|
+
{
|
|
2593
|
+
name: "prefers-color-scheme",
|
|
2594
|
+
value: dark ? "dark" : "light"
|
|
2595
|
+
},
|
|
2596
|
+
{
|
|
2597
|
+
name: "forced-colors",
|
|
2598
|
+
value: forced ? "active" : "none"
|
|
2599
|
+
},
|
|
2600
|
+
{
|
|
2601
|
+
name: "prefers-reduced-motion",
|
|
2602
|
+
value: reduced ? "reduce" : "no-preference"
|
|
2603
|
+
}
|
|
2604
|
+
];
|
|
2605
|
+
const queries = [];
|
|
2606
|
+
if (print === true) queries.push("print");
|
|
2607
|
+
if (motion !== void 0) queries.push(`(prefers-reduced-motion: ${motion ? "no-preference" : "reduce"})`);
|
|
2608
|
+
await sendProtocol("Emulation.setEmulatedMedia", {
|
|
2609
|
+
media: print === void 0 ? media : print ? "print" : "screen",
|
|
2610
|
+
features: features.map((feature) => feature.name === "prefers-reduced-motion" && motion !== void 0 ? {
|
|
2611
|
+
name: feature.name,
|
|
2612
|
+
value: motion ? "no-preference" : "reduce"
|
|
2613
|
+
} : feature)
|
|
2614
|
+
});
|
|
2615
|
+
await waitForFrame();
|
|
2616
|
+
for (const query of queries) try {
|
|
2617
|
+
await waitForCondition(query, () => matchMedia(query).matches);
|
|
2618
|
+
} catch (cause) {
|
|
2619
|
+
try {
|
|
2620
|
+
await sendProtocol("Emulation.setEmulatedMedia", {
|
|
2621
|
+
media,
|
|
2622
|
+
features
|
|
2623
|
+
});
|
|
2624
|
+
await waitForCondition("pre-call media readings restored", () => matchMedia(media).matches && features.every((feature) => matchMedia(`(${feature.name}: ${feature.value})`).matches));
|
|
2625
|
+
} catch (restoration) {
|
|
2626
|
+
throw new Error(`Media emulation did not reach the tester: ${query}`, { cause: restoration });
|
|
2627
|
+
}
|
|
2628
|
+
throw new Error(`Media emulation did not reach the tester: ${query}`, { cause });
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
/**
|
|
2632
|
+
* Restores the media readings observed before the first stage as explicit emulation. With nothing
|
|
2633
|
+
* staged, clears every override and waits for a stable reading, not a proved engine baseline. Checks
|
|
2634
|
+
* the budget between polls, so a frame that never paints is not bounded by it.
|
|
2635
|
+
*
|
|
2636
|
+
* @returns A promise resolving after the media readings settle.
|
|
2637
|
+
* @throws Thrown when a media read-back exhausts its 1000 millisecond budget between polls.
|
|
2638
|
+
*
|
|
2639
|
+
* @remarks
|
|
2640
|
+
* Restores print, reduced motion, colour scheme, and forced colours from {@link MEDIA_STAGE},
|
|
2641
|
+
* waits per axis for the recorded value, and removes the marker after every axis agrees. With no
|
|
2642
|
+
* marker, sends the empty reset and compares readings taken strictly after that send. Each poll
|
|
2643
|
+
* waits for a frame; the interval is 10 milliseconds. The provider must expose a DevTools session.
|
|
2644
|
+
*
|
|
2645
|
+
* @example
|
|
2646
|
+
* ```ts
|
|
2647
|
+
* await releaseMedia()
|
|
2648
|
+
* ```
|
|
2649
|
+
*/
|
|
2650
|
+
async function releaseMedia() {
|
|
2651
|
+
const queries = [
|
|
2652
|
+
"print",
|
|
2653
|
+
"(prefers-reduced-motion: reduce)",
|
|
2654
|
+
"(prefers-color-scheme: dark)",
|
|
2655
|
+
"(forced-colors: active)"
|
|
2656
|
+
];
|
|
2657
|
+
const staged = document.documentElement.getAttribute(MEDIA_STAGE);
|
|
2658
|
+
if (staged !== null) {
|
|
2659
|
+
const readings = staged.split("").map((value) => value === "1");
|
|
2660
|
+
await sendProtocol("Emulation.setEmulatedMedia", {
|
|
2661
|
+
media: readings[0] ? "print" : "screen",
|
|
2662
|
+
features: [
|
|
2663
|
+
{
|
|
2664
|
+
name: "prefers-reduced-motion",
|
|
2665
|
+
value: readings[1] ? "reduce" : "no-preference"
|
|
2666
|
+
},
|
|
2667
|
+
{
|
|
2668
|
+
name: "prefers-color-scheme",
|
|
2669
|
+
value: readings[2] ? "dark" : "light"
|
|
2670
|
+
},
|
|
2671
|
+
{
|
|
2672
|
+
name: "forced-colors",
|
|
2673
|
+
value: readings[3] ? "active" : "none"
|
|
2674
|
+
}
|
|
2675
|
+
]
|
|
2676
|
+
});
|
|
2677
|
+
try {
|
|
2678
|
+
await Promise.all(queries.map((query, index) => waitForCondition(query, async () => {
|
|
2679
|
+
await waitForFrame();
|
|
2680
|
+
return matchMedia(query).matches === readings[index];
|
|
2681
|
+
})));
|
|
2682
|
+
} catch (cause) {
|
|
2683
|
+
throw new Error("Media emulation did not clear from the tester", { cause });
|
|
2684
|
+
}
|
|
2685
|
+
document.documentElement.removeAttribute(MEDIA_STAGE);
|
|
2686
|
+
return;
|
|
2687
|
+
}
|
|
2688
|
+
await sendProtocol("Emulation.setEmulatedMedia", {
|
|
2689
|
+
media: "",
|
|
2690
|
+
features: []
|
|
2691
|
+
});
|
|
2692
|
+
let previous = queries.map((query) => matchMedia(query).matches);
|
|
2693
|
+
try {
|
|
2694
|
+
await waitForCondition("media emulation cleared", async () => {
|
|
2695
|
+
await waitForFrame();
|
|
2696
|
+
const readings = queries.map((query) => matchMedia(query).matches);
|
|
2697
|
+
const stable = readings.every((reading, index) => reading === previous[index]);
|
|
2698
|
+
previous = readings;
|
|
2699
|
+
return stable;
|
|
2700
|
+
});
|
|
2701
|
+
} catch (cause) {
|
|
2702
|
+
throw new Error("Media emulation did not clear from the tester", { cause });
|
|
2703
|
+
}
|
|
2704
|
+
}
|
|
2705
|
+
/**
|
|
2106
2706
|
* Shoots one frame at one viewport size and proves the file on disk holds this run's bytes.
|
|
2107
2707
|
*
|
|
2108
2708
|
* @param options - The path to write, the viewport to shoot at, and the element to shoot.
|
|
@@ -2931,6 +3531,6 @@ function createHarness(options) {
|
|
|
2931
3531
|
};
|
|
2932
3532
|
}
|
|
2933
3533
|
//#endregion
|
|
2934
|
-
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 };
|
|
3534
|
+
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, expandCaptures, extractOrphans, extractStyles, fillAccessible, findKeyframes, findRule, holdAccessible, 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, resolveRendered, sendProtocol, stageMedia, stagePane, traverseAccessible, typeAccessible, typeInput, waitForAnimations, waitForFrame, waitForState };
|
|
2935
3535
|
|
|
2936
3536
|
//# sourceMappingURL=index.js.map
|