Skip to content

Learning Management System

Technology Stack

See Tech Stack for LMS vs shared vs CMS-only dependencies. Cross-app ticket: T-027 CMS vs LMS Drift.

UI conventions

Public and learner surfaces use shadcn/ui (new-york, neutral) with primitives from @open-learning-hub/ui (packages/ui). Keep learner-facing components aligned with CMS preview where types overlap — see T-027 CMS vs LMS Drift and apps/cms/.cursor/rules/cms-system.mdc for the authoring-side shadcn delta list.

  • Schema-backed forms (auth, account, admin CRUD) use @open-learning-hub/ui/form (Form / FormField / FormItem / FormLabel / FormControl / FormMessage) with React Hook Form, matching CMS admin/auth. Build client Zod schemas with translated error messages where field copy is i18n. Search/method="get" filters, widget editors, quiz player, and switch-only patches stay outside this pattern.
  • Composition and registry workflow: .agents/skills/shadcn/SKILL.md (use npx shadcn@latest per repo).
  • Upstream component reference: ui.shadcn.com/docs.
  • TypeScript / file layout: 030-coding-best-practices.mdc.
  • Canonical list rendering/search/paging contract: 055-data-listing-patterns.mdc.
  • Row-level action menus: menu-actions.md.

Admin settings tab surfaces

Admin settings pages such as user settings and course settings should share one tabbed layout contract:

  • Use @open-learning-hub/ui/tabs with <TabsList variant="line" className="w-full"> so the active tab is underlined and the tab list spans the constrained shell.
  • Center the entire tabs block, not just the tab strip: put className="mx-auto w-full md:w-3/5" on the Tabs root so tab content (including tables/forms) follows the same width.
  • Add className="mt-4" to each TabsContent to preserve spacing between the tab list and panels.
  • Keep settings labels in i18n and preserve stable data-testid values for row actions, tab triggers, and tab panels.
  • Preserve URL/query behavior for server-rendered tab pages, especially hidden tab inputs, pagination query params, and search filters.
  • When these layout classes are part of the UX contract, assert them in focused component/e2e tests.
  • Scope note: this md:w-3/5 tabs-root width contract is for admin settings surfaces; learner pages already constrained by PageContent (variant="learner" / max-w-3xl) should keep tab roots at w-full without adding a second width cap (see /learn/dashboard).

The standalone 040-design-system.mdc file is a short pointer; substantive UI guidance lives here and in the references above.

App chrome width: Admin and course entry routes use full-width main shell (MainSiteShell / isAppChromePath). All other routes use the centered 90% SiteShell. See toggleable-navigation.md.

Public/learner page spacing: Wrap route bodies in PageContent (pt-2 below the site header). Do not add per-page py-12 / py-16 on new public or /learn routes (admin, auth, and course entry use their own shells). Marketing CMS pages (CmsPageSurface): horizontal inset is owned by SiteShell px-6 only, while widgets keep their authored appearance behavior (default padded when appearance.padding is missing/true, flush only when explicitly false). displayTitle toggles the hero heading only—not layout gutters.

Icons

See the repo-wide rule 035-icons-lucide.mdc (canonical; do not duplicate policy here).

Hierarchical Design

See LMS Hierarchy Structure for more details.

User Management & Access

See User Management & Access for personas, RBAC, permission matrix, and access flows.

Tenant Configuration

See Tenant Configuration for feature flags, custom domains, tenant suspension, and super-admin activation flows.

Course Configuration

See Course Configuration for per-course feature flags (course_settings, sequential gating) and admin settings surfaces.

Analytics

See Analytics for dashboard query shapes, source tables, and caching behaviour.

Quiz System

See Quiz System for more detail.

Course Enrollment System

See Course Enrollment System for more detail.

Entry Pagination System

See Entry Pagination System for more detail.

Toggleable Navigation

See Toggleable Navigation for more detail.

Implementation Plan

See Implementation Plan for the phased build plan.

Site configuration project

The CMS project identified by CMS_PROJECT_UUID in .env.local is not a course — it owns the LMS site itself: navigation, the home page, About-style pages, and the blog.

It is excluded from /projects results client-side in getPublishedCourses (src/lib/cms/client.ts), so it never appears in the course catalogue at /learn. The home-page featured section also inherits this exclusion because it resolves featured slugs from LMS course_settings and then filters CMS results from getPublishedCourses.

Header/Footer chrome

The LMS discovers Header and Footer as ordinary CMS pages by slug (header, footer) in the site project hierarchy. Their page bodies are not rendered as public routes (header and footer are reserved slugs alongside home, learn, blog, and similar markers).

