How to Safely Integrate Screen Code Generated by Claude Code into an Existing Project
Open Claude Code in the terminal and type /design, and a screen draft pops up in seconds. For a solo developer, this is convenient because it requires less hands-on effort. The trouble begins the moment you open that code.
It completely ignores the shadcn/ui components already set up in the project and crafts brand-new, raw <button> tags instead. Instead of semantic colors registered in the palette, it sprinkles arbitrary hex codes like bg-[#1e293b] across every file. Every time you build a screen, you waste 40 minutes fixing messy import paths and deleting inline styles.
This isn't just your imagination. According to a 2024 study by GitClear analyzing 211 million lines of commits, the proportion of code completely discarded or rewritten within two weeks of introducing AI tools jumped from 3.1% to 5.7%, nearly doubling. The refactoring rate plummeted from 25% to under 10%. Technical debt accumulates just as fast as code grows. Drop the expectation that the model will automatically maintain the existing design system, and tie its hands at the system level instead.
Fixing Component Paths with Project Rule Files
The reason Claude Code ignores existing code is simple: it narrows down and skims only the necessary files to conserve the context window. Without any constraints, the model builds screens by combining the most primitive HTML tags.
Thanks to prompt caching, configuration files at the session root stay at around 10% of the base input cost. They don't disappear even when you reset or compress the conversation. Embedding component reuse rules here prevents the model from recklessly creating raw tags.
Write common component paths and style rules in the CLAUDE.md file at your project root.
`markdown
Design System Guidelines
- Component Reuse (STRICT)
- DO NOT use raw DOM tags (, , ).
- MUST import from @/components/ui:
- Button: import { Button } from "@/components/ui/button"
- Input: import { Input } from "@/components/ui/input"
- Card: import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
- Dialog: import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"
- If a component does not exist in @/components/ui, ask to run: "npx shadcn@latest add ".
- Token Boundaries
- NEVER use arbitrary hex codes or pixel widths: NO bg-[#...], NO w-[...px].
- Use Semantic CSS variables:
- Surfaces: bg-background, bg-card, bg-muted
- Text: text-foreground, text-muted-foreground, text-primary
- Borders: border-border, border-input
`
You don't need to pass a thousand-line global CSS file into your prompt wholesale every time. You can just extract token names into JSON from your Tailwind configuration.
`javascript
// scripts/extract-tokens.mjs
import fs from 'fs';
import resolveConfig from 'tailwindcss/resolveConfig.js';
import tailwindConfig from '../tailwind.config.js';
const fullConfig = resolveConfig(tailwindConfig);
const semanticTokens = {
colors: Object.keys(fullConfig.theme.colors || {}).filter(
(name) => !['inherit', 'current', 'transparent'].includes(name)
),
spacing: Object.keys(fullConfig.theme.spacing || {}),
borderRadius: Object.keys(fullConfig.theme.borderRadius || {}),
};
if (!fs.existsSync('.claude')) {
fs.mkdirSync('.claude');
}
fs.writeFileSync(
'.claude/design-tokens.json',
JSON.stringify(semanticTokens, null, 2)
);
`
Hook this script into postinstall and predev inside package.json.
`json
{
"scripts": {
"postinstall": "node scripts/extract-tokens.mjs",
"predev": "node scripts/extract-tokens.mjs"
}
}
`
The list of available classes updates with every build. The manual labor spent fixing misspelled style classes vanishes.
Automatically Replacing Arbitrary Styles with Hook Scripts
No matter how many warnings you write in the prompt, the model occasionally spits out wacky values. If you throw a single screenshot at it and ask it to build a screen, it will embed fixed widths like w-[380px] tailored to the image aspect ratio. This is the primary culprit behind horizontal scrolling breaking on mobile screens.
To meet WCAG 2.1 AA standards, you must specify criteria by resolution and enforce strict linting the moment a file is generated.
| Verification Area |
Target Criteria |
Required Tailwind Classes |
Blocking Condition |
| Mobile |
390px (base) |
flex-col, w-full, grid-cols-1 |
Horizontal scroll caused by fixed width usage like w-[...px] |
| Tablet |
768px (md:) |
md:flex-row, md:grid-cols-2, md:p-6 |
When mobile 1-column structure persists on wide screens |
| Desktop |
1440px (xl:) |
xl:max-w-7xl, mx-auto, xl:grid-cols-4 |
When layout container expands infinitely on high resolution |
| Dark Mode |
.dark selector |
bg-background, text-foreground |
Leaving default classes like bg-white, text-black alone |
| Accessibility |
WCAG 2.1 AA |
aria-label, <main>, focus-visible:ring-2 |
Missing screen reader alternative text on icon buttons |
When controlling an unruly model, using lifecycle hooks is much more reliable. Using Claude Code's PostToolUse hook executes scripts the moment a file is written to disk.
Register hook commands in .claude/settings.json.
`json
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "node .claude/hooks/ast-lint-guard.mjs",
"timeout": 30
}
]
}
]
}
}
`
Now, write the checking script. Catch common hex codes with regular expressions, convert them to project tokens, and enforce rules with the Tailwind ESLint plugin.
`javascript
// .claude/hooks/ast-lint-guard.mjs
import fs from 'fs';
import readline from 'readline';
import { execSync } from 'child_process';
const rl = readline.createInterface({ input: process.stdin });
let inputBuffer = '';
rl.on('line', (line) => { inputBuffer += line; });
rl.on('close', () => {
try {
const payload = JSON.parse(inputBuffer);
const filePath = payload.tool_input?.file_path || payload.tool_input?.path;
if (!filePath || !/\.(tsx|jsx)$/.test(filePath) || !fs.existsSync(filePath)) {
process.exit(0);
}
let code = fs.readFileSync(filePath, 'utf-8');
let changed = false;
const replacementMap = {
'#ffffff': 'bg-background',
'#000000': 'text-foreground',
'#020817': 'bg-background',
'#0f172a': 'bg-card',
'#1e293b': 'bg-muted',
'#64748b': 'text-muted-foreground',
'#2563eb': 'bg-primary',
};
for (const [hex, token] of Object.entries(replacementMap)) {
const regex = new RegExp(`(bg|text|border)-\[${hex}\]`, 'gi');
if (regex.test(code)) {
code = code.replace(regex, token);
changed = true;
}
}
if (changed) {
fs.writeFileSync(filePath, code, 'utf-8');
}
execSync(`npx eslint "${filePath}" --rule "tailwindcss/no-arbitrary-value: error"`, {
stdio: 'pipe',
});
process.exit(0);
} catch (error) {
const failureLog = error.stdout?.toString() || error.stderr?.toString() || error.message;
console.error([Lint Pipeline Block] Style rule violation:\n${failureLog});
process.exit(1);
}
});
`
Install the linter plugin in your project.
`bash
npm install -D eslint-plugin-tailwindcss
`
When the script outputs exit code 1, Claude Code reads the error log and rewrites the code using semantic classes on the next turn. This reduces the times you struggle with broken layouts during the QA phase.
Physically Separating Views and Business Logic
If you tell Claude Code to build a screen, it tends to blend fetch functions, massive mock data objects, and JSX together into a single file. If you want to attach a real API later, you have to rip out even the rendering code.
Screen code doesn't need to know how state changes. It's safer to split a single feature directory into four files and establish the data schema first.
First, define the data specifications.
`typescript
// src/components/features/dashboard-card/schema.ts
import { z } from "zod";
export const MetricItemSchema = z.object({
id: z.string(),
label: z.string(),
value: z.string(),
changePercentage: z.number(),
trend: z.enum(["up", "down", "neutral"]),
});
export const DashboardCardSchema = z.object({
title: z.string().min(1),
metrics: z.array(MetricItemSchema),
});
export type DashboardCardData = z.infer;
export interface DashboardCardViewProps {
data: DashboardCardData;
isLoading?: boolean;
onActionClick?: (metricId: string) => void;
}
`
Next, explicitly command Claude Code not to use any internal state when invoked in the terminal.
`bash
claude "Create src/components/features/dashboard-card/dashboard-card-view.tsx as a pure UI component implementing DashboardCardViewProps from src/components/features/dashboard-card/schema.ts. Do not use useState, useEffect, or fetch internally under any circumstances; build it responsively using only the passed props and @/components/ui elements."
`
Wrap it with a hook when attaching data. Structure mock data and actual API call functions similarly.
`typescript
// src/components/features/dashboard-card/use-dashboard-card.ts
import { useQuery } from "@tanstack/react-query";
import { DashboardCardData } from "./schema";
const MOCK_DATA: DashboardCardData = {
title: "Monthly Active Metrics",
metrics: [
{ id: "m-1", label: "New Inflows", value: "1,240 people", changePercentage: 12.5, trend: "up" },
{ id: "m-2", label: "Churn Rate", value: "2.1%", changePercentage: -0.4, trend: "down" },
],
};
export const useDashboardCard = (cardId: string, useMock = false) => {
return useQuery({
queryKey: ["dashboard-card", cardId],
queryFn: async () => {
if (useMock) {
return MOCK_DATA;
}
const res = await fetch(/api/dashboard/${cardId});
if (!res.ok) throw new Error("Data retrieval failed");
return res.json();
},
});
};
`
Assemble the two in the container component.
`typescript
// src/components/features/dashboard-card/index.tsx
"use client";
import React from "react";
import { DashboardCardView } from "./dashboard-card-view";
import { useDashboardCard } from "./use-dashboard-card";
export function DashboardCardContainer({ cardId, useMock = false }: { cardId: string; useMock?: boolean }) {
const { data, isLoading } = useDashboardCard(cardId, useMock);
if (!data) return null;
return (
<DashboardCardView
data={data}
isLoading={isLoading}
onActionClick={(id) => console.log(id)}
/>
);
}
`
Refine the screen with useMock={true} before the backend is ready. Once the API is complete, just delete the flag. There is no reason to touch a single line of view code.
Isolating Working Trees and Cherry-Picking Changes
If you chat lengthily with the model in the terminal and tweak the UI, pristine global config files often end up modified or stray temporary files spawn across directories. It's much less stressful to carve out an experiment-only directory while leaving your workspace untouched.
Using Git worktree allows you to run Claude Code in a completely isolated folder.
`bash
git worktree add ../saas-ui-sandbox -b experiment/ai-dashboard-ui
cd ../saas-ui-sandbox
claude
`
If the experiment breaks, you can wipe the whole folder without a second thought.
`bash
cd ../saas-platform
git worktree remove ../saas-ui-sandbox --force
git branch -D experiment/ai-dashboard-ui
`
If you get the desired look, don't merge the entire branch; pick only code snippets of the view files using interactive mode.
`bash
git checkout main
git checkout -p experiment/ai-dashboard-ui -- src/components/features/dashboard-card/dashboard-card-view.tsx
`
Review the code blocks popping up in the terminal, press y for parts you like, and discard weird modifications with n.
If a session exceeds 5 turns, the context gets fuzzy and the model starts talking nonsense. You must clean up sessions periodically:
- Type Inspection: Once a single component is built, run
npx tsc --noEmit immediately. Proceed to the next prompt only when type errors equal zero.
- Context Compression: When conversations get long, don't hesitate to type
/compact to minimize token waste.
- Clearing Sessions: After screen work finishes, leave a commit and clear memory completely with
/clear. When things get tangled, use /rewind to return to a previous checkpoint.
A model's high generation capability is entirely separate from whether that code can enter production. Narrowing input channels with CLAUDE.md, validating output code with lifecycle hooks, and separating workspaces with worktrees will prevent you from staying up all night cleaning up code spit out by AI.