name: pie-element-author
description: Implements a new PIE element (or non-trivial extension) end-to-end: PRD review → Model/Session/Controller → Svelte delivery component → authoring surface → tests. Use when adding a new @pie-element/* package or extending an existing element with a new mode or config surface.
allowed-tools: Read, Edit, Write, Glob, Grep, Bash, TodoWrite
PIE Element Author
End-to-end guide for implementing a new Svelte-first PIE element or making a non-trivial extension to an existing one.
When to Use
- Adding a net-new
@pie-element/* package (any QTI 2.2 interaction type not yet implemented).
- Non-trivial extension: adds an authoring-visible config option, a new mode, or a new delivery surface.
- Any work whose scope meets the PRD bar in
AGENTS.md — specifically: new elements, non-trivial extensions, cross-cutting model/session/event contract changes, or authoring-surface changes.
Do not invoke for bug fixes, refactors, demo tweaks, or dependency bumps — a PR description is enough.
Pre-Implementation Checklist
Before writing a line of code:
Package Structure
Every Svelte element lives at packages/elements-svelte/<slug>/ and contains:
src/
delivery/
<ElementName>.svelte # Student-facing delivery component
index.ts # Re-exports component default
author/
Author.svelte # Authoring surface
index.ts
controller/
index.ts # Pure TS controller (no DOM, no Svelte)
index.ts # ESM root — re-exports all three sub-entries
index.iife.ts # IIFE entry — exports delivery component default; NO customElements.define
types.ts # Model, Session, ViewModel — no `any`
package.json # "pie": { "controller": "@pie-element/<slug>/controller" }
# "exports" map with ./delivery, ./controller, ./controller.js, ./author subpaths
# "files" includes controller.js
controller.js # Shim: export * from './dist/controller/index.js'
docs.contract.json # PieDocsContract
vite.config.ts # ESM delivery build
vite.config.iife.ts # IIFE delivery build
vite.config.controller.ts # Controller build
vite.config.author.ts # Author build
vitest.config.ts
Controller Contract
All five methods are pure functions — no DOM access, no side effects, no any.
| Method | Signature (sketch) | Contract |
|---|
model(question, session, env) | → ViewModel | Derives the view-model; never mutates inputs; strips correctness data when env.mode !== 'evaluate'. |
outcome(question, session, env) | → { score: number, empty: boolean } | Score in [0, 1]; empty: true iff session has no response; must be deterministic. |
createDefaultModel() | → Model | Returns a model that passes validate() with zero errors, usable as a blank starting point for authoring. |
validate(model) | → Record<string, string> | Returns {} for a valid model; returns error messages keyed by field for invalid model; never throws. |
createCorrectResponseSession(question) | → Session | Returns a session whose outcome() produces score === 1.0. |
model() must never return any. The disabled field on the view model must reflect env.mode !== 'gather' or env.disabled === true.
Session and Completion Rule
session.completed must flip atomically on the last interaction — i.e., in the same state update that records the final response, not in a separate $effect. Players gate navigation on this flag; late-setting it causes race conditions.
Pattern:
function handleInteraction(update: SessionUpdate) {
const next = applyUpdate(session, update);
next.completed = isComplete(next, model);
session = next;
dispatch('session-changed', { session });
}
Never derive completed lazily in model() or a $derived — the controller receives the session as-is; what the delivery writes is what the controller scores.
Svelte 5 Patterns
Use Svelte 5 runes throughout. Key rules:
- State:
let x = $state(initialValue) — not let x.
- Props:
let { model, session, mode } = $props() — not export let.
- Derived:
let foo = $derived(expr) — not $: foo = expr.
- Side effects:
$effect(() => { … }) — not $: { … }.
- No
$: reactive statements — those are Svelte 4 and silently misbehave in Svelte 5 rune-mode files.
- Never add
tag: '...' inside <svelte:options customElement={...}>. Svelte auto-defines that tag at module evaluation, conflicting with player-controlled registration and causing CustomElementRegistry duplicate-name errors.
session-changed Event Dispatch
Every session mutation must dispatch a session-changed event so the player and host can react. Use the shared helper from @pie-lib/delivery-events-svelte (or the equivalent in packages/lib-svelte/):
import { dispatchSessionChanged } from '@pie-lib/delivery-events-svelte';
function handleInteraction(update) {
session = { ...session, ...update, completed: isComplete(...) };
dispatchSessionChanged(hostElement, session);
}
If the lib helper is not available, dispatch manually:
hostElement.dispatchEvent(
new CustomEvent('session-changed', {
detail: { session },
bubbles: true,
composed: true,
})
);
Never skip dispatching — the player will not know the session changed.
Testing Requirements
Tests must cover all 10 dimensions from AGENTS.md. At minimum:
Controller unit tests (src/controller/index.test.ts):
Delivery component tests (Testing Library, happy-dom):
E2E / accessibility (Playwright):
Quality Gates Before Marking Done
Run these from the repo root; all must pass:
bun run lint:fix
bunx tsc --noEmit
bunx svelte-check
bun test
Before a merge request also run:
bun run typecheck
bun run check
bun run test:e2e
bun run lint