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 CHANGED
@@ -36,6 +36,64 @@ npm exec -- slidev-practicum-validate path/to/slides.md
36
36
 
37
37
  В `example.md` у канонических слайдов в заметках автора (`<!-- Контракт … -->`) описано, сколько пунктов и какой вложенности ждёт каждый variant.
38
38
 
39
+ ## Строгая проверка типографики
40
+
41
+ Режим включается явно. Обычная проверка и внешний вид существующих макетов сохраняются. Галерея показывает весь API темы, поэтому её варианты с `muted` и несколькими уровнями текста могут не проходить строгую политику.
42
+
43
+ Правила для содержательного текста слайда:
44
+
45
+ - Не более двух размеров обычного текста, включая заголовок, подписи, даты и номера шагов.
46
+ - Третий размер разрешён только для явно отмеченных ключевых чисел: `<Text as="data" size="9">42%</Text>`. В этом режиме `as="data"` означает утверждение автора, что это ключевое число. Все такие числа на слайде должны иметь один размер. Содержимое не проверяется на наличие цифр.
47
+ - В каждом `Slot` один цвет текста, без `muted` и токенов `text-muted*`. Между разными слотами цвета могут различаться.
48
+ - Текст должен помещаться в границах содержимого слота. При переполнении измените текст или композицию.
49
+ - Шапка, номер слайда в шапке, логотипы, фоновые и SVG-иллюстрации исключены. Содержательные подписи к иллюстрациям учитываются.
50
+
51
+ Пример явной композиции: два обычных размера `5` и `3`, отдельный размер `7` для ключевого числа, один основной цвет слота.
52
+
53
+ ```md
54
+ ---
55
+ layout: message
56
+ variant: centered
57
+ ---
58
+
59
+ <Slot role="primary">
60
+ <Text as="h1" size="5">Результат эксперимента</Text>
61
+ <Text as="data" size="7">42%</Text>
62
+ <Text size="3">Участники завершили практическое задание</Text>
63
+ </Slot>
64
+ ```
65
+
66
+ Проверка объявленных атрибутов:
67
+
68
+ ```bash
69
+ npm exec -- slidev-practicum-validate slides.md --typography-guard
70
+ ```
71
+
72
+ Она считает только явно объявленные `Text`, сравнивает токены цветов внутри `Slot` и ловит запрещённый `muted`. Диапазон `size="7-8"` считается одной политикой подбора, `max-size` — политикой `0-12`. Литеральные привязки вроде `:size="7"` и `:muted="false"` поддерживаются. Динамические размеры, цвета, теги, стили и условные ветви получают сообщение о невозможности статической проверки. Они не подменяются значениями по умолчанию.
73
+
74
+ Для итоговой проверки сначала соберите эту же колоду, затем проверьте результат в Chromium:
75
+
76
+ ```bash
77
+ npm install -D playwright-chromium
78
+ npm exec -- slidev build slides.md --out dist
79
+ npm exec -- slidev-practicum-check-typography slides.md --dist dist
80
+ ```
81
+
82
+ Команда ожидает загрузки шрифтов и стабильного подбора текста, проверяет вычисленные размеры и цвета видимых текстовых фрагментов, затем их выход за границы слотов. Учитываются также вложенная разметка, ссылки и текст, созданный сокращёнными Markdown-макетами и компонентами темы. Разные токены одного вычисленного цвета в браузере объединяются. Сообщения содержат номер слайда, фрагмент текста и найденные значения. Нарушение или ошибка отображения дают код выхода `1`.
83
+
84
+ Используйте свежую сборку указанного файла с корнем сайта `/`. Поддерживаются маршруты `hash` и `history`. Проверяется начальное видимое состояние каждого слайда, без перебора кликов, анимаций и интерактивных состояний. Включения слайдов через `src` пока явно отклоняются. Статический результат не заменяет браузерный. При динамических атрибутах итоговый вердикт даёт браузерная команда для показанного состояния.
85
+
86
+ Порядок работы агента:
87
+
88
+ 1. Включайте режим, когда он выбран для колоды. При копировании примера из галереи проверяйте его по строгим правилам: сама галерея не гарантирует их соблюдение.
89
+ 2. Запустите статическую проверку, затем сборку и браузерную проверку. Если строгая статическая проверка не может вычислить динамические атрибуты, отдельно запустите обычный `slidev-practicum-validate` через `npm exec --` для проверки макетов, а типографику подтвердите браузерной командой. В отчёте укажите эту границу проверки.
90
+ 3. Исправляйте замечания в колоде. Если сокращённый макет создаёт `muted` или лишние размеры, используйте явные `Slot` и `Text` либо локальный вариант по контракту темы. Не редактируйте установленный пакет и не маскируйте замечание дополнительным мелким размером.
91
+ 4. После исправлений повторите проверку объявлений, пересоберите колоду и проверьте новую сборку. Успех — отсутствие диагностик в применимых проверках и код выхода `0`. Интерактивные состояния проверьте отдельно.
92
+
93
+ Если команда или флаг отсутствуют, проверьте установленную ревизию `slidev-theme-practicum`: она должна содержать этот режим. Обновите зависимость на нужную ревизию перед проверкой. Отсутствие инструмента не означает, что колода прошла проверку.
94
+
95
+ В репозитории темы доступны `npm run check-typography -- slides.md --dist dist` и `npm run test:accessibility -- --typography-guard`. Последняя команда проверяет готовую сборку галереи `example.md` с дополнительной строгой политикой. Автоматические проверки самого механизма запускаются через `npm run test:typography` и входят в `npm test`.
96
+
39
97
  Для использования в другой Slidev-презентации:
