%+H?veM(QpcW3(3*v8yK?wmU}`wFu4APt{V5fQHzJFm`=
z%1J+;AG@fnZ2joj;5%cgFEzym&m%TD*22+yo5nRcx_;W6s(7R@sN+9`JQETsOpPAa
z)2Hm2i+y)(`({bslMCQcemQQVp
zcf4Y)t**TASt;n?vP~D0_6rYIq}Xvd!l!&k7BvQQ;+)F25u2>+z(k|qVuxC3H4b(k
zS3R`Zy`g&I-z_E|q|~V+W^1Z!J->v(vwT!&A5Ah12K%kDhnEcsQfLwu`Lo`*!Exqy
z0wh0jwq=SBQ4Y&2$(7IR(pkM$=;vlHn{b!J9PaQ#I?juQDe7cTM>`iv8^Pw+3OQJl
z2qEZ7gWLi%G^G+%vN_=0;;rPjH>WLc5<}+RPOI%~=BcQ&@nr*3)!9X<1S70*EeV$T
zt@ZMbxjj|y`>yjjV_s?hp15@Nr#U3vTH_&~lxyQVJwi*bSoyJeqcb8~iQ2v1VCXOB
zlW9Z
g7UFLH(Cz<`PyW
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
V#imJlfiq$@C(oV0e*Mj@KLJJ`_No8?
diff --git a/tests/e2e/support/test.js b/tests/e2e/support/test.js
index 1b913e3..27ca902 100644
--- a/tests/e2e/support/test.js
+++ b/tests/e2e/support/test.js
@@ -8,18 +8,8 @@ const canonicalImageAsset = {
url: 'https://episphere.github.io/quest/images/FemaleBaldness1.png',
path: join(repositoryRoot, 'tests', 'e2e', 'assets', 'FemaleBaldness1.png'),
};
-const questRuntimeAssets = [
- {
- url: 'https://episphere.github.io/quest-dev/ActiveLogic.css',
- path: join(repositoryRoot, 'ActiveLogic.css'),
- },
- {
- url: 'https://episphere.github.io/quest-dev/Style1.css',
- path: join(repositoryRoot, 'Style1.css'),
- },
-];
const versionedQuestRuntimeAsset = /^https:\/\/cdn\.jsdelivr\.net\/gh\/episphere\/quest@v[^/]+\/(ActiveLogic|Style1)\.css$/;
-const FIREFOX_INSTALL_TRIGGER_DEPRECATION = /^\[JavaScript Warning: "InstallTrigger is deprecated and will be removed in the future\." \{file: "http:\/\/127\.0\.0\.1:\d+\/main\.js" line: 179\}\] \[source: http:\/\/127\.0\.0\.1:\d+\/main\.js\]$/;
+const FIREFOX_INSTALL_TRIGGER_DEPRECATION = /^\[JavaScript Warning: "InstallTrigger is deprecated and will be removed in the future\." \{file: "http:\/\/127\.0\.0\.1:\d+\/main\.js(?:\?[^"\]]+)?" line: \d+\}\] \[source: http:\/\/127\.0\.0\.1:\d+\/main\.js(?:\?[^\]]+)?\]$/;
const testServerPort = process.env.QUEST_PLAYWRIGHT_PORT ?? '4173';
const LOCAL_ORIGINS = new Set([
@@ -86,21 +76,6 @@ export async function installOfflineDiagnostics(page) {
});
});
- // A Connect render normally supplies prefetched Markdown and retains the
- // questionnaire URL. Quest uses the nonempty URL as the signal to fetch
- // its two runtime stylesheets. Fulfill that production branch with the
- // exact checked-in CSS while keeping the browser deny-by-default.
- for (const asset of questRuntimeAssets) {
- await page.route(asset.url, async (route) => {
- diagnostics.fulfilledExternalRequests.push(asset.url);
- await route.fulfill({
- status: 200,
- contentType: 'text/css',
- path: asset.path,
- });
- });
- }
-
await page.route(versionedQuestRuntimeAsset, async (route) => {
const url = route.request().url();
const fileName = new URL(url).pathname.split('/').at(-1);
diff --git a/tests/e2e/visual.spec.js b/tests/e2e/visual.spec.js
index e2c714c..e015318 100644
--- a/tests/e2e/visual.spec.js
+++ b/tests/e2e/visual.spec.js
@@ -56,6 +56,37 @@ test.describe('stable participant styling @visual', () => {
);
});
+ test('keeps keyboard focus visible on list and grid choices', async ({ page }, testInfo) => {
+ await openParticipant(page);
+ await waitInHarness(page, 550);
+ const listChoice = activeQuestion(page, 'CHOICE').locator('#CHOICE_1');
+ await listChoice.focus();
+ await page.keyboard.press('Space');
+ await expect(listChoice).toBeFocused();
+ await expect(listChoice).toBeChecked();
+ await stabilizeVisual(page, testInfo);
+
+ await expect(activeQuestion(page, 'CHOICE')).toHaveScreenshot(
+ 'participant-choice-keyboard-focus.png',
+ screenshotOptions,
+ );
+
+ await openParticipant(page, { fixture: 'gridResponsive.txt' });
+ await goNext(page);
+ await waitInHarness(page, 550);
+ const gridChoice = activeQuestion(page, 'GRID_RATE').locator('#GRID_WALK_1');
+ await gridChoice.focus();
+ await page.keyboard.press('Space');
+ await expect(gridChoice).toBeFocused();
+ await expect(gridChoice).toBeChecked();
+ await stabilizeVisual(page, testInfo);
+
+ await expect(activeQuestion(page, 'GRID_RATE')).toHaveScreenshot(
+ 'participant-grid-keyboard-focus.png',
+ screenshotOptions,
+ );
+ });
+
test('keeps validation and modal states visually reviewable', async ({ page }, testInfo) => {
await openParticipant(page, { fixture: 'validation.txt' });
await activeQuestion(page, 'BOUNDED').locator('#bounded').fill('9');
diff --git a/tests/e2e/visual.spec.js-snapshots/participant-choice-keyboard-focus-chromium-desktop.png b/tests/e2e/visual.spec.js-snapshots/participant-choice-keyboard-focus-chromium-desktop.png
new file mode 100644
index 0000000000000000000000000000000000000000..2fc0834c8bc0ba81c82b3f4901d8a344fd181872
GIT binary patch
literal 2254
zcmeHJX;2eJ6kY*AX+Z`o)?$E}C^|@qIC2Sw(8}q^QH02iqK0yX;U);-5N{BTAw>>B
z1cWH}6#@k1NCg8}j)0tk5ikS@%
zN*(~9U}JsqI{>8nU_8D<3a(>~3L*e@B-@-cKNFq3Fy4*#z#>|g!X&NQIS$#&%
z?$?g#UB_4?y|Fm7{RXz9NIT&1?#IEd;VJDJecP>{`Z&pi+YAO{ZeGFU&p#rU8U!V@
zr+1o{AXY+@_c4y{@(y0O%Xvl;3?c=qug@Om6Jo=E9;D$G-&r{B2SC&Ez%~HR$ru74
zqdW$Hl68nQz-T^K03h-Ih);4_l1R{>Zhz(
zyk{ei1KrcNSEa4Fmlz+N=SJhJFS5lkPC?KhGNXNv$-J5g!?Vx!
zA(p%uMk4F2kGbkXXu6O|!fN1TZtlxXUMboDovxwIi@n?sIUm_v{vd9?GPIul0D3?!
z(ywM|%6h3;daT!eG4snZiL&2nU5#ZB=ki0-<3i58f7zQ#DX3;=^m=+{(ww}XlxqRt
z!@cTH-uzwHNyZTp!f3gx8)drsQ<%MTm5#)06>hs38#5-1=+*Gv*1P!JEoNc;7J59e
zHe<;^R_Gt6&xCYN#lK>q;*P~}$~EFe)$A?N#wgC6Y9Zv3I9^c#8vrm?&UTl%eh|Nu
zXx+q{b@R~=nOSCni2=G31@)?jHIeP@QF2gHlkA5743fSp?GE~cV2+=Lm@qsVj%t85
zR4TX!u^##JItn_UR2Gc{Zt&p!4Q8$xoER_9{uZaFu5NDHP9_p;4ws^cMB>OKKkdGb
zu5M`%v`^mAitf|A&~!F;&4o&a7p7@v(QosbUi(RgBxdg@tqlfBj|crZP`T@lyHoi8
zszF(L!8JoN7A^*22?PRc2&KH+JX<^-FQNW^rRPgFEI+Sc=#+4Rq9qgx_2Iw|ci208
z@e(EZ>7koz?y35$OJOsLus095W{^ufA!W#N*hOi75oez1bGy(FV{4Pm5%bf79Vv8{
zOlxsKg`wHUk;FX@bJpLytsF2OifrtAKeAXnK4Nb7kg-f+_v{CCqbTC~yJbcF`q~^_
zY)_<>?XY?LYW=(nW>56m8K&xm029x$Y+h0p;@xy@$66xuI2b)YL)##hp$Zi%>-uvH
zdLwZSBj&0uZ*amYnO1`zt3FAI7#zzFN&zP0->(MNxBUkmwd!Qk8fqi@kTMwc@f9Ez
z%Zq-)_MD}-vX0we%ehMVs0HdU?xe02|6wn5nya!iLHA<;exEhsd{A*Wx%$Lmd3&GX
zUm5;TGJlJ$s=8FC3?7!{w}@INBw`rFN9#nSLAq;V>ckoVh{bwc4$1IWA}jfhNCW=o
VFT&-6nl@nu*jU(|Ec?bM=@-kuN9+Iq
literal 0
HcmV?d00001
diff --git a/tests/e2e/visual.spec.js-snapshots/participant-grid-keyboard-focus-chromium-desktop.png b/tests/e2e/visual.spec.js-snapshots/participant-grid-keyboard-focus-chromium-desktop.png
new file mode 100644
index 0000000000000000000000000000000000000000..454e8f497ecad8f48b60712e60b877f2769c2d24
GIT binary patch
literal 5632
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 {
+test.describe('Windows user-agent native parity @canonical @windows-a11y', () => {
test.beforeEach(async ({}, testInfo) => {
test.skip(
testInfo.project.name !== 'chromium-windows-ua',
- 'These checks characterize the Quest Windows user-agent branch used for JAWS support.',
+ 'These checks guard against user-agent-specific divergence; they do not simulate JAWS.',
);
});
- test('adds response-container tab stops and persists pointer selection', async ({ page }) => {
+ test('keeps only native list controls in the sequential focus order', async ({ page }) => {
await openParticipant(page);
- await expect(page.locator('.question.active .response')).toHaveCount(2);
- await expect(page.locator('.question.active .response').first()).toHaveAttribute('tabindex', '0');
+ await waitInHarness(page, 550);
+ const question = activeQuestion(page, 'CHOICE');
+ await expect(question.locator('.response')).toHaveCount(2);
+ await expect(question.locator('.response[tabindex]')).toHaveCount(0);
+ await expect(question.locator('#srFocusHelper')).toHaveCount(0);
+
+ await page.locator('#host-before').focus();
+ await page.keyboard.press('Tab');
+ const first = question.locator('#CHOICE_1');
+ const second = question.locator('#CHOICE_2');
+ await expect(first).toBeFocused();
+ await page.keyboard.press('ArrowRight');
+ await expect(second).toBeChecked();
+ await expect(second).toBeFocused();
+ await page.keyboard.press('Tab');
+ const next = question.getByRole('button', { name: 'Next question' });
+ await expect(next).toBeFocused();
+ await waitInHarness(page, 150);
+ await expect(next).toBeFocused();
- await selectLabeledResponse(page, 'Green');
- await expect(activeQuestion(page).locator('#CHOICE_2')).toBeChecked();
await expectHealthyHarness(page);
});
- test('moves the grid focus helper through the next row and terminal Next target after radio selections', async ({ page }) => {
+ test('keeps radio-grid arrows and row-to-row Tab traversal native', async ({ page }) => {
await openParticipant(page, { fixture: 'gridResponsive.txt' });
+ await waitInHarness(page, 550);
await goNext(page);
await waitInHarness(page, 550);
const question = activeQuestion(page, 'GRID_RATE');
- await question.locator('tr[data-question-id="GRID_WALK"] label', { hasText: 'Sometimes' }).click();
-
- await expect(question.locator('#srFocusHelper')).toBeFocused();
- await expect(question.locator('#qtextGRID_CYCLE #srFocusHelper')).toHaveCount(1);
- await question.locator('tr[data-question-id="GRID_CYCLE"] label', { hasText: 'Often' }).click();
-
- await expect(question.locator('#srFocusHelper')).toBeFocused();
- await expect(question.getByRole('button', { name: 'Next question' }).locator('#srFocusHelper')).toHaveCount(1);
+ await expect(question.locator('#srFocusHelper')).toHaveCount(0);
+ await expect(question.locator('.screen-reader-focus')).toHaveAttribute('tabindex', '-1');
+ await page.keyboard.press('Tab');
+ await expect(question.locator('#GRID_WALK_0')).toBeFocused();
+ await page.keyboard.press('ArrowRight');
+ await waitInHarness(page, 150);
+ await expect(question.locator('#GRID_WALK_1')).toBeChecked();
+ await expect(question.locator('#GRID_WALK_1')).toBeFocused();
+ await page.keyboard.press('Tab');
+ await expect(question.locator('#GRID_CYCLE_0')).toBeFocused();
await expectHealthyHarness(page);
});
- test('keeps checkbox-grid focus in an intermediate cell and moves it to Next at the terminal cell', async ({ page }) => {
+ test('keeps checkbox-grid selection on each native checkbox', async ({ page }) => {
await openParticipant(page, { fixture: 'gridCheckboxFocus.txt' });
+ await waitInHarness(page, 550);
await goNext(page);
await waitInHarness(page, 550);
const question = activeQuestion(page, 'GRID_CHECK');
+ const first = question.locator('#GRID_CHECK_ROW_A_0');
+ const second = question.locator('#GRID_CHECK_ROW_A_1');
+ const third = question.locator('#GRID_CHECK_ROW_B_0');
+ const terminal = question.locator('#GRID_CHECK_ROW_B_1');
- const firstCell = question.locator('tr[data-question-id="GRID_CHECK_ROW_A"] td.response').first();
- await firstCell.locator('label', { hasText: 'Phone' }).click();
- await expect(firstCell.locator('#srFocusHelper')).toHaveCount(1);
- await expect(question.locator('#srFocusHelper')).toBeFocused();
-
- const terminalCell = question.locator('tr[data-question-id="GRID_CHECK_ROW_B"] td.response').last();
- await terminalCell.locator('label', { hasText: 'Email' }).click();
- await expect(question.getByRole('button', { name: 'Next question' }).locator('#srFocusHelper')).toHaveCount(1);
- await expect(question.locator('#srFocusHelper')).toBeFocused();
- await expect(firstCell.locator('input[type="checkbox"]')).toBeChecked();
- await expect(terminalCell.locator('input[type="checkbox"]')).toBeChecked();
+ await expect(question.locator('#srFocusHelper')).toHaveCount(0);
+ await page.keyboard.press('Tab');
+ await expect(first).toBeFocused();
+ await page.keyboard.press('Space');
+ await waitInHarness(page, 150);
+ await expect(first).toBeChecked();
+ await expect(first).toBeFocused();
+ await page.keyboard.press('Tab');
+ await expect(second).toBeFocused();
+ await page.keyboard.press('Tab');
+ await expect(third).toBeFocused();
+ await page.keyboard.press('Tab');
+ await expect(terminal).toBeFocused();
+ await page.keyboard.press('Space');
+ await waitInHarness(page, 150);
+ await expect(terminal).toBeChecked();
+ await expect(terminal).toBeFocused();
+ await page.keyboard.press('Tab');
+ await expect(question.getByRole('button', { name: 'Next question' })).toBeFocused();
await expectHealthyHarness(page);
});
});
diff --git a/tests/fixtures/canonical/unansweredModals.txt b/tests/fixtures/canonical/unansweredModals.txt
new file mode 100644
index 0000000..4216ca1
--- /dev/null
+++ b/tests/fixtures/canonical/unansweredModals.txt
@@ -0,0 +1,10 @@
+// Distinction between a requested response and a required response.
+{"name":"TEST_UNANSWERED_MODALS"}
+
+[SOFT?] You may answer this question or continue without answering.
+(1) Optional response
+
+[HARD!] You must answer this question before continuing.
+(1) Required response
+
+[END,end] Unanswered-response modal testing complete.
diff --git a/tests/harness/participant.js b/tests/harness/participant.js
index 8911d3b..4445865 100644
--- a/tests/harness/participant.js
+++ b/tests/harness/participant.js
@@ -5,6 +5,7 @@ import '../../Style1.css';
import * as bootstrap from 'bootstrap';
import { transform } from '../../main.js';
+import { moduleParams } from '../../questionnaire.js';
import { getStateManager } from '../../stateManager.js';
globalThis.bootstrap = bootstrap;
@@ -175,10 +176,24 @@ function stateSnapshot() {
} : null;
}
+function runtimeSnapshot() {
+ const stateManager = getStateManager(true);
+ const processor = stateManager?.getQuestionProcessor();
+ return {
+ basePath: moduleParams.basePath ?? null,
+ processor: processor ? {
+ questionCount: processor.questions.length,
+ processedQuestionCount: processor.processedQuestions.size,
+ questionIds: processor.questions.map(({ questionID }) => questionID),
+ } : null,
+ };
+}
+
function snapshot() {
return serializable({
activeQuestionId: questRoot.querySelector('form.question.active')?.id ?? null,
focusedElement: describeTarget(document.activeElement),
+ runtime: runtimeSnapshot(),
state: stateSnapshot(),
logs,
});
diff --git a/tests/integration/accessibilityBehavior.spec.js b/tests/integration/accessibilityBehavior.spec.js
index cdaea81..ea4bb4f 100644
--- a/tests/integration/accessibilityBehavior.spec.js
+++ b/tests/integration/accessibilityBehavior.spec.js
@@ -22,60 +22,58 @@ describe('screen-reader and keyboard behavior', () => {
vi.useRealTimers();
});
- it('uses native list controls while adding Windows-specific refocus and announcements', async () => {
+ it('keeps focus on a native list control while announcing its changed state', async () => {
const quest = await renderFreshQuest();
- quest.moduleParams.isWindowsEnvironment = true;
const radio = quest.root.querySelector('#Q1_2');
+ radio.focus();
radio.click();
radio.dispatchEvent(new Event('change', { bubbles: true, cancelable: true }));
- await vi.advanceTimersByTimeAsync(100);
+ await vi.advanceTimersByTimeAsync(300);
expect(document.activeElement).toBe(radio);
expect(quest.root.querySelector('#ariaLiveSelectionAnnouncer').textContent).toContain('Second answer Selected.');
});
- it('moves the Windows table focus helper to the next visible row after a radio selection', async () => {
+ it('keeps focus on a native grid radio after its state changes', async () => {
const quest = await renderFreshQuest({
markdown: GRID_SURVEY,
persistedData: { treeJSON: treeAt('GRID') },
});
- quest.moduleParams.isWindowsEnvironment = true;
const firstRowChoice = quest.root.querySelector('#ROW_ONE_0');
+ firstRowChoice.focus();
firstRowChoice.click();
firstRowChoice.dispatchEvent(new Event('change', { bubbles: true, cancelable: true }));
- const helper = quest.root.querySelector('#srFocusHelper');
- await vi.advanceTimersByTimeAsync(100);
- expect(document.activeElement).toBe(helper);
- expect(helper.closest('tr')?.dataset.questionId).toBe('ROW_TWO');
- expect(helper.parentElement.tagName).toBe('TH');
+ await vi.advanceTimersByTimeAsync(300);
+ expect(document.activeElement).toBe(firstRowChoice);
+ expect(firstRowChoice.checked).toBe(true);
+ expect(quest.root.querySelector('#srFocusHelper')).toBeNull();
});
- it('moves the Windows table focus helper to Next after the final row selection', async () => {
+ it('keeps focus on a native final-row grid radio instead of advancing on selection', async () => {
const quest = await renderFreshQuest({
markdown: GRID_SURVEY,
persistedData: { treeJSON: treeAt('GRID') },
});
- quest.moduleParams.isWindowsEnvironment = true;
const finalRowChoice = quest.root.querySelector('#ROW_TWO_1');
+ finalRowChoice.focus();
finalRowChoice.click();
finalRowChoice.dispatchEvent(new Event('change', { bubbles: true, cancelable: true }));
- const helper = quest.root.querySelector('#srFocusHelper');
- await vi.advanceTimersByTimeAsync(100);
- expect(document.activeElement).toBe(helper);
- expect(helper.closest('button')).toBe(quest.root.querySelector('#GRID .next'));
+ await vi.advanceTimersByTimeAsync(300);
+ expect(document.activeElement).toBe(finalRowChoice);
+ expect(finalRowChoice.checked).toBe(true);
+ expect(quest.root.querySelector('#GRID .next')).not.toBe(document.activeElement);
});
- it('announces table selections without adding non-native selection commands on macOS paths', async () => {
+ it('announces table selections without adding non-native selection commands', async () => {
const quest = await renderFreshQuest({
markdown: GRID_SURVEY,
persistedData: { treeJSON: treeAt('GRID') },
});
- quest.moduleParams.isWindowsEnvironment = false;
const choice = quest.root.querySelector('#ROW_ONE_1');
choice.click();
@@ -86,7 +84,7 @@ describe('screen-reader and keyboard behavior', () => {
expect(choice.getAttribute('role')).toBeNull();
});
- it('uses Up and Down only to leave nested text fields, without overriding radio native keys', async () => {
+ it('leaves Up and Down under native text and radio control', async () => {
const quest = await renderFreshQuest();
const fieldset = quest.root.querySelector('#Q1 fieldset');
fieldset.innerHTML = `
@@ -99,14 +97,18 @@ describe('screen-reader and keyboard behavior', () => {
const secondText = fieldset.querySelector('#SECOND_TEXT');
secondText.focus();
- secondText.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'ArrowUp' }));
+ const textArrowUp = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'ArrowUp' });
+ secondText.dispatchEvent(textArrowUp);
await vi.advanceTimersByTimeAsync(0);
- expect(document.activeElement).toBe(fieldset.querySelector('#FIRST_RADIO'));
+ expect(textArrowUp.defaultPrevented).toBe(false);
+ expect(document.activeElement).toBe(secondText);
firstText.focus();
- firstText.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'ArrowDown' }));
+ const textArrowDown = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'ArrowDown' });
+ firstText.dispatchEvent(textArrowDown);
await vi.advanceTimersByTimeAsync(0);
- expect(document.activeElement).not.toBe(firstText);
+ expect(textArrowDown.defaultPrevented).toBe(false);
+ expect(document.activeElement).toBe(firstText);
const radio = fieldset.querySelector('#SECOND_RADIO');
radio.focus();
@@ -115,20 +117,18 @@ describe('screen-reader and keyboard behavior', () => {
expect(nativeArrow.defaultPrevented).toBe(false);
});
- it('reconstructs and restores question focus after closing the soft-response modal', async () => {
+ it.each(['softModal', 'hardModal'])('reconstructs and restores question focus after closing %s', async (modalId) => {
const quest = await renderFreshQuest();
- const modal = quest.root.querySelector('#softModal');
+ const modal = quest.root.querySelector(`#${modalId}`);
const focusTarget = quest.root.querySelector('#Q1 .screen-reader-focus');
// The initial render already schedules a 500 ms focus. Remove it so a
- // passing assertion proves the close handler's 100 ms + 500 ms chain.
+ // passing assertion proves the hidden-modal handler's 100 ms + 500 ms chain.
vi.clearAllTimers();
expect(document.activeElement).not.toBe(focusTarget);
- modal.style.display = 'block';
- modal.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ modal.dispatchEvent(new Event('hidden.bs.modal'));
- expect(modal.style.display).toBe('none');
await vi.advanceTimersByTimeAsync(100);
expect(document.activeElement).not.toBe(focusTarget);
await vi.advanceTimersByTimeAsync(500);
diff --git a/tests/integration/accessibilityBuilder.spec.js b/tests/integration/accessibilityBuilder.spec.js
index 43f8803..275ec91 100644
--- a/tests/integration/accessibilityBuilder.spec.js
+++ b/tests/integration/accessibilityBuilder.spec.js
@@ -1,4 +1,4 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
import { renderFreshQuest } from '../helpers/questRuntime.js';
async function loadAccessibilityFixture(markup) {
@@ -24,6 +24,7 @@ describe('accessible question text construction', () => {
expect(legend.textContent).toContain('Section heading');
expect(legend.textContent).toContain('Choose an answer.');
expect(fieldset.querySelectorAll('.screen-reader-focus')).toHaveLength(1);
+ expect(fieldset.querySelector('.screen-reader-focus').tabIndex).toBe(-1);
expect([...fieldset.querySelectorAll('br')].every((br) => br.getAttribute('aria-hidden') === 'true')).toBe(true);
expect(fieldset.querySelectorAll('br').length).toBeLessThanOrEqual(3);
});
@@ -90,7 +91,7 @@ describe('accessible question text construction', () => {
it('updates a raw forid in an existing legend and avoids rebuilding focus controls', async () => {
const { quest, accessibility } = await loadAccessibilityFixture(`
`);
const fieldset = quest.root.querySelector('fieldset');
@@ -119,112 +120,27 @@ describe('accessible question text construction', () => {
});
});
-describe('accessible selection focus and defensive paths', () => {
- beforeEach(() => vi.useFakeTimers());
+describe('accessible selection announcements and defensive paths', () => {
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
});
- it('keeps focus on a non-final table checkbox and announces its state', async () => {
+ it('announces table checkbox state without managing focus', async () => {
+ vi.useFakeTimers();
const { quest, accessibility } = await loadAccessibilityFixture(`
-
-
+
`);
const input = quest.root.querySelector('#CHECK_1');
- const event = { target: input, type: 'change', preventDefault: vi.fn() };
+ input.focus();
+ accessibility.updateAriaLiveSelectionAnnouncerTable(input.closest('.response'));
- accessibility.handleRadioCheckboxTableEvents(event);
-
- expect(event.preventDefault).toHaveBeenCalledOnce();
- await vi.advanceTimersByTimeAsync(100);
- expect(document.activeElement).toBe(quest.root.querySelector('#srFocusHelper'));
- expect(document.activeElement.closest('td')).toBe(input.closest('td'));
- await vi.advanceTimersByTimeAsync(150);
+ await vi.advanceTimersByTimeAsync(250);
expect(quest.root.querySelector('#ariaLiveSelectionAnnouncer').textContent).toBe('First Selected.');
- });
-
- it('moves focus from the final table checkbox to the active question Next button', async () => {
- const { quest, accessibility } = await loadAccessibilityFixture(`
-
-
-
- `);
-
- accessibility.handleRadioCheckboxTableEvents({
- target: quest.root.querySelector('#FINAL'),
- type: 'change',
- preventDefault: vi.fn(),
- });
-
- const helper = quest.root.querySelector('#srFocusHelper');
- await vi.advanceTimersByTimeAsync(100);
- expect(document.activeElement).toBe(helper);
- expect(helper.closest('button')).toBe(quest.root.querySelector('button.next'));
- });
-
- it('logs invalid table inputs and missing focus targets without throwing', async () => {
- const { quest, accessibility } = await loadAccessibilityFixture(`
-
-
- `);
- const invalid = quest.root.querySelector('#BAD');
- accessibility.handleRadioCheckboxTableEvents({ target: invalid, type: 'change', preventDefault: vi.fn() });
- expect(quest.errors.some(([message]) => message.includes('Invalid event type'))).toBe(true);
-
- accessibility.handleRadioCheckboxTableEvents({
- target: quest.root.querySelector('#RADIO'),
- type: 'change',
- preventDefault: vi.fn(),
- });
- await vi.advanceTimersByTimeAsync(100);
- expect(quest.errors.some(([message]) => message.includes('Next question cell not found'))).toBe(true);
-
- quest.root.querySelector('#srFocusHelper').remove();
- accessibility.handleRadioCheckboxTableEvents({
- target: quest.root.querySelector('tr:last-child input'),
- type: 'change',
- preventDefault: vi.fn(),
- });
- await vi.advanceTimersByTimeAsync(100);
- expect(quest.errors.some(([message]) => message.includes('Focus helper not found'))).toBe(true);
- });
-
- it('logs missing active-question and Next-button targets for final selections', async () => {
- const { quest, accessibility } = await loadAccessibilityFixture(`
-
-
- `);
- const event = { target: quest.root.querySelector('#ONLY'), type: 'change', preventDefault: vi.fn() };
-
- accessibility.handleRadioCheckboxTableEvents(event);
- await vi.advanceTimersByTimeAsync(100);
- expect(quest.errors.some(([message]) => message === 'Active question not found')).toBe(true);
-
- quest.root.insertAdjacentHTML('afterbegin', '');
- accessibility.handleRadioCheckboxTableEvents(event);
- await vi.advanceTimersByTimeAsync(100);
- expect(quest.errors.some(([message]) => message === 'Next question button not found')).toBe(true);
- });
-
- it('walks past non-response siblings when ArrowUp leaves a nested text field', async () => {
- const { quest, accessibility } = await loadAccessibilityFixture(`
- spacing
-
- `);
- const current = quest.root.querySelector('#CURRENT');
- current.focus();
-
- accessibility.handleUpDownArrowKeys({ key: 'ArrowUp', target: current, preventDefault: vi.fn() });
- await vi.advanceTimersByTimeAsync(0);
- expect(document.activeElement).toBe(quest.root.querySelector('#PREVIOUS'));
+ expect(document.activeElement).toBe(input);
});
it('returns safely when announcer dependencies are absent and clears an existing region', async () => {
diff --git a/tests/integration/authoredMarkupFidelity.spec.js b/tests/integration/authoredMarkupFidelity.spec.js
new file mode 100644
index 0000000..7b1c921
--- /dev/null
+++ b/tests/integration/authoredMarkupFidelity.spec.js
@@ -0,0 +1,237 @@
+import { describe, expect, it, vi } from 'vitest';
+import { renderFreshQuest } from '../helpers/questRuntime.js';
+
+const authoredMarkupSurvey = `
+{"name":"MARKUP_FIDELITY"}
+
+[SOURCE] Source response.
+|__|id=SOURCE_VALUE|
+
+[RICH] Heading detail
+
+First production-style paragraph keeps italic detail and underlined detail.
+
+Second production-style paragraph contains |displayif=doesNotEqual(SOURCE,"")|{$SOURCE_VALUE}| and includes |popup|more information|Help title|Authored popup detail|.
+
+(1) Keep later response
+(2) Clear later response
+
+[SUMMARY] |displayif=doesNotEqual(SOURCE,"")|Chosen source: {$SOURCE_VALUE}|
+|displayif=equals(SOURCE,"")|Fallback participant|
+
+[END,end] Done.
+`;
+
+async function activeQuestion(quest, questionID) {
+ if (questionID) {
+ await vi.waitFor(() => {
+ expect(quest.root.querySelector('form.question.active')?.id).toBe(questionID);
+ });
+ }
+ return quest.root.querySelector('form.question.active');
+}
+
+async function next(quest) {
+ (await activeQuestion(quest)).querySelector('.next').click();
+}
+
+async function back(quest) {
+ (await activeQuestion(quest)).querySelector('.previous').click();
+}
+
+function nonWhitespaceChildOrder(element) {
+ return Array.from(element.childNodes)
+ .filter((node) => node.nodeType !== Node.TEXT_NODE || node.textContent.trim())
+ .filter((node) => !node.classList?.contains('screen-reader-focus'))
+ .map((node) => (
+ node.nodeType === Node.TEXT_NODE
+ ? `text:${node.textContent.replace(/\s+/g, ' ').trim()}`
+ : node.tagName
+ ));
+}
+
+function richSignature(question) {
+ const fieldset = question.querySelector('fieldset');
+ const legend = fieldset.querySelector(':scope > legend');
+ const helper = fieldset.querySelector('.screen-reader-focus');
+ const firstResponse = fieldset.querySelector('.response');
+ const pipedValue = legend.querySelector('[original-forid="SOURCE_VALUE"], [forid="SOURCE_VALUE"]');
+ const popover = legend.querySelector('[data-bs-toggle="popover"]');
+
+ return {
+ legendCount: fieldset.querySelectorAll(':scope > legend').length,
+ helperCount: fieldset.querySelectorAll('.screen-reader-focus').length,
+ helperTabIndex: helper.tabIndex,
+ helperImmediatelyBeforeResponse: helper.nextElementSibling === firstResponse,
+ helperParentTag: helper.parentElement.tagName,
+ helperPrecedesPopover: Boolean(popover && (helper.compareDocumentPosition(popover) & Node.DOCUMENT_POSITION_FOLLOWING)),
+ legendText: legend.textContent.replace(/\s+/g, ' ').trim(),
+ legendChildOrder: nonWhitespaceChildOrder(legend),
+ fieldsetBreakCount: fieldset.querySelectorAll(':scope > br').length,
+ legendBreakCount: legend.querySelectorAll('br').length,
+ boldTexts: Array.from(legend.querySelectorAll('b'), (element) => element.textContent),
+ italicTexts: Array.from(legend.querySelectorAll('i'), (element) => element.textContent),
+ underlinedTexts: Array.from(legend.querySelectorAll('u'), (element) => element.textContent),
+ pipedValue: pipedValue && {
+ text: pipedValue.textContent,
+ parentTag: pipedValue.parentElement.tagName,
+ boldText: pipedValue.closest('b')?.textContent,
+ displayIf: pipedValue.closest('[displayif]')?.getAttribute('displayif'),
+ visible: pipedValue.closest('[displayif]')?.style.display !== 'none',
+ },
+ popover: popover && {
+ text: popover.textContent,
+ title: popover.getAttribute('title'),
+ content: popover.getAttribute('data-bs-content'),
+ role: popover.getAttribute('role'),
+ tabindex: popover.getAttribute('tabindex'),
+ trigger: popover.getAttribute('data-bs-trigger'),
+ parentTag: popover.parentElement.tagName,
+ },
+ };
+}
+
+function summarySignature(question) {
+ const fieldset = question.querySelector('fieldset');
+ const legend = fieldset.querySelector(':scope > legend');
+ const displayIfs = Array.from(legend.querySelectorAll('[displayif]'));
+ const helper = fieldset.querySelector('.screen-reader-focus');
+ const populated = displayIfs.find((element) => element.getAttribute('displayif') === "doesNotEqual(SOURCE,'')");
+ const fallback = displayIfs.find((element) => element.getAttribute('displayif') === "equals(SOURCE,'')");
+
+ return {
+ legendCount: fieldset.querySelectorAll(':scope > legend').length,
+ helperCount: fieldset.querySelectorAll('.screen-reader-focus').length,
+ helperTabIndex: helper.tabIndex,
+ breakCount: fieldset.querySelectorAll('br').length,
+ populated: { text: populated.textContent, display: populated.style.display },
+ fallback: { text: fallback.textContent, display: fallback.style.display },
+ };
+}
+
+function updateSource(input, value) {
+ input.value = value;
+ input.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
+}
+
+describe('authored question markup fidelity', () => {
+ it('keeps production-style rich markup stable through navigation and source edits', async () => {
+ const quest = await renderFreshQuest({ markdown: authoredMarkupSurvey });
+ const sourceInput = (await activeQuestion(quest, 'SOURCE')).querySelector('#SOURCE_VALUE');
+
+ updateSource(sourceInput, 'Original participant');
+ await next(quest);
+
+ const rich = await activeQuestion(quest, 'RICH');
+ const richBeforeNavigation = richSignature(rich);
+ const legendText = rich.querySelector('legend').textContent;
+ const responseControls = Array.from(rich.querySelectorAll('.response input'));
+
+ expect(richBeforeNavigation).toEqual({
+ legendCount: 1,
+ helperCount: 1,
+ helperTabIndex: -1,
+ helperImmediatelyBeforeResponse: false,
+ helperParentTag: 'LEGEND',
+ helperPrecedesPopover: true,
+ legendText: 'Heading detail First production-style paragraph keeps italic detail and underlined detail. Second production-style paragraph contains Original participant and includes more information.',
+ legendChildOrder: ['B', 'BR', 'text:First production-style paragraph keeps', 'I', 'text:and', 'U', 'text:.', 'text:Second production-style paragraph contains', 'B', 'text:and includes', 'A', 'text:.'],
+ fieldsetBreakCount: 1,
+ legendBreakCount: 1,
+ boldTexts: ['Heading detail', 'Original participant'],
+ italicTexts: ['italic detail'],
+ underlinedTexts: ['underlined detail'],
+ pipedValue: {
+ text: 'Original participant',
+ parentTag: 'SPAN',
+ boldText: 'Original participant',
+ displayIf: "doesNotEqual(SOURCE,'')",
+ visible: true,
+ },
+ popover: {
+ text: 'more information',
+ title: 'Help title',
+ content: 'Authored popup detail',
+ role: 'button',
+ tabindex: '0',
+ trigger: 'manual',
+ parentTag: 'LEGEND',
+ },
+ });
+ expect(legendText).toContain('Heading detail\n\nFirst production-style paragraph');
+ expect(legendText).toContain('underlined detail.\n\nSecond production-style paragraph');
+ expect(responseControls).toHaveLength(2);
+ for (const control of responseControls) {
+ expect(control.labels).toHaveLength(1);
+ expect(control.labels[0].htmlFor).toBe(control.id);
+ expect(control.labels[0].control).toBe(control);
+ }
+
+ responseControls[0].click();
+ await next(quest);
+ const summary = await activeQuestion(quest, 'SUMMARY');
+
+ expect(summarySignature(summary)).toEqual({
+ legendCount: 1,
+ helperCount: 1,
+ helperTabIndex: -1,
+ breakCount: 1,
+ populated: { text: 'Chosen source: Original participant', display: '' },
+ fallback: { text: 'Fallback participant', display: 'none' },
+ });
+
+ await back(quest);
+ const reactivatedRich = await activeQuestion(quest, 'RICH');
+ expect(richSignature(reactivatedRich)).toEqual(richBeforeNavigation);
+ expect(reactivatedRich.querySelector('#RICH_1').checked).toBe(true);
+
+ await back(quest);
+ const returnedSource = await activeQuestion(quest, 'SOURCE');
+ updateSource(returnedSource.querySelector('#SOURCE_VALUE'), '');
+ await next(quest);
+
+ const richWithoutSource = await activeQuestion(quest, 'RICH');
+ expect(richWithoutSource.querySelectorAll(':scope > fieldset > legend')).toHaveLength(1);
+ expect(richWithoutSource.querySelectorAll('.screen-reader-focus')).toHaveLength(1);
+ expect(richWithoutSource.querySelector('#RICH_1').checked).toBe(false);
+ expect(richWithoutSource.querySelector('#RICH_2').checked).toBe(false);
+ expect(quest.state.getSurveyState().RICH).toBeUndefined();
+ expect(richSignature(richWithoutSource).pipedValue).toMatchObject({ text: '', visible: false });
+
+ await next(quest);
+ const fallbackSummary = await activeQuestion(quest, 'SUMMARY');
+ const fallbackSignature = summarySignature(fallbackSummary);
+ expect(fallbackSignature).toEqual({
+ legendCount: 1,
+ helperCount: 1,
+ helperTabIndex: -1,
+ breakCount: 1,
+ populated: { text: 'Chosen source: ', display: 'none' },
+ fallback: { text: 'Fallback participant', display: '' },
+ });
+
+ await back(quest);
+ await back(quest);
+ const sourceForUpdate = await activeQuestion(quest, 'SOURCE');
+ updateSource(sourceForUpdate.querySelector('#SOURCE_VALUE'), 'Updated participant');
+ await next(quest);
+
+ const richWithUpdatedSource = await activeQuestion(quest, 'RICH');
+ expect(richSignature(richWithUpdatedSource)).toMatchObject({
+ legendCount: 1,
+ helperCount: 1,
+ helperTabIndex: -1,
+ pipedValue: { text: 'Updated participant', visible: true },
+ });
+ expect(richWithUpdatedSource.querySelector('#RICH_1').checked).toBe(false);
+
+ await next(quest);
+ const updatedSummary = await activeQuestion(quest, 'SUMMARY');
+ expect(summarySignature(updatedSummary)).toEqual({
+ ...fallbackSignature,
+ populated: { text: 'Chosen source: Updated participant', display: '' },
+ fallback: { text: 'Fallback participant', display: 'none' },
+ });
+ expect(quest.errors).toEqual([]);
+ });
+});
diff --git a/tests/integration/eventHandlers.spec.js b/tests/integration/eventHandlers.spec.js
index fd402e3..1cd8e49 100644
--- a/tests/integration/eventHandlers.spec.js
+++ b/tests/integration/eventHandlers.spec.js
@@ -8,6 +8,42 @@ const TEXT_SURVEY = `
[END,end] Done.
`;
+const NATIVE_ARROW_SURVEY = `
+{"name":"EVENT_NATIVE_ARROWS"}
+[NOTES?] Enter two short notes.
+|___|notes|
+[END,end] Done.
+`;
+
+const NATIVE_SELECT_SURVEY = `
+{"name":"EVENT_NATIVE_SELECT"}
+[STATE?] In which state do you live?
+|state|id=home_state|
+[END,end] Done.
+`;
+
+const CHOICE_LINKED_TEXT_SURVEY = `
+{"name":"EVENT_CHOICE_LINKED_TEXT"}
+[OTHER?] Choose an option and add details if needed.
+(1:OTHER_GROUP|OTHER_LABEL) Other details
+(2) No additional details
+[END,end] Done.
+`;
+
+const CHECKBOX_LINKED_TEXT_SURVEY = `
+{"name":"EVENT_CHECKBOX_LINKED_TEXT"}
+[OTHER?] Choose an option and add details if needed.
+[1:OTHER_GROUP|OTHER_LABEL] Other details
+[2] No additional details
+[END,end] Done.
+`;
+
+const POPOVER_SURVEY = `
+{"name":"EVENT_POPOVER"}
+[HELP] Read |popup|more information|Help title|Synthetic help text|.
+[END,end] Done.
+`;
+
describe('delegated runtime event handling', () => {
afterEach(() => {
vi.clearAllTimers();
@@ -100,6 +136,111 @@ describe('delegated runtime event handling', () => {
expect(phone.value).toBe('555-555-');
});
+ it('does not cancel ArrowDown on a standalone native textarea (CONNECT-1587)', async () => {
+ const quest = await renderFreshQuest({ markdown: NATIVE_ARROW_SURVEY });
+ const textarea = quest.root.querySelector('#notes');
+ const arrowDown = new KeyboardEvent('keydown', {
+ bubbles: true,
+ cancelable: true,
+ key: 'ArrowDown',
+ });
+
+ expect(textarea.dispatchEvent(arrowDown)).toBe(true);
+ expect(arrowDown.defaultPrevented).toBe(false);
+ });
+
+ it('does not cancel native select navigation, activation, or dismissal keys (CONNECT-1587)', async () => {
+ const quest = await renderFreshQuest({ markdown: NATIVE_SELECT_SURVEY });
+ const select = quest.root.querySelector('#home_state');
+
+ for (const key of ['ArrowDown', 'ArrowUp', ' ', 'Enter', 'Escape']) {
+ const event = new KeyboardEvent('keydown', {
+ bubbles: true,
+ cancelable: true,
+ key,
+ });
+
+ expect(select.dispatchEvent(event)).toBe(true);
+ expect(event.defaultPrevented).toBe(false);
+ }
+ });
+
+ it('does not cancel arrows or move focus from a choice-linked native textarea (CONNECT-1587)', async () => {
+ vi.useFakeTimers();
+ const quest = await renderFreshQuest({ markdown: CHOICE_LINKED_TEXT_SURVEY });
+ const textarea = quest.root.querySelector('#OTHER_TEXT');
+ vi.clearAllTimers();
+ textarea.value = 'first\nsecond';
+ textarea.focus();
+ textarea.setSelectionRange(0, 0);
+
+ for (const key of ['ArrowDown', 'ArrowUp']) {
+ const event = new KeyboardEvent('keydown', {
+ bubbles: true,
+ cancelable: true,
+ key,
+ });
+
+ expect(textarea.dispatchEvent(event)).toBe(true);
+ expect(event.defaultPrevented).toBe(false);
+ await vi.advanceTimersByTimeAsync(0);
+ expect(document.activeElement).toBe(textarea);
+ }
+ });
+
+ it('keeps keyboard activation on a linked checkbox while retaining pointer-only text focus', async () => {
+ vi.useFakeTimers();
+ const quest = await renderFreshQuest({ markdown: CHECKBOX_LINKED_TEXT_SURVEY });
+ const checkbox = quest.root.querySelector('#OTHER_GROUP_1');
+ const label = quest.root.querySelector('#OTHER_LABEL');
+ const textarea = quest.root.querySelector('#OTHER_TEXT');
+ vi.clearAllTimers();
+
+ checkbox.focus();
+ checkbox.click();
+ await vi.advanceTimersByTimeAsync(0);
+ expect(checkbox.checked).toBe(true);
+ expect(document.activeElement).toBe(checkbox);
+
+ checkbox.click();
+ expect(checkbox.checked).toBe(false);
+ label.dispatchEvent(new Event('pointerdown', { bubbles: true }));
+ checkbox.click();
+ await vi.advanceTimersByTimeAsync(0);
+ expect(checkbox.checked).toBe(true);
+ expect(document.activeElement).toBe(textarea);
+ });
+
+ it('gives a role-button popover explicit native-equivalent activation and dismissal', async () => {
+ const quest = await renderFreshQuest({ markdown: POPOVER_SURVEY });
+ const trigger = quest.root.querySelector('[data-bs-toggle="popover"]');
+ const instance = bootstrap.Popover.getInstance(trigger);
+ expect(instance).not.toBeNull();
+ expect(trigger.dataset.bsTrigger).toBe('manual');
+
+ trigger.focus();
+ expect(trigger.classList.contains('show')).toBe(false);
+
+ const space = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: ' ' });
+ expect(trigger.dispatchEvent(space)).toBe(false);
+ expect(space.defaultPrevented).toBe(true);
+ expect(trigger.classList.contains('show')).toBe(true);
+
+ const escape = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'Escape' });
+ expect(trigger.dispatchEvent(escape)).toBe(false);
+ expect(escape.defaultPrevented).toBe(true);
+ expect(trigger.classList.contains('show')).toBe(false);
+ expect(document.activeElement).toBe(trigger);
+
+ const click = new MouseEvent('click', { bubbles: true, cancelable: true });
+ expect(trigger.dispatchEvent(click)).toBe(false);
+ expect(click.defaultPrevented).toBe(true);
+ expect(trigger.classList.contains('show')).toBe(true);
+
+ trigger.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
+ expect(trigger.classList.contains('show')).toBe(false);
+ });
+
it('updates the live selection announcement without requiring listeners on individual controls', async () => {
vi.useFakeTimers();
const quest = await renderFreshQuest();
@@ -116,13 +257,13 @@ describe('delegated runtime event handling', () => {
expect(quest.root.querySelector('#ariaLiveSelectionAnnouncer').textContent).toContain('Second answer Unselected.');
});
- it('builds a legend, a single hidden focus stop, and accessible break semantics', async () => {
+ it('builds a legend, a programmatic-only question focus target, and accessible break semantics', async () => {
const quest = await renderFreshQuest();
const fieldset = quest.root.querySelector('#Q1 fieldset');
expect(fieldset.querySelector('legend.question-text')?.textContent).toContain('Choose one answer');
expect(fieldset.querySelectorAll('.screen-reader-focus')).toHaveLength(1);
- expect(fieldset.querySelector('.screen-reader-focus').tabIndex).toBe(0);
+ expect(fieldset.querySelector('.screen-reader-focus').tabIndex).toBe(-1);
expect([...fieldset.querySelectorAll('br')].every((br) => br.getAttribute('aria-hidden') === 'true')).toBe(true);
expect(quest.root.querySelectorAll('#srAnnouncerContainer [aria-live="polite"]')).toHaveLength(2);
});
diff --git a/tests/integration/questionProcessor.spec.js b/tests/integration/questionProcessor.spec.js
index 42708d9..22ac1d4 100644
--- a/tests/integration/questionProcessor.spec.js
+++ b/tests/integration/questionProcessor.spec.js
@@ -159,6 +159,7 @@ describe('QuestionProcessor constructs', () => {
expect(intro.querySelector('[data-encoded-expression]')).not.toBeNull();
expect(intro.querySelector('.displayif').getAttribute('displayif')).toBe('equals(SHOW,1)');
expect(intro.querySelector('[data-bs-toggle="popover"]').getAttribute('data-bs-content')).toBe('Synthetic help text');
+ expect(intro.querySelector('[data-bs-toggle="popover"]').getAttribute('data-bs-trigger')).toBe('manual');
expect(intro.querySelector('img').src).toBe('https://example.org/test.png');
expect(intro.querySelector('input[type="hidden"]').getAttribute('skipto')).toBe('TARGET');
expect(grid.dataset.grid).toBe('true');
diff --git a/tests/integration/questionnaireHelpers.spec.js b/tests/integration/questionnaireHelpers.spec.js
index 9a58483..71bb912 100644
--- a/tests/integration/questionnaireHelpers.spec.js
+++ b/tests/integration/questionnaireHelpers.spec.js
@@ -197,6 +197,6 @@ describe('questionnaire runtime helpers', () => {
expect(isMobileDevice()).toBe(true);
window.matchMedia.mockReturnValueOnce({ matches: false });
expect(isMobileDevice()).toBe(false);
- expect(window.matchMedia).toHaveBeenCalledWith('(max-width: 576px)');
+ expect(window.matchMedia).toHaveBeenCalledWith('(max-width: 575.98px)');
});
});
diff --git a/tests/knownDefects/registry.js b/tests/knownDefects/registry.js
index eb4120a..13f5573 100644
--- a/tests/knownDefects/registry.js
+++ b/tests/knownDefects/registry.js
@@ -24,10 +24,6 @@ export const runtimeDefects = Object.freeze({
treePrune: defect('QD-TREE-002', 'Tree.prune branch removal', 'Pruning does not return to the authored predecessor with the expected sibling structure intact.'),
treeHasNext: defect('QD-TREE-003', 'Tree.hasNext non-mutating lookahead', 'Lookahead reports false for the first root child even though a next value exists.'),
gridRowCondition: defect('QD-GRID-001', 'Radio-grid row displayif encoding', 'The row condition is encoded more than once and cannot be decoded to the authored expression.'),
- gridBackFocusLifecycle: defect('QD-GRID-002', 'Grid Back focus-helper lifecycle', 'Returning to a previously answered grid restores its values but logs that the focus helper is missing instead of preserving a valid focus-management node.', {
- sourcePaths: ['prod/module2.txt'],
- questionId: 'D_981441822',
- }),
mathDotValue: defect('QD-MATH-001', 'MathJS dot-notation value lookup', 'A valid leaf in a one-property response object is not returned.'),
mathDotExists: defect('QD-MATH-002', 'MathJS dot-notation existence lookup', 'exists() does not recognize a valid nested response leaf.'),
mathMonthRange: defect('QD-MATH-003', 'dateCompare documented month range', 'dateCompare accepts month 12 even though its contract documents zero through eleven.'),
@@ -52,7 +48,16 @@ export const runtimeDefects = Object.freeze({
submitFocusRestore: defect('QD-A11Y-001', 'Submit-dialog Escape focus restoration', 'The dialog closes on Escape but leaves focus inactive instead of returning it to the Submit Survey trigger.'),
modalQuestionFocusRace: defect('QD-A11Y-002', 'Question-to-modal focus handoff', 'A pending delayed question-focus callback can run after a response dialog opens and move focus away from the dialog title.'),
staleSelectionAnnouncement: defect('QD-A11Y-003', 'Selection announcement after navigation', 'A delayed selection announcement can repopulate the live region after Next or Back explicitly clears it.'),
- gridDeferredFocusAfterNavigation: defect('QD-A11Y-004', 'Windows grid focus-helper after navigation', 'A pending Windows grid-focus callback can move the shared helper back into an inactive grid after the participant advances.'),
+ textareaReset: defect('QD-RESET-001', 'Standalone textarea Reset behavior', 'Standalone textarea questions do not receive a Reset action, and the existing reset routine does not clear textarea values.', {
+ sourcePaths: [
+ 'prod/module1.txt',
+ 'prod/module1Spanish.txt',
+ 'prod/module2026ROIPreferences.txt',
+ 'prod/module2026ROIPreferencesSpanish.txt',
+ ],
+ questionIds: ['D_868232409', 'D_233198706', 'D_395168461'],
+ automatedContract: 'A standalone textarea question offers Reset, and keyboard activation clears both its visible value and active response state.',
+ }),
authoringFallbackClear: defect('QD-AUTHOR-001', 'Authoring clear-memory operation after localforage fallback', 'The fallback storage adapter has no removeItem method, so Clear Memory throws after initialization falls back.', {
source: 'index.html authoring interface',
}),
@@ -65,14 +70,20 @@ export const axeDefects = Object.freeze({
validationContrast: defect('QD-AXE-004', 'Validation message contrast', 'The visible validation message does not meet the required color contrast.', { ruleId: 'color-contrast', impact: 'serious', targets: ['.validation-container > span'] }),
validationLabel: defect('QD-AXE-005', 'Bounded numeric input label', 'The input relies on a title-only label relationship.', { ruleId: 'label-title-only', impact: 'serious', targets: ['#bounded'] }),
imageAlt: defect('QD-AXE-006', 'Question image text alternative', 'QuestionProcessor emits an image without an alternative text attribute.', { ruleId: 'image-alt', impact: 'critical', targets: ['#PLAIN img'] }),
+ actionHoverContrast: defect('QD-AXE-007', 'Participant action hover contrast', 'The white action-button text does not retain sufficient contrast against the lighter hover background.', { ruleId: 'color-contrast', impact: 'serious', targets: ['.next'] }),
});
export const accessibilityDefects = Object.freeze({
compoundQuestionContext: defect('QD-A11Y-005', 'Compound-question radio context', 'Radio choices in a multi-subgroup form are named only by their response option, not the subgroup prompt that gives the choice its meaning.', {
- sourcePaths: ['prod/moduleDietScreener', 'prod/moduleDietScreenerSpanish.txt'],
- questionId: 'D_916948380',
+ sourcePaths: [
+ 'prod/moduleDietScreener',
+ 'prod/moduleDietScreenerSpanish.txt',
+ 'prod/moduleQoL.txt',
+ 'prod/moduleQoLSpanish.txt',
+ ],
+ questionIds: ['D_916948380', 'D_284353934'],
automatedContract: 'Each radio choice exposes both its subgroup prompt and its response option in its accessible name or equivalent accessible context.',
- manualContract: 'Verify that VoiceOver and JAWS announce the food or sub-question prompt together with the selected frequency when moving across a compound form.',
+ manualContract: 'Verify that VoiceOver and JAWS announce the food or sub-question prompt together with each response option when moving across a compound form.',
}),
1079: defect('CONNECT-1079', 'Quest 2 participant choice semantics', 'Choice inputs are not consistently exposed to assistive technology with their native role, accessible name, and checked state.', {
issue: 'https://github.com/episphere/connect/issues/1079',
@@ -82,14 +93,6 @@ export const accessibilityDefects = Object.freeze({
manualContract: 'Recheck real VoiceOver/Safari and JAWS/Chrome or Edge announcement, focus, and activation after the PWA redesign.',
limitation: 'Playwright accessibility trees do not run VoiceOver or JAWS and cannot validate their command-routing modes.',
}),
- 1587: defect('CONNECT-1587', 'Native participant keyboard behavior', 'Quest delegated key handling suppresses browser-native input behavior.', {
- issue: 'https://github.com/episphere/connect/issues/1587',
- title: 'Keyboard Navigation for Quest',
- scope: 'Keyboard-only operation without requiring a screen reader.',
- automatedContract: 'Focused native radios, checkboxes, and selects retain their standard Space and arrow-key behavior.',
- manualContract: 'Test Tab/Shift+Tab, Enter, Space, radio arrows, checkbox Space, and native select keys separately from screen-reader commands.',
- limitation: 'VoiceOver Quick Nav and the JAWS Virtual Cursor alter key routing; those are recorded in the manual matrix instead of treated as raw browser key events.',
- }),
});
export const allKnownDefects = Object.freeze([
diff --git a/tests/knownDefects/runtime.spec.js b/tests/knownDefects/runtime.spec.js
index a48c6fa..132c51b 100644
--- a/tests/knownDefects/runtime.spec.js
+++ b/tests/knownDefects/runtime.spec.js
@@ -15,6 +15,13 @@ const expectedCovidGridResponseIds = [
// contain this identical, truncated display condition for D_114280729.
const MALFORMED_COVID_GRID_COMPLEMENT = 'someSelected("D_488415137_0","D_488415137_1","D_167695804_0","D_167695804_1","D_730334054_0","D_730334054_1","D_215996690_0","D_215996690_1","D_462737492_0","D_462737492_1","D_469675296_0","D_469675296_1","D_962475128_0","D_962475128_1","D_989576239_0","D_989576239_1","D_338613869_0","D_338613869_1","D_126794793_0","D_126794793_1","D_218793117_0","D_218793117_1","D_524096053_0","SRVCOV_COV19C1_V1R0_1,1","D_814101706_0","D_814101706_1","D_635026188_0","D_238135048_0","D_238135048_1","D_632714520_0","D_632714520_1';
+const STANDALONE_TEXTAREA_SURVEY = `
+{"name":"TEXTAREA_RESET"}
+[NOTES?] Enter two short notes.
+|___|notes|
+[END,end] Done.
+`;
+
function buildBranchedTree() {
const tree = new Tree();
tree.add(['Q1', 'Q2', 'Q3']);
@@ -157,6 +164,25 @@ describe('characterized Quest runtime defects', () => {
expect(evaluateCondition('equals(PRIOR,"yes")')).toBe(true);
});
+ it.fails(`${runtimeDefects.textareaReset.localDefectId}: renders Reset for a standalone textarea`, async () => {
+ const quest = await renderFreshQuest({ markdown: STANDALONE_TEXTAREA_SURVEY });
+ expect(quest.root.querySelector('#NOTES [data-click-type="reset"]')).not.toBeNull();
+ });
+
+ it.fails(`${runtimeDefects.textareaReset.localDefectId}: clears a standalone textarea and its active response`, async () => {
+ const quest = await renderFreshQuest({ markdown: STANDALONE_TEXTAREA_SURVEY });
+ const textarea = quest.root.querySelector('#notes');
+ textarea.value = 'Clear this response';
+ textarea.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
+ expect(quest.state.getActiveQuestionState().NOTES).toBe('Clear this response');
+
+ const { resetChildren } = await import('../../eventHandlers.js');
+ resetChildren(textarea.form);
+
+ expect(textarea.value).toBe('');
+ expect(quest.state.getActiveQuestionState().NOTES).toBeUndefined();
+ });
+
it.fails(`${runtimeDefects.corpusMalformedCondition.localDefectId}: rejects a truncated function expression`, async () => {
const evaluateCondition = await loadEvaluator();
expect(evaluateCondition('someSelected("D_632714520_1')).toBe(false);
diff --git a/tests/setup/jsdom.js b/tests/setup/jsdom.js
index 4b5b2f9..7825ec3 100644
--- a/tests/setup/jsdom.js
+++ b/tests/setup/jsdom.js
@@ -56,6 +56,10 @@ class BootstrapComponentStub {
return this.instances.get(element) ?? null;
}
+ static getOrCreateInstance(element, options) {
+ return this.getInstance(element) ?? new this(element, options);
+ }
+
show() {
this._element?.classList.add('show');
this._element?.dispatchEvent(new Event('shown.bs.modal'));
@@ -66,6 +70,14 @@ class BootstrapComponentStub {
this._element?.dispatchEvent(new Event('hidden.bs.modal'));
}
+ toggle() {
+ if (this._element?.classList.contains('show')) {
+ this.hide();
+ } else {
+ this.show();
+ }
+ }
+
dispose() {
this.constructor.instances.delete(this._element);
}
diff --git a/tests/unit/buildGrid.spec.js b/tests/unit/buildGrid.spec.js
index dc6f8cd..dcd91a9 100644
--- a/tests/unit/buildGrid.spec.js
+++ b/tests/unit/buildGrid.spec.js
@@ -19,6 +19,7 @@ describe('parseGrid', () => {
expect(decodeURIComponent(form.getAttribute('displayif'))).toBe('equals(ELIGIBLE,1)');
expect(form.querySelectorAll('tbody tr')).toHaveLength(2);
expect(form.querySelectorAll('input[type="radio"]')).toHaveLength(4);
+ expect(form.querySelector('#srFocusHelper')).toBeNull();
expect(form.querySelector('#ROW1_0').value).toBe('1');
expect(form.querySelector('span[data-gridreplace="name"]')).not.toBeNull();
expect(form.querySelector('span[data-gridreplace="firstName"]')).not.toBeNull();
From d07a201c6219c88ed5970ab93d3d4fe3188bd3a3 Mon Sep 17 00:00:00 2001
From: Joe Armani <93854858+JoeArmani@users.noreply.github.com>
Date: Thu, 13 Aug 2026 16:06:27 -0400
Subject: [PATCH 2/7] move updated functionality to testing suite from
knownDefects + refinements
---
accessibleQuestionTextBuilder.js | 8 +++-
docs/accessibilityManualTest.md | 45 +++----------------
docs/keyboardNavigation.md | 74 ++++++++++++++++++++++++++++++++
eventHandlers.js | 18 +++++---
tests/README.md | 7 +--
tests/e2e/accessibility.spec.js | 40 +++++++++++++++++
tests/e2e/knownDefects.spec.js | 67 +----------------------------
tests/knownDefects/registry.js | 2 -
8 files changed, 144 insertions(+), 117 deletions(-)
create mode 100644 docs/keyboardNavigation.md
diff --git a/accessibleQuestionTextBuilder.js b/accessibleQuestionTextBuilder.js
index e01a596..64da025 100644
--- a/accessibleQuestionTextBuilder.js
+++ b/accessibleQuestionTextBuilder.js
@@ -19,7 +19,13 @@ export function manageAccessibleQuestion(fieldsetEle, questionFocusSet) {
// Focus the hidden, focusable element
if (!moduleParams.isRenderer) {
setTimeout(() => {
- focusableEle.focus({ preventScroll: true });
+ // A response or submit dialog may open before this delayed
+ // question-focus handoff runs. Keep focus in the active modal
+ // instead of returning it to content behind the dialog.
+ const openModal = moduleParams.questDiv?.querySelector('.modal.show');
+ if (focusableEle.isConnected && !openModal) {
+ focusableEle.focus({ preventScroll: true });
+ }
}, 500);
}
diff --git a/docs/accessibilityManualTest.md b/docs/accessibilityManualTest.md
index a486b5f..e80e90c 100644
--- a/docs/accessibilityManualTest.md
+++ b/docs/accessibilityManualTest.md
@@ -4,6 +4,9 @@ Scope: **Quest 2 participant runtime only**
Browser automation can inspect DOM semantics, focus, state, and live regions, but it does not run VoiceOver or JAWS and cannot prove what either screen reader announces. This matrix describes those manual accessibility testing processes.
+For the keyboard-only command guide, see
+[`keyboardNavigation.md`](keyboardNavigation.md).
+
## Test boundary
- Use only non-production participant data and the canonical fixtures under `tests/fixtures/canonical/`.
@@ -55,7 +58,7 @@ Run KBD-MAC and KBD-WIN without a screen reader. Repeat in both Chrome and Edge
Raw browser key expectations apply only to this keyboard-only baseline. Do not file a failure solely because a screen reader reserves or reroutes one of these keys.
-Standards note for issue #1587: a custom element with `role="button"` must activate with both Enter and Space, as specified by the [WAI-ARIA button pattern](https://www.w3.org/WAI/ARIA/apg/patterns/button/). Native `
');
const breakQuest = await renderFreshQuest({
- // Keep both authored production blocks intact; placing the dependent
- // source after the active target lets Quest resolve the pipe without
- // changing the malformed target markup under characterization.
+ // Participant mode mounts the first question block, so keep the malformed
+ // dependent target first. The later production source remains searchable
+ // by QuestionProcessor, which resolves the pipe without rewriting either
+ // authored block under characterization.
markdown: survey('PRODUCTION_NONSTANDARD_BREAK', nonstandardBreak, medicationSource),
});
const fertilityQuestion = breakQuest.root.querySelector('#D_466346054');
diff --git a/tests/e2e/authoring.spec.js b/tests/e2e/authoring.spec.js
index 819df0e..5ab5aa3 100644
--- a/tests/e2e/authoring.spec.js
+++ b/tests/e2e/authoring.spec.js
@@ -91,6 +91,25 @@ test.describe('checked-in authoring application @authoring', () => {
await expect(page.locator('#pagestyle')).toHaveAttribute('href', 'Default.css');
await expect(page.locator('#pagelogic')).toHaveAttribute('href', 'Default.css');
+ await page.locator('#rendering').evaluate((rendering) => {
+ const probe = document.createElement('form');
+ probe.id = 'default-layout-probe';
+ probe.className = 'question';
+ probe.innerHTML = `
+
+ `;
+ rendering.append(probe);
+ });
+ const defaultLayoutProbe = page.locator(
+ '#default-layout-probe fieldset > legend + .screen-reader-focus + *',
+ );
+ await expect(defaultLayoutProbe).toHaveCSS('clear', 'left');
+ await page.locator('#default-layout-probe').evaluate((probe) => probe.remove());
+
await openAuthoringSettings(page);
await page.getByRole('switch', { name: 'Activate Logic' }).check();
await expect(page.locator('#pagelogic')).toHaveAttribute('href', 'ActiveLogic.css');
diff --git a/tests/e2e/keyboardNavigation.spec.js b/tests/e2e/keyboardNavigation.spec.js
index 855a565..a61cd15 100644
--- a/tests/e2e/keyboardNavigation.spec.js
+++ b/tests/e2e/keyboardNavigation.spec.js
@@ -87,6 +87,10 @@ async function expectGridFocusIndicatorUnclipped(label) {
const labelStyle = getComputedStyle(element);
const markerStyle = getComputedStyle(element, '::before');
const pixels = (value) => Number.parseFloat(value) || 0;
+ const markerWidth = pixels(markerStyle.width)
+ + (markerStyle.boxSizing === 'border-box'
+ ? 0
+ : pixels(markerStyle.borderLeftWidth) + pixels(markerStyle.borderRightWidth));
const markerHeight = pixels(markerStyle.height)
+ (markerStyle.boxSizing === 'border-box'
? 0
@@ -95,18 +99,63 @@ async function expectGridFocusIndicatorUnclipped(label) {
0,
pixels(markerStyle.outlineWidth) + pixels(markerStyle.outlineOffset),
);
+ const labelRect = element.getBoundingClientRect();
+ const markerCenterX = labelRect.left + (labelRect.width / 2);
+ const markerCenterY = labelRect.top + (labelRect.height / 2);
+ const paintBounds = {
+ left: markerCenterX - (markerWidth / 2) - focusExtent,
+ right: markerCenterX + (markerWidth / 2) + focusExtent,
+ top: markerCenterY - (markerHeight / 2) - focusExtent,
+ bottom: markerCenterY + (markerHeight / 2) + focusExtent,
+ };
+ const clippingAncestors = [];
+ let ancestor = element.parentElement;
+
+ while (ancestor) {
+ const style = getComputedStyle(ancestor);
+ const clipsX = ['hidden', 'clip', 'scroll', 'auto'].includes(style.overflowX);
+ const clipsY = ['hidden', 'clip', 'scroll', 'auto'].includes(style.overflowY);
+ if (clipsX || clipsY) {
+ const rect = ancestor.getBoundingClientRect();
+ clippingAncestors.push({
+ tagName: ancestor.tagName,
+ id: ancestor.id,
+ className: ancestor.className,
+ clipsX,
+ clipsY,
+ containsX: !clipsX
+ || (paintBounds.left >= rect.left - 0.5 && paintBounds.right <= rect.right + 0.5),
+ containsY: !clipsY
+ || (paintBounds.top >= rect.top - 0.5 && paintBounds.bottom <= rect.bottom + 0.5),
+ });
+ }
+ ancestor = ancestor.parentElement;
+ }
return {
+ focusPaintWidth: markerWidth + (2 * focusExtent),
focusPaintHeight: markerHeight + (2 * focusExtent),
- labelHeight: element.getBoundingClientRect().height,
+ labelWidth: labelRect.width,
+ labelHeight: labelRect.height,
+ overflowX: labelStyle.overflowX,
overflowY: labelStyle.overflowY,
+ clippingAncestors,
};
});
+ expect(
+ geometry.overflowX === 'visible'
+ || geometry.labelWidth + 0.5 >= geometry.focusPaintWidth,
+ `Grid focus indicator is horizontally clipped: ${JSON.stringify(geometry)}`,
+ ).toBe(true);
expect(
geometry.overflowY === 'visible'
|| geometry.labelHeight + 0.5 >= geometry.focusPaintHeight,
- `Grid focus indicator is clipped: ${JSON.stringify(geometry)}`,
+ `Grid focus indicator is vertically clipped: ${JSON.stringify(geometry)}`,
+ ).toBe(true);
+ expect(
+ geometry.clippingAncestors.every(({ containsX, containsY }) => containsX && containsY),
+ `A grid ancestor clips the focus indicator: ${JSON.stringify(geometry)}`,
).toBe(true);
}
diff --git a/tests/e2e/responsive.spec.js b/tests/e2e/responsive.spec.js
index 268f1aa..e305380 100644
--- a/tests/e2e/responsive.spec.js
+++ b/tests/e2e/responsive.spec.js
@@ -84,11 +84,13 @@ test.describe('participant responsive layout @responsive @canonical', () => {
const question = activeQuestion(page, 'GRID_RATE');
const table = question.locator('table.quest-grid');
await expect(table).toBeVisible();
+ await expect(table).toHaveCSS('margin-top', '10px');
if (testInfo.project.name === 'chromium-phone') {
await expect(table.locator('thead')).toHaveCSS('display', 'none');
await expect(table.locator('td.response').first()).toHaveCSS('display', 'block');
await expect(table.locator('td.response').first()).toHaveAttribute('data-header', /^\s*Never$/);
+ await expect(table).toHaveCSS('padding-top', '0px');
} else {
await expect(table).toHaveCSS('display', 'inline-table');
await expect(table.locator('tr').first()).toHaveCSS('display', 'table-row');
@@ -250,6 +252,8 @@ test.describe('participant responsive layout @responsive @canonical', () => {
await question.locator('tr[data-question-id="GRID_WALK"] label', { hasText: 'Sometimes' }).click();
await page.mouse.move(0, 0);
+ // Keep response text visible: label rendering is part of the mobile-grid
+ // regression contract, so masking it would hide a previously observed bug.
await expect(question).toHaveScreenshot(
`participant-grid-${testInfo.project.name}.png`,
{
diff --git a/tests/e2e/responsive.spec.js-snapshots/participant-grid-chromium-desktop-chromium-desktop.png b/tests/e2e/responsive.spec.js-snapshots/participant-grid-chromium-desktop-chromium-desktop.png
index a2a6da1f188d6a0267dbb5379a9aa26ff064f68f..b49d6938c8e3cd97f0b041c1b2b171684a1369e2 100644
GIT binary patch
literal 16873
zcmeIaWmHt}8}E&x@G}5K0V&C$LqS?XksLxv8A`glb5sOGX_y&lkpUdKYX}vT7`hwj
z?#?+k|5)p+=iT$H=bRU39o~!%6MNtLy1v)v`@Oe@y;M^mxk+=AfPjERN%4gS0Rdq*
z0l}3A|6B#nNGye^6A;`XP(i~u_q2zTS#y-C9WfFL
zwwZbhodUdqw228A8%vvw7{AR3LR|jG_xG;95;*+o(q2
zP;OI;yXUEH`DUi3Pr%m^oD}t6J}ia&Ah~?__9rZjy}cwr6JW+86;$NBc~
z4I$g1sM4+FJ}Za;t|L`Y*Du)d=%~zbQOtJlONV}?(%;BWpGM35Hp}gXgeDaGFP3Iq
zsQ3(edvnR8&b)UA*Dt@r>NGMXQA#a!Cl+pN6gbg1NekZHtdz%KMc)_7?$9~z(*Y~v
z(VVPv3}Tz=IGG-x>%2nnR;5ckVO35}Avh%XkKO|oyv-Pu<
z`_1}u+vy&&W{Z?_V$yoarIc95`BhK%u|EFIntCG|DBP=KalCWcX!y@&kjzTCLz=C#mMC-#yO#uuB5-*
z2H3!$aR|S9V0-CeZ8_CBC3*Z`EbGPu7XLGp0Y}E6zcf&ZPbggs_cE;Wb!a~db48ue
z9!$231qS}Xe*5;V{%pn0V`%B|Y~~`jHVquG2qD3r`1jOHG*;8dWrK4m1GkndSj>xycs!2YdS%IC7O`sM442%A~)Z3e#0
zr1lo4?xXMX@Pc@V*T!T(M%-e&IyCFh@S&8o*`
ztCW|P`r#)`$n)7$PTZ%z4Tn$W%K2bmFPINY}?I9oA|I{6%w}#&2->^w^(1H83*jj$@qlWZg2Xb``q7jaBKjUqhQZ(I+om+5}^m7<1ZY-6>KAfmI-|dp?5~-PNz{}L7R-Wul9Svm-r`5^r
z)}0=BEDg_Z&&rhl{0$A1aNc^^^>E@71$T|QcuXCJBn+r$Ft^UMz
z?}5i#CI!*Wh$ETEHuUWCKp`oS0Bpm+;_&u8z4el|!U0Y%$76Ou!~}P1TYaTttMon_
zG&lNAp>W{RR?jUe{?;UW1NMu?Tx|@qQ_1piJWe;Sq@toNjo)}tjedX;vQNq&?NVYB
z_lJgnKqjZIDVqF7o`$`!A+uPkXoKvxzWVbt)l`m&ZCA}+BhZ{|@Pp$~Z8%T^b*`A5
zt+kQT^)a|iZ`ZP*)m1`5dU|^GWKr$f*FDd#-%51UnMvt?2GQ@jaLgK~JLvrTOg5yI
zQoNyOJOAOt`}glj8N|f5)-QzhN?Pg&Pxg-&uzKZo16p->js9fe0C|*%c)(*J;jR7E
zzFgH%HDi^u;zCuiolO(7sEG6yIIK$R7KnQO3o#{F1$f1
zBB?z3YV8w(S%#*^kYwwLV_3DBuV7Uq?~0tmD-Rf1hkRY@n~_Q>8+@vH#iYy$0)A=>3SJtLQJurhK!g|fC8
z$?&sBW4*V358Ze*M9!(5GbgsWyR#Y6rJ6mirjZ|?t;1bAn3s?k)6Rug^Z8vD_!}bI
zahjhxF`ziZqgV3XN>?pSN;B7z1W_R*>l=6MHaY1c%v`P^Rxn(`d(g(@`ntsP-1o+b
zu&}UFEflu%)Muw@=x~shUQ&CMQWEPWIGNPG&c4mpibJx{aS|CfOuH?tiM39ZECdmg
zS**-^)X4AKayi(AxbzP3o#*#fMEe$@9fbHBG{x$D$?518f8%;$(Ca{OH8vZ(IS+)!
ztKD?7CKtPKdmRNS1GYOfojxfqL-m!84poH>QstCS$UXyA?YQYR!JP%KA%cZSwE|E$FuTzPb~+^4K<`~4V-DpYK!
zHCLXklenyaG;AnnD^RMkeUK`#|9RO3IfcbiJhj0pti?ekm4mpiSW1#8J|3KtE-+$Z*
z^xIYdN^ZNm>6(A-%(
z%bpPVBTT?O_B#q-LT=q{ZfOmFUbXtY)xLQb6dVxr2W(X2U&LEQcI%1@u&
zg_tD3BTwf&U^?H8>R`#L7;lI0mPC`iqJyb_QXe3s;O7=UF=nBv_=J!gGzHI{!;$$#
zflic*o3@-D3mh-;`x8Y(LyD&Z40I7?@
zr+?jor}}y0Q+KV@+DwAl%71^RibK(F?@WniOqydlpz)}DZC|4R_2-?*k%fLwF2@8h
z8H2LqSZRP?pWpnJ(`dZ9+hSYh6#{}UVIx<)#2Ptn&Hm>5_3M|g!z3)nyfsg+w5@|^
zf0bU;+j}fBW#vVgug9w54_L)n7fi_RK{L|>go|4oX8S3$(cgZJTk{X^#jbK9%0}h~
zi$|$_f`EYJ{5+&u`5fzhyc21NEi-o$y1hJE=JVO570gNMMQ-gp@0mHJ*-Ev^t?ih$
zI=|;)>PGozM@bd-hLwS4sLquBRh1b-S6A0qz?Uf+UP{)&ok?vhtMi?_PLYpO9Vbs3
zRFe0cesY9)DFv9!bu$I!XnBf+SKz0pUhCv*$OVQ;i8$pX_1^6{?s%Bxq}9u3@R6Rg
zzfK?ZCxT`Hx5#rmHgt%r$$eh!nwWl(?-&via*1sx;dm0x$C4M1Gl2j;ggcD(R%Q%zFb^T7v^P0^pI
z871Q?{T-qP1~-+0JuZI9J%0R{{4|nY
zdi%6wzRuSZbw&lZ;Ed79d2OF%!
zOqp5|UB?wZX*>?*_eahBkX3F0zQnCFN!RH3leQge6x$IC8N)#*dzu>c$;QWue0{?P
zmqzy^h2DnieO{u)UYO^{!)rhv3Tr$!+|NUzmW$#h>wNRZiqfavJ|iUFLm78;-J)!}
zS!(p8cT_R+%txBPth^YJ5xi-Me^U!3ZA7n{4g0Z3@6#<{-B;-JjKUv+LniHLou6@-=B#^S-7^T5jSy?G!c$^W{oDXVx$T(oyrs@Qk1(_8{
zWvrD=%#TD$nEkU?2nt9Q4@^}3{eIc9pnN1vz-dLAdq<`1aSg0*M63|4l<-Q|v(Gfc
zWNltzWe}6{w=dvgWbWa_qyg43=))sUql5JcoHu9Kk+uqF^XSN^YU0_*b{zZsBqmK2
zd9BuVKar_%5<$lq+{K@z+CzR?%`@w$TEV!A<3_cgZ+s-{OvCHXptg-TF&7oyCI@RH
z&EaXHjx+2&J9Bzvf35nad$M4apU$^GQ*m=Kz2;RqF-+WFSJCs?s67svq~?4+}T`%!!smOzU(e?tXA+@xIhPzSD##eOA7BL(IvvB
zp17qG<><1ZJY#tC_U*E=l9H|%S!nE8%atov$l>?s@X_s4&rLTq%+2RA6rxg+$tSDq
z#kj;zf(svoS-HErKawo%+q00qL8|8bkB7DO_wsj0W|xOj&Ml9`OvS3;m8!K1+jFh=
z{H}@+5IkboC3dt9>8|y`RRn(yM7-Z9mOY)loi0x!9yl`xq#S+v{6ytsfB8~Am%`&3
z-CY(p>be?^G*+r`NIoWqY;J(U;}vF$f8P;?Q~44T5Oh;eID*+Dqo7p<-a|lt_y0cp
zFJ1y_**95INnh-6)>c3Y(6GZ3}vOcp3FD_iSMZ>;k>EYqEtYmMX9
zEzsIkO?-mU%#I{N$AG_<*$%X|wdK4nHa*^5%E{)C>CV1$o7?tG04_)kodUP=>$<0_Gn}OH0d?lqORoyo@Rx
z4FOODy?_5CB{(!xx6tUkJwb5Aa-_rphvF~1Vq?-ANuLQtP1gDVyP1JM+wJO-I&J^`
zdww=b`k|m@xJyddJ@(NtK;);Jf>xbpC#!|v9kdh_nN0LWg9MCBC8Zm^nb3|zAt6Rp
zUi}IcfhG_lTzZp*ug`-1M9_+Otn`Be@%8n!E$mJfb9=(btC$Xh)t@oZNe7O1bg1&i
zm+>O(IbErEVOpfh7GVty@UaR9tj8+bZj!J=Z?xmfX`<-2sgizeWlmG|8q-kc>zMK<
z_QT!3em(a**YMKHC3*1Zt7?*PmbP=)TFKpxmyBs(Tw$@;r)XHYE&Oad6!uJ964KX|
zB%;2-Wl&Y*BK8JNB%q{F?b5n6-Iy7>I5&rOoUC@U`upSE@y>kQi^*?tpMe;@HSR8_{n1Y$mqrA=I00U^aOpFZFVOoCzb5=VhBUTJyu#=ZH^^<_qKL-
z``*4#?170t%*=668T&Ol3WLR?w?Hiz*ZFFX&c(5-3)>Gf3eGl1p$eGv6lfNA=5ZEm
zqM}E2hm+iTCFm1DF^`q_(T}$6SkLv?y2{Z^=;utqzO{eG3CI}@7X>Zbk4`rmGCj%h
zli;EVJ58R={QgX?MuK-{y<*c~d*y}9KF#Mq
z9?|#NAnYESlZMa3EkCko6vebfn6p(U@ahYb9Px|U$31uj=7dwjxy~q>r!`F)52{@I
zALMJEh?gbhPq=I>dsl3S3V6451(Ty0r!&l&BPqCb>_^L%Ydlux`1Dk1a-N!xmf8F{
zN_uk||6~``8LqB3YO2Hnnji+hI0L<8&c?x^IGP_C8v5i->&fZiCQ8U6$;8AYwgQw6
zYcnBOQ$a0eW54zCw{Ho;Ub?a}x+vd_qlx~<;~%Pq7*TO?VBQJ1G*L}>8X1`a53gJi7p}jhpza2+P{stkqb#)4j43W@L
zB!*{idpkiYz<2gfgxBUIFI4JeiG_15f<`C@bfs-lXJ@CaUA*?4wEh(kS~MIlB;93^
zw+4P&K3szuZsIhHaE{+8B**nVFe%v@J1j
z@AuyuE6L$+vx}ptZLg}TqI>GSwLRNh!b$RR@mKJz#PpP=hFoO?m+K>+!wtg`LufkM
zaiV6$dlOWlD~G7#coj%pptyJ~-cZa6-*QBIl1O1YkV+kgroKM6{gnZMzCvSZOswK(
zZQ_JSR9v7a+4f_DZ&BHX7?v*Eh#YQA#HDul?yt0N_=P?zFsOba+hCslJQ$tGd_^(E
zgp*qFTgZJ~tq%RRPqqOg2x#j|TVx4+xhm&AW{qz|j_?B4)@HlVo>1vDWmNR$*c)D&Mne(sS1)fl=x#MBdO
zpB5IAmj}yZedwXdi!$;{P##q?#pNt!TXm%ZJ($FL71Ch}j2lwRBAULZK2%jeu&!^{EgAv-e0r
z5nPC-TX8HZeX1DrH)>KH<0=cE82H6;cK=W|?eN%`I#AiA?sS^y=+SaJT!A*sFNRre
zW;`?UHn&dRm!`%-;|9LI%Bet!&Yi!1-w3f>cM@Vb7wn~}t_T}>1*G|Ym{6=`2pL1*
z_KaNl3yNvq!;N*kF$4Up_`v_;bQ^+uz&rL}eGI3C_yH~p?IIH>d5y7n(abA1Vy=2d
z(utwRnyOwz&i@IgX0e!+^gFN)*&f$`3J0{5T6LM{5G7mVCC3Dw@q>$_G>^=LZ7wc)
z9c`H`k8~vp<$iF|4!Jgy`zj+uWuB3@a|ifv-1D$mPft&!rv@n|{-7j8RUi8utl3^~
z9iiE4I=#in8B0jX3qwmXMCVKL-zhMv6IV}tdQ3t}C(3x`IY|OyqOzaSVjj#yW&Q>l
z4vj1o>ICIOi;KnjlND|PZi3?O
zU8V@lolu(&2^*y{^#N5Ux|E%g*x?;8b8k}d<*98aKlLdQkJBKDy)qLQkVj`82oz>T
zMFAop;M)KFRqsWn-Qx>ctyP4_lg28xjz>i=${T^OcrEu@fC)uoL~&K*EtPfm*LO9)
za+$U3I5o3c`0BNDb?kuZl$eLH+TRcX^H`I6Z*623`BUGiE)%JUwjM3R#G4S^eo$oA
zOu8(dCgx^2QR5LIxg%qWc4YU_dM+P)lLAd8r#z7sc!F7x)YVFGv&ed%t=`H}`
z$tful6B7zi4|(NC#BL%|cHdvW#isJ*5jgy749tDhf|iOV`mnEcb>|8U0jzMT-uv?l
z7w4y$Y`<&-3vZkK3v%#(Ms7i0zAm>##8V8oj@T*4$pLh-IXzfUoVKS;5Fx!~GhCGJ
z&-LNMhuVWtyFa{&lIp(gR1aF}H1rb0J#0;~TM*nRmk-)+-h4fyhj{xuTl(~$ol@p|z*2l=xwJ0-
zTORC42sD_)#l=}!Svfd39zA-Q1;SG#5*Zm8NlHp8fbbz{`Nz7oh0eNlIZjSRC7g(W
z({*=2)!2BdsOV8SHwu%V@9_2A=mZu_C@j`a=0qMUzaRrus|1jv@%$49yIOLzp%19z
zyu3Ve{f?=rDI6{r3>7e2X`(M!OCeJLBl6%N*P3k)#Yxr0#26@X4LF){Nv)X>{Kra)-D
ztH@DzX6R)7J0q#6AX%0a*Wvr^h;sdjUlBNQ{nuw$QeNt1lGC}{?|zW{g(w!zFLWdU
zY0s~#1rjGNDtez=M}@E#mW-sMrYL>WwV&o?4BcYsfp`s4OO8VqyV4zdV`Bn~)H^xa
zol9&}#69My*cx1CV{hDJ0~?!Vc0--+<%vReY!iKtJ=b3Q#M3ze*2jh0vv%GL<0al|
zQ&d3j!E8A-FdCVF`a?rPY-M1A4y{9*((C)P#Xi^qh|b#@iaP>-7w;|l6Aq}t1bWF1
zTB(DnuX@F?sir>lab50(%*O7o4&mGzLrDHieamw`J8}VP$Xa1WGa}UZAVZY&?{Y4~pz@TjFZhzQNuhorkpA!s>L{rN&YHc-*o
zdhSTNPWBi6{LMEAi{zWMm$q#O-d-rBnSM`fc<#;Jvsev=Ik)es1iEN6$Nx}6!NqVEo_gKx@
z^&yAHMncjBFKJ51T1btPZ6|zNB
zuS@|&b#rzuDk}P}lRs(J5AdYzbbY|7
zay-W?wBp^c-x_CuK##w_%9sp1T?f}2>Ii=8X-bRl|LVCu3Tz}Is2bRasTvO`64&zu
z?Ent;^y6I?b01(X8KlA|$c(C8Gff&p!2FM1M}hI8nXTqp_%wrvk-|KRhqxG)BPK3h
z;-)}z^X5%~in)Pi$GWv(LLGW+rlobOqJ9>yn2>dAVTe!tXdT#1(g+RQTYA-*ECw-$
zdgxL5dV7ac3odk~NPu2M-oMzuZnpgboZRLPz8$n~Yyy{dPVH`|M7DaGj>zm0_`P|l
zY{=~x>RYoy2`cTN&WDRk1T+f_#KML&EKvVQIgX3!fdb702#!+$Z1=&5L}sO>)9?TX
z%V}l*V6RNr`_cTC8T7#K{vH58?Q#{ljeJmKmb(GTxfSy^Empkc6x^+4ba&K&skO6JZFrwU=^D}a3Osf>LC1K3tRf}4ai
zl0MXHshdexR~Jzsm;5=CK#|%_yDIavx3^bATvmvY_sih*^M?(4`_yL)q4M(0I=^ke
zsx9O=_r)5M>}pln4d!8}OFRGsn`eQmAb`lTcXHzA93L9ebriPlmdVn%4&SUhWL3!}
zX4~%gX1mmKnIh0j=zy}gHq;8pJFyj@k!4j;n7NnF-@iYDB3AqJ^>qoOZGqgD7!S7p
zY;9GlN$pSL-TIVJVXnW5DiD`@A+Il0cm*^?$S}JV&jQ97$BzNK8L{D)NSz^1a@=7P
z^7&JlbsDS&-Y6kovcJPNi0bkf_~+a7R>^0Ms>!m2B!kA}j87FLL^Yy2sL
zVC((=7QAN7P6UeYP^{khx}CBL^!;9w49SE}KcvRO1nA%sS(<~rJ)jAH0n~zUQX=4F
zPhI+4D?fVJGLxBaU|T7U4I~|E&{W`@a76Q%MX)rkb1k5OtikOH2JC}hR$nkMTBzu|
zLJ!aM@_1tLuQ3iH-1tpJMWDEpr*q-I?cZTRW?`G9lMnk7?C9ud8(-lv{o%$v))=pi
z@sWv%^X#-h@B+laS?`2PXSkL~ety1%P{Q(5L&LIbAI3#K;t|HU;R3AK`uaK~Z*u~x
zd)-lM*mv(8hOu
z;^N{86J*mg1E3gN5fOXG8${~9jIHc++4u$^3!ikpW}7gyI_P6xZL
zhE~`0(Qv2pkA!-q``?RXccfV)l?IjtSe
z%a}hUS%JjHsP{_+I14hcx^3OD#tCH@=hYREKN;4ziv^xl)o|MQfoleubbtUo3OfDL
z#+9(VhQsk=l}^Bc8~$qm`*Smz_qOe32Rvl2DuHg~rFrGQo<4p<{-22`$98+X?fmpG
z3Uu4I7dOP*OG`?63BI%@2wDMT7#|-mLzTJA{tCtBC8+#X^!A*b$OnpR-1Si`E2LOf&v@jY1Ln!gK);M;_
zr%y}FTfbddCgP<|AmkN0XK^M}45JlsGysfv!aU#wgFGX7j2vpp1avZDjAlXQ#_Q;MxE4#Q(O3JRt(b;H9Od=;-LL
zU%&qNA(JpVJPa@e5Ga7I7Oe##MwYMtMm%*&u#-kZfNlXT8xRoC`0H1Hfk9S9Ma4TJ
zTd&QTnVF4^f-B3k|6btpRlrME;xL^yNDt6XFWd$0Wa#quT?qb*B-kt%N4cQOXGs52c
z|6WbP$6X#(4JRwPz&!13P8olzfHCjNhLR$JaY$aj-42oRcyM!&l>bo?5o3Ox%Bq^s(#*xNWEkZ=kmklnl)dkpvlkkdvZnY2NDlpa
z;YPgrFe{-g%iI?bS2d=$!g{D6h3H3KyY@7)o(BrWp${Lt4Q$t@LW9A6
zSs#MeHb6;Ocdd@QfYXsDxqZ3glBta7|0hoR;;0euHCTB8_|9A_v)rc#LmTQWOw0unh^F{=ll(DFz3DZO
zqYVZc4IQ0*&3JQjw(EQwP>Yu$r~9jiASeOb6rLb&02uMg!$qkJyi!1F4m4>=3K}as
zlU9llveW5s{%&PJzK9@Dlms`elqSEvckiCt>48J(eapA^qsi|6ckxnl<7>|k#x8SI
z%SyM<#KjP8djg1XZ_5zk0U{e9~rXK?ANtX>p^60q&O(Ufjf1#j&KNxe$R!ia1
zEf@#xpU)R(ow4ft(bKX7a@X5O!{-cuiIVolIVh{=O{>hVJDeYncC=oEBA;
z2Wr8N2k`vb*_KxyeeTOvUli%qFaejr^+R6g3_er
zz?P7=uP<WocoNd8PX&z$CJT^vVBb0RWj|k6cHZ?8mE$
z!Ew+;O92%Z>y8!tpnJdSia8rNX@D+Z04Xn=Xefw*ZK=>ug)>cww@+UDJNLnZiK;OJ
zAP>cFjGSyYsCI?aWzm>&*xAaQ$o~5!mqS;BX3u5-3DV^ze&Le%B^3R@tN-VT
z|I;4^TMeALqz|b!u`cTyeSeD-Nfd91+2=#&aFc~D+eD5&Pj=HC2;Zs=JF%4dj#N0@
zy?1Z-7vjL!d+Q>$4-|y|P_Ao$;5yc3j9}|#(UiO;qP-#|j|p&}itbyV=i&r9z6M
z!UVNf*D7Q5Af*oM+|I_>L#>Y5st2S?e}-vi-S7#PY-J7v-DIZ@<~sai6>&l#5~6!k
zk2NT1qAhuNE*##zW4fD|&|PswM*8wY>g>lBxFG)`*Z&Wox~#ZeJHqabbmM+g2=3Ql
zEL9v7QDuH+Hj47e$ojl(=^jpbhfc~Q_^>$l3ha{>*UWIb?@!vpG^0KEw=0SaIrlrn
z;BP!;JHC7w-lWhknX>Asr%kM;aA_MXalPos-W>P;((iSh;Y&YjZiF^!lgzm8REv;+
zkOJz74{+-5EzQCms@c=g=L;p2DERpdA}P;lsH7KZy^nTeNiZ_O67xJHQ!76F94fHs
zFuSN<%-?o~$J&?mwX|MYvH|d_m5-tMwW~LL*rV#;>cW0QvKEFt?!cfaRz+0*IB;m
zDy-P0x;8g*B6WL6jzp%Wcmq8(d|D+3qv3^*Qy6
zS&Kz-gJRfvB`A(mk<(t2@{Q{u9$$Q3e$J1#OOmpU9vD>42@RnA
zJy^j_)#2mq+Gl()`K59-G3369V%SL4TxU**yOZcVdmkAL+t%^%`%;GP)%VYtxhOr8
zMZPA&+&t5`I=B};M-I-#X+%oTYic5!Mn=(U^DwP8uM3?!HnfUPYwLOWJiPB4nS~k&
z6qZM0rMJeebk#atubJySa42kxMbGnL-s6l9gV;@y)J~;ugxPCX|5&Q9c+$AsQ=(>)
zZC_IVH0S;2MutY)R?kR-8&&jon7LcDJV~Qc<(uHPBVWrAztuMVK9jJtoIc^PtmXD1
z)fcP9e=Q%&e`^H-@$%w|__>tdZOp~Gt_PN1^LA_e0{5Md@6K7Ct_1h1R=y{VN(;fM
zWGC{;>c^LKCCZT``{lpi4S%>%^7JzzGmk$hb~fVHg?glsi9dTZpURZ?D_b9O!5m5)
zHGa}*Du>4_SpG$;aan+l-SH}tPh!L@x%S|Dk*1DV|Bxvz*kXRucfWWH>?CA6H9hQ=
zCW(sQ$8X>2jn>er(p<$FA?DqN{%nn~q~sVoR(=1Zp{_5^kp4S$7xRI7#*r*uk#+{y__z}TEh*dC!2;W7Hse8*$^8UqLOq8(*Ts*Lk{R-0
z282n-XysBaUNYZ&v?gg1qS;YZ-|Ly9GKF5wgqcJHHW7oFvXli_Vkzm5(*bhDA|cW_PFHWY!7oVaI@78x`nV8bF=zpRej>z(
za*3prG`kyw(XESc#5FJeERCF^i%sezt>{%3Dk;z18x1JhYcFt#K&J+5%@B621e=rX
zP9TRR!z!J+3$`QpPKn8k*D{$X{u#$W804bfv+(eiCy&^pgBF5x3Q*^1-p5Z<%lAKx
z>+`5r%yf#)?GEd-Jqvj;@q_oko#Xg}rtCvA>FEozQ97*vFENpu?E{<mcDZRMq)y(M)kLZ7=SJ
zVONLJRWH-Rxr?}>-VoGih3(Il?_XwzuJ%Wz!cRDv4ryqsUK
zh+G6&RkhE-f*Cm6NxG_|H&elJ$p
z+Lxp|1E;WU@81cTA9n|b-w_nN>SY^nbgDP26sG}69Kb5CwcZ386&o8L)BpRL3c@Wc
z-wnrPW=ae0{n9*+?ju0cUPQ@4qS{n)+pg#F@YDo)y&&P_M2dLswRm@xWO0v;S;Q>+
zeXgSe=rhCBbPeDX2#IxC_
zoWg)k`!A9UOT>77ymVOF3*UIfIhCex;l*m4UX)>3MWc}nWkT@1!FR1QF1um<*{j?y
z=tB9W^2zyD#S{E2Mz`fG*0|!+v#GdtVrHI^rI8ccG5mBi<{ToBog7?
zT!a0%NOpSjp7zF#sGHt2MipPrTXYHpMGSjEdm4#6R5jYKY=0rwtO#FacZz2ye^Q~g
zhUe&iY)1)#)o&`|jsvxpDet5p{4RdYcFYv;qk;p
zUlA4ikeZMg@oSu#yo2q;`jO`Cp7McUNz_TP@qp80j(u%pbQF_3ymEdA
z2x}Jisbr+26e?b$tsAeC8?PCtATba}!7Z7@FZI*k
z{9}dql5`EDT26KBA0ea%#-g70Hxzi>tjwVsGdu;VV`xiNPj$b-c{5_Lk4!Nt-#?_)
zwLyVNuk^JNKN2R*Baj1+o6#mQ6Z>{M?NeuD#dezR0hipaIVxW$rKEBmbvn?n&u*sP
zvthkMVsJxw4Ut^4Eh@leDHAuGdj~|i`Wo2Uhh&HT8bMRj^!h?5E));Xa`LRv%kRGB
z*WE^1bG6Bcqf_azG)0Z4sj@n&_FWQVWHgsk7QbG&QZ2Uc7Z?HVG%F!YEZeEQ7Rzv=
zt$k}?x{if-7$Xm`NxqC{qQ>>ga)k%rNR9paTV&`$J#sgb>^#jHt*Two{!)3SV*<@)
z)S}aM!v?$-DKm*l{%gz;t7{n>^bD1TUsME|9~Pk#JYX-!B8&HS~#Tfxvl
z$$n=Cl|6i9a_+FWq0HNmxwe6RUUBRytj|p%b0X|`Tn)>lq3&{(a@#GAziJoXQtqwT
zF{?|+RVCWbk)Tid8;_9nSH4)5ehdB5=vl
zG-cPDiEEsb%1p8Q_l_mNQsd8`jz5W7_>t2TpA{8~KQjR-hz60}a%zK9hH3irs~wD`
z|C=s`NTd3D1_H*BMkJq*CPF>&^Vqfk>)WooQEVsnkOL{eH3z6!dIL|vx@qM-c{2{kZ~d{Yx%kQR!+>Uc
z;fYmB8^g>WeMK!^RDP{7GG~?7Wkxk5uJoOx?7fUQ+ApVOW(q{dg_bHJHvE4#Au(
zG+H@zh`~5?2OT{=wY;mWA>R&dNJG>YdL^Rb&Frf=#q=NYzEW5ZjuyDFnKAoa*fBKZ
zevCZN`R^R}`=R%9Kh3vp?Ed96zK*!cF8%97EtG)ZisR+~7XVikBpwxRhX7j>sDM3!
z_r#{&n(y>IH@XU6u67$X?=Xeb)wm#`*I^n}BV7tAGYHmDI_R}fc~@0(^n=s&Jy~+a
z8;fg|gwN5Q(aHO~yrx~($!RLbx4X77
z@d4jU_`dO;!NZ5t4re+rL1pm4&G~QdkjP{)-k8fgAaad&6#qrS`6HVAUk?8NwPA0)
zIlh9GEMJ_GBt_pa+r=gDe*S+LQi`h2^Bq~1JWm)q9wZs}JRNx=cbXQ4V8x@NWw6_=z-U4sbd|mn%<5p!_uVl@tUQ1fC&Kl2dz8
J@chmD{{bxiBklkI
literal 9373
zcmeHtXH=8iMq&Mjur1wOm*9b^2
zQF;%dhlJ$pjPt+iuKWG0b?#c{TM{7euk8J_jsJ5c8Tu1fPSDWM(96j_eMv)ez--qJ{(L#K
z?e5Itf~Me6^#~k>xQQx^Pa5i+KJco?;*=BVnS_mLXdYfE7o?%N%J`OvhUVP4szWqa
zul?`K|2>EQ(M9lP$)G))({ZkAvOT1v}ckv;<6?P8G#bzyJU)zNYsinun_94M4~
z?k&^arOml+K3?8YJM@7hVJ4z)tf+RaW*BAOn@U$vBs;u2>1mgzUT{Ukmbvqfa?3=I
zrJR!&Z?;tgYUq1Aw|%%q*cWJJcHH~vXZBpqv@6Db%Ab3%P}eird&ic`c
zF59}XfJ0JXxCMg#7vq47(}pMn;nsSLb){5RS*=)+})Gb-l`E_6#(ple}~
zUr+K|59VuqH@=SRS4Ma*d`np!tJJS?vWOa98psoOUb^ePF+-ZYgj#=h_Lw&Z=_=@UEjFYX3>MI`k(o%?MiYIZk66-Q%
zqoM0OP~*HTnEU2F&-$eZw2OXv)D%
zgfe-52+g{zEU~RB9huTdZ`~4Wmc-cU*dxY$-z$FNn#Zk)8fUnTfE2`j2*Gwf(REUe
zJ5jafBx{(≻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
diff --git a/tests/e2e/responsive.spec.js-snapshots/participant-grid-chromium-phone-chromium-phone.png b/tests/e2e/responsive.spec.js-snapshots/participant-grid-chromium-phone-chromium-phone.png
index 4fbb71a6b3b33c2b37dd377657655e310565e892..ae17d41db02dd797bca3d558b3ea4622758f07b1 100644
GIT binary patch
literal 22402
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 |