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 (
+
+ );
+ }
+ // 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 (
+
+ );
+}
+
+/**
+ * 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 (
+
+ {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 (
+
+ 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 (
+
+ );
+}
+
+/** 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 (
+