@troshab/slidev-theme-troshab 0.1.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 (168) hide show
  1. package/CLAUDE.md +537 -0
  2. package/LICENSE +134 -0
  3. package/README.md +168 -0
  4. package/SKILL.md +414 -0
  5. package/components/AnimatedCounter.vue +35 -0
  6. package/components/Background.vue +204 -0
  7. package/components/Callout.vue +135 -0
  8. package/components/Card.vue +75 -0
  9. package/components/CardGrid.vue +67 -0
  10. package/components/CaseStudy.vue +66 -0
  11. package/components/CodeDiff.vue +229 -0
  12. package/components/CodeHighlight.vue +337 -0
  13. package/components/ColorSwatch.vue +114 -0
  14. package/components/Confetti.vue +292 -0
  15. package/components/Conversation.vue +405 -0
  16. package/components/Countdown.vue +476 -0
  17. package/components/Definition.vue +59 -0
  18. package/components/DeviceMockup.vue +392 -0
  19. package/components/Funnel.vue +87 -0
  20. package/components/Icon.vue +73 -0
  21. package/components/Iframe.vue +38 -0
  22. package/components/Image.vue +69 -0
  23. package/components/ImageCompare.vue +436 -0
  24. package/components/MatrixGrid.vue +85 -0
  25. package/components/MermaidChart.vue +299 -0
  26. package/components/Metric.vue +161 -0
  27. package/components/PersonCard.vue +165 -0
  28. package/components/PricingTable.vue +144 -0
  29. package/components/Progress.vue +100 -0
  30. package/components/Pyramid.vue +81 -0
  31. package/components/QRCode.vue +137 -0
  32. package/components/QuoteBlock.vue +101 -0
  33. package/components/SpeechBubble.vue +169 -0
  34. package/components/Stepper.vue +542 -0
  35. package/components/StyledList.vue +156 -0
  36. package/components/StyledText.vue +275 -0
  37. package/components/SwotGrid.vue +99 -0
  38. package/components/Tags.vue +20 -0
  39. package/components/Testimonial.vue +243 -0
  40. package/components/Typewriter.vue +181 -0
  41. package/components_base/AnimatedCounter.vue +208 -0
  42. package/components_base/CodeHighlight.vue +364 -0
  43. package/composables/useColors.ts +101 -0
  44. package/composables/useShiki.ts +81 -0
  45. package/example_content.md +371 -0
  46. package/example_dark.md +10 -0
  47. package/example_slides/001-cover.md +15 -0
  48. package/example_slides/002-agenda.md +25 -0
  49. package/example_slides/003-section-layouts.md +14 -0
  50. package/example_slides/004-fullscreen-centered.md +7 -0
  51. package/example_slides/005-fullscreen-align-bottom.md +14 -0
  52. package/example_slides/006-fullscreen-no-padding.md +14 -0
  53. package/example_slides/007-fullscreen-bg-image-dark.md +13 -0
  54. package/example_slides/008-fullscreen-bg-image-light.md +13 -0
  55. package/example_slides/009-fullscreen-bg-gradient.md +15 -0
  56. package/example_slides/010-fullscreen-bg-color.md +13 -0
  57. package/example_slides/011-split-basic.md +17 -0
  58. package/example_slides/012-split-image-text.md +18 -0
  59. package/example_slides/013-split-contrast.md +22 -0
  60. package/example_slides/014-columns-basic.md +13 -0
  61. package/example_slides/015-columns-two.md +26 -0
  62. package/example_slides/016-columns-ratios.md +22 -0
  63. package/example_slides/017-columns-three.md +31 -0
  64. package/example_slides/018-columns-four.md +22 -0
  65. package/example_slides/019-columns-alignment.md +23 -0
  66. package/example_slides/020-columns-styled.md +21 -0
  67. package/example_slides/021-footnote-prop.md +16 -0
  68. package/example_slides/022-iframe-fullscreen.md +8 -0
  69. package/example_slides/023-iframe-split.md +18 -0
  70. package/example_slides/024-section-components.md +14 -0
  71. package/example_slides/025-styled-text.md +9 -0
  72. package/example_slides/026-styled-text.md +15 -0
  73. package/example_slides/027-text-formatting.md +28 -0
  74. package/example_slides/028-text-spoiler.md +15 -0
  75. package/example_slides/029-icon-component.md +47 -0
  76. package/example_slides/030-metric-component.md +29 -0
  77. package/example_slides/031-person-card.md +33 -0
  78. package/example_slides/032-styled-list.md +50 -0
  79. package/example_slides/033-color-swatch.md +35 -0
  80. package/example_slides/034-code-highlight.md +9 -0
  81. package/example_slides/035-iframe-component.md +9 -0
  82. package/example_slides/036-callout.md +15 -0
  83. package/example_slides/037-card-grid.md +27 -0
  84. package/example_slides/038-stepper-variants.md +18 -0
  85. package/example_slides/039-stepper-clicks.md +49 -0
  86. package/example_slides/040-stepper-interactive.md +28 -0
  87. package/example_slides/041-tags-progress.md +21 -0
  88. package/example_slides/042-speech-bubble.md +30 -0
  89. package/example_slides/043-conversation.md +13 -0
  90. package/example_slides/044-device-iphone.md +26 -0
  91. package/example_slides/045-device-browser.md +7 -0
  92. package/example_slides/046-qrcode.md +26 -0
  93. package/example_slides/047-countdown.md +14 -0
  94. package/example_slides/048-typewriter.md +8 -0
  95. package/example_slides/049-confetti.md +16 -0
  96. package/example_slides/050-image-compare.md +13 -0
  97. package/example_slides/051-code-diff.md +24 -0
  98. package/example_slides/052-quote-block.md +8 -0
  99. package/example_slides/053-testimonial.md +26 -0
  100. package/example_slides/054-testimonial-featured.md +16 -0
  101. package/example_slides/055-funnel.md +12 -0
  102. package/example_slides/056-pyramid.md +13 -0
  103. package/example_slides/057-pricing-table.md +9 -0
  104. package/example_slides/058-swot-grid.md +12 -0
  105. package/example_slides/059-matrix-grid.md +12 -0
  106. package/example_slides/060-case-study.md +11 -0
  107. package/example_slides/061-definition.md +15 -0
  108. package/example_slides/062-mermaid-intro.md +34 -0
  109. package/example_slides/063-mermaid-flowchart.md +19 -0
  110. package/example_slides/064-mermaid-sequence.md +17 -0
  111. package/example_slides/065-mermaid-xy-chart.md +16 -0
  112. package/example_slides/066-mermaid-pie.md +17 -0
  113. package/example_slides/067-mermaid-class.md +19 -0
  114. package/example_slides/068-mermaid-state.md +19 -0
  115. package/example_slides/069-mermaid-er.md +22 -0
  116. package/example_slides/070-mermaid-gantt.md +24 -0
  117. package/example_slides/071-mermaid-timeline.md +17 -0
  118. package/example_slides/072-mermaid-mindmap.md +21 -0
  119. package/example_slides/073-mermaid-gitgraph.md +20 -0
  120. package/example_slides/074-mermaid-split.md +30 -0
  121. package/example_slides/075-mermaid-columns.md +32 -0
  122. package/example_slides/076-section-addons.md +14 -0
  123. package/example_slides/077-asciinema.md +27 -0
  124. package/example_slides/078-fancyarrow.md +31 -0
  125. package/example_slides/079-fancyarrow-demo.md +23 -0
  126. package/example_slides/080-section-theme.md +14 -0
  127. package/example_slides/081-color-architecture.md +22 -0
  128. package/example_slides/082-semantic-text-colors.md +25 -0
  129. package/example_slides/083-typography.md +16 -0
  130. package/example_slides/084-typography-rationale.md +22 -0
  131. package/example_slides/085-icons.md +24 -0
  132. package/example_slides/086-tables.md +14 -0
  133. package/example_slides/087-code-blocks.md +18 -0
  134. package/example_slides/088-motion-modes.md +35 -0
  135. package/example_slides/089-slide-transitions.md +31 -0
  136. package/example_slides/090-v-click-reveals.md +40 -0
  137. package/example_slides/091-accessibility.md +27 -0
  138. package/example_slides/092-safe-zone.md +17 -0
  139. package/example_slides/093-questions.md +8 -0
  140. package/example_white.md +10 -0
  141. package/fonts/IBMPlexMono-Medium.woff2 +1449 -0
  142. package/fonts/IBMPlexMono-Regular.woff2 +1449 -0
  143. package/fonts/IBMPlexSans-Bold.woff2 +1449 -0
  144. package/fonts/IBMPlexSans-Medium.woff2 +1449 -0
  145. package/fonts/IBMPlexSans-Regular.woff2 +1449 -0
  146. package/fonts/IBMPlexSans-SemiBold.woff2 +1449 -0
  147. package/fonts/LICENSE.txt +93 -0
  148. package/layouts/slide.vue +251 -0
  149. package/package.json +62 -0
  150. package/public/avatars/alice.png +0 -0
  151. package/public/avatars/bob.png +0 -0
  152. package/public/avatars/carol.png +0 -0
  153. package/scripts/chart-audit.mjs +216 -0
  154. package/scripts/contrast-audit.mjs +299 -0
  155. package/scripts/generate-palette.mjs +395 -0
  156. package/scripts/integrity-audit.mjs +357 -0
  157. package/scripts/shared/css-utils.mjs +216 -0
  158. package/scripts/shiki-audit.mjs +300 -0
  159. package/scripts/typography-audit.mjs +300 -0
  160. package/setup/main.ts +107 -0
  161. package/setup/mermaid.ts +237 -0
  162. package/setup/shiki.ts +40 -0
  163. package/snippets/demo.ts +26 -0
  164. package/styles/base.css +1053 -0
  165. package/styles/colors.css +422 -0
  166. package/styles/index.css +12 -0
  167. package/styles/motion.css +486 -0
  168. package/uno.config.ts +67 -0
