Skip to content

Commit 84c5054

Browse files
committed
refactor: consolidate element-player around unified custom element
Remove legacy ESM wrapper players and align docs/tests/package exports around pie-element-player so consumers use one CE surface. Harden CE packaging for external clients by enabling project-level customElement config, removing Svelte-oriented export leakage, and exposing explicit stylesheet/CDN entries. Made-with: Cursor
1 parent 0de60c2 commit 84c5054

25 files changed

Lines changed: 485 additions & 985 deletions

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,11 +126,13 @@ PIE elements include print views for generating paper-based assessments and answ
126126
### Element-Level Print Player (This Project)
127127
For development and testing of individual elements:
128128
```html
129-
<pie-esm-print-player element-name="multiple-choice" role="student" model={...} />
129+
<pie-element-player view="print" element-name="multiple-choice" role="student"></pie-element-player>
130130
```
131131
- **Package:** `@pie-element/element-player`
132-
- **Use for:** Element development, testing, documentation
133-
- **Location:** `packages/element-player/src/players/EsmPrintPlayer.svelte`
132+
- **Use for:** Element development, testing, documentation, and optional composable embedding
133+
- **Location:** `packages/element-player/src/players/PieElementPlayer.svelte`
134+
135+
For most production app flows, prefer the standard upstream player stacks in `../pie-elements` and `../pie-players`.
134136

135137
### Item-Level Print Player (pie-players)
136138
For production rendering of complete assessment items:

