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);