Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,88 @@
# CLAUDE.md

<!-- vibekit:agents-core:start -->
<!-- Generated from vibe-kit/ai-doc/references/agents-core.md. Edit there, then run: node vibe-kit/ai-doc/scripts/sync-agents-core.cjs -->

Guidelines to reduce common LLM coding mistakes.

**The contract: you finish the work.** A turn ends when the task is done and verified. A turn does not end with a list of things the user could do next. Judgment calls inside the task are yours to make.

## 1. Think Before Coding

Understand the request, then decide. Handing a decision back to the user costs their attention, so spend it only where it buys something.

- State an assumption in one line and keep going. A written assumption is not a blocker.
- Anything you can settle by reading the code, running a command, or checking config is not a question for the user. Go settle it.
- Two readings of the request that lead to materially different work? Ask. Same work either way, or one reading clearly better? Pick it, name it in one line, continue.
- Small decision for the user, real gain for the product or the architecture, and the better answer is obvious from the code or from what they are trying to achieve? Take it and keep moving.
- Suggest a simpler approach when you see one, then build it. Push back in a sentence or two, not a memo.

**A workflow the user already set up is already authorized.** A release PR the tooling opened exists to be merged. A green pipeline exists to be deployed. A version bump exists to be published. A task in review exists to be closed. Run the checks that gate the step, take it, and report it done. Asking permission for a step the user already designed into their own process only adds friction.

The same holds for anything running on the user's own systems: their repos, their registries, their infrastructure, their boards. Act, verify, report.

**Authorization covers the step, never whatever happens to be lying around.** Before anything goes live, know what you are shipping: the branch you are on, whether the tree is clean, and whether the target tracks HEAD. Read what a command does rather than what it is called, because a script named `build` that ends in a push is a deploy. Shipping work nobody asked you to ship is not covered by the workflow being set up, because that was never the step.

The exceptions are a closed list of four, and the list does not grow by analogy: a message sent to another person under the user's name (client email, public post, customer reply), a payment or a refund, deleting data that has no backup, and pushing into a client's live production system. Those land on somebody else and cannot be recalled. Confirm those and nothing else. "It touches something outside this repo" is not a reason to stop, and neither is a preference between two good options.

## 2. Simplicity First

Minimum code that solves the problem. Nothing speculative.

- No features beyond what was asked.
- No abstractions for single-use code.
- No unrequested "flexibility."
- No error handling for impossible scenarios.
- 200 lines that could be 50, rewrite.

## 3. Surgical Changes

Touch only what you must. Clean up only your own mess.

- Don't "improve" adjacent code, comments, or formatting.
- Don't refactor things that aren't broken.
- Match existing style.
- Remove imports/variables/functions that YOUR changes made unused.
- Don't remove pre-existing dead code unless asked.

Every changed line should trace directly to the user's request.

## 4. Goal-Driven Execution

Define success criteria. Loop until verified.

- "Add validation" becomes "Write tests for invalid inputs, then make them pass"
- "Fix the bug" becomes "Write a test that reproduces it, then make it pass"
- "Refactor X" becomes "Ensure tests pass before and after"

For multi-step tasks, state a brief plan with verification checks.

Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.

## 5. Fix It, Don't Flag It

Anything you would hand back as "worth knowing for next time" gets fixed in this session instead.

- Found a second problem while fixing the first? Fix it too. Do not list it as a follow-up.
- Found a gap, a stale value, a missing case, a wrong config? Fix it, then say what you fixed.
- "Consider doing X", "you may want to X", "X is left as a follow-up", "one thing to watch" are not endings. Do X, then report it done.
- Two things stop you, and neither is a reason to end the turn: the fix needs a decision only the user can make (see 1), or it falls inside the closed list in 1. Ask, get the answer, then finish it in the same turn.
- Verify the fix rather than asserting it. Read the state back.

If a sentence you are about to write opens with "Consider", "You may want to", "One thing to watch", "I didn't touch", "Worth noting", "Optional improvement", "Recommend that you", or "Next steps", the work is not finished. Go finish it, then write the sentence that says it is done.