Chrome metadata comes from named widgets on those pages, matched by widget title (case-insensitive) in src/lib/cms/site-chrome.ts:

Widget title Page Widget type LMS consumer
Site Name Header text SiteHeader, layout metadata
favicon Header image-asset /api/favicon (rewrite from /favicon.ico)
Copyright Footer text SiteFooter

Navigation structure is separate from these widgets:

  • Main nav — children of the header hierarchy node (main-nav.tsx).
  • Footer columns — children (and grandchildren) of the footer node (site-footer.tsx).

Missing or misnamed chrome widgets fall back to i18n defaults (site name, copyright) or a static favicon. There is no CMS validation for the widget title contract. Author-facing steps live in apps/cms/docs/guides/create-your-lms-site.md (Configure Header And Footer Chrome).

Favicon is implemented today via the Header favicon widget, not a separate CMS site document. Discovery surfaces (/sitemap.xml, /robots.txt, /.well-known/change-password, llms.txt, llms-full.txt, and scoped .md variants) are implemented and documented in docs/reference/seo-and-discovery.md. PWA manifest/icons and additional well-known files continue to ship in follow-up work.

URL routing from page slug + acronym markers

Each page-hierarchy node carries two routing-relevant fields from the CMS:

  • slug: non-editable, title-derived URL slug. LMS uses this for public page routing.
  • acronym: editable short code. LMS uses this for structural markers (home, blog, learn, docs, admin), not for regular URL generation.

The slug maps to LMS routes as follows:

  • page with acronym home/ (locale root)
  • top-level non-marker pages → /\[slug\]
  • page with acronym blog/blog
  • page with acronym learn/learn (renders the authored Learn page / course-catalog surface)
  • children of the blog marker page → /blog/\[slug\]
  • docs/docs/ (static Zensical site published to public/docs/; not a CMS page body). MainNav renders an external anchor (target="_blank", rel="noopener noreferrer") with label from i18n nav.docs and a Lucide ExternalLink icon — same contract as CMS shared-site-header-nav-docs.
  • admin → navigation marker only; LMS maps to /admin (not a CMS-authored marketing page), and the top-nav link is rendered only for admin roles (tenant_admin, course_admin, super_admin).

@open-learning-hub/widget-wire-schemas/src/site-contract.ts is the canonical cross-app source for structural route mapping and chrome marker constants. src/components/site/nav-href.ts and route handlers consume that shared contract.

Page bodies are fetched via getPageById (which hits /projects/{CMS_PROJECT_UUID}/pages/{pageGuid}). The page-hierarchy node carries the GUID — look up normal pages by slug with findHierarchyNode(hierarchy, slug), and structural marker pages by acronym with findStructuralNode(hierarchy, marker), then call getPageById(node.id, …). findStructuralNode intentionally scopes lookup to top-level pages and Header children so footer aliases cannot hijack root/blog/learn marker resolution. The navigation tree itself comes from getPageHierarchy. docs and admin hierarchy nodes are ordering/visibility hooks only — do not fetch or render their CMS page content on the public site.

The reserved-slug list in src/app/\[locale\]/\[slug\]/page.tsx (home, learn, blog, admin, docs, header, footer, invite, _suspended, api) prevents the generic \[slug\] route from shadowing the dedicated routes above. Locale-prefixed doc paths (/{locale}/docs/...) redirect to /docs/... in src/proxy.ts (locale-neutral static site).

Display text from the page title

The top navigator (src/components/site/main-nav-view.tsx) renders node.title as the link label for each entry except docs and admin, which use nav.docs and nav.admin from i18n. Never use slug or acronym as display text.

Admin nav entry

The LMS site header shows data-testid="main-nav-link-admin" only when both conditions are true:

  • the CMS site hierarchy includes a top-level admin node, and
  • the logged-in user has one of the admin roles (tenant_admin, course_admin, super_admin).

When shown, the link targets /admin and follows standard active-link semantics (aria-current="page" on /admin and nested admin routes).

Routes

