Skill: 727155455/frontend-doctor (version 1.0.0)

--- name: Frontend Doctor description: Diagnose and fix common frontend issues — white screen, JS errors, resource loading failures, React/Vue hydration, browser extension popup, and CSS layout bugs. metadata: openclaw: emoji: 🩺 homepage: https://github.com/openclaw/clawhub skillKey: frontend-doctor always: false requires: bins: - node --- # Frontend Doctor You are a senior frontend engineer and debugger. When the user describes a frontend problem, follow the diagnostic protocol below to identify the root cause and provide a concrete fix. ## Supported Problem Types 1. **White Screen** — Page renders blank with no visible content 2. **JS Errors** — Runtime JavaScript exceptions crashing the page 3. **Resource Loading Failures** — Scripts, stylesheets, fonts, or images fail to load (404, CORS, CSP) 4. **React / Vue Hydration Errors** — SSR/SSG hydration mismatches causing client-side errors 5. **Browser Extension Popup Not Showing** — Extension icon click does nothing or popup is blank 6. **CSS Layout Issues** — Broken layout, overflow, z-index, flexbox/grid misalignment --- ## Diagnostic Protocol ### Step 1 — Gather Context Ask the user for: - Framework / library (React, Vue, Svelte, vanilla JS, etc.) - Build tool (Vite, Webpack, Next.js, Nuxt, etc.) - Browser and version - The exact error message or symptom - Relevant code snippets (component, config, HTML) - Console output (errors, warnings) - Network tab findings (failed requests, status codes) ### Step 2 — Run Targeted Diagnosis #### 🔲 White Screen **Common causes:** - JS bundle fails to load or throws before mount - Root element (`#app`, `#root`) missing in HTML - Router misconfiguration (SPA 404 on refresh) - Environment variable missing at build time - Async component or lazy import throws silently **Checklist:** ``` □ Open DevTools → Console: any uncaught errors? □ Open DevTools → Network: is main bundle (main.js / chunk.js) returning 200? □ Check HTML source: does <div id="root"> or <div id="app"> exist? □ Check if window.__INITIAL_STATE__ or similar SSR data is missing □ Add error boundary (React) or errorCaptured (Vue) to catch silent throws □ Verify VITE_* / NEXT_PUBLIC_* env vars are set in production build ``` **Quick fixes:** ```jsx // React: Add error boundary at root class ErrorBoundary extends React.Component { state = { error: null } componentDidCatch(error) { this.setState({ error }) } render() { if (this.state.error) return <pre>{this.state.error.message}</pre> return this.props.children } } ``` ```js // Vite SPA: fix 404 on refresh — configure server fallback // vite.config.ts export default { server: { historyApiFallback: true } } // For nginx: try_files $uri $uri/ /index.html; ``` --- #### ⚠️ JS Errors **Common causes:** - `Cannot read properties of undefined/null` - Module not found / import path wrong - Async race condition (component unmounted before fetch resolves) - Third-party script conflict - TypeScript compiled to wrong target **Checklist:** ``` □ Read the full stack trace — find YOUR file, not node_modules □ Check if the error is in an async callback (add try/catch) □ Verify all imports resolve (check tsconfig paths, aliases) □ Check if optional chaining (?.) is needed □ Look for useEffect cleanup missing (React) ``` **Quick fixes:** ```js // Safe optional chaining const name = user?.profile?.name ?? 'Anonymous' // React: cancel async on unmount useEffect(() => { let cancelled = false fetchData().then(data => { if (!cancelled) setData(data) }) return () => { cancelled = true } }, []) ``` --- #### 📦 Resource Loading Failures **Common causes:** - Wrong `publicPath` / `base` in build config - CORS headers missing on CDN/API - Content Security Policy blocking scripts - Asset hash mismatch after deploy (stale cache) - Font/image path wrong in CSS **Checklist:** ``` □ Network tab: check exact URL being requested vs actual file location □ Check response headers for CORS: Access-Control-Allow-Origin □ Check browser console for CSP violations □ Verify base URL in vite.config.ts / next.config.js / webpack output.publicPath □ Hard refresh (Cmd+Shift+R) to rule out cache ``` **Quick fixes:** ```ts // vite.config.ts — fix base path for subdirectory deploy export default defineConfig({ base: '/my-app/' }) // next.config.js — fix asset prefix module.exports = { assetPrefix: 'https://cdn.example.com' } ``` ```nginx # nginx CORS for fonts/assets location ~* \.(woff2?|ttf|eot|svg)$ { add_header Access-Control-Allow-Origin *; } ``` --- #### ⚛️ React / Vue Hydration Errors **Common causes:** - Server HTML doesn't match client render (date, random ID, window check) - Using `typeof window !== 'undefined'` incorrectly in render - Third-party component not SSR-safe - Mismatched whitespace in HTML **React checklist:** ``` □ Error: "Hydration failed because the initial UI does not match" □ Find component that reads browser-only APIs (localStorage, window, Date.now()) □ Wrap browser-only code in useEffect or dynamic import with ssr: false ``` **Quick fixes:** ```jsx // Next.js: disable SSR for a component import dynamic from 'next/dynamic' const BrowserOnlyChart = dynamic(() => import('./Chart'), { ssr: false }) // React 18: suppress hydration warning for intentional mismatch <time suppressHydrationWarning>{new Date().toLocaleString()}</time> ``` **Vue / Nuxt checklist:** ``` □ Error: "Hydration node mismatch" □ Use <ClientOnly> wrapper for browser-only components □ Avoid v-if based on window/document in SSR context ``` ```vue <!-- Nuxt: wrap browser-only content --> <ClientOnly> <BrowserOnlyComponent /> </ClientOnly> ``` --- #### 🧩 Browser Extension Popup Not Showing **Common causes:** - `popup.html` not declared in `manifest.json` - Content Security Policy blocking inline scripts in popup - JS error in popup script crashes before render - Manifest V3: service worker not registered - Hot reload dev server URL used instead of extension URL **Checklist:** ``` □ Check manifest.json → action.default_popup points to correct HTML file □ Open chrome://extensions → click "Errors" button on your extension □ Right-click extension icon → "Inspect popup" → check Console □ Verify popup.html has <script src="popup.js"> (no inline scripts in MV3) □ Check that popup.js is listed in web_accessible_resources if needed ``` **Quick fixes:** ```json // manifest.json (MV3) { "manifest_version": 3, "action": { "default_popup": "popup.html", "default_icon": "icon.png" }, "content_security_policy": { "extension_pages": "script-src 'self'; object-src 'self'" } } ``` ```html <!-- popup.html — no inline scripts allowed in MV3 --> <!DOCTYPE html> <html> <body> <div id="root"></div> <script src="popup.js"></script> </body> </html> ``` --- #### 🎨 CSS Layout Issues **Common causes:** - Flexbox/Grid child overflow not handled - `z-index` not working (stacking context issue) - `position: absolute` escaping wrong parent - `100vh` broken on mobile (address bar) - Margin collapse unexpected behavior - CSS specificity conflict **Checklist:** ``` □ Open DevTools → Elements → Computed styles: check display, position, overflow □ Use DevTools Layout panel to visualize flex/grid □ Check if z-index parent has position set (required for z-index to work) □ Check if overflow: hidden on parent clips child □ Use outline: 1px solid red on elements to debug box model ``` **Quick fixes:** ```css /* Fix z-index not working — parent needs a stacking context */ .parent { position: relative; /* or absolute/fixed/sticky */ z-index: 0; } /* Fix 100vh on mobile */ .full-height { height: 100vh; height: 100dvh; /* dynamic viewport height */ } /* Fix flex child overflow */ .flex-child { min-width: 0; /* allows text truncation inside flex */ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* Debug layout quickly */ * { outline: 1px solid rgba(255, 0, 0, 0.2); } ``` --- ## Step 3 — Provide Fix After diagnosis, provide: 1. **Root cause** — one clear sentence 2. **Fix** — minimal code change with before/after if applicable 3. **Verification** — how to confirm the fix worked 4. **Prevention** — one tip to avoid recurrence --- ## Usage Examples ``` /frontend-doctor my Next.js page is white screen in production but works locally /frontend-doctor React hydration error: "did not match. Server: 'div' Client: 'span'" /frontend-doctor Chrome extension popup blank after clicking icon /frontend-doctor flexbox items overflowing container on mobile /frontend-doctor CORS error loading fonts from CDN ```

