Headless QTI runtime core (MIT) + Mantine skin
Status: accepted
The QTI runtime is split into two layers. A headless core — interaction state, the response-processing interpreter, the content-tree allowlist walk, and unstyled slotted primitives — lives in conform-ed (MIT) with no Mantine dependency. A Mantine skin — presentational components and the Native Extensions that are emergent’s differentiators — lives in emergent (closed) and plugs into the core.
- Interop, research, and a stated intent to potentially open-source mean the standards-conformant runtime should not be born locked inside the closed product. conform-ed is already ours, MIT, and published, so the reusable value lives there.
- Mirrors how robust component libraries are built (headless logic + styled layer): the core is reusable and themeable; the skin carries product polish and the moat.
Considered and rejected
Section titled “Considered and rejected”- Monolithic Mantine renderer in emergent, extract later. Faster start, but extracting a headless core out of Mantine-coupled code afterwards is real work and tends not to happen.
- Whole renderer (core + default skin) in conform-ed. Maximal reuse but emergent loses bespoke control over per-interaction markup. Note (2026-06): conform-ed later added an unstyled, semantic-HTML reference skin (conform-ed ADR-0001) so interactions can be developed and conformance-tested without emergent. That is not this rejected option: it is deliberately not a product UI, and emergent keeps full bespoke control via its Mantine skin.
Consequences
Section titled “Consequences”- The headless core must never import Mantine or emergent private packages.
The core↔skin contract (resolved)
Section titled “The core↔skin contract (resolved)”The item body is a data tree of mixed flow content with inline interactions, some of
extension kinds the core never knew at build time. That forces a registry: the
core walks the tree and dispatches each node by kind to a registered skin
component. (The QTI skin occupies the Surface Layout activity Slot; surrounding
chrome — audio, progress, hints — are Surface Widgets, not the skin’s concern.)
- Plug mechanism. Registry-dispatched controlled components. Each skin
component receives a controlled props bundle by default; an opt-in
useInteractionState(id)hook is the escape hatch for the minority (complex DnD, audio-record) needing finer subscription or imperative control. The core owns response/outcome state, memoizes per-node prop slices, and supplies an a11y baseline; the skin owns ephemeral presentation state (hover, playing). - Composition. Skin components render the whole interaction (full layout
freedom — inline-blank cloze framing, per-option audio) but spread core
prop-getters (
getOptionProps(id),getGapProps(id)) that wire selection, roving tabindex, and ARIA, so keyboard/a11y is centralized without taking structural control. Body flow content renders as semantic HTML by default; the skin may register element overrides formath(KaTeX),ruby(furigana), andmedia. - Extension registration. A factory —
createQtiRuntime({ interactions, skin })→{ ItemRenderer, TestRenderer, useAttempt }(echoing thecreateSupabase…bindings convention). The runtime’s kind-union is exactly the injected descriptor set — no global registry, no module augmentation. An interaction descriptor splits into a pure-logic runtime descriptor (kind, zod schema, JSON↔XML serializer, scoring) which imports no React/Mantine and so may live in emergent, and a skin descriptor (component, authoring form). conform-ed shipsqtiCoreInteractions; emergent assembles core + extensions with full type inference. - Extension scoring & serialization. Interop-preserving by default: an extension
expresses its result as standard QTI response variables where it can (normal RP,
clean round-trip);
client-custom(pure scorer fn) andexternal(server/machine/ human) are explicit opt-outs. Extensions serialize to a foreign-namespaced element (e.g.<emergent:audio-record>), not PCI; the round-trip CI gate keeps core-only items untouched and flags extension items as emergent-only on export. - Authoring & delivery projection. The
AuthoringFormedits the full item definition (including the scoring key —correctResponse, mapping, feedback); the deliveryComponentconsumes a projection. So the runtime descriptor exposesproject(definition, { scoringPolicy })→ delivery shape: key present for client-scored/practice, stripped for secure/server-scored (ADR-0003). The delivery skin is reused three ways — live delivery, authoring live-preview, and the in-editor interaction NodeView — each fed the appropriate projection. - Packaging. Headless core as a new
@conform-ed/qti-react(deps: react,@conform-ed/contracts,@conform-ed/qti-xml). Skin lives in emergentpackages/ui(Mantine already there), consumed by learner/teacher/admin.