# Pages Editor Improvements Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Add font size/color controls, image linking with lightbox, video embedding, and flexible grid layout to the Puck-based Pages Editor.

**Architecture:** Extend existing Puck configuration with new fields and components. Create reusable ColorPickerField and video utilities. Update publisher.ts to render all new features to static HTML. All changes apply to both dealer Pages Editor and admin Resource Guide CMS since they share puck-config.tsx.

**Tech Stack:** Next.js 16, React 19, Puck 0.20.2, Lexical 0.38.2, TypeScript

---

## Task 1: Create ColorPickerField Component

**Files:**

- Create: `lib/cms/ColorPickerField.tsx`

**Step 1: Create the ColorPickerField component**

```tsx
// lib/cms/ColorPickerField.tsx
'use client';

import { useState, useRef, useEffect } from 'react';

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

// AMSOIL brand palette
const BRAND_COLORS = [
  { label: 'Primary Blue', value: '#093b66' },
  { label: 'Accent Orange', value: '#f8981d' },
  { label: 'Black', value: '#000000' },
  { label: 'White', value: '#ffffff' },
  { label: 'Gray', value: '#757575' },
];

export function ColorPickerField({ value, onChange }: ColorPickerFieldProps) {
  const [showCustom, setShowCustom] = useState(false);
  const [customValue, setCustomValue] = useState(value || '#000000');
  const inputRef = useRef<HTMLInputElement>(null);

  // Update custom value when external value changes
  useEffect(() => {
    if (value && !BRAND_COLORS.some((c) => c.value === value)) {
      setCustomValue(value);
      setShowCustom(true);
    }
  }, [value]);

  const handleSwatchClick = (color: string) => {
    setShowCustom(false);
    onChange(color);
  };

  const handleCustomChange = (hex: string) => {
    setCustomValue(hex);
    // Only update if valid hex
    if (/^#[0-9A-Fa-f]{6}$/.test(hex)) {
      onChange(hex);
    }
  };

  const isSelected = (color: string) => value === color;

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
      {/* Brand color swatches */}
      <div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
        {BRAND_COLORS.map((color) => (
          <button
            key={color.value}
            type="button"
            onClick={() => handleSwatchClick(color.value)}
            title={color.label}
            style={{
              width: '32px',
              height: '32px',
              borderRadius: '4px',
              border: isSelected(color.value) ? '3px solid #093b66' : '1px solid #d1d5db',
              backgroundColor: color.value,
              cursor: 'pointer',
              outline: 'none',
              boxShadow: isSelected(color.value) ? '0 0 0 2px white, 0 0 0 4px #093b66' : 'none',
            }}
          />
        ))}
        {/* Custom color toggle */}
        <button
          type="button"
          onClick={() => {
            setShowCustom(true);
            setTimeout(() => inputRef.current?.focus(), 0);
          }}
          title="Custom color"
          style={{
            width: '32px',
            height: '32px',
            borderRadius: '4px',
            border: showCustom ? '3px solid #093b66' : '1px solid #d1d5db',
            background: 'linear-gradient(135deg, #ff0000 0%, #00ff00 50%, #0000ff 100%)',
            cursor: 'pointer',
            outline: 'none',
            boxShadow: showCustom ? '0 0 0 2px white, 0 0 0 4px #093b66' : 'none',
          }}
        />
      </div>

      {/* Custom hex input */}
      {showCustom && (
        <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
          <div
            style={{
              width: '24px',
              height: '24px',
              borderRadius: '4px',
              backgroundColor: customValue,
              border: '1px solid #d1d5db',
            }}
          />
          <input
            ref={inputRef}
            type="text"
            value={customValue}
            onChange={(e) => handleCustomChange(e.target.value)}
            placeholder="#000000"
            style={{
              padding: '0.25rem 0.5rem',
              border: '1px solid #d1d5db',
              borderRadius: '4px',
              fontSize: '0.875rem',
              fontFamily: 'monospace',
              width: '90px',
            }}
          />
          <input
            type="color"
            value={customValue}
            onChange={(e) => handleCustomChange(e.target.value)}
            style={{
              width: '32px',
              height: '32px',
              padding: 0,
              border: 'none',
              cursor: 'pointer',
            }}
          />
        </div>
      )}

      {/* Current value display */}
      <div style={{ fontSize: '0.75rem', color: '#757575' }}>Current: {value || 'none'}</div>
    </div>
  );
}
```

**Step 2: Verify the file was created correctly**

Run: `head -20 lib/cms/ColorPickerField.tsx`
Expected: Shows the file header and imports

**Step 3: Commit**

```bash
git add lib/cms/ColorPickerField.tsx
git commit -m "feat(cms): add ColorPickerField component with brand palette"
```

---

## Task 2: Add Font Size and Color to Heading Component

**Files:**

- Modify: `lib/cms/puck-config.tsx`

**Step 1: Update HeadingProps interface**

In `lib/cms/puck-config.tsx`, find the HeadingProps interface (around line 22-26) and update:

```tsx
export interface HeadingProps {
  text: string;
  level: 'h2' | 'h3' | 'h4';
  alignment: 'left' | 'center' | 'right';
  fontSize?: number;
  color?: string;
}
```

**Step 2: Update HeadingComponent to use new props**

Find the HeadingComponent function (around line 100-124) and update:

```tsx
// Heading Component
function HeadingComponent({ text, level, alignment, fontSize, color }: HeadingProps) {
  const Tag = level as 'h2' | 'h3' | 'h4';
  const defaultSizes = {
    h2: 32,
    h3: 24,
    h4: 20,
  };

  const actualSize = fontSize || defaultSizes[level];

  return (
    <Tag
      className="cms-heading"
      style={{
        fontSize: `${actualSize / 16}rem`,
        fontWeight: 600,
        textAlign: alignment,
        marginBottom: '1rem',
        color: color || '#093b66',
        lineHeight: 1.2,
      }}
    >
      {text || 'Heading'}
    </Tag>
  );
}
```

**Step 3: Add import for ColorPickerField**

At the top of the file, add the import (around line 12-14):

```tsx
import { ColorPickerField } from './ColorPickerField';
```

**Step 4: Update Heading config fields**

Find the Heading configuration (around line 514-545) and update the fields:

```tsx
    Heading: {
      fields: {
        text: {
          type: 'text',
          label: 'Heading Text',
        },
        level: {
          type: 'select',
          label: 'Heading Level',
          options: [
            { label: 'H2 - Section Heading', value: 'h2' },
            { label: 'H3 - Subsection', value: 'h3' },
            { label: 'H4 - Minor Heading', value: 'h4' },
          ],
        },
        alignment: {
          type: 'select',
          label: 'Alignment',
          options: [
            { label: 'Left', value: 'left' },
            { label: 'Center', value: 'center' },
            { label: 'Right', value: 'right' },
          ],
        },
        fontSize: {
          type: 'number',
          label: 'Font Size (px)',
          min: 12,
          max: 72,
        },
        color: {
          type: 'custom',
          label: 'Color',
          render: ({ value, onChange }) => (
            <ColorPickerField value={value || '#093b66'} onChange={onChange} />
          ),
        },
      },
      defaultProps: {
        text: 'New Heading',
        level: 'h2',
        alignment: 'left',
        fontSize: 32,
        color: '#093b66',
      },
      render: HeadingComponent as any,
    },
```

