@citolab/tspci 2.14.0 → 2.16.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/CHANGELOG.md CHANGED
@@ -3,6 +3,24 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # [2.16.0](https://github.com/Citolab/tspci/compare/v2.15.1...v2.16.0) (2026-11-02)
7
+
8
+ ### Features
9
+
10
+ * **cli:** enhance watcher to persist until dev server stops ([169759b](https://github.com/Citolab/tspci/commit/169759ba66b43c92661de5a9d8f59fcb42a96f0b))
11
+
12
+
13
+ # [2.15.0](https://github.com/Citolab/tspci/compare/v2.14.0...v2.15.0) (2026-10-17)
14
+
15
+
16
+ ### Features
17
+
18
+ * **tspci:** correct response from the settings, margins and tools in the authoring descriptor ([86f2628](https://github.com/Citolab/tspci/commit/86f2628b6c71492a9c38db24172ae72701dea645))
19
+
20
+
21
+
22
+
23
+
6
24
  # [2.14.0](https://github.com/Citolab/tspci/compare/v2.13.0...v2.14.0) (2026-10-14)
7
25
 
8
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@citolab/tspci",
3
- "version": "2.14.0",
3
+ "version": "2.16.0",
4
4
  "description": "an opiniated typescript ims-pci builder with tao integration",
5
5
  "author": "patrick <patrick.deklein@cito.nl>",
6
6
  "repository": {
@@ -65,5 +65,5 @@
65
65
  "access": "public",
66
66
  "registry": "https://registry.npmjs.org/"
67
67
  },
68
- "gitHead": "d72febca7cea937f77be4cef910a7f2d72fd524f"
68
+ "gitHead": "1536c32b716a6b6015f95bb3948b0e95ec9c9297"
69
69
  }
@@ -43,7 +43,7 @@ or an object with a `default` plus hints for the author's form:
43
43
  | `color` | a colour picker | `"#4b9f4a"` |
44
44
  | `colorList` | a list of colour pickers | `"#c91a09, #0055bf"` |
45
45
  | `hidden` | nothing (set by the system, not the author) | |
46
- | `pci` | a "Configure…" button that opens your PCI's own editor (section 5) | whatever your editor writes, as a string |
46
+ | `pci` | a "Configure…" button that opens your PCI's own editor (section 6) | whatever your editor writes, as a string |
47
47
 
48
48
  A plain value gets an editor from its value: `"true"`/`"false"` a switch, a whole number a number field, the rest a
49
49
  text field. `label` is one string or one per language. An authoring system that does not know an editor shows a
@@ -75,7 +75,12 @@ type PropTypes = PciProperties<typeof configProps>; // { width: string; mode: st
75
75
  `{ base: { integer: 30 } }` is `single` / `integer`. The package's item declares it, and an authoring system uses it
76
76
  for every new item with your PCI.
77
77
  - **`score`** lists the scoring your PCI is made for: `MATCH_CORRECT` (the response equals the correct one) and/or
78
- `NONE` (not scored, or scored by a person). TAO offers exactly these.
78
+ `NONE` (not scored, or scored by a person). TAO offers exactly these. The first one is the default.
79
+ - **`correctResponse: "settings"`** says your PCI can tell the correct response from its settings
80
+ (`getCorrectResponse`, section 4). Leave it out when the correct answer is not in the settings.
81
+ - **`tolerance`** on a `single` response (or on a record field) says which margin a score rule may give it: `range`
82
+ for an `integer` or `float`, `area` for a `point` (section 4).
83
+ - **`role: "tool"`** (or a `score` of only `["NONE"]`) makes your PCI a tool that is never scored (section 5).
79
84
 
80
85
  A PCI that reports several values (a moment and a position, say) uses one variable with **record** cardinality:
81
86
  QTI binds a PCI to exactly one response variable, and a record has named fields of their own type.
@@ -118,8 +123,58 @@ reports becomes the correct response, or one of several accepted answers. Scorin
118
123
  author checks the answer and how a candidate sees it in review.
119
124
  - **Margins live in the item, not in the PCI.** A time window or an area is response processing
120
125
  (`qti-field-value`, `qti-gte`/`qti-lte`, `qti-inside`) that the authoring system writes from the author's rules.
126
+ Your PCI reports the raw answer and only says which kind of margin fits, with `tolerance`: `range` (0,75 ± 0,05) or
127
+ `area` (within 1 of the point (3, 4), in your PCI's own units). Prefer snapping when the answer has steps (a number
128
+ line in quarters): then the exact match already works and nobody needs a margin.
121
129
 
122
- ## 5. Authoring mode: settings your PCI edits itself
130
+ ### The correct response from the settings
131
+
132
+ In a configurable PCI the correct answer usually follows from the settings: 3 of 10 slices coloured → 30. Instead of
133
+ letting the author answer your PCI again after every change, export `getCorrectResponse` next to `getInstance`:
134
+
135
+ ```ts
136
+ const factory: IMSpciFactory<PropTypes> = {
137
+ typeIdentifier: "myPci",
138
+ getInstance: (dom, config, state) => new PciInstance(dom, config, state),
139
+ // the data- properties as strings (a missing one means its default) → what getResponse() reports for the answer
140
+ getCorrectResponse: (properties) => {
141
+ const { segments, colored } = pieSettings({ ...configProps, ...properties });
142
+ return { base: { integer: Math.round((colored / segments) * 100) } };
143
+ },
144
+ };
145
+ ```
146
+
147
+ and set `"correctResponse": "settings"` in `config.tspci`. An authoring system that supports it fills the item's
148
+ correct response with it and keeps it in line when the author changes a setting; the author can still choose another
149
+ answer. Players never call it. Rules:
150
+
151
+ - **Parse the settings exactly as your drawing does** (share the function), so the correct answer and what the
152
+ student sees cannot disagree.
153
+ - **The student must be able to see what is asked.** If a setting changes the correct answer, it must change what the
154
+ student sees before answering (the fraction to place, the coordinates, the number of slices): a "correct value"
155
+ setting that is never shown makes the question unanswerable.
156
+ - **Return a response your PCI can show**: `setResponse(getCorrectResponse(p))` on an instance with the same settings
157
+ must give the same `getResponse()`.
158
+ - **`undefined` when there is no single answer** for these settings (3/5 on a line in steps of 0.25): the authoring
159
+ system then asks the author.
160
+ - **No DOM, no instance**: it may run on its own, in another frame, before any instance exists.
161
+
162
+ ## 5. A tool: never scored
163
+
164
+ Not every PCI is a question. A ruler, a protractor, a calculator or a scratch number line helps the student, and its
165
+ use is never scored. Mark it with `"role": "tool"` (or a `score` of only `["NONE"]`) in `config.tspci`:
166
+
167
+ ```json
168
+ "config": { "tspci": { "typeIdentifier": "ruler", "role": "tool", "score": ["NONE"],
169
+ "response": { "cardinality": "single", "baseType": "string" } } }
170
+ ```
171
+
172
+ QTI binds every PCI to a response variable, so a tool still has one; it may stay `undefined`, or carry the tool's
173
+ state (where the ruler lies) for review. An authoring system shows it as "not scored" and offers no correct answer;
174
+ `correctResponse` and `tolerance` make no sense on a tool, and the build warns about them. Everything else still
175
+ applies: settings, `getState()` and restore, the read-only statuses, keyboard use.
176
+
177
+ ## 6. Authoring mode: settings your PCI edits itself
123
178
 
124
179
  Some settings cannot be a text field or a number: a shape, a set of areas, a path. Give such a setting
125
180
  `"editor": "pci"` and let your PCI edit it. Your PCI then has a second mode, for the item author:
@@ -167,5 +222,8 @@ the property. See `docs/authoring-mode.md` in the tspci repository for the proto
167
222
  - [ ] `config.tspci.response` matches what `getResponse()` returns
168
223
  - [ ] `getResponse()` gives the same response for the same answer, and `undefined` without one
169
224
  - [ ] `setResponse()` / `boundTo` and `status: "solution"` show a given response
225
+ - [ ] when the correct answer follows from the settings: `getCorrectResponse` and `"correctResponse": "settings"`, and
226
+ every setting that changes the correct answer is visible to the student
227
+ - [ ] a tool: `"role": "tool"`, no correct response
170
228
  - [ ] `npm run prod` writes `dist/authoring.json` without warnings
171
229
  - [ ] with an authoring mode: delivery works without the `pci` properties (it uses the defaults)
@@ -26,7 +26,8 @@
26
26
  "score": [
27
27
  "MATCH_CORRECT",
28
28
  "NONE"
29
- ]
29
+ ],
30
+ "correctResponse": "settings"
30
31
  }
31
32
  },
