@lessonkit/react 0.9.3 → 1.0.1

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.cjs CHANGED
@@ -42,9 +42,15 @@ __export(index_exports, {
42
42
  ThemeProvider: () => ThemeProvider,
43
43
  blockCatalogVersion: () => blockCatalogVersion,
44
44
  buildBlockCatalog: () => buildBlockCatalog,
45
- createPluginHost: () => import_core7.createPluginHost,
46
- defineLessonkitPlugin: () => import_core7.defineLessonkitPlugin,
45
+ buildTelemetryEvent: () => import_core10.buildTelemetryEvent,
46
+ createLessonkitRuntime: () => import_core10.createLessonkitRuntime,
47
+ createPluginRegistry: () => import_core10.createPluginRegistry,
48
+ createTelemetryPipeline: () => import_core10.createTelemetryPipeline,
49
+ defineAssessmentPlugin: () => import_core10.defineAssessmentPlugin,
50
+ defineLifecyclePlugin: () => import_core10.defineLifecyclePlugin,
51
+ defineTelemetryPlugin: () => import_core10.defineTelemetryPlugin,
47
52
  getBlockCatalogEntry: () => getBlockCatalogEntry,
53
+ resetQuizWarningsForTests: () => resetQuizWarningsForTests,
48
54
  useCompletion: () => useCompletion,
49
55
  useLessonkit: () => useLessonkit,
50
56
  useProgress: () => useProgress,
@@ -55,240 +61,95 @@ __export(index_exports, {
55
61
  module.exports = __toCommonJS(index_exports);
56
62
 
57
63
  // src/components.tsx
58
- var import_react3 = require("react");
64
+ var import_react5 = require("react");
59
65
  var import_accessibility = require("@lessonkit/accessibility");
60
66
 
61
67
  // src/context.tsx
68
+ var import_react2 = require("react");
69
+
70
+ // src/provider/useLessonkitProviderRuntime.ts
62
71
  var import_react = require("react");
63
- var import_core5 = require("@lessonkit/core");
64
- var import_xapi3 = require("@lessonkit/xapi");
72
+ var import_core8 = require("@lessonkit/core");
65
73
  var import_xapi4 = require("@lessonkit/xapi");
74
+ var import_xapi5 = require("@lessonkit/xapi");
66
75
 
67
76
  // src/runtime/emitTelemetry.ts
77
+ var import_core2 = require("@lessonkit/core");
78
+
79
+ // src/runtime/telemetryPipeline.ts
68
80
  var import_core = require("@lessonkit/core");
69
81
  var import_xapi = require("@lessonkit/xapi");
70
82
 
71
83
  // src/runtime/lxpackBridge.ts
72
84
  var import_bridge = require("@lessonkit/lxpack/bridge");
73
- function getBridge() {
74
- const fromSdk = (0, import_bridge.getLxpackBridge)();
75
- if (fromSdk) return fromSdk;
76
- if (typeof window === "undefined") return null;
77
- const parent = window.parent;
78
- if (!parent || parent === window) return null;
79
- return parent.lxpack ?? null;
85
+ function forwardTelemetryToLxpack(event, mode = "auto") {
86
+ (0, import_bridge.forwardTelemetryToBridge)(event, mode);
80
87
  }
81
- function applyBridgeAction(bridge, action) {
82
- if (!action) return;
83
- switch (action.kind) {
84
- case "completeLesson":
85
- bridge.completeLesson?.(action.lessonId);
86
- return;
87
- case "completeCourse":
88
- bridge.completeCourse?.();
89
- return;
90
- case "submitAssessment": {
91
- const scaled = (0, import_bridge.normalizeScore)({
92
- score: action.score,
93
- maxScore: action.maxScore
94
- });
95
- if (scaled === null) return;
96
- bridge.submitAssessment?.({
97
- id: action.id,
98
- score: scaled,
99
- passingScore: (0, import_bridge.normalizePassingThreshold)({
100
- passingScore: action.passingScore,
101
- maxScore: action.maxScore
102
- }),
103
- maxScore: action.maxScore
104
- });
105
- return;
106
- }
107
- case "track":
108
- bridge.track?.(action.event);
109
- return;
110
- default:
111
- return;
112
- }
88
+
89
+ // src/runtime/telemetryPipeline.ts
90
+ function isDevEnvironment() {
91
+ const g = globalThis;
92
+ return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
113
93
  }
114
- function forwardTelemetryToLxpack(event, mode = "auto") {
115
- if (mode === "off") return;
116
- const bridge = getBridge();
117
- if (!bridge) return;
118
- const lessonkitEvent = (0, import_bridge.telemetryEventToLessonkit)(event);
119
- if (!lessonkitEvent) return;
120
- const action = (0, import_bridge.mapLessonkitTelemetryToBridgeAction)(lessonkitEvent);
121
- applyBridgeAction(bridge, action);
94
+ function createLegacyPipeline(opts, extraSinks = []) {
95
+ return (0, import_core.createTelemetryPipeline)([
96
+ (0, import_core.createTrackingPipelineSink)("tracking", (event) => opts.tracking.track(event)),
97
+ {
98
+ id: "xapi",
99
+ emit(event) {
100
+ try {
101
+ const statement = (0, import_xapi.telemetryEventToXAPIStatement)(event);
102
+ if (statement) opts.xapi?.send(statement);
103
+ } catch (err) {
104
+ if (isDevEnvironment()) {
105
+ console.warn(
106
+ "[lessonkit] xAPI mapping skipped:",
107
+ err instanceof Error ? err.message : err
108
+ );
109
+ }
110
+ }
111
+ }
112
+ },
113
+ {
114
+ id: "lxpack-bridge",
115
+ emit(event) {
116
+ forwardTelemetryToLxpack(event, opts.lxpackBridge);
117
+ }
118
+ },
119
+ ...extraSinks
120
+ ]);
121
+ }
122
+ function emitThroughPipeline(event, opts, extraSinks) {
123
+ createLegacyPipeline(opts, extraSinks).emit(event);
122
124
  }
123
125
 
124
126
  // src/runtime/emitTelemetry.ts
125
127
  var warnedMissingCourseId = false;
126
- var warnedMissingQuizLesson = false;
127
- function isDevEnvironment() {
128
+ function isDevEnvironment2() {
128
129
  const g = globalThis;
129
130
  return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
130
131
  }
131
132
  function emitTelemetry(tracking, xapi, event, opts) {
132
133
  if (!event.courseId) {
133
- if (isDevEnvironment() && !warnedMissingCourseId) {
134
+ if (isDevEnvironment2() && !warnedMissingCourseId) {
134
135
  warnedMissingCourseId = true;
135
136
  console.warn("[lessonkit] telemetry event missing courseId");
136
137
  }
137
138
  return;
138
139
  }
139
- tracking.track(event);
140
- try {
141
- const statement = (0, import_xapi.telemetryEventToXAPIStatement)(event);
142
- if (statement) xapi?.send(statement);
143
- } catch (err) {
144
- if (isDevEnvironment()) {
145
- console.warn("[lessonkit] xAPI mapping skipped:", err instanceof Error ? err.message : err);
146
- }
147
- }
148
- forwardTelemetryToLxpack(event, opts?.lxpackBridge ?? "auto");
149
- }
150
- function buildTrackEvent(opts) {
151
- const base = {
152
- timestamp: (0, import_core.nowIso)(),
153
- courseId: opts.courseId,
154
- sessionId: opts.sessionId,
155
- attemptId: opts.attemptId,
156
- user: opts.user
140
+ const legacy = {
141
+ tracking,
142
+ xapi,
143
+ lxpackBridge: opts?.lxpackBridge ?? "auto"
157
144
  };
158
- switch (opts.name) {
159
- case "course_started":
160
- return { name: "course_started", ...base };
161
- case "course_completed":
162
- return { name: "course_completed", ...base };
163
- case "lesson_started": {
164
- const data = opts.data;
165
- const lessonId = opts.lessonId ?? data?.lessonId;
166
- if (!lessonId) throw new Error("lesson_started requires lessonId");
167
- return {
168
- name: "lesson_started",
169
- ...base,
170
- lessonId,
171
- data: { ...data, lessonId }
172
- };
173
- }
174
- case "lesson_completed":
175
- case "lesson_time_on_task": {
176
- const data = opts.data;
177
- const lessonId = opts.lessonId ?? data?.lessonId;
178
- if (!lessonId) throw new Error(`${opts.name} requires lessonId`);
179
- return {
180
- name: opts.name,
181
- ...base,
182
- lessonId,
183
- data: { ...data, lessonId }
184
- };
185
- }
186
- case "quiz_answered": {
187
- const data = opts.data;
188
- const lessonId = opts.lessonId;
189
- if (!lessonId) throw new Error("quiz_answered requires active lessonId");
190
- return { name: "quiz_answered", ...base, lessonId, data };
191
- }
192
- case "quiz_completed": {
193
- const data = opts.data;
194
- const lessonId = opts.lessonId;
195
- if (!lessonId) throw new Error("quiz_completed requires active lessonId");
196
- return { name: "quiz_completed", ...base, lessonId, data };
197
- }
198
- case "interaction":
199
- return {
200
- name: "interaction",
201
- ...base,
202
- lessonId: opts.lessonId,
203
- data: opts.data
204
- };
205
- default:
206
- return { name: opts.name, ...base };
207
- }
208
- }
209
- function tryBuildTrackEvent(opts) {
210
- const isQuiz = opts.name === "quiz_answered" || opts.name === "quiz_completed";
211
- if (isQuiz && !opts.lessonId) {
212
- if (isDevEnvironment() && !warnedMissingQuizLesson) {
213
- warnedMissingQuizLesson = true;
214
- console.warn(
215
- `[lessonkit] ${opts.name} skipped: wrap <Quiz> in <Lesson> so an active lessonId is available`
216
- );
217
- }
218
- return null;
219
- }
220
- return buildTrackEvent(opts);
145
+ emitThroughPipeline(event, legacy, opts?.extraSinks);
221
146
  }
222
147
 
223
148
  // src/runtime/ports.ts
224
- function createNoopStorage() {
225
- return {
226
- getItem: () => null,
227
- setItem: () => {
228
- }
229
- };
230
- }
231
- function createSessionStoragePort() {
232
- if (typeof sessionStorage === "undefined") return createNoopStorage();
233
- return {
234
- getItem: (key) => {
235
- try {
236
- return sessionStorage.getItem(key);
237
- } catch {
238
- return null;
239
- }
240
- },
241
- setItem: (key, value) => {
242
- try {
243
- sessionStorage.setItem(key, value);
244
- } catch {
245
- }
246
- },
247
- removeItem: (key) => {
248
- try {
249
- sessionStorage.removeItem(key);
250
- } catch {
251
- }
252
- }
253
- };
254
- }
149
+ var import_core3 = require("@lessonkit/core");
255
150
 
256
151
  // src/runtime/progress.ts
257
- function createProgressController() {
258
- let activeLessonId;
259
- let completedLessonIds = /* @__PURE__ */ new Set();
260
- let courseCompleted = false;
261
- const lessonStartTimes = /* @__PURE__ */ new Map();
262
- return {
263
- getState: () => ({
264
- activeLessonId,
265
- completedLessonIds: new Set(completedLessonIds),
266
- courseCompleted
267
- }),
268
- setActiveLesson: (lessonId, startedAtMs) => {
269
- const previousLessonId = activeLessonId;
270
- activeLessonId = lessonId;
271
- lessonStartTimes.set(lessonId, startedAtMs);
272
- return { previousLessonId };
273
- },
274
- completeLesson: (lessonId, completedAtMs) => {
275
- if (completedLessonIds.has(lessonId)) return { didComplete: false };
276
- completedLessonIds = new Set(completedLessonIds).add(lessonId);
277
- if (activeLessonId === lessonId) {
278
- activeLessonId = void 0;
279
- }
280
- const startedAt = lessonStartTimes.get(lessonId);
281
- lessonStartTimes.delete(lessonId);
282
- const durationMs = typeof startedAt === "number" ? Math.max(0, completedAtMs - startedAt) : void 0;
283
- return { durationMs, didComplete: true };
284
- },
285
- completeCourse: () => {
286
- if (courseCompleted) return { didComplete: false };
287
- courseCompleted = true;
288
- return { didComplete: true };
289
- }
290
- };
291
- }
152
+ var import_core4 = require("@lessonkit/core");
292
153
 
293
154
  // src/runtime/xapi.ts
294
155
  var import_xapi2 = require("@lessonkit/xapi");
@@ -306,64 +167,60 @@ function createXapiClientFromConfig(config, queue) {
306
167
  }
307
168
 
308
169
  // src/runtime/session.ts
309
- var import_core2 = require("@lessonkit/core");
310
- var SESSION_STORAGE_KEY = "lessonkit:sessionId";
311
- function getTabSessionId(storage) {
312
- return storage.getItem(SESSION_STORAGE_KEY);
313
- }
314
- var COURSE_STARTED_PREFIX = "lessonkit:course_started:";
315
- function resolveSessionId(storage, provided) {
316
- if (provided) return provided;
317
- const existing = storage.getItem(SESSION_STORAGE_KEY);
318
- if (existing) return existing;
319
- const id = (0, import_core2.createSessionId)();
320
- storage.setItem(SESSION_STORAGE_KEY, id);
321
- return id;
322
- }
323
- function courseStartedStorageKey(sessionId, courseId) {
324
- return `${COURSE_STARTED_PREFIX}${sessionId}:${courseId ?? ""}`;
325
- }
326
- function hasCourseStarted(storage, sessionId, courseId) {
327
- if (!courseId) return false;
328
- return storage.getItem(courseStartedStorageKey(sessionId, courseId)) === "1";
329
- }
330
- function markCourseStarted(storage, sessionId, courseId) {
331
- if (!courseId) return;
332
- storage.setItem(courseStartedStorageKey(sessionId, courseId), "1");
333
- }
334
- function migrateCourseStartedMark(storage, fromSessionId, toSessionId, courseId) {
335
- if (!courseId || fromSessionId === toSessionId) return;
336
- if (hasCourseStarted(storage, fromSessionId, courseId)) {
337
- markCourseStarted(storage, toSessionId, courseId);
338
- storage.removeItem?.(courseStartedStorageKey(fromSessionId, courseId));
170
+ var import_core5 = require("@lessonkit/core");
171
+
172
+ // src/runtime/courseStartedPipeline.ts
173
+ var import_xapi3 = require("@lessonkit/xapi");
174
+ function emitCourseStartedNonTrackingPipeline(opts) {
175
+ let xapiStatementSent = false;
176
+ if (!opts.skipXapi && opts.xapi) {
177
+ const statement = (0, import_xapi3.telemetryEventToXAPIStatement)(opts.event);
178
+ if (statement) {
179
+ opts.xapi.send(statement);
180
+ xapiStatementSent = true;
181
+ }
339
182
  }
183
+ forwardTelemetryToLxpack(opts.event, opts.lxpackBridge);
184
+ const emitCtx = {
185
+ courseId: opts.event.courseId,
186
+ sessionId: opts.event.sessionId,
187
+ attemptId: opts.event.attemptId
188
+ };
189
+ for (const sink of opts.extraSinks ?? []) {
190
+ sink.emit(opts.event, emitCtx);
191
+ }
192
+ return { xapiStatementSent };
340
193
  }
341
194
 
342
195
  // src/runtime/plugins.ts
343
- var import_core3 = require("@lessonkit/core");
196
+ var import_core6 = require("@lessonkit/core");
344
197
  function createReactPluginHost(plugins) {
345
198
  if (!plugins?.length) return null;
346
- return (0, import_core3.createPluginHost)(plugins);
199
+ return (0, import_core6.createPluginRegistry)(plugins);
347
200
  }
348
201
  function buildPluginContext(opts) {
349
202
  return {
350
203
  courseId: opts.courseId,
351
204
  sessionId: opts.sessionId,
352
- attemptId: opts.attemptId
205
+ attemptId: opts.attemptId,
206
+ user: opts.user
353
207
  };
354
208
  }
355
209
  function emitTelemetryWithPlugins(opts) {
356
210
  const next = opts.pluginHost ? opts.pluginHost.runTelemetry(opts.event, opts.pluginCtx) : opts.event;
357
211
  if (next === null) return;
358
- emitTelemetry(opts.tracking, opts.xapi, next, { lxpackBridge: opts.lxpackBridge ?? "auto" });
212
+ emitTelemetry(opts.tracking, opts.xapi, next, {
213
+ lxpackBridge: opts.lxpackBridge ?? "auto",
214
+ extraSinks: opts.extraSinks
215
+ });
359
216
  }
360
217
 
361
218
  // src/runtime/telemetry.ts
362
- var import_core4 = require("@lessonkit/core");
219
+ var import_core7 = require("@lessonkit/core");
363
220
  function createTrackingClientFromConfig(config) {
364
- if (config.tracking?.enabled === false) return (0, import_core4.createTrackingClient)();
221
+ if (config.tracking?.enabled === false) return (0, import_core7.createTrackingClient)();
365
222
  if (config.tracking?.createClient) return config.tracking.createClient();
366
- return (0, import_core4.createTrackingClient)({
223
+ return (0, import_core7.createTrackingClient)({
367
224
  sink: config.tracking?.sink,
368
225
  batchSink: config.tracking?.batchSink,
369
226
  batch: config.tracking?.batch
@@ -380,90 +237,236 @@ async function disposeTrackingClient(client) {
380
237
  }
381
238
  }
382
239
 
383
- // src/context.tsx
384
- var import_jsx_runtime = require("react/jsx-runtime");
385
- var LessonkitContext = (0, import_react.createContext)(null);
240
+ // src/provider/useLessonkitProviderRuntime.ts
386
241
  var useIsoLayoutEffect = typeof window !== "undefined" ? import_react.useLayoutEffect : import_react.useEffect;
387
- var defaultStorage = createSessionStoragePort();
242
+ var defaultStorage = (0, import_core3.createSessionStoragePort)();
388
243
  function isTrackingActive(tracking) {
389
244
  return tracking?.enabled !== false;
390
245
  }
391
- function emitCourseStarted(opts) {
246
+ function isCourseStartedSinkSettled(result) {
247
+ return result === "emitted";
248
+ }
249
+ function buildCourseStartedEvent(opts) {
392
250
  const pluginCtx = buildPluginContext({
393
251
  courseId: opts.courseId,
394
252
  sessionId: opts.sessionId,
395
- attemptId: opts.attemptId
253
+ attemptId: opts.attemptId,
254
+ user: opts.user
255
+ });
256
+ const built = (0, import_core2.buildTelemetryEvent)({
257
+ name: "course_started",
258
+ courseId: opts.courseId,
259
+ sessionId: opts.sessionId,
260
+ attemptId: opts.attemptId,
261
+ user: opts.user
396
262
  });
263
+ return opts.pluginHost ? opts.pluginHost.runTelemetry(built, pluginCtx) : built;
264
+ }
265
+ function emitCourseStartedPipelineOnly(opts) {
397
266
  try {
398
- emitTelemetryWithPlugins({
399
- pluginHost: opts.pluginHost,
400
- tracking: opts.tracking,
267
+ const { xapiStatementSent } = emitCourseStartedNonTrackingPipeline({
268
+ event: opts.event,
401
269
  xapi: opts.xapi,
402
- event: buildTrackEvent({
403
- name: "course_started",
404
- courseId: opts.courseId,
405
- sessionId: opts.sessionId,
406
- attemptId: opts.attemptId,
407
- user: opts.user
408
- }),
409
- pluginCtx,
410
- lxpackBridge: opts.lxpackBridge
270
+ lxpackBridge: opts.lxpackBridge,
271
+ extraSinks: opts.extraSinks,
272
+ skipXapi: opts.skipXapi
411
273
  });
412
- markCourseStarted(opts.storage, opts.sessionId, opts.courseId);
413
- return true;
274
+ (0, import_core5.markCourseStarted)(opts.storage, opts.sessionId, opts.courseId);
275
+ (0, import_core5.markCourseStartedPipelineDelivered)(opts.storage, opts.sessionId, opts.courseId);
276
+ if (xapiStatementSent) {
277
+ opts.onXapiStatementSent?.();
278
+ }
279
+ return "emitted";
414
280
  } catch {
415
- return false;
281
+ return "failed";
416
282
  }
417
283
  }
418
- function LessonkitProvider(props) {
419
- const config = props.config;
420
- const sessionIdRef = (0, import_react.useRef)(resolveSessionId(defaultStorage, config.session?.sessionId));
421
- const prevConfiguredSessionIdRef = (0, import_react.useRef)(config.session?.sessionId);
422
- if (config.session?.sessionId) {
423
- sessionIdRef.current = config.session.sessionId;
284
+ function emitCourseStarted(opts) {
285
+ const event = buildCourseStartedEvent(opts);
286
+ if (event === null) return "filtered";
287
+ const trackingAlreadyEmitted = (0, import_core5.hasCourseStartedEmittedToTracking)(
288
+ opts.storage,
289
+ opts.sessionId,
290
+ opts.courseId
291
+ );
292
+ if (!trackingAlreadyEmitted) {
293
+ try {
294
+ opts.tracking.track(event);
295
+ (0, import_core5.markCourseStartedEmittedToTracking)(opts.storage, opts.sessionId, opts.courseId);
296
+ } catch {
297
+ return "failed";
298
+ }
299
+ }
300
+ return emitCourseStartedPipelineOnly({
301
+ ...opts,
302
+ event,
303
+ skipXapi: opts.skipXapi,
304
+ onXapiStatementSent: opts.onXapiStatementSent
305
+ });
306
+ }
307
+ function emitCourseStartedToTrackingOnly(opts) {
308
+ const event = buildCourseStartedEvent(opts);
309
+ if (event === null) return "filtered";
310
+ const trackingAlreadyEmitted = (0, import_core5.hasCourseStartedEmittedToTracking)(
311
+ opts.storage,
312
+ opts.sessionId,
313
+ opts.courseId
314
+ );
315
+ if (!trackingAlreadyEmitted) {
316
+ try {
317
+ opts.tracking.track(event);
318
+ (0, import_core5.markCourseStartedEmittedToTracking)(opts.storage, opts.sessionId, opts.courseId);
319
+ } catch {
320
+ return "failed";
321
+ }
322
+ }
323
+ try {
324
+ emitCourseStartedNonTrackingPipeline({
325
+ event,
326
+ xapi: null,
327
+ lxpackBridge: opts.lxpackBridge,
328
+ extraSinks: opts.extraSinks,
329
+ skipXapi: true
330
+ });
331
+ (0, import_core5.markCourseStartedPipelineDelivered)(opts.storage, opts.sessionId, opts.courseId);
332
+ return "emitted";
333
+ } catch {
334
+ return "failed";
335
+ }
336
+ }
337
+ function emitPendingCourseStarted(opts) {
338
+ const trackingEmitted = (0, import_core5.hasCourseStartedEmittedToTracking)(
339
+ opts.storage,
340
+ opts.sessionId,
341
+ opts.courseId
342
+ );
343
+ const sessionStarted = (0, import_core5.hasCourseStarted)(opts.storage, opts.sessionId, opts.courseId);
344
+ if (sessionStarted && !trackingEmitted) {
345
+ return emitCourseStartedToTrackingOnly(opts);
346
+ }
347
+ if (trackingEmitted && !sessionStarted) {
348
+ const event = buildCourseStartedEvent(opts);
349
+ if (event === null) return "filtered";
350
+ return emitCourseStartedPipelineOnly({ ...opts, event });
351
+ }
352
+ if (!trackingEmitted && !sessionStarted) {
353
+ return emitCourseStarted(opts);
354
+ }
355
+ const pipelineDelivered = (0, import_core5.hasCourseStartedPipelineDelivered)(
356
+ opts.storage,
357
+ opts.sessionId,
358
+ opts.courseId
359
+ );
360
+ if (sessionStarted && trackingEmitted && !pipelineDelivered) {
361
+ const event = buildCourseStartedEvent(opts);
362
+ if (event === null) return "filtered";
363
+ return emitCourseStartedPipelineOnly({
364
+ ...opts,
365
+ event,
366
+ skipXapi: opts.skipXapi,
367
+ onXapiStatementSent: opts.onXapiStatementSent
368
+ });
369
+ }
370
+ return "emitted";
371
+ }
372
+ function assertTrackingSinkConfig(tracking) {
373
+ if (!tracking?.sink || !tracking?.batchSink) return;
374
+ throw new Error(
375
+ "[lessonkit] tracking.sink and tracking.batchSink cannot both be set; use batchSink alone for batched delivery"
376
+ );
377
+ }
378
+ function useLessonkitProviderRuntime(config) {
379
+ const normalizedCourseId = (0, import_react.useMemo)(
380
+ () => (0, import_core8.assertValidId)(config.courseId, "courseId"),
381
+ [config.courseId]
382
+ );
383
+ const normalizedConfig = (0, import_react.useMemo)(
384
+ () => ({ ...config, courseId: normalizedCourseId }),
385
+ [config, normalizedCourseId]
386
+ );
387
+ const useV2Runtime = normalizedConfig.runtimeVersion !== "v1";
388
+ const extraSinksRef = (0, import_react.useRef)(normalizedConfig.sinks);
389
+ extraSinksRef.current = normalizedConfig.sinks;
390
+ const headlessRef = (0, import_react.useRef)(null);
391
+ const sessionIdRef = (0, import_react.useRef)((0, import_core5.resolveSessionId)(defaultStorage, normalizedConfig.session?.sessionId));
392
+ const prevConfiguredSessionIdRef = (0, import_react.useRef)(normalizedConfig.session?.sessionId);
393
+ if (normalizedConfig.session?.sessionId) {
394
+ sessionIdRef.current = normalizedConfig.session.sessionId;
424
395
  } else if (prevConfiguredSessionIdRef.current) {
425
- sessionIdRef.current = resolveSessionId(defaultStorage, void 0);
396
+ sessionIdRef.current = (0, import_core5.resolveSessionId)(defaultStorage, void 0);
426
397
  }
427
- const attemptIdRef = (0, import_react.useRef)(config.session?.attemptId);
428
- const userRef = (0, import_react.useRef)(config.session?.user);
429
- attemptIdRef.current = config.session?.attemptId;
430
- userRef.current = config.session?.user;
431
- const courseIdRef = (0, import_react.useRef)(config.courseId);
432
- courseIdRef.current = config.courseId;
433
- const lxpackBridgeModeRef = (0, import_react.useRef)(config.lxpack?.bridge ?? "auto");
434
- lxpackBridgeModeRef.current = config.lxpack?.bridge ?? "auto";
435
- const pluginHost = (0, import_react.useMemo)(() => createReactPluginHost(config.plugins), [config.plugins]);
398
+ const attemptIdRef = (0, import_react.useRef)(normalizedConfig.session?.attemptId);
399
+ const userRef = (0, import_react.useRef)(normalizedConfig.session?.user);
400
+ attemptIdRef.current = normalizedConfig.session?.attemptId;
401
+ userRef.current = normalizedConfig.session?.user;
402
+ const courseIdRef = (0, import_react.useRef)(normalizedCourseId);
403
+ courseIdRef.current = normalizedCourseId;
404
+ const lxpackBridgeModeRef = (0, import_react.useRef)(normalizedConfig.lxpack?.bridge ?? "auto");
405
+ lxpackBridgeModeRef.current = normalizedConfig.lxpack?.bridge ?? "auto";
406
+ const pluginHost = (0, import_react.useMemo)(() => createReactPluginHost(normalizedConfig.plugins), [normalizedConfig.plugins]);
436
407
  const pluginHostRef = (0, import_react.useRef)(pluginHost);
437
408
  pluginHostRef.current = pluginHost;
438
- const progressRef = (0, import_react.useRef)(createProgressController());
409
+ const progressRef = (0, import_react.useRef)((0, import_core4.createProgressController)());
439
410
  const courseStartedEmittedToSinkRef = (0, import_react.useRef)(false);
440
- const prevCourseIdForProgressRef = (0, import_react.useRef)(config.courseId);
411
+ const prevCourseIdForProgressRef = (0, import_react.useRef)(normalizedCourseId);
441
412
  const pendingCourseIdResetRef = (0, import_react.useRef)(false);
442
- if (prevCourseIdForProgressRef.current !== config.courseId) {
443
- prevCourseIdForProgressRef.current = config.courseId;
444
- progressRef.current = createProgressController();
413
+ const prevUseV2RuntimeRef = (0, import_react.useRef)(useV2Runtime);
414
+ const xapiCourseStartedSentOnClientRef = (0, import_react.useRef)(false);
415
+ if (prevUseV2RuntimeRef.current !== useV2Runtime) {
416
+ prevUseV2RuntimeRef.current = useV2Runtime;
417
+ if (useV2Runtime) {
418
+ headlessRef.current = (0, import_core8.createLessonkitRuntime)({
419
+ courseId: normalizedCourseId,
420
+ runtimeVersion: "v2",
421
+ session: normalizedConfig.session
422
+ });
423
+ progressRef.current = headlessRef.current.progress;
424
+ } else {
425
+ headlessRef.current = null;
426
+ progressRef.current = (0, import_core4.createProgressController)();
427
+ }
428
+ pendingCourseIdResetRef.current = true;
429
+ courseStartedEmittedToSinkRef.current = false;
430
+ } else if (useV2Runtime && !headlessRef.current) {
431
+ headlessRef.current = (0, import_core8.createLessonkitRuntime)({
432
+ courseId: normalizedCourseId,
433
+ runtimeVersion: "v2",
434
+ session: normalizedConfig.session
435
+ });
436
+ }
437
+ if (prevCourseIdForProgressRef.current !== normalizedCourseId) {
438
+ prevCourseIdForProgressRef.current = normalizedCourseId;
439
+ if (useV2Runtime && headlessRef.current) {
440
+ headlessRef.current.resetForCourseChange(normalizedCourseId);
441
+ progressRef.current = headlessRef.current.progress;
442
+ } else {
443
+ progressRef.current = (0, import_core4.createProgressController)();
444
+ }
445
445
  pendingCourseIdResetRef.current = true;
446
446
  courseStartedEmittedToSinkRef.current = false;
447
447
  }
448
+ if (useV2Runtime && headlessRef.current) {
449
+ progressRef.current = headlessRef.current.progress;
450
+ }
448
451
  const [progress, setProgress] = (0, import_react.useState)(() => progressRef.current.getState());
449
452
  const syncProgress = (0, import_react.useCallback)(() => {
450
453
  setProgress(progressRef.current.getState());
451
454
  }, []);
452
455
  const activeLessonIdRef = (0, import_react.useRef)(progress.activeLessonId);
453
456
  activeLessonIdRef.current = progress.activeLessonId;
454
- const xapiQueueRef = (0, import_react.useRef)((0, import_xapi3.createInMemoryXAPIQueue)());
457
+ const xapiQueueRef = (0, import_react.useRef)((0, import_xapi4.createInMemoryXAPIQueue)());
455
458
  const xapiRef = (0, import_react.useRef)(null);
456
459
  const [xapi, setXapi] = (0, import_react.useState)(null);
457
- const prevXapiCourseIdRef = (0, import_react.useRef)(config.courseId);
458
- const xapiEnabled = config.xapi?.enabled;
459
- const xapiClient = config.xapi?.client;
460
- const xapiTransport = config.xapi?.transport;
461
- const courseId = config.courseId;
462
- const trackingEnabled = config.tracking?.enabled;
460
+ const prevXapiCourseIdRef = (0, import_react.useRef)(normalizedCourseId);
461
+ const xapiEnabled = normalizedConfig.xapi?.enabled;
462
+ const xapiClient = normalizedConfig.xapi?.client;
463
+ const xapiTransport = normalizedConfig.xapi?.transport;
464
+ const courseId = normalizedCourseId;
465
+ const trackingEnabled = normalizedConfig.tracking?.enabled;
463
466
  useIsoLayoutEffect(() => {
464
467
  const courseChanged = prevXapiCourseIdRef.current !== courseId;
465
468
  if (courseChanged) {
466
- if (config.xapi?.client) {
469
+ if (normalizedConfig.xapi?.client) {
467
470
  const g = globalThis;
468
471
  if (typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production") {
469
472
  console.warn(
@@ -472,32 +475,42 @@ function LessonkitProvider(props) {
472
475
  }
473
476
  void xapiRef.current?.flush();
474
477
  }
475
- xapiQueueRef.current = (0, import_xapi3.createInMemoryXAPIQueue)();
478
+ xapiQueueRef.current = (0, import_xapi4.createInMemoryXAPIQueue)();
476
479
  prevXapiCourseIdRef.current = courseId;
480
+ xapiCourseStartedSentOnClientRef.current = false;
477
481
  }
478
482
  const prev = xapiRef.current;
479
- const next = createXapiClientFromConfig(config, xapiQueueRef.current);
483
+ const next = createXapiClientFromConfig(normalizedConfig, xapiQueueRef.current);
480
484
  xapiRef.current = next;
481
485
  setXapi(next);
482
- if (next && !prev) {
486
+ if (next) {
483
487
  const sessionId = sessionIdRef.current;
484
488
  const cid = courseIdRef.current;
485
- const trackingActive = isTrackingActive(config.tracking);
486
- const alreadyStarted = hasCourseStarted(defaultStorage, sessionId, cid);
487
- if (!trackingActive || alreadyStarted) {
489
+ const trackingActive = isTrackingActive(normalizedConfig.tracking);
490
+ const alreadyStarted = (0, import_core5.hasCourseStarted)(defaultStorage, sessionId, cid);
491
+ const clientChanged = !prev || prev !== next;
492
+ const skipBootstrap = trackingActive && !alreadyStarted;
493
+ const needsBootstrap = !skipBootstrap && !xapiCourseStartedSentOnClientRef.current && (!alreadyStarted || clientChanged);
494
+ if (needsBootstrap) {
488
495
  try {
489
- const statement = (0, import_xapi4.telemetryEventToXAPIStatement)(
490
- buildTrackEvent({
491
- name: "course_started",
492
- courseId: cid,
493
- sessionId,
494
- attemptId: attemptIdRef.current,
495
- user: userRef.current
496
- })
497
- );
498
- if (statement) {
499
- next.send(statement);
500
- markCourseStarted(defaultStorage, sessionId, cid);
496
+ const event = buildCourseStartedEvent({
497
+ pluginHost: pluginHostRef.current,
498
+ courseId: cid,
499
+ sessionId,
500
+ attemptId: attemptIdRef.current,
501
+ user: userRef.current,
502
+ lxpackBridge: lxpackBridgeModeRef.current
503
+ });
504
+ if (event === null) {
505
+ } else {
506
+ const statement = (0, import_xapi5.telemetryEventToXAPIStatement)(event);
507
+ if (statement) {
508
+ next.send(statement);
509
+ if (!alreadyStarted) {
510
+ (0, import_core5.markCourseStarted)(defaultStorage, sessionId, cid);
511
+ }
512
+ xapiCourseStartedSentOnClientRef.current = true;
513
+ }
501
514
  }
502
515
  } catch {
503
516
  }
@@ -522,46 +535,56 @@ function LessonkitProvider(props) {
522
535
  void prev?.flush();
523
536
  };
524
537
  }, [xapiEnabled, xapiClient, xapiTransport, courseId, trackingEnabled]);
525
- const trackingRef = (0, import_react.useRef)((0, import_core5.createTrackingClient)());
538
+ const trackingRef = (0, import_react.useRef)((0, import_core8.createTrackingClient)());
526
539
  const trackingClientForUnmountRef = (0, import_react.useRef)(trackingRef.current);
527
540
  const [tracking, setTracking] = (0, import_react.useState)(() => trackingRef.current);
528
- const trackingSink = config.tracking?.sink;
529
- const trackingBatchSink = config.tracking?.batchSink;
530
- const batchEnabled = config.tracking?.batch?.enabled;
531
- const batchFlushIntervalMs = config.tracking?.batch?.flushIntervalMs;
532
- const batchMaxBatchSize = config.tracking?.batch?.maxBatchSize;
541
+ const trackingSink = normalizedConfig.tracking?.sink;
542
+ const trackingBatchSink = normalizedConfig.tracking?.batchSink;
543
+ const batchEnabled = normalizedConfig.tracking?.batch?.enabled;
544
+ const batchFlushIntervalMs = normalizedConfig.tracking?.batch?.flushIntervalMs;
545
+ const batchMaxBatchSize = normalizedConfig.tracking?.batch?.maxBatchSize;
533
546
  const buildCurrentPluginCtx = (0, import_react.useCallback)(
534
547
  () => buildPluginContext({
535
548
  courseId: courseIdRef.current,
536
549
  sessionId: sessionIdRef.current,
537
- attemptId: attemptIdRef.current
550
+ attemptId: attemptIdRef.current,
551
+ user: userRef.current
538
552
  }),
539
553
  []
540
554
  );
541
555
  useIsoLayoutEffect(() => {
542
556
  const prev = trackingRef.current;
543
- const baseSink = config.tracking?.sink;
557
+ const baseSink = normalizedConfig.tracking?.sink;
558
+ const userBatchSink = normalizedConfig.tracking?.batchSink;
559
+ assertTrackingSinkConfig(normalizedConfig.tracking);
544
560
  const sink = pluginHostRef.current && baseSink ? pluginHostRef.current.composeTrackingSink(baseSink, buildCurrentPluginCtx) ?? baseSink : baseSink;
545
- const batchSink = pluginHostRef.current && config.tracking?.batchSink ? (events) => {
546
- const delivered = pluginHostRef.current.deliverTelemetryBatch(
547
- events,
548
- buildCurrentPluginCtx()
549
- );
550
- return config.tracking.batchSink(delivered);
551
- } : config.tracking?.batchSink;
561
+ const batchSink = pluginHostRef.current && userBatchSink ? async (events) => {
562
+ const host = pluginHostRef.current;
563
+ const ctx = buildCurrentPluginCtx();
564
+ const delivered = host.deliverTelemetryBatch(events, ctx);
565
+ const perEventForBatch = [];
566
+ const collector = (event) => {
567
+ perEventForBatch.push(event);
568
+ };
569
+ const composedPerEvent = host.composeTrackingSink(collector, buildCurrentPluginCtx) ?? collector;
570
+ for (const event of delivered) {
571
+ await Promise.resolve(composedPerEvent(event));
572
+ }
573
+ return userBatchSink(perEventForBatch);
574
+ } : userBatchSink;
552
575
  const next = createTrackingClientFromConfig({
553
- tracking: { ...config.tracking, sink, batchSink }
576
+ tracking: { ...normalizedConfig.tracking, sink, batchSink }
554
577
  });
555
578
  trackingRef.current = next;
556
579
  trackingClientForUnmountRef.current = next;
557
580
  setTracking(next);
558
581
  const sessionId = sessionIdRef.current;
559
582
  const cid = courseIdRef.current;
560
- const trackingActive = isTrackingActive(config.tracking);
583
+ const trackingActive = isTrackingActive(normalizedConfig.tracking);
561
584
  if (!trackingActive) {
562
585
  courseStartedEmittedToSinkRef.current = false;
563
- } else if (!courseStartedEmittedToSinkRef.current && !hasCourseStarted(defaultStorage, sessionId, cid)) {
564
- const emitted = emitCourseStarted({
586
+ } else if (!courseStartedEmittedToSinkRef.current) {
587
+ const result = emitPendingCourseStarted({
565
588
  pluginHost: pluginHostRef.current,
566
589
  tracking: next,
567
590
  xapi: xapiRef.current,
@@ -570,9 +593,14 @@ function LessonkitProvider(props) {
570
593
  courseId: cid,
571
594
  attemptId: attemptIdRef.current,
572
595
  user: userRef.current,
573
- lxpackBridge: lxpackBridgeModeRef.current
596
+ lxpackBridge: lxpackBridgeModeRef.current,
597
+ extraSinks: extraSinksRef.current,
598
+ skipXapi: xapiCourseStartedSentOnClientRef.current,
599
+ onXapiStatementSent: () => {
600
+ xapiCourseStartedSentOnClientRef.current = true;
601
+ }
574
602
  });
575
- courseStartedEmittedToSinkRef.current = emitted;
603
+ courseStartedEmittedToSinkRef.current = isCourseStartedSinkSettled(result);
576
604
  } else if (trackingActive) {
577
605
  courseStartedEmittedToSinkRef.current = true;
578
606
  }
@@ -588,8 +616,8 @@ function LessonkitProvider(props) {
588
616
  batchEnabled,
589
617
  batchFlushIntervalMs,
590
618
  batchMaxBatchSize,
591
- config.plugins,
592
- config.courseId,
619
+ normalizedConfig.plugins,
620
+ normalizedCourseId,
593
621
  buildCurrentPluginCtx
594
622
  ]);
595
623
  const emitWithBridge = (0, import_react.useCallback)((trackingClient, event) => {
@@ -601,14 +629,32 @@ function LessonkitProvider(props) {
601
629
  pluginCtx: buildPluginContext({
602
630
  courseId: courseIdRef.current,
603
631
  sessionId: sessionIdRef.current,
604
- attemptId: attemptIdRef.current
632
+ attemptId: attemptIdRef.current,
633
+ user: userRef.current
605
634
  }),
606
- lxpackBridge: lxpackBridgeModeRef.current
635
+ lxpackBridge: lxpackBridgeModeRef.current,
636
+ extraSinks: extraSinksRef.current
607
637
  });
608
638
  }, []);
639
+ const emitLifecycleEvent = (0, import_react.useCallback)(
640
+ (name, data, lessonId) => {
641
+ const event = (0, import_core2.tryBuildTelemetryEvent)({
642
+ name,
643
+ courseId: courseIdRef.current,
644
+ lessonId: lessonId ?? activeLessonIdRef.current,
645
+ sessionId: sessionIdRef.current,
646
+ attemptId: attemptIdRef.current,
647
+ user: userRef.current,
648
+ data
649
+ });
650
+ if (!event) return;
651
+ emitWithBridge(trackingRef.current, event);
652
+ },
653
+ [emitWithBridge]
654
+ );
609
655
  const track = (0, import_react.useCallback)(
610
656
  (name, data, opts) => {
611
- const event = tryBuildTrackEvent({
657
+ const event = (0, import_core2.tryBuildTelemetryEvent)({
612
658
  name,
613
659
  courseId: courseIdRef.current,
614
660
  lessonId: opts?.lessonId ?? activeLessonIdRef.current,
@@ -626,7 +672,7 @@ function LessonkitProvider(props) {
626
672
  if (!pendingCourseIdResetRef.current) return;
627
673
  pendingCourseIdResetRef.current = false;
628
674
  syncProgress();
629
- if (!isTrackingActive(config.tracking)) return;
675
+ if (!isTrackingActive(normalizedConfig.tracking)) return;
630
676
  const sessionId = sessionIdRef.current;
631
677
  const cid = courseIdRef.current;
632
678
  void (async () => {
@@ -634,8 +680,8 @@ function LessonkitProvider(props) {
634
680
  await trackingRef.current?.flush?.();
635
681
  } catch {
636
682
  }
637
- if (!courseStartedEmittedToSinkRef.current && !hasCourseStarted(defaultStorage, sessionId, cid)) {
638
- const emitted = emitCourseStarted({
683
+ if (!courseStartedEmittedToSinkRef.current) {
684
+ const result = emitPendingCourseStarted({
639
685
  pluginHost: pluginHostRef.current,
640
686
  tracking: trackingRef.current,
641
687
  xapi: xapiRef.current,
@@ -644,12 +690,13 @@ function LessonkitProvider(props) {
644
690
  courseId: cid,
645
691
  attemptId: attemptIdRef.current,
646
692
  user: userRef.current,
647
- lxpackBridge: lxpackBridgeModeRef.current
693
+ lxpackBridge: lxpackBridgeModeRef.current,
694
+ extraSinks: extraSinksRef.current
648
695
  });
649
- courseStartedEmittedToSinkRef.current = emitted;
696
+ courseStartedEmittedToSinkRef.current = isCourseStartedSinkSettled(result);
650
697
  }
651
698
  })();
652
- }, [config.courseId, config.tracking?.enabled, syncProgress]);
699
+ }, [normalizedCourseId, normalizedConfig.tracking?.enabled, syncProgress]);
653
700
  const emitLessonCompleted = (0, import_react.useCallback)(
654
701
  (lessonId, durationMs) => {
655
702
  track("lesson_completed", { lessonId, durationMs }, { lessonId });
@@ -661,13 +708,19 @@ function LessonkitProvider(props) {
661
708
  );
662
709
  const completeLesson = (0, import_react.useCallback)(
663
710
  (lessonId) => {
711
+ if (useV2Runtime && headlessRef.current) {
712
+ headlessRef.current.completeLesson(lessonId, emitLifecycleEvent);
713
+ syncProgress();
714
+ void Promise.resolve(trackingRef.current?.flush?.());
715
+ return;
716
+ }
664
717
  const result = progressRef.current.completeLesson(lessonId, Date.now());
665
718
  if (!result.didComplete) return;
666
719
  syncProgress();
667
720
  emitLessonCompleted(lessonId, result.durationMs);
668
721
  void Promise.resolve(trackingRef.current?.flush?.());
669
722
  },
670
- [syncProgress, emitLessonCompleted]
723
+ [syncProgress, emitLessonCompleted, useV2Runtime, emitLifecycleEvent]
671
724
  );
672
725
  (0, import_react.useEffect)(() => {
673
726
  return () => {
@@ -691,8 +744,19 @@ function LessonkitProvider(props) {
691
744
  }, []);
692
745
  const setActiveLesson = (0, import_react.useCallback)(
693
746
  (lessonId) => {
747
+ if (useV2Runtime && headlessRef.current) {
748
+ headlessRef.current.setActiveLesson(lessonId, emitLifecycleEvent);
749
+ syncProgress();
750
+ void Promise.resolve(trackingRef.current?.flush?.());
751
+ return;
752
+ }
694
753
  const current = progressRef.current.getState();
695
754
  if (current.activeLessonId === lessonId) return;
755
+ if (current.completedLessonIds.has(lessonId)) {
756
+ progressRef.current.setActiveLesson(lessonId, Date.now());
757
+ syncProgress();
758
+ return;
759
+ }
696
760
  const previous = current.activeLessonId;
697
761
  if (previous && previous !== lessonId) {
698
762
  const completed = progressRef.current.completeLesson(previous, Date.now());
@@ -705,9 +769,15 @@ function LessonkitProvider(props) {
705
769
  syncProgress();
706
770
  track("lesson_started", { lessonId }, { lessonId });
707
771
  },
708
- [track, syncProgress, emitLessonCompleted]
772
+ [track, syncProgress, emitLessonCompleted, useV2Runtime, emitLifecycleEvent]
709
773
  );
710
774
  const completeCourse = (0, import_react.useCallback)(() => {
775
+ if (useV2Runtime && headlessRef.current) {
776
+ headlessRef.current.completeCourse(emitLifecycleEvent);
777
+ syncProgress();
778
+ void trackingRef.current?.flush?.();
779
+ return;
780
+ }
711
781
  const current = progressRef.current.getState();
712
782
  if (current.activeLessonId) {
713
783
  const lessonResult = progressRef.current.completeLesson(current.activeLessonId, Date.now());
@@ -720,24 +790,37 @@ function LessonkitProvider(props) {
720
790
  syncProgress();
721
791
  track("course_completed");
722
792
  void trackingRef.current?.flush?.();
723
- }, [track, syncProgress, emitLessonCompleted]);
724
- const sessionUser = config.session?.user;
725
- const sessionAttemptId = config.session?.attemptId;
726
- const sessionConfiguredId = config.session?.sessionId;
793
+ }, [track, syncProgress, emitLessonCompleted, useV2Runtime, emitLifecycleEvent]);
794
+ const sessionUser = normalizedConfig.session?.user;
795
+ const sessionUserKey = (0, import_react.useMemo)(
796
+ () => sessionUser ? JSON.stringify(sessionUser) : "",
797
+ [sessionUser]
798
+ );
799
+ const sessionAttemptId = normalizedConfig.session?.attemptId;
800
+ const sessionConfiguredId = normalizedConfig.session?.sessionId;
801
+ (0, import_react.useEffect)(() => {
802
+ if (useV2Runtime && headlessRef.current) {
803
+ headlessRef.current.updateConfig({
804
+ courseId: normalizedCourseId,
805
+ session: normalizedConfig.session
806
+ });
807
+ }
808
+ }, [useV2Runtime, normalizedCourseId, sessionAttemptId, sessionConfiguredId, sessionUserKey, normalizedConfig.session]);
727
809
  (0, import_react.useEffect)(() => {
728
810
  if (!pluginHost) return;
729
811
  const ctx = buildPluginContext({
730
812
  courseId: courseIdRef.current,
731
813
  sessionId: sessionIdRef.current,
732
- attemptId: attemptIdRef.current
814
+ attemptId: attemptIdRef.current,
815
+ user: userRef.current
733
816
  });
734
817
  pluginHost.setupAll(ctx);
735
818
  return () => {
736
819
  pluginHost.disposeAll();
737
820
  };
738
- }, [pluginHost, config.courseId, sessionAttemptId, sessionConfiguredId]);
821
+ }, [pluginHost, normalizedCourseId, sessionAttemptId, sessionConfiguredId, sessionUserKey]);
739
822
  (0, import_react.useEffect)(() => {
740
- const nextConfigured = config.session?.sessionId;
823
+ const nextConfigured = normalizedConfig.session?.sessionId;
741
824
  const prevConfigured = prevConfiguredSessionIdRef.current;
742
825
  if (nextConfigured === prevConfigured) return;
743
826
  prevConfiguredSessionIdRef.current = nextConfigured;
@@ -745,23 +828,23 @@ function LessonkitProvider(props) {
745
828
  if (nextConfigured) {
746
829
  const fromIds = /* @__PURE__ */ new Set();
747
830
  if (prevConfigured) fromIds.add(prevConfigured);
748
- const tabId = getTabSessionId(defaultStorage);
831
+ const tabId = (0, import_core5.getTabSessionId)(defaultStorage);
749
832
  if (tabId) fromIds.add(tabId);
750
833
  for (const fromId of fromIds) {
751
834
  if (fromId !== nextConfigured) {
752
- migrateCourseStartedMark(defaultStorage, fromId, nextConfigured, cid);
835
+ (0, import_core5.migrateCourseStartedMark)(defaultStorage, fromId, nextConfigured, cid);
753
836
  }
754
837
  }
755
838
  sessionIdRef.current = nextConfigured;
756
839
  } else if (prevConfigured) {
757
- const nextAuto = resolveSessionId(defaultStorage, void 0);
758
- migrateCourseStartedMark(defaultStorage, prevConfigured, nextAuto, cid);
840
+ const nextAuto = (0, import_core5.resolveSessionId)(defaultStorage, void 0);
841
+ (0, import_core5.migrateCourseStartedMark)(defaultStorage, prevConfigured, nextAuto, cid);
759
842
  sessionIdRef.current = nextAuto;
760
843
  }
761
- }, [sessionConfiguredId, config.courseId]);
844
+ }, [sessionConfiguredId, normalizedCourseId]);
762
845
  const runtime = (0, import_react.useMemo)(
763
846
  () => ({
764
- config,
847
+ config: normalizedConfig,
765
848
  tracking,
766
849
  xapi,
767
850
  session: { sessionId: sessionIdRef.current, attemptId: attemptIdRef.current, user: userRef.current },
@@ -773,7 +856,7 @@ function LessonkitProvider(props) {
773
856
  plugins: pluginHost
774
857
  }),
775
858
  [
776
- config,
859
+ normalizedConfig,
777
860
  tracking,
778
861
  xapi,
779
862
  progress,
@@ -787,13 +870,21 @@ function LessonkitProvider(props) {
787
870
  sessionConfiguredId
788
871
  ]
789
872
  );
873
+ return runtime;
874
+ }
875
+
876
+ // src/context.tsx
877
+ var import_jsx_runtime = require("react/jsx-runtime");
878
+ var LessonkitContext = (0, import_react2.createContext)(null);
879
+ function LessonkitProvider(props) {
880
+ const runtime = useLessonkitProviderRuntime(props.config);
790
881
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(LessonkitContext.Provider, { value: runtime, children: props.children });
791
882
  }
792
883
 
793
884
  // src/hooks.ts
794
- var import_react2 = require("react");
885
+ var import_react3 = require("react");
795
886
  function useLessonkit() {
796
- const ctx = (0, import_react2.useContext)(LessonkitContext);
887
+ const ctx = (0, import_react3.useContext)(LessonkitContext);
797
888
  if (!ctx) throw new Error("LessonKit: missing LessonkitProvider");
798
889
  return ctx;
799
890
  }
@@ -803,52 +894,84 @@ function useProgress() {
803
894
  }
804
895
  function useTracking() {
805
896
  const { track } = useLessonkit();
806
- return (0, import_react2.useMemo)(() => ({ track }), [track]);
897
+ return (0, import_react3.useMemo)(() => ({ track }), [track]);
807
898
  }
808
899
  function useCompletion() {
809
900
  const { completeLesson, completeCourse } = useLessonkit();
810
- return (0, import_react2.useMemo)(() => ({ completeLesson, completeCourse }), [completeLesson, completeCourse]);
901
+ return (0, import_react3.useMemo)(() => ({ completeLesson, completeCourse }), [completeLesson, completeCourse]);
811
902
  }
812
- function useQuizState() {
903
+ function useQuizState(enclosingLessonId) {
813
904
  const { track } = useLessonkit();
814
- return (0, import_react2.useMemo)(
905
+ const trackOpts = enclosingLessonId ? { lessonId: enclosingLessonId } : void 0;
906
+ return (0, import_react3.useMemo)(
815
907
  () => ({
816
908
  answer: (opts) => {
817
- track("quiz_answered", opts);
909
+ track("quiz_answered", opts, trackOpts);
818
910
  },
819
911
  complete: (opts) => {
820
- track("quiz_completed", opts);
912
+ track("quiz_completed", opts, trackOpts);
821
913
  }
822
914
  }),
823
- [track]
915
+ [track, enclosingLessonId]
824
916
  );
825
917
  }
826
918
 
919
+ // src/lessonContext.tsx
920
+ var import_react4 = require("react");
921
+ var LessonContext = (0, import_react4.createContext)(void 0);
922
+ function useEnclosingLessonId() {
923
+ return (0, import_react4.useContext)(LessonContext);
924
+ }
925
+
827
926
  // src/runtime/validateComponentId.ts
828
- var import_core6 = require("@lessonkit/core");
829
- var warnedPaths = /* @__PURE__ */ new Set();
830
- function isDevEnvironment2() {
927
+ var import_core9 = require("@lessonkit/core");
928
+ function isDevEnvironment3() {
831
929
  const g = globalThis;
832
930
  return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
833
931
  }
834
- function warnInvalidComponentId(id, path) {
835
- if (!isDevEnvironment2()) return;
836
- const key = `${path}:${String(id)}`;
837
- if (warnedPaths.has(key)) return;
838
- const result = (0, import_core6.validateId)(id, path);
839
- if (result.ok) return;
840
- warnedPaths.add(key);
841
- const detail = result.issues.map((i) => `${i.path}: ${i.message}`).join("; ");
842
- console.warn(`[lessonkit] invalid ${path} \u2014 ${detail}`);
932
+ function normalizeComponentId(id, path) {
933
+ if (path === "courseId") return (0, import_core9.assertValidId)(id, "courseId");
934
+ if (path === "lessonId") return (0, import_core9.assertValidId)(id, "lessonId");
935
+ if (path === "checkId") return (0, import_core9.assertValidId)(id, "checkId");
936
+ if (path === "blockId") return (0, import_core9.assertValidId)(id, "blockId");
937
+ return (0, import_core9.assertValidId)(id, path);
938
+ }
939
+
940
+ // src/runtime/lessonMountRegistry.ts
941
+ var mountCounts = /* @__PURE__ */ new Map();
942
+ var warnedConcurrentLessons = false;
943
+ function registerLessonMount(lessonId) {
944
+ if (isDevEnvironment3() && mountCounts.size > 0 && !mountCounts.has(lessonId) && !warnedConcurrentLessons) {
945
+ warnedConcurrentLessons = true;
946
+ console.warn(
947
+ "[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."
948
+ );
949
+ }
950
+ mountCounts.set(lessonId, (mountCounts.get(lessonId) ?? 0) + 1);
951
+ return () => {
952
+ const next = (mountCounts.get(lessonId) ?? 1) - 1;
953
+ if (next <= 0) {
954
+ mountCounts.delete(lessonId);
955
+ } else {
956
+ mountCounts.set(lessonId, next);
957
+ }
958
+ };
959
+ }
960
+ function getLessonMountCount(lessonId) {
961
+ return mountCounts.get(lessonId) ?? 0;
843
962
  }
844
963
 
845
964
  // src/components.tsx
846
965
  var import_jsx_runtime2 = require("react/jsx-runtime");
966
+ var warnedQuizOutsideLesson = false;
967
+ function resetQuizWarningsForTests() {
968
+ warnedQuizOutsideLesson = false;
969
+ }
847
970
  function Course(props) {
848
- warnInvalidComponentId(props.courseId, "courseId");
849
- const providerConfig = (0, import_react3.useMemo)(
850
- () => ({ ...props.config, courseId: props.courseId }),
851
- [props.config, props.courseId]
971
+ const courseId = (0, import_react5.useMemo)(() => normalizeComponentId(props.courseId, "courseId"), [props.courseId]);
972
+ const providerConfig = (0, import_react5.useMemo)(
973
+ () => ({ ...props.config, courseId }),
974
+ [props.config, courseId]
852
975
  );
853
976
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(LessonkitProvider, { config: providerConfig, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": props.title, children: [
854
977
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("h1", { children: props.title }),
@@ -856,41 +979,64 @@ function Course(props) {
856
979
  ] }) });
857
980
  }
858
981
  function Lesson(props) {
859
- warnInvalidComponentId(props.lessonId, "lessonId");
982
+ const lessonId = (0, import_react5.useMemo)(() => normalizeComponentId(props.lessonId, "lessonId"), [props.lessonId]);
983
+ const autoComplete = props.autoCompleteOnUnmount !== false;
860
984
  const { setActiveLesson, config } = useLessonkit();
861
985
  const { completeLesson } = useCompletion();
862
- const id = props.lessonId;
863
- const lessonMountGenerationRef = (0, import_react3.useRef)(0);
864
- (0, import_react3.useEffect)(() => {
986
+ const lessonMountGenerationRef = (0, import_react5.useRef)(0);
987
+ (0, import_react5.useEffect)(() => {
988
+ const unregister = registerLessonMount(lessonId);
865
989
  const generation = ++lessonMountGenerationRef.current;
866
- setActiveLesson(id);
990
+ setActiveLesson(lessonId);
867
991
  return () => {
868
- const lessonId = id;
992
+ unregister();
993
+ if (getLessonMountCount(lessonId) > 0) {
994
+ return;
995
+ }
996
+ if (!autoComplete) return;
869
997
  queueMicrotask(() => {
870
998
  if (lessonMountGenerationRef.current !== generation) return;
871
999
  completeLesson(lessonId);
872
1000
  });
873
1001
  };
874
- }, [id, config.courseId, setActiveLesson, completeLesson]);
875
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("article", { "aria-label": props.title, children: [
1002
+ }, [lessonId, config.courseId, setActiveLesson, completeLesson, autoComplete]);
1003
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(LessonContext.Provider, { value: lessonId, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("article", { "aria-label": props.title, children: [
876
1004
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("h2", { children: props.title }),
877
1005
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { children: props.children })
878
- ] });
1006
+ ] }) });
879
1007
  }
880
1008
  function Scenario(props) {
881
- if (props.blockId !== void 0) warnInvalidComponentId(props.blockId, "blockId");
882
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("section", { "aria-label": "Scenario", "data-lk-block-id": props.blockId, children: props.children });
1009
+ const blockId = (0, import_react5.useMemo)(
1010
+ () => props.blockId !== void 0 ? normalizeComponentId(props.blockId, "blockId") : void 0,
1011
+ [props.blockId]
1012
+ );
1013
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("section", { "aria-label": "Scenario", "data-lk-block-id": blockId, children: props.children });
883
1014
  }
884
1015
  function Reflection(props) {
885
- if (props.blockId !== void 0) warnInvalidComponentId(props.blockId, "blockId");
886
- const promptId = (0, import_react3.useId)();
887
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": "Reflection", "data-lk-block-id": props.blockId, children: [
1016
+ const blockId = (0, import_react5.useMemo)(
1017
+ () => props.blockId !== void 0 ? normalizeComponentId(props.blockId, "blockId") : void 0,
1018
+ [props.blockId]
1019
+ );
1020
+ const promptId = (0, import_react5.useId)();
1021
+ const hintId = (0, import_react5.useId)();
1022
+ const [internalValue, setInternalValue] = (0, import_react5.useState)("");
1023
+ const isControlled = props.value !== void 0;
1024
+ const value = isControlled ? props.value : internalValue;
1025
+ const handleChange = (event) => {
1026
+ if (!isControlled) setInternalValue(event.target.value);
1027
+ props.onChange?.(event.target.value);
1028
+ };
1029
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": "Reflection", "data-lk-block-id": blockId, children: [
888
1030
  props.prompt ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { id: promptId, children: props.prompt }) : null,
1031
+ props.hint ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { id: hintId, style: import_accessibility.visuallyHiddenStyle, children: props.hint }) : null,
889
1032
  props.children,
890
1033
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
891
1034
  "textarea",
892
1035
  {
1036
+ value,
1037
+ onChange: handleChange,
893
1038
  "aria-labelledby": props.prompt ? promptId : void 0,
1039
+ "aria-describedby": props.hint ? hintId : void 0,
894
1040
  "aria-label": props.prompt ? void 0 : "Reflection response"
895
1041
  }
896
1042
  )
@@ -909,18 +1055,35 @@ function KnowledgeCheck(props) {
909
1055
  );
910
1056
  }
911
1057
  function Quiz(props) {
912
- warnInvalidComponentId(props.checkId, "checkId");
913
- const quiz = useQuizState();
914
- const { plugins, config, progress, session } = useLessonkit();
915
- const [selected, setSelected] = (0, import_react3.useState)(null);
916
- const [selectionCorrect, setSelectionCorrect] = (0, import_react3.useState)(null);
917
- const completedRef = (0, import_react3.useRef)(false);
918
- const questionId = (0, import_react3.useId)();
919
- (0, import_react3.useEffect)(() => {
1058
+ const checkId = (0, import_react5.useMemo)(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
1059
+ const enclosingLessonId = useEnclosingLessonId();
1060
+ const missingLesson = enclosingLessonId === void 0;
1061
+ (0, import_react5.useEffect)(() => {
1062
+ if (!missingLesson || isDevEnvironment3()) return;
1063
+ if (!warnedQuizOutsideLesson) {
1064
+ warnedQuizOutsideLesson = true;
1065
+ console.error(
1066
+ "[lessonkit] <Quiz> must be wrapped in <Lesson>; quiz telemetry will not be emitted."
1067
+ );
1068
+ }
1069
+ }, [missingLesson]);
1070
+ if (missingLesson && isDevEnvironment3()) {
1071
+ throw new Error("[lessonkit] <Quiz> must be wrapped in <Lesson>");
1072
+ }
1073
+ const quiz = useQuizState(enclosingLessonId);
1074
+ const { plugins, config, session } = useLessonkit();
1075
+ const [selected, setSelected] = (0, import_react5.useState)(null);
1076
+ const [selectionCorrect, setSelectionCorrect] = (0, import_react5.useState)(null);
1077
+ const [quizPassed, setQuizPassed] = (0, import_react5.useState)(false);
1078
+ const completedRef = (0, import_react5.useRef)(false);
1079
+ const questionId = (0, import_react5.useId)();
1080
+ const choicesKey = props.choices.join("\0");
1081
+ (0, import_react5.useEffect)(() => {
920
1082
  completedRef.current = false;
1083
+ setQuizPassed(false);
921
1084
  setSelected(null);
922
1085
  setSelectionCorrect(null);
923
- }, [props.checkId, props.answer, props.question, config.courseId]);
1086
+ }, [checkId, props.answer, props.question, config.courseId, enclosingLessonId, choicesKey]);
924
1087
  const isChoiceCorrect = (choice, custom) => {
925
1088
  if (!custom) return choice === props.answer;
926
1089
  if (custom.passed !== void 0) return custom.passed;
@@ -929,7 +1092,11 @@ function Quiz(props) {
929
1092
  }
930
1093
  return choice === props.answer;
931
1094
  };
932
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": "Quiz", "data-lk-check-id": props.checkId, children: [
1095
+ if (missingLesson) {
1096
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("section", { role: "alert", "aria-label": "Quiz configuration error", "data-lk-check-id": checkId, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { children: "Quiz must be placed inside a Lesson." }) });
1097
+ }
1098
+ const passed = quizPassed;
1099
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": "Quiz", "data-lk-check-id": checkId, children: [
933
1100
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { id: questionId, children: props.question }),
934
1101
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("fieldset", { "aria-labelledby": questionId, children: [
935
1102
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("legend", { style: import_accessibility.visuallyHiddenStyle, children: "Quiz choices" }),
@@ -941,17 +1108,21 @@ function Quiz(props) {
941
1108
  name: questionId,
942
1109
  value: c,
943
1110
  checked: selected === c,
1111
+ disabled: passed,
1112
+ "aria-invalid": selected === c && selectionCorrect === false ? true : void 0,
944
1113
  onChange: () => {
1114
+ if (passed) return;
945
1115
  setSelected(c);
946
1116
  const pluginCtx = buildPluginContext({
947
1117
  courseId: config.courseId,
948
1118
  sessionId: session.sessionId,
949
- attemptId: session.attemptId
1119
+ attemptId: session.attemptId,
1120
+ user: session.user
950
1121
  });
951
1122
  const custom = plugins?.scoreAssessment(
952
1123
  {
953
- checkId: props.checkId,
954
- lessonId: progress.activeLessonId,
1124
+ checkId,
1125
+ lessonId: enclosingLessonId,
955
1126
  response: c
956
1127
  },
957
1128
  pluginCtx
@@ -959,18 +1130,20 @@ function Quiz(props) {
959
1130
  const correct = isChoiceCorrect(c, custom);
960
1131
  setSelectionCorrect(correct);
961
1132
  quiz.answer({
962
- checkId: props.checkId,
1133
+ checkId,
963
1134
  question: props.question,
964
1135
  choice: c,
965
1136
  correct
966
1137
  });
967
1138
  if (correct && !completedRef.current) {
968
1139
  completedRef.current = true;
1140
+ setQuizPassed(true);
1141
+ const maxScore = custom?.maxScore ?? 1;
969
1142
  quiz.complete({
970
- checkId: props.checkId,
1143
+ checkId,
971
1144
  score: custom?.score ?? 1,
972
- maxScore: custom?.maxScore ?? 1,
973
- passingScore: props.passingScore ?? 1
1145
+ maxScore,
1146
+ passingScore: props.passingScore ?? maxScore
974
1147
  });
975
1148
  }
976
1149
  }
@@ -982,20 +1155,40 @@ function Quiz(props) {
982
1155
  selected && selectionCorrect !== null ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { role: "status", "aria-live": "polite", children: selectionCorrect ? "Correct" : "Try again" }) : null
983
1156
  ] });
984
1157
  }
985
- function ProgressTracker() {
1158
+ function ProgressTracker(props) {
986
1159
  const { progress } = useLessonkit();
987
1160
  const completed = progress.completedLessonIds.size;
988
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("aside", { "aria-label": "Progress", children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("p", { children: [
1161
+ if (props.totalLessons != null) {
1162
+ const total = props.totalLessons;
1163
+ const displayed = Math.min(completed, total);
1164
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("aside", { "aria-label": "Progress", children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1165
+ "div",
1166
+ {
1167
+ role: "progressbar",
1168
+ "aria-valuemin": 0,
1169
+ "aria-valuemax": total,
1170
+ "aria-valuenow": displayed,
1171
+ "aria-label": "Lessons completed",
1172
+ children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("p", { children: [
1173
+ "Lessons completed: ",
1174
+ displayed,
1175
+ " of ",
1176
+ total
1177
+ ] })
1178
+ }
1179
+ ) });
1180
+ }
1181
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("aside", { "aria-label": "Progress", role: "status", children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("p", { children: [
989
1182
  "Lessons completed: ",
990
1183
  completed
991
1184
  ] }) });
992
1185
  }
993
1186
 
994
1187
  // src/index.tsx
995
- var import_core7 = require("@lessonkit/core");
1188
+ var import_core10 = require("@lessonkit/core");
996
1189
 
997
1190
  // src/theme/ThemeProvider.tsx
998
- var import_react4 = __toESM(require("react"), 1);
1191
+ var import_react6 = __toESM(require("react"), 1);
999
1192
  var import_themes = require("@lessonkit/themes");
1000
1193
 
1001
1194
  // src/theme/applyCssVariables.ts
@@ -1015,8 +1208,8 @@ function applyCssVariables(target, vars, previousKeys) {
1015
1208
 
1016
1209
  // src/theme/ThemeProvider.tsx
1017
1210
  var import_jsx_runtime3 = require("react/jsx-runtime");
1018
- var ThemeContext = (0, import_react4.createContext)(null);
1019
- var useIsoLayoutEffect2 = typeof window !== "undefined" ? import_react4.useLayoutEffect : import_react4.default.useEffect;
1211
+ var ThemeContext = (0, import_react6.createContext)(null);
1212
+ var useIsoLayoutEffect2 = typeof window !== "undefined" ? import_react6.useLayoutEffect : import_react6.default.useEffect;
1020
1213
  function getSystemMode() {
1021
1214
  if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
1022
1215
  return "light";
@@ -1034,7 +1227,7 @@ function ThemeProvider(props) {
1034
1227
  const preset = props.preset ?? "default";
1035
1228
  const mode = props.mode ?? "light";
1036
1229
  const targetKind = props.target ?? "document";
1037
- const [resolvedMode, setResolvedMode] = (0, import_react4.useState)(
1230
+ const [resolvedMode, setResolvedMode] = (0, import_react6.useState)(
1038
1231
  () => mode === "system" ? getSystemMode() : mode
1039
1232
  );
1040
1233
  useIsoLayoutEffect2(() => {
@@ -1050,20 +1243,20 @@ function ThemeProvider(props) {
1050
1243
  return () => mq.removeEventListener("change", onChange);
1051
1244
  }, [mode]);
1052
1245
  const dataTheme = mode === "system" ? resolvedMode : mode === "dark" ? "dark" : "light";
1053
- const effectiveTheme = (0, import_react4.useMemo)(() => {
1246
+ const effectiveTheme = (0, import_react6.useMemo)(() => {
1054
1247
  const modeBase = resolveModeBase(mode, dataTheme);
1055
1248
  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));
1056
1249
  return (0, import_themes.mergeThemes)(base, props.theme ?? {});
1057
1250
  }, [preset, mode, dataTheme, props.theme]);
1058
- const hostRef = (0, import_react4.useRef)(null);
1059
- const appliedKeysRef = (0, import_react4.useRef)(/* @__PURE__ */ new Set());
1251
+ const hostRef = (0, import_react6.useRef)(null);
1252
+ const appliedKeysRef = (0, import_react6.useRef)(/* @__PURE__ */ new Set());
1060
1253
  useIsoLayoutEffect2(() => {
1061
1254
  if (targetKind === "document" && typeof document !== "undefined") {
1062
1255
  document.documentElement.setAttribute("data-lk-theme", dataTheme);
1063
1256
  return () => document.documentElement.removeAttribute("data-lk-theme");
1064
1257
  }
1065
1258
  }, [targetKind, dataTheme]);
1066
- const inject = (0, import_react4.useCallback)(() => {
1259
+ const inject = (0, import_react6.useCallback)(() => {
1067
1260
  const vars = (0, import_themes.themeToCssVariables)(effectiveTheme);
1068
1261
  const el = targetKind === "document" && typeof document !== "undefined" ? document.documentElement : hostRef.current;
1069
1262
  if (!el) return;
@@ -1080,7 +1273,7 @@ function ThemeProvider(props) {
1080
1273
  appliedKeysRef.current = /* @__PURE__ */ new Set();
1081
1274
  };
1082
1275
  }, [inject, targetKind]);
1083
- const value = (0, import_react4.useMemo)(
1276
+ const value = (0, import_react6.useMemo)(
1084
1277
  () => ({
1085
1278
  theme: effectiveTheme,
1086
1279
  preset,
@@ -1095,7 +1288,7 @@ function ThemeProvider(props) {
1095
1288
  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 }) });
1096
1289
  }
1097
1290
  function useTheme() {
1098
- const ctx = (0, import_react4.useContext)(ThemeContext);
1291
+ const ctx = (0, import_react6.useContext)(ThemeContext);
1099
1292
  if (!ctx) {
1100
1293
  throw new Error("useTheme must be used within a ThemeProvider");
1101
1294
  }
@@ -1142,6 +1335,12 @@ var BLOCK_CATALOG = [
1142
1335
  props: [
1143
1336
  { name: "title", type: "string", required: true, description: "Lesson title shown in the h2." },
1144
1337
  { name: "lessonId", type: "LessonId", required: true, description: "Stable lesson identifier for telemetry and packaging." },
1338
+ {
1339
+ name: "autoCompleteOnUnmount",
1340
+ type: "boolean",
1341
+ required: false,
1342
+ description: "When false, unmount does not emit lesson_completed (default true)."
1343
+ },
1145
1344
  { name: "children", type: "ReactNode", required: true, description: "Scenario, Quiz, Reflection, and other blocks." }
1146
1345
  ],
1147
1346
  requiredIds: ["lessonId"],
@@ -1194,6 +1393,9 @@ var BLOCK_CATALOG = [
1194
1393
  props: [
1195
1394
  { name: "blockId", type: "BlockId", required: false, description: "Optional stable block id for interaction telemetry URNs." },
1196
1395
  { name: "prompt", type: "string", required: false, description: "Reflection question or instruction." },
1396
+ { name: "hint", type: "string", required: false, description: "Optional hint linked via aria-describedby." },
1397
+ { name: "value", type: "string", required: false, description: "Controlled textarea value." },
1398
+ { name: "onChange", type: "(value: string) => void", required: false, description: "Called when the learner edits the textarea." },
1197
1399
  { name: "children", type: "ReactNode", required: false, description: "Optional content above the textarea." }
1198
1400
  ],
1199
1401
  requiredIds: [],
@@ -1212,6 +1414,7 @@ var BLOCK_CATALOG = [
1212
1414
  },
1213
1415
  telemetry: {
1214
1416
  emits: [],
1417
+ requiresActiveLesson: true,
1215
1418
  manualTracking: "useTracking().track('interaction', { kind, blockId, payload }) on submit or blur"
1216
1419
  }
1217
1420
  },
@@ -1224,7 +1427,13 @@ var BLOCK_CATALOG = [
1224
1427
  { name: "checkId", type: "CheckId", required: true, description: "Stable check identifier for telemetry and LXPack assessments." },
1225
1428
  { name: "question", type: "string", required: true, description: "Question text shown above choices." },
1226
1429
  { name: "choices", type: "string[]", required: true, description: "Radio button choice labels." },
1227
- { name: "answer", type: "string", required: true, description: "Correct choice value (must match one choice)." }
1430
+ { name: "answer", type: "string", required: true, description: "Correct choice value (must match one choice)." },
1431
+ {
1432
+ name: "passingScore",
1433
+ type: "number",
1434
+ required: false,
1435
+ description: "Minimum score required to pass (defaults to maxScore when omitted)."
1436
+ }
1228
1437
  ],
1229
1438
  requiredIds: ["checkId"],
1230
1439
  parentConstraints: ["Lesson"],
@@ -1249,7 +1458,14 @@ var BLOCK_CATALOG = [
1249
1458
  type: "ProgressTracker",
1250
1459
  category: "chrome",
1251
1460
  description: "Displays count of completed lessons from runtime progress state.",
1252
- props: [],
1461
+ props: [
1462
+ {
1463
+ name: "totalLessons",
1464
+ type: "number",
1465
+ required: false,
1466
+ description: "When set, renders role=progressbar with aria-valuenow/max."
1467
+ }
1468
+ ],
1253
1469
  requiredIds: [],
1254
1470
  parentConstraints: ["Course"],
1255
1471
  a11y: {
@@ -1302,9 +1518,15 @@ function getBlockCatalogEntry(type) {
1302
1518
  ThemeProvider,
1303
1519
  blockCatalogVersion,
1304
1520
  buildBlockCatalog,
1305
- createPluginHost,
1306
- defineLessonkitPlugin,
1521
+ buildTelemetryEvent,
1522
+ createLessonkitRuntime,
1523
+ createPluginRegistry,
1524
+ createTelemetryPipeline,
1525
+ defineAssessmentPlugin,
1526
+ defineLifecyclePlugin,
1527
+ defineTelemetryPlugin,
1307
1528
  getBlockCatalogEntry,
1529
+ resetQuizWarningsForTests,
1308
1530
  useCompletion,
1309
1531
  useLessonkit,
1310
1532
  useProgress,