From 31c9f7c33e55c6081765a4c717b94014fe872a84 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 2 Apr 2026 17:33:17 -0700 Subject: [PATCH] feat: add web frontend rules and design quality hook --- .cursor/hooks.json | 2 +- .cursor/hooks/after-file-edit.js | 5 +- WORKING-CONTEXT.md | 2 + hooks/README.md | 1 + hooks/hooks.json | 12 +++ rules/README.md | 5 + rules/web/coding-style.md | 96 +++++++++++++++++ rules/web/design-quality.md | 63 +++++++++++ rules/web/hooks.md | 120 +++++++++++++++++++++ rules/web/patterns.md | 79 ++++++++++++++ rules/web/performance.md | 64 +++++++++++ rules/web/security.md | 57 ++++++++++ rules/web/testing.md | 55 ++++++++++ scripts/hooks/design-quality-check.js | 131 +++++++++++++++++++++++ tests/hooks/design-quality-check.test.js | 82 ++++++++++++++ 15 files changed, 772 insertions(+), 2 deletions(-) create mode 100644 rules/web/coding-style.md create mode 100644 rules/web/design-quality.md create mode 100644 rules/web/hooks.md create mode 100644 rules/web/patterns.md create mode 100644 rules/web/performance.md create mode 100644 rules/web/security.md create mode 100644 rules/web/testing.md create mode 100644 scripts/hooks/design-quality-check.js create mode 100644 tests/hooks/design-quality-check.test.js diff --git a/.cursor/hooks.json b/.cursor/hooks.json index 56969a1f..cbe4d346 100644 --- a/.cursor/hooks.json +++ b/.cursor/hooks.json @@ -37,7 +37,7 @@ { "command": "node .cursor/hooks/after-file-edit.js", "event": "afterFileEdit", - "description": "Auto-format, TypeScript check, console.log warning" + "description": "Auto-format, TypeScript check, console.log warning, and frontend design-quality reminder" } ], "beforeMCPExecution": [ diff --git a/.cursor/hooks/after-file-edit.js b/.cursor/hooks/after-file-edit.js index 58fd0fb9..59643529 100644 --- a/.cursor/hooks/after-file-edit.js +++ b/.cursor/hooks/after-file-edit.js @@ -1,5 +1,5 @@ #!/usr/bin/env node -const { readStdin, runExistingHook, transformToClaude } = require('./adapter'); +const { hookEnabled, readStdin, runExistingHook, transformToClaude } = require('./adapter'); readStdin().then(raw => { try { const input = JSON.parse(raw); @@ -11,6 +11,9 @@ readStdin().then(raw => { // Accumulate edited paths for batch format+typecheck at stop time runExistingHook('post-edit-accumulator.js', claudeStr); runExistingHook('post-edit-console-warn.js', claudeStr); + if (hookEnabled('post:edit:design-quality-check', ['standard', 'strict'])) { + runExistingHook('design-quality-check.js', claudeStr); + } } catch {} process.stdout.write(raw); }).catch(() => process.exit(0)); diff --git a/WORKING-CONTEXT.md b/WORKING-CONTEXT.md index ed31f87b..b968462d 100644 --- a/WORKING-CONTEXT.md +++ b/WORKING-CONTEXT.md @@ -115,3 +115,5 @@ Keep this file detailed for only the current sprint, blockers, and next actions. - 2026-04-02: Closed fresh auto-generated bundle PRs `#1182` and `#1183` under the existing policy. Useful ideas from generator output must be ported manually into canonical repo surfaces instead of merging `.claude`/bundle PRs wholesale. - 2026-04-02: Ported the safe one-file macOS observer fix from `#1164` directly into `main` as a POSIX `mkdir` fallback for `continuous-learning-v2` lazy-start locking, then closed the PR as superseded by direct port. - 2026-04-02: Ported the safe core of `#1153` directly into `main`: markdownlint cleanup for orchestration/docs surfaces plus the Windows `USERPROFILE` and path-normalization fixes in `install-apply` / `repair` tests. Local validation after installing repo deps: `node tests/scripts/install-apply.test.js`, `node tests/scripts/repair.test.js`, and targeted `yarn markdownlint` all passed. +- 2026-04-02: Direct-ported the safe web/frontend rules lane from `#1122` into `rules/web/`, but adapted `rules/web/hooks.md` to prefer project-local tooling and avoid remote one-off package execution examples. +- 2026-04-02: Adapted the design-quality reminder from `#1127` into the current ECC hook architecture with a local `scripts/hooks/design-quality-check.js`, Claude `hooks/hooks.json` wiring, Cursor `after-file-edit.js` wiring, and dedicated hook coverage in `tests/hooks/design-quality-check.test.js`. diff --git a/hooks/README.md b/hooks/README.md index 27fae0a5..a88c91bf 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -35,6 +35,7 @@ User request → Claude picks a tool → PreToolUse hook runs → Tool executes | **PR logger** | `Bash` | Logs PR URL and review command after `gh pr create` | | **Build analysis** | `Bash` | Background analysis after build commands (async, non-blocking) | | **Quality gate** | `Edit\|Write\|MultiEdit` | Runs fast quality checks after edits | +| **Design quality check** | `Edit\|Write\|MultiEdit` | Warns when frontend edits drift toward generic template-looking UI | | **Prettier format** | `Edit` | Auto-formats JS/TS files with Prettier after edits | | **TypeScript check** | `Edit` | Runs `tsc --noEmit` after editing `.ts`/`.tsx` files | | **console.log warning** | `Edit` | Warns about `console.log` statements in edited files | diff --git a/hooks/hooks.json b/hooks/hooks.json index 2bf2715e..84f21a83 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -226,6 +226,18 @@ "description": "Run quality gate checks after file edits", "id": "post:quality-gate" }, + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"post:edit:design-quality-check\" \"scripts/hooks/design-quality-check.js\" \"standard,strict\"", + "timeout": 10 + } + ], + "description": "Warn when frontend edits drift toward generic template-looking UI", + "id": "post:edit:design-quality-check" + }, { "matcher": "Edit|Write|MultiEdit", "hooks": [ diff --git a/rules/README.md b/rules/README.md index 8a4466c5..f6d9c646 100644 --- a/rules/README.md +++ b/rules/README.md @@ -17,6 +17,7 @@ rules/ ├── typescript/ # TypeScript/JavaScript specific ├── python/ # Python specific ├── golang/ # Go specific +├── web/ # Web and frontend specific ├── swift/ # Swift specific └── php/ # PHP specific ``` @@ -33,6 +34,7 @@ rules/ ./install.sh typescript ./install.sh python ./install.sh golang +./install.sh web ./install.sh swift ./install.sh php @@ -56,6 +58,7 @@ cp -r rules/common ~/.claude/rules/common cp -r rules/typescript ~/.claude/rules/typescript cp -r rules/python ~/.claude/rules/python cp -r rules/golang ~/.claude/rules/golang +cp -r rules/web ~/.claude/rules/web cp -r rules/swift ~/.claude/rules/swift cp -r rules/php ~/.claude/rules/php @@ -86,6 +89,8 @@ To add support for a new language (e.g., `rust/`): ``` 4. Reference existing skills if available, or create new ones under `skills/`. +For non-language domains like `web/`, follow the same layered pattern when there is enough reusable domain-specific guidance to justify a standalone ruleset. + ## Rule Priority When language-specific rules and common rules conflict, **language-specific rules take precedence** (specific overrides general). This follows the standard layered configuration pattern (similar to CSS specificity or `.gitignore` precedence). diff --git a/rules/web/coding-style.md b/rules/web/coding-style.md new file mode 100644 index 00000000..5707164e --- /dev/null +++ b/rules/web/coding-style.md @@ -0,0 +1,96 @@ +> This file extends [common/coding-style.md](../common/coding-style.md) with web-specific frontend content. + +# Web Coding Style + +## File Organization + +Organize by feature or surface area, not by file type: + +```text +src/ +├── components/ +│ ├── hero/ +│ │ ├── Hero.tsx +│ │ ├── HeroVisual.tsx +│ │ └── hero.css +│ ├── scrolly-section/ +│ │ ├── ScrollySection.tsx +│ │ ├── StickyVisual.tsx +│ │ └── scrolly.css +│ └── ui/ +│ ├── Button.tsx +│ ├── SurfaceCard.tsx +│ └── AnimatedText.tsx +├── hooks/ +│ ├── useReducedMotion.ts +│ └── useScrollProgress.ts +├── lib/ +│ ├── animation.ts +│ └── color.ts +└── styles/ + ├── tokens.css + ├── typography.css + └── global.css +``` + +## CSS Custom Properties + +Define design tokens as variables. Do not hardcode palette, typography, or spacing repeatedly: + +```css +:root { + --color-surface: oklch(98% 0 0); + --color-text: oklch(18% 0 0); + --color-accent: oklch(68% 0.21 250); + + --text-base: clamp(1rem, 0.92rem + 0.4vw, 1.125rem); + --text-hero: clamp(3rem, 1rem + 7vw, 8rem); + + --space-section: clamp(4rem, 3rem + 5vw, 10rem); + + --duration-fast: 150ms; + --duration-normal: 300ms; + --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); +} +``` + +## Animation-Only Properties + +Prefer compositor-friendly motion: +- `transform` +- `opacity` +- `clip-path` +- `filter` (sparingly) + +Avoid animating layout-bound properties: +- `width` +- `height` +- `top` +- `left` +- `margin` +- `padding` +- `border` +- `font-size` + +## Semantic HTML First + +```html +
+ +
+
+
+

...

+
+
+ +``` + +Do not reach for generic wrapper `div` stacks when a semantic element exists. + +## Naming + +- Components: PascalCase (`ScrollySection`, `SurfaceCard`) +- Hooks: `use` prefix (`useReducedMotion`) +- CSS classes: kebab-case or utility classes +- Animation timelines: camelCase with intent (`heroRevealTl`) diff --git a/rules/web/design-quality.md b/rules/web/design-quality.md new file mode 100644 index 00000000..22c63c3f --- /dev/null +++ b/rules/web/design-quality.md @@ -0,0 +1,63 @@ +> This file extends [common/patterns.md](../common/patterns.md) with web-specific design-quality guidance. + +# Web Design Quality Standards + +## Anti-Template Policy + +Do not ship generic template-looking UI. Frontend output should look intentional, opinionated, and specific to the product. + +### Banned Patterns + +- Default card grids with uniform spacing and no hierarchy +- Stock hero section with centered headline, gradient blob, and generic CTA +- Unmodified library defaults passed off as finished design +- Flat layouts with no layering, depth, or motion +- Uniform radius, spacing, and shadows across every component +- Safe gray-on-white styling with one decorative accent color +- Dashboard-by-numbers layouts with sidebar + cards + charts and no point of view +- Default font stacks used without a deliberate reason + +### Required Qualities + +Every meaningful frontend surface should demonstrate at least four of these: + +1. Clear hierarchy through scale contrast +2. Intentional rhythm in spacing, not uniform padding everywhere +3. Depth or layering through overlap, shadows, surfaces, or motion +4. Typography with character and a real pairing strategy +5. Color used semantically, not just decoratively +6. Hover, focus, and active states that feel designed +7. Grid-breaking editorial or bento composition where appropriate +8. Texture, grain, or atmosphere when it fits the visual direction +9. Motion that clarifies flow instead of distracting from it +10. Data visualization treated as part of the design system, not an afterthought + +## Before Writing Frontend Code + +1. Pick a specific style direction. Avoid vague defaults like "clean minimal". +2. Define a palette intentionally. +3. Choose typography deliberately. +4. Gather at least a small set of real references. +5. Use ECC design/frontend skills where relevant. + +## Worthwhile Style Directions + +- Editorial / magazine +- Neo-brutalism +- Glassmorphism with real depth +- Dark luxury or light luxury with disciplined contrast +- Bento layouts +- Scrollytelling +- 3D integration +- Swiss / International +- Retro-futurism + +Do not default to dark mode automatically. Choose the visual direction the product actually wants. + +## Component Checklist + +- [ ] Does it avoid looking like a default Tailwind or shadcn template? +- [ ] Does it have intentional hover/focus/active states? +- [ ] Does it use hierarchy rather than uniform emphasis? +- [ ] Would this look believable in a real product screenshot? +- [ ] If it supports both themes, do both light and dark feel intentional? diff --git a/rules/web/hooks.md b/rules/web/hooks.md new file mode 100644 index 00000000..22f8c277 --- /dev/null +++ b/rules/web/hooks.md @@ -0,0 +1,120 @@ +> This file extends [common/hooks.md](../common/hooks.md) with web-specific hook recommendations. + +# Web Hooks + +## Recommended PostToolUse Hooks + +Prefer project-local tooling. Do not wire hooks to remote one-off package execution. + +### Format on Save + +Use the project's existing formatter entrypoint after edits: + +```json +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit", + "command": "pnpm prettier --write \"$FILE_PATH\"", + "description": "Format edited frontend files" + } + ] + } +} +``` + +Equivalent local commands via `yarn prettier` or `npm exec prettier --` are fine when they use repo-owned dependencies. + +### Lint Check + +```json +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit", + "command": "pnpm eslint --fix \"$FILE_PATH\"", + "description": "Run ESLint on edited frontend files" + } + ] + } +} +``` + +### Type Check + +```json +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit", + "command": "pnpm tsc --noEmit --pretty false", + "description": "Type-check after frontend edits" + } + ] + } +} +``` + +### CSS Lint + +```json +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit", + "command": "pnpm stylelint --fix \"$FILE_PATH\"", + "description": "Lint edited stylesheets" + } + ] + } +} +``` + +## PreToolUse Hooks + +### Guard File Size + +Block oversized writes from tool input content, not from a file that may not exist yet: + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Write", + "command": "node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{const i=JSON.parse(d);const c=i.tool_input?.content||'';const lines=c.split('\\n').length;if(lines>800){console.error('[Hook] BLOCKED: File exceeds 800 lines ('+lines+' lines)');console.error('[Hook] Split into smaller modules');process.exit(2)}console.log(d)})\"", + "description": "Block writes that exceed 800 lines" + } + ] + } +} +``` + +## Stop Hooks + +### Final Build Verification + +```json +{ + "hooks": { + "Stop": [ + { + "command": "pnpm build", + "description": "Verify the production build at session end" + } + ] + } +} +``` + +## Ordering + +Recommended order: +1. format +2. lint +3. type check +4. build verification diff --git a/rules/web/patterns.md b/rules/web/patterns.md new file mode 100644 index 00000000..ccec3685 --- /dev/null +++ b/rules/web/patterns.md @@ -0,0 +1,79 @@ +> This file extends [common/patterns.md](../common/patterns.md) with web-specific patterns. + +# Web Patterns + +## Component Composition + +### Compound Components + +Use compound components when related UI shares state and interaction semantics: + +```tsx + + + Overview + Settings + + ... + ... + +``` + +- Parent owns state +- Children consume via context +- Prefer this over prop drilling for complex widgets + +### Render Props / Slots + +- Use render props or slot patterns when behavior is shared but markup must vary +- Keep keyboard handling, ARIA, and focus logic in the headless layer + +### Container / Presentational Split + +- Container components own data loading and side effects +- Presentational components receive props and render UI +- Presentational components should stay pure + +## State Management + +Treat these separately: + +| Concern | Tooling | +|---------|---------| +| Server state | TanStack Query, SWR, tRPC | +| Client state | Zustand, Jotai, signals | +| URL state | search params, route segments | +| Form state | React Hook Form or equivalent | + +- Do not duplicate server state into client stores +- Derive values instead of storing redundant computed state + +## URL As State + +Persist shareable state in the URL: +- filters +- sort order +- pagination +- active tab +- search query + +## Data Fetching + +### Stale-While-Revalidate + +- Return cached data immediately +- Revalidate in the background +- Prefer existing libraries instead of rolling this by hand + +### Optimistic Updates + +- Snapshot current state +- Apply optimistic update +- Roll back on failure +- Emit visible error feedback when rolling back + +### Parallel Loading + +- Fetch independent data in parallel +- Avoid parent-child request waterfalls +- Prefetch likely next routes or states when justified diff --git a/rules/web/performance.md b/rules/web/performance.md new file mode 100644 index 00000000..b7202800 --- /dev/null +++ b/rules/web/performance.md @@ -0,0 +1,64 @@ +> This file extends [common/performance.md](../common/performance.md) with web-specific performance content. + +# Web Performance Rules + +## Core Web Vitals Targets + +| Metric | Target | +|--------|--------| +| LCP | < 2.5s | +| INP | < 200ms | +| CLS | < 0.1 | +| FCP | < 1.5s | +| TBT | < 200ms | + +## Bundle Budget + +| Page Type | JS Budget (gzipped) | CSS Budget | +|-----------|---------------------|------------| +| Landing page | < 150kb | < 30kb | +| App page | < 300kb | < 50kb | +| Microsite | < 80kb | < 15kb | + +## Loading Strategy + +1. Inline critical above-the-fold CSS where justified +2. Preload the hero image and primary font only +3. Defer non-critical CSS or JS +4. Dynamically import heavy libraries + +```js +const gsapModule = await import('gsap'); +const { ScrollTrigger } = await import('gsap/ScrollTrigger'); +``` + +## Image Optimization + +- Explicit `width` and `height` +- `loading="eager"` plus `fetchpriority="high"` for hero media only +- `loading="lazy"` for below-the-fold assets +- Prefer AVIF or WebP with fallbacks +- Never ship source images far beyond rendered size + +## Font Loading + +- Max two font families unless there is a clear exception +- `font-display: swap` +- Subset where possible +- Preload only the truly critical weight/style + +## Animation Performance + +- Animate compositor-friendly properties only +- Use `will-change` narrowly and remove it when done +- Prefer CSS for simple transitions +- Use `requestAnimationFrame` or established animation libraries for JS motion +- Avoid scroll handler churn; use IntersectionObserver or well-behaved libraries + +## Performance Checklist + +- [ ] All images have explicit dimensions +- [ ] No accidental render-blocking resources +- [ ] No layout shifts from dynamic content +- [ ] Motion stays on compositor-friendly properties +- [ ] Third-party scripts load async/defer and only when needed diff --git a/rules/web/security.md b/rules/web/security.md new file mode 100644 index 00000000..b44278cf --- /dev/null +++ b/rules/web/security.md @@ -0,0 +1,57 @@ +> This file extends [common/security.md](../common/security.md) with web-specific security content. + +# Web Security Rules + +## Content Security Policy + +Always configure a production CSP. + +### Nonce-Based CSP + +Use a per-request nonce for scripts instead of `'unsafe-inline'`. + +```text +Content-Security-Policy: + default-src 'self'; + script-src 'self' 'nonce-{RANDOM}' https://cdn.jsdelivr.net; + style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; + img-src 'self' data: https:; + font-src 'self' https://fonts.gstatic.com; + connect-src 'self' https://*.example.com; + frame-src 'none'; + object-src 'none'; + base-uri 'self'; +``` + +Adjust origins to the project. Do not cargo-cult this block unchanged. + +## XSS Prevention + +- Never inject unsanitized HTML +- Avoid `innerHTML` / `dangerouslySetInnerHTML` unless sanitized first +- Escape dynamic template values +- Sanitize user HTML with a vetted local sanitizer when absolutely necessary + +## Third-Party Scripts + +- Load asynchronously +- Use SRI when serving from a CDN +- Audit quarterly +- Prefer self-hosting for critical dependencies when practical + +## HTTPS and Headers + +```text +Strict-Transport-Security: max-age=31536000; includeSubDomains; preload +X-Content-Type-Options: nosniff +X-Frame-Options: DENY +Referrer-Policy: strict-origin-when-cross-origin +Permissions-Policy: camera=(), microphone=(), geolocation=() +``` + +## Forms + +- CSRF protection on state-changing forms +- Rate limiting on submission endpoints +- Validate client and server side +- Prefer honeypots or light anti-abuse controls over heavy-handed CAPTCHA defaults diff --git a/rules/web/testing.md b/rules/web/testing.md new file mode 100644 index 00000000..6bf58124 --- /dev/null +++ b/rules/web/testing.md @@ -0,0 +1,55 @@ +> This file extends [common/testing.md](../common/testing.md) with web-specific testing content. + +# Web Testing Rules + +## Priority Order + +### 1. Visual Regression + +- Screenshot key breakpoints: 320, 768, 1024, 1440 +- Test hero sections, scrollytelling sections, and meaningful states +- Use Playwright screenshots for visual-heavy work +- If both themes exist, test both + +### 2. Accessibility + +- Run automated accessibility checks +- Test keyboard navigation +- Verify reduced-motion behavior +- Verify color contrast + +### 3. Performance + +- Run Lighthouse or equivalent against meaningful pages +- Keep CWV targets from [performance.md](performance.md) + +### 4. Cross-Browser + +- Minimum: Chrome, Firefox, Safari +- Test scrolling, motion, and fallback behavior + +### 5. Responsive + +- Test 320, 375, 768, 1024, 1440, 1920 +- Verify no overflow +- Verify touch interactions + +## E2E Shape + +```ts +import { test, expect } from '@playwright/test'; + +test('landing hero loads', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('h1')).toBeVisible(); +}); +``` + +- Avoid flaky timeout-based assertions +- Prefer deterministic waits + +## Unit Tests + +- Test utilities, data transforms, and custom hooks +- For highly visual components, visual regression often carries more signal than brittle markup assertions +- Visual regression supplements coverage targets; it does not replace them diff --git a/scripts/hooks/design-quality-check.js b/scripts/hooks/design-quality-check.js new file mode 100644 index 00000000..5bc1fa7e --- /dev/null +++ b/scripts/hooks/design-quality-check.js @@ -0,0 +1,131 @@ +#!/usr/bin/env node +/** + * PostToolUse hook: lightweight frontend design-quality reminder. + * + * This stays self-contained inside ECC. It does not call remote models or + * install packages. The goal is to catch obviously generic UI drift and keep + * frontend edits aligned with ECC's stronger design standards. + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const FRONTEND_EXTENSIONS = /\.(astro|css|html|jsx|scss|svelte|tsx|vue)$/i; +const MAX_STDIN = 1024 * 1024; + +const GENERIC_SIGNALS = [ + { pattern: /\bget started\b/i, label: '"Get Started" CTA copy' }, + { pattern: /\blearn more\b/i, label: '"Learn more" CTA copy' }, + { pattern: /\bgrid-cols-(3|4)\b/, label: 'uniform multi-card grid' }, + { pattern: /\bbg-gradient-to-[trbl]/, label: 'stock gradient utility usage' }, + { pattern: /\btext-center\b/, label: 'centered default layout cues' }, + { pattern: /\bfont-(sans|inter)\b/i, label: 'default font utility' }, +]; + +const CHECKLIST = [ + 'visual hierarchy with real contrast', + 'intentional spacing rhythm', + 'depth, layering, or overlap', + 'purposeful hover and focus states', + 'color and typography that feel specific', +]; + +function getFilePaths(input) { + const toolInput = input?.tool_input || {}; + if (toolInput.file_path) { + return [String(toolInput.file_path)]; + } + + if (Array.isArray(toolInput.edits)) { + return toolInput.edits + .map(edit => String(edit?.file_path || '')) + .filter(Boolean); + } + + return []; +} + +function readContent(filePath) { + try { + return fs.readFileSync(path.resolve(filePath), 'utf8'); + } catch { + return ''; + } +} + +function detectSignals(content) { + return GENERIC_SIGNALS.filter(signal => signal.pattern.test(content)).map(signal => signal.label); +} + +function buildWarning(frontendPaths, findings) { + const pathLines = frontendPaths.map(fp => ` - ${fp}`).join('\n'); + const signalLines = findings.length > 0 + ? findings.map(item => ` - ${item}`).join('\n') + : ' - no obvious canned-template strings detected'; + + return [ + '[Hook] DESIGN CHECK: frontend file(s) modified:', + pathLines, + '[Hook] Review for generic/template drift. Frontend should have:', + CHECKLIST.map(item => ` - ${item}`).join('\n'), + '[Hook] Heuristic signals:', + signalLines, + ].join('\n'); +} + +function run(inputOrRaw) { + let input; + let rawInput = inputOrRaw; + + try { + if (typeof inputOrRaw === 'string') { + rawInput = inputOrRaw; + input = inputOrRaw.trim() ? JSON.parse(inputOrRaw) : {}; + } else { + input = inputOrRaw || {}; + rawInput = JSON.stringify(inputOrRaw ?? {}); + } + } catch { + return { exitCode: 0, stdout: typeof rawInput === 'string' ? rawInput : '' }; + } + + const filePaths = getFilePaths(input); + const frontendPaths = filePaths.filter(filePath => FRONTEND_EXTENSIONS.test(filePath)); + + if (frontendPaths.length === 0) { + return { exitCode: 0, stdout: typeof rawInput === 'string' ? rawInput : '' }; + } + + const findings = []; + for (const filePath of frontendPaths) { + const content = readContent(filePath); + findings.push(...detectSignals(content)); + } + + return { + exitCode: 0, + stdout: typeof rawInput === 'string' ? rawInput : '', + stderr: buildWarning(frontendPaths, findings), + }; +} + +module.exports = { run }; + +if (require.main === module) { + let raw = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', chunk => { + if (raw.length < MAX_STDIN) { + const remaining = MAX_STDIN - raw.length; + raw += chunk.substring(0, remaining); + } + }); + process.stdin.on('end', () => { + const result = run(raw); + if (result.stderr) process.stderr.write(`${result.stderr}\n`); + process.stdout.write(typeof result.stdout === 'string' ? result.stdout : raw); + process.exit(Number.isInteger(result.exitCode) ? result.exitCode : 0); + }); +} diff --git a/tests/hooks/design-quality-check.test.js b/tests/hooks/design-quality-check.test.js new file mode 100644 index 00000000..99f79199 --- /dev/null +++ b/tests/hooks/design-quality-check.test.js @@ -0,0 +1,82 @@ +/** + * Tests for scripts/hooks/design-quality-check.js + * + * Run with: node tests/hooks/design-quality-check.test.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const hook = require('../../scripts/hooks/design-quality-check'); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +let passed = 0; +let failed = 0; + +console.log('\nDesign Quality Hook Tests'); +console.log('=========================\n'); + +if (test('passes through non-frontend files silently', () => { + const input = JSON.stringify({ tool_input: { file_path: '/tmp/file.py' } }); + const result = hook.run(input); + assert.strictEqual(result.exitCode, 0); + assert.strictEqual(result.stdout, input); + assert.ok(!result.stderr); +})) passed++; else failed++; + +if (test('warns for frontend file path', () => { + const tmpFile = path.join(os.tmpdir(), `design-quality-${Date.now()}.tsx`); + fs.writeFileSync(tmpFile, 'export function Hero(){ return
Get Started
; }\n'); + try { + const input = JSON.stringify({ tool_input: { file_path: tmpFile } }); + const result = hook.run(input); + assert.strictEqual(result.exitCode, 0); + assert.strictEqual(result.stdout, input); + assert.match(result.stderr, /DESIGN CHECK/); + assert.match(result.stderr, /Get Started/); + } finally { + fs.unlinkSync(tmpFile); + } +})) passed++; else failed++; + +if (test('handles MultiEdit edits[] payloads', () => { + const tmpFile = path.join(os.tmpdir(), `design-quality-${Date.now()}.css`); + fs.writeFileSync(tmpFile, '.hero{background:linear-gradient(to right,#000,#333)}\n'); + try { + const input = JSON.stringify({ + tool_input: { + edits: [{ file_path: tmpFile }, { file_path: '/tmp/notes.md' }] + } + }); + const result = hook.run(input); + assert.strictEqual(result.exitCode, 0); + assert.strictEqual(result.stdout, input); + assert.match(result.stderr, /frontend file\(s\) modified/); + assert.match(result.stderr, /\.css/); + } finally { + fs.unlinkSync(tmpFile); + } +})) passed++; else failed++; + +if (test('returns original stdout on invalid JSON', () => { + const input = '{not valid json'; + const result = hook.run(input); + assert.strictEqual(result.exitCode, 0); + assert.strictEqual(result.stdout, input); +})) passed++; else failed++; + +console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); +process.exit(failed > 0 ? 1 : 0);