32
33
  "source": "src/index.ts"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "compilerOptions": {
3
3
  "declaration": true,
4
- "lib": ["es2017", "dom", "dom.iterable"],
4
+ "lib": ["es2022", "dom", "dom.iterable"],
5
5
  "module": "esnext",
6
6
  "moduleResolution": "bundler",
7
7
  "removeComments": true,
@@ -12,7 +12,7 @@
12
12
  import * as ctx from "qtiCustomInteractionContext"; // provided by the delivery engine at runtime
13
13
  import configProps from "./config.json"; // the settings; the build passes only their defaults (AUTHORING.md)
14
14
  import style from "./styles.css"; // bundled as a string, added to the shadow root below
15
- import { escapeHtml, pieSvg } from "./pie.js";
15
+ import { escapeHtml, pieSettings, pieSvg } from "./pie.js";
16
16
  // Images are bundled too: `import logo from "./assets/logo.png"` gives a data URL for <img src>.
17
17
 
18
18
  /** The response for the text in the input: a whole number, or undefined while there is no answer. */
@@ -157,9 +157,22 @@ class PciInstance {
157
157
  };
158
158
  }
159
159
 
160
+ /**
161
+ * The correct response for a configuration, for authoring systems (config.tspci.correctResponse "settings" in
162
+ * package.json, AUTHORING.md "Correct answers"): they call it whenever the author changes a setting and fill the item's
163
+ * correct response with it. It gets the data- properties as strings (a missing one means its default), uses the same
164
+ * parsing as the drawing, so the two cannot disagree, and returns what getResponse() reports for that answer.
165
+ * Players never call it, and it must not touch the DOM.
166
+ */
167
+ function getCorrectResponse(properties) {
168
+ const { segments, colored } = pieSettings({ ...configProps, ...properties });
169
+ return { base: { integer: Math.round((colored / segments) * 100) } };
170
+ }
171
+
160
172
  const factory = {
161
173
  typeIdentifier: "###___PCI_NAME___###", // the same as config.tspci.typeIdentifier in package.json
162
174
  getInstance: (dom, config, state) => new PciInstance(dom, config, state),
175
+ getCorrectResponse,
163
176
  };
