slidev-theme-practicum 0.1.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +238 -18
  2. package/components/Slide.vue +27 -4
  3. package/components/Slot.vue +2 -1
  4. package/components/StepsGrid.vue +112 -0
  5. package/composables/deck-decors.ts +74 -0
  6. package/composables/decor-sources.ts +43 -0
  7. package/composables/layout-authoring.ts +34 -9
  8. package/composables/layout-recipes.ts +83 -2
  9. package/composables/layout-shorthands.ts +233 -2
  10. package/composables/local-layout-variant-files.ts +106 -0
  11. package/composables/local-layout-variants.ts +73 -0
  12. package/composables/slide-layout.ts +3 -0
  13. package/composables/use-theme-config.ts +17 -10
  14. package/composables/validate-deck-layouts.cjs +77 -6
  15. package/env.d.ts +18 -0
  16. package/example.md +135 -5
  17. package/package.json +6 -3
  18. package/scripts/browser-smoke.mjs +168 -5
  19. package/scripts/check-local-layout-variant-build.mjs +189 -0
  20. package/scripts/check-package.mjs +18 -2
  21. package/scripts/requirements-illustrations.txt +1 -0
  22. package/scripts/trace-line-art.py +422 -0
  23. package/scripts/validate-deck.cjs +1 -1
  24. package/setup/vite-plugins.ts +109 -2
  25. package/skills/slidev-practicum/SKILL.md +18 -4
  26. package/skills/slidev-practicum/references/contour-illustrations.md +114 -0
  27. package/skills/slidev-practicum/references/deck-project-structure.md +136 -0
  28. package/skills/slidev-practicum/references/illustration-examples/balance-scales.png +0 -0
  29. package/skills/slidev-practicum/references/illustration-examples/balance-scales.svg +88 -0
  30. package/skills/slidev-practicum/references/illustration-examples/chainsaw.png +0 -0
  31. package/skills/slidev-practicum/references/illustration-examples/chainsaw.svg +4 -0
  32. package/skills/slidev-practicum/references/illustration-examples/graduation-cap.png +0 -0
  33. package/skills/slidev-practicum/references/illustration-examples/graduation-cap.svg +23 -0
  34. package/skills/slidev-practicum/references/illustration-examples/woodcutter-axe.png +0 -0
  35. package/skills/slidev-practicum/references/illustration-examples/woodcutter-axe.svg +4 -0
  36. package/skills/slidev-practicum/references/photographic-illustrations.md +100 -0
