npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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-lms

Peer dependencies (all granular — no @instructure/ui barrel):

  • react ^18.0.0
  • react-dom ^18.0.0 — required by platform-modules
  • @instructure/platform-institutional-tagging workspace:*
  • @instructure/platform-modules workspace:* — <ModulesScreen>; a static import in the shell, so every host loads it even if the Modules tab is never opened
  • @instructure/platform-notebook workspace:*
  • @instructure/platform-provider workspace:* — foundation-LMS's own picker calls usePlatformUi().executeQuery, so it declares this rather than treating it purely as a "host installs above" transitive
  • @instructure/platform-sanitize workspace:* — <NotebookScreen> runs the host-supplied page body through sanitizeHtml before inserting it via dangerouslySetInnerHTML
  • @instructure/platform-study-assist workspace:*
  • @instructure/platform-widget-dashboard workspace:*
  • @instructure/ui-a11y-content ^11.0.0
  • @instructure/ui-alerts ^11.0.0 — not imported directly; reached through platform-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 — via platform-modules
  • @instructure/ui-pill ^11.0.0 — via platform-modules
  • @instructure/ui-progress ^11.0.0 — via platform-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 inlines platform-grades, which imports it (see "ui-table gotcha" below)
  • @instructure/ui-text ^11.0.0
  • @instructure/ui-text-input ^11.0.0 — via platform-modules
  • @instructure/ui-view ^11.0.0
  • @tanstack/react-query ^5.0.0 — the picker uses useQuery directly
  • graphql ^16.0.0 — graphql-tag's runtime imports parse from here
  • graphql-tag ^2.12.0 — the picker's GraphQL query is a gql template
  • zod ^3.23.8 — not imported directly; platform-modules validates adapter output against its schemas at runtime, so this is a hard import in its dist

Hosts also need @instructure/platform-provider (installed above <FoundationLms>) and everything it requires — see "Transitive peers" below.

Transitive peers. @instructure/platform-widget-dashboard declares its own peer set (~30 InstUI packages — buttons, modal, tabs, select, etc., plus platform-instui-bindings, platform-rce-adapter, graphql-tag, and more). @instructure/platform-institutional-tagging declares another ~28 InstUI peers plus platform-instui-bindings (see packages/institutional-tagging/package.json); most overlap widget-dashboard's set, but ui-drilldown, ui-tag, and ui-truncate-text are 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 linked package.json files for the authoritative lists.

@instructure/ui-table gotcha. Widget-dashboard's built dist/index.js inlines @instructure/platform-grades, and grades imports ui-table. Because widget-dashboard doesn't externalize platform-grades, the ui-table import ends up resolved against widget-dashboard's own directory at runtime. Foundation-lms declares ui-table as a peer (see list above) so hoisted / flat installers (Canvas under yarn, or any consumer with a flat node_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's node_modules — so pnpm-strict consumers hit the runtime failure until the upstream fix lands (externalizing platform-grades in packages/widget_dashboard/vite.config.ts). This package's test suite uses a local stub to bypass the same pnpm-strict resolution failure — see vitest.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 in FoundationLms.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. zod in 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) plus react-dom, on the same reasoning as the ui-table entry 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 on FoundationShellContext so embedded screens can render display names and avatars without the host re-forwarding through per-screen *Props. Canvas hosts pass ENV.current_user directly. The outer <PlatformUiProvider> should carry the same user's .id as currentUserId.

Shell props

  • courseId?: string — course the shell is scoped to, published on FoundationShellContext for descendants. Optional at the shell level because dashboard-only hosts don't need it; required to use the notebook tab — its picker calls course(id).pagesConnection via executeQuery, and without a course id the screen renders a "missing course context" fallback rather than throwing. Canvas hosts on a course-scoped route pass ENV.COURSE_ID. Temporary — this prop only exists because there is no course-picker inside the shell yet. Once a /courses/:id route lives in this package and the user picks a course inside <FoundationLms> itself, courseId moves 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 when mode is 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/notebook can 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 neither homeHref nor onHomeClick is 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. Requires notebookApi + loadPageBody; see the Notebook screen section. Also carries a required canUseNotebook: boolean gate — the tab is only rendered when this is explicitly true; anything else (including false) hides the notebook tab from the sidebar entirely and normalizes any controlled mode="notebook" (or an initialMode restored from a URL) to "dashboard". Kept required rather than defaulting to true so a host wiring notebookProps cannot 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 as notebookProps: 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 with canUseTags: false to hide the sidebar entry entirely and normalize any controlled mode="tags" back to "dashboard". Only canUseTags: true actually 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. Requires fetchAssistResponse + listPages inside; carries a required canUseStudyAssist: boolean. See the Study Assist screen section.
  • modulesProps?: ModulesScreenProps — forwarded to the embedded <ModulesScreen /> when the 'modules' tab is active. Optional in full: courseId falls 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's WidgetDashboardTranslations, ~400 keys). Override any subset via dashboardProps.translations. Hosts with an i18next-backed pipeline pair that with a dashboardProps.translate dispatcher — 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 dashboardProps and any inline translations map out of the render. Two separate wins: (1) a stable dashboardProps identity feeds WidgetDashboardProvider's memoized context value — that provider does memoize, so pinning identity there avoids invalidating dozens of downstream consumers on every render. (2) A stable translations identity keeps <DashboardScreen /> from re-allocating its overlay Proxy each render. What hoisting does not buy: useTranslations() consumers. platform-widget-dashboard's TranslationsProvider builds 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 } | null

