@pie-players/pie-players-shared 0.3.66 → 0.3.68

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.
Files changed (89) hide show
  1. package/README.md +59 -0
  2. package/dist/formative/index.d.ts +16 -0
  3. package/dist/formative/index.js +5 -0
  4. package/dist/formative/mastery.d.ts +16 -0
  5. package/dist/formative/mastery.js +43 -0
  6. package/dist/formative/outcome.d.ts +19 -0
  7. package/dist/formative/outcome.js +90 -0
  8. package/dist/formative/policy.d.ts +37 -0
  9. package/dist/formative/policy.js +86 -0
  10. package/dist/formative/session.d.ts +19 -0
  11. package/dist/formative/session.js +113 -0
  12. package/dist/formative/state.d.ts +60 -0
  13. package/dist/formative/state.js +140 -0
  14. package/dist/formative/types.d.ts +164 -0
  15. package/dist/formative/types.js +13 -0
  16. package/dist/i18n/catalogs.d.ts +38 -0
  17. package/dist/i18n/catalogs.js +47 -0
  18. package/dist/i18n/index.d.ts +28 -4
  19. package/dist/i18n/index.js +34 -3
  20. package/dist/i18n/language-tags.d.ts +51 -0
  21. package/dist/i18n/language-tags.js +84 -0
  22. package/dist/i18n/messages/en-US.d.ts +601 -0
  23. package/dist/i18n/messages/en-US.js +617 -0
  24. package/dist/i18n/messages/nl-NL.d.ts +516 -0
  25. package/dist/i18n/messages/nl-NL.js +516 -0
  26. package/dist/i18n/provider.d.ts +168 -0
  27. package/dist/i18n/provider.js +472 -0
  28. package/dist/i18n/types.d.ts +177 -28
  29. package/dist/i18n/types.js +6 -2
  30. package/dist/index.d.ts +3 -1
  31. package/dist/index.js +2 -0
  32. package/dist/pie/custom-element-define.d.ts +24 -0
  33. package/dist/pie/custom-element-define.js +36 -6
  34. package/dist/pie/index.d.ts +1 -1
  35. package/dist/pie/index.js +1 -1
  36. package/dist/pie/versioned-tag.js +3 -2
  37. package/dist/security/sanitize-forbidden-lists.d.ts +8 -0
  38. package/dist/security/sanitize-forbidden-lists.js +41 -0
  39. package/dist/security/sanitize-item-markup.js +3 -36
  40. package/dist/security/sanitize-svg-icon.js +3 -25
  41. package/dist/security/wrap-overwide-images.d.ts +5 -10
  42. package/dist/security/wrap-overwide-images.js +13 -62
  43. package/dist/security/wrap-overwide-tables.d.ts +5 -10
  44. package/dist/security/wrap-overwide-tables.js +13 -62
  45. package/dist/security/wrap-overwide.d.ts +51 -0
  46. package/dist/security/wrap-overwide.js +91 -0
  47. package/dist/server/npm-auth-env.d.ts +13 -0
  48. package/dist/server/npm-auth-env.js +64 -0
  49. package/dist/timed-media/index.d.ts +17 -0
  50. package/dist/timed-media/index.js +16 -0
  51. package/dist/timed-media/media-element-source.d.ts +36 -0
  52. package/dist/timed-media/media-element-source.js +121 -0
  53. package/dist/timed-media/policy.d.ts +36 -0
  54. package/dist/timed-media/policy.js +297 -0
  55. package/dist/timed-media/session.d.ts +21 -0
  56. package/dist/timed-media/session.js +85 -0
  57. package/dist/timed-media/state.d.ts +66 -0
  58. package/dist/timed-media/state.js +357 -0
  59. package/dist/timed-media/types.d.ts +343 -0
  60. package/dist/timed-media/types.js +12 -0
  61. package/dist/tools/term-lookup.d.ts +189 -0
  62. package/dist/tools/term-lookup.js +284 -0
  63. package/dist/types/index.d.ts +105 -1
  64. package/dist/ui/attribute-coercion.d.ts +1 -0
  65. package/dist/ui/attribute-coercion.js +19 -0
  66. package/dist/ui/content-styles.d.ts +11 -3
  67. package/dist/ui/content-styles.js +42 -13
  68. package/dist/ui/first-focusable.d.ts +22 -0
  69. package/dist/ui/first-focusable.js +47 -0
  70. package/dist/ui/focus-trap.js +7 -2
  71. package/dist/ui/pointer-drag.d.ts +30 -0
  72. package/dist/ui/pointer-drag.js +34 -0
  73. package/package.json +35 -3
  74. package/dist/i18n/loader.d.ts +0 -35
  75. package/dist/i18n/loader.js +0 -132
  76. package/dist/i18n/simple-i18n.d.ts +0 -68
  77. package/dist/i18n/simple-i18n.js +0 -198
  78. package/dist/i18n/translations/ar/common.json +0 -36
  79. package/dist/i18n/translations/ar/toolkit.json +0 -48
  80. package/dist/i18n/translations/ar/tools.json +0 -103
  81. package/dist/i18n/translations/en/common.json +0 -36
  82. package/dist/i18n/translations/en/toolkit.json +0 -48
  83. package/dist/i18n/translations/en/tools.json +0 -103
  84. package/dist/i18n/translations/es/common.json +0 -36
  85. package/dist/i18n/translations/es/toolkit.json +0 -48
  86. package/dist/i18n/translations/es/tools.json +0 -103
  87. package/dist/i18n/translations/zh/common.json +0 -36
  88. package/dist/i18n/translations/zh/toolkit.json +0 -48
  89. package/dist/i18n/translations/zh/tools.json +0 -103
