Files
iptvnator/apps/electron-backend-e2e/src/theme-tokens.e2e.ts
T
4grayandClaude Opus 5.5 23a1860119 fix(ui): destructive confirmations, verb labels and provider icons (#1783)
* fix(ui): destructive confirmations, verb labels and provider icons

Confirmations: ConfirmDialogData.confirmLabel is required, so no dialog can
fall back to "Yes"/"No"; the dismiss defaults to "Cancel" and
`tone: 'destructive'` styles the confirm with .app-destructive-button. Every
caller names its action ("Remove playlist", "Clear", "Refresh playlist",
"Cancel download" with a "Close" dismiss). The confirm button has the
confirm-dialog-confirm test id and drops its no-op color="primary".

The no-op `warn` color input becomes .app-destructive-button on the EPG
mapping, playlist item, error view, EPG/reset settings, delete-all and source
cleanup buttons, and on the unsaved-changes dialog's Discard.

Provider icons come from SOURCE_TYPE_ICONS in shared/interfaces (Xtream
cloud, Stalker cast, M3U playlist_play / link / description / subject) in the
add dialog, auto-import, empty state, playlist switcher, playlist rows,
dashboard source rail, command palette, Sources filters and both reset
summaries. Stalker no longer borrows the Dashboard icon, and Xtream no longer
shares a glyph with M3U URL playlists.

The playlist error view removed a playlist through the stale
PlaylistActions.removePlaylist: it dropped the playlist from state before the
delete ran, swallowed failures, skipped the source activity guard and showed
no toast. It now uses PlaylistDeleteActionService like every other removal,
commits only a completed delete, toasts and goes home. The unused action and
its effect are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): one provider icon per playlist row, imperative Korean remove label

A restored Stalker or Xtream playlist can also carry a URL, and the row's
independent checks then showed the M3U URL icon next to the provider icon.
The row now switches on resolvePlaylistSourceIconKey(), the precedence every
other surface uses, so each source shows exactly one icon.

HOME.PLAYLISTS.REMOVE now names the confirm button and the row's delete
tooltip; in Korean it read "the playlist has been removed". It now says
"remove playlist", like every other locale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): keep the auto-refresh badge on playlist rows with one provider icon

Showing one provider icon per row moved the auto-refresh badge into the M3U
branches only, so a restored Stalker playlist with a URL and auto-refresh
lost it although the URL is still re-fetched. The row now renders one icon
container: the provider icon from the shared precedence, then the badge for
any row with a URL or a local M3U, exactly the rows that showed it before.
The Xtream portal-status dot, used without source health, keeps that corner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): let the playlist row's cancel action render in the error color

The row's action buttons set `color: inherit`, and the selected row does so
again with more specific selectors. Both beat Material's token-driven icon
color, so the .app-destructive-button cancel action kept the row color
(selection blue on the active row). Pin the cancel button to
--mat-sys-error in both row states.

The large-deletion Electron E2E now checks the cancel color in both themes;
without this rule it reads rgb(47, 123, 255) instead of the error red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ui): give the dialog service spec the now-required confirm labels

ConfirmDialogData.confirmLabel became required, and the spec still built
confirmations without one. Jest only transpiles, so the suite stayed green,
but the "Typecheck Jest spec programs" CI step rejected it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:36:16 +02:00

249 lines
10 KiB
TypeScript