**Step 5: Verify lint passes**

Run: `npm run lint -- --max-warnings=0 lib/cms/puck-config.tsx lib/cms/ColorPickerField.tsx`
Expected: No errors

**Step 6: Commit**

```bash
git add lib/cms/puck-config.tsx
git commit -m "feat(cms): add font size and color controls to Heading component"
```

---

## Task 3: Update Publisher for Heading Font Size and Color

**Files:**

- Modify: `lib/cms/publisher.ts`

**Step 1: Update renderHeading function signature**

Find the renderHeading function (around line 175-182) and update:

```tsx
function renderHeading(
  text: string,
  level: 'h2' | 'h3' | 'h4',
  alignment: 'left' | 'center' | 'right',
  fontSize?: number,
  color?: string
): string {
  const defaultSizes = { h2: 32, h3: 24, h4: 20 };
  const actualSize = fontSize || defaultSizes[level];
  const actualColor = color || '#093b66';

  const styles: string[] = [];
  if (alignment !== 'left') styles.push(`text-align: ${alignment}`);
  styles.push(`font-size: ${actualSize / 16}rem`);
  styles.push(`color: ${actualColor}`);

  const styleAttr = styles.length > 0 ? ` style="${styles.join('; ')}"` : '';
  return `<${level} class="cms-heading"${styleAttr}>${escapeHtml(text || '')}</${level}>`;
}
```

**Step 2: Update renderPuckComponent call for Heading**

Find the Heading case in renderPuckComponent (around line 112-117) and update:

```tsx
    case 'Heading':
      return renderHeading(
        component.props.text as string,
        component.props.level as 'h2' | 'h3' | 'h4',
        component.props.alignment as 'left' | 'center' | 'right',
        component.props.fontSize as number | undefined,
        component.props.color as string | undefined
      );
```

**Step 3: Verify lint passes**

Run: `npm run lint -- --max-warnings=0 lib/cms/publisher.ts`
Expected: No errors

**Step 4: Commit**

```bash
git add lib/cms/publisher.ts
git commit -m "feat(cms): render heading font size and color in published pages"
```

---

## Task 4: Add Font Size and Color to Lexical Toolbar

**Files:**

- Modify: `lib/cms/LexicalField.tsx`

**Step 1: Add font size state and command imports**

At the top of the file, add to the Lexical imports (around line 24-34):

```tsx
import {
  $getRoot,
  $getSelection,
  $isRangeSelection,
  $createParagraphNode,
  FORMAT_TEXT_COMMAND,
  UNDO_COMMAND,
  REDO_COMMAND,
  EditorState,
  LexicalEditor,
  $isTextNode,
  $getNodeByKey,
  TextNode,
} from 'lexical';
```

**Step 2: Add brand colors constant**

After the imports (around line 40), add:

```tsx
// AMSOIL brand palette for text colors
const BRAND_COLORS = [
  { label: 'Primary Blue', value: '#093b66' },
  { label: 'Accent Orange', value: '#f8981d' },
  { label: 'Black', value: '#000000' },
  { label: 'Gray', value: '#757575' },
];

const FONT_SIZES = [12, 14, 16, 18, 20, 24, 28, 32, 36, 48, 72];
```

**Step 3: Update the Toolbar component with font size and color**

Replace the Toolbar function (starting around line 102) with:

