# Phase 10: Tiptap Rich Text Editor for Puck CMS

**Goal:** Create a fully functional, highly customized WYSIWYG editor using Tiptap that perfectly matches the frontend typography, includes image uploads (via Vercel Blob), features a custom React modal for links, and properly constraints to a 1280px container layout.

---

## 🤖 Universal Agent Prompt

Copy and paste the prompt below to any AI agent to perfectly recreate the Rich Text editor without any of the bugs or layout issues we previously encountered.

```text
**Goal:** Implement a custom Tiptap-based Rich Text editor (`WysiwygField`) for Puck CMS.

Please execute the following 4 steps exactly as written.

### Step 1: Install Dependencies
Run the following command to install Tiptap. 
**CRITICAL:** You MUST use `--legacy-peer-deps` to avoid peer dependency conflicts with Puck's internal version of Tiptap.

`npm install @tiptap/react @tiptap/starter-kit @tiptap/extension-underline @tiptap/extension-link @tiptap/extension-text-align @tiptap/extension-color @tiptap/extension-text-style @tiptap/extension-image --legacy-peer-deps`

### Step 2: Global CSS Typography Fixes
In `app/globals.css`, locate your `.prose-article` styling and make the following two critical updates so the editor matches the frontend perfectly:

1. **Add `h1` styling** — missing because H1 is reserved for page titles, but the WYSIWYG editor needs it for the heading dropdown. Must match the existing h2/h3/h4 pattern (raw values, no `@apply`):

```css
/* Desktop */
.prose-article h1 {
  font-size: 2.25rem;        /* 36px — larger than h2's 30px */
  font-weight: 700;
  color: var(--foreground);
  margin-top: 3rem;
  margin-bottom: 1.5rem;
  line-height: 1.3;
  scroll-margin-top: 100px;
}

/* Mobile — inside existing @media (max-width: 768px) block */
.prose-article h1 {
  font-size: 1.875rem;       /* same as desktop h2 */
}
```

Full heading hierarchy after fix:
| Level | Desktop | Mobile | Weight |
|-------|---------|--------|--------|
| h1    | 36px    | 30px   | 700    |
| h2    | 30px    | 24px   | 700    |
| h3    | 24px    | 20px   | 600    |
| h4    | 20px    | 18px   | 600    |

2. **Upgrade `strong` weight** — Japanese fonts at `font-weight: 600` look barely different from normal text. Upgrade to `700` (truly bold):
```css
.prose-article strong,
.prose-article b {
  font-weight: 700;
  color: var(--foreground);
}
```

### Step 3: Create `WysiwygField.tsx`
Create `components/editor/WysiwygField.tsx` and paste the following code. 
*Note: This includes the custom React modal for Links (replacing `window.prompt`), Heading 4 support, and uses the `prose-article` class so the editor preview looks exactly like the live site.*

```tsx
"use client";

import React, { useEffect, useState, useRef } from 'react';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Underline from '@tiptap/extension-underline';
import Link from '@tiptap/extension-link';
import TextAlign from '@tiptap/extension-text-align';
import Color from '@tiptap/extension-color';
import { TextStyle } from '@tiptap/extension-text-style';
import ImageExtension from '@tiptap/extension-image';
import { upload } from '@vercel/blob/client';
import {
  Bold, Italic, Underline as UnderlineIcon, Strikethrough,
  List, ListOrdered, Link as LinkIcon, RemoveFormatting,
  Image as ImageIcon, Code, Loader2
} from 'lucide-react';

interface WysiwygFieldProps {
  value: string;
  onChange: (value: string) => void;
}

