@superbuilders/primer-tives 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,17 @@
1
1
  # @superbuilders/primer-tives
2
2
 
3
- Client SDK for the Primer adaptive learning engine. Drives a state machine over HTTP POST — the client renders frames, the server controls all routing and assessment.
3
+ Client SDK for the Primer adaptive learning engine.
4
4
 
5
- The SDK has no concept of routines, curricula, or backend storage structure. It advances a student through whatever the server decides to serve next.
5
+ This package turns the Primer HTTP protocol into a strongly typed, server-driven state machine. Your app renders the current frame, calls the method that is valid for that frame, and the server decides what comes next.
6
+
7
+ The SDK does **not** know about routines, curricula, storage schemas, or routing rules. It only knows how to:
8
+
9
+ - start a student session
10
+ - submit answers or timeouts
11
+ - normalize transport failures into sentinel errors plus `retriable` semantics
12
+ - expose the current step as an ergonomic TypeScript union
13
+ - expose interaction-native review data for feedback rendering
14
+ - preserve full type safety for portable custom interactions (PCIs)
6
15
 
7
16
  ## Install
8
17
 
@@ -12,19 +21,37 @@ bun add @superbuilders/primer-tives
12
21
 
13
22
  Dependency: `@superbuilders/errors` is installed automatically.
14
23
 
24
+ ## What you get
25
+
26
+ - **Single entry point**: `create(config)`
27
+ - **Single wire endpoint**: `POST ${origin}/api/v0/advance`
28
+ - **Single runtime model**: `PrimerState`
29
+ - **Six phases/kinds to handle**:
30
+ - phases: `observation`, `interaction`, `feedback`, `completed`, `errored`, `fatal`
31
+ - interaction kinds: `choice`, `text-entry`, `extended-text`, `order`, `match`, `portable-custom`
32
+ - **Typed PCI submissions** driven by the `PciRegistry`
33
+ - **Live in-memory state objects** with action methods like `advance()`, `submitChoice()`, `submitOrder()`, `submitMatch()`, `submit()`, `timeout()`, and `retry()`
34
+
15
35
  ## Quick start
16
36
 
17
37
  ```ts
18
- import { create, ErrRateLimited } from "@superbuilders/primer-tives"
19
38
  import * as errors from "@superbuilders/errors"
39
+ import {
40
+ create,
41
+ ErrRateLimited,
42
+ type PrimerState,
43
+ } from "@superbuilders/primer-tives"
20
44
 
21
45
  const client = create({
22
46
  publishableKey: "pk_live_abc123",
23
47
  origin: "https://sb-primer.vercel.app",
24
- supportedPcis: ["urn:primer:pci:division-remainder"],
48
+ supportedPcis: [
49
+ "urn:primer:pci:division-remainder",
50
+ "urn:primer:pci:fraction-addition",
51
+ ],
25
52
  })
26
53
 
27
- let state = await client.start("student-uuid")
54
+ let state: PrimerState = await client.start("student-uuid")
28
55
 
29
56
  while (state.phase !== "completed" && state.phase !== "fatal") {
30
57
  switch (state.phase) {
@@ -34,32 +61,64 @@ while (state.phase !== "completed" && state.phase !== "fatal") {
34
61
  break
35
62
 
36
63
  case "interaction":
64
+ renderStimulus(state.stimulus)
65
+
37
66
  switch (state.kind) {
38
67
  case "choice":
39
68
  state = await state.submitChoice(["option-a"])
40
69
  break
70
+
41
71
  case "text-entry":
42
72
  state = await state.submitText("42")
43
73
  break
74
+
44
75
  case "extended-text":
45
76
  if (state.cardinality === "single") {
46
77
  state = await state.submitText("answer")
47
78
  } else {
48
- state = await state.submitTexts(["a", "b"])
79
+ state = await state.submitTexts(["first", "second"])
49
80
  }
50
81
  break
82
+
83
+ case "order":
84
+ state = await state.submitOrder(["choice-1", "choice-2", "choice-3"])
85
+ break
86
+
87
+ case "match":
88
+ state = await state.submitMatch([
89
+ { source: "left-1", target: "right-2" },
90
+ { source: "left-2", target: "right-1" },
91
+ ])
92
+ break
93
+
51
94
  case "portable-custom":
52
- state = await state.submit({ quotient: "3", remainder: "1" })
95
+ switch (state.pciId) {
96
+ case "urn:primer:pci:division-remainder":
97
+ state = await state.submit({ quotient: "3", remainder: "1" })
98
+ break
99
+ case "urn:primer:pci:fraction-addition":
100
+ state = await state.submit({ numerator: "5", denominator: "6" })
101
+ break
102
+ }
53
103
  break
54
104
  }
55
105
  break
56
106
 
57
107
  case "feedback":
58
- renderFeedback(state.isCorrect, state.feedbackContent, state.correctAnswer)
108
+ renderFeedback({
109
+ correct: state.isCorrect,
110
+ content: state.feedbackContent,
111
+ review: state.review,
112
+ interaction: state.interaction,
113
+ submission: state.submission,
114
+ })
59
115
  state = await state.advance()
60
116
  break
61
117
 
62
118
  case "errored":
119
+ if (!state.retriable) {
120
+ throw state.error
121
+ }
63
122
  if (errors.is(state.error, ErrRateLimited)) {
64
123
  await delay(1000)
65
124
  }
@@ -69,27 +128,18 @@ while (state.phase !== "completed" && state.phase !== "fatal") {
69
128
  }
70
129
  ```
