@pie-players/pie-assessment-toolkit 0.3.63 → 0.3.65
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 +92 -1
- package/dist/components/ItemToolBar.custom-element.js +1 -1
- package/dist/components/PieAssessmentToolkit.custom-element.js +11 -11
- package/dist/components/SectionToolBar.custom-element.js +1 -1
- package/dist/components/chunks/ItemToolBar-cckwpz6c.js +51 -0
- package/dist/components/chunks/ItemToolBar-pryf0rtz.js +22 -0
- package/dist/index.d.ts +10 -6
- package/dist/index.js +12 -4
- package/dist/policy/core/ToolPolicyEngine.d.ts +21 -0
- package/dist/policy/core/ToolPolicyEngine.js +27 -0
- package/dist/policy/core/feature-decision.d.ts +57 -0
- package/dist/policy/core/feature-decision.js +40 -0
- package/dist/policy/engine.d.ts +1 -0
- package/dist/policy/sources/PnpPolicySource.d.ts +22 -0
- package/dist/policy/sources/PnpPolicySource.js +41 -11
- package/dist/runtime/catalog-registration.d.ts +56 -1
- package/dist/runtime/catalog-registration.js +64 -31
- package/dist/runtime/composition-emit-scheduler.d.ts +78 -0
- package/dist/runtime/composition-emit-scheduler.js +154 -0
- package/dist/runtime/core/engine-resolver.d.ts +1 -1
- package/dist/services/AccessibilityCatalogResolver.d.ts +100 -4
- package/dist/services/AccessibilityCatalogResolver.js +183 -58
- package/dist/services/SSMLExtractor.js +28 -18
- package/dist/services/TTSService.d.ts +25 -0
- package/dist/services/TTSService.js +241 -45
- package/dist/services/ToolRegistry.d.ts +218 -8
- package/dist/services/ToolRegistry.js +124 -8
- package/dist/services/ToolkitCoordinator.d.ts +25 -3
- package/dist/services/ToolkitCoordinator.js +47 -6
- package/dist/services/catalog-media.d.ts +25 -0
- package/dist/services/catalog-media.js +101 -0
- package/dist/services/createDefaultToolRegistry.d.ts +25 -58
- package/dist/services/createDefaultToolRegistry.js +24 -104
- package/dist/services/defaultPersonalNeedsProfile.d.ts +20 -2
- package/dist/services/defaultPersonalNeedsProfile.js +21 -19
- package/dist/services/interfaces.d.ts +29 -2
- package/dist/services/pnp-standard-features.d.ts +1 -1
- package/dist/services/spoken-audio-cards.d.ts +54 -0
- package/dist/services/spoken-audio-cards.js +66 -0
- package/dist/services/tool-config-defaults.d.ts +7 -23
- package/dist/services/tool-config-defaults.js +7 -46
- package/dist/services/tool-config-validation.d.ts +1 -1
- package/dist/services/tool-config-validation.js +44 -4
- package/dist/services/tts/browser-provider.js +2 -1
- package/dist/services/tts/math-aware-text-processing.js +3 -3
- package/dist/services/tts/text-processing.d.ts +51 -0
- package/dist/services/tts/text-processing.js +117 -1
- package/dist/services/tts-runtime-config.js +7 -2
- package/dist/tools/internal.d.ts +34 -0
- package/dist/tools/internal.js +33 -0
- package/dist/tools/tool-tag-map.d.ts +15 -3
- package/dist/tools/tool-tag-map.js +21 -18
- package/package.json +14 -10
- package/dist/components/chunks/ItemToolBar-843902tp.js +0 -51
- package/dist/components/chunks/ItemToolBar-84nv78dy.js +0 -22
- package/dist/tools/registrations/accessibility-tools.d.ts +0 -34
- package/dist/tools/registrations/accessibility-tools.js +0 -217
- package/dist/tools/registrations/calculator.d.ts +0 -20
- package/dist/tools/registrations/calculator.js +0 -228
- package/dist/tools/registrations/interaction-tools.d.ts +0 -27
- package/dist/tools/registrations/interaction-tools.js +0 -143
- package/dist/tools/registrations/measurement-tools.d.ts +0 -24
- package/dist/tools/registrations/measurement-tools.js +0 -130
- package/dist/tools/registrations/subject-specific-tools.d.ts +0 -27
- package/dist/tools/registrations/subject-specific-tools.js +0 -158
- package/dist/tools/registrations/tts.d.ts +0 -21
- package/dist/tools/registrations/tts.js +0 -184
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feature policy decisions — eligibility for policy-addressable capabilities
|
|
3
|
+
* that are not toolbar tools.
|
|
4
|
+
*
|
|
5
|
+
* "Tool" in this codebase means *policy-addressable capability*, not "gadget on
|
|
6
|
+
* a toolbar" (see `docs/tools-and-accomodations/architecture.md`). Most such
|
|
7
|
+
* capabilities render as toolbar surfaces, so the engine's main answer is a
|
|
8
|
+
* placement-scoped `ToolPolicyDecision`. Some do not: a signed alternate renders
|
|
9
|
+
* as its own region and is never placed on a toolbar, so asking
|
|
10
|
+
* `decide({ level: "item", ... })` about it would answer the wrong question —
|
|
11
|
+
* it would be absent because it is not in `tools.placement.item`, not because
|
|
12
|
+
* policy said no.
|
|
13
|
+
*
|
|
14
|
+
* A feature decision answers only the eligibility half: *did policy grant this
|
|
15
|
+
* feature id?* Whether the capability has anything to show is a separate,
|
|
16
|
+
* independent check owned by its renderer — for signing, whether a matching
|
|
17
|
+
* catalog card exists. Both are required; neither implies the other.
|
|
18
|
+
*/
|
|
19
|
+
import type { PnpPolicyResult } from "../sources/PnpPolicySource.js";
|
|
20
|
+
import type { PnpPolicySourceRule } from "./policy-source-tag.js";
|
|
21
|
+
import type { ToolPolicyResolutionDecision, ToolPolicySourceType } from "./provenance.js";
|
|
22
|
+
export interface FeaturePolicyDecision {
|
|
23
|
+
/** The PNP/AfA support id that was evaluated (e.g. `"signLanguage"`). */
|
|
24
|
+
featureId: string;
|
|
25
|
+
/**
|
|
26
|
+
* `true` only when policy explicitly granted the feature at one of the six
|
|
27
|
+
* precedence levels. A feature nobody configured is **not** available —
|
|
28
|
+
* accommodations require a documented need, so silence means no.
|
|
29
|
+
*/
|
|
30
|
+
granted: boolean;
|
|
31
|
+
action: ToolPolicyResolutionDecision["action"];
|
|
32
|
+
/** Which precedence rule produced the verdict. */
|
|
33
|
+
rule: PnpPolicySourceRule;
|
|
34
|
+
precedence: 1 | 2 | 3 | 4 | 5 | 6;
|
|
35
|
+
sourceType: ToolPolicySourceType;
|
|
36
|
+
/** Human-readable explanation, suitable for a policy debugger. */
|
|
37
|
+
reason: string;
|
|
38
|
+
/**
|
|
39
|
+
* `true` when the grant is a mandate (item or district `requiredTools`)
|
|
40
|
+
* rather than a student-profile support.
|
|
41
|
+
*/
|
|
42
|
+
required: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Feature parameters resolved from item `toolParameters` then assessment
|
|
45
|
+
* `toolConfigs`, keyed by the feature id. The seam a later configurable
|
|
46
|
+
* presentation would hang on; no vocabulary is defined yet.
|
|
47
|
+
*/
|
|
48
|
+
parameters?: unknown;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Interpret a single-feature `PnpPolicySource.resolveFeature(...)` result.
|
|
52
|
+
*
|
|
53
|
+
* `resolveFeature` evaluates exactly one support id, so the result carries
|
|
54
|
+
* exactly one decision and at most one flags entry — no mapped-tool-id
|
|
55
|
+
* bookkeeping is needed to read it back out.
|
|
56
|
+
*/
|
|
57
|
+
export declare function interpretFeatureResult(featureId: string, result: PnpPolicyResult): FeaturePolicyDecision;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feature policy decisions — eligibility for policy-addressable capabilities
|
|
3
|
+
* that are not toolbar tools.
|
|
4
|
+
*
|
|
5
|
+
* "Tool" in this codebase means *policy-addressable capability*, not "gadget on
|
|
6
|
+
* a toolbar" (see `docs/tools-and-accomodations/architecture.md`). Most such
|
|
7
|
+
* capabilities render as toolbar surfaces, so the engine's main answer is a
|
|
8
|
+
* placement-scoped `ToolPolicyDecision`. Some do not: a signed alternate renders
|
|
9
|
+
* as its own region and is never placed on a toolbar, so asking
|
|
10
|
+
* `decide({ level: "item", ... })` about it would answer the wrong question —
|
|
11
|
+
* it would be absent because it is not in `tools.placement.item`, not because
|
|
12
|
+
* policy said no.
|
|
13
|
+
*
|
|
14
|
+
* A feature decision answers only the eligibility half: *did policy grant this
|
|
15
|
+
* feature id?* Whether the capability has anything to show is a separate,
|
|
16
|
+
* independent check owned by its renderer — for signing, whether a matching
|
|
17
|
+
* catalog card exists. Both are required; neither implies the other.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Interpret a single-feature `PnpPolicySource.resolveFeature(...)` result.
|
|
21
|
+
*
|
|
22
|
+
* `resolveFeature` evaluates exactly one support id, so the result carries
|
|
23
|
+
* exactly one decision and at most one flags entry — no mapped-tool-id
|
|
24
|
+
* bookkeeping is needed to read it back out.
|
|
25
|
+
*/
|
|
26
|
+
export function interpretFeatureResult(featureId, result) {
|
|
27
|
+
const decision = result.decisions[0];
|
|
28
|
+
const flags = Array.from(result.perToolFlags.values())[0];
|
|
29
|
+
return {
|
|
30
|
+
featureId,
|
|
31
|
+
granted: decision?.action === "enable",
|
|
32
|
+
action: decision?.action ?? "skip",
|
|
33
|
+
rule: decision?.rule ?? "pnp-support",
|
|
34
|
+
precedence: decision?.precedence ?? 6,
|
|
35
|
+
sourceType: decision?.sourceType ?? "system",
|
|
36
|
+
reason: decision?.reason ?? `Feature "${featureId}" not configured`,
|
|
37
|
+
required: Boolean(flags?.required),
|
|
38
|
+
parameters: flags?.settings,
|
|
39
|
+
};
|
|
40
|
+
}
|
package/dist/policy/engine.d.ts
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
export { ToolPolicyEngine, type PnpEnforcementMode, type ResolvedEngineInputs, type ToolPolicyChangeEvent, type ToolPolicyChangeListener, type ToolPolicyEngineArgs, type ToolPolicyEngineInputs, } from "./core/ToolPolicyEngine.js";
|
|
23
23
|
export { TOOL_POLICY_ENGINE_KEY, type ToolPolicyEngineContext, } from "./core/engine-context.js";
|
|
24
|
+
export type { FeaturePolicyDecision } from "./core/feature-decision.js";
|
|
24
25
|
export type { RequiredToolBlockedDetails, ToolPolicyDecision, ToolPolicyDecisionRequest, ToolPolicyDiagnostic, ToolPolicyDiagnosticCode, ToolPolicyEntry, ToolPolicyHostGate, ToolScope, } from "./core/decision-types.js";
|
|
25
26
|
export type { PolicySource, PolicySourceDecisionContext, PolicySourceProvenanceEntry, PolicySourceResult, } from "./core/PolicySource.js";
|
|
26
27
|
export type { PolicySourceTag, PnpPolicySourceRule, PnpPolicySourceTag, CustomPolicySourceTag, } from "./core/policy-source-tag.js";
|
|
@@ -88,6 +88,28 @@ export declare class PnpPolicySource {
|
|
|
88
88
|
private readonly toolRegistry;
|
|
89
89
|
constructor(toolRegistry: ToolRegistry);
|
|
90
90
|
apply(args: PnpPolicyApplyArgs): PnpPolicyResult;
|
|
91
|
+
/**
|
|
92
|
+
* Evaluate exactly one PNP support id through the same six-level
|
|
93
|
+
* precedence `apply(...)` uses.
|
|
94
|
+
*
|
|
95
|
+
* This exists for **policy-addressable capabilities that are not toolbar
|
|
96
|
+
* tools** — a signed alternate rendered as its own region, for example.
|
|
97
|
+
* `apply(...)` only evaluates support ids that appear somewhere in the
|
|
98
|
+
* bound policy inputs, and it keys its result maps by *mapped tool id*, so
|
|
99
|
+
* a caller asking about one feature would have to re-derive that mapping
|
|
100
|
+
* and could collide with another support id that maps to the same tool.
|
|
101
|
+
* Evaluating one id in isolation avoids both problems: the returned result
|
|
102
|
+
* carries exactly one decision, and `decisions[0].action` is the verdict.
|
|
103
|
+
*
|
|
104
|
+
* Reusing `resolveSupport(...)` rather than re-walking the precedence rules
|
|
105
|
+
* is the point — a second copy of the six levels would drift.
|
|
106
|
+
*/
|
|
107
|
+
resolveFeature(featureId: string, args: PnpPolicyApplyArgs): PnpPolicyResult;
|
|
108
|
+
/**
|
|
109
|
+
* Build the rule-evaluation context and the empty result (with its
|
|
110
|
+
* configuration-source attribution) from the bound policy inputs.
|
|
111
|
+
*/
|
|
112
|
+
private prepare;
|
|
91
113
|
private resolveSupport;
|
|
92
114
|
/**
|
|
93
115
|
* Map a QTI / PNP support id (e.g. `"calculator-basic"`) to the
|
|
@@ -14,6 +14,46 @@ export class PnpPolicySource {
|
|
|
14
14
|
this.toolRegistry = toolRegistry;
|
|
15
15
|
}
|
|
16
16
|
apply(args) {
|
|
17
|
+
const { ctx, result } = this.prepare(args);
|
|
18
|
+
const { pnp, districtPolicy, itemSettings } = ctx;
|
|
19
|
+
const allSupports = new Set();
|
|
20
|
+
pnp?.supports?.forEach((s) => allSupports.add(s));
|
|
21
|
+
pnp?.prohibitedSupports?.forEach((s) => allSupports.add(s));
|
|
22
|
+
districtPolicy?.blockedTools?.forEach((s) => allSupports.add(s));
|
|
23
|
+
districtPolicy?.requiredTools?.forEach((s) => allSupports.add(s));
|
|
24
|
+
itemSettings?.requiredTools?.forEach((s) => allSupports.add(s));
|
|
25
|
+
itemSettings?.restrictedTools?.forEach((s) => allSupports.add(s));
|
|
26
|
+
for (const supportId of allSupports) {
|
|
27
|
+
this.resolveSupport(supportId, ctx, result);
|
|
28
|
+
}
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Evaluate exactly one PNP support id through the same six-level
|
|
33
|
+
* precedence `apply(...)` uses.
|
|
34
|
+
*
|
|
35
|
+
* This exists for **policy-addressable capabilities that are not toolbar
|
|
36
|
+
* tools** — a signed alternate rendered as its own region, for example.
|
|
37
|
+
* `apply(...)` only evaluates support ids that appear somewhere in the
|
|
38
|
+
* bound policy inputs, and it keys its result maps by *mapped tool id*, so
|
|
39
|
+
* a caller asking about one feature would have to re-derive that mapping
|
|
40
|
+
* and could collide with another support id that maps to the same tool.
|
|
41
|
+
* Evaluating one id in isolation avoids both problems: the returned result
|
|
42
|
+
* carries exactly one decision, and `decisions[0].action` is the verdict.
|
|
43
|
+
*
|
|
44
|
+
* Reusing `resolveSupport(...)` rather than re-walking the precedence rules
|
|
45
|
+
* is the point — a second copy of the six levels would drift.
|
|
46
|
+
*/
|
|
47
|
+
resolveFeature(featureId, args) {
|
|
48
|
+
const { ctx, result } = this.prepare(args);
|
|
49
|
+
this.resolveSupport(featureId, ctx, result);
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Build the rule-evaluation context and the empty result (with its
|
|
54
|
+
* configuration-source attribution) from the bound policy inputs.
|
|
55
|
+
*/
|
|
56
|
+
prepare(args) {
|
|
17
57
|
const { assessment, currentItemRef } = args;
|
|
18
58
|
const pnp = assessment?.personalNeedsProfile;
|
|
19
59
|
const settings = assessment?.settings;
|
|
@@ -46,13 +86,6 @@ export class PnpPolicySource {
|
|
|
46
86
|
config: itemSettings,
|
|
47
87
|
};
|
|
48
88
|
}
|
|
49
|
-
const allSupports = new Set();
|
|
50
|
-
pnp?.supports?.forEach((s) => allSupports.add(s));
|
|
51
|
-
pnp?.prohibitedSupports?.forEach((s) => allSupports.add(s));
|
|
52
|
-
settings?.districtPolicy?.blockedTools?.forEach((s) => allSupports.add(s));
|
|
53
|
-
settings?.districtPolicy?.requiredTools?.forEach((s) => allSupports.add(s));
|
|
54
|
-
itemSettings?.requiredTools?.forEach((s) => allSupports.add(s));
|
|
55
|
-
itemSettings?.restrictedTools?.forEach((s) => allSupports.add(s));
|
|
56
89
|
const ctx = {
|
|
57
90
|
pnp,
|
|
58
91
|
districtPolicy: settings?.districtPolicy,
|
|
@@ -60,10 +93,7 @@ export class PnpPolicySource {
|
|
|
60
93
|
itemSettings,
|
|
61
94
|
toolConfigs: settings?.toolConfigs,
|
|
62
95
|
};
|
|
63
|
-
|
|
64
|
-
this.resolveSupport(supportId, ctx, result);
|
|
65
|
-
}
|
|
66
|
-
return result;
|
|
96
|
+
return { ctx, result };
|
|
67
97
|
}
|
|
68
98
|
resolveSupport(supportId, ctx, out) {
|
|
69
99
|
// 1. District block (absolute veto)
|
|
@@ -1,6 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where accessibility catalogs live on a rendered entity, and which owner scope
|
|
3
|
+
* each one belongs to.
|
|
4
|
+
*
|
|
5
|
+
* Catalogs are placed dynamically: a shell registers what its entity carries
|
|
6
|
+
* when it mounts, and readers (TTS, the item card's media region) resolve by
|
|
7
|
+
* identifier within an owner scope. Both sides therefore have to agree on two
|
|
8
|
+
* facts — the three places catalogs can hang off an entity, and the owner
|
|
9
|
+
* context each one is filed under. This module is the only place either is
|
|
10
|
+
* decided, so a reader cannot look up a scope registration never wrote.
|
|
11
|
+
*
|
|
12
|
+
* Part of PIE Assessment Toolkit.
|
|
13
|
+
*/
|
|
1
14
|
import type { AccessibilityCatalog } from "@pie-players/pie-players-shared/types";
|
|
2
15
|
import type { CatalogOwnerContext } from "../services/AccessibilityCatalogResolver.js";
|
|
3
|
-
import type { RuntimeRegistrationDetail } from "./registration-events.js";
|
|
16
|
+
import type { RuntimeRegistrationDetail, RuntimeRegistrationKind } from "./registration-events.js";
|
|
4
17
|
export interface CatalogRegistrationRuntimeContext {
|
|
5
18
|
assessmentId?: string;
|
|
6
19
|
sectionId?: string;
|
|
@@ -9,4 +22,46 @@ export interface CatalogRegistration {
|
|
|
9
22
|
context: CatalogOwnerContext;
|
|
10
23
|
catalogs: AccessibilityCatalog[];
|
|
11
24
|
}
|
|
25
|
+
/** The entity shape catalogs hang off: an item, or a passage. */
|
|
26
|
+
export interface CatalogSourceEntity {
|
|
27
|
+
accessibilityCatalogs?: AccessibilityCatalog[];
|
|
28
|
+
config?: {
|
|
29
|
+
extractedCatalogs?: AccessibilityCatalog[];
|
|
30
|
+
models?: Array<{
|
|
31
|
+
id?: string;
|
|
32
|
+
accessibilityCatalogs?: AccessibilityCatalog[];
|
|
33
|
+
}>;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** Who is rendering the entity — everything owner scoping is derived from. */
|
|
37
|
+
export interface CatalogOwnerIdentity {
|
|
38
|
+
kind: RuntimeRegistrationKind;
|
|
39
|
+
/** The rendered instance id. */
|
|
40
|
+
itemId: string;
|
|
41
|
+
canonicalItemId?: string;
|
|
42
|
+
assessmentId?: string;
|
|
43
|
+
sectionId?: string;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The owner context an entity's catalogs are registered under, and therefore the
|
|
47
|
+
* one a reader must look them up with.
|
|
48
|
+
*
|
|
49
|
+
* Exported because readers construct the lookup context themselves: the
|
|
50
|
+
* resolver matches contexts field by field, so a reader that hand-assembled its
|
|
51
|
+
* own would silently resolve nothing the day either side gained a field.
|
|
52
|
+
*/
|
|
53
|
+
export declare function catalogOwnerContextFor(owner: CatalogOwnerIdentity): CatalogOwnerContext;
|
|
54
|
+
/**
|
|
55
|
+
* Every catalog an entity carries, paired with the owner scope it belongs in.
|
|
56
|
+
*
|
|
57
|
+
* Three places carry catalogs, and the distinction matters to resolution rather
|
|
58
|
+
* than only to bookkeeping: entity-level `accessibilityCatalogs` and
|
|
59
|
+
* extractor-generated `config.extractedCatalogs` are filed against the entity,
|
|
60
|
+
* while a model's own catalogs are filed against that model, so two models on
|
|
61
|
+
* one item can use the same catalog identifier without colliding.
|
|
62
|
+
*
|
|
63
|
+
* Passages have no models, so the walk stops after the entity-level pair.
|
|
64
|
+
*/
|
|
65
|
+
export declare function collectEntityCatalogRegistrations(entity: CatalogSourceEntity | null | undefined, owner: CatalogOwnerIdentity): CatalogRegistration[];
|
|
66
|
+
/** Adapter for the runtime registration event a shell dispatches on mount. */
|
|
12
67
|
export declare function collectCatalogRegistrations(detail: RuntimeRegistrationDetail, runtime?: CatalogRegistrationRuntimeContext): CatalogRegistration[];
|
|
@@ -1,53 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where accessibility catalogs live on a rendered entity, and which owner scope
|
|
3
|
+
* each one belongs to.
|
|
4
|
+
*
|
|
5
|
+
* Catalogs are placed dynamically: a shell registers what its entity carries
|
|
6
|
+
* when it mounts, and readers (TTS, the item card's media region) resolve by
|
|
7
|
+
* identifier within an owner scope. Both sides therefore have to agree on two
|
|
8
|
+
* facts — the three places catalogs can hang off an entity, and the owner
|
|
9
|
+
* context each one is filed under. This module is the only place either is
|
|
10
|
+
* decided, so a reader cannot look up a scope registration never wrote.
|
|
11
|
+
*
|
|
12
|
+
* Part of PIE Assessment Toolkit.
|
|
13
|
+
*/
|
|
1
14
|
const hasCatalogs = (catalogs) => Array.isArray(catalogs) && catalogs.length > 0;
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
15
|
+
/**
|
|
16
|
+
* The owner context an entity's catalogs are registered under, and therefore the
|
|
17
|
+
* one a reader must look them up with.
|
|
18
|
+
*
|
|
19
|
+
* Exported because readers construct the lookup context themselves: the
|
|
20
|
+
* resolver matches contexts field by field, so a reader that hand-assembled its
|
|
21
|
+
* own would silently resolve nothing the day either side gained a field.
|
|
22
|
+
*/
|
|
23
|
+
export function catalogOwnerContextFor(owner) {
|
|
24
|
+
if (owner.kind === "passage") {
|
|
25
|
+
return {
|
|
9
26
|
ownerKind: "passage",
|
|
10
|
-
assessmentId:
|
|
11
|
-
sectionId:
|
|
12
|
-
passageId:
|
|
27
|
+
assessmentId: owner.assessmentId,
|
|
28
|
+
sectionId: owner.sectionId,
|
|
29
|
+
passageId: owner.canonicalItemId || owner.itemId,
|
|
13
30
|
};
|
|
14
|
-
if (hasCatalogs(entity.accessibilityCatalogs)) {
|
|
15
|
-
registrations.push({ context, catalogs: entity.accessibilityCatalogs });
|
|
16
|
-
}
|
|
17
|
-
if (hasCatalogs(entity.config?.extractedCatalogs)) {
|
|
18
|
-
registrations.push({
|
|
19
|
-
context,
|
|
20
|
-
catalogs: entity.config.extractedCatalogs,
|
|
21
|
-
});
|
|
22
|
-
}
|
|
23
|
-
return registrations;
|
|
24
31
|
}
|
|
25
|
-
|
|
32
|
+
return {
|
|
26
33
|
ownerKind: "itemModel",
|
|
27
|
-
assessmentId:
|
|
28
|
-
sectionId:
|
|
29
|
-
itemId:
|
|
30
|
-
canonicalItemId:
|
|
34
|
+
assessmentId: owner.assessmentId,
|
|
35
|
+
sectionId: owner.sectionId,
|
|
36
|
+
itemId: owner.itemId,
|
|
37
|
+
canonicalItemId: owner.canonicalItemId || owner.itemId,
|
|
31
38
|
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Every catalog an entity carries, paired with the owner scope it belongs in.
|
|
42
|
+
*
|
|
43
|
+
* Three places carry catalogs, and the distinction matters to resolution rather
|
|
44
|
+
* than only to bookkeeping: entity-level `accessibilityCatalogs` and
|
|
45
|
+
* extractor-generated `config.extractedCatalogs` are filed against the entity,
|
|
46
|
+
* while a model's own catalogs are filed against that model, so two models on
|
|
47
|
+
* one item can use the same catalog identifier without colliding.
|
|
48
|
+
*
|
|
49
|
+
* Passages have no models, so the walk stops after the entity-level pair.
|
|
50
|
+
*/
|
|
51
|
+
export function collectEntityCatalogRegistrations(entity, owner) {
|
|
52
|
+
if (!entity)
|
|
53
|
+
return [];
|
|
54
|
+
const context = catalogOwnerContextFor(owner);
|
|
55
|
+
const registrations = [];
|
|
32
56
|
if (hasCatalogs(entity.accessibilityCatalogs)) {
|
|
33
|
-
registrations.push({
|
|
34
|
-
context: itemContext,
|
|
35
|
-
catalogs: entity.accessibilityCatalogs,
|
|
36
|
-
});
|
|
57
|
+
registrations.push({ context, catalogs: entity.accessibilityCatalogs });
|
|
37
58
|
}
|
|
38
59
|
if (hasCatalogs(entity.config?.extractedCatalogs)) {
|
|
39
60
|
registrations.push({
|
|
40
|
-
context
|
|
61
|
+
context,
|
|
41
62
|
catalogs: entity.config.extractedCatalogs,
|
|
42
63
|
});
|
|
43
64
|
}
|
|
65
|
+
if (owner.kind === "passage")
|
|
66
|
+
return registrations;
|
|
44
67
|
for (const model of entity.config?.models ?? []) {
|
|
45
68
|
if (!hasCatalogs(model.accessibilityCatalogs))
|
|
46
69
|
continue;
|
|
47
70
|
registrations.push({
|
|
48
|
-
context: { ...
|
|
71
|
+
context: { ...context, modelId: model.id },
|
|
49
72
|
catalogs: model.accessibilityCatalogs,
|
|
50
73
|
});
|
|
51
74
|
}
|
|
52
75
|
return registrations;
|
|
53
76
|
}
|
|
77
|
+
/** Adapter for the runtime registration event a shell dispatches on mount. */
|
|
78
|
+
export function collectCatalogRegistrations(detail, runtime = {}) {
|
|
79
|
+
return collectEntityCatalogRegistrations(detail.item, {
|
|
80
|
+
kind: detail.kind,
|
|
81
|
+
itemId: detail.itemId,
|
|
82
|
+
canonicalItemId: detail.canonicalItemId,
|
|
83
|
+
assessmentId: runtime.assessmentId,
|
|
84
|
+
sectionId: runtime.sectionId,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composition emit scheduler (PIE-885).
|
|
3
|
+
*
|
|
4
|
+
* `PieAssessmentToolkit.svelte` publishes the section composition to the
|
|
5
|
+
* players through exactly one path — the `composition-changed` event — and
|
|
6
|
+
* coalesces bursts of updates behind a one-shot latch so several changes
|
|
7
|
+
* within one frame produce a single emit.
|
|
8
|
+
*
|
|
9
|
+
* That latch used to be cleared only by a `requestAnimationFrame` callback,
|
|
10
|
+
* and the frame branch was chosen whenever `window.requestAnimationFrame`
|
|
11
|
+
* merely *existed* rather than when it was known to fire. In a document that
|
|
12
|
+
* never paints the callback never ran, the latch never cleared, and no
|
|
13
|
+
* `composition-changed` was ever dispatched: the section controller held a
|
|
14
|
+
* correct view model while the player kept its initial empty composition, so
|
|
15
|
+
* every `pie-section-player` route rendered no content at all. The permanent
|
|
16
|
+
* failure was in contexts with no compositor — headless browsers, hidden or
|
|
17
|
+
* offscreen tabs, agent and CI automation harnesses. A background tab only
|
|
18
|
+
* rendered late, because its pending frame becomes due on refocus.
|
|
19
|
+
*
|
|
20
|
+
* So the frame is raced against a deadline timer instead of trusted. Whichever
|
|
21
|
+
* arrives first releases the latch and flushes, and a non-painting document
|
|
22
|
+
* degrades to a slower render rather than a permanent blank. Svelte's own
|
|
23
|
+
* `tick()` races the same two primitives for the same reason.
|
|
24
|
+
*
|
|
25
|
+
* This scheduler owns the latch and both handles, which is what makes "the
|
|
26
|
+
* latch cannot stay set after a scheduled frame is cancelled or superseded"
|
|
27
|
+
* structural rather than a discipline every call site has to keep: releasing
|
|
28
|
+
* the latch and releasing the handles is one operation in one place.
|
|
29
|
+
*
|
|
30
|
+
* Deliberately stateless across cycles. A cycle the timer wins does not switch
|
|
31
|
+
* the scheduler into a timer-preferring mode: that trades the deadline cost for
|
|
32
|
+
* a mode which never returns to frame alignment once a single frame is missed,
|
|
33
|
+
* and a slower render is the accepted outcome.
|
|
34
|
+
*
|
|
35
|
+
* NOT a public surface: internal to the toolkit and not exported from
|
|
36
|
+
* `runtime/engine.ts`. It exists as its own module so the race can be pinned in
|
|
37
|
+
* a unit test without mounting the toolkit CE — same rationale as
|
|
38
|
+
* `runtime/stage-emit-gate.ts`.
|
|
39
|
+
*/
|
|
40
|
+
/**
|
|
41
|
+
* How long to wait for a frame before the timer takes over.
|
|
42
|
+
*
|
|
43
|
+
* Six frames at 60fps, so on any normally painting document the frame still
|
|
44
|
+
* wins and emits stay paint-aligned. Chrome clamps timers in hidden tabs to
|
|
45
|
+
* ≥1s and can clamp harder under intensive throttling; that makes a hidden tab
|
|
46
|
+
* bounded-slow instead of blank, which is the point.
|
|
47
|
+
*/
|
|
48
|
+
export declare const DEFAULT_FRAME_DEADLINE_MS = 100;
|
|
49
|
+
/**
|
|
50
|
+
* Timing primitives, injectable so tests can drive the race deterministically.
|
|
51
|
+
*
|
|
52
|
+
* An omitted primitive is resolved from `globalThis` at `schedule()` time
|
|
53
|
+
* rather than captured at construction: the pre-fix code also checked per
|
|
54
|
+
* emit, and a host can install a `requestAnimationFrame` shim after the
|
|
55
|
+
* toolkit is constructed. An explicit `null` opts the primitive out.
|
|
56
|
+
*/
|
|
57
|
+
export interface CompositionEmitSchedulerTiming {
|
|
58
|
+
requestFrame?: ((callback: () => void) => number) | null;
|
|
59
|
+
cancelFrame?: ((handle: number) => void) | null;
|
|
60
|
+
setTimer?: ((callback: () => void, delayMs: number) => unknown) | null;
|
|
61
|
+
clearTimer?: ((handle: unknown) => void) | null;
|
|
62
|
+
queueMicrotaskFn?: ((callback: () => void) => void) | null;
|
|
63
|
+
frameDeadlineMs?: number;
|
|
64
|
+
}
|
|
65
|
+
export interface CompositionEmitScheduler {
|
|
66
|
+
/**
|
|
67
|
+
* Arm a flush. While a cycle is pending this only replaces the stored
|
|
68
|
+
* callback — that is the coalescing guarantee: one resolution, one flush.
|
|
69
|
+
*/
|
|
70
|
+
schedule(flush: () => void): void;
|
|
71
|
+
/**
|
|
72
|
+
* Abandon a pending cycle. Releases the latch and both handles together,
|
|
73
|
+
* and is idempotent.
|
|
74
|
+
*/
|
|
75
|
+
cancel(): void;
|
|
76
|
+
isPending(): boolean;
|
|
77
|
+
}
|
|
78
|
+
export declare function createCompositionEmitScheduler(timing?: CompositionEmitSchedulerTiming): CompositionEmitScheduler;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composition emit scheduler (PIE-885).
|
|
3
|
+
*
|
|
4
|
+
* `PieAssessmentToolkit.svelte` publishes the section composition to the
|
|
5
|
+
* players through exactly one path — the `composition-changed` event — and
|
|
6
|
+
* coalesces bursts of updates behind a one-shot latch so several changes
|
|
7
|
+
* within one frame produce a single emit.
|
|
8
|
+
*
|
|
9
|
+
* That latch used to be cleared only by a `requestAnimationFrame` callback,
|
|
10
|
+
* and the frame branch was chosen whenever `window.requestAnimationFrame`
|
|
11
|
+
* merely *existed* rather than when it was known to fire. In a document that
|
|
12
|
+
* never paints the callback never ran, the latch never cleared, and no
|
|
13
|
+
* `composition-changed` was ever dispatched: the section controller held a
|
|
14
|
+
* correct view model while the player kept its initial empty composition, so
|
|
15
|
+
* every `pie-section-player` route rendered no content at all. The permanent
|
|
16
|
+
* failure was in contexts with no compositor — headless browsers, hidden or
|
|
17
|
+
* offscreen tabs, agent and CI automation harnesses. A background tab only
|
|
18
|
+
* rendered late, because its pending frame becomes due on refocus.
|
|
19
|
+
*
|
|
20
|
+
* So the frame is raced against a deadline timer instead of trusted. Whichever
|
|
21
|
+
* arrives first releases the latch and flushes, and a non-painting document
|
|
22
|
+
* degrades to a slower render rather than a permanent blank. Svelte's own
|
|
23
|
+
* `tick()` races the same two primitives for the same reason.
|
|
24
|
+
*
|
|
25
|
+
* This scheduler owns the latch and both handles, which is what makes "the
|
|
26
|
+
* latch cannot stay set after a scheduled frame is cancelled or superseded"
|
|
27
|
+
* structural rather than a discipline every call site has to keep: releasing
|
|
28
|
+
* the latch and releasing the handles is one operation in one place.
|
|
29
|
+
*
|
|
30
|
+
* Deliberately stateless across cycles. A cycle the timer wins does not switch
|
|
31
|
+
* the scheduler into a timer-preferring mode: that trades the deadline cost for
|
|
32
|
+
* a mode which never returns to frame alignment once a single frame is missed,
|
|
33
|
+
* and a slower render is the accepted outcome.
|
|
34
|
+
*
|
|
35
|
+
* NOT a public surface: internal to the toolkit and not exported from
|
|
36
|
+
* `runtime/engine.ts`. It exists as its own module so the race can be pinned in
|
|
37
|
+
* a unit test without mounting the toolkit CE — same rationale as
|
|
38
|
+
* `runtime/stage-emit-gate.ts`.
|
|
39
|
+
*/
|
|
40
|
+
/**
|
|
41
|
+
* How long to wait for a frame before the timer takes over.
|
|
42
|
+
*
|
|
43
|
+
* Six frames at 60fps, so on any normally painting document the frame still
|
|
44
|
+
* wins and emits stay paint-aligned. Chrome clamps timers in hidden tabs to
|
|
45
|
+
* ≥1s and can clamp harder under intensive throttling; that makes a hidden tab
|
|
46
|
+
* bounded-slow instead of blank, which is the point.
|
|
47
|
+
*/
|
|
48
|
+
export const DEFAULT_FRAME_DEADLINE_MS = 100;
|
|
49
|
+
function pickFunction(override, ambient) {
|
|
50
|
+
if (typeof override === "function")
|
|
51
|
+
return override;
|
|
52
|
+
if (override === null)
|
|
53
|
+
return null;
|
|
54
|
+
return typeof ambient === "function" ? ambient : null;
|
|
55
|
+
}
|
|
56
|
+
export function createCompositionEmitScheduler(timing = {}) {
|
|
57
|
+
const frameDeadlineMs = timing.frameDeadlineMs ?? DEFAULT_FRAME_DEADLINE_MS;
|
|
58
|
+
let pending = false;
|
|
59
|
+
let pendingFlush = null;
|
|
60
|
+
let frameHandle = null;
|
|
61
|
+
let timerHandle = null;
|
|
62
|
+
// Kept from the arming call so a cancel is paired with the primitive that
|
|
63
|
+
// actually armed the handle.
|
|
64
|
+
let cancelFrame = null;
|
|
65
|
+
let clearTimer = null;
|
|
66
|
+
function release() {
|
|
67
|
+
pending = false;
|
|
68
|
+
if (frameHandle !== null) {
|
|
69
|
+
cancelFrame?.(frameHandle);
|
|
70
|
+
frameHandle = null;
|
|
71
|
+
}
|
|
72
|
+
if (timerHandle !== null) {
|
|
73
|
+
clearTimer?.(timerHandle);
|
|
74
|
+
timerHandle = null;
|
|
75
|
+
}
|
|
76
|
+
cancelFrame = null;
|
|
77
|
+
clearTimer = null;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Resolve one cycle. `firedFrom` names the side that arrived so its own
|
|
81
|
+
* handle is dropped rather than handed back to a cancel that has nothing
|
|
82
|
+
* left to cancel.
|
|
83
|
+
*
|
|
84
|
+
* The latch is released before the flush callback runs, so a re-entrant
|
|
85
|
+
* `schedule()` from inside the flush arms a fresh cycle instead of being
|
|
86
|
+
* swallowed. The pre-fix code cleared its latch first for the same reason.
|
|
87
|
+
*/
|
|
88
|
+
function resolve(firedFrom) {
|
|
89
|
+
if (!pending)
|
|
90
|
+
return;
|
|
91
|
+
if (firedFrom === "frame")
|
|
92
|
+
frameHandle = null;
|
|
93
|
+
if (firedFrom === "timer")
|
|
94
|
+
timerHandle = null;
|
|
95
|
+
const flushNow = pendingFlush;
|
|
96
|
+
pendingFlush = null;
|
|
97
|
+
release();
|
|
98
|
+
flushNow?.();
|
|
99
|
+
}
|
|
100
|
+
function schedule(flush) {
|
|
101
|
+
pendingFlush = flush;
|
|
102
|
+
if (pending)
|
|
103
|
+
return;
|
|
104
|
+
pending = true;
|
|
105
|
+
const ambient = globalThis;
|
|
106
|
+
const requestFrame = pickFunction(timing.requestFrame, ambient.requestAnimationFrame);
|
|
107
|
+
cancelFrame = pickFunction(timing.cancelFrame, ambient.cancelAnimationFrame);
|
|
108
|
+
const setTimer = pickFunction(timing.setTimer, ambient.setTimeout);
|
|
109
|
+
clearTimer = pickFunction(timing.clearTimer, ambient.clearTimeout);
|
|
110
|
+
if (requestFrame) {
|
|
111
|
+
const handle = requestFrame(() => resolve("frame"));
|
|
112
|
+
// A primitive that calls back synchronously has already flushed and
|
|
113
|
+
// released; arming anything else would leave a stray handle behind.
|
|
114
|
+
if (!pending)
|
|
115
|
+
return;
|
|
116
|
+
frameHandle = handle;
|
|
117
|
+
// The frame is not trusted to arrive. Without a timer alongside it, a
|
|
118
|
+
// document that never paints leaves the latch set forever.
|
|
119
|
+
if (setTimer) {
|
|
120
|
+
const deadlineHandle = setTimer(() => resolve("timer"), frameDeadlineMs);
|
|
121
|
+
if (pending)
|
|
122
|
+
timerHandle = deadlineHandle;
|
|
123
|
+
}
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
// No frame primitive at all (SSR, non-DOM test environments). Preserve
|
|
127
|
+
// the microtask timing those hosts already had.
|
|
128
|
+
const queueMicrotaskFn = pickFunction(timing.queueMicrotaskFn, ambient.queueMicrotask);
|
|
129
|
+
if (queueMicrotaskFn) {
|
|
130
|
+
queueMicrotaskFn(() => resolve("microtask"));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (setTimer) {
|
|
134
|
+
const deadlineHandle = setTimer(() => resolve("timer"), 0);
|
|
135
|
+
if (pending)
|
|
136
|
+
timerHandle = deadlineHandle;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
// Nothing to defer with. A synchronous flush is strictly better than
|
|
140
|
+
// dropping the composition on the floor.
|
|
141
|
+
resolve("microtask");
|
|
142
|
+
}
|
|
143
|
+
function cancel() {
|
|
144
|
+
if (!pending)
|
|
145
|
+
return;
|
|
146
|
+
pendingFlush = null;
|
|
147
|
+
release();
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
schedule,
|
|
151
|
+
cancel,
|
|
152
|
+
isPending: () => pending,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -120,7 +120,7 @@ export declare function resolveRuntime(args: {
|
|
|
120
120
|
createSectionController: unknown;
|
|
121
121
|
isolation: string;
|
|
122
122
|
env: Record<string, unknown>;
|
|
123
|
-
toolConfigStrictness: "off" | "
|
|
123
|
+
toolConfigStrictness: "off" | "warn" | "error";
|
|
124
124
|
onFrameworkError: FrameworkErrorHandler | undefined;
|
|
125
125
|
onStageChange: StageChangeHandler | undefined;
|
|
126
126
|
onLoadingComplete: LoadingCompleteHandler | undefined;
|