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.
Files changed (50) hide show
  1. package/README.md +225 -22
  2. package/components/Slide.vue +12 -58
  3. package/components/Slot.vue +4 -3
  4. package/components/StepsGrid.vue +117 -0
  5. package/components/Text.vue +5 -0
  6. package/composables/deck-decors.ts +74 -0
  7. package/composables/deck-slot-markup.cjs +19 -5
  8. package/composables/decor-sources.ts +43 -0
  9. package/composables/layout-authoring.ts +34 -9
  10. package/composables/layout-recipes.ts +16 -2
  11. package/composables/layout-shorthands.ts +157 -83
  12. package/composables/local-layout-variant-files.ts +106 -0
  13. package/composables/local-layout-variants.ts +73 -0
  14. package/composables/slide-layout.ts +3 -0
  15. package/composables/text-fit-runtime.ts +7 -3
  16. package/composables/theme-foundation.ts +7 -3
  17. package/composables/typography-guard.cjs +59 -0
  18. package/composables/use-theme-config.ts +17 -10
  19. package/composables/validate-deck-layouts.cjs +95 -7
  20. package/composables/validate-deck-typography.cjs +101 -0
  21. package/env.d.ts +18 -0
  22. package/example.md +1 -1
  23. package/package.json +18 -6
  24. package/scripts/browser-smoke.mjs +85 -23
  25. package/scripts/check-accessibility.mjs +364 -0
  26. package/scripts/check-consumer.mjs +159 -0
  27. package/scripts/check-local-layout-variant-build.mjs +189 -0
  28. package/scripts/check-package.mjs +18 -2
  29. package/scripts/check-pixels.mjs +251 -0
  30. package/scripts/check-typography.mjs +108 -0
  31. package/scripts/requirements-illustrations.txt +1 -0
  32. package/scripts/test-typography.mjs +89 -0
  33. package/scripts/trace-line-art.py +422 -0
  34. package/scripts/typography-browser.mjs +134 -0
  35. package/scripts/validate-deck.cjs +23 -3
  36. package/setup/vite-plugins.ts +109 -2
  37. package/skills/slidev-practicum/SKILL.md +19 -4
  38. package/skills/slidev-practicum/references/contour-illustrations.md +114 -0
  39. package/skills/slidev-practicum/references/deck-project-structure.md +136 -0
  40. package/skills/slidev-practicum/references/illustration-examples/balance-scales.png +0 -0
  41. package/skills/slidev-practicum/references/illustration-examples/balance-scales.svg +88 -0
  42. package/skills/slidev-practicum/references/illustration-examples/chainsaw.png +0 -0
  43. package/skills/slidev-practicum/references/illustration-examples/chainsaw.svg +4 -0
  44. package/skills/slidev-practicum/references/illustration-examples/graduation-cap.png +0 -0
  45. package/skills/slidev-practicum/references/illustration-examples/graduation-cap.svg +23 -0
  46. package/skills/slidev-practicum/references/illustration-examples/woodcutter-axe.png +0 -0
  47. package/skills/slidev-practicum/references/illustration-examples/woodcutter-axe.svg +4 -0
  48. package/skills/slidev-practicum/references/photographic-illustrations.md +100 -0
  49. package/styles/index.css +20 -27
  50. 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 должен использовать YS Text Bold с весом 600`,
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 должен содержать ровно 5 карточек`)
200
+ assert.equal(cardCount, 5, `слайд ${slideNumber}: канонический пример staggered должен содержать 5 карточек`)
178
201
 
179
- const [gridBox, cardBoxes, placements] = await Promise.all([
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
- assert.deepEqual(placements, [
197
- { columnEnd: '3', columnStart: '1', rowEnd: '2', rowStart: '1' },
198
- { columnEnd: '5', columnStart: '3', rowEnd: '2', rowStart: '1' },
199
- { columnEnd: '7', columnStart: '5', rowEnd: '2', rowStart: '1' },
200
- { columnEnd: '4', columnStart: '2', rowEnd: '3', rowStart: '2' },
201
- { columnEnd: '6', columnStart: '4', rowEnd: '3', rowStart: '2' },
202
- ], `слайд ${slideNumber}: staggered должен использовать центрированную геометрию 3+2`)
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
- boxes.slice(0, 3).every(box => Math.abs(box.y - topY) <= 1),
224
+ topCards.every(box => Math.abs(box.y - topY) <= 1),
210
225
  `слайд ${slideNumber}: первые три staggered-карточки должны быть в верхнем ряду`,
211
226
  )
212
227
  assert.ok(
213
- boxes.slice(3).every(box => Math.abs(box.y - bottomY) <= 1) && bottomY > topY,
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 = (boxes[3].x + boxes[4].x + boxes[4].width) / 2
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()