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.
@@ -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 — ровно 5 шагов во frontmatter для ритма 3+2. -->
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.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
- "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:package",
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:package": "node scripts/check-package.mjs"
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 должен использовать YS Text Bold с весом 600`,
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 должен содержать ровно 5 карточек`)
200
+ assert.equal(cardCount, 5, `слайд ${slideNumber}: канонический пример staggered должен содержать 5 карточек`)
201
201
 
202
- const [gridBox, cardBoxes, placements] = await Promise.all([
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
- assert.deepEqual(placements, [
220
- { columnEnd: '3', columnStart: '1', rowEnd: '2', rowStart: '1' },
221
- { columnEnd: '5', columnStart: '3', rowEnd: '2', rowStart: '1' },
222
- { columnEnd: '7', columnStart: '5', rowEnd: '2', rowStart: '1' },
223
- { columnEnd: '4', columnStart: '2', rowEnd: '3', rowStart: '2' },
224
- { columnEnd: '6', columnStart: '4', rowEnd: '3', rowStart: '2' },
225
- ], `слайд ${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
+ )
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
- boxes.slice(0, 3).every(box => Math.abs(box.y - topY) <= 1),
224
+ topCards.every(box => Math.abs(box.y - topY) <= 1),
233
225
  `слайд ${slideNumber}: первые три staggered-карточки должны быть в верхнем ряду`,
234
226
  )
235
227
  assert.ok(
236
- 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,
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 = (boxes[3].x + boxes[4].x + boxes[4].width) / 2
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()