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
13 changes: 13 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,18 @@
- Runnable apps live under `apps/`, including `demo`, `framework-demo`, and the Storybook apps.
- Keep package boundaries intact between `packages/cdg-*`, demo apps, and Storybook apps.
- Make focused changes; avoid broad refactors unless the task requires them.
- Prefer clear segmentation: keep constants and pure utilities in dedicated files instead of mixing them into large orchestration modules.
- Prefer package/module boundaries that separate contracts, capabilities/utilities, and runtime orchestration when expanding scope.
- Keep implementation guides and example stories aligned with the code they document.
- Framework-agnostic implementation guidance must align with `apps/demo`.
- React implementation guidance must align with `apps/framework-demo`.

## Public Exports

- When adding reusable developer-facing resources (types, constants, utilities, helpers), consider promoting them to public exports.
- Validate public export candidates before exposing them: stable naming, tests, documentation, and compatibility impact.
- Prefer exporting from package entrypoints (`index.ts` / `exports`) intentionally; avoid accidental deep-import-only APIs.

## Monorepo Workflow

- Use `pnpm` for installs and script execution; do not switch to `npm` or `yarn` for workspace tasks.
Expand All @@ -43,14 +51,19 @@

## Documentation

- Any code change must include accompanying documentation updates.
- Acceptable documentation updates include one or more of: Storybook docs, package/app README updates, or inline JSDoc for the touched API/logic.
- For exported APIs, prefer JSDoc updates in addition to user-facing docs when behavior/contracts change.
- Update Storybook documentation when public behavior, integration flow, or architecture guidance changes.
- Keep runtime contracts in `apps/storybook-hub/docs`.
- Keep framework-specific implementation guides next to their example stories in `apps/storybook-web/stories` and `apps/storybook-react/stories`.
- Storybook contribution guidance is pull-request oriented and should not describe release workflow.

## Build and Test

- Any code change must include corresponding test updates or additions.
- Run the smallest relevant validation for the change when possible.
- For documentation changes, prefer `pnpm run ci:docs` and the relevant Storybook build.
- For broader changes, use the repo validation commands already documented in Storybook contribution guidance.
- Keep coverage thresholds unchanged, but aim to stay at least 5 percentage points above the enforced minima when practical (currently branch >= 85% and functions >= 95%).
- Do not land code changes that reduce coverage below enforced thresholds; update/add tests to maintain threshold compliance.
2 changes: 2 additions & 0 deletions .github/instructions/react-framework.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ applyTo: 'apps/framework-demo/src/**/*.{ts,tsx}, apps/storybook-react/**/*.{ts,t
- Function files: `name.function.ts`
- Constant files: `name.constant.ts`
- Type files: `name.type.ts`
- Keep constants and pure utilities in dedicated files instead of embedding them in component render files.

## Folder Structure

Expand Down Expand Up @@ -75,6 +76,7 @@ export default Example;
## Barrel Exports

- Use folder-level `index.ts` barrel exports.
- Export reusable developer-facing utilities/types intentionally through barrel files after validating naming, tests, and docs.

