feat: Settings uses the width of a wide screen: groups sit in two columns, each narrow enough that a row's control stays near its name (M503 #5595)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m24s
release / go (push) Successful in 1m41s
release / integration (push) Successful in 5m13s
release / android (push) Successful in 5m15s
release / Build signed APK (releases and dev) (push) Successful in 5m25s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped

The content was capped at max-w-3xl, so a wide window showed one narrow
strip and a lot of empty space. SettingsColumns lays a page's groups out as
one column on a phone or narrow window and two side by side from xl up;
the page cap moves to 100rem so the columns don't stretch on a very wide
screen.

- Account: Android app, Profile and Password, with Signed-in devices beside.
- Playback: "Between tracks" (crossfade) beside "Volume leveling".
- Connections: ListenBrainz beside Subsonic apps.
- Appearance, Notifications, About: one column's width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 10:28:06 -04:00
co-authored by Claude Opus 5.5
parent ace7e22e6a
commit 864eb45580
9 changed files with 291 additions and 208 deletions
@@ -0,0 +1,24 @@
<script lang="ts">
import type { Snippet } from 'svelte';
// A settings page's groups laid out for the width it has (#5595 follow-up):
// one column on a phone or a narrow window, two side by side on a wide
// screen, so a wide window shows more at once instead of one narrow strip
// with empty space beside it. Each column stays narrow enough that a row's
// control sits near its name. `main` holds the groups read first; `side`
// the ones beside them (signed-in devices beside the profile, say). A
// page with only `main` keeps its groups to one column's width.
let { main, side }: { main: Snippet; side?: Snippet } = $props();
</script>
<div class="grid items-start gap-6 xl:grid-cols-2">
<div class="min-w-0 space-y-6">
{@render main()}
</div>
{#if side}
<div class="min-w-0 space-y-6">
{@render side()}
</div>
{/if}
</div>
@@ -0,0 +1,21 @@
import { describe, expect, test } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { createRawSnippet } from 'svelte';
import SettingsColumns from './SettingsColumns.svelte';
const snip = (id: string) => createRawSnippet(() => ({ render: () => `<section data-testid="${id}"></section>` }));
describe('SettingsColumns', () => {
test('main and side are two columns that sit side by side on a wide screen', () => {
render(SettingsColumns, { main: snip('main'), side: snip('side') });
const grid = screen.getByTestId('main').parentElement!.parentElement!;
expect(grid.className).toMatch(/xl:grid-cols-2/);
expect(grid.children).toHaveLength(2);
expect(screen.getByTestId('side').parentElement!.parentElement).toBe(grid);
});
test('with only main, the groups keep to one column', () => {
render(SettingsColumns, { main: snip('only') });
expect(screen.getByTestId('only').parentElement!.parentElement!.children).toHaveLength(1);
});
});
+4 -2
View File
@@ -24,8 +24,10 @@
<SectionRail label="Settings sections" {items} /> <SectionRail label="Settings sections" {items} />
</div> </div>
</div> </div>
<!-- Capped so a row's control sits near its name on a wide screen. --> <!-- Each page lays its groups out in SettingsColumns: two columns on a
<div class="min-w-0 max-w-3xl space-y-6"> wide screen, each narrow enough that a row's control sits near its
name. The cap only stops the columns stretching on a very wide one. -->
<div class="min-w-0 max-w-[100rem] space-y-6">
{@render children?.()} {@render children?.()}
</div> </div>
</div> </div>
+7 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import SettingsColumns from '#lib/components/settings/SettingsColumns.svelte';
import { pageTitle } from '#lib/branding.js'; import { pageTitle } from '#lib/branding.js';
import { user } from '#lib/auth/user.svelte.js'; import { user } from '#lib/auth/user.svelte.js';
import ServerVersion from '#lib/components/ServerVersion.svelte'; import ServerVersion from '#lib/components/ServerVersion.svelte';
@@ -8,7 +9,9 @@
<svelte:head><title>{pageTitle('Settings · About')}</title></svelte:head> <svelte:head><title>{pageTitle('Settings · About')}</title></svelte:head>
<SettingsGroup title="About"> <SettingsColumns>
{#snippet main()}
<SettingsGroup title="About">
<SettingRow label="Server version"> <SettingRow label="Server version">
<ServerVersion /> <ServerVersion />
</SettingRow> </SettingRow>
@@ -17,4 +20,6 @@
<span class="text-sm text-text-secondary">{user.value.username}</span> <span class="text-sm text-text-secondary">{user.value.username}</span>
</SettingRow> </SettingRow>
{/if} {/if}
</SettingsGroup> </SettingsGroup>
{/snippet}
</SettingsColumns>
+13 -7
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import SettingsColumns from '#lib/components/settings/SettingsColumns.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { useQueryClient } from '@tanstack/svelte-query'; import { useQueryClient } from '@tanstack/svelte-query';
import { pageTitle } from '#lib/branding.js'; import { pageTitle } from '#lib/branding.js';
@@ -103,9 +104,11 @@
<svelte:head><title>{pageTitle('Settings · Account')}</title></svelte:head> <svelte:head><title>{pageTitle('Settings · Account')}</title></svelte:head>
<MobileAppDownload /> <SettingsColumns>
{#snippet main()}
<MobileAppDownload />
<form onsubmit={onSaveProfile}> <form onsubmit={onSaveProfile}>
<SettingsGroup title="Profile" id="profile"> <SettingsGroup title="Profile" id="profile">
{#if loadFailed} {#if loadFailed}
<div class="px-4 py-3 text-sm text-text-secondary"> <div class="px-4 py-3 text-sm text-text-secondary">
@@ -143,9 +146,9 @@
</div> </div>
{/if} {/if}
</SettingsGroup> </SettingsGroup>
</form> </form>
<form onsubmit={onSavePassword}> <form onsubmit={onSavePassword}>
<SettingsGroup title="Password" hint="Changing it signs out your other devices."> <SettingsGroup title="Password" hint="Changing it signs out your other devices.">
<SettingRow label="Current password" for="password-current"> <SettingRow label="Current password" for="password-current">
<input <input
@@ -186,6 +189,9 @@
</button> </button>
</div> </div>
</SettingsGroup> </SettingsGroup>
</form> </form>
{/snippet}
<ActiveSessions /> {#snippet side()}
<ActiveSessions />
{/snippet}
</SettingsColumns>
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import SettingsColumns from '#lib/components/settings/SettingsColumns.svelte';
import { pageTitle } from '#lib/branding.js'; import { pageTitle } from '#lib/branding.js';
import { theme, setTheme, type ThemePreference } from '#lib/stores/theme.svelte.js'; import { theme, setTheme, type ThemePreference } from '#lib/stores/theme.svelte.js';
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte'; import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
@@ -14,8 +15,12 @@
<svelte:head><title>{pageTitle('Settings · Appearance')}</title></svelte:head> <svelte:head><title>{pageTitle('Settings · Appearance')}</title></svelte:head>
<SettingsGroup title="Appearance"> <SettingsColumns>
{#snippet main()}
<SettingsGroup title="Appearance">
<SettingRow label="Theme" hint="System follows your device's light or dark setting."> <SettingRow label="Theme" hint="System follows your device's light or dark setting.">
<Segmented name="theme" label="Theme" options={themes} value={theme.value} onchange={setTheme} /> <Segmented name="theme" label="Theme" options={themes} value={theme.value} onchange={setTheme} />
</SettingRow> </SettingRow>
</SettingsGroup> </SettingsGroup>
{/snippet}
</SettingsColumns>
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import SettingsColumns from '#lib/components/settings/SettingsColumns.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { useQueryClient } from '@tanstack/svelte-query'; import { useQueryClient } from '@tanstack/svelte-query';
import type { CreateQueryResult } from '@tanstack/svelte-query'; import type { CreateQueryResult } from '@tanstack/svelte-query';
@@ -170,7 +171,9 @@
</div> </div>
{/snippet} {/snippet}
<SettingsGroup title="ListenBrainz" hint="Sends what you play to your ListenBrainz profile."> <SettingsColumns>
{#snippet main()}
<SettingsGroup title="ListenBrainz" hint="Sends what you play to your ListenBrainz profile.">
{#if $status.isPending} {#if $status.isPending}
<div class="px-4 py-3 text-sm text-text-secondary">Loading…</div> <div class="px-4 py-3 text-sm text-text-secondary">Loading…</div>
{:else if $status.isError} {:else if $status.isError}
@@ -224,9 +227,10 @@
/> />
</SettingRow> </SettingRow>
{/if} {/if}
</SettingsGroup> </SettingsGroup>
{/snippet}
<SettingsGroup title="Subsonic apps" hint="How third-party Subsonic apps sign in as you."> {#snippet side()}
<SettingsGroup title="Subsonic apps" hint="How third-party Subsonic apps sign in as you.">
<SettingRow label="API token" hint="For apps that sign in with an API key. Use this where you can."> <SettingRow label="API token" hint="For apps that sign in with an API key. Use this where you can.">
<button type="button" disabled={tokenSaving} onclick={onRegenerateToken} class={button}> <button type="button" disabled={tokenSaving} onclick={onRegenerateToken} class={button}>
{confirmRegen ? 'Click again to confirm' : tokenSaving ? 'Regenerating…' : 'Regenerate'} {confirmRegen ? 'Click again to confirm' : tokenSaving ? 'Regenerating…' : 'Regenerate'}
@@ -257,4 +261,6 @@
{#if subsonicPassword} {#if subsonicPassword}
{@render secret(subsonicPassword, 'Subsonic password')} {@render secret(subsonicPassword, 'Subsonic password')}
{/if} {/if}
</SettingsGroup> </SettingsGroup>
{/snippet}
</SettingsColumns>
@@ -1,8 +1,13 @@
<script lang="ts"> <script lang="ts">
import SettingsColumns from '#lib/components/settings/SettingsColumns.svelte';
import { pageTitle } from '#lib/branding.js'; import { pageTitle } from '#lib/branding.js';
import NotificationSettings from '#lib/components/NotificationSettings.svelte'; import NotificationSettings from '#lib/components/NotificationSettings.svelte';
</script> </script>
<svelte:head><title>{pageTitle('Settings · Notifications')}</title></svelte:head> <svelte:head><title>{pageTitle('Settings · Notifications')}</title></svelte:head>
<NotificationSettings /> <SettingsColumns>
{#snippet main()}
<NotificationSettings />
{/snippet}
</SettingsColumns>
+11 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import SettingsColumns from '#lib/components/settings/SettingsColumns.svelte';
import { pageTitle } from '#lib/branding.js'; import { pageTitle } from '#lib/branding.js';
import { player, setCrossfade } from '#lib/player/store.svelte.js'; import { player, setCrossfade } from '#lib/player/store.svelte.js';
import NormalizationSetting from '#lib/components/NormalizationSetting.svelte'; import NormalizationSetting from '#lib/components/NormalizationSetting.svelte';
@@ -8,7 +9,9 @@
<svelte:head><title>{pageTitle('Settings · Playback')}</title></svelte:head> <svelte:head><title>{pageTitle('Settings · Playback')}</title></svelte:head>
<SettingsGroup title="Playback"> <SettingsColumns>
{#snippet main()}
<SettingsGroup title="Between tracks">
<SettingRow <SettingRow
label="Crossfade" label="Crossfade"
hint="Fades one track into the next. Most albums sound best with it off." hint="Fades one track into the next. Most albums sound best with it off."
@@ -28,5 +31,11 @@
{player.crossfadeSec === 0 ? 'Off' : `${player.crossfadeSec}s`} {player.crossfadeSec === 0 ? 'Off' : `${player.crossfadeSec}s`}
</span> </span>
</SettingRow> </SettingRow>
</SettingsGroup>
{/snippet}
{#snippet side()}
<SettingsGroup title="Volume leveling" hint="Evens out loudness between tracks and albums.">
<NormalizationSetting /> <NormalizationSetting />
</SettingsGroup> </SettingsGroup>
{/snippet}
</SettingsColumns>