slidev-theme-practicum 0.2.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 +225 -22
- package/components/Slide.vue +12 -58
- package/components/Slot.vue +4 -3
- package/components/StepsGrid.vue +117 -0
- package/components/Text.vue +5 -0
- package/composables/deck-decors.ts +74 -0
- package/composables/deck-slot-markup.cjs +19 -5
- package/composables/decor-sources.ts +43 -0
- package/composables/layout-authoring.ts +34 -9
- package/composables/layout-recipes.ts +16 -2
- package/composables/layout-shorthands.ts +157 -83
- package/composables/local-layout-variant-files.ts +106 -0
- package/composables/local-layout-variants.ts +73 -0
- package/composables/slide-layout.ts +3 -0
- package/composables/text-fit-runtime.ts +7 -3
- package/composables/theme-foundation.ts +7 -3
- package/composables/typography-guard.cjs +59 -0
- package/composables/use-theme-config.ts +17 -10
- package/composables/validate-deck-layouts.cjs +95 -7
- package/composables/validate-deck-typography.cjs +101 -0
- package/env.d.ts +18 -0
- package/example.md +1 -1
- package/package.json +18 -6
- package/scripts/browser-smoke.mjs +85 -23
- package/scripts/check-accessibility.mjs +364 -0
- package/scripts/check-consumer.mjs +159 -0
- package/scripts/check-local-layout-variant-build.mjs +189 -0
- package/scripts/check-package.mjs +18 -2
- package/scripts/check-pixels.mjs +251 -0
- package/scripts/check-typography.mjs +108 -0
- package/scripts/requirements-illustrations.txt +1 -0
- package/scripts/test-typography.mjs +89 -0
- package/scripts/trace-line-art.py +422 -0
- package/scripts/typography-browser.mjs +134 -0
- package/scripts/validate-deck.cjs +23 -3
- package/setup/vite-plugins.ts +109 -2
- package/skills/slidev-practicum/SKILL.md +19 -4
- package/skills/slidev-practicum/references/contour-illustrations.md +114 -0
- package/skills/slidev-practicum/references/deck-project-structure.md +136 -0
- package/skills/slidev-practicum/references/illustration-examples/balance-scales.png +0 -0
- package/skills/slidev-practicum/references/illustration-examples/balance-scales.svg +88 -0
- package/skills/slidev-practicum/references/illustration-examples/chainsaw.png +0 -0
- package/skills/slidev-practicum/references/illustration-examples/chainsaw.svg +4 -0
- package/skills/slidev-practicum/references/illustration-examples/graduation-cap.png +0 -0
- package/skills/slidev-practicum/references/illustration-examples/graduation-cap.svg +23 -0
- package/skills/slidev-practicum/references/illustration-examples/woodcutter-axe.png +0 -0
- package/skills/slidev-practicum/references/illustration-examples/woodcutter-axe.svg +4 -0
- package/skills/slidev-practicum/references/photographic-illustrations.md +100 -0
- package/styles/index.css +20 -27
- package/styles/vars.css +6 -2
|
@@ -10,9 +10,28 @@ import {
|
|
|
10
10
|
|
|
11
11
|
const PROJECT_ROOT = resolve(import.meta.dirname, '..')
|
|
12
12
|
const DIST_DIR = resolve(PROJECT_ROOT, 'dist')
|
|
13
|
+
const EXPECTED_DOCUMENT_TITLE = 'Яндекс Практикум — справочная галерея - Slidev'
|
|
13
14
|
const REPRESENTATIVE_SLIDES = [1, 9, 15, 20, 38, 46, 47]
|
|
14
15
|
const VISUAL_ONLY_SLIDES = [17, 18]
|
|
15
16
|
const P0_REGRESSION_SLIDES = [23, 24, 25, 26, 27, 28]
|
|
17
|
+
const P0_FRONTMATTER_TEXT = {
|
|
18
|
+
23: {
|
|
19
|
+
selector: '.Slide_variant_comparison-before-after',
|
|
20
|
+
values: ['«Я просто запомнил формулу»', '«Я выбрал формулу по условию»'],
|
|
21
|
+
},
|
|
22
|
+
24: {
|
|
23
|
+
selector: '.Slide_variant_comparison-stable-variable',
|
|
24
|
+
values: ['Научиться проверять гипотезу', 'Данные и уровень подсказок'],
|
|
25
|
+
},
|
|
26
|
+
25: {
|
|
27
|
+
selector: '.Slide-StepsGrid_linear',
|
|
28
|
+
values: ['Назвать цель', 'Дать контекст', 'Показать пример', 'Оставить практику', 'Проверить критерий', 'Закрепить вывод'],
|
|
29
|
+
},
|
|
30
|
+
26: {
|
|
31
|
+
selector: '.Slide-StepsGrid_staggered',
|
|
32
|
+
values: ['Заметить сбой', 'Повторить шаг', 'Найти правило', 'Изменить подход', 'Записать вывод'],
|
|
33
|
+
},
|
|
34
|
+
}
|
|
16
35
|
const LIFECYCLE_EVENT = 'practicum:slide-lifecycle'
|
|
17
36
|
const LIFECYCLE_STATE_KEY = '__practicumBrowserLifecycle'
|
|
18
37
|
const THEME_MEDIA = [
|
|
@@ -40,6 +59,10 @@ function assertNoDiagnostics(label, diagnostics) {
|
|
|
40
59
|
)
|
|
41
60
|
}
|
|
42
61
|
|
|
62
|
+
function normalizeVisibleText(value) {
|
|
63
|
+
return value.replace(/\s+/g, ' ').trim()
|
|
64
|
+
}
|
|
65
|
+
|
|
43
66
|
async function settleSlide(page) {
|
|
44
67
|
await page.evaluate(async () => {
|
|
45
68
|
await Promise.race([
|
|
@@ -123,7 +146,7 @@ async function assertStrongEmphasis(slide, slideNumber) {
|
|
|
123
146
|
assert.equal(
|
|
124
147
|
await strong.evaluate(element => getComputedStyle(element).fontWeight),
|
|
125
148
|
'600',
|
|
126
|
-
`слайд ${slideNumber}: strong должен использовать
|
|
149
|
+
`слайд ${slideNumber}: strong должен использовать полужирное начертание с весом 600`,
|
|
127
150
|
)
|
|
128
151
|
}
|
|
129
152
|
|
|
@@ -174,47 +197,44 @@ async function assertStaggeredStepsLayout(slide, slideNumber) {
|
|
|
174
197
|
const cards = grid.locator(':scope > .Slide-Step')
|
|
175
198
|
const cardCount = await cards.count()
|
|
176
199
|
|
|
177
|
-
assert.equal(cardCount, 5, `слайд ${slideNumber}: staggered должен содержать
|
|
200
|
+
assert.equal(cardCount, 5, `слайд ${slideNumber}: канонический пример staggered должен содержать 5 карточек`)
|
|
178
201
|
|
|
179
|
-
const [gridBox, cardBoxes
|
|
202
|
+
const [gridBox, cardBoxes] = await Promise.all([
|
|
180
203
|
grid.boundingBox(),
|
|
181
204
|
Promise.all(Array.from({ length: cardCount }, (_, index) => cards.nth(index).boundingBox())),
|
|
182
|
-
cards.evaluateAll(elements => elements.map((element) => {
|
|
183
|
-
const style = getComputedStyle(element)
|
|
184
|
-
|
|
185
|
-
return {
|
|
186
|
-
columnEnd: style.gridColumnEnd,
|
|
187
|
-
columnStart: style.gridColumnStart,
|
|
188
|
-
rowEnd: style.gridRowEnd,
|
|
189
|
-
rowStart: style.gridRowStart,
|
|
190
|
-
}
|
|
191
|
-
})),
|
|
192
205
|
])
|
|
193
206
|
|
|
194
207
|
assert.ok(gridBox, `слайд ${slideNumber}: не найдена геометрия staggered grid`)
|
|
195
208
|
assert.ok(cardBoxes.every(Boolean), `слайд ${slideNumber}: не найдена геометрия всех staggered-карточек`)
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
{
|
|
202
|
-
|
|
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
|
+
)
|
|
203
216
|
|
|
204
217
|
const boxes = cardBoxes
|
|
205
218
|
const topY = boxes[0].y
|
|
206
219
|
const bottomY = boxes[3].y
|
|
220
|
+
const topCards = boxes.slice(0, 3)
|
|
221
|
+
const lowerCards = boxes.slice(3)
|
|
207
222
|
|
|
208
223
|
assert.ok(
|
|
209
|
-
|
|
224
|
+
topCards.every(box => Math.abs(box.y - topY) <= 1),
|
|
210
225
|
`слайд ${slideNumber}: первые три staggered-карточки должны быть в верхнем ряду`,
|
|
211
226
|
)
|
|
212
227
|
assert.ok(
|
|
213
|
-
|
|
228
|
+
lowerCards.every(box => Math.abs(box.y - bottomY) <= 1) && bottomY > topY,
|
|
214
229
|
`слайд ${slideNumber}: последние две staggered-карточки должны быть в нижнем ряду`,
|
|
215
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
|
+
)
|
|
216
236
|
|
|
217
|
-
const lowerCenter = (
|
|
237
|
+
const lowerCenter = (lowerCards[0].x + lowerCards[1].x + lowerCards[1].width) / 2
|
|
218
238
|
const gridCenter = gridBox.x + gridBox.width / 2
|
|
219
239
|
|
|
220
240
|
assert.ok(
|
|
@@ -251,6 +271,40 @@ async function assertNumberedQuartet(slide, slideNumber) {
|
|
|
251
271
|
)
|
|
252
272
|
}
|
|
253
273
|
|
|
274
|
+
async function assertP0FrontmatterText(slide, slideNumber) {
|
|
275
|
+
const contract = P0_FRONTMATTER_TEXT[slideNumber]
|
|
276
|
+
if (!contract)
|
|
277
|
+
return
|
|
278
|
+
|
|
279
|
+
assert.equal(
|
|
280
|
+
await slide.locator(contract.selector).count(),
|
|
281
|
+
1,
|
|
282
|
+
`слайд ${slideNumber}: не найден ожидаемый variant/arrangement ${contract.selector}`,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
const visibleText = await slide.locator('.Text').evaluateAll(elements => elements
|
|
286
|
+
.filter((element) => {
|
|
287
|
+
const rect = element.getBoundingClientRect()
|
|
288
|
+
const style = getComputedStyle(element)
|
|
289
|
+
|
|
290
|
+
return rect.width > 0
|
|
291
|
+
&& rect.height > 0
|
|
292
|
+
&& style.display !== 'none'
|
|
293
|
+
&& style.visibility !== 'hidden'
|
|
294
|
+
&& style.opacity !== '0'
|
|
295
|
+
})
|
|
296
|
+
.map(element => element.textContent ?? ''))
|
|
297
|
+
const normalizedVisibleText = visibleText.map(normalizeVisibleText)
|
|
298
|
+
|
|
299
|
+
for (const expected of contract.values) {
|
|
300
|
+
assert.ok(
|
|
301
|
+
normalizedVisibleText.includes(normalizeVisibleText(expected)),
|
|
302
|
+
`слайд ${slideNumber}: вложенный текст front matter «${expected}» не виден; `
|
|
303
|
+
+ `видимые Text=${JSON.stringify(normalizedVisibleText)}`,
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
254
308
|
async function inspectSlide(
|
|
255
309
|
page,
|
|
256
310
|
origin,
|
|
@@ -315,6 +369,7 @@ async function inspectSlide(
|
|
|
315
369
|
await assertStaggeredStepsLayout(slide, slideNumber)
|
|
316
370
|
if (slideNumber === 28)
|
|
317
371
|
await assertNumberedQuartet(slide, slideNumber)
|
|
372
|
+
await assertP0FrontmatterText(slide, slideNumber)
|
|
318
373
|
assertLifecycleTransition(
|
|
319
374
|
beforeLifecycle,
|
|
320
375
|
afterLifecycle,
|
|
@@ -474,6 +529,13 @@ export async function runBrowserSmoke() {
|
|
|
474
529
|
REPRESENTATIVE_SLIDES[index - 1],
|
|
475
530
|
() => inFlightTracker.waitForIdle(),
|
|
476
531
|
)
|
|
532
|
+
if (index === 0) {
|
|
533
|
+
assert.equal(
|
|
534
|
+
await page.title(),
|
|
535
|
+
EXPECTED_DOCUMENT_TITLE,
|
|
536
|
+
'первый headmatter title должен задавать служебный HTML title колоды',
|
|
537
|
+
)
|
|
538
|
+
}
|
|
477
539
|
}
|
|
478
540
|
|
|
479
541
|
for (const slideNumber of VISUAL_ONLY_SLIDES) {
|
|
@@ -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()
|