Skip to content

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.
  • 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.
  • The headless core must never import Mantine or emergent private packages.

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 for math (KaTeX), ruby (furigana), and media.
  • Extension registration. A factory — createQtiRuntime({ interactions, skin }){ ItemRenderer, TestRenderer, useAttempt } (echoing the createSupabase… 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 ships qtiCoreInteractions; 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) and external (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 AuthoringForm edits the full item definition (including the scoring key — correctResponse, mapping, feedback); the delivery Component consumes a projection. So the runtime descriptor exposes project(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 emergent packages/ui (Mantine already there), consumed by learner/teacher/admin.