feat(web): improve desktop user settings layout (#615)

- Reorganize settings into task-focused groups with uniform cards
- Move search into the page header and refine desktop detail navigation
- Remove redundant category jump links
This commit is contained in:
Quick
2026-08-12 10:44:03 -04:00
committed by GitHub
parent 75936cdae2
commit e36612e619
5 changed files with 422 additions and 418 deletions
+13
View File
@@ -1714,6 +1714,19 @@
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.04);
}
/* Panel chrome only once there is a sidebar to enclose. Below this the
settings detail view is already a stack of surface-panel groups, and a
panel around them would nest a card in a card. */
@media (min-width: 1024px) {
.surface-panel-lg {
background: color-mix(in srgb, var(--surface) 90%, transparent);
border-radius: calc(var(--radius-2xl) + 0.35rem);
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 0.04),
0 24px 50px -34px rgb(0 0 0 / 0.65);
}
}
.caption-empty-state span {
display: block;
height: 0.35rem;
@@ -1,4 +1,3 @@
import { useState } from "react";
import { ChevronRight } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { Link } from "react-router";
@@ -35,14 +34,7 @@ export function SettingsOverviewNav({
idPrefix,
variant = "grouped-list",
}: SettingsOverviewNavProps) {
const firstGroupId = groups[0] ? groupSectionId(idPrefix, groups[0].label) : "";
const [activeGroupId, setActiveGroupId] = useState(firstGroupId);
const isDirectory = variant === "directory";
const resolvedActiveGroupId = groups.some(
(group) => groupSectionId(idPrefix, group.label) === activeGroupId,
)
? activeGroupId
: firstGroupId;
if (groups.length === 0) {
return (
@@ -55,136 +47,96 @@ export function SettingsOverviewNav({
);
}
// No category jump bar: it listed the same group names, with the same
// counts, directly above the headings that repeat them — and on an index
// this short it saved about one scroll flick.
return (
<div className={cn(isDirectory && "lg:space-y-8")}>
{isDirectory ? (
<nav
aria-label={`${ariaLabel} categories`}
className="surface-panel-subtle hidden max-w-2xl overflow-hidden rounded-xl border lg:flex"
>
{groups.map((group) => {
const sectionId = groupSectionId(idPrefix, group.label);
const isActive = sectionId === resolvedActiveGroupId;
<nav
aria-label={ariaLabel}
className={cn(
"grid items-start gap-6 lg:grid-cols-2",
isDirectory && "block space-y-6 lg:space-y-10",
)}
>
{groups.map((group) => {
const sectionId = groupSectionId(idPrefix, group.label);
return (
<a
key={group.label}
href={`#${sectionId}`}
aria-label={`${group.label}, ${group.items.length} settings sections`}
aria-current={isActive ? "location" : undefined}
onClick={() => setActiveGroupId(sectionId)}
className={cn(
"text-muted-foreground hover:bg-surface-hover/60 hover:text-foreground focus-visible:ring-ring/60 relative flex min-h-11 flex-1 items-center justify-center gap-2 px-4 text-sm font-medium transition-colors focus-visible:z-10 focus-visible:ring-[3px] focus-visible:outline-none focus-visible:ring-inset",
isActive &&
"text-foreground after:bg-primary after:absolute after:inset-x-4 after:bottom-0 after:h-0.5 after:rounded-full",
)}
>
<span>{group.label}</span>
<span className="text-muted-foreground text-xs tabular-nums">
{group.items.length}
</span>
</a>
);
})}
</nav>
) : null}
<nav
aria-label={ariaLabel}
className={cn(
"grid items-start gap-6 lg:grid-cols-2",
isDirectory && "block space-y-6 lg:space-y-10",
)}
>
{groups.map((group) => {
const sectionId = groupSectionId(idPrefix, group.label);
return (
<section
key={group.label}
aria-labelledby={sectionId}
className={cn(isDirectory && "scroll-mt-6")}
return (
<section key={group.label} aria-labelledby={sectionId}>
<h2
id={sectionId}
className={cn(
"text-muted-foreground mb-2 px-1 text-[11px] font-semibold tracking-[0.16em] uppercase",
isDirectory &&
"lg:text-foreground lg:mb-3 lg:px-0 lg:text-base lg:font-semibold lg:tracking-tight lg:normal-case",
)}
>
<h2
id={sectionId}
className={cn(
"text-muted-foreground mb-2 px-1 text-[11px] font-semibold tracking-[0.16em] uppercase",
isDirectory &&
"lg:text-foreground lg:mb-3 lg:px-0 lg:text-base lg:font-semibold lg:tracking-tight lg:normal-case",
)}
>
{group.label}
{isDirectory ? (
<span className="text-muted-foreground ml-1.5 hidden font-medium lg:inline">
({group.items.length})
</span>
) : null}
</h2>
<ul
className={cn(
"surface-panel overflow-hidden rounded-2xl border-0 shadow-none",
isDirectory &&
"lg:grid lg:grid-cols-2 lg:gap-3 lg:overflow-visible lg:rounded-none lg:bg-transparent 2xl:grid-cols-4",
)}
>
{group.items.map((item) => {
const Icon = item.icon;
{group.label}
</h2>
<ul
className={cn(
"surface-panel overflow-hidden rounded-2xl border-0 shadow-none",
isDirectory &&
"lg:grid lg:grid-cols-2 lg:gap-3 lg:overflow-visible lg:rounded-none lg:bg-transparent 2xl:grid-cols-4",
)}
>
{group.items.map((item) => {
const Icon = item.icon;
return (
<li
key={item.id}
return (
<li
key={item.id}
className={cn(
"border-border/60 border-b last:border-b-0",
isDirectory && "lg:border-0",
)}
>
<Link
to={item.href}
className={cn(
"border-border/60 border-b last:border-b-0",
isDirectory && "lg:border-0",
"hover:bg-surface-hover/70 focus-visible:bg-surface-hover/70 focus-visible:ring-ring/70 flex min-h-[4.5rem] items-center gap-3 px-4 py-3 transition-colors focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset",
isDirectory &&
"lg:bg-card/45 lg:border-border/70 lg:hover:border-ring/40 lg:h-28 lg:rounded-xl lg:border lg:px-4 lg:py-4 lg:shadow-sm lg:hover:-translate-y-0.5 lg:hover:shadow-md lg:focus-visible:-translate-y-0.5",
)}
>
<Link
to={item.href}
<span
className={cn(
"hover:bg-surface-hover/70 focus-visible:bg-surface-hover/70 focus-visible:ring-ring/70 flex min-h-[4.5rem] items-center gap-3 px-4 py-3 transition-colors focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset",
isDirectory &&
"lg:bg-card/45 lg:border-border/70 lg:hover:border-ring/40 lg:h-28 lg:rounded-xl lg:border lg:px-4 lg:py-4 lg:shadow-sm lg:hover:-translate-y-0.5 lg:hover:shadow-md lg:focus-visible:-translate-y-0.5",
"bg-accent text-foreground flex h-9 w-9 shrink-0 items-center justify-center rounded-xl",
isDirectory && "lg:h-10 lg:w-10",
)}
>
<Icon className="h-[18px] w-[18px]" aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
<span
className={cn(
"bg-accent text-foreground flex h-9 w-9 shrink-0 items-center justify-center rounded-xl",
isDirectory && "lg:h-10 lg:w-10",
"block text-sm font-semibold",
isDirectory && "lg:text-[15px]",
)}
>
<Icon className="h-[18px] w-[18px]" aria-hidden="true" />
{item.label}
</span>
<span className="min-w-0 flex-1">
<span
className={cn(
"block text-sm font-semibold",
isDirectory && "lg:text-[15px]",
)}
>
{item.label}
</span>
<span
className={cn(
"text-muted-foreground mt-0.5 block text-xs leading-snug",
isDirectory && "lg:line-clamp-3",
)}
>
{item.description}
</span>
<span
className={cn(
"text-muted-foreground mt-0.5 block text-xs leading-snug",
isDirectory && "lg:line-clamp-3",
)}
>
{item.description}
</span>
<ChevronRight
className="text-muted-foreground h-4 w-4 shrink-0"
aria-hidden="true"
/>
</Link>
</li>
);
})}
</ul>
</section>
);
})}
</nav>
</div>
</span>
<ChevronRight
className="text-muted-foreground h-4 w-4 shrink-0"
aria-hidden="true"
/>
</Link>
</li>
);
})}
</ul>
</section>
);
})}
</nav>
);
}
+50 -5
View File
@@ -49,16 +49,61 @@ describe("SettingsLayout", () => {
);
expect(screen.getByRole("heading", { name: "Settings" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Playback" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Appearance" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Library & Data" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Account" })).toBeInTheDocument();
for (const group of ["Playback", "Appearance", "Home & Discovery", "Connections", "Account"]) {
expect(screen.getByRole("heading", { name: group })).toBeInTheDocument();
}
expect(
screen.getByRole("link", { name: /Playback.*Quality, language, and skipping/ }),
screen.getByRole("link", { name: /Playback.*Quality, languages, skipping/ }),
).toHaveAttribute("href", "/settings/playback");
expect(screen.getByRole("link", { name: /Connect Apps.*Sign-in details/ })).toBeInTheDocument();
});
it("names each settings group exactly once", () => {
render(
<MemoryRouter initialEntries={["/settings"]}>
<SettingsLayout />
</MemoryRouter>,
);
// The category jump bar used to repeat every group name and count directly
// above the headings that already carry them.
expect(
screen.queryByRole("navigation", { name: "Settings sections categories" }),
).not.toBeInTheDocument();
for (const group of ["Playback", "Appearance", "Home & Discovery", "Connections", "Account"]) {
expect(screen.getAllByRole("heading", { name: group })).toHaveLength(1);
expect(
screen.queryByRole("link", { name: new RegExp(`^${group}, \\d+ settings`) }),
).toBeNull();
}
});
it("uses one desktop grid and card geometry for every settings group", () => {
const markup = renderToStaticMarkup(
<MemoryRouter initialEntries={["/settings"]}>
<SettingsLayout />
</MemoryRouter>,
);
// Five groups, seventeen sections — every card the same height so no group
// is visually ranked above another.
expect(markup.match(/2xl:grid-cols-4/g)).toHaveLength(5);
expect(markup.match(/lg:h-28/g)).toHaveLength(17);
expect(markup).not.toContain("max-w-5xl");
});
it("keeps each settings section in exactly one group", () => {
const markup = renderToStaticMarkup(
<MemoryRouter initialEntries={["/settings"]}>
<SettingsLayout />
</MemoryRouter>,
);
for (const path of ["devices", "libraries", "watch-providers", "profiles"]) {
expect(markup.match(new RegExp(`href="/settings/${path}"`, "g"))).toHaveLength(1);
}
});
it("offers a clear return to the settings index from detail pages", () => {
render(
<MemoryRouter initialEntries={["/settings/playback"]}>
+272 -257
View File
@@ -61,6 +61,13 @@ const settingIndex = (...labels: string[]) => labels.map((label) => ({ label }))
*/
const WIDE_SETTINGS_PAGES = new Set(["devices"]);
/**
* Grouped by the question a person arrives with, not by which service stores
* the value: "how does it play", "how does it look", "what do I see", "what is
* it wired to", "who am I". Devices sits under Playback because every setting
* on that screen is a playback override, and Personalize sits with Home Screen
* because both shape what the app puts in front of you.
*/
const NAV_SECTIONS: NavSection[] = [
{
label: "Playback",
@@ -69,7 +76,7 @@ const NAV_SECTIONS: NavSection[] = [
path: "playback",
label: "Playback",
icon: Play,
description: "Quality, language, and skipping",
description: "Quality, languages, skipping, and what plays next.",
keywords: [
"video quality",
"bitrate",
@@ -98,7 +105,7 @@ const NAV_SECTIONS: NavSection[] = [
path: "subtitle-appearance",
label: "Subtitles",
icon: Subtitles,
description: "Language, behavior, and style",
description: "Subtitle language, when they appear, and how they look.",
keywords: [
"subtitle language",
"forced subtitles",
@@ -124,215 +131,11 @@ const NAV_SECTIONS: NavSection[] = [
"Subtitle position",
),
},
],
},
{
label: "Appearance",
items: [
{
path: "appearance",
label: "Appearance",
icon: Palette,
description: "Theme and interface tone",
keywords: [
"theme",
"profile theme",
"dark",
"light",
"custom theme",
"date format",
"time format",
"clock",
"24-hour",
"12-hour",
],
settings: settingIndex(
"Theme",
"Date & time",
"Date format",
"Time format",
"Current selection",
"Reset to Cinema Dark",
),
},
{
path: "interface",
label: "Navigation & Cards",
icon: PanelTop,
description: "Menus, poster size, and captions",
keywords: [
"navigation",
"menu",
"pin library",
"poster size",
"card size",
"hide title",
"hide year",
"artwork only",
"preset",
],
settings: settingIndex(
"Card preset",
"Poster size",
"Caption",
"Title & metadata",
"Title only",
"Artwork only",
"Primary menu",
"Choose destination or shortcut",
"Add to menu",
"Reset to default",
),
},
{
path: "theme-editor",
label: "Theme Editor",
icon: Wand2,
description: "Customize colors and CSS",
keywords: ["design tokens", "token overrides", "custom css", "community themes"],
settings: settingIndex("Preview", "Token Overrides", "Custom CSS", "Community Themes"),
},
{
path: "accessibility",
label: "Accessibility",
icon: Eye,
description: "Readability and contrast",
keywords: ["contrast", "readability", "motion", "transparency", "text"],
settings: settingIndex("Text size", "Text weight", "Contrast", "High Contrast", "Preview"),
},
{
path: "home-screen",
label: "Home Screen",
icon: LayoutDashboard,
description: "Sections and layout",
keywords: ["sections", "rows", "continue watching", "next up", "library order"],
settings: settingIndex(
"Scope",
"Sections",
"Reset section customizations",
"Continue Watching",
"Next Up",
"Recently Added",
"Library order",
),
},
{
path: "card-overlays",
label: "Card Overlays",
icon: Layers,
description: "Badges on poster cards",
keywords: ["poster", "badges", "overlay", "accent color", "preset"],
settings: settingIndex(
"Preview",
"Preset",
"Accent color",
"Show icon",
"Position",
"How styling works",
),
},
{
path: "personalize",
label: "Personalize",
icon: Sparkles,
description: "Re-tune your taste profile",
keywords: ["taste profile", "recommendations", "ratings", "likes", "dislikes"],
settings: settingIndex("Refine your taste profile", "Taste profile", "Recommendations"),
},
],
},
{
label: "Library & Data",
items: [
{
path: "libraries",
label: "Libraries",
icon: Library,
description: "Visibility and access",
keywords: [
"library visibility",
"access",
"disabled libraries",
"library order",
"playback preferences",
],
settings: settingIndex(
"Remember library pages",
"Library visibility",
"Library order",
"Spoken language",
"Subtitle language",
"Subtitle behavior",
"Forced subtitles",
"Playback preferences",
),
},
{
path: "history-import",
label: "History Import",
icon: Clock,
description: "Emby watch history",
keywords: ["emby", "watched history", "import", "mapping", "sync"],
settings: settingIndex(
"New import",
"Import history",
"Fetched",
"Matched",
"Unmatched",
"Progress",
"History",
"Skipped",
),
},
{
path: "webhook-sync",
label: "Webhook Sync",
icon: Server,
description: "Plex, Emby, and Jellyfin webhook intake",
keywords: ["plex", "emby", "jellyfin", "webhook", "progress", "watched"],
settings: settingIndex(
"Add a connection",
"Connected servers",
"Recent deliveries",
"Plex",
"Emby",
"Jellyfin",
"Server URL",
"Token",
),
},
{
path: "watch-providers",
label: "Watch Providers",
icon: Cloud,
description: "Trakt watch history and scrobbling",
keywords: ["trakt", "import", "export", "scrobble", "favorites", "watch history"],
settings: settingIndex(
"Last imported",
"Last exported",
"Watched",
"Progress",
"Favorites",
"Exported",
"Import watched history",
"Import paused progress",
"Send watched changes",
"Send unwatched changes",
"Sync favorites",
"Sync favorite removals",
"Scrobble playback",
),
},
],
},
{
label: "Account",
items: [
{
path: "devices",
label: "Your Devices",
icon: MonitorSmartphone,
description: "Settings for each device you watch on",
description: "Per-device quality, HDR, and audio or subtitle sync.",
keywords: [
"devices",
"tv",
@@ -359,28 +162,159 @@ const NAV_SECTIONS: NavSection[] = [
"Forget this device",
),
},
],
},
{
label: "Appearance",
items: [
{
path: "notifications",
label: "Notifications",
icon: Bell,
description: "New-episode alerts and webhooks",
keywords: ["new episodes", "email", "discord", "browser push", "webhooks"],
path: "appearance",
label: "Appearance",
icon: Palette,
description: "Theme, interface tone, and date and time formats.",
keywords: [
"theme",
"profile theme",
"dark",
"light",
"custom theme",
"date format",
"time format",
"clock",
"24-hour",
"12-hour",
],
settings: settingIndex(
"New Episode Notifications",
"Email Notifications",
"Discord Notifications",
"Browser Notifications",
"Webhooks",
"Per-episode alerts",
"Digest",
"Webhook URL",
"Theme",
"Date & time",
"Date format",
"Time format",
"Current selection",
"Reset to Cinema Dark",
),
},
{
path: "interface",
label: "Navigation & Cards",
icon: PanelTop,
description: "Your primary menu, poster size, and card captions.",
keywords: [
"navigation",
"menu",
"pin library",
"poster size",
"card size",
"hide title",
"hide year",
"artwork only",
"preset",
],
settings: settingIndex(
"Card preset",
"Poster size",
"Caption",
"Title & metadata",
"Title only",
"Artwork only",
"Primary menu",
"Choose destination or shortcut",
"Add to menu",
"Reset to default",
),
},
{
path: "card-overlays",
label: "Card Overlays",
icon: Layers,
description: "Badges drawn on poster cards, and where they sit.",
keywords: ["poster", "badges", "overlay", "accent color", "preset"],
settings: settingIndex(
"Preview",
"Preset",
"Accent color",
"Show icon",
"Position",
"How styling works",
),
},
{
path: "accessibility",
label: "Accessibility",
icon: Eye,
description: "Text size, weight, and contrast for easier reading.",
keywords: ["contrast", "readability", "motion", "transparency", "text"],
settings: settingIndex("Text size", "Text weight", "Contrast", "High Contrast", "Preview"),
},
{
path: "theme-editor",
label: "Theme Editor",
icon: Wand2,
description: "Fine-tune theme colors and add your own CSS.",
keywords: ["design tokens", "token overrides", "custom css", "community themes"],
settings: settingIndex("Preview", "Token Overrides", "Custom CSS", "Community Themes"),
},
],
},
{
label: "Home & Discovery",
items: [
{
path: "home-screen",
label: "Home Screen",
icon: LayoutDashboard,
description: "Which rows appear on Home, and in what order.",
keywords: ["sections", "rows", "continue watching", "next up", "library order"],
settings: settingIndex(
"Scope",
"Sections",
"Reset section customizations",
"Continue Watching",
"Next Up",
"Recently Added",
"Library order",
),
},
{
path: "personalize",
label: "Personalize",
icon: Sparkles,
description: "Re-tune the taste profile behind your recommendations.",
keywords: ["taste profile", "recommendations", "ratings", "likes", "dislikes"],
settings: settingIndex("Refine your taste profile", "Taste profile", "Recommendations"),
},
{
path: "libraries",
label: "Libraries",
icon: Library,
description: "Which libraries you see, their order, and per-library audio.",
keywords: [
"library visibility",
"access",
"disabled libraries",
"library order",
"playback preferences",
],
settings: settingIndex(
"Remember library pages",
"Library visibility",
"Library order",
"Spoken language",
"Subtitle language",
"Subtitle behavior",
"Forced subtitles",
"Playback preferences",
),
},
],
},
{
label: "Connections",
items: [
{
path: "connect-apps",
label: "Connect Apps",
icon: Cast,
description: "Sign-in details for other apps",
description: "Sign-in details for Silo and Jellyfin-compatible apps.",
keywords: [
"jellyfin",
"infuse",
@@ -405,11 +339,72 @@ const NAV_SECTIONS: NavSection[] = [
"Every profile at a glance",
),
},
{
path: "watch-providers",
label: "Watch Providers",
icon: Cloud,
description: "Trakt watch history, favorites, and scrobbling.",
keywords: ["trakt", "import", "export", "scrobble", "favorites", "watch history"],
settings: settingIndex(
"Last imported",
"Last exported",
"Watched",
"Progress",
"Favorites",
"Exported",
"Import watched history",
"Import paused progress",
"Send watched changes",
"Send unwatched changes",
"Sync favorites",
"Sync favorite removals",
"Scrobble playback",
),
},
{
path: "webhook-sync",
label: "Webhook Sync",
icon: Server,
description: "Take progress from Plex, Emby, and Jellyfin webhooks.",
keywords: ["plex", "emby", "jellyfin", "webhook", "progress", "watched"],
settings: settingIndex(
"Add a connection",
"Connected servers",
"Recent deliveries",
"Plex",
"Emby",
"Jellyfin",
"Server URL",
"Token",
),
},
{
path: "history-import",
label: "History Import",
icon: Clock,
description: "Bring an existing Emby watch history into Silo.",
keywords: ["emby", "watched history", "import", "mapping", "sync"],
settings: settingIndex(
"New import",
"Import history",
"Fetched",
"Matched",
"Unmatched",
"Progress",
"History",
"Skipped",
),
},
],
},
{
label: "Account",
items: [
{
path: "profiles",
label: "Profiles",
icon: Users,
description: "Names, PINs, and access rules",
description: "Household profile names, PINs, and library access.",
keywords: ["profile name", "pin", "access", "primary profile", "household"],
settings: settingIndex(
"Profile name",
@@ -421,6 +416,23 @@ const NAV_SECTIONS: NavSection[] = [
),
primaryOrAdmin: true,
},
{
path: "notifications",
label: "Notifications",
icon: Bell,
description: "New-episode alerts by email, Discord, push, or webhook.",
keywords: ["new episodes", "email", "discord", "browser push", "webhooks"],
settings: settingIndex(
"New Episode Notifications",
"Email Notifications",
"Discord Notifications",
"Browser Notifications",
"Webhooks",
"Per-episode alerts",
"Digest",
"Webhook URL",
),
},
],
},
];
@@ -428,24 +440,21 @@ const NAV_SECTIONS: NavSection[] = [
interface SettingsOverviewProps {
sections: readonly { label: string; items: readonly NavItem[] }[];
profile: { name: string; avatar_url?: string } | null;
search: string;
onSearchChange: (value: string) => void;
resultCount: number;
totalCount: number;
}
function SettingsOverview({
sections,
profile,
search,
onSearchChange,
resultCount,
totalCount,
}: SettingsOverviewProps) {
/**
* The index is the same directory the admin settings index uses: category jump
* links, then one uniform card grid per group across the full page width.
*
* The two-column list it replaced paired groups side by side, so a short group
* next to a long one left a column of dead space taller than the short group
* itself — the exact shape of the desktop complaint.
*/
function SettingsOverview({ sections, profile }: SettingsOverviewProps) {
const profileName = profile?.name ?? "Your profile";
return (
<div className="mx-auto mt-6 w-full max-w-5xl space-y-6 sm:mt-8">
<div className="w-full space-y-6">
<Link
to="/profiles"
aria-label={`Current profile: ${profileName}`}
@@ -464,14 +473,6 @@ function SettingsOverview({
<ChevronRight className="text-muted-foreground h-4 w-4 shrink-0" aria-hidden="true" />
</Link>
<SettingsSearchInput
value={search}
onChange={onSearchChange}
resultCount={resultCount}
totalCount={totalCount}
className="w-full sm:max-w-md"
/>
<SettingsOverviewNav
groups={sections.map((section) => ({
...section,
@@ -485,6 +486,7 @@ function SettingsOverview({
}))}
ariaLabel="Settings sections"
idPrefix="settings-index"
variant="directory"
/>
</div>
);
@@ -555,9 +557,21 @@ export default function SettingsLayout() {
/>
</div>
<div className="mt-5 min-w-0 flex-1 lg:mt-10 lg:flex lg:gap-10">
<aside className="hidden lg:block lg:w-[220px] lg:shrink-0">
<nav aria-label="Settings sections" className="sticky top-6 space-y-5 pl-3">
{/* From lg up, the rail and the active page share one panel, the
same shell the admin settings use. Below lg the rail is hidden
and the page is already a stack of its own panels, so the extra
chrome would only nest a card inside a card — mobile keeps the
bare layout.
Admin scrolls its detail pane inside the panel; here the page
scrolls and the rail sticks, because Your Devices owns a
viewport-height scroller that a nested one would strand. */}
<div className="surface-panel-lg mt-5 flex min-w-0 flex-1 flex-col lg:mt-10 lg:min-h-[500px] lg:flex-row lg:overflow-hidden">
<aside className="border-border hidden lg:block lg:w-60 lg:flex-shrink-0 lg:border-r">
<nav
aria-label="Settings sections"
className="sticky top-6 space-y-5 py-5 pr-3 pl-5"
>
{filteredSections.map((section) => (
<SideNavSection
key={section.label}
@@ -581,8 +595,8 @@ export default function SettingsLayout() {
</nav>
</aside>
<div className="min-w-0 flex-1 pt-8 lg:pt-0">
<div className={cn("mx-auto w-full", wideSetting ? "max-w-6xl" : "max-w-3xl")}>
<div className="min-w-0 flex-1 p-4 sm:p-6">
<div className={cn("w-full", wideSetting ? "max-w-none" : "max-w-3xl")}>
<Outlet />
</div>
</div>
@@ -591,22 +605,23 @@ export default function SettingsLayout() {
) : (
<>
<PageBack to="/" preferHistory={false} floating />
<div className="page-header mt-10 gap-5 sm:mt-12">
<div className="page-header mt-10 mb-6 gap-5 sm:mt-12 sm:mb-8">
<div className="min-w-0 space-y-3">
<h1 className="page-title text-[clamp(2rem,4vw,3rem)]">Settings</h1>
<p className="page-subtitle text-sm sm:text-base">
Make Silo work the way you like.
</p>
</div>
<SettingsSearchInput
value={settingsSearch}
onChange={setSettingsSearch}
resultCount={filteredSettingsCount}
totalCount={flatItems.length}
className="w-full sm:max-w-sm lg:w-[26rem] lg:max-w-none"
showShortcutHint
/>
</div>
<SettingsOverview
sections={filteredSections}
profile={profile}
search={settingsSearch}
onSearchChange={setSettingsSearch}
resultCount={filteredSettingsCount}
totalCount={flatItems.length}
/>
<SettingsOverview sections={filteredSections} profile={profile} />
</>
)}
</main>
@@ -69,28 +69,20 @@ describe("AdminSettingsLayout", () => {
}
});
it("renders desktop category jump links with section counts", () => {
it("names each settings group exactly once", () => {
renderInteractiveLayout();
const categoryNavigation = screen.getByRole("navigation", {
name: "Admin settings sections categories",
});
expect(categoryNavigation).toContainElement(
screen.getByRole("link", { name: "Server, 4 settings sections" }),
);
expect(screen.getByRole("link", { name: "Media, 7 settings sections" })).toHaveAttribute(
"href",
"#admin-settings-index-media",
);
expect(screen.getByRole("link", { name: "Connections, 6 settings sections" })).toHaveAttribute(
"href",
"#admin-settings-index-connections",
);
expect(screen.getByRole("link", { name: "Data, 3 settings sections" })).toHaveAttribute(
"href",
"#admin-settings-index-data",
);
// The category jump bar used to repeat every group name and count directly
// above the headings that already carry them.
expect(
screen.queryByRole("navigation", { name: "Admin settings sections categories" }),
).not.toBeInTheDocument();
for (const group of ["Server", "Media", "Connections", "Data"]) {
expect(screen.getAllByRole("heading", { name: group })).toHaveLength(1);
expect(
screen.queryByRole("link", { name: new RegExp(`^${group}, \\d+ settings`) }),
).toBeNull();
}
});
it("uses one desktop grid and card geometry for every settings group", () => {
@@ -102,19 +94,6 @@ describe("AdminSettingsLayout", () => {
expect(markup.match(/lg:line-clamp-3/g)).toHaveLength(20);
});
it("marks a selected category jump link as the current location", async () => {
renderInteractiveLayout();
const server = screen.getByRole("link", { name: "Server, 4 settings sections" });
const data = screen.getByRole("link", { name: "Data, 3 settings sections" });
expect(server).toHaveAttribute("aria-current", "location");
await userEvent.click(data);
expect(server).not.toHaveAttribute("aria-current");
expect(data).toHaveAttribute("aria-current", "location");
});
it("renders every settings tab", () => {
const markup = renderLayout();