From 03dfafd68b4ce441058100b41bc034a205563871 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:02:57 +0200 Subject: [PATCH 1/6] fix(player): pin overlay reds to the --pc-* palette (#1759) --- .changes/playback-overlay-reds.md | 6 + docs/architecture/iptvnator-ui-guidelines.md | 4 +- docs/architecture/player-controls-contract.md | 7 +- .../player-controls.component.scss | 16 +- .../player-controls.palette.spec.ts | 150 ++++++++++++++++++ .../player-timeline.component.scss | 6 +- 6 files changed, 181 insertions(+), 8 deletions(-) create mode 100644 .changes/playback-overlay-reds.md create mode 100644 libs/ui/playback/src/lib/player-controls/player-controls.palette.spec.ts diff --git a/.changes/playback-overlay-reds.md b/.changes/playback-overlay-reds.md new file mode 100644 index 000000000..6e724ba07 --- /dev/null +++ b/.changes/playback-overlay-reds.md @@ -0,0 +1,6 @@ +--- +type: fix +area: playback +--- + +The player's LIVE badge uses a slightly deeper red so its label is easier to read, and the record button now turns red while a recording runs. Both reds stay the same in the light and dark themes. diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 9397e23f7..3033a67cd 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -89,7 +89,9 @@ app text. Loader/stall and transient feedback overlays own a light foreground and dark scrim because they cover video. Video viewports remain black in both themes and fullscreen; frame-copy and built-in shared controls keep their light-on-dark overlay palette — the fixed `--pc-*` token set of the shared -dock (accent blue, cyan, violet and a light text ramp), never the app theme. +dock (accent blue, cyan, violet, the `--pc-live` / `--pc-danger` reds and a +light text ramp), never the app theme. The overlay styles in +`player-controls/` never read a `--mat-sys-*` token. EPG timeline, list, empty states and programme details use the library-local `libs/ui/epg/src/lib/_epg-theme.scss` palette, based on app surfaces, separators, diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index b1a770685..2417b6953 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -309,8 +309,11 @@ of its own: a timeline row above a three-column control row, sitting directly on the video over the bottom scrim. The palette is a fixed set of `--pc-*` custom properties on `:host` — accent blue `#4f8eff` for the primary action and progress, cyan `#5cd6ff` for "something is on", violet -`#b599ff` for "a value was changed", and the `#e7ecf3` / `#9aa3b2` / -`#6b7384` text ramp. They are literal on purpose: the overlay is +`#b599ff` for "a value was changed", the `#e7ecf3` / `#9aa3b2` / +`#6b7384` text ramp, and two reds: `--pc-live` `#d32f2f` fills the LIVE +badge (white label 5.0:1), and `--pc-danger` `#ff5252` colours the active +record glyph and the recording status (6.2:1 on the glass over a black +frame). They are literal on purpose: the overlay is theme-independent (see the UI guidelines' player theme boundary), and the app's `--app-selection-color` is a different blue that would fight the video. diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.scss b/libs/ui/playback/src/lib/player-controls/player-controls.component.scss index 880a52c58..e721e8735 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.scss +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.scss @@ -12,6 +12,13 @@ --pc-accent-blue-hover: #2a66d6; --pc-accent-cyan: #5cd6ff; --pc-accent-violet: #b599ff; + // Reds, literal like the rest: the theme's error colour changes with the + // theme, and its dark-theme tone is too pale to carry a white label. + // White on the LIVE fill is 5.0:1; the earlier #e53935 gave only 4.2:1, + // under the 4.5:1 text floor. The record glyph and the recording status + // text read 6.2:1 on the glass over a black frame. + --pc-live: #d32f2f; + --pc-danger: #ff5252; --pc-text: #e7ecf3; --pc-text-secondary: #9aa3b2; --pc-text-tertiary: #6b7384; @@ -316,8 +323,13 @@ gap: 4px; } -.player-controls__record-button--active { - color: var(--mat-sys-error, #ff5252) !important; +// The glyph is the only visible part of the button, and the icon-button rule +// below colours the mat-icon directly, so the red must name the icon too. +// A disabled button (loading, e.g. a reconnect mid-recording) keeps the muted +// disabled colour, so it does not look clickable. +.player-controls__record-button--active:not([disabled]), +.player-controls__record-button--active:not([disabled]) mat-icon { + color: var(--pc-danger) !important; } // Value chips (subtitles, speed): the two settings a viewer changes mid-film. diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.palette.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.palette.spec.ts new file mode 100644 index 000000000..62bee3157 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-controls.palette.spec.ts @@ -0,0 +1,150 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +// The overlay sits on video in both app themes, so its colours come from the +// fixed `--pc-*` palette. A theme token read here stays invisible only while +// the theme leaves it undeclared; once declared, the overlay follows the theme. +const CONTROLS_DIR = resolve( + process.cwd(), + 'libs/ui/playback/src/lib/player-controls' +); + +// Comments are dropped so prose that names a token can neither trip nor +// satisfy the checks below. A `//` counts only after whitespace, which keeps +// the `://` of a URL intact. +function stripComments(source: string): string { + return source + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/(^|\s)\/\/[^\n]*/g, '$1'); +} + +const STYLE_SOURCES = new Map( + readdirSync(CONTROLS_DIR) + .filter((file) => file.endsWith('.scss')) + .map((file) => [ + file, + stripComments(readFileSync(resolve(CONTROLS_DIR, file), 'utf8')), + ]) +); + +const HOST_STYLES = STYLE_SOURCES.get('player-controls.component.scss') ?? ''; +const TIMELINE_STYLES = + STYLE_SOURCES.get('player-timeline.component.scss') ?? ''; + +type Rgb = [number, number, number]; + +function paletteColor(token: string): { rgb: Rgb; alpha: number } { + const value = + HOST_STYLES.match(new RegExp(`${token}:\\s*([^;]+);`))?.[1].trim() ?? + ''; + const hex = value.match(/^#([0-9a-f]{6})$/i)?.[1]; + if (hex) { + const rgb = [0, 2, 4].map((offset) => + Number.parseInt(hex.slice(offset, offset + 2), 16) + ) as Rgb; + return { rgb, alpha: 1 }; + } + const rgba = value.match(/^rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)$/); + if (rgba) { + const rgb = rgba.slice(1, 4).map(Number) as Rgb; + return { rgb, alpha: Number(rgba[4]) }; + } + throw new Error(`${token} is not a literal palette colour: "${value}"`); +} + +function contrastRatio(foreground: Rgb, background: Rgb): number { + const luminance = (rgb: Rgb): number => { + const [red, green, blue] = rgb.map((channel) => { + const normalized = channel / 255; + return normalized <= 0.04045 + ? normalized / 12.92 + : ((normalized + 0.055) / 1.055) ** 2.4; + }); + return red * 0.2126 + green * 0.7152 + blue * 0.0722; + }; + const values = [luminance(foreground), luminance(background)].sort( + (left, right) => right - left + ); + return (values[0] + 0.05) / (values[1] + 0.05); +} + +describe('player controls overlay palette', () => { + it('never reads the app theme system tokens', () => { + expect([...STYLE_SOURCES.keys()]).toEqual( + expect.arrayContaining([ + 'player-controls.component.scss', + 'player-timeline.component.scss', + ]) + ); + const offenders = [...STYLE_SOURCES] + .filter(([, source]) => source.includes('--mat-sys-')) + .map(([file]) => file); + + expect(offenders).toEqual([]); + expect( + stripComments('// names --mat-sys-error\n/* and --mat-sys-x */') + ).not.toContain('--mat-sys-'); + expect(stripComments("url('https://example.com/a.svg')")).toBe( + "url('https://example.com/a.svg')" + ); + }); + + it('draws the LIVE badge and the recording reds from the palette', () => { + expect(TIMELINE_STYLES).toMatch( + /\.player-controls__live-badge\s*\{[^}]*background:\s*var\(--pc-live\);/ + ); + expect(TIMELINE_STYLES).toMatch( + /\.player-controls__recording-status--active\s*\{[^}]*color:\s*var\(--pc-danger\);/ + ); + expect(TIMELINE_STYLES).toMatch( + /\.player-controls__recording-status--active mat-icon\s*\{[^}]*color:\s*var\(--pc-danger\);/ + ); + // The icon-button rule colours the mat-icon itself, so a red set only + // on the button never reaches the glyph. + expect(HOST_STYLES).toMatch( + /\.player-controls__record-button--active:not\(\[disabled\]\),\s*\.player-controls__record-button--active:not\(\[disabled\]\) mat-icon\s*\{[^}]*color:\s*var\(--pc-danger\)/ + ); + }); + + it('lets a disabled record button keep its muted colour while recording', () => { + // The red is `!important`, so an unscoped rule would also beat the + // disabled icon colour: a recording that reconnects (loading, so the + // button is disabled) would show a red glyph that cannot be clicked. + const activeRecordSelectors = [ + ...HOST_STYLES.matchAll(/([^{}]*)\{[^}]*var\(--pc-danger\)/g), + ].flatMap(([, selectors]) => + selectors + .split(',') + .map((selector) => selector.trim()) + .filter((selector) => + selector.includes('player-controls__record-button--active') + ) + ); + + expect(activeRecordSelectors).toEqual([ + '.player-controls__record-button--active:not([disabled])', + '.player-controls__record-button--active:not([disabled]) mat-icon', + ]); + expect(HOST_STYLES).toMatch( + /:host :is\(button\[mat-icon-button\]\[disabled\]\) mat-icon\s*\{[^}]*color:\s*var\(--pc-text-tertiary\);/ + ); + }); + + it('keeps the palette reds readable on video', () => { + const white: Rgb = [255, 255, 255]; + const glass = paletteColor('--pc-glass-bg'); + const glassOnBlackFrame = glass.rgb.map( + (channel) => channel * glass.alpha + ) as Rgb; + + // The white LIVE label is small bold text: the 4.5:1 text floor. + expect( + contrastRatio(white, paletteColor('--pc-live').rgb) + ).toBeGreaterThanOrEqual(4.5); + // "REC 0:12" is text too, so it gets the text floor as well, not + // just the 3:1 a glyph would need. + expect( + contrastRatio(paletteColor('--pc-danger').rgb, glassOnBlackFrame) + ).toBeGreaterThanOrEqual(4.5); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/player-timeline.component.scss b/libs/ui/playback/src/lib/player-controls/player-timeline.component.scss index bd6275d44..ebd986e32 100644 --- a/libs/ui/playback/src/lib/player-controls/player-timeline.component.scss +++ b/libs/ui/playback/src/lib/player-controls/player-timeline.component.scss @@ -141,12 +141,12 @@ } .player-controls__recording-status--active { - color: var(--mat-sys-error, #ff5252); + color: var(--pc-danger); font-weight: 700; } .player-controls__recording-status--active mat-icon { - color: var(--mat-sys-error, #ff5252); + color: var(--pc-danger); } .player-controls__live-badge { @@ -156,7 +156,7 @@ padding: 2px 8px; border-radius: 4px; color: #ffffff; - background: var(--mat-sys-error, #e53935); + background: var(--pc-live); font-size: 0.66rem; font-weight: 700; letter-spacing: 0.08em; From dc3f8298071e1f23206a8ed3d8b3004ce3e8f3cb Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:03:29 +0200 Subject: [PATCH 2/6] fix(parental-lock): focus, tokens and phone width for lock UI (#1761) --- .changes/settings-parental-lock-ui-polish.md | 6 + apps/web-e2e/src/parental-lock-ui.e2e.ts | 412 ++++++++++++++++++ docs/architecture/parental-lock.md | 9 +- ...talker-category-lock-dialog.component.html | 3 + ...talker-category-lock-dialog.component.scss | 58 ++- ...ker-category-lock-dialog.component.spec.ts | 12 + .../category-management-dialog.component.html | 101 +++-- .../category-management-dialog.component.scss | 8 +- ...tegory-management-dialog.component.spec.ts | 140 +++++- .../category-management-dialog.component.ts | 94 ++-- .../group-management-dialog.component.html | 3 + .../group-management-dialog.component.scss | 8 +- .../groups-view/groups-view.component.scss | 18 +- .../groups-view/groups-view.component.spec.ts | 1 + .../groups-view/groups-view.component.ts | 1 + .../parental-lock-pin-dialog.component.scss | 16 +- .../workspace-context-panel.component.html | 3 + .../workspace-context-panel.component.scss | 18 +- .../workspace-context-panel.component.spec.ts | 34 ++ .../workspace-context-panel.component.ts | 2 + 20 files changed, 826 insertions(+), 121 deletions(-) create mode 100644 .changes/settings-parental-lock-ui-polish.md create mode 100644 apps/web-e2e/src/parental-lock-ui.e2e.ts diff --git a/.changes/settings-parental-lock-ui-polish.md b/.changes/settings-parental-lock-ui-polish.md new file mode 100644 index 000000000..58c0e3a62 --- /dev/null +++ b/.changes/settings-parental-lock-ui-polish.md @@ -0,0 +1,6 @@ +--- +type: fix +area: settings +--- + +Parental lock screens are easier to use: keyboard focus is visible on the "N locked" rows and in the Stalker lock list, locked categories stand out in the management dialogs, a wrong PIN shows in red, and the lock dialogs fit on a phone. In the browser version the category dialog no longer shows hide/show controls that do nothing there. diff --git a/apps/web-e2e/src/parental-lock-ui.e2e.ts b/apps/web-e2e/src/parental-lock-ui.e2e.ts new file mode 100644 index 000000000..15a37f4bd --- /dev/null +++ b/apps/web-e2e/src/parental-lock-ui.e2e.ts @@ -0,0 +1,412 @@ +import type { Locator, Page } from '@playwright/test'; +import { join } from 'path'; +import { expect, test } from './fixtures'; +import { pressTab, surfaceContrast } from './e2e-helpers'; +import { + addStalkerPortal, + addXtreamPortal, + interceptPwaProviderRequests, + resetPwaMockServers, +} from './sources-pwa.helpers'; + +/** + * Parental lock UI on app tokens (PWA). + * + * The lock rows, dialogs and PIN error once used Material system colours the + * theme never declares, so every one of them resolved to nothing: no focus + * ring after the outline reset, a dashed border and list border with no + * colour, a PIN error in body text, and dialogs 440–480px wide on a 375px + * phone. These tests pin the visible result in both themes: + * + * 1. Tabbing to each "N locked" row and each Stalker lock row shows a + * 2px ring with >= 3:1 contrast against the row and its surface. + * 2. The rail rows' dashed border and the lock list's border are painted. + * 3. The PIN error is a red with >= 4.5:1 contrast on the dialog. + * 4. The lock dialogs fit a 375px viewport without sideways overflow. + * 5. In the PWA the Xtream dialog shows lock controls only — hide/show is + * Electron-only — and its clear-search button has a name. + * + * Tag: @parental — run only this spec with: + * pnpm nx run web-e2e:e2e-ci--src/parental-lock-ui.e2e.ts + */ + +const M3U_FIXTURE = join(__dirname, 'fixtures/test.m3u'); +const PIN = '2468'; +const PHONE = { width: 375, height: 812 }; +const THEMES = ['light', 'dark'] as const; + +async function setTheme(page: Page, theme: (typeof THEMES)[number]) { + await page.evaluate( + (dark) => document.body.classList.toggle('dark-theme', dark), + theme === 'dark' + ); +} + +async function enableParentalLock(page: Page): Promise { + await page.locator('a[href$="/workspace/settings"]').click(); + await page.waitForURL(/\/workspace\/settings\/general$/); + await page.locator('[data-test-id="settings-section-parental"]').click(); + await page.waitForURL(/\/workspace\/settings\/parental$/); + + const toggle = page.locator( + '[data-test-id="parental-lock-enabled"] button[role="switch"]' + ); + await toggle.click(); + await page.locator('[data-test-id="parental-lock-pin"]').fill(PIN); + await page.locator('[data-test-id="parental-lock-pin-confirm"]').fill(PIN); + await page.locator('[data-test-id="parental-lock-pin-submit"]').click(); + await expect(toggle).toHaveAttribute('aria-checked', 'true'); +} + +/** Answers the unlock prompt that a PIN-gated action opens. */ +async function enterPin(page: Page, pin = PIN): Promise { + const input = page.locator('[data-test-id="parental-lock-pin"]'); + await expect(input).toBeVisible(); + await input.fill(pin); + await page.locator('[data-test-id="parental-lock-pin-submit"]').click(); +} + +/** + * The dialog opened for `component`, once it holds focus: MatDialog moves + * focus to its first tabbable element only after the open animation, which + * would otherwise pull focus away from a row the test has just tabbed to. + */ +async function openedDialog(page: Page, component: string): Promise { + const dialog = page.locator('mat-dialog-container', { + has: page.locator(component), + }); + await expect + .poll(() => + dialog.evaluate((el) => el.contains(document.activeElement)) + ) + .toBe(true); + return dialog; +} + +/** + * WCAG contrast of one colour property against what is painted behind the + * element: white, each ancestor's background and, with `ownFill`, the + * element's own background (the approach of `surfaceContrast`). + */ +async function contrastBehind( + locator: Locator, + property: 'color' | 'outlineColor', + ownFill: boolean +): Promise { + return locator.evaluate( + (element, [property, ownFill]) => { + const canvas = document.createElement('canvas'); + canvas.width = canvas.height = 1; + const context = canvas.getContext('2d'); + if (!context) throw new Error('Canvas is required'); + const paint = (color: string) => { + context.fillStyle = color; + context.fillRect(0, 0, 1, 1); + }; + const luminance = () => + Array.from(context.getImageData(0, 0, 1, 1).data) + .slice(0, 3) + .map((value) => { + const channel = value / 255; + return channel <= 0.04045 + ? channel / 12.92 + : ((channel + 0.055) / 1.055) ** 2.4; + }) + .reduce( + (sum, channel, index) => + sum + channel * [0.2126, 0.7152, 0.0722][index], + 0 + ); + const layers: Element[] = ownFill ? [element] : []; + for (let e = element.parentElement; e; e = e.parentElement) { + layers.unshift(e); + } + paint('white'); + layers.forEach((layer) => + paint(getComputedStyle(layer).backgroundColor) + ); + const behind = luminance(); + paint(getComputedStyle(element)[property]); + const front = luminance(); + return ( + (Math.max(front, behind) + 0.05) / + (Math.min(front, behind) + 0.05) + ); + }, + [property, ownFill] as const + ); +} + +/** Focus `from` and Tab (as that browser's user does) until `target` has focus. */ +async function tabTo( + page: Page, + browserName: string, + from: Locator, + target: Locator, + maxTabs = 1 +): Promise { + await from.focus(); + for (let i = 0; i < maxTabs; i++) { + await pressTab(page, browserName); + if (await target.evaluate((el) => el === document.activeElement)) { + break; + } + } + await expect(target).toBeFocused(); +} + +/** + * The ring sits inside the row (offset -2px), so it must stand out from the + * row's own fill and from the surface around the row. + */ +async function expectFocusRing(row: Locator): Promise { + await expect(row).toBeFocused(); + await expect(row).toHaveCSS('outline-style', 'solid'); + await expect(row).toHaveCSS('outline-width', '2px'); + const inside = await contrastBehind(row, 'outlineColor', true); + const outside = await contrastBehind(row, 'outlineColor', false); + expect(Math.min(inside, outside)).toBeGreaterThanOrEqual(3); +} + +/** Colour and fill of a lock toggle, to tell locked from unlocked. */ +async function toggleLook(toggle: Locator): Promise { + return toggle.evaluate((el) => { + const style = getComputedStyle(el); + return `${style.color} ${style.backgroundColor}`; + }); +} + +async function expectPaintedBorder( + locator: Locator, + style: 'dashed' | 'solid' +): Promise { + await expect(locator).toHaveCSS('border-top-style', style); + const { border } = await surfaceContrast(locator); + // 1 means the border colour is transparent against its own fill. + expect(border).toBeGreaterThan(1.15); +} + +async function expectDialogFitsPhone( + page: Page, + dialog: Locator +): Promise { + await page.setViewportSize(PHONE); + await expect + .poll(async () => { + const box = await dialog.boundingBox(); + return box ? box.x >= 0 && box.x + box.width <= PHONE.width : false; + }) + .toBe(true); + // A min-width on the content overflows the capped panel without + // widening the panel or the document, so measure the blocks inside. + const overflow = await dialog.evaluate((panel) => { + const edge = panel.getBoundingClientRect().right; + const blocks = panel.querySelectorAll( + 'mat-dialog-content, mat-dialog-actions, .header-actions, ' + + '.search-inline, .categories-list' + ); + return Math.max( + ...Array.from( + blocks, + (block) => block.getBoundingClientRect().right - edge + ) + ); + }); + expect(overflow).toBeLessThanOrEqual(0.5); + expect( + await page.evaluate(() => document.documentElement.scrollWidth) + ).toBeLessThanOrEqual(PHONE.width); +} + +test.describe('Parental lock UI', () => { + test.beforeEach(async ({ page, request }) => { + await resetPwaMockServers(request); + await interceptPwaProviderRequests(page); + await page.goto('/'); + }); + + test('@parental @xtream PWA dialog, locked-categories row and PIN error', async ({ + page, + browserName, + }) => { + test.slow(); + await addXtreamPortal(page, 'Lock UI Portal'); + const portalUrl = page.url(); + await enableParentalLock(page); + // A reload starts locked: the unlock lives in memory only. + await page.goto(portalUrl); + + const manage = page.locator( + '[data-test-id="context-manage-categories"]' + ); + await expect(manage).toBeEnabled({ timeout: 15_000 }); + await manage.click(); + await enterPin(page); + + const dialog = await openedDialog( + page, + 'app-category-management-dialog' + ); + const toggles = dialog.locator('[data-test-id^="category-lock-"]'); + await expect(toggles.first()).toBeVisible(); + + // PWA: locks only, no hide/show selection controls. + await expect( + dialog.locator('[data-test-id="category-locked-count"]') + ).toBeVisible(); + await expect(dialog.locator('.bulk-actions')).toHaveCount(0); + await expect(dialog.locator('mat-checkbox')).toHaveCount(0); + + await dialog.locator('.search-inline input').fill('a'); + await dialog.getByRole('button', { name: 'Clear search' }).click(); + await expect(dialog.locator('.search-inline input')).toHaveValue(''); + + // Locked reads as a state, not only as another glyph. + await toggles.first().click(); + await expect(toggles.first()).toHaveAttribute('aria-pressed', 'true'); + expect(await toggleLook(toggles.first())).not.toBe( + await toggleLook(toggles.nth(1)) + ); + + await dialog.getByRole('button', { name: 'Save' }).click(); + await expect(dialog).toBeHidden(); + await page.locator('[data-test-id="header-parental-lock"]').click(); + + const lockedRow = page.locator( + '[data-test-id="context-locked-categories"]' + ); + await expect(lockedRow).toBeVisible(); + await tabTo( + page, + browserName, + page.locator('.context-panel .category-item').last(), + lockedRow + ); + for (const theme of THEMES) { + await setTheme(page, theme); + await expectFocusRing(lockedRow); + await expectPaintedBorder(lockedRow, 'dashed'); + } + + // A wrong PIN reads as an error in both themes. + await lockedRow.click(); + await enterPin(page, '0000'); + const error = page.locator('[data-test-id="parental-lock-pin-error"]'); + await expect(error).toBeVisible(); + for (const theme of THEMES) { + await setTheme(page, theme); + const [r, g, b] = await error.evaluate((el) => + (getComputedStyle(el).color.match(/\d+/g) ?? []).map(Number) + ); + expect(r - Math.max(g, b)).toBeGreaterThan(50); + expect(await contrastBehind(error, 'color', true)).toBeGreaterThan( + 4.5 + ); + } + await enterPin(page); + await expect(lockedRow).toBeHidden(); + + await manage.click(); + await expect(toggles.first()).toBeVisible(); + await expectDialogFitsPhone(page, dialog); + }); + + test('@parental @stalker lock dialog rows, list border and phone width', async ({ + page, + browserName, + }) => { + test.slow(); + await addStalkerPortal(page, 'Lock UI Stalker'); + const portalUrl = page.url(); + await enableParentalLock(page); + await page.goto(portalUrl); + + const manage = page.locator( + '[data-test-id="context-manage-categories"]' + ); + await expect(manage).toBeEnabled({ timeout: 15_000 }); + await manage.click(); + await enterPin(page); + + const dialog = await openedDialog( + page, + 'app-stalker-category-lock-dialog' + ); + const rows = dialog.locator('.category-item'); + await expect(rows.first()).toBeVisible(); + + const search = dialog.locator('.search-inline input'); + await tabTo(page, browserName, search, rows.first()); + for (const theme of THEMES) { + await setTheme(page, theme); + await expectFocusRing(rows.first()); + await expectPaintedBorder( + dialog.locator('.categories-list'), + 'solid' + ); + } + // The next row takes the ring over when Tab moves on. + await pressTab(page, browserName); + await expectFocusRing(rows.nth(1)); + + await search.fill('a'); + await dialog.getByRole('button', { name: 'Clear search' }).click(); + await expect(search).toHaveValue(''); + + await expectDialogFitsPhone(page, dialog); + }); + + test('@parental @m3u locked-groups row', async ({ page, browserName }) => { + test.slow(); + // The fixture's placeholder media hosts stay offline. + await page.route( + /^https?:\/\/(?:channel\.icons\.url|example\.channels|xml-url)\//, + (route) => route.abort() + ); + await page.getByRole('button', { name: 'Add playlist' }).click(); + const addDialog = page.locator('mat-dialog-container'); + await addDialog.getByRole('radio', { name: /M3U file/i }).click(); + await page.setInputFiles('input[type="file"]', M3U_FIXTURE); + await Promise.all([ + page.waitForURL(/\/workspace\/playlists\/.+\/all$/), + addDialog + .getByRole('button', { name: 'Add playlist', exact: true }) + .click(), + ]); + const groupsUrl = page.url().replace(/\/all$/, '/groups'); + await enableParentalLock(page); + await page.goto(groupsUrl); + + const manage = page.locator('.groups-nav-action--manage'); + await expect(manage).toBeVisible({ timeout: 15_000 }); + await manage.click(); + await enterPin(page); + + const dialog = await openedDialog(page, 'app-group-management-dialog'); + const toggle = dialog.locator('[data-test-id="group-lock-News"]'); + await toggle.click(); + await expect(toggle).toHaveAttribute('aria-pressed', 'true'); + expect(await toggleLook(toggle)).not.toBe( + await toggleLook( + dialog.locator('[data-test-id="group-lock-Sport"]') + ) + ); + await dialog.getByRole('button', { name: 'Save' }).click(); + await expect(dialog).toBeHidden(); + await page.locator('[data-test-id="header-parental-lock"]').click(); + + const lockedRow = page.locator('[data-test-id="groups-locked-row"]'); + await expect(lockedRow).toBeVisible(); + await tabTo( + page, + browserName, + page.locator('.groups-nav-list .nav-item').last(), + lockedRow + ); + for (const theme of THEMES) { + await setTheme(page, theme); + await expectFocusRing(lockedRow); + await expectPaintedBorder(lockedRow, 'dashed'); + } + }); +}); diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index d049b35b0..d9b871fb0 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -374,7 +374,8 @@ so a cancelled or refused PIN leaves it showing the real state. the PIN. The Xtream dialog loads its candidates through the capability-selected data source (`IXtreamDataSource.getAllCategories`, which the PWA source answers from its session cache or the API), so PWA - users can set locks too; the hide/show checkboxes remain Electron-only. + users can set locks too; the hide/show checkboxes, the selection count + and the Select/Deselect-all actions remain Electron-only. A deliberate non-choice: no dedicated lock button in the rail header (the header already carries search, sort and manage, and the lock is a category-management concern) and no keyword-based "adult" recognition @@ -415,6 +416,12 @@ so a cancelled or refused PIN leaves it showing the real state. so a child cannot switch the idle relock off for the next unlock. - Header lock/unlock button and the `parental-lock-now` / `parental-unlock` palette commands. +- Styling uses app tokens only (the theme declares no `--mat-sys-*`); the + PIN error and the Stalker adult chip use a local red per theme. Keyboard + focus on a lock row is a 2px inset `--app-selection-color` ring, and the + lock dialogs open with `maxWidth: 'calc(100vw - 32px)'` and no content + min-width, so they fit a 375px phone. `apps/web-e2e/src/parental-lock-ui.e2e.ts` + checks rings, borders, the PIN error and phone width in both themes. ## Startup footprint diff --git a/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.html b/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.html index 4c77372d8..c8c50ebe9 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.html @@ -47,6 +47,9 @@ type="button" mat-icon-button class="search-inline__clear" + [attr.aria-label]=" + 'EMBEDDED_MPV.PLAYER.CLEAR_SEARCH' | translate + " (click)="clearSearch()" > close diff --git a/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.scss b/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.scss index 6fa341147..6cda75f1b 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.scss +++ b/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.scss @@ -1,3 +1,13 @@ +// App tokens only: the theme declares no Material system colours. The +// adult-chip red is local, with a value per theme (>= 4.5:1 on its tint). +:host { + --lock-dialog-error-color: #b3261e; +} + +:host-context(.dark-theme) { + --lock-dialog-error-color: #ffb4ab; +} + .mat-mdc-dialog-content { display: flex; flex-direction: column; @@ -7,7 +17,7 @@ .lock-dialog__description { margin: 0; font-size: 0.8125rem; - color: var(--mat-sys-on-surface-variant); + color: var(--app-body-color); } .header-actions { @@ -20,15 +30,16 @@ .selection-info { font-size: 0.8125rem; - color: var(--mat-sys-on-surface-variant); + color: var(--app-body-color); strong { - color: var(--mat-sys-primary); + color: var(--app-heading-color); } } .bulk-actions { display: flex; + flex-wrap: wrap; gap: 8px; button { @@ -43,19 +54,19 @@ padding: 0 10px; min-height: 36px; border-radius: 10px; - border: 1px solid var(--app-search-border, var(--mat-sys-outline-variant)); - background: var(--app-search-bg, var(--mat-sys-surface-container)); + border: 1px solid var(--app-search-border); + background: var(--app-search-bg); cursor: text; &:focus-within { - border-color: var(--mat-sys-primary); + border-color: var(--app-selection-color); } &__icon { font-size: 18px; width: 18px; height: 18px; - color: var(--mat-sys-on-surface-variant); + color: var(--app-eyebrow-color); } input { @@ -63,7 +74,7 @@ border: none; outline: none; background: transparent; - color: var(--mat-sys-on-surface); + color: var(--app-heading-color); font: inherit; font-size: 0.84rem; min-width: 0; @@ -92,7 +103,7 @@ min-height: 200px; max-height: 50vh; overflow-y: auto; - border: 1px solid var(--mat-sys-outline-variant); + border: 1px solid var(--app-separator); border-radius: 8px; } @@ -105,29 +116,36 @@ min-height: 32px; border: none; background: transparent; - color: var(--mat-sys-on-surface); + color: var(--app-heading-color); font: inherit; font-size: 0.8125rem; text-align: left; cursor: pointer; &:hover { - background-color: var(--mat-sys-surface-container); + background-color: var(--app-card-hover-bg); + } + + // The global reset strips every button outline; an inset ring keeps + // the focused row visible inside the list's clipped scroll box. + &:focus-visible { + outline: 2px solid var(--app-selection-color); + outline-offset: -2px; } &:not(:last-child) { - border-bottom: 1px solid var(--mat-sys-surface-container); + border-bottom: 1px solid var(--app-widget-header-border); } &__icon { font-size: 18px; width: 18px; height: 18px; - color: var(--mat-sys-on-surface-variant); + color: var(--app-eyebrow-color); } &--locked .category-item__icon { - color: var(--mat-sys-primary); + color: var(--app-selection-color); } } @@ -144,18 +162,22 @@ font-weight: 600; padding: 1px 6px; border-radius: 999px; - background: color-mix(in srgb, var(--mat-sys-error) 16%, transparent); - color: var(--mat-sys-error); + background: color-mix( + in srgb, + var(--lock-dialog-error-color) 16%, + transparent + ); + color: var(--lock-dialog-error-color); } .empty-message { padding: 24px; text-align: center; - color: var(--mat-sys-on-surface-variant); + color: var(--app-body-color); font-size: 0.8125rem; } +// No min-width: the opener caps the panel at the viewport on a phone. :host ::ng-deep .mat-mdc-dialog-content { - min-width: 440px; max-width: 600px; } diff --git a/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.spec.ts index 0e410f21b..e78aec3c5 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.spec.ts @@ -65,4 +65,16 @@ describe('StalkerCategoryLockDialogComponent', () => { expect(dialogRef.close).toHaveBeenCalledWith(false); expect(parentalLock.setStalkerLocks).not.toHaveBeenCalled(); }); + + it('labels the clear-search button', () => { + component.searchTerm.set('new'); + fixture.detectChanges(); + + const clear: HTMLButtonElement = fixture.nativeElement.querySelector( + '.search-inline__clear' + ); + expect(clear.getAttribute('aria-label')).toBe( + 'EMBEDDED_MPV.PLAYER.CLEAR_SEARCH' + ); + }); }); diff --git a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.html b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.html index 9ade8e26d..789ade247 100644 --- a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.html +++ b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.html @@ -7,48 +7,66 @@

{{ 'XTREAM.CATEGORY_MANAGEMENT.LOADING' | translate }}

} @else { -
-
- {{ 'XTREAM.CATEGORY_MANAGEMENT.SELECTED' | translate }}: - {{ selectedCount() }} / {{ totalCount() }} - @if (showLocks()) { - - · {{ 'PARENTAL_LOCK.LOCKED_COUNT' | translate }}: - {{ lockedCount() }} - + + @if (supportsVisibility || showLocks()) { +
+
+ @if (supportsVisibility) { + {{ 'XTREAM.CATEGORY_MANAGEMENT.SELECTED' | translate }}: + {{ selectedCount() }} / + {{ totalCount() }} + } + @if (showLocks()) { + + @if (supportsVisibility) { + · + } + {{ 'PARENTAL_LOCK.LOCKED_COUNT' | translate }}: + {{ lockedCount() }} + + } +
+ @if (supportsVisibility) { +
+ + +
}
-
- - -
-
+ }