ID Route URL Description Auth
1 Website visitors N
1.1 src/app/\[locale\]/page.tsx / Website home page. N
1.2 src/app/\[locale\]/\[slug\]/page.tsx /\[slug\] Generic CMS page. N
1.3 src/app/\[locale\]/blog/page.tsx /blog Blog index. N
1.4 src/app/\[locale\]/blog/\[slug\]/page.tsx /blog/\[slug\] Blog article / post. N
2 LMS > Students
2.1 src/app/\[locale\]/learn/page.tsx /learn Course catalogue. N
2.2 src/app/\[locale\]/learn/sign-in/page.tsx /learn/sign-in Sign-in form. N
2.2a src/app/\[locale\]/learn/sign-out/page.tsx /learn/sign-out Branded sign-out confirmation; unauthenticated users redirect home. N
2.3 src/app/\[locale\]/learn/sign-up/page.tsx /learn/sign-up Account creation form. N
2.4 src/app/\[locale\]/learn/verify/\[token\]/page.tsx /learn/verify/\[token\] Email verification. N
2.5 src/app/\[locale\]/learn/forgot/page.tsx /learn/forgot Forgot-password request. N
2.6 src/app/\[locale\]/learn/reset/\[token\]/page.tsx /learn/reset/\[token\] Password reset. N
2.6a src/app/\[locale\]/learn/magic/page.tsx /learn/magic Magic-link sign-in request (tenant magicLink flag). N
2.6b src/app/\[locale\]/learn/magic/\[token\]/page.tsx /learn/magic/\[token\] Magic-link consume → session. N
2.7 src/app/\[locale\]/learn/dashboard/page.tsx /learn/dashboard Student dashboard (enrolled courses, progress). Y (NextAuth)
2.8 src/app/\[locale\]/learn/course/\[slug\]/enroll/page.tsx /learn/course/\[slug\]/enroll Enroll in the course. Planned (Phase 6). Y (NextAuth)
2.9 src/app/\[locale\]/learn/course/\[slug\]/page.tsx /learn/course/\[slug\] Public course landing today; post-enrol home in Phase 6. N today / Y after Phase 6
2.10 src/app/\[locale\]/learn/course/\[slug\]/entry/\[entrySlug\]/page.tsx /learn/course/\[slug\]/entry/\[entrySlug\] Lesson / quiz / assignment player. Planned (Phase 7). Y (NextAuth)
3 LMS > Admin
3.1 src/app/\[locale\]/admin/page.tsx /admin Tenant admin dashboard (users, enrolments). Y (tenant_admin / super_admin)
3.2 src/app/\[locale\]/admin/users/page.tsx /admin/users Tenant-scoped user listing and filtering. Y (tenant_admin / super_admin)
3.3 src/app/\[locale\]/admin/users/invite/page.tsx /admin/users/invite Invite form for student/course/tenant admin roles. Y (tenant_admin / super_admin)
3.4 src/app/\[locale\]/admin/users/invitations/page.tsx /admin/users/invitations Pending invitation management (list/search/resend/revoke). Y (tenant_admin / super_admin)
3.5 src/app/\[locale\]/admin/courses/\[slug\]/dashboard/page.tsx /admin/courses/\[slug\]/dashboard Course-level analytics & cohort management. Y (course_admin / tenant_admin / super_admin)
3.6 src/app/\[locale\]/admin/tenants/page.tsx /admin/tenants Tenant management. Y (super_admin)
3.7 src/app/\[locale\]/admin/api-docs/page.tsx /admin/api-docs Swagger / OpenAPI UI. Y (tenant_admin / super_admin)
4 API route handlers
4.1 src/app/api/auth/\[...nextauth\]/route.ts /api/auth/\[...nextauth\] NextAuth credentials & session callbacks (GET, POST). Internal
4.2 src/app/api/favicon/route.ts /api/favicon Per-tenant dynamic favicon (GET). N
4.3 src/app/api/well-known/security/route.ts /.well-known/security.txt RFC 9116 security disclosure. N
5 App-level conventions (Next.js file conventions, not /api/*)
5.1 src/app/sitemap.ts /sitemap.xml Per-tenant sitemap of published discovery URLs. N
5.2 src/app/robots.ts /robots.txt Per-tenant crawler rules + sitemap reference. N
5.3 src/app/manifest.ts /manifest.webmanifest PWA manifest from CMS site doc. N
5.4 src/app/icon.tsx /icon.png PWA / browser icon (192, 512). N
5.5 src/app/apple-icon.tsx /apple-icon.png iOS home-screen icon (180×180). N
5.6 src/app/opengraph-image.tsx /opengraph-image Default Open Graph card (1200×630). N
5.7 src/app/twitter-image.tsx /twitter-image Default Twitter card (1200×600). N
5.8 src/app/llms.txt/route.ts /llms.txt AI/discovery URL index (default locale, public only). N
5.9 src/app/llms-full.txt/route.ts /llms-full.txt AI/discovery markdown corpus (rate-limited, public only). N
5.10 src/app/api/aeo/markdown/[...path]/route.ts /*.md (rewritten) Scoped markdown projections for published public pages. N