@longsightgroup/qti3-player 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import {
2
2
  assertQtiAttemptStateV1,
3
3
  createItemSession,
4
+ createCatalogSupportResolution,
5
+ createTextToSpeechTraversal,
4
6
  parseQtiXml,
5
7
  visibleModalFeedback,
6
8
  type QtiAssessmentItem,
@@ -13,7 +15,12 @@ import {
13
15
  type QtiInteraction,
14
16
  type QtiItemSession,
15
17
  type QtiObjectAsset,
18
+ type QtiPortableCustomDefinition,
19
+ type QtiPortableCustomStateValue,
16
20
  type QtiScoreResult,
21
+ type QtiCatalogSupportResolution,
22
+ type QtiCatalogSupportResolutionOptions,
23
+ type QtiTextToSpeechTraversal,
17
24
  type QtiValue,
18
25
  } from "@longsightgroup/qti3-core";
19
26
 
@@ -50,6 +57,15 @@ export interface QtiResponseChangeEventDetail {
50
57
  value: QtiValue;
51
58
  }
52
59
 
60
+ export interface QtiPortableCustomMountEventDetail {
61
+ responseIdentifier: string;
62
+ interaction: QtiInteraction;
63
+ definition: QtiPortableCustomDefinition;
64
+ host: HTMLElement;
65
+ value: QtiValue;
66
+ state?: QtiPortableCustomStateValue | undefined;
67
+ }
68
+
53
69
  export type QtiScoreEventDetail = QtiScoreResult;
54
70
 
55
71
  export interface QtiValidationEventDetail {
@@ -69,6 +85,7 @@ export interface QtiAssessmentItemPlayerEventDetailMap {
69
85
  "qti-ready": QtiReadyEventDetail;
70
86
  "qti-statechange": QtiStateChangeEventDetail;
71
87
  "qti-responsechange": QtiResponseChangeEventDetail;
88
+ "qti-portable-custom-mount": QtiPortableCustomMountEventDetail;
72
89
  "qti-score": QtiScoreEventDetail;
73
90
  "qti-validation": QtiValidationEventDetail;
74
91
  "qti-suspend": QtiSuspendEventDetail;
@@ -224,6 +241,18 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
224
241
  return state;
225
242
  }
226
243
 
244
+ getTextToSpeechTraversal(): QtiTextToSpeechTraversal | undefined {
245
+ if (!this.documentModel) return undefined;
246
+ return createTextToSpeechTraversal(this.documentModel);
247
+ }
248
+
249
+ getCatalogSupportResolution(
250
+ options: QtiCatalogSupportResolutionOptions = {},
251
+ ): QtiCatalogSupportResolution | undefined {
252
+ if (!this.documentModel) return undefined;
253
+ return createCatalogSupportResolution(this.documentModel, options);
254
+ }
255
+
227
256
  private emitStateChange(state = this.serialize()): void {
228
257
  if (!state) return;
229
258
  this.dispatchPlayerEvent("qti-statechange", { state });
@@ -243,6 +272,10 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
243
272
  this.applyDefaultStyles();
244
273
  const root = document.createElement("article");
245
274
  root.className = "qti3-player";
275
+ if (documentModel.item.language) {
276
+ root.lang = documentModel.item.language;
277
+ root.setAttribute("xml:lang", documentModel.item.language);
278
+ }
246
279
  root.append(playerStyleElement());
247
280
 
248
281
  if (documentModel.item.prompt && documentModel.item.body.length === 0) {
@@ -283,6 +316,7 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
283
316
  field.dataset.responseIdentifier = interaction.responseIdentifier;
284
317
 
285
318
  const heading = document.createElement("h3");
319
+ copySafeAttributes(heading, interaction.promptAttributes ?? {});
286
320
  heading.textContent = interactionLabel(interaction);
287
321
  field.append(heading);
288
322
  if (interaction.responseIdentifier) {
@@ -371,7 +405,7 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
371
405
  }
372
406
 
373
407
  if (interaction.type === "portableCustom") {
374
- field.append(renderPortableCustomResponse(interaction, update, currentValue));
408
+ field.append(this.renderPortableCustomResponse(interaction, update, currentValue));
375
409
  return field;
376
410
  }
377
411
 
@@ -421,6 +455,99 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
421
455
  return field;
422
456
  }
423
457
 
458
+ private renderPortableCustomResponse(
459
+ interaction: QtiInteraction,
460
+ update: (value: QtiValue) => void,
461
+ currentValue: QtiValue,
462
+ ): HTMLElement {
463
+ const definition =
464
+ interaction.portableCustom ?? portableCustomDefinitionFromAttributes(interaction);
465
+ const responseIdentifier =
466
+ interaction.responseIdentifier ?? definition.responseIdentifier ?? "";
467
+ const currentState = responseIdentifier
468
+ ? this.currentInteractionState(responseIdentifier)
469
+ : undefined;
470
+
471
+ const group = document.createElement("div");
472
+ group.role = "group";
473
+ group.setAttribute("aria-label", interaction.prompt ?? "Portable custom interaction");
474
+
475
+ const host = document.createElement("div");
476
+ host.className = "qti3-portable-custom-host";
477
+ host.tabIndex = 0;
478
+ host.dataset.responseIdentifier = responseIdentifier;
479
+ host.dataset.typeIdentifier = definition.customInteractionTypeIdentifier ?? "";
480
+ host.dataset.module = definition.module ?? "";
481
+ host.dataset.qtiName = interaction.qtiName;
482
+ if (definition.interactionModules?.primaryConfiguration) {
483
+ host.dataset.primaryConfiguration = definition.interactionModules.primaryConfiguration;
484
+ }
485
+ if (definition.interactionModules?.secondaryConfiguration) {
486
+ host.dataset.secondaryConfiguration = definition.interactionModules.secondaryConfiguration;
487
+ }
488
+ if (currentState !== undefined) host.dataset.state = JSON.stringify(currentState);
489
+ host.setAttribute("role", "application");
490
+ host.setAttribute("aria-label", interaction.prompt ?? "Portable custom interaction host");
491
+ host.style.border = "1px solid CanvasText";
492
+ host.style.padding = "0.5rem";
493
+ host.style.marginBlockEnd = "0.5rem";
494
+
495
+ if (definition.interactionMarkup.length > 0) {
496
+ const markup = document.createElement("div");
497
+ markup.className = "qti3-portable-custom-markup";
498
+ markup.append(...this.renderContentNodes(definition.interactionMarkup));
499
+ host.append(markup);
500
+ } else {
501
+ host.textContent = "Portable custom interaction host";
502
+ }
503
+
504
+ const fallback = document.createElement("input");
505
+ fallback.type = "hidden";
506
+ fallback.className = "qti3-portable-custom-response";
507
+ fallback.hidden = true;
508
+ fallback.tabIndex = -1;
509
+ fallback.setAttribute("aria-hidden", "true");
510
+ fallback.value = scalarString(currentValue);
511
+
512
+ const handlePortableCustomEvent = (event: Event) => {
513
+ const state = portableCustomEventState(event);
514
+ const value = portableCustomEventValue(event);
515
+ const validity = portableCustomEventValidity(event);
516
+ if (state !== undefined && responseIdentifier && this.session) {
517
+ this.session.setInteractionState(responseIdentifier, state);
518
+ host.dataset.state = JSON.stringify(state);
519
+ }
520
+ if (value !== undefined) {
521
+ fallback.value = String(value ?? "");
522
+ update(value);
523
+ }
524
+ if (validity && responseIdentifier) {
525
+ this.setPortableCustomValidity(responseIdentifier, validity.valid, validity.message);
526
+ this.emitStateChange();
527
+ }
528
+ if (value === undefined && state !== undefined && !validity) this.emitStateChange();
529
+ };
530
+
531
+ host.addEventListener("qti3-portable-custom-response", handlePortableCustomEvent);
532
+ host.addEventListener("qti3-pci-response", handlePortableCustomEvent);
533
+ host.addEventListener("qti3-portable-custom-state", handlePortableCustomEvent);
534
+ host.addEventListener("qti3-portable-custom-validity", handlePortableCustomEvent);
535
+
536
+ queueMicrotask(() => {
537
+ this.dispatchPlayerEvent("qti-portable-custom-mount", {
538
+ responseIdentifier,
539
+ interaction,
540
+ definition,
541
+ host,
542
+ value: currentValue,
543
+ state: currentState,
544
+ });
545
+ });
546
+
547
+ group.append(host, fallback);
548
+ return group;
549
+ }
550
+
424
551
  private renderEmbeddedInteraction(interaction: QtiInteraction): HTMLElement {
425
552
  if (interaction.type !== "inlineChoice" && interaction.type !== "textEntry") {
426
553
  return this.renderInteraction(interaction);
@@ -480,11 +607,13 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
480
607
  }
481
608
  if (node.qtiName === "qti-prompt") {
482
609
  const prompt = document.createElement("p");
483
- prompt.className = "qti3-item-prompt";
610
+ copySafeAttributes(prompt, node.attributes);
611
+ prompt.classList.add("qti3-item-prompt");
484
612
  prompt.append(...this.renderContentNodes(node.children));
485
613
  return [prompt];
486
614
  }
487
615
 
616
+ if (unsafeContentElements.has(node.qtiName)) return [];
488
617
  const elementName = contentElementName(node.qtiName);
489
618
  if (!elementName) return this.renderContentNodes(node.children);
490
619
  const element = createContentElement(elementName);
@@ -652,6 +781,32 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
652
781
  return this.session?.serialize().responses[identifier] ?? null;
653
782
  }
654
783
 
784
+ private currentInteractionState(identifier: string): QtiPortableCustomStateValue | undefined {
785
+ return this.session?.serialize().interactionStates?.[identifier];
786
+ }
787
+
788
+ private setPortableCustomValidity(
789
+ responseIdentifier: string,
790
+ valid: boolean,
791
+ message: string | undefined,
792
+ ): void {
793
+ if (valid) {
794
+ this.clearValidationMessage(responseIdentifier);
795
+ return;
796
+ }
797
+ const diagnostic: QtiDiagnostic = {
798
+ code: "response.portableCustom.validity",
799
+ severity: "error",
800
+ message: message?.trim() || `${responseIdentifier} is not valid.`,
801
+ path: responseIdentifier,
802
+ };
803
+ this.validationMessages = [
804
+ ...this.validationMessages.filter((entry) => entry.path !== responseIdentifier),
805
+ diagnostic,
806
+ ];
807
+ this.renderValidationMessages();
808
+ }
809
+
655
810
  private applyDefaultStyles(): void {
656
811
  this.style.color = "CanvasText";
657
812
  this.style.backgroundColor = "Canvas";
@@ -1692,6 +1847,12 @@ function renderGraphicAssociateResponse(
1692
1847
  const maximumAssociations =
1693
1848
  interaction.responseCardinality === "single" ? 1 : maximumAllowedResponses(interaction);
1694
1849
  let selectedHotspot: QtiChoice | undefined;
1850
+ let draggedHotspot: QtiChoice | undefined;
1851
+ let dragPointerId: number | undefined;
1852
+ let dragStart: { x: number; y: number } | undefined;
1853
+ let dragStarted = false;
1854
+ let suppressNextClick = false;
1855
+ let previewLine: SVGLineElement | undefined;
1695
1856
 
1696
1857
  const surface = document.createElement("div");
1697
1858
  surface.className = "qti3-graphic-associate-surface";
@@ -1785,6 +1946,52 @@ function renderGraphicAssociateResponse(
1785
1946
  renderState();
1786
1947
  commit();
1787
1948
  };
1949
+ const authoredPointFromPointer = (event: PointerEvent) => {
1950
+ const rect = surface.getBoundingClientRect();
1951
+ return {
1952
+ x: Math.max(0, Math.min(width, ((event.clientX - rect.left) / rect.width) * width)),
1953
+ y: Math.max(0, Math.min(height, ((event.clientY - rect.top) / rect.height) * height)),
1954
+ };
1955
+ };
1956
+ const removePreviewLine = () => {
1957
+ previewLine?.remove();
1958
+ previewLine = undefined;
1959
+ };
1960
+ const suppressFollowingClick = () => {
1961
+ suppressNextClick = true;
1962
+ setTimeout(() => {
1963
+ suppressNextClick = false;
1964
+ }, 0);
1965
+ };
1966
+ const updatePreviewLine = (source: QtiChoice, event: PointerEvent) => {
1967
+ const start = hotspotCenter(source, width, height);
1968
+ const end = authoredPointFromPointer(event);
1969
+ if (!previewLine) {
1970
+ previewLine = document.createElementNS("http://www.w3.org/2000/svg", "line");
1971
+ previewLine.dataset.preview = "true";
1972
+ connections.append(previewLine);
1973
+ }
1974
+ previewLine.setAttribute("x1", String(start.x));
1975
+ previewLine.setAttribute("y1", String(start.y));
1976
+ previewLine.setAttribute("x2", String(end.x));
1977
+ previewLine.setAttribute("y2", String(end.y));
1978
+ };
1979
+ const hotspotFromPointer = (event: PointerEvent) => {
1980
+ const element = document.elementFromPoint(event.clientX, event.clientY);
1981
+ const button = element?.closest<HTMLButtonElement>(".qti3-graphic-associate-hotspot");
1982
+ const identifier = button?.dataset.choiceIdentifier;
1983
+ return choices.find((choice) => choice.identifier === identifier);
1984
+ };
1985
+ const finishDrag = (event: PointerEvent, source: QtiChoice) => {
1986
+ const target = hotspotFromPointer(event);
1987
+ removePreviewLine();
1988
+ if (target) {
1989
+ addPair(source, target);
1990
+ return;
1991
+ }
1992
+ selectedHotspot = undefined;
1993
+ renderState();
1994
+ };
1788
1995
  const chooseHotspot = (choice: QtiChoice) => {
1789
1996
  if (!selectedHotspot) {
1790
1997
  selectedHotspot = choice;
@@ -1861,8 +2068,60 @@ function renderGraphicAssociateResponse(
1861
2068
  button.setAttribute("aria-pressed", "false");
1862
2069
  button.setAttribute("aria-label", hotspotAccessibleLabel(choice, index));
1863
2070
  button.style.position = "absolute";
2071
+ button.style.touchAction = "none";
1864
2072
  placeHotspotButton(button, choice, width, height);
1865
- button.addEventListener("click", () => chooseHotspot(choice));
2073
+ button.addEventListener("click", (event) => {
2074
+ if (suppressNextClick) {
2075
+ suppressNextClick = false;
2076
+ event.preventDefault();
2077
+ return;
2078
+ }
2079
+ chooseHotspot(choice);
2080
+ });
2081
+ button.addEventListener("pointerdown", (event) => {
2082
+ if (event.button !== 0) return;
2083
+ draggedHotspot = choice;
2084
+ dragPointerId = event.pointerId;
2085
+ dragStart = { x: event.clientX, y: event.clientY };
2086
+ dragStarted = false;
2087
+ button.setPointerCapture(event.pointerId);
2088
+ });
2089
+ button.addEventListener("pointermove", (event) => {
2090
+ if (dragPointerId !== event.pointerId || !draggedHotspot || !dragStart) return;
2091
+ const moved = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
2092
+ if (!dragStarted && moved < 4) return;
2093
+ if (!dragStarted) {
2094
+ dragStarted = true;
2095
+ suppressFollowingClick();
2096
+ selectedHotspot = draggedHotspot;
2097
+ renderState();
2098
+ }
2099
+ updatePreviewLine(draggedHotspot, event);
2100
+ event.preventDefault();
2101
+ });
2102
+ button.addEventListener("pointerup", (event) => {
2103
+ if (dragPointerId !== event.pointerId || !draggedHotspot) return;
2104
+ const source = draggedHotspot;
2105
+ draggedHotspot = undefined;
2106
+ dragPointerId = undefined;
2107
+ dragStart = undefined;
2108
+ button.releasePointerCapture(event.pointerId);
2109
+ if (!dragStarted) return;
2110
+ dragStarted = false;
2111
+ suppressFollowingClick();
2112
+ finishDrag(event, source);
2113
+ event.preventDefault();
2114
+ });
2115
+ button.addEventListener("pointercancel", (event) => {
2116
+ if (dragPointerId !== event.pointerId) return;
2117
+ draggedHotspot = undefined;
2118
+ dragPointerId = undefined;
2119
+ dragStart = undefined;
2120
+ dragStarted = false;
2121
+ removePreviewLine();
2122
+ selectedHotspot = undefined;
2123
+ renderState();
2124
+ });
1866
2125
  button.addEventListener("keydown", (event) => {
1867
2126
  if (event.key === "ArrowRight" || event.key === "ArrowDown") {
1868
2127
  event.preventDefault();
@@ -1888,6 +2147,14 @@ function renderGapMatchResponse(
1888
2147
  update: (value: QtiValue) => void,
1889
2148
  currentValue: QtiValue,
1890
2149
  ): HTMLElement {
2150
+ if (
2151
+ interaction.type === "graphicGapMatch" &&
2152
+ interaction.object &&
2153
+ interaction.choices.some((choice) => choice.role === "hotspot")
2154
+ ) {
2155
+ return renderGraphicGapMatchResponse(interaction, update, currentValue);
2156
+ }
2157
+
1891
2158
  const group = responseGroup();
1892
2159
  appendGraphicContext(group, interaction);
1893
2160
 
@@ -2012,6 +2279,174 @@ function renderGapMatchResponse(
2012
2279
  return group;
2013
2280
  }
2014
2281
 
2282
+ function renderGraphicGapMatchResponse(
2283
+ interaction: QtiInteraction,
2284
+ update: (value: QtiValue) => void,
2285
+ currentValue: QtiValue,
2286
+ ): HTMLElement {
2287
+ const group = responseGroup();
2288
+ const width = objectWidth(interaction);
2289
+ const height = objectHeight(interaction);
2290
+ const sources = sourceChoices(interaction);
2291
+ const gaps = targetChoices(interaction).filter((choice) => choice.role === "hotspot");
2292
+ const assignments = new Map<string, QtiChoice>();
2293
+ let selectedSource: QtiChoice | undefined;
2294
+ let draggedSource: string | undefined;
2295
+
2296
+ for (const pair of valueToStrings(currentValue)) {
2297
+ const [sourceIdentifier, gapIdentifier] = pair.split(/\s+/);
2298
+ const source = sources.find((choice) => choice.identifier === sourceIdentifier);
2299
+ if (source && gapIdentifier) assignments.set(gapIdentifier, source);
2300
+ }
2301
+
2302
+ const surface = document.createElement("div");
2303
+ surface.className = "qti3-graphic-context qti3-graphic-gap-match-surface";
2304
+ surface.role = "group";
2305
+ surface.setAttribute("aria-label", `${readableType(interaction.type)} target image`);
2306
+ surface.style.position = "relative";
2307
+ surface.style.inlineSize = `${width}px`;
2308
+ surface.style.aspectRatio = `${width} / ${height}`;
2309
+ surface.style.maxInlineSize = "100%";
2310
+ surface.style.border = "1px solid CanvasText";
2311
+ surface.style.background = "Canvas";
2312
+ surface.style.overflow = "visible";
2313
+ surface.style.setProperty(
2314
+ "--qti3-graphic-gap-label-block-size",
2315
+ `${graphicGapLabelBlockSize(sources)}rem`,
2316
+ );
2317
+
2318
+ if (interaction.object?.data && objectIsImage(interaction.object)) {
2319
+ const image = document.createElement("img");
2320
+ image.src = interaction.object.data;
2321
+ image.alt = interaction.object.text || `${readableType(interaction.type)} image`;
2322
+ image.style.position = "absolute";
2323
+ image.style.inset = "0";
2324
+ image.style.inlineSize = "100%";
2325
+ image.style.blockSize = "100%";
2326
+ image.style.objectFit = "contain";
2327
+ image.style.pointerEvents = "none";
2328
+ surface.append(image);
2329
+ }
2330
+
2331
+ const sourceRegion = tokenRegion(`${readableType(interaction.type)} choices`);
2332
+ sourceRegion.classList.add("qti3-graphic-gap-source-region");
2333
+ const choicesWidth = positivePixelValue(interaction.attributes["data-choices-container-width"]);
2334
+ if (choicesWidth !== undefined) sourceRegion.style.maxInlineSize = `${choicesWidth}px`;
2335
+
2336
+ const summary = document.createElement("p");
2337
+ summary.className = "qti3-selection-summary";
2338
+ summary.setAttribute("aria-live", "polite");
2339
+
2340
+ const commit = () => {
2341
+ update(
2342
+ [...assignments.entries()].map(
2343
+ ([gapIdentifier, source]) => `${source.identifier} ${gapIdentifier}`,
2344
+ ),
2345
+ );
2346
+ };
2347
+ const syncSources = () => {
2348
+ for (const button of sourceRegion.querySelectorAll<HTMLButtonElement>("button")) {
2349
+ button.setAttribute(
2350
+ "aria-pressed",
2351
+ button.dataset.choiceIdentifier === selectedSource?.identifier ? "true" : "false",
2352
+ );
2353
+ }
2354
+ };
2355
+ const clearSourceIfSingleUse = (source: QtiChoice, keepGapIdentifier: string) => {
2356
+ if (parseUnlimitedMaximum(source.attributes["match-max"]) !== 1) return;
2357
+ for (const [gapIdentifier, assigned] of assignments.entries()) {
2358
+ if (gapIdentifier !== keepGapIdentifier && assigned.identifier === source.identifier) {
2359
+ assignments.delete(gapIdentifier);
2360
+ }
2361
+ }
2362
+ };
2363
+ const assign = (gap: QtiChoice, sourceIdentifier: string | undefined) => {
2364
+ const source = sources.find((choice) => choice.identifier === sourceIdentifier);
2365
+ if (!source) return;
2366
+ clearSourceIfSingleUse(source, gap.identifier);
2367
+ assignments.set(gap.identifier, source);
2368
+ selectedSource = undefined;
2369
+ syncSources();
2370
+ renderTargets();
2371
+ commit();
2372
+ };
2373
+ const targetLabel = (gap: QtiChoice, index: number) =>
2374
+ gap.attributes["aria-label"] || gap.attributes["hotspot-label"] || `Target ${index + 1}`;
2375
+ const renderTargetButton = (gap: QtiChoice, index: number): HTMLButtonElement => {
2376
+ const assigned = assignments.get(gap.identifier);
2377
+ const label = targetLabel(gap, index);
2378
+ const button = document.createElement("button");
2379
+ button.type = "button";
2380
+ button.className = "qti3-hotspot-button qti3-graphic-gap-hotspot";
2381
+ button.dataset.gapIdentifier = gap.identifier;
2382
+ button.dataset.selected = assigned ? "true" : "false";
2383
+ button.setAttribute(
2384
+ "aria-label",
2385
+ assigned ? `${label}, assigned ${assigned.text}` : `${label}, empty`,
2386
+ );
2387
+ button.addEventListener("dragover", (event) => {
2388
+ event.preventDefault();
2389
+ button.classList.add("qti3-drop-target");
2390
+ });
2391
+ button.addEventListener("dragleave", () => button.classList.remove("qti3-drop-target"));
2392
+ button.addEventListener("drop", (event) => {
2393
+ event.preventDefault();
2394
+ button.classList.remove("qti3-drop-target");
2395
+ assign(gap, event.dataTransfer?.getData("text/plain") || draggedSource);
2396
+ });
2397
+ button.addEventListener("click", () => assign(gap, selectedSource?.identifier));
2398
+ button.addEventListener("keydown", (event) => {
2399
+ if (event.key !== "Delete" && event.key !== "Backspace") return;
2400
+ if (!assignments.has(gap.identifier)) return;
2401
+ event.preventDefault();
2402
+ assignments.delete(gap.identifier);
2403
+ renderTargets();
2404
+ commit();
2405
+ });
2406
+ placeHotspotButton(button, gap, width, height);
2407
+ if (assigned) {
2408
+ const assignedLabel = document.createElement("span");
2409
+ assignedLabel.className = "qti3-graphic-gap-label";
2410
+ assignedLabel.textContent = assigned.text;
2411
+ button.append(assignedLabel);
2412
+ }
2413
+ return button;
2414
+ };
2415
+ const renderTargets = () => {
2416
+ surface.querySelectorAll(".qti3-graphic-gap-hotspot").forEach((target) => target.remove());
2417
+ for (const [index, gap] of gaps.entries()) {
2418
+ surface.append(renderTargetButton(gap, index));
2419
+ }
2420
+ summary.textContent =
2421
+ assignments.size > 0
2422
+ ? `${assignments.size} ${assignments.size === 1 ? "label" : "labels"} placed.`
2423
+ : "No labels placed.";
2424
+ };
2425
+
2426
+ for (const source of sources) {
2427
+ const button = tokenButton(source);
2428
+ button.draggable = true;
2429
+ button.addEventListener("dragstart", (event) => {
2430
+ draggedSource = source.identifier;
2431
+ event.dataTransfer?.setData("text/plain", source.identifier);
2432
+ event.dataTransfer?.setDragImage(button, 8, 8);
2433
+ });
2434
+ button.addEventListener("dragend", () => {
2435
+ draggedSource = undefined;
2436
+ syncSources();
2437
+ });
2438
+ button.addEventListener("click", () => {
2439
+ selectedSource = source;
2440
+ syncSources();
2441
+ });
2442
+ sourceRegion.append(button);
2443
+ }
2444
+
2445
+ renderTargets();
2446
+ group.append(surface, sourceRegion, summary);
2447
+ return group;
2448
+ }
2449
+
2015
2450
  function renderSelect(
2016
2451
  interaction: QtiInteraction,
2017
2452
  update: (value: QtiValue) => void,
@@ -2376,10 +2811,9 @@ function renderPositionObjectResponse(
2376
2811
  const height = objectAssetHeight(stageObject, 300);
2377
2812
  const movableWidth = objectAssetWidth(movableObject, Math.max(32, Math.round(width * 0.12)));
2378
2813
  const movableHeight = objectAssetHeight(movableObject, Math.max(32, Math.round(height * 0.12)));
2379
- let point = parsePointValue(currentValue) ?? {
2380
- x: Math.round(width / 2),
2381
- y: Math.round(height / 2),
2382
- };
2814
+ const parsedPoint = parsePointValue(currentValue);
2815
+ let point = parsedPoint ?? { x: 0, y: 0 };
2816
+ let isPlaced = Boolean(parsedPoint);
2383
2817
 
2384
2818
  const stage = document.createElement("div");
2385
2819
  stage.className = "qti3-position-object-stage";
@@ -2393,8 +2827,9 @@ function renderPositionObjectResponse(
2393
2827
  stage.style.border = "1px solid CanvasText";
2394
2828
  stage.style.background = "Canvas";
2395
2829
  stage.style.color = "CanvasText";
2396
- stage.style.overflow = "hidden";
2830
+ stage.style.overflow = "visible";
2397
2831
  stage.style.touchAction = "none";
2832
+ stage.style.marginBlockEnd = `${Math.ceil(movableHeight + 12)}px`;
2398
2833
 
2399
2834
  if (stageObject?.data && objectIsImage(stageObject)) {
2400
2835
  const image = document.createElement("img");
@@ -2446,10 +2881,24 @@ function renderPositionObjectResponse(
2446
2881
  point.y = Math.max(0, Math.min(height, point.y));
2447
2882
  };
2448
2883
  const commit = () => {
2884
+ if (!isPlaced) return;
2449
2885
  update(pointToString(point));
2450
2886
  };
2451
2887
  const syncMarker = () => {
2888
+ if (!isPlaced) {
2889
+ marker.dataset.placed = "false";
2890
+ marker.style.insetInlineStart = `${Math.round(movableWidth / 2)}px`;
2891
+ marker.style.insetBlockStart = `calc(100% + ${Math.round(movableHeight / 2 + 8)}px)`;
2892
+ coordinate.value = "";
2893
+ coordinate.textContent = "Object not placed";
2894
+ stage.setAttribute(
2895
+ "aria-label",
2896
+ `${readableType(interaction.type)} placement stage, object not placed`,
2897
+ );
2898
+ return;
2899
+ }
2452
2900
  clamp();
2901
+ marker.dataset.placed = "true";
2453
2902
  marker.style.insetInlineStart = `${percent(point.x, width)}%`;
2454
2903
  marker.style.insetBlockStart = `${percent(point.y, height)}%`;
2455
2904
  coordinate.value = pointToString(point);
@@ -2465,9 +2914,16 @@ function renderPositionObjectResponse(
2465
2914
  x: Math.round(((event.clientX - rect.left) / rect.width) * width),
2466
2915
  y: Math.round(((event.clientY - rect.top) / rect.height) * height),
2467
2916
  };
2917
+ isPlaced = true;
2468
2918
  clamp();
2469
2919
  };
2920
+ const ensureKeyboardPoint = () => {
2921
+ if (isPlaced) return;
2922
+ point = { x: 0, y: 0 };
2923
+ isPlaced = true;
2924
+ };
2470
2925
  const moveBy = (dx: number, dy: number, emit = true) => {
2926
+ ensureKeyboardPoint();
2471
2927
  point.x += dx;
2472
2928
  point.y += dy;
2473
2929
  syncMarker();
@@ -2479,22 +2935,30 @@ function renderPositionObjectResponse(
2479
2935
  else if (event.key === "ArrowRight") moveBy(step, 0, false);
2480
2936
  else if (event.key === "ArrowUp") moveBy(0, -step, false);
2481
2937
  else if (event.key === "ArrowDown") moveBy(0, step, false);
2482
- else if (event.key === "Enter" || event.key === " ") commit();
2483
- else return;
2938
+ else if (event.key === "Enter" || event.key === " ") {
2939
+ ensureKeyboardPoint();
2940
+ syncMarker();
2941
+ commit();
2942
+ } else return;
2484
2943
  event.preventDefault();
2485
2944
  };
2486
2945
 
2487
2946
  let dragging = false;
2947
+ let dragMoved = false;
2488
2948
  marker.addEventListener("pointerdown", (event) => {
2489
2949
  dragging = true;
2950
+ dragMoved = false;
2490
2951
  marker.setPointerCapture(event.pointerId);
2491
2952
  marker.style.cursor = "grabbing";
2492
- pointFromPointer(event);
2493
- syncMarker();
2953
+ if (isPlaced) {
2954
+ pointFromPointer(event);
2955
+ syncMarker();
2956
+ }
2494
2957
  event.preventDefault();
2495
2958
  });
2496
2959
  marker.addEventListener("pointermove", (event) => {
2497
2960
  if (!dragging) return;
2961
+ dragMoved = true;
2498
2962
  pointFromPointer(event);
2499
2963
  syncMarker();
2500
2964
  });
@@ -2503,9 +2967,11 @@ function renderPositionObjectResponse(
2503
2967
  dragging = false;
2504
2968
  marker.releasePointerCapture(event.pointerId);
2505
2969
  marker.style.cursor = "grab";
2506
- pointFromPointer(event);
2507
- syncMarker();
2508
- commit();
2970
+ if (dragMoved || isPlaced) {
2971
+ pointFromPointer(event);
2972
+ syncMarker();
2973
+ commit();
2974
+ }
2509
2975
  });
2510
2976
  marker.addEventListener("pointercancel", () => {
2511
2977
  dragging = false;
@@ -2685,52 +3151,6 @@ function renderDrawingResponse(
2685
3151
  return group;
2686
3152
  }
2687
3153
 
2688
- function renderPortableCustomResponse(
2689
- interaction: QtiInteraction,
2690
- update: (value: QtiValue) => void,
2691
- currentValue: QtiValue,
2692
- ): HTMLElement {
2693
- const group = document.createElement("div");
2694
- group.role = "group";
2695
- group.setAttribute("aria-label", interaction.prompt ?? "Portable custom interaction");
2696
-
2697
- const host = document.createElement("div");
2698
- host.className = "qti3-portable-custom-host";
2699
- host.tabIndex = 0;
2700
- host.dataset.responseIdentifier = interaction.responseIdentifier ?? "";
2701
- host.dataset.typeIdentifier = interaction.attributes["custom-interaction-type-identifier"] ?? "";
2702
- host.dataset.module = interaction.attributes.module ?? "";
2703
- host.dataset.qtiName = interaction.qtiName;
2704
- host.setAttribute("role", "application");
2705
- host.setAttribute("aria-label", interaction.prompt ?? "Portable custom interaction host");
2706
- host.textContent = "Portable custom interaction host";
2707
- host.style.border = "1px solid CanvasText";
2708
- host.style.padding = "0.5rem";
2709
- host.style.marginBlockEnd = "0.5rem";
2710
-
2711
- const fallback = document.createElement("input");
2712
- fallback.value = scalarString(currentValue);
2713
- fallback.setAttribute("aria-label", `${interaction.prompt ?? "Portable custom"} response`);
2714
- fallback.addEventListener("input", () => update(fallback.value));
2715
- fallback.addEventListener("change", () => update(fallback.value));
2716
-
2717
- host.addEventListener("qti3-portable-custom-response", (event) => {
2718
- const value = portableCustomEventValue(event);
2719
- if (value === undefined) return;
2720
- fallback.value = String(value ?? "");
2721
- update(value);
2722
- });
2723
- host.addEventListener("qti3-pci-response", (event) => {
2724
- const value = portableCustomEventValue(event);
2725
- if (value === undefined) return;
2726
- fallback.value = String(value ?? "");
2727
- update(value);
2728
- });
2729
-
2730
- group.append(host, fallback);
2731
- return group;
2732
- }
2733
-
2734
3154
  function renderHotspotResponse(
2735
3155
  interaction: QtiInteraction,
2736
3156
  update: (value: QtiValue) => void,
@@ -2884,6 +3304,7 @@ function configureMediaElement(
2884
3304
  const sourceElement = document.createElement("source");
2885
3305
  sourceElement.src = source.src;
2886
3306
  if (source.type) sourceElement.type = source.type;
3307
+ copySafeMediaChildAttributes(sourceElement, source.attributes, sourceAttributeNames);
2887
3308
  media.append(sourceElement);
2888
3309
  }
2889
3310
  for (const track of object.tracks) {
@@ -2894,6 +3315,7 @@ function configureMediaElement(
2894
3315
  if (track.srclang) trackElement.srclang = track.srclang;
2895
3316
  if (track.label) trackElement.label = track.label;
2896
3317
  if (track.default) trackElement.default = true;
3318
+ copySafeMediaChildAttributes(trackElement, track.attributes, trackAttributeNames);
2897
3319
  media.append(trackElement);
2898
3320
  }
2899
3321
 
@@ -2907,6 +3329,30 @@ function copyMediaDataAttributes(element: HTMLElement, attributes: Record<string
2907
3329
  }
2908
3330
  }
2909
3331
 
3332
+ const sourceAttributeNames = new Set(["src", "srcset", "type"]);
3333
+ const trackAttributeNames = new Set(["default", "kind", "label", "src", "srclang"]);
3334
+
3335
+ function copySafeMediaChildAttributes(
3336
+ element: HTMLElement,
3337
+ attributes: Record<string, string>,
3338
+ controlledNames: Set<string>,
3339
+ ): void {
3340
+ for (const [name, value] of Object.entries(attributes)) {
3341
+ const normalizedName = name.toLowerCase();
3342
+ if (controlledNames.has(normalizedName)) continue;
3343
+ if (
3344
+ normalizedName === "class" ||
3345
+ normalizedName === "id" ||
3346
+ normalizedName === "title" ||
3347
+ normalizedName === "media" ||
3348
+ normalizedName === "sizes" ||
3349
+ normalizedName.startsWith("data-")
3350
+ ) {
3351
+ element.setAttribute(name, value);
3352
+ }
3353
+ }
3354
+ }
3355
+
2910
3356
  function mediaElementType(object: QtiObjectAsset): "audio" | "video" | undefined {
2911
3357
  const types = [object.type, ...object.sources.map((source) => source.type)].filter(
2912
3358
  (value): value is string => Boolean(value),
@@ -3036,6 +3482,10 @@ function choiceText(choices: QtiChoice[], identifier: string | undefined): strin
3036
3482
 
3037
3483
  function sourceChoices(interaction: QtiInteraction): QtiChoice[] {
3038
3484
  const choices = choicesOrFallback(interaction);
3485
+ if (interaction.type === "gapMatch" || interaction.type === "graphicGapMatch") {
3486
+ const gapChoices = choices.filter((choice) => choice.role === "gapChoice");
3487
+ return gapChoices.length > 0 ? gapChoices : choices;
3488
+ }
3039
3489
  const sourceRoles = new Set(["associableChoice", "matchSource", "gapChoice", "hotspot"]);
3040
3490
  const sources = choices.filter((choice) => sourceRoles.has(choice.role));
3041
3491
  return sources.length > 0 ? sources : choices;
@@ -3218,6 +3668,20 @@ function dimension(value: string | undefined, fallback: number): number {
3218
3668
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
3219
3669
  }
3220
3670
 
3671
+ function positivePixelValue(value: string | undefined): number | undefined {
3672
+ const parsed = Number(value);
3673
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
3674
+ }
3675
+
3676
+ function graphicGapLabelBlockSize(sources: QtiChoice[]): number {
3677
+ const maxLength = Math.max(
3678
+ 0,
3679
+ ...sources.map((source) => (source.text || source.identifier).trim().length),
3680
+ );
3681
+ const estimatedLines = Math.max(1, Math.ceil(maxLength / 22));
3682
+ return Number((estimatedLines * 0.95 + 0.9).toFixed(2));
3683
+ }
3684
+
3221
3685
  function placeHotspotButton(
3222
3686
  button: HTMLButtonElement,
3223
3687
  choice: QtiChoice,
@@ -3545,6 +4009,25 @@ function polylineElement(points: DrawingPoint[]): SVGPolylineElement {
3545
4009
  return line;
3546
4010
  }
3547
4011
 
4012
+ function portableCustomDefinitionFromAttributes(
4013
+ interaction: QtiInteraction,
4014
+ ): QtiPortableCustomDefinition {
4015
+ return {
4016
+ responseIdentifier: interaction.responseIdentifier,
4017
+ customInteractionTypeIdentifier: interaction.attributes["custom-interaction-type-identifier"],
4018
+ module: interaction.attributes.module,
4019
+ interactionMarkup: [],
4020
+ templateVariables: [],
4021
+ contextVariables: [],
4022
+ stylesheets: [],
4023
+ dataAttributes: Object.fromEntries(
4024
+ Object.entries(interaction.attributes).filter(([name]) => name.startsWith("data-")),
4025
+ ),
4026
+ attributes: interaction.attributes,
4027
+ source: interaction.source,
4028
+ };
4029
+ }
4030
+
3548
4031
  function portableCustomEventValue(event: Event): QtiValue | undefined {
3549
4032
  if (!("detail" in event)) return undefined;
3550
4033
  const detail = event.detail as { value?: QtiValue; response?: QtiValue } | QtiValue | undefined;
@@ -3552,14 +4035,49 @@ function portableCustomEventValue(event: Event): QtiValue | undefined {
3552
4035
  if (typeof detail === "object" && detail !== null && !Array.isArray(detail)) {
3553
4036
  if ("value" in detail) return detail.value ?? null;
3554
4037
  if ("response" in detail) return detail.response ?? null;
4038
+ if ("state" in detail || "valid" in detail) return undefined;
3555
4039
  }
3556
4040
  return detail as QtiValue;
3557
4041
  }
3558
4042
 
4043
+ function portableCustomEventState(event: Event): QtiPortableCustomStateValue | undefined {
4044
+ if (!("detail" in event)) return undefined;
4045
+ const detail = event.detail as { state?: unknown } | undefined;
4046
+ if (typeof detail !== "object" || detail === null || !("state" in detail)) return undefined;
4047
+ return isPortableCustomStateValue(detail.state) ? detail.state : undefined;
4048
+ }
4049
+
4050
+ function portableCustomEventValidity(
4051
+ event: Event,
4052
+ ): { valid: boolean; message?: string | undefined } | undefined {
4053
+ if (!("detail" in event)) return undefined;
4054
+ const detail = event.detail as { valid?: unknown; message?: unknown } | undefined;
4055
+ if (typeof detail !== "object" || detail === null || typeof detail.valid !== "boolean") {
4056
+ return undefined;
4057
+ }
4058
+ return {
4059
+ valid: detail.valid,
4060
+ message: typeof detail.message === "string" ? detail.message : undefined,
4061
+ };
4062
+ }
4063
+
4064
+ function isPortableCustomStateValue(value: unknown): value is QtiPortableCustomStateValue {
4065
+ if (value === null) return true;
4066
+ if (typeof value === "string" || typeof value === "boolean") return true;
4067
+ if (typeof value === "number") return Number.isFinite(value);
4068
+ if (Array.isArray(value)) return value.every(isPortableCustomStateValue);
4069
+ if (typeof value === "object") {
4070
+ return Object.values(value as Record<string, unknown>).every(isPortableCustomStateValue);
4071
+ }
4072
+ return false;
4073
+ }
4074
+
3559
4075
  const htmlContentElements = new Set([
3560
4076
  "a",
3561
4077
  "abbr",
3562
4078
  "b",
4079
+ "bdi",
4080
+ "bdo",
3563
4081
  "blockquote",
3564
4082
  "br",
3565
4083
  "caption",
@@ -3573,6 +4091,12 @@ const htmlContentElements = new Set([
3573
4091
  "em",
3574
4092
  "figcaption",
3575
4093
  "figure",
4094
+ "h1",
4095
+ "h2",
4096
+ "h3",
4097
+ "h4",
4098
+ "h5",
4099
+ "h6",
3576
4100
  "hr",
3577
4101
  "i",
3578
4102
  "img",
@@ -3582,6 +4106,12 @@ const htmlContentElements = new Set([
3582
4106
  "p",
3583
4107
  "pre",
3584
4108
  "q",
4109
+ "rb",
4110
+ "rbc",
4111
+ "rp",
4112
+ "rt",
4113
+ "rtc",
4114
+ "ruby",
3585
4115
  "samp",
3586
4116
  "small",
3587
4117
  "span",
@@ -3599,6 +4129,8 @@ const htmlContentElements = new Set([
3599
4129
  "var",
3600
4130
  ]);
3601
4131
 
4132
+ const unsafeContentElements = new Set(["script", "style"]);
4133
+
3602
4134
  const mathMlElements = new Set([
3603
4135
  "math",
3604
4136
  "maction",
@@ -3663,32 +4195,89 @@ function copySafeAttributes(element: Element, attributes: Record<string, string>
3663
4195
  for (const [name, value] of Object.entries(attributes)) {
3664
4196
  if (!isSafeContentAttribute(name, value)) continue;
3665
4197
  element.setAttribute(name, value);
4198
+ if (name === "xml:lang" && !Object.hasOwn(attributes, "lang")) {
4199
+ element.setAttribute("lang", value);
4200
+ }
3666
4201
  }
4202
+ applySharedAccessibilityVocabulary(element, attributes);
4203
+ }
4204
+
4205
+ function applySharedAccessibilityVocabulary(
4206
+ element: Element,
4207
+ attributes: Record<string, string>,
4208
+ ): void {
4209
+ for (const [name, value] of Object.entries(attributes)) {
4210
+ const ariaName = qtiAriaAttributeName(name);
4211
+ if (!ariaName || hasAttributeName(attributes, ariaName)) continue;
4212
+ element.setAttribute(ariaName, value);
4213
+ }
4214
+
4215
+ const suppressTts = attributeValue(attributes, "data-qti-suppress-tts");
4216
+ if (
4217
+ suppressesScreenReaderSpeech(suppressTts) &&
4218
+ !hasAttributeName(attributes, "aria-hidden") &&
4219
+ !hasAttributeName(attributes, "data-qti-aria-hidden")
4220
+ ) {
4221
+ element.setAttribute("aria-hidden", "true");
4222
+ }
4223
+ }
4224
+
4225
+ function qtiAriaAttributeName(name: string): string | undefined {
4226
+ const normalizedName = name.toLowerCase();
4227
+ const prefix = "data-qti-aria-";
4228
+ if (!normalizedName.startsWith(prefix)) return undefined;
4229
+ const suffix = normalizedName.slice(prefix.length);
4230
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(suffix)) return undefined;
4231
+ return `aria-${suffix}`;
4232
+ }
4233
+
4234
+ function attributeValue(attributes: Record<string, string>, name: string): string | undefined {
4235
+ const normalizedName = name.toLowerCase();
4236
+ const entry = Object.entries(attributes).find(
4237
+ ([attributeName]) => attributeName.toLowerCase() === normalizedName,
4238
+ );
4239
+ return entry?.[1];
4240
+ }
4241
+
4242
+ function hasAttributeName(attributes: Record<string, string>, name: string): boolean {
4243
+ return attributeValue(attributes, name) !== undefined;
4244
+ }
4245
+
4246
+ function suppressesScreenReaderSpeech(value: string | undefined): boolean {
4247
+ if (!value) return false;
4248
+ const tokens = value
4249
+ .toLowerCase()
4250
+ .split(/[\s,]+/)
4251
+ .filter(Boolean);
4252
+ return tokens.includes("all") || tokens.includes("screen-reader");
3667
4253
  }
3668
4254
 
3669
4255
  function isSafeContentAttribute(name: string, value: string): boolean {
3670
- if (name.startsWith("on")) return false;
3671
- if (name === "style") return false;
3672
- if (name === "href" || name === "src" || name === "data") {
4256
+ const normalizedName = name.toLowerCase();
4257
+ if (normalizedName.startsWith("on")) return false;
4258
+ if (normalizedName === "style") return false;
4259
+ if (normalizedName === "href" || normalizedName === "src" || normalizedName === "data") {
3673
4260
  return isSafeUrl(value);
3674
4261
  }
3675
4262
  return (
3676
- name === "alt" ||
3677
- name === "aria-label" ||
3678
- name === "aria-describedby" ||
3679
- name === "class" ||
3680
- name === "colspan" ||
3681
- name === "height" ||
3682
- name === "id" ||
3683
- name === "lang" ||
3684
- name === "role" ||
3685
- name === "rowspan" ||
3686
- name === "scope" ||
3687
- name === "title" ||
3688
- name === "type" ||
3689
- name === "width" ||
3690
- mathMlAttributeNames.has(name) ||
3691
- name.startsWith("data-")
4263
+ normalizedName === "alt" ||
4264
+ normalizedName === "class" ||
4265
+ normalizedName === "colspan" ||
4266
+ normalizedName === "dir" ||
4267
+ normalizedName === "headers" ||
4268
+ normalizedName === "height" ||
4269
+ normalizedName === "id" ||
4270
+ normalizedName === "lang" ||
4271
+ normalizedName === "role" ||
4272
+ normalizedName === "rowspan" ||
4273
+ normalizedName === "scope" ||
4274
+ normalizedName === "title" ||
4275
+ normalizedName === "type" ||
4276
+ normalizedName === "width" ||
4277
+ normalizedName === "xml:lang" ||
4278
+ mathMlAttributeNames.has(normalizedName) ||
4279
+ normalizedName.startsWith("aria-") ||
4280
+ normalizedName.startsWith("data-")
3692
4281
  );
3693
4282
  }
3694
4283
 
@@ -3834,6 +4423,23 @@ function playerStyleElement(): HTMLStyleElement {
3834
4423
  margin-block: 0;
3835
4424
  }
3836
4425
 
4426
+ .qti3-player .qti-hidden {
4427
+ display: none !important;
4428
+ }
4429
+
4430
+ .qti3-player .qti-visually-hidden {
4431
+ position: absolute !important;
4432
+ overflow: hidden !important;
4433
+ clip: rect(1px, 1px, 1px, 1px) !important;
4434
+ clip-path: inset(50%) !important;
4435
+ inline-size: 1px !important;
4436
+ block-size: 1px !important;
4437
+ margin: -1px !important;
4438
+ padding: 0 !important;
4439
+ border: 0 !important;
4440
+ white-space: nowrap !important;
4441
+ }
4442
+
3837
4443
  .qti3-embedded-interaction {
3838
4444
  display: inline-flex;
3839
4445
  gap: 0.35rem;
@@ -4206,6 +4812,7 @@ function playerStyleElement(): HTMLStyleElement {
4206
4812
  }
4207
4813
 
4208
4814
  .qti3-graphic-associate-surface,
4815
+ .qti3-graphic-gap-match-surface,
4209
4816
  .qti3-graphic-order-surface {
4210
4817
  touch-action: manipulation;
4211
4818
  }
@@ -4233,10 +4840,60 @@ function playerStyleElement(): HTMLStyleElement {
4233
4840
  }
4234
4841
 
4235
4842
  .qti3-graphic-associate-hotspot,
4843
+ .qti3-graphic-gap-hotspot,
4236
4844
  .qti3-graphic-order-hotspot {
4237
4845
  z-index: 2;
4238
4846
  }
4239
4847
 
4848
+ .qti3-graphic-gap-match-surface {
4849
+ margin-block-end: calc(var(--qti3-graphic-gap-label-block-size, 2rem) + 0.75rem);
4850
+ }
4851
+
4852
+ .qti3-graphic-gap-hotspot {
4853
+ display: grid;
4854
+ place-items: center;
4855
+ padding: 0;
4856
+ overflow: visible;
4857
+ border-style: dashed;
4858
+ background: rgb(255 255 255 / 0.08);
4859
+ color: CanvasText;
4860
+ }
4861
+
4862
+ .qti3-graphic-gap-hotspot[data-selected="true"] {
4863
+ border-style: solid;
4864
+ background: color-mix(in srgb, Highlight 18%, Canvas);
4865
+ }
4866
+
4867
+ .qti3-graphic-gap-label {
4868
+ position: absolute;
4869
+ inset-block-start: calc(100% + 0.2rem);
4870
+ inset-inline-start: 50%;
4871
+ transform: translateX(-50%);
4872
+ box-sizing: border-box;
4873
+ inline-size: max-content;
4874
+ max-inline-size: min(12rem, calc(100vw - 2rem));
4875
+ min-inline-size: 0;
4876
+ padding: 0.25rem 0.4rem;
4877
+ border: 1px solid CanvasText;
4878
+ border-radius: 0.25rem;
4879
+ background: Canvas;
4880
+ color: CanvasText;
4881
+ font-size: 0.75rem;
4882
+ font-weight: 700;
4883
+ line-height: 1.15;
4884
+ overflow-wrap: anywhere;
4885
+ pointer-events: none;
4886
+ box-shadow: 0 1px 2px rgb(0 0 0 / 0.16);
4887
+ text-align: center;
4888
+ white-space: normal;
4889
+ }
4890
+
4891
+ @supports not (background: color-mix(in srgb, Highlight 18%, Canvas)) {
4892
+ .qti3-graphic-gap-hotspot[data-selected="true"] {
4893
+ background: Canvas;
4894
+ }
4895
+ }
4896
+
4240
4897
  .qti3-graphic-order-hotspot {
4241
4898
  display: grid;
4242
4899
  place-items: center;