diff --git a/docs/superpowers/plans/2026-07-30-responsive-channel-epg-preview.md b/docs/superpowers/plans/2026-07-30-responsive-channel-epg-preview.md new file mode 100644 index 000000000..858dc30c3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-responsive-channel-epg-preview.md @@ -0,0 +1,494 @@ +# Preserve EPG Preview in Narrow Channel Lists Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Keep current-program context and enabled channel actions visible when a shared channel row is narrowed to the supported Live TV sidebar widths. + +**Architecture:** Preserve the shared `app-channel-list-item` API and virtual-scroll height. Change only its container-query degradation order and the matching skeleton geometry, with one deterministic Electron regression that forces item hosts to representative widths independently of scrollbar behavior. + +**Tech Stack:** Angular standalone components, SCSS container queries, Playwright Electron E2E, Nx, Prettier. + +--- + +### Task 1: Add the failing narrow-row Electron regression + +**Files:** + +- Modify: `apps/electron-backend-e2e/src/xtream-epg.e2e.ts` + +- [ ] **Step 1: Add a dedicated narrow-width EPG test** + +Add one test outside the timezone loop so the responsive contract is exercised +once rather than duplicated for each timezone. Reuse the existing fictional +`epg/epg` portal, select `EPG Focus`, and use `Timezone News` for a current +programme plus `Night Sports` for the no-program placeholder. + +```ts +test('@epg @xtream @electron keeps EPG context and actions at narrow channel-row widths', async ({ + dataDir, + request, +}) => { + await resetMockServers(request, ['xtream']); + const fixture = await fetchXtreamEpgFixture(request, epgCredentials); + const currentProgram = fixture.shortEpg[0]; + if (!currentProgram) { + throw new Error( + 'Expected the Xtream EPG fixture to include a current program.' + ); + } + const app = await launchElectronApp(dataDir, { env: { TZ: 'UTC' } }); + + try { + await addXtreamPortal(app.mainWindow, { + name: `${epgPortalName} Narrow`, + username: epgCredentials.username, + password: epgCredentials.password, + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Live TV'); + await clickCategoryByNameExact(app.mainWindow, fixture.categoryName); + + const currentRow = channelItemByTitle( + app.mainWindow, + fixture.stream.name ?? '' + ).first(); + const placeholderRow = channelItemByTitle( + app.mainWindow, + 'Night Sports' + ).first(); + await expect(currentRow).toBeVisible({ timeout: 20000 }); + await expect(placeholderRow).toBeVisible(); + + await setPortalChannelItemWidth(app.mainWindow, 232); + + await expect(currentRow.locator('.epg-title')).toHaveText( + currentProgram.title + ); + await expect(currentRow.locator('.epg-progress-track')).toBeVisible(); + await expect(currentRow.locator('.favorite-button')).toBeVisible(); + await expect(placeholderRow.locator('.epg-placeholder')).toBeVisible(); + await expect(currentRow.locator('.channel-logo-shell')).toBeHidden(); + await expect(currentRow.locator('.epg-time').first()).toBeVisible(); + await expect(currentRow.locator('.epg-time').last()).toBeHidden(); + await expect(currentRow).toHaveCSS('min-height', '68px'); + await expectNarrowRowContentFits(currentRow); + await expectNarrowRowContentFits(placeholderRow); + + await setPortalChannelItemWidth(app.mainWindow, 200); + + await expect(currentRow.locator('.epg-title')).toBeVisible(); + await expect(currentRow.locator('.epg-progress-track')).toBeVisible(); + await expect(currentRow.locator('.favorite-button')).toBeVisible(); + await expect(placeholderRow.locator('.epg-placeholder')).toBeVisible(); + await expect(currentRow.locator('.epg-time').first()).toBeHidden(); + await expect(currentRow.locator('.epg-time').last()).toBeHidden(); + await expect(currentRow).toHaveCSS('min-height', '68px'); + await expectNarrowRowContentFits(currentRow); + await expectNarrowRowContentFits(placeholderRow); + } finally { + await closeElectronApp(app); + } +}); +``` + +- [ ] **Step 2: Add the deterministic host-width helper** + +Place the helper with the existing E2E-local utilities: + +```ts +async function setPortalChannelItemWidth( + page: Parameters[0], + width: number +): Promise { + const itemHosts = page.locator( + 'app-portal-channels-list app-channel-list-item' + ); + await itemHosts.evaluateAll((elements, itemWidth) => { + for (const element of elements) { + (element as HTMLElement).style.width = `${itemWidth}px`; + } + }, width); + await expect + .poll(() => + itemHosts + .first() + .evaluate((element) => + Math.round(element.getBoundingClientRect().width) + ) + ) + .toBe(width); +} + +async function expectNarrowRowContentFits( + row: ReturnType +): Promise { + const rowBox = await row.boundingBox(); + const detailsBox = await row.locator('.channel-details').boundingBox(); + const actionsBox = await row.locator('.action-buttons').boundingBox(); + if (!rowBox || !detailsBox || !actionsBox) { + throw new Error('Expected visible narrow-row geometry.'); + } + + expect(detailsBox.x).toBeGreaterThanOrEqual(rowBox.x); + expect(detailsBox.x + detailsBox.width).toBeLessThanOrEqual( + actionsBox.x + 0.5 + ); + expect(actionsBox.x + actionsBox.width).toBeLessThanOrEqual( + rowBox.x + rowBox.width + 0.5 + ); + + const progressTrack = row.locator('.epg-progress-track'); + if ((await progressTrack.count()) > 0) { + const progressBox = await progressTrack.boundingBox(); + expect(progressBox?.width ?? 0).toBeGreaterThanOrEqual(24); + } +} +``` + +- [ ] **Step 3: Run the focused E2E and prove the old behavior fails** + +Run: + +```bash +pnpm nx run electron-backend-e2e:e2e-ci--src/xtream-epg.e2e.ts --skip-nx-cache +``` + +Expected: the new test fails at `232px` because the current `max-width: 270px` +rule hides the programme title, timeline, and placeholder, switches the EPG row +to the compact height, and leaves the logo visible. At `200px`, the current +rule also hides the complete action group. Existing EPG tests should remain +green before the new assertions are reached. + +### Task 2: Implement the shared responsive contract + +**Files:** + +- Modify: `libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss:241` +- Modify: `libs/ui/components/src/lib/channel-list-container/channel-list-item-skeleton/channel-list-item-skeleton.component.scss:89` +- Verify: `apps/electron-backend-e2e/src/xtream-epg.e2e.ts` + +- [ ] **Step 1: Keep timing horizontal below 310px** + +Replace the one-column timeline at `max-width: 310px` with a start-time and +flexible-progress row while continuing to hide the end time: + +```scss +@container (max-width: 310px) { + .epg-timeline { + grid-template-columns: auto minmax(24px, 1fr); + gap: 5px; + } + + .epg-time:last-child { + display: none; + } +} +``` + +- [ ] **Step 2: Preserve EPG and actions below 270px** + +Replace the current `max-width: 270px` degradation with: + +```scss +@container (max-width: 270px) { + .channel-list-item { + gap: 8px; + padding-inline: 8px 6px; + } + + .channel-list-item:not(.compact) .channel-logo-shell { + display: none; + } + + .channel-list-item:not(.compact) .channel-content { + gap: 0; + } + + .channel-list-item.compact .channel-logo-shell { + width: 34px; + height: 34px; + } +} +``` + +Do not set the row to `52px`; EPG rows inherit the base `68px` minimum. Do not +hide `.epg-title`, `.epg-timeline`, `.epg-placeholder`, +`.program-info-button`, or `.action-buttons`. + +- [ ] **Step 3: Remove only the start time below 220px** + +Replace the current `max-width: 220px` action-hiding rule with: + +```scss +@container (max-width: 220px) { + .channel-list-item:not(.compact) .epg-timeline { + grid-template-columns: minmax(24px, 1fr); + } + + .channel-list-item:not(.compact) .epg-time:first-child { + display: none; + } + + .channel-list-item.compact { + .channel-logo-shell, + .action-buttons { + display: none; + } + + .channel-content { + gap: 0; + } + } +} +``` + +The end time is already hidden by the wider breakpoint. Channel name, current +programme or placeholder, progress, drag affordance, and every enabled action +remain available in EPG rows. The `.compact` branch deliberately preserves the +existing radio/non-EPG degradation. + +- [ ] **Step 4: Match skeleton degradation to the live row** + +Keep the existing `360px` tightening. Replace the narrower skeleton rules so +they hide only the logo and its gap: + +```scss +@container (max-width: 270px) { + .channel-list-item-skeleton { + gap: 8px; + padding-inline: 8px 6px; + } + + .channel-list-item-skeleton:not(.compact) .channel-logo-skeleton { + display: none; + } + + .channel-list-item-skeleton:not(.compact) .channel-content-skeleton { + gap: 0; + } + + .channel-list-item-skeleton.compact .channel-logo-skeleton { + width: 34px; + height: 34px; + } +} +``` + +Scope the skeleton's existing `max-width: 220px` behavior to compact rows: + +```scss +@container (max-width: 220px) { + .channel-list-item-skeleton.compact { + .channel-logo-skeleton, + .action-buttons-skeleton { + display: none; + } + + .channel-content-skeleton { + gap: 0; + } + } +} +``` + +The EPG title, progress, action slots, and `68px` minimum height must remain +stable at every supported narrow width, while compact skeletons preserve their +current behavior. + +- [ ] **Step 5: Run focused and component validation** + +Run: + +```bash +pnpm nx run electron-backend-e2e:e2e-ci--src/xtream-epg.e2e.ts --skip-nx-cache +pnpm nx test components --skip-nx-cache +pnpm nx lint components --skip-nx-cache +pnpm nx lint electron-backend-e2e --skip-nx-cache +``` + +Expected: all commands pass. The Electron test proves the old narrow-width +failure is fixed; component tests protect unchanged rendering and interactions. + +- [ ] **Step 6: Commit the regression and implementation** + +```bash +git add \ + apps/electron-backend-e2e/src/xtream-epg.e2e.ts \ + libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss \ + libs/ui/components/src/lib/channel-list-container/channel-list-item-skeleton/channel-list-item-skeleton.component.scss +git commit -m "fix(ui): preserve EPG in narrow channel rows" +``` + +### Task 3: Document the contract and add the release note + +**Files:** + +- Modify: `docs/architecture/iptvnator-ui-guidelines.md:111` +- Create: `.changes/ui-narrow-channel-epg.md` + +- [ ] **Step 1: Add the canonical responsive priority** + +Under `## Channel List Item`, add a `### Responsive Information Priority` +section after `### Content Layout`: + +```md +### Responsive Information Priority + +- Keep EPG rows at `68px`; only rows without EPG use the `52px` compact height. +- At `310px` and below, hide the programme end time and retain the start time + plus progress on one row. +- At `270px` and below, hide the decorative logo before hiding programme + context or actions. +- At `220px` and below, hide the programme start time and retain progress. +- Never remove the channel name, current-programme title or no-program + placeholder, progress, drag affordance, or an enabled row action merely + because the shared item is narrow. +- Keep the skeleton geometry aligned with the loaded row. +``` + +- [ ] **Step 2: Add a user-facing fix note** + +Create `.changes/ui-narrow-channel-epg.md`: + +```md +--- +type: fix +area: ui +--- + +Narrow channel lists now keep the current programme, progress, and channel +actions visible instead of dropping useful EPG context. +``` + +Do not list issue `#1118` as closed because this PR intentionally implements +only its first increment. + +- [ ] **Step 3: Validate formatting and release-note metadata** + +Run: + +```bash +pnpm exec prettier --check \ + docs/architecture/iptvnator-ui-guidelines.md \ + .changes/ui-narrow-channel-epg.md +pnpm run release:notes:validate +git diff --check +``` + +Expected: all commands pass. + +- [ ] **Step 4: Commit documentation and release metadata** + +```bash +git add \ + docs/architecture/iptvnator-ui-guidelines.md \ + .changes/ui-narrow-channel-epg.md +git commit -m "docs(ui): document narrow channel row priority" +``` + +### Task 4: Run final automated and Electron UI verification + +**Files:** + +- Verify all files changed in Tasks 1–3. +- Do not add generated screenshots or a real playlist to the repository. + +- [ ] **Step 1: Run the complete affected validation ladder** + +Run: + +```bash +pnpm nx test components --skip-nx-cache +pnpm nx lint components --skip-nx-cache +pnpm nx lint electron-backend-e2e --skip-nx-cache +pnpm nx run electron-backend-e2e:e2e-ci--src/xtream-epg.e2e.ts --skip-nx-cache +pnpm nx build web --skip-nx-cache +pnpm run release:notes:validate +pnpm exec prettier --check \ + apps/electron-backend-e2e/src/xtream-epg.e2e.ts \ + libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss \ + libs/ui/components/src/lib/channel-list-container/channel-list-item-skeleton/channel-list-item-skeleton.component.scss \ + docs/architecture/iptvnator-ui-guidelines.md \ + .changes/ui-narrow-channel-epg.md +git diff --check +``` + +Expected: every command exits successfully. + +- [ ] **Step 2: Build and launch an isolated Electron runtime** + +Build once: + +```bash +pnpm nx run electron-backend:build-e2e +``` + +Start the mock servers in a dedicated terminal: + +```bash +pnpm nx run-many \ + --target=serve \ + --projects=xtream-mock-server,stalker-mock-server \ + --parallel=2 \ + --output-style=stream +``` + +Create a disposable profile and launch the built app with CDP: + +```bash +IPTVNATOR_VISUAL_DATA_DIR="$(mktemp -d /tmp/iptvnator-sidebar.XXXXXX)" +IPTVNATOR_E2E_DATA_DIR="$IPTVNATOR_VISUAL_DATA_DIR" \ +IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS=1 \ +ELECTRON_IS_DEV=0 \ +NODE_ENV=test \ +TZ=UTC \ +./node_modules/.bin/electron \ + --remote-debugging-address=127.0.0.1 \ + --remote-debugging-port=9222 \ + dist/apps/electron-backend/main.js +``` + +- [ ] **Step 3: Verify real layout behavior with `agent-browser`** + +Connect to the IPTVnator renderer, not a DevTools target: + +```bash +agent-browser --cdp 9222 tab list +agent-browser --cdp 9222 snapshot -i -c -d 4 +``` + +Using only mock credentials, inspect Xtream `EPG Focus → Timezone News` at the +persisted `live-channels-sidebar-width=250`. Confirm in computed layout and a +temporary screenshot that: + +- the programme title and progress are visible; +- the end time and logo are hidden at the resulting `~228–234px` item width; +- the favorite action is visible and not clipped; +- the row remains `68px` high; +- `Night Sports` shows the no-program placeholder without overlap. + +Then add the local Stalker mock portal at +`http://localhost:3210/portal.php` with MAC `00:1A:79:00:00:01` and inspect its +Live surface at the `250px` sidebar minimum. This proves a second provider +using the shared row has the same geometry. Save screenshots only under +`/tmp`, for example: + +```bash +agent-browser --cdp 9222 screenshot /tmp/iptvnator-narrow-epg-xtream.png +agent-browser --cdp 9222 screenshot /tmp/iptvnator-narrow-epg-stalker.png +``` + +- [ ] **Step 4: Check repository scope and hand off** + +Run: + +```bash +git status --short +git diff origin/master...HEAD --stat +git log --oneline origin/master..HEAD +``` + +Expected: the branch contains only the approved design, regression, shared-row +SCSS, canonical guideline, release note, and this implementation plan. No +`.superpowers/` visual artifacts, screenshots, real playlist data, credentials, +or unrelated changes are staged or committed.