path: README.md
content: # Frontend Doctor

Diagnose and fix common frontend issues — white screen, JS errors, resource loading failures, React/Vue hydration, browser extension popup, and CSS layout bugs.

## Install

```bash
npm install -g @openclaw/frontend-doctor
```

## Usage

```bash
# Scan current directory
frontend-doctor

# Scan a specific project
frontend-doctor ./my-app
```

## Diagnostics

| Module | What it checks |
|---|---|
| White Screen | Missing root element, env vars, lazy imports without Suspense, missing ErrorBoundary |
| JS Errors | Deep property access, unhandled async, useEffect cleanup, TS target |
| Resource Loading | Base path config, mixed content, CSS absolute URLs |
| Hydration | SSR window access, non-deterministic render, dangerouslySetInnerHTML, "use client" |
| CSS Layout | 100vh mobile, overflow hidden, z-index wars, flex truncation, box-sizing |
| Extension Popup | Manifest config, CSP inline scripts, localStorage, deprecated APIs, permissions |

## OpenClaw Skill

This is an [OpenClaw](https://github.com/openclaw/clawhub) skill. Use it with:

```
/frontend-doctor my Next.js page is white screen in production
```

## License

MIT


path: package.json
content: {
  "name": "@openclaw/frontend-doctor",
  "version": "1.0.0",
  "description": "Diagnose and fix common frontend issues — white screen, JS errors, resource loading failures, React/Vue hydration, browser extension popup, and CSS layout bugs.",
  "main": "./src/cli.js",
  "exports": {
    ".": "./src/cli.js",
    "./diagnostics/*": "./src/diagnostics/*.js"
  },
  "bin": {
    "frontend-doctor": "./src/cli.js"
  },
  "type": "module",
  "scripts": {
    "start": "node src/cli.js",
    "test": "node --test test/"
  },
  "engines": {
    "node": ">=18.0.0"
  },
  "files": [
    "src/",
    "SKILL.md",
    "LICENSE"
  ],
  "keywords": [
    "openclaw",
    "skill",
    "frontend",
    "debugging",
    "diagnosis",
    "react",
    "vue",
    "css",
    "hydration",
    "white-screen"
  ],
  "repository": {
    "type": "git",
    "url": "https://github.com/openclaw/clawhub"
  },
  "license": "MIT",
  "homepage": "https://github.com/openclaw/clawhub",
  "openclaw": {
    "skillKey": "frontend-doctor"
  }
}


path: src/cli.js
content: #!/usr/bin/env node
import { resolve } from 'path';
import { diagnoseWhiteScreen } from './diagnostics/white-screen.js';
import { diagnoseJsErrors } from './diagnostics/js-errors.js';
import { diagnoseResourceLoading } from './diagnostics/resource-loading.js';
import { diagnoseHydration } from './diagnostics/hydration.js';
import { diagnoseCssLayout } from './diagnostics/css-layout.js';
import { diagnoseExtensionPopup } from './diagnostics/extension-popup.js';
import { formatReport } from './report.js';

const cwd = resolve(process.argv[2] || '.');

console.log(`\n  \x1b[1mfrontend-doctor\x1b[0m  scanning ${cwd} ...\n`);

const results = [
  ...diagnoseWhiteScreen(cwd),
  ...diagnoseJsErrors(cwd),
  ...diagnoseResourceLoading(cwd),
  ...diagnoseHydration(cwd),
  ...diagnoseCssLayout(cwd),
  ...diagnoseExtensionPopup(cwd),
];

formatReport(results);

process.exit(results.length > 0 ? 1 : 0);


path: src/diagnostics/css-layout.js
content: import { findFiles, grepFiles } from '../scanner.js';

export function diagnoseCssLayout(cwd) {
  const results = [];
  const cssFiles = findFiles(cwd, ['.css', '.scss', '.less']);
  const jsFiles = findFiles(cwd, ['.js', '.jsx', '.ts', '.tsx', '.vue']);
  const allStyleFiles = [...cssFiles, ...jsFiles];

  // Check: 100vh on mobile — doesn't account for browser chrome
  const vh100 = grepFiles(allStyleFiles, /100vh/);
  if (vh100.length > 0) {
    results.push({
      rootCause: '100vh used — overflows on mobile due to browser chrome',
      evidence: `${vh100[0].file}:${vh100[0].line} — ${vh100[0].text}`,
      fix: 'Use 100dvh (dynamic viewport height) or min-height: 100vh with overflow handling',
    });
  }

  // Check: overflow hidden on body — can break scroll
  const overflowBody = grepFiles(cssFiles, /body\s*\{[^}]*overflow\s*:\s*hidden/);
  if (overflowBody.length > 0) {
    results.push({
      rootCause: 'overflow: hidden on body — may prevent page scrolling',
      evidence: `${overflowBody[0].file}:${overflowBody[0].line} — ${overflowBody[0].text}`,
      fix: 'Remove overflow: hidden from body, or scope it to a modal-open state',
    });
  }

  // Check: z-index wars (very high values)
  const highZIndex = grepFiles(allStyleFiles, /z-index\s*:\s*(\d{4,})/);
  if (highZIndex.length > 3) {
    results.push({
      rootCause: 'Multiple high z-index values — likely z-index stacking issues',
      evidence: `${highZIndex.length} instances found, e.g. ${highZIndex[0].file}:${highZIndex[0].line}`,
      fix: 'Define a z-index scale (e.g. --z-dropdown: 100, --z-modal: 200) and use CSS custom properties',
    });
  }

  // Check: flex/grid without min-width: 0 — content overflow
  const flexContainers = grepFiles(allStyleFiles, /display\s*:\s*flex/);
  const minWidth0 = grepFiles(allStyleFiles, /min-width\s*:\s*0/);
  const textOverflow = grepFiles(allStyleFiles, /text-overflow\s*:\s*ellipsis/);
  if (flexContainers.length > 0 && textOverflow.length > 0 && minWidth0.length === 0) {
    results.push({
      rootCause: 'Flex layout with text-overflow but no min-width: 0 — text may overflow container',
      evidence: `${textOverflow[0].file}:${textOverflow[0].line} — ${textOverflow[0].text}`,
      fix: 'Add min-width: 0 to flex children that need text truncation',
    });
  }

  // Check: missing box-sizing border-box
  const boxSizing = grepFiles(cssFiles, /box-sizing\s*:\s*border-box/);
  const widthPercent = grepFiles(cssFiles, /width\s*:\s*\d+%/);
  const padding = grepFiles(cssFiles, /padding\s*:/);
  if (boxSizing.length === 0 && widthPercent.length > 0 && padding.length > 0) {
    results.push({
      rootCause: 'No box-sizing: border-box — percentage widths + padding will overflow',
      evidence: 'No box-sizing reset found in stylesheets',
      fix: 'Add *, *::before, *::after { box-sizing: border-box; } to your global styles',
    });
  }

  // Check: position fixed/sticky without width
  const fixedNoWidth = grepFiles(cssFiles, /position\s*:\s*(fixed|sticky)/);
  if (fixedNoWidth.length > 0) {
    const widthDecl = grepFiles(cssFiles, /position\s*:\s*(fixed|sticky)[^}]*width\s*:/);
    if (widthDecl.length === 0) {
      results.push({
        rootCause: 'position: fixed/sticky without explicit width — element may collapse or overflow',
        evidence: `${fixedNoWidth[0].file}:${fixedNoWidth[0].line} — ${fixedNoWidth[0].text}`,
        fix: 'Add width: 100% or explicit dimensions to fixed/sticky elements',
      });
    }
  }

  return results;
}


