@pie-players/pie-tool-answer-eliminator 0.3.75 → 1.0.0-beta.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
@@ -7,7 +7,7 @@ Test-taking strategy tool that allows students to eliminate answer choices they
7
7
  - **Element-Level State**: Answer eliminations tracked per PIE element (not per item)
8
8
  - **Visual Feedback**: Strikethrough styling for eliminated choices
9
9
  - **Global Uniqueness**: Uses composite keys for state management across sections
10
- - **Ephemeral State**: State is client-only, separate from PIE session data
10
+ - **Separate from session data**: Eliminations never enter the PIE session; the coordinator's `saveToolState` hook persists them ([Persistence Integration](#persistence-integration))
11
11
  - **ElementToolStateStore Integration**: Works with Assessment Toolkit's state management
12
12
 
13
13
  ## Installation
@@ -28,14 +28,9 @@ The answer eliminator is automatically integrated when using the PIE Section Pla
28
28
  <script type="module">
29
29
  import '@pie-players/pie-section-player';
30
30
  import { ToolkitCoordinator } from '@pie-players/pie-assessment-toolkit';
31
- import {
32
- createPackagedToolRegistry,
33
- DEFAULT_TOOL_MODULE_LOADERS,
34
- } from '@pie-players/pie-default-tool-loaders';
31
+ import { createPackagedToolRegistry } from '@pie-players/pie-default-tool-loaders';
35
32
 
