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