> ## Documentation Index
> Fetch the complete documentation index at: https://hyperscape-ai-mintlify-docs-update.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Dialogue skilling panels

# Dialogue and Skilling Panel System Documentation

Comprehensive guide for the unified skilling panels and NPC dialogue system (polished in PR #1093, March 26, 2026).

## Overview

PR #1093 introduced:

* **Unified Skilling Panels**: Shared components and styling for all crafting/processing interfaces
* **NPC Dialogue Redesign**: Dedicated modal shell with live 3D VRM portraits
* **Service Handoff Fix**: Proper dialogue closure when opening bank/store/tanner

**Impact**: Eliminates \~500 lines of duplicated styling, more immersive NPC interactions.

## Skilling Panel System

### Architecture

**Before** (duplicated styling):

```
FletchingPanel.tsx    - 200 lines of styling
CookingPanel.tsx      - 200 lines of styling
SmeltingPanel.tsx     - 200 lines of styling
SmithingPanel.tsx     - 200 lines of styling
CraftingPanel.tsx     - 200 lines of styling
TanningPanel.tsx      - 200 lines of styling
Total: ~1200 lines
```

**After** (shared components):

```
SkillingPanelShared.tsx  - 300 lines (shared)
FletchingPanel.tsx       - 100 lines (logic only)
CookingPanel.tsx         - 100 lines (logic only)
SmeltingPanel.tsx        - 100 lines (logic only)
SmithingPanel.tsx        - 100 lines (logic only)
CraftingPanel.tsx        - 100 lines (logic only)
TanningPanel.tsx         - 100 lines (logic only)
Total: ~900 lines (25% reduction)
```

### Shared Components

#### `SkillingPanelBody`

Container for skilling panel content with intro text and empty state.

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
export function SkillingPanelBody(props: {
  theme: Theme;
  children?: ReactNode;
  emptyMessage?: string;
  intro?: string;
}): JSX.Element
```

**Usage**:

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
<SkillingPanelBody
  theme={theme}
  intro="Select logs and a bowstring to fletch arrows."
  emptyMessage="No fletching recipes available."
>
  {recipes.map(recipe => (
    <RecipeCard key={recipe.id} recipe={recipe} />
  ))}
</SkillingPanelBody>
```

**Features**:

* Intro text at top (optional)
* Empty state message when no children
* Consistent padding and spacing
* Responsive layout (mobile/desktop)

#### `SkillingSection`

Section container with consistent styling.

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
export function SkillingSection(props: {
  theme: Theme;
  children: ReactNode;
  className?: string;
  style?: CSSProperties;
}): JSX.Element
```

**Usage**:

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
<SkillingSection theme={theme}>
  <h3>Available Recipes</h3>
  {recipes.map(recipe => (
    <RecipeCard key={recipe.id} recipe={recipe} />
  ))}
</SkillingSection>
```

**Features**:

* Consistent padding (12px)
* Border radius (8px)
* Background color from theme
* Flexbox layout (column, gap: 8px)

#### `SkillingQuantitySelector`

Reusable quantity selector with preset buttons and custom input.

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
export function SkillingQuantitySelector(props: {
  theme: Theme;
  showCustomInput: boolean;
  customQuantity: string;
  lastCustomQuantity: number;
  onCustomQuantityChange: (value: string) => void;
  onCustomSubmit: () => void;
  onCancelCustomInput: () => void;
  onPresetQuantity: (quantity: number) => void;
  allQuantity: number;
  onShowCustomInput: () => void;
}): JSX.Element
```

**Usage**:

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
const [showCustomInput, setShowCustomInput] = useState(false);
const [customQuantity, setCustomQuantity] = useState("");
const [lastCustomQuantity, setLastCustomQuantity] = useState(1);

<SkillingQuantitySelector
  theme={theme}
  showCustomInput={showCustomInput}
  customQuantity={customQuantity}
  lastCustomQuantity={lastCustomQuantity}
  onCustomQuantityChange={setCustomQuantity}
  onCustomSubmit={() => {
    const qty = parseInt(customQuantity, 10);
    if (qty > 0) {
      setLastCustomQuantity(qty);
      handleCraft(selectedRecipe, qty);
    }
    setShowCustomInput(false);
  }}
  onCancelCustomInput={() => setShowCustomInput(false)}
  onPresetQuantity={(qty) => handleCraft(selectedRecipe, qty)}
  allQuantity={getMaxCraftableQuantity(selectedRecipe)}
  onShowCustomInput={() => setShowCustomInput(true)}
/>
```

**Features**:

* Preset buttons: 1, 5, 10, All, X (custom)
* Custom input mode with validation
* Mobile-friendly touch targets (44px min)
* Keyboard support (Enter to submit, Escape to cancel)
* Auto-focus on custom input

#### Style Helpers

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
// Consistent selectable item styling
export function getSkillingSelectableStyle(
  theme: Theme,
  selected: boolean,
  disabled?: boolean,
): CSSProperties

// Consistent badge styling (level requirements, etc.)
export function getSkillingBadgeStyle(theme: Theme): CSSProperties
```

**Usage**:

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
<div style={getSkillingSelectableStyle(theme, isSelected, isDisabled)}>
  <img src={recipe.icon} alt={recipe.name} />
  <span>{recipe.name}</span>
  <span style={getSkillingBadgeStyle(theme)}>Lv {recipe.levelRequired}</span>
</div>
```

### Migration Guide

**Before** (duplicated styling):

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
// FletchingPanel.tsx
const selectableStyle: CSSProperties = {
  display: 'flex',
  alignItems: 'center',
  gap: '8px',
  padding: '8px',
  borderRadius: '4px',
  backgroundColor: selected ? theme.colors.primary : theme.colors.background,
  cursor: disabled ? 'not-allowed' : 'pointer',
  opacity: disabled ? 0.5 : 1,
  // ... 20 more lines
};

// CookingPanel.tsx
const selectableStyle: CSSProperties = {
  display: 'flex',
  alignItems: 'center',
  gap: '8px',
  padding: '8px',
  borderRadius: '4px',
  backgroundColor: selected ? theme.colors.primary : theme.colors.background,
  cursor: disabled ? 'not-allowed' : 'pointer',
  opacity: disabled ? 0.5 : 1,
  // ... 20 more lines (DUPLICATE!)
};
```

**After** (shared styling):

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
// FletchingPanel.tsx
import { getSkillingSelectableStyle } from './skilling/SkillingPanelShared';

<div style={getSkillingSelectableStyle(theme, selected, disabled)}>
  {/* Content */}
</div>

// CookingPanel.tsx
import { getSkillingSelectableStyle } from './skilling/SkillingPanelShared';

<div style={getSkillingSelectableStyle(theme, selected, disabled)}>
  {/* Content */}
</div>
```

## Dialogue System

### Architecture

**Before** (inline dialogue):

```
DialoguePanel.tsx - Renders dialogue inline in game window
├── No dedicated modal shell
├── No focus management
├── No live character portraits
└── Service handoffs leave orphaned dialogue
```

**After** (dedicated modal):

```
DialoguePopupShell.tsx - Dedicated modal shell
├── Focus trap (Escape to close)
├── ARIA attributes (accessibility)
├── Proper z-index layering
└── Service handoff closes dialogue

DialogueCharacterPortrait.tsx - Live 3D VRM portrait
├── Dedicated WebGPU viewport
├── Real-time character rendering
├── Smooth camera transitions
└── Lighting and post-processing
```

### DialoguePopupShell

Dedicated modal shell for NPC dialogue with focus management.

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
export function DialoguePopupShell(props: {
  visible: boolean;
  title: string;
  children: ReactNode;
  onClose: () => void;
  width?: number | string;
  maxWidth?: number | string;
  maxHeight?: number | string;
  contentStyle?: CSSProperties;
}): JSX.Element | null
```

**Features**:

* **Focus Trap**: Escape key closes dialogue
* **ARIA Attributes**: `role="dialog"`, `aria-modal="true"`, `aria-labelledby`
* **Backdrop**: Semi-transparent overlay with click-to-close
* **Z-Index**: Renders above game UI (z-index: 1000)
* **Responsive**: Mobile and desktop variants

**Usage**:

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
<DialoguePopupShell
  visible={isDialogueOpen}
  title={npcName}
  onClose={handleClose}
  width={800}
  maxWidth="90vw"
  maxHeight="80vh"
>
  <DialogueContent npcId={npcId} />
</DialoguePopupShell>
```

**Implementation**:

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
export function DialoguePopupShell(props: DialoguePopupShellProps) {
  const { visible, title, children, onClose, width = 600, maxWidth = '90vw', maxHeight = '80vh' } = props;

  // Focus trap
  useEffect(() => {
    if (!visible) return;

    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'Escape') {
        onClose();
      }
    };

    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [visible, onClose]);

  if (!visible) return null;

  return (
    <div className="dialogue-backdrop" onClick={onClose}>
      <div
        className="dialogue-modal"
        onClick={(e) => e.stopPropagation()}
        role="dialog"
        aria-modal="true"
        aria-labelledby="dialogue-title"
        style={{ width, maxWidth, maxHeight }}
      >
        <div className="dialogue-header">
          <h2 id="dialogue-title">{title}</h2>
          <button onClick={onClose} aria-label="Close dialogue">×</button>
        </div>
        <div className="dialogue-content" style={props.contentStyle}>
          {children}
        </div>
      </div>
    </div>
  );
}
```

### DialogueCharacterPortrait

Live 3D VRM portrait rendering in dialogue panels.

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
export const DialogueCharacterPortrait = React.memo(
  function DialogueCharacterPortrait(props: {
    world: ClientWorld;
    npcEntityId?: string;
    npcName: string;
    className?: string;
  }): JSX.Element
);
```

