@citolab/tspci 2.12.1 → 2.13.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,23 @@
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.13.0](/compare/v2.12.1...v2.13.0) (2026-10-14)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+ * **tspci:** give Tailwind 4 variables their defaults in the PCI's shadow root 3d95703
12
+
13
+
14
+ ### Features
15
+
16
+ * **tspci:** authoring descriptor, typed settings and a dev page without CDNs 436ef53
17
+ * **tspci:** authoring mode for PCIs that fill settings themselves 5892fa7
18
+
19
+
20
+
21
+
22
+
6
23
  ## [2.12.1](/compare/v2.12.0...v2.12.1) (2026-08-19)
7
24
 
8
25
 
package/package.json CHANGED
@@ -1,9 +1,13 @@
1
1
  {
2
2
  "name": "@citolab/tspci",
3
- "version": "2.12.1",
3
+ "version": "2.13.0",
4
4
  "description": "an opiniated typescript ims-pci builder with tao integration",
5
5
  "author": "patrick <patrick.deklein@cito.nl>",
6
- "repository": "@citolab/tspci",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Citolab/tspci.git",
9
+ "directory": "lib/tspci"
10
+ },
7
11
  "bin": {
8
12
  "tspci": "src/cli.js"
9
13
  },
@@ -12,11 +16,13 @@
12
16
  "init": "tspci init --force --path=../../pcis",
13
17
  "test:init:local": "bash ./scripts/test-init-local.sh",
14
18
  "test:init:smoke": "bash ./scripts/test-init-local.sh",
15
- "test:init:matrix": "bash ./scripts/test-init-matrix.sh"
19
+ "test:init:matrix": "bash ./scripts/test-init-matrix.sh",
20
+ "test": "node --test test/*.test.mjs"
16
21
  },
17
22
  "type": "module",
18
23
  "types": "types/index.d.ts",
19
24
  "dependencies": {
25
+ "@citolab/qti-components": "^9.5.0",
20
26
  "@compodoc/live-server": "^1.2.3",
21
27
  "@inquirer/prompts": "^8.2.0",
22
28
  "@rollup/plugin-alias": "^6.0.0",
@@ -30,16 +36,20 @@
30
36
  "@tailwindcss/nesting": "0.0.0-insiders.565cd3e",
31
37
  "@tailwindcss/postcss": "^4.1.18",
32
38
  "autoprefixer": "10.4.23",
39
+ "bootstrap": "5.1.3",
33
40
  "chalk": "5.6.2",
34
41
  "chokidar": "^5.0.0",
35
42
  "clear": "0.1.0",
36
43
  "deepmerge": "^4.3.1",
37
44
  "fast-glob": "^3.3.3",
38
45
  "figlet": "1.10.0",
46
+ "htm": "3.1.1",
39
47
  "is-module": "^1.0.0",
40
48
  "postcss": "^8.5.6",
41
49
  "postcss-import": "^16.1.1",
42
50
  "postcss-load-config": "^6.0.1",
51
+ "preact": "10.28.2",
52
+ "requirejs": "2.3.8",
43
53
  "rollup": "^4.59.0",
44
54
  "rollup-plugin-livereload": "^2.0.5",
45
55
  "tailwindcss": "^4.1.18",
@@ -55,5 +65,5 @@
55
65
  "access": "public",
56
66
  "registry": "https://registry.npmjs.org/"
57
67
  },
58
- "gitHead": "525a89e24eb69a03fbf3f0b76c33f865f38f44ad"
68
+ "gitHead": "b62ff736df6ebd0ec8c86e35338a6bb705e0820c"
59
69
  }
