Skip to content

Commit f4a7697

Browse files
chore(qti3): publish parity release
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 7e4ef96 commit f4a7697

60 files changed

Lines changed: 739 additions & 47 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/demo/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414
"test:e2e": "playwright test",
1515
"test:e2e:ui": "playwright test --ui",
1616
"test:e2e:headed": "playwright test --headed",
17-
"test:a11y": "playwright test tests/playwright/component-fixtures-a11y.pw.ts tests/playwright/assessment-a11y-behavior.pw.ts",
18-
"test:a11y:ui": "playwright test tests/playwright/component-fixtures-a11y.pw.ts tests/playwright/assessment-a11y-behavior.pw.ts --ui"
17+
"test:a11y": "playwright test tests/playwright/component-fixtures-a11y.pw.ts tests/playwright/assessment-a11y-behavior.pw.ts tests/playwright/qti3-stage5-a11y.pw.ts",
18+
"test:a11y:ui": "playwright test tests/playwright/component-fixtures-a11y.pw.ts tests/playwright/assessment-a11y-behavior.pw.ts tests/playwright/qti3-stage5-a11y.pw.ts --ui"
1919
},
2020
"dependencies": {
2121
"@acme/likert-scale-plugin": "workspace:*",

apps/demo/src/lib/a11y/fixtures.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ export type A11yFixtureId =
1818
| 'assessment-section-menu'
1919
| 'assessment-rubric-display'
2020
| 'assessment-timer'
21-
| 'assessment-shell';
21+
| 'assessment-shell'
22+
| 'pnp-catalog-stimulus';
2223

2324
export interface A11yFixture {
2425
id: A11yFixtureId;
@@ -46,6 +47,7 @@ export const A11Y_FIXTURES: A11yFixture[] = [
4647
{ id: 'assessment-rubric-display', title: 'RubricDisplay (assessment-player)' },
4748
{ id: 'assessment-timer', title: 'AssessmentTimer (assessment-player)' },
4849
{ id: 'assessment-shell', title: 'AssessmentShell (assessment-player)' },
50+
{ id: 'pnp-catalog-stimulus', title: 'PNP catalog and shared stimulus runtime' },
4951
];
5052

5153

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
<script lang="ts">
2+
// @ts-expect-error - Svelte-check can't resolve workspace subpath exports, but runtime works correctly
3+
import { ItemBody } from '@pie-qti/item-player/components';
4+
import { Player, type PnpProfile } from '@pie-qti/item-player';
5+
import type { InteractionResponseValue } from '@pie-qti/item-player/web-components';
6+
import type { ResolvedItemDeliveryContext } from '@pie-qti/ims-cp-core';
7+
import { registerDefaultComponents } from '@pie-qti/default-components';
8+
import { onMount } from 'svelte';
9+
10+
type FixtureResponseValue = InteractionResponseValue | null;
11+
12+
// Clean-room QTI 3 fixture authored for Stage 5 public accessibility evidence.
13+
// It exercises shared stimulus, scoped catalog lookup, PNP rebinding, and
14+
// answer-key non-disclosure without using official/private conformance assets.
15+
const qtiXml = `<qti-assessment-item xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0"
16+
identifier="pnp-catalog-stimulus-a11y" title="PNP Catalog Stimulus A11y Fixture"
17+
adaptive="false" time-dependent="false">
18+
<qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="string">
19+
<qti-correct-response>
20+
<qti-value>braided delta</qti-value>
21+
</qti-correct-response>
22+
</qti-response-declaration>
23+
<qti-outcome-declaration identifier="SCORE" cardinality="single" base-type="float"/>
24+
<qti-assessment-stimulus-ref identifier="passage_1" href="../stimuli/river.xml" title="River passage"/>
25+
<qti-item-body>
26+
<p class="item-note" data-catalog-idref="item_term">Use the shared passage to answer the question.</p>
27+
<p>
28+
Name the river feature:
29+
<qti-text-entry-interaction response-identifier="RESPONSE" expected-length="16"/>
30+
</p>
31+
</qti-item-body>
32+
<qti-catalog-info>
33+
<qti-card identifier="item_term">
34+
<qti-card-entry usage="glossary-on-screen">
35+
<qti-html-content>Question instructions.</qti-html-content>
36+
</qti-card-entry>
37+
</qti-card>
38+
<qti-card identifier="term_delta">
39+
<qti-card-entry usage="glossary-on-screen">
40+
<qti-html-content>Item-local fallback definition.</qti-html-content>
41+
</qti-card-entry>
42+
</qti-card>
43+
</qti-catalog-info>
44+
</qti-assessment-item>`;
45+
46+
const deliveryContext: ResolvedItemDeliveryContext = {
47+
itemHref: 'items/item.xml',
48+
stimuli: {
49+
passage_1: {
50+
identifier: 'passage_1',
51+
href: '../stimuli/river.xml',
52+
resolvedHref: 'stimuli/river.xml',
53+
title: 'River passage',
54+
bodyHtml: `<section aria-label="Shared river passage">
55+
<p>
56+
A <span class="stimulus-term" data-catalog-idref="term_delta">delta</span>
57+
forms where a river drops sediment.
58+
</p>
59+
</section>`,
60+
stylesheets: [],
61+
validationMessages: [],
62+
},
63+
},
64+
stylesheets: [
65+
{
66+
href: 'item.css',
67+
xml: '<qti-stylesheet href="item.css"/>',
68+
resolvedHref: 'items/item.css',
69+
source: 'item',
70+
cssText: '.item-note { border-left: 4px solid currentColor; padding-left: 0.5rem; }',
71+
},
72+
{
73+
href: 'stimulus.css',
74+
xml: '<qti-stylesheet href="stimulus.css"/>',
75+
resolvedHref: 'stimuli/stimulus.css',
76+
source: 'stimulus',
77+
stimulusIdentifier: 'passage_1',
78+
cssText: '.stimulus-term { text-decoration: underline; }',
79+
},
80+
],
81+
catalogSources: [
82+
{
83+
scope: 'stimulus',
84+
baseHref: 'stimuli/river.xml',
85+
stimulusIdentifier: 'passage_1',
86+
xml: `<qti-catalog-info>
87+
<qti-card identifier="term_delta">
88+
<qti-card-entry usage="glossary-on-screen">
89+
<qti-html-content>Stimulus-scoped delta definition.</qti-html-content>
90+
</qti-card-entry>
91+
<qti-card-entry usage="tts-pronunciation" xml:lang="en">
92+
<qti-html-content>DEL-tuh</qti-html-content>
93+
</qti-card-entry>
94+
</qti-card>
95+
</qti-catalog-info>`,
96+
},
97+
],
98+
validationMessages: [],
99+
};
100+
101+
const initialPnp: PnpProfile = {
102+
content: {
103+
glossaryOnScreen: true,
104+
catalogSupports: { ttsPronunciation: { active: true, languageCode: 'en' } },
105+
},
106+
};
107+
108+
let player = $state<Player | null>(null);
109+
let responses = $state<Record<string, FixtureResponseValue>>({ RESPONSE: null });
110+
let glossaryEnabled = $state(true);
111+
let lastCatalogEvent = $state<string>('none');
112+
let mounted = $state(false);
113+
let fixtureRoot: HTMLDivElement | null = $state(null);
114+
115+
onMount(() => {
116+
const newPlayer = new Player({
117+
itemXml: qtiXml,
118+
role: 'candidate',
119+
pnp: initialPnp,
120+
deliveryContext,
121+
});
122+
registerDefaultComponents(newPlayer.getComponentRegistry());
123+
player = newPlayer;
124+
mounted = true;
125+
});
126+
127+
function toggleGlossary() {
128+
glossaryEnabled = !glossaryEnabled;
129+
player?.updatePnp({ content: { glossaryOnScreen: glossaryEnabled } });
130+
}
131+
132+
$effect(() => {
133+
if (!fixtureRoot) return;
134+
const handler = (event: Event) => handleCatalogLookup(event as CustomEvent);
135+
const el = fixtureRoot;
136+
el.addEventListener('qti-catalog-lookup', handler);
137+
return () => el.removeEventListener('qti-catalog-lookup', handler);
138+
});
139+
140+
function handleCatalogLookup(event: CustomEvent) {
141+
const detail = event.detail ?? {};
142+
lastCatalogEvent = `${detail.usage ?? 'unknown'}:${detail.html ?? ''}`;
143+
}
144+
</script>
145+
146+
<div bind:this={fixtureRoot} class="space-y-4">
147+
<p class="text-sm text-base-content/70">
148+
Fixture for shared stimulus rendering, dynamic PNP catalog supports, keyboard focus behavior,
149+
host catalog events, and scoped stylesheet isolation.
150+
</p>
151+
152+
<div class="flex flex-wrap gap-2">
153+
<button type="button" class="btn btn-sm" onclick={toggleGlossary}>
154+
{glossaryEnabled ? 'Disable glossary support' : 'Enable glossary support'}
155+
</button>
156+
<div role="status" aria-live="polite" data-testid="catalog-event-status">
157+
Last catalog event: {lastCatalogEvent}
158+
</div>
159+
</div>
160+
161+
{#if mounted && player}
162+
<div class="qti-item-player">
163+
<ItemBody
164+
{player}
165+
{responses}
166+
disabled={false}
167+
onResponseChange={(id: string, value: FixtureResponseValue) => (responses = { ...responses, [id]: value })}
168+
/>
169+
</div>
170+
{/if}
171+
</div>

apps/demo/src/routes/a11y-components/[fixture]/+page.svelte

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import ExtendedTextInteractionFixture from '$lib/a11y/fixtures/ExtendedTextInteractionFixture.svelte';
2222
import MediaInteractionFixture from '$lib/a11y/fixtures/MediaInteractionFixture.svelte';
2323
import ModalFeedbackFixture from '$lib/a11y/fixtures/ModalFeedbackFixture.svelte';
24+
import PnpCatalogStimulusFixture from '$lib/a11y/fixtures/PnpCatalogStimulusFixture.svelte';
2425
2526
interface Props {
2627
data: { fixture: string };
@@ -87,6 +88,8 @@
8788
<AssessmentTimerFixture />
8889
{:else if fixture === 'assessment-shell'}
8990
<AssessmentShellFixture />
91+
{:else if fixture === 'pnp-catalog-stimulus'}
92+
<PnpCatalogStimulusFixture />
9093
{:else}
9194
<div class="alert alert-error">
9295
Unknown fixture: <code class="font-mono">{data.fixture}</code>

apps/demo/tests/playwright/assessment-a11y-behavior.pw.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,33 @@ test.describe('assessment accessibility behavior', () => {
6262

6363
await expect(page.getByRole('timer')).toHaveAttribute('aria-label', /remaining/i);
6464

65-
await page.getByRole('button', { name: /simulate warning/i }).click();
65+
const warningButton = page.getByRole('button', { name: /simulate warning/i });
66+
await warningButton.focus();
67+
await page.keyboard.press('Enter');
6668
await expect(page.getByRole('status').filter({ hasText: /time remaining/i })).toBeAttached();
69+
await expect(warningButton).toBeFocused();
6770

68-
await page.getByRole('button', { name: /simulate expiry/i }).click();
71+
const expiryButton = page.getByRole('button', { name: /simulate expiry/i });
72+
await expiryButton.focus();
73+
await page.keyboard.press('Enter');
6974
await expect(page.getByRole('alert').filter({ hasText: /time expired/i })).toBeAttached();
75+
await expect(expiryButton).toBeFocused();
76+
});
77+
78+
test('timer controls remain reachable in a narrow reflow viewport', async ({ page }) => {
79+
await page.setViewportSize({ width: 320, height: 800 });
80+
await page.goto('a11y-components/assessment-timer');
81+
82+
const root = page.locator('[data-testid="a11y-fixture-root"]');
83+
await expect(root).toBeVisible();
84+
await expect(page.getByRole('timer')).toBeVisible();
85+
86+
for (const name of [/simulate tick/i, /simulate warning/i, /simulate expiry/i]) {
87+
const button = page.getByRole('button', { name });
88+
await expect(button).toBeVisible();
89+
await button.focus();
90+
await expect(button).toBeFocused();
91+
}
7092
});
7193

7294
test('composed assessment demo shell has no automated WCAG AA violations', async ({ page }) => {
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import AxeBuilder from '@axe-core/playwright';
2+
import { expect, test } from '@playwright/test';
3+
4+
import { WCAG_AA_TAGS } from './a11y-utils';
5+
6+
// Clean-room browser evidence for QTI 3 PNP/catalog/shared-stimulus behavior.
7+
// The fixture is repository-authored and intentionally independent of official
8+
// 1EdTech/private conformance packages.
9+
test.describe('QTI 3 Stage 5 PNP/catalog/stimulus browser evidence', () => {
10+
test('supports keyboard glossary popup behavior without duplicate stimulus content or answer leakage', async ({ page }) => {
11+
await page.goto('a11y-components/pnp-catalog-stimulus');
12+
const root = page.locator('[data-testid="a11y-fixture-root"]');
13+
await expect(root).toBeVisible();
14+
15+
await expect(root.getByLabel('Shared river passage')).toBeVisible();
16+
await expect(root.locator('[data-stimulus-idref="passage_1"]')).toHaveCount(1);
17+
await expect(root.locator('[data-stimulus-idref="passage_1"] .stimulus-term')).toHaveCount(1);
18+
await expect(root.getByRole('textbox', { name: /text entry response/i })).toBeVisible();
19+
await expect(root.getByText(/braided delta|qti-correct-response|correctResponse|Item-local fallback definition/i)).toHaveCount(
20+
0
21+
);
22+
23+
const exposure = await root.evaluate((el) => ({
24+
text: (el as HTMLElement).innerText,
25+
labels: Array.from(el.querySelectorAll<HTMLElement>('[aria-label], [aria-description], [title]')).map((node) =>
26+
[node.getAttribute('aria-label'), node.getAttribute('aria-description'), node.getAttribute('title')].join(' ')
27+
),
28+
}));
29+
expect([exposure.text, ...exposure.labels].join(' ')).not.toMatch(
30+
/braided delta|qti-correct-response|correctResponse/i
31+
);
32+
33+
const glossaryTrigger = root.getByRole('button', { name: /show definition: delta/i });
34+
await expect(glossaryTrigger).toBeVisible();
35+
await expect(glossaryTrigger).toHaveAttribute('data-catalog-usage', 'glossary-on-screen');
36+
await glossaryTrigger.focus();
37+
await expect(glossaryTrigger).toBeFocused();
38+
39+
await page.keyboard.press('Enter');
40+
await expect(root.getByRole('dialog', { name: /delta/i })).toContainText('Stimulus-scoped delta definition');
41+
42+
await page.keyboard.press('Escape');
43+
await expect(root.getByRole('dialog', { name: /delta/i })).toHaveCount(0);
44+
await expect(glossaryTrigger).toBeFocused();
45+
});
46+
47+
test('dynamically rebinds PNP catalog UI and emits host-routed support events only on user action', async ({ page }) => {
48+
await page.goto('a11y-components/pnp-catalog-stimulus');
49+
const root = page.locator('[data-testid="a11y-fixture-root"]');
50+
await expect(root).toBeVisible();
51+
52+
await expect(root.getByTestId('catalog-event-status')).toContainText('none');
53+
const pronunciationTrigger = root.getByRole('button', { name: /request pronunciation: delta/i });
54+
await expect(pronunciationTrigger).toBeVisible();
55+
await pronunciationTrigger.click();
56+
await expect(root.getByTestId('catalog-event-status')).toContainText('tts-pronunciation:DEL-tuh');
57+
await expect(root.getByTestId('catalog-event-status')).not.toContainText(/braided delta|correctResponse/i);
58+
59+
await root.getByRole('button', { name: /disable glossary support/i }).click();
60+
await expect(root.getByRole('button', { name: /show definition: delta/i })).toHaveCount(0);
61+
await expect(root.getByRole('button', { name: /request pronunciation: delta/i })).toBeVisible();
62+
63+
await root.getByRole('button', { name: /enable glossary support/i }).click();
64+
await expect(root.getByRole('button', { name: /show definition: delta/i })).toHaveCount(1);
65+
});
66+
67+
test('keeps resolved styles scoped to the item instance and stimulus wrapper', async ({ page }) => {
68+
await page.goto('a11y-components/pnp-catalog-stimulus');
69+
const root = page.locator('[data-testid="a11y-fixture-root"]');
70+
await expect(root).toBeVisible();
71+
72+
const itemBody = root.locator('[data-qti-item-body-scope]');
73+
await expect(itemBody).toHaveCount(1);
74+
const scope = await itemBody.getAttribute('data-qti-item-body-scope');
75+
expect(scope).toBeTruthy();
76+
77+
const styleText = await itemBody.locator('style[data-qti-stylesheets="resolved"]').textContent();
78+
expect(styleText).toContain(`[data-qti-item-body-scope="${scope}"] .item-note`);
79+
expect(styleText).toContain(`[data-qti-item-body-scope="${scope}"] [data-stimulus-idref="passage_1"] .stimulus-term`);
80+
expect(styleText).not.toContain('items/item.css');
81+
expect(styleText).not.toContain('stimuli/stimulus.css');
82+
expect(styleText).not.toContain('@import');
83+
expect(styleText).not.toContain('url(');
84+
});
85+
86+
test('has no automated WCAG AA violations in the composed Stage 5 fixture', async ({ page }) => {
87+
await page.goto('a11y-components/pnp-catalog-stimulus');
88+
await expect(page.locator('[data-testid="a11y-fixture-root"]')).toBeVisible();
89+
90+
const results = await new AxeBuilder({ page })
91+
.include('[data-testid="a11y-fixture-root"]')
92+
.withTags(WCAG_AA_TAGS)
93+
.analyze();
94+
95+
expect(results.violations).toEqual([]);
96+
});
97+
});

apps/docs/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# @pie-qti/app-docs
22

3+
## 0.1.7
4+
5+
### Patch Changes
6+
7+
- Updated dependencies
8+
- @pie-qti/assessment-player@0.1.7
9+
- @pie-qti/item-player@0.1.7
10+
311
## 0.1.6
412

513
### Patch Changes

apps/docs/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@pie-qti/app-docs",
3-
"version": "0.1.6",
3+
"version": "0.1.7",
44
"description": "PIE-QTI Documentation Site",
55
"type": "module",
66
"private": true,

apps/transform/CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,24 @@
11
# @pie-qti/app-transform
22

3+
## 0.1.7
4+
5+
### Patch Changes
6+
7+
- Updated dependencies
8+
- @pie-qti/assessment-player@0.1.7
9+
- @pie-qti/default-components@0.1.7
10+
- @pie-qti/demo-vendor-extensions@0.1.7
11+
- @pie-qti/i18n@0.1.7
12+
- @pie-qti/item-player@0.1.7
13+
- @pie-qti/player-elements@0.1.7
14+
- @pie-qti/qti-common@0.1.7
15+
- @pie-qti/storage@0.1.7
16+
- @pie-qti/to-pie@0.1.7
17+
- @pie-qti/transform-core@0.1.7
18+
- @pie-qti/transform-types@0.1.7
19+
- @pie-qti/typeset-katex@0.1.7
20+
- @pie-qti/web-component-loaders@0.1.7
21+
322
## 0.1.6
423

524
### Patch Changes

0 commit comments

Comments
 (0)