@superbuilders/primer-tives 3.5.0 → 3.5.1

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