Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions __TEST__/e2e/a11y.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Accessibility regression net for the Lighthouse findings fixed in #402:
// missing form labels, ARIA tab roles on incompatible elements, multiply-
// labelled modal toggles, and Recents contrast. Runs axe-core (the same engine
// behind Lighthouse's a11y audits) against the live editor — default view and
// the transcribe/export modals — asserting the specific rules from the issue,
// plus aria-hidden-focus (the modal toggles are aria-hidden and MUST stay out
// of the tab order for that to be valid).
import { test, expect } from '@playwright/test';
import { createRequire } from 'node:module';

const require = createRequire(import.meta.url);
const AXE_PATH = require.resolve('axe-core/axe.min.js');

// The rules #402 is about — scoped so unrelated/pre-existing audits don't make
// this spec flaky. Grow the list as more of the issue is addressed.
const RULES = [
'label', // form elements have labels
'select-name', // selects have accessible names
'aria-required-children', // tablist must contain tabs (roles now removed)
'aria-allowed-role', // no role="tab" on input/label
'form-field-multiple-labels', // modal toggles no longer multiply-labelled
'color-contrast', // Recents list legibility
'aria-hidden-focus', // aria-hidden toggles must be unfocusable
];

const runAxe = async (page, scope) => {
await page.addScriptTag({ path: AXE_PATH });
return page.evaluate(async ({ scope, rules }) => {
const result = await window.axe.run(scope || document, {
runOnly: { type: 'rule', values: rules },
});
return result.violations.map((v) => ({
id: v.id,
impact: v.impact,
nodes: v.nodes.map((n) => n.target.join(' ')).slice(0, 5),
}));
}, { scope, rules: RULES });
};

test.beforeEach(async ({ page }) => {
await page.goto('/index.html');
await page.waitForSelector('#hypertranscript [data-m]');
});

test('default view (with Recents content) has no #402-class violations', async ({ page }) => {
// populate the Recents list both ways storage.js renders it
await page.evaluate(() => {
const fp = document.getElementById('file-picker');
fp.insertAdjacentHTML('beforeend', `<li><a class="file-item" title="t" data-index=0>my-project</a></li>`);
fp.insertAdjacentHTML('beforeend', `<li style="padding:8px 16px; opacity:0.75">No files saved.</li>`);
});
expect(await runAxe(page)).toEqual([]);
});

test('transcribe modal (Local and Cloud tabs) has no #402-class violations', async ({ page }) => {
await page.evaluate(() => { document.getElementById('transcribe-modal').checked = true; });
expect(await runAxe(page, '.modal-box')).toEqual([]);
await page.click('label[for="service-cloud"]');
expect(await page.evaluate(async () => {
const result = await window.axe.run(document.querySelector('#cloud-group'), {
runOnly: { type: 'rule', values: ['label', 'select-name', 'aria-allowed-role', 'aria-required-children'] },
});
return result.violations.map((v) => v.id);
})).toEqual([]);
});

test('modal label-buttons are keyboard-operable; toggles are out of the tab order', async ({ page }) => {
const r = await page.evaluate(() => {
const infoBtn = document.getElementById('info-btn');
const toggle = document.getElementById('info-modal');
return {
btnTabbable: infoBtn.tabIndex === 0,
btnWired: infoBtn.dataset.a11yWired === '1',
toggleHidden: toggle.getAttribute('aria-hidden') === 'true',
toggleUntabbable: toggle.tabIndex === -1,
};
});
expect(r).toEqual({ btnTabbable: true, btnWired: true, toggleHidden: true, toggleUntabbable: true });

// Enter on the focused label-button opens the modal (was impossible before —
// labels aren't natively keyboard-activatable)
await page.focus('#info-btn');
await page.keyboard.press('Enter');
expect(await page.evaluate(() => document.getElementById('info-modal').checked)).toBe(true);
});
21 changes: 21 additions & 0 deletions __TEST__/e2e/span-merge.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,27 @@ test('retyping a word\'s first letter reflows the leaked char back, keeping orig
expect(r.editor).toEqual({ t: 'Editor ', m: r.orig.editorM, d: r.orig.editorD });
});

test('splitting a word re-indexes the player wordArr so the new spans can highlight', async ({ page }) => {
// A stale wordArr is why split words don't highlight; after a split (span
// count changes) the editor must rebuild it to match the DOM (#394).
const r = await page.evaluate(() => {
const t = document.querySelector('#hypertranscript');
const inst = window.hyperaudioInstance;
const domBefore = t.querySelectorAll('span[data-m]').length;
const span = [...t.querySelectorAll('span[data-m]')].find((s) => s.textContent.trim() === 'makes');
span.textContent = 'ma kes '; // add a space -> split on blur
t.dispatchEvent(new Event('blur'));
const domNodes = [...t.querySelectorAll('span[data-m]')];
const arrNodes = inst.wordArr.map((w) => w.n);
return {
grew: t.querySelectorAll('span[data-m]').length === domBefore + 1,
arrMatchesDom: arrNodes.length === domNodes.length && domNodes.every((n) => arrNodes.includes(n)),
};
});
expect(r.grew).toBe(true);
expect(r.arrMatchesDom).toBe(true);
});

test('a clean transcript is untouched on blur (no spurious merges)', async ({ page }) => {
const { before, after } = await page.evaluate(() => {
const t = document.querySelector('#hypertranscript');
Expand Down
23 changes: 17 additions & 6 deletions css/hyperaudio-lite-editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -444,12 +444,15 @@ dialog::backdrop {
margin: -70px auto 0 !important;
}
#captions-display { width: 100%; }
/* The three transcribe tabs are a fixed 165px each (495px) with nowrap labels,
which overflowed the narrowed modal. Let them share the row and shrink
evenly, with the label WRAPPING to two lines rather than collapsing into an
overlapping garble. */
.modal-box [role="tablist"] { flex-wrap: wrap; width: 100%; box-sizing: border-box; }
.modal-box [name="my_tabs_2"] {
/* The transcribe tabs are a fixed 150px each with nowrap labels, which
overflowed the narrowed modal. Let them share the row and shrink evenly,
with the label WRAPPING to two lines rather than collapsing into an
overlapping garble. (Selectors are class/name-based — the ARIA tab roles
were removed in #402, and the radio groups were renamed when the modal
gained the Local/Cloud switch.) */
.modal-box .tabs { flex-wrap: wrap; width: 100%; box-sizing: border-box; }
.modal-box [name="local_tabs"],
.modal-box [name="cloud_tabs"] {
flex: 1 1 0;
width: auto !important;
min-width: 6rem;
Expand Down Expand Up @@ -804,6 +807,14 @@ body.find-replace-open .hyperaudio-transcript { padding-top: 108px; }
#service-cloud:checked ~ #local-group { display: none; }
#service-cloud:checked ~ #cloud-group { display: block; }

/* Modal label-buttons made keyboard-focusable by js/a11y.js (#402): give them
a clear focus ring — as labels they don't get the browser's button focus
styling for free. */
label[data-a11y-wired]:focus-visible {
outline: 2px solid oklch(var(--p));
outline-offset: 2px;
}

/* API key field with a show/hide eye toggle (#390). */
.key-field {
position: relative;
Expand Down
Loading
Loading