40
98
 
41
99
  ```bash
@@ -46,6 +104,8 @@ npm install -D slidev-theme-practicum
46
104
  theme: practicum
47
105
  ```
48
106
 
107
+ Тема не загружает шрифты из сети. Если `YS Text` установлен в системе, браузер использует его локально. Иначе стек последовательно переходит к локальному `Inter`, системному интерфейсному шрифту и `sans-serif`. Поэтому сборка и показ остаются офлайн-воспроизводимыми, но метрики текста без `YS Text` могут немного отличаться. Перед выпуском проверяйте галерею тем же окружением Chromium, которым сформированы пиксельные эталоны.
108
+
49
109
  ## Структура проекта колоды
50
110
 
51
111
  Для презентации, которая использует установленную тему, каноническая точка входа — `slides.md`, а локальные медиа лежат в `public/` и подключаются абсолютным путём от корня сайта:
@@ -230,7 +290,7 @@ defineSlots<DeckLayoutVariantSlots>()
230
290
  | `comparison` | `before-after` | Markdown heading; `comparison.from` и `comparison.to` с обязательным `title`; необязательные `kicker`, `body` и `relation.label` |
231
291
  | `comparison` | `stable-variable` | Тот же контракт; вертикальное направление задаёт arrangement |
232
292
  | `steps` | `linear` | Markdown heading; `items` из 3–6 объектов с обязательным `title`, необязательными `body`, `label`; не больше одного `active: true` |
233
- | `steps` | `staggered` | Тот же контракт, но ровно 5 элементов |
293
+ | `steps` | `staggered` | Тот же контракт, но от 2 до 6 элементов; label сверху, title и body снизу |
234
294
  | `metrics` | `dashboard` | Markdown heading; ровно 5 `metrics` с обязательными `value` и одним из `body`, `label`, `title`; без `media` и произвольных spans |
235
295
  | `facts` | `numbered-quartet` | Markdown heading; ровно 4 пункта вида «значение → вложенная подпись»; номера 1–4 добавляет тема |
236
296
 
@@ -336,6 +396,10 @@ arrangement: numbered-quartet
336
396
  | `header` | `default`, `cover`, `none` | задаёт макет | служебный верхний слой |
337
397
  | `decor` | объект: `id` или семантический запрос | нет | декоративный слой для сокращённой записи |
338
398
 
