claude-presentation-master 6.1.0 → 7.2.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.
@@ -0,0 +1,279 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Automatic Presentation QA Review System
4
+ *
5
+ * Captures screenshots of every slide and performs automated visual analysis.
6
+ * Reports issues and scores each slide against quality criteria.
7
+ *
8
+ * Usage: node qa-presentation.cjs <html-file>
9
+ */
10
+
11
+ const { chromium } = require('playwright');
12
+ const path = require('path');
13
+ const fs = require('fs');
14
+
15
+ const QA_CRITERIA = {
16
+ // Text readability
17
+ MIN_TITLE_SIZE: 40, // px - titles should be large
18
+ MIN_BODY_SIZE: 16, // px - body text readable
19
+ MAX_WORDS_PER_SLIDE: 50, // Glance test
20
+
21
+ // Visual balance
22
+ MIN_WHITESPACE_RATIO: 0.3, // 30% whitespace minimum
23
+ MAX_ELEMENTS: 7, // Miller's Law
24
+
25
+ // Contrast
26
+ MIN_CONTRAST_RATIO: 4.5, // WCAG AA
27
+ };
28
+
29
+ async function captureAndAnalyzeSlides(htmlPath) {
30
+ const browser = await chromium.launch();
31
+ const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } });
32
+
33
+ const absolutePath = path.resolve(htmlPath);
34
+ await page.goto(`file://${absolutePath}`, { waitUntil: 'networkidle' });
35
+ await page.waitForTimeout(3000); // Wait for images and animations
36
+
37
+ // Get total slides
38
+ const totalSlides = await page.evaluate(() => {
39
+ if (typeof Reveal !== 'undefined') {
40
+ return Reveal.getTotalSlides();
41
+ }
42
+ return document.querySelectorAll('.slides > section').length || 10;
43
+ });
44
+
45
+ console.log('\n' + '='.repeat(60));
46
+ console.log(' AUTOMATIC PRESENTATION QA REVIEW');
47
+ console.log('='.repeat(60));
48
+ console.log(`\nAnalyzing ${totalSlides} slides...\n`);
49
+
50
+ const results = [];
51
+ const screenshotDir = path.join(path.dirname(absolutePath), 'qa-screenshots');
52
+
53
+ if (!fs.existsSync(screenshotDir)) {
54
+ fs.mkdirSync(screenshotDir, { recursive: true });
55
+ }
56
+
57
+ for (let i = 0; i < totalSlides; i++) {
58
+ const screenshotPath = path.join(screenshotDir, `slide-${i + 1}.png`);
59
+ await page.screenshot({ path: screenshotPath });
60
+
61
+ // Analyze the current slide
62
+ const analysis = await page.evaluate(() => {
63
+ const slide = document.querySelector('.present') || document.querySelector('section');
64
+ if (!slide) return { error: 'No slide found' };
65
+
66
+ // Get all text content
67
+ const textContent = slide.innerText || '';
68
+ const wordCount = textContent.split(/\s+/).filter(w => w.length > 0).length;
69
+
70
+ // Helper to parse RGB color
71
+ function parseColor(colorStr) {
72
+ const match = colorStr.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
73
+ if (match) {
74
+ return { r: parseInt(match[1]), g: parseInt(match[2]), b: parseInt(match[3]) };
75
+ }
76
+ return null;
77
+ }
78
+
79
+ // Calculate relative luminance
80
+ function getLuminance(rgb) {
81
+ const [r, g, b] = [rgb.r, rgb.g, rgb.b].map(c => {
82
+ c = c / 255;
83
+ return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
84
+ });
85
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
86
+ }
87
+
88
+ // Calculate contrast ratio
89
+ function getContrastRatio(fg, bg) {
90
+ const l1 = getLuminance(fg);
91
+ const l2 = getLuminance(bg);
92
+ const lighter = Math.max(l1, l2);
93
+ const darker = Math.min(l1, l2);
94
+ return (lighter + 0.05) / (darker + 0.05);
95
+ }
96
+
97
+ // Get background color (walk up to find non-transparent)
98
+ let bgColor = null;
99
+ let el = slide;
100
+ while (el && !bgColor) {
101
+ const bg = window.getComputedStyle(el).backgroundColor;
102
+ const parsed = parseColor(bg);
103
+ if (parsed && (parsed.r > 0 || parsed.g > 0 || parsed.b > 0)) {
104
+ bgColor = parsed;
105
+ }
106
+ el = el.parentElement;
107
+ }
108
+ if (!bgColor) bgColor = { r: 10, g: 10, b: 11 }; // Default dark bg
109
+
110
+ // Get computed styles of text elements and check contrast
111
+ const titles = slide.querySelectorAll('h1, h2, h3, .display-xl, .display-lg, .display-md, .title');
112
+ const titleData = Array.from(titles).map(t => {
113
+ const style = window.getComputedStyle(t);
114
+ const color = parseColor(style.color);
115
+ const contrast = color ? getContrastRatio(color, bgColor) : 0;
116
+ return {
117
+ size: parseFloat(style.fontSize),
118
+ contrast: contrast,
119
+ text: t.textContent.substring(0, 30)
120
+ };
121
+ });
122
+
123
+ const bodyText = slide.querySelectorAll('p, li, .body-lg, .stat-label, .subtitle');
124
+ const bodyData = Array.from(bodyText).map(t => {
125
+ const style = window.getComputedStyle(t);
126
+ const color = parseColor(style.color);
127
+ const contrast = color ? getContrastRatio(color, bgColor) : 0;
128
+ return {
129
+ size: parseFloat(style.fontSize),
130
+ contrast: contrast
131
+ };
132
+ });
133
+
134
+ // Count major elements
135
+ const majorElements = slide.querySelectorAll('h1, h2, h3, .metric-card, .challenge-item, .timeline-phase, .invest-bar, .result-item, img, .chart-container');
136
+
137
+ // Check for empty content
138
+ const hasContent = textContent.trim().length > 10;
139
+
140
+ return {
141
+ wordCount,
142
+ titleData,
143
+ bodyData,
144
+ titleSizes: titleData.map(t => t.size),
145
+ bodySizes: bodyData.map(t => t.size),
146
+ titleContrasts: titleData.map(t => t.contrast),
147
+ bodyContrasts: bodyData.map(t => t.contrast),
148
+ elementCount: majorElements.length,
149
+ hasContent,
150
+ bgColorRGB: bgColor,
151
+ textPreview: textContent.substring(0, 100).replace(/\s+/g, ' ')
152
+ };
153
+ });
154
+
155
+ // Score this slide
156
+ const issues = [];
157
+ let score = 100;
158
+
159
+ // Check word count (glance test)
160
+ if (analysis.wordCount > QA_CRITERIA.MAX_WORDS_PER_SLIDE) {
161
+ issues.push(`Too many words: ${analysis.wordCount} (max ${QA_CRITERIA.MAX_WORDS_PER_SLIDE})`);
162
+ score -= 15;
163
+ }
164
+
165
+ if (analysis.wordCount < 3) {
166
+ issues.push(`Too sparse: only ${analysis.wordCount} words`);
167
+ score -= 10;
168
+ }
169
+
170
+ // Check title sizes
171
+ const smallTitles = (analysis.titleSizes || []).filter(s => s < QA_CRITERIA.MIN_TITLE_SIZE);
172
+ if (smallTitles.length > 0) {
173
+ issues.push(`Small title text: ${smallTitles.map(s => Math.round(s) + 'px').join(', ')}`);
174
+ score -= 10;
175
+ }
176
+
177
+ // Check body text sizes
178
+ const smallBody = (analysis.bodySizes || []).filter(s => s < QA_CRITERIA.MIN_BODY_SIZE);
179
+ if (smallBody.length > 0) {
180
+ issues.push(`Small body text: ${smallBody.length} elements under ${QA_CRITERIA.MIN_BODY_SIZE}px`);
181
+ score -= 5;
182
+ }
183
+
184
+ // Check element count (Miller's Law)
185
+ if (analysis.elementCount > QA_CRITERIA.MAX_ELEMENTS) {
186
+ issues.push(`Too many elements: ${analysis.elementCount} (max ${QA_CRITERIA.MAX_ELEMENTS})`);
187
+ score -= 10;
188
+ }
189
+
190
+ // Check for content
191
+ if (!analysis.hasContent) {
192
+ issues.push('Slide appears empty or has minimal content');
193
+ score -= 20;
194
+ }
195
+
196
+ // CHECK CONTRAST - CRITICAL
197
+ const lowContrastTitles = (analysis.titleContrasts || []).filter(c => c < 4.5);
198
+ if (lowContrastTitles.length > 0) {
199
+ const worstContrast = Math.min(...lowContrastTitles).toFixed(1);
200
+ issues.push(`LOW CONTRAST: ${lowContrastTitles.length} title(s) below 4.5:1 (worst: ${worstContrast}:1)`);
201
+ score -= 25; // Major penalty - this is a critical failure
202
+ }
203
+
204
+ const lowContrastBody = (analysis.bodyContrasts || []).filter(c => c < 3.0);
205
+ if (lowContrastBody.length > 0) {
206
+ issues.push(`Low contrast body text: ${lowContrastBody.length} elements below 3:1`);
207
+ score -= 15;
208
+ }
209
+
210
+ const slideResult = {
211
+ slide: i + 1,
212
+ score: Math.max(0, score),
213
+ issues,
214
+ analysis,
215
+ screenshot: screenshotPath
216
+ };
217
+
218
+ results.push(slideResult);
219
+
220
+ // Print result
221
+ const status = score >= 90 ? '✅' : score >= 70 ? '⚠️' : '❌';
222
+ console.log(`Slide ${i + 1}: ${status} Score: ${Math.max(0, score)}/100`);
223
+ if (issues.length > 0) {
224
+ issues.forEach(issue => console.log(` └─ ${issue}`));
225
+ }
226
+
227
+ // Next slide
228
+ await page.keyboard.press('ArrowRight');
229
+ await page.waitForTimeout(1500); // Longer wait for animations and images
230
+ }
231
+
232
+ await browser.close();
233
+
234
+ // Summary
235
+ const avgScore = Math.round(results.reduce((sum, r) => sum + r.score, 0) / results.length);
236
+ const passedSlides = results.filter(r => r.score >= 80).length;
237
+ const failedSlides = results.filter(r => r.score < 70).length;
238
+
239
+ console.log('\n' + '='.repeat(60));
240
+ console.log(' SUMMARY');
241
+ console.log('='.repeat(60));
242
+ console.log(`\n Average Score: ${avgScore}/100`);
243
+ console.log(` Passed (80+): ${passedSlides}/${totalSlides}`);
244
+ console.log(` Failed (<70): ${failedSlides}/${totalSlides}`);
245
+ console.log(` Screenshots: ${screenshotDir}/`);
246
+
247
+ if (avgScore >= 90) {
248
+ console.log('\n ✅ PRESENTATION PASSED QA\n');
249
+ } else if (avgScore >= 70) {
250
+ console.log('\n ⚠️ PRESENTATION NEEDS IMPROVEMENT\n');
251
+ } else {
252
+ console.log('\n ❌ PRESENTATION FAILED QA\n');
253
+ }
254
+
255
+ // Write detailed report
256
+ const reportPath = path.join(screenshotDir, 'qa-report.json');
257
+ fs.writeFileSync(reportPath, JSON.stringify({
258
+ file: htmlPath,
259
+ timestamp: new Date().toISOString(),
260
+ summary: { avgScore, passedSlides, failedSlides, totalSlides },
261
+ slides: results
262
+ }, null, 2));
263
+
264
+ console.log(` Report saved: ${reportPath}\n`);
265
+
266
+ return { avgScore, results };
267
+ }
268
+
269
+ // Run if called directly
270
+ const htmlFile = process.argv[2];
271
+ if (!htmlFile) {
272
+ console.error('Usage: node qa-presentation.cjs <html-file>');
273
+ process.exit(1);
274
+ }
275
+
276
+ captureAndAnalyzeSlides(htmlFile).catch(err => {
277
+ console.error('QA Error:', err.message);
278
+ process.exit(1);
279
+ });