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

@@ -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>();