```tsx
// Toolbar component
function Toolbar() {
  const [editor] = useLexicalComposerContext();
  const [showColorPicker, setShowColorPicker] = useState(false);
  const [customColor, setCustomColor] = useState('#000000');
  const colorPickerRef = useRef<HTMLDivElement>(null);

  // Close color picker when clicking outside
  useEffect(() => {
    const handleClickOutside = (e: MouseEvent) => {
      if (colorPickerRef.current && !colorPickerRef.current.contains(e.target as Node)) {
        setShowColorPicker(false);
      }
    };
    document.addEventListener('mousedown', handleClickOutside);
    return () => document.removeEventListener('mousedown', handleClickOutside);
  }, []);

  const formatBold = useCallback(() => {
    editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold');
  }, [editor]);

  const formatItalic = useCallback(() => {
    editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic');
  }, [editor]);

  const formatStrikethrough = useCallback(() => {
    editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough');
  }, [editor]);

  const formatHeading = useCallback(
    (level: 'h2' | 'h3' | 'h4') => {
      editor.update(() => {
        const selection = $getSelection();
        if ($isRangeSelection(selection)) {
          $setBlocksType(selection, () => $createHeadingNode(level));
        }
      });
    },
    [editor]
  );

  const formatParagraph = useCallback(() => {
    editor.update(() => {
      const selection = $getSelection();
      if ($isRangeSelection(selection)) {
        $setBlocksType(selection, () => $createParagraphNode());
      }
    });
  }, [editor]);

  const formatBulletList = useCallback(() => {
    editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
  }, [editor]);

  const formatNumberedList = useCallback(() => {
    editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
  }, [editor]);

  const formatQuote = useCallback(() => {
    editor.update(() => {
      const selection = $getSelection();
      if ($isRangeSelection(selection)) {
        $setBlocksType(selection, () => $createQuoteNode());
      }
    });
  }, [editor]);

  const insertLink = useCallback(() => {
    const url = window.prompt('Enter URL:');
    if (url === null) return;

    if (url === '') {
      editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
      return;
    }

    // Validate URL
    if (!isValidUrl(url)) {
      alert('Invalid URL. Please enter a valid http, https, mailto, or tel URL.');
      return;
    }

    editor.dispatchCommand(TOGGLE_LINK_COMMAND, url);
  }, [editor]);

  const applyFontSize = useCallback(
    (size: number) => {
      editor.update(() => {
        const selection = $getSelection();
        if ($isRangeSelection(selection)) {
          const nodes = selection.getNodes();
          nodes.forEach((node) => {
            if ($isTextNode(node)) {
              // Apply font size as inline style
              const style = node.getStyle();
              const newStyle = style
                .split(';')
                .filter((s) => !s.trim().startsWith('font-size'))
                .concat(`font-size: ${size}px`)
                .filter(Boolean)
                .join(';');
              node.setStyle(newStyle);
            }
          });
        }
      });
    },
    [editor]
  );

  const applyColor = useCallback(
    (color: string) => {
      editor.update(() => {
        const selection = $getSelection();
        if ($isRangeSelection(selection)) {
          const nodes = selection.getNodes();
          nodes.forEach((node) => {
            if ($isTextNode(node)) {
              // Apply color as inline style
              const style = node.getStyle();
              const newStyle = style
                .split(';')
                .filter((s) => !s.trim().startsWith('color'))
                .concat(`color: ${color}`)
                .filter(Boolean)
                .join(';');
              node.setStyle(newStyle);
            }
          });
        }
      });
      setShowColorPicker(false);
    },
    [editor]
  );

  const undo = useCallback(() => {
    editor.dispatchCommand(UNDO_COMMAND, undefined);
  }, [editor]);

  const redo = useCallback(() => {
    editor.dispatchCommand(REDO_COMMAND, undefined);
  }, [editor]);

  return (
    <div
      role="toolbar"
      aria-label="Text formatting"
      style={{
        display: 'flex',
        flexWrap: 'wrap',
        gap: '0.25rem',
        padding: '0.5rem',
        borderBottom: '1px solid #d1d5db',
        background: '#f9fafb',
        alignItems: 'center',
      }}
    >
      <button
        type="button"
        onClick={formatBold}
        style={toolbarButtonStyle}
        title="Bold"
        aria-label="Bold"
      >
        <strong>B</strong>
      </button>
      <button
        type="button"
        onClick={formatItalic}
        style={toolbarButtonStyle}
        title="Italic"
        aria-label="Italic"
      >
        <em>I</em>
      </button>
      <button
        type="button"
        onClick={formatStrikethrough}
        style={toolbarButtonStyle}
        title="Strikethrough"
        aria-label="Strikethrough"
      >
        <s>S</s>
      </button>

      <span style={{ width: '1px', background: '#d1d5db', margin: '0 0.25rem', height: '24px' }} />

      {/* Font Size Dropdown */}
      <select
        onChange={(e) => applyFontSize(parseInt(e.target.value, 10))}
        style={{
          ...toolbarButtonStyle,
          minWidth: '60px',
          appearance: 'auto',
        }}
        title="Font Size"
        aria-label="Font Size"
        defaultValue=""
      >
        <option value="" disabled>
          Size
        </option>
        {FONT_SIZES.map((size) => (
          <option key={size} value={size}>
            {size}px
          </option>
        ))}
      </select>

      {/* Color Picker */}
      <div style={{ position: 'relative' }} ref={colorPickerRef}>
        <button
          type="button"
          onClick={() => setShowColorPicker(!showColorPicker)}
          style={{
            ...toolbarButtonStyle,
            display: 'flex',
            alignItems: 'center',
            gap: '4px',
          }}
          title="Text Color"
          aria-label="Text Color"
        >
          <span>A</span>
          <span
            style={{
              width: '14px',
              height: '4px',
              backgroundColor: customColor,
              display: 'block',
            }}
          />
        </button>
        {showColorPicker && (
          <div
            style={{
              position: 'absolute',
              top: '100%',
              left: 0,
              zIndex: 1000,
              background: 'white',
              border: '1px solid #d1d5db',
              borderRadius: '6px',
              padding: '0.5rem',
              boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
              minWidth: '160px',
            }}
          >
            <div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap', marginBottom: '0.5rem' }}>
              {BRAND_COLORS.map((c) => (
                <button
                  key={c.value}
                  type="button"
                  onClick={() => {
                    setCustomColor(c.value);
                    applyColor(c.value);
                  }}
                  title={c.label}
                  style={{
                    width: '28px',
                    height: '28px',
                    borderRadius: '4px',
                    border: '1px solid #d1d5db',
                    backgroundColor: c.value,
                    cursor: 'pointer',
                  }}
                />
              ))}
            </div>
            <div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
              <input
                type="color"
                value={customColor}
                onChange={(e) => setCustomColor(e.target.value)}
                style={{
                  width: '28px',
                  height: '28px',
                  padding: 0,
                  border: 'none',
                  cursor: 'pointer',
                }}
              />
              <input
                type="text"
                value={customColor}
                onChange={(e) => setCustomColor(e.target.value)}
                style={{
                  flex: 1,
                  padding: '4px',
                  border: '1px solid #d1d5db',
                  borderRadius: '4px',
                  fontSize: '12px',
                  fontFamily: 'monospace',
                }}
              />
              <button
                type="button"
                onClick={() => applyColor(customColor)}
                style={{
                  padding: '4px 8px',
                  background: '#093b66',
                  color: 'white',
                  border: 'none',
                  borderRadius: '4px',
                  cursor: 'pointer',
                  fontSize: '12px',
                }}
              >
                Apply
              </button>
            </div>
          </div>
        )}
      </div>

      <span style={{ width: '1px', background: '#d1d5db', margin: '0 0.25rem', height: '24px' }} />

      <button
        type="button"
        onClick={() => formatHeading('h2')}
        style={toolbarButtonStyle}
        title="Heading 2"
        aria-label="Heading 2"
      >
        H2
      </button>
      <button
        type="button"
        onClick={() => formatHeading('h3')}
        style={toolbarButtonStyle}
        title="Heading 3"
        aria-label="Heading 3"
      >
        H3
      </button>
      <button
        type="button"
        onClick={() => formatHeading('h4')}
        style={toolbarButtonStyle}
        title="Heading 4"
        aria-label="Heading 4"
      >
        H4
      </button>
      <button
        type="button"
        onClick={formatParagraph}
        style={toolbarButtonStyle}
        title="Normal text"
        aria-label="Normal text"
      >
        P
      </button>

      <span style={{ width: '1px', background: '#d1d5db', margin: '0 0.25rem', height: '24px' }} />

      <button
        type="button"
        onClick={formatBulletList}
        style={toolbarButtonStyle}
        title="Bullet List"
        aria-label="Bullet List"
      >
        UL
      </button>
      <button
        type="button"
        onClick={formatNumberedList}
        style={toolbarButtonStyle}
        title="Numbered List"
        aria-label="Numbered List"
      >
        OL
      </button>
      <button
        type="button"
        onClick={formatQuote}
        style={toolbarButtonStyle}
        title="Blockquote"
        aria-label="Blockquote"
      >
        Q
      </button>

      <span style={{ width: '1px', background: '#d1d5db', margin: '0 0.25rem', height: '24px' }} />

      <button
        type="button"
        onClick={insertLink}
        style={toolbarButtonStyle}
        title="Add Link"
        aria-label="Add Link"
      >
        Link
      </button>

      <span style={{ width: '1px', background: '#d1d5db', margin: '0 0.25rem', height: '24px' }} />

      <button
        type="button"
        onClick={undo}
        style={toolbarButtonStyle}
        title="Undo"
        aria-label="Undo"
      >
        Undo
      </button>
      <button
        type="button"
        onClick={redo}
        style={toolbarButtonStyle}
        title="Redo"
        aria-label="Redo"
      >
        Redo
      </button>
    </div>
  );
}
```

**Step 4: Add useState and useRef imports**

Update the React import at the top of the file:

```tsx
import { useCallback, useEffect, useRef, useState } from 'react';
```

**Step 5: Verify lint passes**

Run: `npm run lint -- --max-warnings=0 lib/cms/LexicalField.tsx`
Expected: No errors

**Step 6: Commit**

