@lessonkit/react 0.9.3 → 1.0.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
@@ -3,8 +3,10 @@ import { useEffect as useEffect2, useId, useMemo as useMemo3, useRef as useRef2,
3
3
  import { visuallyHiddenStyle } from "@lessonkit/accessibility";
4
4
 
5
5
  // src/context.tsx
6
+ import { createContext } from "react";
7
+
8
+ // src/provider/useLessonkitProviderRuntime.ts
6
9
  import {
7
- createContext,
8
10
  useCallback,
9
11
  useEffect,
10
12
  useLayoutEffect,
@@ -12,241 +14,102 @@ import {
12
14
  useRef,
13
15
  useState
14
16
  } from "react";
15
- import { createTrackingClient as createTrackingClient2 } from "@lessonkit/core";
17
+ import { createLessonkitRuntime, createTrackingClient as createTrackingClient2, assertValidId } from "@lessonkit/core";
16
18
  import { createInMemoryXAPIQueue } from "@lessonkit/xapi";
17
19
  import { telemetryEventToXAPIStatement as telemetryEventToXAPIStatement2 } from "@lessonkit/xapi";
18
20
 
19
21
  // src/runtime/emitTelemetry.ts
20
- import { nowIso } from "@lessonkit/core";
22
+ import { buildTelemetryEvent, tryBuildTelemetryEvent } from "@lessonkit/core";
23
+
24
+ // src/runtime/telemetryPipeline.ts
25
+ import {
26
+ createTelemetryPipeline,
27
+ createTrackingPipelineSink
28
+ } from "@lessonkit/core";
21
29
  import { telemetryEventToXAPIStatement } from "@lessonkit/xapi";
22
30
 
23
31
  // src/runtime/lxpackBridge.ts
24
32
  import {
25
- getLxpackBridge as getLxpackBridgeFromSdk,
33
+ dispatchBridgeAction,
34
+ forwardTelemetryToBridge,
35
+ getLxpackBridge,
26
36
  mapLessonkitTelemetryToBridgeAction,
27
- normalizePassingThreshold,
28
- normalizeScore,
29
37
  telemetryEventToLessonkit
30
38
  } from "@lessonkit/lxpack/bridge";
31
- function getBridge() {
32
- const fromSdk = getLxpackBridgeFromSdk();
33
- if (fromSdk) return fromSdk;
34
- if (typeof window === "undefined") return null;
35
- const parent = window.parent;
36
- if (!parent || parent === window) return null;
37
- return parent.lxpack ?? null;
39
+ function forwardTelemetryToLxpack(event, mode = "auto") {
40
+ forwardTelemetryToBridge(event, mode);
38
41
  }
39
- function applyBridgeAction(bridge, action) {
40
- if (!action) return;
41
- switch (action.kind) {
42
- case "completeLesson":
43
- bridge.completeLesson?.(action.lessonId);
44
- return;
45
- case "completeCourse":
46
- bridge.completeCourse?.();
47
- return;
48
- case "submitAssessment": {
49
- const scaled = normalizeScore({
50
- score: action.score,
51
- maxScore: action.maxScore
52
- });
53
- if (scaled === null) return;
54
- bridge.submitAssessment?.({
55
- id: action.id,
56
- score: scaled,
57
- passingScore: normalizePassingThreshold({
58
- passingScore: action.passingScore,
59
- maxScore: action.maxScore
60
- }),
61
- maxScore: action.maxScore
62
- });
63
- return;
64
- }
65
- case "track":
66
- bridge.track?.(action.event);
67
- return;
68
- default:
69
- return;
70
- }
42
+
43
+ // src/runtime/telemetryPipeline.ts
44
+ function isDevEnvironment() {
45
+ const g = globalThis;
46
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
71
47
  }
72
- function forwardTelemetryToLxpack(event, mode = "auto") {
73
- if (mode === "off") return;
74
- const bridge = getBridge();
75
- if (!bridge) return;
76
- const lessonkitEvent = telemetryEventToLessonkit(event);
77
- if (!lessonkitEvent) return;
78
- const action = mapLessonkitTelemetryToBridgeAction(lessonkitEvent);
79
- applyBridgeAction(bridge, action);
48
+ function createLegacyPipeline(opts, extraSinks = []) {
49
+ return createTelemetryPipeline([
50
+ createTrackingPipelineSink("tracking", (event) => opts.tracking.track(event)),
51
+ {
52
+ id: "xapi",
53
+ emit(event) {
54
+ try {
55
+ const statement = telemetryEventToXAPIStatement(event);
56
+ if (statement) opts.xapi?.send(statement);
57
+ } catch (err) {
58
+ if (isDevEnvironment()) {
59
+ console.warn(
60
+ "[lessonkit] xAPI mapping skipped:",
61
+ err instanceof Error ? err.message : err
62
+ );
63
+ }
64
+ }
65
+ }
66
+ },
67
+ {
68
+ id: "lxpack-bridge",
69
+ emit(event) {
70
+ forwardTelemetryToLxpack(event, opts.lxpackBridge);
71
+ }
72
+ },
73
+ ...extraSinks
74
+ ]);
75
+ }
76
+ function emitThroughPipeline(event, opts, extraSinks) {
77
+ createLegacyPipeline(opts, extraSinks).emit(event);
80
78
  }
81
79
 
82
80
  // src/runtime/emitTelemetry.ts
83
81
  var warnedMissingCourseId = false;
84
- var warnedMissingQuizLesson = false;
85
- function isDevEnvironment() {
82
+ function isDevEnvironment2() {
86
83
  const g = globalThis;
87
84
  return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
88
85
  }
89
86
  function emitTelemetry(tracking, xapi, event, opts) {
90
87
  if (!event.courseId) {
91
- if (isDevEnvironment() && !warnedMissingCourseId) {
88
+ if (isDevEnvironment2() && !warnedMissingCourseId) {
92
89
  warnedMissingCourseId = true;
93
90
  console.warn("[lessonkit] telemetry event missing courseId");
94
91
  }
95
92
  return;
96
93
  }
97
- tracking.track(event);
98
- try {
99
- const statement = telemetryEventToXAPIStatement(event);
100
- if (statement) xapi?.send(statement);
101
- } catch (err) {
102
- if (isDevEnvironment()) {
103
- console.warn("[lessonkit] xAPI mapping skipped:", err instanceof Error ? err.message : err);
104
- }
105
- }
106
- forwardTelemetryToLxpack(event, opts?.lxpackBridge ?? "auto");
107
- }
108
- function buildTrackEvent(opts) {
109
- const base = {
110
- timestamp: nowIso(),
111
- courseId: opts.courseId,
112
- sessionId: opts.sessionId,
113
- attemptId: opts.attemptId,
114
- user: opts.user
94
+ const legacy = {
95
+ tracking,
96
+ xapi,
97
+ lxpackBridge: opts?.lxpackBridge ?? "auto"
115
98
  };
116
- switch (opts.name) {
117
- case "course_started":
118
- return { name: "course_started", ...base };
119
- case "course_completed":
120
- return { name: "course_completed", ...base };
121
- case "lesson_started": {
122
- const data = opts.data;
123
- const lessonId = opts.lessonId ?? data?.lessonId;
124
- if (!lessonId) throw new Error("lesson_started requires lessonId");
125
- return {
126
- name: "lesson_started",
127
- ...base,
128
- lessonId,
129
- data: { ...data, lessonId }
130
- };
131
- }
132
- case "lesson_completed":
133
- case "lesson_time_on_task": {
134
- const data = opts.data;
135
- const lessonId = opts.lessonId ?? data?.lessonId;
136
- if (!lessonId) throw new Error(`${opts.name} requires lessonId`);
137
- return {
138
- name: opts.name,
139
- ...base,
140
- lessonId,
141
- data: { ...data, lessonId }
142
- };
143
- }
144
- case "quiz_answered": {
145
- const data = opts.data;
146
- const lessonId = opts.lessonId;
147
- if (!lessonId) throw new Error("quiz_answered requires active lessonId");
148
- return { name: "quiz_answered", ...base, lessonId, data };
149
- }
150
- case "quiz_completed": {
151
- const data = opts.data;
152
- const lessonId = opts.lessonId;
153
- if (!lessonId) throw new Error("quiz_completed requires active lessonId");
154
- return { name: "quiz_completed", ...base, lessonId, data };
155
- }
156
- case "interaction":
157
- return {
158
- name: "interaction",
159
- ...base,
160
- lessonId: opts.lessonId,
161
- data: opts.data
162
- };
163
- default:
164
- return { name: opts.name, ...base };
165
- }
166
- }
167
- function tryBuildTrackEvent(opts) {
168
- const isQuiz = opts.name === "quiz_answered" || opts.name === "quiz_completed";
169
- if (isQuiz && !opts.lessonId) {
170
- if (isDevEnvironment() && !warnedMissingQuizLesson) {
171
- warnedMissingQuizLesson = true;
172
- console.warn(
173
- `[lessonkit] ${opts.name} skipped: wrap <Quiz> in <Lesson> so an active lessonId is available`
174
- );
175
- }
176
- return null;
177
- }
178
- return buildTrackEvent(opts);
99
+ emitThroughPipeline(event, legacy, opts?.extraSinks);
179
100
  }
180
101
 
181
102
  // src/runtime/ports.ts
182
- function createNoopStorage() {
183
- return {
184
- getItem: () => null,
185
- setItem: () => {
186
- }
187
- };
188
- }
189
- function createSessionStoragePort() {
190
- if (typeof sessionStorage === "undefined") return createNoopStorage();
191
- return {
192
- getItem: (key) => {
193
- try {
194
- return sessionStorage.getItem(key);
195
- } catch {
196
- return null;
197
- }
198
- },
199
- setItem: (key, value) => {
200
- try {
201
- sessionStorage.setItem(key, value);
202
- } catch {
203
- }
204
- },
205
- removeItem: (key) => {
206
- try {
207
- sessionStorage.removeItem(key);
208
- } catch {
209
- }
210
- }
211
- };
212
- }
103
+ import {
104
+ createDefaultClock,
105
+ createGlobalTimer,
106
+ createNoopStorage,
107
+ createSessionStoragePort,
108
+ resetStoragePortForTests
109
+ } from "@lessonkit/core";
213
110
 
214
111
  // src/runtime/progress.ts
215
- function createProgressController() {
216
- let activeLessonId;
217
- let completedLessonIds = /* @__PURE__ */ new Set();
218
- let courseCompleted = false;
219
- const lessonStartTimes = /* @__PURE__ */ new Map();
220
- return {
221
- getState: () => ({
222
- activeLessonId,
223
- completedLessonIds: new Set(completedLessonIds),
224
- courseCompleted
225
- }),
226
- setActiveLesson: (lessonId, startedAtMs) => {
227
- const previousLessonId = activeLessonId;
228
- activeLessonId = lessonId;
229
- lessonStartTimes.set(lessonId, startedAtMs);
230
- return { previousLessonId };
231
- },
232
- completeLesson: (lessonId, completedAtMs) => {
233
- if (completedLessonIds.has(lessonId)) return { didComplete: false };
234
- completedLessonIds = new Set(completedLessonIds).add(lessonId);
235
- if (activeLessonId === lessonId) {
236
- activeLessonId = void 0;
237
- }
238
- const startedAt = lessonStartTimes.get(lessonId);
239
- lessonStartTimes.delete(lessonId);
240
- const durationMs = typeof startedAt === "number" ? Math.max(0, completedAtMs - startedAt) : void 0;
241
- return { durationMs, didComplete: true };
242
- },
243
- completeCourse: () => {
244
- if (courseCompleted) return { didComplete: false };
245
- courseCompleted = true;
246
- return { didComplete: true };
247
- }
248
- };
249
- }
112
+ import { createProgressController } from "@lessonkit/core";
250
113
 
251
114
  // src/runtime/xapi.ts
252
115
  import { createXAPIClient } from "@lessonkit/xapi";
@@ -264,56 +127,38 @@ function createXapiClientFromConfig(config, queue) {
264
127
  }
265
128
 
266
129
  // src/runtime/session.ts
267
- import { createSessionId } from "@lessonkit/core";
268
- var SESSION_STORAGE_KEY = "lessonkit:sessionId";
269
- function getTabSessionId(storage) {
270
- return storage.getItem(SESSION_STORAGE_KEY);
271
- }
272
- var COURSE_STARTED_PREFIX = "lessonkit:course_started:";
273
- function resolveSessionId(storage, provided) {
274
- if (provided) return provided;
275
- const existing = storage.getItem(SESSION_STORAGE_KEY);
276
- if (existing) return existing;
277
- const id = createSessionId();
278
- storage.setItem(SESSION_STORAGE_KEY, id);
279
- return id;
280
- }
281
- function courseStartedStorageKey(sessionId, courseId) {
282
- return `${COURSE_STARTED_PREFIX}${sessionId}:${courseId ?? ""}`;
283
- }
284
- function hasCourseStarted(storage, sessionId, courseId) {
285
- if (!courseId) return false;
286
- return storage.getItem(courseStartedStorageKey(sessionId, courseId)) === "1";
287
- }
288
- function markCourseStarted(storage, sessionId, courseId) {
289
- if (!courseId) return;
290
- storage.setItem(courseStartedStorageKey(sessionId, courseId), "1");
291
- }
292
- function migrateCourseStartedMark(storage, fromSessionId, toSessionId, courseId) {
293
- if (!courseId || fromSessionId === toSessionId) return;
294
- if (hasCourseStarted(storage, fromSessionId, courseId)) {
295
- markCourseStarted(storage, toSessionId, courseId);
296
- storage.removeItem?.(courseStartedStorageKey(fromSessionId, courseId));
297
- }
298
- }
130
+ import {
131
+ SESSION_STORAGE_KEY,
132
+ getTabSessionId,
133
+ resolveSessionId,
134
+ hasCourseStarted,
135
+ markCourseStarted,
136
+ hasCourseStartedEmittedToTracking,
137
+ markCourseStartedEmittedToTracking,
138
+ migrateCourseStartedMark
139
+ } from "@lessonkit/core";
299
140
 
300
141
  // src/runtime/plugins.ts
301
- import { createPluginHost } from "@lessonkit/core";
142
+ import { createPluginRegistry } from "@lessonkit/core";
302
143
  function createReactPluginHost(plugins) {
303
144
  if (!plugins?.length) return null;
304
- return createPluginHost(plugins);
145
+ return createPluginRegistry(plugins);
305
146
  }
306
147
  function buildPluginContext(opts) {
307
148
  return {
308
149
  courseId: opts.courseId,
309
150
  sessionId: opts.sessionId,
310
- attemptId: opts.attemptId
151
+ attemptId: opts.attemptId,
152
+ user: opts.user
311
153
  };
312
154
  }
313
155
  function emitTelemetryWithPlugins(opts) {
314
156
  const next = opts.pluginHost ? opts.pluginHost.runTelemetry(opts.event, opts.pluginCtx) : opts.event;
315
157
  if (next === null) return;
316
- emitTelemetry(opts.tracking, opts.xapi, next, { lxpackBridge: opts.lxpackBridge ?? "auto" });
158
+ emitTelemetry(opts.tracking, opts.xapi, next, {
159
+ lxpackBridge: opts.lxpackBridge ?? "auto",
160
+ extraSinks: opts.extraSinks
161
+ });
317
162
  }
318
163
 
319
164
  // src/runtime/telemetry.ts
@@ -338,34 +183,46 @@ async function disposeTrackingClient(client) {
338
183
  }
339
184
  }
340
185
 
341
- // src/context.tsx
342
- import { jsx } from "react/jsx-runtime";
343
- var LessonkitContext = createContext(null);
186
+ // src/provider/useLessonkitProviderRuntime.ts
344
187
  var useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
345
188
  var defaultStorage = createSessionStoragePort();
346
189
  function isTrackingActive(tracking) {
347
190
  return tracking?.enabled !== false;
348
191
  }
349
- function emitCourseStarted(opts) {
192
+ var noopTrackingClient = { track: () => {
193
+ } };
194
+ function buildCourseStartedEvent(opts) {
350
195
  const pluginCtx = buildPluginContext({
351
196
  courseId: opts.courseId,
352
197
  sessionId: opts.sessionId,
353
- attemptId: opts.attemptId
198
+ attemptId: opts.attemptId,
199
+ user: opts.user
200
+ });
201
+ const built = buildTelemetryEvent({
202
+ name: "course_started",
203
+ courseId: opts.courseId,
204
+ sessionId: opts.sessionId,
205
+ attemptId: opts.attemptId,
206
+ user: opts.user
207
+ });
208
+ return opts.pluginHost ? opts.pluginHost.runTelemetry(built, pluginCtx) : built;
209
+ }
210
+ function emitCourseStartedPipelineOnly(opts) {
211
+ const pluginCtx = buildPluginContext({
212
+ courseId: opts.courseId,
213
+ sessionId: opts.sessionId,
214
+ attemptId: opts.attemptId,
215
+ user: opts.user
354
216
  });
355
217
  try {
356
218
  emitTelemetryWithPlugins({
357
- pluginHost: opts.pluginHost,
358
- tracking: opts.tracking,
219
+ pluginHost: null,
220
+ tracking: noopTrackingClient,
359
221
  xapi: opts.xapi,
360
- event: buildTrackEvent({
361
- name: "course_started",
362
- courseId: opts.courseId,
363
- sessionId: opts.sessionId,
364
- attemptId: opts.attemptId,
365
- user: opts.user
366
- }),
222
+ event: opts.event,
367
223
  pluginCtx,
368
- lxpackBridge: opts.lxpackBridge
224
+ lxpackBridge: opts.lxpackBridge,
225
+ extraSinks: opts.extraSinks
369
226
  });
370
227
  markCourseStarted(opts.storage, opts.sessionId, opts.courseId);
371
228
  return true;
@@ -373,35 +230,159 @@ function emitCourseStarted(opts) {
373
230
  return false;
374
231
  }
375
232
  }
376
- function LessonkitProvider(props) {
377
- const config = props.config;
378
- const sessionIdRef = useRef(resolveSessionId(defaultStorage, config.session?.sessionId));
379
- const prevConfiguredSessionIdRef = useRef(config.session?.sessionId);
380
- if (config.session?.sessionId) {
381
- sessionIdRef.current = config.session.sessionId;
233
+ function emitCourseStarted(opts) {
234
+ const event = buildCourseStartedEvent(opts);
235
+ if (event === null) return true;
236
+ const trackingAlreadyEmitted = hasCourseStartedEmittedToTracking(
237
+ opts.storage,
238
+ opts.sessionId,
239
+ opts.courseId
240
+ );
241
+ if (!trackingAlreadyEmitted) {
242
+ try {
243
+ opts.tracking.track(event);
244
+ markCourseStartedEmittedToTracking(opts.storage, opts.sessionId, opts.courseId);
245
+ } catch {
246
+ return false;
247
+ }
248
+ }
249
+ return emitCourseStartedPipelineOnly({ ...opts, event });
250
+ }
251
+ function emitCourseStartedToTrackingOnly(opts) {
252
+ const event = buildCourseStartedEvent(opts);
253
+ if (event === null) return true;
254
+ const trackingAlreadyEmitted = hasCourseStartedEmittedToTracking(
255
+ opts.storage,
256
+ opts.sessionId,
257
+ opts.courseId
258
+ );
259
+ if (!trackingAlreadyEmitted) {
260
+ try {
261
+ opts.tracking.track(event);
262
+ markCourseStartedEmittedToTracking(opts.storage, opts.sessionId, opts.courseId);
263
+ } catch {
264
+ return false;
265
+ }
266
+ }
267
+ const pluginCtx = buildPluginContext({
268
+ courseId: opts.courseId,
269
+ sessionId: opts.sessionId,
270
+ attemptId: opts.attemptId,
271
+ user: opts.user
272
+ });
273
+ try {
274
+ emitTelemetryWithPlugins({
275
+ pluginHost: null,
276
+ tracking: noopTrackingClient,
277
+ xapi: null,
278
+ event,
279
+ pluginCtx,
280
+ lxpackBridge: opts.lxpackBridge,
281
+ extraSinks: opts.extraSinks
282
+ });
283
+ return true;
284
+ } catch {
285
+ return false;
286
+ }
287
+ }
288
+ function emitPendingCourseStarted(opts) {
289
+ const trackingEmitted = hasCourseStartedEmittedToTracking(
290
+ opts.storage,
291
+ opts.sessionId,
292
+ opts.courseId
293
+ );
294
+ const sessionStarted = hasCourseStarted(opts.storage, opts.sessionId, opts.courseId);
295
+ if (sessionStarted && !trackingEmitted) {
296
+ return emitCourseStartedToTrackingOnly(opts);
297
+ }
298
+ if (trackingEmitted && !sessionStarted) {
299
+ const event = buildCourseStartedEvent(opts);
300
+ if (event === null) return true;
301
+ return emitCourseStartedPipelineOnly({ ...opts, event });
302
+ }
303
+ if (!trackingEmitted && !sessionStarted) {
304
+ return emitCourseStarted(opts);
305
+ }
306
+ return true;
307
+ }
308
+ function assertTrackingSinkConfig(tracking) {
309
+ if (!tracking?.sink || !tracking?.batchSink) return;
310
+ throw new Error(
311
+ "[lessonkit] tracking.sink and tracking.batchSink cannot both be set; use batchSink alone for batched delivery"
312
+ );
313
+ }
314
+ function useLessonkitProviderRuntime(config) {
315
+ const normalizedCourseId = useMemo(
316
+ () => assertValidId(config.courseId, "courseId"),
317
+ [config.courseId]
318
+ );
319
+ const normalizedConfig = useMemo(
320
+ () => ({ ...config, courseId: normalizedCourseId }),
321
+ [config, normalizedCourseId]
322
+ );
323
+ const useV2Runtime = normalizedConfig.runtimeVersion !== "v1";
324
+ const extraSinksRef = useRef(normalizedConfig.sinks);
325
+ extraSinksRef.current = normalizedConfig.sinks;
326
+ const headlessRef = useRef(null);
327
+ const sessionIdRef = useRef(resolveSessionId(defaultStorage, normalizedConfig.session?.sessionId));
328
+ const prevConfiguredSessionIdRef = useRef(normalizedConfig.session?.sessionId);
329
+ if (normalizedConfig.session?.sessionId) {
330
+ sessionIdRef.current = normalizedConfig.session.sessionId;
382
331
  } else if (prevConfiguredSessionIdRef.current) {
383
332
  sessionIdRef.current = resolveSessionId(defaultStorage, void 0);
384
333
  }
385
- const attemptIdRef = useRef(config.session?.attemptId);
386
- const userRef = useRef(config.session?.user);
387
- attemptIdRef.current = config.session?.attemptId;
388
- userRef.current = config.session?.user;
389
- const courseIdRef = useRef(config.courseId);
390
- courseIdRef.current = config.courseId;
391
- const lxpackBridgeModeRef = useRef(config.lxpack?.bridge ?? "auto");
392
- lxpackBridgeModeRef.current = config.lxpack?.bridge ?? "auto";
393
- const pluginHost = useMemo(() => createReactPluginHost(config.plugins), [config.plugins]);
334
+ const attemptIdRef = useRef(normalizedConfig.session?.attemptId);
335
+ const userRef = useRef(normalizedConfig.session?.user);
336
+ attemptIdRef.current = normalizedConfig.session?.attemptId;
337
+ userRef.current = normalizedConfig.session?.user;
338
+ const courseIdRef = useRef(normalizedCourseId);
339
+ courseIdRef.current = normalizedCourseId;
340
+ const lxpackBridgeModeRef = useRef(normalizedConfig.lxpack?.bridge ?? "auto");
341
+ lxpackBridgeModeRef.current = normalizedConfig.lxpack?.bridge ?? "auto";
342
+ const pluginHost = useMemo(() => createReactPluginHost(normalizedConfig.plugins), [normalizedConfig.plugins]);
394
343
  const pluginHostRef = useRef(pluginHost);
395
344
  pluginHostRef.current = pluginHost;
396
345
  const progressRef = useRef(createProgressController());
397
346
  const courseStartedEmittedToSinkRef = useRef(false);
398
- const prevCourseIdForProgressRef = useRef(config.courseId);
347
+ const prevCourseIdForProgressRef = useRef(normalizedCourseId);
399
348
  const pendingCourseIdResetRef = useRef(false);
400
- if (prevCourseIdForProgressRef.current !== config.courseId) {
401
- prevCourseIdForProgressRef.current = config.courseId;
402
- progressRef.current = createProgressController();
349
+ const prevUseV2RuntimeRef = useRef(useV2Runtime);
350
+ const xapiCourseStartedSentOnClientRef = useRef(false);
351
+ if (prevUseV2RuntimeRef.current !== useV2Runtime) {
352
+ prevUseV2RuntimeRef.current = useV2Runtime;
353
+ if (useV2Runtime) {
354
+ headlessRef.current = createLessonkitRuntime({
355
+ courseId: normalizedCourseId,
356
+ runtimeVersion: "v2",
357
+ session: normalizedConfig.session
358
+ });
359
+ progressRef.current = headlessRef.current.progress;
360
+ } else {
361
+ headlessRef.current = null;
362
+ progressRef.current = createProgressController();
363
+ }
403
364
  pendingCourseIdResetRef.current = true;
404
365
  courseStartedEmittedToSinkRef.current = false;
366
+ } else if (useV2Runtime && !headlessRef.current) {
367
+ headlessRef.current = createLessonkitRuntime({
368
+ courseId: normalizedCourseId,
369
+ runtimeVersion: "v2",
370
+ session: normalizedConfig.session
371
+ });
372
+ }
373
+ if (prevCourseIdForProgressRef.current !== normalizedCourseId) {
374
+ prevCourseIdForProgressRef.current = normalizedCourseId;
375
+ if (useV2Runtime && headlessRef.current) {
376
+ headlessRef.current.resetForCourseChange(normalizedCourseId);
377
+ progressRef.current = headlessRef.current.progress;
378
+ } else {
379
+ progressRef.current = createProgressController();
380
+ }
381
+ pendingCourseIdResetRef.current = true;
382
+ courseStartedEmittedToSinkRef.current = false;
383
+ }
384
+ if (useV2Runtime && headlessRef.current) {
385
+ progressRef.current = headlessRef.current.progress;
405
386
  }
406
387
  const [progress, setProgress] = useState(() => progressRef.current.getState());
407
388
  const syncProgress = useCallback(() => {
@@ -412,16 +393,16 @@ function LessonkitProvider(props) {
412
393
  const xapiQueueRef = useRef(createInMemoryXAPIQueue());
413
394
  const xapiRef = useRef(null);
414
395
  const [xapi, setXapi] = useState(null);
415
- const prevXapiCourseIdRef = useRef(config.courseId);
416
- const xapiEnabled = config.xapi?.enabled;
417
- const xapiClient = config.xapi?.client;
418
- const xapiTransport = config.xapi?.transport;
419
- const courseId = config.courseId;
420
- const trackingEnabled = config.tracking?.enabled;
396
+ const prevXapiCourseIdRef = useRef(normalizedCourseId);
397
+ const xapiEnabled = normalizedConfig.xapi?.enabled;
398
+ const xapiClient = normalizedConfig.xapi?.client;
399
+ const xapiTransport = normalizedConfig.xapi?.transport;
400
+ const courseId = normalizedCourseId;
401
+ const trackingEnabled = normalizedConfig.tracking?.enabled;
421
402
  useIsoLayoutEffect(() => {
422
403
  const courseChanged = prevXapiCourseIdRef.current !== courseId;
423
404
  if (courseChanged) {
424
- if (config.xapi?.client) {
405
+ if (normalizedConfig.xapi?.client) {
425
406
  const g = globalThis;
426
407
  if (typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production") {
427
408
  console.warn(
@@ -432,20 +413,24 @@ function LessonkitProvider(props) {
432
413
  }
433
414
  xapiQueueRef.current = createInMemoryXAPIQueue();
434
415
  prevXapiCourseIdRef.current = courseId;
416
+ xapiCourseStartedSentOnClientRef.current = false;
435
417
  }
436
418
  const prev = xapiRef.current;
437
- const next = createXapiClientFromConfig(config, xapiQueueRef.current);
419
+ const next = createXapiClientFromConfig(normalizedConfig, xapiQueueRef.current);
438
420
  xapiRef.current = next;
439
421
  setXapi(next);
440
- if (next && !prev) {
422
+ if (next) {
441
423
  const sessionId = sessionIdRef.current;
442
424
  const cid = courseIdRef.current;
443
- const trackingActive = isTrackingActive(config.tracking);
425
+ const trackingActive = isTrackingActive(normalizedConfig.tracking);
444
426
  const alreadyStarted = hasCourseStarted(defaultStorage, sessionId, cid);
445
- if (!trackingActive || alreadyStarted) {
427
+ const clientChanged = !prev || prev !== next;
428
+ const skipBootstrap = trackingActive && !alreadyStarted;
429
+ const needsBootstrap = !skipBootstrap && !xapiCourseStartedSentOnClientRef.current && (!alreadyStarted || clientChanged);
430
+ if (needsBootstrap) {
446
431
  try {
447
432
  const statement = telemetryEventToXAPIStatement2(
448
- buildTrackEvent({
433
+ buildTelemetryEvent({
449
434
  name: "course_started",
450
435
  courseId: cid,
451
436
  sessionId,
@@ -455,7 +440,10 @@ function LessonkitProvider(props) {
455
440
  );
456
441
  if (statement) {
457
442
  next.send(statement);
458
- markCourseStarted(defaultStorage, sessionId, cid);
443
+ if (!alreadyStarted) {
444
+ markCourseStarted(defaultStorage, sessionId, cid);
445
+ }
446
+ xapiCourseStartedSentOnClientRef.current = true;
459
447
  }
460
448
  } catch {
461
449
  }
@@ -483,43 +471,53 @@ function LessonkitProvider(props) {
483
471
  const trackingRef = useRef(createTrackingClient2());
484
472
  const trackingClientForUnmountRef = useRef(trackingRef.current);
485
473
  const [tracking, setTracking] = useState(() => trackingRef.current);
486
- const trackingSink = config.tracking?.sink;
487
- const trackingBatchSink = config.tracking?.batchSink;
488
- const batchEnabled = config.tracking?.batch?.enabled;
489
- const batchFlushIntervalMs = config.tracking?.batch?.flushIntervalMs;
490
- const batchMaxBatchSize = config.tracking?.batch?.maxBatchSize;
474
+ const trackingSink = normalizedConfig.tracking?.sink;
475
+ const trackingBatchSink = normalizedConfig.tracking?.batchSink;
476
+ const batchEnabled = normalizedConfig.tracking?.batch?.enabled;
477
+ const batchFlushIntervalMs = normalizedConfig.tracking?.batch?.flushIntervalMs;
478
+ const batchMaxBatchSize = normalizedConfig.tracking?.batch?.maxBatchSize;
491
479
  const buildCurrentPluginCtx = useCallback(
492
480
  () => buildPluginContext({
493
481
  courseId: courseIdRef.current,
494
482
  sessionId: sessionIdRef.current,
495
- attemptId: attemptIdRef.current
483
+ attemptId: attemptIdRef.current,
484
+ user: userRef.current
496
485
  }),
497
486
  []
498
487
  );
499
488
  useIsoLayoutEffect(() => {
500
489
  const prev = trackingRef.current;
501
- const baseSink = config.tracking?.sink;
490
+ const baseSink = normalizedConfig.tracking?.sink;
491
+ const userBatchSink = normalizedConfig.tracking?.batchSink;
492
+ assertTrackingSinkConfig(normalizedConfig.tracking);
502
493
  const sink = pluginHostRef.current && baseSink ? pluginHostRef.current.composeTrackingSink(baseSink, buildCurrentPluginCtx) ?? baseSink : baseSink;
503
- const batchSink = pluginHostRef.current && config.tracking?.batchSink ? (events) => {
504
- const delivered = pluginHostRef.current.deliverTelemetryBatch(
505
- events,
506
- buildCurrentPluginCtx()
507
- );
508
- return config.tracking.batchSink(delivered);
509
- } : config.tracking?.batchSink;
494
+ const batchSink = pluginHostRef.current && userBatchSink ? async (events) => {
495
+ const host = pluginHostRef.current;
496
+ const ctx = buildCurrentPluginCtx();
497
+ const delivered = host.deliverTelemetryBatch(events, ctx);
498
+ const perEventForBatch = [];
499
+ const collector = (event) => {
500
+ perEventForBatch.push(event);
501
+ };
502
+ const composedPerEvent = host.composeTrackingSink(collector, buildCurrentPluginCtx) ?? collector;
503
+ for (const event of delivered) {
504
+ await Promise.resolve(composedPerEvent(event));
505
+ }
506
+ return userBatchSink(perEventForBatch);
507
+ } : userBatchSink;
510
508
  const next = createTrackingClientFromConfig({
511
- tracking: { ...config.tracking, sink, batchSink }
509
+ tracking: { ...normalizedConfig.tracking, sink, batchSink }
512
510
  });
513
511
  trackingRef.current = next;
514
512
  trackingClientForUnmountRef.current = next;
515
513
  setTracking(next);
516
514
  const sessionId = sessionIdRef.current;
517
515
  const cid = courseIdRef.current;
518
- const trackingActive = isTrackingActive(config.tracking);
516
+ const trackingActive = isTrackingActive(normalizedConfig.tracking);
519
517
  if (!trackingActive) {
520
518
  courseStartedEmittedToSinkRef.current = false;
521
- } else if (!courseStartedEmittedToSinkRef.current && !hasCourseStarted(defaultStorage, sessionId, cid)) {
522
- const emitted = emitCourseStarted({
519
+ } else if (!courseStartedEmittedToSinkRef.current) {
520
+ const emitted = emitPendingCourseStarted({
523
521
  pluginHost: pluginHostRef.current,
524
522
  tracking: next,
525
523
  xapi: xapiRef.current,
@@ -528,8 +526,12 @@ function LessonkitProvider(props) {
528
526
  courseId: cid,
529
527
  attemptId: attemptIdRef.current,
530
528
  user: userRef.current,
531
- lxpackBridge: lxpackBridgeModeRef.current
529
+ lxpackBridge: lxpackBridgeModeRef.current,
530
+ extraSinks: extraSinksRef.current
532
531
  });
532
+ if (emitted) {
533
+ markCourseStartedEmittedToTracking(defaultStorage, sessionId, cid);
534
+ }
533
535
  courseStartedEmittedToSinkRef.current = emitted;
534
536
  } else if (trackingActive) {
535
537
  courseStartedEmittedToSinkRef.current = true;
@@ -546,8 +548,8 @@ function LessonkitProvider(props) {
546
548
  batchEnabled,
547
549
  batchFlushIntervalMs,
548
550
  batchMaxBatchSize,
549
- config.plugins,
550
- config.courseId,
551
+ normalizedConfig.plugins,
552
+ normalizedCourseId,
551
553
  buildCurrentPluginCtx
552
554
  ]);
553
555
  const emitWithBridge = useCallback((trackingClient, event) => {
@@ -559,14 +561,32 @@ function LessonkitProvider(props) {
559
561
  pluginCtx: buildPluginContext({
560
562
  courseId: courseIdRef.current,
561
563
  sessionId: sessionIdRef.current,
562
- attemptId: attemptIdRef.current
564
+ attemptId: attemptIdRef.current,
565
+ user: userRef.current
563
566
  }),
564
- lxpackBridge: lxpackBridgeModeRef.current
567
+ lxpackBridge: lxpackBridgeModeRef.current,
568
+ extraSinks: extraSinksRef.current
565
569
  });
566
570
  }, []);
571
+ const emitLifecycleEvent = useCallback(
572
+ (name, data, lessonId) => {
573
+ const event = tryBuildTelemetryEvent({
574
+ name,
575
+ courseId: courseIdRef.current,
576
+ lessonId: lessonId ?? activeLessonIdRef.current,
577
+ sessionId: sessionIdRef.current,
578
+ attemptId: attemptIdRef.current,
579
+ user: userRef.current,
580
+ data
581
+ });
582
+ if (!event) return;
583
+ emitWithBridge(trackingRef.current, event);
584
+ },
585
+ [emitWithBridge]
586
+ );
567
587
  const track = useCallback(
568
588
  (name, data, opts) => {
569
- const event = tryBuildTrackEvent({
589
+ const event = tryBuildTelemetryEvent({
570
590
  name,
571
591
  courseId: courseIdRef.current,
572
592
  lessonId: opts?.lessonId ?? activeLessonIdRef.current,
@@ -584,7 +604,7 @@ function LessonkitProvider(props) {
584
604
  if (!pendingCourseIdResetRef.current) return;
585
605
  pendingCourseIdResetRef.current = false;
586
606
  syncProgress();
587
- if (!isTrackingActive(config.tracking)) return;
607
+ if (!isTrackingActive(normalizedConfig.tracking)) return;
588
608
  const sessionId = sessionIdRef.current;
589
609
  const cid = courseIdRef.current;
590
610
  void (async () => {
@@ -592,8 +612,8 @@ function LessonkitProvider(props) {
592
612
  await trackingRef.current?.flush?.();
593
613
  } catch {
594
614
  }
595
- if (!courseStartedEmittedToSinkRef.current && !hasCourseStarted(defaultStorage, sessionId, cid)) {
596
- const emitted = emitCourseStarted({
615
+ if (!courseStartedEmittedToSinkRef.current) {
616
+ const emitted = emitPendingCourseStarted({
597
617
  pluginHost: pluginHostRef.current,
598
618
  tracking: trackingRef.current,
599
619
  xapi: xapiRef.current,
@@ -602,12 +622,13 @@ function LessonkitProvider(props) {
602
622
  courseId: cid,
603
623
  attemptId: attemptIdRef.current,
604
624
  user: userRef.current,
605
- lxpackBridge: lxpackBridgeModeRef.current
625
+ lxpackBridge: lxpackBridgeModeRef.current,
626
+ extraSinks: extraSinksRef.current
606
627
  });
607
628
  courseStartedEmittedToSinkRef.current = emitted;
608
629
  }
609
630
  })();
610
- }, [config.courseId, config.tracking?.enabled, syncProgress]);
631
+ }, [normalizedCourseId, normalizedConfig.tracking?.enabled, syncProgress]);
611
632
  const emitLessonCompleted = useCallback(
612
633
  (lessonId, durationMs) => {
613
634
  track("lesson_completed", { lessonId, durationMs }, { lessonId });
@@ -619,13 +640,19 @@ function LessonkitProvider(props) {
619
640
  );
620
641
  const completeLesson = useCallback(
621
642
  (lessonId) => {
643
+ if (useV2Runtime && headlessRef.current) {
644
+ headlessRef.current.completeLesson(lessonId, emitLifecycleEvent);
645
+ syncProgress();
646
+ void Promise.resolve(trackingRef.current?.flush?.());
647
+ return;
648
+ }
622
649
  const result = progressRef.current.completeLesson(lessonId, Date.now());
623
650
  if (!result.didComplete) return;
624
651
  syncProgress();
625
652
  emitLessonCompleted(lessonId, result.durationMs);
626
653
  void Promise.resolve(trackingRef.current?.flush?.());
627
654
  },
628
- [syncProgress, emitLessonCompleted]
655
+ [syncProgress, emitLessonCompleted, useV2Runtime, emitLifecycleEvent]
629
656
  );
630
657
  useEffect(() => {
631
658
  return () => {
@@ -649,8 +676,19 @@ function LessonkitProvider(props) {
649
676
  }, []);
650
677
  const setActiveLesson = useCallback(
651
678
  (lessonId) => {
679
+ if (useV2Runtime && headlessRef.current) {
680
+ headlessRef.current.setActiveLesson(lessonId, emitLifecycleEvent);
681
+ syncProgress();
682
+ void Promise.resolve(trackingRef.current?.flush?.());
683
+ return;
684
+ }
652
685
  const current = progressRef.current.getState();
653
686
  if (current.activeLessonId === lessonId) return;
687
+ if (current.completedLessonIds.has(lessonId)) {
688
+ progressRef.current.setActiveLesson(lessonId, Date.now());
689
+ syncProgress();
690
+ return;
691
+ }
654
692
  const previous = current.activeLessonId;
655
693
  if (previous && previous !== lessonId) {
656
694
  const completed = progressRef.current.completeLesson(previous, Date.now());
@@ -663,9 +701,15 @@ function LessonkitProvider(props) {
663
701
  syncProgress();
664
702
  track("lesson_started", { lessonId }, { lessonId });
665
703
  },
666
- [track, syncProgress, emitLessonCompleted]
704
+ [track, syncProgress, emitLessonCompleted, useV2Runtime, emitLifecycleEvent]
667
705
  );
668
706
  const completeCourse = useCallback(() => {
707
+ if (useV2Runtime && headlessRef.current) {
708
+ headlessRef.current.completeCourse(emitLifecycleEvent);
709
+ syncProgress();
710
+ void trackingRef.current?.flush?.();
711
+ return;
712
+ }
669
713
  const current = progressRef.current.getState();
670
714
  if (current.activeLessonId) {
671
715
  const lessonResult = progressRef.current.completeLesson(current.activeLessonId, Date.now());
@@ -678,24 +722,37 @@ function LessonkitProvider(props) {
678
722
  syncProgress();
679
723
  track("course_completed");
680
724
  void trackingRef.current?.flush?.();
681
- }, [track, syncProgress, emitLessonCompleted]);
682
- const sessionUser = config.session?.user;
683
- const sessionAttemptId = config.session?.attemptId;
684
- const sessionConfiguredId = config.session?.sessionId;
725
+ }, [track, syncProgress, emitLessonCompleted, useV2Runtime, emitLifecycleEvent]);
726
+ const sessionUser = normalizedConfig.session?.user;
727
+ const sessionUserKey = useMemo(
728
+ () => sessionUser ? JSON.stringify(sessionUser) : "",
729
+ [sessionUser]
730
+ );
731
+ const sessionAttemptId = normalizedConfig.session?.attemptId;
732
+ const sessionConfiguredId = normalizedConfig.session?.sessionId;
733
+ useEffect(() => {
734
+ if (useV2Runtime && headlessRef.current) {
735
+ headlessRef.current.updateConfig({
736
+ courseId: normalizedCourseId,
737
+ session: normalizedConfig.session
738
+ });
739
+ }
740
+ }, [useV2Runtime, normalizedCourseId, sessionAttemptId, sessionConfiguredId, sessionUserKey, normalizedConfig.session]);
685
741
  useEffect(() => {
686
742
  if (!pluginHost) return;
687
743
  const ctx = buildPluginContext({
688
744
  courseId: courseIdRef.current,
689
745
  sessionId: sessionIdRef.current,
690
- attemptId: attemptIdRef.current
746
+ attemptId: attemptIdRef.current,
747
+ user: userRef.current
691
748
  });
692
749
  pluginHost.setupAll(ctx);
693
750
  return () => {
694
751
  pluginHost.disposeAll();
695
752
  };
696
- }, [pluginHost, config.courseId, sessionAttemptId, sessionConfiguredId]);
753
+ }, [pluginHost, normalizedCourseId, sessionAttemptId, sessionConfiguredId, sessionUserKey]);
697
754
  useEffect(() => {
698
- const nextConfigured = config.session?.sessionId;
755
+ const nextConfigured = normalizedConfig.session?.sessionId;
699
756
  const prevConfigured = prevConfiguredSessionIdRef.current;
700
757
  if (nextConfigured === prevConfigured) return;
701
758
  prevConfiguredSessionIdRef.current = nextConfigured;
@@ -716,10 +773,10 @@ function LessonkitProvider(props) {
716
773
  migrateCourseStartedMark(defaultStorage, prevConfigured, nextAuto, cid);
717
774
  sessionIdRef.current = nextAuto;
718
775
  }
719
- }, [sessionConfiguredId, config.courseId]);
776
+ }, [sessionConfiguredId, normalizedCourseId]);
720
777
  const runtime = useMemo(
721
778
  () => ({
722
- config,
779
+ config: normalizedConfig,
723
780
  tracking,
724
781
  xapi,
725
782
  session: { sessionId: sessionIdRef.current, attemptId: attemptIdRef.current, user: userRef.current },
@@ -731,7 +788,7 @@ function LessonkitProvider(props) {
731
788
  plugins: pluginHost
732
789
  }),
733
790
  [
734
- config,
791
+ normalizedConfig,
735
792
  tracking,
736
793
  xapi,
737
794
  progress,
@@ -745,6 +802,14 @@ function LessonkitProvider(props) {
745
802
  sessionConfiguredId
746
803
  ]
747
804
  );
805
+ return runtime;
806
+ }
807
+
808
+ // src/context.tsx
809
+ import { jsx } from "react/jsx-runtime";
810
+ var LessonkitContext = createContext(null);
811
+ function LessonkitProvider(props) {
812
+ const runtime = useLessonkitProviderRuntime(props.config);
748
813
  return /* @__PURE__ */ jsx(LessonkitContext.Provider, { value: runtime, children: props.children });
749
814
  }
750
815
 
@@ -767,46 +832,74 @@ function useCompletion() {
767
832
  const { completeLesson, completeCourse } = useLessonkit();
768
833
  return useMemo2(() => ({ completeLesson, completeCourse }), [completeLesson, completeCourse]);
769
834
  }
770
- function useQuizState() {
835
+ function useQuizState(enclosingLessonId) {
771
836
  const { track } = useLessonkit();
837
+ const trackOpts = enclosingLessonId ? { lessonId: enclosingLessonId } : void 0;
772
838
  return useMemo2(
773
839
  () => ({
774
840
  answer: (opts) => {
775
- track("quiz_answered", opts);
841
+ track("quiz_answered", opts, trackOpts);
776
842
  },
777
843
  complete: (opts) => {
778
- track("quiz_completed", opts);
844
+ track("quiz_completed", opts, trackOpts);
779
845
  }
780
846
  }),
781
- [track]
847
+ [track, enclosingLessonId]
782
848
  );
783
849
  }
784
850
 
851
+ // src/lessonContext.tsx
852
+ import { createContext as createContext2, useContext as useContext2 } from "react";
853
+ var LessonContext = createContext2(void 0);
854
+ function useEnclosingLessonId() {
855
+ return useContext2(LessonContext);
856
+ }
857
+
785
858
  // src/runtime/validateComponentId.ts
786
- import { validateId } from "@lessonkit/core";
787
- var warnedPaths = /* @__PURE__ */ new Set();
788
- function isDevEnvironment2() {
859
+ import { assertValidId as assertValidId2 } from "@lessonkit/core";
860
+ function isDevEnvironment3() {
789
861
  const g = globalThis;
790
862
  return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
791
863
  }
792
- function warnInvalidComponentId(id, path) {
793
- if (!isDevEnvironment2()) return;
794
- const key = `${path}:${String(id)}`;
795
- if (warnedPaths.has(key)) return;
796
- const result = validateId(id, path);
797
- if (result.ok) return;
798
- warnedPaths.add(key);
799
- const detail = result.issues.map((i) => `${i.path}: ${i.message}`).join("; ");
800
- console.warn(`[lessonkit] invalid ${path} \u2014 ${detail}`);
864
+ function normalizeComponentId(id, path) {
865
+ return assertValidId2(id, path);
866
+ }
867
+
868
+ // src/runtime/lessonMountRegistry.ts
869
+ var mountCounts = /* @__PURE__ */ new Map();
870
+ var warnedConcurrentLessons = false;
871
+ function registerLessonMount(lessonId) {
872
+ if (isDevEnvironment3() && mountCounts.size > 0 && !mountCounts.has(lessonId) && !warnedConcurrentLessons) {
873
+ warnedConcurrentLessons = true;
874
+ console.warn(
875
+ "[lessonkit] Multiple <Lesson> components are mounted; only one should be active at a time. Set autoCompleteOnUnmount={false} on routed lessons or unmount the previous lesson before showing the next."
876
+ );
877
+ }
878
+ mountCounts.set(lessonId, (mountCounts.get(lessonId) ?? 0) + 1);
879
+ return () => {
880
+ const next = (mountCounts.get(lessonId) ?? 1) - 1;
881
+ if (next <= 0) {
882
+ mountCounts.delete(lessonId);
883
+ } else {
884
+ mountCounts.set(lessonId, next);
885
+ }
886
+ };
887
+ }
888
+ function getLessonMountCount(lessonId) {
889
+ return mountCounts.get(lessonId) ?? 0;
801
890
  }
802
891
 
803
892
  // src/components.tsx
804
893
  import { jsx as jsx2, jsxs } from "react/jsx-runtime";
894
+ var warnedQuizOutsideLesson = false;
895
+ function resetQuizWarningsForTests() {
896
+ warnedQuizOutsideLesson = false;
897
+ }
805
898
  function Course(props) {
806
- warnInvalidComponentId(props.courseId, "courseId");
899
+ const courseId = useMemo3(() => normalizeComponentId(props.courseId, "courseId"), [props.courseId]);
807
900
  const providerConfig = useMemo3(
808
- () => ({ ...props.config, courseId: props.courseId }),
809
- [props.config, props.courseId]
901
+ () => ({ ...props.config, courseId }),
902
+ [props.config, courseId]
810
903
  );
811
904
  return /* @__PURE__ */ jsx2(LessonkitProvider, { config: providerConfig, children: /* @__PURE__ */ jsxs("section", { "aria-label": props.title, children: [
812
905
  /* @__PURE__ */ jsx2("h1", { children: props.title }),
@@ -814,41 +907,64 @@ function Course(props) {
814
907
  ] }) });
815
908
  }
816
909
  function Lesson(props) {
817
- warnInvalidComponentId(props.lessonId, "lessonId");
910
+ const lessonId = useMemo3(() => normalizeComponentId(props.lessonId, "lessonId"), [props.lessonId]);
911
+ const autoComplete = props.autoCompleteOnUnmount !== false;
818
912
  const { setActiveLesson, config } = useLessonkit();
819
913
  const { completeLesson } = useCompletion();
820
- const id = props.lessonId;
821
914
  const lessonMountGenerationRef = useRef2(0);
822
915
  useEffect2(() => {
916
+ const unregister = registerLessonMount(lessonId);
823
917
  const generation = ++lessonMountGenerationRef.current;
824
- setActiveLesson(id);
918
+ setActiveLesson(lessonId);
825
919
  return () => {
826
- const lessonId = id;
920
+ unregister();
921
+ if (getLessonMountCount(lessonId) > 0) {
922
+ return;
923
+ }
924
+ if (!autoComplete) return;
827
925
  queueMicrotask(() => {
828
926
  if (lessonMountGenerationRef.current !== generation) return;
829
927
  completeLesson(lessonId);
830
928
  });
831
929
  };
832
- }, [id, config.courseId, setActiveLesson, completeLesson]);
833
- return /* @__PURE__ */ jsxs("article", { "aria-label": props.title, children: [
930
+ }, [lessonId, config.courseId, setActiveLesson, completeLesson, autoComplete]);
931
+ return /* @__PURE__ */ jsx2(LessonContext.Provider, { value: lessonId, children: /* @__PURE__ */ jsxs("article", { "aria-label": props.title, children: [
834
932
  /* @__PURE__ */ jsx2("h2", { children: props.title }),
835
933
  /* @__PURE__ */ jsx2("div", { children: props.children })
836
- ] });
934
+ ] }) });
837
935
  }
838
936
  function Scenario(props) {
839
- if (props.blockId !== void 0) warnInvalidComponentId(props.blockId, "blockId");
840
- return /* @__PURE__ */ jsx2("section", { "aria-label": "Scenario", "data-lk-block-id": props.blockId, children: props.children });
937
+ const blockId = useMemo3(
938
+ () => props.blockId !== void 0 ? normalizeComponentId(props.blockId, "blockId") : void 0,
939
+ [props.blockId]
940
+ );
941
+ return /* @__PURE__ */ jsx2("section", { "aria-label": "Scenario", "data-lk-block-id": blockId, children: props.children });
841
942
  }
842
943
  function Reflection(props) {
843
- if (props.blockId !== void 0) warnInvalidComponentId(props.blockId, "blockId");
944
+ const blockId = useMemo3(
945
+ () => props.blockId !== void 0 ? normalizeComponentId(props.blockId, "blockId") : void 0,
946
+ [props.blockId]
947
+ );
844
948
  const promptId = useId();
845
- return /* @__PURE__ */ jsxs("section", { "aria-label": "Reflection", "data-lk-block-id": props.blockId, children: [
949
+ const hintId = useId();
950
+ const [internalValue, setInternalValue] = useState2("");
951
+ const isControlled = props.value !== void 0;
952
+ const value = isControlled ? props.value : internalValue;
953
+ const handleChange = (event) => {
954
+ if (!isControlled) setInternalValue(event.target.value);
955
+ props.onChange?.(event.target.value);
956
+ };
957
+ return /* @__PURE__ */ jsxs("section", { "aria-label": "Reflection", "data-lk-block-id": blockId, children: [
846
958
  props.prompt ? /* @__PURE__ */ jsx2("p", { id: promptId, children: props.prompt }) : null,
959
+ props.hint ? /* @__PURE__ */ jsx2("p", { id: hintId, style: visuallyHiddenStyle, children: props.hint }) : null,
847
960
  props.children,
848
961
  /* @__PURE__ */ jsx2(
849
962
  "textarea",
850
963
  {
964
+ value,
965
+ onChange: handleChange,
851
966
  "aria-labelledby": props.prompt ? promptId : void 0,
967
+ "aria-describedby": props.hint ? hintId : void 0,
852
968
  "aria-label": props.prompt ? void 0 : "Reflection response"
853
969
  }
854
970
  )
@@ -867,18 +983,35 @@ function KnowledgeCheck(props) {
867
983
  );
868
984
  }
869
985
  function Quiz(props) {
870
- warnInvalidComponentId(props.checkId, "checkId");
871
- const quiz = useQuizState();
872
- const { plugins, config, progress, session } = useLessonkit();
986
+ const checkId = useMemo3(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
987
+ const enclosingLessonId = useEnclosingLessonId();
988
+ const missingLesson = enclosingLessonId === void 0;
989
+ useEffect2(() => {
990
+ if (!missingLesson || isDevEnvironment3()) return;
991
+ if (!warnedQuizOutsideLesson) {
992
+ warnedQuizOutsideLesson = true;
993
+ console.error(
994
+ "[lessonkit] <Quiz> must be wrapped in <Lesson>; quiz telemetry will not be emitted."
995
+ );
996
+ }
997
+ }, [missingLesson]);
998
+ if (missingLesson && isDevEnvironment3()) {
999
+ throw new Error("[lessonkit] <Quiz> must be wrapped in <Lesson>");
1000
+ }
1001
+ const quiz = useQuizState(enclosingLessonId);
1002
+ const { plugins, config, session } = useLessonkit();
873
1003
  const [selected, setSelected] = useState2(null);
874
1004
  const [selectionCorrect, setSelectionCorrect] = useState2(null);
1005
+ const [quizPassed, setQuizPassed] = useState2(false);
875
1006
  const completedRef = useRef2(false);
876
1007
  const questionId = useId();
1008
+ const choicesKey = props.choices.join("\0");
877
1009
  useEffect2(() => {
878
1010
  completedRef.current = false;
1011
+ setQuizPassed(false);
879
1012
  setSelected(null);
880
1013
  setSelectionCorrect(null);
881
- }, [props.checkId, props.answer, props.question, config.courseId]);
1014
+ }, [checkId, props.answer, props.question, config.courseId, enclosingLessonId, choicesKey]);
882
1015
  const isChoiceCorrect = (choice, custom) => {
883
1016
  if (!custom) return choice === props.answer;
884
1017
  if (custom.passed !== void 0) return custom.passed;
@@ -887,7 +1020,11 @@ function Quiz(props) {
887
1020
  }
888
1021
  return choice === props.answer;
889
1022
  };
890
- return /* @__PURE__ */ jsxs("section", { "aria-label": "Quiz", "data-lk-check-id": props.checkId, children: [
1023
+ if (missingLesson) {
1024
+ return /* @__PURE__ */ jsx2("section", { role: "alert", "aria-label": "Quiz configuration error", "data-lk-check-id": checkId, children: /* @__PURE__ */ jsx2("p", { children: "Quiz must be placed inside a Lesson." }) });
1025
+ }
1026
+ const passed = quizPassed;
1027
+ return /* @__PURE__ */ jsxs("section", { "aria-label": "Quiz", "data-lk-check-id": checkId, children: [
891
1028
  /* @__PURE__ */ jsx2("p", { id: questionId, children: props.question }),
892
1029
  /* @__PURE__ */ jsxs("fieldset", { "aria-labelledby": questionId, children: [
893
1030
  /* @__PURE__ */ jsx2("legend", { style: visuallyHiddenStyle, children: "Quiz choices" }),
@@ -899,17 +1036,21 @@ function Quiz(props) {
899
1036
  name: questionId,
900
1037
  value: c,
901
1038
  checked: selected === c,
1039
+ disabled: passed,
1040
+ "aria-invalid": selected === c && selectionCorrect === false ? true : void 0,
902
1041
  onChange: () => {
1042
+ if (passed) return;
903
1043
  setSelected(c);
904
1044
  const pluginCtx = buildPluginContext({
905
1045
  courseId: config.courseId,
906
1046
  sessionId: session.sessionId,
907
- attemptId: session.attemptId
1047
+ attemptId: session.attemptId,
1048
+ user: session.user
908
1049
  });
909
1050
  const custom = plugins?.scoreAssessment(
910
1051
  {
911
- checkId: props.checkId,
912
- lessonId: progress.activeLessonId,
1052
+ checkId,
1053
+ lessonId: enclosingLessonId,
913
1054
  response: c
914
1055
  },
915
1056
  pluginCtx
@@ -917,18 +1058,20 @@ function Quiz(props) {
917
1058
  const correct = isChoiceCorrect(c, custom);
918
1059
  setSelectionCorrect(correct);
919
1060
  quiz.answer({
920
- checkId: props.checkId,
1061
+ checkId,
921
1062
  question: props.question,
922
1063
  choice: c,
923
1064
  correct
924
1065
  });
925
1066
  if (correct && !completedRef.current) {
926
1067
  completedRef.current = true;
1068
+ setQuizPassed(true);
1069
+ const maxScore = custom?.maxScore ?? 1;
927
1070
  quiz.complete({
928
- checkId: props.checkId,
1071
+ checkId,
929
1072
  score: custom?.score ?? 1,
930
- maxScore: custom?.maxScore ?? 1,
931
- passingScore: props.passingScore ?? 1
1073
+ maxScore,
1074
+ passingScore: props.passingScore ?? maxScore
932
1075
  });
933
1076
  }
934
1077
  }
@@ -940,23 +1083,51 @@ function Quiz(props) {
940
1083
  selected && selectionCorrect !== null ? /* @__PURE__ */ jsx2("p", { role: "status", "aria-live": "polite", children: selectionCorrect ? "Correct" : "Try again" }) : null
941
1084
  ] });
942
1085
  }
943
- function ProgressTracker() {
1086
+ function ProgressTracker(props) {
944
1087
  const { progress } = useLessonkit();
945
1088
  const completed = progress.completedLessonIds.size;
946
- return /* @__PURE__ */ jsx2("aside", { "aria-label": "Progress", children: /* @__PURE__ */ jsxs("p", { children: [
1089
+ if (props.totalLessons != null) {
1090
+ const total = props.totalLessons;
1091
+ const displayed = Math.min(completed, total);
1092
+ return /* @__PURE__ */ jsx2("aside", { "aria-label": "Progress", children: /* @__PURE__ */ jsx2(
1093
+ "div",
1094
+ {
1095
+ role: "progressbar",
1096
+ "aria-valuemin": 0,
1097
+ "aria-valuemax": total,
1098
+ "aria-valuenow": displayed,
1099
+ "aria-label": "Lessons completed",
1100
+ children: /* @__PURE__ */ jsxs("p", { children: [
1101
+ "Lessons completed: ",
1102
+ displayed,
1103
+ " of ",
1104
+ total
1105
+ ] })
1106
+ }
1107
+ ) });
1108
+ }
1109
+ return /* @__PURE__ */ jsx2("aside", { "aria-label": "Progress", role: "status", children: /* @__PURE__ */ jsxs("p", { children: [
947
1110
  "Lessons completed: ",
948
1111
  completed
949
1112
  ] }) });
950
1113
  }
951
1114
 
952
1115
  // src/index.tsx
953
- import { createPluginHost as createPluginHost2, defineLessonkitPlugin } from "@lessonkit/core";
1116
+ import {
1117
+ buildTelemetryEvent as buildTelemetryEvent2,
1118
+ createLessonkitRuntime as createLessonkitRuntime2,
1119
+ createPluginRegistry as createPluginRegistry2,
1120
+ createTelemetryPipeline as createTelemetryPipeline2,
1121
+ defineAssessmentPlugin,
1122
+ defineLifecyclePlugin,
1123
+ defineTelemetryPlugin
1124
+ } from "@lessonkit/core";
954
1125
 
955
1126
  // src/theme/ThemeProvider.tsx
956
1127
  import React3, {
957
- createContext as createContext2,
1128
+ createContext as createContext3,
958
1129
  useCallback as useCallback2,
959
- useContext as useContext2,
1130
+ useContext as useContext3,
960
1131
  useLayoutEffect as useLayoutEffect2,
961
1132
  useMemo as useMemo4,
962
1133
  useRef as useRef3,
@@ -988,7 +1159,7 @@ function applyCssVariables(target, vars, previousKeys) {
988
1159
 
989
1160
  // src/theme/ThemeProvider.tsx
990
1161
  import { jsx as jsx3 } from "react/jsx-runtime";
991
- var ThemeContext = createContext2(null);
1162
+ var ThemeContext = createContext3(null);
992
1163
  var useIsoLayoutEffect2 = typeof window !== "undefined" ? useLayoutEffect2 : React3.useEffect;
993
1164
  function getSystemMode() {
994
1165
  if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
@@ -1068,7 +1239,7 @@ function ThemeProvider(props) {
1068
1239
  return /* @__PURE__ */ jsx3(ThemeContext.Provider, { value, children: /* @__PURE__ */ jsx3("div", { ref: hostRef, "data-lk-theme": dataTheme, children: props.children }) });
1069
1240
  }
1070
1241
  function useTheme() {
1071
- const ctx = useContext2(ThemeContext);
1242
+ const ctx = useContext3(ThemeContext);
1072
1243
  if (!ctx) {
1073
1244
  throw new Error("useTheme must be used within a ThemeProvider");
1074
1245
  }
@@ -1115,6 +1286,12 @@ var BLOCK_CATALOG = [
1115
1286
  props: [
1116
1287
  { name: "title", type: "string", required: true, description: "Lesson title shown in the h2." },
1117
1288
  { name: "lessonId", type: "LessonId", required: true, description: "Stable lesson identifier for telemetry and packaging." },
1289
+ {
1290
+ name: "autoCompleteOnUnmount",
1291
+ type: "boolean",
1292
+ required: false,
1293
+ description: "When false, unmount does not emit lesson_completed (default true)."
1294
+ },
1118
1295
  { name: "children", type: "ReactNode", required: true, description: "Scenario, Quiz, Reflection, and other blocks." }
1119
1296
  ],
1120
1297
  requiredIds: ["lessonId"],
@@ -1167,6 +1344,9 @@ var BLOCK_CATALOG = [
1167
1344
  props: [
1168
1345
  { name: "blockId", type: "BlockId", required: false, description: "Optional stable block id for interaction telemetry URNs." },
1169
1346
  { name: "prompt", type: "string", required: false, description: "Reflection question or instruction." },
1347
+ { name: "hint", type: "string", required: false, description: "Optional hint linked via aria-describedby." },
1348
+ { name: "value", type: "string", required: false, description: "Controlled textarea value." },
1349
+ { name: "onChange", type: "(value: string) => void", required: false, description: "Called when the learner edits the textarea." },
1170
1350
  { name: "children", type: "ReactNode", required: false, description: "Optional content above the textarea." }
1171
1351
  ],
1172
1352
  requiredIds: [],
@@ -1185,6 +1365,7 @@ var BLOCK_CATALOG = [
1185
1365
  },
1186
1366
  telemetry: {
1187
1367
  emits: [],
1368
+ requiresActiveLesson: true,
1188
1369
  manualTracking: "useTracking().track('interaction', { kind, blockId, payload }) on submit or blur"
1189
1370
  }
1190
1371
  },
@@ -1222,7 +1403,14 @@ var BLOCK_CATALOG = [
1222
1403
  type: "ProgressTracker",
1223
1404
  category: "chrome",
1224
1405
  description: "Displays count of completed lessons from runtime progress state.",
1225
- props: [],
1406
+ props: [
1407
+ {
1408
+ name: "totalLessons",
1409
+ type: "number",
1410
+ required: false,
1411
+ description: "When set, renders role=progressbar with aria-valuenow/max."
1412
+ }
1413
+ ],
1226
1414
  requiredIds: [],
1227
1415
  parentConstraints: ["Course"],
1228
1416
  a11y: {
@@ -1274,9 +1462,15 @@ export {
1274
1462
  ThemeProvider,
1275
1463
  blockCatalogVersion,
1276
1464
  buildBlockCatalog,
1277
- createPluginHost2 as createPluginHost,
1278
- defineLessonkitPlugin,
1465
+ buildTelemetryEvent2 as buildTelemetryEvent,
1466
+ createLessonkitRuntime2 as createLessonkitRuntime,
1467
+ createPluginRegistry2 as createPluginRegistry,
1468
+ createTelemetryPipeline2 as createTelemetryPipeline,
1469
+ defineAssessmentPlugin,
1470
+ defineLifecyclePlugin,
1471
+ defineTelemetryPlugin,
1279
1472
  getBlockCatalogEntry,
1473
+ resetQuizWarningsForTests,
1280
1474
  useCompletion,
1281
1475
  useLessonkit,
1282
1476
  useProgress,