**Features**:

* **Live Rendering**: Real-time VRM character in dedicated viewport
* **Camera Framing**: Auto-frames character's head/shoulders
* **Lighting**: Dedicated lighting setup for portrait quality
* **Post-Processing**: Bloom and tone mapping for visual polish
* **Performance**: Separate render loop (30 FPS) to avoid blocking game

**Usage**:

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
<DialogueCharacterPortrait
  world={world}
  npcEntityId={npcId}
  npcName="Shopkeeper"
  className="dialogue-portrait"
/>
```

**Implementation**:

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
export const DialogueCharacterPortrait = React.memo(
  function DialogueCharacterPortrait(props: DialogueCharacterPortraitProps) {
    const canvasRef = useRef<HTMLCanvasElement>(null);
    const rendererRef = useRef<WebGPURenderer | null>(null);
    const sceneRef = useRef<Scene | null>(null);
    const cameraRef = useRef<PerspectiveCamera | null>(null);

    useEffect(() => {
      if (!canvasRef.current) return;

      // Create dedicated WebGPU renderer
      const renderer = new WebGPURenderer({ 
        canvas: canvasRef.current,
        antialias: true,
      });
      renderer.setSize(300, 400);
      renderer.setPixelRatio(window.devicePixelRatio);

      // Create scene with portrait lighting
      const scene = new Scene();
      const camera = new PerspectiveCamera(50, 300 / 400, 0.1, 100);
      camera.position.set(0, 1.6, 2); // Head/shoulders framing

      // Add lights
      const keyLight = new DirectionalLight(0xffffff, 1.5);
      keyLight.position.set(2, 3, 2);
      scene.add(keyLight);

      const fillLight = new DirectionalLight(0xffffff, 0.5);
      fillLight.position.set(-2, 1, -1);
      scene.add(fillLight);

      // Load NPC VRM
      const npcEntity = props.world.entities.get(props.npcEntityId);
      if (npcEntity?.avatar?.vrm) {
        const vrmClone = SkeletonUtils.clone(npcEntity.avatar.vrm.scene);
        scene.add(vrmClone);
      }

      // Render loop (30 FPS)
      let rafId: number;
      const animate = () => {
        renderer.render(scene, camera);
        rafId = requestAnimationFrame(animate);
      };
      animate();

      // Cleanup
      return () => {
        cancelAnimationFrame(rafId);
        renderer.dispose();
      };
    }, [props.npcEntityId]);

    return <canvas ref={canvasRef} className={props.className} />;
  }
);
```

