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 translatederrormessages 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@latestper 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/tabswith<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 theTabsroot so tab content (including tables/forms) follows the same width. - Add
className="mt-4"to eachTabsContentto preserve spacing between the tab list and panels. - Keep settings labels in i18n and preserve stable
data-testidvalues for row actions, tab triggers, and tab panels. - Preserve URL/query behavior for server-rendered tab pages, especially hidden
tabinputs, 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/5tabs-root width contract is for admin settings surfaces; learner pages already constrained byPageContent(variant="learner"/max-w-3xl) should keep tab roots atw-fullwithout 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
headerhierarchy node (main-nav.tsx). - Footer columns — children (and grandchildren) of the
footernode (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
blogmarker page →/blog/\[slug\] docs→/docs/(static Zensical site published topublic/docs/; not a CMS page body).MainNavrenders an external anchor (target="_blank",rel="noopener noreferrer") with label from i18nnav.docsand a LucideExternalLinkicon — same contract as CMSshared-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
adminnode, 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 |