apps/element-demo/DEPENDENCIES.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ In production ESM mode, the demo app loads elements from a CDN (like esm.sh, jsp
1717

1818
```typescript
1919
// Production: Load from CDN
20-
<pie-esm-element-player
20+
<pie-element-player
2121
element-name="multiple-choice"
2222
cdn-url="https://esm.sh"
2323
/>

apps/element-demo/src/routes/[element]/deliver/+page.svelte

Lines changed: 22 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -80,26 +80,19 @@ const buildModel = async (
8080
8181
if (!currentModel) {
8282
elementModel = null;
83+
esmModelReady = false;
8384
modelError = 'No model configuration found';
8485
console.error('[deliver] No model provided');
8586
return;
8687
}
8788
8889
const modelFn = currentController?.model;
8990
if (!modelFn || typeof modelFn !== 'function') {
90-
if (currentPlayerType === 'iife' && currentModel && typeof currentModel === 'object') {
91-
// In IIFE mode the delivery host can bootstrap with raw model while controller arrives.
92-
// This avoids passing an empty placeholder model to elements that require full shape.
93-
elementModel = { ...currentModel, mode: currentMode };
94-
elementSession = normalizeSession(currentSession);
95-
esmModelReady = true;
96-
modelError = null;
97-
return;
98-
}
9991
modelError = currentController
10092
? 'Controller model() function is required but not found'
10193
: 'Controller not loaded yet';
10294
elementModel = null;
95+
esmModelReady = false;
10396
if (currentController) {
10497
console.error('[deliver] Controller missing model() function');
10598
}
@@ -255,32 +248,28 @@ function handleBuildState(event: CustomEvent) {
255248
>
256249
{#snippet children()}
257250
<pie-element-theme-daisyui theme={$theme}>
258-
{#if esmModelReady || playerType === 'iife'}
259-
<div class="delivery-view">
260-
<div class="element-container">
261-
<pie-element-player
262-
strategy={playerType}
263-
view="delivery"
264-
element-name={data.elementName}
265-
package-name={data.packageName}
266-
element-version={(data as LayoutData & { elementVersion?: string }).elementVersion || 'latest'}
267-
model={elementModel}
268-
session={elementSession}
269-
rebuildVersion={$iifeBuildRequestVersion}
270-
onsession-changed={handleSessionChanged}
271-
oncontroller-changed={handleIifeControllerChanged}
272-
onbundle-meta={handleBundleMeta}
273-
onbuild-state={handleBuildState}
274-
></pie-element-player>
275-
</div>
251+
<div class="delivery-view">
252+
<div class="element-container">
253+
<pie-element-player
254+
strategy={playerType}
255+
view="delivery"
256+
element-name={data.elementName}
257+
package-name={data.packageName}
258+
element-version={(data as LayoutData & { elementVersion?: string }).elementVersion || 'latest'}
259+
model={esmModelReady ? elementModel : undefined}
260+
session={esmModelReady ? elementSession : undefined}
261+
rebuildVersion={$iifeBuildRequestVersion}
262+
onsession-changed={handleSessionChanged}
263+
oncontroller-changed={handleIifeControllerChanged}
264+
onbundle-meta={handleBundleMeta}
265+
onbuild-state={handleBuildState}
266+
></pie-element-player>
267+
{#if !esmModelReady}
268+
<div class="model-error">{modelError ?? 'Preparing view model...'}</div>
269+
{/if}
276270
</div>
277-
{:else}
278-
<div class="model-error">{modelError ?? 'Preparing ESM view model...'}</div>
279-
{/if}
271+
</div>
280272
</pie-element-theme-daisyui>
281-
{#if modelError}
282-
<div class="model-error">{modelError}</div>
283-
{/if}
284273
{/snippet}
285274
</DeliveryPlayerLayout>
286275

apps/element-demo/test/e2e/math-algebra-quadratic.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,7 @@ test.describe('Math Algebra Quadratic Demo - Multiple Choice Element', () => {
308308
await expect(page).toHaveURL(/\/print/);
309309

310310
// Verify print view loads
311-
const printPlayer = page.locator('pie-esm-print-player');
311+
const printPlayer = page.locator('pie-element-player[view="print"]');
312312
if ((await printPlayer.count()) > 0) {
313313
await expect(printPlayer).toBeVisible();
314314
}

apps/element-demo/test/e2e/simple-cloze-svelte.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ test.describe('Simple Cloze (Svelte 5) - Author and Delivery', () => {
8989
test('7. Print tab renders correctly', async ({ page }) => {
9090
await switchTab(page, 'print');
9191
await expect(page).toHaveURL(/\/print/);
92-
const printPlayer = page.locator('pie-esm-print-player');
92+
const printPlayer = page.locator('pie-element-player[view="print"]');
9393
const printContent = page.locator('[data-testid="print-view"]');
9494
const hasPrintPlayer = (await printPlayer.count()) > 0;
9595
const hasPrintContent = (await printContent.count()) > 0;

apps/element-demo/test/e2e/unified-player-strategy.spec.ts

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ async function waitForHostSettled(page: Page) {
3333
);
3434
}
3535

36+
function hasBothStrategies(): boolean {
37+
return STRATEGIES.includes('esm') && STRATEGIES.includes('iife');
38+
}
39+
3640
test.describe('Unified element player strategy host', () => {
3741
test('delivery uses one host for esm and iife', async ({ page }) => {
3842
test.setTimeout(120_000);
@@ -75,4 +79,201 @@ test.describe('Unified element player strategy host', () => {
7579
await page.waitForSelector(`${ELEMENT}-print`, { timeout: 45_000 });
7680
}
7781
});
82+
83+
test('author forwards model updates as full snapshots', async ({ page }) => {
84+
test.setTimeout(120_000);
85+
86+
await page.goto(`/${ELEMENT}/author?player=esm`);
87+
await page.waitForSelector('pie-element-player[view="author"]', { timeout: 45_000 });
88+
await waitForHostSettled(page);
89+
await page.waitForSelector(`${ELEMENT}-configure`, { timeout: 45_000 });
90+
91+
const marker = `marker-${Date.now()}`;
92+
const eventResult = await page.evaluate(
93+
async ({ elementName, markerValue }) => {
94+
const host = document.querySelector('pie-element-player');
95+
if (!(host instanceof HTMLElement)) {
96+
return { ok: false, reason: 'host-missing' };
97+
}
98+
const configure = host.querySelector(`${elementName}-configure`) as any;
99+
if (!configure) {
100+
return { ok: false, reason: 'configure-missing' };
101+
}
102+
103+
return await new Promise<{ ok: boolean; detail?: any; count?: number; reason?: string }>(
104+
(resolve) => {
105+
let count = 0;
106+
let detail: unknown;
107+
const listener = (event: Event) => {
108+
count += 1;
109+
detail = (event as CustomEvent).detail;
110+
};
111+
host.addEventListener('model-changed', listener);
112+
113+
try {
114+
const baseModel =
115+
configure.model && typeof configure.model === 'object' ? configure.model : {};
116+
configure.model = { ...baseModel, parityMarker: markerValue };
117+
configure.dispatchEvent(
118+
new CustomEvent('model.updated', {
119+
detail: { update: { parityPartial: true, parityMarker: markerValue } },
120+
bubbles: false,
121+
})
122+
);
123+
} catch {
124+
host.removeEventListener('model-changed', listener);
125+
resolve({ ok: false, reason: 'dispatch-failed' });
126+
return;
127+
}
128+
129+
setTimeout(() => {
130+
host.removeEventListener('model-changed', listener);
131+
resolve({ ok: count > 0, count, detail });
132+
}, 80);
133+
}
134+
);
135+
},
136+
{ elementName: ELEMENT, markerValue: marker }
137+
);
138+
139+
expect(eventResult.ok).toBeTruthy();
140+
expect(eventResult.count).toBeGreaterThan(0);
141+
expect(eventResult.detail).toBeTruthy();
142+
expect(eventResult.detail?.parityMarker).toBe(marker);
143+
});
144+
145+
test('delivery forwards stable session payloads for repeated updates', async ({ page }) => {
146+
test.setTimeout(120_000);
147+
148+
await page.goto(`/${ELEMENT}/deliver?mode=gather&role=student&player=esm${DEMO_QUERY}`);
149+
await page.waitForSelector('pie-element-player[view="delivery"]', { timeout: 45_000 });
150+
await waitForHostSettled(page);
151+
152+
const marker = `session-marker-${Date.now()}`;
153+
await page.evaluate(
154+
({ elementName, markerValue }) => {
155+
const host = document.querySelector('pie-element-player');
156+
if (!(host instanceof HTMLElement)) {
157+
return;
158+
}
159+
const container = host.querySelector('.demo-element-player');
160+
const innerElement =
161+
container?.querySelector<HTMLElement>(`${elementName}-element`) ??
162+
container?.querySelector<HTMLElement>(':scope > *:not(.loading):not(.error)') ??
163+
null;
164+
if (!innerElement) {
165+
return;
166+
}
167+
(window as any).__paritySessionDetails = [];
168+
host.addEventListener('session-changed', (event: Event) => {
169+
const detail = (event as CustomEvent).detail;
170+
(window as any).__paritySessionDetails.push(JSON.stringify(detail));
171+
});
172+
innerElement.dispatchEvent(
173+
new CustomEvent('session-changed', {
174+
detail: { parityMarker: markerValue, session: { value: ['A'] } },
175+
bubbles: true,
176+
composed: true,
177+
})
178+
);
179+
innerElement.dispatchEvent(
180+
new CustomEvent('session-changed', {
181+
detail: { parityMarker: markerValue, session: { value: ['A'] } },
182+
bubbles: true,
183+
composed: true,
184+
})
185+
);
186+
},
187+
{ elementName: ELEMENT, markerValue: marker }
188+
);
189+
190+
await page.waitForFunction(() => {
191+
const entries = (window as any).__paritySessionDetails as string[] | undefined;
192+
return Array.isArray(entries) && entries.length > 0;
193+
});
194+
195+
const eventResult = await page.evaluate(() => {
196+
const entries = ((window as any).__paritySessionDetails as string[]) || [];
197+
let hasConsecutiveDuplicate = false;
198+
for (let i = 1; i < entries.length; i += 1) {
199+
if (entries[i] === entries[i - 1]) {
200+
hasConsecutiveDuplicate = true;
201+
break;
202+
}
203+
}
204+
const lastDetail = entries.length > 0 ? JSON.parse(entries[entries.length - 1]) : null;
205+
return {
206+
count: entries.length,
207+
hasConsecutiveDuplicate,
208+
lastDetail,
209+
};
210+
});
211+
212+
expect(eventResult.count).toBeGreaterThan(0);
213+
expect(eventResult.lastDetail?.session).toEqual({ value: ['A'] });
214+
});
215+
216+
test('session remains stable across esm/iife strategy switches', async ({ page }) => {
217+
test.skip(!hasBothStrategies(), 'Requires both esm and iife strategies');
218+
test.setTimeout(120_000);
219+
220+
const token = `switch-${Date.now()}`;
221+
await page.goto(`/${ELEMENT}/deliver?mode=gather&role=student&player=esm${DEMO_QUERY}`);
222+
await page.waitForSelector('pie-element-player[view="delivery"]', { timeout: 45_000 });
223+
await waitForHostSettled(page);
224+
225+
await page.evaluate((value) => {
226+
const host = document.querySelector('pie-element-player');
227+
if (!(host instanceof HTMLElement)) return;
228+
const container = host.querySelector('.demo-element-player');
229+
const innerElement =
230+
container?.querySelector<HTMLElement>(':scope > *:not(.loading):not(.error)') ?? null;
231+
if (!innerElement) return;
232+
const currentSession =
233+
(host as any).session && typeof (host as any).session === 'object'
234+
? (host as any).session
235+
: {};
236+
const nextSession = { ...currentSession, paritySwitchToken: value };
237+
innerElement.dispatchEvent(
238+
new CustomEvent('session-changed', {
239+
detail: { session: nextSession },
240+
bubbles: true,
241+
composed: true,
242+
})
243+
);
244+
}, token);
245+
246+
await page.waitForFunction(
247+
(value) => {
248+
const host = document.querySelector('pie-element-player') as any;
249+
return host?.session?.paritySwitchToken === value;
250+
},
251+
token,
252+
{ timeout: 15_000 }
253+
);
254+
255+
await page.goto(`/${ELEMENT}/deliver?mode=gather&role=student&player=iife${DEMO_QUERY}`);
256+
await page.waitForSelector('pie-element-player[view="delivery"]', { timeout: 45_000 });
257+
await waitForHostSettled(page);
258+
await page.waitForFunction(
259+
(value) => {
260+
const host = document.querySelector('pie-element-player') as any;
261+
return host?.session?.paritySwitchToken === value;
262+
},
263+
token,
264+
{ timeout: 15_000 }
265+
);
266+
267+
await page.goto(`/${ELEMENT}/deliver?mode=gather&role=student&player=esm${DEMO_QUERY}`);
268+
await page.waitForSelector('pie-element-player[view="delivery"]', { timeout: 45_000 });
269+
await waitForHostSettled(page);
270+
await page.waitForFunction(
271+
(value) => {
272+
const host = document.querySelector('pie-element-player') as any;
273+
return host?.session?.paritySwitchToken === value;
274+
},
275+
token,
276+
{ timeout: 15_000 }
277+
);
278+
});
78279
});

docs/ARCHITECTURE.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,12 +86,12 @@ This was enabled by the PIE team's work on upstream library updates (React 18, M
8686

8787
![Unified Player: One Player, All Views, Any Framework](img/unified-player-architecture-1-1769801208629.jpg)
8888

89-
**Element-Level Players** (this repository):
89+
**Element-Level Player** (this repository):
9090

91-
- `<pie-esm-element-player>` - Interactive delivery and authoring
92-
- `<pie-esm-print-player>` - Print views for development/testing
91+
- `<pie-element-player>` - Unified delivery, authoring, and print views
9392
- **Package**: `@pie-element/element-player`
94-
- **Use for**: Element development, testing, documentation
93+
- **Use for**: Element development, testing, documentation, and optional composable embedding
94+
- **Positioning**: Not the default production orchestration path; production usage typically relies on the standard players in upstream `pie-elements` and `pie-players`
9595

9696
**Item-Level Players** (pie-players repository):
9797

0 commit comments

Comments
 (0)