### Service Handoff Fix

**Problem**: Opening bank/store/tanner from dialogue left the dialogue panel open with a terminal "Continue" step.

**Fix**: Emit `DIALOGUE_CLOSE` event when opening services.

**Implementation**:

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
// In NPCInteractionHandler.ts
private handleServiceOpen(npcId: string, serviceType: 'bank' | 'store' | 'tanner'): void {
  // Close dialogue before opening service
  this.world.emit(EventType.DIALOGUE_CLOSE, { npcId });

  // Open service panel
  switch (serviceType) {
    case 'bank':
      this.world.emit(EventType.UI_BANK_OPEN, { npcId });
      break;
    case 'store':
      this.world.emit(EventType.UI_STORE_OPEN, { npcId });
      break;
    case 'tanner':
      this.world.emit(EventType.UI_TANNING_OPEN, { npcId });
      break;
  }
}
```

## Shared Components Reference

### SkillingPanelBody

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
export function SkillingPanelBody(props: {
  theme: Theme;
  children?: ReactNode;
  emptyMessage?: string;
  intro?: string;
}): JSX.Element {
  return (
    <div className="skilling-panel-body">
      {props.intro && (
        <div className="skilling-intro">{props.intro}</div>
      )}
      {props.children ? (
        props.children
      ) : (
        <div className="skilling-empty">{props.emptyMessage || 'No items available.'}</div>
      )}
    </div>
  );
}
```

