@pie-players/pie-players-shared 0.3.74 → 1.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +88 -75
  2. package/dist/components/vendor/nds/README.md +4 -5
  3. package/dist/config/profile.d.ts +1 -1
  4. package/dist/config/profile.js +1 -1
  5. package/dist/i18n/index.d.ts +1 -1
  6. package/dist/i18n/index.js +1 -1
  7. package/dist/i18n/language-tags.d.ts +9 -1
  8. package/dist/i18n/language-tags.js +11 -1
  9. package/dist/i18n/messages/en-US.d.ts +33 -28
  10. package/dist/i18n/messages/en-US.js +34 -29
  11. package/dist/i18n/messages/nl-NL.d.ts +33 -28
  12. package/dist/i18n/messages/nl-NL.js +34 -29
  13. package/dist/i18n/provider.d.ts +14 -7
  14. package/dist/i18n/provider.js +26 -29
  15. package/dist/i18n/types.d.ts +4 -5
  16. package/dist/index.d.ts +1 -1
  17. package/dist/index.js +0 -1
  18. package/dist/instrumentation/providers/BaseInstrumentationProvider.js +0 -14
  19. package/dist/loader-config.d.ts +0 -2
  20. package/dist/loader-config.js +0 -2
  21. package/dist/loaders/element-loader.js +13 -6
  22. package/dist/loaders/esm-adapter.d.ts +11 -0
  23. package/dist/loaders/esm-adapter.js +74 -45
  24. package/dist/loaders/iife-adapter.d.ts +3 -3
  25. package/dist/loaders/iife-adapter.js +36 -35
  26. package/dist/loaders/index.d.ts +4 -2
  27. package/dist/loaders/index.js +2 -1
  28. package/dist/loaders/mathjax-events.d.ts +22 -0
  29. package/dist/loaders/mathjax-events.js +65 -0
  30. package/dist/loaders/preloaded-registration.d.ts +18 -2
  31. package/dist/loaders/preloaded-registration.js +14 -2
  32. package/dist/media/index.d.ts +4 -6
  33. package/dist/media/index.js +6 -8
  34. package/dist/object/index.d.ts +8 -3
  35. package/dist/object/index.js +8 -6
  36. package/dist/pie/asset-handler.d.ts +0 -17
  37. package/dist/pie/asset-handler.js +1 -1
  38. package/dist/pie/authoring.js +5 -7
  39. package/dist/pie/elements-rendered.d.ts +23 -0
  40. package/dist/pie/elements-rendered.js +96 -0
  41. package/dist/pie/index.d.ts +4 -6
  42. package/dist/pie/index.js +4 -7
  43. package/dist/pie/initialization.d.ts +13 -14
  44. package/dist/pie/initialization.js +61 -75
  45. package/dist/pie/instrumentation-event-bridge.js +23 -1
  46. package/dist/pie/instrumentation-event-map.js +11 -18
  47. package/dist/pie/item-controller.js +4 -0
  48. package/dist/pie/item-session-contract.js +7 -9
  49. package/dist/pie/logger.d.ts +6 -0
  50. package/dist/pie/logger.js +10 -0
  51. package/dist/pie/math-assets.d.ts +48 -0
  52. package/dist/pie/math-assets.js +81 -0
  53. package/dist/pie/resource-monitor.d.ts +34 -32
  54. package/dist/pie/resource-monitor.js +152 -231
  55. package/dist/pie/scoring.d.ts +1 -6
  56. package/dist/pie/scoring.js +3 -8
  57. package/dist/pie/session-commit.js +1 -1
  58. package/dist/pie/stages.d.ts +11 -47
  59. package/dist/pie/stages.js +11 -47
  60. package/dist/pie/updates.js +5 -4
  61. package/dist/pie/utils.d.ts +0 -2
  62. package/dist/pie/utils.js +0 -2
  63. package/dist/security/sanitize-item-markup.d.ts +8 -4
  64. package/dist/security/sanitize-item-markup.js +52 -6
  65. package/dist/security/sanitize-svg-icon.js +2 -2
  66. package/dist/security/wrap-overwide-images.d.ts +1 -1
  67. package/dist/security/wrap-overwide-images.js +20 -1
  68. package/dist/security/wrap-overwide.d.ts +8 -0
  69. package/dist/security/wrap-overwide.js +47 -3
  70. package/dist/timed-media/media-element-source.js +1 -2
  71. package/dist/types/index.d.ts +88 -153
  72. package/dist/types/index.js +2 -2
  73. package/dist/ui/attribute-coercion.d.ts +5 -0
  74. package/dist/ui/attribute-coercion.js +6 -4
  75. package/dist/ui/content-styles.d.ts +6 -10
  76. package/dist/ui/content-styles.js +8 -13
  77. package/dist/ui/first-focusable.d.ts +0 -1
  78. package/dist/ui/first-focusable.js +0 -1
  79. package/dist/ui/focus-trap.js +2 -3
  80. package/dist/ui/scope-css.d.ts +23 -26
  81. package/dist/ui/scope-css.js +23 -26
  82. package/package.json +6 -4
  83. package/dist/loaders/mathjax-version-conflict.d.ts +0 -17
  84. package/dist/loaders/mathjax-version-conflict.js +0 -51
  85. package/dist/pie/stage-tracker.d.ts +0 -50
  86. package/dist/pie/stage-tracker.js +0 -105
