diff --git a/.changes/ui-dialog-action-rows.md b/.changes/ui-dialog-action-rows.md
new file mode 100644
index 000000000..87e1e5d5d
--- /dev/null
+++ b/.changes/ui-dialog-action-rows.md
@@ -0,0 +1,9 @@
+---
+type: fix
+area: ui
+---
+
+The "Unsaved changes" dialog in Settings now uses short Cancel / Discard / Save
+buttons that stay on one row in every language, and stack neatly on phones. The
+programme details dialog has one Close button, keeps its main action on the
+right and opens at the same width from every screen.
diff --git a/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts b/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts
index 034825f12..2762bc823 100644
--- a/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts
+++ b/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts
@@ -20,6 +20,9 @@ import {
fetchStalkerCategoryFixture,
} from './portal-mock-fixtures';
+import type { Locator, Page } from '@playwright/test';
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
import {
applyTheme,
expectTextContrast,
@@ -32,6 +35,42 @@ const epgCredentials = {
password: 'epg',
};
+/**
+ * Every opener shares one dialog config: the programme dialog is named by its
+ * `mat-dialog-title` and opens in the same 540px pane wherever it starts.
+ */
+async function expectProgrammeDialog(
+ page: Page,
+ title?: string
+): Promise {
+ const dialog = page.getByRole('dialog');
+ await expect(dialog).toBeVisible();
+ const heading = dialog.locator('.epg-dialog__title');
+ if (title) {
+ await expect(heading).toHaveText(title);
+ }
+ await expect(dialog).toHaveAccessibleName(
+ (await heading.innerText()).trim()
+ );
+ await expect
+ .poll(() =>
+ dialog.evaluate(
+ (element) =>
+ element.closest('.cdk-overlay-pane')
+ ?.offsetWidth
+ )
+ )
+ .toBe(540);
+ return dialog;
+}
+
+async function closeProgrammeDialog(page: Page, dialog: Locator) {
+ // The footer Close is the dialog's only visible dismiss.
+ await expect(dialog.getByRole('button', { name: 'Close' })).toHaveCount(1);
+ await dialog.locator('.epg-dialog__close').click();
+ await page.waitForSelector('.epg-dialog', { state: 'detached' });
+}
+
test('@epg @xtream @electron opens the programme dialog from a timeline block and reacts to zoom', async ({
dataDir,
request,
@@ -172,17 +211,30 @@ test('@epg @xtream @electron opens the programme dialog from a timeline block an
// programme-details dialog with the programme metadata.
await nowBlock.locator('.epg-timeline__info').click();
- const dialog = app.mainWindow.locator('.epg-dialog');
- await expect(dialog).toBeVisible();
- await expect(dialog.locator('.epg-dialog__title')).toHaveText(
+ const dialog = await expectProgrammeDialog(
+ app.mainWindow,
currentProgram.title
);
- // An on-air programme offers "watch live" as the primary action.
- await expect(dialog.locator('.epg-dialog__btn--primary')).toBeVisible();
+ // An on-air programme offers "watch live" as the primary action, last
+ // in the footer and to the right of the dismiss.
+ const footer = dialog.locator('.epg-dialog__actions button');
+ await expect(footer).toHaveCount(2);
+ await expect(footer.first()).toHaveClass(/epg-dialog__close/);
+ await expect(footer.last()).toHaveClass(/epg-dialog__btn--primary/);
+ // Layout offsets, not bounding boxes: the dialog may still be scaling
+ // in, which transforms client rects.
+ const [closeBox, primaryBox] = await footer.evaluateAll((buttons) =>
+ buttons.map((button) => ({
+ left: (button as HTMLElement).offsetLeft,
+ top: (button as HTMLElement).offsetTop,
+ }))
+ );
+ expect(primaryBox.left).toBeGreaterThan(closeBox.left);
+ expect(primaryBox.top).toBe(closeBox.top);
for (const theme of ['light', 'dark', 'light'] as const) {
await applyTheme(app.mainWindow, theme);
- await expectThemeSurface(dialog, theme);
+ await expectThemeSurface(dialog.locator('.epg-dialog'), theme);
await expectTextContrast(dialog.locator('.epg-dialog__title'));
await expectTextContrast(dialog.locator('.epg-dialog__desc'));
await expectTextContrast(dialog.locator('.epg-dialog__close'), 3);
@@ -190,10 +242,15 @@ test('@epg @xtream @electron opens the programme dialog from a timeline block an
await app.mainWindow.screenshot({
path: test.info().outputPath('epg-light.png'),
});
- await dialog.locator('.epg-dialog__close').click();
- await app.mainWindow.waitForSelector('.epg-dialog', {
- state: 'detached',
- });
+ await closeProgrammeDialog(app.mainWindow, dialog);
+
+ // The channel row's info button opens the same dialog.
+ await channelRow.locator('.program-info-button').click();
+ await closeProgrammeDialog(
+ app.mainWindow,
+ await expectProgrammeDialog(app.mainWindow)
+ );
+
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'epg');
await app.mainWindow.getByTestId('epg-view-mode-list').click();
@@ -218,6 +275,17 @@ test('@epg @xtream @electron opens the programme dialog from a timeline block an
guide.locator('[data-when="now"] .desc').first()
);
}
+ // The list view's info button opens the same dialog.
+ await guide
+ .locator('[data-when="now"]')
+ .first()
+ .getByRole('button', { name: 'Show details about this program' })
+ .click();
+ await closeProgrammeDialog(
+ app.mainWindow,
+ await expectProgrammeDialog(app.mainWindow)
+ );
+
// Keep a fresh channel's EPG IPC pending so the real list loading
// template stays mounted through both theme changes.
await app.electronApp.evaluate(({ ipcMain }) => {
@@ -249,6 +317,112 @@ test('@epg @xtream @electron opens the programme dialog from a timeline block an
}
});
+test('@epg @xtream @electron stacks the programme dialog actions on a phone', async ({
+ dataDir,
+ request,
+}) => {
+ // French carries the longest primary label ("watch from start").
+ const fr = JSON.parse(
+ readFileSync(
+ join(__dirname, '../../web/src/assets/i18n/fr.json'),
+ 'utf8'
+ )
+ ) as {
+ WORKSPACE: { SHELL: { RAIL_LIVE: string } };
+ EPG: {
+ PROGRAM_DIALOG: { SHOW_PROGRAM_DETAILS: string };
+ TIMELINE: { WATCH_FROM_START: string };
+ };
+ };
+ await resetMockServers(request, ['xtream']);
+ const fixture = await fetchXtreamEpgFixture(request, epgCredentials);
+ const app = await launchElectronApp(dataDir, { env: { TZ: 'UTC' } });
+
+ try {
+ await app.mainWindow.route('https://test-streams.mux.dev/**', () => {
+ // Keep the external demo request pending; guide data is local.
+ });
+ await addXtreamPortal(app.mainWindow, {
+ name: 'Xtream Phone Dialog',
+ username: epgCredentials.username,
+ password: epgCredentials.password,
+ });
+ await waitForXtreamWorkspaceReady(app.mainWindow);
+ await openSettings(app.mainWindow);
+ await app.mainWindow.getByTestId('select-language').click();
+ await app.mainWindow.locator('mat-option[data-test-id="fr"]').click();
+ await openSettingsSection(app.mainWindow, 'epg');
+ await app.mainWindow.getByTestId('epg-view-mode-list').click();
+ await saveSettings(app.mainWindow);
+
+ await openWorkspaceSection(
+ app.mainWindow,
+ fr.WORKSPACE.SHELL.RAIL_LIVE
+ );
+ await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
+ await channelItemByTitle(app.mainWindow, fixture.stream.name ?? '')
+ .first()
+ .click();
+ const guide = app.mainWindow.locator('app-epg-list-view');
+ await expect(guide.locator('[data-when="past"]').first()).toBeVisible();
+
+ // The window cannot shrink below its desktop minimum, so emulate a
+ // phone viewport below the 640px breakpoint.
+ await app.mainWindow.setViewportSize({ width: 360, height: 800 });
+ await guide
+ .locator('[data-when="past"]')
+ .first()
+ .getByRole('button', {
+ name: fr.EPG.PROGRAM_DIALOG.SHOW_PROGRAM_DETAILS,
+ })
+ .click();
+ const dialog = app.mainWindow.getByRole('dialog');
+ await expect(dialog).toBeVisible();
+ await expect(dialog.locator('.epg-dialog__btn--primary')).toContainText(
+ fr.EPG.TIMELINE.WATCH_FROM_START
+ );
+
+ // Every action row stacks one full-width button per row in DOM
+ // order, and no label spills out of its button.
+ const rows = await dialog
+ .locator('.epg-dialog__tools, .epg-dialog__actions')
+ .evaluateAll((containers) =>
+ containers.map((container) => {
+ const buttons = Array.from(
+ container.querySelectorAll('button')
+ );
+ return {
+ width: (container as HTMLElement).clientWidth,
+ buttons: buttons.map((button) => ({
+ top: button.offsetTop,
+ bottom: button.offsetTop + button.offsetHeight,
+ width: button.offsetWidth,
+ overflows:
+ button.scrollWidth > button.clientWidth + 1,
+ })),
+ };
+ })
+ );
+ expect(rows).toHaveLength(2);
+ for (const row of rows) {
+ expect(row.buttons.length).toBeGreaterThan(1);
+ row.buttons.forEach((button, index) => {
+ expect(Math.abs(button.width - row.width)).toBeLessThanOrEqual(
+ 1
+ );
+ expect(button.overflows).toBe(false);
+ if (index > 0) {
+ expect(button.top).toBeGreaterThanOrEqual(
+ row.buttons[index - 1].bottom
+ );
+ }
+ });
+ }
+ } finally {
+ await closeElectronApp(app);
+ }
+});
+
test('@epg @stalker @theme @electron applies live themes to the shared Stalker guide', async ({
dataDir,
request,
diff --git a/apps/web-e2e/src/settings-unsaved-dialog-layout.e2e.ts b/apps/web-e2e/src/settings-unsaved-dialog-layout.e2e.ts
new file mode 100644
index 000000000..c8fbdae50
--- /dev/null
+++ b/apps/web-e2e/src/settings-unsaved-dialog-layout.e2e.ts
@@ -0,0 +1,200 @@
+import type { Locator, Page } from '@playwright/test';
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { expect, test } from './fixtures';
+
+/**
+ * The audit found the unsaved-changes actions wrapping in most locales. These
+ * are the long-label locales it measured, plus Arabic for the RTL row.
+ */
+const LOCALES = ['en', 'de', 'ru', 'fr', 'hu', 'ar'] as const;
+type Locale = (typeof LOCALES)[number];
+
+const I18N_DIR = join(__dirname, '../../web/src/assets/i18n');
+
+interface UnsavedDialogLabels {
+ CANCEL: string;
+ SETTINGS: {
+ UNSAVED_DIALOG_DISCARD: string;
+ UNSAVED_DIALOG_SAVE: string;
+ };
+}
+
+/** The shipped labels, in DOM order: dismiss, discard, primary save. */
+function expectedLabels(locale: Locale): string[] {
+ const labels = JSON.parse(
+ readFileSync(join(I18N_DIR, `${locale}.json`), 'utf8')
+ ) as UnsavedDialogLabels;
+ return [
+ labels.CANCEL,
+ labels.SETTINGS.UNSAVED_DIALOG_DISCARD,
+ labels.SETTINGS.UNSAVED_DIALOG_SAVE,
+ ];
+}
+
+/** Below the 640px phone breakpoint the row stacks by design. */
+const PHONE_VIEWPORT = { width: 360, height: 740 };
+
+/**
+ * Layout boxes relative to the actions row (its `position: relative` makes it
+ * the buttons' offset parent). Offsets ignore the dialog's scale-in transform,
+ * which `getBoundingClientRect` would include mid-animation.
+ */
+interface ActionBox {
+ testId: string | null;
+ top: number;
+ bottom: number;
+ left: number;
+ right: number;
+ width: number;
+ /** Label broken over more than one line inside its button. */
+ labelLines: number;
+ /** Label wider than its button (clipped or spilling out). */
+ overflows: boolean;
+}
+
+interface ActionRow {
+ /** The row's content box, between its paddings. */
+ contentLeft: number;
+ contentRight: number;
+ buttons: ActionBox[];
+}
+
+async function openSettings(page: Page) {
+ await page.locator('a[href$="/workspace/settings"]').click();
+ await page.waitForURL(/\/workspace\/settings\/general$/);
+ await expect(page.locator('.settings-container')).toBeVisible();
+}
+
+async function useLanguage(page: Page, locale: Locale) {
+ if (locale === 'en') {
+ return;
+ }
+ await page.locator('[data-test-id="select-language"]').click();
+ await page.locator(`mat-option[data-test-id="${locale}"]`).click();
+
+ const saveButton = page.locator('[data-test-id="save-settings"]');
+ await saveButton.click();
+ await expect(saveButton).toBeHidden();
+}
+
+/**
+ * Stages an edit, then leaves the settings area so the guard asks. Waiting for
+ * the locale's own labels proves the translation loaded before measuring.
+ */
+async function openUnsavedDialog(page: Page, locale: Locale): Promise {
+ await page
+ .locator('[data-test-id="select-theme"] [data-test-id="DARK_THEME"]')
+ .click();
+ await expect(
+ page.locator('[data-test-id="settings-unsaved-bar"]')
+ ).toBeVisible();
+
+ // The rail's Dashboard link stays visible in the phone top bar, unlike
+ // the brand link.
+ await page
+ .getByRole('navigation')
+ .locator('a[href$="/workspace/dashboard"]')
+ .click();
+ const dialog = page.getByRole('dialog');
+ await expect(dialog).toBeVisible();
+ await expect(dialog.locator('mat-dialog-actions button')).toHaveText(
+ expectedLabels(locale)
+ );
+ return dialog;
+}
+
+async function measureActions(dialog: Locator): Promise {
+ return dialog.locator('mat-dialog-actions').evaluate((row) => {
+ const rowStyle = getComputedStyle(row);
+ const buttons = Array.from(row.querySelectorAll('button')).map(
+ (button) => {
+ const label =
+ button.querySelector('.mdc-button__label') ?? button;
+ return {
+ testId: button.getAttribute('data-test-id'),
+ top: button.offsetTop,
+ bottom: button.offsetTop + button.offsetHeight,
+ left: button.offsetLeft,
+ right: button.offsetLeft + button.offsetWidth,
+ width: button.offsetWidth,
+ labelLines: label.getClientRects().length,
+ overflows: button.scrollWidth > button.clientWidth + 1,
+ };
+ }
+ );
+ return {
+ contentLeft: parseFloat(rowStyle.paddingLeft),
+ contentRight:
+ (row as HTMLElement).clientWidth -
+ parseFloat(rowStyle.paddingRight),
+ buttons,
+ };
+ });
+}
+
+function expectReadableLabels(row: ActionRow) {
+ expect(row.buttons.map((button) => button.testId)).toEqual([
+ 'unsaved-dialog-stay',
+ 'unsaved-dialog-discard',
+ 'unsaved-dialog-save',
+ ]);
+ for (const button of row.buttons) {
+ expect(button.labelLines, `${button.testId} label wraps`).toBe(1);
+ expect(button.overflows, `${button.testId} label is cut`).toBe(false);
+ expect(button.left).toBeGreaterThanOrEqual(row.contentLeft - 1);
+ expect(button.right).toBeLessThanOrEqual(row.contentRight + 1);
+ }
+}
+
+test.describe('Settings unsaved-changes dialog layout', () => {
+ for (const locale of LOCALES) {
+ test(`@settings @web keeps the actions on one row (${locale})`, async ({
+ page,
+ }) => {
+ await page.goto('/');
+ await openSettings(page);
+ await useLanguage(page, locale);
+ const dialog = await openUnsavedDialog(page, locale);
+
+ const row = await measureActions(dialog);
+ expectReadableLabels(row);
+ const [first] = row.buttons;
+ for (const button of row.buttons) {
+ expect(
+ Math.abs(button.top - first.top),
+ `${button.testId} left the row`
+ ).toBeLessThanOrEqual(1);
+ }
+ });
+ }
+
+ for (const locale of ['de', 'ru'] as const) {
+ test(`@settings @web stacks the actions full width on a phone (${locale})`, async ({
+ page,
+ }) => {
+ await page.goto('/');
+ await openSettings(page);
+ await useLanguage(page, locale);
+ await page.setViewportSize(PHONE_VIEWPORT);
+ const dialog = await openUnsavedDialog(page, locale);
+
+ const row = await measureActions(dialog);
+ expectReadableLabels(row);
+ // One full-width action per row, in DOM order: dismiss on top,
+ // the primary save at the bottom.
+ row.buttons.forEach((button, index) => {
+ expect(
+ Math.abs(
+ button.width - (row.contentRight - row.contentLeft)
+ )
+ ).toBeLessThanOrEqual(1);
+ if (index > 0) {
+ expect(button.top).toBeGreaterThanOrEqual(
+ row.buttons[index - 1].bottom
+ );
+ }
+ });
+ });
+ }
+});
diff --git a/apps/web/src/app/settings/settings-unsaved-changes-dialog.component.spec.ts b/apps/web/src/app/settings/settings-unsaved-changes-dialog.component.spec.ts
new file mode 100644
index 000000000..ed0c10840
--- /dev/null
+++ b/apps/web/src/app/settings/settings-unsaved-changes-dialog.component.spec.ts
@@ -0,0 +1,78 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { MAT_DIALOG_DATA } from '@angular/material/dialog';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { TranslateModule, TranslateService } from '@ngx-translate/core';
+import {
+ SettingsUnsavedChangesDialogComponent,
+ SettingsUnsavedChangesDialogData,
+} from './settings-unsaved-changes-dialog.component';
+
+describe('SettingsUnsavedChangesDialogComponent', () => {
+ let fixture: ComponentFixture;
+
+ async function render(data: SettingsUnsavedChangesDialogData) {
+ await TestBed.configureTestingModule({
+ imports: [
+ SettingsUnsavedChangesDialogComponent,
+ NoopAnimationsModule,
+ TranslateModule.forRoot(),
+ ],
+ providers: [{ provide: MAT_DIALOG_DATA, useValue: data }],
+ }).compileComponents();
+
+ const translate = TestBed.inject(TranslateService);
+ translate.setTranslation('en', {
+ CANCEL: 'Cancel',
+ SETTINGS: {
+ UNSAVED_DIALOG_DISCARD: 'Discard',
+ UNSAVED_DIALOG_SAVE: 'Save',
+ },
+ });
+ translate.use('en');
+
+ fixture = TestBed.createComponent(
+ SettingsUnsavedChangesDialogComponent
+ );
+ fixture.detectChanges();
+ }
+
+ function actionButtons(): HTMLButtonElement[] {
+ return Array.from(
+ fixture.nativeElement.querySelectorAll(
+ '.unsaved-dialog__actions button'
+ )
+ );
+ }
+
+ it('orders the actions dismiss first and the primary save last', async () => {
+ await render({ canSave: true });
+
+ expect(
+ actionButtons().map((button) => [
+ button.getAttribute('data-test-id'),
+ button.textContent?.trim(),
+ ])
+ ).toEqual([
+ ['unsaved-dialog-stay', 'Cancel'],
+ ['unsaved-dialog-discard', 'Discard'],
+ ['unsaved-dialog-save', 'Save'],
+ ]);
+ });
+
+ it('keeps the initial focus on the dismiss so Enter stays safe', async () => {
+ await render({ canSave: true });
+
+ const [dismiss, discard, save] = actionButtons();
+ expect(dismiss.hasAttribute('cdkFocusInitial')).toBe(true);
+ expect(discard.hasAttribute('cdkFocusInitial')).toBe(false);
+ expect(save.hasAttribute('cdkFocusInitial')).toBe(false);
+ });
+
+ it('disables save while the form cannot be saved', async () => {
+ await render({ canSave: false });
+
+ const [, discard, save] = actionButtons();
+ expect(save.disabled).toBe(true);
+ expect(discard.disabled).toBe(false);
+ });
+});
diff --git a/apps/web/src/app/settings/settings-unsaved-changes-dialog.component.ts b/apps/web/src/app/settings/settings-unsaved-changes-dialog.component.ts
index cdd877ee3..9e3613540 100644
--- a/apps/web/src/app/settings/settings-unsaved-changes-dialog.component.ts
+++ b/apps/web/src/app/settings/settings-unsaved-changes-dialog.component.ts
@@ -15,8 +15,8 @@ export interface SettingsUnsavedChangesDialogData {
/**
* Asked once, when the user is about to leave the settings area with staged
* edits. Section switches never show this — the shared form survives them.
- * "Keep editing" carries the initial focus so Enter is always the safe
- * choice.
+ * "Cancel" (stay) carries the initial focus so Enter is always the safe
+ * choice. Labels are single verbs so the row fits one line in every locale.
*/
@Component({
imports: [MatButtonModule, MatDialogModule, MatIconModule, TranslateModule],
@@ -36,6 +36,22 @@ export interface SettingsUnsavedChangesDialogData {
height: 18px;
}
}
+
+ /* Phone breakpoint: one full-width action per row, in DOM order,
+ instead of letting a long translation wrap the row unevenly. */
+ @media (max-width: 640px) {
+ .unsaved-dialog__actions {
+ flex-direction: column;
+ align-items: stretch;
+ gap: 8px;
+ }
+
+ .unsaved-dialog__actions
+ .mat-mdc-button-base
+ + .mat-mdc-button-base {
+ margin: 0;
+ }
+ }
`,
],
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
@@ -55,21 +71,21 @@ export interface SettingsUnsavedChangesDialogData {
}
-
-
+
+