@superbuilders/primer-tives 0.5.0 → 0.8.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.
Files changed (74) hide show
  1. package/README.md +294 -481
  2. package/dist/client/choice-state.d.ts.map +1 -0
  3. package/dist/client/consumed.d.ts.map +1 -0
  4. package/dist/client/content.d.ts.map +1 -0
  5. package/dist/{client.d.ts → client/create.d.ts} +7 -5
  6. package/dist/client/create.d.ts.map +1 -0
  7. package/dist/client/extended-text-state.d.ts.map +1 -0
  8. package/dist/client/feedback-state.d.ts.map +1 -0
  9. package/dist/{index.d.ts → client/index.d.ts} +7 -5
  10. package/dist/client/index.d.ts.map +1 -0
  11. package/dist/{index.js → client/index.js} +81 -47
  12. package/dist/client/index.js.map +24 -0
  13. package/dist/client/match-state.d.ts.map +1 -0
  14. package/dist/client/observation-state.d.ts.map +1 -0
  15. package/dist/client/order-state.d.ts.map +1 -0
  16. package/dist/client/pci-state.d.ts.map +1 -0
  17. package/dist/client/pci.d.ts.map +1 -0
  18. package/dist/{session-context.d.ts → client/session-context.d.ts} +4 -7
  19. package/dist/client/session-context.d.ts.map +1 -0
  20. package/dist/{session.d.ts → client/session.d.ts} +4 -2
  21. package/dist/client/session.d.ts.map +1 -0
  22. package/dist/client/text-entry-state.d.ts.map +1 -0
  23. package/dist/{transport.d.ts → client/transport.d.ts} +5 -4
  24. package/dist/client/transport.d.ts.map +1 -0
  25. package/dist/client/types.d.ts.map +1 -0
  26. package/dist/errors.d.ts +8 -3
  27. package/dist/errors.d.ts.map +1 -1
  28. package/dist/grade-level.d.ts +5 -0
  29. package/dist/grade-level.d.ts.map +1 -0
  30. package/dist/logger.d.ts +8 -0
  31. package/dist/logger.d.ts.map +1 -0
  32. package/dist/server/create-server.d.ts +42 -0
  33. package/dist/server/create-server.d.ts.map +1 -0
  34. package/dist/server/exchange.d.ts +17 -0
  35. package/dist/server/exchange.d.ts.map +1 -0
  36. package/dist/server/index.d.ts +8 -0
  37. package/dist/server/index.d.ts.map +1 -0
  38. package/dist/server/index.js +259 -0
  39. package/dist/server/index.js.map +14 -0
  40. package/dist/server/students.d.ts +14 -0
  41. package/dist/server/students.d.ts.map +1 -0
  42. package/dist/subject.d.ts +6 -0
  43. package/dist/subject.d.ts.map +1 -0
  44. package/package.json +8 -4
  45. package/dist/choice-state.d.ts.map +0 -1
  46. package/dist/client.d.ts.map +0 -1
  47. package/dist/consumed.d.ts.map +0 -1
  48. package/dist/content.d.ts.map +0 -1
  49. package/dist/extended-text-state.d.ts.map +0 -1
  50. package/dist/feedback-state.d.ts.map +0 -1
  51. package/dist/index.d.ts.map +0 -1
  52. package/dist/index.js.map +0 -23
  53. package/dist/match-state.d.ts.map +0 -1
  54. package/dist/observation-state.d.ts.map +0 -1
  55. package/dist/order-state.d.ts.map +0 -1
  56. package/dist/pci-state.d.ts.map +0 -1
  57. package/dist/pci.d.ts.map +0 -1
  58. package/dist/session-context.d.ts.map +0 -1
  59. package/dist/session.d.ts.map +0 -1
  60. package/dist/text-entry-state.d.ts.map +0 -1
  61. package/dist/transport.d.ts.map +0 -1
  62. package/dist/types.d.ts.map +0 -1
  63. /package/dist/{choice-state.d.ts → client/choice-state.d.ts} +0 -0
  64. /package/dist/{consumed.d.ts → client/consumed.d.ts} +0 -0
  65. /package/dist/{content.d.ts → client/content.d.ts} +0 -0
  66. /package/dist/{extended-text-state.d.ts → client/extended-text-state.d.ts} +0 -0
  67. /package/dist/{feedback-state.d.ts → client/feedback-state.d.ts} +0 -0
  68. /package/dist/{match-state.d.ts → client/match-state.d.ts} +0 -0
  69. /package/dist/{observation-state.d.ts → client/observation-state.d.ts} +0 -0
  70. /package/dist/{order-state.d.ts → client/order-state.d.ts} +0 -0
  71. /package/dist/{pci-state.d.ts → client/pci-state.d.ts} +0 -0
  72. /package/dist/{pci.d.ts → client/pci.d.ts} +0 -0
  73. /package/dist/{text-entry-state.d.ts → client/text-entry-state.d.ts} +0 -0
  74. /package/dist/{types.d.ts → client/types.d.ts} +0 -0
package/README.md CHANGED
@@ -1,19 +1,6 @@
1
1
  # @superbuilders/primer-tives
2
2
 
3
- Client SDK for the Primer adaptive learning engine.
4
-
5
- This package turns the Primer HTTP protocol into a strongly typed, server-driven state machine. Your app renders the current frame, calls the method that is valid for that frame, and the server decides what comes next.
6
-
7
- The SDK does **not** know about routines, curricula, storage schemas, or routing rules. It only knows how to:
8
-
9
- - start a student session
10
- - submit answers or timeouts
11
- - normalize transport failures into sentinel errors plus `retriable` semantics
12
- - expose the current step as an ergonomic TypeScript union
13
- - expose interaction-native review data for feedback rendering
14
- - preserve full type safety for portable custom interactions (PCIs)
15
-
16
- ## Install
3
+ TypeScript SDK for the Primer adaptive learning engine. Wraps the raw HTTP protocol in two tiny, fully typed surfaces — one for your backend, one for the browser — so it's mechanically impossible to misuse.
17
4
 
