@superbuilders/primer-tives 3.5.0 → 3.6.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
@@ -2,13 +2,14 @@
2
2
 
3
3
  TypeScript SDK primitives for the Primer adaptive learning runtime.
4
4
 
5
- The public lifecycle is one async call:
5
+ The public lifecycle starts with one async call and one optional auth transition:
6
6
 
7
7
  ```txt
8
- create(options) -> Promise<PrimerState>
8
+ start(options) -> Promise<PrimerState>
9
+ UnauthenticatedState.login() -> Promise<PrimerState>
9
10
  ```
10
11
 
11
- `create` resolves learner auth, opens the first Primer frame, and returns a live learning state. There is no public auth result union, client wrapper, `start()` method, `snapshot()` API, or branded state type.
12
+ `start` resolves existing learner auth when available, enters the first Primer learning state when the learner is ready, and returns the live state machine object your renderer drives. When learner sign-in is needed, `start` returns `UnauthenticatedState`; render a sign-in button and call `state.login()` directly from that button's click or tap handler.
12
13
 
13
14
  ```sh
14
15
  bun add @superbuilders/primer-tives
@@ -16,80 +17,163 @@ bun add @superbuilders/primer-tives
16
17
 
17
18
  ## Version
18
19
 
19
- The current SDK version is `3.5.0`.
20
-
21
- The SDK sends `X-Primer-SDK-Version: 3.5.0` on `/api/v0/advance`.
20
+ The current SDK version is `3.6.0`.
22
21
 
23
22
  ## Entrypoints
24
23
 
25
- There is no package-root export.
24
+ There is no package-root export. Import from the public subpath that owns the surface you need.
26
25
 
27
26
  | Subpath | Owns |
28
27
  | --- | --- |
29
- | `@superbuilders/primer-tives/client` | `create`, `PrimerOptions`, `PrimerState`, state interfaces, PCI render props |
30
- | `@superbuilders/primer-tives/contracts` | Content, stimulus, interaction, submission, review, PCI types, validation helpers |
31
- | `@superbuilders/primer-tives/errors` | SDK error sentinels |
28
+ | `@superbuilders/primer-tives/client` | `start`, `PrimerOptions`, `PrimerState`, all state interfaces, PCI render props |
29
+ | `@superbuilders/primer-tives/contracts` | Content, stimulus, interaction, submission, review, PCI types, schemas, validation helpers |
30
+ | `@superbuilders/primer-tives/errors` | Every SDK error sentinel |
32
31
  | `@superbuilders/primer-tives/logger` | `PrimerLogger` interface |
33
32
  | `@superbuilders/primer-tives/subject` | `Subject`, `SUBJECTS` |
34
- | `@superbuilders/primer-tives/subject-pcis` | Subject-required PCI helpers |
33
+ | `@superbuilders/primer-tives/subject-pcis` | Subject-required PCI helpers and type helpers |
35
34
  | `@superbuilders/primer-tives/grade-level` | `GradeLevel`, `GRADE_LEVELS` |
36
35
 
37
36
  ## Quick Start
38
37
 
39
- Math requires the fraction-input PCI capability:
38
+ Math content can require the fraction-input PCI capability, so a math renderer must declare it.
40
39
 
41
40
  ```ts
42
41
  import * as logger from "@superbuilders/slog"
43
- import { create } from "@superbuilders/primer-tives/client"
42
+ import { start, type PrimerOptions } from "@superbuilders/primer-tives/client"
44
43
 
45
- const state = await create({
44
+ const options = {
46
45
  origin: "https://primerlearn.dev",
47
46
  publishableKey: "pk_...",
48
47
  subject: "math",
49
48
  supportedPcis: ["urn:primer:pci:fraction-input"],
50
49
  logger
51
- })
50
+ } satisfies PrimerOptions<"math", readonly ["urn:primer:pci:fraction-input"]>
51
+
52
+ let state = await start(options)
52
53
  ```
53
54
 
54
- If you already have a Cognito/OIDC access token, pass it directly:
55
+ If your application already has a learner access token, pass it directly. `start` uses that token for the learning runtime.
55
56
 
56
57
  ```ts
57
- const state = await create({
58
+ const options = {
58
59
  origin: "https://primerlearn.dev",
59
60
  publishableKey: "pk_...",
60
61
  accessToken,
61
62
  subject: "math",
62
63
  supportedPcis: ["urn:primer:pci:fraction-input"],
63
64
  logger
64
- })
65
+ } satisfies PrimerOptions<"math", readonly ["urn:primer:pci:fraction-input"]>
66
+
67
+ let state = await start(options)
65
68
  ```
66
69
 
67
- Vocabulary has no required PCI capability, so `supportedPcis` can be omitted:
70
+ When `start` returns `UnauthenticatedState`, render sign-in UI and call `login()` directly from a user gesture:
68
71
 
69
72
  ```ts
70
- const state = await create({
73
+ import type { PrimerState, UnauthenticatedState } from "@superbuilders/primer-tives/client"
74
+
75
+ let state: PrimerState = await start(options)
76
+
77
+ if (state.phase === "unauthenticated") {
78
+ renderSignInButton(state)
79
+ }
80
+
81
+ function handleSignInClick(authState: UnauthenticatedState): void {
82
+ void authState.login().then(function continueAfterLogin(nextState) {
83
+ state = nextState
84
+ renderPrimer(state)
85
+ })
86
+ }
87
+ ```
88
+
89
+ For Chrome and other popup blockers, `login()` must be called directly from the click or tap handler. Do not put `await`, `setTimeout`, dynamic imports, analytics calls, or other async work before `state.login()`.
90
+
91
+ Vocabulary and science currently have no required PCI capabilities, so `supportedPcis` can be omitted for those subjects.
92
+
93
+ ```ts
94
+ const options = {
71
95
  origin: "https://primerlearn.dev",
72
96
  publishableKey: "pk_...",
73
97
  subject: "vocabulary",
74
98
  logger
75
- })
99
+ } satisfies PrimerOptions<"vocabulary">
100
+
101
+ let state = await start(options)
76
102
  ```
77
103
 
78
- Omitting `subject` means the SDK asks Primer for the internal all-subject runtime scope. Because that can include any subject, the required PCI set is the union of all subject-required PCIs:
104
+ Omitting `subject` means the SDK asks Primer for the all-subject runtime scope. Because that scope can include math, it requires the union of all subject-required PCIs.
79
105
 
80
106
  ```ts
81
- const state = await create({
107
+ const options = {
82
108
  origin: "https://primerlearn.dev",
83
109
  publishableKey: "pk_...",
84
110
  supportedPcis: ["urn:primer:pci:fraction-input"],
85
111
  logger
86
- })
112
+ } satisfies PrimerOptions<undefined, readonly ["urn:primer:pci:fraction-input"]>
113
+
114
+ let state = await start(options)
115
+ ```
116
+
117
+ ## `start(options)`
118
+
119
+ ```ts
120
+ function start<
121
+ const S extends Subject | undefined = undefined,
122
+ const Supported extends readonly PciId[] = []
123
+ >(options: PrimerOptions<S, Supported>): Promise<PrimerState>
124
+ ```
125
+
126
+ `start` is the first SDK lifecycle operation. It may return any current state the renderer must handle:
127
+
128
+ | Result | Meaning |
129
+ | --- | --- |
130
+ | `UnauthenticatedState` | Learner sign-in is needed before runtime learning can begin. |
131
+ | `ObservationState`, `InteractionState`, or `FeedbackState` | A learning state is ready to render. |
132
+ | `CompletedState` | The runtime scope is already complete. |
133
+ | `ErroredState` | Startup or runtime communication failed but may be retriable. |
134
+ | `FatalState` | Startup or runtime communication failed terminally for this state object. |
135
+
136
+ Always switch on `state.phase`. Do not assume the first state is renderable learning content.
137
+
138
+ ```ts
139
+ import * as errors from "@superbuilders/errors"
140
+ import * as logger from "@superbuilders/slog"
141
+ import { start, type PrimerOptions } from "@superbuilders/primer-tives/client"
142
+ import { ErrAuthUnavailable, ErrMalformedAccessToken } from "@superbuilders/primer-tives/errors"
143
+
144
+ const options = {
145
+ origin,
146
+ publishableKey,
147
+ accessToken,
148
+ subject: "math",
149
+ supportedPcis: ["urn:primer:pci:fraction-input"],
150
+ logger
151
+ } satisfies PrimerOptions<"math", readonly ["urn:primer:pci:fraction-input"]>
152
+
153
+ let state = await start(options)
154
+ if (state.phase === "unauthenticated") {
155
+ if (errors.is(state.error, ErrAuthUnavailable)) {
156
+ renderUnsupportedBrowserMessage()
157
+ return
158
+ }
159
+ renderSignInButton(state)
160
+ return
161
+ }
162
+
163
+ if (state.phase === "fatal") {
164
+ if (errors.is(state.error, ErrMalformedAccessToken)) {
165
+ renderSignInAgain()
166
+ return
167
+ }
168
+ logger.error("primer fatal state", { error: state.error })
169
+ throw state.error
170
+ }
87
171
  ```
88
172
 
89
- ## PrimerOptions
173
+ ## `PrimerOptions`
90
174
 
91
175
  ```ts