164
177
 
165
178
  // Register with the engine: as an AMD module (qtiCustomInteractionContext), or through the global some engines set.
@@ -17,6 +17,7 @@ import * as ctx from "qtiCustomInteractionContext"; // provided by the delivery
17
17
  import configProps from "./config.json"; // the settings; the build passes only their defaults (AUTHORING.md)
18
18
  import style from "./styles.css"; // Tailwind, compiled to a string, added to the shadow root below
19
19
  import Interaction from "./interaction";
20
+ import { pieSettings } from "./pie";
20
21
  import { Actions, StateModel, initStore } from "./store";
21
22
 
22
23
  // Like every data- attribute, each setting arrives as a string.
@@ -154,9 +155,22 @@ class PciInstance implements IMSpci<PropTypes> {
154
155
  };
155
156
  }
156
157
 
158
+ /**
159
+ * The correct response for a configuration, for authoring systems (config.tspci.correctResponse "settings" in
160
+ * package.json, AUTHORING.md "Correct answers"): they call it whenever the author changes a setting and fill the item's
161
+ * correct response with it. It gets the data- properties as strings (a missing one means its default), uses the same
162
+ * parsing as the drawing, so the two cannot disagree, and returns what getResponse() reports for that answer.
163
+ * Players never call it, and it must not touch the DOM.
164
+ */
165
+ function getCorrectResponse(properties: Partial<PropTypes>): QtiVariableJSON | undefined {
166
+ const { segments, colored } = pieSettings({ ...(configProps as unknown as PropTypes), ...properties });
167
+ return { base: { integer: Math.round((colored / segments) * 100) } };
168
+ }
169
+
157
170
  const factory: IMSpciFactory<PropTypes> = {
158
171
  typeIdentifier: "###___PCI_NAME___###", // the same as config.tspci.typeIdentifier in package.json
159
172
  getInstance: (dom, config, state) => new PciInstance(dom, config, state),
173
+ getCorrectResponse,
160
174
  };
161
175
 
162
176
  // Register with the engine: as an AMD module (qtiCustomInteractionContext), or through the global some engines set.
@@ -9,12 +9,15 @@ const toInt = (value: string, fallback: number, min: number, max: number) => {
9
9
  return Number.isNaN(n) ? fallback : Math.min(max, Math.max(min, n));
10
10
  };
11
11
 
12
+ /** The pie the settings describe; the limits match the min/max in config.json. */
13
+ export function pieSettings(p: Partial<PropTypes>) {
14
+ const segments = toInt(p.segments, 10, 2, 24);
15
+ return { segments, colored: toInt(p.colored, 0, 0, segments), size: toInt(p.size, 200, 100, 400) };
16
+ }
17
+
12
18
  /** A circle of `segments` equal slices, the first `colored` ones filled, clockwise from the top. */
