cssupgrade 2026.0.1 → 2026.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +9 -2
- package/CONTRIBUTING.md +12 -0
- package/package.json +7 -2
- package/src/widelyAvailable/cssNesting.js +161 -58
- package/tests/computedStyles.js +319 -0
- package/tests/css-nesting.test.js +138 -0
- package/tests/fixtures/cssNesting.js +109 -0
package/.github/workflows/ci.yml
CHANGED
|
@@ -21,7 +21,10 @@ jobs:
|
|
|
21
21
|
text: ${{ steps.read_readme.outputs.content }}
|
|
22
22
|
node-test:
|
|
23
23
|
name: Test
|
|
24
|
-
runs-on:
|
|
24
|
+
runs-on: macos-latest
|
|
25
|
+
permissions:
|
|
26
|
+
contents: read
|
|
27
|
+
id-token: write
|
|
25
28
|
steps:
|
|
26
29
|
- uses: actions/checkout@v7
|
|
27
30
|
- name: Set up Node.js
|
|
@@ -29,9 +32,13 @@ jobs:
|
|
|
29
32
|
with:
|
|
30
33
|
node-version-file: package.json
|
|
31
34
|
- run: npm ci
|
|
35
|
+
- run: npx playwright install
|
|
36
|
+
- run: sudo safaridriver --enable
|
|
32
37
|
- run: npm test
|
|
38
|
+
env:
|
|
39
|
+
SAFARI: "1"
|
|
33
40
|
- uses: codecov/codecov-action@v7
|
|
34
41
|
with:
|
|
35
|
-
|
|
42
|
+
use_oidc: true
|
|
36
43
|
flags: css
|
|
37
44
|
files: lcov.info
|
package/CONTRIBUTING.md
CHANGED
|
@@ -4,9 +4,21 @@ To run tests locally, follow these steps:
|
|
|
4
4
|
|
|
5
5
|
```console
|
|
6
6
|
npm ci
|
|
7
|
+
npx playwright install
|
|
7
8
|
npm test
|
|
8
9
|
```
|
|
9
10
|
|
|
11
|
+
The browser tests render every fixture with the original and transformed CSS and
|
|
12
|
+
compare the computed styles of all elements in Chromium, Firefox, WebKit, and
|
|
13
|
+
Safari. Register fixtures in the test file of the transformer they cover.
|
|
14
|
+
|
|
15
|
+
Real Safari runs only on macOS with Remote Automation enabled:
|
|
16
|
+
|
|
17
|
+
```console
|
|
18
|
+
sudo safaridriver --enable
|
|
19
|
+
npm run test:safari
|
|
20
|
+
```
|
|
21
|
+
|
|
10
22
|
We use pre-commit hooks to lint and format code before committing.
|
|
11
23
|
|
|
12
24
|
To install the pre-commit hooks, run:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cssupgrade",
|
|
3
|
-
"version": "2026.0.
|
|
3
|
+
"version": "2026.0.2",
|
|
4
4
|
"description": "Auto-upgrade your CSS syntax",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
"cssupgrade": "./bin/cssupgrade.js"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
|
-
"test": "node --test --experimental-test-coverage --test-reporter=spec --test-reporter=lcov --test-reporter-destination=stdout --test-reporter-destination=lcov.info --test-coverage-exclude=tests/**/* tests/*.test.js tests/**/*.test.js"
|
|
11
|
+
"test": "node --test --experimental-test-coverage --test-reporter=spec --test-reporter=lcov --test-reporter-destination=stdout --test-reporter-destination=lcov.info --test-coverage-exclude=tests/**/* tests/*.test.js tests/**/*.test.js",
|
|
12
|
+
"test:safari": "SAFARI=1 node --test --test-reporter=spec --test-name-pattern=\"computed styles safari\" tests/*.test.js tests/**/*.test.js"
|
|
12
13
|
},
|
|
13
14
|
"keywords": [
|
|
14
15
|
"css",
|
|
@@ -32,5 +33,9 @@
|
|
|
32
33
|
"repository": {
|
|
33
34
|
"type": "git",
|
|
34
35
|
"url": "https://github.com/codingjoe/cssupgrade.git"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"playwright": "^1.65.0",
|
|
39
|
+
"selenium-webdriver": "^4.51.0"
|
|
35
40
|
}
|
|
36
41
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { generate } from "css-tree"
|
|
1
2
|
import { parseCss } from "../parser.js"
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -40,13 +41,16 @@ const selectorListPseudoClasses = new Set([
|
|
|
40
41
|
"-webkit-any",
|
|
41
42
|
"any",
|
|
42
43
|
"has",
|
|
43
|
-
"host",
|
|
44
|
-
"host-context",
|
|
45
44
|
"is",
|
|
46
45
|
"matches",
|
|
47
46
|
"not",
|
|
48
47
|
])
|
|
49
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Pseudo-classes whose specificity is a pseudo-class plus their argument.
|
|
51
|
+
*/
|
|
52
|
+
const hostPseudoClasses = new Set(["host", "host-context"])
|
|
53
|
+
|
|
50
54
|
/**
|
|
51
55
|
* Pseudo-classes that select the nth element and accept an optional selector list.
|
|
52
56
|
*/
|
|
@@ -58,19 +62,9 @@ const nthPseudoClasses = new Set(["nth-child", "nth-last-child"])
|
|
|
58
62
|
const plainFunctionalPseudoClasses = new Set(["dir", "lang", "state"])
|
|
59
63
|
|
|
60
64
|
/**
|
|
61
|
-
*
|
|
62
|
-
*/
|
|
63
|
-
const selectorContinuationPattern = /^[\s>+~.#:[]/
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Relative combinator at the start of a nested selector.
|
|
65
|
+
* CSS whitespace characters.
|
|
67
66
|
*/
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Leading whitespace of the first selector in a nested selector.
|
|
72
|
-
*/
|
|
73
|
-
const leadingWhitespacePattern = /^\s/
|
|
67
|
+
const cssWhitespaceCharacters = new Set([" ", "\t", "\n", "\r", "\f"])
|
|
74
68
|
|
|
75
69
|
/**
|
|
76
70
|
* Add two specificity values.
|
|
@@ -200,6 +194,16 @@ function calculatePseudoClassSpecificity(node) {
|
|
|
200
194
|
return [0, 1, 0]
|
|
201
195
|
case selectorListPseudoClasses.has(name):
|
|
202
196
|
return calculateSelectorArgumentSpecificity(node)
|
|
197
|
+
case hostPseudoClasses.has(name): {
|
|
198
|
+
const argumentSpecificity = calculateSelectorArgumentSpecificity(node)
|
|
199
|
+
|
|
200
|
+
switch (argumentSpecificity) {
|
|
201
|
+
case null:
|
|
202
|
+
return null
|
|
203
|
+
default:
|
|
204
|
+
return addSpecificity([0, 1, 0], argumentSpecificity)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
203
207
|
case nthPseudoClasses.has(name): {
|
|
204
208
|
const argumentSpecificity = calculateNthSelectorSpecificity(node)
|
|
205
209
|
|
|
@@ -286,16 +290,14 @@ function isPlainRule(rule) {
|
|
|
286
290
|
}
|
|
287
291
|
|
|
288
292
|
/**
|
|
289
|
-
*
|
|
293
|
+
* Check whether two selector nodes serialize identically.
|
|
290
294
|
*
|
|
291
|
-
* @param {
|
|
292
|
-
* @param {import("css-tree").CssNode}
|
|
293
|
-
* @returns {
|
|
295
|
+
* @param {import("css-tree").CssNode} left - First selector node.
|
|
296
|
+
* @param {import("css-tree").CssNode} right - Second selector node.
|
|
297
|
+
* @returns {boolean} True when both nodes are equal.
|
|
294
298
|
*/
|
|
295
|
-
function
|
|
296
|
-
return
|
|
297
|
-
code.slice(selector.loc.start.offset, selector.loc.end.offset).trim(),
|
|
298
|
-
)
|
|
299
|
+
function isEqualSelectorNode(left, right) {
|
|
300
|
+
return generate(left) === generate(right)
|
|
299
301
|
}
|
|
300
302
|
|
|
301
303
|
/**
|
|
@@ -332,47 +334,146 @@ function hasCommentOutsideBlock(comments, rule) {
|
|
|
332
334
|
}
|
|
333
335
|
|
|
334
336
|
/**
|
|
335
|
-
*
|
|
337
|
+
* Describe the remainder of a child selector that starts with a combinator.
|
|
336
338
|
*
|
|
337
|
-
* @param {string}
|
|
338
|
-
* @param {
|
|
339
|
-
* @
|
|
339
|
+
* @param {string} code - CSS source text.
|
|
340
|
+
* @param {import("css-tree").CssNode} childSelector - Child selector.
|
|
341
|
+
* @param {import("css-tree").CssNode} combinator - Combinator node.
|
|
342
|
+
* @param {import("css-tree").CssNode} next - First node after the combinator.
|
|
343
|
+
* @returns {{kind: string, text: string}} Child selector remainder.
|
|
340
344
|
*/
|
|
341
|
-
function
|
|
342
|
-
switch (
|
|
343
|
-
case
|
|
344
|
-
return
|
|
345
|
-
|
|
346
|
-
|
|
345
|
+
function describeCombinatorRemainder(code, childSelector, combinator, next) {
|
|
346
|
+
switch (combinator.name.trim()) {
|
|
347
|
+
case "":
|
|
348
|
+
return {
|
|
349
|
+
kind: "descendant",
|
|
350
|
+
text: code.slice(next.loc.start.offset, childSelector.loc.end.offset),
|
|
351
|
+
}
|
|
352
|
+
default:
|
|
353
|
+
return {
|
|
354
|
+
kind: "relative",
|
|
355
|
+
text: code.slice(combinator.loc.start.offset, childSelector.loc.end.offset),
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
347
359
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
360
|
+
/**
|
|
361
|
+
* Describe the remainder of a child selector after a parent selector.
|
|
362
|
+
*
|
|
363
|
+
* @param {string} code - CSS source text.
|
|
364
|
+
* @param {import("css-tree").CssNode} childSelector - Child selector.
|
|
365
|
+
* @param {import("css-tree").CssNode[]} childNodes - Child selector nodes.
|
|
366
|
+
* @param {number} index - Index of the first node after the parent selector.
|
|
367
|
+
* @returns {{kind: string, text: string} | null} Remainder, or null when incomplete.
|
|
368
|
+
*/
|
|
369
|
+
function describeSelectorRemainder(code, childSelector, childNodes, index) {
|
|
370
|
+
const boundary = childNodes[index]
|
|
371
|
+
|
|
372
|
+
switch (boundary.type) {
|
|
373
|
+
case "Combinator": {
|
|
374
|
+
const next = childNodes[index + 1]
|
|
375
|
+
|
|
376
|
+
switch (next === undefined) {
|
|
377
|
+
case true:
|
|
352
378
|
return null
|
|
353
379
|
default:
|
|
354
|
-
return
|
|
380
|
+
return describeCombinatorRemainder(code, childSelector, boundary, next)
|
|
355
381
|
}
|
|
356
382
|
}
|
|
383
|
+
default:
|
|
384
|
+
return {
|
|
385
|
+
kind: "compound",
|
|
386
|
+
text: code.slice(boundary.loc.start.offset, childSelector.loc.end.offset),
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Match a parent selector against the start of a child selector.
|
|
393
|
+
*
|
|
394
|
+
* @param {string} code - CSS source text.
|
|
395
|
+
* @param {import("css-tree").CssNode} parentSelector - Parent selector.
|
|
396
|
+
* @param {import("css-tree").CssNode} childSelector - Child selector.
|
|
397
|
+
* @returns {{kind: string, text: string} | null} Remainder, or null without a match.
|
|
398
|
+
*/
|
|
399
|
+
function matchSelectorPrefix(code, parentSelector, childSelector) {
|
|
400
|
+
const parentNodes = [...parentSelector.children]
|
|
401
|
+
const childNodes = [...childSelector.children]
|
|
402
|
+
|
|
403
|
+
switch (true) {
|
|
404
|
+
case parentNodes.length >= childNodes.length:
|
|
405
|
+
return null
|
|
406
|
+
case !parentNodes.every((node, index) =>
|
|
407
|
+
isEqualSelectorNode(node, childNodes[index]),
|
|
408
|
+
):
|
|
409
|
+
return null
|
|
410
|
+
default:
|
|
411
|
+
return describeSelectorRemainder(
|
|
412
|
+
code,
|
|
413
|
+
childSelector,
|
|
414
|
+
childNodes,
|
|
415
|
+
parentNodes.length,
|
|
416
|
+
)
|
|
357
417
|
}
|
|
358
418
|
}
|
|
359
419
|
|
|
360
420
|
/**
|
|
361
421
|
* Render the remainder of a child selector as a nested selector.
|
|
362
422
|
*
|
|
363
|
-
* @param {string} remainder - Child selector
|
|
423
|
+
* @param {{kind: string, text: string}} remainder - Child selector remainder.
|
|
364
424
|
* @returns {string} Nested selector.
|
|
365
425
|
*/
|
|
366
426
|
function renderNestedSelector(remainder) {
|
|
367
|
-
|
|
427
|
+
switch (remainder.kind) {
|
|
428
|
+
case "descendant":
|
|
429
|
+
return remainder.text
|
|
430
|
+
case "relative":
|
|
431
|
+
return `& ${remainder.text}`
|
|
432
|
+
default:
|
|
433
|
+
return `&${remainder.text}`
|
|
434
|
+
}
|
|
435
|
+
}
|
|
368
436
|
|
|
437
|
+
/**
|
|
438
|
+
* Count the backslashes that end a string.
|
|
439
|
+
*
|
|
440
|
+
* @param {string} text - CSS source text.
|
|
441
|
+
* @returns {number} Number of trailing backslashes.
|
|
442
|
+
*/
|
|
443
|
+
function countTrailingBackslashes(text) {
|
|
444
|
+
switch (text.endsWith("\\")) {
|
|
445
|
+
case true:
|
|
446
|
+
return countTrailingBackslashes(text.slice(0, -1)) + 1
|
|
447
|
+
default:
|
|
448
|
+
return 0
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Check whether a character is escaped by a preceding backslash.
|
|
454
|
+
*
|
|
455
|
+
* @param {string} text - CSS source text.
|
|
456
|
+
* @param {number} index - Index of the character to inspect.
|
|
457
|
+
* @returns {boolean} True when the character is escaped.
|
|
458
|
+
*/
|
|
459
|
+
function isEscapedCharacter(text, index) {
|
|
460
|
+
return countTrailingBackslashes(text.slice(0, index)) % 2 === 1
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Remove trailing whitespace that is not part of an escape.
|
|
465
|
+
*
|
|
466
|
+
* @param {string} text - Declaration source text.
|
|
467
|
+
* @returns {string} Text without insignificant trailing whitespace.
|
|
468
|
+
*/
|
|
469
|
+
function trimDeclarationText(text) {
|
|
369
470
|
switch (true) {
|
|
370
|
-
case
|
|
371
|
-
return
|
|
372
|
-
case
|
|
471
|
+
case !cssWhitespaceCharacters.has(text.at(-1)):
|
|
472
|
+
return text
|
|
473
|
+
case isEscapedCharacter(text, text.length - 1):
|
|
373
474
|
return text
|
|
374
475
|
default:
|
|
375
|
-
return
|
|
476
|
+
return trimDeclarationText(text.slice(0, -1))
|
|
376
477
|
}
|
|
377
478
|
}
|
|
378
479
|
|
|
@@ -397,13 +498,15 @@ function findNestedPrelude(code, parent, child, comments) {
|
|
|
397
498
|
break
|
|
398
499
|
}
|
|
399
500
|
|
|
400
|
-
const parentSelectors =
|
|
401
|
-
const childSelectors =
|
|
501
|
+
const parentSelectors = [...parent.prelude.children]
|
|
502
|
+
const childSelectors = [...child.prelude.children]
|
|
402
503
|
|
|
403
504
|
switch (true) {
|
|
404
|
-
case new Set(parentSelectors).size !==
|
|
505
|
+
case new Set(parentSelectors.map((selector) => generate(selector))).size !==
|
|
506
|
+
parentSelectors.length:
|
|
405
507
|
return null
|
|
406
|
-
case new Set(childSelectors).size !==
|
|
508
|
+
case new Set(childSelectors.map((selector) => generate(selector))).size !==
|
|
509
|
+
childSelectors.length:
|
|
407
510
|
return null
|
|
408
511
|
default:
|
|
409
512
|
break
|
|
@@ -423,11 +526,10 @@ function findNestedPrelude(code, parent, child, comments) {
|
|
|
423
526
|
}
|
|
424
527
|
|
|
425
528
|
const matches = childSelectors.map((selector) => ({
|
|
426
|
-
selector,
|
|
427
529
|
parents: parentSelectors
|
|
428
530
|
.map((parentSelector) => ({
|
|
429
531
|
parentSelector,
|
|
430
|
-
remainder: matchSelectorPrefix(parentSelector, selector),
|
|
532
|
+
remainder: matchSelectorPrefix(code, parentSelector, selector),
|
|
431
533
|
}))
|
|
432
534
|
.filter(({ remainder }) => remainder !== null),
|
|
433
535
|
}))
|
|
@@ -443,7 +545,7 @@ function findNestedPrelude(code, parent, child, comments) {
|
|
|
443
545
|
parentSelector,
|
|
444
546
|
remainders: matches
|
|
445
547
|
.filter(({ parents }) => parents[0].parentSelector === parentSelector)
|
|
446
|
-
.map(({ parents }) => parents[0].remainder),
|
|
548
|
+
.map(({ parents }) => renderNestedSelector(parents[0].remainder)),
|
|
447
549
|
}))
|
|
448
550
|
const remainders = suffixes[0].remainders
|
|
449
551
|
|
|
@@ -451,13 +553,11 @@ function findNestedPrelude(code, parent, child, comments) {
|
|
|
451
553
|
case suffixes.some(
|
|
452
554
|
({ remainders: candidate }) =>
|
|
453
555
|
candidate.length !== remainders.length ||
|
|
454
|
-
candidate.some(
|
|
455
|
-
(remainder, index) => remainder.trim() !== remainders[index].trim(),
|
|
456
|
-
),
|
|
556
|
+
candidate.some((remainder, index) => remainder !== remainders[index]),
|
|
457
557
|
):
|
|
458
558
|
return null
|
|
459
559
|
default:
|
|
460
|
-
return remainders.
|
|
560
|
+
return remainders.join(", ")
|
|
461
561
|
}
|
|
462
562
|
}
|
|
463
563
|
|
|
@@ -575,7 +675,10 @@ class NestingModernizer {
|
|
|
575
675
|
case true: {
|
|
576
676
|
const group = {
|
|
577
677
|
node: child,
|
|
578
|
-
prelude:
|
|
678
|
+
prelude: this.#code.slice(
|
|
679
|
+
child.prelude.loc.start.offset,
|
|
680
|
+
child.prelude.loc.end.offset,
|
|
681
|
+
),
|
|
579
682
|
children: [],
|
|
580
683
|
leadingComments: [],
|
|
581
684
|
}
|
|
@@ -763,9 +866,9 @@ class NestingModernizer {
|
|
|
763
866
|
* @returns {string} Declaration text.
|
|
764
867
|
*/
|
|
765
868
|
#declarationText(declaration) {
|
|
766
|
-
return `${
|
|
767
|
-
.slice(declaration.loc.start.offset, declaration.loc.end.offset)
|
|
768
|
-
|
|
869
|
+
return `${trimDeclarationText(
|
|
870
|
+
this.#code.slice(declaration.loc.start.offset, declaration.loc.end.offset),
|
|
871
|
+
)};`
|
|
769
872
|
}
|
|
770
873
|
|
|
771
874
|
/**
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { after, before, describe, test } from "node:test"
|
|
3
|
+
import { chromium, firefox, webkit } from "playwright"
|
|
4
|
+
import { Builder, By } from "selenium-webdriver"
|
|
5
|
+
import { transform } from "../src/index.js"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {Object} ComputedStyleFixture
|
|
9
|
+
* @property {string} name - Scenario in imperative mood.
|
|
10
|
+
* @property {string} html - Markup rendered with the original and transformed CSS.
|
|
11
|
+
* @property {string} css - CSS source that the transformation rewrites.
|
|
12
|
+
* @property {string[]} [hover] - Selectors to hover before reading the computed styles.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {Object} ComputedElement
|
|
17
|
+
* @property {string} tagName - Lowercase tag name.
|
|
18
|
+
* @property {string} id - Element id, or an empty string.
|
|
19
|
+
* @property {string[]} classes - Class names.
|
|
20
|
+
* @property {Record<string, string>} styles - Computed value per pseudo-element and property.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {Object} BrowserSession
|
|
25
|
+
* @property {function({html: string, hover?: string[]}, string): Promise<Record<string, string>>} readStyles - Render a stylesheet and read the computed styles.
|
|
26
|
+
* @property {function(): Promise<void>} close - Stop the browser.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Playwright engines that must show the same styles before and after a transformation.
|
|
31
|
+
*/
|
|
32
|
+
const playwrightEngines = { chromium, firefox, webkit }
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Baselines whose transformations must preserve the computed styles.
|
|
36
|
+
*/
|
|
37
|
+
const baselines = ["widely-available", "newly-available"]
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Read every computed property of every element and its pseudo-elements.
|
|
41
|
+
*
|
|
42
|
+
* Runs in the browser, so it must not reference the module scope.
|
|
43
|
+
*
|
|
44
|
+
* @returns {ComputedElement[]} Computed styles in document order.
|
|
45
|
+
*/
|
|
46
|
+
function collectComputedStyles() {
|
|
47
|
+
const pseudoElements = [null, "::before", "::after", "::marker"]
|
|
48
|
+
|
|
49
|
+
return [...document.querySelectorAll("*")].map((element) => ({
|
|
50
|
+
tagName: element.tagName.toLowerCase(),
|
|
51
|
+
id: element.id,
|
|
52
|
+
classes: [...element.classList],
|
|
53
|
+
styles: Object.fromEntries(
|
|
54
|
+
pseudoElements.flatMap((pseudoElement) => {
|
|
55
|
+
const computed = getComputedStyle(element, pseudoElement)
|
|
56
|
+
|
|
57
|
+
return [...computed].map((property) => [
|
|
58
|
+
`${pseudoElement ?? "self"} ${property}`,
|
|
59
|
+
computed.getPropertyValue(property),
|
|
60
|
+
])
|
|
61
|
+
}),
|
|
62
|
+
),
|
|
63
|
+
}))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Format an element for a difference message.
|
|
68
|
+
*
|
|
69
|
+
* @param {ComputedElement} element - Computed element.
|
|
70
|
+
* @param {number} index - Position in document order.
|
|
71
|
+
* @returns {string} Element name with id and classes.
|
|
72
|
+
*/
|
|
73
|
+
function formatElement({ tagName, id, classes }, index) {
|
|
74
|
+
return `${index} ${tagName}${id === "" ? "" : `#${id}`}${classes
|
|
75
|
+
.map((name) => `.${name}`)
|
|
76
|
+
.join("")}`
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Flatten the computed styles of all elements into one record.
|
|
81
|
+
*
|
|
82
|
+
* @param {ComputedElement[]} elements - Computed styles in document order.
|
|
83
|
+
* @returns {Record<string, string>} Computed value per element, pseudo-element, and property.
|
|
84
|
+
*/
|
|
85
|
+
function flattenStyles(elements) {
|
|
86
|
+
return Object.fromEntries(
|
|
87
|
+
elements.flatMap((element, index) =>
|
|
88
|
+
Object.entries(element.styles).map(([key, value]) => [
|
|
89
|
+
`${formatElement(element, index)} ${key}`,
|
|
90
|
+
value,
|
|
91
|
+
]),
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* List the computed styles that differ between two snapshots.
|
|
98
|
+
*
|
|
99
|
+
* @param {Record<string, string>} before - Styles of the original source.
|
|
100
|
+
* @param {Record<string, string>} after - Styles of the transformed source.
|
|
101
|
+
* @returns {string[]} One entry per changed property.
|
|
102
|
+
*/
|
|
103
|
+
function calculateDifferences(before, after) {
|
|
104
|
+
return [...new Set([...Object.keys(before), ...Object.keys(after)])]
|
|
105
|
+
.filter((key) => before[key] !== after[key])
|
|
106
|
+
.sort()
|
|
107
|
+
.map((key) => `${key}: ${before[key]} -> ${after[key]}`)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Replace the document with markup.
|
|
112
|
+
*
|
|
113
|
+
* Runs in the browser, so it must not reference the module scope.
|
|
114
|
+
*
|
|
115
|
+
* @param {string} markup - Document markup.
|
|
116
|
+
* @returns {void}
|
|
117
|
+
*/
|
|
118
|
+
function writeDocument(markup) {
|
|
119
|
+
document.open()
|
|
120
|
+
document.write(markup)
|
|
121
|
+
document.close()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Drives a Playwright browser.
|
|
126
|
+
*/
|
|
127
|
+
class PlaywrightSession {
|
|
128
|
+
#browser
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* @param {import("playwright").Browser} browser - Launched browser.
|
|
132
|
+
*/
|
|
133
|
+
constructor(browser) {
|
|
134
|
+
this.#browser = browser
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Launch a Playwright browser engine.
|
|
139
|
+
*
|
|
140
|
+
* @param {import("playwright").BrowserType} engine - Browser engine.
|
|
141
|
+
* @returns {Promise<PlaywrightSession>} Launched session.
|
|
142
|
+
*/
|
|
143
|
+
static async launch(engine) {
|
|
144
|
+
return new PlaywrightSession(await engine.launch())
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Render markup with a stylesheet and read the computed styles.
|
|
149
|
+
*
|
|
150
|
+
* @param {{html: string, hover?: string[]}} fixture - Markup and selectors to hover.
|
|
151
|
+
* @param {string} css - Stylesheet to apply.
|
|
152
|
+
* @returns {Promise<Record<string, string>>} Computed styles of all elements.
|
|
153
|
+
*/
|
|
154
|
+
async readStyles(fixture, css) {
|
|
155
|
+
const page = await this.#browser.newPage()
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
await page.setContent(`<!doctype html><style>${css}</style>${fixture.html}`)
|
|
159
|
+
|
|
160
|
+
for (const selector of fixture.hover ?? []) {
|
|
161
|
+
await page.hover(selector)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return flattenStyles(await page.evaluate(collectComputedStyles))
|
|
165
|
+
} finally {
|
|
166
|
+
await page.close()
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Stop the browser.
|
|
172
|
+
*
|
|
173
|
+
* @returns {Promise<void>} Resolves when the browser stopped.
|
|
174
|
+
*/
|
|
175
|
+
async close() {
|
|
176
|
+
await this.#browser.close()
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Drives Safari through SafariDriver.
|
|
182
|
+
*/
|
|
183
|
+
class SafariSession {
|
|
184
|
+
#driver
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* @param {import("selenium-webdriver").WebDriver} driver - SafariDriver session.
|
|
188
|
+
*/
|
|
189
|
+
constructor(driver) {
|
|
190
|
+
this.#driver = driver
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Launch Safari.
|
|
195
|
+
*
|
|
196
|
+
* @returns {Promise<SafariSession>} Launched session.
|
|
197
|
+
*/
|
|
198
|
+
static async launch() {
|
|
199
|
+
return new SafariSession(await new Builder().forBrowser("safari").build())
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Render markup with a stylesheet and read the computed styles.
|
|
204
|
+
*
|
|
205
|
+
* @param {{html: string, hover?: string[]}} fixture - Markup and selectors to hover.
|
|
206
|
+
* @param {string} css - Stylesheet to apply.
|
|
207
|
+
* @returns {Promise<Record<string, string>>} Computed styles of all elements.
|
|
208
|
+
*/
|
|
209
|
+
async readStyles(fixture, css) {
|
|
210
|
+
await this.#driver.get("about:blank")
|
|
211
|
+
await this.#driver.actions().move({ x: 0, y: 0 }).perform()
|
|
212
|
+
await this.#driver.executeScript(
|
|
213
|
+
writeDocument,
|
|
214
|
+
`<!doctype html><style>${css}</style>${fixture.html}`,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
for (const selector of fixture.hover ?? []) {
|
|
218
|
+
const element = await this.#driver.findElement(By.css(selector))
|
|
219
|
+
|
|
220
|
+
await this.#driver.actions().move({ origin: element }).perform()
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return flattenStyles(await this.#driver.executeScript(collectComputedStyles))
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Stop Safari.
|
|
228
|
+
*
|
|
229
|
+
* @returns {Promise<void>} Resolves when Safari stopped.
|
|
230
|
+
*/
|
|
231
|
+
async close() {
|
|
232
|
+
await this.#driver.quit()
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Verify that a fixture keeps its computed styles after the transformation.
|
|
238
|
+
*
|
|
239
|
+
* @param {BrowserSession} session - Browser session.
|
|
240
|
+
* @param {ComputedStyleFixture} fixture - Fixture to transform.
|
|
241
|
+
* @returns {Promise<void>} Resolves when the styles match.
|
|
242
|
+
*/
|
|
243
|
+
async function verifyComputedStyles(session, fixture) {
|
|
244
|
+
const transformed = baselines
|
|
245
|
+
.map((baseline) => ({ baseline, ...transform(fixture.css, baseline) }))
|
|
246
|
+
.filter(({ modified }) => modified)
|
|
247
|
+
|
|
248
|
+
assert(transformed.length > 0, "must transform the fixture")
|
|
249
|
+
|
|
250
|
+
const before = await session.readStyles(fixture, fixture.css)
|
|
251
|
+
|
|
252
|
+
for (const { baseline, code } of transformed) {
|
|
253
|
+
const after = await session.readStyles(fixture, code)
|
|
254
|
+
|
|
255
|
+
assert.deepEqual(
|
|
256
|
+
calculateDifferences(before, after),
|
|
257
|
+
[],
|
|
258
|
+
`differs with the ${baseline} baseline`,
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Register computed style tests for one browser.
|
|
265
|
+
*
|
|
266
|
+
* @param {string} name - Browser name.
|
|
267
|
+
* @param {function(): Promise<BrowserSession>} launch - Starts the browser.
|
|
268
|
+
* @param {ComputedStyleFixture[]} fixtures - Fixtures to test.
|
|
269
|
+
* @returns {void}
|
|
270
|
+
*/
|
|
271
|
+
function describeBrowser(name, launch, fixtures) {
|
|
272
|
+
describe(name, () => {
|
|
273
|
+
let session
|
|
274
|
+
|
|
275
|
+
before(async () => {
|
|
276
|
+
session = await launch()
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
after(async () => {
|
|
280
|
+
await session.close()
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
for (const fixture of fixtures) {
|
|
284
|
+
test(fixture.name, async () => {
|
|
285
|
+
await verifyComputedStyles(session, fixture)
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
test("detect a changed declaration", async () => {
|
|
290
|
+
const fixture = { html: `<div class="box">x</div>` }
|
|
291
|
+
const before = await session.readStyles(fixture, ".box { color: red }")
|
|
292
|
+
const after = await session.readStyles(fixture, ".box { color: blue }")
|
|
293
|
+
|
|
294
|
+
assert.notDeepEqual(calculateDifferences(before, after), [])
|
|
295
|
+
})
|
|
296
|
+
})
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Compare the computed styles of every fixture before and after the transformation.
|
|
301
|
+
*
|
|
302
|
+
* Call inside the `describe` block of a transformer. The tests run in Chromium,
|
|
303
|
+
* Firefox, WebKit, and Safari for every baseline that changes the fixture source.
|
|
304
|
+
* Safari runs only when `SAFARI=1` is set.
|
|
305
|
+
*
|
|
306
|
+
* @param {ComputedStyleFixture[]} fixtures - Stylesheets and markup to test.
|
|
307
|
+
* @returns {void}
|
|
308
|
+
*/
|
|
309
|
+
export function testComputedStyles(fixtures) {
|
|
310
|
+
describe("computed styles", () => {
|
|
311
|
+
for (const [engineName, engine] of Object.entries(playwrightEngines)) {
|
|
312
|
+
describeBrowser(engineName, () => PlaywrightSession.launch(engine), fixtures)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (process.env.SAFARI === "1") {
|
|
316
|
+
describeBrowser("safari", () => SafariSession.launch(), fixtures)
|
|
317
|
+
}
|
|
318
|
+
})
|
|
319
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import assert from "node:assert/strict"
|
|
2
2
|
import { describe, test } from "node:test"
|
|
3
3
|
import { transform } from "../src/index.js"
|
|
4
|
+
import { testComputedStyles } from "./computedStyles.js"
|
|
5
|
+
import { cssNestingFixtures } from "./fixtures/cssNesting.js"
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* Assert that a transformation produces the expected source text.
|
|
@@ -510,6 +512,8 @@ describe("cssNesting", () => {
|
|
|
510
512
|
|
|
511
513
|
assert.equal(transform(input).modified, false)
|
|
512
514
|
})
|
|
515
|
+
|
|
516
|
+
testComputedStyles(cssNestingFixtures)
|
|
513
517
|
})
|
|
514
518
|
|
|
515
519
|
describe("cssNesting edge cases", () => {
|
|
@@ -819,6 +823,140 @@ describe("cssNesting host selectors", () => {
|
|
|
819
823
|
}`,
|
|
820
824
|
)
|
|
821
825
|
})
|
|
826
|
+
|
|
827
|
+
test("leave :host with a different specificity unchanged", () => {
|
|
828
|
+
const input = `:host(.a), .b { color: red; }
|
|
829
|
+
:host(.a) .c, .b .c { color: blue; }`
|
|
830
|
+
|
|
831
|
+
assert.equal(transform(input).modified, false)
|
|
832
|
+
})
|
|
833
|
+
|
|
834
|
+
test("leave :host-context with a different specificity unchanged", () => {
|
|
835
|
+
const input = `:host-context(.a), .b { color: red; }
|
|
836
|
+
:host-context(.a) .c, .b .c { color: blue; }`
|
|
837
|
+
|
|
838
|
+
assert.equal(transform(input).modified, false)
|
|
839
|
+
})
|
|
840
|
+
|
|
841
|
+
test("leave :host with an unknown selector argument unchanged", () => {
|
|
842
|
+
const input = `:host(:foo(.a)) { color: red; }
|
|
843
|
+
:host(:foo(.a)) .c { color: blue; }`
|
|
844
|
+
|
|
845
|
+
assert.equal(transform(input).modified, false)
|
|
846
|
+
})
|
|
847
|
+
})
|
|
848
|
+
|
|
849
|
+
describe("cssNesting escaped selectors", () => {
|
|
850
|
+
test("leave a hex escape that consumes whitespace unchanged", () => {
|
|
851
|
+
const input = String.raw`.\31 { color: red; }
|
|
852
|
+
.\31 23 { color: blue; }`
|
|
853
|
+
|
|
854
|
+
assert.equal(transform(input).modified, false)
|
|
855
|
+
})
|
|
856
|
+
|
|
857
|
+
test("leave a class name that continues a hex escape unchanged", () => {
|
|
858
|
+
const input = String.raw`.m-1\31 { color: red; }
|
|
859
|
+
.m-1\31 0 { color: blue; }`
|
|
860
|
+
|
|
861
|
+
assert.equal(transform(input).modified, false)
|
|
862
|
+
})
|
|
863
|
+
|
|
864
|
+
test("nest a compound selector with an escaped space", () => {
|
|
865
|
+
assertTransform(
|
|
866
|
+
String.raw`.a\ { color: red; }
|
|
867
|
+
.a\ .b { color: blue; }`,
|
|
868
|
+
String.raw`.a\ {
|
|
869
|
+
color: red;
|
|
870
|
+
&.b {
|
|
871
|
+
color: blue;
|
|
872
|
+
}
|
|
873
|
+
}`,
|
|
874
|
+
)
|
|
875
|
+
})
|
|
876
|
+
|
|
877
|
+
test("nest a parent list with escaped spaces", () => {
|
|
878
|
+
assertTransform(
|
|
879
|
+
String.raw`.a\ , .b { color: red; }
|
|
880
|
+
.a\ .c, .b .c { color: blue; }`,
|
|
881
|
+
String.raw`.a\ , .b {
|
|
882
|
+
color: red;
|
|
883
|
+
.c {
|
|
884
|
+
color: blue;
|
|
885
|
+
}
|
|
886
|
+
}`,
|
|
887
|
+
)
|
|
888
|
+
})
|
|
889
|
+
|
|
890
|
+
test("nest a parent with a non-breaking space in its selector", () => {
|
|
891
|
+
assertTransform(
|
|
892
|
+
".a\u00A0 { color: red; }\n.a\u00A0 .b { color: blue; }",
|
|
893
|
+
".a\u00A0 {\n color: red;\n .b {\n color: blue;\n }\n}",
|
|
894
|
+
)
|
|
895
|
+
})
|
|
896
|
+
|
|
897
|
+
test("leave a child joined by a non-breaking space unchanged", () => {
|
|
898
|
+
const input = ".a { color: red; }\n.a\u00A0.b { color: blue; }"
|
|
899
|
+
|
|
900
|
+
assert.equal(transform(input).modified, false)
|
|
901
|
+
})
|
|
902
|
+
})
|
|
903
|
+
|
|
904
|
+
describe("cssNesting escaped declarations", () => {
|
|
905
|
+
test("preserve a declaration with a trailing escaped space", () => {
|
|
906
|
+
assertTransform(
|
|
907
|
+
String.raw`.a { --x: \ }
|
|
908
|
+
.a .b { color: blue; }`,
|
|
909
|
+
String.raw`.a {
|
|
910
|
+
--x: \ ;
|
|
911
|
+
.b {
|
|
912
|
+
color: blue;
|
|
913
|
+
}
|
|
914
|
+
}`,
|
|
915
|
+
)
|
|
916
|
+
})
|
|
917
|
+
|
|
918
|
+
test("preserve declarations after a trailing escaped space", () => {
|
|
919
|
+
assertTransform(
|
|
920
|
+
String.raw`.a { --x: \ ; color: red; }
|
|
921
|
+
.a .b { color: blue; }`,
|
|
922
|
+
String.raw`.a {
|
|
923
|
+
--x: \ ;
|
|
924
|
+
color: red;
|
|
925
|
+
.b {
|
|
926
|
+
color: blue;
|
|
927
|
+
}
|
|
928
|
+
}`,
|
|
929
|
+
)
|
|
930
|
+
})
|
|
931
|
+
|
|
932
|
+
test("trim whitespace after an escaped backslash", () => {
|
|
933
|
+
assertTransform(
|
|
934
|
+
String.raw`.a { --x: \\ }
|
|
935
|
+
.a .b { color: blue; }`,
|
|
936
|
+
String.raw`.a {
|
|
937
|
+
--x: \\;
|
|
938
|
+
.b {
|
|
939
|
+
color: blue;
|
|
940
|
+
}
|
|
941
|
+
}`,
|
|
942
|
+
)
|
|
943
|
+
})
|
|
944
|
+
})
|
|
945
|
+
|
|
946
|
+
describe("cssNesting selector boundaries", () => {
|
|
947
|
+
test("leave a child with a trailing combinator unchanged", () => {
|
|
948
|
+
const input = `.a { color: red; }
|
|
949
|
+
.a > { color: blue; }`
|
|
950
|
+
|
|
951
|
+
assert.equal(transform(input).modified, false)
|
|
952
|
+
})
|
|
953
|
+
|
|
954
|
+
test("leave a shorter child selector unchanged", () => {
|
|
955
|
+
const input = `.a .b { color: red; }
|
|
956
|
+
.a { color: blue; }`
|
|
957
|
+
|
|
958
|
+
assert.equal(transform(input).modified, false)
|
|
959
|
+
})
|
|
822
960
|
})
|
|
823
961
|
|
|
824
962
|
describe("cssNesting line endings", () => {
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {import("../computedStyles.js").ComputedStyleFixture} ComputedStyleFixture
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Stylesheets and matching markup for the cssNesting computed style tests.
|
|
7
|
+
*
|
|
8
|
+
* @type {ComputedStyleFixture[]}
|
|
9
|
+
*/
|
|
10
|
+
export const cssNestingFixtures = [
|
|
11
|
+
{
|
|
12
|
+
name: "nest descendant and compound selectors",
|
|
13
|
+
html: `
|
|
14
|
+
<article class="card primary"><h2 class="title">A</h2></article>
|
|
15
|
+
<article class="card"><h2 class="title">B</h2></article>`,
|
|
16
|
+
css: `
|
|
17
|
+
.card {
|
|
18
|
+
color: red;
|
|
19
|
+
padding: 1rem;
|
|
20
|
+
}
|
|
21
|
+
.card .title {
|
|
22
|
+
color: blue;
|
|
23
|
+
font-weight: bold;
|
|
24
|
+
}
|
|
25
|
+
.card.primary {
|
|
26
|
+
border: 1px solid green;
|
|
27
|
+
}
|
|
28
|
+
.card.primary .title {
|
|
29
|
+
text-decoration: underline;
|
|
30
|
+
}`,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "nest combinators",
|
|
34
|
+
html: `
|
|
35
|
+
<div class="card"><span class="title">A</span><span class="subtitle">B</span></div>
|
|
36
|
+
<div class="card"><span class="title">C</span><span class="subtitle">D</span></div>
|
|
37
|
+
<div class="card"><span class="title">E</span><span class="subtitle">F</span></div>`,
|
|
38
|
+
css: `
|
|
39
|
+
.card { color: red }
|
|
40
|
+
.card > .title { color: blue }
|
|
41
|
+
.card + .card { margin-top: 1rem }
|
|
42
|
+
.card ~ .card { opacity: 0.5 }
|
|
43
|
+
.card > .title + .subtitle { color: green }`,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: "nest selectors of equal specificity",
|
|
47
|
+
html: `
|
|
48
|
+
<div class="a"><span class="child">A</span></div>
|
|
49
|
+
<div class="b"><span class="child">B</span></div>
|
|
50
|
+
<ul class="list"><li>1</li><li class="skip">2</li><li>3</li></ul>`,
|
|
51
|
+
css: `
|
|
52
|
+
.a, .b { color: red }
|
|
53
|
+
.a .child, .b .child { color: blue }
|
|
54
|
+
.a .child:hover, .b .child:hover { color: green }
|
|
55
|
+
.list li { color: purple }
|
|
56
|
+
.list li:nth-child(2n + 1) { color: orange }
|
|
57
|
+
.list li:not(.skip) { text-decoration: underline }
|
|
58
|
+
.list li:where(.skip) { opacity: 0.5 }`,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: "nest pseudo-classes on functional selectors",
|
|
62
|
+
html: `
|
|
63
|
+
<div class="alpha"><span class="content">A</span></div>
|
|
64
|
+
<div class="beta"><span class="content">B</span></div>`,
|
|
65
|
+
css: `
|
|
66
|
+
:is(.alpha, .beta) .content { color: red }
|
|
67
|
+
:is(.alpha, .beta) .content:hover { color: blue }
|
|
68
|
+
:not(.alpha) .content { font-style: italic }`,
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: "nest pseudo-elements",
|
|
72
|
+
html: `<p class="item">text <strong>bold</strong></p>`,
|
|
73
|
+
css: `
|
|
74
|
+
.item { color: red }
|
|
75
|
+
.item::before { content: "before"; color: blue }
|
|
76
|
+
.item::after { content: "after"; color: green }
|
|
77
|
+
.item strong { color: purple }`,
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: "nest rules inside at-rules",
|
|
81
|
+
html: `
|
|
82
|
+
<div class="card"><h2 class="title">A <span>B</span></h2></div>
|
|
83
|
+
<div class="grid"><div>C</div></div>
|
|
84
|
+
<div class="layered">D <span>E</span></div>`,
|
|
85
|
+
css: `
|
|
86
|
+
@media (min-width: 1px) {
|
|
87
|
+
.card .title { color: blue }
|
|
88
|
+
.card .title span { color: pink }
|
|
89
|
+
}
|
|
90
|
+
@supports (display: grid) {
|
|
91
|
+
.grid { display: grid }
|
|
92
|
+
.grid > * { display: block }
|
|
93
|
+
}
|
|
94
|
+
@layer base {
|
|
95
|
+
.layered { color: teal }
|
|
96
|
+
.layered span { color: maroon }
|
|
97
|
+
}`,
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: "nest interactive states",
|
|
101
|
+
html: `<button class="button"><span class="label">A</span></button>`,
|
|
102
|
+
hover: [".button"],
|
|
103
|
+
css: `
|
|
104
|
+
.button { color: red }
|
|
105
|
+
.button:hover { color: green }
|
|
106
|
+
.button .label { color: blue }
|
|
107
|
+
.button:hover .label { color: purple }`,
|
|
108
|
+
},
|
|
109
|
+
]
|