399
+ Пакет объявляет `slidev.colorSchema: light`: системное переключение светлой и тёмной темы не поддерживается. Значения `mode: dark` и `mode: color` — проверенные семантические варианты отдельных слайдов внутри одной цветовой схемы, а не альтернативные карты для `prefers-color-scheme`. Тёмный и акцентный варианты используют светлый текст, логотип и служебные элементы.
400
+
401
+ Белый текст на ярких фирменных фонах сохраняется как осознанное визуальное правило. На оранжевом, зелёном и особенно жёлтом тоне отдельные пары не достигают порогов WCAG. Автоматическая проверка продолжает измерять их и выводит число `акцентные исключения`, но разрешает низкий контраст только внутри `mode: color`, `surface: color` и активного шага. Во всех остальных контекстах недостаточный контраст остаётся ошибкой.
402
+
339
403
  ## Slot API
340
404
 
341
405
  | Свойство | Тип | По умолчанию | Смысл |
@@ -373,7 +437,7 @@ arrangement: numbered-quartet
373
437
 
374
438
  В `Text` можно писать блочный Markdown темы (списки, абзацы, цитаты) — не полный GFM Slidev. Это не layout shorthand: на `cover:*` по-прежнему нужны явные `Slot` / `Text`.
375
439
 
376
- Inline-разметка `**жирный**` сохраняется как `<strong>` и отображается начертанием YS Text Bold с весом `600`.
440
+ Inline-разметка `**жирный**` сохраняется как `<strong>` и отображается доступным локальным полужирным начертанием с весом `600`.
377
441
 