import type { Locator, Page } from '@playwright/test';
import {
closeElectronApp,
expect,
launchElectronApp,
openSettings,
openSettingsSection,
test,
} from './electron-test-fixtures';
import { applyTheme } from './theme-contrast';
/**
* WCAG contrast between an element's outline colour and the fill inside it,
* both composited over the backgrounds behind the field.
*/
async function outlineContrast(
outline: Locator,
fillSelector: string
): Promise<number> {
return outline.evaluate((el, selector) => {
const ctx = document.createElement('canvas').getContext('2d')!;
const field = el.closest('mat-form-field')!;
const layers = [
getComputedStyle(field.querySelector(selector)!).backgroundColor,
];
for (let node = field.parentElement; node; node = node.parentElement) {
layers.unshift(getComputedStyle(node).backgroundColor);
}
const paint = (colors: string[]) => {
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, 1, 1);
for (const color of colors) {
ctx.fillStyle = color;
ctx.fillRect(0, 0, 1, 1);
}
const [r, g, b] = Array.from(
ctx.getImageData(0, 0, 1, 1).data.slice(0, 3)
).map((channel) => {
const c = channel / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const fill = paint(layers);
const line = paint([...layers, getComputedStyle(el).borderTopColor]);
return (Math.max(fill, line) + 0.05) / (Math.min(fill, line) + 0.05);
}, fillSelector);
}
/** Resolves a CSS color (including var() and color-mix()) to rgb()/rgba(). */
async function resolveColor(page: Page, value: string): Promise<string> {
return page.evaluate((css) => {
const probe = document.createElement('div');
probe.style.color = css;
document.body.appendChild(probe);
const color = getComputedStyle(probe).color;
probe.remove();
return color;
}, value);
}
async function systemVariable(page: Page, name: string): Promise<string> {
return page.evaluate(
(variable) =>
getComputedStyle(document.body).getPropertyValue(variable).trim(),
name
);
}
test.describe('Theme tokens', () => {
test('@theme @electron declares Material system variables and applies app token overrides in both themes', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
const page = app.mainWindow;
try {
await openSettings(page);
const surfaces: Record<string, string> = {};
for (const theme of ['light', 'dark'] as const) {
await applyTheme(page, theme);
// The legacy define-theme config never emits these; app
// styles that read them used to resolve to nothing.
for (const variable of [
'--mat-sys-surface',
'--mat-sys-on-surface',
'--mat-sys-outline-variant',
'--mat-sys-error',
'--mat-sys-corner-medium',
'--mat-sys-body-medium',
]) {
expect(
await systemVariable(page, variable),
`${variable} in ${theme} theme`
).not.toBe('');
}
surfaces[theme] = await systemVariable(
page,
'--mat-sys-surface'
);
// The dark surface belongs to the app host. Components that
// carry `dark-theme` for its tokens (the fullscreen channel
// panel, the diagnostic's alternative sources) keep their own.
const backgrounds = await page.evaluate(() => {
const nested = document.createElement('div');
nested.className = 'dark-theme';
nested.style.background = 'rgb(22, 27, 36)';
document.body.appendChild(nested);
const result = {
nested: getComputedStyle(nested).backgroundColor,
body: getComputedStyle(document.body).backgroundColor,
};
nested.remove();
return result;
});
expect(backgrounds.nested, `nested in ${theme} theme`).toBe(
'rgb(22, 27, 36)'
);
if (theme === 'dark') {
expect(backgrounds.body).toBe(
await resolveColor(page, 'var(--mat-sys-surface)')
);
}
// Form-field overrides must reach Material's --mat-* tokens
// (the retired --mdc-* names were ignored): the outline takes
// the app's 10px shape instead of the 4px M3 default.
const outline = page
.locator(
'mat-form-field.mat-form-field-appearance-outline .mdc-notched-outline__leading'
)
.first();
await expect(outline).toBeVisible();
await expect
.poll(() =>
outline.evaluate(
(el) => getComputedStyle(el).borderTopLeftRadius
)
)
.toBe('10px');
// The outline is the field's only boundary (WCAG 1.4.11).
expect(
await outlineContrast(
outline,
'.mat-mdc-text-field-wrapper'
),
`field outline contrast in ${theme} theme`
).toBeGreaterThanOrEqual(3);
// A floated label inherits the field's text size and renders
// at 75% of it; it must stay legible.
const floated = page
.locator('mat-form-field .mdc-floating-label--float-above')
.first();
await expect(floated).toBeVisible();
expect(
await floated.evaluate((el) => {
const style = getComputedStyle(el);
const scale = new DOMMatrixReadOnly(style.transform).a;
return parseFloat(style.fontSize) * scale;
}),
`floated label size in ${theme} theme`
).toBeGreaterThanOrEqual(11);
// The typography tokens carry the font stack through
// --app-font-family. A dangling var() voids the whole `font`
// shorthand; body text would still inherit the same family,
// so check the token's own size and weight.
expect(
await page.evaluate(() => {
const probe = document.createElement('span');
probe.style.font = 'var(--mat-sys-label-small)';
document.body.appendChild(probe);
const style = getComputedStyle(probe);
const font = {
family: style.fontFamily,
size: Math.round(parseFloat(style.fontSize)),
weight: style.fontWeight,
};
probe.remove();
return font;
}),
`label-small typography in ${theme} theme`
).toEqual({
family: expect.stringMatching(/^"?DM Sans"?,/),
size: 11,
weight: '500',
});
}
expect(surfaces['light']).not.toBe(surfaces['dark']);
} finally {
await closeElectronApp(app);
}
});
test('@theme @electron destructive actions name the action and use the error color', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
const page = app.mainWindow;
try {
await openSettings(page);
await openSettingsSection(page, 'epg');
for (const theme of ['light', 'dark'] as const) {
await applyTheme(page, theme);
const error = await resolveColor(page, 'var(--mat-sys-error)');
const trigger = page.getByRole('button', {
name: 'Clear EPG data',
exact: true,
});
// The `warn` color input was a no-op with M3: the trigger
// rendered in the primary color.
await expect
.poll(() =>
trigger.evaluate((el) => getComputedStyle(el).color)
)
.toBe(error);
await trigger.click();
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
await expect(
dialog.getByRole('button', { name: 'Yes' })
).toHaveCount(0);
const confirm = dialog.getByTestId('confirm-dialog-confirm');
await expect(confirm).toHaveText('Clear EPG data');
await expect(confirm).toHaveClass(/app-destructive-button/);
await expect
.poll(() =>
confirm.evaluate(
(el) => getComputedStyle(el).backgroundColor
)
)
.toBe(error);
await dialog
.getByRole('button', { name: 'Cancel', exact: true })
.click();
await expect(dialog).toBeHidden();
}
} finally {
await closeElectronApp(app);
}
});
});