Files
iptvnator/docs/superpowers/plans/2026-07-30-responsive-channel-epg-preview.md
T
4gray 46c7713841 fix(ui): preserve EPG in narrow channel rows (#1312)
Preserve current-program context and enabled actions in narrow channel rows while aligning loaded rows, skeletons, and virtual-scroll geometry across M3U, Xtream, Stalker, Favorites, and Recent views.
2026-07-31 08:27:36 +02:00

16 KiB
Raw Blame History

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.

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:

async function setPortalChannelItemWidth(
    page: Parameters<typeof channelItemByTitle>[0],
    width: number
): Promise<void> {
    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<typeof channelItemByTitle>
): Promise<void> {
    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:

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:

@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:

@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:

@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 non-EPG degradation; radio-only consumers without EPG opt into that branch through showEpg=false, rather than changing mixed-list height through isRadio.

  • 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:

@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:

@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:

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
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:

### 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:

---
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:

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
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:

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:

pnpm nx run electron-backend:build-e2e

Start the mock servers in a dedicated terminal:

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:

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:

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:

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:

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.