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,134 @@
|
|
|
1
|
+
import { inspectTypography } from '../composables/typography-guard.cjs'
|
|
2
|
+
|
|
3
|
+
export async function settleTypography(slide) {
|
|
4
|
+
await slide.evaluate(async (root) => {
|
|
5
|
+
const timeout = new Promise((_, reject) => setTimeout(
|
|
6
|
+
() => reject(new Error('Шрифты или изображения не загрузились за 5 секунд.')), 5_000,
|
|
7
|
+
))
|
|
8
|
+
await Promise.race([
|
|
9
|
+
Promise.all([
|
|
10
|
+
document.fonts.ready,
|
|
11
|
+
...[...root.querySelectorAll('img')].map(image => image.decode().catch(() => undefined)),
|
|
12
|
+
]),
|
|
13
|
+
timeout,
|
|
14
|
+
])
|
|
15
|
+
await new Promise((resolveStable, rejectStable) => {
|
|
16
|
+
const deadline = performance.now() + 5_000
|
|
17
|
+
let previous = ''
|
|
18
|
+
let stableFrames = 0
|
|
19
|
+
function observe() {
|
|
20
|
+
const signature = [...root.querySelectorAll('.Slot, .Slot-Content, .Text, .Text *')]
|
|
21
|
+
.map((element) => {
|
|
22
|
+
const style = getComputedStyle(element)
|
|
23
|
+
const rect = element.getBoundingClientRect()
|
|
24
|
+
return [element.className, element.textContent, style.fontSize, style.color,
|
|
25
|
+
rect.x, rect.y, rect.width, rect.height, element.scrollWidth, element.scrollHeight].join(':')
|
|
26
|
+
}).join('|')
|
|
27
|
+
stableFrames = signature === previous ? stableFrames + 1 : 0
|
|
28
|
+
previous = signature
|
|
29
|
+
if (stableFrames >= 8)
|
|
30
|
+
resolveStable()
|
|
31
|
+
else if (performance.now() >= deadline)
|
|
32
|
+
rejectStable(new Error('Типографика слайда не стабилизировалась за 5 секунд.'))
|
|
33
|
+
else
|
|
34
|
+
requestAnimationFrame(observe)
|
|
35
|
+
}
|
|
36
|
+
requestAnimationFrame(observe)
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Функция передаётся в браузер целиком, поэтому не использует внешние замыкания.
|
|
42
|
+
export function collectRenderedTypography(root) {
|
|
43
|
+
const content = root.querySelector('.Slide-Grid') ?? root.querySelector('.slidev-layout') ?? root
|
|
44
|
+
const excluded = '.Slide-Header, .Header, .Logo, .Slot-Background, .ImageRenderer, svg, img, canvas, video, audio, script, style'
|
|
45
|
+
const slots = [...content.querySelectorAll('.Slot')]
|
|
46
|
+
const texts = []
|
|
47
|
+
const issues = []
|
|
48
|
+
const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT)
|
|
49
|
+
const canvas = document.createElement('canvas')
|
|
50
|
+
canvas.width = canvas.height = 1
|
|
51
|
+
const colorContext = canvas.getContext('2d', { willReadFrequently: true })
|
|
52
|
+
if (!colorContext)
|
|
53
|
+
throw new Error('Не удалось нормализовать вычисленные цвета в sRGB.')
|
|
54
|
+
const colors = new Map()
|
|
55
|
+
|
|
56
|
+
function normalizeColor(value) {
|
|
57
|
+
if (!colors.has(value)) {
|
|
58
|
+
colorContext.clearRect(0, 0, 1, 1)
|
|
59
|
+
colorContext.fillStyle = value
|
|
60
|
+
colorContext.fillRect(0, 0, 1, 1)
|
|
61
|
+
const [red, green, blue, alpha] = colorContext.getImageData(0, 0, 1, 1).data
|
|
62
|
+
colors.set(value, `rgba(${red}, ${green}, ${blue}, ${alpha / 255})`)
|
|
63
|
+
}
|
|
64
|
+
return colors.get(value)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function visible(element) {
|
|
68
|
+
for (let current = element; current; current = current.parentElement) {
|
|
69
|
+
const style = getComputedStyle(current)
|
|
70
|
+
if (style.display === 'none' || style.visibility !== 'visible' || Number(style.opacity) === 0)
|
|
71
|
+
return false
|
|
72
|
+
if (current === root)
|
|
73
|
+
break
|
|
74
|
+
}
|
|
75
|
+
return true
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
while (walker.nextNode()) {
|
|
79
|
+
const node = walker.currentNode
|
|
80
|
+
const element = node.parentElement
|
|
81
|
+
const text = node.textContent.replace(/\s+/gu, ' ').trim().slice(0, 100)
|
|
82
|
+
if (!text || !element || element.closest(excluded) || !visible(element))
|
|
83
|
+
continue
|
|
84
|
+
const range = document.createRange()
|
|
85
|
+
range.selectNodeContents(node)
|
|
86
|
+
const rects = [...range.getClientRects()].filter(rect => rect.width > 0 && rect.height > 0)
|
|
87
|
+
if (!rects.length)
|
|
88
|
+
continue
|
|
89
|
+
const slotElement = element.closest('.Slot')
|
|
90
|
+
const slot = slotElement ? `Slot ${slots.indexOf(slotElement) + 1}` : 'Слайд вне Slot'
|
|
91
|
+
const style = getComputedStyle(element)
|
|
92
|
+
const textElement = element.closest('.Text')
|
|
93
|
+
texts.push({
|
|
94
|
+
text,
|
|
95
|
+
slot,
|
|
96
|
+
size: `${Math.round(Number.parseFloat(style.fontSize) * 100) / 100}px`,
|
|
97
|
+
color: normalizeColor(style.color),
|
|
98
|
+
keyNumber: textElement?.tagName === 'DATA',
|
|
99
|
+
muted: Boolean(element.closest('.Text_muted, [data-text-color^="text-muted"]')),
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
// Проверяем также внешний Slot: вложенная композиция не должна вытекать из него.
|
|
103
|
+
for (let current = slotElement; current; current = current.parentElement?.closest('.Slot')) {
|
|
104
|
+
const boundsElement = current.querySelector(':scope > .Slot-Content') ?? current
|
|
105
|
+
const bounds = boundsElement.getBoundingClientRect()
|
|
106
|
+
const scaleY = current.getBoundingClientRect().height / current.offsetHeight || 1
|
|
107
|
+
const lineHeight = Number.parseFloat(style.lineHeight) * scaleY
|
|
108
|
+
const overflow = Math.max(0, ...rects.flatMap((rect) => {
|
|
109
|
+
// Range включает метрики шрифта за пределами CSS-строки даже у
|
|
110
|
+
// корректного заголовка. По вертикали проверяем именно строку.
|
|
111
|
+
const leading = Number.isFinite(lineHeight) ? Math.max(0, rect.height - lineHeight) / 2 : 0
|
|
112
|
+
return [
|
|
113
|
+
bounds.left - rect.left, rect.right - bounds.right,
|
|
114
|
+
bounds.top - rect.top - leading, rect.bottom - leading - bounds.bottom,
|
|
115
|
+
]
|
|
116
|
+
}))
|
|
117
|
+
if (overflow > 1) {
|
|
118
|
+
issues.push({
|
|
119
|
+
code: 'text-overflow',
|
|
120
|
+
message: `Slot ${slots.indexOf(current) + 1}: «${text}» выходит за границы текста слота на ${overflow.toFixed(1)} px.`,
|
|
121
|
+
hint: 'Сократите текст или измените композицию, сохранив число размеров.',
|
|
122
|
+
})
|
|
123
|
+
break
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { texts, issues }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function inspectRenderedTypography(slide) {
|
|
131
|
+
await settleTypography(slide)
|
|
132
|
+
const { texts, issues } = await slide.evaluate(collectRenderedTypography)
|
|
133
|
+
return { textCount: texts.length, issues: [...issues, ...inspectTypography(texts)] }
|
|
134
|
+
}
|
|
@@ -1,12 +1,32 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
const { resolve } = require('node:path')
|
|
3
|
+
const { parseArgs } = require('node:util')
|
|
3
4
|
const { formatDeckLayoutIssues, validateDeckLayouts } = require('../composables/validate-deck-layouts.cjs')
|
|
4
5
|
|
|
5
|
-
const
|
|
6
|
+
const { values, positionals } = parseArgs({
|
|
7
|
+
allowPositionals: true,
|
|
8
|
+
options: {
|
|
9
|
+
'typography-guard': { type: 'boolean', default: false },
|
|
10
|
+
'help': { type: 'boolean', short: 'h' },
|
|
11
|
+
},
|
|
12
|
+
})
|
|
13
|
+
if (values.help) {
|
|
14
|
+
console.log('Использование: slidev-practicum-validate [slides.md] [--typography-guard]\n'
|
|
15
|
+
+ '--typography-guard проверяет объявленные политики Text. Фактический результат проверяйте командой slidev-practicum-check-typography.')
|
|
16
|
+
process.exit(0)
|
|
17
|
+
}
|
|
18
|
+
if (positionals.length > 1) {
|
|
19
|
+
console.error('Укажите один файл колоды.')
|
|
20
|
+
process.exit(1)
|
|
21
|
+
}
|
|
22
|
+
const deckPath = resolve(positionals[0] ?? 'example.md')
|
|
23
|
+
const typographyGuard = values['typography-guard']
|
|
6
24
|
|
|
7
|
-
validateDeckLayouts(deckPath).then((issues) => {
|
|
25
|
+
validateDeckLayouts(deckPath, { typographyGuard }).then((issues) => {
|
|
8
26
|
if (!issues.length) {
|
|
9
27
|
console.log(`OK: ${deckPath} — контракты макетов, Markdown и локальных вариантов соблюдены.`)
|
|
28
|
+
if (typographyGuard)
|
|
29
|
+
console.log('Объявленные политики Text соблюдены. Для размеров после подбора, Markdown и компонентов выполните slidev-practicum-check-typography.')
|
|
10
30
|
process.exit(0)
|
|
11
31
|
}
|
|
12
32
|
|
|
@@ -22,6 +22,7 @@ description: Используй для Slidev-презентаций, фотог
|
|
|
22
22
|
- Внутри этого репозитория используй `theme: ./`.
|
|
23
23
|
- Во внешней колоде с установленным пакетом используй `theme: practicum`.
|
|
24
24
|
- Пиши по-русски, если пользователь не попросил другой язык.
|
|
25
|
+
- Если для колоды выбрана строгая типографика, прочитай раздел README темы «Строгая проверка типографики», включая пример слайда и порядок работы агента. Во внешней колоде источник — `node_modules/slidev-theme-practicum/README.md`. Проверяй объявления через `npm exec -- slidev-practicum-validate slides.md --typography-guard`, затем фактический результат свежей сборки через `npm exec -- slidev-practicum-check-typography slides.md --dist dist`. Отмечай ключевые числа явно через `Text as="data"`, не назначай это исключение датам и номерам шагов автоматически. Не считай статическую проверку достаточной для Markdown, динамических атрибутов и автоматического подбора размеров. После исправлений пересобери колоду и повтори проверки по порядку из README.
|
|
25
26
|
- Выбирай тип слайда по задаче кадра, следуя `README.md`.
|
|
26
27
|
- Используй верхнеуровневый `title` только в первом headmatter как служебное название всей колоды. Не считай его видимым содержимым: пиши основной заголовок каждого кадра в теле слайда как Markdown `# …`, а для явной композиции — как видимый `<Text as="h1">…</Text>`. Вложенные `title` моделей компонентов сохраняй по контракту варианта.
|
|
27
28
|
- Для повторяющейся композиции конкретной колоды без Vue-тегов в `slides.md` используй `components/layout-variants/<layout>/<variant>.vue` и существующие поля front matter `layout` + `variant`; точный контракт бери из раздела README «Локальные варианты презентации».
|
package/styles/index.css
CHANGED
|
@@ -1,32 +1,5 @@
|
|
|
1
1
|
@import './vars.css';
|
|
2
2
|
|
|
3
|
-
@font-face {
|
|
4
|
-
font-family: 'YS Text';
|
|
5
|
-
src: url('https://yastatic.net/s3/home/fonts/ys/4/text-regular.woff2') format('woff2'),
|
|
6
|
-
url('https://yastatic.net/s3/home/fonts/ys/4/text-regular.woff') format('woff');
|
|
7
|
-
font-weight: 400;
|
|
8
|
-
font-style: normal;
|
|
9
|
-
font-display: swap;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
@font-face {
|
|
13
|
-
font-family: 'YS Text';
|
|
14
|
-
src: url('https://yastatic.net/s3/home/fonts/ys/4/text-regular-italic.woff2') format('woff2'),
|
|
15
|
-
url('https://yastatic.net/s3/home/fonts/ys/4/text-regular-italic.woff') format('woff');
|
|
16
|
-
font-weight: 400;
|
|
17
|
-
font-style: italic;
|
|
18
|
-
font-display: swap;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
@font-face {
|
|
22
|
-
font-family: 'YS Text';
|
|
23
|
-
src: url('https://yastatic.net/s3/home/fonts/ys/4/text-bold.woff2') format('woff2'),
|
|
24
|
-
url('https://yastatic.net/s3/home/fonts/ys/4/text-bold.woff') format('woff');
|
|
25
|
-
font-weight: 600;
|
|
26
|
-
font-style: normal;
|
|
27
|
-
font-display: swap;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
3
|
html,
|
|
31
4
|
body,
|
|
32
5
|
#slide-content,
|
|
@@ -131,6 +104,13 @@ body,
|
|
|
131
104
|
text-underline-offset: auto;
|
|
132
105
|
}
|
|
133
106
|
|
|
107
|
+
.slidev-layout :where(a, button, [tabindex]:not([tabindex="-1"])):focus-visible {
|
|
108
|
+
outline-color: var(--theme-text);
|
|
109
|
+
outline-style: solid;
|
|
110
|
+
outline-width: var(--theme-focus-width);
|
|
111
|
+
outline-offset: var(--theme-focus-offset);
|
|
112
|
+
}
|
|
113
|
+
|
|
134
114
|
.slidev-layout strong,
|
|
135
115
|
.slidev-layout b {
|
|
136
116
|
font-weight: 600;
|
|
@@ -199,3 +179,16 @@ body,
|
|
|
199
179
|
.slidev-layout .slidev-code-line.line-highlighted {
|
|
200
180
|
background: var(--theme-inline-code-bg);
|
|
201
181
|
}
|
|
182
|
+
|
|
183
|
+
@media (prefers-reduced-motion: reduce) {
|
|
184
|
+
.slidev-layout,
|
|
185
|
+
.slidev-layout *,
|
|
186
|
+
.slidev-layout *::before,
|
|
187
|
+
.slidev-layout *::after {
|
|
188
|
+
scroll-behavior: auto !important;
|
|
189
|
+
animation-duration: 0.01ms !important;
|
|
190
|
+
animation-iteration-count: 1 !important;
|
|
191
|
+
transition-duration: 0.01ms !important;
|
|
192
|
+
transition-delay: 0ms !important;
|
|
193
|
+
}
|
|
194
|
+
}
|
package/styles/vars.css
CHANGED
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
--theme-grid-span-12-width: calc((var(--theme-cell-width) * 12) + (var(--theme-grid-gap) * 11));
|
|
23
23
|
|
|
24
24
|
--theme-stroke-thin: 1px;
|
|
25
|
+
--theme-focus-width: 3px;
|
|
26
|
+
--theme-focus-offset: 3px;
|
|
25
27
|
--theme-space-flow: calc(var(--theme-grid-module) * 2);
|
|
26
28
|
--theme-radius-inline-code: var(--theme-grid-module);
|
|
27
29
|
--theme-slot-margin-1: calc(var(--theme-grid-module) * 1);
|
|
@@ -41,7 +43,7 @@
|
|
|
41
43
|
--theme-margin-bottom: calc(var(--theme-grid-module) * 3);
|
|
42
44
|
--theme-margin-left: calc(var(--theme-grid-module) * 3);
|
|
43
45
|
|
|
44
|
-
--theme-font-sans: 'YS Text', Inter, sans-serif;
|
|
46
|
+
--theme-font-sans: 'YS Text', Inter, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
45
47
|
--theme-font-mono: 'SFMono-Regular', 'SF Mono', 'Menlo', 'Consolas', monospace;
|
|
46
48
|
|
|
47
49
|
--theme-text-size-0: calc(var(--theme-grid-module) * 2);
|
|
@@ -75,7 +77,7 @@
|
|
|
75
77
|
--theme-color-light-1: #f0f0f0;
|
|
76
78
|
--theme-color-dark-0: #1e1e1e;
|
|
77
79
|
--theme-color-dark-1: #3c3c3c;
|
|
78
|
-
--theme-color-dark-2: #
|
|
80
|
+
--theme-color-dark-2: #646464;
|
|
79
81
|
--theme-color-blue-0: #027ef2;
|
|
80
82
|
--theme-color-blue-1: #98d2fe;
|
|
81
83
|
--theme-color-orange-0: #ff6c26;
|
|
@@ -91,9 +93,11 @@
|
|
|
91
93
|
--theme-text: var(--theme-color-dark-0);
|
|
92
94
|
--theme-text-muted: var(--theme-color-dark-2);
|
|
93
95
|
--theme-text-on-dark: var(--theme-color-light-0);
|
|
96
|
+
--theme-text-on-color: var(--theme-color-light-0);
|
|
94
97
|
--theme-border-subtle: color-mix(in srgb, var(--theme-color-dark-2) 22%, transparent);
|
|
95
98
|
--theme-current-color: var(--theme-color-blue-0);
|
|
96
99
|
--theme-text-muted-on-contrast: color-mix(in srgb, var(--theme-color-light-0) 72%, transparent);
|
|
100
|
+
--theme-text-muted-on-color: var(--theme-color-light-0);
|
|
97
101
|
--theme-link: var(--theme-current-color);
|
|
98
102
|
|
|
99
103
|
--theme-code-bg: var(--theme-color-light-1);
|