npm package: @superbuilders/incept-renderer
Description: QTI 3.0 Assessment Renderer - Parse, validate, and render QTI XML with customizable themes
Latest version: 0.1.28

# @superbuilders/incept-renderer

A secure, server-driven QTI 3.0 assessment renderer for React/Next.js applications. Renders interactive questions with built-in validation while keeping correct answers secure on the server.

## Table of Contents

- [Installation](#installation)
- [Core Concepts](#core-concepts)
- [Quick Start](#quick-start)
- [Architecture](#architecture)
- [API Reference](#api-reference)
  - [Server Functions](#server-functions)
  - [Client Components](#client-components)
  - [Types](#types)
- [Rendering Stimuli](#rendering-stimuli)
- [Theming](#theming)
- [Complete Examples](#complete-examples)
- [Supported Interactions](#supported-interactions)
- [FAQ](#faq)

---

## Installation

```bash
npm install @superbuilders/incept-renderer
# or
bun add @superbuilders/incept-renderer
# or
yarn add @superbuilders/incept-renderer
```

### Peer Dependencies

This package requires:
- `react` >= 18.0.0
- `react-dom` >= 18.0.0

For full functionality with Tailwind CSS theming, ensure your app has Tailwind configured.

---

## Core Concepts

### Security Model

**Correct answers NEVER leave the server.** This package uses a secure architecture where:

1. **Server parses XML** → Extracts only display data (questions, choices, prompts)
2. **Client renders UI** → Users interact with questions
3. **Server validates** → Checks answers against the original XML
4. **Client shows feedback** → Displays correctness and feedback messages

```
┌─────────────────────────────────────────────────────────────────┐
│  XML Source (your database/API)                                 │
│  Contains: questions, choices, AND correct answers              │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  SERVER: buildDisplayModel(xml)                                 │
│  Extracts: questions, choices, prompts                          │
│  EXCLUDES: correct answers, response processing rules           │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  CLIENT: QTIRenderer                                            │
│  Shows: questions, choices, user can select answers             │
│  Cannot see: correct answers (never sent to browser)            │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼ User clicks "Check"
┌─────────────────────────────────────────────────────────────────┐
│  SERVER: validateResponsesSecure(xml, userResponses)            │
│  Compares: user answers against correct answers                 │
│  Returns: isCorrect, feedback HTML, per-choice correctness      │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  CLIENT: Shows feedback                                         │
│  Green/red highlights, feedback messages, scores                │
└─────────────────────────────────────────────────────────────────┘
```

---

## Quick Start

### 1. Create the Server Component (page.tsx)

```tsx
// app/quiz/[id]/page.tsx
import * as React from "react"
import { buildDisplayModel, validateResponsesSecure } from "@superbuilders/incept-renderer/actions"
import { QuizClient } from "./client"

export default function QuizPage({ params }: { params: Promise<{ id: string }> }) {
  // Fetch your QTI XML and build the display model
  const displayPromise = params.then(async (p) => {
    const xml = await getQtiXmlFromYourDatabase(p.id)
    return buildDisplayModel(xml)
  })

  // Create a server action for validation
  async function validateAnswers(responses: Record<string, string | string[]>) {
    "use server"
    const { id } = await params
    const xml = await getQtiXmlFromYourDatabase(id)
    return validateResponsesSecure(xml, responses)
  }

  return (
    <React.Suspense fallback={<div>Loading question...</div>}>
      <QuizClient displayPromise={displayPromise} onValidate={validateAnswers} />
    </React.Suspense>
  )
}

// Your function to get QTI XML from wherever you store it
async function getQtiXmlFromYourDatabase(id: string): Promise<string> {
  // Examples:
  // - Database: await db.query.questions.findFirst({ where: eq(id, questionId) })
  // - REST API: await fetch(`https://api.example.com/questions/${id}`).then(r => r.text())
  // - File system: await fs.readFile(`./questions/${id}.xml`, 'utf-8')
  throw new Error("Implement this function")
}
```

### 2. Create the Client Component (client.tsx)

```tsx
// app/quiz/[id]/client.tsx
"use client"

import * as React from "react"
import { QTIRenderer } from "@superbuilders/incept-renderer"
import type { DisplayItem, FormShape, ValidateResult } from "@superbuilders/incept-renderer"

interface QuizClientProps {
  displayPromise: Promise<{ item: DisplayItem; shape: FormShape; itemKey: string }>
  onValidate: (responses: Record<string, string | string[]>) => Promise<ValidateResult>
}

export function QuizClient({ displayPromise, onValidate }: QuizClientProps) {
  // Unwrap the promise from the server
  const { item, shape, itemKey } = React.use(displayPromise)

  // State management
  const [responses, setResponses] = React.useState<Record<string, string | string[]>>({})
  const [result, setResult] = React.useState<ValidateResult | undefined>()
  const [showFeedback, setShowFeedback] = React.useState(false)
  const [isChecking, setIsChecking] = React.useState(false)

  // Handle user selecting/changing answers
  const handleResponseChange = (responseId: string, value: string | string[]) => {
    setResponses(prev => ({ ...prev, [responseId]: value }))
  }

  // Handle "Check Answer" click
  const handleCheck = async () => {
    setIsChecking(true)
    const validation = await onValidate(responses)
    setResult(validation)
    setShowFeedback(true)
    setIsChecking(false)
  }

  // Handle "Try Again" click
  const handleTryAgain = () => {
    setShowFeedback(false)
    setResult(undefined)
  }

  return (
    <div className="max-w-2xl mx-auto p-4">
      {/* The QTI Renderer */}
      <QTIRenderer
        item={item}
        responses={responses}
        onResponseChange={handleResponseChange}
        showFeedback={showFeedback}
        disabled={showFeedback}
        overallFeedback={result ? {
          isCorrect: result.overallCorrect,
          messageHtml: result.feedbackHtml
        } : undefined}
        choiceCorrectness={result?.selectedChoicesByResponse}
        responseFeedback={result?.perResponse}
      />

      {/* Your app's buttons */}
      <div className="mt-6 flex gap-4">
        {!showFeedback ? (
          <button
            onClick={handleCheck}
            disabled={isChecking || Object.keys(responses).length === 0}
            className="bg-green-500 text-white px-6 py-3 rounded-lg font-bold disabled:opacity-50"
          >
            {isChecking ? "Checking..." : "Check Answer"}
          </button>
        ) : result?.overallCorrect ? (
          <button
            onClick={() => window.location.href = "/next-question"}
            className="bg-green-500 text-white px-6 py-3 rounded-lg font-bold"
          >
            Continue →
          </button>
        ) : (
          <button
            onClick={handleTryAgain}
            className="bg-blue-500 text-white px-6 py-3 rounded-lg font-bold"
          >
            Try Again
          </button>
        )}
      </div>
    </div>
  )
}
```

---

## Architecture

### File Structure Pattern

```
app/quiz/[id]/
├── page.tsx      # Server Component: fetches XML, creates validation action
└── client.tsx    # Client Component: renders QTIRenderer, manages state
```

### Data Flow

```
1. User visits /quiz/123
       │
       ▼
2. page.tsx (Server)
   - Fetches QTI XML from your data source
   - Calls buildDisplayModel(xml)
   - Passes display data to client
       │
       ▼
3. client.tsx (Client)
   - Renders QTIRenderer with display data
   - User interacts with choices
       │
       ▼
4. User clicks "Check Answer"
   - Client calls onValidate(responses)
   - Server action validates against original XML
   - Returns { overallCorrect, feedbackHtml, selectedChoicesByResponse }
       │
       ▼
5. Client shows feedback
   - QTIRenderer shows green/red highlights
   - Feedback message displayed
```

---

## API Reference

### Server Functions

Import from `@superbuilders/incept-renderer/actions`:

#### `buildDisplayModel(xml: string)`

Parses QTI XML and extracts display-safe data.

```tsx
import { buildDisplayModel } from "@superbuilders/incept-renderer/actions"

const { item, shape, itemKey } = await buildDisplayModel(qtiXmlString)
```

**Parameters:**
- `xml` (string) - Raw QTI 3.0 XML string

**Returns:**
```tsx
{
  itemKey: string          // Unique identifier for caching
  item: DisplayItem        // Parsed question data for rendering
  shape: FormShape         // Schema describing expected response structure
}
```

#### `validateResponsesSecure(xml: string, responses: Record<string, string | string[]>)`

Validates user responses against the QTI XML.

```tsx
import { validateResponsesSecure } from "@superbuilders/incept-renderer/actions"

const result = await validateResponsesSecure(qtiXmlString, {
  RESPONSE: "choice_A"
})
```

**Parameters:**
- `xml` (string) - Raw QTI 3.0 XML string (same as used for buildDisplayModel)
- `responses` (Record<string, string | string[]>) - User's selected answers

**Returns:**
```tsx
{
  overallCorrect: boolean   // true if all responses are correct
  feedbackHtml: string      // Combined HTML feedback message
  selectedChoicesByResponse: Record<string, Array<{ id: string; isCorrect: boolean }>>
  perResponse: Record<string, { isCorrect: boolean; messageHtml?: string }>
}
```

#### `parseAssessmentStimulusXml(xml: string)`

Parses QTI Assessment Stimulus XML (reading materials, passages).

```tsx
import { parseAssessmentStimulusXml } from "@superbuilders/incept-renderer"

const stimulus = parseAssessmentStimulusXml(stimulusXmlString)
```

**Parameters:**
- `xml` (string) - Raw QTI 3.0 Assessment Stimulus XML string

**Returns:**
```tsx
{
  identifier: string   // Unique identifier from the XML
  title: string        // Human-readable title
  xmlLang: string      // Language code (e.g., "en")
  bodyHtml: string     // Sanitized HTML content ready for rendering
}
```

---

### Client Components

Import from `@superbuilders/incept-renderer`:

#### `<QTIRenderer />`

The main component for rendering QTI questions.

```tsx
import { QTIRenderer } from "@superbuilders/incept-renderer"

<QTIRenderer
  item={displayItem}
  responses={responses}
  onResponseChange={handleChange}
  showFeedback={showFeedback}
  disabled={isDisabled}
  overallFeedback={feedbackData}
  choiceCorrectness={correctnessMap}
  responseFeedback={perResponseFeedback}
  theme="duolingo"
/>
```

**Props:**

| Prop | Type | Required | Description |
|------|------|----------|-------------|
| `item` | `DisplayItem` | ✅ | Parsed question data from `buildDisplayModel` |
| `responses` | `Record<string, string \| string[]>` | ✅ | Current user responses |
| `onResponseChange` | `(id: string, value: string \| string[]) => void` | ✅ | Called when user selects/changes an answer |
| `showFeedback` | `boolean` | ❌ | Whether to show correct/incorrect feedback |
| `disabled` | `boolean` | ❌ | Disable all interactions |
| `overallFeedback` | `{ isCorrect: boolean; messageHtml?: string }` | ❌ | Overall feedback after validation |
| `choiceCorrectness` | `Record<string, Array<{ id: string; isCorrect: boolean }>>` | ❌ | Per-choice correctness for highlighting |
| `responseFeedback` | `Record<string, { isCorrect: boolean; messageHtml?: string }>` | ❌ | Per-response feedback messages |
| `theme` | `"duolingo" \| "neobrutalist" \| string` | ❌ | Visual theme (default: `"duolingo"`) |

#### `<QTIStimulusRenderer />`

Renders QTI Assessment Stimuli (reading materials, passages).

```tsx
import { QTIStimulusRenderer } from "@superbuilders/incept-renderer"

<QTIStimulusRenderer
  stimulus={parsedStimulus}
  className="my-8"
/>
```

**Props:**

| Prop | Type | Required | Description |
|------|------|----------|-------------|
| `stimulus` | `AssessmentStimulus` | ✅ | Parsed stimulus from `parseAssessmentStimulusXml` |
| `className` | `string` | ❌ | Additional CSS classes for the container |

---

### Types

Import from `@superbuilders/incept-renderer`:

```tsx
import type {
  // Assessment Items (questions)
  DisplayItem,
  DisplayBlock,
  DisplayChoice,
  DisplayChoiceInteraction,
  FormShape,
  ValidateResult,
  // Assessment Stimuli (reading materials)
  AssessmentStimulus
} from "@superbuilders/incept-renderer"
```

#### `DisplayItem`

```tsx
interface DisplayItem {
  identifier: string
  title: string
  blocks: DisplayBlock[]
}
```

#### `DisplayBlock`

```tsx
type DisplayBlock =
  | { type: "html"; html: string }
  | { type: "interaction"; interaction: DisplayChoiceInteraction }
```

#### `DisplayChoiceInteraction`

```tsx
interface DisplayChoiceInteraction {
  responseIdentifier: string
  type: "choice"
  maxChoices: number
  prompt?: string
  choices: DisplayChoice[]
}
```

#### `DisplayChoice`

```tsx
interface DisplayChoice {
  identifier: string
  contentHtml: string
}
```

#### `FormShape`

```tsx
interface FormShape {
  responses: Record<string, {
    cardinality: "single" | "multiple"
    baseType: string
  }>
}
```

#### `ValidateResult`

```tsx
interface ValidateResult {
  overallCorrect: boolean
  feedbackHtml: string
  selectedChoicesByResponse: Record<string, Array<{ id: string; isCorrect: boolean }>>
  perResponse: Record<string, { isCorrect: boolean; messageHtml?: string }>
}
```

#### `AssessmentStimulus`

```tsx
interface AssessmentStimulus {
  identifier: string   // Unique identifier from the XML
  title: string        // Human-readable title  
  xmlLang: string      // Language code (e.g., "en", "es")
  bodyHtml: string     // Sanitized HTML content
}
```

---

## Rendering Stimuli

In addition to assessment items (questions), the package supports rendering **Assessment Stimuli** — reading materials, passages, or articles that provide context for questions.

### What are Stimuli?

QTI Assessment Stimuli (`qti-assessment-stimulus`) are standalone content blocks that contain:
- Reading passages
- Articles with images
- Reference materials
- Any HTML content that accompanies questions

Unlike assessment items, stimuli have no interactions or correct answers — they're purely display content.

### Parsing Stimulus XML

Use `parseAssessmentStimulusXml` to parse stimulus XML:

```tsx
import { parseAssessmentStimulusXml } from "@superbuilders/incept-renderer"

const stimulusXml = `<?xml version="1.0" encoding="UTF-8"?>
<qti-assessment-stimulus 
  xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0"
  identifier="stimulus-1" 
  xml:lang="en" 
  title="Biodiversity and Ecosystem Health">
  <qti-stimulus-body>
    <h2>Biodiversity and ecosystem health</h2>
    <p><strong>Biodiversity</strong> is the variety of species in an ecosystem.</p>
    <figure>
      <img src="coral-reef.jpg" alt="A coral reef" />
      <figcaption>Coral reef ecosystems have high biodiversity.</figcaption>
    </figure>
  </qti-stimulus-body>
</qti-assessment-stimulus>`

const stimulus = parseAssessmentStimulusXml(stimulusXml)
// Returns: { identifier, title, xmlLang, bodyHtml }
```

### Rendering Stimuli

Use the `QTIStimulusRenderer` component to render parsed stimuli:

```tsx
import { QTIStimulusRenderer, parseAssessmentStimulusXml } from "@superbuilders/incept-renderer"

function ReadingPassage({ xml }: { xml: string }) {
  const stimulus = parseAssessmentStimulusXml(xml)
  
  return (
    <QTIStimulusRenderer 
      stimulus={stimulus} 
      className="my-8"
    />
  )
}
```

### Server Action Pattern

For Next.js apps, create a server action to parse stimuli:

```tsx
// lib/qti-actions.ts
"use server"

import { parseAssessmentStimulusXml } from "@superbuilders/incept-renderer"

export async function parseStimulus(xml: string) {
  return parseAssessmentStimulusXml(xml)
}
```

```tsx
// app/reading/[id]/page.tsx
import { parseStimulus } from "@/lib/qti-actions"
import { QTIStimulusRenderer } from "@superbuilders/incept-renderer"

export default async function ReadingPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const xml = await fetchStimulusXml(id) // Your data fetching
  const stimulus = await parseStimulus(xml)
  
  return <QTIStimulusRenderer stimulus={stimulus} />
}
```

### Stimulus with Questions

A common pattern is showing a stimulus alongside related questions:

```tsx
import { QTIStimulusRenderer, QTIRenderer } from "@superbuilders/incept-renderer"

function QuestionWithPassage({ stimulus, item, ...props }) {
  return (
    <div className="grid grid-cols-2 gap-8">
      {/* Reading passage on the left */}
      <div className="overflow-y-auto max-h-[80vh]">
        <QTIStimulusRenderer stimulus={stimulus} />
      </div>
      
      {/* Question on the right */}
      <div>
        <QTIRenderer item={item} {...props} />
      </div>
    </div>
  )
}
```

### AssessmentStimulus Type

```tsx
interface AssessmentStimulus {
  identifier: string    // Unique ID from the XML
  title: string         // Human-readable title
  xmlLang: string       // Language code (e.g., "en")
  bodyHtml: string      // Sanitized HTML content
}
```

### Supported Content

The stimulus renderer supports:
- **Headings** (`h1`–`h6`)
- **Text formatting** (`p`, `strong`, `em`, `b`, `i`, `u`, `sub`, `sup`)
- **Lists** (`ul`, `ol`, `li`)
- **Images** (`img`, `figure`, `figcaption`)
- **Tables** (`table`, `thead`, `tbody`, `tr`, `th`, `td`)
- **Links** (`a`)
- **MathML** (mathematical expressions)
- **Collapsible sections** (`details`, `summary`)
- **Semantic elements** (`article`, `section`, `blockquote`, `cite`)

All content is sanitized to prevent XSS attacks while preserving safe HTML structure.

---

## Theming

The package includes two built-in themes and supports custom themes via CSS variables. **You are not required to use the built-in styles** — you can fully customize the appearance to match your application's design system.

### Required CSS Imports

Import the base theme CSS in your app's root layout or global styles:

```tsx
// In your layout.tsx or globals.css
import "@superbuilders/incept-renderer/styles/themes.css"
```

### Built-in Themes

#### Duolingo (Default)

Clean, friendly design inspired by Duolingo's learning interface with a vibrant color palette.

**For the complete Duolingo experience**, import the Duolingo theme CSS which includes:
- Full Duolingo color palette (owl green, macaw blue, cardinal red, etc.)
- Rounded square choice indicators (A, B, C, D)
- Filled checkboxes with inset gap effect
- HTML content styling (lists, typography)
- Dark mode support

```tsx
// In your layout.tsx or globals.css
import "@superbuilders/incept-renderer/styles/themes.css"
import "@superbuilders/incept-renderer/styles/duolingo.css"  // Full Duolingo styling
```

```tsx
<QTIRenderer theme="duolingo" ... />
```

#### Neobrutalist

Bold, high-contrast design with thick borders and sharp shadows.

```tsx
<QTIRenderer theme="neobrutalist" ... />
```

### Custom Themes

Create your own theme by defining CSS variables for `[data-qti-theme="your-theme-name"]` in your stylesheet, then pass that theme name to the `QTIRenderer` component.

```tsx
<QTIRenderer theme="my-custom-theme" ... />
```

### CSS Variables Reference

Below is the **complete list of CSS variables** you can override when creating a custom theme. All variables are optional — any variable you don't define will fall back to the default value.

#### Container Variables

Controls the main question container/card appearance.

| Variable | Description | Default |
|----------|-------------|---------|
| `--qti-container-bg` | Background color of the container | `var(--background, #ffffff)` |
| `--qti-container-border` | Border color of the container | `var(--border, #e5e5e5)` |
| `--qti-container-border-width` | Border thickness | `2px` |
| `--qti-container-radius` | Border radius (rounded corners) | `var(--radius, 0.5rem)` |
| `--qti-container-shadow` | Box shadow | `none` |

#### Typography Variables

Controls fonts and text styling throughout the renderer.

| Variable | Description | Default |
|----------|-------------|---------|
| `--qti-font-family` | Font family for all QTI text | `inherit` |
| `--qti-font-weight` | Base font weight | `500` |
| `--qti-tracking` | Letter spacing | `normal` |

#### Choice Variables

Controls the appearance of answer choices (radio buttons, checkboxes).

| Variable | Description | Default |
|----------|-------------|---------|
| `--qti-choice-bg` | Default background of choices | `var(--background, #ffffff)` |
| `--qti-choice-border` | Default border color of choices | `var(--accent, #f4f4f5)` |
| `--qti-choice-hover-bg` | Background on hover | `var(--muted, #f4f4f5)` |
| `--qti-choice-selected-bg` | Background when selected | `oklch(0.9 0.1 220 / 0.3)` |
| `--qti-choice-selected-border` | Border when selected | `oklch(0.6 0.2 250)` |
| `--qti-choice-correct-bg` | Background for correct answers | `oklch(0.9 0.15 140 / 0.3)` |
| `--qti-choice-correct-border` | Border for correct answers | `#22c55e` |
| `--qti-choice-incorrect-bg` | Background for incorrect answers | `oklch(0.9 0.1 25 / 0.3)` |
| `--qti-choice-incorrect-border` | Border for incorrect answers | `#ef4444` |

#### Button Variables

Controls the appearance of buttons (if using the QTI button classes).

| Variable | Description | Default |
|----------|-------------|---------|
| `--qti-button-bg` | Button background color | `var(--primary, #18181b)` |
| `--qti-button-text` | Button text color | `var(--primary-foreground, #ffffff)` |
| `--qti-button-border` | Button border color | `var(--primary, #18181b)` |
| `--qti-button-shadow` | Button box shadow | `none` |
| `--qti-button-radius` | Button border radius | `var(--radius, 0.5rem)` |
| `--qti-button-font-weight` | Button font weight | `700` |

#### Feedback Variables

Controls the appearance of feedback messages shown after validation.

| Variable | Description | Default |
|----------|-------------|---------|
| `--qti-feedback-bg` | Feedback container background | `var(--background, #ffffff)` |
| `--qti-feedback-border` | Feedback container border | `var(--border, #e5e5e5)` |
| `--qti-feedback-correct-text` | Text color for correct feedback | `#22c55e` |
| `--qti-feedback-correct-icon-bg` | Icon background for correct | `#22c55e` |
| `--qti-feedback-incorrect-text` | Text color for incorrect feedback | `#ef4444` |
| `--qti-feedback-incorrect-icon-bg` | Icon background for incorrect | `#ef4444` |

#### Input Variables (Text Entry, Textarea, Dropdowns)

Controls the appearance of text input fields, textareas, and select/dropdown elements.

| Variable | Description | Default |
|----------|-------------|---------|
| `--qti-input-bg` | Input background color | `var(--background, #ffffff)` |
| `--qti-input-border` | Input border color | `var(--border, #e5e5e5)` |
| `--qti-input-border-width` | Input border thickness | `2px` |
| `--qti-input-radius` | Input border radius | `var(--radius, 0.5rem)` |
| `--qti-input-shadow` | Input box shadow | `none` |
| `--qti-input-focus-border` | Border color when focused | `var(--ring, #2563eb)` |
| `--qti-input-focus-shadow` | Box shadow when focused | `0 0 0 2px rgba(37, 99, 235, 0.2)` |
| `--qti-input-correct-border` | Border for correct text answers | `#22c55e` |
| `--qti-input-incorrect-border` | Border for incorrect text answers | `#ef4444` |

#### Choice Indicator Variables (Radio/Checkbox Circles)

Controls the appearance of the radio button and checkbox indicators.

| Variable | Description | Default |
|----------|-------------|---------|
| `--qti-indicator-size` | Size of the indicator | `1.5rem` |
| `--qti-indicator-bg` | Default background | `var(--background, #ffffff)` |
| `--qti-indicator-border` | Default border color | `var(--border, #d4d4d8)` |
| `--qti-indicator-border-width` | Border thickness | `2px` |
| `--qti-indicator-radius` | Border radius (use `9999px` for circles, `0` for squares) | `9999px` |
| `--qti-indicator-checked-bg` | Background when checked | `var(--primary, #18181b)` |
| `--qti-indicator-checked-border` | Border when checked | `var(--primary, #18181b)` |
| `--qti-indicator-checked-text` | Checkmark/dot color | `var(--primary-foreground, #ffffff)` |

#### Progress Bar Variables

Controls the appearance of progress indicators.

| Variable | Description | Default |
|----------|-------------|---------|
| `--qti-progress-bg` | Progress bar background (empty part) | `var(--muted, #f4f4f5)` |
| `--qti-progress-fill` | Progress bar fill color | `var(--primary, #18181b)` |
| `--qti-progress-height` | Height of the progress bar | `0.5rem` |
| `--qti-progress-radius` | Border radius | `var(--radius, 0.5rem)` |
| `--qti-progress-border` | Border color | `transparent` |
| `--qti-progress-border-width` | Border thickness | `0px` |

#### Content Variables (Question Text, Prompts)

Controls the appearance of HTML content (question text, prompts, instructions).

| Variable | Description | Default |
|----------|-------------|---------|
| `--qti-content-font-size` | Font size for content | `1.125rem` |
| `--qti-content-line-height` | Line height for content | `1.625` |
| `--qti-content-font-weight` | Font weight for content | `500` |

#### Image Grid Variables

Controls the layout of image-based choice interactions.

| Variable | Description | Default |
|----------|-------------|---------|
| `--qti-grid-gap` | Gap between grid items | `1rem` |
| `--qti-image-card-min-height` | Minimum height of image cards | `180px` |
| `--qti-image-card-padding` | Padding inside image cards | `1rem` |
| `--qti-image-max-height` | Maximum height of images | `160px` |

#### Order Interaction Variables (Drag-and-Drop Sorting)

Controls the appearance of the order interaction container that holds draggable items.

| Variable | Description | Default |
|----------|-------------|---------|
| `--qti-order-container-bg` | Container background | `var(--muted, #f4f4f5)` |
| `--qti-order-container-border` | Container border color | `transparent` |
| `--qti-order-container-border-width` | Container border thickness | `2px` |
| `--qti-order-container-radius` | Container border radius | `0.75rem` |
| `--qti-order-container-padding` | Container padding | `1rem` |
| `--qti-order-container-gap` | Gap between drag items | `0.5rem` |
| `--qti-order-container-correct-bg` | Background when correct | `oklch(0.9 0.15 140 / 0.2)` |
| `--qti-order-container-correct-border` | Border when correct | `#22c55e` |
| `--qti-order-container-incorrect-bg` | Background when incorrect | `oklch(0.9 0.1 25 / 0.2)` |
| `--qti-order-container-incorrect-border` | Border when incorrect | `#ef4444` |

#### Order Item Variables (Drag Tokens)

Controls the appearance of individual draggable items/tokens.

| Variable | Description | Default |
|----------|-------------|---------|
| `--qti-order-item-bg` | Item background | `var(--background, #ffffff)` |
| `--qti-order-item-border` | Item border color | `var(--border, #e5e5e5)` |
| `--qti-order-item-border-width` | Item border thickness | `1px` |
| `--qti-order-item-radius` | Item border radius | `0.5rem` |
| `--qti-order-item-padding` | Item padding | `1rem` |
| `--qti-order-item-shadow` | Item box shadow | `0 1px 2px rgba(0, 0, 0, 0.05)` |
| `--qti-order-item-hover-border` | Border on hover | `oklch(0.6 0.2 250)` |
| `--qti-order-item-hover-shadow` | Shadow on hover | `0 4px 6px rgba(0, 0, 0, 0.1)` |
| `--qti-order-item-dragging-border` | Border while dragging | `oklch(0.6 0.2 250)` |
| `--qti-order-item-dragging-shadow` | Shadow while dragging | `0 8px 16px rgba(0, 0, 0, 0.15)` |
| `--qti-order-item-handle-color` | Drag handle icon color | `var(--muted-foreground, #71717a)` |

### Complete Custom Theme Example

Here's a complete example defining all variables for a custom theme:

```css
/* In your globals.css or styles file */
[data-qti-theme="my-custom-theme"] {
  /* ========== Container ========== */
  --qti-container-bg: #ffffff;
  --qti-container-border: #e0e0e0;
  --qti-container-border-width: 1px;
  --qti-container-radius: 12px;
  --qti-container-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);

  /* ========== Typography ========== */
  --qti-font-family: "Inter", system-ui, sans-serif;
  --qti-font-weight: 400;
  --qti-tracking: normal;

  /* ========== Choices (Radio/Checkbox Cards) ========== */
  --qti-choice-bg: #f8f8f8;
  --qti-choice-border: #d0d0d0;
  --qti-choice-hover-bg: #f0f0f0;
  --qti-choice-selected-bg: #e8f4fd;
  --qti-choice-selected-border: #2196f3;
  --qti-choice-correct-bg: #e8f5e9;
  --qti-choice-correct-border: #4caf50;
  --qti-choice-incorrect-bg: #ffebee;
  --qti-choice-incorrect-border: #f44336;

  /* ========== Buttons ========== */
  --qti-button-bg: #2196f3;
  --qti-button-text: #ffffff;
  --qti-button-border: transparent;
  --qti-button-shadow: none;
  --qti-button-radius: 8px;
  --qti-button-font-weight: 600;

  /* ========== Feedback Messages ========== */
  --qti-feedback-bg: #ffffff;
  --qti-feedback-border: #e0e0e0;
  --qti-feedback-correct-text: #2e7d32;
  --qti-feedback-correct-icon-bg: #4caf50;
  --qti-feedback-incorrect-text: #c62828;
  --qti-feedback-incorrect-icon-bg: #f44336;

  /* ========== Text Inputs / Textareas / Dropdowns ========== */
  --qti-input-bg: #ffffff;
  --qti-input-border: #d0d0d0;
  --qti-input-border-width: 1px;
  --qti-input-radius: 8px;
  --qti-input-shadow: none;
  --qti-input-focus-border: #2196f3;
  --qti-input-focus-shadow: 0 0 0 3px rgba(33, 150, 243, 0.2);
  --qti-input-correct-border: #4caf50;
  --qti-input-incorrect-border: #f44336;

  /* ========== Choice Indicators (Radio/Checkbox Circles) ========== */
  --qti-indicator-size: 1.5rem;
  --qti-indicator-bg: #ffffff;
  --qti-indicator-border: #d0d0d0;
  --qti-indicator-border-width: 2px;
  --qti-indicator-radius: 9999px; /* Use 0 for square checkboxes */
  --qti-indicator-checked-bg: #2196f3;
  --qti-indicator-checked-border: #2196f3;
  --qti-indicator-checked-text: #ffffff;

  /* ========== Progress Bar ========== */
  --qti-progress-bg: #e0e0e0;
  --qti-progress-fill: #2196f3;
  --qti-progress-height: 8px;
  --qti-progress-radius: 4px;
  --qti-progress-border: transparent;
  --qti-progress-border-width: 0;

  /* ========== Content (Question Text) ========== */
  --qti-content-font-size: 1.125rem;
  --qti-content-line-height: 1.6;
  --qti-content-font-weight: 400;

  /* ========== Image Grids ========== */
  --qti-grid-gap: 1rem;
  --qti-image-card-min-height: 180px;
  --qti-image-card-padding: 1rem;
  --qti-image-max-height: 160px;

  /* ========== Order Interaction (Drag-and-Drop Container) ========== */
  --qti-order-container-bg: #f5f5f5;
  --qti-order-container-border: #e0e0e0;
  --qti-order-container-border-width: 1px;
  --qti-order-container-radius: 12px;
  --qti-order-container-padding: 1rem;
  --qti-order-container-gap: 0.75rem;
  --qti-order-container-correct-bg: rgba(76, 175, 80, 0.15);
  --qti-order-container-correct-border: #4caf50;
  --qti-order-container-incorrect-bg: rgba(244, 67, 54, 0.15);
  --qti-order-container-incorrect-border: #f44336;

  /* ========== Order Items (Drag Tokens) ========== */
  --qti-order-item-bg: #ffffff;
  --qti-order-item-border: #e0e0e0;
  --qti-order-item-border-width: 1px;
  --qti-order-item-radius: 8px;
  --qti-order-item-padding: 1rem;
  --qti-order-item-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
  --qti-order-item-hover-border: #2196f3;
  --qti-order-item-hover-shadow: 0 4px 8px rgba(0, 0, 0, 0.12);
  --qti-order-item-dragging-border: #2196f3;
  --qti-order-item-dragging-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
  --qti-order-item-handle-color: #9e9e9e;
}

/* Optional: Dark mode support for your custom theme */
.dark[data-qti-theme="my-custom-theme"],
[data-qti-theme="my-custom-theme"] .dark {
  /* Container */
  --qti-container-bg: #1a1a1a;
  --qti-container-border: #333333;
  
  /* Choices */
  --qti-choice-bg: #252525;
  --qti-choice-border: #444444;
  --qti-choice-hover-bg: #333333;
  --qti-choice-selected-bg: rgba(33, 150, 243, 0.2);
  --qti-choice-selected-border: #2196f3;
  --qti-choice-correct-bg: rgba(76, 175, 80, 0.2);
  --qti-choice-incorrect-bg: rgba(244, 67, 54, 0.2);
  
  /* Feedback */
  --qti-feedback-bg: #1a1a1a;
  --qti-feedback-border: #333333;
  
  /* Inputs */
  --qti-input-bg: #252525;
  --qti-input-border: #444444;
  
  /* Indicators */
  --qti-indicator-bg: #252525;
  --qti-indicator-border: #444444;
  
  /* Progress */
  --qti-progress-bg: #333333;

  /* Order Interaction */
  --qti-order-container-bg: #252525;
  --qti-order-container-border: #444444;
  --qti-order-container-correct-bg: rgba(76, 175, 80, 0.2);
  --qti-order-container-incorrect-bg: rgba(244, 67, 54, 0.2);
  
  /* Order Items */
  --qti-order-item-bg: #1a1a1a;
  --qti-order-item-border: #444444;
  --qti-order-item-handle-color: #888888;
}
```

Then use your custom theme:

```tsx
<QTIRenderer theme="my-custom-theme" ... />
```

### Partial Overrides

You don't need to define all variables. You can create a theme that only overrides specific values:

```css
/* Minimal custom theme - only changes colors */
[data-qti-theme="brand-colors"] {
  --qti-choice-selected-bg: #your-brand-color-light;
  --qti-choice-selected-border: #your-brand-color;
  --qti-button-bg: #your-brand-color;
}
```

---

## Complete Examples

### Example 1: Simple Quiz Page

```tsx
// app/quiz/[id]/page.tsx
import * as React from "react"
import { buildDisplayModel, validateResponsesSecure } from "@superbuilders/incept-renderer/actions"
import { db } from "@/db"
import { questions } from "@/db/schema"
import { eq } from "drizzle-orm"
import { QuizClient } from "./client"

export default function QuizPage({ params }: { params: Promise<{ id: string }> }) {
  const displayPromise = params.then(async (p) => {
    const question = await db.query.questions.findFirst({
      where: eq(questions.id, p.id)
    })
    if (!question) throw new Error("Question not found")
    return buildDisplayModel(question.xml)
  })

  async function validate(responses: Record<string, string | string[]>) {
    "use server"
    const { id } = await params
    const question = await db.query.questions.findFirst({
      where: eq(questions.id, id)
    })
    if (!question) throw new Error("Question not found")
    return validateResponsesSecure(question.xml, responses)
  }

  return (
    <React.Suspense fallback={<LoadingSkeleton />}>
      <QuizClient displayPromise={displayPromise} onValidate={validate} />
    </React.Suspense>
  )
}
```

### Example 2: Multi-Question Assessment

```tsx
// app/assessment/[id]/page.tsx
import * as React from "react"
import { buildDisplayModel, validateResponsesSecure } from "@superbuilders/incept-renderer/actions"
import { AssessmentClient } from "./client"

export default function AssessmentPage({ params }: { params: Promise<{ id: string }> }) {
  // Fetch all questions for this assessment
  const questionsPromise = params.then(async (p) => {
    const assessment = await fetchAssessment(p.id)
    return Promise.all(
      assessment.questionIds.map(async (qId) => {
        const xml = await fetchQuestionXml(qId)
        const display = await buildDisplayModel(xml)
        return { id: qId, xml, ...display }
      })
    )
  })

  // Generic validator that works for any question
  async function validateQuestion(questionId: string, responses: Record<string, string | string[]>) {
    "use server"
    const xml = await fetchQuestionXml(questionId)
    return validateResponsesSecure(xml, responses)
  }

  return (
    <React.Suspense fallback={<div>Loading assessment...</div>}>
      <AssessmentClient 
        questionsPromise={questionsPromise} 
        onValidate={validateQuestion} 
      />
    </React.Suspense>
  )
}
```

### Example 3: With Theming Toggle

```tsx
// app/quiz/[id]/client.tsx
"use client"

import * as React from "react"
import { QTIRenderer } from "@superbuilders/incept-renderer"
import type { DisplayItem, FormShape, ValidateResult } from "@superbuilders/incept-renderer"

const THEMES = [
  { value: "duolingo", label: "Duolingo" },
  { value: "neobrutalist", label: "Neobrutalist" },
] as const

type Theme = typeof THEMES[number]["value"]

export function QuizClient({ displayPromise, onValidate }: {
  displayPromise: Promise<{ item: DisplayItem; shape: FormShape; itemKey: string }>
  onValidate: (r: Record<string, string | string[]>) => Promise<ValidateResult>
}) {
  const { item } = React.use(displayPromise)
  
  const [responses, setResponses] = React.useState<Record<string, string | string[]>>({})
  const [result, setResult] = React.useState<ValidateResult | undefined>()
  const [showFeedback, setShowFeedback] = React.useState(false)
  const [theme, setTheme] = React.useState<Theme>("duolingo")

  return (
    <div>
      {/* Theme Selector */}
      <div className="mb-4 flex justify-end">
        <select
          value={theme}
          onChange={(e) => setTheme(e.target.value as Theme)}
          className="border rounded px-2 py-1"
        >
          {THEMES.map((t) => (
            <option key={t.value} value={t.value}>{t.label}</option>
          ))}
        </select>
      </div>

      {/* Question Renderer */}
      <QTIRenderer
        item={item}
        responses={responses}
        onResponseChange={(id, val) => setResponses(prev => ({ ...prev, [id]: val }))}
        showFeedback={showFeedback}
        theme={theme}
        overallFeedback={result ? {
          isCorrect: result.overallCorrect,
          messageHtml: result.feedbackHtml
        } : undefined}
        choiceCorrectness={result?.selectedChoicesByResponse}
      />

      {/* Check Button */}
      <button
        onClick={async () => {
          const validation = await onValidate(responses)
          setResult(validation)
          setShowFeedback(true)
        }}
        disabled={showFeedback}
        className="mt-4 bg-blue-500 text-white px-4 py-2 rounded"
      >
        Check Answer
      </button>
    </div>
  )
}
```

---

## Supported Interactions

| Interaction Type | Support | Description |
|------------------|---------|-------------|
| `choiceInteraction` | ✅ Full | Single/multiple choice questions |
| `inlineChoiceInteraction` | ✅ Full | Dropdown selections within text |
| `textEntryInteraction` | ✅ Full | Free-text input fields |
| `orderInteraction` | 🔶 Basic | Drag-and-drop ordering |
| `matchInteraction` | 🔶 Basic | Matching pairs |
| `gapMatchInteraction` | 🔶 Basic | Fill-in-the-blank with draggable options |

---

## FAQ

### How do I store QTI XML?

Store it however works best for your app:
- **Database:** Store as a TEXT/VARCHAR column
- **File system:** Store as `.xml` files
- **External API:** Fetch from a QTI content server

The package doesn't care where the XML comes from—it just needs a string.

### Can users cheat by inspecting the browser?

No! Correct answers are **never sent to the browser**. The `buildDisplayModel` function only extracts display data. Validation happens on the server with the original XML.

### How do I add custom styling?

Use the `theme` prop with a custom theme name, then define CSS variables for `[data-qti-theme="your-theme"]` in your stylesheet.

### Can I use this without Next.js?

The package is designed for Next.js server components and server actions. For other frameworks, you'd need to:
1. Create your own server endpoint for `buildDisplayModel` and `validateResponsesSecure`
2. Call those endpoints from your client

### How do I handle MathML/LaTeX?

The package preserves MathML in the HTML output. Ensure your app has MathML rendering support (modern browsers handle it natively, or use MathJax/KaTeX for broader support).

---

## License

MIT © Superbuilders