```bash
git add lib/cms/LexicalField.tsx
git commit -m "feat(cms): add font size and color controls to Lexical toolbar"
```

---

## Task 5: Add Image Linking Fields

**Files:**

- Modify: `lib/cms/puck-config.tsx`

**Step 1: Update ImageProps interface**

Find the ImageProps interface (around line 65-71) and update:

```tsx
export interface ImageProps {
  src: string;
  alt: string;
  width?: 'auto' | 'full' | 'half';
  alignment: 'left' | 'center' | 'right';
  caption?: string;
  linkUrl?: string;
  linkBehavior?: 'navigate' | 'lightbox';
  openInNewTab?: boolean;
}
```

**Step 2: Update ImageComponent**

Find the ImageComponent function (around line 358-439) and replace:

```tsx
// Image Component
function ImageComponent({
  src,
  alt,
  width,
  alignment,
  caption,
  linkUrl,
  linkBehavior,
  openInNewTab,
}: ImageProps) {
  const widthStyles = {
    auto: 'auto',
    full: '100%',
    half: '50%',
  };

  const alignmentStyles = {
    left: 'flex-start',
    center: 'center',
    right: 'flex-end',
  };

  const hasValidSrc = src && src.trim() !== '';
  const hasLink = linkUrl && linkUrl.trim() !== '';

  const imageElement = hasValidSrc ? (
    // eslint-disable-next-line @next/next/no-img-element -- CMS images use external URLs that can't be whitelisted
    <img
      src={src}
      alt={alt || 'Image'}
      style={{
        width: widthStyles[width || 'auto'],
        maxWidth: '100%',
        height: 'auto',
        borderRadius: '8px',
        cursor: hasLink ? 'pointer' : 'default',
      }}
    />
  ) : (
    <div
      style={{
        width: widthStyles[width || 'auto'],
        minWidth: '200px',
        height: '150px',
        backgroundColor: '#f0f0f0',
        border: '2px dashed #c6c6c6',
        borderRadius: '8px',
        display: 'flex',
        flexDirection: 'column',
        alignItems: 'center',
        justifyContent: 'center',
        color: '#757575',
      }}
    >
      <svg
        width="48"
        height="48"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.5"
      >
        <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
        <circle cx="8.5" cy="8.5" r="1.5" />
        <polyline points="21 15 16 10 5 21" />
      </svg>
      <span style={{ marginTop: '0.5rem', fontSize: '0.875rem' }}>Select an image</span>
    </div>
  );

  // Wrap in link if linkUrl is provided
  const wrappedImage =
    hasLink && linkBehavior === 'navigate' ? (
      <a
        href={linkUrl}
        target={openInNewTab ? '_blank' : undefined}
        rel={openInNewTab ? 'noopener noreferrer' : undefined}
        style={{ display: 'block' }}
      >
        {imageElement}
      </a>
    ) : hasLink && linkBehavior === 'lightbox' ? (
      <button
        type="button"
        onClick={() => {
          // Lightbox preview in editor - just show alert
          alert('Lightbox preview: Image will open in a modal overlay on the published page.');
        }}
        style={{
          border: 'none',
          padding: 0,
          background: 'none',
          cursor: 'zoom-in',
        }}
      >
        {imageElement}
      </button>
    ) : (
      imageElement
    );

  return (
    <figure
      style={{
        display: 'flex',
        flexDirection: 'column',
        alignItems: alignmentStyles[alignment],
        margin: '1.5rem 0',
      }}
    >
      {wrappedImage}
      {caption && (
        <figcaption
          style={{
            marginTop: '0.5rem',
            fontSize: '0.875rem',
            color: '#757575',
            textAlign: alignment,
          }}
        >
          {caption}
        </figcaption>
      )}
    </figure>
  );
}
```

**Step 3: Update Image config fields**

Find the Image configuration (around line 692-736) and update:

```tsx
    Image: {
      fields: {
        src: {
          type: 'custom',
          label: 'Image',
          render: ({ value, onChange }) => (
            <ImagePickerField value={value || ''} onChange={onChange} />
          ),
        },
        alt: {
          type: 'text',
          label: 'Alt Text',
        },
        width: {
          type: 'select',
          label: 'Width',
          options: [
            { label: 'Auto', value: 'auto' },
            { label: 'Full Width', value: 'full' },
            { label: 'Half Width', value: 'half' },
          ],
        },
        alignment: {
          type: 'select',
          label: 'Alignment',
          options: [
            { label: 'Left', value: 'left' },
            { label: 'Center', value: 'center' },
            { label: 'Right', value: 'right' },
          ],
        },
        caption: {
          type: 'text',
          label: 'Caption (optional)',
        },
        linkUrl: {
          type: 'text',
          label: 'Link URL (optional)',
        },
        linkBehavior: {
          type: 'select',
          label: 'Click Behavior',
          options: [
            { label: 'Navigate to URL', value: 'navigate' },
            { label: 'Open in Lightbox', value: 'lightbox' },
          ],
        },
        openInNewTab: {
          type: 'radio',
          label: 'Open in New Tab',
          options: [
            { label: 'Yes', value: true },
            { label: 'No', value: false },
          ],
        },
      },
      defaultProps: {
        src: '',
        alt: 'Image',
        width: 'auto',
        alignment: 'center',
        caption: '',
        linkUrl: '',
        linkBehavior: 'navigate',
        openInNewTab: false,
      },
      render: ImageComponent as any,
    },
```

**Step 4: Verify lint passes**

Run: `npm run lint -- --max-warnings=0 lib/cms/puck-config.tsx`
Expected: No errors

**Step 5: Commit**

```bash
git add lib/cms/puck-config.tsx
git commit -m "feat(cms): add image linking with navigate and lightbox options"
```

---

## Task 6: Update Publisher for Image Linking

**Files:**

- Modify: `lib/cms/publisher.ts`
- Modify: `public/assets/cms.css`

**Step 1: Update renderImage function**

Find the renderImage function (around line 301-325) and replace:

