slidev-theme-practicum 0.3.0 → 0.4.0
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/README.md +78 -4
- package/components/Slide.vue +1 -1
- package/components/Slot.vue +2 -2
- package/components/StepsGrid.vue +19 -14
- package/components/Text.vue +5 -0
- package/composables/deck-slot-markup.cjs +19 -5
- package/composables/layout-shorthands.ts +152 -67
- package/composables/text-fit-runtime.ts +7 -3
- package/composables/theme-foundation.ts +7 -3
- package/composables/typography-guard.cjs +59 -0
- package/composables/validate-deck-layouts.cjs +18 -1
- package/composables/validate-deck-typography.cjs +101 -0
- package/example.md +1 -1
- package/package.json +14 -5
- package/scripts/browser-smoke.mjs +20 -23
- package/scripts/check-accessibility.mjs +364 -0
- package/scripts/check-consumer.mjs +159 -0
- package/scripts/check-pixels.mjs +251 -0
- package/scripts/check-typography.mjs +108 -0
- package/scripts/test-typography.mjs +89 -0
- package/scripts/typography-browser.mjs +134 -0
- package/scripts/validate-deck.cjs +22 -2
- package/skills/slidev-practicum/SKILL.md +1 -0
- package/styles/index.css +20 -27
- package/styles/vars.css +6 -2
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
const { inspectTypography } = require('./typography-guard.cjs')
|
|
2
|
+
|
|
3
|
+
/** @typedef {import('./deck-slot-markup.cjs').DeckLiveNode} DeckLiveNode */
|
|
4
|
+
/** @typedef {import('./typography-guard.cjs').TypographyText} TypographyText */
|
|
5
|
+
/** @typedef {import('./typography-guard.cjs').TypographyIssue} TypographyIssue */
|
|
6
|
+
|
|
7
|
+
const EXCLUDED = new Set(['Header', 'Logo', 'Image', 'Decor', 'svg', 'img', 'script', 'style'])
|
|
8
|
+
const SIZE_PATTERN = /^(\d+)(?:\s*-\s*(\d+))?$/u
|
|
9
|
+
|
|
10
|
+
/** @param {DeckLiveNode} node @returns {string} */
|
|
11
|
+
function textContent(node) {
|
|
12
|
+
if (node.kind === 'text')
|
|
13
|
+
return node.content
|
|
14
|
+
return node.kind === 'element' ? node.children.map(textContent).join(' ') : ''
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** @param {unknown} value */
|
|
18
|
+
function isEnabled(value) {
|
|
19
|
+
return value === '' || Boolean(value)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** @param {Record<string, unknown>} props */
|
|
23
|
+
function sizePolicy(props) {
|
|
24
|
+
if (('max-size' in props && isEnabled(props['max-size'])) || ('maxSize' in props && isEnabled(props.maxSize)))
|
|
25
|
+
return '0-12'
|
|
26
|
+
const input = String(props.size ?? '2').trim()
|
|
27
|
+
const match = input.match(SIZE_PATTERN)
|
|
28
|
+
if (!match || Number(match[1]) > 12 || Number(match[2] ?? match[1]) > 12)
|
|
29
|
+
return undefined
|
|
30
|
+
const min = Math.min(Number(match[1]), Number(match[2] ?? match[1]))
|
|
31
|
+
const max = Math.max(Number(match[1]), Number(match[2] ?? match[1]))
|
|
32
|
+
return min === max ? String(min) : `${min}-${max}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Проверяет только явно объявленные Text. Сокращённая запись и стили
|
|
37
|
+
* компонентов проверяются после отображения слайда в браузере.
|
|
38
|
+
* @param {DeckLiveNode[]} children
|
|
39
|
+
* @returns {TypographyIssue[]}
|
|
40
|
+
*/
|
|
41
|
+
function validateDeclaredTypography(children) {
|
|
42
|
+
/** @type {TypographyText[]} */
|
|
43
|
+
const texts = []
|
|
44
|
+
/** @type {TypographyIssue[]} */
|
|
45
|
+
const issues = []
|
|
46
|
+
let slotIndex = 0
|
|
47
|
+
|
|
48
|
+
/** @param {DeckLiveNode} node @param {string} slot @param {string[]} inherited */
|
|
49
|
+
function visit(node, slot, inherited) {
|
|
50
|
+
if (node.kind !== 'element' || EXCLUDED.has(node.tag))
|
|
51
|
+
return
|
|
52
|
+
if (node.tag === 'Slot') {
|
|
53
|
+
slotIndex += 1
|
|
54
|
+
slot = `Slot ${slotIndex}${node.props.role ? ` (${node.props.role})` : ''}`
|
|
55
|
+
}
|
|
56
|
+
const relevantProps = node.tag === 'Text'
|
|
57
|
+
? ['size', 'max-size', 'maxSize', 'color', 'muted', 'as', 'class', 'style']
|
|
58
|
+
: ['class', 'style']
|
|
59
|
+
const dynamic = [...inherited, ...(node.dynamicProps ?? []).filter(prop =>
|
|
60
|
+
!prop.startsWith('v-bind:') || relevantProps.includes(prop.slice(7)),
|
|
61
|
+
)]
|
|
62
|
+
if (node.tag === 'Text') {
|
|
63
|
+
const text = textContent(node).replace(/\s+/gu, ' ').trim().slice(0, 100)
|
|
64
|
+
if (!text)
|
|
65
|
+
return
|
|
66
|
+
if (dynamic.length) {
|
|
67
|
+
issues.push({
|
|
68
|
+
code: 'dynamic-typography',
|
|
69
|
+
message: `«${text}»: статически не определены ${[...new Set(dynamic)].join(', ')}.`,
|
|
70
|
+
hint: 'Задайте литеральные атрибуты или проверьте фактический результат командой slidev-practicum-check-typography.',
|
|
71
|
+
})
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
const size = sizePolicy(node.props)
|
|
75
|
+
if (size === undefined) {
|
|
76
|
+
issues.push({
|
|
77
|
+
code: 'invalid-size',
|
|
78
|
+
message: `«${text}»: неверный размер ${String(node.props.size)}.`,
|
|
79
|
+
hint: 'Укажите токен 0–12 или диапазон этих токенов, например size="7-8".',
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
const color = String(node.props.color ?? 'current')
|
|
83
|
+
const muted = ('muted' in node.props && isEnabled(node.props.muted)) || color.startsWith('text-muted')
|
|
84
|
+
texts.push({
|
|
85
|
+
text,
|
|
86
|
+
size,
|
|
87
|
+
color: muted ? 'text-muted' : color,
|
|
88
|
+
slot,
|
|
89
|
+
keyNumber: String(node.props.as ?? '').toLowerCase() === 'data',
|
|
90
|
+
muted,
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
for (const child of node.children)
|
|
94
|
+
visit(child, slot, dynamic)
|
|
95
|
+
}
|
|
96
|
+
for (const child of children)
|
|
97
|
+
visit(child, 'Слайд вне Slot', [])
|
|
98
|
+
return [...issues, ...inspectTypography(texts)]
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = { validateDeclaredTypography }
|
package/example.md
CHANGED
|
@@ -557,7 +557,7 @@ items:
|
|
|
557
557
|
body: Применить в другой задаче
|
|
558
558
|
---
|
|
559
559
|
|
|
560
|
-
<!-- Контракт: steps staggered —
|
|
560
|
+
<!-- Контракт: steps staggered — от 2 до 6 шагов во frontmatter, два центрированных ряда; label сверху, title и body снизу. -->
|
|
561
561
|
|
|
562
562
|
# Как команда разбирает ошибку
|
|
563
563
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "slidev-theme-practicum",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Strict Slidev presentation theme with a 12x12 grid, editorial layouts, and low-level slot composition API",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"slidev-theme",
|
|
@@ -39,10 +39,12 @@
|
|
|
39
39
|
"favicon": "/theme/favicon.svg",
|
|
40
40
|
"fonts": {
|
|
41
41
|
"provider": "none",
|
|
42
|
-
"sans": "YS Text, Inter",
|
|
42
|
+
"sans": "YS Text, Inter, system-ui",
|
|
43
43
|
"mono": "monospace",
|
|
44
44
|
"local": [
|
|
45
45
|
"YS Text",
|
|
46
|
+
"Inter",
|
|
47
|
+
"system-ui",
|
|
46
48
|
"monospace"
|
|
47
49
|
]
|
|
48
50
|
}
|
|
@@ -53,7 +55,8 @@
|
|
|
53
55
|
"slidev": ">=0.50.0"
|
|
54
56
|
},
|
|
55
57
|
"bin": {
|
|
56
|
-
"slidev-practicum-validate": "scripts/validate-deck.cjs"
|
|
58
|
+
"slidev-practicum-validate": "scripts/validate-deck.cjs",
|
|
59
|
+
"slidev-practicum-check-typography": "scripts/check-typography.mjs"
|
|
57
60
|
},
|
|
58
61
|
"scripts": {
|
|
59
62
|
"dev": "slidev example.md",
|
|
@@ -65,7 +68,8 @@
|
|
|
65
68
|
"lint": "eslint .",
|
|
66
69
|
"lint:fix": "eslint . --fix",
|
|
67
70
|
"validate-deck": "node scripts/validate-deck.cjs",
|
|
68
|
-
"
|
|
71
|
+
"check-typography": "node scripts/check-typography.mjs",
|
|
72
|
+
"test": "npm run lint && npm run typecheck && npm run test:unit && npm run test:architecture && npm run test:build && npm run test:layout-variant-build && npm run test:build-artifact && npm run test:browser && npm run test:accessibility && npm run test:typography && npm run test:pixels && npm run test:package && npm run test:consumer",
|
|
69
73
|
"typecheck": "vue-tsc --noEmit",
|
|
70
74
|
"test:architecture": "node scripts/check-test-architecture.mjs",
|
|
71
75
|
"test:unit": "node --test tests/*.test.cjs",
|
|
@@ -74,7 +78,12 @@
|
|
|
74
78
|
"test:layout-variant-build": "node scripts/check-local-layout-variant-build.mjs",
|
|
75
79
|
"test:build-artifact": "node scripts/check-build-artifact.mjs",
|
|
76
80
|
"test:browser": "node scripts/browser-smoke.mjs",
|
|
77
|
-
"test:
|
|
81
|
+
"test:accessibility": "node scripts/check-accessibility.mjs",
|
|
82
|
+
"test:typography": "node scripts/test-typography.mjs",
|
|
83
|
+
"test:pixels": "node scripts/check-pixels.mjs",
|
|
84
|
+
"test:pixels:update": "node scripts/check-pixels.mjs --update",
|
|
85
|
+
"test:package": "node scripts/check-package.mjs",
|
|
86
|
+
"test:consumer": "node scripts/check-consumer.mjs"
|
|
78
87
|
},
|
|
79
88
|
"author": "",
|
|
80
89
|
"type": "commonjs",
|
|
@@ -146,7 +146,7 @@ async function assertStrongEmphasis(slide, slideNumber) {
|
|
|
146
146
|
assert.equal(
|
|
147
147
|
await strong.evaluate(element => getComputedStyle(element).fontWeight),
|
|
148
148
|
'600',
|
|
149
|
-
`слайд ${slideNumber}: strong должен использовать
|
|
149
|
+
`слайд ${slideNumber}: strong должен использовать полужирное начертание с весом 600`,
|
|
150
150
|
)
|
|
151
151
|
}
|
|
152
152
|
|
|
@@ -197,47 +197,44 @@ async function assertStaggeredStepsLayout(slide, slideNumber) {
|
|
|
197
197
|
const cards = grid.locator(':scope > .Slide-Step')
|
|
198
198
|
const cardCount = await cards.count()
|
|
199
199
|
|
|
200
|
-
assert.equal(cardCount, 5, `слайд ${slideNumber}: staggered должен содержать
|
|
200
|
+
assert.equal(cardCount, 5, `слайд ${slideNumber}: канонический пример staggered должен содержать 5 карточек`)
|
|
201
201
|
|
|
202
|
-
const [gridBox, cardBoxes
|
|
202
|
+
const [gridBox, cardBoxes] = await Promise.all([
|
|
203
203
|
grid.boundingBox(),
|
|
204
204
|
Promise.all(Array.from({ length: cardCount }, (_, index) => cards.nth(index).boundingBox())),
|
|
205
|
-
cards.evaluateAll(elements => elements.map((element) => {
|
|
206
|
-
const style = getComputedStyle(element)
|
|
207
|
-
|
|
208
|
-
return {
|
|
209
|
-
columnEnd: style.gridColumnEnd,
|
|
210
|
-
columnStart: style.gridColumnStart,
|
|
211
|
-
rowEnd: style.gridRowEnd,
|
|
212
|
-
rowStart: style.gridRowStart,
|
|
213
|
-
}
|
|
214
|
-
})),
|
|
215
205
|
])
|
|
216
206
|
|
|
217
207
|
assert.ok(gridBox, `слайд ${slideNumber}: не найдена геометрия staggered grid`)
|
|
218
208
|
assert.ok(cardBoxes.every(Boolean), `слайд ${slideNumber}: не найдена геометрия всех staggered-карточек`)
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
{
|
|
225
|
-
|
|
209
|
+
const titleMargins = await grid.locator('.Slide-Step-TitleFitGroup').evaluateAll(elements =>
|
|
210
|
+
elements.map(element => Number.parseFloat(getComputedStyle(element).marginTop)),
|
|
211
|
+
)
|
|
212
|
+
assert.ok(
|
|
213
|
+
titleMargins.length === cardCount && titleMargins.every(marginTop => marginTop > 0),
|
|
214
|
+
`слайд ${slideNumber}: title-группы staggered должны быть прибиты к нижнему краю`,
|
|
215
|
+
)
|
|
226
216
|
|
|
227
217
|
const boxes = cardBoxes
|
|
228
218
|
const topY = boxes[0].y
|
|
229
219
|
const bottomY = boxes[3].y
|
|
220
|
+
const topCards = boxes.slice(0, 3)
|
|
221
|
+
const lowerCards = boxes.slice(3)
|
|
230
222
|
|
|
231
223
|
assert.ok(
|
|
232
|
-
|
|
224
|
+
topCards.every(box => Math.abs(box.y - topY) <= 1),
|
|
233
225
|
`слайд ${slideNumber}: первые три staggered-карточки должны быть в верхнем ряду`,
|
|
234
226
|
)
|
|
235
227
|
assert.ok(
|
|
236
|
-
|
|
228
|
+
lowerCards.every(box => Math.abs(box.y - bottomY) <= 1) && bottomY > topY,
|
|
237
229
|
`слайд ${slideNumber}: последние две staggered-карточки должны быть в нижнем ряду`,
|
|
238
230
|
)
|
|
231
|
+
assert.ok(
|
|
232
|
+
Math.abs(topCards[0].x - gridBox.x) <= 1
|
|
233
|
+
&& Math.abs(topCards.at(-1).x + topCards.at(-1).width - gridBox.x - gridBox.width) <= 1,
|
|
234
|
+
`слайд ${slideNumber}: верхний ряд staggered не заполняет ширину сетки`,
|
|
235
|
+
)
|
|
239
236
|
|
|
240
|
-
const lowerCenter = (
|
|
237
|
+
const lowerCenter = (lowerCards[0].x + lowerCards[1].x + lowerCards[1].width) / 2
|
|
241
238
|
const gridCenter = gridBox.x + gridBox.width / 2
|
|
242
239
|
|
|
243
240
|
assert.ok(
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
import { parseSync } from '@slidev/parser'
|
|
5
|
+
import { chromium } from 'playwright-chromium'
|
|
6
|
+
import { createStaticDistServer, isSameOriginUrl } from './browser-smoke-runtime.mjs'
|
|
7
|
+
import { inspectRenderedTypography } from './typography-browser.mjs'
|
|
8
|
+
|
|
9
|
+
const PROJECT_ROOT = resolve(import.meta.dirname, '..')
|
|
10
|
+
const DIST_DIR = resolve(PROJECT_ROOT, 'dist')
|
|
11
|
+
const DECK_PATH = resolve(PROJECT_ROOT, 'example.md')
|
|
12
|
+
const VIEWPORT = { width: 1280, height: 720 }
|
|
13
|
+
const TYPOGRAPHY_GUARD = process.argv.includes('--typography-guard')
|
|
14
|
+
|
|
15
|
+
async function settleSlide(page) {
|
|
16
|
+
await page.evaluate(async () => {
|
|
17
|
+
await Promise.race([
|
|
18
|
+
document.fonts?.ready ?? Promise.resolve(),
|
|
19
|
+
new Promise(resolveTimeout => setTimeout(resolveTimeout, 3_000)),
|
|
20
|
+
])
|
|
21
|
+
await new Promise((resolveFrame) => {
|
|
22
|
+
requestAnimationFrame(() => requestAnimationFrame(resolveFrame))
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function openSlide(page, origin, slideNumber) {
|
|
28
|
+
const hash = `#/${slideNumber}`
|
|
29
|
+
|
|
30
|
+
if (page.url() === 'about:blank')
|
|
31
|
+
await page.goto(`${origin}/${hash}`, { waitUntil: 'domcontentloaded' })
|
|
32
|
+
else {
|
|
33
|
+
await page.evaluate((nextHash) => {
|
|
34
|
+
window.location.hash = nextHash
|
|
35
|
+
}, hash)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
await page.waitForFunction(expectedHash => window.location.hash === expectedHash, hash)
|
|
39
|
+
const slide = page.locator(`.slidev-page[data-slidev-no="${slideNumber}"]`)
|
|
40
|
+
await slide.waitFor({ state: 'visible' })
|
|
41
|
+
await settleSlide(page)
|
|
42
|
+
return slide
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function inspectSlideAccessibility(slide, slideNumber) {
|
|
46
|
+
return slide.evaluate((root, currentSlideNumber) => {
|
|
47
|
+
function parseColor(value) {
|
|
48
|
+
if (value.startsWith('#')) {
|
|
49
|
+
const hex = value.slice(1)
|
|
50
|
+
const hexadecimalDigits = '0123456789abcdefABCDEF'
|
|
51
|
+
if (hex.length === 6 && [...hex].every(character => hexadecimalDigits.includes(character))) {
|
|
52
|
+
return {
|
|
53
|
+
red: Number.parseInt(hex.slice(0, 2), 16),
|
|
54
|
+
green: Number.parseInt(hex.slice(2, 4), 16),
|
|
55
|
+
blue: Number.parseInt(hex.slice(4, 6), 16),
|
|
56
|
+
alpha: 1,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const rgb = value.match(/^rgba?\(([^)]+)\)$/u)
|
|
62
|
+
if (rgb) {
|
|
63
|
+
const parts = rgb[1].replaceAll(',', ' ').split(/\s+/u).filter(Boolean)
|
|
64
|
+
const channels = parts.slice(0, 3).map(part => part.endsWith('%')
|
|
65
|
+
? Number.parseFloat(part) * 2.55
|
|
66
|
+
: Number.parseFloat(part))
|
|
67
|
+
const alpha = parts[3] === undefined ? 1 : Number.parseFloat(parts[3])
|
|
68
|
+
return channels.every(Number.isFinite) && Number.isFinite(alpha)
|
|
69
|
+
? { red: channels[0], green: channels[1], blue: channels[2], alpha }
|
|
70
|
+
: null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const srgb = value.match(/^color\(srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)(?:\s*\/\s*([\d.]+))?\)$/u)
|
|
74
|
+
if (!srgb)
|
|
75
|
+
return null
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
red: Number.parseFloat(srgb[1]) * 255,
|
|
79
|
+
green: Number.parseFloat(srgb[2]) * 255,
|
|
80
|
+
blue: Number.parseFloat(srgb[3]) * 255,
|
|
81
|
+
alpha: srgb[4] === undefined ? 1 : Number.parseFloat(srgb[4]),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function composite(foreground, background) {
|
|
86
|
+
const alpha = foreground.alpha + background.alpha * (1 - foreground.alpha)
|
|
87
|
+
if (alpha === 0)
|
|
88
|
+
return { red: 0, green: 0, blue: 0, alpha: 0 }
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
red: (foreground.red * foreground.alpha + background.red * background.alpha * (1 - foreground.alpha)) / alpha,
|
|
92
|
+
green: (foreground.green * foreground.alpha + background.green * background.alpha * (1 - foreground.alpha)) / alpha,
|
|
93
|
+
blue: (foreground.blue * foreground.alpha + background.blue * background.alpha * (1 - foreground.alpha)) / alpha,
|
|
94
|
+
alpha,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function luminance(color) {
|
|
99
|
+
const channels = [color.red, color.green, color.blue]
|
|
100
|
+
.map(channel => channel / 255)
|
|
101
|
+
.map(channel => channel <= 0.04045
|
|
102
|
+
? channel / 12.92
|
|
103
|
+
: ((channel + 0.055) / 1.055) ** 2.4)
|
|
104
|
+
|
|
105
|
+
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function contrastRatio(foreground, background) {
|
|
109
|
+
const foregroundLuminance = luminance(foreground)
|
|
110
|
+
const backgroundLuminance = luminance(background)
|
|
111
|
+
const light = Math.max(foregroundLuminance, backgroundLuminance)
|
|
112
|
+
const dark = Math.min(foregroundLuminance, backgroundLuminance)
|
|
113
|
+
return (light + 0.05) / (dark + 0.05)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function visible(element) {
|
|
117
|
+
const rect = element.getBoundingClientRect()
|
|
118
|
+
const style = getComputedStyle(element)
|
|
119
|
+
return rect.width > 0
|
|
120
|
+
&& rect.height > 0
|
|
121
|
+
&& style.display !== 'none'
|
|
122
|
+
&& style.visibility !== 'hidden'
|
|
123
|
+
&& Number.parseFloat(style.opacity) > 0
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function directText(element) {
|
|
127
|
+
return [...element.childNodes]
|
|
128
|
+
.filter(node => node.nodeType === Node.TEXT_NODE)
|
|
129
|
+
.map(node => node.textContent ?? '')
|
|
130
|
+
.join(' ')
|
|
131
|
+
.replace(/\s+/gu, ' ')
|
|
132
|
+
.trim()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function backgroundFor(element) {
|
|
136
|
+
const layers = []
|
|
137
|
+
let current = element
|
|
138
|
+
|
|
139
|
+
while (current instanceof Element) {
|
|
140
|
+
const style = getComputedStyle(current)
|
|
141
|
+
const color = parseColor(style.backgroundColor)
|
|
142
|
+
if (color)
|
|
143
|
+
layers.push(color)
|
|
144
|
+
if (current === root)
|
|
145
|
+
break
|
|
146
|
+
current = current.parentElement
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return layers.reverse().reduce(
|
|
150
|
+
(background, layer) => composite(layer, background),
|
|
151
|
+
{ red: 255, green: 255, blue: 255, alpha: 1 },
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function selectorFor(element) {
|
|
156
|
+
const classes = [...element.classList].slice(0, 2).join('.')
|
|
157
|
+
return `${element.tagName.toLowerCase()}${classes ? `.${classes}` : ''}`
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function sameColorChannels(left, right) {
|
|
161
|
+
return left && right
|
|
162
|
+
&& Math.abs(left.red - right.red) < 0.5
|
|
163
|
+
&& Math.abs(left.green - right.green) < 0.5
|
|
164
|
+
&& Math.abs(left.blue - right.blue) < 0.5
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function isDocumentedAccentException(element, foreground) {
|
|
168
|
+
const accentContext = element.closest('.Slide_mode_color, .Slot_surface_color, .Slide-Step_active')
|
|
169
|
+
const lightText = parseColor(getComputedStyle(root).getPropertyValue('--theme-color-light-0').trim())
|
|
170
|
+
return accentContext !== null && sameColorChannels(foreground, lightText)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const failures = []
|
|
174
|
+
let contrastChecks = 0
|
|
175
|
+
let contrastExceptions = 0
|
|
176
|
+
let mediaChecks = 0
|
|
177
|
+
|
|
178
|
+
for (const element of root.querySelectorAll('*')) {
|
|
179
|
+
if (!visible(element))
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
const text = directText(element)
|
|
183
|
+
if (!text)
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
const foreground = parseColor(getComputedStyle(element).color)
|
|
187
|
+
if (!foreground) {
|
|
188
|
+
failures.push(`слайд ${currentSlideNumber}: не удалось разобрать цвет ${selectorFor(element)}`)
|
|
189
|
+
continue
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const background = backgroundFor(element)
|
|
193
|
+
const style = getComputedStyle(element)
|
|
194
|
+
const fontSize = Number.parseFloat(style.fontSize)
|
|
195
|
+
const fontWeight = Number.parseInt(style.fontWeight, 10)
|
|
196
|
+
const requiredRatio = fontSize >= 24 || (fontSize >= 18.66 && fontWeight >= 600) ? 3 : 4.5
|
|
197
|
+
const actualRatio = contrastRatio(composite(foreground, background), background)
|
|
198
|
+
contrastChecks += 1
|
|
199
|
+
|
|
200
|
+
if (actualRatio + 0.01 < requiredRatio) {
|
|
201
|
+
if (isDocumentedAccentException(element, foreground)) {
|
|
202
|
+
contrastExceptions += 1
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
failures.push(
|
|
207
|
+
`слайд ${currentSlideNumber}: контраст ${actualRatio.toFixed(2)} < ${requiredRatio.toFixed(1)} `
|
|
208
|
+
+ `у ${selectorFor(element)} «${text.slice(0, 60)}»`,
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
for (const image of root.querySelectorAll('img')) {
|
|
214
|
+
if (!visible(image))
|
|
215
|
+
continue
|
|
216
|
+
mediaChecks += 1
|
|
217
|
+
const hasAlt = image.hasAttribute('alt')
|
|
218
|
+
const alt = image.getAttribute('alt') ?? ''
|
|
219
|
+
const hidden = image.getAttribute('aria-hidden') === 'true'
|
|
220
|
+
|| image.closest('[aria-hidden="true"]') !== null
|
|
221
|
+
|
|
222
|
+
if (!hasAlt)
|
|
223
|
+
failures.push(`слайд ${currentSlideNumber}: img без атрибута alt`)
|
|
224
|
+
else if (alt && hidden)
|
|
225
|
+
failures.push(`слайд ${currentSlideNumber}: содержательный img с alt скрыт через aria-hidden`)
|
|
226
|
+
else if (!alt && !hidden)
|
|
227
|
+
failures.push(`слайд ${currentSlideNumber}: декоративный img с пустым alt не скрыт через aria-hidden`)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
for (const graphic of root.querySelectorAll('[role="img"]')) {
|
|
231
|
+
if (!visible(graphic))
|
|
232
|
+
continue
|
|
233
|
+
mediaChecks += 1
|
|
234
|
+
const hidden = graphic.getAttribute('aria-hidden') === 'true'
|
|
235
|
+
|| graphic.closest('[aria-hidden="true"]') !== null
|
|
236
|
+
const name = graphic.getAttribute('aria-label')?.trim()
|
|
237
|
+
|| graphic.querySelector(':scope > title')?.textContent?.trim()
|
|
238
|
+
|| ''
|
|
239
|
+
|
|
240
|
+
if (!hidden && !name)
|
|
241
|
+
failures.push(`слайд ${currentSlideNumber}: role="img" не имеет доступного имени`)
|
|
242
|
+
if (hidden && name)
|
|
243
|
+
failures.push(`слайд ${currentSlideNumber}: скрытая графика одновременно имеет доступное имя`)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return { contrastChecks, contrastExceptions, failures, mediaChecks }
|
|
247
|
+
}, slideNumber)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function inspectFocusAndReducedMotion(page, slide) {
|
|
251
|
+
await page.keyboard.press('Tab')
|
|
252
|
+
const result = await slide.evaluate((root) => {
|
|
253
|
+
const layout = root.querySelector('.slidev-layout')
|
|
254
|
+
if (!layout)
|
|
255
|
+
throw new Error('активный слайд не содержит .slidev-layout')
|
|
256
|
+
|
|
257
|
+
const focusProbe = document.createElement('a')
|
|
258
|
+
focusProbe.href = '#focus-probe'
|
|
259
|
+
focusProbe.textContent = 'Проверка видимого фокуса'
|
|
260
|
+
layout.append(focusProbe)
|
|
261
|
+
focusProbe.focus()
|
|
262
|
+
const focusStyle = getComputedStyle(focusProbe)
|
|
263
|
+
const focusVisible = focusProbe.matches(':focus-visible')
|
|
264
|
+
const focusWidth = Number.parseFloat(focusStyle.outlineWidth)
|
|
265
|
+
const focusStyleName = focusStyle.outlineStyle
|
|
266
|
+
|
|
267
|
+
const motionProbe = document.createElement('span')
|
|
268
|
+
motionProbe.style.animationDuration = '10s'
|
|
269
|
+
motionProbe.style.transitionDuration = '10s'
|
|
270
|
+
layout.append(motionProbe)
|
|
271
|
+
const motionStyle = getComputedStyle(motionProbe)
|
|
272
|
+
const animationDuration = motionStyle.animationDuration
|
|
273
|
+
const transitionDuration = motionStyle.transitionDuration
|
|
274
|
+
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
275
|
+
|
|
276
|
+
focusProbe.remove()
|
|
277
|
+
motionProbe.remove()
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
animationDuration,
|
|
281
|
+
focusStyleName,
|
|
282
|
+
focusVisible,
|
|
283
|
+
focusWidth,
|
|
284
|
+
reducedMotion,
|
|
285
|
+
transitionDuration,
|
|
286
|
+
}
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
assert.equal(result.reducedMotion, true, 'Chromium должен эмулировать prefers-reduced-motion: reduce')
|
|
290
|
+
assert.equal(result.focusVisible, true, 'клавиатурный фокус должен сопоставляться с :focus-visible')
|
|
291
|
+
assert.notEqual(result.focusStyleName, 'none', 'видимый фокус не должен отключать outline')
|
|
292
|
+
assert.ok(result.focusWidth >= 3, `ширина фокуса должна быть не меньше 3px, получено ${result.focusWidth}`)
|
|
293
|
+
const durationMs = value => Number.parseFloat(value) * (value.endsWith('ms') ? 1 : 1_000)
|
|
294
|
+
assert.ok(
|
|
295
|
+
durationMs(result.animationDuration) <= 0.01,
|
|
296
|
+
`анимация должна отключаться в reduced-motion, получено ${result.animationDuration}`,
|
|
297
|
+
)
|
|
298
|
+
assert.ok(
|
|
299
|
+
durationMs(result.transitionDuration) <= 0.01,
|
|
300
|
+
`переход должен отключаться в reduced-motion, получено ${result.transitionDuration}`,
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function run() {
|
|
305
|
+
const source = readFileSync(DECK_PATH, 'utf8')
|
|
306
|
+
const slideCount = parseSync(source, DECK_PATH).slides.length
|
|
307
|
+
const failures = []
|
|
308
|
+
const externalRequests = []
|
|
309
|
+
let contrastChecks = 0
|
|
310
|
+
let contrastExceptions = 0
|
|
311
|
+
let mediaChecks = 0
|
|
312
|
+
let browser
|
|
313
|
+
let server
|
|
314
|
+
|
|
315
|
+
try {
|
|
316
|
+
server = await createStaticDistServer(DIST_DIR)
|
|
317
|
+
browser = await chromium.launch()
|
|
318
|
+
const context = await browser.newContext({
|
|
319
|
+
reducedMotion: 'reduce',
|
|
320
|
+
serviceWorkers: 'block',
|
|
321
|
+
viewport: VIEWPORT,
|
|
322
|
+
})
|
|
323
|
+
const page = await context.newPage()
|
|
324
|
+
page.setDefaultTimeout(15_000)
|
|
325
|
+
page.on('request', (request) => {
|
|
326
|
+
if (!isSameOriginUrl(server.origin, request.url()))
|
|
327
|
+
externalRequests.push(request.url())
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
for (let slideNumber = 1; slideNumber <= slideCount; slideNumber += 1) {
|
|
331
|
+
const slide = await openSlide(page, server.origin, slideNumber)
|
|
332
|
+
const result = await inspectSlideAccessibility(slide, slideNumber)
|
|
333
|
+
failures.push(...result.failures)
|
|
334
|
+
contrastChecks += result.contrastChecks
|
|
335
|
+
contrastExceptions += result.contrastExceptions
|
|
336
|
+
mediaChecks += result.mediaChecks
|
|
337
|
+
|
|
338
|
+
if (TYPOGRAPHY_GUARD) {
|
|
339
|
+
const typography = await inspectRenderedTypography(slide)
|
|
340
|
+
failures.push(...typography.issues.map(issue => `Слайд ${slideNumber}: ${issue.message}`))
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (slideNumber === 1)
|
|
344
|
+
await inspectFocusAndReducedMotion(page, slide)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
finally {
|
|
348
|
+
await browser?.close()
|
|
349
|
+
await server?.close()
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
for (const requestUrl of new Set(externalRequests))
|
|
353
|
+
failures.push(`внешний сетевой запрос: ${requestUrl}`)
|
|
354
|
+
|
|
355
|
+
assert.deepEqual(failures, [], `Нарушения доступности:\n${failures.join('\n')}`)
|
|
356
|
+
console.log(
|
|
357
|
+
`Доступность: слайды=${slideCount}, контрастные пары=${contrastChecks}, `
|
|
358
|
+
+ `акцентные исключения=${contrastExceptions}, медиа=${mediaChecks}, `
|
|
359
|
+
+ `внешние запросы=${externalRequests.length}, `
|
|
360
|
+
+ 'видимый фокус=1, reduced-motion=1',
|
|
361
|
+
)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
await run()
|