**Styling**:

```css theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
.skilling-panel-body {
  display: flex;
  flex-direction: column;
  gap: 12px;
  padding: 16px;
  overflow-y: auto;
}

.skilling-intro {
  font-size: 14px;
  color: rgba(255, 255, 255, 0.7);
  margin-bottom: 8px;
}

.skilling-empty {
  text-align: center;
  color: rgba(255, 255, 255, 0.5);
  padding: 32px;
}
```

### SkillingSection

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
export function SkillingSection(props: {
  theme: Theme;
  children: ReactNode;
  className?: string;
  style?: CSSProperties;
}): JSX.Element {
  return (
    <div 
      className={`skilling-section ${props.className || ''}`}
      style={{
        display: 'flex',
        flexDirection: 'column',
        gap: '8px',
        padding: '12px',
        borderRadius: '8px',
        backgroundColor: props.theme.colors.background,
        ...props.style,
      }}
    >
      {props.children}
    </div>
  );
}
```

### SkillingQuantitySelector

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
export function SkillingQuantitySelector(props: SkillingQuantitySelectorProps): JSX.Element {
  return (
    <div className="quantity-selector">
      {props.showCustomInput ? (
        // Custom input mode
        <div className="custom-input-mode">
          <input
            type="number"
            value={props.customQuantity}
            onChange={(e) => props.onCustomQuantityChange(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === 'Enter') props.onCustomSubmit();
              if (e.key === 'Escape') props.onCancelCustomInput();
            }}
            placeholder="Enter quantity"
            autoFocus
          />
          <button onClick={props.onCustomSubmit}>OK</button>
          <button onClick={props.onCancelCustomInput}>Cancel</button>
        </div>
      ) : (
        // Preset buttons mode
        <div className="preset-buttons">
          <button onClick={() => props.onPresetQuantity(1)}>1</button>
          <button onClick={() => props.onPresetQuantity(5)}>5</button>
          <button onClick={() => props.onPresetQuantity(10)}>10</button>
          <button onClick={() => props.onPresetQuantity(props.allQuantity)}>All</button>
          <button onClick={props.onShowCustomInput}>X</button>
        </div>
      )}
    </div>
  );
}
```

**Styling**:

```css theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
.quantity-selector {
  display: flex;
  gap: 8px;
  margin-top: 12px;
}

.preset-buttons {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
}

.preset-buttons button {
  min-width: 44px;  /* Mobile touch target */
  min-height: 44px;
  padding: 8px 16px;
  border-radius: 4px;
  background-color: rgba(255, 255, 255, 0.1);
  border: 1px solid rgba(255, 255, 255, 0.2);
  color: white;
  cursor: pointer;
  transition: background-color 0.2s;
}

.preset-buttons button:hover {
  background-color: rgba(255, 255, 255, 0.2);
}

.custom-input-mode {
  display: flex;
  gap: 8px;
  align-items: center;
}

.custom-input-mode input {
  flex: 1;
  padding: 8px;
  border-radius: 4px;
  background-color: rgba(0, 0, 0, 0.3);
  border: 1px solid rgba(255, 255, 255, 0.2);
  color: white;
}
```

