slidev-theme-practicum 0.1.3 → 0.2.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
@@ -58,8 +58,11 @@ theme: practicum
58
58
  | Объяснить понятие, ситуацию или решение | Поясняющий слайд (`explainer`) | Заголовок и связное пояснение |
59
59
  | Дать большой текстовый блок с разметкой | Текстовый блок (`explainer`, `title-body`) | Абзац, затем короткий список или критерий |
60
60
  | Показать план, правила или шаги | Коллекцию (`collection`) | Однотипные пункты в одном формате |
61
+ | Сопоставить два состояния или подхода | Сравнение (`collection`, `comparison`) | Две стороны с самостоятельными заголовками |
62
+ | Показать последовательность действий | Шаги (`collection`, `steps`) | От 3 до 6 коротких действий |
61
63
  | Показать события во времени | Таймлайн (`collection`, `timeline`) | Годы или месяцы и короткие подписи |
62
64
  | Показать несколько чисел | Метрики (`collection`, `metrics`) | Главное число и подписи, которые объясняют масштаб |
65
+ | Дать четыре пронумерованные опоры | Факты (`collection`, `facts`) | Четыре значения с вложенными подписями |
63
66
  | Собрать нестандартную композицию | Ручную сетку (`none`) | Только когда обычные типы не выражают задачу |
64
67
 
65
68
  ## Что писать
@@ -108,6 +111,94 @@ layout: cover | message | explainer | collection | none
108
111
 
109
112
  `layout: cover` markdown shorthand **не** имеет: для всех `cover:*` нужны явные `<Slot role="...">` и `<Text>` (см. обложки в [example.md](example.md)).
110
113
 
114
+ ### Варианты collection P0
115
+
116
+ | `variant` | `arrangement` | Контракт |
117
+ | ------------- | ------------------ | -------- |
118
+ | `comparison` | `before-after` | Markdown heading; `comparison.from` и `comparison.to` с обязательным `title`; необязательные `kicker`, `body` и `relation.label` |
119
+ | `comparison` | `stable-variable` | Тот же контракт; вертикальное направление задаёт arrangement |
120
+ | `steps` | `linear` | Markdown heading; `items` из 3–6 объектов с обязательным `title`, необязательными `body`, `label`; не больше одного `active: true` |
121
+ | `steps` | `staggered` | Тот же контракт, но ровно 5 элементов |
122
+ | `metrics` | `dashboard` | Markdown heading; ровно 5 `metrics` с обязательными `value` и одним из `body`, `label`, `title`; без `media` и произвольных spans |
123
+ | `facts` | `numbered-quartet` | Markdown heading; ровно 4 пункта вида «значение → вложенная подпись»; номера 1–4 добавляет тема |
124
+
125
+ Канонический alias для пронумерованных фактов — `variant: facts-numbered-quartet`. Для остальных вариантов также можно использовать aliases `comparison-before-after`, `comparison-stable-variable`, `steps-linear`, `steps-staggered` и `metrics-dashboard` без `arrangement`.
126
+
127
+ Минимальное сравнение:
128
+
129
+ ```md
130
+ ---
131
+ layout: collection
132
+ variant: comparison
133
+ arrangement: before-after
134
+ comparison:
135
+ from:
136
+ title: Ручная проверка
137
+ to:
138
+ title: Единый контур
139
+ relation:
140
+ label: переход
141
+ ---
142
+
143
+ # Как меняется процесс
144
+ ```
145
+
146
+ Минимальные шаги:
147
+
148
+ ```md
149
+ ---
150
+ layout: collection
151
+ variant: steps
152
+ arrangement: linear
153
+ items:
154
+ - title: Собрать входы
155
+ - title: Проверить риски
156
+ active: true
157
+ - title: Зафиксировать результат
158
+ ---
159
+
160
+ # Три шага
161
+ ```
162
+
163
+ Минимальный dashboard:
164
+
165
+ ```md
166
+ ---
167
+ layout: collection
168
+ variant: metrics
169
+ arrangement: dashboard
170
+ metrics:
171
+ - { value: 42%, label: прошли сценарий }
172
+ - { value: 18, label: команд }
173
+ - { value: 9, label: проверок }
174
+ - { value: 2×, label: быстрее обратная связь }
175
+ - { value: 0, label: ручных spans }
176
+ ---
177
+
178
+ # Панель результата
179
+ ```
180
+
181
+ Минимальный `numbered-quartet`:
182
+
183
+ ```md
184
+ ---
185
+ layout: collection
186
+ variant: facts
187
+ arrangement: numbered-quartet
188
+ ---
189
+
190
+ # Четыре опоры
191
+
192
+ - Входы
193
+ - собраны до старта
194
+ - Риски
195
+ - проверены явно
196
+ - Результат
197
+ - сохранён в репозитории
198
+ - Стоп
199
+ - задан до запуска
200
+ ```
201
+
111
202
  | Слой | Для чего |
112
203
  | ------------- | ------------------------------------------------ |
113
204
  | `layout` | семантика слайда на уровне Slidev |
