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).
Target outcome
Section titled “Target outcome”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.
Slices
Section titled “Slices”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 ontypeacross 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
schemaVersionclassified 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_payloadjsonb, audit/LWW) plus thelayout_kinddiscriminator (screen= FK-referenced, matcher NULL, unlimited published;preset= matcher-resolved) and matcher columnsdelivery_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_assignmentfilter 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 withexternal_package_assignmentis 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.SurfaceContextwith 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).
S5 — Chrome widget catalog
Section titled “S5 — Chrome widget catalog”instructions,audio-player,hint,progress,word-card,submit,next,streak,level-badge— wrapping existingpackages/uidomain components where they exist; control widgets callsessioncapability functions and honourisBusy.- All copy via new
LL.surface.*entries (en only;zh-Hansleft to the translation flow).
S6 — Navigation widgets
Section titled “S6 — Navigation widgets”unit-menu,activity-rail(orientation prop),breadcrumb,offering-header,dashboard-tiles, bound to thestructurefacet (tree from syncedlearning_unit,currentUnitId,goToUnit()).- Optional completion decoration from locally synced progress signals only; absent data → plain buttons.
S7 — Embeddable hosts + viewport
Section titled “S7 — Embeddable hosts + viewport”- 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.
viewportwidget dispatches by assignment kind; unextracted kinds (LTI, survey, if deferred mid-slice) render an “open in full view” link rather than breaking.
S8 — Learner-web experience routes
Section titled “S8 — Learner-web experience routes”/experience/$offeringId(dashboard screen) and/experience/$offeringId/$unitId(activity screen); selection is URL state.- Posture wiring:
linkedentries on the offering surface,defaultlanding redirect,exclusivevanilla-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.
S9 — Admin-console authoring feature
Section titled “S9 — Admin-console authoring feature”- 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.
S10 — End-to-end lane (Playwright)
Section titled “S10 — End-to-end lane (Playwright)”- Seed a Planet-English-shaped offering (grouping tree + cmi5 external-package assignments,
drizzle-seed), attach an
exclusiveexperience; 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.
Sequencing notes
Section titled “Sequencing notes”- 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.