Skip to content

Surface Experience v1 — build plan

Design authority: ADR-0050 and the glossary terms in CONTEXT.md → “Surface layout and subject packs”. Architecture background: surface-layout-and-subject-packs.md. This plan turns the settled decisions into ordered, independently-landable slices (TDD per slice; bun run validate green before each commit).

An admin can paste validated layout JSON into the admin console, compose a Surface Experience (dashboard + activity screens), attach it to an offering with a posture (linked / default / exclusive), and a learner or projecting teacher gets the bespoke interface — e.g. the Planet English shape: dashboard over 6×4 unit groupings, activity screen with margin rails and a cmi5 viewport at ~85–90% — with the vanilla LMS untouched alongside it.

S1 — Domain contract (packages/domain/src/surface-layout.ts)

Section titled “S1 — Domain contract (packages/domain/src/surface-layout.ts)”
  • zod v4 schemas: document envelope (schemaVersion: "1.0", self-describing), named slots as a discriminated slot-kind union (kind: "widget-list" only in v1), widget instances as a discriminated union on type across the 15 catalog widgets (each member owns its static props schema).
  • Per-delivery-mode widget policy table + validateAgainstPolicy(document, modesCovered); screen-role enum (dashboard | activity); attachment-posture enum.
  • Derived JSON Schema artifact via z.toJSONSchema() (build script output, gitignored or checked in next to the schema — for editor autocomplete only, never consumed at runtime).
  • Pure unit tests: envelope round-trips, unknown widget type rejected, policy violations rejected, unknown schemaVersion classified as invalid-for-render.

S2 — DB schema + sync registry (packages/db)

Section titled “S2 — DB schema + sync registry (packages/db)”
  • surface_layout: assessment_definition-style row (state draft|published|retired, version_no, schema_version, document_payload jsonb, audit/LWW) plus the layout_kind discriminator (screen = FK-referenced, matcher NULL, unlimited published; preset = matcher-resolved) and matcher columns delivery_mode / activity_kind (nullable) with the NULL-folding partial unique index over published presets (DDL now; resolution is F1). The kind column is load-bearing: without it the determinism index allows only one published matcher-less (screen) layout platform-wide.
  • surface_experience, surface_experience_screen (unique(experience, role), FK → surface_layout), surface_experience_attachment (offering FK, posture; partial unique on offering where posture ∈ {default, exclusive}).
  • Sync entries: learner read-only (published gate + membership reach through attachment → offering_membership, following the external_package_assignment filter pattern); admin read-write. Tier-①/② row filters only.
  • Shared reach-filter builder in packages/db: one tier-② helper expressing the “published + attached-to-a-membership-offering” reach that the three new entries use (existing entries may migrate opportunistically, out of scope here). The commonality with external_package_assignment is filter grammar only — see the ADR-0050 revisit-trigger note rejecting the internal-package remodel.
  • Regenerated migrations per the runbook (Anton applies); drizzle-seed fixtures that import the S1 schema so an invalid preset cannot seed.

S3 — Admin write rail (apps/api mutation expanders)

Section titled “S3 — Admin write rail (apps/api mutation expanders)”
  • Expanders for layout create/update/publish and experience/screen/attachment mutations: server-side re-validation (S1 zod + widget policy) rejecting fail-closed. Policy scope is per layout_kind: a preset must satisfy the widget policy for every delivery mode its matcher covers (NULL matcher = all modes, fail-closed); a screen is shape-validated only (mode policy is the renderer’s job at render time, per ADR-0050 decision 4/5), and a screen row carrying matcher values is rejected outright.
  • Real-PG integration lane tests: reject invalid JSON, reject policy violation, publish happy path, attachment posture uniqueness violations surface as clean errors.

S4 — Renderer core (packages/ui/src/surface/)

Section titled “S4 — Renderer core (packages/ui/src/surface/)”
  • SurfaceRenderer(document, context): named-slot responsive grid (renderer-owned breakpoints/reading order), widget dispatch over the catalog union.
  • SurfaceContext with optional facets (activity, session, learner, structure); facet-requirement declarations per widget; single logged drop-rail (missing facet, policy deny, unknown-at-runtime type).
  • Fallback handling: invalid document / unknown schemaVersion → structured “unrenderable” result the host maps to vanilla fallback (fail-open access, ADR-0050 §8).
  • bun test + happy-dom component tests (the a47552a render-harness conventions).
  • instructions, audio-player, hint, progress, word-card, submit, next, streak, level-badge — wrapping existing packages/ui domain components where they exist; control widgets call session capability functions and honour isBusy.
  • All copy via new LL.surface.* entries (en only; zh-Hans left to the translation flow).
  • unit-menu, activity-rail (orientation prop), breadcrumb, offering-header, dashboard-tiles, bound to the structure facet (tree from synced learning_unit, currentUnitId, goToUnit()).
  • Optional completion decoration from locally synced progress signals only; absent data → plain buttons.
  • Extract per-kind route bodies into embeddable host components; routes re-consume them (no behavior change): external-package (cmi5 launch + sandboxed iframe, ADR-0032 proxy), assessment players, web-content, media, documents.
  • viewport widget dispatches by assignment kind; unextracted kinds (LTI, survey, if deferred mid-slice) render an “open in full view” link rather than breaking.
  • /experience/$offeringId (dashboard screen) and /experience/$offeringId/$unitId (activity screen); selection is URL state.
  • Posture wiring: linked entries on the offering surface, default landing redirect, exclusive vanilla-route redirect into the experience, all-exclusive login landing; broken-screen fail-open to vanilla with loud logging.
  • Teachers enter through existing offering membership; no separate rendering.
  • Layout paste-JSON editor with client-side zod feedback + live preview (same SurfaceRenderer, mock facets); experience composition (screens ← published layouts) and attachment management (offering, posture) on the admin sync rail.
  • Seed a Planet-English-shaped offering (grouping tree + cmi5 external-package assignments, drizzle-seed), attach an exclusive experience; verify: login lands in the dashboard, rail navigation switches activities, viewport launches the cmi5 AU, vanilla routes redirect, broken layout fails open.

F1 — (follow-up, explicitly not v1) preset resolution layer

Section titled “F1 — (follow-up, explicitly not v1) preset resolution layer”
  • Pure resolution function over the matcher columns (most-specific-wins; DDL already guarantees no ties) + first consumer wiring. Blocked on a real consumer appearing.
  • S1 → S2 → S3 are strictly ordered; S4–S6 depend only on S1; S7 is independent of S2–S6; S8 needs S2+S4+S6+S7; S9 needs S3+S4; S10 needs S8 (+S9 for the authoring path).
  • Migrations regenerate (never hand-edited); DB apply steps are Anton’s.
  • Anton’s separate session on a Mantine v9 QTI-3 subset concerns the Skin (activity internals) — orthogonal to this lane by the layering rule; no coordination point except the activity/assessment host boundary in S7.