```tsx
function renderImage(
  src: string,
  alt: string,
  width?: 'auto' | 'full' | 'half',
  alignment?: 'left' | 'center' | 'right',
  caption?: string,
  linkUrl?: string,
  linkBehavior?: 'navigate' | 'lightbox',
  openInNewTab?: boolean
): string {
  if (!src) {
    return '';
  }

  const widthClass =
    width === 'full' ? 'cms-image--full' : width === 'half' ? 'cms-image--half' : '';
  const alignClass = `cms-image--${alignment || 'center'}`;
  const classes = ['cms-image', widthClass, alignClass].filter(Boolean).join(' ');

  const captionHtml = caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : '';
  const imageHtml = `<img src="${escapeHtml(src)}" alt="${escapeHtml(alt || 'Image')}" loading="lazy">`;

  let wrappedImage = imageHtml;

  if (linkUrl && linkUrl.trim()) {
    // Validate URL - prevent javascript: protocol
    const isValidLink = !linkUrl.toLowerCase().startsWith('javascript:');

    if (isValidLink) {
      if (linkBehavior === 'lightbox') {
        // Lightbox using dialog element
        const dialogId = getUniqueId('lightbox');
        wrappedImage = `
          <button type="button" class="cms-lightbox-trigger" onclick="document.getElementById('${dialogId}').showModal()">
            ${imageHtml}
          </button>
          <dialog id="${dialogId}" class="cms-lightbox-dialog" onclick="if(event.target===this)this.close()">
            <button type="button" class="cms-lightbox-close" onclick="this.parentElement.close()" aria-label="Close">&times;</button>
            <img src="${escapeHtml(src)}" alt="${escapeHtml(alt || 'Image')}">
          </dialog>
        `;
      } else {
        // Navigate link
        const isExternal =
          linkUrl.startsWith('http') &&
          !linkUrl.includes(process.env.NEXT_PUBLIC_BASE_URL || 'localhost');
        const target = openInNewTab ? ' target="_blank"' : '';
        const rel = isExternal || openInNewTab ? ' rel="noopener noreferrer"' : '';
        wrappedImage = `<a href="${escapeHtml(linkUrl)}"${target}${rel}>${imageHtml}</a>`;
      }
    }
  }

  return `
    <figure class="${classes}">
      ${wrappedImage}
      ${captionHtml}
    </figure>
  `;
}
```

**Step 2: Update renderPuckComponent call for Image**

Find the Image case in renderPuckComponent (around line 145-152) and update:

```tsx
    case 'Image':
      return renderImage(
        component.props.src as string,
        component.props.alt as string,
        component.props.width as 'auto' | 'full' | 'half' | undefined,
        component.props.alignment as 'left' | 'center' | 'right',
        component.props.caption as string | undefined,
        component.props.linkUrl as string | undefined,
        component.props.linkBehavior as 'navigate' | 'lightbox' | undefined,
        component.props.openInNewTab as boolean | undefined
      );
```

**Step 3: Add lightbox CSS styles**

Add to the end of `public/assets/cms.css`:

```css
/* ==================== LIGHTBOX ==================== */
.cms-lightbox-trigger {
  border: none;
  padding: 0;
  background: none;
  cursor: zoom-in;
  display: block;
}

.cms-lightbox-trigger img {
  transition: transform 0.2s ease;
}

.cms-lightbox-trigger:hover img {
  transform: scale(1.02);
}

.cms-lightbox-dialog {
  border: none;
  background: transparent;
  padding: 0;
  max-width: 90vw;
  max-height: 90vh;
  position: fixed;
  inset: 0;
  margin: auto;
}

.cms-lightbox-dialog::backdrop {
  background: rgba(0, 0, 0, 0.85);
}

.cms-lightbox-dialog img {
  max-width: 90vw;
  max-height: 85vh;
  object-fit: contain;
  border-radius: var(--cms-radius);
}

.cms-lightbox-close {
  position: absolute;
  top: -40px;
  right: 0;
  background: transparent;
  border: none;
  color: white;
  font-size: 2rem;
  cursor: pointer;
  padding: 0.5rem;
  line-height: 1;
}

.cms-lightbox-close:hover {
  color: var(--cms-accent);
}

/* Keyboard accessibility for lightbox */
.cms-lightbox-dialog:focus {
  outline: none;
}
```

**Step 4: Verify lint passes**

Run: `npm run lint -- --max-warnings=0 lib/cms/publisher.ts`
Expected: No errors

**Step 5: Commit**

```bash
git add lib/cms/publisher.ts public/assets/cms.css
git commit -m "feat(cms): render image links and lightbox in published pages"
```

---

## Task 7: Create Video Utilities

**Files:**

- Create: `lib/cms/video-utils.ts`

**Step 1: Create the video utilities file**

```tsx
// lib/cms/video-utils.ts

/**
 * Video embed utilities for CMS
 * Handles URL parsing and embed code sanitization
 */

/**
 * Parses a YouTube URL and extracts the video ID
 */
export function parseYouTubeUrl(url: string): string | null {
  if (!url) return null;

  // Handle various YouTube URL formats
  const patterns = [
    /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/,
    /^([a-zA-Z0-9_-]{11})$/, // Just the ID
  ];

  for (const pattern of patterns) {
    const match = url.match(pattern);
    if (match) return match[1];
  }

  return null;
}

/**
 * Parses a Vimeo URL and extracts the video ID
 */
export function parseVimeoUrl(url: string): string | null {
  if (!url) return null;

  const patterns = [
    /vimeo\.com\/(\d+)/,
    /player\.vimeo\.com\/video\/(\d+)/,
    /^(\d+)$/, // Just the ID
  ];

  for (const pattern of patterns) {
    const match = url.match(pattern);
    if (match) return match[1];
  }

  return null;
}

/**
 * Generates a YouTube embed URL with options
 */
export function getYouTubeEmbedUrl(
  videoId: string,
  options: { autoplay?: boolean; loop?: boolean; controls?: boolean } = {}
): string {
  const params = new URLSearchParams();

  if (options.autoplay) {
    params.set('autoplay', '1');
    params.set('mute', '1'); // Required for autoplay
  }
  if (options.loop) {
    params.set('loop', '1');
    params.set('playlist', videoId); // Required for loop
  }
  if (options.controls === false) {
    params.set('controls', '0');
  }

  const queryString = params.toString();
  return `https://www.youtube-nocookie.com/embed/${videoId}${queryString ? `?${queryString}` : ''}`;
}

/**
 * Generates a Vimeo embed URL with options
 */
export function getVimeoEmbedUrl(
  videoId: string,
  options: { autoplay?: boolean; loop?: boolean; controls?: boolean } = {}
): string {
  const params = new URLSearchParams();

  if (options.autoplay) {
    params.set('autoplay', '1');
    params.set('muted', '1'); // Required for autoplay
  }
  if (options.loop) {
    params.set('loop', '1');
  }
  if (options.controls === false) {
    params.set('controls', '0');
  }

  const queryString = params.toString();
  return `https://player.vimeo.com/video/${videoId}${queryString ? `?${queryString}` : ''}`;
}

/**
 * Allowed domains for video embeds
 */
const ALLOWED_EMBED_DOMAINS = [
  'youtube.com',
  'youtube-nocookie.com',
  'youtu.be',
  'vimeo.com',
  'player.vimeo.com',
  'wistia.com',
  'fast.wistia.com',
  'loom.com',
  'dailymotion.com',
];

/**
 * Sanitizes custom embed code
 * Only allows iframe tags with src from approved domains
 */