### Style Helpers

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
export function getSkillingSelectableStyle(
  theme: Theme,
  selected: boolean,
  disabled?: boolean,
): CSSProperties {
  return {
    display: 'flex',
    alignItems: 'center',
    gap: '8px',
    padding: '8px',
    borderRadius: '4px',
    backgroundColor: selected 
      ? theme.colors.primary 
      : theme.colors.background,
    cursor: disabled ? 'not-allowed' : 'pointer',
    opacity: disabled ? 0.5 : 1,
    border: `1px solid ${selected ? theme.colors.primaryBorder : theme.colors.border}`,
    transition: 'background-color 0.2s, border-color 0.2s',
  };
}

export function getSkillingBadgeStyle(theme: Theme): CSSProperties {
  return {
    display: 'inline-block',
    padding: '2px 6px',
    borderRadius: '3px',
    backgroundColor: theme.colors.accent,
    color: theme.colors.accentText,
    fontSize: '12px',
    fontWeight: 'bold',
  };
}
```

## Integration Examples

### Fletching Panel

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
import { 
  SkillingPanelBody, 
  SkillingSection, 
  SkillingQuantitySelector,
  getSkillingSelectableStyle,
  getSkillingBadgeStyle,
} from './skilling/SkillingPanelShared';

export function FletchingPanel() {
  const theme = useTheme();
  const [selectedRecipe, setSelectedRecipe] = useState<FletchingRecipe | null>(null);
  const [showCustomInput, setShowCustomInput] = useState(false);
  const [customQuantity, setCustomQuantity] = useState("");

  const recipes = getAvailableFletchingRecipes();

  return (
    <SkillingPanelBody
      theme={theme}
      intro="Select logs and a bowstring to fletch arrows."
      emptyMessage="No fletching recipes available."
    >
      <SkillingSection theme={theme}>
        <h3>Available Recipes</h3>
        {recipes.map(recipe => (
          <div
            key={recipe.id}
            style={getSkillingSelectableStyle(theme, selectedRecipe?.id === recipe.id)}
            onClick={() => setSelectedRecipe(recipe)}
          >
            <img src={recipe.icon} alt={recipe.name} />
            <span>{recipe.name}</span>
            <span style={getSkillingBadgeStyle(theme)}>Lv {recipe.levelRequired}</span>
          </div>
        ))}
      </SkillingSection>

      {selectedRecipe && (
        <SkillingQuantitySelector
          theme={theme}
          showCustomInput={showCustomInput}
          customQuantity={customQuantity}
          lastCustomQuantity={1}
          onCustomQuantityChange={setCustomQuantity}
          onCustomSubmit={() => handleFletch(selectedRecipe, parseInt(customQuantity))}
          onCancelCustomInput={() => setShowCustomInput(false)}
          onPresetQuantity={(qty) => handleFletch(selectedRecipe, qty)}
          allQuantity={getMaxFletchableQuantity(selectedRecipe)}
          onShowCustomInput={() => setShowCustomInput(true)}
        />
      )}
    </SkillingPanelBody>
  );
}
```

### Dialogue Panel with Portrait

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
import { DialoguePopupShell } from './dialogue/DialoguePopupShell';
import { DialogueCharacterPortrait } from './dialogue/DialogueCharacterPortrait';

