@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.
- package/CLAUDE.md +537 -0
- package/LICENSE +134 -0
- package/README.md +168 -0
- package/SKILL.md +414 -0
- package/components/AnimatedCounter.vue +35 -0
- package/components/Background.vue +204 -0
- package/components/Callout.vue +135 -0
- package/components/Card.vue +75 -0
- package/components/CardGrid.vue +67 -0
- package/components/CaseStudy.vue +66 -0
- package/components/CodeDiff.vue +229 -0
- package/components/CodeHighlight.vue +337 -0
- package/components/ColorSwatch.vue +114 -0
- package/components/Confetti.vue +292 -0
- package/components/Conversation.vue +405 -0
- package/components/Countdown.vue +476 -0
- package/components/Definition.vue +59 -0
- package/components/DeviceMockup.vue +392 -0
- package/components/Funnel.vue +87 -0
- package/components/Icon.vue +73 -0
- package/components/Iframe.vue +38 -0
- package/components/Image.vue +69 -0
- package/components/ImageCompare.vue +436 -0
- package/components/MatrixGrid.vue +85 -0
- package/components/MermaidChart.vue +299 -0
- package/components/Metric.vue +161 -0
- package/components/PersonCard.vue +165 -0
- package/components/PricingTable.vue +144 -0
- package/components/Progress.vue +100 -0
- package/components/Pyramid.vue +81 -0
- package/components/QRCode.vue +137 -0
- package/components/QuoteBlock.vue +101 -0
- package/components/SpeechBubble.vue +169 -0
- package/components/Stepper.vue +542 -0
- package/components/StyledList.vue +156 -0
- package/components/StyledText.vue +275 -0
- package/components/SwotGrid.vue +99 -0
- package/components/Tags.vue +20 -0
- package/components/Testimonial.vue +243 -0
- package/components/Typewriter.vue +181 -0
- package/components_base/AnimatedCounter.vue +208 -0
- package/components_base/CodeHighlight.vue +364 -0
- package/composables/useColors.ts +101 -0
- package/composables/useShiki.ts +81 -0
- package/example_content.md +371 -0
- package/example_dark.md +10 -0
- package/example_slides/001-cover.md +15 -0
- package/example_slides/002-agenda.md +25 -0
- package/example_slides/003-section-layouts.md +14 -0
- package/example_slides/004-fullscreen-centered.md +7 -0
- package/example_slides/005-fullscreen-align-bottom.md +14 -0
- package/example_slides/006-fullscreen-no-padding.md +14 -0
- package/example_slides/007-fullscreen-bg-image-dark.md +13 -0
- package/example_slides/008-fullscreen-bg-image-light.md +13 -0
- package/example_slides/009-fullscreen-bg-gradient.md +15 -0
- package/example_slides/010-fullscreen-bg-color.md +13 -0
- package/example_slides/011-split-basic.md +17 -0
- package/example_slides/012-split-image-text.md +18 -0
- package/example_slides/013-split-contrast.md +22 -0
- package/example_slides/014-columns-basic.md +13 -0
- package/example_slides/015-columns-two.md +26 -0
- package/example_slides/016-columns-ratios.md +22 -0
- package/example_slides/017-columns-three.md +31 -0
- package/example_slides/018-columns-four.md +22 -0
- package/example_slides/019-columns-alignment.md +23 -0
- package/example_slides/020-columns-styled.md +21 -0
- package/example_slides/021-footnote-prop.md +16 -0
- package/example_slides/022-iframe-fullscreen.md +8 -0
- package/example_slides/023-iframe-split.md +18 -0
- package/example_slides/024-section-components.md +14 -0
- package/example_slides/025-styled-text.md +9 -0
- package/example_slides/026-styled-text.md +15 -0
- package/example_slides/027-text-formatting.md +28 -0
- package/example_slides/028-text-spoiler.md +15 -0
- package/example_slides/029-icon-component.md +47 -0
- package/example_slides/030-metric-component.md +29 -0
- package/example_slides/031-person-card.md +33 -0
- package/example_slides/032-styled-list.md +50 -0
- package/example_slides/033-color-swatch.md +35 -0
- package/example_slides/034-code-highlight.md +9 -0
- package/example_slides/035-iframe-component.md +9 -0
- package/example_slides/036-callout.md +15 -0
- package/example_slides/037-card-grid.md +27 -0
- package/example_slides/038-stepper-variants.md +18 -0
- package/example_slides/039-stepper-clicks.md +49 -0
- package/example_slides/040-stepper-interactive.md +28 -0
- package/example_slides/041-tags-progress.md +21 -0
- package/example_slides/042-speech-bubble.md +30 -0
- package/example_slides/043-conversation.md +13 -0
- package/example_slides/044-device-iphone.md +26 -0
- package/example_slides/045-device-browser.md +7 -0
- package/example_slides/046-qrcode.md +26 -0
- package/example_slides/047-countdown.md +14 -0
- package/example_slides/048-typewriter.md +8 -0
- package/example_slides/049-confetti.md +16 -0
- package/example_slides/050-image-compare.md +13 -0
- package/example_slides/051-code-diff.md +24 -0
- package/example_slides/052-quote-block.md +8 -0
- package/example_slides/053-testimonial.md +26 -0
- package/example_slides/054-testimonial-featured.md +16 -0
- package/example_slides/055-funnel.md +12 -0
- package/example_slides/056-pyramid.md +13 -0
- package/example_slides/057-pricing-table.md +9 -0
- package/example_slides/058-swot-grid.md +12 -0
- package/example_slides/059-matrix-grid.md +12 -0
- package/example_slides/060-case-study.md +11 -0
- package/example_slides/061-definition.md +15 -0
- package/example_slides/062-mermaid-intro.md +34 -0
- package/example_slides/063-mermaid-flowchart.md +19 -0
- package/example_slides/064-mermaid-sequence.md +17 -0
- package/example_slides/065-mermaid-xy-chart.md +16 -0
- package/example_slides/066-mermaid-pie.md +17 -0
- package/example_slides/067-mermaid-class.md +19 -0
- package/example_slides/068-mermaid-state.md +19 -0
- package/example_slides/069-mermaid-er.md +22 -0
- package/example_slides/070-mermaid-gantt.md +24 -0
- package/example_slides/071-mermaid-timeline.md +17 -0
- package/example_slides/072-mermaid-mindmap.md +21 -0
- package/example_slides/073-mermaid-gitgraph.md +20 -0
- package/example_slides/074-mermaid-split.md +30 -0
- package/example_slides/075-mermaid-columns.md +32 -0
- package/example_slides/076-section-addons.md +14 -0
- package/example_slides/077-asciinema.md +27 -0
- package/example_slides/078-fancyarrow.md +31 -0
- package/example_slides/079-fancyarrow-demo.md +23 -0
- package/example_slides/080-section-theme.md +14 -0
- package/example_slides/081-color-architecture.md +22 -0
- package/example_slides/082-semantic-text-colors.md +25 -0
- package/example_slides/083-typography.md +16 -0
- package/example_slides/084-typography-rationale.md +22 -0
- package/example_slides/085-icons.md +24 -0
- package/example_slides/086-tables.md +14 -0
- package/example_slides/087-code-blocks.md +18 -0
- package/example_slides/088-motion-modes.md +35 -0
- package/example_slides/089-slide-transitions.md +31 -0
- package/example_slides/090-v-click-reveals.md +40 -0
- package/example_slides/091-accessibility.md +27 -0
- package/example_slides/092-safe-zone.md +17 -0
- package/example_slides/093-questions.md +8 -0
- package/example_white.md +10 -0
- package/fonts/IBMPlexMono-Medium.woff2 +1449 -0
- package/fonts/IBMPlexMono-Regular.woff2 +1449 -0
- package/fonts/IBMPlexSans-Bold.woff2 +1449 -0
- package/fonts/IBMPlexSans-Medium.woff2 +1449 -0
- package/fonts/IBMPlexSans-Regular.woff2 +1449 -0
- package/fonts/IBMPlexSans-SemiBold.woff2 +1449 -0
- package/fonts/LICENSE.txt +93 -0
- package/layouts/slide.vue +251 -0
- package/package.json +62 -0
- package/public/avatars/alice.png +0 -0
- package/public/avatars/bob.png +0 -0
- package/public/avatars/carol.png +0 -0
- package/scripts/chart-audit.mjs +216 -0
- package/scripts/contrast-audit.mjs +299 -0
- package/scripts/generate-palette.mjs +395 -0
- package/scripts/integrity-audit.mjs +357 -0
- package/scripts/shared/css-utils.mjs +216 -0
- package/scripts/shiki-audit.mjs +300 -0
- package/scripts/typography-audit.mjs +300 -0
- package/setup/main.ts +107 -0
- package/setup/mermaid.ts +237 -0
- package/setup/shiki.ts +40 -0
- package/snippets/demo.ts +26 -0
- package/styles/base.css +1053 -0
- package/styles/colors.css +422 -0
- package/styles/index.css +12 -0
- package/styles/motion.css +486 -0
- 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)
|