refactor(settings): kill triple header, compact theme picker, real footer

Four UX-audit fixes for the Settings page:

1. Header dedupe
   - The page used to stack THREE headers vertically: the workspace rail's
     "Settings" entry, a page-level <h1>Settings</h1> + subtitle, and the
     General section's <h3>General</h3> + subtitle. The page-level and
     section-level subtitles both used SETTINGS.GENERAL_SUBTITLE
     ("Change the configuration of the application"), word-for-word.
   - Drop the visible page-level header entirely. Keep an a11y-only
     <span class="visually-hidden"> for the data-test-id hook so the
     existing settings.component.spec selector still resolves. Left rail
     announces "Settings"; section header is now the page anchor.
   - Add a global .visually-hidden helper to styles.scss for re-use.

2. Theme + Cover-size pickers shrink to a real segmented control
   - .theme-switcher was a 3-column grid of ~78px-tall buttons making
     "Choose theme" look like the most important action in Settings.
     Replaced with a 30px-tall segmented control (track + thumb with
     box-shadow on the selected option), matching the standard Material
     "compact row" cadence everywhere else.
   - Shortened all 18 locales' THEMES.* labels from "Light theme / Dark
     theme / System theme" to "Light / Dark / System". The "theme" word
     duplicated the section's own h3 ("Visual theme") and forced labels
     to wrap into two lines in narrow columns.

3. Save bar becomes a real sticky footer
   - The action bar was a `border-radius:18px; margin-left:auto;
     position:sticky` chip floating bottom-right that clipped the last
     form rows underneath. Now spans the full content column with a
     top border separator — sits flush with the bottom edge while
     still sticky on scroll. The save-button gradient also drops the
     hand-rolled #3b82f6 in favour of var(--app-selection-color) so it
     matches the unified blue primary used everywhere else.

4. Playlist switcher hidden on /settings
   - Workspace shell header gains an isSettingsRoute input that hides
     the playlist switcher block AND the leading actions group
     (+ Add source / header shortcut / bulk action). Settings is a
     global page — those controls were implying that switching
     playlists scopes settings, which it doesn't. Wired through from
     the shell facade's existing isSettingsRoute computed.
   - Mock header in workspace-shell.component.spec gains the matching
     input() declaration so the binding resolves under tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 4.7 committed 2026-05-19 01:33:40 +02:00
1 parent 7399fbfa9e
commit 115911d2a6
24 files changed
+207 -156

No files matched your search