@@ -366,6 +366,75 @@ const slideClass = computed(() => ({
366
366
  justify-content: flex-start;
367
367
  }
368
368
 
369
+ :deep(.Slide-ComparisonRelation) {
370
+ display: flex;
371
+ min-width: 0;
372
+ min-height: 100%;
373
+ flex-direction: column;
374
+ align-items: center;
375
+ justify-content: center;
376
+ gap: calc(var(--theme-grid-module) * 2);
377
+ text-align: center;
378
+ }
379
+
380
+ :deep(.Slide-ComparisonDirection) {
381
+ font-size: var(--theme-text-size-5);
382
+ line-height: var(--theme-text-line-5);
383
+ }
384
+
385
+ :deep(.Slide-StepsGrid) {
386
+ display: grid;
387
+ width: 100%;
388
+ height: 100%;
389
+ min-width: 0;
390
+ min-height: 0;
391
+ grid-template-columns: repeat(var(--slide-steps-count), minmax(0, 1fr));
392
+ gap: var(--theme-grid-gap);
393
+ }
394
+
395
+ :deep(.Slide-Step) {
396
+ display: flex;
397
+ min-width: 0;
398
+ min-height: 0;
399
+ flex-direction: column;
400
+ gap: calc(var(--theme-grid-module) * 2);
401
+ padding: var(--theme-slot-margin-3);
402
+ border-radius: var(--theme-panel-radius);
403
+ background: var(--theme-surface-light);
404
+ }
405
+
406
+ :deep(.Slide-StepsGrid_staggered) {
407
+ grid-template-columns: repeat(6, minmax(0, 1fr));
408
+ grid-template-rows: repeat(2, minmax(0, 1fr));
409
+ }
410
+
411
+ :deep(.Slide-StepsGrid_staggered .Slide-Step:nth-child(1)) {
412
+ grid-area: 1 / 1 / 2 / 3;
413
+ }
414
+
415
+ :deep(.Slide-StepsGrid_staggered .Slide-Step:nth-child(2)) {
416
+ grid-area: 1 / 3 / 2 / 5;
417
+ }
418
+
419
+ :deep(.Slide-StepsGrid_staggered .Slide-Step:nth-child(3)) {
420
+ grid-area: 1 / 5 / 2 / 7;
421
+ }
422
+
423
+ :deep(.Slide-StepsGrid_staggered .Slide-Step:nth-child(4)) {
424
+ grid-area: 2 / 2 / 3 / 4;
425
+ }
426
+
427
+ :deep(.Slide-StepsGrid_staggered .Slide-Step:nth-child(5)) {
428
+ grid-area: 2 / 4 / 3 / 6;
429
+ }
430
+
431
+ :deep(.Slide-Step_active) {
432
+ background: var(--theme-current-color);
433
+ color: var(--theme-text-on-dark);
434
+ --theme-text: var(--theme-text-on-dark);
435
+ --theme-text-muted: var(--theme-text-muted-on-contrast);
436
+ }
437
+
369
438
  :deep(.Slide-Quote) {
370
439
  max-width: var(--theme-grid-span-11-width);
371
440
  font-size: var(--theme-text-size-7);
@@ -159,7 +159,6 @@ export const DEFAULT_DECOR_DATA = Object.freeze([
159
159
  x: '50%',
160
160
  y: '12%',
161
161
  zoom: 3,
162
- color: 'var(--theme-color-dark-0)',
163
162
  opacity: 1,
164
163
  },
165
164
  },
@@ -239,6 +239,46 @@ export const LAYOUT_RECIPES: ThemeLayoutRecipe[] = [
239
239
  ],
240
240
  },
241
241
  },
242
+ {
243
+ content: 'comparison',
244
+ aliases: [{ variant: 'comparison-before-after' }, { variant: 'comparison', arrangement: 'before-after' }],
245
+ regions: {
246
+ primary: '1 / 1 / 4 / -1',
247
+ secondary: [
248
+ { area: '4 / 1 / -1 / 6', surface: 'light', margin: 4 },
249
+ { area: '4 / 8 / -1 / -1', surface: 'light', margin: 4 },
250
+ ],
251
+ support: { area: '4 / 6 / -1 / 8', centered: true },
252
+ },
253
+ },
254
+ {
255
+ content: 'comparison',
256
+ aliases: [{ variant: 'comparison-stable-variable' }, { variant: 'comparison', arrangement: 'stable-variable' }],
257
+ regions: {
258
+ primary: '1 / 1 / 4 / -1',
259
+ secondary: [
260
+ { area: '4 / 1 / 8 / -1', surface: 'light', margin: 4 },
261
+ { area: '9 / 1 / -1 / -1', surface: 'light', margin: 4 },
262
+ ],
263
+ support: { area: '8 / 1 / 9 / -1', centered: true },
264
+ },
265
+ },
266
+ {
267
+ content: 'steps',
268
+ aliases: [{ variant: 'steps-linear' }, { variant: 'steps', arrangement: 'linear' }],
269
+ regions: {
270
+ primary: '1 / 1 / 4 / -1',
271
+ support: '4 / 1 / -1 / -1',
272
+ },
273
+ },
274
+ {
275
+ content: 'steps',
276
+ aliases: [{ variant: 'steps-staggered' }, { variant: 'steps', arrangement: 'staggered' }],
277
+ regions: {
278
+ primary: '1 / 1 / 4 / -1',
279
+ support: '4 / 1 / -1 / -1',
280
+ },
281
+ },
242
282
  {
243
283
  content: 'metrics',
244
284
  aliases: [{ variant: 'metrics-featured-media' }, { variant: 'metrics', arrangement: 'featured-media' }],
@@ -251,6 +291,20 @@ export const LAYOUT_RECIPES: ThemeLayoutRecipe[] = [
251
291
  ],
252
292
  },
253
293
  },