378
442
  Slidev парсит markdown внутри компонента только если контент **отделён пустой строкой** от открывающего и закрывающего тега (как в [доке Slidev](https://sli.dev/builtin/components)). Без пустых строк список схлопнется в одну строку и сломается. Не добавляйте лишний отступ в 4 пробела у пунктов — Slidev превратит блок в code.
379
443
 
@@ -527,7 +591,7 @@ Endpoint сохранения принимает только точное зн
527
591
  | `light-1` | `#f0f0f0` |
528
592
  | `dark-0` | `#1e1e1e` |
529
593
  | `dark-1` | `#3c3c3c` |
530
- | `dark-2` | `#969696` |
594
+ | `dark-2` | `#646464` |
531
595
  | `blue-0` | `#027ef2` |
532
596
  | `blue-1` | `#98d2fe` |
533
597
  | `orange-0` | `#ff6c26` |
@@ -549,4 +613,14 @@ npm test
549
613
  - модульные, контрактные и архитектурные тесты;
550
614
  - продукционную сборку, локальный вариант внешней презентации и состав артефактов;
551
615
  - репрезентативные слайды в Chromium, переполнение холста и загрузку медиа по `/theme/...`;
552
- - состав и размер npm-пакета.
616
+ - контраст всех видимых текстовых пар, семантику медиа и `alt`, видимый фокус, `prefers-reduced-motion` и отсутствие внешних запросов;
617
+ - точное совпадение всех 47 слайдов `example.md` с PNG-эталонами при зафиксированных Chromium, viewport, DPR, sRGB и доступности локальных шрифтов;
618
+ - состав и размер npm-пакета, затем установку созданного локального архива во временную внешнюю презентацию, её сборку, показ без внешних запросов и ресурсы `/theme/...`.
619
+
620
+ ## Обновление пиксельных эталонов
621
+
622
+ Пиксельные эталоны меняются только осознанно после визуального просмотра результата:
623
+
624
+ ```bash
625
+ npm run test:pixels:update
626
+ ```
@@ -197,7 +197,7 @@ const slideClass = computed(() => ({
197
197
  <slot v-if="!isCompiledMode && slots.header" name="header" />
198
198
  <Header v-else-if="resolvedHeader !== 'none'"
199
199
  :variant="resolvedHeader === 'cover' ? 'cover' : 'default'"
200
- :inverted="isContrast" />
200
+ :inverted="resolvedTheme.foreground === 'light'" />
201
201
  </div>
202
202
  <DebugGrid v-if="showDebugGrid" />
203
203
  <div class="Slide-Grid">
@@ -334,8 +334,8 @@ const surfaceClass = computed(() => {
334
334
 
335
335
  .Slot_surface_color {
336
336
  background: var(--theme-slot-color, var(--theme-current-color));
337
- --theme-text: var(--theme-text-on-dark);
338
- --theme-text-muted: var(--theme-text-muted-on-contrast);
337
+ --theme-text: var(--theme-text-on-color);
338
+ --theme-text-muted: var(--theme-text-muted-on-color);
339
339
  --theme-inline-code-text: var(--theme-text);
340
340
  color: var(--theme-text);
341
341
  }
@@ -29,7 +29,7 @@ const props = defineProps<{
29
29
  :class="{ 'Slide-Step_active': item.active }"
30
30
  :data-index="index + 1">
31
31
  <Text size="4">{{ item.label }}</Text>
32
- <TextFitGroup class="Slide-Step-FitGroup"
32
+ <TextFitGroup class="Slide-Step-FitGroup Slide-Step-TitleFitGroup"
33
33
  :fit-group="`${stepsFitGroup}-titles`">
34
34
  <Text size="3-5">{{ item.title }}</Text>
35
35
  </TextFitGroup>
@@ -78,35 +78,40 @@ const props = defineProps<{
78
78
  min-width: 0;
79
79
  }
80
80
 
81
+ .Slide-StepsGrid_staggered .Slide-Step-TitleFitGroup {
82
+ margin-top: auto;
83
+ }
84
+
81
85
  .Slide-StepsGrid_staggered {
82
86
  grid-template-columns: repeat(6, minmax(0, 1fr));
83
87
  grid-template-rows: repeat(2, minmax(0, 1fr));
84
88
  }
85
89
 
86
- .Slide-StepsGrid_staggered .Slide-Step:nth-child(1) {
87
- grid-area: 1 / 1 / 2 / 3;
90
+ .Slide-StepsGrid_staggered .Slide-Step {
91
+ grid-column: span 2;
88
92
  }
89
93
 
90
- .Slide-StepsGrid_staggered .Slide-Step:nth-child(2) {
91
- grid-area: 1 / 3 / 2 / 5;
94
+ .Slide-StepsGrid_staggered[data-count='2'] .Slide-Step {
95
+ grid-column: 2 / 6;
92
96
  }
93
97
 
94
- .Slide-StepsGrid_staggered .Slide-Step:nth-child(3) {
95
- grid-area: 1 / 5 / 2 / 7;
98
+ .Slide-StepsGrid_staggered[data-count='3'] .Slide-Step,
99
+ .Slide-StepsGrid_staggered[data-count='4'] .Slide-Step {
100
+ grid-column: span 3;
96
101
  }
97
102
 
98
- .Slide-StepsGrid_staggered .Slide-Step:nth-child(4) {
99
- grid-area: 2 / 2 / 3 / 4;
103
+ .Slide-StepsGrid_staggered[data-count='3'] .Slide-Step:nth-child(3) {
104
+ grid-column: 2 / 6;
100
105
  }
101
106
 
102
- .Slide-StepsGrid_staggered .Slide-Step:nth-child(5) {
103
- grid-area: 2 / 4 / 3 / 6;
107
+ .Slide-StepsGrid_staggered[data-count='5'] .Slide-Step:nth-child(4) {
108
+ grid-column: 2 / 4;
104
109
  }
105
110
 
106
111
  .Slide-Step_active {
107
112
  background: var(--theme-current-color);
108
- color: var(--theme-text-on-dark);
109
- --theme-text: var(--theme-text-on-dark);
110
- --theme-text-muted: var(--theme-text-muted-on-contrast);
113
+ color: var(--theme-text-on-color);
114
+ --theme-text: var(--theme-text-on-color);
115
+ --theme-text-muted: var(--theme-text-muted-on-color);
111
116
  }
112
117
  </style>
@@ -16,7 +16,9 @@ type TextColorToken =
16
16
  | 'text'
17
17
  | 'text-muted'
18
18
  | 'text-on-dark'
19
+ | 'text-on-color'
19
20
  | 'text-muted-on-contrast'
21
+ | 'text-muted-on-color'
20
22
  | 'link'
21
23
  | 'light-0'
22
24
  | 'light-1'
@@ -37,7 +39,9 @@ const TEXT_COLOR_MAP: Record<TextColorToken, string> = {
37
39
  'text': 'var(--theme-text)',
38
40
  'text-muted': 'var(--theme-text-muted)',
39
41
  'text-on-dark': 'var(--theme-text-on-dark)',
42
+ 'text-on-color': 'var(--theme-text-on-color)',
40
43
  'text-muted-on-contrast': 'var(--theme-text-muted-on-contrast)',
44
+ 'text-muted-on-color': 'var(--theme-text-muted-on-color)',
41
45
  'link': 'var(--theme-link)',
42
46
  'light-0': 'var(--theme-color-light-0)',
43
47
  'light-1': 'var(--theme-color-light-1)',
@@ -119,6 +123,7 @@ const textStyle = computed(() => ({
119
123
  <component :is="as"
120
124
  ref="textElement"
121
125
  class="Text"
126
+ :data-text-color="resolvedColor"
122
127
  :class="[
123
128
  `Text_size_${normalizedSize}`,
124
129
  {
@@ -7,7 +7,7 @@ const { parse } = require('vue/compiler-sfc')
7
7
  * @typedef {(
8
8
  * | { kind: 'comment' }
9
9
  * | { kind: 'text', content: string }
10
- * | { kind: 'element', tag: string, props: Record<string, unknown>, children: DeckLiveNode[] }
10
+ * | { kind: 'element', tag: string, props: Record<string, unknown>, dynamicProps?: string[], children: DeckLiveNode[] }
11
11
  * )} DeckLiveNode
12
12
  */
13
13
  /** @typedef {{ children: DeckLiveNode[], intents: ExplicitSlotIntent[] }} DeckLiveStructure */
@@ -26,6 +26,11 @@ function formatCompilerError(error) {
26
26
  * @returns {unknown}
27
27
  */
28
28
  function readLiteralBinding(prop) {
29
+ const source = prop.exp?.loc.source.trim()
30
+ if (source === 'true' || source === 'false')
31
+ return source === 'true'
32
+ if (source === 'null')
33
+ return null
29
34
  const ast = prop.exp?.ast
30
35
  if (!ast)
31
36
  return undefined
@@ -75,11 +80,12 @@ function containsSlot(node) {
75
80
 
76
81
  /**
77
82
  * @param {import('@vue/compiler-core').ElementNode} node
78
- * @returns {Record<string, unknown>}
83
+ * @returns {{ props: Record<string, unknown>, dynamicProps?: string[] }}
79
84
  */
80
85
  function elementProps(node) {
81
86
  /** @type {Record<string, unknown>} */
82
87
  const props = {}
88
+ const dynamicProps = []
83
89
 
84
90
  for (const prop of node.props) {
85
91
  if (prop.type === 6) {
@@ -87,8 +93,11 @@ function elementProps(node) {
87
93
  continue
88
94
  }
89
95
 
90
- if (prop.name !== 'bind')
96
+ if (prop.name !== 'bind') {
97
+ if (SLOT_CONTROL_FLOW_DIRECTIVES.has(prop.name))
98
+ dynamicProps.push(`v-${prop.name}`)
91
99
  continue
100
+ }
92
101
 
93
102
  const key = prop.arg?.type === 4 && prop.arg.isStatic
94
103
  ? prop.arg.content
@@ -96,6 +105,11 @@ function elementProps(node) {
96
105
 
97
106
  if (node.tag !== 'Slot'
98
107
  || (!SLOT_CONTRACT_PROPS.has(key) && key !== 'label' && key)) {
108
+ const value = readLiteralBinding(prop)
109
+ if (key && value !== undefined)
110
+ props[key] = value
111
+ else
112
+ dynamicProps.push(key ? `v-bind:${key}` : 'v-bind')
99
113
  continue
100
114
  }
101
115
 
@@ -118,7 +132,7 @@ function elementProps(node) {
118
132
  props[key] = value
119
133
  }
120
134
 
121
- return props
135
+ return dynamicProps.length ? { props, dynamicProps } : { props }
122
136
  }
123
137
 
124
138
  /**
@@ -134,7 +148,7 @@ function liveNode(node) {
134
148
  return {
135
149
  kind: 'element',
136
150
  tag: node.tag,
137
- props: elementProps(node),
151
+ ...elementProps(node),
138
152
  children: node.children
139
153
  .map(liveNode)
140
154
  .filter(isLiveNode),