feat(settings): grouped navigation, cards and switches from the settings concept (#1853)

* feat(settings): grouped navigation, cards and switches from the settings concept

Implements the Claude Design "Settings concept" handoff.

Navigation: the sidebar is a 20px "Settings" title over App, Library, Devices
and Data groups, with About pinned to the footer beside the installed version
and an update badge. The active item is a soft fill without a border. Esc
leaves settings like the header Back. Reset is no longer a page: its one
destructive action is the last card of "Backup & data".

Pages: a title and one-line subtitle, then rows grouped into titled cards
with one right-aligned control column. Selects are compact without floating
labels, checkboxes are switches, segmented controls are pills, and
descriptions are capped at 56ch. "Show subtitles" moves to Playback, the
Embedded MPV note becomes a callout under the player select (only while it
is selected), and the TMDB attribution becomes the About footer.

EPG: Add and Refresh all sit in the Sources header, the empty state has its
own Add, the format examples are one line, Clear EPG data is its own row and
the offset is a stepper. About: version, update state and channel share one
card; the nightly warning is a callout shown only while Nightly is selected,
with a shortcut to the backup page; the support buttons are neutral with
coloured icons.

Save model: the save bar stays (design option B) and now floats over the
content column, counts the staged changes, answers Cmd/Ctrl+S, and marks
pages with staged edits in the sidebar. A saved change that waits for a
restart shows a dismissible notice.

Rows stack under 600px of pane width (the page is a size container, so the
persistent sidebar is accounted for). Page-specific styles moved into the
About, EPG, Backup and Remote control section stylesheets to stay within the
component style budget. New SETTINGS keys are translated in all 19 locales
and NAV_RESET is removed; brand names and loanwords that stay English are in
the identical-value baseline.

E2E: Material slide toggles report aria-checked after the next render, so
both suites toggle through a setSwitch helper; the settings nav is a
navigation landmark, so the Dashboard rail link is scoped to the rail.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): compare restart notices against the running app, fix CI fallout

The restart notice now lists only the restart controls whose saved value
differs from what the running app uses: the stored values at first load,
or what the embedded engine reports it actually runs for the frame-copy
opt-in. Saving the launch value back withdraws the notice instead of
leaving it up with no chip to explain it. The refresh reads the current
list untracked and writes only a changed one, because the engine probe
effect calls it.

CI: the zoneless checklist dropped the deleted Reset section component, and
the theme-tokens Electron E2E floats the recording folder label on the
Playback page now that the settings selects carry no floating label.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): keep a dismissed restart reminder away until the setting changes

"Later" now records the saved value it dismissed, for the rest of the app
run, so an unrelated save (or reopening Settings) does not bring the same
reminder back. It returns once a restart setting is saved with another
value.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 authored and GitHub committed 2026-10-09 20:39:07 +02:00
1 parent 4c6540679d
commit 8030ced95a
90 files changed
+5957 -3617

No files matched your search