package/README.md CHANGED
@@ -19,12 +19,71 @@ Supported subpaths are declared in `package.json`:
19
19
  - `@pie-players/pie-players-shared/security`
20
20
  - `@pie-players/pie-players-shared/object`
21
21
  - `@pie-players/pie-players-shared/types`
22
+ - `@pie-players/pie-players-shared/formative`
23
+ - `@pie-players/pie-players-shared/timed-media`
22
24
  - `@pie-players/pie-players-shared/pie`
23
25
  - `@pie-players/pie-players-shared/pie/tag-names`
24
26
  - `@pie-players/pie-players-shared/loaders`
25
27
  - `@pie-players/pie-players-shared/server/npm-registry`
26
28
  - `@pie-players/pie-players-shared/i18n`
27
29
 
30
+ ## Formative Delivery
31
+
32
+ `@pie-players/pie-players-shared/formative` holds the policy resolution, outcome
33
+ aggregation, Try-state reducer, item view, and mastery rollup that
34
+ `pie-section-player` drives. It is pure — no DOM, no timers, no element registry
35
+ — so it is testable without a browser and importable by an adapter that has no
36
+ player.
37
+
38
+ ```ts
39
+ import {
40
+ aggregateFormativeOutcome,
41
+ resolveFormativePolicy,
42
+ resolveFormativeItemView,
43
+ rollupFormativeMastery,
44
+ } from "@pie-players/pie-players-shared/formative";
45
+ ```
46
+
47
+ The authored half — `FormativeDeliveryPolicy` on `AssessmentSection` and
48
+ `FormativeItemPolicy` on `AssessmentItemRef` — is re-exported from the package
49
+ root and from `/types`, beside the section types it annotates.
50
+
51
+ The contract is [`docs/prds/formative-delivery-contract.md`](../../docs/prds/formative-delivery-contract.md),
52
+ including the QTI 3 mapping and why PIE says **Try** rather than "attempt".
53
+
54
+ ## Timed Media
55
+
56
+ `@pie-players/pie-players-shared/timed-media` holds the timed-media section
57
+ vocabulary, its validation, the cue reduction, the session slice, and the **Media
58
+ Time Source** port that a section reaches media through. Pure apart from
59
+ `createMediaElementTimeSource`, which is inert until called, so the contract stays
60
+ importable in Node beside the rest.
61
+
62
+ ```ts
63
+ import {
64
+ createMediaElementTimeSource,
65
+ normalizeTimedMediaSectionData,
66
+ reduceTimedMediaState,
67
+ resolveTimedMediaProjection,
68
+ type MediaTimeSource,
69
+ } from "@pie-players/pie-players-shared/timed-media";
70
+ ```
71
+
72
+ The port is shaped after `HTMLMediaElement` on purpose — a native `<video>`
73
+ satisfies it through the adapter above — with two departures: seeking is
74
+ `seekTo(seconds)` rather than a writable `currentTime`, and `capabilities` declares
75
+ `canPause` / `canRestrictSeeking`, because a source that cannot control playback
76
+ needs a way to say so. A host implementing the port directly needs no PIE element to
77
+ deliver timed media.
78
+
79
+ The authored half — `sectionType` and `timedMedia` on `AssessmentSection` — is
80
+ re-exported from the package root and from `/types`, beside the section types it
81
+ annotates.
82
+
83
+ The contract is [`docs/prds/timed-media-section-contract.md`](../../docs/prds/timed-media-section-contract.md),
84
+ including why cue gate conditions name the formative vocabulary and why an
85
+ unenforceable policy degrades to advisory rather than failing closed.
86
+
28
87
  ## Browser ESM Element Contract
