@lessonkit/react 0.9.2 → 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.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");
72
+ var import_core8 = require("@lessonkit/core");
64
73
  var import_xapi3 = require("@lessonkit/xapi");
65
74
  var import_xapi4 = 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,37 @@ 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));
339
- }
340
- }
170
+ var import_core5 = require("@lessonkit/core");
341
171
 
342
172
  // src/runtime/plugins.ts
343
- var import_core3 = require("@lessonkit/core");
173
+ var import_core6 = require("@lessonkit/core");
344
174
  function createReactPluginHost(plugins) {
345
175
  if (!plugins?.length) return null;
346
- return (0, import_core3.createPluginHost)(plugins);
176
+ return (0, import_core6.createPluginRegistry)(plugins);
347
177
  }
348
178
  function buildPluginContext(opts) {
349
179
  return {
350
180
  courseId: opts.courseId,
351
181
  sessionId: opts.sessionId,
352
- attemptId: opts.attemptId
182
+ attemptId: opts.attemptId,
183
+ user: opts.user
353
184
  };
354
185
  }
355
186
  function emitTelemetryWithPlugins(opts) {
356
187
  const next = opts.pluginHost ? opts.pluginHost.runTelemetry(opts.event, opts.pluginCtx) : opts.event;
357
188
  if (next === null) return;
358
- emitTelemetry(opts.tracking, opts.xapi, next, { lxpackBridge: opts.lxpackBridge ?? "auto" });
189
+ emitTelemetry(opts.tracking, opts.xapi, next, {
190
+ lxpackBridge: opts.lxpackBridge ?? "auto",
191
+ extraSinks: opts.extraSinks
192
+ });
359
193
  }
360
194
 
361
195
  // src/runtime/telemetry.ts