path: src/diagnostics/extension-popup.js
content: import { join } from 'path';
import { existsSync } from 'fs';
import { findFiles, grepFiles, readJson, readText } from '../scanner.js';

export function diagnoseExtensionPopup(cwd) {
  const results = [];
  const manifest = readJson(join(cwd, 'manifest.json'));
  if (!manifest || !manifest.manifest_version) return results;

  const isV3 = manifest.manifest_version === 3;
  const jsFiles = findFiles(cwd, ['.js', '.ts', '.jsx', '.tsx']);
  const htmlFiles = findFiles(cwd, ['.html']);

  // Check: popup HTML missing or misconfigured
  const popupPath = manifest.action?.default_popup || manifest.browser_action?.default_popup;
  if (popupPath) {
    if (!existsSync(join(cwd, popupPath))) {
      results.push({
        rootCause: 'Popup HTML file referenced in manifest does not exist',
        evidence: `manifest.json — default_popup: "${popupPath}" not found`,
        fix: `Create the file at ${popupPath}`,
      });
    }
  } else {
    if (isV3 && !manifest.action) {
      results.push({
        rootCause: 'MV3 manifest missing "action" field — popup will not appear',
        evidence: 'manifest.json — no "action" key found',
        fix: 'Add "action": { "default_popup": "popup.html" } to manifest.json',
      });
    }
    if (!isV3 && !manifest.browser_action) {
      results.push({
        rootCause: 'MV2 manifest missing "browser_action" — popup will not appear',
        evidence: 'manifest.json — no "browser_action" key found',
        fix: 'Add "browser_action": { "default_popup": "popup.html" } to manifest.json',
      });
    }
  }

  // Check: CSP blocks inline scripts (MV3 default)
  if (isV3) {
    for (const file of htmlFiles) {
      const content = readText(file);
      if (content && /<script(?!\s+src)[^>]*>[\s\S]+?<\/script>/.test(content)) {
        results.push({
          rootCause: 'Inline script in MV3 extension — blocked by Content Security Policy',
          evidence: `${file} — inline <script> tag found`,
          fix: 'Move inline scripts to separate .js files and reference with <script src="...">',
        });
        break;
      }
    }
  }

  // Check: popup uses localStorage instead of chrome.storage
  const localStorageUse = grepFiles(jsFiles, /localStorage\.(get|set)Item/);
  if (localStorageUse.length > 0) {
    results.push({
      rootCause: 'localStorage used in extension — data is scoped to popup and lost when closed',
      evidence: `${localStorageUse[0].file}:${localStorageUse[0].line} — ${localStorageUse[0].text}`,
      fix: 'Use chrome.storage.local or chrome.storage.sync for persistent extension data',
    });
  }

  // Check: MV3 using chrome.browserAction (removed in MV3)
  if (isV3) {
    const oldApi = grepFiles(jsFiles, /chrome\.browserAction/);
    if (oldApi.length > 0) {
      results.push({
        rootCause: 'chrome.browserAction used in MV3 — API removed, use chrome.action instead',
        evidence: `${oldApi[0].file}:${oldApi[0].line} — ${oldApi[0].text}`,
        fix: 'Replace chrome.browserAction with chrome.action (MV3)',
      });
    }
  }

  // Check: missing permissions for APIs used
  const permissions = manifest.permissions || [];
  const apiPermMap = {
    'chrome.tabs': 'tabs',
    'chrome.storage': 'storage',
    'chrome.alarms': 'alarms',
    'chrome.notifications': 'notifications',
    'chrome.cookies': 'cookies',
  };
  for (const [api, perm] of Object.entries(apiPermMap)) {
    const usage = grepFiles(jsFiles, new RegExp(api.replace('.', '\\.')));
    if (usage.length > 0 && !permissions.includes(perm)) {
      results.push({
        rootCause: `${api} used but "${perm}" permission not declared`,
        evidence: `${usage[0].file}:${usage[0].line} — ${usage[0].text}`,
        fix: `Add "${perm}" to permissions array in manifest.json`,
      });
    }
  }

  return results;
}