export function sanitizeEmbedCode(embedCode: string): string | null {
  if (!embedCode) return null;

  // Parse the HTML
  const parser = new DOMParser();
  const doc = parser.parseFromString(embedCode, 'text/html');

  // Find iframe
  const iframe = doc.querySelector('iframe');
  if (!iframe) return null;

  // Validate src domain
  const src = iframe.getAttribute('src');
  if (!src) return null;

  try {
    const url = new URL(src);
    const domain = url.hostname.replace(/^www\./, '');

    const isAllowed = ALLOWED_EMBED_DOMAINS.some(
      (allowed) => domain === allowed || domain.endsWith(`.${allowed}`)
    );

    if (!isAllowed) {
      console.warn(`Embed domain not allowed: ${domain}`);
      return null;
    }

    // Build sanitized iframe with only allowed attributes
    const allowedAttrs = [
      'src',
      'width',
      'height',
      'frameborder',
      'allow',
      'allowfullscreen',
      'title',
    ];
    const attrs: string[] = [];

    for (const attr of allowedAttrs) {
      const value = iframe.getAttribute(attr);
      if (value !== null) {
        attrs.push(`${attr}="${escapeAttr(value)}"`);
      }
    }

    return `<iframe ${attrs.join(' ')}></iframe>`;
  } catch {
    return null;
  }
}

/**
 * Escapes attribute values for safe HTML output
 */
function escapeAttr(str: string): string {
  return str
    .replace(/&/g, '&amp;')
    .replace(/"/g, '&quot;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;');
}

/**
 * Aspect ratio CSS values
 */
export const ASPECT_RATIOS: Record<string, string> = {
  '16:9': '16 / 9',
  '4:3': '4 / 3',
  '1:1': '1 / 1',
  '9:16': '9 / 16',
};
```

**Step 2: Verify the file was created correctly**

Run: `head -30 lib/cms/video-utils.ts`
Expected: Shows the file header and first functions

**Step 3: Commit**

```bash
git add lib/cms/video-utils.ts
git commit -m "feat(cms): add video URL parsing and embed sanitization utilities"
```

---

## Task 8: Add Video Component to Puck Config

**Files:**

- Modify: `lib/cms/puck-config.tsx`

**Step 1: Add VideoProps interface**

After the TwoColumnProps interface (around line 78), add:

```tsx
export interface VideoProps {
  embedType: 'youtube' | 'vimeo' | 'url' | 'embed';
  videoId?: string;
  videoUrl?: string;
  embedCode?: string;
  aspectRatio: '16:9' | '4:3' | '1:1' | '9:16';
  autoplay: boolean;
  loop: boolean;
  showControls: boolean;
}
```

**Step 2: Add video utilities import**

Add to the imports at the top of the file:

```tsx
import {
  parseYouTubeUrl,
  parseVimeoUrl,
  getYouTubeEmbedUrl,
  getVimeoEmbedUrl,
  sanitizeEmbedCode,
  ASPECT_RATIOS,
} from './video-utils';
```

**Step 3: Add VideoComponent**

After the TwoColumnComponent (around line 480), add:

```tsx
// Video Component
function VideoComponent({
  embedType,
  videoId,
  videoUrl,
  embedCode,
  aspectRatio,
  autoplay,
  loop,
  showControls,
}: VideoProps) {
  const aspectValue = ASPECT_RATIOS[aspectRatio] || '16 / 9';

  const renderContent = () => {
    if (embedType === 'youtube') {
      const id = parseYouTubeUrl(videoId || '');
      if (!id) {
        return (
          <div style={{ padding: '2rem', textAlign: 'center', color: '#757575' }}>
            Enter a valid YouTube URL or video ID
          </div>
        );
      }
      const src = getYouTubeEmbedUrl(id, { autoplay, loop, controls: showControls });
      return (
        <iframe
          src={src}
          style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', border: 'none' }}
          allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
          allowFullScreen
          title="YouTube video"
        />
      );
    }

    if (embedType === 'vimeo') {
      const id = parseVimeoUrl(videoId || '');
      if (!id) {
        return (
          <div style={{ padding: '2rem', textAlign: 'center', color: '#757575' }}>
            Enter a valid Vimeo URL or video ID
          </div>
        );
      }
      const src = getVimeoEmbedUrl(id, { autoplay, loop, controls: showControls });
      return (
        <iframe
          src={src}
          style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', border: 'none' }}
          allow="autoplay; fullscreen; picture-in-picture"
          allowFullScreen
          title="Vimeo video"
        />
      );
    }

    if (embedType === 'url') {
      if (!videoUrl) {
        return (
          <div style={{ padding: '2rem', textAlign: 'center', color: '#757575' }}>
            Enter a video URL
          </div>
        );
      }
      return (
        <video
          src={videoUrl}
          autoPlay={autoplay}
          muted={autoplay}
          loop={loop}
          controls={showControls}
          style={{
            position: 'absolute',
            inset: 0,
            width: '100%',
            height: '100%',
            objectFit: 'contain',
          }}
        />
      );
    }

    if (embedType === 'embed') {
      const sanitized = sanitizeEmbedCode(embedCode || '');
      if (!sanitized) {
        return (
          <div style={{ padding: '2rem', textAlign: 'center', color: '#757575' }}>
            Enter a valid embed code from YouTube, Vimeo, Wistia, or Loom
          </div>
        );
      }
      return (
        <div
          style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }}
          dangerouslySetInnerHTML={{ __html: sanitized }}
        />
      );
    }

    return (
      <div style={{ padding: '2rem', textAlign: 'center', color: '#757575' }}>
        Select an embed type to add a video
      </div>
    );
  };

  return (
    <div
      className="cms-video-container"
      style={{
        position: 'relative',
        width: '100%',
        aspectRatio: aspectValue,
        backgroundColor: '#000',
        borderRadius: '8px',
        overflow: 'hidden',
        margin: '1.5rem 0',
      }}
    >
      {renderContent()}
    </div>
  );
}
```

**Step 4: Add Video to puck components config**

After the TwoColumn config (around line 773), add:

```tsx
    Video: {
      label: 'Video',
      fields: {
        embedType: {
          type: 'select',
          label: 'Video Source',
          options: [
            { label: 'YouTube', value: 'youtube' },
            { label: 'Vimeo', value: 'vimeo' },
            { label: 'Direct URL (MP4/WebM)', value: 'url' },
            { label: 'Custom Embed Code', value: 'embed' },
          ],
        },
        videoId: {
          type: 'text',
          label: 'Video ID or URL',
        },
        videoUrl: {
          type: 'text',
          label: 'Video URL',
        },
        embedCode: {
          type: 'textarea',
          label: 'Embed Code',
        },
        aspectRatio: {
          type: 'select',
          label: 'Aspect Ratio',
          options: [
            { label: '16:9 (Widescreen)', value: '16:9' },
            { label: '4:3 (Standard)', value: '4:3' },
            { label: '1:1 (Square)', value: '1:1' },
            { label: '9:16 (Vertical)', value: '9:16' },
          ],
        },
        autoplay: {
          type: 'radio',
          label: 'Autoplay (muted)',
          options: [
            { label: 'Yes', value: true },
            { label: 'No', value: false },
          ],
        },
        loop: {
          type: 'radio',
          label: 'Loop',
          options: [
            { label: 'Yes', value: true },
            { label: 'No', value: false },
          ],
        },
        showControls: {
          type: 'radio',
          label: 'Show Controls',
          options: [
            { label: 'Yes', value: true },
            { label: 'No', value: false },
          ],
        },
      },
      defaultProps: {
        embedType: 'youtube',
        videoId: '',
        videoUrl: '',
        embedCode: '',
        aspectRatio: '16:9',
        autoplay: false,
        loop: false,
        showControls: true,
      },
      render: VideoComponent as any,
    },