@@ -0,0 +1,74 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { extname, isAbsolute, relative, resolve } from 'node:path'
4
+ import { pathToFileURL } from 'node:url'
5
+ import { parse as parseYaml } from 'yaml'
6
+ import { collectDecorSources, unwrapDecorFile } from './decor-sources'
7
+
8
+ export const DECK_DECORS_VIRTUAL_ID = 'virtual:practicum-deck-decors'
9
+ export const RESOLVED_DECK_DECORS_VIRTUAL_ID = `\0${DECK_DECORS_VIRTUAL_ID}`
10
+ export const CONVENTION_DECOR_FILES = Object.freeze([
11
+ 'decors.yaml',
12
+ 'decors.yml',
13
+ 'decors.json',
14
+ 'decors.mjs',
15
+ ])
16
+
17
+ const SUPPORTED_EXTENSIONS = new Set(['.yaml', '.yml', '.json', '.mjs', '.js'])
18
+
19
+ export function resolveDecorFilePath(root: string, spec: string) {
20
+ const base = resolve(root)
21
+ const resolved = resolve(base, spec)
22
+ const rel = relative(base, resolved)
23
+
24
+ if (!rel || rel.startsWith('..') || isAbsolute(rel))
25
+ throw new Error(`Decor catalog path escapes the deck root: ${spec}`)
26
+
27
+ return resolved
28
+ }
29
+
30
+ export function findConventionDecorFile(root: string) {
31
+ return CONVENTION_DECOR_FILES
32
+ .map(name => resolve(root, name))
33
+ .find(path => existsSync(path))
34
+ }
35
+
36
+ export function collectDeckDecorFiles(input: { decors: unknown, root: string }) {
37
+ const { files } = collectDecorSources(input.decors)
38
+
39
+ if (files.length)
40
+ return files.map(file => resolveDecorFilePath(input.root, file))
41
+
42
+ if (input.decors != null)
43
+ return []
44
+
45
+ const convention = findConventionDecorFile(input.root)
46
+ return convention ? [convention] : []
47
+ }
48
+
49
+ export async function loadDecorFile(path: string) {
50
+ const ext = extname(path).toLowerCase()
51
+ if (!SUPPORTED_EXTENSIONS.has(ext))
52
+ throw new Error(`Unsupported decor catalog file: ${path}`)
53
+
54
+ if (!existsSync(path))
55
+ throw new Error(`Decor catalog file not found: ${path}`)
56
+
57
+ if (ext === '.mjs' || ext === '.js') {
58
+ const loadModule = new Function('specifier', 'return import(specifier)') as (specifier: string) => Promise<Record<string, unknown>>
59
+ const mod = await loadModule(`${pathToFileURL(path).href}?t=${Date.now()}`)
60
+ return unwrapDecorFile(mod.decors ?? mod.default)
61
+ }
62
+
63
+ const source = await readFile(path, 'utf8')
64
+ return unwrapDecorFile(ext === '.json' ? JSON.parse(source) : parseYaml(source))
65
+ }
66
+
67
+ export async function resolveDeckFileDecors(input: { decors: unknown, root: string }) {
68
+ const records: Record<string, unknown>[] = []
69
+
70
+ for (const path of collectDeckDecorFiles(input))
71
+ records.push(...await loadDecorFile(path))
72
+
73
+ return records
74
+ }
@@ -0,0 +1,43 @@
1
+ export function isRecord(value: unknown): value is Record<string, unknown> {
2
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
3
+ }
4
+
5
+ export function isDecorFileRef(value: unknown): value is string {
6
+ return typeof value === 'string' && Boolean(value.trim())
7
+ }
8
+
9
+ export function collectDecorSources(value: unknown): {
10
+ files: string[]
11
+ inline: Record<string, unknown>[]
12
+ } {
13
+ if (isDecorFileRef(value))
14
+ return { files: [value.trim()], inline: [] }
15
+
16
+ if (!Array.isArray(value))
17
+ return { files: [], inline: [] }
18
+
19
+ const files: string[] = []
20
+ const inline: Record<string, unknown>[] = []
21
+
22
+ for (const item of value) {
23
+ if (isDecorFileRef(item))
24
+ files.push(item.trim())
25
+ else if (isRecord(item))
26
+ inline.push(item)
27
+ }
28
+
29
+ return { files, inline }
30
+ }
31
+
32
+ export function unwrapDecorFile(value: unknown): Record<string, unknown>[] {
33
+ if (Array.isArray(value))
34
+ return value.filter(isRecord)
35
+
36
+ if (isRecord(value) && Array.isArray(value.decors))
37
+ return value.decors.filter(isRecord)
38
+
39
+ if (isRecord(value) && typeof value.id === 'string')
40
+ return [value]
41
+
42
+ return []
43
+ }
@@ -8,6 +8,7 @@ import {
8
8
  type LayoutShorthandContext,
9
9
  } from './layout-shorthands'