18
5
  ```sh
19
6
  bun add @superbuilders/primer-tives
@@ -21,37 +8,66 @@ bun add @superbuilders/primer-tives
21
8
 
22
9
  Dependency: `@superbuilders/errors` is installed automatically.
23
10
 
24
- ## What you get
11
+ ## Two entrypoints
12
+
13
+ The package ships two subpaths. There is no root export — you must pick the side of the wire you're on:
25
14
 
26
- - **Single entry point**: `create(config)`
27
- - **Single wire endpoint**: `POST ${origin}/api/v0/advance`
28
- - **Single runtime model**: `PrimerState`
29
- - **Six phases/kinds to handle**:
30
- - phases: `observation`, `interaction`, `feedback`, `completed`, `errored`, `fatal`
31
- - interaction kinds: `choice`, `text-entry`, `extended-text`, `order`, `match`, `portable-custom`
32
- - **Typed PCI submissions** driven by the `PciRegistry`
33
- - **Live in-memory state objects** with action methods like `advance()`, `submitChoice()`, `submitOrder()`, `submitMatch()`, `submit()`, `timeout()`, and `retry()`
15
+ | Import | Runs on | Wraps |
16
+ |---|---|---|
17
+ | `@superbuilders/primer-tives/server` | your backend | `POST /api/v0/auth/exchange`, `POST /api/v0/students`, `PATCH /api/v0/students/{id}` |
18
+ | `@superbuilders/primer-tives/client` | the browser | `POST /api/v0/advance` (the lesson state machine) |
19
+
20
+ No route strings, no `fetch()` calls, no snake_case wire bodies on your side. Both surfaces normalize transport failures into sentinel errors from `@superbuilders/errors`.
21
+
22
+ ## Round trip
34
23
 
35
- ## Quick start
24
+ Your backend provisions a student (once), exchanges a `sk_` for a short-lived access token (each session), and hands the token to your frontend. Your frontend passes the token to `create()` and drives the returned state machine.
36
25
 
37
26
  ```ts
27
+ // ── your backend ─────────────────────────────────────────────────────
38
28
  import * as errors from "@superbuilders/errors"
29
+ import { createPrimerServer } from "@superbuilders/primer-tives/server"
30
+
31
+ const primer = createPrimerServer({
32
+ origin: "https://primer.example.com",
33
+ secretKey: process.env.PRIMER_CLIENT_SECRET_KEY_DEV
34
+ })
35
+
36
+ // One-time per user: provision a Primer-owned student.
37
+ const studentId = await primer.createNativeStudent("4")
38
+ // persist `studentId` alongside your user record
39
+
40
+ // Every session: mint a short-lived access token.
41
+ const result = await errors.try(
42
+ primer.exchangeNativeStudentForAccessToken(studentId)
43
+ )
44
+ if (result.error) {
45
+ // map ErrInvalidSecretKey / ErrStudentNotFound / ErrServerError etc.
46
+ throw result.error
47
+ }
48
+ const { accessToken, expiresInSeconds } = result.data
49
+ // ship `accessToken` to the browser
50
+ ```
51
+
52
+ ```ts
53
+ // ── your frontend ────────────────────────────────────────────────────
39
54
  import {
40
55
  create,
41
56
  ErrRateLimited,
42
- type PrimerState,
43
- } from "@superbuilders/primer-tives"
57
+ type PrimerState
58
+ } from "@superbuilders/primer-tives/client"
44
59
 
45
60
  const client = create({
46
- publishableKey: "pk_live_abc123",
47
- origin: "https://sb-primer.vercel.app",
61
+ accessToken, // from your backend
62
+ origin: "https://primer.example.com",
63
+ subject: "math",
48
64
  supportedPcis: [
49
65
  "urn:primer:pci:division-remainder",
50
- "urn:primer:pci:fraction-addition",
51
- ],
66
+ "urn:primer:pci:fraction-addition"
67
+ ]
52
68
  })
53
69
 
54
- let state: PrimerState = await client.start("student-uuid")
70
+ let state: PrimerState = await client.start()
55
71
 
