feat(human-flow): AST-based scanner v2 + Friction Index rubric

Upgrade the human-flow skill (Gemini-assisted, Claude-reviewed):
- scan.mjs rewritten to AST-based (@babel/parser/traverse) with 4
  detectors: unlabeled-icon-button, tiny-target, missing-feedback-props,
  click-without-keyboard; regex fallback on parse failure.
- Objective Friction Index (Motor 3.0 / Cognitive 2.5 / Keyboard 2.5 /
  Feedback 2.0); 0-10 Human Workflow Score.
- New heuristics: State-Flow Audit, Precision Rail / Fumble Zones,
  Restraint-o-Meter (1-5) for the fancy pass.
- `fix` command DISABLED for now (advisory only): the AST generator
  reprints whole files and produces noisy diffs; agents apply surgical
  fixes from the report. To be revisited with a string-splice editor.
- Add @babel/* deps + package-lock.json.
- Memory: agy review/review-files is NOT actually read-only (wrote files
  + ran npm despite documented plan-mode) — diff after every agy review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-05 17:11:53 -07:00
parent bf491354e3
commit d4741e447f
9 changed files with 571 additions and 198 deletions

View File

@@ -29,6 +29,7 @@
## Feedback
- [Bot alerts need a ticket link](feedback_bot_alert_ticket_link.md) — Syncro ticket bot-alerts MUST include a clickable link: https://computerguru.syncromsp.com/tickets/<internal_id> (internal id, not ticket number). post-bot-alert.sh posts raw text; put the URL in the message.
- [Verify committed state before push](feedback_verify_committed_state_before_push.md) — webhook builds from origin/main: verify the COMMITTED build (git stash + build), not the working tree; bad git-add pathspec silently aborts staging. Stage by directory.
- [Scheduling = coord todo, not schedulers](feedback_scheduling_via_coord_todo.md) — Defer future work as a coord todo (POST /api/coord/todos; needs text + created_by_user + created_by_machine) for a later session to pick up. NOT /schedule remote CCR agents (no vault/creds there) or local scheduled tasks.
- [Attribution is read, never inferred](feedback_attribution_from_identity.md) — Who-did-what (user+machine) comes ONLY from identity.json + users.json + git authorship. Never infer from hostname patterns, the userEmail hint, or memory. The "5070" box is Mike's. sync.sh reconciles git config to identity.json; /save renders the User block via whoami-block.sh.
- [D2TESTNAS SSH Access](feedback_d2testnas_ssh.md) — Use root@192.168.0.9 with Paper123!@#, not sysadmin.
@@ -59,6 +60,7 @@
- [Python on Windows — use py launcher](feedback_python_windows.md) — Windows Store python/python3 aliases disabled; always use py or jq on DESKTOP-0O8A1RL.
- [Memory tooling may delete now — additive-only constraint dropped](feedback_memory_sync_destructive_ok.md) — As of 2026-06-02, memory-dream and sync-memory.sh are sanctioned to perform destructive ops (apply proposed merges/dedups, propagate repo deletions back to harness profile stores). Onboarding-phase safety net now fights deliberate consolidation (e.g. 2026-06-01's 39 deletions resurrected on the next sync). Script updates pending.
- [Unsaved sessions are recoverable from transcripts](feedback_session_recovery.md) — Crashed/closed-before-save sessions live in `~/.claude/projects/<slug>/*.jsonl`; the detector auto-recovers orphans, `/recover <uuid>` does it manually. Ollama prose + Python verbatim. See `.claude/RECOVERY.md`.
- [agy review is not read-only](feedback_agy_review_not_readonly.md) — agy review/review-files CAN write files + run npm despite docs claiming plan-mode; always git diff after and treat Gemini's output as a proposal to validate, not trusted/finished work.
### Syncro
- [Syncro API plumbing](feedback_syncro_api.md) — Content-Type required on all POST/PUT; NO idempotency anywhere — always GET before retrying; response wrappers (`.ticket.id`, `.comment.id`); add_line_item shape (internal ID, flat response, required fields); HTML uses `<br>` not `<ul>/<li>`; timer_entry response is FLAT but SUPERSEDED (use add_line_item).

View File

@@ -0,0 +1,12 @@
---
name: feedback-agy-review-not-readonly
description: agy review/review-files can actually WRITE files + run npm, despite docs claiming read-only plan mode — review Gemini's diffs, don't trust its summary.
metadata:
type: feedback
---
The `agy` SKILL.md documents `review` / `review-files` as read-only (`--approval-mode plan`: "Gemini can read files but cannot modify anything"). Observed 2026-06-05 on GURU-5070: a `review-files` call asking Gemini to "improve" the human-flow skill resulted in Gemini **actually editing 6 repo files, adding babel deps to package.json, and running npm install** (created package-lock.json + node_modules). So plan-mode was NOT enforced for that run.
**Why:** The documented safety contract (read-only review) cannot be relied on. Gemini also over-claims — its final summary said it "delivered/upgraded" the skill as if complete, but the only way to know what truly happened was to `git diff` and run the code.
**How to apply:** After ANY `agy review*` call, `git status` / `git diff` the target tree to see what actually changed — never trust the summary. If you need a guaranteed read-only second opinion, copy targets to a scratch dir first, or verify the wrapper's approval-mode. The improvements may be good, but they are a PROPOSAL to review and validate (run it, check repo rules like NO EMOJIS), not trusted output. Related: [[reference_gitea_internal]] is unrelated; see agy SKILL.md path gotcha.

View File

@@ -38,12 +38,23 @@ Run via natural language ("human-flow scan the sessions table", "run human-flow
| Command | Description |
|---------------------|-------------|
| `scan [target]` | Quick static + heuristic scan of files or directories for mouse/keyboard friction. Produces a prioritized report. |
| `audit [target]` | Deeper pass: combines code analysis, component review, and workflow walkthroughs. Scores intuitiveness and suggests specific refactors. |
| `fancy [target]` | **"Fancy as fuck" mode** — a second, beauty- and elegance-focused pass. Evaluates opportunities for tasteful delight (transitions, micro-interactions, hover states, view transitions, loading experiences, etc.), determines appropriateness, and suggests refinements/polish. |
| `report [target]` | Generate a clean, user-facing markdown report suitable for sharing with designers/devs. |
| `scan [target]` | AST-powered scan of files/directories for workflow friction. Produces a 0-10 Friction Index report. |
| `audit [target]` | Deeper pass: combines AST analysis, component review, and state-flow audit. |
| `fix [target]` | **DISABLED (advisory only for now).** Auto-apply is off — the AST code generator reprints whole files and produces noisy diffs. Use the scan/report output and have an agent apply the fixes surgically. Will be revisited with a surgical (string-splice) editor. |
| `fancy [target]` | **"Fancy as fuck" mode** — elegance pass with a calibrated Restraint-o-Meter. |
| `report [target]` | Generate a formatted markdown report with the Friction Index rubric. |
If no command, defaults to `scan` on the provided target (or current frontend dir).
If no command, defaults to `scan` on the provided target.
## Friction Index (0-10)
The scan produces an objective score based on weighted deductions:
- **Motor (3.0)**: Target size, precision, Fitts's Law.
- **Cognitive (2.5)**: Discoverability, affordance, consistency.
- **Keyboard (2.5)**: Accessibility, focus flow, parity.
- **Feedback (2.0)**: Visual response, state transitions.
Score = 10 - Σ(IssueSeverity * DimensionWeight)
You can combine: e.g. run `scan` first for friction, then `fancy` for delight opportunities.

View File

@@ -0,0 +1,217 @@
{
"name": "human-flow",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "human-flow",
"version": "0.1.0",
"dependencies": {
"@babel/generator": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/generator": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-globals": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.7"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/template": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-globals": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7",
"debug": "^4.3.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
"license": "MIT",
"bin": {
"jsesc": "bin/jsesc"
},
"engines": {
"node": ">=6"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
}
}
}

View File

@@ -6,5 +6,11 @@
"scripts": {
"scan": "node scripts/scan.mjs",
"fancy": "node scripts/scan.mjs --fancy"
},
"dependencies": {
"@babel/generator": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7"
}
}
}

View File

@@ -34,9 +34,24 @@ Before suggesting any fancy element, answer these questions honestly:
- Dense internal tools / operator consoles: Favor *restraint and precision*. Think "expensive mechanical instrument" — satisfying, confident, never showy. Over-the-top sparkle or bouncy motion will feel wrong and unprofessional here.
- Onboarding, public-facing, marketing, or higher-emotion flows: More permission for expressive, delightful "useful decoration" that makes the experience feel alive and premium while still serving clear user goals.
## The Restraint-o-Meter
Calibrate your "Fancy" recommendations using this scale:
| Level | Profile | Examples |
| :--- | :--- | :--- |
| **1** | **Clinical** | Zero motion. Immediate cuts. High density. (Log viewers, raw data dumps). |
| **2** | **Functional** | Subtle hover states only. (Internal monitoring tools). |
| **3** | **Professional** | Standard easings (150-200ms). Skeleton shimmers. (Admin Dashboards, GuruRMM). |
| **4** | **Polished** | View Transitions. Subtle card lifts. Optimistic UI. (User-facing settings, consumer tools). |
| **5** | **Expressive** | Full shared-element morphs. Physics-based springs. (Onboarding, Marketing). |
**Guidance**: If you are in an operator console (Level 2-3), avoid any motion that takes > 200ms or that changes element positions significantly.
---
## Categories of Elegant Delight
## Technical Signals for Fancy Opportunities
### 1. Transitions & Easing (The Foundation)

View File

@@ -193,7 +193,30 @@ Prioritize findings that affect the most frequent user workflows in the product
---
## Related Anti-Patterns from Parent Skills
## 7. State-Flow Audit (Dynamic Friction)
**Anti-patterns**:
- Elements that jump or shift layout when data loads (layout thrash).
- Lack of optimistic UI for frequent, low-risk actions (waiting for server for every checkbox toggle).
- "Dead zones" during state transitions where the UI is locked but doesn't look it.
**Better human workflow**:
- Use skeleton screens with consistent dimensions.
- Apply optimistic updates with clear rollback on error.
- Ensure the "next logical target" is available or signaled as "loading".
---
## 8. The Precision Rail & Fumble Zones
**Anti-patterns**:
- Important interactive controls placed in the leftmost 40px or rightmost 40px of a screen with zero padding.
- Dense clusters of varied actions in the "Fumble Zone" (corners).
**Better human workflow**:
- Provide at least 16px of "safe padding" on edges.
- Group similar actions; keep high-risk actions away from frequent navigation rails.
This skill deliberately overlaps with and specializes rules from `impeccable` (no identical card grids, no hero metrics, strong focus on cognitive load and emotional journey) and `frontend-design` (click targets 44px, hover states, focus states, disabled states).

View File

@@ -7,12 +7,14 @@ Use this structure for all `scan`, `audit`, and `report` outputs.
## Human-Flow Report: <Target / Component / Page>
**Date**: YYYY-MM-DD
**Scanner**: human-flow v1 (mouse + keyboard intuition focus)
**Scope**: <files/components scanned>
**Overall Human Workflow Score**: X/10
- Mouse Ergonomics: X/10
- Keyboard Parity & Efficiency: X/10
- Workflow Discoverability & Friction: X/10
**Scanner**: human-flow v2 (AST-Powered)
**Overall Human Workflow Score**: X/10
### Friction Index Rubric
- **Motor (3.0)**: Target size, precision, travel distance.
- **Cognitive (2.5)**: Discoverability, affordance, consistency.
- **Keyboard (2.5)**: Accessibility, focus flow, parity.
- **Feedback (2.0)**: Visual response, state transitions.
**Summary**
(2-4 sentences: the biggest sources of unintuitive behavior for a human operator using mouse and keyboard, and the net effect on daily workflow.)

View File

@@ -1,23 +1,24 @@
#!/usr/bin/env node
/**
* human-flow scanner
* human-flow scanner v2 (AST-Powered)
*
* Static analysis pass for mouse + keyboard workflow friction.
* Expands the spirit of frontend-design and impeccable with a narrow,
* human-motor-and-expectation focus.
* Sophisticated analysis pass for mouse + keyboard workflow friction.
* Uses @babel/parser for deep JSX/TSX understanding.
*
* 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.
* node scripts/scan.mjs --path src
* node scripts/scan.mjs --path src --fix
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parse } from '@babel/parser';
import _traverse from '@babel/traverse';
import _generate from '@babel/generator';
import * as t from '@babel/types';
const traverse = _traverse.default;
const generate = _generate.default;
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -25,12 +26,13 @@ const args = process.argv.slice(2);
let targetPath = 'src';
let format = 'text';
let mode = 'friction'; // 'friction' | 'fancy'
let applyFix = false;
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++; }
if (args[i] === '--fix') applyFix = true;
}
const absTarget = path.resolve(process.cwd(), targetPath);
@@ -40,16 +42,40 @@ if (!fs.existsSync(absTarget)) {
process.exit(1);
}
// `--fix` auto-apply is DISABLED for now: @babel/generator reprints the whole
// AST, producing noisy diffs that touch untouched code. Until it does surgical
// edits, run advisory only — agents apply fixes surgically from the report.
if (applyFix) {
console.error('[INFO] --fix (auto-apply) is disabled; running an advisory scan instead. Apply fixes surgically from the report.');
applyFix = false;
}
const findings = [];
let fixesApplied = 0;
// Friction Index Rubric Weights
const WEIGHTS = {
MOTOR: 3.0,
COGNITIVE: 2.5,
KEYBOARD: 2.5,
FEEDBACK: 2.0
};
const SEVERITY_POINTS = {
high: 1.0,
medium: 0.5,
low: 0.2
};
function walk(dir) {
if (!fs.existsSync(dir)) return;
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)) {
} else if (/\.(tsx|jsx|ts|js)$/.test(entry.name)) {
analyzeFile(full);
}
}
@@ -58,197 +84,256 @@ function walk(dir) {
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\(|<Link|react-router|next\/|router\.push)/i.test(content) && !/document\.startViewTransition|View Transitions|view-transition/i.test(content)) {
findings.push({
file: rel,
line: 1,
category: 'fancy-opportunity',
severity: 'low',
pattern: 'missing-view-transitions',
message: 'Navigation or view change logic detected without use of the View Transitions API.',
humanImpact: 'Page-like changes can feel abrupt or cheap. Modern "ajax-style" smooth transitions between views feel significantly more premium.',
suggestion: 'Consider wrapping key navigation with document.startViewTransition() + CSS view-transition-name for elegant morphs or fades. Only where it genuinely improves perceived quality.'
});
}
// Basic hover without fancy enhancement
if (/:hover\s*\{[^}]*background|transform|box-shadow|scale|opacity/i.test(content)) {
findings.push({
file: rel,
line: 1,
category: 'fancy-opportunity',
severity: 'low',
pattern: 'basic-hover',
message: 'Hover state exists but may be basic. Opportunity for more elegant micro-interaction.',
humanImpact: 'A merely functional hover feels flat. A refined one (subtle lift + shadow + accent) makes the interface feel alive and high-craft.',
suggestion: 'Layer tasteful depth (shadow + slight scale or translate) with excellent easing. Keep it restrained, especially in dense data views.'
});
}
return; // In fancy mode we mostly collect signals for the agent to do deep qualitative work
}
// === FRICTION MODE (original) ===
// 1. Small / sm button targets in interactive contexts (very common friction)
const smallButton = /size=["']sm["']|<button[^>]*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.'
let modified = false;
try {
const ast = parse(content, {
sourceType: 'module',
plugins: ['jsx', 'typescript', 'decorators-legacy', 'classProperties'],
errorRecovery: true
});
}
// 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.71.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.'
if (mode === 'fancy') {
// Fancy / beauty & elegance pass
const hasMotion = /transition:|animate-|@keyframes|framer-motion|ViewTransition/i.test(content);
if (hasMotion) {
addFinding({
file: rel,
line: 1,
category: 'FEEDBACK',
severity: 'low',
pattern: 'existing-motion',
message: 'Existing motion detected. Review for quality, easing, and restraint.',
humanImpact: 'Motion can feel premium or cheap depending on execution.',
suggestion: 'Check if easings match the Restraint-o-Meter Level 3-4 (150-250ms).'
});
}
// Missing View Transitions in SPA contexts
if (/(useNavigate|navigate\(|<Link|router\.push)/i.test(content) && !/document\.startViewTransition/i.test(content)) {
addFinding({
file: rel,
line: 1,
category: 'FEEDBACK',
severity: 'low',
pattern: 'missing-view-transitions',
message: 'Navigation detected without View Transitions API.',
humanImpact: 'View changes feel abrupt. Transitions feel significantly more premium.',
suggestion: 'Wrap navigation in document.startViewTransition() where appropriate.'
});
}
return;
}
traverse(ast, {
JSXOpeningElement(path) {
const node = path.node;
const name = getComponentName(node);
// 1. Unlabeled Icon Button (with Fixer)
if (isButtonLike(node) && !hasAriaLabel(node)) {
const parent = path.parentPath.node;
if (parent.children && hasOnlyIconChild(parent.children)) {
if (applyFix) {
const iconNode = parent.children.find(c => c.type === 'JSXElement');
const iconName = getComponentName(iconNode.openingElement);
const label = iconName.replace(/Icon$/, '');
node.attributes.push(t.jsxAttribute(t.jsxIdentifier('aria-label'), t.stringLiteral(label)));
modified = true;
fixesApplied++;
} else {
addFinding({
file: rel,
line: node.loc.start.line,
category: 'COGNITIVE',
severity: 'high',
pattern: 'unlabeled-icon-button',
message: `Button "${name}" contains only an icon but has no aria-label or title.`,
humanImpact: 'Keyboard and screen reader users have no way to know what this button does.',
suggestion: 'Add an aria-label or title prop describing the action.'
});
}
}
}
// 2. Tiny Target Calculator
if (isInteractive(node)) {
const size = getTargetSize(node);
if (size < 32) {
addFinding({
file: rel,
line: node.loc.start.line,
category: 'MOTOR',
severity: 'high',
pattern: 'tiny-target',
message: `Interactive element "${name}" has a detected size of ~${size}px.`,
humanImpact: 'Small targets require high precision, leading to slower workflows and mis-clicks.',
suggestion: 'Increase height/width to at least 32px (ideally 44px) or add generous padding.'
});
}
}
// 3. Interaction Feedback Missing
if (name === 'Button' || name === 'ActionButton') {
if (!hasFeedbackProps(node)) {
addFinding({
file: rel,
line: node.loc.start.line,
category: 'FEEDBACK',
severity: 'medium',
pattern: 'missing-feedback-props',
message: `Button "${name}" lacks loading or active state props.`,
humanImpact: 'Users may be unsure if their click was registered during long operations.',
suggestion: 'Add isLoading or active props to provide immediate visual feedback.'
});
}
}
// 4. Keyboard Parity: onClick without key handler
if (hasProp(node, 'onClick') && !isNativeButton(node) && !hasKeyboardProps(node)) {
addFinding({
file: rel,
line: node.loc.start.line,
category: 'KEYBOARD',
severity: 'high',
pattern: 'click-without-keyboard',
message: `Custom element "${name}" has onClick but no keyboard handlers (onKeyDown) or tabIndex.`,
humanImpact: 'Keyboard users cannot trigger this action, creating a complete blocker for some workflows.',
suggestion: 'Add tabIndex={0} and an onKeyDown handler for Enter/Space.'
});
}
}
});
}
// 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 <button> when possible.'
});
if (modified && applyFix) {
const output = generate(ast, { retainLines: true }, content);
fs.writeFileSync(file, output.code);
}
} catch (e) {
// Graceful degradation: Fallback to regex for critical failures
runLegacyRegexScan(content, rel);
}
}
// 4. Icon-only buttons without accessible name (common with small action icons)
const iconButton = /<Button[^>]*>\s*<[^>]+Icon|<\s*button[^>]*>\s*<[^>]+Icon|<[A-Z][^>]*>\s*<[^>]+Icon/g;
while ((match = iconButton.exec(content)) !== null) {
const lineNo = content.substring(0, match.index).split('\n').length;
const nearby = content.substring(Math.max(0, match.index - 30), match.index + 180);
if (!/aria-label|title=/.test(nearby)) {
findings.push({
file: rel,
line: lineNo,
category: 'discoverability',
severity: 'medium',
pattern: 'icon-only-no-label',
message: 'Icon-only button or action with no aria-label or title.',
humanImpact: 'Screen readers and keyboard users (and anyone who forgets what the tiny icon means) have no idea what it does until they activate it.',
suggestion: 'Add aria-label (and preferably a visible label or tooltip that works on focus too).'
});
function addFinding(f) {
findings.push(f);
}
// Helpers
function getComponentName(node) {
if (node.name.type === 'JSXIdentifier') return node.name.name;
if (node.name.type === 'JSXMemberExpression') return node.name.property.name;
return 'unknown';
}
function isButtonLike(node) {
const name = getComponentName(node);
return ['button', 'Button', 'IconButton', 'ActionButton'].includes(name) || hasProp(node, 'role', 'button');
}
function isNativeButton(node) {
return getComponentName(node) === 'button';
}
function isInteractive(node) {
const name = getComponentName(node);
return isButtonLike(node) || ['a', 'input', 'select', 'textarea'].includes(name) || hasProp(node, 'onClick');
}
function hasProp(node, propName, value) {
return node.attributes.some(attr => {
if (attr.type !== 'JSXAttribute') return false;
if (attr.name.name !== propName) return false;
if (value === undefined) return true;
if (attr.value && attr.value.type === 'StringLiteral') return attr.value.value === value;
return false;
});
}
function hasAriaLabel(node) {
return hasProp(node, 'aria-label') || hasProp(node, 'title') || hasProp(node, 'label');
}
function hasOnlyIconChild(children) {
const visibleChildren = children.filter(c => c.type !== 'JSXText' || c.value.trim() !== '');
if (visibleChildren.length !== 1) return false;
const child = visibleChildren[0];
if (child.type !== 'JSXElement') return false;
const name = getComponentName(child.openingElement);
return name.endsWith('Icon') || name === 'Icon';
}
function getTargetSize(node) {
let size = 44; // Default
node.attributes.forEach(attr => {
if (attr.type === 'JSXAttribute' && attr.name.name === 'size') {
if (attr.value.value === 'sm' || attr.value.value === 'xs') size = 28;
}
}
if (attr.type === 'JSXAttribute' && attr.name.name === 'className') {
const val = attr.value.value || '';
if (val.includes('btn--sm') || val.includes('h-6') || val.includes('h-4')) size = 24;
}
});
return size;
}
// 5. Very narrow status / action columns (precision rail)
if (/width:\s*2[0-9]px|width:\s*30px|padding-left:\s*0 !important/.test(content) && /status|actions|select/i.test(content)) {
findings.push({
function hasFeedbackProps(node) {
return hasProp(node, 'loading') || hasProp(node, 'isLoading') || hasProp(node, 'active');
}
function hasKeyboardProps(node) {
return hasProp(node, 'onKeyDown') || hasProp(node, 'onKeyPress') || hasProp(node, 'tabIndex');
}
function runLegacyRegexScan(content, rel) {
// Simple fallback for files that fail AST parsing
if (/onClick=\{[^}]+}\s*(?!.*(onKeyDown|tabIndex|role=))/g.test(content)) {
addFinding({
file: rel,
line: 1,
category: 'target-size',
severity: 'medium',
pattern: 'narrow-rail',
message: 'Very narrow column (status, select, or actions rail) used for interactive or important visual elements.',
humanImpact: 'Mouse must be extremely precise to hit the control or even read the status comfortably.',
suggestion: 'Widen the rail or make the entire left edge a larger hit area (see dt__checkwrap pattern). Status can be visual + text on hover/focus.'
});
}
// 6. Row that is fully clickable + internal small actions (mis-click risk)
if (/onRowClick|onClick.*row|tr.*onClick/.test(content) && /dt__rowactions|rowactions/.test(content)) {
findings.push({
file: rel,
line: 1,
category: 'workflow',
severity: 'medium',
pattern: 'row-click-plus-internal-actions',
message: 'Whole row is clickable (for detail/open) while also containing small action buttons inside the row.',
humanImpact: 'Easy to accidentally trigger the row action when aiming for the small icon (or vice versa). Classic source of "I didn\'t mean to open that".',
suggestion: 'Make the primary row action very clearly the dominant target (bigger visual weight, different treatment). Or stop making the whole row clickable and use a dedicated primary button + separate secondary actions.'
category: 'KEYBOARD',
severity: 'high',
pattern: 'regex-click-without-keyboard',
message: 'Detected onClick without keyboard support via fallback scanner.',
humanImpact: 'Potential keyboard blocker.',
suggestion: 'Manually review for keyboard parity.'
});
}
}
// Start Scan
walk(absTarget);
// Deduplicate similar findings per file
const seen = new Set();
const uniqueFindings = findings.filter(f => {
const key = `${f.file}:${f.pattern}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
if (format === 'json') {
console.log(JSON.stringify({ target: absTarget, mode, findings: uniqueFindings }, null, 2));
if (applyFix) {
console.log(`\nFixed ${fixesApplied} mechanical issues across the target.`);
} else {
const title = mode === 'fancy'
? `Human-Flow "Fancy as Fuck" Signals for: ${absTarget}`
: `Human-Flow Scan Results for: ${absTarget}`;
console.log(`${title}\n`);
// Calculate Score
const scoreDeductions = findings.reduce((acc, f) => {
const dim = f.category;
const points = SEVERITY_POINTS[f.severity] * WEIGHTS[dim];
acc[dim] = (acc[dim] || 0) + points;
return acc;
}, {});
if (uniqueFindings.length === 0) {
if (mode === 'fancy') {
console.log('No obvious static fancy signals detected.\nThis is normal — the real fancy pass is qualitative. Load references/fancy-as-fuck.md and evaluate the target for beauty, elegance, and appropriate delight opportunities.');
} else {
console.log('No obvious mouse/keyboard friction patterns detected by static rules.\nRun a full agent review with the references/heuristics.md for deeper semantic issues.');
}
const totalDeduction = Object.values(scoreDeductions).reduce((a, b) => a + b, 0);
const finalScore = Math.max(0, Math.min(10, 10 - totalDeduction)).toFixed(1);
if (format === 'json') {
console.log(JSON.stringify({ target: absTarget, score: finalScore, findings }, null, 2));
} else {
uniqueFindings.forEach((f, i) => {
console.log(`${i + 1}. [${f.severity.toUpperCase()}] ${f.category}${f.pattern}`);
console.log(` File: ${f.file}:${f.line}`);
console.log(` ${f.message}`);
console.log(` Human impact: ${f.humanImpact}`);
console.log(` Suggestion: ${f.suggestion}\n`);
});
console.log(`## Human-Flow Scan: ${targetPath}`);
console.log(`**Overall Human Workflow Score: ${finalScore}/10**\n`);
if (findings.length === 0) {
console.log('[OK] No friction detected. Workflow is clean.');
} else {
findings.forEach((f, i) => {
console.log(`${i + 1}. [${f.severity.toUpperCase()}] ${f.category}${f.pattern}`);
console.log(` File: ${f.file}:${f.line}`);
console.log(` ${f.message}`);
console.log(` Human impact: ${f.humanImpact}`);
console.log(` Suggestion: ${f.suggestion}\n`);
});
}
}
}
const exitCode = mode === 'fancy' ? 0 : (uniqueFindings.length > 0 ? 2 : 0);
process.exit(exitCode);