@@ -0,0 +1,171 @@
1
+ # Authoring: how an authoring system configures and scores this PCI
2
+
3
+ A PCI does not only run in a test player. An item author places it in an item in an authoring system (such as
4
+ qti-construction or TAO), sets it up, and decides what counts as a correct answer. This file explains what the
5
+ authoring system learns from your project and what your PCI has to do for it. tspci does the packaging; you only
6
+ fill in two files.
7
+
8
+ ## 1. Settings: `src/config.json`
9
+
10
+ Every key is a setting of your PCI. In an item it becomes a `data-` attribute (`width` → `data-width`), and your PCI
11
+ receives it in `config.properties` (always as a string, as attributes are).
12
+
13
+ A value can be a plain default, as in every tspci project:
14
+
15
+ ```json
16
+ { "title": "Percentages", "width": "200" }
17
+ ```
18
+
19
+ or an object with a `default` plus hints for the author's form:
20
+
21
+ ```json
22
+ {
23
+ "width": { "default": "200", "editor": "integer", "min": 50, "label": { "en": "Width (px)", "nl": "Breedte (px)" } },
24
+ "mode": {
25
+ "default": "mixed",
26
+ "editor": "enum",
27
+ "options": [
28
+ { "value": "mixed", "label": { "en": "Multiple colours" } },
29
+ { "value": "single", "label": { "en": "One colour" } }
30
+ ]
31
+ },
32
+ "colors": { "default": "#c91a09, #0055bf", "editor": "colorList" }
33
+ }
34
+ ```
35
+
36
+ | editor | the author gets | value |
37
+ | --- | --- | --- |
38
+ | `text` | a text field | any text |
39
+ | `textarea` | a larger text field | any text |
40
+ | `integer`, `number` | a number field (`min`, `max`) | `"200"` |
41
+ | `boolean` | a switch | `"true"` / `"false"` |
42
+ | `enum` | a dropdown of `options` | one of the option values |
43
+ | `color` | a colour picker | `"#4b9f4a"` |
44
+ | `colorList` | a list of colour pickers | `"#c91a09, #0055bf"` |
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 |
47
+
48
+ A plain value gets an editor from its value: `"true"`/`"false"` a switch, a whole number a number field, the rest a
49
+ text field. `label` is one string or one per language. An authoring system that does not know an editor shows a
50
+ text field, so a hint never makes a setting unusable.
51
+
52
+ The build passes **only the defaults** to your PCI, so `import configProps from "./config.json"` keeps working. Type
53
+ it with `PciProperties`:
54
+
55
+ ```ts
56
+ import { PciProperties } from "@citolab/tspci";
57
+ import configProps from "./config.json";
58
+ type PropTypes = PciProperties<typeof configProps>; // { width: string; mode: string; ... }
59
+ ```
60
+
61
+ ## 2. The response: `package.json` → `config.tspci`
62
+
63
+ ```json
64
+ "config": {
65
+ "tspci": {
66
+ "typeIdentifier": "myPci",
67
+ "label": "My PCI",
68
+ "response": { "cardinality": "single", "baseType": "integer" },
69
+ "score": ["MATCH_CORRECT", "NONE"]
70
+ }
71
+ }
72
+ ```
73
+
74
+ - **`response`** is the response variable your PCI fills with `getResponse()`. It must be what your code returns:
75
+ `{ base: { integer: 30 } }` is `single` / `integer`. The package's item declares it, and an authoring system uses it
76
+ for every new item with your PCI.
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.
79
+
80
+ A PCI that reports several values (a moment and a position, say) uses one variable with **record** cardinality:
81
+ QTI binds a PCI to exactly one response variable, and a record has named fields of their own type.
82
+
83
+ ```json
84
+ "response": {
85
+ "cardinality": "record",
86
+ "fields": {
87
+ "time": { "baseType": "float", "tolerance": "range", "label": { "en": "Moment" } },
88
+ "point": { "baseType": "point", "tolerance": "area", "label": { "en": "Position" } }
89
+ }
90
+ }
91
+ ```
92
+
93
+ `getResponse()` then returns `{ record: [{ name: "time", base: { float: 4.1 } }, { name: "point", base: { point: [455, 250] } }] }`.
94
+ `tolerance` tells the authoring system what kind of margin a score rule may give a field: `range` (between two
95
+ numbers) or `area` (a point inside a shape). Elements the author puts in your `qti-interaction-markup`, such as a
96
+ video, go in `markup`: `"markup": { "video": { "editor": "media", "accept": ["video/*"], "required": true } }`.
97
+
98
+ ## 3. What the build makes of it
99
+
100
+ `tspci` (and `tspci --dev`) writes `dist/authoring.json`, generated from both files, and warns about what an
101
+ authoring system could not use (an unknown editor, an enum default that is not one of its options, a record field
102
+ without a base type). `tspci --target qti3` puts it in the package next to the bundle, as a file of the PCI resource
103
+ (`resources/pci/authoring.json`), like the SBOM; `--no-authoring` leaves it out. `tspci --target tao` builds TAO's
104
+ settings form from the same `config.json`.
105
+
106
+ ## 4. Correct answers: what your PCI must do
107
+
108
+ Authors set the correct answer **by answering your PCI** in a preview ("answer in preview"): whatever `getResponse()`
109
+ reports becomes the correct response, or one of several accepted answers. Scoring then compares responses, so:
110
+
111
+ - **One answer, one response.** Report the meaning, not the route: two ways to build the same thing must give the
112
+ same response (sort lists, round numbers, leave out ids, order and timestamps). A response that differs in a
113
+ detail the author did not mean scores 0.
114
+ - **Report the type you declared.** A number as `integer`/`float`, not a JSON string with quotes in it.
115
+ - **Nothing answered, nothing reported.** Return `undefined` until the candidate did something.
116
+ - **Show a given answer.** `setResponse()` and `boundTo` must restore a response, and with `status: "solution"` your
117
+ PCI receives `responseDeclaration.correctResponse` and should show it (`showCorrectResponse()`); that is how an
118
+ author checks the answer and how a candidate sees it in review.
119
+ - **Margins live in the item, not in the PCI.** A time window or an area is response processing
120
+ (`qti-field-value`, `qti-gte`/`qti-lte`, `qti-inside`) that the authoring system writes from the author's rules.
121
+
122
+ ## 5. Authoring mode: settings your PCI edits itself
123
+
124
+ Some settings cannot be a text field or a number: a shape, a set of areas, a path. Give such a setting
125
+ `"editor": "pci"` and let your PCI edit it. Your PCI then has a second mode, for the item author:
126
+
127
+ ```json
128
+ "partition": { "default": "", "editor": "pci", "label": { "en": "Shape to colour" } }
129
+ ```
130
+
131
+ In `package.json`, `config.tspci.authoring` can set the button text and the size you would like
132
+ (`{ "label": { "en": "Draw the shape…" }, "width": 960, "height": 760 }`). The descriptor then has
133
+ `"authoring": { "properties": ["partition"], ... }`.
134
+
135
+ Next to `getInstance`, the object you register gets a method that an authoring system calls instead. A player never
136
+ calls it:
137
+
138
+ ```ts
139
+ getAuthoringInstance = (dom: HTMLElement, config: PciAuthoringConfiguration<PropTypes>): PciAuthoringInstance => {
140
+ // render your editor into dom, starting from config.properties; call config.onchange?.() after each change
141
+ const instance = {
142
+ getConfig: () => ({ partition: JSON.stringify(shape) }), // only the "pci" properties, as strings
143
+ validate: () => (shape.parts < 2 ? ["Draw at least one line"] : []), // optional: what keeps Done from finishing
144
+ destroy: () => { /* unmount */ },
145
+ };
146
+ config.onready?.(instance);
147
+ return instance;
148
+ };
149
+ ```
150
+
151
+ The authoring system runs this in a sandboxed iframe (TAO calls it directly in its creator page) and owns the
152
+ Done and Cancel buttons. Done writes what
153
+ `getConfig()` returns as `data-` attributes, and delivery reads them like any other setting. Two rules:
154
+
155
+ - **Delivery must not need authoring mode.** Without the attribute, or with one it cannot read, your PCI uses its
156
+ default. Other players and authoring systems never call `getAuthoringInstance`, and an authoring system that does
157
+ not know `pci` shows the setting as a text field.
158
+ - **No messages, no globals.** Your PCI never calls `postMessage`; the authoring system's own page in the iframe does
159
+ that.
160
+
161
+ `tspci --dev` shows the button in the config panel: it runs your editor in place of the preview, and Done updates
162
+ the property. See `docs/authoring-mode.md` in the tspci repository for the protocol and the reasons behind it.
163
+
164
+ ## Checklist
165
+
166
+ - [ ] every setting in `src/config.json` has a `default`, an `editor` and a `label`
167
+ - [ ] `config.tspci.response` matches what `getResponse()` returns
168
+ - [ ] `getResponse()` gives the same response for the same answer, and `undefined` without one
169
+ - [ ] `setResponse()` / `boundTo` and `status: "solution"` show a given response
170
+ - [ ] `npm run prod` writes `dist/authoring.json` without warnings
171
+ - [ ] with an authoring mode: delivery works without the `pci` properties (it uses the defaults)
@@ -1,13 +1,23 @@
1
+ // Imports the build bundles into the PCI (see the readme): CSS as a string, images as a data URL.
1
2
  declare module "*.css" {
2
3
  const content: string;
3
4
  export = content;
4
5
  }
