@lessonkit/react 0.3.1 → 0.5.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/dist/index.js CHANGED
@@ -12,8 +12,94 @@ import {
12
12
  useRef,
13
13
  useState
14
14
  } from "react";
15
- import { createTrackingClient, nowIso } from "@lessonkit/core";
16
- import { createInMemoryXAPIQueue, createXAPIClient } from "@lessonkit/xapi";
15
+ import { createTrackingClient } from "@lessonkit/core";
16
+ import { createInMemoryXAPIQueue } from "@lessonkit/xapi";
17
+
18
+ // src/runtime/emitTelemetry.ts
19
+ import { nowIso } from "@lessonkit/core";
20
+ import { telemetryEventToXAPIStatement } from "@lessonkit/xapi";
21
+ var warnedMissingCourseId = false;
22
+ function isDevEnvironment() {
23
+ const g = globalThis;
24
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
25
+ }
26
+ function emitTelemetry(tracking, xapi, event) {
27
+ if (!event.courseId) {
28
+ if (isDevEnvironment() && !warnedMissingCourseId) {
29
+ warnedMissingCourseId = true;
30
+ console.warn("[lessonkit] telemetry event missing courseId");
31
+ }
32
+ return;
33
+ }
34
+ tracking.track(event);
35
+ try {
36
+ const statement = telemetryEventToXAPIStatement(event);
37
+ if (statement) xapi?.send(statement);
38
+ } catch (err) {
39
+ if (isDevEnvironment()) {
40
+ console.warn("[lessonkit] xAPI mapping skipped:", err instanceof Error ? err.message : err);
41
+ }
42
+ }
43
+ }
44
+ function buildTrackEvent(opts) {
45
+ const base = {
46
+ timestamp: nowIso(),
47
+ courseId: opts.courseId,
48
+ sessionId: opts.sessionId,
49
+ attemptId: opts.attemptId,
50
+ user: opts.user
51
+ };
52
+ switch (opts.name) {
53
+ case "course_started":
54
+ return { name: "course_started", ...base };
55
+ case "course_completed":
56
+ return { name: "course_completed", ...base };
57
+ case "lesson_started": {
58
+ const data = opts.data;
59
+ const lessonId = opts.lessonId ?? data?.lessonId;
60
+ if (!lessonId) throw new Error("lesson_started requires lessonId");
61
+ return {
62
+ name: "lesson_started",
63
+ ...base,
64
+ lessonId,
65
+ data: { lessonId, ...data }
66
+ };
67
+ }
68
+ case "lesson_completed":
69
+ case "lesson_time_on_task": {
70
+ const data = opts.data;
71
+ const lessonId = opts.lessonId ?? data?.lessonId;
72
+ if (!lessonId) throw new Error(`${opts.name} requires lessonId`);
73
+ return {
74
+ name: opts.name,
75
+ ...base,
76
+ lessonId,
77
+ data: { lessonId, ...data }
78
+ };
79
+ }
80
+ case "quiz_answered": {
81
+ const data = opts.data;
82
+ const lessonId = opts.lessonId;
83
+ if (!lessonId) throw new Error("quiz_answered requires active lessonId");
84
+ return { name: "quiz_answered", ...base, lessonId, data };
85
+ }
86
+ case "quiz_completed": {
87
+ const data = opts.data;
88
+ const lessonId = opts.lessonId;
89
+ if (!lessonId) throw new Error("quiz_completed requires active lessonId");
90
+ return { name: "quiz_completed", ...base, lessonId, data };
91
+ }
92
+ case "interaction":
93
+ return {
94
+ name: "interaction",
95
+ ...base,
96
+ lessonId: opts.lessonId,
97
+ data: opts.data
98
+ };
99
+ default:
100
+ return { name: opts.name, ...base };
101
+ }
102
+ }
17
103
 
18
104
  // src/runtime/ports.ts
19
105
  function createNoopStorage() {
@@ -42,6 +128,53 @@ function createSessionStoragePort() {
42
128
  };
43
129
  }