export function DialoguePanel() {
  const world = useWorld();
  const [isOpen, setIsOpen] = useState(false);
  const [npcId, setNpcId] = useState<string | null>(null);
  const [npcName, setNpcName] = useState("");
  const [dialogueText, setDialogueText] = useState("");
  const [responses, setResponses] = useState<DialogueResponse[]>([]);

  return (
    <DialoguePopupShell
      visible={isOpen}
      title={npcName}
      onClose={() => setIsOpen(false)}
      width={800}
      maxWidth="90vw"
      contentStyle={{ display: 'flex', gap: '16px' }}
    >
      {/* Left: Character portrait */}
      <DialogueCharacterPortrait
        world={world}
        npcEntityId={npcId}
        npcName={npcName}
        className="dialogue-portrait"
      />

      {/* Right: Dialogue text and responses */}
      <div className="dialogue-text-area">
        <p>{dialogueText}</p>
        <div className="dialogue-responses">
          {responses.map((response, index) => (
            <button
              key={index}
              onClick={() => handleResponse(response)}
              className="dialogue-response-button"
            >
              {response.text}
            </button>
          ))}
        </div>
      </div>
    </DialoguePopupShell>
  );
}
```

## Testing

### Unit Tests

**SkillingPanelShared.test.tsx**:

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
import { render, screen, fireEvent } from '@testing-library/react';
import { SkillingQuantitySelector } from './SkillingPanelShared';

describe('SkillingQuantitySelector', () => {
  it('renders preset buttons', () => {
    render(<SkillingQuantitySelector {...defaultProps} />);
    expect(screen.getByText('1')).toBeInTheDocument();
    expect(screen.getByText('5')).toBeInTheDocument();
    expect(screen.getByText('10')).toBeInTheDocument();
    expect(screen.getByText('All')).toBeInTheDocument();
    expect(screen.getByText('X')).toBeInTheDocument();
  });

  it('calls onPresetQuantity when preset button clicked', () => {
    const onPresetQuantity = vi.fn();
    render(<SkillingQuantitySelector {...defaultProps} onPresetQuantity={onPresetQuantity} />);
    
    fireEvent.click(screen.getByText('5'));
    expect(onPresetQuantity).toHaveBeenCalledWith(5);
  });

  it('shows custom input when X clicked', () => {
    const onShowCustomInput = vi.fn();
    render(<SkillingQuantitySelector {...defaultProps} onShowCustomInput={onShowCustomInput} />);
    
    fireEvent.click(screen.getByText('X'));
    expect(onShowCustomInput).toHaveBeenCalled();
  });

  it('submits custom quantity on Enter key', () => {
    const onCustomSubmit = vi.fn();
    render(
      <SkillingQuantitySelector 
        {...defaultProps} 
        showCustomInput={true}
        customQuantity="25"
        onCustomSubmit={onCustomSubmit}
      />
    );
    
    const input = screen.getByPlaceholderText('Enter quantity');
    fireEvent.keyDown(input, { key: 'Enter' });
    expect(onCustomSubmit).toHaveBeenCalled();
  });
});
```

### Integration Tests

**dialogue-handoff.spec.ts**:

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
import { test, expect } from '@playwright/test';

test('dialogue closes when opening bank', async ({ page }) => {
  await page.goto('http://localhost:3333');
  await loginAsTestUser(page);

  // 1. Open dialogue with banker NPC
  await page.click('[data-entity-id="banker_npc"]');
  await expect(page.locator('.dialogue-modal')).toBeVisible();

  // 2. Click "Open Bank" response
  await page.click('text=Open Bank');

  // 3. Verify dialogue closed
  await expect(page.locator('.dialogue-modal')).not.toBeVisible();

  // 4. Verify bank panel opened
  await expect(page.locator('.bank-panel')).toBeVisible();
});

test('dialogue portrait renders NPC character', async ({ page }) => {
  await page.goto('http://localhost:3333');
  await loginAsTestUser(page);

  // 1. Open dialogue with NPC
  await page.click('[data-entity-id="shopkeeper_npc"]');

  // 2. Verify portrait canvas exists
  const portrait = page.locator('.dialogue-portrait canvas');
  await expect(portrait).toBeVisible();

  // 3. Verify canvas has content (not blank)
  const canvas = await portrait.elementHandle();
  const screenshot = await canvas.screenshot();
  expect(screenshot.length).toBeGreaterThan(1000); // Not empty
});
```

## Troubleshooting

### Issue: Skilling panel shows duplicate styling

**Diagnosis**: Check if panel is using shared components.

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
// ❌ BAD (duplicated styling)
const selectableStyle: CSSProperties = {
  display: 'flex',
  alignItems: 'center',
  // ... 20 lines of styling
};

// ✅ GOOD (shared styling)
import { getSkillingSelectableStyle } from './skilling/SkillingPanelShared';
const selectableStyle = getSkillingSelectableStyle(theme, selected, disabled);
```

### Issue: Dialogue doesn't close when opening bank

**Diagnosis**:

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
// Check if DIALOGUE_CLOSE event is emitted
world.on(EventType.DIALOGUE_CLOSE, (data) => {
  console.log('Dialogue close event:', data);
});
```

**Causes**:

1. `DIALOGUE_CLOSE` event not emitted before service open
2. Dialogue panel not listening to `DIALOGUE_CLOSE` event
3. Service handoff code missing

**Fix**:

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
// Emit DIALOGUE_CLOSE before opening service
this.world.emit(EventType.DIALOGUE_CLOSE, { npcId });
this.world.emit(EventType.UI_BANK_OPEN, { npcId });
```

