@lessonkit/react 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # `@lessonkit/react`
2
2
 
3
+ [![CI](https://github.com/eddiethedean/lessonkit/actions/workflows/checks.yml/badge.svg)](https://github.com/eddiethedean/lessonkit/actions/workflows/checks.yml)
4
+ [![npm](https://img.shields.io/npm/v/@lessonkit/react.svg)](https://www.npmjs.com/package/@lessonkit/react)
5
+ [![License](https://img.shields.io/github/license/eddiethedean/lessonkit)](../../LICENSE)
6
+
3
7
  React components and hooks for building learning experiences in LessonKit.
4
8
 
5
9
  ## Install
@@ -11,11 +15,26 @@ npm install @lessonkit/react react react-dom
11
15
  ## Quick example
12
16
 
13
17
  ```tsx
18
+ import { useMemo } from "react";
19
+ import type { TelemetryEvent } from "@lessonkit/core";
14
20
  import { Course, Lesson, Quiz, Scenario, ProgressTracker } from "@lessonkit/react";
21
+ import type { XAPIStatement } from "@lessonkit/xapi";
15
22
 
16
23
  export default function App() {
24
+ const config = useMemo(
25
+ () => ({
26
+ tracking: {
27
+ sink: (event: TelemetryEvent) => console.log(event),
28
+ },
29
+ xapi: {
30
+ transport: (statement: XAPIStatement) => console.log(statement),
31
+ },
32
+ }),
33
+ [],
34
+ );
35
+
17
36
  return (
18
- <Course title="Cybersecurity Basics" courseId="cyber-basics">
37
+ <Course title="Cybersecurity Basics" courseId="cyber-basics" config={config}>
19
38
  <ProgressTracker />
20
39
 
21
40
  <Lesson title="Phishing Awareness" lessonId="phishing-101">
@@ -34,7 +53,7 @@ export default function App() {
34
53
  }
35
54
  ```
36
55
 
37
- ## API (0.1.x)
56
+ ## API (0.3.0)
38
57
 
39
58
  ### Components
40
59
 
@@ -57,4 +76,12 @@ export default function App() {
57
76
 
58
77
  - `@lessonkit/react` ships **framework primitives**, not content. You bring your own layout/content
59
78
  and compose interactions as React components.
79
+ - `Course` accepts a `config` prop that is passed through to `LessonkitProvider` (tracking sink,
80
+ optional `xapi.transport` or custom `xapi.client`, session metadata). Hoist `config` with `useMemo`
81
+ so tracking/xAPI clients are not recreated every render.
82
+ - When a `<Lesson>` unmounts (for example, wizard navigation), it automatically calls `completeLesson`
83
+ for that lesson. Use stable `lessonId` values so completion and time-on-task telemetry stay consistent.
84
+ - If you omit `session.sessionId`, the provider reuses a tab-scoped id via `sessionStorage` so React
85
+ Strict Mode remounts do not split analytics sessions in development.
86
+ - Accessibility guidance lives in [`docs/ACCESSIBILITY.md`](../../docs/ACCESSIBILITY.md).
60
87
 
package/dist/index.cjs CHANGED
@@ -38,6 +38,7 @@ module.exports = __toCommonJS(index_exports);
38
38
 
39
39
  // src/components.tsx
40
40
  var import_react3 = require("react");
41
+ var import_accessibility = require("@lessonkit/accessibility");
41
42
 
42
43
  // src/context.tsx
43
44
  var import_react = require("react");
@@ -45,78 +46,226 @@ var import_core = require("@lessonkit/core");
45
46
  var import_xapi = require("@lessonkit/xapi");
46
47
  var import_jsx_runtime = require("react/jsx-runtime");
47
48
  var LessonkitContext = (0, import_react.createContext)(null);
49
+ var SESSION_STORAGE_KEY = "lessonkit:sessionId";
50
+ var COURSE_STARTED_PREFIX = "lessonkit:course_started:";
51
+ function disposeTrackingClient(client) {
52
+ client?.flush?.();
53
+ client?.dispose?.();
54
+ }
55
+ function safeSessionStorageGetItem(key) {
56
+ if (typeof sessionStorage === "undefined") return null;
57
+ try {
58
+ return sessionStorage.getItem(key);
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+ function safeSessionStorageSetItem(key, value) {
64
+ if (typeof sessionStorage === "undefined") return;
65
+ try {
66
+ sessionStorage.setItem(key, value);
67
+ } catch {
68
+ }
69
+ }
70
+ function resolveSessionId(provided) {
71
+ if (provided) return provided;
72
+ const existing = safeSessionStorageGetItem(SESSION_STORAGE_KEY);
73
+ if (existing) return existing;
74
+ const id = (0, import_core.createSessionId)();
75
+ safeSessionStorageSetItem(SESSION_STORAGE_KEY, id);
76
+ return id;
77
+ }
78
+ function courseStartedStorageKey(sessionId, courseId) {
79
+ return `${COURSE_STARTED_PREFIX}${sessionId}:${courseId ?? ""}`;
80
+ }
81
+ function hasCourseStarted(sessionId, courseId) {
82
+ if (!courseId) return false;
83
+ return safeSessionStorageGetItem(courseStartedStorageKey(sessionId, courseId)) === "1";
84
+ }
85
+ function markCourseStarted(sessionId, courseId) {
86
+ if (!courseId) return;
87
+ safeSessionStorageSetItem(courseStartedStorageKey(sessionId, courseId), "1");
88
+ }
89
+ function createTrackingClientFromConfig(config) {
90
+ if (config.tracking?.enabled === false) {
91
+ return (0, import_core.createTrackingClient)();
92
+ }
93
+ return (0, import_core.createTrackingClient)({
94
+ sink: config.tracking?.sink,
95
+ batchSink: config.tracking?.batchSink,
96
+ batch: config.tracking?.batch
97
+ });
98
+ }
99
+ function createXapiClientFromConfig(config, queue) {
100
+ if (config.xapi?.enabled === false) return null;
101
+ if (config.xapi?.client) return config.xapi.client;
102
+ const baseId = config.courseId ? `urn:lessonkit:course:${config.courseId}` : void 0;
103
+ return (0, import_xapi.createXAPIClient)({ baseId, transport: config.xapi?.transport, queue });
104
+ }
48
105
  function LessonkitProvider(props) {
49
106
  const config = props.config ?? {};
50
- const tracking = (0, import_react.useMemo)(() => {
51
- if (config.tracking?.enabled === false) return (0, import_core.createTrackingClient)();
52
- return (0, import_core.createTrackingClient)({ sink: config.tracking?.sink });
53
- }, [config.tracking?.enabled, config.tracking?.sink]);
54
- const xapi = (0, import_react.useMemo)(() => {
55
- if (config.xapi?.enabled === false) return null;
56
- const baseId = config.courseId ? `urn:lessonkit:course:${config.courseId}` : void 0;
57
- return config.xapi?.client ?? (0, import_xapi.createXAPIClient)({ baseId });
58
- }, [config.xapi?.enabled, config.xapi?.client, config.courseId]);
107
+ const sessionIdRef = (0, import_react.useRef)(resolveSessionId(config.session?.sessionId));
108
+ if (config.session?.sessionId) sessionIdRef.current = config.session.sessionId;
109
+ const attemptIdRef = (0, import_react.useRef)(config.session?.attemptId);
110
+ const userRef = (0, import_react.useRef)(config.session?.user);
111
+ attemptIdRef.current = config.session?.attemptId;
112
+ userRef.current = config.session?.user;
113
+ const courseIdRef = (0, import_react.useRef)(config.courseId);
114
+ courseIdRef.current = config.courseId;
115
+ const trackingRef = (0, import_react.useRef)((0, import_core.createTrackingClient)());
116
+ const [tracking, setTracking] = (0, import_react.useState)(() => trackingRef.current);
117
+ const courseStartedInProviderRef = (0, import_react.useRef)(false);
118
+ const trackingEnabled = config.tracking?.enabled;
119
+ const trackingSink = config.tracking?.sink;
120
+ const trackingBatchSink = config.tracking?.batchSink;
121
+ const batchEnabled = config.tracking?.batch?.enabled;
122
+ const batchFlushIntervalMs = config.tracking?.batch?.flushIntervalMs;
123
+ const batchMaxBatchSize = config.tracking?.batch?.maxBatchSize;
124
+ (0, import_react.useLayoutEffect)(() => {
125
+ const prev = trackingRef.current;
126
+ const next = createTrackingClientFromConfig(config);
127
+ trackingRef.current = next;
128
+ setTracking(next);
129
+ const sessionId = sessionIdRef.current;
130
+ const cid = courseIdRef.current;
131
+ const shouldEmitCourseStarted = cid ? !hasCourseStarted(sessionId, cid) : !courseStartedInProviderRef.current;
132
+ if (shouldEmitCourseStarted) {
133
+ if (cid) {
134
+ markCourseStarted(sessionId, cid);
135
+ } else {
136
+ courseStartedInProviderRef.current = true;
137
+ }
138
+ next.track({
139
+ name: "course_started",
140
+ timestamp: (0, import_core.nowIso)(),
141
+ courseId: cid,
142
+ sessionId,
143
+ attemptId: attemptIdRef.current,
144
+ user: userRef.current
145
+ });
146
+ }
147
+ return () => {
148
+ disposeTrackingClient(prev);
149
+ };
150
+ }, [
151
+ trackingEnabled,
152
+ trackingSink,
153
+ trackingBatchSink,
154
+ batchEnabled,
155
+ batchFlushIntervalMs,
156
+ batchMaxBatchSize
157
+ ]);
158
+ const xapiQueueRef = (0, import_react.useRef)((0, import_xapi.createInMemoryXAPIQueue)());
159
+ const xapiRef = (0, import_react.useRef)(null);
160
+ const [xapi, setXapi] = (0, import_react.useState)(null);
161
+ const xapiEnabled = config.xapi?.enabled;
162
+ const xapiClient = config.xapi?.client;
163
+ const xapiTransport = config.xapi?.transport;
164
+ const courseId = config.courseId;
165
+ (0, import_react.useLayoutEffect)(() => {
166
+ const prev = xapiRef.current;
167
+ const next = createXapiClientFromConfig(config, xapiQueueRef.current);
168
+ xapiRef.current = next;
169
+ setXapi(next);
170
+ void (async () => {
171
+ if (prev) {
172
+ try {
173
+ await prev.flush();
174
+ } catch {
175
+ }
176
+ }
177
+ try {
178
+ await next?.flush();
179
+ } catch {
180
+ }
181
+ })();
182
+ return () => {
183
+ void prev?.flush();
184
+ };
185
+ }, [xapiEnabled, xapiClient, xapiTransport, courseId]);
59
186
  const [completedLessonIds, setCompletedLessonIds] = (0, import_react.useState)(() => /* @__PURE__ */ new Set());
187
+ const completedLessonIdsRef = (0, import_react.useRef)(completedLessonIds);
188
+ completedLessonIdsRef.current = completedLessonIds;
60
189
  const [activeLessonId, setActiveLessonId] = (0, import_react.useState)(void 0);
61
190
  const [courseCompleted, setCourseCompleted] = (0, import_react.useState)(false);
62
- const courseIdRef = (0, import_react.useRef)(config.courseId);
63
- courseIdRef.current = config.courseId;
191
+ const courseCompletedRef = (0, import_react.useRef)(false);
192
+ courseCompletedRef.current = courseCompleted;
193
+ const activeLessonIdRef = (0, import_react.useRef)(void 0);
194
+ activeLessonIdRef.current = activeLessonId;
195
+ const lessonStartTimesRef = (0, import_react.useRef)(/* @__PURE__ */ new Map());
64
196
  const track = (0, import_react.useCallback)(
65
197
  (name, data, opts) => {
66
- tracking.track({
198
+ trackingRef.current?.track({
67
199
  name,
68
200
  timestamp: (0, import_core.nowIso)(),
69
201
  courseId: courseIdRef.current,
70
- lessonId: opts?.lessonId ?? activeLessonId,
202
+ lessonId: opts?.lessonId ?? activeLessonIdRef.current,
203
+ sessionId: sessionIdRef.current,
204
+ attemptId: attemptIdRef.current,
205
+ user: userRef.current,
71
206
  data
72
207
  });
73
208
  },
74
- [tracking, activeLessonId]
75
- );
76
- const setActiveLesson = (0, import_react.useCallback)(
77
- (lessonId) => {
78
- setActiveLessonId(lessonId);
79
- track("lesson_started", { lessonId }, { lessonId });
80
- xapi?.startedLesson({ lessonId });
81
- },
82
- [track, xapi]
209
+ []
83
210
  );
211
+ (0, import_react.useEffect)(() => {
212
+ return () => {
213
+ trackingRef.current?.flush?.();
214
+ void xapiRef.current?.flush();
215
+ };
216
+ }, []);
217
+ const setActiveLesson = (0, import_react.useCallback)((lessonId) => {
218
+ if (activeLessonIdRef.current === lessonId) return;
219
+ activeLessonIdRef.current = lessonId;
220
+ setActiveLessonId(lessonId);
221
+ lessonStartTimesRef.current.set(lessonId, Date.now());
222
+ track("lesson_started", { lessonId }, { lessonId });
223
+ xapiRef.current?.startedLesson({ lessonId });
224
+ }, [track]);
84
225
  const completeLesson = (0, import_react.useCallback)(
85
226
  (lessonId) => {
86
- setCompletedLessonIds((prev) => new Set(prev).add(lessonId));
87
- track("lesson_completed", { lessonId }, { lessonId });
88
- xapi?.completeLesson({ lessonId });
227
+ if (completedLessonIdsRef.current.has(lessonId)) return;
228
+ completedLessonIdsRef.current = new Set(completedLessonIdsRef.current).add(lessonId);
229
+ setCompletedLessonIds(completedLessonIdsRef.current);
230
+ const startedAt = lessonStartTimesRef.current.get(lessonId);
231
+ lessonStartTimesRef.current.delete(lessonId);
232
+ const durationMs = typeof startedAt === "number" ? Math.max(0, Date.now() - startedAt) : void 0;
233
+ track("lesson_completed", { lessonId, durationMs }, { lessonId });
234
+ if (durationMs !== void 0) {
235
+ track("lesson_time_on_task", { lessonId, durationMs }, { lessonId });
236
+ }
237
+ xapiRef.current?.completeLesson({ lessonId, durationMs });
89
238
  },
90
- [track, xapi]
239
+ [track]
91
240
  );
92
241
  const completeCourse = (0, import_react.useCallback)(() => {
242
+ if (courseCompletedRef.current) return;
243
+ courseCompletedRef.current = true;
93
244
  setCourseCompleted(true);
94
245
  track("course_completed");
95
- xapi?.completeCourse({});
96
- }, [track, xapi]);
246
+ xapiRef.current?.completeCourse();
247
+ }, [track]);
248
+ const progress = (0, import_react.useMemo)(
249
+ () => ({
250
+ activeLessonId,
251
+ completedLessonIds: new Set(completedLessonIds),
252
+ courseCompleted
253
+ }),
254
+ [activeLessonId, completedLessonIds, courseCompleted]
255
+ );
97
256
  const runtime = (0, import_react.useMemo)(
98
257
  () => ({
99
258
  config,
100
259
  tracking,
101
260
  xapi,
102
- progress: { activeLessonId, completedLessonIds, courseCompleted },
261
+ session: { sessionId: sessionIdRef.current, attemptId: attemptIdRef.current, user: userRef.current },
262
+ progress,
103
263
  setActiveLesson,
104
264
  completeLesson,
105
265
  completeCourse,
106
266
  track
107
267
  }),
108
- [
109
- config,
110
- tracking,
111
- xapi,
112
- activeLessonId,
113
- completedLessonIds,
114
- courseCompleted,
115
- setActiveLesson,
116
- completeLesson,
117
- completeCourse,
118
- track
119
- ]
268
+ [config, tracking, xapi, progress, setActiveLesson, completeLesson, completeCourse, track]
120
269
  );
121
270
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(LessonkitContext.Provider, { value: runtime, children: props.children });
122
271
  }
@@ -158,7 +307,11 @@ function useQuizState() {
158
307
  // src/components.tsx
159
308
  var import_jsx_runtime2 = require("react/jsx-runtime");
160
309
  function Course(props) {
161
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(LessonkitProvider, { config: { courseId: props.courseId }, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": props.title, children: [
310
+ const providerConfig = (0, import_react3.useMemo)(
311
+ () => ({ ...props.config, courseId: props.courseId }),
312
+ [props.config, props.courseId]
313
+ );
314
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(LessonkitProvider, { config: providerConfig, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": props.title, children: [
162
315
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("h1", { children: props.title }),
163
316
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { children: props.children })
164
317
  ] }) });
@@ -166,7 +319,9 @@ function Course(props) {
166
319
  function Lesson(props) {
167
320
  const { setActiveLesson } = useLessonkit();
168
321
  const { completeLesson } = useCompletion();
169
- const id = props.lessonId ?? (0, import_react3.useMemo)(() => `lesson-${cryptoRandomId()}`, []);
322
+ const reactId = (0, import_react3.useId)();
323
+ const generatedId = (0, import_react3.useMemo)(() => `lesson-${sanitizeLessonId(reactId)}`, [reactId]);
324
+ const id = props.lessonId ?? generatedId;
170
325
  (0, import_react3.useEffect)(() => {
171
326
  setActiveLesson(id);
172
327
  return () => {
@@ -186,7 +341,13 @@ function Reflection(props) {
186
341
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": "Reflection", children: [
187
342
  props.prompt ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { id: promptId, children: props.prompt }) : null,
188
343
  props.children,
189
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("textarea", { "aria-labelledby": props.prompt ? promptId : void 0 })
344
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
345
+ "textarea",
346
+ {
347
+ "aria-labelledby": props.prompt ? promptId : void 0,
348
+ "aria-label": props.prompt ? void 0 : "Reflection response"
349
+ }
350
+ )
190
351
  ] });
191
352
  }
192
353
  function KnowledgeCheck(props) {
@@ -195,12 +356,13 @@ function KnowledgeCheck(props) {
195
356
  function Quiz(props) {
196
357
  const quiz = useQuizState();
197
358
  const [selected, setSelected] = (0, import_react3.useState)(null);
359
+ const completedRef = (0, import_react3.useRef)(false);
198
360
  const questionId = (0, import_react3.useId)();
199
361
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": "Quiz", children: [
200
362
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { id: questionId, children: props.question }),
201
363
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("fieldset", { "aria-labelledby": questionId, children: [
202
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("legend", { className: "sr-only", children: "Quiz choices" }),
203
- props.choices.map((c) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("label", { style: { display: "block" }, children: [
364
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("legend", { style: import_accessibility.visuallyHiddenStyle, children: "Quiz choices" }),
365
+ props.choices.map((c, i) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("label", { style: { display: "block" }, children: [
204
366
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
205
367
  "input",
206
368
  {
@@ -210,12 +372,17 @@ function Quiz(props) {
210
372
  checked: selected === c,
211
373
  onChange: () => {
212
374
  setSelected(c);
213
- quiz.answer({ question: props.question, choice: c, correct: c === props.answer });
375
+ const correct = c === props.answer;
376
+ quiz.answer({ question: props.question, choice: c, correct });
377
+ if (correct && !completedRef.current) {
378
+ completedRef.current = true;
379
+ quiz.complete({ score: 1, maxScore: 1 });
380
+ }
214
381
  }
215
382
  }
216
383
  ),
217
384
  c
218
- ] }, c))
385
+ ] }, `${questionId}-${i}`))
219
386
  ] }),
220
387
  selected ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { role: "status", "aria-live": "polite", children: selected === props.answer ? "Correct" : "Try again" }) : null
221
388
  ] });
@@ -228,10 +395,9 @@ function ProgressTracker() {
228
395
  completed
229
396
  ] }) });
230
397
  }
231
- function cryptoRandomId() {
232
- const g = globalThis;
233
- if (g.crypto?.randomUUID) return g.crypto.randomUUID();
234
- return Math.random().toString(16).slice(2);
398
+ function sanitizeLessonId(id) {
399
+ const s = id.replace(/[^a-zA-Z0-9_-]/g, "");
400
+ return s.length ? s : "id";
235
401
  }
236
402
  // Annotate the CommonJS export names for ESM import in node:
237
403
  0 && (module.exports = {
package/dist/index.d.cts CHANGED
@@ -1,52 +1,35 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import React from 'react';
3
3
  import * as _lessonkit_core from '@lessonkit/core';
4
- import { LessonId, CourseId, TelemetryEvent, TrackingClient } from '@lessonkit/core';
5
- import { XAPIClient } from '@lessonkit/xapi';
6
-
7
- declare function Course(props: {
8
- title: string;
9
- courseId?: string;
10
- children: React.ReactNode;
11
- }): react_jsx_runtime.JSX.Element;
12
- declare function Lesson(props: {
13
- title: string;
14
- lessonId?: LessonId;
15
- children: React.ReactNode;
16
- }): react_jsx_runtime.JSX.Element;
17
- declare function Scenario(props: {
18
- children: React.ReactNode;
19
- }): react_jsx_runtime.JSX.Element;
20
- declare function Reflection(props: {
21
- prompt?: string;
22
- children?: React.ReactNode;
23
- }): react_jsx_runtime.JSX.Element;
24
- declare function KnowledgeCheck(props: {
25
- question: string;
26
- choices: string[];
27
- answer: string;
28
- }): react_jsx_runtime.JSX.Element;
29
- declare function Quiz(props: {
30
- question: string;
31
- choices: string[];
32
- answer: string;
33
- }): react_jsx_runtime.JSX.Element;
34
- declare function ProgressTracker(): react_jsx_runtime.JSX.Element;
4
+ import { CourseId, TelemetryUser, TelemetryEvent, TrackingClient, LessonId } from '@lessonkit/core';
5
+ import { XAPITransport, XAPIClient } from '@lessonkit/xapi';
35
6
 
36
7
  type LessonkitConfig = {
37
8
  courseId?: CourseId;
9
+ session?: {
10
+ sessionId?: string;
11
+ attemptId?: string;
12
+ user?: TelemetryUser;
13
+ };
38
14
  tracking?: {
39
15
  enabled?: boolean;
40
16
  sink?: (event: TelemetryEvent) => void | Promise<void>;
17
+ batchSink?: (events: TelemetryEvent[]) => void | Promise<void>;
18
+ batch?: {
19
+ enabled?: boolean;
20
+ flushIntervalMs?: number;
21
+ maxBatchSize?: number;
22
+ };
41
23
  };
42
24
  xapi?: {
43
25
  enabled?: boolean;
26
+ transport?: XAPITransport;
44
27
  client?: XAPIClient;
45
28
  };
46
29
  };
47
30
  type ProgressState = {
48
31
  activeLessonId?: LessonId;
49
- completedLessonIds: Set<LessonId>;
32
+ completedLessonIds: ReadonlySet<LessonId>;
50
33
  courseCompleted: boolean;
51
34
  };
52
35
  type LessonkitRuntime = {
@@ -54,6 +37,11 @@ type LessonkitRuntime = {
54
37
  tracking: TrackingClient;
55
38
  xapi: XAPIClient | null;
56
39
  progress: ProgressState;
40
+ session: {
41
+ sessionId: string;
42
+ attemptId?: string;
43
+ user?: TelemetryUser;
44
+ };
57
45
  setActiveLesson: (lessonId: LessonId) => void;
58
46
  completeLesson: (lessonId: LessonId) => void;
59
47
  completeCourse: () => void;
@@ -66,6 +54,36 @@ declare function LessonkitProvider(props: {
66
54
  children: React.ReactNode;
67
55
  }): react_jsx_runtime.JSX.Element;
68
56
 
57
+ declare function Course(props: {
58
+ title: string;
59
+ courseId?: CourseId;
60
+ config?: Omit<React.ComponentProps<typeof LessonkitProvider>["config"], "courseId">;
61
+ children: React.ReactNode;
62
+ }): react_jsx_runtime.JSX.Element;
63
+ declare function Lesson(props: {
64
+ title: string;
65
+ lessonId?: LessonId;
66
+ children: React.ReactNode;
67
+ }): react_jsx_runtime.JSX.Element;
68
+ declare function Scenario(props: {
69
+ children: React.ReactNode;
70
+ }): react_jsx_runtime.JSX.Element;
71
+ declare function Reflection(props: {
72
+ prompt?: string;
73
+ children?: React.ReactNode;
74
+ }): react_jsx_runtime.JSX.Element;
75
+ declare function KnowledgeCheck(props: {
76
+ question: string;
77
+ choices: string[];
78
+ answer: string;
79
+ }): react_jsx_runtime.JSX.Element;
80
+ declare function Quiz(props: {
81
+ question: string;
82
+ choices: string[];
83
+ answer: string;
84
+ }): react_jsx_runtime.JSX.Element;
85
+ declare function ProgressTracker(): react_jsx_runtime.JSX.Element;
86
+
69
87
  declare function useLessonkit(): LessonkitRuntime;
70
88
  declare function useProgress(): ProgressState;
71
89
  declare function useTracking(): {
package/dist/index.d.ts CHANGED
@@ -1,52 +1,35 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import React from 'react';
3
3
  import * as _lessonkit_core from '@lessonkit/core';
4
- import { LessonId, CourseId, TelemetryEvent, TrackingClient } from '@lessonkit/core';
5
- import { XAPIClient } from '@lessonkit/xapi';
6
-
7
- declare function Course(props: {
8
- title: string;
9
- courseId?: string;
10
- children: React.ReactNode;
11
- }): react_jsx_runtime.JSX.Element;
12
- declare function Lesson(props: {
13
- title: string;
14
- lessonId?: LessonId;
15
- children: React.ReactNode;
16
- }): react_jsx_runtime.JSX.Element;
17
- declare function Scenario(props: {
18
- children: React.ReactNode;
19
- }): react_jsx_runtime.JSX.Element;
20
- declare function Reflection(props: {
21
- prompt?: string;
22
- children?: React.ReactNode;
23
- }): react_jsx_runtime.JSX.Element;
24
- declare function KnowledgeCheck(props: {
25
- question: string;
26
- choices: string[];
27
- answer: string;
28
- }): react_jsx_runtime.JSX.Element;
29
- declare function Quiz(props: {
30
- question: string;
31
- choices: string[];
32
- answer: string;
33
- }): react_jsx_runtime.JSX.Element;
34
- declare function ProgressTracker(): react_jsx_runtime.JSX.Element;
4
+ import { CourseId, TelemetryUser, TelemetryEvent, TrackingClient, LessonId } from '@lessonkit/core';
5
+ import { XAPITransport, XAPIClient } from '@lessonkit/xapi';
35
6
 
36
7
  type LessonkitConfig = {
37
8
  courseId?: CourseId;
9
+ session?: {
10
+ sessionId?: string;
11
+ attemptId?: string;
12
+ user?: TelemetryUser;
13
+ };
38
14
  tracking?: {
39
15
  enabled?: boolean;
40
16
  sink?: (event: TelemetryEvent) => void | Promise<void>;
17
+ batchSink?: (events: TelemetryEvent[]) => void | Promise<void>;
18
+ batch?: {
19
+ enabled?: boolean;
20
+ flushIntervalMs?: number;
21
+ maxBatchSize?: number;
22
+ };
41
23
  };
42
24
  xapi?: {
43
25
  enabled?: boolean;
26
+ transport?: XAPITransport;
44
27
  client?: XAPIClient;
45
28
  };
46
29
  };
47
30
  type ProgressState = {
48
31
  activeLessonId?: LessonId;
49
- completedLessonIds: Set<LessonId>;
32
+ completedLessonIds: ReadonlySet<LessonId>;
50
33
  courseCompleted: boolean;
51
34
  };
52
35
  type LessonkitRuntime = {
@@ -54,6 +37,11 @@ type LessonkitRuntime = {
54
37
  tracking: TrackingClient;
55
38
  xapi: XAPIClient | null;
56
39
  progress: ProgressState;
40
+ session: {
41
+ sessionId: string;
42
+ attemptId?: string;
43
+ user?: TelemetryUser;
44
+ };
57
45
  setActiveLesson: (lessonId: LessonId) => void;
58
46
  completeLesson: (lessonId: LessonId) => void;
59
47
  completeCourse: () => void;
@@ -66,6 +54,36 @@ declare function LessonkitProvider(props: {
66
54
  children: React.ReactNode;
67
55
  }): react_jsx_runtime.JSX.Element;
68
56
 
57
+ declare function Course(props: {
58
+ title: string;
59
+ courseId?: CourseId;
60
+ config?: Omit<React.ComponentProps<typeof LessonkitProvider>["config"], "courseId">;
61
+ children: React.ReactNode;
62
+ }): react_jsx_runtime.JSX.Element;
63
+ declare function Lesson(props: {
64
+ title: string;
65
+ lessonId?: LessonId;
66
+ children: React.ReactNode;
67
+ }): react_jsx_runtime.JSX.Element;
68
+ declare function Scenario(props: {
69
+ children: React.ReactNode;
70
+ }): react_jsx_runtime.JSX.Element;
71
+ declare function Reflection(props: {
72
+ prompt?: string;
73
+ children?: React.ReactNode;
74
+ }): react_jsx_runtime.JSX.Element;
75
+ declare function KnowledgeCheck(props: {
76
+ question: string;
77
+ choices: string[];
78
+ answer: string;
79
+ }): react_jsx_runtime.JSX.Element;
80
+ declare function Quiz(props: {
81
+ question: string;
82
+ choices: string[];
83
+ answer: string;
84
+ }): react_jsx_runtime.JSX.Element;
85
+ declare function ProgressTracker(): react_jsx_runtime.JSX.Element;
86
+
69
87
  declare function useLessonkit(): LessonkitRuntime;
70
88
  declare function useProgress(): ProgressState;
71
89
  declare function useTracking(): {
package/dist/index.js CHANGED
@@ -1,84 +1,241 @@
1
1
  // src/components.tsx
2
- import { useEffect, useId, useMemo as useMemo3, useState as useState2 } from "react";
2
+ import { useEffect as useEffect2, useId, useMemo as useMemo3, useRef as useRef2, useState as useState2 } from "react";
3
+ import { visuallyHiddenStyle } from "@lessonkit/accessibility";
3
4
 
4
5
  // src/context.tsx
5
- import { createContext, useCallback, useMemo, useRef, useState } from "react";
6
- import { createTrackingClient, nowIso } from "@lessonkit/core";
7
- import { createXAPIClient } from "@lessonkit/xapi";
6
+ import {
7
+ createContext,
8
+ useCallback,
9
+ useEffect,
10
+ useLayoutEffect,
11
+ useMemo,
12
+ useRef,
13
+ useState
14
+ } from "react";
15
+ import { createSessionId, createTrackingClient, nowIso } from "@lessonkit/core";
16
+ import { createInMemoryXAPIQueue, createXAPIClient } from "@lessonkit/xapi";
8
17
  import { jsx } from "react/jsx-runtime";
9
18
  var LessonkitContext = createContext(null);
19
+ var SESSION_STORAGE_KEY = "lessonkit:sessionId";
20
+ var COURSE_STARTED_PREFIX = "lessonkit:course_started:";
21
+ function disposeTrackingClient(client) {
22
+ client?.flush?.();
23
+ client?.dispose?.();
24
+ }
25
+ function safeSessionStorageGetItem(key) {
26
+ if (typeof sessionStorage === "undefined") return null;
27
+ try {
28
+ return sessionStorage.getItem(key);
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+ function safeSessionStorageSetItem(key, value) {
34
+ if (typeof sessionStorage === "undefined") return;
35
+ try {
36
+ sessionStorage.setItem(key, value);
37
+ } catch {
38
+ }
39
+ }
40
+ function resolveSessionId(provided) {
41
+ if (provided) return provided;
42
+ const existing = safeSessionStorageGetItem(SESSION_STORAGE_KEY);
43
+ if (existing) return existing;
44
+ const id = createSessionId();
45
+ safeSessionStorageSetItem(SESSION_STORAGE_KEY, id);
46
+ return id;
47
+ }
48
+ function courseStartedStorageKey(sessionId, courseId) {
49
+ return `${COURSE_STARTED_PREFIX}${sessionId}:${courseId ?? ""}`;
50
+ }
51
+ function hasCourseStarted(sessionId, courseId) {
52
+ if (!courseId) return false;
53
+ return safeSessionStorageGetItem(courseStartedStorageKey(sessionId, courseId)) === "1";
54
+ }
55
+ function markCourseStarted(sessionId, courseId) {
56
+ if (!courseId) return;
57
+ safeSessionStorageSetItem(courseStartedStorageKey(sessionId, courseId), "1");
58
+ }
59
+ function createTrackingClientFromConfig(config) {
60
+ if (config.tracking?.enabled === false) {
61
+ return createTrackingClient();
62
+ }
63
+ return createTrackingClient({
64
+ sink: config.tracking?.sink,
65
+ batchSink: config.tracking?.batchSink,
66
+ batch: config.tracking?.batch
67
+ });
68
+ }
69
+ function createXapiClientFromConfig(config, queue) {
70
+ if (config.xapi?.enabled === false) return null;
71
+ if (config.xapi?.client) return config.xapi.client;
72
+ const baseId = config.courseId ? `urn:lessonkit:course:${config.courseId}` : void 0;
73
+ return createXAPIClient({ baseId, transport: config.xapi?.transport, queue });
74
+ }
10
75
  function LessonkitProvider(props) {
11
76
  const config = props.config ?? {};
12
- const tracking = useMemo(() => {
13
- if (config.tracking?.enabled === false) return createTrackingClient();
14
- return createTrackingClient({ sink: config.tracking?.sink });
15
- }, [config.tracking?.enabled, config.tracking?.sink]);
16
- const xapi = useMemo(() => {
17
- if (config.xapi?.enabled === false) return null;
18
- const baseId = config.courseId ? `urn:lessonkit:course:${config.courseId}` : void 0;
19
- return config.xapi?.client ?? createXAPIClient({ baseId });
20
- }, [config.xapi?.enabled, config.xapi?.client, config.courseId]);
77
+ const sessionIdRef = useRef(resolveSessionId(config.session?.sessionId));
78
+ if (config.session?.sessionId) sessionIdRef.current = config.session.sessionId;
79
+ const attemptIdRef = useRef(config.session?.attemptId);
80
+ const userRef = useRef(config.session?.user);
81
+ attemptIdRef.current = config.session?.attemptId;
82
+ userRef.current = config.session?.user;
83
+ const courseIdRef = useRef(config.courseId);
84
+ courseIdRef.current = config.courseId;
85
+ const trackingRef = useRef(createTrackingClient());
86
+ const [tracking, setTracking] = useState(() => trackingRef.current);
87
+ const courseStartedInProviderRef = useRef(false);
88
+ const trackingEnabled = config.tracking?.enabled;
89
+ const trackingSink = config.tracking?.sink;
90
+ const trackingBatchSink = config.tracking?.batchSink;
91
+ const batchEnabled = config.tracking?.batch?.enabled;
92
+ const batchFlushIntervalMs = config.tracking?.batch?.flushIntervalMs;
93
+ const batchMaxBatchSize = config.tracking?.batch?.maxBatchSize;
94
+ useLayoutEffect(() => {
95
+ const prev = trackingRef.current;
96
+ const next = createTrackingClientFromConfig(config);
97
+ trackingRef.current = next;
98
+ setTracking(next);
99
+ const sessionId = sessionIdRef.current;
100
+ const cid = courseIdRef.current;
101
+ const shouldEmitCourseStarted = cid ? !hasCourseStarted(sessionId, cid) : !courseStartedInProviderRef.current;
102
+ if (shouldEmitCourseStarted) {
103
+ if (cid) {
104
+ markCourseStarted(sessionId, cid);
105
+ } else {
106
+ courseStartedInProviderRef.current = true;
107
+ }
108
+ next.track({
109
+ name: "course_started",
110
+ timestamp: nowIso(),
111
+ courseId: cid,
112
+ sessionId,
113
+ attemptId: attemptIdRef.current,
114
+ user: userRef.current
115
+ });
116
+ }
117
+ return () => {
118
+ disposeTrackingClient(prev);
119
+ };
120
+ }, [
121
+ trackingEnabled,
122
+ trackingSink,
123
+ trackingBatchSink,
124
+ batchEnabled,
125
+ batchFlushIntervalMs,
126
+ batchMaxBatchSize
127
+ ]);
128
+ const xapiQueueRef = useRef(createInMemoryXAPIQueue());
129
+ const xapiRef = useRef(null);
130
+ const [xapi, setXapi] = useState(null);
131
+ const xapiEnabled = config.xapi?.enabled;
132
+ const xapiClient = config.xapi?.client;
133
+ const xapiTransport = config.xapi?.transport;
134
+ const courseId = config.courseId;
135
+ useLayoutEffect(() => {
136
+ const prev = xapiRef.current;
137
+ const next = createXapiClientFromConfig(config, xapiQueueRef.current);
138
+ xapiRef.current = next;
139
+ setXapi(next);
140
+ void (async () => {
141
+ if (prev) {
142
+ try {
143
+ await prev.flush();
144
+ } catch {
145
+ }
146
+ }
147
+ try {
148
+ await next?.flush();
149
+ } catch {
150
+ }
151
+ })();
152
+ return () => {
153
+ void prev?.flush();
154
+ };
155
+ }, [xapiEnabled, xapiClient, xapiTransport, courseId]);
21
156
  const [completedLessonIds, setCompletedLessonIds] = useState(() => /* @__PURE__ */ new Set());
157
+ const completedLessonIdsRef = useRef(completedLessonIds);
158
+ completedLessonIdsRef.current = completedLessonIds;
22
159
  const [activeLessonId, setActiveLessonId] = useState(void 0);
23
160
  const [courseCompleted, setCourseCompleted] = useState(false);
24
- const courseIdRef = useRef(config.courseId);
25
- courseIdRef.current = config.courseId;
161
+ const courseCompletedRef = useRef(false);
162
+ courseCompletedRef.current = courseCompleted;
163
+ const activeLessonIdRef = useRef(void 0);
164
+ activeLessonIdRef.current = activeLessonId;
165
+ const lessonStartTimesRef = useRef(/* @__PURE__ */ new Map());
26
166
  const track = useCallback(
27
167
  (name, data, opts) => {
28
- tracking.track({
168
+ trackingRef.current?.track({
29
169
  name,
30
170
  timestamp: nowIso(),
31
171
  courseId: courseIdRef.current,
32
- lessonId: opts?.lessonId ?? activeLessonId,
172
+ lessonId: opts?.lessonId ?? activeLessonIdRef.current,
173
+ sessionId: sessionIdRef.current,
174
+ attemptId: attemptIdRef.current,
175
+ user: userRef.current,
33
176
  data
34
177
  });
35
178
  },
36
- [tracking, activeLessonId]
37
- );
38
- const setActiveLesson = useCallback(
39
- (lessonId) => {
40
- setActiveLessonId(lessonId);
41
- track("lesson_started", { lessonId }, { lessonId });
42
- xapi?.startedLesson({ lessonId });
43
- },
44
- [track, xapi]
179
+ []
45
180
  );
181
+ useEffect(() => {
182
+ return () => {
183
+ trackingRef.current?.flush?.();
184
+ void xapiRef.current?.flush();
185
+ };
186
+ }, []);
187
+ const setActiveLesson = useCallback((lessonId) => {
188
+ if (activeLessonIdRef.current === lessonId) return;
189
+ activeLessonIdRef.current = lessonId;
190
+ setActiveLessonId(lessonId);
191
+ lessonStartTimesRef.current.set(lessonId, Date.now());
192
+ track("lesson_started", { lessonId }, { lessonId });
193
+ xapiRef.current?.startedLesson({ lessonId });
194
+ }, [track]);
46
195
  const completeLesson = useCallback(
47
196
  (lessonId) => {
48
- setCompletedLessonIds((prev) => new Set(prev).add(lessonId));
49
- track("lesson_completed", { lessonId }, { lessonId });
50
- xapi?.completeLesson({ lessonId });
197
+ if (completedLessonIdsRef.current.has(lessonId)) return;
198
+ completedLessonIdsRef.current = new Set(completedLessonIdsRef.current).add(lessonId);
199
+ setCompletedLessonIds(completedLessonIdsRef.current);
200
+ const startedAt = lessonStartTimesRef.current.get(lessonId);
201
+ lessonStartTimesRef.current.delete(lessonId);
202
+ const durationMs = typeof startedAt === "number" ? Math.max(0, Date.now() - startedAt) : void 0;
203
+ track("lesson_completed", { lessonId, durationMs }, { lessonId });
204
+ if (durationMs !== void 0) {
205
+ track("lesson_time_on_task", { lessonId, durationMs }, { lessonId });
206
+ }
207
+ xapiRef.current?.completeLesson({ lessonId, durationMs });
51
208
  },
52
- [track, xapi]
209
+ [track]
53
210
  );
54
211
  const completeCourse = useCallback(() => {
212
+ if (courseCompletedRef.current) return;
213
+ courseCompletedRef.current = true;
55
214
  setCourseCompleted(true);
56
215
  track("course_completed");
57
- xapi?.completeCourse({});
58
- }, [track, xapi]);
216
+ xapiRef.current?.completeCourse();
217
+ }, [track]);
218
+ const progress = useMemo(
219
+ () => ({
220
+ activeLessonId,
221
+ completedLessonIds: new Set(completedLessonIds),
222
+ courseCompleted
223
+ }),
224
+ [activeLessonId, completedLessonIds, courseCompleted]
225
+ );
59
226
  const runtime = useMemo(
60
227
  () => ({
61
228
  config,
62
229
  tracking,
63
230
  xapi,
64
- progress: { activeLessonId, completedLessonIds, courseCompleted },
231
+ session: { sessionId: sessionIdRef.current, attemptId: attemptIdRef.current, user: userRef.current },
232
+ progress,
65
233
  setActiveLesson,
66
234
  completeLesson,
67
235
  completeCourse,
68
236
  track
69
237
  }),
70
- [
71
- config,
72
- tracking,
73
- xapi,
74
- activeLessonId,
75
- completedLessonIds,
76
- courseCompleted,
77
- setActiveLesson,
78
- completeLesson,
79
- completeCourse,
80
- track
81
- ]
238
+ [config, tracking, xapi, progress, setActiveLesson, completeLesson, completeCourse, track]
82
239
  );
83
240
  return /* @__PURE__ */ jsx(LessonkitContext.Provider, { value: runtime, children: props.children });
84
241
  }
@@ -120,7 +277,11 @@ function useQuizState() {
120
277
  // src/components.tsx
121
278
  import { jsx as jsx2, jsxs } from "react/jsx-runtime";
122
279
  function Course(props) {
123
- return /* @__PURE__ */ jsx2(LessonkitProvider, { config: { courseId: props.courseId }, children: /* @__PURE__ */ jsxs("section", { "aria-label": props.title, children: [
280
+ const providerConfig = useMemo3(
281
+ () => ({ ...props.config, courseId: props.courseId }),
282
+ [props.config, props.courseId]
283
+ );
284
+ return /* @__PURE__ */ jsx2(LessonkitProvider, { config: providerConfig, children: /* @__PURE__ */ jsxs("section", { "aria-label": props.title, children: [
124
285
  /* @__PURE__ */ jsx2("h1", { children: props.title }),
125
286
  /* @__PURE__ */ jsx2("div", { children: props.children })
126
287
  ] }) });
@@ -128,8 +289,10 @@ function Course(props) {
128
289
  function Lesson(props) {
129
290
  const { setActiveLesson } = useLessonkit();
130
291
  const { completeLesson } = useCompletion();
131
- const id = props.lessonId ?? useMemo3(() => `lesson-${cryptoRandomId()}`, []);
132
- useEffect(() => {
292
+ const reactId = useId();
293
+ const generatedId = useMemo3(() => `lesson-${sanitizeLessonId(reactId)}`, [reactId]);
294
+ const id = props.lessonId ?? generatedId;
295
+ useEffect2(() => {
133
296
  setActiveLesson(id);
134
297
  return () => {
135
298
  completeLesson(id);
@@ -148,7 +311,13 @@ function Reflection(props) {
148
311
  return /* @__PURE__ */ jsxs("section", { "aria-label": "Reflection", children: [
149
312
  props.prompt ? /* @__PURE__ */ jsx2("p", { id: promptId, children: props.prompt }) : null,
150
313
  props.children,
151
- /* @__PURE__ */ jsx2("textarea", { "aria-labelledby": props.prompt ? promptId : void 0 })
314
+ /* @__PURE__ */ jsx2(
315
+ "textarea",
316
+ {
317
+ "aria-labelledby": props.prompt ? promptId : void 0,
318
+ "aria-label": props.prompt ? void 0 : "Reflection response"
319
+ }
320
+ )
152
321
  ] });
153
322
  }
154
323
  function KnowledgeCheck(props) {
@@ -157,12 +326,13 @@ function KnowledgeCheck(props) {
157
326
  function Quiz(props) {
158
327
  const quiz = useQuizState();
159
328
  const [selected, setSelected] = useState2(null);
329
+ const completedRef = useRef2(false);
160
330
  const questionId = useId();
161
331
  return /* @__PURE__ */ jsxs("section", { "aria-label": "Quiz", children: [
162
332
  /* @__PURE__ */ jsx2("p", { id: questionId, children: props.question }),
163
333
  /* @__PURE__ */ jsxs("fieldset", { "aria-labelledby": questionId, children: [
164
- /* @__PURE__ */ jsx2("legend", { className: "sr-only", children: "Quiz choices" }),
165
- props.choices.map((c) => /* @__PURE__ */ jsxs("label", { style: { display: "block" }, children: [
334
+ /* @__PURE__ */ jsx2("legend", { style: visuallyHiddenStyle, children: "Quiz choices" }),
335
+ props.choices.map((c, i) => /* @__PURE__ */ jsxs("label", { style: { display: "block" }, children: [
166
336
  /* @__PURE__ */ jsx2(
167
337
  "input",
168
338
  {
@@ -172,12 +342,17 @@ function Quiz(props) {
172
342
  checked: selected === c,
173
343
  onChange: () => {
174
344
  setSelected(c);
175
- quiz.answer({ question: props.question, choice: c, correct: c === props.answer });
345
+ const correct = c === props.answer;
346
+ quiz.answer({ question: props.question, choice: c, correct });
347
+ if (correct && !completedRef.current) {
348
+ completedRef.current = true;
349
+ quiz.complete({ score: 1, maxScore: 1 });
350
+ }
176
351
  }
177
352
  }
178
353
  ),
179
354
  c
180
- ] }, c))
355
+ ] }, `${questionId}-${i}`))
181
356
  ] }),
182
357
  selected ? /* @__PURE__ */ jsx2("p", { role: "status", "aria-live": "polite", children: selected === props.answer ? "Correct" : "Try again" }) : null
183
358
  ] });
@@ -190,10 +365,9 @@ function ProgressTracker() {
190
365
  completed
191
366
  ] }) });
192
367
  }
193
- function cryptoRandomId() {
194
- const g = globalThis;
195
- if (g.crypto?.randomUUID) return g.crypto.randomUUID();
196
- return Math.random().toString(16).slice(2);
368
+ function sanitizeLessonId(id) {
369
+ const s = id.replace(/[^a-zA-Z0-9_-]/g, "");
370
+ return s.length ? s : "id";
197
371
  }
198
372
  export {
199
373
  Course,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lessonkit/react",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "description": "React components and hooks for building learning experiences with LessonKit.",
6
6
  "license": "Apache-2.0",
@@ -37,11 +37,12 @@
37
37
  "dist"
38
38
  ],
39
39
  "scripts": {
40
- "build": "tsup src/index.tsx --format esm,cjs --dts --external react --external react-dom",
41
- "dev": "tsup src/index.tsx --format esm,cjs --dts --watch --external react --external react-dom",
40
+ "build": "tsup src/index.tsx --format esm,cjs --dts --external react --external react-dom --external @lessonkit/accessibility",
41
+ "dev": "tsup src/index.tsx --format esm,cjs --dts --watch --external react --external react-dom --external @lessonkit/accessibility",
42
42
  "prepublishOnly": "npm run build",
43
43
  "typecheck": "tsc -p tsconfig.json",
44
44
  "test": "vitest run --passWithNoTests",
45
+ "test:coverage": "vitest run --coverage --passWithNoTests=false",
45
46
  "lint": "echo \"(no lint configured yet)\""
46
47
  },
47
48
  "peerDependencies": {
@@ -49,8 +50,9 @@
49
50
  "react-dom": ">=18"
50
51
  },
51
52
  "dependencies": {
52
- "@lessonkit/core": "0.1.1",
53
- "@lessonkit/xapi": "0.1.1"
53
+ "@lessonkit/accessibility": "0.3.0",
54
+ "@lessonkit/core": "0.3.0",
55
+ "@lessonkit/xapi": "0.3.0"
54
56
  },
55
57
  "devDependencies": {
56
58
  "@testing-library/react": "^16.3.0",