Screen 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 /> uses maxWidth="720px"; the placeholder body uses 45rem. Those narrower caps sit inside the shell's 1366px and win. Do not raise maxWidth above 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's currentUser prop by default; pass an explicit override here only for preview / Storybook use cases.
  • dashboardFeatures?.educator_dashboard — set to true to mount <EducatorWidgetDashboardContainer /> instead of the learner container. Defaults to the learner container.
  • currentUserRoles?, preferences?, sharedCourseData?, dashboardFeatures?, observedUsersList?, observedUserId?, canAddObservee? — direct pass-through to WidgetDashboardProvider. 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 live executeQuery(UPDATE_WIDGET_DASHBOARD_LAYOUT, …) against whatever executeQuery the 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 for dashboardType are 'educator' (when dashboardFeatures.educator_dashboard is on) and undefined (when it's off — widget-dashboard's perExperienceLayoutEnabled gate erases the 'student' label before it reaches this hook). Treat undefined as "single shared / non-educator slot", not as "unknown"; a naïve switch (dashboardType) { case 'student': …; case 'educator': … } would leave the real non-educator save in the default branch. The declared union still includes 'student' because it's derived from WidgetDashboardEditProvider.onSave and reserved for a future switcher; route on undefined vs '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 own testHelpers to fill gaps in the shipped en.json) and widget-dashboard's shipped en.json via 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's platformBridge.tsx, which resolves as thunks[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's TranslationsProvider. 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 an aria-label) and English source strings with %{var} placeholders (e.g. translate('%{points} pts', {points})). Canvas's ui/features/widget_dashboard/react/platformBridge.tsx is one implementation. Miss semantics: when the host translator returns the key verbatim (thunk-registry hosts like Canvas's platformBridge resolve as thunks[key]?.() ?? key, so an unregistered thunk yields the raw identifier), we fall back to buildTranslate(mergedTranslations) so the base overlay's real English lands in the DOM instead of the identifier. When the prop is omitted entirely, the same buildTranslate fallback is used for every key — adequate for English-only setups.
  • announceForScreenReader?: (msg: string) => void — screen-reader announcer plumbed to TranslationsProvider.

<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:

  1. Picker phase — reads courseId from FoundationShellContext, calls usePlatformUi().executeQuery with a course(id).pagesConnection GraphQL query, and renders titles as a clickable list.
  2. 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 own NotebookApi (four methods: getNotes, createNote, updateNote, deleteNote).
  • loadPageBody: (courseId, pageId) => Promise<{ title, body, updatedAt }> (required) — page-body loader. Canvas GraphQL's Page type exposes only metadata, so the shell can't source the body via executeQuery. body is HTML — foundation-LMS runs it through sanitizeHtml from @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 .enhanced class is load-bearing (see the "Anatomy" section below).
  • pageSize?: number — notes per tray page. Defaults to 10.
  • highlightTheme?: HighlightTheme — highlight colors + widths. A sensible default ships; pass a host-specific theme to override.
  • locale?: string — forwarded to NotesListView. Defaults to 'en'.
  • currentUser?: CurrentUser — inherited from the shell's currentUser via FoundationShellContext. 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 to NotebookProvider.onOpen.
  • translations?: Partial<NotebookTranslations> — overrides for notebook's internal strings. Own-enumerable plain map or a lazy Proxy both work; forwarded verbatim to NotebookProvider.
  • translate?: TranslateFunction — runtime dispatcher forwarded verbatim to NotebookProvider. Canvas hosts pass their platformBridge.tsx dispatcher.

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 uses useQuery and the body loader wraps loadPageBody in 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 a loadPageBody function. 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 → notebook unmounts and remounts <NotebookScreen>, so its selectedPage state and NotebookProvider'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's courseId, 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 explicit null opts out of submission selection. Treat it as mount-time: platform-modules does 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 — which filters registry 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 package console.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 — useModulesPageData calls useQueryClient(). Pass your own stable queryClient to <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:

  1. 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 the accounts array nor the legacy top-level shape is present; passing accounts=[{ id: undefined, … }] (an unset ENV.ACCOUNT_ID) is treated as a valid array, and downstream queries just emit $accountId: null — Canvas rejects those server-side, ScreenErrorBoundary doesn'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-level executeQuery+currentUserId shape (no accounts at all) fabricates accounts[0].id = 'default', which Canvas GraphQL rejects with Invalid input: "default". See the "do NOT also pass them at the top level" note on the Canvas integration example.
  2. accounts[0].executeQuery — the GraphQL transport. Same shape as the dashboard's executeQuery from usePlatformUi().
  3. permissions={{ canView, canCreate, canEdit }} — the fine-grained gating that decides which buttons the tags UI renders. Derived from Canvas's manage_institutional_tags_view / _create / _edit permissions (a REST GET /api/v1/accounts/:id/permissions call). 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):

  1. Picker phase — reads courseId from FoundationShellContext, calls the host's listPages(courseId) for the page list, renders titles as a clickable list.
  2. Viewer phase — when a page is picked, mounts <AssistProvider> with the shell's courseId + the selected page's id, 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 reads request.state?.courseID / pageID and calls the host's AI endpoint. Canvas hosts POST to /api/v1/courses/:id/study_assist; the response shape already matches AssistResponse. Non-Canvas hosts implement whatever produces the same shape.
  • listPages: (courseId: string) => Promise<StudyAssistPage[]> (required) — page list for the picker. StudyAssistPage.id is the value threaded into AssistProvider's pageId, so it must be whatever the backend expects. Canvas hosts return page.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 Page type doesn't expose the URL slug the study_assist endpoint consumes. Rather than invent a REST client inside the shell or reach for window.fetch, we ask the host for listPages. Canvas hosts write the four-line adapter shown above.
  • No study-assist backend transport. Same reasoning. fetchAssistResponse is host-owned; Canvas hosts wrap POST /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