@@ -14,18 +14,20 @@
class="settings-layout"
novalidate
>
<!-- Page-level "Settings" h1 intentionally removed: the left rail
already labels the page "Settings" (sg-nav-title), and each
section below provides its own h3 + subtitle. Stacking another
h1 in the right pane reproduced the audit's "vertical ladder
of identical labels" complaint. -->
@if (!isDialog) {
<header
class="settings-page-header"
<!-- Reserved hook so the page test selector still resolves,
even though there's no visible chrome here. -->
<span
class="visually-hidden"
data-test-id="settings-page-header"
>
<div class="settings-page-header__text">
<h1 data-test-id="settings-page-title">
{{ 'SETTINGS.TITLE' | translate }}
</h1>
<p>{{ 'SETTINGS.GENERAL_SUBTITLE' | translate }}</p>
</div>
</header>
{{ 'SETTINGS.TITLE' | translate }}
</span>
}
<div class="settings-content">
@@ -235,50 +235,59 @@
}
}
// Compact segmented control — replaces the previous 3 buttons each ~78px
// tall. Per the UX audit, "Light/Dark/System" was reading as the most
// important setting on the page because of its sheer footprint. Now the
// row reads at the same weight as the other compact rows (Language,
// Startup behavior, etc.), restoring the visual hierarchy.
.theme-switcher {
width: min(100%, 540px);
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
width: min(100%, 360px);
display: inline-flex;
padding: 3px;
gap: 2px;
background: color-mix(
in srgb,
var(--mat-sys-surface-container-low) 80%,
transparent
);
border: 1px solid
color-mix(in srgb, var(--mat-sys-outline-variant) 60%, transparent);
border-radius: 10px;
}
.theme-switcher__option {
flex: 1 1 0;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 54px;
padding: 12px 14px;
border: 1px solid
color-mix(in srgb, var(--mat-sys-outline-variant) 70%, transparent);
border-radius: 16px;
background: color-mix(
in srgb,
var(--mat-sys-surface-container-low) 88%,
transparent
);
gap: 6px;
min-height: 30px;
padding: 4px 12px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--mat-sys-on-surface-variant);
font: inherit;
font-size: 0.9rem;
font-weight: 600;
letter-spacing: -0.01em;
font-size: 0.82rem;
font-weight: 500;
letter-spacing: 0;
cursor: pointer;
transition:
border-color 0.18s ease,
background 0.18s ease,
box-shadow 0.18s ease,
color 0.18s ease;
background 0.16s ease,
color 0.16s ease,
box-shadow 0.16s ease;
mat-icon {
width: 18px;
height: 18px;
font-size: 18px;
width: 16px;
height: 16px;
font-size: 16px;
line-height: 16px;
}
&:hover {
background: color-mix(
in srgb,
var(--mat-sys-surface-container-high) 86%,
var(--mat-sys-surface-container-highest) 70%,
transparent
);
color: var(--mat-sys-on-surface);
@@ -286,31 +295,19 @@
&:focus-visible {
outline: none;
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--app-selection-color) 18%, transparent);
box-shadow: 0 0 0 2px
color-mix(in srgb, var(--app-selection-color) 50%, transparent);
}
}
.theme-switcher__option--selected {
border-color: var(--settings-group-active-ring);
background: linear-gradient(
135deg,
color-mix(
in srgb,
var(--app-selection-surface-strong) 72%,
var(--mat-sys-surface-container-high)
),
color-mix(
in srgb,
var(--app-selection-surface) 72%,
var(--mat-sys-surface-container)
)
);
background: var(--app-widget-bg, var(--mat-sys-surface));
color: var(--app-selection-color);
font-weight: 600;
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.18),
inset 0 0 0 1px
color-mix(in srgb, var(--app-selection-border) 54%, transparent),
0 10px 18px -22px var(--settings-group-active-glow);
color-mix(in srgb, var(--app-selection-border) 36%, transparent);
}
.epg-sources {
@@ -708,21 +705,31 @@
);
}
// Sticky-footer Save bar — full content width so it never overlaps form
// rows. The previous chip-style bar (border-radius:18px, margin-left:auto)
// floated in the bottom-right corner and clipped fields underneath; the
// UX audit called this out as the worst of three available patterns.
// Now the bar acts as a proper footer: spans the column, separator line
// on top, sits flush with the bottom edge while still sticking on scroll.
.action-bar {
position: sticky;
bottom: calc(
var(--settings-action-bar-offset) + var(--settings-safe-bottom)
);
margin-left: auto;
margin-left: 0;
margin-right: 0;
z-index: 100;
display: flex;
justify-content: flex-end;
align-items: center;
gap: 12px;
flex-wrap: wrap;
padding: 10px 14px;
border: 1px solid var(--settings-action-bar-border);
border-radius: 18px;
padding: 12px 18px;
border-top: 1px solid var(--settings-action-bar-border);
border-left: 0;
border-right: 0;
border-bottom: 0;
border-radius: 0;
background: var(--settings-action-bar-bg);
backdrop-filter: blur(12px);
box-shadow: var(--settings-action-bar-shadow);
@@ -735,16 +742,21 @@
box-shadow: none;
border-radius: 16px;
padding: 12px;
border-top: 1px solid var(--settings-action-bar-border);
justify-content: space-between;
}
.settings-save-button.mat-mdc-button-base {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: #fff;
// Use the unified blue primary (--app-selection-color) instead of the
// old hand-rolled #3b82f6 gradient that didn't match the rest of the
// app's CTAs after the color unification commit.
background: var(--app-selection-color);
color: var(--app-selection-on-color, #fff);
font-weight: 500;
padding: 0 20px;
border-radius: 10px;
box-shadow: 0 4px 14px rgba(59, 130, 246, 0.28);
box-shadow: 0 4px 14px
color-mix(in srgb, var(--app-selection-color) 28%, transparent);
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
@@ -756,7 +768,8 @@
&:hover:not([disabled]) {
transform: translateY(-1px);
box-shadow: 0 6px 18px rgba(59, 130, 246, 0.38);
box-shadow: 0 6px 18px
color-mix(in srgb, var(--app-selection-color) 38%, transparent);
}
&:active:not([disabled]) {
+3 -3
View File
@@ -375,9 +375,9 @@
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
},
"THEMES": {
"DARK_THEME": "السمة الداكنة",
"LIGHT_THEME": "السمة الفاتحة",
"SYSTEM_THEME": "سمة النظام"
"DARK_THEME": "داكن",
"LIGHT_THEME": "فاتح",
"SYSTEM_THEME": "النظام"
},
"PLAYBACK_DIAGNOSTICS": {
"UNSUPPORTED_CONTAINER": {
+3 -3
View File
@@ -375,9 +375,9 @@
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
},
"THEMES": {
"DARK_THEME": "المظهر الداكن",
"LIGHT_THEME": "المظهر الفاتح",
"SYSTEM_THEME": "مظهر النظام"
"DARK_THEME": "داكن",
"LIGHT_THEME": "فاتح",
"SYSTEM_THEME": "النظام"
},
"PLAYBACK_DIAGNOSTICS": {
"UNSUPPORTED_CONTAINER": {
+3 -3
View File
@@ -375,9 +375,9 @@
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
},
"THEMES": {
"DARK_THEME": "Цёмная тэма",
"LIGHT_THEME": "Светлая тэма",
"SYSTEM_THEME": "Сістэмная тэма"
"DARK_THEME": "Цёмная",
"LIGHT_THEME": "Светлая",
"SYSTEM_THEME": "Сістэмная"
},
"PLAYBACK_DIAGNOSTICS": {
"UNSUPPORTED_CONTAINER": {
+3 -3
View File
@@ -375,9 +375,9 @@
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
},
"THEMES": {
"DARK_THEME": "Σκούρο θέμα",
"LIGHT_THEME": "Ανοιχτόχρωμο θέμα",
"SYSTEM_THEME": "Θέμα συστήματος"
"DARK_THEME": "Σκούρο",
"LIGHT_THEME": "Ανοιχτό",
"SYSTEM_THEME": "Σύστημα"
},
"PLAYBACK_DIAGNOSTICS": {
"UNSUPPORTED_CONTAINER": {
+3 -3
View File
@@ -375,9 +375,9 @@
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
},
"THEMES": {
"DARK_THEME": "Dark theme",
"LIGHT_THEME": "Light theme",
"SYSTEM_THEME": "System theme"
"DARK_THEME": "Dark",
"LIGHT_THEME": "Light",
"SYSTEM_THEME": "System"
},
"PLAYBACK_DIAGNOSTICS": {
"UNSUPPORTED_CONTAINER": {
+3 -3
View File
@@ -375,9 +375,9 @@
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
},
"THEMES": {
"DARK_THEME": "Tema oscuro",
"LIGHT_THEME": "Tema claro",
"SYSTEM_THEME": "Tema del sistema"
"DARK_THEME": "Oscuro",
"LIGHT_THEME": "Claro",
"SYSTEM_THEME": "Sistema"
},
"PLAYBACK_DIAGNOSTICS": {
"UNSUPPORTED_CONTAINER": {
+3 -3
View File
@@ -375,9 +375,9 @@
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
},
"THEMES": {
"DARK_THEME": "Thème foncé",
"LIGHT_THEME": "Thème clair",
"SYSTEM_THEME": "Thème du système"
"DARK_THEME": "Sombre",
"LIGHT_THEME": "Clair",
"SYSTEM_THEME": "Système"
},
"PLAYBACK_DIAGNOSTICS": {
"UNSUPPORTED_CONTAINER": {
+3 -3
View File
@@ -375,9 +375,9 @@
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
},
"THEMES": {
"DARK_THEME": "Tema scuro",
"LIGHT_THEME": "Tema chiaro",
"SYSTEM_THEME": "Tema di sistema"
"DARK_THEME": "Scuro",
"LIGHT_THEME": "Chiaro",
"SYSTEM_THEME": "Sistema"
},
"PLAYBACK_DIAGNOSTICS": {
"UNSUPPORTED_CONTAINER": {
+3 -3
View File
@@ -375,9 +375,9 @@
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
},
"THEMES": {
"DARK_THEME": "ダークテーマ",
"LIGHT_THEME": "ライトテーマ",
"SYSTEM_THEME": "システムテーマ"
"DARK_THEME": "ダーク",
"LIGHT_THEME": "ライト",
"SYSTEM_THEME": "システム"
},
"PLAYBACK_DIAGNOSTICS": {
"UNSUPPORTED_CONTAINER": {
+3 -3
View File
@@ -375,9 +375,9 @@
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
},
"THEMES": {
"DARK_THEME": "어두운 테마",
"LIGHT_THEME": "밝은 테마",
"SYSTEM_THEME": "시스템 테마"
"DARK_THEME": "어둡게",
"LIGHT_THEME": "밝게",
"SYSTEM_THEME": "시스템"
},
"PLAYBACK_DIAGNOSTICS": {
"UNSUPPORTED_CONTAINER": {
+3 -3
View File
@@ -375,9 +375,9 @@
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
},
"THEMES": {
"DARK_THEME": "Donker thema",
"LIGHT_THEME": "Licht thema",
"SYSTEM_THEME": "Systeemthema"
"DARK_THEME": "Donker",
"LIGHT_THEME": "Licht",
"SYSTEM_THEME": "Systeem"
},
"PLAYBACK_DIAGNOSTICS": {
"UNSUPPORTED_CONTAINER": {
+3 -3
View File
@@ -375,9 +375,9 @@
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
},
"THEMES": {
"DARK_THEME": "Ciemny motyw",
"LIGHT_THEME": "Jasny motyw",
"SYSTEM_THEME": "Motyw systemowy"
"DARK_THEME": "Ciemny",
"LIGHT_THEME": "Jasny",
"SYSTEM_THEME": "Systemowy"
},
"PLAYBACK_DIAGNOSTICS": {
"UNSUPPORTED_CONTAINER": {
+3 -3
View File
@@ -375,9 +375,9 @@
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
},
"THEMES": {
"DARK_THEME": "Tema escuro",
"LIGHT_THEME": "Tema claro",
"SYSTEM_THEME": "Tema do sistema"
"DARK_THEME": "Escuro",
"LIGHT_THEME": "Claro",
"SYSTEM_THEME": "Sistema"
},
"PLAYBACK_DIAGNOSTICS": {
"UNSUPPORTED_CONTAINER": {
+3 -3
View File
@@ -375,9 +375,9 @@
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
},
"THEMES": {
"DARK_THEME": "Тёмная тема",
"LIGHT_THEME": "Светлая тема",
"SYSTEM_THEME": "Системная тема"
"DARK_THEME": "Тёмная",
"LIGHT_THEME": "Светлая",
"SYSTEM_THEME": "Системная"
},
"PLAYBACK_DIAGNOSTICS": {
"UNSUPPORTED_CONTAINER": {
+3 -3
View File
@@ -375,9 +375,9 @@
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
},
"THEMES": {
"DARK_THEME": "Koyu tema",
"LIGHT_THEME": "Açık tema",
"SYSTEM_THEME": "Sistem teması"
"DARK_THEME": "Koyu",
"LIGHT_THEME": "Açık",
"SYSTEM_THEME": "Sistem"
},
"PLAYBACK_DIAGNOSTICS": {
"UNSUPPORTED_CONTAINER": {
+2 -2
View File
@@ -375,8 +375,8 @@
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
},
"THEMES": {
"DARK_THEME": "暗黑模式",
"LIGHT_THEME": "明亮模式",
"DARK_THEME": "深色",
"LIGHT_THEME": "浅色",
"SYSTEM_THEME": "跟随系统"
},
"PLAYBACK_DIAGNOSTICS": {
+2 -2
View File
@@ -375,8 +375,8 @@
"PLAYLISTS_REMOVE_FAILED": "Could not delete playlists. Please try again."
},
"THEMES": {
"DARK_THEME": "深色主題",
"LIGHT_THEME": "亮色主題",
"DARK_THEME": "深色",
"LIGHT_THEME": "淺色",
"SYSTEM_THEME": "跟隨系統"
},
"PLAYBACK_DIAGNOSTICS": {
+17
View File
@@ -203,3 +203,20 @@ textarea,
.mat-mdc-snack-bar-container.settings-snackbar {
margin-bottom: calc(76px + env(safe-area-inset-bottom, 0px));
}
// WCAG-friendly visually-hidden helper — content stays in the
// accessibility tree and DOM query results but occupies no layout box.
// Used where a visible heading would duplicate adjacent navigation
// context (e.g. Settings page title that the left rail already
// announces).
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@@ -1,18 +1,24 @@
<header class="workspace-header">
<app-playlist-switcher
class="playlist-switcher"
[currentTitle]="playlistTitle()"
[subtitle]="playlistSubtitle()"
[showPlaylistInfo]="canOpenPlaylistInfo()"
[showAccountInfo]="canOpenAccountInfo()"
[showAddPlaylist]="true"
[canRefreshActivePlaylist]="canRefreshPlaylist()"
[isRefreshingActivePlaylist]="isRefreshingPlaylist()"
(playlistInfoRequested)="onPlaylistInfoRequested()"
(accountInfoRequested)="onAccountInfoRequested()"
(addPlaylistRequested)="onAddPlaylistRequested()"
(refreshPlaylistRequested)="onRefreshPlaylistRequested()"
/>
<header class="workspace-header" [class.is-settings-route]="isSettingsRoute()">
<!-- Playlist switcher hidden on /settings: those controls are
playlist-scoped, but Settings is a global page. Per the UX
audit, leaving them visible implied switching playlists changes
which settings you're editing. -->
@if (!isSettingsRoute()) {
<app-playlist-switcher
class="playlist-switcher"
[currentTitle]="playlistTitle()"
[subtitle]="playlistSubtitle()"
[showPlaylistInfo]="canOpenPlaylistInfo()"
[showAccountInfo]="canOpenAccountInfo()"
[showAddPlaylist]="true"
[canRefreshActivePlaylist]="canRefreshPlaylist()"
[isRefreshingActivePlaylist]="isRefreshingPlaylist()"
(playlistInfoRequested)="onPlaylistInfoRequested()"
(accountInfoRequested)="onAccountInfoRequested()"
(addPlaylistRequested)="onAddPlaylistRequested()"
(refreshPlaylistRequested)="onRefreshPlaylistRequested()"
/>
}
@if (!hasNoPlaylists()) {
<label class="search-field">
@@ -53,42 +59,44 @@
</label>
}
<div class="header-actions header-actions--leading">
<button
type="button"
mat-icon-button
(click)="onAddPlaylistRequested()"
[matTooltip]="'WORKSPACE.SHELL.ADD_PLAYLIST_ALL' | translate"
[attr.aria-label]="'WORKSPACE.SHELL.ADD_PLAYLIST' | translate"
>
<mat-icon>add</mat-icon>
</button>
@if (headerShortcut(); as action) {
@if (!isSettingsRoute()) {
<div class="header-actions header-actions--leading">
<button
type="button"
class="header-shortcut"
mat-icon-button
(click)="onHeaderShortcutRequested()"
[matTooltip]="action.tooltipKey | translate"
[attr.aria-label]="action.ariaLabelKey | translate"
(click)="onAddPlaylistRequested()"
[matTooltip]="'WORKSPACE.SHELL.ADD_PLAYLIST_ALL' | translate"
[attr.aria-label]="'WORKSPACE.SHELL.ADD_PLAYLIST' | translate"
>
<mat-icon>{{ action.icon }}</mat-icon>
<mat-icon>add</mat-icon>
</button>
}
@if (headerBulkAction(); as bulk) {
<button
type="button"
class="header-shortcut header-bulk-action"
mat-icon-button
[disabled]="bulk.disabled"
(click)="onHeaderBulkActionRequested()"
[matTooltip]="bulk.tooltip"
[attr.aria-label]="bulk.ariaLabel"
>
<mat-icon>{{ bulk.icon }}</mat-icon>
</button>
}
</div>
@if (headerShortcut(); as action) {
<button
type="button"
class="header-shortcut"
mat-icon-button
(click)="onHeaderShortcutRequested()"
[matTooltip]="action.tooltipKey | translate"
[attr.aria-label]="action.ariaLabelKey | translate"
>
<mat-icon>{{ action.icon }}</mat-icon>
</button>
}
@if (headerBulkAction(); as bulk) {
<button
type="button"
class="header-shortcut header-bulk-action"
mat-icon-button
[disabled]="bulk.disabled"
(click)="onHeaderBulkActionRequested()"
[matTooltip]="bulk.tooltip"
[attr.aria-label]="bulk.ariaLabel"
>
<mat-icon>{{ bulk.icon }}</mat-icon>
</button>
}
</div>
}
@if (isElectron()) {
<div class="header-actions header-actions--trailing">
@@ -48,6 +48,15 @@ export class WorkspaceShellHeaderComponent {
readonly hasNoPlaylists = input(false);
readonly isDownloadsView = input(false);
readonly hasActiveDownloads = input(false);
/**
* When true the playlist switcher + the "+ Add source" / refresh /
* bulk-action buttons are hidden — those controls scope to a
* playlist, but Settings is a global page, so leaving them visible
* implies (falsely) that switching the playlist changes which
* settings you're editing. Driven from the shell facade's existing
* isSettingsRoute computed.
*/
readonly isSettingsRoute = input(false);
readonly searchChanged = output<string>();
readonly searchSubmitted = output<string>();
@@ -39,6 +39,7 @@
[hasNoPlaylists]="facade.hasNoPlaylists()"
[isDownloadsView]="facade.isDownloadsView()"
[hasActiveDownloads]="facade.hasActiveDownloads()"
[isSettingsRoute]="facade.isSettingsRoute()"
(refreshPlaylistRequested)="facade.refreshCurrentPlaylist()"
(searchChanged)="facade.onSearchInput($event)"
(searchSubmitted)="facade.onSearchEnter($event)"
@@ -57,6 +57,7 @@ class MockWorkspaceShellHeaderComponent {
readonly hasNoPlaylists = input(false);
readonly isDownloadsView = input(false);
readonly hasActiveDownloads = input(false);
readonly isSettingsRoute = input(false);
readonly headerBulkAction = input<WorkspaceHeaderBulkAction | null>(null);
readonly searchChanged = output<string>();
readonly searchSubmitted = output<string>();