@instructure/platform-foundation-lms
v10.0.0
Published
A minimal learning shell that showcases foundational Platform UI components. Ships a side navigation bar with tabs for each integrated showcase (dashboard, modules, notebook, tags, study assist, block editor). The Widget Dashboard, Modules, Notebook, Stud
Maintainers
Keywords
Readme
@instructure/platform-foundation-lms
A minimal learning shell that showcases foundational Platform UI components. Ships a side navigation bar with tabs for each integrated showcase (dashboard, modules, notebook, tags, study assist, block editor). The Widget Dashboard, Modules, Notebook, Study Assist and Tags screens are wired up; the remaining screen (block editor) is being migrated from the canvas-horizon Foundation app in follow-up releases.
Scope. This package is a standalone application shell — it expects to be the root of a page and owns full-height layout, tab-mode state,
FoundationShellContext, and its own translations catalog. Do not import<FoundationLms>(or any of its screens) inside another package, and do not include it in another package's Storybook. Storybook stories live inside the underlying component packages (@instructure/platform-widget-dashboard,@instructure/platform-notebook, …) — consume those directly if you need the components in a different context. Treating this shell as a reusable component leads to duplicate<PlatformUiProvider>/<InstUISettingsProvider>trees, tab-state collisions with the outer host's routing, and stripped-margin layout bugs from the shell fighting a smaller container for height.
Install
pnpm add @instructure/platform-foundation-lmsPeer dependencies (all granular — no @instructure/ui barrel):
react^18.0.0react-dom^18.0.0— required byplatform-modules@instructure/platform-institutional-taggingworkspace:*@instructure/platform-modulesworkspace:*—<ModulesScreen>; a static import in the shell, so every host loads it even if the Modules tab is never opened@instructure/platform-notebookworkspace:*@instructure/platform-providerworkspace:*— foundation-LMS's own picker callsusePlatformUi().executeQuery, so it declares this rather than treating it purely as a "host installs above" transitive@instructure/platform-sanitizeworkspace:*—<NotebookScreen>runs the host-supplied page body throughsanitizeHtmlbefore inserting it viadangerouslySetInnerHTML@instructure/platform-study-assistworkspace:*@instructure/platform-widget-dashboardworkspace:*@instructure/ui-a11y-content^11.0.0@instructure/ui-alerts^11.0.0— not imported directly; reached throughplatform-modules(see "modules' peers" below)@instructure/ui-buttons^11.0.0@instructure/ui-flex^11.0.0@instructure/ui-heading^11.0.0@instructure/ui-icons^11.0.0@instructure/ui-menu^11.0.0— viaplatform-modules@instructure/ui-pill^11.0.0— viaplatform-modules@instructure/ui-progress^11.0.0— viaplatform-modules@instructure/ui-side-nav-bar^11.0.0@instructure/ui-spinner^11.0.0@instructure/ui-table^11.0.0— not imported directly; required because widget-dashboard's bundle inlinesplatform-grades, which imports it (see "ui-table gotcha" below)@instructure/ui-text^11.0.0@instructure/ui-text-input^11.0.0— viaplatform-modules@instructure/ui-view^11.0.0@tanstack/react-query^5.0.0— the picker usesuseQuerydirectlygraphql^16.0.0—graphql-tag's runtime importsparsefrom heregraphql-tag^2.12.0— the picker's GraphQL query is agqltemplatezod^3.23.8— not imported directly;platform-modulesvalidates adapter output against its schemas at runtime, so this is a hard import in itsdist
Hosts also need @instructure/platform-provider (installed above <FoundationLms>) and everything it requires — see "Transitive peers" below.
Transitive peers.
@instructure/platform-widget-dashboarddeclares its own peer set (~30 InstUI packages — buttons, modal, tabs, select, etc., plusplatform-instui-bindings,platform-rce-adapter,graphql-tag, and more).@instructure/platform-institutional-taggingdeclares another ~28 InstUI peers plusplatform-instui-bindings(seepackages/institutional-tagging/package.json); most overlap widget-dashboard's set, butui-drilldown,ui-tag, andui-truncate-textare tagging-specific. Consumers must satisfy both sets. Foundation-lms deliberately does not re-declare them so this package's contract doesn't drift every time an upstream peer moves — see the linkedpackage.jsonfiles for the authoritative lists.
@instructure/ui-tablegotcha. Widget-dashboard's builtdist/index.jsinlines@instructure/platform-grades, and grades importsui-table. Because widget-dashboard doesn't externalizeplatform-grades, the ui-table import ends up resolved against widget-dashboard's own directory at runtime. Foundation-lms declaresui-tableas a peer (see list above) so hoisted / flat installers (Canvas under yarn, or any consumer with a flatnode_modules) get an install-time signal and a resolvable copy. Under pnpm-strict the peer declaration isn't enough — widget-dashboard still can't reach into foundation-lms'snode_modules— so pnpm-strict consumers hit the runtime failure until the upstream fix lands (externalizingplatform-gradesinpackages/widget_dashboard/vite.config.ts). This package's test suite uses a local stub to bypass the same pnpm-strict resolution failure — seevitest.config.ts.
@instructure/platform-modules' peers. Unlike widget-dashboard's and tagging's sets, these are not left to the host.<ModulesScreen>is a static import inFoundationLms.tsx, so modules is in the module graph of every consumer whether or not the Modules tab is ever opened — an unmet peer is a load-time failure for the whole shell, not a broken tab.zodin particular is a top-level runtime import in modules'dist. Foundation-lms therefore declares the six modules reaches that nothing else here pulls in (ui-alerts,ui-menu,ui-pill,ui-progress,ui-text-input,zod) plusreact-dom, on the same reasoning as theui-tableentry above. The rest of modules' set overlaps peers already declared here.
The host is responsible for providing an InstUISettingsProvider above <FoundationLms /> (via @instructure/platform-instui-bindings or @instructure/emotion directly). The package deliberately does not install a theme so it can't override a host that has opted into canvasHighContrast or a brand theme.
Usage
Foundation-LMS is a plain shell — the host is expected to install <PlatformUiProvider> (and <InstUISettingsProvider>) above it. Every embedded screen reads executeQuery, notify, queryClient, permissions, locale, and timezone from that outer provider via usePlatformUi(); foundation-lms itself only manages the sidebar, mode state, and shell translations.
import { FoundationLms } from '@instructure/platform-foundation-lms'
import { PlatformUiProvider } from '@instructure/platform-provider'
import { platformExecuteQuery } from '@canvas/graphql'
export const App = () => (
<PlatformUiProvider
executeQuery={platformExecuteQuery}
currentUserId={ENV.current_user.id}
locale={ENV.LOCALE}
timezone={ENV.TIMEZONE}
>
<FoundationLms currentUser={ENV.current_user} />
</PlatformUiProvider>
)Canvas hosts typically consolidate the <PlatformUiProvider> + <TranslationsProvider> + <ColorPickerTranslationsProvider> wrap in a single <PlatformBridge> component (see ui/features/widget_dashboard/react/platformBridge.tsx) and drop <FoundationLms> inside it. Foundation-lms doesn't reach for anything Canvas-specific — the shape of that outer wrap is entirely the host's decision.
Required prop
currentUser: { id, display_name, avatar_image_url }— signed-in user, published onFoundationShellContextso embedded screens can render display names and avatars without the host re-forwarding through per-screen*Props. Canvas hosts passENV.current_userdirectly. The outer<PlatformUiProvider>should carry the same user's.idascurrentUserId.
Shell props
courseId?: string— course the shell is scoped to, published onFoundationShellContextfor descendants. Optional at the shell level because dashboard-only hosts don't need it; required to use the notebook tab — its picker callscourse(id).pagesConnectionviaexecuteQuery, and without a course id the screen renders a "missing course context" fallback rather than throwing. Canvas hosts on a course-scoped route passENV.COURSE_ID. Temporary — this prop only exists because there is no course-picker inside the shell yet. Once a/courses/:idroute lives in this package and the user picks a course inside<FoundationLms>itself,courseIdmoves out of the shell's public API and into internal state. Expect it to disappear in a future minor.initialMode?: FoundationMode— uncontrolled starting tab. Ignored whenmodeis provided. Defaults to'dashboard'.mode?: FoundationMode— controlled active tab. When set, the component defers to the host for tab state so a route like/foundation/notebookcan drive selection.onModeChange?: (mode: FoundationMode) => void— fires on every tab click (both controlled and uncontrolled). Use to persist the change (e.g. push a route).translations?: Partial<FoundationLmsTranslations>— override any subset of rendered strings; unspecified keys fall back to the shipped English defaults.homeHref?: string— when set, the home item renders as a link pointing at this URL.onHomeClick?: () => void— when set, the home item is clickable and invokes this handler. When neitherhomeHrefnoronHomeClickis given, the home item is hidden — the shell never touches host URL state on its own.homeIcon?: React.ReactNode— defaults to<IconHomeLine />from@instructure/ui-icons. Canvas hosts can pass<IconCanvasLogoLine />to brand it.dashboardProps?: DashboardScreenProps— forwarded to the embedded<DashboardScreen />when the'dashboard'tab is active. See the Widget Dashboard screen section for the full contract.notebookProps?: NotebookTabProps— forwarded to the embedded<NotebookScreen />when the'notebook'tab is active. Omit to render the placeholder for that tab. RequiresnotebookApi+loadPageBody; see the Notebook screen section. Also carries a requiredcanUseNotebook: booleangate — the tab is only rendered when this is explicitlytrue; anything else (includingfalse) hides the notebook tab from the sidebar entirely and normalizes any controlledmode="notebook"(or aninitialModerestored from a URL) to"dashboard". Kept required rather than defaulting totrueso a host wiringnotebookPropscannot forget to check permissions and accidentally expose the feature. Consumer derives the value from a permission + feature-flag check (ENV.COURSE_ROLES.can(...) && ENV.FEATURES.foundation_notebook).tagsProps?: TagsTabProps— forwarded to the embedded<TagsScreen />when the'tags'tab is active. Two-level gating, same asnotebookProps: omit to render a placeholder body when the tab is clicked (the sidebar entry stays visible — that's the "wired later" state, not fail-closed); pass withcanUseTags: falseto hide the sidebar entry entirely and normalize any controlledmode="tags"back to"dashboard". OnlycanUseTags: trueactually renders the tags UI. Fine-grained create / edit permissions inside the tags UI are read from<PlatformUiProvider>by<AccountTags>internally — see the Tags screen section.studyAssistProps?: StudyAssistTabProps— forwarded to the embedded<StudyAssistScreen />when the'study'tab is active. Same three-state gating as notebook / tags. RequiresfetchAssistResponse+listPagesinside; carries a requiredcanUseStudyAssist: boolean. See the Study Assist screen section.modulesProps?: ModulesScreenProps— forwarded to the embedded<ModulesScreen />when the'modules'tab is active. Optional in full:courseIdfalls back to the shell's, and when neither resolves the screen renders its own missing-course fallback rather than the shared "not yet migrated" placeholder. See the Modules screen section for the full contract.
// Controlled example (e.g. driven by a router)
<FoundationLms
currentUser={ENV.current_user}
mode={currentTab}
onModeChange={(next) => navigate(`/foundation/${next}`)}
homeHref="/"
/>Translations
The shipped English strings live in locales/en.json and are the source of truth — the FoundationLmsTranslations type is derived from the JSON's keys, so any drift breaks the build.
Hosts pass pre-resolved overrides via the translations prop. Anything you omit falls back to the shipped English default:
import { FoundationLms } from '@instructure/platform-foundation-lms'
<FoundationLms
currentUser={ENV.current_user}
translations={{
dashboardLabel: I18n.t('foundation.dashboard'),
notebookLabel: I18n.t('foundation.notebook'),
tagsLabel: I18n.t('foundation.tags'),
studyAssistLabel: I18n.t('foundation.studyAssist'),
blockEditorLabel: I18n.t('foundation.blockEditor'),
}}
/>There is no runtime translator prop on the shell — foundation-lms has a few dozen strings and hosts using i18next (or any other translator) resolve them once at mount time. Simpler than plumbing a t function through context.
The shell's translations must be an own-enumerable plain object. The merge here goes through mergeStrings (Object.keys(overrides)), which reports empty on a get-only Proxy — so a Proxy handed to the shell is silently dropped and every label renders English. This is different from <DashboardScreen />'s translations, which accepts a lazy Proxy as a first-class shape. That set is a low bar to pre-resolve; the asymmetry keeps the shell's implementation simple.
Descendants read the merged catalog via useFoundationLmsTranslations():
const t = useFoundationLmsTranslations()
return <Text>{t.dashboardLabel}</Text>FoundationLmsTranslationsProvider and PlaceholderScreen are both exported for hosts that want to render foundation-lms components outside a <FoundationLms /> shell. The provider has no effect when mounted above <FoundationLms /> — the shell installs its own provider using the translations prop, and that inner provider shadows any ancestor one.
Read the shipped JSON directly (e.g. as a base for machine translation) via @instructure/platform-foundation-lms/locales/en.json.
<DashboardScreen />has its own translation catalog (@instructure/platform-widget-dashboard'sWidgetDashboardTranslations, ~400 keys). Override any subset viadashboardProps.translations. Hosts with an i18next-backed pipeline pair that with adashboardProps.translatedispatcher — widget-dashboard's internal code needs a translator function to handle interpolated strings correctly. See the Widget Dashboard screen section for the full contract.
Hoist
dashboardPropsand any inlinetranslationsmap out of the render. Two separate wins: (1) a stabledashboardPropsidentity feedsWidgetDashboardProvider's memoized context value — that provider does memoize, so pinning identity there avoids invalidating dozens of downstream consumers on every render. (2) A stabletranslationsidentity keeps<DashboardScreen />from re-allocating its overlay Proxy each render. What hoisting does not buy:useTranslations()consumers.platform-widget-dashboard'sTranslationsProviderbuilds its value inline with no memo, so those descendants re-render every DashboardScreen render regardless of whether the Proxy identity is pinned. (The ~400-key base spread is at module scope, so re-mounting doesn't re-pay it either.)
Shell context
<FoundationLms /> publishes its currentUser on a small React context so embedded screens don't need it re-forwarded through per-screen *Props. Screens rendered standalone (outside the shell) fall back to their own defaults; screens rendered inside pick it up automatically.
import {
FoundationShellProvider,
useFoundationShell,
} from '@instructure/platform-foundation-lms'
// inside a screen component
const shell = useFoundationShell() // { currentUser } | nullScreen chrome
Every screen renders inside a single <View as="main" display="block" padding="large x-large x-large x-large" maxWidth="1366px"> that lives on <FoundationLms />'s content Flex.Item — so the whole shell has one <main> landmark (nested <main> is invalid HTML), a uniform outer padding, and a 1366px readable-width cap. Screens return body content only.
Rules of thumb for anyone adding a new screen:
- Do not wrap your screen in
<View as="main">— the shell already does it. Return<View as="section">(or a plain component root) instead. - Do not repeat the outer padding.
padding="large x-large x-large x-large"is the shell's job. Padding inside your content (card padding, form padding, list padding) is your job. - Cap narrower widths when your layout benefits. The picker inside
<NotebookScreen />usesmaxWidth="720px"; the placeholder body uses45rem. Those narrower caps sit inside the shell's 1366px and win. Do not raisemaxWidthabove 1366px — override the shell if you legitimately need a wider screen, don't sneak past the cap. - Error / empty states inherit the same chrome. No need for a per-fallback
<View padding>block; a bare<View as="section">sits inside the outer wrapper and looks right.
Widget Dashboard screen
The 'dashboard' tab mounts <DashboardScreen />, which composes the four widget-dashboard providers (WidgetDashboardProvider, WidgetDashboardEditProvider, WidgetLayoutProvider, TranslationsProvider) around either the learner or educator container. The wiring mirrors what canvas-lms uses in production (see canvas-lms/ui/features/widget_dashboard/index.tsx).
<DashboardScreen /> is exported from the package root so hosts can render it standalone (e.g. in Storybook) or through the <FoundationLms /> shell via dashboardProps.
import { FoundationLms } from '@instructure/platform-foundation-lms'
// Assumes an ancestor <PlatformUiProvider> supplies executeQuery, notify, queryClient, etc.
<FoundationLms
currentUser={ENV.current_user}
dashboardProps={{
// currentUser is inherited from the shell — you don't need to repeat it.
sharedCourseData,
dashboardFeatures: { widget_dashboard_dark_mode: true, educator_dashboard: true },
// dashboardType is 'educator' when educator_dashboard is on, and undefined
// otherwise (the 'student' label is stripped upstream). Route on undefined
// vs 'educator' — dropping the argument would let one experience overwrite
// the other's stored layout.
onSaveLayout: async (config, dashboardType) => {
await saveDashboardPreferences(config, dashboardType)
},
}}
/>Key props (see the exported DashboardScreenProps for the full list):
currentUser?— inherited from the shell'scurrentUserprop by default; pass an explicit override here only for preview / Storybook use cases.dashboardFeatures?.educator_dashboard— set totrueto mount<EducatorWidgetDashboardContainer />instead of the learner container. Defaults to the learner container.currentUserRoles?,preferences?,sharedCourseData?,dashboardFeatures?,observedUsersList?,observedUserId?,canAddObservee?— direct pass-through toWidgetDashboardProvider. Everything is optional; sensible defaults keep the screen renderable without a live backend.customWidgets?: WidgetRegistry— host-supplied widget renderers, merged over the built-ins.renderCoursesTab?: () => React.ReactNode— Canvas injects its dashboard-card-backed component here; non-Canvas hosts can omit it.rcsConfig?: RceConfig— enables the RCE inside the educator announcement widget; without it that widget falls back to a plain textarea.onSaveLayout?: (config: WidgetConfig, dashboardType?: DashboardExperience) => Promise<void>— persistence hook called after every layout edit. Foundation-LMS installs an explicit no-op when this prop is omitted, because widget-dashboard's built-in fallback (useWidgetDashboardEdit.tsx) would otherwise fire a liveexecuteQuery(UPDATE_WIDGET_DASHBOARD_LAYOUT, …)against whateverexecuteQuerythe host wired into<PlatformUiProvider>— writing to the session user's stored layout the first time Save Changes is clicked in a Storybook / preview. If you want widget-dashboard's built-in Canvas GraphQL mutation, pass it explicitly. As of this writing the only observable values fordashboardTypeare'educator'(whendashboardFeatures.educator_dashboardis on) andundefined(when it's off — widget-dashboard'sperExperienceLayoutEnabledgate erases the'student'label before it reaches this hook). Treatundefinedas "single shared / non-educator slot", not as "unknown"; a naïveswitch (dashboardType) { case 'student': …; case 'educator': … }would leave the real non-educator save in thedefaultbranch. The declared union still includes'student'because it's derived fromWidgetDashboardEditProvider.onSaveand reserved for a future switcher; route onundefinedvs'educator'today, or one experience's persisted layout will silently overwrite the other's.translations?: Partial<WidgetDashboardTranslations>— overrides for any subset of widget-dashboard strings. Layered on top of a canonical base overlay (copied from the package's owntestHelpersto fill gaps in the shippeden.json) and widget-dashboard's shippeden.jsonvia a get-trap Proxy — enumeration would defeat any lazy Proxy passed here, so accepting a Proxy is a first-class shape. Missing keys and self-echo returns (overrides[key] === key) fall through to the base catalog. Trade-off worth naming: a locale where the correct translation legitimately equals the identifier can't be expressed via a Proxy-returning-key host — our layer treats that as a "no thunk registered" signal and falls back to base English. Zero widget-dashboard keys hit this today (values are all title-cased or sentences). If a future key does, the escape hatch depends on the host's Proxy shape: for thunk-registry Proxies (like Canvas'splatformBridge.tsx, which resolves asthunks[key]?.() ?? key), a target entry is never read — the host must supply a plain object for that specific key or drop the key-echoing default; for Proxies whose get trap consults its own target, a target entry works.translate?: TranslateFunction— dispatcher forwarded to widget-dashboard'sTranslationsProvider. Widget-dashboard's internal call sites use two conventions the host translator must handle: identifier keys with interpolation (as of this writing:reorderWidgetName,removeWidgetName,widgetMovedDirection,nEnrollments,roleInCourse,sendMessageToName— a miss on any of these puts the raw identifier into anaria-label) and English source strings with%{var}placeholders (e.g.translate('%{points} pts', {points})). Canvas'sui/features/widget_dashboard/react/platformBridge.tsxis one implementation. Miss semantics: when the host translator returns the key verbatim (thunk-registry hosts like Canvas's platformBridge resolve asthunks[key]?.() ?? key, so an unregistered thunk yields the raw identifier), we fall back tobuildTranslate(mergedTranslations)so the base overlay's real English lands in the DOM instead of the identifier. When the prop is omitted entirely, the samebuildTranslatefallback is used for every key — adequate for English-only setups.announceForScreenReader?: (msg: string) => void— screen-reader announcer plumbed toTranslationsProvider.
<DashboardScreen /> is also exported for standalone use (e.g. Storybook). Same rule as <FoundationLms /> — the host installs <PlatformUiProvider> above it. Foundation-lms does not install one at any level.
Notebook screen
The 'notebook' tab mounts <NotebookScreen /> when notebookProps is supplied — otherwise the tab falls through to the placeholder, so dashboard-only hosts don't have to wire notebook plumbing. <NotebookScreen /> is also exported from the package root for standalone use.
Because foundation-lms consumers are blank-page hosts (nothing above the shell knows what content to display), the screen owns the full flow:
- Picker phase — reads
courseIdfromFoundationShellContext, callsusePlatformUi().executeQuerywith acourse(id).pagesConnectionGraphQL query, and renders titles as a clickable list. - Viewer phase — when a page is picked, calls the host's
loadPageBody(courseId, pageId)for the HTML, then mounts<NotebookProvider>+<ContentWithNoteWrapper>+ the tray around the body. A back button returns to the picker.
If the shell has no courseId, the screen renders a "missing course context" fallback rather than throwing.
import { FoundationLms } from '@instructure/platform-foundation-lms'
import type { NotebookApi } from '@instructure/platform-notebook'
// Storage adapter you already wired for notes.
const notebookApi: NotebookApi = /* your Canvas GraphQL-backed adapter */
// Canvas GraphQL doesn't expose wiki page bodies — only metadata (`_id`,
// `title`, `updatedAt`). Foundation-LMS asks the host to load the body so
// the shell stays transport-agnostic (doesn't reach for window.fetch,
// doesn't couple to Canvas's session-cookie shape).
const loadPageBody = async (courseId: string, pageId: string) => {
const res = await fetch(`/api/v1/courses/${courseId}/pages/${pageId}`)
if (!res.ok) throw new Error(`Page fetch failed: ${res.status}`)
const page = await res.json()
return { title: page.title, body: page.body, updatedAt: page.updated_at }
}
<FoundationLms
currentUser={ENV.current_user}
courseId={ENV.COURSE_ID}
notebookProps={{
notebookApi,
loadPageBody,
// Required. Host-owned permission + feature-flag check.
// `false` (or omitted at runtime) hides the tab entirely.
canUseNotebook:
ENV.COURSE_ROLES?.can('view_notebook') && ENV.FEATURES?.foundation_notebook,
}}
/>Key props (see the exported NotebookScreenProps for the full list):
notebookApi: NotebookApi(required) — storage adapter for notes. No localStorage / in-memory default ships, so a Canvas host that forgets to wire this can't silently persist to the session user's browser. Preview / Storybook hosts that want a local sandbox implement their ownNotebookApi(four methods:getNotes,createNote,updateNote,deleteNote).loadPageBody: (courseId, pageId) => Promise<{ title, body, updatedAt }>(required) — page-body loader. Canvas GraphQL'sPagetype exposes only metadata, so the shell can't source the body viaexecuteQuery.bodyis HTML — foundation-LMS runs it throughsanitizeHtmlfrom@instructure/platform-sanitize(Canvas's frontend allowlist, preserves iframes / MathML / RCE-generated markup) before inserting into a<div class="user_content enhanced">. Hosts don't need to sanitize on their side. The.enhancedclass is load-bearing (see the "Anatomy" section below).pageSize?: number— notes per tray page. Defaults to10.highlightTheme?: HighlightTheme— highlight colors + widths. A sensible default ships; pass a host-specific theme to override.locale?: string— forwarded toNotesListView. Defaults to'en'.currentUser?: CurrentUser— inherited from the shell'scurrentUserviaFoundationShellContext. Pass an explicit override only for preview / Storybook rendering as a different user.onOpen?: () => void— fires the first time the tray is opened. Forwarded verbatim toNotebookProvider.onOpen.translations?: Partial<NotebookTranslations>— overrides for notebook's internal strings. Own-enumerable plain map or a lazy Proxy both work; forwarded verbatim toNotebookProvider.translate?: TranslateFunction— runtime dispatcher forwarded verbatim toNotebookProvider. Canvas hosts pass theirplatformBridge.tsxdispatcher.
courseId is not a prop of <NotebookScreen> — it comes from <FoundationLms courseId="…" /> (published on FoundationShellContext). Dashboard-only hosts can omit it.
pageLastModifiedAt is not a prop either — it's derived from the loaded body's updatedAt so ContentWithNoteWrapper's highlight-recovery pass is driven by real content changes rather than a stale value the host has to remember to update.
Anatomy of the viewer wrap
<PageViewer> wraps the loaded body in:
<div class="user_content enhanced">…host body HTML…</div>.enhanced is load-bearing, not stylistic. ContentWithNoteWrapper's init effect stalls in a MutationObserver until an ancestor (via .closest('.user_content')) carries .enhanced — see packages/notebook/src/ContentWithNoteWrapper.tsx:82-108. Without it, the tray still opens but drag-select silently no-ops and existing highlights never render. The shell owns this wrapper; hosts don't need to think about it.
What foundation-LMS deliberately does not do here
- No internal
<QueryClientProvider>. The picker usesuseQueryand the body loader wrapsloadPageBodyin one — both reach the QueryClient that<PlatformUiProvider>(installed by the host above<FoundationLms>) already provides. Installing a second client here would fragment the cache — notebook queries would live in a different tree than the dashboard's. - No internal
<InstUISettingsProvider>/ theme. Same rule as<DashboardScreen />: the host installs one above<FoundationLms>. - No body-fetch transport. Canvas GraphQL doesn't expose page bodies. Rather than invent a REST client inside the shell or reach for
window.fetch, we ask the host for aloadPageBodyfunction. Canvas hosts write the four-line adapter shown above; non-Canvas hosts can wire anything they want (in-memory maps, other CMS APIs, markdown-to-HTML). - Tab-switch state is not preserved. Switching from
notebook→dashboard→notebookunmounts and remounts<NotebookScreen>, so itsselectedPagestate andNotebookProvider's reducer (isTrayOpen,selectedNoteId) reset — the user lands back at the picker. The TanStack Query cache survives (lives on the shared client), so page lists and bodies aren't re-fetched.
Modules screen
<ModulesScreen /> renders @instructure/platform-modules against Canvas GraphQL. It is
exported from the package root so hosts can render it standalone, or through the
<FoundationLms /> shell via modulesProps.
<FoundationLms
currentUser={currentUser}
modulesProps={{
courseId: '42',
courseName: 'Intro to Everything',
view: 'student',
}}
/>Props
courseId?: string— the course whose modules render. Falls back to the shell'scourseId, so a host already passing<FoundationLms courseId>need not repeat it. When neither resolves the screen renders its own missing-course fallback rather than the shared "not yet migrated" placeholder.courseName?: string/courseContext?: string— list header title and its decorative crumb.view?: ModulesView—'student'(default) or'teacher'.studentId?: string | null— see the safety note below. A blank string counts as absent and falls back to the shell user; an explicitnullopts out of submission selection. Treat it as mount-time:platform-modulesdoes not key its cache on the student, so changing it on a mounted screen re-reads whatever is cached for that course.itemsMode?: ItemsMode— defaults to'auto', which probes the course and picks.'inline'asks for every module's items in the list query; treat it as a diagnostic setting rather than a host one.initialFilter?: string— whichfiltersregistry key starts active. Uncontrolled after mount.onUnsupportedItem?: (item: UnsupportedModuleItem) => void— redirects the report for a module item whose content type the package can't map. Items are dropped either way; without a handler the packageconsole.warns a full diagnostic itself, so supplying one that doesn't log leaves you with less visibility than omitting the prop.
Provider requirements
The host must mount <PlatformUiProvider> above the shell. Two things come from it:
executeQuery— read off the primary account and handed to the modules GraphQL adapter. There is no separate prop for it; wiring the dashboard already wires modules.QueryClientProvider—useModulesPageDatacallsuseQueryClient(). Pass your own stablequeryClientto<PlatformUiProvider>; its default is a default parameter, so a host that omits it mints a new client on every render and discards the react-query cache.
studentId is derived, and that is deliberate
In the student view studentId defaults to the shell's currentUser.id. In the teacher view it
is forced to null even if you pass one.
Canvas's submissionsConnection returns every visible student's submissions to a viewer
holding manage_grades or view_all_grades, ordered by user id. Selecting submissions for a
teacher read would therefore surface the wrong person's status. Omitting the id means the
submission field set is not requested at all and ModuleItem.status comes back undefined —
already the contract's "not fetched" — rather than filled with somebody else's data.
Translations are not wired yet
<ModulesI18nProvider> receives the host locale and timezone (so Intl date and number
formatting follows the host, and due dates print in the course's zone) but no translations
catalogue, so the modules tree renders English.
platform-modules now ships named keys and a locales/en.json (FOUND-344), so surfacing a
partial translations prop here — mirroring how <DashboardScreen /> surfaces
widget-dashboard's catalogue — is a follow-up change rather than part of this one.
Tags screen
The 'tags' tab mounts <TagsScreen /> when tagsProps is supplied and canUseTags is true. <TagsScreen /> is a thin wrapper around <AccountTags /> from @instructure/platform-institutional-tagging — hosts get institutional-tag management (create / edit / archive categories and tags, assign to users) without wiring the tagging package themselves.
import { FoundationLms } from '@instructure/platform-foundation-lms'
<FoundationLms
currentUser={ENV.current_user}
tagsProps={{
// Required. Host-owned permission fetch — Canvas hosts hit
// /api/v1/accounts/:id/permissions and read
// manage_institutional_tags_view.
canUseTags: institutionalTagPermissions.canView,
// Both optional. Hosts wire these for URL sync — a `?tag_id=...`
// deep link seeds `initialTagId`, and `onTagSelect` pushes updates
// back to the URL.
initialTagId: new URLSearchParams(location.search).get('tag_id') ?? undefined,
onTagSelect: tagId => {
const url = new URL(location.href)
if (tagId) url.searchParams.set('tag_id', tagId)
else url.searchParams.delete('tag_id')
history.replaceState(null, '', url.toString())
},
}}
/>Props (see the exported TagsScreenProps for the full list — everything except canUseTags is optional):
initialTagId?: string— tag to open on mount. Forwarded verbatim to<AccountTags>, which normalizes legacy / relay ids at that boundary. Seeds initial selection only; changing the prop after mount is ignored.onTagSelect?: (tagId: string \| null) => void— fires when the user selects or clears a tag inside the tags UI. Hosts wire this to push a URL update so a deep link survives a page reload.
<PlatformUiProvider> requirements
<AccountTags> reads three things from <PlatformUiProvider> — the host installs the provider above <FoundationLms>, so all three are host wiring, not foundation-lms's problem:
accounts[0].id— the account id every institutional-tag query is scoped to. The failure mode here is silent, not a client throw.PlatformUiProvider's "missing accounts" throw fires only when neither theaccountsarray nor the legacy top-level shape is present; passingaccounts=[{ id: undefined, … }](an unsetENV.ACCOUNT_ID) is treated as a valid array, and downstream queries just emit$accountId: null— Canvas rejects those server-side,ScreenErrorBoundarydoesn't catch them, and the user sees an empty tags UI with no explanation. Assert the id at the boundary before mounting, e.g.if (!ENV.ACCOUNT_ID) return <YourOwnFallback />. Related but separate footgun: the legacy top-levelexecuteQuery+currentUserIdshape (noaccountsat all) fabricatesaccounts[0].id = 'default', which Canvas GraphQL rejects withInvalid input: "default". See the "do NOT also pass them at the top level" note on the Canvas integration example.accounts[0].executeQuery— the GraphQL transport. Same shape as the dashboard'sexecuteQueryfromusePlatformUi().permissions={{ canView, canCreate, canEdit }}— the fine-grained gating that decides which buttons the tags UI renders. Derived from Canvas'smanage_institutional_tags_view / _create / _editpermissions (a RESTGET /api/v1/accounts/:id/permissionscall). Fail-closed on error.
The Canvas integration example below shows the full wiring.
What foundation-LMS deliberately does not do here
- No permission fetch. Foundation-LMS does not call
/api/v1/accounts/:id/permissions— hosts do it themselves and pass the resolved booleans to both<PlatformUiProvider permissions={…}>(for fine-grained gating inside the tags UI) and<FoundationLms tagsProps={{ canUseTags }}>(for tab visibility). Keeping this out of the shell means non-Canvas hosts don't have to fake a Canvas REST endpoint. - No account picker. The shell expects a single account context supplied via
<PlatformUiProvider accounts={…}>— same shape canvas-horizon used. If foundation-LMS ever grows a multi-account switcher,<AccountTags>will read whichever account is active.
Study Assist screen
The 'study' tab mounts <StudyAssistScreen /> when studyAssistProps is supplied and canUseStudyAssist is true. Wraps <AssistProvider> + <AssistContent> from @instructure/platform-study-assist for an AI-driven "summarize / quiz me / flashcards" experience over a course wiki page.
Two-phase flow (same shape as the notebook):
- Picker phase — reads
courseIdfromFoundationShellContext, calls the host'slistPages(courseId)for the page list, renders titles as a clickable list. - Viewer phase — when a page is picked, mounts
<AssistProvider>with the shell'scourseId+ the selected page'sid, then<AssistContent>renders the assist UI. A back button returns to the picker.
If the shell has no courseId, the screen renders a "missing course context" fallback rather than throwing.
import { FoundationLms } from '@instructure/platform-foundation-lms'
import type {
AssistRequest,
AssistResponse,
} from '@instructure/platform-study-assist'
import type { StudyAssistPage } from '@instructure/platform-foundation-lms'
// Canvas's study_assist endpoint gates on active StudentEnrollment + the
// `study_assist` feature flag. Cedar owns the transport; Canvas proxies.
//
// NOTE: a valid CSRF token MUST be sent on every request
const fetchAssistResponse = async (request: AssistRequest): Promise<AssistResponse> => {
const courseId = request.state?.courseID
if (!courseId) throw new Error('AssistRequest missing courseID')
const res = await fetch(`/api/v1/courses/${courseId}/study_assist`, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
// 'X-CSRF-Token': <required — see note above>,
},
body: JSON.stringify({
prompt: request.prompt,
state: request.state,
caller_app: 'canvas',
}),
})
const json = await res.json().catch(() => ({}))
if (!res.ok) {
return {
error: json.error ?? `Study Assist returned ${res.status}`,
statusCode: res.status,
}
}
return json as AssistResponse
}
// Canvas study_assist keys pages by URL slug (`page.url`), not the numeric
// `_id`. That's why the shell can't source this list from GraphQL and asks
// the host — Canvas GraphQL's `Page` type doesn't expose `url`.
const listPages = async (courseId: string): Promise<StudyAssistPage[]> => {
const res = await fetch(
`/api/v1/courses/${courseId}/pages?sort=title&order=asc&per_page=30`,
{ credentials: 'same-origin' }
)
const rows = (await res.json()) as Array<{
url: string
title: string
published: boolean
}>
return rows.map(row => ({ id: row.url, title: row.title, published: row.published }))
}
<FoundationLms
currentUser={ENV.current_user}
courseId={ENV.COURSE_ID}
studyAssistProps={{
fetchAssistResponse,
listPages,
// Required. Fail-closed. Canvas has no separate permission for study
// assist — Cedar's own enrollment + feature-flag check runs server-side,
// so hosts typically wire this to `ENV.FEATURES.study_assist` (and
// maybe an enrollment flag if available client-side).
canUseStudyAssist: ENV.FEATURES?.study_assist === true,
}}
/>Props (see the exported StudyAssistScreenProps for the full list):
fetchAssistResponse: (request: AssistRequest) => Promise<AssistResponse>(required) — study-assist backend. The function readsrequest.state?.courseID/pageIDand calls the host's AI endpoint. Canvas hosts POST to/api/v1/courses/:id/study_assist; the response shape already matchesAssistResponse. Non-Canvas hosts implement whatever produces the same shape.listPages: (courseId: string) => Promise<StudyAssistPage[]>(required) — page list for the picker.StudyAssistPage.idis the value threaded intoAssistProvider'spageId, so it must be whatever the backend expects. Canvas hosts returnpage.url(URL slug); other backends return whatever their study_assist endpoint uses.
courseId is not a prop of <StudyAssistScreen> — it comes from <FoundationLms courseId="…" /> (published on FoundationShellContext), same as notebook.
What foundation-LMS deliberately does not do here
- No page-list transport. Canvas GraphQL's
Pagetype doesn't expose the URL slug the study_assist endpoint consumes. Rather than invent a REST client inside the shell or reach forwindow.fetch, we ask the host forlistPages. Canvas hosts write the four-line adapter shown above. - No study-assist backend transport. Same reasoning.
fetchAssistResponseis host-owned; Canvas hosts wrapPOST /api/v1/courses/:id/study_assist; other backends can wire anything AssistResponse-shaped. - Tab-switch state is not preserved. Same as notebook — switching to another tab and back drops the picker selection and the assist chat. The TanStack Query cache preserves the page list, but the assist chat resets.
Canvas LMS integration
Because Foundation-LMS is a plain React library, hosts (like Canvas) consume it via a feature bundle rather than a Module Federation remote. Example bundle file:
// ui/features/foundation_lms/index.tsx
import { createRoot } from 'react-dom/client'
import { FoundationLms } from '@instructure/platform-foundation-lms'
import { InstUISettingsProvider } from '@instructure/emotion'
import canvas from '@instructure/ui-themes'
import { ready } from '@instructure/ready'
import { PlatformUiProvider } from '@instructure/platform-provider'
import { platformExecuteQuery } from '@canvas/graphql'
import { queryClient } from '@instructure/platform-query'
import { showFlashAlert } from '@instructure/platform-alerts'
ready(() => {
const mountPoint = document.getElementById('foundation-lms-mount')
if (!mountPoint) return
createRoot(mountPoint).render(
<InstUISettingsProvider theme={canvas}>
<PlatformUiProvider
queryClient={queryClient}
notify={({ type, message }) => showFlashAlert({ type, message })}
// When `accounts` is provided, `PlatformUiProvider` reads
// `executeQuery` / `currentUserId` / `locale` / `timezone` /
// `appContext` off `accounts[0]` — do NOT also pass them at the top
// level. Doing both silently ignores the top-level copies and hides
// which fields flow through. Widget-dashboard, notebook, AND tags
// all resolve their transport through this array.
//
// Always pass `accounts` when the tags tab is enabled — the compat
// "top-level executeQuery" shape (no `accounts`) fabricates
// `accounts[0].id = 'default'`, and Canvas GraphQL rejects
// `account(id: "default")` on the first tags query.
accounts={[
{
id: ENV.ACCOUNT_ID,
name: ENV.ACCOUNT_NAME ?? 'Canvas',
executeQuery: platformExecuteQuery,
currentUserId: ENV.current_user.id,
locale: ENV.LOCALE,
timezone: ENV.TIMEZONE,
appContext: 'canvas-academic',
},
]}
permissions={institutionalTagPermissions /* { canView, canCreate, canEdit } */}
>
<FoundationLms
currentUser={ENV.current_user}
courseId={ENV.COURSE_ID}
homeHref="/"
notebookProps={{
notebookApi,
loadPageBody,
canUseNotebook:
ENV.COURSE_ROLES?.can('view_notebook') &&
ENV.FEATURES?.foundation_notebook,
}}
tagsProps={{
canUseTags: institutionalTagPermissions.canView,
}}
/>
</PlatformUiProvider>
</InstUISettingsProvider>,
)
})Mount point height matters. <FoundationLms /> defaults to height="100%" on its outer container, which resolves against the parent's height. Give the mount point (or an ancestor) a concrete height so the sidebar fills the region:
#foundation-lms-mount {
height: 100vh; /* or a fixed pixel height */
}Alternatively, pass <FoundationLms height="100vh" /> and skip the CSS.
And in ui/featureBundles.ts:
foundation_lms: () => import('./features/foundation_lms/index'),Roadmap
Foundation-LMS is being built as a full-featured LMS shell, not a menu of à la carte screens. Every tab that lands is expected to be enabled by every consumer (Canvas is the primary target and today's only consumer, but the same rule applies to any future host). Screens are peer dependencies rather than optional peers on purpose — a host that installs foundation-lms is signing up to install the whole tree, not to cherry-pick.
The per-tab canUse* gates on notebookProps / tagsProps / studyAssistProps remain, but they exist for permission + feature-flag control per user session (a viewer without the right Canvas permission still can't see the tab, an in-progress feature can still be dark-launched), not for a host to opt out of shipping a screen. When a new screen lands, the expectation is: bump foundation-lms, add the new *Props bundle on <FoundationLms>, and derive the canUse* boolean from your permission / feature-flag surface.
Screens still to migrate from the canvas-horizon Foundation app:
- Block Content Editor