13
19
  export const Pie = ({ config }: { config: PropTypes }) => {
14
- // the limits match the min/max in config.json
15
- const segments = toInt(config.segments, 10, 2, 24);
16
- const colored = toInt(config.colored, 0, 0, segments);
17
- const size = toInt(config.size, 200, 100, 400);
20
+ const { segments, colored, size } = pieSettings(config);
18
21
  const c = size / 2; // centre
19
22
  const r = c - 4; // leave room for the outline
20
23
  const point = (i: number) => {
@@ -20,7 +20,7 @@ import {
20
20
  import * as ctx from "qtiCustomInteractionContext"; // provided by the delivery engine at runtime
21
21
  import configProps from "./config.json"; // the settings; the build passes only their defaults (AUTHORING.md)
22
22
  import style from "./styles.css"; // bundled as a string, added to the shadow root below
23
- import { escapeHtml, pieSvg } from "./pie";
23
+ import { escapeHtml, pieSettings, pieSvg } from "./pie";
24
24
  // Images are bundled too: `import logo from "./assets/logo.png"` gives a data URL for <img src>.
25
25
 
26
26
  // Like every data- attribute, each setting arrives as a string.
@@ -172,9 +172,22 @@ class PciInstance implements IMSpci<PropTypes> {
172
172
  };
173
173
  }
174
174
 
175
+ /**
176
+ * The correct response for a configuration, for authoring systems (config.tspci.correctResponse "settings" in
177
+ * package.json, AUTHORING.md "Correct answers"): they call it whenever the author changes a setting and fill the item's
178
+ * correct response with it. It gets the data- properties as strings (a missing one means its default), uses the same
179
+ * parsing as the drawing, so the two cannot disagree, and returns what getResponse() reports for that answer.
180
+ * Players never call it, and it must not touch the DOM.
181
+ */
182
+ function getCorrectResponse(properties: Partial<PropTypes>): QtiVariableJSON | undefined {
183
+ const { segments, colored } = pieSettings({ ...(configProps as unknown as PropTypes), ...properties });
184
+ return { base: { integer: Math.round((colored / segments) * 100) } };
185
+ }
186
+
175
187
  const factory: IMSpciFactory<PropTypes> = {
176
188
  typeIdentifier: "###___PCI_NAME___###", // the same as config.tspci.typeIdentifier in package.json
177
189
  getInstance: (dom, config, state) => new PciInstance(dom, config, state),
190
+ getCorrectResponse,
178
191
  };
179
192
 
180
193
  // Register with the engine: as an AMD module (qtiCustomInteractionContext), or through the global some engines set.
@@ -10,7 +10,7 @@ const toInt = (value: string, fallback: number, min: number, max: number) => {
10
10
  };
11
11
 
12
12
  /** The pie the settings describe; the limits match the min/max in config.json. */
13
- export function pieSettings(p: PropTypes) {
13
+ export function pieSettings(p: Partial<PropTypes>) {
14
14
  const segments = toInt(p.segments, 10, 2, 24);
15
15
  return {
16
16
  segments,
package/public/index.html CHANGED
@@ -424,6 +424,82 @@
424
424
  })
425
425
  .catch(() => {});
426
426
 
427
+ // The media slots (authoring.json `markup`, editor "media"): an authoring system with a media bank puts the author's
428
+ // file in qti-interaction-markup. Here you choose one from disk. It stays in this browser (Cache Storage, per PCI), so
429
+ // it is still there after the reload every build does; the PCI gets it as a blob: URL (its iframe is of this origin).
430
+ const devMedia = {}; // slot -> { name, type, url }
431
+ const mediaSlots = () =>
432
+ Object.entries(authoringDescriptor?.markup || {}).filter(([, spec]) => (spec?.editor || "media") === "media");
433
+ const mediaCache = () => caches.open(`tspci-media-${authoringDescriptor?.typeIdentifier || "pci"}`);
434
+ const mediaKey = (slot) => new URL(`/__tspci_media__/${encodeURIComponent(slot)}`, location.href).href;
435
+ const mediaLoaded = authoringLoaded
436
+ .then(async () => {
437
+ if (!mediaSlots().length || !("caches" in window)) return;
438
+ const cache = await mediaCache();
439
+ for (const [slot] of mediaSlots()) {
440
+ const response = await cache.match(mediaKey(slot));
441
+ if (!response) continue;
442
+ const blob = await response.blob();
443
+ const name = decodeURIComponent(response.headers.get("x-file-name") || slot);
444
+ devMedia[slot] = { name, type: blob.type, url: URL.createObjectURL(blob) };
445
+ }
446
+ renderConfigPanel();
447
+ })
448
+ .catch((error) => console.warn("media: could not read Cache Storage", error));
449
+
450
+ async function setMedia(slot, file) {
451
+ const cache = await mediaCache();
452
+ if (devMedia[slot]) URL.revokeObjectURL(devMedia[slot].url);
453
+ if (file) {
454
+ const headers = { "content-type": file.type || "application/octet-stream", "x-file-name": encodeURIComponent(file.name) };
455
+ await cache.put(mediaKey(slot), new Response(file, { headers }));
456
+ devMedia[slot] = { name: file.name, type: file.type, url: URL.createObjectURL(file) };
457
+ } else {
458
+ await cache.delete(mediaKey(slot));
459
+ delete devMedia[slot];
460
+ }
461
+ renderConfigPanel();
462
+ refreshCurrentPci();
463
+ }
464
+
465
+ /** The chosen files as the elements an authoring system writes: its `element`, else what `accept` asks for. */
466
+ function mediaMarkupHtml() {
467
+ return mediaSlots()
468
+ .map(([slot, spec]) => {
469
+ const media = devMedia[slot];
470
+ if (!media) return "";
471
+ const element =
472
+ spec.element || (spec.accept || []).map((type) => type.split("/")[0]).find((kind) => kind === "video" || kind === "audio") || "img";
473
+ const src = escapeHtmlAttribute(media.url);
474
+ return element === "img"
475
+ ? `<img src="${src}" alt="${escapeHtmlAttribute(media.name.replace(/\.[^.]*$/, ""))}">`
476
+ : `<${element} src="${src}" controls></${element}>`;
477
+ })
478
+ .join("");
479
+ }
480
+
481
+ function MediaControls() {
482
+ const slots = mediaSlots();
483
+ if (!slots.length) return null;
484
+ return html`<div class="mb-3 rounded border border-dashed p-2">
485
+ ${slots.map(([slot, spec]) => {
486
+ const media = devMedia[slot];
487
+ return html`<div class="mb-2">
488
+ <label class="form-label text-muted mb-1" title="markup: ${slot}">${authoringText(spec.label, slot)}${spec.required ? " *" : ""}</label>
489
+ <input type="file" class="form-control form-control-sm" accept="${(spec.accept || []).join(",")}"
490
+ onChange="${(e) => e.target.files[0] && setMedia(slot, e.target.files[0])}" />
491
+ ${media
492
+ ? html`<div class="d-flex align-items-center gap-2 small mt-1">
493
+ <code class="text-truncate" title="${media.name}">${media.name}</code>
494
+ <button type="button" class="btn btn-sm btn-link p-0" onClick="${() => setMedia(slot, null)}">remove</button>
495
+ </div>`
496
+ : html`<div class="small text-muted mt-1">None: the markup has no ${spec.element || slot}.</div>`}
497
+ </div>`;
498
+ })}
499
+ <p class="small text-muted mb-0">Goes in <code>qti-interaction-markup</code>, as an authoring system puts it there. The file stays in this browser.</p>
500
+ </div>`;
501
+ }
502
+
427
503
  const authoringText = (text, fallback) =>
428
504
  !text ? fallback : typeof text === "string" ? text : text.en || Object.values(text)[0] || fallback;
429
505
  const toHex = (c) => {
@@ -562,6 +638,7 @@
562
638
  : ""}`
563
639
  : html`No <code>authoring.json</code> yet: build once, or see AUTHORING.md`}
564
640
  </p>
641
+ <${MediaControls} />
565
642
  <${AuthoringControls} />
566
643
  <fieldset disabled=${!!authoringSession}>
567
644
  <form onSubmit="${(e) => e.preventDefault()}">
@@ -930,7 +1007,7 @@
930
1007
  <qti-interaction-module id="pci" primary-path="./index"></qti-interaction-module>
931
1008
  </qti-interaction-modules>
932
1009
  <qti-interaction-markup>
933
- <div data-qti-pci-runtime-host style="height:100%"></div>
1010
+ <div data-qti-pci-runtime-host style="height:100%"></div>${mediaMarkupHtml()}
934
1011
  </qti-interaction-markup>
935
1012
  </qti-portable-custom-interaction>
936
1013
  </qti-item-body>
@@ -964,7 +1041,7 @@
964
1041
  if (typeof globalThis.off !== "function") {
965
1042
  globalThis.off = () => {};
966
1043
  }
967
- await Promise.all([loadQtiComponents(), authoringLoaded]);
1044
+ await Promise.all([loadQtiComponents(), authoringLoaded, mediaLoaded]);
968
1045
  loadItem();
969
1046
  };
970
1047
  </script>
package/readme.md CHANGED
@@ -68,6 +68,8 @@ Tailwind is a popular css framework and can be used to give a nice look and feel
68
68
 
69
69
  preact is a small version of react. You can develop your PCI using hooks and the render function as you are familiar with in react. When preact is selected the cli will also add a store to manage state. This can easily be used to store all user actions in the PCI, and even replay them.
70
70
 
71
+ One trap with SVG: Preact writes attributes on SVG elements exactly as you spell them, and SVG attribute names are case-sensitive. `tabIndex` on an SVG element does nothing, so a keyboard user can never reach it; write `tabindex` (on HTML elements both work).
72
+
71
73
  ### specific implementations like TAO.
72
74
 
73
75
  We also support to create a PCI that can be imported in TAO. Therefor you should use @citolab/tspci-tao.
@@ -131,20 +133,31 @@ have:
131
133
  },
132
134
  "markup": {},
133
135
  "response": { "cardinality": "single", "baseType": "string" },
136
+ "role": "question",
134
137
  "score": ["MATCH_CORRECT", "NONE"]
135
138
  }
136
139
  ```
137
140
 
138
141
  The qti3 export declares the item's response from `config.tspci.response` (it used to be `single`/`string` always),
139
142
  and the build warns about what an authoring system could not use: an unknown editor, an enum default that is not one
140
- of its options, a record field without a base type.
143
+ of its options, a record field without a base type, a margin on a type it cannot measure, a correct response on a tool.
141
144
 
142
145
  **Scoring.** Authors set the correct answer by answering your PCI in a preview: what `getResponse()` reports becomes
143
146
  the correct response, or one of several accepted answers, and scoring compares responses. So report the meaning,
144
147
  not the route (the same answer must give the same response), report the declared type, return `undefined` without an
145
148
  answer, and show a given response (`setResponse`, `boundTo`, and `responseDeclaration.correctResponse` in
146
149
  `status: "solution"`). Margins (a time window, an area) live in the item's response processing, which the authoring
147
- system writes.
150
+ system writes; `tolerance` (`range` for numbers, `area` for points) on the response or a record field says which kind
151
+ fits.
152
+
153
+ **The correct response from the settings.** When the correct answer follows from the settings (3 of 10 slices → 30),
154
+ the registered object gets `getCorrectResponse(properties)` next to `getInstance`, and `config.tspci.correctResponse`
155
+ is `"settings"`. An authoring system fills the item's correct response with it and keeps it in line when the author
156
+ changes a setting. Every setting that changes the correct answer must be visible to the student, or the question
157
+ cannot be answered. Players never call it; the templates `tspci init` makes have one.
158
+
159
+ **Tools.** A PCI that helps the student but is never scored (a ruler, a calculator) has `config.tspci.role: "tool"`
160
+ (or a `score` of only `["NONE"]`). It still has a response variable, as QTI requires, but no correct response.
148
161
 
149
162
  **Authoring mode.** A setting with `"editor": "pci"` is edited by the PCI itself: next to `getInstance`, the registered
150
163
  object has `getAuthoringInstance(dom, config)`, which returns `{ getConfig, validate?, destroy? }`. An authoring
package/release-notes.md CHANGED
@@ -1,3 +1,13 @@
1
+ # 2.15.0
2
+
3
+ - `getCorrectResponse(properties)` on the registered object (typed on `IMSpciFactory`): the correct response for the item's settings, so an authoring system can fill the correct response and keep it in line when the author changes a setting; `config.tspci.correctResponse: "settings"` puts it in `authoring.json`
4
+ - `tolerance` on a `single` response (it was already on record fields): `range` for an integer or float, `area` for a point, the margin a score rule may give; the build warns when it does not fit the base type
5
+ - tools: `config.tspci.role: "tool"` (or a `score` of only `["NONE"]`) marks a PCI that is never scored, such as a ruler or a calculator; every descriptor now has a `role`, and the build warns about a score, a correct response or a margin on a tool
6
+ - the templates of `tspci init` have a `getCorrectResponse` that shares the drawing's parsing of the settings, and `"correctResponse": "settings"`
7
+ - the generated tsconfig uses `lib: es2022` (was es2017), so `Object.fromEntries`, `replaceAll` and `at()` typecheck
8
+ - fix: `tspci init` with the javascript template kept the preset's name, description and `typeIdentifier` ("myPci") in package.json, which the generated code does not register
9
+ - AUTHORING.md: the correct response from the settings, margins, tools, and a checklist item for settings the student must see; the readme warns that Preact needs `tabindex`, not `tabIndex`, on SVG elements
10
+
1
11
  # 2.12.1
2
12
 
3
13
  - `tspci init` now writes a tsconfig that is ready for TypeScript 6: an explicit `rootDir` of `./src`, `moduleResolution: "bundler"` instead of the deprecated node10 resolution, `module: "esnext"` and `lib: es2017`
package/src/authoring.js CHANGED
@@ -17,6 +17,15 @@ export const DESCRIPTOR_FILE = "authoring.json";
17
17
  */
18
18
  export const PROPERTY_EDITORS = ["text", "textarea", "integer", "number", "boolean", "enum", "color", "colorList", "hidden", "pci"];
19
19
 
20
+ /** How an authoring system may find the correct response itself: `settings` = the PCI has getCorrectResponse(properties). */
21
+ export const CORRECT_RESPONSE_SOURCES = ["settings"];
22
+
23
+ /** The margin a score rule may give a response or a record field: `range` for numbers, `area` for points. */
24
+ export const TOLERANCES = { range: ["integer", "float"], area: ["point"] };
25
+
26
+ /** `question` is scored; a `tool` (a ruler, a calculator) never counts towards the score. */
27
+ export const ROLES = ["question", "tool"];
28
+
20
29
  const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
21
30
 
22
31
  /** config.json → the plain defaults the PCI receives (every value a string, as a data- attribute gives it). */
@@ -51,6 +60,17 @@ function toProperty(key, entry, problems) {
51
60
  return { editor, default: String(value), ...rest };
52
61
  }
53
62
 
63
+ /** A margin only fits a base type it can measure: a range on numbers, an area on points. */
64
+ function checkTolerance(where, tolerance, baseType, problems) {
65
+ if (tolerance === undefined) return;
66
+ const fits = TOLERANCES[tolerance];
67
+ if (!fits) {
68
+ problems.push(`${where}.tolerance: "${tolerance}" is not one of ${Object.keys(TOLERANCES).join(", ")}`);
69
+ } else if (baseType && !fits.includes(baseType)) {
70
+ problems.push(`${where}.tolerance: "${tolerance}" needs a base type ${fits.join(" or ")}, not ${baseType}`);
71
+ }
72
+ }
73
+
54
74
  function checkResponse(response, problems) {
55
75
  if (!response) return;
56
76
  const cardinality = response.cardinality ?? "single";
@@ -62,12 +82,39 @@ function checkResponse(response, problems) {
62
82
  if (!fields.length) problems.push("config.tspci.response: a record needs fields");
63
83
  for (const [name, field] of fields) {
64
84
  if (!field?.baseType) problems.push(`config.tspci.response.fields.${name}: baseType is missing`);
85
+ checkTolerance(`config.tspci.response.fields.${name}`, field?.tolerance, field?.baseType, problems);
86
+ }
87
+ if (response.tolerance !== undefined) problems.push("config.tspci.response.tolerance: a record has a tolerance per field, not one for all");
88
+ } else {
89
+ if (!response.baseType) problems.push("config.tspci.response: baseType is missing");
90
+ // A margin on a list would mean a margin per value: no authoring system scores that, so only single responses.
91
+ if (response.tolerance !== undefined && cardinality !== "single") {
92
+ problems.push(`config.tspci.response.tolerance: only a single response can have one, not ${cardinality}`);
93
+ } else {
94
+ checkTolerance("config.tspci.response", response.tolerance, response.baseType, problems);
65
95
  }
66
- } else if (!response.baseType) {
67
- problems.push("config.tspci.response: baseType is missing");
68
96
  }
69
97
  }
70
98
 
99
+ /**
100
+ * Question or tool, and where the correct response can come from. A tool is never scored: config.tspci.role "tool",
101
+ * or a score list with only NONE. A tool has no correct response, so correctResponse makes no sense on it.
102
+ */
103
+ function toRole(tspci, problems) {
104
+ const scoreOnlyNone = Array.isArray(tspci.score) && tspci.score.length > 0 && tspci.score.every((s) => s === "NONE");
105
+ const role = tspci.role ?? (scoreOnlyNone ? "tool" : "question");
106
+ if (!ROLES.includes(role)) problems.push(`config.tspci.role: "${role}" is not one of ${ROLES.join(", ")}`);
107
+ if (role === "tool" && Array.isArray(tspci.score) && !scoreOnlyNone) {
108
+ problems.push("config.tspci.score: a tool is never scored, so score can only be [\"NONE\"]");
109
+ }
110
+ const source = tspci.correctResponse;
111
+ if (source !== undefined && !CORRECT_RESPONSE_SOURCES.includes(source)) {
112
+ problems.push(`config.tspci.correctResponse: "${source}" is not one of ${CORRECT_RESPONSE_SOURCES.join(", ")}`);
113
+ }
114
+ if (source !== undefined && role === "tool") problems.push("config.tspci.correctResponse: a tool has no correct response");
115
+ return { role, correctResponse: CORRECT_RESPONSE_SOURCES.includes(source) && role !== "tool" ? source : undefined };
116
+ }
117
+
71
118
  /**
72
119
  * The authoring mode of a PCI: present when a property has editor `pci`. config.tspci.authoring adds a label for the
73
120
  * button that opens it and the size the PCI would like for it.
@@ -83,7 +130,8 @@ function toAuthoring(authoring, properties, problems) {
83
130
 
84
131
  /**
85
132
  * The descriptor of a PCI project. `problems` lists what an authoring system could not use (an unknown editor, an
86
- * enum default that is not an option, a record field without a base type); the build reports them as warnings.
133
+ * enum default that is not an option, a record field without a base type, a margin on a type it cannot measure, a
134
+ * correct response on a tool); the build reports them as warnings.
87
135
  */
88
136
  export function buildDescriptor(packageJson, config) {
89
137
  const tspci = packageJson?.config?.tspci ?? {};
@@ -96,6 +144,9 @@ export function buildDescriptor(packageJson, config) {
96
144
  const response = tspci.response ?? { cardinality: "single", baseType: "string" };
97
145
  checkResponse(response, problems);
98
146
  const authoring = toAuthoring(tspci.authoring, properties, problems);
147
+ const { role, correctResponse } = toRole(tspci, problems);
148
+ const margins = [response.tolerance, ...Object.values(response.fields ?? {}).map((f) => f?.tolerance)].filter((t) => t !== undefined);
149
+ if (role === "tool" && margins.length) problems.push("config.tspci.response: a tool is never scored, so a tolerance makes no sense");
99
150
 
100
151
  return {
101
152
  descriptor: {
@@ -106,7 +157,9 @@ export function buildDescriptor(packageJson, config) {
106
157
  properties,
107
158
  markup: tspci.markup ?? {},
108
159
  response,
109
- ...(tspci.score ? { score: tspci.score } : {}),
160
+ role,
161
+ ...(tspci.score ? { score: tspci.score } : role === "tool" ? { score: ["NONE"] } : {}),
162
+ ...(correctResponse ? { correctResponse } : {}),
110
163
  ...(authoring ? { authoring } : {}),
111
164
  },
112
165
  problems,
package/src/cli.js CHANGED
@@ -128,7 +128,10 @@ const watchBuild = (devServer = false, target = "IMS") => {
128
128
  result.close();
129
129
  }
130
130
  });
131
- watcher.close();
131
+ // keep watching until the dev server stops (closing it here cancels the first build)
132
+ const closeWatcher = () => watcher.close();
133
+ process.on("SIGINT", closeWatcher);
134
+ process.on("SIGTERM", closeWatcher);
132
135
  });
133
136
  };
134
137
 
@@ -455,10 +458,8 @@ const run = async () => {
455
458
  path.join(destinationFolder, "src"),
456
459
  pciName
457
460
  );
458
- // point to the javascript file
459
- const packageJson = path.join(destinationFolder, "package.json");
460
- const packageJsonContent = fs.readFileSync(packageJson, "utf8");
461
- let packageJsonContentJson = JSON.parse(packageJsonContent);
461
+ // point to the javascript file. Keep the package.json built above (name, description, typeIdentifier): reading
462
+ // it from disk again gave every javascript project the preset's "myPci", which its code does not register.
462
463
  packageJsonContentJson.source = "src/index.js";
463
464
  const newPackageJsonContent = JSON.stringify(packageJsonContentJson, null, 2);
464
465
  fs.writeFileSync(packageJson, newPackageJsonContent);
package/types/index.d.ts CHANGED
@@ -69,10 +69,20 @@ export interface AuthoringResponseField {
69
69
  editor?: string;
70
70
  label?: AuthoringText;
71
71
  /** The kind of margin a score rule may give it: "range" (numbers), "area" (points). */
72
- tolerance?: string;
72
+ tolerance?: AuthoringTolerance;
73
73
  [hint: string]: unknown;
74
74
  }
75
75
 
76
+ /**
77
+ * The kind of margin a score rule may give a response: "range" (an integer or float between two values, or within a
78
+ * distance of the correct one), "area" (a point inside a shape, or within a distance of the correct point). The margin
79
+ * itself is response processing in the item, written by the authoring system; the PCI only says which kind fits.
80
+ */
81
+ export type AuthoringTolerance = "range" | "area";
82
+
83
+ /** "question" is scored; a "tool" (a ruler, a protractor, a calculator) never counts towards the score. */
84
+ export type PciRole = "question" | "tool";
85
+
76
86
  /**
77
87
  * dist/authoring.json, generated from src/config.json and package.json config.tspci, and shipped next to the bundle.
78
88
  * See AUTHORING.md.
@@ -86,10 +96,18 @@ export interface AuthoringDescriptor {
86
96
  /** Elements the item puts in qti-interaction-markup, e.g. `{ "video": { "editor": "media", "accept": ["video/*"] } }`. */
87
97
  markup: Record<string, { editor: string; element?: string; accept?: string[]; required?: boolean; label?: AuthoringText }>;
88
98
  response:
89
- | { cardinality: "single" | "multiple" | "ordered"; baseType: string }
99
+ | { cardinality: "single"; baseType: string; tolerance?: AuthoringTolerance }
100
+ | { cardinality: "multiple" | "ordered"; baseType: string }
90
101
  | { cardinality: "record"; fields: Record<string, AuthoringResponseField> };
102
+ /** config.tspci.role; a score of only ["NONE"] makes a tool. */
103
+ role: PciRole;
91
104
  /** The response processing templates the PCI is meant for (TAO's score setting), e.g. ["MATCH_CORRECT", "NONE"]. */
92
105
  score?: string[];
106
+ /**
107
+ * "settings": the registered object has getCorrectResponse(properties), so an authoring system can fill the correct
108
+ * response from the item's settings (and keep it in line when they change). Never on a tool.
109
+ */
110
+ correctResponse?: "settings";
93
111
  /** Present when the PCI has an authoring mode: properties with editor "pci", plus config.tspci.authoring. */
94
112
  authoring?: AuthoringMode;
95
113
  }
@@ -205,8 +223,18 @@ export interface IMSpci<T> {
205
223
  export interface IMSpciFactory<T> {
206
224
  typeIdentifier: string;
207
225
  getInstance: (dom: HTMLElement, configuration: ConfigProperties<T>, state?: string) => IMSpci<T>;
226
+ /**
227
+ * Not part of QTI 3: the correct response for these settings, for an authoring system (config.tspci.correctResponse
228
+ * "settings"). Gets the item's data- properties as strings (a missing one means its default) and returns what
229
+ * getResponse() reports for the correct answer, or undefined when these settings have no single correct answer.
230
+ * Players never call it. It must not touch the DOM: it may run without an instance, in another frame.
231
+ */
232
+ getCorrectResponse?: PciCorrectResponse<T>;
208
233
  }
209
234
 
235
+ /** See IMSpciFactory.getCorrectResponse. */
236
+ export type PciCorrectResponse<T> = (properties: Partial<T>) => QtiVariableJSON | undefined;
237
+
210
238
  /**
211
239
  * The QTI 3 PCI (the object registered with qtiCustomInteractionContext) extended with an authoring mode:
212
240
  * getAuthoringInstance next to getInstance. Delivery engines only know getInstance and never call it, so the PCI works