@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 +1219 -169
- package/dist/client/auth/provider.d.ts +18 -3
- package/dist/client/auth/provider.d.ts.map +1 -1
- package/dist/client/auth/storage.d.ts +2 -1
- package/dist/client/auth/storage.d.ts.map +1 -1
- package/dist/client/index.d.ts +3 -3
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +201 -45
- package/dist/client/index.js.map +8 -7
- package/dist/client/session-context.d.ts +1 -1
- package/dist/client/session-context.d.ts.map +1 -1
- package/dist/client/{create.d.ts → start.d.ts} +7 -5
- package/dist/client/start.d.ts.map +1 -0
- package/dist/client/start.type-test.d.ts +2 -0
- package/dist/client/start.type-test.d.ts.map +1 -0
- package/dist/client/types.d.ts +7 -2
- package/dist/client/types.d.ts.map +1 -1
- package/dist/client/unauthenticated-state.d.ts +10 -0
- package/dist/client/unauthenticated-state.d.ts.map +1 -0
- package/dist/version.d.ts +1 -1
- package/package.json +1 -1
- package/dist/client/create.d.ts.map +0 -1
- package/dist/client/create.type-test.d.ts +0 -2
- package/dist/client/create.type-test.d.ts.map +0 -1
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
|
|
5
|
+
The public lifecycle starts with one async call and one optional auth transition:
|
|
6
6
|
|
|
7
7
|
```txt
|
|
8
|
-
|
|
8
|
+
start(options) -> Promise<PrimerState>
|
|
9
|
+
UnauthenticatedState.login() -> Promise<PrimerState>
|
|
9
10
|
```
|
|
10
11
|
|
|
11
|
-
`
|
|
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.
|
|
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` | `
|
|
30
|
-
| `@superbuilders/primer-tives/contracts` | Content, stimulus, interaction, submission, review, PCI types, validation helpers |
|
|
31
|
-
| `@superbuilders/primer-tives/errors` | SDK error
|
|
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
|
|
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 {
|
|
42
|
+
import { start, type PrimerOptions } from "@superbuilders/primer-tives/client"
|
|
44
43
|
|
|
45
|
-
const
|
|
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
|
|
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
|
|
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
|
-
|
|
70
|
+
When `start` returns `UnauthenticatedState`, render sign-in UI and call `login()` directly from a user gesture:
|
|
68
71
|
|
|
69
72
|
```ts
|
|
70
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
| `
|
|
107
|
-
| `
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
| `
|
|
120
|
-
| `
|
|
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
|
-
|
|
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
|
-
|
|
262
|
+
```ts
|
|
263
|
+
import { SUBJECTS } from "@superbuilders/primer-tives/subject"
|
|
264
|
+
import type { Subject } from "@superbuilders/primer-tives/subject"
|
|
129
265
|
|
|
130
|
-
|
|
266
|
+
const subjects = SUBJECTS
|
|
267
|
+
type RuntimeSubject = Subject
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Current public subjects:
|
|
131
271
|
|
|
132
|
-
|
|
|
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
|
|
279
|
+
The type-level rule is a subset check:
|
|
140
280
|
|
|
141
281
|
```txt
|
|
142
|
-
required PCIs for
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
351
|
+
if (state.phase === "fatal") {
|
|
352
|
+
logger.error("primer fatal state", { error: state.error })
|
|
353
|
+
throw state.error
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
```
|
|
175
357
|
|
|
176
|
-
|
|
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
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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
|
-
|
|
405
|
+
Correct browser-safe pattern:
|
|
206
406
|
|
|
207
407
|
```ts
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
})
|
|
408
|
+
function handleSignInClick(state: UnauthenticatedState): void {
|
|
409
|
+
void state.login().then(function continueAfterLogin(nextState) {
|
|
410
|
+
renderPrimer(nextState)
|
|
411
|
+
})
|
|
412
|
+
}
|
|
413
|
+
```
|
|
215
414
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
239
|
-
|
|
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
|
-
|
|
593
|
+
For single-cardinality extended text, call `submitText(value)`. For multiple-cardinality extended text, call `submitTexts(values)`.
|
|
244
594
|
|
|
245
|
-
|
|
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
|
-
|
|
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
|
-
|
|
251
|
-
type
|
|
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(
|
|
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
|
-
##
|
|
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
|
-
|
|
727
|
+
## `CompletedState`
|
|
268
728
|
|
|
269
729
|
```ts
|
|
270
|
-
|
|
730
|
+
interface CompletedState {
|
|
731
|
+
readonly phase: "completed"
|
|
732
|
+
}
|
|
733
|
+
```
|
|
734
|
+
|
|
735
|
+
Terminal state. There is no transition method.
|
|
736
|
+
|
|
737
|
+
## `ErroredState`
|
|
271
738
|
|
|
272
|
-
|
|
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
|
-
`
|
|
748
|
+
`ErroredState` means the current learner intent could not complete, but the learning session itself is not necessarily terminal.
|
|
276
749
|
|
|
277
|
-
|
|
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
|
-
|
|
756
|
+
```ts
|
|
757
|
+
interface FatalState {
|
|
758
|
+
readonly phase: "fatal"
|
|
759
|
+
readonly error: Error
|
|
760
|
+
readonly retriable: false
|
|
761
|
+
}
|
|
762
|
+
```
|
|
280
763
|
|
|
281
|
-
|
|
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
|
-
| `
|
|
286
|
-
| `
|
|
287
|
-
| `
|
|
288
|
-
| `
|
|
289
|
-
| `
|
|
290
|
-
| `
|
|
291
|
-
| `
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1271
|
+
## Logger
|
|
330
1272
|
|
|
331
1273
|
```ts
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
|
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
|
-
|
|
1367
|
+
learner access token
|
|
359
1368
|
```
|
|
360
1369
|
|
|
361
|
-
The publishable key identifies the Primer frontend. It
|
|
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
|
|
1372
|
+
The access token authenticates the learner. Primer verifies it before producing learning state.
|
|
364
1373
|
|
|
365
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
accessToken present
|
|
381
|
-
accessToken absent
|
|
382
|
-
|
|
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
|
|
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.
|