path: src/diagnostics/hydration.js
content: import { detectFramework, findFiles, grepFiles, readText } from '../scanner.js';

export function diagnoseHydration(cwd) {
  const results = [];
  const { framework } = detectFramework(cwd);
  const jsFiles = findFiles(cwd, ['.js', '.jsx', '.ts', '.tsx']);

  // React / Next.js hydration issues
  if (framework === 'react' || framework === 'next') {
    // Check: window accessed during SSR without guard
    const windowAccess = grepFiles(jsFiles, /window\.\w+/);
    const windowGuards = grepFiles(jsFiles, /typeof\s+window\s*[!=]==?\s*['"]undefined['"]/);
    const useEffectWindow = grepFiles(jsFiles, /useEffect\s*\([\s\S]*?window/);
    if (windowAccess.length > 0 && windowGuards.length === 0 && useEffectWindow.length === 0) {
      results.push({
        rootCause: 'window accessed during SSR without guard — causes hydration mismatch',
        evidence: `${windowAccess[0].file}:${windowAccess[0].line} — ${windowAccess[0].text}`,
        fix: 'Wrap browser APIs in useEffect or guard with typeof window !== "undefined"',
      });
    }

    // Check: Date/Math.random in render — non-deterministic output
    const nonDeterministic = grepFiles(jsFiles, /new Date\(\)|Date\.now\(\)|Math\.random\(\)/);
    const filtered = nonDeterministic.filter(m =>
      !m.text.startsWith('//') && !m.text.startsWith('*') && !m.text.includes('useEffect')
    );
    if (filtered.length > 0) {
      results.push({
        rootCause: 'Non-deterministic value (Date/Math.random) in render — server/client mismatch',
        evidence: `${filtered[0].file}:${filtered[0].line} — ${filtered[0].text}`,
        fix: 'Move Date.now()/Math.random() into useEffect or pass as props from server',
      });
    }

    // Check: dangerouslySetInnerHTML with dynamic content
    const dangerousHtml = grepFiles(jsFiles, /dangerouslySetInnerHTML/);
    if (dangerousHtml.length > 0) {
      results.push({
        rootCause: 'dangerouslySetInnerHTML can cause hydration mismatch if content differs server/client',
        evidence: `${dangerousHtml[0].file}:${dangerousHtml[0].line} — ${dangerousHtml[0].text}`,
        fix: 'Ensure HTML string is identical on server and client, or use suppressHydrationWarning',
      });
    }

    // Check: Next.js — missing "use client" with hooks
    if (framework === 'next') {
      const hookFiles = grepFiles(jsFiles, /\b(useState|useEffect|useRef|useContext)\s*\(/);
      for (const match of hookFiles.slice(0, 5)) {
        const content = readText(match.file);
        if (content && !content.startsWith("'use client'") && !content.startsWith('"use client"')) {
          results.push({
            rootCause: 'React hook used in Next.js file without "use client" directive',
            evidence: `${match.file}:${match.line} — ${match.text}`,
            fix: 'Add "use client" at the top of the file, or move hook usage to a client component',
          });
          break;
        }
      }
    }
  }

  // Vue / Nuxt hydration issues
  if (framework === 'vue' || framework === 'nuxt') {
    const vueFiles = findFiles(cwd, ['.vue', '.js', '.ts']);

    // Check: document/window in setup or created
    const browserInSetup = grepFiles(vueFiles, /(setup|created)\s*\([\s\S]*?(document|window)\./);
    const directAccess = grepFiles(vueFiles, /^\s*(document|window)\.\w+/);
    if (browserInSetup.length > 0 || directAccess.length > 0) {
      const match = browserInSetup[0] || directAccess[0];
      results.push({
        rootCause: 'Browser API accessed during SSR in Vue — causes hydration mismatch',
        evidence: `${match.file}:${match.line} — ${match.text}`,
        fix: 'Use onMounted() or <ClientOnly> wrapper for browser-dependent code',
      });
    }

    // Check: v-html with dynamic content
    const vHtml = grepFiles(vueFiles, /v-html\s*=/);
    if (vHtml.length > 0) {
      results.push({
        rootCause: 'v-html can cause hydration mismatch if content differs server/client',
        evidence: `${vHtml[0].file}:${vHtml[0].line} — ${vHtml[0].text}`,
        fix: 'Ensure v-html content is identical on server and client, or wrap in <ClientOnly>',
      });
    }
  }

  return results;
}


path: src/diagnostics/js-errors.js
content: import { existsSync } from 'fs';
import { detectFramework, findFiles, grepFiles } from '../scanner.js';

export function diagnoseJsErrors(cwd) {
  const results = [];
  const { framework } = detectFramework(cwd);
  const jsFiles = findFiles(cwd, ['.js', '.jsx', '.ts', '.tsx']);

  // Check: unguarded property access patterns
  const unsafeAccess = grepFiles(jsFiles, /\w+\.\w+\.\w+(?!\?)/ );
  const deepChains = unsafeAccess.filter(m =>
    !m.text.includes('?.') &&
    /\w+\.\w+\.\w+\.\w+/.test(m.text) &&
    !m.text.startsWith('//') &&
    !m.text.startsWith('*') &&
    !m.text.includes('console.') &&
    !m.text.includes('import') &&
    !m.text.includes('require')
  );
  if (deepChains.length > 0) {
    const sample = deepChains[0];
    results.push({
      rootCause: 'Deep property access without optional chaining — risk of "Cannot read properties of undefined"',
      evidence: `${sample.file}:${sample.line} — ${sample.text}`,
      fix: 'Use optional chaining (?.) for nested property access, e.g. obj?.nested?.prop',
    });
  }

  // Check: async without try/catch
  const asyncNoTry = grepFiles(jsFiles, /async\s+\w+|async\s*\(/);
  if (asyncNoTry.length > 0) {
    const tryCatches = grepFiles(jsFiles, /try\s*\{/);
    if (tryCatches.length === 0 && asyncNoTry.length > 2) {
      results.push({
        rootCause: 'Async functions found but no try/catch blocks — unhandled promise rejections likely',
        evidence: `${asyncNoTry[0].file}:${asyncNoTry[0].line} — ${asyncNoTry[0].text}`,
        fix: 'Wrap async operations in try/catch or add .catch() to promise chains',
      });
    }
  }

  // Check: useEffect without cleanup (React)
  if (framework === 'react' || framework === 'next') {
    const effectsWithFetch = grepFiles(jsFiles, /useEffect\s*\(\s*\(\)\s*=>\s*\{[\s\S]*fetch/);
    const effectCleanups = grepFiles(jsFiles, /return\s*\(\)\s*=>\s*\{/);
    if (effectsWithFetch.length > 0 && effectCleanups.length === 0) {
      results.push({
        rootCause: 'useEffect with fetch but no cleanup — may update state on unmounted component',
        evidence: `${effectsWithFetch[0].file}:${effectsWithFetch[0].line} — ${effectsWithFetch[0].text}`,
        fix: 'Add cleanup: useEffect(() => { let cancelled = false; fetch().then(d => { if (!cancelled) set(d) }); return () => { cancelled = true } }, [])',
      });
    }
  }

  // Check: tsconfig target too low
  const tsFiles = findFiles(cwd, ['.ts', '.tsx']);
  if (tsFiles.length > 0) {
    const tsconfig = grepFiles([cwd + '/tsconfig.json'].filter(f => existsSync(f)), /"target"\s*:\s*"(es5|es3)"/i);
    if (tsconfig.length > 0) {
      results.push({
        rootCause: 'TypeScript target set too low — modern syntax may not compile correctly',
        evidence: `${tsconfig[0].file}:${tsconfig[0].line} — ${tsconfig[0].text}`,
        fix: 'Update tsconfig.json target to "es2017" or higher',
      });
    }
  }

  return results;
}


path: src/diagnostics/resource-loading.js
content: import { join } from 'path';
import { detectFramework, findFiles, grepFiles, readText, readJson } from '../scanner.js';

export function diagnoseResourceLoading(cwd) {
  const results = [];
  const { buildTool } = detectFramework(cwd);
  const cssFiles = findFiles(cwd, ['.css', '.scss', '.less']);
  const configFiles = findFiles(cwd, ['.js', '.ts', '.mjs']);

  // Check: vite base path
  if (buildTool === 'vite') {
    const viteConfig = readText(join(cwd, 'vite.config.ts')) || readText(join(cwd, 'vite.config.js'));
    if (viteConfig && !/base\s*:/.test(viteConfig)) {
      results.push({
        rootCause: 'Vite config missing "base" — assets may 404 when deployed to a subdirectory',
        evidence: 'vite.config.{ts,js} — no base property found',
        fix: 'Add base: "/your-subpath/" to vite.config if deploying to a subdirectory',
      });
    }
  }

  // Check: webpack publicPath
  if (buildTool === 'webpack') {
    const wpConfig = readText(join(cwd, 'webpack.config.js'));
    if (wpConfig && !/publicPath/.test(wpConfig)) {
      results.push({
        rootCause: 'Webpack config missing output.publicPath — assets may load from wrong path',
        evidence: 'webpack.config.js — no publicPath in output config',
        fix: 'Add output.publicPath: "/" or your deploy path to webpack.config.js',
      });
    }
  }

  // Check: next.js assetPrefix
  if (buildTool === 'next') {
    const nextConfig = readText(join(cwd, 'next.config.js')) || readText(join(cwd, 'next.config.mjs'));
    if (nextConfig && /assetPrefix/.test(nextConfig)) {
      const hasTrailingSlash = /assetPrefix\s*:\s*['"][^'"]+\/['"]/.test(nextConfig);
      if (!hasTrailingSlash) {
        results.push({
          rootCause: 'next.config assetPrefix may be missing trailing slash',
          evidence: 'next.config.{js,mjs} — assetPrefix found without trailing slash',
          fix: 'Ensure assetPrefix ends with / e.g. "https://cdn.example.com/"',
        });
      }
    }
  }

  // Check: font/image paths in CSS using absolute paths without base
  const badPaths = grepFiles(cssFiles, /url\(\s*['"]?\//);
  if (badPaths.length > 0) {
    results.push({
      rootCause: 'CSS url() uses absolute path — may break in subdirectory deployments',
      evidence: `${badPaths[0].file}:${badPaths[0].line} — ${badPaths[0].text}`,
      fix: 'Use relative paths in CSS url() or ensure build tool rewrites them correctly',
    });
  }

  // Check: mixed content (http in https context)
  const allFiles = findFiles(cwd, ['.js', '.jsx', '.ts', '.tsx', '.html', '.css']);
  const httpRefs = grepFiles(allFiles, /["']http:\/\/(?!localhost)/);
  if (httpRefs.length > 0) {
    results.push({
      rootCause: 'HTTP resource referenced — will be blocked on HTTPS pages (mixed content)',
      evidence: `${httpRefs[0].file}:${httpRefs[0].line} — ${httpRefs[0].text}`,
      fix: 'Change http:// to https:// or use protocol-relative // URLs',
    });
  }

  return results;
}


path: src/diagnostics/white-screen.js
content: import { join } from 'path';
import { existsSync } from 'fs';
import { detectFramework, findFiles, grepFiles, readText } from '../scanner.js';

export function diagnoseWhiteScreen(cwd) {
  const results = [];
  const { framework, buildTool, pkg, deps } = detectFramework(cwd);
  const htmlFiles = findFiles(cwd, ['.html']);
  const jsFiles = findFiles(cwd, ['.js', '.jsx', '.ts', '.tsx']);

  // Check: root element missing in HTML
  for (const file of htmlFiles) {
    const content = readText(file);
    if (!content) continue;
    const hasRoot = /id=["'](root|app|__next|__nuxt)["']/.test(content);
    if (!hasRoot && /<!DOCTYPE|<html/i.test(content)) {
      results.push({
        rootCause: 'Root mount element (#root / #app) missing in HTML',
        evidence: `${file} — no <div id="root"> or <div id="app"> found`,
        fix: 'Add <div id="root"></div> (or #app) inside <body> before your script tag',
      });
    }
  }

  // Check: env vars referenced but .env missing
  const envFile = join(cwd, '.env');
  const envLocalFile = join(cwd, '.env.local');
  const envPatterns = {
    vite: /import\.meta\.env\.\w+/,
    next: /process\.env\.NEXT_PUBLIC_\w+/,
    cra: /process\.env\.REACT_APP_\w+/,
  };
  for (const [tool, pattern] of Object.entries(envPatterns)) {
    const matches = grepFiles(jsFiles, pattern);
    if (matches.length > 0 && !existsSync(envFile) && !existsSync(envLocalFile)) {
      results.push({
        rootCause: `Environment variables referenced but no .env file found`,
        evidence: `${matches[0].file}:${matches[0].line} — ${matches[0].text}`,
        fix: `Create .env file with the required variables (${tool} prefix)`,
      });
      break;
    }
  }

  // Check: lazy import without error boundary (React)
  if (framework === 'react' || framework === 'next') {
    const lazyMatches = grepFiles(jsFiles, /React\.lazy\(|lazy\(/);
    if (lazyMatches.length > 0) {
      const suspenseMatches = grepFiles(jsFiles, /<Suspense/);
      if (suspenseMatches.length === 0) {
        results.push({
          rootCause: 'React.lazy() used without <Suspense> fallback',
          evidence: `${lazyMatches[0].file}:${lazyMatches[0].line} — ${lazyMatches[0].text}`,
          fix: 'Wrap lazy components with <Suspense fallback={<Loading />}>',
        });
      }
    }
    const boundaryMatches = grepFiles(jsFiles, /componentDidCatch|ErrorBoundary/);
    if (boundaryMatches.length === 0) {
      results.push({
        rootCause: 'No ErrorBoundary found — JS errors will cause white screen',
        evidence: 'No componentDidCatch or ErrorBoundary detected in source files',
        fix: 'Add an ErrorBoundary component at the root of your app to catch render errors',
      });
    }
  }

  // Check: Vue errorCaptured missing
  if (framework === 'vue' || framework === 'nuxt') {
    const errorHandlerMatches = grepFiles(jsFiles, /errorCaptured|app\.config\.errorHandler/);
    if (errorHandlerMatches.length === 0) {
      results.push({
        rootCause: 'No global error handler — Vue errors may cause white screen',
        evidence: 'No errorCaptured hook or app.config.errorHandler found',
        fix: 'Add app.config.errorHandler in your main entry or use errorCaptured in root component',
      });
    }
  }

  // Check: SPA history fallback missing
  if (buildTool === 'vite') {
    const viteConfig = readText(join(cwd, 'vite.config.ts')) || readText(join(cwd, 'vite.config.js'));
    if (viteConfig && !/historyApiFallback/.test(viteConfig)) {
      const routerUsed = grepFiles(jsFiles, /createBrowserRouter|BrowserRouter|createRouter.*history/);
      if (routerUsed.length > 0) {
        results.push({
          rootCause: 'SPA router detected but no history fallback configured — refresh will 404',
          evidence: `${routerUsed[0].file}:${routerUsed[0].line} — ${routerUsed[0].text}`,
          fix: 'For dev: add server.historyApiFallback in vite.config. For prod: configure try_files in nginx/server',
        });
      }
    }
  }

  return results;
}


path: src/report.js
content: export function formatReport(results) {
  if (!results.length) {
    console.log('\n  \x1b[32m✓ No issues detected.\x1b[0m\n');
    return;
  }
  for (const r of results) {
    console.log(`\n  \x1b[31m✗ Root Cause:\x1b[0m  ${r.rootCause}`);
    console.log(`    \x1b[33mEvidence:\x1b[0m    ${r.evidence}`);
    console.log(`    \x1b[36mFix:\x1b[0m         ${r.fix}`);
  }
  console.log();
}


path: src/scanner.js
content: import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
import { join, extname } from 'path';

export function readJson(filePath) {
  try {
    return JSON.parse(readFileSync(filePath, 'utf-8'));
  } catch {
    return null;
  }
}

export function readText(filePath) {
  try {
    return readFileSync(filePath, 'utf-8');
  } catch {
    return null;
  }
}

export function findFiles(dir, extensions, maxDepth = 4, depth = 0) {
  if (depth > maxDepth || !existsSync(dir)) return [];
  const results = [];
  try {
    for (const entry of readdirSync(dir)) {
      if (entry.startsWith('.') || entry === 'node_modules' || entry === 'dist' || entry === 'build') continue;
      const full = join(dir, entry);
      try {
        const stat = statSync(full);
        if (stat.isDirectory()) {
          results.push(...findFiles(full, extensions, maxDepth, depth + 1));
        } else if (extensions.includes(extname(entry))) {
          results.push(full);
        }
      } catch { /* skip inaccessible */ }
    }
  } catch { /* skip unreadable dir */ }
  return results;
}

export function detectFramework(cwd) {
  const pkg = readJson(join(cwd, 'package.json'));
  if (!pkg) return { framework: null, pkg: null };
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };
  let framework = 'vanilla';
  if (deps['next']) framework = 'next';
  else if (deps['nuxt'] || deps['nuxt3']) framework = 'nuxt';
  else if (deps['react']) framework = 'react';
  else if (deps['vue']) framework = 'vue';
  else if (deps['svelte']) framework = 'svelte';
  let buildTool = null;
  if (deps['vite']) buildTool = 'vite';
  else if (deps['webpack']) buildTool = 'webpack';
  else if (deps['next']) buildTool = 'next';
  else if (deps['nuxt'] || deps['nuxt3']) buildTool = 'nuxt';
  return { framework, buildTool, pkg, deps };
}

export function grepFiles(files, pattern) {
  const matches = [];
  for (const file of files) {
    const content = readText(file);
    if (!content) continue;
    const lines = content.split('\n');
    for (let i = 0; i < lines.length; i++) {
      if (pattern.test(lines[i])) {
        matches.push({ file, line: i + 1, text: lines[i].trim() });
      }
    }
  }
  return matches;
}


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)