@troshab/slidev-theme-troshab 0.1.6 → 0.1.8

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.
@@ -45,7 +45,6 @@ defineProps<{
45
45
  padding: 1rem 1rem 1rem 1.25rem;
46
46
  margin-block: 1rem;
47
47
  background-color: var(--color-bg-soft);
48
- color: var(--color-text);
49
48
  border-left: var(--border-accent) solid var(--color-info);
50
49
  border-radius: 0 0.375rem 0.375rem 0;
51
50
  }
@@ -123,7 +123,10 @@ const sizeClasses: Record<string, string> = {
123
123
 
124
124
  .metric-value {
125
125
  font-weight: var(--font-weight-bold);
126
- line-height: 1.1;
126
+ /* IBM Plex Sans glyph box ~1.275 + tabular-nums + bold add ~5% — at h1*1.5 (lg)
127
+ and gradient text (background-clip: text), browser computes scrollH 5-7% above
128
+ line-height. 1.4 absorbs the rounding without visible spacing change. */
129
+ line-height: 1.4;
127
130
  font-variant-numeric: tabular-nums;
128
131
  white-space: nowrap;
129
132
  overflow: hidden;
@@ -267,6 +267,12 @@ function isUpcoming(index: number) {
267
267
  .stepper-step {
268
268
  display: flex;
269
269
  align-items: center;
270
+ /* Reserve horizontal space for .stepper-marker's transform: scale(1.15) on
271
+ * active state. Scaled marker's visible box extends ~6px past its layout
272
+ * box; without padding, step.scrollWidth exceeds clientWidth by 1-3px on
273
+ * short labels. Per CSS spec, transformed descendants count toward parent
274
+ * scrollable overflow. */
275
+ padding: 0 var(--transform-scale-comp);
270
276
  }
271
277
 
272
278
  .stepper-horizontal .stepper-step {
@@ -16,7 +16,7 @@ import type { SemanticColor } from '../composables/useColors'
16
16
  import { semanticColorVar } from '../composables/useColors'
17
17
 
18
18
  const props = withDefaults(defineProps<{
19
- marker?: 'disc' | 'number' | 'check' | 'arrow' | 'star' | 'none'
19
+ marker?: 'disc' | 'number' | 'check' | 'cross' | 'arrow' | 'star' | 'none'
20
20
  color?: SemanticColor
21
21
  size?: 'sm' | 'md' | 'lg'
22
22
  columns?: 1 | 2
@@ -81,6 +81,15 @@ const props = withDefaults(defineProps<{
81
81
  font-weight: var(--font-weight-bold);
82
82
  }
83
83
 
84
+ /* Marker: cross (pairs with check — semantic "mistake/don't/anti-pattern") */
85
+ .styled-list-cross li::before {
86
+ content: '\2717';
87
+ position: absolute;
88
+ left: 0;
89
+ color: var(--marker-color);
90
+ font-weight: var(--font-weight-bold);
91
+ }
92
+
84
93
  /* Marker: arrow */
85
94
  .styled-list-arrow li::before {
86
95
  content: '\2192';
@@ -115,7 +124,7 @@ const props = withDefaults(defineProps<{
115
124
  left: 0;
116
125
  color: var(--marker-color);
117
126
  font-weight: var(--font-weight-bold);
118
- font-size: var(--font-size-base);
127
+ font-size: inherit;
119
128
  width: 1.5em;
120
129
  text-align: center;
121
130
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@troshab/slidev-theme-troshab",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "A minimal, universal Slidev theme with flexible layouts and ready-to-use slide templates",
5
5
  "author": "troshab",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",
@@ -39,6 +39,14 @@
39
39
  "slidev-addon-fancy-arrow": "^0.16.0",
40
40
  "slidev-addon-preload-images": "^0.2.0"
41
41
  },
42
+ "peerDependencies": {
43
+ "shiki": ">=3.0.0"
44
+ },
45
+ "peerDependenciesMeta": {
46
+ "shiki": {
47
+ "optional": true
48
+ }
49
+ },
42
50
  "scripts": {
43
51
  "dev": "npx concurrently -n dark,light -c magenta,cyan \"slidev example_dark.md --port 30303\" \"slidev example_white.md --port 31313\"",
44
52
  "dev:dark": "slidev example_dark.md --port 30303",
@@ -0,0 +1,759 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * generate-component-docs.mjs — Sync component API docs from `.vue` source
4
+ *
5
+ * Reads `components/*.vue` entry stubs, extracts:
6
+ * 1. JSDoc header comment before `defineProps` (component description + prop @params)
7
+ * 2. `defineProps<{...}>()` / `withDefaults(defineProps<{...}>(), {...})` — props
8
+ * 3. `<slot name="X" />` usages in template + `useSlots()` refs — slot list
9
+ *
10
+ * Updates the matching `.md` file in the visualise-content skill (by default,
11
+ * `~/.claude/plugins/marketplaces/troshab-own-claude-code/plugins/troshab-own/skills/
12
+ * slidev-visualise-content/components/{Name}.md`) between auto-markers:
13
+ *
14
+ * <!-- BEGIN AUTO PROPS --> …generated Props table… <!-- END AUTO PROPS -->
15
+ * <!-- BEGIN AUTO SLOTS --> …generated Slots table… <!-- END AUTO SLOTS -->
16
+ * <!-- SCHEMA …JSON… -->
17
+ *
18
+ * Human-authored sections (## Usage, ## Notes, ## Examples in theme) are never
19
+ * touched. Missing .md → skeleton is created. Orphan .md (no matching .vue) →
20
+ * warning printed.
21
+ *
22
+ * Flags:
23
+ * --theme-dir DIR (default ~/slidev-theme-troshab)
24
+ * --skill-dir DIR (default visualise-content components folder)
25
+ * --check-only Exit 1 if any .md would change; print diff summary.
26
+ * --only NAMES Comma-separated component names (e.g. Stepper,Callout).
27
+ * Handy when debugging a single component.
28
+ * --no-write Print intended changes to stdout; don't touch files.
29
+ *
30
+ * Exit codes:
31
+ * 0 — all docs in sync (or write succeeded)
32
+ * 1 — drift detected with --check-only, or orphan/missing component
33
+ * 2 — fatal parsing error
34
+ */
35
+
36
+ import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from 'node:fs'
37
+ import { join, resolve, dirname, basename } from 'node:path'
38
+ import { homedir } from 'node:os'
39
+ import { fileURLToPath } from 'node:url'
40
+
41
+ const __dirname = dirname(fileURLToPath(import.meta.url))
42
+
43
+ // ─── CLI ──────────────────────────────────────────────────────────────────────
44
+
45
+ const argv = process.argv.slice(2)
46
+ function flag(name) { return argv.includes(name) }
47
+ function opt(name, fallback) {
48
+ const i = argv.indexOf(name)
49
+ return i >= 0 && i + 1 < argv.length ? argv[i + 1] : fallback
50
+ }
51
+ function expandTilde(p) { return p.startsWith('~') ? join(homedir(), p.slice(1)) : p }
52
+
53
+ const THEME_DIR = resolve(expandTilde(opt('--theme-dir', join(__dirname, '..'))))
54
+ const DEFAULT_SKILL_DIR = join(
55
+ homedir(),
56
+ '.claude', 'plugins', 'marketplaces', 'troshab-own-claude-code',
57
+ 'plugins', 'troshab-own', 'skills', 'slidev-visualise-content', 'components',
58
+ )
59
+ const SKILL_DIR = resolve(expandTilde(opt('--skill-dir', DEFAULT_SKILL_DIR)))
60
+ const CHECK_ONLY = flag('--check-only')
61
+ const NO_WRITE = flag('--no-write')
62
+ const ONLY = opt('--only', null)
63
+ const ONLY_SET = ONLY ? new Set(ONLY.split(',').map(s => s.trim())) : null
64
+
65
+ // ─── .vue parser ──────────────────────────────────────────────────────────────
66
+
67
+ /**
68
+ * Extract the `<script setup lang="ts">` or plain `<script>` block, the template,
69
+ * and any top-of-file HTML comment (acts like JSDoc on entry-stub files).
70
+ * Returns { scriptSrc, templateSrc, headerComment } or null.
71
+ */
72
+ function splitSfc(src) {
73
+ const scriptSetupMatch = src.match(/<script\s+setup(?:\s+[^>]*)?>([\s\S]*?)<\/script>/)
74
+ const plainScriptMatch = src.match(/<script(?:\s+[^>]*)?>([\s\S]*?)<\/script>/)
75
+ const scriptSrc = scriptSetupMatch ? scriptSetupMatch[1] : (plainScriptMatch ? plainScriptMatch[1] : '')
76
+ // Greedy + last `</template>` — avoids truncation at inner `<template v-if>`.
77
+ const templateMatch = src.match(/<template(?:\s[^>]*)?>([\s\S]*)<\/template>/)
78
+ const headerMatch = src.match(/^\s*<!--([\s\S]*?)-->/)
79
+ if (!scriptSrc && !headerMatch) return null
80
+ return {
81
+ scriptSrc,
82
+ templateSrc: templateMatch ? templateMatch[1] : '',
83
+ headerComment: headerMatch ? headerMatch[1] : '',
84
+ isSetup: !!scriptSetupMatch,
85
+ }
86
+ }
87
+
88
+ /**
89
+ * If the entry-stub does `import X from '../components_base/X.vue'; export default X`,
90
+ * return the resolved path to the real implementation .vue. Otherwise null.
91
+ */
92
+ function resolveReExport(scriptSrc, currentFile) {
93
+ const importMatch = scriptSrc.match(/import\s+\w+\s+from\s+['"]([^'"]+\.vue)['"]/)
94
+ if (!importMatch) return null
95
+ const exportMatch = scriptSrc.match(/export\s+default\s+\w+/)
96
+ if (!exportMatch) return null
97
+ const rel = importMatch[1]
98
+ return resolve(dirname(currentFile), rel)
99
+ }
100
+
101
+ /**
102
+ * Extract the JSDoc header block right before the first `defineProps` or
103
+ * `withDefaults(defineProps`. Returns { description, paramDocs } where paramDocs
104
+ * maps propName → description string.
105
+ *
106
+ * If `fallbackHtmlComment` is provided (from an entry-stub <!-- … -->), it's
107
+ * parsed with the same Props:/Slots:/Usage: rules.
108
+ */
109
+ function extractHeaderDoc(scriptSrc, fallbackHtmlComment) {
110
+ const defineIdx = scriptSrc.search(/(?:withDefaults\s*\(\s*)?defineProps\s*</)
111
+
112
+ let raw = ''
113
+ if (defineIdx >= 0) {
114
+ // Look backward for /** … */ ending just before defineProps.
115
+ const before = scriptSrc.slice(0, defineIdx)
116
+ const jsdocMatch = before.match(/\/\*\*[\s\S]*?\*\/(?=\s*(?:const\s+\w+\s*=\s*)?(?:withDefaults\s*\(\s*)?defineProps\s*<|\s*$)/)
117
+ const allMatches = [...before.matchAll(/\/\*\*[\s\S]*?\*\//g)]
118
+ raw = jsdocMatch ? jsdocMatch[0] : (allMatches.length ? allMatches[allMatches.length - 1][0] : '')
119
+ }
120
+ if (!raw && fallbackHtmlComment) {
121
+ // Treat the HTML comment as a JSDoc body (lines already look like " * foo"
122
+ // but may also just be plain lines — normalise accordingly).
123
+ raw = `/**\n${fallbackHtmlComment}\n*/`
124
+ }
125
+ if (!raw) return { description: '', paramDocs: {} }
126
+
127
+ // Clean leading " * " and trailing "*/".
128
+ const lines = raw
129
+ .replace(/^\s*\/\*\*\s*/, '')
130
+ .replace(/\*\/\s*$/, '')
131
+ .split('\n')
132
+ .map(l => l.replace(/^\s*\*\s?/, '').trimEnd())
133
+
134
+ const paramDocs = {}
135
+ const descLines = []
136
+ let inDescription = true
137
+
138
+ for (const line of lines) {
139
+ const paramMatch = line.match(/^@param\s+\{?[^}]*\}?\s*(\w+)\s*[-—]?\s*(.*)$/)
140
+ if (paramMatch) {
141
+ inDescription = false
142
+ paramDocs[paramMatch[1]] = paramMatch[2].trim()
143
+ continue
144
+ }
145
+ // Some JSDocs list props inline like "value: number — Target number".
146
+ const inlineParamMatch = line.match(/^\s*(\w+):\s*[^—\-]+[—\-]\s+(.+)$/)
147
+ if (inlineParamMatch && !inDescription) {
148
+ paramDocs[inlineParamMatch[1]] = inlineParamMatch[2].trim()
149
+ continue
150
+ }
151
+ // "Props:" section header switches into prop-list mode.
152
+ if (/^\s*Props:\s*$/i.test(line)) { inDescription = false; continue }
153
+ if (/^\s*Slots?:\s*$/i.test(line)) { inDescription = false; continue }
154
+ if (/^\s*Usage:?\s*$/i.test(line)) { inDescription = false; continue }
155
+ // Stop accumulating once we see a usage example, a code block, or a blank
156
+ // followed by an HTML-tag line — otherwise multi-line descriptions get
157
+ // concatenated with embedded `<Component …>` examples.
158
+ if (inDescription) {
159
+ const trimmed = line.trim()
160
+ if (!trimmed) { if (descLines.length) inDescription = false; continue }
161
+ if (/^</.test(trimmed)) { inDescription = false; continue }
162
+ descLines.push(trimmed)
163
+ }
164
+ }
165
+
166
+ // Pass 2 for "Props:" block style ("value: number — description").
167
+ const propsSectionMatch = raw.match(/Props:\s*\n([\s\S]*?)(?:\n\s*(?:Slots?|Usage|Notes?|Example)s?:|\*\/)/i)
168
+ if (propsSectionMatch) {
169
+ for (const line of propsSectionMatch[1].split('\n')) {
170
+ const m = line.replace(/^\s*\*\s?/, '').match(/^\s*(\w+)[?!:]?\s*(?:[:\-]\s*)?[^—\-]*[—\-]\s+(.+)$/)
171
+ if (m && !paramDocs[m[1]]) paramDocs[m[1]] = m[2].trim()
172
+ }
173
+ }
174
+
175
+ return {
176
+ description: descLines.join(' ').replace(/\s+/g, ' ').trim(),
177
+ paramDocs,
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Find defineProps<{...}>() or withDefaults(defineProps<{...}>(), {...}).
183
+ * Returns { propsBlock: string, defaultsBlock: string | null }.
184
+ */
185
+ function extractPropsBlocks(scriptSrc) {
186
+ // Locate `defineProps<`.
187
+ const propsStart = scriptSrc.indexOf('defineProps<')
188
+ if (propsStart < 0) return null
189
+
190
+ // Balanced `<...>` extraction starting at the `<` after defineProps.
191
+ const openAngle = scriptSrc.indexOf('<', propsStart)
192
+ if (openAngle < 0) return null
193
+ let depth = 0
194
+ let typeStart = openAngle + 1
195
+ let typeEnd = -1
196
+ for (let i = openAngle; i < scriptSrc.length; i++) {
197
+ const c = scriptSrc[i]
198
+ if (c === '<') depth++
199
+ else if (c === '>') {
200
+ depth--
201
+ if (depth === 0) { typeEnd = i; break }
202
+ }
203
+ }
204
+ if (typeEnd < 0) return null
205
+ const typeLiteral = scriptSrc.slice(typeStart, typeEnd).trim()
206
+
207
+ // Check if wrapped in withDefaults(..., {...}).
208
+ const withDefaultsIdx = scriptSrc.lastIndexOf('withDefaults', propsStart)
209
+ let defaultsBlock = null
210
+ if (withDefaultsIdx >= 0 && withDefaultsIdx < propsStart) {
211
+ // Locate the comma after defineProps<...>() and the { that starts defaults.
212
+ const afterClose = scriptSrc.indexOf(')', typeEnd)
213
+ if (afterClose > 0) {
214
+ const commaIdx = scriptSrc.indexOf(',', afterClose)
215
+ if (commaIdx > 0) {
216
+ const defBraceStart = scriptSrc.indexOf('{', commaIdx)
217
+ if (defBraceStart > 0) {
218
+ let bDepth = 0
219
+ let defEnd = -1
220
+ for (let i = defBraceStart; i < scriptSrc.length; i++) {
221
+ const c = scriptSrc[i]
222
+ if (c === '{') bDepth++
223
+ else if (c === '}') {
224
+ bDepth--
225
+ if (bDepth === 0) { defEnd = i; break }
226
+ }
227
+ }
228
+ if (defEnd > 0) defaultsBlock = scriptSrc.slice(defBraceStart + 1, defEnd).trim()
229
+ }
230
+ }
231
+ }
232
+ }
233
+
234
+ return { propsBlock: typeLiteral, defaultsBlock }
235
+ }
236
+
237
+ /**
238
+ * Parse `propName?: type` entries from the props-type literal. Handles union
239
+ * types (a|b|c), generics, nested arrays.
240
+ * Returns [{ name, type, required }].
241
+ */
242
+ function parsePropsBlock(block) {
243
+ // Strip single-line "// …" and multi-line "/* … */" comments.
244
+ let stripped = block
245
+ .replace(/\/\*[\s\S]*?\*\//g, '')
246
+ .replace(/(^|\s)\/\/[^\n]*/g, (_, p) => p)
247
+ .trim()
248
+
249
+ // Remove outer `{...}` if present — defineProps<{ ... }>() passes the literal
250
+ // as `{ type?: '...'; title?: string }`; we need to walk inside the object.
251
+ if (stripped.startsWith('{') && stripped.endsWith('}')) {
252
+ stripped = stripped.slice(1, -1)
253
+ }
254
+
255
+ const entries = []
256
+ let depth = 0
257
+ let current = ''
258
+ for (let i = 0; i < stripped.length; i++) {
259
+ const c = stripped[i]
260
+ if (c === '[' || c === '{' || c === '(') depth++
261
+ else if (c === ']' || c === '}' || c === ')') depth--
262
+ // `<` / `>` are tricky: `<T>` increases/decreases depth, but `|`/`>` in
263
+ // union/comparison contexts would too. For our case the prop-type literal
264
+ // usually stays balanced with the existing depth tracking.
265
+ if (c === '<' && /\w/.test(stripped[i - 1] || '')) depth++
266
+ else if (c === '>' && depth > 0 && stripped[i + 1] !== '=' && stripped[i - 1] !== '=') depth--
267
+ if ((c === ',' || c === ';' || c === '\n') && depth === 0) {
268
+ const piece = current.trim()
269
+ if (piece) entries.push(piece)
270
+ current = ''
271
+ continue
272
+ }
273
+ current += c
274
+ }
275
+ if (current.trim()) entries.push(current.trim())
276
+
277
+ const props = []
278
+ for (const entry of entries) {
279
+ // propName?: type
280
+ const m = entry.match(/^(\w+)(\??)\s*:\s*([\s\S]+)$/)
281
+ if (!m) continue
282
+ const [, name, optional, rawType] = m
283
+ props.push({ name, type: rawType.trim(), required: optional !== '?' })
284
+ }
285
+ return props
286
+ }
287
+
288
+ /**
289
+ * Parse defaults block. Returns propName → default-value (as raw string).
290
+ */
291
+ function parseDefaultsBlock(block) {
292
+ if (!block) return {}
293
+ const out = {}
294
+ let depth = 0
295
+ let current = ''
296
+ for (let i = 0; i < block.length; i++) {
297
+ const c = block[i]
298
+ if (c === '<' || c === '[' || c === '{' || c === '(') depth++
299
+ else if (c === '>' || c === ']' || c === '}' || c === ')') depth--
300
+ if ((c === ',' || c === '\n') && depth === 0) {
301
+ addDefault(out, current)
302
+ current = ''
303
+ continue
304
+ }
305
+ current += c
306
+ }
307
+ addDefault(out, current)
308
+ return out
309
+ }
310
+ function addDefault(out, piece) {
311
+ const trimmed = piece.trim().replace(/^\/\/.*$/gm, '').trim()
312
+ if (!trimmed) return
313
+ const m = trimmed.match(/^(\w+)\s*:\s*([\s\S]+?)$/)
314
+ if (!m) return
315
+ out[m[1]] = m[2].trim()
316
+ }
317
+
318
+ /**
319
+ * Slots: grep static `<slot name="X" />` + dynamic `<slot :name="`step${i}`" />` +
320
+ * `slots.XXX` / `slots[`step${i}`]` refs. Dynamic names are emitted in their
321
+ * raw template form (e.g. "step${i}") so collapseSlots() can render them.
322
+ * Returns sorted array of unique slot names.
323
+ */
324
+ function extractSlots(scriptSrc, templateSrc) {
325
+ const slots = new Set()
326
+ const staticSlotRe = /<slot(?:\s+name=["']([\w-]+)["'])?[\s\S]*?(?:\/>|>)/g
327
+ let m
328
+ while ((m = staticSlotRe.exec(templateSrc))) {
329
+ slots.add(m[1] || 'default')
330
+ }
331
+
332
+ // Dynamic: <slot :name="`step${...}`" /> or <slot :name="'step' + idx" />.
333
+ const dynSlotRe = /<slot\s+:name=["']\s*`?([^`"'$]+)(?:\$\{[^}]+\}|[+\s]+[\w.]+)/g
334
+ while ((m = dynSlotRe.exec(templateSrc))) {
335
+ slots.add(`${m[1].trim()}N`)
336
+ }
337
+
338
+ // slots[`stepN`] / slots.stepN / $slots.stepN access patterns in script.
339
+ const accessRe = /(?:\$?slots)[\s]*\[[\s]*`([^`${]+)(?:\$\{[^}]+\})?`[\s]*\]/g
340
+ while ((m = accessRe.exec(scriptSrc))) {
341
+ slots.add(`${m[1].trim()}N`)
342
+ }
343
+ const dotRe = /(?:\$?slots)\.(\w+)/g
344
+ while ((m = dotRe.exec(scriptSrc))) slots.add(m[1])
345
+
346
+ const noise = new Set(['name', 'value', 'length', 'forEach', 'map'])
347
+ return [...slots].filter(s => !noise.has(s)).sort()
348
+ }
349
+
350
+ // ─── Collapse long slot lists (e.g. step1..stepN) ────────────────────────────
351
+
352
+ function collapseSlots(slots) {
353
+ // Group patterns like step1, step2, step3 → "step1..step3".
354
+ const groups = new Map()
355
+ const singles = []
356
+ for (const s of slots) {
357
+ const m = s.match(/^([a-zA-Z]+)(\d+)$/)
358
+ if (!m) { singles.push(s); continue }
359
+ const [, prefix, num] = m
360
+ const arr = groups.get(prefix) || []
361
+ arr.push(parseInt(num, 10))
362
+ groups.set(prefix, arr)
363
+ }
364
+ const out = [...singles]
365
+ for (const [prefix, nums] of groups) {
366
+ nums.sort((a, b) => a - b)
367
+ if (nums.length === 1) out.push(`${prefix}${nums[0]}`)
368
+ else out.push(`${prefix}${nums[0]}..${prefix}${nums[nums.length - 1]}`)
369
+ }
370
+ return out.sort()
371
+ }
372
+
373
+ // ─── Markdown table renderers ────────────────────────────────────────────────
374
+
375
+ function renderPropsTable(props, paramDocs, defaults) {
376
+ if (!props.length) return '_No props._'
377
+ const rows = props.map(p => {
378
+ const def = defaults[p.name] ?? (p.required ? '—' : '—')
379
+ const desc = (paramDocs[p.name] || '').replace(/\|/g, '\\|')
380
+ return `| \`${p.name}\` | \`${p.type.replace(/\|/g, '\\|')}\` | \`${def.replace(/\|/g, '\\|')}\` | ${p.required ? 'yes' : 'no'} | ${desc} |`
381
+ })
382
+ return [
383
+ '| Prop | Type | Default | Required | Description |',
384
+ '|------|------|---------|----------|-------------|',
385
+ ...rows,
386
+ ].join('\n')
387
+ }
388
+
389
+ /**
390
+ * Scan example_slides/*.md for usages of `<ComponentName`. Returns sorted
391
+ * array of { file, heading } tuples. `file` is relative to example_slides/.
392
+ */
393
+ function findExamplesInTheme(themeDir, componentName) {
394
+ const dir = join(themeDir, 'example_slides')
395
+ if (!existsSync(dir)) return []
396
+ const out = []
397
+ for (const f of readdirSync(dir).sort()) {
398
+ if (!f.endsWith('.md')) continue
399
+ let src
400
+ try { src = readFileSync(join(dir, f), 'utf8') } catch { continue }
401
+ // Component tag: `<ComponentName` followed by whitespace, `/`, `>`, or end.
402
+ // Must not be inside a code fence — example_slides occasionally ship
403
+ // snippets showing wrong usage, but in practice the files are ~10-20 lines
404
+ // and any occurrence is a demo. So simple match is good enough.
405
+ const re = new RegExp('<' + componentName + '[\\s/>]', 'm')
406
+ if (!re.test(src)) continue
407
+ const headingMatch = src.match(/^#\s+(.+?)\s*$/m)
408
+ out.push({ file: f, heading: headingMatch ? headingMatch[1] : '(no heading)' })
409
+ }
410
+ return out
411
+ }
412
+
413
+ function renderExamplesTable(examples) {
414
+ if (!examples.length) return '_No theme example slides reference this component yet._'
415
+ const rows = examples.map(e => `| [\`${e.file}\`](@../../../../slidev-theme-troshab/example_slides/${e.file}) | ${e.heading.replace(/\|/g, '\\|')} |`)
416
+ return [
417
+ '| File | Heading |',
418
+ '|------|---------|',
419
+ ...rows,
420
+ ].join('\n')
421
+ }
422
+
423
+ function renderSlotsTable(slots, slotDescs = {}) {
424
+ if (!slots.length) return '_No slots._'
425
+ const rows = slots.map(s => {
426
+ const desc = (slotDescs[s] || '').replace(/\|/g, '\\|')
427
+ return `| \`${s}\` | ${desc} |`
428
+ })
429
+ return [
430
+ '| Slot | Description |',
431
+ '|------|-------------|',
432
+ ...rows,
433
+ ].join('\n')
434
+ }
435
+
436
+ function renderSchema(name, props, defaults, slots) {
437
+ const obj = {
438
+ name,
439
+ props: Object.fromEntries(props.map(p => {
440
+ const item = { type: p.type, required: p.required }
441
+ // Detect pure literal-union enum: `'a' | 'b' | 'c'` (no mixed `string` etc.).
442
+ const literalMatches = [...p.type.matchAll(/['"]([^'"]+)['"]/g)].map(x => x[1])
443
+ const residue = p.type.replace(/['"][^'"]+['"]/g, '').replace(/\s*\|\s*/g, '').trim()
444
+ if (literalMatches.length > 0 && residue === '') {
445
+ item.enum = literalMatches
446
+ }
447
+ if (defaults[p.name] !== undefined) item.default = defaults[p.name]
448
+ return [p.name, item]
449
+ })),
450
+ slots,
451
+ }
452
+ return JSON.stringify(obj, null, 2)
453
+ }
454
+
455
+ // ─── .md updating ────────────────────────────────────────────────────────────
456
+
457
+ const MARKERS = {
458
+ propsBegin: '<!-- BEGIN AUTO PROPS -->',
459
+ propsEnd: '<!-- END AUTO PROPS -->',
460
+ slotsBegin: '<!-- BEGIN AUTO SLOTS -->',
461
+ slotsEnd: '<!-- END AUTO SLOTS -->',
462
+ examplesBegin: '<!-- BEGIN AUTO EXAMPLES -->',
463
+ examplesEnd: '<!-- END AUTO EXAMPLES -->',
464
+ schemaBegin: '<!-- SCHEMA',
465
+ schemaEnd: '-->',
466
+ }
467
+
468
+ function replaceBetween(src, begin, end, replacement) {
469
+ const s = src.indexOf(begin)
470
+ const e = src.indexOf(end, s + begin.length)
471
+ if (s < 0 || e < 0) return null
472
+ return src.slice(0, s + begin.length) + '\n' + replacement + '\n' + src.slice(e)
473
+ }
474
+
475
+ function upsertSchema(src, jsonBody) {
476
+ const block = `${MARKERS.schemaBegin}\n${jsonBody}\n${MARKERS.schemaEnd}`
477
+ const idx = src.indexOf(MARKERS.schemaBegin)
478
+ if (idx < 0) return src.trimEnd() + '\n\n' + block + '\n'
479
+ // Replace from schemaBegin up to the next `-->` line.
480
+ const afterBegin = src.indexOf('\n', idx)
481
+ const endIdx = src.indexOf(MARKERS.schemaEnd, afterBegin)
482
+ if (endIdx < 0) return src.trimEnd() + '\n\n' + block + '\n'
483
+ const trail = src.slice(endIdx + MARKERS.schemaEnd.length)
484
+ return src.slice(0, idx) + block + trail
485
+ }
486
+
487
+ function generateSkeleton(name, description, propsTable, slotsTable, examplesTable, schema) {
488
+ return `# ${name}
489
+ ${description ? `\n${description}\n` : ''}
490
+ ## Props
491
+
492
+ ${MARKERS.propsBegin}
493
+ ${propsTable}
494
+ ${MARKERS.propsEnd}
495
+
496
+ ## Slots
497
+
498
+ ${MARKERS.slotsBegin}
499
+ ${slotsTable}
500
+ ${MARKERS.slotsEnd}
501
+
502
+ ## Usage
503
+
504
+ _TODO: add representative usage example(s)._
505
+
506
+ ## Notes
507
+
508
+ _TODO: add constraints, common mistakes, layout tips._
509
+
510
+ ## Examples in theme
511
+
512
+ ${MARKERS.examplesBegin}
513
+ ${examplesTable}
514
+ ${MARKERS.examplesEnd}
515
+
516
+ ${MARKERS.schemaBegin}
517
+ ${schema}
518
+ ${MARKERS.schemaEnd}
519
+ `
520
+ }
521
+
522
+ /**
523
+ * Pull `| propName | type | default | ... | Description |` tables out of an
524
+ * existing `.md` so we can preserve the hand-written Description column even
525
+ * when the schema otherwise gets regenerated.
526
+ * Returns { propName → description }. Empty if no table found.
527
+ */
528
+ function readExistingPropDescriptions(md, heading) {
529
+ const headingIdx = md.search(new RegExp(`^##\\s+${heading}\\s*$`, 'm'))
530
+ if (headingIdx < 0) return {}
531
+ const lines = md.slice(headingIdx).split('\n')
532
+ const out = {}
533
+ // Find the first header row: `| Prop | ... | Description |`
534
+ for (let i = 0; i < lines.length; i++) {
535
+ const row = lines[i]
536
+ if (!/^\|.*Description\s*\|?$/i.test(row) && !/^\|.*Description\s*\|/i.test(row)) continue
537
+ // Header row found at i; data rows start at i+2 (i+1 is `|---|---|` separator).
538
+ for (let j = i + 2; j < lines.length; j++) {
539
+ const r = lines[j].trim()
540
+ if (!r.startsWith('|')) break
541
+ const cells = r.split('|').map(c => c.trim()).filter(c => c.length > 0 || true)
542
+ // Leading and trailing | create empty cells; strip them.
543
+ const inner = r.replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim())
544
+ if (inner.length < 2) continue
545
+ const propName = inner[0].replace(/^`|`$/g, '').trim()
546
+ const desc = inner[inner.length - 1]
547
+ if (propName && desc) out[propName] = desc
548
+ }
549
+ break
550
+ }
551
+ return out
552
+ }
553
+
554
+ function readExistingSlotDescriptions(md) {
555
+ const headingIdx = md.search(/^##\s+Slots\s*$/m)
556
+ if (headingIdx < 0) return {}
557
+ const lines = md.slice(headingIdx).split('\n')
558
+ const out = {}
559
+ for (let i = 0; i < lines.length; i++) {
560
+ if (!/^\|\s*Slot\s*\|/i.test(lines[i])) continue
561
+ for (let j = i + 2; j < lines.length; j++) {
562
+ const r = lines[j].trim()
563
+ if (!r.startsWith('|')) break
564
+ const inner = r.replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim())
565
+ if (inner.length < 2) continue
566
+ const slotName = inner[0].replace(/^`|`$/g, '').trim()
567
+ const desc = inner[inner.length - 1]
568
+ if (slotName && desc) out[slotName] = desc
569
+ }
570
+ break
571
+ }
572
+ return out
573
+ }
574
+
575
+ function updateMd(currentMd, name, description, props, paramDocs, defaults, slots, slotDescs, examples, schema) {
576
+ const mergedParamDocs = { ...paramDocs }
577
+ const mergedSlotDescs = { ...slotDescs }
578
+ if (currentMd) {
579
+ const prev = readExistingPropDescriptions(currentMd, 'Props')
580
+ for (const [k, v] of Object.entries(prev)) {
581
+ if (!mergedParamDocs[k] || mergedParamDocs[k].length < v.length) mergedParamDocs[k] = v
582
+ }
583
+ const prevSlots = readExistingSlotDescriptions(currentMd)
584
+ for (const [k, v] of Object.entries(prevSlots)) {
585
+ if (!mergedSlotDescs[k] || mergedSlotDescs[k].length < v.length) mergedSlotDescs[k] = v
586
+ }
587
+ }
588
+
589
+ const propsTable = renderPropsTable(props, mergedParamDocs, defaults)
590
+ const slotsTable = renderSlotsTable(slots, mergedSlotDescs)
591
+ const examplesTable = renderExamplesTable(examples)
592
+
593
+ if (!currentMd) return generateSkeleton(name, description, propsTable, slotsTable, examplesTable, schema)
594
+
595
+ let out = currentMd
596
+ out = upsertTableBlock(out, 'Props', MARKERS.propsBegin, MARKERS.propsEnd, propsTable)
597
+ out = upsertTableBlock(out, 'Slots', MARKERS.slotsBegin, MARKERS.slotsEnd, slotsTable)
598
+ out = upsertTableBlock(out, 'Examples in theme', MARKERS.examplesBegin, MARKERS.examplesEnd, examplesTable)
599
+ out = upsertSchema(out, schema)
600
+ return out
601
+ }
602
+
603
+ /**
604
+ * Replace an existing `## Heading` table (|-rows) with a managed BEGIN/END block
605
+ * that contains the freshly-rendered table. Leaves non-table prose inside the
606
+ * section untouched (e.g. Stepper's `StepperItem: { … }` note lives on).
607
+ *
608
+ * If the markers already exist — simple replace-between.
609
+ * If the heading exists but no markers — find the first consecutive |-row run
610
+ * and wrap it with markers, replacing just those lines.
611
+ * If the heading is missing — append a new section after the previous managed
612
+ * block (or at EOF).
613
+ */
614
+ function upsertTableBlock(md, heading, beginMarker, endMarker, table) {
615
+ // Fast path: markers exist.
616
+ const replaced = replaceBetween(md, beginMarker, endMarker, table)
617
+ if (replaced !== null) return replaced
618
+
619
+ const headingRe = new RegExp(`^##\\s+${heading}\\s*$`, 'm')
620
+ const headingMatch = md.match(headingRe)
621
+ if (headingMatch) {
622
+ const headingIdx = md.indexOf(headingMatch[0])
623
+ const afterHeading = md.indexOf('\n', headingIdx) + 1
624
+ const rest = md.slice(afterHeading)
625
+ const lines = rest.split('\n')
626
+ // Find start of the table (first line starting with `|`).
627
+ let tableStart = -1
628
+ let tableEnd = -1
629
+ for (let i = 0; i < lines.length; i++) {
630
+ const trimmed = lines[i].trim()
631
+ if (trimmed.startsWith('##')) break
632
+ if (tableStart < 0 && trimmed.startsWith('|')) tableStart = i
633
+ if (tableStart >= 0 && !trimmed.startsWith('|') && tableEnd < 0) {
634
+ tableEnd = i
635
+ break
636
+ }
637
+ }
638
+ if (tableStart >= 0) {
639
+ if (tableEnd < 0) tableEnd = lines.length
640
+ const before = lines.slice(0, tableStart).join('\n')
641
+ const after = lines.slice(tableEnd).join('\n')
642
+ const block = `${beginMarker}\n${table}\n${endMarker}`
643
+ const rebuilt = [before.trimEnd(), block, after.trimStart()].filter(Boolean).join('\n\n')
644
+ return md.slice(0, afterHeading) + rebuilt
645
+ }
646
+ // Heading exists but no table — inject block right after heading line.
647
+ const block = `\n${beginMarker}\n${table}\n${endMarker}\n`
648
+ return md.slice(0, afterHeading) + block + rest
649
+ }
650
+
651
+ // No heading — append at EOF (before any schema comment) as a new section.
652
+ const schemaIdx = md.indexOf(MARKERS.schemaBegin)
653
+ const newSection = `\n## ${heading}\n\n${beginMarker}\n${table}\n${endMarker}\n`
654
+ if (schemaIdx < 0) return md.trimEnd() + '\n' + newSection
655
+ return md.slice(0, schemaIdx).trimEnd() + '\n' + newSection + '\n' + md.slice(schemaIdx)
656
+ }
657
+
658
+ // ─── Orchestrate ─────────────────────────────────────────────────────────────
659
+
660
+ function listVueComponents() {
661
+ const dir = join(THEME_DIR, 'components')
662
+ if (!existsSync(dir)) { console.error(`[gen-docs] missing ${dir}`); process.exit(2) }
663
+ return readdirSync(dir).filter(f => f.endsWith('.vue')).map(f => ({
664
+ name: basename(f, '.vue'),
665
+ path: join(dir, f),
666
+ }))
667
+ }
668
+
669
+ function listMdDocs() {
670
+ if (!existsSync(SKILL_DIR)) { mkdirSync(SKILL_DIR, { recursive: true }) }
671
+ return readdirSync(SKILL_DIR).filter(f => f.endsWith('.md'))
672
+ }
673
+
674
+ function diffSummary(oldSrc, newSrc) {
675
+ if (oldSrc === newSrc) return null
676
+ const oldLines = (oldSrc || '').split('\n').length
677
+ const newLines = newSrc.split('\n').length
678
+ return `${oldLines} → ${newLines} lines`
679
+ }
680
+
681
+ function main() {
682
+ const components = listVueComponents()
683
+ const existingMds = new Set(listMdDocs())
684
+ let changed = 0
685
+ let errors = 0
686
+ const orphans = []
687
+
688
+ for (const { name, path } of components) {
689
+ if (ONLY_SET && !ONLY_SET.has(name)) continue
690
+ const vueSrc = readFileSync(path, 'utf8')
691
+ const parts = splitSfc(vueSrc)
692
+ if (!parts) {
693
+ console.error(`[gen-docs] ${name}: could not parse SFC`)
694
+ errors++
695
+ continue
696
+ }
697
+
698
+ // Parse props from this file; if missing and it's a re-export, follow the
699
+ // import to the real implementation (components_base/X.vue).
700
+ let propsBlocks = extractPropsBlocks(parts.scriptSrc)
701
+ let slotsScriptSrc = parts.scriptSrc
702
+ let slotsTemplateSrc = parts.templateSrc
703
+ if (!propsBlocks) {
704
+ const reExport = resolveReExport(parts.scriptSrc, path)
705
+ if (reExport && existsSync(reExport)) {
706
+ const baseSrc = readFileSync(reExport, 'utf8')
707
+ const baseParts = splitSfc(baseSrc)
708
+ if (baseParts) {
709
+ propsBlocks = extractPropsBlocks(baseParts.scriptSrc)
710
+ slotsScriptSrc = baseParts.scriptSrc
711
+ slotsTemplateSrc = baseParts.templateSrc
712
+ }
713
+ }
714
+ }
715
+
716
+ const { description, paramDocs } = extractHeaderDoc(parts.scriptSrc, parts.headerComment)
717
+ const props = propsBlocks ? parsePropsBlock(propsBlocks.propsBlock) : []
718
+ const defaults = propsBlocks ? parseDefaultsBlock(propsBlocks.defaultsBlock) : {}
719
+ const slotsRaw = extractSlots(slotsScriptSrc, slotsTemplateSrc)
720
+ const slots = collapseSlots(slotsRaw)
721
+ const examples = findExamplesInTheme(THEME_DIR, name)
722
+
723
+ const schema = renderSchema(name, props, defaults, slots)
724
+
725
+ const mdPath = join(SKILL_DIR, `${name}.md`)
726
+ const existingMd = existsSync(mdPath) ? readFileSync(mdPath, 'utf8') : ''
727
+ existingMds.delete(`${name}.md`)
728
+
729
+ const newMd = updateMd(existingMd, name, description, props, paramDocs, defaults, slots, {}, examples, schema)
730
+ const diff = diffSummary(existingMd, newMd)
731
+ if (diff) {
732
+ changed++
733
+ if (CHECK_ONLY) console.log(`[drift] ${name}.md — ${diff}`)
734
+ else if (NO_WRITE) console.log(`[would-write] ${name}.md — ${diff}`)
735
+ else { writeFileSync(mdPath, newMd, 'utf8'); console.log(`[write] ${name}.md — ${diff}`) }
736
+ }
737
+ }
738
+
739
+ // Orphans — .md files in skill-dir with no matching .vue.
740
+ for (const md of existingMds) {
741
+ if (ONLY_SET) continue
742
+ orphans.push(md)
743
+ }
744
+ if (orphans.length) {
745
+ console.log(`[orphan] ${orphans.length} .md file(s) without matching .vue in theme:`)
746
+ for (const o of orphans) console.log(` ${o}`)
747
+ }
748
+
749
+ if (CHECK_ONLY) {
750
+ if (changed > 0 || errors > 0 || orphans.length > 0) process.exit(1)
751
+ console.log('[gen-docs] All docs in sync')
752
+ process.exit(0)
753
+ }
754
+
755
+ console.log(`[gen-docs] ${components.length} components, ${changed} updated, ${errors} error(s), ${orphans.length} orphan(s)`)
756
+ process.exit(errors > 0 ? 2 : 0)
757
+ }
758
+
759
+ main()
package/setup/shiki.ts CHANGED
@@ -15,9 +15,10 @@ const theme = createCssVariablesTheme({
15
15
  })
16
16
 
17
17
  /**
18
- * Transformer that adds `data-lang` attribute to <pre> elements.
19
- * CSS in base.css uses `::before { content: attr(data-lang) }` to render
20
- * a language label bar above every code block.
18
+ * Transformer that adds `data-lang` and `data-filename` attributes to <pre>.
19
+ * CSS in base.css uses:
20
+ * ::before { content: attr(data-lang) } — language label, left
21
+ * ::after { content: attr(data-filename) } — filename label, right
21
22
  */
22
23
  const langLabelTransformer: ShikiTransformer = {
23
24
  name: 'lang-label',
@@ -26,6 +27,14 @@ const langLabelTransformer: ShikiTransformer = {
26
27
  if (lang && lang !== 'text') {
27
28
  node.properties['data-lang'] = lang
28
29
  }
30
+ // Parse {filename="..."} from code block meta
31
+ const meta = this.options.meta?.__raw
32
+ if (meta) {
33
+ const match = meta.match(/filename=["']([^"']+)["']/)
34
+ if (match) {
35
+ node.properties['data-filename'] = match[1]
36
+ }
37
+ }
29
38
  },
30
39
  }
31
40
 
package/styles/base.css CHANGED
@@ -52,19 +52,24 @@
52
52
  --font-weight-semibold: 600;
53
53
  --font-weight-bold: 700;
54
54
 
55
- /* --- Line Heights (role-based) --- */
56
- --line-height-heading: 1.15; /* tight for 1-2 line headings */
57
- --line-height-body: 1.35; /* slides with short text blocks */
58
- --line-height-reading: 1.5; /* longer paragraphs, BDA recommended */
59
- --line-height-list: 1.4;
60
- --line-height-code: 1.4;
55
+ /* --- Line Heights (role-based) ---
56
+ Chung (2004, Vision Research): optimal 1.25-1.5 for body.
57
+ Bringhurst "Elements of Typographic Style": headings 1.1-1.25, body 1.3-1.5.
58
+ Kolers et al. (1981): reading speed U-curve — extremes reduce speed. */
59
+ --line-height-heading: 1.3; /* IBM Plex Sans glyph box (1.275) + safety margin */
60
+ --line-height-body: 1.4; /* Chung mid — comfortable for slides */
61
+ --line-height-reading: 1.5; /* Chung upper — BDA recommended for paragraphs */
62
+ --line-height-list: 1.4; /* matches body for consistency */
63
+ --line-height-code: 1.5; /* mono needs more room due to fixed glyph width */
61
64
 
62
65
  /* --- Letter & Word Spacing (dyslexia-friendly) ---
63
- Zorzi et al. (2012, PNAS): letter-spacing reduces crowding.
64
- BDA 2018: word/letter ratio >= 3.5x. Ours: 4.0x (above minimum). */
65
- --letter-spacing-body: 0.02em;
66
- --word-spacing-body: 0.08em;
67
- --letter-spacing-heading: 0.01em;
66
+ Zorzi et al. (2012, PNAS 109:11455): letter-spacing +20% reading speed.
67
+ Pelli et al. (2007): crowding as dyslexic bottleneck space symbols.
68
+ BDA 2018: word/letter ratio >= 3.5x. Ours: 4.0x (above minimum).
69
+ Presentation tradeoff: full BDA (0.12em/0.16em) too wide for large display type. */
70
+ --letter-spacing-body: 0.025em; /* slight spacing — Zorzi crowding benefit */
71
+ --word-spacing-body: 0.1em; /* word/letter ratio = 4.0x (BDA >= 3.5x) */
72
+ --letter-spacing-heading: 0.005em; /* near-neutral — large headlines don't need extra spacing */
68
73
 
69
74
  /* --- Spacing System (8-point grid) ---
70
75
  Based on 8px base unit for visual rhythm.
@@ -94,15 +99,43 @@
94
99
  --slide-padding: var(--space-lg);
95
100
  --slide-gap: var(--space-sm);
96
101
 
97
- /* --- Safe-zone for projectors/TV overscan (5% default) --- */
98
- --safe-inset-x: 5%;
99
- --safe-inset-y: 5%;
102
+ /* --- Safe-zone for projectors/TV overscan ---
103
+ SMPTE RP 27.3: 5% action-safe zone. Equal inset on all sides — asymmetric % causes
104
+ aspect-ratio dependent visual shift. Using rem keeps proportional across canvas sizes. */
105
+ --safe-inset-x: 3rem; /* ~94px at 31.347px root */
106
+ --safe-inset-y: 3rem; /* equal to x for symmetric safe zone */
100
107
 
101
108
  /* --- Border Width Tokens (rem-based, scale with canvas) --- */
102
109
  --border-thin: 0.0625rem; /* ≈ 1px at 980 → ~2px at 1920 */
103
110
  --border-medium: 0.125rem; /* ≈ 2px → ~4px */
104
111
  --border-thick: 0.1875rem; /* ≈ 3px → ~6px */
105
112
  --border-accent: 0.25rem; /* ≈ 4px → ~8px */
113
+
114
+ /* --- Fine spacing (< 8px, for tight UI elements) --- */
115
+ --space-3xs: 0.125rem; /* 2px */
116
+ --space-2xs: 0.25rem; /* 4px */
117
+ --space-xs-fine: 0.375rem; /* 6px */
118
+ --space-tight: 0.75rem; /* 12px — between xs and sm */
119
+ --space-code-pt: 2.25rem; /* 36px — code block top padding (for lang label) */
120
+
121
+ /* --- Border Radius Tokens --- */
122
+ --radius-xs: 0.25rem; /* 4px — inline code */
123
+ --radius-sm: 0.375rem; /* 6px — buttons, small cards */
124
+ --radius-md: 0.5rem; /* 8px — cards, panels */
125
+ --radius-pill: 9999px; /* full pill shape */
126
+
127
+ /* --- Transform Compensation ---
128
+ Padding to compensate for transform: scale() visual overflow beyond layout box.
129
+ Calculated for scale(1.15) on medium elements (~63px markers).
130
+ Formula: (scale - 1) * element_size / 2 ≈ 4.7px per side. */
131
+ --transform-scale-comp: 0.1875rem; /* 6px — for scale(1.15) on 63px elements */
132
+
133
+ /* --- Typography ratios (em-based, font-size relative) --- */
134
+ --font-size-smaller: 0.8em; /* for <sup>, <sub>, <small> */
135
+ --letter-spacing-emphasis: 0.05em; /* for small uppercase labels */
136
+ --underline-offset: 0.15em; /* text-underline-offset */
137
+ --em-quarter: 0.25em; /* list item spacing */
138
+ --em-half: 1.5em; /* indent / nested */
106
139
  }
107
140
 
108
141
  /* ============================================
@@ -138,6 +171,17 @@
138
171
  text-align: left;
139
172
  }
140
173
 
174
+ /* Mixed-font line-box fix: <p>/<li> containing inline <code> mix IBM Plex Sans
175
+ * and Plex Mono glyph metrics. Mono's effective descent is slightly larger
176
+ * than Sans at the same font-size, so the line box expands by ~1px — which
177
+ * rounds up to scrollHeight > clientHeight. Bumping line-height from 1.4 to
178
+ * 1.45 on these specific paragraphs absorbs the mixed-font rounding without
179
+ * visible impact on prose-only paragraphs. */
180
+ .slidev-layout p:has(code),
181
+ .slidev-layout li:has(code) {
182
+ line-height: 1.45;
183
+ }
184
+
141
185
  /* --- Headings --- */
142
186
  .slidev-layout h1,
143
187
  .slidev-layout h2,
@@ -247,9 +291,9 @@
247
291
  }
248
292
 
249
293
  .slidev-layout pre {
250
- border-radius: 0.5rem;
251
- margin-top: 1rem;
252
- margin-bottom: 1rem;
294
+ border-radius: var(--radius-md);
295
+ margin-top: var(--space-sm);
296
+ margin-bottom: var(--space-sm);
253
297
  white-space: pre-wrap;
254
298
  overflow-wrap: break-word;
255
299
  }
@@ -272,11 +316,21 @@
272
316
  border: none;
273
317
  }
274
318
 
319
+ /* Slidev wraps code blocks in .slidev-code-wrapper (block, no BFC). Inner
320
+ * <pre> inherits 1rem top/bottom margin from the .slidev-layout pre rule
321
+ * (~31px at canvas font-size). When wrapper is a flex item (slide-col-body),
322
+ * those margins do not collapse with siblings or out of the flex container,
323
+ * inflating body height beyond column clientHeight. Zero margin inside the
324
+ * wrapper; outer spacing is handled by the wrapper itself. */
325
+ .slidev-code-wrapper pre {
326
+ margin: 0;
327
+ }
328
+
275
329
  /* Language label bar above code blocks (via Shiki transformer data-lang attribute) */
276
330
  .slidev-layout pre[data-lang] {
277
331
  position: relative;
278
332
  /* Override Slidev's --slidev-code-padding (applied with !important) to make room for the label */
279
- --slidev-code-padding: 2.25rem 0.5rem 0.5rem 0.5rem;
333
+ --slidev-code-padding: var(--space-code-pt) 0.5rem 0.5rem 0.5rem;
280
334
  }
281
335
 
282
336
  .slidev-layout pre[data-lang]::before {
@@ -285,7 +339,7 @@
285
339
  top: 0;
286
340
  left: 0;
287
341
  right: 0;
288
- padding: 0.25rem 0.75rem;
342
+ padding: var(--space-2xs) 0.75rem;
289
343
  font-family: var(--font-mono);
290
344
  font-size: 0.8em;
291
345
  font-weight: var(--font-weight-medium);
@@ -294,15 +348,29 @@
294
348
  color: var(--color-text-tertiary);
295
349
  border-bottom: var(--border-thin) solid var(--color-border);
296
350
  background: var(--shiki-color-background, var(--color-bg-muted));
297
- border-radius: 0.5rem 0.5rem 0 0;
351
+ border-radius: var(--radius-md) var(--radius-md) 0 0;
352
+ }
353
+
354
+ /* Filename label (right side of header bar) — via {filename="..."} in code fence meta */
355
+ .slidev-layout pre[data-filename]::after {
356
+ content: attr(data-filename);
357
+ position: absolute;
358
+ top: 0;
359
+ right: 0;
360
+ padding: var(--space-2xs) 0.75rem;
361
+ font-family: var(--font-mono);
362
+ font-size: 0.8em;
363
+ font-weight: var(--font-weight-normal);
364
+ color: var(--color-text-muted);
365
+ opacity: 0.7;
298
366
  }
299
367
 
300
368
  .slidev-layout code {
301
- padding-left: 0.375rem;
302
- padding-right: 0.375rem;
303
- padding-top: 0.125rem;
304
- padding-bottom: 0.125rem;
305
- border-radius: 0.25rem;
369
+ padding-left: var(--space-xs-fine);
370
+ padding-right: var(--space-xs-fine);
371
+ padding-top: var(--space-3xs);
372
+ padding-bottom: var(--space-3xs);
373
+ border-radius: var(--radius-xs);
306
374
  background-color: var(--color-bg-muted);
307
375
  }
308
376
 
@@ -317,18 +385,18 @@
317
385
 
318
386
  .slidev-layout table {
319
387
  width: 100%;
320
- margin-top: 1rem;
321
- margin-bottom: 1rem;
388
+ margin-top: var(--space-sm);
389
+ margin-bottom: var(--space-sm);
322
390
  border-collapse: collapse;
323
391
  font-variant-numeric: tabular-nums;
324
392
  }
325
393
 
326
394
  .slidev-layout th,
327
395
  .slidev-layout td {
328
- padding-left: 1rem;
329
- padding-right: 1rem;
330
- padding-top: 0.5rem;
331
- padding-bottom: 0.5rem;
396
+ padding-left: var(--space-sm);
397
+ padding-right: var(--space-sm);
398
+ padding-top: var(--space-xs);
399
+ padding-bottom: var(--space-xs);
332
400
  border: var(--border-thin) solid var(--color-border);
333
401
  text-align: left;
334
402
  }
@@ -343,9 +411,9 @@
343
411
  ============================================ */
344
412
 
345
413
  .slidev-layout blockquote {
346
- padding-left: 1rem;
347
- margin-top: 1rem;
348
- margin-bottom: 1rem;
414
+ padding-left: var(--space-sm);
415
+ margin-top: var(--space-sm);
416
+ margin-bottom: var(--space-sm);
349
417
  border-left: var(--border-accent) solid var(--color-border-strong);
350
418
  color: var(--color-text-secondary);
351
419
  font-style: normal; /* No italic for readability */
@@ -564,10 +632,17 @@
564
632
  display: flex;
565
633
  flex-direction: column;
566
634
  flex: 1;
567
- overflow: auto;
568
635
  min-height: 0;
569
636
  }
570
637
 
638
+ /* Prevent last-child margin from leaking into scrollHeight (margin-collapse artifact) */
639
+ .slide-single > *:last-child,
640
+ .slide-panel-content > *:last-child,
641
+ .slide-col > *:last-child,
642
+ .slide-col-body > *:last-child {
643
+ margin-bottom: 0;
644
+ }
645
+
571
646
  .slide-grid {
572
647
  display: flex;
573
648
  flex: 1;
@@ -575,7 +650,6 @@
575
650
  }
576
651
 
577
652
  .slide-col {
578
- overflow: hidden;
579
653
  display: flex;
580
654
  flex-direction: column;
581
655
  min-width: 0;
@@ -601,13 +675,13 @@
601
675
  }
602
676
 
603
677
  /* Per-column backgrounds */
604
- .slide-col-bg-soft { background: var(--color-bg-soft); border-radius: 0.5rem; padding: 1rem; }
605
- .slide-col-bg-muted { background: var(--color-bg-muted); border-radius: 0.5rem; padding: 1rem; }
606
- .slide-col-bg-primary { background: var(--color-primary); color: var(--color-primary-foreground); border-radius: 0.5rem; padding: 1rem; }
607
- .slide-col-bg-success { background: var(--color-success); color: var(--color-success-foreground); border-radius: 0.5rem; padding: 1rem; }
608
- .slide-col-bg-warning { background: var(--color-warning); color: var(--color-warning-foreground); border-radius: 0.5rem; padding: 1rem; }
609
- .slide-col-bg-danger { background: var(--color-danger); color: var(--color-danger-foreground); border-radius: 0.5rem; padding: 1rem; }
610
- .slide-col-bg-info { background: var(--color-info); color: var(--color-info-foreground); border-radius: 0.5rem; padding: 1rem; }
678
+ .slide-col-bg-soft { background: var(--color-bg-soft); border-radius: var(--radius-md); padding: var(--space-sm); }
679
+ .slide-col-bg-muted { background: var(--color-bg-muted); border-radius: var(--radius-md); padding: var(--space-sm); }
680
+ .slide-col-bg-primary { background: var(--color-primary); color: var(--color-primary-foreground); border-radius: var(--radius-md); padding: var(--space-sm); }
681
+ .slide-col-bg-success { background: var(--color-success); color: var(--color-success-foreground); border-radius: var(--radius-md); padding: var(--space-sm); }
682
+ .slide-col-bg-warning { background: var(--color-warning); color: var(--color-warning-foreground); border-radius: var(--radius-md); padding: var(--space-sm); }
683
+ .slide-col-bg-danger { background: var(--color-danger); color: var(--color-danger-foreground); border-radius: var(--radius-md); padding: var(--space-sm); }
684
+ .slide-col-bg-info { background: var(--color-info); color: var(--color-info-foreground); border-radius: var(--radius-md); padding: var(--space-sm); }
611
685
 
612
686
  .slide-col-bg-primary h1, .slide-col-bg-primary h2, .slide-col-bg-primary h3,
613
687
  .slide-col-bg-success h1, .slide-col-bg-success h2, .slide-col-bg-success h3,
@@ -616,7 +690,7 @@
616
690
  .slide-col-bg-info h1, .slide-col-bg-info h2, .slide-col-bg-info h3 { color: inherit; }
617
691
 
618
692
  /* Per-column border */
619
- .slide-col-border { border: var(--border-thin) solid var(--color-border); border-radius: 0.5rem; padding: 1rem; }
693
+ .slide-col-border { border: var(--border-thin) solid var(--color-border); border-radius: var(--radius-md); padding: var(--space-sm); }
620
694
 
621
695
  /* --- 7c. Split Mode --- */
622
696
  .slide-split {
@@ -633,7 +707,6 @@
633
707
 
634
708
  .slide-panel {
635
709
  position: relative;
636
- overflow: hidden;
637
710
  }
638
711
 
639
712
  .slide-panel-bg-only {
@@ -648,7 +721,6 @@
648
721
 
649
722
  .slide-panel-content {
650
723
  padding: var(--safe-inset-y) var(--safe-inset-x);
651
- overflow: auto;
652
724
  display: flex;
653
725
  flex-direction: column;
654
726
  justify-content: center;
@@ -682,6 +754,15 @@
682
754
  margin-right: calc(var(--safe-inset-x) * -1);
683
755
  }
684
756
 
757
+ /* When footer is present, reduce bottom padding to reclaim space */
758
+ .slidev-layout:has(.slide-footer) {
759
+ padding-bottom: 0;
760
+ }
761
+ /* Split panels: reduce bottom padding when footer takes over that space */
762
+ .slide-split:has(.slide-footer) .slide-panel-content {
763
+ padding-bottom: 1%;
764
+ }
765
+
685
766
  /* ============================================
686
767
  8. Component Styles
687
768
  ============================================ */
@@ -690,17 +771,17 @@
690
771
  .tags-container {
691
772
  display: flex;
692
773
  flex-wrap: wrap;
693
- gap: 0.5rem;
694
- margin-top: 1rem;
695
- margin-bottom: 1rem;
774
+ gap: var(--space-xs);
775
+ margin-top: var(--space-sm);
776
+ margin-bottom: var(--space-sm);
696
777
  }
697
778
 
698
779
  .tag {
699
- padding-left: 0.75rem;
700
- padding-right: 0.75rem;
701
- padding-top: 0.25rem;
702
- padding-bottom: 0.25rem;
703
- border-radius: 9999px;
780
+ padding-left: var(--space-tight);
781
+ padding-right: var(--space-tight);
782
+ padding-top: var(--space-2xs);
783
+ padding-bottom: var(--space-2xs);
784
+ border-radius: var(--radius-pill);
704
785
  font-size: var(--font-size-small);
705
786
  background-color: var(--color-primary-soft);
706
787
  color: var(--color-primary);
@@ -709,8 +790,8 @@
709
790
  /* Button components */
710
791
  .btn {
711
792
  display: inline-block;
712
- padding: 0.5rem 1rem;
713
- border-radius: 0.375rem;
793
+ padding: var(--space-xs) 1rem;
794
+ border-radius: var(--radius-sm);
714
795
  font-weight: var(--font-weight-medium);
715
796
  text-decoration: none;
716
797
  cursor: pointer;
@@ -799,7 +880,7 @@ a.btn-secondary:hover {
799
880
  position: absolute;
800
881
  inset: 5% 5%;
801
882
  border: var(--border-thick) dashed var(--color-danger);
802
- border-radius: 0.5rem;
883
+ border-radius: var(--radius-md);
803
884
  pointer-events: none;
804
885
  }
805
886
 
@@ -825,9 +906,9 @@ a.btn-secondary:hover {
825
906
  /* Enhanced blockquote callout base style */
826
907
  .slidev-layout blockquote:has(p:first-child strong:first-child) {
827
908
  border-left-width: var(--border-accent);
828
- padding: 1rem 1rem 1rem 1.25rem;
909
+ padding: var(--space-sm) 1rem 1rem 1.25rem;
829
910
  background-color: var(--color-bg-soft);
830
- border-radius: 0 0.375rem 0.375rem 0;
911
+ border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
831
912
  }
832
913
 
833
914
  /* Semantic callout classes — apply to blockquote */
@@ -862,7 +943,7 @@ a.btn-secondary:hover {
862
943
 
863
944
  /* Ensure focus is not obscured */
864
945
  .slidev-layout :focus-visible {
865
- scroll-padding: 1rem;
946
+ scroll-padding: var(--space-sm);
866
947
  }
867
948
 
868
949
  /* (Old layout sections 12-27 removed - replaced by section 7 above) */
@@ -915,19 +996,19 @@ a.btn-secondary:hover {
915
996
 
916
997
  /* Ensure thought bubble circles are not clipped in overflow: auto containers */
917
998
  .bubble-thought.bubble-bottom {
918
- margin-bottom: 1.5rem;
999
+ margin-bottom: var(--space-md);
919
1000
  }
920
1001
 
921
1002
  .bubble-thought.bubble-top {
922
- margin-top: 1.5rem;
1003
+ margin-top: var(--space-md);
923
1004
  }
924
1005
 
925
1006
  .bubble-thought.bubble-left {
926
- margin-left: 2rem;
1007
+ margin-left: var(--space-lg);
927
1008
  }
928
1009
 
929
1010
  .bubble-thought.bubble-right {
930
- margin-right: 2rem;
1011
+ margin-right: var(--space-lg);
931
1012
  }
932
1013
 
933
1014
  /* ============================================
@@ -935,7 +1016,7 @@ a.btn-secondary:hover {
935
1016
  ============================================ */
936
1017
 
937
1018
  .device-mockup-placeholder {
938
- padding: 2rem;
1019
+ padding: var(--space-lg);
939
1020
  text-align: center;
940
1021
  }
941
1022
 
package/styles/motion.css CHANGED
@@ -313,7 +313,7 @@
313
313
  animation: entrance-slide-right var(--dur-moderate-02) var(--ease-enter) calc(var(--stagger-base) * 4) both;
314
314
  }
315
315
 
316
- .slide-grid > .slide-col:last-child:nth-last-child(2) {
316
+ .slide-grid > .slide-col:nth-child(2):last-child {
317
317
  animation: entrance-slide-left var(--dur-moderate-02) var(--ease-enter) calc(var(--stagger-base) * 4) both;
318
318
  }
319
319