56
72
  while (state.phase !== "completed" && state.phase !== "fatal") {
57
73
  switch (state.phase) {
@@ -59,418 +75,280 @@ while (state.phase !== "completed" && state.phase !== "fatal") {
59
75
  renderStimulus(state.stimulus)
60
76
  state = await state.advance()
61
77
  break
62
-
63
78
  case "interaction":
64
- renderStimulus(state.stimulus)
65
-
66
- switch (state.kind) {
67
- case "choice":
68
- state = await state.submitChoice(["option-a"])
69
- break
70
-
71
- case "text-entry":
72
- state = await state.submitText("42")
73
- break
74
-
75
- case "extended-text":
76
- if (state.cardinality === "single") {
77
- state = await state.submitText("answer")
78
- } else {
79
- state = await state.submitTexts(["first", "second"])
80
- }
81
- break
82
-
83
- case "order":
84
- state = await state.submitOrder(["choice-1", "choice-2", "choice-3"])
85
- break
86
-
87
- case "match":
88
- state = await state.submitMatch([
89
- { source: "left-1", target: "right-2" },
90
- { source: "left-2", target: "right-1" },
91
- ])
92
- break
93
-
94
- case "portable-custom":
95
- switch (state.pciId) {
96
- case "urn:primer:pci:division-remainder":
97
- state = await state.submit({ quotient: "3", remainder: "1" })
98
- break
99
- case "urn:primer:pci:fraction-addition":
100
- state = await state.submit({ numerator: "5", denominator: "6" })
101
- break
102
- }
103
- break
104
- }
79
+ state = await runInteraction(state)
105
80
  break
106
-
107
81
  case "feedback":
108
- renderFeedback({
109
- correct: state.isCorrect,
110
- content: state.feedbackContent,
111
- review: state.review,
112
- interaction: state.interaction,
113
- submission: state.submission,
114
- })
82
+ renderFeedback(state.feedbackContent, state.isCorrect)
115
83
  state = await state.advance()
116
84
  break
117
-
118
85
  case "errored":
119
- if (!state.retriable) {
86
+ if (state.retriable) {
87
+ state = await state.retry()
88
+ } else {
120
89
  throw state.error
121
90
  }
122
- if (errors.is(state.error, ErrRateLimited)) {
123
- await delay(1000)
124
- }
125
- state = await state.retry()
126
91
  break
127
92
  }
128
93
  }
129
94
  ```
130
95
 
131
- Both `state.phase` and `state.kind` are discriminated unions, so TypeScript narrows automatically inside each branch.
132
-
133
- ## Mental model
134
-
135
- Primer is **server-authored**.
96
+ ---
136
97
 
137
- - The client sends an **intent**: advance, submit, or timeout.
138
- - The server evaluates that intent and returns the next frame.
139
- - The SDK wraps that frame in a typed state object.
140
- - Your UI renders the state and calls the next valid method.
141
-
142
- In other words, your code does not calculate progression locally. The server owns progression; the SDK owns transport, typing, and ergonomics.
143
-
144
- ## Configuration
98
+ # `/server`
145
99
 
146
100
  ```ts
147
- interface Config<Pcis extends PciId = PciId> {
148
- readonly publishableKey: string
149
- readonly supportedPcis: readonly Pcis[]
150
- readonly origin: string
151
- readonly fetch?: typeof globalThis.fetch
152
- readonly abort?: AbortController
153
- readonly logger?: PrimerLogger
154
- }
101
+ import {
102
+ createPrimerServer,
103
+ type PrimerServer,
104
+ type PrimerServerConfig,
105
+ type SessionToken,
106
+ type GradeLevel,
107
+ GRADE_LEVELS
108
+ } from "@superbuilders/primer-tives/server"
155
109
  ```
156
110
 
157
- | Field | Required | Description |
158
- |---|---|---|
159
- | `publishableKey` | yes | Must start with `pk_`. Sent as `Authorization: Bearer pk_...` |
160
- | `supportedPcis` | yes | PCI URNs the renderer can handle. Use `[]` if none |
161
- | `origin` | yes | Full Primer API base URL, for example `https://sb-primer.vercel.app` |
162
- | `fetch` | no | Custom fetch implementation. Defaults to `globalThis.fetch` |
163
- | `abort` | no | `AbortController` whose `signal` is passed to every request |
164
- | `logger` | no | Structured logger with `debug`, `info`, `warn`, and `error` methods |
165
-
166
- `create()` validates the publishable key prefix immediately and throws `ErrMalformedPublishableKey` if it does not start with `pk_`.
167
-
168
- ### Logger interface
111
+ ## `createPrimerServer(config)`
169
112
 
170
113
  ```ts
171
- interface PrimerLogger {
172
- debug(message: string, attributes?: Record<string, unknown>): void
173
- info(message: string, attributes?: Record<string, unknown>): void
174
- warn(message: string, attributes?: Record<string, unknown>): void
175
- error(message: string, attributes?: Record<string, unknown>): void
114
+ interface PrimerServerConfig {
115
+ readonly origin: string // e.g. https://primer.example.com (no trailing slash)
116
+ readonly secretKey: string // your sk_… key
117
+ readonly fetch?: typeof globalThis.fetch // override (defaults to globalThis.fetch)
118
+ readonly abort?: AbortController // wired into every request signal
119
+ readonly logger?: PrimerLogger // optional structured logger
176
120
  }
121
+
122
+ function createPrimerServer(config: PrimerServerConfig): PrimerServer
177
123
  ```
178
124
 
179
- ## Wire protocol
125
+ Returns a `PrimerServer` with four methods. Each method throws a sentinel-wrapped `Error` — use `errors.try()` from `@superbuilders/errors` at the call site.
180
126
 
181
- Every request is a `POST` to:
127
+ ### `createNativeStudent(gradeLevel): Promise<string>`
182
128
 
183
- ```txt
184
- ${origin}/api/v0/advance
185
- ```
186
-
187
- The path constant is exported as:
129
+ Provision a new Primer-owned student. Returns the `studentId` string — persist it in your own database keyed by your user. Call this **once per user**.
188
130
 
189
131
  ```ts
190
- const ADVANCE_PATH = "/api/v0/advance"
132
+ const studentId = await primer.createNativeStudent("5")
191
133
  ```
192
134
 
193
- ### Headers
135
+ **Do not call this for Timeback integrations.** Timeback students are provisioned automatically on first `exchangeTimebackStudentForAccessToken`.
194
136
 
195
- ```txt
196
- Authorization: Bearer pk_...
197
- Content-Type: application/json
198
- ```
137
+ ### `updateNativeStudentGradeLevel(studentId, gradeLevel): Promise<void>`
199
138
 
200
- ### Request body
139
+ Change a Primer student's grade level.
201
140
 
202
141
  ```ts
203
- interface WireRequestBody<Pcis extends PciId = PciId> {
204
- studentId: string
205
- supportedPcis: readonly PciId[]
206
- intent: WireIntent<Pcis>
207
- }
142
+ await primer.updateNativeStudentGradeLevel(studentId, "6")
208
143
  ```
209
144
 
210
- ```ts
211
- type WireIntent<Pcis extends PciId = PciId> =
212
- | { kind: "observation" }
213
- | { kind: "interaction"; submission: RendererSubmission<Pcis> }
214
- | { kind: "timeout" }
215
- ```
145
+ **Do not call this for Timeback students.** Grade-level changes flow from the SIS.
216
146
 
217
- ### Response outcomes
147
+ ### `exchangeNativeStudentForAccessToken(studentId): Promise<SessionToken>`
218
148
 
219
- Conceptually, the server returns one of three outcomes:
149
+ Mint a short-lived access token for an existing Primer-owned student. Call this **every session start**; tokens expire in 15 minutes by default.
220
150
 
221
151
  ```ts
222
- type WireResult<Pcis extends PciId = PciId> =
223
- | {
224
- outcome: "advanced"
225
- stimulus: RendererStimulus | null
226
- interaction: RendererInteraction<Pcis> | null
227
- }
228
- | {
229
- outcome: "submitted"
230
- stimulus: RendererStimulus | null
231
- interaction: RendererInteraction<Pcis>
232
- submission: RendererSubmission<Pcis>
233
- isCorrect: boolean
234
- feedbackContent: ContentInline[]
235
- review: InteractionReview<Pcis> | null
236
- }
237
- | { outcome: "completed" }
152
+ const { accessToken, expiresInSeconds } = await primer.exchangeNativeStudentForAccessToken(studentId)
238
153
  ```
239
154
 
240
- ### Important notes
155
+ ### `exchangeTimebackStudentForAccessToken(sourcedId): Promise<SessionToken>`
241
156
 
242
- - `origin` must be the **full base URL**. The SDK constructs requests as `${origin}${ADVANCE_PATH}`.
243
- - `supportedPcis` is sent on every request so the server knows which portable custom interactions the client can render.
244
- - The client also uses `supportedPcis` as an inbound safety check: if the server returns a PCI frame the client did not advertise support for, the SDK returns a fatal `ErrUnsupportedPci` state.
157
+ Mint a short-lived access token by OneRoster `sourcedId`. The server contacts the Timeback API to verify identity and grade, auto-provisions the Primer student row on first use, and returns the token. Subsequent exchanges for the same `sourcedId` reuse the existing row.
245
158
 
246
- ## State machine
247
-
248
- `PrimerState` is a discriminated union on `phase`:
159
+ ```ts
160
+ const { accessToken, expiresInSeconds } = await primer.exchangeTimebackStudentForAccessToken(sourcedId)
161
+ ```
249
162
 
250
- ```txt
251
- observation -> interaction -> feedback -> observation -> ... -> completed
252
- | \ | \ | \
253
- v \ v \ v \
254
- errored fatal errored fatal errored fatal
163
+ ## `SessionToken`
255
164
 
256
- errored -> retry() -> replays exact failed action
257
- interaction timeout -> same transport path, intent kind "timeout"
165
+ ```ts
166
+ interface SessionToken {
167
+ readonly accessToken: string // HS256 JWS — pass to client SDK as Config.accessToken
168
+ readonly expiresInSeconds: number // typically 900 (15 min)
169
+ }
258
170
  ```
259
171
 
260
- ### `observation`
172
+ Hand `accessToken` to your frontend over whatever channel you already use (cookie, HTML response, WebSocket, API response). The browser passes it to `create()`.
261
173
 
262
- Display-only frame. Render `state.stimulus`, then call `advance()`.
174
+ ## `GradeLevel`
263
175
 
264
176
  ```ts
265
- state.phase === "observation"
266
- state.stimulus // RendererStimulus | null
267
- state.advance() // Promise<PrimerState>
177
+ type GradeLevel = "K" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "10" | "11" | "12"
268
178
  ```
269
179
 
270
- ### `interaction`
271
-
272
- Student must respond. Discriminate on `state.kind`.
273
-
274
- All interaction states also expose:
180
+ Exported as both `GradeLevel` and the `GRADE_LEVELS` readonly tuple.
275
181
 
276
- - `state.stimulus`
277
- - `state.interaction`
278
- - `timeout()`
182
+ ## Error sentinels (`/server`)
279
183
 
280
- #### Interaction kinds
184
+ | Sentinel | Raised when |
185
+ |---|---|
186
+ | `ErrInvalidSecretKey` | HTTP 401 — missing, malformed, or unknown `sk_` |
187
+ | `ErrStudentNotFound` | HTTP 404 — student doesn't exist (native) or sourced ID unknown (timeback) |
188
+ | `ErrUnsupportedGrade` | HTTP 400 — Timeback user's grade is outside K–12 |
189
+ | `ErrTimebackUnavailable` | HTTP 502 — Timeback OneRoster endpoint failed |
190
+ | `ErrBadRequest` | HTTP 400 (non-grade) — validation failure |
191
+ | `ErrServerError` | HTTP 5xx |
192
+ | `ErrJsonParse` | Success response body wasn't valid JSON |
193
+ | `ErrNetwork` | fetch() rejected (DNS, connection, TLS) |
194
+ | `ErrTimeout` | fetch() aborted (your `AbortController` or `TimeoutError`) |
195
+
196
+ All server methods follow the same pattern:
281
197
 
282
- | `state.kind` | Submit method | Key fields |
283
- |---|---|---|
284
- | `"choice"` | `submitChoice(selectedKeys: string[])` | `options`, `minChoices`, `maxChoices` |
285
- | `"text-entry"` | `submitText(value: string)` | `interaction.base`, `interaction.expectedLength?`, `interaction.patternMask?`, `interaction.placeholderText?` |
286
- | `"extended-text"` + `cardinality: "single"` | `submitText(value: string)` | `interaction.format`, `interaction.expectedLength?`, `interaction.expectedLines?`, `interaction.patternMask?`, `interaction.placeholderText?` |
287
- | `"extended-text"` + `cardinality: "multiple"` | `submitTexts(values: string[])` | `minStrings`, `maxStrings`, plus the same metadata as single-cardinality extended text |
288
- | `"order"` | `submitOrder(orderedKeys: string[])` | `choices`, `minChoices`, `maxChoices`, `interaction.shuffle` |
289
- | `"match"` | `submitMatch(pairs: Array<{ source: string; target: string }>)` | `sourceChoices`, `targetChoices`, `minAssociations`, `maxAssociations`, `interaction.shuffle` |
290
- | `"portable-custom"` | `submit(value: PciValue<K>)` | `pciId`, `properties` |
198
+ ```ts
199
+ const result = await errors.try(primer.createNativeStudent("5"))
200
+ if (result.error) {
201
+ if (errors.is(result.error, ErrInvalidSecretKey)) {
202
+ // rotate the sk_ and retry
203
+ }
204
+ throw result.error
205
+ }
206
+ const studentId = result.data
207
+ ```
291
208
 
292
- ### `feedback`
209
+ ---
293
210
 
294
- The server has evaluated the submission. Render feedback, then call `advance()`.
211
+ # `/client`
295
212
 
296
213
  ```ts
297
- state.phase === "feedback"
298
- state.stimulus // RendererStimulus | null
299
- state.interaction // RendererInteraction<Pcis>
300
- state.submission // RendererSubmission<Pcis>
301
- state.isCorrect // boolean
302
- state.feedbackContent // ContentInline[]
303
- state.review // InteractionReview<Pcis> | null
304
- state.advance() // Promise<PrimerState>
214
+ import {
215
+ create,
216
+ type Client,
217
+ type Config,
218
+ type PrimerState,
219
+ type PrimerLogger,
220
+ type Subject,
221
+ type SubjectScope,
222
+ SUBJECTS,
223
+ ErrRateLimited,
224
+ // …sentinels + every state/interaction/content/PCI type
225
+ } from "@superbuilders/primer-tives/client"
305
226
  ```
306
227
 
307
- `review` is interaction-native feedback data when available:
228
+ ## `create(config)`
308
229
 
309
230
  ```ts
310
- type InteractionReview<Pcis extends PciId = PciId> =
311
- | { type: "choice"; correctKeys: string[] }
312
- | { type: "text-entry"; correctValue: ReviewScalarValue | null }
313
- | { type: "extended-text"; correctValues: ReviewScalarValue[] }
314
- | { type: "order"; correctOrder: string[] }
315
- | { type: "match"; correctPairs: Array<{ source: string; target: string }> }
316
- | {
317
- type: "portable-custom"
318
- pciId: Pcis
319
- fields: Array<{
320
- fieldIdentifier: string
321
- baseType: "identifier" | "string" | "integer" | "float" | "pair"
322
- value: ReviewScalarValue | null
323
- }>
324
- }
325
-
326
- type ReviewScalarValue =
327
- | { kind: "identifier"; value: string }
328
- | { kind: "string"; value: string }
329
- | { kind: "integer"; value: number }
330
- | { kind: "float"; value: number }
331
- | { kind: "pair"; source: string; target: string }
231
+ function create<const Pcis extends PciId>(config: Config<Pcis>): Client<Pcis>
232
+
233
+ interface Config<Pcis extends PciId = PciId> {
234
+ readonly accessToken: string // JWS from /server
235
+ readonly supportedPcis: readonly Pcis[] // PCI URNs this renderer handles ([] if none)
236
+ readonly origin: string // same origin you gave /server
237
+ readonly subject: SubjectScope // "math" | "vocabulary" | "science" | "all"
238
+ readonly fetch?: typeof globalThis.fetch
239
+ readonly abort?: AbortController
240
+ readonly logger?: PrimerLogger
241
+ }
242
+
243
+ interface Client<Pcis extends PciId = PciId> {
244
+ start(): Promise<PrimerState<Pcis>> // idempotent
245
+ }
332
246
  ```
333
247
 
334
- Notes:
248
+ `create()` does a cheap structural check on the token (must start with `eyJ` and contain two dots) and throws `ErrMalformedAccessToken` if the shape is wrong. Signature verification happens on the server.
335
249
 
336
- - `null` means the server did not provide review data.
337
- - order review preserves ordering directly.
338
- - match review preserves directional `{ source, target }` pairs directly.
339
- - PCI review uses record fields and can carry `pair` values without flattening them into strings.
340
- - `feedback.advance()` uses the same observation intent as `observation.advance()`.
250
+ ### `subject` scope
341
251
 
342
- ### `completed`
252
+ | Value | Behavior |
253
+ |---|---|
254
+ | `"math"` / `"vocabulary"` / `"science"` | Restrict drill selection to courses of that subject |
255
+ | `"all"` | No filter; drills from any subject the frontend is bound to |
343
256
 
344
- Session finished successfully. No further actions.
257
+ Per-session. To switch subjects, construct a new client. Student placements are isolated per subject — a student with an in-progress math placement resumes it on a math-scoped reconnect, a new vocabulary-scoped client bootstraps a fresh vocabulary placement, and neither disturbs the other. Curriculum routing is not affected by `subject`.
345
258
 
346
- ### `errored`
259
+ ## `PrimerState` — the state machine
347
260
 
348
- Recoverable or user-correctable failure.
261
+ `start()` returns a `PrimerState` union. Each phase has its own shape and action methods.
349
262
 
350
263
  ```ts
351
- state.phase === "errored"
352
- state.error // Error sentinel
353
- state.retriable // boolean
354
- state.retry() // Promise<PrimerState>
264
+ type PrimerState =
265
+ | ObservationState // advance()
266
+ | InteractionState // submit…() / timeout()
267
+ | FeedbackState // advance()
268
+ | CompletedState // terminal
269
+ | ErroredState // retry() — retriable:boolean
270
+ | FatalState // terminal
355
271
  ```
356
272
 
357
- Caller guidance:
358
-
359
- - switch on sentinel identity with `errors.is(...)`
360
- - check `state.retriable` before calling `retry()`
361
- - non-retriable errored states keep the original sentinel but do not replay the failed action
362
-
363
- ### `fatal`
273
+ ### `observation`
364
274
 
365
- Permanent failure. Session cannot recover.
275
+ Server wants you to show a stimulus and the student to indicate they're done reading.
366
276
 
367
277
  ```ts
368
- state.phase === "fatal"
369
- state.error // Error sentinel
278
+ interface ObservationState {
279
+ readonly phase: "observation"
280
+ readonly stimulus: RendererStimulus | null
281
+ advance(): Promise<PrimerState>
282
+ }
370
283
  ```
371
284
 
372
- Fatal states can come from:
285
+ ### `interaction`
373
286
 
374
- - malformed request semantics on the server (`400`)
375
- - invalid publishable key (`401`)
376
- - forbidden origin (`403`)
377
- - missing resource (`404`)
378
- - unsupported PCI (`422`)
379
- - a successful response that still contains a `portable-custom` frame whose `pciId` is not in `supportedPcis`
287
+ Server wants an answer. `InteractionState` is a discriminated union over `kind`:
380
288
 
381
- ## Interaction payloads
289
+ | `kind` | method |
290
+ |---|---|
291
+ | `choice` | `submitChoice(selectedKeys: string[])` |
292
+ | `text-entry` | `submitText(value: string)` |
293
+ | `extended-text` (single) | `submitText(value: string)` |
294
+ | `extended-text` (multiple) | `submitTexts(values: string[])` |
295
+ | `order` | `submitOrder(orderedKeys: string[])` |
296
+ | `match` | `submitMatch(pairs: MatchPair[])` |
297
+ | `portable-custom` | `submit(value: PciValue<K>)` |
382
298
 
383
- ```ts
384
- type PciSubmission<Pcis extends PciId = PciId> = {
385
- [K in Pcis]: {
386
- type: "portable-custom"
387
- pciId: K
388
- value: PciValue<K>
389
- }
390
- }[Pcis]
391
-
392
- type RendererSubmission<Pcis extends PciId = PciId> =
393
- | { type: "choice"; selectedKeys: string[] }
394
- | { type: "text-entry"; value: string }
395
- | { type: "extended-text"; values: string[] }
396
- | { type: "order"; orderedKeys: string[] }
397
- | { type: "match"; pairs: Array<{ source: string; target: string }> }
398
- | PciSubmission<Pcis>
399
- ```
299
+ All six shapes also expose `timeout(): Promise<PrimerState>`.
400
300
 
401
- Notes:
301
+ ### `feedback`
402
302
 
403
- - single-cardinality extended text is still submitted as `type: "extended-text"` with a one-element `values` array
404
- - match submissions are directional `{ source, target }` pairs
405
- - portable custom submissions carry both `pciId` and a typed PCI `value`
303
+ Server has graded the submission and returned feedback content.
406
304
 
407
- ## Client-side validation
305
+ ```ts
306
+ interface FeedbackState {
307
+ readonly phase: "feedback"
308
+ readonly stimulus: RendererStimulus | null
309
+ readonly interaction: RendererInteraction
310
+ readonly submission: RendererSubmission
311
+ readonly isCorrect: boolean
312
+ readonly feedbackContent: ContentInline[] // server-provided, localized feedback
313
+ readonly review: InteractionReview | null // correct answers etc., if available
314
+ advance(): Promise<PrimerState>
315
+ }
316
+ ```
408
317
 
409
- The SDK performs **selective** client-side validation before sending some submissions.
318
+ ### `errored`
410
319
 
411
- | Interaction kind | Validation performed |
412
- |---|---|
413
- | `choice` | min selections, max selections, duplicate keys, unknown option identifiers |
414
- | `text-entry` | none |
415
- | `extended-text` + `single` | none |
416
- | `extended-text` + `multiple` | `minStrings`, `maxStrings` |
417
- | `order` | min selections, max selections, duplicate keys, unknown choice identifiers |
418
- | `match` | `minAssociations`, `maxAssociations`, unknown source/target identifiers, and per-choice `matchMin` / `matchMax` caps on both sides |
419
- | `portable-custom` | no outbound value validation |
320
+ Transport or validation failure that might be recoverable.
420
321
 
421
- If validation fails:
322
+ ```ts
323
+ interface ErroredState {
324
+ readonly phase: "errored"
325
+ readonly error: Error // sentinel-wrapped
326
+ readonly retriable: boolean
327
+ retry(): Promise<PrimerState> // no-op on non-retriable
328
+ }
329
+ ```
422
330
 
423
- - the submit call resolves to an `errored` state
424
- - `state.error` will match `ErrInvalidSubmission`
425
- - `state.retriable` will be `false`
426
- - the original interaction object is still usable for a corrected resubmission
427
- - `retry()` returns the same errored state instead of replaying the invalid payload
331
+ ### `fatal`
428
332
 
429
- ## Error sentinels
333
+ Unrecoverable failure (bad request, invalid token, expired token, unsupported PCI, forbidden).
430
334
 
431
335
  ```ts
432
- import * as errors from "@superbuilders/errors"
433
- import {
434
- ErrInvalidSubmission,
435
- ErrNetwork,
436
- ErrRateLimited,
437
- ErrUnsupportedPci,
438
- } from "@superbuilders/primer-tives"
439
-
440
- if (errors.is(state.error, ErrNetwork)) {
441
- // handle offline / DNS / CORS / fetch failure
336
+ interface FatalState {
337
+ readonly phase: "fatal"
338
+ readonly error: Error
339
+ readonly retriable: false
442
340
  }
443
341
  ```
444
342
 
445
- ### Surfaced as `errored`
446
-
447
- | Sentinel | Meaning | `state.retriable` |
448
- |---|---|---|
449
- | `ErrNetwork` | fetch failed before a response was received | `true` |
450
- | `ErrTimeout` | request was aborted or timed out | `true` |
451
- | `ErrRateLimited` | `429` | `true` |
452
- | `ErrServiceUnavailable` | `502`, `503`, or `504` | `true` |
453
- | `ErrServerError` | any other unhandled HTTP error status | `true` |
454
- | `ErrJsonParse` | response body was not valid JSON | `true` |
455
- | `ErrConflict` | `409`, or a local conflicting in-flight action | `true` |
456
- | `ErrInvalidSubmission` | client-side validation failed | `false` |
457
-
458
- ### Permanent: surfaced as `fatal`
459
-
460
- | Sentinel | Meaning |
461
- |---|---|
462
- | `ErrBadRequest` | `400` |
463
- | `ErrInvalidPublishableKey` | `401` |
464
- | `ErrForbidden` | `403` |
465
- | `ErrNotFound` | `404` |
466
- | `ErrUnsupportedPci` | server rejected the request with `422`, or the server returned a PCI frame the client did not advertise in `supportedPcis` |
343
+ ### `completed`
467
344
 
468
- ### Thrown directly
345
+ Terminal. Session is done.
469
346
 
470
- | Sentinel | Meaning |
471
- |---|---|
472
- | `ErrMalformedPublishableKey` | `create()` received a key that does not start with `pk_` |
473
- | `ErrNotSerializable` | code attempted to serialize a live `PrimerState` |
347
+ ```ts
348
+ interface CompletedState {
349
+ readonly phase: "completed"
350
+ }
351
+ ```
474
352
 
475
353
  ## Content format
476
354
 
@@ -486,172 +364,107 @@ type ContentInline =
486
364
  type ContentBlock = { type: "paragraph"; children: ContentInline[] }
487
365
  ```
488
366
 
489
- ### Helpers
367
+ All three inline variants share the uniform `{ type, value: string }` shape. `ContentSpan` covers HTML-ish rich-text formatting. `latex` is for inline math expressions (render via Temml).
368
+
369
+ Helpers:
490
370
 
491
371
  ```ts
492
372
  inlinesToPlainText(nodes: ContentInline[]): string
493
373
  blocksToPlainText(blocks: ContentBlock[]): string
494
374
  ```
495
375
 
496
- - `inlinesToPlainText()` strips formatting and concatenates inline values
497
- - `blocksToPlainText()` flattens block content with newlines between paragraphs
498
-
499
- Typical use cases:
500
-
501
- - accessibility labels
502
- - plain-text fallbacks
503
- - analytics or logging output that should ignore inline formatting
504
-
505
- ## Stimulus model
376
+ ## PCI system (Portable Custom Interactions)
506
377
 
507
378
  ```ts
508
- interface BodyStimulus {
509
- type: "body"
510
- body: ContentBlock[]
511
- }
512
-
513
- interface ImageStimulus {
514
- type: "image"
515
- description: ContentInline[]
516
- src: string
517
- }
518
-
519
- type RendererStimulus = BodyStimulus | ImageStimulus
520
- ```
521
-
522
- A frame can also have `stimulus: null`.
523
-
524
- ## PCI system
525
-
526
- Portable Custom Interactions let Primer serve domain-specific input types while preserving compile-time type safety.
527
-
528
- ### Registry
379
+ type PciUrn = "urn:primer:pci:division-remainder" | "urn:primer:pci:fraction-addition"
529
380
 
530
- ```ts
531
- interface PciRegistry {
381
+ type PciRegistry = {
532
382
  "urn:primer:pci:division-remainder": {
533
- props: { dividend: number; divisor: number }
534
- value: { quotient: string; remainder: string }
383
+ props: DivisionRemainderProps
384
+ value: DivisionRemainderSubmission
535
385
  }
536
386
  "urn:primer:pci:fraction-addition": {
537
- props: {
538
- left: { numerator: number; denominator: number }
539
- right: { numerator: number; denominator: number }
540
- }
541
- value: { numerator: string; denominator: string }
387
+ props: FractionAdditionProps
388
+ value: FractionAdditionSubmission
542
389
  }
543
390
  }
544
- ```
545
-
546
- ### Type helpers
547
391
 
548
- ```ts
549
- type PciUrn = `urn:primer:pci:${string}`
550
- type PciId = keyof PciRegistry & string
392
+ type PciId = keyof PciRegistry
551
393
  type PciProps<K extends PciId> = PciRegistry[K]["props"]
552
394
  type PciValue<K extends PciId> = PciRegistry[K]["value"]
553
395
  ```
554
396
 
555
- ### PCI renderer props
556
-
557
- ```ts
558
- type PciRenderProps<K extends PciId> =
559
- | {
560
- mode: "pending"
561
- properties: PciProps<K>
562
- onValueChange: (value: PciValue<K> | null) => void
563
- }
564
- | {
565
- mode: "submitted"
566
- properties: PciProps<K>
567
- submission: PciValue<K>
568
- review: Extract<InteractionReview<K>, { type: "portable-custom"; pciId: K }> | null
569
- }
570
- ```
571
-
572
- ### Adding a PCI
573
-
574
- 1. Add a new entry to `PciRegistry` in `src/pci.ts`
575
- 2. Use the URN format `urn:primer:pci:<name>`
576
- 3. Add that PCI URN to `supportedPcis` when creating the client
577
- 4. Make sure your renderer knows how to render and collect a `PciValue` for that PCI
578
-
579
- ## Import guide
397
+ Declare the PCI URNs your renderer can handle via `Config.supportedPcis`. Frames requiring an unsupported PCI resolve to `fatal` with `ErrUnsupportedPci`.
580
398
 
581
- Everything is exported from the package root:
399
+ ### Renderer props
582
400
 
583
401
  ```ts
584
- import {
585
- ADVANCE_PATH,
586
- ErrConflict,
587
- ErrInvalidSubmission,
588
- ErrMalformedPublishableKey,
589
- ErrNetwork,
590
- ErrTimeout,
591
- ErrUnsupportedPci,
592
- blocksToPlainText,
593
- create,
594
- inlinesToPlainText,
595
- } from "@superbuilders/primer-tives"
596
-
597
- import type {
598
- ChoiceState,
599
- Client,
600
- Config,
601
- ContentBlock,
602
- ContentInline,
603
- FeedbackState,
604
- MatchState,
605
- ObservationState,
606
- OrderState,
607
- PciId,
608
- PciProps,
609
- PciRenderProps,
610
- PciValue,
611
- PrimerLogger,
612
- PrimerState,
613
- RendererInteraction,
614
- RendererStimulus,
615
- RendererSubmission,
616
- } from "@superbuilders/primer-tives"
617
- ```
618
-
619
- ## Behavioral notes
620
-
621
- ### States are not serializable
622
-
623
- `PrimerState` is live in-memory state that contains closures. Every state object has a poisoned `toJSON()` that throws `ErrNotSerializable`.
402
+ type PciPendingRenderProps<K extends PciId> = {
403
+ mode: "pending"
404
+ properties: PciProps<K>
405
+ onValueChange: (value: PciValue<K> | null) => void
406
+ }
624
407
 
625
- Do **not**:
408
+ type PciSubmittedRenderProps<K extends PciId> = {
409
+ mode: "submitted"
410
+ properties: PciProps<K>
411
+ submission: PciValue<K>
412
+ review: Extract<InteractionReview<K>, { type: "portable-custom"; pciId: K }> | null
413
+ }
626
414
 
627
- - `JSON.stringify(state)`
628
- - persist a state object to storage
629
- - send a state object over the network
630
- - treat a state object as a durable snapshot
415
+ type PciRenderProps<K extends PciId> = PciPendingRenderProps<K> | PciSubmittedRenderProps<K>
416
+ ```
631
417
 
632
- Instead, keep it in memory and render it immediately.
418
+ ## Error sentinels (`/client`)
633
419
 
634
- ### Repeated actions are deduplicated narrowly
420
+ ### Surfaced as `errored`
635
421
 
636
- State objects memoize only true re-entry of the same in-flight action.
422
+ | Sentinel | Raised when |
423
+ |---|---|
424
+ | `ErrNetwork` | fetch() rejected |
425
+ | `ErrTimeout` | fetch() aborted |
426
+ | `ErrServerError` | HTTP 5xx |
427
+ | `ErrServiceUnavailable` | HTTP 502/503/504 |
428
+ | `ErrRateLimited` | HTTP 429 |
429
+ | `ErrConflict` | HTTP 409 |
430
+ | `ErrJsonParse` | Success response body wasn't valid JSON |
431
+ | `ErrInvalidSubmission` | client-side validation rejected the submission |
432
+
433
+ ### Surfaced as `fatal`
434
+
435
+ | Sentinel | Raised when |
436
+ |---|---|
437
+ | `ErrBadRequest` | HTTP 400 |
438
+ | `ErrInvalidAccessToken` | HTTP 401 |
439
+ | `ErrTokenExpired` | HTTP 401 with token-expired detail |
440
+ | `ErrForbidden` | HTTP 403 |
441
+ | `ErrNotFound` | HTTP 404 |
442
+ | `ErrUnsupportedPci` | HTTP 422 or a frame asks for a PCI not in `supportedPcis` |
637
443
 
638
- - calling `advance()` twice on the same observation state returns the same promise
639
- - calling the same submit method twice with the same payload returns the same promise
640
- - calling `retry()` twice on the same errored state returns the same promise
641
- - submit and timeout do **not** alias to each other anymore
642
- - conflicting in-flight actions surface `ErrConflict` instead of silently reusing the wrong promise
444
+ ### Thrown directly by `create()`
643
445
 
644
- ### `start()` is memoized per client instance
446
+ | Sentinel | Raised when |
447
+ |---|---|
448
+ | `ErrMalformedAccessToken` | token doesn't start with `eyJ` or lacks two dots |
449
+ | `ErrNotSerializable` | you called `JSON.stringify()` on a live `PrimerState` (don't) |
645
450
 
646
- A `Client` instance memoizes the first `start()` call and returns the same promise on later calls. In practice, create a fresh client instance per independently managed session.
451
+ ## Logger interface
647
452
 
648
- ## Build and publish
453
+ ```ts
454
+ interface PrimerLogger {
455
+ debug(message: string, attributes?: Record<string, unknown>): void
456
+ info(message: string, attributes?: Record<string, unknown>): void
457
+ warn(message: string, attributes?: Record<string, unknown>): void
458
+ error(message: string, attributes?: Record<string, unknown>): void
459
+ }
460
+ ```
649
461
 
650
- The package is built as browser-targeted ESM from `src/index.ts`, emits declaration files, and publishes `dist/` plus this README.
462
+ Same shape on both sides. Plug in your slog/pino/console adapter.
651
463
 
652
- Useful package scripts:
464
+ ## Behavioral notes
653
465
 
654
- ```sh
655
- bun run build
656
- bun run typecheck
657
- ```
466
+ - **`start()` is idempotent.** Calling it twice returns the same promise.
467
+ - **Retry semantics.** `errored.retry()` re-runs the same intent. `errored.retriable === false` for client-validation errors (e.g. `ErrInvalidSubmission`) — those must be fixed, not retried.
468
+ - **Session resumption.** A returning student resumes wherever the server last placed them. No client-side cursor management.
469
+ - **Live state.** `PrimerState` holds real closures (action methods, pending-promise caches). Don't serialize it; don't store it across reloads. Call `start()` again on boot.
470
+ - **PCI type safety.** `Config.supportedPcis` is a `const` generic; only those URNs flow through `PciInteractionState`/`PciSubmission` at the type level. Mismatch at runtime is a `fatal` with `ErrUnsupportedPci`.