diff --git a/.changes/ui-kit-toolbar-pickers.md b/.changes/ui-kit-toolbar-pickers.md
new file mode 100644
index 000000000..9d43868b9
--- /dev/null
+++ b/.changes/ui-kit-toolbar-pickers.md
@@ -0,0 +1,9 @@
+---
+type: fix
+area: ui
+---
+
+The catalog Refine button, the season picker and the guide's channel filter
+now match the Sources sort, with a chevron showing they open a menu. Screen
+readers hear the guide filter's choice and the favorites sort order. The
+minimum-rating chip keeps its accent colour after any navigation.
diff --git a/apps/electron-backend-e2e/src/catalog-sort-menus.e2e.ts b/apps/electron-backend-e2e/src/catalog-sort-menus.e2e.ts
index d4d647790..6e12a8be3 100644
--- a/apps/electron-backend-e2e/src/catalog-sort-menus.e2e.ts
+++ b/apps/electron-backend-e2e/src/catalog-sort-menus.e2e.ts
@@ -31,6 +31,9 @@ import { applyTheme, expectTextContrast } from './theme-contrast';
//
// The refine menu keeps its rating thresholds in a submenu, so it opens
// whole below its button in a short window.
+//
+// Refine and the rating chip are Material buttons: their look comes from
+// button tokens, so it holds whichever stylesheet the page added last.
// ---------------------------------------------------------------------------
const locales = ['en', 'de', 'ru', 'hu'] as const;
@@ -396,6 +399,87 @@ test.describe('Electron catalog sort menus', () => {
}
});
+ test("keeps the Refine picker and the rating chip's look whichever stylesheet the page added last", async ({
+ dataDir,
+ request,
+ }) => {
+ await resetMockServers(request, ['xtream']);
+ const app = await launchElectronApp(dataDir);
+ const page = app.mainWindow;
+
+ try {
+ await addXtreamPortal(page);
+ await waitForXtreamWorkspaceReady(page);
+ await page.setViewportSize({ width: 1280, height: 800 });
+ await openWorkspaceSection(page, 'Movies');
+ await expect(
+ page.locator('app-category-content-view mat-card').first()
+ ).toBeVisible({ timeout: 20_000 });
+ const menu = await openRefineMenu(page);
+ const thresholds = await openRatingSubmenu(page);
+ await thresholds
+ .getByRole('menuitemradio', { name: /\b7\b/ })
+ .click();
+ await expect(menu).toBeHidden();
+
+ const chip = page.locator(
+ 'app-category-content-view .rating-refinement-chip'
+ );
+ const refine = page.locator(
+ 'app-category-content-view .refine-action'
+ );
+ const look = async () => ({
+ chip: await chip.evaluate((element) => {
+ const probe = document.createElement('span');
+ probe.style.color = 'var(--mat-sys-primary)';
+ element.parentElement?.append(probe);
+ const primary = getComputedStyle(probe).color;
+ probe.remove();
+ const style = getComputedStyle(element);
+ return {
+ isPrimary: style.color === primary,
+ padding: `${style.paddingLeft} ${style.paddingRight}`,
+ };
+ }),
+ refine: await refine.evaluate((element) => {
+ const style = getComputedStyle(element);
+ return {
+ height: Math.round(
+ element.getBoundingClientRect().height
+ ),
+ radius: style.borderRadius,
+ background: style.backgroundColor,
+ };
+ }),
+ });
+ const expected = {
+ chip: { isPrimary: true, padding: '10px 10px' },
+ refine: { height: 32, radius: '8px' },
+ };
+
+ // Angular removes a component's stylesheet with its last instance
+ // and appends it again with the next one, so either Material's
+ // button sheet or the catalog view's may come last.
+ const moveLast = (needle: string) =>
+ page.evaluate((text) => {
+ const sheets = [
+ ...document.head.querySelectorAll('style'),
+ ].filter((sheet) => sheet.textContent?.includes(text));
+ for (const sheet of sheets) document.head.append(sheet);
+ return sheets.length;
+ }, needle);
+
+ const initial = await look();
+ expect(initial).toMatchObject(expected);
+ expect(await moveLast('.mat-mdc-button:not(:disabled)')).toBe(1);
+ expect(await look(), 'Material button sheet last').toEqual(initial);
+ expect(await moveLast('rating-refinement-chip')).toBe(1);
+ expect(await look(), 'catalog view sheet last').toEqual(initial);
+ } finally {
+ await closeElectronApp(app);
+ }
+ });
+
test('shows aligned radio rows with a legible check in the live, category and source sort menus, in light and dark themes', async ({
dataDir,
request,
diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md
index a628088a6..f17f94521 100644
--- a/docs/architecture/iptvnator-ui-guidelines.md
+++ b/docs/architecture/iptvnator-ui-guidelines.md
@@ -154,6 +154,15 @@ tokens, so the retired `--mdc-*` names compile but do nothing;
a spec loads as raw CSS cannot use Sass modules; it declares the `--mat-*`
token directly and says why.
+Change what a Material button draws (label colour, padding) through its
+tokens, not plain properties on its host. Material's own rules, such as
+`.mat-mdc-button:not(:disabled)` for the colour, match a component's
+`.class[_ngcontent-…]` selector in specificity, so the stylesheet added last
+wins. Angular removes a component's stylesheet with its last instance and
+appends it again with the next one, so that order changes with navigation.
+`catalog-sort-menus.e2e.ts` moves each sheet last and checks that the Refine
+picker and the rating chip keep their look.
+
### Material off the startup path
`renderer.initialBytes` ([performance journeys](performance-journeys.md#rendererinitialbytes))
@@ -1040,8 +1049,9 @@ and drive the season container's presenters.
Every section below the hero (Episodes, Cast & crew, Similar) uses
`app-detail-section-header`: an 18px/600 title, a muted 12.5px tabular
-counter, a lead slot (the season pills or dropdown, a 30px pill) and an end
-slot (the list/grid toggle, rail arrows). Sections sit 56px apart, with 16px
+counter, a lead slot (the 30px season pills, or past four seasons the
+season dropdown, a small secondary button with a chevron) and an end slot
+(the list/grid toggle, rail arrows). Sections sit 56px apart, with 16px
between a header and its content and 56px after the last one. The Episodes
counter reads "N episodes · M watched". The list/grid switch is a segmented
track whose checked segment is a neutral raised fill with heading ink; the
@@ -1235,8 +1245,15 @@ ui:form-field-defaults:validate` (in CI) reports a missing provider.
A `mat-select` belongs inside a form. Its option panel is styled once in
`m3-theme.scss` to match the kit menu (a bordered 10px panel, 36px rows at
13px, the chosen row on the list-selection tokens). A picker in a toolbar is
-not a select: it is a secondary button with a chevron that opens an
-`app-menu`.
+not a select: it is a `mat-flat-button` with the `secondary-button` mixin and
+a trailing `expand_more` chevron (`iconPositionEnd`, `trigger-chevron`) that
+opens a `mat-menu`, as the Sources sort, the catalog Refine button, the
+programme guide's scope and the season dropdown do. Its label shows the
+current value where there is one; when the visible label is only the value,
+the accessible name adds what it picks ("Channels shown: Favorites"). An
+active picker (refinements applied) takes the selected-row tint. A panel
+header's icon actions use `ghost-icon-button`, as the category panel's
+search and sort do.
The add-source forms (M3U URL, Xtream, Stalker) and the edit dialog share one
vocabulary, so a field reads the same wherever it appears:
diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html
index 5f2511981..8206493bd 100644
--- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html
+++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html
@@ -65,7 +65,7 @@
@if (canSortContent()) {
diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss
index 745ae09d1..aa7df3e54 100644
--- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss
+++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss
@@ -1,4 +1,5 @@
@use '@angular/material' as mat;
+@use '../../../../../../ui/styles/app-controls' as controls;
@use '../../../../../../ui/styles/panel-header' as panel;
:host {
@@ -67,8 +68,7 @@
app-region: no-drag;
}
-.refinement-chip,
-.refine-action {
+.refinement-chip {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -84,20 +84,18 @@
)
);
- mat-icon {
- width: 18px;
- height: 18px;
- font-size: 18px;
- }
-}
-
-.refinement-chip {
max-width: 220px;
padding: 0 10px;
background: var(--mat-sys-surface-container);
border: 1px solid color-mix(in srgb, var(--app-on-surface) 6%, transparent);
font-size: 0.78rem;
font-weight: 600;
+
+ mat-icon {
+ width: 18px;
+ height: 18px;
+ font-size: 18px;
+ }
}
.refinement-chip-label {
@@ -115,22 +113,49 @@
user-select: none;
}
-.rating-refinement-chip,
-.refine-action.refine-action-active {
+// A Material button host: Material's own colour and icon-padding rules tie
+// with the plain properties above, and Angular removes and re-adds a
+// component's stylesheet with its last instance, so the later sheet would
+// decide. The colour and the tokens agree whichever sheet comes last.
+.rating-refinement-chip {
color: var(--mat-sys-primary);
+ @include mat.button-overrides(
+ (
+ text-label-text-color: var(--mat-sys-primary),
+ text-state-layer-color: var(--mat-sys-primary),
+ text-horizontal-padding: 10px,
+ text-with-icon-horizontal-padding: 10px,
+ )
+ );
}
+// The kit's toolbar picker: a secondary button whose chevron says it opens
+// a menu. With refinements applied it takes the selected-row tint; the
+// count badge says how many.
.refine-action {
grid-area: refine;
justify-self: end;
- padding: 0 12px;
- background: transparent;
- border: 1px solid color-mix(in srgb, var(--app-on-surface) 8%, transparent);
+ flex-shrink: 0;
app-region: no-drag;
+ @include controls.secondary-button(sm);
+
+ &.refine-action-active {
+ @include mat.button-overrides(
+ (
+ filled-container-color: var(--app-list-selected-bg),
+ filled-label-text-color: var(--app-list-selected-fg),
+ )
+ );
+ }
+}
+
+.refine-action__chevron {
+ @include controls.trigger-chevron;
}
.refine-count {
display: inline-flex;
+ margin-inline-start: 6px;
align-items: center;
justify-content: center;
min-width: 18px;
diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html
index e0d16d321..efbd34fab 100644
--- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html
+++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html
@@ -58,7 +58,10 @@
('WORKSPACE.SORT_LABEL' | translate) +
(favSortLabelKey() | translate)
"
- [attr.aria-label]="'WORKSPACE.SORT_LABEL' | translate"
+ [attr.aria-label]="
+ ('WORKSPACE.SORT_LABEL' | translate) +
+ (favSortLabelKey() | translate)
+ "
>
sort_by_alpha
diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts
index 94c17550f..18019a6e7 100644
--- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts
+++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts
@@ -361,6 +361,35 @@ describe('UnifiedCollectionPageComponent', () => {
}
);
+ it('names the favorites sort button with the current order', async () => {
+ const favorite = {
+ uid: 'm3u::playlist-1::https://example.com/one.m3u8',
+ name: 'Favorite One',
+ contentType: 'live',
+ sourceType: 'm3u',
+ playlistId: 'playlist-1',
+ playlistName: 'Playlist One',
+ streamUrl: 'https://example.com/one.m3u8',
+ } satisfies UnifiedCollectionItem;
+ favoritesData.getFavorites.mockResolvedValueOnce([favorite]);
+
+ fixture.componentRef.setInput('mode', 'favorites');
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.componentInstance.isLoading.set(false);
+ fixture.componentInstance.selectedContentType.set('live');
+ fixture.componentInstance.allItems.set([favorite]);
+ fixture.detectChanges();
+
+ const button = fixture.nativeElement.querySelector(
+ '.favorites-sort-button'
+ ) as HTMLButtonElement;
+ // No translations are loaded, so the keys are the rendered text.
+ expect(button.getAttribute('aria-label')).toBe(
+ `WORKSPACE.SORT_LABEL${fixture.componentInstance.favSortLabelKey()}`
+ );
+ });
+
it('uses compact loading rows when the runtime has no EPG support', () => {
fixture.detectChanges();
diff --git a/libs/ui/components/src/lib/season-container/season-tabs.component.html b/libs/ui/components/src/lib/season-container/season-tabs.component.html
index 582786ccc..50cefccfc 100644
--- a/libs/ui/components/src/lib/season-container/season-tabs.component.html
+++ b/libs/ui/components/src/lib/season-container/season-tabs.component.html
@@ -1,18 +1,19 @@