44
130
 
131
+ // src/runtime/progress.ts
132
+ function createProgressController() {
133
+ let activeLessonId;
134
+ let completedLessonIds = /* @__PURE__ */ new Set();
135
+ let courseCompleted = false;
136
+ const lessonStartTimes = /* @__PURE__ */ new Map();
137
+ return {
138
+ getState: () => ({
139
+ activeLessonId,
140
+ completedLessonIds: new Set(completedLessonIds),
141
+ courseCompleted
142
+ }),
143
+ setActiveLesson: (lessonId, startedAtMs) => {
144
+ const previousLessonId = activeLessonId;
145
+ activeLessonId = lessonId;
146
+ lessonStartTimes.set(lessonId, startedAtMs);
147
+ return { previousLessonId };
148
+ },
149
+ completeLesson: (lessonId, completedAtMs) => {
150
+ if (completedLessonIds.has(lessonId)) return { didComplete: false };
151
+ completedLessonIds = new Set(completedLessonIds).add(lessonId);
152
+ const startedAt = lessonStartTimes.get(lessonId);
153
+ lessonStartTimes.delete(lessonId);
154
+ const durationMs = typeof startedAt === "number" ? Math.max(0, completedAtMs - startedAt) : void 0;
155
+ return { durationMs, didComplete: true };
156
+ },
157
+ completeCourse: () => {
158
+ if (courseCompleted) return { didComplete: false };
159
+ courseCompleted = true;
160
+ return { didComplete: true };
161
+ }
162
+ };
163
+ }
164
+
165
+ // src/runtime/xapi.ts
166
+ import { createXAPIClient } from "@lessonkit/xapi";
167
+ function createXapiClientFromConfig(config, queue) {
168
+ if (config.xapi?.enabled === false) return null;
169
+ if (config.xapi?.client) return config.xapi.client;
170
+ if (!config.courseId) return null;
171
+ return createXAPIClient({
172
+ courseId: config.courseId,
173
+ transport: config.xapi?.transport,
174
+ queue
175
+ });
176
+ }
177
+
45
178
  // src/runtime/session.ts
46
179
  import { createSessionId } from "@lessonkit/core";
47
180
  var SESSION_STORAGE_KEY = "lessonkit:sessionId";
@@ -85,14 +218,8 @@ function createTrackingClientFromConfig(config) {
85
218
  batch: config.tracking?.batch
86
219
  });
87
220
  }