```

**Step 5: Update categories to include Video**

Find the categories section (around line 808-821) and update the content category:

```tsx
  categories: {
    content: {
      title: 'Content',
      components: ['Heading', 'RichText', 'Image', 'Video'],
    },
    interactive: {
      title: 'Interactive',
      components: ['Accordion', 'Tabs'],
    },
    layout: {
      title: 'Layout',
      components: ['TwoColumn', 'Spacer', 'Divider'],
    },
  },
```

**Step 6: Verify lint passes**

Run: `npm run lint -- --max-warnings=0 lib/cms/puck-config.tsx lib/cms/video-utils.ts`
Expected: No errors

**Step 7: Commit**

```bash
git add lib/cms/puck-config.tsx
git commit -m "feat(cms): add Video component with YouTube, Vimeo, URL, and embed support"
```

---

## Task 9: Update Publisher for Video Component

**Files:**

- Modify: `lib/cms/publisher.ts`
- Modify: `public/assets/cms.css`

**Step 1: Add video utilities import**

At the top of publisher.ts, add:

```tsx
import {
  parseYouTubeUrl,
  parseVimeoUrl,
  getYouTubeEmbedUrl,
  getVimeoEmbedUrl,
  sanitizeEmbedCode,
  ASPECT_RATIOS,
} from './video-utils';
```

**Step 2: Add renderVideo function**

After the renderImage function (around line 360), add:

```tsx
function renderVideo(
  embedType: 'youtube' | 'vimeo' | 'url' | 'embed',
  videoId?: string,
  videoUrl?: string,
  embedCode?: string,
  aspectRatio: '16:9' | '4:3' | '1:1' | '9:16' = '16:9',
  autoplay: boolean = false,
  loop: boolean = false,
  showControls: boolean = true
): string {
  const aspectValue = ASPECT_RATIOS[aspectRatio] || '16 / 9';
  const containerStyle = `aspect-ratio: ${aspectValue}`;

  let content = '';

  if (embedType === 'youtube') {
    const id = parseYouTubeUrl(videoId || '');
    if (!id) return '';
    const src = getYouTubeEmbedUrl(id, { autoplay, loop, controls: showControls });
    content = `<iframe src="${escapeHtml(src)}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen title="YouTube video"></iframe>`;
  } else if (embedType === 'vimeo') {
    const id = parseVimeoUrl(videoId || '');
    if (!id) return '';
    const src = getVimeoEmbedUrl(id, { autoplay, loop, controls: showControls });
    content = `<iframe src="${escapeHtml(src)}" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen title="Vimeo video"></iframe>`;
  } else if (embedType === 'url') {
    if (!videoUrl) return '';
    const autoplayAttr = autoplay ? ' autoplay muted' : '';
    const loopAttr = loop ? ' loop' : '';
    const controlsAttr = showControls ? ' controls' : '';
    content = `<video src="${escapeHtml(videoUrl)}"${autoplayAttr}${loopAttr}${controlsAttr}></video>`;
  } else if (embedType === 'embed') {
    const sanitized = sanitizeEmbedCode(embedCode || '');
    if (!sanitized) return '';
    content = sanitized;
  }

  if (!content) return '';

  return `
    <div class="cms-video-container" style="${containerStyle}">
      ${content}
    </div>
  `;
}
```

**Step 3: Add Video case to renderPuckComponent**

Find the default case in renderPuckComponent and add before it:

```tsx
    case 'Video':
      return renderVideo(
        component.props.embedType as 'youtube' | 'vimeo' | 'url' | 'embed',
        component.props.videoId as string | undefined,
        component.props.videoUrl as string | undefined,
        component.props.embedCode as string | undefined,
        component.props.aspectRatio as '16:9' | '4:3' | '1:1' | '9:16',
        component.props.autoplay as boolean,
        component.props.loop as boolean,
        component.props.showControls as boolean
      );
```

**Step 4: Add video CSS styles**

Add to the end of `public/assets/cms.css`:

```css
/* ==================== VIDEO ==================== */
.cms-video-container {
  position: relative;
  width: 100%;
  background-color: #000;
  border-radius: var(--cms-radius);
  overflow: hidden;
  margin: 1.5rem 0;
}

.cms-video-container iframe,
.cms-video-container video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: none;
}

.cms-video-container video {
  object-fit: contain;
}
```

**Step 5: Verify lint passes**

Run: `npm run lint -- --max-warnings=0 lib/cms/publisher.ts`
Expected: No errors

**Step 6: Commit**

```bash
git add lib/cms/publisher.ts public/assets/cms.css
git commit -m "feat(cms): render video embeds in published pages"
```

---

## Task 10: Add Grid Component to Puck Config

**Files:**

- Modify: `lib/cms/puck-config.tsx`

**Step 1: Add GridProps interface**

After the VideoProps interface, add:

```tsx
export interface GridProps {
  columns: 2 | 3 | 4;
  gap: 'none' | 'small' | 'medium' | 'large';
  verticalAlign: 'top' | 'center' | 'bottom';
  column1: React.FC;
  column2: React.FC;
  column3?: React.FC;
  column4?: React.FC;
}
```

**Step 2: Add GridComponent**

After the VideoComponent, add:

```tsx
// Grid Layout Component
function GridComponent({
  columns,
  gap,
  verticalAlign,
  column1: Column1,
  column2: Column2,
  column3: Column3,
  column4: Column4,
}: GridProps) {
  const gapStyles = {
    none: '0',
    small: '1rem',
    medium: '1.5rem',
    large: '2rem',
  };

  const alignStyles = {
    top: 'flex-start',
    center: 'center',
    bottom: 'flex-end',
  };

  const columnCount = columns || 2;
  const columnSlots = [Column1, Column2, Column3, Column4].slice(0, columnCount);

  return (
    <div
      className="cms-grid"
      style={{
        display: 'grid',
        gridTemplateColumns: `repeat(${columnCount}, 1fr)`,
        gap: gapStyles[gap] || '1.5rem',
        alignItems: alignStyles[verticalAlign] || 'flex-start',
        marginBottom: '1.5rem',
      }}
    >
      {columnSlots.map((ColumnSlot, index) => (
        <div key={index} className={`cms-grid-column cms-grid-column-${index + 1}`}>
          {ColumnSlot && <ColumnSlot />}
        </div>
      ))}
    </div>
  );
}
```

**Step 3: Add Grid to puck components config**

After the Video config, add:

```tsx
    Grid: {
      label: 'Grid Layout',
      fields: {
        columns: {
          type: 'select',
          label: 'Number of Columns',
          options: [
            { label: '2 Columns', value: 2 },
            { label: '3 Columns', value: 3 },
            { label: '4 Columns', value: 4 },
          ],
        },
        gap: {
          type: 'select',
          label: 'Gap',
          options: [
            { label: 'None', value: 'none' },
            { label: 'Small', value: 'small' },
            { label: 'Medium', value: 'medium' },
            { label: 'Large', value: 'large' },
          ],
        },
        verticalAlign: {
          type: 'select',
          label: 'Vertical Alignment',
          options: [
            { label: 'Top', value: 'top' },
            { label: 'Center', value: 'center' },
            { label: 'Bottom', value: 'bottom' },
          ],
        },
        column1: {
          type: 'slot',
        },
        column2: {
          type: 'slot',
        },
        column3: {
          type: 'slot',
        },
        column4: {
          type: 'slot',
        },
      },
      defaultProps: {
        columns: 2,
        gap: 'medium',
        verticalAlign: 'top',
        column1: [],
        column2: [],
        column3: [],
        column4: [],
      },
      render: GridComponent as any,
    },