294
+ {
295
+ content: 'metrics',
296
+ aliases: [{ variant: 'metrics-dashboard' }, { variant: 'metrics', arrangement: 'dashboard' }],
297
+ regions: {
298
+ primary: '1 / 1 / 4 / -1',
299
+ support: [
300
+ { area: '4 / 1 / 8 / 5', surface: 'light', margin: 3 },
301
+ { area: '4 / 5 / 8 / 9', surface: 'light', margin: 3 },
302
+ { area: '4 / 9 / 8 / -1', surface: 'light', margin: 3 },
303
+ { area: '8 / 1 / -1 / 7', surface: 'light', margin: 3 },
304
+ { area: '8 / 7 / -1 / -1', surface: 'light', margin: 3 },
305
+ ],
306
+ },
307
+ },
254
308
  {
255
309
  content: 'metrics',
256
310
  aliases: [{ variant: 'metrics-featured-copy' }, { variant: 'metrics', arrangement: 'featured-copy' }],
@@ -335,6 +389,19 @@ export const LAYOUT_RECIPES: ThemeLayoutRecipe[] = [
335
389
  ],
336
390
  },
337
391
  },
392
+ {
393
+ content: 'facts',
394
+ aliases: [{ variant: 'facts-numbered-quartet' }, { variant: 'facts', arrangement: 'numbered-quartet' }],
395
+ regions: {
396
+ primary: '1 / 1 / 4 / -1',
397
+ support: [
398
+ { area: '4 / 1 / 8 / 7', surface: 'light', margin: 3 },
399
+ { area: '4 / 7 / 8 / -1', surface: 'light', margin: 3 },
400
+ { area: '8 / 1 / -1 / 7', surface: 'light', margin: 3 },
401
+ { area: '8 / 7 / -1 / -1', surface: 'light', margin: 3 },
402
+ ],
403
+ },
404
+ },
338
405
  {
339
406
  content: 'facts',
340
407
  aliases: [{ variant: 'facts-featured' }, { variant: 'facts', arrangement: 'featured' }],
@@ -136,6 +136,7 @@ function hText(
136
136
  priority?: 1 | 2 | 3
137
137
  align?: 'start' | 'middle' | 'end'
138
138
  className?: string
139
+ color?: 'light-0'
139
140
  typography?: 'compact'
140
141
  } = {},
141
142
  ) {
@@ -147,6 +148,7 @@ function hText(
147
148
  priority?: 1 | 2 | 3
148
149
  align?: 'start' | 'middle' | 'end'
149
150
  class?: string
151
+ color?: 'light-0'
150
152
  } & {
151
153
  'data-typography'?: 'compact'
152
154
  } = {
@@ -164,6 +166,8 @@ function hText(
164
166
  textProps.align = options.align
165
167
  if (options.className)
166
168
  textProps.class = options.className
169
+ if (options.color)
170
+ textProps.color = options.color
167
171
  if (options.typography)
168
172
  textProps['data-typography'] = options.typography
169
173
 
@@ -358,6 +362,7 @@ const FACTS_VARIANT_COUNTS: Record<string, number> = {
358
362
  'facts-duo': 2,
359
363
  'facts-trio': 3,
360
364
  'facts-quartet': 4,
365
+ 'facts-numbered-quartet': 4,
361
366
  'facts-stacked': 3,
362
367
  'facts-featured': 3,
363
368
  }
@@ -726,6 +731,166 @@ function buildPointsChildren(children: VNode[], context: LayoutShorthandContext)
726
731
  ]
727
732
  }
728
733
 
734
+ type ComparisonSide = {
735
+ title: string
736
+ kicker: string
737
+ body: string
738
+ }
739
+
740
+ function normalizeComparisonSide(
741
+ value: unknown,
742
+ side: 'from' | 'to',
743
+ ): ComparisonSide {
744
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
745
+ failMarkdownContract('collection:comparison', `ожидает object в comparison.${side}.`)
746
+
747
+ const record = value as Record<string, unknown>
748
+ const title = readRecordText(record, ['title'])
749
+
750
+ if (!title)
751
+ failMarkdownContract('collection:comparison', `ожидает обязательный title в comparison.${side}.`)
752
+
753
+ return {
754
+ title,
755
+ kicker: readRecordText(record, ['kicker']),
756
+ body: readRecordText(record, ['body']),
757
+ }
758
+ }
759
+
760
+ function buildComparisonSideChildren(
761
+ context: LayoutShorthandContext,
762
+ side: ComparisonSide,
763
+ index: number,
764
+ ) {
765
+ return hSlot(context, 'secondary', `slide-collection-${context.variant}-side-${index + 1}`, {
766
+ gap: '3',
767
+ }, [
768
+ ...(side.kicker
769
+ ? [hText(context, 'div', '2', side.kicker, { priority: 3, muted: true })]
770
+ : []),
771
+ hText(context, 'div', '5-7', side.title, { priority: 1 }),
772
+ ...(side.body
773
+ ? [hText(context, 'div', '3-4', side.body, { priority: 2, muted: true })]
774
+ : []),
775
+ ])
776
+ }
777
+
778
+ function buildComparisonChildren(children: VNode[], context: LayoutShorthandContext) {
779
+ const title = readHeadingText(children, 'collection:comparison')
780
+ const comparison = context.readFrontmatterObject('comparison')
781
+
782
+ if (!comparison)
783
+ failMarkdownContract('collection:comparison', 'ожидает frontmatter comparison с полями from и to.')
784
+
785
+ const relation = context.readFrontmatterObject('relation')
786
+ const from = normalizeComparisonSide(comparison.from, 'from')
787
+ const to = normalizeComparisonSide(comparison.to, 'to')
788
+ const relationLabel = relation ? readRecordText(relation, ['label']) : ''
789
+ const isBeforeAfter = context.variant === 'comparison-before-after'
790
+
791
+ ensureOnlySupportedChildren('collection:comparison', children, node =>
792
+ node === title.node
793
+ || isBlankTextNode(node),
794
+ )
795
+
796
+ return [
797
+ hSlot(context, 'primary', `slide-collection-${context.variant}-primary`, {}, [
798
+ hText(context, 'h1', '6', title.text),
799
+ ]),
800
+ buildComparisonSideChildren(context, from, 0),
801
+ buildComparisonSideChildren(context, to, 1),
802
+ hSlot(context, 'support', `slide-collection-${context.variant}-relation`, {}, [
803
+ h('div', {
804
+ class: [
805
+ 'Slide-ComparisonRelation',
806
+ `Slide-ComparisonRelation_${isBeforeAfter ? 'horizontal' : 'vertical'}`,
807
+ ],
808
+ }, [
809
+ h('span', { 'class': 'Slide-ComparisonDirection', 'aria-hidden': 'true' }, isBeforeAfter ? '→' : '↓'),
810
+ ...(relationLabel
811
+ ? [hText(context, 'div', '2-3', relationLabel, { align: 'middle', muted: true })]
812
+ : []),
813
+ ]),
814
+ ]),
815
+ ]
816
+ }
817
+
818
+ type StepItem = {
819
+ title: string
820
+ body: string
821
+ label: string
822
+ active: boolean
823
+ }
824
+
825
+ function normalizeStepItems(context: LayoutShorthandContext): StepItem[] {
826
+ const items = context.readFrontmatterArray('items')
827
+ const isStaggered = context.variant === 'steps-staggered'
828
+
829
+ if (items.length < 3 || items.length > 6)
830
+ failMarkdownContract('collection:steps', 'ожидает от 3 до 6 элементов во frontmatter items.')
831
+
832
+ if (isStaggered && items.length !== 5)
833
+ failMarkdownContract('collection:steps', 'arrangement: staggered ожидает ровно 5 элементов.')
834
+
835
+ const normalized = items.map((item, index) => {
836
+ if (typeof item !== 'object' || item === null || Array.isArray(item))
837
+ failMarkdownContract('collection:steps', `ожидает object item в items[${index}].`)
838
+
839
+ const record = item as Record<string, unknown>
840
+ const title = readRecordText(record, ['title'])
841
+
842
+ if (!title)
843
+ failMarkdownContract('collection:steps', `ожидает обязательный title в items[${index}].`)
844
+
845
+ return {
846
+ title,
847
+ body: readRecordText(record, ['body']),
848
+ label: readRecordText(record, ['label']) || String(index + 1),
849
+ active: record.active === true,
850
+ }
851
+ })
852
+
853
+ if (normalized.filter(item => item.active).length > 1)
854
+ failMarkdownContract('collection:steps', 'поддерживает не больше одного элемента с active: true.')
855
+
856
+ return normalized
857
+ }
858
+
859
+ function buildStepsChildren(children: VNode[], context: LayoutShorthandContext) {
860
+ const title = readHeadingText(children, 'collection:steps')
861
+ const items = normalizeStepItems(context)
862
+ const arrangement = context.variant === 'steps-staggered' ? 'staggered' : 'linear'
863
+
864
+ ensureOnlySupportedChildren('collection:steps', children, node =>
865
+ node === title.node
866
+ || isBlankTextNode(node),
867
+ )
868
+
869
+ return [
870
+ hSlot(context, 'primary', `slide-collection-${context.variant}-primary`, {}, [
871
+ hText(context, 'h1', '6', title.text),
872
+ ]),
873
+ hSlot(context, 'support', `slide-collection-${context.variant}-support`, {}, [
874
+ h('div', {
875
+ 'class': ['Slide-StepsGrid', `Slide-StepsGrid_${arrangement}`],
876
+ 'data-count': String(items.length),
877
+ 'style': { '--slide-steps-count': items.length },
878
+ }, items.map((item, index) =>
879
+ h('article', {
880
+ 'class': ['Slide-Step', item.active ? 'Slide-Step_active' : ''],
881
+ 'data-index': String(index + 1),
882
+ }, [
883
+ hText(context, 'div', '2', item.label, { muted: !item.active }),
884
+ hText(context, 'div', '4-5', item.title, { priority: 1 }),
885
+ ...(item.body
886
+ ? [hText(context, 'div', '2-3', item.body, { priority: 2, muted: true })]
887
+ : []),
888
+ ]),
889
+ )),
890
+ ]),
891
+ ]
892
+ }
893
+
729
894
  function normalizeTimelineItems(context: LayoutShorthandContext) {
730
895
  const items = context.readFrontmatterArray('items')
731
896
 
@@ -804,6 +969,32 @@ function normalizeMetricItems(context: LayoutShorthandContext) {
804
969
  })
805
970
  }