362
- var import_core4 = require("@lessonkit/core");
196
+ var import_core7 = require("@lessonkit/core");
363
197
  function createTrackingClientFromConfig(config) {
364
- if (config.tracking?.enabled === false) return (0, import_core4.createTrackingClient)();
198
+ if (config.tracking?.enabled === false) return (0, import_core7.createTrackingClient)();
365
199
  if (config.tracking?.createClient) return config.tracking.createClient();
366
- return (0, import_core4.createTrackingClient)({
200
+ return (0, import_core7.createTrackingClient)({
367
201
  sink: config.tracking?.sink,
368
202
  batchSink: config.tracking?.batchSink,
369
203
  batch: config.tracking?.batch
@@ -380,70 +214,206 @@ async function disposeTrackingClient(client) {
380
214
  }
381
215
  }
382
216
 
383
- // src/context.tsx
384
- var import_jsx_runtime = require("react/jsx-runtime");
385
- var LessonkitContext = (0, import_react.createContext)(null);
217
+ // src/provider/useLessonkitProviderRuntime.ts
386
218
  var useIsoLayoutEffect = typeof window !== "undefined" ? import_react.useLayoutEffect : import_react.useEffect;
387
- var defaultStorage = createSessionStoragePort();
219
+ var defaultStorage = (0, import_core3.createSessionStoragePort)();
388
220
  function isTrackingActive(tracking) {
389
221
  return tracking?.enabled !== false;
390
222
  }
391
- function emitCourseStarted(opts) {
223
+ var noopTrackingClient = { track: () => {
224
+ } };
225
+ function buildCourseStartedEvent(opts) {
226
+ const pluginCtx = buildPluginContext({
227
+ courseId: opts.courseId,
228
+ sessionId: opts.sessionId,
229
+ attemptId: opts.attemptId,
230
+ user: opts.user
231
+ });
232
+ const built = (0, import_core2.buildTelemetryEvent)({
233
+ name: "course_started",
234
+ courseId: opts.courseId,
235
+ sessionId: opts.sessionId,
236
+ attemptId: opts.attemptId,
237
+ user: opts.user
238
+ });
239
+ return opts.pluginHost ? opts.pluginHost.runTelemetry(built, pluginCtx) : built;
240
+ }
241
+ function emitCourseStartedPipelineOnly(opts) {
392
242
  const pluginCtx = buildPluginContext({
393
243
  courseId: opts.courseId,
394
244
  sessionId: opts.sessionId,
395
- attemptId: opts.attemptId
245
+ attemptId: opts.attemptId,
246
+ user: opts.user
396
247
  });
397
248
  try {
398
249
  emitTelemetryWithPlugins({
399
- pluginHost: opts.pluginHost,
400
- tracking: opts.tracking,
250
+ pluginHost: null,
251
+ tracking: noopTrackingClient,
401
252
  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
- }),
253
+ event: opts.event,
409
254
  pluginCtx,
410
- lxpackBridge: opts.lxpackBridge
255
+ lxpackBridge: opts.lxpackBridge,
256
+ extraSinks: opts.extraSinks
411
257
  });
412
- markCourseStarted(opts.storage, opts.sessionId, opts.courseId);
258
+ (0, import_core5.markCourseStarted)(opts.storage, opts.sessionId, opts.courseId);
413
259
  return true;
414
260
  } catch {
415
261
  return false;
416
262
  }
417
263
  }
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;
264
+ function emitCourseStarted(opts) {
265
+ const event = buildCourseStartedEvent(opts);
266
+ if (event === null) return true;
267
+ const trackingAlreadyEmitted = (0, import_core5.hasCourseStartedEmittedToTracking)(
268
+ opts.storage,
269
+ opts.sessionId,
270
+ opts.courseId
271
+ );
272
+ if (!trackingAlreadyEmitted) {
273
+ try {
274
+ opts.tracking.track(event);
275
+ (0, import_core5.markCourseStartedEmittedToTracking)(opts.storage, opts.sessionId, opts.courseId);
276
+ } catch {
277
+ return false;
278
+ }
279
+ }
280
+ return emitCourseStartedPipelineOnly({ ...opts, event });
281
+ }
282
+ function emitCourseStartedToTrackingOnly(opts) {
283
+ const event = buildCourseStartedEvent(opts);
284
+ if (event === null) return true;
285
+ const trackingAlreadyEmitted = (0, import_core5.hasCourseStartedEmittedToTracking)(
286
+ opts.storage,
287
+ opts.sessionId,
288
+ opts.courseId
289
+ );
290
+ if (!trackingAlreadyEmitted) {
291
+ try {
292
+ opts.tracking.track(event);
293
+ (0, import_core5.markCourseStartedEmittedToTracking)(opts.storage, opts.sessionId, opts.courseId);
294
+ } catch {
295
+ return false;
296
+ }
297
+ }
298
+ const pluginCtx = buildPluginContext({
299
+ courseId: opts.courseId,
300
+ sessionId: opts.sessionId,
301
+ attemptId: opts.attemptId,
302
+ user: opts.user
303
+ });
304
+ try {
305
+ emitTelemetryWithPlugins({
306
+ pluginHost: null,
307
+ tracking: noopTrackingClient,
308
+ xapi: null,
309
+ event,
310
+ pluginCtx,
311
+ lxpackBridge: opts.lxpackBridge,
312
+ extraSinks: opts.extraSinks
313
+ });
314
+ return true;
315
+ } catch {
316
+ return false;
317
+ }
318
+ }
319
+ function emitPendingCourseStarted(opts) {
320
+ const trackingEmitted = (0, import_core5.hasCourseStartedEmittedToTracking)(
321
+ opts.storage,
322
+ opts.sessionId,
323
+ opts.courseId
324
+ );
325
+ const sessionStarted = (0, import_core5.hasCourseStarted)(opts.storage, opts.sessionId, opts.courseId);
326
+ if (sessionStarted && !trackingEmitted) {
327
+ return emitCourseStartedToTrackingOnly(opts);
328
+ }
329
+ if (trackingEmitted && !sessionStarted) {
330
+ const event = buildCourseStartedEvent(opts);
331
+ if (event === null) return true;
332
+ return emitCourseStartedPipelineOnly({ ...opts, event });
333
+ }
334
+ if (!trackingEmitted && !sessionStarted) {
335
+ return emitCourseStarted(opts);
336
+ }
337
+ return true;
338
+ }
339
+ function assertTrackingSinkConfig(tracking) {
340
+ if (!tracking?.sink || !tracking?.batchSink) return;
341
+ throw new Error(
342
+ "[lessonkit] tracking.sink and tracking.batchSink cannot both be set; use batchSink alone for batched delivery"
343
+ );
344
+ }
345
+ function useLessonkitProviderRuntime(config) {
346
+ const normalizedCourseId = (0, import_react.useMemo)(
347
+ () => (0, import_core8.assertValidId)(config.courseId, "courseId"),
348
+ [config.courseId]
349
+ );
350
+ const normalizedConfig = (0, import_react.useMemo)(
351
+ () => ({ ...config, courseId: normalizedCourseId }),
352
+ [config, normalizedCourseId]
353
+ );
354
+ const useV2Runtime = normalizedConfig.runtimeVersion !== "v1";
355
+ const extraSinksRef = (0, import_react.useRef)(normalizedConfig.sinks);
356
+ extraSinksRef.current = normalizedConfig.sinks;
357
+ const headlessRef = (0, import_react.useRef)(null);
358
+ const sessionIdRef = (0, import_react.useRef)((0, import_core5.resolveSessionId)(defaultStorage, normalizedConfig.session?.sessionId));
359
+ const prevConfiguredSessionIdRef = (0, import_react.useRef)(normalizedConfig.session?.sessionId);
360
+ if (normalizedConfig.session?.sessionId) {
361
+ sessionIdRef.current = normalizedConfig.session.sessionId;
424
362
  } else if (prevConfiguredSessionIdRef.current) {
425
- sessionIdRef.current = resolveSessionId(defaultStorage, void 0);
363
+ sessionIdRef.current = (0, import_core5.resolveSessionId)(defaultStorage, void 0);
426
364
  }
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]);
365
+ const attemptIdRef = (0, import_react.useRef)(normalizedConfig.session?.attemptId);
366
+ const userRef = (0, import_react.useRef)(normalizedConfig.session?.user);
367
+ attemptIdRef.current = normalizedConfig.session?.attemptId;
368
+ userRef.current = normalizedConfig.session?.user;
369
+ const courseIdRef = (0, import_react.useRef)(normalizedCourseId);
370
+ courseIdRef.current = normalizedCourseId;
371
+ const lxpackBridgeModeRef = (0, import_react.useRef)(normalizedConfig.lxpack?.bridge ?? "auto");
372
+ lxpackBridgeModeRef.current = normalizedConfig.lxpack?.bridge ?? "auto";
373
+ const pluginHost = (0, import_react.useMemo)(() => createReactPluginHost(normalizedConfig.plugins), [normalizedConfig.plugins]);
436
374
  const pluginHostRef = (0, import_react.useRef)(pluginHost);
437
375
  pluginHostRef.current = pluginHost;
438
- const progressRef = (0, import_react.useRef)(createProgressController());
376
+ const progressRef = (0, import_react.useRef)((0, import_core4.createProgressController)());
439
377
  const courseStartedEmittedToSinkRef = (0, import_react.useRef)(false);
440
- const prevCourseIdForProgressRef = (0, import_react.useRef)(config.courseId);
378
+ const prevCourseIdForProgressRef = (0, import_react.useRef)(normalizedCourseId);
441
379
  const pendingCourseIdResetRef = (0, import_react.useRef)(false);
442
- if (prevCourseIdForProgressRef.current !== config.courseId) {
443
- prevCourseIdForProgressRef.current = config.courseId;
444
- progressRef.current = createProgressController();
380
+ const prevUseV2RuntimeRef = (0, import_react.useRef)(useV2Runtime);
381
+ const xapiCourseStartedSentOnClientRef = (0, import_react.useRef)(false);
382
+ if (prevUseV2RuntimeRef.current !== useV2Runtime) {
383
+ prevUseV2RuntimeRef.current = useV2Runtime;
384
+ if (useV2Runtime) {
385
+ headlessRef.current = (0, import_core8.createLessonkitRuntime)({
386
+ courseId: normalizedCourseId,
387
+ runtimeVersion: "v2",
388
+ session: normalizedConfig.session
389
+ });
390
+ progressRef.current = headlessRef.current.progress;
391
+ } else {
392
+ headlessRef.current = null;
393
+ progressRef.current = (0, import_core4.createProgressController)();
394
+ }
445
395
  pendingCourseIdResetRef.current = true;
446
396
  courseStartedEmittedToSinkRef.current = false;
397
+ } else if (useV2Runtime && !headlessRef.current) {
398
+ headlessRef.current = (0, import_core8.createLessonkitRuntime)({
399
+ courseId: normalizedCourseId,
400
+ runtimeVersion: "v2",
401
+ session: normalizedConfig.session
402
+ });
403
+ }
404
+ if (prevCourseIdForProgressRef.current !== normalizedCourseId) {
405
+ prevCourseIdForProgressRef.current = normalizedCourseId;
406
+ if (useV2Runtime && headlessRef.current) {
407
+ headlessRef.current.resetForCourseChange(normalizedCourseId);
408
+ progressRef.current = headlessRef.current.progress;
409
+ } else {
410
+ progressRef.current = (0, import_core4.createProgressController)();
411
+ }
412
+ pendingCourseIdResetRef.current = true;
413
+ courseStartedEmittedToSinkRef.current = false;
414
+ }
415
+ if (useV2Runtime && headlessRef.current) {
416
+ progressRef.current = headlessRef.current.progress;
447
417
  }
448
418
  const [progress, setProgress] = (0, import_react.useState)(() => progressRef.current.getState());
449
419
  const syncProgress = (0, import_react.useCallback)(() => {
@@ -454,16 +424,16 @@ function LessonkitProvider(props) {
454
424
  const xapiQueueRef = (0, import_react.useRef)((0, import_xapi3.createInMemoryXAPIQueue)());
455
425
  const xapiRef = (0, import_react.useRef)(null);
456
426
  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;
427
+ const prevXapiCourseIdRef = (0, import_react.useRef)(normalizedCourseId);
428
+ const xapiEnabled = normalizedConfig.xapi?.enabled;
429
+ const xapiClient = normalizedConfig.xapi?.client;
430
+ const xapiTransport = normalizedConfig.xapi?.transport;
431
+ const courseId = normalizedCourseId;
432
+ const trackingEnabled = normalizedConfig.tracking?.enabled;
463
433
  useIsoLayoutEffect(() => {
464
434
  const courseChanged = prevXapiCourseIdRef.current !== courseId;
465
435
  if (courseChanged) {
466
- if (config.xapi?.client) {
436
+ if (normalizedConfig.xapi?.client) {
467
437
  const g = globalThis;
468
438
  if (typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production") {
469
439
  console.warn(
@@ -474,20 +444,24 @@ function LessonkitProvider(props) {
474
444
  }
475
445
  xapiQueueRef.current = (0, import_xapi3.createInMemoryXAPIQueue)();
476
446
  prevXapiCourseIdRef.current = courseId;
447
+ xapiCourseStartedSentOnClientRef.current = false;
477
448
  }
478
449
  const prev = xapiRef.current;
479
- const next = createXapiClientFromConfig(config, xapiQueueRef.current);
450
+ const next = createXapiClientFromConfig(normalizedConfig, xapiQueueRef.current);
480
451
  xapiRef.current = next;
481
452
  setXapi(next);
482
- if (next && !prev) {
453
+ if (next) {
483
454
  const sessionId = sessionIdRef.current;
484
455
  const cid = courseIdRef.current;
485
- const trackingActive = isTrackingActive(config.tracking);
486
- const alreadyStarted = hasCourseStarted(defaultStorage, sessionId, cid);
487
- if (!trackingActive || alreadyStarted) {
456
+ const trackingActive = isTrackingActive(normalizedConfig.tracking);
457
+ const alreadyStarted = (0, import_core5.hasCourseStarted)(defaultStorage, sessionId, cid);
458
+ const clientChanged = !prev || prev !== next;
459
+ const skipBootstrap = trackingActive && !alreadyStarted;
460
+ const needsBootstrap = !skipBootstrap && !xapiCourseStartedSentOnClientRef.current && (!alreadyStarted || clientChanged);
461
+ if (needsBootstrap) {
488
462
  try {
489
463
  const statement = (0, import_xapi4.telemetryEventToXAPIStatement)(
490
- buildTrackEvent({
464
+ (0, import_core2.buildTelemetryEvent)({
491
465
  name: "course_started",
492
466
  courseId: cid,
493
467
  sessionId,
@@ -497,7 +471,10 @@ function LessonkitProvider(props) {
497
471
  );
498
472
  if (statement) {
499
473
  next.send(statement);
500
- markCourseStarted(defaultStorage, sessionId, cid);
474
+ if (!alreadyStarted) {
475
+ (0, import_core5.markCourseStarted)(defaultStorage, sessionId, cid);
476
+ }
477
+ xapiCourseStartedSentOnClientRef.current = true;
501
478
  }
502
479
  } catch {
503
480
  }
@@ -522,49 +499,56 @@ function LessonkitProvider(props) {
522
499
  void prev?.flush();
523
500
  };
524
501
  }, [xapiEnabled, xapiClient, xapiTransport, courseId, trackingEnabled]);
525
- const trackingRef = (0, import_react.useRef)((0, import_core5.createTrackingClient)());
502
+ const trackingRef = (0, import_react.useRef)((0, import_core8.createTrackingClient)());
526
503
  const trackingClientForUnmountRef = (0, import_react.useRef)(trackingRef.current);
527
504
  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;
505
+ const trackingSink = normalizedConfig.tracking?.sink;
506
+ const trackingBatchSink = normalizedConfig.tracking?.batchSink;
507
+ const batchEnabled = normalizedConfig.tracking?.batch?.enabled;
508
+ const batchFlushIntervalMs = normalizedConfig.tracking?.batch?.flushIntervalMs;
509
+ const batchMaxBatchSize = normalizedConfig.tracking?.batch?.maxBatchSize;
533
510
  const buildCurrentPluginCtx = (0, import_react.useCallback)(
534
511
  () => buildPluginContext({
535
512
  courseId: courseIdRef.current,
536
513
  sessionId: sessionIdRef.current,
537
- attemptId: attemptIdRef.current
514
+ attemptId: attemptIdRef.current,
515
+ user: userRef.current
538
516
  }),
539
517
  []
540
518
  );
541
519
  useIsoLayoutEffect(() => {
542
520
  const prev = trackingRef.current;
543
- const baseSink = config.tracking?.sink;
544
- const sink = pluginHostRef.current && baseSink ? (event) => {
545
- const composed = pluginHostRef.current.composeTrackingSink(baseSink, buildCurrentPluginCtx()) ?? baseSink;
546
- return composed(event);
547
- } : baseSink;
548
- const batchSink = pluginHostRef.current && config.tracking?.batchSink ? (events) => {
549
- const delivered = pluginHostRef.current.deliverTelemetryBatch(
550
- events,
551
- buildCurrentPluginCtx()
552
- );
553
- return config.tracking.batchSink(delivered);
554
- } : config.tracking?.batchSink;
521
+ const baseSink = normalizedConfig.tracking?.sink;
522
+ const userBatchSink = normalizedConfig.tracking?.batchSink;
523
+ assertTrackingSinkConfig(normalizedConfig.tracking);
524
+ const sink = pluginHostRef.current && baseSink ? pluginHostRef.current.composeTrackingSink(baseSink, buildCurrentPluginCtx) ?? baseSink : baseSink;
525
+ const batchSink = pluginHostRef.current && userBatchSink ? async (events) => {
526
+ const host = pluginHostRef.current;
527
+ const ctx = buildCurrentPluginCtx();
528
+ const delivered = host.deliverTelemetryBatch(events, ctx);
529
+ const perEventForBatch = [];
530
+ const collector = (event) => {
531
+ perEventForBatch.push(event);
532
+ };
533
+ const composedPerEvent = host.composeTrackingSink(collector, buildCurrentPluginCtx) ?? collector;
534
+ for (const event of delivered) {
535
+ await Promise.resolve(composedPerEvent(event));
536
+ }
537
+ return userBatchSink(perEventForBatch);
538
+ } : userBatchSink;
555
539
  const next = createTrackingClientFromConfig({
556
- tracking: { ...config.tracking, sink, batchSink }
540
+ tracking: { ...normalizedConfig.tracking, sink, batchSink }
557
541
  });
558
542
  trackingRef.current = next;
559
543
  trackingClientForUnmountRef.current = next;
560
544
  setTracking(next);
561
545
  const sessionId = sessionIdRef.current;
562
546
  const cid = courseIdRef.current;
563
- const trackingActive = isTrackingActive(config.tracking);
547
+ const trackingActive = isTrackingActive(normalizedConfig.tracking);
564
548
  if (!trackingActive) {
565
549
  courseStartedEmittedToSinkRef.current = false;
566
- } else if (!courseStartedEmittedToSinkRef.current && !hasCourseStarted(defaultStorage, sessionId, cid)) {
567
- const emitted = emitCourseStarted({
550
+ } else if (!courseStartedEmittedToSinkRef.current) {
551
+ const emitted = emitPendingCourseStarted({
568
552
  pluginHost: pluginHostRef.current,
569
553
  tracking: next,
570
554
  xapi: xapiRef.current,
@@ -573,8 +557,12 @@ function LessonkitProvider(props) {
573
557
  courseId: cid,
574
558
  attemptId: attemptIdRef.current,
575
559
  user: userRef.current,
576
- lxpackBridge: lxpackBridgeModeRef.current
560
+ lxpackBridge: lxpackBridgeModeRef.current,
561
+ extraSinks: extraSinksRef.current
577
562
  });
563
+ if (emitted) {
564
+ (0, import_core5.markCourseStartedEmittedToTracking)(defaultStorage, sessionId, cid);
565
+ }
578
566
  courseStartedEmittedToSinkRef.current = emitted;
579
567
  } else if (trackingActive) {
580
568
  courseStartedEmittedToSinkRef.current = true;
@@ -591,8 +579,8 @@ function LessonkitProvider(props) {
591
579
  batchEnabled,
592
580
  batchFlushIntervalMs,
593
581
  batchMaxBatchSize,
594
- config.plugins,
595
- config.courseId,
582
+ normalizedConfig.plugins,
583
+ normalizedCourseId,
596
584
  buildCurrentPluginCtx
597
585
  ]);
598
586
  const emitWithBridge = (0, import_react.useCallback)((trackingClient, event) => {
@@ -604,14 +592,32 @@ function LessonkitProvider(props) {
604
592
  pluginCtx: buildPluginContext({
605
593
  courseId: courseIdRef.current,
606
594
  sessionId: sessionIdRef.current,
607
- attemptId: attemptIdRef.current
595
+ attemptId: attemptIdRef.current,
596
+ user: userRef.current
608
597
  }),
609
- lxpackBridge: lxpackBridgeModeRef.current
598
+ lxpackBridge: lxpackBridgeModeRef.current,
599
+ extraSinks: extraSinksRef.current
610
600
  });
611
601
  }, []);
602
+ const emitLifecycleEvent = (0, import_react.useCallback)(
603
+ (name, data, lessonId) => {
604
+ const event = (0, import_core2.tryBuildTelemetryEvent)({
605
+ name,
606
+ courseId: courseIdRef.current,
607
+ lessonId: lessonId ?? activeLessonIdRef.current,
608
+ sessionId: sessionIdRef.current,
609
+ attemptId: attemptIdRef.current,
610
+ user: userRef.current,
611
+ data
612
+ });
613
+ if (!event) return;
614
+ emitWithBridge(trackingRef.current, event);
615
+ },
616
+ [emitWithBridge]
617
+ );
612
618
  const track = (0, import_react.useCallback)(
613
619
  (name, data, opts) => {
614
- const event = tryBuildTrackEvent({
620
+ const event = (0, import_core2.tryBuildTelemetryEvent)({
615
621
  name,
616
622
  courseId: courseIdRef.current,
617
623
  lessonId: opts?.lessonId ?? activeLessonIdRef.current,
@@ -629,7 +635,7 @@ function LessonkitProvider(props) {
629
635
  if (!pendingCourseIdResetRef.current) return;
630
636
  pendingCourseIdResetRef.current = false;
631
637
  syncProgress();
632
- if (!isTrackingActive(config.tracking)) return;
638
+ if (!isTrackingActive(normalizedConfig.tracking)) return;
633
639
  const sessionId = sessionIdRef.current;
634
640
  const cid = courseIdRef.current;
635
641
  void (async () => {
@@ -637,8 +643,8 @@ function LessonkitProvider(props) {
637
643
  await trackingRef.current?.flush?.();
638
644
  } catch {
639
645
  }
640
- if (!courseStartedEmittedToSinkRef.current && !hasCourseStarted(defaultStorage, sessionId, cid)) {
641
- const emitted = emitCourseStarted({
646
+ if (!courseStartedEmittedToSinkRef.current) {
647
+ const emitted = emitPendingCourseStarted({
642
648
  pluginHost: pluginHostRef.current,
643
649
  tracking: trackingRef.current,
644
650
  xapi: xapiRef.current,
@@ -647,12 +653,13 @@ function LessonkitProvider(props) {
647
653
  courseId: cid,
648
654
  attemptId: attemptIdRef.current,
649
655
  user: userRef.current,
650
- lxpackBridge: lxpackBridgeModeRef.current
656
+ lxpackBridge: lxpackBridgeModeRef.current,
657
+ extraSinks: extraSinksRef.current
651
658
  });
652
659
  courseStartedEmittedToSinkRef.current = emitted;
653
660
  }
654
661
  })();
655
- }, [config.courseId, config.tracking?.enabled, syncProgress]);
662
+ }, [normalizedCourseId, normalizedConfig.tracking?.enabled, syncProgress]);
656
663
  const emitLessonCompleted = (0, import_react.useCallback)(
657
664
  (lessonId, durationMs) => {
658
665
  track("lesson_completed", { lessonId, durationMs }, { lessonId });
@@ -664,20 +671,27 @@ function LessonkitProvider(props) {
664
671
  );
665
672
  const completeLesson = (0, import_react.useCallback)(
666
673
  (lessonId) => {
674
+ if (useV2Runtime && headlessRef.current) {
675
+ headlessRef.current.completeLesson(lessonId, emitLifecycleEvent);
676
+ syncProgress();
677
+ void Promise.resolve(trackingRef.current?.flush?.());
678
+ return;
679
+ }
667
680
  const result = progressRef.current.completeLesson(lessonId, Date.now());
668
681
  if (!result.didComplete) return;
669
682
  syncProgress();
670
683
  emitLessonCompleted(lessonId, result.durationMs);
671
684
  void Promise.resolve(trackingRef.current?.flush?.());
672
685
  },
673
- [syncProgress, emitLessonCompleted]
686
+ [syncProgress, emitLessonCompleted, useV2Runtime, emitLifecycleEvent]
674
687
  );
675
688
  (0, import_react.useEffect)(() => {
676
689
  return () => {
677
690
  const client = trackingClientForUnmountRef.current;
691
+ const xapi2 = xapiRef.current;
678
692
  void (async () => {
679
693
  try {
680
- await xapiRef.current?.flush();
694
+ await xapi2?.flush();
681
695
  } catch {
682
696
  }
683
697
  try {
@@ -693,8 +707,19 @@ function LessonkitProvider(props) {
693
707
  }, []);
694
708
  const setActiveLesson = (0, import_react.useCallback)(
695
709
  (lessonId) => {
710
+ if (useV2Runtime && headlessRef.current) {
711
+ headlessRef.current.setActiveLesson(lessonId, emitLifecycleEvent);
712
+ syncProgress();
713
+ void Promise.resolve(trackingRef.current?.flush?.());
714
+ return;
715
+ }
696
716
  const current = progressRef.current.getState();
697
717
  if (current.activeLessonId === lessonId) return;
718
+ if (current.completedLessonIds.has(lessonId)) {
719
+ progressRef.current.setActiveLesson(lessonId, Date.now());
720
+ syncProgress();
721
+ return;
722
+ }
698
723
  const previous = current.activeLessonId;
699
724
  if (previous && previous !== lessonId) {
700
725
  const completed = progressRef.current.completeLesson(previous, Date.now());
@@ -707,32 +732,58 @@ function LessonkitProvider(props) {
707
732
  syncProgress();
708
733
  track("lesson_started", { lessonId }, { lessonId });
709
734
  },
710
- [track, syncProgress, emitLessonCompleted]
735
+ [track, syncProgress, emitLessonCompleted, useV2Runtime, emitLifecycleEvent]
711
736
  );
712
737
  const completeCourse = (0, import_react.useCallback)(() => {
738
+ if (useV2Runtime && headlessRef.current) {
739
+ headlessRef.current.completeCourse(emitLifecycleEvent);
740
+ syncProgress();
741
+ void trackingRef.current?.flush?.();
742
+ return;
743
+ }
744
+ const current = progressRef.current.getState();
745
+ if (current.activeLessonId) {
746
+ const lessonResult = progressRef.current.completeLesson(current.activeLessonId, Date.now());
747
+ if (lessonResult.didComplete) {
748
+ emitLessonCompleted(current.activeLessonId, lessonResult.durationMs);
749
+ }
750
+ }
713
751
  const result = progressRef.current.completeCourse();
714
752
  if (!result.didComplete) return;
715
753
  syncProgress();
716
754
  track("course_completed");
717
755
  void trackingRef.current?.flush?.();
718
- }, [track, syncProgress]);
719
- const sessionUser = config.session?.user;
720
- const sessionAttemptId = config.session?.attemptId;
721
- const sessionConfiguredId = config.session?.sessionId;
756
+ }, [track, syncProgress, emitLessonCompleted, useV2Runtime, emitLifecycleEvent]);
757
+ const sessionUser = normalizedConfig.session?.user;
758
+ const sessionUserKey = (0, import_react.useMemo)(
759
+ () => sessionUser ? JSON.stringify(sessionUser) : "",
760
+ [sessionUser]
761
+ );
762
+ const sessionAttemptId = normalizedConfig.session?.attemptId;
763
+ const sessionConfiguredId = normalizedConfig.session?.sessionId;
764
+ (0, import_react.useEffect)(() => {
765
+ if (useV2Runtime && headlessRef.current) {
766
+ headlessRef.current.updateConfig({
767
+ courseId: normalizedCourseId,
768
+ session: normalizedConfig.session
769
+ });
770
+ }
771
+ }, [useV2Runtime, normalizedCourseId, sessionAttemptId, sessionConfiguredId, sessionUserKey, normalizedConfig.session]);
722
772
  (0, import_react.useEffect)(() => {
723
773
  if (!pluginHost) return;
724
774
  const ctx = buildPluginContext({
725
775
  courseId: courseIdRef.current,
726
776
  sessionId: sessionIdRef.current,
727
- attemptId: attemptIdRef.current
777
+ attemptId: attemptIdRef.current,
778
+ user: userRef.current
728
779
  });
729
780
  pluginHost.setupAll(ctx);
730
781
  return () => {
731
782
  pluginHost.disposeAll();
732
783
  };
733
- }, [pluginHost, config.courseId, sessionAttemptId, sessionConfiguredId]);
784
+ }, [pluginHost, normalizedCourseId, sessionAttemptId, sessionConfiguredId, sessionUserKey]);
734
785
  (0, import_react.useEffect)(() => {
735
- const nextConfigured = config.session?.sessionId;
786
+ const nextConfigured = normalizedConfig.session?.sessionId;
736
787
  const prevConfigured = prevConfiguredSessionIdRef.current;
737
788
  if (nextConfigured === prevConfigured) return;
738
789
  prevConfiguredSessionIdRef.current = nextConfigured;
@@ -740,23 +791,23 @@ function LessonkitProvider(props) {
740
791
  if (nextConfigured) {
741
792
  const fromIds = /* @__PURE__ */ new Set();
742
793
  if (prevConfigured) fromIds.add(prevConfigured);
743
- const tabId = getTabSessionId(defaultStorage);
794
+ const tabId = (0, import_core5.getTabSessionId)(defaultStorage);
744
795
  if (tabId) fromIds.add(tabId);
745
796
  for (const fromId of fromIds) {
746
797
  if (fromId !== nextConfigured) {
747
- migrateCourseStartedMark(defaultStorage, fromId, nextConfigured, cid);
798
+ (0, import_core5.migrateCourseStartedMark)(defaultStorage, fromId, nextConfigured, cid);
748
799
  }
749
800
  }
750
801
  sessionIdRef.current = nextConfigured;
751
802
  } else if (prevConfigured) {
752
- const nextAuto = resolveSessionId(defaultStorage, void 0);
753
- migrateCourseStartedMark(defaultStorage, prevConfigured, nextAuto, cid);
803
+ const nextAuto = (0, import_core5.resolveSessionId)(defaultStorage, void 0);
804
+ (0, import_core5.migrateCourseStartedMark)(defaultStorage, prevConfigured, nextAuto, cid);
754
805
  sessionIdRef.current = nextAuto;
755
806
  }
756
- }, [sessionConfiguredId, config.courseId]);
807
+ }, [sessionConfiguredId, normalizedCourseId]);
757
808
  const runtime = (0, import_react.useMemo)(
758
809
  () => ({
759
- config,
810
+ config: normalizedConfig,
760
811
  tracking,
761
812
  xapi,
762
813
  session: { sessionId: sessionIdRef.current, attemptId: attemptIdRef.current, user: userRef.current },
@@ -768,7 +819,7 @@ function LessonkitProvider(props) {
768
819
  plugins: pluginHost
769
820
  }),
770
821
  [
771
- config,
822
+ normalizedConfig,
772
823
  tracking,
773
824
  xapi,
774
825
  progress,
@@ -782,13 +833,21 @@ function LessonkitProvider(props) {
782
833
  sessionConfiguredId
783
834
  ]
784
835
  );
836
+ return runtime;
837
+ }
838
+
839
+ // src/context.tsx
840
+ var import_jsx_runtime = require("react/jsx-runtime");
841
+ var LessonkitContext = (0, import_react2.createContext)(null);
842
+ function LessonkitProvider(props) {
843
+ const runtime = useLessonkitProviderRuntime(props.config);
785
844
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(LessonkitContext.Provider, { value: runtime, children: props.children });
786
845
  }
787
846
 
788
847
  // src/hooks.ts
789
- var import_react2 = require("react");
848
+ var import_react3 = require("react");
790
849
  function useLessonkit() {
791
- const ctx = (0, import_react2.useContext)(LessonkitContext);
850
+ const ctx = (0, import_react3.useContext)(LessonkitContext);
792
851
  if (!ctx) throw new Error("LessonKit: missing LessonkitProvider");
793
852
  return ctx;
794
853
  }
@@ -798,52 +857,80 @@ function useProgress() {
798
857
  }
799
858
  function useTracking() {
800
859
  const { track } = useLessonkit();
801
- return (0, import_react2.useMemo)(() => ({ track }), [track]);
860
+ return (0, import_react3.useMemo)(() => ({ track }), [track]);
802
861
  }
803
862
  function useCompletion() {
804
863
  const { completeLesson, completeCourse } = useLessonkit();
805
- return (0, import_react2.useMemo)(() => ({ completeLesson, completeCourse }), [completeLesson, completeCourse]);
864
+ return (0, import_react3.useMemo)(() => ({ completeLesson, completeCourse }), [completeLesson, completeCourse]);
806
865
  }
807
- function useQuizState() {
866
+ function useQuizState(enclosingLessonId) {
808
867
  const { track } = useLessonkit();
809
- return (0, import_react2.useMemo)(
868
+ const trackOpts = enclosingLessonId ? { lessonId: enclosingLessonId } : void 0;
869
+ return (0, import_react3.useMemo)(
810
870
  () => ({
811
871
  answer: (opts) => {
812
- track("quiz_answered", opts);
872
+ track("quiz_answered", opts, trackOpts);
813
873
  },
814
874
  complete: (opts) => {
815
- track("quiz_completed", opts);
875
+ track("quiz_completed", opts, trackOpts);
816
876
  }
817
877
  }),
818
- [track]
878
+ [track, enclosingLessonId]
819
879
  );
820
880
  }
821
881
 
882
+ // src/lessonContext.tsx
883
+ var import_react4 = require("react");
884
+ var LessonContext = (0, import_react4.createContext)(void 0);
885
+ function useEnclosingLessonId() {
886
+ return (0, import_react4.useContext)(LessonContext);
887
+ }
888
+
822
889
  // src/runtime/validateComponentId.ts
823
- var import_core6 = require("@lessonkit/core");
824
- var warnedPaths = /* @__PURE__ */ new Set();
825
- function isDevEnvironment2() {
890
+ var import_core9 = require("@lessonkit/core");
891
+ function isDevEnvironment3() {
826
892
  const g = globalThis;
827
893
  return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
828
894
  }
829
- function warnInvalidComponentId(id, path) {
830
- if (!isDevEnvironment2()) return;
831
- const key = `${path}:${String(id)}`;
832
- if (warnedPaths.has(key)) return;
833
- const result = (0, import_core6.validateId)(id, path);
834
- if (result.ok) return;
835
- warnedPaths.add(key);
836
- const detail = result.issues.map((i) => `${i.path}: ${i.message}`).join("; ");
837
- console.warn(`[lessonkit] invalid ${path} \u2014 ${detail}`);
895
+ function normalizeComponentId(id, path) {
896
+ return (0, import_core9.assertValidId)(id, path);
897
+ }
898
+
899
+ // src/runtime/lessonMountRegistry.ts
900
+ var mountCounts = /* @__PURE__ */ new Map();
901
+ var warnedConcurrentLessons = false;
902
+ function registerLessonMount(lessonId) {
903
+ if (isDevEnvironment3() && mountCounts.size > 0 && !mountCounts.has(lessonId) && !warnedConcurrentLessons) {
904
+ warnedConcurrentLessons = true;
905
+ console.warn(
906
+ "[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."
907
+ );
908
+ }
909
+ mountCounts.set(lessonId, (mountCounts.get(lessonId) ?? 0) + 1);
910
+ return () => {
911
+ const next = (mountCounts.get(lessonId) ?? 1) - 1;
912
+ if (next <= 0) {
913
+ mountCounts.delete(lessonId);
914
+ } else {
915
+ mountCounts.set(lessonId, next);
916
+ }
917
+ };
918
+ }
919
+ function getLessonMountCount(lessonId) {
920
+ return mountCounts.get(lessonId) ?? 0;
838
921
  }
839
922
 
840
923
  // src/components.tsx
841
924
  var import_jsx_runtime2 = require("react/jsx-runtime");
925
+ var warnedQuizOutsideLesson = false;
926
+ function resetQuizWarningsForTests() {
927
+ warnedQuizOutsideLesson = false;
928
+ }
842
929
  function Course(props) {
843
- warnInvalidComponentId(props.courseId, "courseId");
844
- const providerConfig = (0, import_react3.useMemo)(
845
- () => ({ ...props.config, courseId: props.courseId }),
846
- [props.config, props.courseId]
930
+ const courseId = (0, import_react5.useMemo)(() => normalizeComponentId(props.courseId, "courseId"), [props.courseId]);
931
+ const providerConfig = (0, import_react5.useMemo)(
932
+ () => ({ ...props.config, courseId }),
933
+ [props.config, courseId]
847
934
  );
848
935
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(LessonkitProvider, { config: providerConfig, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": props.title, children: [
849
936
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("h1", { children: props.title }),
@@ -851,41 +938,64 @@ function Course(props) {
851
938
  ] }) });
852
939
  }
853
940
  function Lesson(props) {
854
- warnInvalidComponentId(props.lessonId, "lessonId");
941
+ const lessonId = (0, import_react5.useMemo)(() => normalizeComponentId(props.lessonId, "lessonId"), [props.lessonId]);
942
+ const autoComplete = props.autoCompleteOnUnmount !== false;
855
943
  const { setActiveLesson, config } = useLessonkit();
856
944
  const { completeLesson } = useCompletion();
857
- const id = props.lessonId;
858
- const lessonMountGenerationRef = (0, import_react3.useRef)(0);
859
- (0, import_react3.useEffect)(() => {
945
+ const lessonMountGenerationRef = (0, import_react5.useRef)(0);
946
+ (0, import_react5.useEffect)(() => {
947
+ const unregister = registerLessonMount(lessonId);
860
948
  const generation = ++lessonMountGenerationRef.current;
861
- setActiveLesson(id);
949
+ setActiveLesson(lessonId);
862
950
  return () => {
863
- const lessonId = id;
951
+ unregister();
952
+ if (getLessonMountCount(lessonId) > 0) {
953
+ return;
954
+ }
955
+ if (!autoComplete) return;
864
956
  queueMicrotask(() => {
865
957
  if (lessonMountGenerationRef.current !== generation) return;
866
958
  completeLesson(lessonId);
867
959
  });
868
960
  };
869
- }, [id, config.courseId, setActiveLesson, completeLesson]);
870
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("article", { "aria-label": props.title, children: [
961
+ }, [lessonId, config.courseId, setActiveLesson, completeLesson, autoComplete]);
962
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(LessonContext.Provider, { value: lessonId, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("article", { "aria-label": props.title, children: [
871
963
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("h2", { children: props.title }),
872
964
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { children: props.children })
873
- ] });
965
+ ] }) });
874
966
  }
875
967
  function Scenario(props) {
876
- if (props.blockId !== void 0) warnInvalidComponentId(props.blockId, "blockId");
877
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("section", { "aria-label": "Scenario", "data-lk-block-id": props.blockId, children: props.children });
968
+ const blockId = (0, import_react5.useMemo)(
969
+ () => props.blockId !== void 0 ? normalizeComponentId(props.blockId, "blockId") : void 0,
970
+ [props.blockId]
971
+ );
972
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("section", { "aria-label": "Scenario", "data-lk-block-id": blockId, children: props.children });
878
973
  }
879
974
  function Reflection(props) {
880
- if (props.blockId !== void 0) warnInvalidComponentId(props.blockId, "blockId");
881
- const promptId = (0, import_react3.useId)();
882
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": "Reflection", "data-lk-block-id": props.blockId, children: [
975
+ const blockId = (0, import_react5.useMemo)(
976
+ () => props.blockId !== void 0 ? normalizeComponentId(props.blockId, "blockId") : void 0,
977
+ [props.blockId]
978
+ );
979
+ const promptId = (0, import_react5.useId)();
980
+ const hintId = (0, import_react5.useId)();
981
+ const [internalValue, setInternalValue] = (0, import_react5.useState)("");
982
+ const isControlled = props.value !== void 0;
983
+ const value = isControlled ? props.value : internalValue;
984
+ const handleChange = (event) => {
985
+ if (!isControlled) setInternalValue(event.target.value);
986
+ props.onChange?.(event.target.value);
987
+ };
988
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": "Reflection", "data-lk-block-id": blockId, children: [
883
989
  props.prompt ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { id: promptId, children: props.prompt }) : null,
990
+ props.hint ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { id: hintId, style: import_accessibility.visuallyHiddenStyle, children: props.hint }) : null,
884
991
  props.children,
885
992
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
886
993
  "textarea",
887
994
  {
995
+ value,
996
+ onChange: handleChange,
888
997
  "aria-labelledby": props.prompt ? promptId : void 0,
998
+ "aria-describedby": props.hint ? hintId : void 0,
889
999
  "aria-label": props.prompt ? void 0 : "Reflection response"
890
1000
  }
891
1001
  )
@@ -898,23 +1008,41 @@ function KnowledgeCheck(props) {
898
1008
  checkId: props.checkId,
899
1009
  question: props.question,
900
1010
  choices: props.choices,
901
- answer: props.answer
1011
+ answer: props.answer,
1012
+ passingScore: props.passingScore
902
1013
  }
903
1014
  );
904
1015
  }
905
1016
  function Quiz(props) {
906
- warnInvalidComponentId(props.checkId, "checkId");
907
- const quiz = useQuizState();
908
- const { plugins, config, progress, session } = useLessonkit();
909
- const [selected, setSelected] = (0, import_react3.useState)(null);
910
- const [selectionCorrect, setSelectionCorrect] = (0, import_react3.useState)(null);
911
- const completedRef = (0, import_react3.useRef)(false);
912
- const questionId = (0, import_react3.useId)();
913
- (0, import_react3.useEffect)(() => {
1017
+ const checkId = (0, import_react5.useMemo)(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
1018
+ const enclosingLessonId = useEnclosingLessonId();
1019
+ const missingLesson = enclosingLessonId === void 0;
1020
+ (0, import_react5.useEffect)(() => {
1021
+ if (!missingLesson || isDevEnvironment3()) return;
1022
+ if (!warnedQuizOutsideLesson) {
1023
+ warnedQuizOutsideLesson = true;
1024
+ console.error(
1025
+ "[lessonkit] <Quiz> must be wrapped in <Lesson>; quiz telemetry will not be emitted."
1026
+ );
1027
+ }
1028
+ }, [missingLesson]);
1029
+ if (missingLesson && isDevEnvironment3()) {
1030
+ throw new Error("[lessonkit] <Quiz> must be wrapped in <Lesson>");
1031
+ }
1032
+ const quiz = useQuizState(enclosingLessonId);
1033
+ const { plugins, config, session } = useLessonkit();
1034
+ const [selected, setSelected] = (0, import_react5.useState)(null);
1035
+ const [selectionCorrect, setSelectionCorrect] = (0, import_react5.useState)(null);
1036
+ const [quizPassed, setQuizPassed] = (0, import_react5.useState)(false);
1037
+ const completedRef = (0, import_react5.useRef)(false);
1038
+ const questionId = (0, import_react5.useId)();
1039
+ const choicesKey = props.choices.join("\0");
1040
+ (0, import_react5.useEffect)(() => {
914
1041
  completedRef.current = false;
1042
+ setQuizPassed(false);
915
1043
  setSelected(null);
916
1044
  setSelectionCorrect(null);
917
- }, [props.checkId, props.answer, props.question]);
1045
+ }, [checkId, props.answer, props.question, config.courseId, enclosingLessonId, choicesKey]);
918
1046
  const isChoiceCorrect = (choice, custom) => {
919
1047
  if (!custom) return choice === props.answer;
920
1048
  if (custom.passed !== void 0) return custom.passed;
@@ -923,7 +1051,11 @@ function Quiz(props) {
923
1051
  }
924
1052
  return choice === props.answer;
925
1053
  };
926
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": "Quiz", "data-lk-check-id": props.checkId, children: [
1054
+ if (missingLesson) {
1055
+ 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." }) });
1056
+ }
1057
+ const passed = quizPassed;
1058
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": "Quiz", "data-lk-check-id": checkId, children: [
927
1059
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { id: questionId, children: props.question }),
928
1060
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("fieldset", { "aria-labelledby": questionId, children: [
929
1061
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("legend", { style: import_accessibility.visuallyHiddenStyle, children: "Quiz choices" }),
@@ -935,17 +1067,21 @@ function Quiz(props) {
935
1067
  name: questionId,
936
1068
  value: c,
937
1069
  checked: selected === c,
1070
+ disabled: passed,
1071
+ "aria-invalid": selected === c && selectionCorrect === false ? true : void 0,
938
1072
  onChange: () => {
1073
+ if (passed) return;
939
1074
  setSelected(c);
940
1075
  const pluginCtx = buildPluginContext({
941
1076
  courseId: config.courseId,
942
1077
  sessionId: session.sessionId,
943
- attemptId: session.attemptId
1078
+ attemptId: session.attemptId,
1079
+ user: session.user
944
1080
  });
945
1081
  const custom = plugins?.scoreAssessment(
946
1082
  {
947
- checkId: props.checkId,
948
- lessonId: progress.activeLessonId,
1083
+ checkId,
1084
+ lessonId: enclosingLessonId,
949
1085
  response: c
950
1086
  },
951
1087
  pluginCtx
@@ -953,18 +1089,20 @@ function Quiz(props) {
953
1089
  const correct = isChoiceCorrect(c, custom);
954
1090
  setSelectionCorrect(correct);
955
1091
  quiz.answer({
956
- checkId: props.checkId,
1092
+ checkId,
957
1093
  question: props.question,
958
1094
  choice: c,
959
1095
  correct
960
1096
  });
961
1097
  if (correct && !completedRef.current) {
962
1098
  completedRef.current = true;
1099
+ setQuizPassed(true);
1100
+ const maxScore = custom?.maxScore ?? 1;
963
1101
  quiz.complete({
964
- checkId: props.checkId,
1102
+ checkId,
965
1103
  score: custom?.score ?? 1,
966
- maxScore: custom?.maxScore ?? 1,
967
- passingScore: 1
1104
+ maxScore,
1105
+ passingScore: props.passingScore ?? maxScore
968
1106
  });
969
1107
  }
970
1108
  }
@@ -976,20 +1114,40 @@ function Quiz(props) {
976
1114
  selected && selectionCorrect !== null ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { role: "status", "aria-live": "polite", children: selectionCorrect ? "Correct" : "Try again" }) : null
977
1115
  ] });
978
1116
  }
979
- function ProgressTracker() {
1117
+ function ProgressTracker(props) {
980
1118
  const { progress } = useLessonkit();
981
1119
  const completed = progress.completedLessonIds.size;
982
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("aside", { "aria-label": "Progress", children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("p", { children: [
1120
+ if (props.totalLessons != null) {
1121
+ const total = props.totalLessons;
1122
+ const displayed = Math.min(completed, total);
1123
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("aside", { "aria-label": "Progress", children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1124
+ "div",
1125
+ {
1126
+ role: "progressbar",
1127
+ "aria-valuemin": 0,
1128
+ "aria-valuemax": total,
1129
+ "aria-valuenow": displayed,
1130
+ "aria-label": "Lessons completed",
1131
+ children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("p", { children: [
1132
+ "Lessons completed: ",
1133
+ displayed,
1134
+ " of ",
1135
+ total
1136
+ ] })
1137
+ }
1138
+ ) });
1139
+ }
1140
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("aside", { "aria-label": "Progress", role: "status", children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("p", { children: [
983
1141
  "Lessons completed: ",
984
1142
  completed
985
1143
  ] }) });
986
1144
  }
987
1145
 
988
1146
  // src/index.tsx
989
- var import_core7 = require("@lessonkit/core");
1147
+ var import_core10 = require("@lessonkit/core");
990
1148
 
991
1149
  // src/theme/ThemeProvider.tsx
992
- var import_react4 = __toESM(require("react"), 1);
1150
+ var import_react6 = __toESM(require("react"), 1);
993
1151
  var import_themes = require("@lessonkit/themes");
994
1152
 
995
1153
  // src/theme/applyCssVariables.ts
@@ -1009,8 +1167,8 @@ function applyCssVariables(target, vars, previousKeys) {
1009
1167
 
1010
1168
  // src/theme/ThemeProvider.tsx
1011
1169
  var import_jsx_runtime3 = require("react/jsx-runtime");
1012
- var ThemeContext = (0, import_react4.createContext)(null);
1013
- var useIsoLayoutEffect2 = typeof window !== "undefined" ? import_react4.useLayoutEffect : import_react4.default.useEffect;
1170
+ var ThemeContext = (0, import_react6.createContext)(null);
1171
+ var useIsoLayoutEffect2 = typeof window !== "undefined" ? import_react6.useLayoutEffect : import_react6.default.useEffect;
1014
1172
  function getSystemMode() {
1015
1173
  if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
1016
1174
  return "light";
@@ -1028,7 +1186,7 @@ function ThemeProvider(props) {
1028
1186
  const preset = props.preset ?? "default";
1029
1187
  const mode = props.mode ?? "light";
1030
1188
  const targetKind = props.target ?? "document";
1031
- const [resolvedMode, setResolvedMode] = (0, import_react4.useState)(
1189
+ const [resolvedMode, setResolvedMode] = (0, import_react6.useState)(
1032
1190
  () => mode === "system" ? getSystemMode() : mode
1033
1191
  );
1034
1192
  useIsoLayoutEffect2(() => {
@@ -1044,20 +1202,20 @@ function ThemeProvider(props) {
1044
1202
  return () => mq.removeEventListener("change", onChange);
1045
1203
  }, [mode]);
1046
1204
  const dataTheme = mode === "system" ? resolvedMode : mode === "dark" ? "dark" : "light";
1047
- const effectiveTheme = (0, import_react4.useMemo)(() => {
1205
+ const effectiveTheme = (0, import_react6.useMemo)(() => {
1048
1206
  const modeBase = resolveModeBase(mode, dataTheme);
1049
1207
  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));
1050
1208
  return (0, import_themes.mergeThemes)(base, props.theme ?? {});
1051
1209
  }, [preset, mode, dataTheme, props.theme]);
1052
- const hostRef = (0, import_react4.useRef)(null);
1053
- const appliedKeysRef = (0, import_react4.useRef)(/* @__PURE__ */ new Set());
1210
+ const hostRef = (0, import_react6.useRef)(null);
1211
+ const appliedKeysRef = (0, import_react6.useRef)(/* @__PURE__ */ new Set());
1054
1212
  useIsoLayoutEffect2(() => {
1055
1213
  if (targetKind === "document" && typeof document !== "undefined") {
1056
1214
  document.documentElement.setAttribute("data-lk-theme", dataTheme);
1057
1215
  return () => document.documentElement.removeAttribute("data-lk-theme");
1058
1216
  }
1059
1217
  }, [targetKind, dataTheme]);
1060
- const inject = (0, import_react4.useCallback)(() => {
1218
+ const inject = (0, import_react6.useCallback)(() => {
1061
1219
  const vars = (0, import_themes.themeToCssVariables)(effectiveTheme);
1062
1220
  const el = targetKind === "document" && typeof document !== "undefined" ? document.documentElement : hostRef.current;
1063
1221
  if (!el) return;
@@ -1074,7 +1232,7 @@ function ThemeProvider(props) {
1074
1232
  appliedKeysRef.current = /* @__PURE__ */ new Set();
1075
1233
  };
1076
1234
  }, [inject, targetKind]);
1077
- const value = (0, import_react4.useMemo)(
1235
+ const value = (0, import_react6.useMemo)(
1078
1236
  () => ({
1079
1237
  theme: effectiveTheme,
1080
1238
  preset,
@@ -1089,7 +1247,7 @@ function ThemeProvider(props) {
1089
1247
  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 }) });
1090
1248
  }
1091
1249
  function useTheme() {
1092
- const ctx = (0, import_react4.useContext)(ThemeContext);
1250
+ const ctx = (0, import_react6.useContext)(ThemeContext);
1093
1251
  if (!ctx) {
1094
1252
  throw new Error("useTheme must be used within a ThemeProvider");
1095
1253
  }
@@ -1136,6 +1294,12 @@ var BLOCK_CATALOG = [
1136
1294
  props: [
1137
1295
  { name: "title", type: "string", required: true, description: "Lesson title shown in the h2." },
1138
1296
  { name: "lessonId", type: "LessonId", required: true, description: "Stable lesson identifier for telemetry and packaging." },
1297
+ {
1298
+ name: "autoCompleteOnUnmount",
1299
+ type: "boolean",
1300
+ required: false,
1301
+ description: "When false, unmount does not emit lesson_completed (default true)."
1302
+ },
1139
1303
  { name: "children", type: "ReactNode", required: true, description: "Scenario, Quiz, Reflection, and other blocks." }
1140
1304
  ],
1141
1305
  requiredIds: ["lessonId"],
@@ -1188,6 +1352,9 @@ var BLOCK_CATALOG = [
1188
1352
  props: [
1189
1353
  { name: "blockId", type: "BlockId", required: false, description: "Optional stable block id for interaction telemetry URNs." },
1190
1354
  { name: "prompt", type: "string", required: false, description: "Reflection question or instruction." },
1355
+ { name: "hint", type: "string", required: false, description: "Optional hint linked via aria-describedby." },
1356
+ { name: "value", type: "string", required: false, description: "Controlled textarea value." },
1357
+ { name: "onChange", type: "(value: string) => void", required: false, description: "Called when the learner edits the textarea." },
1191
1358
  { name: "children", type: "ReactNode", required: false, description: "Optional content above the textarea." }
1192
1359
  ],
1193
1360
  requiredIds: [],
@@ -1206,6 +1373,7 @@ var BLOCK_CATALOG = [
1206
1373
  },
1207
1374
  telemetry: {
1208
1375
  emits: [],
1376
+ requiresActiveLesson: true,
1209
1377
  manualTracking: "useTracking().track('interaction', { kind, blockId, payload }) on submit or blur"
1210
1378
  }
1211
1379
  },
@@ -1243,7 +1411,14 @@ var BLOCK_CATALOG = [
1243
1411
  type: "ProgressTracker",
1244
1412
  category: "chrome",
1245
1413
  description: "Displays count of completed lessons from runtime progress state.",
1246
- props: [],
1414
+ props: [
1415
+ {
1416
+ name: "totalLessons",
1417
+ type: "number",
1418
+ required: false,
1419
+ description: "When set, renders role=progressbar with aria-valuenow/max."
1420
+ }
1421
+ ],
1247
1422
  requiredIds: [],
1248
1423
  parentConstraints: ["Course"],
1249
1424
  a11y: {
@@ -1296,9 +1471,15 @@ function getBlockCatalogEntry(type) {
1296
1471
  ThemeProvider,
1297
1472
  blockCatalogVersion,
1298
1473
  buildBlockCatalog,
1299
- createPluginHost,
1300
- defineLessonkitPlugin,
1474
+ buildTelemetryEvent,
1475
+ createLessonkitRuntime,
1476
+ createPluginRegistry,
1477
+ createTelemetryPipeline,
1478
+ defineAssessmentPlugin,
1479
+ defineLifecyclePlugin,
1480
+ defineTelemetryPlugin,
1301
1481
  getBlockCatalogEntry,
1482
+ resetQuizWarningsForTests,
1302
1483
  useCompletion,
1303
1484
  useLessonkit,
1304
1485
  useProgress,