@pie-players/pie-assessment-toolkit 1.0.0-beta.3 → 1.0.0-beta.4

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 (38) hide show
  1. package/README.md +15 -7
  2. package/custom-elements.json +19 -0
  3. package/dist/components/ItemScope.custom-element.js +1 -1
  4. package/dist/components/ItemToolBar.custom-element.js +1 -1
  5. package/dist/components/PieAssessmentToolkit.custom-element.js +7 -7
  6. package/dist/components/SectionToolBar.custom-element.js +1 -1
  7. package/dist/components/chunks/ItemToolBar-4vq9nbxz.js +2 -0
  8. package/dist/components/chunks/{ItemToolBar-jpac75fa.js → ItemToolBar-f971t0cw.js} +1 -1
  9. package/dist/components/chunks/{ItemToolBar-341vf20n.js → ItemToolBar-n05xjkp8.js} +2 -2
  10. package/dist/components/chunks/{ItemToolBar-66hcwmfw.js → ItemToolBar-pxan157g.js} +2 -2
  11. package/dist/components/chunks/ItemToolBar-rgmvw9sm.js +40 -0
  12. package/dist/components/chunks/{ItemToolBar-kkj69ptr.js → ItemToolBar-xwjrpdmz.js} +1 -1
  13. package/dist/components/chunks/sre-engine-dq0w45fv.js +1 -0
  14. package/dist/policy/core/ToolPolicyEngine.d.ts +8 -0
  15. package/dist/policy/core/ToolPolicyEngine.js +4 -2
  16. package/dist/policy/core/compose-decision.d.ts +7 -1
  17. package/dist/policy/core/compose-decision.js +29 -0
  18. package/dist/policy/core/decision-types.d.ts +20 -0
  19. package/dist/policy/core/feature-decision.d.ts +8 -1
  20. package/dist/policy/core/feature-decision.js +9 -2
  21. package/dist/policy/core/provenance.d.ts +1 -1
  22. package/dist/policy/engine.d.ts +2 -1
  23. package/dist/policy/sources/PnpPolicySource.d.ts +44 -2
  24. package/dist/policy/sources/PnpPolicySource.js +80 -11
  25. package/dist/runtime/core/engine-resolver.d.ts +17 -0
  26. package/dist/runtime/item-feature-decisions.d.ts +44 -0
  27. package/dist/runtime/item-feature-decisions.js +115 -0
  28. package/dist/services/ToolkitCoordinator.d.ts +9 -0
  29. package/dist/services/ToolkitCoordinator.js +23 -0
  30. package/dist/services/interfaces.d.ts +10 -0
  31. package/dist/services/support-conflicts.d.ts +10 -0
  32. package/dist/services/support-conflicts.js +11 -0
  33. package/dist/services/tool-config-validation.js +8 -2
  34. package/dist/services/tool-icons.js +1 -0
  35. package/package.json +6 -6
  36. package/dist/components/chunks/ItemToolBar-qvtpthc9.js +0 -40
  37. package/dist/components/chunks/ItemToolBar-vdkyn9f3.js +0 -2
  38. package/dist/components/chunks/sre-engine-bj997w4b.js +0 -1
