@pie-element/mc-populated-blank 0.1.0 → 0.2.1

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.
@@ -1,85 +1,114 @@
1
1
  <svelte:options
2
2
  customElement={{
3
+ tag: 'mc-populated-blank',
3
4
  shadow: 'none',
4
5
  props: {
5
6
  model: { type: 'Object' },
6
7
  session: { type: 'Object' },
7
8
  options: { type: 'Object' },
8
- onSessionChange: { type: 'Object' },
9
- onAudioStarted: { type: 'Object' },
10
- onAudioEnded: { type: 'Object' },
11
9
  },
12
10
  }}
13
11
  />
14
12
 
15
13
  <script lang="ts">
16
- import { onDestroy } from 'svelte';
17
-
18
14
  /** Must match controller `BLANK_TOKEN` (kept local to avoid pulling controller into delivery). */
19
15
  const BLANK_TOKEN = '{{blank}}';
20
-
21
- type McChoice = {
22
- id: string;
23
- labelHtml?: string;
24
- imageUrl?: string;
25
- imageAlt?: string;
26
- };
27
- type McModel = {
28
- id?: string;
29
- mode?: string;
30
- correctness?: 'correct' | 'incorrect' | string;
31
- responseCorrect?: boolean;
32
- interactionMode?: string;
33
- template?: string;
34
- layoutProfile?: string;
35
- choiceLayout?: 'horizontal' | 'vertical' | string;
36
- showVisibleTranscript?: boolean;
37
- hasAudio?: boolean;
38
- audioUrl?: string;
39
- audioTranscript?: string;
40
- choices?: McChoice[];
41
- choiceMode?: 'text' | 'image' | string;
42
- prompt?: string;
43
- sentenceHtml?: string;
44
- locale?: string;
45
- autoplayAudioEnabled?: boolean;
46
- disabled?: boolean;
47
- correctChoiceId?: string;
48
- shuffle?: boolean;
49
- lockChoiceOrder?: boolean;
50
- teacherInstructions?: string | null;
51
- };
52
- type McSession = {
53
- id?: string;
54
- element?: string;
55
- choiceId?: string;
56
- [key: string]: unknown;
16
+ const DEFAULT_LAYOUT_LIMITS = {
17
+ blankStandaloneWidthRem: 7,
18
+ blankWideWidthRem: 10,
19
+ blankUnderlineWidthPx: 2,
20
+ blankUnderlineWideWidthPx: 4,
21
+ horizontalChoiceWidthPx: 170,
22
+ horizontalChoiceWidthVw: 30,
23
+ horizontalChoiceTileMinHeightRem: 11,
24
+ horizontalChoiceContentMinHeightRem: 7.5,
25
+ selectedImageMaxHeightRem: 4,
26
+ choiceImageMaxHeightRem: 5,
27
+ listenButtonSizePx: 128,
28
+ stimulusMinColumnPx: 210,
29
+ textMinColumnPx: 260,
30
+ legendMaxChars: 120,
31
+ choiceGroupGapRem: 0.5,
32
+ choiceRowGapRem: 0.5,
33
+ toggleButtonGapRem: 0.5,
34
+ horizontalChoiceRadioTopMarginRem: 0.5,
35
+ audioBlankTemplateMarginTopRem: 0.8,
36
+ audioBlankTemplateMarginBottomRem: 1.8,
37
+ audioInstructionsMaxWidthPx: 875,
38
+ narrowHorizontalChoiceMaxWidthPx: 230,
39
+ stimulusGridColumnGapRem: 2,
40
+ stimulusGridRowGapRem: 0.7,
41
+ stimulusSentenceMarginTopRem: 0.2,
42
+ stimulusChoicesMarginTopRem: 0.9,
43
+ tokenGridColumnGapRem: 1.5,
44
+ tokenGridRowGapRem: 0.8,
45
+ tokenTemplateMarginTopRem: 0.6,
46
+ tokenInlineTokenGapRem: 0.35,
47
+ tokenChoicesMarginTopRem: 0.2,
48
+ inlineGridColumnGapRem: 1.5,
49
+ inlineGridRowGapRem: 0.65,
50
+ inlineTemplateMarginTopRem: 0.45,
51
+ inlineChoicesMarginTopRem: 0.25,
52
+ } as const;
53
+ const DEFAULT_LAYOUT_PROFILE_PRESETS: Record<string, Record<string, number>> = {
54
+ audio_blank_only: {
55
+ blankWideWidthRem: 10,
56
+ blankUnderlineWideWidthPx: 4,
57
+ },
58
+ stimulus_image_blank: {
59
+ blankWideWidthRem: 10,
60
+ blankUnderlineWideWidthPx: 4,
61
+ },
62
+ token_sequence: {
63
+ blankStandaloneWidthRem: 7,
64
+ blankUnderlineWideWidthPx: 4,
65
+ },
57
66
  };
67
+ const DEFAULT_AUDIO_BUTTON_SKINS = {
68
+ default: {
69
+ silentUrl:
70
+ 'https://assets.learnosity.com/organisations/844/0c9f2aa3-3cd5-4de7-93ef-541c24ca35da.svg',
71
+ playingUrl:
72
+ 'https://assets.learnosity.com/organisations/844/231dfdc2-c113-4be5-91fb-e75a0ca5994b.svg',
73
+ },
74
+ es: {
75
+ silentUrl:
76
+ 'https://assets.learnosity.com/organisations/844/27a9d5b5-d873-4bd5-b9ba-22748782d8ba.svg',
77
+ playingUrl:
78
+ 'https://assets.learnosity.com/organisations/844/120f216d-96b7-4560-94b8-1d90710216b7.svg',
79
+ },
80
+ } as const;
81
+ const DEFAULT_UI_TEXT = {
82
+ answerChoices: 'Answer choices',
83
+ selectedAnswerInSentence: 'Selected answer in sentence',
84
+ showCorrectAnswer: 'Show correct answer',
85
+ hideCorrectAnswer: 'Hide correct answer',
86
+ clickToEnableAutoplay: 'Click to enable audio autoplay',
87
+ audioResourceUnavailable: 'Audio is enabled but no playable audio URL is configured.',
88
+ transcriptLabel: 'Transcript',
89
+ listenLabelEn: 'Listen',
90
+ listenLabelEs: 'Escuchar',
91
+ } as const;
58
92
 
59
- let props = $props<{
60
- model?: McModel;
61
- session?: McSession;
62
- options?: Record<string, unknown>;
63
- onSessionChange?: (session: McSession) => void;
64
- onAudioStarted?: () => void;
65
- onAudioEnded?: () => void;
66
- }>();
93
+ let { model, session, options } = $props<{ model?: any; session?: any; options?: any }>();
67
94
  let audioEl = $state<HTMLAudioElement | null>(null);
68
- let activeUtterance = $state<SpeechSynthesisUtterance | null>(null);
69
- let isSynthSpeaking = $state(false);
70
95
  let isMediaPlaying = $state(false);
71
96
  let localChoiceId = $state('');
72
97
  let autoPlayPromptOpen = $state(false);
73
98
  let autoplayAttempted = $state(false);
99
+ let featureAudioButtonEl = $state<HTMLButtonElement | null>(null);
100
+ let autoplayEnableButtonEl = $state<HTMLButtonElement | null>(null);
101
+ let toggleCorrectAnswerButtonEl = $state<HTMLButtonElement | null>(null);
102
+ let choicesGroupEl = $state<HTMLDivElement | null>(null);
74
103
  const instanceId = `mc-populated-blank-${Math.random().toString(36).slice(2, 10)}`;
75
104
 
76
- const isEvaluateMode = $derived(props?.model?.mode === 'evaluate');
77
- const correctness = $derived(props?.model?.correctness);
105
+ const isEvaluateMode = $derived(model?.env?.mode === 'evaluate');
106
+ const correctness = $derived(model?.correctness);
78
107
  const isCorrect = $derived(correctness === 'correct');
79
108
  const isIncorrect = $derived(correctness === 'incorrect');
80
- const isAudioOnlyMode = $derived(props?.model?.interactionMode === 'audio_mc_only');
109
+ const isAudioOnlyMode = $derived(model?.interactionMode === 'audio_mc_only');
81
110
  const isBlankOnlyTemplate = $derived.by(() => {
82
- const t = props?.model?.template || '';
111
+ const t = model?.template || '';
83
112
  if (!t) return false;
84
113
  const plain = t
85
114
  .replace(/<[^>]+>/g, '')
@@ -87,25 +116,91 @@ const isBlankOnlyTemplate = $derived.by(() => {
87
116
  .trim();
88
117
  return plain === BLANK_TOKEN;
89
118
  });
90
- const layoutProfile = $derived(props?.model?.layoutProfile || '');
119
+ const layoutProfile = $derived(model?.layoutProfile || '');
91
120
  const choiceLayout = $derived(
92
- props?.model?.choiceLayout || (isAudioOnlyMode || isBlankOnlyTemplate ? 'horizontal' : 'vertical')
121
+ model?.choiceLayout || (isAudioOnlyMode || isBlankOnlyTemplate ? 'horizontal' : 'vertical')
93
122
  );
94
123
  const isHorizontalChoices = $derived(choiceLayout === 'horizontal');
95
- const isVicFamilyLayout = $derived(layoutProfile === 'inline_sentence' && !isHorizontalChoices);
96
- const isSelVicFamilyLayout = $derived(isVicFamilyLayout && !!props?.model?.hasAudio);
97
- const showVisibleTranscript = $derived(!!props?.model?.showVisibleTranscript);
98
- const hasPlayableAudio = $derived(!!props?.model?.hasAudio && !!props?.model?.audioUrl);
99
- const hasTranscriptOnlyAudio = $derived(
100
- !!props?.model?.hasAudio && !props?.model?.audioUrl && !!props?.model?.audioTranscript
101
- );
124
+ const showVisibleTranscript = $derived(!!model?.showVisibleTranscript);
125
+ const hasPlayableAudio = $derived(!!model?.hasAudio && !!model?.audioUrl);
126
+ const hasAudioButMissingResource = $derived(!!model?.hasAudio && !model?.audioUrl);
102
127
  const hasInlineSentenceAudioLayout = $derived(
103
- layoutProfile === 'inline_sentence' && !!props?.model?.hasAudio
128
+ layoutProfile === 'inline_sentence' && !!model?.hasAudio
129
+ );
130
+ const uiText = $derived.by(() => ({
131
+ ...DEFAULT_UI_TEXT,
132
+ ...(model?.uiText || {}),
133
+ }));
134
+ const profilePresetLimits = $derived.by(() => {
135
+ const profile = String(layoutProfile || '');
136
+ const defaults = DEFAULT_LAYOUT_PROFILE_PRESETS[profile] || {};
137
+ const customPresets =
138
+ model?.layoutProfilePresets && typeof model.layoutProfilePresets === 'object'
139
+ ? model.layoutProfilePresets
140
+ : {};
141
+ const custom =
142
+ customPresets[profile] && typeof customPresets[profile] === 'object'
143
+ ? customPresets[profile]
144
+ : {};
145
+ return {
146
+ ...defaults,
147
+ ...custom,
148
+ };
149
+ });
150
+ const layoutLimits = $derived.by(() => {
151
+ const configured =
152
+ model?.layoutLimits && typeof model.layoutLimits === 'object' ? model.layoutLimits : {};
153
+ return {
154
+ ...DEFAULT_LAYOUT_LIMITS,
155
+ ...profilePresetLimits,
156
+ ...configured,
157
+ };
158
+ });
159
+ const rootStyle = $derived.by(() =>
160
+ [
161
+ `--mpb-listen-button-size:${layoutLimits.listenButtonSizePx}px`,
162
+ `--mpb-blank-standalone-width:${layoutLimits.blankStandaloneWidthRem}rem`,
163
+ `--mpb-blank-wide-width:${layoutLimits.blankWideWidthRem}rem`,
164
+ `--mpb-blank-underline-width:${layoutLimits.blankUnderlineWidthPx}px`,
165
+ `--mpb-blank-underline-wide-width:${layoutLimits.blankUnderlineWideWidthPx}px`,
166
+ `--mpb-selected-image-max-height:${layoutLimits.selectedImageMaxHeightRem}rem`,
167
+ `--mpb-choice-image-max-height:${layoutLimits.choiceImageMaxHeightRem}rem`,
168
+ `--mpb-choice-width-px:${layoutLimits.horizontalChoiceWidthPx}px`,
169
+ `--mpb-choice-width-vw:${layoutLimits.horizontalChoiceWidthVw}vw`,
170
+ `--mpb-choice-tile-min-height:${layoutLimits.horizontalChoiceTileMinHeightRem}rem`,
171
+ `--mpb-choice-content-min-height:${layoutLimits.horizontalChoiceContentMinHeightRem}rem`,
172
+ `--mpb-stimulus-min-column:${layoutLimits.stimulusMinColumnPx}px`,
173
+ `--mpb-text-min-column:${layoutLimits.textMinColumnPx}px`,
174
+ `--mpb-choice-group-gap:${layoutLimits.choiceGroupGapRem}rem`,
175
+ `--mpb-choice-row-gap:${layoutLimits.choiceRowGapRem}rem`,
176
+ `--mpb-toggle-button-gap:${layoutLimits.toggleButtonGapRem}rem`,
177
+ `--mpb-horizontal-choice-radio-top-margin:${layoutLimits.horizontalChoiceRadioTopMarginRem}rem`,
178
+ `--mpb-audio-blank-template-margin-top:${layoutLimits.audioBlankTemplateMarginTopRem}rem`,
179
+ `--mpb-audio-blank-template-margin-bottom:${layoutLimits.audioBlankTemplateMarginBottomRem}rem`,
180
+ `--mpb-audio-instructions-max-width:${layoutLimits.audioInstructionsMaxWidthPx}px`,
181
+ `--mpb-narrow-choice-max-width:${layoutLimits.narrowHorizontalChoiceMaxWidthPx}px`,
182
+ `--mpb-stimulus-grid-column-gap:${layoutLimits.stimulusGridColumnGapRem}rem`,
183
+ `--mpb-stimulus-grid-row-gap:${layoutLimits.stimulusGridRowGapRem}rem`,
184
+ `--mpb-stimulus-sentence-margin-top:${layoutLimits.stimulusSentenceMarginTopRem}rem`,
185
+ `--mpb-stimulus-choices-margin-top:${layoutLimits.stimulusChoicesMarginTopRem}rem`,
186
+ `--mpb-token-grid-column-gap:${layoutLimits.tokenGridColumnGapRem}rem`,
187
+ `--mpb-token-grid-row-gap:${layoutLimits.tokenGridRowGapRem}rem`,
188
+ `--mpb-token-template-margin-top:${layoutLimits.tokenTemplateMarginTopRem}rem`,
189
+ `--mpb-token-inline-token-gap:${layoutLimits.tokenInlineTokenGapRem}rem`,
190
+ `--mpb-token-choices-margin-top:${layoutLimits.tokenChoicesMarginTopRem}rem`,
191
+ `--mpb-inline-grid-column-gap:${layoutLimits.inlineGridColumnGapRem}rem`,
192
+ `--mpb-inline-grid-row-gap:${layoutLimits.inlineGridRowGapRem}rem`,
193
+ `--mpb-inline-template-margin-top:${layoutLimits.inlineTemplateMarginTopRem}rem`,
194
+ `--mpb-inline-choices-margin-top:${layoutLimits.inlineChoicesMarginTopRem}rem`,
195
+ ].join(';')
104
196
  );
105
197
  const useFeatureButtonAudio = $derived.by(() => {
198
+ if (typeof model?.useFeatureButtonAudio === 'boolean') {
199
+ return !!model.useFeatureButtonAudio;
200
+ }
106
201
  const profile = layoutProfile || '';
107
202
  return (
108
- !!props?.model?.hasAudio &&
203
+ !!model?.hasAudio &&
109
204
  (profile === 'audio_blank_only' ||
110
205
  profile === 'stimulus_image_blank' ||
111
206
  profile === 'token_sequence')
@@ -113,7 +208,7 @@ const useFeatureButtonAudio = $derived.by(() => {
113
208
  });
114
209
 
115
210
  const templateParts = $derived.by(() => {
116
- const t = props?.model?.template || '';
211
+ const t = model?.template || '';
117
212
  const idx = t.indexOf(BLANK_TOKEN);
118
213
  if (idx < 0) {
119
214
  return { before: t, after: '' };
@@ -123,93 +218,50 @@ const templateParts = $derived.by(() => {
123
218
  after: t.slice(idx + BLANK_TOKEN.length),
124
219
  };
125
220
  });
126
-
127
- function plainTextFromHtml(value = ''): string {
128
- return value
129
- .replace(/<[^>]+>/g, ' ')
130
- .replace(/&nbsp;/g, ' ')
131
- .replace(/\s+/g, ' ')
132
- .trim();
133
- }
134
-
135
221
  const blankWidth = $derived.by(() => {
136
222
  if (layoutProfile === 'audio_blank_only' || layoutProfile === 'stimulus_image_blank') {
137
- return '10rem';
223
+ return `${layoutLimits.blankWideWidthRem}rem`;
138
224
  }
139
225
  if (isBlankOnlyTemplate) {
140
- return '7rem';
141
- }
142
- if (choiceMode === 'text') {
143
- const modelChoices = Array.isArray(props?.model?.choices) ? props.model.choices : [];
144
- const longestChoiceChars = modelChoices.reduce((max: number, choice: McChoice) => {
145
- const textLength = plainTextFromHtml(choice?.labelHtml || '').length;
146
- return Math.max(max, textLength);
147
- }, 0);
148
- if (longestChoiceChars > 0) {
149
- const widthInCh = Math.min(24, Math.max(7, longestChoiceChars + 1));
150
- return `${widthInCh}ch`;
151
- }
226
+ return `${layoutLimits.blankStandaloneWidthRem}rem`;
152
227
  }
153
228
  return 'auto';
154
229
  });
155
230
  const blankBorderWidth = $derived.by(() => {
156
231
  if (layoutProfile === 'audio_blank_only' || layoutProfile === 'stimulus_image_blank') {
157
- return '4px';
232
+ return `${layoutLimits.blankUnderlineWideWidthPx}px`;
158
233
  }
159
- return '2px';
234
+ return `${layoutLimits.blankUnderlineWidthPx}px`;
160
235
  });
161
236
 
162
- const choices = $derived(Array.isArray(props?.model?.choices) ? props.model.choices : []);
163
- const shouldShuffleChoices = $derived.by(() => {
164
- const modelShuffle = !!props?.model?.shuffle;
165
- const optionsShuffle = !!props?.options?.shuffle;
166
- const lockChoiceOrder = !!props?.model?.lockChoiceOrder || !!props?.options?.lockChoiceOrder;
167
- return (modelShuffle || optionsShuffle) && !lockChoiceOrder;
168
- });
169
- const shuffleSeed = $derived.by(
170
- () =>
171
- `${props?.session?.id || props?.model?.id || instanceId}:${choices.map((c: McChoice) => c.id).join('|')}`
172
- );
173
- function hashString(value: string): number {
174
- let hash = 2166136261;
175
- for (let i = 0; i < value.length; i++) {
176
- hash ^= value.charCodeAt(i);
177
- hash = Math.imul(hash, 16777619);
178
- }
179
- return hash >>> 0;
180
- }
181
- function deterministicShuffle<T extends { id?: string }>(items: T[], seed: string): T[] {
182
- return [...items]
183
- .map((item, index) => ({
184
- item,
185
- key: hashString(`${seed}:${item?.id || index}:${index}`),
186
- }))
187
- .sort((a, b) => a.key - b.key)
188
- .map((entry) => entry.item);
189
- }
190
- const renderedChoices = $derived.by(() =>
191
- shouldShuffleChoices ? deterministicShuffle(choices, shuffleSeed) : choices
192
- );
193
- const choiceMode = $derived(props?.model?.choiceMode || 'text');
194
- const selectedId = $derived(props?.session?.choiceId || localChoiceId || '');
195
- const radioGroupName = $derived(`${instanceId}-choice-group-${props?.model?.id || '1'}`);
237
+ const choices = $derived(Array.isArray(model?.choices) ? model.choices : []);
238
+ const choiceMode = $derived(model?.choiceMode || 'text');
239
+ const selectedId = $derived(session?.choiceId || localChoiceId || '');
240
+ const radioGroupName = $derived(`${instanceId}-choice-group-${model?.id || '1'}`);
196
241
 
197
242
  let showCorrectAnswer = $state(false);
198
243
 
199
244
  const displayChoiceId = $derived.by(() => {
200
- if (isEvaluateMode && showCorrectAnswer && props?.model?.correctChoiceId) {
201
- return props.model.correctChoiceId;
245
+ if (isEvaluateMode && showCorrectAnswer && model?.correctChoiceId) {
246
+ return model.correctChoiceId;
202
247
  }
203
248
  return selectedId;
204
249
  });
205
250
 
206
- const displayChoice = $derived.by(() =>
207
- renderedChoices.find((c: McChoice) => c.id === displayChoiceId)
208
- );
251
+ const displayChoice = $derived.by(() => choices.find((c: any) => c.id === displayChoiceId));
209
252
 
210
253
  const legendText = $derived.by(() => {
211
- const plain = plainTextFromHtml(props?.model?.prompt || '');
212
- return plain.length > 120 ? `${plain.slice(0, 117)}…` : plain || t('answerChoices');
254
+ const plain = (model?.prompt || '')
255
+ .replace(/<[^>]+>/g, ' ')
256
+ .replace(/\s+/g, ' ')
257
+ .trim();
258
+ const maxChars = Math.max(
259
+ 8,
260
+ Number(layoutLimits.legendMaxChars) || DEFAULT_LAYOUT_LIMITS.legendMaxChars
261
+ );
262
+ return plain.length > maxChars
263
+ ? `${plain.slice(0, Math.max(1, maxChars - 1))}…`
264
+ : plain || uiText.answerChoices;
213
265
  });
214
266
 
215
267
  const promptId = $derived(`${instanceId}-prompt`);
@@ -217,62 +269,51 @@ const transcriptId = $derived(`${instanceId}-transcript`);
217
269
  const legendId = $derived(`${instanceId}-choices-legend`);
218
270
  const resultId = $derived(`${instanceId}-result`);
219
271
 
220
- const choicesGroupLabelledBy = $derived(props?.model?.prompt ? promptId : legendId);
272
+ const choicesGroupLabelledBy = $derived(model?.prompt ? promptId : undefined);
273
+ const choicesGroupAriaLabel = $derived.by(() => {
274
+ if (model?.prompt) return undefined;
275
+ const explicit = String(model?.choiceGroupLabel || '').trim();
276
+ if (explicit) return explicit;
277
+ return legendText;
278
+ });
221
279
 
222
280
  const templateDescribedBy = $derived.by(() => {
223
281
  const ids: string[] = [];
224
- if (props?.model?.prompt) ids.push(promptId);
225
- if (props?.model?.hasAudio && props?.model?.audioTranscript) {
282
+ if (model?.prompt) ids.push(promptId);
283
+ if (model?.hasAudio && model?.audioTranscript) {
226
284
  ids.push(transcriptId);
227
285
  }
228
286
  return ids.length ? ids.join(' ') : undefined;
229
287
  });
230
288
 
231
289
  const lang = $derived.by(() => {
232
- const locale = props?.model?.locale || '';
290
+ const locale = model?.locale || '';
233
291
  return locale ? locale.slice(0, 2) : 'en';
234
292
  });
235
-
236
- const uiStrings = {
237
- en: {
238
- answerChoices: 'Answer choices',
239
- correctSelected: 'Correct answer selected',
240
- incorrectSelected: 'Incorrect answer selected',
241
- selectedAnswerInSentence: 'Selected answer in sentence',
242
- selectedAnswerImage: 'Selected answer image',
243
- showCorrect: 'Show correct answer',
244
- hideCorrect: 'Hide correct answer',
245
- enableAutoplay: 'Click to enable audio autoplay',
246
- listen: 'Listen',
247
- teacherInstructions: 'Teacher instructions',
248
- },
249
- es: {
250
- answerChoices: 'Opciones de respuesta',
251
- correctSelected: 'Respuesta correcta seleccionada',
252
- incorrectSelected: 'Respuesta incorrecta seleccionada',
253
- selectedAnswerInSentence: 'Respuesta seleccionada en la oración',
254
- selectedAnswerImage: 'Imagen de la respuesta seleccionada',
255
- showCorrect: 'Mostrar respuesta correcta',
256
- hideCorrect: 'Ocultar respuesta correcta',
257
- enableAutoplay: 'Haga clic para habilitar la reproducción automática de audio',
258
- listen: 'Escuchar',
259
- teacherInstructions: 'Instrucciones para el docente',
260
- },
261
- } as const;
262
- type UiKey = keyof (typeof uiStrings)['en'];
263
- function t(key: UiKey): string {
264
- return (lang === 'es' ? uiStrings.es[key] : uiStrings.en[key]) || uiStrings.en[key];
265
- }
293
+ const audioErrorMessage = $derived.by(() =>
294
+ hasAudioButMissingResource ? uiText.audioResourceUnavailable : ''
295
+ );
266
296
 
267
297
  const resultText = $derived.by(() => {
268
298
  if (!isEvaluateMode || showCorrectAnswer) return '';
269
- if (isCorrect) return t('correctSelected');
270
- if (isIncorrect && selectedId) return t('incorrectSelected');
299
+ if (isCorrect) return 'Correct answer selected';
300
+ if (isIncorrect && selectedId) return 'Incorrect answer selected';
271
301
  return '';
272
302
  });
273
303
 
274
- function emitSession(updatedSession: McSession) {
275
- props?.onSessionChange?.(updatedSession);
304
+ function emitSession(updatedSession: any, sourceEl?: HTMLElement | null) {
305
+ const host = getHostElement(sourceEl);
306
+ if (host?.onSessionChange) {
307
+ host.onSessionChange(updatedSession);
308
+ return;
309
+ }
310
+ host?.dispatchEvent(
311
+ new CustomEvent('session-changed', {
312
+ bubbles: true,
313
+ composed: true,
314
+ detail: { complete: !!updatedSession?.choiceId, component: 'mc-populated-blank' },
315
+ })
316
+ );
276
317
  }
277
318
 
278
319
  function onRadioChange(e: Event) {
@@ -281,12 +322,12 @@ function onRadioChange(e: Event) {
281
322
  const choiceId = input.value;
282
323
  localChoiceId = choiceId;
283
324
  const updatedSession = {
284
- ...props.session,
285
- id: props.session?.id || props.model?.id || '1',
325
+ ...session,
326
+ id: session?.id || model?.id || '1',
286
327
  element: 'mc-populated-blank',
287
328
  choiceId,
288
329
  };
289
- emitSession(updatedSession);
330
+ emitSession(updatedSession, input);
290
331
  }
291
332
 
292
333
  function toggleCorrectAnswer() {
@@ -294,7 +335,7 @@ function toggleCorrectAnswer() {
294
335
  }
295
336
 
296
337
  function onRadioGroupKeydown(e: KeyboardEvent) {
297
- if (props?.model?.disabled) return;
338
+ if (model?.disabled) return;
298
339
  const key = e.key;
299
340
  if (!['ArrowDown', 'ArrowRight', 'ArrowUp', 'ArrowLeft'].includes(key)) return;
300
341
  e.preventDefault();
@@ -320,45 +361,57 @@ function handleEnableAutoplayClick() {
320
361
  audioEl.play().finally(() => {
321
362
  autoPlayPromptOpen = false;
322
363
  });
323
- return;
324
- }
325
- if (hasTranscriptOnlyAudio) {
326
- speakTranscript();
327
- autoPlayPromptOpen = false;
328
364
  }
329
365
  }
330
366
 
331
- function onAudioPlaying() {
367
+ function onAudioPlaying(e: Event) {
368
+ const host = getHostElement(e.currentTarget as HTMLElement);
332
369
  isMediaPlaying = true;
333
- props?.onAudioStarted?.();
370
+ host?.onAudioStarted?.();
334
371
  autoPlayPromptOpen = false;
335
372
  }
336
373
 
337
- function onAudioEnded() {
374
+ function onAudioEnded(e: Event) {
375
+ const host = getHostElement(e.currentTarget as HTMLElement);
338
376
  isMediaPlaying = false;
339
- props?.onAudioEnded?.();
377
+ host?.onAudioEnded?.();
340
378
  }
341
379
 
342
- function speechButtonLabel() {
343
- return t('listen');
380
+ function getHostElement(sourceEl?: HTMLElement | null) {
381
+ let cursor: HTMLElement | null | undefined = sourceEl;
382
+ while (cursor) {
383
+ const maybeHost = cursor as any;
384
+ if (
385
+ typeof maybeHost?.onSessionChange === 'function' ||
386
+ typeof maybeHost?.onAudioStarted === 'function' ||
387
+ typeof maybeHost?.onAudioEnded === 'function'
388
+ ) {
389
+ return maybeHost;
390
+ }
391
+ cursor = cursor.parentElement;
392
+ }
393
+ return (document.querySelector('mc-populated-blank') as any) || null;
394
+ }
395
+
396
+ function speechButtonLabel(locale = '') {
397
+ return locale.toLowerCase().startsWith('es') ? uiText.listenLabelEs : uiText.listenLabelEn;
344
398
  }
345
399
 
346
400
  const featureAudioSkin = $derived.by(() => {
347
- const locale = String(props?.model?.locale || '').toLowerCase();
348
- if (locale.startsWith('es')) {
349
- return {
350
- silentUrl:
351
- 'https://assets.learnosity.com/organisations/844/27a9d5b5-d873-4bd5-b9ba-22748782d8ba.svg',
352
- playingUrl:
353
- 'https://assets.learnosity.com/organisations/844/120f216d-96b7-4560-94b8-1d90710216b7.svg',
354
- };
355
- }
356
- return {
357
- silentUrl:
358
- 'https://assets.learnosity.com/organisations/844/0c9f2aa3-3cd5-4de7-93ef-541c24ca35da.svg',
359
- playingUrl:
360
- 'https://assets.learnosity.com/organisations/844/231dfdc2-c113-4be5-91fb-e75a0ca5994b.svg',
361
- };
401
+ const locale = String(model?.locale || '').toLowerCase();
402
+ const lang = locale.slice(0, 2);
403
+ const byLocale =
404
+ model?.audioButtonSkinsByLocale && typeof model.audioButtonSkinsByLocale === 'object'
405
+ ? model.audioButtonSkinsByLocale
406
+ : {};
407
+ const customSingle =
408
+ model?.audioButtonSkin && typeof model.audioButtonSkin === 'object'
409
+ ? model.audioButtonSkin
410
+ : null;
411
+ const defaultSkin = locale.startsWith('es')
412
+ ? DEFAULT_AUDIO_BUTTON_SKINS.es
413
+ : DEFAULT_AUDIO_BUTTON_SKINS.default;
414
+ return byLocale[locale] || byLocale[lang] || byLocale.default || customSingle || defaultSkin;
362
415
  });
363
416
 
364
417
  function playFeatureAudio() {
@@ -366,105 +419,123 @@ function playFeatureAudio() {
366
419
  audioEl.play().catch(() => {
367
420
  // Keep behavior resilient: if playback is blocked, user can retry.
368
421
  });
369
- return;
370
422
  }
371
- speakTranscript();
372
423
  }
373
424
 
374
- function speakTranscript() {
375
- const transcript = (props?.model?.audioTranscript || '').trim();
376
- if (!transcript || !window?.speechSynthesis) return;
377
-
378
- window.speechSynthesis.cancel();
379
- const utterance = new SpeechSynthesisUtterance(transcript);
380
- utterance.lang = props?.model?.locale || 'en-US';
425
+ $effect(() => {
426
+ if (session?.choiceId) {
427
+ localChoiceId = session.choiceId;
428
+ }
429
+ });
381
430
 
382
- utterance.onstart = () => {
383
- isSynthSpeaking = true;
384
- props?.onAudioStarted?.();
431
+ $effect(() => {
432
+ const el = audioEl;
433
+ if (!el) return;
434
+ const handlePlaying = (e: Event) => onAudioPlaying(e);
435
+ const handleEnded = (e: Event) => onAudioEnded(e);
436
+ el.addEventListener('playing', handlePlaying);
437
+ el.addEventListener('ended', handleEnded);
438
+ return () => {
439
+ el.removeEventListener('playing', handlePlaying);
440
+ el.removeEventListener('ended', handleEnded);
385
441
  };
442
+ });
386
443
 
387
- utterance.onend = () => {
388
- isSynthSpeaking = false;
389
- activeUtterance = null;
390
- props?.onAudioEnded?.();
444
+ $effect(() => {
445
+ const btn = featureAudioButtonEl;
446
+ if (!btn) return;
447
+ const handleClick = () => playFeatureAudio();
448
+ btn.addEventListener('click', handleClick);
449
+ return () => {
450
+ btn.removeEventListener('click', handleClick);
391
451
  };
452
+ });
392
453
 
393
- utterance.onerror = () => {
394
- isSynthSpeaking = false;
395
- activeUtterance = null;
454
+ $effect(() => {
455
+ const btn = autoplayEnableButtonEl;
456
+ if (!btn) return;
457
+ const handleClick = () => handleEnableAutoplayClick();
458
+ btn.addEventListener('click', handleClick);
459
+ return () => {
460
+ btn.removeEventListener('click', handleClick);
396
461
  };
462
+ });
397
463
 
398
- activeUtterance = utterance;
399
- window.speechSynthesis.speak(utterance);
400
- }
401
-
402
- onDestroy(() => {
403
- if (activeUtterance && window?.speechSynthesis) {
404
- window.speechSynthesis.cancel();
405
- }
464
+ $effect(() => {
465
+ const btn = toggleCorrectAnswerButtonEl;
466
+ if (!btn) return;
467
+ const handleClick = () => toggleCorrectAnswer();
468
+ btn.addEventListener('click', handleClick);
469
+ return () => {
470
+ btn.removeEventListener('click', handleClick);
471
+ };
406
472
  });
407
473
 
408
474
  $effect(() => {
409
- if (props?.session?.choiceId) {
410
- localChoiceId = props.session.choiceId;
411
- }
475
+ const group = choicesGroupEl;
476
+ if (!group) return;
477
+ const handleChange = (e: Event) => {
478
+ const target = e.target as HTMLInputElement | null;
479
+ if (!target || target.type !== 'radio') return;
480
+ onRadioChange(e);
481
+ };
482
+ const handleKeydown = (e: KeyboardEvent) => onRadioGroupKeydown(e);
483
+ group.addEventListener('change', handleChange);
484
+ group.addEventListener('keydown', handleKeydown);
485
+ return () => {
486
+ group.removeEventListener('change', handleChange);
487
+ group.removeEventListener('keydown', handleKeydown);
488
+ };
412
489
  });
413
490
 
414
491
  $effect(() => {
415
- if (!props?.model?.hasAudio || !props?.model?.autoplayAudioEnabled) return;
492
+ if (!model?.hasAudio || !model?.autoplayAudioEnabled) return;
416
493
  if (autoplayAttempted) return;
417
494
 
418
495
  autoplayAttempted = true;
419
- if (audioEl && props?.model?.audioUrl) {
496
+ if (audioEl && model?.audioUrl) {
420
497
  // Browser autoplay restrictions vary. Try immediate play; if blocked show click-to-enable affordance.
421
498
  audioEl.play().catch(() => {
422
499
  autoPlayPromptOpen = true;
423
500
  });
424
- return;
425
- }
426
-
427
- if (props?.model?.audioTranscript) {
428
- autoPlayPromptOpen = true;
429
501
  }
430
502
  });
431
503
  </script>
432
504
 
433
505
  <div
434
- class={`p-4 mc-populated-blank-root layout-${layoutProfile} ${hasInlineSentenceAudioLayout ? 'has-inline-audio' : ''} ${isVicFamilyLayout ? 'vic-family-layout' : ''} ${isSelVicFamilyLayout ? 'sel-vic-family-layout' : ''}`}
506
+ class={`p-4 mc-populated-blank-root pie-element pie-element-mc-populated-blank pie-delivery-root layout-${layoutProfile} ${hasInlineSentenceAudioLayout ? 'has-inline-audio' : ''}`}
435
507
  lang={lang}
508
+ style={rootStyle}
436
509
  >
437
- {#if props?.model?.prompt}
438
- <div class="mb-4 prose" id={promptId}>{@html props.model.prompt}</div>
510
+ {#if model?.prompt}
511
+ <div class="mb-4 prose pie-prompt" id={promptId}>{@html model.prompt}</div>
439
512
  {/if}
440
513
 
441
- {#if props?.model?.hasAudio}
442
- <div class="mb-4 audio-container">
514
+ {#if model?.hasAudio}
515
+ <div class="mb-4 audio-container pie-audio-container">
443
516
  {#if hasPlayableAudio && useFeatureButtonAudio}
444
517
  <audio
445
518
  bind:this={audioEl}
446
- class="sr-only"
519
+ class="sr-only pie-audio-player"
447
520
  preload="metadata"
448
- src={props.model.audioUrl}
521
+ src={model.audioUrl}
449
522
  aria-hidden="true"
450
523
  tabindex="-1"
451
- onplaying={onAudioPlaying}
452
- onended={onAudioEnded}
453
524
  ></audio>
454
525
  <button
455
- class="listen-button rli-feature-audio"
526
+ bind:this={featureAudioButtonEl}
527
+ class="listen-button pie-listen-button rli-feature-audio"
456
528
  type="button"
457
- aria-label={speechButtonLabel()}
458
- onclick={playFeatureAudio}
529
+ aria-label={speechButtonLabel(model?.locale)}
459
530
  >
460
531
  <img
461
- class={`listen-feature-icon rli-feature-listen ${isMediaPlaying || isSynthSpeaking ? '' : 'listen-active'}`}
532
+ class={`listen-feature-icon pie-listen-icon rli-feature-listen ${isMediaPlaying ? '' : 'listen-active'}`}
462
533
  src={featureAudioSkin.silentUrl}
463
534
  alt=""
464
535
  aria-hidden="true"
465
536
  />
466
537
  <img
467
- class={`listen-feature-icon rli-feature-listen ${isMediaPlaying || isSynthSpeaking ? 'listen-active' : ''}`}
538
+ class={`listen-feature-icon pie-listen-icon rli-feature-listen ${isMediaPlaying ? 'listen-active' : ''}`}
468
539
  src={featureAudioSkin.playingUrl}
469
540
  alt=""
470
541
  aria-hidden="true"
@@ -474,84 +545,62 @@ $effect(() => {
474
545
  <audio
475
546
  bind:this={audioEl}
476
547
  controls
477
- class="w-full max-w-md"
548
+ class="w-full max-w-md pie-audio-player"
478
549
  preload="metadata"
479
- src={props.model.audioUrl}
480
- aria-describedby={props?.model?.audioTranscript ? transcriptId : undefined}
481
- onplaying={onAudioPlaying}
482
- onended={onAudioEnded}
550
+ src={model.audioUrl}
551
+ aria-describedby={model?.audioTranscript ? transcriptId : undefined}
483
552
  >
484
553
  <track kind="captions" />
485
554
  </audio>
486
555
  {#if autoPlayPromptOpen}
487
- <button class="mt-2 text-sm underline" type="button" onclick={handleEnableAutoplayClick}>
488
- {t('enableAutoplay')}
556
+ <button
557
+ bind:this={autoplayEnableButtonEl}
558
+ class="mt-2 text-sm underline pie-audio-autoplay-enable"
559
+ type="button"
560
+ >
561
+ {uiText.clickToEnableAutoplay}
489
562
  </button>
490
563
  {/if}
491
- {:else if hasTranscriptOnlyAudio}
492
- <button
493
- class="listen-button rli-feature-audio"
494
- type="button"
495
- aria-label={speechButtonLabel()}
496
- onclick={playFeatureAudio}
497
- >
498
- <img
499
- class={`listen-feature-icon rli-feature-listen ${isMediaPlaying || isSynthSpeaking ? '' : 'listen-active'}`}
500
- src={featureAudioSkin.silentUrl}
501
- alt=""
502
- aria-hidden="true"
503
- />
504
- <img
505
- class={`listen-feature-icon rli-feature-listen ${isMediaPlaying || isSynthSpeaking ? 'listen-active' : ''}`}
506
- src={featureAudioSkin.playingUrl}
507
- alt=""
508
- aria-hidden="true"
509
- />
510
- </button>
564
+ {:else if hasAudioButMissingResource}
565
+ <p class="text-sm text-red-700 pie-audio-error" role="alert">{audioErrorMessage}</p>
511
566
  {/if}
512
- {#if props?.model?.audioTranscript}
567
+ {#if model?.audioTranscript}
513
568
  <p
514
- class={`text-sm mt-2 text-gray-700 ${showVisibleTranscript ? '' : 'sr-only'}`}
569
+ class={`text-sm mt-2 text-gray-700 pie-audio-transcript ${showVisibleTranscript ? '' : 'sr-only'}`}
515
570
  id={transcriptId}
516
571
  >
517
- {props.model.audioTranscript}
572
+ <strong>{uiText.transcriptLabel}:</strong> {model.audioTranscript}
518
573
  </p>
519
574
  {/if}
520
575
  </div>
521
576
  {/if}
522
577
 
523
- {#if props?.model?.teacherInstructions}
524
- <div class="mb-4 teacher-instructions">
525
- <h4 class="teacher-instructions-title">{t('teacherInstructions')}</h4>
526
- <div class="prose teacher-instructions-content">{@html props.model.teacherInstructions}</div>
527
- </div>
528
- {/if}
529
-
530
- {#if props?.model?.sentenceHtml}
531
- <div class="mb-3 prose prose-p:my-1 sentence-line" aria-describedby={templateDescribedBy}>
532
- {@html props.model.sentenceHtml}
578
+ {#if model?.sentenceHtml}
579
+ <div class="mb-3 prose prose-p:my-1 sentence-line pie-sentence-line" aria-describedby={templateDescribedBy}>
580
+ {@html model.sentenceHtml}
533
581
  </div>
534
582
  {/if}
535
583
 
536
584
  {#if !isAudioOnlyMode}
537
- <div class="mb-4 template-line" aria-describedby={templateDescribedBy}>
585
+ <div class="mb-4 template-line pie-template-line" aria-describedby={templateDescribedBy}>
538
586
  {@html templateParts.before}
539
587
  <span
540
- class={`inline-flex items-center min-h-[1.5em] px-2 mx-1 border-b-2 border-gray-500 align-baseline blank-slot ${isBlankOnlyTemplate ? 'blank-slot-standalone' : ''}`}
588
+ 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' : ''}`}
541
589
  style={`width:${blankWidth};border-bottom-width:${blankBorderWidth};`}
542
590
  role="status"
543
591
  aria-live="polite"
544
592
  aria-atomic="true"
545
- aria-label={t('selectedAnswerInSentence')}
593
+ aria-label={uiText.selectedAnswerInSentence}
546
594
  >
547
595
  {#if choiceMode === 'image' && displayChoice?.imageUrl}
548
596
  <img
549
597
  src={displayChoice.imageUrl}
550
- alt={displayChoice.imageAlt || t('selectedAnswerImage')}
551
- class="max-h-16 w-auto object-contain"
598
+ alt={displayChoice.imageAlt || 'Selected answer image'}
599
+ class="w-auto object-contain pie-blank-image"
600
+ style="max-height:var(--mpb-selected-image-max-height, 4rem);"
552
601
  />
553
602
  {:else if displayChoice?.labelHtml}
554
- <span class="blank-inner choice-label">{@html displayChoice.labelHtml}</span>
603
+ <span class="blank-inner choice-label pie-blank-value">{@html displayChoice.labelHtml}</span>
555
604
  {:else}
556
605
  <span class="blank-inner-empty" aria-hidden="true">&nbsp;</span>
557
606
  {/if}
@@ -562,45 +611,53 @@ $effect(() => {
562
611
 
563
612
  {#if isEvaluateMode && isIncorrect}
564
613
  <button
614
+ bind:this={toggleCorrectAnswerButtonEl}
565
615
  type="button"
566
- class="mb-3 flex items-center gap-2 cursor-pointer select-none"
567
- onclick={toggleCorrectAnswer}
616
+ class="mb-3 flex items-center cursor-pointer select-none pie-toggle-correct-answer"
617
+ style="gap:var(--mpb-toggle-button-gap, 0.5rem);"
568
618
  aria-pressed={showCorrectAnswer}
569
619
  >
570
620
  <span class="text-sm hover:underline">
571
- {showCorrectAnswer ? t('hideCorrect') : t('showCorrect')}
621
+ {showCorrectAnswer ? uiText.hideCorrectAnswer : uiText.showCorrectAnswer}
572
622
  </span>
573
623
  </button>
574
624
  {/if}
575
625
  {#if resultText}
576
- <p id={resultId} class="sr-only" role="status" aria-live="polite">{resultText}</p>
626
+ <p id={resultId} class="sr-only pie-result-feedback" role="status" aria-live="polite">{resultText}</p>
577
627
  {/if}
578
628
 
579
- <fieldset class="border-0 p-0 m-0" disabled={props?.model?.disabled}>
580
- <legend class="sr-only" id={legendId}>{legendText}</legend>
629
+ <fieldset class="border-0 p-0 m-0 pie-choices-fieldset" disabled={model?.disabled}>
630
+ <legend class="sr-only pie-choices-legend" id={legendId}>{legendText}</legend>
581
631
  <div
582
- class={`gap-2 ${isHorizontalChoices ? 'flex flex-row flex-wrap items-start justify-center' : 'flex flex-col'}`}
632
+ bind:this={choicesGroupEl}
633
+ class={`pie-choices ${isHorizontalChoices ? 'flex flex-row flex-wrap items-start justify-center' : 'flex flex-col'}`}
634
+ style="gap:var(--mpb-choice-group-gap, 0.5rem);"
583
635
  role="radiogroup"
584
636
  tabindex="-1"
585
637
  aria-labelledby={choicesGroupLabelledBy}
638
+ aria-label={choicesGroupAriaLabel}
586
639
  aria-describedby={resultText ? resultId : undefined}
587
- onkeydown={onRadioGroupKeydown}
588
640
  >
589
- {#each renderedChoices as c (c.id)}
641
+ {#each choices as c (c.id)}
590
642
  <div
591
- class={`flex items-start gap-2 choice-row ${isHorizontalChoices ? 'choice-row-horizontal' : ''} ${((showCorrectAnswer && isEvaluateMode ? props?.model?.correctChoiceId : selectedId) === c.id) ? 'is-selected' : ''}`}
643
+ class={`flex items-start choice-row pie-choice ${isHorizontalChoices ? 'choice-row-horizontal pie-choice-horizontal' : ''} ${((showCorrectAnswer && isEvaluateMode ? model?.correctChoiceId : selectedId) === c.id) ? 'is-selected pie-choice-selected' : ''}`}
644
+ style="gap:var(--mpb-choice-row-gap, 0.5rem);"
592
645
  >
593
646
  {#if isHorizontalChoices}
594
- <label for={`${instanceId}-opt-${c.id}`} class="cursor-pointer choice-tile text-center">
595
- <span class="choice-tile-content">
647
+ <label
648
+ for={`${instanceId}-opt-${c.id}`}
649
+ class="cursor-pointer choice-tile text-center pie-choice-tile"
650
+ >
651
+ <span class="choice-tile-content pie-choice-tile-content">
596
652
  {#if choiceMode === 'image' && c.imageUrl}
597
653
  <img
598
654
  src={c.imageUrl}
599
655
  alt={c.imageAlt || `Choice ${c.id}`}
600
- class="max-h-20 object-contain mx-auto"
656
+ class="object-contain mx-auto pie-choice-image"
657
+ style="max-height:var(--mpb-choice-image-max-height, 5rem);"
601
658
  />
602
659
  {:else}
603
- <span class="choice-label">{@html c.labelHtml || ''}</span>
660
+ <span class="choice-label pie-choice-label">{@html c.labelHtml || ''}</span>
604
661
  {/if}
605
662
  </span>
606
663
  <input
@@ -609,11 +666,10 @@ $effect(() => {
609
666
  id={`${instanceId}-opt-${c.id}`}
610
667
  value={c.id}
611
668
  checked={
612
- (showCorrectAnswer && isEvaluateMode ? props?.model?.correctChoiceId : selectedId) === c.id
669
+ (showCorrectAnswer && isEvaluateMode ? model?.correctChoiceId : selectedId) === c.id
613
670
  }
614
- disabled={props?.model?.disabled}
615
- onchange={onRadioChange}
616
- class="choice-radio-bottom"
671
+ disabled={model?.disabled}
672
+ class="choice-radio-bottom pie-choice-radio pie-choice-radio-bottom"
617
673
  />
618
674
  </label>
619
675
  {:else}
@@ -623,25 +679,33 @@ $effect(() => {
623
679
  id={`${instanceId}-opt-${c.id}`}
624
680
  value={c.id}
625
681
  checked={
626
- (showCorrectAnswer && isEvaluateMode ? props?.model?.correctChoiceId : selectedId) === c.id
682
+ (showCorrectAnswer && isEvaluateMode ? model?.correctChoiceId : selectedId) === c.id
627
683
  }
628
- disabled={props?.model?.disabled}
629
- onchange={onRadioChange}
630
- class="mt-1"
684
+ disabled={model?.disabled}
685
+ class="choice-radio-inline pie-choice-radio pie-choice-radio-inline"
631
686
  />
632
- <label for={`${instanceId}-opt-${c.id}`} class="cursor-pointer flex-1">
687
+ <label for={`${instanceId}-opt-${c.id}`} class="cursor-pointer flex-1 pie-choice-label-wrap">
633
688
  {#if choiceMode === 'image' && c.imageUrl}
634
- <img src={c.imageUrl} alt={c.imageAlt || `Choice ${c.id}`} class="max-h-20 object-contain" />
689
+ <img
690
+ src={c.imageUrl}
691
+ alt={c.imageAlt || `Choice ${c.id}`}
692
+ class="object-contain pie-choice-image"
693
+ style="max-height:var(--mpb-choice-image-max-height, 5rem);"
694
+ />
635
695
  {:else}
636
- <span class="choice-label">{@html c.labelHtml || ''}</span>
696
+ <span class="choice-label pie-choice-label">{@html c.labelHtml || ''}</span>
637
697
  {/if}
638
698
  </label>
639
699
  {/if}
640
700
  {#if isEvaluateMode && !showCorrectAnswer}
641
701
  {#if selectedId === c.id && isCorrect}
642
- <span class="text-green-600 text-sm font-medium" aria-hidden="true">✓</span>
702
+ <span class="text-green-600 text-sm font-medium pie-choice-feedback-correct" aria-hidden="true">
703
+ ✓
704
+ </span>
643
705
  {:else if selectedId === c.id && isIncorrect}
644
- <span class="text-red-600 text-sm font-medium" aria-hidden="true">✗</span>
706
+ <span class="text-red-600 text-sm font-medium pie-choice-feedback-incorrect" aria-hidden="true">
707
+ ✗
708
+ </span>
645
709
  {/if}
646
710
  {/if}
647
711
  </div>
@@ -694,12 +758,12 @@ $effect(() => {
694
758
  }
695
759
 
696
760
  .blank-slot-standalone {
697
- width: 7rem;
761
+ width: var(--mpb-blank-standalone-width, 7rem);
698
762
  }
699
763
 
700
764
  .listen-button {
701
- width: 128px;
702
- height: 128px;
765
+ width: var(--mpb-listen-button-size, 128px);
766
+ height: var(--mpb-listen-button-size, 128px);
703
767
  border: 0;
704
768
  padding: 0;
705
769
  background-color: transparent;
@@ -712,8 +776,8 @@ $effect(() => {
712
776
  }
713
777
 
714
778
  .listen-feature-icon {
715
- width: 128px;
716
- height: 128px;
779
+ width: var(--mpb-listen-button-size, 128px);
780
+ height: var(--mpb-listen-button-size, 128px);
717
781
  object-fit: contain;
718
782
  display: none;
719
783
  }
@@ -725,7 +789,7 @@ $effect(() => {
725
789
  .choice-row-horizontal {
726
790
  flex-direction: column;
727
791
  align-items: center;
728
- width: min(170px, 30vw);
792
+ width: min(var(--mpb-choice-width-px, 170px), var(--mpb-choice-width-vw, 30vw));
729
793
  gap: 0;
730
794
  }
731
795
 
@@ -735,7 +799,7 @@ $effect(() => {
735
799
  justify-content: space-between;
736
800
  align-items: center;
737
801
  width: 100%;
738
- min-height: 11rem;
802
+ min-height: var(--mpb-choice-tile-min-height, 11rem);
739
803
  padding: 0.8rem 0.65rem 0.5rem;
740
804
  border-radius: 8px;
741
805
  background: transparent;
@@ -747,88 +811,19 @@ $effect(() => {
747
811
  display: flex;
748
812
  align-items: center;
749
813
  justify-content: center;
750
- min-height: 7.5rem;
814
+ min-height: var(--mpb-choice-content-min-height, 7.5rem);
751
815
  }
752
816
 
753
817
  .choice-row-horizontal:hover .choice-tile {
754
- background: #f2f2f2;
818
+ background: #ececec;
755
819
  }
756
820
 
757
821
  .choice-row-horizontal.is-selected .choice-tile {
758
- background: #fcfcd3;
822
+ background: #eceabf;
759
823
  }
760
824
 
761
825
  .choice-row-horizontal.is-selected:hover .choice-tile {
762
- background: #fcfcd3;
763
- }
764
-
765
- .choice-row:not(.choice-row-horizontal):hover {
766
- background: #f2f2f2;
767
- border-radius: 6px;
768
- }
769
-
770
- .choice-row.is-selected:not(.choice-row-horizontal) {
771
- background: #fcfcd3;
772
- border-radius: 6px;
773
- }
774
-
775
- :global(.access-contrast-3) .choice-row-horizontal,
776
- :global(.access-contrast-6) .choice-row-horizontal {
777
- border-bottom-width: 4px;
778
- border-bottom-style: solid;
779
- padding-bottom: 10px;
780
- }
781
-
782
- :global(.access-contrast-3) .choice-row:not(.choice-row-horizontal),
783
- :global(.access-contrast-6) .choice-row:not(.choice-row-horizontal) {
784
- border-left-width: 4px;
785
- border-left-style: solid;
786
- padding-left: 0.35rem;
787
- }
788
-
789
- :global(.access-contrast-3) .choice-row-horizontal,
790
- :global(.access-contrast-3) .choice-row:not(.choice-row-horizontal) {
791
- border-color: #2d4e84;
792
- }
793
-
794
- :global(.access-contrast-6) .choice-row-horizontal,
795
- :global(.access-contrast-6) .choice-row:not(.choice-row-horizontal) {
796
- border-color: #000;
797
- }
798
-
799
- :global(.access-contrast-3) .choice-row:hover,
800
- :global(.access-contrast-6) .choice-row:hover {
801
- border-radius: 0;
802
- }
803
-
804
- :global(.access-contrast-3) .choice-row:hover {
805
- border-color: #fff25d;
806
- color: #fff25d;
807
- }
808
-
809
- :global(.access-contrast-6) .choice-row:hover {
810
- border-color: #fff;
811
- color: #fff;
812
- }
813
-
814
- :global(.access-contrast-3) .choice-row:hover:not(.is-selected):not(.choice-row-horizontal),
815
- :global(.access-contrast-6) .choice-row:hover:not(.is-selected):not(.choice-row-horizontal) {
816
- background: inherit;
817
- }
818
-
819
- :global(.access-contrast-3) .choice-row-horizontal:hover:not(.is-selected) .choice-tile,
820
- :global(.access-contrast-6) .choice-row-horizontal:hover:not(.is-selected) .choice-tile {
821
- background: inherit;
822
- }
823
-
824
- :global(.access-contrast-3) .choice-row.is-selected:hover,
825
- :global(.access-contrast-6) .choice-row.is-selected:hover {
826
- color: #000;
827
- }
828
-
829
- :global(.access-contrast-3) .choice-row-horizontal.is-selected:hover .choice-tile,
830
- :global(.access-contrast-6) .choice-row-horizontal.is-selected:hover .choice-tile {
831
- background: #fcfcd3;
826
+ background: #eceabf;
832
827
  }
833
828
 
834
829
  .choice-row-horizontal :global(p) {
@@ -841,34 +836,6 @@ $effect(() => {
841
836
  margin: 0;
842
837
  }
843
838
 
844
- .teacher-instructions {
845
- border-left: 4px solid #60a5fa;
846
- background: #eff6ff;
847
- padding: 0.5rem 0.75rem;
848
- border-radius: 4px;
849
- }
850
-
851
- .teacher-instructions-title {
852
- margin: 0 0 0.3rem;
853
- font-size: 0.9rem;
854
- font-weight: 600;
855
- }
856
-
857
- .vic-family-layout {
858
- margin-left: auto;
859
- margin-right: auto;
860
- max-width: 800px;
861
- }
862
-
863
- .vic-family-layout .choice-row {
864
- user-select: none;
865
- margin-right: 10px;
866
- }
867
-
868
- .sel-vic-family-layout {
869
- font-size: 1.5em;
870
- }
871
-
872
839
  .blank-inner {
873
840
  width: 100%;
874
841
  display: inline-flex;
@@ -878,36 +845,44 @@ $effect(() => {
878
845
  }
879
846
 
880
847
  .choice-radio-bottom {
881
- margin-top: 0.5rem;
848
+ margin-top: var(--mpb-horizontal-choice-radio-top-margin, 0.5rem);
849
+ }
850
+
851
+ .choice-radio-inline {
852
+ margin-top: var(--mpb-horizontal-choice-radio-top-margin, 0.5rem);
882
853
  }
883
854
 
884
855
  .layout-audio_blank_only .audio-container,
885
856
  .layout-stimulus_image_blank .audio-container {
886
857
  display: flex;
858
+ width: min(100%, var(--mpb-audio-instructions-max-width, 875px));
859
+ margin-left: auto;
860
+ margin-right: auto;
887
861
  justify-content: flex-end;
888
862
  }
889
863
 
890
864
  .layout-audio_blank_only .template-line,
891
865
  .layout-stimulus_image_blank .template-line {
892
866
  text-align: center;
893
- margin: 0.8rem 0 1.8rem;
867
+ margin: var(--mpb-audio-blank-template-margin-top, 0.8rem) 0
868
+ var(--mpb-audio-blank-template-margin-bottom, 1.8rem);
894
869
  }
895
870
 
896
871
  .layout-audio_blank_only .blank-slot,
897
872
  .layout-stimulus_image_blank .blank-slot {
898
- width: 10rem;
899
- border-bottom-width: 4px;
873
+ width: var(--mpb-blank-wide-width, 10rem);
874
+ border-bottom-width: var(--mpb-blank-underline-wide-width, 4px);
900
875
  }
901
876
 
902
877
  .layout-stimulus_image_blank {
903
878
  display: grid;
904
- grid-template-columns: minmax(210px, 1fr) auto;
879
+ grid-template-columns: minmax(var(--mpb-stimulus-min-column, 210px), 1fr) auto;
905
880
  grid-template-areas:
906
881
  'sentence audio'
907
882
  '. template'
908
883
  'choices choices';
909
- column-gap: 2rem;
910
- row-gap: 0.7rem;
884
+ column-gap: var(--mpb-stimulus-grid-column-gap, 2rem);
885
+ row-gap: var(--mpb-stimulus-grid-row-gap, 0.7rem);
911
886
  align-items: start;
912
887
  }
913
888
 
@@ -918,7 +893,7 @@ $effect(() => {
918
893
 
919
894
  .layout-stimulus_image_blank .sentence-line {
920
895
  grid-area: sentence;
921
- margin: 0.2rem 0 0;
896
+ margin: var(--mpb-stimulus-sentence-margin-top, 0.2rem) 0 0;
922
897
  }
923
898
 
924
899
  .layout-stimulus_image_blank .template-line {
@@ -930,17 +905,17 @@ $effect(() => {
930
905
 
931
906
  .layout-stimulus_image_blank fieldset {
932
907
  grid-area: choices;
933
- margin-top: 0.9rem;
908
+ margin-top: var(--mpb-stimulus-choices-margin-top, 0.9rem);
934
909
  }
935
910
 
936
911
  .layout-token_sequence {
937
912
  display: grid;
938
- grid-template-columns: minmax(260px, 1fr) auto;
913
+ grid-template-columns: minmax(var(--mpb-text-min-column, 260px), 1fr) auto;
939
914
  grid-template-areas:
940
915
  'template audio'
941
916
  'choices choices';
942
- column-gap: 1.5rem;
943
- row-gap: 0.8rem;
917
+ column-gap: var(--mpb-token-grid-column-gap, 1.5rem);
918
+ row-gap: var(--mpb-token-grid-row-gap, 0.8rem);
944
919
  align-items: start;
945
920
  }
946
921
 
@@ -952,34 +927,34 @@ $effect(() => {
952
927
 
953
928
  .layout-token_sequence .template-line {
954
929
  grid-area: template;
955
- margin: 0.6rem 0 0;
930
+ margin: var(--mpb-token-template-margin-top, 0.6rem) 0 0;
956
931
  justify-self: center;
957
932
  text-align: center;
958
933
  }
959
934
 
960
935
  .layout-token_sequence .template-line :global(span + span) {
961
- margin-left: 0.35rem;
936
+ margin-left: var(--mpb-token-inline-token-gap, 0.35rem);
962
937
  }
963
938
 
964
939
  .layout-token_sequence .blank-slot {
965
- width: 7rem;
966
- border-bottom-width: 4px;
967
- margin-left: 0.35rem;
940
+ width: var(--mpb-blank-standalone-width, 7rem);
941
+ border-bottom-width: var(--mpb-blank-underline-wide-width, 4px);
942
+ margin-left: var(--mpb-token-inline-token-gap, 0.35rem);
968
943
  }
969
944
 
970
945
  .layout-token_sequence fieldset {
971
946
  grid-area: choices;
972
- margin-top: 0.2rem;
947
+ margin-top: var(--mpb-token-choices-margin-top, 0.2rem);
973
948
  }
974
949
 
975
950
  .layout-inline_sentence.has-inline-audio {
976
951
  display: grid;
977
- grid-template-columns: minmax(260px, 1fr) auto;
952
+ grid-template-columns: minmax(var(--mpb-text-min-column, 260px), 1fr) auto;
978
953
  grid-template-areas:
979
954
  'template audio'
980
955
  'choices choices';
981
- column-gap: 1.5rem;
982
- row-gap: 0.65rem;
956
+ column-gap: var(--mpb-inline-grid-column-gap, 1.5rem);
957
+ row-gap: var(--mpb-inline-grid-row-gap, 0.65rem);
983
958
  align-items: start;
984
959
  }
985
960
 
@@ -991,13 +966,45 @@ $effect(() => {
991
966
 
992
967
  .layout-inline_sentence.has-inline-audio .template-line {
993
968
  grid-area: template;
994
- margin: 0.45rem 0 0;
969
+ margin: var(--mpb-inline-template-margin-top, 0.45rem) 0 0;
995
970
  text-align: center;
996
971
  justify-self: center;
997
972
  }
998
973
 
999
974
  .layout-inline_sentence.has-inline-audio fieldset {
1000
975
  grid-area: choices;
1001
- margin-top: 0.25rem;
976
+ margin-top: var(--mpb-inline-choices-margin-top, 0.25rem);
977
+ }
978
+
979
+ /* Match Learnosity responsive behavior for CQT audio-blank layouts:
980
+ at smaller widths, audio control shifts left and answer tiles stack. */
981
+ @media (max-width: 760px) {
982
+ .layout-audio_blank_only .audio-container,
983
+ .layout-stimulus_image_blank .audio-container,
984
+ .layout-token_sequence .audio-container,
985
+ .layout-inline_sentence.has-inline-audio .audio-container {
986
+ width: 100%;
987
+ margin-left: 0;
988
+ margin-right: 0;
989
+ justify-content: flex-start;
990
+ justify-self: start;
991
+ }
992
+
993
+ .layout-audio_blank_only .pie-choices,
994
+ .layout-token_sequence .pie-choices,
995
+ .layout-stimulus_image_blank .pie-choices,
996
+ .layout-inline_sentence.has-inline-audio .pie-choices {
997
+ flex-direction: column;
998
+ align-items: flex-start;
999
+ justify-content: flex-start;
1000
+ }
1001
+
1002
+ .layout-audio_blank_only .choice-row-horizontal,
1003
+ .layout-token_sequence .choice-row-horizontal,
1004
+ .layout-stimulus_image_blank .choice-row-horizontal,
1005
+ .layout-inline_sentence.has-inline-audio .choice-row-horizontal {
1006
+ width: min(100%, var(--mpb-narrow-choice-max-width, 230px));
1007
+ align-items: flex-start;
1008
+ }
1002
1009
  }
1003
1010
  </style>