export function WysiwygField({ value, onChange }: WysiwygFieldProps) {
  const [isClient, setIsClient] = useState(false);
  const [isUploadingImage, setIsUploadingImage] = useState(false);
  const [linkModalOpen, setLinkModalOpen] = useState(false);
  const [linkUrl, setLinkUrl] = useState('');
  const fileInputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    setIsClient(true);
  }, []);

  const editor = useEditor({
    extensions: [
      StarterKit,
      Underline,
      ImageExtension.configure({
        inline: true,
        allowBase64: true,
        HTMLAttributes: {
          class: 'rounded-md max-w-full h-auto',
        },
      }),
      Link.configure({
        openOnClick: false,
        HTMLAttributes: {
          class: 'text-[#3B82F6] underline cursor-pointer',
        },
      }),
      TextAlign.configure({
        types: ['heading', 'paragraph'],
      }),
      TextStyle,
      Color,
    ],
    content: value || '',
    onUpdate: ({ editor }) => {
      onChange(editor.getHTML());
    },
    editorProps: {
      attributes: {
        class: 'prose-article max-w-none focus:outline-none min-h-[200px] p-4 bg-white text-gray-900 rounded-b-md border border-gray-200 border-t-0',
        style: 'color: #111827;',
      },
    },
  });

  useEffect(() => {
    if (editor && value !== editor.getHTML()) {
      editor.commands.setContent(value || '');
    }
  }, [value, editor]);

  if (!isClient) return <div className="min-h-[250px] w-full bg-white rounded-md animate-pulse border border-gray-200" />;
  if (!editor) return null;

  const toggleLink = () => {
    const previousUrl = editor.getAttributes('link').href;
    setLinkUrl(previousUrl || '');
    setLinkModalOpen(true);
  };

  const handleSaveLink = () => {
    if (linkUrl === '') {
      editor.chain().focus().extendMarkRange('link').unsetLink().run();
    } else {
      editor.chain().focus().extendMarkRange('link').setLink({ href: linkUrl }).run();
    }
    setLinkModalOpen(false);
  };

  const handleRemoveLink = () => {
    editor.chain().focus().extendMarkRange('link').unsetLink().run();
    setLinkModalOpen(false);
  };

  const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;

    const getToken = () => {
      if (typeof window === 'undefined') return '';
      const params = new URLSearchParams(window.location.search);
      return params.get('token') || '';
    };

    setIsUploadingImage(true);
    try {
      const ext = file.name.split('.').pop() || 'bin';
      const timestamp = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
      const cleanName = file.name.replace(/\.[^.]+$/, '').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 50);
      const pathname = `websites/editor/images/${cleanName}_${timestamp}.${ext}`;
      const token = getToken();

      const blob = await upload(pathname, file, {
        access: 'public',
        handleUploadUrl: '/api/editor/upload',
        clientPayload: JSON.stringify({ token }),
      });

      editor.chain().focus().setImage({ src: blob.url }).run();
    } catch (err) {
      console.error('Failed to upload image:', err);
      alert('Failed to upload image.');
    } finally {
      setIsUploadingImage(false);
      if (fileInputRef.current) fileInputRef.current.value = '';
    }
  };

  const ToolbarBtn = ({ isActive = false, onClick, children, title, disabled = false }: any) => (
    <button
      type="button"
      disabled={disabled}
      onClick={(e) => { e.preventDefault(); e.stopPropagation(); onClick(); }}
      title={title}
      className="p-1.5 rounded transition-colors disabled:opacity-50"
      style={{
        backgroundColor: isActive ? '#E5E7EB' : 'transparent',
        color: isActive ? '#111827' : '#4B5563',
        border: 'none',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
        cursor: disabled ? 'not-allowed' : 'pointer'
      }}
      onMouseEnter={(e) => {
        if (!isActive && !disabled) {
          e.currentTarget.style.backgroundColor = '#F3F4F6';
          e.currentTarget.style.color = '#111827';
        }
      }}
      onMouseLeave={(e) => {
        if (!isActive && !disabled) {
          e.currentTarget.style.backgroundColor = 'transparent';
          e.currentTarget.style.color = '#4B5563';
        }
      }}
    >
      {children}
    </button>
  );

  return (
    <div className="flex flex-col w-full bg-white rounded-md border border-gray-200 overflow-hidden relative">
      <input type="file" accept="image/*" ref={fileInputRef} onChange={handleImageUpload} style={{ display: 'none' }} />
      
      {linkModalOpen && (
        <div className="fixed inset-0 z-[100000] flex items-center justify-center bg-black/40 backdrop-blur-sm">
          <div className="bg-white rounded-xl shadow-2xl w-full max-w-sm overflow-hidden animate-in fade-in zoom-in-95 duration-200">
            <div className="px-5 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
              <h3 className="font-semibold text-gray-900">Insert Link</h3>
              <button onClick={() => setLinkModalOpen(false)} className="text-gray-400 hover:text-gray-600 transition-colors">
                ✕
              </button>
            </div>
            <div className="p-5">
              <label className="block text-sm font-medium text-gray-700 mb-1.5">URL</label>
              <input
                type="url"
                autoFocus
                value={linkUrl}
                onChange={(e) => setLinkUrl(e.target.value)}
                placeholder="https://example.com"
                className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all text-gray-900 bg-white"
                onKeyDown={(e) => {
                  if (e.key === 'Enter') handleSaveLink();
                  if (e.key === 'Escape') setLinkModalOpen(false);
                }}
              />
            </div>
            <div className="px-5 py-4 border-t border-gray-100 bg-gray-50 flex justify-end gap-2">
              <button 
                onClick={handleRemoveLink}
                className="px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg transition-colors mr-auto"
              >
                Remove
              </button>
              <button 
                onClick={() => setLinkModalOpen(false)}
                className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200 rounded-lg transition-colors"
              >
                Cancel
              </button>
              <button 
                onClick={handleSaveLink}
                className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors shadow-sm"
              >
                Save
              </button>
            </div>
          </div>
        </div>
      )}

      <div className="flex flex-wrap items-center bg-gray-50 border-b border-gray-200 p-1 gap-0.5">
        <select
          value={
            editor.isActive('heading', { level: 1 }) ? 'h1' :
            editor.isActive('heading', { level: 2 }) ? 'h2' :
            editor.isActive('heading', { level: 3 }) ? 'h3' :
            editor.isActive('heading', { level: 4 }) ? 'h4' : 'p'
          }
          onChange={(e) => {
            const val = e.target.value;
            if (val === 'p') editor.chain().focus().setParagraph().run();
            else if (val === 'h1') editor.chain().focus().toggleHeading({ level: 1 }).run();
            else if (val === 'h2') editor.chain().focus().toggleHeading({ level: 2 }).run();
            else if (val === 'h3') editor.chain().focus().toggleHeading({ level: 3 }).run();
            else if (val === 'h4') editor.chain().focus().toggleHeading({ level: 4 }).run();
          }}
          className="bg-transparent border-none text-gray-700 text-sm font-medium py-1.5 px-3 outline-none cursor-pointer hover:bg-gray-200 rounded-sm appearance-none"
        >
          <option value="p">Styles</option>
          <option value="h1">Heading 1</option>
          <option value="h2">Heading 2</option>
          <option value="h3">Heading 3</option>
          <option value="h4">Heading 4</option>
          <option value="p">Paragraph</option>
        </select>

        <div className="w-px h-6 bg-gray-300 mx-1" />

        <ToolbarBtn title="Bold" isActive={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}>
          <Bold size={16} />
        </ToolbarBtn>
        <ToolbarBtn title="Italic" isActive={editor.isActive('italic')} onClick={() => editor.chain().focus().toggleItalic().run()}>
          <Italic size={16} />
        </ToolbarBtn>
        <ToolbarBtn title="Underline" isActive={editor.isActive('underline')} onClick={() => editor.chain().focus().toggleUnderline().run()}>
          <UnderlineIcon size={16} />
        </ToolbarBtn>
        <ToolbarBtn title="Strikethrough" isActive={editor.isActive('strike')} onClick={() => editor.chain().focus().toggleStrike().run()}>
          <Strikethrough size={16} />
        </ToolbarBtn>

        <div className="w-px h-6 bg-gray-300 mx-1" />

        <ToolbarBtn title="Numbered List" isActive={editor.isActive('orderedList')} onClick={() => editor.chain().focus().toggleOrderedList().run()}>
          <ListOrdered size={16} />
        </ToolbarBtn>
        <ToolbarBtn title="Bullet List" isActive={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()}>
          <List size={16} />
        </ToolbarBtn>
        
        <div className="w-px h-6 bg-gray-300 mx-1" />

        <ToolbarBtn title="Link" isActive={editor.isActive('link')} onClick={toggleLink}>
          <LinkIcon size={16} />
        </ToolbarBtn>
        <ToolbarBtn title="Clear Formatting" onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}>
          <RemoveFormatting size={16} />
        </ToolbarBtn>
        <ToolbarBtn title="Upload Image" disabled={isUploadingImage} onClick={() => fileInputRef.current?.click()}>
          {isUploadingImage ? <Loader2 size={16} className="animate-spin text-blue-500" /> : <ImageIcon size={16} />}
        </ToolbarBtn>
        <ToolbarBtn title="Code Block" isActive={editor.isActive('codeBlock')} onClick={() => editor.chain().focus().toggleCodeBlock().run()}>
          <Code size={16} />
        </ToolbarBtn>
      </div>

      <EditorContent editor={editor} className="puck-richtext-editor" />
    </div>
  );
}
\```

### Step 4: Component Layout & Integration
1. **Fix `RichText.tsx` Layout:**
   To ensure the text is properly constrained to 1280px (`max-w-7xl`), you MUST separate the container from the `.prose-article` class (which overrides max-widths). Update `components/sections/RichText.tsx`:
```tsx
export function RichText({ content = '', theme = 'default' }: RichTextProps) {
  const t = THEMES[theme] || THEMES.default;
  if (!content) return null;
  return (
    <section className={`w-full py-8 ${t.sectionBg}`}>
      <div className="container max-w-7xl mx-auto px-4">
        <div className={`prose-article ${t.proseAccent}`} dangerouslySetInnerHTML={{ __html: content }} />
      </div>
    </section>
  );
}
```

2. **Integrate into Puck (`lib/puck/config.tsx`):**
   Import the field at the top:
   `import { WysiwygField } from "@/components/editor/WysiwygField";`
   
   Replace the standard textarea in the `RichText` field configuration:
```tsx
         label: "Rich Text",
      fields: {
        content: { 
          type: "custom", 
          label: "Content",
          render: ({ value, onChange }: any) => (
            <WysiwygField value={value || ""} onChange={onChange} />
          )
        },
        // ... theme config ...
```
```

---

# Phase 10 Implementation Gotchas

## 1. Missing Theme Options in Puck Config
The prompt instructs replacing the `RichText` field configuration in `lib/puck/config.tsx`, but abbreviates the `theme` configuration as `// ... theme config ...`. 
By default, the old configuration only had 3 hardcoded theme options (`default`, `warm`, `ocean`), despite the `RichText.tsx` component actually supporting 5 themes (`default`, `warm`, `professional`, `ocean`, `forest`).

**Fix:** Ensure you manually update the `theme` options array in `lib/puck/config.tsx` to include all 5 supported themes when making this change:
```tsx
theme: { 
  type: "select", 
  label: "Theme", 
  options: [
    { label: "Default (Brand)", value: "default" }, 
    { label: "Warm (Orange)", value: "warm" }, 
    { label: "Professional (Dark)", value: "professional" }, 
    { label: "Ocean (Blue)", value: "ocean" }, 
    { label: "Forest (Green/Teal)", value: "forest" }
  ] 
}
```

---

## 2. Tiptap v3: StarterKit Already Bundles Link + Underline (CRITICAL)

**This project uses Tiptap v3**, not v2. In v3, `StarterKit` internally bundles `@tiptap/extension-link` and `@tiptap/extension-underline`. The Step 1 install command installs these as separate packages, and if you also pass them as standalone extensions alongside `StarterKit`, you get **duplicate extensions** which cause heading toggle commands to silently fail — including `toggleHeading({ level: 1 })`.

**Symptom:** Heading dropdown shows the correct level (e.g. "見出し 1") but the text doesn't visually change size, or the `editor.isActive('heading')` state is incorrect.

**Fix:** Explicitly disable the built-in versions in `StarterKit.configure()` so your configured versions take priority:
```tsx
extensions: [
  StarterKit.configure({
    heading: { levels: [1, 2, 3, 4] },  // REQUIRED: explicit heading levels
    link: false,      // disable StarterKit's bundled Link
    underline: false, // disable StarterKit's bundled Underline
  }),
  Underline,          // your configured version
  Link.configure({ openOnClick: false, ... }),  // your configured version
  // ... rest of extensions
],
```

---

## 3. Tailwind v4: `@layer base` Resets ALL Heading Sizes (CRITICAL)

**This project uses Tailwind v4** (`@import "tailwindcss"` instead of `@tailwind base`). In Tailwind v4, the preflight runs inside `@layer base` and resets every heading to:
```css
h1, h2, h3, h4, h5, h6 {
  font-size: inherit;   /* strips ALL heading sizes */
  font-weight: inherit; /* strips ALL bold weight */
}
```

The Step 2 CSS in the prompt uses `@apply` syntax:
```css
/* ❌ This won't work with Tailwind v4 cascade */
.prose-article h1 {
  @apply text-3xl font-bold mt-12 mb-8 text-foreground;
}
```

**Fix:** Use raw CSS values instead of `@apply`, and add `!important` to `font-size` and `font-weight`:
```css
/* ✅ Correct for Tailwind v4 */
.prose-article h1 {
  font-size: 2.25rem !important;   /* beats @layer base reset */
  font-weight: 700 !important;     /* beats @layer base reset */
  color: var(--foreground);
  margin-top: 3rem;
  margin-bottom: 1.5rem;
  line-height: 1.3;
  scroll-margin-top: 100px;
}
```

Also add the corresponding mobile rule inside the existing `@media (max-width: 768px)` block:
```css
.prose-article h1 { font-size: 1.875rem; }
```

Additionally, inject a `<style>` tag with `!important` rules inside `WysiwygField.tsx` so the editor sidebar preview also correctly shows heading sizes regardless of Tailwind cascade timing.

---

## 4. Puck Canvas is a Blank iframe — globals.css is NOT Loaded (CRITICAL)

The Puck editor canvas renders inside a **blank browser iframe**. This iframe does NOT automatically inherit or load the Next.js app's `globals.css`. 

**Symptom:** Heading styling works correctly in the right-panel editor preview (WysiwygField), but the canvas (left-panel page preview) shows all `prose-article` headings as the same small body text size.

**Fix:** Inject all necessary `prose-article` CSS directly inside the `overrides.iframe` callback in `editor-client.tsx`:

```tsx
overrides={{
  iframe: ({ children }) => (
    <div style={{ display: 'flex', flexDirection: 'column' }}>
      {/* REQUIRED: Inject prose-article styles into the blank iframe document */}
      <style>{`
        .prose-article h1 { font-size: 2.25rem !important; font-weight: 700 !important; line-height: 1.3; margin-top: 2.5rem; margin-bottom: 1.25rem; }
        .prose-article h2 { font-size: 1.875rem !important; font-weight: 700 !important; line-height: 1.3; margin-top: 2rem; margin-bottom: 1rem; }
        .prose-article h3 { font-size: 1.5rem !important; font-weight: 600 !important; line-height: 1.4; margin-top: 1.5rem; margin-bottom: 0.75rem; }
        .prose-article h4 { font-size: 1.25rem !important; font-weight: 600 !important; line-height: 1.4; margin-top: 1.25rem; margin-bottom: 0.5rem; }
        .prose-article p { margin-bottom: 1.25rem; line-height: 1.8; }
        .prose-article strong, .prose-article b { font-weight: 700 !important; }
        .prose-article ul { list-style-type: disc; padding-left: 1.5rem; margin-bottom: 1rem; }
        .prose-article ol { list-style-type: decimal; padding-left: 1.5rem; margin-bottom: 1rem; }
        .prose-article li { margin-bottom: 0.25rem; line-height: 1.7; }
        .prose-article a { color: #3B82F6; text-decoration: underline; }
        .prose-article code { background: #f3f4f6; padding: 0.125rem 0.375rem; border-radius: 4px; font-size: 0.875em; }
        .prose-article pre { background: #1f2937; color: #f9fafb; padding: 1rem; border-radius: 8px; overflow-x: auto; margin-bottom: 1rem; }
      `}</style>
      <CanvasHeader />
      {children}
      <CanvasFooter categories={categories} />
    </div>
  ),
}}
```

> **Note:** Any new CSS class used in content components that needs to appear in the canvas preview MUST also be added here. The canvas iframe is isolated from the parent page's stylesheet.

---

## ✅ Phase 10 Completion Verification

Run this checklist to confirm Phase 10 is fully implemented:

- [ ] `npx tsc --noEmit` passes with 0 errors
- [ ] Tiptap dependencies installed with `--legacy-peer-deps`
- [ ] `WysiwygField.tsx` created in `components/editor/`
- [ ] StarterKit configured with `link: false, underline: false, heading: { levels: [1,2,3,4] }`
- [ ] `globals.css` — `.prose-article h1` has `!important` on font-size + font-weight
- [ ] `editor-client.tsx` — `overrides.iframe` injects prose-article CSS style block
- [ ] "Rich Text" component appears in the "Content" category in Puck sidebar
- [ ] WYSIWYG toolbar renders: Bold, Italic, Underline, Strikethrough, Headings (H1–H4), Lists, Link, Image, Code
- [ ] Custom React link modal opens (NOT `window.prompt`)
- [ ] Image upload works via Vercel Blob
- [ ] Editor preview (right sidebar) — H1 appears as 36px bold, H2 as 30px, H3 as 24px, H4 as 20px
- [ ] Canvas preview (left panel) — same heading sizes as editor preview
- [ ] `RichText.tsx` component renders HTML inside `max-w-7xl` container (two separate divs)
- [ ] Theme selector (Default, Warm, Professional, Ocean, Forest) changes styling
- [ ] Publish → live site → rich text content renders correctly with all formatting

> **Phase 10 is complete when ALL boxes above are checked.**