36
- const toolRegistry = createPackagedToolRegistry({
37
- toolModuleLoaders: DEFAULT_TOOL_MODULE_LOADERS,
38
- });
33
+ const toolRegistry = createPackagedToolRegistry();
39
34
  const coordinator = new ToolkitCoordinator({
40
35
  assessmentId: 'my-assessment',
41
36
  toolRegistry,
@@ -54,7 +49,7 @@ The answer eliminator is automatically integrated when using the PIE Section Pla
54
49
 
55
50
  The section player automatically:
56
51
  - Renders answer eliminator buttons in question toolbars
57
- - Generates global element IDs
52
+ - Passes each element's composite state key to the tool as `elementStateKeys`
58
53
  - Manages state lifecycle
59
54
 
60
55
  The tool reads its coordinator and the `ElementToolStateStore` from the toolkit
@@ -62,7 +57,7 @@ runtime context, so it has to sit inside `<pie-assessment-toolkit>`.
62
57
 
63
58
  ### Manual Integration (Advanced)
64
59
 
65
- For a custom layout inside the toolkit:
60
+ For a custom layout inside the toolkit. The tool detects choices under the item root its enclosing `<pie-item-scope>` provides, and with none it warns and injects nothing:
66
61
 
67
62
  ```html
68
63
  <script type="module">
@@ -71,21 +66,23 @@ For a custom layout inside the toolkit:
71
66
  const tool = document.querySelector('pie-tool-answer-eliminator');
72
67
  // Store key per PIE element in the question, by model id
73
68
  tool.elementStateKeys = {
74
- mc1: 'my-assessment:section-1:question-1:mc1',
75
- mc2: 'my-assessment:section-1:question-1:mc2',
69
+ mc1: 'my-assessment:section-1:attempt-1:question-1:mc1',
70
+ mc2: 'my-assessment:section-1:attempt-1:question-1:mc2',
76
71
  };
77
- tool.scopeElement = document.querySelector('.question-content');
78
72
  tool.visible = true; // or alwaysOn = true; the tool injects no buttons while neither is set
79
73
  </script>
80
74
 
81
75
  <pie-assessment-toolkit>
82
- <pie-tool-answer-eliminator></pie-tool-answer-eliminator>
76
+ <pie-item-scope item-id="question-1">
77
+ <!-- the item's rendered content -->
78
+ <pie-tool-answer-eliminator></pie-tool-answer-eliminator>
79
+ </pie-item-scope>
83
80
  </pie-assessment-toolkit>
84
81
  ```
85
82
 
86
83
  ## Props/Attributes
87
84
 
88
- `elementStateKeys` and `scopeElement` are JS properties only; the others also take the attribute shown.
85
+ `elementStateKeys` is a JS property only; the others also take the attribute shown.
89
86
 
90
87
  | Property | Attribute | Type | Default | Description |
91
88
  |----------|-----------|------|---------|-------------|
@@ -93,42 +90,40 @@ For a custom layout inside the toolkit:
93
90
  | `alwaysOn` | `always-on` | `boolean` | `false` | Shows the buttons regardless of `visible`, for a profile-based accommodation |
94
91
  | `strategy` | `strategy` | `'strikethrough' \| 'mask'` | `'strikethrough'` | Elimination styling; any other value uses `strikethrough` |
95
92
  | `buttonAlignment` | `button-alignment` | `'left' \| 'right' \| 'inline'` | `'right'` | Button placement relative to the choice |
96
- | `toolId` | `tool-id` | `string` | `'answerEliminator'` | Id the tool registers with the coordinator under |
97
- | `elementStateKeys` | | `Record<string, string>` | `{}` | Composite key `assessmentId:sectionId:itemId:elementId` per PIE element, by model id, into the runtime context's element tool state store. A choice is tracked under its nearest ancestor with a listed id and persists under that key; choices outside a listed element are kept in memory only |
98
- | `scopeElement` | | `HTMLElement` | | Root to detect choices in. Without it the tool uses the root its enclosing `pie-item-scope` provides, and with neither it warns and injects nothing |
93
+ | `elementStateKeys` | | `Record<string, string>` | `{}` | Composite key `assessmentId:sectionId:attemptId:itemId:elementId` per PIE element, by model id, into the runtime context's element tool state store. A choice is tracked under its nearest ancestor with a listed id and persists under that key; choices outside a listed element are kept in memory only |
99
94
 
100
95
  ## Global Element ID Format
101
96
 
102
97
  The tool uses globally unique composite keys for state management:
103
98
 
104
99
  ```
105
- ${assessmentId}:${sectionId}:${itemId}:${elementId}
100
+ ${assessmentId}:${sectionId}:${attemptId}:${itemId}:${elementId}
106
101
  ```
107
102
 
108
103
  **Example:**
109
104
  ```typescript
110
- "demo-assessment:section-1:question-1:mc1"
111
- "biology-exam:section-2:genetics-q1:ebsr1"
105
+ "demo-assessment:section-1:attempt-1:question-1:mc1"
106
+ "biology-exam:section-2:attempt-1:genetics-q1:ebsr1"
112
107
  ```
113
108
 
114
109
  ### Benefits of Composite Keys
115
110
 
116
- - ✅ **Element-Level Granularity**: Each PIE element has independent eliminations
117
- - ✅ **No Cross-Item Contamination**: Eliminations from question 1 don't appear on question 2
118
- - ✅ **Cross-Section Persistence**: State persists when navigating between sections
119
- - ✅ **Global Uniqueness**: No ID collisions across entire assessment
111
+ - **Element-Level Granularity**: Each PIE element has independent eliminations
112
+ - **No Cross-Item Contamination**: Eliminations from question 1 don't appear on question 2
113
+ - **Cross-Section Persistence**: State persists when navigating between sections
114
+ - **Global Uniqueness**: No ID collisions across entire assessment
120
115
 
121
116
  ### Why Element-Level?
122
117
 
123
118
  Items can contain **multiple interactive elements** whose choice ids repeat (two multiple-choice elements both have a choice `a`). Each element needs independent state:
124
119
 
125
120
  ```typescript
126
- // ✅ Correct: Element-level state
121
+ // Element-level state
127
122
  {
128
- "demo:section-1:question-1:mc1": {
123
+ "demo:section-1:attempt-1:question-1:mc1": {
129
124
  "answerEliminator": { "eliminatedChoices": ["a", "c"] }
130
125
  },
131
- "demo:section-1:question-1:mc2": {
126
+ "demo:section-1:attempt-1:question-1:mc2": {
132
127
  "answerEliminator": { "eliminatedChoices": ["a"] }
133
128
  }
134
129
  }
@@ -136,14 +131,14 @@ Items can contain **multiple interactive elements** whose choice ids repeat (two
136
131
 
137
132
  ## State Management
138
133
 
139
- ### Ephemeral vs Persistent State
134
+ ### Tool state and session data
140
135
 
141
- The answer eliminator stores state in **ElementToolStateStore** (ephemeral, client-only):
136
+ The answer eliminator stores eliminations in the toolkit's **ElementToolStateStore**, apart from the PIE session. They leave the browser only through the coordinator's `saveToolState` hook.
142
137
 
143
- **Tool State (Ephemeral - NOT sent to server):**
138
+ **Tool state (not part of the session, never scored):**
144
139
  ```typescript
145
140
  {
146
- "demo:section-1:question-1:mc1": {
141
+ "demo:section-1:attempt-1:question-1:mc1": {
147
142
  "answerEliminator": {
148
143
  "eliminatedChoices": ["choice-b", "choice-d"]
149
144
  }
@@ -168,31 +163,19 @@ The answer eliminator stores state in **ElementToolStateStore** (ephemeral, clie
168
163
  To persist tool state across page refreshes:
169
164
 
170
165
  ```typescript
171
- import {
172
- createPackagedToolRegistry,
173
- DEFAULT_TOOL_MODULE_LOADERS,
174
- } from '@pie-players/pie-default-tool-loaders';
166
+ import { ToolkitCoordinator } from '@pie-players/pie-assessment-toolkit';
167
+ import { createPackagedToolRegistry } from '@pie-players/pie-default-tool-loaders';
175
168
 
176
- const toolRegistry = createPackagedToolRegistry({
177
- toolModuleLoaders: DEFAULT_TOOL_MODULE_LOADERS,
178
- });
169
+ const storageKey = 'tool-state:my-assessment';
179
170
  const coordinator = new ToolkitCoordinator({
180
171
  assessmentId: 'my-assessment',
181
- toolRegistry,
182
- tools: { placement: { item: ['answerEliminator'] } }
183
- });
184
-
185
- // Save to localStorage on change
186
- const storageKey = `tool-state:${coordinator.assessmentId}`;
187
- coordinator.elementToolStateStore.setOnStateChange((state) => {
188
- localStorage.setItem(storageKey, JSON.stringify(state));
172
+ toolRegistry: createPackagedToolRegistry(),
173
+ tools: { placement: { item: ['answerEliminator'] } },
174
+ hooks: {
175
+ loadToolState: () => JSON.parse(localStorage.getItem(storageKey) ?? 'null'),
176
+ saveToolState: (state) => localStorage.setItem(storageKey, JSON.stringify(state)),
177
+ },
189
178
  });
190
-
191
- // Load on mount
192
- const saved = localStorage.getItem(storageKey);
193
- if (saved) {
194
- coordinator.elementToolStateStore.loadState(JSON.parse(saved));
195
- }
196
179
  ```
197
180
 
198
181
  ## How It Works
@@ -226,22 +209,22 @@ Elimination styling is applied by a pluggable `EliminationStrategy` built on the
226
209
  - `strikethrough` (default): a `::highlight(pie-answer-eliminated-<id>)` rule renders a line-through over the choice label.
227
210
  - `mask`: a `::highlight(pie-answer-masked-<id>)` rule dims and blurs the choice.
228
211
 
229
- Images need separate treatment: they are replaced elements, so neither `::highlight()` nor `text-decoration` paints on them and a picture choice would look untouched. The `strikethrough` strategy therefore wraps each `img` in the eliminated choice in a `span.pie-answer-eliminator-image-strike` (marked with `data-pie-answer-eliminator-image-strike`) and overlays a decorative, `pointer-events: none` SVG that draws an X corner to corner — upper-left to lower-right and lower-left to upper-right. Restoring the choice unwraps the image and returns the DOM to its original shape. Each diagonal is painted over a wider light casing line (`--pie-answer-eliminator-image-strike-casing-color`) so it stays legible over dark artwork.
212
+ Images need separate treatment: they are replaced elements, so neither `::highlight()` nor `text-decoration` paints on them and a picture choice would look untouched. The `strikethrough` strategy therefore wraps each `img` in the eliminated choice in a `span.pie-answer-eliminator-image-strike` (marked with `data-pie-answer-eliminator-image-strike`) and overlays a decorative, `pointer-events: none` SVG that draws an X corner to corner — upper-left to lower-right and lower-left to upper-right. Restoring the choice unwraps the image and returns the DOM to its original shape. Each diagonal is painted over a wider light casing line (`--pie-answer-eliminator-image-strike-casing-color`, package-private with no compatibility guarantee) so it stays legible over dark artwork.
230
213
 
231
214
  MathJax-rendered math needs its own mark for the same reason. MathJax's CHTML output draws each glyph as an `mjx-c` element with empty `textContent` (the character comes from `::before` generated content, which belongs to no Range), and its SVG output has no text at all — so the highlight had nothing to decorate and a math-only choice looked untouched. For each `mjx-container` in the eliminated choice, the `strikethrough` strategy marks the inner `mjx-math` box with `pie-answer-eliminator-math-strike` plus one of two modifiers, and the theme paints it:
232
215
 
233
216
  | expression | mark | `data-pie-answer-eliminator-math-strike` |
234
217
  | --- | --- | --- |
235
- | single row of symbols | centred line-through, as the prose gets | `line` |
218
+ | single row of symbols | centered line-through, as the prose gets | `line` |
236
219
  | contains a fraction bar or table rule | diagonals, as an eliminated image gets | `cross` |
237
220
 
238
- A centred line on a fraction lands on the math axis — exactly where the fraction bar already sits — so it reads as a recoloured bar rather than an elimination. The split is structural (`mjx-mfrac`, `mjx-mtable`), not height-based: an inline `a/b` is only 1.16× its font size, indistinguishable in height from a radical or a parenthesised row, yet it is precisely the colliding case. Radicals and stacked limits keep the line, since their bars sit at the top or the strike simply crosses the base.
221
+ A centered line on a fraction lands on the math axis — exactly where the fraction bar already sits — so it reads as a recolored bar rather than an elimination. The split is structural (`mjx-mfrac`, `mjx-mtable`), not height-based: an inline `a/b` is only 1.16× its font size, indistinguishable in height from a radical or a parenthesised row, yet it is precisely the colliding case. Radicals and stacked limits keep the line, since their bars sit at the top or the strike simply crosses the base.
239
222
 
240
223
  The inner `mjx-math` box is the paint target, not the container: for inline math `mjx-container` is `display: inline`, so its rect is the surrounding line box while the expression itself overflows it — a fraction sticks out several pixels above and below.
241
224
 
242
225
  Only MathJax containers are marked: natively rendered MathML keeps real text in `mi`/`mn`/`mo`, so the highlight already strikes every token there.
243
226
 
244
- All three treatments — the text line-through, the diagonals over an image, the line over math — are drawn in one colour, `--pie-answer-eliminator-strike-color` (defaulting to `--pie-incorrect`), so a choice mixing prose, pictures, and math reads as a single strike and can be restyled from one place.
227
+ All three treatments — the text line-through, the diagonals over an image, the line over math — are drawn in one color, `--pie-answer-eliminator-strike-color` (a registered `component-public` token, defaulting to `--pie-incorrect`), so a choice mixing prose, pictures, and math reads as a single strike and can be restyled from one place.
245
228
 
246
229
  For browsers without the Highlight API, each strategy falls back to a class on the choice container. Either way the eliminated choice also receives ARIA hooks (`data-pie-answer-eliminated`, plus `aria-disabled`/`aria-hidden` and an offscreen "(eliminated)" announcement) for assistive technology.
247
230
 
@@ -255,7 +238,7 @@ The ElementToolStateStore provides cleanup methods:
255
238
 
256
239
  ```typescript
257
240
  // Clear state for a specific element
258
- store.clearElement('demo:section-1:question-1:mc1');
241
+ store.clearElement('demo:section-1:attempt-1:question-1:mc1');
259
242
 
260
243
  // Clear all answer eliminator state across all elements
261
244
  store.clearTool('answerEliminator');
@@ -279,24 +262,16 @@ interface AnswerEliminatorState {
279
262
  }
280
263
  ```
281
264
 
282
- ## Browser Support
283
-
284
- - Chrome/Edge 90+
285
- - Firefox 88+
286
- - Safari 14+
287
-
288
- Requires ES2020+ support (native ES modules, optional chaining, nullish coalescing).
289
-
290
265
  ## Examples
291
266
 
292
267
  See the [section-demos](../../apps/section-demos/) for complete examples:
293
268
 
294
269
  - **Three Questions Demo**: Element-level answer eliminator with state persistence
295
- - **Paired Passages Demo**: Multi-section assessment with cross-section state
270
+ - **Two Passages Demo**: Multi-section assessment with cross-section state
296
271
 
297
272
  ## Related Documentation
298
273
 
299
- - [ToolkitCoordinator Architecture](../../docs/architecture/architecture.md#toolkitcoordinator-centralized-service-management) - Element-level state design
274
+ - [Assessment toolkit architecture](../../docs/architecture/architecture.md#assessment-toolkit): the coordinator and element-level tool state
300
275
  - [Assessment Toolkit README](../assessment-toolkit/README.md) - Toolkit overview
301
276
  - [Section Player README](../section-player/README.md) - Integration guide
302
277
 
@@ -0,0 +1,127 @@
1
+ {
2
+ "schemaVersion": "2.1.0",
3
+ "modules": [
4
+ {
5
+ "kind": "javascript-module",
6
+ "path": "dist/tool-answer-eliminator.js",
7
+ "declarations": [
8
+ {
9
+ "kind": "class",
10
+ "description": "Process-of-elimination tool for the item in its enclosing `<pie-item-scope>`: it adds a toggle beside each multiple-choice, EBSR and inline-dropdown choice that strikes the choice through or masks it, per `strategy`. It renders nothing of its own and is active while `visible` or `always-on` is set. Eliminations persist in the runtime context's element tool state store, under the key `elementStateKeys` gives each PIE element.",
11
+ "name": "ToolAnswerEliminator",
12
+ "tagName": "pie-tool-answer-eliminator",
13
+ "customElement": true,
14
+ "members": [
15
+ {
16
+ "kind": "field",
17
+ "name": "visible",
18
+ "type": {
19
+ "text": "boolean"
20
+ },
21
+ "default": "false",
22
+ "attribute": "visible"
23
+ },
24
+ {
25
+ "kind": "field",
26
+ "name": "strategy",
27
+ "type": {
28
+ "text": "'strikethrough' | 'mask'"
29
+ },
30
+ "default": "'strikethrough'",
31
+ "attribute": "strategy"
32
+ },
33
+ {
34
+ "kind": "field",
35
+ "name": "alwaysOn",
36
+ "type": {
37
+ "text": "boolean"
38
+ },
39
+ "default": "false",
40
+ "attribute": "always-on"
41
+ },
42
+ {
43
+ "kind": "field",
44
+ "name": "buttonAlignment",
45
+ "type": {
46
+ "text": "'left' | 'right' | 'inline'"
47
+ },
48
+ "default": "'right'",
49
+ "attribute": "button-alignment"
50
+ },
51
+ {
52
+ "kind": "field",
53
+ "name": "elementStateKeys",
54
+ "type": {
55
+ "text": "ElementStateKeys"
56
+ },
57
+ "default": "{}",
58
+ "description": "Store key per PIE element, by model id (JS property only)",
59
+ "attribute": "elementstatekeys"
60
+ }
61
+ ],
62
+ "attributes": [
63
+ {
64
+ "name": "visible",
65
+ "type": {
66
+ "text": "boolean"
67
+ },
68
+ "default": "false",
69
+ "fieldName": "visible"
70
+ },
71
+ {
72
+ "name": "strategy",
73
+ "type": {
74
+ "text": "string"
75
+ },
76
+ "default": "'strikethrough'",
77
+ "fieldName": "strategy"
78
+ },
79
+ {
80
+ "name": "always-on",
81
+ "type": {
82
+ "text": "boolean"
83
+ },
84
+ "default": "false",
85
+ "fieldName": "alwaysOn"
86
+ },
87
+ {
88
+ "name": "button-alignment",
89
+ "type": {
90
+ "text": "string"
91
+ },
92
+ "default": "'right'",
93
+ "fieldName": "buttonAlignment"
94
+ },
95
+ {
96
+ "name": "elementstatekeys",
97
+ "type": {
98
+ "text": "ElementStateKeys"
99
+ },
100
+ "default": "{}",
101
+ "description": "Store key per PIE element, by model id (JS property only)",
102
+ "fieldName": "elementStateKeys"
103
+ }
104
+ ],
105
+ "cssProperties": [
106
+ {
107
+ "name": "--pie-answer-eliminator-strike-color",
108
+ "description": "Single colour for every part of an elimination: the text line-through, the diagonals over an image, and the line or diagonals over rendered math, so one choice reads as one treatment. Must fall back through canonical --pie-incorrect so a host that themes only the canonical feedback tokens still gets a coherent strike."
109
+ }
110
+ ],
111
+ "x-pie": {
112
+ "shadow": "open"
113
+ }
114
+ }
115
+ ],
116
+ "exports": [
117
+ {
118
+ "kind": "custom-element-definition",
119
+ "name": "pie-tool-answer-eliminator",
120
+ "declaration": {
121
+ "name": "ToolAnswerEliminator"
122
+ }
123
+ }
124
+ ]
125
+ }
126
+ ]
127
+ }