@pie-element/mc-populated-blank 0.2.10 → 0.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +0 -2
  2. package/dist/author/index.js +186 -152
  3. package/dist/author/index.js.map +1 -1
  4. package/dist/controller/defaults.d.ts +41 -73
  5. package/dist/controller/defaults.d.ts.map +1 -1
  6. package/dist/controller/index.d.ts +154 -10
  7. package/dist/controller/index.d.ts.map +1 -1
  8. package/dist/controller/index.js +29 -30
  9. package/dist/controller/index.js.map +1 -1
  10. package/dist/controller/index.test.d.ts +63 -0
  11. package/dist/controller/index.test.d.ts.map +1 -0
  12. package/dist/delivery/AudioPlayer.test.d.ts +2 -0
  13. package/dist/delivery/AudioPlayer.test.d.ts.map +1 -0
  14. package/dist/delivery/ChoiceRow.test.d.ts +2 -0
  15. package/dist/delivery/ChoiceRow.test.d.ts.map +1 -0
  16. package/dist/delivery/ClozeMarker.test.d.ts +2 -0
  17. package/dist/delivery/ClozeMarker.test.d.ts.map +1 -0
  18. package/dist/delivery/McPopulatedBlank.transcript.test.d.ts +2 -0
  19. package/dist/delivery/McPopulatedBlank.transcript.test.d.ts.map +1 -0
  20. package/dist/delivery/computeAudioMode.d.ts +7 -0
  21. package/dist/delivery/computeAudioMode.d.ts.map +1 -0
  22. package/dist/delivery/computeAudioMode.test.d.ts +2 -0
  23. package/dist/delivery/computeAudioMode.test.d.ts.map +1 -0
  24. package/dist/delivery/computeChoiceCorrectness.d.ts +18 -0
  25. package/dist/delivery/computeChoiceCorrectness.d.ts.map +1 -0
  26. package/dist/delivery/computeChoiceCorrectness.test.d.ts +19 -0
  27. package/dist/delivery/computeChoiceCorrectness.test.d.ts.map +1 -0
  28. package/dist/delivery/computeDisplayState.d.ts +47 -0
  29. package/dist/delivery/computeDisplayState.d.ts.map +1 -0
  30. package/dist/delivery/computeDisplayState.test.d.ts +38 -0
  31. package/dist/delivery/computeDisplayState.test.d.ts.map +1 -0
  32. package/dist/delivery/computeLayoutProfile.d.ts +18 -0
  33. package/dist/delivery/computeLayoutProfile.d.ts.map +1 -0
  34. package/dist/delivery/computeLayoutProfile.test.d.ts +2 -0
  35. package/dist/delivery/computeLayoutProfile.test.d.ts.map +1 -0
  36. package/dist/delivery/computeLayoutStyle.d.ts +27 -0
  37. package/dist/delivery/computeLayoutStyle.d.ts.map +1 -0
  38. package/dist/delivery/computeLayoutStyle.test.d.ts +2 -0
  39. package/dist/delivery/computeLayoutStyle.test.d.ts.map +1 -0
  40. package/dist/delivery/index.d.ts.map +1 -1
  41. package/dist/delivery/index.js +1591 -927
  42. package/dist/delivery/index.js.map +1 -1
  43. package/dist/delivery/variant-css-map.d.ts.map +1 -1
  44. package/dist/index.iife.d.ts +151 -10
  45. package/dist/index.iife.d.ts.map +1 -1
  46. package/dist/index.iife.js +233 -114
  47. package/dist/index.js +1591 -927
  48. package/dist/index.js.map +1 -1
  49. package/dist/print/index.js +194 -166
  50. package/dist/print/index.js.map +1 -1
  51. package/dist/shared/layoutLimits.d.ts +40 -0
  52. package/dist/shared/layoutLimits.d.ts.map +1 -0
  53. package/dist/shared/types.d.ts +82 -0
  54. package/dist/shared/types.d.ts.map +1 -0
  55. package/package.json +4 -2
  56. package/src/controller/defaults.ts +9 -39
  57. package/src/controller/index.test.ts +428 -0
  58. package/src/controller/index.ts +63 -50
  59. package/src/delivery/AudioPlayer.svelte +246 -0
  60. package/src/delivery/AudioPlayer.test.ts +149 -0
  61. package/src/delivery/ChoiceRow.svelte +222 -0
  62. package/src/delivery/ChoiceRow.test.ts +179 -0
  63. package/src/delivery/ClozeMarker.svelte +83 -0
  64. package/src/delivery/ClozeMarker.test.ts +128 -0
  65. package/src/delivery/McPopulatedBlank.svelte +225 -732
  66. package/src/delivery/McPopulatedBlank.transcript.test.ts +154 -0
  67. package/src/delivery/computeAudioMode.test.ts +40 -0
  68. package/src/delivery/computeAudioMode.ts +11 -0
  69. package/src/delivery/computeChoiceCorrectness.test.ts +120 -0
  70. package/src/delivery/computeChoiceCorrectness.ts +42 -0
  71. package/src/delivery/computeDisplayState.test.ts +274 -0
  72. package/src/delivery/computeDisplayState.ts +101 -0
  73. package/src/delivery/computeLayoutProfile.test.ts +163 -0
  74. package/src/delivery/computeLayoutProfile.ts +72 -0
  75. package/src/delivery/computeLayoutStyle.test.ts +168 -0
  76. package/src/delivery/computeLayoutStyle.ts +128 -0
  77. package/src/delivery/cqt-css/sel-r1-base.css +59 -0
  78. package/src/delivery/cqt-css/sel-r1-g-stem.css +5 -20
  79. package/src/delivery/cqt-css/sel-r1-gg-plus.css +3 -20
  80. package/src/delivery/cqt-css/sel-r1-ggplus.css +4 -21
  81. package/src/delivery/cqt-css/sel-r1-gplusggg.css +16 -11
  82. package/src/delivery/cqt-css/sel-r1-plusggg.css +8 -11
  83. package/src/delivery/cqt-css/sel-r1-s3.css +3 -9
  84. package/src/delivery/cqt-css/sel-vic.css +61 -0
  85. package/src/delivery/cqt-css/sr-vic.css +49 -0
  86. package/src/delivery/index.ts +14 -23
  87. package/src/delivery/variant-css-map.ts +26 -12
  88. package/src/shared/layoutLimits.ts +76 -0
  89. package/src/shared/types.ts +84 -0
@@ -1,6 +1,5 @@
1
1
  <svelte:options
