@lessonkit/react 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,7 +17,7 @@ npm install @lessonkit/react react react-dom
17
17
  ```tsx
18
18
  import { useMemo } from "react";
19
19
  import type { TelemetryEvent } from "@lessonkit/core";
20
- import { Course, Lesson, Quiz, Scenario, ProgressTracker } from "@lessonkit/react";
20
+ import { Course, Lesson, Quiz, Scenario, ProgressTracker, ThemeProvider } from "@lessonkit/react";
21
21
  import type { XAPIStatement } from "@lessonkit/xapi";
22
22
 
23
23
  export default function App() {
@@ -34,6 +34,7 @@ export default function App() {
34
34
  );
35
35
 
36
36
  return (
37
+ <ThemeProvider mode="light">
37
38
  <Course title="Cybersecurity Basics" courseId="cyber-basics" config={config}>
38
39
  <ProgressTracker />
39
40
 
@@ -49,11 +50,12 @@ export default function App() {
49
50
  />
50
51
  </Lesson>
51
52
  </Course>
53
+ </ThemeProvider>
52
54
  );
53
55
  }
54
56
  ```
55
57
 
56
- ## API (0.3.0)
58
+ ## API (0.4.0)
57
59
 
58
60
  ### Components
59
61
 
@@ -71,6 +73,12 @@ export default function App() {
71
73
  - `useTracking`
72
74
  - `useQuizState`
73
75
  - `useCompletion`
76
+ - `useTheme`
77
+
78
+ ### Theming
79
+
80
+ - `ThemeProvider` — injects `--lk-*` CSS variables (see [`docs/THEMING.md`](../../docs/THEMING.md))
81
+ - Props: `preset`, `mode` (`light` | `dark` | `system`), `theme` (partial override), `target` (`document` | `element`)
74
82
 
75
83
  ## Notes
76
84
 
@@ -84,4 +92,5 @@ export default function App() {
84
92
  - If you omit `session.sessionId`, the provider reuses a tab-scoped id via `sessionStorage` so React
85
93
  Strict Mode remounts do not split analytics sessions in development.
86
94
  - Accessibility guidance lives in [`docs/ACCESSIBILITY.md`](../../docs/ACCESSIBILITY.md).
95
+ - Theming and token catalog: [`docs/THEMING.md`](../../docs/THEMING.md).
87
96
 
package/dist/index.cjs CHANGED
@@ -1,7 +1,9 @@
1
1
  "use strict";
2
+ var __create = Object.create;
2
3
  var __defProp = Object.defineProperty;
3
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
5
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
6
8
  var __export = (target, all) => {
7
9
  for (var name in all)
@@ -15,6 +17,14 @@ var __copyProps = (to, from, except, desc) => {
15
17
  }
16
18
  return to;
17
19
  };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
18
28
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
29
 
20
30
  // src/index.tsx
@@ -28,10 +38,12 @@ __export(index_exports, {
28
38
  Quiz: () => Quiz,
29
39
  Reflection: () => Reflection,
30
40
  Scenario: () => Scenario,
41
+ ThemeProvider: () => ThemeProvider,
31
42
  useCompletion: () => useCompletion,
32
43
  useLessonkit: () => useLessonkit,
33
44
  useProgress: () => useProgress,
34
45
  useQuizState: () => useQuizState,
46
+ useTheme: () => useTheme,
35
47
  useTracking: () => useTracking
36
48
  });
37
49
  module.exports = __toCommonJS(index_exports);
@@ -42,69 +54,91 @@ var import_accessibility = require("@lessonkit/accessibility");
42
54
 
43
55
  // src/context.tsx
44
56
  var import_react = require("react");
45
- var import_core = require("@lessonkit/core");
46
- var import_xapi = require("@lessonkit/xapi");
47
- var import_jsx_runtime = require("react/jsx-runtime");
48
- var LessonkitContext = (0, import_react.createContext)(null);
49
- var SESSION_STORAGE_KEY = "lessonkit:sessionId";
50
- var COURSE_STARTED_PREFIX = "lessonkit:course_started:";
51
- function disposeTrackingClient(client) {
52
- client?.flush?.();
53
- client?.dispose?.();
57
+ var import_core2 = require("@lessonkit/core");
58
+ var import_xapi2 = require("@lessonkit/xapi");
59
+
60
+ // src/runtime/ports.ts
61
+ function createNoopStorage() {
62
+ return {
63
+ getItem: () => null,
64
+ setItem: () => {
65
+ }
66
+ };
54
67
  }
55
- function safeSessionStorageGetItem(key) {
56
- if (typeof sessionStorage === "undefined") return null;
57
- try {
58
- return sessionStorage.getItem(key);
59
- } catch {
60
- return null;
61
- }
68
+ function createSessionStoragePort() {
69
+ if (typeof sessionStorage === "undefined") return createNoopStorage();
70
+ return {
71
+ getItem: (key) => {
72
+ try {
73
+ return sessionStorage.getItem(key);
74
+ } catch {
75
+ return null;
76
+ }
77
+ },
78
+ setItem: (key, value) => {
79
+ try {
80
+ sessionStorage.setItem(key, value);
81
+ } catch {
82
+ }
83
+ }
84
+ };
62
85
  }
63
- function safeSessionStorageSetItem(key, value) {
64
- if (typeof sessionStorage === "undefined") return;
65
- try {
66
- sessionStorage.setItem(key, value);
67
- } catch {
68
- }
86
+
87
+ // src/runtime/xapi.ts
88
+ var import_xapi = require("@lessonkit/xapi");
89
+ function createXapiClientFromConfig(config, queue) {
90
+ if (config.xapi?.enabled === false) return null;
91
+ if (config.xapi?.client) return config.xapi.client;
92
+ const baseId = config.courseId ? `urn:lessonkit:course:${config.courseId}` : void 0;
93
+ return (0, import_xapi.createXAPIClient)({ baseId, transport: config.xapi?.transport, queue });
69
94
  }
70
- function resolveSessionId(provided) {
95
+
96
+ // src/runtime/session.ts
97
+ var import_core = require("@lessonkit/core");
98
+ var SESSION_STORAGE_KEY = "lessonkit:sessionId";
99
+ var COURSE_STARTED_PREFIX = "lessonkit:course_started:";
100
+ function resolveSessionId(storage, provided) {
71
101
  if (provided) return provided;
72
- const existing = safeSessionStorageGetItem(SESSION_STORAGE_KEY);
102
+ const existing = storage.getItem(SESSION_STORAGE_KEY);
73
103
  if (existing) return existing;
74
104
  const id = (0, import_core.createSessionId)();
75
- safeSessionStorageSetItem(SESSION_STORAGE_KEY, id);
105
+ storage.setItem(SESSION_STORAGE_KEY, id);
76
106
  return id;
77
107
  }
78
108
  function courseStartedStorageKey(sessionId, courseId) {
79
109
  return `${COURSE_STARTED_PREFIX}${sessionId}:${courseId ?? ""}`;
80
110
  }
81
- function hasCourseStarted(sessionId, courseId) {
111
+ function hasCourseStarted(storage, sessionId, courseId) {
82
112
  if (!courseId) return false;
83
- return safeSessionStorageGetItem(courseStartedStorageKey(sessionId, courseId)) === "1";
113
+ return storage.getItem(courseStartedStorageKey(sessionId, courseId)) === "1";
84
114
  }
85
- function markCourseStarted(sessionId, courseId) {
115
+ function markCourseStarted(storage, sessionId, courseId) {
86
116
  if (!courseId) return;
87
- safeSessionStorageSetItem(courseStartedStorageKey(sessionId, courseId), "1");
117
+ storage.setItem(courseStartedStorageKey(sessionId, courseId), "1");
88
118
  }
119
+
120
+ // src/context.tsx
121
+ var import_jsx_runtime = require("react/jsx-runtime");
122
+ var LessonkitContext = (0, import_react.createContext)(null);
123
+ var useIsoLayoutEffect = typeof window !== "undefined" ? import_react.useLayoutEffect : import_react.useEffect;
124
+ function disposeTrackingClient(client) {
125
+ client?.flush?.();
126
+ client?.dispose?.();
127
+ }
128
+ var defaultStorage = createSessionStoragePort();
89
129
  function createTrackingClientFromConfig(config) {
90
130
  if (config.tracking?.enabled === false) {
91
- return (0, import_core.createTrackingClient)();
131
+ return (0, import_core2.createTrackingClient)();
92
132
  }
93
- return (0, import_core.createTrackingClient)({
133
+ return (0, import_core2.createTrackingClient)({
94
134
  sink: config.tracking?.sink,
95
135
  batchSink: config.tracking?.batchSink,
96
136
  batch: config.tracking?.batch
97
137
  });
98
138
  }
99
- function createXapiClientFromConfig(config, queue) {
100
- if (config.xapi?.enabled === false) return null;
101
- if (config.xapi?.client) return config.xapi.client;
102
- const baseId = config.courseId ? `urn:lessonkit:course:${config.courseId}` : void 0;
103
- return (0, import_xapi.createXAPIClient)({ baseId, transport: config.xapi?.transport, queue });
104
- }
105
139
  function LessonkitProvider(props) {
106
140
  const config = props.config ?? {};
107
- const sessionIdRef = (0, import_react.useRef)(resolveSessionId(config.session?.sessionId));
141
+ const sessionIdRef = (0, import_react.useRef)(resolveSessionId(defaultStorage, config.session?.sessionId));
108
142
  if (config.session?.sessionId) sessionIdRef.current = config.session.sessionId;
109
143
  const attemptIdRef = (0, import_react.useRef)(config.session?.attemptId);
110
144
  const userRef = (0, import_react.useRef)(config.session?.user);
@@ -112,7 +146,7 @@ function LessonkitProvider(props) {
112
146
  userRef.current = config.session?.user;
113
147
  const courseIdRef = (0, import_react.useRef)(config.courseId);
114
148
  courseIdRef.current = config.courseId;
115
- const trackingRef = (0, import_react.useRef)((0, import_core.createTrackingClient)());
149
+ const trackingRef = (0, import_react.useRef)((0, import_core2.createTrackingClient)());
116
150
  const [tracking, setTracking] = (0, import_react.useState)(() => trackingRef.current);
117
151
  const courseStartedInProviderRef = (0, import_react.useRef)(false);
118
152
  const trackingEnabled = config.tracking?.enabled;
@@ -121,23 +155,23 @@ function LessonkitProvider(props) {
121
155
  const batchEnabled = config.tracking?.batch?.enabled;
122
156
  const batchFlushIntervalMs = config.tracking?.batch?.flushIntervalMs;
123
157
  const batchMaxBatchSize = config.tracking?.batch?.maxBatchSize;
124
- (0, import_react.useLayoutEffect)(() => {
158
+ useIsoLayoutEffect(() => {
125
159
  const prev = trackingRef.current;
126
160
  const next = createTrackingClientFromConfig(config);
127
161
  trackingRef.current = next;
128
162
  setTracking(next);
129
163
  const sessionId = sessionIdRef.current;
130
164
  const cid = courseIdRef.current;
131
- const shouldEmitCourseStarted = cid ? !hasCourseStarted(sessionId, cid) : !courseStartedInProviderRef.current;
165
+ const shouldEmitCourseStarted = cid ? !hasCourseStarted(defaultStorage, sessionId, cid) : !courseStartedInProviderRef.current;
132
166
  if (shouldEmitCourseStarted) {
133
167
  if (cid) {
134
- markCourseStarted(sessionId, cid);
168
+ markCourseStarted(defaultStorage, sessionId, cid);
135
169
  } else {
136
170
  courseStartedInProviderRef.current = true;
137
171
  }
138
172
  next.track({
139
173
  name: "course_started",
140
- timestamp: (0, import_core.nowIso)(),
174
+ timestamp: (0, import_core2.nowIso)(),
141
175
  courseId: cid,
142
176
  sessionId,
143
177
  attemptId: attemptIdRef.current,
@@ -155,14 +189,14 @@ function LessonkitProvider(props) {
155
189
  batchFlushIntervalMs,
156
190
  batchMaxBatchSize
157
191
  ]);
158
- const xapiQueueRef = (0, import_react.useRef)((0, import_xapi.createInMemoryXAPIQueue)());
192
+ const xapiQueueRef = (0, import_react.useRef)((0, import_xapi2.createInMemoryXAPIQueue)());
159
193
  const xapiRef = (0, import_react.useRef)(null);
160
194
  const [xapi, setXapi] = (0, import_react.useState)(null);
161
195
  const xapiEnabled = config.xapi?.enabled;
162
196
  const xapiClient = config.xapi?.client;
163
197
  const xapiTransport = config.xapi?.transport;
164
198
  const courseId = config.courseId;
165
- (0, import_react.useLayoutEffect)(() => {
199
+ useIsoLayoutEffect(() => {
166
200
  const prev = xapiRef.current;
167
201
  const next = createXapiClientFromConfig(config, xapiQueueRef.current);
168
202
  xapiRef.current = next;
@@ -197,7 +231,7 @@ function LessonkitProvider(props) {
197
231
  (name, data, opts) => {
198
232
  trackingRef.current?.track({
199
233
  name,
200
- timestamp: (0, import_core.nowIso)(),
234
+ timestamp: (0, import_core2.nowIso)(),
201
235
  courseId: courseIdRef.current,
202
236
  lessonId: opts?.lessonId ?? activeLessonIdRef.current,
203
237
  sessionId: sessionIdRef.current,
@@ -399,6 +433,114 @@ function sanitizeLessonId(id) {
399
433
  const s = id.replace(/[^a-zA-Z0-9_-]/g, "");
400
434
  return s.length ? s : "id";
401
435
  }
436
+
437
+ // src/theme/ThemeProvider.tsx
438
+ var import_react4 = __toESM(require("react"), 1);
439
+ var import_themes = require("@lessonkit/themes");
440
+
441
+ // src/theme/applyCssVariables.ts
442
+ function applyCssVariables(target, vars, previousKeys) {
443
+ for (const key of previousKeys) {
444
+ if (!(key in vars)) {
445
+ target.style.removeProperty(key);
446
+ }
447
+ }
448
+ const nextKeys = /* @__PURE__ */ new Set();
449
+ for (const [key, value] of Object.entries(vars)) {
450
+ target.style.setProperty(key, value);
451
+ nextKeys.add(key);
452
+ }
453
+ return nextKeys;
454
+ }
455
+
456
+ // src/theme/ThemeProvider.tsx
457
+ var import_jsx_runtime3 = require("react/jsx-runtime");
458
+ var ThemeContext = (0, import_react4.createContext)(null);
459
+ var useIsoLayoutEffect2 = typeof window !== "undefined" ? import_react4.useLayoutEffect : import_react4.default.useEffect;
460
+ function getSystemMode() {
461
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
462
+ return "light";
463
+ }
464
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
465
+ }
466
+ function resolveModeBase(mode, resolvedMode) {
467
+ if (mode === "system") {
468
+ return resolvedMode === "dark" ? import_themes.darkTheme : import_themes.lightTheme;
469
+ }
470
+ if (mode === "dark") return import_themes.darkTheme;
471
+ return import_themes.lightTheme;
472
+ }
473
+ function ThemeProvider(props) {
474
+ const preset = props.preset ?? "default";
475
+ const mode = props.mode ?? "light";
476
+ const targetKind = props.target ?? "document";
477
+ const [resolvedMode, setResolvedMode] = (0, import_react4.useState)(
478
+ () => mode === "system" ? getSystemMode() : mode
479
+ );
480
+ useIsoLayoutEffect2(() => {
481
+ if (mode !== "system") {
482
+ setResolvedMode(mode);
483
+ return;
484
+ }
485
+ setResolvedMode(getSystemMode());
486
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
487
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
488
+ const onChange = () => setResolvedMode(mq.matches ? "dark" : "light");
489
+ mq.addEventListener("change", onChange);
490
+ return () => mq.removeEventListener("change", onChange);
491
+ }, [mode]);
492
+ const dataTheme = mode === "system" ? resolvedMode : mode === "dark" ? "dark" : "light";
493
+ const effectiveTheme = (0, import_react4.useMemo)(() => {
494
+ const modeBase = resolveModeBase(mode, dataTheme);
495
+ const base = preset === "default" ? modeBase : preset === "brand" ? (0, import_themes.mergeThemes)(modeBase, import_themes.brandThemeOverrides) : (0, import_themes.mergeThemes)(modeBase, (0, import_themes.getPresetTheme)(preset));
496
+ return (0, import_themes.mergeThemes)(base, props.theme ?? {});
497
+ }, [preset, mode, dataTheme, props.theme]);
498
+ const hostRef = (0, import_react4.useRef)(null);
499
+ const appliedKeysRef = (0, import_react4.useRef)(/* @__PURE__ */ new Set());
500
+ useIsoLayoutEffect2(() => {
501
+ if (targetKind === "document" && typeof document !== "undefined") {
502
+ document.documentElement.setAttribute("data-lk-theme", dataTheme);
503
+ return () => document.documentElement.removeAttribute("data-lk-theme");
504
+ }
505
+ }, [targetKind, dataTheme]);
506
+ const inject = (0, import_react4.useCallback)(() => {
507
+ const vars = (0, import_themes.themeToCssVariables)(effectiveTheme);
508
+ const el = targetKind === "document" && typeof document !== "undefined" ? document.documentElement : hostRef.current;
509
+ if (!el) return;
510
+ appliedKeysRef.current = applyCssVariables(el, vars, appliedKeysRef.current);
511
+ }, [effectiveTheme, targetKind]);
512
+ useIsoLayoutEffect2(() => {
513
+ inject();
514
+ return () => {
515
+ const el = targetKind === "document" && typeof document !== "undefined" ? document.documentElement : hostRef.current;
516
+ if (!el) return;
517
+ for (const key of appliedKeysRef.current) {
518
+ el.style.removeProperty(key);
519
+ }
520
+ appliedKeysRef.current = /* @__PURE__ */ new Set();
521
+ };
522
+ }, [inject, targetKind]);
523
+ const value = (0, import_react4.useMemo)(
524
+ () => ({
525
+ theme: effectiveTheme,
526
+ preset,
527
+ mode,
528
+ resolvedMode: dataTheme
529
+ }),
530
+ [effectiveTheme, preset, mode, dataTheme]
531
+ );
532
+ if (targetKind === "document") {
533
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(ThemeContext.Provider, { value, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { "data-lk-theme": dataTheme, style: { display: "contents" }, children: props.children }) });
534
+ }
535
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(ThemeContext.Provider, { value, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { ref: hostRef, "data-lk-theme": dataTheme, children: props.children }) });
536
+ }
537
+ function useTheme() {
538
+ const ctx = (0, import_react4.useContext)(ThemeContext);
539
+ if (!ctx) {
540
+ throw new Error("useTheme must be used within a ThemeProvider");
541
+ }
542
+ return ctx;
543
+ }
402
544
  // Annotate the CommonJS export names for ESM import in node:
403
545
  0 && (module.exports = {
404
546
  Course,
@@ -409,9 +551,11 @@ function sanitizeLessonId(id) {
409
551
  Quiz,
410
552
  Reflection,
411
553
  Scenario,
554
+ ThemeProvider,
412
555
  useCompletion,
413
556
  useLessonkit,
414
557
  useProgress,
415
558
  useQuizState,
559
+ useTheme,
416
560
  useTracking
417
561
  });
package/dist/index.d.cts CHANGED
@@ -3,6 +3,8 @@ import React from 'react';
3
3
  import * as _lessonkit_core from '@lessonkit/core';
4
4
  import { CourseId, TelemetryUser, TelemetryEvent, TrackingClient, LessonId } from '@lessonkit/core';
5
5
  import { XAPITransport, XAPIClient } from '@lessonkit/xapi';
6
+ import { LessonkitThemeV1, ThemePresetName, PartialLessonkitThemeV1 } from '@lessonkit/themes';
7
+ export { ThemePresetName } from '@lessonkit/themes';
6
8
 
7
9
  type LessonkitConfig = {
8
10
  courseId?: CourseId;
@@ -107,4 +109,24 @@ declare function useQuizState(): {
107
109
  }) => void;
108
110
  };
109
111
 
110
- export { Course, KnowledgeCheck, Lesson, type LessonkitConfig, LessonkitProvider, type LessonkitRuntime, ProgressTracker, Quiz, Reflection, Scenario, useCompletion, useLessonkit, useProgress, useQuizState, useTracking };
112
+ type ThemeMode = "light" | "dark" | "system";
113
+ type ThemeResolvedMode = "light" | "dark";
114
+ type ThemeProviderProps = {
115
+ children: React.ReactNode;
116
+ /** Partial theme merged on top of the resolved preset (last writer wins). */
117
+ theme?: PartialLessonkitThemeV1;
118
+ preset?: ThemePresetName;
119
+ mode?: ThemeMode;
120
+ /** `document` injects on `:root`; `element` scopes to the provider wrapper. */
121
+ target?: "document" | "element";
122
+ };
123
+ type ThemeContextValue = {
124
+ theme: LessonkitThemeV1;
125
+ preset: ThemePresetName;
126
+ mode: ThemeMode;
127
+ resolvedMode: ThemeResolvedMode;
128
+ };
129
+ declare function ThemeProvider(props: ThemeProviderProps): react_jsx_runtime.JSX.Element;
130
+ declare function useTheme(): ThemeContextValue;
131
+
132
+ export { Course, KnowledgeCheck, Lesson, type LessonkitConfig, LessonkitProvider, type LessonkitRuntime, ProgressTracker, Quiz, Reflection, Scenario, type ThemeContextValue, type ThemeMode, ThemeProvider, type ThemeProviderProps, type ThemeResolvedMode, useCompletion, useLessonkit, useProgress, useQuizState, useTheme, useTracking };
package/dist/index.d.ts CHANGED
@@ -3,6 +3,8 @@ import React from 'react';
3
3
  import * as _lessonkit_core from '@lessonkit/core';
4
4
  import { CourseId, TelemetryUser, TelemetryEvent, TrackingClient, LessonId } from '@lessonkit/core';
5
5
  import { XAPITransport, XAPIClient } from '@lessonkit/xapi';
6
+ import { LessonkitThemeV1, ThemePresetName, PartialLessonkitThemeV1 } from '@lessonkit/themes';
7
+ export { ThemePresetName } from '@lessonkit/themes';
6
8
 
7
9
  type LessonkitConfig = {
8
10
  courseId?: CourseId;
@@ -107,4 +109,24 @@ declare function useQuizState(): {
107
109
  }) => void;
108
110
  };
109
111
 
110
- export { Course, KnowledgeCheck, Lesson, type LessonkitConfig, LessonkitProvider, type LessonkitRuntime, ProgressTracker, Quiz, Reflection, Scenario, useCompletion, useLessonkit, useProgress, useQuizState, useTracking };
112
+ type ThemeMode = "light" | "dark" | "system";
113
+ type ThemeResolvedMode = "light" | "dark";
114
+ type ThemeProviderProps = {
115
+ children: React.ReactNode;
116
+ /** Partial theme merged on top of the resolved preset (last writer wins). */
117
+ theme?: PartialLessonkitThemeV1;
118
+ preset?: ThemePresetName;
119
+ mode?: ThemeMode;
120
+ /** `document` injects on `:root`; `element` scopes to the provider wrapper. */
121
+ target?: "document" | "element";
122
+ };
123
+ type ThemeContextValue = {
124
+ theme: LessonkitThemeV1;
125
+ preset: ThemePresetName;
126
+ mode: ThemeMode;
127
+ resolvedMode: ThemeResolvedMode;
128
+ };
129
+ declare function ThemeProvider(props: ThemeProviderProps): react_jsx_runtime.JSX.Element;
130
+ declare function useTheme(): ThemeContextValue;
131
+
132
+ export { Course, KnowledgeCheck, Lesson, type LessonkitConfig, LessonkitProvider, type LessonkitRuntime, ProgressTracker, Quiz, Reflection, Scenario, type ThemeContextValue, type ThemeMode, ThemeProvider, type ThemeProviderProps, type ThemeResolvedMode, useCompletion, useLessonkit, useProgress, useQuizState, useTheme, useTracking };
package/dist/index.js CHANGED
@@ -12,50 +12,78 @@ import {
12
12
  useRef,
13
13
  useState
14
14
  } from "react";
15
- import { createSessionId, createTrackingClient, nowIso } from "@lessonkit/core";
16
- import { createInMemoryXAPIQueue, createXAPIClient } from "@lessonkit/xapi";
17
- import { jsx } from "react/jsx-runtime";
18
- var LessonkitContext = createContext(null);
19
- var SESSION_STORAGE_KEY = "lessonkit:sessionId";
20
- var COURSE_STARTED_PREFIX = "lessonkit:course_started:";
21
- function disposeTrackingClient(client) {
22
- client?.flush?.();
23
- client?.dispose?.();
15
+ import { createTrackingClient, nowIso } from "@lessonkit/core";
16
+ import { createInMemoryXAPIQueue } from "@lessonkit/xapi";
17
+
18
+ // src/runtime/ports.ts
19
+ function createNoopStorage() {
20
+ return {
21
+ getItem: () => null,
22
+ setItem: () => {
23
+ }
24
+ };
24
25
  }
25
- function safeSessionStorageGetItem(key) {
26
- if (typeof sessionStorage === "undefined") return null;
27
- try {
28
- return sessionStorage.getItem(key);
29
- } catch {
30
- return null;
31
- }
26
+ function createSessionStoragePort() {
27
+ if (typeof sessionStorage === "undefined") return createNoopStorage();
28
+ return {
29
+ getItem: (key) => {
30
+ try {
31
+ return sessionStorage.getItem(key);
32
+ } catch {
33
+ return null;
34
+ }
35
+ },
36
+ setItem: (key, value) => {
37
+ try {
38
+ sessionStorage.setItem(key, value);
39
+ } catch {
40
+ }
41
+ }
42
+ };
32
43
  }
33
- function safeSessionStorageSetItem(key, value) {
34
- if (typeof sessionStorage === "undefined") return;
35
- try {
36
- sessionStorage.setItem(key, value);
37
- } catch {
38
- }
44
+
45
+ // src/runtime/xapi.ts
46
+ import { createXAPIClient } from "@lessonkit/xapi";
47
+ function createXapiClientFromConfig(config, queue) {
48
+ if (config.xapi?.enabled === false) return null;
49
+ if (config.xapi?.client) return config.xapi.client;
50
+ const baseId = config.courseId ? `urn:lessonkit:course:${config.courseId}` : void 0;
51
+ return createXAPIClient({ baseId, transport: config.xapi?.transport, queue });
39
52
  }
40
- function resolveSessionId(provided) {
53
+
54
+ // src/runtime/session.ts
55
+ import { createSessionId } from "@lessonkit/core";
56
+ var SESSION_STORAGE_KEY = "lessonkit:sessionId";
57
+ var COURSE_STARTED_PREFIX = "lessonkit:course_started:";
58
+ function resolveSessionId(storage, provided) {
41
59
  if (provided) return provided;
42
- const existing = safeSessionStorageGetItem(SESSION_STORAGE_KEY);
60
+ const existing = storage.getItem(SESSION_STORAGE_KEY);
43
61
  if (existing) return existing;
44
62
  const id = createSessionId();
45
- safeSessionStorageSetItem(SESSION_STORAGE_KEY, id);
63
+ storage.setItem(SESSION_STORAGE_KEY, id);
46
64
  return id;
47
65
  }
48
66
  function courseStartedStorageKey(sessionId, courseId) {
49
67
  return `${COURSE_STARTED_PREFIX}${sessionId}:${courseId ?? ""}`;
50
68
  }
51
- function hasCourseStarted(sessionId, courseId) {
69
+ function hasCourseStarted(storage, sessionId, courseId) {
52
70
  if (!courseId) return false;
53
- return safeSessionStorageGetItem(courseStartedStorageKey(sessionId, courseId)) === "1";
71
+ return storage.getItem(courseStartedStorageKey(sessionId, courseId)) === "1";
54
72
  }
55
- function markCourseStarted(sessionId, courseId) {
73
+ function markCourseStarted(storage, sessionId, courseId) {
56
74
  if (!courseId) return;
57
- safeSessionStorageSetItem(courseStartedStorageKey(sessionId, courseId), "1");
75
+ storage.setItem(courseStartedStorageKey(sessionId, courseId), "1");
58
76
  }
77
+
78
+ // src/context.tsx
79
+ import { jsx } from "react/jsx-runtime";
80
+ var LessonkitContext = createContext(null);
81
+ var useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
82
+ function disposeTrackingClient(client) {
83
+ client?.flush?.();
84
+ client?.dispose?.();
85
+ }
86
+ var defaultStorage = createSessionStoragePort();
59
87
  function createTrackingClientFromConfig(config) {
60
88
  if (config.tracking?.enabled === false) {
61
89
  return createTrackingClient();
@@ -66,15 +94,9 @@ function createTrackingClientFromConfig(config) {
66
94
  batch: config.tracking?.batch
67
95
  });
68
96
  }
69
- function createXapiClientFromConfig(config, queue) {
70
- if (config.xapi?.enabled === false) return null;
71
- if (config.xapi?.client) return config.xapi.client;
72
- const baseId = config.courseId ? `urn:lessonkit:course:${config.courseId}` : void 0;
73
- return createXAPIClient({ baseId, transport: config.xapi?.transport, queue });
74
- }
75
97
  function LessonkitProvider(props) {
76
98
  const config = props.config ?? {};
77
- const sessionIdRef = useRef(resolveSessionId(config.session?.sessionId));
99
+ const sessionIdRef = useRef(resolveSessionId(defaultStorage, config.session?.sessionId));
78
100
  if (config.session?.sessionId) sessionIdRef.current = config.session.sessionId;
79
101
  const attemptIdRef = useRef(config.session?.attemptId);
80
102
  const userRef = useRef(config.session?.user);
@@ -91,17 +113,17 @@ function LessonkitProvider(props) {
91
113
  const batchEnabled = config.tracking?.batch?.enabled;
92
114
  const batchFlushIntervalMs = config.tracking?.batch?.flushIntervalMs;
93
115
  const batchMaxBatchSize = config.tracking?.batch?.maxBatchSize;
94
- useLayoutEffect(() => {
116
+ useIsoLayoutEffect(() => {
95
117
  const prev = trackingRef.current;
96
118
  const next = createTrackingClientFromConfig(config);
97
119
  trackingRef.current = next;
98
120
  setTracking(next);
99
121
  const sessionId = sessionIdRef.current;
100
122
  const cid = courseIdRef.current;
101
- const shouldEmitCourseStarted = cid ? !hasCourseStarted(sessionId, cid) : !courseStartedInProviderRef.current;
123
+ const shouldEmitCourseStarted = cid ? !hasCourseStarted(defaultStorage, sessionId, cid) : !courseStartedInProviderRef.current;
102
124
  if (shouldEmitCourseStarted) {
103
125
  if (cid) {
104
- markCourseStarted(sessionId, cid);
126
+ markCourseStarted(defaultStorage, sessionId, cid);
105
127
  } else {
106
128
  courseStartedInProviderRef.current = true;
107
129
  }
@@ -132,7 +154,7 @@ function LessonkitProvider(props) {
132
154
  const xapiClient = config.xapi?.client;
133
155
  const xapiTransport = config.xapi?.transport;
134
156
  const courseId = config.courseId;
135
- useLayoutEffect(() => {
157
+ useIsoLayoutEffect(() => {
136
158
  const prev = xapiRef.current;
137
159
  const next = createXapiClientFromConfig(config, xapiQueueRef.current);
138
160
  xapiRef.current = next;
@@ -369,6 +391,129 @@ function sanitizeLessonId(id) {
369
391
  const s = id.replace(/[^a-zA-Z0-9_-]/g, "");
370
392
  return s.length ? s : "id";
371
393
  }
394
+
395
+ // src/theme/ThemeProvider.tsx
396
+ import React3, {
397
+ createContext as createContext2,
398
+ useCallback as useCallback2,
399
+ useContext as useContext2,
400
+ useLayoutEffect as useLayoutEffect2,
401
+ useMemo as useMemo4,
402
+ useRef as useRef3,
403
+ useState as useState3
404
+ } from "react";
405
+ import {
406
+ brandThemeOverrides,
407
+ darkTheme,
408
+ getPresetTheme,
409
+ lightTheme,
410
+ mergeThemes,
411
+ themeToCssVariables
412
+ } from "@lessonkit/themes";
413
+
414
+ // src/theme/applyCssVariables.ts
415
+ function applyCssVariables(target, vars, previousKeys) {
416
+ for (const key of previousKeys) {
417
+ if (!(key in vars)) {
418
+ target.style.removeProperty(key);
419
+ }
420
+ }
421
+ const nextKeys = /* @__PURE__ */ new Set();
422
+ for (const [key, value] of Object.entries(vars)) {
423
+ target.style.setProperty(key, value);
424
+ nextKeys.add(key);
425
+ }
426
+ return nextKeys;
427
+ }
428
+
429
+ // src/theme/ThemeProvider.tsx
430
+ import { jsx as jsx3 } from "react/jsx-runtime";
431
+ var ThemeContext = createContext2(null);
432
+ var useIsoLayoutEffect2 = typeof window !== "undefined" ? useLayoutEffect2 : React3.useEffect;
433
+ function getSystemMode() {
434
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
435
+ return "light";
436
+ }
437
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
438
+ }
439
+ function resolveModeBase(mode, resolvedMode) {
440
+ if (mode === "system") {
441
+ return resolvedMode === "dark" ? darkTheme : lightTheme;
442
+ }
443
+ if (mode === "dark") return darkTheme;
444
+ return lightTheme;
445
+ }
446
+ function ThemeProvider(props) {
447
+ const preset = props.preset ?? "default";
448
+ const mode = props.mode ?? "light";
449
+ const targetKind = props.target ?? "document";
450
+ const [resolvedMode, setResolvedMode] = useState3(
451
+ () => mode === "system" ? getSystemMode() : mode
452
+ );
453
+ useIsoLayoutEffect2(() => {
454
+ if (mode !== "system") {
455
+ setResolvedMode(mode);
456
+ return;
457
+ }
458
+ setResolvedMode(getSystemMode());
459
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
460
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
461
+ const onChange = () => setResolvedMode(mq.matches ? "dark" : "light");
462
+ mq.addEventListener("change", onChange);
463
+ return () => mq.removeEventListener("change", onChange);
464
+ }, [mode]);
465
+ const dataTheme = mode === "system" ? resolvedMode : mode === "dark" ? "dark" : "light";
466
+ const effectiveTheme = useMemo4(() => {
467
+ const modeBase = resolveModeBase(mode, dataTheme);
468
+ const base = preset === "default" ? modeBase : preset === "brand" ? mergeThemes(modeBase, brandThemeOverrides) : mergeThemes(modeBase, getPresetTheme(preset));
469
+ return mergeThemes(base, props.theme ?? {});
470
+ }, [preset, mode, dataTheme, props.theme]);
471
+ const hostRef = useRef3(null);
472
+ const appliedKeysRef = useRef3(/* @__PURE__ */ new Set());
473
+ useIsoLayoutEffect2(() => {
474
+ if (targetKind === "document" && typeof document !== "undefined") {
475
+ document.documentElement.setAttribute("data-lk-theme", dataTheme);
476
+ return () => document.documentElement.removeAttribute("data-lk-theme");
477
+ }
478
+ }, [targetKind, dataTheme]);
479
+ const inject = useCallback2(() => {
480
+ const vars = themeToCssVariables(effectiveTheme);
481
+ const el = targetKind === "document" && typeof document !== "undefined" ? document.documentElement : hostRef.current;
482
+ if (!el) return;
483
+ appliedKeysRef.current = applyCssVariables(el, vars, appliedKeysRef.current);
484
+ }, [effectiveTheme, targetKind]);
485
+ useIsoLayoutEffect2(() => {
486
+ inject();
487
+ return () => {
488
+ const el = targetKind === "document" && typeof document !== "undefined" ? document.documentElement : hostRef.current;
489
+ if (!el) return;
490
+ for (const key of appliedKeysRef.current) {
491
+ el.style.removeProperty(key);
492
+ }
493
+ appliedKeysRef.current = /* @__PURE__ */ new Set();
494
+ };
495
+ }, [inject, targetKind]);
496
+ const value = useMemo4(
497
+ () => ({
498
+ theme: effectiveTheme,
499
+ preset,
500
+ mode,
501
+ resolvedMode: dataTheme
502
+ }),
503
+ [effectiveTheme, preset, mode, dataTheme]
504
+ );
505
+ if (targetKind === "document") {
506
+ return /* @__PURE__ */ jsx3(ThemeContext.Provider, { value, children: /* @__PURE__ */ jsx3("div", { "data-lk-theme": dataTheme, style: { display: "contents" }, children: props.children }) });
507
+ }
508
+ return /* @__PURE__ */ jsx3(ThemeContext.Provider, { value, children: /* @__PURE__ */ jsx3("div", { ref: hostRef, "data-lk-theme": dataTheme, children: props.children }) });
509
+ }
510
+ function useTheme() {
511
+ const ctx = useContext2(ThemeContext);
512
+ if (!ctx) {
513
+ throw new Error("useTheme must be used within a ThemeProvider");
514
+ }
515
+ return ctx;
516
+ }
372
517
  export {
373
518
  Course,
374
519
  KnowledgeCheck,
@@ -378,9 +523,11 @@ export {
378
523
  Quiz,
379
524
  Reflection,
380
525
  Scenario,
526
+ ThemeProvider,
381
527
  useCompletion,
382
528
  useLessonkit,
383
529
  useProgress,
384
530
  useQuizState,
531
+ useTheme,
385
532
  useTracking
386
533
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lessonkit/react",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "description": "React components and hooks for building learning experiences with LessonKit.",
6
6
  "license": "Apache-2.0",
@@ -37,8 +37,8 @@
37
37
  "dist"
38
38
  ],
39
39
  "scripts": {
40
- "build": "tsup src/index.tsx --format esm,cjs --dts --external react --external react-dom --external @lessonkit/accessibility",
41
- "dev": "tsup src/index.tsx --format esm,cjs --dts --watch --external react --external react-dom --external @lessonkit/accessibility",
40
+ "build": "tsup src/index.tsx --format esm,cjs --dts --external react --external react-dom --external @lessonkit/accessibility --external @lessonkit/themes",
41
+ "dev": "tsup src/index.tsx --format esm,cjs --dts --watch --external react --external react-dom --external @lessonkit/accessibility --external @lessonkit/themes",
42
42
  "prepublishOnly": "npm run build",
43
43
  "typecheck": "tsc -p tsconfig.json",
44
44
  "test": "vitest run --passWithNoTests",
@@ -50,9 +50,10 @@
50
50
  "react-dom": ">=18"
51
51
  },
52
52
  "dependencies": {
53
- "@lessonkit/accessibility": "0.3.0",
54
- "@lessonkit/core": "0.3.0",
55
- "@lessonkit/xapi": "0.3.0"
53
+ "@lessonkit/accessibility": "0.4.0",
54
+ "@lessonkit/core": "0.4.0",
55
+ "@lessonkit/themes": "0.4.0",
56
+ "@lessonkit/xapi": "0.4.0"
56
57
  },
57
58
  "devDependencies": {
58
59
  "@testing-library/react": "^16.3.0",