10
10
  import {
11
+ hasLayoutVariant,
11
12
  resolveLayoutSpec,
12
13
  resolveLayoutVariant,
13
14
  resolveLayoutRecipe,
@@ -21,6 +22,11 @@ import {
21
22
  type ThemeLayoutSlotSpec,
22
23
  } from './layout-recipes'
23
24
  import { isBlankTextVNode, isCommentVNode } from './layout-vnode'
25
+ import {
26
+ buildDeckLayoutVariantVNode,
27
+ resolveDeckLayoutVariant,
28
+ type DeckLayoutVariantCatalog,
29
+ } from './local-layout-variants'
24
30
  import { createSlotRules, type SlotRules } from './slot-rules'
25
31
  import {
26
32
  isContrastSlideMode,
@@ -48,6 +54,7 @@ export type LayoutAuthoringInput = {
48
54
  defaultTone: ThemeTone
49
55
  debugGrid?: boolean
50
56
  slotPlanState?: LayoutSlotPlanState
57
+ layoutVariants?: DeckLayoutVariantCatalog
51
58
  }
52
59
 
53
60
  export type LayoutSlotPlanState = Map<string, { role: ThemeLayoutRole, index: number }>
@@ -303,17 +310,27 @@ function resolveHeader(input: {
303
310
  export function compileLayoutAuthoring(input: LayoutAuthoringInput): CompiledLayoutAuthoring {
304
311
  const props = input.props
305
312
  const frontmatter = input.frontmatter
306
- const explicitLayout = props.layout || readFrontmatterString(frontmatter, 'layout')
313
+ const frontmatterLayout = readFrontmatterString(frontmatter, 'layout')
314
+ const explicitLayout = props.layout || frontmatterLayout
315
+ const rawVariant = props.variant || readFrontmatterString(frontmatter, 'variant')
316
+ const rawArrangement = props.arrangement || readFrontmatterString(frontmatter, 'arrangement')
307
317
  const layout = isThemeLayout(explicitLayout) ? explicitLayout : ''
308
318
  const active = Boolean(layout)
309
319
  const slideMode = resolveSlideMode(props.mode ?? readFrontmatterString(frontmatter, 'mode'), 'Slide.mode', 'light')
310
- const variant = resolveLayoutVariant(
311
- layout,
312
- props.variant || readFrontmatterString(frontmatter, 'variant'),
313
- props.arrangement || readFrontmatterString(frontmatter, 'arrangement'),
314
- )
315
- const layoutSpec = active ? resolveLayoutSpec(layout as ThemeLayout, variant) : null
316
- const recipe = active ? resolveLayoutRecipe(layout as ThemeLayout, variant) : null
320
+ const builtInVariant = active && hasLayoutVariant(layout as ThemeLayout, rawVariant)
321
+ const deckVariant = active && rawVariant && !builtInVariant
322
+ ? resolveDeckLayoutVariant({
323
+ layout: layout as ThemeLayout,
324
+ variant: rawVariant,
325
+ arrangement: rawArrangement,
326
+ variants: input.layoutVariants ?? {},
327
+ })
328
+ : undefined
329
+ const variant = deckVariant
330
+ ? rawVariant
331
+ : resolveLayoutVariant(layout, rawVariant, rawArrangement)
332
+ const layoutSpec = active && !deckVariant ? resolveLayoutSpec(layout as ThemeLayout, variant) : null
333
+ const recipe = active && !deckVariant ? resolveLayoutRecipe(layout as ThemeLayout, variant) : null
317
334
  const contrast = isContrastSlideMode(slideMode)
318
335
  const frontmatterDecor = readFrontmatterObject(frontmatter, 'decor')
319
336
  const agendaDecor = resolveAgendaDecor({
@@ -337,7 +354,15 @@ export function compileLayoutAuthoring(input: LayoutAuthoringInput): CompiledLay
337
354
  const rawChildren = input.children?.filter(isVNode) ?? []
338
355
  const shouldCompileChildren = input.children !== undefined
339
356
  const slotRules = createSlotRules({ Slot: input.components.Slot })
340
- const children = active && shouldCompileChildren
357
+ const children = deckVariant && shouldCompileChildren
358
+ ? [buildDeckLayoutVariantVNode({
359
+ layout: layout as ThemeLayout,
360
+ variant,
361
+ component: deckVariant,
362
+ frontmatter,
363
+ children: flattenVNodes(rawChildren, true),
364
+ })]
365
+ : active && shouldCompileChildren
341
366
  ? buildSourceChildren({
342
367
  rawChildren,
343
368
  layout: layout as ThemeLayout,
@@ -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' }],
@@ -350,16 +417,30 @@ export const LAYOUT_RECIPES: ThemeLayoutRecipe[] = [
350
417
  ]),
351
418
  ]
352
419
 
353
- export function resolveLayoutRecipe(layout: ThemeLayout, variant: string, arrangement = '') {
420
+ function findLayoutRecipe(layout: ThemeLayout, variant: string, arrangement = '') {
354
421
  const resolvedVariant = variant.trim()
355
422
  const resolvedArrangement = arrangement.trim()
356
- const recipe = LAYOUT_RECIPES.find(candidate =>
423
+ return LAYOUT_RECIPES.find(candidate =>
357
424
  candidate.layout === layout
358
425
  && candidate.aliases.some(alias =>
359
426
  alias.variant === resolvedVariant
360
427
  && (alias.arrangement ?? '') === resolvedArrangement,
361
428
  ),
362
429
  )
430
+ }
431
+
432
+ export function hasLayoutVariant(layout: ThemeLayout, variant: string) {
433
+ const resolvedVariant = variant.trim()
434
+ return LAYOUT_RECIPES.some(candidate =>
435
+ candidate.layout === layout
436
+ && candidate.aliases.some(alias => alias.variant === resolvedVariant),
437
+ )
438
+ }
439
+
440
+ export function resolveLayoutRecipe(layout: ThemeLayout, variant: string, arrangement = '') {
441
+ const resolvedVariant = variant.trim()
442
+ const resolvedArrangement = arrangement.trim()
443
+ const recipe = findLayoutRecipe(layout, resolvedVariant, resolvedArrangement)
363
444
 
364
445
  if (!recipe) {
365
446
  const suffix = resolvedArrangement ? `:${resolvedArrangement}` : ''
@@ -8,6 +8,7 @@ export type LayoutShorthandComponents = {
8
8
  Image: Component
9
9
  Person: Component
10
10
  Slot: Component
11
+ StepsGrid: Component
11
12
  Text: Component
12
13
  Timeline: Component
13
14
  }
@@ -136,6 +137,7 @@ function hText(
136
137
  priority?: 1 | 2 | 3
137
138
  align?: 'start' | 'middle' | 'end'
138
139
  className?: string
140
+ color?: 'light-0'
139
141
  typography?: 'compact'
140
142
  } = {},
141
143
  ) {
@@ -147,6 +149,7 @@ function hText(
147
149
  priority?: 1 | 2 | 3
148
150
  align?: 'start' | 'middle' | 'end'
149
151
  class?: string
152
+ color?: 'light-0'
150
153
  } & {
151
154
  'data-typography'?: 'compact'
152
155
  } = {
@@ -164,6 +167,8 @@ function hText(
164
167
  textProps.align = options.align
165
168
  if (options.className)
166
169
  textProps.class = options.className
170
+ if (options.color)
171
+ textProps.color = options.color
167
172
  if (options.typography)
168
173
  textProps['data-typography'] = options.typography
169
174
 
@@ -358,6 +363,7 @@ const FACTS_VARIANT_COUNTS: Record<string, number> = {
358
363
  'facts-duo': 2,
359
364
  'facts-trio': 3,
360
365
  'facts-quartet': 4,
366
+ 'facts-numbered-quartet': 4,
361
367
  'facts-stacked': 3,
362
368
  'facts-featured': 3,
363
369
  }
@@ -542,7 +548,7 @@ function buildCenteredChildren(children: VNode[], context: LayoutShorthandContex
542
548
 
543
549
  return [
544
550
  hSlot(context, 'primary', 'slide-message-centered-primary', { centered: true }, [
545
- hText(context, 'h1', '7', title.text, { align: 'middle' }),
551
+ hText(context, 'h1', '7-12', title.text, { align: 'middle' }),
546
552
  ]),
547
553
  ]
548
554
  }
@@ -726,6 +732,154 @@ function buildPointsChildren(children: VNode[], context: LayoutShorthandContext)
726
732
  ]
727
733
  }
728
734
 
735
+ type ComparisonSide = {
736
+ title: string
737
+ kicker: string
738
+ body: string
739
+ }
740
+
741
+ function normalizeComparisonSide(
742
+ value: unknown,
743
+ side: 'from' | 'to',
744
+ ): ComparisonSide {
745
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
746
+ failMarkdownContract('collection:comparison', `ожидает object в comparison.${side}.`)
747
+
748
+ const record = value as Record<string, unknown>
749
+ const title = readRecordText(record, ['title'])
750
+
751
+ if (!title)
752
+ failMarkdownContract('collection:comparison', `ожидает обязательный title в comparison.${side}.`)
753
+
754
+ return {
755
+ title,
756
+ kicker: readRecordText(record, ['kicker']),
757
+ body: readRecordText(record, ['body']),
758
+ }
759
+ }
760
+
761
+ function buildComparisonSideChildren(
762
+ context: LayoutShorthandContext,
763
+ side: ComparisonSide,
764
+ index: number,
765
+ ) {
766
+ return hSlot(context, 'secondary', `slide-collection-${context.variant}-side-${index + 1}`, {
767
+ gap: '3',
768
+ }, [
769
+ ...(side.kicker
770
+ ? [hText(context, 'div', '2', side.kicker, { priority: 3, muted: true })]
771
+ : []),
772
+ hText(context, 'div', '5-7', side.title, { priority: 1 }),
773
+ ...(side.body
774
+ ? [hText(context, 'div', '3-4', side.body, { priority: 2, muted: true })]
775
+ : []),
776
+ ])
777
+ }
778
+
779
+ function buildComparisonChildren(children: VNode[], context: LayoutShorthandContext) {
780
+ const title = readHeadingText(children, 'collection:comparison')
781
+ const comparison = context.readFrontmatterObject('comparison')
782
+
783
+ if (!comparison)
784
+ failMarkdownContract('collection:comparison', 'ожидает frontmatter comparison с полями from и to.')
785
+
786
+ const relation = context.readFrontmatterObject('relation')
787
+ const from = normalizeComparisonSide(comparison.from, 'from')
788
+ const to = normalizeComparisonSide(comparison.to, 'to')
789
+ const relationLabel = relation ? readRecordText(relation, ['label']) : ''
790
+ const isBeforeAfter = context.variant === 'comparison-before-after'
791
+
792
+ ensureOnlySupportedChildren('collection:comparison', children, node =>
793
+ node === title.node
794
+ || isBlankTextNode(node),
795
+ )
796
+
797
+ return [
798
+ hSlot(context, 'primary', `slide-collection-${context.variant}-primary`, {}, [
799
+ hText(context, 'h1', '6', title.text),
800
+ ]),
801
+ buildComparisonSideChildren(context, from, 0),
802
+ buildComparisonSideChildren(context, to, 1),
803
+ hSlot(context, 'support', `slide-collection-${context.variant}-relation`, {}, [
804
+ h('div', {
805
+ class: [
806
+ 'Slide-ComparisonRelation',
807
+ `Slide-ComparisonRelation_${isBeforeAfter ? 'horizontal' : 'vertical'}`,
808
+ ],
809
+ }, [
810
+ h('span', { 'class': 'Slide-ComparisonDirection', 'aria-hidden': 'true' }, isBeforeAfter ? '→' : '↓'),
811
+ ...(relationLabel
812
+ ? [hText(context, 'div', '2-3', relationLabel, { align: 'middle', muted: true })]
813
+ : []),
814
+ ]),
815
+ ]),
816
+ ]
817
+ }
818
+
819
+ type StepItem = {
820
+ title: string
821
+ body: string
822
+ label: string
823
+ active: boolean
824
+ }
825
+
826
+ function normalizeStepItems(context: LayoutShorthandContext): StepItem[] {
827
+ const items = context.readFrontmatterArray('items')
828
+ const isStaggered = context.variant === 'steps-staggered'
829
+
830
+ if (items.length < 3 || items.length > 6)
831
+ failMarkdownContract('collection:steps', 'ожидает от 3 до 6 элементов во frontmatter items.')
832
+
833
+ if (isStaggered && items.length !== 5)
834
+ failMarkdownContract('collection:steps', 'arrangement: staggered ожидает ровно 5 элементов.')
835
+
836
+ const normalized = items.map((item, index) => {
837
+ if (typeof item !== 'object' || item === null || Array.isArray(item))
838
+ failMarkdownContract('collection:steps', `ожидает object item в items[${index}].`)
839
+
840
+ const record = item as Record<string, unknown>
841
+ const title = readRecordText(record, ['title'])
842
+
843
+ if (!title)
844
+ failMarkdownContract('collection:steps', `ожидает обязательный title в items[${index}].`)
845
+
846
+ return {
847
+ title,
848
+ body: readRecordText(record, ['body']),
849
+ label: readRecordText(record, ['label']) || String(index + 1),
850
+ active: record.active === true,
851
+ }
852
+ })
853
+
854
+ if (normalized.filter(item => item.active).length > 1)
855
+ failMarkdownContract('collection:steps', 'поддерживает не больше одного элемента с active: true.')
856
+
857
+ return normalized
858
+ }
859
+
860
+ function buildStepsChildren(children: VNode[], context: LayoutShorthandContext) {
861
+ const title = readHeadingText(children, 'collection:steps')
862
+ const items = normalizeStepItems(context)
863
+ const arrangement = context.variant === 'steps-staggered' ? 'staggered' : 'linear'
864
+
865
+ ensureOnlySupportedChildren('collection:steps', children, node =>
866
+ node === title.node
867
+ || isBlankTextNode(node),
868
+ )
869
+
870
+ return [
871
+ hSlot(context, 'primary', `slide-collection-${context.variant}-primary`, {}, [
872
+ hText(context, 'h1', '6', title.text),
873
+ ]),
874
+ hSlot(context, 'support', `slide-collection-${context.variant}-support`, {}, [
875
+ h(context.components.StepsGrid, {
876
+ items,
877
+ arrangement,
878
+ }),
879
+ ]),
880
+ ]
881
+ }
882
+
729
883
  function normalizeTimelineItems(context: LayoutShorthandContext) {
730
884
  const items = context.readFrontmatterArray('items')
731
885
 
@@ -804,6 +958,32 @@ function normalizeMetricItems(context: LayoutShorthandContext) {
804
958
  })
805
959
  }
806
960
 
961
+ function normalizeDashboardMetrics(context: LayoutShorthandContext) {
962
+ const metrics = context.readFrontmatterArray('metrics')
963
+
964
+ if (metrics.length !== 5)
965
+ failMarkdownContract('collection:metrics', 'arrangement: dashboard ожидает ровно 5 элементов во frontmatter metrics.')
966
+
967
+ return metrics.map((item, index) => {
968
+ if (typeof item !== 'object' || item === null || Array.isArray(item))
969
+ failMarkdownContract('collection:metrics', `ожидает object item в metrics[${index}].`)
970
+
971
+ const record = item as Record<string, unknown>
972
+ const value = readRecordText(record, ['value'])
973
+ const label = readRecordText(record, ['body', 'label', 'title'])
974
+ const hasArbitrarySpan = ['area', 'col', 'row', 'span', 'colSpan', 'rowSpan']
975
+ .some(key => record[key] !== undefined)
976
+
977
+ if (!value || !label)
978
+ failMarkdownContract('collection:metrics', `ожидает value и body/label/title в metrics[${index}].`)
979
+
980
+ if (hasArbitrarySpan)
981
+ failMarkdownContract('collection:metrics', `не принимает произвольные координаты или spans в metrics[${index}].`)
982
+
983
+ return { value, label }
984
+ })
985
+ }
986
+
807
987
  function normalizeMetricItemsFromList(list: VNode) {
808
988
  const entries = readNestedListItems(list, 'collection:metrics')
809
989
 
@@ -841,6 +1021,32 @@ function normalizeMediaItems(context: LayoutShorthandContext) {
841
1021
  }
842
1022
 
843
1023
  function buildMetricsChildren(children: VNode[], context: LayoutShorthandContext) {
1024
+ if (context.variant === 'metrics-dashboard') {
1025
+ const title = readHeadingText(children, 'collection:metrics')
1026
+ const metrics = normalizeDashboardMetrics(context)
1027
+ const fitGroup = 'collection-metrics-dashboard'
1028
+
1029
+ ensureOnlySupportedChildren('collection:metrics', children, node =>
1030
+ node === title.node
1031
+ || isBlankTextNode(node),
1032
+ )
1033
+
1034
+ return [
1035
+ hSlot(context, 'primary', 'slide-collection-metrics-dashboard-primary', {}, [
1036
+ hText(context, 'h1', '6', title.text),
1037
+ ]),
1038
+ ...metrics.map((metric, index) =>
1039
+ hSlot(context, 'support', `slide-collection-metrics-dashboard-support-${index + 1}`, {
1040
+ fitGroup,
1041
+ gap: '2',
1042
+ }, [
1043
+ hText(context, 'div', index < 3 ? '5-7' : '6-8', metric.value, { priority: 1 }),
1044
+ hText(context, 'div', '2-4', metric.label, { priority: 2, muted: true, typography: 'compact' }),
1045
+ ]),
1046
+ ),
1047
+ ]
1048
+ }
1049
+
844
1050
  const list = children.find(isListNode)
845
1051
 
846
1052
  ensureOnlySupportedChildren('collection:metrics', children, node =>
@@ -964,7 +1170,8 @@ function buildFactsChildren(children: VNode[], context: LayoutShorthandContext)
964
1170
  const isStacked = context.variant === 'facts-stacked'
965
1171
  const isDuo = context.variant === 'facts-duo'
966
1172
  const isQuartet = context.variant === 'facts-quartet'
967
- const shouldCoordinateFactSizes = isStacked || isDuo || context.variant === 'facts-trio' || isQuartet
1173
+ const isNumberedQuartet = context.variant === 'facts-numbered-quartet'
1174
+ const shouldCoordinateFactSizes = isStacked || isDuo || context.variant === 'facts-trio' || isQuartet || isNumberedQuartet
968
1175
  const factFitGroup = shouldCoordinateFactSizes ? `collection-${context.variant}-facts` : ''
969
1176
  const supportFactFitGroup = isFeatured ? `collection-${context.variant}-support-facts` : factFitGroup
970
1177
  const headingText = readInlineText(heading)
@@ -997,6 +1204,24 @@ function buildFactsChildren(children: VNode[], context: LayoutShorthandContext)
997
1204
  if (footnoteText && !factFitGroup)
998
1205
  featuredChildren.push(hText(context, 'div', '2-3', footnoteText, { priority: 3, muted: true }))
999
1206
 
1207
+ if (isNumberedQuartet) {
1208
+ return [
1209
+ hSlot(context, 'primary', 'slide-collection-facts-numbered-quartet-primary', { gap: '3' }, primaryChildren),
1210
+ ...facts.map((fact, index) =>
1211
+ hSlot(context, 'support', `slide-collection-facts-numbered-quartet-support-${index + 1}`, {
1212
+ gap: '2',
1213
+ fitGroup: factFitGroup,
1214
+ }, [
1215
+ hText(context, 'div', '4', String(index + 1), {
1216
+ priority: 1,
1217
+ }),
1218
+ hText(context, 'div', '4', fact.value, { priority: 1, align: 'end' }),
1219
+ hText(context, 'div', '2-3', fact.label, { priority: 2, muted: true }),
1220
+ ]),
1221
+ ),
1222
+ ]
1223
+ }
1224
+
1000
1225
  return [
1001
1226
  hSlot(context, 'primary', `slide-collection-${context.variant}-primary`, { gap: '3' }, primaryChildren),
1002
1227
  hSlot(context, 'support', `slide-collection-${context.variant}-support-1`, {
@@ -1018,16 +1243,22 @@ function buildFactsChildren(children: VNode[], context: LayoutShorthandContext)
1018
1243
 
1019
1244
  const LAYOUT_SHORTHANDS = {
1020
1245
  'collection:agenda': buildAgendaChildren,
1246
+ 'collection:comparison-before-after': buildComparisonChildren,
1247
+ 'collection:comparison-stable-variable': buildComparisonChildren,
1021
1248
  'collection:facts-stacked': buildFactsChildren,
1022
1249
  'collection:facts-duo': buildFactsChildren,
1023
1250
  'collection:facts-trio': buildFactsChildren,
1024
1251
  'collection:facts-quartet': buildFactsChildren,
1252
+ 'collection:facts-numbered-quartet': buildFactsChildren,
1025
1253
  'collection:facts-featured': buildFactsChildren,
1026
1254
  'collection:points-trio': buildPointsChildren,
1255
+ 'collection:steps-linear': buildStepsChildren,
1256
+ 'collection:steps-staggered': buildStepsChildren,
1027
1257
  'collection:timeline': buildTimelineChildren,
1028
1258
  'collection:metrics-featured-media': buildMetricsChildren,
1029
1259
  'collection:metrics-featured-copy': buildMetricsChildren,
1030
1260
  'collection:metrics-featured-copy-split-media': buildMetricsChildren,
1261
+ 'collection:metrics-dashboard': buildMetricsChildren,
1031
1262
  'message:centered': buildCenteredChildren,
1032
1263
  'message:quote': buildQuoteChildren,
1033
1264
  'message:closing': buildClosingChildren,