71
130
 
72
- Both `state.phase` and `state.kind` are discriminated unions TypeScript narrows the type in each `case` branch automatically. The outer switch on `phase` is exhaustive over the 6 states (`observation`, `interaction`, `feedback`, `completed`, `errored`, `fatal`). The inner switch on `kind` is exhaustive over the 4 interaction types (`choice`, `text-entry`, `extended-text`, `portable-custom`).
131
+ Both `state.phase` and `state.kind` are discriminated unions, so TypeScript narrows automatically inside each branch.
73
132
 
74
- ## Wire protocol
133
+ ## Mental model
75
134
 
76
- Every request is a POST to `https://${origin}/api/v0/advance`:
135
+ Primer is **server-authored**.
77
136
 
78
- ```
79
- POST /api/v0/advance
80
- Header: Authorization: Bearer pk_...
81
- Body: { studentId, supportedPcis, intent }
82
- ```
137
+ - The client sends an **intent**: advance, submit, or timeout.
138
+ - The server evaluates that intent and returns the next frame.
139
+ - The SDK wraps that frame in a typed state object.
140
+ - Your UI renders the state and calls the next valid method.
83
141
 
84
- The `origin` field is required and must be the full Primer API base URL (e.g., `https://sb-primer.vercel.app`). All requests are made as absolute URLs so the browser sends the `Origin` header for server-side origin validation. The path constant `ADVANCE_PATH` is exported for reference.
85
-
86
- The server identifies the student, determines which content to serve based on the frontend's assigned courses, evaluates submissions, and returns the next frame. The client sends:
87
-
88
- - `studentId` — who is being advanced
89
- - `supportedPcis` — which custom interaction types the renderer can handle
90
- - `intent` — `{ kind: "observation" }`, `{ kind: "interaction", submission: ... }`, or `{ kind: "timeout" }`
91
-
92
- Course resolution is entirely server-side. The publishable key identifies the frontend, and the server looks up which courses are assigned to that frontend. The client has no knowledge of courses, subjects, or form factors.
142
+ In other words, your code does not calculate progression locally. The server owns progression; the SDK owns transport, typing, and ergonomics.
93
143
 
94
144
  ## Configuration
95
145
 