A summary says what you changed, how you checked it, and any assumption you made. It is never a to-do list. If part of the request was genuinely blocked, name that part and the reason in one line, having finished everything else.

Breaking something makes the repair yours as well. Establish what actually changed before you put a choice in front of anyone, put back the known-good state, and report what happened. Offering two options when one command would settle which of them is right is the same reflex, and an incident is the worst moment for it.

Work you already did is reported as done, never handed back. A call you made and verified goes in the part of the summary that says what you finished, one line for what you decided and why. Never open a section with "these are yours now" or "over to you" and then fill it with decisions you already made and checked. Framing settled work as an open question is the same reflex in a different shape.

This does not loosen 3. Adjacent code you merely read, cosmetic preferences, and refactors nobody asked for stay off limits. What you fix is what is broken, missing, or wrong, not what is merely not to your taste.

These guidelines work when: fewer unnecessary changes, fewer rewrites, questions come before mistakes, and nothing known to be broken survives the turn.

<!-- vibekit:agents-core:end -->

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview
Expand Down
41 changes: 41 additions & 0 deletions apps/web/__tests__/EmailEditor/modeGuards.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import {describe, expect, it} from 'vitest';
import {getInitialEditorMode, getModeToggleDecision} from '../../src/components/EmailEditor/modeGuards';

describe('EmailEditor mode guards', () => {
it('starts in html mode for custom html templates', () => {
const customHtml = '<table><tr><td style="color:red" class="promo">Hello</td></tr></table>';

expect(getInitialEditorMode(customHtml)).toBe('html');
});

it('allows switching simple html into visual mode without snapshot', () => {
const simpleHtml = '<p>Hello <strong>world</strong></p>';

expect(
getModeToggleDecision({
currentMode: 'html',
htmlContent: simpleHtml,
}),
).toEqual({action: 'switch', nextMode: 'visual'});
});

it('allows switching custom html into visual mode with snapshot for revert', () => {
const customHtml = '<div class="email-shell"><table><tr><td style="padding:24px">Hello</td></tr></table></div>';

const decision = getModeToggleDecision({
currentMode: 'html',
htmlContent: customHtml,
});

expect(decision).toEqual({action: 'switch', nextMode: 'visual', snapshot: customHtml});
});

it('always allows switching from visual to html', () => {
expect(
getModeToggleDecision({
currentMode: 'visual',
htmlContent: '<p>anything</p>',
}),
).toEqual({action: 'switch', nextMode: 'html'});
});
});
113 changes: 43 additions & 70 deletions apps/web/src/components/EmailEditor/EmailEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@ import {
SelectTrigger,
SelectValue,
} from '@plunk/ui';
import {Code2, Eye, Monitor, Smartphone, Tablet, Upload, X} from 'lucide-react';
import {Code2, Eye, Monitor, RotateCcw, Smartphone, Tablet, Upload, X} from 'lucide-react';
import {network} from '../../lib/network';
import {detectCustomHtmlPatterns, wrapEmailWithStyles} from '../../lib/emailStyles';
import {getInitialEditorMode, getModeToggleDecision} from './modeGuards';
import 'tippy.js/dist/tippy.css';

interface EmailEditorProps {
Expand All @@ -53,14 +54,15 @@ const commonVariables = [
];

export function EmailEditor({value, onChange, placeholder, subject, from, replyTo}: EmailEditorProps) {
// Detect if initial value has custom HTML and start in appropriate mode
const initialMode = detectCustomHtmlPatterns(value) ? 'html' : 'visual';
const initialMode = getInitialEditorMode(value);

const [mode, setMode] = useState<'visual' | 'html'>(initialMode);
const [htmlContent, setHtmlContent] = useState(value);
const [showVariableDialog, setShowVariableDialog] = useState(false);
const [showImageDialog, setShowImageDialog] = useState(false);
const [showModeWarningDialog, setShowModeWarningDialog] = useState(false);
// WHY: snapshot stores the original HTML before a lossy visual-mode switch,
// so the user can revert if the conversion dropped markup
const [htmlSnapshot, setHtmlSnapshot] = useState<string | null>(null);
const [previewDevice, setPreviewDevice] = useState<'desktop' | 'tablet' | 'mobile'>('desktop');
const [imageUrl, setImageUrl] = useState('');
const [imageFile, setImageFile] = useState<File | null>(null);
Expand Down Expand Up @@ -151,44 +153,40 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value, editor]);

