Files
vercel__vercel-plugin/hooks/posttooluse-shadcn-font-fix.mjs
John Lindquist 1142c51906 fix(user-prompt-submit): support cursor prompt hook
Normalize UserPromptSubmit input fields across Claude Code and Cursor,
including conversation/session IDs, cwd roots, and prompt/message
fallbacks. Cursor now returns flat beforeSubmitPrompt output with
continue:true and emits seen-skill env patches when no CLAUDE_ENV_FILE is
available, while Claude continues using hookSpecificOutput and appends
seen-skill exports to CLAUDE_ENV_FILE when present.

Verified: bun test hooks/user-prompt-submit-skill-inject.test.ts tests/user-prompt-submit.test.ts
Verified: cursor beforeSubmitPrompt payload returns continue:true + env patch in tests/user-prompt-submit.test.ts
Swarm-Agent: codex-prompt-hook
2026-03-11 15:17:02 -06:00

160 lines
5.1 KiB
JavaScript

#!/usr/bin/env node
/**
* PostToolUse hook: Fix shadcn init breaking Geist fonts
*
* After `npx shadcn init` runs, globals.css gets rewritten with
* `--font-sans: var(--font-sans)` — a circular self-reference that resolves
* to nothing, causing fonts to fall back to Times/serif.
*
* Tailwind v4's `@theme inline` resolves CSS custom properties at parse time,
* NOT at runtime. So `var(--font-geist-sans)` also doesn't work because the
* Next.js font variable is injected via className at runtime.
*
* The fix: use literal font family names in @theme inline.
*
* This hook also reminds to move font variable classNames from <body> to <html>.
*/
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { join } from "node:path";
function detectPlatform(input) {
if ("conversation_id" in input || "cursor_version" in input) {
return "cursor";
}
return "claude-code";
}
function resolveWorkspaceRoot(input) {
if (!Array.isArray(input.workspace_roots)) {
return null;
}
for (const root of input.workspace_roots) {
if (typeof root === "string" && root.trim() !== "") {
return root;
}
}
return null;
}
function resolveCwd(input) {
const candidate = input.cwd
?? input.working_directory
?? resolveWorkspaceRoot(input)
?? process.env.CURSOR_PROJECT_DIR
?? process.env.CLAUDE_WORKING_DIRECTORY
?? process.cwd();
return typeof candidate === "string" && candidate.trim() !== "" ? candidate : process.cwd();
}
// Read hook input from stdin
let input = "";
for await (const chunk of process.stdin) {
input += chunk;
}
let parsed;
try {
parsed = JSON.parse(input);
} catch {
process.exit(0);
}
const platform = detectPlatform(parsed);
const toolName = parsed.tool_name;
const toolInput = parsed.tool_input || {};
// Only trigger after Bash commands that look like shadcn init/add
if (toolName !== "Bash") process.exit(0);
const command = toolInput.command || "";
if (!command.match(/\bnpx\s+shadcn(@latest)?\s+(init|add)\b/)) process.exit(0);
// Find globals.css — check common locations
const cwd = resolveCwd(parsed);
const candidates = [
join(cwd, "app/globals.css"),
join(cwd, "src/app/globals.css"),
];
let globalsPath = null;
for (const candidate of candidates) {
if (existsSync(candidate)) {
globalsPath = candidate;
break;
}
}
if (!globalsPath) process.exit(0);
const content = readFileSync(globalsPath, "utf-8");
// Check for the broken patterns:
// 1. Circular: --font-sans: var(--font-sans)
// 2. Runtime var that @theme can't resolve: --font-sans: var(--font-geist-sans)
const hasBrokenFont =
content.includes("--font-sans: var(--font-sans)") ||
content.includes("--font-sans: var(--font-geist-sans)");
if (!hasBrokenFont) process.exit(0);
// Fix with literal font names that @theme inline can resolve at parse time
let fixed = content.replace(
/--font-sans:\s*var\(--font-(?:sans|geist-sans)\)/g,
'--font-sans: "Geist", "Geist Fallback", ui-sans-serif, system-ui, sans-serif'
);
// Also fix mono if present
fixed = fixed.replace(
/--font-mono:\s*var\(--font-(?:mono|geist-mono)\)/g,
'--font-mono: "Geist Mono", "Geist Mono Fallback", ui-monospace, monospace'
);
writeFileSync(globalsPath, fixed, "utf-8");
const relPath = globalsPath.replace(cwd + "/", "");
// Output a detailed explanation to the conversation
const message = `<!-- shadcn-font-fix -->
**Auto-fix applied to \`${relPath}\`**: Replaced CSS variable font references with literal Geist font family names.
### Why this fix was needed
\`shadcn init\` rewrites \`globals.css\` and sets \`--font-sans: var(--font-sans)\` inside \`@theme inline\` — a circular self-reference that resolves to nothing, causing all text to fall back to Times/serif.
**Why \`var(--font-geist-sans)\` also doesn't work**: Tailwind v4's \`@theme inline\` block resolves CSS custom properties at **CSS parse time** (build time), not at runtime. Next.js \`next/font\` injects \`--font-geist-sans\` via a className on \`<html>\` at **runtime**, so it doesn't exist when \`@theme inline\` evaluates. The variable resolves to empty, and fonts fall back to the browser default serif.
**The correct fix** (applied automatically):
\`\`\`css
@theme inline {
--font-sans: "Geist", "Geist Fallback", ui-sans-serif, system-ui, sans-serif;
--font-mono: "Geist Mono", "Geist Mono Fallback", ui-monospace, monospace;
}
\`\`\`
### Second required fix (manual)
You MUST also move the font variable classNames from \`<body>\` to \`<html>\` in \`layout.tsx\`. The default \`create-next-app\` scaffold puts them on \`<body>\`, but they need to be on \`<html>\` so the \`@font-face\` declarations are scoped to the document root:
\`\`\`tsx
// CORRECT — font variables on <html>
<html lang="en" className={\`\${geistSans.variable} \${geistMono.variable}\`}>
<body className="antialiased">
// WRONG — default scaffold puts them on <body>
<html lang="en">
<body className={\`\${geistSans.variable} \${geistMono.variable} antialiased\`}>
\`\`\`
<!-- /shadcn-font-fix -->`;
const result = platform === "cursor"
? { additional_context: message }
: { additionalContext: message };
console.log(JSON.stringify(result));