@@ -79,6 +79,11 @@ export interface ToolPolicyDecisionRequest {
79
79
  * `restrictedTools` or the profile's `prohibitedSupports` withdraws instead.
80
80
  * `details` is {@link OverrideBlockedDetails}.
81
81
  *
82
+ * `tool-policy.supportConflict` fires for each id a support in effect withdraws
83
+ * that something would otherwise have served, as the toolkit's `screenReader`
84
+ * withdraws `textToSpeech`. It replaces `tool-policy.overrideBlocked` for a `true`
85
+ * override the conflict withdraws. `details` is {@link SupportConflictDetails}.
86
+ *
82
87
  * The toolkit coordinator logs each diagnostic once per code, tool and item,
83
88
  * and hands the same ones to its `onPolicyDiagnostic` listeners.
84
89
  */
@@ -122,6 +127,20 @@ export interface OverrideBlockedDetails {
122
127
  /** The rule that withdrew the granted tool. */
123
128
  rule: "item-restriction" | "pnp-prohibited";
124
129
  }
130
+ /**
131
+ * What would have served an id a support conflict withdraws: the placement
132
+ * that put the tool on this toolbar, the rule that would have granted it, or,
133
+ * for a feature an item player applies, the player's attribute or the item's
134
+ * authored setting it displaced.
135
+ */
136
+ export type SupportConflictServedBy = "placement" | "attribute" | "authored" | Extract<PnpPolicySourceRule, "test-admin-override" | "item-requirement" | "district-requirement" | "pnp-activate-at-init" | "pnp-support">;
137
+ /** Payload of a `tool-policy.supportConflict` diagnostic. */
138
+ export interface SupportConflictDetails {
139
+ /** The support in effect that withholds the id. */
140
+ support: string;
141
+ /** What would otherwise have served the withheld id. */
142
+ servedBy: SupportConflictServedBy;
143
+ }
125
144
  /** Payload of a `tool-policy.itemSettingNotApplied` diagnostic. */
126
145
  export interface ItemSettingNotAppliedDetails {
127
146
  /** Canonical id of the item whose setting names the tool. */
@@ -138,6 +157,7 @@ export interface ToolPolicyDiagnosticDetails {
138
157
  "tool-policy.unknownSupportId": UnknownSupportIdDetails;
139
158
  "tool-policy.itemSettingNotApplied": ItemSettingNotAppliedDetails;
140
159
  "tool-policy.overrideBlocked": OverrideBlockedDetails;
160
+ "tool-policy.supportConflict": SupportConflictDetails;
141
161
  }
142
162
  /** A policy diagnostic, its `details` typed by its `code`. */
143
163
  export type ToolPolicyDiagnostic = {
@@ -77,9 +77,16 @@ export interface FeaturePolicyDecision<P = Record<string, unknown>> {
77
77
  * later ones winning. Set on a denial too; a host denial leaves it out.
78
78
  */
79
79
  parameters?: P;
80
+ /**
81
+ * The support in effect that withholds the feature, on a `support-conflict`
82
+ * verdict.
83
+ */
84
+ withheldBy?: string;
80
85
  /**
81
86
  * Conflicts found while deciding: a `tool-policy.overrideBlocked` for a
82
- * granting override the decision withdrew. Empty on a host denial, which
87
+ * granting override the decision withdrew, and a
88
+ * `tool-policy.supportConflict` for a granting rule a support in effect
89
+ * displaced. Empty on a host denial, which
83
90
  * consults no policy source. An id no tool is registered under describes the
84
91
  * inputs, so it is reported on the engine inputs' `diagnostics`.
85
92
  */
@@ -20,7 +20,7 @@
20
20
  * "nothing was bound to ask against" are the same verdict for different
21
21
  * reasons, and only the second is a defect.
22
22
  */
23
- import { overrideBlockedDiagnostic } from "./compose-decision.js";
23
+ import { overrideBlockedDiagnostic, supportConflictDiagnostic, } from "./compose-decision.js";
24
24
  /**
25
25
  * Reason text for a denial that had no assessment to decide against.
26
26
  *
@@ -79,6 +79,7 @@ export function interpretFeatureResult(featureId, result, context, parameters) {
79
79
  // The source logs "no rule fired" as a skip at the last level so the trail
80
80
  // stays ordered; the verdict names no rule for it.
81
81
  const fired = decision !== undefined && decision.action !== "skip";
82
+ const conflict = result.supportConflicts.get(featureId);
82
83
  return {
83
84
  featureId,
84
85
  granted,
@@ -95,7 +96,13 @@ export function interpretFeatureResult(featureId, result, context, parameters) {
95
96
  : unboundAssessmentReason(featureId),
96
97
  required: Boolean(flags?.required),
97
98
  parameters,
99
+ ...(fired && decision.rule === "support-conflict" && conflict
100
+ ? { withheldBy: conflict.support }
101
+ : {}),
98
102
  assessmentBound: context.assessmentBound,
99
- diagnostics: Array.from(result.blockedOverrides, ([toolId, rule]) => overrideBlockedDiagnostic(toolId, rule)),
103
+ diagnostics: [
104
+ ...Array.from(result.blockedOverrides, ([toolId, rule]) => overrideBlockedDiagnostic(toolId, rule)),
105
+ ...Array.from(result.supportConflicts).flatMap(([toolId, { support, grant }]) => grant ? [supportConflictDiagnostic(toolId, support, grant)] : []),
106
+ ],
100
107
  };
101
108
  }
@@ -8,7 +8,7 @@
8
8
  * provider veto, PNP/profile gates, and custom policy sources.
9
9
  */
10
10
  /** The PNP/profile precedence rules, highest first. */
11
- export type PnpPolicySourceRule = "district-block" | "test-admin-override" | "item-restriction" | "pnp-prohibited" | "item-requirement" | "district-requirement" | "pnp-support" | "pnp-activate-at-init";
11
+ export type PnpPolicySourceRule = "district-block" | "test-admin-override" | "item-restriction" | "pnp-prohibited" | "support-conflict" | "item-requirement" | "district-requirement" | "pnp-support" | "pnp-activate-at-init";
12
12
  /**
13
13
  * Rule vocabulary for tool policy decisions. A decision's `rule` is the one
14
14
  * attribution channel: it names the step that enabled, blocked or advised on a
@@ -7,6 +7,7 @@
7
7
  */
8
8
  export { ToolPolicyEngine, type PnpEnforcementMode, type ResolvedEngineInputs, type ToolPolicyChangeEvent, type ToolPolicyChangeListener, type ToolPolicyEngineArgs, type ToolPolicyEngineInputs, } from "./core/ToolPolicyEngine.js";
9
9
  export { isHostDeniedFeature, type FeaturePolicyDecision, type FeaturePolicyRule, } from "./core/feature-decision.js";
10
- export type { ItemSettingNotAppliedDetails, OverrideBlockedDetails, PlacementMissingDetails, RequiredToolBlockedDetails, ToolPolicyDecision, ToolPolicyDecisionRequest, ToolPolicyDiagnostic, ToolPolicyDiagnosticCode, ToolPolicyDiagnosticDetails, ToolPolicyEntry, ToolPolicyHostGate, ToolScope, UnknownSupportIdDetails, } from "./core/decision-types.js";
10
+ export type { ItemSettingNotAppliedDetails, OverrideBlockedDetails, PlacementMissingDetails, RequiredToolBlockedDetails, SupportConflictDetails, SupportConflictServedBy, ToolPolicyDecision, ToolPolicyDecisionRequest, ToolPolicyDiagnostic, ToolPolicyDiagnosticCode, ToolPolicyDiagnosticDetails, ToolPolicyEntry, ToolPolicyHostGate, ToolScope, UnknownSupportIdDetails, } from "./core/decision-types.js";
11
11
  export type { PolicySource, PolicySourceDecisionContext, PolicySourceProvenanceEntry, PolicySourceResult, } from "./core/PolicySource.js";
12
+ export type { SupportConflict } from "./sources/PnpPolicySource.js";
12
13
  export type { PnpPolicySourceRule, ToolPolicyDecisionRule, ToolPolicyFeatureTrail, ToolPolicyProvenance, ToolPolicyResolutionDecision, ToolPolicySourceType, } from "./core/provenance.js";
@@ -10,7 +10,19 @@
10
10
  import type { AssessmentEntity, ItemSettings } from "@pie-players/pie-players-shared/types";
11
11
  import type { ToolRegistry } from "../../services/ToolRegistry.js";
12
12
  import type { PnpPolicySourceRule, ToolPolicyResolutionDecision, ToolPolicySourceType } from "../core/provenance.js";
13
- import type { OverrideBlockedDetails } from "../core/decision-types.js";
13
+ import type { OverrideBlockedDetails, SupportConflictServedBy } from "../core/decision-types.js";
14
+ /**
15
+ * A support whose being in effect withholds other ids: they resolve blocked
16
+ * under `support-conflict` at precedence 4 while `support` resolves enabled.
17
+ * Every id a conflict names is recognized without a tool registration, so none
18
+ * raises `tool-policy.unknownSupportId`.
19
+ */
20
+ export interface SupportConflict {
21
+ /** The support id that withholds. */
22
+ support: string;
23
+ /** The ids withheld while `support` is in effect. */
24
+ withholds: readonly string[];
25
+ }
14
26
  /** Per-tool flags PNP/profile policy may attach to a surviving entry. */
15
27
  export interface PnpPolicyToolFlags {
16
28
  /** PNP/profile policy mandates this tool (item or district `requiredTools`). */
@@ -82,6 +94,14 @@ export interface PnpPolicyResult {
82
94
  * engine turns each into a `tool-policy.overrideBlocked` diagnostic.
83
95
  */
84
96
  blockedOverrides: Map<string, OverrideBlockedDetails["rule"]>;
97
+ /**
98
+ * Ids a support in effect withdrew under `support-conflict`, each with that
99
+ * support and the rule that would otherwise have granted the id, absent when
100
+ * none would have. The engine turns each into a
101
+ * `tool-policy.supportConflict` diagnostic when something would have served
102
+ * the id.
103
+ */
104
+ supportConflicts: Map<string, SupportConflictResolution>;
85
105
  /** Configuration sources the engine should attach to its provenance. */
86
106
  sources: {
87
107
  assessment?: {
@@ -101,10 +121,21 @@ export interface PnpPolicyResult {
101
121
  };
102
122
  };
103
123
  }
124
+ /** A rule a support conflict can displace: one of levels 5 to 8. */
125
+ export type SupportConflictGrant = Exclude<SupportConflictServedBy, "placement" | "attribute" | "authored">;
126
+ /** One `support-conflict` withdrawal. */
127
+ export interface SupportConflictResolution {
128
+ support: string;
129
+ grant?: SupportConflictGrant;
130
+ }
104
131
  export declare class PnpPolicySource {
105
132
  readonly id = "pnp";
106
133
  private readonly toolRegistry;
107
- constructor(toolRegistry: ToolRegistry);
134
+ private readonly supportConflicts;
135
+ /** Withheld id to the supports that withhold it, in declaration order. */
136
+ private readonly withheldBy;
137
+ private readonly registrylessIds;
138
+ constructor(toolRegistry: ToolRegistry, supportConflicts?: readonly SupportConflict[]);
108
139
  apply(args: PnpPolicyApplyArgs): PnpPolicyResult;
109
140
  /**
110
141
  * Evaluate exactly one PNP support id through the same eight-level
@@ -126,6 +157,17 @@ export declare class PnpPolicySource {
126
157
  * configuration-source attribution) from the bound policy inputs.
127
158
  */
128
159
  private prepare;
160
+ /**
161
+ * Whether `support` resolves enabled, conflicts aside: a support another
162
+ * conflict withholds still withholds, and two conflicts naming each other
163
+ * cannot recurse.
164
+ */
165
+ private supportInEffect;
166
+ /**
167
+ * `withConflict: false` resolves the id as if no support conflict applied,
168
+ * which names the grant a conflict displaces without a second copy of
169
+ * levels 5 to 8.
170
+ */
129
171
  private resolveSupport;
130
172
  /**
131
173
  * Ids the assessment or any of `items` names that no tool is registered
@@ -10,8 +10,22 @@
10
10
  export class PnpPolicySource {
11
11
  id = "pnp";
12
12
  toolRegistry;
13
- constructor(toolRegistry) {
13
+ supportConflicts;
14
+ /** Withheld id to the supports that withhold it, in declaration order. */
15
+ withheldBy = new Map();
16
+ registrylessIds = new Set();
17
+ constructor(toolRegistry, supportConflicts = []) {
14
18
  this.toolRegistry = toolRegistry;
19
+ this.supportConflicts = supportConflicts;
20
+ for (const { support, withholds } of supportConflicts) {
21
+ this.registrylessIds.add(support);
22
+ for (const id of withholds) {
23
+ this.registrylessIds.add(id);
24
+ const supports = this.withheldBy.get(id) ?? [];
25
+ supports.push(support);
26
+ this.withheldBy.set(id, supports);
27
+ }
28
+ }
15
29
  }
16
30
  apply(args) {
17
31
  const { ctx, result } = this.prepare(args);
@@ -27,6 +41,12 @@ export class PnpPolicySource {
27
41
  }
28
42
  itemSettings?.requiredTools?.forEach((s) => allSupports.add(s));
29
43
  itemSettings?.restrictedTools?.forEach((s) => allSupports.add(s));
44
+ // A conflict withdraws a placed tool that no policy input names.
45
+ for (const { support, withholds } of this.supportConflicts) {
46
+ if (this.supportInEffect(support, ctx)) {
47
+ withholds.forEach((s) => allSupports.add(s));
48
+ }
49
+ }
30
50
  for (const supportId of allSupports) {
31
51
  this.resolveSupport(supportId, ctx, result);
32
52
  }
@@ -60,14 +80,7 @@ export class PnpPolicySource {
60
80
  const pnp = assessment?.personalNeedsProfile;
61
81
  const settings = assessment?.settings;
62
82
  const itemSettings = item?.settings;
63
- const result = {
64
- blockedToolIds: new Set(),
65
- mandates: new Map(),
66
- perToolFlags: new Map(),
67
- decisions: [],
68
- blockedOverrides: new Map(),
69
- sources: {},
70
- };
83
+ const result = emptyResult();
71
84
  if (settings?.districtPolicy || settings?.testAdministration) {
72
85
  result.sources.assessment = {
73
86
  id: assessment?.id || "unknown",
@@ -97,7 +110,22 @@ export class PnpPolicySource {
97
110
  };
98
111
  return { ctx, result };
99
112
  }
100
- resolveSupport(supportId, ctx, out) {
113
+ /**
114
+ * Whether `support` resolves enabled, conflicts aside: a support another
115
+ * conflict withholds still withholds, and two conflicts naming each other
116
+ * cannot recurse.
117
+ */
118
+ supportInEffect(support, ctx) {
119
+ const scratch = emptyResult();
120
+ this.resolveSupport(support, ctx, scratch, false);
121
+ return scratch.decisions[0]?.action === "enable";
122
+ }
123
+ /**
124
+ * `withConflict: false` resolves the id as if no support conflict applied,
125
+ * which names the grant a conflict displaces without a second copy of
126
+ * levels 5 to 8.
127
+ */
128
+ resolveSupport(supportId, ctx, out, withConflict = true) {
101
129
  // 1. District block (absolute veto)
102
130
  if (ctx.districtPolicy?.blockedTools?.includes(supportId)) {
103
131
  out.blockedToolIds.add(supportId);
@@ -163,6 +191,35 @@ export class PnpPolicySource {
163
191
  });
164
192
  return;
165
193
  }
194
+ // 4. Support conflict. Ranks with the profile prohibition, below the
195
+ // blocks a person recorded.
196
+ const withholding = withConflict
197
+ ? this.withheldBy
198
+ .get(supportId)
199
+ ?.find((support) => this.supportInEffect(support, ctx))
200
+ : undefined;
201
+ if (withholding) {
202
+ const displaced = emptyResult();
203
+ this.resolveSupport(supportId, ctx, displaced, false);
204
+ const grant = displaced.decisions[0];
205
+ out.blockedToolIds.add(supportId);
206
+ out.supportConflicts.set(supportId, {
207
+ support: withholding,
208
+ ...(grant?.action === "enable"
209
+ ? { grant: grant.rule }
210
+ : {}),
211
+ });
212
+ out.decisions.push({
213
+ precedence: 4,
214
+ rule: "support-conflict",
215
+ featureId: supportId,
216
+ action: "block",
217
+ sourceType: "student",
218
+ reason: `"${supportId}" is withheld while the student's "${withholding}" support is in effect`,
219
+ value: { support: withholding },
220
+ });
221
+ return;
222
+ }
166
223
  const activateAtInit = ctx.pnp?.activateAtInit?.includes(supportId) === true;
167
224
  const mandate = ctx.itemSettings?.requiredTools?.includes(supportId)
168
225
  ? "item-requirement"
@@ -311,8 +368,9 @@ export class PnpPolicySource {
311
368
  ];
312
369
  for (const [rule, ids] of named) {
313
370
  for (const id of ids ?? []) {
314
- if (this.toolRegistry.has(id))
371
+ if (this.toolRegistry.has(id) || this.registrylessIds.has(id)) {
315
372
  continue;
373
+ }
316
374
  const rules = unmapped.get(id) ?? [];
317
375
  if (!rules.includes(rule))
318
376
  rules.push(rule);
@@ -322,3 +380,14 @@ export class PnpPolicySource {
322
380
  return unmapped;
323
381
  }
324
382
  }
383
+ function emptyResult() {
384
+ return {
385
+ blockedToolIds: new Set(),
386
+ mandates: new Map(),
387
+ perToolFlags: new Map(),
388
+ decisions: [],
389
+ blockedOverrides: new Map(),
390
+ supportConflicts: new Map(),
391
+ sources: {},
392
+ };
393
+ }
@@ -22,6 +22,7 @@
22
22
  * `toolConfigStrictness` beside `runtime`.
23
23
  */
24
24
  import type { LoaderConfig } from "@pie-players/pie-players-shared/loader-config";
25
+ import type { I18nProvider } from "@pie-players/pie-players-shared/i18n";
25
26
  import type { LoadingCompleteDetail, StageChangeDetail } from "@pie-players/pie-players-shared/pie";
26
27
  import type { FrameworkErrorModel } from "../../services/framework-error.js";
27
28
  import type { ToolConfigStrictness } from "../../services/tool-config-validation.js";
@@ -71,6 +72,14 @@ export type RuntimeConfig = {
71
72
  * Unset reads as `en-US` wherever the markup names no language.
72
73
  */
73
74
  contentLanguage?: string;
75
+ /**
76
+ * The interface-locale provider the toolkit publishes on its runtime
77
+ * context, in place of the one it would construct: the catalogs it can
78
+ * serve, their loaders, and any host messages. `locale` on the element
79
+ * still names the locale; a provider given with none set keeps the locale
80
+ * its owner gave it.
81
+ */
82
+ i18n?: I18nProvider;
74
83
  onFrameworkError?: FrameworkErrorHandler;
75
84
  /** Called with each `pie-stage-change` detail. */
76
85
  onStageChange?: StageChangeHandler;
@@ -131,6 +140,14 @@ export declare function resolveRuntime(args: {
131
140
  * Unset reads as `en-US` wherever the markup names no language.
132
141
  */
133
142
  contentLanguage?: string;
143
+ /**
144
+ * The interface-locale provider the toolkit publishes on its runtime
145
+ * context, in place of the one it would construct: the catalogs it can
146
+ * serve, their loaders, and any host messages. `locale` on the element
147
+ * still names the locale; a provider given with none set keeps the locale
148
+ * its owner gave it.
149
+ */
150
+ i18n?: I18nProvider;
134
151
  onFrameworkError?: FrameworkErrorHandler;
135
152
  /** Called with each `pie-stage-change` detail. */
136
153
  onStageChange?: StageChangeHandler;
@@ -0,0 +1,44 @@
1
+ import { type ItemFeatureDecisions } from "@pie-players/pie-context";
2
+ import type { ToolScope } from "../policy/core/decision-types.js";
3
+ import type { ToolkitCoordinatorApi } from "../services/interfaces.js";
4
+ /** The feature ids an item player applies to its item's models. */
5
+ export declare const ITEM_PLAYER_FEATURE_IDS: readonly ["autoplayAudio"];
6
+ type DecidingCoordinator = Pick<ToolkitCoordinatorApi, "decideFeaturePolicy" | "onPolicyChange" | "reportFeatureDecisionApplied">;
7
+ /** The item a scope asks feature policy for. */
8
+ export interface ItemFeatureScopeIdentity {
9
+ itemId: string;
10
+ canonicalItemId?: string;
11
+ }
12
+ /**
13
+ * The scope an item's feature decisions are asked with, which brings in the
14
+ * item's registered settings; the same scope an item's content surfaces ask
15
+ * with.
16
+ */
17
+ export declare function itemFeatureScope(identity: ItemFeatureScopeIdentity): ToolScope;
18
+ /**
19
+ * The decisions an item player applies, leaving out each id no rule names. No
20
+ * coordinator, or one predating feature decisions, decides nothing.
21
+ */
22
+ export declare function resolveItemFeatureDecisions(coordinator: Pick<ToolkitCoordinatorApi, "decideFeaturePolicy"> | null, scope: ToolScope | null): ItemFeatureDecisions;
23
+ export interface ItemFeatureDecisionsProvider {
24
+ /**
25
+ * The coordinator the scope's toolkit answered with; `null` until one has.
26
+ * The provider follows its policy changes, an item's settings registering
27
+ * among them.
28
+ */
29
+ setCoordinator(coordinator: DecidingCoordinator | null): void;
30
+ /** The scope's item; `null` while it has no item id. */
31
+ setItem(identity: ItemFeatureScopeIdentity | null): void;
32
+ disconnect(): void;
33
+ }
34
+ /**
35
+ * Answers `itemFeatureDecisionsContext` for the item player under `host`, and
36
+ * relays the player's `feature-decision-applied` to the coordinator.
37
+ *
38
+ * It answers with no decision until both a coordinator and an item are set, so
39
+ * an item player under a scope no toolkit has answered keeps its own setting.
40
+ * A value is republished only when a decision changed, since every new value
41
+ * remounts the item.
42
+ */
43
+ export declare function provideItemFeatureDecisions(host: HTMLElement): ItemFeatureDecisionsProvider;
44
+ export {};
@@ -0,0 +1,115 @@
1
+ import { ContextProvider, FEATURE_DECISION_APPLIED_EVENT, itemFeatureDecisionsContext, } from "@pie-players/pie-context";
2
+ /** The feature ids an item player applies to its item's models. */
3
+ export const ITEM_PLAYER_FEATURE_IDS = ["autoplayAudio"];
4
+ const NO_DECISIONS = Object.freeze({
5
+ features: Object.freeze({}),
6
+ });
7
+ /**
8
+ * The scope an item's feature decisions are asked with, which brings in the
9
+ * item's registered settings; the same scope an item's content surfaces ask
10
+ * with.
11
+ */
12
+ export function itemFeatureScope(identity) {
13
+ const canonicalItemId = identity.canonicalItemId || identity.itemId;
14
+ return {
15
+ level: "item",
16
+ scopeId: canonicalItemId,
17
+ itemId: identity.itemId,
18
+ canonicalItemId,
19
+ };
20
+ }
21
+ /**
22
+ * The decisions an item player applies, leaving out each id no rule names. No
23
+ * coordinator, or one predating feature decisions, decides nothing.
24
+ */
25
+ export function resolveItemFeatureDecisions(coordinator, scope) {
26
+ if (!scope || typeof coordinator?.decideFeaturePolicy !== "function") {
27
+ return NO_DECISIONS;
28
+ }
29
+ const features = {};
30
+ for (const featureId of ITEM_PLAYER_FEATURE_IDS) {
31
+ const decision = coordinator.decideFeaturePolicy(featureId, scope);
32
+ if (decision.rule === "none")
33
+ continue;
34
+ features[featureId] = { granted: decision.granted, rule: decision.rule };
35
+ }
36
+ return { features };
37
+ }
38
+ /**
39
+ * Answers `itemFeatureDecisionsContext` for the item player under `host`, and
40
+ * relays the player's `feature-decision-applied` to the coordinator.
41
+ *
42
+ * It answers with no decision until both a coordinator and an item are set, so
43
+ * an item player under a scope no toolkit has answered keeps its own setting.
44
+ * A value is republished only when a decision changed, since every new value
45
+ * remounts the item.
46
+ */
47
+ export function provideItemFeatureDecisions(host) {
48
+ const provider = new ContextProvider(host, {
49
+ context: itemFeatureDecisionsContext,
50
+ initialValue: NO_DECISIONS,
51
+ });
52
+ provider.connect();
53
+ let signature = JSON.stringify(NO_DECISIONS.features);
54
+ let coordinator = null;
55
+ let scope = null;
56
+ let stopFollowingPolicy = null;
57
+ const refresh = () => {
58
+ const next = resolveItemFeatureDecisions(coordinator, scope);
59
+ const nextSignature = JSON.stringify(next.features);
60
+ if (nextSignature === signature)
61
+ return;
62
+ signature = nextSignature;
63
+ provider.setValue(next);
64
+ };
65
+ const stopFollowing = () => {
66
+ const stop = stopFollowingPolicy;
67
+ stopFollowingPolicy = null;
68
+ try {
69
+ stop?.();
70
+ }
71
+ catch {
72
+ // The coordinator may already have been disposed.
73
+ }
74
+ };
75
+ const onApplied = (event) => {
76
+ const detail = event.detail;
77
+ if (!detail || !scope)
78
+ return;
79
+ coordinator?.reportFeatureDecisionApplied?.(detail, scope);
80
+ };
81
+ host.addEventListener(FEATURE_DECISION_APPLIED_EVENT, onApplied);
82
+ return {
83
+ setCoordinator(next) {
84
+ if (next === coordinator)
85
+ return;
86
+ stopFollowing();
87
+ coordinator = next;
88
+ if (next && typeof next.onPolicyChange === "function") {
89
+ stopFollowingPolicy = next.onPolicyChange((event) => {
90
+ if (event.reason === "disposed") {
91
+ stopFollowing();
92
+ coordinator = null;
93
+ }
94
+ refresh();
95
+ });
96
+ }
97
+ refresh();
98
+ },
99
+ setItem(identity) {
100
+ const next = identity?.itemId ? itemFeatureScope(identity) : null;
101
+ if (next?.scopeId === scope?.scopeId && next?.itemId === scope?.itemId) {
102
+ return;
103
+ }
104
+ scope = next;
105
+ refresh();
106
+ },
107
+ disconnect() {
108
+ host.removeEventListener(FEATURE_DECISION_APPLIED_EVENT, onApplied);
109
+ stopFollowing();
110
+ coordinator = null;
111
+ scope = null;
112
+ provider.disconnect();
113
+ },
114
+ };
115
+ }
@@ -22,6 +22,7 @@
22
22
  * - config-change application and TTS reconfiguration, last.
23
23
  */
24
24
  import type { AccessibilityCatalog, AssessmentEntity, ItemSettings, ToolParametersFor } from "@pie-players/pie-players-shared/types";
25
+ import type { FeatureDecisionAppliedDetail } from "@pie-players/pie-context";
25
26
  import { type CanonicalToolsConfig, type ToolPlacementConfig, type ToolPolicyConfig, type TextToSpeechToolProviderConfig, type ToolProviderConfig, type ToolProvidersConfig, type ToolsConfigInput } from "./tools-config-normalizer.js";
26
27
  import { type ToolConfigStrictness } from "./tool-config-validation.js";
27
28
  import { AccessibilityCatalogResolver } from "./AccessibilityCatalogResolver.js";
@@ -789,6 +790,14 @@ export declare class ToolkitCoordinator {
789
790
  * registered settings ({@link registerItemSettings}).
790
791
  */
791
792
  decideFeaturePolicy<K extends string>(featureId: K, scope?: ToolScope): FeaturePolicyDecision<ToolParametersFor<K>>;
793
+ /**
794
+ * Hear that an item player applied a feature decision to its item's models.
795
+ * A `support-conflict` hold that displaced the player's attribute or the
796
+ * item's authored setting raises `tool-policy.supportConflict`, as a
797
+ * conflict that displaced a granting rule already did when it was decided.
798
+ * Every other block was recorded by a person and raises nothing.
799
+ */
800
+ reportFeatureDecisionApplied(detail: FeatureDecisionAppliedDetail, scope: ToolScope): void;
792
801
  /**
793
802
  * Subscribe to policy-engine change events. Fires whenever the
794
803
  * coordinator's bound inputs change (`updateToolConfig`,
@@ -37,7 +37,9 @@ import { ToolProviderRegistry } from "./tool-providers/index.js";
37
37
  import { ToolRegistry } from "./ToolRegistry.js";
38
38
  import { ToolRequestRegistry } from "./tool-request.js";
39
39
  import { ToolPolicyEngine, } from "../policy/engine.js";
40
+ import { supportConflictDiagnostic } from "../policy/core/compose-decision.js";
40
41
  import { resolveSectionSessionAssignment } from "./section-session-assignment.js";
42
+ import { TOOLKIT_SUPPORT_CONFLICTS } from "./support-conflicts.js";
41
43
  class ToolkitCoordinatorDisposedError extends Error {
42
44
  constructor() {
43
45
  super("ToolkitCoordinator has been disposed.");
@@ -367,6 +369,7 @@ export class ToolkitCoordinator {
367
369
  toolRegistry: this.toolRegistry,
368
370
  contextId: `toolkit-coordinator:${this.assessmentId}`,
369
371
  assessmentOptional: this.config.assessmentOptional === true,
372
+ supportConflicts: TOOLKIT_SUPPORT_CONFLICTS,
370
373
  inputs: {
371
374
  tools: this.config.tools,
372
375
  assessment: null,
@@ -2157,6 +2160,26 @@ export class ToolkitCoordinator {
2157
2160
  }
2158
2161
  return decision;
2159
2162
  }
2163
+ /**
2164
+ * Hear that an item player applied a feature decision to its item's models.
2165
+ * A `support-conflict` hold that displaced the player's attribute or the
2166
+ * item's authored setting raises `tool-policy.supportConflict`, as a
2167
+ * conflict that displaced a granting rule already did when it was decided.
2168
+ * Every other block was recorded by a person and raises nothing.
2169
+ */
2170
+ reportFeatureDecisionApplied(detail, scope) {
2171
+ if (detail.granted ||
2172
+ detail.rule !== "support-conflict" ||
2173
+ (detail.overrode !== "attribute" && detail.overrode !== "authored")) {
2174
+ return;
2175
+ }
2176
+ const decision = this.policyEngine.decideFeature(detail.featureId, scope);
2177
+ if (decision.rule !== "support-conflict" || !decision.withheldBy)
2178
+ return;
2179
+ this.warnPolicyDiagnostics([
2180
+ supportConflictDiagnostic(detail.featureId, decision.withheldBy, detail.overrode),
2181
+ ]);
2182
+ }
2160
2183
  /**
2161
2184
  * Subscribe to policy-engine change events. Fires whenever the
2162
2185
  * coordinator's bound inputs change (`updateToolConfig`,
@@ -26,6 +26,7 @@ import type { ToolProviderApi } from "./tool-providers/ToolProviderApi.js";
26
26
  import type { ToolProviderRegistry } from "./tool-providers/ToolProviderRegistry.js";
27
27
  import type { FeaturePolicyDecision, PolicySource, PnpEnforcementMode, ResolvedEngineInputs, ToolPolicyChangeListener, ToolPolicyDecision, ToolPolicyDecisionRequest, ToolPolicyDiagnostic, ToolScope } from "../policy/engine.js";
28
28
  import type { AssessmentEntity, ItemSettings, ToolParametersFor } from "@pie-players/pie-players-shared/types";
29
+ import type { FeatureDecisionAppliedDetail } from "@pie-players/pie-context";
29
30
  import type { TTSConfig } from "@pie-players/pie-tts";
30
31
  import type { ResolvedToolContext, ToolContextResolverContext, ToolRegistry } from "./ToolRegistry.js";
31
32
  import type { ToolOpenRequest, ToolRequestTarget } from "./tool-request.js";
@@ -615,6 +616,15 @@ export interface ToolkitCoordinatorApi {
615
616
  * assignable; call sites must feature-detect.
616
617
  */
617
618
  decideFeaturePolicy?<K extends string>(featureId: K, scope?: ToolScope): FeaturePolicyDecision<ToolParametersFor<K>>;
619
+ /**
620
+ * Hear that an item player applied a feature decision to its item's models,
621
+ * as `<pie-item-scope>` relays its `feature-decision-applied`. A
622
+ * `support-conflict` hold that displaced the player's attribute or the
623
+ * item's authored setting raises `tool-policy.supportConflict`.
624
+ *
625
+ * Optional, like {@link decideFeaturePolicy}; call sites must feature-detect.
626
+ */
627
+ reportFeatureDecisionApplied?(detail: FeatureDecisionAppliedDetail, scope: ToolScope): void;
618
628
  /**
619
629
  * Subscribe to policy-engine change events. Fires whenever the
620
630
  * coordinator's bound policy inputs change (`updateToolConfig`,
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The support conflicts `ToolkitCoordinator` gives its policy engine. They name
3
+ * capabilities, so they live here, outside the capability-neutral policy core.
4
+ */
5
+ import type { SupportConflict } from "../policy/engine.js";
6
+ /**
7
+ * A student's screen reader speaks the page, so PIE's read-aloud and item audio
8
+ * that starts on its own would talk over it (ADR 0004).
9
+ */
10
+ export declare const TOOLKIT_SUPPORT_CONFLICTS: readonly SupportConflict[];
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The support conflicts `ToolkitCoordinator` gives its policy engine. They name
3
+ * capabilities, so they live here, outside the capability-neutral policy core.
4
+ */
5
+ /**
6
+ * A student's screen reader speaks the page, so PIE's read-aloud and item audio
7
+ * that starts on its own would talk over it (ADR 0004).
8
+ */
9
+ export const TOOLKIT_SUPPORT_CONFLICTS = [
10
+ { support: "screenReader", withholds: ["textToSpeech", "autoplayAudio"] },
11
+ ];