@@ -0,0 +1,300 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Shiki Syntax Highlighting Audit for slidev-theme-troshab
5
+ *
6
+ * 3 sections:
7
+ * 1. Dracula Spec Alignment — token families match official spec
8
+ * 2. Contrast Verification — WCAG 4.5:1 + APCA Lc 60+ (short-read content)
9
+ * 3. Token Distinguishability — OKLCH hue separation >= 30 deg
10
+ *
11
+ * Run: node scripts/shiki-audit.mjs
12
+ *
13
+ * Scientific basis:
14
+ * - Arditi & Cho (2005, IOVS) — Lc 60 threshold for content text (24px+)
15
+ * - Sharma & Bala (2002, Color Res. Appl.) — min 30 deg hue for color-deficient
16
+ * - Fairchild (2013, Color Appearance Models) — OKLCH perceptual uniformity
17
+ * - Dracula Theme Spec (draculatheme.com) — canonical token-to-color mapping
18
+ */
19
+
20
+ import { readFileSync } from 'fs'
21
+ import { resolve, dirname } from 'path'
22
+ import { fileURLToPath } from 'url'
23
+ import {
24
+ contrastRatio, apcaContrast, hexToLinear,
25
+ parsePalette, findMatchingBrace, fmt, fmtLc,
26
+ } from './shared/css-utils.mjs'
27
+
28
+ const __dirname = dirname(fileURLToPath(import.meta.url))
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Paths & Parse
32
+ // ---------------------------------------------------------------------------
33
+
34
+ const colorsPath = resolve(__dirname, '..', 'styles', 'colors.css')
35
+ const palette = parsePalette(colorsPath)
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Parse Shiki tokens from colors.css
39
+ // ---------------------------------------------------------------------------
40
+
41
+ function parseShikiTokens(cssPath) {
42
+ const css = readFileSync(cssPath, 'utf8')
43
+ const darkStart = css.indexOf('.dark {')
44
+ const darkEnd = darkStart >= 0 ? findMatchingBrace(css, darkStart) : -1
45
+
46
+ function extractShiki(text) {
47
+ const tokens = {}
48
+ const re = /--shiki-token-([\w-]+):\s*var\(--color-([\w-]+)\)\s*;/g
49
+ let m
50
+ while ((m = re.exec(text)) !== null) {
51
+ tokens[m[1]] = m[2]
52
+ }
53
+ const bgMatch = text.match(/--shiki-color-background:\s*var\(--color-([\w-]+)\)\s*;/)
54
+ if (bgMatch) tokens['_background'] = bgMatch[1]
55
+ return tokens
56
+ }
57
+
58
+ const darkBlock = darkStart >= 0 && darkEnd >= 0 ? css.slice(darkStart, darkEnd + 1) : ''
59
+ const lightBlock = darkStart >= 0 ? css.slice(0, darkStart) + css.slice(darkEnd + 1) : css
60
+
61
+ const lightShiki = extractShiki(lightBlock)
62
+ const darkShiki = { ...lightShiki, ...extractShiki(darkBlock) }
63
+ return { light: lightShiki, dark: darkShiki }
64
+ }
65
+
66
+ const shikiVars = parseShikiTokens(colorsPath)
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // OKLCH conversion (for hue comparison)
70
+ // ---------------------------------------------------------------------------
71
+
72
+ function hexToOklch(hex) {
73
+ const [rL, gL, bL] = hexToLinear(hex)
74
+
75
+ // Linear sRGB -> CIE XYZ (D65)
76
+ const x = 0.4124564 * rL + 0.3575761 * gL + 0.1804375 * bL
77
+ const y = 0.2126729 * rL + 0.7151522 * gL + 0.0721750 * bL
78
+ const z = 0.0193339 * rL + 0.1191920 * gL + 0.9503041 * bL
79
+
80
+ // XYZ -> LMS (using Oklab M1 matrix)
81
+ const l_ = 0.8189330101 * x + 0.3618667424 * y - 0.1288597137 * z
82
+ const m_ = 0.0329845436 * x + 0.9293118715 * y + 0.0361456387 * z
83
+ const s_ = 0.0482003018 * x + 0.2643662691 * y + 0.6338517070 * z
84
+
85
+ // Cube root
86
+ const lC = Math.cbrt(l_)
87
+ const mC = Math.cbrt(m_)
88
+ const sC = Math.cbrt(s_)
89
+
90
+ // LMS -> Lab (using Oklab M2 matrix)
91
+ const L = 0.2104542553 * lC + 0.7936177850 * mC - 0.0040720468 * sC
92
+ const a = 1.9779984951 * lC - 2.4285922050 * mC + 0.4505937099 * sC
93
+ const b = 0.0259040371 * lC + 0.7827717662 * mC - 0.8086757660 * sC
94
+
95
+ const C = Math.sqrt(a * a + b * b)
96
+ let h = Math.atan2(b, a) * (180 / Math.PI)
97
+ if (h < 0) h += 360
98
+
99
+ return { L, C, h }
100
+ }
101
+
102
+ /** Angular hue difference (0-180 degrees). */
103
+ function hueDiff(h1, h2) {
104
+ let d = Math.abs(h1 - h2)
105
+ if (d > 180) d = 360 - d
106
+ return d
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Canonical Dracula spec mapping
111
+ // ---------------------------------------------------------------------------
112
+
113
+ const DRACULA_SPEC = {
114
+ comment: 'drac-comment',
115
+ punctuation: 'drac-pink',
116
+ keyword: 'drac-pink',
117
+ string: 'drac-yellow',
118
+ 'string-expression': 'drac-yellow',
119
+ function: 'drac-green',
120
+ constant: 'drac-purple',
121
+ parameter: 'drac-orange',
122
+ link: 'drac-cyan',
123
+ }
124
+
125
+ const TOKEN_NAMES = Object.keys(DRACULA_SPEC)
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Helpers
129
+ // ---------------------------------------------------------------------------
130
+
131
+ function dualStatus(ratio, minRatio, lc, minLc) {
132
+ const wcagOk = ratio >= minRatio
133
+ const apcaOk = Math.abs(lc) >= minLc
134
+ if (wcagOk && apcaOk) return 'OK'
135
+ if (!wcagOk) return 'FAIL'
136
+ return 'WARN'
137
+ }
138
+
139
+ function statusIcon(s) {
140
+ if (s === 'FAIL') return '!'
141
+ if (s === 'WARN') return '~'
142
+ return ' '
143
+ }
144
+
145
+ /** Extract Dracula family name from palette ref (e.g. "drac-pink-700" -> "drac-pink"). */
146
+ function familyOf(paletteRef) {
147
+ const m = paletteRef.match(/^([\w]+-[\w]+)-\d+$/)
148
+ return m ? m[1] : paletteRef
149
+ }
150
+
151
+ // ===== MAIN =====
152
+
153
+ console.log('Shiki Syntax Highlighting Audit - slidev-theme-troshab')
154
+ console.log('='.repeat(76))
155
+
156
+ let issues = []
157
+
158
+ // --- Section 1: Dracula Spec Alignment ---
159
+
160
+ console.log('\n1. DRACULA SPEC ALIGNMENT')
161
+ console.log(' Canonical mapping from spec.draculatheme.com')
162
+ console.log('-'.repeat(76))
163
+
164
+ for (const theme of ['light', 'dark']) {
165
+ const shiki = shikiVars[theme]
166
+ console.log(`\n ${theme.toUpperCase()} THEME:`)
167
+ for (const token of TOKEN_NAMES) {
168
+ const paletteRef = shiki[token]
169
+ const expectedFamily = DRACULA_SPEC[token]
170
+ if (!paletteRef) {
171
+ console.log(` ! ${token.padEnd(20)} MISSING`)
172
+ issues.push(`[${theme}] ${token}: missing shiki token`)
173
+ continue
174
+ }
175
+ const actualFamily = familyOf(paletteRef)
176
+ const match = actualFamily === expectedFamily
177
+ const icon = match ? ' ' : '!'
178
+ const status = match ? 'OK' : 'MISMATCH'
179
+ console.log(` ${icon} ${token.padEnd(20)} ${paletteRef.padEnd(22)} expected: ${expectedFamily.padEnd(14)} ${status}`)
180
+ if (!match) {
181
+ issues.push(`[${theme}] ${token}: family ${actualFamily} != expected ${expectedFamily}`)
182
+ }
183
+ }
184
+ }
185
+
186
+ // --- Section 2: Contrast Verification ---
187
+
188
+ console.log('\n\n2. CONTRAST VERIFICATION (font-size-small: 18px, WCAG 4.5:1, APCA Lc 60+)')
189
+ console.log(' Code blocks are short-read content (Lc 60), not body text (Lc 75)')
190
+ console.log('-'.repeat(76))
191
+
192
+ for (const theme of ['light', 'dark']) {
193
+ const shiki = shikiVars[theme]
194
+ const bgRef = shiki['_background']
195
+ const bgHex = bgRef ? palette[bgRef] : null
196
+ if (!bgHex) {
197
+ console.log(`\n ${theme.toUpperCase()}: shiki background missing!`)
198
+ issues.push(`[${theme}] shiki background: missing`)
199
+ continue
200
+ }
201
+ console.log(`\n ${theme.toUpperCase()} on shiki-bg ${bgRef} (${bgHex}):`)
202
+ for (const token of TOKEN_NAMES) {
203
+ const paletteRef = shiki[token]
204
+ if (!paletteRef) { console.log(` ? ${token}: missing`); continue }
205
+ const fgHex = palette[paletteRef]
206
+ if (!fgHex) { console.log(` ? ${token}: palette ref ${paletteRef} not found`); continue }
207
+ const ratio = contrastRatio(fgHex, bgHex)
208
+ const lc = apcaContrast(fgHex, bgHex)
209
+ const minLc = 60
210
+ const status = dualStatus(ratio, 4.5, lc, minLc)
211
+ const icon = statusIcon(status)
212
+ console.log(` ${icon} ${(token + ' (' + paletteRef + ')').padEnd(35)} ${fmt(ratio).padStart(8)} ${fmtLc(lc)} (${minLc}+ ${status})`)
213
+ if (status === 'FAIL') issues.push(`[${theme}] shiki ${token} (${paletteRef}): ${fmt(ratio)} < 4.5:1`)
214
+ if (status === 'WARN') issues.push(`[${theme}] shiki ${token}: APCA ${fmtLc(lc)} < Lc ${minLc} (WCAG OK)`)
215
+ }
216
+ }
217
+
218
+ // --- Section 3: Token Distinguishability ---
219
+
220
+ console.log('\n\n3. TOKEN DISTINGUISHABILITY (OKLCH hue separation >= 30 deg)')
221
+ console.log(' Exempt: punctuation/keyword share drac-pink per Dracula spec')
222
+ console.log('-'.repeat(76))
223
+
224
+ // Pairs that are exempt from hue-diff check (same color per spec)
225
+ const EXEMPT_PAIRS = new Set([
226
+ 'punctuation:keyword',
227
+ 'string:string-expression',
228
+ ])
229
+
230
+ for (const theme of ['light', 'dark']) {
231
+ const shiki = shikiVars[theme]
232
+ console.log(`\n ${theme.toUpperCase()} THEME:`)
233
+
234
+ // Collect token hues
235
+ const tokenHues = {}
236
+ for (const token of TOKEN_NAMES) {
237
+ const ref = shiki[token]
238
+ if (!ref) continue
239
+ const hex = palette[ref]
240
+ if (!hex) continue
241
+ const oklch = hexToOklch(hex)
242
+ tokenHues[token] = { hex, oklch, ref }
243
+ }
244
+
245
+ // Check all unique pairs
246
+ let pairCount = 0
247
+ let warnCount = 0
248
+ const tokens = Object.keys(tokenHues)
249
+ for (let i = 0; i < tokens.length; i++) {
250
+ for (let j = i + 1; j < tokens.length; j++) {
251
+ const a = tokens[i]
252
+ const b = tokens[j]
253
+ const pairKey = `${a}:${b}`
254
+ const pairKeyRev = `${b}:${a}`
255
+ if (EXEMPT_PAIRS.has(pairKey) || EXEMPT_PAIRS.has(pairKeyRev)) continue
256
+
257
+ pairCount++
258
+ const hA = tokenHues[a].oklch.h
259
+ const hB = tokenHues[b].oklch.h
260
+ const diff = hueDiff(hA, hB)
261
+ const ok = diff >= 30
262
+ if (!ok) {
263
+ warnCount++
264
+ const icon = '~'
265
+ console.log(` ${icon} ${a} / ${b}: hue ${hA.toFixed(0)} vs ${hB.toFixed(0)} = ${diff.toFixed(0)} deg WARN (<30)`)
266
+ issues.push(`[${theme}] hue: ${a}/${b} only ${diff.toFixed(0)} deg apart`)
267
+ }
268
+ }
269
+ }
270
+ if (warnCount === 0) {
271
+ console.log(` All ${pairCount} token pairs have >= 30 deg hue separation`)
272
+ }
273
+ }
274
+
275
+ // --- Summary ---
276
+
277
+ console.log('\n' + '='.repeat(76))
278
+ console.log('SUMMARY')
279
+ console.log('='.repeat(76))
280
+
281
+ if (issues.length === 0) {
282
+ console.log('\nAll checks passed!')
283
+ } else {
284
+ const fails = issues.filter(i => !i.includes('APCA') && !i.includes('hue:'))
285
+ const warns = issues.filter(i => i.includes('APCA') || i.includes('hue:'))
286
+ if (fails.length > 0) {
287
+ console.log(`\n${fails.length} FAIL(s):`)
288
+ fails.forEach((issue, i) => console.log(` ${i + 1}. ${issue}`))
289
+ }
290
+ if (warns.length > 0) {
291
+ console.log(`\n${warns.length} WARN(s):`)
292
+ warns.forEach((issue, i) => console.log(` ${i + 1}. ${issue}`))
293
+ }
294
+ }
295
+
296
+ console.log()
297
+
298
+ // Exit code: FAIL = 1, WARN-only = 0, hue WARNs don't fail
299
+ const hasFail = issues.some(i => !i.includes('APCA') && !i.includes('hue:'))
300
+ process.exit(hasFail ? 1 : 0)
@@ -0,0 +1,300 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Typography & CSS Anti-Pattern Audit for slidev-theme-troshab
5
+ *
6
+ * Checks font sizes, weights, line heights, spacing, measure, and CSS anti-patterns.
7
+ * Run: node scripts/typography-audit.mjs
8
+ *
9
+ * Standards:
10
+ * - BDA Style Guide (2018) — dyslexia-friendly typography
11
+ * - WCAG 1.4.12 Text Spacing (resilience test, not mandatory defaults)
12
+ * - ISO 9241 display ergonomics
13
+ * - Lancaster / York presentation guidelines
14
+ *
15
+ * Scientific basis:
16
+ * - Zorzi et al. (2012, PNAS 109:11455) — letter-spacing +20% reading speed
17
+ * - Pelli et al. (2007, J. Vision) — crowding as dyslexic reading bottleneck
18
+ * - Chung (2004, Vision Research) — optimal line-spacing 1.25-1.5x
19
+ * - Kolers et al. (1981) — reading speed U-curve vs line-spacing
20
+ * - Rayner et al. (1998, 2013, QJEP) — excessive word-spacing slows saccades
21
+ * - Rello & Baeza-Yates (2013, ACM ASSETS) — sans-serif > serif for dyslexia
22
+ * - Henderson et al. (2013, Br. J. Ed. Tech.) — cream bg -12-15% eye fatigue
23
+ * - Wilkins et al. (2001, 2005, Ophthal. Physiol. Opt.) — visual stress from #FFF
24
+ */
25
+
26
+ import { readFileSync } from 'fs'
27
+ import { resolve, dirname } from 'path'
28
+ import { fileURLToPath } from 'url'
29
+ import { parseCssVar, scanCssPattern } from './shared/css-utils.mjs'
30
+
31
+ const __dirname = dirname(fileURLToPath(import.meta.url))
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Paths
35
+ // ---------------------------------------------------------------------------
36
+
37
+ const basePath = resolve(__dirname, '..', 'styles', 'base.css')
38
+ const colorsPath = resolve(__dirname, '..', 'styles', 'colors.css')
39
+ const motionPath = resolve(__dirname, '..', 'styles', 'motion.css')
40
+
41
+ const baseCss = readFileSync(basePath, 'utf8')
42
+ const colorsCss = readFileSync(colorsPath, 'utf8')
43
+ const motionCss = readFileSync(motionPath, 'utf8')
44
+ const allCss = baseCss + '\n' + colorsCss + '\n' + motionCss
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Helpers
48
+ // ---------------------------------------------------------------------------
49
+
50
+ let issues = []
51
+ let warns = []
52
+
53
+ function parseRem(val) {
54
+ if (!val) return null
55
+ const m = val.match(/([\d.]+)rem/)
56
+ return m ? parseFloat(m[1]) : null
57
+ }
58
+
59
+ function parseUnitless(val) {
60
+ if (!val) return null
61
+ const m = val.match(/([\d.]+)/)
62
+ return m ? parseFloat(m[1]) : null
63
+ }
64
+
65
+ function parseEm(val) {
66
+ if (!val) return null
67
+ const m = val.match(/([\d.]+)em/)
68
+ return m ? parseFloat(m[1]) : null
69
+ }
70
+
71
+ function parseCh(val) {
72
+ if (!val) return null
73
+ const m = val.match(/([\d.]+)ch/)
74
+ return m ? parseFloat(m[1]) : null
75
+ }
76
+
77
+ function remToPx(rem) {
78
+ return rem * 16
79
+ }
80
+
81
+ function check(name, value, min, max, unit, source) {
82
+ if (value === null) {
83
+ issues.push(`${name}: not found`)
84
+ console.log(` ! ${name.padEnd(30)} NOT FOUND`)
85
+ return
86
+ }
87
+ const inRange = value >= min && (max === null || value <= max)
88
+ const rangeStr = max !== null ? `${min}-${max}${unit}` : `>= ${min}${unit}`
89
+ const status = inRange ? 'OK' : 'FAIL'
90
+ const icon = inRange ? ' ' : '!'
91
+ console.log(` ${icon} ${name.padEnd(30)} ${value}${unit}`.padEnd(52) + ` ${rangeStr.padEnd(16)} ${status} (${source})`)
92
+ if (!inRange) issues.push(`${name}: ${value}${unit} not in ${rangeStr} (${source})`)
93
+ }
94
+
95
+ function warn(name, value, threshold, unit, source) {
96
+ if (value === null) return
97
+ const ok = value >= threshold
98
+ const status = ok ? 'OK' : 'INFO'
99
+ const icon = ok ? ' ' : '~'
100
+ console.log(` ${icon} ${name.padEnd(30)} ${value}${unit}`.padEnd(52) + ` >= ${threshold}${unit}`.padEnd(16) + ` ${status} (${source})`)
101
+ if (!ok) warns.push(`${name}: ${value}${unit} < ${threshold}${unit} (${source})`)
102
+ }
103
+
104
+ // ===== MAIN =====
105
+
106
+ console.log('Typography & CSS Anti-Pattern Audit - slidev-theme-troshab')
107
+ console.log('='.repeat(76))
108
+
109
+ // --- Section 1: Font sizes ---
110
+
111
+ console.log('\n1. FONT SIZES (presentation minimums)')
112
+ console.log('-'.repeat(76))
113
+
114
+ const fontSizeH1 = parseRem(parseCssVar(baseCss, '--font-size-h1'))
115
+ const fontSizeH2 = parseRem(parseCssVar(baseCss, '--font-size-h2'))
116
+ const fontSizeBase = parseRem(parseCssVar(baseCss, '--font-size-base'))
117
+ const fontSizeSmall = parseRem(parseCssVar(baseCss, '--font-size-small'))
118
+
119
+ check('--font-size-h1', fontSizeH1, 2, null, 'rem', 'heading min')
120
+ check('--font-size-h2', fontSizeH2, 1.75, null, 'rem', 'sub-heading min')
121
+ check('--font-size-base', fontSizeBase, 1.5, null, 'rem', 'Lancaster Portal')
122
+ check('--font-size-small', fontSizeSmall, 1.125, null, 'rem', 'York guide')
123
+
124
+ if (fontSizeH1) console.log(`\n APCA context: h1 = ${remToPx(fontSizeH1)}px -> Lc 45+ (large headline)`)
125
+ if (fontSizeH2) console.log(` APCA context: h2 = ${remToPx(fontSizeH2)}px -> Lc 45+ (large headline)`)
126
+ if (fontSizeBase) console.log(` APCA context: base = ${remToPx(fontSizeBase)}px -> Lc 60+ (content text)`)
127
+ if (fontSizeSmall) console.log(` APCA context: small = ${remToPx(fontSizeSmall)}px -> Lc 75+ (body text)`)
128
+
129
+ // --- Section 2: Font weights ---
130
+
131
+ console.log('\n\n2. FONT WEIGHTS (ISO 9241 / projector readability)')
132
+ console.log('-'.repeat(76))
133
+
134
+ const weightNormal = parseUnitless(parseCssVar(baseCss, '--font-weight-normal'))
135
+ check('--font-weight-normal', weightNormal, 400, null, '', 'ISO 9241')
136
+
137
+ // Scan all CSS for light weights (100/200/300)
138
+ const lightWeightMatches = scanCssPattern(allCss, /font-weight:\s*(100|200|300)\b/g)
139
+
140
+ if (lightWeightMatches.length === 0) {
141
+ console.log(' No light font weights (100-300) found. OK')
142
+ } else {
143
+ for (const m of lightWeightMatches) {
144
+ // Get surrounding context (selector)
145
+ const before = allCss.slice(Math.max(0, m.index - 100), m.index)
146
+ const selectorMatch = before.match(/([^\n{}]+)\{[^{}]*$/)
147
+ const selector = selectorMatch ? selectorMatch[1].trim() : '(unknown)'
148
+ console.log(` ! font-weight: ${m[1]} in "${selector}"`.padEnd(60) + 'FAIL (ISO 9241)')
149
+ issues.push(`Light font weight ${m[1]} found in "${selector}"`)
150
+ }
151
+ }
152
+
153
+ // --- Section 3: Line heights & spacing ---
154
+
155
+ console.log('\n\n3. LINE HEIGHTS & SPACING (BDA + WCAG 1.4.12)')
156
+ console.log('-'.repeat(76))
157
+
158
+ const lhHeading = parseUnitless(parseCssVar(baseCss, '--line-height-heading'))
159
+ const lhBody = parseUnitless(parseCssVar(baseCss, '--line-height-body'))
160
+ const lhReading = parseUnitless(parseCssVar(baseCss, '--line-height-reading'))
161
+ const lhCode = parseUnitless(parseCssVar(baseCss, '--line-height-code'))
162
+ const letterBody = parseEm(parseCssVar(baseCss, '--letter-spacing-body'))
163
+ const wordBody = parseEm(parseCssVar(baseCss, '--word-spacing-body'))
164
+ const paragraphSpacing = parseEm(parseCssVar(baseCss, '--paragraph-spacing'))
165
+ const measure = parseCh(parseCssVar(baseCss, '--measure'))
166
+ const measureNarrow = parseCh(parseCssVar(baseCss, '--measure-narrow'))
167
+
168
+ check('--line-height-heading', lhHeading, 1.1, null, '', 'typography')
169
+ check('--line-height-body', lhBody, 1.3, null, '', 'BDA slides')
170
+ check('--line-height-reading', lhReading, 1.5, null, '', 'WCAG / BDA')
171
+ check('--line-height-code', lhCode, 1.3, null, '', 'code readability')
172
+ check('--letter-spacing-body', letterBody, 0.001, null, 'em', 'BDA > 0')
173
+ check('--paragraph-spacing', paragraphSpacing, 0.5, null, 'em', 'readability')
174
+ check('--measure', measure, 55, 75, 'ch', 'Baymard/BDA')
175
+ check('--measure-narrow', measureNarrow, 35, 55, 'ch', 'column constraint')
176
+
177
+ // BDA word/letter ratio
178
+ if (letterBody && wordBody) {
179
+ const ratio = wordBody / letterBody
180
+ check('word/letter ratio', ratio, 3.5, null, 'x', 'BDA >= 3.5x')
181
+ }
182
+
183
+ // WCAG 1.4.12 resilience (informational)
184
+ console.log('\n WCAG 1.4.12 Text Spacing resilience (informational):')
185
+ if (lhBody) warn('line-height vs 1.5x', lhBody, 1.5, '', 'WCAG override')
186
+ if (paragraphSpacing) warn('paragraph-spacing vs 2em', paragraphSpacing, 2, 'em', 'WCAG override')
187
+ if (letterBody) warn('letter-spacing vs 0.12em', letterBody, 0.12, 'em', 'WCAG override')
188
+ if (wordBody) warn('word-spacing vs 0.16em', wordBody, 0.16, 'em', 'WCAG override')
189
+
190
+ // --- Section 4: CSS anti-patterns ---
191
+
192
+ console.log('\n\n4. CSS ANTI-PATTERNS')
193
+ console.log('-'.repeat(76))
194
+
195
+ let antiPatternCount = 0
196
+
197
+ // 4a. text-align: justify
198
+ const justifyMatches = scanCssPattern(allCss, /text-align:\s*justify/g)
199
+ if (justifyMatches.length === 0) {
200
+ console.log(' text-align: justify'.padEnd(52) + 'not found'.padEnd(16) + ' OK (BDA)')
201
+ } else {
202
+ for (const m of justifyMatches) {
203
+ console.log(` ! text-align: justify found`.padEnd(52) + ''.padEnd(16) + ' FAIL (BDA)')
204
+ issues.push('text-align: justify found (BDA forbids)')
205
+ antiPatternCount++
206
+ }
207
+ }
208
+
209
+ // 4b. text-transform: uppercase on body selectors
210
+ const uppercaseMatches = scanCssPattern(allCss, /text-transform:\s*uppercase/g)
211
+ if (uppercaseMatches.length === 0) {
212
+ console.log(' text-transform: uppercase'.padEnd(52) + 'not found'.padEnd(16) + ' OK (BDA)')
213
+ } else {
214
+ for (const m of uppercaseMatches) {
215
+ const before = allCss.slice(Math.max(0, m.index - 200), m.index)
216
+ const selectorMatch = before.match(/([^\n{}]+)\{[^{}]*$/)
217
+ const selector = selectorMatch ? selectorMatch[1].trim() : '(unknown)'
218
+ // Allow in small UI elements like tags, labels
219
+ const isUiElement = /\.tag|\.label|\.badge|\.btn|\.step-number|\.timeline-date/i.test(selector)
220
+ if (isUiElement) {
221
+ console.log(` text-transform: uppercase in "${selector}"`.padEnd(52) + 'UI element'.padEnd(16) + ' OK (allowed)')
222
+ } else {
223
+ console.log(` ~ text-transform: uppercase in "${selector}"`.padEnd(52) + ''.padEnd(16) + ' WARN (BDA)')
224
+ warns.push(`text-transform: uppercase in "${selector}" (BDA: avoid on body text)`)
225
+ antiPatternCount++
226
+ }
227
+ }
228
+ }
229
+
230
+ // 4c. font-style: italic on body default
231
+ const italicMatches = scanCssPattern(baseCss, /font-style:\s*italic/g)
232
+ for (const m of italicMatches) {
233
+ const before = baseCss.slice(Math.max(0, m.index - 200), m.index)
234
+ const selectorMatch = before.match(/([^\n{}]+)\{[^{}]*$/)
235
+ const selector = selectorMatch ? selectorMatch[1].trim() : '(unknown)'
236
+ // Italic in blockquote/cite is acceptable
237
+ const isQuote = /blockquote|cite|em\b|\.quote/i.test(selector)
238
+ if (isQuote) {
239
+ console.log(` font-style: italic in "${selector}"`.padEnd(52) + 'quote context'.padEnd(16) + ' OK (acceptable)')
240
+ } else {
241
+ console.log(` ~ font-style: italic in "${selector}"`.padEnd(52) + ''.padEnd(16) + ' WARN (BDA)')
242
+ warns.push(`font-style: italic in "${selector}" (BDA: avoid on body default)`)
243
+ antiPatternCount++
244
+ }
245
+ }
246
+ if (italicMatches.length === 0) {
247
+ console.log(' font-style: italic on body'.padEnd(52) + 'not found'.padEnd(16) + ' OK (BDA)')
248
+ }
249
+
250
+ // 4d. @font-face without font-display: swap
251
+ const fontFaceBlocks = scanCssPattern(baseCss, /@font-face\s*\{[^}]*\}/g)
252
+ let missingFontDisplay = 0
253
+ for (const m of fontFaceBlocks) {
254
+ if (!m[0].includes('font-display')) {
255
+ const familyMatch = m[0].match(/font-family:\s*'([^']+)'/)
256
+ const family = familyMatch ? familyMatch[1] : '(unknown)'
257
+ console.log(` ! @font-face "${family}" missing font-display: swap`.padEnd(52) + ''.padEnd(16) + ' FAIL (perf/a11y)')
258
+ issues.push(`@font-face "${family}" missing font-display: swap`)
259
+ missingFontDisplay++
260
+ }
261
+ }
262
+ if (missingFontDisplay === 0) {
263
+ console.log(' @font-face font-display: swap'.padEnd(52) + 'all present'.padEnd(16) + ' OK (perf/a11y)')
264
+ }
265
+
266
+ // 4e. font-family root ends with sans-serif
267
+ const fontSans = parseCssVar(baseCss, '--font-sans')
268
+ if (fontSans) {
269
+ const endsWithGeneric = /,\s*(sans-serif|serif|monospace|cursive|fantasy)\s*$/.test(fontSans)
270
+ if (endsWithGeneric) {
271
+ console.log(' --font-sans generic fallback'.padEnd(52) + 'present'.padEnd(16) + ' OK (Oxford/BDA)')
272
+ } else {
273
+ console.log(` ! --font-sans missing generic fallback`.padEnd(52) + ''.padEnd(16) + ' FAIL (Oxford/BDA)')
274
+ issues.push('--font-sans missing generic fallback (sans-serif)')
275
+ }
276
+ } else {
277
+ console.log(` ? --font-sans not found`)
278
+ }
279
+
280
+ // --- Section 5: Summary ---
281
+
282
+ console.log('\n' + '='.repeat(76))
283
+ console.log('SUMMARY')
284
+ console.log('='.repeat(76))
285
+
286
+ if (issues.length === 0 && warns.length === 0) {
287
+ console.log('\nAll checks passed!')
288
+ } else {
289
+ if (issues.length > 0) {
290
+ console.log(`\n${issues.length} FAIL(s):`)
291
+ issues.forEach((issue, i) => console.log(` ${i + 1}. ${issue}`))
292
+ }
293
+ if (warns.length > 0) {
294
+ console.log(`\n${warns.length} WARN/INFO:`)
295
+ warns.forEach((w, i) => console.log(` ${i + 1}. ${w}`))
296
+ }
297
+ }
298
+
299
+ console.log()
300
+ process.exit(issues.length > 0 ? 1 : 0)