@@ -106,208 +156,374 @@ interface Config<Pcis extends PciId = PciId> {
106
156
 
107
157
  | Field | Required | Description |
108
158
  |---|---|---|
109
- | `publishableKey` | yes | Must start with `pk_`. Sent as `Authorization: Bearer pk_...` header |
110
- | `supportedPcis` | yes | PCI URNs the renderer handles. `[]` if none |
111
- | `origin` | yes | Base URL for the Primer API (e.g., `https://sb-primer.vercel.app`). All requests are cross-origin to ensure the browser sends the Origin header for server-side validation |
112
- | `fetch` | no | Custom fetch. Defaults to `globalThis.fetch` |
113
- | `abort` | no | AbortController for cancelling in-flight requests |
114
- | `logger` | no | Structured logger (`debug`, `info`, `warn`, `error`) |
159
+ | `publishableKey` | yes | Must start with `pk_`. Sent as `Authorization: Bearer pk_...` |
160
+ | `supportedPcis` | yes | PCI URNs the renderer can handle. Use `[]` if none |
161
+ | `origin` | yes | Full Primer API base URL, for example `https://sb-primer.vercel.app` |
162
+ | `fetch` | no | Custom fetch implementation. Defaults to `globalThis.fetch` |
163
+ | `abort` | no | `AbortController` whose `signal` is passed to every request |
164
+ | `logger` | no | Structured logger with `debug`, `info`, `warn`, and `error` methods |
165
+
166
+ `create()` validates the publishable key prefix immediately and throws `ErrMalformedPublishableKey` if it does not start with `pk_`.
167
+
168
+ ### Logger interface
169
+
170
+ ```ts
171
+ interface PrimerLogger {
172
+ debug(message: string, attributes?: Record<string, unknown>): void
173
+ info(message: string, attributes?: Record<string, unknown>): void
174
+ warn(message: string, attributes?: Record<string, unknown>): void
175
+ error(message: string, attributes?: Record<string, unknown>): void
176
+ }
177
+ ```
178
+
179
+ ## Wire protocol
180
+
181
+ Every request is a `POST` to:
182
+
183
+ ```txt
184
+ ${origin}/api/v0/advance
185
+ ```
186
+
187
+ The path constant is exported as:
188
+
189
+ ```ts
190
+ const ADVANCE_PATH = "/api/v0/advance"
191
+ ```
192
+
193
+ ### Headers
194
+
195
+ ```txt
196
+ Authorization: Bearer pk_...
197
+ Content-Type: application/json
198
+ ```
199
+
200
+ ### Request body
201
+
202
+ ```ts
203
+ interface WireRequestBody<Pcis extends PciId = PciId> {
204
+ studentId: string
205
+ supportedPcis: readonly PciId[]
206
+ intent: WireIntent<Pcis>
207
+ }
208
+ ```
115
209
 
116
- `create()` validates the key prefix immediately — throws `ErrMalformedPublishableKey` if invalid.
210
+ ```ts
211
+ type WireIntent<Pcis extends PciId = PciId> =
212
+ | { kind: "observation" }
213
+ | { kind: "interaction"; submission: RendererSubmission<Pcis> }
214
+ | { kind: "timeout" }
215
+ ```
216
+
217
+ ### Response outcomes
218
+
219
+ Conceptually, the server returns one of three outcomes:
220
+
221
+ ```ts
222
+ type WireResult<Pcis extends PciId = PciId> =
223
+ | {
224
+ outcome: "advanced"
225
+ stimulus: RendererStimulus | null
226
+ interaction: RendererInteraction<Pcis> | null
227
+ }
228
+ | {
229
+ outcome: "submitted"
230
+ stimulus: RendererStimulus | null
231
+ interaction: RendererInteraction<Pcis>
232
+ submission: RendererSubmission<Pcis>
233
+ isCorrect: boolean
234
+ feedbackContent: ContentInline[]
235
+ review: InteractionReview<Pcis> | null
236
+ }
237
+ | { outcome: "completed" }
238
+ ```
239
+
240
+ ### Important notes
241
+
242
+ - `origin` must be the **full base URL**. The SDK constructs requests as `${origin}${ADVANCE_PATH}`.
243
+ - `supportedPcis` is sent on every request so the server knows which portable custom interactions the client can render.
244
+ - The client also uses `supportedPcis` as an inbound safety check: if the server returns a PCI frame the client did not advertise support for, the SDK returns a fatal `ErrUnsupportedPci` state.
117
245
 
118
246
  ## State machine
119
247
 
120
248
  `PrimerState` is a discriminated union on `phase`:
121
249
 
122
- ```
123
- observation interaction feedback observation ... completed
250
+ ```txt
251
+ observation -> interaction -> feedback -> observation -> ... -> completed
124
252
  | \ | \ | \
125
-
253
+ v \ v \ v \
126
254
  errored fatal errored fatal errored fatal
127
255
 
128
- errored (retry) replays failed action
129
- interaction timeout same as submit (server evaluates)
256
+ errored -> retry() -> replays exact failed action
257
+ interaction timeout -> same transport path, intent kind "timeout"
130
258
  ```
131
259
 
132
- Any action (`advance()`, `submitChoice()`, `timeout()`, etc.) can fail. Transient failures (network, timeout, 5xx) become `errored` with `retry()`. Permanent failures (401, 403, 404) become `fatal` — session is dead, no recovery.
133
-
134
- Calling an action twice (e.g., double-clicking `advance()`) returns the same promise — the SDK memoizes in-flight requests internally. No special handling needed.
135
-
136
260
  ### `observation`
137
261
 
138
- Display-only frame. Show the stimulus, call `advance()`.
262
+ Display-only frame. Render `state.stimulus`, then call `advance()`.
139
263
 
140
264
  ```ts
265
+ state.phase === "observation"
141
266
  state.stimulus // RendererStimulus | null
142
267
  state.advance() // Promise<PrimerState>
143
268
  ```
144
269
 
145
- Stimulus is a discriminated union on `type`:
146
- - `BodyStimulus` — `{ type: "body", body: ContentBlock[] }` — text-only
147
- - `ImageStimulus` — `{ type: "image", description: ContentInline[], src: string }` — image-backed
148
- - `null` — no stimulus
149
-
150
270
  ### `interaction`
151
271
 
152
- Student must respond. Discriminate on `state.kind`:
272
+ Student must respond. Discriminate on `state.kind`.
153
273
 
154
- | `state.kind` | Submit | Key fields |
155
- |---|---|---|
156
- | `"choice"` | `submitChoice(keys: string[])` | `options`, `maxChoices`, `minChoices` |
157
- | `"text-entry"` | `submitText(value: string)` | — |
158
- | `"extended-text"` (single) | `submitText(value: string)` | `cardinality: "single"` |
159
- | `"extended-text"` (multiple) | `submitTexts(values: string[])` | `cardinality: "multiple"`, `maxStrings`, `minStrings` |
160
- | `"portable-custom"` | `submit(value: PciValue<K>)` | `pciId`, `properties` |
274
+ All interaction states also expose:
161
275
 
162
- All interaction states also have a `timeout()` method that advances the state machine as if the student ran out of time.
276
+ - `state.stimulus`
277
+ - `state.interaction`
278
+ - `timeout()`
163
279
 
164
- All submit and timeout methods return `Promise<PrimerState>`.
280
+ #### Interaction kinds
165
281
 
166
- Every interaction state carries `interaction.prompt` (`ContentInline[]`) and `interaction.type` (the discriminant matching `state.kind`).
282
+ | `state.kind` | Submit method | Key fields |
283
+ |---|---|---|
284
+ | `"choice"` | `submitChoice(selectedKeys: string[])` | `options`, `minChoices`, `maxChoices` |
285
+ | `"text-entry"` | `submitText(value: string)` | `interaction.base`, `interaction.expectedLength?`, `interaction.patternMask?`, `interaction.placeholderText?` |
286
+ | `"extended-text"` + `cardinality: "single"` | `submitText(value: string)` | `interaction.format`, `interaction.expectedLength?`, `interaction.expectedLines?`, `interaction.patternMask?`, `interaction.placeholderText?` |
287
+ | `"extended-text"` + `cardinality: "multiple"` | `submitTexts(values: string[])` | `minStrings`, `maxStrings`, plus the same metadata as single-cardinality extended text |
288
+ | `"order"` | `submitOrder(orderedKeys: string[])` | `choices`, `minChoices`, `maxChoices`, `interaction.shuffle` |
289
+ | `"match"` | `submitMatch(pairs: Array<{ source: string; target: string }>)` | `sourceChoices`, `targetChoices`, `minAssociations`, `maxAssociations`, `interaction.shuffle` |
290
+ | `"portable-custom"` | `submit(value: PciValue<K>)` | `pciId`, `properties` |
167
291
 
168
292
  ### `feedback`
169
293
 
170
- Server evaluated the submission. Show result, call `advance()`.
294
+ The server has evaluated the submission. Render feedback, then call `advance()`.
171
295
 
172
296
  ```ts
297
+ state.phase === "feedback"
173
298
  state.stimulus // RendererStimulus | null
174
- state.interaction // the original interaction
175
- state.submission // the student's submission
299
+ state.interaction // RendererInteraction<Pcis>
300
+ state.submission // RendererSubmission<Pcis>
176
301
  state.isCorrect // boolean
177
302
  state.feedbackContent // ContentInline[]
178
- state.correctAnswer // RendererCorrectAnswer | null
303
+ state.review // InteractionReview<Pcis> | null
179
304
  state.advance() // Promise<PrimerState>
180
305
  ```
181
306
 
182
- `correctAnswer` carries the canonical correct response, or `null` when unavailable:
307
+ `review` is interaction-native feedback data when available:
183
308
 
184
309
  ```ts
185
- type RendererCorrectScalarValue =
186
- | { kind: "identifier"; value: string }
187
- | { kind: "string"; value: string }
188
- | { kind: "integer"; value: number }
189
- | { kind: "float"; value: number }
190
-
191
- type RendererCorrectAnswer =
192
- | { kind: "single"; value: RendererCorrectScalarValue | null }
193
- | { kind: "multiple"; values: RendererCorrectScalarValue[] }
310
+ type InteractionReview<Pcis extends PciId = PciId> =
311
+ | { type: "choice"; correctKeys: string[] }
312
+ | { type: "text-entry"; correctValue: ReviewScalarValue | null }
313
+ | { type: "extended-text"; correctValues: ReviewScalarValue[] }
314
+ | { type: "order"; correctOrder: string[] }
315
+ | { type: "match"; correctPairs: Array<{ source: string; target: string }> }
194
316
  | {
195
- kind: "record"
317
+ type: "portable-custom"
318
+ pciId: Pcis
196
319
  fields: Array<{
197
320
  fieldIdentifier: string
198
- baseType: "identifier" | "string" | "integer" | "float"
199
- value: RendererCorrectScalarValue | null
321
+ baseType: "identifier" | "string" | "integer" | "float" | "pair"
322
+ value: ReviewScalarValue | null
200
323
  }>
201
324
  }
325
+
326
+ type ReviewScalarValue =
327
+ | { kind: "identifier"; value: string }
328
+ | { kind: "string"; value: string }
329
+ | { kind: "integer"; value: number }
330
+ | { kind: "float"; value: number }
331
+ | { kind: "pair"; source: string; target: string }
202
332
  ```
203
333
 
204
- For choice interactions, extract correct option identifiers from `kind: "identifier"` scalars. For text-entry, check `kind: "string"` or `kind: "integer"`. For PCI record responses, iterate `fields`. `null` means the server could not determine a correct response.
334
+ Notes:
335
+
336
+ - `null` means the server did not provide review data.
337
+ - order review preserves ordering directly.
338
+ - match review preserves directional `{ source, target }` pairs directly.
339
+ - PCI review uses record fields and can carry `pair` values without flattening them into strings.
340
+ - `feedback.advance()` uses the same observation intent as `observation.advance()`.
205
341
 
206
342
  ### `completed`
207
343
 
208
- Session finished. No further actions.
344
+ Session finished successfully. No further actions.
209
345
 
210
346
  ### `errored`
211
347
 
212
- Transient failure. The request might succeed on retry.
348
+ Recoverable or user-correctable failure.
213
349
 
214
350
  ```ts
215
- state.error // Error sentinel (use errors.is())
216
- state.failedPhase // "observation" | "interaction" | "timeout"
217
- state.retry() // Promise<PrimerState> — replays exact failed intent
351
+ state.phase === "errored"
352
+ state.error // Error sentinel
353
+ state.retriable // boolean
354
+ state.retry() // Promise<PrimerState>
218
355
  ```
219
356
 
220
- Retryable sentinels: `ErrNetwork`, `ErrTimeout`, `ErrRateLimited`, `ErrServerError`, `ErrServiceUnavailable`, `ErrJsonParse`, `ErrConflict`.
357
+ Caller guidance:
358
+
359
+ - switch on sentinel identity with `errors.is(...)`
360
+ - check `state.retriable` before calling `retry()`
361
+ - non-retriable errored states keep the original sentinel but do not replay the failed action
221
362
 
222
363
  ### `fatal`
223
364
 
224
- Permanent failure. Session cannot recover. No `retry()`.
365
+ Permanent failure. Session cannot recover.
225
366
 
226
367
  ```ts
227
- state.error // Error sentinel (use errors.is())
368
+ state.phase === "fatal"
369
+ state.error // Error sentinel
228
370
  ```
229
371
 
230
- Causes:
231
- - Invalid publishable key (401 — `ErrInvalidPublishableKey`)
232
- - Origin not allowed (403 — `ErrForbidden`)
233
- - Resource not found (404 — `ErrNotFound`)
234
- - Malformed request (400 — `ErrBadRequest`)
235
- - Unsupported PCI (422 — `ErrUnsupportedPci`)
372
+ Fatal states can come from:
236
373
 
237
- ## Client-side validation
374
+ - malformed request semantics on the server (`400`)
375
+ - invalid publishable key (`401`)
376
+ - forbidden origin (`403`)
377
+ - missing resource (`404`)
378
+ - unsupported PCI (`422`)
379
+ - a successful response that still contains a `portable-custom` frame whose `pciId` is not in `supportedPcis`
380
+
381
+ ## Interaction payloads
238
382
 
239
- The SDK validates submissions before sending them to the server:
383
+ ```ts
384
+ type PciSubmission<Pcis extends PciId = PciId> = {
385
+ [K in Pcis]: {
386
+ type: "portable-custom"
387
+ pciId: K
388
+ value: PciValue<K>
389
+ }
390
+ }[Pcis]
240
391
 
241
- **Choice:** rejects fewer than `minChoices`, more than `maxChoices`, duplicates, unknown option identifiers.
392
+ type RendererSubmission<Pcis extends PciId = PciId> =
393
+ | { type: "choice"; selectedKeys: string[] }
394
+ | { type: "text-entry"; value: string }
395
+ | { type: "extended-text"; values: string[] }
396
+ | { type: "order"; orderedKeys: string[] }
397
+ | { type: "match"; pairs: Array<{ source: string; target: string }> }
398
+ | PciSubmission<Pcis>
399
+ ```
400
+
401
+ Notes:
242
402
 
243
- **Extended text (multiple):** rejects fewer than `minStrings`, more than `maxStrings`.
403
+ - single-cardinality extended text is still submitted as `type: "extended-text"` with a one-element `values` array
404
+ - match submissions are directional `{ source, target }` pairs
405
+ - portable custom submissions carry both `pciId` and a typed PCI `value`
406
+
407
+ ## Client-side validation
244
408
 
245
- Failed validation returns `errored` (not `fatal`) with `ErrInvalidSubmission`. The original interaction state is still usable call the submit method again with corrected input. The `retry()` on the errored state replays the same invalid submission and is not useful for validation errors.
409
+ The SDK performs **selective** client-side validation before sending some submissions.
410
+
411
+ | Interaction kind | Validation performed |
412
+ |---|---|
413
+ | `choice` | min selections, max selections, duplicate keys, unknown option identifiers |
414
+ | `text-entry` | none |
415
+ | `extended-text` + `single` | none |
416
+ | `extended-text` + `multiple` | `minStrings`, `maxStrings` |
417
+ | `order` | min selections, max selections, duplicate keys, unknown choice identifiers |
418
+ | `match` | `minAssociations`, `maxAssociations`, unknown source/target identifiers, and per-choice `matchMin` / `matchMax` caps on both sides |
419
+ | `portable-custom` | no outbound value validation |
420
+
421
+ If validation fails:
422
+
423
+ - the submit call resolves to an `errored` state
424
+ - `state.error` will match `ErrInvalidSubmission`
425
+ - `state.retriable` will be `false`
426
+ - the original interaction object is still usable for a corrected resubmission
427
+ - `retry()` returns the same errored state instead of replaying the invalid payload
246
428
 
247
429
  ## Error sentinels
248
430
 
249
431
  ```ts
250
432
  import * as errors from "@superbuilders/errors"
251
- import { ErrNetwork, ErrRateLimited } from "@superbuilders/primer-tives"
252
-
253
- if (errors.is(state.error, ErrNetwork)) { /* handle */ }
433
+ import {
434
+ ErrInvalidSubmission,
435
+ ErrNetwork,
436
+ ErrRateLimited,
437
+ ErrUnsupportedPci,
438
+ } from "@superbuilders/primer-tives"
439
+
440
+ if (errors.is(state.error, ErrNetwork)) {
441
+ // handle offline / DNS / CORS / fetch failure
442
+ }
254
443
  ```
255
444
 
256
- **Retryable** surface as `errored`, consumer can call `retry()`:
445
+ ### Surfaced as `errored`
257
446
 
258
- | Sentinel | Cause |
259
- |---|---|
260
- | `ErrNetwork` | Fetch failed (offline, DNS, CORS) |
261
- | `ErrTimeout` | Request aborted or timed out |
262
- | `ErrRateLimited` | 429 |
263
- | `ErrServerError` | 500 or unrecognized 5xx |
264
- | `ErrServiceUnavailable` | 502/503/504 |
265
- | `ErrJsonParse` | Response not valid JSON |
266
- | `ErrConflict` | 409 server already processed this advance |
267
- | `ErrInvalidSubmission` | Client-side validation failed |
268
-
269
- **Permanent** surface as `fatal`, session is dead:
270
-
271
- | Sentinel | Cause |
447
+ | Sentinel | Meaning | `state.retriable` |
448
+ |---|---|---|
449
+ | `ErrNetwork` | fetch failed before a response was received | `true` |
450
+ | `ErrTimeout` | request was aborted or timed out | `true` |
451
+ | `ErrRateLimited` | `429` | `true` |
452
+ | `ErrServiceUnavailable` | `502`, `503`, or `504` | `true` |
453
+ | `ErrServerError` | any other unhandled HTTP error status | `true` |
454
+ | `ErrJsonParse` | response body was not valid JSON | `true` |
455
+ | `ErrConflict` | `409`, or a local conflicting in-flight action | `true` |
456
+ | `ErrInvalidSubmission` | client-side validation failed | `false` |
457
+
458
+ ### Permanent: surfaced as `fatal`
459
+
460
+ | Sentinel | Meaning |
272
461
  |---|---|
273
- | `ErrBadRequest` | 400 — malformed request |
274
- | `ErrInvalidPublishableKey` | 401 — wrong publishable key |
275
- | `ErrForbidden` | 403 — origin not allowed |
276
- | `ErrNotFound` | 404 — resource not found |
277
- | `ErrUnsupportedPci` | 422 server sent a PCI the client can't handle |
462
+ | `ErrBadRequest` | `400` |
463
+ | `ErrInvalidPublishableKey` | `401` |
464
+ | `ErrForbidden` | `403` |
465
+ | `ErrNotFound` | `404` |
466
+ | `ErrUnsupportedPci` | server rejected the request with `422`, or the server returned a PCI frame the client did not advertise in `supportedPcis` |
278
467
 
279
- **Thrown directly** (not wrapped in errored/fatal):
468
+ ### Thrown directly
280
469
 
281
- | Sentinel | Cause |
470
+ | Sentinel | Meaning |
282
471
  |---|---|
283
- | `ErrMalformedPublishableKey` | Key doesn't start with `pk_` thrown by `create()` |
284
- | `ErrNotSerializable` | Attempted to JSON.stringify PrimerState thrown by `toJSON()` |
472
+ | `ErrMalformedPublishableKey` | `create()` received a key that does not start with `pk_` |
473
+ | `ErrNotSerializable` | code attempted to serialize a live `PrimerState` |
285
474
 
286
- ## Submission payloads
475
+ ## Content format
287
476
 
288
477
  ```ts
289
- type RendererSubmission =
290
- | { type: "choice"; selectedKeys: string[] }
291
- | { type: "text-entry"; value: string }
292
- | { type: "extended-text"; values: string[] }
293
- | { type: "portable-custom"; pciId: PciId; value: PciValue<PciId> }
478
+ type ContentSpan =
479
+ | { type: "text"; value: string }
480
+ | { type: "italic"; value: string }
481
+
482
+ type ContentInline =
483
+ | ContentSpan
484
+ | { type: "latex"; value: string }
485
+
486
+ type ContentBlock = { type: "paragraph"; children: ContentInline[] }
294
487
  ```
295
488
 
296
- ## Content format
489
+ ### Helpers
297
490
 
298
491
  ```ts
299
- type ContentInline =
300
- | { type: "text"; value: string }
301
- | { type: "italic"; children: ContentInline[] }
492
+ inlinesToPlainText(nodes: ContentInline[]): string
493
+ blocksToPlainText(blocks: ContentBlock[]): string
494
+ ```
302
495
 
303
- type ContentBlock = { type: "paragraph"; children: ContentInline[] }
496
+ - `inlinesToPlainText()` strips formatting and concatenates inline values
497
+ - `blocksToPlainText()` flattens block content with newlines between paragraphs
498
+
499
+ Typical use cases:
500
+
501
+ - accessibility labels
502
+ - plain-text fallbacks
503
+ - analytics or logging output that should ignore inline formatting
504
+
505
+ ## Stimulus model
506
+
507
+ ```ts
508
+ interface BodyStimulus {
509
+ type: "body"
510
+ body: ContentBlock[]
511
+ }
512
+
513
+ interface ImageStimulus {
514
+ type: "image"
515
+ description: ContentInline[]
516
+ src: string
517
+ }
518
+
519
+ type RendererStimulus = BodyStimulus | ImageStimulus
304
520
  ```
305
521
 
306
- `inlinesToPlainText(nodes)` strips inline formatting for accessibility labels. `blocksToPlainText(blocks)` flattens blocks to plain text.
522
+ A frame can also have `stimulus: null`.
307
523
 
308
524
  ## PCI system
309
525
 
310
- Portable Custom Interactions handle domain-specific input types with full type safety.
526
+ Portable Custom Interactions let Primer serve domain-specific input types while preserving compile-time type safety.
311
527
 
312
528
  ### Registry
313
529
 
@@ -330,31 +546,112 @@ interface PciRegistry {
330
546
  ### Type helpers
331
547
 
332
548
  ```ts
549
+ type PciUrn = `urn:primer:pci:${string}`
333
550
  type PciId = keyof PciRegistry & string
334
- type PciProps<K extends PciId> // server-provided props
335
- type PciValue<K extends PciId> // submission value
551
+ type PciProps<K extends PciId> = PciRegistry[K]["props"]
552
+ type PciValue<K extends PciId> = PciRegistry[K]["value"]
553
+ ```
554
+
555
+ ### PCI renderer props
556
+
557
+ ```ts
558
+ type PciRenderProps<K extends PciId> =
559
+ | {
560
+ mode: "pending"
561
+ properties: PciProps<K>
562
+ onValueChange: (value: PciValue<K> | null) => void
563
+ }
564
+ | {
565
+ mode: "submitted"
566
+ properties: PciProps<K>
567
+ submission: PciValue<K>
568
+ review: Extract<InteractionReview<K>, { type: "portable-custom"; pciId: K }> | null
569
+ }
336
570
  ```
337
571
 
338
572
  ### Adding a PCI
339
573
 
340
- 1. Add entry to `PciRegistry` in `src/pci.ts`
341
- 2. URN format: `urn:primer:pci:<name>`
342
- 3. Include in `supportedPcis` when calling `create()`
343
- 4. Build renderer with `PciRenderProps<"urn:primer:pci:your-pci">`
574
+ 1. Add a new entry to `PciRegistry` in `src/pci.ts`
575
+ 2. Use the URN format `urn:primer:pci:<name>`
576
+ 3. Add that PCI URN to `supportedPcis` when creating the client
577
+ 4. Make sure your renderer knows how to render and collect a `PciValue` for that PCI
344
578
 
345
- ## Import
579
+ ## Import guide
346
580
 
347
581
  Everything is exported from the package root:
348
582
 
349
583
  ```ts
350
- import { create, ADVANCE_PATH } from "@superbuilders/primer-tives"
351
- import { ErrNetwork, ErrTimeout, ErrUnsupportedPci } from "@superbuilders/primer-tives"
352
- import { inlinesToPlainText, blocksToPlainText } from "@superbuilders/primer-tives"
353
- import type { PrimerState, Config, Client, PrimerLogger } from "@superbuilders/primer-tives"
354
- import type { ContentBlock, ContentInline } from "@superbuilders/primer-tives"
355
- import type { PciId, PciProps, PciValue, PciRenderProps } from "@superbuilders/primer-tives"
584
+ import {
585
+ ADVANCE_PATH,
586
+ ErrConflict,
587
+ ErrInvalidSubmission,
588
+ ErrMalformedPublishableKey,
589
+ ErrNetwork,
590
+ ErrTimeout,
591
+ ErrUnsupportedPci,
592
+ blocksToPlainText,
593
+ create,
594
+ inlinesToPlainText,
595
+ } from "@superbuilders/primer-tives"
596
+
597
+ import type {
598
+ ChoiceState,
599
+ Client,
600
+ Config,
601
+ ContentBlock,
602
+ ContentInline,
603
+ FeedbackState,
604
+ MatchState,
605
+ ObservationState,
606
+ OrderState,
607
+ PciId,
608
+ PciProps,
609
+ PciRenderProps,
610
+ PciValue,
611
+ PrimerLogger,
612
+ PrimerState,
613
+ RendererInteraction,
614
+ RendererStimulus,
615
+ RendererSubmission,
616
+ } from "@superbuilders/primer-tives"
356
617
  ```
357
618
 
358
- ## Non-serializable state
619
+ ## Behavioral notes
620
+
621
+ ### States are not serializable
622
+
623
+ `PrimerState` is live in-memory state that contains closures. Every state object has a poisoned `toJSON()` that throws `ErrNotSerializable`.
624
+
625
+ Do **not**:
626
+
627
+ - `JSON.stringify(state)`
628
+ - persist a state object to storage
629
+ - send a state object over the network
630
+ - treat a state object as a durable snapshot
631
+
632
+ Instead, keep it in memory and render it immediately.
633
+
634
+ ### Repeated actions are deduplicated narrowly
635
+
636
+ State objects memoize only true re-entry of the same in-flight action.
359
637
 
360
- `PrimerState` contains closures. Every state object has a poisoned `toJSON()` that throws `ErrNotSerializable`. Do not persist, stringify, or transfer `PrimerState` — it is ephemeral in-memory state.
638
+ - calling `advance()` twice on the same observation state returns the same promise
639
+ - calling the same submit method twice with the same payload returns the same promise
640
+ - calling `retry()` twice on the same errored state returns the same promise
641
+ - submit and timeout do **not** alias to each other anymore
642
+ - conflicting in-flight actions surface `ErrConflict` instead of silently reusing the wrong promise
643
+
644
+ ### `start()` is memoized per client instance
645
+
646
+ A `Client` instance memoizes the first `start()` call and returns the same promise on later calls. In practice, create a fresh client instance per independently managed session.
647
+
648
+ ## Build and publish
649
+
650
+ The package is built as browser-targeted ESM from `src/index.ts`, emits declaration files, and publishes `dist/` plus this README.
651
+
652
+ Useful package scripts:
653
+
654
+ ```sh
655
+ bun run build
656
+ bun run typecheck
657
+ ```