@@ -20,14 +20,9 @@ export type ScorePieItemOptions = {
20
20
  container?: Pick<ParentNode, "querySelector">;
21
21
  env?: Partial<Env>;
22
22
  models?: PieModel[];
23
- /**
24
- * Existing callers keep the historical scorePieItem controller call shape.
25
- * provideScore() opts into the item-player score result shape explicitly.
26
- */
27
- outcomeArguments?: "session-env" | "model-session-env";
28
23
  /**
29
24
  * Preserve one result slot per model, including undefined for missing element/controller.
30
- * Existing scorePieItem callers keep the filtered result shape by default.
25
+ * Missing slots are filtered out by default.
31
26
  */
32
27
  includeMissingResults?: boolean;
33
28
  /** The scoring player's bundle type; `player.js` scores nothing locally. */
@@ -62,13 +62,9 @@ const findRenderedPieElement = (root, modelId) => {
62
62
  return (root.querySelector(`[id="${escapedId}"]`) ||
63
63
  root.querySelector(`[pie-id="${escapedId}"]`));
64
64
  };
65
- const invokeOutcome = async (controller, model, session, env, argumentShape) => {
66
- if (argumentShape === "model-session-env") {
67
- const outcome = controller.outcome;
68
- return outcome.call(controller, model, session, env);
69
- }
65
+ const invokeOutcome = async (controller, model, session, env) => {
70
66
  const outcome = controller.outcome;
71
- return outcome.call(controller, session, env);
67
+ return outcome.call(controller, model, session, env);
72
68
  };
73
69
  /**
74
70
  * Score a PIE item by calling outcome on all PIE elements
@@ -76,7 +72,6 @@ const invokeOutcome = async (controller, model, session, env, argumentShape) =>
76
72
  export async function scorePieItem(config, sessionData, options = {}) {
77
73
  const root = options.container ?? document;
78
74
  const models = options.models ?? config.models ?? [];
79
- const outcomeArguments = options.outcomeArguments ?? "session-env";
80
75
  const scoringEnv = {
81
76
  ...options.env,
82
77
  mode: "evaluate",
@@ -91,7 +86,7 @@ export async function scorePieItem(config, sessionData, options = {}) {
91
86
  if (controller?.outcome) {
92
87
  return {
93
88
  ...session,
94
- ...(await invokeOutcome(controller, model, session, scoringEnv, outcomeArguments)),
89
+ ...(await invokeOutcome(controller, model, session, scoringEnv)),
95
90
  };
96
91
  }
97
92
  else {
@@ -93,7 +93,7 @@ function assignedElementsOf(element) {
93
93
  * elements assigned to each `<slot>`.
94
94
  *
95
95
  * `querySelectorAll("*")` sees none of the last two, and both are load-bearing
96
- * here. The section player mounts each item inside `<pie-item-shell>`'s shadow
96
+ * here. The section player mounts each item inside `<pie-item-scope>`'s shadow
97
97
  * root, and projects its item pane through a slot on the assessment toolkit -
98
98
  * a walk that stops at `<slot>` finds every delivery element in a section
99
99
  * unreachable.
@@ -1,56 +1,21 @@
1
1
  /**
2
- * Canonical stage vocabulary (M6 of the Coherent Options Surface track).
2
+ * Stage vocabulary of the section player's `pie-stage-change` event.
3
3
  *
4
- * One monotonic stage list is the unified replacement for the previous
5
- * drift of `toolkit-ready`, `section-ready`, `section-controller-ready`,
6
- * `interaction-ready`, `readiness-change`, and `ready` — all of which
7
- * have been removed. Hosts ask the same question — "is this CE at
8
- * stage ≥ X?" — across every `<pie-section-player-*>` and
9
- * `<pie-assessment-toolkit>` element.
4
+ * A cohort (one section and attempt) moves through `composed`,
5
+ * `engine-ready`, `interactive` and `disposed`, in that order. The section
6
+ * runtime engine in `@pie-players/pie-assessment-toolkit` is the only emitter;
7
+ * it dispatches the event on the layout element. `pie-loading-complete` is
8
+ * not a stage; it fires once per cohort when the section controller is ready
9
+ * and the element pre-warm has resolved. `readiness.mode = "strict"` only
10
+ * delays `interactive`.
10
11
  *
11
- * The four-stage canonical list (`composed`, `engine-ready`, `interactive`,
12
- * `disposed`) was finalized after the M5/M6 cumulative review confirmed
13
- * zero internal or external consumers for the original `attached`,
14
- * `runtime-bound`, and `ui-rendered` stages. The retro keeps the same
15
- * shape for both the toolkit CE and the layout CEs — every CE iterates
16
- * through the same list so subscribers don't need to special-case shape.
17
- *
18
- * Locked decisions (see `m6_ready_vocab_canonical_followup.plan.md`):
19
- * - **A1** Stage machine model with one canonical event family.
20
- * - **B1** "All items loaded" is reclassified as `pie-loading-complete`,
21
- * not a stage. (The previous `ready` DOM event alias was removed in
22
- * the broad architecture review compat sweep.)
23
- * - **C1** `readiness.mode = "strict"` only delays the `interactive`
24
- * transition; every earlier stage fires identically across modes.
25
- * - **D1** One DOM event family `pie-stage-change` with the stage in the
26
- * payload, mirroring the M3 `framework-error` idiom.
27
- *
28
- * Failure semantics deliberately stay in the M3 framework-error contract —
29
- * `status: "failed"` records the position; the framework error records the
30
- * cause.
12
+ * `status` is `entered`, or `failed` for the stage a non-recoverable
13
+ * framework error stopped the chain at, with `skipped` for each later stage
14
+ * up to `interactive`. The `framework-error` event carries the cause.
31
15
  */
32
16
  export declare const STAGES: readonly ["composed", "engine-ready", "interactive", "disposed"];
33
17
  export type Stage = (typeof STAGES)[number];
34
18
  export type StageStatus = "entered" | "skipped" | "failed";
35
- /**
36
- * Subset of the source CE shape used by stage subscribers. Both shapes
37
- * apply every canonical stage post-retro — the distinction stays in the
38
- * type so future shape-specific stages (e.g. layout-only `paginated`) can
39
- * be added without churn at the call sites.
40
- */
41
- export type StageSourceCe = "toolkit" | "layout";
42
- /**
43
- * Stages that apply to a given CE shape. Post-retro the toolkit and the
44
- * layout CEs share the same canonical list; `applicableStages` is kept as
45
- * an indirection so the tracker, telemetry, and tests don't have to be
46
- * rewritten when a future stage diverges between shapes.
47
- */
48
- export declare function applicableStages(_sourceCe: StageSourceCe): readonly Stage[];
49
- /**
50
- * Strict ordering index used by the tracker to detect monotonic
51
- * violations. `disposed` is last; `composed` is first.
52
- */
53
- export declare function stageOrdinal(stage: Stage): number;
54
19
  export type StageChangeDetail = {
55
20
  stage: Stage;
56
21
  status: StageStatus;
@@ -68,7 +33,6 @@ export type LoadingCompleteDetail = {
68
33
  sectionId: string;
69
34
  attemptId?: string;
70
35
  itemCount: number;
71
- loadedCount: number;
72
36
  timestamp: string;
73
37
  sourceCe: string;
74
38
  };
@@ -1,33 +1,17 @@
1
1
  /**
2
- * Canonical stage vocabulary (M6 of the Coherent Options Surface track).
2
+ * Stage vocabulary of the section player's `pie-stage-change` event.
3
3
  *
4
- * One monotonic stage list is the unified replacement for the previous
5
- * drift of `toolkit-ready`, `section-ready`, `section-controller-ready`,
6
- * `interaction-ready`, `readiness-change`, and `ready` — all of which
7
- * have been removed. Hosts ask the same question — "is this CE at
8
- * stage ≥ X?" — across every `<pie-section-player-*>` and
9
- * `<pie-assessment-toolkit>` element.
4
+ * A cohort (one section and attempt) moves through `composed`,
5
+ * `engine-ready`, `interactive` and `disposed`, in that order. The section
6
+ * runtime engine in `@pie-players/pie-assessment-toolkit` is the only emitter;
7
+ * it dispatches the event on the layout element. `pie-loading-complete` is
8
+ * not a stage; it fires once per cohort when the section controller is ready
9
+ * and the element pre-warm has resolved. `readiness.mode = "strict"` only
10
+ * delays `interactive`.
10
11
  *
11
- * The four-stage canonical list (`composed`, `engine-ready`, `interactive`,
12
- * `disposed`) was finalized after the M5/M6 cumulative review confirmed
13
- * zero internal or external consumers for the original `attached`,
14
- * `runtime-bound`, and `ui-rendered` stages. The retro keeps the same
15
- * shape for both the toolkit CE and the layout CEs — every CE iterates
16
- * through the same list so subscribers don't need to special-case shape.
17
- *
18
- * Locked decisions (see `m6_ready_vocab_canonical_followup.plan.md`):
19
- * - **A1** Stage machine model with one canonical event family.
20
- * - **B1** "All items loaded" is reclassified as `pie-loading-complete`,
21
- * not a stage. (The previous `ready` DOM event alias was removed in
22
- * the broad architecture review compat sweep.)
23
- * - **C1** `readiness.mode = "strict"` only delays the `interactive`
24
- * transition; every earlier stage fires identically across modes.
25
- * - **D1** One DOM event family `pie-stage-change` with the stage in the
26
- * payload, mirroring the M3 `framework-error` idiom.
27
- *
28
- * Failure semantics deliberately stay in the M3 framework-error contract —
29
- * `status: "failed"` records the position; the framework error records the
30
- * cause.
12
+ * `status` is `entered`, or `failed` for the stage a non-recoverable
13
+ * framework error stopped the chain at, with `skipped` for each later stage
14
+ * up to `interactive`. The `framework-error` event carries the cause.
31
15
  */
32
16
  export const STAGES = Object.freeze([
33
17
  "composed",
@@ -35,23 +19,3 @@ export const STAGES = Object.freeze([
35
19
  "interactive",
36
20
  "disposed",
37
21
  ]);
38
- /**
39
- * Stages that apply to a given CE shape. Post-retro the toolkit and the
40
- * layout CEs share the same canonical list; `applicableStages` is kept as
41
- * an indirection so the tracker, telemetry, and tests don't have to be
42
- * rewritten when a future stage diverges between shapes.
43
- */
44
- export function applicableStages(_sourceCe) {
45
- return STAGES;
46
- }
47
- /**
48
- * Strict ordering index used by the tracker to detect monotonic
49
- * violations. `disposed` is last; `composed` is first.
50
- */
51
- export function stageOrdinal(stage) {
52
- const idx = STAGES.indexOf(stage);
53
- if (idx === -1) {
54
- throw new Error(`Unknown stage \`${stage}\``);
55
- }
56
- return idx;
57
- }
@@ -8,7 +8,6 @@ import { createPieLogger, isGlobalDebugEnabled } from "./logger.js";
8
8
  import { findPieController } from "./scoring.js";
9
9
  import { defaultPieElementOptions } from "./types.js";
10
10
  import { findOrAddSession } from "./utils.js";
11
- // Create module-level logger (respects global debug flag - pass function for dynamic checking)
12
11
  const logger = createPieLogger("pie-updates", () => isGlobalDebugEnabled());
13
12
  const describeControllerShape = (controller) => {
14
13
  if (!controller)
@@ -67,8 +66,10 @@ const trackRemoval = (element) => {
67
66
  return () => wasConnected && !element.isConnected;
68
67
  };
69
68
  /**
70
- * Helper function to apply controller to element
71
- * Extracted to eliminate duplication and ensure consistent controller invocation
69
+ * Runs the controller's `model()` and assigns the result to the element, with
70
+ * the authored model's `id` and `element` as defaults, together with the
71
+ * session the controller may have written to. Assigns nothing once the element
72
+ * was removed.
72
73
  */
73
74
  const applyControllerToElement = async (element, model, elementSession, controller, env, logPrefix, wasRemoved, onElementSessionUpdate) => {
74
75
  logger.debug(`${logPrefix} Using controller, env:`, env);
@@ -118,7 +119,7 @@ const applyControllerToElement = async (element, model, elementSession, controll
118
119
  }
119
120
  catch (err) {
120
121
  logger.error(`${logPrefix} ❌ Controller error:`, err);
121
- throw err; // Re-throw - controller errors are fatal
122
+ throw err; // The caller reports it and falls back to the authored model.
122
123
  }
123
124
  };
124
125
  const resolveAndValidateUpdateOptions = (opts) => {
@@ -60,8 +60,6 @@ export declare const getPackageWithoutVersion: (packages: string) => string;
60
60
  /**
61
61
  * Find or add a session entry for a given element
62
62
  *
63
- * TODO: kinda gnarly, copied from player project
64
- *
65
63
  * @param data - Session data array
66
64
  * @param id - Model/element ID
67
65
  * @param element - Element tag name (optional)
package/dist/pie/utils.js CHANGED
@@ -103,8 +103,6 @@ export const getPackageWithoutVersion = (packages) => packages
103
103
  /**
104
104
  * Find or add a session entry for a given element
105
105
  *
106
- * TODO: kinda gnarly, copied from player project
107
- *
108
106
  * @param data - Session data array
109
107
  * @param id - Model/element ID
110
108
  * @param element - Element tag name (optional)
@@ -31,10 +31,14 @@ export interface SanitizeItemMarkupOptions {
31
31
  * Sanitize raw item/passage markup before it is injected into the DOM.
32
32
  *
33
33
  * - Strips `<script>`, event-handler attributes, unknown protocols and
34
- * a standard set of dangerous tags (`iframe`, `object`, `embed`, `base`,
35
- * `form`, `meta`, `link`).
34
+ * the other tags in `SANITIZER_FORBIDDEN_TAGS` (`iframe`, `object`, `embed`,
35
+ * `base`, `form`, `meta`, `link`, `style`, `foreignObject`).
36
36
  * - Preserves PIE custom elements (`pie-*`) and any extra tags listed in
37
37
  * `allowedCustomElements`.
38
+ * - Marks elements that carry an authored color, as `markAuthoredColors`
39
+ * in `@pie-element/shared-utils` describes, so a color scheme can override
40
+ * it. A host that opts out with `trust-markup` or supplies its own
41
+ * sanitizer gets no markers.
38
42
  * - During SSR (no `window`) returns an empty string so untrusted markup
39
43
  * never reaches the prerender output; the live renderer will re-run the
40
44
  * sanitizer on hydrate.
@@ -42,8 +46,8 @@ export interface SanitizeItemMarkupOptions {
42
46
  export declare function sanitizeItemMarkup(markup: string, options?: SanitizeItemMarkupOptions): string;
43
47
  /**
44
48
  * Build the default `ItemMarkupSanitizer` used by the players. The returned
45
- * function is stable for a given set of allowed custom elements so callers
46
- * can safely use reference equality when deciding whether to re-sanitize.
49
+ * function captures the options once; each call returns a new function, so a
50
+ * caller that compares sanitizers by identity keeps the one it built.
47
51
  */
48
52
  export declare function createDefaultItemMarkupSanitizer(options?: SanitizeItemMarkupOptions): ItemMarkupSanitizer;
49
53
  /**
@@ -7,6 +7,7 @@
7
7
  * or supply their own sanitizer function if they need a stricter / looser
8
8
  * allow-list.
9
9
  */
10
+ import { markAuthoredColors } from "@pie-element/shared-utils";
10
11
  import DOMPurify from "dompurify";
11
12
  import { SANITIZER_FORBIDDEN_ATTRS, SANITIZER_FORBIDDEN_TAGS, } from "./sanitize-forbidden-lists.js";
12
13
  import { installStyleAttributeHook } from "./sanitize-style-attribute.js";
@@ -29,6 +30,40 @@ const BASE_ALLOWED_ATTRS = [
29
30
  "lang",
30
31
  "dir",
31
32
  ];
33
+ // MathML authored content uses that DOMPurify drops: elementary math, which
34
+ // `@pie-element/shared-math-rendering-mathjax` rewrites as a table before
35
+ // MathJax reads it, and `semantics`, `annotation`, `mprescripts` and `none`.
36
+ // `annotation-xml` stays out because it is an HTML integration point.
37
+ const MATHML_TAGS = new Set([
38
+ "mstack",
39
+ "mlongdiv",
40
+ "msgroup",
41
+ "msrow",
42
+ "msline",
43
+ "mscarries",
44
+ "mscarry",
45
+ "semantics",
46
+ "annotation",
47
+ "mprescripts",
48
+ "none",
49
+ ]);
50
+ const MATHML_ATTRS = [
51
+ "stackalign",
52
+ "charalign",
53
+ "charspacing",
54
+ "longdivstyle",
55
+ "position",
56
+ "shift",
57
+ "location",
58
+ "crossout",
59
+ "leftoverhang",
60
+ "rightoverhang",
61
+ "mslinethickness",
62
+ "linebreak",
63
+ ];
64
+ // Prefixed MathML such as `<m:math>`, which the HTML parser reads as unknown
65
+ // HTML elements and the math adapter re-creates as MathML.
66
+ const PREFIXED_TAG = /^[a-z_][\w.-]*:[a-z][\w.-]*$/;
32
67
  const BASE_URI_SAFE_ATTRS = ["pie-id"];
33
68
  const FORBIDDEN_TAGS = SANITIZER_FORBIDDEN_TAGS;
34
69
  const FORBIDDEN_ATTRS = SANITIZER_FORBIDDEN_ATTRS;
@@ -57,16 +92,24 @@ function resolvePurifier() {
57
92
  // `style` is URI-safe to DOMPurify, so nothing inside it is inspected
58
93
  // without this. See sanitize-style-attribute.ts.
59
94
  installStyleAttributeHook(purifierInstance);
95
+ // After the style filter, so only declarations that survive it are marked.
96
+ // The markers are the contract a color scheme's CSS overrides authored
97
+ // colors through, shared with element model HTML. (PIE-1119)
98
+ purifierInstance.addHook?.("afterSanitizeAttributes", (node) => markAuthoredColors(node));
60
99
  return purifierInstance;
61
100
  }
62
101
  /**
63
102
  * Sanitize raw item/passage markup before it is injected into the DOM.
64
103
  *
65
104
  * - Strips `<script>`, event-handler attributes, unknown protocols and
66
- * a standard set of dangerous tags (`iframe`, `object`, `embed`, `base`,
67
- * `form`, `meta`, `link`).
105
+ * the other tags in `SANITIZER_FORBIDDEN_TAGS` (`iframe`, `object`, `embed`,
106
+ * `base`, `form`, `meta`, `link`, `style`, `foreignObject`).
68
107
  * - Preserves PIE custom elements (`pie-*`) and any extra tags listed in
69
108
  * `allowedCustomElements`.
109
+ * - Marks elements that carry an authored color, as `markAuthoredColors`
110
+ * in `@pie-element/shared-utils` describes, so a color scheme can override
111
+ * it. A host that opts out with `trust-markup` or supplies its own
112
+ * sanitizer gets no markers.
70
113
  * - During SSR (no `window`) returns an empty string so untrusted markup
71
114
  * never reaches the prerender output; the live renderer will re-run the
72
115
  * sanitizer on hydrate.
@@ -80,8 +123,11 @@ export function sanitizeItemMarkup(markup, options = {}) {
80
123
  const allowedCustomElements = (options.allowedCustomElements ?? []).map((name) => name.toLowerCase());
81
124
  const explicitCustomElementSet = new Set(allowedCustomElements);
82
125
  const result = purifier.sanitize(markup, {
83
- ADD_TAGS: allowedCustomElements,
84
- ADD_ATTR: BASE_ALLOWED_ATTRS,
126
+ // DOMPurify still removes a tag in FORBID_TAGS that this accepts.
127
+ ADD_TAGS: (tagName) => explicitCustomElementSet.has(tagName) ||
128
+ MATHML_TAGS.has(tagName) ||
129
+ PREFIXED_TAG.test(tagName),
130
+ ADD_ATTR: [...BASE_ALLOWED_ATTRS, ...MATHML_ATTRS],
85
131
  ADD_URI_SAFE_ATTR: BASE_URI_SAFE_ATTRS,
86
132
  FORBID_TAGS: FORBIDDEN_TAGS,
87
133
  FORBID_ATTR: FORBIDDEN_ATTRS,
@@ -121,8 +167,8 @@ export function sanitizeItemMarkup(markup, options = {}) {
121
167
  }
122
168
  /**
123
169
  * Build the default `ItemMarkupSanitizer` used by the players. The returned
124
- * function is stable for a given set of allowed custom elements so callers
125
- * can safely use reference equality when deciding whether to re-sanitize.
170
+ * function captures the options once; each call returns a new function, so a
171
+ * caller that compares sanitizers by identity keeps the one it built.
126
172
  */
127
173
  export function createDefaultItemMarkupSanitizer(options = {}) {
128
174
  const { allowedCustomElements, wrapOverwideContent } = options;
@@ -51,8 +51,8 @@ export function sanitizeSvgIcon(icon) {
51
51
  });
52
52
  const str = typeof result === "string" ? result : String(result ?? "");
53
53
  if (stringCache.size >= STRING_CACHE_MAX) {
54
- // Naive LRU: clear oldest half when the cache fills up. Tool icons are
55
- // a small fixed set per assessment so this is rarely hit.
54
+ // Evict the oldest-inserted half when full; a hit does not refresh an entry.
55
+ // Tool icons are a small fixed set per assessment, so this rarely runs.
56
56
  const keys = [...stringCache.keys()];
57
57
  for (let i = 0; i < keys.length / 2; i += 1) {
58
58
  stringCache.delete(keys[i]);
@@ -24,7 +24,7 @@
24
24
  * they never appeared in the authored markup string.
25
25
  *
26
26
  * The wrapping itself lives in `./wrap-overwide.js`, shared with the table
27
- * wrapper: only the four values below and the accessible name differ.
27
+ * wrapper: only the spec below differs.
28
28
  */
29
29
  import { type WrapOverwideOptions } from "./wrap-overwide.js";
30
30
  export type WrapOverwideImagesInElementOptions = WrapOverwideOptions;
@@ -24,7 +24,7 @@
24
24
  * they never appeared in the authored markup string.
25
25
  *
26
26
  * The wrapping itself lives in `./wrap-overwide.js`, shared with the table
27
- * wrapper: only the four values below and the accessible name differ.
27
+ * wrapper: only the spec below differs.
28
28
  */
29
29
  import { isOverwideWrapMutation, wrapOverwideInElement, wrapOverwideMarkup, } from "./wrap-overwide.js";
30
30
  function buildAriaLabel(image) {
@@ -32,12 +32,31 @@ function buildAriaLabel(image) {
32
32
  const trimmed = alt ? alt.trim() : "";
33
33
  return trimmed ? `Scrollable image: ${trimmed}` : "Scrollable image";
34
34
  }
35
+ /**
36
+ * A block-level image — an element's own `display: block`, a flex item — gets a
37
+ * block wrapper, so wrapping it adds no line box below it — a host reset of
38
+ * `img { display: block }` included. The live pass reads the computed display;
39
+ * markup parsed off-document has only the inline style, so the live pass reads
40
+ * a wrapper built there again.
41
+ */
42
+ function imageLayoutClass(image) {
43
+ const view = image.ownerDocument?.defaultView;
44
+ const display = view
45
+ ? view.getComputedStyle(image).display
46
+ : image.style?.display;
47
+ const blockLevel = !!display &&
48
+ !display.startsWith("inline") &&
49
+ display !== "none" &&
50
+ display !== "contents";
51
+ return blockLevel ? "pie-image-scroll-block" : null;
52
+ }
35
53
  const IMAGE_SPEC = {
36
54
  selector: "img",
37
55
  wrapperTag: "span",
38
56
  wrapperClass: "pie-image-scroll",
39
57
  markupProbe: /<img\b/i,
40
58
  buildAriaLabel,
59
+ layoutClass: imageLayoutClass,
41
60
  };
42
61
  /**
43
62
  * Wrap every unwrapped `<img>` descendant of `root` with the shared
@@ -32,6 +32,11 @@ export interface OverwideWrapSpec {
32
32
  markupProbe: RegExp;
33
33
  /** The wrapper's accessible name for one node. */
34
34
  buildAriaLabel: (node: Element) => string;
35
+ /**
36
+ * An extra wrapper class carrying the node's own layout, or `null` for none.
37
+ * Absent when every wrapper lays out alike.
38
+ */
39
+ layoutClass?: (node: Element) => string | null;
35
40
  }
36
41
  export interface WrapOverwideOptions {
37
42
  /**
@@ -45,6 +50,9 @@ export interface WrapOverwideOptions {
45
50
  /**
46
51
  * Wrap every unwrapped match under `root`. Returns the number newly wrapped so
47
52
  * callers can short-circuit when nothing changed. Idempotent.
53
+ *
54
+ * Under a connected `root`, a wrapper whose layout was never read live gains
55
+ * the layout class this pass would have given it.
48
56
  */
49
57
  export declare function wrapOverwideInElement(root: Element, spec: OverwideWrapSpec, options?: WrapOverwideOptions): number;
50
58
  /**
@@ -40,9 +40,18 @@ function isInsidePieCustomElement(node, root) {
40
40
  }
41
41
  return false;
42
42
  }
43
+ /**
44
+ * Wrappers whose layout class was read from the live cascade. A wrapper built
45
+ * off-document saw only its node's inline style, so the first live pass to meet
46
+ * it reads the layout again; this set keeps that to once per wrapper.
47
+ */
48
+ const liveLayoutRead = new WeakSet();
43
49
  /**
44
50
  * Wrap every unwrapped match under `root`. Returns the number newly wrapped so
45
51
  * callers can short-circuit when nothing changed. Idempotent.
52
+ *
53
+ * Under a connected `root`, a wrapper whose layout was never read live gains
54
+ * the layout class this pass would have given it.
46
55
  */
47
56
  export function wrapOverwideInElement(root, spec, options = {}) {
48
57
  const { skipPieDescendants = false } = options;
@@ -52,24 +61,59 @@ export function wrapOverwideInElement(root, spec, options = {}) {
52
61
  const ownerDocument = root.ownerDocument;
53
62
  if (!ownerDocument)
54
63
  return 0;
55
- let wrapped = 0;
64
+ const readsLiveLayout = !!spec.layoutClass && !!ownerDocument.defaultView && root.isConnected;
65
+ const targets = [];
66
+ const unread = [];
56
67
  for (const node of nodes) {
57
68
  const parent = node.parentElement;
58
69
  if (!parent)
59
70
  continue;
60
71
  // Idempotency — already wrapped.
61
- if (parent.classList?.contains(spec.wrapperClass))
72
+ const wrapped = parent.classList?.contains(spec.wrapperClass);
73
+ if (wrapped && (!readsLiveLayout || liveLayoutRead.has(parent)))
62
74
  continue;
63
75
  // Authored-markup pass: leave PIE custom-element internals alone.
64
76
  if (skipPieDescendants && isInsidePieCustomElement(node, root))
65
77
  continue;
66
78
  if (node.closest(EDITING_HOST_SELECTOR))
67
79
  continue;
80
+ if (!wrapped)
81
+ targets.push(node);
82
+ else if (!unread.some((other) => other.parentElement === parent))
83
+ unread.push(node);
84
+ }
85
+ // Layout is read for every node before the first insertion: a read after a
86
+ // wrap forces a fresh style resolution, one per node. A wrapper's own styles
87
+ // override its node's layout — the theme makes every wrapped image a block —
88
+ // so an existing wrapper is read with its classes lifted.
89
+ const unreadWrappers = unread.map((node) => node.parentElement);
90
+ const liftedClasses = unreadWrappers.map((wrapper) => wrapper.className);
91
+ for (const wrapper of unreadWrappers)
92
+ wrapper.className = "";
93
+ const layoutClasses = targets.map((node) => spec.layoutClass?.(node) ?? null);
94
+ const unreadLayoutClasses = unread.map((node) => spec.layoutClass?.(node) ?? null);
95
+ for (const [index, wrapper] of unreadWrappers.entries()) {
96
+ wrapper.className = liftedClasses[index];
97
+ const layoutClass = unreadLayoutClasses[index];
98
+ if (layoutClass)
99
+ wrapper.classList.add(layoutClass);
100
+ liveLayoutRead.add(wrapper);
101
+ }
102
+ let wrapped = 0;
103
+ for (const [index, node] of targets.entries()) {
104
+ const parent = node.parentElement;
105
+ if (!parent)
106
+ continue;
68
107
  const wrapper = ownerDocument.createElement(spec.wrapperTag);
69
- wrapper.className = spec.wrapperClass;
108
+ const layoutClass = layoutClasses[index];
109
+ wrapper.className = layoutClass
110
+ ? `${spec.wrapperClass} ${layoutClass}`
111
+ : spec.wrapperClass;
70
112
  wrapper.setAttribute("tabindex", "0");
71
113
  wrapper.setAttribute("role", "region");
72
114
  wrapper.setAttribute("aria-label", spec.buildAriaLabel(node));
115
+ if (readsLiveLayout)
116
+ liveLayoutRead.add(wrapper);
73
117
  parent.insertBefore(wrapper, node);
74
118
  wrapper.appendChild(node);
75
119
  wrapped += 1;
@@ -116,6 +116,5 @@ export function createMediaElementTimeSource(element, options = {}) {
116
116
  export function findMediaElement(root) {
117
117
  if (!root)
118
118
  return null;
119
- const found = root.querySelector("video, audio");
120
- return found ?? null;
119
+ return root.querySelector("video, audio");
121
120
  }