5
-
6
6
  declare module "*.png" {
7
7
  const content: string;
8
8
  export = content;
9
9
  }
10
+ declare module "*.jpg" {
11
+ const content: string;
12
+ export = content;
13
+ }
14
+ declare module "*.svg" {
15
+ const content: string;
16
+ export = content;
17
+ }
18
+
19
+ // Provided by the delivery engine at runtime.
10
20
  declare module "qtiCustomInteractionContext" {
11
21
  const register: {register: (PortableInteraction) => void};
12
22
  export = register;
13
- }
23
+ }
@@ -17,7 +17,16 @@
17
17
  },
18
18
  "config": {
19
19
  "tspci": {
20
- "typeIdentifier": "myPci"
20
+ "typeIdentifier": "myPci",
21
+ "label": "My PCI",
22
+ "response": {
23
+ "cardinality": "single",
24
+ "baseType": "integer"
25
+ },
26
+ "score": [
27
+ "MATCH_CORRECT",
28
+ "NONE"
29
+ ]
21
30
  }
22
31
  },
23
32
  "source": "src/index.ts"
@@ -1,6 +1,38 @@
1
1
  {
2
- "title" : "Percentages",
3
- "prompt" : "What percentage is colored green?",
4
- "width": "200",
5
- "height": "200"
6
- }
2
+ "title": {
3
+ "default": "Percentages",
4
+ "editor": "text",
5
+ "label": { "en": "Title", "nl": "Titel" }
6
+ },
7
+ "question": {
8
+ "default": "What percentage of the circle is coloured?",
9
+ "editor": "textarea",
10
+ "label": { "en": "Question", "nl": "Vraag" }
11
+ },
12
+ "segments": {
13
+ "default": "10",
14
+ "editor": "integer",
15
+ "min": 2,
16
+ "max": 24,
17
+ "label": { "en": "Number of slices", "nl": "Aantal stukken" }
18
+ },
19
+ "colored": {
20
+ "default": "3",
21
+ "editor": "integer",
22
+ "min": 0,
23
+ "max": 24,
24
+ "label": { "en": "Coloured slices", "nl": "Gekleurde stukken" }
25
+ },
26
+ "color": {
27
+ "default": "#3b82f6",
28
+ "editor": "color",
29
+ "label": { "en": "Colour", "nl": "Kleur" }
30
+ },
31
+ "size": {
32
+ "default": "200",
33
+ "editor": "integer",
34
+ "min": 100,
35
+ "max": 400,
36
+ "label": { "en": "Size of the circle (px)", "nl": "Grootte van de cirkel (px)" }
37
+ }
38
+ }
@@ -1,3 +1,96 @@
1
- h1 {
2
- color: red;
3
- }
1
+ /* Rendered inside the PCI's shadow root: these rules do not leak into the item, and the item's CSS does not leak in. */
2
+
3
+ :host {
4
+ display: block;
5
+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
6
+ color: #1f2933;
7
+ }
8
+
9
+ .pci {
10
+ box-sizing: border-box;
11
+ display: flex;
12
+ flex-direction: column;
13
+ align-items: center;
14
+ gap: 16px;
15
+ width: fit-content;
16
+ max-width: 100%;
17
+ margin: 0 auto;
18
+ padding: 24px 28px;
19
+ border: 1px solid #e3e8ee;
20
+ border-radius: 16px;
21
+ background: linear-gradient(180deg, #ffffff 0%, #f7f9fb 100%);
22
+ box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 8px 24px rgba(16, 24, 40, 0.06);
23
+ }
24
+
25
+ .title {
26
+ margin: 0;
27
+ font-size: 1.25rem;
28
+ font-weight: 650;
29
+ letter-spacing: -0.01em;
30
+ }
31
+
32
+ /* Scales down on a narrow screen; the viewBox keeps the drawing sharp. */
33
+ .pie {
34
+ max-width: 100%;
35
+ height: auto;
36
+ filter: drop-shadow(0 2px 3px rgba(16, 24, 40, 0.15));
37
+ }
38
+
39
+ .slice {
40
+ stroke: #ffffff;
41
+ stroke-width: 2;
42
+ }
43
+ /* Coloured slices carry a fill attribute (the author's colour); the others are grey. */
44
+ .slice:not([fill]) {
45
+ fill: #e5e9f0;
46
+ }
47
+
48
+ .question {
49
+ max-width: 32ch;
50
+ text-align: center;
51
+ color: #52606d;
52
+ cursor: pointer;
53
+ }
54
+
55
+ .answer {
56
+ display: flex;
57
+ align-items: center;
58
+ gap: 6px;
59
+ font-size: 1.125rem;
60
+ font-weight: 600;
61
+ }
62
+
63
+ input {
64
+ width: 4.5ch;
65
+ padding: 8px 10px;
66
+ font: inherit;
67
+ text-align: right;
68
+ color: inherit;
69
+ border: 1.5px solid #cbd2d9;
70
+ border-radius: 10px;
71
+ background: #ffffff;
72
+ transition: border-color 120ms, box-shadow 120ms;
73
+ -moz-appearance: textfield;
74
+ appearance: textfield;
75
+ }
76
+ /* A percentage is typed, not stepped: hide the spin buttons. */
77
+ input::-webkit-outer-spin-button,
78
+ input::-webkit-inner-spin-button {
79
+ -webkit-appearance: none;
80
+ margin: 0;
81
+ }
82
+ /* Keep a clearly visible focus ring for keyboard users. */
83
+ input:focus-visible {
84
+ outline: none;
85
+ border-color: #3b82f6;
86
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3);
87
+ }
88
+ input[aria-invalid="true"] {
89
+ border-color: #e11d48;
90
+ box-shadow: 0 0 0 3px rgba(225, 29, 72, 0.2);
91
+ }
92
+ input:disabled {
93
+ background: #f1f5f9;
94
+ color: #52606d;
95
+ cursor: not-allowed;
96
+ }