29
88
 
30
89
  The canonical producer-side contract lives with the `pie-elements-ng` package
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Formative delivery: Try state, feedback reveal, and section mastery.
3
+ *
4
+ * Pure and Node-safe by design — no DOM, no timers, no element registry — so
5
+ * the contract is testable without a browser and an adapter can import it
6
+ * without pulling in a player. Everything that touches the DOM stays in
7
+ * `@pie-players/pie-section-player`.
8
+ *
9
+ * Contract: `docs/prds/formative-delivery-contract.md`.
10
+ */
11
+ export type { FormativeAction, FormativeCorrectness, FormativeDeliveryPolicy, FormativeEnvOverride, FormativeFeedbackReveal, FormativeItemPolicy, FormativeItemState, FormativeItemView, FormativeMasteryRollup, FormativeRevealTiming, FormativeScoredOutcome, FormativeSectionProjection, FormativeSectionSlice, FormativeTryLimit, FormativeTryOutcome, ResolvedFormativePolicy, } from "./types.js";
12
+ export { FORMATIVE_POLICY_DEFAULTS, isFormativeSectionEnabled, resolveFormativePolicies, resolveFormativePolicy, } from "./policy.js";
13
+ export { aggregateFormativeOutcome } from "./outcome.js";
14
+ export { FORMATIVE_ITEM_STATE_VERSION, createFormativeItemState, hideFormativeItem, recordFormativeTry, resolveFormativeItemView, retryFormativeItem, revealFormativeItem, } from "./state.js";
15
+ export { FORMATIVE_MASTERY_VERSION, rollupFormativeMastery } from "./mastery.js";
16
+ export { FORMATIVE_SLICE_VERSION, normalizeFormativeSectionSlice, toFormativeSectionSlice, } from "./session.js";
@@ -0,0 +1,5 @@
1
+ export { FORMATIVE_POLICY_DEFAULTS, isFormativeSectionEnabled, resolveFormativePolicies, resolveFormativePolicy, } from "./policy.js";
2
+ export { aggregateFormativeOutcome } from "./outcome.js";
3
+ export { FORMATIVE_ITEM_STATE_VERSION, createFormativeItemState, hideFormativeItem, recordFormativeTry, resolveFormativeItemView, retryFormativeItem, revealFormativeItem, } from "./state.js";
4
+ export { FORMATIVE_MASTERY_VERSION, rollupFormativeMastery } from "./mastery.js";
5
+ export { FORMATIVE_SLICE_VERSION, normalizeFormativeSectionSlice, toFormativeSectionSlice, } from "./session.js";
@@ -0,0 +1,16 @@
1
+ import type { FormativeItemState, FormativeMasteryRollup } from "./types.js";
2
+ export declare const FORMATIVE_MASTERY_VERSION: 1;
3
+ /**
4
+ * Roll Try outcomes up to the section.
5
+ *
6
+ * The denominator is the load-bearing decision. An item whose last outcome was
7
+ * `"unknown"` — a rubric item, or one whose element ships no controller — is
8
+ * excluded rather than counted wrong, because counting it wrong would report a
9
+ * false negative to whatever consumes the rollup. An *untried* item is not
10
+ * excluded: nothing yet says it cannot be scored, so it keeps the section from
11
+ * reading as complete after one correct answer.
12
+ */
13
+ export declare function rollupFormativeMastery(args: {
14
+ itemIdentifiers: readonly string[];
15
+ states: Record<string, FormativeItemState>;
16
+ }): FormativeMasteryRollup;
@@ -0,0 +1,43 @@
1
+ export const FORMATIVE_MASTERY_VERSION = 1;
2
+ /**
3
+ * Roll Try outcomes up to the section.
4
+ *
5
+ * The denominator is the load-bearing decision. An item whose last outcome was
6
+ * `"unknown"` — a rubric item, or one whose element ships no controller — is
7
+ * excluded rather than counted wrong, because counting it wrong would report a
8
+ * false negative to whatever consumes the rollup. An *untried* item is not
9
+ * excluded: nothing yet says it cannot be scored, so it keeps the section from
10
+ * reading as complete after one correct answer.
11
+ */
12
+ export function rollupFormativeMastery(args) {
13
+ const identifiers = args.itemIdentifiers ?? [];
14
+ const totalItems = identifiers.length;
15
+ let notScorableItems = 0;
16
+ let masteredItems = 0;
17
+ let triedItems = 0;
18
+ let triesToMasteryTotal = 0;
19
+ for (const identifier of identifiers) {
20
+ const state = args.states?.[identifier];
21
+ if (!state)
22
+ continue;
23
+ if (state.tryCount > 0)
24
+ triedItems += 1;
25
+ if (state.lastOutcome?.correctness === "unknown")
26
+ notScorableItems += 1;
27
+ if (typeof state.firstCorrectTry === "number") {
28
+ masteredItems += 1;
29
+ triesToMasteryTotal += state.firstCorrectTry;
30
+ }
31
+ }
32
+ const scorableItems = Math.max(0, totalItems - notScorableItems);
33
+ return {
34
+ version: FORMATIVE_MASTERY_VERSION,
35
+ totalItems,
36
+ scorableItems,
37
+ masteredItems,
38
+ triedItems,
39
+ averageTriesToMastery: masteredItems > 0 ? triesToMasteryTotal / masteredItems : undefined,
40
+ // A section with nothing scorable is never vacuously complete.
41
+ complete: scorableItems > 0 && masteredItems === scorableItems,
42
+ };
43
+ }
@@ -0,0 +1,19 @@
1
+ import type { FormativeScoredOutcome, FormativeTryOutcome } from "./types.js";
2
+ /**
3
+ * Reduce the array `pie-item-player.provideScore()` returns to one Try outcome.
4
+ *
5
+ * The aggregation mirrors the policy the persisted API scoring path already
6
+ * documents (`docs/item-player/scoring-and-rubrics.md`), so a browser-derived
7
+ * formative result and a server-derived score do not disagree about what a
8
+ * multi-element item is worth:
9
+ *
10
+ * - one scored outcome: `points = score`, `max = max ?? 1`;
11
+ * - several: the mean of normalized fractions, with `max = 1`;
12
+ * - none scored: `"unknown"`, no points.
13
+ *
14
+ * `provideScore()` returns `undefined` in the slot of any model whose element or
15
+ * controller was missing, which is exactly the rubric and no-controller case, so
16
+ * those slots count toward `totalElementCount` and not toward
17
+ * `scoredElementCount`.
18
+ */
19
+ export declare function aggregateFormativeOutcome(outcomes: ReadonlyArray<FormativeScoredOutcome | null | undefined> | null): FormativeTryOutcome;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Full credit is a float comparison, so it needs a tolerance. Averaging
3
+ * normalized fractions ("1/3 + 1/3 + 1/3") lands a hair under 1 in binary
4
+ * floating point, and reporting that as partial credit would be a defect
5
+ * visible to a learner.
6
+ */
7
+ const FULL_CREDIT_EPSILON = 1e-9;
8
+ function isScored(outcome) {
9
+ return (!!outcome &&
10
+ typeof outcome.score === "number" &&
11
+ Number.isFinite(outcome.score));
12
+ }
13
+ /** A denominator of 0 or a missing `max` both mean "normalized to 1". */
14
+ function denominatorOf(outcome) {
15
+ const max = outcome.max;
16
+ return typeof max === "number" && Number.isFinite(max) && max > 0 ? max : 1;
17
+ }
18
+ /**
19
+ * Retained verbatim for a host rendering its own feedback. Empty slots are
20
+ * dropped rather than preserved as holes: every real entry identifies its own
21
+ * model, and `null` in a persisted array carries no information a host could
22
+ * use.
23
+ */
24
+ function definedOutcomes(outcomes) {
25
+ const defined = outcomes.filter((outcome) => !!outcome && typeof outcome === "object");
26
+ return defined.length > 0 ? defined : undefined;
27
+ }
28
+ function classify(points, max) {
29
+ if (max <= 0)
30
+ return "unknown";
31
+ if (points + FULL_CREDIT_EPSILON >= max)
32
+ return "correct";
33
+ if (points <= 0)
34
+ return "incorrect";
35
+ return "partial";
36
+ }
37
+ /**
38
+ * Reduce the array `pie-item-player.provideScore()` returns to one Try outcome.
39
+ *
40
+ * The aggregation mirrors the policy the persisted API scoring path already
41
+ * documents (`docs/item-player/scoring-and-rubrics.md`), so a browser-derived
42
+ * formative result and a server-derived score do not disagree about what a
43
+ * multi-element item is worth:
44
+ *
45
+ * - one scored outcome: `points = score`, `max = max ?? 1`;
46
+ * - several: the mean of normalized fractions, with `max = 1`;
47
+ * - none scored: `"unknown"`, no points.
48
+ *
49
+ * `provideScore()` returns `undefined` in the slot of any model whose element or
50
+ * controller was missing, which is exactly the rubric and no-controller case, so
51
+ * those slots count toward `totalElementCount` and not toward
52
+ * `scoredElementCount`.
53
+ */
54
+ export function aggregateFormativeOutcome(outcomes) {
55
+ const all = Array.isArray(outcomes) ? outcomes : [];
56
+ const scored = all.filter(isScored);
57
+ const totalElementCount = all.length;
58
+ if (scored.length === 0) {
59
+ return {
60
+ correctness: "unknown",
61
+ scoredElementCount: 0,
62
+ totalElementCount,
63
+ elementOutcomes: definedOutcomes(all),
64
+ };
65
+ }
66
+ let points;
67
+ let max;
68
+ if (scored.length === 1) {
69
+ const only = scored[0];
70
+ max = denominatorOf(only);
71
+ points = only.score;
72
+ }
73
+ else {
74
+ max = 1;
75
+ points =
76
+ scored.reduce((total, outcome) => total + outcome.score / denominatorOf(outcome), 0) / scored.length;
77
+ }
78
+ // An element reporting more than its own maximum is an element defect. The
79
+ // aggregate is a ratio, so an out-of-range input has no meaning here; clamp
80
+ // rather than let it read as full credit on a partially correct response.
81
+ const clamped = Math.min(Math.max(points, 0), max);
82
+ return {
83
+ correctness: classify(clamped, max),
84
+ points: clamped,
85
+ max,
86
+ scoredElementCount: scored.length,
87
+ totalElementCount,
88
+ elementOutcomes: definedOutcomes(all),
89
+ };
90
+ }
@@ -0,0 +1,37 @@
1
+ import type { FormativeDeliveryPolicy, FormativeItemPolicy, ResolvedFormativePolicy } from "./types.js";
2
+ /**
3
+ * Built-in defaults. `enabled: false` is what makes this contract invisible to
4
+ * every section authored before it: an absent `formative` field resolves to a
5
+ * disabled policy, and a disabled policy produces no state, no projection and
6
+ * no control.
7
+ *
8
+ * `maxTries: 1` matches QTI 3's `qti-item-session-control@max-attempts` default.
9
+ */
10
+ export declare const FORMATIVE_POLICY_DEFAULTS: ResolvedFormativePolicy;
11
+ /**
12
+ * Merge built-in defaults, the section policy, and one item ref's override, in
13
+ * that order. Each field overrides independently, which is the order QTI 3 uses
14
+ * for `qti-item-session-control` on a section and on an item ref.
15
+ *
16
+ * Unrecognized values fall through to the layer beneath rather than failing the
17
+ * section: authored policy is host-supplied data, and a typo in one field should
18
+ * not cost the learner the whole item.
19
+ */
20
+ export declare function resolveFormativePolicy(sectionPolicy?: FormativeDeliveryPolicy | null, itemPolicy?: FormativeItemPolicy | null): ResolvedFormativePolicy;
21
+ /**
22
+ * Resolve one policy per item in section order.
23
+ *
24
+ * Keyed by the identifier the caller supplies, which in the section runtime is
25
+ * the canonical item id — the same key `itemSessions` and the completion map
26
+ * use, so a formative state and an item session are always addressable by one
27
+ * id.
28
+ */
29
+ export declare function resolveFormativePolicies(args: {
30
+ sectionPolicy?: FormativeDeliveryPolicy | null;
31
+ items: ReadonlyArray<{
32
+ identifier: string;
33
+ policy?: FormativeItemPolicy | null;
34
+ }>;
35
+ }): Record<string, ResolvedFormativePolicy>;
36
+ /** True when at least one item in the section delivers formatively. */
37
+ export declare function isFormativeSectionEnabled(policies: Record<string, ResolvedFormativePolicy>): boolean;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Built-in defaults. `enabled: false` is what makes this contract invisible to
3
+ * every section authored before it: an absent `formative` field resolves to a
4
+ * disabled policy, and a disabled policy produces no state, no projection and
5
+ * no control.
6
+ *
7
+ * `maxTries: 1` matches QTI 3's `qti-item-session-control@max-attempts` default.
8
+ */
9
+ export const FORMATIVE_POLICY_DEFAULTS = {
10
+ enabled: false,
11
+ maxTries: 1,
12
+ feedback: "correctness",
13
+ revealOn: "on-try",
14
+ };
15
+ function normalizeTryLimit(value) {
16
+ if (value === "unlimited")
17
+ return "unlimited";
18
+ if (typeof value !== "number" || !Number.isFinite(value))
19
+ return undefined;
20
+ const rounded = Math.trunc(value);
21
+ // Zero and negatives are meaningless here rather than shorthand for
22
+ // unlimited. QTI spells unlimited as `max-attempts="0"`; the adapter
23
+ // translates, this contract does not overload the number.
24
+ return rounded >= 1 ? rounded : undefined;
25
+ }
26
+ function normalizeFeedback(value) {
27
+ return value === "none" || value === "correctness" || value === "solution"
28
+ ? value
29
+ : undefined;
30
+ }
31
+ function normalizeRevealTiming(value) {
32
+ return value === "on-try" || value === "on-final-try" ? value : undefined;
33
+ }
34
+ /**
35
+ * Merge built-in defaults, the section policy, and one item ref's override, in
36
+ * that order. Each field overrides independently, which is the order QTI 3 uses
37
+ * for `qti-item-session-control` on a section and on an item ref.
38
+ *
39
+ * Unrecognized values fall through to the layer beneath rather than failing the
40
+ * section: authored policy is host-supplied data, and a typo in one field should
41
+ * not cost the learner the whole item.
42
+ */
43
+ export function resolveFormativePolicy(sectionPolicy, itemPolicy) {
44
+ const enabled = typeof itemPolicy?.enabled === "boolean"
45
+ ? itemPolicy.enabled
46
+ : typeof sectionPolicy?.enabled === "boolean"
47
+ ? sectionPolicy.enabled
48
+ : FORMATIVE_POLICY_DEFAULTS.enabled;
49
+ const maxTries = normalizeTryLimit(itemPolicy?.maxTries) ??
50
+ normalizeTryLimit(sectionPolicy?.maxTries) ??
51
+ FORMATIVE_POLICY_DEFAULTS.maxTries;
52
+ const feedback = normalizeFeedback(itemPolicy?.feedback) ??
53
+ normalizeFeedback(sectionPolicy?.feedback) ??
54
+ FORMATIVE_POLICY_DEFAULTS.feedback;
55
+ const requestedRevealOn = normalizeRevealTiming(itemPolicy?.revealOn) ??
56
+ normalizeRevealTiming(sectionPolicy?.revealOn) ??
57
+ FORMATIVE_POLICY_DEFAULTS.revealOn;
58
+ // `"on-final-try"` has no referent under unlimited Tries — there is no final
59
+ // Try to reveal on — so it resolves to the only reading that keeps feedback
60
+ // reachable.
61
+ const revealOn = requestedRevealOn === "on-final-try" && maxTries === "unlimited"
62
+ ? "on-try"
63
+ : requestedRevealOn;
64
+ return { enabled, maxTries, feedback, revealOn };
65
+ }
66
+ /**
67
+ * Resolve one policy per item in section order.
68
+ *
69
+ * Keyed by the identifier the caller supplies, which in the section runtime is
70
+ * the canonical item id — the same key `itemSessions` and the completion map
71
+ * use, so a formative state and an item session are always addressable by one
72
+ * id.
73
+ */
74
+ export function resolveFormativePolicies(args) {
75
+ const resolved = {};
76
+ for (const item of args.items) {
77
+ if (!item?.identifier)
78
+ continue;
79
+ resolved[item.identifier] = resolveFormativePolicy(args.sectionPolicy, item.policy);
80
+ }
81
+ return resolved;
82
+ }
83
+ /** True when at least one item in the section delivers formatively. */
84
+ export function isFormativeSectionEnabled(policies) {
85
+ return Object.values(policies).some((policy) => policy.enabled);
86
+ }
@@ -0,0 +1,19 @@
1
+ import type { FormativeItemState, FormativeSectionSlice } from "./types.js";
2
+ export declare const FORMATIVE_SLICE_VERSION: 1;
3
+ export declare function toFormativeSectionSlice(states: Record<string, FormativeItemState>): FormativeSectionSlice;
4
+ /**
5
+ * Validate a persisted slice against the section it is being restored into.
6
+ *
7
+ * Returns `null` for a slice this build cannot read, which the caller treats as
8
+ * "start formative state clean". Rejection is deliberately narrow: it discards
9
+ * formative progress and nothing else, so a version bump here never costs a
10
+ * learner their responses — item sessions travel in the same snapshot and are
11
+ * normalized separately.
12
+ *
13
+ * An absent slice is `null` too, and indistinguishable from a pre-formative
14
+ * save, which is what keeps existing snapshots valid.
15
+ */
16
+ export declare function normalizeFormativeSectionSlice(args: {
17
+ slice: unknown;
18
+ allowedItemIdentifiers: readonly string[];
19
+ }): Record<string, FormativeItemState> | null;
@@ -0,0 +1,113 @@
1
+ import { FORMATIVE_ITEM_STATE_VERSION } from "./state.js";
2
+ export const FORMATIVE_SLICE_VERSION = 1;
3
+ const CORRECTNESS_VALUES = [
4
+ "correct",
5
+ "partial",
6
+ "incorrect",
7
+ "unknown",
8
+ ];
9
+ function readFiniteNumber(value) {
10
+ return typeof value === "number" && Number.isFinite(value)
11
+ ? value
12
+ : undefined;
13
+ }
14
+ function readNonNegativeInteger(value) {
15
+ const numeric = readFiniteNumber(value);
16
+ if (numeric === undefined)
17
+ return undefined;
18
+ const rounded = Math.trunc(numeric);
19
+ return rounded >= 0 ? rounded : undefined;
20
+ }
21
+ function normalizeOutcome(value) {
22
+ if (!value || typeof value !== "object")
23
+ return undefined;
24
+ const raw = value;
25
+ const correctness = CORRECTNESS_VALUES.find((candidate) => candidate === raw.correctness);
26
+ if (!correctness)
27
+ return undefined;
28
+ return {
29
+ correctness,
30
+ points: readFiniteNumber(raw.points),
31
+ max: readFiniteNumber(raw.max),
32
+ scoredElementCount: readNonNegativeInteger(raw.scoredElementCount) ?? 0,
33
+ totalElementCount: readNonNegativeInteger(raw.totalElementCount) ?? 0,
34
+ elementOutcomes: normalizeElementOutcomes(raw.elementOutcomes),
35
+ };
36
+ }
37
+ /**
38
+ * Element outcomes are element-shaped by contract, so validation stops at "an
39
+ * array of objects". Reaching further would mean this module deciding what an
40
+ * element's outcome may contain, which is the element's business.
41
+ */
42
+ function normalizeElementOutcomes(value) {
43
+ if (!Array.isArray(value))
44
+ return undefined;
45
+ const entries = value.filter((entry) => !!entry && typeof entry === "object" && !Array.isArray(entry));
46
+ return entries.length > 0 ? entries : undefined;
47
+ }
48
+ function normalizeRevealOverride(value) {
49
+ return value === "correctness" || value === "solution" || value === "none"
50
+ ? value
51
+ : undefined;
52
+ }
53
+ function normalizeItemState(itemIdentifier, value) {
54
+ if (!value || typeof value !== "object")
55
+ return null;
56
+ const raw = value;
57
+ if (raw.version !== FORMATIVE_ITEM_STATE_VERSION)
58
+ return null;
59
+ const tryCount = readNonNegativeInteger(raw.tryCount);
60
+ if (tryCount === undefined)
61
+ return null;
62
+ const firstCorrectTry = readNonNegativeInteger(raw.firstCorrectTry);
63
+ return {
64
+ version: FORMATIVE_ITEM_STATE_VERSION,
65
+ itemIdentifier,
66
+ tryCount,
67
+ revealed: raw.revealed === true,
68
+ lastOutcome: normalizeOutcome(raw.lastOutcome),
69
+ firstCorrectTry: firstCorrectTry !== undefined && firstCorrectTry >= 1
70
+ ? firstCorrectTry
71
+ : undefined,
72
+ revealOverride: normalizeRevealOverride(raw.revealOverride),
73
+ };
74
+ }
75
+ export function toFormativeSectionSlice(states) {
76
+ return {
77
+ version: FORMATIVE_SLICE_VERSION,
78
+ items: { ...states },
79
+ };
80
+ }
81
+ /**
82
+ * Validate a persisted slice against the section it is being restored into.
83
+ *
84
+ * Returns `null` for a slice this build cannot read, which the caller treats as
85
+ * "start formative state clean". Rejection is deliberately narrow: it discards
86
+ * formative progress and nothing else, so a version bump here never costs a
87
+ * learner their responses — item sessions travel in the same snapshot and are
88
+ * normalized separately.
89
+ *
90
+ * An absent slice is `null` too, and indistinguishable from a pre-formative
91
+ * save, which is what keeps existing snapshots valid.
92
+ */
93
+ export function normalizeFormativeSectionSlice(args) {
94
+ const { slice } = args;
95
+ if (!slice || typeof slice !== "object")
96
+ return null;
97
+ const raw = slice;
98
+ if (raw.version !== FORMATIVE_SLICE_VERSION)
99
+ return null;
100
+ if (!raw.items || typeof raw.items !== "object")
101
+ return null;
102
+ const allowed = new Set(args.allowedItemIdentifiers ?? []);
103
+ const normalized = {};
104
+ for (const [itemIdentifier, entry] of Object.entries(raw.items)) {
105
+ if (!allowed.has(itemIdentifier))
106
+ continue;
107
+ const state = normalizeItemState(itemIdentifier, entry);
108
+ if (!state)
109
+ continue;
110
+ normalized[itemIdentifier] = state;
111
+ }
112
+ return normalized;
113
+ }
@@ -0,0 +1,60 @@
1
+ import type { FormativeFeedbackReveal, FormativeItemState, FormativeItemView, FormativeTryOutcome, ResolvedFormativePolicy } from "./types.js";
2
+ export declare const FORMATIVE_ITEM_STATE_VERSION: 1;
3
+ export declare function createFormativeItemState(itemIdentifier: string): FormativeItemState;
4
+ /**
5
+ * Record one Try.
6
+ *
7
+ * Idempotent against a double submit: a Try is only counted when the current
8
+ * state can be checked, so a second click landing before the projection
9
+ * round-trips is dropped rather than spending a Try.
10
+ */
11
+ export declare function recordFormativeTry(args: {
12
+ state?: FormativeItemState;
13
+ itemIdentifier: string;
14
+ policy: ResolvedFormativePolicy;
15
+ outcome: FormativeTryOutcome;
16
+ }): FormativeItemState;
17
+ /**
18
+ * Dismiss a reveal and reopen the item for editing. Withdrawing `revealed` is
19
+ * what withdraws the env override, so the item returns to the section's own
20
+ * mode.
21
+ *
22
+ * A learner action, so it respects the Try budget: an item with none left keeps
23
+ * its feedback on screen. `hideFormativeItem` is the host-authority version.
24
+ */
25
+ export declare function retryFormativeItem(args: {
26
+ state?: FormativeItemState;
27
+ policy: ResolvedFormativePolicy;
28
+ }): FormativeItemState | undefined;
29
+ /**
30
+ * Reveal on host authority — a teacher-driven "show the answer".
31
+ *
32
+ * Spends no Try and ignores the Try budget and `revealOn`, because none of those
33
+ * bound a decision the host has already taken. `feedback` is required rather
34
+ * than defaulted from the policy: a forced reveal under `feedback: "none"` would
35
+ * project nothing, so the caller states what to show.
36
+ *
37
+ * Works on an item with no Try yet. The element renders evaluate mode over
38
+ * whatever response is there, including none, which is what "show the answer"
39
+ * means before the learner has answered.
40
+ */
41
+ export declare function revealFormativeItem(args: {
42
+ state?: FormativeItemState;
43
+ itemIdentifier: string;
44
+ policy: ResolvedFormativePolicy;
45
+ feedback: Exclude<FormativeFeedbackReveal, "none">;
46
+ }): FormativeItemState;
47
+ /**
48
+ * Withdraw a reveal on host authority. Unlike a learner retry this ignores the
49
+ * Try budget: a host that revealed an item with no Tries left must be able to
50
+ * put it back.
51
+ */
52
+ export declare function hideFormativeItem(args: {
53
+ state?: FormativeItemState;
54
+ policy: ResolvedFormativePolicy;
55
+ }): FormativeItemState | undefined;
56
+ /** Derive everything a rendering component needs from policy plus state. */
57
+ export declare function resolveFormativeItemView(args: {
58
+ policy: ResolvedFormativePolicy;
59
+ state?: FormativeItemState;
60
+ }): FormativeItemView;