```ts
export { default } from './Example.component';
Expand Down
90 changes: 61 additions & 29 deletions apps/demo/src/app/app.element.css
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
:host {
:is(app-root, cdg-storybook-framework-agnostic-demo) {
display: block;
min-height: 100vh;
height: 100dvh;
width: 100dvw;
overflow: hidden;
font-family: 'Avenir Next', 'Segoe UI', sans-serif;
color: #1f2e3a;
background: linear-gradient(180deg, #f8fafb 0%, #e9eef2 100%);
}

.app-shell {
display: flex;
flex-direction: column;
align-items: stretch;
min-height: 100vh;
display: grid;
grid-template-rows: auto 1fr;
height: 100dvh;
width: 100dvw;
overflow: hidden;
}

.source-link-row {
padding: 1rem 1rem 0;
padding-top: 1rem;
text-align: center;
font-size: 0.88rem;
color: #31485d;
Expand All @@ -36,24 +39,52 @@
}

.file-select-container {
padding: 0.5rem 1rem 0;
padding-top: 0.2rem;
text-align: center;
}

.file-picker-row {
display: flex;
align-items: center;
justify-content: center;
gap: 0.65rem;
font-size: 0.92rem;
color: #1f2e3a;
}

.codec-diagnostic {
display: none;
font-size: 0.82rem;
font-weight: 600;
color: #8d3c12;
}

.codec-diagnostic.is-visible {
display: block;
margin-top: 0.4rem;
}

.file-picker {
display: inline-block;
margin-bottom: 0.5rem;
font-size: 0.92rem;
}

#track-input {
max-width: 520px;
width: 100%;
width: min(520px, 100%);
font: inherit;
font-size: 0.92rem;
}

#track-input::file-selector-button {
font: inherit;
}

#track-input::-webkit-file-upload-button {
font: inherit;
}

.perf-export-button {
margin-top: 0.5rem;
padding: 0.3rem 0.6rem;
border: 1px solid rgba(73, 99, 126, 0.55);
border-radius: 4px;
Expand All @@ -69,19 +100,9 @@
cursor: not-allowed;
}

.cdg-player {
flex: 1 1 auto;
.controls-header {
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
height: 100%;
margin: 0 auto;
visibility: visible;
}

.app-shell.has-track .cdg-player {
visibility: visible;
}

.status {
Expand Down Expand Up @@ -142,7 +163,7 @@
justify-content: center;
gap: 0.45rem;
min-height: 40px;
padding: 0.35rem 0;
padding: 0.35rem 0.5rem;
}

.settings-panel {
Expand Down Expand Up @@ -452,26 +473,34 @@
}

.stage {
flex: 1 1 auto;
container-type: size;
display: flex;
align-items: center;
justify-content: center;
position: relative;
padding: 0.75rem 0 1rem;
overflow: hidden;
background: #000;
}

canvas[data-role='canvas'] {
width: min(72vw, 72%);
max-width: 920px;
aspect-ratio: 25 / 18;
width: min(100%, calc(100cqh * 25 / 18));
height: auto;
display: block;
margin: 0 auto;
background: #000;
image-rendering: pixelated;
}

video[data-role='video'] {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
display: none;
background: #000;
}

.titleImage {
display: flex;
align-items: center;
Expand Down Expand Up @@ -527,7 +556,10 @@ audio[data-role='audio'] {
}

@media (max-width: 760px) {
canvas[data-role='canvas'],
.file-picker-row {
flex-wrap: wrap;
}

.titleImage {
width: calc(100% - 1.2rem);
}
Expand Down
103 changes: 103 additions & 0 deletions apps/demo/src/app/app.element.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ const createDemoHarness = ({
title: string;
artist: string;
};
mediaKind?: 'audio' | 'video';
hasGraphics?: boolean;
}
| undefined;
loadError?: Error;
Expand Down Expand Up @@ -645,6 +647,107 @@ describe('AppElement', () => {
document.body.removeChild(app);
});

it('stops playback when visibility changes to hidden', () => {
const harness = createDemoHarness({
playerState: { status: 'playing' },
});

const visibilityDescriptor = Object.getOwnPropertyDescriptor(
document,
'visibilityState',
);

Object.defineProperty(document, 'visibilityState', {
configurable: true,
get: () => 'hidden',
});

registerAppElement();

const app = document.createElement('cdgplayer-demo-app') as AppElement;
document.body.appendChild(app);

document.dispatchEvent(new Event('visibilitychange'));

expect(harness.player.stop).toHaveBeenCalled();

document.body.removeChild(app);

if (visibilityDescriptor) {
Object.defineProperty(document, 'visibilityState', visibilityDescriptor);
} else {
Reflect.deleteProperty(document, 'visibilityState');
}
});

it('stops playback when receiving stop-playback window message', () => {
const harness = createDemoHarness({
playerState: { status: 'playing' },
});

registerAppElement();

const app = document.createElement('cdgplayer-demo-app') as AppElement;
document.body.appendChild(app);

window.dispatchEvent(
new MessageEvent('message', {
data: { type: 'cdg:stop-playback' },
}),
);

expect(harness.player.stop).toHaveBeenCalled();

document.body.removeChild(app);
});

it('shows codec diagnostics for unsupported video paths', async () => {
vi.spyOn(HTMLMediaElement.prototype, 'canPlayType').mockReturnValue('');

const harness = createDemoHarness({
loadError: new Error('Unable to load video media in this browser'),
playerState: { status: 'error' },
});

registerAppElement();

const app = document.createElement('cdgplayer-demo-app') as AppElement;
document.body.appendChild(app);

const input = app.querySelector<HTMLInputElement>('#track-input');
const codecDiagnostic = app.querySelector<HTMLElement>(
'[data-role="codec-diagnostic"]',
);
const file = new File(['video-content'], 'unsupported.mp4', {
type: 'video/mp4',
});

if (!input) {
throw new Error('Expected file input to exist.');
}

Object.defineProperty(input, 'files', {
configurable: true,
value: {
item: (index: number) => (index === 0 ? file : null),
length: 1,
0: file,
},
});

input.dispatchEvent(new Event('change'));
await Promise.resolve();
await Promise.resolve();

expect(codecDiagnostic?.textContent).toContain(
'unsupported video codec for this browser',
);
expect(codecDiagnostic?.classList.contains('is-visible')).toBe(true);
expect(harness.player.stop).toHaveBeenCalledOnce();

document.body.removeChild(app);
});

it('surfaces non-Error initialization failures when player setup throws', () => {
createPlayerMock.mockImplementation(() => {
throw 'setup exploded';
Expand Down
Loading
Loading