```

**Step 4: Update categories to include Grid**

Update the layout category:

```tsx
    layout: {
      title: 'Layout',
      components: ['Grid', 'TwoColumn', 'Spacer', 'Divider'],
    },
```

**Step 5: Verify lint passes**

Run: `npm run lint -- --max-warnings=0 lib/cms/puck-config.tsx`
Expected: No errors

**Step 6: Commit**

```bash
git add lib/cms/puck-config.tsx
git commit -m "feat(cms): add Grid layout component with 2-4 columns and slots"
```

---

## Task 11: Update Publisher for Grid Component

**Files:**

- Modify: `lib/cms/publisher.ts`
- Modify: `public/assets/cms.css`

**Step 1: Add renderGrid function**

After the renderTwoColumn function, add:

```tsx
function renderGrid(
  columns: 2 | 3 | 4,
  gap: 'none' | 'small' | 'medium' | 'large',
  verticalAlign: 'top' | 'center' | 'bottom',
  column1Slot: Array<{ type: string; props: Record<string, unknown> }> | undefined,
  column2Slot: Array<{ type: string; props: Record<string, unknown> }> | undefined,
  column3Slot: Array<{ type: string; props: Record<string, unknown> }> | undefined,
  column4Slot: Array<{ type: string; props: Record<string, unknown> }> | undefined,
  puckData?: Data,
  componentId?: string
): string {
  const columnCount = columns || 2;
  const gapClass = `cms-grid--gap-${gap || 'medium'}`;
  const alignClass = `cms-grid--align-${verticalAlign || 'top'}`;
  const columnsClass = `cms-grid--${columnCount}-col`;

  const slots = [column1Slot, column2Slot, column3Slot, column4Slot].slice(0, columnCount);

  const columnsHtml = slots
    .map((slot, index) => {
      let content = '';
      if (slot && Array.isArray(slot) && slot.length > 0) {
        content = renderSlotContent(slot, puckData, componentId, `column${index + 1}`);
      } else if (puckData && componentId) {
        content = renderDropZoneContent(puckData, componentId, `column${index + 1}`);
      }
      return `<div class="cms-grid-column">${content}</div>`;
    })
    .join('\n');

  return `
    <div class="cms-grid ${columnsClass} ${gapClass} ${alignClass}">
      ${columnsHtml}
    </div>
  `;
}
```

**Step 2: Add Grid case to renderPuckComponent**

Add before the default case:

```tsx
    case 'Grid':
      return renderGrid(
        component.props.columns as 2 | 3 | 4,
        component.props.gap as 'none' | 'small' | 'medium' | 'large',
        component.props.verticalAlign as 'top' | 'center' | 'bottom',
        component.props.column1 as Array<{ type: string; props: Record<string, unknown> }> | undefined,
        component.props.column2 as Array<{ type: string; props: Record<string, unknown> }> | undefined,
        component.props.column3 as Array<{ type: string; props: Record<string, unknown> }> | undefined,
        component.props.column4 as Array<{ type: string; props: Record<string, unknown> }> | undefined,
        puckData,
        componentId
      );
```

**Step 3: Add grid CSS styles**

Add to the end of `public/assets/cms.css`:

```css
/* ==================== GRID LAYOUT ==================== */
.cms-grid {
  display: grid;
  margin: 1.5rem 0;
}

.cms-grid--2-col {
  grid-template-columns: repeat(2, 1fr);
}

.cms-grid--3-col {
  grid-template-columns: repeat(3, 1fr);
}

.cms-grid--4-col {
  grid-template-columns: repeat(4, 1fr);
}

.cms-grid--gap-none {
  gap: 0;
}

.cms-grid--gap-small {
  gap: 1rem;
}

.cms-grid--gap-medium {
  gap: 1.5rem;
}

.cms-grid--gap-large {
  gap: 2rem;
}

.cms-grid--align-top {
  align-items: flex-start;
}

.cms-grid--align-center {
  align-items: center;
}

.cms-grid--align-bottom {
  align-items: flex-end;
}

.cms-grid-column {
  min-width: 0; /* Prevent grid blowout */
}

/* Responsive Grid */
@media (max-width: 768px) {
  .cms-grid--3-col,
  .cms-grid--4-col {
    grid-template-columns: repeat(2, 1fr);
  }
}

@media (max-width: 480px) {
  .cms-grid--2-col,
  .cms-grid--3-col,
  .cms-grid--4-col {
    grid-template-columns: 1fr;
  }
}
```

**Step 4: Verify lint passes**

Run: `npm run lint -- --max-warnings=0 lib/cms/publisher.ts`
Expected: No errors

**Step 5: Commit**

```bash
git add lib/cms/publisher.ts public/assets/cms.css
git commit -m "feat(cms): render Grid component with responsive breakpoints"
```

---

## Task 12: Final Verification and Cleanup

**Step 1: Run full lint check**

Run: `npm run lint`
Expected: No errors

**Step 2: Run TypeScript type check**

Run: `npx tsc --noEmit`
Expected: No type errors

**Step 3: Run build**

Run: `npm run build`
Expected: Build succeeds

**Step 4: Create final commit if any fixes were needed**

```bash
git add -A
git commit -m "fix(cms): address lint and type errors from pages editor improvements"
```

**Step 5: Push branch**

```bash
git push -u origin 641-pages-editor-improvements
```

---

## Summary

This implementation plan adds 5 major features to the Pages Editor:

1. **Font Size Control** - Granular numeric input (12-72px) for Heading component, dropdown in Lexical toolbar
2. **Font Color Control** - ColorPickerField with AMSOIL brand palette + custom hex for Heading and Lexical
3. **Image Linking** - Link URL with navigate/lightbox behavior options and secure external link handling
4. **Video Embedding** - YouTube, Vimeo, direct URL, and custom embed support with aspect ratio and playback controls
5. **Grid Layout** - 2-4 column flexible grid with gap control and responsive breakpoints

All changes are shared between the dealer Pages Editor and admin Resource Guide CMS through the common `puck-config.tsx` configuration.
