@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,357 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Color Integrity Audit for slidev-theme-troshab
5
+ *
6
+ * Ensures the 3-layer color architecture stays clean:
7
+ * Layer 1 (Palette): hex ONLY in styles/colors.css
8
+ * Layer 2 (Semantic): only var() refs in :root/.dark blocks
9
+ * Layer 3 (Usage): components/layouts/slides use only semantic vars
10
+ *
11
+ * Run: node scripts/integrity-audit.mjs
12
+ */
13
+
14
+ import { readFileSync, readdirSync, statSync } from 'fs'
15
+ import { resolve, dirname, extname, relative } from 'path'
16
+ import { fileURLToPath } from 'url'
17
+ import { parsePalette, parseSemanticTokens } from './shared/css-utils.mjs'
18
+
19
+ const __dirname = dirname(fileURLToPath(import.meta.url))
20
+ const root = resolve(__dirname, '..')
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // File collection
24
+ // ---------------------------------------------------------------------------
25
+
26
+ function collectFiles(dir, extensions) {
27
+ const results = []
28
+ let entries
29
+ try { entries = readdirSync(dir) } catch { return results }
30
+ for (const entry of entries) {
31
+ const full = resolve(dir, entry)
32
+ let stat
33
+ try { stat = statSync(full) } catch { continue }
34
+ if (stat.isDirectory()) {
35
+ if (entry === 'node_modules' || entry === '.git' || entry === 'dist' || entry === 'slides-export') continue
36
+ results.push(...collectFiles(full, extensions))
37
+ } else if (extensions.includes(extname(entry))) {
38
+ results.push(full)
39
+ }
40
+ }
41
+ return results
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Known CSS variable definitions
46
+ // ---------------------------------------------------------------------------
47
+
48
+ const colorsPath = resolve(root, 'styles', 'colors.css')
49
+ const palette = parsePalette(colorsPath)
50
+ const { light, dark } = parseSemanticTokens(colorsPath)
51
+
52
+ // Build set of all defined --color-* var names (without prefix)
53
+ const definedVars = new Set([
54
+ ...Object.keys(palette),
55
+ ...Object.keys(light),
56
+ ...Object.keys(dark),
57
+ ])
58
+
59
+ // Also parse raw CSS for any additional vars (gradients, charts, shiki)
60
+ const colorsCss = readFileSync(colorsPath, 'utf8')
61
+ const allCssVarDefs = new Set()
62
+ for (const m of colorsCss.matchAll(/--([\w-]+)\s*:/g)) {
63
+ allCssVarDefs.add(m[1])
64
+ }
65
+
66
+ // Vars from base.css
67
+ const baseCss = readFileSync(resolve(root, 'styles', 'base.css'), 'utf8')
68
+ for (const m of baseCss.matchAll(/--([\w-]+)\s*:/g)) {
69
+ allCssVarDefs.add(m[1])
70
+ }
71
+
72
+ // Vars from motion.css
73
+ const motionCss = readFileSync(resolve(root, 'styles', 'motion.css'), 'utf8')
74
+ for (const m of motionCss.matchAll(/--([\w-]+)\s*:/g)) {
75
+ allCssVarDefs.add(m[1])
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Checks
80
+ // ---------------------------------------------------------------------------
81
+
82
+ let issues = []
83
+ let warns = []
84
+
85
+ // Hex pattern: #xxx, #xxxxxx, #xxxxxxxx (but not inside CSS var definitions)
86
+ const HEX_RE = /#(?:[0-9a-fA-F]{3}){1,2}(?:[0-9a-fA-F]{2})?\b/g
87
+
88
+ // Allowed hex locations
89
+ const ALLOWED_HEX_FILES = new Set([
90
+ resolve(root, 'styles', 'colors.css'),
91
+ ])
92
+
93
+ // Files where hex in comments is OK (documentation)
94
+ function isComment(line) {
95
+ const trimmed = line.trim()
96
+ return trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*') || trimmed.startsWith('<!--')
97
+ }
98
+
99
+ // ===== MAIN =====
100
+
101
+ console.log('Color Integrity Audit - slidev-theme-troshab')
102
+ console.log('='.repeat(76))
103
+
104
+ // --- Section 1: Hex leakage ---
105
+
106
+ console.log('\n1. HEX LEAKAGE (no raw hex outside colors.css)')
107
+ console.log('-'.repeat(76))
108
+
109
+ const scanFiles = [
110
+ ...collectFiles(resolve(root, 'components'), ['.vue']),
111
+ ...collectFiles(resolve(root, 'layouts'), ['.vue']),
112
+ ...collectFiles(resolve(root, 'composables'), ['.ts']),
113
+ ...collectFiles(resolve(root, 'setup'), ['.ts']),
114
+ ...collectFiles(resolve(root, 'example_slides'), ['.md']),
115
+ ...collectFiles(resolve(root, 'styles'), ['.css']),
116
+ ]
117
+
118
+ let hexLeaks = 0
119
+ for (const file of scanFiles) {
120
+ if (ALLOWED_HEX_FILES.has(file)) continue
121
+ const content = readFileSync(file, 'utf8')
122
+ const lines = content.split('\n')
123
+ for (let i = 0; i < lines.length; i++) {
124
+ const line = lines[i]
125
+ if (isComment(line)) continue
126
+ // Skip hex inside SVG path data (d="M...")
127
+ if (/\bd="[^"]*"/.test(line)) continue
128
+
129
+ const matches = line.matchAll(HEX_RE)
130
+ for (const m of matches) {
131
+ // Skip hex inside HTML entities like &#x27;
132
+ const before = line.slice(Math.max(0, m.index - 2), m.index)
133
+ if (before.endsWith('&#') || before.endsWith('&')) continue
134
+ // Skip hex inside URL encoded strings
135
+ if (before.endsWith('%')) continue
136
+
137
+ const rel = relative(root, file)
138
+ console.log(` ! ${rel}:${i + 1} ${m[0]} "${line.trim().slice(0, 60)}"`)
139
+ issues.push(`Hex ${m[0]} in ${rel}:${i + 1}`)
140
+ hexLeaks++
141
+ }
142
+ }
143
+ }
144
+
145
+ if (hexLeaks === 0) {
146
+ console.log(` Scanned ${scanFiles.length - ALLOWED_HEX_FILES.size} files: no hex leaks found. OK`)
147
+ }
148
+
149
+ // --- Section 2: Undefined CSS variable references ---
150
+
151
+ console.log('\n\n2. UNDEFINED CSS VARIABLE REFERENCES')
152
+ console.log('-'.repeat(76))
153
+
154
+ const varRefFiles = [
155
+ ...collectFiles(resolve(root, 'components'), ['.vue']),
156
+ ...collectFiles(resolve(root, 'layouts'), ['.vue']),
157
+ ...collectFiles(resolve(root, 'styles'), ['.css']),
158
+ ]
159
+
160
+ let undefinedRefs = 0
161
+ const VAR_REF_RE = /var\(--([\w-]+)\)/g
162
+
163
+ // Known external vars (from Slidev/UnoCSS/browser, not ours to define)
164
+ const EXTERNAL_VARS = new Set([
165
+ 'slidev-slide-scale',
166
+ 'un-default',
167
+ 'at-apply',
168
+ ])
169
+
170
+ for (const file of varRefFiles) {
171
+ const content = readFileSync(file, 'utf8')
172
+ const lines = content.split('\n')
173
+ for (let i = 0; i < lines.length; i++) {
174
+ const line = lines[i]
175
+ if (isComment(line)) continue
176
+
177
+ const matches = line.matchAll(VAR_REF_RE)
178
+ for (const m of matches) {
179
+ const varName = m[1]
180
+ if (allCssVarDefs.has(varName)) continue
181
+ if (EXTERNAL_VARS.has(varName)) continue
182
+ // Component-local vars (defined in <style> or set via :style binding)
183
+ if (content.includes(`--${varName}:`)) continue
184
+ if (content.includes(`'--${varName}'`)) continue
185
+
186
+ const rel = relative(root, file)
187
+ console.log(` ~ ${rel}:${i + 1} var(--${varName}) (not in global CSS)`)
188
+ warns.push(`Undefined var(--${varName}) in ${rel}:${i + 1}`)
189
+ undefinedRefs++
190
+ }
191
+ }
192
+ }
193
+
194
+ if (undefinedRefs === 0) {
195
+ console.log(` Scanned ${varRefFiles.length} files: all var() references resolve. OK`)
196
+ }
197
+
198
+ // --- Section 3: Inline styles with hardcoded colors in slides ---
199
+
200
+ console.log('\n\n3. HARDCODED COLORS IN SLIDES')
201
+ console.log('-'.repeat(76))
202
+
203
+ const slideFiles = collectFiles(resolve(root, 'example_slides'), ['.md'])
204
+ const INLINE_COLOR_RE = /style\s*=\s*"[^"]*(?:color|background|border-color)\s*:\s*(?!var\()[^"]*"/gi
205
+
206
+ let slideColorLeaks = 0
207
+ for (const file of slideFiles) {
208
+ const content = readFileSync(file, 'utf8')
209
+ const lines = content.split('\n')
210
+ for (let i = 0; i < lines.length; i++) {
211
+ const line = lines[i]
212
+ const matches = line.matchAll(INLINE_COLOR_RE)
213
+ for (const m of matches) {
214
+ // Allow style="...color: var(--..." patterns
215
+ if (/color\s*:\s*var\(--/.test(m[0])) continue
216
+ // Allow non-color styles like width/height that happen to match
217
+ if (!/(?:color|background|border-color)\s*:\s*[^v]/.test(m[0])) continue
218
+
219
+ const rel = relative(root, file)
220
+ console.log(` ! ${rel}:${i + 1} "${m[0].slice(0, 60)}"`)
221
+ issues.push(`Inline color in ${rel}:${i + 1}`)
222
+ slideColorLeaks++
223
+ }
224
+ }
225
+ }
226
+
227
+ if (slideColorLeaks === 0) {
228
+ console.log(` Scanned ${slideFiles.length} slide files: no hardcoded colors. OK`)
229
+ }
230
+
231
+ // --- Section 4: color-mix() with raw hex (should use vars) ---
232
+
233
+ console.log('\n\n4. color-mix() WITH RAW HEX (should use CSS vars)')
234
+ console.log('-'.repeat(76))
235
+
236
+ const COLOR_MIX_HEX_RE = /color-mix\([^)]*#[0-9a-fA-F]/g
237
+
238
+ let colorMixHexCount = 0
239
+ for (const file of [...scanFiles]) {
240
+ if (ALLOWED_HEX_FILES.has(file)) continue
241
+ const content = readFileSync(file, 'utf8')
242
+ const lines = content.split('\n')
243
+ for (let i = 0; i < lines.length; i++) {
244
+ const line = lines[i]
245
+ if (isComment(line)) continue
246
+ const matches = line.matchAll(COLOR_MIX_HEX_RE)
247
+ for (const m of matches) {
248
+ const rel = relative(root, file)
249
+ console.log(` ! ${rel}:${i + 1} "${m[0].slice(0, 60)}"`)
250
+ issues.push(`color-mix() with hex in ${rel}:${i + 1}`)
251
+ colorMixHexCount++
252
+ }
253
+ }
254
+ }
255
+
256
+ if (colorMixHexCount === 0) {
257
+ console.log(` All color-mix() calls use CSS variables. OK`)
258
+ }
259
+
260
+ // --- Section 5: Foreground pair completeness ---
261
+
262
+ console.log('\n\n5. FOREGROUND PAIR COMPLETENESS')
263
+ console.log(' Every --color-{name} semantic bg needs --color-{name}-foreground')
264
+ console.log('-'.repeat(76))
265
+
266
+ const semanticBgs = ['primary', 'success', 'warning', 'danger', 'info', 'secondary', 'accent']
267
+ let missingFg = 0
268
+
269
+ for (const name of semanticBgs) {
270
+ const hasFg = definedVars.has(name + '-foreground')
271
+ if (hasFg) {
272
+ console.log(` --color-${name}-foreground`.padEnd(42) + 'defined'.padEnd(12) + 'OK')
273
+ } else {
274
+ console.log(` ! --color-${name}-foreground`.padEnd(42) + 'MISSING'.padEnd(12) + 'FAIL')
275
+ issues.push(`Missing --color-${name}-foreground pair`)
276
+ missingFg++
277
+ }
278
+ }
279
+
280
+ // Also check tint pairs
281
+ const tintNames = ['primary-tint', 'success-tint', 'warning-tint', 'danger-tint', 'info-tint']
282
+ for (const name of tintNames) {
283
+ const hasTint = definedVars.has(name)
284
+ if (hasTint) {
285
+ console.log(` --color-${name}`.padEnd(42) + 'defined'.padEnd(12) + 'OK')
286
+ } else {
287
+ console.log(` ! --color-${name}`.padEnd(42) + 'MISSING'.padEnd(12) + 'FAIL')
288
+ issues.push(`Missing --color-${name}`)
289
+ missingFg++
290
+ }
291
+ }
292
+
293
+ // --- Section 6: Duplicate color maps ---
294
+
295
+ console.log('\n\n6. COMPONENT COLOR MAP CONSISTENCY')
296
+ console.log(' Check that local colorVarMap in components cover all semantic colors')
297
+ console.log('-'.repeat(76))
298
+
299
+ const componentFiles = collectFiles(resolve(root, 'components'), ['.vue'])
300
+ const COLORMAP_RE = /const\s+(\w*[Cc]olor\w*)\s*(?::\s*[^=]+)?\s*=\s*\{([^}]+)\}/gs
301
+
302
+ let mapIssues = 0
303
+ for (const file of componentFiles) {
304
+ const content = readFileSync(file, 'utf8')
305
+ const maps = content.matchAll(COLORMAP_RE)
306
+ for (const m of maps) {
307
+ const mapName = m[1]
308
+ const body = m[2]
309
+ // Extract keys from the map
310
+ const keys = [...body.matchAll(/(\w+)\s*:/g)].map(k => k[1])
311
+ // Check if it covers the basic semantic set
312
+ const missing = semanticBgs.filter(s => !keys.includes(s))
313
+ const rel = relative(root, file)
314
+ if (missing.length > 0 && keys.length >= 3) {
315
+ // Only warn if the map has at least 3 entries (skip small purpose-specific maps)
316
+ console.log(` ~ ${rel}: ${mapName} missing: ${missing.join(', ')}`)
317
+ warns.push(`${rel}: ${mapName} missing semantic colors: ${missing.join(', ')}`)
318
+ mapIssues++
319
+ }
320
+ }
321
+ }
322
+
323
+ if (mapIssues === 0) {
324
+ console.log(` All component color maps cover semantic colors. OK`)
325
+ }
326
+
327
+ // --- Summary ---
328
+
329
+ console.log('\n' + '='.repeat(76))
330
+ console.log('SUMMARY')
331
+ console.log('='.repeat(76))
332
+
333
+ const stats = [
334
+ `Files scanned: ${scanFiles.length}`,
335
+ `Hex leaks: ${hexLeaks}`,
336
+ `Undefined vars: ${undefinedRefs}`,
337
+ `Slide color leaks: ${slideColorLeaks}`,
338
+ `color-mix hex: ${colorMixHexCount}`,
339
+ `Missing foreground pairs: ${missingFg}`,
340
+ ]
341
+ console.log(`\n ${stats.join(' | ')}`)
342
+
343
+ if (issues.length === 0 && warns.length === 0) {
344
+ console.log('\n All integrity checks passed!')
345
+ } else {
346
+ if (issues.length > 0) {
347
+ console.log(`\n ${issues.length} FAIL(s):`)
348
+ issues.forEach((issue, i) => console.log(` ${i + 1}. ${issue}`))
349
+ }
350
+ if (warns.length > 0) {
351
+ console.log(`\n ${warns.length} WARN(s):`)
352
+ warns.forEach((w, i) => console.log(` ${i + 1}. ${w}`))
353
+ }
354
+ }
355
+
356
+ console.log()
357
+ process.exit(issues.length > 0 ? 1 : 0)
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Shared CSS utilities for accessibility audit scripts.
3
+ *
4
+ * Color math (WCAG 2.x + APCA), CSS parsing, formatting.
5
+ */
6
+
7
+ import { readFileSync } from 'fs'
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // sRGB → linear channel
11
+ // ---------------------------------------------------------------------------
12
+
13
+ export function hexToLinear(hex) {
14
+ hex = hex.replace('#', '')
15
+ if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]
16
+ const r = parseInt(hex.slice(0, 2), 16) / 255
17
+ const g = parseInt(hex.slice(2, 4), 16) / 255
18
+ const b = parseInt(hex.slice(4, 6), 16) / 255
19
+ return [r, g, b].map(c =>
20
+ c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
21
+ )
22
+ }
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // WCAG 2.x relative luminance
26
+ // ---------------------------------------------------------------------------
27
+
28
+ export function relativeLuminance(hex) {
29
+ const [r, g, b] = hexToLinear(hex)
30
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // WCAG 2.x contrast ratio
35
+ // ---------------------------------------------------------------------------
36
+
37
+ export function contrastRatio(hex1, hex2) {
38
+ const L1 = relativeLuminance(hex1)
39
+ const L2 = relativeLuminance(hex2)
40
+ const lighter = Math.max(L1, L2)
41
+ const darker = Math.min(L1, L2)
42
+ return (lighter + 0.05) / (darker + 0.05)
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // APCA (Accessible Perceptual Contrast Algorithm) — Lc value
47
+ //
48
+ // Public domain formula from Myndex/SAPC-APCA.
49
+ // Returns signed Lc: positive = dark-on-light, negative = light-on-dark.
50
+ // Magnitude is what matters for thresholds (use Math.abs).
51
+ // ---------------------------------------------------------------------------
52
+
53
+ export function apcaContrast(fgHex, bgHex) {
54
+ // sRGB → Y (linearised luminance) with APCA coefficients
55
+ function sRGBtoY(hex) {
56
+ const [rL, gL, bL] = hexToLinear(hex)
57
+ // APCA uses slightly different coefficients than WCAG 2.x
58
+ let Y = 0.2126729 * rL + 0.7151522 * gL + 0.0721750 * bL
59
+ // Soft-clamp black
60
+ if (Y < 0.022) Y += Math.pow(0.022 - Y, 1.414)
61
+ return Y
62
+ }
63
+
64
+ const Ytxt = sRGBtoY(fgHex)
65
+ const Ybg = sRGBtoY(bgHex)
66
+
67
+ let Sapc
68
+ // Normal polarity: dark text on light bg
69
+ if (Ybg > Ytxt) {
70
+ Sapc = (Math.pow(Ybg, 0.56) - Math.pow(Ytxt, 0.57)) * 1.14
71
+ } else {
72
+ // Reverse polarity: light text on dark bg
73
+ Sapc = (Math.pow(Ybg, 0.65) - Math.pow(Ytxt, 0.62)) * 1.14
74
+ }
75
+
76
+ // Clamp low contrast
77
+ if (Math.abs(Sapc) < 0.1) return 0
78
+
79
+ // Apply offset
80
+ if (Sapc > 0) {
81
+ Sapc -= 0.027
82
+ } else {
83
+ Sapc += 0.027
84
+ }
85
+
86
+ return Sapc * 100
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // CSS parsing
91
+ // ---------------------------------------------------------------------------
92
+
93
+ /** Find the index of the closing brace that matches the opening `{` after startOfBlock. */
94
+ export function findMatchingBrace(str, startOfBlock) {
95
+ const braceStart = str.indexOf('{', startOfBlock)
96
+ if (braceStart < 0) return -1
97
+ let depth = 0
98
+ for (let i = braceStart; i < str.length; i++) {
99
+ if (str[i] === '{') depth++
100
+ if (str[i] === '}') { depth--; if (depth === 0) return i }
101
+ }
102
+ return -1
103
+ }
104
+
105
+ /** Extract hex palette `--color-{name}: #{hex};` from colors.css. */
106
+ export function parsePalette(cssPath) {
107
+ const css = readFileSync(cssPath, 'utf8')
108
+ const palette = {}
109
+ const re = /--color-([\w-]+):\s*#([0-9a-fA-F]{3,8})\s*;/g
110
+ let m
111
+ while ((m = re.exec(css)) !== null) {
112
+ palette[m[1]] = '#' + m[2]
113
+ }
114
+ return palette
115
+ }
116
+
117
+ /** Extract light/dark semantic `var()` maps from colors.css. */
118
+ export function parseSemanticTokens(cssPath) {
119
+ const css = readFileSync(cssPath, 'utf8')
120
+
121
+ const darkStart = css.indexOf('.dark {')
122
+ const darkEnd = darkStart >= 0 ? findMatchingBrace(css, darkStart) : -1
123
+
124
+ function extractVarRefs(text) {
125
+ const tokens = {}
126
+ const re = /--color-([\w-]+):\s*var\(--color-([\w-]+)\)\s*;/g
127
+ let m
128
+ while ((m = re.exec(text)) !== null) {
129
+ tokens[m[1]] = m[2]
130
+ }
131
+ return tokens
132
+ }
133
+
134
+ const darkBlock = darkStart >= 0 && darkEnd >= 0
135
+ ? css.slice(darkStart, darkEnd + 1)
136
+ : ''
137
+
138
+ const lightBlock = darkStart >= 0
139
+ ? css.slice(0, darkStart) + css.slice(darkEnd + 1)
140
+ : css
141
+
142
+ const light = extractVarRefs(lightBlock)
143
+ const dark = { ...light, ...extractVarRefs(darkBlock) }
144
+
145
+ return { light, dark }
146
+ }
147
+
148
+ /** Parse chart-N vars: `--chart-N: var(--color-XXX);` from colors.css. */
149
+ export function parseChartTokens(cssPath) {
150
+ const css = readFileSync(cssPath, 'utf8')
151
+
152
+ const darkStart = css.indexOf('.dark {')
153
+ const darkEnd = darkStart >= 0 ? findMatchingBrace(css, darkStart) : -1
154
+
155
+ function extractCharts(text) {
156
+ const tokens = {}
157
+ const re = /--chart-(\d+):\s*var\(--color-([\w-]+)\)\s*;/g
158
+ let m
159
+ while ((m = re.exec(text)) !== null) {
160
+ tokens[parseInt(m[1])] = m[2]
161
+ }
162
+ return tokens
163
+ }
164
+
165
+ const darkBlock = darkStart >= 0 && darkEnd >= 0
166
+ ? css.slice(darkStart, darkEnd + 1)
167
+ : ''
168
+ const lightBlock = darkStart >= 0
169
+ ? css.slice(0, darkStart) + css.slice(darkEnd + 1)
170
+ : css
171
+
172
+ const lightCharts = extractCharts(lightBlock)
173
+ const darkCharts = { ...lightCharts, ...extractCharts(darkBlock) }
174
+
175
+ return { light: lightCharts, dark: darkCharts }
176
+ }
177
+
178
+ /** Resolve a semantic var name to hex via palette. */
179
+ export function resolveHex(palette, semanticMap, varName) {
180
+ const paletteRef = semanticMap[varName]
181
+ if (!paletteRef) return null
182
+ return palette[paletteRef] || null
183
+ }
184
+
185
+ /** Extract a single CSS custom property value (raw string) from CSS text. */
186
+ export function parseCssVar(css, varName) {
187
+ const re = new RegExp(`${varName}:\\s*([^;]+)\\s*;`)
188
+ const m = css.match(re)
189
+ return m ? m[1].trim() : null
190
+ }
191
+
192
+ /** Find all matches of a regex pattern in CSS text. Returns array of match objects. */
193
+ export function scanCssPattern(css, pattern) {
194
+ const re = pattern instanceof RegExp ? pattern : new RegExp(pattern, 'g')
195
+ const matches = []
196
+ let m
197
+ while ((m = re.exec(css)) !== null) {
198
+ matches.push(m)
199
+ }
200
+ return matches
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // Formatting helpers
205
+ // ---------------------------------------------------------------------------
206
+
207
+ /** Format WCAG 2.x ratio as "N.NN:1". */
208
+ export function fmt(ratio) {
209
+ return ratio.toFixed(2) + ':1'
210
+ }
211
+
212
+ /** Format APCA Lc as "Lc NN". */
213
+ export function fmtLc(lc) {
214
+ const abs = Math.abs(lc)
215
+ return 'Lc ' + (abs < 10 ? ' ' : '') + abs.toFixed(0)
216
+ }