88
- function createXapiClientFromConfig(config, queue) {
89
- if (config.xapi?.enabled === false) return null;
90
- if (config.xapi?.client) return config.xapi.client;
91
- const baseId = config.courseId ? `urn:lessonkit:course:${config.courseId}` : void 0;
92
- return createXAPIClient({ baseId, transport: config.xapi?.transport, queue });
93
- }
94
221
  function LessonkitProvider(props) {
95
- const config = props.config ?? {};
222
+ const config = props.config;
96
223
  const sessionIdRef = useRef(resolveSessionId(defaultStorage, config.session?.sessionId));
97
224
  if (config.session?.sessionId) sessionIdRef.current = config.session.sessionId;
98
225
  const attemptIdRef = useRef(config.session?.attemptId);
@@ -101,9 +228,15 @@ function LessonkitProvider(props) {
101
228
  userRef.current = config.session?.user;
102
229
  const courseIdRef = useRef(config.courseId);
103
230
  courseIdRef.current = config.courseId;
231
+ const progressRef = useRef(createProgressController());
232
+ const [progress, setProgress] = useState(() => progressRef.current.getState());
233
+ const syncProgress = useCallback(() => {
234
+ setProgress(progressRef.current.getState());
235
+ }, []);
236
+ const activeLessonIdRef = useRef(progress.activeLessonId);
237
+ activeLessonIdRef.current = progress.activeLessonId;
104
238
  const trackingRef = useRef(createTrackingClient());
105
239
  const [tracking, setTracking] = useState(() => trackingRef.current);
106
- const courseStartedInProviderRef = useRef(false);
107
240
  const trackingEnabled = config.tracking?.enabled;
108
241
  const trackingSink = config.tracking?.sink;
109
242
  const trackingBatchSink = config.tracking?.batchSink;
@@ -117,21 +250,19 @@ function LessonkitProvider(props) {
117
250
  setTracking(next);
118
251
  const sessionId = sessionIdRef.current;
119
252
  const cid = courseIdRef.current;
120
- const shouldEmitCourseStarted = cid ? !hasCourseStarted(defaultStorage, sessionId, cid) : !courseStartedInProviderRef.current;
121
- if (shouldEmitCourseStarted) {
122
- if (cid) {
123
- markCourseStarted(defaultStorage, sessionId, cid);
124
- } else {
125
- courseStartedInProviderRef.current = true;
126
- }
127
- next.track({
128
- name: "course_started",
129
- timestamp: nowIso(),
130
- courseId: cid,
131
- sessionId,
132
- attemptId: attemptIdRef.current,
133
- user: userRef.current
134
- });
253
+ if (!hasCourseStarted(defaultStorage, sessionId, cid)) {
254
+ markCourseStarted(defaultStorage, sessionId, cid);
255
+ emitTelemetry(
256
+ next,
257
+ xapiRef.current,
258
+ buildTrackEvent({
259
+ name: "course_started",
260
+ courseId: cid,
261
+ sessionId,
262
+ attemptId: attemptIdRef.current,
263
+ user: userRef.current
264
+ })
265
+ );
135
266
  }
136
267
  return () => {
137
268
  disposeTrackingClient(prev);
@@ -172,21 +303,10 @@ function LessonkitProvider(props) {
172
303
  void prev?.flush();
173
304
  };
174
305
  }, [xapiEnabled, xapiClient, xapiTransport, courseId]);
175
- const [completedLessonIds, setCompletedLessonIds] = useState(() => /* @__PURE__ */ new Set());
176
- const completedLessonIdsRef = useRef(completedLessonIds);
177
- completedLessonIdsRef.current = completedLessonIds;
178
- const [activeLessonId, setActiveLessonId] = useState(void 0);
179
- const [courseCompleted, setCourseCompleted] = useState(false);
180
- const courseCompletedRef = useRef(false);
181
- courseCompletedRef.current = courseCompleted;
182
- const activeLessonIdRef = useRef(void 0);
183
- activeLessonIdRef.current = activeLessonId;
184
- const lessonStartTimesRef = useRef(/* @__PURE__ */ new Map());
185
306
  const track = useCallback(
186
307
  (name, data, opts) => {
187
- trackingRef.current?.track({
308
+ const event = buildTrackEvent({
188
309
  name,
189
- timestamp: nowIso(),
190
310
  courseId: courseIdRef.current,
191
311
  lessonId: opts?.lessonId ?? activeLessonIdRef.current,
192
312
  sessionId: sessionIdRef.current,
@@ -194,6 +314,7 @@ function LessonkitProvider(props) {
194
314
  user: userRef.current,
195
315
  data
196
316
  });
317
+ emitTelemetry(trackingRef.current, xapiRef.current, event);
197
318
  },
198
319
  []
199
320
  );
@@ -203,45 +324,47 @@ function LessonkitProvider(props) {
203
324
  void xapiRef.current?.flush();
204
325
  };
205
326
  }, []);
206
- const setActiveLesson = useCallback((lessonId) => {
207
- if (activeLessonIdRef.current === lessonId) return;
208
- activeLessonIdRef.current = lessonId;
209
- setActiveLessonId(lessonId);
210
- lessonStartTimesRef.current.set(lessonId, Date.now());
211
- track("lesson_started", { lessonId }, { lessonId });
212
- xapiRef.current?.startedLesson({ lessonId });
213
- }, [track]);
214
- const completeLesson = useCallback(
215
- (lessonId) => {
216
- if (completedLessonIdsRef.current.has(lessonId)) return;
217
- completedLessonIdsRef.current = new Set(completedLessonIdsRef.current).add(lessonId);
218
- setCompletedLessonIds(completedLessonIdsRef.current);
219
- const startedAt = lessonStartTimesRef.current.get(lessonId);
220
- lessonStartTimesRef.current.delete(lessonId);
221
- const durationMs = typeof startedAt === "number" ? Math.max(0, Date.now() - startedAt) : void 0;
327
+ const emitLessonCompleted = useCallback(
328
+ (lessonId, durationMs) => {
222
329
  track("lesson_completed", { lessonId, durationMs }, { lessonId });
223
330
  if (durationMs !== void 0) {
224
331
  track("lesson_time_on_task", { lessonId, durationMs }, { lessonId });
225
332
  }
226
- xapiRef.current?.completeLesson({ lessonId, durationMs });
227
333
  },
228
334
  [track]
229
335
  );
336
+ const completeLesson = useCallback(
337
+ (lessonId) => {
338
+ const result = progressRef.current.completeLesson(lessonId, Date.now());
339
+ if (!result.didComplete) return;
340
+ syncProgress();
341
+ emitLessonCompleted(lessonId, result.durationMs);
342
+ },
343
+ [syncProgress, emitLessonCompleted]
344
+ );
345
+ const setActiveLesson = useCallback(
346
+ (lessonId) => {
347
+ const current = progressRef.current.getState();
348
+ if (current.activeLessonId === lessonId) return;
349
+ const previous = current.activeLessonId;
350
+ if (previous && previous !== lessonId) {
351
+ const completed = progressRef.current.completeLesson(previous, Date.now());
352
+ if (completed.didComplete) {
353
+ emitLessonCompleted(previous, completed.durationMs);
354
+ }
355
+ }
356
+ progressRef.current.setActiveLesson(lessonId, Date.now());
357
+ syncProgress();
358
+ track("lesson_started", { lessonId }, { lessonId });
359
+ },
360
+ [track, syncProgress, emitLessonCompleted]
361
+ );
230
362
  const completeCourse = useCallback(() => {
231
- if (courseCompletedRef.current) return;
232
- courseCompletedRef.current = true;
233
- setCourseCompleted(true);
363
+ const result = progressRef.current.completeCourse();
364
+ if (!result.didComplete) return;
365
+ syncProgress();
234
366
  track("course_completed");
235
- xapiRef.current?.completeCourse();
236
- }, [track]);
237
- const progress = useMemo(
238
- () => ({
239
- activeLessonId,
240
- completedLessonIds: new Set(completedLessonIds),
241
- courseCompleted
242
- }),
243
- [activeLessonId, completedLessonIds, courseCompleted]
244
- );
367
+ }, [track, syncProgress]);
245
368
  const runtime = useMemo(
246
369
  () => ({
247
370
  config,
@@ -293,9 +416,28 @@ function useQuizState() {
293
416
  );
294
417
  }
295
418
 
419
+ // src/runtime/validateComponentId.ts
420
+ import { validateId } from "@lessonkit/core";
421
+ var warnedPaths = /* @__PURE__ */ new Set();
422
+ function isDevEnvironment2() {
423
+ const g = globalThis;
424
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
425
+ }
426
+ function warnInvalidComponentId(id, path) {
427
+ if (!isDevEnvironment2()) return;
428
+ const key = `${path}:${String(id)}`;
429
+ if (warnedPaths.has(key)) return;
430
+ const result = validateId(id, path);
431
+ if (result.ok) return;
432
+ warnedPaths.add(key);
433
+ const detail = result.issues.map((i) => `${i.path}: ${i.message}`).join("; ");
434
+ console.warn(`[lessonkit] invalid ${path} \u2014 ${detail}`);
435
+ }
436
+
296
437
  // src/components.tsx
297
438
  import { jsx as jsx2, jsxs } from "react/jsx-runtime";
298
439
  function Course(props) {
440
+ warnInvalidComponentId(props.courseId, "courseId");
299
441
  const providerConfig = useMemo3(
300
442
  () => ({ ...props.config, courseId: props.courseId }),
301
443
  [props.config, props.courseId]
@@ -306,15 +448,23 @@ function Course(props) {
306
448
  ] }) });
307
449
  }
308
450
  function Lesson(props) {
451
+ warnInvalidComponentId(props.lessonId, "lessonId");
309
452
  const { setActiveLesson } = useLessonkit();
310
453
  const { completeLesson } = useCompletion();
311
- const reactId = useId();
312
- const generatedId = useMemo3(() => `lesson-${sanitizeLessonId(reactId)}`, [reactId]);
313
- const id = props.lessonId ?? generatedId;
454
+ const id = props.lessonId;
455
+ const pendingCompleteRef = useRef2(null);
314
456
  useEffect2(() => {
457
+ if (pendingCompleteRef.current !== null) {
458
+ clearTimeout(pendingCompleteRef.current);
459
+ pendingCompleteRef.current = null;
460
+ }
315
461
  setActiveLesson(id);
316
462
  return () => {
317
- completeLesson(id);
463
+ const lessonId = id;
464
+ pendingCompleteRef.current = setTimeout(() => {
465
+ pendingCompleteRef.current = null;
466
+ completeLesson(lessonId);
467
+ }, 0);
318
468
  };
319
469
  }, [id, setActiveLesson, completeLesson]);
320
470
  return /* @__PURE__ */ jsxs("article", { "aria-label": props.title, children: [
@@ -323,11 +473,13 @@ function Lesson(props) {
323
473
  ] });
324
474
  }
325
475
  function Scenario(props) {
326
- return /* @__PURE__ */ jsx2("section", { "aria-label": "Scenario", children: props.children });
476
+ if (props.blockId !== void 0) warnInvalidComponentId(props.blockId, "blockId");
477
+ return /* @__PURE__ */ jsx2("section", { "aria-label": "Scenario", "data-lk-block-id": props.blockId, children: props.children });
327
478
  }
328
479
  function Reflection(props) {
480
+ if (props.blockId !== void 0) warnInvalidComponentId(props.blockId, "blockId");
329
481
  const promptId = useId();
330
- return /* @__PURE__ */ jsxs("section", { "aria-label": "Reflection", children: [
482
+ return /* @__PURE__ */ jsxs("section", { "aria-label": "Reflection", "data-lk-block-id": props.blockId, children: [
331
483
  props.prompt ? /* @__PURE__ */ jsx2("p", { id: promptId, children: props.prompt }) : null,
332
484
  props.children,
333
485
  /* @__PURE__ */ jsx2(
@@ -340,14 +492,23 @@ function Reflection(props) {
340
492
  ] });
341
493
  }
342
494
  function KnowledgeCheck(props) {
343
- return /* @__PURE__ */ jsx2(Quiz, { question: props.question, choices: props.choices, answer: props.answer });
495
+ return /* @__PURE__ */ jsx2(
496
+ Quiz,
497
+ {
498
+ checkId: props.checkId,
499
+ question: props.question,
500
+ choices: props.choices,
501
+ answer: props.answer
502
+ }
503
+ );
344
504
  }
345
505
  function Quiz(props) {
506
+ warnInvalidComponentId(props.checkId, "checkId");
346
507
  const quiz = useQuizState();
347
508
  const [selected, setSelected] = useState2(null);
348
509
  const completedRef = useRef2(false);
349
510
  const questionId = useId();
350
- return /* @__PURE__ */ jsxs("section", { "aria-label": "Quiz", children: [
511
+ return /* @__PURE__ */ jsxs("section", { "aria-label": "Quiz", "data-lk-check-id": props.checkId, children: [
351
512
  /* @__PURE__ */ jsx2("p", { id: questionId, children: props.question }),
352
513
  /* @__PURE__ */ jsxs("fieldset", { "aria-labelledby": questionId, children: [
353
514
  /* @__PURE__ */ jsx2("legend", { style: visuallyHiddenStyle, children: "Quiz choices" }),
@@ -362,10 +523,15 @@ function Quiz(props) {
362
523
  onChange: () => {
363
524
  setSelected(c);
364
525
  const correct = c === props.answer;
365
- quiz.answer({ question: props.question, choice: c, correct });
526
+ quiz.answer({
527
+ checkId: props.checkId,
528
+ question: props.question,
529
+ choice: c,
530
+ correct
531
+ });
366
532
  if (correct && !completedRef.current) {
367
533
  completedRef.current = true;
368
- quiz.complete({ score: 1, maxScore: 1 });
534
+ quiz.complete({ checkId: props.checkId, score: 1, maxScore: 1 });
369
535
  }
370
536
  }
371
537
  }
@@ -384,9 +550,128 @@ function ProgressTracker() {
384
550
  completed
385
551
  ] }) });
386
552
  }
387
- function sanitizeLessonId(id) {
388
- const s = id.replace(/[^a-zA-Z0-9_-]/g, "");
389
- return s.length ? s : "id";
553
+
554
+ // src/theme/ThemeProvider.tsx
555
+ import React3, {
556
+ createContext as createContext2,
557
+ useCallback as useCallback2,
558
+ useContext as useContext2,
559
+ useLayoutEffect as useLayoutEffect2,
560
+ useMemo as useMemo4,
561
+ useRef as useRef3,
562
+ useState as useState3
563
+ } from "react";
564
+ import {
565
+ brandThemeOverrides,
566
+ darkTheme,
567
+ getPresetTheme,
568
+ lightTheme,
569
+ mergeThemes,
570
+ themeToCssVariables
571
+ } from "@lessonkit/themes";
572
+
573
+ // src/theme/applyCssVariables.ts
574
+ function applyCssVariables(target, vars, previousKeys) {
575
+ for (const key of previousKeys) {
576
+ if (!(key in vars)) {
577
+ target.style.removeProperty(key);
578
+ }
579
+ }
580
+ const nextKeys = /* @__PURE__ */ new Set();
581
+ for (const [key, value] of Object.entries(vars)) {
582
+ target.style.setProperty(key, value);
583
+ nextKeys.add(key);
584
+ }
585
+ return nextKeys;
586
+ }
587
+
588
+ // src/theme/ThemeProvider.tsx
589
+ import { jsx as jsx3 } from "react/jsx-runtime";
590
+ var ThemeContext = createContext2(null);
591
+ var useIsoLayoutEffect2 = typeof window !== "undefined" ? useLayoutEffect2 : React3.useEffect;
592
+ function getSystemMode() {
593
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
594
+ return "light";
595
+ }
596
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
597
+ }
598
+ function resolveModeBase(mode, resolvedMode) {
599
+ if (mode === "system") {
600
+ return resolvedMode === "dark" ? darkTheme : lightTheme;
601
+ }
602
+ if (mode === "dark") return darkTheme;
603
+ return lightTheme;
604
+ }
605
+ function ThemeProvider(props) {
606
+ const preset = props.preset ?? "default";
607
+ const mode = props.mode ?? "light";
608
+ const targetKind = props.target ?? "document";
609
+ const [resolvedMode, setResolvedMode] = useState3(
610
+ () => mode === "system" ? getSystemMode() : mode
611
+ );
612
+ useIsoLayoutEffect2(() => {
613
+ if (mode !== "system") {
614
+ setResolvedMode(mode);
615
+ return;
616
+ }
617
+ setResolvedMode(getSystemMode());
618
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
619
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
620
+ const onChange = () => setResolvedMode(mq.matches ? "dark" : "light");
621
+ mq.addEventListener("change", onChange);
622
+ return () => mq.removeEventListener("change", onChange);
623
+ }, [mode]);
624
+ const dataTheme = mode === "system" ? resolvedMode : mode === "dark" ? "dark" : "light";
625
+ const effectiveTheme = useMemo4(() => {
626
+ const modeBase = resolveModeBase(mode, dataTheme);
627
+ const base = preset === "default" ? modeBase : preset === "brand" ? mergeThemes(modeBase, brandThemeOverrides) : mergeThemes(modeBase, getPresetTheme(preset));
628
+ return mergeThemes(base, props.theme ?? {});
629
+ }, [preset, mode, dataTheme, props.theme]);
630
+ const hostRef = useRef3(null);
631
+ const appliedKeysRef = useRef3(/* @__PURE__ */ new Set());
632
+ useIsoLayoutEffect2(() => {
633
+ if (targetKind === "document" && typeof document !== "undefined") {
634
+ document.documentElement.setAttribute("data-lk-theme", dataTheme);
635
+ return () => document.documentElement.removeAttribute("data-lk-theme");
636
+ }
637
+ }, [targetKind, dataTheme]);
638
+ const inject = useCallback2(() => {
639
+ const vars = themeToCssVariables(effectiveTheme);
640
+ const el = targetKind === "document" && typeof document !== "undefined" ? document.documentElement : hostRef.current;
641
+ if (!el) return;
642
+ appliedKeysRef.current = applyCssVariables(el, vars, appliedKeysRef.current);
643
+ }, [effectiveTheme, targetKind]);
644
+ useIsoLayoutEffect2(() => {
645
+ inject();
646
+ return () => {
647
+ const el = targetKind === "document" && typeof document !== "undefined" ? document.documentElement : hostRef.current;
648
+ if (!el) return;
649
+ for (const key of appliedKeysRef.current) {
650
+ el.style.removeProperty(key);
651
+ }
652
+ appliedKeysRef.current = /* @__PURE__ */ new Set();
653
+ };
654
+ }, [inject, targetKind]);
655
+ const value = useMemo4(
656
+ () => ({
657
+ theme: effectiveTheme,
658
+ preset,
659
+ mode,
660
+ resolvedMode: dataTheme
661
+ }),
662
+ [effectiveTheme, preset, mode, dataTheme]
663
+ );
664
+ if (targetKind === "document") {
665
+ return /* @__PURE__ */ jsx3(ThemeContext.Provider, { value, children: /* @__PURE__ */ jsx3("div", { "data-lk-theme": dataTheme, style: { display: "contents" }, children: props.children }) });
666
+ }
667
+ return /* @__PURE__ */ jsx3(ThemeContext.Provider, { value, children: /* @__PURE__ */ jsx3("div", { ref: hostRef, "data-lk-theme": dataTheme, children: props.children }) });
668
+ }
669
+ function useTheme() {
670
+ const ctx = useContext2(ThemeContext);
671
+ if (!ctx) {
672
+ throw new Error("useTheme must be used within a ThemeProvider");
673
+ }
674
+ return ctx;
390
675
  }
391
676
  export {
392
677
  Course,
@@ -397,9 +682,11 @@ export {
397
682
  Quiz,
398
683
  Reflection,
399
684
  Scenario,
685
+ ThemeProvider,
400
686
  useCompletion,
401
687
  useLessonkit,
402
688
  useProgress,
403
689
  useQuizState,
690
+ useTheme,
404
691
  useTracking
405
692
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lessonkit/react",
3
- "version": "0.3.1",
3
+ "version": "0.5.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.1",
54
- "@lessonkit/core": "0.3.1",
55
- "@lessonkit/xapi": "0.3.1"
53
+ "@lessonkit/accessibility": "0.5.0",
54
+ "@lessonkit/core": "0.5.0",
55
+ "@lessonkit/themes": "0.5.0",
56
+ "@lessonkit/xapi": "0.5.0"
56
57
  },
57
58
  "devDependencies": {
58
59
  "@testing-library/react": "^16.3.0",