// Use the same pattern detection as initialization (no editor manipulation)
const detectCustomHtml = (html: string): boolean => {
return detectCustomHtmlPatterns(html);
};

const handleModeToggle = () => {
if (mode === 'visual') {
// Switching to HTML mode
const decision = getModeToggleDecision({
currentMode: mode,
htmlContent,
});

if (decision.nextMode === 'html') {
const currentHtml = editor?.getHTML() || '';
setHtmlContent(currentHtml);
setHtmlSnapshot(null);
setMode('html');
} else {
// Switching to visual mode - check if custom HTML will be lost
if (detectCustomHtml(htmlContent)) {
setShowModeWarningDialog(true);
} else {
switchToVisualMode();
}
return;
}
};

const switchToVisualMode = () => {
// Only switch if we have an editor and html content
if (editor) {
if (decision.snapshot) {
setHtmlSnapshot(decision.snapshot);
}
editor.commands.setContent(htmlContent || '');
onChange(htmlContent);
setMode('visual');
}
setShowModeWarningDialog(false);
};

const stayInHtmlMode = () => {
// Explicitly stay in HTML mode and just close the dialog
setShowModeWarningDialog(false);
// Ensure we're in HTML mode
if (mode !== 'html') {
setMode('html');
}
const revertToSnapshot = () => {
if (!htmlSnapshot) return;
setHtmlContent(htmlSnapshot);
onChange(htmlSnapshot);
setHtmlSnapshot(null);
setMode('html');
};

const dismissSnapshot = () => {
setHtmlSnapshot(null);
};

const handleHtmlChange = (newHtml: string) => {
Expand Down Expand Up @@ -385,6 +383,22 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT
onInsertImage={() => setShowImageDialog(true)}
canUploadImages={canUploadImages}
/>
{htmlSnapshot && (
<div className="flex items-center justify-between gap-2 bg-amber-50 border-b border-amber-200 px-4 py-2">
<p className="text-sm text-amber-800">
Some custom HTML may not display correctly in the visual editor.
</p>
<div className="flex gap-2 shrink-0">
<Button type="button" variant="ghost" size="sm" onClick={dismissSnapshot} className="text-amber-700 h-7">
Dismiss
</Button>
<Button type="button" variant="outline" size="sm" onClick={revertToSnapshot} className="h-7">
<RotateCcw className="h-3.5 w-3.5 mr-1.5" />
Revert to HTML
</Button>
</div>
</div>
)}
<div className="min-h-[400px] max-h-[600px] overflow-y-auto">
<EditorContent editor={editor} />
</div>
Expand Down Expand Up @@ -685,47 +699,6 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT
</DialogContent>
</Dialog>

{/* Mode switch warning dialog */}
<Dialog
open={showModeWarningDialog}
onOpenChange={open => {
// Only allow closing (not opening) and ensure we stay in current mode
if (!open) {
setShowModeWarningDialog(false);
}
}}
>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>Custom HTML Detected</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-neutral-700">
Your HTML contains custom formatting, styles, or elements that the visual editor doesn&apos;t support.
Switching to visual mode will cause these customizations to be lost or modified.
</p>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-sm text-amber-800 font-medium">This may affect:</p>
<ul className="text-sm text-amber-700 mt-2 ml-4 list-disc space-y-1">
<li>Custom HTML elements and attributes</li>
<li>Inline styles and CSS classes</li>
<li>Complex table structures</li>
<li>Custom formatting or layout</li>
</ul>
</div>

<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={stayInHtmlMode}>
Stay in HTML Mode
</Button>
<Button type="button" variant="destructive" onClick={switchToVisualMode}>
Switch Anyway
</Button>
</div>
</div>
</DialogContent>
</Dialog>

{/* Image insertion dialog */}
<Dialog open={showImageDialog} onOpenChange={setShowImageDialog}>
<DialogContent className="sm:max-w-md">
Expand Down
29 changes: 29 additions & 0 deletions apps/web/src/components/EmailEditor/modeGuards.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {detectCustomHtmlPatterns} from '../../lib/emailStyles';

export type EmailEditorMode = 'visual' | 'html';

export type ModeToggleDecision = {action: 'switch'; nextMode: EmailEditorMode; snapshot?: string};

export const getInitialEditorMode = (value: string): EmailEditorMode => {
return detectCustomHtmlPatterns(value) ? 'html' : 'visual';
};

export const getModeToggleDecision = ({
currentMode,
htmlContent,
}: {
currentMode: EmailEditorMode;
htmlContent: string;
}): ModeToggleDecision => {
if (currentMode === 'visual') {
return {action: 'switch', nextMode: 'html'};
}

// WHY: custom HTML detection has false positives, so we allow the switch
// but snapshot the original HTML so the user can revert without data loss
if (detectCustomHtmlPatterns(htmlContent)) {
return {action: 'switch', nextMode: 'visual', snapshot: htmlContent};
}

return {action: 'switch', nextMode: 'visual'};
};
46 changes: 40 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,27 +18,61 @@
"test:run": "vitest run"
},
"resolutions": {
"fumadocs-core": "16.0.8"
"fumadocs-core": "16.0.8",
"@isaacs/brace-expansion": "^5.0.1",
"ajv": "^8.18.0",
"brace-expansion@npm:^1.1.7": "^1.1.13",
"brace-expansion@npm:^2.0.1": "^2.0.3",
"dompurify": "^3.4.11",
"effect": "^3.21.4",
"esbuild": "^0.28.1",
"fast-uri": "^4.0.0",
"fast-xml-parser": "^5.7.0",
"form-data": "^4.0.6",
"ip-address": "^10.2.0",
"js-yaml": "^4.2.0",
"markdown-it": "^14.2.0",
"minimatch@npm:^3.1.2": "^3.1.4",
"minimatch@npm:^9.0.4": "^9.0.7",
"minimatch@npm:^10.1.1": "^10.2.3",
"multer": "^2.2.0",
"next": "^16.2.6",
"nodemailer": "^9.0.1",
"path-to-regexp@npm:~0.1.12": "^0.1.13",
"path-to-regexp@npm:^8.3.0": "^8.4.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.10",
"qs": "^6.15.3",
"serialize-javascript": "^7.0.5",
"shell-quote": "^1.9.0",
"tar": "^7.5.17",
"turbo": "^2.9.14",
"undici": "^6.27.0",
"uuid": "^11.1.1",
"vite": "^7.3.5",
"vitest": "^4.1.0",
"ws": "^8.21.0",
"yaml": "^2.8.3"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@formatjs/cli": "^6.7.4",
"@types/supertest": "^6.0.3",
"@vitest/coverage-v8": "^4.0.14",
"@vitest/ui": "^4.0.14",
"@vitest/coverage-v8": "^4.1.0",
"@vitest/ui": "^4.1.0",
"dotenv": "^17.0.1",
"eslint": "^9.27.0",
"eslint-config-next": "^16.0.1",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-import": "^2.31.0",
"form-data": "^4.0.3",
"form-data": "^4.0.6",
"prettier": "^3.5.3",
"rimraf": "^6.1.2",
"supertest": "^7.1.4",
"turbo": "^2.6.0",
"turbo": "^2.9.14",
"typescript": "^5.7.2",
"typescript-eslint": "^8.48.0",
"vitest": "^4.0.14",
"vitest": "^4.1.0",
"vitest-mock-extended": "^3.1.0"
},
"engines": {
Expand Down
Loading