diff --git a/src/app/demo/composed/page.tsx b/src/app/demo/composed/page.tsx new file mode 100644 index 00000000..10051724 --- /dev/null +++ b/src/app/demo/composed/page.tsx @@ -0,0 +1,238 @@ +'use client'; + +import Link from 'next/link'; +import { useState } from 'react'; + +import { Composed } from '@/components/widgets/Composed'; +import type { ComposedSpec } from '@/lib/pathway/schema'; + +/** + * The composed kind, demonstrated with hand-written compositions — the same + * primitive alphabet the generator authors with (issue #100). What the + * pipeline generates live varies; these two pin the shapes a reviewer + * should recognize: a gated walkthrough and a browsable reveal deck. + */ + +const EXAMPLES: { label: string; spec: ComposedSpec }[] = [ + { + label: 'Gated walkthrough', + spec: { + kind: 'composed', + learningComponentId: null, + title: 'Comparing fractions, one step at a time', + components: [ + { + type: 'Sequence', + id: 'root', + policy: { order: 'linear', disclosure: 'gated', revealed: 'accumulate' }, + children: ['s1', 's2', 's3', 's4'], + }, + { type: 'Group', id: 's1', children: ['s1-text', 's1-why'] }, + { + type: 'Text', + id: 's1-text', + text: 'Both fractions have the same denominator, **6**. That means both wholes are cut into six equal parts.', + variant: null, + }, + { + type: 'Callout', + id: 's1-why', + intent: 'why', + label: 'Why?', + text: 'Comparing only works when the pieces are the same size.', + }, + { + type: 'Text', + id: 's2', + text: 'Now compare the numerators: **2** and **5**. Every piece is the same size, so five pieces is more than two.', + variant: null, + }, + { + type: 'Text', + id: 's3', + text: 'So **5/6 > 2/6** — same-size pieces, more of them.', + variant: null, + }, + { + type: 'Check', + id: 's4', + prompt: 'Your turn: which is bigger, **3/8 or 5/8**?', + options: [ + { text: '3/8', feedback: 'same-size eighths — three of them is fewer.' }, + { text: '5/8', feedback: 'same-size pieces, more of them.' }, + { text: "Can't tell", feedback: 'you can — the denominators match, so compare numerators.' }, + ], + answer: 1, + }, + ], + }, + }, + { + label: 'Reveal deck', + spec: { + kind: 'composed', + learningComponentId: null, + title: 'Predict, then check', + components: [ + { + type: 'Sequence', + id: 'root', + policy: { order: 'free', disclosure: 'all', revealed: 'replace' }, + children: ['c1', 'c2'], + }, + { type: 'Reveal', id: 'c1', faces: [{ title: 'Front', child: 'c1-q' }, { title: 'Back', child: 'c1-a' }] }, + { type: 'Text', id: 'c1-q', text: 'Which is bigger: 1/3 or 1/4?', variant: null }, + { type: 'Text', id: 'c1-a', text: '**1/3** — fewer pieces means bigger pieces.', variant: null }, + { type: 'Reveal', id: 'c2', faces: [{ title: 'Front', child: 'c2-q' }, { title: 'Back', child: 'c2-a' }] }, + { type: 'Text', id: 'c2-q', text: 'Is 2/4 the same as 1/2?', variant: null }, + { type: 'Text', id: 'c2-a', text: 'Yes — **equivalent fractions** name the same amount.', variant: null }, + ], + }, + }, + { + label: 'Mini games', + spec: { + kind: 'composed', + learningComponentId: null, + title: 'Fraction games', + components: [ + { type: 'Group', id: 'root', children: ['m1', 'h1'] }, + { + type: 'Match', + id: 'm1', + prompt: 'Match each fraction to its equivalent.', + pairs: [ + { left: '1/2', right: '4/8' }, + { left: '1/3', right: '2/6' }, + { left: '3/4', right: '6/8' }, + ], + }, + { + type: 'Hunt', + id: 'h1', + prompt: 'Tap every fraction equivalent to one half.', + items: [ + { text: '2/4', target: true, feedback: 'two of four equal parts is half.' }, + { text: '3/5', target: false, feedback: 'three fifths is more than half.' }, + { text: '5/10', target: true, feedback: 'five of ten equal parts is half.' }, + { text: '2/3', target: false, feedback: 'two thirds is more than half.' }, + { text: '4/8', target: true, feedback: 'four of eight equal parts is half.' }, + { text: '1/3', target: false, feedback: 'one third is less than half.' }, + ], + }, + ], + }, + }, + { + label: 'Inputs with knobs', + spec: { + kind: 'composed', + learningComponentId: null, + title: 'Fractions, estimated and explored', + components: [ + { type: 'Group', id: 'root', children: ['e1', 'mo1'] }, + { + type: 'Estimate', + id: 'e1', + prompt: 'What percent of a pizza is left if you eat **3/8** of it?', + min: 0, + max: 100, + unit: '%', + actual: 62.5, + feedback: 'Five of the eight equal slices remain — 5/8 is 62.5%.', + }, + { + type: 'Model', + id: 'mo1', + prompt: 'Keep the numerator at 1 and change the denominator. What happens to the size of each piece?', + variable: { name: 'denominator', options: ['2', '4', '8', '16'] }, + outcomes: [ + { option: '2', text: '**1/2** — the whole splits into 2 big pieces. Each piece is half of everything.' }, + { option: '4', text: '**1/4** — 4 equal pieces now. Each piece is half as big as before.' }, + { option: '8', text: '**1/8** — 8 slim pieces. More pieces means each one shrinks.' }, + { option: '16', text: '**1/16** — 16 tiny slivers. A bigger denominator always means smaller pieces.' }, + ], + }, + ], + }, + }, + { + label: 'Free form', + spec: { + kind: 'composed', + learningComponentId: null, + title: 'Build three quarters', + state: { pieces: { values: ['0', '1', '2', '3'], initial: '0' } }, + components: [ + { type: 'Group', id: 'root', children: ['goal', 'count', 'add', 'partway', 'done'] }, + { + type: 'Text', + id: 'goal', + text: 'A whole has **4 equal parts**. Add pieces until you are holding **3/4**.', + variant: null, + }, + { type: 'Text', id: 'count', text: 'You are holding **{pieces}/4** of the whole.', variant: null }, + { type: 'Action', id: 'add', label: 'Add a piece', onTap: [{ var: 'pieces', cycle: true }] }, + { + type: 'Callout', + id: 'partway', + intent: 'note', + label: 'Halfway:', + text: '2/4 is the same amount as 1/2 — two names, one quantity.', + showWhen: { var: 'pieces', equals: '2' }, + }, + { + type: 'Callout', + id: 'done', + intent: 'tip', + label: 'There it is:', + text: '3/4 — three of the four equal parts. One more piece would make the whole.', + showWhen: { var: 'pieces', equals: '3' }, + }, + ], + }, + }, +]; + +export default function ComposedDemoPage() { + const [example, setExample] = useState(0); + const [completions, setCompletions] = useState(0); + + return ( +
+

+ + ← all demos + +

+

Composed

+

+ An activity assembled from pedagogical primitives — the model authors the composition tree, + human-written renderers draw it, and the structural gate refuses unsound trees. Nothing in + the v1 alphabet measures, so completion is the only signal (completions this session:{' '} + {completions}). +

+
+ {EXAMPLES.map((entry, i) => ( + + ))} +
+ setCompletions((n) => n + 1)} + /> +
+ ); +} diff --git a/src/components/a2ui/A2UISurfaceView.tsx b/src/components/a2ui/A2UISurfaceView.tsx new file mode 100644 index 00000000..07152c4f --- /dev/null +++ b/src/components/a2ui/A2UISurfaceView.tsx @@ -0,0 +1,1036 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { motion } from 'motion/react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; + +import type { A2UIComponent, A2UISurfaceMessage } from '@/lib/a2learn/a2ui'; +import type { SequencePolicy } from '@/lib/a2learn/primitives'; + +/** + * A renderer for the slice of the A2UI basic catalog the boundary mapper + * emits: Column, Card, Text, Divider, Image, Button, Tabs, List. This is + * not a general A2UI client — it exists so the app can *show* a mapped + * surface being a real UI, not just JSON that validates. A component + * outside the emitted slice renders as a visible gap, never silently + * dropped. + * + * Presentation is the renderer's job — that's A2UI's core split: the + * surface carries semantics, the host draws them in its own design + * language. So this renderer animates tab switches, styles cards like the + * app's cards, pages horizontal lists like a deck, and renders Text's + * simple-Markdown scope the way the catalog defines it (no HTML, links and + * images unwrapped to their text). What it never does is exceed the + * semantics: no component gains state the surface didn't model. + */ + +type Props = { + surface: A2UISurfaceMessage; + /** Called when a Button's action fires — the demo shows the payload. */ + onAction?: (action: Record) => void; +}; + +export function A2UISurfaceView({ surface, onAction }: Props) { + const byId = new Map(surface.createSurface.components.map((c) => [c.id, c])); + + // The free-form state layer: finite, declared variables from the surface's + // data model. The interpreter below is everything that ever executes — + // compositions author values, visibility conditions, and transitions as data. + const stateDefs = ((surface.createSurface.dataModel?.state ?? {}) as Record< + string, + { values: string[]; initial: string } + >); + const [stateVals, setStateVals] = useState>(() => + Object.fromEntries(Object.entries(stateDefs).map(([k, d]) => [k, d.initial])), + ); + const applyOps = (ops: { var: string; set?: string | null; cycle?: boolean | null }[]) => { + setStateVals((prev) => { + const next = { ...prev }; + for (const op of ops) { + const def = stateDefs[op.var]; + if (!def) continue; + if (op.set != null && def.values.includes(op.set)) next[op.var] = op.set; + else if (op.cycle) { + const i = def.values.indexOf(next[op.var] ?? def.initial); + next[op.var] = def.values[(i + 1) % def.values.length]; + } + } + return next; + }); + }; + const interp = (text: string) => + Object.keys(stateDefs).length === 0 + ? text + : text.replace(/\{([a-zA-Z][a-zA-Z0-9_]*)\}/g, (m, name) => stateVals[name] ?? m); + + function render(id: string, seen: Set): React.ReactNode { + const component = byId.get(id); + if (!component) return ; + if (seen.has(id)) return ; + const showWhen = component.showWhen as { var: string; equals: string } | undefined; + if (showWhen && stateVals[showWhen.var] !== showWhen.equals) return null; + const path = new Set(seen).add(id); + + const children = () => childIds(component).map((childId) => render(childId, path)); + + switch (component.component) { + case 'Column': + return ( +
+ {columnChildren(component, path)} +
+ ); + case 'Row': + return ( +
+ {children()} +
+ ); + case 'List': { + const horizontal = component.direction === 'horizontal'; + if (!horizontal) { + return ( +
+ {children()} +
+ ); + } + // A horizontal list pages like a deck: swipe, or use the paging + // controls — position affordances are presentation, not state the + // surface didn't model. + return ( + ({ + id: childId, + node: render(childId, path), + }))} + /> + ); + } + case 'Card': { + // Compositional presentation: a Card whose sole child is a two-tab + // Tabs is a thing with two faces — draw it as a real flip card. The + // semantics are untouched (same components, same two panels); only + // the affordance is native. + const child = typeof component.child === 'string' ? byId.get(component.child) : undefined; + if (child?.component === 'Tabs' && Array.isArray(child.tabs) && child.tabs.length === 2) { + const faces = (child.tabs as { title?: unknown; child?: unknown }[]).map((tab) => ({ + title: String(tab.title ?? ''), + child: typeof tab.child === 'string' ? tab.child : null, + })); + return ( + render(childId, path)} + /> + ); + } + return ( +
+ {children()} +
+ ); + } + case 'Text': { + const caption = component.variant === 'caption'; + // The catalog's Text supports "simple Markdown formatting (without + // HTML, images, or links)" — so render exactly that: GFM emphasis, + // lists and headings; raw HTML ignored, links and images unwrapped + // to their text. Drawing literal ** marks would under-render the + // spec, not respect it. + return ( +
+ + {interp(String(component.text ?? ''))} + +
+ ); + } + case 'Divider': + return
; + case 'Tabs': { + const tabs = Array.isArray(component.tabs) ? (component.tabs as { title?: unknown; child?: unknown }[]) : []; + return ( + ({ + title: String(tab.title ?? ''), + child: typeof tab.child === 'string' ? tab.child : null, + }))} + render={(childId) => render(childId, path)} + /> + ); + } + case 'Image': + // The catalog's accessibility field is `description`, not `alt`. + return ( + + ); + case 'Button': + return ( + + ); + // --- a2learn interaction primitives (draft catalog = basic ∪ primitives) --- + case 'a2learn:Sequence': { + const policy = (component.policy ?? {}) as Partial; + const items = childIds(component).map((childId) => ({ + id: childId, + node: render(childId, path), + })); + const action = component.completeAction as Record | undefined; + const finale = action ? { label: 'Done', onClick: () => onAction?.(action) } : undefined; + if (policy.disclosure === 'gated') { + return ( + + ); + } + return ; + } + case 'a2learn:Reveal': { + const faces = (Array.isArray(component.faces) ? component.faces : []) as { + title?: unknown; + child?: unknown; + }[]; + if (faces.length === 2) { + const pair = faces.map((tab) => ({ + title: String(tab.title ?? ''), + child: typeof tab.child === 'string' ? tab.child : null, + })); + return ( + render(childId, path)} + /> + ); + } + return ( + ({ + title: String(tab.title ?? ''), + child: typeof tab.child === 'string' ? tab.child : null, + }))} + render={(childId) => render(childId, path)} + /> + ); + } + case 'a2learn:Check': { + const options = (Array.isArray(component.options) ? component.options : []) as { + text?: unknown; + feedback?: unknown; + }[]; + return ( + ({ text: String(o.text ?? ''), feedback: String(o.feedback ?? '') }))} + /> + ); + } + case 'a2learn:Match': { + const pairs = (Array.isArray(component.pairs) ? component.pairs : []) as { left?: unknown; right?: unknown }[]; + return ( + ({ left: String(p.left ?? ''), right: String(p.right ?? '') }))} + /> + ); + } + case 'a2learn:Hunt': { + const items = (Array.isArray(component.items) ? component.items : []) as { + text?: unknown; target?: unknown; feedback?: unknown; + }[]; + return ( + ({ text: String(i.text ?? ''), target: Boolean(i.target), feedback: String(i.feedback ?? '') }))} + /> + ); + } + case 'a2learn:Action': { + const ops = (Array.isArray(component.onTap) ? component.onTap : []) as { + var: string; set?: string | null; cycle?: boolean | null; + }[]; + return ( + applyOps(ops)} + className="self-start rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90" + > + {interp(String(component.label ?? ''))} + + ); + } + case 'a2learn:Estimate': { + return ( + + ); + } + case 'a2learn:Model': { + const variable = (component.variable ?? {}) as { name?: unknown; options?: unknown }; + const outcomes = (Array.isArray(component.outcomes) ? component.outcomes : []) as { + option?: unknown; text?: unknown; + }[]; + return ( + [String(o.option ?? ''), String(o.text ?? '')]))} + /> + ); + } + case 'a2learn:Callout': { + // Pedagogical emphasis with intent as data — the renderer maps + // intent to its design language; unknown intents get the neutral box. + const intent = String(component.intent ?? 'note'); + const tone = + intent === 'why' + ? 'border-amber-200 bg-amber-50 dark:border-amber-800/50 dark:bg-amber-950/30' + : intent === 'tip' + ? 'border-primary/25 bg-primary/5' + : 'border-border bg-muted/40'; + const labelTone = intent === 'tip' ? 'font-semibold text-primary' : 'font-semibold'; + return ( +
+ {component.label ? {String(component.label)} : null} + {typeof component.child === 'string' ? ( + render(component.child, path) + ) : ( + + + {interp(String(component.text ?? ''))} + + + )} +
+ ); + } + default: + return ; + } + } + + /** + * A Column's children, with one compositional merge: a horizontal List + * (a deck) immediately followed by an action Button folds that button + * into the deck's paging controls as the final page's action — the same + * button, the same action, presented where the reader finishes, the way + * the native widgets end on their own CTA. Nothing is dropped or added. + */ + function columnChildren(component: A2UIComponent, path: Set): React.ReactNode[] { + const ids = childIds(component); + const nodes: React.ReactNode[] = []; + for (let i = 0; i < ids.length; i++) { + const child = byId.get(ids[i]); + const following = i + 1 < ids.length ? byId.get(ids[i + 1]) : undefined; + if ( + child?.component === 'List' && + child.direction === 'horizontal' && + following?.component === 'Button' && + following.action && + typeof following.action === 'object' + ) { + const labelSource = + typeof following.child === 'string' ? byId.get(following.child) : undefined; + const action = following.action as Record; + nodes.push( + ({ id: childId, node: render(childId, path) }))} + finale={{ + label: String(labelSource?.text ?? 'Done'), + onClick: () => onAction?.(action), + }} + />, + ); + i++; // the button is folded into the deck — don't render it twice + continue; + } + nodes.push(render(ids[i], path)); + } + return nodes; + } + + return
{render('root', new Set())}
; +} + +/** Child references, in the shapes the mapper emits (string child / id list). */ +function childIds(component: A2UIComponent): string[] { + const ids: string[] = []; + if (typeof component.child === 'string') ids.push(component.child); + if (Array.isArray(component.children)) { + for (const ref of component.children) if (typeof ref === 'string') ids.push(ref); + } + return ids; +} + +/** + * A horizontal List as a paged deck: one item in view, Back/Next and + * position dots as renderer affordances, and page changes slide in place — + * the same 320ms slide-and-fade the native widgets use, not a scroll. + */ +function DeckView({ + items, + finale, +}: { + items: { id: string; node: React.ReactNode }[]; + /** Rendered in Next's place on the last page — the deck's own completion action. */ + finale?: { label: string; onClick: () => void }; +}) { + const [index, setIndex] = useState(0); + const [direction, setDirection] = useState(0); + + const go = (next: number) => { + const clamped = Math.max(0, Math.min(items.length - 1, next)); + setDirection(Math.sign(clamped - index)); + setIndex(clamped); + }; + + return ( +
+ + {items[index]?.node} + + {items.length > 1 && ( + <> +
+ {items.map((item, i) => ( + + ))} +
+
+ + {finale && index === items.length - 1 ? ( + + ) : ( + + )} +
+ + )} +
+ ); +} + +/** + * Sequence(disclosure: gated): items reveal one advance at a time — + * accumulate keeps passed items visible, replace shows only the newest. + * The advance past the last item is the completion, exactly the native + * step-reveal's discipline, driven here entirely by policy data. + */ +function GatedSequenceView({ + items, + accumulate, + finale, +}: { + items: { id: string; node: React.ReactNode }[]; + accumulate: boolean; + finale?: { label: string; onClick: () => void }; +}) { + const [revealed, setRevealed] = useState(1); + const atEnd = revealed >= items.length; + const visible = accumulate ? items.slice(0, revealed) : items.slice(revealed - 1, revealed); + return ( +
+ {visible.map((item, i) => ( + + {item.node} + + ))} + {!atEnd ? ( + + ) : finale ? ( + + ) : null} +
+ ); +} + +/** + * A self-check: pick, see immediately whether that was it and why, try again + * on a miss. Deliberately local — nothing leaves this component, because it + * exists for the learner's retrieval, not anyone's measurement. State is + * icon + word, never color alone. + */ +function CheckView({ + prompt, + options, + answer, +}: { + prompt: string; + options: { text: string; feedback: string }[]; + answer: number; +}) { + const [picked, setPicked] = useState(null); + const settled = picked === answer; + return ( +
+
+ + {prompt} + +
+
+ {options.map((option, i) => { + const isPicked = picked === i; + const revealed = isPicked || (settled && i === answer); + return ( + + ); + })} +
+ {picked !== null && !settled && ( +

Try another one — checking yourself is the point.

+ )} +
+ ); +} + +/** + * Match: tap a left item, then the right item you think pairs with it. + * A hit locks the pair with a check; a miss flashes and clears. The shuffle + * is deterministic per mount, progress is "n of N matched", and — like every + * local game primitive — nothing leaves the component. + */ +function MatchView({ prompt, pairs }: { prompt: string; pairs: { left: string; right: string }[] }) { + const [shuffled] = useState(() => + pairs.map((_, i) => i).sort((a, b) => ((a * 7919 + 13) % 101) - ((b * 7919 + 13) % 101)), + ); + const [selectedLeft, setSelectedLeft] = useState(null); + const [matched, setMatched] = useState>(new Set()); + const [missAt, setMissAt] = useState(null); + + const tryPair = (rightIdx: number) => { + if (selectedLeft === null || matched.has(rightIdx)) return; + if (rightIdx === selectedLeft) { + setMatched((prev) => new Set(prev).add(rightIdx)); + setSelectedLeft(null); + } else { + setMissAt(rightIdx); + setTimeout(() => setMissAt(null), 500); + } + }; + + const complete = matched.size === pairs.length; + return ( +
+
+

{prompt}

+ + {matched.size} / {pairs.length} matched + +
+
+
+ {pairs.map((pair, i) => ( + + ))} +
+
+ {shuffled.map((originalIdx) => ( + tryPair(originalIdx)} + animate={missAt === originalIdx ? { x: [0, -6, 6, -3, 0] } : {}} + transition={{ duration: 0.35 }} + className={`rounded-lg border px-3 py-2 text-left text-sm transition-colors ${ + matched.has(originalIdx) + ? 'border-green-600/40 bg-green-50 text-muted-foreground dark:bg-green-950/30' + : missAt === originalIdx + ? 'border-amber-500/60 bg-amber-50 dark:bg-amber-950/30' + : 'border-border hover:border-muted-foreground/40' + } ${selectedLeft === null && !matched.has(originalIdx) ? 'opacity-70' : ''}`} + > + {matched.has(originalIdx) ? '✓ ' : ''}{pairs[originalIdx].right} + + ))} +
+
+

+ {complete + ? '✓ all matched — every pair retrieved.' + : selectedLeft === null + ? 'Tap an item on the left, then its match on the right.' + : 'Now tap its match on the right.'} +

+
+ ); +} + +/** + * Hunt: find every target among near-miss decoys. Each tap answers + * immediately — a target locks in with its feedback, a decoy flashes with + * why it is not one. Progress counts found targets; local state only. + */ +function HuntView({ prompt, items }: { prompt: string; items: { text: string; target: boolean; feedback: string }[] }) { + const [tapped, setTapped] = useState>(new Set()); + const [lastTap, setLastTap] = useState(null); + const targets = items.filter((i) => i.target).length; + const found = items.filter((i, idx) => i.target && tapped.has(idx)).length; + const complete = found === targets; + return ( +
+
+

{prompt}

+ + {found} / {targets} found + +
+
+ {items.map((item, i) => { + const isTapped = tapped.has(i); + return ( + { setTapped((prev) => new Set(prev).add(i)); setLastTap(i); }} + animate={isTapped && !item.target && lastTap === i ? { x: [0, -6, 6, -3, 0] } : {}} + transition={{ duration: 0.35 }} + className={`rounded-full border px-3.5 py-1.5 text-sm transition-colors ${ + isTapped && item.target + ? 'border-green-600/50 bg-green-50 dark:bg-green-950/30' + : isTapped + ? 'border-amber-500/60 bg-amber-50 dark:bg-amber-950/30' + : 'border-border hover:border-muted-foreground/40' + }`} + > + {isTapped ? (item.target ? '✓ ' : '✗ ') : ''}{item.text} + + ); + })} +
+ {lastTap !== null && tapped.has(lastTap) && ( +

+ {items[lastTap].target ? '✓ ' : '✗ '}{items[lastTap].feedback} +

+ )} + {complete &&

✓ found them all — that's the discrimination.

} +
+ ); +} + +/** + * Estimate: commit a value on the slider, then the reveal shows your mark + * and the actual on the same track — juxtaposition, never a verdict badge. + * Committing before seeing is the pedagogy; nothing is transmitted. + */ +function EstimateView({ + prompt, min, max, unit, actual, feedback, +}: { + prompt: string; min: number; max: number; unit: string; actual: number; feedback: string; +}) { + const [value, setValue] = useState((min + max) / 2); + const [locked, setLocked] = useState(false); + const pct = (v: number) => ((v - min) / (max - min || 1)) * 100; + const fmt = (v: number) => `${Number.isInteger(v) ? v : v.toFixed(1)}${unit}`; + return ( +
+
+ + {prompt} + +
+
+ {locked && ( + + actual: {fmt(actual)} + + )} + setValue(Number(e.target.value))} + className="w-full accent-primary" + aria-label={prompt} + /> + {locked && ( +
+ )} +
+ {!locked ? ( +
+ your estimate: {fmt(value)} + +
+ ) : ( +

+ You said {fmt(value)} · actual {fmt(actual)} — off by {fmt(Math.abs(actual - value))}. {feedback} +

+ )} +
+ ); +} + +/** + * Model: an explanation with a knob. The student sets the variable; the + * authored outcome for that value shows. All-numeric options render as a + * stepped slider, labels as chips. The renderer only ever selects among + * authored outcomes — it computes nothing. + */ +function ModelView({ + prompt, name, options, outcomes, +}: { + prompt: string; name: string; options: string[]; outcomes: Record; +}) { + const [index, setIndex] = useState(0); + const numeric = options.length > 0 && options.every((o) => /^-?\d+(\.\d+)?$/.test(o.trim())); + const current = options[index] ?? ''; + return ( +
+
+ + {prompt} + +
+ {numeric ? ( +
+ {name}: + setIndex(Number(e.target.value))} + className="flex-1 accent-primary" + aria-label={name} + /> + {current} +
+ ) : ( +
+ {name}: + {options.map((option, i) => ( + + ))} +
+ )} + + + {outcomes[current] ?? ''} + + +
+ ); +} + +type FlipFace = { title: string; child: string | null }; + +/** + * A genuine 3D flip: one container rotating in perspective, two + * backface-hidden faces stacked in the same grid cell (so the tallest face + * sets the height), tap anywhere to turn it over. The caption names the + * other face using the surface's own tab titles — no flashcard vocabulary + * baked into the renderer. + */ +function FlipCard({ + faces, + render, +}: { + faces: [FlipFace, FlipFace]; + render: (childId: string) => React.ReactNode; +}) { + const [flipped, setFlipped] = useState(false); + const faceClass = + 'col-start-1 row-start-1 flex min-h-44 flex-col items-center justify-center gap-3 rounded-2xl border border-border bg-card p-6 text-center shadow-sm [backface-visibility:hidden]'; + return ( +
+
+ setFlipped((f) => !f)} + > +
{faces[0].child ? render(faces[0].child) : null}
+
+ {faces[1].child ? render(faces[1].child) : null} +
+
+
+

+ Tap to see {flipped ? faces[0].title.toLowerCase() : faces[1].title.toLowerCase()} +

+
+ ); +} + +/** + * Tab switching is renderer-local state — the one interaction the basic + * catalog gives back (this is how the flashcard reveal survives projection). + * Presentation is ours to choose, so a switch flips: the panel turns over + * like a card, and the whole panel is tappable to advance to the next tab — + * semantics unchanged, affordance native. + */ +function TabsView({ + tabs, + render, +}: { + tabs: { title: string; child: string | null }[]; + render: (childId: string) => React.ReactNode; +}) { + const [active, setActive] = useState(0); + const current = tabs[active]; + const advance = () => setActive((i) => (i + 1) % tabs.length); + return ( +
+
+ {tabs.map((tab, i) => ( + + ))} +
+ + {current?.child ? render(current.child) : null} + +
+ ); +} + +/** A broken image collapses to nothing; the alt text is the honest fallback. */ +function SurfaceImage({ url, alt }: { url: string; alt: string }) { + const [failed, setFailed] = useState(false); + const ref = useRef(null); + // An image that broke before hydration already fired its error event; + // onError alone would miss it, and the gap would render as silence. + useEffect(() => { + const img = ref.current; + if (img?.complete && img.naturalWidth === 0) setFailed(true); + }, []); + if (failed) { + return ( +

+ 🖼 image unavailable — {alt || 'no description provided'} +

+ ); + } + return ( + // eslint-disable-next-line @next/next/no-img-element -- arbitrary external URL from a surface; next/image needs configured hosts + {alt} setFailed(true)} /> + ); +} + +function Gap({ label }: { label: string }) { + return ( +

+ ⚠ {label} +

+ ); +} + +/** The demo's action panel: what the conversation would hear. */ +export function ActionLog({ action }: { action: Record | null }) { + return action ? ( +
+

✓ action dispatched — what the agent would receive:

+
{JSON.stringify(action, null, 2)}
+
+ ) : ( +

+ Nothing dispatched yet — finish the activity to see its completion action. +

+ ); +} + +/** Small stateful wrapper so a server page can compose surface + log. */ +export function A2UISurfaceDemo({ surface }: { surface: A2UISurfaceMessage }) { + const [action, setAction] = useState | null>(null); + return ( +
+ + +
+ ); +} diff --git a/src/components/pathway/PathwayWalkthrough.tsx b/src/components/pathway/PathwayWalkthrough.tsx index 1d7da40d..e4b09eee 100644 --- a/src/components/pathway/PathwayWalkthrough.tsx +++ b/src/components/pathway/PathwayWalkthrough.tsx @@ -35,6 +35,7 @@ export type WalkthroughSession = { * button is shown. */ const HAS_OWN_CTA = new Set([ + 'composed', 'markdown-card', 'flashcard', 'step-reveal', diff --git a/src/components/widgets/Composed.tsx b/src/components/widgets/Composed.tsx new file mode 100644 index 00000000..7f4ad790 --- /dev/null +++ b/src/components/widgets/Composed.tsx @@ -0,0 +1,140 @@ +'use client'; + +import { useMemo, useRef } from 'react'; + +import { A2UISurfaceView } from '@/components/a2ui/A2UISurfaceView'; +import { A2LEARN_CATALOG_DRAFT } from '@/lib/a2learn/primitives'; +import type { A2UIComponent, A2UISurfaceMessage } from '@/lib/a2learn/a2ui'; +import type { ComposedComponent, ComposedSpec } from '@/lib/pathway/schema'; + +type Props = { spec: ComposedSpec; onComplete?: (correct: boolean) => void }; + +/** + * The composed kind's renderer: adapt the spec's flat primitive tree into an + * a2learn-catalog surface and hand it to the primitives renderer. The model + * authored structure and content only — completion wiring is injected here, + * renderer-side, so event payloads are never model-authored data. + * + * Completion rule, deterministic: the first Sequence in the tree gets the + * completion as its finale (its last advance is the natural end of the + * activity); a composition with no Sequence gets a plain "Got it" button + * appended below. Either way `onComplete(true)` fires — and the `true` is + * honest only because the v1 alphabet is verdict-free: completion is the + * only thing measured, so completing is succeeding. A verdict-carrying + * alphabet must revisit this line before it ships (issue #100, phase 3). + */ + +const COMPLETE_ACTION = { event: { name: 'composed.completed' } }; + +function toSurfaceComponent(component: ComposedComponent): A2UIComponent { + const base = mapComponent(component); + if ('showWhen' in component && component.showWhen) base.showWhen = component.showWhen; + return base; +} + +function mapComponent(component: ComposedComponent): A2UIComponent { + switch (component.type) { + case 'Group': + return { id: component.id, component: 'Column', children: component.children }; + case 'Check': + return { + id: component.id, + component: 'a2learn:Check', + prompt: component.prompt, + options: component.options, + answer: component.answer, + }; + case 'Match': + return { id: component.id, component: 'a2learn:Match', prompt: component.prompt, pairs: component.pairs }; + case 'Estimate': + return { + id: component.id, + component: 'a2learn:Estimate', + prompt: component.prompt, + min: component.min, + max: component.max, + unit: component.unit, + actual: component.actual, + feedback: component.feedback, + }; + case 'Model': + return { + id: component.id, + component: 'a2learn:Model', + prompt: component.prompt, + variable: component.variable, + outcomes: component.outcomes, + }; + case 'Action': + return { id: component.id, component: 'a2learn:Action', label: component.label, onTap: component.onTap }; + case 'Hunt': + return { id: component.id, component: 'a2learn:Hunt', prompt: component.prompt, items: component.items }; + case 'Text': + return { + id: component.id, + component: 'Text', + text: component.text, + ...(component.variant === 'caption' ? { variant: 'caption' } : {}), + }; + case 'Callout': + return { + id: component.id, + component: 'a2learn:Callout', + intent: component.intent, + label: component.label, + text: component.text, + }; + case 'Reveal': + return { id: component.id, component: 'a2learn:Reveal', faces: component.faces }; + case 'Sequence': + return { + id: component.id, + component: 'a2learn:Sequence', + policy: component.policy, + children: component.children, + }; + } +} + +export function Composed({ spec, onComplete }: Props) { + const completed = useRef(false); + + const { surface, hasSequence } = useMemo(() => { + const components = spec.components.map(toSurfaceComponent); + const firstSequence = components.find((c) => c.component === 'a2learn:Sequence'); + if (firstSequence) firstSequence.completeAction = COMPLETE_ACTION; + const message: A2UISurfaceMessage = { + version: 'v1.0', + createSurface: { + surfaceId: `composed-${spec.learningComponentId ?? 'activity'}`, + catalogId: A2LEARN_CATALOG_DRAFT, + components, + // A2UI's own home for this: declared finite state rides the data model. + ...(spec.state ? { dataModel: { state: spec.state } } : {}), + }, + }; + return { surface: message, hasSequence: Boolean(firstSequence) }; + }, [spec]); + + const complete = () => { + if (completed.current) return; + completed.current = true; + onComplete?.(true); + }; + + return ( +
+

{spec.title}

+ + {!hasSequence && ( + + )} +
+ ); +} diff --git a/src/lib/a2learn/primitives.ts b/src/lib/a2learn/primitives.ts new file mode 100644 index 00000000..81a3e687 --- /dev/null +++ b/src/lib/a2learn/primitives.ts @@ -0,0 +1,48 @@ +/** + * a2learn interaction primitives: *generic, parameterized* pedagogy + * components — behavior and emphasis as data, so activity shapes compose at + * generation time without new component code. The product source of truth + * for the composable alphabet is `composedSpec` in `src/lib/pathway/schema.ts` + * (the `composed` widget kind); this module owns the catalog identity and the + * shared policy vocabulary the renderer speaks. + * + * Vocabulary, grounding, and the build plan live in issue #100; the catalog + * direction in #98. Draft means draft: the id is a URN, not a fetchable + * schema — the versioned catalog contract is #98 phase-6 work, and nothing + * outside this repo should program against these shapes yet. + * + * The v1 alphabet (Sequence, Reveal, Callout + basic-catalog content) is + * deliberately verdict-free. Verdict-carrying primitives (Check/Response) + * join only with the #99 evidence contract, and `assesses` on a composed + * activity stays *derived from the alphabet* — never asserted. + */ + +/** The draft a2learn catalog: Google A2UI's basic catalog ∪ these primitives. */ +export const A2LEARN_CATALOG_DRAFT = 'urn:a2learn:catalog:draft'; + +export type SequencePolicy = { + /** May the reader jump around, or only move to what's next? */ + order: 'linear' | 'free'; + /** Is the next item held back until the reader advances? */ + disclosure: 'gated' | 'all'; + /** Do passed items stay visible, or does the next replace them? */ + revealed: 'accumulate' | 'replace'; +}; + +export const SEQUENCE = 'a2learn:Sequence'; +export const REVEAL = 'a2learn:Reveal'; +export const CALLOUT = 'a2learn:Callout'; +/** Local self-check: options + instant feedback, nothing recorded — retrieval + * practice for the learner, never measurement (that is Response, gated on #99). */ +export const CHECK = 'a2learn:Check'; +/** Stateful local mini-games — selection state and progress live entirely + * renderer-side; nothing is recorded. Match = paired-associate retrieval, + * Hunt = discrimination (find every target among near-miss decoys). */ +export const MATCH = 'a2learn:Match'; +export const HUNT = 'a2learn:Hunt'; +/** Commit input: lock in an estimate before the reveal; compared by + * juxtaposition, never judged, never transmitted (hypercorrection effect). */ +export const ESTIMATE = 'a2learn:Estimate'; +/** Parameter input: the value configures which authored outcome shows — + * explanations with knobs. Outcomes are authored data, never formulas. */ +export const MODEL = 'a2learn:Model'; diff --git a/src/lib/pathway/composed.test.ts b/src/lib/pathway/composed.test.ts new file mode 100644 index 00000000..3f4ac00a --- /dev/null +++ b/src/lib/pathway/composed.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it } from 'vitest'; + +import { + composedSpec, + composedSpecStrict, + compositionProblems, + type ComposedSpec, +} from '@/lib/pathway/schema'; + +/** + * The composed kind's structural gate. This is what stands between a + * model-emitted tree and a student's screen, so every rule proves it can + * reject — a vacuously green validator here would render holes at children. + */ + +const sound: ComposedSpec = { + kind: 'composed', + learningComponentId: null, + title: 'Comparing fractions, step by step', + components: [ + { + type: 'Sequence', + id: 'root', + policy: { order: 'linear', disclosure: 'gated', revealed: 'accumulate' }, + children: ['s1', 's2'], + }, + { type: 'Group', id: 's1', children: ['s1-text', 's1-why'] }, + { type: 'Text', id: 's1-text', text: 'Same denominator means same-size pieces.', variant: null }, + { type: 'Callout', id: 's1-why', intent: 'why', label: 'Why?', text: 'Comparing only works when pieces match.', }, + { type: 'Reveal', id: 's2', faces: [{ title: 'Front', child: 's2-q' }, { title: 'Back', child: 's2-a' }] }, + { type: 'Text', id: 's2-q', text: 'Which is bigger: 2/6 or 5/6?', variant: null }, + { type: 'Text', id: 's2-a', text: '**5/6** — more same-size pieces.', variant: null }, + ], +}; + +describe('compositionProblems', () => { + it('accepts a sound composition', () => { + expect(compositionProblems(sound)).toEqual([]); + expect(composedSpecStrict.safeParse(sound).success).toBe(true); + }); + + it('rejects a missing root', () => { + const spec = { + ...sound, + components: sound.components.map((c) => (c.id === 'root' ? { ...c, id: 'not-root' } : c)), + } as ComposedSpec; + expect(compositionProblems(spec)).toContainEqual(expect.stringContaining('root')); + }); + + it('rejects a dangling reference', () => { + const spec = { + ...sound, + components: sound.components.filter((c) => c.id !== 's1-why'), + } as ComposedSpec; + expect(compositionProblems(spec)).toContainEqual(expect.stringContaining('missing component: s1-why')); + expect(composedSpecStrict.safeParse(spec).success).toBe(false); + }); + + it('rejects duplicate ids', () => { + const spec = { + ...sound, + components: [...sound.components, { type: 'Text', id: 's1-text', text: 'again', variant: null }], + } as ComposedSpec; + expect(compositionProblems(spec)).toContainEqual(expect.stringContaining('duplicate')); + }); + + it('rejects a reference cycle', () => { + const spec = { + ...sound, + components: [ + ...sound.components.filter((c) => c.id !== 's1'), + { type: 'Group', id: 's1', children: ['s1-text', 'loop'] }, + { type: 'Group', id: 'loop', children: ['s1'] }, + ], + } as ComposedSpec; + expect(compositionProblems(spec)).toContainEqual(expect.stringContaining('cycle')); + }); + + it('rejects a one-faced Reveal', () => { + const spec = { + ...sound, + components: sound.components.map((c) => + c.type === 'Reveal' ? { ...c, faces: c.faces.slice(0, 1) } : c, + ), + } as ComposedSpec; + expect(compositionProblems(spec)).toContainEqual(expect.stringContaining('exactly 2 faces')); + }); + + it('the base schema stays refinement-free for generation, and rejects unknown components', () => { + // Providers serialize the base schema; refinements only run at parse + // time. An unknown component name must still fail the union itself. + const spec = { + ...sound, + components: [...sound.components, { type: 'Quiz', id: 'q', text: 'nope' }], + }; + expect(composedSpec.safeParse(spec).success).toBe(false); + }); +}); + +describe('canonicalizeNode', () => { + it('maps the field-name synonyms models actually produce', async () => { + const { canonicalizeNode } = await import('@/lib/widgets/definitions/composed.generate'); + // body → text, observed live + expect(canonicalizeNode({ id: 't', type: 'Text', body: '# Hi' })).toMatchObject({ text: '# Hi', variant: null }); + // flat policy fields → nested policy, observed live + expect( + canonicalizeNode({ id: 's', type: 'Sequence', order: 'linear', disclosure: 'gated', revealed: 'accumulate', children: ['a'] }), + ).toMatchObject({ policy: { order: 'linear', disclosure: 'gated', revealed: 'accumulate' } }); + // front/back shorthand → faces + expect(canonicalizeNode({ id: 'r', type: 'Reveal', front: 'q', back: 'a' })).toMatchObject({ + faces: [{ title: 'Front', child: 'q' }, { title: 'Back', child: 'a' }], + }); + // unknown intent coerces to note; unknown type passes through for the strict parse to reject + expect(canonicalizeNode({ id: 'c', type: 'Callout', intent: 'warning', label: 'L', text: 'x' })).toMatchObject({ intent: 'note' }); + expect(canonicalizeNode({ id: 'q', type: 'Quiz' })).toMatchObject({ type: 'Quiz' }); + }); +}); + +describe('Check structural rules', () => { + const withCheck = (options: { text: string; feedback: string }[], answer: number): ComposedSpec => ({ + kind: 'composed', + learningComponentId: null, + title: 'Check test', + components: [ + { type: 'Group', id: 'root', children: ['q'] }, + { type: 'Check', id: 'q', prompt: 'Which sphere is water?', options, answer }, + ], + }); + const two = [ + { text: 'hydrosphere', feedback: 'water is the hydro- part' }, + { text: 'geosphere', feedback: 'that is land and rock' }, + ]; + + it('accepts a sound check', () => { + expect(compositionProblems(withCheck(two, 0))).toEqual([]); + }); + + it('rejects an out-of-range answer and a one-option check', () => { + expect(compositionProblems(withCheck(two, 5))).toContainEqual(expect.stringContaining('answer index')); + expect(compositionProblems(withCheck(two.slice(0, 1), 0))).toContainEqual(expect.stringContaining('2+ options')); + }); +}); + +describe('Match and Hunt structural rules', () => { + const base = (extra: ComposedSpec['components'][number]): ComposedSpec => ({ + kind: 'composed', + learningComponentId: null, + title: 'Games', + components: [{ type: 'Group', id: 'root', children: ['g'] }, extra], + }); + + it('accepts sound games and rejects degenerate ones', () => { + expect(compositionProblems(base({ type: 'Match', id: 'g', prompt: 'p', pairs: [ + { left: '1/2', right: '2/4' }, { left: '1/3', right: '2/6' }] }))).toEqual([]); + expect(compositionProblems(base({ type: 'Match', id: 'g', prompt: 'p', pairs: [ + { left: 'a', right: 'b' }] }))).toContainEqual(expect.stringContaining('2+ pairs')); + expect(compositionProblems(base({ type: 'Hunt', id: 'g', prompt: 'p', items: [ + { text: 'a', target: true, feedback: 'f' }, { text: 'b', target: false, feedback: 'f' }, + { text: 'c', target: false, feedback: 'f' }] }))).toEqual([]); + expect(compositionProblems(base({ type: 'Hunt', id: 'g', prompt: 'p', items: [ + { text: 'a', target: true, feedback: 'f' }, { text: 'b', target: true, feedback: 'f' }, + { text: 'c', target: true, feedback: 'f' }] }))).toContainEqual(expect.stringContaining('decoy')); + }); +}); + +describe('Estimate and Model structural rules', () => { + const base = (extra: ComposedSpec['components'][number]): ComposedSpec => ({ + kind: 'composed', + learningComponentId: null, + title: 'Inputs', + components: [{ type: 'Group', id: 'root', children: ['x'] }, extra], + }); + + it('accepts sound inputs and rejects broken ones', () => { + expect(compositionProblems(base({ type: 'Estimate', id: 'x', prompt: 'p', min: 0, max: 100, unit: '%', actual: 71, feedback: 'f' }))).toEqual([]); + expect(compositionProblems(base({ type: 'Estimate', id: 'x', prompt: 'p', min: 0, max: 100, unit: null, actual: 150, feedback: 'f' }))).toContainEqual(expect.stringContaining('inside the range')); + expect(compositionProblems(base({ type: 'Estimate', id: 'x', prompt: 'p', min: 5, max: 5, unit: null, actual: 5, feedback: 'f' }))).toContainEqual(expect.stringContaining('min < max')); + + const model = (options: string[], outcomeKeys: string[]) => + base({ + type: 'Model', id: 'x', prompt: 'p', + variable: { name: 'denominator', options }, + outcomes: outcomeKeys.map((o) => ({ option: o, text: `with ${o} parts` })), + }); + expect(compositionProblems(model(['2', '4', '8'], ['2', '4', '8']))).toEqual([]); + expect(compositionProblems(model(['2', '4'], ['2']))).toContainEqual(expect.stringContaining('one authored outcome per option')); + expect(compositionProblems(model(['2', '4'], ['2', '8']))).toContainEqual(expect.stringContaining('one authored outcome per option')); + }); +}); + +describe('free-form state layer structural rules', () => { + const machine = (over: Partial): ComposedSpec => ({ + kind: 'composed', + learningComponentId: null, + title: 'Machine', + state: { pieces: { values: ['0', '1', '2'], initial: '0' } }, + components: [ + { type: 'Group', id: 'root', children: ['count', 'add', 'done-note'] }, + { type: 'Text', id: 'count', text: 'You have {pieces} pieces.', variant: null }, + { type: 'Action', id: 'add', label: 'Add a piece', onTap: [{ var: 'pieces', cycle: true }] }, + { type: 'Callout', id: 'done-note', intent: 'tip', label: 'Nice!', text: 'All there.', showWhen: { var: 'pieces', equals: '2' } }, + ], + ...over, + }); + + it('accepts a sound machine', () => { + expect(compositionProblems(machine({}))).toEqual([]); + }); + + it('rejects undeclared variables wherever referenced', () => { + expect(compositionProblems(machine({ state: { other: { values: ['a', 'b'], initial: 'a' } } }))) + .toEqual(expect.arrayContaining([ + expect.stringContaining('interpolates undeclared state variable "pieces"'), + expect.stringContaining('showWhen: undeclared state variable "pieces"'), + expect.stringContaining('onTap: undeclared state variable "pieces"'), + ])); + }); + + it('rejects a bad initial, an out-of-set value, and a set+cycle op', () => { + expect(compositionProblems(machine({ state: { pieces: { values: ['0', '1', '2'], initial: '9' } } }))) + .toContainEqual(expect.stringContaining('initial')); + const bad = machine({}); + (bad.components[3] as { showWhen: { var: string; equals: string } }).showWhen = { var: 'pieces', equals: '99' }; + expect(compositionProblems(bad)).toContainEqual(expect.stringContaining('not a declared value')); + const dual = machine({}); + (dual.components[2] as { onTap: { var: string; set?: string; cycle?: boolean }[] }).onTap = [{ var: 'pieces', set: '1', cycle: true }]; + expect(compositionProblems(dual)).toContainEqual(expect.stringContaining('exactly one of set or cycle')); + }); +}); diff --git a/src/lib/pathway/schema.ts b/src/lib/pathway/schema.ts index 299da79e..8ae9871b 100644 --- a/src/lib/pathway/schema.ts +++ b/src/lib/pathway/schema.ts @@ -754,7 +754,360 @@ export const writingWorkshopSpec = z.object({ export type WritingWorkshopSpec = z.infer; +// --- composed: an activity assembled from pedagogical primitives --------- + +/** + * The composed kind is the registry's generative escape hatch: instead of one + * fixed interaction, the model emits a flat tree of *pedagogical primitives* + * (issue #100 holds the vocabulary and its learning-science grounding) and + * human-written renderers draw it. Invariant 1 holds — this is data, validated + * here, never code. The v1 alphabet is deliberately verdict-free: nothing in + * it can measure, so a composed activity's `assesses` is false by + * construction (derived from the alphabet, not asserted). Verdict-carrying + * primitives (Check/Response) join only with the #99 evidence contract. + * + * Components reference each other by id in a flat list — the same shape A2UI + * surfaces use, so compositions project to the a2learn catalog verbatim. + */ + +const composedChildId = z.string().describe('The id of another component in this composition.'); + +/** + * The free-form tier: mechanics as data, never code. A composition may + * declare finite state (each variable an enumerated value list), any node + * may be conditionally visible on one of those values, an Action node + * mutates them, and Text interpolates them. The human-written interpreter + * is the only thing that executes — and because state is finite and + * declared, the structural gate can audit every reference, which is what + * separates a statechart from arbitrary code. Spelling follows A2UI's + * dataModel/actions semantics so free-form compositions project cleanly — + * and state is kept flat and JSON-Patch-friendly on purpose: AG-UI's + * STATE_SNAPSHOT/STATE_DELTA is the wire form for the day state is allowed + * to leave the widget. Local stays the default; sharing state through the + * AG-UI channel is an explicit opt-in that belongs to the #99 contract, + * same as every other value that crosses the boundary. + */ +const composedShowWhen = z + .object({ + var: z.string().describe('A declared state variable.'), + equals: z.string().describe('Show this component only while the variable holds this value.'), + }) + .nullish(); + +const composedStateOp = z.object({ + var: z.string().describe('A declared state variable.'), + set: z.string().nullish().describe('Set the variable to this declared value.'), + cycle: z.boolean().nullish().describe('Advance the variable to its next declared value, wrapping.'), +}); + +const composedAction = z.object({ + type: z.literal('Action'), + id: z.string(), + label: z.string().describe('Button label, sentence case.'), + onTap: z.array(composedStateOp).describe('1-3 state changes applied in order.'), + showWhen: composedShowWhen, +}); + + +const composedText = z.object({ + type: z.literal('Text'), + id: z.string(), + text: z.string().describe('Markdown body text. Bold key terms; no headings, links, or images.'), + variant: z + .enum(['body', 'caption']) + .nullable() + .describe('"caption" renders small and muted; null means body.'), + showWhen: composedShowWhen, +}); + +const composedCallout = z.object({ + type: z.literal('Callout'), + id: z.string(), + intent: z + .enum(['why', 'tip', 'note']) + .describe('"why" for reasoning behind an idea (elaborative), "tip" for strategy advice, "note" for anything else.'), + label: z.string().describe('Short bold lead-in, e.g. "Why?" or "Tip:".'), + text: z.string().describe('One or two sentences of markdown.'), + showWhen: composedShowWhen, +}); + +const composedGroup = z.object({ + type: z.literal('Group'), + id: z.string(), + children: z.array(composedChildId).describe('2–6 child ids, rendered top to bottom.'), + showWhen: composedShowWhen, +}); + +const composedReveal = z.object({ + type: z.literal('Reveal'), + id: z.string(), + faces: z + .array(z.object({ title: z.string(), child: composedChildId })) + .describe('Exactly 2 faces: front first, back second. The student taps to turn it over — put the prompt on the front and the payoff on the back.'), + showWhen: composedShowWhen, +}); + +const composedSequence = z.object({ + type: z.literal('Sequence'), + id: z.string(), + policy: z.object({ + order: z.enum(['linear', 'free']).describe('"linear" when items build on each other; "free" when they can be browsed.'), + disclosure: z.enum(['gated', 'all']).describe('"gated" holds each item back until the student advances — use for walkthroughs.'), + revealed: z.enum(['accumulate', 'replace']).describe('"accumulate" keeps passed items visible (worked examples); "replace" shows one at a time (decks).'), + }), + children: z.array(composedChildId).describe('2–8 item ids, in teaching order.'), + showWhen: composedShowWhen, +}); + +const composedCheck = z.object({ + type: z.literal('Check'), + id: z.string(), + prompt: z.string().describe('The question, in markdown. One concrete thing to retrieve or apply.'), + options: z + .array( + z.object({ + text: z.string().describe('The option as the student sees it.'), + feedback: z + .string() + .describe('Shown immediately when this option is picked — why it is or is not the one, one sentence.'), + }), + ) + .describe('2-4 options, exactly one of them right.'), + answer: z.number().int().describe('Zero-based index of the right option.'), + showWhen: composedShowWhen, +}); + +/** + * Check is deliberately *local*: feedback renders in the widget and nothing + * leaves it — no verdict, no event, no evidence. It exists because retrieval + * beats rereading (the learner checks themself), not because anyone is + * measured; `assesses` on a composed activity stays false with Check in the + * alphabet. An answer that should count as evidence is the Response + * primitive, which waits for the #99 contract. + */ + +const composedMatch = z.object({ + type: z.literal('Match'), + id: z.string(), + prompt: z.string().describe('What matching means here, e.g. "Match each term to its meaning."'), + pairs: z + .array(z.object({ left: z.string(), right: z.string() })) + .describe('2-6 pairs. Left items show in order; right items are shuffled by the renderer.'), + showWhen: composedShowWhen, +}); + +const composedHunt = z.object({ + type: z.literal('Hunt'), + id: z.string(), + prompt: z.string().describe('What to find, e.g. "Tap every fraction equivalent to 1/2."'), + items: z + .array( + z.object({ + text: z.string(), + target: z.boolean().describe('True if this is one of the things to find.'), + feedback: z.string().describe('Shown when tapped — why it is or is not one, one sentence.'), + }), + ) + .describe('4-9 items, mixing targets and near-miss decoys. At least one of each.'), + showWhen: composedShowWhen, +}); + +/** + * Match and Hunt are the stateful tier of the local alphabet: real game + * mechanics (selection state, progress, lockout on success) implemented + * entirely renderer-side. Like Check, nothing leaves the component — they + * exist for retrieval and discrimination practice, not measurement, and a + * composed activity's `assesses` stays false with them in play. + */ + +const composedEstimate = z.object({ + type: z.literal('Estimate'), + id: z.string(), + prompt: z.string().describe('What to estimate, e.g. "What fraction of Earth\'s water is drinkable?"'), + min: z.number().describe('Slider minimum.'), + max: z.number().describe('Slider maximum.'), + unit: z.string().nullable().describe('Display unit, e.g. "%", "years", null for none.'), + actual: z.number().describe('The real value, revealed after the student commits.'), + feedback: z.string().describe('One sentence of context shown at the reveal.'), + showWhen: composedShowWhen, +}); + +const composedModel = z.object({ + type: z.literal('Model'), + id: z.string(), + prompt: z.string().describe('What the knob explores, e.g. "What happens as the denominator grows?"'), + variable: z.object({ + name: z.string().describe('The variable the student sets, e.g. "denominator".'), + options: z.array(z.string()).describe('2-6 values in order. All-numeric values render as a slider; labels render as chips.'), + }), + outcomes: z + .array(z.object({ option: z.string(), text: z.string() })) + .describe('One outcome per option, same values: the markdown shown when that value is selected. You must author every outcome — the renderer never computes.'), + showWhen: composedShowWhen, +}); + +/** + * The two honest input roles (issue #100 round 4): Estimate is a *commit* + * input — the value exists to be locked in before a reveal, compared by + * juxtaposition, never judged, never transmitted. Model is a *parameter* + * input — the value configures which authored content shows. The model + * authors outcomes as data, never formulas: nothing model-written executes. + * The third role, inputs whose values leave the widget, is Response (#99). + */ + +const composedComponent = z.discriminatedUnion('type', [ + composedText, + composedCheck, + composedMatch, + composedHunt, + composedEstimate, + composedModel, + composedAction, + composedCallout, + composedGroup, + composedReveal, + composedSequence, +]); +export type ComposedComponent = z.infer; + +export const composedSpec = z.object({ + kind: z.literal('composed'), + learningComponentId: z.string().nullable(), + title: z.string().describe('Short student-facing activity title.'), + components: z + .array(composedComponent) + .describe('Flat component list. Exactly one component must have id "root" — usually a Group or Sequence that references the rest. 3–20 components.'), + state: z + .record( + z.string(), + z.object({ + values: z.array(z.string()).describe('2-8 enumerated values this variable can hold.'), + initial: z.string().describe('The starting value — must be one of values.'), + }), + ) + .nullish() + .describe('Optional finite state for free-form mechanics. Every value a variable can hold is declared here; Actions move between them, showWhen reads them, Text interpolates them as {varName}.'), +}); +export type ComposedSpec = z.infer; + +/** + * Structural rules the union cannot express (discriminated-union members must + * stay plain objects, and providers never see refinements anyway). The + * generator runs this before accepting a composition; the registry entry's + * schema runs it again at render time. + */ +export function compositionProblems(spec: ComposedSpec): string[] { + const problems: string[] = []; + const ids = new Set(); + for (const c of spec.components) { + if (ids.has(c.id)) problems.push(`duplicate component id: ${c.id}`); + ids.add(c.id); + } + if (!ids.has('root')) problems.push('no component with id "root"'); + + const childRefs = (c: ComposedComponent): string[] => + c.type === 'Group' || c.type === 'Sequence' + ? c.children + : c.type === 'Reveal' + ? c.faces.map((f) => f.child) + : []; + + for (const c of spec.components) { + for (const ref of childRefs(c)) { + if (!ids.has(ref)) problems.push(`${c.id} references missing component: ${ref}`); + if (ref === c.id) problems.push(`${c.id} references itself`); + } + } + if (spec.components.some((c) => c.type === 'Reveal' && c.faces.length !== 2)) { + problems.push('a Reveal must have exactly 2 faces'); + } + for (const c of spec.components) { + if (c.type === 'Check' && (c.options.length < 2 || c.answer < 0 || c.answer >= c.options.length)) { + problems.push(`${c.id}: a Check needs 2+ options and an answer index inside them`); + } + if (c.type === 'Match' && c.pairs.length < 2) { + problems.push(`${c.id}: a Match needs 2+ pairs`); + } + if ( + c.type === 'Hunt' && + (c.items.length < 3 || !c.items.some((i) => i.target) || !c.items.some((i) => !i.target)) + ) { + problems.push(`${c.id}: a Hunt needs 3+ items with at least one target and one decoy`); + } + if (c.type === 'Estimate' && (c.min >= c.max || c.actual < c.min || c.actual > c.max)) { + problems.push(`${c.id}: an Estimate needs min < max with the actual inside the range`); + } + if (c.type === 'Model') { + const opts = c.variable.options; + const outcomeKeys = c.outcomes.map((o) => o.option); + if ( + opts.length < 2 || + opts.length !== outcomeKeys.length || + !opts.every((o) => outcomeKeys.includes(o)) + ) { + problems.push(`${c.id}: a Model needs 2+ options with exactly one authored outcome per option`); + } + } + } + + const stateDefs = spec.state ?? {}; + for (const [name, def] of Object.entries(stateDefs)) { + if (def.values.length < 2 || def.values.length > 8) problems.push(`state ${name}: declare 2-8 values`); + if (!def.values.includes(def.initial)) problems.push(`state ${name}: initial "${def.initial}" is not a declared value`); + } + const varOk = (name: string, value?: string | null) => { + const def = stateDefs[name]; + if (!def) return `undeclared state variable "${name}"`; + if (value != null && !def.values.includes(value)) return `"${value}" is not a declared value of "${name}"`; + return null; + }; + for (const c of spec.components) { + if (c.showWhen) { + const err = varOk(c.showWhen.var, c.showWhen.equals); + if (err) problems.push(`${c.id} showWhen: ${err}`); + } + if (c.type === 'Action') { + if (c.onTap.length === 0) problems.push(`${c.id}: an Action needs at least one state change`); + for (const op of c.onTap) { + const hasSet = op.set != null; + const hasCycle = op.cycle === true; + if (hasSet === hasCycle) problems.push(`${c.id}: each state change is exactly one of set or cycle`); + const err = varOk(op.var, op.set ?? undefined); + if (err) problems.push(`${c.id} onTap: ${err}`); + } + } + if ((c.type === 'Text' || c.type === 'Callout') && Object.keys(stateDefs).length > 0) { + for (const m of c.text.matchAll(/\{([a-zA-Z][a-zA-Z0-9_]*)\}/g)) { + if (!stateDefs[m[1]]) problems.push(`${c.id}: interpolates undeclared state variable "${m[1]}"`); + } + } + } + + // Cycle check: walk from root; a composition is a tree, not a graph. + const byId = new Map(spec.components.map((c) => [c.id, c])); + const visiting = new Set(); + const walk = (id: string): boolean => { + if (visiting.has(id)) return false; + visiting.add(id); + const node = byId.get(id); + const ok = !node || childRefs(node).every(walk); + visiting.delete(id); + return ok; + }; + if (ids.has('root') && !walk('root')) problems.push('composition contains a reference cycle'); + + return problems; +} + +/** The strict form — for the registry entry, where refinements are allowed. */ +export const composedSpecStrict = composedSpec.superRefine((spec, ctx) => { + for (const problem of compositionProblems(spec)) { + ctx.addIssue({ code: 'custom', message: problem }); + } +}); + export const widgetSpec = z.discriminatedUnion('kind', [ + composedSpec, debateAiSpec, writingWorkshopSpec, fractionAreaModelSpec, @@ -783,6 +1136,7 @@ export const learningOutcome = z.object({ /** The widget kinds the registry can render. Keep in step with `widgetSpec`. */ export const widgetKind = z.enum([ + 'composed', 'fraction-area-model', 'swiper-flashcard', 'draft-meter', diff --git a/src/lib/structured.test.ts b/src/lib/structured.test.ts new file mode 100644 index 00000000..b2741c57 --- /dev/null +++ b/src/lib/structured.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { salvageFencedOutput } from '@/lib/structured'; + +const schema = z.object({ answer: z.string() }); + +describe('salvageFencedOutput', () => { + it('recovers valid JSON from a markdown fence', async () => { + const error = { text: '```json\n{ "answer": "yes" }\n```' }; + expect(await salvageFencedOutput(schema, error)).toEqual({ answer: 'yes' }); + }); + + it('recovers from a bare fence without a language tag', async () => { + const error = { text: '```\n{ "answer": "yes" }\n```' }; + expect(await salvageFencedOutput(schema, error)).toEqual({ answer: 'yes' }); + }); + + it('never loosens validation — fenced but invalid stays rejected', async () => { + expect(await salvageFencedOutput(schema, { text: '```json\n{ "wrong": 1 }\n```' })).toBeNull(); + expect(await salvageFencedOutput(schema, { text: '```json\nnot json\n```' })).toBeNull(); + }); + + it('ignores errors without fenced text', async () => { + expect(await salvageFencedOutput(schema, { text: '{ "answer": "yes" }' })).toBeNull(); + expect(await salvageFencedOutput(schema, new Error('boom'))).toBeNull(); + }); + + it('ignores text with prose around the fence', async () => { + const error = { text: 'Here is your JSON:\n```json\n{ "answer": "yes" }\n```\nHope that helps!' }; + expect(await salvageFencedOutput(schema, error)).toBeNull(); + }); +}); diff --git a/src/lib/structured.ts b/src/lib/structured.ts index 008b0ee4..1d04b38a 100644 --- a/src/lib/structured.ts +++ b/src/lib/structured.ts @@ -1,4 +1,4 @@ -import { generateText, streamText, Output } from 'ai'; +import { asSchema, generateText, streamText, Output } from 'ai'; import type { FlexibleSchema, LanguageModel } from 'ai'; import { fallbackModel, pathwayModel } from '@/lib/model'; @@ -60,11 +60,41 @@ export async function generateStructured(options: StructuredOptions): Prom try { return (await callModel(primary, options)).output; } catch (error) { + // Long structured outputs sometimes arrive wrapped in a markdown fence — + // valid JSON the SDK refuses to parse. Salvaging deterministically beats + // burning a cross-vendor retry on formatting. + const salvaged = await salvageFencedOutput(options.schema, error); + if (salvaged !== null) return salvaged; + const fallback = fallbackModel(); if (!fallback) throw error; console.error('[model] primary provider failed, retrying once on the OpenAI fallback:', error); - return (await callModel(fallback, options)).output; + try { + return (await callModel(fallback, options)).output; + } catch (fallbackError) { + const salvagedFallback = await salvageFencedOutput(options.schema, fallbackError); + if (salvagedFallback !== null) return salvagedFallback; + throw fallbackError; + } + } +} + +/** + * The raw text of a failed parse, unfenced and re-validated against the real + * schema. Returns null unless the result is exactly a valid instance — this + * never loosens what counts as acceptable output, only where the JSON sits. + */ +export async function salvageFencedOutput(schema: FlexibleSchema, error: unknown): Promise { + const text = (error as { text?: unknown })?.text; + if (typeof text !== 'string') return null; + const match = text.match(/^\s*```(?:json)?\s*([\s\S]*?)\s*```\s*$/); + if (!match) return null; + try { + const result = await asSchema(schema).validate?.(JSON.parse(match[1])); + return result?.success ? result.value : null; + } catch { + return null; } } diff --git a/src/lib/widgets/builtins.generate.ts b/src/lib/widgets/builtins.generate.ts index e2728824..fe7a9256 100644 --- a/src/lib/widgets/builtins.generate.ts +++ b/src/lib/widgets/builtins.generate.ts @@ -3,6 +3,7 @@ * transitively; only ever imported by `pathway/generate.ts`, never by a * client component. Import `./builtins` (not this file) for rendering. */ +import '@/lib/widgets/definitions/composed.generate'; import '@/lib/widgets/definitions/fraction-area-model.generate'; import '@/lib/widgets/definitions/swiper-flashcard.generate'; import '@/lib/widgets/definitions/draft-meter.generate'; diff --git a/src/lib/widgets/builtins.ts b/src/lib/widgets/builtins.ts index 56c61556..c7d96511 100644 --- a/src/lib/widgets/builtins.ts +++ b/src/lib/widgets/builtins.ts @@ -7,6 +7,7 @@ * `builtins.generate.ts`. The `widgetSpec` union and `widgetKind` enum in * `pathway/schema.ts` still need it too — see CONTRIBUTING.md for the full list. */ +import '@/lib/widgets/definitions/composed'; import '@/lib/widgets/definitions/fraction-area-model'; import '@/lib/widgets/definitions/swiper-flashcard'; import '@/lib/widgets/definitions/draft-meter'; diff --git a/src/lib/widgets/definitions/composed.generate.ts b/src/lib/widgets/definitions/composed.generate.ts new file mode 100644 index 00000000..e855b0d0 --- /dev/null +++ b/src/lib/widgets/definitions/composed.generate.ts @@ -0,0 +1,247 @@ +import { z } from 'zod'; + +import { composedSpec, compositionProblems, type ComposedSpec } from '@/lib/pathway/schema'; +import { generateStructured } from '@/lib/structured'; +import { registerWidgetGenerator } from '@/lib/widgets/types'; + +/** + * The model authors only what is genuinely its to author: a title and the + * component tree. The envelope (kind, learning component binding) is the + * generator's job, and field-name drift gets one deterministic + * canonicalization pass — models reliably produce the right *structure* here + * while wobbling on names (`body` for `text`, flat policy fields). What + * canonicalization never does is invent content or repair references: a tree + * the model cannot structure is a tree the student should not receive, so + * the strict schema and `compositionProblems` gate after the rename pass. + */ +/** + * One permissive node shape advertising the superset of every component's + * fields — the serialized schema is what the model actually obeys (a loose + * object serializes as bare {id, type} and the model dutifully emits exactly + * that), so every field a node might need must be visible here. The strict + * discriminated union in `composedSpec` is enforced after canonicalization. + */ +const authoringNode = z.object({ + id: z.string().describe('Short lowercase slug, unique in the list.'), + type: z.string().describe('One of: Text, Callout, Group, Reveal, Sequence.'), + text: z.string().nullish().describe('Text and Callout: the markdown content.'), + variant: z.string().nullish().describe('Text only: "caption" or null.'), + intent: z.string().nullish().describe('Callout only: "why", "tip", or "note".'), + label: z.string().nullish().describe('Callout only: short bold lead-in like "Why?".'), + children: z.array(z.string()).nullish().describe('Group and Sequence: ids of the children, in order.'), + faces: z + .array(z.object({ title: z.string(), child: z.string() })) + .nullish() + .describe('Reveal only: exactly two faces, front then back.'), + prompt: z.string().nullish().describe('Check only: the question, markdown.'), + options: z + .array(z.object({ text: z.string(), feedback: z.string() })) + .nullish() + .describe('Check only: 2-4 options, each with one-sentence feedback.'), + answer: z.number().nullish().describe('Check only: zero-based index of the right option.'), + pairs: z + .array(z.object({ left: z.string(), right: z.string() })) + .nullish() + .describe('Match only: 2-6 pairs to connect.'), + items: z + .array(z.object({ text: z.string(), target: z.boolean(), feedback: z.string() })) + .nullish() + .describe('Hunt only: 4-9 items, targets plus near-miss decoys, each with feedback.'), + min: z.number().nullish().describe('Estimate only: slider minimum.'), + max: z.number().nullish().describe('Estimate only: slider maximum.'), + unit: z.string().nullish().describe('Estimate only: display unit or null.'), + actual: z.number().nullish().describe('Estimate only: the real value, inside [min, max].'), + feedback: z.string().nullish().describe('Estimate only: one sentence of context for the reveal.'), + variable: z + .object({ name: z.string(), options: z.array(z.string()) }) + .nullish() + .describe('Model only: the knob — name plus 2-6 ordered values.'), + outcomes: z + .array(z.object({ option: z.string(), text: z.string() })) + .nullish() + .describe('Model only: exactly one markdown outcome per option value.'), + onTap: z + .array(z.object({ var: z.string(), set: z.string().nullish(), cycle: z.boolean().nullish() })) + .nullish() + .describe('Action only: 1-3 state changes — each sets a declared value or cycles to the next.'), + showWhen: z + .object({ var: z.string(), equals: z.string() }) + .nullish() + .describe('Any component: show only while a declared state variable holds this value.'), + policy: z + .object({ + order: z.string().describe('"linear" or "free"'), + disclosure: z.string().describe('"gated" or "all"'), + revealed: z.string().describe('"accumulate" or "replace"'), + }) + .nullish() + .describe('Sequence only: how the children are traversed.'), +}); + +const composedAuthoring = z.object({ + title: z.string().nullish().describe('Short student-facing activity title.'), + state: z + .record(z.string(), z.object({ values: z.array(z.string()), initial: z.string() })) + .nullish() + .describe('Optional finite state for free-form mechanics: variable name -> {values: 2-8 declared values, initial}.'), + components: z + .array(authoringNode) + .describe('Flat component list. Exactly one component must have id "root". 3–20 components. Set fields that do not apply to a node type to null.'), +}); + +type LooseNode = { id: string; type: string } & Partial> & + Record; + +const str = (value: unknown): string | undefined => (typeof value === 'string' ? value : undefined); + +/** One node, canonical field names — synonyms mapped, nothing invented. */ +export function canonicalizeNode(node: LooseNode): Record { + const canonical = canonicalizeCore(node); + if (node.showWhen && typeof node.showWhen === 'object') canonical.showWhen = node.showWhen; + return canonical; +} + +function canonicalizeCore(node: LooseNode): Record { + const text = str(node.text) ?? str(node.body) ?? str(node.content) ?? str(node.markdown); + + switch (node.type) { + case 'Text': + return { + type: 'Text', + id: node.id, + text: text ?? '', + variant: node.variant === 'caption' ? 'caption' : null, + }; + case 'Callout': { + const intent = ['why', 'tip', 'note'].includes(str(node.intent) ?? '') ? node.intent : 'note'; + return { type: 'Callout', id: node.id, intent, label: str(node.label) ?? '', text: text ?? '' }; + } + case 'Check': + return { + type: 'Check', + id: node.id, + prompt: str(node.prompt) ?? text ?? '', + options: Array.isArray(node.options) ? node.options : [], + answer: typeof node.answer === 'number' ? node.answer : -1, + }; + case 'Match': + return { type: 'Match', id: node.id, prompt: str(node.prompt) ?? text ?? '', pairs: Array.isArray(node.pairs) ? node.pairs : [] }; + case 'Estimate': + return { + type: 'Estimate', + id: node.id, + prompt: str(node.prompt) ?? text ?? '', + min: typeof node.min === 'number' ? node.min : 0, + max: typeof node.max === 'number' ? node.max : 0, + unit: str(node.unit) ?? null, + actual: typeof node.actual === 'number' ? node.actual : (typeof node.value === 'number' ? node.value : NaN), + feedback: str(node.feedback) ?? '', + }; + case 'Model': + return { + type: 'Model', + id: node.id, + prompt: str(node.prompt) ?? text ?? '', + variable: node.variable ?? { name: '', options: [] }, + outcomes: Array.isArray(node.outcomes) ? node.outcomes : [], + }; + case 'Hunt': + return { type: 'Hunt', id: node.id, prompt: str(node.prompt) ?? text ?? '', items: Array.isArray(node.items) ? node.items : [] }; + case 'Action': + return { + type: 'Action', + id: node.id, + label: str(node.label) ?? text ?? '', + onTap: Array.isArray(node.onTap) ? node.onTap : [], + }; + case 'Group': + return { type: 'Group', id: node.id, children: Array.isArray(node.children) ? node.children : [] }; + case 'Reveal': { + // Accept the {front, back} shorthand models reach for. + const faces = + Array.isArray(node.faces) && node.faces.length > 0 + ? node.faces + : str(node.front) && str(node.back) + ? [ + { title: 'Front', child: node.front }, + { title: 'Back', child: node.back }, + ] + : []; + return { type: 'Reveal', id: node.id, faces }; + } + case 'Sequence': { + const source = (node.policy && typeof node.policy === 'object' ? node.policy : node) as Record< + string, + unknown + >; + return { + type: 'Sequence', + id: node.id, + policy: { order: source.order, disclosure: source.disclosure, revealed: source.revealed }, + children: Array.isArray(node.children) ? node.children : [], + }; + } + default: + // Unknown types pass through untouched; the strict parse rejects them. + return node; + } +} + +registerWidgetGenerator({ + kind: 'composed', + async generate(ctx) { + const authored = await generateStructured({ + schema: composedAuthoring, + system: [ + 'You compose a short learning activity from pedagogical building blocks.', + 'Output a single JSON object with exactly two top-level keys: "title" (short student-facing activity title) and "components". No markdown fences, no commentary.', + 'Component types, each an object with "type" and "id" fields, referenced by id in a flat list:', + 'Text {text: markdown, variant: "caption" or null}; Callout {intent: "why"|"tip"|"note", label, text} — an emphasized box: "why" for the reasoning behind an idea, "tip" for strategy;', + 'Check {prompt, options: 2-4 of {text, feedback}, answer: index of the right one} — a self-check with instant feedback; nothing is recorded, it exists so the student retrieves instead of rereads;', + 'Match {prompt, pairs: 2-6 of {left, right}} — a matching game: left column in order, right column shuffled, student pairs them up with live progress;', + 'Hunt {prompt, items: 4-9 of {text, target, feedback}} — a find-them-all game: tap every target among near-miss decoys, instant feedback per tap;', + 'Estimate {prompt, min, max, unit, actual, feedback} — the student commits a guess on a slider BEFORE seeing the real value, then sees both side by side; committing first is what makes the reveal stick;', + 'Model {prompt, variable: {name, options: 2-6 ordered values}, outcomes: one {option, text} per value} — an explanation with a knob: the student sets the variable and watches your authored outcome change. Author every outcome yourself; numeric options render as a slider;', + 'Group {children: 2–6 ids, stacked top to bottom}; Reveal {faces: exactly two of {title, child} — front then back; the student taps to turn it over: prompt on the front, payoff on the back};', + 'Sequence {policy: {order: "linear"|"free", disclosure: "gated"|"all", revealed: "accumulate"|"replace"}, children: 2–8 ids in teaching order}.', + 'Pedagogy: retrieval beats rereading — mix interaction types: Check for one concrete question, Match for term-meaning or equivalent-pairs practice, Hunt for telling examples from non-examples, Reveal (prediction front, payoff back) over long explanations.', + 'Sequence(linear, gated, accumulate) for walkthroughs where steps build on each other; Sequence(free, all, replace) for browsable card decks;', + 'a "why" Callout wherever reasoning deserves emphasis. Keep each Text under ~60 words — interaction density over prose density.', + 'Free-form mechanics, when no component above fits the interaction you want: declare finite state at the top level (state: {varName: {values, initial}}),', + 'then use Action {label, onTap: [{var, set: value} or {var, cycle: true}]} to change it, showWhen: {var, equals} on any component to conditionally show it,', + 'and {varName} inside Text to display the current value. Every value a variable can ever hold must be declared — design the mechanic as a small state machine.', + 'Reach for the named components first; go free-form only for a mechanic they cannot express.', + 'Structure rules: exactly one component has id "root" (usually a Group or Sequence); every referenced id exists in the list;', + 'ids are short lowercase slugs; 3–20 components total. Compose for the topic and audience you are given — do not imitate a quiz; nothing here measures.', + ].join(' '), + prompt: ctx.prompt, + }); + + const parsed = composedSpec.safeParse({ + kind: 'composed', + learningComponentId: ctx.plan.outcomes[ctx.step.outcomeIndex]?.learningComponentId ?? null, + // The plan step's own title is the honest fallback when the model + // fixates on the tree and forgets to name the activity. + title: authored.title?.trim() || ctx.step.title, + components: authored.components.map(canonicalizeNode), + ...(authored.state ? { state: authored.state } : {}), + }); + + const widget = parsed.success ? normalize(parsed.data) : null; + if (widget) return { widget, note: null }; + + return { + widget: null, + note: 'The composed activity had structural problems — built a fallback activity for this step instead.', + }; + }, +}); + +function normalize(spec: ComposedSpec): ComposedSpec | null { + // Drop components with blank ids, then require a sound tree — no repair + // beyond that: a composition the model cannot structure is a composition + // the student should not receive. + const components = spec.components.filter((c) => c.id.trim()); + const cleaned = { ...spec, components }; + return compositionProblems(cleaned).length === 0 ? cleaned : null; +} diff --git a/src/lib/widgets/definitions/composed.ts b/src/lib/widgets/definitions/composed.ts new file mode 100644 index 00000000..54102b69 --- /dev/null +++ b/src/lib/widgets/definitions/composed.ts @@ -0,0 +1,18 @@ +import { lazy } from 'react'; +import { composedSpecStrict, type ComposedSpec } from '@/lib/pathway/schema'; +import { registerWidgetCatalog } from '@/lib/widgets/types'; + +registerWidgetCatalog({ + kind: 'composed', + schema: composedSpecStrict, + // Derived, not asserted: the composed alphabet (Sequence, Reveal, Callout, + // Text) contains nothing that measures, so a composition cannot assess by + // construction. When verdict-carrying primitives join the alphabet (#100 + // phase 3, gated on #99), this becomes a per-composition derivation. + assesses: false, + component: lazy(() => + import('@/components/widgets/Composed').then((m) => ({ default: m.Composed })), + ), + plannerDescription: + 'Composes a bespoke reading-and-reflection activity from pedagogical building blocks — gated walkthroughs, tap-to-reveal cards, why/tip callouts — when no single fixed activity shape fits the need. Explains and explores; does not measure.', +});