diff --git a/docs/superpowers/plans/2026-07-31-live-panel-toggle-consistency.md b/docs/superpowers/plans/2026-07-31-live-panel-toggle-consistency.md
index 0e76c4839..13b44c5e7 100644
--- a/docs/superpowers/plans/2026-07-31-live-panel-toggle-consistency.md
+++ b/docs/superpowers/plans/2026-07-31-live-panel-toggle-consistency.md
@@ -59,6 +59,7 @@ SCSS, ngx-translate.
### Task 1: Introduce independent panel intent and legacy migration
**Files:**
+
- Create:
`libs/portal/shared/data-access/src/lib/live-layout-panel-state.service.ts`
- Create:
@@ -179,12 +180,13 @@ git commit -m "feat(ui): add independent live panel state"
### Task 2: Make Guide disclosure capability explicit
**Files:**
+
- Modify:
`libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.{ts,html,scss,spec.ts}`
- Modify:
`libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.{ts,html,scss,spec.ts}`
-- [ ] **Step 1: Write failing component tests**
+- [x] **Step 1: Write failing component tests**
For both timeline and list, assert that `collapsible=false` renders a static
heading with no disclosure button, while `collapsible=true` exposes a 40px
@@ -195,8 +197,9 @@ label:
fixture.componentRef.setInput('collapsible', false);
fixture.componentRef.setInput('panelId', 'live-guide-panel');
await fixture.whenStable();
-expect(fixture.nativeElement.querySelector('[data-testid="live-guide-toggle"]'))
- .toBeNull();
+expect(
+ fixture.nativeElement.querySelector('[data-testid="live-guide-toggle"]')
+).toBeNull();
fixture.componentRef.setInput('collapsible', true);
await fixture.whenStable();
@@ -207,7 +210,7 @@ expect(toggle.getAttribute('aria-controls')).toBe('live-guide-panel');
expect(toggle.getAttribute('aria-expanded')).toBe('true');
```
-- [ ] **Step 2: Run both targets and verify RED**
+- [x] **Step 2: Run both targets and verify RED**
```bash
pnpm nx test ui-epg --runInBand
@@ -215,7 +218,7 @@ pnpm nx test ui-epg --runInBand
Expected: FAIL because the new inputs and static-heading branch do not exist.
-- [ ] **Step 3: Implement capability, ARIA, and target sizing**
+- [x] **Step 3: Implement capability, ARIA, and target sizing**
Add:
@@ -229,7 +232,7 @@ content in a non-interactive container. Bind `aria-controls` to `panelId`, bind
the schedule region `id`, add `data-testid="live-guide-toggle"`, and set
`min-height: 40px`.
-- [ ] **Step 4: Run and verify GREEN**
+- [x] **Step 4: Run and verify GREEN**
```bash
pnpm nx test ui-epg --runInBand
@@ -238,7 +241,7 @@ pnpm nx lint ui-epg
Expected: both commands exit 0.
-- [ ] **Step 5: Commit**
+- [x] **Step 5: Commit**
```bash
git add libs/ui/epg
@@ -248,6 +251,7 @@ git commit -m "fix(ui): expose guide collapse capability"
### Task 3: Define shared M3U panel-control contracts
**Files:**
+
- Modify:
`libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.{ts,html,scss,spec.ts}`
- Modify:
@@ -266,9 +270,7 @@ and bidirectional focus:
```ts
expect(groupsHide.getAttribute('aria-controls')).toBe('live-groups-panel');
-expect(channelsHide.getAttribute('aria-controls')).toBe(
- 'live-channels-panel'
-);
+expect(channelsHide.getAttribute('aria-controls')).toBe('live-channels-panel');
groupsHide.click();
expect(groupsPanelExpandedChange).toHaveBeenCalledWith(false);
fixture.componentRef.setInput('groupsPanelExpanded', false);
@@ -343,6 +345,7 @@ git commit -m "feat(ui): add independent M3U panel controls"
### Task 4: Give the workspace Groups panel explicit ownership
**Files:**
+
- Modify:
`libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.{ts,html,scss,spec.ts}`
- Modify:
@@ -407,6 +410,7 @@ git commit -m "feat(ui): add workspace groups disclosure"
### Task 5: Integrate independent Channels state in Xtream and Stalker
**Files:**
+
- Modify:
`libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.{ts,html,scss,spec.ts}`
- Modify:
@@ -443,9 +447,7 @@ Inject `LiveLayoutPanelStateService`, compute Channels effective visibility for
```ts
this.livePanelState.toggleMasterSuppression(
- this.showLiveChannelSidebar()
- ? ['groups', 'channels']
- : ['groups']
+ this.showLiveChannelSidebar() ? ['groups', 'channels'] : ['groups']
);
```
@@ -485,6 +487,7 @@ git commit -m "feat(ui): separate portal live panel toggles"
### Task 6: Integrate shared state and mobile restoration in M3U
**Files:**
+
- Modify:
`libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.{ts,html,scss,spec.ts}`
@@ -552,6 +555,7 @@ git commit -m "feat(ui): separate M3U live panel toggles"
### Task 7: Preserve the single collection-header Channels control
**Files:**
+
- Modify:
`libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.{ts,html,scss,spec.ts}`
- Modify:
@@ -607,6 +611,7 @@ git commit -m "feat(ui): align collection live panel control"
### Task 8: Translate panel actions
**Files:**
+
- Modify: `apps/web/src/assets/i18n/*.json`
- [ ] **Step 1: Add the English source keys**
@@ -648,6 +653,7 @@ git commit -m "feat(i18n): translate live panel controls"
### Task 9: Add atomized Electron regression coverage
**Files:**
+
- Create: `apps/electron-backend-e2e/src/live-panel-toggles.e2e.ts`
- Reuse: `apps/electron-backend-e2e/src/electron-test-fixtures.ts`
- Reuse: `apps/electron-backend-e2e/src/portal-mock-fixtures.ts`
@@ -695,6 +701,7 @@ git commit -m "test(e2e): cover live panel toggles"
### Task 10: Update canonical docs and release note
**Files:**
+
- Modify: `docs/architecture/iptvnator-ui-guidelines.md`
- Modify: `CLAUDE.md`
- Create: `.changes/ui-live-panel-toggles.md`
@@ -745,6 +752,7 @@ git commit -m "docs(ui): document live panel ownership"
### Task 11: Full verification and strict review
**Files:**
+
- Review every file changed relative to `origin/master`.
- [ ] **Step 1: Run affected unit targets**
diff --git a/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.html b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.html
index b8559e77a..28ed1a15e 100644
--- a/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.html
+++ b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.html
@@ -7,22 +7,31 @@
>
-
+ }
@@ -112,7 +121,8 @@
}
{{
- viewDate() | date: 'EEE, d MMM' : '' : currentLocale()
+ viewDate()
+ | date: 'EEE, d MMM' : '' : currentLocale()
}}
- @if (!collapsed()) {
-
- @if (state === 'list' && !archivePlaybackAvailable()) {
-
- info
- {{ 'EPG.TIMELINE.SCHEDULE_ONLY_NOTICE' | translate }}
-
- }
-
- @switch (state) {
- @case ('loading') {
-
- @for (row of skeletonRows; track $index) {
-
-
-
-
- }
+
+ @if (!collapsed()) {
+
+ @if (state === 'list' && !archivePlaybackAvailable()) {
+
+ info
+ {{
+ 'EPG.TIMELINE.SCHEDULE_ONLY_NOTICE' | translate
+ }}
}
- @case ('list') {
-
-
- @if (nowStripVisible() && nowRow(); as now) {
-
-
- {{
- 'EPG.TIMELINE.ON_NOW' | translate
- }}
- {{ now.program.title }}
- @if (now.progress !== null) {
-
- }
- @if (nowRowMinutesLeft() !== null) {
- {{ nowRowMinutesLeft() }}
- {{
- 'EPG.TIMELINE.MINUTES_SHORT' | translate
- }}
- }
-
- arrow_upward
- {{ 'EPG.TIMELINE.NOW' | translate }}
-
-
- }
-
- }
- @default {
-
+ }
+ @case ('list') {
+
+
+ @if (nowStripVisible() && nowRow(); as now) {
+
+
+ {{
+ 'EPG.TIMELINE.ON_NOW' | translate
+ }}
+ {{
+ now.program.title
+ }}
+ @if (now.progress !== null) {
+
+ }
+ @if (nowRowMinutesLeft() !== null) {
+ {{ nowRowMinutesLeft() }}
+ {{
+ 'EPG.TIMELINE.MINUTES_SHORT'
+ | translate
+ }}
+ }
+
+ arrow_upward
+ {{ 'EPG.TIMELINE.NOW' | translate }}
+
+
+ }
+
+
+ }
+ @default {
+
+ }
}
}
- }
+
diff --git a/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.scss b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.scss
index ecc2cf272..69b67b4a2 100644
--- a/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.scss
+++ b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.scss
@@ -53,6 +53,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
padding: 4px 6px;
border-radius: 7px;
min-width: 0;
+ min-height: 40px;
}
.g-collapse:hover {
background: $surface-2;
@@ -81,6 +82,17 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
text-align: left;
min-width: 0;
}
+.g-heading-static {
+ justify-content: center;
+ min-height: 40px;
+}
+.g-panel-content {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ flex-direction: column;
+ overflow: hidden;
+}
.gt b {
font-size: 13.5px;
font-weight: 600;
diff --git a/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.spec.ts b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.spec.ts
index e5f77bba8..182069939 100644
--- a/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.spec.ts
+++ b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.spec.ts
@@ -1,7 +1,7 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { EpgProgram } from '@iptvnator/shared/interfaces';
-import { TranslateService } from '@ngx-translate/core';
+import { TranslateModule } from '@ngx-translate/core';
import { BehaviorSubject } from 'rxjs';
import { EpgListViewComponent } from './epg-list-view.component';
import { EpgListRow } from './epg-list-view.utils';
@@ -39,7 +39,7 @@ describe('EpgListViewComponent', () => {
undefined
);
TestBed.configureTestingModule({
- imports: [EpgListViewComponent],
+ imports: [EpgListViewComponent, TranslateModule.forRoot()],
providers: [
{
provide: MatDialog,
@@ -47,14 +47,6 @@ describe('EpgListViewComponent', () => {
open: () => ({ afterClosed: () => dialogResult }),
},
},
- {
- provide: TranslateService,
- useValue: {
- currentLang: 'en',
- defaultLang: 'en',
- onLangChange: new BehaviorSubject(null),
- },
- },
],
});
@@ -234,6 +226,38 @@ describe('EpgListViewComponent', () => {
expect(emitted).toEqual([true]);
});
+ it('renders an accessible Guide disclosure only when the host can collapse it', async () => {
+ setInputs({
+ channelName: 'Sports',
+ collapsible: true,
+ panelId: 'live-guide-panel',
+ });
+ await fixture.whenStable();
+
+ const toggle = fixture.nativeElement.querySelector(
+ '[data-testid="live-guide-toggle"]'
+ ) as HTMLButtonElement | null;
+ expect(toggle).not.toBeNull();
+ expect(toggle?.getAttribute('aria-controls')).toBe('live-guide-panel');
+ expect(toggle?.getAttribute('aria-expanded')).toBe('true');
+ expect(
+ fixture.nativeElement.querySelector('#live-guide-panel')
+ ).not.toBeNull();
+
+ setInputs({ collapsible: false });
+ await fixture.whenStable();
+
+ expect(
+ fixture.nativeElement.querySelector(
+ '[data-testid="live-guide-toggle"]'
+ )
+ ).toBeNull();
+ expect(
+ fixture.nativeElement.querySelector('.g-heading-static')
+ ?.textContent
+ ).toContain('Sports');
+ });
+
it('derives a live progress percentage from the summary when collapsed', () => {
const start = new Date(Date.now() - 30 * 60_000).toISOString();
const stop = new Date(Date.now() + 30 * 60_000).toISOString();
diff --git a/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.ts b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.ts
index ace1429e4..d30a3320a 100644
--- a/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.ts
+++ b/libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.ts
@@ -87,6 +87,8 @@ export class EpgListViewComponent {
readonly emptyReason = input
('none');
readonly selectedDate = input(null);
readonly collapsed = input(false);
+ readonly collapsible = input(true);
+ readonly panelId = input(null);
readonly summary = input(null);
readonly summaryLabelKey = input('EPG.CURRENT_PROGRAM');
@@ -135,7 +137,9 @@ export class EpgListViewComponent {
);
readonly nowRowMinutesLeft = computed(() => {
const row = this.nowRow();
- return row ? Math.max(0, Math.round((row.stopMs - this.nowMs()) / 60_000)) : null;
+ return row
+ ? Math.max(0, Math.round((row.stopMs - this.nowMs()) / 60_000))
+ : null;
});
readonly viewDate = computed(() => parseEpgDateKey(this.viewDayKey()));
diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.html b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.html
index cabb39a71..55c0ad053 100644
--- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.html
+++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.html
@@ -7,37 +7,46 @@
>
-
-
- keyboard_arrow_down
-
-
+ @if (collapsible()) {
+
+
+ keyboard_arrow_down
+
+
+ {{ channelName() }}
+ @if (!collapsed() && hasSummary()) {
+
+ @if (!isLivePlayback()) {
+ history
+ }
+ {{ summary()?.title }}
+
+ } @else {
+ {{ sourceLabel() }}
+ }
+
+
+ } @else {
+
{{ channelName() }}
- @if (!collapsed() && hasSummary()) {
-
- @if (!isLivePlayback()) {
- history
- }
- {{ summary()?.title }}
-
- } @else {
- {{ sourceLabel() }}
- }
+ {{ sourceLabel() }}
-
+ }
@@ -55,7 +64,10 @@
summary()?.title
}}
@if (progress() !== null) {
-
+
}
@@ -145,7 +157,8 @@
}
{{
- viewDate() | date: 'EEE, d MMM' : '' : currentLocale()
+ viewDate()
+ | date: 'EEE, d MMM' : '' : currentLocale()
}}
- @if (!collapsed()) {
-
- @if (state === 'ribbon' && !archivePlaybackAvailable()) {
-
- info
- {{ 'EPG.TIMELINE.SCHEDULE_ONLY_NOTICE' | translate }}
-
- }
+
+ @if (!collapsed()) {
+
+ @if (state === 'ribbon' && !archivePlaybackAvailable()) {
+
+ info
+ {{
+ 'EPG.TIMELINE.SCHEDULE_ONLY_NOTICE' | translate
+ }}
+
+ }
- @switch (state) {
- @case ('loading') {
-
- @for (width of skeletonWidths; track $index) {
-
- }
-
- }
- @case ('ribbon') {
-
+ }
+ @case ('ribbon') {
+
+ }
+ @default {
+
-
- }
- @default {
-
+ }
}
}
- }
+
diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.scss b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.scss
index 70421dd4c..9beae6dda 100644
--- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.scss
+++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.scss
@@ -65,6 +65,7 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
border-radius: 7px;
font: inherit;
min-width: 0;
+ min-height: 40px;
max-width: 60%;
&:hover {
@@ -72,6 +73,18 @@ $font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
}
}
+.epg-timeline__heading-static {
+ min-height: 40px;
+}
+
+.epg-timeline__panel-content {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ flex-direction: column;
+ overflow: hidden;
+}
+
.epg-timeline__collapse-icon {
width: 18px;
height: 18px;
diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.spec.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.spec.ts
index 54d88a3b5..36db0282a 100644
--- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.spec.ts
+++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.spec.ts
@@ -1,8 +1,8 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { EpgProgram } from '@iptvnator/shared/interfaces';
-import { TranslateService } from '@ngx-translate/core';
-import { BehaviorSubject, of } from 'rxjs';
+import { TranslateModule } from '@ngx-translate/core';
+import { of } from 'rxjs';
import { EpgTimelineComponent } from './epg-timeline.component';
import { TimelineBlock } from './epg-timeline.utils';
@@ -36,18 +36,12 @@ describe('EpgTimelineComponent', () => {
beforeEach(() => {
TestBed.configureTestingModule({
- imports: [EpgTimelineComponent],
+ imports: [EpgTimelineComponent, TranslateModule.forRoot()],
providers: [
{
provide: MatDialog,
- useValue: { open: () => ({ afterClosed: () => of(undefined) }) },
- },
- {
- provide: TranslateService,
useValue: {
- currentLang: 'en',
- defaultLang: 'en',
- onLangChange: new BehaviorSubject(null),
+ open: () => ({ afterClosed: () => of(undefined) }),
},
},
],
@@ -202,6 +196,38 @@ describe('EpgTimelineComponent', () => {
expect(emitted).toEqual([true]);
});
+ it('renders an accessible Guide disclosure only when the host can collapse it', async () => {
+ setInputs({
+ channelName: 'News',
+ collapsible: true,
+ panelId: 'live-guide-panel',
+ });
+ await fixture.whenStable();
+
+ const toggle = fixture.nativeElement.querySelector(
+ '[data-testid="live-guide-toggle"]'
+ ) as HTMLButtonElement | null;
+ expect(toggle).not.toBeNull();
+ expect(toggle?.getAttribute('aria-controls')).toBe('live-guide-panel');
+ expect(toggle?.getAttribute('aria-expanded')).toBe('true');
+ expect(
+ fixture.nativeElement.querySelector('#live-guide-panel')
+ ).not.toBeNull();
+
+ setInputs({ collapsible: false });
+ await fixture.whenStable();
+
+ expect(
+ fixture.nativeElement.querySelector(
+ '[data-testid="live-guide-toggle"]'
+ )
+ ).toBeNull();
+ expect(
+ fixture.nativeElement.querySelector('.epg-timeline__heading-static')
+ ?.textContent
+ ).toContain('News');
+ });
+
it('derives a live progress percentage from the summary', () => {
const start = new Date(Date.now() - 30 * 60_000).toISOString();
const stop = new Date(Date.now() + 30 * 60_000).toISOString();
diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts
index 1c7471de2..b09a095f1 100644
--- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts
+++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts
@@ -37,10 +37,7 @@ import {
summaryMinutesLeft,
summaryProgress,
} from './epg-summary.util';
-import {
- canCatchUpProgramme,
- epgDialogActionFor,
-} from './epg-archive.util';
+import { canCatchUpProgramme, epgDialogActionFor } from './epg-archive.util';
import {
EpgTimelineEmptyReason,
EpgTimelineEmptyStateComponent,
@@ -98,6 +95,8 @@ export class EpgTimelineComponent {
readonly emptyReason = input('none');
readonly selectedDate = input(null);
readonly collapsed = input(false);
+ readonly collapsible = input(true);
+ readonly panelId = input(null);
readonly summary = input(null);
readonly summaryLabelKey = input('EPG.CURRENT_PROGRAM');
@@ -178,11 +177,15 @@ export class EpgTimelineComponent {
readonly dividers = computed(() => buildTimelineDayDividers(this.axis()));
readonly trackWidthPx = computed(() => {
const axis = this.axis();
- return ((axis.endMs - axis.startMs) / TIMELINE_MINUTE_MS) * this.scale();
+ return (
+ ((axis.endMs - axis.startMs) / TIMELINE_MINUTE_MS) * this.scale()
+ );
});
readonly playheadLeftPx = computed(() => {
const axis = this.axis();
- return ((this.nowMs() - axis.startMs) / TIMELINE_MINUTE_MS) * this.scale();
+ return (
+ ((this.nowMs() - axis.startMs) / TIMELINE_MINUTE_MS) * this.scale()
+ );
});
readonly zoomLabelKey = computed(() => {
const scale = this.scale();
@@ -225,7 +228,9 @@ export class EpgTimelineComponent {
* could become usable, but with no programmes for the day there is nothing
* to jump to or zoom.
*/
- readonly showRibbonControls = computed(() => this.renderState() === 'ribbon');
+ readonly showRibbonControls = computed(
+ () => this.renderState() === 'ribbon'
+ );
/**
* The date stepper navigates between days, which is only meaningful when the
@@ -241,9 +246,7 @@ export class EpgTimelineComponent {
// ── collapsed-summary state ──
readonly hasSummary = computed(() => summaryHasTitle(this.summary()));
- readonly hasTimeRange = computed(() =>
- summaryHasTimeRange(this.summary())
- );
+ readonly hasTimeRange = computed(() => summaryHasTimeRange(this.summary()));
readonly progress = computed(() =>
summaryProgress(this.summary(), this.nowMs())
);