92
- type PrimerOptions<S extends Subject | undefined = undefined, Pcis extends PciId = never> = {
176
+ type PrimerOptions<S extends Subject | undefined = undefined, Supported extends readonly PciId[] = []> = {
93
177
  readonly origin: string
94
178
  readonly publishableKey: string
95
179
  readonly accessToken?: string
@@ -101,53 +185,109 @@ type PrimerOptions<S extends Subject | undefined = undefined, Pcis extends PciId
101
185
  }
102
186
  ```
103
187
 
104
- | Field | Meaning |
188
+ | Field | Required | Meaning |
189
+ | --- | --- | --- |
190
+ | `origin` | Yes | Primer deployment origin. |
191
+ | `publishableKey` | Yes | Public key identifying the Primer frontend your runtime belongs to. |
192
+ | `accessToken` | No | Learner access token. When present, `start` uses it for the learning runtime. |
193
+ | `subject` | No | Public content scope: `"math"`, `"vocabulary"`, or `"science"`. Omitted means all-subject scope. |
194
+ | `supportedPcis` | Subject-dependent | Renderer capabilities for Portable Custom Interactions. Required when the chosen scope can emit required PCIs. |
195
+ | `fetch` | No | Fetch override for tests, instrumentation, or host runtime integration. |
196
+ | `abort` | No | Abort controller for SDK runtime work. |
197
+ | `logger` | Yes | Structured logger implementing `debug`, `info`, `warn`, and `error`. |
198
+
199
+ The presence or absence of `accessToken` selects startup auth semantics.
200
+
201
+ | Shape | Semantics |
105
202
  | --- | --- |
106
- | `origin` | Primer deployment origin. |
107
- | `publishableKey` | Public Primer frontend key. |
108
- | `accessToken` | Optional Cognito/OIDC learner access token. When present, hosted auth is skipped. |
109
- | `subject` | Optional content scope. Omitted means internal all-subject scope. Public callers do not pass `"all"`. |
110
- | `supportedPcis` | Renderer PCI capabilities. Required or optional based on `subject`. |
111
- | `fetch` | Optional fetch override for tests/instrumentation. |
112
- | `abort` | Optional abort controller for runtime requests. |
113
- | `logger` | Structured logger implementing `debug`, `info`, `warn`, and `error`. |
114
-
115
- The presence or absence of `accessToken` selects auth behavior:
116
-
117
- | Shape | Behavior |
203
+ | `accessToken` present | `start` validates the token shape locally and uses it for learning runtime state. |
204
+ | `accessToken` absent | `start` uses existing browser auth state when available, or returns `UnauthenticatedState` when learner sign-in is needed. |
205
+
206
+ The public API exposes auth as state behavior. `UnauthenticatedState.login()` is the sign-in transition.
207
+
208
+ When `subject` is present, it must be a concrete literal subject at the options declaration site. Do not pass a broad `Subject` value into `PrimerOptions`; narrow it in host control flow, then construct options with `satisfies PrimerOptions<"math", ...>`, `satisfies PrimerOptions<"vocabulary", ...>`, or `satisfies PrimerOptions<"science", ...>`.
209
+
210
+ ## Auth Semantics
211
+
212
+ An access token is expected to be JWS-shaped: it starts with `eyJ` and contains exactly two dots. If a provided token does not match that shape, `start` returns `FatalState` with `ErrMalformedAccessToken`.
213
+
214
+ SDK-managed auth may require browser capabilities and learner interaction. Auth failures are exposed through `UnauthenticatedState.error`:
215
+
216
+ | Sentinel | Meaning |
118
217
  | --- | --- |
119
- | `accessToken` present | Use provided access token, then call `/api/v0/advance`. |
120
- | `accessToken` absent | Resolve a token through Primer-hosted auth, then call `/api/v0/advance`. |
218
+ | `ErrAuthUnavailable` | SDK-managed auth requires browser functionality that is unavailable in the current runtime. |
219
+ | `ErrAuthConfigInvalid` | SDK-managed auth was given invalid public configuration. |
220
+ | `ErrAuthCallbackInvalid` | The auth result could not be accepted as a successful learner auth result. |
221
+ | `ErrAuthStateMismatch` | The auth result did not match the auth attempt that initiated it. |
222
+ | `ErrAuthPopupBlocked` | The browser blocked the learner auth window. |
223
+ | `ErrAuthCancelled` | The learner auth interaction was closed or exceeded its allowed time. |
224
+ | `ErrMalformedAccessToken` | The resolved token was not shaped like a learner access token. |
121
225
 
122
- Hosted auth storage, callback parsing, popup details, and state handling are SDK internals.
226
+ Applications should handle user-actionable auth failures directly and log unexpected failures before propagating them.
227
+
228
+ ```ts
229
+ import * as errors from "@superbuilders/errors"
230
+ import * as logger from "@superbuilders/slog"
231
+ import {
232
+ ErrAuthCancelled,
233
+ ErrAuthPopupBlocked,
234
+ ErrAuthUnavailable
235
+ } from "@superbuilders/primer-tives/errors"
236
+
237
+ let state = await start(options)
238
+ if (state.phase === "unauthenticated") {
239
+ if (errors.is(state.error, ErrAuthPopupBlocked)) {
240
+ renderPopupInstructions(state)
241
+ return
242
+ }
243
+ if (errors.is(state.error, ErrAuthCancelled)) {
244
+ renderTryAgain(state)
245
+ return
246
+ }
247
+ if (errors.is(state.error, ErrAuthUnavailable)) {
248
+ renderUnsupportedBrowserMessage()
249
+ return
250
+ }
251
+ if (state.error !== null) {
252
+ logger.error("primer auth failed", { error: state.error })
253
+ }
254
+ renderSignInButton(state)
255
+ }
256
+ ```
123
257
 
124
258
  ## Subject And PCI Contract
125
259
 
126
260
  `subject` selects content scope and determines required renderer capabilities.
127
261
 
128
- `supportedPcis` declares which Portable Custom Interactions the host renderer can render. It may be a superset of the required PCIs.
262
+ ```ts
263
+ import { SUBJECTS } from "@superbuilders/primer-tives/subject"
264
+ import type { Subject } from "@superbuilders/primer-tives/subject"
129
265
 
130
- The SDK enforces subject-required PCI capability in TypeScript:
266
+ const subjects = SUBJECTS
267
+ type RuntimeSubject = Subject
268
+ ```
269
+
270
+ Current public subjects:
131
271
 
132
- | Public subject option | Required `supportedPcis` |
272
+ | Subject | Required PCI support |
133
273
  | --- | --- |
134
- | omitted | union of all subject-required PCIs, currently `"urn:primer:pci:fraction-input"` |
135
274
  | `"math"` | `"urn:primer:pci:fraction-input"` |
136
275
  | `"vocabulary"` | none |
137
276
  | `"science"` | none |
277
+ | omitted subject | union of all subject-required PCIs, currently `"urn:primer:pci:fraction-input"` |
138
278
 
139
- The check is a subset check:
279
+ The type-level rule is a subset check:
140
280
 
141
281
  ```txt
142
- required PCIs for subject supportedPcis
282
+ required PCIs for selected scope <= supportedPcis
143
283
  ```
144
284
 
145
- Order does not matter. Extra supported PCIs are allowed.
285
+ Order does not matter. Extra supported PCIs are allowed. `supportedPcis` is a renderer capability declaration, not a content request.
146
286
 
147
- This fails at compile time:
287
+ This fails at compile time because math can emit a required PCI:
148
288
 
149
289
  ```ts
150
- await create({
290
+ await start({
151
291
  origin,
152
292
  publishableKey,
153
293
  subject: "math",
@@ -158,25 +298,86 @@ await create({
158
298
  This passes:
159
299
 
160
300
  ```ts
161
- await create({
301
+ const options = {
162
302
  origin,
163
303
  publishableKey,
164
304
  subject: "math",
165
305
  supportedPcis: ["urn:primer:pci:fraction-input"],
166
306
  logger
167
- })
307
+ } satisfies PrimerOptions<"math", readonly ["urn:primer:pci:fraction-input"]>
308
+
309
+ await start(options)
168
310
  ```
169
311
 
170
- If `subject` is a broad dynamic `Subject` value, TypeScript requires the union of possible subject-required PCIs. This lets renderer wrappers pass their full supported PCI registry once and get subject-level safety for free.
312
+ The runtime also protects the trust boundary. If Primer presents a portable custom interaction that is not declared in `supportedPcis`, the SDK returns `FatalState` with `ErrUnsupportedPci`.
313
+
314
+ ## Runtime Loop
315
+
316
+ Every renderer should switch on `state.phase`. Interaction rendering should then switch on `state.kind`.
171
317
 
172
- The runtime still protects the trust boundary. If the server returns a PCI not declared in `supportedPcis`, the SDK returns `FatalState` with `ErrUnsupportedPci`.
318
+ ```ts
319
+ import * as logger from "@superbuilders/slog"
320
+ import type { PrimerState } from "@superbuilders/primer-tives/client"
321
+
322
+ async function runPrimer(initialState: PrimerState): Promise<void> {
323
+ let state = initialState
324
+
325
+ while (state.phase !== "completed" && state.phase !== "fatal") {
326
+ switch (state.phase) {
327
+ case "unauthenticated":
328
+ renderSignInButton(state)
329
+ return
330
+ case "observation":
331
+ renderFrame(state.body, state.stimulus)
332
+ state = await state.advance()
333
+ break
334
+ case "interaction":
335
+ state = await renderAndSubmitInteraction(state)
336
+ break
337
+ case "feedback":
338
+ renderFeedback(state.feedbackContent, state.isCorrect, state.review)
339
+ state = await state.advance()
340
+ break
341
+ case "errored":
342
+ if (state.retriable) {
343
+ state = await state.retry()
344
+ break
345
+ }
346
+ logger.error("primer state error", { error: state.error })
347
+ throw state.error
348
+ }
349
+ }
173
350
 
174
- ## PrimerState
351
+ if (state.phase === "fatal") {
352
+ logger.error("primer fatal state", { error: state.error })
353
+ throw state.error
354
+ }
355
+ }
356
+ ```
175
357
 
176
- `create` returns a `PrimerState` only after auth and the first runtime request have succeeded.
358
+ State transitions:
359
+
360
+ | Current state | Valid operation | Next result |
361
+ | --- | --- | --- |
362
+ | `UnauthenticatedState` | `login()` | `Promise<PrimerState>` |
363
+ | `ObservationState` | `advance()` | `Promise<PrimerState>` |
364
+ | `ChoiceState` | `submitChoice(selectedKeys)` or `timeout()` | `Promise<PrimerState>` |
365
+ | `TextEntryState` | `submitText(value)` or `timeout()` | `Promise<PrimerState>` |
366
+ | `ExtendedTextSingleState` | `submitText(value)` or `timeout()` | `Promise<PrimerState>` |
367
+ | `ExtendedTextMultipleState` | `submitTexts(values)` or `timeout()` | `Promise<PrimerState>` |
368
+ | `OrderState` | `submitOrder(orderedKeys)` or `timeout()` | `Promise<PrimerState>` |
369
+ | `MatchState` | `submitMatch(pairs)` or `timeout()` | `Promise<PrimerState>` |
370
+ | `PciInteractionState` | `submit(value)` or `timeout()` | `Promise<PrimerState>` |
371
+ | `FeedbackState` | `advance()` | `Promise<PrimerState>` |
372
+ | `CompletedState` | none | terminal |
373
+ | `ErroredState` | `retry()` when `retriable` is `true` | `Promise<PrimerState>` |
374
+ | `FatalState` | none | terminal |
375
+
376
+ ## `PrimerState`
177
377
 
178
378
  ```ts
179
379
  type PrimerState<Pcis extends PciId = PciId> =
380
+ | UnauthenticatedState<Pcis>
180
381
  | ObservationState<Pcis>
181
382
  | InteractionState<Pcis>
182
383
  | FeedbackState<Pcis>
@@ -185,168 +386,976 @@ type PrimerState<Pcis extends PciId = PciId> =
185
386
  | FatalState
186
387
  ```
187
388
 
188
- The state machine is structural and discriminated by `phase`, then by `kind` for interactions. Each state exposes only transitions valid from that state.
389
+ `PrimerState` is live in-memory state. It contains transition closures, pending-operation guards, and retry behavior. Do not serialize it, store it, clone it through JSON, or pass it through host data. Calling `JSON.stringify(state)` throws `ErrNotSerializable`.
189
390
 
190
- ```txt
191
- observation -> advance
192
- feedback -> advance
193
- choice -> submitChoice / timeout
194
- text-entry -> submitText / timeout
195
- extended-text -> submitText or submitTexts / timeout
196
- order -> submitOrder / timeout
197
- match -> submitMatch / timeout
198
- portable-custom -> submit / timeout
199
- completed -> no transition
200
- fatal -> no transition
391
+ Start a new state by calling `start` again after a reload, remount, account switch, or subject switch.
392
+
393
+ ## `UnauthenticatedState`
394
+
395
+ ```ts
396
+ interface UnauthenticatedState<Pcis extends PciId = PciId> {
397
+ readonly phase: "unauthenticated"
398
+ readonly error: Error | null
399
+ login(): Promise<PrimerState<Pcis>>
400
+ }
201
401
  ```
202
402
 
203
- The state object is live and non-serializable. Do not put it in storage, JSON, or server data.
403
+ `UnauthenticatedState` means learner sign-in is required before learning content can be rendered. Render a sign-in button or equivalent learner action. Call `login()` only from that user action.
204
404
 
205
- ## Runtime Loop
405
+ Correct browser-safe pattern:
206
406
 
207
407
  ```ts
208
- let state = await create({
209
- origin,
210
- publishableKey,
211
- subject: "math",
212
- supportedPcis: ["urn:primer:pci:fraction-input"],
213
- logger
214
- })
408
+ function handleSignInClick(state: UnauthenticatedState): void {
409
+ void state.login().then(function continueAfterLogin(nextState) {
410
+ renderPrimer(nextState)
411
+ })
412
+ }
413
+ ```
215
414
 
216
- while (state.phase !== "completed" && state.phase !== "fatal") {
217
- switch (state.phase) {
218
- case "observation":
219
- renderFrame(state.body, state.stimulus)
220
- state = await state.advance()
221
- break
222
- case "interaction":
223
- state = await renderAndSubmitInteraction(state)
224
- break
225
- case "feedback":
226
- renderFeedback(state.feedbackContent, state.isCorrect, state.review)
227
- state = await state.advance()
228
- break
229
- case "errored":
230
- if (state.retriable) {
231
- state = await state.retry()
232
- break
233
- }
234
- throw state.error
415
+ React renderers should use the same direct-call rule:
416
+
417
+ ```tsx
418
+ function SignInButton({ state }: { state: UnauthenticatedState }) {
419
+ async function handleClick() {
420
+ const nextState = await state.login()
421
+ renderPrimer(nextState)
235
422
  }
423
+
424
+ return <button type="button" onClick={handleClick}>Sign in to continue</button>
236
425
  }
426
+ ```
237
427
 
238
- if (state.phase === "fatal") {
239
- throw state.error
428
+ For Chrome popup blocking, `state.login()` must be the first async-producing operation in the click or tap handler. This is correct:
429
+
430
+ ```ts
431
+ async function handleClick() {
432
+ const nextState = await state.login()
433
+ renderPrimer(nextState)
434
+ }
435
+ ```
436
+
437
+ This is not browser-safe:
438
+
439
+ ```ts
440
+ async function handleClick() {
441
+ await recordAnalyticsClick()
442
+ const nextState = await state.login()
443
+ renderPrimer(nextState)
444
+ }
445
+ ```
446
+
447
+ If `login()` cannot complete auth, it resolves to another `UnauthenticatedState` with `error` set to the auth sentinel. Render retry or unsupported-browser UI from that state.
448
+
449
+ ## Common State Fields
450
+
451
+ Learning states that render content expose `body` and `stimulus`.
452
+
453
+ ```ts
454
+ interface RenderableState {
455
+ readonly body: ContentBlock[]
456
+ readonly stimulus: RendererStimulus | null
457
+ }
458
+ ```
459
+
460
+ `body` is the main instructional content. `stimulus` is optional supporting material. Current stimulus support is image-only, but it is still a discriminated union so renderers can remain future-safe.
461
+
462
+ ## `ObservationState`
463
+
464
+ ```ts
465
+ interface ObservationState<Pcis extends PciId = PciId> {
466
+ readonly phase: "observation"
467
+ readonly body: ContentBlock[]
468
+ readonly stimulus: RendererStimulus | null
469
+ advance(): Promise<PrimerState<Pcis>>
470
+ }
471
+ ```
472
+
473
+ Render the frame, then call `advance()` when the learner is ready to continue. Observation states have no answer to submit.
474
+
475
+ Repeated `advance()` calls while the first one is pending return the same pending result.
476
+
477
+ ## `InteractionState`
478
+
479
+ ```ts
480
+ type InteractionState<Pcis extends PciId = PciId> =
481
+ | ChoiceState<Pcis>
482
+ | TextEntryState<Pcis>
483
+ | ExtendedTextState<Pcis>
484
+ | OrderState<Pcis>
485
+ | MatchState<Pcis>
486
+ | PciInteractionState<Pcis>
487
+ ```
488
+
489
+ Every interaction state includes:
490
+
491
+ | Field | Meaning |
492
+ | --- | --- |
493
+ | `phase: "interaction"` | State-machine discriminator. |
494
+ | `kind` | Renderer-facing interaction kind. |
495
+ | `body` | Frame content. |
496
+ | `stimulus` | Optional frame stimulus. |
497
+ | `interaction` | Full interaction contract object. |
498
+ | submit method | Kind-specific learner submission operation. |
499
+ | `timeout()` | Records that the learner timed out or the host chose to end the attempt without a submission. |
500
+
501
+ Submission methods validate standard interaction payloads before runtime submission. Invalid standard submissions return `ErroredState` with `ErrInvalidSubmission`.
502
+
503
+ Concurrent interaction operations are guarded:
504
+
505
+ | Situation | SDK behavior |
506
+ | --- | --- |
507
+ | Same submit payload while submit is pending | Returns the same pending result. |
508
+ | Different submit payload while submit is pending | Returns `ErroredState` with `ErrConflict`. |
509
+ | Submit while timeout is pending | Returns `ErroredState` with `ErrConflict`. |
510
+ | Timeout while submit is pending | Returns `ErroredState` with `ErrConflict`. |
511
+ | Repeated timeout while timeout is pending | Returns the same pending result. |
512
+
513
+ ## `ChoiceState`
514
+
515
+ ```ts
516
+ interface ChoiceState<Pcis extends PciId = PciId> {
517
+ readonly phase: "interaction"
518
+ readonly kind: "choice"
519
+ readonly body: ContentBlock[]
520
+ readonly stimulus: RendererStimulus | null
521
+ readonly interaction: Extract<StandardRendererInteraction, { type: "choice" }>
522
+ readonly options: RendererChoice[]
523
+ readonly minChoices: number
524
+ readonly maxChoices: number
525
+ submitChoice(selectedKeys: string[]): Promise<PrimerState<Pcis>>
526
+ timeout(): Promise<PrimerState<Pcis>>
527
+ }
528
+ ```
529
+
530
+ Use `minChoices` and `maxChoices` to decide whether the UI should submit immediately or require an explicit submit action.
531
+
532
+ Valid `submitChoice` payloads:
533
+
534
+ | Requirement | Error if violated |
535
+ | --- | --- |
536
+ | At least `minChoices` identifiers | `ErrInvalidSubmission` |
537
+ | At most `maxChoices` identifiers | `ErrInvalidSubmission` |
538
+ | Every identifier exists in `options` | `ErrInvalidSubmission` |
539
+ | No duplicate identifiers | `ErrInvalidSubmission` |
540
+
541
+ ## `TextEntryState`
542
+
543
+ ```ts
544
+ interface TextEntryState<Pcis extends PciId = PciId> {
545
+ readonly phase: "interaction"
546
+ readonly kind: "text-entry"
547
+ readonly body: ContentBlock[]
548
+ readonly stimulus: RendererStimulus | null
549
+ readonly interaction: Extract<StandardRendererInteraction, { type: "text-entry" }>
550
+ submitText(value: string): Promise<PrimerState<Pcis>>
551
+ timeout(): Promise<PrimerState<Pcis>>
552
+ }
553
+ ```
554
+
555
+ The interaction may include `expectedLength`, `patternMask`, and `placeholderText`. These are renderer hints. The SDK requires the submission to be a text-entry submission with a string value.
556
+
557
+ ## `ExtendedTextState`
558
+
559
+ Extended text has two cardinalities.
560
+
561
+ ```ts
562
+ interface ExtendedTextSingleState<Pcis extends PciId = PciId> {
563
+ readonly phase: "interaction"
564
+ readonly kind: "extended-text"
565
+ readonly cardinality: "single"
566
+ readonly body: ContentBlock[]
567
+ readonly stimulus: RendererStimulus | null
568
+ readonly interaction: Extract<
569
+ StandardRendererInteraction,
570
+ { type: "extended-text"; cardinality: "single" }
571
+ >
572
+ submitText(value: string): Promise<PrimerState<Pcis>>
573
+ timeout(): Promise<PrimerState<Pcis>>
574
+ }
575
+
576
+ interface ExtendedTextMultipleState<Pcis extends PciId = PciId> {
577
+ readonly phase: "interaction"
578
+ readonly kind: "extended-text"
579
+ readonly cardinality: "multiple"
580
+ readonly body: ContentBlock[]
581
+ readonly stimulus: RendererStimulus | null
582
+ readonly interaction: Extract<
583
+ StandardRendererInteraction,
584
+ { type: "extended-text"; cardinality: "multiple" }
585
+ >
586
+ readonly minStrings: number
587
+ readonly maxStrings: number
588
+ submitTexts(values: string[]): Promise<PrimerState<Pcis>>
589
+ timeout(): Promise<PrimerState<Pcis>>
240
590
  }
241
591
  ```
242
592
 
243
- ## PCI Type Safety
593
+ For single-cardinality extended text, call `submitText(value)`. For multiple-cardinality extended text, call `submitTexts(values)`.
244
594
 
245
- Portable Custom Interactions are typed by PCI id.
595
+ Valid multiple-cardinality payloads:
596
+
597
+ | Requirement | Error if violated |
598
+ | --- | --- |
599
+ | At least `minStrings` values | `ErrInvalidSubmission` |
600
+ | At most `maxStrings` values | `ErrInvalidSubmission` |
601
+ | No duplicate values | `ErrInvalidSubmission` |
602
+
603
+ `expectedLength`, `expectedLines`, `patternMask`, and `placeholderText` are renderer hints.
604
+
605
+ ## `OrderState`
246
606
 
247
607
  ```ts
248
- import type { PciProps, PciValue } from "@superbuilders/primer-tives/contracts"
608
+ interface OrderState<Pcis extends PciId = PciId> {
609
+ readonly phase: "interaction"
610
+ readonly kind: "order"
611
+ readonly body: ContentBlock[]
612
+ readonly stimulus: RendererStimulus | null
613
+ readonly interaction: Extract<StandardRendererInteraction, { type: "order" }>
614
+ readonly choices: RendererChoice[]
615
+ readonly minChoices: number
616
+ readonly maxChoices: number
617
+ submitOrder(orderedKeys: string[]): Promise<PrimerState<Pcis>>
618
+ timeout(): Promise<PrimerState<Pcis>>
619
+ }
620
+ ```
621
+
622
+ Submit identifiers in learner-selected order.
623
+
624
+ Valid `submitOrder` payloads:
625
+
626
+ | Requirement | Error if violated |
627
+ | --- | --- |
628
+ | At least `minChoices` identifiers | `ErrInvalidSubmission` |
629
+ | At most `maxChoices` identifiers | `ErrInvalidSubmission` |
630
+ | Every identifier exists in `choices` | `ErrInvalidSubmission` |
631
+ | No duplicate identifiers | `ErrInvalidSubmission` |
632
+
633
+ ## `MatchState`
634
+
635
+ ```ts
636
+ interface MatchPair {
637
+ source: string
638
+ target: string
639
+ }
640
+
641
+ interface MatchState<Pcis extends PciId = PciId> {
642
+ readonly phase: "interaction"
643
+ readonly kind: "match"
644
+ readonly body: ContentBlock[]
645
+ readonly stimulus: RendererStimulus | null
646
+ readonly interaction: Extract<StandardRendererInteraction, { type: "match" }>
647
+ readonly sourceChoices: RendererMatchChoice[]
648
+ readonly targetChoices: RendererMatchChoice[]
649
+ readonly minAssociations: number
650
+ readonly maxAssociations: number
651
+ submitMatch(pairs: MatchPair[]): Promise<PrimerState<Pcis>>
652
+ timeout(): Promise<PrimerState<Pcis>>
653
+ }
654
+ ```
655
+
656
+ Each pair connects one source identifier to one target identifier.
657
+
658
+ Valid `submitMatch` payloads:
659
+
660
+ | Requirement | Error if violated |
661
+ | --- | --- |
662
+ | At least `minAssociations` pairs | `ErrInvalidSubmission` |
663
+ | At most `maxAssociations` pairs | `ErrInvalidSubmission` |
664
+ | Every source identifier exists in `sourceChoices` | `ErrInvalidSubmission` |
665
+ | Every target identifier exists in `targetChoices` | `ErrInvalidSubmission` |
666
+ | No duplicate source-target pairs | `ErrInvalidSubmission` |
667
+ | Every choice respects its `matchMin` | `ErrInvalidSubmission` |
668
+ | Every choice respects its `matchMax` when `matchMax` is nonzero | `ErrInvalidSubmission` |
669
+
670
+ `matchMax: 0` means unbounded.
671
+
672
+ ## `PciInteractionState`
673
+
674
+ Portable Custom Interaction state is typed by PCI id.
249
675
 
250
- type FractionProps = PciProps<"urn:primer:pci:fraction-input">
251
- type FractionValue = PciValue<"urn:primer:pci:fraction-input">
676
+ ```ts
677
+ type PciInteractionState<Pcis extends PciId = PciId> = {
678
+ [K in Pcis]: {
679
+ readonly phase: "interaction"
680
+ readonly kind: "portable-custom"
681
+ readonly body: ContentBlock[]
682
+ readonly stimulus: RendererStimulus | null
683
+ readonly interaction: PciInteraction<K>
684
+ readonly pciId: K
685
+ readonly properties: PciProps<K>
686
+ submit(value: PciValue<K>): Promise<PrimerState<Pcis>>
687
+ timeout(): Promise<PrimerState<Pcis>>
688
+ }
689
+ }[Pcis]
252
690
  ```
253
691
 
254
692
  When the state is narrowed to a PCI id, `submit` accepts only that PCI's value type.
255
693
 
256
694
  ```ts
695
+ import type { PciValue } from "@superbuilders/primer-tives/contracts"
696
+
257
697
  if (state.phase === "interaction" && state.kind === "portable-custom") {
258
698
  if (state.pciId === "urn:primer:pci:fraction-input") {
259
- const value = readFractionInput(state.properties)
699
+ const value: PciValue<"urn:primer:pci:fraction-input"> = readFractionInput(
700
+ state.properties
701
+ )
260
702
  state = await state.submit(value)
261
703
  }
262
704
  }
263
705
  ```
264
706
 
265
- ## Subject PCI Helpers
707
+ ## `FeedbackState`
708
+
709
+ ```ts
710
+ interface FeedbackState<Pcis extends PciId = PciId> {
711
+ readonly phase: "feedback"
712
+ readonly body: ContentBlock[]
713
+ readonly stimulus: RendererStimulus | null
714
+ readonly interaction: RendererInteraction<Pcis>
715
+ readonly submission: RendererSubmission<Pcis>
716
+ readonly isCorrect: boolean
717
+ readonly feedbackContent: ContentInline[]
718
+ readonly review: InteractionReview<Pcis> | null
719
+ advance(): Promise<PrimerState<Pcis>>
720
+ }
721
+ ```
722
+
723
+ Feedback state is returned after a successful learner submission. Render the submitted frame, submitted value, correctness, feedback content, and optional review data. Call `advance()` when the learner is ready to continue.
724
+
725
+ Repeated `advance()` calls while the first one is pending return the same pending result.
266
726
 
267
- Use `@superbuilders/primer-tives/subject-pcis` when tooling needs the same contract as the SDK.
727
+ ## `CompletedState`
268
728
 
269
729
  ```ts
270
- import { requiredPcisForSubject } from "@superbuilders/primer-tives/subject-pcis"
730
+ interface CompletedState {
731
+ readonly phase: "completed"
732
+ }
733
+ ```
734
+
735
+ Terminal state. There is no transition method.
736
+
737
+ ## `ErroredState`
271
738
 
272
- const required = requiredPcisForSubject("math")
739
+ ```ts
740
+ interface ErroredState<Pcis extends PciId = PciId> {
741
+ readonly phase: "errored"
742
+ readonly error: Error
743
+ readonly retriable: boolean
744
+ retry(): Promise<PrimerState<Pcis>>
745
+ }
273
746
  ```
274
747
 
275
- `requiredPcisForSubject(undefined)` returns the all-subject required PCI union.
748
+ `ErroredState` means the current learner intent could not complete, but the learning session itself is not necessarily terminal.
276
749
 
277
- ## Errors
750
+ If `retriable` is `true`, `retry()` repeats the exact failed intent. If `retriable` is `false`, `retry()` resolves to the same errored state.
751
+
752
+ `ErrInvalidSubmission` is non-retriable because the submitted value was invalid for the active interaction. Renderer code should fix the payload before submitting again from a fresh interaction state.
753
+
754
+ ## `FatalState`
278
755
 
279
- Errors are sentinel values from `@superbuilders/errors`.
756
+ ```ts
757
+ interface FatalState {
758
+ readonly phase: "fatal"
759
+ readonly error: Error
760
+ readonly retriable: false
761
+ }
762
+ ```
280
763
 
281
- Common sentinels:
764
+ Fatal state means the SDK cannot recover by retrying the current learner intent. Render a terminal error UI, call `start` again with valid options, or send the learner through auth again depending on the sentinel.
765
+
766
+ Fatal sentinels:
282
767
 
283
768
  | Sentinel | Meaning |
284
769
  | --- | --- |
285
- | `ErrAuthUnavailable` | Hosted auth needs browser APIs that are unavailable. |
286
- | `ErrAuthConfigInvalid` | Hosted auth internal URL/config state was invalid. |
287
- | `ErrAuthCallbackInvalid` | Hosted auth callback was malformed or rejected. |
288
- | `ErrAuthStateMismatch` | Callback state did not match stored state. |
289
- | `ErrAuthPopupBlocked` | Browser blocked the hosted auth popup. |
290
- | `ErrAuthCancelled` | Hosted auth popup was closed or timed out. |
291
- | `ErrMalformedAccessToken` | Access token is not JWS-shaped. |
292
- | `ErrInvalidAccessToken` | Server rejected the access token. |
293
- | `ErrTokenExpired` | Server rejected an expired token. |
294
- | `ErrUnsupportedPci` | Server returned an undeclared PCI. |
295
- | `ErrInvalidSubmission` | Submission did not match the active interaction. |
296
- | `ErrSdkUpgradeRequired` | Server requires a newer SDK version. |
297
- | `ErrNetwork` | Fetch failed before an HTTP response. |
298
- | `ErrTimeout` | Request was aborted. |
299
- | `ErrJsonParse` | Server returned invalid JSON. |
770
+ | `ErrBadRequest` | Primer rejected the runtime request as invalid for the SDK contract. |
771
+ | `ErrInvalidAccessToken` | The learner token was rejected. |
772
+ | `ErrTokenExpired` | The learner token expired. |
773
+ | `ErrForbidden` | The learner is not allowed to continue in this runtime scope. |
774
+ | `ErrNotFound` | The requested runtime scope or state could not be found. |
775
+ | `ErrSdkUpgradeRequired` | The installed SDK is too old for the current Primer runtime. |
776
+ | `ErrUnsupportedPci` | Primer presented a PCI that the renderer did not declare in `supportedPcis`. |
777
+
778
+ ## `/contracts`
779
+
780
+ The contracts subpath contains renderer-facing data types and validation helpers.
781
+
782
+ ```ts
783
+ import {
784
+ ChoiceSubmissionSchema,
785
+ ExtendedTextSubmissionSchema,
786
+ FractionInputPciSubmissionSchema,
787
+ MatchPairSchema,
788
+ MatchSubmissionSchema,
789
+ OrderSubmissionSchema,
790
+ PCI_IDS,
791
+ RendererSubmissionSchema,
792
+ TextEntrySubmissionSchema,
793
+ blocksToPlainText,
794
+ inlinesToPlainText,
795
+ isPciId,
796
+ submissionValidationMessage,
797
+ validateSubmissionForInteraction
798
+ } from "@superbuilders/primer-tives/contracts"
799
+
800
+ import type {
801
+ ContentBlock,
802
+ ContentInline,
803
+ ContentSpan,
804
+ FractionInputForm,
805
+ FractionInputProps,
806
+ FractionInputSubmission,
807
+ ImageStimulus,
808
+ InteractionReview,
809
+ MatchPair,
810
+ PciId,
811
+ PciInteraction,
812
+ PciProps,
813
+ PciRegistry,
814
+ PciSubmission,
815
+ PciUrn,
816
+ PciValue,
817
+ RendererChoice,
818
+ RendererInteraction,
819
+ RendererMatchChoice,
820
+ RendererStimulus,
821
+ RendererSubmission,
822
+ StandardRendererInteraction
823
+ } from "@superbuilders/primer-tives/contracts"
824
+ ```
825
+
826
+ ## Content
827
+
828
+ ```ts
829
+ type ContentSpan = { type: "text"; value: string } | { type: "italic"; value: string }
830
+ type ContentInline = ContentSpan | { type: "latex"; value: string }
831
+ type ContentBlock = { type: "paragraph"; children: ContentInline[] }
832
+ ```
833
+
834
+ Helpers:
835
+
836
+ ```ts
837
+ function inlinesToPlainText(nodes: ContentInline[]): string
838
+ function blocksToPlainText(blocks: ContentBlock[]): string
839
+ ```
840
+
841
+ Use the plain-text helpers for accessibility labels, logging summaries, search snippets, and renderer fallbacks. LaTeX inline nodes contribute their raw `value` to plain text.
842
+
843
+ ## Stimulus
844
+
845
+ ```ts
846
+ interface ImageStimulus {
847
+ kind: "image"
848
+ alt: ContentInline[]
849
+ src: string
850
+ }
851
+
852
+ type RendererStimulus = ImageStimulus
853
+ ```
854
+
855
+ `RendererStimulus` is currently image-only. Always switch on `stimulus.kind` anyway.
856
+
857
+ ## Interactions
858
+
859
+ ```ts
860
+ type RendererInteraction<Pcis extends PciId = PciId> =
861
+ | StandardRendererInteraction
862
+ | PciInteraction<Pcis>
863
+ ```
864
+
865
+ Standard interactions:
866
+
867
+ | Type | Key fields |
868
+ | --- | --- |
869
+ | `choice` | `prompt`, `options`, `shuffle`, `minChoices`, `maxChoices` |
870
+ | `text-entry` | `prompt`, `base`, `expectedLength`, `patternMask`, `placeholderText` |
871
+ | `extended-text` single | `prompt`, `format`, `expectedLines`, `expectedLength`, `patternMask`, `placeholderText` |
872
+ | `extended-text` multiple | single fields plus `minStrings`, `maxStrings` |
873
+ | `order` | `prompt`, `choices`, `shuffle`, `minChoices`, `maxChoices` |
874
+ | `match` | `prompt`, `sourceChoices`, `targetChoices`, `shuffle`, `minAssociations`, `maxAssociations` |
875
+ | `portable-custom` | `prompt`, `pciId`, `properties` |
876
+
877
+ Choice objects:
878
+
879
+ ```ts
880
+ interface RendererChoice {
881
+ identifier: string
882
+ content: ContentInline[]
883
+ }
884
+ ```
885
+
886
+ Match choice objects:
887
+
888
+ ```ts
889
+ interface RendererMatchChoice {
890
+ identifier: string
891
+ content: ContentInline[]
892
+ matchMax: number
893
+ matchMin: number
894
+ }
895
+ ```
896
+
897
+ ## Submissions
898
+
899
+ ```ts
900
+ type RendererSubmission<Pcis extends PciId = PciId> =
901
+ | { type: "choice"; selectedKeys: string[] }
902
+ | { type: "text-entry"; value: string }
903
+ | { type: "extended-text"; values: string[] }
904
+ | { type: "order"; orderedKeys: string[] }
905
+ | { type: "match"; pairs: MatchPair[] }
906
+ | PciSubmission<Pcis>
907
+ ```
908
+
909
+ Public schemas:
910
+
911
+ | Schema | Validates |
912
+ | --- | --- |
913
+ | `MatchPairSchema` | `{ source, target }` match pair shape |
914
+ | `ChoiceSubmissionSchema` | choice submission shape |
915
+ | `TextEntrySubmissionSchema` | text-entry submission shape |
916
+ | `ExtendedTextSubmissionSchema` | extended-text submission shape |
917
+ | `OrderSubmissionSchema` | order submission shape |
918
+ | `MatchSubmissionSchema` | match submission shape |
919
+ | `FractionInputPciSubmissionSchema` | fraction-input PCI submission shape |
920
+ | `RendererSubmissionSchema` | union of all supported submission shapes |
921
+
922
+ Always use `safeParse` when parsing arbitrary input.
923
+
924
+ ```ts
925
+ import * as errors from "@superbuilders/errors"
926
+ import * as logger from "@superbuilders/slog"
927
+ import { RendererSubmissionSchema } from "@superbuilders/primer-tives/contracts"
928
+
929
+ const parsed = RendererSubmissionSchema.safeParse(payload)
930
+ if (!parsed.success) {
931
+ logger.error("submission payload invalid", { error: parsed.error })
932
+ throw errors.wrap(parsed.error, "submission payload")
933
+ }
934
+
935
+ const submission = parsed.data
936
+ ```
937
+
938
+ ## Semantic Submission Validation
939
+
940
+ Shape validation answers “does this look like a submission?” Semantic validation answers “is this submission valid for this exact interaction?”
941
+
942
+ ```ts
943
+ function validateSubmissionForInteraction(
944
+ interaction: RendererInteraction,
945
+ submission: RendererSubmission
946
+ ): SubmissionValidationResult
947
+
948
+ function submissionValidationMessage(result: SubmissionValidationFailure): string
949
+ ```
950
+
951
+ Result shape:
952
+
953
+ ```ts
954
+ type SubmissionValidationResult =
955
+ | { ok: true; value: RendererSubmission }
956
+ | { ok: false; issues: readonly string[] }
957
+ ```
958
+
959
+ Validation checks:
960
+
961
+ | Interaction | Checks |
962
+ | --- | --- |
963
+ | `choice` | type match, min/max selection count, duplicate identifiers, unknown identifiers |
964
+ | `text-entry` | type match |
965
+ | `extended-text` single | type match, exactly one value |
966
+ | `extended-text` multiple | type match, min/max value count, duplicate values |
967
+ | `order` | type match, min/max selection count, duplicate identifiers, unknown identifiers |
968
+ | `match` | type match, min/max association count, duplicate pairs, unknown sources, unknown targets, `matchMin`, `matchMax` |
969
+ | `portable-custom` | type match, PCI id match, PCI value schema |
970
+
971
+ The built-in standard interaction state methods call this before submitting. Custom renderer utilities that build `RendererSubmission` values directly should call it too.
300
972
 
301
973
  ```ts
302
974
  import * as errors from "@superbuilders/errors"
303
- import { ErrAuthPopupBlocked, ErrTokenExpired } from "@superbuilders/primer-tives/errors"
304
-
305
- const result = await errors.try(
306
- create({
307
- origin,
308
- publishableKey,
309
- subject: "math",
310
- supportedPcis: ["urn:primer:pci:fraction-input"],
311
- logger
975
+ import * as logger from "@superbuilders/slog"
976
+ import {
977
+ submissionValidationMessage,
978
+ validateSubmissionForInteraction
979
+ } from "@superbuilders/primer-tives/contracts"
980
+ import { ErrInvalidSubmission } from "@superbuilders/primer-tives/errors"
981
+
982
+ const validation = validateSubmissionForInteraction(interaction, submission)
983
+ if (!validation.ok) {
984
+ const message = submissionValidationMessage(validation)
985
+ logger.error("submission invalid", { issues: validation.issues })
986
+ throw errors.wrap(ErrInvalidSubmission, message)
987
+ }
988
+ ```
989
+
990
+ ## Review Types
991
+
992
+ `FeedbackState.review` is `InteractionReview | null`.
993
+
994
+ ```ts
995
+ type InteractionReview<Pcis extends PciId = PciId> =
996
+ | ChoiceReview
997
+ | TextEntryReview
998
+ | ExtendedTextReview
999
+ | OrderReview
1000
+ | MatchReview
1001
+ | PciReview<Pcis>
1002
+ ```
1003
+
1004
+ Review variants:
1005
+
1006
+ | Type | Data |
1007
+ | --- | --- |
1008
+ | `choice` | `correctKeys: string[]` |
1009
+ | `text-entry` | `correctValue: ReviewScalarValue | null` |
1010
+ | `extended-text` | `correctValues: ReviewScalarValue[]` |
1011
+ | `order` | `correctOrder: string[]` |
1012
+ | `match` | `correctPairs: MatchPair[]` |
1013
+ | `portable-custom` | `pciId`, `fields: ReviewRecordField[]` |
1014
+
1015
+ Review scalar values:
1016
+
1017
+ ```ts
1018
+ type ReviewScalarValue =
1019
+ | { kind: "identifier"; value: string }
1020
+ | { kind: "string"; value: string }
1021
+ | { kind: "integer"; value: number }
1022
+ | { kind: "float"; value: number }
1023
+ | { kind: "pair"; source: string; target: string }
1024
+ ```
1025
+
1026
+ `review` is for renderer display and inspection. Correctness already lives on `FeedbackState.isCorrect`.
1027
+
1028
+ ## PCI Registry
1029
+
1030
+ The current PCI registry contains one PCI.
1031
+
1032
+ ```ts
1033
+ const PCI_IDS = ["urn:primer:pci:fraction-input"] as const
1034
+ ```
1035
+
1036
+ ```ts
1037
+ type PciId = "urn:primer:pci:fraction-input"
1038
+ type PciProps<K extends PciId> = PciRegistry[K]["props"]
1039
+ type PciValue<K extends PciId> = PciRegistry[K]["value"]
1040
+ ```
1041
+
1042
+ Use `isPciId(value)` to narrow an arbitrary string to the current `PciId` union.
1043
+
1044
+ ## Fraction Input PCI
1045
+
1046
+ ```ts
1047
+ type FractionInputForm = "whole" | "proper" | "improper" | "mixed"
1048
+
1049
+ interface FractionInputProps {
1050
+ form: FractionInputForm
1051
+ requireSimplified: boolean
1052
+ }
1053
+ ```
1054
+
1055
+ Submitted value:
1056
+
1057
+ ```ts
1058
+ type FractionInputSubmission =
1059
+ | { form: "whole"; whole: string }
1060
+ | { form: "proper"; numerator: string; denominator: string }
1061
+ | { form: "improper"; numerator: string; denominator: string }
1062
+ | { form: "mixed"; whole: string; numerator: string; denominator: string }
1063
+ ```
1064
+
1065
+ Example renderer branch:
1066
+
1067
+ ```ts
1068
+ if (state.phase === "interaction" && state.kind === "portable-custom") {
1069
+ if (state.pciId === "urn:primer:pci:fraction-input") {
1070
+ renderFractionInput({
1071
+ mode: "pending",
1072
+ properties: state.properties,
1073
+ onValueChange: handleFractionValueChange
1074
+ })
1075
+ }
1076
+ }
1077
+ ```
1078
+
1079
+ ## PCI Render Props
1080
+
1081
+ PCI render props are convenience types for renderer components.
1082
+
1083
+ ```ts
1084
+ type PciPendingRenderProps<K extends PciId> = {
1085
+ mode: "pending"
1086
+ properties: PciProps<K>
1087
+ onValueChange: (value: PciValue<K> | null) => void
1088
+ }
1089
+
1090
+ type PciSubmittedRenderProps<K extends PciId> = {
1091
+ mode: "submitted"
1092
+ properties: PciProps<K>
1093
+ submission: PciValue<K>
1094
+ review: Extract<InteractionReview<K>, { type: "portable-custom"; pciId: K }> | null
1095
+ }
1096
+
1097
+ type PciRenderProps<K extends PciId> = PciPendingRenderProps<K> | PciSubmittedRenderProps<K>
1098
+ ```
1099
+
1100
+ ## Subject PCI Helpers
1101
+
1102
+ Use `@superbuilders/primer-tives/subject-pcis` when renderer tooling needs the same subject-to-PCI contract as `start`.
1103
+
1104
+ ```ts
1105
+ import {
1106
+ REQUIRED_PCIS_BY_SUBJECT,
1107
+ missingPcisForSubject,
1108
+ requiredPcisForSubject
1109
+ } from "@superbuilders/primer-tives/subject-pcis"
1110
+ import type {
1111
+ HasRequiredPcis,
1112
+ MissingRequiredPcis,
1113
+ RequiredPciForSubject
1114
+ } from "@superbuilders/primer-tives/subject-pcis"
1115
+
1116
+ const requiredForMath = requiredPcisForSubject("math")
1117
+ const missingForMath = missingPcisForSubject("math", [])
1118
+ ```
1119
+
1120
+ `requiredPcisForSubject(undefined)` returns the all-subject required PCI union.
1121
+
1122
+ ## Errors
1123
+
1124
+ All SDK sentinels are exported from `@superbuilders/primer-tives/errors` and are compatible with `errors.is()` from `@superbuilders/errors`.
1125
+
1126
+ ```ts
1127
+ import * as errors from "@superbuilders/errors"
1128
+ import { ErrTokenExpired } from "@superbuilders/primer-tives/errors"
1129
+
1130
+ if (errors.is(err, ErrTokenExpired)) {
1131
+ renderSignInAgain()
1132
+ }
1133
+ ```
1134
+
1135
+ Complete export set:
1136
+
1137
+ ```ts
1138
+ import {
1139
+ ErrAuthCallbackInvalid,
1140
+ ErrAuthCancelled,
1141
+ ErrAuthConfigInvalid,
1142
+ ErrAuthPopupBlocked,
1143
+ ErrAuthStateMismatch,
1144
+ ErrAuthUnavailable,
1145
+ ErrBadRequest,
1146
+ ErrConflict,
1147
+ ErrForbidden,
1148
+ ErrInvalidAccessToken,
1149
+ ErrInvalidSubmission,
1150
+ ErrJsonParse,
1151
+ ErrMalformedAccessToken,
1152
+ ErrNetwork,
1153
+ ErrNotFound,
1154
+ ErrNotSerializable,
1155
+ ErrRateLimited,
1156
+ ErrSdkUpgradeRequired,
1157
+ ErrServerError,
1158
+ ErrServiceUnavailable,
1159
+ ErrTimeout,
1160
+ ErrTokenExpired,
1161
+ ErrUnsupportedPci
1162
+ } from "@superbuilders/primer-tives/errors"
1163
+ ```
1164
+
1165
+ ## Auth And Startup Errors
1166
+
1167
+ Auth and startup failures are represented as state whenever possible.
1168
+
1169
+ | Sentinel | Meaning | Typical handling |
1170
+ | --- | --- | --- |
1171
+ | `ErrAuthUnavailable` | SDK-managed auth cannot run in the current host environment. | Render unsupported-runtime or externally managed sign-in UI. |
1172
+ | `ErrAuthConfigInvalid` | Public auth configuration is invalid. | Log and treat as integration error. |
1173
+ | `ErrAuthCallbackInvalid` | Learner auth did not complete with an acceptable result. | Offer sign-in retry; log if unexpected. |
1174
+ | `ErrAuthStateMismatch` | Auth result does not match the initiated auth attempt. | Offer sign-in retry. |
1175
+ | `ErrAuthPopupBlocked` | Browser blocked learner auth UI. | Render sign-in instructions and retry from a direct user gesture. |
1176
+ | `ErrAuthCancelled` | Learner auth was closed or timed out. | Offer retry. |
1177
+ | `ErrMalformedAccessToken` | Provided or resolved token is not shaped like a learner access token. | Re-authenticate learner or fix token source. |
1178
+
1179
+ ## Runtime Error States
1180
+
1181
+ Runtime errors are represented as `ErroredState` or `FatalState`.
1182
+
1183
+ | Sentinel | State | Retriable | Meaning |
1184
+ | --- | --- | --- | --- |
1185
+ | `ErrNetwork` | `ErroredState` | yes | Runtime communication failed before a usable Primer result existed. |
1186
+ | `ErrTimeout` | `ErroredState` | yes | Runtime work was aborted or exceeded the host's allowed time. |
1187
+ | `ErrServerError` | `ErroredState` | yes | Primer could not produce a normal runtime result. |
1188
+ | `ErrServiceUnavailable` | `ErroredState` | yes | Primer is temporarily unavailable. |
1189
+ | `ErrRateLimited` | `ErroredState` | yes | Runtime work is temporarily rate limited. |
1190
+ | `ErrConflict` | `ErroredState` | yes | The learner intent conflicts with another in-flight or current runtime action. |
1191
+ | `ErrJsonParse` | `ErroredState` | yes | Runtime data could not be interpreted as the SDK contract. |
1192
+ | `ErrInvalidSubmission` | `ErroredState` | no | Renderer submitted a value that is invalid for the active interaction. |
1193
+ | `ErrBadRequest` | `FatalState` | no | Runtime request violates the SDK contract. |
1194
+ | `ErrInvalidAccessToken` | `FatalState` | no | Learner token is invalid. |
1195
+ | `ErrTokenExpired` | `FatalState` | no | Learner token expired. |
1196
+ | `ErrForbidden` | `FatalState` | no | Learner cannot continue in this runtime scope. |
1197
+ | `ErrNotFound` | `FatalState` | no | Runtime scope or state is unavailable. |
1198
+ | `ErrSdkUpgradeRequired` | `FatalState` | no | Installed SDK version is too old for Primer. |
1199
+ | `ErrUnsupportedPci` | `FatalState` | no | Renderer did not declare support for the presented PCI. |
1200
+ | `ErrNotSerializable` | thrown by `toJSON` | no | A live `PrimerState` was serialized. |
1201
+
1202
+ ## Error-Handling Recipes
1203
+
1204
+ Handle auth-needed state before rendering learning content:
1205
+
1206
+ ```ts
1207
+ let state = await start(options)
1208
+
1209
+ if (state.phase === "unauthenticated") {
1210
+ if (errors.is(state.error, ErrAuthCancelled)) {
1211
+ renderTryAgain(state)
1212
+ return
1213
+ }
1214
+ if (state.error !== null) {
1215
+ logger.error("primer auth needed", { error: state.error })
1216
+ }
1217
+ renderSignInButton(state)
1218
+ return
1219
+ }
1220
+ ```
1221
+
1222
+ Bind login directly to the sign-in button:
1223
+
1224
+ ```ts
1225
+ function handleSignInClick(state: UnauthenticatedState): void {
1226
+ void state.login().then(function continueAfterLogin(nextState) {
1227
+ renderPrimer(nextState)
312
1228
  })
313
- )
314
- if (result.error) {
315
- if (errors.is(result.error, ErrAuthPopupBlocked)) {
316
- renderStartButtonAgain()
1229
+ }
1230
+ ```
1231
+
1232
+ Handle runtime errors through the state machine:
1233
+
1234
+ ```ts
1235
+ if (state.phase === "errored") {
1236
+ if (state.retriable) {
1237
+ state = await state.retry()
317
1238
  return
318
1239
  }
319
- if (errors.is(result.error, ErrTokenExpired)) {
1240
+ logger.error("primer non-retriable state", { error: state.error })
1241
+ throw state.error
1242
+ }
1243
+
1244
+ if (state.phase === "fatal") {
1245
+ if (errors.is(state.error, ErrTokenExpired)) {
320
1246
  renderSignInAgain()
321
1247
  return
322
1248
  }
323
- throw result.error
1249
+ if (errors.is(state.error, ErrSdkUpgradeRequired)) {
1250
+ renderSdkUpgradeMessage()
1251
+ return
1252
+ }
1253
+ logger.error("primer fatal state", { error: state.error })
1254
+ throw state.error
324
1255
  }
325
1256
  ```
326
1257
 
327
- ## Testing
1258
+ Handle invalid submissions by fixing renderer state, not by retrying the same invalid intent:
1259
+
1260
+ ```ts
1261
+ const next = await state.submitChoice(selectedKeys)
1262
+ if (next.phase === "errored") {
1263
+ if (errors.is(next.error, ErrInvalidSubmission)) {
1264
+ renderSelectionError(next.error.message)
1265
+ return
1266
+ }
1267
+ }
1268
+ state = next
1269
+ ```
328
1270
 
329
- Use a custom fetch to test runtime behavior without a live Primer server.
1271
+ ## Logger
330
1272
 
331
1273
  ```ts
332
- const fetchMock: typeof globalThis.fetch = async function fetchPrimer() {
333
- return new Response(
334
- JSON.stringify({
335
- outcome: "advanced",
336
- frame: { body: [], stimulus: null, interaction: null }
337
- }),
338
- { status: 200, headers: { "Content-Type": "application/json" } }
339
- )
1274
+ import type { PrimerLogger } from "@superbuilders/primer-tives/logger"
1275
+
1276
+ interface PrimerLogger {
1277
+ debug(message: string, attributes?: Record<string, unknown>): void
1278
+ info(message: string, attributes?: Record<string, unknown>): void
1279
+ warn(message: string, attributes?: Record<string, unknown>): void
1280
+ error(message: string, attributes?: Record<string, unknown>): void
340
1281
  }
1282
+ ```
1283
+
1284
+ `@superbuilders/slog` matches this interface directly.
1285
+
1286
+ ```ts
1287
+ import * as logger from "@superbuilders/slog"
1288
+ import { start, type PrimerOptions } from "@superbuilders/primer-tives/client"
1289
+
1290
+ const options = {
1291
+ origin,
1292
+ publishableKey,
1293
+ subject: "vocabulary",
1294
+ logger
1295
+ } satisfies PrimerOptions<"vocabulary">
1296
+
1297
+ const state = await start(options)
1298
+ ```
1299
+
1300
+ ## Grade Levels
1301
+
1302
+ ```ts
1303
+ import { GRADE_LEVELS } from "@superbuilders/primer-tives/grade-level"
1304
+ import type { GradeLevel } from "@superbuilders/primer-tives/grade-level"
341
1305
 
342
- const state = await create({
1306
+ const gradeLevels = GRADE_LEVELS
1307
+ type RuntimeGradeLevel = GradeLevel
1308
+ ```
1309
+
1310
+ ```ts
1311
+ const GRADE_LEVELS = ["K", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"] as const
1312
+ type GradeLevel = (typeof GRADE_LEVELS)[number]
1313
+ ```
1314
+
1315
+ Grade level is not a `start` option. Treat grade-level identifiers as content/runtime data when Primer presents them, not as an SDK lifecycle input.
1316
+
1317
+ ## Testing
1318
+
1319
+ `PrimerOptions.fetch` exists so tests, host runtimes, and instrumentation can provide a fetch-compatible function. The SDK treats it exactly as the runtime communication function for `start`, transitions, submissions, retries, and timeouts.
1320
+
1321
+ The runtime exchange shape is not public SDK surface. Tests should assert SDK semantics after `start` resolves:
1322
+
1323
+ | Scenario | Assert |
1324
+ | --- | --- |
1325
+ | auth is needed | `start` resolves to `UnauthenticatedState` |
1326
+ | auth login fails | `login()` resolves to `UnauthenticatedState` with the expected auth sentinel |
1327
+ | first runtime work fails recoverably | `start` resolves to `ErroredState` with `retriable: true` |
1328
+ | first runtime work fails terminally | `start` resolves to `FatalState` |
1329
+ | unsupported PCI is presented | `start` resolves to `FatalState` with `ErrUnsupportedPci` |
1330
+ | standard submission is invalid | submit method resolves to `ErroredState` with `ErrInvalidSubmission` |
1331
+ | concurrent submit/timeout conflict occurs | transition resolves to `ErroredState` with `ErrConflict` |
1332
+ | state is serialized | serialization throws `ErrNotSerializable` |
1333
+
1334
+ Example test shape:
1335
+
1336
+ ```ts
1337
+ import * as errors from "@superbuilders/errors"
1338
+ import { start, type PrimerOptions } from "@superbuilders/primer-tives/client"
1339
+ import { ErrUnsupportedPci } from "@superbuilders/primer-tives/errors"
1340
+
1341
+ declare const fetchMock: typeof globalThis.fetch
1342
+
1343
+ const options = {
343
1344
  origin: "https://primer.test",
344
1345
  publishableKey: "pk_test",
345
1346
  accessToken: "eyJ.test.token",
346
1347
  subject: "vocabulary",
347
1348
  fetch: fetchMock,
348
1349
  logger
349
- })
1350
+ } satisfies PrimerOptions<"vocabulary">
1351
+
1352
+ const state = await start(options)
1353
+
1354
+ if (state.phase === "fatal") {
1355
+ if (errors.is(state.error, ErrUnsupportedPci)) {
1356
+ renderRendererCapabilityError()
1357
+ }
1358
+ }
350
1359
  ```
351
1360
 
352
1361
  ## Security Model
@@ -355,35 +1364,76 @@ The browser may hold:
355
1364
 
356
1365
  ```txt
357
1366
  publishable key
358
- Cognito/OIDC access token
1367
+ learner access token
359
1368
  ```
360
1369
 
361
- The publishable key identifies the Primer frontend. It does not authenticate the learner.
1370
+ The publishable key identifies the Primer frontend. It is not learner auth.
362
1371
 
363
- The access token authenticates the learner. Primer verifies it server-side before touching routing or learning state.
1372
+ The access token authenticates the learner. Primer verifies it before producing learning state.
364
1373
 
365
- Primer does not need:
1374
+ PCI support is a renderer capability declaration. It is not negotiated implicitly. If a renderer cannot handle a required PCI, it must not claim that PCI in `supportedPcis`.
1375
+
1376
+ Primer does not need these as public SDK inputs:
366
1377
 
367
1378
  ```txt
368
1379
  learner email
369
1380
  verified email
370
- Primer learner JWT
371
- frontend secret key for learner runtime
372
- game backend token exchange
1381
+ frontend secret key
1382
+ student id
1383
+ grade level
373
1384
  ```
374
1385
 
1386
+ ## Integration Checklist
1387
+
1388
+ 1. Import `start` and `PrimerOptions` from `@superbuilders/primer-tives/client`.
1389
+ 2. Import shared renderer contracts from `@superbuilders/primer-tives/contracts`.
1390
+ 3. Define options with `satisfies PrimerOptions<...>` so subject and PCI requirements stay visible at the declaration site.
1391
+ 4. Pass `origin`, `publishableKey`, and `logger` to `start`.
1392
+ 5. Either pass `accessToken` or handle `UnauthenticatedState`.
1393
+ 6. Choose a public `subject`, or omit `subject` for all-subject runtime scope.
1394
+ 7. Declare every required renderer PCI in `supportedPcis`.
1395
+ 8. Await `start` and render by switching on `state.phase`.
1396
+ 9. If `state.phase === "unauthenticated"`, render sign-in UI and bind `state.login()` directly to the click or tap handler.
1397
+ 10. For interaction states, render by switching on `state.kind`.
1398
+ 11. Use only the transition methods exposed by the current state.
1399
+ 12. Handle `ErroredState` through `retriable` and `retry()`.
1400
+ 13. Handle `FatalState` as terminal for the current state object.
1401
+ 14. Never serialize `PrimerState`.
1402
+ 15. Start a new state with `start` after reload, remount, account switch, or subject switch.
1403
+
1404
+ ## What This SDK Does Not Expose
1405
+
1406
+ The current SDK intentionally does not expose:
1407
+
1408
+ | Not exposed | Use instead |
1409
+ | --- | --- |
1410
+ | package-root exports | explicit public subpaths |
1411
+ | backend-only SDK surface | browser/client semantic SDK only |
1412
+ | public auth result union | `UnauthenticatedState` |
1413
+ | separate auth API object | `UnauthenticatedState.login()` |
1414
+ | client wrapper object | `start` returning `Promise<PrimerState>` |
1415
+ | `snapshot()` | live `PrimerState` only |
1416
+ | serializable state | start a new state with `start` |
1417
+ | public `"all"` subject value | omit `subject` |
1418
+ | implicit PCI negotiation | explicit `supportedPcis` |
1419
+ | grade-level lifecycle option | runtime content/state handling |
1420
+
375
1421
  ## Final Invariants
376
1422
 
377
1423
  ```txt
378
- create is the only public lifecycle operation
379
- create returns Promise<PrimerState>
380
- accessToken present skips hosted auth
381
- accessToken absent uses hosted auth
382
- subject is optional; omitted means internal all-subject scope
1424
+ start is the public lifecycle entrypoint
1425
+ start returns Promise<PrimerState>
1426
+ accessToken present is used for learner runtime auth
1427
+ accessToken absent may produce UnauthenticatedState
1428
+ UnauthenticatedState.login is the hosted-auth user-gesture transition
1429
+ subject is optional; omitted means all-subject runtime scope
383
1430
  subject determines required renderer PCI capabilities
384
1431
  supportedPcis declares renderer PCI capabilities
385
- PrimerState is the learning state machine
1432
+ PrimerState is the live learning state machine
386
1433
  only valid state variants expose learning transitions
1434
+ standard interaction submissions are validated before runtime submission
1435
+ fatal state is terminal for the current state object
1436
+ live state is not serializable
387
1437
  ```
388
1438
 
389
- Keep these concepts separate. The publishable key is not learner auth. The access token is not content authorization. PCI support is not negotiated implicitly. The state object is not serializable.
1439
+ Keep these concepts separate: publishable key is not learner auth; access token is not content authorization; subject selects scope but does not prove renderer capability; PCI support is explicit; `PrimerState` is live behavior, not data.