### Issue: Portrait canvas is blank

**Diagnosis**:

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
// Check if NPC entity exists
const npcEntity = world.entities.get(npcId);
console.log('NPC entity:', npcEntity);

// Check if VRM is loaded
console.log('VRM loaded:', !!npcEntity?.avatar?.vrm);

// Check WebGPU renderer
console.log('Renderer initialized:', !!rendererRef.current);
```

**Causes**:

1. NPC entity not found
2. VRM not loaded
3. WebGPU renderer failed to initialize
4. Canvas not mounted

**Fix**:

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
// Add error handling
useEffect(() => {
  if (!canvasRef.current) {
    console.error('Canvas ref not available');
    return;
  }

  const npcEntity = props.world.entities.get(props.npcEntityId);
  if (!npcEntity?.avatar?.vrm) {
    console.error('NPC VRM not loaded:', props.npcEntityId);
    return;
  }

  // ... renderer setup
}, [props.npcEntityId]);
```

### Issue: Quantity selector doesn't validate input

**Diagnosis**:

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
// Check custom quantity value
console.log('Custom quantity:', customQuantity);

// Check if validation is applied
const qty = parseInt(customQuantity, 10);
console.log('Parsed quantity:', qty, 'Valid:', qty > 0);
```

**Causes**:

1. No validation on custom input
2. Negative/zero quantities allowed
3. Non-numeric input not rejected

**Fix**:

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
const handleCustomSubmit = () => {
  const qty = parseInt(customQuantity, 10);
  
  // Validate quantity
  if (!Number.isFinite(qty) || qty <= 0) {
    showMessage("Please enter a valid quantity.");
    return;
  }

  if (qty > maxQuantity) {
    showMessage(`Maximum quantity is ${maxQuantity}.`);
    return;
  }

  // Submit valid quantity
  handleCraft(selectedRecipe, qty);
  setShowCustomInput(false);
};
```

## Performance Considerations

### Portrait Rendering

**Optimization**:

* Separate render loop (30 FPS) to avoid blocking game (60 FPS)
* Dedicated WebGPU renderer (doesn't share with main game renderer)
* VRM clone (doesn't affect main game character)
* Dispose on unmount (prevents memory leaks)

**Memory**:

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
// Cleanup on unmount
useEffect(() => {
  return () => {
    if (rendererRef.current) {
      rendererRef.current.dispose();
      rendererRef.current = null;
    }
    if (sceneRef.current) {
      sceneRef.current.clear();
      sceneRef.current = null;
    }
  };
}, []);
```

### Shared Component Memoization

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
// Prevent unnecessary re-renders
export const DialogueCharacterPortrait = React.memo(
  function DialogueCharacterPortrait(props: DialogueCharacterPortraitProps) {
    // ... implementation
  }
);

export const SkillingQuantitySelector = React.memo(
  function SkillingQuantitySelector(props: SkillingQuantitySelectorProps) {
    // ... implementation
  }
);
```

## Related Documentation

* [SkillingPanelShared.tsx](../packages/client/src/game/panels/skilling/SkillingPanelShared.tsx) - Shared components
* [DialoguePopupShell.tsx](../packages/client/src/game/panels/dialogue/DialoguePopupShell.tsx) - Modal shell
* [DialogueCharacterPortrait.tsx](../packages/client/src/game/panels/dialogue/DialogueCharacterPortrait.tsx) - Portrait renderer
* [FletchingPanel.tsx](../packages/client/src/game/panels/FletchingPanel.tsx) - Example usage
* [NPCInteractionHandler.ts](../packages/shared/src/systems/client/interaction/handlers/NPCInteractionHandler.ts) - Service handoff

## Changelog

### March 26, 2026 (PR #1093)

* Extracted shared skilling panel components
* Unified layouts for all crafting/processing panels
* Added reusable quantity selector with presets
* Redesigned NPC dialogue with dedicated modal shell
* Added live 3D VRM portrait rendering
* Fixed service handoff (bank/store/tanner closes dialogue)
* Eliminated \~500 lines of duplicated styling
* 15 files changed, 1,623 additions, 1,265 deletions
