#!/usr/bin/env node /** * human-flow scanner * * Static analysis pass for mouse + keyboard workflow friction. * Expands the spirit of frontend-design and impeccable with a narrow, * human-motor-and-expectation focus. * * Usage: * node scripts/scan.mjs --path dashboard/src --format json * node scripts/scan.mjs --path dashboard/src/features/sessions * * It is intentionally lightweight (regex + heuristics) so it can run fast * inside agent loops. The real intelligence comes from the agent combining * these findings with full component reading and task-flow understanding. */ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const args = process.argv.slice(2); let targetPath = 'src'; let format = 'text'; let mode = 'friction'; // 'friction' | 'fancy' for (let i = 0; i < args.length; i++) { if (args[i] === '--path' || args[i] === '-p') targetPath = args[++i]; if (args[i] === '--format' || args[i] === '-f') format = args[++i]; if (args[i] === '--fancy' || args[i] === '--mode=fancy') mode = 'fancy'; if (args[i] === '--mode' && args[i + 1] === 'fancy') { mode = 'fancy'; i++; } } const absTarget = path.resolve(process.cwd(), targetPath); if (!fs.existsSync(absTarget)) { console.error(`Target not found: ${absTarget}`); process.exit(1); } const findings = []; function walk(dir) { const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { if (['node_modules', 'dist', 'build', '.git'].includes(entry.name)) continue; walk(full); } else if (/\.(tsx|jsx|ts|js|css)$/.test(entry.name)) { analyzeFile(full); } } } function analyzeFile(file) { const content = fs.readFileSync(file, 'utf8'); const rel = path.relative(process.cwd(), file).replace(/\\/g, '/'); const lines = content.split('\n'); if (mode === 'fancy') { // Fancy / beauty & elegance pass — lighter static signals + prompts for qualitative review let match; // Existing transitions / animations (look for opportunities to refine) const hasTransition = /transition:|transition-\w+:|animate-|@keyframes|ViewTransition|view-transition/i.test(content); if (hasTransition) { findings.push({ file: rel, line: 1, category: 'fancy-existing', severity: 'info', pattern: 'existing-motion', message: 'This file already contains motion/transition code. Good candidate for the fancy pass to review quality, consistency, and restraint.', humanImpact: 'Existing fancy elements can feel either premium or cheap/janky depending on execution.', suggestion: 'In the fancy pass, evaluate easing curves, durations, performance, reduced-motion respect, and whether the motion serves the human workflow or just decorates.' }); } // Missing View Transitions API in SPA navigation contexts if (/(useNavigate|navigate\(|]*className=.*btn--sm|height:\s*2[0-8]px|min-height:\s*2[0-8]px/g; let match; while ((match = smallButton.exec(content)) !== null) { const lineNo = content.substring(0, match.index).split('\n').length; findings.push({ file: rel, line: lineNo, category: 'target-size', severity: 'high', pattern: 'small-button', message: 'Compact "sm" button or very small height used for an action. Frequent actions (especially in lists) become precision targets.', humanImpact: 'Operators must slow down and aim carefully for common tasks. High error rate under time pressure.', suggestion: 'Use default (md) size for primary/frequent actions. For true compact row actions, ensure generous invisible padding or switch to a larger always-visible treatment.' }); } // 2. Hover-revealed or low-opacity row actions (the classic operator console anti-pattern) if (/\.dt__rowactions|\.rowactions|\.actions\s*\{[^}]*opacity:\s*0\.[0-6]/s.test(content) || /opacity:\s*0\.[0-6][^}]*hover|hover[^}]*opacity:\s*(1|0\.[7-9])/s.test(content)) { const lineNo = 1; // best effort findings.push({ file: rel, line: lineNo, category: 'discoverability', severity: 'high', pattern: 'hover-only-actions', message: 'Row or list actions are dimmed or hidden until hover (or only fully visible on hover).', humanImpact: 'A human scanning a list with eyes + mouse must "paint" every row to discover what they can do. Keyboard users often never see the controls at full strength.', suggestion: 'Raise resting opacity to 0.7–1.0 so actions are scannable at a glance. Or move frequent actions into a dedicated, always-visible column or primary row target. Keep hover only for polish, not discovery.' }); } // 3. onClick without obvious keyboard support on non-native elements const clickNoKeyboard = /onClick=\{[^}]+}\s*(?!.*(onKeyDown|tabIndex|role=))/g; while ((match = clickNoKeyboard.exec(content)) !== null) { const lineNo = content.substring(0, match.index).split('\n').length; // Only flag if it looks like a custom interactive (div, span, custom component in list context) const context = content.substring(Math.max(0, match.index - 80), match.index + 120); if (/<\s*(div|span|tr|td|li|custom|Card|Row)[^>]*onClick|onClick[^>]*<\s*(div|span|tr|td|li|Card|Row)/.test(context)) { findings.push({ file: rel, line: lineNo, category: 'keyboard-parity', severity: 'high', pattern: 'click-without-keyboard', message: 'Custom element has onClick but no visible tabIndex/onKeyDown/Enter-Space handling in the immediate area.', humanImpact: 'Keyboard (or mixed mouse+keyboard) users cannot activate the same thing the mouse can without extra workarounds.', suggestion: 'Add tabIndex={0}, onKeyDown handler for Enter/Space, and strong :focus-visible styles. Prefer native