slidev-theme-practicum 0.2.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.
Files changed (50) hide show
  1. package/README.md +225 -22
  2. package/components/Slide.vue +12 -58
  3. package/components/Slot.vue +4 -3
  4. package/components/StepsGrid.vue +117 -0
  5. package/components/Text.vue +5 -0
  6. package/composables/deck-decors.ts +74 -0
  7. package/composables/deck-slot-markup.cjs +19 -5
  8. package/composables/decor-sources.ts +43 -0
  9. package/composables/layout-authoring.ts +34 -9
  10. package/composables/layout-recipes.ts +16 -2
  11. package/composables/layout-shorthands.ts +157 -83
  12. package/composables/local-layout-variant-files.ts +106 -0
  13. package/composables/local-layout-variants.ts +73 -0
  14. package/composables/slide-layout.ts +3 -0
  15. package/composables/text-fit-runtime.ts +7 -3
  16. package/composables/theme-foundation.ts +7 -3
  17. package/composables/typography-guard.cjs +59 -0
  18. package/composables/use-theme-config.ts +17 -10
  19. package/composables/validate-deck-layouts.cjs +95 -7
  20. package/composables/validate-deck-typography.cjs +101 -0
  21. package/env.d.ts +18 -0
  22. package/example.md +1 -1
  23. package/package.json +18 -6
  24. package/scripts/browser-smoke.mjs +85 -23
  25. package/scripts/check-accessibility.mjs +364 -0
  26. package/scripts/check-consumer.mjs +159 -0
  27. package/scripts/check-local-layout-variant-build.mjs +189 -0
  28. package/scripts/check-package.mjs +18 -2
  29. package/scripts/check-pixels.mjs +251 -0
  30. package/scripts/check-typography.mjs +108 -0
  31. package/scripts/requirements-illustrations.txt +1 -0
  32. package/scripts/test-typography.mjs +89 -0
  33. package/scripts/trace-line-art.py +422 -0
  34. package/scripts/typography-browser.mjs +134 -0
  35. package/scripts/validate-deck.cjs +23 -3
  36. package/setup/vite-plugins.ts +109 -2
  37. package/skills/slidev-practicum/SKILL.md +19 -4
  38. package/skills/slidev-practicum/references/contour-illustrations.md +114 -0
  39. package/skills/slidev-practicum/references/deck-project-structure.md +136 -0
  40. package/skills/slidev-practicum/references/illustration-examples/balance-scales.png +0 -0
  41. package/skills/slidev-practicum/references/illustration-examples/balance-scales.svg +88 -0
  42. package/skills/slidev-practicum/references/illustration-examples/chainsaw.png +0 -0
  43. package/skills/slidev-practicum/references/illustration-examples/chainsaw.svg +4 -0
  44. package/skills/slidev-practicum/references/illustration-examples/graduation-cap.png +0 -0
  45. package/skills/slidev-practicum/references/illustration-examples/graduation-cap.svg +23 -0
  46. package/skills/slidev-practicum/references/illustration-examples/woodcutter-axe.png +0 -0
  47. package/skills/slidev-practicum/references/illustration-examples/woodcutter-axe.svg +4 -0
  48. package/skills/slidev-practicum/references/photographic-illustrations.md +100 -0
  49. package/styles/index.css +20 -27
  50. package/styles/vars.css +6 -2
