@badreddinekarama/csspow 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/bin/csspow.js ADDED
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * csspoison — CSS Chaos Engineering CLI
4
+ * Author: Badreddine Karama
5
+ * Usage: npx csspoison run <url> [options]
6
+ */
7
+
8
+ import { program } from 'commander';
9
+ import { fileURLToPath } from 'url';
10
+ import { dirname, join } from 'path';
11
+ import { readFileSync } from 'fs';
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+
15
+ let version;
16
+ try {
17
+ const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
18
+ version = pkg.version;
19
+ } catch {
20
+ version = '0.1.0';
21
+ }
22
+
23
+ program
24
+ .name('csspoison')
25
+ .description('☣ CSS chaos engineering — stress-test your UI in seconds\n by Badreddine Karama')
26
+ .version(version);
27
+
28
+ program
29
+ .command('run <url>')
30
+ .description('Run CSS chaos modes against a URL')
31
+ .option('-m, --mode <preset>', 'Preset to use: light | chaos | brutal | a11y', 'chaos')
32
+ .option('--modes <list>', 'Comma-separated modes: rtl,zoom,font-chaos,etc')
33
+ .option('--record', 'Save screenshots and generate HTML report', false)
34
+ .option('--headless', 'Run in headless mode (default: true)', true)
35
+ .option('--no-headless', 'Show the browser window')
36
+ .option('--output <dir>', 'Output directory for report', './csspoison-report')
37
+ .option('--timeout <ms>', 'Page load timeout in milliseconds', '30000')
38
+ .action(async (url, opts) => {
39
+ const { run } = await import('../src/run.js');
40
+ await run(url, opts);
41
+ });
42
+
43
+ program
44
+ .command('modes')
45
+ .description('List all available chaos modes')
46
+ .action(async () => {
47
+ const { listModes } = await import('../src/list-modes.js');
48
+ listModes();
49
+ });
50
+
51
+ program
52
+ .command('presets')
53
+ .description('List all available presets')
54
+ .action(async () => {
55
+ const { listPresets } = await import('../src/list-presets.js');
56
+ listPresets();
57
+ });
58
+
59
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@badreddinekarama/csspow",
3
+ "version": "0.1.0",
4
+ "description": "Zero-install CLI for CSS chaos testing — stress-test your UI in seconds",
5
+ "author": "Badreddine Karama",
6
+ "type": "module",
7
+ "bin": {
8
+ "csspow": "./bin/csspow.js"
9
+ },
10
+ "main": "./src/index.js",
11
+ "exports": {
12
+ ".": "./src/index.js"
13
+ },
14
+ "files": [
15
+ "bin",
16
+ "src",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "scripts": {
21
+ "start": "node bin/csspow.js",
22
+ "test": "echo \"No tests yet\"",
23
+ "lint": "eslint src/**/*.js"
24
+ },
25
+ "dependencies": {
26
+ "commander": "^12.1.0",
27
+ "playwright": "^1.52.0",
28
+ "chalk": "^5.4.1",
29
+ "ora": "^8.2.0"
30
+ },
31
+ "keywords": ["css", "testing", "chaos", "playwright", "ui-testing", "cli", "accessibility"],
32
+ "license": "MIT",
33
+ "engines": {
34
+ "node": ">=18.0.0"
35
+ }
36
+ }
package/src/core.js ADDED
@@ -0,0 +1,206 @@
1
+ /**
2
+ * csspow — CSS Chaos Engine
3
+ * Author: Badreddine Karama
4
+ *
5
+ * Pure JS poison engine — safe to inject into any browser context.
6
+ * No Node.js APIs used here.
7
+ */
8
+
9
+ export const MODES = {
10
+ rtl: {
11
+ name: 'RTL Flip',
12
+ description: 'Forces right-to-left text direction on the entire page',
13
+ category: 'layout',
14
+ apply(ctx) {
15
+ ctx.originalDir = document.documentElement.dir;
16
+ document.documentElement.dir = 'rtl';
17
+ },
18
+ revert(ctx) {
19
+ document.documentElement.dir = ctx.originalDir || '';
20
+ },
21
+ },
22
+
23
+ zoom: {
24
+ name: '200% Zoom',
25
+ description: 'Doubles the browser zoom level, exposing layout overflow issues',
26
+ category: 'viewport',
27
+ apply(ctx) {
28
+ ctx.originalZoom = document.body.style.zoom;
29
+ document.body.style.zoom = '200%';
30
+ },
31
+ revert(ctx) {
32
+ document.body.style.zoom = ctx.originalZoom || '';
33
+ },
34
+ },
35
+
36
+ 'font-chaos': {
37
+ name: 'Font Chaos',
38
+ description: 'Replaces all fonts with random fallback families (Comic Sans, cursive, monospace)',
39
+ category: 'visual',
40
+ apply(ctx) {
41
+ const fonts = [
42
+ 'Comic Sans MS, cursive',
43
+ 'monospace',
44
+ 'fantasy',
45
+ 'Papyrus, fantasy',
46
+ 'Courier New, monospace',
47
+ ];
48
+ const chosen = fonts[Math.floor(Math.random() * fonts.length)];
49
+ const style = document.createElement('style');
50
+ style.id = '__csspow_font';
51
+ style.textContent = `* { font-family: ${chosen} !important; }`;
52
+ document.head.appendChild(style);
53
+ ctx.fontStyleEl = style;
54
+ },
55
+ revert(ctx) {
56
+ if (ctx.fontStyleEl) ctx.fontStyleEl.remove();
57
+ },
58
+ },
59
+
60
+ 'spacing-chaos': {
61
+ name: 'Spacing Chaos',
62
+ description: 'Applies random padding and margins to expose fragile layouts',
63
+ category: 'layout',
64
+ apply(ctx) {
65
+ const els = Array.from(document.querySelectorAll('div, section, article, header, footer, nav, main, aside'));
66
+ ctx.spacingOriginals = els.map(el => ({ el, padding: el.style.padding, margin: el.style.margin }));
67
+ const rand = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
68
+ els.forEach(el => {
69
+ el.style.padding = `${rand(0, 32)}px ${rand(0, 48)}px`;
70
+ el.style.margin = `${rand(0, 24)}px`;
71
+ });
72
+ },
73
+ revert(ctx) {
74
+ if (ctx.spacingOriginals) {
75
+ ctx.spacingOriginals.forEach(({ el, padding, margin }) => {
76
+ el.style.padding = padding;
77
+ el.style.margin = margin;
78
+ });
79
+ }
80
+ },
81
+ },
82
+
83
+ 'overflow-chaos': {
84
+ name: 'Overflow Chaos',
85
+ description: 'Forces narrow widths on containers to trigger text clipping and overflow',
86
+ category: 'layout',
87
+ apply(ctx) {
88
+ const style = document.createElement('style');
89
+ style.id = '__csspow_overflow';
90
+ style.textContent = `
91
+ div, section, article, p, span, a, li {
92
+ max-width: 200px !important;
93
+ overflow: visible !important;
94
+ }
95
+ `;
96
+ document.head.appendChild(style);
97
+ ctx.overflowStyleEl = style;
98
+ },
99
+ revert(ctx) {
100
+ if (ctx.overflowStyleEl) ctx.overflowStyleEl.remove();
101
+ },
102
+ },
103
+
104
+ contrast: {
105
+ name: 'High Contrast',
106
+ description: 'Forces extreme black/white contrast to test accessibility and legibility',
107
+ category: 'accessibility',
108
+ apply(ctx) {
109
+ const style = document.createElement('style');
110
+ style.id = '__csspow_contrast';
111
+ style.textContent = `
112
+ * { background-color: black !important; color: white !important; border-color: white !important; }
113
+ img, svg, video, canvas { filter: invert(1) !important; }
114
+ a { color: yellow !important; }
115
+ `;
116
+ document.head.appendChild(style);
117
+ ctx.contrastStyleEl = style;
118
+ },
119
+ revert(ctx) {
120
+ if (ctx.contrastStyleEl) ctx.contrastStyleEl.remove();
121
+ },
122
+ },
123
+
124
+ 'motion-reduce': {
125
+ name: 'Reduce Motion',
126
+ description: 'Disables all animations and transitions',
127
+ category: 'accessibility',
128
+ apply(ctx) {
129
+ const style = document.createElement('style');
130
+ style.id = '__csspow_motion';
131
+ style.textContent = `
132
+ *, *::before, *::after {
133
+ animation-duration: 0.001ms !important;
134
+ animation-delay: 0ms !important;
135
+ transition-duration: 0.001ms !important;
136
+ transition-delay: 0ms !important;
137
+ }
138
+ `;
139
+ document.head.appendChild(style);
140
+ ctx.motionStyleEl = style;
141
+ },
142
+ revert(ctx) {
143
+ if (ctx.motionStyleEl) ctx.motionStyleEl.remove();
144
+ },
145
+ },
146
+
147
+ 'layout-break': {
148
+ name: 'Layout Break',
149
+ description: 'Randomly changes display types (flex/block/grid) to expose fragile component structure',
150
+ category: 'layout',
151
+ apply(ctx) {
152
+ const displays = ['block', 'flex', 'grid', 'inline-block'];
153
+ const els = Array.from(document.querySelectorAll('div, section, header, nav, main, footer, aside, article'));
154
+ ctx.layoutOriginals = els.map(el => ({ el, display: el.style.display }));
155
+ els.forEach(el => {
156
+ el.style.display = displays[Math.floor(Math.random() * displays.length)];
157
+ });
158
+ },
159
+ revert(ctx) {
160
+ if (ctx.layoutOriginals) {
161
+ ctx.layoutOriginals.forEach(({ el, display }) => {
162
+ el.style.display = display;
163
+ });
164
+ }
165
+ },
166
+ },
167
+ };
168
+
169
+ export function detectOverflows() {
170
+ const overflowing = [];
171
+ const els = document.querySelectorAll('*');
172
+ for (const el of els) {
173
+ if (el.scrollWidth > el.clientWidth + 2) {
174
+ const tag = el.tagName.toLowerCase();
175
+ const id = el.id ? `#${el.id}` : '';
176
+ const cls = el.className && typeof el.className === 'string'
177
+ ? `.${el.className.trim().split(/\s+/).slice(0, 2).join('.')}`
178
+ : '';
179
+ overflowing.push(`${tag}${id}${cls}`);
180
+ }
181
+ }
182
+ return [...new Set(overflowing)].slice(0, 20);
183
+ }
184
+
185
+ export function poison(doc, options = {}) {
186
+ const { modes = [] } = options;
187
+ const results = [];
188
+ for (const modeId of modes) {
189
+ const mode = MODES[modeId];
190
+ if (!mode) {
191
+ results.push({ mode: modeId, applied: false, error: `Unknown mode: ${modeId}` });
192
+ continue;
193
+ }
194
+ const ctx = {};
195
+ try {
196
+ mode.apply(ctx);
197
+ const overflows = detectOverflows();
198
+ results.push({ mode: modeId, applied: true, overflows, overflowCount: overflows.length });
199
+ } catch (err) {
200
+ results.push({ mode: modeId, applied: false, error: err.message });
201
+ }
202
+ }
203
+ return results;
204
+ }
205
+
206
+ export const MODE_IDS = Object.keys(MODES);
package/src/index.js ADDED
@@ -0,0 +1 @@
1
+ export { run } from './run.js';
@@ -0,0 +1,29 @@
1
+ import chalk from 'chalk';
2
+ import { MODES } from './core.js';
3
+
4
+ export function listModes() {
5
+ console.log('');
6
+ console.log(chalk.red.bold(' ☣ Available poison modes'));
7
+ console.log(chalk.gray(' ────────────────────────────────────────────────────'));
8
+
9
+ const byCategory = {};
10
+ for (const [id, mode] of Object.entries(MODES)) {
11
+ const cat = mode.category || 'other';
12
+ if (!byCategory[cat]) byCategory[cat] = [];
13
+ byCategory[cat].push({ id, ...mode });
14
+ }
15
+
16
+ for (const [cat, modes] of Object.entries(byCategory)) {
17
+ console.log('');
18
+ console.log(` ${chalk.bold(chalk.yellow(cat.toUpperCase()))}`);
19
+ for (const m of modes) {
20
+ console.log(` ${chalk.cyan(m.id.padEnd(18))} ${chalk.white(m.name)}`);
21
+ console.log(` ${' '.repeat(18)} ${chalk.gray(m.description)}`);
22
+ }
23
+ }
24
+
25
+ console.log('');
26
+ console.log(chalk.gray(' Usage:'));
27
+ console.log(` ${chalk.white('csspow run <url> --modes rtl,zoom,font-chaos')}`);
28
+ console.log('');
29
+ }
@@ -0,0 +1,20 @@
1
+ import chalk from 'chalk';
2
+ import { PRESETS } from './presets.js';
3
+
4
+ export function listPresets() {
5
+ console.log('');
6
+ console.log(chalk.red.bold(' ☣ Available presets'));
7
+ console.log(chalk.gray(' ─────────────────────────────────────────────────────'));
8
+
9
+ for (const [id, preset] of Object.entries(PRESETS)) {
10
+ console.log('');
11
+ console.log(` ${chalk.bold(chalk.yellow(id.padEnd(10)))} ${chalk.white(preset.name)}`);
12
+ console.log(` ${' '.repeat(10)} ${chalk.gray(preset.description)}`);
13
+ console.log(` ${' '.repeat(10)} Modes: ${chalk.cyan(preset.modes.join(', '))}`);
14
+ }
15
+
16
+ console.log('');
17
+ console.log(chalk.gray(' Usage:'));
18
+ console.log(` ${chalk.white('csspow run <url> --mode brutal')}`);
19
+ console.log('');
20
+ }
package/src/presets.js ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * csspow — Presets
3
+ * Author: Badreddine Karama
4
+ */
5
+
6
+ export const PRESETS = {
7
+ light: {
8
+ name: 'Light',
9
+ description: 'Subtle issues — zoom + fonts. Good for quick sanity checks.',
10
+ modes: ['zoom', 'font-chaos'],
11
+ },
12
+ chaos: {
13
+ name: 'Chaos',
14
+ description: 'Noticeable breakage — the standard experience.',
15
+ modes: ['rtl', 'zoom', 'spacing-chaos', 'font-chaos'],
16
+ },
17
+ brutal: {
18
+ name: 'Brutal',
19
+ description: 'Everything breaks. Maximum destruction.',
20
+ modes: ['rtl', 'zoom', 'font-chaos', 'spacing-chaos', 'overflow-chaos', 'contrast', 'motion-reduce', 'layout-break'],
21
+ },
22
+ a11y: {
23
+ name: 'A11y',
24
+ description: 'Accessibility-focused — tests color contrast, motion sensitivity, zoom.',
25
+ modes: ['contrast', 'motion-reduce', 'zoom'],
26
+ },
27
+ };
28
+
29
+ export function resolveModes(preset, modes) {
30
+ if (modes && modes.length > 0) return modes;
31
+ if (preset && PRESETS[preset]) return PRESETS[preset].modes;
32
+ return PRESETS.chaos.modes;
33
+ }
34
+
35
+ export const PRESET_NAMES = Object.keys(PRESETS);
@@ -0,0 +1,155 @@
1
+ /**
2
+ * csspow — HTML Report Generator
3
+ * Author: Badreddine Karama
4
+ */
5
+
6
+ import { writeFileSync, mkdirSync, copyFileSync, existsSync } from 'fs';
7
+ import { join } from 'path';
8
+
9
+ export function generateReport({ url, outputDir, screenshots, beforePath, durationMs }) {
10
+ mkdirSync(join(outputDir, 'screenshots'), { recursive: true });
11
+
12
+ const processedShots = screenshots.map(s => {
13
+ const filename = `${s.mode}.png`;
14
+ const dest = join(outputDir, 'screenshots', filename);
15
+ if (s.screenshotPath && existsSync(s.screenshotPath)) {
16
+ copyFileSync(s.screenshotPath, dest);
17
+ }
18
+ return { ...s, filename };
19
+ });
20
+
21
+ let beforeFilename = null;
22
+ if (beforePath && existsSync(beforePath)) {
23
+ beforeFilename = 'before.png';
24
+ copyFileSync(beforePath, join(outputDir, 'screenshots', beforeFilename));
25
+ }
26
+
27
+ const totalBreaks = processedShots.reduce((n, s) => n + (s.overflowCount || 0), 0);
28
+ const html = buildHTML({ url, screenshots: processedShots, beforeFilename, totalBreaks, durationMs });
29
+ writeFileSync(join(outputDir, 'index.html'), html, 'utf8');
30
+ return join(outputDir, 'index.html');
31
+ }
32
+
33
+ function buildHTML({ url, screenshots, beforeFilename, totalBreaks, durationMs }) {
34
+ const screenshotCards = screenshots.map(s => {
35
+ const breakBadge = s.overflowCount > 0
36
+ ? `<span class="badge bad">${s.overflowCount} overflow${s.overflowCount !== 1 ? 's' : ''}</span>`
37
+ : `<span class="badge ok">No overflows</span>`;
38
+
39
+ const overflowList = s.overflows && s.overflows.length > 0
40
+ ? `<div class="overflows"><strong>Overflowing elements:</strong><ul>${s.overflows.map(o => `<li><code>${escapeHtml(o)}</code></li>`).join('')}</ul></div>`
41
+ : '';
42
+
43
+ return `
44
+ <section class="card">
45
+ <div class="card-header">
46
+ <h2>${escapeHtml(s.modeName || s.mode)}</h2>
47
+ ${breakBadge}
48
+ </div>
49
+ <p class="description">${escapeHtml(s.modeDescription || '')}</p>
50
+ ${overflowList}
51
+ <div class="screenshot-wrap">
52
+ <img src="screenshots/${s.filename}" alt="${escapeHtml(s.mode)} screenshot" loading="lazy" />
53
+ </div>
54
+ </section>`;
55
+ }).join('\n');
56
+
57
+ const beforeSection = beforeFilename ? `
58
+ <section class="card baseline">
59
+ <div class="card-header">
60
+ <h2>Baseline</h2>
61
+ <span class="badge ok">Before</span>
62
+ </div>
63
+ <p class="description">Page appearance before any chaos was applied.</p>
64
+ <div class="screenshot-wrap">
65
+ <img src="screenshots/${beforeFilename}" alt="Baseline screenshot" loading="lazy" />
66
+ </div>
67
+ </section>` : '';
68
+
69
+ const duration = durationMs ? `${(durationMs / 1000).toFixed(1)}s` : '—';
70
+
71
+ return `<!DOCTYPE html>
72
+ <html lang="en">
73
+ <head>
74
+ <meta charset="UTF-8" />
75
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
76
+ <title>csspow report — ${escapeHtml(url)}</title>
77
+ <style>
78
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
79
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; background: #0d0d0d; color: #e8e8e8; line-height: 1.6; padding: 0 0 80px; }
80
+ header { background: linear-gradient(135deg, #1a0a00 0%, #0d0d0d 60%); border-bottom: 1px solid #2a2a2a; padding: 40px 32px; }
81
+ header .logo { font-size: 13px; font-weight: 700; letter-spacing: 0.2em; text-transform: uppercase; color: #ff4500; margin-bottom: 12px; }
82
+ header h1 { font-size: clamp(22px, 4vw, 36px); font-weight: 800; color: #fff; word-break: break-all; }
83
+ header h1 span { color: #ff6633; }
84
+ .meta { display: flex; flex-wrap: wrap; gap: 24px; margin-top: 24px; }
85
+ .meta-item { display: flex; flex-direction: column; gap: 2px; }
86
+ .meta-item .label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; color: #888; }
87
+ .meta-item .value { font-size: 20px; font-weight: 700; color: #fff; }
88
+ .meta-item .value.danger { color: #ff4500; }
89
+ .meta-item .value.ok { color: #3ddc84; }
90
+ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(540px, 1fr)); gap: 24px; padding: 32px; max-width: 1600px; margin: 0 auto; }
91
+ .card { background: #161616; border: 1px solid #252525; border-radius: 12px; overflow: hidden; }
92
+ .card.baseline { border-color: #1e3a2a; }
93
+ .card-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid #252525; gap: 12px; }
94
+ .card-header h2 { font-size: 16px; font-weight: 700; color: #fff; }
95
+ .badge { font-size: 11px; font-weight: 700; padding: 3px 10px; border-radius: 20px; white-space: nowrap; flex-shrink: 0; }
96
+ .badge.bad { background: rgba(255,69,0,0.15); color: #ff6633; border: 1px solid rgba(255,69,0,0.3); }
97
+ .badge.ok { background: rgba(61,220,132,0.1); color: #3ddc84; border: 1px solid rgba(61,220,132,0.2); }
98
+ .description { padding: 12px 20px 0; font-size: 13px; color: #888; }
99
+ .overflows { margin: 12px 20px 0; padding: 12px; background: rgba(255,69,0,0.05); border: 1px solid rgba(255,69,0,0.15); border-radius: 8px; font-size: 12px; }
100
+ .overflows strong { color: #ff6633; display: block; margin-bottom: 8px; }
101
+ .overflows ul { list-style: none; display: flex; flex-wrap: wrap; gap: 6px; }
102
+ .overflows code { background: rgba(255,69,0,0.1); padding: 2px 6px; border-radius: 4px; font-family: monospace; font-size: 11px; color: #ffaa88; }
103
+ .screenshot-wrap { padding: 20px; }
104
+ .screenshot-wrap img { width: 100%; height: auto; border-radius: 8px; border: 1px solid #252525; display: block; }
105
+ footer { text-align: center; padding: 32px; font-size: 12px; color: #444; border-top: 1px solid #1a1a1a; margin-top: 40px; }
106
+ footer a { color: #ff6633; text-decoration: none; }
107
+ footer .creator { color: #666; margin-top: 6px; font-size: 11px; }
108
+ @media (max-width: 600px) { .grid { grid-template-columns: 1fr; padding: 16px; } header { padding: 24px 16px; } }
109
+ </style>
110
+ </head>
111
+ <body>
112
+ <header>
113
+ <div class="logo">☣ csspow report</div>
114
+ <h1>Testing: <span>${escapeHtml(url)}</span></h1>
115
+ <div class="meta">
116
+ <div class="meta-item">
117
+ <span class="label">Modes tested</span>
118
+ <span class="value">${screenshots.length}</span>
119
+ </div>
120
+ <div class="meta-item">
121
+ <span class="label">Total overflows found</span>
122
+ <span class="value ${totalBreaks > 0 ? 'danger' : 'ok'}">${totalBreaks}</span>
123
+ </div>
124
+ <div class="meta-item">
125
+ <span class="label">Run time</span>
126
+ <span class="value">${duration}</span>
127
+ </div>
128
+ <div class="meta-item">
129
+ <span class="label">Generated</span>
130
+ <span class="value" style="font-size:14px">${new Date().toLocaleString()}</span>
131
+ </div>
132
+ </div>
133
+ </header>
134
+
135
+ <div class="grid">
136
+ ${beforeSection}
137
+ ${screenshotCards}
138
+ </div>
139
+
140
+ <footer>
141
+ Generated by <a href="https://github.com/csspow/csspow" target="_blank">☣ csspow</a>
142
+ <div class="creator">Created by <strong style="color:#ff6633">Badreddine Karama</strong></div>
143
+ </footer>
144
+ </body>
145
+ </html>`;
146
+ }
147
+
148
+ function escapeHtml(str) {
149
+ return String(str)
150
+ .replace(/&/g, '&amp;')
151
+ .replace(/</g, '&lt;')
152
+ .replace(/>/g, '&gt;')
153
+ .replace(/"/g, '&quot;')
154
+ .replace(/'/g, '&#39;');
155
+ }
package/src/run.js ADDED
@@ -0,0 +1,239 @@
1
+ /**
2
+ * csspow run command
3
+ * Launches Playwright, injects poison modes, captures screenshots, generates report.
4
+ */
5
+
6
+ import { chromium } from 'playwright';
7
+ import { setupPlaywrightEnv } from './setup-env.js';
8
+ import { mkdirSync, existsSync, writeFileSync } from 'fs';
9
+ import { join, resolve } from 'path';
10
+ import chalk from 'chalk';
11
+ import ora from 'ora';
12
+ import { MODES, MODE_IDS } from './core.js';
13
+ import { resolveModes, PRESETS } from './presets.js';
14
+ import { generateReport } from './reporter.js';
15
+
16
+ const POISON_INLINE = {
17
+ rtl: `document.documentElement.dir = 'rtl';`,
18
+ zoom: `document.body.style.zoom = '200%';`,
19
+ 'font-chaos': `
20
+ const fonts = ['Comic Sans MS, cursive','monospace','fantasy','Courier New, monospace'];
21
+ const s = document.createElement('style');
22
+ s.textContent = '* { font-family: ' + fonts[Math.floor(Math.random()*fonts.length)] + ' !important; }';
23
+ document.head.appendChild(s);
24
+ `,
25
+ 'spacing-chaos': `
26
+ const rand = (a,b) => Math.floor(Math.random()*(b-a+1))+a;
27
+ document.querySelectorAll('div,section,article,header,footer,nav,main,aside').forEach(el => {
28
+ el.style.padding = rand(0,32)+'px '+rand(0,48)+'px';
29
+ el.style.margin = rand(0,24)+'px';
30
+ });
31
+ `,
32
+ 'overflow-chaos': `
33
+ const s = document.createElement('style');
34
+ s.textContent = 'div,section,article,p,span,a,li{max-width:200px!important;overflow:visible!important;}';
35
+ document.head.appendChild(s);
36
+ `,
37
+ contrast: `
38
+ const s = document.createElement('style');
39
+ s.textContent = '*{background-color:black!important;color:white!important;border-color:white!important;}img,svg,video,canvas{filter:invert(1)!important;}a{color:yellow!important;}';
40
+ document.head.appendChild(s);
41
+ `,
42
+ 'motion-reduce': `
43
+ const s = document.createElement('style');
44
+ s.textContent = '*,*::before,*::after{animation-duration:0.001ms!important;transition-duration:0.001ms!important;}';
45
+ document.head.appendChild(s);
46
+ `,
47
+ 'layout-break': `
48
+ const displays=['block','flex','grid','inline-block'];
49
+ document.querySelectorAll('div,section,header,nav,main,footer,aside,article').forEach(el => {
50
+ el.style.display = displays[Math.floor(Math.random()*displays.length)];
51
+ });
52
+ `,
53
+ };
54
+
55
+ const DETECT_OVERFLOWS = `
56
+ (() => {
57
+ const overflowing = [];
58
+ for (const el of document.querySelectorAll('*')) {
59
+ if (el.scrollWidth > el.clientWidth + 2) {
60
+ const tag = el.tagName.toLowerCase();
61
+ const id = el.id ? '#'+el.id : '';
62
+ const cls = el.className && typeof el.className === 'string'
63
+ ? '.'+el.className.trim().split(/\\s+/).slice(0,2).join('.')
64
+ : '';
65
+ overflowing.push(tag+id+cls);
66
+ }
67
+ }
68
+ return [...new Set(overflowing)].slice(0, 20);
69
+ })()
70
+ `;
71
+
72
+ export async function run(url, opts) {
73
+ const startTime = Date.now();
74
+
75
+ // Set up library paths for Playwright's Chromium (needed in Nix/NixOS environments)
76
+ setupPlaywrightEnv();
77
+
78
+ // Normalize URL
79
+ if (!url.startsWith('http://') && !url.startsWith('https://')) {
80
+ url = 'https://' + url;
81
+ }
82
+
83
+ // Resolve modes
84
+ const rawModes = opts.modes ? opts.modes.split(',').map(m => m.trim()) : null;
85
+ const modes = resolveModes(rawModes ? null : opts.mode, rawModes);
86
+
87
+ const outputDir = resolve(opts.output || './csspow-report');
88
+ const shouldRecord = opts.record !== false;
89
+
90
+ console.log('');
91
+ console.log(chalk.red.bold(' ☣ csspow') + chalk.gray(' by Badreddine Karama'));
92
+ console.log(chalk.gray(' ─────────────────────────────────────'));
93
+ console.log(` ${chalk.bold('URL:')} ${chalk.cyan(url)}`);
94
+ console.log(` ${chalk.bold('Modes:')} ${chalk.yellow(modes.join(', '))}`);
95
+ console.log(` ${chalk.bold('Record:')} ${shouldRecord ? chalk.green('yes → ' + outputDir) : chalk.gray('no')}`);
96
+ console.log(chalk.gray(' ─────────────────────────────────────'));
97
+ console.log('');
98
+
99
+ const spinner = ora({ color: 'red' });
100
+
101
+ // Validate modes
102
+ const unknown = modes.filter(m => !POISON_INLINE[m]);
103
+ if (unknown.length > 0) {
104
+ console.error(chalk.red(` Unknown mode(s): ${unknown.join(', ')}`));
105
+ console.error(chalk.gray(` Run ${chalk.white('csspow modes')} to see available modes.`));
106
+ process.exit(1);
107
+ }
108
+
109
+ spinner.start('Launching browser...');
110
+
111
+ let browser;
112
+ try {
113
+ browser = await chromium.launch({ headless: opts.headless !== false });
114
+ } catch (err) {
115
+ spinner.fail('Failed to launch browser');
116
+ console.error(chalk.red('\n ' + err.message));
117
+ console.error(chalk.gray('\n Make sure Playwright browsers are installed:'));
118
+ console.error(chalk.white(' npx playwright install chromium\n'));
119
+ process.exit(1);
120
+ }
121
+
122
+ const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
123
+
124
+ try {
125
+ spinner.text = `Loading ${url}...`;
126
+ await page.goto(url, { waitUntil: 'networkidle', timeout: parseInt(opts.timeout) || 30000 });
127
+ } catch (err) {
128
+ spinner.fail(`Failed to load ${url}`);
129
+ console.error(chalk.red('\n ' + err.message + '\n'));
130
+ await browser.close();
131
+ process.exit(1);
132
+ }
133
+
134
+ if (shouldRecord) {
135
+ mkdirSync(join(outputDir, 'screenshots'), { recursive: true });
136
+ }
137
+
138
+ // Capture baseline
139
+ let beforePath = null;
140
+ if (shouldRecord) {
141
+ beforePath = join(outputDir, 'screenshots', 'before.png');
142
+ await page.screenshot({ path: beforePath, fullPage: true });
143
+ spinner.succeed('Baseline screenshot captured');
144
+ }
145
+
146
+ const screenshots = [];
147
+
148
+ for (const mode of modes) {
149
+ spinner.start(`Applying ${chalk.yellow(mode)}...`);
150
+
151
+ // Navigate fresh for each mode to avoid compound effects
152
+ try {
153
+ await page.goto(url, { waitUntil: 'networkidle', timeout: parseInt(opts.timeout) || 30000 });
154
+ } catch (_) {}
155
+
156
+ const script = POISON_INLINE[mode] || '';
157
+
158
+ try {
159
+ await page.evaluate(script);
160
+ } catch (err) {
161
+ spinner.warn(` ${mode}: could not inject (${err.message})`);
162
+ continue;
163
+ }
164
+
165
+ // Small wait so the page settles
166
+ await page.waitForTimeout(400);
167
+
168
+ let overflows = [];
169
+ try {
170
+ overflows = await page.evaluate(DETECT_OVERFLOWS);
171
+ } catch (_) {}
172
+
173
+ let screenshotPath = null;
174
+ if (shouldRecord) {
175
+ screenshotPath = join(outputDir, 'screenshots', `${mode}.png`);
176
+ await page.screenshot({ path: screenshotPath, fullPage: true });
177
+ }
178
+
179
+ const modeInfo = MODES[mode];
180
+ screenshots.push({
181
+ mode,
182
+ screenshotPath: screenshotPath || '',
183
+ overflowCount: overflows.length,
184
+ overflows,
185
+ modeName: modeInfo?.name || mode,
186
+ modeDescription: modeInfo?.description || '',
187
+ });
188
+
189
+ const overflowText = overflows.length > 0
190
+ ? chalk.red(` — ${overflows.length} overflow${overflows.length !== 1 ? 's' : ''}`)
191
+ : chalk.green(' — clean');
192
+
193
+ spinner.succeed(`${chalk.yellow(mode)}${overflowText}`);
194
+
195
+ if (overflows.length > 0) {
196
+ overflows.slice(0, 5).forEach(el => {
197
+ console.log(chalk.gray(` overflow: ${el}`));
198
+ });
199
+ if (overflows.length > 5) {
200
+ console.log(chalk.gray(` ...and ${overflows.length - 5} more`));
201
+ }
202
+ }
203
+ }
204
+
205
+ await browser.close();
206
+
207
+ const durationMs = Date.now() - startTime;
208
+
209
+ // Summary
210
+ console.log('');
211
+ const totalBreaks = screenshots.reduce((n, s) => n + s.overflowCount, 0);
212
+ console.log(chalk.bold(' Summary'));
213
+ console.log(chalk.gray(' ─────────────────────────────────────'));
214
+ console.log(` Modes tested: ${chalk.white(screenshots.length)}`);
215
+ console.log(` Total overflows: ${totalBreaks > 0 ? chalk.red.bold(totalBreaks) : chalk.green('0')}`);
216
+ console.log(` Duration: ${chalk.white((durationMs / 1000).toFixed(1) + 's')}`);
217
+
218
+ // Report
219
+ if (shouldRecord) {
220
+ const reportPath = generateReport({
221
+ url,
222
+ outputDir,
223
+ screenshots,
224
+ beforePath,
225
+ durationMs,
226
+ });
227
+
228
+ console.log('');
229
+ console.log(` ${chalk.green('✓')} Report saved to: ${chalk.cyan(reportPath)}`);
230
+ console.log(chalk.gray(` Open it in your browser: ${chalk.white('open ' + reportPath)}`));
231
+ }
232
+
233
+ console.log('');
234
+
235
+ // Exit code reflects whether any breaks were found
236
+ if (totalBreaks > 0) {
237
+ process.exit(0); // Still success — we found breaks, which is informational not fatal
238
+ }
239
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Set up LD_LIBRARY_PATH for Playwright's Chromium to find Nix store libraries.
3
+ * This is called automatically before launching any browser.
4
+ */
5
+
6
+ import { existsSync } from 'fs';
7
+
8
+ const NIX_LIBS = [
9
+ // libgbm — required by Chrome headless shell
10
+ '/nix/store/24w3s75aa2lrvvxsybficn8y3zxd27kp-mesa-libgbm-25.1.0/lib',
11
+ // glib
12
+ '/nix/store/syzi2bpl8j599spgvs20xjkjzcw758as-glib-2.84.3/lib',
13
+ '/nix/store/y3nxdc2x8hwivppzgx5hkrhacsh87l21-glib-2.84.3/lib',
14
+ '/nix/store/f7rcazhd826xlcz43il4vafv28888cgj-glib-2.86.3/lib',
15
+ // dbus
16
+ '/nix/store/231d6mmkylzr80pf30dbywa9x9aryjgy-dbus-1.14.10-lib/lib',
17
+ '/nix/store/zbydgvn9gypb3vg88mzydn88ky6cibaz-dbus-1.14.10/lib',
18
+ // alsa
19
+ '/nix/store/yw5xqn8lqinrifm9ij80nrmf0i6fdcbx-alsa-lib-1.2.13/lib',
20
+ // libxkbcommon
21
+ '/nix/store/sisfq9wihyqqjzmrpik9b4xksifw97ha-libxkbcommon-1.8.1/lib',
22
+ // libdrm
23
+ '/nix/store/lp1ak5dgh8fy09nrfryfsm0ayk6c0wwh-libdrm-2.4.124-bin/lib',
24
+ ];
25
+
26
+ export function setupPlaywrightEnv() {
27
+ const existing = process.env.LD_LIBRARY_PATH || '';
28
+ const nixPaths = NIX_LIBS.filter(p => existsSync(p));
29
+
30
+ if (nixPaths.length === 0) return; // Not on Nix, libraries should be at system paths
31
+
32
+ const allPaths = [...new Set([...nixPaths, ...existing.split(':').filter(Boolean)])];
33
+ process.env.LD_LIBRARY_PATH = allPaths.join(':');
34
+ }