+15
View File
@@ -21,6 +21,21 @@ import sharp from 'sharp';
* about Safari's preference; Chromium and Firefox treat Alt+Tab as a plain
* Tab, but they get the unmodified key so their run stays a literal user Tab.
*/
/**
* Material slide toggles render `aria-checked` after Angular's next change
* detection, later than Playwright's `check()` looks, which then reports
* that the click changed nothing. Click when needed and wait for the state.
*/
export async function setSwitch(
toggle: Locator,
checked: boolean
): Promise<void> {
if ((await toggle.isChecked()) !== checked) {
await toggle.click();
}
await expect(toggle).toBeChecked({ checked });
}
export async function pressTab(
page: Page,
browserName: string,
+10 -6
View File
@@ -2,7 +2,7 @@ import type { Page } from '@playwright/test';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { expect, test } from './fixtures';
import { pressTab, waitForScrollIdle } from './e2e-helpers';
import { pressTab, waitForScrollIdle, setSwitch } from './e2e-helpers';
/**
* The M3U movie-recognition workflow end to end: a playlist entry whose URL
@@ -100,12 +100,15 @@ async function selectPlayer(
async function enableTmdb(page: Page): Promise<void> {
await page.goto('/workspace/settings/tmdb');
await page.locator('[data-test-id="tmdb-enabled"] input').check();
await setSwitch(
page.locator('[data-test-id="tmdb-enabled"] [role="switch"]'),
true
);
await page.locator('[data-test-id="tmdb-api-key"]').fill('e2e-key');
// The M3U recognition toggle only appears once TMDB itself is on, and it
// ships enabled — assert rather than click, so a changed default fails.
await expect(
page.locator('[data-test-id="tmdb-m3u-vod-details"] input')
page.locator('[data-test-id="tmdb-m3u-vod-details"] [role="switch"]')
).toBeChecked();
await saveSettings(page);
}
@@ -222,9 +225,10 @@ async function configureMetadata(
if (mode === 'disabled') return;
await enableTmdb(page);
if (mode === 'details-disabled') {
await page
.locator('[data-test-id="tmdb-m3u-vod-details"] input')
.uncheck();
await setSwitch(
page.locator('[data-test-id="tmdb-m3u-vod-details"] [role="switch"]'),
false
);
await saveSettings(page);
}
}
+1 -1
View File
@@ -138,7 +138,7 @@ test.describe('portrait phone 375x812', () => {
// No footer Back any more; the list scrolls and ends inside the
// panel instead of painting past it (#1326).
await expect(panel.locator('button:has-text("Back")')).toHaveCount(0);
const listBox = await boxOf(panel.locator('.settings-sections-list'));
const listBox = await boxOf(panel.locator('.settings-nav'));
expect(listBox.y + listBox.height).toBeLessThanOrEqual(
panelBox.y + panelBox.height + 1
);
+75 -9
View File
@@ -1,5 +1,6 @@
import type { Page } from '@playwright/test';
import { join } from 'path';
import { setSwitch } from './e2e-helpers';
import { expect, test } from './fixtures';
async function openSettings(page: Page) {
@@ -96,12 +97,12 @@ test.describe('Settings', () => {
const setting = page.locator(
'[data-test-id="web-player-shared-controls-setting"]'
);
const checkbox = setting.locator('input[type="checkbox"]');
const checkbox = setting.getByRole('switch');
await expect(setting).toBeVisible();
// Shared controls default ON; the checkbox is the opt-out.
// Shared controls default ON; the switch is the opt-out.
await expect(checkbox).toBeChecked();
await checkbox.uncheck();
await setSwitch(checkbox, false);
await saveSettings(page);
await page.reload();
await openSettings(page);
@@ -175,13 +176,30 @@ test.describe('Settings', () => {
.click();
await expect(unsavedBar).toBeVisible();
// The bar counts the staged change and the sidebar marks its page.
await expect(
unsavedBar.locator('[data-test-id="settings-unsaved-message"]')
).toHaveText('1 unsaved change');
await expect(
page.locator('[data-test-id="settings-section-dirty-general"]')
).toBeVisible();
// The staged edit belongs to the page, not the section — moving to
// another section page must keep the bar (and the pending change).
await openSettingsSection(page, 'playback');
await expect(unsavedBar).toBeVisible();
await expect(
page.locator('[data-test-id="settings-section-dirty-general"]')
).toBeVisible();
await expect(
page.locator('[data-test-id="settings-section-dirty-playback"]')
).toHaveCount(0);
await page.locator('[data-test-id="discard-settings"]').click();
await expect(unsavedBar).toBeHidden();
await expect(
page.locator('[data-test-id="settings-section-dirty-general"]')
).toHaveCount(0);
await openSettingsSection(page, 'general');
await expect(
@@ -189,6 +207,50 @@ test.describe('Settings', () => {
).toHaveAttribute('aria-checked', 'true');
});
test('@settings @web Ctrl+S saves the staged edits', async ({ page }) => {
await openSettings(page);
await page
.locator('[data-test-id="select-theme"] [data-test-id="DARK_THEME"]')
.click();
const unsavedBar = page.locator(
'[data-test-id="settings-unsaved-bar"]'
);
await expect(unsavedBar).toBeVisible();
await page.keyboard.press('Control+s');
await expect(unsavedBar).toBeHidden();
await page.reload();
await openSettings(page);
await expect(
page
.locator('[data-test-id="select-theme"]')
.getByRole('radio', { name: 'Dark', exact: true })
).toHaveAttribute('aria-checked', 'true');
});
test('@settings @web Esc leaves settings like the header Back', async ({
page,
}) => {
await openSettings(page);
// The sidebar groups the pages and pins About to the footer.
await expect(
page.locator('.settings-nav__group[data-group="app"]')
).toContainText('General');
await expect(
page.locator('.settings-nav__footer [data-test-id="settings-section-about"]')
).toBeVisible();
// The rail link's tooltip is still up after the click that opened
// settings, and a visible tooltip consumes Escape: move on and wait
// for it to finish closing first.
await page.locator('.settings-group__header h2').click();
await expect(page.locator('.mat-mdc-tooltip')).toHaveCount(0);
await page.keyboard.press('Escape');
await page.waitForURL(/\/workspace\/dashboard$/);
});
test('@settings @web Leaving with unsaved edits asks for confirmation', async ({
page,
}) => {
@@ -203,7 +265,7 @@ test.describe('Settings', () => {
// Trying to leave the settings area surfaces the dialog.
await page
.getByRole('navigation')
.locator('app-workspace-shell-rail')
.getByRole('link', { name: 'Dashboard', exact: true })
.click();
const dialog = page.getByRole('dialog');
@@ -219,7 +281,7 @@ test.describe('Settings', () => {
// Leave without saving: the staged edit is discarded.
await page
.getByRole('navigation')
.locator('app-workspace-shell-rail')
.getByRole('link', { name: 'Dashboard', exact: true })
.click();
await page
@@ -247,7 +309,7 @@ test.describe('Settings', () => {
.click();
await page
.getByRole('navigation')
.locator('app-workspace-shell-rail')
.getByRole('link', { name: 'Dashboard', exact: true })
.click();
await page
@@ -349,7 +411,8 @@ test.describe('Settings', () => {
);
await expect(search).toBeEnabled();
// "subtitles" is a keyword of the "Show captions" row.
// "subtitles" is a keyword of the "Show subtitles" row, which lives
// on the Playback page: the result leads there from General.
await search.fill('subtitles');
await expect(page).toHaveURL(/\/workspace\/settings\/general\?q=subtitles$/);
await expect(
@@ -357,14 +420,17 @@ test.describe('Settings', () => {
).toBeVisible();
await expect(page.locator('app-settings-general-section')).toHaveCount(0);
await expect(
page.locator('[data-test-id="settings-section-matches-general"]')
page.locator('[data-test-id="settings-section-matches-playback"]')
).toHaveText('1');
await expect(
page.locator('[data-test-id="settings-section-matches-general"]')
).toHaveText('0');
await page
.locator('[data-test-id="settings-search-result-show-captions"]')
.click();
await expect(page).toHaveURL(/\/workspace\/settings\/general$/);
await expect(page).toHaveURL(/\/workspace\/settings\/playback$/);
await expect(search).toHaveValue('');
const row = page.locator('[data-setting-id="show-captions"]');
await expect(row).toBeFocused();
@@ -1,4 +1,5 @@
import type { Page } from '@playwright/test';
import { setSwitch } from './e2e-helpers';
import { expect } from './fixtures';
/** Complete lazy VOD season workflow, using fictional provider/TMDB replies. */
@@ -115,7 +116,10 @@ export async function verifyStalkerSeasonMarkers(
});
});
await page.goto('/workspace/settings/tmdb');
await page.locator('[data-test-id="tmdb-enabled"] input').check();
await setSwitch(
page.locator('[data-test-id="tmdb-enabled"] [role="switch"]'),
true
);
await page.locator('[data-test-id="tmdb-api-key"]').fill('e2e-key');
const save = page.getByRole('button', { name: 'Save changes' });
await save.click();
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { APIRequestContext, Locator, Page } from '@playwright/test';
import { expect } from './fixtures';
import { setInputValue } from './e2e-helpers';
import { setInputValue, setSwitch } from './e2e-helpers';
import {
getRegisteredProviderUrl,
interceptProviderTargetRegistration,
@@ -180,12 +180,12 @@ export async function selectWebPlayer(
await page.goto('/workspace/settings/playback');
await page.locator('[data-test-id="select-video-player"]').click();
await page.getByRole('option', { name: engine, exact: true }).click();
const toggle = page.locator(
'[data-test-id="web-player-shared-controls-toggle"]'
await setSwitch(
page.locator(
'[data-test-id="web-player-shared-controls-toggle"] [role="switch"]'
),
sharedControls
);
if ((await toggle.locator('input').isChecked()) !== sharedControls) {
await toggle.click();
}
const saveButton = page.getByRole('button', { name: 'Save changes' });
await saveButton.click();
await expect(saveButton).toBeHidden();
+1 -1
View File
@@ -912,7 +912,7 @@ test('@xtream posters-only wall — hides VOD titles behind a hover caption afte
const catalogUrl = page.url();
await page.goto('/workspace/settings/general');
const toggle = page.locator('[data-test-id="cover-titles-toggle"]');
await expect(toggle.locator('input')).toBeChecked();
await expect(toggle.getByRole('switch')).toBeChecked();
await toggle.click();
const saveButton = page.locator('[data-test-id="save-settings"]');
await saveButton.click();