806
971
 
972
+ function normalizeDashboardMetrics(context: LayoutShorthandContext) {
973
+ const metrics = context.readFrontmatterArray('metrics')
974
+
975
+ if (metrics.length !== 5)
976
+ failMarkdownContract('collection:metrics', 'arrangement: dashboard ожидает ровно 5 элементов во frontmatter metrics.')
977
+
978
+ return metrics.map((item, index) => {
979
+ if (typeof item !== 'object' || item === null || Array.isArray(item))
980
+ failMarkdownContract('collection:metrics', `ожидает object item в metrics[${index}].`)
981
+
982
+ const record = item as Record<string, unknown>
983
+ const value = readRecordText(record, ['value'])
984
+ const label = readRecordText(record, ['body', 'label', 'title'])
985
+ const hasArbitrarySpan = ['area', 'col', 'row', 'span', 'colSpan', 'rowSpan']
986
+ .some(key => record[key] !== undefined)
987
+
988
+ if (!value || !label)
989
+ failMarkdownContract('collection:metrics', `ожидает value и body/label/title в metrics[${index}].`)
990
+
991
+ if (hasArbitrarySpan)
992
+ failMarkdownContract('collection:metrics', `не принимает произвольные координаты или spans в metrics[${index}].`)
993
+
994
+ return { value, label }
995
+ })
996
+ }
997
+
807
998
  function normalizeMetricItemsFromList(list: VNode) {
808
999
  const entries = readNestedListItems(list, 'collection:metrics')
809
1000
 
@@ -841,6 +1032,32 @@ function normalizeMediaItems(context: LayoutShorthandContext) {
841
1032
  }
842
1033
 
843
1034
  function buildMetricsChildren(children: VNode[], context: LayoutShorthandContext) {
1035
+ if (context.variant === 'metrics-dashboard') {
1036
+ const title = readHeadingText(children, 'collection:metrics')
1037
+ const metrics = normalizeDashboardMetrics(context)
1038
+ const fitGroup = 'collection-metrics-dashboard'
1039
+
1040
+ ensureOnlySupportedChildren('collection:metrics', children, node =>
1041
+ node === title.node
1042
+ || isBlankTextNode(node),
1043
+ )
1044
+
1045
+ return [
1046
+ hSlot(context, 'primary', 'slide-collection-metrics-dashboard-primary', {}, [
1047
+ hText(context, 'h1', '6', title.text),
1048
+ ]),
1049
+ ...metrics.map((metric, index) =>
1050
+ hSlot(context, 'support', `slide-collection-metrics-dashboard-support-${index + 1}`, {
1051
+ fitGroup,
1052
+ gap: '2',
1053
+ }, [
1054
+ hText(context, 'div', index < 3 ? '5-7' : '6-8', metric.value, { priority: 1 }),
1055
+ hText(context, 'div', '2-4', metric.label, { priority: 2, muted: true, typography: 'compact' }),
1056
+ ]),
1057
+ ),
1058
+ ]
1059
+ }
1060
+
844
1061
  const list = children.find(isListNode)
845
1062
 
846
1063
  ensureOnlySupportedChildren('collection:metrics', children, node =>
@@ -964,7 +1181,8 @@ function buildFactsChildren(children: VNode[], context: LayoutShorthandContext)
964
1181
  const isStacked = context.variant === 'facts-stacked'
965
1182
  const isDuo = context.variant === 'facts-duo'
966
1183
  const isQuartet = context.variant === 'facts-quartet'
967
- const shouldCoordinateFactSizes = isStacked || isDuo || context.variant === 'facts-trio' || isQuartet
1184
+ const isNumberedQuartet = context.variant === 'facts-numbered-quartet'
1185
+ const shouldCoordinateFactSizes = isStacked || isDuo || context.variant === 'facts-trio' || isQuartet || isNumberedQuartet
968
1186
  const factFitGroup = shouldCoordinateFactSizes ? `collection-${context.variant}-facts` : ''
969
1187
  const supportFactFitGroup = isFeatured ? `collection-${context.variant}-support-facts` : factFitGroup
970
1188
  const headingText = readInlineText(heading)
@@ -997,6 +1215,24 @@ function buildFactsChildren(children: VNode[], context: LayoutShorthandContext)
997
1215
  if (footnoteText && !factFitGroup)
998
1216
  featuredChildren.push(hText(context, 'div', '2-3', footnoteText, { priority: 3, muted: true }))
999
1217
 
1218
+ if (isNumberedQuartet) {
1219
+ return [
1220
+ hSlot(context, 'primary', 'slide-collection-facts-numbered-quartet-primary', { gap: '3' }, primaryChildren),
1221
+ ...facts.map((fact, index) =>
1222
+ hSlot(context, 'support', `slide-collection-facts-numbered-quartet-support-${index + 1}`, {
1223
+ gap: '2',
1224
+ fitGroup: factFitGroup,
1225
+ }, [
1226
+ hText(context, 'div', '4', String(index + 1), {
1227
+ priority: 1,
1228
+ }),
1229
+ hText(context, 'div', '4', fact.value, { priority: 1, align: 'end' }),
1230
+ hText(context, 'div', '2-3', fact.label, { priority: 2, muted: true }),
1231
+ ]),
1232
+ ),
1233
+ ]
1234
+ }
1235
+
1000
1236
  return [
1001
1237
  hSlot(context, 'primary', `slide-collection-${context.variant}-primary`, { gap: '3' }, primaryChildren),
1002
1238
  hSlot(context, 'support', `slide-collection-${context.variant}-support-1`, {
@@ -1018,16 +1254,22 @@ function buildFactsChildren(children: VNode[], context: LayoutShorthandContext)
1018
1254
 
1019
1255
  const LAYOUT_SHORTHANDS = {
1020
1256
  'collection:agenda': buildAgendaChildren,
1257
+ 'collection:comparison-before-after': buildComparisonChildren,
1258
+ 'collection:comparison-stable-variable': buildComparisonChildren,
1021
1259
  'collection:facts-stacked': buildFactsChildren,
1022
1260
  'collection:facts-duo': buildFactsChildren,
1023
1261
  'collection:facts-trio': buildFactsChildren,
1024
1262
  'collection:facts-quartet': buildFactsChildren,
1263
+ 'collection:facts-numbered-quartet': buildFactsChildren,
1025
1264
  'collection:facts-featured': buildFactsChildren,
1026
1265
  'collection:points-trio': buildPointsChildren,
1266
+ 'collection:steps-linear': buildStepsChildren,
1267
+ 'collection:steps-staggered': buildStepsChildren,
1027
1268
  'collection:timeline': buildTimelineChildren,
1028
1269
  'collection:metrics-featured-media': buildMetricsChildren,
1029
1270
  'collection:metrics-featured-copy': buildMetricsChildren,
1030
1271
  'collection:metrics-featured-copy-split-media': buildMetricsChildren,
1272
+ 'collection:metrics-dashboard': buildMetricsChildren,
1031
1273
  'message:centered': buildCenteredChildren,
1032
1274
  'message:quote': buildQuoteChildren,
1033
1275
  'message:closing': buildClosingChildren,
package/example.md CHANGED
@@ -306,9 +306,6 @@ variant: title-supports-bottom-plain
306
306
  # Когда нужны два самостоятельных абзаца
307
307
 
308
308
  - Этот вариант снимает приглушение с нижних опор. Каждый абзац читается как самостоятельный текст: его не воспринимают как «второстепенный к заголовку», а ставят рядом по весу.
309
- - авава
310
- - ава
311
- авава
312
309
  - Берите слайд, когда тезис распадается на два независимых направления: например, «что включить» и «чего избегать», «теория» и «практика», «причина» и «следствие». Третий блок не нужен — два аргумента уже задают сильную смысловую пару.
313
310
 
314
311
  ---
@@ -467,10 +464,143 @@ decor:
467
464
 
468
465
  # Оранжевый тон: акцент
469
466
 
470
- 1. Выносите важный поворот или предупреждение
467
+ 1. Подсвечивайте один критический поворот
471
468
  2. Не окрашивайте им весь ряд одинаковых фактов
472
469
  3. Оставляйте рядом достаточно воздуха
473
470
 
471
+ ---
472
+ layout: collection
473
+ variant: comparison
474
+ arrangement: before-after
475
+ comparison:
476
+ from:
477
+ kicker: До разбора
478
+ title: «Я просто запомнил формулу»
479
+ body: Ответ верный, но ученик не может объяснить выбор метода.
480
+ to:
481
+ kicker: После разбора
482
+ title: «Я выбрал формулу по условию»
483
+ body: Ученик связывает данные, метод и проверку результата.
484
+ relation:
485
+ label: объяснить ход мысли
486
+ ---
487
+
488
+ <!-- Контракт: comparison before-after — заголовок в Markdown, две стороны и связь во frontmatter. -->
489
+
490
+ # Как обратная связь превращает ответ в решение
491
+
492
+ ---
493
+ layout: collection
494
+ variant: comparison
495
+ arrangement: stable-variable
496
+ comparison:
497
+ from:
498
+ kicker: Сохраняем
499
+ title: Научиться проверять гипотезу
500
+ body: Результат занятия остаётся общим для любой группы.
501
+ to:
502
+ kicker: Меняем
503
+ title: Данные и уровень подсказок
504
+ body: Контекст и поддержка зависят от опыта студентов.
505
+ relation:
506
+ label: одна цель, разные маршруты
507
+ ---
508
+
509
+ <!-- Контракт: comparison stable-variable — тот же shorthand, но вертикальная связь. -->
510
+
511
+ # Как адаптировать практику, сохраняя учебную цель
512
+
513
+ ---
514
+ layout: collection
515
+ variant: steps
516
+ arrangement: linear
517
+ items:
518
+ - title: Назвать цель
519
+ body: Что студент сделает сам
520
+ - title: Дать контекст
521
+ body: Где возникает задача
522
+ - title: Показать пример
523
+ body: Один разобранный ход
524
+ - title: Оставить практику
525
+ body: Задание без подсказки
526
+ - title: Проверить критерий
527
+ body: Видимый признак качества
528
+ active: true
529
+ - title: Закрепить вывод
530
+ body: Что перенести дальше
531
+ ---
532
+
533
+ <!-- Контракт: steps linear — ровно 6 шагов во frontmatter, один из них active. -->
534
+
535
+ # Шесть шагов учебного объяснения
536
+
537
+ ---
538
+ layout: collection
539
+ variant: steps
540
+ arrangement: staggered
541
+ items:
542
+ - label: Сигнал
543
+ title: Заметить сбой
544
+ body: Зафиксировать наблюдение
545
+ - label: Факт
546
+ title: Повторить шаг
547
+ body: Отделить случайность
548
+ - label: Причина
549
+ title: Найти правило
550
+ body: Объяснить механизм
551
+ active: true
552
+ - label: Решение
553
+ title: Изменить подход
554
+ body: Проверить новый ход
555
+ - label: Перенос
556
+ title: Записать вывод
557
+ body: Применить в другой задаче
558
+ ---
559
+
560
+ <!-- Контракт: steps staggered — ровно 5 шагов во frontmatter для ритма 3+2. -->
561
+
562
+ # Как команда разбирает ошибку
563
+
564
+ ---
565
+ layout: collection
566
+ variant: metrics
567
+ arrangement: dashboard
568
+ metrics:
569
+ - value: '84%'
570
+ label: завершили тренажёр
571
+ - value: '18 мин'
572
+ label: медиана выполнения
573
+ - value: '3'
574
+ label: попытки до зачёта
575
+ - value: '12'
576
+ label: вопросов разобрали
577
+ - value: '+21 п. п.'
578
+ label: к входной диагностике
579
+ ---
580
+
581
+ <!-- Контракт: metrics dashboard — ровно 5 метрик во frontmatter, без media и ручных spans. -->
582
+
583
+ # Что изменилось после практикума
584
+
585
+ ---
586
+ layout: collection
587
+ variant: facts
588
+ arrangement: numbered-quartet
589
+ ---
590
+
591
+ <!-- Контракт: facts numbered-quartet — заголовок и 4 вложенных Markdown-факта; номера добавляет тема. -->
592
+
593
+ # Четыре вопроса перед публикацией задания
594
+
595
+ - Цель
596
+ - Что студент сделает самостоятельно
597
+ - Контекст
598
+ - Почему задача нужна именно сейчас
599
+ - Критерий
600
+ - Как выглядит проверяемый результат
601
+ - Обратная связь
602
+ - Что поможет улучшить следующую попытку
603
+
474
604
  ---
475
605
  layout: collection
476
606
  variant: metrics
@@ -717,7 +847,7 @@ header: default
717
847
  </Slot>
718
848
 
719
849
  <Slot area="6 / 1 / -1 / 7" surface="color" tone="orange" margin="4" gap="2">
720
- <Text size="5">Цветная поверхность</Text>
850
+ <Text size="5">Акцентная поверхность</Text>
721
851
  <Text size="3">Слот целиком красится в текущий акцентный цвет — для смыслового выделения.</Text>
722
852
  </Slot>
723
853
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slidev-theme-practicum",
3
- "version": "0.1.3",
3
+ "version": "0.2.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",
@@ -10,8 +10,9 @@ import {
10
10
 
11
11
  const PROJECT_ROOT = resolve(import.meta.dirname, '..')
12
12
  const DIST_DIR = resolve(PROJECT_ROOT, 'dist')
13
- const REPRESENTATIVE_SLIDES = [1, 9, 15, 20, 32, 40, 41]
13
+ const REPRESENTATIVE_SLIDES = [1, 9, 15, 20, 38, 46, 47]
14
14
  const VISUAL_ONLY_SLIDES = [17, 18]
15
+ const P0_REGRESSION_SLIDES = [23, 24, 25, 26, 27, 28]
15
16
  const LIFECYCLE_EVENT = 'practicum:slide-lifecycle'
16
17
  const LIFECYCLE_STATE_KEY = '__practicumBrowserLifecycle'
17
18
  const THEME_MEDIA = [
@@ -168,6 +169,88 @@ async function assertClosingLayout(slide, slideNumber, expectedTextCount) {
168
169
  )
169
170
  }
170
171
 
172
+ async function assertStaggeredStepsLayout(slide, slideNumber) {
173
+ const grid = slide.locator('.Slide-StepsGrid_staggered')
174
+ const cards = grid.locator(':scope > .Slide-Step')
175
+ const cardCount = await cards.count()
176
+
177
+ assert.equal(cardCount, 5, `слайд ${slideNumber}: staggered должен содержать ровно 5 карточек`)
178
+
179
+ const [gridBox, cardBoxes, placements] = await Promise.all([
180
+ grid.boundingBox(),
181
+ 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
+ ])
193
+
194
+ assert.ok(gridBox, `слайд ${slideNumber}: не найдена геометрия staggered grid`)
195
+ 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`)
203
+
204
+ const boxes = cardBoxes
205
+ const topY = boxes[0].y
206
+ const bottomY = boxes[3].y
207
+
208
+ assert.ok(
209
+ boxes.slice(0, 3).every(box => Math.abs(box.y - topY) <= 1),
210
+ `слайд ${slideNumber}: первые три staggered-карточки должны быть в верхнем ряду`,
211
+ )
212
+ assert.ok(
213
+ boxes.slice(3).every(box => Math.abs(box.y - bottomY) <= 1) && bottomY > topY,
214
+ `слайд ${slideNumber}: последние две staggered-карточки должны быть в нижнем ряду`,
215
+ )
216
+
217
+ const lowerCenter = (boxes[3].x + boxes[4].x + boxes[4].width) / 2
218
+ const gridCenter = gridBox.x + gridBox.width / 2
219
+
220
+ assert.ok(
221
+ Math.abs(lowerCenter - gridCenter) <= 1,
222
+ `слайд ${slideNumber}: нижняя пара staggered не центрирована`,
223
+ )
224
+ }
225
+
226
+ async function assertNumberedQuartet(slide, slideNumber) {
227
+ const markers = slide.locator('.Text_size_4')
228
+ assert.deepEqual(
229
+ (await markers.allTextContents()).map(text => text.trim()),
230
+ ['1', 'Цель', '2', 'Контекст', '3', 'Критерий', '4', 'Обратная связь'],
231
+ `слайд ${slideNumber}: номер и название каждого факта должны быть plain Text размера 4`,
232
+ )
233
+ assert.equal(
234
+ await slide.locator('.Slide-FactNumber').count(),
235
+ 0,
236
+ `слайд ${slideNumber}: номера не должны оформляться кружками`,
237
+ )
238
+
239
+ const factTitles = slide.locator('.Text_size_4.Text_align_end')
240
+ assert.deepEqual(
241
+ (await factTitles.allTextContents()).map(text => text.trim()),
242
+ ['Цель', 'Контекст', 'Критерий', 'Обратная связь'],
243
+ `слайд ${slideNumber}: названия фактов должны быть прижаты к нижнему краю карточки`,
244
+ )
245
+ assert.ok(
246
+ (await factTitles.evaluateAll(elements => elements.every((element) => {
247
+ const marginTop = Number.parseFloat(getComputedStyle(element).marginTop)
248
+ return Number.isFinite(marginTop) && marginTop > 0
249
+ }))),
250
+ `слайд ${slideNumber}: названия фактов должны быть отодвинуты вниз от номера`,
251
+ )
252
+ }
253
+
171
254
  async function inspectSlide(
172
255
  page,
173
256
  origin,
@@ -222,12 +305,16 @@ async function inspectSlide(
222
305
  result.heightOverflow <= 1,
223
306
  `слайд ${slideNumber}: вертикальное переполнение ${result.heightOverflow}px`,
224
307
  )
225
- if (slideNumber === 32)
308
+ if (slideNumber === 38)
226
309
  await assertStrongEmphasis(slide, slideNumber)
227
- if (slideNumber === 40)
310
+ if (slideNumber === 46)
228
311
  await assertClosingLayout(slide, slideNumber, 1)
229
- if (slideNumber === 41)
312
+ if (slideNumber === 47)
230
313
  await assertClosingLayout(slide, slideNumber, 2)
314
+ if (slideNumber === 26)
315
+ await assertStaggeredStepsLayout(slide, slideNumber)
316
+ if (slideNumber === 28)
317
+ await assertNumberedQuartet(slide, slideNumber)
231
318
  assertLifecycleTransition(
232
319
  beforeLifecycle,
233
320
  afterLifecycle,
@@ -400,6 +487,17 @@ export async function runBrowserSmoke() {
400
487
  )
401
488
  }
402
489
 
490
+ for (const slideNumber of P0_REGRESSION_SLIDES) {
491
+ activeSlide = `P0-регрессия: слайд ${slideNumber}`
492
+ await inspectSlide(
493
+ page,
494
+ server.origin,
495
+ slideNumber,
496
+ undefined,
497
+ () => inFlightTracker.waitForIdle(),
498
+ )
499
+ }
500
+
403
501
  for (const asset of THEME_MEDIA) {
404
502
  activeSlide = `медиа: ${asset.label}`
405
503
  await loadThemeImage(page, server.origin, asset)
@@ -413,7 +511,7 @@ export async function runBrowserSmoke() {
413
511
  assertNoDiagnostics('неуспешные запросы того же источника', failedRequests)
414
512
 
415
513
  console.log(
416
- `Браузерная проверка: слайды=${REPRESENTATIVE_SLIDES.length + VISUAL_ONLY_SLIDES.length}, `
514
+ `Браузерная проверка: слайды=${REPRESENTATIVE_SLIDES.length + VISUAL_ONLY_SLIDES.length + P0_REGRESSION_SLIDES.length}, `
417
515
  + `ошибкиСтраницы=${pageErrors.length}, ошибкиКонсоли=${consoleErrors.length}, `
418
516
  + `ошибочныеОтветы=${failedResponses.length}, активныеЗапросы=${inFlightTracker.size}, `
419
517
  + `переполнение=0, медиа=${THEME_MEDIA.length}, переходыЖЦ=${REPRESENTATIVE_SLIDES.length - 1}`,