@@ -0,0 +1,117 @@
1
+ <script setup lang="ts">
2
+ import { getCurrentInstance } from 'vue'
3
+ import Text from './Text.vue'
4
+ import TextFitGroup from './TextFitGroup.vue'
5
+
6
+ type StepItem = {
7
+ title: string
8
+ body: string
9
+ label: string
10
+ active: boolean
11
+ }
12
+
13
+ const stepsFitGroup = `steps-${getCurrentInstance()?.uid ?? 'default'}`
14
+
15
+ const props = defineProps<{
16
+ items: StepItem[]
17
+ arrangement: 'linear' | 'staggered'
18
+ }>()
19
+ </script>
20
+
21
+ <template>
22
+ <div class="Slide-StepsGrid"
23
+ :class="`Slide-StepsGrid_${props.arrangement}`"
24
+ :data-count="props.items.length"
25
+ :style="{ '--slide-steps-count': props.items.length }">
26
+ <article v-for="(item, index) in props.items"
27
+ :key="index"
28
+ class="Slide-Step"
29
+ :class="{ 'Slide-Step_active': item.active }"
30
+ :data-index="index + 1">
31
+ <Text size="4">{{ item.label }}</Text>
32
+ <TextFitGroup class="Slide-Step-FitGroup Slide-Step-TitleFitGroup"
33
+ :fit-group="`${stepsFitGroup}-titles`">
34
+ <Text size="3-5">{{ item.title }}</Text>
35
+ </TextFitGroup>
36
+ <TextFitGroup v-if="item.body"
37
+ class="Slide-Step-FitGroup"
38
+ :fit-group="`${stepsFitGroup}-bodies`">
39
+ <Text size="2-3" priority="2" muted>{{ item.body }}</Text>
40
+ </TextFitGroup>
41
+ </article>
42
+ </div>
43
+ </template>
44
+
45
+ <style scoped>
46
+ .Slide-StepsGrid {
47
+ display: grid;
48
+ width: 100%;
49
+ max-width: 100%;
50
+ height: 100%;
51
+ min-width: 0;
52
+ min-height: 0;
53
+ grid-template-columns: repeat(var(--slide-steps-count), minmax(0, 1fr));
54
+ gap: var(--theme-grid-gap);
55
+ }
56
+
57
+ .Slide-Step {
58
+ display: flex;
59
+ min-width: 0;
60
+ min-height: 0;
61
+ overflow: hidden;
62
+ flex-direction: column;
63
+ gap: calc(var(--theme-grid-module) * 2);
64
+ padding: var(--theme-slot-margin-3);
65
+ border-radius: var(--theme-panel-radius);
66
+ background: var(--theme-surface-light);
67
+ }
68
+
69
+ .Slide-Step-FitGroup {
70
+ width: 100%;
71
+ height: auto;
72
+ min-width: 0;
73
+ }
74
+
75
+ .Slide-Step :deep(.TextFitGroup) {
76
+ width: 100%;
77
+ height: auto;
78
+ min-width: 0;
79
+ }
80
+
81
+ .Slide-StepsGrid_staggered .Slide-Step-TitleFitGroup {
82
+ margin-top: auto;
83
+ }
84
+
85
+ .Slide-StepsGrid_staggered {
86
+ grid-template-columns: repeat(6, minmax(0, 1fr));
87
+ grid-template-rows: repeat(2, minmax(0, 1fr));
88
+ }
89
+
90
+ .Slide-StepsGrid_staggered .Slide-Step {
91
+ grid-column: span 2;
92
+ }
93
+
94
+ .Slide-StepsGrid_staggered[data-count='2'] .Slide-Step {
95
+ grid-column: 2 / 6;
96
+ }
97
+
98
+ .Slide-StepsGrid_staggered[data-count='3'] .Slide-Step,
99
+ .Slide-StepsGrid_staggered[data-count='4'] .Slide-Step {
100
+ grid-column: span 3;
101
+ }
102
+
103
+ .Slide-StepsGrid_staggered[data-count='3'] .Slide-Step:nth-child(3) {
104
+ grid-column: 2 / 6;
105
+ }
106
+
107
+ .Slide-StepsGrid_staggered[data-count='5'] .Slide-Step:nth-child(4) {
108
+ grid-column: 2 / 4;
109
+ }
110
+
111
+ .Slide-Step_active {
112
+ background: var(--theme-current-color);
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);
116
+ }
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
  {
@@ -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
+ }
@@ -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),
@@ -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,
@@ -417,16 +417,30 @@ export const LAYOUT_RECIPES: ThemeLayoutRecipe[] = [
417
417
  ]),
418
418
  ]
419
419
 
420
- export function resolveLayoutRecipe(layout: ThemeLayout, variant: string, arrangement = '') {
420
+ function findLayoutRecipe(layout: ThemeLayout, variant: string, arrangement = '') {
421
421
  const resolvedVariant = variant.trim()
422
422
  const resolvedArrangement = arrangement.trim()
423
- const recipe = LAYOUT_RECIPES.find(candidate =>
423
+ return LAYOUT_RECIPES.find(candidate =>
424
424
  candidate.layout === layout
425
425
  && candidate.aliases.some(alias =>
426
426
  alias.variant === resolvedVariant
427
427
  && (alias.arrangement ?? '') === resolvedArrangement,
428
428
  ),
429
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)
430
444
 
431
445
  if (!recipe) {
432
446
  const suffix = resolvedArrangement ? `:${resolvedArrangement}` : ''