2
2
  customElement={{
3
- tag: 'mc-populated-blank',
4
3
  shadow: 'none',
5
4
  props: {
6
5
  model: { type: 'Object' },
@@ -12,78 +11,26 @@
12
11
 
13
12
  <script lang="ts">
14
13
  import { color } from '@pie-lib/styling-svelte';
14
+ import { forwardSessionChange, resolveDeliveryHost } from '@pie-lib/delivery-events-svelte';
15
+ import AudioPlayer from './AudioPlayer.svelte';
16
+ import ClozeMarker from './ClozeMarker.svelte';
17
+ import ChoiceRow from './ChoiceRow.svelte';
18
+ import { computeChoiceCorrectness } from './computeChoiceCorrectness';
19
+ import { computeLayoutProfile } from './computeLayoutProfile';
20
+ import { computeLayoutStyle, DEFAULT_LAYOUT_LIMITS } from './computeLayoutStyle';
21
+ import {
22
+ computeFeatureAudioSkin,
23
+ computeDisplayChoiceId,
24
+ computeResultText,
25
+ computeLegendText,
26
+ } from './computeDisplayState';
15
27
  import {
16
28
  ensureVariantCssInjected,
17
29
  getVariantCssConfig,
18
30
  getVariantRootClass,
19
31
  } from './variant-css-map';
20
- /** Must match controller `BLANK_TOKEN` (kept local to avoid pulling controller into delivery). */
32
+
21
33
  const BLANK_TOKEN = '{{blank}}';
22
- const DEFAULT_LAYOUT_LIMITS = {
23
- blankStandaloneWidthRem: 7,
24
- blankWideWidthRem: 10,
25
- blankUnderlineWidthPx: 2,
26
- blankUnderlineWideWidthPx: 4,
27
- horizontalChoiceWidthPx: 170,
28
- horizontalChoiceWidthVw: 30,
29
- horizontalChoiceTileMinHeightRem: 11,
30
- horizontalChoiceContentMinHeightRem: 7.5,
31
- selectedImageMaxHeightRem: 4,
32
- choiceImageMaxHeightRem: 5,
33
- listenButtonSizePx: 128,
34
- stimulusMinColumnPx: 210,
35
- textMinColumnPx: 260,
36
- legendMaxChars: 120,
37
- choiceGroupGapRem: 0.5,
38
- choiceRowGapRem: 0.5,
39
- toggleButtonGapRem: 0.5,
40
- horizontalChoiceRadioTopMarginRem: 0.5,
41
- audioBlankTemplateMarginTopRem: 0.8,
42
- audioBlankTemplateMarginBottomRem: 1.8,
43
- audioInstructionsMaxWidthPx: 875,
44
- narrowHorizontalChoiceMaxWidthPx: 230,
45
- stimulusGridColumnGapRem: 2,
46
- stimulusGridRowGapRem: 0.7,
47
- stimulusSentenceMarginTopRem: 0.2,
48
- stimulusChoicesMarginTopRem: 0.9,
49
- tokenGridColumnGapRem: 1.5,
50
- tokenGridRowGapRem: 0.8,
51
- tokenTemplateMarginTopRem: 0.6,
52
- tokenInlineTokenGapRem: 0.35,
53
- tokenChoicesMarginTopRem: 0.2,
54
- inlineGridColumnGapRem: 1.5,
55
- inlineGridRowGapRem: 0.65,
56
- inlineTemplateMarginTopRem: 0.45,
57
- inlineChoicesMarginTopRem: 0.25,
58
- } as const;
59
- const DEFAULT_LAYOUT_PROFILE_PRESETS: Record<string, Record<string, number>> = {
60
- audio_blank_only: {
61
- blankWideWidthRem: 10,
62
- blankUnderlineWideWidthPx: 4,
63
- },
64
- stimulus_image_blank: {
65
- blankWideWidthRem: 10,
66
- blankUnderlineWideWidthPx: 4,
67
- },
68
- token_sequence: {
69
- blankStandaloneWidthRem: 7,
70
- blankUnderlineWideWidthPx: 4,
71
- },
72
- };
73
- const DEFAULT_AUDIO_BUTTON_SKINS = {
74
- default: {
75
- silentUrl:
76
- 'https://assets.learnosity.com/organisations/844/0c9f2aa3-3cd5-4de7-93ef-541c24ca35da.svg',
77
- playingUrl:
78
- 'https://assets.learnosity.com/organisations/844/231dfdc2-c113-4be5-91fb-e75a0ca5994b.svg',
79
- },
80
- es: {
81
- silentUrl:
82
- 'https://assets.learnosity.com/organisations/844/27a9d5b5-d873-4bd5-b9ba-22748782d8ba.svg',
83
- playingUrl:
84
- 'https://assets.learnosity.com/organisations/844/120f216d-96b7-4560-94b8-1d90710216b7.svg',
85
- },
86
- } as const;
87
34
  const DEFAULT_UI_TEXT = {
88
35
  answerChoices: 'Answer choices',
89
36
  selectedAnswerInSentence: 'Selected answer in sentence',
@@ -92,22 +39,20 @@ const DEFAULT_UI_TEXT = {
92
39
  clickToEnableAutoplay: 'Click to enable audio autoplay',
93
40
  audioResourceUnavailable: 'Audio is enabled but no playable audio URL is configured.',
94
41
  transcriptLabel: 'Transcript',
95
- listenLabelEn: 'Listen',
96
- listenLabelEs: 'Escuchar',
97
42
  } as const;
98
43
 
99
44
  let { model, session } = $props<{ model?: any; session?: any; options?: any }>();
100
- let audioEl = $state<HTMLAudioElement | null>(null);
101
- let isMediaPlaying = $state(false);
102
45
  let localChoiceId = $state('');
103
- let autoPlayPromptOpen = $state(false);
104
- let autoplayAttempted = $state(false);
105
- let featureAudioButtonEl = $state<HTMLButtonElement | null>(null);
106
- let autoplayEnableButtonEl = $state<HTMLButtonElement | null>(null);
107
46
  let toggleCorrectAnswerButtonEl = $state<HTMLButtonElement | null>(null);
108
47
  let choicesGroupEl = $state<HTMLDivElement | null>(null);
48
+ let rootEl = $state<HTMLDivElement | null>(null);
49
+ let ancestorHasTranscriptClass = $state(false);
109
50
  const instanceId = `mc-populated-blank-${Math.random().toString(36).slice(2, 10)}`;
110
51
 
52
+ // ---------------------------------------------------------------------------
53
+ // Cluster: modeFlags — delivery mode, correctness, and answer-reveal state
54
+ // Feeds: shouldShowCorrectAnswerToggle, choiceState, a11y
55
+ // ---------------------------------------------------------------------------
111
56
  const deliveryMode = $derived((model?.mode || model?.env?.mode || '').toString());
112
57
  const isEvaluateMode = $derived(deliveryMode === 'evaluate');
113
58
  const correctness = $derived(model?.correctness);
@@ -116,58 +61,33 @@ const isResponseCorrect = $derived(
116
61
  );
117
62
  const isCorrect = $derived(correctness === 'correct');
118
63
  const isIncorrect = $derived(correctness === 'incorrect');
119
- const isAudioOnlyMode = $derived(model?.interactionMode === 'audio_mc_only');
120
- const isBlankOnlyTemplate = $derived.by(() => {
121
- const t = model?.template || '';
122
- if (!t) return false;
123
- const plain = t
124
- .replace(/<[^>]+>/g, '')
125
- .replace(/&nbsp;/g, ' ')
126
- .trim();
127
- return plain === BLANK_TOKEN;
128
- });
129
- const layoutProfile = $derived(model?.layoutProfile || '');
130
- const variantCssConfig = $derived(getVariantCssConfig(model?.customType));
131
- const variantRootClass = $derived(getVariantRootClass(model?.customType));
132
- const choiceLayout = $derived(
133
- model?.choiceLayout || (isAudioOnlyMode || isBlankOnlyTemplate ? 'horizontal' : 'vertical')
64
+
65
+ let showCorrectAnswer = $state(false);
66
+ const shouldShowCorrectAnswerToggle = $derived(
67
+ isEvaluateMode && !isResponseCorrect && !!model?.correctChoiceId
134
68
  );
135
- const isHorizontalChoices = $derived(choiceLayout === 'horizontal');
136
- const showVisibleTranscript = $derived(!!model?.showVisibleTranscript);
137
- const hasPlayableAudio = $derived(!!model?.hasAudio && !!model?.audioUrl);
138
- const hasAudioButMissingResource = $derived(!!model?.hasAudio && !model?.audioUrl);
139
- const hasInlineSentenceAudioLayout = $derived(
140
- layoutProfile === 'inline_sentence' && !!model?.hasAudio
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Cluster: layoutConfig — profile limits, blank sizing, choice layout, audio mode
72
+ // Feeds: layout (rootStyle, blankWidth, blankBorderWidth, legendMaxChars), audioMode, choiceState
73
+ // ---------------------------------------------------------------------------
74
+ const layoutProfile = $derived(model?.layoutProfile || '');
75
+ const layoutProfileFlags = $derived(
76
+ computeLayoutProfile({
77
+ interactionMode: model?.interactionMode,
78
+ template: model?.template,
79
+ choiceLayout: model?.choiceLayout,
80
+ layoutProfile,
81
+ hasAudio: model?.hasAudio,
82
+ useFeatureButtonAudio: model?.useFeatureButtonAudio,
83
+ })
141
84
  );
142
- const uiText = $derived.by(() => ({
143
- ...DEFAULT_UI_TEXT,
144
- ...(model?.uiText || {}),
145
- }));
146
- const profilePresetLimits = $derived.by(() => {
147
- const profile = String(layoutProfile || '');
148
- const defaults = DEFAULT_LAYOUT_PROFILE_PRESETS[profile] || {};
149
- const customPresets =
150
- model?.layoutProfilePresets && typeof model.layoutProfilePresets === 'object'
151
- ? model.layoutProfilePresets
152
- : {};
153
- const custom =
154
- customPresets[profile] && typeof customPresets[profile] === 'object'
155
- ? customPresets[profile]
156
- : {};
157
- return {
158
- ...defaults,
159
- ...custom,
160
- };
161
- });
162
- const layoutLimits = $derived.by(() => {
163
- const configured =
164
- model?.layoutLimits && typeof model.layoutLimits === 'object' ? model.layoutLimits : {};
165
- return {
166
- ...DEFAULT_LAYOUT_LIMITS,
167
- ...profilePresetLimits,
168
- ...configured,
169
- };
170
- });
85
+ const isAudioOnlyMode = $derived(layoutProfileFlags.isAudioOnlyMode);
86
+ const isBlankOnlyTemplate = $derived(layoutProfileFlags.isBlankOnlyTemplate);
87
+ const isHorizontalChoices = $derived(layoutProfileFlags.isHorizontalChoices);
88
+ const hasInlineSentenceAudioLayout = $derived(layoutProfileFlags.hasInlineSentenceAudioLayout);
89
+ const useFeatureButtonAudio = $derived(layoutProfileFlags.useFeatureButtonAudio);
90
+ const showVisibleTranscript = $derived(ancestorHasTranscriptClass);
171
91
  const correctAnswerStyleVars = $derived.by(() =>
172
92
  [
173
93
  `--pie-correct-answer-toggle-label-color:${color.text()}`,
@@ -185,124 +105,66 @@ const correctAnswerStyleVars = $derived.by(() =>
185
105
  `--pie-correct-answer-feedback-glyph-color:${color.white()}`,
186
106
  ].join(';')
187
107
  );
188
- const rootStyle = $derived.by(() =>
189
- [
190
- `--mpb-listen-button-size:${layoutLimits.listenButtonSizePx}px`,
191
- `--mpb-blank-standalone-width:${layoutLimits.blankStandaloneWidthRem}rem`,
192
- `--mpb-blank-wide-width:${layoutLimits.blankWideWidthRem}rem`,
193
- `--mpb-blank-underline-width:${layoutLimits.blankUnderlineWidthPx}px`,
194
- `--mpb-blank-underline-wide-width:${layoutLimits.blankUnderlineWideWidthPx}px`,
195
- `--mpb-selected-image-max-height:${layoutLimits.selectedImageMaxHeightRem}rem`,
196
- `--mpb-choice-image-max-height:${layoutLimits.choiceImageMaxHeightRem}rem`,
197
- `--mpb-choice-width-px:${layoutLimits.horizontalChoiceWidthPx}px`,
198
- `--mpb-choice-width-vw:${layoutLimits.horizontalChoiceWidthVw}vw`,
199
- `--mpb-choice-tile-min-height:${layoutLimits.horizontalChoiceTileMinHeightRem}rem`,
200
- `--mpb-choice-content-min-height:${layoutLimits.horizontalChoiceContentMinHeightRem}rem`,
201
- `--mpb-stimulus-min-column:${layoutLimits.stimulusMinColumnPx}px`,
202
- `--mpb-text-min-column:${layoutLimits.textMinColumnPx}px`,
203
- `--mpb-choice-group-gap:${layoutLimits.choiceGroupGapRem}rem`,
204
- `--mpb-choice-row-gap:${layoutLimits.choiceRowGapRem}rem`,
205
- `--mpb-toggle-button-gap:${layoutLimits.toggleButtonGapRem}rem`,
206
- `--mpb-horizontal-choice-radio-top-margin:${layoutLimits.horizontalChoiceRadioTopMarginRem}rem`,
207
- `--mpb-audio-blank-template-margin-top:${layoutLimits.audioBlankTemplateMarginTopRem}rem`,
208
- `--mpb-audio-blank-template-margin-bottom:${layoutLimits.audioBlankTemplateMarginBottomRem}rem`,
209
- `--mpb-audio-instructions-max-width:${layoutLimits.audioInstructionsMaxWidthPx}px`,
210
- `--mpb-narrow-choice-max-width:${layoutLimits.narrowHorizontalChoiceMaxWidthPx}px`,
211
- `--mpb-stimulus-grid-column-gap:${layoutLimits.stimulusGridColumnGapRem}rem`,
212
- `--mpb-stimulus-grid-row-gap:${layoutLimits.stimulusGridRowGapRem}rem`,
213
- `--mpb-stimulus-sentence-margin-top:${layoutLimits.stimulusSentenceMarginTopRem}rem`,
214
- `--mpb-stimulus-choices-margin-top:${layoutLimits.stimulusChoicesMarginTopRem}rem`,
215
- `--mpb-token-grid-column-gap:${layoutLimits.tokenGridColumnGapRem}rem`,
216
- `--mpb-token-grid-row-gap:${layoutLimits.tokenGridRowGapRem}rem`,
217
- `--mpb-token-template-margin-top:${layoutLimits.tokenTemplateMarginTopRem}rem`,
218
- `--mpb-token-inline-token-gap:${layoutLimits.tokenInlineTokenGapRem}rem`,
219
- `--mpb-token-choices-margin-top:${layoutLimits.tokenChoicesMarginTopRem}rem`,
220
- `--mpb-inline-grid-column-gap:${layoutLimits.inlineGridColumnGapRem}rem`,
221
- `--mpb-inline-grid-row-gap:${layoutLimits.inlineGridRowGapRem}rem`,
222
- `--mpb-inline-template-margin-top:${layoutLimits.inlineTemplateMarginTopRem}rem`,
223
- `--mpb-inline-choices-margin-top:${layoutLimits.inlineChoicesMarginTopRem}rem`,
108
+ const layout = $derived(
109
+ computeLayoutStyle({
110
+ layoutProfile,
111
+ isBlankOnlyTemplate,
112
+ configuredLimits: model?.layoutLimits,
113
+ customProfilePresets: model?.layoutProfilePresets,
224
114
  correctAnswerStyleVars,
225
- ].join(';')
115
+ })
226
116
  );
227
- const useFeatureButtonAudio = $derived.by(() => {
228
- if (typeof model?.useFeatureButtonAudio === 'boolean') {
229
- return !!model.useFeatureButtonAudio;
230
- }
231
- const profile = layoutProfile || '';
232
- return (
233
- !!model?.hasAudio &&
234
- (profile === 'audio_blank_only' ||
235
- profile === 'stimulus_image_blank' ||
236
- profile === 'token_sequence')
237
- );
238
- });
239
-
240
- const templateParts = $derived.by(() => {
241
- const t = model?.template || '';
242
- const idx = t.indexOf(BLANK_TOKEN);
243
- if (idx < 0) {
244
- return { before: t, after: '' };
245
- }
246
- return {
247
- before: t.slice(0, idx),
248
- after: t.slice(idx + BLANK_TOKEN.length),
249
- };
250
- });
251
- const blankWidth = $derived.by(() => {
252
- if (layoutProfile === 'audio_blank_only' || layoutProfile === 'stimulus_image_blank') {
253
- return `${layoutLimits.blankWideWidthRem}rem`;
254
- }
255
- if (isBlankOnlyTemplate) {
256
- return `${layoutLimits.blankStandaloneWidthRem}rem`;
257
- }
258
- return 'auto';
259
- });
260
- const blankBorderWidth = $derived.by(() => {
261
- if (layoutProfile === 'audio_blank_only' || layoutProfile === 'stimulus_image_blank') {
262
- return `${layoutLimits.blankUnderlineWideWidthPx}px`;
263
- }
264
- return `${layoutLimits.blankUnderlineWidthPx}px`;
265
- });
266
117
 
118
+ // ---------------------------------------------------------------------------
119
+ // Cluster: choiceState — selection, display choice, per-choice correctness, result text
120
+ // Feeds: template blank display, choice rendering, fieldset, result sr-only text
121
+ // ---------------------------------------------------------------------------
267
122
  const choices = $derived(Array.isArray(model?.choices) ? model.choices : []);
268
123
  const choiceMode = $derived(model?.choiceMode || 'text');
269
124
  const selectedId = $derived(session?.choiceId || localChoiceId || '');
270
125
  const radioGroupName = $derived(`${instanceId}-choice-group-${model?.id || '1'}`);
271
-
272
- let showCorrectAnswer = $state(false);
273
- const shouldShowCorrectAnswerToggle = $derived(
274
- isEvaluateMode && !isResponseCorrect && !!model?.correctChoiceId
126
+ const displayChoiceId = $derived(
127
+ computeDisplayChoiceId({
128
+ selectedId,
129
+ isEvaluateMode,
130
+ showCorrectAnswer,
131
+ alwaysShowCorrect: !!model?.alwaysShowCorrect,
132
+ correctChoiceId: String(model?.correctChoiceId || ''),
133
+ })
275
134
  );
276
-
277
- const displayChoiceId = $derived.by(() => {
278
- if (isEvaluateMode && showCorrectAnswer && model?.correctChoiceId) {
279
- return model.correctChoiceId;
280
- }
281
- return selectedId;
282
- });
283
-
284
135
  const displayChoice = $derived.by(() => choices.find((c: any) => c.id === displayChoiceId));
285
136
  const displayChoiceLabelHtml = $derived.by(() => String(displayChoice?.labelHtml || ''));
137
+ const resultText = $derived(
138
+ computeResultText({ isEvaluateMode, showCorrectAnswer, isCorrect, isIncorrect, selectedId })
139
+ );
140
+ const choiceCorrectnessById = $derived(
141
+ computeChoiceCorrectness({
142
+ isEvaluateMode,
143
+ correctChoiceId: String(model?.correctChoiceId || ''),
144
+ selectedId: String(selectedId || ''),
145
+ showCorrectAnswer,
146
+ })
147
+ );
286
148
 
287
- const legendText = $derived.by(() => {
288
- const plain = (model?.prompt || '')
289
- .replace(/<[^>]+>/g, ' ')
290
- .replace(/\s+/g, ' ')
291
- .trim();
292
- const maxChars = Math.max(
293
- 8,
294
- Number(layoutLimits.legendMaxChars) || DEFAULT_LAYOUT_LIMITS.legendMaxChars
295
- );
296
- return plain.length > maxChars
297
- ? `${plain.slice(0, Math.max(1, maxChars - 1))}…`
298
- : plain || uiText.answerChoices;
299
- });
300
-
149
+ // ---------------------------------------------------------------------------
150
+ // Cluster: a11y — stable IDs, aria labelling, described-by relationships
151
+ // Feeds: fieldset legend, radiogroup labelling, template described-by
152
+ // ---------------------------------------------------------------------------
153
+ const uiText = $derived.by(() => ({
154
+ ...DEFAULT_UI_TEXT,
155
+ ...(model?.uiText || {}),
156
+ }));
301
157
  const promptId = $derived(`${instanceId}-prompt`);
302
158
  const transcriptId = $derived(`${instanceId}-transcript`);
303
159
  const legendId = $derived(`${instanceId}-choices-legend`);
304
160
  const resultId = $derived(`${instanceId}-result`);
305
-
161
+ const legendText = $derived(
162
+ computeLegendText({
163
+ prompt: model?.prompt || '',
164
+ legendMaxChars: layout.legendMaxChars,
165
+ answerChoicesLabel: uiText.answerChoices,
166
+ })
167
+ );
306
168
  const choicesGroupLabelledBy = $derived(model?.prompt ? promptId : undefined);
307
169
  const choicesGroupAriaLabel = $derived.by(() => {
308
170
  if (model?.prompt) return undefined;
@@ -310,73 +172,40 @@ const choicesGroupAriaLabel = $derived.by(() => {
310
172
  if (explicit) return explicit;
311
173
  return legendText;
312
174
  });
313
-
314
175
  const templateDescribedBy = $derived.by(() => {
315
176
  const ids: string[] = [];
316
177
  if (model?.prompt) ids.push(promptId);
317
- if (model?.hasAudio && model?.audioTranscript) {
318
- ids.push(transcriptId);
319
- }
178
+ if (model?.hasAudio && model?.audioTranscript) ids.push(transcriptId);
320
179
  return ids.length ? ids.join(' ') : undefined;
321
180
  });
322
181
 
182
+ // ---------------------------------------------------------------------------
183
+ // Misc — locale, audio error, template parsing, variant CSS, style strings
184
+ // ---------------------------------------------------------------------------
323
185
  const lang = $derived.by(() => {
324
186
  const locale = model?.locale || '';
325
187
  return locale ? locale.slice(0, 2) : 'en';
326
188
  });
327
- const audioErrorMessage = $derived.by(() =>
328
- hasAudioButMissingResource ? uiText.audioResourceUnavailable : ''
329
- );
330
-
331
- const resultText = $derived.by(() => {
332
- if (!isEvaluateMode || showCorrectAnswer) return '';
333
- if (isCorrect) return 'Correct answer selected';
334
- if (isIncorrect && selectedId) return 'Incorrect answer selected';
335
- return '';
336
- });
337
- const choiceCorrectnessById = $derived.by(() => {
338
- const map = new Map<string, 'correct' | 'incorrect'>();
339
- const correctChoiceId = String(model?.correctChoiceId || '');
340
- const activeSelectedId = String(selectedId || '');
341
-
342
- if (!isEvaluateMode || !correctChoiceId) {
343
- return map;
344
- }
345
-
346
- if (showCorrectAnswer) {
347
- // Reveal mode should show only the canonical correct answer state.
348
- map.set(correctChoiceId, 'correct');
349
- return map;
350
- }
351
-
352
- if (!activeSelectedId) {
353
- map.set(correctChoiceId, 'incorrect');
354
- return map;
355
- }
356
-
357
- if (activeSelectedId === correctChoiceId) {
358
- map.set(correctChoiceId, 'correct');
359
- return map;
360
- }
361
-
362
- map.set(activeSelectedId, 'incorrect');
363
- map.set(correctChoiceId, 'incorrect');
364
- return map;
189
+ const variantCssConfig = $derived(getVariantCssConfig(model?.customType));
190
+ const variantRootClass = $derived(getVariantRootClass(model?.customType));
191
+ const templateParts = $derived.by(() => {
192
+ const t = model?.template || '';
193
+ const idx = t.indexOf(BLANK_TOKEN);
194
+ if (idx < 0) return { before: t, after: '' };
195
+ return {
196
+ before: t.slice(0, idx),
197
+ after: t.slice(idx + BLANK_TOKEN.length),
198
+ };
365
199
  });
366
200
 
367
201
  function emitSession(updatedSession: any, sourceEl?: HTMLElement | null) {
368
- const host = getHostElement(sourceEl);
369
- if (host?.onSessionChange) {
370
- host.onSessionChange(updatedSession);
371
- return;
372
- }
373
- host?.dispatchEvent(
374
- new CustomEvent('session-changed', {
375
- bubbles: true,
376
- composed: true,
377
- detail: { complete: !!updatedSession?.choiceId, component: 'mc-populated-blank' },
378
- })
379
- );
202
+ forwardSessionChange({
203
+ sourceEl,
204
+ fallbackSelector: 'mc-populated-blank',
205
+ component: 'mc-populated-blank',
206
+ session: updatedSession,
207
+ complete: !!updatedSession?.choiceId,
208
+ });
380
209
  }
381
210
 
382
211
  function onRadioChange(e: Event) {
@@ -426,70 +255,20 @@ function onRadioGroupKeydown(e: KeyboardEvent) {
426
255
  next.focus();
427
256
  }
428
257
 
429
- function handleEnableAutoplayClick() {
430
- if (hasPlayableAudio && audioEl) {
431
- audioEl.play().finally(() => {
432
- autoPlayPromptOpen = false;
433
- });
434
- }
435
- }
436
-
437
- function onAudioPlaying(e: Event) {
438
- const host = getHostElement(e.currentTarget as HTMLElement);
439
- isMediaPlaying = true;
440
- host?.onAudioStarted?.();
441
- autoPlayPromptOpen = false;
442
- }
443
-
444
- function onAudioEnded(e: Event) {
445
- const host = getHostElement(e.currentTarget as HTMLElement);
446
- isMediaPlaying = false;
447
- host?.onAudioEnded?.();
448
- }
258
+ const featureAudioSkin = $derived(
259
+ computeFeatureAudioSkin({
260
+ locale: model?.locale,
261
+ audioButtonSkin: model?.audioButtonSkin,
262
+ audioButtonSkinsByLocale: model?.audioButtonSkinsByLocale,
263
+ })
264
+ );
449
265
 
450
- function getHostElement(sourceEl?: HTMLElement | null) {
451
- let cursor: HTMLElement | null | undefined = sourceEl;
452
- while (cursor) {
453
- const maybeHost = cursor as any;
454
- if (
455
- typeof maybeHost?.onSessionChange === 'function' ||
456
- typeof maybeHost?.onAudioStarted === 'function' ||
457
- typeof maybeHost?.onAudioEnded === 'function'
458
- ) {
459
- return maybeHost;
460
- }
461
- cursor = cursor.parentElement;
462
- }
463
- return (document.querySelector('mc-populated-blank') as any) || null;
266
+ function onAudioStarted() {
267
+ resolveDeliveryHost(rootEl, { fallbackSelector: 'mc-populated-blank' })?.onAudioStarted?.();
464
268
  }
465
269
 
466
- function speechButtonLabel(locale = '') {
467
- return locale.toLowerCase().startsWith('es') ? uiText.listenLabelEs : uiText.listenLabelEn;
468
- }
469
-
470
- const featureAudioSkin = $derived.by(() => {
471
- const locale = String(model?.locale || '').toLowerCase();
472
- const lang = locale.slice(0, 2);
473
- const byLocale =
474
- model?.audioButtonSkinsByLocale && typeof model.audioButtonSkinsByLocale === 'object'
475
- ? model.audioButtonSkinsByLocale
476
- : {};
477
- const customSingle =
478
- model?.audioButtonSkin && typeof model.audioButtonSkin === 'object'
479
- ? model.audioButtonSkin
480
- : null;
481
- const defaultSkin = locale.startsWith('es')
482
- ? DEFAULT_AUDIO_BUTTON_SKINS.es
483
- : DEFAULT_AUDIO_BUTTON_SKINS.default;
484
- return byLocale[locale] || byLocale[lang] || byLocale.default || customSingle || defaultSkin;
485
- });
486
-
487
- function playFeatureAudio() {
488
- if (hasPlayableAudio && audioEl) {
489
- audioEl.play().catch(() => {
490
- // Keep behavior resilient: if playback is blocked, user can retry.
491
- });
492
- }
270
+ function onAudioEnded() {
271
+ resolveDeliveryHost(rootEl, { fallbackSelector: 'mc-populated-blank' })?.onAudioEnded?.();
493
272
  }
494
273
 
495
274
  $effect(() => {
@@ -498,49 +277,14 @@ $effect(() => {
498
277
  }
499
278
  });
500
279
 
501
- $effect(() => {
502
- const el = audioEl;
503
- if (!el) return;
504
- const handlePlaying = (e: Event) => onAudioPlaying(e);
505
- const handleEnded = (e: Event) => onAudioEnded(e);
506
- el.addEventListener('playing', handlePlaying);
507
- el.addEventListener('ended', handleEnded);
508
- return () => {
509
- el.removeEventListener('playing', handlePlaying);
510
- el.removeEventListener('ended', handleEnded);
511
- };
512
- });
513
-
514
- $effect(() => {
515
- const btn = featureAudioButtonEl;
516
- if (!btn) return;
517
- const handleClick = () => playFeatureAudio();
518
- btn.addEventListener('click', handleClick);
519
- return () => {
520
- btn.removeEventListener('click', handleClick);
521
- };
522
- });
523
-
524
- $effect(() => {
525
- const btn = autoplayEnableButtonEl;
526
- if (!btn) return;
527
- const handleClick = () => handleEnableAutoplayClick();
528
- btn.addEventListener('click', handleClick);
529
- return () => {
530
- btn.removeEventListener('click', handleClick);
531
- };
532
- });
533
-
534
280
  $effect(() => {
535
281
  const btn = toggleCorrectAnswerButtonEl;
536
282
  if (!btn) return;
537
- const handleClick = () => toggleCorrectAnswer();
538
- btn.addEventListener('click', handleClick);
539
- return () => {
540
- btn.removeEventListener('click', handleClick);
541
- };
283
+ btn.addEventListener('click', toggleCorrectAnswer);
284
+ return () => btn.removeEventListener('click', toggleCorrectAnswer);
542
285
  });
543
286
 
287
+ // Delegated listeners on the choices group — two events with a radio-guard on change.
544
288
  $effect(() => {
545
289
  const group = choicesGroupEl;
546
290
  if (!group) return;
@@ -559,32 +303,48 @@ $effect(() => {
559
303
  });
560
304
 
561
305
  $effect(() => {
562
- if (!model?.hasAudio || !model?.autoplayAudioEnabled) return;
563
- if (autoplayAttempted) return;
564
-
565
- autoplayAttempted = true;
566
- if (audioEl && model?.audioUrl) {
567
- // Browser autoplay restrictions vary. Try immediate play; if blocked show click-to-enable affordance.
568
- audioEl.play().catch(() => {
569
- autoPlayPromptOpen = true;
570
- });
571
- }
306
+ ensureVariantCssInjected(variantCssConfig);
572
307
  });
573
308
 
574
309
  $effect(() => {
575
- ensureVariantCssInjected(variantCssConfig);
310
+ const el = rootEl;
311
+ if (!el) return;
312
+
313
+ const check = () => {
314
+ ancestorHasTranscriptClass = !!el.closest('.rli-with-audio-transcript');
315
+ };
316
+ check();
317
+
318
+ const observer = new MutationObserver(check);
319
+ // Walk up and observe each ancestor for class changes
320
+ let node: Element | null = el.parentElement;
321
+ while (node) {
322
+ observer.observe(node, { attributes: true, attributeFilter: ['class'] });
323
+ node = node.parentElement;
324
+ }
325
+ return () => observer.disconnect();
576
326
  });
577
327
  </script>
578
328
 
579
329
  <div
330
+ bind:this={rootEl}
580
331
  class={`p-4 mc-populated-blank-root pie-element pie-element-mc-populated-blank pie-delivery-root layout-${layoutProfile} ${variantRootClass} ${hasInlineSentenceAudioLayout ? 'has-inline-audio' : ''}`}
581
332
  lang={lang}
582
- style={rootStyle}
333
+ style={layout.rootStyle}
583
334
  >
584
335
  {#if model?.prompt}
585
336
  <div class="mb-4 prose pie-prompt" id={promptId}>{@html model.prompt}</div>
586
337
  {/if}
587
338
 
339
+ {#if model?.audioTranscript}
340
+ <p
341
+ class={`text-sm mb-3 text-gray-700 text-center pie-audio-transcript ${showVisibleTranscript ? '' : 'sr-only'}`}
342
+ id={transcriptId}
343
+ >
344
+ {model.audioTranscript}
345
+ </p>
346
+ {/if}
347
+
588
348
  {#if shouldShowCorrectAnswerToggle}
589
349
  <div class="pie-correct-answer-toggle-row">
590
350
  <button
@@ -653,69 +413,20 @@ $effect(() => {
653
413
  </div>
654
414
  {/if}
655
415
 
656
- {#if model?.hasAudio}
657
- <div class="mb-4 audio-container pie-audio-container">
658
- {#if hasPlayableAudio && useFeatureButtonAudio}
659
- <audio
660
- bind:this={audioEl}
661
- class="sr-only pie-audio-player"
662
- preload="metadata"
663
- src={model.audioUrl}
664
- aria-hidden="true"
665
- tabindex="-1"
666
- ></audio>
667
- <button
668
- bind:this={featureAudioButtonEl}
669
- class="listen-button pie-listen-button rli-feature-audio"
670
- type="button"
671
- aria-label={speechButtonLabel(model?.locale)}
672
- >
673
- <img
674
- class={`listen-feature-icon pie-listen-icon rli-feature-listen ${isMediaPlaying ? '' : 'listen-active'}`}
675
- src={featureAudioSkin.silentUrl}
676
- alt=""
677
- aria-hidden="true"
678
- />
679
- <img
680
- class={`listen-feature-icon pie-listen-icon rli-feature-listen ${isMediaPlaying ? 'listen-active' : ''}`}
681
- src={featureAudioSkin.playingUrl}
682
- alt=""
683
- aria-hidden="true"
684
- />
685
- </button>
686
- {:else if hasPlayableAudio}
687
- <audio
688
- bind:this={audioEl}
689
- controls
690
- class="w-full max-w-md pie-audio-player"
691
- preload="metadata"
692
- src={model.audioUrl}
693
- aria-describedby={model?.audioTranscript ? transcriptId : undefined}
694
- >
695
- <track kind="captions" />
696
- </audio>
697
- {#if autoPlayPromptOpen}
698
- <button
699
- bind:this={autoplayEnableButtonEl}
700
- class="mt-2 text-sm underline pie-audio-autoplay-enable"
701
- type="button"
702
- >
703
- {uiText.clickToEnableAutoplay}
704
- </button>
705
- {/if}
706
- {:else if hasAudioButMissingResource}
707
- <p class="text-sm text-red-700 pie-audio-error" role="alert">{audioErrorMessage}</p>
708
- {/if}
709
- {#if model?.audioTranscript}
710
- <p
711
- class={`text-sm mt-2 text-gray-700 pie-audio-transcript ${showVisibleTranscript ? '' : 'sr-only'}`}
712
- id={transcriptId}
713
- >
714
- <strong>{uiText.transcriptLabel}:</strong> {model.audioTranscript}
715
- </p>
716
- {/if}
717
- </div>
718
- {/if}
416
+ <AudioPlayer
417
+ hasAudio={!!model?.hasAudio}
418
+ audioUrl={model?.audioUrl}
419
+ {useFeatureButtonAudio}
420
+ autoplayEnabled={!!model?.autoplayAudioEnabled}
421
+ audioTranscript={model?.audioTranscript}
422
+ {showVisibleTranscript}
423
+ {transcriptId}
424
+ {featureAudioSkin}
425
+ {uiText}
426
+ locale={model?.locale}
427
+ onaudiostarted={onAudioStarted}
428
+ onaudioended={onAudioEnded}
429
+ />
719
430
 
720
431
  {#if model?.sentenceHtml}
721
432
  <div class="mb-3 prose prose-p:my-1 sentence-line pie-sentence-line" aria-describedby={templateDescribedBy}>
@@ -726,27 +437,15 @@ $effect(() => {
726
437
  {#if !isAudioOnlyMode}
727
438
  <div class="mb-4 template-line pie-template-line" aria-describedby={templateDescribedBy}>
728
439
  {@html templateParts.before}
729
- <span
730
- class={`inline-flex items-center min-h-[1.5em] px-2 mx-1 border-b-2 border-gray-500 align-baseline blank-slot pie-blank-slot ${isBlankOnlyTemplate ? 'blank-slot-standalone pie-blank-slot-standalone' : ''}`}
731
- style={`width:${blankWidth};border-bottom-width:${blankBorderWidth};`}
732
- role="status"
733
- aria-live="polite"
734
- aria-atomic="true"
735
- aria-label={uiText.selectedAnswerInSentence}
736
- >
737
- {#if choiceMode === 'image' && displayChoice?.imageUrl}
738
- <img
739
- src={displayChoice.imageUrl}
740
- alt={displayChoice.imageAlt || 'Selected answer image'}
741
- class="w-auto object-contain pie-blank-image"
742
- style="max-height:var(--mpb-selected-image-max-height, 4rem);"
743
- />
744
- {:else if displayChoiceLabelHtml}
745
- <span class="choice-html pie-blank-value">{@html displayChoiceLabelHtml}</span>
746
- {:else}
747
- <span class="blank-inner-empty" aria-hidden="true">&nbsp;</span>
748
- {/if}
749
- </span>
440
+ <ClozeMarker
441
+ {choiceMode}
442
+ displayChoice={displayChoice}
443
+ {displayChoiceLabelHtml}
444
+ isStandalone={isBlankOnlyTemplate}
445
+ blankWidth={layout.blankWidth}
446
+ blankBorderWidth={layout.blankBorderWidth}
447
+ ariaLabel={uiText.selectedAnswerInSentence}
448
+ />
750
449
  {@html templateParts.after}
751
450
  </div>
752
451
  {/if}
@@ -768,74 +467,17 @@ $effect(() => {
768
467
  aria-describedby={resultText ? resultId : undefined}
769
468
  >
770
469
  {#each choices as c (c.id)}
771
- {@const choiceCorrectness = choiceCorrectnessById.get(c.id)}
772
- <div
773
- class={`flex items-start choice-row pie-choice ${isHorizontalChoices ? 'choice-row-horizontal pie-choice-horizontal' : ''} ${((showCorrectAnswer ? model?.correctChoiceId : selectedId) === c.id) ? 'is-selected pie-choice-selected' : ''} ${choiceCorrectness ? `choice-${choiceCorrectness} pie-choice-${choiceCorrectness}` : ''}`}
774
- style="gap:var(--mpb-choice-row-gap, 0.5rem);"
775
- >
776
- {#if isHorizontalChoices}
777
- <label
778
- for={`${instanceId}-opt-${c.id}`}
779
- class="cursor-pointer choice-tile text-center pie-choice-tile"
780
- >
781
- <span class="choice-tile-content pie-choice-tile-content">
782
- {#if choiceMode === 'image' && c.imageUrl}
783
- <img
784
- src={c.imageUrl}
785
- alt={c.imageAlt || `Choice ${c.id}`}
786
- class="object-contain mx-auto pie-choice-image"
787
- style="max-height:var(--mpb-choice-image-max-height, 5rem);"
788
- />
789
- {:else}
790
- <span class="choice-html pie-choice-label">{@html c.labelHtml || ''}</span>
791
- {/if}
792
- </span>
793
- <input
794
- type="radio"
795
- name={radioGroupName}
796
- id={`${instanceId}-opt-${c.id}`}
797
- value={c.id}
798
- checked={
799
- (showCorrectAnswer ? model?.correctChoiceId : selectedId) === c.id
800
- }
801
- disabled={model?.disabled}
802
- class="choice-radio-bottom pie-choice-radio pie-choice-radio-bottom"
803
- />
804
- </label>
805
- {:else}
806
- <input
807
- type="radio"
808
- name={radioGroupName}
809
- id={`${instanceId}-opt-${c.id}`}
810
- value={c.id}
811
- checked={
812
- (showCorrectAnswer ? model?.correctChoiceId : selectedId) === c.id
813
- }
814
- disabled={model?.disabled}
815
- class="choice-radio-inline pie-choice-radio pie-choice-radio-inline"
816
- />
817
- <label for={`${instanceId}-opt-${c.id}`} class="cursor-pointer flex-1 pie-choice-label-wrap">
818
- {#if choiceMode === 'image' && c.imageUrl}
819
- <img
820
- src={c.imageUrl}
821
- alt={c.imageAlt || `Choice ${c.id}`}
822
- class="object-contain pie-choice-image"
823
- style="max-height:var(--mpb-choice-image-max-height, 5rem);"
824
- />
825
- {:else}
826
- <span class="choice-html pie-choice-label">{@html c.labelHtml || ''}</span>
827
- {/if}
828
- </label>
829
- {/if}
830
- {#if isEvaluateMode && choiceCorrectness}
831
- <span
832
- class={`pie-choice-feedback-badge ${choiceCorrectness === 'correct' ? 'pie-choice-feedback-correct' : 'pie-choice-feedback-incorrect'}`}
833
- aria-hidden="true"
834
- >
835
- {choiceCorrectness === 'correct' ? '✓' : '✕'}
836
- </span>
837
- {/if}
838
- </div>
470
+ <ChoiceRow
471
+ choice={c}
472
+ {choiceMode}
473
+ isHorizontal={isHorizontalChoices}
474
+ isSelected={displayChoiceId === c.id}
475
+ isDisabled={!!model?.disabled}
476
+ correctness={choiceCorrectnessById.get(c.id)}
477
+ {isEvaluateMode}
478
+ {instanceId}
479
+ {radioGroupName}
480
+ />
839
481
  {/each}
840
482
  </div>
841
483
  </fieldset>
@@ -858,19 +500,6 @@ $effect(() => {
858
500
  max-width: none;
859
501
  }
860
502
 
861
- .blank-slot:focus-within {
862
- outline: 2px solid var(--pie-focus, #2563eb);
863
- outline-offset: 2px;
864
- }
865
-
866
- .blank-slot {
867
- display: inline-flex;
868
- align-items: center;
869
- justify-content: center;
870
- text-align: center;
871
- vertical-align: baseline;
872
- }
873
-
874
503
  .pie-toggle-correct-answer {
875
504
  width: 100%;
876
505
  cursor: pointer;
@@ -930,166 +559,8 @@ $effect(() => {
930
559
  margin: 0;
931
560
  }
932
561
 
933
- .blank-inner-empty {
934
- display: inline-block;
935
- min-width: 4ch;
936
- }
937
-
938
- .blank-slot-standalone {
939
- width: var(--mpb-blank-standalone-width, 7rem);
940
- }
941
-
942
- .listen-button {
943
- width: var(--mpb-listen-button-size, 128px);
944
- height: var(--mpb-listen-button-size, 128px);
945
- border: 0;
946
- padding: 0;
947
- background-color: transparent;
948
- cursor: pointer;
949
- z-index: 1;
950
- }
951
-
952
- .listen-button:hover {
953
- background-color: #e2f1fe;
954
- }
955
-
956
- .listen-feature-icon {
957
- width: var(--mpb-listen-button-size, 128px);
958
- height: var(--mpb-listen-button-size, 128px);
959
- object-fit: contain;
960
- display: none;
961
- }
962
-
963
- .listen-feature-icon.listen-active {
964
- display: block;
965
- }
966
-
967
- .choice-row-horizontal {
968
- flex-direction: column;
969
- align-items: center;
970
- width: min(var(--mpb-choice-width-px, 170px), var(--mpb-choice-width-vw, 30vw));
971
- gap: 0;
972
- }
973
-
974
- .choice-tile {
975
- display: flex;
976
- flex-direction: column;
977
- justify-content: space-between;
978
- align-items: center;
979
- width: 100%;
980
- min-height: var(--mpb-choice-tile-min-height, 11rem);
981
- padding: 0.8rem 0.65rem 0.5rem;
982
- border-radius: 8px;
983
- background: transparent;
984
- transition: background-color 120ms ease-in-out;
985
- }
986
-
987
- .choice-tile-content {
988
- width: 100%;
989
- display: flex;
990
- align-items: center;
991
- justify-content: center;
992
- min-height: var(--mpb-choice-content-min-height, 7.5rem);
993
- }
994
-
995
- .choice-row-horizontal:hover .choice-tile {
996
- background: var(--pie-correct-answer-choice-hover-bg, #ececec);
997
- }
998
-
999
- .pie-choice-horizontal:not(.is-selected):not(:hover) .pie-choice-tile {
1000
- background: transparent;
1001
- }
1002
-
1003
- .choice-row-horizontal.is-selected .choice-tile {
1004
- background: var(--pie-correct-answer-choice-selected-bg, #f1f1f1);
1005
- }
1006
-
1007
- .choice-row-horizontal.is-selected:hover .choice-tile {
1008
- background: var(--pie-correct-answer-choice-selected-bg, #f1f1f1);
1009
- }
1010
-
1011
- .pie-choice:not(.pie-choice-horizontal):hover .pie-choice-label-wrap {
1012
- background: var(--pie-correct-answer-choice-hover-bg, #ececec);
1013
- }
1014
-
1015
- .pie-choice:not(.pie-choice-horizontal):not(.is-selected):not(:hover) .pie-choice-label-wrap {
1016
- background: transparent;
1017
- }
1018
-
1019
- .pie-choice:not(.pie-choice-horizontal).is-selected .pie-choice-label-wrap {
1020
- background: var(--pie-correct-answer-choice-selected-bg, #f1f1f1);
1021
- border-radius: 6px;
1022
- }
1023
-
1024
- .pie-choice:not(.pie-choice-horizontal).is-selected:hover .pie-choice-label-wrap {
1025
- background: var(--pie-correct-answer-choice-selected-bg, #f1f1f1);
1026
- }
1027
-
1028
- .pie-choice.choice-correct {
1029
- border-left: 3px solid var(--pie-correct-answer-choice-correct-border, #0ea449);
1030
- }
1031
-
1032
- .pie-choice.choice-incorrect {
1033
- border-left: 3px solid var(--pie-correct-answer-choice-incorrect-border, #bf0d00);
1034
- }
1035
-
1036
- .pie-choice-horizontal.choice-correct .choice-tile {
1037
- background: var(--pie-correct-answer-choice-correct-bg, #e8f5e9);
1038
- }
1039
-
1040
- .pie-choice-horizontal.choice-incorrect .choice-tile {
1041
- background: var(--pie-correct-answer-choice-incorrect-bg, #ffebee);
1042
- }
1043
-
1044
- .pie-choice-feedback-badge {
1045
- margin-left: auto;
1046
- display: inline-flex;
1047
- align-items: center;
1048
- justify-content: center;
1049
- width: 1.1rem;
1050
- height: 1.1rem;
1051
- border-radius: 9999px;
1052
- font-size: 0.72rem;
1053
- line-height: 1;
1054
- font-weight: 700;
1055
- color: var(--pie-correct-answer-feedback-glyph-color, #fff);
1056
- }
1057
-
1058
- .pie-choice-feedback-correct {
1059
- background: var(--pie-correct-answer-feedback-correct-bg, #087d38);
1060
- }
1061
-
1062
- .pie-choice-feedback-incorrect {
1063
- background: var(--pie-correct-answer-feedback-incorrect-bg, #bf0d00);
1064
- }
1065
-
1066
- .choice-row-horizontal :global(p) {
1067
- margin: 0;
1068
- text-align: center;
1069
- }
1070
-
1071
- .choice-html :global(p) {
1072
- margin: 0;
1073
- }
1074
-
1075
- .choice-html {
1076
- width: 100%;
1077
- display: inline-flex;
1078
- align-items: center;
1079
- justify-content: center;
1080
- text-align: center;
1081
- }
1082
-
1083
- .choice-radio-bottom {
1084
- margin-top: var(--mpb-horizontal-choice-radio-top-margin, 0.5rem);
1085
- }
1086
-
1087
- .choice-radio-inline {
1088
- margin-top: var(--mpb-horizontal-choice-radio-top-margin, 0.5rem);
1089
- }
1090
-
1091
- .layout-audio_blank_only .audio-container,
1092
- .layout-stimulus_image_blank .audio-container {
562
+ .layout-audio_blank_only :global(.pie-audio-container),
563
+ .layout-stimulus_image_blank :global(.pie-audio-container) {
1093
564
  display: flex;
1094
565
  width: min(100%, var(--mpb-audio-instructions-max-width, 875px));
1095
566
  margin-left: auto;
@@ -1104,16 +575,24 @@ $effect(() => {
1104
575
  var(--mpb-audio-blank-template-margin-bottom, 1.8rem);
1105
576
  }
1106
577
 
1107
- .layout-audio_blank_only .blank-slot,
1108
- .layout-stimulus_image_blank .blank-slot {
578
+ .layout-audio_blank_only fieldset {
579
+ display: flex;
580
+ justify-content: center;
581
+ }
582
+
583
+ .layout-audio_blank_only :global(.pie-blank-slot),
584
+ .layout-stimulus_image_blank :global(.pie-blank-slot) {
1109
585
  width: var(--mpb-blank-wide-width, 10rem);
1110
- border-bottom-width: var(--mpb-blank-underline-wide-width, 4px);
586
+ border-bottom-width: var(--mpb-blank-underline-wide-width, 6px);
587
+ min-height: 160px;
588
+ padding-bottom: 4px;
1111
589
  }
1112
590
 
1113
591
  .layout-stimulus_image_blank {
1114
592
  display: grid;
1115
593
  grid-template-columns: minmax(var(--mpb-stimulus-min-column, 210px), 1fr) auto;
1116
594
  grid-template-areas:
595
+ 'transcript transcript'
1117
596
  'sentence audio'
1118
597
  '. template'
1119
598
  'choices choices';
@@ -1122,7 +601,11 @@ $effect(() => {
1122
601
  align-items: start;
1123
602
  }
1124
603
 
1125
- .layout-stimulus_image_blank .audio-container {
604
+ .layout-stimulus_image_blank .pie-audio-transcript {
605
+ grid-area: transcript;
606
+ }
607
+
608
+ .layout-stimulus_image_blank :global(.pie-audio-container) {
1126
609
  grid-area: audio;
1127
610
  margin: 0;
1128
611
  }
@@ -1146,16 +629,21 @@ $effect(() => {
1146
629
 
1147
630
  .layout-token_sequence {
1148
631
  display: grid;
1149
- grid-template-columns: minmax(var(--mpb-text-min-column, 260px), 1fr) auto;
632
+ grid-template-columns: 1fr auto;
1150
633
  grid-template-areas:
1151
- 'template audio'
1152
- 'choices choices';
634
+ 'transcript audio'
635
+ 'template template'
636
+ 'choices choices';
1153
637
  column-gap: var(--mpb-token-grid-column-gap, 1.5rem);
1154
638
  row-gap: var(--mpb-token-grid-row-gap, 0.8rem);
1155
639
  align-items: start;
1156
640
  }
1157
641
 
1158
- .layout-token_sequence .audio-container {
642
+ .layout-token_sequence .pie-audio-transcript {
643
+ grid-area: transcript;
644
+ }
645
+
646
+ .layout-token_sequence :global(.pie-audio-container) {
1159
647
  grid-area: audio;
1160
648
  margin: 0;
1161
649
  justify-self: end;
@@ -1172,7 +660,7 @@ $effect(() => {
1172
660
  margin-left: var(--mpb-token-inline-token-gap, 0.35rem);
1173
661
  }
1174
662
 
1175
- .layout-token_sequence .blank-slot {
663
+ .layout-token_sequence :global(.pie-blank-slot) {
1176
664
  width: var(--mpb-blank-standalone-width, 7rem);
1177
665
  border-bottom-width: var(--mpb-blank-underline-wide-width, 4px);
1178
666
  margin-left: var(--mpb-token-inline-token-gap, 0.35rem);
@@ -1187,6 +675,7 @@ $effect(() => {
1187
675
  display: grid;
1188
676
  grid-template-columns: minmax(var(--mpb-text-min-column, 260px), 1fr) auto;
1189
677
  grid-template-areas:
678
+ 'transcript transcript'
1190
679
  'template audio'
1191
680
  'choices choices';
1192
681
  column-gap: var(--mpb-inline-grid-column-gap, 1.5rem);
@@ -1194,7 +683,11 @@ $effect(() => {
1194
683
  align-items: start;
1195
684
  }
1196
685
 
1197
- .layout-inline_sentence.has-inline-audio .audio-container {
686
+ .layout-inline_sentence.has-inline-audio .pie-audio-transcript {
687
+ grid-area: transcript;
688
+ }
689
+
690
+ .layout-inline_sentence.has-inline-audio :global(.pie-audio-container) {
1198
691
  grid-area: audio;
1199
692
  margin: 0;
1200
693
  justify-self: end;
@@ -1215,10 +708,10 @@ $effect(() => {
1215
708
  /* Match Learnosity responsive behavior for CQT audio-blank layouts:
1216
709
  at smaller widths, audio control shifts left and answer tiles stack. */
1217
710
  @media (max-width: 760px) {
1218
- .layout-audio_blank_only .audio-container,
1219
- .layout-stimulus_image_blank .audio-container,
1220
- .layout-token_sequence .audio-container,
1221
- .layout-inline_sentence.has-inline-audio .audio-container {
711
+ .layout-audio_blank_only :global(.pie-audio-container),
712
+ .layout-stimulus_image_blank :global(.pie-audio-container),
713
+ .layout-token_sequence :global(.pie-audio-container),
714
+ .layout-inline_sentence.has-inline-audio :global(.pie-audio-container) {
1222
715
  width: 100%;
1223
716
  margin-left: 0;
1224
717
  margin-right: 0;